feat(map): add seamless five-band grid LOD
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user