From d4827009d4f63bd291c9e50e185d43edfafa32ef Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 6 Aug 2026 11:22:45 +0300 Subject: [PATCH] feat(map): add functional sector grid v2 --- apps/catalog/src/CesiumMapRenderer.tsx | 732 +++++++++++++++++++++++-- apps/catalog/src/MapFixturePreview.tsx | 517 ++++++++++++++++- apps/catalog/src/mapGridPolicy.d.mts | 7 + apps/catalog/src/mapGridPolicy.mjs | 17 + apps/catalog/src/mapSectorGrid.d.mts | 210 ++++++- apps/catalog/src/mapSectorGrid.mjs | 730 ++++++++++++++++++++++++ apps/catalog/src/styles.css | 69 +++ docs/FOUNDRY_MAP_CESIUM_CANON.md | 23 +- package.json | 2 +- runtime-seed/page-layouts/map.json | 10 +- scripts/map-grid-lod.test.mjs | 36 +- scripts/map-sector-grid.test.mjs | 406 ++++++++++++++ server/catalog-server.mjs | 114 +++- server/foundry-mcp.mjs | 7 + server/map-grid-persistence.test.mjs | 98 ++++ 15 files changed, 2902 insertions(+), 76 deletions(-) create mode 100644 server/map-grid-persistence.test.mjs diff --git a/apps/catalog/src/CesiumMapRenderer.tsx b/apps/catalog/src/CesiumMapRenderer.tsx index 730b3fb..11c5894 100644 --- a/apps/catalog/src/CesiumMapRenderer.tsx +++ b/apps/catalog/src/CesiumMapRenderer.tsx @@ -6,6 +6,7 @@ import { BingMapsImageryProvider, BingMapsStyle, BillboardGraphics, + BoundingSphere, Color, Credit, Cesium3DTileset, @@ -30,6 +31,7 @@ import { HorizontalOrigin, ImageryLayer, JulianDate, + LabelCollection, LabelGraphics, LabelStyle, Matrix4, @@ -75,11 +77,16 @@ import { fixedGridOrigin, graticuleGranularity, graticuleLinePlan, + graticuleMajorTileAt, graticuleSectorAt, - graticuleSectorBounds, + graticuleSectorSummary, + isGraticuleMajorLineValue, + isLocalMajorLineIndex, localGridPlan, + localMajorTileAt, localSectorAt, - localSectorBounds, + localSectorSummary, + localVolumeAt, splitLongitudeRange, } from "./mapSectorGrid.mjs"; @@ -302,17 +309,38 @@ export type GridLodProfile = { graticuleLineWidthPx: number; graticuleColor: string; graticuleOpacity: number; + majorLinesEnabled: boolean; + majorLabelsEnabled: boolean; + majorLineWidthMultiplier: number; + volumeEnabled: boolean; + volumeMinimumHeightMeters: number; + volumeMaximumHeightMeters: number; + volumeBandHeightMeters: number; }; -export type GridSectorSelection = { +export type GridVolumeSelection = { id: string; - lod: number; - mode: "3d" | "graticule"; - address: { eastIndex: number; northIndex: number } | { longitudeIndex: number; latitudeIndex: number }; - bounds: { west: number; east: number; south: number; north: number }; - units: "meters-enu" | "degrees-wgs84"; + 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; @@ -343,6 +371,8 @@ export type CesiumMapRendererHandle = { fitRuntimeEntities: (entityIds?: string[]) => boolean; focusRuntimeEntity: (entityId: string) => boolean; focusCoordinates: (longitude: number, latitude: number) => boolean; + focusGridSector: (sector: GridSectorSelection) => boolean; + focusGridMajorTile: (tile: GridMajorTileSelection) => boolean; }; type TerrainRouteSample = { @@ -964,18 +994,36 @@ type LocalGridAddressing = { 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; }; type GraticuleGridAddressing = { mode: "graticule"; lod: number; stepDegrees: number; + majorStepDegrees: number | null; + heightMeters: number; + lineWidthPixels: number; + lineColor: string; + lineOpacity: number; + majorLineWidthMultiplier: 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 = { @@ -987,6 +1035,8 @@ type LocalGridLayerPlan = { enu: Matrix4; inverseEnu: Matrix4; grid: ReturnType; + majorGrid: ReturnType | null; + majorStepMeters: number | null; metersPerPixel: number; cameraHeightMeters: number; cameraPosition: Cartesian3; @@ -998,9 +1048,35 @@ type GraticuleGridLayerPlan = { 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, ( @@ -1074,39 +1150,62 @@ function planGridLayer(viewer: Viewer, presentation: MapPresentation, previousLo .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, + 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 }; + 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 = { @@ -1115,13 +1214,15 @@ function materializeLocalGrid(plan: LocalGridLayerPlan, serial: number): GridRes originLongitude: plan.origin.longitude, stepMeters: plan.grid.stepMeters, }; - const toWorld = (eastMeters: number, northMeters: number) => { - const tangentPoint = Matrix4.multiplyByPoint(plan.enu, new Cartesian3(eastMeters, northMeters, 0), new Cartesian3()); - const cartographic = Cartographic.fromCartesian(tangentPoint); + 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 Cartesian3.fromRadians(cartographic.longitude, cartographic.latitude, Math.max(0, plan.lod.heightMeters)); + 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)); @@ -1175,20 +1276,21 @@ function materializeLocalGrid(plan: LocalGridLayerPlan, serial: number): GridRes plan.lod.tileSizeKm * 1_000, plan.grid.radiusMeters / 80, ); - const addSegmentedLine = (axis: "east" | "north", line: (typeof plan.grid.lines)[number]) => { + 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) { - const address = axis === "east" - ? localSectorAt({ eastMeters: line.offsetMeters, northMeters: 0 }, definition) - : localSectorAt({ eastMeters: 0, northMeters: line.offsetMeters }, definition); dataSource.entities.add({ - id: `${address.id}/line-${axis}/part-${segmentStart}`, + id: `grid/local/l${plan.lod.index + 1}/${hierarchy}/line-${axis}-${line.index}/part-${segmentStart}`, polyline: { positions, - width: lineWidthPixels, - material: lineColor, + width: hierarchy === "major" ? majorLineWidthPixels : lineWidthPixels, + material: hierarchy === "major" ? majorColor : lineColor, clampToGround: false, arcType: ArcType.NONE, }, @@ -1216,8 +1318,17 @@ function materializeLocalGrid(plan: LocalGridLayerPlan, serial: number): GridRes } flush(); }; - for (const line of plan.grid.lines) addSegmentedLine("east", line); - for (const line of plan.grid.lines) addSegmentedLine("north", line); + 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; @@ -1251,21 +1362,71 @@ function materializeLocalGrid(plan: LocalGridLayerPlan, serial: number): GridRes } } } - return { dataSource, points, crosses }; + + 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 line = (id: string, positions: Cartesian3[]) => positions.length >= 2 && dataSource.entities.add({ + 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: plan.lod.graticuleLineWidthPx, - material: color, + 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), @@ -1279,30 +1440,240 @@ function materializeGraticule(plan: GraticuleGridLayerPlan, serial: number): Gri : [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}/meridian/${longitude}`, + `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}/parallel/${latitude}/interval-${index}`, + `grid/wgs84/l${plan.lod.index + 1}/${major ? "major" : "minor"}/parallel/${latitude}/interval-${index}`, positionsForParts(parts, (longitude) => Cartesian3.fromDegrees(longitude, latitude, height)), + major, ); }); } - return { dataSource, points: null, crosses: null }; + + 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 GRID_SELECTION_COLOR = Color.fromCssColorString("#35CFFF"); + +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 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: clamp(2.5 * addressing.majorLineWidthMultiplier, 3, 12), + }); + if (polyline.material?.uniforms) polyline.material.uniforms.color = GRID_SELECTION_COLOR.withAlpha(0.95); + }; + 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: GRID_SELECTION_COLOR.withAlpha(volumeAvailable ? 0.1 : 0.16), + 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 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: clamp(addressing.lineWidthPixels * addressing.majorLineWidthMultiplier * 1.5, 3, 12), + material: GRID_SELECTION_COLOR.withAlpha(0.95), + 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: GRID_SELECTION_COLOR.withAlpha(0.12), + 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; @@ -1313,6 +1684,10 @@ class GridLayerController { 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, @@ -1322,8 +1697,19 @@ class GridLayerController { 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]) - : JSON.stringify([addressing.mode, addressing.lod, addressing.stepDegrees]); + ? 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]); } rebuild(presentation: MapPresentation) { @@ -1338,6 +1724,7 @@ class GridLayerController { this.cancelPending(); if (plan.mode === "hidden") { + this.setSelection(null); this.onAddressingChange(); if (this.current) this.removeResources(this.current.resources); this.current = null; @@ -1352,14 +1739,47 @@ class GridLayerController { 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, } : { 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, }; const activeAddressing = this.pending?.addressing ?? this.current?.addressing; - if (this.addressingKey(activeAddressing) !== this.addressingKey(addressing)) this.onAddressingChange(); + 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; @@ -1375,6 +1795,7 @@ class GridLayerController { 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(() => { @@ -1413,13 +1834,41 @@ class GridLayerController { 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 { - id: address.id, - lod: addressing.lod, + ...summary, mode: "3d", - address: { eastIndex: address.eastIndex, northIndex: address.northIndex }, - bounds: localSectorBounds(address, addressing.stepMeters), 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); @@ -1427,21 +1876,42 @@ class GridLayerController { 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 { - id: address.id, - lod: addressing.lod, + ...summary, mode: "graticule", - address: { longitudeIndex: address.longitudeIndex, latitudeIndex: address.latitudeIndex }, - bounds: graticuleSectorBounds(address, addressing.stepDegrees), 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) { @@ -1453,6 +1923,7 @@ class GridLayerController { }).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) { @@ -1460,6 +1931,41 @@ class GridLayerController { 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.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() { @@ -1524,6 +2030,7 @@ function applyPresentation( export const CesiumMapRenderer = forwardRef void; onGridSectorSelect?: (sector: GridSectorSelection | null) => void; + selectedGridSector?: GridSectorSelection | null; onGatewayHealth?: (health: MapGatewayHealth | null) => void; onProviderStatus?: (status: MapProviderStatus) => void; onCameraChange?: (camera: MapCameraView) => void; @@ -1538,6 +2045,7 @@ export const CesiumMapRenderer = forwardRef(function CesiumMapRenderer({ onSelect, onGridSectorSelect, + selectedGridSector, onGatewayHealth, onProviderStatus, onCameraChange, @@ -1557,6 +2065,7 @@ export const CesiumMapRenderer = forwardRef(null); const terrainRef = useRef<{ world: CesiumTerrainProvider | null; ellipsoid: EllipsoidTerrainProvider } | null>(null); const rebuildGridRef = useRef<(() => void) | null>(null); + const gridControllerRef = useRef(null); const runtimeDataSourcesRef = useRef(new Map()); const hGeoZoneLayersRef = useRef(new Map()); const faultedHGeoZoneGeometryKeysRef = useRef(new Set()); @@ -1566,6 +2075,7 @@ export const CesiumMapRenderer = forwardRef { + selectedGridSectorRef.current = selectedGridSector; + if (selectedGridSector !== undefined) gridControllerRef.current?.setSelection(selectedGridSector); + }, [selectedGridSector]); + useEffect(() => { onCameraChangeRef.current = onCameraChange; }, [onCameraChange]); @@ -1983,6 +2498,126 @@ export const CesiumMapRenderer = forwardRef { + const viewer = viewerRef.current; + if (!viewer || viewer.isDestroyed() || !Number.isFinite(longitude) || !Number.isFinite(latitude)) return false; + if (!forceFit && gridControllerRef.current?.getActiveLod() === lod) { + return focusCoordinates(longitude, latitude); + } + const profiles = presentationRef.current.gridLodProfiles; + const index = lod - 1; + const profile = profiles[index]; + if (!profile) return false; + const lowerKm = index === 0 ? 0 : profiles[index - 1].maxHeightKm; + const configuredUpperKm = index === profiles.length - 1 + ? Math.min(presentationRef.current.gridAutoDisableHeightKm, Math.max(profile.maxHeightKm, lowerKm + 1)) + : profile.maxHeightKm; + const upperKm = Math.max(lowerKm + 0.2, configuredUpperKm); + // Twelve percent clears the policy's eight-percent hysteresis in both + // travel directions while retaining room to fit a major tile. + const safeLowerKm = lowerKm + (upperKm - lowerKm) * 0.12; + const safeUpperKm = upperKm - (upperKm - lowerKm) * 0.12; + const frustum = viewer.camera.frustum as unknown as { fovy?: number }; + const verticalFov = clamp(Number(frustum.fovy) || Math.PI / 3, 0.2, Math.PI - 0.2); + const aspect = Math.max(0.1, viewer.scene.canvas.clientWidth / Math.max(1, viewer.scene.canvas.clientHeight)); + const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspect); + const limitingFov = Math.min(verticalFov, horizontalFov); + const fitHeightKm = Math.max(0, footprintMeters) / (2 * Math.tan(limitingFov / 2)) * 1.08 / 1_000; + const desiredHeightKm = clamp( + forceFit ? Math.max(safeLowerKm, fitHeightKm) : (safeLowerKm + safeUpperKm) / 2, + safeLowerKm, + safeUpperKm, + ); + const pitch = clamp(Number.isFinite(viewer.camera.pitch) ? viewer.camera.pitch : -1.15, -1.45, -0.65); + const range = desiredHeightKm * 1_000 / Math.max(0.25, Math.sin(-pitch)); + const target = Cartesian3.fromDegrees(longitude, latitude, 0, viewer.scene.globe.ellipsoid); + viewer.camera.flyToBoundingSphere(new BoundingSphere(target, 0), { + duration: 0.45, + offset: new HeadingPitchRange( + Number.isFinite(viewer.camera.heading) ? viewer.camera.heading : 0, + pitch, + range, + ), + // Do not add Cesium's usual high flight arc: it can cross a neighbouring + // LOD band even though both endpoints belong to the intended band. + maximumHeight: Math.max(desiredHeightKm * 1_000, viewer.camera.positionCartographic.height), + }); + return true; + }, [focusCoordinates]); + + const focusGridSector = useCallback((sector: GridSectorSelection) => { + let longitude: number; + let latitude: number; + if (sector.mode === "3d") { + const origin = fixedGridOrigin(presentationRef.current); + const anchor = Cartesian3.fromDegrees(origin.longitude, origin.latitude, 0); + const enu = Transforms.eastNorthUpToFixedFrame(anchor); + const tangentCenter = Matrix4.multiplyByPoint(enu, new Cartesian3( + sector.center.eastMeters, + sector.center.northMeters, + 0, + ), new Cartesian3()); + const cartographic = Cartographic.fromCartesian(tangentCenter); + if (!cartographic) return false; + longitude = CesiumMath.toDegrees(cartographic.longitude); + latitude = CesiumMath.toDegrees(cartographic.latitude); + } else { + longitude = sector.center.longitude; + latitude = sector.center.latitude; + } + const footprintMeters = sector.mode === "3d" + ? Math.hypot( + sector.bounds.east - sector.bounds.west, + sector.bounds.north - sector.bounds.south, + ) + : Math.hypot( + (sector.bounds.east - sector.bounds.west) * 111_320 * Math.cos(CesiumMath.toRadians(sector.center.latitude)), + (sector.bounds.north - sector.bounds.south) * 111_320, + ); + const activeLod = gridControllerRef.current?.getActiveLod(); + if (!focusGridTarget(longitude, latitude, sector.lod, footprintMeters, false)) return false; + if (activeLod === sector.lod) gridControllerRef.current?.setSelection(sector); + else gridControllerRef.current?.navigateToSelection(sector); + return true; + }, [focusGridTarget]); + + const focusGridMajorTile = useCallback((tile: GridMajorTileSelection) => { + if (tile.family === "local-enu-major") { + const origin = fixedGridOrigin(presentationRef.current); + const anchor = Cartesian3.fromDegrees(origin.longitude, origin.latitude, 0); + const enu = Transforms.eastNorthUpToFixedFrame(anchor); + const tangentCenter = Matrix4.multiplyByPoint(enu, new Cartesian3( + tile.center.eastMeters, + tile.center.northMeters, + 0, + ), new Cartesian3()); + const cartographic = Cartographic.fromCartesian(tangentCenter); + return cartographic ? focusGridTarget( + CesiumMath.toDegrees(cartographic.longitude), + CesiumMath.toDegrees(cartographic.latitude), + tile.lod, + Math.hypot(tile.bounds.east - tile.bounds.west, tile.bounds.north - tile.bounds.south), + true, + ) : false; + } + return focusGridTarget( + tile.center.longitude, + tile.center.latitude, + tile.lod, + Math.hypot( + (tile.bounds.east - tile.bounds.west) * 111_320 * Math.cos(CesiumMath.toRadians(tile.center.latitude)), + (tile.bounds.north - tile.bounds.south) * 111_320, + ), + true, + ); + }, [focusGridTarget]); + const focusRuntimeEntity = useCallback((entityId: string) => { const viewer = viewerRef.current; if (!viewer || viewer.isDestroyed()) return false; @@ -2012,7 +2647,9 @@ export const CesiumMapRenderer = forwardRef { const stopForPageLeave = () => stopSpiralAnimation("stopped"); @@ -2146,6 +2783,7 @@ export const CesiumMapRenderer = forwardRef onGridSectorSelectRef.current?.(null)); + gridControllerRef.current = gridController; const rebuildGrid = () => gridController?.rebuild(presentationRef.current); rebuildGridRef.current = rebuildGrid; removeGridCameraListener = viewer.camera.moveEnd.addEventListener(() => { @@ -2312,6 +2950,9 @@ export const CesiumMapRenderer = forwardRef).kind === "nodedc-hgeozone" && typeof (pickedId as Partial).entityId === "string" ) { + gridController?.setSelection(null); onGridSectorSelectRef.current?.(null); onSelectRef.current?.((pickedId as HGeoZonePickId).entityId); return; @@ -2350,7 +2993,9 @@ export const CesiumMapRenderer = forwardRef { if (!viewer || viewer.isDestroyed()) return; @@ -2395,6 +3040,7 @@ export const CesiumMapRenderer = forwardRef import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer }))); type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean }; @@ -62,6 +71,239 @@ const GRID_MODE_OPTIONS: Array> = [ { value: "graticule", label: "Гратикула", description: "Проекция по поверхности" }, ]; +type SectorGridLodProfile = GridLodProfile & { + majorLinesEnabled: boolean; + majorLabelsEnabled: boolean; + majorLineWidthMultiplier: 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)}°`; +} + function beginGatewayHealthEpoch(order: GatewayHealthOrder) { order.nextEpoch += 1; order.latestStartedEpoch = order.nextEpoch; @@ -284,7 +526,9 @@ function resolveGridLodProfiles(settings?: Partial): GridLodPro gridLodProfiles: Array.isArray(settings?.gridLodProfiles) ? settings.gridLodProfiles : [], cacheRefresh: false, }; - return Array.from({ length: 5 }, (_unused, index) => gridLodProfile(legacySettings, index) as GridLodProfile); + return Array.from({ length: 5 }, (_unused, index) => normalizeSectorGridLodProfile( + gridLodProfile(legacySettings, index) as SectorGridLodProfile, + )); } // A valid, deterministic scene view is available before Cesium emits its @@ -411,6 +655,7 @@ export const MapFixturePreview = forwardRef(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); @@ -675,18 +920,75 @@ export const MapFixturePreview = forwardRef) => setMapSettings((current) => ({ ...current, ...patch })); const selectedGridLodIndex = Math.max(0, Math.min(4, Number.parseInt(selectedGridLod, 10) || 0)); - const activeGridLod = mapSettings.gridLodProfiles[selectedGridLodIndex] ?? DEFAULT_GRID_LOD_PROFILES[selectedGridLodIndex]; + const activeGridLod = (mapSettings.gridLodProfiles[selectedGridLodIndex] + ?? DEFAULT_GRID_LOD_PROFILES[selectedGridLodIndex]) as SectorGridLodProfile; + const sectorGridLodProfiles = mapSettings.gridLodProfiles as SectorGridLodProfile[]; + const gridSectorDefinitionKey = useMemo(() => JSON.stringify({ + origin: [mapSettings.gridCenterLatitude, mapSettings.gridCenterLongitude], + profiles: sectorGridLodProfiles.map((profile) => ({ + mode: profile.mode, + stepKm: profile.stepKm, + tileSizeKm: profile.tileSizeKm, + graticuleStepDegrees: profile.graticuleStepDegrees, + majorLinesEnabled: profile.majorLinesEnabled, + volumeEnabled: profile.volumeEnabled, + volumeMinimumHeightMeters: profile.volumeMinimumHeightMeters, + volumeMaximumHeightMeters: profile.volumeMaximumHeightMeters, + volumeBandHeightMeters: profile.volumeBandHeightMeters, + })), + }), [mapSettings.gridCenterLatitude, mapSettings.gridCenterLongitude, sectorGridLodProfiles]); + const fixedSectorGridOrigin = useMemo(() => ({ + latitude: mapSettings.gridCenterLatitude, + longitude: mapSettings.gridCenterLongitude, + }), [mapSettings.gridCenterLatitude, mapSettings.gridCenterLongitude]); + const selectedGridParentLod = useMemo(() => selectedGridSector + ? gridSectorParentLodSelection(selectedGridSector, sectorGridLodProfiles, fixedSectorGridOrigin) + : null, [fixedSectorGridOrigin, sectorGridLodProfiles, selectedGridSector]); + const selectedGridNeighborTargets = useMemo(() => Object.fromEntries( + GRID_SECTOR_DIRECTIONS.map(({ id }) => [id, selectedGridSector + ? gridSectorNeighborSelection(selectedGridSector, id, sectorGridLodProfiles, fixedSectorGridOrigin) + : null]), + ) as Record, [fixedSectorGridOrigin, sectorGridLodProfiles, selectedGridSector]); + const selectedGridSectorProfile = selectedGridSector + ? sectorGridLodProfiles[selectedGridSector.lod - 1] ?? null + : null; + const selectedGridVolumeTargets = useMemo(() => ({ + above: selectedGridSector + ? gridSectorVolumeNeighborSelection(selectedGridSector, "above", selectedGridSectorProfile, fixedSectorGridOrigin) + : null, + below: selectedGridSector + ? gridSectorVolumeNeighborSelection(selectedGridSector, "below", selectedGridSectorProfile, fixedSectorGridOrigin) + : null, + }), [fixedSectorGridOrigin, selectedGridSector, selectedGridSectorProfile]); + const activeGraticuleMajorStepDegrees = activeGridLod.mode === "graticule" + ? graticuleMajorStepDegrees(activeGridLod.graticuleStepDegrees) + : null; + + useEffect(() => { + setSelectedGridSector(null); + }, [gridSectorDefinitionKey]); const minimumGridLodHeight = selectedGridLodIndex === 0 ? 0.1 : mapSettings.gridLodProfiles[selectedGridLodIndex - 1].maxHeightKm + 0.1; const maximumGridLodHeight = selectedGridLodIndex === mapSettings.gridLodProfiles.length - 1 ? 20_000 : Math.max(minimumGridLodHeight, mapSettings.gridLodProfiles[selectedGridLodIndex + 1].maxHeightKm - 0.1); - const updateGridLod = (patch: Partial) => updateMapSettings({ + const updateGridLod = (patch: Partial) => updateMapSettings({ gridLodProfiles: mapSettings.gridLodProfiles.map((profile, index) => ( index === selectedGridLodIndex ? { ...profile, ...patch } : profile )), }); + const updateGridVolumeRange = (patch: Partial>) => { + const minimum = patch.volumeMinimumHeightMeters ?? activeGridLod.volumeMinimumHeightMeters; + const maximum = Math.max(minimum + 1, patch.volumeMaximumHeightMeters ?? activeGridLod.volumeMaximumHeightMeters); + const span = maximum - minimum; + updateGridLod({ + volumeMinimumHeightMeters: minimum, + volumeMaximumHeightMeters: maximum, + volumeBandHeightMeters: Math.min(span, Math.max(1, patch.volumeBandHeightMeters ?? activeGridLod.volumeBandHeightMeters)), + }); + }; const setCacheEnabled = (cacheEnabled: boolean) => { updateMapSettings({ cacheEnabled }); setRendererRevision((value) => value + 1); @@ -711,6 +1013,29 @@ export const MapFixturePreview = forwardRef { + setGridSectorCopyState("idle"); + }, [selectedGridSector?.id]); + + const copySelectedGridSectorId = useCallback(async () => { + if (!selectedGridSector) return; + try { + await navigator.clipboard.writeText(selectedGridSector.id); + setGridSectorCopyState("copied"); + } catch { + setGridSectorCopyState("error"); + } + }, [selectedGridSector]); + + const focusGridSector = useCallback((sector: GridSectorSelection | null) => { + if (!sector || !mapRendererRef.current?.focusGridSector(sector)) return; + setSelectedGridSector(sector); + }, []); + + const focusGridMajorTile = useCallback((tile: NonNullable) => { + mapRendererRef.current?.focusGridMajorTile(tile); + }, []); + const handleSpiralStateChange = useCallback((state: CameraSpiralState) => { setSpiralRunning(state.running); setSpiralMessage(state.running ? null : spiralStopMessage(state.reason)); @@ -1323,11 +1648,60 @@ export const MapFixturePreview = forwardRef `${value} км`} onChange={(maxHeightKm) => updateGridLod({ maxHeightKm })} /> {selectedGridLodIndex === 4 ? Последний LOD остаётся активным выше своего порога до общего автовыключения. : null} - updateGridLod({ mode })} /> + updateGridLod({ + mode, + volumeEnabled: mode === "3d" && activeGridLod.volumeEnabled, + ...(mode === "3d" ? { + stepKm: Math.min(50, activeGridLod.stepKm), + tileSizeKm: normalizedMajorTileSizeKm(Math.min(50, activeGridLod.stepKm), activeGridLod.tileSizeKm), + } : { + graticuleStepDegrees: normalizedGraticuleStepDegrees(activeGridLod.graticuleStepDegrees), + }), + })} + /> `${value} м`} onChange={(heightMeters) => updateGridLod({ heightMeters })} /> `${value}°`} onChange={(max3dViewAngleDegrees) => updateGridLod({ max3dViewAngleDegrees })} /> - `${value} км`} onChange={(stepKm) => updateGridLod({ stepKm, radiusKm: Math.min(activeGridLod.radiusKm, stepKm * MAX_LOCAL_GRID_INDEX) })} /> - `${value} км`} onChange={(tileSizeKm) => updateGridLod({ tileSizeKm })} /> + `${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 })} /> @@ -1341,14 +1715,132 @@ export const MapFixturePreview = forwardRef `${value} м`} onChange={(crossesWidthMeters) => updateGridLod({ crossesWidthMeters })} /> updateGridLod({ crossesColor })} /> `${value}%`} onChange={(crossesOpacity) => updateGridLod({ crossesOpacity })} /> - `${value}°`} onChange={(graticuleStepDegrees) => updateGridLod({ graticuleStepDegrees })} /> + `${value}°`} onChange={(graticuleStepDegrees) => updateGridLod({ graticuleStepDegrees: normalizedGraticuleStepDegrees(graticuleStepDegrees) })} /> `${value} px`} onChange={(graticuleLineWidthPx) => updateGridLod({ graticuleLineWidthPx })} /> updateGridLod({ graticuleColor })} /> `${value}%`} onChange={(graticuleOpacity) => updateGridLod({ graticuleOpacity })} /> - {selectedGridSector?.id ?? "Нажмите сектор на карте"} - {selectedGridSector ? LOD {selectedGridSector.lod} · {selectedGridSector.units === "meters-enu" - ? `ENU ${selectedGridSector.bounds.west}…${selectedGridSector.bounds.east} м E; ${selectedGridSector.bounds.south}…${selectedGridSector.bounds.north} м N` - : `WGS84 ${selectedGridSector.bounds.west}…${selectedGridSector.bounds.east}°; ${selectedGridSector.bounds.south}…${selectedGridSector.bounds.north}°`} : null} + {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} +
+ {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} + : Кликните ячейку, чтобы получить устойчивый адрес, геометрию и навигацию по соседям.} +
, }, { @@ -1479,6 +1971,7 @@ export const MapFixturePreview = forwardRef = { + address: Address; + bounds: Bounds; + center: Center; + areaSquareMeters: number; +}; + +export type LocalSectorDetail = AddressDetail; +export type LocalMajorTileDetail = AddressDetail; +export type GraticuleSectorDetail = AddressDetail; +export type GraticuleMajorTileDetail = AddressDetail; + +export type LocalSectorSummary = { + id: string; + address: LocalSectorAddress; + family: "local-enu"; + lod: number; + label: string; + indices: { eastIndex: number; northIndex: number }; + bounds: LocalBounds; + center: LocalCenter; + areaSquareMeters: number; + neighbors: Record<"north" | "east" | "south" | "west", LocalSectorDetail>; + majorTile: LocalMajorTileSummary | null; + parentMajorTile: LocalMajorTileSummary | null; +}; + +export type LocalMajorTileSummary = { + id: string; + address: LocalMajorTileAddress; + family: "local-enu-major"; + lod: number; + label: string; + indices: { eastIndex: number; northIndex: number }; + minorPerSide: number; + childCount: number; + bounds: LocalBounds; + center: LocalCenter; + areaSquareMeters: number; + neighbors: Record<"north" | "east" | "south" | "west", LocalMajorTileDetail>; +}; + +export type LocalVolumeBounds = LocalBounds & { + altitudeFloorMeters: number; + altitudeCeilingMeters: number; +}; + +export type LocalVolumeSummary = { + id: string; + address: LocalVolumeAddress; + family: "local-enu-volume"; + lod: number; + label: string; + indices: { eastIndex: number; northIndex: number; bandIndex: number }; + bounds: LocalVolumeBounds; + center: LocalCenter & { altitudeMeters: number }; + footprintAreaSquareMeters: number; + volumeCubicMeters: number; +}; + +export type GraticuleSectorSummary = { + id: string; + address: GraticuleSectorAddress; + family: "wgs84-graticule"; + lod: number; + label: string; + indices: { longitudeIndex: number; latitudeIndex: number }; + bounds: GeodeticBounds; + center: GeodeticCenter; + areaSquareMeters: number; + neighbors: Record<"north" | "east" | "south" | "west", GraticuleSectorDetail | null>; + majorTile: GraticuleMajorTileSummary | null; + parentMajorTile: GraticuleMajorTileSummary | null; +}; + +export type GraticuleMajorTileSummary = { + id: string; + address: GraticuleMajorTileAddress; + family: "wgs84-graticule-major"; + lod: number; + label: string; + indices: { longitudeIndex: number; latitudeIndex: number }; + minorPerSide: number; + childCount: number; + bounds: GeodeticBounds; + center: GeodeticCenter; + areaSquareMeters: number; + neighbors: Record<"north" | "east" | "south" | "west", GraticuleMajorTileDetail | null>; +}; + export const MAX_LOCAL_GRID_INDEX: number; +export const MAX_GRID_CHILD_PAGE_SIZE: number; + +export type GridChildPage = { offset?: number; limit?: number }; export function normalizeLongitudeDegrees(value: number): number; export function fixedGridOrigin(settings: { gridCenterLatitude?: number; gridCenterLongitude?: number }): { latitude: number; longitude: number }; + +// Existing horizontal sector IDs are intentionally unchanged in v2. export function localSectorId(definition: LocalGridDefinition, eastIndex: number, northIndex: number): string; export function localSectorAt(point: { eastMeters: number; northMeters: number }, definition: LocalGridDefinition): LocalSectorAddress; -export function localSectorBounds(address: LocalSectorAddress, stepMeters: number): { west: number; east: number; south: number; north: number }; +export function localSectorBounds(address: LocalSectorAddress, stepMeters: number): LocalBounds; export function localSectorNeighbors(address: LocalSectorAddress, definition: LocalGridDefinition): Record<"north" | "east" | "south" | "west", LocalSectorAddress>; export function localParentSector(address: LocalSectorAddress, childStepMeters: number, parentDefinition: LocalGridDefinition): LocalSectorAddress; +export function localSectorCenter(address: LocalSectorAddress, stepMeters: number): LocalCenter; +export function localSectorAreaSquareMeters(address: LocalSectorAddress, stepMeters: number): number; +export function localSectorSummary( + address: LocalSectorAddress, + definition: LocalGridDefinition & { tileSizeMeters?: number }, +): LocalSectorSummary; + +export function localMajorTileId(definition: LocalHierarchyDefinition, eastIndex: number, northIndex: number): string; +export function localMajorTileAt(point: { eastMeters: number; northMeters: number }, definition: LocalHierarchyDefinition): LocalMajorTileAddress; +export function localMajorTileBounds(address: LocalMajorTileAddress, tileSizeMeters: number): LocalBounds; +export function localMajorTileForSector(address: LocalSectorAddress, definition: LocalHierarchyDefinition): LocalMajorTileAddress; +export function localMajorTileChildren( + address: LocalMajorTileAddress, + definition: LocalHierarchyDefinition, + options?: GridChildPage, +): LocalSectorAddress[]; +export function localMajorTileNeighbors(address: LocalMajorTileAddress, definition: LocalHierarchyDefinition): Record<"north" | "east" | "south" | "west", LocalMajorTileAddress>; +export function isLocalMajorLineIndex(lineIndex: number, definition: LocalHierarchyDefinition): boolean; +export function localMajorTileCenter(address: LocalMajorTileAddress, tileSizeMeters: number): LocalCenter; +export function localMajorTileAreaSquareMeters(address: LocalMajorTileAddress, tileSizeMeters: number): number; +export function localMajorTileSummary(address: LocalMajorTileAddress, definition: LocalHierarchyDefinition): LocalMajorTileSummary; + +export function localVolumeId(definition: LocalVolumeDefinition, eastIndex: number, northIndex: number, bandIndex: number): string; +export function localVolumeAt( + point: { eastMeters: number; northMeters: number; altitudeMeters: number }, + definition: LocalVolumeDefinition, +): LocalVolumeAddress | null; +export function localVolumeBounds(address: LocalVolumeAddress, definition: LocalVolumeDefinition): LocalVolumeBounds; +export function localVolumeNeighbors(address: LocalVolumeAddress, definition: LocalVolumeDefinition): { + north: LocalVolumeAddress; + east: LocalVolumeAddress; + south: LocalVolumeAddress; + west: LocalVolumeAddress; + above: LocalVolumeAddress | null; + below: LocalVolumeAddress | null; +}; +export function localVolumeCenter(address: LocalVolumeAddress, definition: LocalVolumeDefinition): LocalCenter & { altitudeMeters: number }; +export function localVolumeSummary(address: LocalVolumeAddress, definition: LocalVolumeDefinition): LocalVolumeSummary; + export function localGridPlan(input: { stepMeters: number; radiusMeters: number; maximumMarkers?: number }): { stepMeters: number; radiusMeters: number; @@ -40,9 +219,36 @@ export function localGridPlan(input: { stepMeters: number; radiusMeters: number; markerStride: number; lines: Array<{ index: number; offsetMeters: number; extentMeters: number }>; }; + +// Existing horizontal graticule IDs are intentionally unchanged in v2. export function graticuleSectorId(definition: GraticuleDefinition, longitudeIndex: number, latitudeIndex: number): string; export function graticuleSectorAt(point: { longitude: number; latitude: number }, definition: GraticuleDefinition): GraticuleSectorAddress; -export function graticuleSectorBounds(address: GraticuleSectorAddress, stepDegrees: number): { west: number; east: number; south: number; north: number }; +export function graticuleSectorBounds(address: GraticuleSectorAddress, stepDegrees: number): GeodeticBounds; +export function graticuleSectorNeighbors(address: GraticuleSectorAddress, definition: GraticuleDefinition): Record<"north" | "east" | "south" | "west", GraticuleSectorAddress | null>; +export function graticuleSectorCenter(address: GraticuleSectorAddress, stepDegrees: number): GeodeticCenter; +export function graticuleSectorAreaSquareMeters(address: GraticuleSectorAddress, stepDegrees: number): number; +export function graticuleSectorSummary( + address: GraticuleSectorAddress, + definition: GraticuleDefinition & { majorStepDegrees?: number }, +): GraticuleSectorSummary; + +export function graticuleMajorTileId(definition: GraticuleHierarchyDefinition, longitudeIndex: number, latitudeIndex: number): string; +export function graticuleMajorTileAt(point: { longitude: number; latitude: number }, definition: GraticuleHierarchyDefinition): GraticuleMajorTileAddress; +export function graticuleMajorTileBounds(address: GraticuleMajorTileAddress, majorStepDegrees: number): GeodeticBounds; +export function graticuleMajorTileForSector(address: GraticuleSectorAddress, definition: GraticuleHierarchyDefinition): GraticuleMajorTileAddress; +export function graticuleMajorTileChildren( + address: GraticuleMajorTileAddress, + definition: GraticuleHierarchyDefinition, + options?: GridChildPage, +): GraticuleSectorAddress[]; +export function graticuleMajorTileNeighbors(address: GraticuleMajorTileAddress, definition: GraticuleHierarchyDefinition): Record<"north" | "east" | "south" | "west", GraticuleMajorTileAddress | null>; +export function isGraticuleMajorLineIndex(lineIndex: number, definition: GraticuleHierarchyDefinition): boolean; +export function isGraticuleMajorLineValue(valueDegrees: number, definition: GraticuleHierarchyDefinition): boolean; +export function graticuleMajorTileCenter(address: GraticuleMajorTileAddress, majorStepDegrees: number): GeodeticCenter; +export function graticuleMajorTileAreaSquareMeters(address: GraticuleMajorTileAddress, majorStepDegrees: number): number; +export function graticuleMajorTileSummary(address: GraticuleMajorTileAddress, definition: GraticuleHierarchyDefinition): GraticuleMajorTileSummary; +export function geodeticRectangleAreaSquareMeters(bounds: GeodeticBounds): number; + export function splitLongitudeRange(west: number, east: number): Array<{ west: number; east: number }>; export function alignedGridValues(minimum: number, maximum: number, step: number, options?: { includeMaximum?: boolean }): number[]; export function boundedAngularParts(start: number, end: number, maximumSpanDegrees?: number): Array<{ start: number; end: number }>; diff --git a/apps/catalog/src/mapSectorGrid.mjs b/apps/catalog/src/mapSectorGrid.mjs index c34257a..79f8180 100644 --- a/apps/catalog/src/mapSectorGrid.mjs +++ b/apps/catalog/src/mapSectorGrid.mjs @@ -1,6 +1,10 @@ const EPSILON = 1e-9; const WGS84_EQUATORIAL_RADIUS_METERS = 6_378_137; +const WGS84_FLATTENING = 1 / 298.257223563; +const WGS84_ECCENTRICITY_SQUARED = WGS84_FLATTENING * (2 - WGS84_FLATTENING); +const WGS84_ECCENTRICITY = Math.sqrt(WGS84_ECCENTRICITY_SQUARED); export const MAX_LOCAL_GRID_INDEX = 512; +export const MAX_GRID_CHILD_PAGE_SIZE = 10_000; const finite = (value, fallback) => Number.isFinite(Number(value)) ? Number(value) : fallback; const clamp = (value, minimum, maximum) => Math.min(maximum, Math.max(minimum, value)); @@ -8,6 +12,42 @@ const canonicalZero = (value) => Object.is(value, -0) ? 0 : value; const signedIndex = (value) => value >= 0 ? `+${value}` : String(value); const decimalToken = (value, digits) => Number(value).toFixed(digits).replace("-0.", "0."); +const positiveNumber = (value, errorCode) => { + const number = Number(value); + if (!Number.isFinite(number) || number <= 0) throw new Error(errorCode); + return number; +}; + +const safeInteger = (value, errorCode = "grid_index_must_be_safe_integer") => { + const number = Number(value); + if (!Number.isSafeInteger(number)) throw new Error(errorCode); + return canonicalZero(number); +}; + +const integerMultiple = (outer, inner, errorCode) => { + const ratio = outer / inner; + const rounded = Math.round(ratio); + if (!Number.isSafeInteger(rounded) + || rounded < 1 + || Math.abs(ratio - rounded) > EPSILON * Math.max(1, Math.abs(ratio))) { + throw new Error(errorCode); + } + return rounded; +}; + +function childPageRange(totalCount, options) { + const total = safeInteger(totalCount, "grid_child_count_must_be_safe_integer"); + const offset = safeInteger(options?.offset ?? 0, "grid_child_offset_must_be_safe_integer"); + if (offset < 0) throw new Error("grid_child_offset_must_be_non_negative"); + const remaining = Math.max(0, total - offset); + const limit = options?.limit == null + ? remaining + : safeInteger(options.limit, "grid_child_limit_must_be_safe_integer"); + if (limit < 1 && remaining > 0) throw new Error("grid_child_limit_must_be_positive"); + if (limit > MAX_GRID_CHILD_PAGE_SIZE) throw new Error("grid_child_page_limit_exceeded"); + return { start: Math.min(offset, total), end: Math.min(total, offset + Math.max(0, limit)) }; +} + export function normalizeLongitudeDegrees(value) { const longitude = finite(value, 0); return canonicalZero(((longitude + 180) % 360 + 360) % 360 - 180); @@ -81,6 +121,360 @@ export function localParentSector(address, childStepMeters, parentDefinition) { }, parentDefinition); } +function localHierarchyMetrics(definition) { + const stepMeters = positiveNumber(definition?.stepMeters, "grid_step_must_be_positive"); + const tileSizeMeters = positiveNumber(definition?.tileSizeMeters, "grid_tile_size_must_be_positive"); + const minorPerMajor = integerMultiple( + tileSizeMeters, + stepMeters, + "grid_tile_size_must_be_integer_multiple_of_step", + ); + return { stepMeters, tileSizeMeters, minorPerMajor }; +} + +function localMajorDefinitionToken(definition, metrics = localHierarchyMetrics(definition)) { + return `${localDefinitionToken({ ...definition, stepMeters: metrics.stepMeters })}/t${decimalToken(metrics.tileSizeMeters, 3)}`; +} + +export function localMajorTileId(definition, eastIndex, northIndex) { + const metrics = localHierarchyMetrics(definition); + const east = safeInteger(eastIndex); + const north = safeInteger(northIndex); + return `grid/local/${localMajorDefinitionToken(definition, metrics)}/e${signedIndex(east)}/n${signedIndex(north)}`; +} + +function localMajorAddress(definition, eastIndex, northIndex) { + const metrics = localHierarchyMetrics(definition); + const east = safeInteger(eastIndex); + const north = safeInteger(northIndex); + return { + family: "local-enu-major", + lod: definition.lod, + eastIndex: east, + northIndex: north, + minorPerSide: metrics.minorPerMajor, + id: localMajorTileId(definition, east, north), + }; +} + +export function localMajorTileAt(point, definition) { + const { tileSizeMeters } = localHierarchyMetrics(definition); + return localMajorAddress( + definition, + Math.floor(finite(point?.eastMeters, 0) / tileSizeMeters), + Math.floor(finite(point?.northMeters, 0) / tileSizeMeters), + ); +} + +export function localMajorTileBounds(address, tileSizeMeters) { + const size = positiveNumber(tileSizeMeters, "grid_tile_size_must_be_positive"); + const eastIndex = safeInteger(address?.eastIndex); + const northIndex = safeInteger(address?.northIndex); + return { + west: eastIndex * size, + east: (eastIndex + 1) * size, + south: northIndex * size, + north: (northIndex + 1) * size, + }; +} + +export function localMajorTileForSector(address, definition) { + const { minorPerMajor } = localHierarchyMetrics(definition); + return localMajorAddress( + definition, + Math.floor(safeInteger(address?.eastIndex) / minorPerMajor), + Math.floor(safeInteger(address?.northIndex) / minorPerMajor), + ); +} + +export function localMajorTileChildren(address, definition, options) { + const { minorPerMajor } = localHierarchyMetrics(definition); + const majorEastIndex = safeInteger(address?.eastIndex); + const majorNorthIndex = safeInteger(address?.northIndex); + const firstEastIndex = safeInteger(majorEastIndex * minorPerMajor); + const firstNorthIndex = safeInteger(majorNorthIndex * minorPerMajor); + safeInteger(firstEastIndex + minorPerMajor - 1); + safeInteger(firstNorthIndex + minorPerMajor - 1); + const page = childPageRange(minorPerMajor ** 2, options); + const children = []; + for (let childIndex = page.start; childIndex < page.end; childIndex += 1) { + const eastIndex = firstEastIndex + childIndex % minorPerMajor; + const northIndex = firstNorthIndex + Math.floor(childIndex / minorPerMajor); + children.push({ + family: "local-enu", + lod: definition.lod, + eastIndex, + northIndex, + id: localSectorId(definition, eastIndex, northIndex), + }); + } + return children; +} + +export function localMajorTileNeighbors(address, definition) { + const eastIndex = safeInteger(address?.eastIndex); + const northIndex = safeInteger(address?.northIndex); + return { + north: localMajorAddress(definition, eastIndex, northIndex + 1), + east: localMajorAddress(definition, eastIndex + 1, northIndex), + south: localMajorAddress(definition, eastIndex, northIndex - 1), + west: localMajorAddress(definition, eastIndex - 1, northIndex), + }; +} + +export function isLocalMajorLineIndex(lineIndex, definition) { + const { minorPerMajor } = localHierarchyMetrics(definition); + return safeInteger(lineIndex, "grid_line_index_must_be_safe_integer") % minorPerMajor === 0; +} + +export function localSectorCenter(address, stepMeters) { + const bounds = localSectorBounds(address, stepMeters); + return { + eastMeters: (bounds.west + bounds.east) / 2, + northMeters: (bounds.south + bounds.north) / 2, + }; +} + +export function localSectorAreaSquareMeters(address, stepMeters) { + const bounds = localSectorBounds(address, stepMeters); + return (bounds.east - bounds.west) * (bounds.north - bounds.south); +} + +function localSectorDetail(address, stepMeters) { + const bounds = localSectorBounds(address, stepMeters); + return { + address, + bounds, + center: localSectorCenter(address, stepMeters), + areaSquareMeters: localSectorAreaSquareMeters(address, stepMeters), + }; +} + +export function localSectorSummary(address, definition) { + const bounds = localSectorBounds(address, definition.stepMeters); + const neighbors = Object.fromEntries( + Object.entries(localSectorNeighbors(address, definition)) + .map(([direction, neighbor]) => [direction, localSectorDetail(neighbor, definition.stepMeters)]), + ); + const majorTile = definition?.tileSizeMeters == null + ? null + : localMajorTileSummary(localMajorTileForSector(address, definition), definition); + return { + id: address.id, + address, + family: "local-enu", + lod: address.lod, + label: `L${address.lod} E${signedIndex(address.eastIndex)} N${signedIndex(address.northIndex)}`, + indices: { eastIndex: address.eastIndex, northIndex: address.northIndex }, + bounds, + center: localSectorCenter(address, definition.stepMeters), + areaSquareMeters: localSectorAreaSquareMeters(address, definition.stepMeters), + neighbors, + majorTile, + parentMajorTile: majorTile, + }; +} + +export function localMajorTileCenter(address, tileSizeMeters) { + const bounds = localMajorTileBounds(address, tileSizeMeters); + return { + eastMeters: (bounds.west + bounds.east) / 2, + northMeters: (bounds.south + bounds.north) / 2, + }; +} + +export function localMajorTileAreaSquareMeters(address, tileSizeMeters) { + const bounds = localMajorTileBounds(address, tileSizeMeters); + return (bounds.east - bounds.west) * (bounds.north - bounds.south); +} + +function localMajorTileDetail(address, tileSizeMeters) { + const bounds = localMajorTileBounds(address, tileSizeMeters); + return { + address, + bounds, + center: localMajorTileCenter(address, tileSizeMeters), + areaSquareMeters: localMajorTileAreaSquareMeters(address, tileSizeMeters), + }; +} + +export function localMajorTileSummary(address, definition) { + const metrics = localHierarchyMetrics(definition); + const bounds = localMajorTileBounds(address, metrics.tileSizeMeters); + const neighbors = Object.fromEntries( + Object.entries(localMajorTileNeighbors(address, definition)) + .map(([direction, neighbor]) => [direction, localMajorTileDetail(neighbor, metrics.tileSizeMeters)]), + ); + return { + id: address.id, + address, + family: "local-enu-major", + lod: address.lod, + label: `L${address.lod} TILE E${signedIndex(address.eastIndex)} N${signedIndex(address.northIndex)}`, + indices: { eastIndex: address.eastIndex, northIndex: address.northIndex }, + minorPerSide: metrics.minorPerMajor, + childCount: safeInteger(metrics.minorPerMajor ** 2, "grid_child_count_must_be_safe_integer"), + bounds, + center: localMajorTileCenter(address, metrics.tileSizeMeters), + areaSquareMeters: localMajorTileAreaSquareMeters(address, metrics.tileSizeMeters), + neighbors, + }; +} + +function localVolumeMetrics(definition) { + const stepMeters = positiveNumber(definition?.stepMeters, "grid_step_must_be_positive"); + const altitudeBandMeters = positiveNumber( + definition?.altitudeBandMeters, + "grid_altitude_band_must_be_positive", + ); + const altitudeFloorMeters = Number( + definition?.altitudeFloorMeters ?? definition?.altitudeOriginMeters ?? 0, + ); + if (!Number.isFinite(altitudeFloorMeters)) { + throw new Error("grid_altitude_floor_must_be_finite"); + } + const altitudeCeilingMeters = definition?.altitudeCeilingMeters == null + ? Number.POSITIVE_INFINITY + : Number(definition.altitudeCeilingMeters); + if ((!Number.isFinite(altitudeCeilingMeters) && altitudeCeilingMeters !== Number.POSITIVE_INFINITY) + || altitudeCeilingMeters <= altitudeFloorMeters) { + throw new Error("grid_altitude_ceiling_must_exceed_floor"); + } + return { stepMeters, altitudeBandMeters, altitudeFloorMeters, altitudeCeilingMeters }; +} + +function localVolumeDefinitionToken(definition, metrics = localVolumeMetrics(definition)) { + const ceilingToken = Number.isFinite(metrics.altitudeCeilingMeters) + ? decimalToken(metrics.altitudeCeilingMeters, 3) + : "inf"; + return `${localDefinitionToken({ ...definition, stepMeters: metrics.stepMeters })}/f${decimalToken(metrics.altitudeFloorMeters, 3)}/c${ceilingToken}/h${decimalToken(metrics.altitudeBandMeters, 3)}`; +} + +export function localVolumeId(definition, eastIndex, northIndex, bandIndex) { + const metrics = localVolumeMetrics(definition); + const east = safeInteger(eastIndex); + const north = safeInteger(northIndex); + const band = safeInteger(bandIndex, "grid_altitude_band_index_must_be_safe_integer"); + if (band < 0 + || metrics.altitudeFloorMeters + band * metrics.altitudeBandMeters >= metrics.altitudeCeilingMeters) { + throw new Error("grid_altitude_band_index_out_of_range"); + } + return `grid/local-volume/${localVolumeDefinitionToken(definition, metrics)}/e${signedIndex(east)}/n${signedIndex(north)}/z${signedIndex(band)}`; +} + +function localVolumeAddress(definition, eastIndex, northIndex, bandIndex) { + const metrics = localVolumeMetrics(definition); + const east = safeInteger(eastIndex); + const north = safeInteger(northIndex); + const band = safeInteger(bandIndex, "grid_altitude_band_index_must_be_safe_integer"); + if (band < 0) throw new Error("grid_altitude_band_index_out_of_range"); + const altitudeFloorMeters = metrics.altitudeFloorMeters + band * metrics.altitudeBandMeters; + if (altitudeFloorMeters >= metrics.altitudeCeilingMeters) { + throw new Error("grid_altitude_band_index_out_of_range"); + } + const altitudeCeilingMeters = Math.min( + metrics.altitudeCeilingMeters, + altitudeFloorMeters + metrics.altitudeBandMeters, + ); + return { + family: "local-enu-volume", + lod: definition.lod, + eastIndex: east, + northIndex: north, + bandIndex: band, + altitudeFloorMeters, + altitudeCeilingMeters, + altitudeBandMeters: altitudeCeilingMeters - altitudeFloorMeters, + id: localVolumeId(definition, east, north, band), + }; +} + +export function localVolumeAt(point, definition) { + const metrics = localVolumeMetrics(definition); + const altitudeMeters = Number(point?.altitudeMeters); + if (!Number.isFinite(altitudeMeters) + || altitudeMeters < metrics.altitudeFloorMeters + || altitudeMeters >= metrics.altitudeCeilingMeters) { + return null; + } + const eastIndex = Math.floor(finite(point?.eastMeters, 0) / metrics.stepMeters); + const northIndex = Math.floor(finite(point?.northMeters, 0) / metrics.stepMeters); + const bandIndex = Math.floor( + (altitudeMeters - metrics.altitudeFloorMeters) / metrics.altitudeBandMeters, + ); + return localVolumeAddress(definition, eastIndex, northIndex, bandIndex); +} + +export function localVolumeBounds(address, definition) { + const metrics = localVolumeMetrics(definition); + const horizontal = localSectorBounds(address, metrics.stepMeters); + const bandIndex = safeInteger(address?.bandIndex, "grid_altitude_band_index_must_be_safe_integer"); + if (bandIndex < 0) throw new Error("grid_altitude_band_index_out_of_range"); + const altitudeFloorMeters = metrics.altitudeFloorMeters + bandIndex * metrics.altitudeBandMeters; + if (altitudeFloorMeters >= metrics.altitudeCeilingMeters) { + throw new Error("grid_altitude_band_index_out_of_range"); + } + return { + ...horizontal, + altitudeFloorMeters, + altitudeCeilingMeters: Math.min( + metrics.altitudeCeilingMeters, + altitudeFloorMeters + metrics.altitudeBandMeters, + ), + }; +} + +export function localVolumeNeighbors(address, definition) { + const metrics = localVolumeMetrics(definition); + const eastIndex = safeInteger(address?.eastIndex); + const northIndex = safeInteger(address?.northIndex); + const bandIndex = safeInteger(address?.bandIndex, "grid_altitude_band_index_must_be_safe_integer"); + const aboveFloor = metrics.altitudeFloorMeters + (bandIndex + 1) * metrics.altitudeBandMeters; + return { + north: localVolumeAddress(definition, eastIndex, northIndex + 1, bandIndex), + east: localVolumeAddress(definition, eastIndex + 1, northIndex, bandIndex), + south: localVolumeAddress(definition, eastIndex, northIndex - 1, bandIndex), + west: localVolumeAddress(definition, eastIndex - 1, northIndex, bandIndex), + above: aboveFloor >= metrics.altitudeCeilingMeters + ? null + : localVolumeAddress(definition, eastIndex, northIndex, bandIndex + 1), + below: bandIndex === 0 + ? null + : localVolumeAddress(definition, eastIndex, northIndex, bandIndex - 1), + }; +} + +export function localVolumeCenter(address, definition) { + const horizontal = localSectorCenter(address, definition.stepMeters); + const bounds = localVolumeBounds(address, definition); + return { + ...horizontal, + altitudeMeters: (bounds.altitudeFloorMeters + bounds.altitudeCeilingMeters) / 2, + }; +} + +export function localVolumeSummary(address, definition) { + const bounds = localVolumeBounds(address, definition); + const footprintAreaSquareMeters = (bounds.east - bounds.west) * (bounds.north - bounds.south); + return { + id: address.id, + address, + family: "local-enu-volume", + lod: address.lod, + label: `L${address.lod} E${signedIndex(address.eastIndex)} N${signedIndex(address.northIndex)} Z${signedIndex(address.bandIndex)}`, + indices: { + eastIndex: address.eastIndex, + northIndex: address.northIndex, + bandIndex: address.bandIndex, + }, + bounds, + center: localVolumeCenter(address, definition), + footprintAreaSquareMeters, + volumeCubicMeters: footprintAreaSquareMeters + * (bounds.altitudeCeilingMeters - bounds.altitudeFloorMeters), + }; +} + export function localGridPlan({ stepMeters, radiusMeters, maximumMarkers = 5_000 }) { const step = Math.max(1, finite(stepMeters, 1)); const requestedRadius = Math.max(step, finite(radiusMeters, step)); @@ -138,6 +532,342 @@ export function graticuleSectorBounds(address, stepDegrees) { }; } +function graticuleTopology(stepDegrees, errorCode = "grid_graticule_step_must_partition_hemisphere") { + const step = positiveNumber(stepDegrees, "grid_graticule_step_must_be_positive"); + const longitudeHemisphereCount = integerMultiple(180, step, errorCode); + const latitudeHemisphereCount = integerMultiple(90, step, errorCode); + return { + stepDegrees: step, + longitudeHemisphereCount, + latitudeHemisphereCount, + longitudeCount: longitudeHemisphereCount * 2, + latitudeCount: latitudeHemisphereCount * 2, + }; +} + +function wrapGraticuleIndex(index, hemisphereCount) { + const span = hemisphereCount * 2; + return canonicalZero(((index + hemisphereCount) % span + span) % span - hemisphereCount); +} + +function graticuleSectorAddress(definition, longitudeIndex, latitudeIndex) { + const longitude = safeInteger(longitudeIndex); + const latitude = safeInteger(latitudeIndex); + return { + family: "wgs84-graticule", + lod: definition.lod, + longitudeIndex: longitude, + latitudeIndex: latitude, + id: graticuleSectorId(definition, longitude, latitude), + }; +} + +export function graticuleSectorNeighbors(address, definition) { + const stepDegrees = positiveNumber(definition?.stepDegrees, "grid_graticule_step_must_be_positive"); + const minimumLongitudeIndex = Math.floor(-180 / stepDegrees); + const maximumLongitudeIndex = Math.floor((180 - EPSILON) / stepDegrees); + const minimumLatitudeIndex = Math.floor(-90 / stepDegrees); + const maximumLatitudeIndex = Math.floor((90 - EPSILON) / stepDegrees); + const longitudeIndex = safeInteger(address?.longitudeIndex); + const latitudeIndex = safeInteger(address?.latitudeIndex); + if (longitudeIndex < minimumLongitudeIndex || longitudeIndex > maximumLongitudeIndex) { + throw new Error("grid_graticule_longitude_index_out_of_range"); + } + if (latitudeIndex < minimumLatitudeIndex || latitudeIndex > maximumLatitudeIndex) { + throw new Error("grid_graticule_latitude_index_out_of_range"); + } + return { + north: latitudeIndex === maximumLatitudeIndex + ? null + : graticuleSectorAddress(definition, longitudeIndex, latitudeIndex + 1), + east: graticuleSectorAddress( + definition, + longitudeIndex === maximumLongitudeIndex ? minimumLongitudeIndex : longitudeIndex + 1, + latitudeIndex, + ), + south: latitudeIndex === minimumLatitudeIndex + ? null + : graticuleSectorAddress(definition, longitudeIndex, latitudeIndex - 1), + west: graticuleSectorAddress( + definition, + longitudeIndex === minimumLongitudeIndex ? maximumLongitudeIndex : longitudeIndex - 1, + latitudeIndex, + ), + }; +} + +function graticuleHierarchyMetrics(definition) { + const stepDegrees = positiveNumber(definition?.stepDegrees, "grid_graticule_step_must_be_positive"); + const majorStepDegrees = positiveNumber( + definition?.majorStepDegrees, + "grid_graticule_major_step_must_be_positive", + ); + const minorPerMajor = integerMultiple( + majorStepDegrees, + stepDegrees, + "grid_graticule_major_step_must_be_integer_multiple_of_step", + ); + const topology = graticuleTopology( + majorStepDegrees, + "grid_graticule_major_step_must_partition_hemisphere", + ); + return { ...topology, stepDegrees, majorStepDegrees, minorPerMajor }; +} + +function graticuleMajorDefinitionToken(definition, metrics = graticuleHierarchyMetrics(definition)) { + return `${graticuleDefinitionToken({ ...definition, stepDegrees: metrics.stepDegrees })}/m${decimalToken(metrics.majorStepDegrees, 6)}`; +} + +export function graticuleMajorTileId(definition, longitudeIndex, latitudeIndex) { + const metrics = graticuleHierarchyMetrics(definition); + const longitude = wrapGraticuleIndex( + safeInteger(longitudeIndex), + metrics.longitudeHemisphereCount, + ); + const latitude = safeInteger(latitudeIndex); + if (latitude < -metrics.latitudeHemisphereCount || latitude >= metrics.latitudeHemisphereCount) { + throw new Error("grid_graticule_major_latitude_index_out_of_range"); + } + return `grid/${graticuleMajorDefinitionToken(definition, metrics)}/x${signedIndex(longitude)}/y${signedIndex(latitude)}`; +} + +function graticuleMajorAddress(definition, longitudeIndex, latitudeIndex) { + const metrics = graticuleHierarchyMetrics(definition); + const longitude = wrapGraticuleIndex(safeInteger(longitudeIndex), metrics.longitudeHemisphereCount); + const latitude = safeInteger(latitudeIndex); + if (latitude < -metrics.latitudeHemisphereCount || latitude >= metrics.latitudeHemisphereCount) { + throw new Error("grid_graticule_major_latitude_index_out_of_range"); + } + return { + family: "wgs84-graticule-major", + lod: definition.lod, + longitudeIndex: longitude, + latitudeIndex: latitude, + minorPerSide: metrics.minorPerMajor, + id: graticuleMajorTileId(definition, longitude, latitude), + }; +} + +export function graticuleMajorTileAt(point, definition) { + const { majorStepDegrees } = graticuleHierarchyMetrics(definition); + const longitude = normalizeLongitudeDegrees(point?.longitude); + const latitude = clamp(finite(point?.latitude, 0), -90, 90 - EPSILON); + return graticuleMajorAddress( + definition, + Math.floor(longitude / majorStepDegrees), + Math.floor(latitude / majorStepDegrees), + ); +} + +export function graticuleMajorTileBounds(address, majorStepDegrees) { + const topology = graticuleTopology( + majorStepDegrees, + "grid_graticule_major_step_must_partition_hemisphere", + ); + const longitudeIndex = wrapGraticuleIndex( + safeInteger(address?.longitudeIndex), + topology.longitudeHemisphereCount, + ); + const latitudeIndex = safeInteger(address?.latitudeIndex); + if (latitudeIndex < -topology.latitudeHemisphereCount + || latitudeIndex >= topology.latitudeHemisphereCount) { + throw new Error("grid_graticule_major_latitude_index_out_of_range"); + } + return { + west: longitudeIndex * topology.stepDegrees, + east: (longitudeIndex + 1) * topology.stepDegrees, + south: latitudeIndex * topology.stepDegrees, + north: (latitudeIndex + 1) * topology.stepDegrees, + }; +} + +export function graticuleMajorTileForSector(address, definition) { + const metrics = graticuleHierarchyMetrics(definition); + return graticuleMajorAddress( + definition, + Math.floor(safeInteger(address?.longitudeIndex) / metrics.minorPerMajor), + Math.floor(safeInteger(address?.latitudeIndex) / metrics.minorPerMajor), + ); +} + +export function graticuleMajorTileChildren(address, definition, options) { + const metrics = graticuleHierarchyMetrics(definition); + const major = graticuleMajorAddress(definition, address?.longitudeIndex, address?.latitudeIndex); + const firstLongitudeIndex = major.longitudeIndex * metrics.minorPerMajor; + const firstLatitudeIndex = major.latitudeIndex * metrics.minorPerMajor; + const page = childPageRange(metrics.minorPerMajor ** 2, options); + const children = []; + for (let childIndex = page.start; childIndex < page.end; childIndex += 1) { + children.push(graticuleSectorAddress( + definition, + firstLongitudeIndex + childIndex % metrics.minorPerMajor, + firstLatitudeIndex + Math.floor(childIndex / metrics.minorPerMajor), + )); + } + return children; +} + +export function graticuleMajorTileNeighbors(address, definition) { + const metrics = graticuleHierarchyMetrics(definition); + const tile = graticuleMajorAddress(definition, address?.longitudeIndex, address?.latitudeIndex); + return { + north: tile.latitudeIndex === metrics.latitudeHemisphereCount - 1 + ? null + : graticuleMajorAddress(definition, tile.longitudeIndex, tile.latitudeIndex + 1), + east: graticuleMajorAddress(definition, tile.longitudeIndex + 1, tile.latitudeIndex), + south: tile.latitudeIndex === -metrics.latitudeHemisphereCount + ? null + : graticuleMajorAddress(definition, tile.longitudeIndex, tile.latitudeIndex - 1), + west: graticuleMajorAddress(definition, tile.longitudeIndex - 1, tile.latitudeIndex), + }; +} + +export function isGraticuleMajorLineIndex(lineIndex, definition) { + const { minorPerMajor } = graticuleHierarchyMetrics(definition); + return safeInteger(lineIndex, "grid_line_index_must_be_safe_integer") % minorPerMajor === 0; +} + +export function isGraticuleMajorLineValue(valueDegrees, definition) { + const { majorStepDegrees } = graticuleHierarchyMetrics(definition); + const ratio = finite(valueDegrees, Number.NaN) / majorStepDegrees; + return Number.isFinite(ratio) + && Math.abs(ratio - Math.round(ratio)) <= EPSILON * Math.max(1, Math.abs(ratio)); +} + +function authalicQ(latitudeRadians) { + const sine = Math.sin(latitudeRadians); + const eccentricitySine = WGS84_ECCENTRICITY * sine; + return (1 - WGS84_ECCENTRICITY_SQUARED) * ( + sine / (1 - WGS84_ECCENTRICITY_SQUARED * sine * sine) + - Math.log((1 - eccentricitySine) / (1 + eccentricitySine)) / (2 * WGS84_ECCENTRICITY) + ); +} + +export function geodeticRectangleAreaSquareMeters(bounds) { + const south = clamp(finite(bounds?.south, -90), -90, 90); + const north = clamp(finite(bounds?.north, 90), -90, 90); + if (north <= south) return 0; + const rawWest = finite(bounds?.west, -180); + const rawEast = finite(bounds?.east, 180); + let longitudeSpanDegrees = rawEast - rawWest; + if (Math.abs(longitudeSpanDegrees) >= 360 - EPSILON) longitudeSpanDegrees = 360; + else if (longitudeSpanDegrees < 0) longitudeSpanDegrees += 360; + longitudeSpanDegrees = clamp(longitudeSpanDegrees, 0, 360); + const longitudeSpanRadians = longitudeSpanDegrees * Math.PI / 180; + const southRadians = south * Math.PI / 180; + const northRadians = north * Math.PI / 180; + return WGS84_EQUATORIAL_RADIUS_METERS ** 2 + * longitudeSpanRadians + * Math.abs(authalicQ(northRadians) - authalicQ(southRadians)) + / 2; +} + +function geodeticBoundsCenter(bounds) { + const width = bounds.east - bounds.west; + return { + longitude: width >= 360 - EPSILON + ? 0 + : normalizeLongitudeDegrees(bounds.west + width / 2), + latitude: (bounds.south + bounds.north) / 2, + }; +} + +export function graticuleSectorCenter(address, stepDegrees) { + return geodeticBoundsCenter(graticuleSectorBounds(address, stepDegrees)); +} + +export function graticuleSectorAreaSquareMeters(address, stepDegrees) { + return geodeticRectangleAreaSquareMeters(graticuleSectorBounds(address, stepDegrees)); +} + +function graticuleSectorDetail(address, stepDegrees) { + const bounds = graticuleSectorBounds(address, stepDegrees); + return { + address, + bounds, + center: graticuleSectorCenter(address, stepDegrees), + areaSquareMeters: graticuleSectorAreaSquareMeters(address, stepDegrees), + }; +} + +export function graticuleSectorSummary(address, definition) { + const bounds = graticuleSectorBounds(address, definition.stepDegrees); + const neighbors = Object.fromEntries( + Object.entries(graticuleSectorNeighbors(address, definition)) + .map(([direction, neighbor]) => [ + direction, + neighbor == null ? null : graticuleSectorDetail(neighbor, definition.stepDegrees), + ]), + ); + const majorTile = definition?.majorStepDegrees == null + ? null + : graticuleMajorTileSummary(graticuleMajorTileForSector(address, definition), definition); + return { + id: address.id, + address, + family: "wgs84-graticule", + lod: address.lod, + label: `L${address.lod} X${signedIndex(address.longitudeIndex)} Y${signedIndex(address.latitudeIndex)}`, + indices: { + longitudeIndex: address.longitudeIndex, + latitudeIndex: address.latitudeIndex, + }, + bounds, + center: graticuleSectorCenter(address, definition.stepDegrees), + areaSquareMeters: graticuleSectorAreaSquareMeters(address, definition.stepDegrees), + neighbors, + majorTile, + parentMajorTile: majorTile, + }; +} + +export function graticuleMajorTileCenter(address, majorStepDegrees) { + return geodeticBoundsCenter(graticuleMajorTileBounds(address, majorStepDegrees)); +} + +export function graticuleMajorTileAreaSquareMeters(address, majorStepDegrees) { + return geodeticRectangleAreaSquareMeters(graticuleMajorTileBounds(address, majorStepDegrees)); +} + +function graticuleMajorTileDetail(address, majorStepDegrees) { + const bounds = graticuleMajorTileBounds(address, majorStepDegrees); + return { + address, + bounds, + center: graticuleMajorTileCenter(address, majorStepDegrees), + areaSquareMeters: graticuleMajorTileAreaSquareMeters(address, majorStepDegrees), + }; +} + +export function graticuleMajorTileSummary(address, definition) { + const metrics = graticuleHierarchyMetrics(definition); + const bounds = graticuleMajorTileBounds(address, metrics.majorStepDegrees); + const neighbors = Object.fromEntries( + Object.entries(graticuleMajorTileNeighbors(address, definition)) + .map(([direction, neighbor]) => [ + direction, + neighbor == null ? null : graticuleMajorTileDetail(neighbor, metrics.majorStepDegrees), + ]), + ); + return { + id: address.id, + address, + family: "wgs84-graticule-major", + lod: address.lod, + label: `L${address.lod} TILE X${signedIndex(address.longitudeIndex)} Y${signedIndex(address.latitudeIndex)}`, + indices: { + longitudeIndex: address.longitudeIndex, + latitudeIndex: address.latitudeIndex, + }, + minorPerSide: metrics.minorPerMajor, + childCount: safeInteger(metrics.minorPerMajor ** 2, "grid_child_count_must_be_safe_integer"), + bounds, + center: graticuleMajorTileCenter(address, metrics.majorStepDegrees), + areaSquareMeters: graticuleMajorTileAreaSquareMeters(address, metrics.majorStepDegrees), + neighbors, + }; +} + export function splitLongitudeRange(west, east) { const rawWest = finite(west, -180); const rawEast = finite(east, 180); diff --git a/apps/catalog/src/styles.css b/apps/catalog/src/styles.css index c323bf5..f3eb537 100644 --- a/apps/catalog/src/styles.css +++ b/apps/catalog/src/styles.css @@ -983,6 +983,75 @@ textarea { text-align: right; } +.catalog-map-grid-sector { + display: grid; + min-width: 0; + gap: 0.55rem; + border-top: 1px solid color-mix(in srgb, var(--nodedc-glass-outline) 44%, transparent); + padding-top: 0.7rem; +} + +.catalog-map-grid-sector__facts, +.catalog-map-grid-sector__volume { + display: grid; + min-width: 0; + gap: 0.35rem; +} + +.catalog-map-grid-sector__facts .nodedc-control-row__control, +.catalog-map-grid-sector__volume .nodedc-control-row__control { + min-width: 0; + overflow-wrap: anywhere; + text-align: right; +} + +.catalog-map-grid-sector__relation, +.catalog-map-grid-sector__neighbor, +.catalog-map-grid-sector__volume { + display: grid; + min-width: 0; + gap: 0.4rem; + border-radius: var(--nodedc-radius-control); + background: var(--nodedc-glass-control-bg); + padding: 0.62rem; +} + +.catalog-map-grid-sector__relation > small, +.catalog-map-grid-sector__neighbor > code, +.catalog-map-grid-sector__volume > code { + color: var(--nodedc-text-muted); + font-size: var(--nodedc-font-size-xs); + line-height: 1.35; +} + +.catalog-map-grid-sector__relation > code, +.catalog-map-grid-sector__neighbor > code, +.catalog-map-grid-sector__volume > code { + min-width: 0; + overflow-wrap: anywhere; + white-space: normal; +} + +.catalog-map-grid-sector__neighbors { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; +} + +.catalog-map-grid-sector__volume-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.4rem; +} + +.catalog-map-grid-sector__neighbor { + align-content: start; +} + +.catalog-map-grid-sector__volume[data-enabled] { + box-shadow: inset 0 0 0 1px color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 28%, transparent); +} + .catalog-map-subject-card__tab-panel { display: grid; gap: 0.65rem; diff --git a/docs/FOUNDRY_MAP_CESIUM_CANON.md b/docs/FOUNDRY_MAP_CESIUM_CANON.md index 1409d9f..e409e7c 100644 --- a/docs/FOUNDRY_MAP_CESIUM_CANON.md +++ b/docs/FOUNDRY_MAP_CESIUM_CANON.md @@ -4,7 +4,7 @@ Область: Module Foundry Map Page, Platform Map Gateway, общий TileCache, DC AMD Proxy и DC AMD Connector -Контрольная дата: 2026-07-16 +Контрольная дата: 2026-08-06 Этот документ — воспроизводимый канон интеграции Cesium в NODE.DC. Он описывает не только текущий Foundry Map Page, но и обязательные границы для следующих продуктов NODE.DC, которым потребуются Cesium Terrain, imagery, 3D Tiles или другие разрешённые provider assets. @@ -181,6 +181,27 @@ Per-entity `PolygonGraphics` для массового слоя геозон н а viewport через антимеридиан разбивается на два диапазона; - sector id включает origin, LOD, шаг и signed integer address. Он не содержит render serial, viewport или camera state и сохраняется при pan/zoom/reload; +- каждый minor-сектор имеет вычисляемые bounds, центр, площадь и четыре + адресных соседа. Для WGS84 longitude-соседи замыкаются через антимеридиан, + а за северным и южным полюсом сосед отсутствует; +- local ENU hierarchy использует `tileSizeKm` как размер major-тайла. Major + содержит целое число minor-секторов по каждой стороне; изменение шага в UI + сохраняет это соотношение. Для WGS84 major-шаг равен пяти minor-шагам; +- выбранный minor-сектор показывает parent major, число его children и + позволяет центрировать parent, переходить к четырём соседям и совместимому + следующему LOD без перебора Cesium entities. Полный список children не + материализуется автоматически: чистый API выдаёт его ограниченными pages; + горизонтальный sector id при этом не меняется; +- LOD 1–2 по умолчанию поддерживают отдельный высотный адрес ENU-объёма. + Диапазон задаётся как полуоткрытый `[minimum, maximum)`, делится на bands, а + последний band может быть короче. Volume id является дочерним адресом и не + подменяет стабильный id горизонтального сектора; +- Cesium материализует пространственную клетку только для выбранного сектора: + нижнюю и верхнюю рамки, вертикальные рёбра и границы bands. Вся ENU-плоскость + не размножается в объёмные entities, поэтому стоимость выбора ограничена; +- minor и major линии собираются раздельно. Major получает только множитель + толщины, наследует цвет/прозрачность текущего LOD, а подписи создаются + ограниченным набором вокруг viewport и не являются источником адресации; - camera может менять LOD, view-cone и набор видимых линий, но не координаты, границы или идентификаторы секторов; - новый buffer подключается до удаления предыдущего. Дешёвый plan key diff --git a/package.json b/package.json index 45ac1a4..f45f73e 100644 --- a/package.json +++ b/package.json @@ -23,7 +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 scripts/map-sector-grid.test.mjs", + "test:map-grid-lod": "node --test scripts/map-grid-lod.test.mjs scripts/map-sector-grid.test.mjs server/map-grid-persistence.test.mjs", "test:map-object-layers": "node --test scripts/map-object-layers.test.mjs", "test:map-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 f2857a3..b5410ae 100644 --- a/runtime-seed/page-layouts/map.json +++ b/runtime-seed/page-layouts/map.json @@ -75,11 +75,11 @@ "gridCrossesColor": "#9c9c9c", "gridCrossesOpacity": 46, "gridLodProfiles": [ - { "maxHeightKm": 10, "stepKm": 1, "mode": "3d", "heightMeters": 300, "max3dViewAngleDegrees": 30, "tileSizeKm": 10, "radiusKm": 50, "lineDiameterMeters": 4, "lineColor": "#f5f5f5", "lineOpacity": 12, "dotsEnabled": true, "dotsDiameterMeters": 7, "dotsColor": "#ffffff", "dotsOpacity": 58, "crossesEnabled": false, "crossesLengthMeters": 60, "crossesWidthMeters": 10, "crossesColor": "#9c9c9c", "crossesOpacity": 46, "graticuleStepDegrees": 0.25, "graticuleLineWidthPx": 1, "graticuleColor": "#f5f5f5", "graticuleOpacity": 12 }, - { "maxHeightKm": 50, "stepKm": 5, "mode": "3d", "heightMeters": 500, "max3dViewAngleDegrees": 30, "tileSizeKm": 10, "radiusKm": 1000, "lineDiameterMeters": 10, "lineColor": "#f8fbfc", "lineOpacity": 12, "dotsEnabled": true, "dotsDiameterMeters": 80, "dotsColor": "#ffffff", "dotsOpacity": 22, "crossesEnabled": false, "crossesLengthMeters": 60, "crossesWidthMeters": 10, "crossesColor": "#9c9c9c", "crossesOpacity": 46, "graticuleStepDegrees": 0.5, "graticuleLineWidthPx": 1, "graticuleColor": "#f8fbfc", "graticuleOpacity": 12 }, - { "maxHeightKm": 200, "stepKm": 25, "mode": "3d", "heightMeters": 500, "max3dViewAngleDegrees": 30, "tileSizeKm": 25, "radiusKm": 1000, "lineDiameterMeters": 7, "lineColor": "#fafafa", "lineOpacity": 12, "dotsEnabled": true, "dotsDiameterMeters": 10, "dotsColor": "#ffffff", "dotsOpacity": 58, "crossesEnabled": false, "crossesLengthMeters": 60, "crossesWidthMeters": 10, "crossesColor": "#9c9c9c", "crossesOpacity": 46, "graticuleStepDegrees": 1, "graticuleLineWidthPx": 1, "graticuleColor": "#fafafa", "graticuleOpacity": 12 }, - { "maxHeightKm": 800, "stepKm": 50, "mode": "graticule", "heightMeters": 500, "max3dViewAngleDegrees": 30, "tileSizeKm": 10, "radiusKm": 1000, "lineDiameterMeters": 10, "lineColor": "#fcfdfd", "lineOpacity": 12, "dotsEnabled": true, "dotsDiameterMeters": 10, "dotsColor": "#fcfdfd", "dotsOpacity": 58, "crossesEnabled": false, "crossesLengthMeters": 60, "crossesWidthMeters": 10, "crossesColor": "#9c9c9c", "crossesOpacity": 46, "graticuleStepDegrees": 2, "graticuleLineWidthPx": 1, "graticuleColor": "#fcfdfd", "graticuleOpacity": 12 }, - { "maxHeightKm": 3000, "stepKm": 50, "mode": "graticule", "heightMeters": 500, "max3dViewAngleDegrees": 30, "tileSizeKm": 10, "radiusKm": 100, "lineDiameterMeters": 7, "lineColor": "#9c9c9c", "lineOpacity": 12, "dotsEnabled": true, "dotsDiameterMeters": 10, "dotsColor": "#9c9c9c", "dotsOpacity": 58, "crossesEnabled": false, "crossesLengthMeters": 60, "crossesWidthMeters": 10, "crossesColor": "#9c9c9c", "crossesOpacity": 46, "graticuleStepDegrees": 2, "graticuleLineWidthPx": 1, "graticuleColor": "#9c9c9c", "graticuleOpacity": 8 } + { "maxHeightKm": 10, "stepKm": 1, "mode": "3d", "heightMeters": 300, "max3dViewAngleDegrees": 30, "tileSizeKm": 10, "radiusKm": 50, "lineDiameterMeters": 4, "lineColor": "#f5f5f5", "lineOpacity": 12, "dotsEnabled": true, "dotsDiameterMeters": 7, "dotsColor": "#ffffff", "dotsOpacity": 58, "crossesEnabled": false, "crossesLengthMeters": 60, "crossesWidthMeters": 10, "crossesColor": "#9c9c9c", "crossesOpacity": 46, "graticuleStepDegrees": 0.25, "graticuleLineWidthPx": 1, "graticuleColor": "#f5f5f5", "graticuleOpacity": 12, "majorLinesEnabled": true, "majorLabelsEnabled": true, "majorLineWidthMultiplier": 2.5, "volumeEnabled": true, "volumeMinimumHeightMeters": 0, "volumeMaximumHeightMeters": 300, "volumeBandHeightMeters": 300 }, + { "maxHeightKm": 50, "stepKm": 5, "mode": "3d", "heightMeters": 500, "max3dViewAngleDegrees": 30, "tileSizeKm": 10, "radiusKm": 1000, "lineDiameterMeters": 10, "lineColor": "#f8fbfc", "lineOpacity": 12, "dotsEnabled": true, "dotsDiameterMeters": 80, "dotsColor": "#ffffff", "dotsOpacity": 22, "crossesEnabled": false, "crossesLengthMeters": 60, "crossesWidthMeters": 10, "crossesColor": "#9c9c9c", "crossesOpacity": 46, "graticuleStepDegrees": 0.5, "graticuleLineWidthPx": 1, "graticuleColor": "#f8fbfc", "graticuleOpacity": 12, "majorLinesEnabled": true, "majorLabelsEnabled": true, "majorLineWidthMultiplier": 2.5, "volumeEnabled": true, "volumeMinimumHeightMeters": 0, "volumeMaximumHeightMeters": 500, "volumeBandHeightMeters": 500 }, + { "maxHeightKm": 200, "stepKm": 25, "mode": "3d", "heightMeters": 500, "max3dViewAngleDegrees": 30, "tileSizeKm": 25, "radiusKm": 1000, "lineDiameterMeters": 7, "lineColor": "#fafafa", "lineOpacity": 12, "dotsEnabled": true, "dotsDiameterMeters": 10, "dotsColor": "#ffffff", "dotsOpacity": 58, "crossesEnabled": false, "crossesLengthMeters": 60, "crossesWidthMeters": 10, "crossesColor": "#9c9c9c", "crossesOpacity": 46, "graticuleStepDegrees": 1, "graticuleLineWidthPx": 1, "graticuleColor": "#fafafa", "graticuleOpacity": 12, "majorLinesEnabled": true, "majorLabelsEnabled": false, "majorLineWidthMultiplier": 2, "volumeEnabled": false, "volumeMinimumHeightMeters": 0, "volumeMaximumHeightMeters": 500, "volumeBandHeightMeters": 500 }, + { "maxHeightKm": 800, "stepKm": 50, "mode": "graticule", "heightMeters": 500, "max3dViewAngleDegrees": 30, "tileSizeKm": 10, "radiusKm": 1000, "lineDiameterMeters": 10, "lineColor": "#fcfdfd", "lineOpacity": 12, "dotsEnabled": true, "dotsDiameterMeters": 10, "dotsColor": "#fcfdfd", "dotsOpacity": 58, "crossesEnabled": false, "crossesLengthMeters": 60, "crossesWidthMeters": 10, "crossesColor": "#9c9c9c", "crossesOpacity": 46, "graticuleStepDegrees": 2, "graticuleLineWidthPx": 1, "graticuleColor": "#fcfdfd", "graticuleOpacity": 12, "majorLinesEnabled": true, "majorLabelsEnabled": false, "majorLineWidthMultiplier": 2, "volumeEnabled": false, "volumeMinimumHeightMeters": 0, "volumeMaximumHeightMeters": 500, "volumeBandHeightMeters": 500 }, + { "maxHeightKm": 3000, "stepKm": 50, "mode": "graticule", "heightMeters": 500, "max3dViewAngleDegrees": 30, "tileSizeKm": 10, "radiusKm": 100, "lineDiameterMeters": 7, "lineColor": "#9c9c9c", "lineOpacity": 12, "dotsEnabled": true, "dotsDiameterMeters": 10, "dotsColor": "#9c9c9c", "dotsOpacity": 58, "crossesEnabled": false, "crossesLengthMeters": 60, "crossesWidthMeters": 10, "crossesColor": "#9c9c9c", "crossesOpacity": 46, "graticuleStepDegrees": 2, "graticuleLineWidthPx": 1, "graticuleColor": "#9c9c9c", "graticuleOpacity": 8, "majorLinesEnabled": true, "majorLabelsEnabled": false, "majorLineWidthMultiplier": 2, "volumeEnabled": false, "volumeMinimumHeightMeters": 0, "volumeMaximumHeightMeters": 500, "volumeBandHeightMeters": 500 } ] }, "mapHeight": 620, diff --git a/scripts/map-grid-lod.test.mjs b/scripts/map-grid-lod.test.mjs index 86f5ffa..0390ff1 100644 --- a/scripts/map-grid-lod.test.mjs +++ b/scripts/map-grid-lod.test.mjs @@ -23,7 +23,17 @@ const settings = { }; test("canonical defaults preserve the exact effective MMAP/MOSCOWMAP five-LOD donor profile", () => { - assert.deepEqual(DEFAULT_GRID_LOD_PROFILES, [ + const donorProfiles = DEFAULT_GRID_LOD_PROFILES.map(({ + majorLinesEnabled: _majorLinesEnabled, + majorLabelsEnabled: _majorLabelsEnabled, + majorLineWidthMultiplier: _majorLineWidthMultiplier, + volumeEnabled: _volumeEnabled, + volumeMinimumHeightMeters: _volumeMinimumHeightMeters, + volumeMaximumHeightMeters: _volumeMaximumHeightMeters, + volumeBandHeightMeters: _volumeBandHeightMeters, + ...profile + }) => profile); + assert.deepEqual(donorProfiles, [ { maxHeightKm: 10, stepKm: 1, @@ -150,6 +160,21 @@ test("canonical defaults preserve the exact effective MMAP/MOSCOWMAP five-LOD do graticuleOpacity: 8, }, ]); + assert.deepEqual(DEFAULT_GRID_LOD_PROFILES.map((profile) => ({ + majorLinesEnabled: profile.majorLinesEnabled, + majorLabelsEnabled: profile.majorLabelsEnabled, + majorLineWidthMultiplier: profile.majorLineWidthMultiplier, + volumeEnabled: profile.volumeEnabled, + volumeMinimumHeightMeters: profile.volumeMinimumHeightMeters, + volumeMaximumHeightMeters: profile.volumeMaximumHeightMeters, + volumeBandHeightMeters: profile.volumeBandHeightMeters, + })), [ + { majorLinesEnabled: true, majorLabelsEnabled: true, majorLineWidthMultiplier: 2.5, volumeEnabled: true, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 300, volumeBandHeightMeters: 300 }, + { majorLinesEnabled: true, majorLabelsEnabled: true, majorLineWidthMultiplier: 2.5, volumeEnabled: true, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500 }, + { majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2, volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500 }, + { majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2, volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500 }, + { majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2, volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500 }, + ]); }); test("LOD selection keeps three spatial bands, two graticule bands and the independent 10,000 km cutoff", () => { @@ -264,6 +289,15 @@ test("renderer uses fixed ENU sectors, angular graticules and a non-blank double 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/); diff --git a/scripts/map-sector-grid.test.mjs b/scripts/map-sector-grid.test.mjs index a6282bd..bae4126 100644 --- a/scripts/map-sector-grid.test.mjs +++ b/scripts/map-sector-grid.test.mjs @@ -12,16 +12,44 @@ import { alignedGridValues, boundedAngularParts, fixedGridOrigin, + geodeticRectangleAreaSquareMeters, graticuleGranularity, graticuleLinePlan, + graticuleMajorTileAt, + graticuleMajorTileBounds, + graticuleMajorTileChildren, + graticuleMajorTileForSector, + graticuleMajorTileNeighbors, + graticuleMajorTileSummary, + graticuleSectorAreaSquareMeters, graticuleSectorAt, graticuleSectorBounds, + graticuleSectorNeighbors, + graticuleSectorSummary, + isGraticuleMajorLineIndex, + isGraticuleMajorLineValue, + isLocalMajorLineIndex, localGridPlan, + localMajorTileAt, + localMajorTileBounds, + localMajorTileChildren, + localMajorTileForSector, + localMajorTileId, + localMajorTileNeighbors, + localMajorTileSummary, + MAX_GRID_CHILD_PAGE_SIZE, MAX_LOCAL_GRID_INDEX, localParentSector, + localSectorAreaSquareMeters, localSectorAt, localSectorBounds, localSectorNeighbors, + localSectorSummary, + localVolumeAt, + localVolumeBounds, + localVolumeId, + localVolumeNeighbors, + localVolumeSummary, normalizeLongitudeDegrees, splitLongitudeRange, } from "../apps/catalog/src/mapSectorGrid.mjs"; @@ -33,6 +61,18 @@ const localDefinition = { stepMeters: 1_000, }; +const localHierarchyDefinition = { + ...localDefinition, + tileSizeMeters: 10_000, +}; + +const volumeDefinition = { + ...localDefinition, + altitudeFloorMeters: -50, + altitudeCeilingMeters: 225, + altitudeBandMeters: 100, +}; + test("fixed origin normalizes WGS84 coordinates without consulting the camera", () => { assert.deepEqual(fixedGridOrigin({}), { latitude: 55.7558, longitude: 37.6173 }); assert.deepEqual(fixedGridOrigin({ gridCenterLatitude: 100, gridCenterLongitude: 540 }), { @@ -108,6 +148,230 @@ test("local sector neighbors and integer-ratio parents preserve signed addressin ); }); +test("local major tiles keep a fixed phase across positive and negative ENU boundaries", () => { + const samples = [ + [-10_000.001, -2], + [-10_000, -1], + [-0.001, -1], + [0, 0], + [9_999.999, 0], + [10_000, 1], + ]; + assert.deepEqual( + samples.map(([eastMeters]) => localMajorTileAt( + { eastMeters, northMeters: 9_999.999 }, + localHierarchyDefinition, + ).eastIndex), + samples.map(([, expected]) => expected), + ); + + const tile = localMajorTileAt( + { eastMeters: -0.001, northMeters: 9_999.999 }, + localHierarchyDefinition, + ); + assert.equal(tile.id, "grid/local/55.755800,37.617300/l1/s1000.000/t10000.000/e-1/n+0"); + assert.equal(tile.minorPerSide, 10); + assert.deepEqual(localMajorTileBounds(tile, localHierarchyDefinition.tileSizeMeters), { + west: -10_000, + east: 0, + south: 0, + north: 10_000, + }); +}); + +test("local major parent, children and neighbors are exact inverse hierarchy operations", () => { + const tile = localMajorTileAt( + { eastMeters: -1, northMeters: 1 }, + localHierarchyDefinition, + ); + const children = localMajorTileChildren(tile, localHierarchyDefinition); + assert.equal(children.length, 100); + assert.deepEqual( + [children[0].eastIndex, children[0].northIndex, children.at(-1).eastIndex, children.at(-1).northIndex], + [-10, 0, -1, 9], + ); + for (const child of children) { + assert.equal(localMajorTileForSector(child, localHierarchyDefinition).id, tile.id); + } + const neighbors = localMajorTileNeighbors(tile, localHierarchyDefinition); + assert.deepEqual( + Object.fromEntries(Object.entries(neighbors).map(([direction, address]) => [ + direction, + [address.eastIndex, address.northIndex], + ])), + { north: [-1, 1], east: [0, 0], south: [-1, -1], west: [-2, 0] }, + ); + assert.equal(isLocalMajorLineIndex(-10, localHierarchyDefinition), true); + assert.equal(isLocalMajorLineIndex(-1, localHierarchyDefinition), false); + assert.equal(isLocalMajorLineIndex(0, localHierarchyDefinition), true); +}); + +test("large local major tiles require bounded child pages", () => { + const largeDefinition = { ...localDefinition, stepMeters: 100, tileSizeMeters: 50_000 }; + const tile = localMajorTileAt({ eastMeters: 0, northMeters: 0 }, largeDefinition); + assert.equal(tile.minorPerSide, 500); + assert.throws( + () => localMajorTileChildren(tile, largeDefinition), + /grid_child_page_limit_exceeded/, + ); + const tail = localMajorTileChildren(tile, largeDefinition, { offset: 249_998, limit: 2 }); + assert.deepEqual(tail.map(({ eastIndex, northIndex }) => [eastIndex, northIndex]), [ + [498, 499], + [499, 499], + ]); + assert.throws( + () => localMajorTileChildren(tile, largeDefinition, { limit: MAX_GRID_CHILD_PAGE_SIZE + 1 }), + /grid_child_page_limit_exceeded/, + ); +}); + +test("local hierarchy rejects non-integral, non-positive and unsafe definitions", () => { + assert.throws( + () => localMajorTileAt({ eastMeters: 0, northMeters: 0 }, { + ...localDefinition, + tileSizeMeters: 2_500, + }), + /grid_tile_size_must_be_integer_multiple_of_step/, + ); + assert.throws( + () => localMajorTileAt({ eastMeters: 0, northMeters: 0 }, { + ...localDefinition, + tileSizeMeters: 0, + }), + /grid_tile_size_must_be_positive/, + ); + assert.throws( + () => localMajorTileAt({ eastMeters: 0, northMeters: 0 }, { + ...localDefinition, + stepMeters: Number.NaN, + tileSizeMeters: 10_000, + }), + /grid_step_must_be_positive/, + ); + assert.throws( + () => localMajorTileId(localHierarchyDefinition, 0.5, 0), + /grid_index_must_be_safe_integer/, + ); + assert.doesNotThrow(() => localMajorTileAt( + { eastMeters: -0.001, northMeters: 0 }, + { ...localDefinition, stepMeters: 0.1, tileSizeMeters: 1 }, + )); +}); + +test("local summaries are UI-ready and expose neighbor and major-parent geometry", () => { + const sector = localSectorAt({ eastMeters: -1, northMeters: 2_500 }, localDefinition); + const summary = localSectorSummary(sector, localHierarchyDefinition); + assert.equal(summary.id, sector.id); + assert.deepEqual(summary.center, { eastMeters: -500, northMeters: 2_500 }); + assert.equal(summary.areaSquareMeters, 1_000_000); + assert.equal(localSectorAreaSquareMeters(sector, 1_000), 1_000_000); + assert.equal(summary.neighbors.west.address.id, localSectorNeighbors(sector, localDefinition).west.id); + assert.deepEqual(summary.neighbors.north.center, { eastMeters: -500, northMeters: 3_500 }); + assert.equal(summary.majorTile?.id, summary.parentMajorTile?.id); + assert.equal(summary.majorTile?.minorPerSide, 10); + assert.deepEqual(summary.majorTile?.center, { eastMeters: -5_000, northMeters: 5_000 }); + assert.equal(summary.majorTile?.areaSquareMeters, 100_000_000); + + const standalone = localSectorSummary(sector, localDefinition); + assert.equal(standalone.majorTile, null); + assert.equal(standalone.parentMajorTile, null); + const majorSummary = localMajorTileSummary(summary.majorTile.address, localHierarchyDefinition); + assert.equal(majorSummary.childCount, 100); + assert.equal(majorSummary.neighbors.east.address.eastIndex, 0); +}); + +test("local volume addressing is half-open and clips an incomplete last band", () => { + const samples = [ + [-50, 0], + [49.999, 0], + [50, 1], + [149.999, 1], + [150, 2], + [224.999, 2], + ]; + for (const [altitudeMeters, expectedBand] of samples) { + assert.equal( + localVolumeAt({ eastMeters: -1, northMeters: 2_500, altitudeMeters }, volumeDefinition)?.bandIndex, + expectedBand, + ); + } + assert.equal(localVolumeAt({ eastMeters: 0, northMeters: 0, altitudeMeters: -50.001 }, volumeDefinition), null); + assert.equal(localVolumeAt({ eastMeters: 0, northMeters: 0, altitudeMeters: 225 }, volumeDefinition), null); + + const last = localVolumeAt({ eastMeters: -1, northMeters: 2_500, altitudeMeters: 200 }, volumeDefinition); + assert.ok(last); + assert.equal(last.id, "grid/local-volume/55.755800,37.617300/l1/s1000.000/f-50.000/c225.000/h100.000/e-1/n+2/z+2"); + assert.equal(last.altitudeBandMeters, 75); + assert.deepEqual(localVolumeBounds(last, volumeDefinition), { + west: -1_000, + east: 0, + south: 2_000, + north: 3_000, + altitudeFloorMeters: 150, + altitudeCeilingMeters: 225, + }); + const summary = localVolumeSummary(last, volumeDefinition); + assert.equal(summary.address.id, last.id); + assert.deepEqual(summary.center, { + eastMeters: -500, + northMeters: 2_500, + altitudeMeters: 187.5, + }); + assert.equal(summary.footprintAreaSquareMeters, 1_000_000); + assert.equal(summary.volumeCubicMeters, 75_000_000); +}); + +test("local volume neighbors stop at vertical limits but remain unbounded horizontally", () => { + const first = localVolumeAt({ eastMeters: 0, northMeters: 0, altitudeMeters: 0 }, volumeDefinition); + assert.ok(first); + const firstNeighbors = localVolumeNeighbors(first, volumeDefinition); + assert.equal(firstNeighbors.below, null); + assert.equal(firstNeighbors.above?.bandIndex, 1); + assert.equal(firstNeighbors.west.eastIndex, -1); + + const last = localVolumeAt({ eastMeters: 0, northMeters: 0, altitudeMeters: 200 }, volumeDefinition); + assert.ok(last); + const lastNeighbors = localVolumeNeighbors(last, volumeDefinition); + assert.equal(lastNeighbors.above, null); + assert.equal(lastNeighbors.below?.bandIndex, 1); +}); + +test("local volume contract rejects degenerate ranges, bands and invalid addresses", () => { + assert.throws( + () => localVolumeAt({ eastMeters: 0, northMeters: 0, altitudeMeters: 0 }, { + ...volumeDefinition, + altitudeBandMeters: 0, + }), + /grid_altitude_band_must_be_positive/, + ); + assert.throws( + () => localVolumeAt({ eastMeters: 0, northMeters: 0, altitudeMeters: 0 }, { + ...volumeDefinition, + altitudeCeilingMeters: -50, + }), + /grid_altitude_ceiling_must_exceed_floor/, + ); + assert.throws( + () => localVolumeAt({ eastMeters: 0, northMeters: 0, altitudeMeters: 0 }, { + ...volumeDefinition, + altitudeFloorMeters: Number.NaN, + }), + /grid_altitude_floor_must_be_finite/, + ); + assert.equal( + localVolumeAt({ eastMeters: 0, northMeters: 0, altitudeMeters: Number.NaN }, volumeDefinition), + null, + ); + assert.throws( + () => localVolumeId(volumeDefinition, 0, 0, -1), + /grid_altitude_band_index_out_of_range/, + ); + assert.throws( + () => localVolumeId(volumeDefinition, 0, 0, 3), + /grid_altitude_band_index_out_of_range/, + ); +}); + test("local grid plans are symmetric about the immutable origin and cap marker density", () => { const plan = localGridPlan({ stepMeters: 1_000, radiusMeters: 2_000, maximumMarkers: 4 }); assert.deepEqual(plan.lines.map(({ index, offsetMeters }) => [index, offsetMeters]), [ @@ -229,3 +493,145 @@ test("graticule sector IDs, negative boundaries and bounds use the same global p }); assert.equal(seamEast.id, seamWest.id); }); + +test("graticule minor neighbors wrap at the date line and stop at both poles", () => { + const definition = { lod: 4, stepDegrees: 2 }; + const northEast = graticuleSectorAt({ longitude: 179.999, latitude: 89.999 }, definition); + const northEastNeighbors = graticuleSectorNeighbors(northEast, definition); + assert.equal(northEastNeighbors.north, null); + assert.deepEqual( + [northEastNeighbors.east.longitudeIndex, northEastNeighbors.east.latitudeIndex], + [-90, 44], + ); + assert.deepEqual( + [northEastNeighbors.west.longitudeIndex, northEastNeighbors.west.latitudeIndex], + [88, 44], + ); + + const southWest = graticuleSectorAt({ longitude: -180, latitude: -90 }, definition); + const southWestNeighbors = graticuleSectorNeighbors(southWest, definition); + assert.equal(southWestNeighbors.south, null); + assert.equal(southWestNeighbors.west.longitudeIndex, 89); + + const unevenDefinition = { lod: 4, stepDegrees: 7 }; + const unevenWest = graticuleSectorAt({ longitude: -180, latitude: 0 }, unevenDefinition); + const unevenNeighbors = graticuleSectorNeighbors(unevenWest, unevenDefinition); + assert.equal(unevenNeighbors.west.longitudeIndex, 25); + assert.throws( + () => graticuleSectorNeighbors({ ...unevenWest, latitudeIndex: 13 }, unevenDefinition), + /grid_graticule_latitude_index_out_of_range/, + ); +}); + +test("graticule major hierarchy has stable IDs, exact children and seam-safe neighbors", () => { + const definition = { lod: 4, stepDegrees: 2, majorStepDegrees: 10 }; + const tile = graticuleMajorTileAt({ longitude: -0.001, latitude: 9.999 }, definition); + assert.equal(tile.id, "grid/wgs84/l4/s2.000000/m10.000000/x-1/y+0"); + assert.equal(tile.minorPerSide, 5); + assert.deepEqual(graticuleMajorTileBounds(tile, definition.majorStepDegrees), { + west: -10, + east: 0, + south: 0, + north: 10, + }); + const children = graticuleMajorTileChildren(tile, definition); + assert.equal(children.length, 25); + assert.deepEqual( + [children[0].longitudeIndex, children[0].latitudeIndex, children.at(-1).longitudeIndex, children.at(-1).latitudeIndex], + [-5, 0, -1, 4], + ); + for (const child of children) { + assert.equal(graticuleMajorTileForSector(child, definition).id, tile.id); + } + + const seam = graticuleMajorTileAt({ longitude: 179.999, latitude: 89.999 }, definition); + const seamNeighbors = graticuleMajorTileNeighbors(seam, definition); + assert.equal(seam.longitudeIndex, 17); + assert.equal(seam.latitudeIndex, 8); + assert.equal(seamNeighbors.north, null); + assert.equal(seamNeighbors.east.longitudeIndex, -18); + assert.equal(graticuleMajorTileAt({ longitude: 180, latitude: 0 }, definition).longitudeIndex, -18); + assert.equal(graticuleMajorTileAt({ longitude: -180, latitude: 0 }, definition).id, + graticuleMajorTileAt({ longitude: 180, latitude: 0 }, definition).id); + assert.equal( + graticuleMajorTileAt({ longitude: -180, latitude: 0 }, definition).id, + graticuleMajorTileAt({ longitude: 540, latitude: 0 }, definition).id, + ); + assert.equal(isGraticuleMajorLineIndex(-5, definition), true); + assert.equal(isGraticuleMajorLineIndex(-4, definition), false); + assert.equal(isGraticuleMajorLineValue(-10, definition), true); + assert.equal(isGraticuleMajorLineValue(-9.999, definition), false); +}); + +test("large graticule major tiles expose children through bounded pages", () => { + const definition = { lod: 4, stepDegrees: 0.1, majorStepDegrees: 90 }; + const tile = graticuleMajorTileAt({ longitude: 0, latitude: 0 }, definition); + assert.equal(tile.minorPerSide, 900); + assert.throws( + () => graticuleMajorTileChildren(tile, definition), + /grid_child_page_limit_exceeded/, + ); + const tail = graticuleMajorTileChildren(tile, definition, { offset: 809_999, limit: 1 }); + assert.deepEqual( + [tail[0].longitudeIndex, tail[0].latitudeIndex], + [899, 899], + ); +}); + +test("graticule hierarchy rejects ambiguous global partitions and malformed indices", () => { + assert.throws( + () => graticuleMajorTileAt({ longitude: 0, latitude: 0 }, { + lod: 4, + stepDegrees: 2, + majorStepDegrees: 5, + }), + /grid_graticule_major_step_must_be_integer_multiple_of_step/, + ); + assert.throws( + () => graticuleMajorTileAt({ longitude: 0, latitude: 0 }, { + lod: 4, + stepDegrees: 1, + majorStepDegrees: 7, + }), + /grid_graticule_major_step_must_partition_hemisphere/, + ); + assert.throws( + () => graticuleMajorTileAt({ longitude: 0, latitude: 0 }, { + lod: 4, + stepDegrees: 1, + majorStepDegrees: 0, + }), + /grid_graticule_major_step_must_be_positive/, + ); + assert.throws( + () => graticuleMajorTileBounds({ longitudeIndex: 0, latitudeIndex: 9.5 }, 10), + /grid_index_must_be_safe_integer/, + ); +}); + +test("WGS84 summaries expose ellipsoidal area, centers, neighbors and major parents", () => { + const definition = { lod: 4, stepDegrees: 2, majorStepDegrees: 10 }; + const sector = graticuleSectorAt({ longitude: -0.001, latitude: -0.001 }, definition); + const summary = graticuleSectorSummary(sector, definition); + assert.deepEqual(summary.center, { longitude: -1, latitude: -1 }); + assert.equal(summary.address.id, sector.id); + assert.equal(summary.neighbors.east.address.id, + graticuleSectorAt({ longitude: 0, latitude: -0.001 }, definition).id); + assert.equal(summary.majorTile?.id, summary.parentMajorTile?.id); + assert.deepEqual(summary.majorTile?.bounds, { west: -10, east: 0, south: -10, north: 0 }); + assert.equal(summary.majorTile?.childCount, 25); + assert.ok(summary.areaSquareMeters > 49_000_000_000 && summary.areaSquareMeters < 50_000_000_000); + + const equator = graticuleSectorAt({ longitude: 0, latitude: 0 }, definition); + const polar = graticuleSectorAt({ longitude: 0, latitude: 89 }, definition); + assert.ok( + graticuleSectorAreaSquareMeters(equator, definition.stepDegrees) + > graticuleSectorAreaSquareMeters(polar, definition.stepDegrees) * 20, + ); + const worldArea = geodeticRectangleAreaSquareMeters({ west: -180, east: 180, south: -90, north: 90 }); + assert.ok(worldArea > 5.10e14 && worldArea < 5.11e14); + + const majorSummary = graticuleMajorTileSummary(summary.majorTile.address, definition); + assert.equal(majorSummary.neighbors.east.address.longitudeIndex, 0); + assert.equal(graticuleSectorSummary(sector, { lod: 4, stepDegrees: 2 }).majorTile, null); +}); diff --git a/server/catalog-server.mjs b/server/catalog-server.mjs index 41e14c8..a2b0561 100644 --- a/server/catalog-server.mjs +++ b/server/catalog-server.mjs @@ -536,7 +536,8 @@ const GRID_LOD_PROFILE_KEYS = new Set([ "lineDiameterMeters", "lineColor", "lineOpacity", "dotsEnabled", "dotsDiameterMeters", "dotsColor", "dotsOpacity", "crossesEnabled", "crossesLengthMeters", "crossesWidthMeters", "crossesColor", "crossesOpacity", "graticuleStepDegrees", "graticuleLineWidthPx", "graticuleColor", - "graticuleOpacity", + "graticuleOpacity", "majorLinesEnabled", "majorLabelsEnabled", "majorLineWidthMultiplier", + "volumeEnabled", "volumeMinimumHeightMeters", "volumeMaximumHeightMeters", "volumeBandHeightMeters", ]); const GRID_LOD_PROFILE_INPUT_KEYS = new Set([...GRID_LOD_PROFILE_KEYS, "lineWidthPx"]); @@ -561,6 +562,8 @@ const DEFAULT_GRID_LOD_PROFILES = Object.freeze([ dotsEnabled: true, dotsDiameterMeters: 7, dotsColor: "#ffffff", dotsOpacity: 58, crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46, graticuleStepDegrees: 0.25, graticuleLineWidthPx: 1, graticuleColor: "#f5f5f5", graticuleOpacity: 12, + majorLinesEnabled: true, majorLabelsEnabled: true, majorLineWidthMultiplier: 2.5, + volumeEnabled: true, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 300, volumeBandHeightMeters: 300, }), Object.freeze({ maxHeightKm: 50, stepKm: 5, mode: "3d", heightMeters: 500, max3dViewAngleDegrees: 30, @@ -568,6 +571,8 @@ const DEFAULT_GRID_LOD_PROFILES = Object.freeze([ dotsEnabled: true, dotsDiameterMeters: 80, dotsColor: "#ffffff", dotsOpacity: 22, crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46, graticuleStepDegrees: 0.5, graticuleLineWidthPx: 1, graticuleColor: "#f8fbfc", graticuleOpacity: 12, + majorLinesEnabled: true, majorLabelsEnabled: true, majorLineWidthMultiplier: 2.5, + volumeEnabled: true, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500, }), Object.freeze({ maxHeightKm: 200, stepKm: 25, mode: "3d", heightMeters: 500, max3dViewAngleDegrees: 30, @@ -575,6 +580,8 @@ const DEFAULT_GRID_LOD_PROFILES = Object.freeze([ dotsEnabled: true, dotsDiameterMeters: 10, dotsColor: "#ffffff", dotsOpacity: 58, crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46, graticuleStepDegrees: 1, graticuleLineWidthPx: 1, graticuleColor: "#fafafa", graticuleOpacity: 12, + majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2, + volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500, }), Object.freeze({ maxHeightKm: 800, stepKm: 50, mode: "graticule", heightMeters: 500, max3dViewAngleDegrees: 30, @@ -582,6 +589,8 @@ const DEFAULT_GRID_LOD_PROFILES = Object.freeze([ dotsEnabled: true, dotsDiameterMeters: 10, dotsColor: "#fcfdfd", dotsOpacity: 58, crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46, graticuleStepDegrees: 2, graticuleLineWidthPx: 1, graticuleColor: "#fcfdfd", graticuleOpacity: 12, + majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2, + volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500, }), Object.freeze({ maxHeightKm: 3_000, stepKm: 50, mode: "graticule", heightMeters: 500, max3dViewAngleDegrees: 30, @@ -589,14 +598,30 @@ const DEFAULT_GRID_LOD_PROFILES = Object.freeze([ dotsEnabled: true, dotsDiameterMeters: 10, dotsColor: "#9c9c9c", dotsOpacity: 58, crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46, graticuleStepDegrees: 2, graticuleLineWidthPx: 1, graticuleColor: "#9c9c9c", graticuleOpacity: 8, + majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2, + volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500, }), ]); function promoteLegacyGridLodProfiles(settings) { return DEFAULT_GRID_LOD_PROFILES.map((fallback, index) => { const number = index + 1; + // Keep migration provenance: sector-v2 extension fields must stay absent + // until validateGridLodProfiles applies mode-aware defaults. Spreading the + // new fallback here would make an old flat payload look explicit and + // could reject it for a hierarchy it never configured. + const { + majorLinesEnabled: _majorLinesEnabled, + majorLabelsEnabled: _majorLabelsEnabled, + majorLineWidthMultiplier: _majorLineWidthMultiplier, + volumeEnabled: _volumeEnabled, + volumeMinimumHeightMeters: _volumeMinimumHeightMeters, + volumeMaximumHeightMeters: _volumeMaximumHeightMeters, + volumeBandHeightMeters: _volumeBandHeightMeters, + ...legacyFallback + } = fallback; return { - ...fallback, + ...legacyFallback, maxHeightKm: settings[`gridLod${number}MaxHeightKm`] ?? fallback.maxHeightKm, stepKm: settings[`gridLod${number}StepKm`] ?? fallback.stepKm, mode: settings[`gridLod${number}Mode`] ?? fallback.mode, @@ -631,13 +656,30 @@ function validateGridLodProfiles(value) { throw applicationError(`invalid_map_grid_lod_profile_${index + 1}`); } const legacyProfile = Object.hasOwn(profile, "lineWidthPx"); - const normalized = legacyProfile ? { + const normalized = { ...profile, - graticuleStepDegrees: profile.graticuleStepDegrees ?? DEFAULT_GRID_LOD_PROFILES[index].graticuleStepDegrees, - graticuleLineWidthPx: profile.graticuleLineWidthPx ?? profile.lineWidthPx, - graticuleColor: profile.graticuleColor ?? profile.lineColor, - graticuleOpacity: profile.graticuleOpacity ?? profile.lineOpacity, - } : { ...profile }; + ...(legacyProfile ? { + graticuleStepDegrees: profile.graticuleStepDegrees ?? DEFAULT_GRID_LOD_PROFILES[index].graticuleStepDegrees, + graticuleLineWidthPx: profile.graticuleLineWidthPx ?? profile.lineWidthPx, + graticuleColor: profile.graticuleColor ?? profile.lineColor, + graticuleOpacity: profile.graticuleOpacity ?? profile.lineOpacity, + } : {}), + majorLinesEnabled: profile.majorLinesEnabled ?? DEFAULT_GRID_LOD_PROFILES[index].majorLinesEnabled, + majorLabelsEnabled: profile.majorLabelsEnabled ?? ( + (profile.majorLinesEnabled ?? DEFAULT_GRID_LOD_PROFILES[index].majorLinesEnabled) + ? DEFAULT_GRID_LOD_PROFILES[index].majorLabelsEnabled + : false + ), + majorLineWidthMultiplier: profile.majorLineWidthMultiplier ?? DEFAULT_GRID_LOD_PROFILES[index].majorLineWidthMultiplier, + volumeEnabled: profile.volumeEnabled ?? ( + (profile.mode ?? DEFAULT_GRID_LOD_PROFILES[index].mode) === "3d" + ? DEFAULT_GRID_LOD_PROFILES[index].volumeEnabled + : false + ), + volumeMinimumHeightMeters: profile.volumeMinimumHeightMeters ?? DEFAULT_GRID_LOD_PROFILES[index].volumeMinimumHeightMeters, + volumeMaximumHeightMeters: profile.volumeMaximumHeightMeters ?? DEFAULT_GRID_LOD_PROFILES[index].volumeMaximumHeightMeters, + volumeBandHeightMeters: profile.volumeBandHeightMeters ?? DEFAULT_GRID_LOD_PROFILES[index].volumeBandHeightMeters, + }; delete normalized.lineWidthPx; if (GRID_LOD_PROFILE_KEYS.size !== Object.keys(normalized).length || [...GRID_LOD_PROFILE_KEYS].some((key) => !Object.hasOwn(normalized, key))) { @@ -649,19 +691,62 @@ function validateGridLodProfiles(value) { throw applicationError(`invalid_map_grid_lod_profile_${index + 1}_maxHeightKm_order`); } previousMaxHeightKm = maxHeightKm; - if (!['3d', 'graticule'].includes(normalized.mode)) throw applicationError(code("mode")); + if (!["3d", "graticule"].includes(normalized.mode)) throw applicationError(code("mode")); const stepKm = requireNumber(normalized.stepKm, 0.1, 5_000, code("stepKm")); + const tileSizeKm = requireNumber(normalized.tileSizeKm, 1, 50, code("tileSizeKm")); + const graticuleStepDegrees = requireNumber( + normalized.graticuleStepDegrees, + 0.1, + 180, + code("graticuleStepDegrees"), + ); const radiusKm = requireNumber(normalized.radiusKm, 1, 100_000, code("radiusKm")); if (radiusKm / stepKm > MAX_LOCAL_GRID_INDEX) { throw applicationError(`invalid_map_grid_lod_profile_${index + 1}_workload`); } + const hasExplicitMajorPolicy = ["majorLinesEnabled", "majorLabelsEnabled", "majorLineWidthMultiplier"] + .some((key) => Object.hasOwn(profile, key)); + let majorLinesEnabled = requireBoolean(normalized.majorLinesEnabled, code("majorLinesEnabled")); + let majorLabelsEnabled = requireBoolean(normalized.majorLabelsEnabled, code("majorLabelsEnabled")); + if (majorLabelsEnabled && !majorLinesEnabled) { + throw applicationError(code("majorLabelsEnabled_requires_majorLinesEnabled")); + } + if (normalized.mode === "3d" && majorLinesEnabled) { + const minorPerMajor = tileSizeKm / stepKm; + if (minorPerMajor < 1 || Math.abs(minorPerMajor - Math.round(minorPerMajor)) + > 1e-9 * Math.max(1, Math.abs(minorPerMajor))) { + if (hasExplicitMajorPolicy) throw applicationError(code("tileSizeKm_step_ratio")); + majorLinesEnabled = false; + majorLabelsEnabled = false; + } + } + if (normalized.mode === "graticule" && majorLinesEnabled) { + const majorLatitudeHemisphereSteps = 90 / (graticuleStepDegrees * 5); + if (Math.abs(majorLatitudeHemisphereSteps - Math.round(majorLatitudeHemisphereSteps)) + > 1e-9 * Math.max(1, Math.abs(majorLatitudeHemisphereSteps))) { + if (hasExplicitMajorPolicy) throw applicationError(code("graticuleStepDegrees_major_partition")); + majorLinesEnabled = false; + majorLabelsEnabled = false; + } + } + const volumeEnabled = requireBoolean(normalized.volumeEnabled, code("volumeEnabled")); + if (volumeEnabled && normalized.mode !== "3d") { + throw applicationError(code("volumeEnabled_mode")); + } + const volumeMinimumHeightMeters = requireNumber(normalized.volumeMinimumHeightMeters, -1_000, 10_000, code("volumeMinimumHeightMeters")); + const volumeMaximumHeightMeters = requireNumber(normalized.volumeMaximumHeightMeters, -1_000, 10_000, code("volumeMaximumHeightMeters")); + const volumeBandHeightMeters = requireNumber(normalized.volumeBandHeightMeters, 1, 10_000, code("volumeBandHeightMeters")); + if (volumeMaximumHeightMeters <= volumeMinimumHeightMeters) throw applicationError(code("volumeHeightOrder")); + if (volumeBandHeightMeters > volumeMaximumHeightMeters - volumeMinimumHeightMeters) { + throw applicationError(code("volumeBandHeightMeters_span")); + } return { maxHeightKm, stepKm, mode: normalized.mode, heightMeters: requireNumber(normalized.heightMeters, 0, 5_000, code("heightMeters")), max3dViewAngleDegrees: requireNumber(normalized.max3dViewAngleDegrees, 30, 170, code("max3dViewAngleDegrees")), - tileSizeKm: requireNumber(normalized.tileSizeKm, 1, 50, code("tileSizeKm")), + tileSizeKm, radiusKm, lineDiameterMeters: requireNumber(normalized.lineDiameterMeters, 1, 100, code("lineDiameterMeters")), lineColor: requireHex(normalized.lineColor, code("lineColor")), @@ -675,10 +760,17 @@ function validateGridLodProfiles(value) { crossesWidthMeters: requireNumber(normalized.crossesWidthMeters, 1, 500, code("crossesWidthMeters")), crossesColor: requireHex(normalized.crossesColor, code("crossesColor")), crossesOpacity: requireNumber(normalized.crossesOpacity, 0, 100, code("crossesOpacity")), - graticuleStepDegrees: requireNumber(normalized.graticuleStepDegrees, 0.1, 180, code("graticuleStepDegrees")), + graticuleStepDegrees, graticuleLineWidthPx: requireNumber(normalized.graticuleLineWidthPx, 1, 3, code("graticuleLineWidthPx")), graticuleColor: requireHex(normalized.graticuleColor, code("graticuleColor")), graticuleOpacity: requireNumber(normalized.graticuleOpacity, 0, 100, code("graticuleOpacity")), + majorLinesEnabled, + majorLabelsEnabled, + majorLineWidthMultiplier: requireNumber(normalized.majorLineWidthMultiplier, 1, 8, code("majorLineWidthMultiplier")), + volumeEnabled, + volumeMinimumHeightMeters, + volumeMaximumHeightMeters, + volumeBandHeightMeters, }; }); } diff --git a/server/foundry-mcp.mjs b/server/foundry-mcp.mjs index 24641ee..5d73ca0 100644 --- a/server/foundry-mcp.mjs +++ b/server/foundry-mcp.mjs @@ -485,6 +485,13 @@ const mapPageSettingsPatchInputSchema = { graticuleLineWidthPx: { type: "number", minimum: 1, maximum: 3 }, graticuleColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" }, graticuleOpacity: { type: "number", minimum: 0, maximum: 100 }, + majorLinesEnabled: { type: "boolean" }, + majorLabelsEnabled: { type: "boolean" }, + majorLineWidthMultiplier: { type: "number", minimum: 1, maximum: 8 }, + volumeEnabled: { type: "boolean" }, + volumeMinimumHeightMeters: { type: "number", minimum: -1000, maximum: 10000 }, + volumeMaximumHeightMeters: { type: "number", minimum: -1000, maximum: 10000 }, + volumeBandHeightMeters: { type: "number", minimum: 1, maximum: 10000 }, }, }, }, diff --git a/server/map-grid-persistence.test.mjs b/server/map-grid-persistence.test.mjs new file mode 100644 index 0000000..d3d58fa --- /dev/null +++ b/server/map-grid-persistence.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import vm from "node:vm"; +import { DEFAULT_GRID_LOD_PROFILES } from "../apps/catalog/src/mapGridPolicy.mjs"; + +const seed = JSON.parse(await readFile(new URL("../runtime-seed/page-layouts/map.json", import.meta.url), "utf8")); +const serverSource = await readFile(new URL("./catalog-server.mjs", import.meta.url), "utf8"); +const mcpSource = await readFile(new URL("./foundry-mcp.mjs", import.meta.url), "utf8"); +const extensionKeys = [ + "majorLinesEnabled", + "majorLabelsEnabled", + "majorLineWidthMultiplier", + "volumeEnabled", + "volumeMinimumHeightMeters", + "volumeMaximumHeightMeters", + "volumeBandHeightMeters", +]; + +function applicationError(code, statusCode = 400) { + const error = new Error(code); + error.statusCode = statusCode; + return error; +} + +const validators = { + applicationError, + isObject: (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value), + requireNumber(value, minimum, maximum, code) { + if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) { + throw applicationError(code); + } + return value; + }, + requireBoolean(value, code) { + if (typeof value !== "boolean") throw applicationError(code); + return value; + }, + requireHex(value, code) { + const normalized = String(value || ""); + if (!/^#[0-9a-f]{6}$/i.test(normalized)) throw applicationError(code); + return normalized.toLowerCase(); + }, +}; + +function loadServerGridContract() { + const start = serverSource.indexOf("const GRID_LOD_PROFILE_KEYS"); + const end = serverSource.indexOf("function validateMapPageSettingsPatch"); + assert.ok(start >= 0 && end > start, "server grid contract source boundaries"); + const context = vm.createContext({ ...validators }); + const source = `${serverSource.slice(start, end)}\n;globalThis.gridContract = { DEFAULT_GRID_LOD_PROFILES, promoteLegacyGridLodProfiles, validateGridLodProfiles };`; + new vm.Script(source, { filename: "catalog-server.grid-contract.mjs" }).runInContext(context); + return context.gridContract; +} + +const serverGrid = loadServerGridContract(); + +test("seed, client defaults and server promotion keep the sector-v2 profile contract identical", () => { + assert.deepEqual(seed.settings.gridLodProfiles, DEFAULT_GRID_LOD_PROFILES); + const legacyProfiles = seed.settings.gridLodProfiles.map((profile) => { + const legacy = { ...profile }; + for (const key of extensionKeys) delete legacy[key]; + return legacy; + }); + assert.deepEqual( + structuredClone(serverGrid.validateGridLodProfiles(legacyProfiles)), + DEFAULT_GRID_LOD_PROFILES, + ); +}); + +test("flat layouts migrate mode-aware extensions while explicit invalid hierarchy is rejected", () => { + const flatSettings = { + ...seed.settings, + gridLodProfiles: undefined, + gridLod1StepKm: 3, + gridTileSizeKm: 10, + gridLod2Mode: "graticule", + }; + const migrated = structuredClone(serverGrid.validateGridLodProfiles( + serverGrid.promoteLegacyGridLodProfiles(flatSettings), + )); + assert.equal(migrated[0].majorLinesEnabled, false); + assert.equal(migrated[0].majorLabelsEnabled, false); + assert.equal(migrated[1].mode, "graticule"); + assert.equal(migrated[1].volumeEnabled, false); + + const invalidExplicit = structuredClone(DEFAULT_GRID_LOD_PROFILES); + invalidExplicit[0].stepKm = 3; + invalidExplicit[0].tileSizeKm = 10; + assert.throws( + () => serverGrid.validateGridLodProfiles(invalidExplicit), + /invalid_map_grid_lod_profile_1_tileSizeKm_step_ratio/, + ); +}); + +test("Foundry MCP exposes every persisted sector-v2 profile field", () => { + for (const key of extensionKeys) assert.match(mcpSource, new RegExp(`\\b${key}: \\{`)); +});