From 08a3c4739142bfff646580c4f3ec93da2b793ab2 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 29 Jul 2026 18:28:54 +0300 Subject: [PATCH] feat(map): add synthetic moving target layer --- docs/MAP_CESIUM_REACT_ADAPTER.md | 22 ++ .../src/CesiumMapRenderer.tsx | 363 +++++++++++++++++- packages/map-cesium-react/src/contracts.ts | 52 +++ packages/map-cesium-react/src/ghostPins.ts | 112 ++++++ packages/map-cesium-react/src/index.ts | 11 + .../map-cesium-react/test/ghostPins.test.mjs | 32 ++ 6 files changed, 578 insertions(+), 14 deletions(-) create mode 100644 packages/map-cesium-react/src/ghostPins.ts create mode 100644 packages/map-cesium-react/test/ghostPins.test.mjs diff --git a/docs/MAP_CESIUM_REACT_ADAPTER.md b/docs/MAP_CESIUM_REACT_ADAPTER.md index ff56d14..0e071ab 100644 --- a/docs/MAP_CESIUM_REACT_ADAPTER.md +++ b/docs/MAP_CESIUM_REACT_ADAPTER.md @@ -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. diff --git a/packages/map-cesium-react/src/CesiumMapRenderer.tsx b/packages/map-cesium-react/src/CesiumMapRenderer.tsx index 6b8e1c9..771476c 100644 --- a/packages/map-cesium-react/src/CesiumMapRenderer.tsx +++ b/packages/map-cesium-react/src/CesiumMapRenderer.tsx @@ -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(null); const stateCallbackRef = useRef(onRuntimeStateChange); const cameraCallbackRef = useRef(onCameraChange); const runtimeRef = useRef(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
; -} +}); 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(); + +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 = [ + ``, + ` 0 + ? ` stroke="${outlineColor.toCssColorString()}" stroke-width="${safeOutlineWidth}"/>` + : "/>", + "", + ].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"], diff --git a/packages/map-cesium-react/src/contracts.ts b/packages/map-cesium-react/src/contracts.ts index bfed36c..adfec11 100644 --- a/packages/map-cesium-react/src/contracts.ts +++ b/packages/map-cesium-react/src/contracts.ts @@ -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; +}; diff --git a/packages/map-cesium-react/src/ghostPins.ts b/packages/map-cesium-react/src/ghostPins.ts new file mode 100644 index 0000000..29204ca --- /dev/null +++ b/packages/map-cesium-react/src/ghostPins.ts @@ -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)); +} diff --git a/packages/map-cesium-react/src/index.ts b/packages/map-cesium-react/src/index.ts index 67d0de5..ebea2e2 100644 --- a/packages/map-cesium-react/src/index.ts +++ b/packages/map-cesium-react/src/index.ts @@ -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, diff --git a/packages/map-cesium-react/test/ghostPins.test.mjs b/packages/map-cesium-react/test/ghostPins.test.mjs new file mode 100644 index 0000000..b97f8bb --- /dev/null +++ b/packages/map-cesium-react/test/ghostPins.test.mjs @@ -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); +});