Files
NODEDC_DESIGN_GUIDELINE/apps/catalog/src/CesiumMapRenderer.tsx
T

3187 lines
137 KiB
TypeScript

import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react";
import {
ArcType,
Cartesian2,
Cartesian3,
BingMapsImageryProvider,
BingMapsStyle,
BillboardGraphics,
BoundingSphere,
Color,
Credit,
Cesium3DTileset,
Cesium3DTileStyle,
CesiumTerrainProvider,
CallbackProperty,
CallbackPositionProperty,
Cartographic,
ClassificationType,
ColorGeometryInstanceAttribute,
ConstantPositionProperty,
CustomDataSource,
DefaultProxy,
EllipsoidTerrainProvider,
Entity,
GeometryInstance,
GroundPolylineGeometry,
GroundPolylinePrimitive,
GroundPrimitive,
HeightReference,
HeadingPitchRange,
HorizontalOrigin,
ImageryLayer,
JulianDate,
LabelCollection,
LabelGraphics,
LabelStyle,
Matrix4,
Math as CesiumMath,
PerInstanceColorAppearance,
PointGraphics,
PointPrimitiveCollection,
PolygonGeometry,
PolygonHierarchy,
PolylineColorAppearance,
PolylineGraphics,
PolylineCollection,
Resource,
sampleTerrainMostDetailed,
ScreenSpaceEventHandler,
ScreenSpaceEventType,
SunLight,
Transforms,
VerticalOrigin,
Viewer,
} from "cesium";
import "cesium/Build/Cesium/Widgets/widgets.css";
import { MAX_SPIRAL_RADIUS_METERS, spiralSurfaceFrame, type GeodeticRadians } from "./mapSpiralMath.js";
import { cameraSurveyPitchForViewport, cameraSurveySampleDistances, cameraSurveySpiralDistance } from "./mapCameraPresets.js";
import { mapRuntimeEntityId, type MapRuntimeBinding, type MapRuntimeFact } from "./useMapDataProductRuntime.js";
import {
mapPresentationProfileForFact,
mapRuntimeDisplayLabel,
mapRuntimeFactIsVisible,
resolveMapPresentationClass,
resolveMapPresentationStyle,
type MapPresentationFilters,
type MapPresentationProfile,
} from "./mapPresentationProfile.js";
import { normalizeHGeoZoneRing } from "./hGeoZoneProjection.mjs";
import {
gridShouldBeVisible,
resolveGridMode,
selectGridLod,
} from "./mapGridPolicy.mjs";
import {
boundedAngularParts,
fixedGridOrigin,
graticuleGranularity,
graticuleLinePlan,
graticuleMajorTileAt,
graticuleSectorAt,
graticuleSectorSummary,
isGraticuleMajorLineValue,
isLocalMajorLineIndex,
localGridPlan,
localMajorTileAt,
localSectorAt,
localSectorSummary,
localVolumeAt,
splitLongitudeRange,
} from "./mapSectorGrid.mjs";
const MAX_SPIRAL_SUBSTEPS_PER_FRAME = 300;
const TERRAIN_SAMPLE_TIMEOUT_MS = 12_000;
const SPIRAL_TILE_WAIT_TIMEOUT_MS = 45_000;
const MAX_HGEOZONE_INSTANCES_PER_BATCH = 256;
const elevatedTargetImageCache = new Map<string, string>();
function elevatedTargetImage(
fillColor: Color,
outlineColor: Color,
outlineWidthPx: number,
headSizePx: number,
) {
const safeHeadSize = Math.max(1, headSizePx);
const safeOutlineWidth = Math.max(0, outlineWidthPx);
const imageSize = Math.max(1, Math.ceil(safeHeadSize + safeOutlineWidth * 2));
const key = [
fillColor.toCssColorString(),
outlineColor.toCssColorString(),
safeOutlineWidth,
safeHeadSize,
imageSize,
].join("|");
const cached = elevatedTargetImageCache.get(key);
if (cached) return { image: cached, size: imageSize };
const center = imageSize / 2;
const radius = Math.max(0.5, safeHeadSize / 2);
const svg = [
`<svg xmlns="http://www.w3.org/2000/svg" width="${imageSize}" height="${imageSize}" viewBox="0 0 ${imageSize} ${imageSize}">`,
`<circle cx="${center}" cy="${center}" r="${radius}" fill="${fillColor.toCssColorString()}"`,
safeOutlineWidth > 0
? ` stroke="${outlineColor.toCssColorString()}" stroke-width="${safeOutlineWidth}"/>`
: "/>",
"</svg>",
].join("");
const image = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
elevatedTargetImageCache.set(key, image);
return { image, size: imageSize };
}
type RuntimeConfig = {
cesiumVersion: string;
provider: string;
ionReady: boolean;
gatewayReady: boolean;
osmBuildingsReady: boolean;
gaussianSplatsReady: boolean;
gaussianAssetId?: string | null;
assetEndpointBase: string;
resourceProxyBase?: string | null;
gatewayHealthUrl?: string | null;
};
export type MapGatewayHealth = {
cache?: {
mode?: string;
writePolicy?: string;
entries?: number;
bytes?: number;
maxBytes?: number;
atCapacity?: boolean;
persistent?: boolean;
};
diagnostics?: {
cacheHits?: number;
cacheMisses?: number;
cacheRefreshes?: number;
upstreamRequests?: number;
egressRequests?: number;
upstreamFailures?: number;
slowUpstreamRequests?: number;
lastFailure?: string | null;
lastFailureAt?: string | null;
};
referenceSources?: {
transportStations?: {
profileId?: string;
seedFactCount?: number;
fetchEnabled?: boolean;
cellDegrees?: number;
cachedCellCount?: number;
upstreamRequests?: number;
upstreamFailures?: number;
searchRequests?: number;
searchFailures?: number;
upstreamState?: "idle" | "ready" | "degraded";
activeFetches?: number;
queuedFetches?: number;
lastRefreshAt?: string | null;
lastFailure?: string | null;
lastFailureAt?: string | null;
};
};
ionConfigured?: boolean;
};
type MapProviderState = "loading" | "ready" | "error" | "not-configured";
// Provider loading must be observable independently. In particular, imagery
// is optional for scene construction: a Bing metadata failure must never
// prevent Cesium World Terrain from being requested and rendered.
export type MapProviderStatus = {
imagery: MapProviderState;
terrain: MapProviderState;
buildings: MapProviderState;
errors: Partial<Record<"imagery" | "terrain" | "buildings" | "projection", string>>;
};
type IonAssetEndpoint = {
assetId: string;
type: "TERRAIN" | "3DTILES" | "IMAGERY";
url?: string;
credentialMode?: "gateway";
externalType?: "BING";
options?: { url?: string; mapStyle?: string };
attributions: Array<{ html?: string; collapsible?: boolean }>;
};
export type MapPresentation = {
imagerySource: "cesium-live";
imageryVisible: boolean;
cacheEnabled: boolean;
cacheNoOverwrite: boolean;
terrainEnabled: boolean;
terrainExaggeration: number;
monochrome: boolean;
monochromeColor: string;
imageryGamma: number;
imageryHue: number;
imageryAlpha: number;
globeColor: string;
backgroundColor: string;
atmosphereEnabled: boolean;
atmosphereHue: number;
atmosphereSaturation: number;
atmosphereBrightness: number;
fogEnabled: boolean;
fogDensity: number;
sunEnabled: boolean;
sunHour: number;
sunIntensity: number;
shadowsEnabled: boolean;
buildingsVisible: boolean;
buildingsColor: string;
buildingsOpacity: number;
buildingsDetail: number;
imageryBrightness: number;
imageryContrast: number;
imagerySaturation: number;
gridVisible: boolean;
gridLodEnabled: boolean;
grid3dEnabled: boolean;
gridGraticuleEnabled: boolean;
gridCenterMode: "fixed";
gridCenterLatitude: number;
gridCenterLongitude: number;
gridTileSizeKm: number;
gridAutoDisableHeightKm: number;
gridRebuildOnMoveEnd: boolean;
gridLegacyMode: 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;
gridLod5MaxHeightKm: 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;
gridLodProfiles: GridLodProfile[];
cacheRefresh: boolean;
};
export type GridLodProfile = {
maxHeightKm: number;
stepKm: number;
mode: "3d" | "graticule";
heightMeters: number;
max3dViewAngleDegrees: number;
tileSizeKm: number;
radiusKm: number;
lineDiameterMeters: number;
lineColor: string;
lineOpacity: number;
dotsEnabled: boolean;
dotsDiameterMeters: number;
dotsColor: string;
dotsOpacity: number;
crossesEnabled: boolean;
crossesLengthMeters: number;
crossesWidthMeters: number;
crossesColor: string;
crossesOpacity: number;
graticuleStepDegrees: number;
graticuleLineWidthPx: number;
graticuleColor: string;
graticuleOpacity: number;
majorLinesEnabled: boolean;
majorLabelsEnabled: boolean;
majorLineWidthMultiplier: number;
volumeEnabled: boolean;
volumeMinimumHeightMeters: number;
volumeMaximumHeightMeters: number;
volumeBandHeightMeters: number;
selectionFillColor: string;
selectionFillOpacityPercent: number;
selectionOutlineColor: string;
selectionOutlineWidthPx: number;
selectionOutlineOpacityPercent: number;
};
export type GridVolumeSelection = {
id: string;
index: number;
floor: number;
ceiling: number;
bandHeight: number;
};
type LocalGridSectorSelection = ReturnType<typeof localSectorSummary> & {
mode: "3d";
units: "meters-enu";
volume: GridVolumeSelection | null;
};
type GraticuleGridSectorSelection = ReturnType<typeof graticuleSectorSummary> & {
mode: "graticule";
units: "degrees-wgs84";
volume: null;
};
export type GridSectorSelection = LocalGridSectorSelection | GraticuleGridSectorSelection;
export type GridMajorTileSelection = NonNullable<GridSectorSelection["parentMajorTile"]>;
export type MapCameraView = {
longitude: number;
latitude: number;
height: number;
heading: number;
pitch: number;
roll: number;
};
export type CameraSpiralConfig = {
heightAboveGroundMeters: number;
speedMetersPerSecond: number;
pitchMetersPerTurn: number;
targetRadiusMeters?: number;
viewPitchRadians?: number;
waitForTiles?: boolean;
};
export type CameraSpiralState = {
running: boolean;
reason?: "stopped" | "mode_disabled" | "renderer_restarted" | "page_hidden" | "render_error" | "spiral_extent_limit" | "spiral_runtime_error" | "terrain_sampling_error" | "tile_loading_timeout" | "tile_loading_error" | "target_radius_reached";
};
export type CesiumMapRendererHandle = {
startSpiralAnimation: (config: CameraSpiralConfig) => boolean;
stopSpiralAnimation: (reason?: CameraSpiralState["reason"]) => void;
getCameraView: () => MapCameraView | null;
fitRuntimeEntities: (entityIds?: string[]) => boolean;
focusRuntimeEntity: (entityId: string) => boolean;
focusSubjectCoordinates: (longitude: number, latitude: number) => boolean;
focusCoordinates: (longitude: number, latitude: number) => boolean;
focusGridSector: (sector: GridSectorSelection) => boolean;
focusGridMajorTile: (tile: GridMajorTileSelection) => boolean;
};
type TerrainRouteSample = {
distanceMeters: number;
heightMeters: number;
};
type SpiralSession = {
viewer: Viewer;
origin: GeodeticRadians;
initialHeading: number;
pitch: number;
roll: number;
config: CameraSpiralConfig;
surfaceDistanceMeters: number;
lastTimestamp: number | null;
pendingElapsedSeconds: number;
tilesWaitStartedAt: number | null;
requestId: number | null;
previousCameraInputsEnabled: boolean;
terrainProvider: CesiumTerrainProvider | null;
terrainSamples: TerrainRouteSample[];
terrainSamplePending: boolean;
terrainSampleExhausted: boolean;
terrainSampleFailures: number;
terrainRequestGeneration: number;
terrainRetryAt: number;
terrainSampleSpacingMeters: number;
targetSurfaceDistanceMeters: number;
buildingsTileset: Cesium3DTileset | null;
previousBuildingsCullRequestsWhileMoving: boolean | null;
previousBuildingsFoveatedScreenSpaceError: boolean | null;
previousBuildingsFoveatedTimeDelay: number | null;
};
const accent = Color.fromCssColorString("#ff2f92");
const violet = Color.fromCssColorString("#8f72dc");
const clamp = (value: number, minimum: number, maximum: number) => Math.max(minimum, Math.min(maximum, value));
function showBelowCameraHeight(viewer: Viewer, limit?: number) {
if (!limit) return true;
return new CallbackProperty(() => Number(viewer.camera.positionCartographic?.height || 0) <= limit, false);
}
function elevatedPinGroundHeight(viewer: Viewer, longitude: number, latitude: number, fallbackHeightMeters = 0) {
const sampled = viewer.scene.globe.getHeight(Cartographic.fromDegrees(longitude, latitude));
return Number.isFinite(sampled) ? Number(sampled) : fallbackHeightMeters;
}
function elevatedPinTopPosition(
viewer: Viewer,
longitude: number,
latitude: number,
stemHeightMeters: number,
fallbackHeightMeters = 0,
) {
return new CallbackPositionProperty(() => Cartesian3.fromDegrees(
longitude,
latitude,
elevatedPinGroundHeight(viewer, longitude, latitude, fallbackHeightMeters) + stemHeightMeters,
), false);
}
function elevatedPinStemPositions(
viewer: Viewer,
longitude: number,
latitude: number,
stemHeightMeters: number,
fallbackHeightMeters = 0,
) {
return new CallbackProperty(() => {
const groundHeight = elevatedPinGroundHeight(viewer, longitude, latitude, fallbackHeightMeters);
return [
Cartesian3.fromDegrees(longitude, latitude, groundHeight),
Cartesian3.fromDegrees(longitude, latitude, groundHeight + stemHeightMeters),
];
}, false);
}
function getCameraView(viewer: Viewer): MapCameraView {
const position = viewer.camera.positionCartographic;
return {
longitude: CesiumMath.toDegrees(position.longitude),
latitude: CesiumMath.toDegrees(position.latitude),
height: position.height,
heading: viewer.camera.heading,
pitch: viewer.camera.pitch,
roll: viewer.camera.roll,
};
}
function horizontalFieldOfViewRadians(viewer: Viewer) {
const frustum = viewer.camera.frustum as unknown as {
fovy?: number;
fov?: number;
aspectRatio?: number;
};
const aspectRatio = Number.isFinite(frustum.aspectRatio) && Number(frustum.aspectRatio) > 0
? Number(frustum.aspectRatio)
: Math.max(0.1, viewer.canvas.clientWidth / Math.max(1, viewer.canvas.clientHeight));
if (Number.isFinite(frustum.fovy) && Number(frustum.fovy) > 0) {
return 2 * Math.atan(Math.tan(Number(frustum.fovy) / 2) * aspectRatio);
}
if (Number.isFinite(frustum.fov) && Number(frustum.fov) > 0) return Number(frustum.fov);
return Math.PI / 3;
}
function interpolateTerrainRouteHeight(samples: TerrainRouteSample[], distanceMeters: number) {
if (samples.length === 0) return null;
if (distanceMeters === samples[0].distanceMeters) return samples[0].heightMeters;
for (let index = 1; index < samples.length; index += 1) {
const upper = samples[index];
if (distanceMeters > upper.distanceMeters) continue;
const lower = samples[index - 1];
const span = upper.distanceMeters - lower.distanceMeters;
if (span <= 0) return upper.heightMeters;
const progress = clamp((distanceMeters - lower.distanceMeters) / span, 0, 1);
return lower.heightMeters + (upper.heightMeters - lower.heightMeters) * progress;
}
return null;
}
function runtimePointColor(fact: MapRuntimeFact) {
// This is a semantic default for the generic Map entity-stream adapter,
// not a provider style. A renderer-neutral style profile can refine it
// later without changing a data product or its L2 workflow.
if (fact.presentationStatus === "stale") return Color.fromCssColorString("#f5a623");
if (["inactive", "no-position", "no_position"].includes(fact.presentationStatus)) {
return Color.fromCssColorString("#7d8491");
}
return fact.semanticType === "map.moving_object" ? accent : violet;
}
type HGeoZonePickId = {
kind: "nodedc-hgeozone";
entityId: string;
instanceId: string;
};
type HGeoZoneFillPart = {
pickId: HGeoZonePickId;
hierarchy: PolygonHierarchy;
color: Color;
};
type HGeoZoneOutlinePart = {
pickId: HGeoZonePickId;
positions: Cartesian3[];
color: Color;
};
type HGeoZonePrimitiveBatch<TPart> = {
primitive: GroundPrimitive | GroundPolylinePrimitive;
parts: TPart[];
};
type HGeoZoneProjectionLayer = {
geometryKey: string;
styleKey: string;
hideCameraHeightMeters: number | null;
fills: Array<HGeoZonePrimitiveBatch<HGeoZoneFillPart>>;
outlines: Array<HGeoZonePrimitiveBatch<HGeoZoneOutlinePart>>;
};
function hGeoZoneRingPositions(ring: Array<[number, number]>) {
return normalizeHGeoZoneRing(ring)
.map(([longitude, latitude]) => Cartesian3.fromDegrees(longitude, latitude, 0));
}
function hGeoZoneHierarchy(polygon: Array<Array<[number, number]>>) {
const outer = hGeoZoneRingPositions(polygon[0]);
if (outer.length < 3) return null;
const holes = polygon.slice(1)
.map((ring) => hGeoZoneRingPositions(ring))
.filter((ring) => ring.length >= 3)
.map((ring) => new PolygonHierarchy(ring));
return new PolygonHierarchy(outer, holes);
}
function hGeoZoneBatches<T>(items: T[]) {
const batches: T[][] = [];
for (let index = 0; index < items.length; index += MAX_HGEOZONE_INSTANCES_PER_BATCH) {
batches.push(items.slice(index, index + MAX_HGEOZONE_INSTANCES_PER_BATCH));
}
return batches;
}
function removeHGeoZoneLayer(viewer: Viewer, layer: HGeoZoneProjectionLayer) {
for (const batch of [...layer.fills, ...layer.outlines]) {
viewer.scene.groundPrimitives.remove(batch.primitive);
}
}
function hGeoZoneFaultKey(bindingId: string, geometryKey: string) {
return `${bindingId}\u0000${geometryKey}`;
}
function quarantineHGeoZoneLayers(
viewer: Viewer,
layers: Map<string, HGeoZoneProjectionLayer>,
faultedGeometryKeys: Set<string>,
) {
if (!layers.size) return false;
const hasPendingGeometry = [...layers.values()].some((layer) => (
[...layer.fills, ...layer.outlines].some((batch) => !batch.primitive.ready)
));
if (!hasPendingGeometry) return false;
for (const [bindingId, layer] of layers) {
faultedGeometryKeys.add(hGeoZoneFaultKey(bindingId, layer.geometryKey));
removeHGeoZoneLayer(viewer, layer);
}
layers.clear();
viewer.scene.requestRender();
return true;
}
function syncHGeoZoneVisibility(viewer: Viewer, layers: Map<string, HGeoZoneProjectionLayer>) {
const cameraHeight = Number(viewer.camera.positionCartographic?.height || 0);
for (const layer of layers.values()) {
const show = layer.hideCameraHeightMeters === null || cameraHeight <= layer.hideCameraHeightMeters;
for (const batch of [...layer.fills, ...layer.outlines]) batch.primitive.show = show;
}
viewer.scene.requestRender();
}
function updateHGeoZoneColors<TPart extends { pickId: HGeoZonePickId; color: Color }>(
batches: Array<HGeoZonePrimitiveBatch<TPart>>,
nextParts: TPart[],
) {
const nextColors = new Map(nextParts.map((part) => [part.pickId.instanceId, part.color]));
for (const batch of batches) {
for (const part of batch.parts) {
const color = nextColors.get(part.pickId.instanceId);
if (!color) continue;
const attributes = batch.primitive.getGeometryInstanceAttributes(part.pickId);
if (attributes) attributes.color = ColorGeometryInstanceAttribute.toValue(color);
part.color = color;
}
}
}
function createHGeoZoneLayer(
viewer: Viewer,
geometryKey: string,
styleKey: string,
hideCameraHeightMeters: number | null,
fillParts: HGeoZoneFillPart[],
outlineParts: HGeoZoneOutlinePart[],
outlineWidthPx: number,
) {
const fills = hGeoZoneBatches(fillParts).map((parts) => {
const primitive = viewer.scene.groundPrimitives.add(new GroundPrimitive({
geometryInstances: parts.map((part) => new GeometryInstance({
id: part.pickId,
geometry: new PolygonGeometry({
polygonHierarchy: part.hierarchy,
vertexFormat: PerInstanceColorAppearance.FLAT_VERTEX_FORMAT,
}),
attributes: { color: ColorGeometryInstanceAttribute.fromColor(part.color) },
})),
appearance: new PerInstanceColorAppearance({ flat: true, translucent: true }),
allowPicking: true,
asynchronous: true,
classificationType: ClassificationType.TERRAIN,
releaseGeometryInstances: true,
}));
return { primitive, parts };
});
const outlines = outlineWidthPx <= 0 ? [] : hGeoZoneBatches(outlineParts).map((parts) => {
const primitive = viewer.scene.groundPrimitives.add(new GroundPolylinePrimitive({
geometryInstances: parts.map((part) => new GeometryInstance({
id: part.pickId,
geometry: new GroundPolylineGeometry({
positions: part.positions,
width: outlineWidthPx,
loop: true,
}),
attributes: { color: ColorGeometryInstanceAttribute.fromColor(part.color) },
})),
appearance: new PolylineColorAppearance({ translucent: true }),
allowPicking: true,
asynchronous: true,
classificationType: ClassificationType.TERRAIN,
releaseGeometryInstances: true,
}));
return { primitive, parts };
});
const layer = { geometryKey, styleKey, hideCameraHeightMeters, fills, outlines };
syncHGeoZoneVisibility(viewer, new Map([["layer", layer]]));
return layer;
}
function syncHGeoZoneLayers(
viewer: Viewer,
layers: Map<string, HGeoZoneProjectionLayer>,
bindings: MapRuntimeBinding[],
presentationProfiles: MapPresentationProfile[],
presentationFilters: MapPresentationFilters,
faultedGeometryKeys: Set<string>,
) {
const activeBindings = new Set(bindings.filter((binding) => binding.slotId === "zones").map((binding) => binding.bindingId));
for (const [bindingId, layer] of layers) {
if (activeBindings.has(bindingId)) continue;
removeHGeoZoneLayer(viewer, layer);
layers.delete(bindingId);
}
for (const binding of bindings) {
if (binding.slotId !== "zones") continue;
const fillParts: HGeoZoneFillPart[] = [];
const outlineParts: HGeoZoneOutlinePart[] = [];
const geometryMembers: string[] = [];
let outlineWidthPx = 1.5;
let hideCameraHeightMeters: number | null = null;
let outlineColor = Color.fromCssColorString("#c9b6ff").withAlpha(0.9);
const styleMembers: string[] = [];
for (const fact of binding.facts) {
const profile = mapPresentationProfileForFact(
presentationProfiles,
binding.presentationProfileId,
fact.semanticType,
);
if (
!fact.geometry
|| fact.geometry.type === "Point"
|| (profile && profile.target.variant !== "surface-fill")
|| (profile && !mapRuntimeFactIsVisible(fact, profile, presentationFilters, binding.bindingId))
) continue;
const presentationClass = profile ? resolveMapPresentationClass(fact, profile) : undefined;
const resolvedStyle = profile ? resolveMapPresentationStyle(profile, presentationClass) : undefined;
const fillColor = resolvedStyle
? Color.fromCssColorString(resolvedStyle.color).withAlpha(resolvedStyle.opacity)
: runtimePointColor(fact).withAlpha(0.28);
if (profile?.target.variant === "surface-fill") {
outlineWidthPx = profile.target.outlineWidthPx;
hideCameraHeightMeters = profile.target.hideCameraHeightMeters;
outlineColor = Color.fromCssColorString(profile.target.outlineColor).withAlpha(profile.target.outlineOpacity);
}
const polygons = fact.geometry.type === "Polygon" ? [fact.geometry.coordinates] : fact.geometry.coordinates;
const baseEntityId = mapRuntimeEntityId(binding.bindingId, fact);
for (const [polygonIndex, polygon] of polygons.entries()) {
const hierarchy = hGeoZoneHierarchy(polygon);
if (!hierarchy) continue;
const instanceBase = `${baseEntityId}:part:${polygonIndex}`;
const fillPickId: HGeoZonePickId = {
kind: "nodedc-hgeozone",
entityId: baseEntityId,
instanceId: `${instanceBase}:fill`,
};
fillParts.push({ pickId: fillPickId, hierarchy, color: fillColor });
geometryMembers.push(fillPickId.instanceId);
styleMembers.push(`${fillPickId.instanceId}:${resolvedStyle?.id ?? "default"}:${fillColor.toCssHexString()}:${fillColor.alpha}`);
for (const [ringIndex, ring] of polygon.entries()) {
const positions = hGeoZoneRingPositions(ring);
if (positions.length < 3) continue;
const outlinePickId: HGeoZonePickId = {
kind: "nodedc-hgeozone",
entityId: baseEntityId,
instanceId: `${instanceBase}:ring:${ringIndex}`,
};
outlineParts.push({ pickId: outlinePickId, positions, color: outlineColor });
geometryMembers.push(outlinePickId.instanceId);
}
}
}
const geometryKey = JSON.stringify([binding.cursor, outlineWidthPx, geometryMembers]);
const styleKey = JSON.stringify([styleMembers, outlineColor.toCssHexString(), outlineColor.alpha]);
const current = layers.get(binding.bindingId);
if (!fillParts.length) {
if (current) removeHGeoZoneLayer(viewer, current);
layers.delete(binding.bindingId);
continue;
}
if (faultedGeometryKeys.has(hGeoZoneFaultKey(binding.bindingId, geometryKey))) {
if (current) removeHGeoZoneLayer(viewer, current);
layers.delete(binding.bindingId);
continue;
}
if (current?.geometryKey === geometryKey) {
current.hideCameraHeightMeters = hideCameraHeightMeters;
if (current.styleKey !== styleKey) {
const ready = [...current.fills, ...current.outlines].every((batch) => batch.primitive.ready);
if (ready) {
updateHGeoZoneColors(current.fills, fillParts);
updateHGeoZoneColors(current.outlines, outlineParts);
current.styleKey = styleKey;
syncHGeoZoneVisibility(viewer, layers);
continue;
}
} else {
syncHGeoZoneVisibility(viewer, layers);
continue;
}
}
if (current) removeHGeoZoneLayer(viewer, current);
layers.set(binding.bindingId, createHGeoZoneLayer(
viewer,
geometryKey,
styleKey,
hideCameraHeightMeters,
fillParts,
outlineParts,
outlineWidthPx,
));
}
viewer.scene.requestRender();
}
function syncRuntimeDataSources(
viewer: Viewer,
dataSources: Map<string, CustomDataSource>,
hGeoZoneLayers: Map<string, HGeoZoneProjectionLayer>,
bindings: MapRuntimeBinding[],
presentationProfiles: MapPresentationProfile[],
presentationFilters: MapPresentationFilters,
faultedHGeoZoneGeometryKeys: Set<string>,
) {
const activeBindings = new Map(bindings
.filter((binding) => (
binding.slotId === "points"
|| binding.slotId === "reference-points"
|| binding.slotId === "zones"
))
.map((binding) => [binding.bindingId, binding]));
for (const [bindingId, dataSource] of dataSources) {
if (activeBindings.has(bindingId)) continue;
viewer.dataSources.remove(dataSource, true);
dataSources.delete(bindingId);
}
for (const binding of activeBindings.values()) {
let dataSource = dataSources.get(binding.bindingId);
if (!dataSource) {
dataSource = new CustomDataSource(`nodedc-map-slot:${binding.bindingId}`);
viewer.dataSources.add(dataSource);
dataSources.set(binding.bindingId, dataSource);
}
const wanted = new Set<string>();
for (const fact of binding.facts) {
const profile = mapPresentationProfileForFact(
presentationProfiles,
binding.presentationProfileId,
fact.semanticType,
);
const presentationClass = profile ? resolveMapPresentationClass(fact, profile) : undefined;
if (
!fact.geometry
|| (profile && !mapRuntimeFactIsVisible(fact, profile, presentationFilters, binding.bindingId))
) continue;
const resolvedStyle = profile ? resolveMapPresentationStyle(profile, presentationClass) : undefined;
const color = resolvedStyle
? Color.fromCssColorString(resolvedStyle.color).withAlpha(resolvedStyle.opacity)
: runtimePointColor(fact);
const label = mapRuntimeDisplayLabel(fact, profile);
const baseEntityId = mapRuntimeEntityId(binding.bindingId, fact);
if (fact.geometry.type === "Point" && (binding.slotId === "points" || binding.slotId === "reference-points")) {
if (profile && profile.target.variant !== "elevated-spike") continue;
const entityId = baseEntityId;
wanted.add(entityId);
const [longitude, latitude] = fact.geometry.coordinates;
const entity = dataSource.entities.getById(entityId) ?? dataSource.entities.add({ id: entityId });
entity.name = label;
entity.polygon = undefined;
if (!profile) {
entity.billboard = undefined;
entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(longitude, latitude, 0));
entity.polyline = undefined;
entity.point = new PointGraphics({
pixelSize: 10,
color,
outlineColor: Color.TRANSPARENT,
outlineWidth: 0,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
entity.label = new LabelGraphics({
text: label,
font: "700 13px Arial",
fillColor: Color.WHITE,
outlineColor: Color.TRANSPARENT,
outlineWidth: 0,
style: LabelStyle.FILL,
showBackground: true,
backgroundColor: Color.BLACK.withAlpha(0.72),
backgroundPadding: new Cartesian2(10, 7),
pixelOffset: new Cartesian2(10, 0),
horizontalOrigin: HorizontalOrigin.LEFT,
verticalOrigin: VerticalOrigin.BOTTOM,
heightReference: HeightReference.NONE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
continue;
}
if (profile.target.variant !== "elevated-spike") continue;
const target = profile.target;
const fallbackHeightMeters = typeof fact.attributes.elevation_meters === "number" && Number.isFinite(fact.attributes.elevation_meters)
? fact.attributes.elevation_meters
: 0;
entity.position = elevatedPinTopPosition(
viewer,
longitude,
latitude,
target.stemHeightMeters,
fallbackHeightMeters,
);
entity.polyline = new PolylineGraphics({
positions: elevatedPinStemPositions(
viewer,
longitude,
latitude,
target.stemHeightMeters,
fallbackHeightMeters,
),
width: target.stemWidthPx,
material: color,
show: showBelowCameraHeight(viewer, target.hideCameraHeightMeters),
});
const targetImage = elevatedTargetImage(
color,
Color.fromCssColorString(target.outlineColor).withAlpha(target.outlineOpacity),
target.outlineWidthPx,
target.headSizePx,
);
entity.point = undefined;
entity.billboard = new BillboardGraphics({
image: targetImage.image,
width: targetImage.size,
height: targetImage.size,
horizontalOrigin: HorizontalOrigin.CENTER,
verticalOrigin: VerticalOrigin.CENTER,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
show: showBelowCameraHeight(viewer, target.hideCameraHeightMeters),
});
entity.label = new LabelGraphics({
text: label,
font: `${profile.label.fontWeight} ${profile.label.sizePx}px Arial`,
fillColor: Color.fromCssColorString(profile.label.color),
outlineColor: Color.TRANSPARENT,
outlineWidth: 0,
style: LabelStyle.FILL,
showBackground: profile.label.backgroundOpacity > 0,
backgroundColor: Color.fromCssColorString(profile.label.backgroundColor).withAlpha(profile.label.backgroundOpacity),
backgroundPadding: new Cartesian2(profile.label.paddingX, profile.label.paddingY),
pixelOffset: new Cartesian2(profile.label.offsetX, profile.label.offsetY),
horizontalOrigin: HorizontalOrigin.LEFT,
verticalOrigin: VerticalOrigin.BOTTOM,
heightReference: HeightReference.NONE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
show: profile.label.mode !== "none" && showBelowCameraHeight(viewer, profile.label.hideCameraHeightMeters),
});
continue;
}
if (
binding.slotId !== "zones"
|| fact.geometry.type === "Point"
|| (profile && profile.target.variant !== "surface-fill")
) continue;
const polygons = fact.geometry.type === "Polygon" ? [fact.geometry.coordinates] : fact.geometry.coordinates;
const outerRing = normalizeHGeoZoneRing(polygons[0]?.[0] ?? []);
if (!outerRing.length) continue;
wanted.add(baseEntityId);
const labelAnchor = outerRing.reduce(
(accumulator, [longitude, latitude]) => [accumulator[0] + longitude, accumulator[1] + latitude] as [number, number],
[0, 0] as [number, number],
);
const divisor = Math.max(1, outerRing.length);
const entity = dataSource.entities.getById(baseEntityId) ?? dataSource.entities.add({ id: baseEntityId });
entity.name = label;
entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(labelAnchor[0] / divisor, labelAnchor[1] / divisor, 0));
entity.billboard = undefined;
entity.point = undefined;
entity.polygon = undefined;
entity.polyline = undefined;
entity.label = new LabelGraphics({
text: label,
font: profile ? `${profile.label.fontWeight} ${profile.label.sizePx}px Arial` : "700 13px Arial",
fillColor: profile ? Color.fromCssColorString(profile.label.color) : Color.WHITE,
outlineColor: Color.TRANSPARENT,
outlineWidth: 0,
style: LabelStyle.FILL,
showBackground: (profile?.label.backgroundOpacity ?? 0.72) > 0,
backgroundColor: profile
? Color.fromCssColorString(profile.label.backgroundColor).withAlpha(profile.label.backgroundOpacity)
: Color.BLACK.withAlpha(0.72),
backgroundPadding: new Cartesian2(profile?.label.paddingX ?? 10, profile?.label.paddingY ?? 7),
pixelOffset: new Cartesian2(profile?.label.offsetX ?? 10, profile?.label.offsetY ?? 0),
horizontalOrigin: HorizontalOrigin.LEFT,
verticalOrigin: VerticalOrigin.BOTTOM,
heightReference: HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
show: (profile?.label.mode ?? "attributes") !== "none"
&& (profile ? showBelowCameraHeight(viewer, profile.label.hideCameraHeightMeters) : true),
});
}
for (const entity of [...dataSource.entities.values]) {
if (typeof entity.id === "string" && !wanted.has(entity.id)) dataSource.entities.remove(entity);
}
}
syncHGeoZoneLayers(
viewer,
hGeoZoneLayers,
bindings,
presentationProfiles,
presentationFilters,
faultedHGeoZoneGeometryKeys,
);
viewer.scene.requestRender();
}
type GridLodBand = GridLodProfile & { index: number; id: string };
type LocalGridAddressing = {
mode: "3d";
lod: number;
stepMeters: number;
radiusMeters: number;
originLatitude: number;
originLongitude: number;
enu: Matrix4;
inverseEnu: Matrix4;
heightMeters: number;
tileSizeMeters: number;
majorStepMeters: number | null;
lineColor: string;
lineOpacity: number;
majorLineWidthMultiplier: number;
volumeEnabled: boolean;
volumeMinimumHeightMeters: number;
volumeMaximumHeightMeters: number;
volumeBandHeightMeters: number;
selectionFillColor: string;
selectionFillOpacityPercent: number;
selectionOutlineColor: string;
selectionOutlineWidthPx: number;
selectionOutlineOpacityPercent: number;
};
type GraticuleGridAddressing = {
mode: "graticule";
lod: number;
stepDegrees: number;
majorStepDegrees: number | null;
heightMeters: number;
lineWidthPixels: number;
lineColor: string;
lineOpacity: number;
majorLineWidthMultiplier: number;
selectionFillColor: string;
selectionFillOpacityPercent: number;
selectionOutlineColor: string;
selectionOutlineWidthPx: number;
selectionOutlineOpacityPercent: number;
};
type GridAddressing = LocalGridAddressing | GraticuleGridAddressing;
type GridResources = {
dataSource: CustomDataSource;
points: PointPrimitiveCollection | null;
crosses: PolylineCollection | null;
labels: LabelCollection | null;
};
type HiddenGridPlan = { mode: "hidden"; key: string; lodIndex: number | null };
type LocalGridLayerPlan = {
mode: "3d";
key: string;
lodIndex: number;
lod: GridLodBand;
origin: { latitude: number; longitude: number };
enu: Matrix4;
inverseEnu: Matrix4;
grid: ReturnType<typeof localGridPlan>;
majorGrid: ReturnType<typeof localGridPlan> | null;
majorStepMeters: number | null;
metersPerPixel: number;
cameraHeightMeters: number;
cameraPosition: Cartesian3;
cameraDirection: Cartesian3;
};
type GraticuleGridLayerPlan = {
mode: "graticule";
key: string;
lodIndex: number;
lod: GridLodBand;
grid: ReturnType<typeof graticuleLinePlan>;
majorStepDegrees: number | null;
};
type GridLayerPlan = HiddenGridPlan | LocalGridLayerPlan | GraticuleGridLayerPlan;
function integerMultipleRatio(outer: number, inner: number) {
if (!Number.isFinite(outer) || !Number.isFinite(inner) || outer <= 0 || inner <= 0) return null;
const ratio = outer / inner;
const rounded = Math.round(ratio);
return Number.isSafeInteger(rounded) && rounded >= 1 && Math.abs(ratio - rounded) <= 1e-9 * Math.max(1, ratio)
? rounded
: null;
}
function localMajorStepMeters(lod: GridLodBand, stepMeters: number) {
const tileSizeMeters = lod.tileSizeKm * 1_000;
return integerMultipleRatio(tileSizeMeters, stepMeters) === null ? null : tileSizeMeters;
}
function graticuleMajorStepDegrees(stepDegrees: number) {
const candidate = stepDegrees * 5;
return integerMultipleRatio(90, candidate) === null ? null : candidate;
}
function gridMajorColor(cssColor: string, opacity: number) {
// Major boundaries inherit the profile colour and opacity. Their hierarchy
// is expressed only through the persisted width multiplier.
return Color.fromCssColorString(cssColor).withAlpha(clamp(opacity / 100, 0, 1));
}
function gridMetersPerPixel(viewer: Viewer) {
const frustum = viewer.camera.frustum as unknown as { fovy?: number };
return Math.max(0.01, (
Math.max(1, Number(viewer.camera.positionCartographic?.height || 1))
* 2 * Math.tan((Number(frustum.fovy) || Math.PI / 3) / 2)
) / Math.max(1, viewer.scene.canvas.clientHeight));
}
function graticuleViewport(viewer: Viewer, stepDegrees: number, cameraHeightKm: number) {
const ellipsoid = viewer.scene.globe.ellipsoid;
const margin = Math.max(stepDegrees * 2, Math.min(30, cameraHeightKm / 200));
const alignLatitude = (value: number, direction: "down" | "up") => clamp(
(direction === "down" ? Math.floor(value / stepDegrees) : Math.ceil(value / stepDegrees)) * stepDegrees,
-89.9,
89.9,
);
const alignIntervals = (west: number, east: number) => splitLongitudeRange(west - margin, east + margin).map((interval) => ({
west: interval.west <= -180 ? -180 : Math.floor(interval.west / stepDegrees) * stepDegrees,
east: interval.east >= 180 ? 180 : Math.ceil(interval.east / stepDegrees) * stepDegrees,
}));
const rectangle = viewer.camera.computeViewRectangle(ellipsoid);
if (rectangle) {
const south = CesiumMath.toDegrees(rectangle.south);
const north = CesiumMath.toDegrees(rectangle.north);
const west = CesiumMath.toDegrees(rectangle.west);
const east = CesiumMath.toDegrees(rectangle.east);
return {
south: alignLatitude(south - margin, "down"),
north: alignLatitude(north + margin, "up"),
longitudeIntervals: alignIntervals(west, east),
};
}
const camera = viewer.camera.positionCartographic;
const latitude = camera ? CesiumMath.toDegrees(camera.latitude) : 0;
const longitude = camera ? CesiumMath.toDegrees(camera.longitude) : 0;
const horizon = CesiumMath.toDegrees(Math.acos(clamp(
ellipsoid.maximumRadius / (ellipsoid.maximumRadius + Math.max(0, cameraHeightKm * 1_000)),
-1,
1,
)));
const range = Math.min(180, Math.max(10, horizon * 2));
return {
south: alignLatitude(latitude - range - margin, "down"),
north: alignLatitude(latitude + range + margin, "up"),
longitudeIntervals: alignIntervals(longitude - range - margin, longitude + range + margin),
};
}
function planGridLayer(viewer: Viewer, presentation: MapPresentation, previousLodIndex: number | null): GridLayerPlan {
const cameraHeightKm = Math.max(0, Number(viewer.camera.positionCartographic?.height || 0) / 1_000);
if (!gridShouldBeVisible(presentation, cameraHeightKm)) return { mode: "hidden", key: "hidden", lodIndex: null };
const lod = selectGridLod(presentation, cameraHeightKm, previousLodIndex) as GridLodBand;
const mode = resolveGridMode(presentation, lod.mode);
if (mode === "hidden") return { mode: "hidden", key: `hidden:${lod.index}`, lodIndex: lod.index };
if (mode === "3d") {
const origin = fixedGridOrigin(presentation);
const anchor = Cartesian3.fromDegrees(origin.longitude, origin.latitude, 0);
const enu = Transforms.eastNorthUpToFixedFrame(anchor);
const inverseEnu = Matrix4.inverseTransformation(enu, new Matrix4());
const stepMeters = clamp(lod.stepKm * 1_000, 100, 5_000_000);
const radiusMeters = clamp(lod.radiusKm * 1_000, stepMeters, 100_000_000);
const metersPerPixel = gridMetersPerPixel(viewer);
const cameraPosition = Cartesian3.clone(viewer.camera.positionWC, new Cartesian3());
const cameraDirection = Cartesian3.normalize(viewer.camera.directionWC, new Cartesian3());
const cameraLocal = Matrix4.multiplyByPoint(inverseEnu, cameraPosition, new Cartesian3());
const cameraPositionBucketMeters = Math.max(stepMeters, lod.tileSizeKm * 500);
const cameraPositionKey = [cameraLocal.x, cameraLocal.y]
.map((value) => Math.round(value / cameraPositionBucketMeters));
const directionKey = [cameraDirection.x, cameraDirection.y, cameraDirection.z]
.map((value) => Math.round(value * 50) / 50);
const cameraHeightMeters = cameraHeightKm * 1_000;
const grid = localGridPlan({ stepMeters, radiusMeters, maximumMarkers: 5_000 });
const majorStepMeters = localMajorStepMeters(lod, grid.stepMeters);
const majorGrid = majorStepMeters !== null && (lod.majorLinesEnabled || lod.majorLabelsEnabled)
? localGridPlan({ stepMeters: majorStepMeters, radiusMeters: grid.radiusMeters, maximumMarkers: 1 })
: null;
const key = JSON.stringify([
"local-enu", lod.index, origin.latitude, origin.longitude, stepMeters, radiusMeters,
lod.heightMeters, lod.max3dViewAngleDegrees, lod.tileSizeKm,
lod.lineDiameterMeters, lod.lineColor, lod.lineOpacity,
lod.dotsEnabled, lod.dotsDiameterMeters, lod.dotsColor, lod.dotsOpacity,
lod.crossesEnabled, lod.crossesLengthMeters, lod.crossesWidthMeters, lod.crossesColor, lod.crossesOpacity,
lod.majorLinesEnabled, lod.majorLabelsEnabled, lod.majorLineWidthMultiplier,
lod.volumeEnabled, lod.volumeMinimumHeightMeters, lod.volumeMaximumHeightMeters, lod.volumeBandHeightMeters,
majorStepMeters,
grid.markerStride, cameraPositionKey, directionKey, Math.round(cameraHeightKm * 10) / 10,
Math.round(metersPerPixel * 10) / 10,
]);
return {
mode, key, lodIndex: lod.index, lod, origin, enu, inverseEnu, grid, majorGrid, majorStepMeters,
metersPerPixel, cameraHeightMeters, cameraPosition, cameraDirection,
};
}
const viewport = graticuleViewport(viewer, lod.graticuleStepDegrees, cameraHeightKm);
const grid = graticuleLinePlan({ ...viewport, stepDegrees: lod.graticuleStepDegrees });
const majorStepDegrees = graticuleMajorStepDegrees(grid.stepDegrees);
const key = JSON.stringify([
"wgs84-graticule", lod.index, lod.heightMeters, lod.graticuleStepDegrees,
lod.graticuleLineWidthPx, lod.graticuleColor, lod.graticuleOpacity,
lod.majorLinesEnabled, lod.majorLabelsEnabled, lod.majorLineWidthMultiplier, majorStepDegrees,
grid.south, grid.north, grid.longitudeIntervals,
]);
return { mode, key, lodIndex: lod.index, lod, grid, majorStepDegrees };
}
function localShellPosition(
enu: Matrix4,
eastMeters: number,
northMeters: number,
heightMeters: number,
) {
const tangentPoint = Matrix4.multiplyByPoint(enu, new Cartesian3(eastMeters, northMeters, 0), new Cartesian3());
const cartographic = Cartographic.fromCartesian(tangentPoint);
return Cartesian3.fromRadians(cartographic.longitude, cartographic.latitude, heightMeters);
}
function materializeLocalGrid(plan: LocalGridLayerPlan, serial: number): GridResources {
const dataSource = new CustomDataSource(`nodedc-map-grid-local:${serial}`);
const points = plan.lod.dotsEnabled ? new PointPrimitiveCollection() : null;
const crosses = plan.lod.crossesEnabled ? new PolylineCollection() : null;
const labels = plan.lod.majorLabelsEnabled && plan.majorStepMeters !== null ? new LabelCollection() : null;
const lineColor = Color.fromCssColorString(plan.lod.lineColor).withAlpha(clamp(plan.lod.lineOpacity / 100, 0, 1));
const majorColor = gridMajorColor(plan.lod.lineColor, plan.lod.lineOpacity);
const dotColor = Color.fromCssColorString(plan.lod.dotsColor).withAlpha(clamp(plan.lod.dotsOpacity / 100, 0, 1));
const crossColor = Color.fromCssColorString(plan.lod.crossesColor).withAlpha(clamp(plan.lod.crossesOpacity / 100, 0, 1));
const lineWidthPixels = clamp(plan.lod.lineDiameterMeters / plan.metersPerPixel, 1, 8);
const majorLineWidthPixels = clamp(lineWidthPixels * plan.lod.majorLineWidthMultiplier, lineWidthPixels, 16);
const dotPixelSize = clamp(plan.lod.dotsDiameterMeters / plan.metersPerPixel, 1, 128);
const crossWidthPixels = clamp(plan.lod.crossesWidthMeters / plan.metersPerPixel, 1, 8);
const definition = {
lod: plan.lod.index + 1,
originLatitude: plan.origin.latitude,
originLongitude: plan.origin.longitude,
stepMeters: plan.grid.stepMeters,
};
const hierarchyDefinition = plan.majorStepMeters === null ? null : {
...definition,
tileSizeMeters: plan.majorStepMeters,
};
const toWorld = (eastMeters: number, northMeters: number, heightMeters = Math.max(0, plan.lod.heightMeters)) => {
// Metric addressing stays in the immutable ENU frame. Reprojection only
// bends the visual shell to WGS84 at an absolute height; terrain and 3D
// Tiles never move the sector boundaries.
return localShellPosition(plan.enu, eastMeters, northMeters, Math.max(0, heightMeters));
};
const useViewCone = Number.isFinite(plan.lod.max3dViewAngleDegrees) && plan.lod.max3dViewAngleDegrees < 170;
const viewConeMinimumDot = Math.cos(CesiumMath.toRadians(plan.lod.max3dViewAngleDegrees));
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],
hierarchy: "minor" | "major",
) => {
let positions: Cartesian3[] = [];
let segmentStart = 0;
const flush = () => {
if (positions.length >= 2) {
dataSource.entities.add({
id: `grid/local/l${plan.lod.index + 1}/${hierarchy}/line-${axis}-${line.index}/part-${segmentStart}`,
polyline: {
positions,
width: hierarchy === "major" ? majorLineWidthPixels : lineWidthPixels,
material: hierarchy === "major" ? majorColor : lineColor,
clampToGround: false,
arcType: ArcType.NONE,
},
});
}
positions = [];
};
let part = 0;
for (let variable = -line.extentMeters; variable < line.extentMeters; variable += segmentStepMeters) {
const next = Math.min(variable + segmentStepMeters, line.extentMeters);
const middle = (variable + next) / 2;
const middleEast = axis === "east" ? line.offsetMeters : middle;
const middleNorth = axis === "east" ? middle : line.offsetMeters;
if (!inViewCone(middleEast, middleNorth)) {
flush();
part += 1;
segmentStart = part;
continue;
}
const start = axis === "east" ? toWorld(line.offsetMeters, variable) : toWorld(variable, line.offsetMeters);
const end = axis === "east" ? toWorld(line.offsetMeters, next) : toWorld(next, line.offsetMeters);
if (positions.length === 0) positions.push(start);
positions.push(end);
part += 1;
}
flush();
};
const skipMinorMajorBoundary = (line: (typeof plan.grid.lines)[number]) => Boolean(
plan.lod.majorLinesEnabled
&& hierarchyDefinition
&& isLocalMajorLineIndex(line.index, hierarchyDefinition),
);
for (const line of plan.grid.lines) if (!skipMinorMajorBoundary(line)) addSegmentedLine("east", line, "minor");
for (const line of plan.grid.lines) if (!skipMinorMajorBoundary(line)) addSegmentedLine("north", line, "minor");
if (plan.lod.majorLinesEnabled && plan.majorGrid) {
for (const line of plan.majorGrid.lines) addSegmentedLine("east", line, "major");
for (const line of plan.majorGrid.lines) addSegmentedLine("north", line, "major");
}
const radiusSquared = plan.grid.radiusMeters ** 2;
const crossHalf = plan.lod.crossesLengthMeters / 2;
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)),
});
const northSouth = crosses.add({
id: { kind: "nodedc-grid-cross", sectorId: address.id },
positions: [toWorld(eastMeters, northMeters - crossHalf), toWorld(eastMeters, northMeters + crossHalf)],
width: Math.max(1, Math.round(crossWidthPixels)),
});
if (eastWest.material?.uniforms) eastWest.material.uniforms.color = crossColor;
if (northSouth.material?.uniforms) northSouth.material.uniforms.color = crossColor;
}
}
}
if (labels && hierarchyDefinition) {
// Tile labels are primitives, not Entities, and are hard-capped. The
// globally phased stride keeps them stable while camera-driven cone
// culling changes the visible subset.
const maximumLabels = 48;
const tileSizeMeters = hierarchyDefinition.tileSizeMeters;
const maximumTileIndex = Math.ceil(plan.grid.radiusMeters / tileSizeMeters);
const approximateTileCount = Math.PI * (plan.grid.radiusMeters / tileSizeMeters) ** 2;
const labelStride = Math.max(1, Math.ceil(Math.sqrt(approximateTileCount / maximumLabels)));
const firstIndex = -Math.ceil(maximumTileIndex / labelStride) * labelStride;
let labelCount = 0;
for (let eastIndex = firstIndex; eastIndex <= maximumTileIndex && labelCount < maximumLabels; eastIndex += labelStride) {
const eastMeters = (eastIndex + 0.5) * tileSizeMeters;
for (let northIndex = firstIndex; northIndex <= maximumTileIndex && labelCount < maximumLabels; northIndex += labelStride) {
const northMeters = (northIndex + 0.5) * tileSizeMeters;
if (eastMeters ** 2 + northMeters ** 2 > radiusSquared || !inViewCone(eastMeters, northMeters)) continue;
const tile = localMajorTileAt({ eastMeters, northMeters }, hierarchyDefinition);
labels.add({
id: { kind: "nodedc-grid-major-label", sectorId: tile.id },
position: toWorld(
eastMeters,
northMeters,
plan.lod.heightMeters + Math.max(20, plan.metersPerPixel * 5),
),
text: `L${tile.lod} · E${tile.eastIndex >= 0 ? "+" : ""}${tile.eastIndex} N${tile.northIndex >= 0 ? "+" : ""}${tile.northIndex}`,
font: "600 12px Arial",
fillColor: Color.WHITE.withAlpha(0.9),
outlineColor: Color.BLACK.withAlpha(0.72),
outlineWidth: 2,
style: LabelStyle.FILL_AND_OUTLINE,
showBackground: true,
backgroundColor: Color.BLACK.withAlpha(0.52),
backgroundPadding: new Cartesian2(7, 4),
horizontalOrigin: HorizontalOrigin.CENTER,
verticalOrigin: VerticalOrigin.CENTER,
});
labelCount += 1;
}
}
}
return { dataSource, points, crosses, labels };
}
function materializeGraticule(plan: GraticuleGridLayerPlan, serial: number): GridResources {
const dataSource = new CustomDataSource(`nodedc-map-grid-graticule:${serial}`);
const color = Color.fromCssColorString(plan.lod.graticuleColor).withAlpha(clamp(plan.lod.graticuleOpacity / 100, 0, 1));
const majorColor = gridMajorColor(plan.lod.graticuleColor, plan.lod.graticuleOpacity);
const labels = plan.lod.majorLabelsEnabled && plan.majorStepDegrees !== null ? new LabelCollection() : null;
const clampToGround = plan.lod.heightMeters <= 0;
const height = clampToGround ? 0 : plan.lod.heightMeters;
const sampleStep = Math.min(5, Math.max(0.5, plan.grid.stepDegrees));
const hierarchyDefinition = plan.majorStepDegrees === null ? null : {
lod: plan.lod.index + 1,
stepDegrees: plan.grid.stepDegrees,
majorStepDegrees: plan.majorStepDegrees,
};
const line = (id: string, positions: Cartesian3[], major: boolean) => positions.length >= 2 && dataSource.entities.add({
id,
polyline: {
positions,
width: major
? clamp(plan.lod.graticuleLineWidthPx * plan.lod.majorLineWidthMultiplier, plan.lod.graticuleLineWidthPx, 16)
: plan.lod.graticuleLineWidthPx,
material: major ? majorColor : color,
clampToGround,
arcType: ArcType.RHUMB,
granularity: graticuleGranularity(sampleStep, clampToGround),
},
});
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);
const major = Boolean(
plan.lod.majorLinesEnabled
&& hierarchyDefinition
&& isGraticuleMajorLineValue(longitude, hierarchyDefinition),
);
line(
`grid/wgs84/l${plan.lod.index + 1}/${major ? "major" : "minor"}/meridian/${longitude}`,
positionsForParts(parts, (latitude) => Cartesian3.fromDegrees(longitude, latitude, height)),
major,
);
}
for (const latitude of plan.grid.parallels) {
const major = Boolean(
plan.lod.majorLinesEnabled
&& hierarchyDefinition
&& isGraticuleMajorLineValue(latitude, hierarchyDefinition),
);
plan.grid.longitudeIntervals.forEach((interval, index) => {
// RHUMB interpolation is correct for parallels, but Cesium rejects an
// exactly antipodal equatorial pair. Intermediate vertices keep every
// segment below 90° without multiplying the number of Cesium entities.
const parts = boundedAngularParts(interval.west, interval.east);
line(
`grid/wgs84/l${plan.lod.index + 1}/${major ? "major" : "minor"}/parallel/${latitude}/interval-${index}`,
positionsForParts(parts, (longitude) => Cartesian3.fromDegrees(longitude, latitude, height)),
major,
);
});
}
if (labels && hierarchyDefinition) {
const maximumLabels = 48;
const majorStep = hierarchyDefinition.majorStepDegrees;
const minimumLatitudeIndex = Math.max(-Math.round(90 / majorStep), Math.floor(plan.grid.south / majorStep));
const maximumLatitudeIndex = Math.min(Math.round(90 / majorStep) - 1, Math.floor((plan.grid.north - 1e-9) / majorStep));
const longitudeRanges = plan.grid.longitudeIntervals.map((interval) => ({
minimum: Math.max(-Math.round(180 / majorStep), Math.floor(interval.west / majorStep)),
maximum: Math.min(Math.round(180 / majorStep) - 1, Math.floor((interval.east - 1e-9) / majorStep)),
}));
const latitudeCount = Math.max(0, maximumLatitudeIndex - minimumLatitudeIndex + 1);
const longitudeCount = longitudeRanges.reduce((sum, range) => sum + Math.max(0, range.maximum - range.minimum + 1), 0);
const labelStride = Math.max(1, Math.ceil(Math.sqrt((latitudeCount * longitudeCount) / maximumLabels)));
const firstLatitudeIndex = Math.ceil(minimumLatitudeIndex / labelStride) * labelStride;
const seen = new Set<string>();
let labelCount = 0;
for (let latitudeIndex = firstLatitudeIndex;
latitudeIndex <= maximumLatitudeIndex && labelCount < maximumLabels;
latitudeIndex += labelStride) {
const latitude = (latitudeIndex + 0.5) * majorStep;
for (const range of longitudeRanges) {
const firstLongitudeIndex = Math.ceil(range.minimum / labelStride) * labelStride;
for (let longitudeIndex = firstLongitudeIndex;
longitudeIndex <= range.maximum && labelCount < maximumLabels;
longitudeIndex += labelStride) {
const longitude = (longitudeIndex + 0.5) * majorStep;
const tile = graticuleMajorTileAt({ longitude, latitude }, hierarchyDefinition);
if (seen.has(tile.id)) continue;
seen.add(tile.id);
labels.add({
id: { kind: "nodedc-grid-major-label", sectorId: tile.id },
position: Cartesian3.fromDegrees(longitude, latitude, height + 100),
text: `L${tile.lod} · X${tile.longitudeIndex >= 0 ? "+" : ""}${tile.longitudeIndex} Y${tile.latitudeIndex >= 0 ? "+" : ""}${tile.latitudeIndex}`,
font: "600 12px Arial",
fillColor: Color.WHITE.withAlpha(0.88),
outlineColor: Color.BLACK.withAlpha(0.72),
outlineWidth: 2,
style: LabelStyle.FILL_AND_OUTLINE,
showBackground: true,
backgroundColor: Color.BLACK.withAlpha(0.48),
backgroundPadding: new Cartesian2(7, 4),
horizontalOrigin: HorizontalOrigin.CENTER,
verticalOrigin: VerticalOrigin.CENTER,
});
labelCount += 1;
}
}
}
}
return { dataSource, points: null, crosses: null, labels };
}
function materializeGridLayer(plan: Exclude<GridLayerPlan, HiddenGridPlan>, serial: number) {
return plan.mode === "3d" ? materializeLocalGrid(plan, serial) : materializeGraticule(plan, serial);
}
const DEFAULT_GRID_SELECTION_COLOR = "#35cfff";
type GridSelectionStyle = {
fillColor: Color;
outlineColor: Color;
outlineWidthPixels: number;
};
function safeGridSelectionColor(value: unknown) {
if (typeof value === "string" && value.trim()) {
try {
const parsed = Color.fromCssColorString(value.trim());
if (parsed) return parsed;
} catch {
// Persisted profiles may predate strict colour validation. Rendering a
// stable selection is preferable to failing the whole Cesium layer.
}
}
return Color.fromCssColorString(DEFAULT_GRID_SELECTION_COLOR);
}
function safeGridSelectionPercent(value: unknown, fallback: number) {
const candidate = typeof value === "number" ? value : Number.NaN;
return clamp(Number.isFinite(candidate) ? candidate : fallback, 0, 100) / 100;
}
function resolveGridSelectionStyle(addressing: GridAddressing): GridSelectionStyle {
const fallbackOutlineWidth = addressing.mode === "3d"
? clamp(2.5 * addressing.majorLineWidthMultiplier, 3, 12)
: clamp(addressing.lineWidthPixels * addressing.majorLineWidthMultiplier * 1.5, 3, 12);
const requestedOutlineWidth = typeof addressing.selectionOutlineWidthPx === "number"
? addressing.selectionOutlineWidthPx
: Number.NaN;
const fallbackFillOpacityPercent = addressing.mode === "graticule"
? 12
: addressing.volumeEnabled ? 10 : 16;
return {
fillColor: safeGridSelectionColor(addressing.selectionFillColor).withAlpha(
safeGridSelectionPercent(addressing.selectionFillOpacityPercent, fallbackFillOpacityPercent),
),
outlineColor: safeGridSelectionColor(addressing.selectionOutlineColor).withAlpha(
safeGridSelectionPercent(addressing.selectionOutlineOpacityPercent, 95),
),
outlineWidthPixels: clamp(
Number.isFinite(requestedOutlineWidth) ? requestedOutlineWidth : fallbackOutlineWidth,
1,
12,
),
};
}
function sampledRange(start: number, end: number, maximumStep: number) {
const span = end - start;
const count = Math.max(1, Math.min(64, Math.ceil(Math.abs(span) / Math.max(1e-6, maximumStep))));
return Array.from({ length: count + 1 }, (_value, index) => start + span * index / count);
}
function localSelectionPerimeter(
addressing: LocalGridAddressing,
bounds: GridSectorSelection["bounds"],
heightMeters: number,
) {
const maximumStep = Math.max(1_000, Math.min(25_000, addressing.stepMeters / 4));
const south = sampledRange(bounds.west, bounds.east, maximumStep)
.map((east) => localShellPosition(addressing.enu, east, bounds.south, heightMeters));
const east = sampledRange(bounds.south, bounds.north, maximumStep).slice(1)
.map((north) => localShellPosition(addressing.enu, bounds.east, north, heightMeters));
const north = sampledRange(bounds.east, bounds.west, maximumStep).slice(1)
.map((eastMeters) => localShellPosition(addressing.enu, eastMeters, bounds.north, heightMeters));
const west = sampledRange(bounds.north, bounds.south, maximumStep).slice(1)
.map((northMeters) => localShellPosition(addressing.enu, bounds.west, northMeters, heightMeters));
return [...south, ...east, ...north, ...west];
}
function materializeLocalSelection(
selection: LocalGridSectorSelection,
addressing: LocalGridAddressing,
serial: number,
): GridResources | null {
const width = selection.bounds.east - selection.bounds.west;
const depth = selection.bounds.north - selection.bounds.south;
if (Math.abs(width - addressing.stepMeters) > 1e-6 * addressing.stepMeters
|| Math.abs(depth - addressing.stepMeters) > 1e-6 * addressing.stepMeters) return null;
const dataSource = new CustomDataSource(`nodedc-map-grid-selection-local:${serial}`);
const crosses = new PolylineCollection();
const selectionStyle = resolveGridSelectionStyle(addressing);
const volumeAvailable = addressing.volumeEnabled
&& Number.isFinite(addressing.volumeBandHeightMeters)
&& addressing.volumeBandHeightMeters > 0
&& addressing.volumeMaximumHeightMeters > addressing.volumeMinimumHeightMeters;
const floor = volumeAvailable
? clamp(selection.volume?.floor ?? addressing.volumeMinimumHeightMeters,
addressing.volumeMinimumHeightMeters, addressing.volumeMaximumHeightMeters)
: addressing.heightMeters;
const ceiling = volumeAvailable
? clamp(selection.volume?.ceiling ?? Math.min(
addressing.volumeMaximumHeightMeters,
addressing.volumeMinimumHeightMeters + addressing.volumeBandHeightMeters,
), floor, addressing.volumeMaximumHeightMeters)
: addressing.heightMeters;
const floorPerimeter = localSelectionPerimeter(addressing, selection.bounds, floor);
const ceilingPerimeter = localSelectionPerimeter(addressing, selection.bounds, ceiling);
const addLine = (id: string, positions: Cartesian3[]) => {
const polyline = crosses.add({
id: { kind: "nodedc-grid-selection", sectorId: selection.id, edgeId: id },
positions,
width: selectionStyle.outlineWidthPixels,
});
if (polyline.material?.uniforms) polyline.material.uniforms.color = selectionStyle.outlineColor;
};
addLine("floor", floorPerimeter);
if (ceiling > floor + 1e-6) addLine("ceiling", ceilingPerimeter);
const corners = [
[selection.bounds.west, selection.bounds.south],
[selection.bounds.east, selection.bounds.south],
[selection.bounds.east, selection.bounds.north],
[selection.bounds.west, selection.bounds.north],
] as const;
if (ceiling > floor + 1e-6) {
corners.forEach(([eastMeters, northMeters], index) => addLine(`post-${index}`, [
localShellPosition(addressing.enu, eastMeters, northMeters, floor),
localShellPosition(addressing.enu, eastMeters, northMeters, ceiling),
]));
}
const ceilingSurface = ceilingPerimeter.slice(0, -1);
if (ceilingSurface.length >= 3) dataSource.entities.add({
id: `${selection.id}/selection-cap`,
polygon: {
hierarchy: new PolygonHierarchy(ceilingSurface),
perPositionHeight: true,
material: selectionStyle.fillColor,
outline: false,
},
});
return { dataSource, points: null, crosses, labels: null };
}
function materializeGraticuleSelection(
selection: GraticuleGridSectorSelection,
addressing: GraticuleGridAddressing,
serial: number,
): GridResources {
const dataSource = new CustomDataSource(`nodedc-map-grid-selection-graticule:${serial}`);
const selectionStyle = resolveGridSelectionStyle(addressing);
const height = Math.max(0, addressing.heightMeters);
const horizontalStep = Math.min(10, Math.max(0.5, addressing.stepDegrees));
const verticalStep = Math.min(10, Math.max(0.5, addressing.stepDegrees));
// A WGS84 cell may logically end at a pole, where every longitude becomes
// the same Cartesian point. Keep the visual cage just inside the singularity
// so Cesium never receives a degenerate polygon while addressing stays exact.
const renderSouth = clamp(selection.bounds.south, -89.9, 89.9);
const renderNorth = clamp(selection.bounds.north, -89.9, 89.9);
const south = sampledRange(selection.bounds.west, selection.bounds.east, horizontalStep)
.map((longitude) => Cartesian3.fromDegrees(longitude, renderSouth, height));
const east = sampledRange(renderSouth, renderNorth, verticalStep).slice(1)
.map((latitude) => Cartesian3.fromDegrees(selection.bounds.east, latitude, height));
const north = sampledRange(selection.bounds.east, selection.bounds.west, horizontalStep).slice(1)
.map((longitude) => Cartesian3.fromDegrees(longitude, renderNorth, height));
const west = sampledRange(renderNorth, renderSouth, verticalStep).slice(1)
.map((latitude) => Cartesian3.fromDegrees(selection.bounds.west, latitude, height));
const perimeter = [...south, ...east, ...north, ...west];
const clampToGround = addressing.heightMeters <= 0;
dataSource.entities.add({
id: `${selection.id}/selection-surface`,
polyline: {
positions: perimeter,
width: selectionStyle.outlineWidthPixels,
material: selectionStyle.outlineColor,
clampToGround,
arcType: ArcType.RHUMB,
granularity: graticuleGranularity(horizontalStep, clampToGround),
},
polygon: {
hierarchy: new PolygonHierarchy(perimeter.slice(0, -1)),
height: clampToGround ? undefined : height,
heightReference: clampToGround ? HeightReference.CLAMP_TO_GROUND : HeightReference.NONE,
arcType: ArcType.RHUMB,
material: selectionStyle.fillColor,
outline: false,
},
});
return { dataSource, points: null, crosses: null, labels: null };
}
function materializeGridSelection(
selection: GridSectorSelection,
addressing: GridAddressing,
serial: number,
) {
if (selection.lod !== addressing.lod || selection.mode !== addressing.mode) return null;
return selection.mode === "3d" && addressing.mode === "3d"
? materializeLocalSelection(selection, addressing, serial)
: selection.mode === "graticule" && addressing.mode === "graticule"
? materializeGraticuleSelection(selection, addressing, serial)
: null;
}
class GridLayerController {
private current: { resources: GridResources; addressing: GridAddressing } | null = null;
private pending: { resources: GridResources; addressing: GridAddressing } | null = null;
private key: string | null = null;
private lodIndex: number | null = null;
private epoch = 0;
private serial = 0;
private removeReadyListener: (() => void) | null = null;
private fallbackTimer: number | null = null;
private retiredResources = new WeakSet<GridResources>();
private selection: GridSectorSelection | null = null;
private selectionResources: GridResources | null = null;
private selectionKey: string | null = null;
private navigationSelectionId: string | null = null;
constructor(
private readonly viewer: Viewer,
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,
addressing.majorStepMeters,
addressing.volumeEnabled,
addressing.volumeMinimumHeightMeters,
addressing.volumeMaximumHeightMeters,
addressing.volumeBandHeightMeters,
])
: JSON.stringify([addressing.mode, addressing.lod, addressing.stepDegrees, addressing.majorStepDegrees]);
}
private syncSelectionStyle(plan: Exclude<GridLayerPlan, HiddenGridPlan>) {
let changed = false;
const targetLod = plan.lod.index + 1;
for (const layer of [this.current, this.pending]) {
const addressing = layer?.addressing;
if (!addressing || addressing.mode !== plan.mode || addressing.lod !== targetLod) continue;
if (
addressing.selectionFillColor === plan.lod.selectionFillColor
&& addressing.selectionFillOpacityPercent === plan.lod.selectionFillOpacityPercent
&& addressing.selectionOutlineColor === plan.lod.selectionOutlineColor
&& addressing.selectionOutlineWidthPx === plan.lod.selectionOutlineWidthPx
&& addressing.selectionOutlineOpacityPercent === plan.lod.selectionOutlineOpacityPercent
) continue;
addressing.selectionFillColor = plan.lod.selectionFillColor;
addressing.selectionFillOpacityPercent = plan.lod.selectionFillOpacityPercent;
addressing.selectionOutlineColor = plan.lod.selectionOutlineColor;
addressing.selectionOutlineWidthPx = plan.lod.selectionOutlineWidthPx;
addressing.selectionOutlineOpacityPercent = plan.lod.selectionOutlineOpacityPercent;
changed = true;
}
if (changed) this.refreshSelection(this.pending?.addressing ?? this.current?.addressing);
}
rebuild(presentation: MapPresentation) {
// Planning is intentionally cheap. Geometry is materialized only after
// key comparison; repeated camera/settings notifications cannot create
// and discard thousands of Cesium objects for an unchanged layer.
const plan = planGridLayer(this.viewer, presentation, this.lodIndex);
if (plan.key === this.key) {
if (plan.mode !== "hidden") this.syncSelectionStyle(plan);
return;
}
this.key = plan.key;
this.lodIndex = plan.lodIndex;
const epoch = ++this.epoch;
this.cancelPending();
if (plan.mode === "hidden") {
this.setSelection(null);
this.onAddressingChange();
if (this.current) this.removeResources(this.current.resources);
this.current = null;
this.viewer.scene.requestRender();
return;
}
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,
enu: plan.enu,
inverseEnu: plan.inverseEnu,
heightMeters: plan.lod.heightMeters,
tileSizeMeters: plan.lod.tileSizeKm * 1_000,
majorStepMeters: plan.majorStepMeters,
lineColor: plan.lod.lineColor,
lineOpacity: plan.lod.lineOpacity,
majorLineWidthMultiplier: plan.lod.majorLineWidthMultiplier,
volumeEnabled: plan.lod.volumeEnabled,
volumeMinimumHeightMeters: plan.lod.volumeMinimumHeightMeters,
volumeMaximumHeightMeters: plan.lod.volumeMaximumHeightMeters,
volumeBandHeightMeters: plan.lod.volumeBandHeightMeters,
selectionFillColor: plan.lod.selectionFillColor,
selectionFillOpacityPercent: plan.lod.selectionFillOpacityPercent,
selectionOutlineColor: plan.lod.selectionOutlineColor,
selectionOutlineWidthPx: plan.lod.selectionOutlineWidthPx,
selectionOutlineOpacityPercent: plan.lod.selectionOutlineOpacityPercent,
} : {
mode: "graticule",
lod: plan.lod.index + 1,
stepDegrees: plan.grid.stepDegrees,
majorStepDegrees: plan.majorStepDegrees,
heightMeters: plan.lod.heightMeters,
lineWidthPixels: plan.lod.graticuleLineWidthPx,
lineColor: plan.lod.graticuleColor,
lineOpacity: plan.lod.graticuleOpacity,
majorLineWidthMultiplier: plan.lod.majorLineWidthMultiplier,
selectionFillColor: plan.lod.selectionFillColor,
selectionFillOpacityPercent: plan.lod.selectionFillOpacityPercent,
selectionOutlineColor: plan.lod.selectionOutlineColor,
selectionOutlineWidthPx: plan.lod.selectionOutlineWidthPx,
selectionOutlineOpacityPercent: plan.lod.selectionOutlineOpacityPercent,
};
const activeAddressing = this.pending?.addressing ?? this.current?.addressing;
if (activeAddressing && this.addressingKey(activeAddressing) !== this.addressingKey(addressing)) {
const selectionTargetsNextLod = Boolean(
this.selection
&& this.selection.lod === addressing.lod
&& this.selection.mode === addressing.mode
&& this.selection.lod !== activeAddressing.lod,
);
const selectionIsNavigating = Boolean(
this.selection
&& this.navigationSelectionId === this.selection.id,
);
if (selectionTargetsNextLod) this.navigationSelectionId = null;
if (!selectionTargetsNextLod && !selectionIsNavigating) {
this.setSelection(null);
this.onAddressingChange();
}
}
const resources = materializeGridLayer(plan, ++this.serial);
const next = { resources, addressing };
this.pending = next;
this.mountResources(resources);
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.removeResources(previous.resources);
this.refreshSelection(next.addressing);
this.viewer.scene.requestRender();
};
this.removeReadyListener = this.viewer.scene.postRender.addEventListener(() => {
readyFrames = this.viewer.dataSourceDisplay.ready ? readyFrames + 1 : 0;
if (readyFrames >= 2) commit();
else this.viewer.scene.requestRender();
});
this.fallbackTimer = window.setTimeout(commit, 2_000);
this.viewer.scene.requestRender();
}
pick(worldPosition: Cartesian3): GridSectorSelection | null {
const addressing = this.pending?.addressing ?? this.current?.addressing;
if (!addressing) return null;
if (addressing.mode === "3d") {
const ellipsoid = this.viewer.scene.globe.ellipsoid;
const surface = ellipsoid.scaleToGeodeticSurface(worldPosition, new Cartesian3()) ?? worldPosition;
const normal = ellipsoid.geodeticSurfaceNormal(surface, new Cartesian3());
const localSurface = Matrix4.multiplyByPoint(addressing.inverseEnu, surface, new Cartesian3());
const localNormal = Matrix4.multiplyByPointAsVector(addressing.inverseEnu, normal, new Cartesian3());
// Geometry is addressed on the origin's ENU tangent plane and then
// reprojected along the WGS84 normal. Intersect that normal with the
// same plane so analytic picking returns the exact source cell even at
// the outer edge of a 1,000 km LOD.
const normalScale = Math.abs(localNormal.z) > 1e-9 ? -localSurface.z / localNormal.z : 0;
const local = new Cartesian3(
localSurface.x + localNormal.x * normalScale,
localSurface.y + localNormal.y * normalScale,
0,
);
if (local.x ** 2 + local.y ** 2 > addressing.radiusMeters ** 2) return null;
const definition = {
lod: addressing.lod,
originLatitude: addressing.originLatitude,
originLongitude: addressing.originLongitude,
stepMeters: addressing.stepMeters,
};
const address = localSectorAt({ eastMeters: local.x, northMeters: local.y }, definition);
const hierarchyDefinition = addressing.majorStepMeters === null
? definition
: { ...definition, tileSizeMeters: addressing.majorStepMeters };
const summary = localSectorSummary(address, hierarchyDefinition);
const cartographic = Cartographic.fromCartesian(worldPosition);
const volumeAvailable = addressing.volumeEnabled
&& Number.isFinite(addressing.volumeBandHeightMeters)
&& addressing.volumeBandHeightMeters > 0
&& addressing.volumeMaximumHeightMeters > addressing.volumeMinimumHeightMeters;
const altitudeMeters = clamp(
Number(cartographic?.height ?? addressing.volumeMinimumHeightMeters),
addressing.volumeMinimumHeightMeters,
Math.max(addressing.volumeMinimumHeightMeters, addressing.volumeMaximumHeightMeters - 1e-6),
);
const volumeAddress = volumeAvailable ? localVolumeAt({
eastMeters: local.x,
northMeters: local.y,
altitudeMeters,
}, {
...definition,
altitudeFloorMeters: addressing.volumeMinimumHeightMeters,
altitudeCeilingMeters: addressing.volumeMaximumHeightMeters,
altitudeBandMeters: addressing.volumeBandHeightMeters,
}) : null;
return {
...summary,
mode: "3d",
units: "meters-enu",
volume: volumeAddress ? {
id: volumeAddress.id,
index: volumeAddress.bandIndex,
floor: volumeAddress.altitudeFloorMeters,
ceiling: volumeAddress.altitudeCeilingMeters,
bandHeight: volumeAddress.altitudeBandMeters,
} : null,
};
}
const cartographic = Cartographic.fromCartesian(worldPosition);
const address = graticuleSectorAt({
longitude: CesiumMath.toDegrees(cartographic.longitude),
latitude: CesiumMath.toDegrees(cartographic.latitude),
}, { lod: addressing.lod, stepDegrees: addressing.stepDegrees });
const definition = addressing.majorStepDegrees === null
? { lod: addressing.lod, stepDegrees: addressing.stepDegrees }
: { lod: addressing.lod, stepDegrees: addressing.stepDegrees, majorStepDegrees: addressing.majorStepDegrees };
const summary = graticuleSectorSummary(address, definition);
return {
...summary,
mode: "graticule",
units: "degrees-wgs84",
volume: null,
};
}
setSelection(selection: GridSectorSelection | null) {
if (!selection || selection.id !== this.navigationSelectionId) this.navigationSelectionId = null;
this.selection = selection;
this.refreshSelection(this.pending?.addressing ?? this.current?.addressing);
}
navigateToSelection(selection: GridSectorSelection) {
this.navigationSelectionId = selection.id;
this.selection = selection;
this.refreshSelection(this.pending?.addressing ?? this.current?.addressing);
}
getActiveLod() {
return (this.pending?.addressing ?? this.current?.addressing)?.lod ?? null;
}
destroy() {
this.epoch += 1;
this.cancelPending();
if (this.current) this.removeResources(this.current.resources);
this.current = null;
this.selection = null;
this.navigationSelectionId = null;
this.clearSelectionResources();
}
private mountResources(resources: GridResources) {
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);
if (resources.labels) this.viewer.scene.primitives.add(resources.labels);
}
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);
if (resources.labels) this.viewer.scene.primitives.remove(resources.labels);
}
private refreshSelection(addressing: GridAddressing | undefined) {
const key = this.selection && addressing ? JSON.stringify([
this.selection.id,
this.selection.volume?.id ?? null,
this.addressingKey(addressing),
addressing.heightMeters,
addressing.lineColor,
addressing.lineOpacity,
addressing.majorLineWidthMultiplier,
addressing.selectionFillColor,
addressing.selectionFillOpacityPercent,
addressing.selectionOutlineColor,
addressing.selectionOutlineWidthPx,
addressing.selectionOutlineOpacityPercent,
addressing.mode === "3d" ? [
addressing.volumeEnabled,
addressing.volumeMinimumHeightMeters,
addressing.volumeMaximumHeightMeters,
addressing.volumeBandHeightMeters,
] : addressing.lineWidthPixels,
]) : null;
if (key === this.selectionKey) return;
this.selectionKey = key;
const previous = this.selectionResources;
const next = this.selection && addressing
? materializeGridSelection(this.selection, addressing, ++this.serial)
: null;
this.selectionResources = next;
if (next) this.mountResources(next);
if (previous) this.removeResources(previous);
this.viewer.scene.requestRender();
}
private clearSelectionResources() {
this.selectionKey = null;
if (this.selectionResources) this.removeResources(this.selectionResources);
this.selectionResources = null;
}
private cancelPending() {
this.removeReadyListener?.();
this.removeReadyListener = null;
if (this.fallbackTimer !== null) window.clearTimeout(this.fallbackTimer);
this.fallbackTimer = null;
if (this.pending) this.removeResources(this.pending.resources);
this.pending = null;
}
}
function applyPresentation(
viewer: Viewer,
imageryLayer: ImageryLayer | null,
buildings: Cesium3DTileset | null,
terrain: { world: CesiumTerrainProvider | null; ellipsoid: EllipsoidTerrainProvider },
presentation: MapPresentation,
) {
if (imageryLayer) {
imageryLayer.show = presentation.imageryVisible && !presentation.monochrome;
imageryLayer.brightness = presentation.imageryBrightness / 100;
imageryLayer.contrast = presentation.imageryContrast / 100;
imageryLayer.saturation = presentation.imagerySaturation / 100;
imageryLayer.gamma = presentation.imageryGamma / 100;
imageryLayer.hue = CesiumMath.toRadians(presentation.imageryHue);
imageryLayer.alpha = presentation.imageryAlpha / 100;
}
if (buildings) {
buildings.show = presentation.buildingsVisible;
buildings.maximumScreenSpaceError = presentation.buildingsDetail;
buildings.style = new Cesium3DTileStyle({
color: `color('${presentation.buildingsColor}', ${presentation.buildingsOpacity})`,
});
}
viewer.terrainProvider = presentation.terrainEnabled && terrain.world ? terrain.world : terrain.ellipsoid;
viewer.scene.globe.show = true;
viewer.scene.globe.baseColor = Color.fromCssColorString(presentation.monochrome ? presentation.monochromeColor : presentation.globeColor);
viewer.scene.globe.enableLighting = presentation.sunEnabled;
(viewer.scene as unknown as { verticalExaggeration?: number }).verticalExaggeration = clamp(presentation.terrainExaggeration, 0.25, 3);
viewer.scene.backgroundColor = Color.fromCssColorString(presentation.backgroundColor);
viewer.scene.fog.enabled = presentation.fogEnabled;
viewer.scene.fog.density = clamp(presentation.fogDensity / 10_000, 0, 0.01);
const atmosphere = viewer.scene.skyAtmosphere;
if (atmosphere) {
atmosphere.show = presentation.atmosphereEnabled;
atmosphere.hueShift = clamp(presentation.atmosphereHue / 100, -1, 1);
atmosphere.saturationShift = clamp(presentation.atmosphereSaturation / 100, -1, 1);
atmosphere.brightnessShift = clamp(presentation.atmosphereBrightness / 100, -1, 1);
}
viewer.shadows = presentation.shadowsEnabled;
const sunDate = JulianDate.toDate(JulianDate.now());
sunDate.setUTCHours(clamp(Math.round(presentation.sunHour), 0, 24), 0, 0, 0);
viewer.clock.currentTime = JulianDate.fromDate(sunDate);
viewer.clock.shouldAnimate = false;
const sun = new SunLight();
(sun as unknown as { intensity?: number }).intensity = clamp(presentation.sunIntensity / 100, 0, 2);
viewer.scene.light = sun;
viewer.scene.requestRender();
}
export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
onSelect?: (entityId: string) => void;
onGridSectorSelect?: (sector: GridSectorSelection | null) => void;
selectedGridSector?: GridSectorSelection | null;
onGatewayHealth?: (health: MapGatewayHealth | null) => void;
onProviderStatus?: (status: MapProviderStatus) => void;
onCameraChange?: (camera: MapCameraView) => void;
onCacheRefreshConsumed?: () => void;
onReadyChange?: (ready: boolean) => void;
onSpiralStateChange?: (state: CameraSpiralState) => void;
initialCamera?: MapCameraView;
presentation: MapPresentation;
runtimeBindings?: MapRuntimeBinding[];
presentationProfiles?: MapPresentationProfile[];
presentationFilters?: MapPresentationFilters;
}>(function CesiumMapRenderer({
onSelect,
onGridSectorSelect,
selectedGridSector,
onGatewayHealth,
onProviderStatus,
onCameraChange,
onCacheRefreshConsumed,
onReadyChange,
onSpiralStateChange,
initialCamera,
presentation,
runtimeBindings = [],
presentationProfiles = [],
presentationFilters = {},
}, ref) {
const containerRef = useRef<HTMLDivElement>(null);
const creditContainerRef = useRef<HTMLDivElement>(null);
const viewerRef = useRef<Viewer | null>(null);
const imageryLayerRef = useRef<ImageryLayer | null>(null);
const buildingsRef = useRef<Cesium3DTileset | null>(null);
const terrainRef = useRef<{ world: CesiumTerrainProvider | null; ellipsoid: EllipsoidTerrainProvider } | null>(null);
const rebuildGridRef = useRef<(() => void) | null>(null);
const gridControllerRef = useRef<GridLayerController | null>(null);
const runtimeDataSourcesRef = useRef(new Map<string, CustomDataSource>());
const hGeoZoneLayersRef = useRef(new Map<string, HGeoZoneProjectionLayer>());
const faultedHGeoZoneGeometryKeysRef = useRef(new Set<string>());
const presentationRef = useRef(presentation);
const runtimeBindingsRef = useRef(runtimeBindings);
const presentationProfilesRef = useRef(presentationProfiles);
const presentationFiltersRef = useRef(presentationFilters);
const onSelectRef = useRef(onSelect);
const onGridSectorSelectRef = useRef(onGridSectorSelect);
const selectedGridSectorRef = useRef(selectedGridSector);
const onCameraChangeRef = useRef(onCameraChange);
const onCacheRefreshConsumedRef = useRef(onCacheRefreshConsumed);
const onReadyChangeRef = useRef(onReadyChange);
const onSpiralStateChangeRef = useRef(onSpiralStateChange);
const spiralSessionRef = useRef<SpiralSession | null>(null);
useEffect(() => {
onSelectRef.current = onSelect;
}, [onSelect]);
useEffect(() => {
onGridSectorSelectRef.current = onGridSectorSelect;
}, [onGridSectorSelect]);
useEffect(() => {
selectedGridSectorRef.current = selectedGridSector;
if (selectedGridSector !== undefined) gridControllerRef.current?.setSelection(selectedGridSector);
}, [selectedGridSector]);
useEffect(() => {
onCameraChangeRef.current = onCameraChange;
}, [onCameraChange]);
useEffect(() => {
onCacheRefreshConsumedRef.current = onCacheRefreshConsumed;
}, [onCacheRefreshConsumed]);
useEffect(() => {
onReadyChangeRef.current = onReadyChange;
}, [onReadyChange]);
useEffect(() => {
onSpiralStateChangeRef.current = onSpiralStateChange;
}, [onSpiralStateChange]);
const stopSpiralAnimation = useCallback((reason: CameraSpiralState["reason"] = "stopped") => {
const session = spiralSessionRef.current;
if (!session) return;
spiralSessionRef.current = null;
if (session.requestId !== null) window.cancelAnimationFrame(session.requestId);
if (!session.viewer.isDestroyed()) {
if (session.buildingsTileset && !session.buildingsTileset.isDestroyed()) {
if (session.previousBuildingsCullRequestsWhileMoving !== null) {
session.buildingsTileset.cullRequestsWhileMoving = session.previousBuildingsCullRequestsWhileMoving;
}
if (session.previousBuildingsFoveatedScreenSpaceError !== null) {
session.buildingsTileset.foveatedScreenSpaceError = session.previousBuildingsFoveatedScreenSpaceError;
}
if (session.previousBuildingsFoveatedTimeDelay !== null) {
session.buildingsTileset.foveatedTimeDelay = session.previousBuildingsFoveatedTimeDelay;
}
}
session.viewer.scene.screenSpaceCameraController.enableInputs = session.previousCameraInputsEnabled;
session.viewer.scene.requestRender();
rebuildGridRef.current?.();
onCameraChangeRef.current?.(getCameraView(session.viewer));
}
onSpiralStateChangeRef.current?.({ running: false, reason });
}, []);
const startSpiralAnimation = useCallback((input: CameraSpiralConfig) => {
const viewer = viewerRef.current;
const position = viewer?.camera.positionCartographic;
if (!viewer || viewer.isDestroyed() || !position) return false;
if (![input.heightAboveGroundMeters, input.speedMetersPerSecond, input.pitchMetersPerTurn].every(Number.isFinite)) return false;
if (input.targetRadiusMeters !== undefined && !Number.isFinite(input.targetRadiusMeters)) return false;
if (input.viewPitchRadians !== undefined && !Number.isFinite(input.viewPitchRadians)) return false;
const terrainProvider = presentationRef.current.terrainEnabled ? terrainRef.current?.world ?? null : null;
if (presentationRef.current.terrainEnabled && !terrainProvider) return false;
const heightAboveGroundMeters = clamp(input.heightAboveGroundMeters, 10, 100_000);
const config: CameraSpiralConfig = {
heightAboveGroundMeters,
speedMetersPerSecond: clamp(input.speedMetersPerSecond, 1, 5_000),
// Preserve overlap even in a narrow/portrait viewport. Presets express
// an upper bound; the live frustum may reduce the actual turn pitch.
pitchMetersPerTurn: clamp(
cameraSurveyPitchForViewport(
heightAboveGroundMeters,
input.pitchMetersPerTurn,
horizontalFieldOfViewRadians(viewer),
),
1,
100_000,
),
targetRadiusMeters: clamp(input.targetRadiusMeters ?? 25_000, 100, MAX_SPIRAL_RADIUS_METERS),
...(input.viewPitchRadians !== undefined
? { viewPitchRadians: clamp(input.viewPitchRadians, -Math.PI / 2 + 0.01, -0.05) }
: {}),
waitForTiles: input.waitForTiles === true,
};
stopSpiralAnimation("renderer_restarted");
viewer.camera.cancelFlight();
viewer.camera.lookAtTransform(Matrix4.IDENTITY);
const controller = viewer.scene.screenSpaceCameraController;
const buildingsTileset = buildingsRef.current;
const session: SpiralSession = {
viewer,
origin: { longitude: position.longitude, latitude: position.latitude },
initialHeading: Number.isFinite(viewer.camera.heading) ? viewer.camera.heading : 0,
pitch: config.viewPitchRadians
?? clamp(Number.isFinite(viewer.camera.pitch) ? viewer.camera.pitch : -Math.PI / 4, -Math.PI / 2 + 0.01, -0.05),
roll: 0,
config,
surfaceDistanceMeters: 0,
lastTimestamp: null,
pendingElapsedSeconds: 0,
tilesWaitStartedAt: null,
requestId: null,
previousCameraInputsEnabled: controller.enableInputs,
terrainProvider,
terrainSamples: [],
terrainSamplePending: false,
terrainSampleExhausted: false,
terrainSampleFailures: 0,
terrainRequestGeneration: 0,
terrainRetryAt: 0,
terrainSampleSpacingMeters: clamp(config.speedMetersPerSecond / 30, 10, 250),
targetSurfaceDistanceMeters: cameraSurveySpiralDistance(
config.targetRadiusMeters ?? 25_000,
config.pitchMetersPerTurn,
),
buildingsTileset,
previousBuildingsCullRequestsWhileMoving: buildingsTileset?.cullRequestsWhileMoving ?? null,
previousBuildingsFoveatedScreenSpaceError: buildingsTileset?.foveatedScreenSpaceError ?? null,
previousBuildingsFoveatedTimeDelay: buildingsTileset?.foveatedTimeDelay ?? null,
};
// A survey must request the whole visible OSM Buildings footprint while
// the camera moves. Cesium's interactive defaults deliberately defer and
// cull edge requests; restore them when the survey stops.
if (buildingsTileset) {
buildingsTileset.cullRequestsWhileMoving = false;
buildingsTileset.foveatedScreenSpaceError = false;
buildingsTileset.foveatedTimeDelay = 0;
}
controller.enableInputs = false;
spiralSessionRef.current = session;
const groundHeightAtDistance = (distanceMeters: number) => {
if (!session.terrainProvider) return 0;
// Only the most-detailed sampled route is stable enough for AGL.
// globe.getHeight() follows the currently rendered LOD and can jump as
// tiles refine, so it must not drive the camera altitude.
return interpolateTerrainRouteHeight(session.terrainSamples, distanceMeters);
};
const applyFrame = (frame: ReturnType<typeof spiralSurfaceFrame>, rawGround: number) => {
if (spiralSessionRef.current !== session || viewer.isDestroyed()) return;
const scene = viewer.scene as unknown as {
verticalExaggeration?: number;
verticalExaggerationRelativeHeight?: number;
};
const terrainActive = Boolean(session.terrainProvider);
const exaggeration = terrainActive && Number.isFinite(scene.verticalExaggeration) ? Number(scene.verticalExaggeration) : 1;
const relativeHeight = terrainActive && Number.isFinite(scene.verticalExaggerationRelativeHeight)
? Number(scene.verticalExaggerationRelativeHeight)
: 0;
const renderedGround = relativeHeight + (rawGround - relativeHeight) * exaggeration;
viewer.camera.setView({
destination: Cartesian3.fromRadians(
frame.longitude,
frame.latitude,
renderedGround + session.config.heightAboveGroundMeters,
),
orientation: {
heading: frame.tangentHeading,
pitch: session.pitch,
roll: session.roll,
},
});
viewer.scene.requestRender();
};
const requestTerrainSamples = () => {
if (!session.terrainProvider || session.terrainSamplePending || spiralSessionRef.current !== session) return;
if (session.terrainSampleExhausted) return;
if (Date.now() < session.terrainRetryAt) return;
const lastSample = session.terrainSamples.at(-1);
// Prefetch before the camera reaches the buffer tail. At normal and
// high speeds this leaves roughly three seconds for Gateway/VPN latency.
if (lastSample && lastSample.distanceMeters - session.surfaceDistanceMeters > session.terrainSampleSpacingMeters * 90) return;
const startDistance = lastSample ? lastSample.distanceMeters + session.terrainSampleSpacingMeters : 0;
const distances: number[] = [];
const positions: Cartographic[] = [];
let reachedExtent = false;
let reachedTargetDistance = false;
const plannedDistances = cameraSurveySampleDistances(
startDistance,
session.terrainSampleSpacingMeters,
session.targetSurfaceDistanceMeters,
120,
);
for (const distanceMeters of plannedDistances) {
try {
const frame = spiralSurfaceFrame(
session.origin,
session.initialHeading,
distanceMeters,
session.config.pitchMetersPerTurn,
);
distances.push(distanceMeters);
positions.push(new Cartographic(frame.longitude, frame.latitude, 0));
if (distanceMeters >= session.targetSurfaceDistanceMeters) {
reachedTargetDistance = true;
break;
}
} catch (error) {
if (error instanceof Error && error.message === "spiral_extent_limit") {
reachedExtent = true;
} else {
stopSpiralAnimation("spiral_runtime_error");
}
break;
}
}
if (positions.length === 0 || spiralSessionRef.current !== session) {
if (reachedExtent && spiralSessionRef.current === session) stopSpiralAnimation("spiral_extent_limit");
return;
}
session.terrainSamplePending = true;
const requestGeneration = ++session.terrainRequestGeneration;
let timeoutId = 0;
const timeout = new Promise<never>((_resolve, reject) => {
timeoutId = window.setTimeout(() => reject(new Error("terrain_sample_timeout")), TERRAIN_SAMPLE_TIMEOUT_MS);
});
void Promise.race([
sampleTerrainMostDetailed(session.terrainProvider, positions),
timeout,
]).then((sampledPositions) => {
window.clearTimeout(timeoutId);
if (
spiralSessionRef.current !== session
|| session.terrainRequestGeneration !== requestGeneration
|| viewer.isDestroyed()
) return;
const nextSamples = sampledPositions.map((sampledPosition, index) => {
if (!Number.isFinite(sampledPosition.height)) throw new Error("terrain_height_unavailable");
return {
distanceMeters: distances[index],
heightMeters: Number(sampledPosition.height),
};
});
session.terrainSamples = [...session.terrainSamples, ...nextSamples]
.filter((sample) => sample.distanceMeters >= session.surfaceDistanceMeters - session.terrainSampleSpacingMeters * 2);
if (reachedTargetDistance || reachedExtent) session.terrainSampleExhausted = true;
session.terrainSamplePending = false;
session.terrainSampleFailures = 0;
session.terrainRetryAt = 0;
viewer.scene.requestRender();
}).catch(() => {
window.clearTimeout(timeoutId);
if (spiralSessionRef.current !== session || session.terrainRequestGeneration !== requestGeneration) return;
session.terrainSamplePending = false;
session.terrainSampleFailures += 1;
if (session.terrainSampleFailures >= 4) {
stopSpiralAnimation("terrain_sampling_error");
return;
}
const retryDelay = [500, 1_500, 4_000][session.terrainSampleFailures - 1] ?? 4_000;
session.terrainRetryAt = Date.now() + retryDelay;
viewer.scene.requestRender();
});
};
const tick = (timestamp: number) => {
if (spiralSessionRef.current !== session || viewer.isDestroyed()) return;
if (document.hidden) {
session.lastTimestamp = null;
session.pendingElapsedSeconds = 0;
session.tilesWaitStartedAt = null;
} else if (session.lastTimestamp === null) {
session.lastTimestamp = timestamp;
} else {
const currentTilesReady = !session.config.waitForTiles
|| (viewer.scene.globe.tilesLoaded && (!session.buildingsTileset || session.buildingsTileset.tilesLoaded));
if (!currentTilesReady) {
session.tilesWaitStartedAt ??= timestamp;
if (timestamp - session.tilesWaitStartedAt >= SPIRAL_TILE_WAIT_TIMEOUT_MS) {
stopSpiralAnimation("tile_loading_timeout");
return;
}
// Do not accumulate route time while the current Terrain, Imagery or
// OSM Buildings footprint is still resolving through TileCache.
session.lastTimestamp = timestamp;
session.pendingElapsedSeconds = 0;
requestTerrainSamples();
viewer.scene.requestRender();
session.requestId = window.requestAnimationFrame(tick);
return;
}
session.tilesWaitStartedAt = null;
// Never catch up a stalled UI thread with one large camera jump. A
// cache survey values continuous visible coverage over wall-clock
// speed; limiting one rendered advance to 1/30 s keeps intermediate
// viewports observable by Cesium before the next tile-settle gate.
const elapsedSeconds = Math.min(1 / 30, Math.max(0, (timestamp - session.lastTimestamp) / 1000));
session.lastTimestamp = timestamp;
session.pendingElapsedSeconds += elapsedSeconds;
try {
let nextFrame: ReturnType<typeof spiralSurfaceFrame> | null = null;
let nextGround: number | null = null;
let substeps = 0;
while (session.pendingElapsedSeconds > 0.000_001 && substeps < MAX_SPIRAL_SUBSTEPS_PER_FRAME) {
const stepSeconds = Math.min(session.pendingElapsedSeconds, 1 / 30);
const candidateDistance = session.surfaceDistanceMeters + session.config.speedMetersPerSecond * stepSeconds;
const candidateFrame = spiralSurfaceFrame(
session.origin,
session.initialHeading,
candidateDistance,
session.config.pitchMetersPerTurn,
);
if (candidateFrame.radiusMeters >= (session.config.targetRadiusMeters ?? MAX_SPIRAL_RADIUS_METERS)) {
stopSpiralAnimation("target_radius_reached");
return;
}
const candidateGround = groundHeightAtDistance(candidateDistance);
if (candidateGround === null) {
const lastTerrainSample = session.terrainSamples.at(-1);
if (session.terrainSampleExhausted && (!lastTerrainSample || candidateDistance > lastTerrainSample.distanceMeters)) {
throw new Error("spiral_extent_limit");
}
// Missing terrain is a loading state, never an ellipsoid-height
// fallback. Pause the route clock until a sampled buffer exists.
session.pendingElapsedSeconds = 0;
session.lastTimestamp = timestamp;
requestTerrainSamples();
break;
}
session.surfaceDistanceMeters = candidateDistance;
session.pendingElapsedSeconds -= stepSeconds;
nextFrame = candidateFrame;
nextGround = candidateGround;
substeps += 1;
}
if (nextFrame && nextGround !== null) applyFrame(nextFrame, nextGround);
requestTerrainSamples();
} catch (error) {
stopSpiralAnimation(error instanceof Error && error.message === "spiral_extent_limit"
? "spiral_extent_limit"
: "spiral_runtime_error");
return;
}
}
if (spiralSessionRef.current === session) session.requestId = window.requestAnimationFrame(tick);
};
try {
requestTerrainSamples();
const initialFrame = spiralSurfaceFrame(session.origin, session.initialHeading, 0, session.config.pitchMetersPerTurn);
const initialGround = groundHeightAtDistance(0);
if (initialGround !== null) applyFrame(initialFrame, initialGround);
} catch {
stopSpiralAnimation("spiral_runtime_error");
return false;
}
session.requestId = window.requestAnimationFrame(tick);
rebuildGridRef.current?.();
onSpiralStateChangeRef.current?.({ running: true });
return true;
}, [stopSpiralAnimation]);
const runtimeEntities = useCallback((entityIds?: string[]) => {
const allowed = entityIds ? new Set(entityIds) : null;
return [...runtimeDataSourcesRef.current.values()].flatMap((dataSource) => (
[...dataSource.entities.values].filter((entity) => !allowed || allowed.has(String(entity.id)))
));
}, []);
const fitRuntimeEntities = useCallback((entityIds?: string[]) => {
const viewer = viewerRef.current;
if (!viewer || viewer.isDestroyed()) return false;
const entities = runtimeEntities(entityIds);
if (!entities.length) return false;
void viewer.flyTo(entities, { duration: 0.55 });
return true;
}, [runtimeEntities]);
const focusCoordinates = useCallback((longitude: number, latitude: number) => {
const viewer = viewerRef.current;
if (!viewer || viewer.isDestroyed()
|| !Number.isFinite(longitude) || longitude < -180 || longitude > 180
|| !Number.isFinite(latitude) || latitude < -90 || latitude > 90) return false;
// Preserve the observer's current composition exactly as the proven
// legacy MMAP/AIS interaction does: move the camera/viewport frame to the
// selected point without replacing heading, pitch, roll or ground offset.
const camera = viewer.camera;
const canvas = viewer.scene.canvas;
const viewportCenter = camera.pickEllipsoid(
new Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2),
viewer.scene.globe.ellipsoid,
);
const groundTarget = Cartesian3.fromDegrees(
longitude,
latitude,
0,
viewer.scene.globe.ellipsoid,
);
const destination = viewportCenter
? Cartesian3.add(
groundTarget,
Cartesian3.subtract(camera.position, viewportCenter, new Cartesian3()),
new Cartesian3(),
)
: Cartesian3.fromRadians(
CesiumMath.toRadians(longitude),
CesiumMath.toRadians(latitude),
camera.positionCartographic.height,
viewer.scene.globe.ellipsoid,
);
camera.flyTo({
destination,
orientation: {
heading: camera.heading,
pitch: camera.pitch,
roll: camera.roll,
},
duration: 0.45,
});
return true;
}, []);
const focusGridTarget = useCallback((
longitude: number,
latitude: number,
lod: number,
footprintMeters: number,
forceFit: boolean,
) => {
const viewer = viewerRef.current;
if (!viewer || viewer.isDestroyed() || !Number.isFinite(longitude) || !Number.isFinite(latitude)) return false;
if (!forceFit && gridControllerRef.current?.getActiveLod() === lod) {
return focusCoordinates(longitude, latitude);
}
const profiles = presentationRef.current.gridLodProfiles;
const index = lod - 1;
const profile = profiles[index];
if (!profile) return false;
const lowerKm = index === 0 ? 0 : profiles[index - 1].maxHeightKm;
const configuredUpperKm = index === profiles.length - 1
? Math.min(presentationRef.current.gridAutoDisableHeightKm, Math.max(profile.maxHeightKm, lowerKm + 1))
: profile.maxHeightKm;
const upperKm = Math.max(lowerKm + 0.2, configuredUpperKm);
// Twelve percent clears the policy's eight-percent hysteresis in both
// travel directions while retaining room to fit a major tile.
const safeLowerKm = lowerKm + (upperKm - lowerKm) * 0.12;
const safeUpperKm = upperKm - (upperKm - lowerKm) * 0.12;
const frustum = viewer.camera.frustum as unknown as { fovy?: number };
const verticalFov = clamp(Number(frustum.fovy) || Math.PI / 3, 0.2, Math.PI - 0.2);
const aspect = Math.max(0.1, viewer.scene.canvas.clientWidth / Math.max(1, viewer.scene.canvas.clientHeight));
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspect);
const limitingFov = Math.min(verticalFov, horizontalFov);
const fitHeightKm = Math.max(0, footprintMeters) / (2 * Math.tan(limitingFov / 2)) * 1.08 / 1_000;
const desiredHeightKm = clamp(
forceFit ? Math.max(safeLowerKm, fitHeightKm) : (safeLowerKm + safeUpperKm) / 2,
safeLowerKm,
safeUpperKm,
);
const pitch = clamp(Number.isFinite(viewer.camera.pitch) ? viewer.camera.pitch : -1.15, -1.45, -0.65);
const range = desiredHeightKm * 1_000 / Math.max(0.25, Math.sin(-pitch));
const target = Cartesian3.fromDegrees(longitude, latitude, 0, viewer.scene.globe.ellipsoid);
viewer.camera.flyToBoundingSphere(new BoundingSphere(target, 0), {
duration: 0.45,
offset: new HeadingPitchRange(
Number.isFinite(viewer.camera.heading) ? viewer.camera.heading : 0,
pitch,
range,
),
// Do not add Cesium's usual high flight arc: it can cross a neighbouring
// LOD band even though both endpoints belong to the intended band.
maximumHeight: Math.max(desiredHeightKm * 1_000, viewer.camera.positionCartographic.height),
});
return true;
}, [focusCoordinates]);
const focusGridSector = useCallback((sector: GridSectorSelection) => {
let longitude: number;
let latitude: number;
if (sector.mode === "3d") {
const origin = fixedGridOrigin(presentationRef.current);
const anchor = Cartesian3.fromDegrees(origin.longitude, origin.latitude, 0);
const enu = Transforms.eastNorthUpToFixedFrame(anchor);
const tangentCenter = Matrix4.multiplyByPoint(enu, new Cartesian3(
sector.center.eastMeters,
sector.center.northMeters,
0,
), new Cartesian3());
const cartographic = Cartographic.fromCartesian(tangentCenter);
if (!cartographic) return false;
longitude = CesiumMath.toDegrees(cartographic.longitude);
latitude = CesiumMath.toDegrees(cartographic.latitude);
} else {
longitude = sector.center.longitude;
latitude = sector.center.latitude;
}
const footprintMeters = sector.mode === "3d"
? Math.hypot(
sector.bounds.east - sector.bounds.west,
sector.bounds.north - sector.bounds.south,
)
: Math.hypot(
(sector.bounds.east - sector.bounds.west) * 111_320 * Math.cos(CesiumMath.toRadians(sector.center.latitude)),
(sector.bounds.north - sector.bounds.south) * 111_320,
);
const activeLod = gridControllerRef.current?.getActiveLod();
if (!focusGridTarget(longitude, latitude, sector.lod, footprintMeters, false)) return false;
if (activeLod === sector.lod) gridControllerRef.current?.setSelection(sector);
else gridControllerRef.current?.navigateToSelection(sector);
return true;
}, [focusGridTarget]);
const focusGridMajorTile = useCallback((tile: GridMajorTileSelection) => {
if (tile.family === "local-enu-major") {
const origin = fixedGridOrigin(presentationRef.current);
const anchor = Cartesian3.fromDegrees(origin.longitude, origin.latitude, 0);
const enu = Transforms.eastNorthUpToFixedFrame(anchor);
const tangentCenter = Matrix4.multiplyByPoint(enu, new Cartesian3(
tile.center.eastMeters,
tile.center.northMeters,
0,
), new Cartesian3());
const cartographic = Cartographic.fromCartesian(tangentCenter);
return cartographic ? focusGridTarget(
CesiumMath.toDegrees(cartographic.longitude),
CesiumMath.toDegrees(cartographic.latitude),
tile.lod,
Math.hypot(tile.bounds.east - tile.bounds.west, tile.bounds.north - tile.bounds.south),
true,
) : false;
}
return focusGridTarget(
tile.center.longitude,
tile.center.latitude,
tile.lod,
Math.hypot(
(tile.bounds.east - tile.bounds.west) * 111_320 * Math.cos(CesiumMath.toRadians(tile.center.latitude)),
(tile.bounds.north - tile.bounds.south) * 111_320,
),
true,
);
}, [focusGridTarget]);
const focusRuntimeEntity = useCallback((entityId: string) => {
const viewer = viewerRef.current;
if (!viewer || viewer.isDestroyed()) return false;
const entity = runtimeEntities([entityId])[0];
if (!entity) return false;
// A subject selection is an explicit navigation command. Using the
// generic viewport-transfer helper here can produce an imperceptible or
// invalid destination when the current screen centre misses the globe
// (for example near the horizon). Cesium's entity-aware flight resolves
// the time-dynamic position itself and restores the proven operational
// 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]);
const focusSubjectCoordinates = useCallback((longitude: number, latitude: number) => {
const viewer = viewerRef.current;
if (!viewer || viewer.isDestroyed()
|| !Number.isFinite(longitude) || longitude < -180 || longitude > 180
|| !Number.isFinite(latitude) || latitude < -90 || latitude > 90) return false;
// Selection can make a filtered subject visible and request navigation in
// the same React tick. In that case Cesium has not rebuilt its data source
// yet, so entity lookup cannot be the only way to fly to the subject.
// Coordinates from the authorized search/runtime fact provide the same
// explicit, inspectable camera composition without clearing user filters.
const target = Cartesian3.fromDegrees(longitude, latitude, 0, viewer.scene.globe.ellipsoid);
viewer.camera.flyToBoundingSphere(new BoundingSphere(target, 1), {
duration: 0.45,
offset: new HeadingPitchRange(0, -0.9, 8_000),
});
return true;
}, []);
useImperativeHandle(ref, () => ({
startSpiralAnimation,
stopSpiralAnimation,
getCameraView: () => {
const viewer = viewerRef.current;
return viewer && !viewer.isDestroyed() ? getCameraView(viewer) : null;
},
fitRuntimeEntities,
focusCoordinates,
focusRuntimeEntity,
focusSubjectCoordinates,
focusGridSector,
focusGridMajorTile,
}), [fitRuntimeEntities, focusCoordinates, focusGridMajorTile, focusGridSector, focusRuntimeEntity, focusSubjectCoordinates, startSpiralAnimation, stopSpiralAnimation]);
useEffect(() => {
const stopForPageLeave = () => stopSpiralAnimation("stopped");
const resetHiddenClock = () => {
const session = spiralSessionRef.current;
if (!session || !document.hidden) return;
session.lastTimestamp = null;
session.pendingElapsedSeconds = 0;
session.tilesWaitStartedAt = null;
};
window.addEventListener("pagehide", stopForPageLeave);
document.addEventListener("visibilitychange", resetHiddenClock);
return () => {
window.removeEventListener("pagehide", stopForPageLeave);
document.removeEventListener("visibilitychange", resetHiddenClock);
};
}, [stopSpiralAnimation]);
useEffect(() => {
presentationRef.current = presentation;
const activeSpiral = spiralSessionRef.current;
if (activeSpiral && Boolean(activeSpiral.terrainProvider) !== presentation.terrainEnabled) {
stopSpiralAnimation("renderer_restarted");
}
if (viewerRef.current && terrainRef.current) applyPresentation(viewerRef.current, imageryLayerRef.current, buildingsRef.current, terrainRef.current, presentation);
rebuildGridRef.current?.();
const viewer = viewerRef.current;
const terrain = terrainRef.current;
onReadyChangeRef.current?.(Boolean(
viewer
&& !viewer.isDestroyed()
&& terrain
&& (!presentation.terrainEnabled || terrain.world),
));
}, [presentation, stopSpiralAnimation]);
useEffect(() => {
runtimeBindingsRef.current = runtimeBindings;
presentationProfilesRef.current = presentationProfiles;
presentationFiltersRef.current = presentationFilters;
if (viewerRef.current && !viewerRef.current.isDestroyed()) {
syncRuntimeDataSources(
viewerRef.current,
runtimeDataSourcesRef.current,
hGeoZoneLayersRef.current,
runtimeBindings,
presentationProfiles,
presentationFilters,
faultedHGeoZoneGeometryKeysRef.current,
);
}
}, [presentationFilters, presentationProfiles, runtimeBindings]);
useEffect(() => {
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 gridCameraChangedTimer: number | undefined;
let gridResizeTimer: number | undefined;
let removeRefreshRenderListener: (() => void) | undefined;
let removeRenderErrorListener: (() => void) | undefined;
const removeProviderFailureListeners: Array<() => void> = [];
let cancelled = false;
const start = async () => {
try {
const response = await fetch("/api/map/runtime-config");
const config = response.ok ? await response.json() as RuntimeConfig : null;
if (config?.gatewayHealthUrl) {
void fetch(config.gatewayHealthUrl)
.then((healthResponse) => healthResponse.ok ? healthResponse.json() as Promise<MapGatewayHealth> : null)
.then((health) => { if (!cancelled) onGatewayHealth?.(health); })
.catch(() => { if (!cancelled) onGatewayHealth?.(null); });
}
if (!containerRef.current || cancelled) return;
viewer = new Viewer(containerRef.current, {
animation: false,
baseLayer: false,
baseLayerPicker: false,
fullscreenButton: false,
geocoder: false,
homeButton: false,
infoBox: false,
navigationHelpButton: false,
sceneModePicker: false,
selectionIndicator: false,
timeline: false,
requestRenderMode: true,
maximumRenderTimeChange: Number.POSITIVE_INFINITY,
// The default Cesium panel hides the useful UI state and displays
// `[object Object]` for several non-Error render faults. We report a
// safe diagnostic through the Map panel and attempt one bounded
// recovery instead of leaving a modal over a stopped renderer.
showRenderLoopErrors: false,
// Sandbox-only: Cesium writes credits into a dedicated, visually
// suppressed container. Runtime attribution metadata is preserved;
// external and commercial surfaces must provide visible credits.
creditContainer: creditContainerRef.current ?? undefined,
});
const resourceProxy = config?.resourceProxyBase ? new DefaultProxy(config.resourceProxyBase) : undefined;
const buildResource = (url: string) => {
// Put cache intent into the upstream URL itself. Cesium providers
// derive child resources (metadata, imagery tiles, terrain and 3D
// tiles) from this URL; DefaultProxy then forwards the exact intent
// to Gateway for every derived request.
const routedUrl = new URL(url);
// Provider bytes are cached once by Platform Map Gateway. Version
// the browser-facing route so an old malformed HTTP response cannot
// shadow the shared TileCache after a transport fix.
routedUrl.searchParams.set("nodedc_client_revision", "2");
if (!presentationRef.current.cacheEnabled) routedUrl.searchParams.set("nodedc_cache_mode", "passthrough");
if (presentationRef.current.cacheRefresh || !presentationRef.current.cacheNoOverwrite) routedUrl.searchParams.set("nodedc_cache_refresh", "1");
return new Resource({
url: routedUrl.toString(),
proxy: resourceProxy,
});
};
const loadEndpoint = async (assetId: string) => {
if (!config?.gatewayReady) throw new Error("map_gateway_not_ready");
const endpointResponse = await fetch(`${config.assetEndpointBase}/${assetId}/endpoint`);
if (!endpointResponse.ok) throw new Error(`Map Gateway asset ${assetId}: ${endpointResponse.status}`);
return endpointResponse.json() as Promise<IonAssetEndpoint>;
};
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;
gridController = new GridLayerController(viewer, () => onGridSectorSelectRef.current?.(null));
gridControllerRef.current = gridController;
const rebuildGrid = () => gridController?.rebuild(presentationRef.current);
rebuildGridRef.current = rebuildGrid;
removeGridCameraListener = viewer.camera.moveEnd.addEventListener(() => {
if (presentationRef.current.gridRebuildOnMoveEnd) rebuildGrid();
syncHGeoZoneVisibility(viewer!, hGeoZoneLayersRef.current);
onCameraChangeRef.current?.(getCameraView(viewer!));
});
removeGridCameraChangedListener = viewer.camera.changed.addEventListener(() => {
if (presentationRef.current.gridRebuildOnMoveEnd || gridCameraChangedTimer !== undefined) return;
gridCameraChangedTimer = window.setTimeout(() => {
gridCameraChangedTimer = undefined;
rebuildGrid();
}, 150);
});
const providerStatus: MapProviderStatus = {
imagery: config?.gatewayReady ? "loading" : "not-configured",
terrain: config?.gatewayReady ? "loading" : "not-configured",
buildings: config?.gatewayReady ? "loading" : "not-configured",
errors: config?.gatewayReady ? {} : {
imagery: "Platform Map Gateway недоступен",
terrain: "Platform Map Gateway недоступен",
buildings: "Platform Map Gateway недоступен",
},
};
const providerTileFailureLatched = { imagery: false, terrain: false, buildings: false };
const reportProvider = (provider: "imagery" | "terrain" | "buildings", state: MapProviderState, error?: unknown) => {
if (state === "ready" && providerTileFailureLatched[provider]) return;
providerStatus[provider] = state;
if (state === "error") {
providerStatus.errors[provider] = error instanceof Error && error.message ? error.message : "provider_unavailable";
} else {
delete providerStatus.errors[provider];
}
if (!cancelled) onProviderStatus?.({ ...providerStatus, errors: { ...providerStatus.errors } });
};
const latchProviderTileFailure = (provider: "imagery" | "terrain" | "buildings") => {
if (cancelled) return;
// Keep the provider failed until the renderer is recreated. Merely
// emptying Cesium's request queues cannot turn a viewport with a
// failed tile back into a successful cache survey.
providerTileFailureLatched[provider] = true;
stopSpiralAnimation("tile_loading_error");
reportProvider(provider, "error", new Error(`${provider}_tile_failed`));
};
onProviderStatus?.({ ...providerStatus, errors: { ...providerStatus.errors } });
let renderRecoveryScheduled = false;
removeRenderErrorListener = viewer.scene.renderError.addEventListener((_scene, error) => {
stopSpiralAnimation("render_error");
const raw = error instanceof Error ? error.message : "cesium_render_error";
const safe = raw.replace(/[^A-Za-z0-9_.:-]/g, "_").slice(0, 120) || "cesium_render_error";
const projectionQuarantined = quarantineHGeoZoneLayers(
viewer!,
hGeoZoneLayersRef.current,
faultedHGeoZoneGeometryKeysRef.current,
);
if (projectionQuarantined) {
// A domain projection is not a live-provider or TileCache failure.
// Quarantine the exact binding/cursor geometry and resume the base
// scene; a new cursor or renderer restart may try a corrected set.
providerStatus.errors.projection = `hgeozone_quarantined:${safe}`;
if (!cancelled) onProviderStatus?.({ ...providerStatus, errors: { ...providerStatus.errors } });
} else {
reportProvider("imagery", "error", safe);
reportProvider("terrain", "error", safe);
reportProvider("buildings", "error", safe);
}
if (renderRecoveryScheduled) return;
renderRecoveryScheduled = true;
window.setTimeout(() => {
if (cancelled || !viewer || viewer.isDestroyed()) return;
// Cesium's default listener stops its render loop after a scene
// error. One retry handles a transient resource/resize race but
// remains bounded if the browser or GPU fault is persistent.
viewer.useDefaultRenderLoop = true;
viewer.scene.requestRender();
}, 0);
});
// Projection primitives are created only after the scene-level fault
// boundary is active. Their asynchronous Cesium workers must never be
// able to fail before the offending domain layer can be quarantined.
syncRuntimeDataSources(
viewer,
runtimeDataSourcesRef.current,
hGeoZoneLayersRef.current,
runtimeBindingsRef.current,
presentationProfilesRef.current,
presentationFiltersRef.current,
faultedHGeoZoneGeometryKeysRef.current,
);
if (config?.gatewayReady) {
// Do not serialize provider startup. A failure in Bing imagery is
// recoverable and must not stop terrain, buildings, or the scene.
void loadEndpoint("2").then(async (endpoint) => {
if (endpoint.externalType !== "BING" || !endpoint.options?.url || endpoint.credentialMode !== "gateway") throw new Error("cesium_live_imagery_endpoint_invalid");
const imageryProvider = await BingMapsImageryProvider.fromUrl(buildResource(endpoint.options.url), {
// Cesium requires a key-shaped value to form its Bing URL. This
// public marker is stripped by Map Gateway before upstream use.
key: "nodedc-gateway",
mapStyle: (endpoint.options.mapStyle || "Aerial") as BingMapsStyle,
tileProtocol: "https",
});
if (cancelled || !viewer || viewer.isDestroyed()) return;
removeProviderFailureListeners.push(imageryProvider.errorEvent.addEventListener(() => latchProviderTileFailure("imagery")));
for (const attribution of endpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible));
imageryLayerRef.current = viewer.imageryLayers.addImageryProvider(imageryProvider);
applyPresentation(viewer, imageryLayerRef.current, buildingsRef.current, terrain, presentationRef.current);
reportProvider("imagery", "ready");
}).catch((error) => reportProvider("imagery", "error", error));
void loadEndpoint("1").then(async (terrainEndpoint) => {
if (!terrainEndpoint.url || terrainEndpoint.credentialMode !== "gateway") throw new Error("terrain_endpoint_invalid");
const terrainResource = buildResource(terrainEndpoint.url);
const world = await CesiumTerrainProvider.fromUrl(terrainResource, { requestVertexNormals: true, requestWaterMask: true });
if (cancelled || !viewer || viewer.isDestroyed()) return;
removeProviderFailureListeners.push(world.errorEvent.addEventListener(() => latchProviderTileFailure("terrain")));
terrain.world = world;
for (const attribution of terrainEndpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible));
applyPresentation(viewer, imageryLayerRef.current, buildingsRef.current, terrain, presentationRef.current);
onReadyChangeRef.current?.(!presentationRef.current.terrainEnabled || Boolean(terrain.world));
reportProvider("terrain", "ready");
}).catch((error) => reportProvider("terrain", "error", error));
void loadEndpoint("96188").then(async (buildingsEndpoint) => {
if (buildingsEndpoint.type !== "3DTILES" || !buildingsEndpoint.url || buildingsEndpoint.credentialMode !== "gateway") throw new Error("buildings_endpoint_invalid");
const buildingsResource = buildResource(buildingsEndpoint.url);
const buildings = await Cesium3DTileset.fromUrl(buildingsResource);
if (cancelled || !viewer || viewer.isDestroyed()) return;
removeProviderFailureListeners.push(buildings.tileFailed.addEventListener(() => latchProviderTileFailure("buildings")));
viewer.scene.primitives.add(buildings);
buildingsRef.current = buildings;
for (const attribution of buildingsEndpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible));
applyPresentation(viewer, imageryLayerRef.current, buildings, terrain, presentationRef.current);
reportProvider("buildings", "ready");
}).catch((error) => reportProvider("buildings", "error", error));
if (config.gaussianSplatsReady && config.gaussianAssetId) {
void loadEndpoint(config.gaussianAssetId).then(async (gaussianEndpoint) => {
if (!gaussianEndpoint.url || gaussianEndpoint.credentialMode !== "gateway") throw new Error("gaussian_endpoint_invalid");
const gaussianResource = buildResource(gaussianEndpoint.url);
const gaussian = await Cesium3DTileset.fromUrl(gaussianResource);
if (cancelled || !viewer || viewer.isDestroyed()) return;
viewer.scene.primitives.add(gaussian);
}).catch(() => undefined);
}
}
applyPresentation(viewer, imageryLayerRef.current, buildingsRef.current, terrain, presentationRef.current);
if (initialCamera) {
viewer.camera.setView({
destination: Cartesian3.fromDegrees(initialCamera.longitude, initialCamera.latitude, initialCamera.height),
orientation: {
heading: initialCamera.heading,
pitch: initialCamera.pitch,
roll: initialCamera.roll,
},
});
} else {
const center = Cartesian3.fromDegrees(37.618423, 55.751244, 0);
viewer.camera.lookAt(center, new HeadingPitchRange(
0,
-0.9,
40_000,
));
viewer.camera.lookAtTransform(Matrix4.IDENTITY);
}
rebuildGrid();
if (selectedGridSectorRef.current !== undefined) {
gridController.setSelection(selectedGridSectorRef.current);
}
onCameraChangeRef.current?.(getCameraView(viewer));
onReadyChangeRef.current?.(!presentationRef.current.terrainEnabled || Boolean(terrain.world));
if (presentationRef.current.cacheRefresh && onCacheRefreshConsumedRef.current) {
// All root provider resources for the current view were created with
// `nodedc_cache_refresh=1`. Reset after the first render so later
// navigation goes back to the selected steady-state policy.
removeRefreshRenderListener = viewer.scene.postRender.addEventListener(() => {
removeRefreshRenderListener?.();
removeRefreshRenderListener = undefined;
if (!cancelled) onCacheRefreshConsumedRef.current?.();
});
viewer.scene.requestRender();
}
handler = new ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction((movement: { position: Cartesian2 }) => {
const picked = viewer?.scene.pick(movement.position);
const pickedId = picked?.id;
if (pickedId instanceof Entity && pickedId.id && !String(pickedId.id).startsWith("grid/")) {
onSelectRef.current?.(pickedId.id);
return;
}
if (
pickedId
&& typeof pickedId === "object"
&& (pickedId as Partial<HGeoZonePickId>).kind === "nodedc-hgeozone"
&& typeof (pickedId as Partial<HGeoZonePickId>).entityId === "string"
) {
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;
const gridSelection = worldPosition ? gridController?.pick(worldPosition) ?? null : null;
gridController?.setSelection(gridSelection);
onGridSectorSelectRef.current?.(gridSelection);
}, ScreenSpaceEventType.LEFT_CLICK);
resizeObserver = new ResizeObserver(() => {
if (!viewer || viewer.isDestroyed()) return;
viewer.resize();
viewer.scene.requestRender();
if (gridResizeTimer === undefined) {
gridResizeTimer = window.setTimeout(() => {
gridResizeTimer = undefined;
rebuildGrid();
}, 100);
}
});
resizeObserver.observe(containerRef.current);
} catch (error) {
// Failure before viewer creation is distinct from a provider failure;
// surface it through the Inspector without exposing any credentials.
if (!cancelled) onProviderStatus?.({
imagery: "error",
terrain: "error",
buildings: "error",
errors: {
imagery: error instanceof Error ? error.message : "map_start_failed",
terrain: error instanceof Error ? error.message : "map_start_failed",
buildings: error instanceof Error ? error.message : "map_start_failed",
},
});
}
};
void start();
return () => {
cancelled = true;
stopSpiralAnimation("renderer_restarted");
onReadyChangeRef.current?.(false);
resizeObserver?.disconnect();
removeGridCameraListener?.();
removeGridCameraChangedListener?.();
if (gridCameraChangedTimer !== undefined) window.clearTimeout(gridCameraChangedTimer);
if (gridResizeTimer !== undefined) window.clearTimeout(gridResizeTimer);
removeRefreshRenderListener?.();
removeRenderErrorListener?.();
for (const removeListener of removeProviderFailureListeners) removeListener();
handler?.destroy();
gridController?.destroy();
gridControllerRef.current = null;
if (viewer && !viewer.isDestroyed()) viewer.destroy();
viewerRef.current = null;
imageryLayerRef.current = null;
buildingsRef.current = null;
terrainRef.current = null;
rebuildGridRef.current = null;
runtimeDataSourcesRef.current.clear();
hGeoZoneLayersRef.current.clear();
faultedHGeoZoneGeometryKeysRef.current.clear();
};
}, [onGatewayHealth, onProviderStatus, stopSpiralAnimation]);
return (
<div className="catalog-cesium-map">
<div ref={containerRef} className="catalog-cesium-map__canvas" />
<div ref={creditContainerRef} className="catalog-cesium-map__credits" aria-hidden="true" />
</div>
);
});