mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
merge main 43a67f9 into feat/voice-outlines
This commit is contained in:
commit
3ac3499e4a
175 files changed
+4153
-522
No files matched your search
@@ -5,6 +5,7 @@ import { greatCircleMeters } from '../geoDistance.js';
|
||||
import {
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
governorRequestRender,
|
||||
} from '../renderGovernor.js';
|
||||
import {
|
||||
isRateLimitedOutcome,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
phaseTiming,
|
||||
recordWorkSlice,
|
||||
} from '../sources/phaseTiming.js';
|
||||
import { formatDistanceText } from '../data/distanceFormat.js';
|
||||
|
||||
// Dev convenience: expose the app's Cesium instance for console/preview probing
|
||||
// (single shared module instance — avoids dual-Cesium state bugs when testing).
|
||||
@@ -153,6 +155,17 @@ export function createAnnotationEngine({
|
||||
// task with the FINAL outcome — resolved or failed — so narration that said
|
||||
// "tracing the boundary now" can later honestly confirm or correct itself.
|
||||
const outlineListeners = new Set();
|
||||
// Told whenever the set of marks, or one mark's look, changes (Draw's list).
|
||||
const changeListeners = new Set();
|
||||
const notifyChange = () => {
|
||||
for (const listener of changeListeners) {
|
||||
try {
|
||||
listener();
|
||||
} catch {
|
||||
/* one listener's failure must not stop the others */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function emitOutlineEvent(evt) {
|
||||
for (const listener of outlineListeners) {
|
||||
@@ -198,8 +211,11 @@ export function createAnnotationEngine({
|
||||
// World annotations carry persistent per-frame scene animation (pulsing
|
||||
// live colors, camera-scaled rings, route-flow uniforms in the renderer),
|
||||
// so the scene must render continuously while any mark exists. (perf wave 2)
|
||||
// Only a mark someone can see needs every frame drawn: a board of marks
|
||||
// hidden from Draw's list lets the scene idle.
|
||||
function syncAnnotationHold() {
|
||||
if (annotations.size > 0) holdContinuousRender('annotations');
|
||||
const visible = [...annotations.values()].some((anno) => !anno.hidden);
|
||||
if (visible) holdContinuousRender('annotations');
|
||||
else releaseContinuousRender('annotations');
|
||||
}
|
||||
|
||||
@@ -339,6 +355,7 @@ export function createAnnotationEngine({
|
||||
const now = performance.now();
|
||||
let changed = false;
|
||||
let pending = false; // any mark still fading in / out, or with a TTL fade ahead
|
||||
let expired = false;
|
||||
for (const anno of annotations.values()) {
|
||||
const next = computeAlpha(anno, now);
|
||||
if (next !== anno.alpha) {
|
||||
@@ -350,6 +367,7 @@ export function createAnnotationEngine({
|
||||
removeRendered(anno);
|
||||
annotations.delete(anno.id);
|
||||
changed = true;
|
||||
expired = true;
|
||||
} catch {
|
||||
// Leave failed cleanup addressable without stopping every other mark's tick.
|
||||
anno.cleanupPending = true;
|
||||
@@ -361,7 +379,11 @@ export function createAnnotationEngine({
|
||||
if (changed) {
|
||||
renderer.sync(annotations);
|
||||
syncAnnotationHold();
|
||||
// The frame that takes a just-hidden mark off: with every mark hidden
|
||||
// the hold is gone, and nothing else would draw it.
|
||||
governorRequestRender('annotations');
|
||||
}
|
||||
if (expired) notifyChange();
|
||||
// Stop once the board is STABLE — persistent marks (persist=true is the default)
|
||||
// finish fading in after ~260ms and never change again, so without this the loop
|
||||
// would spin O(N) every frame forever. ensureTicking() restarts on the next
|
||||
@@ -838,6 +860,7 @@ export function createAnnotationEngine({
|
||||
if (ids.length) {
|
||||
renderer.sync(annotations);
|
||||
ensureTicking();
|
||||
notifyChange();
|
||||
}
|
||||
if (opts.flyTo && firstAnchor) frameAnnotation(firstAnchor);
|
||||
else if (ids.length) ensureMarksVisible(ids);
|
||||
@@ -1329,6 +1352,11 @@ export function createAnnotationEngine({
|
||||
* degenerate geocode stacks on one point (several Capitol monuments) would erase each other. */
|
||||
function findDuplicate(anno) {
|
||||
if (!anno.anchor) return null;
|
||||
// A shape drawn by hand is always new: each click is meant, and every
|
||||
// shape is unnamed until its row names it, so two nearby pins would
|
||||
// otherwise read as one and the second would replace or vanish into the
|
||||
// first.
|
||||
if (anno.manual) return null;
|
||||
const near = (a, b) =>
|
||||
a &&
|
||||
b &&
|
||||
@@ -1346,6 +1374,10 @@ export function createAnnotationEngine({
|
||||
};
|
||||
for (const ex of annotations.values()) {
|
||||
if (ex.type !== anno.type || !ex.anchor) continue;
|
||||
// A shape drawn by hand and a voice mark are never the same mark: a
|
||||
// match would let a voice TTL expire the drawn one, or replace it. A
|
||||
// hidden mark is not refreshed either; a new shape there is new.
|
||||
if (Boolean(ex.manual) !== Boolean(anno.manual) || ex.hidden) continue;
|
||||
// Identity is GEOMETRY, not label: "Marina" and "Marina District" resolve to the SAME
|
||||
// polygon/anchor and must collapse to one mark (the caller replaces label/color in place
|
||||
// so the latest caption wins). Resolution is deterministic, so the same place re-resolves
|
||||
@@ -1418,6 +1450,11 @@ export function createAnnotationEngine({
|
||||
alpha: 0, // animate in
|
||||
bornAt: now,
|
||||
expiring: false,
|
||||
// Drawn by hand (drawTool.js): listed under Draw, renamed and recoloured
|
||||
// there. `name` is the person's own label, before any distance is added.
|
||||
manual: spec?.manual === true,
|
||||
name: spec?.manual === true ? cleanLabel(spec?.label) || null : null,
|
||||
hidden: false,
|
||||
};
|
||||
|
||||
if (type === 'route') {
|
||||
@@ -1511,9 +1548,102 @@ export function createAnnotationEngine({
|
||||
}
|
||||
renderer.sync(annotations);
|
||||
syncAnnotationHold();
|
||||
notifyChange();
|
||||
if (failure) throw failure;
|
||||
}
|
||||
|
||||
/** Take one mark off the board at once. */
|
||||
function remove(id) {
|
||||
const anno = annotations.get(id);
|
||||
if (!anno) return false;
|
||||
// Through removeRendered, like clear(): a failed cleanup leaves the mark on
|
||||
// the board, still addressable, instead of an orphan nothing owns.
|
||||
removeRendered(anno);
|
||||
annotations.delete(id);
|
||||
renderer.sync(annotations);
|
||||
syncAnnotationHold();
|
||||
notifyChange();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Hide or show one mark; a hidden mark stays on the board at alpha 0. */
|
||||
function setHidden(id, hidden) {
|
||||
const anno = annotations.get(id);
|
||||
if (!anno) return false;
|
||||
anno.hidden = Boolean(hidden);
|
||||
// Skip the fade-in, so showing it again is immediate.
|
||||
anno.bornAt = Math.min(anno.bornAt, performance.now() - 260);
|
||||
// Take it off (or put it back) now, not when its alpha next changes: a
|
||||
// mark hidden before its first frame has no alpha change to wait for.
|
||||
renderer.sync(annotations);
|
||||
syncAnnotationHold();
|
||||
governorRequestRender('annotations');
|
||||
ensureTicking();
|
||||
notifyChange();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename or recolour a hand-drawn mark. The renderers build a mark's look
|
||||
* when it is added, so the mark is added again with the new look.
|
||||
*/
|
||||
function restyle(id, { name, color } = {}) {
|
||||
const anno = annotations.get(id);
|
||||
if (!anno?.manual) return false;
|
||||
if (name !== undefined) {
|
||||
anno.name = cleanLabel(name) || null;
|
||||
anno.label =
|
||||
anno.type === 'route'
|
||||
? composeRouteLabel(anno.name, anno.distanceM, null, 'manual', false)
|
||||
: anno.name;
|
||||
}
|
||||
if (color !== undefined && COLORS.has(color)) anno.color = color;
|
||||
renderer.remove(anno);
|
||||
try {
|
||||
renderer.add(anno);
|
||||
} catch (error) {
|
||||
rollbackRendererState(anno);
|
||||
annotations.delete(id);
|
||||
renderer.sync(annotations);
|
||||
syncAnnotationHold();
|
||||
notifyChange();
|
||||
throw error;
|
||||
}
|
||||
renderer.sync(annotations);
|
||||
ensureTicking();
|
||||
notifyChange();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Fly the camera to one mark; a line is framed whole, not by its start. */
|
||||
function frame(id) {
|
||||
const anno = annotations.get(id);
|
||||
if (!anno) return false;
|
||||
if (anno.type === 'route' && anno.path?.length) {
|
||||
const ring = anno.path.map((p) => [p.lon, p.lat]);
|
||||
// Unwrap around the first vertex, as ringRange does, so a line across
|
||||
// the antimeridian is centred on it, not on the far side of the globe.
|
||||
const ref = ring[0][0];
|
||||
const lons = ring.map(
|
||||
([lon]) => lon - 360 * Math.round((lon - ref) / 360),
|
||||
);
|
||||
const lats = ring.map(([, lat]) => lat);
|
||||
const midLon = (Math.min(...lons) + Math.max(...lons)) / 2;
|
||||
frameAnnotation({
|
||||
...anno,
|
||||
to: null,
|
||||
ring,
|
||||
polygons: null,
|
||||
anchor: {
|
||||
lon: ((((midLon + 180) % 360) + 360) % 360) - 180,
|
||||
lat: (Math.min(...lats) + Math.max(...lats)) / 2,
|
||||
height: 0,
|
||||
},
|
||||
});
|
||||
} else frameAnnotation(anno);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Begin a graceful fade-out of everything, then remove. */
|
||||
function fadeOutAll() {
|
||||
const now = performance.now();
|
||||
@@ -1642,6 +1772,16 @@ export function createAnnotationEngine({
|
||||
const engine = {
|
||||
annotate,
|
||||
clear,
|
||||
remove,
|
||||
setHidden,
|
||||
restyle,
|
||||
frame,
|
||||
/** Subscribe to board changes; returns an unsubscribe fn. */
|
||||
onChange(listener) {
|
||||
if (destroyed || typeof listener !== 'function') return () => {};
|
||||
changeListeners.add(listener);
|
||||
return () => changeListeners.delete(listener);
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
@@ -1651,6 +1791,7 @@ export function createAnnotationEngine({
|
||||
if (tickHandle != null) cancelAnimationFrame(tickHandle);
|
||||
tickHandle = null;
|
||||
outlineListeners.clear();
|
||||
changeListeners.clear();
|
||||
releaseContinuousRender('annotations');
|
||||
renderer.destroy();
|
||||
}
|
||||
@@ -1958,6 +2099,7 @@ function normalizeType(type) {
|
||||
}
|
||||
|
||||
function computeAlpha(anno, now) {
|
||||
if (anno.hidden) return 0;
|
||||
const inT = (now - anno.bornAt) / 260;
|
||||
const fadeIn = inT >= 1 ? 1 : Math.max(0, inT);
|
||||
|
||||
@@ -2166,9 +2308,7 @@ function greatCircleM(a, b) {
|
||||
}
|
||||
|
||||
function formatDistance(m) {
|
||||
if (!Number.isFinite(m)) return null;
|
||||
if (m >= 1000) return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`;
|
||||
return `${Math.round(m / 10) * 10} m`;
|
||||
return formatDistanceText(m, { metreStep: 10 });
|
||||
}
|
||||
|
||||
function composeRouteLabel(baseLabel, distM, durS, mode, fallback) {
|
||||
|
||||
Reference in new issue
Block a user