refactor(map): split foundry workspace and cesium runtime

This commit is contained in:
Codex
2026-08-09 19:22:25 +03:00
parent 6c1265b8b6
commit b70e165910
39 changed files with 5959 additions and 4225 deletions
+2 -1
View File
@@ -65,7 +65,8 @@ import {
type DesignProfileSummary,
type DesignProfileStatus,
} 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 {
mapDesignFragmentForLayout,
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 { MapPageLayout, MapPageSettings } from "./MapFixturePreview.js";
import type { MapPageLayout, MapPageSettings } from "./mapPageContract.js";
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
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 { ToolbarPlacement } from "@nodedc/ui-react";
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";
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 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";
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 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;
}
// Stable TypeScript facade for existing `.js` specifiers. Runtime behavior is
// executable JavaScript so browser code and Node behavioral tests share it.
export * from "./mapPresentationProfile.mjs";
+241
View File
@@ -0,0 +1,241 @@
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;
};
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;
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;
};
};
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 { 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 type { MapDataProductBinding } from "./MapFixturePreview.js";
import type { MapDataProductBinding } from "./mapPageContract.js";
import { mapRuntimeFactKey } from "./mapRuntimeIdentity.mjs";
export { mapRuntimeEntityId } from "./mapRuntimeIdentity.mjs";
export type DataProductPoint = {
type: "Point";
@@ -78,14 +81,6 @@ type BindingState = {
const identifier = /^[A-Za-z0-9._:-]{1,160}$/;
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 {
return typeof value === "string" && !Number.isNaN(Date.parse(value));
}
@@ -246,7 +241,7 @@ function replaceSnapshot(current: BindingState, snapshot: SnapshotEnvelope): Bin
...current,
cursor: snapshot.cursor,
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;
const facts = { ...current.facts };
for (const operation of patch.operations) {
if (operation.op === "upsert") facts[factKey(operation.fact)] = operation.fact;
else delete facts[`${operation.semanticType}\u0000${operation.sourceId}`];
if (operation.op === "upsert") facts[mapRuntimeFactKey(operation.fact)] = operation.fact;
else delete facts[mapRuntimeFactKey(operation)];
}
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 {
const facts = { ...current.facts };
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 };
}
return { ...current, facts };
@@ -421,7 +416,7 @@ export function useMapDataProductRuntime({
dataProductId: binding.dataProductId,
slotId: binding.slotId,
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,
state: record.state,
};