mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
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.
167 lines
6.0 KiB
JavaScript
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);
|
|
}
|