47 changed files with 6464 additions and 4284 deletions
+2 -1
View File
@@ -65,7 +65,8 @@ import {
type DesignProfileSummary, type DesignProfileSummary,
type DesignProfileStatus, type DesignProfileStatus,
} from "./applicationManifest.js"; } from "./applicationManifest.js";
import { createDefaultMapPageLayout, MapFixturePreview, type MapFixturePreviewHandle, type MapPageLayout } from "./MapFixturePreview.js"; import { MapFixturePreview } from "./MapFixturePreview.js";
import { createDefaultMapPageLayout, type MapFixturePreviewHandle, type MapPageLayout } from "./mapPageContract.js";
import { import {
mapDesignFragmentForLayout, mapDesignFragmentForLayout,
mapDesignFragmentFromLayout, mapDesignFragmentFromLayout,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,212 @@
import type { RefObject } from "react";
import { Button, Checker, Icon, WorkspaceWindow } from "@nodedc/ui-react";
import type { WorkspaceWindowRect } from "@nodedc/ui-react";
import type { GridSectorSelection } from "./mapRendererContract.js";
import {
MAP_SCOPE_MISSING_VALUE,
MAP_SCOPE_OBJECT_KIND_FIELD,
MAP_SCOPE_PROVIDER_FIELD,
formatGridSectorArea,
gridSectorBoundsLabel,
normalizedSectorScopeValue,
} from "./mapSectorWorkspace.js";
import type { MapSelectableEntity } from "./mapWorkspaceModel.mjs";
type ScopeOption = { value: string; label: string; count: number };
export function MapSectorWorkspaceWindow({
boundsRef,
selection,
rect,
maximized,
active,
zIndex,
copyState,
spatialEntities,
visibleEntities,
bindingOptions,
providerOptions,
objectKindOptions,
excludedBindingIds,
excludedProviders,
excludedObjectKinds,
hideOutside,
selectedEntityId,
onRectChange,
onMaximizedChange,
onActivate,
onDeactivate,
onCopyStableId,
onHideOutsideChange,
onBindingEnabledChange,
onProviderEnabledChange,
onObjectKindEnabledChange,
onSelectEntity,
}: {
boundsRef: RefObject<HTMLElement | null>;
selection: GridSectorSelection;
rect: WorkspaceWindowRect;
maximized: boolean;
active: boolean;
zIndex: number;
copyState: "idle" | "copied" | "error";
spatialEntities: MapSelectableEntity[];
visibleEntities: MapSelectableEntity[];
bindingOptions: ScopeOption[];
providerOptions: ScopeOption[];
objectKindOptions: ScopeOption[];
excludedBindingIds: string[];
excludedProviders: string[];
excludedObjectKinds: string[];
hideOutside: boolean;
selectedEntityId?: string;
onRectChange: (rect: WorkspaceWindowRect) => void;
onMaximizedChange: (value: boolean) => void;
onActivate: () => void;
onDeactivate: () => void;
onCopyStableId: () => void;
onHideOutsideChange: (value: boolean) => void;
onBindingEnabledChange: (bindingId: string, enabled: boolean) => void;
onProviderEnabledChange: (provider: string, enabled: boolean) => void;
onObjectKindEnabledChange: (objectKind: string, enabled: boolean) => void;
onSelectEntity: (entityId: string) => void;
}) {
return (
<WorkspaceWindow
boundsRef={boundsRef}
rect={rect}
onRectChange={onRectChange}
maximized={maximized}
onMaximizedChange={onMaximizedChange}
onActivate={onActivate}
onClose={onDeactivate}
title={`Активный сектор · LOD ${selection.lod}`}
subtitle={selection.label}
status={`${visibleEntities.length} / ${spatialEntities.length}`}
active={active}
zIndex={zIndex}
minWidth={340}
minHeight={380}
footer={(
<Button variant="secondary" size="compact" width="full" onClick={onDeactivate}>
Деактивировать сектор
</Button>
)}
className="catalog-map-fixture__sector-window catalog-map-fixture__map-glass-window"
aria-label={`Активный сектор: ${selection.id}`}
>
<div className="catalog-map-sector-window">
<section className="catalog-map-sector-window__summary" aria-label="Параметры сектора">
<code title={selection.id}>{selection.id}</code>
<span>{gridSectorBoundsLabel(selection)}</span>
<span>{formatGridSectorArea(selection.areaSquareMeters)}</span>
<Button
variant="secondary"
size="compact"
width="full"
icon={<Icon name={copyState === "copied" ? "check" : "copy"} />}
onClick={onCopyStableId}
>{copyState === "copied" ? "ID скопирован" : "Копировать stable ID"}</Button>
</section>
<Checker checked={hideOutside} label="Скрыть объекты за сектором" onChange={onHideOutsideChange} />
<ScopeFilterSection
id="map-sector-domains"
title="Домены данных"
options={bindingOptions}
excluded={excludedBindingIds}
onEnabledChange={onBindingEnabledChange}
/>
{providerOptions.length ? (
<ScopeFilterSection
id="map-sector-providers"
title="Провайдеры"
options={providerOptions}
excluded={excludedProviders}
exposeRawValue
onEnabledChange={onProviderEnabledChange}
/>
) : null}
{objectKindOptions.length ? (
<ScopeFilterSection
id="map-sector-kinds"
title="Типы объектов"
options={objectKindOptions}
excluded={excludedObjectKinds}
exposeRawValue
onEnabledChange={onObjectKindEnabledChange}
/>
) : null}
<section className="catalog-map-sector-window__objects" aria-labelledby="map-sector-objects-title">
<div className="catalog-map-sector-window__section-title">
<strong id="map-sector-objects-title">Объекты сектора</strong>
<small>{visibleEntities.length} / {spatialEntities.length}</small>
</div>
<div className="catalog-map-sector-window__object-list">
{visibleEntities.map((entity) => {
const provider = normalizedSectorScopeValue(entity.fact.attributes[MAP_SCOPE_PROVIDER_FIELD]);
const objectKind = normalizedSectorScopeValue(entity.fact.attributes[MAP_SCOPE_OBJECT_KIND_FIELD]);
return (
<button
type="button"
key={entity.id}
data-selected={entity.id === selectedEntityId || undefined}
onClick={() => onSelectEntity(entity.id)}
>
<span>{entity.title}</span>
<code>{entity.fact.sourceId}</code>
<small>{[provider, objectKind, entity.status].filter(Boolean).join(" · ")}</small>
</button>
);
})}
{!visibleEntities.length ? (
<small className="catalog-map-sector-window__empty">
{spatialEntities.length
? "Объекты скрыты текущими фильтрами."
: "В секторе нет точечных объектов подключённых Data Products."}
</small>
) : null}
</div>
</section>
</div>
</WorkspaceWindow>
);
}
function ScopeFilterSection({
id,
title,
options,
excluded,
exposeRawValue = false,
onEnabledChange,
}: {
id: string;
title: string;
options: ScopeOption[];
excluded: string[];
exposeRawValue?: boolean;
onEnabledChange: (value: string, enabled: boolean) => void;
}) {
return (
<section className="catalog-map-sector-window__filters" aria-labelledby={`${id}-title`}>
<div className="catalog-map-sector-window__section-title">
<strong id={`${id}-title`}>{title}</strong>
<small>{options.length}</small>
</div>
{options.map((option) => (
<Checker
key={option.value}
checked={!excluded.includes(option.value)}
label={`${option.label} · ${option.count}`}
title={exposeRawValue && option.value !== MAP_SCOPE_MISSING_VALUE ? option.value : undefined}
onChange={(enabled) => onEnabledChange(option.value, enabled)}
/>
))}
</section>
);
}
@@ -0,0 +1,238 @@
import type { Dispatch, RefObject } from "react";
import { Icon, SegmentedControl, WorkspaceWindow } from "@nodedc/ui-react";
import {
mapFactMatchesFilters,
mapPresentationFacetValueIsEnabled,
} from "./mapPresentationProfile.mjs";
import type { MapSubjectCardModel } from "./mapSubjectCard.mjs";
import {
mapProfileHasSubjectWindowControls,
type MapFilteredTarget,
type MapPresentationSummary,
type MapSelectableEntity,
} from "./mapWorkspaceModel.mjs";
import type {
MapWorkspaceAction,
MapWorkspaceState,
} from "./mapWorkspaceState.mjs";
export function MapSubjectWorkspaceWindows({
boundsRef,
workspaceState,
dispatch,
summaries,
filteredTargets,
selectable,
expandedFacetRows,
selectedSubjectCard,
onToggleFacetRow,
onTogglePresentationFilter,
onSelectEntity,
}: {
boundsRef: RefObject<HTMLElement | null>;
workspaceState: MapWorkspaceState;
dispatch: Dispatch<MapWorkspaceAction>;
summaries: MapPresentationSummary[];
filteredTargets: MapFilteredTarget[];
selectable: MapSelectableEntity[];
expandedFacetRows: Record<string, boolean>;
selectedSubjectCard: MapSubjectCardModel | null;
onToggleFacetRow: (rowId: string) => void;
onTogglePresentationFilter: (bindingId: string, field: string, value: string, availableValues: string[]) => void;
onSelectEntity: (entityId: string) => void;
}) {
const selectedEntityId = workspaceState.selectedEntityId;
return (
<>
{summaries.map((summary) => {
const state = workspaceState.subjectStates[summary.bindingId];
if (!state?.window.open || !mapProfileHasSubjectWindowControls(summary.profile)) return null;
const visibleCount = filteredTargets.filter((target) => target.bindingId === summary.bindingId && target.renderable).length;
return (
<WorkspaceWindow
key={summary.bindingId}
boundsRef={boundsRef}
rect={state.window.rect}
onRectChange={(rect) => dispatch({
type: "set-subject-window-rect",
bindingId: summary.bindingId,
rect,
})}
maximized={state.window.maximized}
onMaximizedChange={(value) => dispatch({
type: "set-subject-window-maximized",
bindingId: summary.bindingId,
value,
})}
onActivate={() => dispatch({ type: "activate-window", windowId: `binding:${summary.bindingId}` })}
onClose={() => dispatch({ type: "close-subject-window", bindingId: summary.bindingId })}
title={summary.displayName}
subtitle={`${summary.total} всего · ${visibleCount} на карте`}
active={workspaceState.activeWindowId === `binding:${summary.bindingId}`}
zIndex={state.window.zIndex}
minWidth={240}
minHeight={220}
autoHeight
className="catalog-map-fixture__subject-window catalog-map-fixture__map-glass-window"
>
<div className="catalog-map-fixture__target-filters">
<section aria-label={`${summary.displayName}: фильтры и счётчики`}>
<div className="catalog-map-fixture__target-filter-list">
{summary.profile.facets.filter((facet) => facet.counter || facet.filterable).flatMap((facet) => (
facet.values.map((item) => {
const active = mapPresentationFacetValueIsEnabled(state.filters, facet.field, item.value);
const rowId = `${summary.bindingId}:${facet.field}:${item.value}`;
const expanded = Boolean(expandedFacetRows[rowId]);
const matchingEntities = selectable.filter((entity) => (
entity.bindingId === summary.bindingId
&& mapFactMatchesFilters(
entity.fact,
summary.profile,
{
[summary.bindingId]: {
visible: true,
facets: { [facet.field]: [item.value] },
},
},
summary.bindingId,
)
));
return (
<div className="catalog-map-fixture__target-filter-branch" key={`${facet.field}:${item.value}`}>
<div className="catalog-map-fixture__target-filter-row" data-active={active || undefined}>
<button
type="button"
className="catalog-map-fixture__target-filter-body"
aria-pressed={active}
disabled={!facet.filterable}
onClick={() => onTogglePresentationFilter(
summary.bindingId,
facet.field,
item.value,
facet.values.map((value) => value.value),
)}
>
<span className="catalog-map-fixture__target-filter-label">{item.label}</span>
<span className="catalog-map-fixture__target-filter-count">{summary.counts[facet.field]?.[item.value] ?? 0}</span>
</button>
<button
type="button"
className="catalog-map-fixture__target-filter-expander"
aria-label={`${expanded ? "Свернуть" : "Развернуть"} ${item.label}`}
aria-expanded={expanded}
onClick={() => onToggleFacetRow(rowId)}
>
<Icon name="chevron-right" size={14} />
</button>
</div>
{expanded ? (
<div className="catalog-map-fixture__target-filter-children">
{matchingEntities.map((entity) => (
<button
type="button"
className="catalog-map-fixture__target-filter-entity"
key={entity.id}
data-selected={entity.id === selectedEntityId || undefined}
onClick={() => onSelectEntity(entity.id)}
>
<span>{entity.title}</span>
{entity.status ? <small>{entity.status}</small> : null}
</button>
))}
{!matchingEntities.length ? <small>Нет объектов в группе.</small> : null}
</div>
) : null}
</div>
);
})
))}
</div>
</section>
</div>
</WorkspaceWindow>
);
})}
{workspaceState.subjectCard.open && selectedSubjectCard ? (
<WorkspaceWindow
boundsRef={boundsRef}
rect={workspaceState.subjectCard.rect}
onRectChange={(rect) => dispatch({ type: "set-subject-card-rect", rect })}
maximized={workspaceState.subjectCard.maximized}
onMaximizedChange={(value) => dispatch({ type: "set-subject-card-maximized", value })}
onActivate={() => dispatch({ type: "activate-window", windowId: "subject-card" })}
onClose={() => dispatch({ type: "close-subject-card" })}
title={selectedSubjectCard.title}
subtitle={selectedSubjectCard.sourceId}
active={workspaceState.activeWindowId === "subject-card"}
zIndex={workspaceState.subjectCard.zIndex}
minWidth={320}
minHeight={320}
className="catalog-map-fixture__subject-card catalog-map-fixture__map-glass-window"
aria-label={`Карточка объекта: ${selectedSubjectCard.title}`}
>
<MapSubjectCard
model={selectedSubjectCard}
tabId={workspaceState.subjectCard.tabId}
onTabChange={(tabId) => dispatch({ type: "set-subject-card-tab", tabId })}
/>
</WorkspaceWindow>
) : null}
</>
);
}
function MapSubjectCard({
model,
tabId,
onTabChange,
}: {
model: MapSubjectCardModel;
tabId: string;
onTabChange: (tabId: string) => void;
}) {
const activeTabId = model.tabs.some((tab) => tab.id === tabId) ? tabId : model.defaultTabId;
return (
<div className="catalog-map-subject-card">
<div className="catalog-map-subject-card__tabs">
<SegmentedControl
value={activeTabId}
items={model.tabs.map((tab) => ({ value: tab.id, label: tab.label }))}
label="Разделы карточки объекта"
onChange={onTabChange}
/>
</div>
{model.tabs.filter((tab) => tab.id === activeTabId).map((tab) => (
<div key={tab.id} className="catalog-map-subject-card__tab-panel" role="tabpanel">
{tab.empty ? <div className="catalog-map-subject-card__empty">{tab.emptyMessage}</div> : null}
{tab.sections.map((section) => (
<section key={section.id} className="catalog-map-subject-card__section" aria-labelledby={`subject-card-${tab.id}-${section.id}`}>
<h3 id={`subject-card-${tab.id}-${section.id}`}>{section.label}</h3>
{section.rows.length ? (
<dl>
{section.rows.map((row) => (
<div key={row.key} className="catalog-map-subject-card__row">
<dt>{row.label}</dt>
<dd>{row.value}</dd>
</div>
))}
</dl>
) : null}
{section.readings.length ? (
<div className="catalog-map-subject-card__readings">
{section.readings.map((reading) => (
<div className="catalog-map-subject-card__reading" key={reading.id}>
<span>{reading.label}</span>
<strong>{reading.value}</strong>
{reading.observedAt ? <time dateTime={reading.observedAt}>{new Date(reading.observedAt).toLocaleString("ru-RU")}</time> : null}
</div>
))}
</div>
) : null}
</section>
))}
</div>
))}
</div>
);
}
+377
View File
@@ -0,0 +1,377 @@
import type { KeyboardEvent, RefObject } from "react";
import { Checker, Dropdown, Icon, IconButton } from "@nodedc/ui-react";
import type { MapPageSettings, MapSubjectState } from "./mapPageContract.js";
import type { MapProviderStatus } from "./mapRendererContract.js";
import type { MapSearchDocument } from "./mapSearch.mjs";
import {
mapProfileHasSubjectWindowControls,
type MapFilteredTarget,
type MapPresentationSummary,
} from "./mapWorkspaceModel.mjs";
type ReferenceObjectSummary = {
layer: { id: string; visible: boolean };
displayName: string;
total: number;
};
const providerStateLabel: Record<MapProviderStatus["imagery"], string> = {
loading: "загружается",
ready: "готов",
error: "недоступен",
"not-configured": "не настроен",
};
export function MapWorkspaceToolbar({
searchOpen,
searchQuery,
searchInputRef,
searchResults,
searchActiveIndex,
remoteSearchQuery,
referenceSearchState,
summaries,
referenceSummaries,
subjectStates,
filteredTargets,
providerStatus,
mapSettings,
liveCacheSummary,
transportDiagnostic,
gatewayHealthAge,
gatewayCheckState,
gatewayCheckError,
onOpenSubjectWindow,
onToggleSubjectVisibility,
onToggleReferenceLayer,
onLayersOpenChange,
onTerrainChange,
onBuildingsVisibleChange,
onGridVisibleChange,
onCacheEnabledChange,
onCacheNoOverwriteChange,
onFitVisibleTargets,
onToggleSearch,
onSearchQueryChange,
onSearchKeyDown,
onSearchActiveIndexChange,
onSearchResult,
}: {
searchOpen: boolean;
searchQuery: string;
searchInputRef: RefObject<HTMLInputElement | null>;
searchResults: MapSearchDocument[];
searchActiveIndex: number;
remoteSearchQuery: string;
referenceSearchState: "idle" | "loading" | "ready" | "error";
summaries: MapPresentationSummary[];
referenceSummaries: ReferenceObjectSummary[];
subjectStates: Record<string, MapSubjectState>;
filteredTargets: MapFilteredTarget[];
providerStatus: MapProviderStatus;
mapSettings: MapPageSettings;
liveCacheSummary: string;
transportDiagnostic: string | null;
gatewayHealthAge: string | null;
gatewayCheckState: "idle" | "checking" | "ready" | "stale" | "error";
gatewayCheckError: string | null;
onOpenSubjectWindow: (bindingId: string) => void;
onToggleSubjectVisibility: (bindingId: string) => void;
onToggleReferenceLayer: (layerId: string) => void;
onLayersOpenChange: (open: boolean) => void;
onTerrainChange: (value: boolean) => void;
onBuildingsVisibleChange: (value: boolean) => void;
onGridVisibleChange: (value: boolean) => void;
onCacheEnabledChange: (value: boolean) => void;
onCacheNoOverwriteChange: (value: boolean) => void;
onFitVisibleTargets: () => void;
onToggleSearch: () => void;
onSearchQueryChange: (value: string) => void;
onSearchKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void;
onSearchActiveIndexChange: (index: number) => void;
onSearchResult: (result: MapSearchDocument) => void;
}) {
const objectLayerCount = summaries.length + referenceSummaries.length;
return (
<div className="catalog-map-fixture__toolbar" aria-label="Map toolbar" data-search-open={searchOpen || undefined}>
<MapObjectsMenu
objectLayerCount={objectLayerCount}
summaries={summaries}
referenceSummaries={referenceSummaries}
subjectStates={subjectStates}
filteredTargets={filteredTargets}
onOpenSubjectWindow={onOpenSubjectWindow}
onToggleSubjectVisibility={onToggleSubjectVisibility}
onToggleReferenceLayer={onToggleReferenceLayer}
/>
<MapLayersMenu
providerStatus={providerStatus}
mapSettings={mapSettings}
liveCacheSummary={liveCacheSummary}
transportDiagnostic={transportDiagnostic}
gatewayHealthAge={gatewayHealthAge}
gatewayCheckState={gatewayCheckState}
gatewayCheckError={gatewayCheckError}
onOpenChange={onLayersOpenChange}
onTerrainChange={onTerrainChange}
onBuildingsVisibleChange={onBuildingsVisibleChange}
onGridVisibleChange={onGridVisibleChange}
onCacheEnabledChange={onCacheEnabledChange}
onCacheNoOverwriteChange={onCacheNoOverwriteChange}
/>
<IconButton label="Обзор объектов" onClick={onFitVisibleTargets}><Icon name="globe" /></IconButton>
<IconButton
label={searchOpen ? "Закрыть поиск" : "Поиск"}
aria-expanded={searchOpen}
aria-controls="map-subject-search"
data-active={searchOpen || undefined}
onClick={onToggleSearch}
><Icon name="search" /></IconButton>
<MapSearch
open={searchOpen}
query={searchQuery}
inputRef={searchInputRef}
results={searchResults}
activeIndex={searchActiveIndex}
remoteQuery={remoteSearchQuery}
referenceSearchState={referenceSearchState}
onQueryChange={onSearchQueryChange}
onKeyDown={onSearchKeyDown}
onActiveIndexChange={onSearchActiveIndexChange}
onResult={onSearchResult}
/>
</div>
);
}
function MapObjectsMenu({
objectLayerCount,
summaries,
referenceSummaries,
subjectStates,
filteredTargets,
onOpenSubjectWindow,
onToggleSubjectVisibility,
onToggleReferenceLayer,
}: {
objectLayerCount: number;
summaries: MapPresentationSummary[];
referenceSummaries: ReferenceObjectSummary[];
subjectStates: Record<string, MapSubjectState>;
filteredTargets: MapFilteredTarget[];
onOpenSubjectWindow: (bindingId: string) => void;
onToggleSubjectVisibility: (bindingId: string) => void;
onToggleReferenceLayer: (layerId: string) => void;
}) {
return (
<Dropdown
placement="top-start"
width={320}
minWidth={240}
offset={10}
surfaceRole="menu"
surfaceClassName="catalog-map-fixture__objects-menu nodedc-map-glass"
trigger={({ open, toggle, setTriggerRef, surfaceId }) => (
<IconButton ref={setTriggerRef} label="Объекты" aria-controls={surfaceId} aria-expanded={open} aria-pressed={open} data-active={open || undefined} onClick={toggle}><Icon name="target" /></IconButton>
)}
>
{({ close }) => (
<div className="catalog-map-fixture__objects-menu-list">
<div className="catalog-map-fixture__objects-menu-head">
<strong>Объекты</strong>
<small>{objectLayerCount} {objectLayerCount === 1 ? "группа" : "групп"}</small>
</div>
{summaries.map((summary) => {
const state = subjectStates[summary.bindingId];
const visibleCount = filteredTargets.filter((target) => target.bindingId === summary.bindingId && target.renderable).length;
const visible = state?.visible !== false;
const hasControls = mapProfileHasSubjectWindowControls(summary.profile);
return (
<div
className="catalog-map-fixture__objects-menu-item"
key={summary.bindingId}
data-visible={visible || undefined}
data-open={hasControls && state?.window.open || undefined}
>
<button
type="button"
role={hasControls ? "menuitem" : "menuitemcheckbox"}
aria-checked={hasControls ? undefined : visible}
className="catalog-map-fixture__objects-menu-toggle"
onClick={() => {
if (hasControls) {
onOpenSubjectWindow(summary.bindingId);
close();
} else onToggleSubjectVisibility(summary.bindingId);
}}
>
<span>{summary.displayName}</span>
<small>{visible ? `на карте: ${visibleCount}` : `слой скрыт · ${summary.total} объектов`}</small>
</button>
</div>
);
})}
{referenceSummaries.map(({ layer, displayName, total }) => (
<div className="catalog-map-fixture__objects-menu-item" key={layer.id} data-visible={layer.visible || undefined}>
<button
type="button"
role="menuitemcheckbox"
aria-checked={layer.visible}
className="catalog-map-fixture__objects-menu-toggle"
onClick={() => onToggleReferenceLayer(layer.id)}
>
<span>{displayName}</span>
<small>{layer.visible ? `на карте: ${total}` : `слой скрыт · ${total} объектов`}</small>
</button>
</div>
))}
{!objectLayerCount ? <small className="catalog-map-fixture__objects-menu-empty">Нет подключённых объектов.</small> : null}
</div>
)}
</Dropdown>
);
}
function MapLayersMenu({
providerStatus,
mapSettings,
liveCacheSummary,
transportDiagnostic,
gatewayHealthAge,
gatewayCheckState,
gatewayCheckError,
onOpenChange,
onTerrainChange,
onBuildingsVisibleChange,
onGridVisibleChange,
onCacheEnabledChange,
onCacheNoOverwriteChange,
}: {
providerStatus: MapProviderStatus;
mapSettings: MapPageSettings;
liveCacheSummary: string;
transportDiagnostic: string | null;
gatewayHealthAge: string | null;
gatewayCheckState: "idle" | "checking" | "ready" | "stale" | "error";
gatewayCheckError: string | null;
onOpenChange: (open: boolean) => void;
onTerrainChange: (value: boolean) => void;
onBuildingsVisibleChange: (value: boolean) => void;
onGridVisibleChange: (value: boolean) => void;
onCacheEnabledChange: (value: boolean) => void;
onCacheNoOverwriteChange: (value: boolean) => void;
}) {
return (
<Dropdown
placement="top-start"
width={320}
minWidth={240}
offset={10}
surfaceRole="dialog"
surfaceClassName="catalog-map-fixture__objects-menu catalog-map-fixture__layers-menu nodedc-map-glass"
onOpenChange={onOpenChange}
trigger={({ open, toggle, setTriggerRef, surfaceId }) => (
<IconButton ref={setTriggerRef} label="Слои карты" aria-controls={surfaceId} aria-expanded={open} aria-pressed={open} data-active={open || undefined} onClick={toggle}><Icon name="grid" /></IconButton>
)}
>
<div className="catalog-map-fixture__layers-content" aria-label="Настройки слоёв карты">
<div className="catalog-map-fixture__objects-menu-head">
<strong>Слои карты</strong>
<small>Подложка, рельеф и визуальные слои</small>
</div>
<div className="catalog-map-fixture__provider">
<strong>Cesium World Imagery</strong>
<small>официальный live provider · imagery: {providerStateLabel[providerStatus.imagery]} · terrain: {providerStateLabel[providerStatus.terrain]}</small>
</div>
{Object.entries(providerStatus.errors).map(([provider, error]) => <small key={provider} className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{provider}: {error}</small>)}
<Checker checked={mapSettings.terrainEnabled} label="Terrain" onChange={onTerrainChange} />
<Checker checked={mapSettings.buildingsVisible} label="3D здания" onChange={onBuildingsVisibleChange} />
<Checker checked={mapSettings.gridVisible} label="Планетарная сетка" onChange={onGridVisibleChange} />
<small className="catalog-map-inspector__note">Live Cache: {liveCacheSummary}</small>
{transportDiagnostic ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{transportDiagnostic}</small> : null}
{gatewayHealthAge ? <small className="catalog-map-inspector__note">{gatewayHealthAge}</small> : null}
{gatewayCheckState === "error" || gatewayCheckState === "stale" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{gatewayCheckError}</small> : null}
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheEnabled} label="Кэшировать live-данные" onChange={onCacheEnabledChange} />
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheNoOverwrite} disabled={!mapSettings.cacheEnabled} label="Не перезаписывать cache" onChange={onCacheNoOverwriteChange} />
</div>
</Dropdown>
);
}
function MapSearch({
open,
query,
inputRef,
results,
activeIndex,
remoteQuery,
referenceSearchState,
onQueryChange,
onKeyDown,
onActiveIndexChange,
onResult,
}: {
open: boolean;
query: string;
inputRef: RefObject<HTMLInputElement | null>;
results: MapSearchDocument[];
activeIndex: number;
remoteQuery: string;
referenceSearchState: "idle" | "loading" | "ready" | "error";
onQueryChange: (value: string) => void;
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void;
onActiveIndexChange: (index: number) => void;
onResult: (result: MapSearchDocument) => void;
}) {
return (
<div className="catalog-map-search" data-open={open || undefined}>
<label className="catalog-map-search__field" htmlFor="map-subject-search">
<Icon name="search" />
<input
ref={inputRef}
id="map-subject-search"
type="search"
value={query}
autoComplete="off"
spellCheck={false}
placeholder="Название, ID объекта или трекера"
aria-label="Поиск объектов карты"
aria-controls="map-subject-search-results"
aria-activedescendant={results.length ? `map-subject-search-result-${activeIndex}` : undefined}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={onKeyDown}
/>
</label>
{query.trim() ? (
<div id="map-subject-search-results" className="catalog-map-search__results nodedc-map-glass" role="listbox" aria-label="Результаты поиска">
{results.map((result, index) => (
<button
key={`${result.bindingId}:${result.sourceId}`}
id={`map-subject-search-result-${index}`}
type="button"
role="option"
aria-selected={index === activeIndex}
data-active={index === activeIndex || undefined}
onPointerEnter={() => onActiveIndexChange(index)}
onClick={() => onResult(result)}
>
<span>{result.title}</span>
<small>{result.groupTitle}</small>
</button>
))}
{!results.length ? (
<small className="catalog-map-search__empty">
{remoteQuery === query.trim()
? (referenceSearchState === "loading"
? "Ищем станцию в OSM…"
: referenceSearchState === "error"
? "Поиск OSM временно недоступен; локальные данные сохранены."
: "Станции с таким точным названием не найдены.")
: "Совпадений нет. Enter — найти станцию по точному названию в OSM."}
</small>
) : null}
</div>
) : null}
</div>
);
}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { NodedcTheme } from "@nodedc/ui-core"; import type { NodedcTheme } from "@nodedc/ui-core";
import type { MapPageLayout, MapPageSettings } from "./MapFixturePreview.js"; import type { MapPageLayout, MapPageSettings } from "./mapPageContract.js";
import type { MapPresentationProfile } from "./mapPresentationProfile.js"; import type { MapPresentationProfile } from "./mapPresentationProfile.js";
export const applicationManifestSchemaVersion = "0.1.0" as const; export const applicationManifestSchemaVersion = "0.1.0" as const;
+1 -1
View File
@@ -1,7 +1,7 @@
import type { GlassMaterialSettings, NodedcTheme } from "@nodedc/ui-core"; import type { GlassMaterialSettings, NodedcTheme } from "@nodedc/ui-core";
import type { ToolbarPlacement } from "@nodedc/ui-react"; import type { ToolbarPlacement } from "@nodedc/ui-react";
import type { FaviconAssetUrls } from "./favicon.js"; import type { FaviconAssetUrls } from "./favicon.js";
import type { MapPageLayout, MapPageSettings } from "./MapFixturePreview.js"; import type { MapPageLayout, MapPageSettings } from "./mapPageContract.js";
import type { MapPresentationProfile } from "./mapPresentationProfile.js"; import type { MapPresentationProfile } from "./mapPresentationProfile.js";
export type MaterialDraft = { export type MaterialDraft = {
File diff suppressed because it is too large Load Diff
+610
View File
@@ -0,0 +1,610 @@
import {
BillboardGraphics,
CallbackProperty,
CallbackPositionProperty,
Cartesian2,
Cartesian3,
Cartographic,
ClassificationType,
Color,
ColorGeometryInstanceAttribute,
ConstantPositionProperty,
CustomDataSource,
GeometryInstance,
GroundPolylineGeometry,
GroundPolylinePrimitive,
GroundPrimitive,
HeightReference,
HorizontalOrigin,
LabelGraphics,
LabelStyle,
PerInstanceColorAppearance,
PointGraphics,
PolygonGeometry,
PolygonHierarchy,
PolylineColorAppearance,
PolylineGraphics,
VerticalOrigin,
Viewer,
} from "cesium";
import { mapRuntimeEntityId, type MapRuntimeBinding, type MapRuntimeFact } from "./useMapDataProductRuntime.js";
import {
mapPresentationProfileForFact,
mapRuntimeDisplayLabel,
mapRuntimeFactIsVisible,
resolveMapPresentationClass,
resolveMapPresentationStyle,
type MapPresentationFilters,
type MapPresentationProfile,
} from "./mapPresentationProfile.js";
import { normalizeHGeoZoneRing } from "./hGeoZoneProjection.mjs";
const MAX_HGEOZONE_INSTANCES_PER_BATCH = 256;
const elevatedTargetImageCache = new Map<string, string>();
function elevatedTargetImage(
fillColor: Color,
outlineColor: Color,
outlineWidthPx: number,
headSizePx: number,
) {
const safeHeadSize = Math.max(1, headSizePx);
const safeOutlineWidth = Math.max(0, outlineWidthPx);
const imageSize = Math.max(1, Math.ceil(safeHeadSize + safeOutlineWidth * 2));
const key = [
fillColor.toCssColorString(),
outlineColor.toCssColorString(),
safeOutlineWidth,
safeHeadSize,
imageSize,
].join("|");
const cached = elevatedTargetImageCache.get(key);
if (cached) return { image: cached, size: imageSize };
const center = imageSize / 2;
const radius = Math.max(0.5, safeHeadSize / 2);
const svg = [
`<svg xmlns="http://www.w3.org/2000/svg" width="${imageSize}" height="${imageSize}" viewBox="0 0 ${imageSize} ${imageSize}">`,
`<circle cx="${center}" cy="${center}" r="${radius}" fill="${fillColor.toCssColorString()}"`,
safeOutlineWidth > 0
? ` stroke="${outlineColor.toCssColorString()}" stroke-width="${safeOutlineWidth}"/>`
: "/>",
"</svg>",
].join("");
const image = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
elevatedTargetImageCache.set(key, image);
return { image, size: imageSize };
}
const accent = Color.fromCssColorString("#ff2f92");
const violet = Color.fromCssColorString("#8f72dc");
function showBelowCameraHeight(viewer: Viewer, limit?: number) {
if (!limit) return true;
return new CallbackProperty(() => Number(viewer.camera.positionCartographic?.height || 0) <= limit, false);
}
function elevatedPinGroundHeight(viewer: Viewer, longitude: number, latitude: number, fallbackHeightMeters = 0) {
const sampled = viewer.scene.globe.getHeight(Cartographic.fromDegrees(longitude, latitude));
return Number.isFinite(sampled) ? Number(sampled) : fallbackHeightMeters;
}
function elevatedPinTopPosition(
viewer: Viewer,
longitude: number,
latitude: number,
stemHeightMeters: number,
fallbackHeightMeters = 0,
) {
return new CallbackPositionProperty(() => Cartesian3.fromDegrees(
longitude,
latitude,
elevatedPinGroundHeight(viewer, longitude, latitude, fallbackHeightMeters) + stemHeightMeters,
), false);
}
function elevatedPinStemPositions(
viewer: Viewer,
longitude: number,
latitude: number,
stemHeightMeters: number,
fallbackHeightMeters = 0,
) {
return new CallbackProperty(() => {
const groundHeight = elevatedPinGroundHeight(viewer, longitude, latitude, fallbackHeightMeters);
return [
Cartesian3.fromDegrees(longitude, latitude, groundHeight),
Cartesian3.fromDegrees(longitude, latitude, groundHeight + stemHeightMeters),
];
}, false);
}
function runtimePointColor(fact: MapRuntimeFact) {
// This is a semantic default for the generic Map entity-stream adapter,
// not a provider style. A renderer-neutral style profile can refine it
// later without changing a data product or its L2 workflow.
if (fact.presentationStatus === "stale") return Color.fromCssColorString("#f5a623");
if (["inactive", "no-position", "no_position"].includes(fact.presentationStatus)) {
return Color.fromCssColorString("#7d8491");
}
return fact.semanticType === "map.moving_object" ? accent : violet;
}
export type HGeoZonePickId = {
kind: "nodedc-hgeozone";
entityId: string;
instanceId: string;
};
type HGeoZoneFillPart = {
pickId: HGeoZonePickId;
hierarchy: PolygonHierarchy;
color: Color;
};
type HGeoZoneOutlinePart = {
pickId: HGeoZonePickId;
positions: Cartesian3[];
color: Color;
};
type HGeoZonePrimitiveBatch<TPart> = {
primitive: GroundPrimitive | GroundPolylinePrimitive;
parts: TPart[];
};
export type HGeoZoneProjectionLayer = {
geometryKey: string;
styleKey: string;
hideCameraHeightMeters: number | null;
fills: Array<HGeoZonePrimitiveBatch<HGeoZoneFillPart>>;
outlines: Array<HGeoZonePrimitiveBatch<HGeoZoneOutlinePart>>;
};
function hGeoZoneRingPositions(ring: Array<[number, number]>) {
return normalizeHGeoZoneRing(ring)
.map(([longitude, latitude]) => Cartesian3.fromDegrees(longitude, latitude, 0));
}
function hGeoZoneHierarchy(polygon: Array<Array<[number, number]>>) {
const outer = hGeoZoneRingPositions(polygon[0]);
if (outer.length < 3) return null;
const holes = polygon.slice(1)
.map((ring) => hGeoZoneRingPositions(ring))
.filter((ring) => ring.length >= 3)
.map((ring) => new PolygonHierarchy(ring));
return new PolygonHierarchy(outer, holes);
}
function hGeoZoneBatches<T>(items: T[]) {
const batches: T[][] = [];
for (let index = 0; index < items.length; index += MAX_HGEOZONE_INSTANCES_PER_BATCH) {
batches.push(items.slice(index, index + MAX_HGEOZONE_INSTANCES_PER_BATCH));
}
return batches;
}
function removeHGeoZoneLayer(viewer: Viewer, layer: HGeoZoneProjectionLayer) {
for (const batch of [...layer.fills, ...layer.outlines]) {
viewer.scene.groundPrimitives.remove(batch.primitive);
}
}
function hGeoZoneFaultKey(bindingId: string, geometryKey: string) {
return `${bindingId}\u0000${geometryKey}`;
}
export function quarantineHGeoZoneLayers(
viewer: Viewer,
layers: Map<string, HGeoZoneProjectionLayer>,
faultedGeometryKeys: Set<string>,
) {
if (!layers.size) return false;
const hasPendingGeometry = [...layers.values()].some((layer) => (
[...layer.fills, ...layer.outlines].some((batch) => !batch.primitive.ready)
));
if (!hasPendingGeometry) return false;
for (const [bindingId, layer] of layers) {
faultedGeometryKeys.add(hGeoZoneFaultKey(bindingId, layer.geometryKey));
removeHGeoZoneLayer(viewer, layer);
}
layers.clear();
viewer.scene.requestRender();
return true;
}
export function syncHGeoZoneVisibility(viewer: Viewer, layers: Map<string, HGeoZoneProjectionLayer>) {
const cameraHeight = Number(viewer.camera.positionCartographic?.height || 0);
for (const layer of layers.values()) {
const show = layer.hideCameraHeightMeters === null || cameraHeight <= layer.hideCameraHeightMeters;
for (const batch of [...layer.fills, ...layer.outlines]) batch.primitive.show = show;
}
viewer.scene.requestRender();
}
function updateHGeoZoneColors<TPart extends { pickId: HGeoZonePickId; color: Color }>(
batches: Array<HGeoZonePrimitiveBatch<TPart>>,
nextParts: TPart[],
) {
const nextColors = new Map(nextParts.map((part) => [part.pickId.instanceId, part.color]));
for (const batch of batches) {
for (const part of batch.parts) {
const color = nextColors.get(part.pickId.instanceId);
if (!color) continue;
const attributes = batch.primitive.getGeometryInstanceAttributes(part.pickId);
if (attributes) attributes.color = ColorGeometryInstanceAttribute.toValue(color);
part.color = color;
}
}
}
function createHGeoZoneLayer(
viewer: Viewer,
geometryKey: string,
styleKey: string,
hideCameraHeightMeters: number | null,
fillParts: HGeoZoneFillPart[],
outlineParts: HGeoZoneOutlinePart[],
outlineWidthPx: number,
) {
const fills = hGeoZoneBatches(fillParts).map((parts) => {
const primitive = viewer.scene.groundPrimitives.add(new GroundPrimitive({
geometryInstances: parts.map((part) => new GeometryInstance({
id: part.pickId,
geometry: new PolygonGeometry({
polygonHierarchy: part.hierarchy,
vertexFormat: PerInstanceColorAppearance.FLAT_VERTEX_FORMAT,
}),
attributes: { color: ColorGeometryInstanceAttribute.fromColor(part.color) },
})),
appearance: new PerInstanceColorAppearance({ flat: true, translucent: true }),
allowPicking: true,
asynchronous: true,
classificationType: ClassificationType.TERRAIN,
releaseGeometryInstances: true,
}));
return { primitive, parts };
});
const outlines = outlineWidthPx <= 0 ? [] : hGeoZoneBatches(outlineParts).map((parts) => {
const primitive = viewer.scene.groundPrimitives.add(new GroundPolylinePrimitive({
geometryInstances: parts.map((part) => new GeometryInstance({
id: part.pickId,
geometry: new GroundPolylineGeometry({
positions: part.positions,
width: outlineWidthPx,
loop: true,
}),
attributes: { color: ColorGeometryInstanceAttribute.fromColor(part.color) },
})),
appearance: new PolylineColorAppearance({ translucent: true }),
allowPicking: true,
asynchronous: true,
classificationType: ClassificationType.TERRAIN,
releaseGeometryInstances: true,
}));
return { primitive, parts };
});
const layer = { geometryKey, styleKey, hideCameraHeightMeters, fills, outlines };
syncHGeoZoneVisibility(viewer, new Map([["layer", layer]]));
return layer;
}
function syncHGeoZoneLayers(
viewer: Viewer,
layers: Map<string, HGeoZoneProjectionLayer>,
bindings: MapRuntimeBinding[],
presentationProfiles: MapPresentationProfile[],
presentationFilters: MapPresentationFilters,
faultedGeometryKeys: Set<string>,
) {
const activeBindings = new Set(bindings.filter((binding) => binding.slotId === "zones").map((binding) => binding.bindingId));
for (const [bindingId, layer] of layers) {
if (activeBindings.has(bindingId)) continue;
removeHGeoZoneLayer(viewer, layer);
layers.delete(bindingId);
}
for (const binding of bindings) {
if (binding.slotId !== "zones") continue;
const fillParts: HGeoZoneFillPart[] = [];
const outlineParts: HGeoZoneOutlinePart[] = [];
const geometryMembers: string[] = [];
let outlineWidthPx = 1.5;
let hideCameraHeightMeters: number | null = null;
let outlineColor = Color.fromCssColorString("#c9b6ff").withAlpha(0.9);
const styleMembers: string[] = [];
for (const fact of binding.facts) {
const profile = mapPresentationProfileForFact(
presentationProfiles,
binding.presentationProfileId,
fact.semanticType,
);
if (
!fact.geometry
|| fact.geometry.type === "Point"
|| (profile && profile.target.variant !== "surface-fill")
|| (profile && !mapRuntimeFactIsVisible(fact, profile, presentationFilters, binding.bindingId))
) continue;
const presentationClass = profile ? resolveMapPresentationClass(fact, profile) : undefined;
const resolvedStyle = profile ? resolveMapPresentationStyle(profile, presentationClass) : undefined;
const fillColor = resolvedStyle
? Color.fromCssColorString(resolvedStyle.color).withAlpha(resolvedStyle.opacity)
: runtimePointColor(fact).withAlpha(0.28);
if (profile?.target.variant === "surface-fill") {
outlineWidthPx = profile.target.outlineWidthPx;
hideCameraHeightMeters = profile.target.hideCameraHeightMeters;
outlineColor = Color.fromCssColorString(profile.target.outlineColor).withAlpha(profile.target.outlineOpacity);
}
const polygons = fact.geometry.type === "Polygon" ? [fact.geometry.coordinates] : fact.geometry.coordinates;
const baseEntityId = mapRuntimeEntityId(binding.bindingId, fact);
for (const [polygonIndex, polygon] of polygons.entries()) {
const hierarchy = hGeoZoneHierarchy(polygon);
if (!hierarchy) continue;
const instanceBase = `${baseEntityId}:part:${polygonIndex}`;
const fillPickId: HGeoZonePickId = {
kind: "nodedc-hgeozone",
entityId: baseEntityId,
instanceId: `${instanceBase}:fill`,
};
fillParts.push({ pickId: fillPickId, hierarchy, color: fillColor });
geometryMembers.push(fillPickId.instanceId);
styleMembers.push(`${fillPickId.instanceId}:${resolvedStyle?.id ?? "default"}:${fillColor.toCssHexString()}:${fillColor.alpha}`);
for (const [ringIndex, ring] of polygon.entries()) {
const positions = hGeoZoneRingPositions(ring);
if (positions.length < 3) continue;
const outlinePickId: HGeoZonePickId = {
kind: "nodedc-hgeozone",
entityId: baseEntityId,
instanceId: `${instanceBase}:ring:${ringIndex}`,
};
outlineParts.push({ pickId: outlinePickId, positions, color: outlineColor });
geometryMembers.push(outlinePickId.instanceId);
}
}
}
const geometryKey = JSON.stringify([binding.cursor, outlineWidthPx, geometryMembers]);
const styleKey = JSON.stringify([styleMembers, outlineColor.toCssHexString(), outlineColor.alpha]);
const current = layers.get(binding.bindingId);
if (!fillParts.length) {
if (current) removeHGeoZoneLayer(viewer, current);
layers.delete(binding.bindingId);
continue;
}
if (faultedGeometryKeys.has(hGeoZoneFaultKey(binding.bindingId, geometryKey))) {
if (current) removeHGeoZoneLayer(viewer, current);
layers.delete(binding.bindingId);
continue;
}
if (current?.geometryKey === geometryKey) {
current.hideCameraHeightMeters = hideCameraHeightMeters;
if (current.styleKey !== styleKey) {
const ready = [...current.fills, ...current.outlines].every((batch) => batch.primitive.ready);
if (ready) {
updateHGeoZoneColors(current.fills, fillParts);
updateHGeoZoneColors(current.outlines, outlineParts);
current.styleKey = styleKey;
syncHGeoZoneVisibility(viewer, layers);
continue;
}
} else {
syncHGeoZoneVisibility(viewer, layers);
continue;
}
}
if (current) removeHGeoZoneLayer(viewer, current);
layers.set(binding.bindingId, createHGeoZoneLayer(
viewer,
geometryKey,
styleKey,
hideCameraHeightMeters,
fillParts,
outlineParts,
outlineWidthPx,
));
}
viewer.scene.requestRender();
}
export function syncRuntimeDataSources(
viewer: Viewer,
dataSources: Map<string, CustomDataSource>,
hGeoZoneLayers: Map<string, HGeoZoneProjectionLayer>,
bindings: MapRuntimeBinding[],
presentationProfiles: MapPresentationProfile[],
presentationFilters: MapPresentationFilters,
faultedHGeoZoneGeometryKeys: Set<string>,
) {
const activeBindings = new Map(bindings
.filter((binding) => (
binding.slotId === "points"
|| binding.slotId === "reference-points"
|| binding.slotId === "zones"
))
.map((binding) => [binding.bindingId, binding]));
for (const [bindingId, dataSource] of dataSources) {
if (activeBindings.has(bindingId)) continue;
viewer.dataSources.remove(dataSource, true);
dataSources.delete(bindingId);
}
for (const binding of activeBindings.values()) {
let dataSource = dataSources.get(binding.bindingId);
if (!dataSource) {
dataSource = new CustomDataSource(`nodedc-map-slot:${binding.bindingId}`);
viewer.dataSources.add(dataSource);
dataSources.set(binding.bindingId, dataSource);
}
const wanted = new Set<string>();
for (const fact of binding.facts) {
const profile = mapPresentationProfileForFact(
presentationProfiles,
binding.presentationProfileId,
fact.semanticType,
);
const presentationClass = profile ? resolveMapPresentationClass(fact, profile) : undefined;
if (
!fact.geometry
|| (profile && !mapRuntimeFactIsVisible(fact, profile, presentationFilters, binding.bindingId))
) continue;
const resolvedStyle = profile ? resolveMapPresentationStyle(profile, presentationClass) : undefined;
const color = resolvedStyle
? Color.fromCssColorString(resolvedStyle.color).withAlpha(resolvedStyle.opacity)
: runtimePointColor(fact);
const label = mapRuntimeDisplayLabel(fact, profile);
const baseEntityId = mapRuntimeEntityId(binding.bindingId, fact);
if (fact.geometry.type === "Point" && (binding.slotId === "points" || binding.slotId === "reference-points")) {
if (profile && profile.target.variant !== "elevated-spike") continue;
const entityId = baseEntityId;
wanted.add(entityId);
const [longitude, latitude] = fact.geometry.coordinates;
const entity = dataSource.entities.getById(entityId) ?? dataSource.entities.add({ id: entityId });
entity.name = label;
entity.polygon = undefined;
if (!profile) {
entity.billboard = undefined;
entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(longitude, latitude, 0));
entity.polyline = undefined;
entity.point = new PointGraphics({
pixelSize: 10,
color,
outlineColor: Color.TRANSPARENT,
outlineWidth: 0,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
entity.label = new LabelGraphics({
text: label,
font: "700 13px Arial",
fillColor: Color.WHITE,
outlineColor: Color.TRANSPARENT,
outlineWidth: 0,
style: LabelStyle.FILL,
showBackground: true,
backgroundColor: Color.BLACK.withAlpha(0.72),
backgroundPadding: new Cartesian2(10, 7),
pixelOffset: new Cartesian2(10, 0),
horizontalOrigin: HorizontalOrigin.LEFT,
verticalOrigin: VerticalOrigin.BOTTOM,
heightReference: HeightReference.NONE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
continue;
}
if (profile.target.variant !== "elevated-spike") continue;
const target = profile.target;
const fallbackHeightMeters = typeof fact.attributes.elevation_meters === "number" && Number.isFinite(fact.attributes.elevation_meters)
? fact.attributes.elevation_meters
: 0;
entity.position = elevatedPinTopPosition(
viewer,
longitude,
latitude,
target.stemHeightMeters,
fallbackHeightMeters,
);
entity.polyline = new PolylineGraphics({
positions: elevatedPinStemPositions(
viewer,
longitude,
latitude,
target.stemHeightMeters,
fallbackHeightMeters,
),
width: target.stemWidthPx,
material: color,
show: showBelowCameraHeight(viewer, target.hideCameraHeightMeters),
});
const targetImage = elevatedTargetImage(
color,
Color.fromCssColorString(target.outlineColor).withAlpha(target.outlineOpacity),
target.outlineWidthPx,
target.headSizePx,
);
entity.point = undefined;
entity.billboard = new BillboardGraphics({
image: targetImage.image,
width: targetImage.size,
height: targetImage.size,
horizontalOrigin: HorizontalOrigin.CENTER,
verticalOrigin: VerticalOrigin.CENTER,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
show: showBelowCameraHeight(viewer, target.hideCameraHeightMeters),
});
entity.label = new LabelGraphics({
text: label,
font: `${profile.label.fontWeight} ${profile.label.sizePx}px Arial`,
fillColor: Color.fromCssColorString(profile.label.color),
outlineColor: Color.TRANSPARENT,
outlineWidth: 0,
style: LabelStyle.FILL,
showBackground: profile.label.backgroundOpacity > 0,
backgroundColor: Color.fromCssColorString(profile.label.backgroundColor).withAlpha(profile.label.backgroundOpacity),
backgroundPadding: new Cartesian2(profile.label.paddingX, profile.label.paddingY),
pixelOffset: new Cartesian2(profile.label.offsetX, profile.label.offsetY),
horizontalOrigin: HorizontalOrigin.LEFT,
verticalOrigin: VerticalOrigin.BOTTOM,
heightReference: HeightReference.NONE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
show: profile.label.mode !== "none" && showBelowCameraHeight(viewer, profile.label.hideCameraHeightMeters),
});
continue;
}
if (
binding.slotId !== "zones"
|| fact.geometry.type === "Point"
|| (profile && profile.target.variant !== "surface-fill")
) continue;
const polygons = fact.geometry.type === "Polygon" ? [fact.geometry.coordinates] : fact.geometry.coordinates;
const outerRing = normalizeHGeoZoneRing(polygons[0]?.[0] ?? []);
if (!outerRing.length) continue;
wanted.add(baseEntityId);
const labelAnchor = outerRing.reduce(
(accumulator, [longitude, latitude]) => [accumulator[0] + longitude, accumulator[1] + latitude] as [number, number],
[0, 0] as [number, number],
);
const divisor = Math.max(1, outerRing.length);
const entity = dataSource.entities.getById(baseEntityId) ?? dataSource.entities.add({ id: baseEntityId });
entity.name = label;
entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(labelAnchor[0] / divisor, labelAnchor[1] / divisor, 0));
entity.billboard = undefined;
entity.point = undefined;
entity.polygon = undefined;
entity.polyline = undefined;
entity.label = new LabelGraphics({
text: label,
font: profile ? `${profile.label.fontWeight} ${profile.label.sizePx}px Arial` : "700 13px Arial",
fillColor: profile ? Color.fromCssColorString(profile.label.color) : Color.WHITE,
outlineColor: Color.TRANSPARENT,
outlineWidth: 0,
style: LabelStyle.FILL,
showBackground: (profile?.label.backgroundOpacity ?? 0.72) > 0,
backgroundColor: profile
? Color.fromCssColorString(profile.label.backgroundColor).withAlpha(profile.label.backgroundOpacity)
: Color.BLACK.withAlpha(0.72),
backgroundPadding: new Cartesian2(profile?.label.paddingX ?? 10, profile?.label.paddingY ?? 7),
pixelOffset: new Cartesian2(profile?.label.offsetX ?? 10, profile?.label.offsetY ?? 0),
horizontalOrigin: HorizontalOrigin.LEFT,
verticalOrigin: VerticalOrigin.BOTTOM,
heightReference: HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
show: (profile?.label.mode ?? "attributes") !== "none"
&& (profile ? showBelowCameraHeight(viewer, profile.label.hideCameraHeightMeters) : true),
});
}
for (const entity of [...dataSource.entities.values]) {
if (typeof entity.id === "string" && !wanted.has(entity.id)) dataSource.entities.remove(entity);
}
}
syncHGeoZoneLayers(
viewer,
hGeoZoneLayers,
bindings,
presentationProfiles,
presentationFilters,
faultedHGeoZoneGeometryKeys,
);
viewer.scene.requestRender();
}
@@ -0,0 +1,348 @@
import type { Dispatch, SetStateAction } from "react";
import {
Button,
Checker,
ColorField,
ControlRow,
Icon,
InspectorSelectField,
RangeControl,
SegmentedControl,
} from "@nodedc/ui-react";
import type { SelectOption } from "@nodedc/ui-react";
import { DEFAULT_GRID_LOD_PROFILES } from "./mapGridPolicy.mjs";
import { MAX_LOCAL_GRID_INDEX } from "./mapSectorGrid.mjs";
import {
GRID_SECTOR_DIRECTIONS,
formatGridSectorArea,
gridSectorBoundsLabel,
gridSectorCenterLabel,
normalizedGraticuleStepDegrees,
normalizedMajorTileSizeKm,
type GridSectorDirection,
type SectorGridLodProfile,
} from "./mapSectorWorkspace.js";
import type {
GridMajorTileSelection,
GridSectorSelection,
} from "./mapRendererContract.js";
import type { MapPageSettings } from "./mapPageContract.js";
import type { MapInspectorSection } from "./mapInspectorSections.js";
const GRID_MODE_OPTIONS: Array<SelectOption<"3d" | "graticule">> = [
{ value: "3d", label: "3D", description: "Приподнятая пространственная сетка" },
{ value: "graticule", label: "Гратикула", description: "Проекция по поверхности" },
];
type GridSectorCopyState = "idle" | "copied" | "error";
export function buildMapGridInspectorSections({
mapSettings,
updateMapSettings,
selectedGridLod,
setSelectedGridLod,
selectedGridLodIndex,
activeGridLod,
minimumGridLodHeight,
maximumGridLodHeight,
updateGridLod,
updateGridVolumeRange,
activeGraticuleMajorStepDegrees,
selectedGridSector,
gridSectorCopyState,
copySelectedGridSectorId,
mapRendererReady,
focusGridMajorTile,
selectedGridParentLod,
sectorGridLodProfiles,
focusGridSector,
selectedGridNeighborTargets,
selectedGridSectorProfile,
selectedGridVolumeTargets,
}: {
mapSettings: MapPageSettings;
updateMapSettings: (patch: Partial<MapPageSettings>) => void;
selectedGridLod: string;
setSelectedGridLod: Dispatch<SetStateAction<string>>;
selectedGridLodIndex: number;
activeGridLod: SectorGridLodProfile;
minimumGridLodHeight: number;
maximumGridLodHeight: number;
updateGridLod: (patch: Partial<SectorGridLodProfile>) => void;
updateGridVolumeRange: (patch: Partial<Pick<SectorGridLodProfile,
"volumeMinimumHeightMeters" | "volumeMaximumHeightMeters" | "volumeBandHeightMeters">>) => void;
activeGraticuleMajorStepDegrees: number | null;
selectedGridSector: GridSectorSelection | null;
gridSectorCopyState: GridSectorCopyState;
copySelectedGridSectorId: () => Promise<void>;
mapRendererReady: boolean;
focusGridMajorTile: (tile: GridMajorTileSelection) => void;
selectedGridParentLod: GridSectorSelection | null;
sectorGridLodProfiles: SectorGridLodProfile[];
focusGridSector: (sector: GridSectorSelection | null) => void;
selectedGridNeighborTargets: Record<GridSectorDirection, GridSectorSelection | null>;
selectedGridSectorProfile: SectorGridLodProfile | null;
selectedGridVolumeTargets: { above: GridSectorSelection | null; below: GridSectorSelection | null };
}): MapInspectorSection[] {
return [
{
id: "map-grid",
label: "Сетка и LOD",
description: "first adapter control",
group: "Слои",
icon: <Icon name="grid" />,
content: <div className="catalog-map-grid-inspector">
<small className="catalog-map-inspector__note">Фиксированная московская ENU-адресация задаёт неизменные сектора на LOD 13. LOD 45 используют глобальную WGS84-гратику́лу; камера выбирает только LOD и видимую область.</small>
<Checker checked={mapSettings.gridVisible} label="Сетка" onChange={(gridVisible) => updateMapSettings({ gridVisible })} />
<Checker checked={mapSettings.grid3dEnabled} label="3D-сетка" onChange={(grid3dEnabled) => updateMapSettings({ grid3dEnabled })} />
<Checker checked={mapSettings.gridGraticuleEnabled} label="Гратикула" onChange={(gridGraticuleEnabled) => updateMapSettings({ gridGraticuleEnabled })} />
<Checker checked={mapSettings.gridLodEnabled} label="LOD по высоте камеры" onChange={(gridLodEnabled) => updateMapSettings({ gridLodEnabled })} />
<Checker checked={mapSettings.gridRebuildOnMoveEnd} label="Перестраивать после движения" onChange={(gridRebuildOnMoveEnd) => updateMapSettings({ gridRebuildOnMoveEnd })} />
<ControlRow label="Система координат"><strong>Fixed ENU · WGS84</strong></ControlRow>
<RangeControl label="Origin: широта" value={mapSettings.gridCenterLatitude} min={-89.9} max={89.9} step={0.000001} formatValue={(value) => value.toFixed(6)} onChange={(gridCenterLatitude) => updateMapSettings({ gridCenterLatitude })} />
<RangeControl label="Origin: долгота" value={mapSettings.gridCenterLongitude} min={-180} max={180} step={0.000001} formatValue={(value) => value.toFixed(6)} onChange={(gridCenterLongitude) => updateMapSettings({ gridCenterLongitude })} />
<RangeControl label="Автовыключение выше" value={mapSettings.gridAutoDisableHeightKm} min={0} max={50_000} step={100} formatValue={(value) => value === 0 ? "выкл" : `${value} км`} onChange={(gridAutoDisableHeightKm) => updateMapSettings({ gridAutoDisableHeightKm })} />
<div className="catalog-map-grid-lod-tabs">
<SegmentedControl value={selectedGridLod} items={DEFAULT_GRID_LOD_PROFILES.map((_profile, index) => ({ value: String(index), label: `LOD ${index + 1}` }))} label="Уровень детализации сетки" onChange={setSelectedGridLod} />
</div>
<RangeControl label={selectedGridLodIndex === 4 ? "Порог профиля" : "До высоты"} value={activeGridLod.maxHeightKm} min={minimumGridLodHeight} max={maximumGridLodHeight} step={0.1} formatValue={(value) => `${value} км`} onChange={(maxHeightKm) => updateGridLod({ maxHeightKm })} />
{selectedGridLodIndex === 4 ? <small className="catalog-map-inspector__note">Последний LOD остаётся активным выше своего порога до общего автовыключения.</small> : null}
<InspectorSelectField
label="Режим"
value={activeGridLod.mode}
options={GRID_MODE_OPTIONS}
onChange={(mode) => updateGridLod({
mode,
volumeEnabled: mode === "3d" && activeGridLod.volumeEnabled,
...(mode === "3d" ? {
stepKm: Math.min(50, activeGridLod.stepKm),
tileSizeKm: normalizedMajorTileSizeKm(Math.min(50, activeGridLod.stepKm), activeGridLod.tileSizeKm),
} : {
graticuleStepDegrees: normalizedGraticuleStepDegrees(activeGridLod.graticuleStepDegrees),
}),
})}
/>
<RangeControl label="Высота WGS84" value={activeGridLod.heightMeters} min={0} max={5_000} step={10} formatValue={(value) => `${value} м`} onChange={(heightMeters) => updateGridLod({ heightMeters })} />
<RangeControl label="Конус видимости 3D" value={activeGridLod.max3dViewAngleDegrees} min={30} max={170} step={1} formatValue={(value) => `${value}°`} onChange={(max3dViewAngleDegrees) => updateGridLod({ max3dViewAngleDegrees })} />
<RangeControl
label="Шаг ENU-секторов"
value={activeGridLod.stepKm}
min={0.1}
max={activeGridLod.mode === "3d" ? 50 : 5_000}
step={0.1}
formatValue={(value) => `${value} км`}
onChange={(stepKm) => updateGridLod({
stepKm,
tileSizeKm: normalizedMajorTileSizeKm(stepKm, Math.max(stepKm, activeGridLod.tileSizeKm)),
radiusKm: Math.min(activeGridLod.radiusKm, stepKm * MAX_LOCAL_GRID_INDEX),
})}
/>
<RangeControl
label="Размер major-тайла ENU"
value={activeGridLod.tileSizeKm}
min={activeGridLod.stepKm}
max={Math.max(activeGridLod.stepKm, 50)}
step={activeGridLod.stepKm}
formatValue={(value) => `${value} км`}
onChange={(tileSizeKm) => updateGridLod({ tileSizeKm: normalizedMajorTileSizeKm(activeGridLod.stepKm, tileSizeKm) })}
/>
<small className="catalog-map-inspector__note">Major-тайл содержит целое число ENU-секторов. Для гратикулы major-шаг равен пяти minor-шагам.</small>
<Checker
checked={activeGridLod.majorLinesEnabled}
label="Major-линии"
onChange={(majorLinesEnabled) => updateGridLod({
majorLinesEnabled,
majorLabelsEnabled: majorLinesEnabled && activeGridLod.majorLabelsEnabled,
})}
/>
<Checker checked={activeGridLod.majorLabelsEnabled} disabled={!activeGridLod.majorLinesEnabled} label="Подписи major-тайлов" onChange={(majorLabelsEnabled) => updateGridLod({ majorLabelsEnabled })} />
<RangeControl label="Толщина major-линий" value={activeGridLod.majorLineWidthMultiplier} min={1} max={8} step={0.1} formatValue={(value) => `×${value.toFixed(1)}`} onChange={(majorLineWidthMultiplier) => updateGridLod({ majorLineWidthMultiplier })} />
<small className="catalog-map-inspector__note">Прозрачность major-линий наследует прозрачность линий текущего LOD.</small>
{activeGridLod.mode === "graticule" && activeGridLod.majorLinesEnabled && activeGraticuleMajorStepDegrees === null
? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">Major-разметка недоступна для этого шага: пять minor-интервалов должны точно делить 90°-квадрант.</small>
: null}
<RangeControl label="Радиус ENU-поля" value={activeGridLod.radiusKm} min={1} max={Math.min(100_000, activeGridLod.stepKm * MAX_LOCAL_GRID_INDEX)} step={1} formatValue={(value) => `${value} км`} onChange={(radiusKm) => updateGridLod({ radiusKm })} />
<RangeControl label="Диаметр 3D-линий" value={activeGridLod.lineDiameterMeters} min={1} max={100} step={1} formatValue={(value) => `${value} м`} onChange={(lineDiameterMeters) => updateGridLod({ lineDiameterMeters })} />
<ControlRow label="Цвет 3D-линий"><ColorField label="Цвет линий ENU-сетки" value={activeGridLod.lineColor} onChange={(lineColor) => updateGridLod({ lineColor })} /></ControlRow>
<RangeControl label="Прозрачность 3D-линий" value={activeGridLod.lineOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(lineOpacity) => updateGridLod({ lineOpacity })} />
<Checker checked={activeGridLod.dotsEnabled} label="Кружки" onChange={(dotsEnabled) => updateGridLod({ dotsEnabled })} />
<RangeControl label="Кружки: диаметр" value={activeGridLod.dotsDiameterMeters} min={1} max={1_000} step={1} formatValue={(value) => `${value} м`} onChange={(dotsDiameterMeters) => updateGridLod({ dotsDiameterMeters })} />
<ControlRow label="Кружки: цвет"><ColorField label="Цвет кружков сетки" value={activeGridLod.dotsColor} onChange={(dotsColor) => updateGridLod({ dotsColor })} /></ControlRow>
<RangeControl label="Кружки: прозрачность" value={activeGridLod.dotsOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(dotsOpacity) => updateGridLod({ dotsOpacity })} />
<Checker checked={activeGridLod.crossesEnabled} label="Кресты" onChange={(crossesEnabled) => updateGridLod({ crossesEnabled })} />
<RangeControl label="Кресты: длина" value={activeGridLod.crossesLengthMeters} min={2} max={5_000} step={2} formatValue={(value) => `${value} м`} onChange={(crossesLengthMeters) => updateGridLod({ crossesLengthMeters })} />
<RangeControl label="Кресты: ширина" value={activeGridLod.crossesWidthMeters} min={1} max={500} step={1} formatValue={(value) => `${value} м`} onChange={(crossesWidthMeters) => updateGridLod({ crossesWidthMeters })} />
<ControlRow label="Кресты: цвет"><ColorField label="Цвет крестов сетки" value={activeGridLod.crossesColor} onChange={(crossesColor) => updateGridLod({ crossesColor })} /></ControlRow>
<RangeControl label="Кресты: прозрачность" value={activeGridLod.crossesOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(crossesOpacity) => updateGridLod({ crossesOpacity })} />
<RangeControl label="Шаг гратикулы" value={activeGridLod.graticuleStepDegrees} min={0.1} max={10} step={0.05} formatValue={(value) => `${value}°`} onChange={(graticuleStepDegrees) => updateGridLod({ graticuleStepDegrees: normalizedGraticuleStepDegrees(graticuleStepDegrees) })} />
<RangeControl label="Толщина гратикулы" value={activeGridLod.graticuleLineWidthPx} min={1} max={3} step={1} formatValue={(value) => `${value} px`} onChange={(graticuleLineWidthPx) => updateGridLod({ graticuleLineWidthPx })} />
<ControlRow label="Цвет гратикулы"><ColorField label="Цвет WGS84-гратику́лы" value={activeGridLod.graticuleColor} onChange={(graticuleColor) => updateGridLod({ graticuleColor })} /></ControlRow>
<RangeControl label="Прозрачность гратикулы" value={activeGridLod.graticuleOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(graticuleOpacity) => updateGridLod({ graticuleOpacity })} />
{activeGridLod.mode === "3d" ? <>
<Checker checked={activeGridLod.volumeEnabled} label="Объёмный выбор сектора" onChange={(volumeEnabled) => updateGridLod({ volumeEnabled })} />
<RangeControl
label="Нижняя отметка объёма"
value={activeGridLod.volumeMinimumHeightMeters}
min={-1_000}
max={activeGridLod.volumeMaximumHeightMeters - 1}
step={10}
formatValue={(value) => `${value} м WGS84`}
onChange={(volumeMinimumHeightMeters) => updateGridVolumeRange({ volumeMinimumHeightMeters })}
/>
<RangeControl
label="Верхняя отметка объёма"
value={activeGridLod.volumeMaximumHeightMeters}
min={activeGridLod.volumeMinimumHeightMeters + 1}
max={10_000}
step={10}
formatValue={(value) => `${value} м WGS84`}
onChange={(volumeMaximumHeightMeters) => updateGridVolumeRange({ volumeMaximumHeightMeters })}
/>
<RangeControl
label="Высота адресного диапазона"
value={activeGridLod.volumeBandHeightMeters}
min={1}
max={1_000}
step={10}
formatValue={(value) => `${value} м`}
onChange={(volumeBandHeightMeters) => updateGridVolumeRange({ volumeBandHeightMeters })}
/>
<small className="catalog-map-inspector__note">Горизонтальный ID сектора остаётся стабильным. Высотный band добавляется как отдельный адрес внутри выбранной ENU-ячейки.</small>
</> : null}
<ControlRow label="Цвет заливки сектора">
<ColorField
label="Цвет заливки выбранного сектора"
value={activeGridLod.selectionFillColor}
onChange={(selectionFillColor) => updateGridLod({ selectionFillColor })}
/>
</ControlRow>
<RangeControl
label="Прозрачность заливки"
value={activeGridLod.selectionFillOpacityPercent}
min={0}
max={100}
step={1}
formatValue={(value) => `${value}%`}
onChange={(selectionFillOpacityPercent) => updateGridLod({ selectionFillOpacityPercent })}
/>
<ControlRow label="Цвет линии сектора">
<ColorField
label="Цвет линии выбранного сектора"
value={activeGridLod.selectionOutlineColor}
onChange={(selectionOutlineColor) => updateGridLod({ selectionOutlineColor })}
/>
</ControlRow>
<RangeControl
label="Толщина линии"
value={activeGridLod.selectionOutlineWidthPx}
min={1}
max={12}
step={0.25}
formatValue={(value) => `${value} px`}
onChange={(selectionOutlineWidthPx) => updateGridLod({ selectionOutlineWidthPx })}
/>
<RangeControl
label="Прозрачность линии"
value={activeGridLod.selectionOutlineOpacityPercent}
min={0}
max={100}
step={1}
formatValue={(value) => `${value}%`}
onChange={(selectionOutlineOpacityPercent) => updateGridLod({ selectionOutlineOpacityPercent })}
/>
<small className="catalog-map-inspector__note">Оформление применяется к выбранному сектору текущего LOD.</small>
<section className="catalog-map-grid-sector" aria-label="Выбранный сектор">
<ControlRow label="Выбранный сектор"><strong className="catalog-map-grid-sector-id">{selectedGridSector?.id ?? "Нажмите сектор на карте"}</strong></ControlRow>
{selectedGridSector ? <>
<Button
variant="secondary"
size="compact"
width="full"
shape="pill"
icon={<Icon name={gridSectorCopyState === "copied" ? "check" : "copy"} />}
onClick={() => void copySelectedGridSectorId()}
>{gridSectorCopyState === "copied" ? "ID скопирован" : "Копировать stable ID"}</Button>
{gridSectorCopyState === "error" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">Не удалось записать ID в буфер обмена.</small> : null}
<div className="catalog-map-grid-sector__facts">
<ControlRow label="Family / LOD"><strong>{selectedGridSector.mode === "3d" ? "Local ENU" : "WGS84 graticule"} · LOD {selectedGridSector.lod}</strong></ControlRow>
<ControlRow label="Адрес"><span>{selectedGridSector.label}</span></ControlRow>
<ControlRow label="Границы"><span>{gridSectorBoundsLabel(selectedGridSector)}</span></ControlRow>
<ControlRow label="Центр"><span>{gridSectorCenterLabel(selectedGridSector)}</span></ControlRow>
<ControlRow label="Площадь"><strong>{formatGridSectorArea(selectedGridSector.areaSquareMeters)}</strong></ControlRow>
</div>
{selectedGridSector.parentMajorTile ? <div className="catalog-map-grid-sector__relation">
<small>Parent major tile · {selectedGridSector.parentMajorTile.label}</small>
<code title={selectedGridSector.parentMajorTile.id}>{selectedGridSector.parentMajorTile.id}</code>
<small>{selectedGridSector.parentMajorTile.minorPerSide} × {selectedGridSector.parentMajorTile.minorPerSide} · {selectedGridSector.parentMajorTile.childCount} дочерних секторов · {formatGridSectorArea(selectedGridSector.parentMajorTile.areaSquareMeters)}</small>
<Button
variant="secondary"
size="compact"
width="full"
data-grid-navigation-intent="parent-major"
disabled={!mapRendererReady}
onClick={() => focusGridMajorTile(selectedGridSector.parentMajorTile!)}
>Фокус major-тайла</Button>
</div> : <small className="catalog-map-inspector__note">Parent major tile выключен или недоступен для текущей топологии.</small>}
<div className="catalog-map-grid-sector__relation">
<small>Следующий LOD</small>
{selectedGridParentLod ? <>
<code title={selectedGridParentLod.id}>{selectedGridParentLod.id}</code>
<Button
variant="secondary"
size="compact"
width="full"
data-grid-navigation-intent="next-lod"
disabled={!mapRendererReady}
onClick={() => focusGridSector(selectedGridParentLod)}
>Перейти в LOD {selectedGridParentLod.lod}</Button>
</> : <span>{selectedGridSector.lod >= sectorGridLodProfiles.length
? "Верхний уровень иерархии"
: `LOD ${selectedGridSector.lod + 1} меняет систему адресации`}</span>}
</div>
<div className="catalog-map-grid-sector__neighbors" aria-label="Соседние сектора">
{GRID_SECTOR_DIRECTIONS.map(({ id, label }) => {
const target = selectedGridNeighborTargets[id];
return <div className="catalog-map-grid-sector__neighbor" key={id}>
<Button
variant="secondary"
size="compact"
width="full"
data-grid-navigation-intent={id}
disabled={!target || !mapRendererReady}
onClick={() => focusGridSector(target)}
>{label}</Button>
<code title={target?.id}>{target?.id ?? "Граница адресного пространства"}</code>
</div>;
})}
</div>
{selectedGridSector.mode === "3d" && selectedGridSectorProfile ? <div className="catalog-map-grid-sector__volume" data-enabled={selectedGridSectorProfile.volumeEnabled || undefined}>
<ControlRow label="Высотный выбор"><strong>{selectedGridSectorProfile.volumeEnabled ? "Включён" : "Выключен"}</strong></ControlRow>
<ControlRow label="Floor"><span>{selectedGridSector.volume?.floor ?? selectedGridSectorProfile.volumeMinimumHeightMeters} м WGS84</span></ControlRow>
<ControlRow label="Ceiling"><span>{selectedGridSector.volume?.ceiling ?? selectedGridSectorProfile.volumeMaximumHeightMeters} м WGS84</span></ControlRow>
<ControlRow label="Height band"><span>{selectedGridSector.volume?.bandHeight ?? selectedGridSectorProfile.volumeBandHeightMeters} м</span></ControlRow>
{selectedGridSector.volume ? <code title={selectedGridSector.volume.id}>{selectedGridSector.volume.id}</code> : null}
{selectedGridSectorProfile.volumeEnabled ? <div className="catalog-map-grid-sector__volume-actions">
<Button
variant="secondary"
size="compact"
width="full"
data-grid-navigation-intent="below"
disabled={!selectedGridVolumeTargets.below || !mapRendererReady}
onClick={() => focusGridSector(selectedGridVolumeTargets.below)}
>Ниже</Button>
<Button
variant="secondary"
size="compact"
width="full"
data-grid-navigation-intent="above"
disabled={!selectedGridVolumeTargets.above || !mapRendererReady}
onClick={() => focusGridSector(selectedGridVolumeTargets.above)}
>Выше</Button>
</div> : null}
</div> : null}
</> : <small className="catalog-map-inspector__note">Кликните ячейку, чтобы получить устойчивый адрес, геометрию и навигацию по соседям.</small>}
</section>
</div>,
},
];
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { MapPresentation } from "./CesiumMapRenderer.js"; import type { MapPresentation } from "./mapRendererContract.js";
export type GridLodMode = "3d" | "graticule"; export type GridLodMode = "3d" | "graticule";
export type GridLodProfile = { export type GridLodProfile = {
+428
View File
@@ -0,0 +1,428 @@
import type { Dispatch, ReactNode, SetStateAction } from "react";
import {
Button,
Checker,
ColorField,
ControlRow,
Icon,
InspectorSelectField,
RangeControl,
} from "@nodedc/ui-react";
import type { SelectOption } from "@nodedc/ui-react";
import {
OSM_BUILDINGS_OBSERVED_BAND_COUNT,
cameraSurveySpiralDistance,
type CameraSurveySelection,
} from "./mapCameraPresets.js";
import {
isMapReferencePresentationProfile,
type MapReferenceLayer,
} from "./mapReferenceStations.js";
import type { MapPageSettings } from "./mapPageContract.js";
import type { MapGatewayHealth, MapProviderStatus } from "./mapRendererContract.js";
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
import type { MapSelectableEntity } from "./mapWorkspaceModel.mjs";
export type MapInspectorSection = {
id: string;
label: string;
description: string;
group: string;
icon: ReactNode;
content: ReactNode;
};
type UpdateMapSettings = (patch: Partial<MapPageSettings>) => void;
type UpdatePresentationProfile = (
profileId: string,
updater: (profile: MapPresentationProfile) => MapPresentationProfile,
) => void;
type UpdatePresentationStyle = (
profileId: string,
styleId: string,
patch: Partial<MapPresentationProfile["styles"][number]>,
) => void;
type GatewayCheckState = "idle" | "checking" | "ready" | "stale" | "error";
const providerStateLabel: Record<MapProviderStatus["imagery"], string> = {
loading: "загружается",
ready: "готов",
error: "недоступен",
"not-configured": "не настроен",
};
const logarithmicControlValue = (value: number) => Math.log10(Math.max(Number.MIN_VALUE, value));
const valueFromLogarithmicControl = (value: number) => Math.max(1, Math.round(10 ** value));
export const formatMetricDistance = (value: number) => value >= 1000
? `${(value / 1000).toLocaleString("ru-RU", { maximumFractionDigits: value >= 10_000 ? 0 : 1 })} км`
: `${Math.round(value)} м`;
const formatMetricSpeed = (value: number) => value >= 1000
? `${(value / 1000).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} км/с`
: `${Math.round(value)} м/с`;
const formatDuration = (seconds: number) => {
if (seconds >= 86_400) return `${(seconds / 86_400).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} сут`;
if (seconds >= 3_600) return `${(seconds / 3_600).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} ч`;
if (seconds >= 60) return `${Math.round(seconds / 60)} мин`;
return `${Math.max(1, Math.round(seconds))} с`;
};
export function buildMapSurfaceInspectorSections({
mapSettings,
providerStatus,
updateMapSettings,
}: {
mapSettings: MapPageSettings;
providerStatus: MapProviderStatus;
updateMapSettings: UpdateMapSettings;
}): MapInspectorSection[] {
return [
{
id: "map-base",
label: "Подложка и terrain",
description: "provider-neutral surface",
group: "Карта",
icon: <Icon name="globe" />,
content: <>
<ControlRow label="Подложка"><strong>Cesium World Imagery</strong></ControlRow>
<small className="catalog-map-inspector__note">Текущий официальный provider. Другие provider-слои появятся только после отдельного asset-контракта Platform.</small>
<ControlRow label="Live providers"><span>Imagery: {providerStateLabel[providerStatus.imagery]} · Terrain: {providerStateLabel[providerStatus.terrain]} · 3D: {providerStateLabel[providerStatus.buildings]}</span></ControlRow>
{Object.entries(providerStatus.errors).map(([provider, error]) => <small key={provider} className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{provider}: {error}</small>)}
<small className="catalog-map-inspector__note">Рельеф отдельный слой под imagery.</small>
<Checker checked={mapSettings.terrainEnabled} label="Terrain" onChange={(terrainEnabled) => updateMapSettings({ terrainEnabled })} />
<RangeControl label="Вертикальное преувеличение рельефа" value={mapSettings.terrainExaggeration * 100} min={25} max={300} formatValue={(value) => `${(value / 100).toFixed(2)}×`} onChange={(value) => updateMapSettings({ terrainExaggeration: value / 100 })} />
<Checker checked={mapSettings.monochrome} label="Монохромная поверхность" onChange={(monochrome) => updateMapSettings({ monochrome })} />
<ControlRow label="Цвет монохрома"><ColorField label="Цвет монохромной поверхности" value={mapSettings.monochromeColor} onChange={(monochromeColor) => updateMapSettings({ monochromeColor })} /></ControlRow>
<RangeControl label="Яркость" value={mapSettings.imageryBrightness} min={0} max={200} formatValue={(value) => `${value}%`} onChange={(imageryBrightness) => updateMapSettings({ imageryBrightness })} />
<RangeControl label="Контраст" value={mapSettings.imageryContrast} min={0} max={200} formatValue={(value) => `${value}%`} onChange={(imageryContrast) => updateMapSettings({ imageryContrast })} />
<RangeControl label="Насыщенность" value={mapSettings.imagerySaturation} min={0} max={200} formatValue={(value) => `${value}%`} onChange={(imagerySaturation) => updateMapSettings({ imagerySaturation })} />
<RangeControl label="Гамма" value={mapSettings.imageryGamma} min={0} max={300} formatValue={(value) => `${value}%`} onChange={(imageryGamma) => updateMapSettings({ imageryGamma })} />
<RangeControl label="Оттенок" value={mapSettings.imageryHue} min={-180} max={180} formatValue={(value) => `${value}°`} onChange={(imageryHue) => updateMapSettings({ imageryHue })} />
<RangeControl label="Прозрачность imagery" value={mapSettings.imageryAlpha} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(imageryAlpha) => updateMapSettings({ imageryAlpha })} />
<ControlRow label="Цвет планеты"><ColorField label="Цвет terrain без imagery" value={mapSettings.globeColor} onChange={(globeColor) => updateMapSettings({ globeColor })} /></ControlRow>
<ControlRow label="Фон сцены"><ColorField label="Цвет фона сцены" value={mapSettings.backgroundColor} onChange={(backgroundColor) => updateMapSettings({ backgroundColor })} /></ControlRow>
</>,
},
{
id: "map-atmosphere",
label: "Атмосфера и освещение",
description: "scene / color correction",
group: "Карта",
icon: <Icon name="activity" />,
content: <>
<Checker checked={mapSettings.atmosphereEnabled} label="Показывать атмосферу" onChange={(atmosphereEnabled) => updateMapSettings({ atmosphereEnabled })} />
<RangeControl label="Атмосфера: оттенок" value={mapSettings.atmosphereHue} min={-100} max={100} formatValue={(value) => `${value}%`} onChange={(atmosphereHue) => updateMapSettings({ atmosphereHue })} />
<RangeControl label="Атмосфера: насыщенность" value={mapSettings.atmosphereSaturation} min={-100} max={100} formatValue={(value) => `${value}%`} onChange={(atmosphereSaturation) => updateMapSettings({ atmosphereSaturation })} />
<RangeControl label="Атмосфера: яркость" value={mapSettings.atmosphereBrightness} min={-100} max={100} formatValue={(value) => `${value}%`} onChange={(atmosphereBrightness) => updateMapSettings({ atmosphereBrightness })} />
<Checker checked={mapSettings.fogEnabled} label="Туман" onChange={(fogEnabled) => updateMapSettings({ fogEnabled })} />
<RangeControl label="Плотность тумана" value={mapSettings.fogDensity} min={0} max={100} formatValue={(value) => `${(value / 10000).toFixed(4)}`} onChange={(fogDensity) => updateMapSettings({ fogDensity })} />
<Checker checked={mapSettings.sunEnabled} label="Солнечное освещение" onChange={(sunEnabled) => updateMapSettings({ sunEnabled })} />
<RangeControl label="Час солнца" value={mapSettings.sunHour} min={0} max={24} formatValue={(value) => `${value}:00 UTC`} onChange={(sunHour) => updateMapSettings({ sunHour })} />
<RangeControl label="Интенсивность света" value={mapSettings.sunIntensity} min={0} max={200} formatValue={(value) => `${value}%`} onChange={(sunIntensity) => updateMapSettings({ sunIntensity })} />
<Checker checked={mapSettings.shadowsEnabled} label="Тени" onChange={(shadowsEnabled) => updateMapSettings({ shadowsEnabled })} />
</>,
},
{
id: "map-buildings",
label: "3D здания",
description: "3D Tiles / detail",
group: "Карта",
icon: <Icon name="building" />,
content: <>
<Checker checked={mapSettings.buildingsVisible} label="Показывать 3D здания" onChange={(buildingsVisible) => updateMapSettings({ buildingsVisible })} />
<ControlRow label="Цвет"><ColorField label="Цвет зданий" value={mapSettings.buildingsColor} onChange={(buildingsColor) => updateMapSettings({ buildingsColor })} /></ControlRow>
<RangeControl label="Прозрачность" value={Math.round(mapSettings.buildingsOpacity * 100)} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(value) => updateMapSettings({ buildingsOpacity: value / 100 })} />
<RangeControl label="Детализация" value={mapSettings.buildingsDetail} min={4} max={32} formatValue={(value) => `SSE ${value}`} onChange={(buildingsDetail) => updateMapSettings({ buildingsDetail })} />
</>,
},
];
}
export function buildMapPresentationInspectorSections({
presentationProfiles,
referenceLayers,
setReferenceLayers,
updatePresentationProfile,
updatePresentationStyle,
}: {
presentationProfiles: MapPresentationProfile[];
referenceLayers: MapReferenceLayer[];
setReferenceLayers: Dispatch<SetStateAction<MapReferenceLayer[]>>;
updatePresentationProfile: UpdatePresentationProfile;
updatePresentationStyle: UpdatePresentationStyle;
}): MapInspectorSection[] {
return [
...presentationProfiles.flatMap((profile) => {
const referenceProfile = isMapReferencePresentationProfile(profile);
const referenceLayer = referenceLayers.find((layer) => layer.presentationProfileId === profile.id);
return [
{
id: `map-target-${profile.id}`,
label: referenceProfile ? profile.title : profile.target.variant === "surface-fill" ? "HGeoZone" : "Таргет",
description: profile.target.variant === "surface-fill" ? `проекция · ${profile.title}` : profile.title,
group: referenceProfile ? "Станции" : profile.target.variant === "surface-fill" ? "Слои" : "Таргеты",
icon: <Icon name={referenceProfile ? "globe" : profile.target.variant === "surface-fill" ? "grid" : "target"} />,
content: <>
<small className="catalog-map-inspector__note">Профиль принадлежит этой странице Application и управляется тем же provider-neutral MCP-контрактом. Исходный API в настройках отсутствует.</small>
{referenceLayer ? (
<Checker
checked={referenceLayer.visible}
label={`Показывать слой «${profile.title}»`}
onChange={(visible) => setReferenceLayers((current) => current.map((layer) => (
layer.id === referenceLayer.id ? { ...layer, visible } : layer
)))}
/>
) : null}
{profile.target.variant === "surface-fill" && <>
<ControlRow label="Тип слоя"><strong>HGeoZone · ground projection</strong></ControlRow>
{profile.styles.map((style) => {
const classLabels = profile.classes.filter((item) => item.styleId === style.id).map((item) => item.label);
const label = classLabels.length ? classLabels.join(" · ") : style.id;
return <div className="catalog-map-inspector__style" key={style.id}>
<ControlRow label={`Заливка · ${label}`}><ColorField label={`Цвет заливки: ${label}`} value={style.color} onChange={(color) => updatePresentationStyle(profile.id, style.id, { color })} /></ControlRow>
<RangeControl label={`Прозрачность заливки · ${label}`} value={Math.round(style.opacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationStyle(profile.id, style.id, { opacity: value / 100 })} />
</div>;
})}
<ControlRow label="Граница"><ColorField label="Цвет границы HGeoZone" value={profile.target.outlineColor} onChange={(outlineColor) => updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineColor } }) : current)} /></ControlRow>
<RangeControl label="Прозрачность границы" value={Math.round(profile.target.outlineOpacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineOpacity: value / 100 } }) : current)} />
<RangeControl label="Толщина границы" value={profile.target.outlineWidthPx} min={0} max={8} step={0.5} formatValue={(value) => `${value} px`} onChange={(outlineWidthPx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineWidthPx } }) : current)} />
</>}
{profile.target.variant === "elevated-spike" && <>
<RangeControl label="Высота таргета" value={profile.target.stemHeightMeters} min={100} max={10_000} step={50} formatValue={(value) => `${value} м`} onChange={(stemHeightMeters) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, stemHeightMeters } }) : current)} />
<RangeControl label="Размер головки" value={profile.target.headSizePx} min={1} max={32} step={1} formatValue={(value) => `${value} px`} onChange={(headSizePx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, headSizePx } }) : current)} />
<RangeControl label="Толщина стержня" value={profile.target.stemWidthPx} min={0.25} max={12} step={0.25} formatValue={(value) => `${value} px`} onChange={(stemWidthPx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, stemWidthPx } }) : current)} />
</>}
<InspectorSelectField
label="Подпись"
value={profile.label.mode}
options={[
{ value: "subject_id", label: "ID", description: "Стабильный идентификатор сущности" },
{ value: "attributes", label: "Имя", description: "Первое доступное display-поле" },
{ value: "none", label: "Нет", description: "Не показывать плашку" },
]}
onChange={(mode) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, mode } }))}
/>
<RangeControl label="Размер подписи" value={profile.label.sizePx} min={8} max={32} step={1} formatValue={(value) => `${value} px`} onChange={(sizePx) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, sizePx } }))} />
<RangeControl label="Смещение подписи X" value={profile.label.offsetX} min={-100} max={100} step={1} formatValue={(value) => `${value} px`} onChange={(offsetX) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, offsetX } }))} />
<RangeControl label="Смещение подписи Y" value={profile.label.offsetY} min={-100} max={100} step={1} formatValue={(value) => `${value} px`} onChange={(offsetY) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, offsetY } }))} />
<RangeControl label="Скрывать подпись выше" value={profile.label.hideCameraHeightMeters} min={1_000} max={500_000} step={1_000} formatValue={(value) => `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, hideCameraHeightMeters } }))} />
<RangeControl label={profile.target.variant === "surface-fill" ? "Скрывать HGeoZone выше" : "Скрывать таргет выше"} value={profile.target.hideCameraHeightMeters} min={1_000} max={500_000} step={1_000} formatValue={(value) => `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, hideCameraHeightMeters } }))} />
<ControlRow label="Фон плашки"><ColorField label="Цвет фона подписи" value={profile.label.backgroundColor} onChange={(backgroundColor) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundColor } }))} /></ControlRow>
<RangeControl label="Прозрачность плашки" value={Math.round(profile.label.backgroundOpacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundOpacity: value / 100 } }))} />
</>,
},
...(profile.target.variant === "surface-fill" || referenceProfile ? [] : [{
id: `map-state-classes-${profile.id}`,
label: "Классы состояния",
description: "нормализованные фасеты онтологии",
group: "Таргеты",
icon: <Icon name="sliders" />,
content: <>
<small className="catalog-map-inspector__note">Цвета назначены семантическим классам после нормализации данных. Здесь нет названий provider-статусов и привязки к транспорту.</small>
{profile.styles.map((style) => {
const classLabels = profile.classes.filter((item) => item.styleId === style.id).map((item) => item.label);
const label = classLabels.length ? classLabels.join(" · ") : style.id;
return <div className="catalog-map-inspector__style" key={style.id}>
<ControlRow label={label}><ColorField label={`Цвет: ${label}`} value={style.color} onChange={(color) => updatePresentationStyle(profile.id, style.id, { color })} /></ControlRow>
<RangeControl label={`${label}: прозрачность`} value={Math.round(style.opacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationStyle(profile.id, style.id, { opacity: value / 100 })} />
</div>;
})}
</>,
}]),
];
}),
];
}
export function buildMapRuntimeInspectorSections({
animationModeEnabled,
setAnimationMode,
spiralPresetId,
spiralPresetOptions,
spiralRunning,
selectSpiralPreset,
spiralHeightMeters,
setSpiralHeightMeters,
spiralSpeedMetersPerSecond,
setSpiralSpeedMetersPerSecond,
spiralPitchMetersPerTurn,
setSpiralPitchMetersPerTurn,
spiralTargetRadiusMeters,
setSpiralTargetRadiusMeters,
setSpiralPresetId,
spiralCanStart,
spiralTileCacheReady,
providerStatus,
gatewayHealth,
gatewayCheckState,
toggleSpiralAnimation,
spiralMessage,
mapSettings,
setCacheEnabled,
setCacheNoOverwrite,
gatewayEndpoint,
liveCacheSummary,
refreshCurrentViewport,
cacheRefresh,
verifyGateway,
transportDiagnostic,
gatewayHealthAge,
gatewayCheckError,
selected,
}: {
animationModeEnabled: boolean;
setAnimationMode: (enabled: boolean) => void;
spiralPresetId: CameraSurveySelection;
spiralPresetOptions: Array<SelectOption<CameraSurveySelection>>;
spiralRunning: boolean;
selectSpiralPreset: (presetId: CameraSurveySelection) => void;
spiralHeightMeters: number;
setSpiralHeightMeters: Dispatch<SetStateAction<number>>;
spiralSpeedMetersPerSecond: number;
setSpiralSpeedMetersPerSecond: Dispatch<SetStateAction<number>>;
spiralPitchMetersPerTurn: number;
setSpiralPitchMetersPerTurn: Dispatch<SetStateAction<number>>;
spiralTargetRadiusMeters: number;
setSpiralTargetRadiusMeters: Dispatch<SetStateAction<number>>;
setSpiralPresetId: Dispatch<SetStateAction<CameraSurveySelection>>;
spiralCanStart: boolean;
spiralTileCacheReady: boolean;
providerStatus: MapProviderStatus;
gatewayHealth: MapGatewayHealth | null;
gatewayCheckState: GatewayCheckState;
toggleSpiralAnimation: () => void;
spiralMessage: string | null;
mapSettings: MapPageSettings;
setCacheEnabled: (enabled: boolean) => void;
setCacheNoOverwrite: (enabled: boolean) => void;
gatewayEndpoint: string | null;
liveCacheSummary: string;
refreshCurrentViewport: () => void;
cacheRefresh: boolean;
verifyGateway: () => void | Promise<void>;
transportDiagnostic: string | null;
gatewayHealthAge: string | null;
gatewayCheckError: string | null;
selected: MapSelectableEntity | undefined;
}): MapInspectorSection[] {
return [
{
id: "map-camera-animation",
label: "Анимация камеры",
description: "geodesic spiral survey",
group: "Камера",
icon: <Icon name="activity" />,
content: <>
<Checker checked={animationModeEnabled} label="Режим анимации" onChange={setAnimationMode} />
{animationModeEnabled ? <>
<small className="catalog-map-inspector__note">Стартовая точка берётся из текущей позиции камеры. Камера смотрит почти в надир, а маршрут ждёт текущие tiles перед продолжением. Движение идёт по региональной геодезической спирали WGS84 до выбранного радиуса.</small>
<InspectorSelectField
label="Профиль покрытия"
value={spiralPresetId}
options={spiralPresetOptions}
disabled={spiralRunning}
onChange={selectSpiralPreset}
/>
<small className="catalog-map-inspector__note">У текущего OSM Buildings подтверждено {OSM_BUILDINGS_OBSERVED_BAND_COUNT} иерархических bands. Десять профилей управляют высотой и покрытием; фактический LOD Cesium выбирает по SSE, viewport и расстоянию.</small>
<ControlRow label="Слои прохода"><small>Imagery · Terrain · OSM Buildings</small></ControlRow>
<RangeControl
label="Высота над землёй"
value={logarithmicControlValue(spiralHeightMeters)}
min={logarithmicControlValue(10)}
max={logarithmicControlValue(100_000)}
step={0.01}
disabled={spiralRunning}
formatValue={(value) => formatMetricDistance(10 ** value)}
onChange={(value) => {
setSpiralPresetId("custom");
setSpiralHeightMeters(valueFromLogarithmicControl(value));
}}
/>
<RangeControl
label="Скорость камеры"
value={logarithmicControlValue(spiralSpeedMetersPerSecond)}
min={logarithmicControlValue(1)}
max={logarithmicControlValue(5_000)}
step={0.01}
disabled={spiralRunning}
formatValue={(value) => formatMetricSpeed(10 ** value)}
onChange={(value) => {
setSpiralPresetId("custom");
setSpiralSpeedMetersPerSecond(valueFromLogarithmicControl(value));
}}
/>
<RangeControl
label="Шаг спирали"
value={logarithmicControlValue(spiralPitchMetersPerTurn)}
min={logarithmicControlValue(20)}
max={logarithmicControlValue(100_000)}
step={0.01}
disabled={spiralRunning}
formatValue={(value) => formatMetricDistance(10 ** value)}
onChange={(value) => {
setSpiralPresetId("custom");
setSpiralPitchMetersPerTurn(valueFromLogarithmicControl(value));
}}
/>
<RangeControl
label="Радиус прохода"
value={logarithmicControlValue(spiralTargetRadiusMeters)}
min={logarithmicControlValue(1_000)}
max={logarithmicControlValue(250_000)}
step={0.01}
disabled={spiralRunning}
formatValue={(value) => formatMetricDistance(10 ** value)}
onChange={(value) => {
setSpiralPresetId("custom");
setSpiralTargetRadiusMeters(valueFromLogarithmicControl(value));
}}
/>
<small className="catalog-map-inspector__note">Расчётное движение без ожидания сети: {formatDuration(cameraSurveySpiralDistance(spiralTargetRadiusMeters, spiralPitchMetersPerTurn) / spiralSpeedMetersPerSecond)}. Tile waits и автоматическое сужение шага под viewport увеличат фактическое время.</small>
{!spiralCanStart && !spiralRunning ? <small className="catalog-map-inspector__note" role="status">Подготовка: imagery {providerStateLabel[providerStatus.imagery]}, terrain {providerStateLabel[providerStatus.terrain]}, OSM Buildings {providerStateLabel[providerStatus.buildings]}, TileCache {spiralTileCacheReady ? "готов" : gatewayHealth?.cache?.atCapacity ? "заполнен" : gatewayCheckState === "checking" ? "проверяется" : "недоступен для записи"}.</small> : null}
<Button variant="secondary" shape="pill" onClick={toggleSpiralAnimation} disabled={!spiralRunning && !spiralCanStart}>{spiralRunning ? "Остановить" : "Запустить режим анимации"}</Button>
{spiralRunning ? <small className="catalog-map-inspector__note">Камера движется от исходной точки. Выключение режима, уход со страницы или reload остановят сессию.</small> : null}
{spiralMessage ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="status">{spiralMessage}</small> : null}
</> : null}
</>,
},
{
id: "map-cache",
label: "TileCache",
description: "Platform Map Gateway",
group: "Хранение",
icon: <Icon name="database" />,
content: <>
<small className="catalog-map-inspector__note">Общий persistent cache Platform: он не принадлежит приложению, странице или пользователю.</small>
<Checker checked={mapSettings.cacheEnabled} label="Кэшировать live-данные" onChange={setCacheEnabled} />
<small className="catalog-map-inspector__note">Cache hit отдаётся как есть; новый tile записывается только при miss.</small>
<Checker checked={mapSettings.cacheNoOverwrite} disabled={!mapSettings.cacheEnabled} label="Не перезаписывать уже полученное" onChange={setCacheNoOverwrite} />
<ControlRow className="catalog-map-inspector__cache-fact" label="Режим"><span>{mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Live + Cache · append-only" : "Live + Cache · обновление разрешено" : "Live без persistent cache"}</span></ControlRow>
<ControlRow className="catalog-map-inspector__cache-fact" label="Хранилище"><span>Platform Map Gateway</span></ControlRow>
<ControlRow className="catalog-map-inspector__cache-fact" label="Подключение"><span>{gatewayEndpoint ?? "runtime profile · не проверено"}</span></ControlRow>
<ControlRow className="catalog-map-inspector__cache-fact" label="Записано"><span>{liveCacheSummary}</span></ControlRow>
<ControlRow className="catalog-map-inspector__cache-fact" label="Политика"><span>{gatewayHealth?.cache?.writePolicy ?? "append-only · проверяется"}</span></ControlRow>
<Button variant="secondary" shape="pill" icon={<Icon name="refresh" />} onClick={refreshCurrentViewport} disabled={!mapSettings.cacheEnabled || cacheRefresh}> {cacheRefresh ? "Обновляем viewport…" : "Обновить текущий viewport"}</Button>
<Button variant="secondary" shape="pill" icon={<Icon name="refresh" />} onClick={() => void verifyGateway()} disabled={gatewayCheckState === "checking"}>{gatewayCheckState === "checking" ? "Проверяем Gateway…" : "Проверить подключение"}</Button>
<small className="catalog-map-inspector__note">Live Cache: {liveCacheSummary} · {gatewayHealth?.cache?.mode ?? "проверяется"}</small>
{transportDiagnostic ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{transportDiagnostic}</small> : null}
{gatewayHealthAge ? <small className="catalog-map-inspector__note">{gatewayHealthAge}</small> : null}
<small className="catalog-map-inspector__note">{mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Новые miss дописываются; при заполнении объёма Gateway продолжит live-маршрут без удаления прежних tiles." : "Новые запросы этого Application могут явно обновлять уже записанные tiles." : "Real-time: provider остаётся официальным, чтение и запись persistent cache выключены."}</small>
{gatewayHealth?.cache?.atCapacity ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">TileCache заполнен: новые tiles показываются live, но не записываются. Существующий cache не удаляется.</small> : null}
{gatewayCheckState === "error" || gatewayCheckState === "stale" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{gatewayCheckError}</small> : null}
</>,
},
{
id: "map-selection",
label: "Выбранная сущность",
description: "selection contract",
group: "Данные",
icon: <Icon name="target" />,
content: <>
<ControlRow label="Сущность"><strong>{selected?.title ?? "Нет выбора"}</strong></ControlRow>
<ControlRow label="Тип"><span>{selected?.kind ?? "—"}{selected?.status ? ` · ${selected.status}` : ""}</span></ControlRow>
</>,
},
];
}
+226
View File
@@ -0,0 +1,226 @@
import type { WorkspaceWindowRect } from "@nodedc/ui-react";
import type { GridLodProfile, MapCameraView, MapPresentation } from "./mapRendererContract.js";
import { DEFAULT_GRID_LOD_PROFILES } from "./mapGridPolicy.mjs";
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
import {
ensureMapReferencePresentationProfiles,
initialMapReferenceLayers,
type MapReferenceLayer,
} from "./mapReferenceStations.js";
import { DEFAULT_MAP_SUBJECT_DETAIL_PROFILE } from "./mapSubjectCard.mjs";
import type { MapSubjectDetailProfile } from "./mapSubjectCard.mjs";
export type MapPageSettings = Omit<MapPresentation, "cacheRefresh">;
/**
* A stable, provider-neutral visual binding owned by one Map Page instance.
*
* This is intentionally distinct from a live Data Product subject. Engine can
* identify the source entity, while Foundry owns the pin presentation and its
* page-local placement contract.
*/
export type MapPinBinding = {
id: string;
subjectId: string;
kind: "elevated-spike";
label: string;
status: string;
coordinates: { longitude: number; latitude: number; heightMeters: number };
source: { entityId: string; streamId: string; displayFields: string[] };
attributes: Record<string, string | number | boolean>;
};
/**
* A renderer-neutral declaration of a Data Product assigned to a Map slot.
*
* It contains no provider endpoint, tenant/connection scope, credential,
* browser token or raw payload. Foundry resolves the target-scoped server
* consumer from application/page/binding identity.
*/
export type MapDataProductBinding = {
id: string;
displayName?: string;
order?: number;
dataProductId: string;
slotId: string;
delivery: "snapshot+patch";
semanticTypes: string[];
fieldProjection: string[];
presentationProfileId?: string;
subjectDetailProfileId?: string;
aspectId?: string;
joinToBindingId?: string;
dataClass?: "operational" | "restricted";
};
export type MapSubjectWindowState = {
open: boolean;
rect: WorkspaceWindowRect;
maximized: boolean;
zIndex: number;
};
export type MapWorkspaceWindowId = "sector" | "subject-card" | `binding:${string}`;
export type MapSubjectState = {
bindingId: string;
visible: boolean;
/** Missing facet means unconstrained; an explicit empty list means no matches. */
filters: Record<string, string[]>;
window: MapSubjectWindowState;
};
export type MapPageLayout = {
schemaVersion: 1;
pageId: "map";
settings: MapPageSettings;
mapHeight: number;
camera: MapCameraView;
pinBindings: MapPinBinding[];
presentationProfiles: MapPresentationProfile[];
subjectDetailProfiles: MapSubjectDetailProfile[];
dataProductBindings: MapDataProductBinding[];
subjectStates: MapSubjectState[];
referenceLayers: MapReferenceLayer[];
inspectorOpenSections: string[];
savedAt?: string;
};
export type MapFixturePreviewHandle = {
getLayout: () => MapPageLayout | null;
};
export const initialMapSettings: MapPageSettings = {
imagerySource: "cesium-live",
imageryVisible: true,
cacheEnabled: true,
cacheNoOverwrite: true,
terrainEnabled: true,
terrainExaggeration: 1,
monochrome: false,
monochromeColor: "#15151b",
imageryGamma: 57,
imageryHue: 13,
imageryAlpha: 27,
globeColor: "#15151b",
backgroundColor: "#08090d",
atmosphereEnabled: false,
atmosphereHue: 0,
atmosphereSaturation: 0,
atmosphereBrightness: 0,
fogEnabled: true,
fogDensity: 2,
sunEnabled: true,
sunHour: 12,
sunIntensity: 200,
shadowsEnabled: true,
buildingsVisible: true,
buildingsColor: "#a27aff",
buildingsOpacity: 1,
buildingsDetail: 4,
imageryBrightness: 118,
imageryContrast: 102,
imagerySaturation: 0,
gridVisible: true,
gridLodEnabled: true,
grid3dEnabled: true,
gridGraticuleEnabled: true,
gridCenterMode: "fixed",
gridCenterLatitude: 55.7558,
gridCenterLongitude: 37.6173,
gridTileSizeKm: 10,
gridAutoDisableHeightKm: 10_000,
gridRebuildOnMoveEnd: true,
gridLegacyMode: false,
gridMax3dViewAngleDegrees: 30,
gridHeightMeters: 500,
gridLod1MaxHeightKm: 10,
gridLod1StepKm: 1,
gridLod1Mode: "3d",
gridLod2MaxHeightKm: 50,
gridLod2StepKm: 5,
gridLod2Mode: "3d",
gridLod3MaxHeightKm: 200,
gridLod3StepKm: 25,
gridLod3Mode: "3d",
gridLod4MaxHeightKm: 800,
gridLod4StepKm: 50,
gridLod4Mode: "graticule",
gridLod5MaxHeightKm: 3_000,
gridLod5StepKm: 50,
gridLod5Mode: "graticule",
gridRadiusKm: 40,
gridLineWidth: 1,
gridLineDiameterMeters: 7,
gridColor: "#9c9c9c",
gridOpacity: 12,
gridDotsEnabled: true,
gridDotsSize: 7,
gridDotsDiameterMeters: 10,
gridDotsColor: "#9c9c9c",
gridDotsOpacity: 58,
gridCrossesEnabled: false,
gridCrossesLengthMeters: 60,
gridCrossesWidthMeters: 10,
gridCrossesColor: "#9c9c9c",
gridCrossesOpacity: 46,
gridLodProfiles: structuredClone(DEFAULT_GRID_LOD_PROFILES) as GridLodProfile[],
};
// A deterministic provider-neutral viewport exists before the renderer emits
// its first camera update, so a freshly created page is immediately saveable.
export const fallbackMapCamera: MapCameraView = {
longitude: 37.618423,
latitude: 55.751244,
height: 40_000,
heading: 0,
pitch: -0.9,
roll: 0,
};
export function createDefaultMapPageLayout(expanded = false): MapPageLayout {
return {
schemaVersion: 1,
pageId: "map",
settings: structuredClone(initialMapSettings),
mapHeight: expanded ? 620 : 470,
camera: { ...fallbackMapCamera },
pinBindings: [],
presentationProfiles: ensureMapReferencePresentationProfiles([]),
subjectDetailProfiles: [structuredClone(DEFAULT_MAP_SUBJECT_DETAIL_PROFILE) as MapSubjectDetailProfile],
dataProductBindings: [],
subjectStates: [],
referenceLayers: initialMapReferenceLayers(),
inspectorOpenSections: ["map-base"],
};
}
export function defaultSubjectWindowState(index: number): MapSubjectWindowState {
return {
open: false,
rect: {
x: 24 + (index % 5) * 28,
y: 56 + (index % 5) * 28,
width: 280,
height: 260,
},
maximized: false,
zIndex: 20 + index,
};
}
export const defaultSectorWindowRect: WorkspaceWindowRect = {
x: 24,
y: 72,
width: 380,
height: 530,
};
export const defaultSubjectCardRect: WorkspaceWindowRect = {
x: 940,
y: 72,
width: 390,
height: 520,
};
export type { MapSubjectDetailProfile } from "./mapSubjectCard.mjs";
@@ -0,0 +1,97 @@
import type { MapRuntimeFact } from "./useMapDataProductRuntime.js";
export type MapPresentationFacetValue = {
value: string;
label: string;
order: number;
};
export type MapPresentationFacet = {
id: string;
field: string;
label: string;
filterable: boolean;
counter: boolean;
values: MapPresentationFacetValue[];
};
export type MapPresentationStyle = {
id: string;
color: string;
opacity: number;
};
export type MapPresentationClass = {
id: string;
label: string;
priority: number;
match: Array<{ field: string; equals: string }>;
styleId: string;
renderable: boolean;
};
export type MapPresentationProfile = {
id: string;
version: string;
title: string;
semanticTypes: string[];
label: {
mode: "subject_id" | "attributes" | "none";
fields: string[];
fontWeight: number;
sizePx: number;
color: string;
outlineColor: string;
outlineWidthPx: number;
backgroundColor: string;
backgroundOpacity: number;
paddingX: number;
paddingY: number;
maxLength: number;
offsetX: number;
offsetY: number;
hideCameraHeightMeters: number;
};
target: ({
variant: "elevated-spike";
stemHeightMeters: number;
headSizePx: number;
stemWidthPx: number;
} | {
variant: "surface-fill";
}) & {
outlineColor: string;
outlineOpacity: number;
outlineWidthPx: number;
hideCameraHeightMeters: number;
};
facets: MapPresentationFacet[];
styles: MapPresentationStyle[];
classes: MapPresentationClass[];
defaultClassId: string;
sort: Array<{ field: string; order: string[] }>;
};
export type MapSubjectFilterState = {
/** False is an explicit empty map state. It must never normalize to all. */
visible: boolean;
/** Missing facet is unconstrained; explicit empty facet matches nothing. */
facets: Record<string, string[]>;
};
export type MapPresentationFilters = Record<string, MapSubjectFilterState>;
export function normalizeClientMapPresentationProfiles(profiles: MapPresentationProfile[]): MapPresentationProfile[];
export function mapPresentationBindingIsAll(bindingId: string, filters: MapPresentationFilters): boolean;
export function mapPresentationFacetValueIsEnabled(facets: Record<string, string[]>, field: string, value: string): boolean;
export function normalizeMapPresentationFacetSelections(facets: Record<string, string[]>, profile: MapPresentationProfile): Record<string, string[]>;
export function toggleMapPresentationFacetSelection(facets: Record<string, string[]>, field: string, value: string, availableValues: string[]): Record<string, string[]>;
export function mapPresentationProfileForFact(profiles: MapPresentationProfile[], presentationProfileId: string | undefined, semanticType: string): MapPresentationProfile | undefined;
export function resolveMapPresentationClass(fact: MapRuntimeFact, profile: MapPresentationProfile): MapPresentationClass | undefined;
export function resolveMapPresentationStyle(profile: MapPresentationProfile, presentationClass?: MapPresentationClass): MapPresentationStyle | undefined;
export function mapRuntimeDisplayLabel(fact: MapRuntimeFact, profile?: MapPresentationProfile): string;
export function mapRuntimeFactIsRenderable(fact: MapRuntimeFact, profile: MapPresentationProfile): boolean;
export function mapRuntimeFactIsVisible(fact: MapRuntimeFact, profile: MapPresentationProfile, filters: MapPresentationFilters, bindingId: string): boolean;
export function mapFactMatchesFilters(fact: MapRuntimeFact, profile: MapPresentationProfile, filters: MapPresentationFilters, bindingId: string): boolean;
export function compareMapRuntimeFacts(left: MapRuntimeFact, right: MapRuntimeFact, profile: MapPresentationProfile): number;
export function mapPresentationFacetCounts(facts: MapRuntimeFact[], profile: MapPresentationProfile): Record<string, Record<string, number>>;
+160
View File
@@ -0,0 +1,160 @@
/**
* Runtime-only implementation of the provider-neutral presentation model.
* Types live beside it in mapPresentationProfile.d.mts so Node tests can
* execute the same code that the browser uses without a TypeScript loader.
*/
export function normalizeClientMapPresentationProfiles(profiles) {
return profiles.flatMap((profile) => {
const legacyPin = profile.pin;
const target = profile.target ?? legacyPin;
if (!target) return [];
const normalized = { ...profile, target };
delete normalized.pin;
return [normalized];
});
}
export function mapPresentationBindingIsAll(bindingId, filters) {
const state = filters[bindingId];
return state?.visible !== false && Object.keys(state?.facets ?? {}).length === 0;
}
/**
* A missing facet is compact canonical all. An explicit array is the exact
* enabled subset; an empty array therefore remains intentional none.
*/
export function mapPresentationFacetValueIsEnabled(facets, field, value) {
const selected = facets[field];
return selected === undefined || selected.includes(value);
}
export function normalizeMapPresentationFacetSelections(facets, profile) {
return Object.fromEntries(profile.facets.flatMap((facet) => {
const selected = facets[facet.field];
if (selected === undefined) return [];
const availableValues = [...new Set(facet.values.map((item) => item.value))];
const enabledValues = availableValues.filter((value) => selected.includes(value));
// Legacy layouts could persist every value explicitly. Canonicalize that
// to unconstrained so a scoped facet cannot suppress unrelated subjects.
return availableValues.length > 0 && enabledValues.length === availableValues.length
? []
: [[facet.field, enabledValues]];
}));
}
export function toggleMapPresentationFacetSelection(facets, field, value, availableValues) {
const values = [...new Set(availableValues)];
if (!values.includes(value)) return facets;
const selected = facets[field];
const enabled = new Set(selected === undefined
? values
: values.filter((item) => selected.includes(item)));
if (enabled.has(value)) enabled.delete(value);
else enabled.add(value);
const nextEnabled = values.filter((item) => enabled.has(item));
const next = { ...facets };
if (nextEnabled.length === values.length) delete next[field];
else next[field] = nextEnabled;
return next;
}
export function mapPresentationProfileForFact(profiles, presentationProfileId, semanticType) {
const exact = presentationProfileId
? profiles.find((profile) => profile.id === presentationProfileId)
: undefined;
if (exact?.semanticTypes.includes(semanticType)) return exact;
return profiles.find((profile) => profile.semanticTypes.includes(semanticType));
}
export function resolveMapPresentationClass(fact, profile) {
const classes = [...profile.classes].sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id));
return classes.find((item) => item.match.every((condition) => (
normalizedFacetValue(fact.attributes[condition.field]) === condition.equals
))) ?? classes.find((item) => item.id === profile.defaultClassId) ?? classes.at(-1);
}
export function resolveMapPresentationStyle(profile, presentationClass) {
const selected = presentationClass ?? profile.classes.find((item) => item.id === profile.defaultClassId);
return profile.styles.find((style) => style.id === selected?.styleId) ?? profile.styles[0];
}
export function mapRuntimeDisplayLabel(fact, profile) {
if (profile?.label.mode === "subject_id") return fact.sourceId;
const fields = profile?.label.fields ?? ["display_name", "label", "name", "title"];
for (const key of fields) {
const value = fact.attributes[key];
if (typeof value === "string" && value.trim()) {
const normalized = value.trim();
const limit = profile?.label.maxLength ?? 80;
return normalized.length > limit ? `${normalized.slice(0, Math.max(1, limit - 1))}` : normalized;
}
}
return fact.sourceId;
}
export function mapRuntimeFactIsRenderable(fact, profile) {
return Boolean(fact.geometry) && resolveMapPresentationClass(fact, profile)?.renderable === true;
}
export function mapRuntimeFactIsVisible(fact, profile, filters, bindingId) {
return mapRuntimeFactIsRenderable(fact, profile) && mapFactMatchesFilters(fact, profile, filters, bindingId);
}
export function mapFactMatchesFilters(fact, profile, filters, bindingId) {
const state = filters[bindingId];
if (state?.visible === false) return false;
const selectedFacets = profile.facets.flatMap((facet) => {
const selected = state?.facets?.[facet.field];
return selected === undefined ? [] : [{ facet, selected }];
});
if (selectedFacets.some(({ selected }) => selected.length === 0)) return false;
if (selectedFacets.length === 0) return true;
return selectedFacets.every(({ facet, selected }) => (
mapFactParticipatesInFacet(fact, profile, facet)
&& selected.includes(normalizedFacetValue(fact.attributes[facet.field]))
));
}
export function compareMapRuntimeFacts(left, right, profile) {
for (const rule of profile.sort) {
const leftRank = sortRank(rule.order, normalizedFacetValue(left.attributes[rule.field]));
const rightRank = sortRank(rule.order, normalizedFacetValue(right.attributes[rule.field]));
if (leftRank !== rightRank) return leftRank - rightRank;
}
return mapRuntimeDisplayLabel(left, profile).localeCompare(mapRuntimeDisplayLabel(right, profile), "ru");
}
export function mapPresentationFacetCounts(facts, profile) {
return Object.fromEntries(profile.facets.map((facet) => {
const counts = Object.fromEntries(facet.values.map((item) => [item.value, 0]));
for (const fact of facts) {
if (!mapFactParticipatesInFacet(fact, profile, facet)) continue;
const value = normalizedFacetValue(fact.attributes[facet.field]);
if (Object.hasOwn(counts, value)) counts[value] += 1;
}
return [facet.field, counts];
}));
}
/**
* Movement remains an independent fact, but an inactive subject must not
* present a stale last speed as current movement.
*/
function mapFactParticipatesInFacet(fact, profile, facet) {
if (facet.field !== "movement_state") return true;
if (!profile.facets.some((item) => item.field === "signal_state")) return true;
return normalizedFacetValue(fact.attributes.signal_state) === "active";
}
function normalizedFacetValue(value) {
return typeof value === "string" ? value.trim().toLowerCase() : "unknown";
}
function sortRank(order, value) {
const index = order.indexOf(value);
return index === -1 ? order.length : index;
}
+3 -287
View File
@@ -1,287 +1,3 @@
import type { MapRuntimeFact } from "./useMapDataProductRuntime.js"; // Stable TypeScript facade for existing `.js` specifiers. Runtime behavior is
// executable JavaScript so browser code and Node behavioral tests share it.
export type MapPresentationFacetValue = { export * from "./mapPresentationProfile.mjs";
value: string;
label: string;
order: number;
};
export type MapPresentationFacet = {
id: string;
field: string;
label: string;
filterable: boolean;
counter: boolean;
values: MapPresentationFacetValue[];
};
export type MapPresentationStyle = {
id: string;
color: string;
opacity: number;
};
export type MapPresentationClass = {
id: string;
label: string;
priority: number;
match: Array<{ field: string; equals: string }>;
styleId: string;
renderable: boolean;
};
export type MapPresentationProfile = {
id: string;
version: string;
title: string;
semanticTypes: string[];
label: {
mode: "subject_id" | "attributes" | "none";
fields: string[];
fontWeight: number;
sizePx: number;
color: string;
outlineColor: string;
outlineWidthPx: number;
backgroundColor: string;
backgroundOpacity: number;
paddingX: number;
paddingY: number;
maxLength: number;
offsetX: number;
offsetY: number;
hideCameraHeightMeters: number;
};
target: ({
variant: "elevated-spike";
stemHeightMeters: number;
headSizePx: number;
stemWidthPx: number;
} | {
variant: "surface-fill";
}) & {
outlineColor: string;
outlineOpacity: number;
outlineWidthPx: number;
hideCameraHeightMeters: number;
};
facets: MapPresentationFacet[];
styles: MapPresentationStyle[];
classes: MapPresentationClass[];
defaultClassId: string;
sort: Array<{ field: string; order: string[] }>;
};
export type MapSubjectFilterState = {
/** False is an explicit empty map state. It must never be normalized to all. */
visible: boolean;
/** Missing facet = no constraint; an explicitly empty facet = match nothing. */
facets: Record<string, string[]>;
};
/** Application view state is keyed by stable binding id, never by editable labels. */
export type MapPresentationFilters = Record<string, MapSubjectFilterState>;
/**
* Application manifests persisted before profile v1.1 used the internal key
* `pin`. Normalize that storage shape before the first React render so an old
* application cannot crash while it is being upgraded to the public `target`
* contract through MCP.
*/
export function normalizeClientMapPresentationProfiles(profiles: MapPresentationProfile[]) {
return profiles.flatMap((profile) => {
const legacyPin = (profile as MapPresentationProfile & { pin?: MapPresentationProfile["target"] }).pin;
const target = profile.target ?? legacyPin;
if (!target) return [];
const normalized = { ...profile, target } as MapPresentationProfile & { pin?: MapPresentationProfile["target"] };
delete normalized.pin;
return [normalized];
});
}
export function mapPresentationBindingIsAll(bindingId: string, filters: MapPresentationFilters) {
const state = filters[bindingId];
return state?.visible !== false && Object.keys(state?.facets ?? {}).length === 0;
}
/**
* A missing facet is the compact canonical representation of every configured
* value being enabled. An explicit array is the exact enabled subset; an empty
* array therefore remains an intentional match-nothing state.
*/
export function mapPresentationFacetValueIsEnabled(
facets: Record<string, string[]>,
field: string,
value: string,
) {
const selected = facets[field];
return selected === undefined || selected.includes(value);
}
export function normalizeMapPresentationFacetSelections(
facets: Record<string, string[]>,
profile: MapPresentationProfile,
) {
return Object.fromEntries(profile.facets.flatMap((facet) => {
const selected = facets[facet.field];
if (selected === undefined) return [];
const availableValues = [...new Set(facet.values.map((item) => item.value))];
const enabledValues = availableValues.filter((value) => selected.includes(value));
// Legacy layouts could persist every value explicitly. Canonicalize that
// shape to an unconstrained facet so scoped facets (for example movement
// on online subjects) cannot accidentally suppress unrelated subjects.
return availableValues.length > 0 && enabledValues.length === availableValues.length
? []
: [[facet.field, enabledValues] as const];
}));
}
export function toggleMapPresentationFacetSelection(
facets: Record<string, string[]>,
field: string,
value: string,
availableValues: string[],
) {
const values = [...new Set(availableValues)];
if (!values.includes(value)) return facets;
const selected = facets[field];
const enabled = new Set(selected === undefined
? values
: values.filter((item) => selected.includes(item)));
if (enabled.has(value)) enabled.delete(value);
else enabled.add(value);
const nextEnabled = values.filter((item) => enabled.has(item));
const next = { ...facets };
if (nextEnabled.length === values.length) delete next[field];
else next[field] = nextEnabled;
return next;
}
export function mapPresentationProfileForFact(
profiles: MapPresentationProfile[],
presentationProfileId: string | undefined,
semanticType: string,
) {
const exact = presentationProfileId
? profiles.find((profile) => profile.id === presentationProfileId)
: undefined;
if (exact?.semanticTypes.includes(semanticType)) return exact;
return profiles.find((profile) => profile.semanticTypes.includes(semanticType));
}
export function resolveMapPresentationClass(fact: MapRuntimeFact, profile: MapPresentationProfile) {
const classes = [...profile.classes].sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id));
return classes.find((item) => item.match.every((condition) => (
normalizedFacetValue(fact.attributes[condition.field]) === condition.equals
))) ?? classes.find((item) => item.id === profile.defaultClassId) ?? classes.at(-1);
}
export function resolveMapPresentationStyle(profile: MapPresentationProfile, presentationClass?: MapPresentationClass) {
const selected = presentationClass ?? profile.classes.find((item) => item.id === profile.defaultClassId);
return profile.styles.find((style) => style.id === selected?.styleId) ?? profile.styles[0];
}
export function mapRuntimeDisplayLabel(fact: MapRuntimeFact, profile?: MapPresentationProfile) {
if (profile?.label.mode === "subject_id") return fact.sourceId;
const fields = profile?.label.fields ?? ["display_name", "label", "name", "title"];
for (const key of fields) {
const value = fact.attributes[key];
if (typeof value === "string" && value.trim()) {
const normalized = value.trim();
const limit = profile?.label.maxLength ?? 80;
return normalized.length > limit ? `${normalized.slice(0, Math.max(1, limit - 1))}` : normalized;
}
}
return fact.sourceId;
}
export function mapRuntimeFactIsRenderable(fact: MapRuntimeFact, profile: MapPresentationProfile) {
return Boolean(fact.geometry) && resolveMapPresentationClass(fact, profile)?.renderable === true;
}
export function mapRuntimeFactIsVisible(
fact: MapRuntimeFact,
profile: MapPresentationProfile,
filters: MapPresentationFilters,
bindingId: string,
) {
return mapRuntimeFactIsRenderable(fact, profile) && mapFactMatchesFilters(fact, profile, filters, bindingId);
}
export function mapFactMatchesFilters(
fact: MapRuntimeFact,
profile: MapPresentationProfile,
filters: MapPresentationFilters,
bindingId: string,
) {
const state = filters[bindingId];
if (state?.visible === false) return false;
const selectedFacets = profile.facets.flatMap((facet) => {
const selected = state?.facets?.[facet.field];
if (selected === undefined) return [];
return [{ facet, selected }];
});
// Persisted empty arrays and the final interactive deselection are an
// explicit match-nothing state.
if (selectedFacets.some(({ selected }) => selected.length === 0)) return false;
if (selectedFacets.length === 0) return true;
// Values inside one facet form a union; independently selected facets are
// conjunctive. This keeps provider/type/state filters composable.
return selectedFacets.every(({ facet, selected }) => (
mapFactParticipatesInFacet(fact, profile, facet)
&& selected.includes(normalizedFacetValue(fact.attributes[facet.field]))
));
}
export function compareMapRuntimeFacts(left: MapRuntimeFact, right: MapRuntimeFact, profile: MapPresentationProfile) {
for (const rule of profile.sort) {
const leftRank = sortRank(rule.order, normalizedFacetValue(left.attributes[rule.field]));
const rightRank = sortRank(rule.order, normalizedFacetValue(right.attributes[rule.field]));
if (leftRank !== rightRank) return leftRank - rightRank;
}
return mapRuntimeDisplayLabel(left, profile).localeCompare(mapRuntimeDisplayLabel(right, profile), "ru");
}
export function mapPresentationFacetCounts(
facts: MapRuntimeFact[],
profile: MapPresentationProfile,
) {
return Object.fromEntries(profile.facets.map((facet) => {
const counts = Object.fromEntries(facet.values.map((item) => [item.value, 0]));
for (const fact of facts) {
if (!mapFactParticipatesInFacet(fact, profile, facet)) continue;
const value = normalizedFacetValue(fact.attributes[facet.field]);
if (Object.hasOwn(counts, value)) counts[value] += 1;
}
return [facet.field, counts];
})) as Record<string, Record<string, number>>;
}
/**
* `signal_state` and `movement_state` remain orthogonal Data Product facts.
* The operational Map, however, must not present a stale last speed as a
* current movement state. When both canonical facets exist, the movement
* facet is therefore scoped to currently active subjects. Other profiles and
* fields keep their ordinary independent-facet behaviour.
*/
function mapFactParticipatesInFacet(
fact: MapRuntimeFact,
profile: MapPresentationProfile,
facet: MapPresentationFacet,
) {
if (facet.field !== "movement_state") return true;
if (!profile.facets.some((item) => item.field === "signal_state")) return true;
return normalizedFacetValue(fact.attributes.signal_state) === "active";
}
function normalizedFacetValue(value: unknown) {
return typeof value === "string" ? value.trim().toLowerCase() : "unknown";
}
function sortRank(order: string[], value: string) {
const index = order.indexOf(value);
return index === -1 ? order.length : index;
}
+44
View File
@@ -0,0 +1,44 @@
export type MapProviderStartupPolicy = Readonly<{
providerInitializationTimeoutMs: number;
imageryViewportTimeoutMs: number;
terrainViewportTimeoutMs: number;
minimumRenderFrames: number;
}>;
export type MapProviderStartupReason = "loaded" | "timeout" | "cancelled";
export type MapProviderStartupResult = "complete" | "cancelled";
export type MapProviderName = "imagery" | "terrain" | "buildings";
type CesiumEventLike<TListener extends (...args: never[]) => void> = {
addEventListener(listener: TListener): () => void;
};
export const MAP_PROVIDER_STARTUP_POLICY: MapProviderStartupPolicy;
export function waitForGlobeViewportReady(input: {
globe: {
readonly tilesLoaded: boolean;
tileLoadProgressEvent: CesiumEventLike<(pendingRequests: number) => void>;
};
scene: {
postRender: CesiumEventLike<() => void>;
requestRender(): void;
};
signal?: AbortSignal;
timeoutMs: number;
minimumRenderFrames?: number;
}): Promise<MapProviderStartupReason>;
export function runStagedMapProviders(input: {
signal?: AbortSignal;
isCancelled?: () => boolean;
providerInitializationTimeoutMs?: number;
loadImagery: (context: { signal: AbortSignal }) => void | Promise<void>;
waitAfterImagery?: () => void | Promise<void>;
loadTerrain: (context: { signal: AbortSignal }) => void | Promise<void>;
waitAfterTerrain?: () => void | Promise<void>;
loadBuildings: (context: { signal: AbortSignal }) => void | Promise<void>;
loadDeferred?: () => void | Promise<void>;
onProviderError?: (provider: MapProviderName, error: unknown) => void;
onDeferredError?: (error: unknown) => void;
}): Promise<MapProviderStartupResult>;
+141
View File
@@ -0,0 +1,141 @@
export const MAP_PROVIDER_STARTUP_POLICY = Object.freeze({
providerInitializationTimeoutMs: 8_000,
imageryViewportTimeoutMs: 6_000,
terrainViewportTimeoutMs: 5_000,
minimumRenderFrames: 2,
});
/**
* Wait until Cesium has rendered enough frames to discover the current
* viewport and the public Globe queue reports that its terrain and imagery
* are loaded. The deadline is deliberate: a slow or unavailable provider
* must never block the next independent layer.
*/
export function waitForGlobeViewportReady({
globe,
scene,
signal,
timeoutMs,
minimumRenderFrames = MAP_PROVIDER_STARTUP_POLICY.minimumRenderFrames,
}) {
if (signal?.aborted) return Promise.resolve("cancelled");
const frameTarget = Math.max(1, Math.trunc(minimumRenderFrames));
const deadlineMs = Math.max(0, Math.trunc(timeoutMs));
return new Promise((resolve) => {
let settled = false;
let renderFrames = 0;
let removeProgressListener;
let removePostRenderListener;
let timeout;
const cleanup = () => {
removeProgressListener?.();
removePostRenderListener?.();
if (timeout !== undefined) clearTimeout(timeout);
signal?.removeEventListener("abort", onAbort);
};
const finish = (reason) => {
if (settled) return;
settled = true;
cleanup();
resolve(reason);
};
const inspect = () => {
if (renderFrames >= frameTarget && globe.tilesLoaded) finish("loaded");
};
const onAbort = () => finish("cancelled");
const onTileLoadProgress = () => {
inspect();
if (!settled) scene.requestRender();
};
const onPostRender = () => {
renderFrames += 1;
inspect();
if (!settled && renderFrames < frameTarget) scene.requestRender();
};
removeProgressListener = globe.tileLoadProgressEvent.addEventListener(onTileLoadProgress);
removePostRenderListener = scene.postRender.addEventListener(onPostRender);
signal?.addEventListener("abort", onAbort, { once: true });
timeout = setTimeout(() => finish("timeout"), deadlineMs);
scene.requestRender();
});
}
/**
* Preserve independent failure domains while giving the base map first use
* of network and decode capacity. Each provider owns its error; viewport
* gates are scheduling hints and therefore never become provider failures.
*/
export async function runStagedMapProviders({
signal,
isCancelled,
providerInitializationTimeoutMs = MAP_PROVIDER_STARTUP_POLICY.providerInitializationTimeoutMs,
loadImagery,
waitAfterImagery,
loadTerrain,
waitAfterTerrain,
loadBuildings,
loadDeferred,
onProviderError,
onDeferredError,
}) {
const cancelled = () => Boolean(signal?.aborted || isCancelled?.());
const loadProvider = async (provider, load) => {
if (cancelled()) return false;
const stageAbort = new AbortController();
let timeout;
let rejectDeadline;
const abortStage = () => {
rejectDeadline?.(new Error(`${provider}_startup_cancelled`));
stageAbort.abort();
};
signal?.addEventListener("abort", abortStage, { once: true });
const deadline = new Promise((_, reject) => {
rejectDeadline = reject;
timeout = setTimeout(() => {
reject(new Error(`${provider}_startup_timeout`));
stageAbort.abort();
}, Math.max(0, Math.trunc(providerInitializationTimeoutMs)));
});
try {
await Promise.race([load({ signal: stageAbort.signal }), deadline]);
return !cancelled() && !stageAbort.signal.aborted;
} catch (error) {
if (!cancelled()) onProviderError?.(provider, error);
return false;
} finally {
if (timeout !== undefined) clearTimeout(timeout);
signal?.removeEventListener("abort", abortStage);
}
};
const waitForViewport = async (wait) => {
if (!wait || cancelled()) return;
try {
await wait();
} catch {
// A readiness gate controls ordering only. The provider's own error
// event remains the authority for availability and user diagnostics.
}
};
const imageryReady = await loadProvider("imagery", loadImagery);
if (imageryReady) await waitForViewport(waitAfterImagery);
const terrainReady = await loadProvider("terrain", loadTerrain);
if (terrainReady) await waitForViewport(waitAfterTerrain);
await loadProvider("buildings", loadBuildings);
if (loadDeferred && !cancelled()) {
try {
await loadDeferred();
} catch (error) {
if (!cancelled()) onDeferredError?.(error);
}
}
return cancelled() ? "cancelled" : "complete";
}
+1 -1
View File
@@ -51,7 +51,7 @@ export const defaultMapReferencePresentationProfiles: readonly MapPresentationPr
semanticTypes: [...definition.semanticTypes], semanticTypes: [...definition.semanticTypes],
label: { label: {
mode: "attributes", mode: "attributes",
fields: ["name", "official_name", "local_name"], fields: ["name", "official_name", "local_name", "alternate_names"],
fontWeight: 600, fontWeight: 600,
sizePx: 20, sizePx: 20,
color: "#cccccc", color: "#cccccc",
+250
View File
@@ -0,0 +1,250 @@
import type {
GraticuleSectorSummary,
LocalSectorSummary,
} from "./mapSectorGrid.mjs";
/**
* Provider-neutral scene settings consumed by a renderer adapter.
*
* The persisted Map Page contract may use these fields, but it never stores
* Cesium entities, provider endpoints, credentials or raw provider payloads.
*/
export type MapPresentation = {
imagerySource: "cesium-live";
imageryVisible: boolean;
cacheEnabled: boolean;
cacheNoOverwrite: boolean;
terrainEnabled: boolean;
terrainExaggeration: number;
monochrome: boolean;
monochromeColor: string;
imageryGamma: number;
imageryHue: number;
imageryAlpha: number;
globeColor: string;
backgroundColor: string;
atmosphereEnabled: boolean;
atmosphereHue: number;
atmosphereSaturation: number;
atmosphereBrightness: number;
fogEnabled: boolean;
fogDensity: number;
sunEnabled: boolean;
sunHour: number;
sunIntensity: number;
shadowsEnabled: boolean;
buildingsVisible: boolean;
buildingsColor: string;
buildingsOpacity: number;
buildingsDetail: number;
imageryBrightness: number;
imageryContrast: number;
imagerySaturation: number;
gridVisible: boolean;
gridLodEnabled: boolean;
grid3dEnabled: boolean;
gridGraticuleEnabled: boolean;
gridCenterMode: "fixed";
gridCenterLatitude: number;
gridCenterLongitude: number;
gridTileSizeKm: number;
gridAutoDisableHeightKm: number;
gridRebuildOnMoveEnd: boolean;
gridLegacyMode: boolean;
gridMax3dViewAngleDegrees: number;
gridHeightMeters: number;
gridLod1MaxHeightKm: number;
gridLod1StepKm: number;
gridLod1Mode: "3d" | "graticule";
gridLod2MaxHeightKm: number;
gridLod2StepKm: number;
gridLod2Mode: "3d" | "graticule";
gridLod3MaxHeightKm: number;
gridLod3StepKm: number;
gridLod3Mode: "3d" | "graticule";
gridLod4MaxHeightKm: number;
gridLod4StepKm: number;
gridLod4Mode: "3d" | "graticule";
gridLod5StepKm: number;
gridLod5MaxHeightKm: number;
gridLod5Mode: "3d" | "graticule";
gridRadiusKm: number;
gridLineWidth: number;
gridLineDiameterMeters: number;
gridColor: string;
gridOpacity: number;
gridDotsEnabled: boolean;
gridDotsSize: number;
gridDotsDiameterMeters: number;
gridDotsColor: string;
gridDotsOpacity: number;
gridCrossesEnabled: boolean;
gridCrossesLengthMeters: number;
gridCrossesWidthMeters: number;
gridCrossesColor: string;
gridCrossesOpacity: number;
gridLodProfiles: GridLodProfile[];
/** One-shot renderer command; it is intentionally excluded from persistence. */
cacheRefresh: boolean;
};
export type GridLodProfile = {
maxHeightKm: number;
stepKm: number;
mode: "3d" | "graticule";
heightMeters: number;
max3dViewAngleDegrees: number;
tileSizeKm: number;
radiusKm: number;
lineDiameterMeters: number;
lineColor: string;
lineOpacity: number;
dotsEnabled: boolean;
dotsDiameterMeters: number;
dotsColor: string;
dotsOpacity: number;
crossesEnabled: boolean;
crossesLengthMeters: number;
crossesWidthMeters: number;
crossesColor: string;
crossesOpacity: number;
graticuleStepDegrees: number;
graticuleLineWidthPx: number;
graticuleColor: string;
graticuleOpacity: number;
majorLinesEnabled: boolean;
majorLabelsEnabled: boolean;
majorLineWidthMultiplier: number;
volumeEnabled: boolean;
volumeMinimumHeightMeters: number;
volumeMaximumHeightMeters: number;
volumeBandHeightMeters: number;
selectionFillColor: string;
selectionFillOpacityPercent: number;
selectionOutlineColor: string;
selectionOutlineWidthPx: number;
selectionOutlineOpacityPercent: number;
};
export type GridVolumeSelection = {
id: string;
index: number;
floor: number;
ceiling: number;
bandHeight: number;
};
export type LocalGridSectorSelection = LocalSectorSummary & {
mode: "3d";
units: "meters-enu";
volume: GridVolumeSelection | null;
};
export type GraticuleGridSectorSelection = GraticuleSectorSummary & {
mode: "graticule";
units: "degrees-wgs84";
volume: null;
};
export type GridSectorSelection = LocalGridSectorSelection | GraticuleGridSectorSelection;
export type GridMajorTileSelection = NonNullable<GridSectorSelection["parentMajorTile"]>;
export type MapCameraView = {
longitude: number;
latitude: number;
height: number;
heading: number;
pitch: number;
roll: number;
};
export type CameraSpiralConfig = {
heightAboveGroundMeters: number;
speedMetersPerSecond: number;
pitchMetersPerTurn: number;
targetRadiusMeters?: number;
viewPitchRadians?: number;
waitForTiles?: boolean;
};
export type CameraSpiralState = {
running: boolean;
reason?: "stopped" | "mode_disabled" | "renderer_restarted" | "page_hidden" | "render_error" | "spiral_extent_limit" | "spiral_runtime_error" | "terrain_sampling_error" | "tile_loading_timeout" | "tile_loading_error" | "target_radius_reached";
};
export type MapGatewayHealth = {
cache?: {
mode?: string;
writePolicy?: string;
entries?: number;
bytes?: number;
maxBytes?: number;
atCapacity?: boolean;
persistent?: boolean;
byResourceKind?: Record<string, { entries?: number; bytes?: number }>;
};
diagnostics?: {
cacheHits?: number;
cacheMisses?: number;
cacheRefreshes?: number;
upstreamRequests?: number;
egressRequests?: number;
upstreamFailures?: number;
slowUpstreamRequests?: number;
lastFailure?: string | null;
lastFailureAt?: string | null;
};
referenceSources?: {
transportStations?: {
profileId?: string;
seedFactCount?: number;
fetchEnabled?: boolean;
cellDegrees?: number;
cachedCellCount?: number;
indexedFactCount?: number;
upstreamRequests?: number;
upstreamFailures?: number;
searchRequests?: number;
searchFailures?: number;
upstreamState?: "idle" | "ready" | "degraded";
activeFetches?: number;
queuedFetches?: number;
lastRefreshAt?: string | null;
lastFailure?: string | null;
lastFailureAt?: string | null;
};
};
providerCache?: Array<{
assetId?: number;
type?: string;
cachedEndpoint?: boolean;
credentialUsable?: boolean;
entryPointCached?: boolean;
}>;
ionConfigured?: boolean;
};
export type MapProviderState = "loading" | "ready" | "error" | "not-configured";
export type MapProviderStatus = {
imagery: MapProviderState;
terrain: MapProviderState;
buildings: MapProviderState;
errors: Partial<Record<"imagery" | "terrain" | "buildings" | "projection", string>>;
};
/** Imperative adapter surface; callers never receive a Cesium object. */
export type MapRendererHandle = {
startSpiralAnimation: (config: CameraSpiralConfig) => boolean;
stopSpiralAnimation: (reason?: CameraSpiralState["reason"]) => void;
getCameraView: () => MapCameraView | null;
fitRuntimeEntities: (entityIds?: string[]) => boolean;
focusRuntimeEntity: (entityId: string) => boolean;
focusSubjectCoordinates: (longitude: number, latitude: number) => boolean;
focusCoordinates: (longitude: number, latitude: number) => boolean;
focusGridSector: (sector: GridSectorSelection) => boolean;
focusGridMajorTile: (tile: GridMajorTileSelection) => boolean;
};
/** @deprecated Use the provider-neutral MapRendererHandle name. */
export type CesiumMapRendererHandle = MapRendererHandle;
@@ -0,0 +1,4 @@
import type { MapRuntimeFact } from "./useMapDataProductRuntime.js";
export function mapRuntimeFactKey(fact: Pick<MapRuntimeFact, "sourceId" | "semanticType">): string;
export function mapRuntimeEntityId(bindingId: string, fact: Pick<MapRuntimeFact, "sourceId" | "semanticType">): string;
+12
View File
@@ -0,0 +1,12 @@
/** Stable Data Product fact identity: semantic type plus source id. */
export function mapRuntimeFactKey(fact) {
return `${fact.semanticType}\u0000${fact.sourceId}`;
}
/**
* Stable renderer adapter identity. It never depends on a provider message id,
* display label, current geometry or transient Cesium entity instance.
*/
export function mapRuntimeEntityId(bindingId, fact) {
return `nodedc-runtime:${bindingId}:${fact.semanticType}:${fact.sourceId}`;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { MapDataProductBinding } from "./MapFixturePreview.js"; import type { MapDataProductBinding } from "./mapPageContract.js";
import type { MapPresentationProfile } from "./mapPresentationProfile.js"; import type { MapPresentationProfile } from "./mapPresentationProfile.js";
import type { MapRuntimeBinding } from "./useMapDataProductRuntime.js"; import type { MapRuntimeBinding } from "./useMapDataProductRuntime.js";
+18
View File
@@ -0,0 +1,18 @@
import type { GridSectorSelection } from "./mapRendererContract.js";
import type { SectorGridLodProfile } from "./mapSectorWorkspace.js";
import type { MapRuntimeFact } from "./useMapDataProductRuntime.js";
export const MAP_SCOPE_PROVIDER_FIELD: "position_source";
export const MAP_SCOPE_OBJECT_KIND_FIELD: "object_kind";
export const MAP_SCOPE_MISSING_VALUE: "__nodedc_missing__";
export function normalizedSectorScopeValue(value: unknown): string | null;
export function sectorScopeValueLabel(value: string): string;
export function mapFactSectorScopeValue(fact: MapRuntimeFact, field: string): string;
export function mapFactPointCoordinates(fact: MapRuntimeFact | undefined): [number, number] | null;
export function mapFactInsideGridSector(
fact: MapRuntimeFact,
selection: GridSectorSelection,
profiles: SectorGridLodProfile[],
origin: { latitude: number; longitude: number },
): boolean;
+49
View File
@@ -0,0 +1,49 @@
import { graticuleSectorAt, localSectorAtGeodetic } from "./mapSectorGrid.mjs";
export const MAP_SCOPE_PROVIDER_FIELD = "position_source";
export const MAP_SCOPE_OBJECT_KIND_FIELD = "object_kind";
export const MAP_SCOPE_MISSING_VALUE = "__nodedc_missing__";
export function normalizedSectorScopeValue(value) {
if (typeof value !== "string") return null;
const normalized = value.trim();
if (!normalized || normalized.length > 120 || /[\u0000-\u001f\u007f]/.test(normalized)) return null;
return normalized;
}
export function sectorScopeValueLabel(value) {
if (value === MAP_SCOPE_MISSING_VALUE) return "Не указано";
return value.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
}
export function mapFactSectorScopeValue(fact, field) {
return normalizedSectorScopeValue(fact.attributes[field]) ?? MAP_SCOPE_MISSING_VALUE;
}
export function mapFactPointCoordinates(fact) {
if (fact?.geometry?.type !== "Point") return null;
const [longitude, latitude] = fact.geometry.coordinates;
return Number.isFinite(longitude) && longitude >= -180 && longitude <= 180
&& Number.isFinite(latitude) && latitude >= -90 && latitude <= 90
? [longitude, latitude]
: null;
}
export function mapFactInsideGridSector(fact, selection, profiles, origin) {
if (fact.geometry?.type !== "Point") return false;
const [longitude, latitude] = fact.geometry.coordinates;
const profile = profiles[selection.lod - 1];
if (!profile || profile.mode !== selection.mode) return false;
if (selection.mode === "graticule") {
return graticuleSectorAt({ longitude, latitude }, {
lod: selection.lod,
stepDegrees: profile.graticuleStepDegrees,
}).id === selection.id;
}
return localSectorAtGeodetic({ longitude, latitude }, {
lod: selection.lod,
originLatitude: origin.latitude,
originLongitude: origin.longitude,
stepMeters: profile.stepKm * 1_000,
}).id === selection.id;
}
+272
View File
@@ -0,0 +1,272 @@
import { gridLodProfile } from "./mapGridPolicy.mjs";
import {
graticuleSectorAt,
graticuleSectorSummary,
localSectorAt,
localSectorSummary,
localVolumeAt,
type GraticuleSectorAddress,
type LocalSectorAddress,
} from "./mapSectorGrid.mjs";
import { initialMapSettings, type MapPageSettings } from "./mapPageContract.js";
import type {
GridLodProfile,
GridSectorSelection,
MapPresentation,
} from "./mapRendererContract.js";
export {
MAP_SCOPE_MISSING_VALUE,
MAP_SCOPE_OBJECT_KIND_FIELD,
MAP_SCOPE_PROVIDER_FIELD,
mapFactInsideGridSector,
mapFactPointCoordinates,
mapFactSectorScopeValue,
normalizedSectorScopeValue,
sectorScopeValueLabel,
} from "./mapSectorRuntime.mjs";
export type SectorGridLodProfile = GridLodProfile & {
majorLinesEnabled: boolean;
majorLabelsEnabled: boolean;
majorLineWidthMultiplier: number;
selectionFillColor: string;
selectionFillOpacityPercent: number;
selectionOutlineColor: string;
selectionOutlineWidthPx: number;
selectionOutlineOpacityPercent: number;
volumeEnabled: boolean;
volumeMinimumHeightMeters: number;
volumeMaximumHeightMeters: number;
volumeBandHeightMeters: number;
};
export type GridSectorDirection = "north" | "east" | "south" | "west";
export const GRID_SECTOR_DIRECTIONS: Array<{ id: GridSectorDirection; label: string }> = [
{ id: "north", label: "Север" },
{ id: "east", label: "Восток" },
{ id: "south", label: "Юг" },
{ id: "west", label: "Запад" },
];
export function normalizedMajorTileSizeKm(stepKm: number, requestedTileSizeKm: number) {
const safeStepKm = Math.min(50, Math.max(0.1, stepKm));
const maximumRatio = Math.max(1, Math.floor((50 + Number.EPSILON) / safeStepKm));
const requestedRatio = Math.max(1, Math.ceil((requestedTileSizeKm - Number.EPSILON) / safeStepKm));
const ratio = Math.min(maximumRatio, requestedRatio);
return Number((safeStepKm * ratio).toFixed(6));
}
export function normalizedGraticuleStepDegrees(requestedStepDegrees: number) {
const safeStepDegrees = Math.min(10, Math.max(0.1, requestedStepDegrees));
const requestedDivisions = Math.max(1, Math.round(180 / safeStepDegrees));
// Five minor intervals form one major tile and every 90° quadrant must end
// on a major boundary. A hemisphere therefore uses a multiple of ten.
const hemisphereDivisions = Math.max(10, Math.round(requestedDivisions / 10) * 10);
return 180 / hemisphereDivisions;
}
export function graticuleMajorStepDegrees(stepDegrees: number) {
const candidate = stepDegrees * 5;
const quadrantBands = 90 / candidate;
return Math.abs(quadrantBands - Math.round(quadrantBands)) <= 1e-9 * Math.max(1, Math.abs(quadrantBands))
? candidate
: null;
}
export function normalizeSectorGridLodProfile(profile: SectorGridLodProfile): SectorGridLodProfile {
const stepKm = profile.mode === "3d" ? Math.min(50, profile.stepKm) : profile.stepKm;
const volumeMinimumHeightMeters = profile.volumeMinimumHeightMeters;
const volumeMaximumHeightMeters = Math.max(volumeMinimumHeightMeters + 1, profile.volumeMaximumHeightMeters);
return {
...profile,
stepKm,
tileSizeKm: profile.mode === "3d"
? normalizedMajorTileSizeKm(stepKm, Math.max(stepKm, profile.tileSizeKm))
: profile.tileSizeKm,
graticuleStepDegrees: profile.mode === "graticule"
? normalizedGraticuleStepDegrees(profile.graticuleStepDegrees)
: profile.graticuleStepDegrees,
majorLabelsEnabled: profile.majorLinesEnabled && profile.majorLabelsEnabled,
volumeEnabled: profile.mode === "3d" && profile.volumeEnabled,
volumeMinimumHeightMeters,
volumeMaximumHeightMeters,
volumeBandHeightMeters: Math.min(
volumeMaximumHeightMeters - volumeMinimumHeightMeters,
Math.max(1, profile.volumeBandHeightMeters),
),
};
}
export function resolveGridLodProfiles(settings?: Partial<MapPageSettings>): GridLodProfile[] {
// Layouts saved by the previous flat contract retain their tuned values.
// The next ordinary page save persists the canonical five-profile array.
const legacySettings: MapPresentation = {
...initialMapSettings,
...settings,
gridLodProfiles: Array.isArray(settings?.gridLodProfiles) ? settings.gridLodProfiles : [],
cacheRefresh: false,
};
return Array.from({ length: 5 }, (_unused, index) => normalizeSectorGridLodProfile(
gridLodProfile(legacySettings, index) as SectorGridLodProfile,
));
}
export function localGridSectorSelection(
address: LocalSectorAddress,
profile: SectorGridLodProfile,
origin: { latitude: number; longitude: number },
preferredAltitudeMeters?: number,
): GridSectorSelection {
const definition = {
lod: address.lod,
originLatitude: origin.latitude,
originLongitude: origin.longitude,
stepMeters: profile.stepKm * 1_000,
tileSizeMeters: profile.tileSizeKm * 1_000,
};
const summary = localSectorSummary(address, definition);
const volumeSpan = profile.volumeMaximumHeightMeters - profile.volumeMinimumHeightMeters;
const volume = profile.volumeEnabled && volumeSpan > 0
? (() => {
const altitudeMeters = Math.min(
profile.volumeMaximumHeightMeters - Number.EPSILON,
Math.max(
profile.volumeMinimumHeightMeters,
preferredAltitudeMeters ?? profile.volumeMinimumHeightMeters + Math.min(profile.volumeBandHeightMeters, volumeSpan) / 2,
),
);
const volumeAddress = localVolumeAt({ ...summary.center, altitudeMeters }, {
lod: address.lod,
originLatitude: origin.latitude,
originLongitude: origin.longitude,
stepMeters: profile.stepKm * 1_000,
altitudeFloorMeters: profile.volumeMinimumHeightMeters,
altitudeCeilingMeters: profile.volumeMaximumHeightMeters,
altitudeBandMeters: profile.volumeBandHeightMeters,
});
if (!volumeAddress) return null;
return {
id: volumeAddress.id,
index: volumeAddress.bandIndex,
floor: Math.max(profile.volumeMinimumHeightMeters, volumeAddress.altitudeFloorMeters),
ceiling: Math.min(profile.volumeMaximumHeightMeters, volumeAddress.altitudeCeilingMeters),
bandHeight: volumeAddress.altitudeBandMeters,
};
})()
: null;
return {
...summary,
mode: "3d",
address,
units: "meters-enu",
volume,
};
}
export function graticuleGridSectorSelection(
address: GraticuleSectorAddress,
profile: SectorGridLodProfile,
): GridSectorSelection {
const majorStepDegrees = profile.majorLinesEnabled
? graticuleMajorStepDegrees(profile.graticuleStepDegrees) ?? undefined
: undefined;
return {
...graticuleSectorSummary(address, {
lod: address.lod,
stepDegrees: profile.graticuleStepDegrees,
majorStepDegrees,
}),
mode: "graticule",
address,
units: "degrees-wgs84",
volume: null,
};
}
export function gridSectorNeighborSelection(
selection: GridSectorSelection,
direction: GridSectorDirection,
profiles: SectorGridLodProfile[],
origin: { latitude: number; longitude: number },
) {
const profile = profiles[selection.lod - 1];
if (!profile) return null;
if (selection.mode === "3d") {
const neighbor = selection.neighbors[direction];
const preferredAltitudeMeters = selection.volume
? (selection.volume.floor + selection.volume.ceiling) / 2
: undefined;
return neighbor ? localGridSectorSelection(neighbor.address, profile, origin, preferredAltitudeMeters) : null;
}
const neighbor = selection.neighbors[direction];
return neighbor ? graticuleGridSectorSelection(neighbor.address, profile) : null;
}
export function gridSectorParentLodSelection(
selection: GridSectorSelection,
profiles: SectorGridLodProfile[],
origin: { latitude: number; longitude: number },
) {
const parentProfile = profiles[selection.lod];
if (!parentProfile || parentProfile.mode !== selection.mode) return null;
if (selection.mode === "3d") {
const address = localSectorAt(selection.center, {
lod: selection.lod + 1,
originLatitude: origin.latitude,
originLongitude: origin.longitude,
stepMeters: parentProfile.stepKm * 1_000,
});
const preferredAltitudeMeters = selection.volume
? (selection.volume.floor + selection.volume.ceiling) / 2
: undefined;
return localGridSectorSelection(address, parentProfile, origin, preferredAltitudeMeters);
}
return graticuleGridSectorSelection(graticuleSectorAt(selection.center, {
lod: selection.lod + 1,
stepDegrees: parentProfile.graticuleStepDegrees,
}), parentProfile);
}
export function gridSectorVolumeNeighborSelection(
selection: GridSectorSelection,
direction: "above" | "below",
profile: SectorGridLodProfile | null,
origin: { latitude: number; longitude: number },
) {
if (selection.mode !== "3d" || !selection.volume || !profile?.volumeEnabled) return null;
const targetIndex = selection.volume.index + (direction === "above" ? 1 : -1);
const targetFloorMeters = profile.volumeMinimumHeightMeters + targetIndex * profile.volumeBandHeightMeters;
if (targetIndex < 0 || targetFloorMeters >= profile.volumeMaximumHeightMeters) return null;
const targetCeilingMeters = Math.min(
profile.volumeMaximumHeightMeters,
targetFloorMeters + profile.volumeBandHeightMeters,
);
return localGridSectorSelection(
selection.address,
profile,
origin,
(targetFloorMeters + targetCeilingMeters) / 2,
);
}
const formatGridMetric = (value: number, maximumFractionDigits = 1) => value.toLocaleString("ru-RU", {
maximumFractionDigits,
});
export const formatGridSectorArea = (areaSquareMeters: number) => areaSquareMeters >= 1_000_000
? `${formatGridMetric(areaSquareMeters / 1_000_000, areaSquareMeters >= 1_000_000_000 ? 0 : 2)} км²`
: `${formatGridMetric(areaSquareMeters, 0)} м²`;
export function gridSectorBoundsLabel(selection: GridSectorSelection) {
const { west, east, south, north } = selection.bounds;
return selection.mode === "3d"
? `E ${formatGridMetric(west)}${formatGridMetric(east)} м · N ${formatGridMetric(south)}${formatGridMetric(north)} м`
: `λ ${formatGridMetric(west, 6)}${formatGridMetric(east, 6)}° · φ ${formatGridMetric(south, 6)}${formatGridMetric(north, 6)}°`;
}
export function gridSectorCenterLabel(selection: GridSectorSelection) {
return selection.mode === "3d"
? `E ${formatGridMetric(selection.center.eastMeters)} м · N ${formatGridMetric(selection.center.northMeters)} м`
: `${formatGridMetric(selection.center.latitude, 6)}°, ${formatGridMetric(selection.center.longitude, 6)}°`;
}
+53
View File
@@ -0,0 +1,53 @@
import type { MapDataProductBinding, MapSubjectState } from "./mapPageContract.js";
import type { MapPresentationFilters, MapPresentationProfile } from "./mapPresentationProfile.js";
import type { GridSectorSelection } from "./mapRendererContract.js";
import type { SectorGridLodProfile } from "./mapSectorWorkspace.js";
import type { MapRuntimeBinding, MapRuntimeFact } from "./useMapDataProductRuntime.js";
export type MapSelectableEntity = {
id: string;
title: string;
kind: string;
status: string;
bindingId: string;
dataProductId: string;
fact: MapRuntimeFact;
};
export type MapSectorScope = {
excludedBindingIds: string[];
excludedProviders: string[];
excludedObjectKinds: string[];
};
export type MapPresentationSummary = {
bindingId: string;
displayName: string;
profile: MapPresentationProfile;
total: number;
counts: Record<string, Record<string, number>>;
};
export type MapFilteredTarget = {
bindingId: string;
entityId: string;
title: string;
status: string;
renderable: boolean;
};
export function mapProfileHasSubjectWindowControls(profile: MapPresentationProfile): boolean;
export function primaryMapBindingConfigs(bindingConfigs: MapDataProductBinding[]): MapDataProductBinding[];
export function primaryMapRuntimeBindings(runtimeBindings: MapRuntimeBinding[], bindingConfigs: MapDataProductBinding[]): MapRuntimeBinding[];
export function buildMapPresentationFilters(subjectStates: Record<string, MapSubjectState>, bindingConfigs: MapDataProductBinding[], profiles: MapPresentationProfile[]): MapPresentationFilters;
export function buildSelectableMapEntities(runtimeBindings: MapRuntimeBinding[], bindingConfigs: MapDataProductBinding[], profiles: MapPresentationProfile[]): MapSelectableEntity[];
export function buildSectorSpatialEntities(selectable: MapSelectableEntity[], selection: GridSectorSelection | null, profiles: SectorGridLodProfile[], origin: { latitude: number; longitude: number }): MapSelectableEntity[];
export function buildSectorBindingOptions(bindingConfigs: MapDataProductBinding[], sectorEntities: MapSelectableEntity[]): Array<{ value: string; label: string; count: number }>;
export function bindingProjectsField(bindingConfigs: MapDataProductBinding[], field: string): boolean;
export function buildSectorScopeOptions(sectorEntities: MapSelectableEntity[], field: string, enabled?: boolean): Array<{ value: string; label: string; count: number }>;
export function sectorEntityIsExcluded(entity: MapSelectableEntity, scope: MapSectorScope): boolean;
export function buildSectorVisibleEntities(input: { sectorEntities: MapSelectableEntity[]; bindingConfigs: MapDataProductBinding[]; presentationProfiles: MapPresentationProfile[]; presentationFilters: MapPresentationFilters; scope: MapSectorScope }): MapSelectableEntity[];
export function scopeMapRuntimeBindings(input: { runtimeBindings: MapRuntimeBinding[]; selection: GridSectorSelection | null; gridProfiles: SectorGridLodProfile[]; origin: { latitude: number; longitude: number }; hideOutsideSector: boolean; scope: MapSectorScope }): MapRuntimeBinding[];
export function buildMapPresentationSummaries(bindingConfigs: MapDataProductBinding[], runtimeBindings: MapRuntimeBinding[], profiles: MapPresentationProfile[]): MapPresentationSummary[];
export function buildFilteredMapTargets(runtimeBindings: MapRuntimeBinding[], bindingConfigs: MapDataProductBinding[], profiles: MapPresentationProfile[], filters: MapPresentationFilters): MapFilteredTarget[];
export function planMapSubjectReveal(input: { entity: MapSelectableEntity; subjectState: MapSubjectState; profile: MapPresentationProfile | undefined; selectedSector: GridSectorSelection | null; gridProfiles: SectorGridLodProfile[]; origin: { latitude: number; longitude: number }; hideOutsideSector: boolean; scope: MapSectorScope }): { subjectState: MapSubjectState; hideOutsideSector: boolean; scope: MapSectorScope };
+256
View File
@@ -0,0 +1,256 @@
import {
compareMapRuntimeFacts,
mapFactMatchesFilters,
mapPresentationFacetCounts,
mapPresentationProfileForFact,
mapRuntimeDisplayLabel,
mapRuntimeFactIsRenderable,
normalizeMapPresentationFacetSelections,
resolveMapPresentationClass,
} from "./mapPresentationProfile.mjs";
import { mapRuntimeEntityId } from "./mapRuntimeIdentity.mjs";
import {
MAP_SCOPE_OBJECT_KIND_FIELD,
MAP_SCOPE_PROVIDER_FIELD,
mapFactInsideGridSector,
mapFactSectorScopeValue,
sectorScopeValueLabel,
} from "./mapSectorRuntime.mjs";
export function mapProfileHasSubjectWindowControls(profile) {
return profile.facets.some((facet) => facet.counter || facet.filterable);
}
/** Primary bindings create map subjects; joined aspects only enrich them. */
export function primaryMapBindingConfigs(bindingConfigs) {
return [...bindingConfigs]
.filter((binding) => !binding.joinToBindingId)
.sort(bindingOrder);
}
export function primaryMapRuntimeBindings(runtimeBindings, bindingConfigs) {
const runtimeById = new Map(runtimeBindings.map((binding) => [binding.bindingId, binding]));
return primaryMapBindingConfigs(bindingConfigs)
.map((binding) => runtimeById.get(binding.id))
.filter(Boolean);
}
export function buildMapPresentationFilters(subjectStates, bindingConfigs, profiles) {
return Object.fromEntries(Object.entries(subjectStates).map(([bindingId, state]) => {
const binding = bindingConfigs.find((candidate) => candidate.id === bindingId);
const profile = mapPresentationProfileForFact(
profiles,
binding?.presentationProfileId,
binding?.semanticTypes?.[0] ?? "",
);
return [bindingId, {
visible: state.visible,
facets: profile ? normalizeMapPresentationFacetSelections(state.filters, profile) : state.filters,
}];
}));
}
export function buildSelectableMapEntities(runtimeBindings, bindingConfigs, profiles) {
const configById = new Map(bindingConfigs.map((binding) => [binding.id, binding]));
return primaryMapRuntimeBindings(runtimeBindings, bindingConfigs).flatMap((binding) => {
const bindingConfig = configById.get(binding.bindingId);
const facts = [...binding.facts];
const primaryProfile = mapPresentationProfileForFact(
profiles,
bindingConfig?.presentationProfileId,
bindingConfig?.semanticTypes?.[0] ?? facts[0]?.semanticType ?? "",
);
if (primaryProfile) facts.sort((left, right) => compareMapRuntimeFacts(left, right, primaryProfile));
return facts.map((fact) => {
const profile = mapPresentationProfileForFact(profiles, bindingConfig?.presentationProfileId, fact.semanticType);
const presentationClass = profile ? resolveMapPresentationClass(fact, profile) : undefined;
return {
id: mapRuntimeEntityId(binding.bindingId, fact),
title: mapRuntimeDisplayLabel(fact, profile),
kind: fact.semanticType,
status: presentationClass?.label ?? fact.presentationStatus,
bindingId: binding.bindingId,
dataProductId: binding.dataProductId,
fact,
};
});
});
}
export function buildSectorSpatialEntities(selectable, selection, profiles, origin) {
return selection
? selectable.filter((entity) => mapFactInsideGridSector(entity.fact, selection, profiles, origin))
: [];
}
export function buildSectorBindingOptions(bindingConfigs, sectorEntities) {
return primaryMapBindingConfigs(bindingConfigs).map((binding) => ({
value: binding.id,
label: binding.displayName?.trim() || binding.id,
count: sectorEntities.filter((entity) => entity.bindingId === binding.id).length,
}));
}
export function bindingProjectsField(bindingConfigs, field) {
return bindingConfigs.some((binding) => !binding.joinToBindingId && binding.fieldProjection.includes(field));
}
export function buildSectorScopeOptions(sectorEntities, field, enabled = true) {
if (!enabled) return [];
const counts = new Map();
for (const { fact } of sectorEntities) {
const value = mapFactSectorScopeValue(fact, field);
counts.set(value, (counts.get(value) ?? 0) + 1);
}
return [...counts]
.map(([value, count]) => ({ value, count, label: sectorScopeValueLabel(value) }))
.sort((left, right) => left.label.localeCompare(right.label, "ru"));
}
export function sectorEntityIsExcluded(entity, scope) {
return scope.excludedBindingIds.includes(entity.bindingId)
|| scope.excludedProviders.includes(mapFactSectorScopeValue(entity.fact, MAP_SCOPE_PROVIDER_FIELD))
|| scope.excludedObjectKinds.includes(mapFactSectorScopeValue(entity.fact, MAP_SCOPE_OBJECT_KIND_FIELD));
}
export function buildSectorVisibleEntities({
sectorEntities,
bindingConfigs,
presentationProfiles,
presentationFilters,
scope,
}) {
const configById = new Map(bindingConfigs.map((binding) => [binding.id, binding]));
return sectorEntities.filter((entity) => {
if (sectorEntityIsExcluded(entity, scope)) return false;
const binding = configById.get(entity.bindingId);
const profile = mapPresentationProfileForFact(
presentationProfiles,
binding?.presentationProfileId,
entity.fact.semanticType,
);
return Boolean(profile && mapFactMatchesFilters(
entity.fact,
profile,
presentationFilters,
entity.bindingId,
));
});
}
export function scopeMapRuntimeBindings({
runtimeBindings,
selection,
gridProfiles,
origin,
hideOutsideSector,
scope,
}) {
if (!selection) return runtimeBindings;
return runtimeBindings.map((binding) => ({
...binding,
facts: binding.facts.filter((fact) => {
if (scope.excludedBindingIds.includes(binding.bindingId)) return false;
if (scope.excludedProviders.includes(mapFactSectorScopeValue(fact, MAP_SCOPE_PROVIDER_FIELD))) return false;
if (scope.excludedObjectKinds.includes(mapFactSectorScopeValue(fact, MAP_SCOPE_OBJECT_KIND_FIELD))) return false;
return !hideOutsideSector || mapFactInsideGridSector(fact, selection, gridProfiles, origin);
}),
}));
}
export function buildMapPresentationSummaries(bindingConfigs, runtimeBindings, profiles) {
const runtimeById = new Map(runtimeBindings.map((binding) => [binding.bindingId, binding]));
return primaryMapBindingConfigs(bindingConfigs).flatMap((bindingConfig) => {
const binding = runtimeById.get(bindingConfig.id);
const facts = binding?.facts ?? [];
const semanticType = bindingConfig.semanticTypes[0] ?? facts[0]?.semanticType ?? "";
const profile = mapPresentationProfileForFact(profiles, bindingConfig.presentationProfileId, semanticType);
return profile ? [{
bindingId: bindingConfig.id,
displayName: bindingConfig.displayName?.trim() || profile.title || bindingConfig.id,
profile,
total: facts.length,
counts: mapPresentationFacetCounts(facts, profile),
}] : [];
});
}
/**
* Object overview order is the same declared profile order used by facets and
* renderer subjects. A title-only resort must not silently override MCP sort.
*/
export function buildFilteredMapTargets(runtimeBindings, bindingConfigs, profiles, filters) {
return buildSelectableMapEntities(runtimeBindings, bindingConfigs, profiles).flatMap((entity) => {
const binding = bindingConfigs.find((candidate) => candidate.id === entity.bindingId);
const profile = mapPresentationProfileForFact(profiles, binding?.presentationProfileId, entity.fact.semanticType);
if (!profile || !mapFactMatchesFilters(entity.fact, profile, filters, entity.bindingId)) return [];
return [{
bindingId: entity.bindingId,
entityId: entity.id,
title: entity.title,
status: resolveMapPresentationClass(entity.fact, profile)?.label ?? "",
renderable: mapRuntimeFactIsRenderable(entity.fact, profile),
}];
});
}
/**
* Search reveal changes only constraints that hide the selected subject. The
* active sector identity is preserved; outside-sector clipping is relaxed only
* when needed so focus and the rendered subject cannot diverge.
*/
export function planMapSubjectReveal({
entity,
subjectState,
profile,
selectedSector,
gridProfiles,
origin,
hideOutsideSector,
scope,
}) {
let filters = { ...(subjectState?.filters ?? {}) };
for (const facet of profile?.facets ?? []) {
const selected = filters[facet.field];
if (selected === undefined) continue;
const factValue = normalizedFacetValue(entity.fact.attributes[facet.field]);
const available = facet.values.map((item) => item.value);
if (!available.includes(factValue)) {
delete filters[facet.field];
continue;
}
filters[facet.field] = available.filter((value) => selected.includes(value) || value === factValue);
}
if (profile) filters = normalizeMapPresentationFacetSelections(filters, profile);
if (profile && !mapFactMatchesFilters(entity.fact, profile, {
[entity.bindingId]: { visible: true, facets: filters },
}, entity.bindingId)) filters = {};
const provider = mapFactSectorScopeValue(entity.fact, MAP_SCOPE_PROVIDER_FIELD);
const objectKind = mapFactSectorScopeValue(entity.fact, MAP_SCOPE_OBJECT_KIND_FIELD);
const insideSector = selectedSector
? mapFactInsideGridSector(entity.fact, selectedSector, gridProfiles, origin)
: true;
return {
subjectState: {
...subjectState,
bindingId: entity.bindingId,
visible: true,
filters,
},
hideOutsideSector: hideOutsideSector && (!selectedSector || insideSector),
scope: {
excludedBindingIds: scope.excludedBindingIds.filter((value) => value !== entity.bindingId),
excludedProviders: scope.excludedProviders.filter((value) => value !== provider),
excludedObjectKinds: scope.excludedObjectKinds.filter((value) => value !== objectKind),
},
};
}
function bindingOrder(left, right) {
return (left.order ?? 0) - (right.order ?? 0) || left.id.localeCompare(right.id);
}
function normalizedFacetValue(value) {
return typeof value === "string" ? value.trim().toLowerCase() : "unknown";
}
+56
View File
@@ -0,0 +1,56 @@
import type { WorkspaceWindowRect } from "@nodedc/ui-react";
import type { MapSubjectState, MapWorkspaceWindowId } from "./mapPageContract.js";
import type { GridSectorSelection } from "./mapRendererContract.js";
import type { MapSectorScope } from "./mapWorkspaceModel.mjs";
export type MapWorkspaceState = {
selectedEntityId?: string;
selectedSector: GridSectorSelection | null;
subjectStates: Record<string, MapSubjectState>;
activeWindowId?: MapWorkspaceWindowId;
sector: {
window: { rect: WorkspaceWindowRect; maximized: boolean; zIndex: number };
hideOutside: boolean;
scope: MapSectorScope;
};
subjectCard: {
open: boolean;
rect: WorkspaceWindowRect;
maximized: boolean;
zIndex: number;
tabId: string;
};
};
export type MapWorkspaceAction =
| { type: "reset-sector-definition" }
| { type: "deactivate-sector" }
| { type: "set-sector-selection"; selection: GridSectorSelection | null }
| { type: "select-sector"; selection: GridSectorSelection | null }
| { type: "set-sector-hide-outside"; value: boolean }
| { type: "set-sector-scope"; scope: MapSectorScope }
| { type: "set-sector-scope-enabled"; dimension: "binding" | "provider" | "object-kind"; value: string; enabled: boolean }
| { type: "set-sector-window-rect"; rect: WorkspaceWindowRect }
| { type: "set-sector-window-maximized"; value: boolean }
| { type: "toggle-subject-filter"; bindingId: string; field: string; value: string; availableValues: string[] }
| { type: "toggle-subject-visibility"; bindingId: string }
| { type: "replace-subject-state"; bindingId: string; subjectState: MapSubjectState }
| { type: "open-subject-window"; bindingId: string }
| { type: "close-subject-window"; bindingId: string }
| { type: "set-subject-window-rect"; bindingId: string; rect: WorkspaceWindowRect }
| { type: "set-subject-window-maximized"; bindingId: string; value: boolean }
| { type: "activate-window"; windowId: MapWorkspaceWindowId }
| { type: "clear-active-window"; windowId: MapWorkspaceWindowId }
| { type: "select-entity"; entityId: string; validTabIds: string[]; defaultTabId: string }
| { type: "close-subject-card" }
| { type: "set-subject-card-rect"; rect: WorkspaceWindowRect }
| { type: "set-subject-card-maximized"; value: boolean }
| { type: "set-subject-card-tab"; tabId: string }
| { type: "reveal-subject"; bindingId: string; subjectState: MapSubjectState; hideOutsideSector: boolean; scope: MapSectorScope };
export function createMapWorkspaceState(input: {
subjectStates: Record<string, MapSubjectState>;
sectorWindowRect: WorkspaceWindowRect;
subjectCardRect: WorkspaceWindowRect;
}): MapWorkspaceState;
export function mapWorkspaceReducer(state: MapWorkspaceState, action: MapWorkspaceAction): MapWorkspaceState;
+223
View File
@@ -0,0 +1,223 @@
import { toggleMapPresentationFacetSelection } from "./mapPresentationProfile.mjs";
export function createMapWorkspaceState({ subjectStates, sectorWindowRect, subjectCardRect }) {
return {
selectedEntityId: undefined,
selectedSector: null,
subjectStates,
activeWindowId: undefined,
sector: {
window: { rect: sectorWindowRect, maximized: false, zIndex: 142 },
hideOutside: false,
scope: {
excludedBindingIds: [],
excludedProviders: [],
excludedObjectKinds: [],
},
},
subjectCard: {
open: false,
rect: subjectCardRect,
maximized: false,
zIndex: 140,
tabId: "overview",
},
};
}
export function mapWorkspaceReducer(state, action) {
switch (action.type) {
case "reset-sector-definition":
case "deactivate-sector":
return {
...state,
selectedSector: null,
activeWindowId: state.activeWindowId === "sector" ? undefined : state.activeWindowId,
sector: {
...state.sector,
scope: { excludedBindingIds: [], excludedProviders: [], excludedObjectKinds: [] },
},
};
case "set-sector-selection":
return { ...state, selectedSector: action.selection };
case "select-sector":
return action.selection
? activateWindow({ ...state, selectedSector: action.selection }, "sector")
: mapWorkspaceReducer(state, { type: "deactivate-sector" });
case "set-sector-hide-outside":
return { ...state, sector: { ...state.sector, hideOutside: action.value } };
case "set-sector-scope":
return { ...state, sector: { ...state.sector, scope: action.scope } };
case "set-sector-scope-enabled": {
const key = action.dimension === "binding"
? "excludedBindingIds"
: action.dimension === "provider"
? "excludedProviders"
: "excludedObjectKinds";
return {
...state,
sector: {
...state.sector,
scope: {
...state.sector.scope,
[key]: setScopeValueEnabled(state.sector.scope[key], action.value, action.enabled),
},
},
};
}
case "set-sector-window-rect":
return {
...state,
sector: { ...state.sector, window: { ...state.sector.window, rect: action.rect } },
};
case "set-sector-window-maximized":
return {
...state,
sector: { ...state.sector, window: { ...state.sector.window, maximized: action.value } },
};
case "toggle-subject-filter": {
const subject = state.subjectStates[action.bindingId];
if (!subject) return state;
const filters = subject.visible ? subject.filters : {};
return replaceSubjectState(state, action.bindingId, {
...subject,
visible: true,
filters: toggleMapPresentationFacetSelection(
filters,
action.field,
action.value,
action.availableValues,
),
});
}
case "toggle-subject-visibility": {
const subject = state.subjectStates[action.bindingId];
return subject
? replaceSubjectState(state, action.bindingId, { ...subject, visible: !subject.visible })
: state;
}
case "replace-subject-state":
return replaceSubjectState(state, action.bindingId, action.subjectState);
case "open-subject-window": {
const subject = state.subjectStates[action.bindingId];
if (!subject) return state;
return activateWindow(replaceSubjectState(state, action.bindingId, {
...subject,
window: { ...subject.window, open: true },
}), `binding:${action.bindingId}`);
}
case "close-subject-window": {
const subject = state.subjectStates[action.bindingId];
if (!subject) return state;
const next = replaceSubjectState(state, action.bindingId, {
...subject,
window: { ...subject.window, open: false },
});
const windowId = `binding:${action.bindingId}`;
return { ...next, activeWindowId: next.activeWindowId === windowId ? undefined : next.activeWindowId };
}
case "set-subject-window-rect": {
const subject = state.subjectStates[action.bindingId];
return subject ? replaceSubjectState(state, action.bindingId, {
...subject,
window: { ...subject.window, rect: action.rect },
}) : state;
}
case "set-subject-window-maximized": {
const subject = state.subjectStates[action.bindingId];
return subject ? replaceSubjectState(state, action.bindingId, {
...subject,
window: { ...subject.window, maximized: action.value },
}) : state;
}
case "activate-window":
return activateWindow(state, action.windowId);
case "clear-active-window":
return {
...state,
activeWindowId: state.activeWindowId === action.windowId ? undefined : state.activeWindowId,
};
case "select-entity": {
const tabId = action.validTabIds.includes(state.subjectCard.tabId)
? state.subjectCard.tabId
: action.defaultTabId;
return activateWindow({
...state,
selectedEntityId: action.entityId,
subjectCard: { ...state.subjectCard, open: true, tabId },
}, "subject-card");
}
case "close-subject-card":
return {
...state,
activeWindowId: state.activeWindowId === "subject-card" ? undefined : state.activeWindowId,
subjectCard: { ...state.subjectCard, open: false },
};
case "set-subject-card-rect":
return { ...state, subjectCard: { ...state.subjectCard, rect: action.rect } };
case "set-subject-card-maximized":
return { ...state, subjectCard: { ...state.subjectCard, maximized: action.value } };
case "set-subject-card-tab":
return { ...state, subjectCard: { ...state.subjectCard, tabId: action.tabId } };
case "reveal-subject":
return {
...replaceSubjectState(state, action.bindingId, action.subjectState),
sector: {
...state.sector,
hideOutside: action.hideOutsideSector,
scope: action.scope,
},
};
default:
return state;
}
}
function replaceSubjectState(state, bindingId, subjectState) {
return {
...state,
subjectStates: { ...state.subjectStates, [bindingId]: subjectState },
};
}
function activateWindow(state, windowId) {
if (state.activeWindowId === windowId) return state;
const nextZIndex = Math.max(
20,
state.sector.window.zIndex,
state.subjectCard.zIndex,
...Object.values(state.subjectStates).map((subject) => subject.window.zIndex),
) + 1;
if (windowId === "sector") {
return {
...state,
activeWindowId: windowId,
sector: { ...state.sector, window: { ...state.sector.window, zIndex: nextZIndex } },
};
}
if (windowId === "subject-card") {
return {
...state,
activeWindowId: windowId,
subjectCard: { ...state.subjectCard, zIndex: nextZIndex },
};
}
if (windowId.startsWith("binding:")) {
const bindingId = windowId.slice("binding:".length);
const subject = state.subjectStates[bindingId];
return subject ? {
...replaceSubjectState(state, bindingId, {
...subject,
window: { ...subject.window, zIndex: nextZIndex },
}),
activeWindowId: windowId,
} : state;
}
return state;
}
function setScopeValueEnabled(values, value, enabled) {
return enabled
? values.filter((candidate) => candidate !== value)
: [...new Set([...values, value])];
}
+9 -14
View File
@@ -1,5 +1,8 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { MapDataProductBinding } from "./MapFixturePreview.js"; import type { MapDataProductBinding } from "./mapPageContract.js";
import { mapRuntimeFactKey } from "./mapRuntimeIdentity.mjs";
export { mapRuntimeEntityId } from "./mapRuntimeIdentity.mjs";
export type DataProductPoint = { export type DataProductPoint = {
type: "Point"; type: "Point";
@@ -78,14 +81,6 @@ type BindingState = {
const identifier = /^[A-Za-z0-9._:-]{1,160}$/; const identifier = /^[A-Za-z0-9._:-]{1,160}$/;
const cursorPattern = /^(?:0|[1-9]\d*)$/; const cursorPattern = /^(?:0|[1-9]\d*)$/;
function factKey(fact: MapRuntimeFact) {
return `${fact.semanticType}\u0000${fact.sourceId}`;
}
export function mapRuntimeEntityId(bindingId: string, fact: Pick<MapRuntimeFact, "sourceId" | "semanticType">) {
return `nodedc-runtime:${bindingId}:${fact.semanticType}:${fact.sourceId}`;
}
function isIsoTimestamp(value: unknown): value is string { function isIsoTimestamp(value: unknown): value is string {
return typeof value === "string" && !Number.isNaN(Date.parse(value)); return typeof value === "string" && !Number.isNaN(Date.parse(value));
} }
@@ -246,7 +241,7 @@ function replaceSnapshot(current: BindingState, snapshot: SnapshotEnvelope): Bin
...current, ...current,
cursor: snapshot.cursor, cursor: snapshot.cursor,
state: "ready", state: "ready",
facts: Object.fromEntries(snapshot.facts.map((fact) => [factKey(fact), fact])), facts: Object.fromEntries(snapshot.facts.map((fact) => [mapRuntimeFactKey(fact), fact])),
}; };
} }
@@ -257,8 +252,8 @@ function applyPatch(current: BindingState, patch: PatchEnvelope): BindingState |
if (current.cursor !== patch.previousCursor) return null; if (current.cursor !== patch.previousCursor) return null;
const facts = { ...current.facts }; const facts = { ...current.facts };
for (const operation of patch.operations) { for (const operation of patch.operations) {
if (operation.op === "upsert") facts[factKey(operation.fact)] = operation.fact; if (operation.op === "upsert") facts[mapRuntimeFactKey(operation.fact)] = operation.fact;
else delete facts[`${operation.semanticType}\u0000${operation.sourceId}`]; else delete facts[mapRuntimeFactKey(operation)];
} }
return { ...current, facts, cursor: patch.cursor, state: "ready" }; return { ...current, facts, cursor: patch.cursor, state: "ready" };
} }
@@ -266,7 +261,7 @@ function applyPatch(current: BindingState, patch: PatchEnvelope): BindingState |
function applyPresentationPatch(current: BindingState, patch: PresentationPatchEnvelope): BindingState { function applyPresentationPatch(current: BindingState, patch: PresentationPatchEnvelope): BindingState {
const facts = { ...current.facts }; const facts = { ...current.facts };
for (const operation of patch.operations) { for (const operation of patch.operations) {
const key = `${operation.semanticType}\u0000${operation.sourceId}`; const key = mapRuntimeFactKey(operation);
if (facts[key]) facts[key] = { ...facts[key], presentationStatus: operation.status }; if (facts[key]) facts[key] = { ...facts[key], presentationStatus: operation.status };
} }
return { ...current, facts }; return { ...current, facts };
@@ -421,7 +416,7 @@ export function useMapDataProductRuntime({
dataProductId: binding.dataProductId, dataProductId: binding.dataProductId,
slotId: binding.slotId, slotId: binding.slotId,
presentationProfileId: binding.presentationProfileId, presentationProfileId: binding.presentationProfileId,
facts: Object.values(record.facts).sort((left, right) => factKey(left).localeCompare(factKey(right))), facts: Object.values(record.facts).sort((left, right) => mapRuntimeFactKey(left).localeCompare(mapRuntimeFactKey(right))),
cursor: record.cursor, cursor: record.cursor,
state: record.state, state: record.state,
}; };
+7 -1
View File
@@ -211,7 +211,13 @@ function asFact(value: unknown): MapRuntimeFact | null {
|| !isIso(value.observedAt) || !isIso(value.receivedAt) || !isIso(value.observedAt) || !isIso(value.receivedAt)
|| !isObject(value.attributes) || !categories.has(value.attributes.category as MapReferenceStationCategory) || !isObject(value.attributes) || !categories.has(value.attributes.category as MapReferenceStationCategory)
|| !isPoint(value.geometry)) return null; || !isPoint(value.geometry)) return null;
const allowedAttributes = new Set(["name", "category", "network", "operator", "official_name", "local_name", "uic_ref", "wheelchair"]); if (value.attributes.alternate_names !== undefined
&& (!Array.isArray(value.attributes.alternate_names)
|| value.attributes.alternate_names.length > 16
|| value.attributes.alternate_names.some((name) => typeof name !== "string" || !name.trim() || name.length > 256))) {
return null;
}
const allowedAttributes = new Set(["name", "category", "network", "operator", "official_name", "local_name", "alternate_names", "uic_ref", "wheelchair"]);
return { return {
sourceId: value.sourceId, sourceId: value.sourceId,
semanticType: String(value.semanticType), semanticType: String(value.semanticType),
+3 -1
View File
@@ -11,7 +11,7 @@
"scripts": { "scripts": {
"build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react && npm run build --workspace @nodedc/ui-catalog", "build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react && npm run build --workspace @nodedc/ui-catalog",
"build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react", "build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react",
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:hgeozone-projection && npm run test:map-grid-lod && npm run test:map-filters && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile", "check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:hgeozone-projection && npm run test:map-provider-startup && npm run test:map-grid-lod && npm run test:map-filters && npm run test:map-workspace-model && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile",
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog", "dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
"serve": "node server/catalog-server.mjs", "serve": "node server/catalog-server.mjs",
"validate:registry": "node scripts/validate-registry.mjs", "validate:registry": "node scripts/validate-registry.mjs",
@@ -22,6 +22,7 @@
"test:foundry-agent": "node --test server/foundry-agent-store.test.mjs server/foundry-agent-gateway.test.mjs scripts/foundry-agent-installer.test.mjs", "test:foundry-agent": "node --test server/foundry-agent-store.test.mjs server/foundry-agent-gateway.test.mjs scripts/foundry-agent-installer.test.mjs",
"test:map-animation": "node --test scripts/map-spiral.test.mjs scripts/map-camera-presets.test.mjs", "test:map-animation": "node --test scripts/map-spiral.test.mjs scripts/map-camera-presets.test.mjs",
"test:map-filters": "node --test scripts/map-presentation-filters.test.mjs", "test:map-filters": "node --test scripts/map-presentation-filters.test.mjs",
"test:map-workspace-model": "node --test scripts/map-workspace-model.test.mjs scripts/map-workspace-state.test.mjs",
"test:hgeozone-projection": "node --test scripts/hgeozone-projection.test.mjs", "test:hgeozone-projection": "node --test scripts/hgeozone-projection.test.mjs",
"test:map-grid-lod": "node --test scripts/map-grid-lod.test.mjs scripts/map-sector-grid.test.mjs server/map-grid-persistence.test.mjs", "test:map-grid-lod": "node --test scripts/map-grid-lod.test.mjs scripts/map-sector-grid.test.mjs server/map-grid-persistence.test.mjs",
"test:map-object-layers": "node --test scripts/map-object-layers.test.mjs", "test:map-object-layers": "node --test scripts/map-object-layers.test.mjs",
@@ -31,6 +32,7 @@
"test:map-subject-card": "node --test scripts/map-subject-card.test.mjs", "test:map-subject-card": "node --test scripts/map-subject-card.test.mjs",
"test:map-subject-detail-profile": "node --test server/map-subject-detail-profile.test.mjs server/map-live-data-slot.test.mjs", "test:map-subject-detail-profile": "node --test server/map-subject-detail-profile.test.mjs server/map-live-data-slot.test.mjs",
"test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs", "test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs",
"test:map-provider-startup": "node --test scripts/map-provider-startup.test.mjs",
"test:floating-position": "node --test scripts/floating-position-contract.test.mjs", "test:floating-position": "node --test scripts/floating-position-contract.test.mjs",
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs", "test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs",
"test:range-control": "node --test scripts/range-control-contract.test.mjs" "test:range-control": "node --test scripts/range-control-contract.test.mjs"
+26 -1
View File
@@ -107,7 +107,32 @@ export function Dropdown({
useLayoutEffect(() => { useLayoutEffect(() => {
if (!isOpen) return; if (!isOpen) return;
updatePosition(); updatePosition();
}, [isOpen, updatePosition]);
const surface = surfaceRef.current;
const anchor = anchorElement ?? triggerElement;
if (!surface || typeof ResizeObserver === "undefined") {
const frame = window.requestAnimationFrame(updatePosition);
return () => window.cancelAnimationFrame(frame);
}
let frame: number | null = null;
const schedulePositionUpdate = () => {
if (frame !== null) return;
frame = window.requestAnimationFrame(() => {
frame = null;
updatePosition();
});
};
const resizeObserver = new ResizeObserver(schedulePositionUpdate);
resizeObserver.observe(surface);
if (anchor) resizeObserver.observe(anchor);
schedulePositionUpdate();
return () => {
resizeObserver.disconnect();
if (frame !== null) window.cancelAnimationFrame(frame);
};
}, [anchorElement, isOpen, triggerElement, updatePosition]);
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
+7 -4
View File
@@ -49,15 +49,18 @@ test("floating surface remains bounded when its anchor is below the viewport", (
}); });
test("dropdown scroll keeps portal geometry stable and contained", async () => { test("dropdown scroll keeps portal geometry stable and contained", async () => {
const styles = await readFile( const [styles, dropdown] = await Promise.all([
new URL("../packages/ui-core/styles.css", import.meta.url), readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
"utf8", readFile(new URL("../packages/ui-react/src/Dropdown.tsx", import.meta.url), "utf8"),
); ]);
const dropdownRule = styles.match(/\.nodedc-dropdown-surface \{(?<body>[\s\S]*?)\n\}/)?.groups?.body ?? ""; const dropdownRule = styles.match(/\.nodedc-dropdown-surface \{(?<body>[\s\S]*?)\n\}/)?.groups?.body ?? "";
assert.match(dropdownRule, /box-sizing:\s*border-box;/); assert.match(dropdownRule, /box-sizing:\s*border-box;/);
assert.match(dropdownRule, /overflow:\s*auto;/); assert.match(dropdownRule, /overflow:\s*auto;/);
assert.match(dropdownRule, /overscroll-behavior:\s*contain;/); assert.match(dropdownRule, /overscroll-behavior:\s*contain;/);
assert.match(dropdown, /new ResizeObserver\(schedulePositionUpdate\)/);
assert.match(dropdown, /resizeObserver\.observe\(surface\)/);
assert.match(dropdown, /window\.requestAnimationFrame\(updatePosition\)/);
}); });
test("workspace windows keep their glass rim without masked compositor overlays", async () => { test("workspace windows keep their glass rim without masked compositor overlays", async () => {
+4 -4
View File
@@ -48,14 +48,14 @@ test("Inspector hard-forces a raw integrated Select to the split presentation",
}); });
test("Foundry Inspector screens consume the semantic selection field", async () => { test("Foundry Inspector screens consume the semantic selection field", async () => {
const [mapPreview, catalog, coreStyles] = await Promise.all([ const [mapInspector, catalog, coreStyles] = await Promise.all([
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"), readFile(new URL("../apps/catalog/src/mapInspectorSections.tsx", import.meta.url), "utf8"),
readFile(new URL("../apps/catalog/src/CatalogApp.tsx", import.meta.url), "utf8"), readFile(new URL("../apps/catalog/src/CatalogApp.tsx", import.meta.url), "utf8"),
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"), readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
]); ]);
assert.match(mapPreview, /<InspectorSelectField\s+label="Профиль покрытия"/); assert.match(mapInspector, /<InspectorSelectField\s+label="Профиль покрытия"/);
assert.doesNotMatch(mapPreview, /<ControlRow label="Профиль покрытия">/); assert.doesNotMatch(mapInspector, /<ControlRow label="Профиль покрытия">/);
assert.equal((catalog.match(/<InspectorSelectField/g) ?? []).length, 2); assert.equal((catalog.match(/<InspectorSelectField/g) ?? []).length, 2);
assert.match(coreStyles, /\.nodedc-inspector \.nodedc-control-row:has\(\.nodedc-select-anchor\)/); assert.match(coreStyles, /\.nodedc-inspector \.nodedc-control-row:has\(\.nodedc-select-anchor\)/);
}); });
+28 -25
View File
@@ -315,29 +315,32 @@ test("grid center is fixed and camera-independent, including for legacy camera-m
}); });
test("renderer uses fixed ENU sectors, angular graticules and a non-blank double-buffer swap", async () => { test("renderer uses fixed ENU sectors, angular graticules and a non-blank double-buffer swap", async () => {
const source = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"); const [renderer, gridLayer] = await Promise.all([
assert.match(source, /class GridLayerController/); readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"),
assert.match(source, /dataSourceDisplay\.ready/); readFile(new URL("../apps/catalog/src/mapCesiumGridLayer.ts", import.meta.url), "utf8"),
assert.match(source, /fixedGridOrigin\(presentation\)/); ]);
assert.match(source, /Transforms\.eastNorthUpToFixedFrame\(anchor\)/); assert.match(gridLayer, /class GridLayerController/);
assert.match(source, /localSectorAt\(/); assert.match(gridLayer, /dataSourceDisplay\.ready/);
assert.match(source, /gridController\?\.pick\(worldPosition\)/); assert.match(gridLayer, /fixedGridOrigin\(presentation\)/);
assert.match(source, /lod\.stepKm \* 1_000/); assert.match(gridLayer, /Transforms\.eastNorthUpToFixedFrame\(anchor\)/);
assert.match(source, /lod\.graticuleStepDegrees/); assert.match(gridLayer, /localSectorAt\(/);
assert.match(source, /lod\.graticuleLineWidthPx/); assert.match(renderer, /gridController\?\.pick\(worldPosition\)/);
assert.match(source, /arcType: ArcType\.RHUMB/); assert.match(gridLayer, /lod\.stepKm \* 1_000/);
assert.match(source, /clampToGround: false/); assert.match(gridLayer, /lod\.graticuleStepDegrees/);
assert.match(source, /majorLineWidthMultiplier/); assert.match(gridLayer, /lod\.graticuleLineWidthPx/);
assert.match(source, /isLocalMajorLineIndex/); assert.match(gridLayer, /arcType: ArcType\.RHUMB/);
assert.match(source, /isGraticuleMajorLineValue/); assert.match(gridLayer, /clampToGround: false/);
assert.match(source, /new LabelCollection\(\)/); assert.match(gridLayer, /majorLineWidthMultiplier/);
assert.match(source, /const maximumLabels = 48/); assert.match(gridLayer, /isLocalMajorLineIndex/);
assert.match(source, /materializeLocalSelection/); assert.match(gridLayer, /isGraticuleMajorLineValue/);
assert.match(source, /localVolumeAt\(/); assert.match(gridLayer, /new LabelCollection\(\)/);
assert.match(source, /focusGridSector/); assert.match(gridLayer, /const maximumLabels = 48/);
assert.match(source, /selectedGridSector/); assert.match(gridLayer, /materializeLocalSelection/);
assert.match(source, /mountResources\(resources\)[\s\S]*?const previous = this\.current;[\s\S]*?removeResources\(previous\.resources\)/); assert.match(gridLayer, /localVolumeAt\(/);
assert.doesNotMatch(source, /function rebuildElevatedGrid[\s\S]*?entities\.removeAll\(\)/); assert.match(renderer, /focusGridSector/);
assert.doesNotMatch(source, /Math\.min\(40,[\s\S]*?safeRadiusKm \/ safeStepKm/); assert.match(renderer, /selectedGridSector/);
assert.doesNotMatch(source, /snapGridCenter\(/); assert.match(gridLayer, /mountResources\(resources\)[\s\S]*?const previous = this\.current;[\s\S]*?removeResources\(previous\.resources\)/);
assert.doesNotMatch(gridLayer, /function rebuildElevatedGrid[\s\S]*?entities\.removeAll\(\)/);
assert.doesNotMatch(gridLayer, /Math\.min\(40,[\s\S]*?safeRadiusKm \/ safeStepKm/);
assert.doesNotMatch(gridLayer, /snapGridCenter\(/);
}); });
+8 -8
View File
@@ -3,17 +3,17 @@ import { readFile } from "node:fs/promises";
import test from "node:test"; import test from "node:test";
test("Cesium elevated targets keep their profile border while labels occlude them inside the scene overlay", async () => { test("Cesium elevated targets keep their profile border while labels occlude them inside the scene overlay", async () => {
const [renderer, cesiumDisplay] = await Promise.all([ const [runtimeLayers, cesiumDisplay] = await Promise.all([
readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"), readFile(new URL("../apps/catalog/src/mapCesiumRuntimeLayers.ts", import.meta.url), "utf8"),
readFile(new URL("../node_modules/@cesium/engine/Source/DataSources/DataSourceDisplay.js", import.meta.url), "utf8"), readFile(new URL("../node_modules/@cesium/engine/Source/DataSources/DataSourceDisplay.js", import.meta.url), "utf8"),
]); ]);
assert.match(renderer, /BillboardGraphics/); assert.match(runtimeLayers, /BillboardGraphics/);
assert.match(renderer, /elevatedTargetImage\(\s*color,\s*Color\.fromCssColorString\(target\.outlineColor\)\.withAlpha\(target\.outlineOpacity\),\s*target\.outlineWidthPx,\s*target\.headSizePx/); assert.match(runtimeLayers, /elevatedTargetImage\(\s*color,\s*Color\.fromCssColorString\(target\.outlineColor\)\.withAlpha\(target\.outlineOpacity\),\s*target\.outlineWidthPx,\s*target\.headSizePx/);
assert.match(renderer, /entity\.point = undefined;\s*entity\.billboard = new BillboardGraphics/); assert.match(runtimeLayers, /entity\.point = undefined;\s*entity\.billboard = new BillboardGraphics/);
assert.match(renderer, /disableDepthTestDistance: Number\.POSITIVE_INFINITY/); assert.match(runtimeLayers, /disableDepthTestDistance: Number\.POSITIVE_INFINITY/);
assert.match(renderer, /outlineWidth: 0,\s*style: LabelStyle\.FILL/); assert.match(runtimeLayers, /outlineWidth: 0,\s*style: LabelStyle\.FILL/);
assert.doesNotMatch(renderer, /style: 2/); assert.doesNotMatch(runtimeLayers, /style: 2/);
assert.ok(cesiumDisplay.indexOf("new BillboardVisualizer") < cesiumDisplay.indexOf("new LabelVisualizer")); assert.ok(cesiumDisplay.indexOf("new BillboardVisualizer") < cesiumDisplay.indexOf("new LabelVisualizer"));
}); });
+107 -80
View File
@@ -2,71 +2,92 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import test from "node:test"; import test from "node:test";
test("Objects menu opens controllable groups from the row and keeps plain bindings as visibility layers", async () => { const source = (path) => readFile(new URL(path, import.meta.url), "utf8");
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
assert.match(preview, /const toggleSubjectVisibility = \(bindingId: string\)/); test("Objects menu delegates controlled layers to the workspace reducer", async () => {
assert.match(preview, /role=\{hasControls \? "menuitem" : "menuitemcheckbox"\}/); const [preview, toolbar, state] = await Promise.all([
assert.match(preview, /if \(hasControls\) \{\s*openSubjectWindow\(summary\.bindingId\);\s*close\(\)/); source("../apps/catalog/src/MapFixturePreview.tsx"),
assert.match(preview, /toggleSubjectVisibility\(summary\.bindingId\)/); source("../apps/catalog/src/MapWorkspaceToolbar.tsx"),
assert.match(preview, /visible: !state\.visible/); source("../apps/catalog/src/mapWorkspaceState.mjs"),
assert.match(preview, /слой скрыт · \$\{summary\.total\} объектов/); ]);
assert.doesNotMatch(preview, /Фильтры и счётчики: \$\{summary\.displayName\}/);
assert.match(toolbar, /role=\{hasControls \? "menuitem" : "menuitemcheckbox"\}/);
assert.match(toolbar, /onOpenSubjectWindow\(summary\.bindingId\)/);
assert.match(toolbar, /onToggleSubjectVisibility\(summary\.bindingId\)/);
assert.match(toolbar, /слой скрыт · \$\{summary\.total\} объектов/);
assert.match(preview, /type: "toggle-subject-visibility", bindingId/);
assert.match(state, /visible: !subject\.visible/);
}); });
test("a subject window exists only when the presentation profile exposes controls", async () => { test("a subject window exists only when the MCP presentation profile exposes controls", async () => {
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"); const [windows, model, state] = await Promise.all([
source("../apps/catalog/src/MapSubjectWorkspaceWindows.tsx"),
source("../apps/catalog/src/mapWorkspaceModel.mjs"),
source("../apps/catalog/src/mapWorkspaceState.mjs"),
]);
assert.match(preview, /function hasSubjectWindowControls\(profile: MapPresentationProfile\)/); assert.match(model, /export function mapProfileHasSubjectWindowControls/);
assert.match(preview, /if \(!state\?\.window\.open \|\| !hasSubjectWindowControls\(summary\.profile\)\) return null/); assert.match(windows, /!state\?\.window\.open \|\| !mapProfileHasSubjectWindowControls\(summary\.profile\)/);
assert.match(preview, /window: \{ \.\.\.state\.window, open: false \}/); assert.match(windows, /type: "close-subject-window"/);
assert.match(state, /window: \{ \.\.\.subject\.window, open: false \}/);
}); });
test("hidden and visible layers have an explicit visual state", async () => { test("hidden and visible layers have an explicit visual state", async () => {
const styles = await readFile(new URL("../apps/catalog/src/styles.css", import.meta.url), "utf8"); const styles = await source("../apps/catalog/src/styles.css");
assert.match(styles, /\.catalog-map-fixture__objects-menu-item\[data-visible\]/); assert.match(styles, /\.catalog-map-fixture__objects-menu-item\[data-visible\]/);
assert.match(styles, /\.catalog-map-fixture__objects-menu-item:not\(\[data-visible\]\)/); assert.match(styles, /\.catalog-map-fixture__objects-menu-item:not\(\[data-visible\]\)/);
}); });
test("facet controls and renderer consume the same canonical enabled-value state", async () => { test("facet controls and renderer consume one canonical enabled-value state", async () => {
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"); const [preview, windows, state] = await Promise.all([
source("../apps/catalog/src/MapFixturePreview.tsx"),
source("../apps/catalog/src/MapSubjectWorkspaceWindows.tsx"),
source("../apps/catalog/src/mapWorkspaceState.mjs"),
]);
assert.match(preview, /initialSubjectState\(initialLayout\?\.dataProductBindings[\s\S]*?presentationProfiles\)/); assert.match(preview, /normalizeMapPresentationFacetSelections\(state\.filters, profile\)/);
assert.match(preview, /facets: profile \? normalizeMapPresentationFacetSelections\(state\.filters, profile\) : state\.filters/); assert.match(preview, /presentationFilters=\{rendererPresentationFilters\}/);
assert.match(preview, /filters: normalizeMapPresentationFacetSelections\(state\.filters, summary\.profile\)/); assert.match(windows, /mapPresentationFacetValueIsEnabled\(state\.filters, facet\.field, item\.value\)/);
assert.match(preview, /const active = mapPresentationFacetValueIsEnabled\(state\.filters, facet\.field, item\.value\)/); assert.match(windows, /facet\.values\.map\(\(value\) => value\.value\)/);
assert.match(preview, /toggleMapPresentationFacetSelection\(filters, field, value, availableValues\)/); assert.match(state, /toggleMapPresentationFacetSelection\(/);
assert.match(preview, /facet\.values\.map\(\(value\) => value\.value\)/);
}); });
test("joined detail aspects do not become independent map layers", async () => { test("joined detail aspects enrich subjects but never become independent renderer layers", async () => {
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"); const [preview, model] = await Promise.all([
source("../apps/catalog/src/MapFixturePreview.tsx"),
source("../apps/catalog/src/mapWorkspaceModel.mjs"),
]);
assert.match(preview, /dataProductBindings\.filter\(\(binding\) => !binding\.joinToBindingId\)/); assert.match(model, /filter\(\(binding\) => !binding\.joinToBindingId\)/);
assert.match(preview, /runtimeBindings\.filter\(\(binding\) => primaryBindingIds\.has\(binding\.bindingId\)\)/); assert.match(model, /primaryMapBindingConfigs\(bindingConfigs\)/);
assert.match(preview, /runtimeBindings=\{\[\.\.\.sectorScopedPrimaryRuntimeBindings, \.\.\.referenceRuntimeBindings\]\}/); assert.match(preview, /runtimeBindings=\{\[\.\.\.sectorScopedPrimaryRuntimeBindings, \.\.\.referenceRuntimeBindings\]\}/);
}); });
test("bounded map windows keep one stack while layers and settings use their canonical surfaces", async () => { test("bounded map windows share one reducer-owned z-stack while settings and layers keep canonical surfaces", async () => {
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"); const [preview, toolbar, windows, sectorWindow, state, contract] = await Promise.all([
source("../apps/catalog/src/MapFixturePreview.tsx"),
source("../apps/catalog/src/MapWorkspaceToolbar.tsx"),
source("../apps/catalog/src/MapSubjectWorkspaceWindows.tsx"),
source("../apps/catalog/src/MapSectorWorkspaceWindow.tsx"),
source("../apps/catalog/src/mapWorkspaceState.mjs"),
source("../apps/catalog/src/mapPageContract.ts"),
]);
assert.match(preview, /type MapWorkspaceWindowId = "sector" \| "subject-card" \| `binding:\$\{string\}`/); assert.match(contract, /type MapWorkspaceWindowId = "sector" \| "subject-card" \| `binding:\$\{string\}`/);
assert.match(preview, /const activateWorkspaceWindow = useCallback/); assert.match(state, /function activateWindow\(state, windowId\)/);
assert.match(preview, /onActivate=\{\(\) => activateWorkspaceWindow\(`binding:\$\{summary\.bindingId\}`\)\}/); assert.match(state, /Math\.max\([\s\S]*state\.sector\.window\.zIndex[\s\S]*state\.subjectCard\.zIndex/);
assert.doesNotMatch(preview, /onActivate=\{\(\) => openSubjectWindow\(summary\.bindingId\)\}/); assert.match(windows, /type: "activate-window", windowId: `binding:\$\{summary\.bindingId\}`/);
assert.match(preview, /sectorWindowZIndex[\s\S]*subjectCardZIndex/); assert.match(sectorWindow, /onActivate=\{onActivate\}/);
assert.match(preview, /<ApplicationSidePanel[\s\S]*title="Настройки карты"[\s\S]*onClose=\{closeSettingsPanel\}/); assert.match(preview, /<ApplicationSidePanel[\s\S]*title="Настройки карты"[\s\S]*onClose=\{closeSettingsPanel\}/);
assert.match(preview, /createPortal\(headerActions, headerActionsHost\)/); assert.match(preview, /createPortal\(headerActions, headerActionsHost\)/);
assert.doesNotMatch(preview, /settingsPanelPinned|onPinnedChange/); assert.match(toolbar, /surfaceClassName="catalog-map-fixture__objects-menu catalog-map-fixture__layers-menu nodedc-map-glass"/);
assert.match(preview, /surfaceClassName="catalog-map-fixture__objects-menu catalog-map-fixture__layers-menu nodedc-map-glass"/); assert.match(toolbar, /onOpenChange=\{onOpenChange\}/);
assert.match(preview, /onOpenChange=\{setLayersOpen\}/); assert.doesNotMatch(preview, /settingsPanelPinned|settingsWindowZIndex|layersWindowZIndex/);
assert.doesNotMatch(preview, /settingsWindowZIndex|layersWindowZIndex/);
assert.doesNotMatch(preview, /<Window\b/);
}); });
test("selecting a point or HGeoZone preserves the active grid sector", async () => { test("selecting a point or HGeoZone preserves the active grid sector", async () => {
const renderer = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"); const renderer = await source("../apps/catalog/src/CesiumMapRenderer.tsx");
const pointBranch = renderer.match(/if \(pickedId instanceof Entity[\s\S]*?return;\s*\}/)?.[0] ?? ""; const pointBranch = renderer.match(/if \(pickedId instanceof Entity[\s\S]*?return;\s*\}/)?.[0] ?? "";
const zoneBranch = renderer.match(/if \(\s*pickedId[\s\S]*?HGeoZonePickId\)\.entityId\);\s*return;\s*\}/)?.[0] ?? ""; const zoneBranch = renderer.match(/if \(\s*pickedId[\s\S]*?HGeoZonePickId\)\.entityId\);\s*return;\s*\}/)?.[0] ?? "";
@@ -77,59 +98,65 @@ test("selecting a point or HGeoZone preserves the active grid sector", async ()
assert.match(renderer, /gridController\?\.setSelection\(gridSelection\);\s*onGridSectorSelectRef\.current\?\.\(gridSelection\)/); assert.match(renderer, /gridController\?\.setSelection\(gridSelection\);\s*onGridSectorSelectRef\.current\?\.\(gridSelection\)/);
}); });
test("an active sector scopes point facts, exposes data facets and deactivates on close", async () => { test("active sector scope is geometric, filterable and reducer-deactivated", async () => {
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"); const [runtime, sectorWindow, state] = await Promise.all([
source("../apps/catalog/src/mapSectorRuntime.mjs"),
assert.match(preview, /function mapFactInsideGridSector/); source("../apps/catalog/src/MapSectorWorkspaceWindow.tsx"),
assert.match(preview, /localSectorAtGeodetic/); source("../apps/catalog/src/mapWorkspaceState.mjs"),
assert.match(preview, /label="Скрыть объекты за сектором"/);
assert.match(preview, />Домены данных</);
assert.match(preview, />Провайдеры</);
assert.match(preview, />Типы объектов</);
assert.match(preview, />Объекты сектора</);
assert.match(preview, /onClose=\{deactivateGridSector\}/);
assert.match(preview, />\s*Деактивировать сектор\s*</);
assert.match(preview, /setSelectedGridSector\(null\)/);
});
test("reference stations are first-class Objects menu layers without provider settings actions", async () => {
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
assert.match(preview, /const referenceObjectSummaries = useMemo/);
assert.match(preview, /const objectLayerCount = presentationSummaries\.length \+ referenceObjectSummaries\.length/);
assert.match(preview, /referenceObjectSummaries\.map\(\(\{ layer, displayName, total \}\)/);
assert.match(preview, /candidate\.id === layer\.id \? \{ \.\.\.candidate, visible: !candidate\.visible \} : candidate/);
assert.match(preview, /role="menuitemcheckbox"/);
});
test("facet window auto-fits expanded and collapsed content inside map workspace", async () => {
const [preview, workspace] = await Promise.all([
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"),
readFile(new URL("../packages/ui-react/src/WorkspaceWindow.tsx", import.meta.url), "utf8"),
]); ]);
assert.match(preview, /minHeight=\{220\}\s*autoHeight/); assert.match(runtime, /export function mapFactInsideGridSector/);
assert.match(runtime, /localSectorAtGeodetic/);
assert.match(sectorWindow, /label="Скрыть объекты за сектором"/);
assert.match(sectorWindow, /title="Домены данных"/);
assert.match(sectorWindow, /title="Провайдеры"/);
assert.match(sectorWindow, /title="Типы объектов"/);
assert.match(sectorWindow, /id="map-sector-objects-title">Объекты сектора/);
assert.match(sectorWindow, /onClose=\{onDeactivate\}/);
assert.match(sectorWindow, />\s*Деактивировать сектор\s*</);
assert.match(state, /case "deactivate-sector"/);
assert.match(state, /selectedSector: null/);
});
test("reference stations remain first-class visibility layers without provider settings actions", async () => {
const [preview, toolbar] = await Promise.all([
source("../apps/catalog/src/MapFixturePreview.tsx"),
source("../apps/catalog/src/MapWorkspaceToolbar.tsx"),
]);
assert.match(preview, /const referenceObjectSummaries = useMemo/);
assert.match(toolbar, /const objectLayerCount = summaries\.length \+ referenceSummaries\.length/);
assert.match(toolbar, /referenceSummaries\.map\(\(\{ layer, displayName, total \}\)/);
assert.match(toolbar, /onToggleReferenceLayer\(layer\.id\)/);
assert.match(toolbar, /role="menuitemcheckbox"/);
});
test("facet windows auto-fit expanded and collapsed content inside the map workspace", async () => {
const [windows, workspace] = await Promise.all([
source("../apps/catalog/src/MapSubjectWorkspaceWindows.tsx"),
source("../packages/ui-react/src/WorkspaceWindow.tsx"),
]);
assert.match(windows, /minHeight=\{220\}\s*autoHeight/);
assert.match(workspace, /autoHeight\?: boolean/); assert.match(workspace, /autoHeight\?: boolean/);
assert.match(workspace, /content\.scrollHeight/); assert.match(workspace, /content\.scrollHeight/);
assert.match(workspace, /availableHeight = Math\.max\(0, nextBounds\.height - normalized\.y\)/); assert.match(workspace, /availableHeight = Math\.max\(0, nextBounds\.height - normalized\.y\)/);
assert.match(workspace, /new ResizeObserver\(scheduleFit\)/); assert.match(workspace, /new ResizeObserver\(scheduleFit\)/);
}); });
test("facet tree selection focuses the subject without resetting a compatible detail tab", async () => { test("facet and search selection focus the subject while the reducer preserves a valid detail tab", async () => {
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"); const [preview, renderer, state] = await Promise.all([
const renderer = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"); source("../apps/catalog/src/MapFixturePreview.tsx"),
source("../apps/catalog/src/CesiumMapRenderer.tsx"),
source("../apps/catalog/src/mapWorkspaceState.mjs"),
]);
assert.match(preview, /const \[expandedFacetRows, setExpandedFacetRows\]/);
assert.match(preview, /const handleSelectAndFocus = useCallback/); assert.match(preview, /const handleSelectAndFocus = useCallback/);
assert.match(preview, /const focusSubject = useCallback/);
assert.match(preview, /if \(renderer\.focusRuntimeEntity\(entityId\)\) return true/); assert.match(preview, /if \(renderer\.focusRuntimeEntity\(entityId\)\) return true/);
assert.match(preview, /mapFactPointCoordinates\(selectable\.find/);
assert.match(preview, /renderer\.focusSubjectCoordinates\(fallbackCoordinates\[0\], fallbackCoordinates\[1\]\)/); assert.match(preview, /renderer\.focusSubjectCoordinates\(fallbackCoordinates\[0\], fallbackCoordinates\[1\]\)/);
assert.match(preview, /focusSubject\(entityId\)/);
assert.match(preview, /focusSubject\(result\.entityId, result\.coordinates\)/); assert.match(preview, /focusSubject\(result\.entityId, result\.coordinates\)/);
assert.match(preview, /setSubjectCardTabId\(\(current\) =>/); assert.match(preview, /validTabIds: profile\?\.tabs\.map\(\(tab\) => tab\.id\) \?\? \["overview"\]/);
assert.match(preview, /profile\?\.tabs\.some\(\(tab\) => tab\.id === current\)/); assert.match(state, /action\.validTabIds\.includes\(state\.subjectCard\.tabId\)/);
assert.match(preview, /\(profile\?\.defaultTabId \?\? "overview"\)/);
assert.match(renderer, /void viewer\.flyTo\(entity, \{/); assert.match(renderer, /void viewer\.flyTo\(entity, \{/);
assert.match(renderer, /const focusSubjectCoordinates = useCallback/); assert.match(renderer, /const focusSubjectCoordinates = useCallback/);
assert.match(renderer, /viewer\.camera\.flyToBoundingSphere\(new BoundingSphere\(target, 1\), \{/); assert.match(renderer, /viewer\.camera\.flyToBoundingSphere\(new BoundingSphere\(target, 1\), \{/);
+1 -9
View File
@@ -1,13 +1,5 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test"; import test from "node:test";
import ts from "typescript";
const source = await readFile(new URL("../apps/catalog/src/mapPresentationProfile.ts", import.meta.url), "utf8");
const transpiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText;
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled).toString("base64")}`;
const { const {
mapFactMatchesFilters, mapFactMatchesFilters,
mapPresentationBindingIsAll, mapPresentationBindingIsAll,
@@ -15,7 +7,7 @@ const {
mapPresentationFacetValueIsEnabled, mapPresentationFacetValueIsEnabled,
normalizeMapPresentationFacetSelections, normalizeMapPresentationFacetSelections,
toggleMapPresentationFacetSelection, toggleMapPresentationFacetSelection,
} = await import(moduleUrl); } = await import("../apps/catalog/src/mapPresentationProfile.mjs");
const profile = { const profile = {
id: "map.moving-object.operational.default", id: "map.moving-object.operational.default",
+163
View File
@@ -0,0 +1,163 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import {
runStagedMapProviders,
waitForGlobeViewportReady,
} from "../apps/catalog/src/mapProviderStartup.mjs";
function eventHarness() {
const listeners = new Set();
return {
event: {
addEventListener(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
},
raise(...args) {
for (const listener of [...listeners]) listener(...args);
},
get size() {
return listeners.size;
},
};
}
test("providers start in base-map order", async () => {
const calls = [];
const result = await runStagedMapProviders({
loadImagery: () => calls.push("imagery"),
waitAfterImagery: () => calls.push("imagery-ready"),
loadTerrain: () => calls.push("terrain"),
waitAfterTerrain: () => calls.push("terrain-ready"),
loadBuildings: () => calls.push("buildings"),
loadDeferred: () => calls.push("deferred"),
});
assert.equal(result, "complete");
assert.deepEqual(calls, ["imagery", "imagery-ready", "terrain", "terrain-ready", "buildings", "deferred"]);
});
test("imagery failure is reported without blocking terrain or buildings", async () => {
const calls = [];
const errors = [];
await runStagedMapProviders({
loadImagery: () => { calls.push("imagery"); throw new Error("imagery failed"); },
waitAfterImagery: () => calls.push("imagery-ready"),
loadTerrain: () => calls.push("terrain"),
waitAfterTerrain: () => calls.push("terrain-ready"),
loadBuildings: () => calls.push("buildings"),
onProviderError: (provider, error) => errors.push([provider, error.message]),
});
assert.deepEqual(calls, ["imagery", "terrain", "terrain-ready", "buildings"]);
assert.deepEqual(errors, [["imagery", "imagery failed"]]);
});
test("terrain failure is reported without blocking buildings", async () => {
const calls = [];
const errors = [];
await runStagedMapProviders({
loadImagery: () => calls.push("imagery"),
waitAfterImagery: () => calls.push("imagery-ready"),
loadTerrain: () => { calls.push("terrain"); throw new Error("terrain failed"); },
waitAfterTerrain: () => calls.push("terrain-ready"),
loadBuildings: () => calls.push("buildings"),
onProviderError: (provider, error) => errors.push([provider, error.message]),
});
assert.deepEqual(calls, ["imagery", "imagery-ready", "terrain", "buildings"]);
assert.deepEqual(errors, [["terrain", "terrain failed"]]);
});
test("cancellation prevents all later stages", async () => {
const controller = new AbortController();
const calls = [];
const result = await runStagedMapProviders({
signal: controller.signal,
loadImagery: () => { calls.push("imagery"); controller.abort(); },
waitAfterImagery: () => calls.push("imagery-ready"),
loadTerrain: () => calls.push("terrain"),
loadBuildings: () => calls.push("buildings"),
});
assert.equal(result, "cancelled");
assert.deepEqual(calls, ["imagery"]);
});
test("a stalled provider times out without inserting late or blocking later stages", async () => {
const calls = [];
const errors = [];
let releaseImagery;
const stalledImagery = new Promise((resolve) => { releaseImagery = resolve; });
await runStagedMapProviders({
providerInitializationTimeoutMs: 1,
loadImagery: async ({ signal }) => {
calls.push("imagery-start");
await stalledImagery;
if (!signal.aborted) calls.push("imagery-insert");
},
loadTerrain: () => calls.push("terrain"),
loadBuildings: () => calls.push("buildings"),
onProviderError: (provider, error) => errors.push([provider, error.message]),
});
releaseImagery();
await Promise.resolve();
assert.deepEqual(calls, ["imagery-start", "terrain", "buildings"]);
assert.deepEqual(errors, [["imagery", "imagery_startup_timeout"]]);
});
test("viewport readiness waits for discovery frames and cleans listeners", async () => {
const progress = eventHarness();
const postRender = eventHarness();
const globe = { tilesLoaded: true, tileLoadProgressEvent: progress.event };
const scene = { postRender: postRender.event, requestRender() {} };
let settled = false;
const ready = waitForGlobeViewportReady({ globe, scene, timeoutMs: 100, minimumRenderFrames: 2 })
.then((reason) => { settled = true; return reason; });
postRender.raise();
await Promise.resolve();
assert.equal(settled, false);
postRender.raise();
assert.equal(await ready, "loaded");
assert.equal(progress.size, 0);
assert.equal(postRender.size, 0);
});
test("viewport readiness times out and aborts without leaking listeners", async () => {
const timeoutProgress = eventHarness();
const timeoutPostRender = eventHarness();
assert.equal(await waitForGlobeViewportReady({
globe: { tilesLoaded: false, tileLoadProgressEvent: timeoutProgress.event },
scene: { postRender: timeoutPostRender.event, requestRender() {} },
timeoutMs: 1,
}), "timeout");
assert.equal(timeoutProgress.size, 0);
assert.equal(timeoutPostRender.size, 0);
const abortProgress = eventHarness();
const abortPostRender = eventHarness();
const controller = new AbortController();
const pending = waitForGlobeViewportReady({
globe: { tilesLoaded: false, tileLoadProgressEvent: abortProgress.event },
scene: { postRender: abortPostRender.event, requestRender() {} },
signal: controller.signal,
timeoutMs: 100,
});
controller.abort();
assert.equal(await pending, "cancelled");
assert.equal(abortProgress.size, 0);
assert.equal(abortPostRender.size, 0);
});
test("the live renderer uses the staged provider coordinator", async () => {
const renderer = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8");
assert.match(renderer, /runStagedMapProviders\(\{/);
assert.match(renderer, /waitForGlobeViewportReady\(\{/);
assert.match(renderer, /providerStartupAbort\.abort\(\)/);
assert.doesNotMatch(renderer, /Do not serialize provider startup/);
});
+4 -3
View File
@@ -28,13 +28,14 @@ test("Map Page registers a provider-neutral reference-points slot and three stat
test("existing layouts upgrade reference layers on read and renderer accepts their slot", async () => { test("existing layouts upgrade reference layers on read and renderer accepts their slot", async () => {
const server = await readFile(new URL("server/catalog-server.mjs", root), "utf8"); const server = await readFile(new URL("server/catalog-server.mjs", root), "utf8");
const renderer = await readFile(new URL("apps/catalog/src/CesiumMapRenderer.tsx", root), "utf8"); const runtimeLayers = await readFile(new URL("apps/catalog/src/mapCesiumRuntimeLayers.ts", root), "utf8");
const runtime = await readFile(new URL("apps/catalog/src/useMapReferenceRuntime.ts", root), "utf8"); const runtime = await readFile(new URL("apps/catalog/src/useMapReferenceRuntime.ts", root), "utf8");
assert.match(server, /pageLayoutMatch\[1\] === "map" \? validateMapPageLayout\(stored\) : stored/); assert.match(server, /pageLayoutMatch\[1\] === "map" \? validateMapPageLayout\(stored\) : stored/);
assert.match(server, /referenceLayers: structuredClone\(canonicalMapReferenceLayers\)/); assert.match(server, /referenceLayers: structuredClone\(canonicalMapReferenceLayers\)/);
assert.match(renderer, /binding\.slotId === "reference-points"/); assert.match(runtimeLayers, /binding\.slotId === "reference-points"/);
assert.match(runtime, /\/api\/map-gateway\/api\/map\/reference-sources\/v1\/profiles\//); assert.match(runtime, /\/api\/map-gateway\/api\/map\/reference-sources\/v1\/profiles\//);
assert.match(runtime, /allowedAttributes = new Set\(\["name", "category", "network", "operator", "official_name", "local_name", "uic_ref", "wheelchair"\]\)/); assert.match(runtime, /allowedAttributes = new Set\(\["name", "category", "network", "operator", "official_name", "local_name", "alternate_names", "uic_ref", "wheelchair"\]\)/);
assert.match(runtime, /alternate_names/);
assert.doesNotMatch(runtime, /credential|accessToken|providerEndpoint|rawPayload/); assert.doesNotMatch(runtime, /credential|accessToken|providerEndpoint|rawPayload/);
}); });
+3 -1
View File
@@ -85,7 +85,7 @@ test("reference subjects remain searchable by profile labels without a provider
id: "map.reference.station.v1", id: "map.reference.station.v1",
title: "Метро", title: "Метро",
semanticTypes: ["map.station"], semanticTypes: ["map.station"],
label: { mode: "attributes", fields: ["name", "official_name"] }, label: { mode: "attributes", fields: ["name", "official_name", "alternate_names"] },
}; };
const index = buildMapSearchIndex({ const index = buildMapSearchIndex({
runtimeBindings: [{ runtimeBindings: [{
@@ -93,6 +93,7 @@ test("reference subjects remain searchable by profile labels without a provider
presentationProfileId: stationProfile.id, presentationProfileId: stationProfile.id,
facts: [point("osm.node.1", "map.station", { facts: [point("osm.node.1", "map.station", {
name: "Петроградская", name: "Петроградская",
alternate_names: ["Petrogradskaya station"],
provider_note: "not searchable", provider_note: "not searchable",
})], })],
}], }],
@@ -100,6 +101,7 @@ test("reference subjects remain searchable by profile labels without a provider
}); });
assert.equal(searchMapSubjects(index, "петрог").at(0)?.title, "Петроградская"); assert.equal(searchMapSubjects(index, "петрог").at(0)?.title, "Петроградская");
assert.equal(searchMapSubjects(index, "Petrogradskaya").at(0)?.sourceId, "osm.node.1");
assert.equal(searchMapSubjects(index, "osm.node.1").at(0)?.groupTitle, "Метро"); assert.equal(searchMapSubjects(index, "osm.node.1").at(0)?.groupTitle, "Метро");
assert.deepEqual(searchMapSubjects(index, "provider_note"), []); assert.deepEqual(searchMapSubjects(index, "provider_note"), []);
}); });
+8 -5
View File
@@ -275,17 +275,20 @@ test("restricted identity aspects render full identifiers and bounded string lis
}); });
test("Cesium pin and label share one entity selection that opens the subject card", async () => { test("Cesium pin and label share one entity selection that opens the subject card", async () => {
const [renderer, preview] = await Promise.all([ const [renderer, preview, windows, state] = await Promise.all([
readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"), readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"),
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"), readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"),
readFile(new URL("../apps/catalog/src/MapSubjectWorkspaceWindows.tsx", import.meta.url), "utf8"),
readFile(new URL("../apps/catalog/src/mapWorkspaceState.mjs", import.meta.url), "utf8"),
]); ]);
assert.match(renderer, /viewer\?\.scene\.pick/); assert.match(renderer, /viewer\?\.scene\.pick/);
assert.match(renderer, /pickedId instanceof Entity/); assert.match(renderer, /pickedId instanceof Entity/);
assert.match(renderer, /onSelectRef\.current\?\.\(pickedId\.id\)/); assert.match(renderer, /onSelectRef\.current\?\.\(pickedId\.id\)/);
assert.match(preview, /const handleSelect = useCallback/); assert.match(preview, /const handleSelect = useCallback/);
assert.match(preview, /setSubjectCardOpen\(true\)/); assert.match(preview, /type: "select-entity"/);
assert.match(preview, /title=\{selectedSubjectCard\.title\}/); assert.match(state, /subjectCard: \{ \.\.\.state\.subjectCard, open: true, tabId \}/);
assert.match(preview, /Карточка объекта:/); assert.match(windows, /title=\{selectedSubjectCard\.title\}/);
assert.match(preview, /SegmentedControl/); assert.match(windows, /Карточка объекта:/);
assert.match(windows, /SegmentedControl/);
}); });
+235
View File
@@ -0,0 +1,235 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildFilteredMapTargets,
buildMapPresentationFilters,
buildSelectableMapEntities,
planMapSubjectReveal,
primaryMapRuntimeBindings,
} from "../apps/catalog/src/mapWorkspaceModel.mjs";
import { mapRuntimeEntityId, mapRuntimeFactKey } from "../apps/catalog/src/mapRuntimeIdentity.mjs";
import { localSectorAtGeodetic, localSectorSummary } from "../apps/catalog/src/mapSectorGrid.mjs";
const origin = { latitude: 55.7558, longitude: 37.6173 };
const gridProfile = {
mode: "3d",
stepKm: 1,
tileSizeKm: 5,
graticuleStepDegrees: 1,
};
const primaryBinding = {
id: "positions",
order: 1,
dataProductId: "fleet.positions.current.v5",
slotId: "points",
delivery: "snapshot+patch",
semanticTypes: ["map.moving_object"],
fieldProjection: ["display_name", "signal_state", "position_source", "object_kind"],
presentationProfileId: "moving",
};
const joinedBinding = {
...primaryBinding,
id: "identity",
order: 2,
dataProductId: "fleet.units.identity.current.v1",
joinToBindingId: "positions",
};
const profile = {
id: "moving",
title: "Current positions",
semanticTypes: ["map.moving_object"],
label: { mode: "attributes", fields: ["display_name"], maxLength: 80 },
target: { variant: "elevated-spike" },
facets: [{
id: "signal",
field: "signal_state",
filterable: true,
counter: true,
values: [
{ value: "active", label: "Online", order: 1 },
{ value: "inactive", label: "Offline", order: 2 },
],
}],
styles: [{ id: "active", color: "#fff", opacity: 1 }],
classes: [{ id: "active", label: "Online", priority: 1, match: [], styleId: "active", renderable: true }],
defaultClassId: "active",
sort: [{ field: "signal_state", order: ["active", "inactive"] }],
};
function pointFact(sourceId, signalState, coordinates, displayName = sourceId) {
return {
sourceId,
semanticType: "map.moving_object",
observedAt: "2026-08-09T12:00:00.000Z",
receivedAt: "2026-08-09T12:00:01.000Z",
attributes: {
display_name: displayName,
signal_state: signalState,
position_source: "gelios",
object_kind: "tracked_unit",
},
geometry: { type: "Point", coordinates },
presentationStatus: signalState,
};
}
const inactive = pointFact("unit-offline", "inactive", [37.6173, 55.7558], "Альфа");
const active = pointFact("unit-online", "active", [37.618, 55.756], "Янтарь");
const runtimeBindings = [{
bindingId: "positions",
dataProductId: primaryBinding.dataProductId,
slotId: "points",
presentationProfileId: "moving",
facts: [inactive, active],
cursor: "1",
state: "ready",
}, {
bindingId: "identity",
dataProductId: joinedBinding.dataProductId,
slotId: "subject-aspect",
presentationProfileId: "moving",
facts: [active],
cursor: "1",
state: "ready",
}];
test("stable runtime identity uses binding, semantic type and source id only", () => {
const moved = { ...active, geometry: { type: "Point", coordinates: [40, 60] } };
assert.equal(mapRuntimeFactKey(active), "map.moving_object\u0000unit-online");
assert.equal(mapRuntimeEntityId("positions", active), mapRuntimeEntityId("positions", moved));
assert.equal(mapRuntimeEntityId("positions", active), "nodedc-runtime:positions:map.moving_object:unit-online");
});
test("joined aspects enrich a primary subject and never become map entities", () => {
assert.deepEqual(
primaryMapRuntimeBindings(runtimeBindings, [joinedBinding, primaryBinding]).map((binding) => binding.bindingId),
["positions"],
);
assert.deepEqual(
buildSelectableMapEntities(runtimeBindings, [joinedBinding, primaryBinding], [profile]).map((entity) => entity.fact.sourceId),
["unit-online", "unit-offline"],
);
});
test("object overview preserves the same MCP profile sort as facets and renderer", () => {
const filters = { positions: { visible: true, facets: {} } };
const selectable = buildSelectableMapEntities(runtimeBindings, [primaryBinding, joinedBinding], [profile]);
const targets = buildFilteredMapTargets(runtimeBindings, [primaryBinding, joinedBinding], [profile], filters);
// Alphabetical title order would be Альфа, Янтарь. Declared state order is
// active, inactive and must remain authoritative everywhere.
assert.deepEqual(selectable.map((entity) => entity.title), ["Янтарь", "Альфа"]);
assert.deepEqual(targets.map((target) => target.title), ["Янтарь", "Альфа"]);
});
test("explicit empty facets remain match-nothing through workspace projection", () => {
const subjectStates = {
positions: {
bindingId: "positions",
visible: true,
filters: { signal_state: [] },
window: { open: false, rect: { x: 0, y: 0, width: 280, height: 260 }, maximized: false, zIndex: 20 },
},
};
const filters = buildMapPresentationFilters(subjectStates, [primaryBinding], [profile]);
assert.deepEqual(filters.positions.facets, { signal_state: [] });
assert.deepEqual(buildFilteredMapTargets(runtimeBindings, [primaryBinding], [profile], filters), []);
});
test("search reveal removes only constraints hiding the selected subject", () => {
const address = localSectorAtGeodetic(origin, {
lod: 1,
originLatitude: origin.latitude,
originLongitude: origin.longitude,
stepMeters: 1_000,
});
const selectedSector = {
...localSectorSummary(address, {
lod: 1,
originLatitude: origin.latitude,
originLongitude: origin.longitude,
stepMeters: 1_000,
tileSizeMeters: 5_000,
}),
mode: "3d",
units: "meters-enu",
volume: null,
};
const entity = buildSelectableMapEntities(runtimeBindings, [primaryBinding], [profile])
.find((candidate) => candidate.fact.sourceId === "unit-offline");
const subjectState = {
bindingId: "positions",
visible: false,
filters: { signal_state: ["active"] },
window: { open: true, rect: { x: 10, y: 20, width: 280, height: 260 }, maximized: false, zIndex: 42 },
};
const reveal = planMapSubjectReveal({
entity,
subjectState,
profile,
selectedSector,
gridProfiles: [gridProfile],
origin,
hideOutsideSector: true,
scope: {
excludedBindingIds: ["positions", "other"],
excludedProviders: ["gelios", "other"],
excludedObjectKinds: ["tracked_unit", "other"],
},
});
assert.equal(reveal.subjectState.visible, true);
assert.deepEqual(reveal.subjectState.filters, {});
assert.deepEqual(reveal.subjectState.window, subjectState.window);
assert.equal(reveal.hideOutsideSector, true);
assert.deepEqual(reveal.scope, {
excludedBindingIds: ["other"],
excludedProviders: ["other"],
excludedObjectKinds: ["other"],
});
});
test("search keeps the active sector but relaxes outside clipping for an outside subject", () => {
const address = localSectorAtGeodetic(origin, {
lod: 1,
originLatitude: origin.latitude,
originLongitude: origin.longitude,
stepMeters: 1_000,
});
const selectedSector = {
...localSectorSummary(address, {
lod: 1,
originLatitude: origin.latitude,
originLongitude: origin.longitude,
stepMeters: 1_000,
tileSizeMeters: 5_000,
}),
mode: "3d",
units: "meters-enu",
volume: null,
};
const outsideEntity = {
...buildSelectableMapEntities(runtimeBindings, [primaryBinding], [profile])[0],
fact: pointFact("far-away", "active", [50, 60]),
};
const subjectState = {
bindingId: "positions",
visible: true,
filters: {},
window: { open: false, rect: { x: 0, y: 0, width: 280, height: 260 }, maximized: false, zIndex: 20 },
};
const reveal = planMapSubjectReveal({
entity: outsideEntity,
subjectState,
profile,
selectedSector,
gridProfiles: [gridProfile],
origin,
hideOutsideSector: true,
scope: { excludedBindingIds: [], excludedProviders: [], excludedObjectKinds: [] },
});
assert.equal(reveal.hideOutsideSector, false);
assert.equal(selectedSector.id, reveal ? selectedSector.id : null);
});
+102
View File
@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createMapWorkspaceState, mapWorkspaceReducer } from "../apps/catalog/src/mapWorkspaceState.mjs";
const rect = (x, y, width = 280, height = 260) => ({ x, y, width, height });
const subjectStates = {
positions: {
bindingId: "positions",
visible: true,
filters: {},
window: { open: false, rect: rect(24, 56), maximized: false, zIndex: 20 },
},
zones: {
bindingId: "zones",
visible: true,
filters: {},
window: { open: false, rect: rect(52, 84), maximized: false, zIndex: 21 },
},
};
function initialState() {
return createMapWorkspaceState({
subjectStates: structuredClone(subjectStates),
sectorWindowRect: rect(24, 72, 380, 530),
subjectCardRect: rect(940, 72, 390, 520),
});
}
test("one reducer owns the complete floating-window stack", () => {
const opened = mapWorkspaceReducer(initialState(), { type: "open-subject-window", bindingId: "positions" });
assert.equal(opened.subjectStates.positions.window.open, true);
assert.equal(opened.activeWindowId, "binding:positions");
assert.ok(opened.subjectStates.positions.window.zIndex > opened.sector.window.zIndex);
const activatedCard = mapWorkspaceReducer(opened, {
type: "select-entity",
entityId: "nodedc-runtime:positions:map.moving_object:unit-1",
validTabIds: ["overview", "position"],
defaultTabId: "overview",
});
assert.equal(activatedCard.subjectCard.open, true);
assert.equal(activatedCard.activeWindowId, "subject-card");
assert.ok(activatedCard.subjectCard.zIndex > opened.subjectStates.positions.window.zIndex);
// Re-activating the active window is stable and does not inflate z-index.
assert.equal(
mapWorkspaceReducer(activatedCard, { type: "activate-window", windowId: "subject-card" }),
activatedCard,
);
});
test("selecting a domain subject never clears the active grid sector", () => {
const sector = { id: "grid/local/sector", lod: 2, mode: "3d" };
const withSector = mapWorkspaceReducer(initialState(), { type: "select-sector", selection: sector });
const withSubject = mapWorkspaceReducer(withSector, {
type: "select-entity",
entityId: "nodedc-runtime:positions:map.moving_object:unit-1",
validTabIds: ["overview"],
defaultTabId: "overview",
});
assert.equal(withSubject.selectedSector, sector);
assert.equal(withSubject.selectedEntityId, "nodedc-runtime:positions:map.moving_object:unit-1");
});
test("sector deactivation clears only sector scope and keeps subject UI state", () => {
let state = mapWorkspaceReducer(initialState(), { type: "select-sector", selection: { id: "sector", lod: 1, mode: "3d" } });
state = mapWorkspaceReducer(state, { type: "set-sector-scope-enabled", dimension: "binding", value: "positions", enabled: false });
state = mapWorkspaceReducer(state, { type: "open-subject-window", bindingId: "positions" });
state = mapWorkspaceReducer(state, { type: "deactivate-sector" });
assert.equal(state.selectedSector, null);
assert.deepEqual(state.sector.scope, {
excludedBindingIds: [],
excludedProviders: [],
excludedObjectKinds: [],
});
assert.equal(state.subjectStates.positions.window.open, true);
assert.equal(state.subjectStates.positions.visible, true);
});
test("search reveal state is applied atomically without replacing window geometry", () => {
const state = initialState();
const originalWindow = state.subjectStates.positions.window;
const revealedSubject = {
...state.subjectStates.positions,
visible: true,
filters: { signal_state: ["inactive"] },
};
const next = mapWorkspaceReducer(state, {
type: "reveal-subject",
bindingId: "positions",
subjectState: revealedSubject,
hideOutsideSector: false,
scope: { excludedBindingIds: [], excludedProviders: ["other"], excludedObjectKinds: [] },
});
assert.deepEqual(next.subjectStates.positions.window, originalWindow);
assert.deepEqual(next.subjectStates.positions.filters, { signal_state: ["inactive"] });
assert.deepEqual(next.sector.scope.excludedProviders, ["other"]);
});