Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88cbef1aba | ||
|
|
f645725e8c | ||
|
|
b70e165910 |
@@ -65,7 +65,8 @@ import {
|
|||||||
type DesignProfileSummary,
|
type DesignProfileSummary,
|
||||||
type DesignProfileStatus,
|
type DesignProfileStatus,
|
||||||
} from "./applicationManifest.js";
|
} from "./applicationManifest.js";
|
||||||
import { createDefaultMapPageLayout, MapFixturePreview, type MapFixturePreviewHandle, type MapPageLayout } from "./MapFixturePreview.js";
|
import { MapFixturePreview } from "./MapFixturePreview.js";
|
||||||
|
import { createDefaultMapPageLayout, type MapFixturePreviewHandle, type MapPageLayout } from "./mapPageContract.js";
|
||||||
import {
|
import {
|
||||||
mapDesignFragmentForLayout,
|
mapDesignFragmentForLayout,
|
||||||
mapDesignFragmentFromLayout,
|
mapDesignFragmentFromLayout,
|
||||||
|
|||||||
+124
-1991
File diff suppressed because it is too large
Load Diff
+396
-1844
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,5 +1,5 @@
|
|||||||
import type { NodedcTheme } from "@nodedc/ui-core";
|
import type { NodedcTheme } from "@nodedc/ui-core";
|
||||||
import type { MapPageLayout, MapPageSettings } from "./MapFixturePreview.js";
|
import type { MapPageLayout, MapPageSettings } from "./mapPageContract.js";
|
||||||
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
|
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
|
||||||
|
|
||||||
export const applicationManifestSchemaVersion = "0.1.0" as const;
|
export const applicationManifestSchemaVersion = "0.1.0" as const;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { GlassMaterialSettings, NodedcTheme } from "@nodedc/ui-core";
|
import type { GlassMaterialSettings, NodedcTheme } from "@nodedc/ui-core";
|
||||||
import type { ToolbarPlacement } from "@nodedc/ui-react";
|
import type { ToolbarPlacement } from "@nodedc/ui-react";
|
||||||
import type { FaviconAssetUrls } from "./favicon.js";
|
import type { FaviconAssetUrls } from "./favicon.js";
|
||||||
import type { MapPageLayout, MapPageSettings } from "./MapFixturePreview.js";
|
import type { MapPageLayout, MapPageSettings } from "./mapPageContract.js";
|
||||||
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
|
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
|
||||||
|
|
||||||
export type MaterialDraft = {
|
export type MaterialDraft = {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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 1–3. LOD 4–5 используют глобальную 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,4 +1,4 @@
|
|||||||
import type { MapPresentation } from "./CesiumMapRenderer.js";
|
import type { MapPresentation } from "./mapRendererContract.js";
|
||||||
|
|
||||||
export type GridLodMode = "3d" | "graticule";
|
export type GridLodMode = "3d" | "graticule";
|
||||||
export type GridLodProfile = {
|
export type GridLodProfile = {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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>>;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,287 +1,3 @@
|
|||||||
import type { MapRuntimeFact } from "./useMapDataProductRuntime.js";
|
// Stable TypeScript facade for existing `.js` specifiers. Runtime behavior is
|
||||||
|
// executable JavaScript so browser code and Node behavioral tests share it.
|
||||||
export type MapPresentationFacetValue = {
|
export * from "./mapPresentationProfile.mjs";
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
order: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MapPresentationFacet = {
|
|
||||||
id: string;
|
|
||||||
field: string;
|
|
||||||
label: string;
|
|
||||||
filterable: boolean;
|
|
||||||
counter: boolean;
|
|
||||||
values: MapPresentationFacetValue[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MapPresentationStyle = {
|
|
||||||
id: string;
|
|
||||||
color: string;
|
|
||||||
opacity: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MapPresentationClass = {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
priority: number;
|
|
||||||
match: Array<{ field: string; equals: string }>;
|
|
||||||
styleId: string;
|
|
||||||
renderable: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MapPresentationProfile = {
|
|
||||||
id: string;
|
|
||||||
version: string;
|
|
||||||
title: string;
|
|
||||||
semanticTypes: string[];
|
|
||||||
label: {
|
|
||||||
mode: "subject_id" | "attributes" | "none";
|
|
||||||
fields: string[];
|
|
||||||
fontWeight: number;
|
|
||||||
sizePx: number;
|
|
||||||
color: string;
|
|
||||||
outlineColor: string;
|
|
||||||
outlineWidthPx: number;
|
|
||||||
backgroundColor: string;
|
|
||||||
backgroundOpacity: number;
|
|
||||||
paddingX: number;
|
|
||||||
paddingY: number;
|
|
||||||
maxLength: number;
|
|
||||||
offsetX: number;
|
|
||||||
offsetY: number;
|
|
||||||
hideCameraHeightMeters: number;
|
|
||||||
};
|
|
||||||
target: ({
|
|
||||||
variant: "elevated-spike";
|
|
||||||
stemHeightMeters: number;
|
|
||||||
headSizePx: number;
|
|
||||||
stemWidthPx: number;
|
|
||||||
} | {
|
|
||||||
variant: "surface-fill";
|
|
||||||
}) & {
|
|
||||||
outlineColor: string;
|
|
||||||
outlineOpacity: number;
|
|
||||||
outlineWidthPx: number;
|
|
||||||
hideCameraHeightMeters: number;
|
|
||||||
};
|
|
||||||
facets: MapPresentationFacet[];
|
|
||||||
styles: MapPresentationStyle[];
|
|
||||||
classes: MapPresentationClass[];
|
|
||||||
defaultClassId: string;
|
|
||||||
sort: Array<{ field: string; order: string[] }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MapSubjectFilterState = {
|
|
||||||
/** False is an explicit empty map state. It must never be normalized to all. */
|
|
||||||
visible: boolean;
|
|
||||||
/** Missing facet = no constraint; an explicitly empty facet = match nothing. */
|
|
||||||
facets: Record<string, string[]>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Application view state is keyed by stable binding id, never by editable labels. */
|
|
||||||
export type MapPresentationFilters = Record<string, MapSubjectFilterState>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Application manifests persisted before profile v1.1 used the internal key
|
|
||||||
* `pin`. Normalize that storage shape before the first React render so an old
|
|
||||||
* application cannot crash while it is being upgraded to the public `target`
|
|
||||||
* contract through MCP.
|
|
||||||
*/
|
|
||||||
export function normalizeClientMapPresentationProfiles(profiles: MapPresentationProfile[]) {
|
|
||||||
return profiles.flatMap((profile) => {
|
|
||||||
const legacyPin = (profile as MapPresentationProfile & { pin?: MapPresentationProfile["target"] }).pin;
|
|
||||||
const target = profile.target ?? legacyPin;
|
|
||||||
if (!target) return [];
|
|
||||||
const normalized = { ...profile, target } as MapPresentationProfile & { pin?: MapPresentationProfile["target"] };
|
|
||||||
delete normalized.pin;
|
|
||||||
return [normalized];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapPresentationBindingIsAll(bindingId: string, filters: MapPresentationFilters) {
|
|
||||||
const state = filters[bindingId];
|
|
||||||
return state?.visible !== false && Object.keys(state?.facets ?? {}).length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A missing facet is the compact canonical representation of every configured
|
|
||||||
* value being enabled. An explicit array is the exact enabled subset; an empty
|
|
||||||
* array therefore remains an intentional match-nothing state.
|
|
||||||
*/
|
|
||||||
export function mapPresentationFacetValueIsEnabled(
|
|
||||||
facets: Record<string, string[]>,
|
|
||||||
field: string,
|
|
||||||
value: string,
|
|
||||||
) {
|
|
||||||
const selected = facets[field];
|
|
||||||
return selected === undefined || selected.includes(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeMapPresentationFacetSelections(
|
|
||||||
facets: Record<string, string[]>,
|
|
||||||
profile: MapPresentationProfile,
|
|
||||||
) {
|
|
||||||
return Object.fromEntries(profile.facets.flatMap((facet) => {
|
|
||||||
const selected = facets[facet.field];
|
|
||||||
if (selected === undefined) return [];
|
|
||||||
const availableValues = [...new Set(facet.values.map((item) => item.value))];
|
|
||||||
const enabledValues = availableValues.filter((value) => selected.includes(value));
|
|
||||||
// Legacy layouts could persist every value explicitly. Canonicalize that
|
|
||||||
// shape to an unconstrained facet so scoped facets (for example movement
|
|
||||||
// on online subjects) cannot accidentally suppress unrelated subjects.
|
|
||||||
return availableValues.length > 0 && enabledValues.length === availableValues.length
|
|
||||||
? []
|
|
||||||
: [[facet.field, enabledValues] as const];
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toggleMapPresentationFacetSelection(
|
|
||||||
facets: Record<string, string[]>,
|
|
||||||
field: string,
|
|
||||||
value: string,
|
|
||||||
availableValues: string[],
|
|
||||||
) {
|
|
||||||
const values = [...new Set(availableValues)];
|
|
||||||
if (!values.includes(value)) return facets;
|
|
||||||
const selected = facets[field];
|
|
||||||
const enabled = new Set(selected === undefined
|
|
||||||
? values
|
|
||||||
: values.filter((item) => selected.includes(item)));
|
|
||||||
if (enabled.has(value)) enabled.delete(value);
|
|
||||||
else enabled.add(value);
|
|
||||||
const nextEnabled = values.filter((item) => enabled.has(item));
|
|
||||||
const next = { ...facets };
|
|
||||||
if (nextEnabled.length === values.length) delete next[field];
|
|
||||||
else next[field] = nextEnabled;
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapPresentationProfileForFact(
|
|
||||||
profiles: MapPresentationProfile[],
|
|
||||||
presentationProfileId: string | undefined,
|
|
||||||
semanticType: string,
|
|
||||||
) {
|
|
||||||
const exact = presentationProfileId
|
|
||||||
? profiles.find((profile) => profile.id === presentationProfileId)
|
|
||||||
: undefined;
|
|
||||||
if (exact?.semanticTypes.includes(semanticType)) return exact;
|
|
||||||
return profiles.find((profile) => profile.semanticTypes.includes(semanticType));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveMapPresentationClass(fact: MapRuntimeFact, profile: MapPresentationProfile) {
|
|
||||||
const classes = [...profile.classes].sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id));
|
|
||||||
return classes.find((item) => item.match.every((condition) => (
|
|
||||||
normalizedFacetValue(fact.attributes[condition.field]) === condition.equals
|
|
||||||
))) ?? classes.find((item) => item.id === profile.defaultClassId) ?? classes.at(-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveMapPresentationStyle(profile: MapPresentationProfile, presentationClass?: MapPresentationClass) {
|
|
||||||
const selected = presentationClass ?? profile.classes.find((item) => item.id === profile.defaultClassId);
|
|
||||||
return profile.styles.find((style) => style.id === selected?.styleId) ?? profile.styles[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapRuntimeDisplayLabel(fact: MapRuntimeFact, profile?: MapPresentationProfile) {
|
|
||||||
if (profile?.label.mode === "subject_id") return fact.sourceId;
|
|
||||||
const fields = profile?.label.fields ?? ["display_name", "label", "name", "title"];
|
|
||||||
for (const key of fields) {
|
|
||||||
const value = fact.attributes[key];
|
|
||||||
if (typeof value === "string" && value.trim()) {
|
|
||||||
const normalized = value.trim();
|
|
||||||
const limit = profile?.label.maxLength ?? 80;
|
|
||||||
return normalized.length > limit ? `${normalized.slice(0, Math.max(1, limit - 1))}…` : normalized;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fact.sourceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapRuntimeFactIsRenderable(fact: MapRuntimeFact, profile: MapPresentationProfile) {
|
|
||||||
return Boolean(fact.geometry) && resolveMapPresentationClass(fact, profile)?.renderable === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapRuntimeFactIsVisible(
|
|
||||||
fact: MapRuntimeFact,
|
|
||||||
profile: MapPresentationProfile,
|
|
||||||
filters: MapPresentationFilters,
|
|
||||||
bindingId: string,
|
|
||||||
) {
|
|
||||||
return mapRuntimeFactIsRenderable(fact, profile) && mapFactMatchesFilters(fact, profile, filters, bindingId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapFactMatchesFilters(
|
|
||||||
fact: MapRuntimeFact,
|
|
||||||
profile: MapPresentationProfile,
|
|
||||||
filters: MapPresentationFilters,
|
|
||||||
bindingId: string,
|
|
||||||
) {
|
|
||||||
const state = filters[bindingId];
|
|
||||||
if (state?.visible === false) return false;
|
|
||||||
|
|
||||||
const selectedFacets = profile.facets.flatMap((facet) => {
|
|
||||||
const selected = state?.facets?.[facet.field];
|
|
||||||
if (selected === undefined) return [];
|
|
||||||
return [{ facet, selected }];
|
|
||||||
});
|
|
||||||
|
|
||||||
// Persisted empty arrays and the final interactive deselection are an
|
|
||||||
// explicit match-nothing state.
|
|
||||||
if (selectedFacets.some(({ selected }) => selected.length === 0)) return false;
|
|
||||||
if (selectedFacets.length === 0) return true;
|
|
||||||
|
|
||||||
// Values inside one facet form a union; independently selected facets are
|
|
||||||
// conjunctive. This keeps provider/type/state filters composable.
|
|
||||||
return selectedFacets.every(({ facet, selected }) => (
|
|
||||||
mapFactParticipatesInFacet(fact, profile, facet)
|
|
||||||
&& selected.includes(normalizedFacetValue(fact.attributes[facet.field]))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function compareMapRuntimeFacts(left: MapRuntimeFact, right: MapRuntimeFact, profile: MapPresentationProfile) {
|
|
||||||
for (const rule of profile.sort) {
|
|
||||||
const leftRank = sortRank(rule.order, normalizedFacetValue(left.attributes[rule.field]));
|
|
||||||
const rightRank = sortRank(rule.order, normalizedFacetValue(right.attributes[rule.field]));
|
|
||||||
if (leftRank !== rightRank) return leftRank - rightRank;
|
|
||||||
}
|
|
||||||
return mapRuntimeDisplayLabel(left, profile).localeCompare(mapRuntimeDisplayLabel(right, profile), "ru");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapPresentationFacetCounts(
|
|
||||||
facts: MapRuntimeFact[],
|
|
||||||
profile: MapPresentationProfile,
|
|
||||||
) {
|
|
||||||
return Object.fromEntries(profile.facets.map((facet) => {
|
|
||||||
const counts = Object.fromEntries(facet.values.map((item) => [item.value, 0]));
|
|
||||||
for (const fact of facts) {
|
|
||||||
if (!mapFactParticipatesInFacet(fact, profile, facet)) continue;
|
|
||||||
const value = normalizedFacetValue(fact.attributes[facet.field]);
|
|
||||||
if (Object.hasOwn(counts, value)) counts[value] += 1;
|
|
||||||
}
|
|
||||||
return [facet.field, counts];
|
|
||||||
})) as Record<string, Record<string, number>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `signal_state` and `movement_state` remain orthogonal Data Product facts.
|
|
||||||
* The operational Map, however, must not present a stale last speed as a
|
|
||||||
* current movement state. When both canonical facets exist, the movement
|
|
||||||
* facet is therefore scoped to currently active subjects. Other profiles and
|
|
||||||
* fields keep their ordinary independent-facet behaviour.
|
|
||||||
*/
|
|
||||||
function mapFactParticipatesInFacet(
|
|
||||||
fact: MapRuntimeFact,
|
|
||||||
profile: MapPresentationProfile,
|
|
||||||
facet: MapPresentationFacet,
|
|
||||||
) {
|
|
||||||
if (facet.field !== "movement_state") return true;
|
|
||||||
if (!profile.facets.some((item) => item.field === "signal_state")) return true;
|
|
||||||
return normalizedFacetValue(fact.attributes.signal_state) === "active";
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizedFacetValue(value: unknown) {
|
|
||||||
return typeof value === "string" ? value.trim().toLowerCase() : "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortRank(order: string[], value: string) {
|
|
||||||
const index = order.indexOf(value);
|
|
||||||
return index === -1 ? order.length : index;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export type MapProviderStartupPolicy = Readonly<{
|
||||||
|
providerInitializationTimeoutMs: number;
|
||||||
|
imageryViewportTimeoutMs: number;
|
||||||
|
terrainViewportTimeoutMs: number;
|
||||||
|
minimumRenderFrames: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type MapProviderStartupReason = "loaded" | "timeout" | "cancelled";
|
||||||
|
export type MapProviderStartupResult = "complete" | "cancelled";
|
||||||
|
export type MapProviderName = "imagery" | "terrain" | "buildings";
|
||||||
|
|
||||||
|
type CesiumEventLike<TListener extends (...args: never[]) => void> = {
|
||||||
|
addEventListener(listener: TListener): () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MAP_PROVIDER_STARTUP_POLICY: MapProviderStartupPolicy;
|
||||||
|
|
||||||
|
export function waitForGlobeViewportReady(input: {
|
||||||
|
globe: {
|
||||||
|
readonly tilesLoaded: boolean;
|
||||||
|
tileLoadProgressEvent: CesiumEventLike<(pendingRequests: number) => void>;
|
||||||
|
};
|
||||||
|
scene: {
|
||||||
|
postRender: CesiumEventLike<() => void>;
|
||||||
|
requestRender(): void;
|
||||||
|
};
|
||||||
|
signal?: AbortSignal;
|
||||||
|
timeoutMs: number;
|
||||||
|
minimumRenderFrames?: number;
|
||||||
|
}): Promise<MapProviderStartupReason>;
|
||||||
|
|
||||||
|
export function runStagedMapProviders(input: {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
isCancelled?: () => boolean;
|
||||||
|
providerInitializationTimeoutMs?: number;
|
||||||
|
loadImagery: (context: { signal: AbortSignal }) => void | Promise<void>;
|
||||||
|
waitAfterImagery?: () => void | Promise<void>;
|
||||||
|
loadTerrain: (context: { signal: AbortSignal }) => void | Promise<void>;
|
||||||
|
waitAfterTerrain?: () => void | Promise<void>;
|
||||||
|
loadBuildings: (context: { signal: AbortSignal }) => void | Promise<void>;
|
||||||
|
loadDeferred?: () => void | Promise<void>;
|
||||||
|
onProviderError?: (provider: MapProviderName, error: unknown) => void;
|
||||||
|
onDeferredError?: (error: unknown) => void;
|
||||||
|
}): Promise<MapProviderStartupResult>;
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
export const MAP_PROVIDER_STARTUP_POLICY = Object.freeze({
|
||||||
|
providerInitializationTimeoutMs: 8_000,
|
||||||
|
imageryViewportTimeoutMs: 6_000,
|
||||||
|
terrainViewportTimeoutMs: 5_000,
|
||||||
|
minimumRenderFrames: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait until Cesium has rendered enough frames to discover the current
|
||||||
|
* viewport and the public Globe queue reports that its terrain and imagery
|
||||||
|
* are loaded. The deadline is deliberate: a slow or unavailable provider
|
||||||
|
* must never block the next independent layer.
|
||||||
|
*/
|
||||||
|
export function waitForGlobeViewportReady({
|
||||||
|
globe,
|
||||||
|
scene,
|
||||||
|
signal,
|
||||||
|
timeoutMs,
|
||||||
|
minimumRenderFrames = MAP_PROVIDER_STARTUP_POLICY.minimumRenderFrames,
|
||||||
|
}) {
|
||||||
|
if (signal?.aborted) return Promise.resolve("cancelled");
|
||||||
|
|
||||||
|
const frameTarget = Math.max(1, Math.trunc(minimumRenderFrames));
|
||||||
|
const deadlineMs = Math.max(0, Math.trunc(timeoutMs));
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
let renderFrames = 0;
|
||||||
|
let removeProgressListener;
|
||||||
|
let removePostRenderListener;
|
||||||
|
let timeout;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
removeProgressListener?.();
|
||||||
|
removePostRenderListener?.();
|
||||||
|
if (timeout !== undefined) clearTimeout(timeout);
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
};
|
||||||
|
const finish = (reason) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
resolve(reason);
|
||||||
|
};
|
||||||
|
const inspect = () => {
|
||||||
|
if (renderFrames >= frameTarget && globe.tilesLoaded) finish("loaded");
|
||||||
|
};
|
||||||
|
const onAbort = () => finish("cancelled");
|
||||||
|
const onTileLoadProgress = () => {
|
||||||
|
inspect();
|
||||||
|
if (!settled) scene.requestRender();
|
||||||
|
};
|
||||||
|
const onPostRender = () => {
|
||||||
|
renderFrames += 1;
|
||||||
|
inspect();
|
||||||
|
if (!settled && renderFrames < frameTarget) scene.requestRender();
|
||||||
|
};
|
||||||
|
|
||||||
|
removeProgressListener = globe.tileLoadProgressEvent.addEventListener(onTileLoadProgress);
|
||||||
|
removePostRenderListener = scene.postRender.addEventListener(onPostRender);
|
||||||
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
timeout = setTimeout(() => finish("timeout"), deadlineMs);
|
||||||
|
scene.requestRender();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preserve independent failure domains while giving the base map first use
|
||||||
|
* of network and decode capacity. Each provider owns its error; viewport
|
||||||
|
* gates are scheduling hints and therefore never become provider failures.
|
||||||
|
*/
|
||||||
|
export async function runStagedMapProviders({
|
||||||
|
signal,
|
||||||
|
isCancelled,
|
||||||
|
providerInitializationTimeoutMs = MAP_PROVIDER_STARTUP_POLICY.providerInitializationTimeoutMs,
|
||||||
|
loadImagery,
|
||||||
|
waitAfterImagery,
|
||||||
|
loadTerrain,
|
||||||
|
waitAfterTerrain,
|
||||||
|
loadBuildings,
|
||||||
|
loadDeferred,
|
||||||
|
onProviderError,
|
||||||
|
onDeferredError,
|
||||||
|
}) {
|
||||||
|
const cancelled = () => Boolean(signal?.aborted || isCancelled?.());
|
||||||
|
const loadProvider = async (provider, load) => {
|
||||||
|
if (cancelled()) return false;
|
||||||
|
const stageAbort = new AbortController();
|
||||||
|
let timeout;
|
||||||
|
let rejectDeadline;
|
||||||
|
const abortStage = () => {
|
||||||
|
rejectDeadline?.(new Error(`${provider}_startup_cancelled`));
|
||||||
|
stageAbort.abort();
|
||||||
|
};
|
||||||
|
signal?.addEventListener("abort", abortStage, { once: true });
|
||||||
|
const deadline = new Promise((_, reject) => {
|
||||||
|
rejectDeadline = reject;
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
reject(new Error(`${provider}_startup_timeout`));
|
||||||
|
stageAbort.abort();
|
||||||
|
}, Math.max(0, Math.trunc(providerInitializationTimeoutMs)));
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await Promise.race([load({ signal: stageAbort.signal }), deadline]);
|
||||||
|
return !cancelled() && !stageAbort.signal.aborted;
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled()) onProviderError?.(provider, error);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (timeout !== undefined) clearTimeout(timeout);
|
||||||
|
signal?.removeEventListener("abort", abortStage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const waitForViewport = async (wait) => {
|
||||||
|
if (!wait || cancelled()) return;
|
||||||
|
try {
|
||||||
|
await wait();
|
||||||
|
} catch {
|
||||||
|
// A readiness gate controls ordering only. The provider's own error
|
||||||
|
// event remains the authority for availability and user diagnostics.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageryReady = await loadProvider("imagery", loadImagery);
|
||||||
|
if (imageryReady) await waitForViewport(waitAfterImagery);
|
||||||
|
|
||||||
|
const terrainReady = await loadProvider("terrain", loadTerrain);
|
||||||
|
if (terrainReady) await waitForViewport(waitAfterTerrain);
|
||||||
|
|
||||||
|
await loadProvider("buildings", loadBuildings);
|
||||||
|
|
||||||
|
if (loadDeferred && !cancelled()) {
|
||||||
|
try {
|
||||||
|
await loadDeferred();
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled()) onDeferredError?.(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cancelled() ? "cancelled" : "complete";
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ export const defaultMapReferencePresentationProfiles: readonly MapPresentationPr
|
|||||||
semanticTypes: [...definition.semanticTypes],
|
semanticTypes: [...definition.semanticTypes],
|
||||||
label: {
|
label: {
|
||||||
mode: "attributes",
|
mode: "attributes",
|
||||||
fields: ["name", "official_name", "local_name"],
|
fields: ["name", "official_name", "local_name", "alternate_names"],
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
sizePx: 20,
|
sizePx: 20,
|
||||||
color: "#cccccc",
|
color: "#cccccc",
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import type {
|
||||||
|
GraticuleSectorSummary,
|
||||||
|
LocalSectorSummary,
|
||||||
|
} from "./mapSectorGrid.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider-neutral scene settings consumed by a renderer adapter.
|
||||||
|
*
|
||||||
|
* The persisted Map Page contract may use these fields, but it never stores
|
||||||
|
* Cesium entities, provider endpoints, credentials or raw provider payloads.
|
||||||
|
*/
|
||||||
|
export type MapPresentation = {
|
||||||
|
imagerySource: "cesium-live";
|
||||||
|
imageryVisible: boolean;
|
||||||
|
cacheEnabled: boolean;
|
||||||
|
cacheNoOverwrite: boolean;
|
||||||
|
terrainEnabled: boolean;
|
||||||
|
terrainExaggeration: number;
|
||||||
|
monochrome: boolean;
|
||||||
|
monochromeColor: string;
|
||||||
|
imageryGamma: number;
|
||||||
|
imageryHue: number;
|
||||||
|
imageryAlpha: number;
|
||||||
|
globeColor: string;
|
||||||
|
backgroundColor: string;
|
||||||
|
atmosphereEnabled: boolean;
|
||||||
|
atmosphereHue: number;
|
||||||
|
atmosphereSaturation: number;
|
||||||
|
atmosphereBrightness: number;
|
||||||
|
fogEnabled: boolean;
|
||||||
|
fogDensity: number;
|
||||||
|
sunEnabled: boolean;
|
||||||
|
sunHour: number;
|
||||||
|
sunIntensity: number;
|
||||||
|
shadowsEnabled: boolean;
|
||||||
|
buildingsVisible: boolean;
|
||||||
|
buildingsColor: string;
|
||||||
|
buildingsOpacity: number;
|
||||||
|
buildingsDetail: number;
|
||||||
|
imageryBrightness: number;
|
||||||
|
imageryContrast: number;
|
||||||
|
imagerySaturation: number;
|
||||||
|
gridVisible: boolean;
|
||||||
|
gridLodEnabled: boolean;
|
||||||
|
grid3dEnabled: boolean;
|
||||||
|
gridGraticuleEnabled: boolean;
|
||||||
|
gridCenterMode: "fixed";
|
||||||
|
gridCenterLatitude: number;
|
||||||
|
gridCenterLongitude: number;
|
||||||
|
gridTileSizeKm: number;
|
||||||
|
gridAutoDisableHeightKm: number;
|
||||||
|
gridRebuildOnMoveEnd: boolean;
|
||||||
|
gridLegacyMode: boolean;
|
||||||
|
gridMax3dViewAngleDegrees: number;
|
||||||
|
gridHeightMeters: number;
|
||||||
|
gridLod1MaxHeightKm: number;
|
||||||
|
gridLod1StepKm: number;
|
||||||
|
gridLod1Mode: "3d" | "graticule";
|
||||||
|
gridLod2MaxHeightKm: number;
|
||||||
|
gridLod2StepKm: number;
|
||||||
|
gridLod2Mode: "3d" | "graticule";
|
||||||
|
gridLod3MaxHeightKm: number;
|
||||||
|
gridLod3StepKm: number;
|
||||||
|
gridLod3Mode: "3d" | "graticule";
|
||||||
|
gridLod4MaxHeightKm: number;
|
||||||
|
gridLod4StepKm: number;
|
||||||
|
gridLod4Mode: "3d" | "graticule";
|
||||||
|
gridLod5StepKm: number;
|
||||||
|
gridLod5MaxHeightKm: number;
|
||||||
|
gridLod5Mode: "3d" | "graticule";
|
||||||
|
gridRadiusKm: number;
|
||||||
|
gridLineWidth: number;
|
||||||
|
gridLineDiameterMeters: number;
|
||||||
|
gridColor: string;
|
||||||
|
gridOpacity: number;
|
||||||
|
gridDotsEnabled: boolean;
|
||||||
|
gridDotsSize: number;
|
||||||
|
gridDotsDiameterMeters: number;
|
||||||
|
gridDotsColor: string;
|
||||||
|
gridDotsOpacity: number;
|
||||||
|
gridCrossesEnabled: boolean;
|
||||||
|
gridCrossesLengthMeters: number;
|
||||||
|
gridCrossesWidthMeters: number;
|
||||||
|
gridCrossesColor: string;
|
||||||
|
gridCrossesOpacity: number;
|
||||||
|
gridLodProfiles: GridLodProfile[];
|
||||||
|
/** One-shot renderer command; it is intentionally excluded from persistence. */
|
||||||
|
cacheRefresh: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GridLodProfile = {
|
||||||
|
maxHeightKm: number;
|
||||||
|
stepKm: number;
|
||||||
|
mode: "3d" | "graticule";
|
||||||
|
heightMeters: number;
|
||||||
|
max3dViewAngleDegrees: number;
|
||||||
|
tileSizeKm: number;
|
||||||
|
radiusKm: number;
|
||||||
|
lineDiameterMeters: number;
|
||||||
|
lineColor: string;
|
||||||
|
lineOpacity: number;
|
||||||
|
dotsEnabled: boolean;
|
||||||
|
dotsDiameterMeters: number;
|
||||||
|
dotsColor: string;
|
||||||
|
dotsOpacity: number;
|
||||||
|
crossesEnabled: boolean;
|
||||||
|
crossesLengthMeters: number;
|
||||||
|
crossesWidthMeters: number;
|
||||||
|
crossesColor: string;
|
||||||
|
crossesOpacity: number;
|
||||||
|
graticuleStepDegrees: number;
|
||||||
|
graticuleLineWidthPx: number;
|
||||||
|
graticuleColor: string;
|
||||||
|
graticuleOpacity: number;
|
||||||
|
majorLinesEnabled: boolean;
|
||||||
|
majorLabelsEnabled: boolean;
|
||||||
|
majorLineWidthMultiplier: number;
|
||||||
|
volumeEnabled: boolean;
|
||||||
|
volumeMinimumHeightMeters: number;
|
||||||
|
volumeMaximumHeightMeters: number;
|
||||||
|
volumeBandHeightMeters: number;
|
||||||
|
selectionFillColor: string;
|
||||||
|
selectionFillOpacityPercent: number;
|
||||||
|
selectionOutlineColor: string;
|
||||||
|
selectionOutlineWidthPx: number;
|
||||||
|
selectionOutlineOpacityPercent: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GridVolumeSelection = {
|
||||||
|
id: string;
|
||||||
|
index: number;
|
||||||
|
floor: number;
|
||||||
|
ceiling: number;
|
||||||
|
bandHeight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LocalGridSectorSelection = LocalSectorSummary & {
|
||||||
|
mode: "3d";
|
||||||
|
units: "meters-enu";
|
||||||
|
volume: GridVolumeSelection | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GraticuleGridSectorSelection = GraticuleSectorSummary & {
|
||||||
|
mode: "graticule";
|
||||||
|
units: "degrees-wgs84";
|
||||||
|
volume: null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GridSectorSelection = LocalGridSectorSelection | GraticuleGridSectorSelection;
|
||||||
|
export type GridMajorTileSelection = NonNullable<GridSectorSelection["parentMajorTile"]>;
|
||||||
|
|
||||||
|
export type MapCameraView = {
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
height: number;
|
||||||
|
heading: number;
|
||||||
|
pitch: number;
|
||||||
|
roll: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CameraSpiralConfig = {
|
||||||
|
heightAboveGroundMeters: number;
|
||||||
|
speedMetersPerSecond: number;
|
||||||
|
pitchMetersPerTurn: number;
|
||||||
|
targetRadiusMeters?: number;
|
||||||
|
viewPitchRadians?: number;
|
||||||
|
waitForTiles?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CameraSpiralState = {
|
||||||
|
running: boolean;
|
||||||
|
reason?: "stopped" | "mode_disabled" | "renderer_restarted" | "page_hidden" | "render_error" | "spiral_extent_limit" | "spiral_runtime_error" | "terrain_sampling_error" | "tile_loading_timeout" | "tile_loading_error" | "target_radius_reached";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapGatewayHealth = {
|
||||||
|
cache?: {
|
||||||
|
mode?: string;
|
||||||
|
writePolicy?: string;
|
||||||
|
entries?: number;
|
||||||
|
bytes?: number;
|
||||||
|
maxBytes?: number;
|
||||||
|
atCapacity?: boolean;
|
||||||
|
persistent?: boolean;
|
||||||
|
byResourceKind?: Record<string, { entries?: number; bytes?: number }>;
|
||||||
|
};
|
||||||
|
diagnostics?: {
|
||||||
|
cacheHits?: number;
|
||||||
|
cacheMisses?: number;
|
||||||
|
cacheRefreshes?: number;
|
||||||
|
upstreamRequests?: number;
|
||||||
|
egressRequests?: number;
|
||||||
|
upstreamFailures?: number;
|
||||||
|
slowUpstreamRequests?: number;
|
||||||
|
lastFailure?: string | null;
|
||||||
|
lastFailureAt?: string | null;
|
||||||
|
};
|
||||||
|
referenceSources?: {
|
||||||
|
transportStations?: {
|
||||||
|
profileId?: string;
|
||||||
|
seedFactCount?: number;
|
||||||
|
fetchEnabled?: boolean;
|
||||||
|
cellDegrees?: number;
|
||||||
|
cachedCellCount?: number;
|
||||||
|
indexedFactCount?: number;
|
||||||
|
upstreamRequests?: number;
|
||||||
|
upstreamFailures?: number;
|
||||||
|
searchRequests?: number;
|
||||||
|
searchFailures?: number;
|
||||||
|
upstreamState?: "idle" | "ready" | "degraded";
|
||||||
|
activeFetches?: number;
|
||||||
|
queuedFetches?: number;
|
||||||
|
lastRefreshAt?: string | null;
|
||||||
|
lastFailure?: string | null;
|
||||||
|
lastFailureAt?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
providerCache?: Array<{
|
||||||
|
assetId?: number;
|
||||||
|
type?: string;
|
||||||
|
cachedEndpoint?: boolean;
|
||||||
|
credentialUsable?: boolean;
|
||||||
|
entryPointCached?: boolean;
|
||||||
|
}>;
|
||||||
|
ionConfigured?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapProviderState = "loading" | "ready" | "error" | "not-configured";
|
||||||
|
|
||||||
|
export type MapProviderStatus = {
|
||||||
|
imagery: MapProviderState;
|
||||||
|
terrain: MapProviderState;
|
||||||
|
buildings: MapProviderState;
|
||||||
|
errors: Partial<Record<"imagery" | "terrain" | "buildings" | "projection", string>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Imperative adapter surface; callers never receive a Cesium object. */
|
||||||
|
export type MapRendererHandle = {
|
||||||
|
startSpiralAnimation: (config: CameraSpiralConfig) => boolean;
|
||||||
|
stopSpiralAnimation: (reason?: CameraSpiralState["reason"]) => void;
|
||||||
|
getCameraView: () => MapCameraView | null;
|
||||||
|
fitRuntimeEntities: (entityIds?: string[]) => boolean;
|
||||||
|
focusRuntimeEntity: (entityId: string) => boolean;
|
||||||
|
focusSubjectCoordinates: (longitude: number, latitude: number) => boolean;
|
||||||
|
focusCoordinates: (longitude: number, latitude: number) => boolean;
|
||||||
|
focusGridSector: (sector: GridSectorSelection) => boolean;
|
||||||
|
focusGridMajorTile: (tile: GridMajorTileSelection) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @deprecated Use the provider-neutral MapRendererHandle name. */
|
||||||
|
export type CesiumMapRendererHandle = MapRendererHandle;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import type { MapRuntimeFact } from "./useMapDataProductRuntime.js";
|
||||||
|
|
||||||
|
export function mapRuntimeFactKey(fact: Pick<MapRuntimeFact, "sourceId" | "semanticType">): string;
|
||||||
|
export function mapRuntimeEntityId(bindingId: string, fact: Pick<MapRuntimeFact, "sourceId" | "semanticType">): string;
|
||||||
@@ -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,4 +1,4 @@
|
|||||||
import type { MapDataProductBinding } from "./MapFixturePreview.js";
|
import type { MapDataProductBinding } from "./mapPageContract.js";
|
||||||
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
|
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
|
||||||
import type { MapRuntimeBinding } from "./useMapDataProductRuntime.js";
|
import type { MapRuntimeBinding } from "./useMapDataProductRuntime.js";
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)}°`;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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])];
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import type { MapDataProductBinding } from "./MapFixturePreview.js";
|
import type { MapDataProductBinding } from "./mapPageContract.js";
|
||||||
|
import { mapRuntimeFactKey } from "./mapRuntimeIdentity.mjs";
|
||||||
|
|
||||||
|
export { mapRuntimeEntityId } from "./mapRuntimeIdentity.mjs";
|
||||||
|
|
||||||
export type DataProductPoint = {
|
export type DataProductPoint = {
|
||||||
type: "Point";
|
type: "Point";
|
||||||
@@ -78,14 +81,6 @@ type BindingState = {
|
|||||||
const identifier = /^[A-Za-z0-9._:-]{1,160}$/;
|
const identifier = /^[A-Za-z0-9._:-]{1,160}$/;
|
||||||
const cursorPattern = /^(?:0|[1-9]\d*)$/;
|
const cursorPattern = /^(?:0|[1-9]\d*)$/;
|
||||||
|
|
||||||
function factKey(fact: MapRuntimeFact) {
|
|
||||||
return `${fact.semanticType}\u0000${fact.sourceId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapRuntimeEntityId(bindingId: string, fact: Pick<MapRuntimeFact, "sourceId" | "semanticType">) {
|
|
||||||
return `nodedc-runtime:${bindingId}:${fact.semanticType}:${fact.sourceId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isIsoTimestamp(value: unknown): value is string {
|
function isIsoTimestamp(value: unknown): value is string {
|
||||||
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
||||||
}
|
}
|
||||||
@@ -246,7 +241,7 @@ function replaceSnapshot(current: BindingState, snapshot: SnapshotEnvelope): Bin
|
|||||||
...current,
|
...current,
|
||||||
cursor: snapshot.cursor,
|
cursor: snapshot.cursor,
|
||||||
state: "ready",
|
state: "ready",
|
||||||
facts: Object.fromEntries(snapshot.facts.map((fact) => [factKey(fact), fact])),
|
facts: Object.fromEntries(snapshot.facts.map((fact) => [mapRuntimeFactKey(fact), fact])),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,8 +252,8 @@ function applyPatch(current: BindingState, patch: PatchEnvelope): BindingState |
|
|||||||
if (current.cursor !== patch.previousCursor) return null;
|
if (current.cursor !== patch.previousCursor) return null;
|
||||||
const facts = { ...current.facts };
|
const facts = { ...current.facts };
|
||||||
for (const operation of patch.operations) {
|
for (const operation of patch.operations) {
|
||||||
if (operation.op === "upsert") facts[factKey(operation.fact)] = operation.fact;
|
if (operation.op === "upsert") facts[mapRuntimeFactKey(operation.fact)] = operation.fact;
|
||||||
else delete facts[`${operation.semanticType}\u0000${operation.sourceId}`];
|
else delete facts[mapRuntimeFactKey(operation)];
|
||||||
}
|
}
|
||||||
return { ...current, facts, cursor: patch.cursor, state: "ready" };
|
return { ...current, facts, cursor: patch.cursor, state: "ready" };
|
||||||
}
|
}
|
||||||
@@ -266,7 +261,7 @@ function applyPatch(current: BindingState, patch: PatchEnvelope): BindingState |
|
|||||||
function applyPresentationPatch(current: BindingState, patch: PresentationPatchEnvelope): BindingState {
|
function applyPresentationPatch(current: BindingState, patch: PresentationPatchEnvelope): BindingState {
|
||||||
const facts = { ...current.facts };
|
const facts = { ...current.facts };
|
||||||
for (const operation of patch.operations) {
|
for (const operation of patch.operations) {
|
||||||
const key = `${operation.semanticType}\u0000${operation.sourceId}`;
|
const key = mapRuntimeFactKey(operation);
|
||||||
if (facts[key]) facts[key] = { ...facts[key], presentationStatus: operation.status };
|
if (facts[key]) facts[key] = { ...facts[key], presentationStatus: operation.status };
|
||||||
}
|
}
|
||||||
return { ...current, facts };
|
return { ...current, facts };
|
||||||
@@ -421,7 +416,7 @@ export function useMapDataProductRuntime({
|
|||||||
dataProductId: binding.dataProductId,
|
dataProductId: binding.dataProductId,
|
||||||
slotId: binding.slotId,
|
slotId: binding.slotId,
|
||||||
presentationProfileId: binding.presentationProfileId,
|
presentationProfileId: binding.presentationProfileId,
|
||||||
facts: Object.values(record.facts).sort((left, right) => factKey(left).localeCompare(factKey(right))),
|
facts: Object.values(record.facts).sort((left, right) => mapRuntimeFactKey(left).localeCompare(mapRuntimeFactKey(right))),
|
||||||
cursor: record.cursor,
|
cursor: record.cursor,
|
||||||
state: record.state,
|
state: record.state,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -211,7 +211,13 @@ function asFact(value: unknown): MapRuntimeFact | null {
|
|||||||
|| !isIso(value.observedAt) || !isIso(value.receivedAt)
|
|| !isIso(value.observedAt) || !isIso(value.receivedAt)
|
||||||
|| !isObject(value.attributes) || !categories.has(value.attributes.category as MapReferenceStationCategory)
|
|| !isObject(value.attributes) || !categories.has(value.attributes.category as MapReferenceStationCategory)
|
||||||
|| !isPoint(value.geometry)) return null;
|
|| !isPoint(value.geometry)) return null;
|
||||||
const allowedAttributes = new Set(["name", "category", "network", "operator", "official_name", "local_name", "uic_ref", "wheelchair"]);
|
if (value.attributes.alternate_names !== undefined
|
||||||
|
&& (!Array.isArray(value.attributes.alternate_names)
|
||||||
|
|| value.attributes.alternate_names.length > 16
|
||||||
|
|| value.attributes.alternate_names.some((name) => typeof name !== "string" || !name.trim() || name.length > 256))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const allowedAttributes = new Set(["name", "category", "network", "operator", "official_name", "local_name", "alternate_names", "uic_ref", "wheelchair"]);
|
||||||
return {
|
return {
|
||||||
sourceId: value.sourceId,
|
sourceId: value.sourceId,
|
||||||
semanticType: String(value.semanticType),
|
semanticType: String(value.semanticType),
|
||||||
|
|||||||
+3
-1
@@ -11,7 +11,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react && npm run build --workspace @nodedc/ui-catalog",
|
"build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react && npm run build --workspace @nodedc/ui-catalog",
|
||||||
"build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react",
|
"build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react",
|
||||||
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:hgeozone-projection && npm run test:map-grid-lod && npm run test:map-filters && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile",
|
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:hgeozone-projection && npm run test:map-provider-startup && npm run test:map-grid-lod && npm run test:map-filters && npm run test:map-workspace-model && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile",
|
||||||
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
|
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
|
||||||
"serve": "node server/catalog-server.mjs",
|
"serve": "node server/catalog-server.mjs",
|
||||||
"validate:registry": "node scripts/validate-registry.mjs",
|
"validate:registry": "node scripts/validate-registry.mjs",
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"test:foundry-agent": "node --test server/foundry-agent-store.test.mjs server/foundry-agent-gateway.test.mjs scripts/foundry-agent-installer.test.mjs",
|
"test:foundry-agent": "node --test server/foundry-agent-store.test.mjs server/foundry-agent-gateway.test.mjs scripts/foundry-agent-installer.test.mjs",
|
||||||
"test:map-animation": "node --test scripts/map-spiral.test.mjs scripts/map-camera-presets.test.mjs",
|
"test:map-animation": "node --test scripts/map-spiral.test.mjs scripts/map-camera-presets.test.mjs",
|
||||||
"test:map-filters": "node --test scripts/map-presentation-filters.test.mjs",
|
"test:map-filters": "node --test scripts/map-presentation-filters.test.mjs",
|
||||||
|
"test:map-workspace-model": "node --test scripts/map-workspace-model.test.mjs scripts/map-workspace-state.test.mjs",
|
||||||
"test:hgeozone-projection": "node --test scripts/hgeozone-projection.test.mjs",
|
"test:hgeozone-projection": "node --test scripts/hgeozone-projection.test.mjs",
|
||||||
"test:map-grid-lod": "node --test scripts/map-grid-lod.test.mjs scripts/map-sector-grid.test.mjs server/map-grid-persistence.test.mjs",
|
"test:map-grid-lod": "node --test scripts/map-grid-lod.test.mjs scripts/map-sector-grid.test.mjs server/map-grid-persistence.test.mjs",
|
||||||
"test:map-object-layers": "node --test scripts/map-object-layers.test.mjs",
|
"test:map-object-layers": "node --test scripts/map-object-layers.test.mjs",
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"test:map-subject-card": "node --test scripts/map-subject-card.test.mjs",
|
"test:map-subject-card": "node --test scripts/map-subject-card.test.mjs",
|
||||||
"test:map-subject-detail-profile": "node --test server/map-subject-detail-profile.test.mjs server/map-live-data-slot.test.mjs",
|
"test:map-subject-detail-profile": "node --test server/map-subject-detail-profile.test.mjs server/map-live-data-slot.test.mjs",
|
||||||
"test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs",
|
"test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs",
|
||||||
|
"test:map-provider-startup": "node --test scripts/map-provider-startup.test.mjs",
|
||||||
"test:floating-position": "node --test scripts/floating-position-contract.test.mjs",
|
"test:floating-position": "node --test scripts/floating-position-contract.test.mjs",
|
||||||
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs",
|
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs",
|
||||||
"test:range-control": "node --test scripts/range-control-contract.test.mjs"
|
"test:range-control": "node --test scripts/range-control-contract.test.mjs"
|
||||||
|
|||||||
@@ -107,7 +107,32 @@ export function Dropdown({
|
|||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
updatePosition();
|
updatePosition();
|
||||||
}, [isOpen, updatePosition]);
|
|
||||||
|
const surface = surfaceRef.current;
|
||||||
|
const anchor = anchorElement ?? triggerElement;
|
||||||
|
if (!surface || typeof ResizeObserver === "undefined") {
|
||||||
|
const frame = window.requestAnimationFrame(updatePosition);
|
||||||
|
return () => window.cancelAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
let frame: number | null = null;
|
||||||
|
const schedulePositionUpdate = () => {
|
||||||
|
if (frame !== null) return;
|
||||||
|
frame = window.requestAnimationFrame(() => {
|
||||||
|
frame = null;
|
||||||
|
updatePosition();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const resizeObserver = new ResizeObserver(schedulePositionUpdate);
|
||||||
|
resizeObserver.observe(surface);
|
||||||
|
if (anchor) resizeObserver.observe(anchor);
|
||||||
|
schedulePositionUpdate();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
if (frame !== null) window.cancelAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
}, [anchorElement, isOpen, triggerElement, updatePosition]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
|||||||
@@ -49,15 +49,18 @@ test("floating surface remains bounded when its anchor is below the viewport", (
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("dropdown scroll keeps portal geometry stable and contained", async () => {
|
test("dropdown scroll keeps portal geometry stable and contained", async () => {
|
||||||
const styles = await readFile(
|
const [styles, dropdown] = await Promise.all([
|
||||||
new URL("../packages/ui-core/styles.css", import.meta.url),
|
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
|
||||||
"utf8",
|
readFile(new URL("../packages/ui-react/src/Dropdown.tsx", import.meta.url), "utf8"),
|
||||||
);
|
]);
|
||||||
const dropdownRule = styles.match(/\.nodedc-dropdown-surface \{(?<body>[\s\S]*?)\n\}/)?.groups?.body ?? "";
|
const dropdownRule = styles.match(/\.nodedc-dropdown-surface \{(?<body>[\s\S]*?)\n\}/)?.groups?.body ?? "";
|
||||||
|
|
||||||
assert.match(dropdownRule, /box-sizing:\s*border-box;/);
|
assert.match(dropdownRule, /box-sizing:\s*border-box;/);
|
||||||
assert.match(dropdownRule, /overflow:\s*auto;/);
|
assert.match(dropdownRule, /overflow:\s*auto;/);
|
||||||
assert.match(dropdownRule, /overscroll-behavior:\s*contain;/);
|
assert.match(dropdownRule, /overscroll-behavior:\s*contain;/);
|
||||||
|
assert.match(dropdown, /new ResizeObserver\(schedulePositionUpdate\)/);
|
||||||
|
assert.match(dropdown, /resizeObserver\.observe\(surface\)/);
|
||||||
|
assert.match(dropdown, /window\.requestAnimationFrame\(updatePosition\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("workspace windows keep their glass rim without masked compositor overlays", async () => {
|
test("workspace windows keep their glass rim without masked compositor overlays", async () => {
|
||||||
|
|||||||
@@ -48,14 +48,14 @@ test("Inspector hard-forces a raw integrated Select to the split presentation",
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Foundry Inspector screens consume the semantic selection field", async () => {
|
test("Foundry Inspector screens consume the semantic selection field", async () => {
|
||||||
const [mapPreview, catalog, coreStyles] = await Promise.all([
|
const [mapInspector, catalog, coreStyles] = await Promise.all([
|
||||||
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../apps/catalog/src/mapInspectorSections.tsx", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../apps/catalog/src/CatalogApp.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../apps/catalog/src/CatalogApp.tsx", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
|
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.match(mapPreview, /<InspectorSelectField\s+label="Профиль покрытия"/);
|
assert.match(mapInspector, /<InspectorSelectField\s+label="Профиль покрытия"/);
|
||||||
assert.doesNotMatch(mapPreview, /<ControlRow label="Профиль покрытия">/);
|
assert.doesNotMatch(mapInspector, /<ControlRow label="Профиль покрытия">/);
|
||||||
assert.equal((catalog.match(/<InspectorSelectField/g) ?? []).length, 2);
|
assert.equal((catalog.match(/<InspectorSelectField/g) ?? []).length, 2);
|
||||||
assert.match(coreStyles, /\.nodedc-inspector \.nodedc-control-row:has\(\.nodedc-select-anchor\)/);
|
assert.match(coreStyles, /\.nodedc-inspector \.nodedc-control-row:has\(\.nodedc-select-anchor\)/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -315,29 +315,32 @@ test("grid center is fixed and camera-independent, including for legacy camera-m
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("renderer uses fixed ENU sectors, angular graticules and a non-blank double-buffer swap", async () => {
|
test("renderer uses fixed ENU sectors, angular graticules and a non-blank double-buffer swap", async () => {
|
||||||
const source = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8");
|
const [renderer, gridLayer] = await Promise.all([
|
||||||
assert.match(source, /class GridLayerController/);
|
readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"),
|
||||||
assert.match(source, /dataSourceDisplay\.ready/);
|
readFile(new URL("../apps/catalog/src/mapCesiumGridLayer.ts", import.meta.url), "utf8"),
|
||||||
assert.match(source, /fixedGridOrigin\(presentation\)/);
|
]);
|
||||||
assert.match(source, /Transforms\.eastNorthUpToFixedFrame\(anchor\)/);
|
assert.match(gridLayer, /class GridLayerController/);
|
||||||
assert.match(source, /localSectorAt\(/);
|
assert.match(gridLayer, /dataSourceDisplay\.ready/);
|
||||||
assert.match(source, /gridController\?\.pick\(worldPosition\)/);
|
assert.match(gridLayer, /fixedGridOrigin\(presentation\)/);
|
||||||
assert.match(source, /lod\.stepKm \* 1_000/);
|
assert.match(gridLayer, /Transforms\.eastNorthUpToFixedFrame\(anchor\)/);
|
||||||
assert.match(source, /lod\.graticuleStepDegrees/);
|
assert.match(gridLayer, /localSectorAt\(/);
|
||||||
assert.match(source, /lod\.graticuleLineWidthPx/);
|
assert.match(renderer, /gridController\?\.pick\(worldPosition\)/);
|
||||||
assert.match(source, /arcType: ArcType\.RHUMB/);
|
assert.match(gridLayer, /lod\.stepKm \* 1_000/);
|
||||||
assert.match(source, /clampToGround: false/);
|
assert.match(gridLayer, /lod\.graticuleStepDegrees/);
|
||||||
assert.match(source, /majorLineWidthMultiplier/);
|
assert.match(gridLayer, /lod\.graticuleLineWidthPx/);
|
||||||
assert.match(source, /isLocalMajorLineIndex/);
|
assert.match(gridLayer, /arcType: ArcType\.RHUMB/);
|
||||||
assert.match(source, /isGraticuleMajorLineValue/);
|
assert.match(gridLayer, /clampToGround: false/);
|
||||||
assert.match(source, /new LabelCollection\(\)/);
|
assert.match(gridLayer, /majorLineWidthMultiplier/);
|
||||||
assert.match(source, /const maximumLabels = 48/);
|
assert.match(gridLayer, /isLocalMajorLineIndex/);
|
||||||
assert.match(source, /materializeLocalSelection/);
|
assert.match(gridLayer, /isGraticuleMajorLineValue/);
|
||||||
assert.match(source, /localVolumeAt\(/);
|
assert.match(gridLayer, /new LabelCollection\(\)/);
|
||||||
assert.match(source, /focusGridSector/);
|
assert.match(gridLayer, /const maximumLabels = 48/);
|
||||||
assert.match(source, /selectedGridSector/);
|
assert.match(gridLayer, /materializeLocalSelection/);
|
||||||
assert.match(source, /mountResources\(resources\)[\s\S]*?const previous = this\.current;[\s\S]*?removeResources\(previous\.resources\)/);
|
assert.match(gridLayer, /localVolumeAt\(/);
|
||||||
assert.doesNotMatch(source, /function rebuildElevatedGrid[\s\S]*?entities\.removeAll\(\)/);
|
assert.match(renderer, /focusGridSector/);
|
||||||
assert.doesNotMatch(source, /Math\.min\(40,[\s\S]*?safeRadiusKm \/ safeStepKm/);
|
assert.match(renderer, /selectedGridSector/);
|
||||||
assert.doesNotMatch(source, /snapGridCenter\(/);
|
assert.match(gridLayer, /mountResources\(resources\)[\s\S]*?const previous = this\.current;[\s\S]*?removeResources\(previous\.resources\)/);
|
||||||
|
assert.doesNotMatch(gridLayer, /function rebuildElevatedGrid[\s\S]*?entities\.removeAll\(\)/);
|
||||||
|
assert.doesNotMatch(gridLayer, /Math\.min\(40,[\s\S]*?safeRadiusKm \/ safeStepKm/);
|
||||||
|
assert.doesNotMatch(gridLayer, /snapGridCenter\(/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,17 +3,17 @@ import { readFile } from "node:fs/promises";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
test("Cesium elevated targets keep their profile border while labels occlude them inside the scene overlay", async () => {
|
test("Cesium elevated targets keep their profile border while labels occlude them inside the scene overlay", async () => {
|
||||||
const [renderer, cesiumDisplay] = await Promise.all([
|
const [runtimeLayers, cesiumDisplay] = await Promise.all([
|
||||||
readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../apps/catalog/src/mapCesiumRuntimeLayers.ts", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../node_modules/@cesium/engine/Source/DataSources/DataSourceDisplay.js", import.meta.url), "utf8"),
|
readFile(new URL("../node_modules/@cesium/engine/Source/DataSources/DataSourceDisplay.js", import.meta.url), "utf8"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.match(renderer, /BillboardGraphics/);
|
assert.match(runtimeLayers, /BillboardGraphics/);
|
||||||
assert.match(renderer, /elevatedTargetImage\(\s*color,\s*Color\.fromCssColorString\(target\.outlineColor\)\.withAlpha\(target\.outlineOpacity\),\s*target\.outlineWidthPx,\s*target\.headSizePx/);
|
assert.match(runtimeLayers, /elevatedTargetImage\(\s*color,\s*Color\.fromCssColorString\(target\.outlineColor\)\.withAlpha\(target\.outlineOpacity\),\s*target\.outlineWidthPx,\s*target\.headSizePx/);
|
||||||
assert.match(renderer, /entity\.point = undefined;\s*entity\.billboard = new BillboardGraphics/);
|
assert.match(runtimeLayers, /entity\.point = undefined;\s*entity\.billboard = new BillboardGraphics/);
|
||||||
assert.match(renderer, /disableDepthTestDistance: Number\.POSITIVE_INFINITY/);
|
assert.match(runtimeLayers, /disableDepthTestDistance: Number\.POSITIVE_INFINITY/);
|
||||||
assert.match(renderer, /outlineWidth: 0,\s*style: LabelStyle\.FILL/);
|
assert.match(runtimeLayers, /outlineWidth: 0,\s*style: LabelStyle\.FILL/);
|
||||||
assert.doesNotMatch(renderer, /style: 2/);
|
assert.doesNotMatch(runtimeLayers, /style: 2/);
|
||||||
assert.ok(cesiumDisplay.indexOf("new BillboardVisualizer") < cesiumDisplay.indexOf("new LabelVisualizer"));
|
assert.ok(cesiumDisplay.indexOf("new BillboardVisualizer") < cesiumDisplay.indexOf("new LabelVisualizer"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,71 +2,92 @@ import assert from "node:assert/strict";
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
test("Objects menu opens controllable groups from the row and keeps plain bindings as visibility layers", async () => {
|
const source = (path) => readFile(new URL(path, import.meta.url), "utf8");
|
||||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
|
||||||
|
|
||||||
assert.match(preview, /const toggleSubjectVisibility = \(bindingId: string\)/);
|
test("Objects menu delegates controlled layers to the workspace reducer", async () => {
|
||||||
assert.match(preview, /role=\{hasControls \? "menuitem" : "menuitemcheckbox"\}/);
|
const [preview, toolbar, state] = await Promise.all([
|
||||||
assert.match(preview, /if \(hasControls\) \{\s*openSubjectWindow\(summary\.bindingId\);\s*close\(\)/);
|
source("../apps/catalog/src/MapFixturePreview.tsx"),
|
||||||
assert.match(preview, /toggleSubjectVisibility\(summary\.bindingId\)/);
|
source("../apps/catalog/src/MapWorkspaceToolbar.tsx"),
|
||||||
assert.match(preview, /visible: !state\.visible/);
|
source("../apps/catalog/src/mapWorkspaceState.mjs"),
|
||||||
assert.match(preview, /слой скрыт · \$\{summary\.total\} объектов/);
|
]);
|
||||||
assert.doesNotMatch(preview, /Фильтры и счётчики: \$\{summary\.displayName\}/);
|
|
||||||
|
assert.match(toolbar, /role=\{hasControls \? "menuitem" : "menuitemcheckbox"\}/);
|
||||||
|
assert.match(toolbar, /onOpenSubjectWindow\(summary\.bindingId\)/);
|
||||||
|
assert.match(toolbar, /onToggleSubjectVisibility\(summary\.bindingId\)/);
|
||||||
|
assert.match(toolbar, /слой скрыт · \$\{summary\.total\} объектов/);
|
||||||
|
assert.match(preview, /type: "toggle-subject-visibility", bindingId/);
|
||||||
|
assert.match(state, /visible: !subject\.visible/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a subject window exists only when the presentation profile exposes controls", async () => {
|
test("a subject window exists only when the MCP presentation profile exposes controls", async () => {
|
||||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
const [windows, model, state] = await Promise.all([
|
||||||
|
source("../apps/catalog/src/MapSubjectWorkspaceWindows.tsx"),
|
||||||
|
source("../apps/catalog/src/mapWorkspaceModel.mjs"),
|
||||||
|
source("../apps/catalog/src/mapWorkspaceState.mjs"),
|
||||||
|
]);
|
||||||
|
|
||||||
assert.match(preview, /function hasSubjectWindowControls\(profile: MapPresentationProfile\)/);
|
assert.match(model, /export function mapProfileHasSubjectWindowControls/);
|
||||||
assert.match(preview, /if \(!state\?\.window\.open \|\| !hasSubjectWindowControls\(summary\.profile\)\) return null/);
|
assert.match(windows, /!state\?\.window\.open \|\| !mapProfileHasSubjectWindowControls\(summary\.profile\)/);
|
||||||
assert.match(preview, /window: \{ \.\.\.state\.window, open: false \}/);
|
assert.match(windows, /type: "close-subject-window"/);
|
||||||
|
assert.match(state, /window: \{ \.\.\.subject\.window, open: false \}/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("hidden and visible layers have an explicit visual state", async () => {
|
test("hidden and visible layers have an explicit visual state", async () => {
|
||||||
const styles = await readFile(new URL("../apps/catalog/src/styles.css", import.meta.url), "utf8");
|
const styles = await source("../apps/catalog/src/styles.css");
|
||||||
|
|
||||||
assert.match(styles, /\.catalog-map-fixture__objects-menu-item\[data-visible\]/);
|
assert.match(styles, /\.catalog-map-fixture__objects-menu-item\[data-visible\]/);
|
||||||
assert.match(styles, /\.catalog-map-fixture__objects-menu-item:not\(\[data-visible\]\)/);
|
assert.match(styles, /\.catalog-map-fixture__objects-menu-item:not\(\[data-visible\]\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("facet controls and renderer consume the same canonical enabled-value state", async () => {
|
test("facet controls and renderer consume one canonical enabled-value state", async () => {
|
||||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
const [preview, windows, state] = await Promise.all([
|
||||||
|
source("../apps/catalog/src/MapFixturePreview.tsx"),
|
||||||
|
source("../apps/catalog/src/MapSubjectWorkspaceWindows.tsx"),
|
||||||
|
source("../apps/catalog/src/mapWorkspaceState.mjs"),
|
||||||
|
]);
|
||||||
|
|
||||||
assert.match(preview, /initialSubjectState\(initialLayout\?\.dataProductBindings[\s\S]*?presentationProfiles\)/);
|
assert.match(preview, /normalizeMapPresentationFacetSelections\(state\.filters, profile\)/);
|
||||||
assert.match(preview, /facets: profile \? normalizeMapPresentationFacetSelections\(state\.filters, profile\) : state\.filters/);
|
assert.match(preview, /presentationFilters=\{rendererPresentationFilters\}/);
|
||||||
assert.match(preview, /filters: normalizeMapPresentationFacetSelections\(state\.filters, summary\.profile\)/);
|
assert.match(windows, /mapPresentationFacetValueIsEnabled\(state\.filters, facet\.field, item\.value\)/);
|
||||||
assert.match(preview, /const active = mapPresentationFacetValueIsEnabled\(state\.filters, facet\.field, item\.value\)/);
|
assert.match(windows, /facet\.values\.map\(\(value\) => value\.value\)/);
|
||||||
assert.match(preview, /toggleMapPresentationFacetSelection\(filters, field, value, availableValues\)/);
|
assert.match(state, /toggleMapPresentationFacetSelection\(/);
|
||||||
assert.match(preview, /facet\.values\.map\(\(value\) => value\.value\)/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("joined detail aspects do not become independent map layers", async () => {
|
test("joined detail aspects enrich subjects but never become independent renderer layers", async () => {
|
||||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
const [preview, model] = await Promise.all([
|
||||||
|
source("../apps/catalog/src/MapFixturePreview.tsx"),
|
||||||
|
source("../apps/catalog/src/mapWorkspaceModel.mjs"),
|
||||||
|
]);
|
||||||
|
|
||||||
assert.match(preview, /dataProductBindings\.filter\(\(binding\) => !binding\.joinToBindingId\)/);
|
assert.match(model, /filter\(\(binding\) => !binding\.joinToBindingId\)/);
|
||||||
assert.match(preview, /runtimeBindings\.filter\(\(binding\) => primaryBindingIds\.has\(binding\.bindingId\)\)/);
|
assert.match(model, /primaryMapBindingConfigs\(bindingConfigs\)/);
|
||||||
assert.match(preview, /runtimeBindings=\{\[\.\.\.sectorScopedPrimaryRuntimeBindings, \.\.\.referenceRuntimeBindings\]\}/);
|
assert.match(preview, /runtimeBindings=\{\[\.\.\.sectorScopedPrimaryRuntimeBindings, \.\.\.referenceRuntimeBindings\]\}/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("bounded map windows keep one stack while layers and settings use their canonical surfaces", async () => {
|
test("bounded map windows share one reducer-owned z-stack while settings and layers keep canonical surfaces", async () => {
|
||||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
const [preview, toolbar, windows, sectorWindow, state, contract] = await Promise.all([
|
||||||
|
source("../apps/catalog/src/MapFixturePreview.tsx"),
|
||||||
|
source("../apps/catalog/src/MapWorkspaceToolbar.tsx"),
|
||||||
|
source("../apps/catalog/src/MapSubjectWorkspaceWindows.tsx"),
|
||||||
|
source("../apps/catalog/src/MapSectorWorkspaceWindow.tsx"),
|
||||||
|
source("../apps/catalog/src/mapWorkspaceState.mjs"),
|
||||||
|
source("../apps/catalog/src/mapPageContract.ts"),
|
||||||
|
]);
|
||||||
|
|
||||||
assert.match(preview, /type MapWorkspaceWindowId = "sector" \| "subject-card" \| `binding:\$\{string\}`/);
|
assert.match(contract, /type MapWorkspaceWindowId = "sector" \| "subject-card" \| `binding:\$\{string\}`/);
|
||||||
assert.match(preview, /const activateWorkspaceWindow = useCallback/);
|
assert.match(state, /function activateWindow\(state, windowId\)/);
|
||||||
assert.match(preview, /onActivate=\{\(\) => activateWorkspaceWindow\(`binding:\$\{summary\.bindingId\}`\)\}/);
|
assert.match(state, /Math\.max\([\s\S]*state\.sector\.window\.zIndex[\s\S]*state\.subjectCard\.zIndex/);
|
||||||
assert.doesNotMatch(preview, /onActivate=\{\(\) => openSubjectWindow\(summary\.bindingId\)\}/);
|
assert.match(windows, /type: "activate-window", windowId: `binding:\$\{summary\.bindingId\}`/);
|
||||||
assert.match(preview, /sectorWindowZIndex[\s\S]*subjectCardZIndex/);
|
assert.match(sectorWindow, /onActivate=\{onActivate\}/);
|
||||||
assert.match(preview, /<ApplicationSidePanel[\s\S]*title="Настройки карты"[\s\S]*onClose=\{closeSettingsPanel\}/);
|
assert.match(preview, /<ApplicationSidePanel[\s\S]*title="Настройки карты"[\s\S]*onClose=\{closeSettingsPanel\}/);
|
||||||
assert.match(preview, /createPortal\(headerActions, headerActionsHost\)/);
|
assert.match(preview, /createPortal\(headerActions, headerActionsHost\)/);
|
||||||
assert.doesNotMatch(preview, /settingsPanelPinned|onPinnedChange/);
|
assert.match(toolbar, /surfaceClassName="catalog-map-fixture__objects-menu catalog-map-fixture__layers-menu nodedc-map-glass"/);
|
||||||
assert.match(preview, /surfaceClassName="catalog-map-fixture__objects-menu catalog-map-fixture__layers-menu nodedc-map-glass"/);
|
assert.match(toolbar, /onOpenChange=\{onOpenChange\}/);
|
||||||
assert.match(preview, /onOpenChange=\{setLayersOpen\}/);
|
assert.doesNotMatch(preview, /settingsPanelPinned|settingsWindowZIndex|layersWindowZIndex/);
|
||||||
assert.doesNotMatch(preview, /settingsWindowZIndex|layersWindowZIndex/);
|
|
||||||
assert.doesNotMatch(preview, /<Window\b/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("selecting a point or HGeoZone preserves the active grid sector", async () => {
|
test("selecting a point or HGeoZone preserves the active grid sector", async () => {
|
||||||
const renderer = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8");
|
const renderer = await source("../apps/catalog/src/CesiumMapRenderer.tsx");
|
||||||
const pointBranch = renderer.match(/if \(pickedId instanceof Entity[\s\S]*?return;\s*\}/)?.[0] ?? "";
|
const pointBranch = renderer.match(/if \(pickedId instanceof Entity[\s\S]*?return;\s*\}/)?.[0] ?? "";
|
||||||
const zoneBranch = renderer.match(/if \(\s*pickedId[\s\S]*?HGeoZonePickId\)\.entityId\);\s*return;\s*\}/)?.[0] ?? "";
|
const zoneBranch = renderer.match(/if \(\s*pickedId[\s\S]*?HGeoZonePickId\)\.entityId\);\s*return;\s*\}/)?.[0] ?? "";
|
||||||
|
|
||||||
@@ -77,59 +98,65 @@ test("selecting a point or HGeoZone preserves the active grid sector", async ()
|
|||||||
assert.match(renderer, /gridController\?\.setSelection\(gridSelection\);\s*onGridSectorSelectRef\.current\?\.\(gridSelection\)/);
|
assert.match(renderer, /gridController\?\.setSelection\(gridSelection\);\s*onGridSectorSelectRef\.current\?\.\(gridSelection\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("an active sector scopes point facts, exposes data facets and deactivates on close", async () => {
|
test("active sector scope is geometric, filterable and reducer-deactivated", async () => {
|
||||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
const [runtime, sectorWindow, state] = await Promise.all([
|
||||||
|
source("../apps/catalog/src/mapSectorRuntime.mjs"),
|
||||||
assert.match(preview, /function mapFactInsideGridSector/);
|
source("../apps/catalog/src/MapSectorWorkspaceWindow.tsx"),
|
||||||
assert.match(preview, /localSectorAtGeodetic/);
|
source("../apps/catalog/src/mapWorkspaceState.mjs"),
|
||||||
assert.match(preview, /label="Скрыть объекты за сектором"/);
|
|
||||||
assert.match(preview, />Домены данных</);
|
|
||||||
assert.match(preview, />Провайдеры</);
|
|
||||||
assert.match(preview, />Типы объектов</);
|
|
||||||
assert.match(preview, />Объекты сектора</);
|
|
||||||
assert.match(preview, /onClose=\{deactivateGridSector\}/);
|
|
||||||
assert.match(preview, />\s*Деактивировать сектор\s*</);
|
|
||||||
assert.match(preview, /setSelectedGridSector\(null\)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reference stations are first-class Objects menu layers without provider settings actions", async () => {
|
|
||||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
|
||||||
|
|
||||||
assert.match(preview, /const referenceObjectSummaries = useMemo/);
|
|
||||||
assert.match(preview, /const objectLayerCount = presentationSummaries\.length \+ referenceObjectSummaries\.length/);
|
|
||||||
assert.match(preview, /referenceObjectSummaries\.map\(\(\{ layer, displayName, total \}\)/);
|
|
||||||
assert.match(preview, /candidate\.id === layer\.id \? \{ \.\.\.candidate, visible: !candidate\.visible \} : candidate/);
|
|
||||||
assert.match(preview, /role="menuitemcheckbox"/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("facet window auto-fits expanded and collapsed content inside map workspace", async () => {
|
|
||||||
const [preview, workspace] = await Promise.all([
|
|
||||||
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"),
|
|
||||||
readFile(new URL("../packages/ui-react/src/WorkspaceWindow.tsx", import.meta.url), "utf8"),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.match(preview, /minHeight=\{220\}\s*autoHeight/);
|
assert.match(runtime, /export function mapFactInsideGridSector/);
|
||||||
|
assert.match(runtime, /localSectorAtGeodetic/);
|
||||||
|
assert.match(sectorWindow, /label="Скрыть объекты за сектором"/);
|
||||||
|
assert.match(sectorWindow, /title="Домены данных"/);
|
||||||
|
assert.match(sectorWindow, /title="Провайдеры"/);
|
||||||
|
assert.match(sectorWindow, /title="Типы объектов"/);
|
||||||
|
assert.match(sectorWindow, /id="map-sector-objects-title">Объекты сектора/);
|
||||||
|
assert.match(sectorWindow, /onClose=\{onDeactivate\}/);
|
||||||
|
assert.match(sectorWindow, />\s*Деактивировать сектор\s*</);
|
||||||
|
assert.match(state, /case "deactivate-sector"/);
|
||||||
|
assert.match(state, /selectedSector: null/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reference stations remain first-class visibility layers without provider settings actions", async () => {
|
||||||
|
const [preview, toolbar] = await Promise.all([
|
||||||
|
source("../apps/catalog/src/MapFixturePreview.tsx"),
|
||||||
|
source("../apps/catalog/src/MapWorkspaceToolbar.tsx"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.match(preview, /const referenceObjectSummaries = useMemo/);
|
||||||
|
assert.match(toolbar, /const objectLayerCount = summaries\.length \+ referenceSummaries\.length/);
|
||||||
|
assert.match(toolbar, /referenceSummaries\.map\(\(\{ layer, displayName, total \}\)/);
|
||||||
|
assert.match(toolbar, /onToggleReferenceLayer\(layer\.id\)/);
|
||||||
|
assert.match(toolbar, /role="menuitemcheckbox"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("facet windows auto-fit expanded and collapsed content inside the map workspace", async () => {
|
||||||
|
const [windows, workspace] = await Promise.all([
|
||||||
|
source("../apps/catalog/src/MapSubjectWorkspaceWindows.tsx"),
|
||||||
|
source("../packages/ui-react/src/WorkspaceWindow.tsx"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.match(windows, /minHeight=\{220\}\s*autoHeight/);
|
||||||
assert.match(workspace, /autoHeight\?: boolean/);
|
assert.match(workspace, /autoHeight\?: boolean/);
|
||||||
assert.match(workspace, /content\.scrollHeight/);
|
assert.match(workspace, /content\.scrollHeight/);
|
||||||
assert.match(workspace, /availableHeight = Math\.max\(0, nextBounds\.height - normalized\.y\)/);
|
assert.match(workspace, /availableHeight = Math\.max\(0, nextBounds\.height - normalized\.y\)/);
|
||||||
assert.match(workspace, /new ResizeObserver\(scheduleFit\)/);
|
assert.match(workspace, /new ResizeObserver\(scheduleFit\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("facet tree selection focuses the subject without resetting a compatible detail tab", async () => {
|
test("facet and search selection focus the subject while the reducer preserves a valid detail tab", async () => {
|
||||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
const [preview, renderer, state] = await Promise.all([
|
||||||
const renderer = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8");
|
source("../apps/catalog/src/MapFixturePreview.tsx"),
|
||||||
|
source("../apps/catalog/src/CesiumMapRenderer.tsx"),
|
||||||
|
source("../apps/catalog/src/mapWorkspaceState.mjs"),
|
||||||
|
]);
|
||||||
|
|
||||||
assert.match(preview, /const \[expandedFacetRows, setExpandedFacetRows\]/);
|
|
||||||
assert.match(preview, /const handleSelectAndFocus = useCallback/);
|
assert.match(preview, /const handleSelectAndFocus = useCallback/);
|
||||||
assert.match(preview, /const focusSubject = useCallback/);
|
|
||||||
assert.match(preview, /if \(renderer\.focusRuntimeEntity\(entityId\)\) return true/);
|
assert.match(preview, /if \(renderer\.focusRuntimeEntity\(entityId\)\) return true/);
|
||||||
assert.match(preview, /mapFactPointCoordinates\(selectable\.find/);
|
|
||||||
assert.match(preview, /renderer\.focusSubjectCoordinates\(fallbackCoordinates\[0\], fallbackCoordinates\[1\]\)/);
|
assert.match(preview, /renderer\.focusSubjectCoordinates\(fallbackCoordinates\[0\], fallbackCoordinates\[1\]\)/);
|
||||||
assert.match(preview, /focusSubject\(entityId\)/);
|
|
||||||
assert.match(preview, /focusSubject\(result\.entityId, result\.coordinates\)/);
|
assert.match(preview, /focusSubject\(result\.entityId, result\.coordinates\)/);
|
||||||
assert.match(preview, /setSubjectCardTabId\(\(current\) =>/);
|
assert.match(preview, /validTabIds: profile\?\.tabs\.map\(\(tab\) => tab\.id\) \?\? \["overview"\]/);
|
||||||
assert.match(preview, /profile\?\.tabs\.some\(\(tab\) => tab\.id === current\)/);
|
assert.match(state, /action\.validTabIds\.includes\(state\.subjectCard\.tabId\)/);
|
||||||
assert.match(preview, /\(profile\?\.defaultTabId \?\? "overview"\)/);
|
|
||||||
assert.match(renderer, /void viewer\.flyTo\(entity, \{/);
|
assert.match(renderer, /void viewer\.flyTo\(entity, \{/);
|
||||||
assert.match(renderer, /const focusSubjectCoordinates = useCallback/);
|
assert.match(renderer, /const focusSubjectCoordinates = useCallback/);
|
||||||
assert.match(renderer, /viewer\.camera\.flyToBoundingSphere\(new BoundingSphere\(target, 1\), \{/);
|
assert.match(renderer, /viewer\.camera\.flyToBoundingSphere\(new BoundingSphere\(target, 1\), \{/);
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import ts from "typescript";
|
|
||||||
|
|
||||||
const source = await readFile(new URL("../apps/catalog/src/mapPresentationProfile.ts", import.meta.url), "utf8");
|
|
||||||
const transpiled = ts.transpileModule(source, {
|
|
||||||
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
|
||||||
}).outputText;
|
|
||||||
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled).toString("base64")}`;
|
|
||||||
const {
|
const {
|
||||||
mapFactMatchesFilters,
|
mapFactMatchesFilters,
|
||||||
mapPresentationBindingIsAll,
|
mapPresentationBindingIsAll,
|
||||||
@@ -15,7 +7,7 @@ const {
|
|||||||
mapPresentationFacetValueIsEnabled,
|
mapPresentationFacetValueIsEnabled,
|
||||||
normalizeMapPresentationFacetSelections,
|
normalizeMapPresentationFacetSelections,
|
||||||
toggleMapPresentationFacetSelection,
|
toggleMapPresentationFacetSelection,
|
||||||
} = await import(moduleUrl);
|
} = await import("../apps/catalog/src/mapPresentationProfile.mjs");
|
||||||
|
|
||||||
const profile = {
|
const profile = {
|
||||||
id: "map.moving-object.operational.default",
|
id: "map.moving-object.operational.default",
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
runStagedMapProviders,
|
||||||
|
waitForGlobeViewportReady,
|
||||||
|
} from "../apps/catalog/src/mapProviderStartup.mjs";
|
||||||
|
|
||||||
|
function eventHarness() {
|
||||||
|
const listeners = new Set();
|
||||||
|
return {
|
||||||
|
event: {
|
||||||
|
addEventListener(listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
raise(...args) {
|
||||||
|
for (const listener of [...listeners]) listener(...args);
|
||||||
|
},
|
||||||
|
get size() {
|
||||||
|
return listeners.size;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("providers start in base-map order", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const result = await runStagedMapProviders({
|
||||||
|
loadImagery: () => calls.push("imagery"),
|
||||||
|
waitAfterImagery: () => calls.push("imagery-ready"),
|
||||||
|
loadTerrain: () => calls.push("terrain"),
|
||||||
|
waitAfterTerrain: () => calls.push("terrain-ready"),
|
||||||
|
loadBuildings: () => calls.push("buildings"),
|
||||||
|
loadDeferred: () => calls.push("deferred"),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, "complete");
|
||||||
|
assert.deepEqual(calls, ["imagery", "imagery-ready", "terrain", "terrain-ready", "buildings", "deferred"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("imagery failure is reported without blocking terrain or buildings", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const errors = [];
|
||||||
|
await runStagedMapProviders({
|
||||||
|
loadImagery: () => { calls.push("imagery"); throw new Error("imagery failed"); },
|
||||||
|
waitAfterImagery: () => calls.push("imagery-ready"),
|
||||||
|
loadTerrain: () => calls.push("terrain"),
|
||||||
|
waitAfterTerrain: () => calls.push("terrain-ready"),
|
||||||
|
loadBuildings: () => calls.push("buildings"),
|
||||||
|
onProviderError: (provider, error) => errors.push([provider, error.message]),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ["imagery", "terrain", "terrain-ready", "buildings"]);
|
||||||
|
assert.deepEqual(errors, [["imagery", "imagery failed"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("terrain failure is reported without blocking buildings", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const errors = [];
|
||||||
|
await runStagedMapProviders({
|
||||||
|
loadImagery: () => calls.push("imagery"),
|
||||||
|
waitAfterImagery: () => calls.push("imagery-ready"),
|
||||||
|
loadTerrain: () => { calls.push("terrain"); throw new Error("terrain failed"); },
|
||||||
|
waitAfterTerrain: () => calls.push("terrain-ready"),
|
||||||
|
loadBuildings: () => calls.push("buildings"),
|
||||||
|
onProviderError: (provider, error) => errors.push([provider, error.message]),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ["imagery", "imagery-ready", "terrain", "buildings"]);
|
||||||
|
assert.deepEqual(errors, [["terrain", "terrain failed"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancellation prevents all later stages", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const calls = [];
|
||||||
|
const result = await runStagedMapProviders({
|
||||||
|
signal: controller.signal,
|
||||||
|
loadImagery: () => { calls.push("imagery"); controller.abort(); },
|
||||||
|
waitAfterImagery: () => calls.push("imagery-ready"),
|
||||||
|
loadTerrain: () => calls.push("terrain"),
|
||||||
|
loadBuildings: () => calls.push("buildings"),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, "cancelled");
|
||||||
|
assert.deepEqual(calls, ["imagery"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a stalled provider times out without inserting late or blocking later stages", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const errors = [];
|
||||||
|
let releaseImagery;
|
||||||
|
const stalledImagery = new Promise((resolve) => { releaseImagery = resolve; });
|
||||||
|
await runStagedMapProviders({
|
||||||
|
providerInitializationTimeoutMs: 1,
|
||||||
|
loadImagery: async ({ signal }) => {
|
||||||
|
calls.push("imagery-start");
|
||||||
|
await stalledImagery;
|
||||||
|
if (!signal.aborted) calls.push("imagery-insert");
|
||||||
|
},
|
||||||
|
loadTerrain: () => calls.push("terrain"),
|
||||||
|
loadBuildings: () => calls.push("buildings"),
|
||||||
|
onProviderError: (provider, error) => errors.push([provider, error.message]),
|
||||||
|
});
|
||||||
|
releaseImagery();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ["imagery-start", "terrain", "buildings"]);
|
||||||
|
assert.deepEqual(errors, [["imagery", "imagery_startup_timeout"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("viewport readiness waits for discovery frames and cleans listeners", async () => {
|
||||||
|
const progress = eventHarness();
|
||||||
|
const postRender = eventHarness();
|
||||||
|
const globe = { tilesLoaded: true, tileLoadProgressEvent: progress.event };
|
||||||
|
const scene = { postRender: postRender.event, requestRender() {} };
|
||||||
|
let settled = false;
|
||||||
|
const ready = waitForGlobeViewportReady({ globe, scene, timeoutMs: 100, minimumRenderFrames: 2 })
|
||||||
|
.then((reason) => { settled = true; return reason; });
|
||||||
|
|
||||||
|
postRender.raise();
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(settled, false);
|
||||||
|
postRender.raise();
|
||||||
|
assert.equal(await ready, "loaded");
|
||||||
|
assert.equal(progress.size, 0);
|
||||||
|
assert.equal(postRender.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("viewport readiness times out and aborts without leaking listeners", async () => {
|
||||||
|
const timeoutProgress = eventHarness();
|
||||||
|
const timeoutPostRender = eventHarness();
|
||||||
|
assert.equal(await waitForGlobeViewportReady({
|
||||||
|
globe: { tilesLoaded: false, tileLoadProgressEvent: timeoutProgress.event },
|
||||||
|
scene: { postRender: timeoutPostRender.event, requestRender() {} },
|
||||||
|
timeoutMs: 1,
|
||||||
|
}), "timeout");
|
||||||
|
assert.equal(timeoutProgress.size, 0);
|
||||||
|
assert.equal(timeoutPostRender.size, 0);
|
||||||
|
|
||||||
|
const abortProgress = eventHarness();
|
||||||
|
const abortPostRender = eventHarness();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const pending = waitForGlobeViewportReady({
|
||||||
|
globe: { tilesLoaded: false, tileLoadProgressEvent: abortProgress.event },
|
||||||
|
scene: { postRender: abortPostRender.event, requestRender() {} },
|
||||||
|
signal: controller.signal,
|
||||||
|
timeoutMs: 100,
|
||||||
|
});
|
||||||
|
controller.abort();
|
||||||
|
assert.equal(await pending, "cancelled");
|
||||||
|
assert.equal(abortProgress.size, 0);
|
||||||
|
assert.equal(abortPostRender.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the live renderer uses the staged provider coordinator", async () => {
|
||||||
|
const renderer = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8");
|
||||||
|
assert.match(renderer, /runStagedMapProviders\(\{/);
|
||||||
|
assert.match(renderer, /waitForGlobeViewportReady\(\{/);
|
||||||
|
assert.match(renderer, /providerStartupAbort\.abort\(\)/);
|
||||||
|
assert.doesNotMatch(renderer, /Do not serialize provider startup/);
|
||||||
|
});
|
||||||
@@ -28,13 +28,14 @@ test("Map Page registers a provider-neutral reference-points slot and three stat
|
|||||||
|
|
||||||
test("existing layouts upgrade reference layers on read and renderer accepts their slot", async () => {
|
test("existing layouts upgrade reference layers on read and renderer accepts their slot", async () => {
|
||||||
const server = await readFile(new URL("server/catalog-server.mjs", root), "utf8");
|
const server = await readFile(new URL("server/catalog-server.mjs", root), "utf8");
|
||||||
const renderer = await readFile(new URL("apps/catalog/src/CesiumMapRenderer.tsx", root), "utf8");
|
const runtimeLayers = await readFile(new URL("apps/catalog/src/mapCesiumRuntimeLayers.ts", root), "utf8");
|
||||||
const runtime = await readFile(new URL("apps/catalog/src/useMapReferenceRuntime.ts", root), "utf8");
|
const runtime = await readFile(new URL("apps/catalog/src/useMapReferenceRuntime.ts", root), "utf8");
|
||||||
|
|
||||||
assert.match(server, /pageLayoutMatch\[1\] === "map" \? validateMapPageLayout\(stored\) : stored/);
|
assert.match(server, /pageLayoutMatch\[1\] === "map" \? validateMapPageLayout\(stored\) : stored/);
|
||||||
assert.match(server, /referenceLayers: structuredClone\(canonicalMapReferenceLayers\)/);
|
assert.match(server, /referenceLayers: structuredClone\(canonicalMapReferenceLayers\)/);
|
||||||
assert.match(renderer, /binding\.slotId === "reference-points"/);
|
assert.match(runtimeLayers, /binding\.slotId === "reference-points"/);
|
||||||
assert.match(runtime, /\/api\/map-gateway\/api\/map\/reference-sources\/v1\/profiles\//);
|
assert.match(runtime, /\/api\/map-gateway\/api\/map\/reference-sources\/v1\/profiles\//);
|
||||||
assert.match(runtime, /allowedAttributes = new Set\(\["name", "category", "network", "operator", "official_name", "local_name", "uic_ref", "wheelchair"\]\)/);
|
assert.match(runtime, /allowedAttributes = new Set\(\["name", "category", "network", "operator", "official_name", "local_name", "alternate_names", "uic_ref", "wheelchair"\]\)/);
|
||||||
|
assert.match(runtime, /alternate_names/);
|
||||||
assert.doesNotMatch(runtime, /credential|accessToken|providerEndpoint|rawPayload/);
|
assert.doesNotMatch(runtime, /credential|accessToken|providerEndpoint|rawPayload/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ test("reference subjects remain searchable by profile labels without a provider
|
|||||||
id: "map.reference.station.v1",
|
id: "map.reference.station.v1",
|
||||||
title: "Метро",
|
title: "Метро",
|
||||||
semanticTypes: ["map.station"],
|
semanticTypes: ["map.station"],
|
||||||
label: { mode: "attributes", fields: ["name", "official_name"] },
|
label: { mode: "attributes", fields: ["name", "official_name", "alternate_names"] },
|
||||||
};
|
};
|
||||||
const index = buildMapSearchIndex({
|
const index = buildMapSearchIndex({
|
||||||
runtimeBindings: [{
|
runtimeBindings: [{
|
||||||
@@ -93,6 +93,7 @@ test("reference subjects remain searchable by profile labels without a provider
|
|||||||
presentationProfileId: stationProfile.id,
|
presentationProfileId: stationProfile.id,
|
||||||
facts: [point("osm.node.1", "map.station", {
|
facts: [point("osm.node.1", "map.station", {
|
||||||
name: "Петроградская",
|
name: "Петроградская",
|
||||||
|
alternate_names: ["Petrogradskaya station"],
|
||||||
provider_note: "not searchable",
|
provider_note: "not searchable",
|
||||||
})],
|
})],
|
||||||
}],
|
}],
|
||||||
@@ -100,6 +101,7 @@ test("reference subjects remain searchable by profile labels without a provider
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(searchMapSubjects(index, "петрог").at(0)?.title, "Петроградская");
|
assert.equal(searchMapSubjects(index, "петрог").at(0)?.title, "Петроградская");
|
||||||
|
assert.equal(searchMapSubjects(index, "Petrogradskaya").at(0)?.sourceId, "osm.node.1");
|
||||||
assert.equal(searchMapSubjects(index, "osm.node.1").at(0)?.groupTitle, "Метро");
|
assert.equal(searchMapSubjects(index, "osm.node.1").at(0)?.groupTitle, "Метро");
|
||||||
assert.deepEqual(searchMapSubjects(index, "provider_note"), []);
|
assert.deepEqual(searchMapSubjects(index, "provider_note"), []);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -275,17 +275,20 @@ test("restricted identity aspects render full identifiers and bounded string lis
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Cesium pin and label share one entity selection that opens the subject card", async () => {
|
test("Cesium pin and label share one entity selection that opens the subject card", async () => {
|
||||||
const [renderer, preview] = await Promise.all([
|
const [renderer, preview, windows, state] = await Promise.all([
|
||||||
readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../apps/catalog/src/MapSubjectWorkspaceWindows.tsx", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../apps/catalog/src/mapWorkspaceState.mjs", import.meta.url), "utf8"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.match(renderer, /viewer\?\.scene\.pick/);
|
assert.match(renderer, /viewer\?\.scene\.pick/);
|
||||||
assert.match(renderer, /pickedId instanceof Entity/);
|
assert.match(renderer, /pickedId instanceof Entity/);
|
||||||
assert.match(renderer, /onSelectRef\.current\?\.\(pickedId\.id\)/);
|
assert.match(renderer, /onSelectRef\.current\?\.\(pickedId\.id\)/);
|
||||||
assert.match(preview, /const handleSelect = useCallback/);
|
assert.match(preview, /const handleSelect = useCallback/);
|
||||||
assert.match(preview, /setSubjectCardOpen\(true\)/);
|
assert.match(preview, /type: "select-entity"/);
|
||||||
assert.match(preview, /title=\{selectedSubjectCard\.title\}/);
|
assert.match(state, /subjectCard: \{ \.\.\.state\.subjectCard, open: true, tabId \}/);
|
||||||
assert.match(preview, /Карточка объекта:/);
|
assert.match(windows, /title=\{selectedSubjectCard\.title\}/);
|
||||||
assert.match(preview, /SegmentedControl/);
|
assert.match(windows, /Карточка объекта:/);
|
||||||
|
assert.match(windows, /SegmentedControl/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildFilteredMapTargets,
|
||||||
|
buildMapPresentationFilters,
|
||||||
|
buildSelectableMapEntities,
|
||||||
|
planMapSubjectReveal,
|
||||||
|
primaryMapRuntimeBindings,
|
||||||
|
} from "../apps/catalog/src/mapWorkspaceModel.mjs";
|
||||||
|
import { mapRuntimeEntityId, mapRuntimeFactKey } from "../apps/catalog/src/mapRuntimeIdentity.mjs";
|
||||||
|
import { localSectorAtGeodetic, localSectorSummary } from "../apps/catalog/src/mapSectorGrid.mjs";
|
||||||
|
|
||||||
|
const origin = { latitude: 55.7558, longitude: 37.6173 };
|
||||||
|
const gridProfile = {
|
||||||
|
mode: "3d",
|
||||||
|
stepKm: 1,
|
||||||
|
tileSizeKm: 5,
|
||||||
|
graticuleStepDegrees: 1,
|
||||||
|
};
|
||||||
|
const primaryBinding = {
|
||||||
|
id: "positions",
|
||||||
|
order: 1,
|
||||||
|
dataProductId: "fleet.positions.current.v5",
|
||||||
|
slotId: "points",
|
||||||
|
delivery: "snapshot+patch",
|
||||||
|
semanticTypes: ["map.moving_object"],
|
||||||
|
fieldProjection: ["display_name", "signal_state", "position_source", "object_kind"],
|
||||||
|
presentationProfileId: "moving",
|
||||||
|
};
|
||||||
|
const joinedBinding = {
|
||||||
|
...primaryBinding,
|
||||||
|
id: "identity",
|
||||||
|
order: 2,
|
||||||
|
dataProductId: "fleet.units.identity.current.v1",
|
||||||
|
joinToBindingId: "positions",
|
||||||
|
};
|
||||||
|
const profile = {
|
||||||
|
id: "moving",
|
||||||
|
title: "Current positions",
|
||||||
|
semanticTypes: ["map.moving_object"],
|
||||||
|
label: { mode: "attributes", fields: ["display_name"], maxLength: 80 },
|
||||||
|
target: { variant: "elevated-spike" },
|
||||||
|
facets: [{
|
||||||
|
id: "signal",
|
||||||
|
field: "signal_state",
|
||||||
|
filterable: true,
|
||||||
|
counter: true,
|
||||||
|
values: [
|
||||||
|
{ value: "active", label: "Online", order: 1 },
|
||||||
|
{ value: "inactive", label: "Offline", order: 2 },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
styles: [{ id: "active", color: "#fff", opacity: 1 }],
|
||||||
|
classes: [{ id: "active", label: "Online", priority: 1, match: [], styleId: "active", renderable: true }],
|
||||||
|
defaultClassId: "active",
|
||||||
|
sort: [{ field: "signal_state", order: ["active", "inactive"] }],
|
||||||
|
};
|
||||||
|
|
||||||
|
function pointFact(sourceId, signalState, coordinates, displayName = sourceId) {
|
||||||
|
return {
|
||||||
|
sourceId,
|
||||||
|
semanticType: "map.moving_object",
|
||||||
|
observedAt: "2026-08-09T12:00:00.000Z",
|
||||||
|
receivedAt: "2026-08-09T12:00:01.000Z",
|
||||||
|
attributes: {
|
||||||
|
display_name: displayName,
|
||||||
|
signal_state: signalState,
|
||||||
|
position_source: "gelios",
|
||||||
|
object_kind: "tracked_unit",
|
||||||
|
},
|
||||||
|
geometry: { type: "Point", coordinates },
|
||||||
|
presentationStatus: signalState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const inactive = pointFact("unit-offline", "inactive", [37.6173, 55.7558], "Альфа");
|
||||||
|
const active = pointFact("unit-online", "active", [37.618, 55.756], "Янтарь");
|
||||||
|
const runtimeBindings = [{
|
||||||
|
bindingId: "positions",
|
||||||
|
dataProductId: primaryBinding.dataProductId,
|
||||||
|
slotId: "points",
|
||||||
|
presentationProfileId: "moving",
|
||||||
|
facts: [inactive, active],
|
||||||
|
cursor: "1",
|
||||||
|
state: "ready",
|
||||||
|
}, {
|
||||||
|
bindingId: "identity",
|
||||||
|
dataProductId: joinedBinding.dataProductId,
|
||||||
|
slotId: "subject-aspect",
|
||||||
|
presentationProfileId: "moving",
|
||||||
|
facts: [active],
|
||||||
|
cursor: "1",
|
||||||
|
state: "ready",
|
||||||
|
}];
|
||||||
|
|
||||||
|
test("stable runtime identity uses binding, semantic type and source id only", () => {
|
||||||
|
const moved = { ...active, geometry: { type: "Point", coordinates: [40, 60] } };
|
||||||
|
assert.equal(mapRuntimeFactKey(active), "map.moving_object\u0000unit-online");
|
||||||
|
assert.equal(mapRuntimeEntityId("positions", active), mapRuntimeEntityId("positions", moved));
|
||||||
|
assert.equal(mapRuntimeEntityId("positions", active), "nodedc-runtime:positions:map.moving_object:unit-online");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("joined aspects enrich a primary subject and never become map entities", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
primaryMapRuntimeBindings(runtimeBindings, [joinedBinding, primaryBinding]).map((binding) => binding.bindingId),
|
||||||
|
["positions"],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
buildSelectableMapEntities(runtimeBindings, [joinedBinding, primaryBinding], [profile]).map((entity) => entity.fact.sourceId),
|
||||||
|
["unit-online", "unit-offline"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("object overview preserves the same MCP profile sort as facets and renderer", () => {
|
||||||
|
const filters = { positions: { visible: true, facets: {} } };
|
||||||
|
const selectable = buildSelectableMapEntities(runtimeBindings, [primaryBinding, joinedBinding], [profile]);
|
||||||
|
const targets = buildFilteredMapTargets(runtimeBindings, [primaryBinding, joinedBinding], [profile], filters);
|
||||||
|
|
||||||
|
// Alphabetical title order would be Альфа, Янтарь. Declared state order is
|
||||||
|
// active, inactive and must remain authoritative everywhere.
|
||||||
|
assert.deepEqual(selectable.map((entity) => entity.title), ["Янтарь", "Альфа"]);
|
||||||
|
assert.deepEqual(targets.map((target) => target.title), ["Янтарь", "Альфа"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("explicit empty facets remain match-nothing through workspace projection", () => {
|
||||||
|
const subjectStates = {
|
||||||
|
positions: {
|
||||||
|
bindingId: "positions",
|
||||||
|
visible: true,
|
||||||
|
filters: { signal_state: [] },
|
||||||
|
window: { open: false, rect: { x: 0, y: 0, width: 280, height: 260 }, maximized: false, zIndex: 20 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const filters = buildMapPresentationFilters(subjectStates, [primaryBinding], [profile]);
|
||||||
|
assert.deepEqual(filters.positions.facets, { signal_state: [] });
|
||||||
|
assert.deepEqual(buildFilteredMapTargets(runtimeBindings, [primaryBinding], [profile], filters), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search reveal removes only constraints hiding the selected subject", () => {
|
||||||
|
const address = localSectorAtGeodetic(origin, {
|
||||||
|
lod: 1,
|
||||||
|
originLatitude: origin.latitude,
|
||||||
|
originLongitude: origin.longitude,
|
||||||
|
stepMeters: 1_000,
|
||||||
|
});
|
||||||
|
const selectedSector = {
|
||||||
|
...localSectorSummary(address, {
|
||||||
|
lod: 1,
|
||||||
|
originLatitude: origin.latitude,
|
||||||
|
originLongitude: origin.longitude,
|
||||||
|
stepMeters: 1_000,
|
||||||
|
tileSizeMeters: 5_000,
|
||||||
|
}),
|
||||||
|
mode: "3d",
|
||||||
|
units: "meters-enu",
|
||||||
|
volume: null,
|
||||||
|
};
|
||||||
|
const entity = buildSelectableMapEntities(runtimeBindings, [primaryBinding], [profile])
|
||||||
|
.find((candidate) => candidate.fact.sourceId === "unit-offline");
|
||||||
|
const subjectState = {
|
||||||
|
bindingId: "positions",
|
||||||
|
visible: false,
|
||||||
|
filters: { signal_state: ["active"] },
|
||||||
|
window: { open: true, rect: { x: 10, y: 20, width: 280, height: 260 }, maximized: false, zIndex: 42 },
|
||||||
|
};
|
||||||
|
const reveal = planMapSubjectReveal({
|
||||||
|
entity,
|
||||||
|
subjectState,
|
||||||
|
profile,
|
||||||
|
selectedSector,
|
||||||
|
gridProfiles: [gridProfile],
|
||||||
|
origin,
|
||||||
|
hideOutsideSector: true,
|
||||||
|
scope: {
|
||||||
|
excludedBindingIds: ["positions", "other"],
|
||||||
|
excludedProviders: ["gelios", "other"],
|
||||||
|
excludedObjectKinds: ["tracked_unit", "other"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(reveal.subjectState.visible, true);
|
||||||
|
assert.deepEqual(reveal.subjectState.filters, {});
|
||||||
|
assert.deepEqual(reveal.subjectState.window, subjectState.window);
|
||||||
|
assert.equal(reveal.hideOutsideSector, true);
|
||||||
|
assert.deepEqual(reveal.scope, {
|
||||||
|
excludedBindingIds: ["other"],
|
||||||
|
excludedProviders: ["other"],
|
||||||
|
excludedObjectKinds: ["other"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search keeps the active sector but relaxes outside clipping for an outside subject", () => {
|
||||||
|
const address = localSectorAtGeodetic(origin, {
|
||||||
|
lod: 1,
|
||||||
|
originLatitude: origin.latitude,
|
||||||
|
originLongitude: origin.longitude,
|
||||||
|
stepMeters: 1_000,
|
||||||
|
});
|
||||||
|
const selectedSector = {
|
||||||
|
...localSectorSummary(address, {
|
||||||
|
lod: 1,
|
||||||
|
originLatitude: origin.latitude,
|
||||||
|
originLongitude: origin.longitude,
|
||||||
|
stepMeters: 1_000,
|
||||||
|
tileSizeMeters: 5_000,
|
||||||
|
}),
|
||||||
|
mode: "3d",
|
||||||
|
units: "meters-enu",
|
||||||
|
volume: null,
|
||||||
|
};
|
||||||
|
const outsideEntity = {
|
||||||
|
...buildSelectableMapEntities(runtimeBindings, [primaryBinding], [profile])[0],
|
||||||
|
fact: pointFact("far-away", "active", [50, 60]),
|
||||||
|
};
|
||||||
|
const subjectState = {
|
||||||
|
bindingId: "positions",
|
||||||
|
visible: true,
|
||||||
|
filters: {},
|
||||||
|
window: { open: false, rect: { x: 0, y: 0, width: 280, height: 260 }, maximized: false, zIndex: 20 },
|
||||||
|
};
|
||||||
|
const reveal = planMapSubjectReveal({
|
||||||
|
entity: outsideEntity,
|
||||||
|
subjectState,
|
||||||
|
profile,
|
||||||
|
selectedSector,
|
||||||
|
gridProfiles: [gridProfile],
|
||||||
|
origin,
|
||||||
|
hideOutsideSector: true,
|
||||||
|
scope: { excludedBindingIds: [], excludedProviders: [], excludedObjectKinds: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(reveal.hideOutsideSector, false);
|
||||||
|
assert.equal(selectedSector.id, reveal ? selectedSector.id : null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { createMapWorkspaceState, mapWorkspaceReducer } from "../apps/catalog/src/mapWorkspaceState.mjs";
|
||||||
|
|
||||||
|
const rect = (x, y, width = 280, height = 260) => ({ x, y, width, height });
|
||||||
|
const subjectStates = {
|
||||||
|
positions: {
|
||||||
|
bindingId: "positions",
|
||||||
|
visible: true,
|
||||||
|
filters: {},
|
||||||
|
window: { open: false, rect: rect(24, 56), maximized: false, zIndex: 20 },
|
||||||
|
},
|
||||||
|
zones: {
|
||||||
|
bindingId: "zones",
|
||||||
|
visible: true,
|
||||||
|
filters: {},
|
||||||
|
window: { open: false, rect: rect(52, 84), maximized: false, zIndex: 21 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function initialState() {
|
||||||
|
return createMapWorkspaceState({
|
||||||
|
subjectStates: structuredClone(subjectStates),
|
||||||
|
sectorWindowRect: rect(24, 72, 380, 530),
|
||||||
|
subjectCardRect: rect(940, 72, 390, 520),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("one reducer owns the complete floating-window stack", () => {
|
||||||
|
const opened = mapWorkspaceReducer(initialState(), { type: "open-subject-window", bindingId: "positions" });
|
||||||
|
assert.equal(opened.subjectStates.positions.window.open, true);
|
||||||
|
assert.equal(opened.activeWindowId, "binding:positions");
|
||||||
|
assert.ok(opened.subjectStates.positions.window.zIndex > opened.sector.window.zIndex);
|
||||||
|
|
||||||
|
const activatedCard = mapWorkspaceReducer(opened, {
|
||||||
|
type: "select-entity",
|
||||||
|
entityId: "nodedc-runtime:positions:map.moving_object:unit-1",
|
||||||
|
validTabIds: ["overview", "position"],
|
||||||
|
defaultTabId: "overview",
|
||||||
|
});
|
||||||
|
assert.equal(activatedCard.subjectCard.open, true);
|
||||||
|
assert.equal(activatedCard.activeWindowId, "subject-card");
|
||||||
|
assert.ok(activatedCard.subjectCard.zIndex > opened.subjectStates.positions.window.zIndex);
|
||||||
|
|
||||||
|
// Re-activating the active window is stable and does not inflate z-index.
|
||||||
|
assert.equal(
|
||||||
|
mapWorkspaceReducer(activatedCard, { type: "activate-window", windowId: "subject-card" }),
|
||||||
|
activatedCard,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selecting a domain subject never clears the active grid sector", () => {
|
||||||
|
const sector = { id: "grid/local/sector", lod: 2, mode: "3d" };
|
||||||
|
const withSector = mapWorkspaceReducer(initialState(), { type: "select-sector", selection: sector });
|
||||||
|
const withSubject = mapWorkspaceReducer(withSector, {
|
||||||
|
type: "select-entity",
|
||||||
|
entityId: "nodedc-runtime:positions:map.moving_object:unit-1",
|
||||||
|
validTabIds: ["overview"],
|
||||||
|
defaultTabId: "overview",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(withSubject.selectedSector, sector);
|
||||||
|
assert.equal(withSubject.selectedEntityId, "nodedc-runtime:positions:map.moving_object:unit-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sector deactivation clears only sector scope and keeps subject UI state", () => {
|
||||||
|
let state = mapWorkspaceReducer(initialState(), { type: "select-sector", selection: { id: "sector", lod: 1, mode: "3d" } });
|
||||||
|
state = mapWorkspaceReducer(state, { type: "set-sector-scope-enabled", dimension: "binding", value: "positions", enabled: false });
|
||||||
|
state = mapWorkspaceReducer(state, { type: "open-subject-window", bindingId: "positions" });
|
||||||
|
state = mapWorkspaceReducer(state, { type: "deactivate-sector" });
|
||||||
|
|
||||||
|
assert.equal(state.selectedSector, null);
|
||||||
|
assert.deepEqual(state.sector.scope, {
|
||||||
|
excludedBindingIds: [],
|
||||||
|
excludedProviders: [],
|
||||||
|
excludedObjectKinds: [],
|
||||||
|
});
|
||||||
|
assert.equal(state.subjectStates.positions.window.open, true);
|
||||||
|
assert.equal(state.subjectStates.positions.visible, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search reveal state is applied atomically without replacing window geometry", () => {
|
||||||
|
const state = initialState();
|
||||||
|
const originalWindow = state.subjectStates.positions.window;
|
||||||
|
const revealedSubject = {
|
||||||
|
...state.subjectStates.positions,
|
||||||
|
visible: true,
|
||||||
|
filters: { signal_state: ["inactive"] },
|
||||||
|
};
|
||||||
|
const next = mapWorkspaceReducer(state, {
|
||||||
|
type: "reveal-subject",
|
||||||
|
bindingId: "positions",
|
||||||
|
subjectState: revealedSubject,
|
||||||
|
hideOutsideSector: false,
|
||||||
|
scope: { excludedBindingIds: [], excludedProviders: ["other"], excludedObjectKinds: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(next.subjectStates.positions.window, originalWindow);
|
||||||
|
assert.deepEqual(next.subjectStates.positions.filters, { signal_state: ["inactive"] });
|
||||||
|
assert.deepEqual(next.sector.scope.excludedProviders, ["other"]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user