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]; }); }