feat(map): add synthetic moving target layer
This commit is contained in:
@@ -68,3 +68,25 @@ recreates the local Cesium viewer and never changes the TileCache write intent.
|
|||||||
|
|
||||||
The package never creates browser, application, Docker or per-user TileCache
|
The package never creates browser, application, Docker or per-user TileCache
|
||||||
storage.
|
storage.
|
||||||
|
|
||||||
|
## Optional synthetic target layer
|
||||||
|
|
||||||
|
The adapter accepts an optional provider-neutral Ghost Pin layer for bounded
|
||||||
|
product sandboxes. It uses the canonical `elevated-spike` presentation:
|
||||||
|
terrain-relative stem, outlined head, label plate and independent camera-height
|
||||||
|
visibility for target and label. The layer is controlled by the existing
|
||||||
|
`targets` visibility flag and does not create a Data Product, Engine command or
|
||||||
|
authoritative `map.moving_object`.
|
||||||
|
|
||||||
|
Consumers own generation, persistence and explicit removal of synthetic
|
||||||
|
positions. The adapter only:
|
||||||
|
|
||||||
|
- resolves the centre of the current visible globe through its imperative
|
||||||
|
`getViewAnchor()` handle;
|
||||||
|
- renders declared numeric positions;
|
||||||
|
- advances optional motion inside the declared radius; and
|
||||||
|
- returns the current positions through `snapshotGhostPins()` for an explicit
|
||||||
|
product save.
|
||||||
|
|
||||||
|
Motion never recreates the Cesium viewer or any provider. A renderer teardown
|
||||||
|
stops its single animation frame loop.
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
import type * as CesiumModule from "cesium";
|
import type * as CesiumModule from "cesium";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
CesiumMapRendererHandle,
|
||||||
CesiumMapRendererProps,
|
CesiumMapRendererProps,
|
||||||
MapCamera,
|
MapCamera,
|
||||||
|
MapGhostPin,
|
||||||
|
MapGhostPinLayer,
|
||||||
MapProviderId,
|
MapProviderId,
|
||||||
MapRuntimeState,
|
MapRuntimeState,
|
||||||
} from "./contracts.js";
|
} from "./contracts.js";
|
||||||
|
import { advanceGhostPins } from "./ghostPins.js";
|
||||||
import {
|
import {
|
||||||
parseProviderEndpoint,
|
parseProviderEndpoint,
|
||||||
type ProviderEndpoint,
|
type ProviderEndpoint,
|
||||||
@@ -30,31 +39,54 @@ type RendererRuntime = {
|
|||||||
imageryLayer: CesiumModule.ImageryLayer | null;
|
imageryLayer: CesiumModule.ImageryLayer | null;
|
||||||
buildings: CesiumModule.Cesium3DTileset | null;
|
buildings: CesiumModule.Cesium3DTileset | null;
|
||||||
grid: CesiumModule.CustomDataSource;
|
grid: CesiumModule.CustomDataSource;
|
||||||
|
ghostPins: CesiumModule.CustomDataSource;
|
||||||
|
currentGhostPins: MapGhostPin[];
|
||||||
|
ghostPinSourceSignature: string;
|
||||||
|
stopGhostPinAnimation: (() => void) | null;
|
||||||
rebuildGrid: () => void;
|
rebuildGrid: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CesiumMapRenderer({
|
export const CesiumMapRenderer = forwardRef<
|
||||||
runtimeConfigUrl,
|
CesiumMapRendererHandle,
|
||||||
camera,
|
CesiumMapRendererProps
|
||||||
layers,
|
>(function CesiumMapRenderer({
|
||||||
settings,
|
runtimeConfigUrl,
|
||||||
cacheIntent,
|
camera,
|
||||||
rendererGeneration = 0,
|
layers,
|
||||||
className,
|
settings,
|
||||||
onRuntimeStateChange,
|
cacheIntent,
|
||||||
onCameraChange,
|
ghostPins,
|
||||||
}: CesiumMapRendererProps) {
|
rendererGeneration = 0,
|
||||||
|
className,
|
||||||
|
onRuntimeStateChange,
|
||||||
|
onCameraChange,
|
||||||
|
}, forwardedRef) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const stateCallbackRef = useRef(onRuntimeStateChange);
|
const stateCallbackRef = useRef(onRuntimeStateChange);
|
||||||
const cameraCallbackRef = useRef(onCameraChange);
|
const cameraCallbackRef = useRef(onCameraChange);
|
||||||
const runtimeRef = useRef<RendererRuntime | null>(null);
|
const runtimeRef = useRef<RendererRuntime | null>(null);
|
||||||
const settingsRef = useRef(settings);
|
const settingsRef = useRef(settings);
|
||||||
const layersRef = useRef(layers);
|
const layersRef = useRef(layers);
|
||||||
|
const ghostPinsRef = useRef(ghostPins);
|
||||||
|
|
||||||
stateCallbackRef.current = onRuntimeStateChange;
|
stateCallbackRef.current = onRuntimeStateChange;
|
||||||
cameraCallbackRef.current = onCameraChange;
|
cameraCallbackRef.current = onCameraChange;
|
||||||
settingsRef.current = settings;
|
settingsRef.current = settings;
|
||||||
layersRef.current = layers;
|
layersRef.current = layers;
|
||||||
|
ghostPinsRef.current = ghostPins;
|
||||||
|
|
||||||
|
useImperativeHandle(forwardedRef, () => ({
|
||||||
|
getViewAnchor: () => {
|
||||||
|
const runtime = runtimeRef.current;
|
||||||
|
return runtime ? viewAnchor(runtime) : null;
|
||||||
|
},
|
||||||
|
snapshotGhostPins: () => {
|
||||||
|
const runtime = runtimeRef.current;
|
||||||
|
return runtime
|
||||||
|
? runtime.currentGhostPins.map((pin) => ({ ...pin }))
|
||||||
|
: null;
|
||||||
|
},
|
||||||
|
}), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const runtime = runtimeRef.current;
|
const runtime = runtimeRef.current;
|
||||||
@@ -63,7 +95,18 @@ export function CesiumMapRenderer({
|
|||||||
}
|
}
|
||||||
applyPresentation(runtime, settings, layers);
|
applyPresentation(runtime, settings, layers);
|
||||||
runtime.rebuildGrid();
|
runtime.rebuildGrid();
|
||||||
}, [settings, layers]);
|
syncGhostPinLayer(runtime, ghostPins);
|
||||||
|
updateGhostPinAnimation(
|
||||||
|
runtime,
|
||||||
|
Boolean(
|
||||||
|
ghostPins?.simulation_enabled
|
||||||
|
&& layers.targets
|
||||||
|
&& ghostPins.pins.length > 0
|
||||||
|
),
|
||||||
|
ghostPinsRef,
|
||||||
|
layersRef,
|
||||||
|
);
|
||||||
|
}, [settings, layers, ghostPins]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
@@ -101,7 +144,11 @@ export function CesiumMapRenderer({
|
|||||||
viewer = createViewer(Cesium, container, creditContainer);
|
viewer = createViewer(Cesium, container, creditContainer);
|
||||||
const ellipsoidTerrain = new Cesium.EllipsoidTerrainProvider();
|
const ellipsoidTerrain = new Cesium.EllipsoidTerrainProvider();
|
||||||
const grid = new Cesium.CustomDataSource("nodedc-map-grid");
|
const grid = new Cesium.CustomDataSource("nodedc-map-grid");
|
||||||
|
const ghostPinDataSource = new Cesium.CustomDataSource(
|
||||||
|
"nodedc-map-ghost-pins",
|
||||||
|
);
|
||||||
void viewer.dataSources.add(grid);
|
void viewer.dataSources.add(grid);
|
||||||
|
void viewer.dataSources.add(ghostPinDataSource);
|
||||||
const runtime: RendererRuntime = {
|
const runtime: RendererRuntime = {
|
||||||
Cesium,
|
Cesium,
|
||||||
viewer,
|
viewer,
|
||||||
@@ -110,6 +157,10 @@ export function CesiumMapRenderer({
|
|||||||
imageryLayer: null,
|
imageryLayer: null,
|
||||||
buildings: null,
|
buildings: null,
|
||||||
grid,
|
grid,
|
||||||
|
ghostPins: ghostPinDataSource,
|
||||||
|
currentGhostPins: [],
|
||||||
|
ghostPinSourceSignature: "",
|
||||||
|
stopGhostPinAnimation: null,
|
||||||
rebuildGrid: () => undefined,
|
rebuildGrid: () => undefined,
|
||||||
};
|
};
|
||||||
runtime.rebuildGrid = () => {
|
runtime.rebuildGrid = () => {
|
||||||
@@ -121,6 +172,17 @@ export function CesiumMapRenderer({
|
|||||||
applyPresentation(runtime, settingsRef.current, layersRef.current);
|
applyPresentation(runtime, settingsRef.current, layersRef.current);
|
||||||
applyCamera(Cesium, viewer, camera);
|
applyCamera(Cesium, viewer, camera);
|
||||||
runtime.rebuildGrid();
|
runtime.rebuildGrid();
|
||||||
|
syncGhostPinLayer(runtime, ghostPinsRef.current);
|
||||||
|
updateGhostPinAnimation(
|
||||||
|
runtime,
|
||||||
|
Boolean(
|
||||||
|
ghostPinsRef.current?.simulation_enabled
|
||||||
|
&& layersRef.current.targets
|
||||||
|
&& ghostPinsRef.current.pins.length > 0
|
||||||
|
),
|
||||||
|
ghostPinsRef,
|
||||||
|
layersRef,
|
||||||
|
);
|
||||||
removeCameraListener = bindCamera(
|
removeCameraListener = bindCamera(
|
||||||
Cesium,
|
Cesium,
|
||||||
viewer,
|
viewer,
|
||||||
@@ -182,6 +244,7 @@ export function CesiumMapRenderer({
|
|||||||
if (disposed) {
|
if (disposed) {
|
||||||
removeCameraListener?.();
|
removeCameraListener?.();
|
||||||
removeRenderListener?.();
|
removeRenderListener?.();
|
||||||
|
runtime.stopGhostPinAnimation?.();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!abortController.signal.aborted) {
|
if (!abortController.signal.aborted) {
|
||||||
@@ -204,6 +267,7 @@ export function CesiumMapRenderer({
|
|||||||
abortController.abort();
|
abortController.abort();
|
||||||
removeCameraListener?.();
|
removeCameraListener?.();
|
||||||
removeRenderListener?.();
|
removeRenderListener?.();
|
||||||
|
runtimeRef.current?.stopGhostPinAnimation?.();
|
||||||
runtimeRef.current = null;
|
runtimeRef.current = null;
|
||||||
viewer?.destroy();
|
viewer?.destroy();
|
||||||
viewer = null;
|
viewer = null;
|
||||||
@@ -220,7 +284,7 @@ export function CesiumMapRenderer({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return <div ref={containerRef} className={className} data-nodedc-map-renderer="cesium" />;
|
return <div ref={containerRef} className={className} data-nodedc-map-renderer="cesium" />;
|
||||||
}
|
});
|
||||||
|
|
||||||
function createViewer(
|
function createViewer(
|
||||||
Cesium: CesiumNamespace,
|
Cesium: CesiumNamespace,
|
||||||
@@ -426,6 +490,7 @@ function applyPresentation(
|
|||||||
color: `color('${settings.buildings_color}', ${settings.buildings_opacity})`,
|
color: `color('${settings.buildings_color}', ${settings.buildings_opacity})`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
runtime.ghostPins.show = layers.targets;
|
||||||
viewer.terrainProvider =
|
viewer.terrainProvider =
|
||||||
layers.terrain && runtime.worldTerrain
|
layers.terrain && runtime.worldTerrain
|
||||||
? runtime.worldTerrain
|
? runtime.worldTerrain
|
||||||
@@ -486,6 +551,276 @@ function applyPresentation(
|
|||||||
viewer.scene.requestRender();
|
viewer.scene.requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncGhostPinLayer(
|
||||||
|
runtime: RendererRuntime,
|
||||||
|
layer: MapGhostPinLayer | undefined,
|
||||||
|
): void {
|
||||||
|
const nextPins = layer?.pins ?? [];
|
||||||
|
const sourceSignature = nextPins.map((pin) => [
|
||||||
|
pin.id,
|
||||||
|
pin.label,
|
||||||
|
pin.longitude,
|
||||||
|
pin.latitude,
|
||||||
|
pin.heading_degrees,
|
||||||
|
pin.speed_meters_per_second,
|
||||||
|
].join(":")).join("|");
|
||||||
|
if (sourceSignature !== runtime.ghostPinSourceSignature) {
|
||||||
|
runtime.currentGhostPins = nextPins.map((pin) => ({ ...pin }));
|
||||||
|
runtime.ghostPinSourceSignature = sourceSignature;
|
||||||
|
}
|
||||||
|
rebuildGhostPinEntities(runtime, layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildGhostPinEntities(
|
||||||
|
runtime: RendererRuntime,
|
||||||
|
layer: MapGhostPinLayer | undefined,
|
||||||
|
): void {
|
||||||
|
const { Cesium, viewer, ghostPins } = runtime;
|
||||||
|
ghostPins.entities.removeAll();
|
||||||
|
if (!layer) {
|
||||||
|
viewer.scene.requestRender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const presentation = layer.presentation;
|
||||||
|
const fillColor = cssColor(Cesium, presentation.color, "#6fb5fb").withAlpha(
|
||||||
|
clamp(presentation.opacity, 0, 1),
|
||||||
|
);
|
||||||
|
const outlineColor = cssColor(
|
||||||
|
Cesium,
|
||||||
|
presentation.outline_color,
|
||||||
|
"#0c0d12",
|
||||||
|
).withAlpha(clamp(presentation.outline_opacity, 0, 1));
|
||||||
|
const headImage = elevatedTargetImage(
|
||||||
|
fillColor,
|
||||||
|
outlineColor,
|
||||||
|
presentation.outline_width_px,
|
||||||
|
presentation.head_size_px,
|
||||||
|
);
|
||||||
|
const targetDistance = new Cesium.DistanceDisplayCondition(
|
||||||
|
0,
|
||||||
|
presentation.target_hide_camera_height_meters,
|
||||||
|
);
|
||||||
|
const labelDistance = new Cesium.DistanceDisplayCondition(
|
||||||
|
0,
|
||||||
|
presentation.label_hide_camera_height_meters,
|
||||||
|
);
|
||||||
|
for (const pin of runtime.currentGhostPins) {
|
||||||
|
const groundHeight = ghostPinGroundHeight(runtime, pin);
|
||||||
|
const headHeight = groundHeight + presentation.stem_height_meters;
|
||||||
|
const label = presentation.label_mode === "subject_id"
|
||||||
|
? pin.id
|
||||||
|
: pin.label;
|
||||||
|
ghostPins.entities.add({
|
||||||
|
id: pin.id,
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(
|
||||||
|
pin.longitude,
|
||||||
|
pin.latitude,
|
||||||
|
headHeight,
|
||||||
|
),
|
||||||
|
polyline: {
|
||||||
|
positions: [
|
||||||
|
Cesium.Cartesian3.fromDegrees(
|
||||||
|
pin.longitude,
|
||||||
|
pin.latitude,
|
||||||
|
groundHeight,
|
||||||
|
),
|
||||||
|
Cesium.Cartesian3.fromDegrees(
|
||||||
|
pin.longitude,
|
||||||
|
pin.latitude,
|
||||||
|
headHeight,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
width: presentation.stem_width_px,
|
||||||
|
material: fillColor,
|
||||||
|
distanceDisplayCondition: targetDistance,
|
||||||
|
},
|
||||||
|
billboard: {
|
||||||
|
image: headImage.image,
|
||||||
|
width: headImage.size,
|
||||||
|
height: headImage.size,
|
||||||
|
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
|
||||||
|
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||||||
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
|
distanceDisplayCondition: targetDistance,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
text: label.slice(0, presentation.label_max_length),
|
||||||
|
font: `${presentation.label_font_weight} ${presentation.label_size_px}px Arial`,
|
||||||
|
fillColor: cssColor(
|
||||||
|
Cesium,
|
||||||
|
presentation.label_color,
|
||||||
|
"#f5f5f5",
|
||||||
|
),
|
||||||
|
outlineColor: Cesium.Color.TRANSPARENT,
|
||||||
|
outlineWidth: 0,
|
||||||
|
style: Cesium.LabelStyle.FILL,
|
||||||
|
show: presentation.label_mode !== "none",
|
||||||
|
showBackground: presentation.label_background_opacity > 0,
|
||||||
|
backgroundColor: cssColor(
|
||||||
|
Cesium,
|
||||||
|
presentation.label_background_color,
|
||||||
|
"#0c0d12",
|
||||||
|
).withAlpha(clamp(presentation.label_background_opacity, 0, 1)),
|
||||||
|
backgroundPadding: new Cesium.Cartesian2(
|
||||||
|
presentation.label_padding_x,
|
||||||
|
presentation.label_padding_y,
|
||||||
|
),
|
||||||
|
pixelOffset: new Cesium.Cartesian2(
|
||||||
|
presentation.label_offset_x,
|
||||||
|
presentation.label_offset_y,
|
||||||
|
),
|
||||||
|
horizontalOrigin: Cesium.HorizontalOrigin.LEFT,
|
||||||
|
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
||||||
|
heightReference: Cesium.HeightReference.NONE,
|
||||||
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
|
distanceDisplayCondition: labelDistance,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
viewer.scene.requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function animateGhostPins(
|
||||||
|
runtime: RendererRuntime,
|
||||||
|
layerRef: { current: MapGhostPinLayer | undefined },
|
||||||
|
layersRef: { current: CesiumMapRendererProps["layers"] },
|
||||||
|
): () => void {
|
||||||
|
let frame = 0;
|
||||||
|
let previousTime = performance.now();
|
||||||
|
const tick = (time: number) => {
|
||||||
|
const layer = layerRef.current;
|
||||||
|
const elapsedSeconds = (time - previousTime) / 1000;
|
||||||
|
previousTime = time;
|
||||||
|
if (
|
||||||
|
layer?.simulation_enabled
|
||||||
|
&& layersRef.current.targets
|
||||||
|
&& runtime.currentGhostPins.length > 0
|
||||||
|
) {
|
||||||
|
runtime.currentGhostPins = advanceGhostPins(
|
||||||
|
runtime.currentGhostPins,
|
||||||
|
layer.anchor,
|
||||||
|
layer.radius_meters,
|
||||||
|
elapsedSeconds,
|
||||||
|
);
|
||||||
|
updateGhostPinPositions(runtime, layer);
|
||||||
|
runtime.viewer.scene.requestRender();
|
||||||
|
}
|
||||||
|
frame = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
frame = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGhostPinAnimation(
|
||||||
|
runtime: RendererRuntime,
|
||||||
|
active: boolean,
|
||||||
|
layerRef: { current: MapGhostPinLayer | undefined },
|
||||||
|
layersRef: { current: CesiumMapRendererProps["layers"] },
|
||||||
|
): void {
|
||||||
|
runtime.stopGhostPinAnimation?.();
|
||||||
|
runtime.stopGhostPinAnimation = active
|
||||||
|
? animateGhostPins(runtime, layerRef, layersRef)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGhostPinPositions(
|
||||||
|
runtime: RendererRuntime,
|
||||||
|
layer: MapGhostPinLayer,
|
||||||
|
): void {
|
||||||
|
const { Cesium, ghostPins } = runtime;
|
||||||
|
for (const pin of runtime.currentGhostPins) {
|
||||||
|
const entity = ghostPins.entities.getById(pin.id);
|
||||||
|
if (!entity) continue;
|
||||||
|
const groundHeight = ghostPinGroundHeight(runtime, pin);
|
||||||
|
const headHeight =
|
||||||
|
groundHeight + layer.presentation.stem_height_meters;
|
||||||
|
entity.position = new Cesium.ConstantPositionProperty(
|
||||||
|
Cesium.Cartesian3.fromDegrees(pin.longitude, pin.latitude, headHeight),
|
||||||
|
);
|
||||||
|
if (entity.polyline) {
|
||||||
|
entity.polyline.positions = new Cesium.ConstantProperty([
|
||||||
|
Cesium.Cartesian3.fromDegrees(
|
||||||
|
pin.longitude,
|
||||||
|
pin.latitude,
|
||||||
|
groundHeight,
|
||||||
|
),
|
||||||
|
Cesium.Cartesian3.fromDegrees(
|
||||||
|
pin.longitude,
|
||||||
|
pin.latitude,
|
||||||
|
headHeight,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ghostPinGroundHeight(
|
||||||
|
runtime: RendererRuntime,
|
||||||
|
pin: MapGhostPin,
|
||||||
|
): number {
|
||||||
|
const cartographic = runtime.Cesium.Cartographic.fromDegrees(
|
||||||
|
pin.longitude,
|
||||||
|
pin.latitude,
|
||||||
|
);
|
||||||
|
return runtime.viewer.scene.globe.getHeight(cartographic) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewAnchor(runtime: RendererRuntime) {
|
||||||
|
const { Cesium, viewer } = runtime;
|
||||||
|
const canvas = viewer.scene.canvas;
|
||||||
|
const center = new Cesium.Cartesian2(
|
||||||
|
canvas.clientWidth / 2,
|
||||||
|
canvas.clientHeight / 2,
|
||||||
|
);
|
||||||
|
const ray = viewer.camera.getPickRay(center);
|
||||||
|
const intersection = ray
|
||||||
|
? viewer.scene.globe.pick(ray, viewer.scene)
|
||||||
|
: undefined;
|
||||||
|
const cartographic = intersection
|
||||||
|
? Cesium.Cartographic.fromCartesian(intersection)
|
||||||
|
: viewer.camera.positionCartographic;
|
||||||
|
if (!cartographic) return null;
|
||||||
|
return {
|
||||||
|
longitude: Cesium.Math.toDegrees(cartographic.longitude),
|
||||||
|
latitude: Cesium.Math.toDegrees(cartographic.latitude),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const elevatedTargetImageCache = new Map<string, string>();
|
||||||
|
|
||||||
|
function elevatedTargetImage(
|
||||||
|
fillColor: CesiumModule.Color,
|
||||||
|
outlineColor: CesiumModule.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 };
|
||||||
|
}
|
||||||
|
|
||||||
function rebuildElevatedGrid(
|
function rebuildElevatedGrid(
|
||||||
runtime: RendererRuntime,
|
runtime: RendererRuntime,
|
||||||
settings: CesiumMapRendererProps["settings"],
|
settings: CesiumMapRendererProps["settings"],
|
||||||
|
|||||||
@@ -57,6 +57,52 @@ export type MapCamera = {
|
|||||||
roll: number;
|
roll: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MapGeoPoint = {
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapGhostPin = MapGeoPoint & {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
heading_degrees: number;
|
||||||
|
speed_meters_per_second: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapGhostPinLabelMode = "subject_id" | "attributes" | "none";
|
||||||
|
|
||||||
|
export type MapGhostPinPresentation = {
|
||||||
|
color: string;
|
||||||
|
opacity: number;
|
||||||
|
stem_height_meters: number;
|
||||||
|
head_size_px: number;
|
||||||
|
stem_width_px: number;
|
||||||
|
outline_color: string;
|
||||||
|
outline_opacity: number;
|
||||||
|
outline_width_px: number;
|
||||||
|
label_mode: MapGhostPinLabelMode;
|
||||||
|
label_font_weight: number;
|
||||||
|
label_size_px: number;
|
||||||
|
label_color: string;
|
||||||
|
label_background_color: string;
|
||||||
|
label_background_opacity: number;
|
||||||
|
label_padding_x: number;
|
||||||
|
label_padding_y: number;
|
||||||
|
label_offset_x: number;
|
||||||
|
label_offset_y: number;
|
||||||
|
label_max_length: number;
|
||||||
|
label_hide_camera_height_meters: number;
|
||||||
|
target_hide_camera_height_meters: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapGhostPinLayer = {
|
||||||
|
anchor: MapGeoPoint | null;
|
||||||
|
radius_meters: number;
|
||||||
|
simulation_enabled: boolean;
|
||||||
|
pins: readonly MapGhostPin[];
|
||||||
|
presentation: MapGhostPinPresentation;
|
||||||
|
};
|
||||||
|
|
||||||
export type MapLayerVisibility = {
|
export type MapLayerVisibility = {
|
||||||
imagery: boolean;
|
imagery: boolean;
|
||||||
terrain: boolean;
|
terrain: boolean;
|
||||||
@@ -119,8 +165,14 @@ export type CesiumMapRendererProps = {
|
|||||||
layers: MapLayerVisibility;
|
layers: MapLayerVisibility;
|
||||||
settings: MapVisualSettings;
|
settings: MapVisualSettings;
|
||||||
cacheIntent: MapCacheIntent;
|
cacheIntent: MapCacheIntent;
|
||||||
|
ghostPins?: MapGhostPinLayer;
|
||||||
rendererGeneration?: number;
|
rendererGeneration?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
onRuntimeStateChange?: (state: MapRuntimeState) => void;
|
onRuntimeStateChange?: (state: MapRuntimeState) => void;
|
||||||
onCameraChange?: (camera: MapCamera) => void;
|
onCameraChange?: (camera: MapCamera) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CesiumMapRendererHandle = {
|
||||||
|
getViewAnchor: () => MapGeoPoint | null;
|
||||||
|
snapshotGhostPins: () => MapGhostPin[] | null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type { MapGeoPoint, MapGhostPin } from "./contracts.js";
|
||||||
|
|
||||||
|
const EARTH_RADIUS_METERS = 6_371_008.8;
|
||||||
|
|
||||||
|
export function advanceGhostPins(
|
||||||
|
pins: readonly MapGhostPin[],
|
||||||
|
anchor: MapGeoPoint | null,
|
||||||
|
radiusMeters: number,
|
||||||
|
elapsedSeconds: number,
|
||||||
|
): MapGhostPin[] {
|
||||||
|
const safeElapsed = clamp(elapsedSeconds, 0, 1);
|
||||||
|
const safeRadius = Math.max(1, radiusMeters);
|
||||||
|
if (safeElapsed === 0) {
|
||||||
|
return pins.map((pin) => ({ ...pin }));
|
||||||
|
}
|
||||||
|
return pins.map((pin) => {
|
||||||
|
let heading = normalizeHeading(pin.heading_degrees);
|
||||||
|
if (anchor) {
|
||||||
|
const distanceFromAnchor = geodesicDistanceMeters(anchor, pin);
|
||||||
|
const projectedDistance =
|
||||||
|
distanceFromAnchor + Math.max(0, pin.speed_meters_per_second) * safeElapsed;
|
||||||
|
if (projectedDistance >= safeRadius * 0.98) {
|
||||||
|
heading = initialBearingDegrees(pin, anchor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const position = destinationPoint(
|
||||||
|
pin,
|
||||||
|
heading,
|
||||||
|
Math.max(0, pin.speed_meters_per_second) * safeElapsed,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...pin,
|
||||||
|
...position,
|
||||||
|
heading_degrees: heading,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destinationPoint(
|
||||||
|
origin: MapGeoPoint,
|
||||||
|
headingDegrees: number,
|
||||||
|
distanceMeters: number,
|
||||||
|
): MapGeoPoint {
|
||||||
|
const angularDistance = Math.max(0, distanceMeters) / EARTH_RADIUS_METERS;
|
||||||
|
const bearing = toRadians(normalizeHeading(headingDegrees));
|
||||||
|
const latitude = toRadians(origin.latitude);
|
||||||
|
const longitude = toRadians(origin.longitude);
|
||||||
|
const nextLatitude = Math.asin(
|
||||||
|
Math.sin(latitude) * Math.cos(angularDistance)
|
||||||
|
+ Math.cos(latitude) * Math.sin(angularDistance) * Math.cos(bearing),
|
||||||
|
);
|
||||||
|
const nextLongitude = longitude + Math.atan2(
|
||||||
|
Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(latitude),
|
||||||
|
Math.cos(angularDistance) - Math.sin(latitude) * Math.sin(nextLatitude),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
longitude: normalizeLongitude(toDegrees(nextLongitude)),
|
||||||
|
latitude: clamp(toDegrees(nextLatitude), -90, 90),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function geodesicDistanceMeters(
|
||||||
|
first: MapGeoPoint,
|
||||||
|
second: MapGeoPoint,
|
||||||
|
): number {
|
||||||
|
const latitudeDelta = toRadians(second.latitude - first.latitude);
|
||||||
|
const longitudeDelta = toRadians(second.longitude - first.longitude);
|
||||||
|
const firstLatitude = toRadians(first.latitude);
|
||||||
|
const secondLatitude = toRadians(second.latitude);
|
||||||
|
const haversine =
|
||||||
|
Math.sin(latitudeDelta / 2) ** 2
|
||||||
|
+ Math.cos(firstLatitude)
|
||||||
|
* Math.cos(secondLatitude)
|
||||||
|
* Math.sin(longitudeDelta / 2) ** 2;
|
||||||
|
return 2 * EARTH_RADIUS_METERS * Math.asin(Math.min(1, Math.sqrt(haversine)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialBearingDegrees(
|
||||||
|
origin: MapGeoPoint,
|
||||||
|
destination: MapGeoPoint,
|
||||||
|
): number {
|
||||||
|
const firstLatitude = toRadians(origin.latitude);
|
||||||
|
const secondLatitude = toRadians(destination.latitude);
|
||||||
|
const longitudeDelta = toRadians(destination.longitude - origin.longitude);
|
||||||
|
const y = Math.sin(longitudeDelta) * Math.cos(secondLatitude);
|
||||||
|
const x =
|
||||||
|
Math.cos(firstLatitude) * Math.sin(secondLatitude)
|
||||||
|
- Math.sin(firstLatitude)
|
||||||
|
* Math.cos(secondLatitude)
|
||||||
|
* Math.cos(longitudeDelta);
|
||||||
|
return normalizeHeading(toDegrees(Math.atan2(y, x)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHeading(value: number): number {
|
||||||
|
return ((value % 360) + 360) % 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLongitude(value: number): number {
|
||||||
|
return ((value + 540) % 360) - 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRadians(value: number): number {
|
||||||
|
return value * Math.PI / 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDegrees(value: number): number {
|
||||||
|
return value * 180 / Math.PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, minimum: number, maximum: number): number {
|
||||||
|
return Math.min(maximum, Math.max(minimum, value));
|
||||||
|
}
|
||||||
@@ -5,9 +5,15 @@ export {
|
|||||||
MAP_RUNTIME_SCHEMA_VERSION,
|
MAP_RUNTIME_SCHEMA_VERSION,
|
||||||
} from "./contracts.js";
|
} from "./contracts.js";
|
||||||
export type {
|
export type {
|
||||||
|
CesiumMapRendererHandle,
|
||||||
CesiumMapRendererProps,
|
CesiumMapRendererProps,
|
||||||
MapCacheIntent,
|
MapCacheIntent,
|
||||||
MapCamera,
|
MapCamera,
|
||||||
|
MapGeoPoint,
|
||||||
|
MapGhostPin,
|
||||||
|
MapGhostPinLabelMode,
|
||||||
|
MapGhostPinLayer,
|
||||||
|
MapGhostPinPresentation,
|
||||||
MapLayerVisibility,
|
MapLayerVisibility,
|
||||||
MapProviderId,
|
MapProviderId,
|
||||||
MapProviderPhase,
|
MapProviderPhase,
|
||||||
@@ -17,6 +23,11 @@ export type {
|
|||||||
MapRuntimeState,
|
MapRuntimeState,
|
||||||
MapVisualSettings,
|
MapVisualSettings,
|
||||||
} from "./contracts.js";
|
} from "./contracts.js";
|
||||||
|
export {
|
||||||
|
advanceGhostPins,
|
||||||
|
destinationPoint,
|
||||||
|
geodesicDistanceMeters,
|
||||||
|
} from "./ghostPins.js";
|
||||||
export {
|
export {
|
||||||
MapRuntimeError,
|
MapRuntimeError,
|
||||||
applyCacheIntent,
|
applyCacheIntent,
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
advanceGhostPins,
|
||||||
|
destinationPoint,
|
||||||
|
geodesicDistanceMeters,
|
||||||
|
} from "../dist/ghostPins.js";
|
||||||
|
|
||||||
|
test("destinationPoint advances a pin over the globe", () => {
|
||||||
|
const origin = { longitude: 37.618423, latitude: 55.751244 };
|
||||||
|
const destination = destinationPoint(origin, 90, 1_000);
|
||||||
|
assert.ok(destination.longitude > origin.longitude);
|
||||||
|
assert.ok(Math.abs(destination.latitude - origin.latitude) < 0.001);
|
||||||
|
assert.ok(Math.abs(geodesicDistanceMeters(origin, destination) - 1_000) < 0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("advanceGhostPins turns an escaping pin back toward its anchor", () => {
|
||||||
|
const anchor = { longitude: 37.618423, latitude: 55.751244 };
|
||||||
|
const edge = destinationPoint(anchor, 90, 990);
|
||||||
|
const [advanced] = advanceGhostPins([
|
||||||
|
{
|
||||||
|
id: "ghost-1",
|
||||||
|
label: "123456",
|
||||||
|
...edge,
|
||||||
|
heading_degrees: 90,
|
||||||
|
speed_meters_per_second: 20,
|
||||||
|
},
|
||||||
|
], anchor, 1_000, 1);
|
||||||
|
assert.ok(geodesicDistanceMeters(anchor, advanced) < 990);
|
||||||
|
assert.ok(advanced.heading_degrees > 260 && advanced.heading_degrees < 280);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user