diff --git a/apps/catalog/src/CesiumMapRenderer.tsx b/apps/catalog/src/CesiumMapRenderer.tsx index 7c80650..730b3fb 100644 --- a/apps/catalog/src/CesiumMapRenderer.tsx +++ b/apps/catalog/src/CesiumMapRenderer.tsx @@ -1,5 +1,6 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react"; import { + ArcType, Cartesian2, Cartesian3, BingMapsImageryProvider, @@ -35,15 +36,18 @@ import { Math as CesiumMath, PerInstanceColorAppearance, PointGraphics, + PointPrimitiveCollection, PolygonGeometry, PolygonHierarchy, PolylineColorAppearance, PolylineGraphics, + PolylineCollection, Resource, sampleTerrainMostDetailed, ScreenSpaceEventHandler, ScreenSpaceEventType, SunLight, + Transforms, VerticalOrigin, Viewer, } from "cesium"; @@ -65,8 +69,19 @@ import { gridShouldBeVisible, resolveGridMode, selectGridLod, - snapGridCenter, } from "./mapGridPolicy.mjs"; +import { + boundedAngularParts, + fixedGridOrigin, + graticuleGranularity, + graticuleLinePlan, + graticuleSectorAt, + graticuleSectorBounds, + localGridPlan, + localSectorAt, + localSectorBounds, + splitLongitudeRange, +} from "./mapSectorGrid.mjs"; const MAX_SPIRAL_SUBSTEPS_PER_FRAME = 300; const TERRAIN_SAMPLE_TIMEOUT_MS = 12_000; @@ -220,12 +235,13 @@ export type MapPresentation = { gridLodEnabled: boolean; grid3dEnabled: boolean; gridGraticuleEnabled: boolean; - gridCenterMode: "camera" | "fixed"; + gridCenterMode: "fixed"; gridCenterLatitude: number; gridCenterLongitude: number; gridTileSizeKm: number; gridAutoDisableHeightKm: number; gridRebuildOnMoveEnd: boolean; + gridLegacyMode: boolean; gridMax3dViewAngleDegrees: number; gridHeightMeters: number; gridLod1MaxHeightKm: number; @@ -241,6 +257,7 @@ export type MapPresentation = { gridLod4StepKm: number; gridLod4Mode: "3d" | "graticule"; gridLod5StepKm: number; + gridLod5MaxHeightKm: number; gridLod5Mode: "3d" | "graticule"; gridRadiusKm: number; gridLineWidth: number; @@ -257,9 +274,45 @@ export type MapPresentation = { gridCrossesWidthMeters: number; gridCrossesColor: string; gridCrossesOpacity: number; + gridLodProfiles: GridLodProfile[]; cacheRefresh: boolean; }; +export type GridLodProfile = { + maxHeightKm: number; + stepKm: number; + mode: "3d" | "graticule"; + heightMeters: number; + max3dViewAngleDegrees: number; + tileSizeKm: number; + radiusKm: number; + lineDiameterMeters: number; + lineColor: string; + lineOpacity: number; + dotsEnabled: boolean; + dotsDiameterMeters: number; + dotsColor: string; + dotsOpacity: number; + crossesEnabled: boolean; + crossesLengthMeters: number; + crossesWidthMeters: number; + crossesColor: string; + crossesOpacity: number; + graticuleStepDegrees: number; + graticuleLineWidthPx: number; + graticuleColor: string; + graticuleOpacity: number; +}; + +export type GridSectorSelection = { + 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"; +}; + export type MapCameraView = { longitude: number; latitude: number; @@ -903,195 +956,414 @@ function syncRuntimeDataSources( viewer.scene.requestRender(); } -type GridLayerBuild = { - dataSource: CustomDataSource | null; - key: string; - lodIndex: number | null; +type GridLodBand = GridLodProfile & { index: number; id: string }; +type LocalGridAddressing = { + mode: "3d"; + lod: number; + stepMeters: number; + radiusMeters: number; + originLatitude: number; + originLongitude: number; + inverseEnu: Matrix4; }; +type GraticuleGridAddressing = { + mode: "graticule"; + lod: number; + stepDegrees: number; +}; +type GridAddressing = LocalGridAddressing | GraticuleGridAddressing; +type GridResources = { + dataSource: CustomDataSource; + points: PointPrimitiveCollection | null; + crosses: PolylineCollection | null; +}; +type HiddenGridPlan = { mode: "hidden"; key: string; lodIndex: number | null }; +type LocalGridLayerPlan = { + mode: "3d"; + key: string; + lodIndex: number; + lod: GridLodBand; + origin: { latitude: number; longitude: number }; + enu: Matrix4; + inverseEnu: Matrix4; + grid: ReturnType; + metersPerPixel: number; + cameraHeightMeters: number; + cameraPosition: Cartesian3; + cameraDirection: Cartesian3; +}; +type GraticuleGridLayerPlan = { + mode: "graticule"; + key: string; + lodIndex: number; + lod: GridLodBand; + grid: ReturnType; +}; +type GridLayerPlan = HiddenGridPlan | LocalGridLayerPlan | GraticuleGridLayerPlan; -function buildGridLayer( - viewer: Viewer, - presentation: MapPresentation, - previousLodIndex: number | null, - serial: number, -): GridLayerBuild { - const cameraHeightKm = Math.max(0, Number(viewer.camera.positionCartographic?.height || 0) / 1000); - if (!gridShouldBeVisible(presentation, cameraHeightKm)) { - return { dataSource: null, key: "hidden", lodIndex: null }; +function gridMetersPerPixel(viewer: Viewer) { + const frustum = viewer.camera.frustum as unknown as { fovy?: number }; + return Math.max(0.01, ( + Math.max(1, Number(viewer.camera.positionCartographic?.height || 1)) + * 2 * Math.tan((Number(frustum.fovy) || Math.PI / 3) / 2) + ) / Math.max(1, viewer.scene.canvas.clientHeight)); +} + +function graticuleViewport(viewer: Viewer, stepDegrees: number, cameraHeightKm: number) { + const ellipsoid = viewer.scene.globe.ellipsoid; + const margin = Math.max(stepDegrees * 2, Math.min(30, cameraHeightKm / 200)); + const alignLatitude = (value: number, direction: "down" | "up") => clamp( + (direction === "down" ? Math.floor(value / stepDegrees) : Math.ceil(value / stepDegrees)) * stepDegrees, + -89.9, + 89.9, + ); + const alignIntervals = (west: number, east: number) => splitLongitudeRange(west - margin, east + margin).map((interval) => ({ + west: interval.west <= -180 ? -180 : Math.floor(interval.west / stepDegrees) * stepDegrees, + east: interval.east >= 180 ? 180 : Math.ceil(interval.east / stepDegrees) * stepDegrees, + })); + const rectangle = viewer.camera.computeViewRectangle(ellipsoid); + if (rectangle) { + const south = CesiumMath.toDegrees(rectangle.south); + const north = CesiumMath.toDegrees(rectangle.north); + const west = CesiumMath.toDegrees(rectangle.west); + const east = CesiumMath.toDegrees(rectangle.east); + return { + south: alignLatitude(south - margin, "down"), + north: alignLatitude(north + margin, "up"), + longitudeIntervals: alignIntervals(west, east), + }; } - - const lod = selectGridLod(presentation, cameraHeightKm, previousLodIndex); - const pitchDegrees = CesiumMath.toDegrees(viewer.camera.pitch); - const viewAngleFromNadir = Math.abs(90 - Math.abs(pitchDegrees)); - const mode = resolveGridMode(presentation, lod.mode, viewAngleFromNadir); - if (mode === "hidden") return { dataSource: null, key: "hidden", lodIndex: lod.index }; - - const safeStepKm = clamp(lod.stepKm, 0.25, 5_000); - const safeRadiusKm = clamp(presentation.gridRadiusKm, safeStepKm, 2_000); - const stepsPerSide = Math.min(40, Math.max(1, Math.floor(safeRadiusKm / safeStepKm))); - const cameraPosition = viewer.camera.positionCartographic; - const center = snapGridCenter({ - latitude: cameraPosition ? CesiumMath.toDegrees(cameraPosition.latitude) : 55.751244, - longitude: cameraPosition ? CesiumMath.toDegrees(cameraPosition.longitude) : 37.618423, - }, presentation); - const latitude = center.latitude; - const longitude = center.longitude; - const key = JSON.stringify([ - lod.index, - mode, - latitude, - longitude, - safeStepKm, - safeRadiusKm, - presentation.gridHeightMeters, - presentation.gridLineWidth, - presentation.gridLineDiameterMeters, - presentation.gridColor, - presentation.gridOpacity, - presentation.gridDotsEnabled, - presentation.gridDotsDiameterMeters, - presentation.gridDotsColor, - presentation.gridDotsOpacity, - presentation.gridCrossesEnabled, - presentation.gridCrossesLengthMeters, - presentation.gridCrossesWidthMeters, - presentation.gridCrossesColor, - presentation.gridCrossesOpacity, - ]); - const dataSource = new CustomDataSource(`nodedc-map-grid:${serial}`); - const entities = dataSource.entities; - const metersPerLatitudeDegree = 110_574; - const metersPerLongitudeDegree = Math.max(1, 111_320 * Math.cos(CesiumMath.toRadians(latitude))); - const stepMeters = safeStepKm * 1000; - const radiusMeters = stepsPerSide * stepMeters; - const deltaLatitude = stepMeters / metersPerLatitudeDegree; - const deltaLongitude = stepMeters / metersPerLongitudeDegree; - const radiusLatitude = radiusMeters / metersPerLatitudeDegree; - const radiusLongitude = radiusMeters / metersPerLongitudeDegree; - const lineColor = Color.fromCssColorString(presentation.gridColor).withAlpha(clamp(presentation.gridOpacity / 100, 0, 1)); - const dotColor = Color.fromCssColorString(presentation.gridDotsColor).withAlpha(clamp(presentation.gridDotsOpacity / 100, 0, 1)); - const crossColor = Color.fromCssColorString(presentation.gridCrossesColor).withAlpha(clamp(presentation.gridCrossesOpacity / 100, 0, 1)); - const elevation = Math.max(0, presentation.gridHeightMeters); - const projected = mode === "graticule"; - const geometryHeight = projected ? 0 : elevation; - const addGridLine = (id: string, positions: Cartesian3[]) => { - if (projected) { - entities.add({ - id, - polyline: { - positions, - width: clamp(presentation.gridLineWidth, 1, 8), - material: lineColor, - clampToGround: true, - }, - }); - return; - } - entities.add({ - id, - corridor: { - positions, - width: clamp(presentation.gridLineDiameterMeters, 1, 500), - material: lineColor, - height: geometryHeight, - heightReference: HeightReference.NONE, - }, - }); + const camera = viewer.camera.positionCartographic; + const latitude = camera ? CesiumMath.toDegrees(camera.latitude) : 0; + const longitude = camera ? CesiumMath.toDegrees(camera.longitude) : 0; + const horizon = CesiumMath.toDegrees(Math.acos(clamp( + ellipsoid.maximumRadius / (ellipsoid.maximumRadius + Math.max(0, cameraHeightKm * 1_000)), + -1, + 1, + ))); + const range = Math.min(180, Math.max(10, horizon * 2)); + return { + south: alignLatitude(latitude - range - margin, "down"), + north: alignLatitude(latitude + range + margin, "up"), + longitudeIntervals: alignIntervals(longitude - range - margin, longitude + range + margin), }; +} - for (let index = -stepsPerSide; index <= stepsPerSide; index += 1) { - const nextLatitude = latitude + index * deltaLatitude; - const nextLongitude = longitude + index * deltaLongitude; - addGridLine(`${serial}:latitude:${index}`, [ - Cartesian3.fromDegrees(longitude - radiusLongitude, nextLatitude, geometryHeight), - Cartesian3.fromDegrees(longitude + radiusLongitude, nextLatitude, geometryHeight), - ]); - addGridLine(`${serial}:longitude:${index}`, [ - Cartesian3.fromDegrees(nextLongitude, latitude - radiusLatitude, geometryHeight), - Cartesian3.fromDegrees(nextLongitude, latitude + radiusLatitude, geometryHeight), +function planGridLayer(viewer: Viewer, presentation: MapPresentation, previousLodIndex: number | null): GridLayerPlan { + const cameraHeightKm = Math.max(0, Number(viewer.camera.positionCartographic?.height || 0) / 1_000); + if (!gridShouldBeVisible(presentation, cameraHeightKm)) return { mode: "hidden", key: "hidden", lodIndex: null }; + const lod = selectGridLod(presentation, cameraHeightKm, previousLodIndex) as GridLodBand; + const mode = resolveGridMode(presentation, lod.mode); + if (mode === "hidden") return { mode: "hidden", key: `hidden:${lod.index}`, lodIndex: lod.index }; + + if (mode === "3d") { + const origin = fixedGridOrigin(presentation); + const anchor = Cartesian3.fromDegrees(origin.longitude, origin.latitude, 0); + const enu = Transforms.eastNorthUpToFixedFrame(anchor); + const inverseEnu = Matrix4.inverseTransformation(enu, new Matrix4()); + const stepMeters = clamp(lod.stepKm * 1_000, 100, 5_000_000); + const radiusMeters = clamp(lod.radiusKm * 1_000, stepMeters, 100_000_000); + const metersPerPixel = gridMetersPerPixel(viewer); + const cameraPosition = Cartesian3.clone(viewer.camera.positionWC, new Cartesian3()); + const cameraDirection = Cartesian3.normalize(viewer.camera.directionWC, new Cartesian3()); + const cameraLocal = Matrix4.multiplyByPoint(inverseEnu, cameraPosition, new Cartesian3()); + const cameraPositionBucketMeters = Math.max(stepMeters, lod.tileSizeKm * 500); + const cameraPositionKey = [cameraLocal.x, cameraLocal.y] + .map((value) => Math.round(value / cameraPositionBucketMeters)); + const directionKey = [cameraDirection.x, cameraDirection.y, cameraDirection.z] + .map((value) => Math.round(value * 50) / 50); + const cameraHeightMeters = cameraHeightKm * 1_000; + const grid = localGridPlan({ stepMeters, radiusMeters, maximumMarkers: 5_000 }); + const 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, + 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, + metersPerPixel, cameraHeightMeters, cameraPosition, cameraDirection, + }; } - const dotStride = Math.max(1, Math.ceil((stepsPerSide * 2 + 1) / 25)); - for (let row = -stepsPerSide; row <= stepsPerSide; row += dotStride) { - for (let column = -stepsPerSide; column <= stepsPerSide; column += dotStride) { - const dotLongitude = longitude + column * deltaLongitude; - const dotLatitude = latitude + row * deltaLatitude; - if (presentation.gridDotsEnabled) { - const diameterMeters = clamp(presentation.gridDotsDiameterMeters, 2, 2_000); - entities.add({ - id: `${serial}:circle:${row}:${column}`, - position: Cartesian3.fromDegrees(dotLongitude, dotLatitude, geometryHeight), - ellipse: { - semiMajorAxis: diameterMeters / 2, - semiMinorAxis: diameterMeters / 2, - material: dotColor, - height: geometryHeight, - heightReference: projected ? HeightReference.CLAMP_TO_GROUND : HeightReference.NONE, + const viewport = graticuleViewport(viewer, lod.graticuleStepDegrees, cameraHeightKm); + const grid = graticuleLinePlan({ ...viewport, stepDegrees: lod.graticuleStepDegrees }); + const key = JSON.stringify([ + "wgs84-graticule", lod.index, lod.heightMeters, lod.graticuleStepDegrees, + lod.graticuleLineWidthPx, lod.graticuleColor, lod.graticuleOpacity, + grid.south, grid.north, grid.longitudeIntervals, + ]); + return { mode, key, lodIndex: lod.index, lod, grid }; +} + +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 lineColor = Color.fromCssColorString(plan.lod.lineColor).withAlpha(clamp(plan.lod.lineOpacity / 100, 0, 1)); + 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 dotPixelSize = clamp(plan.lod.dotsDiameterMeters / plan.metersPerPixel, 1, 128); + const crossWidthPixels = clamp(plan.lod.crossesWidthMeters / plan.metersPerPixel, 1, 8); + const definition = { + lod: plan.lod.index + 1, + originLatitude: plan.origin.latitude, + originLongitude: plan.origin.longitude, + stepMeters: plan.grid.stepMeters, + }; + const toWorld = (eastMeters: number, northMeters: number) => { + const tangentPoint = Matrix4.multiplyByPoint(plan.enu, new Cartesian3(eastMeters, northMeters, 0), new Cartesian3()); + const cartographic = Cartographic.fromCartesian(tangentPoint); + // 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)); + }; + const useViewCone = Number.isFinite(plan.lod.max3dViewAngleDegrees) && plan.lod.max3dViewAngleDegrees < 170; + const viewConeMinimumDot = Math.cos(CesiumMath.toRadians(plan.lod.max3dViewAngleDegrees)); + const viewConeMinimumDistance = Math.max( + plan.grid.stepMeters * 2, + Math.min(plan.grid.radiusMeters, plan.cameraHeightMeters * 6), + ); + const cameraLocal = Matrix4.multiplyByPoint(plan.inverseEnu, plan.cameraPosition, new Cartesian3()); + const cameraDirectionLocal = Matrix4.multiplyByPointAsVector(plan.inverseEnu, plan.cameraDirection, new Cartesian3()); + Cartesian3.normalize(cameraDirectionLocal, cameraDirectionLocal); + const originLatitudeRadians = CesiumMath.toRadians(plan.origin.latitude); + const wgs84SemiMajor = 6_378_137; + const wgs84EccentricitySquared = 0.00669437999014; + const latitudeFactor = 1 - wgs84EccentricitySquared * Math.sin(originLatitudeRadians) ** 2; + const eastCurvatureRadius = wgs84SemiMajor / Math.sqrt(latitudeFactor); + const northCurvatureRadius = wgs84SemiMajor * (1 - wgs84EccentricitySquared) / latitudeFactor ** 1.5; + const approximateShellPoint = (eastMeters: number, northMeters: number) => { + const horizontalSquared = eastMeters ** 2 + northMeters ** 2; + if (horizontalSquared <= 1e-9) return new Cartesian3(0, 0, plan.lod.heightMeters); + const curvatureRadius = horizontalSquared / ( + eastMeters ** 2 / eastCurvatureRadius + northMeters ** 2 / northCurvatureRadius + ); + // Invert the donor's tangent-point -> geodetic-direction reprojection. + // This keeps cone culling close to the WGS84 shell without paying for two + // Cartographic conversions on every coarse candidate. + const scale = curvatureRadius / Math.sqrt(curvatureRadius ** 2 + horizontalSquared); + return new Cartesian3( + eastMeters * scale, + northMeters * scale, + curvatureRadius * scale - curvatureRadius + plan.lod.heightMeters, + ); + }; + const inViewCone = (eastMeters: number, northMeters: number) => { + if (!useViewCone) return true; + const shellPoint = approximateShellPoint(eastMeters, northMeters); + const direction = Cartesian3.subtract(shellPoint, cameraLocal, new Cartesian3()); + const distance = Cartesian3.magnitude(direction); + if (!Number.isFinite(distance) || distance <= viewConeMinimumDistance) return true; + Cartesian3.normalize(direction, direction); + const approximateDot = Cartesian3.dot(direction, cameraDirectionLocal); + if (Math.abs(approximateDot - viewConeMinimumDot) > 0.15) return approximateDot >= viewConeMinimumDot; + // ENU-plane culling is cheap, but the rendered lattice is bent back to + // the WGS84 shell. Resolve candidates near the cone boundary in world + // space so curvature cannot hide the sector beneath an oblique camera. + const worldDirection = Cartesian3.subtract(toWorld(eastMeters, northMeters), plan.cameraPosition, new Cartesian3()); + Cartesian3.normalize(worldDirection, worldDirection); + return Cartesian3.dot(worldDirection, plan.cameraDirection) >= viewConeMinimumDot; + }; + const segmentStepMeters = Math.max( + plan.grid.stepMeters, + plan.lod.tileSizeKm * 1_000, + plan.grid.radiusMeters / 80, + ); + const addSegmentedLine = (axis: "east" | "north", line: (typeof plan.grid.lines)[number]) => { + 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}`, + polyline: { + positions, + width: lineWidthPixels, + material: lineColor, + clampToGround: false, + arcType: ArcType.NONE, }, }); } - if (presentation.gridCrossesEnabled) { - const halfLengthMeters = clamp(presentation.gridCrossesLengthMeters, 2, 5_000) / 2; - const halfLatitude = halfLengthMeters / metersPerLatitudeDegree; - const halfLongitude = halfLengthMeters / metersPerLongitudeDegree; - const corridor = (positions: Cartesian3[]) => ({ - positions, - width: clamp(presentation.gridCrossesWidthMeters, 1, 500), - material: crossColor, - height: geometryHeight, - heightReference: projected ? HeightReference.CLAMP_TO_GROUND : HeightReference.NONE, + positions = []; + }; + let part = 0; + for (let variable = -line.extentMeters; variable < line.extentMeters; variable += segmentStepMeters) { + const next = Math.min(variable + segmentStepMeters, line.extentMeters); + const middle = (variable + next) / 2; + const middleEast = axis === "east" ? line.offsetMeters : middle; + const middleNorth = axis === "east" ? middle : line.offsetMeters; + if (!inViewCone(middleEast, middleNorth)) { + flush(); + part += 1; + segmentStart = part; + continue; + } + const start = axis === "east" ? toWorld(line.offsetMeters, variable) : toWorld(variable, line.offsetMeters); + const end = axis === "east" ? toWorld(line.offsetMeters, next) : toWorld(next, line.offsetMeters); + if (positions.length === 0) positions.push(start); + positions.push(end); + part += 1; + } + flush(); + }; + for (const line of plan.grid.lines) addSegmentedLine("east", line); + for (const line of plan.grid.lines) addSegmentedLine("north", line); + + const radiusSquared = plan.grid.radiusMeters ** 2; + const crossHalf = plan.lod.crossesLengthMeters / 2; + for (let eastIndex = -plan.grid.maximumIndex; eastIndex <= plan.grid.maximumIndex; eastIndex += plan.grid.markerStride) { + const eastMeters = eastIndex * plan.grid.stepMeters; + for (let northIndex = -plan.grid.maximumIndex; northIndex <= plan.grid.maximumIndex; northIndex += plan.grid.markerStride) { + const northMeters = northIndex * plan.grid.stepMeters; + if (eastMeters ** 2 + northMeters ** 2 > radiusSquared) continue; + if (!inViewCone(eastMeters, northMeters)) continue; + const position = toWorld(eastMeters, northMeters); + const address = localSectorAt({ eastMeters, northMeters }, definition); + if (points) points.add({ + id: { kind: "nodedc-grid-intersection", sectorId: address.id }, + position, + color: dotColor, + pixelSize: Math.max(1, Math.round(dotPixelSize)), + }); + if (crosses && crossHalf > 0) { + const eastWest = crosses.add({ + id: { kind: "nodedc-grid-cross", sectorId: address.id }, + positions: [toWorld(eastMeters - crossHalf, northMeters), toWorld(eastMeters + crossHalf, northMeters)], + width: Math.max(1, Math.round(crossWidthPixels)), }); - entities.add({ - id: `${serial}:cross-ns:${row}:${column}`, - corridor: corridor([ - Cartesian3.fromDegrees(dotLongitude, dotLatitude - halfLatitude, geometryHeight), - Cartesian3.fromDegrees(dotLongitude, dotLatitude + halfLatitude, geometryHeight), - ]), - }); - entities.add({ - id: `${serial}:cross-ew:${row}:${column}`, - corridor: corridor([ - Cartesian3.fromDegrees(dotLongitude - halfLongitude, dotLatitude, geometryHeight), - Cartesian3.fromDegrees(dotLongitude + halfLongitude, dotLatitude, geometryHeight), - ]), + const northSouth = crosses.add({ + id: { kind: "nodedc-grid-cross", sectorId: address.id }, + positions: [toWorld(eastMeters, northMeters - crossHalf), toWorld(eastMeters, northMeters + crossHalf)], + width: Math.max(1, Math.round(crossWidthPixels)), }); + if (eastWest.material?.uniforms) eastWest.material.uniforms.color = crossColor; + if (northSouth.material?.uniforms) northSouth.material.uniforms.color = crossColor; } } } - return { dataSource, key, lodIndex: lod.index }; + return { dataSource, points, crosses }; +} + +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 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({ + id, + polyline: { + positions, + width: plan.lod.graticuleLineWidthPx, + material: color, + clampToGround, + arcType: ArcType.RHUMB, + granularity: graticuleGranularity(sampleStep, clampToGround), + }, + }); + const positionsForParts = ( + parts: Array<{ start: number; end: number }>, + positionAt: (angle: number) => Cartesian3, + ) => parts.length === 0 + ? [] + : [positionAt(parts[0].start), ...parts.map(({ end }) => positionAt(end))]; + for (const { longitude } of plan.grid.meridians) { + const parts = boundedAngularParts(plan.grid.south, plan.grid.north); + line( + `grid/wgs84/l${plan.lod.index + 1}/meridian/${longitude}`, + positionsForParts(parts, (latitude) => Cartesian3.fromDegrees(longitude, latitude, height)), + ); + } + for (const latitude of plan.grid.parallels) { + 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}`, + positionsForParts(parts, (longitude) => Cartesian3.fromDegrees(longitude, latitude, height)), + ); + }); + } + return { dataSource, points: null, crosses: null }; +} + +function materializeGridLayer(plan: Exclude, serial: number) { + return plan.mode === "3d" ? materializeLocalGrid(plan, serial) : materializeGraticule(plan, serial); } class GridLayerController { - private current: CustomDataSource | null = null; - private pending: CustomDataSource | null = null; + private current: { resources: GridResources; addressing: GridAddressing } | null = null; + private pending: { resources: GridResources; addressing: GridAddressing } | null = null; private key: string | null = null; private lodIndex: number | null = null; private epoch = 0; private serial = 0; private removeReadyListener: (() => void) | null = null; private fallbackTimer: number | null = null; + private retiredResources = new WeakSet(); - constructor(private readonly viewer: Viewer) {} + constructor( + private readonly viewer: Viewer, + private readonly onAddressingChange: () => void, + ) {} + + private addressingKey(addressing: GridAddressing | undefined) { + if (!addressing) return "hidden"; + return addressing.mode === "3d" + ? JSON.stringify([addressing.mode, addressing.lod, addressing.originLatitude, addressing.originLongitude, addressing.stepMeters]) + : JSON.stringify([addressing.mode, addressing.lod, addressing.stepDegrees]); + } rebuild(presentation: MapPresentation) { - const build = buildGridLayer(this.viewer, presentation, this.lodIndex, ++this.serial); - if (build.key === this.key) return; - this.key = build.key; - this.lodIndex = build.lodIndex; + // Planning is intentionally cheap. Geometry is materialized only after + // key comparison; repeated camera/settings notifications cannot create + // and discard thousands of Cesium objects for an unchanged layer. + const plan = planGridLayer(this.viewer, presentation, this.lodIndex); + if (plan.key === this.key) return; + this.key = plan.key; + this.lodIndex = plan.lodIndex; const epoch = ++this.epoch; this.cancelPending(); - if (!build.dataSource) { - if (this.current) this.viewer.dataSources.remove(this.current, true); + if (plan.mode === "hidden") { + this.onAddressingChange(); + if (this.current) this.removeResources(this.current.resources); this.current = null; this.viewer.scene.requestRender(); return; } - const next = build.dataSource; + const addressing: GridAddressing = plan.mode === "3d" ? { + mode: "3d", + lod: plan.lod.index + 1, + stepMeters: plan.grid.stepMeters, + radiusMeters: plan.grid.radiusMeters, + originLatitude: plan.origin.latitude, + originLongitude: plan.origin.longitude, + inverseEnu: plan.inverseEnu, + } : { + mode: "graticule", + lod: plan.lod.index + 1, + stepDegrees: plan.grid.stepDegrees, + }; + const activeAddressing = this.pending?.addressing ?? this.current?.addressing; + if (this.addressingKey(activeAddressing) !== this.addressingKey(addressing)) this.onAddressingChange(); + const resources = materializeGridLayer(plan, ++this.serial); + const next = { resources, addressing }; this.pending = next; - void this.viewer.dataSources.add(next); + this.mountResources(resources); let readyFrames = 0; const commit = () => { if (epoch !== this.epoch || this.pending !== next) return; @@ -1102,33 +1374,100 @@ class GridLayerController { const previous = this.current; this.current = next; this.pending = null; - if (previous && previous !== next) this.viewer.dataSources.remove(previous, true); + if (previous && previous !== next) this.removeResources(previous.resources); this.viewer.scene.requestRender(); }; this.removeReadyListener = this.viewer.scene.postRender.addEventListener(() => { readyFrames = this.viewer.dataSourceDisplay.ready ? readyFrames + 1 : 0; if (readyFrames >= 2) commit(); + else this.viewer.scene.requestRender(); }); - // A slow terrain worker must not leave an obsolete buffer mounted forever. - // The old layer stays visible during this grace period, so the fallback is - // bounded cleanup rather than a visible blank-before-build swap. this.fallbackTimer = window.setTimeout(commit, 2_000); this.viewer.scene.requestRender(); } + pick(worldPosition: Cartesian3): GridSectorSelection | null { + const addressing = this.pending?.addressing ?? this.current?.addressing; + if (!addressing) return null; + if (addressing.mode === "3d") { + const ellipsoid = this.viewer.scene.globe.ellipsoid; + const surface = ellipsoid.scaleToGeodeticSurface(worldPosition, new Cartesian3()) ?? worldPosition; + const normal = ellipsoid.geodeticSurfaceNormal(surface, new Cartesian3()); + const localSurface = Matrix4.multiplyByPoint(addressing.inverseEnu, surface, new Cartesian3()); + const localNormal = Matrix4.multiplyByPointAsVector(addressing.inverseEnu, normal, new Cartesian3()); + // Geometry is addressed on the origin's ENU tangent plane and then + // reprojected along the WGS84 normal. Intersect that normal with the + // same plane so analytic picking returns the exact source cell even at + // the outer edge of a 1,000 km LOD. + const normalScale = Math.abs(localNormal.z) > 1e-9 ? -localSurface.z / localNormal.z : 0; + const local = new Cartesian3( + localSurface.x + localNormal.x * normalScale, + localSurface.y + localNormal.y * normalScale, + 0, + ); + if (local.x ** 2 + local.y ** 2 > addressing.radiusMeters ** 2) return null; + const definition = { + lod: addressing.lod, + originLatitude: addressing.originLatitude, + originLongitude: addressing.originLongitude, + stepMeters: addressing.stepMeters, + }; + const address = localSectorAt({ eastMeters: local.x, northMeters: local.y }, definition); + return { + id: address.id, + lod: addressing.lod, + mode: "3d", + address: { eastIndex: address.eastIndex, northIndex: address.northIndex }, + bounds: localSectorBounds(address, addressing.stepMeters), + units: "meters-enu", + }; + } + const cartographic = Cartographic.fromCartesian(worldPosition); + const address = graticuleSectorAt({ + longitude: CesiumMath.toDegrees(cartographic.longitude), + latitude: CesiumMath.toDegrees(cartographic.latitude), + }, { lod: addressing.lod, stepDegrees: addressing.stepDegrees }); + return { + id: address.id, + lod: addressing.lod, + mode: "graticule", + address: { longitudeIndex: address.longitudeIndex, latitudeIndex: address.latitudeIndex }, + bounds: graticuleSectorBounds(address, addressing.stepDegrees), + units: "degrees-wgs84", + }; + } + destroy() { this.epoch += 1; this.cancelPending(); - if (this.current) this.viewer.dataSources.remove(this.current, true); + if (this.current) this.removeResources(this.current.resources); this.current = null; } + private mountResources(resources: GridResources) { + const add = this.viewer.dataSources.add(resources.dataSource); + void add.then((dataSource) => { + if (this.retiredResources.has(resources) && !this.viewer.isDestroyed()) { + this.viewer.dataSources.remove(dataSource, true); + } + }).catch(() => undefined); + if (resources.points) this.viewer.scene.primitives.add(resources.points); + if (resources.crosses) this.viewer.scene.primitives.add(resources.crosses); + } + + private removeResources(resources: GridResources) { + this.retiredResources.add(resources); + this.viewer.dataSources.remove(resources.dataSource, true); + if (resources.points) this.viewer.scene.primitives.remove(resources.points); + if (resources.crosses) this.viewer.scene.primitives.remove(resources.crosses); + } + private cancelPending() { this.removeReadyListener?.(); this.removeReadyListener = null; if (this.fallbackTimer !== null) window.clearTimeout(this.fallbackTimer); this.fallbackTimer = null; - if (this.pending) this.viewer.dataSources.remove(this.pending, true); + if (this.pending) this.removeResources(this.pending.resources); this.pending = null; } } @@ -1184,6 +1523,7 @@ function applyPresentation( export const CesiumMapRenderer = forwardRef void; + onGridSectorSelect?: (sector: GridSectorSelection | null) => void; onGatewayHealth?: (health: MapGatewayHealth | null) => void; onProviderStatus?: (status: MapProviderStatus) => void; onCameraChange?: (camera: MapCameraView) => void; @@ -1197,6 +1537,7 @@ export const CesiumMapRenderer = forwardRef(function CesiumMapRenderer({ onSelect, + onGridSectorSelect, onGatewayHealth, onProviderStatus, onCameraChange, @@ -1224,6 +1565,7 @@ export const CesiumMapRenderer = forwardRef { + onGridSectorSelectRef.current = onGridSectorSelect; + }, [onGridSectorSelect]); + useEffect(() => { onCameraChangeRef.current = onCameraChange; }, [onCameraChange]); @@ -1641,14 +1987,20 @@ export const CesiumMapRenderer = forwardRef ({ startSpiralAnimation, @@ -1721,6 +2073,8 @@ export const CesiumMapRenderer = forwardRef void) | undefined; let removeGridCameraChangedListener: (() => void) | undefined; + let gridCameraChangedTimer: number | undefined; + let gridResizeTimer: number | undefined; let removeRefreshRenderListener: (() => void) | undefined; let removeRenderErrorListener: (() => void) | undefined; const removeProviderFailureListeners: Array<() => void> = []; @@ -1791,7 +2145,7 @@ export const CesiumMapRenderer = forwardRef onGridSectorSelectRef.current?.(null)); const rebuildGrid = () => gridController?.rebuild(presentationRef.current); rebuildGridRef.current = rebuildGrid; removeGridCameraListener = viewer.camera.moveEnd.addEventListener(() => { @@ -1800,7 +2154,11 @@ export const CesiumMapRenderer = forwardRef { - if (!presentationRef.current.gridRebuildOnMoveEnd) rebuildGrid(); + if (presentationRef.current.gridRebuildOnMoveEnd || gridCameraChangedTimer !== undefined) return; + gridCameraChangedTimer = window.setTimeout(() => { + gridCameraChangedTimer = undefined; + rebuildGrid(); + }, 150); }); const providerStatus: MapProviderStatus = { @@ -1973,7 +2331,8 @@ export const CesiumMapRenderer = forwardRef { const picked = viewer?.scene.pick(movement.position); const pickedId = picked?.id; - if (pickedId instanceof Entity && pickedId.id) { + if (pickedId instanceof Entity && pickedId.id && !String(pickedId.id).startsWith("grid/")) { + onGridSectorSelectRef.current?.(null); onSelectRef.current?.(pickedId.id); return; } @@ -1982,12 +2341,27 @@ export const CesiumMapRenderer = forwardRef).kind === "nodedc-hgeozone" && typeof (pickedId as Partial).entityId === "string" - ) onSelectRef.current?.((pickedId as HGeoZonePickId).entityId); + ) { + onGridSectorSelectRef.current?.(null); + onSelectRef.current?.((pickedId as HGeoZonePickId).entityId); + return; + } + const ray = viewer?.camera.getPickRay(movement.position); + const worldPosition = ray && viewer + ? viewer.scene.globe.pick(ray, viewer.scene) ?? viewer.camera.pickEllipsoid(movement.position, viewer.scene.globe.ellipsoid) + : undefined; + onGridSectorSelectRef.current?.(worldPosition ? gridController?.pick(worldPosition) ?? null : null); }, ScreenSpaceEventType.LEFT_CLICK); resizeObserver = new ResizeObserver(() => { if (!viewer || viewer.isDestroyed()) return; viewer.resize(); viewer.scene.requestRender(); + if (gridResizeTimer === undefined) { + gridResizeTimer = window.setTimeout(() => { + gridResizeTimer = undefined; + rebuildGrid(); + }, 100); + } }); resizeObserver.observe(containerRef.current); } catch (error) { @@ -2014,6 +2388,8 @@ export const CesiumMapRenderer = forwardRef import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer }))); type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean }; @@ -227,12 +231,13 @@ const initialMapSettings: MapPageSettings = { gridLodEnabled: true, grid3dEnabled: true, gridGraticuleEnabled: true, - gridCenterMode: "camera", + gridCenterMode: "fixed", gridCenterLatitude: 55.7558, gridCenterLongitude: 37.6173, gridTileSizeKm: 10, gridAutoDisableHeightKm: 10_000, gridRebuildOnMoveEnd: true, + gridLegacyMode: false, gridMax3dViewAngleDegrees: 30, gridHeightMeters: 500, gridLod1MaxHeightKm: 10, @@ -241,31 +246,47 @@ const initialMapSettings: MapPageSettings = { gridLod2MaxHeightKm: 50, gridLod2StepKm: 5, gridLod2Mode: "3d", - gridLod3MaxHeightKm: 180, + gridLod3MaxHeightKm: 200, gridLod3StepKm: 25, gridLod3Mode: "3d", - gridLod4MaxHeightKm: 700, - gridLod4StepKm: 100, + gridLod4MaxHeightKm: 800, + gridLod4StepKm: 50, gridLod4Mode: "graticule", - gridLod5StepKm: 500, + gridLod5MaxHeightKm: 3_000, + gridLod5StepKm: 50, gridLod5Mode: "graticule", - gridRadiusKm: 1_000, - gridLineWidth: 4, - gridLineDiameterMeters: 10, - gridColor: "#f5f5f5", + gridRadiusKm: 40, + gridLineWidth: 1, + gridLineDiameterMeters: 7, + gridColor: "#9c9c9c", gridOpacity: 12, gridDotsEnabled: true, gridDotsSize: 7, - gridDotsDiameterMeters: 80, - gridDotsColor: "#ffffff", + gridDotsDiameterMeters: 10, + gridDotsColor: "#9c9c9c", gridDotsOpacity: 58, gridCrossesEnabled: false, - gridCrossesLengthMeters: 200, + gridCrossesLengthMeters: 60, gridCrossesWidthMeters: 10, - gridCrossesColor: "#35cfff", - gridCrossesOpacity: 50, + gridCrossesColor: "#9c9c9c", + gridCrossesOpacity: 46, + gridLodProfiles: structuredClone(DEFAULT_GRID_LOD_PROFILES) as GridLodProfile[], }; +function resolveGridLodProfiles(settings?: Partial): GridLodProfile[] { + // A layout saved by the previous flat contract must not lose the values the + // operator already tuned. Promote its common visual fields and per-band + // height/step/mode values into five authoritative profiles on first read; + // the next ordinary page save persists the canonical array. + const legacySettings: MapPresentation = { + ...initialMapSettings, + ...settings, + gridLodProfiles: Array.isArray(settings?.gridLodProfiles) ? settings.gridLodProfiles : [], + cacheRefresh: false, + }; + return Array.from({ length: 5 }, (_unused, index) => gridLodProfile(legacySettings, index) as GridLodProfile); +} + // A valid, deterministic scene view is available before Cesium emits its // first move-end event. It makes the page contract immediately saveable; // the renderer replaces it with the exact live camera as soon as it is ready. @@ -389,6 +410,7 @@ export const MapFixturePreview = forwardRef(function MapFixturePreview({ features = { inspector: true, toolbar: true, assistant: false }, expanded = false, initialLayout = null, applicationId, pageId }, ref) { const workspaceRef = useRef(null); const [selectedId, setSelectedId] = useState(); + const [selectedGridSector, setSelectedGridSector] = useState(null); const [subjectCardOpen, setSubjectCardOpen] = useState(false); const [subjectCardRect, setSubjectCardRect] = useState(defaultSubjectCardRect); const [subjectCardMaximized, setSubjectCardMaximized] = useState(false); @@ -419,7 +441,13 @@ export const MapFixturePreview = forwardRef initialLayout?.mapHeight ?? (expanded ? 620 : 470)); const [mapCamera, setMapCamera] = useState(initialLayout?.camera ?? fallbackMapCamera); const mapRendererRef = useRef(null); @@ -646,6 +674,19 @@ 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 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({ + gridLodProfiles: mapSettings.gridLodProfiles.map((profile, index) => ( + index === selectedGridLodIndex ? { ...profile, ...patch } : profile + )), + }); const setCacheEnabled = (cacheEnabled: boolean) => { updateMapSettings({ cacheEnabled }); setRendererRevision((value) => value + 1); @@ -1267,57 +1308,47 @@ export const MapFixturePreview = forwardRef - Пять LOD сохраняют пространственную сетку вблизи и переходят к гратикуле на дальних высотах. Новый слой подготавливается до удаления предыдущего. + Фиксированная московская ENU-адресация задаёт неизменные сектора на LOD 1–3. LOD 4–5 используют глобальную WGS84-гратику́лу; камера выбирает только LOD и видимую область. updateMapSettings({ gridVisible })} /> updateMapSettings({ grid3dEnabled })} /> updateMapSettings({ gridGraticuleEnabled })} /> updateMapSettings({ gridLodEnabled })} /> updateMapSettings({ gridRebuildOnMoveEnd })} /> - updateMapSettings({ gridCenterMode })} - /> - {mapSettings.gridCenterMode === "fixed" ? <> - value.toFixed(4)} onChange={(gridCenterLatitude) => updateMapSettings({ gridCenterLatitude })} /> - value.toFixed(4)} onChange={(gridCenterLongitude) => updateMapSettings({ gridCenterLongitude })} /> - : null} - `${value} км`} onChange={(gridTileSizeKm) => updateMapSettings({ gridTileSizeKm })} /> + Fixed ENU · WGS84 + value.toFixed(6)} onChange={(gridCenterLatitude) => updateMapSettings({ gridCenterLatitude })} /> + value.toFixed(6)} onChange={(gridCenterLongitude) => updateMapSettings({ gridCenterLongitude })} /> value === 0 ? "выкл" : `${value} км`} onChange={(gridAutoDisableHeightKm) => updateMapSettings({ gridAutoDisableHeightKm })} /> - `${value} м`} onChange={(gridHeightMeters) => updateMapSettings({ gridHeightMeters })} /> - `${value}°`} onChange={(gridMax3dViewAngleDegrees) => updateMapSettings({ gridMax3dViewAngleDegrees })} /> - updateMapSettings({ gridLod1Mode })} /> - `${value} км`} onChange={(gridLod1MaxHeightKm) => updateMapSettings({ gridLod1MaxHeightKm })} /> - `${value} км`} onChange={(gridLod1StepKm) => updateMapSettings({ gridLod1StepKm })} /> - updateMapSettings({ gridLod2Mode })} /> - `${value} км`} onChange={(gridLod2MaxHeightKm) => updateMapSettings({ gridLod2MaxHeightKm })} /> - `${value} км`} onChange={(gridLod2StepKm) => updateMapSettings({ gridLod2StepKm })} /> - updateMapSettings({ gridLod3Mode })} /> - `${value} км`} onChange={(gridLod3MaxHeightKm) => updateMapSettings({ gridLod3MaxHeightKm })} /> - `${value} км`} onChange={(gridLod3StepKm) => updateMapSettings({ gridLod3StepKm })} /> - updateMapSettings({ gridLod4Mode })} /> - `${value} км`} onChange={(gridLod4MaxHeightKm) => updateMapSettings({ gridLod4MaxHeightKm })} /> - `${value} км`} onChange={(gridLod4StepKm) => updateMapSettings({ gridLod4StepKm })} /> - updateMapSettings({ gridLod5Mode })} /> - `${value} км`} onChange={(gridLod5StepKm) => updateMapSettings({ gridLod5StepKm })} /> - `${value} км`} onChange={(gridRadiusKm) => updateMapSettings({ gridRadiusKm })} /> - updateMapSettings({ gridColor })} /> - `${value} м`} onChange={(gridLineDiameterMeters) => updateMapSettings({ gridLineDiameterMeters })} /> - `${value} px`} onChange={(gridLineWidth) => updateMapSettings({ gridLineWidth })} /> - `${value}%`} onChange={(gridOpacity) => updateMapSettings({ gridOpacity })} /> - updateMapSettings({ gridDotsEnabled })} /> - `${value} м`} onChange={(gridDotsDiameterMeters) => updateMapSettings({ gridDotsDiameterMeters })} /> - updateMapSettings({ gridDotsColor })} /> - `${value}%`} onChange={(gridDotsOpacity) => updateMapSettings({ gridDotsOpacity })} /> - updateMapSettings({ gridCrossesEnabled })} /> - `${value} м`} onChange={(gridCrossesLengthMeters) => updateMapSettings({ gridCrossesLengthMeters })} /> - `${value} м`} onChange={(gridCrossesWidthMeters) => updateMapSettings({ gridCrossesWidthMeters })} /> - updateMapSettings({ gridCrossesColor })} /> - `${value}%`} onChange={(gridCrossesOpacity) => updateMapSettings({ gridCrossesOpacity })} /> +
+ ({ value: String(index), label: `LOD ${index + 1}` }))} label="Уровень детализации сетки" onChange={setSelectedGridLod} /> +
+ `${value} км`} onChange={(maxHeightKm) => updateGridLod({ maxHeightKm })} /> + {selectedGridLodIndex === 4 ? Последний LOD остаётся активным выше своего порога до общего автовыключения. : null} + updateGridLod({ mode })} /> + `${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={(radiusKm) => updateGridLod({ radiusKm })} /> + `${value} м`} onChange={(lineDiameterMeters) => updateGridLod({ lineDiameterMeters })} /> + updateGridLod({ lineColor })} /> + `${value}%`} onChange={(lineOpacity) => updateGridLod({ lineOpacity })} /> + updateGridLod({ dotsEnabled })} /> + `${value} м`} onChange={(dotsDiameterMeters) => updateGridLod({ dotsDiameterMeters })} /> + updateGridLod({ dotsColor })} /> + `${value}%`} onChange={(dotsOpacity) => updateGridLod({ dotsOpacity })} /> + updateGridLod({ crossesEnabled })} /> + `${value} м`} onChange={(crossesLengthMeters) => updateGridLod({ crossesLengthMeters })} /> + `${value} м`} onChange={(crossesWidthMeters) => updateGridLod({ crossesWidthMeters })} /> + updateGridLod({ crossesColor })} /> + `${value}%`} onChange={(crossesOpacity) => updateGridLod({ crossesOpacity })} /> + `${value}°`} onChange={(graticuleStepDegrees) => updateGridLod({ graticuleStepDegrees })} /> + `${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} , }, { @@ -1447,6 +1478,7 @@ export const MapFixturePreview = forwardRef[]; +export function gridLodProfile(settings: MapPresentation, index: number): GridLodProfile; export function gridLodBands(settings: MapPresentation): GridLodBand[]; export function selectGridLod( settings: MapPresentation, @@ -19,10 +44,10 @@ export function selectGridLod( export function resolveGridMode( settings: MapPresentation, requestedMode: GridLodMode, - viewAngleFromNadirDegrees: number, ): GridLodMode | "hidden"; export function gridShouldBeVisible(settings: MapPresentation, cameraHeightKm: number): boolean; export function snapGridCenter( center: { latitude: number; longitude: number }, settings: MapPresentation, + tileSizeKm?: number, ): { latitude: number; longitude: number }; diff --git a/apps/catalog/src/mapGridPolicy.mjs b/apps/catalog/src/mapGridPolicy.mjs index 5d00794..7aab252 100644 --- a/apps/catalog/src/mapGridPolicy.mjs +++ b/apps/catalog/src/mapGridPolicy.mjs @@ -1,37 +1,95 @@ -const GRID_LOD_COUNT = 5; +import { fixedGridOrigin } from "./mapSectorGrid.mjs"; +export const GRID_LOD_COUNT = 5; export const GRID_LOD_HYSTERESIS_RATIO = 0.08; const finite = (value, fallback) => Number.isFinite(Number(value)) ? Number(value) : fallback; const clamp = (value, minimum, maximum) => Math.min(maximum, Math.max(minimum, value)); +// Exact effective values of the tracked Engine MMAP/MOSCOWMAP workflow. The +// renderer fixes donor defects (camera-relative phase, geodesic parallels and +// blank swaps) but preserves the tuned distance and visual profile. +export const DEFAULT_GRID_LOD_PROFILES = Object.freeze([ + Object.freeze({ + 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, + }), + Object.freeze({ + maxHeightKm: 50, stepKm: 5, mode: "3d", heightMeters: 500, max3dViewAngleDegrees: 30, + tileSizeKm: 10, radiusKm: 1_000, 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, + }), + Object.freeze({ + maxHeightKm: 200, stepKm: 25, mode: "3d", heightMeters: 500, max3dViewAngleDegrees: 30, + tileSizeKm: 25, radiusKm: 1_000, 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, + }), + Object.freeze({ + maxHeightKm: 800, stepKm: 50, mode: "graticule", heightMeters: 500, max3dViewAngleDegrees: 30, + tileSizeKm: 10, radiusKm: 1_000, 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, + }), + Object.freeze({ + // Engine keeps the last LOD selected above this threshold; the independent + // 10,000 km auto-disable remains the actual upper visibility boundary. + maxHeightKm: 3_000, 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, + }), +]); + +const color = (value, fallback) => typeof value === "string" ? value : fallback; + +export function gridLodProfile(settings, index) { + const fallback = DEFAULT_GRID_LOD_PROFILES[index] ?? DEFAULT_GRID_LOD_PROFILES[0]; + const source = Array.isArray(settings.gridLodProfiles) && settings.gridLodProfiles[index] + ? settings.gridLodProfiles[index] + : {}; + const number = index + 1; + return { + maxHeightKm: finite(source.maxHeightKm, finite(settings[`gridLod${number}MaxHeightKm`], fallback.maxHeightKm)), + stepKm: clamp(finite(source.stepKm, finite(settings[`gridLod${number}StepKm`], fallback.stepKm)), 0.1, 5_000), + mode: (source.mode ?? settings[`gridLod${number}Mode`] ?? fallback.mode) === "graticule" ? "graticule" : "3d", + heightMeters: clamp(finite(source.heightMeters, finite(settings.gridHeightMeters, fallback.heightMeters)), 0, 5_000), + max3dViewAngleDegrees: clamp(finite(source.max3dViewAngleDegrees, finite(settings.gridMax3dViewAngleDegrees, fallback.max3dViewAngleDegrees)), 30, 170), + tileSizeKm: clamp(finite(source.tileSizeKm, finite(settings.gridTileSizeKm, fallback.tileSizeKm)), 1, 50), + radiusKm: clamp(finite(source.radiusKm, finite(settings.gridRadiusKm, fallback.radiusKm)), 1, 100_000), + lineDiameterMeters: clamp(finite(source.lineDiameterMeters, finite(settings.gridLineDiameterMeters, fallback.lineDiameterMeters)), 1, 100), + lineColor: color(source.lineColor, color(settings.gridColor, fallback.lineColor)), + lineOpacity: clamp(finite(source.lineOpacity, finite(settings.gridOpacity, fallback.lineOpacity)), 0, 100), + dotsEnabled: source.dotsEnabled ?? settings.gridDotsEnabled ?? fallback.dotsEnabled, + dotsDiameterMeters: clamp(finite(source.dotsDiameterMeters, finite(settings.gridDotsDiameterMeters, fallback.dotsDiameterMeters)), 1, 1_000), + dotsColor: color(source.dotsColor, color(settings.gridDotsColor, fallback.dotsColor)), + dotsOpacity: clamp(finite(source.dotsOpacity, finite(settings.gridDotsOpacity, fallback.dotsOpacity)), 0, 100), + crossesEnabled: source.crossesEnabled ?? settings.gridCrossesEnabled ?? fallback.crossesEnabled, + crossesLengthMeters: clamp(finite(source.crossesLengthMeters, finite(settings.gridCrossesLengthMeters, fallback.crossesLengthMeters)), 1, 5_000), + crossesWidthMeters: clamp(finite(source.crossesWidthMeters, finite(settings.gridCrossesWidthMeters, fallback.crossesWidthMeters)), 1, 500), + crossesColor: color(source.crossesColor, color(settings.gridCrossesColor, fallback.crossesColor)), + crossesOpacity: clamp(finite(source.crossesOpacity, finite(settings.gridCrossesOpacity, fallback.crossesOpacity)), 0, 100), + graticuleStepDegrees: clamp(finite(source.graticuleStepDegrees, fallback.graticuleStepDegrees), 0.1, 180), + graticuleLineWidthPx: clamp(finite(source.graticuleLineWidthPx ?? source.lineWidthPx, finite(settings.gridLineWidth, fallback.graticuleLineWidthPx)), 1, 3), + graticuleColor: color(source.graticuleColor, color(source.lineColor, color(settings.gridColor, fallback.graticuleColor))), + graticuleOpacity: clamp(finite(source.graticuleOpacity, finite(source.lineOpacity, finite(settings.gridOpacity, fallback.graticuleOpacity))), 0, 100), + }; +} + export function gridLodBands(settings) { - const maximums = [ - finite(settings.gridLod1MaxHeightKm, 10), - finite(settings.gridLod2MaxHeightKm, 50), - finite(settings.gridLod3MaxHeightKm, 180), - finite(settings.gridLod4MaxHeightKm, 700), - Number.POSITIVE_INFINITY, - ]; - for (let index = 1; index < maximums.length - 1; index += 1) { - maximums[index] = Math.max(maximums[index - 1], maximums[index]); - } - const steps = [ - finite(settings.gridLod1StepKm, 1), - finite(settings.gridLod2StepKm, 5), - finite(settings.gridLod3StepKm, 25), - finite(settings.gridLod4StepKm, 100), - finite(settings.gridLod5StepKm, 500), - ]; - const modes = [1, 2, 3, 4, 5].map((number) => ( - settings[`gridLod${number}Mode`] === "graticule" ? "graticule" : "3d" - )); - return maximums.map((maxHeightKm, index) => ({ + const profiles = Array.from({ length: GRID_LOD_COUNT }, (_unused, index) => gridLodProfile(settings, index)); + return profiles.map((profile, index) => ({ + ...profile, index, id: `lod-${index + 1}`, - maxHeightKm, - stepKm: clamp(steps[index], 0.25, 5_000), - mode: modes[index], })); } @@ -39,14 +97,15 @@ export function selectGridLod(settings, cameraHeightKm, previousIndex = null) { const bands = gridLodBands(settings); if (!settings.gridLodEnabled) return bands[0]; const height = Math.max(0, finite(cameraHeightKm, 0)); - const directIndex = Math.max(0, bands.findIndex((band) => height <= band.maxHeightKm)); + const matchedIndex = bands.findIndex((band) => height <= band.maxHeightKm); + const directIndex = matchedIndex === -1 ? bands.length - 1 : matchedIndex; if (!Number.isInteger(previousIndex) || previousIndex < 0 || previousIndex >= GRID_LOD_COUNT) { return bands[directIndex]; } const previous = bands[previousIndex]; const lowerBoundary = previousIndex === 0 ? 0 : bands[previousIndex - 1].maxHeightKm; - const upperBoundary = previous.maxHeightKm; + const upperBoundary = previousIndex === bands.length - 1 ? Number.POSITIVE_INFINITY : previous.maxHeightKm; const lowerHold = lowerBoundary * (1 - GRID_LOD_HYSTERESIS_RATIO); const upperHold = Number.isFinite(upperBoundary) ? upperBoundary * (1 + GRID_LOD_HYSTERESIS_RATIO) @@ -54,10 +113,9 @@ export function selectGridLod(settings, cameraHeightKm, previousIndex = null) { return height >= lowerHold && height <= upperHold ? previous : bands[directIndex]; } -export function resolveGridMode(settings, requestedMode, viewAngleFromNadirDegrees) { +export function resolveGridMode(settings, requestedMode) { if (requestedMode === "3d") { - const maxAngle = clamp(finite(settings.gridMax3dViewAngleDegrees, 30), 0, 89); - if (settings.grid3dEnabled !== false && viewAngleFromNadirDegrees <= maxAngle) return "3d"; + if (settings.grid3dEnabled !== false) return "3d"; return settings.gridGraticuleEnabled === false ? "hidden" : "graticule"; } if (settings.gridGraticuleEnabled !== false) return "graticule"; @@ -70,21 +128,9 @@ export function gridShouldBeVisible(settings, cameraHeightKm) { return limit <= 0 || Math.max(0, finite(cameraHeightKm, 0)) <= limit; } -export function snapGridCenter({ latitude, longitude }, settings) { - if (settings.gridCenterMode === "fixed") { - return { - latitude: clamp(finite(settings.gridCenterLatitude, 55.7558), -89.999, 89.999), - longitude: clamp(finite(settings.gridCenterLongitude, 37.6173), -180, 180), - }; - } - const sourceLatitude = clamp(finite(latitude, 55.7558), -89.999, 89.999); - const sourceLongitude = clamp(finite(longitude, 37.6173), -180, 180); - const tileKm = clamp(finite(settings.gridTileSizeKm, 10), 0.25, 5_000); - const latitudeStep = tileKm / 110.574; - const snappedLatitude = Math.round(sourceLatitude / latitudeStep) * latitudeStep; - const longitudeStep = tileKm / Math.max(0.001, 111.320 * Math.cos(snappedLatitude * Math.PI / 180)); - return { - latitude: snappedLatitude, - longitude: Math.round(sourceLongitude / longitudeStep) * longitudeStep, - }; +// Backward-compatible export. The camera argument is intentionally ignored: +// a sector definition has one immutable origin and camera movement only +// changes LOD/visibility. +export function snapGridCenter(_cameraCenter, settings) { + return fixedGridOrigin(settings); } diff --git a/apps/catalog/src/mapSectorGrid.d.mts b/apps/catalog/src/mapSectorGrid.d.mts new file mode 100644 index 0000000..1084a64 --- /dev/null +++ b/apps/catalog/src/mapSectorGrid.d.mts @@ -0,0 +1,62 @@ +export type LocalGridDefinition = { + lod: number; + originLatitude: number; + originLongitude: number; + stepMeters: number; +}; + +export type LocalSectorAddress = { + family: "local-enu"; + lod: number; + eastIndex: number; + northIndex: number; + id: string; +}; + +export type GraticuleDefinition = { lod: number; stepDegrees: number }; +export type GraticuleSectorAddress = { + family: "wgs84-graticule"; + lod: number; + longitudeIndex: number; + latitudeIndex: number; + id: string; +}; + +export const MAX_LOCAL_GRID_INDEX: number; + +export function normalizeLongitudeDegrees(value: number): number; +export function fixedGridOrigin(settings: { gridCenterLatitude?: number; gridCenterLongitude?: number }): { latitude: number; longitude: number }; +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 localSectorNeighbors(address: LocalSectorAddress, definition: LocalGridDefinition): Record<"north" | "east" | "south" | "west", LocalSectorAddress>; +export function localParentSector(address: LocalSectorAddress, childStepMeters: number, parentDefinition: LocalGridDefinition): LocalSectorAddress; +export function localGridPlan(input: { stepMeters: number; radiusMeters: number; maximumMarkers?: number }): { + stepMeters: number; + radiusMeters: number; + requestedRadiusMeters: number; + clipped: boolean; + maximumIndex: number; + markerStride: number; + lines: Array<{ index: number; offsetMeters: number; extentMeters: number }>; +}; +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 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 }>; +export function graticuleGranularity(stepDegrees: number, clampToGround: boolean): number; +export function graticuleLinePlan(input: { + south: number; + north: number; + longitudeIntervals: Array<{ west: number; east: number }>; + stepDegrees: number; +}): { + stepDegrees: number; + south: number; + north: number; + longitudeIntervals: Array<{ west: number; east: number }>; + parallels: number[]; + meridians: Array<{ longitude: number; interval: { west: number; east: number } }>; +}; diff --git a/apps/catalog/src/mapSectorGrid.mjs b/apps/catalog/src/mapSectorGrid.mjs new file mode 100644 index 0000000..c34257a --- /dev/null +++ b/apps/catalog/src/mapSectorGrid.mjs @@ -0,0 +1,211 @@ +const EPSILON = 1e-9; +const WGS84_EQUATORIAL_RADIUS_METERS = 6_378_137; +export const MAX_LOCAL_GRID_INDEX = 512; + +const finite = (value, fallback) => Number.isFinite(Number(value)) ? Number(value) : fallback; +const clamp = (value, minimum, maximum) => Math.min(maximum, Math.max(minimum, value)); +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."); + +export function normalizeLongitudeDegrees(value) { + const longitude = finite(value, 0); + return canonicalZero(((longitude + 180) % 360 + 360) % 360 - 180); +} + +export function fixedGridOrigin(settings) { + return { + latitude: clamp(finite(settings?.gridCenterLatitude, 55.7558), -89.9, 89.9), + longitude: normalizeLongitudeDegrees(finite(settings?.gridCenterLongitude, 37.6173)), + }; +} + +function localDefinitionToken(definition) { + const latitude = decimalToken(definition.originLatitude, 6); + const longitude = decimalToken(normalizeLongitudeDegrees(definition.originLongitude), 6); + const stepMeters = decimalToken(definition.stepMeters, 3); + return `${latitude},${longitude}/l${definition.lod}/s${stepMeters}`; +} + +export function localSectorId(definition, eastIndex, northIndex) { + return `grid/local/${localDefinitionToken(definition)}/e${signedIndex(eastIndex)}/n${signedIndex(northIndex)}`; +} + +export function localSectorAt(point, definition) { + const stepMeters = Math.max(EPSILON, finite(definition.stepMeters, 1)); + const eastIndex = canonicalZero(Math.floor(finite(point.eastMeters, 0) / stepMeters)); + const northIndex = canonicalZero(Math.floor(finite(point.northMeters, 0) / stepMeters)); + return { + family: "local-enu", + lod: definition.lod, + eastIndex, + northIndex, + id: localSectorId({ ...definition, stepMeters }, eastIndex, northIndex), + }; +} + +export function localSectorBounds(address, stepMeters) { + const step = Math.max(EPSILON, finite(stepMeters, 1)); + return { + west: address.eastIndex * step, + east: (address.eastIndex + 1) * step, + south: address.northIndex * step, + north: (address.northIndex + 1) * step, + }; +} + +export function localSectorNeighbors(address, definition) { + const at = (eastIndex, northIndex) => ({ + family: "local-enu", + lod: definition.lod, + eastIndex, + northIndex, + id: localSectorId(definition, eastIndex, northIndex), + }); + return { + north: at(address.eastIndex, address.northIndex + 1), + east: at(address.eastIndex + 1, address.northIndex), + south: at(address.eastIndex, address.northIndex - 1), + west: at(address.eastIndex - 1, address.northIndex), + }; +} + +export function localParentSector(address, childStepMeters, parentDefinition) { + const childStep = Math.max(EPSILON, finite(childStepMeters, 1)); + const parentStep = Math.max(EPSILON, finite(parentDefinition.stepMeters, 1)); + const ratio = parentStep / childStep; + if (!Number.isInteger(ratio) || ratio < 1) throw new Error("grid_parent_step_must_be_integer_multiple"); + return localSectorAt({ + eastMeters: address.eastIndex * childStep, + northMeters: address.northIndex * childStep, + }, parentDefinition); +} + +export function localGridPlan({ stepMeters, radiusMeters, maximumMarkers = 5_000 }) { + const step = Math.max(1, finite(stepMeters, 1)); + const requestedRadius = Math.max(step, finite(radiusMeters, step)); + // Invalid in-memory inspector edits must not allocate millions of lines + // before server validation can reject them. Canonical layouts satisfy the + // same ratio explicitly; this is the renderer's final fail-safe. + const maximumIndex = Math.min(MAX_LOCAL_GRID_INDEX, Math.ceil(requestedRadius / step)); + const radius = Math.min(requestedRadius, maximumIndex * step); + const radiusSquared = radius * radius; + const lines = []; + for (let index = -maximumIndex; index <= maximumIndex; index += 1) { + const offsetMeters = index * step; + if (Math.abs(offsetMeters) > radius + EPSILON) continue; + lines.push({ + index, + offsetMeters, + extentMeters: Math.sqrt(Math.max(0, radiusSquared - offsetMeters * offsetMeters)), + }); + } + const approximateMarkers = Math.PI * (radius / step) ** 2; + const markerStride = Math.max(1, Math.ceil(Math.sqrt(approximateMarkers / Math.max(1, maximumMarkers)))); + return { stepMeters: step, radiusMeters: radius, requestedRadiusMeters: requestedRadius, clipped: radius < requestedRadius, maximumIndex, markerStride, lines }; +} + +function graticuleDefinitionToken(definition) { + return `wgs84/l${definition.lod}/s${decimalToken(definition.stepDegrees, 6)}`; +} + +export function graticuleSectorId(definition, longitudeIndex, latitudeIndex) { + return `grid/${graticuleDefinitionToken(definition)}/x${signedIndex(longitudeIndex)}/y${signedIndex(latitudeIndex)}`; +} + +export function graticuleSectorAt(point, definition) { + const stepDegrees = Math.max(EPSILON, finite(definition.stepDegrees, 1)); + const longitude = normalizeLongitudeDegrees(point.longitude); + const latitude = clamp(finite(point.latitude, 0), -90, 90 - EPSILON); + const longitudeIndex = canonicalZero(Math.floor(longitude / stepDegrees)); + const latitudeIndex = canonicalZero(Math.floor(latitude / stepDegrees)); + return { + family: "wgs84-graticule", + lod: definition.lod, + longitudeIndex, + latitudeIndex, + id: graticuleSectorId({ ...definition, stepDegrees }, longitudeIndex, latitudeIndex), + }; +} + +export function graticuleSectorBounds(address, stepDegrees) { + const step = Math.max(EPSILON, finite(stepDegrees, 1)); + return { + west: Math.max(-180, address.longitudeIndex * step), + east: Math.min(180, (address.longitudeIndex + 1) * step), + south: Math.max(-90, address.latitudeIndex * step), + north: Math.min(90, (address.latitudeIndex + 1) * step), + }; +} + +export function splitLongitudeRange(west, east) { + const rawWest = finite(west, -180); + const rawEast = finite(east, 180); + if (Math.abs(rawEast - rawWest) >= 360 - EPSILON) return [{ west: -180, east: 180 }]; + const normalizedWest = normalizeLongitudeDegrees(rawWest); + const normalizedEast = normalizeLongitudeDegrees(rawEast); + if (Math.abs(normalizedWest - normalizedEast) < EPSILON && Math.abs(rawEast - rawWest) > EPSILON) { + return [{ west: -180, east: 180 }]; + } + if (normalizedWest <= normalizedEast) return [{ west: normalizedWest, east: normalizedEast }]; + return [ + { west: normalizedWest, east: 180 }, + { west: -180, east: normalizedEast }, + ]; +} + +export function alignedGridValues(minimum, maximum, step, options = {}) { + const safeStep = Math.max(EPSILON, finite(step, 1)); + const min = finite(minimum, 0); + const max = finite(maximum, min); + const includeMaximum = options.includeMaximum !== false; + const first = Math.ceil((min - EPSILON) / safeStep); + const last = includeMaximum + ? Math.floor((max + EPSILON) / safeStep) + : Math.ceil((max - EPSILON) / safeStep) - 1; + const values = []; + for (let index = first; index <= last; index += 1) values.push(canonicalZero(index * safeStep)); + return values; +} + +export function boundedAngularParts(start, end, maximumSpanDegrees = 90) { + const minimum = finite(start, 0); + const maximum = finite(end, minimum); + const maximumSpan = clamp(finite(maximumSpanDegrees, 90), 0.1, 90); + if (maximum <= minimum) return []; + const parts = []; + for (let partStart = minimum; partStart < maximum; partStart += maximumSpan) { + parts.push({ start: partStart, end: Math.min(maximum, partStart + maximumSpan) }); + } + return parts; +} + +export function graticuleGranularity(stepDegrees, clampToGround) { + const radians = clamp(finite(stepDegrees, 1), 0.1, 180) * Math.PI / 180; + // Cesium's PolylineGeometry consumes angular granularity, while + // GroundPolylineGeometry consumes a surface distance in metres. + return clampToGround ? radians * WGS84_EQUATORIAL_RADIUS_METERS : radians; +} + +export function graticuleLinePlan({ south, north, longitudeIntervals, stepDegrees }) { + const step = clamp(finite(stepDegrees, 1), 0.1, 180); + const safeSouth = clamp(finite(south, -89.9), -89.9, 89.9); + const safeNorth = clamp(finite(north, 89.9), -89.9, 89.9); + const intervals = longitudeIntervals?.length + ? longitudeIntervals + : [{ west: -180, east: 180 }]; + const parallels = alignedGridValues(safeSouth, safeNorth, step) + .filter((latitude) => latitude > -90 + EPSILON && latitude < 90 - EPSILON); + const meridians = []; + const seen = new Set(); + for (const interval of intervals) { + for (const longitude of alignedGridValues(interval.west, interval.east, step, { includeMaximum: interval.east < 180 })) { + const normalized = normalizeLongitudeDegrees(longitude); + const key = decimalToken(normalized, 9); + if (seen.has(key)) continue; + seen.add(key); + meridians.push({ longitude: normalized, interval }); + } + } + return { stepDegrees: step, south: safeSouth, north: safeNorth, longitudeIntervals: intervals, parallels, meridians }; +} diff --git a/apps/catalog/src/styles.css b/apps/catalog/src/styles.css index 084f3f8..c323bf5 100644 --- a/apps/catalog/src/styles.css +++ b/apps/catalog/src/styles.css @@ -958,6 +958,31 @@ textarea { white-space: nowrap; } +.catalog-map-grid-lod-tabs { + width: 100%; + min-width: 0; +} + +.catalog-map-grid-lod-tabs .nodedc-segmented { + display: flex; + width: 100%; + min-width: 0; +} + +.catalog-map-grid-lod-tabs .nodedc-segmented__item { + flex: 1 1 0; + min-width: 0; + padding-inline: 0.45rem; + font-size: var(--nodedc-font-size-xs); + white-space: nowrap; +} + +.catalog-map-grid-sector-id { + min-width: 0; + overflow-wrap: anywhere; + text-align: right; +} + .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 883e62c..1409d9f 100644 --- a/docs/FOUNDRY_MAP_CESIUM_CANON.md +++ b/docs/FOUNDRY_MAP_CESIUM_CANON.md @@ -39,6 +39,8 @@ - Foundry session/auth boundary: `server/nodedc-auth.mjs`; - UI TileCache и health state: `apps/catalog/src/MapFixturePreview.tsx`; - Cesium adapter и provider startup: `apps/catalog/src/CesiumMapRenderer.tsx`; +- LOD policy и canonical M-MAP defaults: `apps/catalog/src/mapGridPolicy.mjs`; +- чистая адресация геосекторов: `apps/catalog/src/mapSectorGrid.mjs`; - Platform provider/cache boundary: `../../platform/services/map-gateway/src/server.mjs`; - Map Gateway runtime topology: `../../platform/infra/synology/docker-compose.platform-http.yml`; - NAS egress service: `../../platform/services/dc-amd-proxy/server.mjs`; @@ -160,6 +162,34 @@ Per-entity `PolygonGraphics` для массового слоя геозон н цвета обновляет per-instance attributes без перестройки геометрии; изменение толщины границы или cursor создаёт новое bounded batch generation. +### 4.4 Секторальная геосетка + +Сетка принадлежит Map Page и остаётся provider-neutral. Cesium adapter только +материализует рассчитанные линии и выполняет аналитический pick сектора. +Положение камеры никогда не становится origin или фазой сетки. + +- один фиксированный WGS84 origin хранится в layout (`55.7558`, `37.6173` по + умолчанию); старый `camera` mode при чтении мигрирует в `fixed`; +- LOD 1–3 используют метрические индексы ENU от origin, абсолютную WGS84 + высоту и `clampToGround=false`; terrain и 3D Tiles не меняют границы; +- LOD 4–5 используют отдельный угловой шаг WGS84 с глобальной нулевой фазой; + `stepKm` не участвует в гратикуле; +- M-MAP thresholds: `10 / 50 / 200 / 800 / 3000 км`; последний LOD остаётся + активным до независимого auto-off `10000 км`; +- режимы по умолчанию: `3D / 3D / 3D / graticule / graticule`; +- параллели строятся как линии постоянной широты (`RHUMB` с явной выборкой), + а viewport через антимеридиан разбивается на два диапазона; +- sector id включает origin, LOD, шаг и signed integer address. Он не содержит + render serial, viewport или camera state и сохраняется при pan/zoom/reload; +- camera может менять LOD, view-cone и набор видимых линий, но не координаты, + границы или идентификаторы секторов; +- новый buffer подключается до удаления предыдущего. Дешёвый plan key + сравнивается до создания Cesium objects; markers имеют bounded stride. + +Изменение origin или шага означает новую sector definition и закономерно +меняет идентификаторы. Сохранённые sector ids нельзя молча интерпретировать с +другим layout profile. + ## 5. Credential model ### 5.1 Cesium Ion master token diff --git a/package.json b/package.json index d538047..45ac1a4 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", + "test:map-grid-lod": "node --test scripts/map-grid-lod.test.mjs scripts/map-sector-grid.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 44e0f64..f2857a3 100644 --- a/runtime-seed/page-layouts/map.json +++ b/runtime-seed/page-layouts/map.json @@ -35,12 +35,13 @@ "gridLodEnabled": true, "grid3dEnabled": true, "gridGraticuleEnabled": true, - "gridCenterMode": "camera", + "gridCenterMode": "fixed", "gridCenterLatitude": 55.7558, "gridCenterLongitude": 37.6173, "gridTileSizeKm": 10, "gridAutoDisableHeightKm": 10000, "gridRebuildOnMoveEnd": true, + "gridLegacyMode": false, "gridMax3dViewAngleDegrees": 30, "gridHeightMeters": 500, "gridLod1MaxHeightKm": 10, @@ -49,29 +50,37 @@ "gridLod2MaxHeightKm": 50, "gridLod2StepKm": 5, "gridLod2Mode": "3d", - "gridLod3MaxHeightKm": 180, + "gridLod3MaxHeightKm": 200, "gridLod3StepKm": 25, "gridLod3Mode": "3d", - "gridLod4MaxHeightKm": 700, - "gridLod4StepKm": 100, + "gridLod4MaxHeightKm": 800, + "gridLod4StepKm": 50, "gridLod4Mode": "graticule", - "gridLod5StepKm": 500, + "gridLod5MaxHeightKm": 3000, + "gridLod5StepKm": 50, "gridLod5Mode": "graticule", - "gridRadiusKm": 1000, - "gridLineWidth": 4, - "gridLineDiameterMeters": 10, - "gridColor": "#f5f5f5", + "gridRadiusKm": 40, + "gridLineWidth": 1, + "gridLineDiameterMeters": 7, + "gridColor": "#9c9c9c", "gridOpacity": 12, "gridDotsEnabled": true, "gridDotsSize": 7, - "gridDotsDiameterMeters": 80, - "gridDotsColor": "#ffffff", + "gridDotsDiameterMeters": 10, + "gridDotsColor": "#9c9c9c", "gridDotsOpacity": 58, "gridCrossesEnabled": false, - "gridCrossesLengthMeters": 200, + "gridCrossesLengthMeters": 60, "gridCrossesWidthMeters": 10, - "gridCrossesColor": "#35cfff", - "gridCrossesOpacity": 50 + "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 } + ] }, "mapHeight": 620, "camera": { diff --git a/scripts/map-grid-lod.test.mjs b/scripts/map-grid-lod.test.mjs index 003101e..86f5ffa 100644 --- a/scripts/map-grid-lod.test.mjs +++ b/scripts/map-grid-lod.test.mjs @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; import { + DEFAULT_GRID_LOD_PROFILES, + gridLodProfile, gridShouldBeVisible, resolveGridMode, selectGridLod, @@ -11,68 +13,259 @@ import { const settings = { gridVisible: true, gridLodEnabled: true, - gridLod1MaxHeightKm: 10, - gridLod1StepKm: 1, - gridLod1Mode: "3d", - gridLod2MaxHeightKm: 50, - gridLod2StepKm: 5, - gridLod2Mode: "3d", - gridLod3MaxHeightKm: 180, - gridLod3StepKm: 25, - gridLod3Mode: "3d", - gridLod4MaxHeightKm: 700, - gridLod4StepKm: 100, - gridLod4Mode: "graticule", - gridLod5StepKm: 500, - gridLod5Mode: "graticule", grid3dEnabled: true, gridGraticuleEnabled: true, - gridMax3dViewAngleDegrees: 30, gridAutoDisableHeightKm: 10_000, - gridCenterMode: "camera", - gridTileSizeKm: 10, + gridCenterMode: "fixed", + gridCenterLatitude: 55.7558, + gridCenterLongitude: 37.6173, + gridLodProfiles: DEFAULT_GRID_LOD_PROFILES, }; -test("five grid LODs preserve the close 3D and distant graticule contract", () => { +test("canonical defaults preserve the exact effective MMAP/MOSCOWMAP five-LOD donor profile", () => { + assert.deepEqual(DEFAULT_GRID_LOD_PROFILES, [ + { + 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: 1_000, + 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: 1_000, + 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: 1_000, + 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: 3_000, + 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, + }, + ]); +}); + +test("LOD selection keeps three spatial bands, two graticule bands and the independent 10,000 km cutoff", () => { assert.deepEqual( - [5, 40, 120, 500, 2_000].map((height) => { + [0, 10, 10.001, 50, 50.001, 200, 200.001, 800, 800.001, 3_000, 9_999].map((height) => { const band = selectGridLod(settings, height); return [band.id, band.stepKm, band.mode]; }), [ ["lod-1", 1, "3d"], + ["lod-1", 1, "3d"], + ["lod-2", 5, "3d"], ["lod-2", 5, "3d"], ["lod-3", 25, "3d"], - ["lod-4", 100, "graticule"], - ["lod-5", 500, "graticule"], + ["lod-3", 25, "3d"], + ["lod-4", 50, "graticule"], + ["lod-4", 50, "graticule"], + ["lod-5", 50, "graticule"], + ["lod-5", 50, "graticule"], + ["lod-5", 50, "graticule"], ], ); + assert.equal(gridShouldBeVisible(settings, 10_000), true); + assert.equal(gridShouldBeVisible(settings, 10_000.001), false); }); -test("LOD hysteresis holds the previous band around a threshold", () => { - assert.equal(selectGridLod(settings, 10.5, 0).id, "lod-1"); - assert.equal(selectGridLod(settings, 10.9, 0).id, "lod-2"); - assert.equal(selectGridLod(settings, 9.5, 1).id, "lod-2"); - assert.equal(selectGridLod(settings, 9.1, 1).id, "lod-1"); +test("every LOD owns independent metric and angular spacing and visual fields", () => { + const profiles = DEFAULT_GRID_LOD_PROFILES.map((profile, index) => ({ + ...profile, + stepKm: 11 + index, + lineDiameterMeters: 21 + index, + graticuleStepDegrees: 0.125 * (index + 1), + graticuleLineWidthPx: 1 + (index % 3), + graticuleColor: `#00000${index}`, + graticuleOpacity: 30 + index, + })); + const profile = gridLodProfile({ ...settings, gridLodProfiles: profiles }, 3); + assert.equal(profile.stepKm, 14); + assert.equal(profile.lineDiameterMeters, 24); + assert.equal(profile.graticuleStepDegrees, 0.5); + assert.equal(profile.graticuleLineWidthPx, 1); + assert.equal(profile.graticuleColor, "#000003"); + assert.equal(profile.graticuleOpacity, 33); }); -test("oblique close view falls back to the graticule without dropping the grid", () => { - assert.equal(resolveGridMode(settings, "3d", 12), "3d"); - assert.equal(resolveGridMode(settings, "3d", 48), "graticule"); - assert.equal(resolveGridMode({ ...settings, gridGraticuleEnabled: false }, "3d", 48), "hidden"); +test("legacy flat visuals migrate while angular graticule spacing stays independent", () => { + const profile = gridLodProfile({ + ...settings, + gridLodProfiles: undefined, + gridRadiusKm: 1_234, + gridHeightMeters: 640, + gridLineDiameterMeters: 17, + gridColor: "#abcdef", + gridOpacity: 37, + gridDotsEnabled: false, + gridLod2StepKm: 7, + }, 1); + assert.equal(profile.maxHeightKm, 50); + assert.equal(profile.stepKm, 7); + assert.equal(profile.radiusKm, 1_234); + assert.equal(profile.heightMeters, 640); + assert.equal(profile.lineDiameterMeters, 17); + assert.equal(profile.lineColor, "#abcdef"); + assert.equal(profile.lineOpacity, 37); + assert.equal(profile.dotsEnabled, false); + assert.equal(profile.graticuleStepDegrees, 0.5); + assert.equal(profile.graticuleLineWidthPx, 1); + assert.equal(profile.graticuleColor, "#abcdef"); + assert.equal(profile.graticuleOpacity, 37); }); -test("grid auto-disable and stable camera tile center are deterministic", () => { - assert.equal(gridShouldBeVisible(settings, 9_999), true); - assert.equal(gridShouldBeVisible(settings, 10_001), false); +test("LOD hysteresis holds the previous band for eight percent on either side of a threshold", () => { + assert.equal(selectGridLod(settings, 10.8, 0).id, "lod-1"); + assert.equal(selectGridLod(settings, 10.8001, 0).id, "lod-2"); + assert.equal(selectGridLod(settings, 9.2001, 1).id, "lod-2"); + assert.equal(selectGridLod(settings, 9.1999, 1).id, "lod-1"); + assert.equal(selectGridLod(settings, 864, 3).id, "lod-4"); + assert.equal(selectGridLod(settings, 864.001, 3).id, "lod-5"); +}); + +test("mode fallback preserves a usable grid when one renderer is disabled", () => { + assert.equal(resolveGridMode(settings, "3d"), "3d"); + assert.equal(resolveGridMode(settings, "graticule"), "graticule"); + assert.equal(resolveGridMode({ ...settings, grid3dEnabled: false }, "3d"), "graticule"); + assert.equal(resolveGridMode({ ...settings, gridGraticuleEnabled: false }, "graticule"), "3d"); + assert.equal(resolveGridMode({ ...settings, grid3dEnabled: false, gridGraticuleEnabled: false }, "3d"), "hidden"); +}); + +test("grid center is fixed and camera-independent, including for legacy camera-mode payloads", () => { const first = snapGridCenter({ latitude: 55.7558, longitude: 37.6173 }, settings); - const second = snapGridCenter({ latitude: 55.76, longitude: 37.62 }, settings); - assert.deepEqual(first, second); + const second = snapGridCenter({ latitude: -22, longitude: -140 }, settings); + assert.deepEqual(first, { latitude: 55.7558, longitude: 37.6173 }); + assert.deepEqual(second, first); + assert.deepEqual(snapGridCenter({ latitude: 0, longitude: 0 }, { + ...settings, + gridCenterMode: "camera", + gridCenterLatitude: 12.5, + gridCenterLongitude: 190, + }), { latitude: 12.5, longitude: -170 }); }); -test("renderer swaps double-buffered data sources and never clears the live grid first", async () => { +test("renderer uses fixed ENU sectors, angular graticules and a non-blank double-buffer swap", async () => { const source = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"); assert.match(source, /class GridLayerController/); assert.match(source, /dataSourceDisplay\.ready/); + assert.match(source, /fixedGridOrigin\(presentation\)/); + assert.match(source, /Transforms\.eastNorthUpToFixedFrame\(anchor\)/); + assert.match(source, /localSectorAt\(/); + assert.match(source, /gridController\?\.pick\(worldPosition\)/); + assert.match(source, /lod\.stepKm \* 1_000/); + assert.match(source, /lod\.graticuleStepDegrees/); + assert.match(source, /lod\.graticuleLineWidthPx/); + assert.match(source, /arcType: ArcType\.RHUMB/); + assert.match(source, /clampToGround: false/); + assert.match(source, /mountResources\(resources\)[\s\S]*?const previous = this\.current;[\s\S]*?removeResources\(previous\.resources\)/); assert.doesNotMatch(source, /function rebuildElevatedGrid[\s\S]*?entities\.removeAll\(\)/); + assert.doesNotMatch(source, /Math\.min\(40,[\s\S]*?safeRadiusKm \/ safeStepKm/); + assert.doesNotMatch(source, /snapGridCenter\(/); }); diff --git a/scripts/map-object-layers.test.mjs b/scripts/map-object-layers.test.mjs index 6bddaf7..c9ce63e 100644 --- a/scripts/map-object-layers.test.mjs +++ b/scripts/map-object-layers.test.mjs @@ -70,10 +70,7 @@ test("facet tree selection focuses the subject without resetting a compatible de assert.match(preview, /setSubjectCardTabId\(\(current\) =>/); assert.match(preview, /profile\?\.tabs\.some\(\(tab\) => tab\.id === current\)/); assert.match(preview, /\(profile\?\.defaultTabId \?\? "overview"\)/); - assert.match(renderer, /camera\.pickEllipsoid/); - assert.match(renderer, /Cartesian3\.subtract\(camera\.position, viewportCenter/); - assert.match(renderer, /heading: camera\.heading/); - assert.match(renderer, /pitch: camera\.pitch/); - assert.match(renderer, /roll: camera\.roll/); + assert.match(renderer, /void viewer\.flyTo\(entity, \{/); assert.match(renderer, /duration: 0\.45/); + assert.match(renderer, /offset: new HeadingPitchRange\(0, -0\.9, 8_000\)/); }); diff --git a/scripts/map-sector-grid.test.mjs b/scripts/map-sector-grid.test.mjs new file mode 100644 index 0000000..a6282bd --- /dev/null +++ b/scripts/map-sector-grid.test.mjs @@ -0,0 +1,231 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + ArcType, + ApproximateTerrainHeights, + Cartesian3, + GroundPolylineGeometry, + Math as CesiumMath, + PolylineGeometry, +} from "cesium"; +import { + alignedGridValues, + boundedAngularParts, + fixedGridOrigin, + graticuleGranularity, + graticuleLinePlan, + graticuleSectorAt, + graticuleSectorBounds, + localGridPlan, + MAX_LOCAL_GRID_INDEX, + localParentSector, + localSectorAt, + localSectorBounds, + localSectorNeighbors, + normalizeLongitudeDegrees, + splitLongitudeRange, +} from "../apps/catalog/src/mapSectorGrid.mjs"; + +const localDefinition = { + lod: 1, + originLatitude: 55.7558, + originLongitude: 37.6173, + stepMeters: 1_000, +}; + +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 }), { + latitude: 89.9, + longitude: -180, + }); + assert.equal(normalizeLongitudeDegrees(-540), -180); + assert.equal(normalizeLongitudeDegrees(360), 0); + assert.equal(Object.is(normalizeLongitudeDegrees(-360), -0), false); +}); + +test("local sectors use half-open floor boundaries on both sides of the ENU origin", () => { + assert.deepEqual( + [ + [0, 0], + [999.999, 999.999], + [1_000, 1_000], + [-0.001, -0.001], + [-1_000, -1_000], + [-1_000.001, -1_000.001], + ].map(([eastMeters, northMeters]) => { + const sector = localSectorAt({ eastMeters, northMeters }, localDefinition); + return [sector.eastIndex, sector.northIndex]; + }), + [ + [0, 0], + [0, 0], + [1, 1], + [-1, -1], + [-1, -1], + [-2, -2], + ], + ); +}); + +test("local sector IDs and bounds stay stable across calls and normalized equivalent origins", () => { + const first = localSectorAt({ eastMeters: -1, northMeters: 2_500 }, localDefinition); + const second = localSectorAt({ eastMeters: -1, northMeters: 2_500 }, { + ...localDefinition, + originLongitude: localDefinition.originLongitude + 360, + }); + assert.equal(first.id, "grid/local/55.755800,37.617300/l1/s1000.000/e-1/n+2"); + assert.equal(second.id, first.id); + assert.deepEqual(localSectorBounds(first, localDefinition.stepMeters), { + west: -1_000, + east: 0, + south: 2_000, + north: 3_000, + }); +}); + +test("local sector neighbors and integer-ratio parents preserve signed addressing", () => { + const child = localSectorAt({ eastMeters: -1, northMeters: 2_500 }, localDefinition); + const neighbors = localSectorNeighbors(child, localDefinition); + assert.deepEqual( + Object.fromEntries(Object.entries(neighbors).map(([direction, address]) => [ + direction, + [address.eastIndex, address.northIndex], + ])), + { + north: [-1, 3], + east: [0, 2], + south: [-1, 1], + west: [-2, 2], + }, + ); + const parent = localParentSector(child, 1_000, { ...localDefinition, lod: 2, stepMeters: 5_000 }); + assert.deepEqual([parent.eastIndex, parent.northIndex], [-1, 0]); + assert.equal(parent.id, "grid/local/55.755800,37.617300/l2/s5000.000/e-1/n+0"); + assert.throws( + () => localParentSector(child, 1_000, { ...localDefinition, lod: 2, stepMeters: 2_500 }), + /grid_parent_step_must_be_integer_multiple/, + ); +}); + +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]), [ + [-2, -2_000], + [-1, -1_000], + [0, 0], + [1, 1_000], + [2, 2_000], + ]); + assert.equal(plan.lines[0].extentMeters, 0); + assert.equal(plan.lines[2].extentMeters, 2_000); + assert.equal(plan.markerStride, 2); + const bounded = localGridPlan({ stepMeters: 100, radiusMeters: 100_000_000 }); + assert.equal(bounded.maximumIndex, MAX_LOCAL_GRID_INDEX); + assert.equal(bounded.clipped, true); + assert.ok(bounded.lines.length <= MAX_LOCAL_GRID_INDEX * 2 + 1); +}); + +test("bounded RHUMB parts compile through Cesium at the equator, poles and date line", () => { + const compile = (positions) => PolylineGeometry.createGeometry(new PolylineGeometry({ + positions, + width: 1, + arcType: ArcType.RHUMB, + granularity: CesiumMath.toRadians(2), + })); + for (const part of boundedAngularParts(-180, 180)) { + assert.ok(compile([ + Cartesian3.fromDegrees(part.start, 0, 500), + Cartesian3.fromDegrees(part.end, 0, 500), + ])); + } + for (const part of boundedAngularParts(-89.9, 89.9)) { + assert.ok(compile([ + Cartesian3.fromDegrees(180, part.start, 500), + Cartesian3.fromDegrees(180, part.end, 500), + ])); + } +}); + +test("ground graticule uses metre granularity and compiles without explosive subdivision", () => { + const stepDegrees = 2; + const granularity = graticuleGranularity(stepDegrees, true); + assert.ok(granularity > 200_000 && granularity < 225_000); + assert.equal(graticuleGranularity(stepDegrees, false), CesiumMath.toRadians(stepDegrees)); + + const positions = boundedAngularParts(-180, 180) + .reduce((values, part, index) => [ + ...values, + ...(index === 0 ? [part.start] : []), + part.end, + ], []) + .map((longitude) => Cartesian3.fromDegrees(longitude, 0)); + const previousTerrainHeights = ApproximateTerrainHeights._terrainHeights; + try { + ApproximateTerrainHeights._terrainHeights = {}; + const source = new GroundPolylineGeometry({ + positions, + width: 1, + arcType: ArcType.RHUMB, + granularity, + }); + assert.equal(source.granularity, granularity); + assert.ok(GroundPolylineGeometry.createGeometry(source)); + } finally { + ApproximateTerrainHeights._terrainHeights = previousTerrainHeights; + } +}); + +test("anti-meridian ranges split explicitly while whole-world ranges keep one interval", () => { + assert.deepEqual(splitLongitudeRange(170, -170), [ + { west: 170, east: 180 }, + { west: -180, east: -170 }, + ]); + assert.deepEqual(splitLongitudeRange(-170, 170), [{ west: -170, east: 170 }]); + assert.deepEqual(splitLongitudeRange(-180, 180), [{ west: -180, east: 180 }]); + assert.deepEqual(splitLongitudeRange(10, 370), [{ west: -180, east: 180 }]); +}); + +test("graticule lines retain a global zero phase and never duplicate the date-line meridian", () => { + const longitudeIntervals = splitLongitudeRange(170, -170); + const plan = graticuleLinePlan({ + south: -3.7, + north: 3.7, + longitudeIntervals, + stepDegrees: 2, + }); + assert.deepEqual(plan.parallels, [-2, 0, 2]); + assert.deepEqual(plan.meridians.map(({ longitude }) => longitude), [ + 170, 172, 174, 176, 178, + -180, -178, -176, -174, -172, -170, + ]); + assert.equal(new Set(plan.meridians.map(({ longitude }) => longitude)).size, plan.meridians.length); + assert.deepEqual(alignedGridValues(1, 7, 2), [2, 4, 6]); + + const world = graticuleLinePlan({ + south: -2, + north: 2, + longitudeIntervals: [{ west: -180, east: 180 }], + stepDegrees: 2, + }); + assert.equal(world.meridians.some(({ longitude }) => longitude === 180), false); + assert.equal(world.meridians.filter(({ longitude }) => longitude === -180).length, 1); +}); + +test("graticule sector IDs, negative boundaries and bounds use the same global phase", () => { + const definition = { lod: 4, stepDegrees: 2 }; + const negative = graticuleSectorAt({ longitude: -0.0001, latitude: -0.0001 }, definition); + const zero = graticuleSectorAt({ longitude: 0, latitude: 0 }, definition); + const seamWest = graticuleSectorAt({ longitude: -180, latitude: 0 }, definition); + const seamEast = graticuleSectorAt({ longitude: 180, latitude: 0 }, definition); + assert.deepEqual([negative.longitudeIndex, negative.latitudeIndex], [-1, -1]); + assert.equal(negative.id, "grid/wgs84/l4/s2.000000/x-1/y-1"); + assert.equal(zero.id, "grid/wgs84/l4/s2.000000/x+0/y+0"); + assert.deepEqual(graticuleSectorBounds(negative, definition.stepDegrees), { + west: -2, + east: 0, + south: -2, + north: 0, + }); + assert.equal(seamEast.id, seamWest.id); +}); diff --git a/server/catalog-server.mjs b/server/catalog-server.mjs index 40c14f4..41e14c8 100644 --- a/server/catalog-server.mjs +++ b/server/catalog-server.mjs @@ -520,21 +520,183 @@ const MAP_PAGE_SETTING_KEYS = new Set([ "buildingsOpacity", "buildingsDetail", "imageryBrightness", "imageryContrast", "imagerySaturation", "gridVisible", "gridLodEnabled", "gridHeightMeters", "grid3dEnabled", "gridGraticuleEnabled", "gridCenterMode", "gridCenterLatitude", - "gridCenterLongitude", "gridTileSizeKm", "gridAutoDisableHeightKm", "gridRebuildOnMoveEnd", + "gridCenterLongitude", "gridTileSizeKm", "gridAutoDisableHeightKm", "gridRebuildOnMoveEnd", "gridLegacyMode", "gridMax3dViewAngleDegrees", "gridLod1MaxHeightKm", "gridLod1StepKm", "gridLod2MaxHeightKm", "gridLod2StepKm", "gridLod3MaxHeightKm", "gridLod3StepKm", "gridLod4MaxHeightKm", "gridLod4StepKm", - "gridLod5StepKm", "gridLod1Mode", "gridLod2Mode", "gridLod3Mode", "gridLod4Mode", + "gridLod5MaxHeightKm", "gridLod5StepKm", "gridLod1Mode", "gridLod2Mode", "gridLod3Mode", "gridLod4Mode", "gridLod5Mode", "gridRadiusKm", "gridLineWidth", "gridLineDiameterMeters", "gridColor", "gridOpacity", "gridDotsEnabled", "gridDotsSize", "gridDotsDiameterMeters", "gridDotsColor", "gridDotsOpacity", "gridCrossesEnabled", "gridCrossesLengthMeters", "gridCrossesWidthMeters", - "gridCrossesColor", "gridCrossesOpacity", + "gridCrossesColor", "gridCrossesOpacity", "gridLodProfiles", ]); +const GRID_LOD_PROFILE_KEYS = new Set([ + "maxHeightKm", "stepKm", "mode", "heightMeters", "max3dViewAngleDegrees", "tileSizeKm", "radiusKm", + "lineDiameterMeters", "lineColor", "lineOpacity", "dotsEnabled", "dotsDiameterMeters", + "dotsColor", "dotsOpacity", "crossesEnabled", "crossesLengthMeters", "crossesWidthMeters", + "crossesColor", "crossesOpacity", "graticuleStepDegrees", "graticuleLineWidthPx", "graticuleColor", + "graticuleOpacity", +]); + +const GRID_LOD_PROFILE_INPUT_KEYS = new Set([...GRID_LOD_PROFILE_KEYS, "lineWidthPx"]); +const MAX_LOCAL_GRID_INDEX = 512; +const LEGACY_GRID_PROFILE_SETTING_KEYS = new Set([ + "gridHeightMeters", "gridTileSizeKm", "gridMax3dViewAngleDegrees", "gridRadiusKm", + "gridLineWidth", "gridLineDiameterMeters", "gridColor", "gridOpacity", + "gridDotsEnabled", "gridDotsSize", "gridDotsDiameterMeters", "gridDotsColor", "gridDotsOpacity", + "gridCrossesEnabled", "gridCrossesLengthMeters", "gridCrossesWidthMeters", "gridCrossesColor", "gridCrossesOpacity", + ...Array.from({ length: 5 }, (_unused, index) => index + 1).flatMap((number) => [ + `gridLod${number}MaxHeightKm`, `gridLod${number}StepKm`, `gridLod${number}Mode`, + ]), +]); + +// Exact effective values of the tracked Engine MMAP/MOSCOWMAP Cesium grid. +// These are persisted by Foundry as presentation policy; the renderer remains +// a provider adapter and does not own or silently retune the sector definition. +const DEFAULT_GRID_LOD_PROFILES = Object.freeze([ + Object.freeze({ + 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, + }), + Object.freeze({ + maxHeightKm: 50, stepKm: 5, mode: "3d", heightMeters: 500, max3dViewAngleDegrees: 30, + tileSizeKm: 10, radiusKm: 1_000, 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, + }), + Object.freeze({ + maxHeightKm: 200, stepKm: 25, mode: "3d", heightMeters: 500, max3dViewAngleDegrees: 30, + tileSizeKm: 25, radiusKm: 1_000, 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, + }), + Object.freeze({ + maxHeightKm: 800, stepKm: 50, mode: "graticule", heightMeters: 500, max3dViewAngleDegrees: 30, + tileSizeKm: 10, radiusKm: 1_000, 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, + }), + Object.freeze({ + maxHeightKm: 3_000, 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, + }), +]); + +function promoteLegacyGridLodProfiles(settings) { + return DEFAULT_GRID_LOD_PROFILES.map((fallback, index) => { + const number = index + 1; + return { + ...fallback, + maxHeightKm: settings[`gridLod${number}MaxHeightKm`] ?? fallback.maxHeightKm, + stepKm: settings[`gridLod${number}StepKm`] ?? fallback.stepKm, + mode: settings[`gridLod${number}Mode`] ?? fallback.mode, + heightMeters: settings.gridHeightMeters ?? fallback.heightMeters, + max3dViewAngleDegrees: settings.gridMax3dViewAngleDegrees ?? fallback.max3dViewAngleDegrees, + tileSizeKm: settings.gridTileSizeKm ?? fallback.tileSizeKm, + radiusKm: settings.gridRadiusKm ?? fallback.radiusKm, + lineDiameterMeters: settings.gridLineDiameterMeters ?? fallback.lineDiameterMeters, + lineColor: settings.gridColor ?? fallback.lineColor, + lineOpacity: settings.gridOpacity ?? fallback.lineOpacity, + dotsEnabled: settings.gridDotsEnabled ?? fallback.dotsEnabled, + dotsDiameterMeters: settings.gridDotsDiameterMeters ?? fallback.dotsDiameterMeters, + dotsColor: settings.gridDotsColor ?? fallback.dotsColor, + dotsOpacity: settings.gridDotsOpacity ?? fallback.dotsOpacity, + crossesEnabled: settings.gridCrossesEnabled ?? fallback.crossesEnabled, + crossesLengthMeters: settings.gridCrossesLengthMeters ?? fallback.crossesLengthMeters, + crossesWidthMeters: settings.gridCrossesWidthMeters ?? fallback.crossesWidthMeters, + crossesColor: settings.gridCrossesColor ?? fallback.crossesColor, + crossesOpacity: settings.gridCrossesOpacity ?? fallback.crossesOpacity, + graticuleLineWidthPx: settings.gridLineWidth ?? fallback.graticuleLineWidthPx, + graticuleColor: settings.gridColor ?? fallback.graticuleColor, + graticuleOpacity: settings.gridOpacity ?? fallback.graticuleOpacity, + }; + }); +} + +function validateGridLodProfiles(value) { + if (!Array.isArray(value) || value.length !== 5) throw applicationError("invalid_map_grid_lod_profiles"); + let previousMaxHeightKm = Number.NEGATIVE_INFINITY; + return value.map((profile, index) => { + if (!isObject(profile) || Object.keys(profile).some((key) => !GRID_LOD_PROFILE_INPUT_KEYS.has(key))) { + throw applicationError(`invalid_map_grid_lod_profile_${index + 1}`); + } + const legacyProfile = Object.hasOwn(profile, "lineWidthPx"); + const normalized = legacyProfile ? { + ...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 }; + delete normalized.lineWidthPx; + if (GRID_LOD_PROFILE_KEYS.size !== Object.keys(normalized).length + || [...GRID_LOD_PROFILE_KEYS].some((key) => !Object.hasOwn(normalized, key))) { + throw applicationError(`invalid_map_grid_lod_profile_${index + 1}`); + } + const code = (key) => `invalid_map_grid_lod_profile_${index + 1}_${key}`; + const maxHeightKm = requireNumber(normalized.maxHeightKm, 0.1, 100_000, code("maxHeightKm")); + if (maxHeightKm <= previousMaxHeightKm) { + throw applicationError(`invalid_map_grid_lod_profile_${index + 1}_maxHeightKm_order`); + } + previousMaxHeightKm = maxHeightKm; + if (!['3d', 'graticule'].includes(normalized.mode)) throw applicationError(code("mode")); + const stepKm = requireNumber(normalized.stepKm, 0.1, 5_000, code("stepKm")); + 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`); + } + 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")), + radiusKm, + lineDiameterMeters: requireNumber(normalized.lineDiameterMeters, 1, 100, code("lineDiameterMeters")), + lineColor: requireHex(normalized.lineColor, code("lineColor")), + lineOpacity: requireNumber(normalized.lineOpacity, 0, 100, code("lineOpacity")), + dotsEnabled: requireBoolean(normalized.dotsEnabled, code("dotsEnabled")), + dotsDiameterMeters: requireNumber(normalized.dotsDiameterMeters, 1, 1_000, code("dotsDiameterMeters")), + dotsColor: requireHex(normalized.dotsColor, code("dotsColor")), + dotsOpacity: requireNumber(normalized.dotsOpacity, 0, 100, code("dotsOpacity")), + crossesEnabled: requireBoolean(normalized.crossesEnabled, code("crossesEnabled")), + crossesLengthMeters: requireNumber(normalized.crossesLengthMeters, 1, 5_000, code("crossesLengthMeters")), + 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")), + graticuleLineWidthPx: requireNumber(normalized.graticuleLineWidthPx, 1, 3, code("graticuleLineWidthPx")), + graticuleColor: requireHex(normalized.graticuleColor, code("graticuleColor")), + graticuleOpacity: requireNumber(normalized.graticuleOpacity, 0, 100, code("graticuleOpacity")), + }; + }); +} + function validateMapPageSettingsPatch(value) { if (!isObject(value) || Object.keys(value).length === 0) throw applicationError("invalid_map_page_settings_patch"); if (Object.keys(value).some((key) => !MAP_PAGE_SETTING_KEYS.has(key))) throw applicationError("invalid_map_page_settings_patch_field"); - return value; + if (value.gridCenterMode !== undefined && !["camera", "fixed"].includes(value.gridCenterMode)) { + throw applicationError("invalid_map_page_setting_gridCenterMode"); + } + if (value.gridLodProfiles === undefined && Object.keys(value).some((key) => LEGACY_GRID_PROFILE_SETTING_KEYS.has(key))) { + throw applicationError("map_grid_lod_profiles_required"); + } + return { + ...value, + ...(value.gridCenterMode !== undefined ? { gridCenterMode: "fixed" } : {}), + ...(value.gridLodProfiles !== undefined ? { gridLodProfiles: validateGridLodProfiles(value.gridLodProfiles) } : {}), + }; } function validateMapInspectorOpenSections(value) { @@ -565,7 +727,7 @@ function validateMapPageLayout(value) { // Layouts written before this field existed are safely upgraded to the // no-overwrite default during read/save; they are not rejected as damaged. if (settings.cacheNoOverwrite !== undefined) requireBoolean(settings.cacheNoOverwrite, "invalid_map_page_setting_cacheNoOverwrite"); - for (const key of ["grid3dEnabled", "gridGraticuleEnabled", "gridRebuildOnMoveEnd", "gridCrossesEnabled"]) { + for (const key of ["grid3dEnabled", "gridGraticuleEnabled", "gridRebuildOnMoveEnd", "gridLegacyMode", "gridCrossesEnabled"]) { if (settings[key] !== undefined) requireBoolean(settings[key], `invalid_map_page_setting_${key}`); } requireString(settings.imagerySource, "invalid_map_page_imagery_source", 64); @@ -575,9 +737,11 @@ function validateMapPageLayout(value) { for (const key of ["monochromeColor", "globeColor", "backgroundColor", "buildingsColor", "gridColor", "gridDotsColor"]) { requireHex(settings[key], `invalid_map_page_setting_${key}`); } - for (const key of ["gridCenterLatitude", "gridCenterLongitude", "gridTileSizeKm", "gridAutoDisableHeightKm", "gridMax3dViewAngleDegrees", "gridLod3MaxHeightKm", "gridLod4MaxHeightKm", "gridLod4StepKm", "gridLod5StepKm", "gridLineDiameterMeters", "gridDotsDiameterMeters", "gridCrossesLengthMeters", "gridCrossesWidthMeters", "gridCrossesOpacity"]) { + for (const key of ["gridTileSizeKm", "gridAutoDisableHeightKm", "gridMax3dViewAngleDegrees", "gridLod3MaxHeightKm", "gridLod4MaxHeightKm", "gridLod4StepKm", "gridLod5MaxHeightKm", "gridLod5StepKm", "gridLineDiameterMeters", "gridDotsDiameterMeters", "gridCrossesLengthMeters", "gridCrossesWidthMeters", "gridCrossesOpacity"]) { if (settings[key] !== undefined) requireNumber(settings[key], -100000, 100000, `invalid_map_page_setting_${key}`); } + if (settings.gridCenterLatitude !== undefined) requireNumber(settings.gridCenterLatitude, -90, 90, "invalid_map_page_setting_gridCenterLatitude"); + if (settings.gridCenterLongitude !== undefined) requireNumber(settings.gridCenterLongitude, -180, 180, "invalid_map_page_setting_gridCenterLongitude"); if (settings.gridCrossesColor !== undefined) requireHex(settings.gridCrossesColor, "invalid_map_page_setting_gridCrossesColor"); if (settings.gridCenterMode !== undefined && !["camera", "fixed"].includes(settings.gridCenterMode)) { throw applicationError("invalid_map_page_setting_gridCenterMode"); @@ -587,6 +751,9 @@ function validateMapPageLayout(value) { throw applicationError(`invalid_map_page_setting_${key}`); } } + const gridLodProfiles = validateGridLodProfiles( + settings.gridLodProfiles ?? promoteLegacyGridLodProfiles(settings), + ); const camera = value.camera; for (const key of ["longitude", "latitude", "height", "heading", "pitch", "roll"]) { requireNumber(camera[key], -1_000_000_000, 1_000_000_000, `invalid_map_page_camera_${key}`); @@ -652,7 +819,7 @@ function validateMapPageLayout(value) { return { schemaVersion: 1, pageId: "map", - settings: { ...settings, cacheNoOverwrite: settings.cacheNoOverwrite ?? true }, + settings: { ...settings, cacheNoOverwrite: settings.cacheNoOverwrite ?? true, gridCenterMode: "fixed", gridLodProfiles }, mapHeight: requireInteger(value.mapHeight, 360, 5000, "invalid_map_page_height"), camera: { longitude: camera.longitude, @@ -736,12 +903,13 @@ function defaultMapPageLayout() { gridLodEnabled: true, grid3dEnabled: true, gridGraticuleEnabled: true, - gridCenterMode: "camera", + gridCenterMode: "fixed", gridCenterLatitude: 55.7558, gridCenterLongitude: 37.6173, gridTileSizeKm: 10, gridAutoDisableHeightKm: 10000, gridRebuildOnMoveEnd: true, + gridLegacyMode: false, gridMax3dViewAngleDegrees: 30, gridHeightMeters: 500, gridLod1MaxHeightKm: 10, @@ -750,29 +918,31 @@ function defaultMapPageLayout() { gridLod2MaxHeightKm: 50, gridLod2StepKm: 5, gridLod2Mode: "3d", - gridLod3MaxHeightKm: 180, + gridLod3MaxHeightKm: 200, gridLod3StepKm: 25, gridLod3Mode: "3d", - gridLod4MaxHeightKm: 700, - gridLod4StepKm: 100, + gridLod4MaxHeightKm: 800, + gridLod4StepKm: 50, gridLod4Mode: "graticule", - gridLod5StepKm: 500, + gridLod5MaxHeightKm: 3000, + gridLod5StepKm: 50, gridLod5Mode: "graticule", - gridRadiusKm: 1000, - gridLineWidth: 4, - gridLineDiameterMeters: 10, - gridColor: "#f5f5f5", + gridRadiusKm: 40, + gridLineWidth: 1, + gridLineDiameterMeters: 7, + gridColor: "#9c9c9c", gridOpacity: 12, gridDotsEnabled: true, gridDotsSize: 7, - gridDotsDiameterMeters: 80, - gridDotsColor: "#ffffff", + gridDotsDiameterMeters: 10, + gridDotsColor: "#9c9c9c", gridDotsOpacity: 58, gridCrossesEnabled: false, - gridCrossesLengthMeters: 200, + gridCrossesLengthMeters: 60, gridCrossesWidthMeters: 10, - gridCrossesColor: "#35cfff", - gridCrossesOpacity: 50, + gridCrossesColor: "#9c9c9c", + gridCrossesOpacity: 46, + gridLodProfiles: structuredClone(DEFAULT_GRID_LOD_PROFILES), }, mapHeight: 470, camera: { diff --git a/server/foundry-mcp.mjs b/server/foundry-mcp.mjs index 3ad38c9..24641ee 100644 --- a/server/foundry-mcp.mjs +++ b/server/foundry-mcp.mjs @@ -414,12 +414,13 @@ const mapPageSettingsPatchInputSchema = { gridLodEnabled: { type: "boolean" }, grid3dEnabled: { type: "boolean" }, gridGraticuleEnabled: { type: "boolean" }, - gridCenterMode: { type: "string", enum: ["camera", "fixed"] }, - gridCenterLatitude: { type: "number" }, - gridCenterLongitude: { type: "number" }, + gridCenterMode: { type: "string", enum: ["fixed"] }, + gridCenterLatitude: { type: "number", minimum: -90, maximum: 90 }, + gridCenterLongitude: { type: "number", minimum: -180, maximum: 180 }, gridTileSizeKm: { type: "number" }, gridAutoDisableHeightKm: { type: "number" }, gridRebuildOnMoveEnd: { type: "boolean" }, + gridLegacyMode: { type: "boolean" }, gridMax3dViewAngleDegrees: { type: "number" }, gridHeightMeters: { type: "number" }, gridLod1MaxHeightKm: { type: "number" }, @@ -434,6 +435,7 @@ const mapPageSettingsPatchInputSchema = { gridLod4MaxHeightKm: { type: "number" }, gridLod4StepKm: { type: "number" }, gridLod4Mode: { type: "string", enum: ["3d", "graticule"] }, + gridLod5MaxHeightKm: { type: "number" }, gridLod5StepKm: { type: "number" }, gridLod5Mode: { type: "string", enum: ["3d", "graticule"] }, gridRadiusKm: { type: "number" }, @@ -451,6 +453,41 @@ const mapPageSettingsPatchInputSchema = { gridCrossesWidthMeters: { type: "number" }, gridCrossesColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" }, gridCrossesOpacity: { type: "number" }, + gridLodProfiles: { + type: "array", + minItems: 5, + maxItems: 5, + items: { + type: "object", + additionalProperties: false, + required: ["maxHeightKm", "stepKm", "mode", "heightMeters", "max3dViewAngleDegrees", "tileSizeKm", "radiusKm", "lineDiameterMeters", "lineColor", "lineOpacity", "dotsEnabled", "dotsDiameterMeters", "dotsColor", "dotsOpacity", "crossesEnabled", "crossesLengthMeters", "crossesWidthMeters", "crossesColor", "crossesOpacity", "graticuleStepDegrees", "graticuleLineWidthPx", "graticuleColor", "graticuleOpacity"], + properties: { + maxHeightKm: { type: "number", minimum: 0.1, maximum: 100000, description: "Strictly greater than the previous LOD maxHeightKm; enforced by Foundry." }, + stepKm: { type: "number", minimum: 0.1, maximum: 5000 }, + mode: { type: "string", enum: ["3d", "graticule"] }, + heightMeters: { type: "number", minimum: 0, maximum: 5000 }, + max3dViewAngleDegrees: { type: "number", minimum: 30, maximum: 170 }, + tileSizeKm: { type: "number", minimum: 1, maximum: 50 }, + radiusKm: { type: "number", minimum: 1, maximum: 100000 }, + lineDiameterMeters: { type: "number", minimum: 1, maximum: 100 }, + lineColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" }, + lineOpacity: { type: "number", minimum: 0, maximum: 100 }, + dotsEnabled: { type: "boolean" }, + dotsDiameterMeters: { type: "number", minimum: 1, maximum: 1000 }, + dotsColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" }, + dotsOpacity: { type: "number", minimum: 0, maximum: 100 }, + crossesEnabled: { type: "boolean" }, + crossesLengthMeters: { type: "number", minimum: 1, maximum: 5000 }, + crossesWidthMeters: { type: "number", minimum: 1, maximum: 500 }, + crossesColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" }, + crossesOpacity: { type: "number", minimum: 0, maximum: 100 }, + graticuleStepDegrees: { type: "number", minimum: 0.1, maximum: 180 }, + graticuleLineWidthPx: { type: "number", minimum: 1, maximum: 3 }, + graticuleColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" }, + graticuleOpacity: { type: "number", minimum: 0, maximum: 100 }, + }, + }, + }, }, };