merge main 43a67f9 into feat/voice-outlines

This commit is contained in:
manjunath committed 2026-11-03 00:26:40 +05:30
commit 3ac3499e4a
175 files changed
+4153 -522

No files matched your search

+144 -4
View File
@@ -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) {