Files
gods-eye-view/src/annotations/worldAnnotationRenderer.js
T
Said Turksever 38eb98b8f8 Take a hidden shape off the globe whole, and keep every drawn shape
Answers the review of bdbbc3d7 and the IC8 pass after it.

A shape hidden from Draw's list was only set to alpha 0, so the line's
flowing stroke, which keeps its own colour, stayed painted. Now:
- the world renderer's sync() sets every entity of a hidden mark to
  show = false, and FlowMaterialProperty follows the mark's alpha;
- the screen renderer does not project a hidden callout, so it is not drawn
  and cannot push a visible label aside;
- hiding acts at once, even before a mark's first frame, and with every
  mark hidden the annotations render hold is released after one last frame;
- fly_route skips a hidden route.

Hand-drawn shapes are never merged as duplicates: every one is drawn on
purpose and unnamed until its row names it, so a second pin dropped near
the first was dropped, or replaced the first when its colour differed. A
restyle the renderer cannot draw now also syncs the renderer.

Tests cover each: renderer show/hide and flow alpha, the screen skip, the
render hold and its last frame, a show with no fade-in, hidden routes, two
nearby pins, and the restyle rollback.
2026-11-02 16:43:39 +00:00

559 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();
// Every mark with entities on the globe, so sync() can take a hidden one out.
const drawn = 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;
drawn.add(anno);
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,
() => anno.alpha ?? 1,
),
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);
drawn.delete(anno);
if (!anno?._entities) return;
for (const entity of anno._entities) {
try {
dataSource.entities.remove(entity);
} catch {
/* already gone */
}
}
anno._entities = null;
}
/**
* CallbackProperty drives per-frame alpha and pulse. A hidden mark is taken
* out whole, so no material that keeps its own colour (the route's flow),
* ground classification or pick can still show it.
*/
function sync() {
for (const anno of drawn) {
const show = !anno.hidden;
for (const entity of anno._entities || [])
if (entity.show !== show) entity.show = show;
}
}
function destroy() {
for (const anno of osmAnnotations) hideOsmCredit(viewer, anno);
osmAnnotations.clear();
drawn.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, alphaOf = () => 1) {
this._color = Cesium.Color.fromCssColorString(colorCss).withAlpha(0.95);
// The mark's live alpha (fade in and out, hidden), applied every frame.
this._alphaOf = alphaOf;
this._live = this._color.clone();
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 = {};
this._live.alpha = this._color.alpha * this._alphaOf();
result.color = this._live;
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];
});
}