mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Traffic roads come from OpenFreeMap vector tiles, or TomTom flow tiles with a key (Hybrid by default). Military areas use OpenFreeMap polygons with a bundled worldwide name index; wide views show named points. ALPR reads an hourly OpenStreetMap extract. Cockpit place context and country, state and US county outlines use bundled Natural Earth and US Census data. Overpass is used only when an operator configures their own endpoint. Also: camera arrivals no longer land below ground, keyless terrain retries on 429, bounded relation geometry for configured Overpass, one shared OpenStreetMap credit, and separate tile and API transports for embedders. Co-authored-by: theGaryLarson <[email protected]> Co-authored-by: Milan Khanal <[email protected]>
540 lines
20 KiB
JavaScript
540 lines
20 KiB
JavaScript
import * as Cesium from 'cesium';
|
|
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
|
|
|
|
/**
|
|
* World-space annotation renderer (Direction A).
|
|
*
|
|
* Draws annotations as native Cesium entities anchored to lon/lat. Because they
|
|
* live in the 3D scene they track the camera, sit at the right depth, and are
|
|
* occluded by the photoreal buildings the way a real marker would be.
|
|
*
|
|
* IMPORTANT — drawing on Google Photorealistic 3D Tiles:
|
|
* The Cesium globe is hidden, so there is no terrain to clamp to. Ground
|
|
* geometry (areas, rings, connectors) is draped onto the photoreal tiles with
|
|
* `classificationType: CESIUM_3D_TILE`; points clamp to the tile
|
|
* surface with `heightReference: CLAMP_TO_GROUND` (which requires the tileset
|
|
* to have `enableCollision = true`, set in initAnnotations). This keeps marks
|
|
* sitting ON the world instead of buried at sea level.
|
|
*
|
|
* Live alpha (fade in/out) and pulsing are driven by CallbackProperty so they
|
|
* animate every render without the engine touching them per frame.
|
|
*
|
|
* Renderer contract (shared with the screen-space renderer):
|
|
* add(anno) / remove(anno) / sync(map) / destroy()
|
|
*/
|
|
|
|
const PALETTE = {
|
|
primary: '#8be9ff',
|
|
amber: '#ffb547',
|
|
cyan: '#39d0ff',
|
|
green: '#5dff9f',
|
|
red: '#ff6b6b',
|
|
};
|
|
|
|
/**
|
|
* What a draped mark is allowed to paint onto.
|
|
*
|
|
* This was CESIUM_3D_TILE, which is right with Google's photoreal tiles under
|
|
* the camera and wrong everywhere else: on a keyless boot (Esri or OSM imagery
|
|
* on Cesium's own globe) there are no 3D tiles to classify against, so every
|
|
* area fill, area outline, route and arrow rendered to nothing. The labels
|
|
* showed and the geometry did not, which reads as a broken feature rather than
|
|
* an unsupported surface — and it hit spoken annotations exactly as hard as
|
|
* hand-drawn ones.
|
|
*
|
|
* BOTH paints onto terrain AND onto 3D tiles, so one value covers both worlds.
|
|
*/
|
|
const CLASSIFY = Cesium.ClassificationType.BOTH;
|
|
const CLAMP = Cesium.HeightReference.CLAMP_TO_GROUND;
|
|
|
|
export function createWorldAnnotationRenderer(viewer) {
|
|
const dataSource = new Cesium.CustomDataSource('gev-annotations');
|
|
viewer.dataSources.add(dataSource);
|
|
const osmAnnotations = new Set();
|
|
|
|
// Register the GevRouteFlow fabric once so Cesium's Material.fromType() can build the
|
|
// material the route pipeline renders. The animated `time` uniform is read straight
|
|
// from performance.now() inside FlowMaterialProperty.getValue (which Cesium calls each
|
|
// rendered frame with the live uniforms object), so the dashes flow with no extra
|
|
// per-frame bookkeeping.
|
|
ensureFlowFabricRegistered();
|
|
|
|
function colorFor(anno) {
|
|
return Cesium.Color.fromCssColorString(
|
|
PALETTE[anno.color] || PALETTE.primary,
|
|
);
|
|
}
|
|
|
|
// Target-ring radius (meters) scaled to camera height so it reads at any
|
|
// altitude. Read once per frame for BOTH ellipse axes — camera height is
|
|
// constant within a frame, so semiMajor === semiMinor always holds.
|
|
function ringRadius() {
|
|
const h = viewer.camera.positionCartographic?.height ?? 1000;
|
|
return Math.max(14, Math.min(170, h * 0.03));
|
|
}
|
|
|
|
// A live color that follows the annotation's fade alpha and an optional pulse.
|
|
function liveColor(anno, base, { alpha = 0.9, pulse = false } = {}) {
|
|
return new Cesium.CallbackProperty(() => {
|
|
const a = (anno.alpha ?? 1) * alpha * (pulse ? pulseFactor() : 1);
|
|
return base.withAlpha(Math.max(0, Math.min(1, a)));
|
|
}, false);
|
|
}
|
|
|
|
function add(anno) {
|
|
const base = colorFor(anno);
|
|
// Published to the mark BEFORE anything is added, and mutated in place as
|
|
// each entity lands: a mid-add failure (bad geometry, lost context) must
|
|
// leave the entities that DID land visible to remove(), or the rollback
|
|
// path cannot reach them and they stay on the globe forever.
|
|
const entities = [];
|
|
anno._entities = entities;
|
|
|
|
if (
|
|
anno.ring &&
|
|
anno.ring.length >= 3 &&
|
|
anno.footprintKind === 'building'
|
|
) {
|
|
// Single building → the PRIMARY highlight is an extruded CLASSIFICATION
|
|
// volume that tints the real photogrammetry mesh (dome, walls, roof —
|
|
// everything inside the column) amber and pulses. A flat footprint
|
|
// extrusion is only 2.5D and misses domes; classification colors the
|
|
// actual tiles. A faint wireframe cage rides on top as a secondary cue.
|
|
const buffered = bufferRing(anno.ring, 3);
|
|
const positions = Cesium.Cartesian3.fromDegreesArray(buffered.flat());
|
|
const groundH =
|
|
sampleGroundOutside(viewer.scene, anno.ring) ??
|
|
(Number.isFinite(anno.anchor.height)
|
|
? anno.anchor.height - (anno.buildingHeight || 25)
|
|
: 0);
|
|
const baseH = groundH - 3;
|
|
// Tall headroom is free: classification only colors where tiles exist, so
|
|
// empty air above the roof isn't tinted — this just guarantees we cover
|
|
// under-tagged building heights.
|
|
const topH = groundH + Math.max(18, anno.buildingHeight || 25) + 24;
|
|
// 1) Classification volume — tints the mesh amber, pulsing.
|
|
entities.push(
|
|
dataSource.entities.add({
|
|
polygon: {
|
|
hierarchy: new Cesium.PolygonHierarchy(positions),
|
|
height: baseH,
|
|
extrudedHeight: topH,
|
|
perPositionHeight: false,
|
|
classificationType: CLASSIFY,
|
|
material: new Cesium.ColorMaterialProperty(
|
|
liveColor(anno, base, { alpha: 0.45, pulse: true }),
|
|
),
|
|
},
|
|
}),
|
|
);
|
|
// 2) Faint, SIMPLIFIED wireframe cage — secondary, lower opacity. Decimate
|
|
// the footprint so the extruded outline draws ~14 clean verticals, not one
|
|
// per (often 100+) ring vertex.
|
|
const cagePositions = Cesium.Cartesian3.fromDegreesArray(
|
|
decimateRing(buffered, 14).flat(),
|
|
);
|
|
entities.push(
|
|
dataSource.entities.add({
|
|
polygon: {
|
|
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
|
|
height: baseH,
|
|
extrudedHeight: topH,
|
|
perPositionHeight: false,
|
|
fill: false,
|
|
outline: true,
|
|
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
|
|
},
|
|
}),
|
|
);
|
|
// 3) Crisp draped base outline so the footprint reads on the ground.
|
|
entities.push(
|
|
dataSource.entities.add({
|
|
polyline: {
|
|
positions,
|
|
width: 3,
|
|
material: new Cesium.PolylineGlowMaterialProperty({
|
|
glowPower: 0.25,
|
|
color: liveColor(anno, base, { alpha: 0.7 }),
|
|
}),
|
|
clampToGround: true,
|
|
classificationType: CLASSIFY,
|
|
},
|
|
}),
|
|
);
|
|
} else if (anno.ring && anno.ring.length >= 3) {
|
|
// Larger area (district / compound / park / state) → flat fill draped on
|
|
// the tiles + a glowing outline. Draping is right here: you can't extrude
|
|
// a whole neighbourhood, and the GIS overlay shows the boundary clearly.
|
|
// A multi-part outline (`anno.polygons`: Hawaii's islands, Berlin as a
|
|
// hole in Brandenburg) draws every part with its holes. All parts share
|
|
// ONE fill and ONE line material, so Cesium batches the whole mark into a
|
|
// ground primitive per kind instead of one per island.
|
|
const parts =
|
|
Array.isArray(anno.polygons) && anno.polygons.length
|
|
? anno.polygons
|
|
: [[anno.ring]];
|
|
const fillMaterial = new Cesium.ColorMaterialProperty(
|
|
// Synthesized (approximate) areas get a fainter fill so they don't read as solid.
|
|
liveColor(anno, base, {
|
|
alpha: anno.synthesized ? 0.1 : 0.2,
|
|
pulse: true,
|
|
}),
|
|
);
|
|
// Synthesized → DASHED outline (signals "approximate, not an authoritative
|
|
// boundary", research §8.4/§8.6); real footprints → solid glow.
|
|
const lineMaterial = anno.synthesized
|
|
? new Cesium.PolylineDashMaterialProperty({
|
|
color: liveColor(anno, base, { alpha: 0.95 }),
|
|
dashLength: 24,
|
|
})
|
|
: new Cesium.PolylineGlowMaterialProperty({
|
|
glowPower: 0.35,
|
|
color: liveColor(anno, base, { alpha: 1 }),
|
|
});
|
|
for (const [outer, ...holes] of parts) {
|
|
if (!Array.isArray(outer) || outer.length < 3) continue;
|
|
const rings = [outer, ...holes.filter((h) => h?.length >= 3)];
|
|
entities.push(
|
|
dataSource.entities.add({
|
|
polygon: {
|
|
hierarchy: new Cesium.PolygonHierarchy(
|
|
Cesium.Cartesian3.fromDegreesArray(outer.flat()),
|
|
rings
|
|
.slice(1)
|
|
.map(
|
|
(hole) =>
|
|
new Cesium.PolygonHierarchy(
|
|
Cesium.Cartesian3.fromDegreesArray(hole.flat()),
|
|
),
|
|
),
|
|
),
|
|
material: fillMaterial,
|
|
classificationType: CLASSIFY,
|
|
},
|
|
}),
|
|
);
|
|
for (const ring of rings) {
|
|
entities.push(
|
|
dataSource.entities.add({
|
|
polyline: {
|
|
positions: Cesium.Cartesian3.fromDegreesArray(
|
|
closedRing(ring).flat(),
|
|
),
|
|
width: 6,
|
|
material: lineMaterial,
|
|
clampToGround: true,
|
|
classificationType: CLASSIFY,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
} else if (
|
|
anno.type === 'route' &&
|
|
Array.isArray(anno.path) &&
|
|
anno.path.length >= 2
|
|
) {
|
|
// A real path (street-following) draped on the 3D tiles, with dashes that
|
|
// FLOW toward the destination (custom st.s + time material; works on the
|
|
// clamped/classified ground polyline).
|
|
const positions = Cesium.Cartesian3.fromDegreesArray(
|
|
anno.path.flatMap((p) => [p.lon, p.lat]),
|
|
);
|
|
entities.push(
|
|
dataSource.entities.add({
|
|
polyline: {
|
|
positions,
|
|
width: 9,
|
|
material: new FlowMaterialProperty(
|
|
PALETTE[anno.color] || PALETTE.primary,
|
|
),
|
|
clampToGround: true,
|
|
classificationType: CLASSIFY,
|
|
},
|
|
}),
|
|
);
|
|
osmAnnotations.add(anno);
|
|
showOsmCredit(viewer, anno);
|
|
} else if (anno.type === 'arrow' && anno.to) {
|
|
// Connector draped across the ground from origin to destination.
|
|
const positions = [
|
|
Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
|
|
Cesium.Cartesian3.fromDegrees(anno.to.lon, anno.to.lat),
|
|
];
|
|
entities.push(
|
|
dataSource.entities.add({
|
|
polyline: {
|
|
positions,
|
|
width: 16,
|
|
material: new Cesium.PolylineArrowMaterialProperty(
|
|
liveColor(anno, base, { alpha: 0.95 }),
|
|
),
|
|
clampToGround: true,
|
|
classificationType: CLASSIFY,
|
|
},
|
|
}),
|
|
);
|
|
} else {
|
|
// pin / highlight / label — a camera-proportional target ring + a marker.
|
|
if (anno.type !== 'label') {
|
|
// Radius scales with camera height so the ring reads at any altitude.
|
|
// semiMajor === semiMinor is required AND must hold every frame, so both
|
|
// axes read the SAME ringRadius() (camera height is constant per frame).
|
|
entities.push(
|
|
dataSource.entities.add({
|
|
position: Cesium.Cartesian3.fromDegrees(
|
|
anno.anchor.lon,
|
|
anno.anchor.lat,
|
|
),
|
|
ellipse: {
|
|
semiMajorAxis: new Cesium.CallbackProperty(ringRadius, false),
|
|
semiMinorAxis: new Cesium.CallbackProperty(ringRadius, false),
|
|
material: new Cesium.ColorMaterialProperty(
|
|
liveColor(anno, base, { alpha: 0.38, pulse: true }),
|
|
),
|
|
outline: false,
|
|
classificationType: CLASSIFY,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
entities.push(pointMarker(anno, base));
|
|
}
|
|
}
|
|
|
|
// Captions belong to the hybrid renderer's screen-space callouts.
|
|
function pointMarker(anno, base) {
|
|
return dataSource.entities.add({
|
|
position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
|
|
point: {
|
|
pixelSize: anno.type === 'label' ? 8 : 14,
|
|
color: liveColor(anno, base, { alpha: 1 }),
|
|
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
|
|
outlineWidth: 3,
|
|
heightReference: CLAMP,
|
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
|
},
|
|
});
|
|
}
|
|
|
|
function remove(anno) {
|
|
hideOsmCredit(viewer, anno);
|
|
osmAnnotations.delete(anno);
|
|
if (!anno?._entities) return;
|
|
for (const entity of anno._entities) {
|
|
try {
|
|
dataSource.entities.remove(entity);
|
|
} catch {
|
|
/* already gone */
|
|
}
|
|
}
|
|
anno._entities = null;
|
|
}
|
|
|
|
function sync() {
|
|
// No-op: CallbackProperty drives per-frame alpha/pulse animation.
|
|
}
|
|
|
|
function destroy() {
|
|
for (const anno of osmAnnotations) hideOsmCredit(viewer, anno);
|
|
osmAnnotations.clear();
|
|
try {
|
|
viewer.dataSources.remove(dataSource, true);
|
|
} catch {
|
|
/* scene torn down */
|
|
}
|
|
}
|
|
|
|
return { add, remove, sync, destroy };
|
|
}
|
|
|
|
function pulseFactor() {
|
|
// 0.6 .. 1.0 sinusoid at ~0.8 Hz
|
|
return 0.8 + 0.2 * Math.sin(performance.now() * 0.005);
|
|
}
|
|
|
|
/**
|
|
* A custom Fabric polyline material whose dashes flow toward the destination.
|
|
* `materialInput.st.s` is the along-line coordinate (0 = origin, 1 = destination),
|
|
* so `fract(s*repeat - time*speed)` scrolls the pattern toward the end. Works on
|
|
* a clamped/classified ground polyline (PolylineMaterialAppearance supports it).
|
|
*/
|
|
function makeRouteFlowMaterial(colorCss) {
|
|
return new Cesium.Material({
|
|
fabric: {
|
|
type: 'GevRouteFlow',
|
|
uniforms: {
|
|
color: Cesium.Color.fromCssColorString(colorCss).withAlpha(0.95),
|
|
time: 0.0,
|
|
repeat: 64.0, // dash cells along the whole route
|
|
duty: 0.46, // fraction of each cell that is "on"
|
|
speed: 0.55, // cells per second toward the destination
|
|
},
|
|
source: `
|
|
czm_material czm_getMaterial(czm_materialInput materialInput) {
|
|
czm_material m = czm_getDefaultMaterial(materialInput);
|
|
float s = materialInput.st.s; // 0 origin -> 1 dest
|
|
float flow = fract(s * repeat - time * speed); // scroll toward dest
|
|
float on = smoothstep(duty + 0.08, duty - 0.08, flow);
|
|
// keep a faint baseline so the whole route stays readable between dashes
|
|
float a = max(on, 0.18);
|
|
m.diffuse = color.rgb;
|
|
m.emission = color.rgb * on * 0.9; // glow on the lit cells
|
|
m.alpha = color.a * a;
|
|
return m;
|
|
}`,
|
|
},
|
|
});
|
|
}
|
|
|
|
let _flowFabricRegistered = false;
|
|
/** Register the GevRouteFlow fabric ONCE so Cesium's `Material.fromType('GevRouteFlow')`
|
|
* can build the material the render pipeline uses. Constructing one Material with the
|
|
* fabric caches it under its type name. */
|
|
export function ensureFlowFabricRegistered() {
|
|
if (_flowFabricRegistered) return;
|
|
makeRouteFlowMaterial('#ffffff'); // side effect: registers the 'GevRouteFlow' type
|
|
_flowFabricRegistered = true;
|
|
}
|
|
|
|
/**
|
|
* MaterialProperty for the animated route. Cesium builds the rendered Material once
|
|
* from getType() (our registered GevRouteFlow fabric), then EACH FRAME calls
|
|
* getValue(time, material.uniforms) and uses whatever we write INTO that uniforms
|
|
* object — it ignores the return value. So getValue writes color/time/repeat/duty/
|
|
* speed straight onto `result` (the live uniforms). Writing the animated `time` here
|
|
* is what actually makes the dashes flow on the GPU. (The prior versions either
|
|
* returned a standalone Material Cesium never rendered, or treated `result` as a
|
|
* Material — both left the real uniforms untouched, so nothing animated.)
|
|
*/
|
|
export function FlowMaterialProperty(colorCss) {
|
|
this._color = Cesium.Color.fromCssColorString(colorCss).withAlpha(0.95);
|
|
this._definitionChanged = new Cesium.Event();
|
|
}
|
|
Object.defineProperties(FlowMaterialProperty.prototype, {
|
|
isConstant: {
|
|
get() {
|
|
return false;
|
|
},
|
|
}, // re-evaluated each frame → it animates
|
|
definitionChanged: {
|
|
get() {
|
|
return this._definitionChanged;
|
|
},
|
|
},
|
|
});
|
|
FlowMaterialProperty.prototype.getType = function getType() {
|
|
return 'GevRouteFlow';
|
|
};
|
|
FlowMaterialProperty.prototype.getValue = function getValue(time, result) {
|
|
// `result` IS the live uniforms object Cesium renders — write into it directly.
|
|
// `time` is read straight from the wall clock so the dashes flow every rendered
|
|
// frame (the scene renders continuously; no requestRenderMode here).
|
|
if (!Cesium.defined(result)) result = {};
|
|
result.color = this._color;
|
|
result.time = performance.now() / 1000; // the per-frame animated value
|
|
result.repeat = 64.0;
|
|
result.duty = 0.46;
|
|
result.speed = 0.55;
|
|
return result;
|
|
};
|
|
FlowMaterialProperty.prototype.equals = function equals(other) {
|
|
return this === other;
|
|
};
|
|
|
|
/** The ring with its first vertex repeated at the end, so an outline closes. */
|
|
function closedRing(ring) {
|
|
const first = ring[0];
|
|
const last = ring[ring.length - 1];
|
|
return first[0] === last[0] && first[1] === last[1] ? ring : [...ring, first];
|
|
}
|
|
|
|
/** Evenly down-sample a [[lon,lat],...] ring to at most n points (keeps shape). */
|
|
function decimateRing(ring, n) {
|
|
if (ring.length <= n) return ring;
|
|
const out = [];
|
|
for (let i = 0; i < n; i++) out.push(ring[Math.floor((i * ring.length) / n)]);
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Sample the ground height (m) just OUTSIDE a building footprint. Sampling at
|
|
* the centroid would clamp onto the roof, so we probe several points beyond the
|
|
* footprint radius and take a low percentile (≈ ground). Returns null if the
|
|
* tiles under those points aren't loaded yet.
|
|
*/
|
|
function sampleGroundOutside(scene, ring) {
|
|
if (
|
|
!scene?.clampToHeightSupported ||
|
|
typeof scene.clampToHeight !== 'function'
|
|
)
|
|
return null;
|
|
let clon = 0;
|
|
let clat = 0;
|
|
for (const [lon, lat] of ring) {
|
|
clon += lon;
|
|
clat += lat;
|
|
}
|
|
clon /= ring.length;
|
|
clat /= ring.length;
|
|
const latS = 111320;
|
|
const lonS = latS * Math.cos(Cesium.Math.toRadians(clat));
|
|
let maxR = 0;
|
|
for (const [lon, lat] of ring) {
|
|
maxR = Math.max(maxR, Math.hypot((lon - clon) * lonS, (lat - clat) * latS));
|
|
}
|
|
const out = maxR * 1.5 + 12;
|
|
const dirs = [
|
|
[1, 0],
|
|
[-1, 0],
|
|
[0, 1],
|
|
[0, -1],
|
|
[0.7, 0.7],
|
|
[-0.7, -0.7],
|
|
[0.7, -0.7],
|
|
[-0.7, 0.7],
|
|
];
|
|
const samples = [];
|
|
for (const [dx, dy] of dirs) {
|
|
const lon = clon + (dx * out) / lonS;
|
|
const lat = clat + (dy * out) / latS;
|
|
try {
|
|
const c = scene.clampToHeight(Cesium.Cartesian3.fromDegrees(lon, lat, 0));
|
|
if (c) {
|
|
const h = Cesium.Cartographic.fromCartesian(c).height;
|
|
if (Number.isFinite(h) && h > -430 && h < 9000) samples.push(h);
|
|
}
|
|
} catch {
|
|
/* tile not ready */
|
|
}
|
|
}
|
|
if (!samples.length) return null;
|
|
samples.sort((a, b) => a - b);
|
|
return samples[Math.floor(samples.length * 0.3)];
|
|
}
|
|
|
|
/**
|
|
* Inflate a [[lon,lat],...] ring outward from its centroid by `meters`, so an
|
|
* extruded building volume encloses the photogrammetry mesh instead of slicing
|
|
* through its edges. Radial buffer — fine for compact building footprints.
|
|
*/
|
|
function bufferRing(ring, meters) {
|
|
let clon = 0;
|
|
let clat = 0;
|
|
for (const [lon, lat] of ring) {
|
|
clon += lon;
|
|
clat += lat;
|
|
}
|
|
clon /= ring.length;
|
|
clat /= ring.length;
|
|
const latScale = 111320;
|
|
const lonScale = latScale * Math.cos(Cesium.Math.toRadians(clat));
|
|
return ring.map(([lon, lat]) => {
|
|
const dx = (lon - clon) * lonScale;
|
|
const dy = (lat - clat) * latScale;
|
|
const d = Math.hypot(dx, dy) || 1;
|
|
const k = (d + meters) / d;
|
|
return [clon + (dx * k) / lonScale, clat + (dy * k) / latScale];
|
|
});
|
|
}
|