Files
gods-eye-view/src/annotations/hybridAnnotationRenderer.js
T
Sameh Khamis 662f51fdf3 feat(annotations): render the screen-space annotation board from a store
The screen annotation renderer keeps each mark's SVG parts as attribute
records and presents the board to a store after every rendered frame; a
view teleports it to the page and hands back the callout text and mark
groups the renderer measures. The tracked-entity fade is measured once
the frame is in the document, removed marks fade out before they leave,
and the styles it injected move to annotations.css.
2026-10-31 21:39:34 -07:00

167 lines
6.0 KiB
JavaScript

import { createWorldAnnotationRenderer } from './worldAnnotationRenderer.js';
import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
/**
* Hybrid annotation renderer (Direction C — the recommended production blend).
*
* Routes each mark to the renderer that does it best:
* - `area` footprints → WORLD-space, so the outline/fill drapes onto and
* conforms to the real 3D building/ground geometry (screen-space can only
* draw a flat polygon that ignores relief and building tops).
* - everything else (highlight / pin / arrow / label) → SCREEN-space SVG, for
* the hand-drawn "whiteboard" aesthetic: pulsing reticles, bowed arrows,
* and glassy callout cards with leader lines.
*
* The area's text label is also drawn as a SCREEN callout (the world drape is
* label-less) so every caption shares one consistent style.
*
* The hybrid surface honors add/update/remove/sync/destroy; update coordinates the
* one route change that spans both sub-renderers. `screen` passes options to the
* screen renderer (its store, in tests).
*/
export function createHybridAnnotationRenderer(
viewer,
{ screen: screenOptions } = {},
) {
const world = createWorldAnnotationRenderer(viewer);
const screen = createScreenAnnotationRenderer(viewer, screenOptions);
// anno.id -> { worldProxy?, screenProxy? } — proxies own the sub-renderer state.
const routed = new Map();
function add(anno) {
const entry = {};
// Route the entry FIRST, then fill it in. A sub-renderer throw (WebGL loss
// mid-add) otherwise stranded whatever the earlier sub-renderer had already
// created: `routed` never learned the id, so remove() was a no-op and the
// engine's rollback could not reach the orphan — a re-annotate of the same
// geometry then stacked a second mark over it. Registered up front, the
// partial entry is addressable and remove() cleans exactly what exists.
routed.set(anno.id, entry);
if (anno.type === 'area' && anno.ring && anno.ring.length >= 3) {
// Drape the footprint in world space, but without its own label...
entry.worldProxy = liveProxy(anno, { label: null });
world.add(entry.worldProxy);
// ...and place the caption as a screen-space callout at the centroid.
if (anno.label) {
entry.screenProxy = liveProxy(anno, { type: 'label', ring: null });
screen.add(entry.screenProxy);
}
} else if (
anno.type === 'route' &&
Array.isArray(anno.path) &&
anno.path.length >= 2
) {
// Drape the path on the tiles (world), caption it with a screen callout
// at the path midpoint.
entry.worldProxy = liveProxy(anno, { label: null });
world.add(entry.worldProxy);
if (anno.label) {
const mid = anno.path[Math.floor(anno.path.length / 2)];
entry.screenProxy = liveProxy(anno, {
type: 'label',
path: null,
anchor: { lon: mid.lon, lat: mid.lat, height: mid.height },
});
screen.add(entry.screenProxy);
}
} else {
entry.screenProxy = anno;
screen.add(anno);
}
}
/**
* Upgrade a pending area without replacing its existing screen-space group.
* World geometry is new, but the reticle's SVG group is converted in place to
* the centroid callout so there is no overlapping fade-out/fade-in pair.
*/
function update(anno) {
const entry = routed.get(anno.id);
if (!entry) {
add(anno);
return;
}
if (anno.type !== 'area' || !anno.ring || anno.ring.length < 3) {
if (anno.outlineUnavailable && entry.screenProxy)
screen.update(entry.screenProxy);
return;
}
if (!entry.worldProxy) {
entry.worldProxy = liveProxy(anno, { label: null });
world.add(entry.worldProxy);
}
const screenProxy = liveProxy(anno, { type: 'label', ring: null });
if (entry.screenProxy) {
screen.update(screenProxy, { empty: !anno.label });
} else if (anno.label) {
screen.add(screenProxy);
}
entry.screenProxy = entry.screenProxy
? screenProxy
: anno.label
? screenProxy
: null;
}
/**
* Release both routes. Tolerates PARTIAL state (only one sub-renderer got
* its content) and an absent id, so it doubles as the rollback path for a
* failed add.
*
* Both releases are ALWAYS attempted, each clears its own proxy as it
* succeeds, and the routing entry is dropped only once nothing is left to
* release. A release that throws therefore leaves the id still addressable,
* so a retry (or a later clear) can finish the job instead of the orphan
* becoming permanently unreachable. The failure is rethrown after both
* routes have had their turn.
*/
function remove(anno) {
const entry = routed.get(anno.id);
if (!entry) return;
let failure = null;
if (entry.worldProxy) {
try {
world.remove(entry.worldProxy);
entry.worldProxy = null;
} catch (error) {
failure = error; // keep the proxy: it still owns live geometry
}
}
if (entry.screenProxy) {
try {
screen.remove(entry.screenProxy);
entry.screenProxy = null;
} catch (error) {
failure = failure || error;
}
}
if (!entry.worldProxy && !entry.screenProxy) routed.delete(anno.id);
if (failure) throw failure;
}
function sync(annotations) {
world.sync(annotations);
screen.sync(annotations);
}
function destroy() {
world.destroy();
screen.destroy();
routed.clear();
}
return { add, update, remove, sync, destroy };
}
/**
* A proxy that inherits from `anno` via the prototype chain (so live fields the
* engine mutates each tick — `alpha`, `bornAt`, `expiring` — are read through),
* with a few own properties overridden. Sub-renderers store their own state
* (`_entities`, SVG refs) as own properties on the proxy, isolated per route.
*/
function liveProxy(anno, overrides) {
return Object.assign(Object.create(anno), overrides);
}