feat(map): add fixed ENU and WGS84 sector grid

This commit is contained in:
Codex
2026-08-06 10:31:03 +03:00
parent 80e948c018
commit 6f1fcb1eb0
15 changed files with 1806 additions and 362 deletions
+548 -172
View File
@@ -1,5 +1,6 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react";
import { import {
ArcType,
Cartesian2, Cartesian2,
Cartesian3, Cartesian3,
BingMapsImageryProvider, BingMapsImageryProvider,
@@ -35,15 +36,18 @@ import {
Math as CesiumMath, Math as CesiumMath,
PerInstanceColorAppearance, PerInstanceColorAppearance,
PointGraphics, PointGraphics,
PointPrimitiveCollection,
PolygonGeometry, PolygonGeometry,
PolygonHierarchy, PolygonHierarchy,
PolylineColorAppearance, PolylineColorAppearance,
PolylineGraphics, PolylineGraphics,
PolylineCollection,
Resource, Resource,
sampleTerrainMostDetailed, sampleTerrainMostDetailed,
ScreenSpaceEventHandler, ScreenSpaceEventHandler,
ScreenSpaceEventType, ScreenSpaceEventType,
SunLight, SunLight,
Transforms,
VerticalOrigin, VerticalOrigin,
Viewer, Viewer,
} from "cesium"; } from "cesium";
@@ -65,8 +69,19 @@ import {
gridShouldBeVisible, gridShouldBeVisible,
resolveGridMode, resolveGridMode,
selectGridLod, selectGridLod,
snapGridCenter,
} from "./mapGridPolicy.mjs"; } 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 MAX_SPIRAL_SUBSTEPS_PER_FRAME = 300;
const TERRAIN_SAMPLE_TIMEOUT_MS = 12_000; const TERRAIN_SAMPLE_TIMEOUT_MS = 12_000;
@@ -220,12 +235,13 @@ export type MapPresentation = {
gridLodEnabled: boolean; gridLodEnabled: boolean;
grid3dEnabled: boolean; grid3dEnabled: boolean;
gridGraticuleEnabled: boolean; gridGraticuleEnabled: boolean;
gridCenterMode: "camera" | "fixed"; gridCenterMode: "fixed";
gridCenterLatitude: number; gridCenterLatitude: number;
gridCenterLongitude: number; gridCenterLongitude: number;
gridTileSizeKm: number; gridTileSizeKm: number;
gridAutoDisableHeightKm: number; gridAutoDisableHeightKm: number;
gridRebuildOnMoveEnd: boolean; gridRebuildOnMoveEnd: boolean;
gridLegacyMode: boolean;
gridMax3dViewAngleDegrees: number; gridMax3dViewAngleDegrees: number;
gridHeightMeters: number; gridHeightMeters: number;
gridLod1MaxHeightKm: number; gridLod1MaxHeightKm: number;
@@ -241,6 +257,7 @@ export type MapPresentation = {
gridLod4StepKm: number; gridLod4StepKm: number;
gridLod4Mode: "3d" | "graticule"; gridLod4Mode: "3d" | "graticule";
gridLod5StepKm: number; gridLod5StepKm: number;
gridLod5MaxHeightKm: number;
gridLod5Mode: "3d" | "graticule"; gridLod5Mode: "3d" | "graticule";
gridRadiusKm: number; gridRadiusKm: number;
gridLineWidth: number; gridLineWidth: number;
@@ -257,9 +274,45 @@ export type MapPresentation = {
gridCrossesWidthMeters: number; gridCrossesWidthMeters: number;
gridCrossesColor: string; gridCrossesColor: string;
gridCrossesOpacity: number; gridCrossesOpacity: number;
gridLodProfiles: GridLodProfile[];
cacheRefresh: boolean; 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 = { export type MapCameraView = {
longitude: number; longitude: number;
latitude: number; latitude: number;
@@ -903,195 +956,414 @@ function syncRuntimeDataSources(
viewer.scene.requestRender(); viewer.scene.requestRender();
} }
type GridLayerBuild = { type GridLodBand = GridLodProfile & { index: number; id: string };
dataSource: CustomDataSource | null; type LocalGridAddressing = {
key: string; mode: "3d";
lodIndex: number | null; 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<typeof localGridPlan>;
metersPerPixel: number;
cameraHeightMeters: number;
cameraPosition: Cartesian3;
cameraDirection: Cartesian3;
};
type GraticuleGridLayerPlan = {
mode: "graticule";
key: string;
lodIndex: number;
lod: GridLodBand;
grid: ReturnType<typeof graticuleLinePlan>;
};
type GridLayerPlan = HiddenGridPlan | LocalGridLayerPlan | GraticuleGridLayerPlan;
function buildGridLayer( function gridMetersPerPixel(viewer: Viewer) {
viewer: Viewer, const frustum = viewer.camera.frustum as unknown as { fovy?: number };
presentation: MapPresentation, return Math.max(0.01, (
previousLodIndex: number | null, Math.max(1, Number(viewer.camera.positionCartographic?.height || 1))
serial: number, * 2 * Math.tan((Number(frustum.fovy) || Math.PI / 3) / 2)
): GridLayerBuild { ) / Math.max(1, viewer.scene.canvas.clientHeight));
const cameraHeightKm = Math.max(0, Number(viewer.camera.positionCartographic?.height || 0) / 1000); }
if (!gridShouldBeVisible(presentation, cameraHeightKm)) {
return { dataSource: null, key: "hidden", lodIndex: null }; function graticuleViewport(viewer: Viewer, stepDegrees: number, cameraHeightKm: number) {
const ellipsoid = viewer.scene.globe.ellipsoid;
const margin = Math.max(stepDegrees * 2, Math.min(30, cameraHeightKm / 200));
const alignLatitude = (value: number, direction: "down" | "up") => clamp(
(direction === "down" ? Math.floor(value / stepDegrees) : Math.ceil(value / stepDegrees)) * stepDegrees,
-89.9,
89.9,
);
const alignIntervals = (west: number, east: number) => splitLongitudeRange(west - margin, east + margin).map((interval) => ({
west: interval.west <= -180 ? -180 : Math.floor(interval.west / stepDegrees) * stepDegrees,
east: interval.east >= 180 ? 180 : Math.ceil(interval.east / stepDegrees) * stepDegrees,
}));
const rectangle = viewer.camera.computeViewRectangle(ellipsoid);
if (rectangle) {
const south = CesiumMath.toDegrees(rectangle.south);
const north = CesiumMath.toDegrees(rectangle.north);
const west = CesiumMath.toDegrees(rectangle.west);
const east = CesiumMath.toDegrees(rectangle.east);
return {
south: alignLatitude(south - margin, "down"),
north: alignLatitude(north + margin, "up"),
longitudeIntervals: alignIntervals(west, east),
};
} }
const camera = viewer.camera.positionCartographic;
const lod = selectGridLod(presentation, cameraHeightKm, previousLodIndex); const latitude = camera ? CesiumMath.toDegrees(camera.latitude) : 0;
const pitchDegrees = CesiumMath.toDegrees(viewer.camera.pitch); const longitude = camera ? CesiumMath.toDegrees(camera.longitude) : 0;
const viewAngleFromNadir = Math.abs(90 - Math.abs(pitchDegrees)); const horizon = CesiumMath.toDegrees(Math.acos(clamp(
const mode = resolveGridMode(presentation, lod.mode, viewAngleFromNadir); ellipsoid.maximumRadius / (ellipsoid.maximumRadius + Math.max(0, cameraHeightKm * 1_000)),
if (mode === "hidden") return { dataSource: null, key: "hidden", lodIndex: lod.index }; -1,
1,
const safeStepKm = clamp(lod.stepKm, 0.25, 5_000); )));
const safeRadiusKm = clamp(presentation.gridRadiusKm, safeStepKm, 2_000); const range = Math.min(180, Math.max(10, horizon * 2));
const stepsPerSide = Math.min(40, Math.max(1, Math.floor(safeRadiusKm / safeStepKm))); return {
const cameraPosition = viewer.camera.positionCartographic; south: alignLatitude(latitude - range - margin, "down"),
const center = snapGridCenter({ north: alignLatitude(latitude + range + margin, "up"),
latitude: cameraPosition ? CesiumMath.toDegrees(cameraPosition.latitude) : 55.751244, longitudeIntervals: alignIntervals(longitude - range - margin, longitude + range + margin),
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,
},
});
}; };
}
for (let index = -stepsPerSide; index <= stepsPerSide; index += 1) { function planGridLayer(viewer: Viewer, presentation: MapPresentation, previousLodIndex: number | null): GridLayerPlan {
const nextLatitude = latitude + index * deltaLatitude; const cameraHeightKm = Math.max(0, Number(viewer.camera.positionCartographic?.height || 0) / 1_000);
const nextLongitude = longitude + index * deltaLongitude; if (!gridShouldBeVisible(presentation, cameraHeightKm)) return { mode: "hidden", key: "hidden", lodIndex: null };
addGridLine(`${serial}:latitude:${index}`, [ const lod = selectGridLod(presentation, cameraHeightKm, previousLodIndex) as GridLodBand;
Cartesian3.fromDegrees(longitude - radiusLongitude, nextLatitude, geometryHeight), const mode = resolveGridMode(presentation, lod.mode);
Cartesian3.fromDegrees(longitude + radiusLongitude, nextLatitude, geometryHeight), if (mode === "hidden") return { mode: "hidden", key: `hidden:${lod.index}`, lodIndex: lod.index };
]);
addGridLine(`${serial}:longitude:${index}`, [ if (mode === "3d") {
Cartesian3.fromDegrees(nextLongitude, latitude - radiusLatitude, geometryHeight), const origin = fixedGridOrigin(presentation);
Cartesian3.fromDegrees(nextLongitude, latitude + radiusLatitude, geometryHeight), 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)); const viewport = graticuleViewport(viewer, lod.graticuleStepDegrees, cameraHeightKm);
for (let row = -stepsPerSide; row <= stepsPerSide; row += dotStride) { const grid = graticuleLinePlan({ ...viewport, stepDegrees: lod.graticuleStepDegrees });
for (let column = -stepsPerSide; column <= stepsPerSide; column += dotStride) { const key = JSON.stringify([
const dotLongitude = longitude + column * deltaLongitude; "wgs84-graticule", lod.index, lod.heightMeters, lod.graticuleStepDegrees,
const dotLatitude = latitude + row * deltaLatitude; lod.graticuleLineWidthPx, lod.graticuleColor, lod.graticuleOpacity,
if (presentation.gridDotsEnabled) { grid.south, grid.north, grid.longitudeIntervals,
const diameterMeters = clamp(presentation.gridDotsDiameterMeters, 2, 2_000); ]);
entities.add({ return { mode, key, lodIndex: lod.index, lod, grid };
id: `${serial}:circle:${row}:${column}`, }
position: Cartesian3.fromDegrees(dotLongitude, dotLatitude, geometryHeight),
ellipse: { function materializeLocalGrid(plan: LocalGridLayerPlan, serial: number): GridResources {
semiMajorAxis: diameterMeters / 2, const dataSource = new CustomDataSource(`nodedc-map-grid-local:${serial}`);
semiMinorAxis: diameterMeters / 2, const points = plan.lod.dotsEnabled ? new PointPrimitiveCollection() : null;
material: dotColor, const crosses = plan.lod.crossesEnabled ? new PolylineCollection() : null;
height: geometryHeight, const lineColor = Color.fromCssColorString(plan.lod.lineColor).withAlpha(clamp(plan.lod.lineOpacity / 100, 0, 1));
heightReference: projected ? HeightReference.CLAMP_TO_GROUND : HeightReference.NONE, 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) { positions = [];
const halfLengthMeters = clamp(presentation.gridCrossesLengthMeters, 2, 5_000) / 2; };
const halfLatitude = halfLengthMeters / metersPerLatitudeDegree; let part = 0;
const halfLongitude = halfLengthMeters / metersPerLongitudeDegree; for (let variable = -line.extentMeters; variable < line.extentMeters; variable += segmentStepMeters) {
const corridor = (positions: Cartesian3[]) => ({ const next = Math.min(variable + segmentStepMeters, line.extentMeters);
positions, const middle = (variable + next) / 2;
width: clamp(presentation.gridCrossesWidthMeters, 1, 500), const middleEast = axis === "east" ? line.offsetMeters : middle;
material: crossColor, const middleNorth = axis === "east" ? middle : line.offsetMeters;
height: geometryHeight, if (!inViewCone(middleEast, middleNorth)) {
heightReference: projected ? HeightReference.CLAMP_TO_GROUND : HeightReference.NONE, 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({ const northSouth = crosses.add({
id: `${serial}:cross-ns:${row}:${column}`, id: { kind: "nodedc-grid-cross", sectorId: address.id },
corridor: corridor([ positions: [toWorld(eastMeters, northMeters - crossHalf), toWorld(eastMeters, northMeters + crossHalf)],
Cartesian3.fromDegrees(dotLongitude, dotLatitude - halfLatitude, geometryHeight), width: Math.max(1, Math.round(crossWidthPixels)),
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),
]),
}); });
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<GridLayerPlan, HiddenGridPlan>, serial: number) {
return plan.mode === "3d" ? materializeLocalGrid(plan, serial) : materializeGraticule(plan, serial);
} }
class GridLayerController { class GridLayerController {
private current: CustomDataSource | null = null; private current: { resources: GridResources; addressing: GridAddressing } | null = null;
private pending: CustomDataSource | null = null; private pending: { resources: GridResources; addressing: GridAddressing } | null = null;
private key: string | null = null; private key: string | null = null;
private lodIndex: number | null = null; private lodIndex: number | null = null;
private epoch = 0; private epoch = 0;
private serial = 0; private serial = 0;
private removeReadyListener: (() => void) | null = null; private removeReadyListener: (() => void) | null = null;
private fallbackTimer: number | null = null; private fallbackTimer: number | null = null;
private retiredResources = new WeakSet<GridResources>();
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) { rebuild(presentation: MapPresentation) {
const build = buildGridLayer(this.viewer, presentation, this.lodIndex, ++this.serial); // Planning is intentionally cheap. Geometry is materialized only after
if (build.key === this.key) return; // key comparison; repeated camera/settings notifications cannot create
this.key = build.key; // and discard thousands of Cesium objects for an unchanged layer.
this.lodIndex = build.lodIndex; 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; const epoch = ++this.epoch;
this.cancelPending(); this.cancelPending();
if (!build.dataSource) { if (plan.mode === "hidden") {
if (this.current) this.viewer.dataSources.remove(this.current, true); this.onAddressingChange();
if (this.current) this.removeResources(this.current.resources);
this.current = null; this.current = null;
this.viewer.scene.requestRender(); this.viewer.scene.requestRender();
return; 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; this.pending = next;
void this.viewer.dataSources.add(next); this.mountResources(resources);
let readyFrames = 0; let readyFrames = 0;
const commit = () => { const commit = () => {
if (epoch !== this.epoch || this.pending !== next) return; if (epoch !== this.epoch || this.pending !== next) return;
@@ -1102,33 +1374,100 @@ class GridLayerController {
const previous = this.current; const previous = this.current;
this.current = next; this.current = next;
this.pending = null; 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.viewer.scene.requestRender();
}; };
this.removeReadyListener = this.viewer.scene.postRender.addEventListener(() => { this.removeReadyListener = this.viewer.scene.postRender.addEventListener(() => {
readyFrames = this.viewer.dataSourceDisplay.ready ? readyFrames + 1 : 0; readyFrames = this.viewer.dataSourceDisplay.ready ? readyFrames + 1 : 0;
if (readyFrames >= 2) commit(); 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.fallbackTimer = window.setTimeout(commit, 2_000);
this.viewer.scene.requestRender(); 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() { destroy() {
this.epoch += 1; this.epoch += 1;
this.cancelPending(); this.cancelPending();
if (this.current) this.viewer.dataSources.remove(this.current, true); if (this.current) this.removeResources(this.current.resources);
this.current = null; 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() { private cancelPending() {
this.removeReadyListener?.(); this.removeReadyListener?.();
this.removeReadyListener = null; this.removeReadyListener = null;
if (this.fallbackTimer !== null) window.clearTimeout(this.fallbackTimer); if (this.fallbackTimer !== null) window.clearTimeout(this.fallbackTimer);
this.fallbackTimer = null; this.fallbackTimer = null;
if (this.pending) this.viewer.dataSources.remove(this.pending, true); if (this.pending) this.removeResources(this.pending.resources);
this.pending = null; this.pending = null;
} }
} }
@@ -1184,6 +1523,7 @@ function applyPresentation(
export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, { export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
onSelect?: (entityId: string) => void; onSelect?: (entityId: string) => void;
onGridSectorSelect?: (sector: GridSectorSelection | null) => void;
onGatewayHealth?: (health: MapGatewayHealth | null) => void; onGatewayHealth?: (health: MapGatewayHealth | null) => void;
onProviderStatus?: (status: MapProviderStatus) => void; onProviderStatus?: (status: MapProviderStatus) => void;
onCameraChange?: (camera: MapCameraView) => void; onCameraChange?: (camera: MapCameraView) => void;
@@ -1197,6 +1537,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
presentationFilters?: MapPresentationFilters; presentationFilters?: MapPresentationFilters;
}>(function CesiumMapRenderer({ }>(function CesiumMapRenderer({
onSelect, onSelect,
onGridSectorSelect,
onGatewayHealth, onGatewayHealth,
onProviderStatus, onProviderStatus,
onCameraChange, onCameraChange,
@@ -1224,6 +1565,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
const presentationProfilesRef = useRef(presentationProfiles); const presentationProfilesRef = useRef(presentationProfiles);
const presentationFiltersRef = useRef(presentationFilters); const presentationFiltersRef = useRef(presentationFilters);
const onSelectRef = useRef(onSelect); const onSelectRef = useRef(onSelect);
const onGridSectorSelectRef = useRef(onGridSectorSelect);
const onCameraChangeRef = useRef(onCameraChange); const onCameraChangeRef = useRef(onCameraChange);
const onCacheRefreshConsumedRef = useRef(onCacheRefreshConsumed); const onCacheRefreshConsumedRef = useRef(onCacheRefreshConsumed);
const onReadyChangeRef = useRef(onReadyChange); const onReadyChangeRef = useRef(onReadyChange);
@@ -1234,6 +1576,10 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
onSelectRef.current = onSelect; onSelectRef.current = onSelect;
}, [onSelect]); }, [onSelect]);
useEffect(() => {
onGridSectorSelectRef.current = onGridSectorSelect;
}, [onGridSectorSelect]);
useEffect(() => { useEffect(() => {
onCameraChangeRef.current = onCameraChange; onCameraChangeRef.current = onCameraChange;
}, [onCameraChange]); }, [onCameraChange]);
@@ -1641,14 +1987,20 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
const viewer = viewerRef.current; const viewer = viewerRef.current;
if (!viewer || viewer.isDestroyed()) return false; if (!viewer || viewer.isDestroyed()) return false;
const entity = runtimeEntities([entityId])[0]; const entity = runtimeEntities([entityId])[0];
const position = entity?.position?.getValue(viewer.clock.currentTime); if (!entity) return false;
if (!position) return false;
const cartographic = Cartographic.fromCartesian(position); // A subject selection is an explicit navigation command. Using the
return focusCoordinates( // generic viewport-transfer helper here can produce an imperceptible or
CesiumMath.toDegrees(cartographic.longitude), // invalid destination when the current screen centre misses the globe
CesiumMath.toDegrees(cartographic.latitude), // (for example near the horizon). Cesium's entity-aware flight resolves
); // the time-dynamic position itself and restores the proven operational
}, [focusCoordinates, runtimeEntities]); // map behaviour: centre the selected subject at an inspectable range.
void viewer.flyTo(entity, {
duration: 0.45,
offset: new HeadingPitchRange(0, -0.9, 8_000),
});
return true;
}, [runtimeEntities]);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
startSpiralAnimation, startSpiralAnimation,
@@ -1721,6 +2073,8 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
let gridController: GridLayerController | undefined; let gridController: GridLayerController | undefined;
let removeGridCameraListener: (() => void) | undefined; let removeGridCameraListener: (() => void) | undefined;
let removeGridCameraChangedListener: (() => void) | undefined; let removeGridCameraChangedListener: (() => void) | undefined;
let gridCameraChangedTimer: number | undefined;
let gridResizeTimer: number | undefined;
let removeRefreshRenderListener: (() => void) | undefined; let removeRefreshRenderListener: (() => void) | undefined;
let removeRenderErrorListener: (() => void) | undefined; let removeRenderErrorListener: (() => void) | undefined;
const removeProviderFailureListeners: Array<() => void> = []; const removeProviderFailureListeners: Array<() => void> = [];
@@ -1791,7 +2145,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
viewer.scene.globe.depthTestAgainstTerrain = true; viewer.scene.globe.depthTestAgainstTerrain = true;
viewerRef.current = viewer; viewerRef.current = viewer;
terrainRef.current = terrain; terrainRef.current = terrain;
gridController = new GridLayerController(viewer); gridController = new GridLayerController(viewer, () => onGridSectorSelectRef.current?.(null));
const rebuildGrid = () => gridController?.rebuild(presentationRef.current); const rebuildGrid = () => gridController?.rebuild(presentationRef.current);
rebuildGridRef.current = rebuildGrid; rebuildGridRef.current = rebuildGrid;
removeGridCameraListener = viewer.camera.moveEnd.addEventListener(() => { removeGridCameraListener = viewer.camera.moveEnd.addEventListener(() => {
@@ -1800,7 +2154,11 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
onCameraChangeRef.current?.(getCameraView(viewer!)); onCameraChangeRef.current?.(getCameraView(viewer!));
}); });
removeGridCameraChangedListener = viewer.camera.changed.addEventListener(() => { removeGridCameraChangedListener = viewer.camera.changed.addEventListener(() => {
if (!presentationRef.current.gridRebuildOnMoveEnd) rebuildGrid(); if (presentationRef.current.gridRebuildOnMoveEnd || gridCameraChangedTimer !== undefined) return;
gridCameraChangedTimer = window.setTimeout(() => {
gridCameraChangedTimer = undefined;
rebuildGrid();
}, 150);
}); });
const providerStatus: MapProviderStatus = { const providerStatus: MapProviderStatus = {
@@ -1973,7 +2331,8 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
handler.setInputAction((movement: { position: Cartesian2 }) => { handler.setInputAction((movement: { position: Cartesian2 }) => {
const picked = viewer?.scene.pick(movement.position); const picked = viewer?.scene.pick(movement.position);
const pickedId = picked?.id; 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); onSelectRef.current?.(pickedId.id);
return; return;
} }
@@ -1982,12 +2341,27 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
&& typeof pickedId === "object" && typeof pickedId === "object"
&& (pickedId as Partial<HGeoZonePickId>).kind === "nodedc-hgeozone" && (pickedId as Partial<HGeoZonePickId>).kind === "nodedc-hgeozone"
&& typeof (pickedId as Partial<HGeoZonePickId>).entityId === "string" && typeof (pickedId as Partial<HGeoZonePickId>).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); }, ScreenSpaceEventType.LEFT_CLICK);
resizeObserver = new ResizeObserver(() => { resizeObserver = new ResizeObserver(() => {
if (!viewer || viewer.isDestroyed()) return; if (!viewer || viewer.isDestroyed()) return;
viewer.resize(); viewer.resize();
viewer.scene.requestRender(); viewer.scene.requestRender();
if (gridResizeTimer === undefined) {
gridResizeTimer = window.setTimeout(() => {
gridResizeTimer = undefined;
rebuildGrid();
}, 100);
}
}); });
resizeObserver.observe(containerRef.current); resizeObserver.observe(containerRef.current);
} catch (error) { } catch (error) {
@@ -2014,6 +2388,8 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
resizeObserver?.disconnect(); resizeObserver?.disconnect();
removeGridCameraListener?.(); removeGridCameraListener?.();
removeGridCameraChangedListener?.(); removeGridCameraChangedListener?.();
if (gridCameraChangedTimer !== undefined) window.clearTimeout(gridCameraChangedTimer);
if (gridResizeTimer !== undefined) window.clearTimeout(gridResizeTimer);
removeRefreshRenderListener?.(); removeRefreshRenderListener?.();
removeRenderErrorListener?.(); removeRenderErrorListener?.();
for (const removeListener of removeProviderFailureListeners) removeListener(); for (const removeListener of removeProviderFailureListeners) removeListener();
+91 -59
View File
@@ -8,6 +8,8 @@ import type {
MapGatewayHealth, MapGatewayHealth,
MapPresentation, MapPresentation,
MapProviderStatus, MapProviderStatus,
GridLodProfile,
GridSectorSelection,
} from "./CesiumMapRenderer.js"; } from "./CesiumMapRenderer.js";
import { mapRuntimeEntityId, useMapDataProductRuntime } from "./useMapDataProductRuntime.js"; import { mapRuntimeEntityId, useMapDataProductRuntime } from "./useMapDataProductRuntime.js";
import { import {
@@ -40,6 +42,8 @@ import {
} from "./mapReferenceStations.js"; } from "./mapReferenceStations.js";
import { useMapReferenceRuntime, useMapReferenceSearch } from "./useMapReferenceRuntime.js"; import { useMapReferenceRuntime, useMapReferenceSearch } from "./useMapReferenceRuntime.js";
import { buildMapSearchIndex, searchMapSubjects } from "./mapSearch.mjs"; import { buildMapSearchIndex, searchMapSubjects } from "./mapSearch.mjs";
import { DEFAULT_GRID_LOD_PROFILES, gridLodProfile } from "./mapGridPolicy.mjs";
import { MAX_LOCAL_GRID_INDEX } from "./mapSectorGrid.mjs";
const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer }))); const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer })));
type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean }; type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean };
@@ -227,12 +231,13 @@ const initialMapSettings: MapPageSettings = {
gridLodEnabled: true, gridLodEnabled: true,
grid3dEnabled: true, grid3dEnabled: true,
gridGraticuleEnabled: true, gridGraticuleEnabled: true,
gridCenterMode: "camera", gridCenterMode: "fixed",
gridCenterLatitude: 55.7558, gridCenterLatitude: 55.7558,
gridCenterLongitude: 37.6173, gridCenterLongitude: 37.6173,
gridTileSizeKm: 10, gridTileSizeKm: 10,
gridAutoDisableHeightKm: 10_000, gridAutoDisableHeightKm: 10_000,
gridRebuildOnMoveEnd: true, gridRebuildOnMoveEnd: true,
gridLegacyMode: false,
gridMax3dViewAngleDegrees: 30, gridMax3dViewAngleDegrees: 30,
gridHeightMeters: 500, gridHeightMeters: 500,
gridLod1MaxHeightKm: 10, gridLod1MaxHeightKm: 10,
@@ -241,31 +246,47 @@ const initialMapSettings: MapPageSettings = {
gridLod2MaxHeightKm: 50, gridLod2MaxHeightKm: 50,
gridLod2StepKm: 5, gridLod2StepKm: 5,
gridLod2Mode: "3d", gridLod2Mode: "3d",
gridLod3MaxHeightKm: 180, gridLod3MaxHeightKm: 200,
gridLod3StepKm: 25, gridLod3StepKm: 25,
gridLod3Mode: "3d", gridLod3Mode: "3d",
gridLod4MaxHeightKm: 700, gridLod4MaxHeightKm: 800,
gridLod4StepKm: 100, gridLod4StepKm: 50,
gridLod4Mode: "graticule", gridLod4Mode: "graticule",
gridLod5StepKm: 500, gridLod5MaxHeightKm: 3_000,
gridLod5StepKm: 50,
gridLod5Mode: "graticule", gridLod5Mode: "graticule",
gridRadiusKm: 1_000, gridRadiusKm: 40,
gridLineWidth: 4, gridLineWidth: 1,
gridLineDiameterMeters: 10, gridLineDiameterMeters: 7,
gridColor: "#f5f5f5", gridColor: "#9c9c9c",
gridOpacity: 12, gridOpacity: 12,
gridDotsEnabled: true, gridDotsEnabled: true,
gridDotsSize: 7, gridDotsSize: 7,
gridDotsDiameterMeters: 80, gridDotsDiameterMeters: 10,
gridDotsColor: "#ffffff", gridDotsColor: "#9c9c9c",
gridDotsOpacity: 58, gridDotsOpacity: 58,
gridCrossesEnabled: false, gridCrossesEnabled: false,
gridCrossesLengthMeters: 200, gridCrossesLengthMeters: 60,
gridCrossesWidthMeters: 10, gridCrossesWidthMeters: 10,
gridCrossesColor: "#35cfff", gridCrossesColor: "#9c9c9c",
gridCrossesOpacity: 50, gridCrossesOpacity: 46,
gridLodProfiles: structuredClone(DEFAULT_GRID_LOD_PROFILES) as GridLodProfile[],
}; };
function resolveGridLodProfiles(settings?: Partial<MapPageSettings>): 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 // A valid, deterministic scene view is available before Cesium emits its
// first move-end event. It makes the page contract immediately saveable; // 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. // the renderer replaces it with the exact live camera as soon as it is ready.
@@ -389,6 +410,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
}>(function MapFixturePreview({ features = { inspector: true, toolbar: true, assistant: false }, expanded = false, initialLayout = null, applicationId, pageId }, ref) { }>(function MapFixturePreview({ features = { inspector: true, toolbar: true, assistant: false }, expanded = false, initialLayout = null, applicationId, pageId }, ref) {
const workspaceRef = useRef<HTMLDivElement>(null); const workspaceRef = useRef<HTMLDivElement>(null);
const [selectedId, setSelectedId] = useState<string>(); const [selectedId, setSelectedId] = useState<string>();
const [selectedGridSector, setSelectedGridSector] = useState<GridSectorSelection | null>(null);
const [subjectCardOpen, setSubjectCardOpen] = useState(false); const [subjectCardOpen, setSubjectCardOpen] = useState(false);
const [subjectCardRect, setSubjectCardRect] = useState<WorkspaceWindowRect>(defaultSubjectCardRect); const [subjectCardRect, setSubjectCardRect] = useState<WorkspaceWindowRect>(defaultSubjectCardRect);
const [subjectCardMaximized, setSubjectCardMaximized] = useState(false); const [subjectCardMaximized, setSubjectCardMaximized] = useState(false);
@@ -419,7 +441,13 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
// Layouts saved before the cache policy field existed retain the safe // Layouts saved before the cache policy field existed retain the safe
// append-only default when they are opened again. // append-only default when they are opened again.
cacheNoOverwrite: initialLayout?.settings?.cacheNoOverwrite ?? true, cacheNoOverwrite: initialLayout?.settings?.cacheNoOverwrite ?? true,
// Camera-relative layouts were decorative and had no stable sector
// identity. Opening one performs a deterministic migration to its stored
// Moscow origin; the current viewport is never promoted to definition.
gridCenterMode: "fixed",
gridLodProfiles: resolveGridLodProfiles(initialLayout?.settings),
})); }));
const [selectedGridLod, setSelectedGridLod] = useState("0");
const [mapHeight, setMapHeight] = useState(() => initialLayout?.mapHeight ?? (expanded ? 620 : 470)); const [mapHeight, setMapHeight] = useState(() => initialLayout?.mapHeight ?? (expanded ? 620 : 470));
const [mapCamera, setMapCamera] = useState<MapCameraView>(initialLayout?.camera ?? fallbackMapCamera); const [mapCamera, setMapCamera] = useState<MapCameraView>(initialLayout?.camera ?? fallbackMapCamera);
const mapRendererRef = useRef<CesiumMapRendererHandle | null>(null); const mapRendererRef = useRef<CesiumMapRendererHandle | null>(null);
@@ -646,6 +674,19 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
[cacheRefresh, mapSettings], [cacheRefresh, mapSettings],
); );
const updateMapSettings = (patch: Partial<MapPageSettings>) => setMapSettings((current) => ({ ...current, ...patch })); const updateMapSettings = (patch: Partial<MapPageSettings>) => 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<GridLodProfile>) => updateMapSettings({
gridLodProfiles: mapSettings.gridLodProfiles.map((profile, index) => (
index === selectedGridLodIndex ? { ...profile, ...patch } : profile
)),
});
const setCacheEnabled = (cacheEnabled: boolean) => { const setCacheEnabled = (cacheEnabled: boolean) => {
updateMapSettings({ cacheEnabled }); updateMapSettings({ cacheEnabled });
setRendererRevision((value) => value + 1); setRendererRevision((value) => value + 1);
@@ -1267,57 +1308,47 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
description: "first adapter control", description: "first adapter control",
group: "Слои", group: "Слои",
content: <> content: <>
<small className="catalog-map-inspector__note">Пять LOD сохраняют пространственную сетку вблизи и переходят к гратикуле на дальних высотах. Новый слой подготавливается до удаления предыдущего.</small> <small className="catalog-map-inspector__note">Фиксированная московская ENU-адресация задаёт неизменные сектора на LOD 13. LOD 45 используют глобальную WGS84-гратику́лу; камера выбирает только LOD и видимую область.</small>
<Checker checked={mapSettings.gridVisible} label="Сетка" onChange={(gridVisible) => updateMapSettings({ gridVisible })} /> <Checker checked={mapSettings.gridVisible} label="Сетка" onChange={(gridVisible) => updateMapSettings({ gridVisible })} />
<Checker checked={mapSettings.grid3dEnabled} label="3D-сетка" onChange={(grid3dEnabled) => updateMapSettings({ grid3dEnabled })} /> <Checker checked={mapSettings.grid3dEnabled} label="3D-сетка" onChange={(grid3dEnabled) => updateMapSettings({ grid3dEnabled })} />
<Checker checked={mapSettings.gridGraticuleEnabled} label="Гратикула" onChange={(gridGraticuleEnabled) => updateMapSettings({ gridGraticuleEnabled })} /> <Checker checked={mapSettings.gridGraticuleEnabled} label="Гратикула" onChange={(gridGraticuleEnabled) => updateMapSettings({ gridGraticuleEnabled })} />
<Checker checked={mapSettings.gridLodEnabled} label="LOD по высоте камеры" onChange={(gridLodEnabled) => updateMapSettings({ gridLodEnabled })} /> <Checker checked={mapSettings.gridLodEnabled} label="LOD по высоте камеры" onChange={(gridLodEnabled) => updateMapSettings({ gridLodEnabled })} />
<Checker checked={mapSettings.gridRebuildOnMoveEnd} label="Перестраивать после движения" onChange={(gridRebuildOnMoveEnd) => updateMapSettings({ gridRebuildOnMoveEnd })} /> <Checker checked={mapSettings.gridRebuildOnMoveEnd} label="Перестраивать после движения" onChange={(gridRebuildOnMoveEnd) => updateMapSettings({ gridRebuildOnMoveEnd })} />
<InspectorSelectField <ControlRow label="Система координат"><strong>Fixed ENU · WGS84</strong></ControlRow>
label="Центр сетки" <RangeControl label="Origin: широта" value={mapSettings.gridCenterLatitude} min={-89.9} max={89.9} step={0.000001} formatValue={(value) => value.toFixed(6)} onChange={(gridCenterLatitude) => updateMapSettings({ gridCenterLatitude })} />
value={mapSettings.gridCenterMode} <RangeControl label="Origin: долгота" value={mapSettings.gridCenterLongitude} min={-180} max={180} step={0.000001} formatValue={(value) => value.toFixed(6)} onChange={(gridCenterLongitude) => updateMapSettings({ gridCenterLongitude })} />
options={[
{ value: "camera", label: "За камерой", description: "Центр следует за viewport по стабильным тайлам" },
{ value: "fixed", label: "Фиксированный", description: "Используются заданные координаты" },
]}
onChange={(gridCenterMode) => updateMapSettings({ gridCenterMode })}
/>
{mapSettings.gridCenterMode === "fixed" ? <>
<RangeControl label="Центр: широта" value={mapSettings.gridCenterLatitude} min={-89.999} max={89.999} step={0.0001} formatValue={(value) => value.toFixed(4)} onChange={(gridCenterLatitude) => updateMapSettings({ gridCenterLatitude })} />
<RangeControl label="Центр: долгота" value={mapSettings.gridCenterLongitude} min={-180} max={180} step={0.0001} formatValue={(value) => value.toFixed(4)} onChange={(gridCenterLongitude) => updateMapSettings({ gridCenterLongitude })} />
</> : null}
<RangeControl label="Размер тайла" value={mapSettings.gridTileSizeKm} min={1} max={500} step={1} formatValue={(value) => `${value} км`} onChange={(gridTileSizeKm) => updateMapSettings({ gridTileSizeKm })} />
<RangeControl label="Автовыключение выше" value={mapSettings.gridAutoDisableHeightKm} min={0} max={50_000} step={100} formatValue={(value) => value === 0 ? "выкл" : `${value} км`} onChange={(gridAutoDisableHeightKm) => updateMapSettings({ gridAutoDisableHeightKm })} /> <RangeControl label="Автовыключение выше" value={mapSettings.gridAutoDisableHeightKm} min={0} max={50_000} step={100} formatValue={(value) => value === 0 ? "выкл" : `${value} км`} onChange={(gridAutoDisableHeightKm) => updateMapSettings({ gridAutoDisableHeightKm })} />
<RangeControl label="Высота над поверхностью" value={mapSettings.gridHeightMeters} min={0} max={1000} formatValue={(value) => `${value} м`} onChange={(gridHeightMeters) => updateMapSettings({ gridHeightMeters })} /> <div className="catalog-map-grid-lod-tabs">
<RangeControl label="Макс. угол обзора 3D" value={mapSettings.gridMax3dViewAngleDegrees} min={0} max={89} step={1} formatValue={(value) => `${value}°`} onChange={(gridMax3dViewAngleDegrees) => updateMapSettings({ gridMax3dViewAngleDegrees })} /> <SegmentedControl value={selectedGridLod} items={DEFAULT_GRID_LOD_PROFILES.map((_profile, index) => ({ value: String(index), label: `LOD ${index + 1}` }))} label="Уровень детализации сетки" onChange={setSelectedGridLod} />
<InspectorSelectField label="LOD 1: режим" value={mapSettings.gridLod1Mode} options={GRID_MODE_OPTIONS} onChange={(gridLod1Mode) => updateMapSettings({ gridLod1Mode })} /> </div>
<RangeControl label="LOD 1: до высоты" value={mapSettings.gridLod1MaxHeightKm} min={1} max={50} formatValue={(value) => `${value} км`} onChange={(gridLod1MaxHeightKm) => updateMapSettings({ gridLod1MaxHeightKm })} /> <RangeControl label={selectedGridLodIndex === 4 ? "Порог профиля" : о высоты"} value={activeGridLod.maxHeightKm} min={minimumGridLodHeight} max={maximumGridLodHeight} step={0.1} formatValue={(value) => `${value} км`} onChange={(maxHeightKm) => updateGridLod({ maxHeightKm })} />
<RangeControl label="LOD 1: шаг" value={mapSettings.gridLod1StepKm} min={1} max={10} formatValue={(value) => `${value} км`} onChange={(gridLod1StepKm) => updateMapSettings({ gridLod1StepKm })} /> {selectedGridLodIndex === 4 ? <small className="catalog-map-inspector__note">Последний LOD остаётся активным выше своего порога до общего автовыключения.</small> : null}
<InspectorSelectField label="LOD 2: режим" value={mapSettings.gridLod2Mode} options={GRID_MODE_OPTIONS} onChange={(gridLod2Mode) => updateMapSettings({ gridLod2Mode })} /> <InspectorSelectField label="Режим" value={activeGridLod.mode} options={GRID_MODE_OPTIONS} onChange={(mode) => updateGridLod({ mode })} />
<RangeControl label="LOD 2: до высоты" value={mapSettings.gridLod2MaxHeightKm} min={10} max={200} formatValue={(value) => `${value} км`} onChange={(gridLod2MaxHeightKm) => updateMapSettings({ gridLod2MaxHeightKm })} /> <RangeControl label="Высота WGS84" value={activeGridLod.heightMeters} min={0} max={5_000} step={10} formatValue={(value) => `${value} м`} onChange={(heightMeters) => updateGridLod({ heightMeters })} />
<RangeControl label="LOD 2: шаг" value={mapSettings.gridLod2StepKm} min={1} max={25} formatValue={(value) => `${value} км`} onChange={(gridLod2StepKm) => updateMapSettings({ gridLod2StepKm })} /> <RangeControl label="Конус видимости 3D" value={activeGridLod.max3dViewAngleDegrees} min={30} max={170} step={1} formatValue={(value) => `${value}°`} onChange={(max3dViewAngleDegrees) => updateGridLod({ max3dViewAngleDegrees })} />
<InspectorSelectField label="LOD 3: режим" value={mapSettings.gridLod3Mode} options={GRID_MODE_OPTIONS} onChange={(gridLod3Mode) => updateMapSettings({ gridLod3Mode })} /> <RangeControl label="Шаг ENU-секторов" value={activeGridLod.stepKm} min={0.1} max={5_000} step={0.1} formatValue={(value) => `${value} км`} onChange={(stepKm) => updateGridLod({ stepKm, radiusKm: Math.min(activeGridLod.radiusKm, stepKm * MAX_LOCAL_GRID_INDEX) })} />
<RangeControl label="LOD 3: до высоты" value={mapSettings.gridLod3MaxHeightKm} min={50} max={1_000} step={10} formatValue={(value) => `${value} км`} onChange={(gridLod3MaxHeightKm) => updateMapSettings({ gridLod3MaxHeightKm })} /> <RangeControl label="Размер тайла ENU" value={activeGridLod.tileSizeKm} min={1} max={50} step={1} formatValue={(value) => `${value} км`} onChange={(tileSizeKm) => updateGridLod({ tileSizeKm })} />
<RangeControl label="LOD 3: шаг" value={mapSettings.gridLod3StepKm} min={5} max={100} formatValue={(value) => `${value} км`} onChange={(gridLod3StepKm) => updateMapSettings({ gridLod3StepKm })} /> <RangeControl label="Радиус ENU-поля" value={activeGridLod.radiusKm} min={1} max={Math.min(100_000, activeGridLod.stepKm * MAX_LOCAL_GRID_INDEX)} step={1} formatValue={(value) => `${value} км`} onChange={(radiusKm) => updateGridLod({ radiusKm })} />
<InspectorSelectField label="LOD 4: режим" value={mapSettings.gridLod4Mode} options={GRID_MODE_OPTIONS} onChange={(gridLod4Mode) => updateMapSettings({ gridLod4Mode })} /> <RangeControl label="Диаметр 3D-линий" value={activeGridLod.lineDiameterMeters} min={1} max={100} step={1} formatValue={(value) => `${value} м`} onChange={(lineDiameterMeters) => updateGridLod({ lineDiameterMeters })} />
<RangeControl label="LOD 4: до высоты" value={mapSettings.gridLod4MaxHeightKm} min={100} max={5_000} step={50} formatValue={(value) => `${value} км`} onChange={(gridLod4MaxHeightKm) => updateMapSettings({ gridLod4MaxHeightKm })} /> <ControlRow label="Цвет 3D-линий"><ColorField label="Цвет линий ENU-сетки" value={activeGridLod.lineColor} onChange={(lineColor) => updateGridLod({ lineColor })} /></ControlRow>
<RangeControl label="LOD 4: шаг" value={mapSettings.gridLod4StepKm} min={10} max={500} step={5} formatValue={(value) => `${value} км`} onChange={(gridLod4StepKm) => updateMapSettings({ gridLod4StepKm })} /> <RangeControl label="Прозрачность 3D-линий" value={activeGridLod.lineOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(lineOpacity) => updateGridLod({ lineOpacity })} />
<InspectorSelectField label="LOD 5: режим" value={mapSettings.gridLod5Mode} options={GRID_MODE_OPTIONS} onChange={(gridLod5Mode) => updateMapSettings({ gridLod5Mode })} /> <Checker checked={activeGridLod.dotsEnabled} label="Кружки" onChange={(dotsEnabled) => updateGridLod({ dotsEnabled })} />
<RangeControl label="LOD 5: шаг" value={mapSettings.gridLod5StepKm} min={50} max={2_000} step={25} formatValue={(value) => `${value} км`} onChange={(gridLod5StepKm) => updateMapSettings({ gridLod5StepKm })} /> <RangeControl label="Кружки: диаметр" value={activeGridLod.dotsDiameterMeters} min={1} max={1_000} step={1} formatValue={(value) => `${value} м`} onChange={(dotsDiameterMeters) => updateGridLod({ dotsDiameterMeters })} />
<RangeControl label="Радиус видимости" value={mapSettings.gridRadiusKm} min={5} max={2_000} step={5} formatValue={(value) => `${value} км`} onChange={(gridRadiusKm) => updateMapSettings({ gridRadiusKm })} /> <ControlRow label="Кружки: цвет"><ColorField label="Цвет кружков сетки" value={activeGridLod.dotsColor} onChange={(dotsColor) => updateGridLod({ dotsColor })} /></ControlRow>
<ControlRow label="Цвет линий"><ColorField label="Цвет линий сетки" value={mapSettings.gridColor} onChange={(gridColor) => updateMapSettings({ gridColor })} /></ControlRow> <RangeControl label="Кружки: прозрачность" value={activeGridLod.dotsOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(dotsOpacity) => updateGridLod({ dotsOpacity })} />
<RangeControl label="3D-линии: диаметр" value={mapSettings.gridLineDiameterMeters} min={1} max={100} step={1} formatValue={(value) => `${value} м`} onChange={(gridLineDiameterMeters) => updateMapSettings({ gridLineDiameterMeters })} /> <Checker checked={activeGridLod.crossesEnabled} label="Кресты" onChange={(crossesEnabled) => updateGridLod({ crossesEnabled })} />
<RangeControl label="Гратикула: толщина" value={mapSettings.gridLineWidth} min={1} max={8} formatValue={(value) => `${value} px`} onChange={(gridLineWidth) => updateMapSettings({ gridLineWidth })} /> <RangeControl label="Кресты: длина" value={activeGridLod.crossesLengthMeters} min={2} max={5_000} step={2} formatValue={(value) => `${value} м`} onChange={(crossesLengthMeters) => updateGridLod({ crossesLengthMeters })} />
<RangeControl label="Прозрачность сетки" value={mapSettings.gridOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(gridOpacity) => updateMapSettings({ gridOpacity })} /> <RangeControl label="Кресты: ширина" value={activeGridLod.crossesWidthMeters} min={1} max={500} step={1} formatValue={(value) => `${value} м`} onChange={(crossesWidthMeters) => updateGridLod({ crossesWidthMeters })} />
<Checker checked={mapSettings.gridDotsEnabled} label="Кружки" onChange={(gridDotsEnabled) => updateMapSettings({ gridDotsEnabled })} /> <ControlRow label="Кресты: цвет"><ColorField label="Цвет крестов сетки" value={activeGridLod.crossesColor} onChange={(crossesColor) => updateGridLod({ crossesColor })} /></ControlRow>
<RangeControl label="Кружки: диаметр" value={mapSettings.gridDotsDiameterMeters} min={2} max={1_000} step={2} formatValue={(value) => `${value} м`} onChange={(gridDotsDiameterMeters) => updateMapSettings({ gridDotsDiameterMeters })} /> <RangeControl label="Кресты: прозрачность" value={activeGridLod.crossesOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(crossesOpacity) => updateGridLod({ crossesOpacity })} />
<ControlRow label="Кружки: цвет"><ColorField label="Цвет кружков сетки" value={mapSettings.gridDotsColor} onChange={(gridDotsColor) => updateMapSettings({ gridDotsColor })} /></ControlRow> <RangeControl label="Шаг гратикулы" value={activeGridLod.graticuleStepDegrees} min={0.1} max={10} step={0.05} formatValue={(value) => `${value}°`} onChange={(graticuleStepDegrees) => updateGridLod({ graticuleStepDegrees })} />
<RangeControl label="Кружки: прозрачность" value={mapSettings.gridDotsOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(gridDotsOpacity) => updateMapSettings({ gridDotsOpacity })} /> <RangeControl label="Толщина гратикулы" value={activeGridLod.graticuleLineWidthPx} min={1} max={3} step={1} formatValue={(value) => `${value} px`} onChange={(graticuleLineWidthPx) => updateGridLod({ graticuleLineWidthPx })} />
<Checker checked={mapSettings.gridCrossesEnabled} label="Кресты" onChange={(gridCrossesEnabled) => updateMapSettings({ gridCrossesEnabled })} /> <ControlRow label="Цвет гратикулы"><ColorField label="Цвет WGS84-гратику́лы" value={activeGridLod.graticuleColor} onChange={(graticuleColor) => updateGridLod({ graticuleColor })} /></ControlRow>
<RangeControl label="Кресты: длина" value={mapSettings.gridCrossesLengthMeters} min={2} max={2_000} step={2} formatValue={(value) => `${value} м`} onChange={(gridCrossesLengthMeters) => updateMapSettings({ gridCrossesLengthMeters })} /> <RangeControl label="Прозрачность гратикулы" value={activeGridLod.graticuleOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(graticuleOpacity) => updateGridLod({ graticuleOpacity })} />
<RangeControl label="Кресты: ширина" value={mapSettings.gridCrossesWidthMeters} min={1} max={100} step={1} formatValue={(value) => `${value} м`} onChange={(gridCrossesWidthMeters) => updateMapSettings({ gridCrossesWidthMeters })} /> <ControlRow label="Выбранный сектор"><strong className="catalog-map-grid-sector-id">{selectedGridSector?.id ?? "Нажмите сектор на карте"}</strong></ControlRow>
<ControlRow label="Кресты: цвет"><ColorField label="Цвет крестов сетки" value={mapSettings.gridCrossesColor} onChange={(gridCrossesColor) => updateMapSettings({ gridCrossesColor })} /></ControlRow> {selectedGridSector ? <small className="catalog-map-inspector__note">LOD {selectedGridSector.lod} · {selectedGridSector.units === "meters-enu"
<RangeControl label="Кресты: прозрачность" value={mapSettings.gridCrossesOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(gridCrossesOpacity) => updateMapSettings({ gridCrossesOpacity })} /> ? `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}°`}</small> : null}
</>, </>,
}, },
{ {
@@ -1447,6 +1478,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
key={rendererRevision} key={rendererRevision}
ref={mapRendererRef} ref={mapRendererRef}
onSelect={handleSelect} onSelect={handleSelect}
onGridSectorSelect={setSelectedGridSector}
onGatewayHealth={handleRendererGatewayHealth} onGatewayHealth={handleRendererGatewayHealth}
onProviderStatus={setProviderStatus} onProviderStatus={setProviderStatus}
onCameraChange={handleCameraChange} onCameraChange={handleCameraChange}
+29 -4
View File
@@ -1,15 +1,40 @@
import type { MapPresentation } from "./CesiumMapRenderer.js"; import type { MapPresentation } from "./CesiumMapRenderer.js";
export type GridLodMode = "3d" | "graticule"; export type GridLodMode = "3d" | "graticule";
export type GridLodBand = { export type GridLodProfile = {
index: number;
id: string;
maxHeightKm: number; maxHeightKm: number;
stepKm: number; stepKm: number;
mode: GridLodMode; mode: GridLodMode;
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 GridLodBand = GridLodProfile & {
index: number;
id: string;
}; };
export const GRID_LOD_HYSTERESIS_RATIO: number; export const GRID_LOD_HYSTERESIS_RATIO: number;
export const GRID_LOD_COUNT: number;
export const DEFAULT_GRID_LOD_PROFILES: readonly Readonly<GridLodProfile>[];
export function gridLodProfile(settings: MapPresentation, index: number): GridLodProfile;
export function gridLodBands(settings: MapPresentation): GridLodBand[]; export function gridLodBands(settings: MapPresentation): GridLodBand[];
export function selectGridLod( export function selectGridLod(
settings: MapPresentation, settings: MapPresentation,
@@ -19,10 +44,10 @@ export function selectGridLod(
export function resolveGridMode( export function resolveGridMode(
settings: MapPresentation, settings: MapPresentation,
requestedMode: GridLodMode, requestedMode: GridLodMode,
viewAngleFromNadirDegrees: number,
): GridLodMode | "hidden"; ): GridLodMode | "hidden";
export function gridShouldBeVisible(settings: MapPresentation, cameraHeightKm: number): boolean; export function gridShouldBeVisible(settings: MapPresentation, cameraHeightKm: number): boolean;
export function snapGridCenter( export function snapGridCenter(
center: { latitude: number; longitude: number }, center: { latitude: number; longitude: number },
settings: MapPresentation, settings: MapPresentation,
tileSizeKm?: number,
): { latitude: number; longitude: number }; ): { latitude: number; longitude: number };
+93 -47
View File
@@ -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; export const GRID_LOD_HYSTERESIS_RATIO = 0.08;
const finite = (value, fallback) => Number.isFinite(Number(value)) ? Number(value) : fallback; const finite = (value, fallback) => Number.isFinite(Number(value)) ? Number(value) : fallback;
const clamp = (value, minimum, maximum) => Math.min(maximum, Math.max(minimum, value)); 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) { export function gridLodBands(settings) {
const maximums = [ const profiles = Array.from({ length: GRID_LOD_COUNT }, (_unused, index) => gridLodProfile(settings, index));
finite(settings.gridLod1MaxHeightKm, 10), return profiles.map((profile, index) => ({
finite(settings.gridLod2MaxHeightKm, 50), ...profile,
finite(settings.gridLod3MaxHeightKm, 180),
finite(settings.gridLod4MaxHeightKm, 700),
Number.POSITIVE_INFINITY,
];
for (let index = 1; index < maximums.length - 1; index += 1) {
maximums[index] = Math.max(maximums[index - 1], maximums[index]);
}
const steps = [
finite(settings.gridLod1StepKm, 1),
finite(settings.gridLod2StepKm, 5),
finite(settings.gridLod3StepKm, 25),
finite(settings.gridLod4StepKm, 100),
finite(settings.gridLod5StepKm, 500),
];
const modes = [1, 2, 3, 4, 5].map((number) => (
settings[`gridLod${number}Mode`] === "graticule" ? "graticule" : "3d"
));
return maximums.map((maxHeightKm, index) => ({
index, index,
id: `lod-${index + 1}`, 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); const bands = gridLodBands(settings);
if (!settings.gridLodEnabled) return bands[0]; if (!settings.gridLodEnabled) return bands[0];
const height = Math.max(0, finite(cameraHeightKm, 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) { if (!Number.isInteger(previousIndex) || previousIndex < 0 || previousIndex >= GRID_LOD_COUNT) {
return bands[directIndex]; return bands[directIndex];
} }
const previous = bands[previousIndex]; const previous = bands[previousIndex];
const lowerBoundary = previousIndex === 0 ? 0 : bands[previousIndex - 1].maxHeightKm; 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 lowerHold = lowerBoundary * (1 - GRID_LOD_HYSTERESIS_RATIO);
const upperHold = Number.isFinite(upperBoundary) const upperHold = Number.isFinite(upperBoundary)
? upperBoundary * (1 + GRID_LOD_HYSTERESIS_RATIO) ? 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]; return height >= lowerHold && height <= upperHold ? previous : bands[directIndex];
} }
export function resolveGridMode(settings, requestedMode, viewAngleFromNadirDegrees) { export function resolveGridMode(settings, requestedMode) {
if (requestedMode === "3d") { if (requestedMode === "3d") {
const maxAngle = clamp(finite(settings.gridMax3dViewAngleDegrees, 30), 0, 89); if (settings.grid3dEnabled !== false) return "3d";
if (settings.grid3dEnabled !== false && viewAngleFromNadirDegrees <= maxAngle) return "3d";
return settings.gridGraticuleEnabled === false ? "hidden" : "graticule"; return settings.gridGraticuleEnabled === false ? "hidden" : "graticule";
} }
if (settings.gridGraticuleEnabled !== false) return "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; return limit <= 0 || Math.max(0, finite(cameraHeightKm, 0)) <= limit;
} }
export function snapGridCenter({ latitude, longitude }, settings) { // Backward-compatible export. The camera argument is intentionally ignored:
if (settings.gridCenterMode === "fixed") { // a sector definition has one immutable origin and camera movement only
return { // changes LOD/visibility.
latitude: clamp(finite(settings.gridCenterLatitude, 55.7558), -89.999, 89.999), export function snapGridCenter(_cameraCenter, settings) {
longitude: clamp(finite(settings.gridCenterLongitude, 37.6173), -180, 180), return fixedGridOrigin(settings);
};
}
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,
};
} }
+62
View File
@@ -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 } }>;
};
+211
View File
@@ -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 };
}
+25
View File
@@ -958,6 +958,31 @@ textarea {
white-space: nowrap; 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 { .catalog-map-subject-card__tab-panel {
display: grid; display: grid;
gap: 0.65rem; gap: 0.65rem;
+30
View File
@@ -39,6 +39,8 @@
- Foundry session/auth boundary: `server/nodedc-auth.mjs`; - Foundry session/auth boundary: `server/nodedc-auth.mjs`;
- UI TileCache и health state: `apps/catalog/src/MapFixturePreview.tsx`; - UI TileCache и health state: `apps/catalog/src/MapFixturePreview.tsx`;
- Cesium adapter и provider startup: `apps/catalog/src/CesiumMapRenderer.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`; - Platform provider/cache boundary: `../../platform/services/map-gateway/src/server.mjs`;
- Map Gateway runtime topology: `../../platform/infra/synology/docker-compose.platform-http.yml`; - Map Gateway runtime topology: `../../platform/infra/synology/docker-compose.platform-http.yml`;
- NAS egress service: `../../platform/services/dc-amd-proxy/server.mjs`; - NAS egress service: `../../platform/services/dc-amd-proxy/server.mjs`;
@@ -160,6 +162,34 @@ Per-entity `PolygonGraphics` для массового слоя геозон н
цвета обновляет per-instance attributes без перестройки геометрии; изменение цвета обновляет per-instance attributes без перестройки геометрии; изменение
толщины границы или cursor создаёт новое bounded batch generation. толщины границы или 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. Credential model
### 5.1 Cesium Ion master token ### 5.1 Cesium Ion master token
+1 -1
View File
@@ -23,7 +23,7 @@
"test:map-animation": "node --test scripts/map-spiral.test.mjs scripts/map-camera-presets.test.mjs", "test:map-animation": "node --test scripts/map-spiral.test.mjs scripts/map-camera-presets.test.mjs",
"test:map-filters": "node --test scripts/map-presentation-filters.test.mjs", "test:map-filters": "node --test scripts/map-presentation-filters.test.mjs",
"test:hgeozone-projection": "node --test scripts/hgeozone-projection.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-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-inspector-overlay-state": "node --test scripts/map-inspector-overlay-state.test.mjs",
"test:map-reference-stations": "node --test scripts/map-reference-stations.test.mjs", "test:map-reference-stations": "node --test scripts/map-reference-stations.test.mjs",
+23 -14
View File
@@ -35,12 +35,13 @@
"gridLodEnabled": true, "gridLodEnabled": true,
"grid3dEnabled": true, "grid3dEnabled": true,
"gridGraticuleEnabled": true, "gridGraticuleEnabled": true,
"gridCenterMode": "camera", "gridCenterMode": "fixed",
"gridCenterLatitude": 55.7558, "gridCenterLatitude": 55.7558,
"gridCenterLongitude": 37.6173, "gridCenterLongitude": 37.6173,
"gridTileSizeKm": 10, "gridTileSizeKm": 10,
"gridAutoDisableHeightKm": 10000, "gridAutoDisableHeightKm": 10000,
"gridRebuildOnMoveEnd": true, "gridRebuildOnMoveEnd": true,
"gridLegacyMode": false,
"gridMax3dViewAngleDegrees": 30, "gridMax3dViewAngleDegrees": 30,
"gridHeightMeters": 500, "gridHeightMeters": 500,
"gridLod1MaxHeightKm": 10, "gridLod1MaxHeightKm": 10,
@@ -49,29 +50,37 @@
"gridLod2MaxHeightKm": 50, "gridLod2MaxHeightKm": 50,
"gridLod2StepKm": 5, "gridLod2StepKm": 5,
"gridLod2Mode": "3d", "gridLod2Mode": "3d",
"gridLod3MaxHeightKm": 180, "gridLod3MaxHeightKm": 200,
"gridLod3StepKm": 25, "gridLod3StepKm": 25,
"gridLod3Mode": "3d", "gridLod3Mode": "3d",
"gridLod4MaxHeightKm": 700, "gridLod4MaxHeightKm": 800,
"gridLod4StepKm": 100, "gridLod4StepKm": 50,
"gridLod4Mode": "graticule", "gridLod4Mode": "graticule",
"gridLod5StepKm": 500, "gridLod5MaxHeightKm": 3000,
"gridLod5StepKm": 50,
"gridLod5Mode": "graticule", "gridLod5Mode": "graticule",
"gridRadiusKm": 1000, "gridRadiusKm": 40,
"gridLineWidth": 4, "gridLineWidth": 1,
"gridLineDiameterMeters": 10, "gridLineDiameterMeters": 7,
"gridColor": "#f5f5f5", "gridColor": "#9c9c9c",
"gridOpacity": 12, "gridOpacity": 12,
"gridDotsEnabled": true, "gridDotsEnabled": true,
"gridDotsSize": 7, "gridDotsSize": 7,
"gridDotsDiameterMeters": 80, "gridDotsDiameterMeters": 10,
"gridDotsColor": "#ffffff", "gridDotsColor": "#9c9c9c",
"gridDotsOpacity": 58, "gridDotsOpacity": 58,
"gridCrossesEnabled": false, "gridCrossesEnabled": false,
"gridCrossesLengthMeters": 200, "gridCrossesLengthMeters": 60,
"gridCrossesWidthMeters": 10, "gridCrossesWidthMeters": 10,
"gridCrossesColor": "#35cfff", "gridCrossesColor": "#9c9c9c",
"gridCrossesOpacity": 50 "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, "mapHeight": 620,
"camera": { "camera": {
+229 -36
View File
@@ -2,6 +2,8 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import test from "node:test"; import test from "node:test";
import { import {
DEFAULT_GRID_LOD_PROFILES,
gridLodProfile,
gridShouldBeVisible, gridShouldBeVisible,
resolveGridMode, resolveGridMode,
selectGridLod, selectGridLod,
@@ -11,68 +13,259 @@ import {
const settings = { const settings = {
gridVisible: true, gridVisible: true,
gridLodEnabled: 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, grid3dEnabled: true,
gridGraticuleEnabled: true, gridGraticuleEnabled: true,
gridMax3dViewAngleDegrees: 30,
gridAutoDisableHeightKm: 10_000, gridAutoDisableHeightKm: 10_000,
gridCenterMode: "camera", gridCenterMode: "fixed",
gridTileSizeKm: 10, 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( 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); const band = selectGridLod(settings, height);
return [band.id, band.stepKm, band.mode]; return [band.id, band.stepKm, band.mode];
}), }),
[ [
["lod-1", 1, "3d"], ["lod-1", 1, "3d"],
["lod-1", 1, "3d"],
["lod-2", 5, "3d"],
["lod-2", 5, "3d"], ["lod-2", 5, "3d"],
["lod-3", 25, "3d"], ["lod-3", 25, "3d"],
["lod-4", 100, "graticule"], ["lod-3", 25, "3d"],
["lod-5", 500, "graticule"], ["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", () => { test("every LOD owns independent metric and angular spacing and visual fields", () => {
assert.equal(selectGridLod(settings, 10.5, 0).id, "lod-1"); const profiles = DEFAULT_GRID_LOD_PROFILES.map((profile, index) => ({
assert.equal(selectGridLod(settings, 10.9, 0).id, "lod-2"); ...profile,
assert.equal(selectGridLod(settings, 9.5, 1).id, "lod-2"); stepKm: 11 + index,
assert.equal(selectGridLod(settings, 9.1, 1).id, "lod-1"); 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", () => { test("legacy flat visuals migrate while angular graticule spacing stays independent", () => {
assert.equal(resolveGridMode(settings, "3d", 12), "3d"); const profile = gridLodProfile({
assert.equal(resolveGridMode(settings, "3d", 48), "graticule"); ...settings,
assert.equal(resolveGridMode({ ...settings, gridGraticuleEnabled: false }, "3d", 48), "hidden"); 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", () => { test("LOD hysteresis holds the previous band for eight percent on either side of a threshold", () => {
assert.equal(gridShouldBeVisible(settings, 9_999), true); assert.equal(selectGridLod(settings, 10.8, 0).id, "lod-1");
assert.equal(gridShouldBeVisible(settings, 10_001), false); 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 first = snapGridCenter({ latitude: 55.7558, longitude: 37.6173 }, settings);
const second = snapGridCenter({ latitude: 55.76, longitude: 37.62 }, settings); const second = snapGridCenter({ latitude: -22, longitude: -140 }, settings);
assert.deepEqual(first, second); 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"); const source = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8");
assert.match(source, /class GridLayerController/); assert.match(source, /class GridLayerController/);
assert.match(source, /dataSourceDisplay\.ready/); 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, /function rebuildElevatedGrid[\s\S]*?entities\.removeAll\(\)/);
assert.doesNotMatch(source, /Math\.min\(40,[\s\S]*?safeRadiusKm \/ safeStepKm/);
assert.doesNotMatch(source, /snapGridCenter\(/);
}); });
+2 -5
View File
@@ -70,10 +70,7 @@ test("facet tree selection focuses the subject without resetting a compatible de
assert.match(preview, /setSubjectCardTabId\(\(current\) =>/); assert.match(preview, /setSubjectCardTabId\(\(current\) =>/);
assert.match(preview, /profile\?\.tabs\.some\(\(tab\) => tab\.id === current\)/); assert.match(preview, /profile\?\.tabs\.some\(\(tab\) => tab\.id === current\)/);
assert.match(preview, /\(profile\?\.defaultTabId \?\? "overview"\)/); assert.match(preview, /\(profile\?\.defaultTabId \?\? "overview"\)/);
assert.match(renderer, /camera\.pickEllipsoid/); assert.match(renderer, /void viewer\.flyTo\(entity, \{/);
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, /duration: 0\.45/); assert.match(renderer, /duration: 0\.45/);
assert.match(renderer, /offset: new HeadingPitchRange\(0, -0\.9, 8_000\)/);
}); });
+231
View File
@@ -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);
});
+191 -21
View File
@@ -520,21 +520,183 @@ const MAP_PAGE_SETTING_KEYS = new Set([
"buildingsOpacity", "buildingsDetail", "imageryBrightness", "imageryContrast", "buildingsOpacity", "buildingsDetail", "imageryBrightness", "imageryContrast",
"imagerySaturation", "gridVisible", "gridLodEnabled", "gridHeightMeters", "imagerySaturation", "gridVisible", "gridLodEnabled", "gridHeightMeters",
"grid3dEnabled", "gridGraticuleEnabled", "gridCenterMode", "gridCenterLatitude", "grid3dEnabled", "gridGraticuleEnabled", "gridCenterMode", "gridCenterLatitude",
"gridCenterLongitude", "gridTileSizeKm", "gridAutoDisableHeightKm", "gridRebuildOnMoveEnd", "gridCenterLongitude", "gridTileSizeKm", "gridAutoDisableHeightKm", "gridRebuildOnMoveEnd", "gridLegacyMode",
"gridMax3dViewAngleDegrees", "gridMax3dViewAngleDegrees",
"gridLod1MaxHeightKm", "gridLod1StepKm", "gridLod2MaxHeightKm", "gridLod2StepKm", "gridLod1MaxHeightKm", "gridLod1StepKm", "gridLod2MaxHeightKm", "gridLod2StepKm",
"gridLod3MaxHeightKm", "gridLod3StepKm", "gridLod4MaxHeightKm", "gridLod4StepKm", "gridLod3MaxHeightKm", "gridLod3StepKm", "gridLod4MaxHeightKm", "gridLod4StepKm",
"gridLod5StepKm", "gridLod1Mode", "gridLod2Mode", "gridLod3Mode", "gridLod4Mode", "gridLod5MaxHeightKm", "gridLod5StepKm", "gridLod1Mode", "gridLod2Mode", "gridLod3Mode", "gridLod4Mode",
"gridLod5Mode", "gridRadiusKm", "gridLineWidth", "gridLineDiameterMeters", "gridColor", "gridOpacity", "gridLod5Mode", "gridRadiusKm", "gridLineWidth", "gridLineDiameterMeters", "gridColor", "gridOpacity",
"gridDotsEnabled", "gridDotsSize", "gridDotsDiameterMeters", "gridDotsColor", "gridDotsOpacity", "gridDotsEnabled", "gridDotsSize", "gridDotsDiameterMeters", "gridDotsColor", "gridDotsOpacity",
"gridCrossesEnabled", "gridCrossesLengthMeters", "gridCrossesWidthMeters", "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) { function validateMapPageSettingsPatch(value) {
if (!isObject(value) || Object.keys(value).length === 0) throw applicationError("invalid_map_page_settings_patch"); 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"); 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) { function validateMapInspectorOpenSections(value) {
@@ -565,7 +727,7 @@ function validateMapPageLayout(value) {
// Layouts written before this field existed are safely upgraded to the // Layouts written before this field existed are safely upgraded to the
// no-overwrite default during read/save; they are not rejected as damaged. // no-overwrite default during read/save; they are not rejected as damaged.
if (settings.cacheNoOverwrite !== undefined) requireBoolean(settings.cacheNoOverwrite, "invalid_map_page_setting_cacheNoOverwrite"); 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}`); if (settings[key] !== undefined) requireBoolean(settings[key], `invalid_map_page_setting_${key}`);
} }
requireString(settings.imagerySource, "invalid_map_page_imagery_source", 64); 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"]) { for (const key of ["monochromeColor", "globeColor", "backgroundColor", "buildingsColor", "gridColor", "gridDotsColor"]) {
requireHex(settings[key], `invalid_map_page_setting_${key}`); 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[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.gridCrossesColor !== undefined) requireHex(settings.gridCrossesColor, "invalid_map_page_setting_gridCrossesColor");
if (settings.gridCenterMode !== undefined && !["camera", "fixed"].includes(settings.gridCenterMode)) { if (settings.gridCenterMode !== undefined && !["camera", "fixed"].includes(settings.gridCenterMode)) {
throw applicationError("invalid_map_page_setting_gridCenterMode"); throw applicationError("invalid_map_page_setting_gridCenterMode");
@@ -587,6 +751,9 @@ function validateMapPageLayout(value) {
throw applicationError(`invalid_map_page_setting_${key}`); throw applicationError(`invalid_map_page_setting_${key}`);
} }
} }
const gridLodProfiles = validateGridLodProfiles(
settings.gridLodProfiles ?? promoteLegacyGridLodProfiles(settings),
);
const camera = value.camera; const camera = value.camera;
for (const key of ["longitude", "latitude", "height", "heading", "pitch", "roll"]) { 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}`); requireNumber(camera[key], -1_000_000_000, 1_000_000_000, `invalid_map_page_camera_${key}`);
@@ -652,7 +819,7 @@ function validateMapPageLayout(value) {
return { return {
schemaVersion: 1, schemaVersion: 1,
pageId: "map", 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"), mapHeight: requireInteger(value.mapHeight, 360, 5000, "invalid_map_page_height"),
camera: { camera: {
longitude: camera.longitude, longitude: camera.longitude,
@@ -736,12 +903,13 @@ function defaultMapPageLayout() {
gridLodEnabled: true, gridLodEnabled: true,
grid3dEnabled: true, grid3dEnabled: true,
gridGraticuleEnabled: true, gridGraticuleEnabled: true,
gridCenterMode: "camera", gridCenterMode: "fixed",
gridCenterLatitude: 55.7558, gridCenterLatitude: 55.7558,
gridCenterLongitude: 37.6173, gridCenterLongitude: 37.6173,
gridTileSizeKm: 10, gridTileSizeKm: 10,
gridAutoDisableHeightKm: 10000, gridAutoDisableHeightKm: 10000,
gridRebuildOnMoveEnd: true, gridRebuildOnMoveEnd: true,
gridLegacyMode: false,
gridMax3dViewAngleDegrees: 30, gridMax3dViewAngleDegrees: 30,
gridHeightMeters: 500, gridHeightMeters: 500,
gridLod1MaxHeightKm: 10, gridLod1MaxHeightKm: 10,
@@ -750,29 +918,31 @@ function defaultMapPageLayout() {
gridLod2MaxHeightKm: 50, gridLod2MaxHeightKm: 50,
gridLod2StepKm: 5, gridLod2StepKm: 5,
gridLod2Mode: "3d", gridLod2Mode: "3d",
gridLod3MaxHeightKm: 180, gridLod3MaxHeightKm: 200,
gridLod3StepKm: 25, gridLod3StepKm: 25,
gridLod3Mode: "3d", gridLod3Mode: "3d",
gridLod4MaxHeightKm: 700, gridLod4MaxHeightKm: 800,
gridLod4StepKm: 100, gridLod4StepKm: 50,
gridLod4Mode: "graticule", gridLod4Mode: "graticule",
gridLod5StepKm: 500, gridLod5MaxHeightKm: 3000,
gridLod5StepKm: 50,
gridLod5Mode: "graticule", gridLod5Mode: "graticule",
gridRadiusKm: 1000, gridRadiusKm: 40,
gridLineWidth: 4, gridLineWidth: 1,
gridLineDiameterMeters: 10, gridLineDiameterMeters: 7,
gridColor: "#f5f5f5", gridColor: "#9c9c9c",
gridOpacity: 12, gridOpacity: 12,
gridDotsEnabled: true, gridDotsEnabled: true,
gridDotsSize: 7, gridDotsSize: 7,
gridDotsDiameterMeters: 80, gridDotsDiameterMeters: 10,
gridDotsColor: "#ffffff", gridDotsColor: "#9c9c9c",
gridDotsOpacity: 58, gridDotsOpacity: 58,
gridCrossesEnabled: false, gridCrossesEnabled: false,
gridCrossesLengthMeters: 200, gridCrossesLengthMeters: 60,
gridCrossesWidthMeters: 10, gridCrossesWidthMeters: 10,
gridCrossesColor: "#35cfff", gridCrossesColor: "#9c9c9c",
gridCrossesOpacity: 50, gridCrossesOpacity: 46,
gridLodProfiles: structuredClone(DEFAULT_GRID_LOD_PROFILES),
}, },
mapHeight: 470, mapHeight: 470,
camera: { camera: {
+40 -3
View File
@@ -414,12 +414,13 @@ const mapPageSettingsPatchInputSchema = {
gridLodEnabled: { type: "boolean" }, gridLodEnabled: { type: "boolean" },
grid3dEnabled: { type: "boolean" }, grid3dEnabled: { type: "boolean" },
gridGraticuleEnabled: { type: "boolean" }, gridGraticuleEnabled: { type: "boolean" },
gridCenterMode: { type: "string", enum: ["camera", "fixed"] }, gridCenterMode: { type: "string", enum: ["fixed"] },
gridCenterLatitude: { type: "number" }, gridCenterLatitude: { type: "number", minimum: -90, maximum: 90 },
gridCenterLongitude: { type: "number" }, gridCenterLongitude: { type: "number", minimum: -180, maximum: 180 },
gridTileSizeKm: { type: "number" }, gridTileSizeKm: { type: "number" },
gridAutoDisableHeightKm: { type: "number" }, gridAutoDisableHeightKm: { type: "number" },
gridRebuildOnMoveEnd: { type: "boolean" }, gridRebuildOnMoveEnd: { type: "boolean" },
gridLegacyMode: { type: "boolean" },
gridMax3dViewAngleDegrees: { type: "number" }, gridMax3dViewAngleDegrees: { type: "number" },
gridHeightMeters: { type: "number" }, gridHeightMeters: { type: "number" },
gridLod1MaxHeightKm: { type: "number" }, gridLod1MaxHeightKm: { type: "number" },
@@ -434,6 +435,7 @@ const mapPageSettingsPatchInputSchema = {
gridLod4MaxHeightKm: { type: "number" }, gridLod4MaxHeightKm: { type: "number" },
gridLod4StepKm: { type: "number" }, gridLod4StepKm: { type: "number" },
gridLod4Mode: { type: "string", enum: ["3d", "graticule"] }, gridLod4Mode: { type: "string", enum: ["3d", "graticule"] },
gridLod5MaxHeightKm: { type: "number" },
gridLod5StepKm: { type: "number" }, gridLod5StepKm: { type: "number" },
gridLod5Mode: { type: "string", enum: ["3d", "graticule"] }, gridLod5Mode: { type: "string", enum: ["3d", "graticule"] },
gridRadiusKm: { type: "number" }, gridRadiusKm: { type: "number" },
@@ -451,6 +453,41 @@ const mapPageSettingsPatchInputSchema = {
gridCrossesWidthMeters: { type: "number" }, gridCrossesWidthMeters: { type: "number" },
gridCrossesColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" }, gridCrossesColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
gridCrossesOpacity: { type: "number" }, 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 },
},
},
},
}, },
}; };