3187 lines
137 KiB
TypeScript
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>
|
|
);
|
|
});
|