From 80e948c0182778ad94ce9becf468983addd4660c Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 5 Aug 2026 19:36:01 +0300 Subject: [PATCH] feat(map): add seamless five-band grid LOD --- apps/catalog/src/CesiumMapRenderer.tsx | 291 +++++++++++++++++++++---- apps/catalog/src/MapFixturePreview.tsx | 81 ++++++- apps/catalog/src/mapGridPolicy.d.mts | 28 +++ apps/catalog/src/mapGridPolicy.mjs | 90 ++++++++ docs/MAP_TEMPLATE.md | 22 ++ package.json | 3 +- runtime-seed/page-layouts/map.json | 29 ++- scripts/map-grid-lod.test.mjs | 78 +++++++ server/catalog-server.mjs | 53 ++++- server/foundry-mcp.mjs | 25 +++ 10 files changed, 638 insertions(+), 62 deletions(-) create mode 100644 apps/catalog/src/mapGridPolicy.d.mts create mode 100644 apps/catalog/src/mapGridPolicy.mjs create mode 100644 scripts/map-grid-lod.test.mjs diff --git a/apps/catalog/src/CesiumMapRenderer.tsx b/apps/catalog/src/CesiumMapRenderer.tsx index f1ef15c..7c80650 100644 --- a/apps/catalog/src/CesiumMapRenderer.tsx +++ b/apps/catalog/src/CesiumMapRenderer.tsx @@ -61,6 +61,12 @@ import { type MapPresentationProfile, } from "./mapPresentationProfile.js"; import { normalizeHGeoZoneRing } from "./hGeoZoneProjection.mjs"; +import { + gridShouldBeVisible, + resolveGridMode, + selectGridLod, + snapGridCenter, +} from "./mapGridPolicy.mjs"; const MAX_SPIRAL_SUBSTEPS_PER_FRAME = 300; const TERRAIN_SAMPLE_TIMEOUT_MS = 12_000; @@ -212,20 +218,45 @@ export type MapPresentation = { imagerySaturation: number; gridVisible: boolean; gridLodEnabled: boolean; + grid3dEnabled: boolean; + gridGraticuleEnabled: boolean; + gridCenterMode: "camera" | "fixed"; + gridCenterLatitude: number; + gridCenterLongitude: number; + gridTileSizeKm: number; + gridAutoDisableHeightKm: number; + gridRebuildOnMoveEnd: 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; + 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; cacheRefresh: boolean; }; @@ -872,23 +903,63 @@ function syncRuntimeDataSources( viewer.scene.requestRender(); } -function rebuildElevatedGrid(viewer: Viewer, dataSource: CustomDataSource, presentation: MapPresentation) { - const entities = dataSource.entities; - entities.removeAll(); - if (!presentation.gridVisible) return; +type GridLayerBuild = { + dataSource: CustomDataSource | null; + key: string; + lodIndex: number | null; +}; +function buildGridLayer( + viewer: Viewer, + presentation: MapPresentation, + previousLodIndex: number | null, + serial: number, +): GridLayerBuild { const cameraHeightKm = Math.max(0, Number(viewer.camera.positionCartographic?.height || 0) / 1000); - const stepKm = !presentation.gridLodEnabled || cameraHeightKm <= presentation.gridLod1MaxHeightKm - ? presentation.gridLod1StepKm - : cameraHeightKm <= presentation.gridLod2MaxHeightKm - ? presentation.gridLod2StepKm - : presentation.gridLod3StepKm; - const safeStepKm = clamp(stepKm, 0.25, 100); - const safeRadiusKm = clamp(presentation.gridRadiusKm, safeStepKm, 150); - const stepsPerSide = Math.min(32, Math.max(1, Math.floor(safeRadiusKm / safeStepKm))); + if (!gridShouldBeVisible(presentation, cameraHeightKm)) { + return { dataSource: null, key: "hidden", lodIndex: null }; + } + + const lod = selectGridLod(presentation, cameraHeightKm, previousLodIndex); + const pitchDegrees = CesiumMath.toDegrees(viewer.camera.pitch); + const viewAngleFromNadir = Math.abs(90 - Math.abs(pitchDegrees)); + const mode = resolveGridMode(presentation, lod.mode, viewAngleFromNadir); + if (mode === "hidden") return { dataSource: null, key: "hidden", lodIndex: lod.index }; + + const safeStepKm = clamp(lod.stepKm, 0.25, 5_000); + const safeRadiusKm = clamp(presentation.gridRadiusKm, safeStepKm, 2_000); + const stepsPerSide = Math.min(40, Math.max(1, Math.floor(safeRadiusKm / safeStepKm))); const cameraPosition = viewer.camera.positionCartographic; - const latitude = cameraPosition ? CesiumMath.toDegrees(cameraPosition.latitude) : 55.751244; - const longitude = cameraPosition ? CesiumMath.toDegrees(cameraPosition.longitude) : 37.618423; + const center = snapGridCenter({ + latitude: cameraPosition ? CesiumMath.toDegrees(cameraPosition.latitude) : 55.751244, + longitude: cameraPosition ? CesiumMath.toDegrees(cameraPosition.longitude) : 37.618423, + }, presentation); + const latitude = center.latitude; + const longitude = center.longitude; + const key = JSON.stringify([ + lod.index, + mode, + latitude, + longitude, + safeStepKm, + safeRadiusKm, + presentation.gridHeightMeters, + presentation.gridLineWidth, + presentation.gridLineDiameterMeters, + presentation.gridColor, + presentation.gridOpacity, + presentation.gridDotsEnabled, + presentation.gridDotsDiameterMeters, + presentation.gridDotsColor, + presentation.gridDotsOpacity, + presentation.gridCrossesEnabled, + presentation.gridCrossesLengthMeters, + presentation.gridCrossesWidthMeters, + presentation.gridCrossesColor, + presentation.gridCrossesOpacity, + ]); + const dataSource = new CustomDataSource(`nodedc-map-grid:${serial}`); + const entities = dataSource.entities; const metersPerLatitudeDegree = 110_574; const metersPerLongitudeDegree = Math.max(1, 111_320 * Math.cos(CesiumMath.toRadians(latitude))); const stepMeters = safeStepKm * 1000; @@ -899,47 +970,167 @@ function rebuildElevatedGrid(viewer: Viewer, dataSource: CustomDataSource, prese const radiusLongitude = radiusMeters / metersPerLongitudeDegree; const lineColor = Color.fromCssColorString(presentation.gridColor).withAlpha(clamp(presentation.gridOpacity / 100, 0, 1)); const dotColor = Color.fromCssColorString(presentation.gridDotsColor).withAlpha(clamp(presentation.gridDotsOpacity / 100, 0, 1)); + const crossColor = Color.fromCssColorString(presentation.gridCrossesColor).withAlpha(clamp(presentation.gridCrossesOpacity / 100, 0, 1)); const elevation = Math.max(0, presentation.gridHeightMeters); + const projected = mode === "graticule"; + const geometryHeight = projected ? 0 : elevation; + const addGridLine = (id: string, positions: Cartesian3[]) => { + if (projected) { + entities.add({ + id, + polyline: { + positions, + width: clamp(presentation.gridLineWidth, 1, 8), + material: lineColor, + clampToGround: true, + }, + }); + return; + } + entities.add({ + id, + corridor: { + positions, + width: clamp(presentation.gridLineDiameterMeters, 1, 500), + material: lineColor, + height: geometryHeight, + heightReference: HeightReference.NONE, + }, + }); + }; for (let index = -stepsPerSide; index <= stepsPerSide; index += 1) { const nextLatitude = latitude + index * deltaLatitude; const nextLongitude = longitude + index * deltaLongitude; - entities.add({ - polyline: { - positions: [ - Cartesian3.fromDegrees(longitude - radiusLongitude, nextLatitude, elevation), - Cartesian3.fromDegrees(longitude + radiusLongitude, nextLatitude, elevation), - ], - width: clamp(presentation.gridLineWidth, 1, 8), - material: lineColor, - }, - }); - entities.add({ - polyline: { - positions: [ - Cartesian3.fromDegrees(nextLongitude, latitude - radiusLatitude, elevation), - Cartesian3.fromDegrees(nextLongitude, latitude + radiusLatitude, elevation), - ], - width: clamp(presentation.gridLineWidth, 1, 8), - material: lineColor, - }, - }); + addGridLine(`${serial}:latitude:${index}`, [ + Cartesian3.fromDegrees(longitude - radiusLongitude, nextLatitude, geometryHeight), + Cartesian3.fromDegrees(longitude + radiusLongitude, nextLatitude, geometryHeight), + ]); + addGridLine(`${serial}:longitude:${index}`, [ + Cartesian3.fromDegrees(nextLongitude, latitude - radiusLatitude, geometryHeight), + Cartesian3.fromDegrees(nextLongitude, latitude + radiusLatitude, geometryHeight), + ]); } - if (!presentation.gridDotsEnabled) return; const dotStride = Math.max(1, Math.ceil((stepsPerSide * 2 + 1) / 25)); for (let row = -stepsPerSide; row <= stepsPerSide; row += dotStride) { for (let column = -stepsPerSide; column <= stepsPerSide; column += dotStride) { - entities.add({ - position: Cartesian3.fromDegrees(longitude + column * deltaLongitude, latitude + row * deltaLatitude, elevation), - point: { - pixelSize: clamp(presentation.gridDotsSize, 2, 28), - color: dotColor, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - }); + const dotLongitude = longitude + column * deltaLongitude; + const dotLatitude = latitude + row * deltaLatitude; + if (presentation.gridDotsEnabled) { + const diameterMeters = clamp(presentation.gridDotsDiameterMeters, 2, 2_000); + entities.add({ + id: `${serial}:circle:${row}:${column}`, + position: Cartesian3.fromDegrees(dotLongitude, dotLatitude, geometryHeight), + ellipse: { + semiMajorAxis: diameterMeters / 2, + semiMinorAxis: diameterMeters / 2, + material: dotColor, + height: geometryHeight, + heightReference: projected ? HeightReference.CLAMP_TO_GROUND : HeightReference.NONE, + }, + }); + } + if (presentation.gridCrossesEnabled) { + const halfLengthMeters = clamp(presentation.gridCrossesLengthMeters, 2, 5_000) / 2; + const halfLatitude = halfLengthMeters / metersPerLatitudeDegree; + const halfLongitude = halfLengthMeters / metersPerLongitudeDegree; + const corridor = (positions: Cartesian3[]) => ({ + positions, + width: clamp(presentation.gridCrossesWidthMeters, 1, 500), + material: crossColor, + height: geometryHeight, + heightReference: projected ? HeightReference.CLAMP_TO_GROUND : HeightReference.NONE, + }); + entities.add({ + id: `${serial}:cross-ns:${row}:${column}`, + corridor: corridor([ + Cartesian3.fromDegrees(dotLongitude, dotLatitude - halfLatitude, geometryHeight), + Cartesian3.fromDegrees(dotLongitude, dotLatitude + halfLatitude, geometryHeight), + ]), + }); + entities.add({ + id: `${serial}:cross-ew:${row}:${column}`, + corridor: corridor([ + Cartesian3.fromDegrees(dotLongitude - halfLongitude, dotLatitude, geometryHeight), + Cartesian3.fromDegrees(dotLongitude + halfLongitude, dotLatitude, geometryHeight), + ]), + }); + } } } + return { dataSource, key, lodIndex: lod.index }; +} + +class GridLayerController { + private current: CustomDataSource | null = null; + private pending: CustomDataSource | 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; + + constructor(private readonly viewer: Viewer) {} + + rebuild(presentation: MapPresentation) { + const build = buildGridLayer(this.viewer, presentation, this.lodIndex, ++this.serial); + if (build.key === this.key) return; + this.key = build.key; + this.lodIndex = build.lodIndex; + const epoch = ++this.epoch; + this.cancelPending(); + + if (!build.dataSource) { + if (this.current) this.viewer.dataSources.remove(this.current, true); + this.current = null; + this.viewer.scene.requestRender(); + return; + } + + const next = build.dataSource; + this.pending = next; + void this.viewer.dataSources.add(next); + 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.viewer.dataSources.remove(previous, true); + this.viewer.scene.requestRender(); + }; + this.removeReadyListener = this.viewer.scene.postRender.addEventListener(() => { + readyFrames = this.viewer.dataSourceDisplay.ready ? readyFrames + 1 : 0; + if (readyFrames >= 2) commit(); + }); + // A slow terrain worker must not leave an obsolete buffer mounted forever. + // The old layer stays visible during this grace period, so the fallback is + // bounded cleanup rather than a visible blank-before-build swap. + this.fallbackTimer = window.setTimeout(commit, 2_000); + this.viewer.scene.requestRender(); + } + + destroy() { + this.epoch += 1; + this.cancelPending(); + if (this.current) this.viewer.dataSources.remove(this.current, true); + this.current = null; + } + + private cancelPending() { + this.removeReadyListener?.(); + this.removeReadyListener = null; + if (this.fallbackTimer !== null) window.clearTimeout(this.fallbackTimer); + this.fallbackTimer = null; + if (this.pending) this.viewer.dataSources.remove(this.pending, true); + this.pending = null; + } } function applyPresentation( @@ -1527,7 +1718,9 @@ export const CesiumMapRenderer = forwardRef void) | undefined; + let removeGridCameraChangedListener: (() => void) | undefined; let removeRefreshRenderListener: (() => void) | undefined; let removeRenderErrorListener: (() => void) | undefined; const removeProviderFailureListeners: Array<() => void> = []; @@ -1593,20 +1786,22 @@ export const CesiumMapRenderer = forwardRef; }; - const gridDataSource = new CustomDataSource("nodedc-map-grid"); - viewer.dataSources.add(gridDataSource); const terrain = { world: null as CesiumTerrainProvider | null, ellipsoid: new EllipsoidTerrainProvider() }; viewer.terrainProvider = terrain.ellipsoid; viewer.scene.globe.depthTestAgainstTerrain = true; viewerRef.current = viewer; terrainRef.current = terrain; - const rebuildGrid = () => rebuildElevatedGrid(viewer!, gridDataSource, presentationRef.current); + gridController = new GridLayerController(viewer); + const rebuildGrid = () => gridController?.rebuild(presentationRef.current); rebuildGridRef.current = rebuildGrid; removeGridCameraListener = viewer.camera.moveEnd.addEventListener(() => { - rebuildGrid(); + if (presentationRef.current.gridRebuildOnMoveEnd) rebuildGrid(); syncHGeoZoneVisibility(viewer!, hGeoZoneLayersRef.current); onCameraChangeRef.current?.(getCameraView(viewer!)); }); + removeGridCameraChangedListener = viewer.camera.changed.addEventListener(() => { + if (!presentationRef.current.gridRebuildOnMoveEnd) rebuildGrid(); + }); const providerStatus: MapProviderStatus = { imagery: config?.gatewayReady ? "loading" : "not-configured", @@ -1818,10 +2013,12 @@ export const CesiumMapRenderer = forwardRef> = [ + { value: "3d", label: "3D", description: "Приподнятая пространственная сетка" }, + { value: "graticule", label: "Гратикула", description: "Проекция по поверхности" }, +]; function beginGatewayHealthEpoch(order: GatewayHealthOrder) { order.nextEpoch += 1; @@ -221,20 +225,45 @@ const initialMapSettings: MapPageSettings = { imagerySaturation: 0, gridVisible: true, gridLodEnabled: true, + grid3dEnabled: true, + gridGraticuleEnabled: true, + gridCenterMode: "camera", + gridCenterLatitude: 55.7558, + gridCenterLongitude: 37.6173, + gridTileSizeKm: 10, + gridAutoDisableHeightKm: 10_000, + gridRebuildOnMoveEnd: true, + gridMax3dViewAngleDegrees: 30, gridHeightMeters: 500, gridLod1MaxHeightKm: 10, gridLod1StepKm: 1, + gridLod1Mode: "3d", gridLod2MaxHeightKm: 50, gridLod2StepKm: 5, + gridLod2Mode: "3d", + gridLod3MaxHeightKm: 180, gridLod3StepKm: 25, - gridRadiusKm: 40, + gridLod3Mode: "3d", + gridLod4MaxHeightKm: 700, + gridLod4StepKm: 100, + gridLod4Mode: "graticule", + gridLod5StepKm: 500, + gridLod5Mode: "graticule", + gridRadiusKm: 1_000, gridLineWidth: 4, + gridLineDiameterMeters: 10, gridColor: "#f5f5f5", gridOpacity: 12, gridDotsEnabled: true, gridDotsSize: 7, + gridDotsDiameterMeters: 80, gridDotsColor: "#ffffff", gridDotsOpacity: 58, + gridCrossesEnabled: false, + gridCrossesLengthMeters: 200, + gridCrossesWidthMeters: 10, + gridCrossesColor: "#35cfff", + gridCrossesOpacity: 50, }; // A valid, deterministic scene view is available before Cesium emits its @@ -1238,23 +1267,57 @@ export const MapFixturePreview = forwardRef - Сетка размещается над поверхностью и меняет шаг по высоте камеры. - updateMapSettings({ gridVisible })} /> + Пять LOD сохраняют пространственную сетку вблизи и переходят к гратикуле на дальних высотах. Новый слой подготавливается до удаления предыдущего. + updateMapSettings({ gridVisible })} /> + updateMapSettings({ grid3dEnabled })} /> + updateMapSettings({ gridGraticuleEnabled })} /> updateMapSettings({ gridLodEnabled })} /> + updateMapSettings({ gridRebuildOnMoveEnd })} /> + updateMapSettings({ gridCenterMode })} + /> + {mapSettings.gridCenterMode === "fixed" ? <> + value.toFixed(4)} onChange={(gridCenterLatitude) => updateMapSettings({ gridCenterLatitude })} /> + value.toFixed(4)} onChange={(gridCenterLongitude) => updateMapSettings({ gridCenterLongitude })} /> + : null} + `${value} км`} onChange={(gridTileSizeKm) => updateMapSettings({ gridTileSizeKm })} /> + value === 0 ? "выкл" : `${value} км`} onChange={(gridAutoDisableHeightKm) => updateMapSettings({ gridAutoDisableHeightKm })} /> `${value} м`} onChange={(gridHeightMeters) => updateMapSettings({ gridHeightMeters })} /> + `${value}°`} onChange={(gridMax3dViewAngleDegrees) => updateMapSettings({ gridMax3dViewAngleDegrees })} /> + updateMapSettings({ gridLod1Mode })} /> `${value} км`} onChange={(gridLod1MaxHeightKm) => updateMapSettings({ gridLod1MaxHeightKm })} /> `${value} км`} onChange={(gridLod1StepKm) => updateMapSettings({ gridLod1StepKm })} /> + updateMapSettings({ gridLod2Mode })} /> `${value} км`} onChange={(gridLod2MaxHeightKm) => updateMapSettings({ gridLod2MaxHeightKm })} /> `${value} км`} onChange={(gridLod2StepKm) => updateMapSettings({ gridLod2StepKm })} /> + updateMapSettings({ gridLod3Mode })} /> + `${value} км`} onChange={(gridLod3MaxHeightKm) => updateMapSettings({ gridLod3MaxHeightKm })} /> `${value} км`} onChange={(gridLod3StepKm) => updateMapSettings({ gridLod3StepKm })} /> - `${value} км`} onChange={(gridRadiusKm) => updateMapSettings({ gridRadiusKm })} /> + updateMapSettings({ gridLod4Mode })} /> + `${value} км`} onChange={(gridLod4MaxHeightKm) => updateMapSettings({ gridLod4MaxHeightKm })} /> + `${value} км`} onChange={(gridLod4StepKm) => updateMapSettings({ gridLod4StepKm })} /> + updateMapSettings({ gridLod5Mode })} /> + `${value} км`} onChange={(gridLod5StepKm) => updateMapSettings({ gridLod5StepKm })} /> + `${value} км`} onChange={(gridRadiusKm) => updateMapSettings({ gridRadiusKm })} /> updateMapSettings({ gridColor })} /> - `${value} px`} onChange={(gridLineWidth) => updateMapSettings({ gridLineWidth })} /> + `${value} м`} onChange={(gridLineDiameterMeters) => updateMapSettings({ gridLineDiameterMeters })} /> + `${value} px`} onChange={(gridLineWidth) => updateMapSettings({ gridLineWidth })} /> `${value}%`} onChange={(gridOpacity) => updateMapSettings({ gridOpacity })} /> - updateMapSettings({ gridDotsEnabled })} /> - `${value} px`} onChange={(gridDotsSize) => updateMapSettings({ gridDotsSize })} /> - updateMapSettings({ gridDotsColor })} /> - `${value}%`} onChange={(gridDotsOpacity) => updateMapSettings({ gridDotsOpacity })} /> + updateMapSettings({ gridDotsEnabled })} /> + `${value} м`} onChange={(gridDotsDiameterMeters) => updateMapSettings({ gridDotsDiameterMeters })} /> + updateMapSettings({ gridDotsColor })} /> + `${value}%`} onChange={(gridDotsOpacity) => updateMapSettings({ gridDotsOpacity })} /> + updateMapSettings({ gridCrossesEnabled })} /> + `${value} м`} onChange={(gridCrossesLengthMeters) => updateMapSettings({ gridCrossesLengthMeters })} /> + `${value} м`} onChange={(gridCrossesWidthMeters) => updateMapSettings({ gridCrossesWidthMeters })} /> + updateMapSettings({ gridCrossesColor })} /> + `${value}%`} onChange={(gridCrossesOpacity) => updateMapSettings({ gridCrossesOpacity })} /> , }, { diff --git a/apps/catalog/src/mapGridPolicy.d.mts b/apps/catalog/src/mapGridPolicy.d.mts new file mode 100644 index 0000000..dc26269 --- /dev/null +++ b/apps/catalog/src/mapGridPolicy.d.mts @@ -0,0 +1,28 @@ +import type { MapPresentation } from "./CesiumMapRenderer.js"; + +export type GridLodMode = "3d" | "graticule"; +export type GridLodBand = { + index: number; + id: string; + maxHeightKm: number; + stepKm: number; + mode: GridLodMode; +}; + +export const GRID_LOD_HYSTERESIS_RATIO: number; +export function gridLodBands(settings: MapPresentation): GridLodBand[]; +export function selectGridLod( + settings: MapPresentation, + cameraHeightKm: number, + previousIndex?: number | null, +): GridLodBand; +export function resolveGridMode( + settings: MapPresentation, + requestedMode: GridLodMode, + viewAngleFromNadirDegrees: number, +): GridLodMode | "hidden"; +export function gridShouldBeVisible(settings: MapPresentation, cameraHeightKm: number): boolean; +export function snapGridCenter( + center: { latitude: number; longitude: number }, + settings: MapPresentation, +): { latitude: number; longitude: number }; diff --git a/apps/catalog/src/mapGridPolicy.mjs b/apps/catalog/src/mapGridPolicy.mjs new file mode 100644 index 0000000..5d00794 --- /dev/null +++ b/apps/catalog/src/mapGridPolicy.mjs @@ -0,0 +1,90 @@ +const GRID_LOD_COUNT = 5; + +export const GRID_LOD_HYSTERESIS_RATIO = 0.08; + +const finite = (value, fallback) => Number.isFinite(Number(value)) ? Number(value) : fallback; +const clamp = (value, minimum, maximum) => Math.min(maximum, Math.max(minimum, value)); + +export function gridLodBands(settings) { + const maximums = [ + finite(settings.gridLod1MaxHeightKm, 10), + finite(settings.gridLod2MaxHeightKm, 50), + finite(settings.gridLod3MaxHeightKm, 180), + finite(settings.gridLod4MaxHeightKm, 700), + Number.POSITIVE_INFINITY, + ]; + for (let index = 1; index < maximums.length - 1; index += 1) { + maximums[index] = Math.max(maximums[index - 1], maximums[index]); + } + const steps = [ + finite(settings.gridLod1StepKm, 1), + finite(settings.gridLod2StepKm, 5), + finite(settings.gridLod3StepKm, 25), + finite(settings.gridLod4StepKm, 100), + finite(settings.gridLod5StepKm, 500), + ]; + const modes = [1, 2, 3, 4, 5].map((number) => ( + settings[`gridLod${number}Mode`] === "graticule" ? "graticule" : "3d" + )); + return maximums.map((maxHeightKm, index) => ({ + index, + id: `lod-${index + 1}`, + maxHeightKm, + stepKm: clamp(steps[index], 0.25, 5_000), + mode: modes[index], + })); +} + +export function selectGridLod(settings, cameraHeightKm, previousIndex = null) { + const bands = gridLodBands(settings); + if (!settings.gridLodEnabled) return bands[0]; + const height = Math.max(0, finite(cameraHeightKm, 0)); + const directIndex = Math.max(0, bands.findIndex((band) => height <= band.maxHeightKm)); + if (!Number.isInteger(previousIndex) || previousIndex < 0 || previousIndex >= GRID_LOD_COUNT) { + return bands[directIndex]; + } + + const previous = bands[previousIndex]; + const lowerBoundary = previousIndex === 0 ? 0 : bands[previousIndex - 1].maxHeightKm; + const upperBoundary = previous.maxHeightKm; + const lowerHold = lowerBoundary * (1 - GRID_LOD_HYSTERESIS_RATIO); + const upperHold = Number.isFinite(upperBoundary) + ? upperBoundary * (1 + GRID_LOD_HYSTERESIS_RATIO) + : Number.POSITIVE_INFINITY; + return height >= lowerHold && height <= upperHold ? previous : bands[directIndex]; +} + +export function resolveGridMode(settings, requestedMode, viewAngleFromNadirDegrees) { + if (requestedMode === "3d") { + const maxAngle = clamp(finite(settings.gridMax3dViewAngleDegrees, 30), 0, 89); + if (settings.grid3dEnabled !== false && viewAngleFromNadirDegrees <= maxAngle) return "3d"; + return settings.gridGraticuleEnabled === false ? "hidden" : "graticule"; + } + if (settings.gridGraticuleEnabled !== false) return "graticule"; + return settings.grid3dEnabled === false ? "hidden" : "3d"; +} + +export function gridShouldBeVisible(settings, cameraHeightKm) { + if (!settings.gridVisible) return false; + const limit = finite(settings.gridAutoDisableHeightKm, 10_000); + return limit <= 0 || Math.max(0, finite(cameraHeightKm, 0)) <= limit; +} + +export function snapGridCenter({ latitude, longitude }, settings) { + if (settings.gridCenterMode === "fixed") { + return { + latitude: clamp(finite(settings.gridCenterLatitude, 55.7558), -89.999, 89.999), + longitude: clamp(finite(settings.gridCenterLongitude, 37.6173), -180, 180), + }; + } + const sourceLatitude = clamp(finite(latitude, 55.7558), -89.999, 89.999); + const sourceLongitude = clamp(finite(longitude, 37.6173), -180, 180); + const tileKm = clamp(finite(settings.gridTileSizeKm, 10), 0.25, 5_000); + const latitudeStep = tileKm / 110.574; + const snappedLatitude = Math.round(sourceLatitude / latitudeStep) * latitudeStep; + const longitudeStep = tileKm / Math.max(0.001, 111.320 * Math.cos(snappedLatitude * Math.PI / 180)); + return { + latitude: snappedLatitude, + longitude: Math.round(sourceLongitude / longitudeStep) * longitudeStep, + }; +} diff --git a/docs/MAP_TEMPLATE.md b/docs/MAP_TEMPLATE.md index 7621fac..da12c28 100644 --- a/docs/MAP_TEMPLATE.md +++ b/docs/MAP_TEMPLATE.md @@ -157,6 +157,28 @@ outline плашки отключён и не создаёт дополните доступной поверхности, а depth-fail material не даёт стержню исчезнуть при пересечении рельефа. +### Планетарная сетка и LOD + +Map Page использует один сохраняемый профиль сетки из пяти LOD. По умолчанию +LOD 1–3 рендерятся как приподнятая пространственная сетка, а LOD 4–5 — как +гратикульная проекция по поверхности. Для каждого LOD отдельно задаются +верхняя высота (кроме последнего), шаг и режим. Общими остаются центр, размер +стабильного тайла, радиус, высота 3D-слоя, допустимый угол от надира, линии, +кружки и кресты. Старые layout автоматически дополняются этими полями только +в client state и переписываются после явного Application Save. + +Выбор band использует hysteresis `0.08`, поэтому камера у границы не заставляет +слой дрожать между соседними LOD. При camera-centered режиме центр квантуется +по `gridTileSizeKm`: небольшое движение внутри тайла не пересобирает геометрию. +Опция `gridRebuildOnMoveEnd` сохраняет доказанный Engine-паттерн и по умолчанию +не запускает тяжёлую перестройку во время движения. + +Смена геометрии выполняется двойным буфером. Следующий `CustomDataSource` +сначала монтируется рядом с текущим; текущий удаляется только после двух +готовых post-render кадров (`dataSourceDisplay.ready`) либо по bounded cleanup +timeout. Запрещено очищать live grid через `entities.removeAll()` до подготовки +замены: именно это создаёт видимые дыры при переходе между LOD. + ## Acceptance fixtures - `registry/fixtures/map/map-empty-offline-v0.1.json` — отсутствие provider/live data без разрушения shell и управляющих действий. diff --git a/package.json b/package.json index a6206d2..d538047 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-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-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", @@ -23,6 +23,7 @@ "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:hgeozone-projection": "node --test scripts/hgeozone-projection.test.mjs", + "test:map-grid-lod": "node --test scripts/map-grid-lod.test.mjs", "test:map-object-layers": "node --test scripts/map-object-layers.test.mjs", "test:map-inspector-overlay-state": "node --test scripts/map-inspector-overlay-state.test.mjs", "test:map-reference-stations": "node --test scripts/map-reference-stations.test.mjs", diff --git a/runtime-seed/page-layouts/map.json b/runtime-seed/page-layouts/map.json index 33a857d..44e0f64 100644 --- a/runtime-seed/page-layouts/map.json +++ b/runtime-seed/page-layouts/map.json @@ -33,20 +33,45 @@ "imagerySaturation": 0, "gridVisible": true, "gridLodEnabled": true, + "grid3dEnabled": true, + "gridGraticuleEnabled": true, + "gridCenterMode": "camera", + "gridCenterLatitude": 55.7558, + "gridCenterLongitude": 37.6173, + "gridTileSizeKm": 10, + "gridAutoDisableHeightKm": 10000, + "gridRebuildOnMoveEnd": true, + "gridMax3dViewAngleDegrees": 30, "gridHeightMeters": 500, "gridLod1MaxHeightKm": 10, "gridLod1StepKm": 1, + "gridLod1Mode": "3d", "gridLod2MaxHeightKm": 50, "gridLod2StepKm": 5, + "gridLod2Mode": "3d", + "gridLod3MaxHeightKm": 180, "gridLod3StepKm": 25, - "gridRadiusKm": 40, + "gridLod3Mode": "3d", + "gridLod4MaxHeightKm": 700, + "gridLod4StepKm": 100, + "gridLod4Mode": "graticule", + "gridLod5StepKm": 500, + "gridLod5Mode": "graticule", + "gridRadiusKm": 1000, "gridLineWidth": 4, + "gridLineDiameterMeters": 10, "gridColor": "#f5f5f5", "gridOpacity": 12, "gridDotsEnabled": true, "gridDotsSize": 7, + "gridDotsDiameterMeters": 80, "gridDotsColor": "#ffffff", - "gridDotsOpacity": 58 + "gridDotsOpacity": 58, + "gridCrossesEnabled": false, + "gridCrossesLengthMeters": 200, + "gridCrossesWidthMeters": 10, + "gridCrossesColor": "#35cfff", + "gridCrossesOpacity": 50 }, "mapHeight": 620, "camera": { diff --git a/scripts/map-grid-lod.test.mjs b/scripts/map-grid-lod.test.mjs new file mode 100644 index 0000000..003101e --- /dev/null +++ b/scripts/map-grid-lod.test.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { + gridShouldBeVisible, + resolveGridMode, + selectGridLod, + snapGridCenter, +} from "../apps/catalog/src/mapGridPolicy.mjs"; + +const settings = { + gridVisible: true, + gridLodEnabled: true, + gridLod1MaxHeightKm: 10, + gridLod1StepKm: 1, + gridLod1Mode: "3d", + gridLod2MaxHeightKm: 50, + gridLod2StepKm: 5, + gridLod2Mode: "3d", + gridLod3MaxHeightKm: 180, + gridLod3StepKm: 25, + gridLod3Mode: "3d", + gridLod4MaxHeightKm: 700, + gridLod4StepKm: 100, + gridLod4Mode: "graticule", + gridLod5StepKm: 500, + gridLod5Mode: "graticule", + grid3dEnabled: true, + gridGraticuleEnabled: true, + gridMax3dViewAngleDegrees: 30, + gridAutoDisableHeightKm: 10_000, + gridCenterMode: "camera", + gridTileSizeKm: 10, +}; + +test("five grid LODs preserve the close 3D and distant graticule contract", () => { + assert.deepEqual( + [5, 40, 120, 500, 2_000].map((height) => { + const band = selectGridLod(settings, height); + return [band.id, band.stepKm, band.mode]; + }), + [ + ["lod-1", 1, "3d"], + ["lod-2", 5, "3d"], + ["lod-3", 25, "3d"], + ["lod-4", 100, "graticule"], + ["lod-5", 500, "graticule"], + ], + ); +}); + +test("LOD hysteresis holds the previous band around a threshold", () => { + assert.equal(selectGridLod(settings, 10.5, 0).id, "lod-1"); + assert.equal(selectGridLod(settings, 10.9, 0).id, "lod-2"); + assert.equal(selectGridLod(settings, 9.5, 1).id, "lod-2"); + assert.equal(selectGridLod(settings, 9.1, 1).id, "lod-1"); +}); + +test("oblique close view falls back to the graticule without dropping the grid", () => { + assert.equal(resolveGridMode(settings, "3d", 12), "3d"); + assert.equal(resolveGridMode(settings, "3d", 48), "graticule"); + assert.equal(resolveGridMode({ ...settings, gridGraticuleEnabled: false }, "3d", 48), "hidden"); +}); + +test("grid auto-disable and stable camera tile center are deterministic", () => { + assert.equal(gridShouldBeVisible(settings, 9_999), true); + assert.equal(gridShouldBeVisible(settings, 10_001), false); + const first = snapGridCenter({ latitude: 55.7558, longitude: 37.6173 }, settings); + const second = snapGridCenter({ latitude: 55.76, longitude: 37.62 }, settings); + assert.deepEqual(first, second); +}); + +test("renderer swaps double-buffered data sources and never clears the live grid first", async () => { + 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.doesNotMatch(source, /function rebuildElevatedGrid[\s\S]*?entities\.removeAll\(\)/); +}); diff --git a/server/catalog-server.mjs b/server/catalog-server.mjs index 367eacc..40c14f4 100644 --- a/server/catalog-server.mjs +++ b/server/catalog-server.mjs @@ -519,9 +519,16 @@ const MAP_PAGE_SETTING_KEYS = new Set([ "sunHour", "sunIntensity", "shadowsEnabled", "buildingsVisible", "buildingsColor", "buildingsOpacity", "buildingsDetail", "imageryBrightness", "imageryContrast", "imagerySaturation", "gridVisible", "gridLodEnabled", "gridHeightMeters", + "grid3dEnabled", "gridGraticuleEnabled", "gridCenterMode", "gridCenterLatitude", + "gridCenterLongitude", "gridTileSizeKm", "gridAutoDisableHeightKm", "gridRebuildOnMoveEnd", + "gridMax3dViewAngleDegrees", "gridLod1MaxHeightKm", "gridLod1StepKm", "gridLod2MaxHeightKm", "gridLod2StepKm", - "gridLod3StepKm", "gridRadiusKm", "gridLineWidth", "gridColor", "gridOpacity", - "gridDotsEnabled", "gridDotsSize", "gridDotsColor", "gridDotsOpacity", + "gridLod3MaxHeightKm", "gridLod3StepKm", "gridLod4MaxHeightKm", "gridLod4StepKm", + "gridLod5StepKm", "gridLod1Mode", "gridLod2Mode", "gridLod3Mode", "gridLod4Mode", + "gridLod5Mode", "gridRadiusKm", "gridLineWidth", "gridLineDiameterMeters", "gridColor", "gridOpacity", + "gridDotsEnabled", "gridDotsSize", "gridDotsDiameterMeters", "gridDotsColor", "gridDotsOpacity", + "gridCrossesEnabled", "gridCrossesLengthMeters", "gridCrossesWidthMeters", + "gridCrossesColor", "gridCrossesOpacity", ]); function validateMapPageSettingsPatch(value) { @@ -558,6 +565,9 @@ function validateMapPageLayout(value) { // Layouts written before this field existed are safely upgraded to the // no-overwrite default during read/save; they are not rejected as damaged. if (settings.cacheNoOverwrite !== undefined) requireBoolean(settings.cacheNoOverwrite, "invalid_map_page_setting_cacheNoOverwrite"); + for (const key of ["grid3dEnabled", "gridGraticuleEnabled", "gridRebuildOnMoveEnd", "gridCrossesEnabled"]) { + if (settings[key] !== undefined) requireBoolean(settings[key], `invalid_map_page_setting_${key}`); + } requireString(settings.imagerySource, "invalid_map_page_imagery_source", 64); for (const key of ["terrainExaggeration", "imageryGamma", "imageryHue", "imageryAlpha", "atmosphereHue", "atmosphereSaturation", "atmosphereBrightness", "fogDensity", "sunHour", "sunIntensity", "buildingsOpacity", "buildingsDetail", "imageryBrightness", "imageryContrast", "imagerySaturation", "gridHeightMeters", "gridLod1MaxHeightKm", "gridLod1StepKm", "gridLod2MaxHeightKm", "gridLod2StepKm", "gridLod3StepKm", "gridRadiusKm", "gridLineWidth", "gridOpacity", "gridDotsSize", "gridDotsOpacity"]) { requireNumber(settings[key], -100000, 100000, `invalid_map_page_setting_${key}`); @@ -565,6 +575,18 @@ function validateMapPageLayout(value) { for (const key of ["monochromeColor", "globeColor", "backgroundColor", "buildingsColor", "gridColor", "gridDotsColor"]) { requireHex(settings[key], `invalid_map_page_setting_${key}`); } + for (const key of ["gridCenterLatitude", "gridCenterLongitude", "gridTileSizeKm", "gridAutoDisableHeightKm", "gridMax3dViewAngleDegrees", "gridLod3MaxHeightKm", "gridLod4MaxHeightKm", "gridLod4StepKm", "gridLod5StepKm", "gridLineDiameterMeters", "gridDotsDiameterMeters", "gridCrossesLengthMeters", "gridCrossesWidthMeters", "gridCrossesOpacity"]) { + if (settings[key] !== undefined) requireNumber(settings[key], -100000, 100000, `invalid_map_page_setting_${key}`); + } + if (settings.gridCrossesColor !== undefined) requireHex(settings.gridCrossesColor, "invalid_map_page_setting_gridCrossesColor"); + if (settings.gridCenterMode !== undefined && !["camera", "fixed"].includes(settings.gridCenterMode)) { + throw applicationError("invalid_map_page_setting_gridCenterMode"); + } + for (const key of ["gridLod1Mode", "gridLod2Mode", "gridLod3Mode", "gridLod4Mode", "gridLod5Mode"]) { + if (settings[key] !== undefined && !["3d", "graticule"].includes(settings[key])) { + throw applicationError(`invalid_map_page_setting_${key}`); + } + } const camera = value.camera; for (const key of ["longitude", "latitude", "height", "heading", "pitch", "roll"]) { requireNumber(camera[key], -1_000_000_000, 1_000_000_000, `invalid_map_page_camera_${key}`); @@ -712,20 +734,45 @@ function defaultMapPageLayout() { imagerySaturation: 0, gridVisible: true, gridLodEnabled: true, + grid3dEnabled: true, + gridGraticuleEnabled: true, + gridCenterMode: "camera", + gridCenterLatitude: 55.7558, + gridCenterLongitude: 37.6173, + gridTileSizeKm: 10, + gridAutoDisableHeightKm: 10000, + gridRebuildOnMoveEnd: true, + gridMax3dViewAngleDegrees: 30, gridHeightMeters: 500, gridLod1MaxHeightKm: 10, gridLod1StepKm: 1, + gridLod1Mode: "3d", gridLod2MaxHeightKm: 50, gridLod2StepKm: 5, + gridLod2Mode: "3d", + gridLod3MaxHeightKm: 180, gridLod3StepKm: 25, - gridRadiusKm: 40, + gridLod3Mode: "3d", + gridLod4MaxHeightKm: 700, + gridLod4StepKm: 100, + gridLod4Mode: "graticule", + gridLod5StepKm: 500, + gridLod5Mode: "graticule", + gridRadiusKm: 1000, gridLineWidth: 4, + gridLineDiameterMeters: 10, gridColor: "#f5f5f5", gridOpacity: 12, gridDotsEnabled: true, gridDotsSize: 7, + gridDotsDiameterMeters: 80, gridDotsColor: "#ffffff", gridDotsOpacity: 58, + gridCrossesEnabled: false, + gridCrossesLengthMeters: 200, + gridCrossesWidthMeters: 10, + gridCrossesColor: "#35cfff", + gridCrossesOpacity: 50, }, mapHeight: 470, camera: { diff --git a/server/foundry-mcp.mjs b/server/foundry-mcp.mjs index f9aa536..3ad38c9 100644 --- a/server/foundry-mcp.mjs +++ b/server/foundry-mcp.mjs @@ -412,20 +412,45 @@ const mapPageSettingsPatchInputSchema = { imagerySaturation: { type: "number" }, gridVisible: { type: "boolean" }, gridLodEnabled: { type: "boolean" }, + grid3dEnabled: { type: "boolean" }, + gridGraticuleEnabled: { type: "boolean" }, + gridCenterMode: { type: "string", enum: ["camera", "fixed"] }, + gridCenterLatitude: { type: "number" }, + gridCenterLongitude: { type: "number" }, + gridTileSizeKm: { type: "number" }, + gridAutoDisableHeightKm: { type: "number" }, + gridRebuildOnMoveEnd: { type: "boolean" }, + gridMax3dViewAngleDegrees: { type: "number" }, gridHeightMeters: { type: "number" }, gridLod1MaxHeightKm: { type: "number" }, gridLod1StepKm: { type: "number" }, + gridLod1Mode: { type: "string", enum: ["3d", "graticule"] }, gridLod2MaxHeightKm: { type: "number" }, gridLod2StepKm: { type: "number" }, + gridLod2Mode: { type: "string", enum: ["3d", "graticule"] }, + gridLod3MaxHeightKm: { type: "number" }, gridLod3StepKm: { type: "number" }, + gridLod3Mode: { type: "string", enum: ["3d", "graticule"] }, + gridLod4MaxHeightKm: { type: "number" }, + gridLod4StepKm: { type: "number" }, + gridLod4Mode: { type: "string", enum: ["3d", "graticule"] }, + gridLod5StepKm: { type: "number" }, + gridLod5Mode: { type: "string", enum: ["3d", "graticule"] }, gridRadiusKm: { type: "number" }, gridLineWidth: { type: "number" }, + gridLineDiameterMeters: { type: "number" }, gridColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" }, gridOpacity: { type: "number" }, gridDotsEnabled: { type: "boolean" }, gridDotsSize: { type: "number" }, + gridDotsDiameterMeters: { type: "number" }, gridDotsColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" }, gridDotsOpacity: { type: "number" }, + gridCrossesEnabled: { type: "boolean" }, + gridCrossesLengthMeters: { type: "number" }, + gridCrossesWidthMeters: { type: "number" }, + gridCrossesColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" }, + gridCrossesOpacity: { type: "number" }, }, };