feat(map): add synthetic moving target layer
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
} 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);
|
||||
});
|
||||
Reference in New Issue
Block a user