feat(map): add synthetic moving target layer

This commit is contained in:
Codex
2026-07-29 18:28:54 +03:00
parent 65644a8609
commit 08a3c47391
6 changed files with 578 additions and 14 deletions
+22
View File
@@ -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
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 {
CesiumMapRendererHandle,
CesiumMapRendererProps,
MapCamera,
MapGhostPin,
MapGhostPinLayer,
MapProviderId,
MapRuntimeState,
} from "./contracts.js";
import { advanceGhostPins } from "./ghostPins.js";
import {
parseProviderEndpoint,
type ProviderEndpoint,
@@ -30,31 +39,54 @@ type RendererRuntime = {
imageryLayer: CesiumModule.ImageryLayer | null;
buildings: CesiumModule.Cesium3DTileset | null;
grid: CesiumModule.CustomDataSource;
ghostPins: CesiumModule.CustomDataSource;
currentGhostPins: MapGhostPin[];
ghostPinSourceSignature: string;
stopGhostPinAnimation: (() => void) | null;
rebuildGrid: () => void;
};
export function CesiumMapRenderer({
runtimeConfigUrl,
camera,
layers,
settings,
cacheIntent,
rendererGeneration = 0,
className,
onRuntimeStateChange,
onCameraChange,
}: CesiumMapRendererProps) {
export const CesiumMapRenderer = forwardRef<
CesiumMapRendererHandle,
CesiumMapRendererProps
>(function CesiumMapRenderer({
runtimeConfigUrl,
camera,
layers,
settings,
cacheIntent,
ghostPins,
rendererGeneration = 0,
className,
onRuntimeStateChange,
onCameraChange,
}, forwardedRef) {
const containerRef = useRef<HTMLDivElement | null>(null);
const stateCallbackRef = useRef(onRuntimeStateChange);
const cameraCallbackRef = useRef(onCameraChange);
const runtimeRef = useRef<RendererRuntime | null>(null);
const settingsRef = useRef(settings);
const layersRef = useRef(layers);
const ghostPinsRef = useRef(ghostPins);
stateCallbackRef.current = onRuntimeStateChange;
cameraCallbackRef.current = onCameraChange;
settingsRef.current = settings;
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(() => {
const runtime = runtimeRef.current;
@@ -63,7 +95,18 @@ export function CesiumMapRenderer({
}
applyPresentation(runtime, settings, layers);
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(() => {
const container = containerRef.current;
@@ -101,7 +144,11 @@ export function CesiumMapRenderer({
viewer = createViewer(Cesium, container, creditContainer);
const ellipsoidTerrain = new Cesium.EllipsoidTerrainProvider();
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(ghostPinDataSource);
const runtime: RendererRuntime = {
Cesium,
viewer,
@@ -110,6 +157,10 @@ export function CesiumMapRenderer({
imageryLayer: null,
buildings: null,
grid,
ghostPins: ghostPinDataSource,
currentGhostPins: [],
ghostPinSourceSignature: "",
stopGhostPinAnimation: null,
rebuildGrid: () => undefined,
};
runtime.rebuildGrid = () => {
@@ -121,6 +172,17 @@ export function CesiumMapRenderer({
applyPresentation(runtime, settingsRef.current, layersRef.current);
applyCamera(Cesium, viewer, camera);
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(
Cesium,
viewer,
@@ -182,6 +244,7 @@ export function CesiumMapRenderer({
if (disposed) {
removeCameraListener?.();
removeRenderListener?.();
runtime.stopGhostPinAnimation?.();
}
} catch (error) {
if (!abortController.signal.aborted) {
@@ -204,6 +267,7 @@ export function CesiumMapRenderer({
abortController.abort();
removeCameraListener?.();
removeRenderListener?.();
runtimeRef.current?.stopGhostPinAnimation?.();
runtimeRef.current = null;
viewer?.destroy();
viewer = null;
@@ -220,7 +284,7 @@ export function CesiumMapRenderer({
]);
return <div ref={containerRef} className={className} data-nodedc-map-renderer="cesium" />;
}
});
function createViewer(
Cesium: CesiumNamespace,
@@ -426,6 +490,7 @@ function applyPresentation(
color: `color('${settings.buildings_color}', ${settings.buildings_opacity})`,
});
}
runtime.ghostPins.show = layers.targets;
viewer.terrainProvider =
layers.terrain && runtime.worldTerrain
? runtime.worldTerrain
@@ -486,6 +551,276 @@ function applyPresentation(
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(
runtime: RendererRuntime,
settings: CesiumMapRendererProps["settings"],
@@ -57,6 +57,52 @@ export type MapCamera = {
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 = {
imagery: boolean;
terrain: boolean;
@@ -119,8 +165,14 @@ export type CesiumMapRendererProps = {
layers: MapLayerVisibility;
settings: MapVisualSettings;
cacheIntent: MapCacheIntent;
ghostPins?: MapGhostPinLayer;
rendererGeneration?: number;
className?: string;
onRuntimeStateChange?: (state: MapRuntimeState) => void;
onCameraChange?: (camera: MapCamera) => void;
};
export type CesiumMapRendererHandle = {
getViewAnchor: () => MapGeoPoint | null;
snapshotGhostPins: () => MapGhostPin[] | null;
};
+112
View File
@@ -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));
}
+11
View File
@@ -5,9 +5,15 @@ export {
MAP_RUNTIME_SCHEMA_VERSION,
} from "./contracts.js";
export type {
CesiumMapRendererHandle,
CesiumMapRendererProps,
MapCacheIntent,
MapCamera,
MapGeoPoint,
MapGhostPin,
MapGhostPinLabelMode,
MapGhostPinLayer,
MapGhostPinPresentation,
MapLayerVisibility,
MapProviderId,
MapProviderPhase,
@@ -17,6 +23,11 @@ export type {
MapRuntimeState,
MapVisualSettings,
} from "./contracts.js";
export {
advanceGhostPins,
destinationPoint,
geodesicDistanceMeters,
} from "./ghostPins.js";
export {
MapRuntimeError,
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);
});