feat(globe): port to CeliumJS

This commit is contained in:
gili8420 2026-08-03 22:12:32 +09:00
parent ec03425067
commit eb7698e034
51 changed files with 3521 additions and 1992 deletions

166
src/lib/map/cesium-scene.ts Normal file
View file

@ -0,0 +1,166 @@
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<string, Entity>();
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();
}
}