feat(map): add seamless five-band grid LOD

This commit is contained in:
Codex
2026-08-05 19:36:01 +03:00
parent 2cd3b33141
commit 80e948c018
10 changed files with 638 additions and 62 deletions
+244 -47
View File
@@ -61,6 +61,12 @@ import {
type MapPresentationProfile,
} from "./mapPresentationProfile.js";
import { normalizeHGeoZoneRing } from "./hGeoZoneProjection.mjs";
import {
gridShouldBeVisible,
resolveGridMode,
selectGridLod,
snapGridCenter,
} from "./mapGridPolicy.mjs";
const MAX_SPIRAL_SUBSTEPS_PER_FRAME = 300;
const TERRAIN_SAMPLE_TIMEOUT_MS = 12_000;
@@ -212,20 +218,45 @@ export type MapPresentation = {
imagerySaturation: number;
gridVisible: boolean;
gridLodEnabled: boolean;
grid3dEnabled: boolean;
gridGraticuleEnabled: boolean;
gridCenterMode: "camera" | "fixed";
gridCenterLatitude: number;
gridCenterLongitude: number;
gridTileSizeKm: number;
gridAutoDisableHeightKm: number;
gridRebuildOnMoveEnd: boolean;
gridMax3dViewAngleDegrees: number;
gridHeightMeters: number;
gridLod1MaxHeightKm: number;
gridLod1StepKm: number;
gridLod1Mode: "3d" | "graticule";
gridLod2MaxHeightKm: number;
gridLod2StepKm: number;
gridLod2Mode: "3d" | "graticule";
gridLod3MaxHeightKm: number;
gridLod3StepKm: number;
gridLod3Mode: "3d" | "graticule";
gridLod4MaxHeightKm: number;
gridLod4StepKm: number;
gridLod4Mode: "3d" | "graticule";
gridLod5StepKm: number;
gridLod5Mode: "3d" | "graticule";
gridRadiusKm: number;
gridLineWidth: number;
gridLineDiameterMeters: number;
gridColor: string;
gridOpacity: number;
gridDotsEnabled: boolean;
gridDotsSize: number;
gridDotsDiameterMeters: number;
gridDotsColor: string;
gridDotsOpacity: number;
gridCrossesEnabled: boolean;
gridCrossesLengthMeters: number;
gridCrossesWidthMeters: number;
gridCrossesColor: string;
gridCrossesOpacity: number;
cacheRefresh: boolean;
};
@@ -872,23 +903,63 @@ function syncRuntimeDataSources(
viewer.scene.requestRender();
}
function rebuildElevatedGrid(viewer: Viewer, dataSource: CustomDataSource, presentation: MapPresentation) {
const entities = dataSource.entities;
entities.removeAll();
if (!presentation.gridVisible) return;
type GridLayerBuild = {
dataSource: CustomDataSource | null;
key: string;
lodIndex: number | null;
};
function buildGridLayer(
viewer: Viewer,
presentation: MapPresentation,
previousLodIndex: number | null,
serial: number,
): GridLayerBuild {
const cameraHeightKm = Math.max(0, Number(viewer.camera.positionCartographic?.height || 0) / 1000);
const stepKm = !presentation.gridLodEnabled || cameraHeightKm <= presentation.gridLod1MaxHeightKm
? presentation.gridLod1StepKm
: cameraHeightKm <= presentation.gridLod2MaxHeightKm
? presentation.gridLod2StepKm
: presentation.gridLod3StepKm;
const safeStepKm = clamp(stepKm, 0.25, 100);
const safeRadiusKm = clamp(presentation.gridRadiusKm, safeStepKm, 150);
const stepsPerSide = Math.min(32, Math.max(1, Math.floor(safeRadiusKm / safeStepKm)));
if (!gridShouldBeVisible(presentation, cameraHeightKm)) {
return { dataSource: null, key: "hidden", lodIndex: null };
}
const lod = selectGridLod(presentation, cameraHeightKm, previousLodIndex);
const pitchDegrees = CesiumMath.toDegrees(viewer.camera.pitch);
const viewAngleFromNadir = Math.abs(90 - Math.abs(pitchDegrees));
const mode = resolveGridMode(presentation, lod.mode, viewAngleFromNadir);
if (mode === "hidden") return { dataSource: null, key: "hidden", lodIndex: lod.index };
const safeStepKm = clamp(lod.stepKm, 0.25, 5_000);
const safeRadiusKm = clamp(presentation.gridRadiusKm, safeStepKm, 2_000);
const stepsPerSide = Math.min(40, Math.max(1, Math.floor(safeRadiusKm / safeStepKm)));
const cameraPosition = viewer.camera.positionCartographic;
const latitude = cameraPosition ? CesiumMath.toDegrees(cameraPosition.latitude) : 55.751244;
const longitude = cameraPosition ? CesiumMath.toDegrees(cameraPosition.longitude) : 37.618423;
const center = snapGridCenter({
latitude: cameraPosition ? CesiumMath.toDegrees(cameraPosition.latitude) : 55.751244,
longitude: cameraPosition ? CesiumMath.toDegrees(cameraPosition.longitude) : 37.618423,
}, presentation);
const latitude = center.latitude;
const longitude = center.longitude;
const key = JSON.stringify([
lod.index,
mode,
latitude,
longitude,
safeStepKm,
safeRadiusKm,
presentation.gridHeightMeters,
presentation.gridLineWidth,
presentation.gridLineDiameterMeters,
presentation.gridColor,
presentation.gridOpacity,
presentation.gridDotsEnabled,
presentation.gridDotsDiameterMeters,
presentation.gridDotsColor,
presentation.gridDotsOpacity,
presentation.gridCrossesEnabled,
presentation.gridCrossesLengthMeters,
presentation.gridCrossesWidthMeters,
presentation.gridCrossesColor,
presentation.gridCrossesOpacity,
]);
const dataSource = new CustomDataSource(`nodedc-map-grid:${serial}`);
const entities = dataSource.entities;
const metersPerLatitudeDegree = 110_574;
const metersPerLongitudeDegree = Math.max(1, 111_320 * Math.cos(CesiumMath.toRadians(latitude)));
const stepMeters = safeStepKm * 1000;
@@ -899,47 +970,167 @@ function rebuildElevatedGrid(viewer: Viewer, dataSource: CustomDataSource, prese
const radiusLongitude = radiusMeters / metersPerLongitudeDegree;
const lineColor = Color.fromCssColorString(presentation.gridColor).withAlpha(clamp(presentation.gridOpacity / 100, 0, 1));
const dotColor = Color.fromCssColorString(presentation.gridDotsColor).withAlpha(clamp(presentation.gridDotsOpacity / 100, 0, 1));
const crossColor = Color.fromCssColorString(presentation.gridCrossesColor).withAlpha(clamp(presentation.gridCrossesOpacity / 100, 0, 1));
const elevation = Math.max(0, presentation.gridHeightMeters);
const projected = mode === "graticule";
const geometryHeight = projected ? 0 : elevation;
const addGridLine = (id: string, positions: Cartesian3[]) => {
if (projected) {
entities.add({
id,
polyline: {
positions,
width: clamp(presentation.gridLineWidth, 1, 8),
material: lineColor,
clampToGround: true,
},
});
return;
}
entities.add({
id,
corridor: {
positions,
width: clamp(presentation.gridLineDiameterMeters, 1, 500),
material: lineColor,
height: geometryHeight,
heightReference: HeightReference.NONE,
},
});
};
for (let index = -stepsPerSide; index <= stepsPerSide; index += 1) {
const nextLatitude = latitude + index * deltaLatitude;
const nextLongitude = longitude + index * deltaLongitude;
entities.add({
polyline: {
positions: [
Cartesian3.fromDegrees(longitude - radiusLongitude, nextLatitude, elevation),
Cartesian3.fromDegrees(longitude + radiusLongitude, nextLatitude, elevation),
],
width: clamp(presentation.gridLineWidth, 1, 8),
material: lineColor,
},
});
entities.add({
polyline: {
positions: [
Cartesian3.fromDegrees(nextLongitude, latitude - radiusLatitude, elevation),
Cartesian3.fromDegrees(nextLongitude, latitude + radiusLatitude, elevation),
],
width: clamp(presentation.gridLineWidth, 1, 8),
material: lineColor,
},
});
addGridLine(`${serial}:latitude:${index}`, [
Cartesian3.fromDegrees(longitude - radiusLongitude, nextLatitude, geometryHeight),
Cartesian3.fromDegrees(longitude + radiusLongitude, nextLatitude, geometryHeight),
]);
addGridLine(`${serial}:longitude:${index}`, [
Cartesian3.fromDegrees(nextLongitude, latitude - radiusLatitude, geometryHeight),
Cartesian3.fromDegrees(nextLongitude, latitude + radiusLatitude, geometryHeight),
]);
}
if (!presentation.gridDotsEnabled) return;
const dotStride = Math.max(1, Math.ceil((stepsPerSide * 2 + 1) / 25));
for (let row = -stepsPerSide; row <= stepsPerSide; row += dotStride) {
for (let column = -stepsPerSide; column <= stepsPerSide; column += dotStride) {
entities.add({
position: Cartesian3.fromDegrees(longitude + column * deltaLongitude, latitude + row * deltaLatitude, elevation),
point: {
pixelSize: clamp(presentation.gridDotsSize, 2, 28),
color: dotColor,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
});
const dotLongitude = longitude + column * deltaLongitude;
const dotLatitude = latitude + row * deltaLatitude;
if (presentation.gridDotsEnabled) {
const diameterMeters = clamp(presentation.gridDotsDiameterMeters, 2, 2_000);
entities.add({
id: `${serial}:circle:${row}:${column}`,
position: Cartesian3.fromDegrees(dotLongitude, dotLatitude, geometryHeight),
ellipse: {
semiMajorAxis: diameterMeters / 2,
semiMinorAxis: diameterMeters / 2,
material: dotColor,
height: geometryHeight,
heightReference: projected ? HeightReference.CLAMP_TO_GROUND : HeightReference.NONE,
},
});
}
if (presentation.gridCrossesEnabled) {
const halfLengthMeters = clamp(presentation.gridCrossesLengthMeters, 2, 5_000) / 2;
const halfLatitude = halfLengthMeters / metersPerLatitudeDegree;
const halfLongitude = halfLengthMeters / metersPerLongitudeDegree;
const corridor = (positions: Cartesian3[]) => ({
positions,
width: clamp(presentation.gridCrossesWidthMeters, 1, 500),
material: crossColor,
height: geometryHeight,
heightReference: projected ? HeightReference.CLAMP_TO_GROUND : HeightReference.NONE,
});
entities.add({
id: `${serial}:cross-ns:${row}:${column}`,
corridor: corridor([
Cartesian3.fromDegrees(dotLongitude, dotLatitude - halfLatitude, geometryHeight),
Cartesian3.fromDegrees(dotLongitude, dotLatitude + halfLatitude, geometryHeight),
]),
});
entities.add({
id: `${serial}:cross-ew:${row}:${column}`,
corridor: corridor([
Cartesian3.fromDegrees(dotLongitude - halfLongitude, dotLatitude, geometryHeight),
Cartesian3.fromDegrees(dotLongitude + halfLongitude, dotLatitude, geometryHeight),
]),
});
}
}
}
return { dataSource, key, lodIndex: lod.index };
}
class GridLayerController {
private current: CustomDataSource | null = null;
private pending: CustomDataSource | null = null;
private key: string | null = null;
private lodIndex: number | null = null;
private epoch = 0;
private serial = 0;
private removeReadyListener: (() => void) | null = null;
private fallbackTimer: number | null = null;
constructor(private readonly viewer: Viewer) {}
rebuild(presentation: MapPresentation) {
const build = buildGridLayer(this.viewer, presentation, this.lodIndex, ++this.serial);
if (build.key === this.key) return;
this.key = build.key;
this.lodIndex = build.lodIndex;
const epoch = ++this.epoch;
this.cancelPending();
if (!build.dataSource) {
if (this.current) this.viewer.dataSources.remove(this.current, true);
this.current = null;
this.viewer.scene.requestRender();
return;
}
const next = build.dataSource;
this.pending = next;
void this.viewer.dataSources.add(next);
let readyFrames = 0;
const commit = () => {
if (epoch !== this.epoch || this.pending !== next) return;
this.removeReadyListener?.();
this.removeReadyListener = null;
if (this.fallbackTimer !== null) window.clearTimeout(this.fallbackTimer);
this.fallbackTimer = null;
const previous = this.current;
this.current = next;
this.pending = null;
if (previous && previous !== next) this.viewer.dataSources.remove(previous, true);
this.viewer.scene.requestRender();
};
this.removeReadyListener = this.viewer.scene.postRender.addEventListener(() => {
readyFrames = this.viewer.dataSourceDisplay.ready ? readyFrames + 1 : 0;
if (readyFrames >= 2) commit();
});
// A slow terrain worker must not leave an obsolete buffer mounted forever.
// The old layer stays visible during this grace period, so the fallback is
// bounded cleanup rather than a visible blank-before-build swap.
this.fallbackTimer = window.setTimeout(commit, 2_000);
this.viewer.scene.requestRender();
}
destroy() {
this.epoch += 1;
this.cancelPending();
if (this.current) this.viewer.dataSources.remove(this.current, true);
this.current = null;
}
private cancelPending() {
this.removeReadyListener?.();
this.removeReadyListener = null;
if (this.fallbackTimer !== null) window.clearTimeout(this.fallbackTimer);
this.fallbackTimer = null;
if (this.pending) this.viewer.dataSources.remove(this.pending, true);
this.pending = null;
}
}
function applyPresentation(
@@ -1527,7 +1718,9 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
let viewer: Viewer | undefined;
let handler: ScreenSpaceEventHandler | undefined;
let resizeObserver: ResizeObserver | undefined;
let gridController: GridLayerController | undefined;
let removeGridCameraListener: (() => void) | undefined;
let removeGridCameraChangedListener: (() => void) | undefined;
let removeRefreshRenderListener: (() => void) | undefined;
let removeRenderErrorListener: (() => void) | undefined;
const removeProviderFailureListeners: Array<() => void> = [];
@@ -1593,20 +1786,22 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
if (!endpointResponse.ok) throw new Error(`Map Gateway asset ${assetId}: ${endpointResponse.status}`);
return endpointResponse.json() as Promise<IonAssetEndpoint>;
};
const gridDataSource = new CustomDataSource("nodedc-map-grid");
viewer.dataSources.add(gridDataSource);
const terrain = { world: null as CesiumTerrainProvider | null, ellipsoid: new EllipsoidTerrainProvider() };
viewer.terrainProvider = terrain.ellipsoid;
viewer.scene.globe.depthTestAgainstTerrain = true;
viewerRef.current = viewer;
terrainRef.current = terrain;
const rebuildGrid = () => rebuildElevatedGrid(viewer!, gridDataSource, presentationRef.current);
gridController = new GridLayerController(viewer);
const rebuildGrid = () => gridController?.rebuild(presentationRef.current);
rebuildGridRef.current = rebuildGrid;
removeGridCameraListener = viewer.camera.moveEnd.addEventListener(() => {
rebuildGrid();
if (presentationRef.current.gridRebuildOnMoveEnd) rebuildGrid();
syncHGeoZoneVisibility(viewer!, hGeoZoneLayersRef.current);
onCameraChangeRef.current?.(getCameraView(viewer!));
});
removeGridCameraChangedListener = viewer.camera.changed.addEventListener(() => {
if (!presentationRef.current.gridRebuildOnMoveEnd) rebuildGrid();
});
const providerStatus: MapProviderStatus = {
imagery: config?.gatewayReady ? "loading" : "not-configured",
@@ -1818,10 +2013,12 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
onReadyChangeRef.current?.(false);
resizeObserver?.disconnect();
removeGridCameraListener?.();
removeGridCameraChangedListener?.();
removeRefreshRenderListener?.();
removeRenderErrorListener?.();
for (const removeListener of removeProviderFailureListeners) removeListener();
handler?.destroy();
gridController?.destroy();
if (viewer && !viewer.isDestroyed()) viewer.destroy();
viewerRef.current = null;
imageryLayerRef.current = null;