From b70e165910fa6d85a0e30ea9196bf33e59d1a497 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 9 Aug 2026 19:22:25 +0300 Subject: [PATCH] refactor(map): split foundry workspace and cesium runtime --- apps/catalog/src/CatalogApp.tsx | 3 +- apps/catalog/src/CesiumMapRenderer.tsx | 1982 +-------------- apps/catalog/src/MapFixturePreview.tsx | 2222 +++-------------- apps/catalog/src/MapSectorWorkspaceWindow.tsx | 212 ++ .../src/MapSubjectWorkspaceWindows.tsx | 238 ++ apps/catalog/src/MapWorkspaceToolbar.tsx | 377 +++ apps/catalog/src/applicationManifest.ts | 2 +- apps/catalog/src/designProfile.ts | 2 +- apps/catalog/src/mapCesiumGridLayer.ts | 1145 +++++++++ apps/catalog/src/mapCesiumRuntimeLayers.ts | 610 +++++ apps/catalog/src/mapGridInspectorSections.tsx | 348 +++ apps/catalog/src/mapGridPolicy.d.mts | 2 +- apps/catalog/src/mapInspectorSections.tsx | 428 ++++ apps/catalog/src/mapPageContract.ts | 226 ++ apps/catalog/src/mapPresentationProfile.d.mts | 97 + apps/catalog/src/mapPresentationProfile.mjs | 160 ++ apps/catalog/src/mapPresentationProfile.ts | 290 +-- apps/catalog/src/mapRendererContract.ts | 241 ++ apps/catalog/src/mapRuntimeIdentity.d.mts | 4 + apps/catalog/src/mapRuntimeIdentity.mjs | 12 + apps/catalog/src/mapSearch.d.mts | 2 +- apps/catalog/src/mapSectorRuntime.d.mts | 18 + apps/catalog/src/mapSectorRuntime.mjs | 49 + apps/catalog/src/mapSectorWorkspace.ts | 272 ++ apps/catalog/src/mapWorkspaceModel.d.mts | 53 + apps/catalog/src/mapWorkspaceModel.mjs | 256 ++ apps/catalog/src/mapWorkspaceState.d.mts | 56 + apps/catalog/src/mapWorkspaceState.mjs | 223 ++ apps/catalog/src/useMapDataProductRuntime.ts | 23 +- package.json | 3 +- scripts/inspector-select-contract.test.mjs | 8 +- scripts/map-grid-lod.test.mjs | 53 +- scripts/map-inspector-overlay-state.test.mjs | 16 +- scripts/map-object-layers.test.mjs | 187 +- scripts/map-presentation-filters.test.mjs | 10 +- scripts/map-reference-stations.test.mjs | 4 +- scripts/map-subject-card.test.mjs | 13 +- scripts/map-workspace-model.test.mjs | 235 ++ scripts/map-workspace-state.test.mjs | 102 + 39 files changed, 5959 insertions(+), 4225 deletions(-) create mode 100644 apps/catalog/src/MapSectorWorkspaceWindow.tsx create mode 100644 apps/catalog/src/MapSubjectWorkspaceWindows.tsx create mode 100644 apps/catalog/src/MapWorkspaceToolbar.tsx create mode 100644 apps/catalog/src/mapCesiumGridLayer.ts create mode 100644 apps/catalog/src/mapCesiumRuntimeLayers.ts create mode 100644 apps/catalog/src/mapGridInspectorSections.tsx create mode 100644 apps/catalog/src/mapInspectorSections.tsx create mode 100644 apps/catalog/src/mapPageContract.ts create mode 100644 apps/catalog/src/mapPresentationProfile.d.mts create mode 100644 apps/catalog/src/mapPresentationProfile.mjs create mode 100644 apps/catalog/src/mapRendererContract.ts create mode 100644 apps/catalog/src/mapRuntimeIdentity.d.mts create mode 100644 apps/catalog/src/mapRuntimeIdentity.mjs create mode 100644 apps/catalog/src/mapSectorRuntime.d.mts create mode 100644 apps/catalog/src/mapSectorRuntime.mjs create mode 100644 apps/catalog/src/mapSectorWorkspace.ts create mode 100644 apps/catalog/src/mapWorkspaceModel.d.mts create mode 100644 apps/catalog/src/mapWorkspaceModel.mjs create mode 100644 apps/catalog/src/mapWorkspaceState.d.mts create mode 100644 apps/catalog/src/mapWorkspaceState.mjs create mode 100644 scripts/map-workspace-model.test.mjs create mode 100644 scripts/map-workspace-state.test.mjs diff --git a/apps/catalog/src/CatalogApp.tsx b/apps/catalog/src/CatalogApp.tsx index 5840264..6dedd29 100644 --- a/apps/catalog/src/CatalogApp.tsx +++ b/apps/catalog/src/CatalogApp.tsx @@ -65,7 +65,8 @@ import { type DesignProfileSummary, type DesignProfileStatus, } from "./applicationManifest.js"; -import { createDefaultMapPageLayout, MapFixturePreview, type MapFixturePreviewHandle, type MapPageLayout } from "./MapFixturePreview.js"; +import { MapFixturePreview } from "./MapFixturePreview.js"; +import { createDefaultMapPageLayout, type MapFixturePreviewHandle, type MapPageLayout } from "./mapPageContract.js"; import { mapDesignFragmentForLayout, mapDesignFragmentFromLayout, diff --git a/apps/catalog/src/CesiumMapRenderer.tsx b/apps/catalog/src/CesiumMapRenderer.tsx index fb4fdb8..1314f86 100644 --- a/apps/catalog/src/CesiumMapRenderer.tsx +++ b/apps/catalog/src/CesiumMapRenderer.tsx @@ -1,133 +1,82 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react"; import { - ArcType, Cartesian2, Cartesian3, BingMapsImageryProvider, BingMapsStyle, - BillboardGraphics, BoundingSphere, Color, Credit, Cesium3DTileset, Cesium3DTileStyle, CesiumTerrainProvider, - CallbackProperty, - CallbackPositionProperty, Cartographic, - ClassificationType, - ColorGeometryInstanceAttribute, - ConstantPositionProperty, CustomDataSource, DefaultProxy, EllipsoidTerrainProvider, Entity, - GeometryInstance, - GroundPolylineGeometry, - GroundPolylinePrimitive, - GroundPrimitive, - HeightReference, HeadingPitchRange, - HorizontalOrigin, ImageryLayer, JulianDate, - LabelCollection, - LabelGraphics, - LabelStyle, Matrix4, Math as CesiumMath, - PerInstanceColorAppearance, - PointGraphics, - PointPrimitiveCollection, - PolygonGeometry, - PolygonHierarchy, - PolylineColorAppearance, - PolylineGraphics, - PolylineCollection, Resource, sampleTerrainMostDetailed, ScreenSpaceEventHandler, ScreenSpaceEventType, SunLight, Transforms, - VerticalOrigin, Viewer, } from "cesium"; import "cesium/Build/Cesium/Widgets/widgets.css"; import { MAX_SPIRAL_RADIUS_METERS, spiralSurfaceFrame, type GeodeticRadians } from "./mapSpiralMath.js"; import { cameraSurveyPitchForViewport, cameraSurveySampleDistances, cameraSurveySpiralDistance } from "./mapCameraPresets.js"; -import { mapRuntimeEntityId, type MapRuntimeBinding, type MapRuntimeFact } from "./useMapDataProductRuntime.js"; -import { - mapPresentationProfileForFact, - mapRuntimeDisplayLabel, - mapRuntimeFactIsVisible, - resolveMapPresentationClass, - resolveMapPresentationStyle, - type MapPresentationFilters, - type MapPresentationProfile, +import type { MapRuntimeBinding } from "./useMapDataProductRuntime.js"; +import type { + MapPresentationFilters, + MapPresentationProfile, } from "./mapPresentationProfile.js"; -import { normalizeHGeoZoneRing } from "./hGeoZoneProjection.mjs"; +import { fixedGridOrigin } from "./mapSectorGrid.mjs"; +import type { + CameraSpiralConfig, + CameraSpiralState, + CesiumMapRendererHandle, + GridMajorTileSelection, + GridSectorSelection, + MapCameraView, + MapGatewayHealth, + MapPresentation, + MapProviderState, + MapProviderStatus, +} from "./mapRendererContract.js"; import { - gridShouldBeVisible, - resolveGridMode, - selectGridLod, -} from "./mapGridPolicy.mjs"; -import { - boundedAngularParts, - fixedGridOrigin, - graticuleGranularity, - graticuleLinePlan, - graticuleMajorTileAt, - graticuleSectorAt, - graticuleSectorSummary, - isGraticuleMajorLineValue, - isLocalMajorLineIndex, - localGridPlan, - localMajorTileAt, - localSectorAt, - localSectorSummary, - localVolumeAt, - splitLongitudeRange, -} from "./mapSectorGrid.mjs"; + quarantineHGeoZoneLayers, + syncHGeoZoneVisibility, + syncRuntimeDataSources, + type HGeoZonePickId, + type HGeoZoneProjectionLayer, +} from "./mapCesiumRuntimeLayers.js"; +import { GridLayerController } from "./mapCesiumGridLayer.js"; + +export type { + CameraSpiralConfig, + CameraSpiralState, + CesiumMapRendererHandle, + GridLodProfile, + GridMajorTileSelection, + GridSectorSelection, + GridVolumeSelection, + MapCameraView, + MapGatewayHealth, + MapPresentation, + MapProviderState, + MapProviderStatus, + MapRendererHandle, +} from "./mapRendererContract.js"; const MAX_SPIRAL_SUBSTEPS_PER_FRAME = 300; const TERRAIN_SAMPLE_TIMEOUT_MS = 12_000; const SPIRAL_TILE_WAIT_TIMEOUT_MS = 45_000; -const MAX_HGEOZONE_INSTANCES_PER_BATCH = 256; -const elevatedTargetImageCache = new Map(); - -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 = [ - ``, - ` 0 - ? ` stroke="${outlineColor.toCssColorString()}" stroke-width="${safeOutlineWidth}"/>` - : "/>", - "", - ].join(""); - const image = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; - elevatedTargetImageCache.set(key, image); - return { image, size: imageSize }; -} type RuntimeConfig = { cesiumVersion: string; @@ -142,60 +91,9 @@ type RuntimeConfig = { gatewayHealthUrl?: string | null; }; -export type MapGatewayHealth = { - cache?: { - mode?: string; - writePolicy?: string; - entries?: number; - bytes?: number; - maxBytes?: number; - atCapacity?: boolean; - persistent?: boolean; - }; - diagnostics?: { - cacheHits?: number; - cacheMisses?: number; - cacheRefreshes?: number; - upstreamRequests?: number; - egressRequests?: number; - upstreamFailures?: number; - slowUpstreamRequests?: number; - lastFailure?: string | null; - lastFailureAt?: string | null; - }; - referenceSources?: { - transportStations?: { - profileId?: string; - seedFactCount?: number; - fetchEnabled?: boolean; - cellDegrees?: number; - cachedCellCount?: number; - upstreamRequests?: number; - upstreamFailures?: number; - searchRequests?: number; - searchFailures?: number; - upstreamState?: "idle" | "ready" | "degraded"; - activeFetches?: number; - queuedFetches?: number; - lastRefreshAt?: string | null; - lastFailure?: string | null; - lastFailureAt?: string | null; - }; - }; - ionConfigured?: boolean; -}; - -type MapProviderState = "loading" | "ready" | "error" | "not-configured"; - // Provider loading must be observable independently. In particular, imagery // is optional for scene construction: a Bing metadata failure must never // prevent Cesium World Terrain from being requested and rendered. -export type MapProviderStatus = { - imagery: MapProviderState; - terrain: MapProviderState; - buildings: MapProviderState; - errors: Partial>; -}; type IonAssetEndpoint = { assetId: string; @@ -207,180 +105,6 @@ type IonAssetEndpoint = { attributions: Array<{ html?: string; collapsible?: boolean }>; }; -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[]; - 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; -}; - -type LocalGridSectorSelection = ReturnType & { - mode: "3d"; - units: "meters-enu"; - volume: GridVolumeSelection | null; -}; - -type GraticuleGridSectorSelection = ReturnType & { - mode: "graticule"; - units: "degrees-wgs84"; - volume: null; -}; - -export type GridSectorSelection = LocalGridSectorSelection | GraticuleGridSectorSelection; -export type GridMajorTileSelection = NonNullable; - -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 CesiumMapRendererHandle = { - 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; -}; - type TerrainRouteSample = { distanceMeters: number; heightMeters: number; @@ -414,50 +138,8 @@ type SpiralSession = { previousBuildingsFoveatedTimeDelay: number | null; }; -const accent = Color.fromCssColorString("#ff2f92"); -const violet = Color.fromCssColorString("#8f72dc"); const clamp = (value: number, minimum: number, maximum: number) => Math.max(minimum, Math.min(maximum, value)); -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 getCameraView(viewer: Viewer): MapCameraView { const position = viewer.camera.positionCartographic; return { @@ -501,1590 +183,6 @@ function interpolateTerrainRouteHeight(samples: TerrainRouteSample[], distanceMe return null; } -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; -} - -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 = { - primitive: GroundPrimitive | GroundPolylinePrimitive; - parts: TPart[]; -}; - -type HGeoZoneProjectionLayer = { - geometryKey: string; - styleKey: string; - hideCameraHeightMeters: number | null; - fills: Array>; - outlines: Array>; -}; - -function hGeoZoneRingPositions(ring: Array<[number, number]>) { - return normalizeHGeoZoneRing(ring) - .map(([longitude, latitude]) => Cartesian3.fromDegrees(longitude, latitude, 0)); -} - -function hGeoZoneHierarchy(polygon: Array>) { - 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(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}`; -} - -function quarantineHGeoZoneLayers( - viewer: Viewer, - layers: Map, - faultedGeometryKeys: Set, -) { - 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; -} - -function syncHGeoZoneVisibility(viewer: Viewer, layers: Map) { - 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( - batches: Array>, - 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, - bindings: MapRuntimeBinding[], - presentationProfiles: MapPresentationProfile[], - presentationFilters: MapPresentationFilters, - faultedGeometryKeys: Set, -) { - 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(); -} - -function syncRuntimeDataSources( - viewer: Viewer, - dataSources: Map, - hGeoZoneLayers: Map, - bindings: MapRuntimeBinding[], - presentationProfiles: MapPresentationProfile[], - presentationFilters: MapPresentationFilters, - faultedHGeoZoneGeometryKeys: Set, -) { - 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(); - 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(); -} - -type GridLodBand = GridLodProfile & { index: number; id: string }; -type LocalGridAddressing = { - mode: "3d"; - lod: number; - stepMeters: number; - radiusMeters: number; - originLatitude: number; - originLongitude: number; - enu: Matrix4; - inverseEnu: Matrix4; - heightMeters: number; - tileSizeMeters: number; - majorStepMeters: number | null; - lineColor: string; - lineOpacity: number; - majorLineWidthMultiplier: number; - volumeEnabled: boolean; - volumeMinimumHeightMeters: number; - volumeMaximumHeightMeters: number; - volumeBandHeightMeters: number; - selectionFillColor: string; - selectionFillOpacityPercent: number; - selectionOutlineColor: string; - selectionOutlineWidthPx: number; - selectionOutlineOpacityPercent: number; -}; -type GraticuleGridAddressing = { - mode: "graticule"; - lod: number; - stepDegrees: number; - majorStepDegrees: number | null; - heightMeters: number; - lineWidthPixels: number; - lineColor: string; - lineOpacity: number; - majorLineWidthMultiplier: number; - selectionFillColor: string; - selectionFillOpacityPercent: number; - selectionOutlineColor: string; - selectionOutlineWidthPx: number; - selectionOutlineOpacityPercent: number; -}; -type GridAddressing = LocalGridAddressing | GraticuleGridAddressing; -type GridResources = { - dataSource: CustomDataSource; - points: PointPrimitiveCollection | null; - crosses: PolylineCollection | null; - labels: LabelCollection | null; -}; -type HiddenGridPlan = { mode: "hidden"; key: string; lodIndex: number | null }; -type LocalGridLayerPlan = { - mode: "3d"; - key: string; - lodIndex: number; - lod: GridLodBand; - origin: { latitude: number; longitude: number }; - enu: Matrix4; - inverseEnu: Matrix4; - grid: ReturnType; - majorGrid: ReturnType | null; - majorStepMeters: number | null; - metersPerPixel: number; - cameraHeightMeters: number; - cameraPosition: Cartesian3; - cameraDirection: Cartesian3; -}; -type GraticuleGridLayerPlan = { - mode: "graticule"; - key: string; - lodIndex: number; - lod: GridLodBand; - grid: ReturnType; - majorStepDegrees: number | null; -}; -type GridLayerPlan = HiddenGridPlan | LocalGridLayerPlan | GraticuleGridLayerPlan; - -function integerMultipleRatio(outer: number, inner: number) { - if (!Number.isFinite(outer) || !Number.isFinite(inner) || outer <= 0 || inner <= 0) return null; - const ratio = outer / inner; - const rounded = Math.round(ratio); - return Number.isSafeInteger(rounded) && rounded >= 1 && Math.abs(ratio - rounded) <= 1e-9 * Math.max(1, ratio) - ? rounded - : null; -} - -function localMajorStepMeters(lod: GridLodBand, stepMeters: number) { - const tileSizeMeters = lod.tileSizeKm * 1_000; - return integerMultipleRatio(tileSizeMeters, stepMeters) === null ? null : tileSizeMeters; -} - -function graticuleMajorStepDegrees(stepDegrees: number) { - const candidate = stepDegrees * 5; - return integerMultipleRatio(90, candidate) === null ? null : candidate; -} - -function gridMajorColor(cssColor: string, opacity: number) { - // Major boundaries inherit the profile colour and opacity. Their hierarchy - // is expressed only through the persisted width multiplier. - return Color.fromCssColorString(cssColor).withAlpha(clamp(opacity / 100, 0, 1)); -} - -function gridMetersPerPixel(viewer: Viewer) { - const frustum = viewer.camera.frustum as unknown as { fovy?: number }; - return Math.max(0.01, ( - Math.max(1, Number(viewer.camera.positionCartographic?.height || 1)) - * 2 * Math.tan((Number(frustum.fovy) || Math.PI / 3) / 2) - ) / Math.max(1, viewer.scene.canvas.clientHeight)); -} - -function graticuleViewport(viewer: Viewer, stepDegrees: number, cameraHeightKm: number) { - const ellipsoid = viewer.scene.globe.ellipsoid; - const margin = Math.max(stepDegrees * 2, Math.min(30, cameraHeightKm / 200)); - const alignLatitude = (value: number, direction: "down" | "up") => clamp( - (direction === "down" ? Math.floor(value / stepDegrees) : Math.ceil(value / stepDegrees)) * stepDegrees, - -89.9, - 89.9, - ); - const alignIntervals = (west: number, east: number) => splitLongitudeRange(west - margin, east + margin).map((interval) => ({ - west: interval.west <= -180 ? -180 : Math.floor(interval.west / stepDegrees) * stepDegrees, - east: interval.east >= 180 ? 180 : Math.ceil(interval.east / stepDegrees) * stepDegrees, - })); - const rectangle = viewer.camera.computeViewRectangle(ellipsoid); - if (rectangle) { - const south = CesiumMath.toDegrees(rectangle.south); - const north = CesiumMath.toDegrees(rectangle.north); - const west = CesiumMath.toDegrees(rectangle.west); - const east = CesiumMath.toDegrees(rectangle.east); - return { - south: alignLatitude(south - margin, "down"), - north: alignLatitude(north + margin, "up"), - longitudeIntervals: alignIntervals(west, east), - }; - } - const camera = viewer.camera.positionCartographic; - const latitude = camera ? CesiumMath.toDegrees(camera.latitude) : 0; - const longitude = camera ? CesiumMath.toDegrees(camera.longitude) : 0; - const horizon = CesiumMath.toDegrees(Math.acos(clamp( - ellipsoid.maximumRadius / (ellipsoid.maximumRadius + Math.max(0, cameraHeightKm * 1_000)), - -1, - 1, - ))); - const range = Math.min(180, Math.max(10, horizon * 2)); - return { - south: alignLatitude(latitude - range - margin, "down"), - north: alignLatitude(latitude + range + margin, "up"), - longitudeIntervals: alignIntervals(longitude - range - margin, longitude + range + margin), - }; -} - -function planGridLayer(viewer: Viewer, presentation: MapPresentation, previousLodIndex: number | null): GridLayerPlan { - const cameraHeightKm = Math.max(0, Number(viewer.camera.positionCartographic?.height || 0) / 1_000); - if (!gridShouldBeVisible(presentation, cameraHeightKm)) return { mode: "hidden", key: "hidden", lodIndex: null }; - const lod = selectGridLod(presentation, cameraHeightKm, previousLodIndex) as GridLodBand; - const mode = resolveGridMode(presentation, lod.mode); - if (mode === "hidden") return { mode: "hidden", key: `hidden:${lod.index}`, lodIndex: lod.index }; - - if (mode === "3d") { - const origin = fixedGridOrigin(presentation); - const anchor = Cartesian3.fromDegrees(origin.longitude, origin.latitude, 0); - const enu = Transforms.eastNorthUpToFixedFrame(anchor); - const inverseEnu = Matrix4.inverseTransformation(enu, new Matrix4()); - const stepMeters = clamp(lod.stepKm * 1_000, 100, 5_000_000); - const radiusMeters = clamp(lod.radiusKm * 1_000, stepMeters, 100_000_000); - const metersPerPixel = gridMetersPerPixel(viewer); - const cameraPosition = Cartesian3.clone(viewer.camera.positionWC, new Cartesian3()); - const cameraDirection = Cartesian3.normalize(viewer.camera.directionWC, new Cartesian3()); - const cameraLocal = Matrix4.multiplyByPoint(inverseEnu, cameraPosition, new Cartesian3()); - const cameraPositionBucketMeters = Math.max(stepMeters, lod.tileSizeKm * 500); - const cameraPositionKey = [cameraLocal.x, cameraLocal.y] - .map((value) => Math.round(value / cameraPositionBucketMeters)); - const directionKey = [cameraDirection.x, cameraDirection.y, cameraDirection.z] - .map((value) => Math.round(value * 50) / 50); - const cameraHeightMeters = cameraHeightKm * 1_000; - const grid = localGridPlan({ stepMeters, radiusMeters, maximumMarkers: 5_000 }); - const majorStepMeters = localMajorStepMeters(lod, grid.stepMeters); - const majorGrid = majorStepMeters !== null && (lod.majorLinesEnabled || lod.majorLabelsEnabled) - ? localGridPlan({ stepMeters: majorStepMeters, radiusMeters: grid.radiusMeters, maximumMarkers: 1 }) - : null; - const key = JSON.stringify([ - "local-enu", lod.index, origin.latitude, origin.longitude, stepMeters, radiusMeters, - lod.heightMeters, lod.max3dViewAngleDegrees, lod.tileSizeKm, - lod.lineDiameterMeters, lod.lineColor, lod.lineOpacity, - lod.dotsEnabled, lod.dotsDiameterMeters, lod.dotsColor, lod.dotsOpacity, - lod.crossesEnabled, lod.crossesLengthMeters, lod.crossesWidthMeters, lod.crossesColor, lod.crossesOpacity, - lod.majorLinesEnabled, lod.majorLabelsEnabled, lod.majorLineWidthMultiplier, - lod.volumeEnabled, lod.volumeMinimumHeightMeters, lod.volumeMaximumHeightMeters, lod.volumeBandHeightMeters, - majorStepMeters, - grid.markerStride, cameraPositionKey, directionKey, Math.round(cameraHeightKm * 10) / 10, - Math.round(metersPerPixel * 10) / 10, - ]); - return { - mode, key, lodIndex: lod.index, lod, origin, enu, inverseEnu, grid, majorGrid, majorStepMeters, - metersPerPixel, cameraHeightMeters, cameraPosition, cameraDirection, - }; - } - - const viewport = graticuleViewport(viewer, lod.graticuleStepDegrees, cameraHeightKm); - const grid = graticuleLinePlan({ ...viewport, stepDegrees: lod.graticuleStepDegrees }); - const majorStepDegrees = graticuleMajorStepDegrees(grid.stepDegrees); - const key = JSON.stringify([ - "wgs84-graticule", lod.index, lod.heightMeters, lod.graticuleStepDegrees, - lod.graticuleLineWidthPx, lod.graticuleColor, lod.graticuleOpacity, - lod.majorLinesEnabled, lod.majorLabelsEnabled, lod.majorLineWidthMultiplier, majorStepDegrees, - grid.south, grid.north, grid.longitudeIntervals, - ]); - return { mode, key, lodIndex: lod.index, lod, grid, majorStepDegrees }; -} - -function localShellPosition( - enu: Matrix4, - eastMeters: number, - northMeters: number, - heightMeters: number, -) { - const tangentPoint = Matrix4.multiplyByPoint(enu, new Cartesian3(eastMeters, northMeters, 0), new Cartesian3()); - const cartographic = Cartographic.fromCartesian(tangentPoint); - return Cartesian3.fromRadians(cartographic.longitude, cartographic.latitude, heightMeters); -} - -function materializeLocalGrid(plan: LocalGridLayerPlan, serial: number): GridResources { - const dataSource = new CustomDataSource(`nodedc-map-grid-local:${serial}`); - const points = plan.lod.dotsEnabled ? new PointPrimitiveCollection() : null; - const crosses = plan.lod.crossesEnabled ? new PolylineCollection() : null; - const labels = plan.lod.majorLabelsEnabled && plan.majorStepMeters !== null ? new LabelCollection() : null; - const lineColor = Color.fromCssColorString(plan.lod.lineColor).withAlpha(clamp(plan.lod.lineOpacity / 100, 0, 1)); - const majorColor = gridMajorColor(plan.lod.lineColor, plan.lod.lineOpacity); - const dotColor = Color.fromCssColorString(plan.lod.dotsColor).withAlpha(clamp(plan.lod.dotsOpacity / 100, 0, 1)); - const crossColor = Color.fromCssColorString(plan.lod.crossesColor).withAlpha(clamp(plan.lod.crossesOpacity / 100, 0, 1)); - const lineWidthPixels = clamp(plan.lod.lineDiameterMeters / plan.metersPerPixel, 1, 8); - const majorLineWidthPixels = clamp(lineWidthPixels * plan.lod.majorLineWidthMultiplier, lineWidthPixels, 16); - const dotPixelSize = clamp(plan.lod.dotsDiameterMeters / plan.metersPerPixel, 1, 128); - const crossWidthPixels = clamp(plan.lod.crossesWidthMeters / plan.metersPerPixel, 1, 8); - const definition = { - lod: plan.lod.index + 1, - originLatitude: plan.origin.latitude, - originLongitude: plan.origin.longitude, - stepMeters: plan.grid.stepMeters, - }; - const hierarchyDefinition = plan.majorStepMeters === null ? null : { - ...definition, - tileSizeMeters: plan.majorStepMeters, - }; - const toWorld = (eastMeters: number, northMeters: number, heightMeters = Math.max(0, plan.lod.heightMeters)) => { - // Metric addressing stays in the immutable ENU frame. Reprojection only - // bends the visual shell to WGS84 at an absolute height; terrain and 3D - // Tiles never move the sector boundaries. - return localShellPosition(plan.enu, eastMeters, northMeters, Math.max(0, heightMeters)); - }; - const useViewCone = Number.isFinite(plan.lod.max3dViewAngleDegrees) && plan.lod.max3dViewAngleDegrees < 170; - const viewConeMinimumDot = Math.cos(CesiumMath.toRadians(plan.lod.max3dViewAngleDegrees)); - const viewConeMinimumDistance = Math.max( - plan.grid.stepMeters * 2, - Math.min(plan.grid.radiusMeters, plan.cameraHeightMeters * 6), - ); - const cameraLocal = Matrix4.multiplyByPoint(plan.inverseEnu, plan.cameraPosition, new Cartesian3()); - const cameraDirectionLocal = Matrix4.multiplyByPointAsVector(plan.inverseEnu, plan.cameraDirection, new Cartesian3()); - Cartesian3.normalize(cameraDirectionLocal, cameraDirectionLocal); - const originLatitudeRadians = CesiumMath.toRadians(plan.origin.latitude); - const wgs84SemiMajor = 6_378_137; - const wgs84EccentricitySquared = 0.00669437999014; - const latitudeFactor = 1 - wgs84EccentricitySquared * Math.sin(originLatitudeRadians) ** 2; - const eastCurvatureRadius = wgs84SemiMajor / Math.sqrt(latitudeFactor); - const northCurvatureRadius = wgs84SemiMajor * (1 - wgs84EccentricitySquared) / latitudeFactor ** 1.5; - const approximateShellPoint = (eastMeters: number, northMeters: number) => { - const horizontalSquared = eastMeters ** 2 + northMeters ** 2; - if (horizontalSquared <= 1e-9) return new Cartesian3(0, 0, plan.lod.heightMeters); - const curvatureRadius = horizontalSquared / ( - eastMeters ** 2 / eastCurvatureRadius + northMeters ** 2 / northCurvatureRadius - ); - // Invert the donor's tangent-point -> geodetic-direction reprojection. - // This keeps cone culling close to the WGS84 shell without paying for two - // Cartographic conversions on every coarse candidate. - const scale = curvatureRadius / Math.sqrt(curvatureRadius ** 2 + horizontalSquared); - return new Cartesian3( - eastMeters * scale, - northMeters * scale, - curvatureRadius * scale - curvatureRadius + plan.lod.heightMeters, - ); - }; - const inViewCone = (eastMeters: number, northMeters: number) => { - if (!useViewCone) return true; - const shellPoint = approximateShellPoint(eastMeters, northMeters); - const direction = Cartesian3.subtract(shellPoint, cameraLocal, new Cartesian3()); - const distance = Cartesian3.magnitude(direction); - if (!Number.isFinite(distance) || distance <= viewConeMinimumDistance) return true; - Cartesian3.normalize(direction, direction); - const approximateDot = Cartesian3.dot(direction, cameraDirectionLocal); - if (Math.abs(approximateDot - viewConeMinimumDot) > 0.15) return approximateDot >= viewConeMinimumDot; - // ENU-plane culling is cheap, but the rendered lattice is bent back to - // the WGS84 shell. Resolve candidates near the cone boundary in world - // space so curvature cannot hide the sector beneath an oblique camera. - const worldDirection = Cartesian3.subtract(toWorld(eastMeters, northMeters), plan.cameraPosition, new Cartesian3()); - Cartesian3.normalize(worldDirection, worldDirection); - return Cartesian3.dot(worldDirection, plan.cameraDirection) >= viewConeMinimumDot; - }; - const segmentStepMeters = Math.max( - plan.grid.stepMeters, - plan.lod.tileSizeKm * 1_000, - plan.grid.radiusMeters / 80, - ); - const addSegmentedLine = ( - axis: "east" | "north", - line: (typeof plan.grid.lines)[number], - hierarchy: "minor" | "major", - ) => { - let positions: Cartesian3[] = []; - let segmentStart = 0; - const flush = () => { - if (positions.length >= 2) { - dataSource.entities.add({ - id: `grid/local/l${plan.lod.index + 1}/${hierarchy}/line-${axis}-${line.index}/part-${segmentStart}`, - polyline: { - positions, - width: hierarchy === "major" ? majorLineWidthPixels : lineWidthPixels, - material: hierarchy === "major" ? majorColor : lineColor, - clampToGround: false, - arcType: ArcType.NONE, - }, - }); - } - positions = []; - }; - let part = 0; - for (let variable = -line.extentMeters; variable < line.extentMeters; variable += segmentStepMeters) { - const next = Math.min(variable + segmentStepMeters, line.extentMeters); - const middle = (variable + next) / 2; - const middleEast = axis === "east" ? line.offsetMeters : middle; - const middleNorth = axis === "east" ? middle : line.offsetMeters; - if (!inViewCone(middleEast, middleNorth)) { - flush(); - part += 1; - segmentStart = part; - continue; - } - const start = axis === "east" ? toWorld(line.offsetMeters, variable) : toWorld(variable, line.offsetMeters); - const end = axis === "east" ? toWorld(line.offsetMeters, next) : toWorld(next, line.offsetMeters); - if (positions.length === 0) positions.push(start); - positions.push(end); - part += 1; - } - flush(); - }; - const skipMinorMajorBoundary = (line: (typeof plan.grid.lines)[number]) => Boolean( - plan.lod.majorLinesEnabled - && hierarchyDefinition - && isLocalMajorLineIndex(line.index, hierarchyDefinition), - ); - for (const line of plan.grid.lines) if (!skipMinorMajorBoundary(line)) addSegmentedLine("east", line, "minor"); - for (const line of plan.grid.lines) if (!skipMinorMajorBoundary(line)) addSegmentedLine("north", line, "minor"); - if (plan.lod.majorLinesEnabled && plan.majorGrid) { - for (const line of plan.majorGrid.lines) addSegmentedLine("east", line, "major"); - for (const line of plan.majorGrid.lines) addSegmentedLine("north", line, "major"); - } - - const radiusSquared = plan.grid.radiusMeters ** 2; - const crossHalf = plan.lod.crossesLengthMeters / 2; - for (let eastIndex = -plan.grid.maximumIndex; eastIndex <= plan.grid.maximumIndex; eastIndex += plan.grid.markerStride) { - const eastMeters = eastIndex * plan.grid.stepMeters; - for (let northIndex = -plan.grid.maximumIndex; northIndex <= plan.grid.maximumIndex; northIndex += plan.grid.markerStride) { - const northMeters = northIndex * plan.grid.stepMeters; - if (eastMeters ** 2 + northMeters ** 2 > radiusSquared) continue; - if (!inViewCone(eastMeters, northMeters)) continue; - const position = toWorld(eastMeters, northMeters); - const address = localSectorAt({ eastMeters, northMeters }, definition); - if (points) points.add({ - id: { kind: "nodedc-grid-intersection", sectorId: address.id }, - position, - color: dotColor, - pixelSize: Math.max(1, Math.round(dotPixelSize)), - }); - if (crosses && crossHalf > 0) { - const eastWest = crosses.add({ - id: { kind: "nodedc-grid-cross", sectorId: address.id }, - positions: [toWorld(eastMeters - crossHalf, northMeters), toWorld(eastMeters + crossHalf, northMeters)], - width: Math.max(1, Math.round(crossWidthPixels)), - }); - const northSouth = crosses.add({ - id: { kind: "nodedc-grid-cross", sectorId: address.id }, - positions: [toWorld(eastMeters, northMeters - crossHalf), toWorld(eastMeters, northMeters + crossHalf)], - width: Math.max(1, Math.round(crossWidthPixels)), - }); - if (eastWest.material?.uniforms) eastWest.material.uniforms.color = crossColor; - if (northSouth.material?.uniforms) northSouth.material.uniforms.color = crossColor; - } - } - } - - if (labels && hierarchyDefinition) { - // Tile labels are primitives, not Entities, and are hard-capped. The - // globally phased stride keeps them stable while camera-driven cone - // culling changes the visible subset. - const maximumLabels = 48; - const tileSizeMeters = hierarchyDefinition.tileSizeMeters; - const maximumTileIndex = Math.ceil(plan.grid.radiusMeters / tileSizeMeters); - const approximateTileCount = Math.PI * (plan.grid.radiusMeters / tileSizeMeters) ** 2; - const labelStride = Math.max(1, Math.ceil(Math.sqrt(approximateTileCount / maximumLabels))); - const firstIndex = -Math.ceil(maximumTileIndex / labelStride) * labelStride; - let labelCount = 0; - for (let eastIndex = firstIndex; eastIndex <= maximumTileIndex && labelCount < maximumLabels; eastIndex += labelStride) { - const eastMeters = (eastIndex + 0.5) * tileSizeMeters; - for (let northIndex = firstIndex; northIndex <= maximumTileIndex && labelCount < maximumLabels; northIndex += labelStride) { - const northMeters = (northIndex + 0.5) * tileSizeMeters; - if (eastMeters ** 2 + northMeters ** 2 > radiusSquared || !inViewCone(eastMeters, northMeters)) continue; - const tile = localMajorTileAt({ eastMeters, northMeters }, hierarchyDefinition); - labels.add({ - id: { kind: "nodedc-grid-major-label", sectorId: tile.id }, - position: toWorld( - eastMeters, - northMeters, - plan.lod.heightMeters + Math.max(20, plan.metersPerPixel * 5), - ), - text: `L${tile.lod} · E${tile.eastIndex >= 0 ? "+" : ""}${tile.eastIndex} N${tile.northIndex >= 0 ? "+" : ""}${tile.northIndex}`, - font: "600 12px Arial", - fillColor: Color.WHITE.withAlpha(0.9), - outlineColor: Color.BLACK.withAlpha(0.72), - outlineWidth: 2, - style: LabelStyle.FILL_AND_OUTLINE, - showBackground: true, - backgroundColor: Color.BLACK.withAlpha(0.52), - backgroundPadding: new Cartesian2(7, 4), - horizontalOrigin: HorizontalOrigin.CENTER, - verticalOrigin: VerticalOrigin.CENTER, - }); - labelCount += 1; - } - } - } - return { dataSource, points, crosses, labels }; -} - -function materializeGraticule(plan: GraticuleGridLayerPlan, serial: number): GridResources { - const dataSource = new CustomDataSource(`nodedc-map-grid-graticule:${serial}`); - const color = Color.fromCssColorString(plan.lod.graticuleColor).withAlpha(clamp(plan.lod.graticuleOpacity / 100, 0, 1)); - const majorColor = gridMajorColor(plan.lod.graticuleColor, plan.lod.graticuleOpacity); - const labels = plan.lod.majorLabelsEnabled && plan.majorStepDegrees !== null ? new LabelCollection() : null; - const clampToGround = plan.lod.heightMeters <= 0; - const height = clampToGround ? 0 : plan.lod.heightMeters; - const sampleStep = Math.min(5, Math.max(0.5, plan.grid.stepDegrees)); - const hierarchyDefinition = plan.majorStepDegrees === null ? null : { - lod: plan.lod.index + 1, - stepDegrees: plan.grid.stepDegrees, - majorStepDegrees: plan.majorStepDegrees, - }; - const line = (id: string, positions: Cartesian3[], major: boolean) => positions.length >= 2 && dataSource.entities.add({ - id, - polyline: { - positions, - width: major - ? clamp(plan.lod.graticuleLineWidthPx * plan.lod.majorLineWidthMultiplier, plan.lod.graticuleLineWidthPx, 16) - : plan.lod.graticuleLineWidthPx, - material: major ? majorColor : color, - clampToGround, - arcType: ArcType.RHUMB, - granularity: graticuleGranularity(sampleStep, clampToGround), - }, - }); - const positionsForParts = ( - parts: Array<{ start: number; end: number }>, - positionAt: (angle: number) => Cartesian3, - ) => parts.length === 0 - ? [] - : [positionAt(parts[0].start), ...parts.map(({ end }) => positionAt(end))]; - for (const { longitude } of plan.grid.meridians) { - const parts = boundedAngularParts(plan.grid.south, plan.grid.north); - const major = Boolean( - plan.lod.majorLinesEnabled - && hierarchyDefinition - && isGraticuleMajorLineValue(longitude, hierarchyDefinition), - ); - line( - `grid/wgs84/l${plan.lod.index + 1}/${major ? "major" : "minor"}/meridian/${longitude}`, - positionsForParts(parts, (latitude) => Cartesian3.fromDegrees(longitude, latitude, height)), - major, - ); - } - for (const latitude of plan.grid.parallels) { - const major = Boolean( - plan.lod.majorLinesEnabled - && hierarchyDefinition - && isGraticuleMajorLineValue(latitude, hierarchyDefinition), - ); - plan.grid.longitudeIntervals.forEach((interval, index) => { - // RHUMB interpolation is correct for parallels, but Cesium rejects an - // exactly antipodal equatorial pair. Intermediate vertices keep every - // segment below 90° without multiplying the number of Cesium entities. - const parts = boundedAngularParts(interval.west, interval.east); - line( - `grid/wgs84/l${plan.lod.index + 1}/${major ? "major" : "minor"}/parallel/${latitude}/interval-${index}`, - positionsForParts(parts, (longitude) => Cartesian3.fromDegrees(longitude, latitude, height)), - major, - ); - }); - } - - if (labels && hierarchyDefinition) { - const maximumLabels = 48; - const majorStep = hierarchyDefinition.majorStepDegrees; - const minimumLatitudeIndex = Math.max(-Math.round(90 / majorStep), Math.floor(plan.grid.south / majorStep)); - const maximumLatitudeIndex = Math.min(Math.round(90 / majorStep) - 1, Math.floor((plan.grid.north - 1e-9) / majorStep)); - const longitudeRanges = plan.grid.longitudeIntervals.map((interval) => ({ - minimum: Math.max(-Math.round(180 / majorStep), Math.floor(interval.west / majorStep)), - maximum: Math.min(Math.round(180 / majorStep) - 1, Math.floor((interval.east - 1e-9) / majorStep)), - })); - const latitudeCount = Math.max(0, maximumLatitudeIndex - minimumLatitudeIndex + 1); - const longitudeCount = longitudeRanges.reduce((sum, range) => sum + Math.max(0, range.maximum - range.minimum + 1), 0); - const labelStride = Math.max(1, Math.ceil(Math.sqrt((latitudeCount * longitudeCount) / maximumLabels))); - const firstLatitudeIndex = Math.ceil(minimumLatitudeIndex / labelStride) * labelStride; - const seen = new Set(); - let labelCount = 0; - for (let latitudeIndex = firstLatitudeIndex; - latitudeIndex <= maximumLatitudeIndex && labelCount < maximumLabels; - latitudeIndex += labelStride) { - const latitude = (latitudeIndex + 0.5) * majorStep; - for (const range of longitudeRanges) { - const firstLongitudeIndex = Math.ceil(range.minimum / labelStride) * labelStride; - for (let longitudeIndex = firstLongitudeIndex; - longitudeIndex <= range.maximum && labelCount < maximumLabels; - longitudeIndex += labelStride) { - const longitude = (longitudeIndex + 0.5) * majorStep; - const tile = graticuleMajorTileAt({ longitude, latitude }, hierarchyDefinition); - if (seen.has(tile.id)) continue; - seen.add(tile.id); - labels.add({ - id: { kind: "nodedc-grid-major-label", sectorId: tile.id }, - position: Cartesian3.fromDegrees(longitude, latitude, height + 100), - text: `L${tile.lod} · X${tile.longitudeIndex >= 0 ? "+" : ""}${tile.longitudeIndex} Y${tile.latitudeIndex >= 0 ? "+" : ""}${tile.latitudeIndex}`, - font: "600 12px Arial", - fillColor: Color.WHITE.withAlpha(0.88), - outlineColor: Color.BLACK.withAlpha(0.72), - outlineWidth: 2, - style: LabelStyle.FILL_AND_OUTLINE, - showBackground: true, - backgroundColor: Color.BLACK.withAlpha(0.48), - backgroundPadding: new Cartesian2(7, 4), - horizontalOrigin: HorizontalOrigin.CENTER, - verticalOrigin: VerticalOrigin.CENTER, - }); - labelCount += 1; - } - } - } - } - return { dataSource, points: null, crosses: null, labels }; -} - -function materializeGridLayer(plan: Exclude, serial: number) { - return plan.mode === "3d" ? materializeLocalGrid(plan, serial) : materializeGraticule(plan, serial); -} - -const DEFAULT_GRID_SELECTION_COLOR = "#35cfff"; - -type GridSelectionStyle = { - fillColor: Color; - outlineColor: Color; - outlineWidthPixels: number; -}; - -function safeGridSelectionColor(value: unknown) { - if (typeof value === "string" && value.trim()) { - try { - const parsed = Color.fromCssColorString(value.trim()); - if (parsed) return parsed; - } catch { - // Persisted profiles may predate strict colour validation. Rendering a - // stable selection is preferable to failing the whole Cesium layer. - } - } - return Color.fromCssColorString(DEFAULT_GRID_SELECTION_COLOR); -} - -function safeGridSelectionPercent(value: unknown, fallback: number) { - const candidate = typeof value === "number" ? value : Number.NaN; - return clamp(Number.isFinite(candidate) ? candidate : fallback, 0, 100) / 100; -} - -function resolveGridSelectionStyle(addressing: GridAddressing): GridSelectionStyle { - const fallbackOutlineWidth = addressing.mode === "3d" - ? clamp(2.5 * addressing.majorLineWidthMultiplier, 3, 12) - : clamp(addressing.lineWidthPixels * addressing.majorLineWidthMultiplier * 1.5, 3, 12); - const requestedOutlineWidth = typeof addressing.selectionOutlineWidthPx === "number" - ? addressing.selectionOutlineWidthPx - : Number.NaN; - const fallbackFillOpacityPercent = addressing.mode === "graticule" - ? 12 - : addressing.volumeEnabled ? 10 : 16; - return { - fillColor: safeGridSelectionColor(addressing.selectionFillColor).withAlpha( - safeGridSelectionPercent(addressing.selectionFillOpacityPercent, fallbackFillOpacityPercent), - ), - outlineColor: safeGridSelectionColor(addressing.selectionOutlineColor).withAlpha( - safeGridSelectionPercent(addressing.selectionOutlineOpacityPercent, 95), - ), - outlineWidthPixels: clamp( - Number.isFinite(requestedOutlineWidth) ? requestedOutlineWidth : fallbackOutlineWidth, - 1, - 12, - ), - }; -} - -function sampledRange(start: number, end: number, maximumStep: number) { - const span = end - start; - const count = Math.max(1, Math.min(64, Math.ceil(Math.abs(span) / Math.max(1e-6, maximumStep)))); - return Array.from({ length: count + 1 }, (_value, index) => start + span * index / count); -} - -function localSelectionPerimeter( - addressing: LocalGridAddressing, - bounds: GridSectorSelection["bounds"], - heightMeters: number, -) { - const maximumStep = Math.max(1_000, Math.min(25_000, addressing.stepMeters / 4)); - const south = sampledRange(bounds.west, bounds.east, maximumStep) - .map((east) => localShellPosition(addressing.enu, east, bounds.south, heightMeters)); - const east = sampledRange(bounds.south, bounds.north, maximumStep).slice(1) - .map((north) => localShellPosition(addressing.enu, bounds.east, north, heightMeters)); - const north = sampledRange(bounds.east, bounds.west, maximumStep).slice(1) - .map((eastMeters) => localShellPosition(addressing.enu, eastMeters, bounds.north, heightMeters)); - const west = sampledRange(bounds.north, bounds.south, maximumStep).slice(1) - .map((northMeters) => localShellPosition(addressing.enu, bounds.west, northMeters, heightMeters)); - return [...south, ...east, ...north, ...west]; -} - -function materializeLocalSelection( - selection: LocalGridSectorSelection, - addressing: LocalGridAddressing, - serial: number, -): GridResources | null { - const width = selection.bounds.east - selection.bounds.west; - const depth = selection.bounds.north - selection.bounds.south; - if (Math.abs(width - addressing.stepMeters) > 1e-6 * addressing.stepMeters - || Math.abs(depth - addressing.stepMeters) > 1e-6 * addressing.stepMeters) return null; - - const dataSource = new CustomDataSource(`nodedc-map-grid-selection-local:${serial}`); - const crosses = new PolylineCollection(); - const selectionStyle = resolveGridSelectionStyle(addressing); - const volumeAvailable = addressing.volumeEnabled - && Number.isFinite(addressing.volumeBandHeightMeters) - && addressing.volumeBandHeightMeters > 0 - && addressing.volumeMaximumHeightMeters > addressing.volumeMinimumHeightMeters; - const floor = volumeAvailable - ? clamp(selection.volume?.floor ?? addressing.volumeMinimumHeightMeters, - addressing.volumeMinimumHeightMeters, addressing.volumeMaximumHeightMeters) - : addressing.heightMeters; - const ceiling = volumeAvailable - ? clamp(selection.volume?.ceiling ?? Math.min( - addressing.volumeMaximumHeightMeters, - addressing.volumeMinimumHeightMeters + addressing.volumeBandHeightMeters, - ), floor, addressing.volumeMaximumHeightMeters) - : addressing.heightMeters; - const floorPerimeter = localSelectionPerimeter(addressing, selection.bounds, floor); - const ceilingPerimeter = localSelectionPerimeter(addressing, selection.bounds, ceiling); - const addLine = (id: string, positions: Cartesian3[]) => { - const polyline = crosses.add({ - id: { kind: "nodedc-grid-selection", sectorId: selection.id, edgeId: id }, - positions, - width: selectionStyle.outlineWidthPixels, - }); - if (polyline.material?.uniforms) polyline.material.uniforms.color = selectionStyle.outlineColor; - }; - addLine("floor", floorPerimeter); - if (ceiling > floor + 1e-6) addLine("ceiling", ceilingPerimeter); - - const corners = [ - [selection.bounds.west, selection.bounds.south], - [selection.bounds.east, selection.bounds.south], - [selection.bounds.east, selection.bounds.north], - [selection.bounds.west, selection.bounds.north], - ] as const; - if (ceiling > floor + 1e-6) { - corners.forEach(([eastMeters, northMeters], index) => addLine(`post-${index}`, [ - localShellPosition(addressing.enu, eastMeters, northMeters, floor), - localShellPosition(addressing.enu, eastMeters, northMeters, ceiling), - ])); - } - - const ceilingSurface = ceilingPerimeter.slice(0, -1); - if (ceilingSurface.length >= 3) dataSource.entities.add({ - id: `${selection.id}/selection-cap`, - polygon: { - hierarchy: new PolygonHierarchy(ceilingSurface), - perPositionHeight: true, - material: selectionStyle.fillColor, - outline: false, - }, - }); - return { dataSource, points: null, crosses, labels: null }; -} - -function materializeGraticuleSelection( - selection: GraticuleGridSectorSelection, - addressing: GraticuleGridAddressing, - serial: number, -): GridResources { - const dataSource = new CustomDataSource(`nodedc-map-grid-selection-graticule:${serial}`); - const selectionStyle = resolveGridSelectionStyle(addressing); - const height = Math.max(0, addressing.heightMeters); - const horizontalStep = Math.min(10, Math.max(0.5, addressing.stepDegrees)); - const verticalStep = Math.min(10, Math.max(0.5, addressing.stepDegrees)); - // A WGS84 cell may logically end at a pole, where every longitude becomes - // the same Cartesian point. Keep the visual cage just inside the singularity - // so Cesium never receives a degenerate polygon while addressing stays exact. - const renderSouth = clamp(selection.bounds.south, -89.9, 89.9); - const renderNorth = clamp(selection.bounds.north, -89.9, 89.9); - const south = sampledRange(selection.bounds.west, selection.bounds.east, horizontalStep) - .map((longitude) => Cartesian3.fromDegrees(longitude, renderSouth, height)); - const east = sampledRange(renderSouth, renderNorth, verticalStep).slice(1) - .map((latitude) => Cartesian3.fromDegrees(selection.bounds.east, latitude, height)); - const north = sampledRange(selection.bounds.east, selection.bounds.west, horizontalStep).slice(1) - .map((longitude) => Cartesian3.fromDegrees(longitude, renderNorth, height)); - const west = sampledRange(renderNorth, renderSouth, verticalStep).slice(1) - .map((latitude) => Cartesian3.fromDegrees(selection.bounds.west, latitude, height)); - const perimeter = [...south, ...east, ...north, ...west]; - const clampToGround = addressing.heightMeters <= 0; - dataSource.entities.add({ - id: `${selection.id}/selection-surface`, - polyline: { - positions: perimeter, - width: selectionStyle.outlineWidthPixels, - material: selectionStyle.outlineColor, - clampToGround, - arcType: ArcType.RHUMB, - granularity: graticuleGranularity(horizontalStep, clampToGround), - }, - polygon: { - hierarchy: new PolygonHierarchy(perimeter.slice(0, -1)), - height: clampToGround ? undefined : height, - heightReference: clampToGround ? HeightReference.CLAMP_TO_GROUND : HeightReference.NONE, - arcType: ArcType.RHUMB, - material: selectionStyle.fillColor, - outline: false, - }, - }); - return { dataSource, points: null, crosses: null, labels: null }; -} - -function materializeGridSelection( - selection: GridSectorSelection, - addressing: GridAddressing, - serial: number, -) { - if (selection.lod !== addressing.lod || selection.mode !== addressing.mode) return null; - return selection.mode === "3d" && addressing.mode === "3d" - ? materializeLocalSelection(selection, addressing, serial) - : selection.mode === "graticule" && addressing.mode === "graticule" - ? materializeGraticuleSelection(selection, addressing, serial) - : null; -} - -class GridLayerController { - private current: { resources: GridResources; addressing: GridAddressing } | null = null; - private pending: { resources: GridResources; addressing: GridAddressing } | null = null; - private key: string | null = null; - private lodIndex: number | null = null; - private epoch = 0; - private serial = 0; - private removeReadyListener: (() => void) | null = null; - private fallbackTimer: number | null = null; - private retiredResources = new WeakSet(); - private selection: GridSectorSelection | null = null; - private selectionResources: GridResources | null = null; - private selectionKey: string | null = null; - private navigationSelectionId: string | null = null; - - constructor( - private readonly viewer: Viewer, - private readonly onAddressingChange: () => void, - ) {} - - private addressingKey(addressing: GridAddressing | undefined) { - if (!addressing) return "hidden"; - return addressing.mode === "3d" - ? JSON.stringify([ - addressing.mode, - addressing.lod, - addressing.originLatitude, - addressing.originLongitude, - addressing.stepMeters, - addressing.majorStepMeters, - addressing.volumeEnabled, - addressing.volumeMinimumHeightMeters, - addressing.volumeMaximumHeightMeters, - addressing.volumeBandHeightMeters, - ]) - : JSON.stringify([addressing.mode, addressing.lod, addressing.stepDegrees, addressing.majorStepDegrees]); - } - - private syncSelectionStyle(plan: Exclude) { - let changed = false; - const targetLod = plan.lod.index + 1; - for (const layer of [this.current, this.pending]) { - const addressing = layer?.addressing; - if (!addressing || addressing.mode !== plan.mode || addressing.lod !== targetLod) continue; - if ( - addressing.selectionFillColor === plan.lod.selectionFillColor - && addressing.selectionFillOpacityPercent === plan.lod.selectionFillOpacityPercent - && addressing.selectionOutlineColor === plan.lod.selectionOutlineColor - && addressing.selectionOutlineWidthPx === plan.lod.selectionOutlineWidthPx - && addressing.selectionOutlineOpacityPercent === plan.lod.selectionOutlineOpacityPercent - ) continue; - addressing.selectionFillColor = plan.lod.selectionFillColor; - addressing.selectionFillOpacityPercent = plan.lod.selectionFillOpacityPercent; - addressing.selectionOutlineColor = plan.lod.selectionOutlineColor; - addressing.selectionOutlineWidthPx = plan.lod.selectionOutlineWidthPx; - addressing.selectionOutlineOpacityPercent = plan.lod.selectionOutlineOpacityPercent; - changed = true; - } - if (changed) this.refreshSelection(this.pending?.addressing ?? this.current?.addressing); - } - - rebuild(presentation: MapPresentation) { - // Planning is intentionally cheap. Geometry is materialized only after - // key comparison; repeated camera/settings notifications cannot create - // and discard thousands of Cesium objects for an unchanged layer. - const plan = planGridLayer(this.viewer, presentation, this.lodIndex); - if (plan.key === this.key) { - if (plan.mode !== "hidden") this.syncSelectionStyle(plan); - return; - } - this.key = plan.key; - this.lodIndex = plan.lodIndex; - const epoch = ++this.epoch; - this.cancelPending(); - - if (plan.mode === "hidden") { - this.setSelection(null); - this.onAddressingChange(); - if (this.current) this.removeResources(this.current.resources); - this.current = null; - this.viewer.scene.requestRender(); - return; - } - - const addressing: GridAddressing = plan.mode === "3d" ? { - mode: "3d", - lod: plan.lod.index + 1, - stepMeters: plan.grid.stepMeters, - radiusMeters: plan.grid.radiusMeters, - originLatitude: plan.origin.latitude, - originLongitude: plan.origin.longitude, - enu: plan.enu, - inverseEnu: plan.inverseEnu, - heightMeters: plan.lod.heightMeters, - tileSizeMeters: plan.lod.tileSizeKm * 1_000, - majorStepMeters: plan.majorStepMeters, - lineColor: plan.lod.lineColor, - lineOpacity: plan.lod.lineOpacity, - majorLineWidthMultiplier: plan.lod.majorLineWidthMultiplier, - volumeEnabled: plan.lod.volumeEnabled, - volumeMinimumHeightMeters: plan.lod.volumeMinimumHeightMeters, - volumeMaximumHeightMeters: plan.lod.volumeMaximumHeightMeters, - volumeBandHeightMeters: plan.lod.volumeBandHeightMeters, - selectionFillColor: plan.lod.selectionFillColor, - selectionFillOpacityPercent: plan.lod.selectionFillOpacityPercent, - selectionOutlineColor: plan.lod.selectionOutlineColor, - selectionOutlineWidthPx: plan.lod.selectionOutlineWidthPx, - selectionOutlineOpacityPercent: plan.lod.selectionOutlineOpacityPercent, - } : { - mode: "graticule", - lod: plan.lod.index + 1, - stepDegrees: plan.grid.stepDegrees, - majorStepDegrees: plan.majorStepDegrees, - heightMeters: plan.lod.heightMeters, - lineWidthPixels: plan.lod.graticuleLineWidthPx, - lineColor: plan.lod.graticuleColor, - lineOpacity: plan.lod.graticuleOpacity, - majorLineWidthMultiplier: plan.lod.majorLineWidthMultiplier, - selectionFillColor: plan.lod.selectionFillColor, - selectionFillOpacityPercent: plan.lod.selectionFillOpacityPercent, - selectionOutlineColor: plan.lod.selectionOutlineColor, - selectionOutlineWidthPx: plan.lod.selectionOutlineWidthPx, - selectionOutlineOpacityPercent: plan.lod.selectionOutlineOpacityPercent, - }; - const activeAddressing = this.pending?.addressing ?? this.current?.addressing; - if (activeAddressing && this.addressingKey(activeAddressing) !== this.addressingKey(addressing)) { - const selectionTargetsNextLod = Boolean( - this.selection - && this.selection.lod === addressing.lod - && this.selection.mode === addressing.mode - && this.selection.lod !== activeAddressing.lod, - ); - const selectionIsNavigating = Boolean( - this.selection - && this.navigationSelectionId === this.selection.id, - ); - if (selectionTargetsNextLod) this.navigationSelectionId = null; - if (!selectionTargetsNextLod && !selectionIsNavigating) { - this.setSelection(null); - this.onAddressingChange(); - } - } - const resources = materializeGridLayer(plan, ++this.serial); - const next = { resources, addressing }; - this.pending = next; - this.mountResources(resources); - let readyFrames = 0; - const commit = () => { - if (epoch !== this.epoch || this.pending !== next) return; - this.removeReadyListener?.(); - this.removeReadyListener = null; - if (this.fallbackTimer !== null) window.clearTimeout(this.fallbackTimer); - this.fallbackTimer = null; - const previous = this.current; - this.current = next; - this.pending = null; - if (previous && previous !== next) this.removeResources(previous.resources); - this.refreshSelection(next.addressing); - this.viewer.scene.requestRender(); - }; - this.removeReadyListener = this.viewer.scene.postRender.addEventListener(() => { - readyFrames = this.viewer.dataSourceDisplay.ready ? readyFrames + 1 : 0; - if (readyFrames >= 2) commit(); - else this.viewer.scene.requestRender(); - }); - this.fallbackTimer = window.setTimeout(commit, 2_000); - this.viewer.scene.requestRender(); - } - - pick(worldPosition: Cartesian3): GridSectorSelection | null { - const addressing = this.pending?.addressing ?? this.current?.addressing; - if (!addressing) return null; - if (addressing.mode === "3d") { - const ellipsoid = this.viewer.scene.globe.ellipsoid; - const surface = ellipsoid.scaleToGeodeticSurface(worldPosition, new Cartesian3()) ?? worldPosition; - const normal = ellipsoid.geodeticSurfaceNormal(surface, new Cartesian3()); - const localSurface = Matrix4.multiplyByPoint(addressing.inverseEnu, surface, new Cartesian3()); - const localNormal = Matrix4.multiplyByPointAsVector(addressing.inverseEnu, normal, new Cartesian3()); - // Geometry is addressed on the origin's ENU tangent plane and then - // reprojected along the WGS84 normal. Intersect that normal with the - // same plane so analytic picking returns the exact source cell even at - // the outer edge of a 1,000 km LOD. - const normalScale = Math.abs(localNormal.z) > 1e-9 ? -localSurface.z / localNormal.z : 0; - const local = new Cartesian3( - localSurface.x + localNormal.x * normalScale, - localSurface.y + localNormal.y * normalScale, - 0, - ); - if (local.x ** 2 + local.y ** 2 > addressing.radiusMeters ** 2) return null; - const definition = { - lod: addressing.lod, - originLatitude: addressing.originLatitude, - originLongitude: addressing.originLongitude, - stepMeters: addressing.stepMeters, - }; - const address = localSectorAt({ eastMeters: local.x, northMeters: local.y }, definition); - const hierarchyDefinition = addressing.majorStepMeters === null - ? definition - : { ...definition, tileSizeMeters: addressing.majorStepMeters }; - const summary = localSectorSummary(address, hierarchyDefinition); - const cartographic = Cartographic.fromCartesian(worldPosition); - const volumeAvailable = addressing.volumeEnabled - && Number.isFinite(addressing.volumeBandHeightMeters) - && addressing.volumeBandHeightMeters > 0 - && addressing.volumeMaximumHeightMeters > addressing.volumeMinimumHeightMeters; - const altitudeMeters = clamp( - Number(cartographic?.height ?? addressing.volumeMinimumHeightMeters), - addressing.volumeMinimumHeightMeters, - Math.max(addressing.volumeMinimumHeightMeters, addressing.volumeMaximumHeightMeters - 1e-6), - ); - const volumeAddress = volumeAvailable ? localVolumeAt({ - eastMeters: local.x, - northMeters: local.y, - altitudeMeters, - }, { - ...definition, - altitudeFloorMeters: addressing.volumeMinimumHeightMeters, - altitudeCeilingMeters: addressing.volumeMaximumHeightMeters, - altitudeBandMeters: addressing.volumeBandHeightMeters, - }) : null; - return { - ...summary, - mode: "3d", - units: "meters-enu", - volume: volumeAddress ? { - id: volumeAddress.id, - index: volumeAddress.bandIndex, - floor: volumeAddress.altitudeFloorMeters, - ceiling: volumeAddress.altitudeCeilingMeters, - bandHeight: volumeAddress.altitudeBandMeters, - } : null, - }; - } - const cartographic = Cartographic.fromCartesian(worldPosition); - const address = graticuleSectorAt({ - longitude: CesiumMath.toDegrees(cartographic.longitude), - latitude: CesiumMath.toDegrees(cartographic.latitude), - }, { lod: addressing.lod, stepDegrees: addressing.stepDegrees }); - const definition = addressing.majorStepDegrees === null - ? { lod: addressing.lod, stepDegrees: addressing.stepDegrees } - : { lod: addressing.lod, stepDegrees: addressing.stepDegrees, majorStepDegrees: addressing.majorStepDegrees }; - const summary = graticuleSectorSummary(address, definition); - return { - ...summary, - mode: "graticule", - units: "degrees-wgs84", - volume: null, - }; - } - - setSelection(selection: GridSectorSelection | null) { - if (!selection || selection.id !== this.navigationSelectionId) this.navigationSelectionId = null; - this.selection = selection; - this.refreshSelection(this.pending?.addressing ?? this.current?.addressing); - } - - navigateToSelection(selection: GridSectorSelection) { - this.navigationSelectionId = selection.id; - this.selection = selection; - this.refreshSelection(this.pending?.addressing ?? this.current?.addressing); - } - - getActiveLod() { - return (this.pending?.addressing ?? this.current?.addressing)?.lod ?? null; - } - - destroy() { - this.epoch += 1; - this.cancelPending(); - if (this.current) this.removeResources(this.current.resources); - this.current = null; - this.selection = null; - this.navigationSelectionId = null; - this.clearSelectionResources(); - } - - private mountResources(resources: GridResources) { - const add = this.viewer.dataSources.add(resources.dataSource); - void add.then((dataSource) => { - if (this.retiredResources.has(resources) && !this.viewer.isDestroyed()) { - this.viewer.dataSources.remove(dataSource, true); - } - }).catch(() => undefined); - if (resources.points) this.viewer.scene.primitives.add(resources.points); - if (resources.crosses) this.viewer.scene.primitives.add(resources.crosses); - if (resources.labels) this.viewer.scene.primitives.add(resources.labels); - } - - private removeResources(resources: GridResources) { - this.retiredResources.add(resources); - this.viewer.dataSources.remove(resources.dataSource, true); - if (resources.points) this.viewer.scene.primitives.remove(resources.points); - if (resources.crosses) this.viewer.scene.primitives.remove(resources.crosses); - if (resources.labels) this.viewer.scene.primitives.remove(resources.labels); - } - - private refreshSelection(addressing: GridAddressing | undefined) { - const key = this.selection && addressing ? JSON.stringify([ - this.selection.id, - this.selection.volume?.id ?? null, - this.addressingKey(addressing), - addressing.heightMeters, - addressing.lineColor, - addressing.lineOpacity, - addressing.majorLineWidthMultiplier, - addressing.selectionFillColor, - addressing.selectionFillOpacityPercent, - addressing.selectionOutlineColor, - addressing.selectionOutlineWidthPx, - addressing.selectionOutlineOpacityPercent, - addressing.mode === "3d" ? [ - addressing.volumeEnabled, - addressing.volumeMinimumHeightMeters, - addressing.volumeMaximumHeightMeters, - addressing.volumeBandHeightMeters, - ] : addressing.lineWidthPixels, - ]) : null; - if (key === this.selectionKey) return; - this.selectionKey = key; - const previous = this.selectionResources; - const next = this.selection && addressing - ? materializeGridSelection(this.selection, addressing, ++this.serial) - : null; - this.selectionResources = next; - if (next) this.mountResources(next); - if (previous) this.removeResources(previous); - this.viewer.scene.requestRender(); - } - - private clearSelectionResources() { - this.selectionKey = null; - if (this.selectionResources) this.removeResources(this.selectionResources); - this.selectionResources = null; - } - - private cancelPending() { - this.removeReadyListener?.(); - this.removeReadyListener = null; - if (this.fallbackTimer !== null) window.clearTimeout(this.fallbackTimer); - this.fallbackTimer = null; - if (this.pending) this.removeResources(this.pending.resources); - this.pending = null; - } -} function applyPresentation( viewer: Viewer, diff --git a/apps/catalog/src/MapFixturePreview.tsx b/apps/catalog/src/MapFixturePreview.tsx index 648ef01..fe5f134 100644 --- a/apps/catalog/src/MapFixturePreview.tsx +++ b/apps/catalog/src/MapFixturePreview.tsx @@ -1,7 +1,7 @@ -import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react"; +import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useReducer, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react"; import { createPortal } from "react-dom"; -import { ApplicationSidePanel, 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 { ApplicationSidePanel, Icon, IconButton, Inspector } from "@nodedc/ui-react"; +import type { SelectOption } from "@nodedc/ui-react"; import type { CameraSpiralState, CesiumMapRendererHandle, @@ -9,31 +9,19 @@ import type { MapGatewayHealth, MapPresentation, MapProviderStatus, - GridLodProfile, GridSectorSelection, -} from "./CesiumMapRenderer.js"; -import { mapRuntimeEntityId, useMapDataProductRuntime } from "./useMapDataProductRuntime.js"; -import type { MapRuntimeFact } from "./useMapDataProductRuntime.js"; +} from "./mapRendererContract.js"; +import { useMapDataProductRuntime } from "./useMapDataProductRuntime.js"; import { - compareMapRuntimeFacts, - mapFactMatchesFilters, - mapPresentationFacetValueIsEnabled, - mapPresentationFacetCounts, mapPresentationProfileForFact, - mapRuntimeDisplayLabel, - mapRuntimeFactIsRenderable, normalizeMapPresentationFacetSelections, normalizeClientMapPresentationProfiles, - resolveMapPresentationClass, - toggleMapPresentationFacetSelection, type MapPresentationFilters, type MapPresentationProfile, } from "./mapPresentationProfile.js"; import { CAMERA_SURVEY_PRESETS, DEFAULT_CAMERA_SURVEY_PRESET, - OSM_BUILDINGS_OBSERVED_BAND_COUNT, - cameraSurveySpiralDistance, findCameraSurveyPreset, type CameraSurveySelection, } from "./mapCameraPresets.js"; @@ -41,27 +29,84 @@ import { buildMapSubjectCardModel, DEFAULT_MAP_SUBJECT_DETAIL_PROFILE } from "./ import { ensureMapReferencePresentationProfiles, initialMapReferenceLayers, - isMapReferencePresentationProfile, type MapReferenceLayer, } from "./mapReferenceStations.js"; import { useMapReferenceRuntime, useMapReferenceSearch } from "./useMapReferenceRuntime.js"; import { buildMapSearchIndex, searchMapSubjects } from "./mapSearch.mjs"; -import { DEFAULT_GRID_LOD_PROFILES, gridLodProfile } from "./mapGridPolicy.mjs"; +import { DEFAULT_GRID_LOD_PROFILES } from "./mapGridPolicy.mjs"; import { - MAX_LOCAL_GRID_INDEX, - graticuleSectorAt, - graticuleSectorSummary, - localSectorAt, - localSectorAtGeodetic, - localSectorSummary, - localVolumeAt, - type GraticuleSectorAddress, - type LocalSectorAddress, -} from "./mapSectorGrid.mjs"; + createDefaultMapPageLayout, + defaultSectorWindowRect, + defaultSubjectCardRect, + defaultSubjectWindowState, + fallbackMapCamera, + initialMapSettings, +} from "./mapPageContract.js"; +import type { + MapDataProductBinding, + MapFixturePreviewHandle, + MapPageLayout, + MapPageSettings, + MapPinBinding, + MapSubjectDetailProfile, + MapSubjectState, + MapWorkspaceWindowId, +} from "./mapPageContract.js"; +import { + GRID_SECTOR_DIRECTIONS, + MAP_SCOPE_OBJECT_KIND_FIELD, + MAP_SCOPE_PROVIDER_FIELD, + graticuleMajorStepDegrees, + gridSectorNeighborSelection, + gridSectorParentLodSelection, + gridSectorVolumeNeighborSelection, + mapFactPointCoordinates, + normalizeSectorGridLodProfile, + resolveGridLodProfiles, + type GridSectorDirection, + type SectorGridLodProfile, +} from "./mapSectorWorkspace.js"; +import { + bindingProjectsField, + buildFilteredMapTargets, + buildMapPresentationFilters, + buildMapPresentationSummaries, + buildSectorBindingOptions, + buildSectorScopeOptions, + buildSectorSpatialEntities, + buildSectorVisibleEntities, + buildSelectableMapEntities, + mapProfileHasSubjectWindowControls, + planMapSubjectReveal, + primaryMapRuntimeBindings as selectPrimaryMapRuntimeBindings, + scopeMapRuntimeBindings, +} from "./mapWorkspaceModel.mjs"; +import { createMapWorkspaceState, mapWorkspaceReducer } from "./mapWorkspaceState.mjs"; +import { MapSectorWorkspaceWindow } from "./MapSectorWorkspaceWindow.js"; +import { MapSubjectWorkspaceWindows } from "./MapSubjectWorkspaceWindows.js"; +import { MapWorkspaceToolbar } from "./MapWorkspaceToolbar.js"; +import { + buildMapPresentationInspectorSections, + buildMapRuntimeInspectorSections, + buildMapSurfaceInspectorSections, + formatMetricDistance, +} from "./mapInspectorSections.js"; +import { buildMapGridInspectorSections } from "./mapGridInspectorSections.js"; const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer }))); +export { createDefaultMapPageLayout } from "./mapPageContract.js"; +export type { + MapDataProductBinding, + MapFixturePreviewHandle, + MapPageLayout, + MapPageSettings, + MapPinBinding, + MapSubjectDetailProfile, + MapSubjectState, + MapSubjectWindowState, +} from "./mapPageContract.js"; + type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean }; -export type MapPageSettings = Omit; type SurveySettingsSnapshot = Pick< MapPageSettings, "imageryVisible" | "monochrome" | "cacheEnabled" | "cacheNoOverwrite" | "terrainEnabled" | "buildingsVisible" | "buildingsDetail" @@ -71,302 +116,9 @@ type GatewayCheckState = "idle" | "checking" | "ready" | "stale" | "error"; type GatewayHealthOrder = { nextEpoch: number; latestStartedEpoch: number }; const RENDERER_GATEWAY_HEALTH_EPOCH = 1; const SURVEY_GATEWAY_HEALTH_MAX_AGE_MS = 30_000; -const GRID_MODE_OPTIONS: Array> = [ - { value: "3d", label: "3D", description: "Приподнятая пространственная сетка" }, - { value: "graticule", label: "Гратикула", description: "Проекция по поверхности" }, -]; - -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; -}; type GridSectorCopyState = "idle" | "copied" | "error"; -const 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)); -}; - -const 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 each 90° quadrant must end - // on a major boundary. A hemisphere therefore needs a multiple of ten - // minor intervals. - const hemisphereDivisions = Math.max(10, Math.round(requestedDivisions / 10) * 10); - return 180 / hemisphereDivisions; -}; - -const 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; -}; - -const 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), - ), - }; -}; - -type GridSectorDirection = "north" | "east" | "south" | "west"; - -const GRID_SECTOR_DIRECTIONS: Array<{ id: GridSectorDirection; label: string }> = [ - { id: "north", label: "Север" }, - { id: "east", label: "Восток" }, - { id: "south", label: "Юг" }, - { id: "west", label: "Запад" }, -]; - -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, - }; -} - -function graticuleGridSectorSelection( - address: GraticuleSectorAddress, - profile: SectorGridLodProfile, -): GridSectorSelection { - const majorStepDegrees = profile.majorLinesEnabled - ? graticuleMajorStepDegrees(profile.graticuleStepDegrees) ?? undefined - : undefined; - const summary = graticuleSectorSummary(address, { - lod: address.lod, - stepDegrees: profile.graticuleStepDegrees, - majorStepDegrees, - }); - return { - ...summary, - mode: "graticule", - address, - units: "degrees-wgs84", - volume: null, - }; -} - -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]; - if (!neighbor) return null; - const preferredAltitudeMeters = selection.volume - ? (selection.volume.floor + selection.volume.ceiling) / 2 - : undefined; - return localGridSectorSelection(neighbor.address, profile, origin, preferredAltitudeMeters); - } - const neighbor = selection.neighbors[direction]; - return neighbor ? graticuleGridSectorSelection(neighbor.address, profile) : null; -} - -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); - } - const address = graticuleSectorAt(selection.center, { - lod: selection.lod + 1, - stepDegrees: parentProfile.graticuleStepDegrees, - }); - return graticuleGridSectorSelection(address, parentProfile); -} - -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, -}); - -const formatGridSectorArea = (areaSquareMeters: number) => areaSquareMeters >= 1_000_000 - ? `${formatGridMetric(areaSquareMeters / 1_000_000, areaSquareMeters >= 1_000_000_000 ? 0 : 2)} км²` - : `${formatGridMetric(areaSquareMeters, 0)} м²`; - -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)}°`; -} - -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)}°`; -} - -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 mapFactPointCoordinates(fact: MapRuntimeFact | undefined): [number, number] | null { - 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; -} - -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; @@ -400,231 +152,6 @@ function isWritableAppendOnlyTileCache(health: MapGatewayHealth | null) { && health.cache.atCapacity === false; } -/** - * Provider-neutral visual binding. Foundry stores this on an Application page - * instance; a future data binding resolves the live source behind `source`. - */ -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; -}; - -/** - * A renderer-neutral declaration of an entity stream assigned to this page. - * - * It intentionally contains no provider endpoint, tenant/connection scope, - * credential reference, or browser token. The Foundry runtime resolves the - * matching server-side consumer grant from the application/page/binding - * target before it asks the External Data Plane for a snapshot or patches. - */ -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 MapSubjectDetailProfile = { - id: string; - version: string; - title: string; - semanticTypes: string[]; - defaultTabId: string; - tabs: Array<{ - id: string; - label: string; - emptyMessage: string; - sections: Array<{ - id: string; - label: string; - fields: Array<{ - id: string; - aspectId?: string; - source: "fact" | "attribute" | "geometry" | "context"; - field: string; - label: string; - format: "text" | "number" | "timestamp" | "boolean" | "coordinate" | "signal_state" | "movement_state" | "string_list" | "telemetry_readings"; - unit?: string; - allowedReadingIds?: string[]; - }>; - }>; - }>; -}; - -export type MapSubjectWindowState = { - open: boolean; - rect: WorkspaceWindowRect; - maximized: boolean; - zIndex: number; -}; - -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; - 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; -}; - -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[], -}; - -function resolveGridLodProfiles(settings?: Partial): GridLodProfile[] { - // A layout saved by the previous flat contract must not lose the values the - // operator already tuned. Promote its common visual fields and per-band - // height/step/mode values into five authoritative profiles on first read; - // the next ordinary page save persists the canonical 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, - )); -} - -// A valid, deterministic scene view is available before Cesium emits its -// first move-end event. It makes the page contract immediately saveable; -// the renderer replaces it with the exact live camera as soon as it is ready. -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"], - }; -} - const initialProviderStatus: MapProviderStatus = { imagery: "loading", terrain: "loading", @@ -632,40 +159,6 @@ const initialProviderStatus: MapProviderStatus = { errors: {}, }; -const providerStateLabel: Record = { - loading: "загружается", - ready: "готов", - error: "недоступен", - "not-configured": "не настроен", -}; - -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, - }; -} - -const defaultSectorWindowRect: WorkspaceWindowRect = { - x: 24, - y: 72, - width: 380, - height: 530, -}; - -const defaultSubjectCardRect: WorkspaceWindowRect = { - x: 940, - y: 72, - width: 390, - height: 520, -}; function initialSubjectState( bindings: MapDataProductBinding[], @@ -692,25 +185,6 @@ function initialSubjectState( })) as Record; } -function hasSubjectWindowControls(profile: MapPresentationProfile) { - return profile.facets.some((facet) => facet.counter || facet.filterable); -} - -const logarithmicControlValue = (value: number) => Math.log10(Math.max(Number.MIN_VALUE, value)); -const valueFromLogarithmicControl = (value: number) => Math.max(1, Math.round(10 ** value)); -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))} с`; -}; - function spiralStopMessage(reason: CameraSpiralState["reason"]) { if (reason === "target_radius_reached") return "Проход завершён: камера достигла выбранного радиуса."; if (reason === "spiral_extent_limit") return "Режим остановлен у безопасной границы геодезического маршрута."; @@ -733,28 +207,13 @@ export const MapFixturePreview = forwardRef void; }>(function MapFixturePreview({ features = { inspector: true, toolbar: true, assistant: false }, expanded = false, initialLayout = null, applicationId, pageId, settingsPanelHost, headerActionsHost, onSettingsPanelOpenChange }, ref) { const workspaceRef = useRef(null); - const [selectedId, setSelectedId] = useState(); - const [selectedGridSector, setSelectedGridSector] = useState(null); const [gridSectorCopyState, setGridSectorCopyState] = useState("idle"); - const [subjectCardOpen, setSubjectCardOpen] = useState(false); - const [subjectCardRect, setSubjectCardRect] = useState(defaultSubjectCardRect); - const [subjectCardMaximized, setSubjectCardMaximized] = useState(false); - const [subjectCardZIndex, setSubjectCardZIndex] = useState(140); - const [subjectCardTabId, setSubjectCardTabId] = useState("overview"); const [expandedFacetRows, setExpandedFacetRows] = useState>({}); const [inspectorOpen, setInspectorOpen] = useState(false); const [inspectorOpenSections, setInspectorOpenSections] = useState(() => ( initialLayout?.inspectorOpenSections ?? ["map-base"] )); const [layersOpen, setLayersOpen] = 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(""); @@ -809,22 +268,39 @@ export const MapFixturePreview = forwardRef(() => ( initialMapReferenceLayers(initialLayout?.referenceLayers) )); - const [subjectStates, setSubjectStates] = useState>(() => ( - initialSubjectState(initialLayout?.dataProductBindings ?? [], initialLayout?.subjectStates, presentationProfiles) - )); - const presentationFilters = useMemo(() => Object.fromEntries( - Object.entries(subjectStates).map(([bindingId, state]) => { - const binding = dataProductBindings.find((candidate) => candidate.id === bindingId); - const profile = mapPresentationProfileForFact( + const [workspaceState, dispatchWorkspace] = useReducer( + mapWorkspaceReducer, + null, + () => createMapWorkspaceState({ + subjectStates: initialSubjectState( + initialLayout?.dataProductBindings ?? [], + initialLayout?.subjectStates, presentationProfiles, - binding?.presentationProfileId, - binding?.semanticTypes[0] ?? "", - ); - return [bindingId, { - visible: state.visible, - facets: profile ? normalizeMapPresentationFacetSelections(state.filters, profile) : state.filters, - }]; + ), + sectorWindowRect: defaultSectorWindowRect, + subjectCardRect: defaultSubjectCardRect, }), + ); + const selectedId = workspaceState.selectedEntityId; + const selectedGridSector = workspaceState.selectedSector; + const subjectStates = workspaceState.subjectStates; + const activeWorkspaceWindowId = workspaceState.activeWindowId; + const { + rect: sectorWindowRect, + maximized: sectorWindowMaximized, + zIndex: sectorWindowZIndex, + } = workspaceState.sector.window; + const hideObjectsOutsideSector = workspaceState.sector.hideOutside; + const sectorScope = workspaceState.sector.scope; + const { + excludedBindingIds: sectorExcludedBindingIds, + excludedProviders: sectorExcludedProviders, + excludedObjectKinds: sectorExcludedObjectKinds, + } = sectorScope; + const presentationFilters = useMemo(() => buildMapPresentationFilters( + subjectStates, + dataProductBindings, + presentationProfiles, ), [dataProductBindings, presentationProfiles, subjectStates]); const runtimeBindings = useMapDataProductRuntime({ applicationId, @@ -855,12 +331,10 @@ export const MapFixturePreview = forwardRef new Set( - dataProductBindings.filter((binding) => !binding.joinToBindingId).map((binding) => binding.id), - ), [dataProductBindings]); - const primaryRuntimeBindings = useMemo(() => ( - runtimeBindings.filter((binding) => primaryBindingIds.has(binding.bindingId)) - ), [primaryBindingIds, runtimeBindings]); + const primaryRuntimeBindings = useMemo(() => selectPrimaryMapRuntimeBindings( + runtimeBindings, + dataProductBindings, + ), [dataProductBindings, runtimeBindings]); const mapSearchIndex = useMemo(() => buildMapSearchIndex({ runtimeBindings: mapSearchRuntimeBindings, bindingConfigs: dataProductBindings, @@ -869,119 +343,59 @@ export const MapFixturePreview = forwardRef ( searchMapSubjects(mapSearchIndex, searchQuery, 8) ), [mapSearchIndex, searchQuery]); - const selectable = useMemo(() => ( - primaryRuntimeBindings.flatMap((binding) => { - const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId); - const facts = [...binding.facts]; - const primaryProfile = mapPresentationProfileForFact( - presentationProfiles, - 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(presentationProfiles, 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, - }; - }); - }) - ), [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(); - 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)) - .flatMap((bindingConfig) => { - const binding = runtimeBindings.find((candidate) => candidate.bindingId === bindingConfig.id); - const facts = binding?.facts ?? []; - const semanticType = bindingConfig.semanticTypes[0] ?? facts[0]?.semanticType ?? ""; - const profile = mapPresentationProfileForFact(presentationProfiles, bindingConfig?.presentationProfileId, semanticType); - if (!profile) return []; - return [{ - bindingId: bindingConfig.id, - displayName: bindingConfig.displayName?.trim() || profile.title || bindingConfig.id, - profile, - total: facts.length, - counts: mapPresentationFacetCounts(facts, profile), - }]; - }), [dataProductBindings, presentationProfiles, runtimeBindings]); + const selectable = useMemo(() => buildSelectableMapEntities( + runtimeBindings, + dataProductBindings, + presentationProfiles, + ), [dataProductBindings, presentationProfiles, runtimeBindings]); + const sectorSpatialEntities = useMemo(() => buildSectorSpatialEntities( + selectable, + selectedGridSector, + sectorGridLodProfiles, + fixedSectorGridOrigin, + ), [fixedSectorGridOrigin, sectorGridLodProfiles, selectable, selectedGridSector]); + const sectorBindingOptions = useMemo(() => buildSectorBindingOptions( + dataProductBindings, + sectorSpatialEntities, + ), [dataProductBindings, sectorSpatialEntities]); + const sectorProviderFacetAvailable = useMemo(() => bindingProjectsField( + dataProductBindings, + MAP_SCOPE_PROVIDER_FIELD, + ), [dataProductBindings]); + const sectorObjectKindFacetAvailable = useMemo(() => bindingProjectsField( + dataProductBindings, + MAP_SCOPE_OBJECT_KIND_FIELD, + ), [dataProductBindings]); + const sectorProviderOptions = useMemo(() => buildSectorScopeOptions( + sectorSpatialEntities, + MAP_SCOPE_PROVIDER_FIELD, + sectorProviderFacetAvailable, + ), [sectorProviderFacetAvailable, sectorSpatialEntities]); + const sectorObjectKindOptions = useMemo(() => buildSectorScopeOptions( + sectorSpatialEntities, + MAP_SCOPE_OBJECT_KIND_FIELD, + sectorObjectKindFacetAvailable, + ), [sectorObjectKindFacetAvailable, sectorSpatialEntities]); + const sectorVisibleEntities = useMemo(() => buildSectorVisibleEntities({ + sectorEntities: sectorSpatialEntities, + bindingConfigs: dataProductBindings, + presentationProfiles, + presentationFilters, + scope: sectorScope, + }), [dataProductBindings, presentationFilters, presentationProfiles, sectorScope, sectorSpatialEntities]); + const sectorScopedPrimaryRuntimeBindings = useMemo(() => scopeMapRuntimeBindings({ + runtimeBindings: primaryRuntimeBindings, + selection: selectedGridSector, + gridProfiles: sectorGridLodProfiles, + origin: fixedSectorGridOrigin, + hideOutsideSector: hideObjectsOutsideSector, + scope: sectorScope, + }), [fixedSectorGridOrigin, hideObjectsOutsideSector, primaryRuntimeBindings, sectorGridLodProfiles, sectorScope, selectedGridSector]); + const presentationSummaries = useMemo(() => buildMapPresentationSummaries( + dataProductBindings, + runtimeBindings, + presentationProfiles, + ), [dataProductBindings, presentationProfiles, runtimeBindings]); const referenceObjectSummaries = useMemo(() => referenceLayers.flatMap((layer) => { const profile = presentationProfiles.find((candidate) => candidate.id === layer.presentationProfileId); if (!profile) return []; @@ -992,26 +406,12 @@ export const MapFixturePreview = forwardRef sectorScopedPrimaryRuntimeBindings.flatMap((binding) => { - const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId); - return binding.facts.flatMap((fact) => { - const profile = mapPresentationProfileForFact( - presentationProfiles, - bindingConfig?.presentationProfileId, - fact.semanticType, - ); - if (!profile || !mapFactMatchesFilters(fact, profile, presentationFilters, binding.bindingId)) return []; - const presentationClass = resolveMapPresentationClass(fact, profile); - return [{ - bindingId: binding.bindingId, - entityId: mapRuntimeEntityId(binding.bindingId, fact), - title: mapRuntimeDisplayLabel(fact, profile), - status: presentationClass?.label ?? "", - renderable: mapRuntimeFactIsRenderable(fact, profile), - }]; - }); - }).sort((left, right) => left.title.localeCompare(right.title, "ru")), [dataProductBindings, presentationFilters, presentationProfiles, sectorScopedPrimaryRuntimeBindings]); + const filteredTargets = useMemo(() => buildFilteredMapTargets( + sectorScopedPrimaryRuntimeBindings, + dataProductBindings, + presentationProfiles, + presentationFilters, + ), [dataProductBindings, presentationFilters, presentationProfiles, sectorScopedPrimaryRuntimeBindings]); const visibleTargetEntityIds = useMemo(() => ( filteredTargets.filter((target) => target.renderable).map((target) => target.entityId) ), [filteredTargets]); @@ -1126,11 +526,7 @@ export const MapFixturePreview = forwardRef { - setSelectedGridSector(null); - setSectorExcludedBindingIds([]); - setSectorExcludedProviders([]); - setSectorExcludedObjectKinds([]); - setActiveWorkspaceWindowId((current) => current === "sector" ? undefined : current); + dispatchWorkspace({ type: "reset-sector-definition" }); }, [gridSectorDefinitionKey]); const minimumGridLodHeight = selectedGridLodIndex === 0 ? 0.1 @@ -1204,7 +600,7 @@ export const MapFixturePreview = forwardRef { if (!sector || !mapRendererRef.current?.focusGridSector(sector)) return; - setSelectedGridSector(sector); + dispatchWorkspace({ type: "set-sector-selection", selection: sector }); }, []); const focusGridMajorTile = useCallback((tile: NonNullable) => { @@ -1364,76 +760,27 @@ export const MapFixturePreview = forwardRef MapSubjectState) => { - setSubjectStates((current) => { - const index = dataProductBindings.findIndex((binding) => binding.id === bindingId); - const state = current[bindingId] ?? { - bindingId, - visible: true, - filters: {}, - window: defaultSubjectWindowState(Math.max(0, index)), - }; - return { ...current, [bindingId]: update(state) }; - }); - }, [dataProductBindings]); - const togglePresentationFilter = (bindingId: string, field: string, value: string, availableValues: string[]) => { - updateSubjectState(bindingId, (state) => { - const filters = state.visible ? state.filters : {}; - return { - ...state, - visible: true, - filters: toggleMapPresentationFacetSelection(filters, field, value, availableValues), - }; - }); + dispatchWorkspace({ type: "toggle-subject-filter", bindingId, field, value, availableValues }); }; const toggleSubjectVisibility = (bindingId: string) => { - updateSubjectState(bindingId, (state) => ({ ...state, visible: !state.visible })); + dispatchWorkspace({ type: "toggle-subject-visibility", bindingId }); }; const activateWorkspaceWindow = useCallback((windowId: MapWorkspaceWindowId) => { - if (activeWorkspaceWindowId === windowId) return; - const nextZIndex = Math.max( - 20, - sectorWindowZIndex, - subjectCardZIndex, - ...Object.values(subjectStates).map((state) => state.window.zIndex), - ) + 1; - 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, sectorWindowZIndex, subjectCardZIndex, subjectStates, updateSubjectState]); - - const clearActiveWorkspaceWindow = (windowId: MapWorkspaceWindowId) => { - setActiveWorkspaceWindowId((current) => current === windowId ? undefined : current); - }; + dispatchWorkspace({ type: "activate-window", windowId }); + }, []); const openSubjectWindow = (bindingId: string) => { - updateSubjectState(bindingId, (state) => ({ - ...state, - window: { ...state.window, open: true }, - })); - activateWorkspaceWindow(`binding:${bindingId}`); - }; - - const closeSubjectWindow = (bindingId: string) => { - updateSubjectState(bindingId, (state) => ({ ...state, window: { ...state.window, open: false } })); - clearActiveWorkspaceWindow(`binding:${bindingId}`); + dispatchWorkspace({ type: "open-subject-window", bindingId }); }; const closeSettingsPanel = useCallback(() => setInspectorOpen(false), []); @@ -1447,11 +794,7 @@ export const MapFixturePreview = forwardRef () => onSettingsPanelOpenChange?.(false), [onSettingsPanelOpenChange]); const deactivateGridSector = () => { - setSelectedGridSector(null); - setSectorExcludedBindingIds([]); - setSectorExcludedProviders([]); - setSectorExcludedObjectKinds([]); - clearActiveWorkspaceWindow("sector"); + dispatchWorkspace({ type: "deactivate-sector" }); }; const handleGridSectorSelect = (selection: GridSectorSelection | null) => { @@ -1459,26 +802,19 @@ export const MapFixturePreview = forwardRef { - setSectorExcludedBindingIds((current) => enabled - ? current.filter((value) => value !== bindingId) - : [...new Set([...current, bindingId])]); + dispatchWorkspace({ type: "set-sector-scope-enabled", dimension: "binding", value: bindingId, enabled }); }; const setSectorProviderEnabled = (provider: string, enabled: boolean) => { - setSectorExcludedProviders((current) => enabled - ? current.filter((value) => value !== provider) - : [...new Set([...current, provider])]); + dispatchWorkspace({ type: "set-sector-scope-enabled", dimension: "provider", value: provider, enabled }); }; const setSectorObjectKindEnabled = (objectKind: string, enabled: boolean) => { - setSectorExcludedObjectKinds((current) => enabled - ? current.filter((value) => value !== objectKind) - : [...new Set([...current, objectKind])]); + dispatchWorkspace({ type: "set-sector-scope-enabled", dimension: "object-kind", value: objectKind, enabled }); }; const updatePresentationProfile = ( @@ -1497,19 +833,17 @@ export const MapFixturePreview = forwardRef { if (!selectable.some((entity) => entity.id === entityId)) return; - setSelectedId(entityId); const entity = selectable.find((candidate) => candidate.id === entityId); const binding = dataProductBindings.find((candidate) => candidate.id === entity?.bindingId); const profile = subjectDetailProfiles.find((candidate) => candidate.id === binding?.subjectDetailProfileId) ?? subjectDetailProfiles.find((candidate) => candidate.semanticTypes.includes(entity?.fact.semanticType ?? "")); - setSubjectCardTabId((current) => ( - profile?.tabs.some((tab) => tab.id === current) - ? current - : (profile?.defaultTabId ?? "overview") - )); - setSubjectCardOpen(true); - activateWorkspaceWindow("subject-card"); - }, [activateWorkspaceWindow, dataProductBindings, selectable, subjectDetailProfiles]); + dispatchWorkspace({ + type: "select-entity", + entityId, + validTabIds: profile?.tabs.map((tab) => tab.id) ?? ["overview"], + defaultTabId: profile?.defaultTabId ?? "overview", + }); + }, [dataProductBindings, selectable, subjectDetailProfiles]); const focusSubject = useCallback((entityId: string, coordinates?: readonly [number, number]) => { const renderer = mapRendererRef.current; @@ -1529,10 +863,40 @@ export const MapFixturePreview = forwardRef { if (result.selectable) { - setSubjectStates((current) => { - const state = current[result.bindingId]; - return state ? { ...current, [result.bindingId]: { ...state, visible: true } } : current; - }); + const entity = selectable.find((candidate) => candidate.id === result.entityId); + const state = subjectStates[result.bindingId]; + if (entity && state) { + const binding = dataProductBindings.find((candidate) => candidate.id === entity.bindingId); + const profile = mapPresentationProfileForFact( + presentationProfiles, + binding?.presentationProfileId, + entity.fact.semanticType, + ); + const reveal = planMapSubjectReveal({ + entity, + subjectState: state, + profile, + selectedSector: selectedGridSector, + gridProfiles: sectorGridLodProfiles, + origin: fixedSectorGridOrigin, + hideOutsideSector: hideObjectsOutsideSector, + scope: sectorScope, + }); + dispatchWorkspace({ + type: "reveal-subject", + bindingId: result.bindingId, + subjectState: reveal.subjectState, + hideOutsideSector: reveal.hideOutsideSector, + scope: reveal.scope, + }); + } else { + const currentState = subjectStates[result.bindingId]; + if (currentState) dispatchWorkspace({ + type: "replace-subject-state", + bindingId: result.bindingId, + subjectState: { ...currentState, visible: true }, + }); + } handleSelect(result.entityId); } else { setReferenceLayers((current) => current.map((layer) => ( @@ -1544,7 +908,7 @@ export const MapFixturePreview = forwardRef) => { if (event.key === "Escape") { @@ -1727,518 +1091,78 @@ export const MapFixturePreview = forwardRef, - content: <> - Cesium World Imagery - Текущий официальный provider. Другие provider-слои появятся только после отдельного asset-контракта Platform. - Imagery: {providerStateLabel[providerStatus.imagery]} · Terrain: {providerStateLabel[providerStatus.terrain]} · 3D: {providerStateLabel[providerStatus.buildings]} - {Object.entries(providerStatus.errors).map(([provider, error]) => {provider}: {error})} - Рельеф — отдельный слой под imagery. - updateMapSettings({ terrainEnabled })} /> - `${(value / 100).toFixed(2)}×`} onChange={(value) => updateMapSettings({ terrainExaggeration: value / 100 })} /> - updateMapSettings({ monochrome })} /> - updateMapSettings({ monochromeColor })} /> - `${value}%`} onChange={(imageryBrightness) => updateMapSettings({ imageryBrightness })} /> - `${value}%`} onChange={(imageryContrast) => updateMapSettings({ imageryContrast })} /> - `${value}%`} onChange={(imagerySaturation) => updateMapSettings({ imagerySaturation })} /> - `${value}%`} onChange={(imageryGamma) => updateMapSettings({ imageryGamma })} /> - `${value}°`} onChange={(imageryHue) => updateMapSettings({ imageryHue })} /> - `${value}%`} onChange={(imageryAlpha) => updateMapSettings({ imageryAlpha })} /> - updateMapSettings({ globeColor })} /> - updateMapSettings({ backgroundColor })} /> - , - }, - { - id: "map-atmosphere", - label: "Атмосфера и освещение", - description: "scene / color correction", - group: "Карта", - icon: , - content: <> - updateMapSettings({ atmosphereEnabled })} /> - `${value}%`} onChange={(atmosphereHue) => updateMapSettings({ atmosphereHue })} /> - `${value}%`} onChange={(atmosphereSaturation) => updateMapSettings({ atmosphereSaturation })} /> - `${value}%`} onChange={(atmosphereBrightness) => updateMapSettings({ atmosphereBrightness })} /> - updateMapSettings({ fogEnabled })} /> - `${(value / 10000).toFixed(4)}`} onChange={(fogDensity) => updateMapSettings({ fogDensity })} /> - updateMapSettings({ sunEnabled })} /> - `${value}:00 UTC`} onChange={(sunHour) => updateMapSettings({ sunHour })} /> - `${value}%`} onChange={(sunIntensity) => updateMapSettings({ sunIntensity })} /> - updateMapSettings({ shadowsEnabled })} /> - , - }, - { - id: "map-buildings", - label: "3D здания", - description: "3D Tiles / detail", - group: "Карта", - icon: , - content: <> - updateMapSettings({ buildingsVisible })} /> - updateMapSettings({ buildingsColor })} /> - `${value}%`} onChange={(value) => updateMapSettings({ buildingsOpacity: value / 100 })} /> - `SSE ${value}`} onChange={(buildingsDetail) => updateMapSettings({ buildingsDetail })} /> - , - }, - ...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: , - content: <> - Профиль принадлежит этой странице Application и управляется тем же provider-neutral MCP-контрактом. Исходный API в настройках отсутствует. - {referenceLayer ? ( - setReferenceLayers((current) => current.map((layer) => ( - layer.id === referenceLayer.id ? { ...layer, visible } : layer - )))} - /> - ) : null} - {profile.target.variant === "surface-fill" && <> - HGeoZone · ground projection - {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
- updatePresentationStyle(profile.id, style.id, { color })} /> - `${value}%`} onChange={(value) => updatePresentationStyle(profile.id, style.id, { opacity: value / 100 })} /> -
; - })} - updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineColor } }) : current)} /> - `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineOpacity: value / 100 } }) : current)} /> - `${value} px`} onChange={(outlineWidthPx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineWidthPx } }) : current)} /> - } - {profile.target.variant === "elevated-spike" && <> - `${value} м`} onChange={(stemHeightMeters) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, stemHeightMeters } }) : current)} /> - `${value} px`} onChange={(headSizePx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, headSizePx } }) : current)} /> - `${value} px`} onChange={(stemWidthPx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, stemWidthPx } }) : current)} /> - } - updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, mode } }))} - /> - `${value} px`} onChange={(sizePx) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, sizePx } }))} /> - `${value} px`} onChange={(offsetX) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, offsetX } }))} /> - `${value} px`} onChange={(offsetY) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, offsetY } }))} /> - `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, hideCameraHeightMeters } }))} /> - `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, hideCameraHeightMeters } }))} /> - updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundColor } }))} /> - `${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: , - content: <> - Цвета назначены семантическим классам после нормализации данных. Здесь нет названий provider-статусов и привязки к транспорту. - {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
- updatePresentationStyle(profile.id, style.id, { color })} /> - `${value}%`} onChange={(value) => updatePresentationStyle(profile.id, style.id, { opacity: value / 100 })} /> -
; - })} - , - }]), - ]; + ...buildMapSurfaceInspectorSections({ + mapSettings, + providerStatus, + updateMapSettings, + }), + ...buildMapPresentationInspectorSections({ + presentationProfiles, + referenceLayers, + setReferenceLayers, + updatePresentationProfile, + updatePresentationStyle, + }), + ...buildMapGridInspectorSections({ + mapSettings, + updateMapSettings, + selectedGridLod, + setSelectedGridLod, + selectedGridLodIndex, + activeGridLod, + minimumGridLodHeight, + maximumGridLodHeight, + updateGridLod, + updateGridVolumeRange, + activeGraticuleMajorStepDegrees, + selectedGridSector, + gridSectorCopyState, + copySelectedGridSectorId, + mapRendererReady, + focusGridMajorTile, + selectedGridParentLod, + sectorGridLodProfiles, + focusGridSector, + selectedGridNeighborTargets, + selectedGridSectorProfile, + selectedGridVolumeTargets, + }), + ...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, }), - { - id: "map-grid", - label: "Сетка и LOD", - description: "first adapter control", - group: "Слои", - icon: , - content:
- Фиксированная московская ENU-адресация задаёт неизменные сектора на LOD 1–3. LOD 4–5 используют глобальную WGS84-гратику́лу; камера выбирает только LOD и видимую область. - updateMapSettings({ gridVisible })} /> - updateMapSettings({ grid3dEnabled })} /> - updateMapSettings({ gridGraticuleEnabled })} /> - updateMapSettings({ gridLodEnabled })} /> - updateMapSettings({ gridRebuildOnMoveEnd })} /> - Fixed ENU · WGS84 - value.toFixed(6)} onChange={(gridCenterLatitude) => updateMapSettings({ gridCenterLatitude })} /> - value.toFixed(6)} onChange={(gridCenterLongitude) => updateMapSettings({ gridCenterLongitude })} /> - value === 0 ? "выкл" : `${value} км`} onChange={(gridAutoDisableHeightKm) => updateMapSettings({ gridAutoDisableHeightKm })} /> -
- ({ value: String(index), label: `LOD ${index + 1}` }))} label="Уровень детализации сетки" onChange={setSelectedGridLod} /> -
- `${value} км`} onChange={(maxHeightKm) => updateGridLod({ maxHeightKm })} /> - {selectedGridLodIndex === 4 ? Последний LOD остаётся активным выше своего порога до общего автовыключения. : null} - 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), - }), - })} - /> - `${value} м`} onChange={(heightMeters) => updateGridLod({ heightMeters })} /> - `${value}°`} onChange={(max3dViewAngleDegrees) => updateGridLod({ max3dViewAngleDegrees })} /> - `${value} км`} - onChange={(stepKm) => updateGridLod({ - stepKm, - tileSizeKm: normalizedMajorTileSizeKm(stepKm, Math.max(stepKm, activeGridLod.tileSizeKm)), - radiusKm: Math.min(activeGridLod.radiusKm, stepKm * MAX_LOCAL_GRID_INDEX), - })} - /> - `${value} км`} - onChange={(tileSizeKm) => updateGridLod({ tileSizeKm: normalizedMajorTileSizeKm(activeGridLod.stepKm, tileSizeKm) })} - /> - Major-тайл содержит целое число ENU-секторов. Для гратикулы major-шаг равен пяти minor-шагам. - updateGridLod({ - majorLinesEnabled, - majorLabelsEnabled: majorLinesEnabled && activeGridLod.majorLabelsEnabled, - })} - /> - updateGridLod({ majorLabelsEnabled })} /> - `×${value.toFixed(1)}`} onChange={(majorLineWidthMultiplier) => updateGridLod({ majorLineWidthMultiplier })} /> - Прозрачность major-линий наследует прозрачность линий текущего LOD. - {activeGridLod.mode === "graticule" && activeGridLod.majorLinesEnabled && activeGraticuleMajorStepDegrees === null - ? Major-разметка недоступна для этого шага: пять minor-интервалов должны точно делить 90°-квадрант. - : null} - `${value} км`} onChange={(radiusKm) => updateGridLod({ radiusKm })} /> - `${value} м`} onChange={(lineDiameterMeters) => updateGridLod({ lineDiameterMeters })} /> - updateGridLod({ lineColor })} /> - `${value}%`} onChange={(lineOpacity) => updateGridLod({ lineOpacity })} /> - updateGridLod({ dotsEnabled })} /> - `${value} м`} onChange={(dotsDiameterMeters) => updateGridLod({ dotsDiameterMeters })} /> - updateGridLod({ dotsColor })} /> - `${value}%`} onChange={(dotsOpacity) => updateGridLod({ dotsOpacity })} /> - updateGridLod({ crossesEnabled })} /> - `${value} м`} onChange={(crossesLengthMeters) => updateGridLod({ crossesLengthMeters })} /> - `${value} м`} onChange={(crossesWidthMeters) => updateGridLod({ crossesWidthMeters })} /> - updateGridLod({ crossesColor })} /> - `${value}%`} onChange={(crossesOpacity) => updateGridLod({ crossesOpacity })} /> - `${value}°`} onChange={(graticuleStepDegrees) => updateGridLod({ graticuleStepDegrees: normalizedGraticuleStepDegrees(graticuleStepDegrees) })} /> - `${value} px`} onChange={(graticuleLineWidthPx) => updateGridLod({ graticuleLineWidthPx })} /> - updateGridLod({ graticuleColor })} /> - `${value}%`} onChange={(graticuleOpacity) => updateGridLod({ graticuleOpacity })} /> - {activeGridLod.mode === "3d" ? <> - updateGridLod({ volumeEnabled })} /> - `${value} м WGS84`} - onChange={(volumeMinimumHeightMeters) => updateGridVolumeRange({ volumeMinimumHeightMeters })} - /> - `${value} м WGS84`} - onChange={(volumeMaximumHeightMeters) => updateGridVolumeRange({ volumeMaximumHeightMeters })} - /> - `${value} м`} - onChange={(volumeBandHeightMeters) => updateGridVolumeRange({ volumeBandHeightMeters })} - /> - Горизонтальный ID сектора остаётся стабильным. Высотный band добавляется как отдельный адрес внутри выбранной ENU-ячейки. - : null} - - updateGridLod({ selectionFillColor })} - /> - - `${value}%`} - onChange={(selectionFillOpacityPercent) => updateGridLod({ selectionFillOpacityPercent })} - /> - - updateGridLod({ selectionOutlineColor })} - /> - - `${value} px`} - onChange={(selectionOutlineWidthPx) => updateGridLod({ selectionOutlineWidthPx })} - /> - `${value}%`} - onChange={(selectionOutlineOpacityPercent) => updateGridLod({ selectionOutlineOpacityPercent })} - /> - Оформление применяется к выбранному сектору текущего LOD. -
- {selectedGridSector?.id ?? "Нажмите сектор на карте"} - {selectedGridSector ? <> - - {gridSectorCopyState === "error" ? Не удалось записать ID в буфер обмена. : null} -
- {selectedGridSector.mode === "3d" ? "Local ENU" : "WGS84 graticule"} · LOD {selectedGridSector.lod} - {selectedGridSector.label} - {gridSectorBoundsLabel(selectedGridSector)} - {gridSectorCenterLabel(selectedGridSector)} - {formatGridSectorArea(selectedGridSector.areaSquareMeters)} -
- {selectedGridSector.parentMajorTile ?
- Parent major tile · {selectedGridSector.parentMajorTile.label} - {selectedGridSector.parentMajorTile.id} - {selectedGridSector.parentMajorTile.minorPerSide} × {selectedGridSector.parentMajorTile.minorPerSide} · {selectedGridSector.parentMajorTile.childCount} дочерних секторов · {formatGridSectorArea(selectedGridSector.parentMajorTile.areaSquareMeters)} - -
: Parent major tile выключен или недоступен для текущей топологии.} -
- Следующий LOD - {selectedGridParentLod ? <> - {selectedGridParentLod.id} - - : {selectedGridSector.lod >= sectorGridLodProfiles.length - ? "Верхний уровень иерархии" - : `LOD ${selectedGridSector.lod + 1} меняет систему адресации`}} -
-
- {GRID_SECTOR_DIRECTIONS.map(({ id, label }) => { - const target = selectedGridNeighborTargets[id]; - return
- - {target?.id ?? "Граница адресного пространства"} -
; - })} -
- {selectedGridSector.mode === "3d" && selectedGridSectorProfile ?
- {selectedGridSectorProfile.volumeEnabled ? "Включён" : "Выключен"} - {selectedGridSector.volume?.floor ?? selectedGridSectorProfile.volumeMinimumHeightMeters} м WGS84 - {selectedGridSector.volume?.ceiling ?? selectedGridSectorProfile.volumeMaximumHeightMeters} м WGS84 - {selectedGridSector.volume?.bandHeight ?? selectedGridSectorProfile.volumeBandHeightMeters} м - {selectedGridSector.volume ? {selectedGridSector.volume.id} : null} - {selectedGridSectorProfile.volumeEnabled ?
- - -
: null} -
: null} - : Кликните ячейку, чтобы получить устойчивый адрес, геометрию и навигацию по соседям.} -
-
, - }, - { - id: "map-camera-animation", - label: "Анимация камеры", - description: "geodesic spiral survey", - group: "Камера", - icon: , - content: <> - - {animationModeEnabled ? <> - Стартовая точка берётся из текущей позиции камеры. Камера смотрит почти в надир, а маршрут ждёт текущие tiles перед продолжением. Движение идёт по региональной геодезической спирали WGS84 до выбранного радиуса. - - У текущего OSM Buildings подтверждено {OSM_BUILDINGS_OBSERVED_BAND_COUNT} иерархических bands. Десять профилей управляют высотой и покрытием; фактический LOD Cesium выбирает по SSE, viewport и расстоянию. - Imagery · Terrain · OSM Buildings - formatMetricDistance(10 ** value)} - onChange={(value) => { - setSpiralPresetId("custom"); - setSpiralHeightMeters(valueFromLogarithmicControl(value)); - }} - /> - formatMetricSpeed(10 ** value)} - onChange={(value) => { - setSpiralPresetId("custom"); - setSpiralSpeedMetersPerSecond(valueFromLogarithmicControl(value)); - }} - /> - formatMetricDistance(10 ** value)} - onChange={(value) => { - setSpiralPresetId("custom"); - setSpiralPitchMetersPerTurn(valueFromLogarithmicControl(value)); - }} - /> - formatMetricDistance(10 ** value)} - onChange={(value) => { - setSpiralPresetId("custom"); - setSpiralTargetRadiusMeters(valueFromLogarithmicControl(value)); - }} - /> - Расчётное движение без ожидания сети: {formatDuration(cameraSurveySpiralDistance(spiralTargetRadiusMeters, spiralPitchMetersPerTurn) / spiralSpeedMetersPerSecond)}. Tile waits и автоматическое сужение шага под viewport увеличат фактическое время. - {!spiralCanStart && !spiralRunning ? Подготовка: imagery — {providerStateLabel[providerStatus.imagery]}, terrain — {providerStateLabel[providerStatus.terrain]}, OSM Buildings — {providerStateLabel[providerStatus.buildings]}, TileCache — {spiralTileCacheReady ? "готов" : gatewayHealth?.cache?.atCapacity ? "заполнен" : gatewayCheckState === "checking" ? "проверяется" : "недоступен для записи"}. : null} - - {spiralRunning ? Камера движется от исходной точки. Выключение режима, уход со страницы или reload остановят сессию. : null} - {spiralMessage ? {spiralMessage} : null} - : null} - , - }, - { - id: "map-cache", - label: "TileCache", - description: "Platform Map Gateway", - group: "Хранение", - icon: , - content: <> - Общий persistent cache Platform: он не принадлежит приложению, странице или пользователю. - - Cache hit отдаётся как есть; новый tile записывается только при miss. - - {mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Live + Cache · append-only" : "Live + Cache · обновление разрешено" : "Live без persistent cache"} - Platform Map Gateway - {gatewayEndpoint ?? "runtime profile · не проверено"} - {liveCacheSummary} - {gatewayHealth?.cache?.writePolicy ?? "append-only · проверяется"} - - - Live Cache: {liveCacheSummary} · {gatewayHealth?.cache?.mode ?? "проверяется"} - {transportDiagnostic ? {transportDiagnostic} : null} - {gatewayHealthAge ? {gatewayHealthAge} : null} - {mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Новые miss дописываются; при заполнении объёма Gateway продолжит live-маршрут без удаления прежних tiles." : "Новые запросы этого Application могут явно обновлять уже записанные tiles." : "Real-time: provider остаётся официальным, чтение и запись persistent cache выключены."} - {gatewayHealth?.cache?.atCapacity ? TileCache заполнен: новые tiles показываются live, но не записываются. Существующий cache не удаляется. : null} - {gatewayCheckState === "error" || gatewayCheckState === "stale" ? {gatewayCheckError} : null} - , - }, - { - id: "map-selection", - label: "Выбранная сущность", - description: "selection contract", - group: "Данные", - icon: , - content: <> - {selected?.title ?? "Нет выбора"} - {selected?.kind ?? "—"}{selected?.status ? ` · ${selected.status}` : ""} - , - }, ]; const headerActions = ( @@ -2303,489 +1227,101 @@ export const MapFixturePreview = forwardRef 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} -
-
-
- + copyState={gridSectorCopyState} + spatialEntities={sectorSpatialEntities} + visibleEntities={sectorVisibleEntities} + bindingOptions={sectorBindingOptions} + providerOptions={sectorProviderOptions} + objectKindOptions={sectorObjectKindOptions} + excludedBindingIds={sectorExcludedBindingIds} + excludedProviders={sectorExcludedProviders} + excludedObjectKinds={sectorExcludedObjectKinds} + hideOutside={hideObjectsOutsideSector} + selectedEntityId={selectedId} + onRectChange={(rect) => dispatchWorkspace({ type: "set-sector-window-rect", rect })} + onMaximizedChange={(value) => dispatchWorkspace({ type: "set-sector-window-maximized", value })} + onActivate={() => activateWorkspaceWindow("sector")} + onDeactivate={deactivateGridSector} + onCopyStableId={() => void copySelectedGridSectorId()} + onHideOutsideChange={(value) => dispatchWorkspace({ type: "set-sector-hide-outside", value })} + onBindingEnabledChange={setSectorBindingEnabled} + onProviderEnabledChange={setSectorProviderEnabled} + onObjectKindEnabledChange={setSectorObjectKindEnabled} + onSelectEntity={handleSelectAndFocus} + /> ) : null} {toolbarOpen ? ( -
- ( - - )} - > - {({ close }) => ( -
-
- Объекты - {objectLayerCount} {objectLayerCount === 1 ? "группа" : "групп"} -
- {presentationSummaries.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 = hasSubjectWindowControls(summary.profile); - return ( -
- -
- ); - })} - {referenceObjectSummaries.map(({ layer, displayName, total }) => ( -
- -
- ))} - {!objectLayerCount ? Нет подключённых объектов. : null} -
- )} -
- ( - - )} - > -
-
- Слои карты - Подложка, рельеф и визуальные слои -
-
- Cesium World Imagery - официальный live provider · imagery: {providerStateLabel[providerStatus.imagery]} · terrain: {providerStateLabel[providerStatus.terrain]} -
- {Object.entries(providerStatus.errors).map(([provider, error]) => {provider}: {error})} - updateMapSettings({ terrainEnabled })} /> - updateMapSettings({ buildingsVisible })} /> - updateMapSettings({ gridVisible })} /> - Live Cache: {liveCacheSummary} - {transportDiagnostic ? {transportDiagnostic} : null} - {gatewayHealthAge ? {gatewayHealthAge} : null} - {gatewayCheckState === "error" || gatewayCheckState === "stale" ? {gatewayCheckError} : null} - - -
-
- mapRendererRef.current?.fitRuntimeEntities(visibleTargetEntityIds)}> - { - setSearchOpen((current) => !current); - if (searchOpen) { - setSearchQuery(""); - setRemoteSearchQuery(""); - setSearchActiveIndex(0); - } - }} - > -
- - {searchQuery.trim() ? ( -
- {mapSearchResults.map((result, index) => ( - - ))} - {!mapSearchResults.length ? ( - - {remoteSearchQuery === searchQuery.trim() - ? (referenceSearchState === "loading" - ? "Ищем станцию в OSM…" - : referenceSearchState === "error" - ? "Поиск OSM временно недоступен; локальные данные сохранены." - : "Станции с таким точным названием не найдены.") - : "Совпадений нет. Enter — найти станцию по точному названию в OSM."} - - ) : null} -
- ) : null} -
-
- ) : null} - - {presentationSummaries.map((summary) => { - const state = subjectStates[summary.bindingId]; - if (!state?.window.open || !hasSubjectWindowControls(summary.profile)) return null; - const visibleCount = filteredTargets.filter((target) => target.bindingId === summary.bindingId && target.renderable).length; - return ( - updateSubjectState(summary.bindingId, (current) => ({ - ...current, - window: { ...current.window, rect }, - }))} - maximized={state.window.maximized} - onMaximizedChange={(maximized) => updateSubjectState(summary.bindingId, (current) => ({ - ...current, - window: { ...current.window, maximized }, - }))} - onActivate={() => activateWorkspaceWindow(`binding:${summary.bindingId}`)} - onClose={() => closeSubjectWindow(summary.bindingId)} - title={summary.displayName} - subtitle={`${summary.total} всего · ${visibleCount} на карте`} - active={activeWorkspaceWindowId === `binding:${summary.bindingId}`} - zIndex={state.window.zIndex} - minWidth={240} - minHeight={220} - autoHeight - className="catalog-map-fixture__subject-window catalog-map-fixture__map-glass-window" - > -
-
-
- {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 ( -
-
- - -
- {expanded ? ( -
- {matchingEntities.map((entity) => ( - - ))} - {!matchingEntities.length ? Нет объектов в группе. : null} -
- ) : null} -
- ); - }) - ))} -
-
-
-
- ); - })} - - {subjectCardOpen && selectedSubjectCard ? ( - activateWorkspaceWindow("subject-card")} - onClose={() => { - setSubjectCardOpen(false); - clearActiveWorkspaceWindow("subject-card"); + setReferenceLayers((current) => current.map((layer) => ( + layer.id === layerId ? { ...layer, visible: !layer.visible } : layer + )))} + onLayersOpenChange={setLayersOpen} + onTerrainChange={(terrainEnabled) => updateMapSettings({ terrainEnabled })} + onBuildingsVisibleChange={(buildingsVisible) => updateMapSettings({ buildingsVisible })} + onGridVisibleChange={(gridVisible) => updateMapSettings({ gridVisible })} + onCacheEnabledChange={setCacheEnabled} + onCacheNoOverwriteChange={setCacheNoOverwrite} + onFitVisibleTargets={() => mapRendererRef.current?.fitRuntimeEntities(visibleTargetEntityIds)} + onToggleSearch={() => { + setSearchOpen((current) => !current); + if (searchOpen) { + setSearchQuery(""); + setRemoteSearchQuery(""); + setSearchActiveIndex(0); + } }} - title={selectedSubjectCard.title} - subtitle={selectedSubjectCard.sourceId} - active={activeWorkspaceWindowId === "subject-card"} - zIndex={subjectCardZIndex} - minWidth={320} - minHeight={320} - className="catalog-map-fixture__subject-card catalog-map-fixture__map-glass-window" - aria-label={`Карточка объекта: ${selectedSubjectCard.title}`} - > -
-
- tab.id === subjectCardTabId) ? subjectCardTabId : selectedSubjectCard.defaultTabId} - items={selectedSubjectCard.tabs.map((tab) => ({ value: tab.id, label: tab.label }))} - label="Разделы карточки объекта" - onChange={setSubjectCardTabId} - /> -
- {selectedSubjectCard.tabs.filter((tab) => tab.id === ( - selectedSubjectCard.tabs.some((candidate) => candidate.id === subjectCardTabId) - ? subjectCardTabId - : selectedSubjectCard.defaultTabId - )).map((tab) => ( -
- {tab.empty ?
{tab.emptyMessage}
: null} - {tab.sections.map((section) => ( -
-

{section.label}

- {section.rows.length ? ( -
- {section.rows.map((row) => ( -
-
{row.label}
-
{row.value}
-
- ))} -
- ) : null} - {section.readings.length ? ( -
- {section.readings.map((reading) => ( -
- {reading.label} - {reading.value} - {reading.observedAt ? : null} -
- ))} -
- ) : null} -
- ))} -
- ))} -
-
+ onSearchQueryChange={(value) => { + setSearchQuery(value); + setRemoteSearchQuery(""); + }} + onSearchKeyDown={handleSearchKeyDown} + onSearchActiveIndexChange={setSearchActiveIndex} + onSearchResult={handleSearchResult} + /> ) : null} + setExpandedFacetRows((current) => ({ ...current, [rowId]: !current[rowId] }))} + onTogglePresentationFilter={togglePresentationFilter} + onSelectEntity={handleSelectAndFocus} + /> + {assistantOpen ?
NODE.DC AssistantКонтекст выбранной сущности готов к передаче.
: null} diff --git a/apps/catalog/src/MapSectorWorkspaceWindow.tsx b/apps/catalog/src/MapSectorWorkspaceWindow.tsx new file mode 100644 index 0000000..a864d55 --- /dev/null +++ b/apps/catalog/src/MapSectorWorkspaceWindow.tsx @@ -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; + 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 ( + + Деактивировать сектор + + )} + className="catalog-map-fixture__sector-window catalog-map-fixture__map-glass-window" + aria-label={`Активный сектор: ${selection.id}`} + > +
+
+ {selection.id} + {gridSectorBoundsLabel(selection)} + {formatGridSectorArea(selection.areaSquareMeters)} + +
+ + + + + + {providerOptions.length ? ( + + ) : null} + + {objectKindOptions.length ? ( + + ) : null} + +
+
+ Объекты сектора + {visibleEntities.length} / {spatialEntities.length} +
+
+ {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 ( + + ); + })} + {!visibleEntities.length ? ( + + {spatialEntities.length + ? "Объекты скрыты текущими фильтрами." + : "В секторе нет точечных объектов подключённых Data Products."} + + ) : null} +
+
+
+
+ ); +} + +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 ( +
+
+ {title} + {options.length} +
+ {options.map((option) => ( + onEnabledChange(option.value, enabled)} + /> + ))} +
+ ); +} diff --git a/apps/catalog/src/MapSubjectWorkspaceWindows.tsx b/apps/catalog/src/MapSubjectWorkspaceWindows.tsx new file mode 100644 index 0000000..90ae888 --- /dev/null +++ b/apps/catalog/src/MapSubjectWorkspaceWindows.tsx @@ -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; + workspaceState: MapWorkspaceState; + dispatch: Dispatch; + summaries: MapPresentationSummary[]; + filteredTargets: MapFilteredTarget[]; + selectable: MapSelectableEntity[]; + expandedFacetRows: Record; + 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 ( + 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" + > +
+
+
+ {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 ( +
+
+ + +
+ {expanded ? ( +
+ {matchingEntities.map((entity) => ( + + ))} + {!matchingEntities.length ? Нет объектов в группе. : null} +
+ ) : null} +
+ ); + }) + ))} +
+
+
+
+ ); + })} + + {workspaceState.subjectCard.open && selectedSubjectCard ? ( + 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}`} + > + dispatch({ type: "set-subject-card-tab", tabId })} + /> + + ) : 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 ( +
+
+ ({ value: tab.id, label: tab.label }))} + label="Разделы карточки объекта" + onChange={onTabChange} + /> +
+ {model.tabs.filter((tab) => tab.id === activeTabId).map((tab) => ( +
+ {tab.empty ?
{tab.emptyMessage}
: null} + {tab.sections.map((section) => ( +
+

{section.label}

+ {section.rows.length ? ( +
+ {section.rows.map((row) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+ ) : null} + {section.readings.length ? ( +
+ {section.readings.map((reading) => ( +
+ {reading.label} + {reading.value} + {reading.observedAt ? : null} +
+ ))} +
+ ) : null} +
+ ))} +
+ ))} +
+ ); +} diff --git a/apps/catalog/src/MapWorkspaceToolbar.tsx b/apps/catalog/src/MapWorkspaceToolbar.tsx new file mode 100644 index 0000000..1c844f6 --- /dev/null +++ b/apps/catalog/src/MapWorkspaceToolbar.tsx @@ -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 = { + 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; + searchResults: MapSearchDocument[]; + searchActiveIndex: number; + remoteSearchQuery: string; + referenceSearchState: "idle" | "loading" | "ready" | "error"; + summaries: MapPresentationSummary[]; + referenceSummaries: ReferenceObjectSummary[]; + subjectStates: Record; + 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) => void; + onSearchActiveIndexChange: (index: number) => void; + onSearchResult: (result: MapSearchDocument) => void; +}) { + const objectLayerCount = summaries.length + referenceSummaries.length; + return ( +
+ + + + + +
+ ); +} + +function MapObjectsMenu({ + objectLayerCount, + summaries, + referenceSummaries, + subjectStates, + filteredTargets, + onOpenSubjectWindow, + onToggleSubjectVisibility, + onToggleReferenceLayer, +}: { + objectLayerCount: number; + summaries: MapPresentationSummary[]; + referenceSummaries: ReferenceObjectSummary[]; + subjectStates: Record; + filteredTargets: MapFilteredTarget[]; + onOpenSubjectWindow: (bindingId: string) => void; + onToggleSubjectVisibility: (bindingId: string) => void; + onToggleReferenceLayer: (layerId: string) => void; +}) { + return ( + ( + + )} + > + {({ close }) => ( +
+
+ Объекты + {objectLayerCount} {objectLayerCount === 1 ? "группа" : "групп"} +
+ {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 ( +
+ +
+ ); + })} + {referenceSummaries.map(({ layer, displayName, total }) => ( +
+ +
+ ))} + {!objectLayerCount ? Нет подключённых объектов. : null} +
+ )} +
+ ); +} + +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 ( + ( + + )} + > +
+
+ Слои карты + Подложка, рельеф и визуальные слои +
+
+ Cesium World Imagery + официальный live provider · imagery: {providerStateLabel[providerStatus.imagery]} · terrain: {providerStateLabel[providerStatus.terrain]} +
+ {Object.entries(providerStatus.errors).map(([provider, error]) => {provider}: {error})} + + + + Live Cache: {liveCacheSummary} + {transportDiagnostic ? {transportDiagnostic} : null} + {gatewayHealthAge ? {gatewayHealthAge} : null} + {gatewayCheckState === "error" || gatewayCheckState === "stale" ? {gatewayCheckError} : null} + + +
+
+ ); +} + +function MapSearch({ + open, + query, + inputRef, + results, + activeIndex, + remoteQuery, + referenceSearchState, + onQueryChange, + onKeyDown, + onActiveIndexChange, + onResult, +}: { + open: boolean; + query: string; + inputRef: RefObject; + results: MapSearchDocument[]; + activeIndex: number; + remoteQuery: string; + referenceSearchState: "idle" | "loading" | "ready" | "error"; + onQueryChange: (value: string) => void; + onKeyDown: (event: KeyboardEvent) => void; + onActiveIndexChange: (index: number) => void; + onResult: (result: MapSearchDocument) => void; +}) { + return ( +
+ + {query.trim() ? ( +
+ {results.map((result, index) => ( + + ))} + {!results.length ? ( + + {remoteQuery === query.trim() + ? (referenceSearchState === "loading" + ? "Ищем станцию в OSM…" + : referenceSearchState === "error" + ? "Поиск OSM временно недоступен; локальные данные сохранены." + : "Станции с таким точным названием не найдены.") + : "Совпадений нет. Enter — найти станцию по точному названию в OSM."} + + ) : null} +
+ ) : null} +
+ ); +} diff --git a/apps/catalog/src/applicationManifest.ts b/apps/catalog/src/applicationManifest.ts index d7052b3..c071cc9 100644 --- a/apps/catalog/src/applicationManifest.ts +++ b/apps/catalog/src/applicationManifest.ts @@ -1,5 +1,5 @@ import type { NodedcTheme } from "@nodedc/ui-core"; -import type { MapPageLayout, MapPageSettings } from "./MapFixturePreview.js"; +import type { MapPageLayout, MapPageSettings } from "./mapPageContract.js"; import type { MapPresentationProfile } from "./mapPresentationProfile.js"; export const applicationManifestSchemaVersion = "0.1.0" as const; diff --git a/apps/catalog/src/designProfile.ts b/apps/catalog/src/designProfile.ts index a29b215..1a20e9d 100644 --- a/apps/catalog/src/designProfile.ts +++ b/apps/catalog/src/designProfile.ts @@ -1,7 +1,7 @@ import type { GlassMaterialSettings, NodedcTheme } from "@nodedc/ui-core"; import type { ToolbarPlacement } from "@nodedc/ui-react"; import type { FaviconAssetUrls } from "./favicon.js"; -import type { MapPageLayout, MapPageSettings } from "./MapFixturePreview.js"; +import type { MapPageLayout, MapPageSettings } from "./mapPageContract.js"; import type { MapPresentationProfile } from "./mapPresentationProfile.js"; export type MaterialDraft = { diff --git a/apps/catalog/src/mapCesiumGridLayer.ts b/apps/catalog/src/mapCesiumGridLayer.ts new file mode 100644 index 0000000..5232ef2 --- /dev/null +++ b/apps/catalog/src/mapCesiumGridLayer.ts @@ -0,0 +1,1145 @@ +import { + ArcType, + Cartesian2, + Cartesian3, + Cartographic, + Color, + CustomDataSource, + HeightReference, + HorizontalOrigin, + LabelCollection, + LabelStyle, + Matrix4, + Math as CesiumMath, + PointPrimitiveCollection, + PolygonHierarchy, + PolylineCollection, + Transforms, + VerticalOrigin, + Viewer, +} from "cesium"; +import { + gridShouldBeVisible, + resolveGridMode, + selectGridLod, +} from "./mapGridPolicy.mjs"; +import { + boundedAngularParts, + fixedGridOrigin, + graticuleGranularity, + graticuleLinePlan, + graticuleMajorTileAt, + graticuleSectorAt, + graticuleSectorSummary, + isGraticuleMajorLineValue, + isLocalMajorLineIndex, + localGridPlan, + localMajorTileAt, + localSectorAt, + localSectorSummary, + localVolumeAt, + splitLongitudeRange, +} from "./mapSectorGrid.mjs"; +import type { + GraticuleGridSectorSelection, + GridLodProfile, + GridSectorSelection, + LocalGridSectorSelection, + MapPresentation, +} from "./mapRendererContract.js"; + +const clamp = (value: number, minimum: number, maximum: number) => Math.max(minimum, Math.min(maximum, value)); + +type GridLodBand = GridLodProfile & { index: number; id: string }; +type LocalGridAddressing = { + mode: "3d"; + lod: number; + stepMeters: number; + radiusMeters: number; + originLatitude: number; + originLongitude: number; + enu: Matrix4; + inverseEnu: Matrix4; + heightMeters: number; + tileSizeMeters: number; + majorStepMeters: number | null; + lineColor: string; + lineOpacity: number; + majorLineWidthMultiplier: number; + volumeEnabled: boolean; + volumeMinimumHeightMeters: number; + volumeMaximumHeightMeters: number; + volumeBandHeightMeters: number; + selectionFillColor: string; + selectionFillOpacityPercent: number; + selectionOutlineColor: string; + selectionOutlineWidthPx: number; + selectionOutlineOpacityPercent: number; +}; +type GraticuleGridAddressing = { + mode: "graticule"; + lod: number; + stepDegrees: number; + majorStepDegrees: number | null; + heightMeters: number; + lineWidthPixels: number; + lineColor: string; + lineOpacity: number; + majorLineWidthMultiplier: number; + selectionFillColor: string; + selectionFillOpacityPercent: number; + selectionOutlineColor: string; + selectionOutlineWidthPx: number; + selectionOutlineOpacityPercent: number; +}; +type GridAddressing = LocalGridAddressing | GraticuleGridAddressing; +type GridResources = { + dataSource: CustomDataSource; + points: PointPrimitiveCollection | null; + crosses: PolylineCollection | null; + labels: LabelCollection | null; +}; +type HiddenGridPlan = { mode: "hidden"; key: string; lodIndex: number | null }; +type LocalGridLayerPlan = { + mode: "3d"; + key: string; + lodIndex: number; + lod: GridLodBand; + origin: { latitude: number; longitude: number }; + enu: Matrix4; + inverseEnu: Matrix4; + grid: ReturnType; + majorGrid: ReturnType | null; + majorStepMeters: number | null; + metersPerPixel: number; + cameraHeightMeters: number; + cameraPosition: Cartesian3; + cameraDirection: Cartesian3; +}; +type GraticuleGridLayerPlan = { + mode: "graticule"; + key: string; + lodIndex: number; + lod: GridLodBand; + grid: ReturnType; + majorStepDegrees: number | null; +}; +type GridLayerPlan = HiddenGridPlan | LocalGridLayerPlan | GraticuleGridLayerPlan; + +function integerMultipleRatio(outer: number, inner: number) { + if (!Number.isFinite(outer) || !Number.isFinite(inner) || outer <= 0 || inner <= 0) return null; + const ratio = outer / inner; + const rounded = Math.round(ratio); + return Number.isSafeInteger(rounded) && rounded >= 1 && Math.abs(ratio - rounded) <= 1e-9 * Math.max(1, ratio) + ? rounded + : null; +} + +function localMajorStepMeters(lod: GridLodBand, stepMeters: number) { + const tileSizeMeters = lod.tileSizeKm * 1_000; + return integerMultipleRatio(tileSizeMeters, stepMeters) === null ? null : tileSizeMeters; +} + +function graticuleMajorStepDegrees(stepDegrees: number) { + const candidate = stepDegrees * 5; + return integerMultipleRatio(90, candidate) === null ? null : candidate; +} + +function gridMajorColor(cssColor: string, opacity: number) { + // Major boundaries inherit the profile colour and opacity. Their hierarchy + // is expressed only through the persisted width multiplier. + return Color.fromCssColorString(cssColor).withAlpha(clamp(opacity / 100, 0, 1)); +} + +function gridMetersPerPixel(viewer: Viewer) { + const frustum = viewer.camera.frustum as unknown as { fovy?: number }; + return Math.max(0.01, ( + Math.max(1, Number(viewer.camera.positionCartographic?.height || 1)) + * 2 * Math.tan((Number(frustum.fovy) || Math.PI / 3) / 2) + ) / Math.max(1, viewer.scene.canvas.clientHeight)); +} + +function graticuleViewport(viewer: Viewer, stepDegrees: number, cameraHeightKm: number) { + const ellipsoid = viewer.scene.globe.ellipsoid; + const margin = Math.max(stepDegrees * 2, Math.min(30, cameraHeightKm / 200)); + const alignLatitude = (value: number, direction: "down" | "up") => clamp( + (direction === "down" ? Math.floor(value / stepDegrees) : Math.ceil(value / stepDegrees)) * stepDegrees, + -89.9, + 89.9, + ); + const alignIntervals = (west: number, east: number) => splitLongitudeRange(west - margin, east + margin).map((interval) => ({ + west: interval.west <= -180 ? -180 : Math.floor(interval.west / stepDegrees) * stepDegrees, + east: interval.east >= 180 ? 180 : Math.ceil(interval.east / stepDegrees) * stepDegrees, + })); + const rectangle = viewer.camera.computeViewRectangle(ellipsoid); + if (rectangle) { + const south = CesiumMath.toDegrees(rectangle.south); + const north = CesiumMath.toDegrees(rectangle.north); + const west = CesiumMath.toDegrees(rectangle.west); + const east = CesiumMath.toDegrees(rectangle.east); + return { + south: alignLatitude(south - margin, "down"), + north: alignLatitude(north + margin, "up"), + longitudeIntervals: alignIntervals(west, east), + }; + } + const camera = viewer.camera.positionCartographic; + const latitude = camera ? CesiumMath.toDegrees(camera.latitude) : 0; + const longitude = camera ? CesiumMath.toDegrees(camera.longitude) : 0; + const horizon = CesiumMath.toDegrees(Math.acos(clamp( + ellipsoid.maximumRadius / (ellipsoid.maximumRadius + Math.max(0, cameraHeightKm * 1_000)), + -1, + 1, + ))); + const range = Math.min(180, Math.max(10, horizon * 2)); + return { + south: alignLatitude(latitude - range - margin, "down"), + north: alignLatitude(latitude + range + margin, "up"), + longitudeIntervals: alignIntervals(longitude - range - margin, longitude + range + margin), + }; +} + +function planGridLayer(viewer: Viewer, presentation: MapPresentation, previousLodIndex: number | null): GridLayerPlan { + const cameraHeightKm = Math.max(0, Number(viewer.camera.positionCartographic?.height || 0) / 1_000); + if (!gridShouldBeVisible(presentation, cameraHeightKm)) return { mode: "hidden", key: "hidden", lodIndex: null }; + const lod = selectGridLod(presentation, cameraHeightKm, previousLodIndex) as GridLodBand; + const mode = resolveGridMode(presentation, lod.mode); + if (mode === "hidden") return { mode: "hidden", key: `hidden:${lod.index}`, lodIndex: lod.index }; + + if (mode === "3d") { + const origin = fixedGridOrigin(presentation); + const anchor = Cartesian3.fromDegrees(origin.longitude, origin.latitude, 0); + const enu = Transforms.eastNorthUpToFixedFrame(anchor); + const inverseEnu = Matrix4.inverseTransformation(enu, new Matrix4()); + const stepMeters = clamp(lod.stepKm * 1_000, 100, 5_000_000); + const radiusMeters = clamp(lod.radiusKm * 1_000, stepMeters, 100_000_000); + const metersPerPixel = gridMetersPerPixel(viewer); + const cameraPosition = Cartesian3.clone(viewer.camera.positionWC, new Cartesian3()); + const cameraDirection = Cartesian3.normalize(viewer.camera.directionWC, new Cartesian3()); + const cameraLocal = Matrix4.multiplyByPoint(inverseEnu, cameraPosition, new Cartesian3()); + const cameraPositionBucketMeters = Math.max(stepMeters, lod.tileSizeKm * 500); + const cameraPositionKey = [cameraLocal.x, cameraLocal.y] + .map((value) => Math.round(value / cameraPositionBucketMeters)); + const directionKey = [cameraDirection.x, cameraDirection.y, cameraDirection.z] + .map((value) => Math.round(value * 50) / 50); + const cameraHeightMeters = cameraHeightKm * 1_000; + const grid = localGridPlan({ stepMeters, radiusMeters, maximumMarkers: 5_000 }); + const majorStepMeters = localMajorStepMeters(lod, grid.stepMeters); + const majorGrid = majorStepMeters !== null && (lod.majorLinesEnabled || lod.majorLabelsEnabled) + ? localGridPlan({ stepMeters: majorStepMeters, radiusMeters: grid.radiusMeters, maximumMarkers: 1 }) + : null; + const key = JSON.stringify([ + "local-enu", lod.index, origin.latitude, origin.longitude, stepMeters, radiusMeters, + lod.heightMeters, lod.max3dViewAngleDegrees, lod.tileSizeKm, + lod.lineDiameterMeters, lod.lineColor, lod.lineOpacity, + lod.dotsEnabled, lod.dotsDiameterMeters, lod.dotsColor, lod.dotsOpacity, + lod.crossesEnabled, lod.crossesLengthMeters, lod.crossesWidthMeters, lod.crossesColor, lod.crossesOpacity, + lod.majorLinesEnabled, lod.majorLabelsEnabled, lod.majorLineWidthMultiplier, + lod.volumeEnabled, lod.volumeMinimumHeightMeters, lod.volumeMaximumHeightMeters, lod.volumeBandHeightMeters, + majorStepMeters, + grid.markerStride, cameraPositionKey, directionKey, Math.round(cameraHeightKm * 10) / 10, + Math.round(metersPerPixel * 10) / 10, + ]); + return { + mode, key, lodIndex: lod.index, lod, origin, enu, inverseEnu, grid, majorGrid, majorStepMeters, + metersPerPixel, cameraHeightMeters, cameraPosition, cameraDirection, + }; + } + + const viewport = graticuleViewport(viewer, lod.graticuleStepDegrees, cameraHeightKm); + const grid = graticuleLinePlan({ ...viewport, stepDegrees: lod.graticuleStepDegrees }); + const majorStepDegrees = graticuleMajorStepDegrees(grid.stepDegrees); + const key = JSON.stringify([ + "wgs84-graticule", lod.index, lod.heightMeters, lod.graticuleStepDegrees, + lod.graticuleLineWidthPx, lod.graticuleColor, lod.graticuleOpacity, + lod.majorLinesEnabled, lod.majorLabelsEnabled, lod.majorLineWidthMultiplier, majorStepDegrees, + grid.south, grid.north, grid.longitudeIntervals, + ]); + return { mode, key, lodIndex: lod.index, lod, grid, majorStepDegrees }; +} + +function localShellPosition( + enu: Matrix4, + eastMeters: number, + northMeters: number, + heightMeters: number, +) { + const tangentPoint = Matrix4.multiplyByPoint(enu, new Cartesian3(eastMeters, northMeters, 0), new Cartesian3()); + const cartographic = Cartographic.fromCartesian(tangentPoint); + return Cartesian3.fromRadians(cartographic.longitude, cartographic.latitude, heightMeters); +} + +function materializeLocalGrid(plan: LocalGridLayerPlan, serial: number): GridResources { + const dataSource = new CustomDataSource(`nodedc-map-grid-local:${serial}`); + const points = plan.lod.dotsEnabled ? new PointPrimitiveCollection() : null; + const crosses = plan.lod.crossesEnabled ? new PolylineCollection() : null; + const labels = plan.lod.majorLabelsEnabled && plan.majorStepMeters !== null ? new LabelCollection() : null; + const lineColor = Color.fromCssColorString(plan.lod.lineColor).withAlpha(clamp(plan.lod.lineOpacity / 100, 0, 1)); + const majorColor = gridMajorColor(plan.lod.lineColor, plan.lod.lineOpacity); + const dotColor = Color.fromCssColorString(plan.lod.dotsColor).withAlpha(clamp(plan.lod.dotsOpacity / 100, 0, 1)); + const crossColor = Color.fromCssColorString(plan.lod.crossesColor).withAlpha(clamp(plan.lod.crossesOpacity / 100, 0, 1)); + const lineWidthPixels = clamp(plan.lod.lineDiameterMeters / plan.metersPerPixel, 1, 8); + const majorLineWidthPixels = clamp(lineWidthPixels * plan.lod.majorLineWidthMultiplier, lineWidthPixels, 16); + const dotPixelSize = clamp(plan.lod.dotsDiameterMeters / plan.metersPerPixel, 1, 128); + const crossWidthPixels = clamp(plan.lod.crossesWidthMeters / plan.metersPerPixel, 1, 8); + const definition = { + lod: plan.lod.index + 1, + originLatitude: plan.origin.latitude, + originLongitude: plan.origin.longitude, + stepMeters: plan.grid.stepMeters, + }; + const hierarchyDefinition = plan.majorStepMeters === null ? null : { + ...definition, + tileSizeMeters: plan.majorStepMeters, + }; + const toWorld = (eastMeters: number, northMeters: number, heightMeters = Math.max(0, plan.lod.heightMeters)) => { + // Metric addressing stays in the immutable ENU frame. Reprojection only + // bends the visual shell to WGS84 at an absolute height; terrain and 3D + // Tiles never move the sector boundaries. + return localShellPosition(plan.enu, eastMeters, northMeters, Math.max(0, heightMeters)); + }; + const useViewCone = Number.isFinite(plan.lod.max3dViewAngleDegrees) && plan.lod.max3dViewAngleDegrees < 170; + const viewConeMinimumDot = Math.cos(CesiumMath.toRadians(plan.lod.max3dViewAngleDegrees)); + const viewConeMinimumDistance = Math.max( + plan.grid.stepMeters * 2, + Math.min(plan.grid.radiusMeters, plan.cameraHeightMeters * 6), + ); + const cameraLocal = Matrix4.multiplyByPoint(plan.inverseEnu, plan.cameraPosition, new Cartesian3()); + const cameraDirectionLocal = Matrix4.multiplyByPointAsVector(plan.inverseEnu, plan.cameraDirection, new Cartesian3()); + Cartesian3.normalize(cameraDirectionLocal, cameraDirectionLocal); + const originLatitudeRadians = CesiumMath.toRadians(plan.origin.latitude); + const wgs84SemiMajor = 6_378_137; + const wgs84EccentricitySquared = 0.00669437999014; + const latitudeFactor = 1 - wgs84EccentricitySquared * Math.sin(originLatitudeRadians) ** 2; + const eastCurvatureRadius = wgs84SemiMajor / Math.sqrt(latitudeFactor); + const northCurvatureRadius = wgs84SemiMajor * (1 - wgs84EccentricitySquared) / latitudeFactor ** 1.5; + const approximateShellPoint = (eastMeters: number, northMeters: number) => { + const horizontalSquared = eastMeters ** 2 + northMeters ** 2; + if (horizontalSquared <= 1e-9) return new Cartesian3(0, 0, plan.lod.heightMeters); + const curvatureRadius = horizontalSquared / ( + eastMeters ** 2 / eastCurvatureRadius + northMeters ** 2 / northCurvatureRadius + ); + // Invert the donor's tangent-point -> geodetic-direction reprojection. + // This keeps cone culling close to the WGS84 shell without paying for two + // Cartographic conversions on every coarse candidate. + const scale = curvatureRadius / Math.sqrt(curvatureRadius ** 2 + horizontalSquared); + return new Cartesian3( + eastMeters * scale, + northMeters * scale, + curvatureRadius * scale - curvatureRadius + plan.lod.heightMeters, + ); + }; + const inViewCone = (eastMeters: number, northMeters: number) => { + if (!useViewCone) return true; + const shellPoint = approximateShellPoint(eastMeters, northMeters); + const direction = Cartesian3.subtract(shellPoint, cameraLocal, new Cartesian3()); + const distance = Cartesian3.magnitude(direction); + if (!Number.isFinite(distance) || distance <= viewConeMinimumDistance) return true; + Cartesian3.normalize(direction, direction); + const approximateDot = Cartesian3.dot(direction, cameraDirectionLocal); + if (Math.abs(approximateDot - viewConeMinimumDot) > 0.15) return approximateDot >= viewConeMinimumDot; + // ENU-plane culling is cheap, but the rendered lattice is bent back to + // the WGS84 shell. Resolve candidates near the cone boundary in world + // space so curvature cannot hide the sector beneath an oblique camera. + const worldDirection = Cartesian3.subtract(toWorld(eastMeters, northMeters), plan.cameraPosition, new Cartesian3()); + Cartesian3.normalize(worldDirection, worldDirection); + return Cartesian3.dot(worldDirection, plan.cameraDirection) >= viewConeMinimumDot; + }; + const segmentStepMeters = Math.max( + plan.grid.stepMeters, + plan.lod.tileSizeKm * 1_000, + plan.grid.radiusMeters / 80, + ); + const addSegmentedLine = ( + axis: "east" | "north", + line: (typeof plan.grid.lines)[number], + hierarchy: "minor" | "major", + ) => { + let positions: Cartesian3[] = []; + let segmentStart = 0; + const flush = () => { + if (positions.length >= 2) { + dataSource.entities.add({ + id: `grid/local/l${plan.lod.index + 1}/${hierarchy}/line-${axis}-${line.index}/part-${segmentStart}`, + polyline: { + positions, + width: hierarchy === "major" ? majorLineWidthPixels : lineWidthPixels, + material: hierarchy === "major" ? majorColor : lineColor, + clampToGround: false, + arcType: ArcType.NONE, + }, + }); + } + positions = []; + }; + let part = 0; + for (let variable = -line.extentMeters; variable < line.extentMeters; variable += segmentStepMeters) { + const next = Math.min(variable + segmentStepMeters, line.extentMeters); + const middle = (variable + next) / 2; + const middleEast = axis === "east" ? line.offsetMeters : middle; + const middleNorth = axis === "east" ? middle : line.offsetMeters; + if (!inViewCone(middleEast, middleNorth)) { + flush(); + part += 1; + segmentStart = part; + continue; + } + const start = axis === "east" ? toWorld(line.offsetMeters, variable) : toWorld(variable, line.offsetMeters); + const end = axis === "east" ? toWorld(line.offsetMeters, next) : toWorld(next, line.offsetMeters); + if (positions.length === 0) positions.push(start); + positions.push(end); + part += 1; + } + flush(); + }; + const skipMinorMajorBoundary = (line: (typeof plan.grid.lines)[number]) => Boolean( + plan.lod.majorLinesEnabled + && hierarchyDefinition + && isLocalMajorLineIndex(line.index, hierarchyDefinition), + ); + for (const line of plan.grid.lines) if (!skipMinorMajorBoundary(line)) addSegmentedLine("east", line, "minor"); + for (const line of plan.grid.lines) if (!skipMinorMajorBoundary(line)) addSegmentedLine("north", line, "minor"); + if (plan.lod.majorLinesEnabled && plan.majorGrid) { + for (const line of plan.majorGrid.lines) addSegmentedLine("east", line, "major"); + for (const line of plan.majorGrid.lines) addSegmentedLine("north", line, "major"); + } + + const radiusSquared = plan.grid.radiusMeters ** 2; + const crossHalf = plan.lod.crossesLengthMeters / 2; + for (let eastIndex = -plan.grid.maximumIndex; eastIndex <= plan.grid.maximumIndex; eastIndex += plan.grid.markerStride) { + const eastMeters = eastIndex * plan.grid.stepMeters; + for (let northIndex = -plan.grid.maximumIndex; northIndex <= plan.grid.maximumIndex; northIndex += plan.grid.markerStride) { + const northMeters = northIndex * plan.grid.stepMeters; + if (eastMeters ** 2 + northMeters ** 2 > radiusSquared) continue; + if (!inViewCone(eastMeters, northMeters)) continue; + const position = toWorld(eastMeters, northMeters); + const address = localSectorAt({ eastMeters, northMeters }, definition); + if (points) points.add({ + id: { kind: "nodedc-grid-intersection", sectorId: address.id }, + position, + color: dotColor, + pixelSize: Math.max(1, Math.round(dotPixelSize)), + }); + if (crosses && crossHalf > 0) { + const eastWest = crosses.add({ + id: { kind: "nodedc-grid-cross", sectorId: address.id }, + positions: [toWorld(eastMeters - crossHalf, northMeters), toWorld(eastMeters + crossHalf, northMeters)], + width: Math.max(1, Math.round(crossWidthPixels)), + }); + const northSouth = crosses.add({ + id: { kind: "nodedc-grid-cross", sectorId: address.id }, + positions: [toWorld(eastMeters, northMeters - crossHalf), toWorld(eastMeters, northMeters + crossHalf)], + width: Math.max(1, Math.round(crossWidthPixels)), + }); + if (eastWest.material?.uniforms) eastWest.material.uniforms.color = crossColor; + if (northSouth.material?.uniforms) northSouth.material.uniforms.color = crossColor; + } + } + } + + if (labels && hierarchyDefinition) { + // Tile labels are primitives, not Entities, and are hard-capped. The + // globally phased stride keeps them stable while camera-driven cone + // culling changes the visible subset. + const maximumLabels = 48; + const tileSizeMeters = hierarchyDefinition.tileSizeMeters; + const maximumTileIndex = Math.ceil(plan.grid.radiusMeters / tileSizeMeters); + const approximateTileCount = Math.PI * (plan.grid.radiusMeters / tileSizeMeters) ** 2; + const labelStride = Math.max(1, Math.ceil(Math.sqrt(approximateTileCount / maximumLabels))); + const firstIndex = -Math.ceil(maximumTileIndex / labelStride) * labelStride; + let labelCount = 0; + for (let eastIndex = firstIndex; eastIndex <= maximumTileIndex && labelCount < maximumLabels; eastIndex += labelStride) { + const eastMeters = (eastIndex + 0.5) * tileSizeMeters; + for (let northIndex = firstIndex; northIndex <= maximumTileIndex && labelCount < maximumLabels; northIndex += labelStride) { + const northMeters = (northIndex + 0.5) * tileSizeMeters; + if (eastMeters ** 2 + northMeters ** 2 > radiusSquared || !inViewCone(eastMeters, northMeters)) continue; + const tile = localMajorTileAt({ eastMeters, northMeters }, hierarchyDefinition); + labels.add({ + id: { kind: "nodedc-grid-major-label", sectorId: tile.id }, + position: toWorld( + eastMeters, + northMeters, + plan.lod.heightMeters + Math.max(20, plan.metersPerPixel * 5), + ), + text: `L${tile.lod} · E${tile.eastIndex >= 0 ? "+" : ""}${tile.eastIndex} N${tile.northIndex >= 0 ? "+" : ""}${tile.northIndex}`, + font: "600 12px Arial", + fillColor: Color.WHITE.withAlpha(0.9), + outlineColor: Color.BLACK.withAlpha(0.72), + outlineWidth: 2, + style: LabelStyle.FILL_AND_OUTLINE, + showBackground: true, + backgroundColor: Color.BLACK.withAlpha(0.52), + backgroundPadding: new Cartesian2(7, 4), + horizontalOrigin: HorizontalOrigin.CENTER, + verticalOrigin: VerticalOrigin.CENTER, + }); + labelCount += 1; + } + } + } + return { dataSource, points, crosses, labels }; +} + +function materializeGraticule(plan: GraticuleGridLayerPlan, serial: number): GridResources { + const dataSource = new CustomDataSource(`nodedc-map-grid-graticule:${serial}`); + const color = Color.fromCssColorString(plan.lod.graticuleColor).withAlpha(clamp(plan.lod.graticuleOpacity / 100, 0, 1)); + const majorColor = gridMajorColor(plan.lod.graticuleColor, plan.lod.graticuleOpacity); + const labels = plan.lod.majorLabelsEnabled && plan.majorStepDegrees !== null ? new LabelCollection() : null; + const clampToGround = plan.lod.heightMeters <= 0; + const height = clampToGround ? 0 : plan.lod.heightMeters; + const sampleStep = Math.min(5, Math.max(0.5, plan.grid.stepDegrees)); + const hierarchyDefinition = plan.majorStepDegrees === null ? null : { + lod: plan.lod.index + 1, + stepDegrees: plan.grid.stepDegrees, + majorStepDegrees: plan.majorStepDegrees, + }; + const line = (id: string, positions: Cartesian3[], major: boolean) => positions.length >= 2 && dataSource.entities.add({ + id, + polyline: { + positions, + width: major + ? clamp(plan.lod.graticuleLineWidthPx * plan.lod.majorLineWidthMultiplier, plan.lod.graticuleLineWidthPx, 16) + : plan.lod.graticuleLineWidthPx, + material: major ? majorColor : color, + clampToGround, + arcType: ArcType.RHUMB, + granularity: graticuleGranularity(sampleStep, clampToGround), + }, + }); + const positionsForParts = ( + parts: Array<{ start: number; end: number }>, + positionAt: (angle: number) => Cartesian3, + ) => parts.length === 0 + ? [] + : [positionAt(parts[0].start), ...parts.map(({ end }) => positionAt(end))]; + for (const { longitude } of plan.grid.meridians) { + const parts = boundedAngularParts(plan.grid.south, plan.grid.north); + const major = Boolean( + plan.lod.majorLinesEnabled + && hierarchyDefinition + && isGraticuleMajorLineValue(longitude, hierarchyDefinition), + ); + line( + `grid/wgs84/l${plan.lod.index + 1}/${major ? "major" : "minor"}/meridian/${longitude}`, + positionsForParts(parts, (latitude) => Cartesian3.fromDegrees(longitude, latitude, height)), + major, + ); + } + for (const latitude of plan.grid.parallels) { + const major = Boolean( + plan.lod.majorLinesEnabled + && hierarchyDefinition + && isGraticuleMajorLineValue(latitude, hierarchyDefinition), + ); + plan.grid.longitudeIntervals.forEach((interval, index) => { + // RHUMB interpolation is correct for parallels, but Cesium rejects an + // exactly antipodal equatorial pair. Intermediate vertices keep every + // segment below 90° without multiplying the number of Cesium entities. + const parts = boundedAngularParts(interval.west, interval.east); + line( + `grid/wgs84/l${plan.lod.index + 1}/${major ? "major" : "minor"}/parallel/${latitude}/interval-${index}`, + positionsForParts(parts, (longitude) => Cartesian3.fromDegrees(longitude, latitude, height)), + major, + ); + }); + } + + if (labels && hierarchyDefinition) { + const maximumLabels = 48; + const majorStep = hierarchyDefinition.majorStepDegrees; + const minimumLatitudeIndex = Math.max(-Math.round(90 / majorStep), Math.floor(plan.grid.south / majorStep)); + const maximumLatitudeIndex = Math.min(Math.round(90 / majorStep) - 1, Math.floor((plan.grid.north - 1e-9) / majorStep)); + const longitudeRanges = plan.grid.longitudeIntervals.map((interval) => ({ + minimum: Math.max(-Math.round(180 / majorStep), Math.floor(interval.west / majorStep)), + maximum: Math.min(Math.round(180 / majorStep) - 1, Math.floor((interval.east - 1e-9) / majorStep)), + })); + const latitudeCount = Math.max(0, maximumLatitudeIndex - minimumLatitudeIndex + 1); + const longitudeCount = longitudeRanges.reduce((sum, range) => sum + Math.max(0, range.maximum - range.minimum + 1), 0); + const labelStride = Math.max(1, Math.ceil(Math.sqrt((latitudeCount * longitudeCount) / maximumLabels))); + const firstLatitudeIndex = Math.ceil(minimumLatitudeIndex / labelStride) * labelStride; + const seen = new Set(); + let labelCount = 0; + for (let latitudeIndex = firstLatitudeIndex; + latitudeIndex <= maximumLatitudeIndex && labelCount < maximumLabels; + latitudeIndex += labelStride) { + const latitude = (latitudeIndex + 0.5) * majorStep; + for (const range of longitudeRanges) { + const firstLongitudeIndex = Math.ceil(range.minimum / labelStride) * labelStride; + for (let longitudeIndex = firstLongitudeIndex; + longitudeIndex <= range.maximum && labelCount < maximumLabels; + longitudeIndex += labelStride) { + const longitude = (longitudeIndex + 0.5) * majorStep; + const tile = graticuleMajorTileAt({ longitude, latitude }, hierarchyDefinition); + if (seen.has(tile.id)) continue; + seen.add(tile.id); + labels.add({ + id: { kind: "nodedc-grid-major-label", sectorId: tile.id }, + position: Cartesian3.fromDegrees(longitude, latitude, height + 100), + text: `L${tile.lod} · X${tile.longitudeIndex >= 0 ? "+" : ""}${tile.longitudeIndex} Y${tile.latitudeIndex >= 0 ? "+" : ""}${tile.latitudeIndex}`, + font: "600 12px Arial", + fillColor: Color.WHITE.withAlpha(0.88), + outlineColor: Color.BLACK.withAlpha(0.72), + outlineWidth: 2, + style: LabelStyle.FILL_AND_OUTLINE, + showBackground: true, + backgroundColor: Color.BLACK.withAlpha(0.48), + backgroundPadding: new Cartesian2(7, 4), + horizontalOrigin: HorizontalOrigin.CENTER, + verticalOrigin: VerticalOrigin.CENTER, + }); + labelCount += 1; + } + } + } + } + return { dataSource, points: null, crosses: null, labels }; +} + +function materializeGridLayer(plan: Exclude, serial: number) { + return plan.mode === "3d" ? materializeLocalGrid(plan, serial) : materializeGraticule(plan, serial); +} + +const DEFAULT_GRID_SELECTION_COLOR = "#35cfff"; + +type GridSelectionStyle = { + fillColor: Color; + outlineColor: Color; + outlineWidthPixels: number; +}; + +function safeGridSelectionColor(value: unknown) { + if (typeof value === "string" && value.trim()) { + try { + const parsed = Color.fromCssColorString(value.trim()); + if (parsed) return parsed; + } catch { + // Persisted profiles may predate strict colour validation. Rendering a + // stable selection is preferable to failing the whole Cesium layer. + } + } + return Color.fromCssColorString(DEFAULT_GRID_SELECTION_COLOR); +} + +function safeGridSelectionPercent(value: unknown, fallback: number) { + const candidate = typeof value === "number" ? value : Number.NaN; + return clamp(Number.isFinite(candidate) ? candidate : fallback, 0, 100) / 100; +} + +function resolveGridSelectionStyle(addressing: GridAddressing): GridSelectionStyle { + const fallbackOutlineWidth = addressing.mode === "3d" + ? clamp(2.5 * addressing.majorLineWidthMultiplier, 3, 12) + : clamp(addressing.lineWidthPixels * addressing.majorLineWidthMultiplier * 1.5, 3, 12); + const requestedOutlineWidth = typeof addressing.selectionOutlineWidthPx === "number" + ? addressing.selectionOutlineWidthPx + : Number.NaN; + const fallbackFillOpacityPercent = addressing.mode === "graticule" + ? 12 + : addressing.volumeEnabled ? 10 : 16; + return { + fillColor: safeGridSelectionColor(addressing.selectionFillColor).withAlpha( + safeGridSelectionPercent(addressing.selectionFillOpacityPercent, fallbackFillOpacityPercent), + ), + outlineColor: safeGridSelectionColor(addressing.selectionOutlineColor).withAlpha( + safeGridSelectionPercent(addressing.selectionOutlineOpacityPercent, 95), + ), + outlineWidthPixels: clamp( + Number.isFinite(requestedOutlineWidth) ? requestedOutlineWidth : fallbackOutlineWidth, + 1, + 12, + ), + }; +} + +function sampledRange(start: number, end: number, maximumStep: number) { + const span = end - start; + const count = Math.max(1, Math.min(64, Math.ceil(Math.abs(span) / Math.max(1e-6, maximumStep)))); + return Array.from({ length: count + 1 }, (_value, index) => start + span * index / count); +} + +function localSelectionPerimeter( + addressing: LocalGridAddressing, + bounds: GridSectorSelection["bounds"], + heightMeters: number, +) { + const maximumStep = Math.max(1_000, Math.min(25_000, addressing.stepMeters / 4)); + const south = sampledRange(bounds.west, bounds.east, maximumStep) + .map((east) => localShellPosition(addressing.enu, east, bounds.south, heightMeters)); + const east = sampledRange(bounds.south, bounds.north, maximumStep).slice(1) + .map((north) => localShellPosition(addressing.enu, bounds.east, north, heightMeters)); + const north = sampledRange(bounds.east, bounds.west, maximumStep).slice(1) + .map((eastMeters) => localShellPosition(addressing.enu, eastMeters, bounds.north, heightMeters)); + const west = sampledRange(bounds.north, bounds.south, maximumStep).slice(1) + .map((northMeters) => localShellPosition(addressing.enu, bounds.west, northMeters, heightMeters)); + return [...south, ...east, ...north, ...west]; +} + +function materializeLocalSelection( + selection: LocalGridSectorSelection, + addressing: LocalGridAddressing, + serial: number, +): GridResources | null { + const width = selection.bounds.east - selection.bounds.west; + const depth = selection.bounds.north - selection.bounds.south; + if (Math.abs(width - addressing.stepMeters) > 1e-6 * addressing.stepMeters + || Math.abs(depth - addressing.stepMeters) > 1e-6 * addressing.stepMeters) return null; + + const dataSource = new CustomDataSource(`nodedc-map-grid-selection-local:${serial}`); + const crosses = new PolylineCollection(); + const selectionStyle = resolveGridSelectionStyle(addressing); + const volumeAvailable = addressing.volumeEnabled + && Number.isFinite(addressing.volumeBandHeightMeters) + && addressing.volumeBandHeightMeters > 0 + && addressing.volumeMaximumHeightMeters > addressing.volumeMinimumHeightMeters; + const floor = volumeAvailable + ? clamp(selection.volume?.floor ?? addressing.volumeMinimumHeightMeters, + addressing.volumeMinimumHeightMeters, addressing.volumeMaximumHeightMeters) + : addressing.heightMeters; + const ceiling = volumeAvailable + ? clamp(selection.volume?.ceiling ?? Math.min( + addressing.volumeMaximumHeightMeters, + addressing.volumeMinimumHeightMeters + addressing.volumeBandHeightMeters, + ), floor, addressing.volumeMaximumHeightMeters) + : addressing.heightMeters; + const floorPerimeter = localSelectionPerimeter(addressing, selection.bounds, floor); + const ceilingPerimeter = localSelectionPerimeter(addressing, selection.bounds, ceiling); + const addLine = (id: string, positions: Cartesian3[]) => { + const polyline = crosses.add({ + id: { kind: "nodedc-grid-selection", sectorId: selection.id, edgeId: id }, + positions, + width: selectionStyle.outlineWidthPixels, + }); + if (polyline.material?.uniforms) polyline.material.uniforms.color = selectionStyle.outlineColor; + }; + addLine("floor", floorPerimeter); + if (ceiling > floor + 1e-6) addLine("ceiling", ceilingPerimeter); + + const corners = [ + [selection.bounds.west, selection.bounds.south], + [selection.bounds.east, selection.bounds.south], + [selection.bounds.east, selection.bounds.north], + [selection.bounds.west, selection.bounds.north], + ] as const; + if (ceiling > floor + 1e-6) { + corners.forEach(([eastMeters, northMeters], index) => addLine(`post-${index}`, [ + localShellPosition(addressing.enu, eastMeters, northMeters, floor), + localShellPosition(addressing.enu, eastMeters, northMeters, ceiling), + ])); + } + + const ceilingSurface = ceilingPerimeter.slice(0, -1); + if (ceilingSurface.length >= 3) dataSource.entities.add({ + id: `${selection.id}/selection-cap`, + polygon: { + hierarchy: new PolygonHierarchy(ceilingSurface), + perPositionHeight: true, + material: selectionStyle.fillColor, + outline: false, + }, + }); + return { dataSource, points: null, crosses, labels: null }; +} + +function materializeGraticuleSelection( + selection: GraticuleGridSectorSelection, + addressing: GraticuleGridAddressing, + serial: number, +): GridResources { + const dataSource = new CustomDataSource(`nodedc-map-grid-selection-graticule:${serial}`); + const selectionStyle = resolveGridSelectionStyle(addressing); + const height = Math.max(0, addressing.heightMeters); + const horizontalStep = Math.min(10, Math.max(0.5, addressing.stepDegrees)); + const verticalStep = Math.min(10, Math.max(0.5, addressing.stepDegrees)); + // A WGS84 cell may logically end at a pole, where every longitude becomes + // the same Cartesian point. Keep the visual cage just inside the singularity + // so Cesium never receives a degenerate polygon while addressing stays exact. + const renderSouth = clamp(selection.bounds.south, -89.9, 89.9); + const renderNorth = clamp(selection.bounds.north, -89.9, 89.9); + const south = sampledRange(selection.bounds.west, selection.bounds.east, horizontalStep) + .map((longitude) => Cartesian3.fromDegrees(longitude, renderSouth, height)); + const east = sampledRange(renderSouth, renderNorth, verticalStep).slice(1) + .map((latitude) => Cartesian3.fromDegrees(selection.bounds.east, latitude, height)); + const north = sampledRange(selection.bounds.east, selection.bounds.west, horizontalStep).slice(1) + .map((longitude) => Cartesian3.fromDegrees(longitude, renderNorth, height)); + const west = sampledRange(renderNorth, renderSouth, verticalStep).slice(1) + .map((latitude) => Cartesian3.fromDegrees(selection.bounds.west, latitude, height)); + const perimeter = [...south, ...east, ...north, ...west]; + const clampToGround = addressing.heightMeters <= 0; + dataSource.entities.add({ + id: `${selection.id}/selection-surface`, + polyline: { + positions: perimeter, + width: selectionStyle.outlineWidthPixels, + material: selectionStyle.outlineColor, + clampToGround, + arcType: ArcType.RHUMB, + granularity: graticuleGranularity(horizontalStep, clampToGround), + }, + polygon: { + hierarchy: new PolygonHierarchy(perimeter.slice(0, -1)), + height: clampToGround ? undefined : height, + heightReference: clampToGround ? HeightReference.CLAMP_TO_GROUND : HeightReference.NONE, + arcType: ArcType.RHUMB, + material: selectionStyle.fillColor, + outline: false, + }, + }); + return { dataSource, points: null, crosses: null, labels: null }; +} + +function materializeGridSelection( + selection: GridSectorSelection, + addressing: GridAddressing, + serial: number, +) { + if (selection.lod !== addressing.lod || selection.mode !== addressing.mode) return null; + return selection.mode === "3d" && addressing.mode === "3d" + ? materializeLocalSelection(selection, addressing, serial) + : selection.mode === "graticule" && addressing.mode === "graticule" + ? materializeGraticuleSelection(selection, addressing, serial) + : null; +} + +export class GridLayerController { + private current: { resources: GridResources; addressing: GridAddressing } | null = null; + private pending: { resources: GridResources; addressing: GridAddressing } | null = null; + private key: string | null = null; + private lodIndex: number | null = null; + private epoch = 0; + private serial = 0; + private removeReadyListener: (() => void) | null = null; + private fallbackTimer: number | null = null; + private retiredResources = new WeakSet(); + private selection: GridSectorSelection | null = null; + private selectionResources: GridResources | null = null; + private selectionKey: string | null = null; + private navigationSelectionId: string | null = null; + + constructor( + private readonly viewer: Viewer, + private readonly onAddressingChange: () => void, + ) {} + + private addressingKey(addressing: GridAddressing | undefined) { + if (!addressing) return "hidden"; + return addressing.mode === "3d" + ? JSON.stringify([ + addressing.mode, + addressing.lod, + addressing.originLatitude, + addressing.originLongitude, + addressing.stepMeters, + addressing.majorStepMeters, + addressing.volumeEnabled, + addressing.volumeMinimumHeightMeters, + addressing.volumeMaximumHeightMeters, + addressing.volumeBandHeightMeters, + ]) + : JSON.stringify([addressing.mode, addressing.lod, addressing.stepDegrees, addressing.majorStepDegrees]); + } + + private syncSelectionStyle(plan: Exclude) { + let changed = false; + const targetLod = plan.lod.index + 1; + for (const layer of [this.current, this.pending]) { + const addressing = layer?.addressing; + if (!addressing || addressing.mode !== plan.mode || addressing.lod !== targetLod) continue; + if ( + addressing.selectionFillColor === plan.lod.selectionFillColor + && addressing.selectionFillOpacityPercent === plan.lod.selectionFillOpacityPercent + && addressing.selectionOutlineColor === plan.lod.selectionOutlineColor + && addressing.selectionOutlineWidthPx === plan.lod.selectionOutlineWidthPx + && addressing.selectionOutlineOpacityPercent === plan.lod.selectionOutlineOpacityPercent + ) continue; + addressing.selectionFillColor = plan.lod.selectionFillColor; + addressing.selectionFillOpacityPercent = plan.lod.selectionFillOpacityPercent; + addressing.selectionOutlineColor = plan.lod.selectionOutlineColor; + addressing.selectionOutlineWidthPx = plan.lod.selectionOutlineWidthPx; + addressing.selectionOutlineOpacityPercent = plan.lod.selectionOutlineOpacityPercent; + changed = true; + } + if (changed) this.refreshSelection(this.pending?.addressing ?? this.current?.addressing); + } + + rebuild(presentation: MapPresentation) { + // Planning is intentionally cheap. Geometry is materialized only after + // key comparison; repeated camera/settings notifications cannot create + // and discard thousands of Cesium objects for an unchanged layer. + const plan = planGridLayer(this.viewer, presentation, this.lodIndex); + if (plan.key === this.key) { + if (plan.mode !== "hidden") this.syncSelectionStyle(plan); + return; + } + this.key = plan.key; + this.lodIndex = plan.lodIndex; + const epoch = ++this.epoch; + this.cancelPending(); + + if (plan.mode === "hidden") { + this.setSelection(null); + this.onAddressingChange(); + if (this.current) this.removeResources(this.current.resources); + this.current = null; + this.viewer.scene.requestRender(); + return; + } + + const addressing: GridAddressing = plan.mode === "3d" ? { + mode: "3d", + lod: plan.lod.index + 1, + stepMeters: plan.grid.stepMeters, + radiusMeters: plan.grid.radiusMeters, + originLatitude: plan.origin.latitude, + originLongitude: plan.origin.longitude, + enu: plan.enu, + inverseEnu: plan.inverseEnu, + heightMeters: plan.lod.heightMeters, + tileSizeMeters: plan.lod.tileSizeKm * 1_000, + majorStepMeters: plan.majorStepMeters, + lineColor: plan.lod.lineColor, + lineOpacity: plan.lod.lineOpacity, + majorLineWidthMultiplier: plan.lod.majorLineWidthMultiplier, + volumeEnabled: plan.lod.volumeEnabled, + volumeMinimumHeightMeters: plan.lod.volumeMinimumHeightMeters, + volumeMaximumHeightMeters: plan.lod.volumeMaximumHeightMeters, + volumeBandHeightMeters: plan.lod.volumeBandHeightMeters, + selectionFillColor: plan.lod.selectionFillColor, + selectionFillOpacityPercent: plan.lod.selectionFillOpacityPercent, + selectionOutlineColor: plan.lod.selectionOutlineColor, + selectionOutlineWidthPx: plan.lod.selectionOutlineWidthPx, + selectionOutlineOpacityPercent: plan.lod.selectionOutlineOpacityPercent, + } : { + mode: "graticule", + lod: plan.lod.index + 1, + stepDegrees: plan.grid.stepDegrees, + majorStepDegrees: plan.majorStepDegrees, + heightMeters: plan.lod.heightMeters, + lineWidthPixels: plan.lod.graticuleLineWidthPx, + lineColor: plan.lod.graticuleColor, + lineOpacity: plan.lod.graticuleOpacity, + majorLineWidthMultiplier: plan.lod.majorLineWidthMultiplier, + selectionFillColor: plan.lod.selectionFillColor, + selectionFillOpacityPercent: plan.lod.selectionFillOpacityPercent, + selectionOutlineColor: plan.lod.selectionOutlineColor, + selectionOutlineWidthPx: plan.lod.selectionOutlineWidthPx, + selectionOutlineOpacityPercent: plan.lod.selectionOutlineOpacityPercent, + }; + const activeAddressing = this.pending?.addressing ?? this.current?.addressing; + if (activeAddressing && this.addressingKey(activeAddressing) !== this.addressingKey(addressing)) { + const selectionTargetsNextLod = Boolean( + this.selection + && this.selection.lod === addressing.lod + && this.selection.mode === addressing.mode + && this.selection.lod !== activeAddressing.lod, + ); + const selectionIsNavigating = Boolean( + this.selection + && this.navigationSelectionId === this.selection.id, + ); + if (selectionTargetsNextLod) this.navigationSelectionId = null; + if (!selectionTargetsNextLod && !selectionIsNavigating) { + this.setSelection(null); + this.onAddressingChange(); + } + } + const resources = materializeGridLayer(plan, ++this.serial); + const next = { resources, addressing }; + this.pending = next; + this.mountResources(resources); + let readyFrames = 0; + const commit = () => { + if (epoch !== this.epoch || this.pending !== next) return; + this.removeReadyListener?.(); + this.removeReadyListener = null; + if (this.fallbackTimer !== null) window.clearTimeout(this.fallbackTimer); + this.fallbackTimer = null; + const previous = this.current; + this.current = next; + this.pending = null; + if (previous && previous !== next) this.removeResources(previous.resources); + this.refreshSelection(next.addressing); + this.viewer.scene.requestRender(); + }; + this.removeReadyListener = this.viewer.scene.postRender.addEventListener(() => { + readyFrames = this.viewer.dataSourceDisplay.ready ? readyFrames + 1 : 0; + if (readyFrames >= 2) commit(); + else this.viewer.scene.requestRender(); + }); + this.fallbackTimer = window.setTimeout(commit, 2_000); + this.viewer.scene.requestRender(); + } + + pick(worldPosition: Cartesian3): GridSectorSelection | null { + const addressing = this.pending?.addressing ?? this.current?.addressing; + if (!addressing) return null; + if (addressing.mode === "3d") { + const ellipsoid = this.viewer.scene.globe.ellipsoid; + const surface = ellipsoid.scaleToGeodeticSurface(worldPosition, new Cartesian3()) ?? worldPosition; + const normal = ellipsoid.geodeticSurfaceNormal(surface, new Cartesian3()); + const localSurface = Matrix4.multiplyByPoint(addressing.inverseEnu, surface, new Cartesian3()); + const localNormal = Matrix4.multiplyByPointAsVector(addressing.inverseEnu, normal, new Cartesian3()); + // Geometry is addressed on the origin's ENU tangent plane and then + // reprojected along the WGS84 normal. Intersect that normal with the + // same plane so analytic picking returns the exact source cell even at + // the outer edge of a 1,000 km LOD. + const normalScale = Math.abs(localNormal.z) > 1e-9 ? -localSurface.z / localNormal.z : 0; + const local = new Cartesian3( + localSurface.x + localNormal.x * normalScale, + localSurface.y + localNormal.y * normalScale, + 0, + ); + if (local.x ** 2 + local.y ** 2 > addressing.radiusMeters ** 2) return null; + const definition = { + lod: addressing.lod, + originLatitude: addressing.originLatitude, + originLongitude: addressing.originLongitude, + stepMeters: addressing.stepMeters, + }; + const address = localSectorAt({ eastMeters: local.x, northMeters: local.y }, definition); + const hierarchyDefinition = addressing.majorStepMeters === null + ? definition + : { ...definition, tileSizeMeters: addressing.majorStepMeters }; + const summary = localSectorSummary(address, hierarchyDefinition); + const cartographic = Cartographic.fromCartesian(worldPosition); + const volumeAvailable = addressing.volumeEnabled + && Number.isFinite(addressing.volumeBandHeightMeters) + && addressing.volumeBandHeightMeters > 0 + && addressing.volumeMaximumHeightMeters > addressing.volumeMinimumHeightMeters; + const altitudeMeters = clamp( + Number(cartographic?.height ?? addressing.volumeMinimumHeightMeters), + addressing.volumeMinimumHeightMeters, + Math.max(addressing.volumeMinimumHeightMeters, addressing.volumeMaximumHeightMeters - 1e-6), + ); + const volumeAddress = volumeAvailable ? localVolumeAt({ + eastMeters: local.x, + northMeters: local.y, + altitudeMeters, + }, { + ...definition, + altitudeFloorMeters: addressing.volumeMinimumHeightMeters, + altitudeCeilingMeters: addressing.volumeMaximumHeightMeters, + altitudeBandMeters: addressing.volumeBandHeightMeters, + }) : null; + return { + ...summary, + mode: "3d", + units: "meters-enu", + volume: volumeAddress ? { + id: volumeAddress.id, + index: volumeAddress.bandIndex, + floor: volumeAddress.altitudeFloorMeters, + ceiling: volumeAddress.altitudeCeilingMeters, + bandHeight: volumeAddress.altitudeBandMeters, + } : null, + }; + } + const cartographic = Cartographic.fromCartesian(worldPosition); + const address = graticuleSectorAt({ + longitude: CesiumMath.toDegrees(cartographic.longitude), + latitude: CesiumMath.toDegrees(cartographic.latitude), + }, { lod: addressing.lod, stepDegrees: addressing.stepDegrees }); + const definition = addressing.majorStepDegrees === null + ? { lod: addressing.lod, stepDegrees: addressing.stepDegrees } + : { lod: addressing.lod, stepDegrees: addressing.stepDegrees, majorStepDegrees: addressing.majorStepDegrees }; + const summary = graticuleSectorSummary(address, definition); + return { + ...summary, + mode: "graticule", + units: "degrees-wgs84", + volume: null, + }; + } + + setSelection(selection: GridSectorSelection | null) { + if (!selection || selection.id !== this.navigationSelectionId) this.navigationSelectionId = null; + this.selection = selection; + this.refreshSelection(this.pending?.addressing ?? this.current?.addressing); + } + + navigateToSelection(selection: GridSectorSelection) { + this.navigationSelectionId = selection.id; + this.selection = selection; + this.refreshSelection(this.pending?.addressing ?? this.current?.addressing); + } + + getActiveLod() { + return (this.pending?.addressing ?? this.current?.addressing)?.lod ?? null; + } + + destroy() { + this.epoch += 1; + this.cancelPending(); + if (this.current) this.removeResources(this.current.resources); + this.current = null; + this.selection = null; + this.navigationSelectionId = null; + this.clearSelectionResources(); + } + + private mountResources(resources: GridResources) { + const add = this.viewer.dataSources.add(resources.dataSource); + void add.then((dataSource) => { + if (this.retiredResources.has(resources) && !this.viewer.isDestroyed()) { + this.viewer.dataSources.remove(dataSource, true); + } + }).catch(() => undefined); + if (resources.points) this.viewer.scene.primitives.add(resources.points); + if (resources.crosses) this.viewer.scene.primitives.add(resources.crosses); + if (resources.labels) this.viewer.scene.primitives.add(resources.labels); + } + + private removeResources(resources: GridResources) { + this.retiredResources.add(resources); + this.viewer.dataSources.remove(resources.dataSource, true); + if (resources.points) this.viewer.scene.primitives.remove(resources.points); + if (resources.crosses) this.viewer.scene.primitives.remove(resources.crosses); + if (resources.labels) this.viewer.scene.primitives.remove(resources.labels); + } + + private refreshSelection(addressing: GridAddressing | undefined) { + const key = this.selection && addressing ? JSON.stringify([ + this.selection.id, + this.selection.volume?.id ?? null, + this.addressingKey(addressing), + addressing.heightMeters, + addressing.lineColor, + addressing.lineOpacity, + addressing.majorLineWidthMultiplier, + addressing.selectionFillColor, + addressing.selectionFillOpacityPercent, + addressing.selectionOutlineColor, + addressing.selectionOutlineWidthPx, + addressing.selectionOutlineOpacityPercent, + addressing.mode === "3d" ? [ + addressing.volumeEnabled, + addressing.volumeMinimumHeightMeters, + addressing.volumeMaximumHeightMeters, + addressing.volumeBandHeightMeters, + ] : addressing.lineWidthPixels, + ]) : null; + if (key === this.selectionKey) return; + this.selectionKey = key; + const previous = this.selectionResources; + const next = this.selection && addressing + ? materializeGridSelection(this.selection, addressing, ++this.serial) + : null; + this.selectionResources = next; + if (next) this.mountResources(next); + if (previous) this.removeResources(previous); + this.viewer.scene.requestRender(); + } + + private clearSelectionResources() { + this.selectionKey = null; + if (this.selectionResources) this.removeResources(this.selectionResources); + this.selectionResources = null; + } + + private cancelPending() { + this.removeReadyListener?.(); + this.removeReadyListener = null; + if (this.fallbackTimer !== null) window.clearTimeout(this.fallbackTimer); + this.fallbackTimer = null; + if (this.pending) this.removeResources(this.pending.resources); + this.pending = null; + } +} diff --git a/apps/catalog/src/mapCesiumRuntimeLayers.ts b/apps/catalog/src/mapCesiumRuntimeLayers.ts new file mode 100644 index 0000000..b2b133b --- /dev/null +++ b/apps/catalog/src/mapCesiumRuntimeLayers.ts @@ -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(); + +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 = [ + ``, + ` 0 + ? ` stroke="${outlineColor.toCssColorString()}" stroke-width="${safeOutlineWidth}"/>` + : "/>", + "", + ].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 = { + primitive: GroundPrimitive | GroundPolylinePrimitive; + parts: TPart[]; +}; + +export type HGeoZoneProjectionLayer = { + geometryKey: string; + styleKey: string; + hideCameraHeightMeters: number | null; + fills: Array>; + outlines: Array>; +}; + +function hGeoZoneRingPositions(ring: Array<[number, number]>) { + return normalizeHGeoZoneRing(ring) + .map(([longitude, latitude]) => Cartesian3.fromDegrees(longitude, latitude, 0)); +} + +function hGeoZoneHierarchy(polygon: Array>) { + 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(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, + faultedGeometryKeys: Set, +) { + 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) { + 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( + batches: Array>, + 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, + bindings: MapRuntimeBinding[], + presentationProfiles: MapPresentationProfile[], + presentationFilters: MapPresentationFilters, + faultedGeometryKeys: Set, +) { + 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, + hGeoZoneLayers: Map, + bindings: MapRuntimeBinding[], + presentationProfiles: MapPresentationProfile[], + presentationFilters: MapPresentationFilters, + faultedHGeoZoneGeometryKeys: Set, +) { + 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(); + 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(); +} + diff --git a/apps/catalog/src/mapGridInspectorSections.tsx b/apps/catalog/src/mapGridInspectorSections.tsx new file mode 100644 index 0000000..6934222 --- /dev/null +++ b/apps/catalog/src/mapGridInspectorSections.tsx @@ -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> = [ + { 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) => void; + selectedGridLod: string; + setSelectedGridLod: Dispatch>; + selectedGridLodIndex: number; + activeGridLod: SectorGridLodProfile; + minimumGridLodHeight: number; + maximumGridLodHeight: number; + updateGridLod: (patch: Partial) => void; + updateGridVolumeRange: (patch: Partial>) => void; + activeGraticuleMajorStepDegrees: number | null; + selectedGridSector: GridSectorSelection | null; + gridSectorCopyState: GridSectorCopyState; + copySelectedGridSectorId: () => Promise; + mapRendererReady: boolean; + focusGridMajorTile: (tile: GridMajorTileSelection) => void; + selectedGridParentLod: GridSectorSelection | null; + sectorGridLodProfiles: SectorGridLodProfile[]; + focusGridSector: (sector: GridSectorSelection | null) => void; + selectedGridNeighborTargets: Record; + selectedGridSectorProfile: SectorGridLodProfile | null; + selectedGridVolumeTargets: { above: GridSectorSelection | null; below: GridSectorSelection | null }; +}): MapInspectorSection[] { + return [ + { + id: "map-grid", + label: "Сетка и LOD", + description: "first adapter control", + group: "Слои", + icon: , + content:
+ Фиксированная московская ENU-адресация задаёт неизменные сектора на LOD 1–3. LOD 4–5 используют глобальную WGS84-гратику́лу; камера выбирает только LOD и видимую область. + updateMapSettings({ gridVisible })} /> + updateMapSettings({ grid3dEnabled })} /> + updateMapSettings({ gridGraticuleEnabled })} /> + updateMapSettings({ gridLodEnabled })} /> + updateMapSettings({ gridRebuildOnMoveEnd })} /> + Fixed ENU · WGS84 + value.toFixed(6)} onChange={(gridCenterLatitude) => updateMapSettings({ gridCenterLatitude })} /> + value.toFixed(6)} onChange={(gridCenterLongitude) => updateMapSettings({ gridCenterLongitude })} /> + value === 0 ? "выкл" : `${value} км`} onChange={(gridAutoDisableHeightKm) => updateMapSettings({ gridAutoDisableHeightKm })} /> +
+ ({ value: String(index), label: `LOD ${index + 1}` }))} label="Уровень детализации сетки" onChange={setSelectedGridLod} /> +
+ `${value} км`} onChange={(maxHeightKm) => updateGridLod({ maxHeightKm })} /> + {selectedGridLodIndex === 4 ? Последний LOD остаётся активным выше своего порога до общего автовыключения. : null} + 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), + }), + })} + /> + `${value} м`} onChange={(heightMeters) => updateGridLod({ heightMeters })} /> + `${value}°`} onChange={(max3dViewAngleDegrees) => updateGridLod({ max3dViewAngleDegrees })} /> + `${value} км`} + onChange={(stepKm) => updateGridLod({ + stepKm, + tileSizeKm: normalizedMajorTileSizeKm(stepKm, Math.max(stepKm, activeGridLod.tileSizeKm)), + radiusKm: Math.min(activeGridLod.radiusKm, stepKm * MAX_LOCAL_GRID_INDEX), + })} + /> + `${value} км`} + onChange={(tileSizeKm) => updateGridLod({ tileSizeKm: normalizedMajorTileSizeKm(activeGridLod.stepKm, tileSizeKm) })} + /> + Major-тайл содержит целое число ENU-секторов. Для гратикулы major-шаг равен пяти minor-шагам. + updateGridLod({ + majorLinesEnabled, + majorLabelsEnabled: majorLinesEnabled && activeGridLod.majorLabelsEnabled, + })} + /> + updateGridLod({ majorLabelsEnabled })} /> + `×${value.toFixed(1)}`} onChange={(majorLineWidthMultiplier) => updateGridLod({ majorLineWidthMultiplier })} /> + Прозрачность major-линий наследует прозрачность линий текущего LOD. + {activeGridLod.mode === "graticule" && activeGridLod.majorLinesEnabled && activeGraticuleMajorStepDegrees === null + ? Major-разметка недоступна для этого шага: пять minor-интервалов должны точно делить 90°-квадрант. + : null} + `${value} км`} onChange={(radiusKm) => updateGridLod({ radiusKm })} /> + `${value} м`} onChange={(lineDiameterMeters) => updateGridLod({ lineDiameterMeters })} /> + updateGridLod({ lineColor })} /> + `${value}%`} onChange={(lineOpacity) => updateGridLod({ lineOpacity })} /> + updateGridLod({ dotsEnabled })} /> + `${value} м`} onChange={(dotsDiameterMeters) => updateGridLod({ dotsDiameterMeters })} /> + updateGridLod({ dotsColor })} /> + `${value}%`} onChange={(dotsOpacity) => updateGridLod({ dotsOpacity })} /> + updateGridLod({ crossesEnabled })} /> + `${value} м`} onChange={(crossesLengthMeters) => updateGridLod({ crossesLengthMeters })} /> + `${value} м`} onChange={(crossesWidthMeters) => updateGridLod({ crossesWidthMeters })} /> + updateGridLod({ crossesColor })} /> + `${value}%`} onChange={(crossesOpacity) => updateGridLod({ crossesOpacity })} /> + `${value}°`} onChange={(graticuleStepDegrees) => updateGridLod({ graticuleStepDegrees: normalizedGraticuleStepDegrees(graticuleStepDegrees) })} /> + `${value} px`} onChange={(graticuleLineWidthPx) => updateGridLod({ graticuleLineWidthPx })} /> + updateGridLod({ graticuleColor })} /> + `${value}%`} onChange={(graticuleOpacity) => updateGridLod({ graticuleOpacity })} /> + {activeGridLod.mode === "3d" ? <> + updateGridLod({ volumeEnabled })} /> + `${value} м WGS84`} + onChange={(volumeMinimumHeightMeters) => updateGridVolumeRange({ volumeMinimumHeightMeters })} + /> + `${value} м WGS84`} + onChange={(volumeMaximumHeightMeters) => updateGridVolumeRange({ volumeMaximumHeightMeters })} + /> + `${value} м`} + onChange={(volumeBandHeightMeters) => updateGridVolumeRange({ volumeBandHeightMeters })} + /> + Горизонтальный ID сектора остаётся стабильным. Высотный band добавляется как отдельный адрес внутри выбранной ENU-ячейки. + : null} + + updateGridLod({ selectionFillColor })} + /> + + `${value}%`} + onChange={(selectionFillOpacityPercent) => updateGridLod({ selectionFillOpacityPercent })} + /> + + updateGridLod({ selectionOutlineColor })} + /> + + `${value} px`} + onChange={(selectionOutlineWidthPx) => updateGridLod({ selectionOutlineWidthPx })} + /> + `${value}%`} + onChange={(selectionOutlineOpacityPercent) => updateGridLod({ selectionOutlineOpacityPercent })} + /> + Оформление применяется к выбранному сектору текущего LOD. +
+ {selectedGridSector?.id ?? "Нажмите сектор на карте"} + {selectedGridSector ? <> + + {gridSectorCopyState === "error" ? Не удалось записать ID в буфер обмена. : null} +
+ {selectedGridSector.mode === "3d" ? "Local ENU" : "WGS84 graticule"} · LOD {selectedGridSector.lod} + {selectedGridSector.label} + {gridSectorBoundsLabel(selectedGridSector)} + {gridSectorCenterLabel(selectedGridSector)} + {formatGridSectorArea(selectedGridSector.areaSquareMeters)} +
+ {selectedGridSector.parentMajorTile ?
+ Parent major tile · {selectedGridSector.parentMajorTile.label} + {selectedGridSector.parentMajorTile.id} + {selectedGridSector.parentMajorTile.minorPerSide} × {selectedGridSector.parentMajorTile.minorPerSide} · {selectedGridSector.parentMajorTile.childCount} дочерних секторов · {formatGridSectorArea(selectedGridSector.parentMajorTile.areaSquareMeters)} + +
: Parent major tile выключен или недоступен для текущей топологии.} +
+ Следующий LOD + {selectedGridParentLod ? <> + {selectedGridParentLod.id} + + : {selectedGridSector.lod >= sectorGridLodProfiles.length + ? "Верхний уровень иерархии" + : `LOD ${selectedGridSector.lod + 1} меняет систему адресации`}} +
+
+ {GRID_SECTOR_DIRECTIONS.map(({ id, label }) => { + const target = selectedGridNeighborTargets[id]; + return
+ + {target?.id ?? "Граница адресного пространства"} +
; + })} +
+ {selectedGridSector.mode === "3d" && selectedGridSectorProfile ?
+ {selectedGridSectorProfile.volumeEnabled ? "Включён" : "Выключен"} + {selectedGridSector.volume?.floor ?? selectedGridSectorProfile.volumeMinimumHeightMeters} м WGS84 + {selectedGridSector.volume?.ceiling ?? selectedGridSectorProfile.volumeMaximumHeightMeters} м WGS84 + {selectedGridSector.volume?.bandHeight ?? selectedGridSectorProfile.volumeBandHeightMeters} м + {selectedGridSector.volume ? {selectedGridSector.volume.id} : null} + {selectedGridSectorProfile.volumeEnabled ?
+ + +
: null} +
: null} + : Кликните ячейку, чтобы получить устойчивый адрес, геометрию и навигацию по соседям.} +
+
, + }, + ]; +} diff --git a/apps/catalog/src/mapGridPolicy.d.mts b/apps/catalog/src/mapGridPolicy.d.mts index 04c0dcc..b204baf 100644 --- a/apps/catalog/src/mapGridPolicy.d.mts +++ b/apps/catalog/src/mapGridPolicy.d.mts @@ -1,4 +1,4 @@ -import type { MapPresentation } from "./CesiumMapRenderer.js"; +import type { MapPresentation } from "./mapRendererContract.js"; export type GridLodMode = "3d" | "graticule"; export type GridLodProfile = { diff --git a/apps/catalog/src/mapInspectorSections.tsx b/apps/catalog/src/mapInspectorSections.tsx new file mode 100644 index 0000000..95be1c6 --- /dev/null +++ b/apps/catalog/src/mapInspectorSections.tsx @@ -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) => void; +type UpdatePresentationProfile = ( + profileId: string, + updater: (profile: MapPresentationProfile) => MapPresentationProfile, +) => void; +type UpdatePresentationStyle = ( + profileId: string, + styleId: string, + patch: Partial, +) => void; +type GatewayCheckState = "idle" | "checking" | "ready" | "stale" | "error"; + +const providerStateLabel: Record = { + 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: , + content: <> + Cesium World Imagery + Текущий официальный provider. Другие provider-слои появятся только после отдельного asset-контракта Platform. + Imagery: {providerStateLabel[providerStatus.imagery]} · Terrain: {providerStateLabel[providerStatus.terrain]} · 3D: {providerStateLabel[providerStatus.buildings]} + {Object.entries(providerStatus.errors).map(([provider, error]) => {provider}: {error})} + Рельеф — отдельный слой под imagery. + updateMapSettings({ terrainEnabled })} /> + `${(value / 100).toFixed(2)}×`} onChange={(value) => updateMapSettings({ terrainExaggeration: value / 100 })} /> + updateMapSettings({ monochrome })} /> + updateMapSettings({ monochromeColor })} /> + `${value}%`} onChange={(imageryBrightness) => updateMapSettings({ imageryBrightness })} /> + `${value}%`} onChange={(imageryContrast) => updateMapSettings({ imageryContrast })} /> + `${value}%`} onChange={(imagerySaturation) => updateMapSettings({ imagerySaturation })} /> + `${value}%`} onChange={(imageryGamma) => updateMapSettings({ imageryGamma })} /> + `${value}°`} onChange={(imageryHue) => updateMapSettings({ imageryHue })} /> + `${value}%`} onChange={(imageryAlpha) => updateMapSettings({ imageryAlpha })} /> + updateMapSettings({ globeColor })} /> + updateMapSettings({ backgroundColor })} /> + , + }, + { + id: "map-atmosphere", + label: "Атмосфера и освещение", + description: "scene / color correction", + group: "Карта", + icon: , + content: <> + updateMapSettings({ atmosphereEnabled })} /> + `${value}%`} onChange={(atmosphereHue) => updateMapSettings({ atmosphereHue })} /> + `${value}%`} onChange={(atmosphereSaturation) => updateMapSettings({ atmosphereSaturation })} /> + `${value}%`} onChange={(atmosphereBrightness) => updateMapSettings({ atmosphereBrightness })} /> + updateMapSettings({ fogEnabled })} /> + `${(value / 10000).toFixed(4)}`} onChange={(fogDensity) => updateMapSettings({ fogDensity })} /> + updateMapSettings({ sunEnabled })} /> + `${value}:00 UTC`} onChange={(sunHour) => updateMapSettings({ sunHour })} /> + `${value}%`} onChange={(sunIntensity) => updateMapSettings({ sunIntensity })} /> + updateMapSettings({ shadowsEnabled })} /> + , + }, + { + id: "map-buildings", + label: "3D здания", + description: "3D Tiles / detail", + group: "Карта", + icon: , + content: <> + updateMapSettings({ buildingsVisible })} /> + updateMapSettings({ buildingsColor })} /> + `${value}%`} onChange={(value) => updateMapSettings({ buildingsOpacity: value / 100 })} /> + `SSE ${value}`} onChange={(buildingsDetail) => updateMapSettings({ buildingsDetail })} /> + , + }, + ]; +} + +export function buildMapPresentationInspectorSections({ + presentationProfiles, + referenceLayers, + setReferenceLayers, + updatePresentationProfile, + updatePresentationStyle, +}: { + presentationProfiles: MapPresentationProfile[]; + referenceLayers: MapReferenceLayer[]; + setReferenceLayers: Dispatch>; + 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: , + content: <> + Профиль принадлежит этой странице Application и управляется тем же provider-neutral MCP-контрактом. Исходный API в настройках отсутствует. + {referenceLayer ? ( + setReferenceLayers((current) => current.map((layer) => ( + layer.id === referenceLayer.id ? { ...layer, visible } : layer + )))} + /> + ) : null} + {profile.target.variant === "surface-fill" && <> + HGeoZone · ground projection + {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
+ updatePresentationStyle(profile.id, style.id, { color })} /> + `${value}%`} onChange={(value) => updatePresentationStyle(profile.id, style.id, { opacity: value / 100 })} /> +
; + })} + updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineColor } }) : current)} /> + `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineOpacity: value / 100 } }) : current)} /> + `${value} px`} onChange={(outlineWidthPx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineWidthPx } }) : current)} /> + } + {profile.target.variant === "elevated-spike" && <> + `${value} м`} onChange={(stemHeightMeters) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, stemHeightMeters } }) : current)} /> + `${value} px`} onChange={(headSizePx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, headSizePx } }) : current)} /> + `${value} px`} onChange={(stemWidthPx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, stemWidthPx } }) : current)} /> + } + updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, mode } }))} + /> + `${value} px`} onChange={(sizePx) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, sizePx } }))} /> + `${value} px`} onChange={(offsetX) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, offsetX } }))} /> + `${value} px`} onChange={(offsetY) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, offsetY } }))} /> + `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, hideCameraHeightMeters } }))} /> + `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, hideCameraHeightMeters } }))} /> + updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundColor } }))} /> + `${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: , + content: <> + Цвета назначены семантическим классам после нормализации данных. Здесь нет названий provider-статусов и привязки к транспорту. + {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
+ updatePresentationStyle(profile.id, style.id, { color })} /> + `${value}%`} onChange={(value) => updatePresentationStyle(profile.id, style.id, { opacity: value / 100 })} /> +
; + })} + , + }]), + ]; + }), + ]; +} + +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>; + spiralRunning: boolean; + selectSpiralPreset: (presetId: CameraSurveySelection) => void; + spiralHeightMeters: number; + setSpiralHeightMeters: Dispatch>; + spiralSpeedMetersPerSecond: number; + setSpiralSpeedMetersPerSecond: Dispatch>; + spiralPitchMetersPerTurn: number; + setSpiralPitchMetersPerTurn: Dispatch>; + spiralTargetRadiusMeters: number; + setSpiralTargetRadiusMeters: Dispatch>; + setSpiralPresetId: Dispatch>; + 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; + 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: , + content: <> + + {animationModeEnabled ? <> + Стартовая точка берётся из текущей позиции камеры. Камера смотрит почти в надир, а маршрут ждёт текущие tiles перед продолжением. Движение идёт по региональной геодезической спирали WGS84 до выбранного радиуса. + + У текущего OSM Buildings подтверждено {OSM_BUILDINGS_OBSERVED_BAND_COUNT} иерархических bands. Десять профилей управляют высотой и покрытием; фактический LOD Cesium выбирает по SSE, viewport и расстоянию. + Imagery · Terrain · OSM Buildings + formatMetricDistance(10 ** value)} + onChange={(value) => { + setSpiralPresetId("custom"); + setSpiralHeightMeters(valueFromLogarithmicControl(value)); + }} + /> + formatMetricSpeed(10 ** value)} + onChange={(value) => { + setSpiralPresetId("custom"); + setSpiralSpeedMetersPerSecond(valueFromLogarithmicControl(value)); + }} + /> + formatMetricDistance(10 ** value)} + onChange={(value) => { + setSpiralPresetId("custom"); + setSpiralPitchMetersPerTurn(valueFromLogarithmicControl(value)); + }} + /> + formatMetricDistance(10 ** value)} + onChange={(value) => { + setSpiralPresetId("custom"); + setSpiralTargetRadiusMeters(valueFromLogarithmicControl(value)); + }} + /> + Расчётное движение без ожидания сети: {formatDuration(cameraSurveySpiralDistance(spiralTargetRadiusMeters, spiralPitchMetersPerTurn) / spiralSpeedMetersPerSecond)}. Tile waits и автоматическое сужение шага под viewport увеличат фактическое время. + {!spiralCanStart && !spiralRunning ? Подготовка: imagery — {providerStateLabel[providerStatus.imagery]}, terrain — {providerStateLabel[providerStatus.terrain]}, OSM Buildings — {providerStateLabel[providerStatus.buildings]}, TileCache — {spiralTileCacheReady ? "готов" : gatewayHealth?.cache?.atCapacity ? "заполнен" : gatewayCheckState === "checking" ? "проверяется" : "недоступен для записи"}. : null} + + {spiralRunning ? Камера движется от исходной точки. Выключение режима, уход со страницы или reload остановят сессию. : null} + {spiralMessage ? {spiralMessage} : null} + : null} + , + }, + { + id: "map-cache", + label: "TileCache", + description: "Platform Map Gateway", + group: "Хранение", + icon: , + content: <> + Общий persistent cache Platform: он не принадлежит приложению, странице или пользователю. + + Cache hit отдаётся как есть; новый tile записывается только при miss. + + {mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Live + Cache · append-only" : "Live + Cache · обновление разрешено" : "Live без persistent cache"} + Platform Map Gateway + {gatewayEndpoint ?? "runtime profile · не проверено"} + {liveCacheSummary} + {gatewayHealth?.cache?.writePolicy ?? "append-only · проверяется"} + + + Live Cache: {liveCacheSummary} · {gatewayHealth?.cache?.mode ?? "проверяется"} + {transportDiagnostic ? {transportDiagnostic} : null} + {gatewayHealthAge ? {gatewayHealthAge} : null} + {mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Новые miss дописываются; при заполнении объёма Gateway продолжит live-маршрут без удаления прежних tiles." : "Новые запросы этого Application могут явно обновлять уже записанные tiles." : "Real-time: provider остаётся официальным, чтение и запись persistent cache выключены."} + {gatewayHealth?.cache?.atCapacity ? TileCache заполнен: новые tiles показываются live, но не записываются. Существующий cache не удаляется. : null} + {gatewayCheckState === "error" || gatewayCheckState === "stale" ? {gatewayCheckError} : null} + , + }, + { + id: "map-selection", + label: "Выбранная сущность", + description: "selection contract", + group: "Данные", + icon: , + content: <> + {selected?.title ?? "Нет выбора"} + {selected?.kind ?? "—"}{selected?.status ? ` · ${selected.status}` : ""} + , + }, + ]; +} diff --git a/apps/catalog/src/mapPageContract.ts b/apps/catalog/src/mapPageContract.ts new file mode 100644 index 0000000..f712853 --- /dev/null +++ b/apps/catalog/src/mapPageContract.ts @@ -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; + +/** + * 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; +}; + +/** + * 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; + 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"; diff --git a/apps/catalog/src/mapPresentationProfile.d.mts b/apps/catalog/src/mapPresentationProfile.d.mts new file mode 100644 index 0000000..52de81b --- /dev/null +++ b/apps/catalog/src/mapPresentationProfile.d.mts @@ -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; +}; + +export type MapPresentationFilters = Record; + +export function normalizeClientMapPresentationProfiles(profiles: MapPresentationProfile[]): MapPresentationProfile[]; +export function mapPresentationBindingIsAll(bindingId: string, filters: MapPresentationFilters): boolean; +export function mapPresentationFacetValueIsEnabled(facets: Record, field: string, value: string): boolean; +export function normalizeMapPresentationFacetSelections(facets: Record, profile: MapPresentationProfile): Record; +export function toggleMapPresentationFacetSelection(facets: Record, field: string, value: string, availableValues: string[]): Record; +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>; diff --git a/apps/catalog/src/mapPresentationProfile.mjs b/apps/catalog/src/mapPresentationProfile.mjs new file mode 100644 index 0000000..1dd89b4 --- /dev/null +++ b/apps/catalog/src/mapPresentationProfile.mjs @@ -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; +} diff --git a/apps/catalog/src/mapPresentationProfile.ts b/apps/catalog/src/mapPresentationProfile.ts index e522cda..73c7b7f 100644 --- a/apps/catalog/src/mapPresentationProfile.ts +++ b/apps/catalog/src/mapPresentationProfile.ts @@ -1,287 +1,3 @@ -import type { MapRuntimeFact } from "./useMapDataProductRuntime.js"; - -export type MapPresentationFacetValue = { - value: string; - label: string; - order: number; -}; - -export type MapPresentationFacet = { - id: string; - field: string; - label: string; - filterable: boolean; - counter: boolean; - values: MapPresentationFacetValue[]; -}; - -export type MapPresentationStyle = { - id: string; - color: string; - opacity: number; -}; - -export type MapPresentationClass = { - id: string; - label: string; - priority: number; - match: Array<{ field: string; equals: string }>; - styleId: string; - renderable: boolean; -}; - -export type MapPresentationProfile = { - id: string; - version: string; - title: string; - semanticTypes: string[]; - label: { - mode: "subject_id" | "attributes" | "none"; - fields: string[]; - fontWeight: number; - sizePx: number; - color: string; - outlineColor: string; - outlineWidthPx: number; - backgroundColor: string; - backgroundOpacity: number; - paddingX: number; - paddingY: number; - maxLength: number; - offsetX: number; - offsetY: number; - hideCameraHeightMeters: number; - }; - target: ({ - variant: "elevated-spike"; - stemHeightMeters: number; - headSizePx: number; - stemWidthPx: number; - } | { - variant: "surface-fill"; - }) & { - outlineColor: string; - outlineOpacity: number; - outlineWidthPx: number; - hideCameraHeightMeters: number; - }; - facets: MapPresentationFacet[]; - styles: MapPresentationStyle[]; - classes: MapPresentationClass[]; - defaultClassId: string; - sort: Array<{ field: string; order: string[] }>; -}; - -export type MapSubjectFilterState = { - /** False is an explicit empty map state. It must never be normalized to all. */ - visible: boolean; - /** Missing facet = no constraint; an explicitly empty facet = match nothing. */ - facets: Record; -}; - -/** Application view state is keyed by stable binding id, never by editable labels. */ -export type MapPresentationFilters = Record; - -/** - * 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, - field: string, - value: string, -) { - const selected = facets[field]; - return selected === undefined || selected.includes(value); -} - -export function normalizeMapPresentationFacetSelections( - facets: Record, - 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, - 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>; -} - -/** - * `signal_state` and `movement_state` remain orthogonal Data Product facts. - * The operational Map, however, must not present a stale last speed as a - * current movement state. When both canonical facets exist, the movement - * facet is therefore scoped to currently active subjects. Other profiles and - * fields keep their ordinary independent-facet behaviour. - */ -function mapFactParticipatesInFacet( - fact: MapRuntimeFact, - profile: MapPresentationProfile, - facet: MapPresentationFacet, -) { - if (facet.field !== "movement_state") return true; - if (!profile.facets.some((item) => item.field === "signal_state")) return true; - return normalizedFacetValue(fact.attributes.signal_state) === "active"; -} - -function normalizedFacetValue(value: unknown) { - return typeof value === "string" ? value.trim().toLowerCase() : "unknown"; -} - -function sortRank(order: string[], value: string) { - const index = order.indexOf(value); - return index === -1 ? order.length : index; -} +// Stable TypeScript facade for existing `.js` specifiers. Runtime behavior is +// executable JavaScript so browser code and Node behavioral tests share it. +export * from "./mapPresentationProfile.mjs"; diff --git a/apps/catalog/src/mapRendererContract.ts b/apps/catalog/src/mapRendererContract.ts new file mode 100644 index 0000000..f82bd5e --- /dev/null +++ b/apps/catalog/src/mapRendererContract.ts @@ -0,0 +1,241 @@ +import type { + GraticuleSectorSummary, + LocalSectorSummary, +} from "./mapSectorGrid.mjs"; + +/** + * Provider-neutral scene settings consumed by a renderer adapter. + * + * The persisted Map Page contract may use these fields, but it never stores + * Cesium entities, provider endpoints, credentials or raw provider payloads. + */ +export type MapPresentation = { + imagerySource: "cesium-live"; + imageryVisible: boolean; + cacheEnabled: boolean; + cacheNoOverwrite: boolean; + terrainEnabled: boolean; + terrainExaggeration: number; + monochrome: boolean; + monochromeColor: string; + imageryGamma: number; + imageryHue: number; + imageryAlpha: number; + globeColor: string; + backgroundColor: string; + atmosphereEnabled: boolean; + atmosphereHue: number; + atmosphereSaturation: number; + atmosphereBrightness: number; + fogEnabled: boolean; + fogDensity: number; + sunEnabled: boolean; + sunHour: number; + sunIntensity: number; + shadowsEnabled: boolean; + buildingsVisible: boolean; + buildingsColor: string; + buildingsOpacity: number; + buildingsDetail: number; + imageryBrightness: number; + imageryContrast: number; + imagerySaturation: number; + gridVisible: boolean; + gridLodEnabled: boolean; + grid3dEnabled: boolean; + gridGraticuleEnabled: boolean; + gridCenterMode: "fixed"; + gridCenterLatitude: number; + gridCenterLongitude: number; + gridTileSizeKm: number; + gridAutoDisableHeightKm: number; + gridRebuildOnMoveEnd: boolean; + gridLegacyMode: boolean; + gridMax3dViewAngleDegrees: number; + gridHeightMeters: number; + gridLod1MaxHeightKm: number; + gridLod1StepKm: number; + gridLod1Mode: "3d" | "graticule"; + gridLod2MaxHeightKm: number; + gridLod2StepKm: number; + gridLod2Mode: "3d" | "graticule"; + gridLod3MaxHeightKm: number; + gridLod3StepKm: number; + gridLod3Mode: "3d" | "graticule"; + gridLod4MaxHeightKm: number; + gridLod4StepKm: number; + gridLod4Mode: "3d" | "graticule"; + gridLod5StepKm: number; + gridLod5MaxHeightKm: number; + gridLod5Mode: "3d" | "graticule"; + gridRadiusKm: number; + gridLineWidth: number; + gridLineDiameterMeters: number; + gridColor: string; + gridOpacity: number; + gridDotsEnabled: boolean; + gridDotsSize: number; + gridDotsDiameterMeters: number; + gridDotsColor: string; + gridDotsOpacity: number; + gridCrossesEnabled: boolean; + gridCrossesLengthMeters: number; + gridCrossesWidthMeters: number; + gridCrossesColor: string; + gridCrossesOpacity: number; + gridLodProfiles: GridLodProfile[]; + /** One-shot renderer command; it is intentionally excluded from persistence. */ + cacheRefresh: boolean; +}; + +export type GridLodProfile = { + maxHeightKm: number; + stepKm: number; + mode: "3d" | "graticule"; + heightMeters: number; + max3dViewAngleDegrees: number; + tileSizeKm: number; + radiusKm: number; + lineDiameterMeters: number; + lineColor: string; + lineOpacity: number; + dotsEnabled: boolean; + dotsDiameterMeters: number; + dotsColor: string; + dotsOpacity: number; + crossesEnabled: boolean; + crossesLengthMeters: number; + crossesWidthMeters: number; + crossesColor: string; + crossesOpacity: number; + graticuleStepDegrees: number; + graticuleLineWidthPx: number; + graticuleColor: string; + graticuleOpacity: number; + majorLinesEnabled: boolean; + majorLabelsEnabled: boolean; + majorLineWidthMultiplier: number; + volumeEnabled: boolean; + volumeMinimumHeightMeters: number; + volumeMaximumHeightMeters: number; + volumeBandHeightMeters: number; + selectionFillColor: string; + selectionFillOpacityPercent: number; + selectionOutlineColor: string; + selectionOutlineWidthPx: number; + selectionOutlineOpacityPercent: number; +}; + +export type GridVolumeSelection = { + id: string; + index: number; + floor: number; + ceiling: number; + bandHeight: number; +}; + +export type LocalGridSectorSelection = LocalSectorSummary & { + mode: "3d"; + units: "meters-enu"; + volume: GridVolumeSelection | null; +}; + +export type GraticuleGridSectorSelection = GraticuleSectorSummary & { + mode: "graticule"; + units: "degrees-wgs84"; + volume: null; +}; + +export type GridSectorSelection = LocalGridSectorSelection | GraticuleGridSectorSelection; +export type GridMajorTileSelection = NonNullable; + +export type MapCameraView = { + longitude: number; + latitude: number; + height: number; + heading: number; + pitch: number; + roll: number; +}; + +export type CameraSpiralConfig = { + heightAboveGroundMeters: number; + speedMetersPerSecond: number; + pitchMetersPerTurn: number; + targetRadiusMeters?: number; + viewPitchRadians?: number; + waitForTiles?: boolean; +}; + +export type CameraSpiralState = { + running: boolean; + reason?: "stopped" | "mode_disabled" | "renderer_restarted" | "page_hidden" | "render_error" | "spiral_extent_limit" | "spiral_runtime_error" | "terrain_sampling_error" | "tile_loading_timeout" | "tile_loading_error" | "target_radius_reached"; +}; + +export type MapGatewayHealth = { + cache?: { + mode?: string; + writePolicy?: string; + entries?: number; + bytes?: number; + maxBytes?: number; + atCapacity?: boolean; + persistent?: boolean; + }; + diagnostics?: { + cacheHits?: number; + cacheMisses?: number; + cacheRefreshes?: number; + upstreamRequests?: number; + egressRequests?: number; + upstreamFailures?: number; + slowUpstreamRequests?: number; + lastFailure?: string | null; + lastFailureAt?: string | null; + }; + referenceSources?: { + transportStations?: { + profileId?: string; + seedFactCount?: number; + fetchEnabled?: boolean; + cellDegrees?: number; + cachedCellCount?: number; + upstreamRequests?: number; + upstreamFailures?: number; + searchRequests?: number; + searchFailures?: number; + upstreamState?: "idle" | "ready" | "degraded"; + activeFetches?: number; + queuedFetches?: number; + lastRefreshAt?: string | null; + lastFailure?: string | null; + lastFailureAt?: string | null; + }; + }; + ionConfigured?: boolean; +}; + +export type MapProviderState = "loading" | "ready" | "error" | "not-configured"; + +export type MapProviderStatus = { + imagery: MapProviderState; + terrain: MapProviderState; + buildings: MapProviderState; + errors: Partial>; +}; + +/** 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; diff --git a/apps/catalog/src/mapRuntimeIdentity.d.mts b/apps/catalog/src/mapRuntimeIdentity.d.mts new file mode 100644 index 0000000..e827748 --- /dev/null +++ b/apps/catalog/src/mapRuntimeIdentity.d.mts @@ -0,0 +1,4 @@ +import type { MapRuntimeFact } from "./useMapDataProductRuntime.js"; + +export function mapRuntimeFactKey(fact: Pick): string; +export function mapRuntimeEntityId(bindingId: string, fact: Pick): string; diff --git a/apps/catalog/src/mapRuntimeIdentity.mjs b/apps/catalog/src/mapRuntimeIdentity.mjs new file mode 100644 index 0000000..d34c009 --- /dev/null +++ b/apps/catalog/src/mapRuntimeIdentity.mjs @@ -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}`; +} diff --git a/apps/catalog/src/mapSearch.d.mts b/apps/catalog/src/mapSearch.d.mts index 869739c..c066210 100644 --- a/apps/catalog/src/mapSearch.d.mts +++ b/apps/catalog/src/mapSearch.d.mts @@ -1,4 +1,4 @@ -import type { MapDataProductBinding } from "./MapFixturePreview.js"; +import type { MapDataProductBinding } from "./mapPageContract.js"; import type { MapPresentationProfile } from "./mapPresentationProfile.js"; import type { MapRuntimeBinding } from "./useMapDataProductRuntime.js"; diff --git a/apps/catalog/src/mapSectorRuntime.d.mts b/apps/catalog/src/mapSectorRuntime.d.mts new file mode 100644 index 0000000..fdc9cf0 --- /dev/null +++ b/apps/catalog/src/mapSectorRuntime.d.mts @@ -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; diff --git a/apps/catalog/src/mapSectorRuntime.mjs b/apps/catalog/src/mapSectorRuntime.mjs new file mode 100644 index 0000000..d83c6cd --- /dev/null +++ b/apps/catalog/src/mapSectorRuntime.mjs @@ -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; +} diff --git a/apps/catalog/src/mapSectorWorkspace.ts b/apps/catalog/src/mapSectorWorkspace.ts new file mode 100644 index 0000000..d529988 --- /dev/null +++ b/apps/catalog/src/mapSectorWorkspace.ts @@ -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): 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)}°`; +} diff --git a/apps/catalog/src/mapWorkspaceModel.d.mts b/apps/catalog/src/mapWorkspaceModel.d.mts new file mode 100644 index 0000000..6011ec6 --- /dev/null +++ b/apps/catalog/src/mapWorkspaceModel.d.mts @@ -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>; +}; + +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, 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 }; diff --git a/apps/catalog/src/mapWorkspaceModel.mjs b/apps/catalog/src/mapWorkspaceModel.mjs new file mode 100644 index 0000000..22085cc --- /dev/null +++ b/apps/catalog/src/mapWorkspaceModel.mjs @@ -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"; +} diff --git a/apps/catalog/src/mapWorkspaceState.d.mts b/apps/catalog/src/mapWorkspaceState.d.mts new file mode 100644 index 0000000..c876b7f --- /dev/null +++ b/apps/catalog/src/mapWorkspaceState.d.mts @@ -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; + 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; + sectorWindowRect: WorkspaceWindowRect; + subjectCardRect: WorkspaceWindowRect; +}): MapWorkspaceState; +export function mapWorkspaceReducer(state: MapWorkspaceState, action: MapWorkspaceAction): MapWorkspaceState; diff --git a/apps/catalog/src/mapWorkspaceState.mjs b/apps/catalog/src/mapWorkspaceState.mjs new file mode 100644 index 0000000..65292f5 --- /dev/null +++ b/apps/catalog/src/mapWorkspaceState.mjs @@ -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])]; +} diff --git a/apps/catalog/src/useMapDataProductRuntime.ts b/apps/catalog/src/useMapDataProductRuntime.ts index 504e3d0..b47eea9 100644 --- a/apps/catalog/src/useMapDataProductRuntime.ts +++ b/apps/catalog/src/useMapDataProductRuntime.ts @@ -1,5 +1,8 @@ import { useEffect, useMemo, useState } from "react"; -import type { MapDataProductBinding } from "./MapFixturePreview.js"; +import type { MapDataProductBinding } from "./mapPageContract.js"; +import { mapRuntimeFactKey } from "./mapRuntimeIdentity.mjs"; + +export { mapRuntimeEntityId } from "./mapRuntimeIdentity.mjs"; export type DataProductPoint = { type: "Point"; @@ -78,14 +81,6 @@ type BindingState = { const identifier = /^[A-Za-z0-9._:-]{1,160}$/; const cursorPattern = /^(?:0|[1-9]\d*)$/; -function factKey(fact: MapRuntimeFact) { - return `${fact.semanticType}\u0000${fact.sourceId}`; -} - -export function mapRuntimeEntityId(bindingId: string, fact: Pick) { - return `nodedc-runtime:${bindingId}:${fact.semanticType}:${fact.sourceId}`; -} - function isIsoTimestamp(value: unknown): value is string { return typeof value === "string" && !Number.isNaN(Date.parse(value)); } @@ -246,7 +241,7 @@ function replaceSnapshot(current: BindingState, snapshot: SnapshotEnvelope): Bin ...current, cursor: snapshot.cursor, state: "ready", - facts: Object.fromEntries(snapshot.facts.map((fact) => [factKey(fact), fact])), + facts: Object.fromEntries(snapshot.facts.map((fact) => [mapRuntimeFactKey(fact), fact])), }; } @@ -257,8 +252,8 @@ function applyPatch(current: BindingState, patch: PatchEnvelope): BindingState | if (current.cursor !== patch.previousCursor) return null; const facts = { ...current.facts }; for (const operation of patch.operations) { - if (operation.op === "upsert") facts[factKey(operation.fact)] = operation.fact; - else delete facts[`${operation.semanticType}\u0000${operation.sourceId}`]; + if (operation.op === "upsert") facts[mapRuntimeFactKey(operation.fact)] = operation.fact; + else delete facts[mapRuntimeFactKey(operation)]; } return { ...current, facts, cursor: patch.cursor, state: "ready" }; } @@ -266,7 +261,7 @@ function applyPatch(current: BindingState, patch: PatchEnvelope): BindingState | function applyPresentationPatch(current: BindingState, patch: PresentationPatchEnvelope): BindingState { const facts = { ...current.facts }; for (const operation of patch.operations) { - const key = `${operation.semanticType}\u0000${operation.sourceId}`; + const key = mapRuntimeFactKey(operation); if (facts[key]) facts[key] = { ...facts[key], presentationStatus: operation.status }; } return { ...current, facts }; @@ -421,7 +416,7 @@ export function useMapDataProductRuntime({ dataProductId: binding.dataProductId, slotId: binding.slotId, presentationProfileId: binding.presentationProfileId, - facts: Object.values(record.facts).sort((left, right) => factKey(left).localeCompare(factKey(right))), + facts: Object.values(record.facts).sort((left, right) => mapRuntimeFactKey(left).localeCompare(mapRuntimeFactKey(right))), cursor: record.cursor, state: record.state, }; diff --git a/package.json b/package.json index df06e45..984be2a 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "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: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-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", "serve": "node server/catalog-server.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: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-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: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", diff --git a/scripts/inspector-select-contract.test.mjs b/scripts/inspector-select-contract.test.mjs index 036b47a..5b5f643 100644 --- a/scripts/inspector-select-contract.test.mjs +++ b/scripts/inspector-select-contract.test.mjs @@ -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 () => { - const [mapPreview, catalog, coreStyles] = await Promise.all([ - readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"), + const [mapInspector, catalog, coreStyles] = await Promise.all([ + 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("../packages/ui-core/styles.css", import.meta.url), "utf8"), ]); - assert.match(mapPreview, //); + assert.match(mapInspector, //); assert.equal((catalog.match(/ { - const source = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"); - assert.match(source, /class GridLayerController/); - assert.match(source, /dataSourceDisplay\.ready/); - assert.match(source, /fixedGridOrigin\(presentation\)/); - assert.match(source, /Transforms\.eastNorthUpToFixedFrame\(anchor\)/); - assert.match(source, /localSectorAt\(/); - assert.match(source, /gridController\?\.pick\(worldPosition\)/); - assert.match(source, /lod\.stepKm \* 1_000/); - assert.match(source, /lod\.graticuleStepDegrees/); - assert.match(source, /lod\.graticuleLineWidthPx/); - assert.match(source, /arcType: ArcType\.RHUMB/); - assert.match(source, /clampToGround: false/); - assert.match(source, /majorLineWidthMultiplier/); - assert.match(source, /isLocalMajorLineIndex/); - assert.match(source, /isGraticuleMajorLineValue/); - assert.match(source, /new LabelCollection\(\)/); - assert.match(source, /const maximumLabels = 48/); - assert.match(source, /materializeLocalSelection/); - assert.match(source, /localVolumeAt\(/); - assert.match(source, /focusGridSector/); - assert.match(source, /selectedGridSector/); - assert.match(source, /mountResources\(resources\)[\s\S]*?const previous = this\.current;[\s\S]*?removeResources\(previous\.resources\)/); - assert.doesNotMatch(source, /function rebuildElevatedGrid[\s\S]*?entities\.removeAll\(\)/); - assert.doesNotMatch(source, /Math\.min\(40,[\s\S]*?safeRadiusKm \/ safeStepKm/); - assert.doesNotMatch(source, /snapGridCenter\(/); + const [renderer, gridLayer] = await Promise.all([ + readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"), + readFile(new URL("../apps/catalog/src/mapCesiumGridLayer.ts", import.meta.url), "utf8"), + ]); + assert.match(gridLayer, /class GridLayerController/); + assert.match(gridLayer, /dataSourceDisplay\.ready/); + assert.match(gridLayer, /fixedGridOrigin\(presentation\)/); + assert.match(gridLayer, /Transforms\.eastNorthUpToFixedFrame\(anchor\)/); + assert.match(gridLayer, /localSectorAt\(/); + assert.match(renderer, /gridController\?\.pick\(worldPosition\)/); + assert.match(gridLayer, /lod\.stepKm \* 1_000/); + assert.match(gridLayer, /lod\.graticuleStepDegrees/); + assert.match(gridLayer, /lod\.graticuleLineWidthPx/); + assert.match(gridLayer, /arcType: ArcType\.RHUMB/); + assert.match(gridLayer, /clampToGround: false/); + assert.match(gridLayer, /majorLineWidthMultiplier/); + assert.match(gridLayer, /isLocalMajorLineIndex/); + assert.match(gridLayer, /isGraticuleMajorLineValue/); + assert.match(gridLayer, /new LabelCollection\(\)/); + assert.match(gridLayer, /const maximumLabels = 48/); + assert.match(gridLayer, /materializeLocalSelection/); + assert.match(gridLayer, /localVolumeAt\(/); + assert.match(renderer, /focusGridSector/); + assert.match(renderer, /selectedGridSector/); + 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\(/); }); diff --git a/scripts/map-inspector-overlay-state.test.mjs b/scripts/map-inspector-overlay-state.test.mjs index 9ddf108..9c6ab70 100644 --- a/scripts/map-inspector-overlay-state.test.mjs +++ b/scripts/map-inspector-overlay-state.test.mjs @@ -3,17 +3,17 @@ import { readFile } from "node:fs/promises"; import test from "node:test"; test("Cesium elevated targets keep their profile border while labels occlude them inside the scene overlay", async () => { - const [renderer, cesiumDisplay] = await Promise.all([ - readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"), + const [runtimeLayers, cesiumDisplay] = await Promise.all([ + 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"), ]); - assert.match(renderer, /BillboardGraphics/); - assert.match(renderer, /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(renderer, /disableDepthTestDistance: Number\.POSITIVE_INFINITY/); - assert.match(renderer, /outlineWidth: 0,\s*style: LabelStyle\.FILL/); - assert.doesNotMatch(renderer, /style: 2/); + assert.match(runtimeLayers, /BillboardGraphics/); + assert.match(runtimeLayers, /elevatedTargetImage\(\s*color,\s*Color\.fromCssColorString\(target\.outlineColor\)\.withAlpha\(target\.outlineOpacity\),\s*target\.outlineWidthPx,\s*target\.headSizePx/); + assert.match(runtimeLayers, /entity\.point = undefined;\s*entity\.billboard = new BillboardGraphics/); + assert.match(runtimeLayers, /disableDepthTestDistance: Number\.POSITIVE_INFINITY/); + assert.match(runtimeLayers, /outlineWidth: 0,\s*style: LabelStyle\.FILL/); + assert.doesNotMatch(runtimeLayers, /style: 2/); assert.ok(cesiumDisplay.indexOf("new BillboardVisualizer") < cesiumDisplay.indexOf("new LabelVisualizer")); }); diff --git a/scripts/map-object-layers.test.mjs b/scripts/map-object-layers.test.mjs index 113475a..7752475 100644 --- a/scripts/map-object-layers.test.mjs +++ b/scripts/map-object-layers.test.mjs @@ -2,71 +2,92 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -test("Objects menu opens controllable groups from the row and keeps plain bindings as visibility layers", async () => { - const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"); +const source = (path) => readFile(new URL(path, import.meta.url), "utf8"); - assert.match(preview, /const toggleSubjectVisibility = \(bindingId: string\)/); - assert.match(preview, /role=\{hasControls \? "menuitem" : "menuitemcheckbox"\}/); - assert.match(preview, /if \(hasControls\) \{\s*openSubjectWindow\(summary\.bindingId\);\s*close\(\)/); - assert.match(preview, /toggleSubjectVisibility\(summary\.bindingId\)/); - assert.match(preview, /visible: !state\.visible/); - assert.match(preview, /слой скрыт · \$\{summary\.total\} объектов/); - assert.doesNotMatch(preview, /Фильтры и счётчики: \$\{summary\.displayName\}/); +test("Objects menu delegates controlled layers to the workspace reducer", async () => { + const [preview, toolbar, state] = await Promise.all([ + source("../apps/catalog/src/MapFixturePreview.tsx"), + source("../apps/catalog/src/MapWorkspaceToolbar.tsx"), + source("../apps/catalog/src/mapWorkspaceState.mjs"), + ]); + + 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 () => { - const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"); +test("a subject window exists only when the MCP presentation profile exposes controls", async () => { + 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(preview, /if \(!state\?\.window\.open \|\| !hasSubjectWindowControls\(summary\.profile\)\) return null/); - assert.match(preview, /window: \{ \.\.\.state\.window, open: false \}/); + assert.match(model, /export function mapProfileHasSubjectWindowControls/); + assert.match(windows, /!state\?\.window\.open \|\| !mapProfileHasSubjectWindowControls\(summary\.profile\)/); + 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 () => { - 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:not\(\[data-visible\]\)/); }); -test("facet controls and renderer consume the same canonical enabled-value state", async () => { - const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"); +test("facet controls and renderer consume one canonical enabled-value state", async () => { + 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, /facets: profile \? normalizeMapPresentationFacetSelections\(state\.filters, profile\) : state\.filters/); - assert.match(preview, /filters: normalizeMapPresentationFacetSelections\(state\.filters, summary\.profile\)/); - assert.match(preview, /const active = mapPresentationFacetValueIsEnabled\(state\.filters, facet\.field, item\.value\)/); - assert.match(preview, /toggleMapPresentationFacetSelection\(filters, field, value, availableValues\)/); - assert.match(preview, /facet\.values\.map\(\(value\) => value\.value\)/); + assert.match(preview, /normalizeMapPresentationFacetSelections\(state\.filters, profile\)/); + assert.match(preview, /presentationFilters=\{rendererPresentationFilters\}/); + assert.match(windows, /mapPresentationFacetValueIsEnabled\(state\.filters, facet\.field, item\.value\)/); + assert.match(windows, /facet\.values\.map\(\(value\) => value\.value\)/); + assert.match(state, /toggleMapPresentationFacetSelection\(/); }); -test("joined detail aspects do not become independent map layers", async () => { - const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"); +test("joined detail aspects enrich subjects but never become independent renderer layers", async () => { + 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(preview, /runtimeBindings\.filter\(\(binding\) => primaryBindingIds\.has\(binding\.bindingId\)\)/); + assert.match(model, /filter\(\(binding\) => !binding\.joinToBindingId\)/); + assert.match(model, /primaryMapBindingConfigs\(bindingConfigs\)/); assert.match(preview, /runtimeBindings=\{\[\.\.\.sectorScopedPrimaryRuntimeBindings, \.\.\.referenceRuntimeBindings\]\}/); }); -test("bounded map windows keep one stack while layers and settings use their canonical surfaces", async () => { - const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"); +test("bounded map windows share one reducer-owned z-stack while settings and layers keep canonical surfaces", async () => { + 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(preview, /const activateWorkspaceWindow = useCallback/); - assert.match(preview, /onActivate=\{\(\) => activateWorkspaceWindow\(`binding:\$\{summary\.bindingId\}`\)\}/); - assert.doesNotMatch(preview, /onActivate=\{\(\) => openSubjectWindow\(summary\.bindingId\)\}/); - assert.match(preview, /sectorWindowZIndex[\s\S]*subjectCardZIndex/); + assert.match(contract, /type MapWorkspaceWindowId = "sector" \| "subject-card" \| `binding:\$\{string\}`/); + assert.match(state, /function activateWindow\(state, windowId\)/); + assert.match(state, /Math\.max\([\s\S]*state\.sector\.window\.zIndex[\s\S]*state\.subjectCard\.zIndex/); + assert.match(windows, /type: "activate-window", windowId: `binding:\$\{summary\.bindingId\}`/); + assert.match(sectorWindow, /onActivate=\{onActivate\}/); assert.match(preview, / { - 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 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\)/); }); -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, />Домены данныхПровайдерыТипы объектовОбъекты сектора\s*Деактивировать сектор\s* { - 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"), +test("active sector scope is geometric, filterable and reducer-deactivated", async () => { + const [runtime, sectorWindow, state] = await Promise.all([ + source("../apps/catalog/src/mapSectorRuntime.mjs"), + source("../apps/catalog/src/MapSectorWorkspaceWindow.tsx"), + source("../apps/catalog/src/mapWorkspaceState.mjs"), ]); - 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* { + 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, /content\.scrollHeight/); assert.match(workspace, /availableHeight = Math\.max\(0, nextBounds\.height - normalized\.y\)/); assert.match(workspace, /new ResizeObserver\(scheduleFit\)/); }); -test("facet tree selection focuses the subject without resetting a compatible detail tab", async () => { - const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"); - const renderer = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"); +test("facet and search selection focus the subject while the reducer preserves a valid detail tab", async () => { + const [preview, renderer, state] = await Promise.all([ + 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 focusSubject = useCallback/); 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, /focusSubject\(entityId\)/); assert.match(preview, /focusSubject\(result\.entityId, result\.coordinates\)/); - assert.match(preview, /setSubjectCardTabId\(\(current\) =>/); - assert.match(preview, /profile\?\.tabs\.some\(\(tab\) => tab\.id === current\)/); - assert.match(preview, /\(profile\?\.defaultTabId \?\? "overview"\)/); + assert.match(preview, /validTabIds: profile\?\.tabs\.map\(\(tab\) => tab\.id\) \?\? \["overview"\]/); + assert.match(state, /action\.validTabIds\.includes\(state\.subjectCard\.tabId\)/); assert.match(renderer, /void viewer\.flyTo\(entity, \{/); assert.match(renderer, /const focusSubjectCoordinates = useCallback/); assert.match(renderer, /viewer\.camera\.flyToBoundingSphere\(new BoundingSphere\(target, 1\), \{/); diff --git a/scripts/map-presentation-filters.test.mjs b/scripts/map-presentation-filters.test.mjs index 29e89d3..5a858d7 100644 --- a/scripts/map-presentation-filters.test.mjs +++ b/scripts/map-presentation-filters.test.mjs @@ -1,13 +1,5 @@ import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; 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 { mapFactMatchesFilters, mapPresentationBindingIsAll, @@ -15,7 +7,7 @@ const { mapPresentationFacetValueIsEnabled, normalizeMapPresentationFacetSelections, toggleMapPresentationFacetSelection, -} = await import(moduleUrl); +} = await import("../apps/catalog/src/mapPresentationProfile.mjs"); const profile = { id: "map.moving-object.operational.default", diff --git a/scripts/map-reference-stations.test.mjs b/scripts/map-reference-stations.test.mjs index 85cff45..4cbf54f 100644 --- a/scripts/map-reference-stations.test.mjs +++ b/scripts/map-reference-stations.test.mjs @@ -28,12 +28,12 @@ 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 () => { 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"); assert.match(server, /pageLayoutMatch\[1\] === "map" \? validateMapPageLayout\(stored\) : stored/); 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, /allowedAttributes = new Set\(\["name", "category", "network", "operator", "official_name", "local_name", "uic_ref", "wheelchair"\]\)/); assert.doesNotMatch(runtime, /credential|accessToken|providerEndpoint|rawPayload/); diff --git a/scripts/map-subject-card.test.mjs b/scripts/map-subject-card.test.mjs index ff469fd..a8db0e2 100644 --- a/scripts/map-subject-card.test.mjs +++ b/scripts/map-subject-card.test.mjs @@ -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 () => { - 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/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, /pickedId instanceof Entity/); assert.match(renderer, /onSelectRef\.current\?\.\(pickedId\.id\)/); assert.match(preview, /const handleSelect = useCallback/); - assert.match(preview, /setSubjectCardOpen\(true\)/); - assert.match(preview, /title=\{selectedSubjectCard\.title\}/); - assert.match(preview, /Карточка объекта:/); - assert.match(preview, /SegmentedControl/); + assert.match(preview, /type: "select-entity"/); + assert.match(state, /subjectCard: \{ \.\.\.state\.subjectCard, open: true, tabId \}/); + assert.match(windows, /title=\{selectedSubjectCard\.title\}/); + assert.match(windows, /Карточка объекта:/); + assert.match(windows, /SegmentedControl/); }); diff --git a/scripts/map-workspace-model.test.mjs b/scripts/map-workspace-model.test.mjs new file mode 100644 index 0000000..42c7cd2 --- /dev/null +++ b/scripts/map-workspace-model.test.mjs @@ -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); +}); diff --git a/scripts/map-workspace-state.test.mjs b/scripts/map-workspace-state.test.mjs new file mode 100644 index 0000000..be51cf9 --- /dev/null +++ b/scripts/map-workspace-state.test.mjs @@ -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"]); +});