From 58800d957632320fa6717ec3fcff972023528759 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 8 Aug 2026 14:09:51 +0300 Subject: [PATCH] FEAT - FOUNDRY: activate sector workspace --- apps/catalog/src/MapFixturePreview.tsx | 471 ++++++++++++++++++--- apps/catalog/src/mapPresentationProfile.ts | 7 +- apps/catalog/src/mapSectorGrid.d.mts | 8 + apps/catalog/src/mapSectorGrid.mjs | 74 ++++ apps/catalog/src/styles.css | 105 ++++- docs/MAP_TEMPLATE.md | 24 ++ docs/WINDOWS_AND_LAYERS.md | 6 + scripts/map-object-layers.test.mjs | 27 +- scripts/map-presentation-filters.test.mjs | 4 +- scripts/map-sector-grid.test.mjs | 29 ++ 10 files changed, 675 insertions(+), 80 deletions(-) diff --git a/apps/catalog/src/MapFixturePreview.tsx b/apps/catalog/src/MapFixturePreview.tsx index fba0dec..77e77a5 100644 --- a/apps/catalog/src/MapFixturePreview.tsx +++ b/apps/catalog/src/MapFixturePreview.tsx @@ -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(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>({}); const [inspectorOpen, setInspectorOpen] = useState(false); + const [settingsWindowRect, setSettingsWindowRect] = useState(defaultSettingsWindowRect); + const [settingsWindowMaximized, setSettingsWindowMaximized] = useState(false); + const [settingsWindowZIndex, setSettingsWindowZIndex] = useState(13); const [inspectorOpenSections, setInspectorOpenSections] = useState(() => ( initialLayout?.inspectorOpenSections ?? ["map-base"] )); @@ -676,7 +740,14 @@ export const MapFixturePreview = forwardRef(defaultLayersWindowRect); const [layersWindowMaximized, setLayersWindowMaximized] = useState(false); const [layersWindowZIndex, setLayersWindowZIndex] = useState(12); - const [layersWindowActive, setLayersWindowActive] = useState(false); + const [sectorWindowRect, setSectorWindowRect] = useState(defaultSectorWindowRect); + const [sectorWindowMaximized, setSectorWindowMaximized] = useState(false); + const [sectorWindowZIndex, setSectorWindowZIndex] = useState(142); + const [hideObjectsOutsideSector, setHideObjectsOutsideSector] = useState(false); + const [sectorExcludedBindingIds, setSectorExcludedBindingIds] = useState([]); + const [sectorExcludedProviders, setSectorExcludedProviders] = useState([]); + const [sectorExcludedObjectKinds, setSectorExcludedObjectKinds] = useState([]); + const [activeWorkspaceWindowId, setActiveWorkspaceWindowId] = useState(); const [toolbarOpen, setToolbarOpen] = useState(Boolean(features.toolbar)); const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -734,7 +805,6 @@ export const MapFixturePreview = forwardRef>(() => ( initialSubjectState(initialLayout?.dataProductBindings ?? [], initialLayout?.subjectStates) )); - const [activeSubjectBindingId, setActiveSubjectBindingId] = useState(); const presentationFilters = useMemo(() => Object.fromEntries( Object.entries(subjectStates).map(([bindingId, state]) => [bindingId, { visible: state.visible, @@ -765,6 +835,11 @@ export const MapFixturePreview = forwardRef ({ + 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 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(); + 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(); + 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 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 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 JSON.stringify({ origin: [mapSettings.gridCenterLatitude, mapSettings.gridCenterLongitude], profiles: sectorGridLodProfiles.map((profile) => ({ @@ -942,10 +1087,6 @@ export const MapFixturePreview = forwardRef ({ - 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 { 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 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 { updateSubjectState(bindingId, (state) => { @@ -1236,40 +1381,101 @@ export const MapFixturePreview = forwardRef ({ ...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 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
- setInspectorOpen(true)}> + {features.toolbar ? setToolbarOpen((value) => !value)}> : null} {features.assistant ? setAssistantOpen((value) => !value)}> : null} @@ -2060,10 +2263,10 @@ export const MapFixturePreview = forwardRef { 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 ) : null} + {selectedGridSector ? ( + 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={( + + )} + className="catalog-map-fixture__sector-window catalog-map-fixture__map-glass-window" + aria-label={`Активный сектор: ${selectedGridSector.id}`} + > +
+
+ {selectedGridSector.id} + {gridSectorBoundsLabel(selectedGridSector)} + {formatGridSectorArea(selectedGridSector.areaSquareMeters)} + +
+ + + +
+
+ Домены данных + {sectorBindingOptions.length} +
+ {sectorBindingOptions.map((option) => ( + setSectorBindingEnabled(option.value, enabled)} + /> + ))} +
+ + {sectorProviderOptions.length ? ( +
+
+ Провайдеры + {sectorProviderOptions.length} +
+ {sectorProviderOptions.map((option) => ( + setSectorProviderEnabled(option.value, enabled)} + /> + ))} +
+ ) : null} + + {sectorObjectKindOptions.length ? ( +
+
+ Типы объектов + {sectorObjectKindOptions.length} +
+ {sectorObjectKindOptions.map((option) => ( + setSectorObjectKindEnabled(option.value, enabled)} + /> + ))} +
+ ) : null} + +
+
+ Объекты сектора + {sectorVisibleEntities.length} / {sectorSpatialEntities.length} +
+
+ {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 ( + + ); + })} + {!sectorVisibleEntities.length ? ( + + {sectorSpatialEntities.length + ? "Объекты скрыты текущими фильтрами." + : "В секторе нет точечных объектов подключённых Data Products."} + + ) : null} +
+
+
+
+ ) : null} + {toolbarOpen ? (
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 { - 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 = forwardRefNODE.DC AssistantКонтекст выбранной сущности готов к передаче.
: null} - setInspectorOpen(false)} - > - - + {inspectorOpen && Boolean(features.inspector) ? ( + 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="Настройки карты" + > + + + ) : null}
); }); diff --git a/apps/catalog/src/mapPresentationProfile.ts b/apps/catalog/src/mapPresentationProfile.ts index d04c2f3..cae34a7 100644 --- a/apps/catalog/src/mapPresentationProfile.ts +++ b/apps/catalog/src/mapPresentationProfile.ts @@ -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])) )); diff --git a/apps/catalog/src/mapSectorGrid.d.mts b/apps/catalog/src/mapSectorGrid.d.mts index 546a6ea..df37ffd 100644 --- a/apps/catalog/src/mapSectorGrid.d.mts +++ b/apps/catalog/src/mapSectorGrid.d.mts @@ -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, +): 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; diff --git a/apps/catalog/src/mapSectorGrid.mjs b/apps/catalog/src/mapSectorGrid.mjs index 79f8180..3e75ab9 100644 --- a/apps/catalog/src/mapSectorGrid.mjs +++ b/apps/catalog/src/mapSectorGrid.mjs @@ -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 { diff --git a/apps/catalog/src/styles.css b/apps/catalog/src/styles.css index 98df40c..3f3ca43 100644 --- a/apps/catalog/src/styles.css +++ b/apps/catalog/src/styles.css @@ -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; diff --git a/docs/MAP_TEMPLATE.md b/docs/MAP_TEMPLATE.md index da12c28..e77723a 100644 --- a/docs/MAP_TEMPLATE.md +++ b/docs/MAP_TEMPLATE.md @@ -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 и diff --git a/docs/WINDOWS_AND_LAYERS.md b/docs/WINDOWS_AND_LAYERS.md index 5277e36..5be6aa6 100644 --- a/docs/WINDOWS_AND_LAYERS.md +++ b/docs/WINDOWS_AND_LAYERS.md @@ -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 окон. Приложение владеет тем, какое окно открыто и какие данные в нём загружены. Библиотека владеет одинаковым поведением самого слоя. diff --git a/scripts/map-object-layers.test.mjs b/scripts/map-object-layers.test.mjs index c9ce63e..1f2b346 100644 --- a/scripts/map-object-layers.test.mjs +++ b/scripts/map-object-layers.test.mjs @@ -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, / { + 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, />Домены данныхПровайдерыТипы объектовОбъекты сектора\s*Деактивировать сектор\s* { diff --git a/scripts/map-presentation-filters.test.mjs b/scripts/map-presentation-filters.test.mjs index cd4dbbb..a044589 100644 --- a/scripts/map-presentation-filters.test.mjs +++ b/scripts/map-presentation-filters.test.mjs @@ -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); }); diff --git a/scripts/map-sector-grid.test.mjs b/scripts/map-sector-grid.test.mjs index bae4126..59fec51 100644 --- a/scripts/map-sector-grid.test.mjs +++ b/scripts/map-sector-grid.test.mjs @@ -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 }, {