import { Cartesian3, Color, ConstantPositionProperty, Entity, HorizontalOrigin, PolylineDashMaterialProperty, VerticalOrigin, type Viewer, } from 'cesium'; import type { CircleOptions, LineOptions, MapLayer, Marker, MarkerOptions, Scene, } from './core'; /** * Cesium implementation of the Scene contract. * * Every position here goes through `Cartesian3.fromDegrees(lng, lat)`, which * converts geographic degrees straight to earth-centred coordinates. Nothing * passes through a Mercator tiler, so latitudes above 85.051129° — where the * MapLibre implementation silently clamped every vertex — render correctly. * That is the whole reason this file exists. */ /** '#rrggbb' + opacity -> Cesium Color. Falls back to black on an unparseable string. */ function toColor(css: string | undefined, opacity = 1): Color { const parsed = Color.fromCssColorString(css ?? '#000'); // fromCssColorString returns undefined for garbage rather than throwing. return (parsed ?? Color.BLACK).withAlpha(opacity); } export class CesiumScene implements Scene { private entities = new Map(); constructor( public readonly name: string, private viewer: Viewer, ) {} private scopeId(id: string): string { return `${this.name}__${id}`; } private add(id: string, entity: Entity): Entity { this.remove(id); const added = this.viewer.entities.add(entity); this.entities.set(id, added); return added; } addLine(id: string, options: LineOptions): MapLayer { // LatLngTuple is [lat, lng] or [lat, lng, alt]; Cesium wants lng first. // Swapping these is the classic bug here. // `c.length === 3` is what narrows the LatLngTuple union for TypeScript; // a `> 2` comparison does not. const heights: number[] = options.coords.map((c) => (c.length === 3 ? c[2] : 0)); // Only lift the line into 3D when it actually has altitude. A track whose // every vertex sits at ground level (a bounding-box ring, or telemetry // before launch) must stay draped: at height 0 a polyline is coplanar // with the ellipsoid, z-fights it, and disappears entirely. const use3d = Math.max(...heights) > 1; const positions = use3d ? Cartesian3.fromDegreesArrayHeights( options.coords.flatMap((c, i) => [c[1], c[0], heights[i]]), ) : Cartesian3.fromDegreesArray(options.coords.flatMap((c) => [c[1], c[0]])); const color = toColor(options.color, options.opacity ?? 1); this.add( id, new Entity({ id: this.scopeId(id), polyline: { positions, width: options.width ?? 3, material: options.dashArray ? new PolylineDashMaterialProperty({ color, dashLength: options.dashArray[0] + options.dashArray[1], }) : color, // arcType is left at its default, GEODESIC. // Draped only for ground-level geometry (see use3d above); a // real flight is drawn at its own altitude. clampToGround: !use3d, }, }), ); return { id, remove: () => this.remove(id) }; } addCircle(id: string, options: CircleOptions): MapLayer { // radiusPx is screen-space, matching the MapLibre circle-layer semantics // the callers were written against; PointGraphics.pixelSize is the direct // equivalent (diameter, hence the doubling). this.add( id, new Entity({ id: this.scopeId(id), position: Cartesian3.fromDegrees(options.center[0], options.center[1]), point: { pixelSize: (options.radiusPx ?? 5) * 2, color: toColor(options.color, options.opacity ?? 1), outlineColor: toColor(options.strokeColor, 1), outlineWidth: options.strokeWidth ?? 0, }, }), ); return { id, remove: () => this.remove(id) }; } addMarker(id: string, options: MarkerOptions): Marker { const alt = options.altitude ?? 0; const entity = this.add( id, new Entity({ id: this.scopeId(id), position: Cartesian3.fromDegrees(options.lngLat[0], options.lngLat[1], alt), ...(options.iconUrl ? { billboard: { image: options.iconUrl, width: options.iconSize?.[0], height: options.iconSize?.[1], horizontalOrigin: HorizontalOrigin.CENTER, verticalOrigin: VerticalOrigin.BOTTOM, }, } : { point: { pixelSize: 10, color: Color.CRIMSON } }), // Shown by Cesium's own selection UI; the MapLibre build used a // hover popup, which has no direct Cesium equivalent. ...(options.popupHtml ? { description: options.popupHtml } : {}), }), ); return { setLngLat: (pos) => { // LngLatTuple has no altitude, so a moved marker keeps the one it // was created with. entity.position = new ConstantPositionProperty( Cartesian3.fromDegrees(pos[0], pos[1], alt), ); }, remove: () => this.remove(id), }; } remove(id: string): void { const e = this.entities.get(id); if (!e) return; this.viewer.entities.remove(e); this.entities.delete(id); } clear(): void { for (const e of this.entities.values()) this.viewer.entities.remove(e); this.entities.clear(); } dispose(): void { this.clear(); } }