FEAT - FOUNDRY: activate sector workspace

This commit is contained in:
Codex
2026-08-08 14:09:51 +03:00
parent 30c9b8c5fd
commit 58800d9576
10 changed files with 675 additions and 80 deletions
+404 -67
View File
@@ -1,5 +1,5 @@
import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react";
import { Button, Checker, ColorField, ControlRow, Dropdown, Icon, IconButton, Inspector, InspectorSelectField, RangeControl, SegmentedControl, Window, WorkspaceWindow } from "@nodedc/ui-react";
import { Button, Checker, ColorField, ControlRow, Dropdown, Icon, IconButton, Inspector, InspectorSelectField, RangeControl, SegmentedControl, WorkspaceWindow } from "@nodedc/ui-react";
import type { SelectOption, WorkspaceWindowRect } from "@nodedc/ui-react";
import type {
CameraSpiralState,
@@ -12,6 +12,7 @@ import type {
GridSectorSelection,
} from "./CesiumMapRenderer.js";
import { mapRuntimeEntityId, useMapDataProductRuntime } from "./useMapDataProductRuntime.js";
import type { MapRuntimeFact } from "./useMapDataProductRuntime.js";
import {
compareMapRuntimeFacts,
mapFactMatchesFilters,
@@ -48,6 +49,7 @@ import {
graticuleSectorAt,
graticuleSectorSummary,
localSectorAt,
localSectorAtGeodetic,
localSectorSummary,
localVolumeAt,
type GraticuleSectorAddress,
@@ -309,6 +311,50 @@ function gridSectorCenterLabel(selection: GridSectorSelection) {
: `${formatGridMetric(selection.center.latitude, 6)}°, ${formatGridMetric(selection.center.longitude, 6)}°`;
}
const MAP_SCOPE_PROVIDER_FIELD = "position_source";
const MAP_SCOPE_OBJECT_KIND_FIELD = "object_kind";
const MAP_SCOPE_MISSING_VALUE = "__nodedc_missing__";
function normalizedSectorScopeValue(value: unknown) {
if (typeof value !== "string") return null;
const normalized = value.trim();
if (!normalized || normalized.length > 120 || /[\u0000-\u001f\u007f]/.test(normalized)) return null;
return normalized;
}
function sectorScopeValueLabel(value: string) {
if (value === MAP_SCOPE_MISSING_VALUE) return "Не указано";
return value.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
}
function mapFactSectorScopeValue(fact: MapRuntimeFact, field: string) {
return normalizedSectorScopeValue(fact.attributes[field]) ?? MAP_SCOPE_MISSING_VALUE;
}
function mapFactInsideGridSector(
fact: MapRuntimeFact,
selection: GridSectorSelection,
profiles: SectorGridLodProfile[],
origin: { latitude: number; longitude: number },
) {
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;
}
function beginGatewayHealthEpoch(order: GatewayHealthOrder) {
order.nextEpoch += 1;
order.latestStartedEpoch = order.nextEpoch;
@@ -415,6 +461,8 @@ export type MapSubjectWindowState = {
zIndex: number;
};
type MapWorkspaceWindowId = "settings" | "layers" | "sector" | "subject-card" | `binding:${string}`;
export type MapSubjectState = {
bindingId: string;
visible: boolean;
@@ -594,17 +642,31 @@ function defaultSubjectWindowState(index: number): MapSubjectWindowState {
}
const defaultLayersWindowRect: WorkspaceWindowRect = {
x: 1024,
x: 24,
y: 72,
width: 336,
height: 500,
};
const defaultSettingsWindowRect: WorkspaceWindowRect = {
x: 930,
y: 72,
width: 420,
height: 530,
};
const defaultSectorWindowRect: WorkspaceWindowRect = {
x: 24,
y: 72,
width: 380,
height: 530,
};
const defaultSubjectCardRect: WorkspaceWindowRect = {
x: 940,
y: 52,
y: 72,
width: 390,
height: 560,
height: 520,
};
function initialSubjectState(bindings: MapDataProductBinding[], saved: MapSubjectState[] | undefined) {
@@ -665,10 +727,12 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
const [subjectCardRect, setSubjectCardRect] = useState<WorkspaceWindowRect>(defaultSubjectCardRect);
const [subjectCardMaximized, setSubjectCardMaximized] = useState(false);
const [subjectCardZIndex, setSubjectCardZIndex] = useState(140);
const [subjectCardActive, setSubjectCardActive] = useState(false);
const [subjectCardTabId, setSubjectCardTabId] = useState("overview");
const [expandedFacetRows, setExpandedFacetRows] = useState<Record<string, boolean>>({});
const [inspectorOpen, setInspectorOpen] = useState(false);
const [settingsWindowRect, setSettingsWindowRect] = useState<WorkspaceWindowRect>(defaultSettingsWindowRect);
const [settingsWindowMaximized, setSettingsWindowMaximized] = useState(false);
const [settingsWindowZIndex, setSettingsWindowZIndex] = useState(13);
const [inspectorOpenSections, setInspectorOpenSections] = useState<string[]>(() => (
initialLayout?.inspectorOpenSections ?? ["map-base"]
));
@@ -676,7 +740,14 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
const [layersWindowRect, setLayersWindowRect] = useState<WorkspaceWindowRect>(defaultLayersWindowRect);
const [layersWindowMaximized, setLayersWindowMaximized] = useState(false);
const [layersWindowZIndex, setLayersWindowZIndex] = useState(12);
const [layersWindowActive, setLayersWindowActive] = useState(false);
const [sectorWindowRect, setSectorWindowRect] = useState<WorkspaceWindowRect>(defaultSectorWindowRect);
const [sectorWindowMaximized, setSectorWindowMaximized] = useState(false);
const [sectorWindowZIndex, setSectorWindowZIndex] = useState(142);
const [hideObjectsOutsideSector, setHideObjectsOutsideSector] = useState(false);
const [sectorExcludedBindingIds, setSectorExcludedBindingIds] = useState<string[]>([]);
const [sectorExcludedProviders, setSectorExcludedProviders] = useState<string[]>([]);
const [sectorExcludedObjectKinds, setSectorExcludedObjectKinds] = useState<string[]>([]);
const [activeWorkspaceWindowId, setActiveWorkspaceWindowId] = useState<MapWorkspaceWindowId>();
const [toolbarOpen, setToolbarOpen] = useState(Boolean(features.toolbar));
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@@ -734,7 +805,6 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
const [subjectStates, setSubjectStates] = useState<Record<string, MapSubjectState>>(() => (
initialSubjectState(initialLayout?.dataProductBindings ?? [], initialLayout?.subjectStates)
));
const [activeSubjectBindingId, setActiveSubjectBindingId] = useState<string>();
const presentationFilters = useMemo<MapPresentationFilters>(() => Object.fromEntries(
Object.entries(subjectStates).map(([bindingId, state]) => [bindingId, {
visible: state.visible,
@@ -765,6 +835,11 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
...presentationFilters,
...referencePresentationFilters,
}), [presentationFilters, referencePresentationFilters]);
const sectorGridLodProfiles = mapSettings.gridLodProfiles as SectorGridLodProfile[];
const fixedSectorGridOrigin = useMemo(() => ({
latitude: mapSettings.gridCenterLatitude,
longitude: mapSettings.gridCenterLongitude,
}), [mapSettings.gridCenterLatitude, mapSettings.gridCenterLongitude]);
const primaryBindingIds = useMemo(() => new Set(
dataProductBindings.filter((binding) => !binding.joinToBindingId).map((binding) => binding.id),
), [dataProductBindings]);
@@ -804,6 +879,77 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
});
})
), [dataProductBindings, presentationProfiles, primaryRuntimeBindings]);
const sectorSpatialEntities = useMemo(() => selectedGridSector
? selectable.filter((entity) => mapFactInsideGridSector(
entity.fact,
selectedGridSector,
sectorGridLodProfiles,
fixedSectorGridOrigin,
))
: [], [fixedSectorGridOrigin, sectorGridLodProfiles, selectable, selectedGridSector]);
const sectorBindingOptions = useMemo(() => [...dataProductBindings]
.filter((binding) => !binding.joinToBindingId)
.sort((left, right) => (left.order ?? 0) - (right.order ?? 0) || left.id.localeCompare(right.id))
.map((binding) => ({
value: binding.id,
label: binding.displayName?.trim() || binding.id,
count: sectorSpatialEntities.filter((entity) => entity.bindingId === binding.id).length,
})), [dataProductBindings, sectorSpatialEntities]);
const sectorProviderFacetAvailable = useMemo(() => dataProductBindings.some((binding) => (
!binding.joinToBindingId && binding.fieldProjection.includes(MAP_SCOPE_PROVIDER_FIELD)
)), [dataProductBindings]);
const sectorObjectKindFacetAvailable = useMemo(() => dataProductBindings.some((binding) => (
!binding.joinToBindingId && binding.fieldProjection.includes(MAP_SCOPE_OBJECT_KIND_FIELD)
)), [dataProductBindings]);
const sectorProviderOptions = useMemo(() => {
if (!sectorProviderFacetAvailable) return [];
const counts = new Map<string, number>();
sectorSpatialEntities.forEach(({ fact }) => {
const value = mapFactSectorScopeValue(fact, MAP_SCOPE_PROVIDER_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"));
}, [sectorProviderFacetAvailable, sectorSpatialEntities]);
const sectorObjectKindOptions = useMemo(() => {
if (!sectorObjectKindFacetAvailable) return [];
const counts = new Map<string, number>();
sectorSpatialEntities.forEach(({ fact }) => {
const value = mapFactSectorScopeValue(fact, MAP_SCOPE_OBJECT_KIND_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"));
}, [sectorObjectKindFacetAvailable, sectorSpatialEntities]);
const sectorVisibleEntities = useMemo(() => sectorSpatialEntities.filter((entity) => {
if (sectorExcludedBindingIds.includes(entity.bindingId)) return false;
if (sectorExcludedProviders.includes(mapFactSectorScopeValue(entity.fact, MAP_SCOPE_PROVIDER_FIELD))) return false;
if (sectorExcludedObjectKinds.includes(mapFactSectorScopeValue(entity.fact, MAP_SCOPE_OBJECT_KIND_FIELD))) return false;
const binding = dataProductBindings.find((candidate) => candidate.id === entity.bindingId);
const profile = mapPresentationProfileForFact(
presentationProfiles,
binding?.presentationProfileId,
entity.fact.semanticType,
);
return Boolean(profile && mapFactMatchesFilters(entity.fact, profile, presentationFilters, entity.bindingId));
}), [dataProductBindings, presentationFilters, presentationProfiles, sectorExcludedBindingIds, sectorExcludedObjectKinds, sectorExcludedProviders, sectorSpatialEntities]);
const sectorScopedPrimaryRuntimeBindings = useMemo(() => {
if (!selectedGridSector) return primaryRuntimeBindings;
return primaryRuntimeBindings.map((binding) => ({
...binding,
facts: binding.facts.filter((fact) => {
if (sectorExcludedBindingIds.includes(binding.bindingId)) return false;
if (sectorExcludedProviders.includes(mapFactSectorScopeValue(fact, MAP_SCOPE_PROVIDER_FIELD))) return false;
if (sectorExcludedObjectKinds.includes(mapFactSectorScopeValue(fact, MAP_SCOPE_OBJECT_KIND_FIELD))) return false;
return !hideObjectsOutsideSector || mapFactInsideGridSector(
fact,
selectedGridSector,
sectorGridLodProfiles,
fixedSectorGridOrigin,
);
}),
}));
}, [fixedSectorGridOrigin, hideObjectsOutsideSector, primaryRuntimeBindings, sectorExcludedBindingIds, sectorExcludedObjectKinds, sectorExcludedProviders, sectorGridLodProfiles, selectedGridSector]);
const presentationSummaries = useMemo(() => [...dataProductBindings]
.filter((binding) => !binding.joinToBindingId)
.sort((left, right) => (left.order ?? 0) - (right.order ?? 0) || left.id.localeCompare(right.id))
@@ -832,7 +978,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
}];
}), [presentationProfiles, referenceLayers, referenceRuntimeBindings]);
const objectLayerCount = presentationSummaries.length + referenceObjectSummaries.length;
const filteredTargets = useMemo(() => primaryRuntimeBindings.flatMap((binding) => {
const filteredTargets = useMemo(() => sectorScopedPrimaryRuntimeBindings.flatMap((binding) => {
const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId);
return binding.facts.flatMap((fact) => {
const profile = mapPresentationProfileForFact(
@@ -850,7 +996,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
renderable: mapRuntimeFactIsRenderable(fact, profile),
}];
});
}).sort((left, right) => left.title.localeCompare(right.title, "ru")), [dataProductBindings, presentationFilters, presentationProfiles, primaryRuntimeBindings]);
}).sort((left, right) => left.title.localeCompare(right.title, "ru")), [dataProductBindings, presentationFilters, presentationProfiles, sectorScopedPrimaryRuntimeBindings]);
const visibleTargetEntityIds = useMemo(() => (
filteredTargets.filter((target) => target.renderable).map((target) => target.entityId)
), [filteredTargets]);
@@ -927,7 +1073,6 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
const selectedGridLodIndex = Math.max(0, Math.min(4, Number.parseInt(selectedGridLod, 10) || 0));
const activeGridLod = (mapSettings.gridLodProfiles[selectedGridLodIndex]
?? DEFAULT_GRID_LOD_PROFILES[selectedGridLodIndex]) as SectorGridLodProfile;
const sectorGridLodProfiles = mapSettings.gridLodProfiles as SectorGridLodProfile[];
const gridSectorDefinitionKey = useMemo(() => JSON.stringify({
origin: [mapSettings.gridCenterLatitude, mapSettings.gridCenterLongitude],
profiles: sectorGridLodProfiles.map((profile) => ({
@@ -942,10 +1087,6 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
volumeBandHeightMeters: profile.volumeBandHeightMeters,
})),
}), [mapSettings.gridCenterLatitude, mapSettings.gridCenterLongitude, sectorGridLodProfiles]);
const fixedSectorGridOrigin = useMemo(() => ({
latitude: mapSettings.gridCenterLatitude,
longitude: mapSettings.gridCenterLongitude,
}), [mapSettings.gridCenterLatitude, mapSettings.gridCenterLongitude]);
const selectedGridParentLod = useMemo(() => selectedGridSector
? gridSectorParentLodSelection(selectedGridSector, sectorGridLodProfiles, fixedSectorGridOrigin)
: null, [fixedSectorGridOrigin, sectorGridLodProfiles, selectedGridSector]);
@@ -971,6 +1112,10 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
useEffect(() => {
setSelectedGridSector(null);
setSectorExcludedBindingIds([]);
setSectorExcludedProviders([]);
setSectorExcludedObjectKinds([]);
setActiveWorkspaceWindowId((current) => current === "sector" ? undefined : current);
}, [gridSectorDefinitionKey]);
const minimumGridLodHeight = selectedGridLodIndex === 0
? 0.1
@@ -1208,7 +1353,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
}),
}), [dataProductBindings, inspectorOpenSections, mapCamera, mapHeight, mapSettings, pinBindings, presentationProfiles, presentationSummaries, referenceLayers, subjectDetailProfiles, subjectStates]);
const updateSubjectState = (bindingId: string, update: (state: MapSubjectState) => MapSubjectState) => {
const updateSubjectState = useCallback((bindingId: string, update: (state: MapSubjectState) => MapSubjectState) => {
setSubjectStates((current) => {
const index = dataProductBindings.findIndex((binding) => binding.id === bindingId);
const state = current[bindingId] ?? {
@@ -1219,7 +1364,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
};
return { ...current, [bindingId]: update(state) };
});
};
}, [dataProductBindings]);
const togglePresentationFilter = (bindingId: string, field: string, value: string) => {
updateSubjectState(bindingId, (state) => {
@@ -1236,40 +1381,101 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
updateSubjectState(bindingId, (state) => ({ ...state, visible: !state.visible }));
};
const activateWorkspaceWindow = useCallback((windowId: MapWorkspaceWindowId) => {
if (activeWorkspaceWindowId === windowId) return;
const nextZIndex = Math.max(
20,
settingsWindowZIndex,
layersWindowZIndex,
sectorWindowZIndex,
subjectCardZIndex,
...Object.values(subjectStates).map((state) => state.window.zIndex),
) + 1;
if (windowId === "settings") setSettingsWindowZIndex(nextZIndex);
else if (windowId === "layers") setLayersWindowZIndex(nextZIndex);
else if (windowId === "sector") setSectorWindowZIndex(nextZIndex);
else if (windowId === "subject-card") setSubjectCardZIndex(nextZIndex);
else if (windowId.startsWith("binding:")) {
const bindingId = windowId.slice("binding:".length);
updateSubjectState(bindingId, (state) => ({
...state,
window: { ...state.window, zIndex: nextZIndex },
}));
}
setActiveWorkspaceWindowId(windowId);
}, [activeWorkspaceWindowId, layersWindowZIndex, sectorWindowZIndex, settingsWindowZIndex, subjectCardZIndex, subjectStates, updateSubjectState]);
const clearActiveWorkspaceWindow = (windowId: MapWorkspaceWindowId) => {
setActiveWorkspaceWindowId((current) => current === windowId ? undefined : current);
};
const openSubjectWindow = (bindingId: string) => {
const nextZIndex = Math.max(20, layersWindowZIndex, subjectCardZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1;
updateSubjectState(bindingId, (state) => ({
...state,
window: { ...state.window, open: true, zIndex: nextZIndex },
window: { ...state.window, open: true },
}));
setLayersWindowActive(false);
setSubjectCardActive(false);
setActiveSubjectBindingId(bindingId);
activateWorkspaceWindow(`binding:${bindingId}`);
};
const closeSubjectWindow = (bindingId: string) => {
updateSubjectState(bindingId, (state) => ({ ...state, window: { ...state.window, open: false } }));
setActiveSubjectBindingId((current) => current === bindingId ? undefined : current);
clearActiveWorkspaceWindow(`binding:${bindingId}`);
};
const activateLayersWindow = () => {
const nextZIndex = Math.max(20, layersWindowZIndex, subjectCardZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1;
setLayersWindowZIndex(nextZIndex);
setLayersWindowActive(true);
setSubjectCardActive(false);
setActiveSubjectBindingId(undefined);
activateWorkspaceWindow("layers");
};
const toggleLayersWindow = () => {
if (layersOpen) {
setLayersOpen(false);
setLayersWindowActive(false);
clearActiveWorkspaceWindow("layers");
return;
}
setLayersOpen(true);
activateLayersWindow();
};
const openSettingsWindow = () => {
setInspectorOpen(true);
activateWorkspaceWindow("settings");
};
const deactivateGridSector = () => {
setSelectedGridSector(null);
setSectorExcludedBindingIds([]);
setSectorExcludedProviders([]);
setSectorExcludedObjectKinds([]);
clearActiveWorkspaceWindow("sector");
};
const handleGridSectorSelect = (selection: GridSectorSelection | null) => {
if (!selection) {
deactivateGridSector();
return;
}
setSelectedGridSector(selection);
activateWorkspaceWindow("sector");
};
const setSectorBindingEnabled = (bindingId: string, enabled: boolean) => {
setSectorExcludedBindingIds((current) => enabled
? current.filter((value) => value !== bindingId)
: [...new Set([...current, bindingId])]);
};
const setSectorProviderEnabled = (provider: string, enabled: boolean) => {
setSectorExcludedProviders((current) => enabled
? current.filter((value) => value !== provider)
: [...new Set([...current, provider])]);
};
const setSectorObjectKindEnabled = (objectKind: string, enabled: boolean) => {
setSectorExcludedObjectKinds((current) => enabled
? current.filter((value) => value !== objectKind)
: [...new Set([...current, objectKind])]);
};
const updatePresentationProfile = (
profileId: string,
update: (profile: MapPresentationProfile) => MapPresentationProfile,
@@ -1297,11 +1503,8 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
: (profile?.defaultTabId ?? "overview")
));
setSubjectCardOpen(true);
setSubjectCardActive(true);
setLayersWindowActive(false);
setActiveSubjectBindingId(undefined);
setSubjectCardZIndex((current) => Math.max(current, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1);
}, [dataProductBindings, layersWindowZIndex, selectable, subjectDetailProfiles, subjectStates]);
activateWorkspaceWindow("subject-card");
}, [activateWorkspaceWindow, dataProductBindings, selectable, subjectDetailProfiles]);
const handleSelectAndFocus = useCallback((entityId: string) => {
handleSelect(entityId);
@@ -2027,7 +2230,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
key={rendererRevision}
ref={mapRendererRef}
onSelect={handleSelect}
onGridSectorSelect={setSelectedGridSector}
onGridSectorSelect={handleGridSectorSelect}
selectedGridSector={selectedGridSector}
onGatewayHealth={handleRendererGatewayHealth}
onProviderStatus={setProviderStatus}
@@ -2037,14 +2240,14 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
onSpiralStateChange={handleSpiralStateChange}
initialCamera={mapCamera ?? undefined}
presentation={presentation}
runtimeBindings={[...primaryRuntimeBindings, ...referenceRuntimeBindings]}
runtimeBindings={[...sectorScopedPrimaryRuntimeBindings, ...referenceRuntimeBindings]}
presentationProfiles={presentationProfiles}
presentationFilters={rendererPresentationFilters}
/>
</Suspense>
<div className="catalog-map-fixture__actions">
<IconButton label="Настройки карты" aria-pressed={inspectorOpen} data-active={inspectorOpen || undefined} onClick={() => setInspectorOpen(true)}><Icon name="settings" /></IconButton>
<IconButton label="Настройки карты" aria-pressed={inspectorOpen} data-active={inspectorOpen || undefined} onClick={openSettingsWindow}><Icon name="settings" /></IconButton>
<IconButton label="Слои карты" aria-pressed={layersOpen} data-active={layersOpen || undefined} onClick={toggleLayersWindow}><Icon name="grid" /></IconButton>
{features.toolbar ? <IconButton label="Toolbar" aria-pressed={toolbarOpen} data-active={toolbarOpen || undefined} onClick={() => setToolbarOpen((value) => !value)}><Icon name="panel" /></IconButton> : null}
{features.assistant ? <IconButton label="Assistant" aria-pressed={assistantOpen} data-active={assistantOpen || undefined} onClick={() => setAssistantOpen((value) => !value)}><Icon name="apps" /></IconButton> : null}
@@ -2060,10 +2263,10 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
onActivate={activateLayersWindow}
onClose={() => {
setLayersOpen(false);
setLayersWindowActive(false);
clearActiveWorkspaceWindow("layers");
}}
title="Слои карты"
active={layersWindowActive}
active={activeWorkspaceWindowId === "layers"}
zIndex={layersWindowZIndex}
minWidth={320}
minHeight={360}
@@ -2089,6 +2292,136 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
</WorkspaceWindow>
) : null}
{selectedGridSector ? (
<WorkspaceWindow
boundsRef={workspaceRef}
rect={sectorWindowRect}
onRectChange={setSectorWindowRect}
maximized={sectorWindowMaximized}
onMaximizedChange={setSectorWindowMaximized}
onActivate={() => activateWorkspaceWindow("sector")}
onClose={deactivateGridSector}
title={`Активный сектор · LOD ${selectedGridSector.lod}`}
subtitle={selectedGridSector.label}
status={`${sectorVisibleEntities.length} / ${sectorSpatialEntities.length}`}
active={activeWorkspaceWindowId === "sector"}
zIndex={sectorWindowZIndex}
minWidth={340}
minHeight={380}
footer={(
<Button variant="secondary" size="compact" width="full" onClick={deactivateGridSector}>
Деактивировать сектор
</Button>
)}
className="catalog-map-fixture__sector-window catalog-map-fixture__map-glass-window"
aria-label={`Активный сектор: ${selectedGridSector.id}`}
>
<div className="catalog-map-sector-window">
<section className="catalog-map-sector-window__summary" aria-label="Параметры сектора">
<code title={selectedGridSector.id}>{selectedGridSector.id}</code>
<span>{gridSectorBoundsLabel(selectedGridSector)}</span>
<span>{formatGridSectorArea(selectedGridSector.areaSquareMeters)}</span>
<Button
variant="secondary"
size="compact"
width="full"
icon={<Icon name={gridSectorCopyState === "copied" ? "check" : "copy"} />}
onClick={() => void copySelectedGridSectorId()}
>{gridSectorCopyState === "copied" ? "ID скопирован" : "Копировать stable ID"}</Button>
</section>
<Checker
checked={hideObjectsOutsideSector}
label="Скрыть объекты за сектором"
onChange={setHideObjectsOutsideSector}
/>
<section className="catalog-map-sector-window__filters" aria-labelledby="map-sector-domains-title">
<div className="catalog-map-sector-window__section-title">
<strong id="map-sector-domains-title">Домены данных</strong>
<small>{sectorBindingOptions.length}</small>
</div>
{sectorBindingOptions.map((option) => (
<Checker
key={option.value}
checked={!sectorExcludedBindingIds.includes(option.value)}
label={`${option.label} · ${option.count}`}
onChange={(enabled) => setSectorBindingEnabled(option.value, enabled)}
/>
))}
</section>
{sectorProviderOptions.length ? (
<section className="catalog-map-sector-window__filters" aria-labelledby="map-sector-providers-title">
<div className="catalog-map-sector-window__section-title">
<strong id="map-sector-providers-title">Провайдеры</strong>
<small>{sectorProviderOptions.length}</small>
</div>
{sectorProviderOptions.map((option) => (
<Checker
key={option.value}
checked={!sectorExcludedProviders.includes(option.value)}
label={`${option.label} · ${option.count}`}
title={option.value === MAP_SCOPE_MISSING_VALUE ? undefined : option.value}
onChange={(enabled) => setSectorProviderEnabled(option.value, enabled)}
/>
))}
</section>
) : null}
{sectorObjectKindOptions.length ? (
<section className="catalog-map-sector-window__filters" aria-labelledby="map-sector-kinds-title">
<div className="catalog-map-sector-window__section-title">
<strong id="map-sector-kinds-title">Типы объектов</strong>
<small>{sectorObjectKindOptions.length}</small>
</div>
{sectorObjectKindOptions.map((option) => (
<Checker
key={option.value}
checked={!sectorExcludedObjectKinds.includes(option.value)}
label={`${option.label} · ${option.count}`}
title={option.value === MAP_SCOPE_MISSING_VALUE ? undefined : option.value}
onChange={(enabled) => setSectorObjectKindEnabled(option.value, enabled)}
/>
))}
</section>
) : 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>{sectorVisibleEntities.length} / {sectorSpatialEntities.length}</small>
</div>
<div className="catalog-map-sector-window__object-list">
{sectorVisibleEntities.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 === selectedId || undefined}
onClick={() => handleSelectAndFocus(entity.id)}
>
<span>{entity.title}</span>
<code>{entity.fact.sourceId}</code>
<small>{[provider, objectKind, entity.status].filter(Boolean).join(" · ")}</small>
</button>
);
})}
{!sectorVisibleEntities.length ? (
<small className="catalog-map-sector-window__empty">
{sectorSpatialEntities.length
? "Объекты скрыты текущими фильтрами."
: "В секторе нет точечных объектов подключённых Data Products."}
</small>
) : null}
</div>
</section>
</div>
</WorkspaceWindow>
) : null}
{toolbarOpen ? (
<div className="catalog-map-fixture__toolbar" aria-label="Map toolbar" data-search-open={searchOpen || undefined}>
<Dropdown
@@ -2256,7 +2589,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
onClose={() => closeSubjectWindow(summary.bindingId)}
title={summary.displayName}
subtitle={`${summary.total} всего · ${visibleCount} на карте`}
active={activeSubjectBindingId === summary.bindingId}
active={activeWorkspaceWindowId === `binding:${summary.bindingId}`}
zIndex={state.window.zIndex}
minWidth={240}
minHeight={220}
@@ -2343,20 +2676,14 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
onRectChange={setSubjectCardRect}
maximized={subjectCardMaximized}
onMaximizedChange={setSubjectCardMaximized}
onActivate={() => {
const nextZIndex = Math.max(subjectCardZIndex, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1;
setSubjectCardZIndex(nextZIndex);
setSubjectCardActive(true);
setLayersWindowActive(false);
setActiveSubjectBindingId(undefined);
}}
onActivate={() => activateWorkspaceWindow("subject-card")}
onClose={() => {
setSubjectCardOpen(false);
setSubjectCardActive(false);
clearActiveWorkspaceWindow("subject-card");
}}
title={selectedSubjectCard.title}
subtitle={selectedSubjectCard.sourceId}
active={subjectCardActive}
active={activeWorkspaceWindowId === "subject-card"}
zIndex={subjectCardZIndex}
minWidth={320}
minHeight={320}
@@ -2414,25 +2741,35 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
{assistantOpen ? <div className="catalog-map-fixture__assistant"><strong>NODE.DC Assistant</strong><span>Контекст выбранной сущности готов к передаче.</span></div> : null}
<button type="button" className="catalog-map-fixture__resize" aria-label="Изменить высоту карты" onPointerDown={startResize}><span /></button>
<Window
open={inspectorOpen && Boolean(features.inspector)}
title="Настройки карты"
subtitle="MAP / draggable inspector"
placement="end"
draggable
closeOnBackdrop={false}
lockBodyScroll={false}
trapFocus={false}
className="catalog-map-fixture__map-settings-window"
onClose={() => setInspectorOpen(false)}
>
<Inspector
sections={inspectorSections}
openSections={inspectorOpenSections}
singleOpen
onOpenSectionsChange={setInspectorOpenSections}
/>
</Window>
{inspectorOpen && Boolean(features.inspector) ? (
<WorkspaceWindow
boundsRef={workspaceRef}
rect={settingsWindowRect}
onRectChange={setSettingsWindowRect}
maximized={settingsWindowMaximized}
onMaximizedChange={setSettingsWindowMaximized}
onActivate={() => activateWorkspaceWindow("settings")}
onClose={() => {
setInspectorOpen(false);
clearActiveWorkspaceWindow("settings");
}}
title="Настройки карты"
subtitle="MAP / inspector"
active={activeWorkspaceWindowId === "settings"}
zIndex={settingsWindowZIndex}
minWidth={360}
minHeight={380}
className="catalog-map-fixture__map-settings-window catalog-map-fixture__map-glass-window"
aria-label="Настройки карты"
>
<Inspector
sections={inspectorSections}
openSections={inspectorOpenSections}
singleOpen
onOpenSectionsChange={setInspectorOpenSections}
/>
</WorkspaceWindow>
) : null}
</div>
);
});
+3 -4
View File
@@ -214,10 +214,9 @@ export function mapFactMatchesFilters(
if (selectedFacets.some(({ selected }) => selected.length === 0)) return false;
if (selectedFacets.length === 0) return true;
// Chips form one global union. This mirrors the objects window: choosing
// values from two categories expands the visible set instead of requiring a
// fact to satisfy both categories simultaneously.
return selectedFacets.some(({ facet, selected }) => (
// 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]))
));
+8
View File
@@ -168,6 +168,14 @@ export function fixedGridOrigin(settings: { gridCenterLatitude?: number; gridCen
// Existing horizontal sector IDs are intentionally unchanged in v2.
export function localSectorId(definition: LocalGridDefinition, eastIndex: number, northIndex: number): string;
export function localSectorAt(point: { eastMeters: number; northMeters: number }, definition: LocalGridDefinition): LocalSectorAddress;
export function geodeticToLocalGridPlane(
point: { longitude: number; latitude: number },
definition: Pick<LocalGridDefinition, "originLatitude" | "originLongitude">,
): LocalCenter;
export function localSectorAtGeodetic(
point: { longitude: number; latitude: number },
definition: LocalGridDefinition,
): LocalSectorAddress;
export function localSectorBounds(address: LocalSectorAddress, stepMeters: number): LocalBounds;
export function localSectorNeighbors(address: LocalSectorAddress, definition: LocalGridDefinition): Record<"north" | "east" | "south" | "west", LocalSectorAddress>;
export function localParentSector(address: LocalSectorAddress, childStepMeters: number, parentDefinition: LocalGridDefinition): LocalSectorAddress;
+74
View File
@@ -84,6 +84,80 @@ export function localSectorAt(point, definition) {
};
}
/**
* Projects a WGS84 surface position onto the fixed origin's ENU tangent
* plane. This mirrors the renderer's analytic sector picking: the target
* surface normal is intersected with the origin plane before addressing.
*/
export function geodeticToLocalGridPlane(point, definition) {
const latitude = clamp(finite(point?.latitude, 0), -90, 90) * Math.PI / 180;
const longitude = normalizeLongitudeDegrees(finite(point?.longitude, 0)) * Math.PI / 180;
const originLatitude = clamp(finite(definition?.originLatitude, 0), -90, 90) * Math.PI / 180;
const originLongitude = normalizeLongitudeDegrees(finite(definition?.originLongitude, 0)) * Math.PI / 180;
const surfaceCartesian = (lat, lon) => {
const sineLatitude = Math.sin(lat);
const cosineLatitude = Math.cos(lat);
const primeVerticalRadius = WGS84_EQUATORIAL_RADIUS_METERS
/ Math.sqrt(1 - WGS84_ECCENTRICITY_SQUARED * sineLatitude ** 2);
return {
x: primeVerticalRadius * cosineLatitude * Math.cos(lon),
y: primeVerticalRadius * cosineLatitude * Math.sin(lon),
z: primeVerticalRadius * (1 - WGS84_ECCENTRICITY_SQUARED) * sineLatitude,
};
};
const origin = surfaceCartesian(originLatitude, originLongitude);
const target = surfaceCartesian(latitude, longitude);
const offset = {
x: target.x - origin.x,
y: target.y - origin.y,
z: target.z - origin.z,
};
const east = {
x: -Math.sin(originLongitude),
y: Math.cos(originLongitude),
z: 0,
};
const north = {
x: -Math.sin(originLatitude) * Math.cos(originLongitude),
y: -Math.sin(originLatitude) * Math.sin(originLongitude),
z: Math.cos(originLatitude),
};
const up = {
x: Math.cos(originLatitude) * Math.cos(originLongitude),
y: Math.cos(originLatitude) * Math.sin(originLongitude),
z: Math.sin(originLatitude),
};
const targetNormal = {
x: Math.cos(latitude) * Math.cos(longitude),
y: Math.cos(latitude) * Math.sin(longitude),
z: Math.sin(latitude),
};
const dot = (left, right) => left.x * right.x + left.y * right.y + left.z * right.z;
const localSurface = {
eastMeters: dot(offset, east),
northMeters: dot(offset, north),
upMeters: dot(offset, up),
};
const localNormal = {
east: dot(targetNormal, east),
north: dot(targetNormal, north),
up: dot(targetNormal, up),
};
const normalScale = Math.abs(localNormal.up) > EPSILON
? -localSurface.upMeters / localNormal.up
: 0;
return {
eastMeters: localSurface.eastMeters + localNormal.east * normalScale,
northMeters: localSurface.northMeters + localNormal.north * normalScale,
};
}
export function localSectorAtGeodetic(point, definition) {
return localSectorAt(geodeticToLocalGridPlane(point, definition), definition);
}
export function localSectorBounds(address, stepMeters) {
const step = Math.max(EPSILON, finite(stepMeters, 1));
return {
+99 -6
View File
@@ -605,7 +605,7 @@ textarea {
.catalog-map-fixture__actions {
position: absolute;
z-index: 8;
z-index: 400;
top: 1rem;
right: 1rem;
display: flex;
@@ -633,7 +633,9 @@ textarea {
color: var(--nodedc-glass-control-active-text);
}
.catalog-map-fixture__layers {
.catalog-map-fixture__layers,
.catalog-map-fixture__map-settings-window,
.catalog-map-fixture__sector-window {
--nodedc-radius-modal: 1.45rem;
}
@@ -655,18 +657,109 @@ textarea {
-webkit-backdrop-filter: blur(var(--nodedc-blur-modal)) saturate(128%);
}
.catalog-map-fixture__map-glass-window .nodedc-workspace-window__action,
.catalog-map-fixture__map-settings-window .nodedc-window__close {
.catalog-map-fixture__map-glass-window .nodedc-workspace-window__action {
background: rgba(255, 255, 255, 0.24);
color: rgba(255, 255, 255, 0.94);
}
.catalog-map-fixture__map-glass-window .nodedc-workspace-window__action:hover,
.catalog-map-fixture__map-settings-window .nodedc-window__close:hover {
.catalog-map-fixture__map-glass-window .nodedc-workspace-window__action:hover {
background: rgba(255, 255, 255, 0.42);
color: #ffffff;
}
.catalog-map-fixture__map-settings-window .nodedc-workspace-window__body {
padding: 0.3rem 0.75rem 0.8rem;
}
.catalog-map-fixture__sector-window .nodedc-workspace-window__body {
padding: 0.25rem 0.72rem 0.7rem;
}
.catalog-map-sector-window {
display: grid;
gap: 0.7rem;
}
.catalog-map-sector-window__summary,
.catalog-map-sector-window__filters,
.catalog-map-sector-window__objects {
display: grid;
gap: 0.42rem;
border-radius: var(--nodedc-radius-control);
background: var(--nodedc-glass-control-bg);
padding: 0.72rem;
}
.catalog-map-sector-window__summary code {
overflow: hidden;
color: var(--nodedc-text-primary);
font-size: var(--nodedc-font-size-xs);
text-overflow: ellipsis;
white-space: nowrap;
}
.catalog-map-sector-window__summary > span {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
}
.catalog-map-sector-window__section-title {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
}
.catalog-map-sector-window__section-title strong {
font-size: var(--nodedc-font-size-sm);
}
.catalog-map-sector-window__section-title small {
color: var(--nodedc-text-muted);
}
.catalog-map-sector-window__filters .nodedc-checker {
min-height: 2.65rem;
}
.catalog-map-sector-window__object-list {
display: grid;
gap: 0.3rem;
}
.catalog-map-sector-window__object-list > button {
display: grid;
width: 100%;
gap: 0.12rem;
border-radius: var(--nodedc-radius-control);
background: rgba(255, 255, 255, 0.5);
padding: 0.6rem 0.68rem;
color: inherit;
text-align: left;
cursor: pointer;
}
.catalog-map-sector-window__object-list > button:hover,
.catalog-map-sector-window__object-list > button[data-selected] {
background: var(--nodedc-glass-control-active);
}
.catalog-map-sector-window__object-list > button > span {
overflow: hidden;
font-size: var(--nodedc-font-size-sm);
font-weight: var(--nodedc-font-weight-strong);
text-overflow: ellipsis;
white-space: nowrap;
}
.catalog-map-sector-window__object-list code,
.catalog-map-sector-window__object-list small,
.catalog-map-sector-window__empty {
overflow-wrap: anywhere;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
}
.catalog-map-fixture__layers-content {
display: grid;
gap: 0.55rem;
+24
View File
@@ -99,6 +99,30 @@ spatial cell. Частичные строки не создают сетевых
Facet-фильтры поддерживают мультивыбор: OR внутри одного facet и AND между facets. Missing facet означает отсутствие ограничения, а явно пустой список — ноль совпадений. Отжатие последнего chip не включает `Все`; `Все` включается и выключается только явным кликом. Переключение фильтра не двигает камеру, обзор выполняется отдельным действием.
Выбор стабильной ячейки grid активирует сектор и открывает отдельное
`WorkspaceWindow` внутри map stage. Окно показывает stable sector ID, геометрию,
число точечных subjects, домены по primary `bindingId`, провайдеры из
проецированного `position_source`, типы из проецированного `object_kind` и
список совпавших subjects. Значения провайдера и типа выводятся только из
текущего авторизованного Data Product snapshot/patch; renderer не содержит
списков Gelios, Robot2B, trike или других provider-specific значений. Если поле
не входит в `fieldProjection`, соответствующая группа фильтра не появляется.
Фильтры домена, провайдера и типа действуют на карту, пока сектор активен.
Список всегда ограничен геометрией сектора. Опция `Скрыть объекты за сектором`
дополнительно ограничивает сам renderer этой геометрией; по умолчанию она
выключена, поэтому выбор сектора не прячет остальную карту неожиданно. Для
local ENU принадлежность WGS84-точки вычисляется через ту же касательную
плоскость и surface-normal projection, что и analytic picking в Cesium; для
graticule используется тот же глобальный адресатор. Закрытие окна крестиком и
кнопка `Деактивировать сектор` выполняют одну операцию: снимают selection и
очищают transient sector-фильтры.
Настройки карты, слои, окно сектора, binding-окна и карточка subject используют
один application-owned workspace stack. Pointer/focus поднимает выбранное окно
выше остальных независимо от его типа; ни одно из этих map-stage окон не
рендерится в глобальный viewport overlay.
## Platform reference layers
Станции метро, железнодорожные станции и вокзалы не являются L1/L2 workflow и
+6
View File
@@ -88,6 +88,12 @@ Side window использует ту же механику, но placement `end
Workspace window используется для вспомогательных камер, инструментов и сопоставляемых представлений внутри stage. Оно не заменяет modal `Window`, viewport-level Inspector или `ApplicationPanel`.
Если Inspector принадлежит конкретной bounded-сцене и должен конкурировать по
z-order с её слоями, карточками и инструментами, он является содержимым
`WorkspaceWindow`, а не viewport-level side window. Map Page следует именно
этому варианту: настройки карты, слои, активный сектор, окна bindings и
карточка объекта входят в один контролируемый stack.
## Управление состоянием
Библиотека не создаёт глобальный store окон. Приложение владеет тем, какое окно открыто и какие данные в нём загружены. Библиотека владеет одинаковым поведением самого слоя.
+26 -1
View File
@@ -34,7 +34,32 @@ test("joined detail aspects do not become independent map layers", async () => {
assert.match(preview, /dataProductBindings\.filter\(\(binding\) => !binding\.joinToBindingId\)/);
assert.match(preview, /runtimeBindings\.filter\(\(binding\) => primaryBindingIds\.has\(binding\.bindingId\)\)/);
assert.match(preview, /runtimeBindings=\{\[\.\.\.primaryRuntimeBindings, \.\.\.referenceRuntimeBindings\]\}/);
assert.match(preview, /runtimeBindings=\{\[\.\.\.sectorScopedPrimaryRuntimeBindings, \.\.\.referenceRuntimeBindings\]\}/);
});
test("map windows share one bounded activation and z-index stack", async () => {
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
assert.match(preview, /type MapWorkspaceWindowId = "settings" \| "layers" \| "sector" \| "subject-card" \| `binding:\$\{string\}`/);
assert.match(preview, /const activateWorkspaceWindow = useCallback/);
assert.match(preview, /settingsWindowZIndex[\s\S]*layersWindowZIndex[\s\S]*sectorWindowZIndex[\s\S]*subjectCardZIndex/);
assert.match(preview, /title="Настройки карты"[\s\S]*active=\{activeWorkspaceWindowId === "settings"\}/);
assert.doesNotMatch(preview, /<Window\b/);
});
test("an active sector scopes point facts, exposes data facets and deactivates on close", async () => {
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
assert.match(preview, /function mapFactInsideGridSector/);
assert.match(preview, /localSectorAtGeodetic/);
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 () => {
+2 -2
View File
@@ -109,7 +109,7 @@ test("interactive deselect preserves other values and facet constraints", () =>
});
});
test("chips from different facets form one global union", () => {
test("chips are OR within one facet and AND between facets", () => {
const filters = {
fleet: {
visible: true,
@@ -121,7 +121,7 @@ test("chips from different facets form one global union", () => {
};
assert.equal(mapFactMatchesFilters(onlineMoving, profile, filters, "fleet"), true);
assert.equal(mapFactMatchesFilters({ attributes: { signal_state: "active", movement_state: "stopped" } }, profile, filters, "fleet"), true);
assert.equal(mapFactMatchesFilters({ attributes: { signal_state: "active", movement_state: "stopped" } }, profile, filters, "fleet"), false);
assert.equal(mapFactMatchesFilters({ attributes: { signal_state: "inactive", movement_state: "moving" } }, profile, filters, "fleet"), false);
});
+29
View File
@@ -4,15 +4,19 @@ import {
ArcType,
ApproximateTerrainHeights,
Cartesian3,
Ellipsoid,
GroundPolylineGeometry,
Matrix4,
Math as CesiumMath,
PolylineGeometry,
Transforms,
} from "cesium";
import {
alignedGridValues,
boundedAngularParts,
fixedGridOrigin,
geodeticRectangleAreaSquareMeters,
geodeticToLocalGridPlane,
graticuleGranularity,
graticuleLinePlan,
graticuleMajorTileAt,
@@ -42,6 +46,7 @@ import {
localParentSector,
localSectorAreaSquareMeters,
localSectorAt,
localSectorAtGeodetic,
localSectorBounds,
localSectorNeighbors,
localSectorSummary,
@@ -108,6 +113,30 @@ test("local sectors use half-open floor boundaries on both sides of the ENU orig
);
});
test("WGS84 positions use the same tangent-plane address as Cesium sector picking", () => {
const origin = Cartesian3.fromDegrees(localDefinition.originLongitude, localDefinition.originLatitude, 0);
const inverseEnu = Matrix4.inverse(Transforms.eastNorthUpToFixedFrame(origin), new Matrix4());
const point = { longitude: 37.645, latitude: 55.773 };
const worldPosition = Cartesian3.fromDegrees(point.longitude, point.latitude, 0);
const surface = Ellipsoid.WGS84.scaleToGeodeticSurface(worldPosition, new Cartesian3());
const normal = Ellipsoid.WGS84.geodeticSurfaceNormal(surface, new Cartesian3());
const localSurface = Matrix4.multiplyByPoint(inverseEnu, surface, new Cartesian3());
const localNormal = Matrix4.multiplyByPointAsVector(inverseEnu, normal, new Cartesian3());
const normalScale = -localSurface.z / localNormal.z;
const expected = {
eastMeters: localSurface.x + localNormal.x * normalScale,
northMeters: localSurface.y + localNormal.y * normalScale,
};
const actual = geodeticToLocalGridPlane(point, localDefinition);
assert.ok(Math.abs(actual.eastMeters - expected.eastMeters) < 1e-6);
assert.ok(Math.abs(actual.northMeters - expected.northMeters) < 1e-6);
assert.equal(
localSectorAtGeodetic(point, localDefinition).id,
localSectorAt(expected, localDefinition).id,
);
});
test("local sector IDs and bounds stay stable across calls and normalized equivalent origins", () => {
const first = localSectorAt({ eastMeters: -1, northMeters: 2_500 }, localDefinition);
const second = localSectorAt({ eastMeters: -1, northMeters: 2_500 }, {