mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
merge main 07094d9 into feat/voice-outlines
This commit is contained in:
commit
c5aa0d28c3
593 files changed
+42386
-19629
No files matched your search
@@ -5,6 +5,9 @@ import {
|
||||
TRACKED_OVERLAY_SOURCE_ID,
|
||||
} from '../data/trackedReadout.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { nextTick } from 'vue';
|
||||
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
|
||||
import { applicationPinia } from '../ui/vueRuntime.js';
|
||||
|
||||
/** A label whose detailed outline could not be resolved. */
|
||||
function outlineUnavailableLabel(label) {
|
||||
@@ -27,11 +30,17 @@ function outlineUnavailableLabel(label) {
|
||||
* Marks behind the globe horizon are culled with an EllipsoidalOccluder, and
|
||||
* anything that projects off-screen / behind the camera is hidden.
|
||||
*
|
||||
* The board is presented to the screen annotations store, whose view renders
|
||||
* the SVG overlay; each mark's parts are plain attribute records here, and the
|
||||
* view hands back the elements this measures (callout text and mark groups).
|
||||
*
|
||||
* Screen-renderer contract (update is used by the hybrid outline-upgrade path):
|
||||
* add(anno) / update(anno) / remove(anno) / sync(map) / destroy()
|
||||
*/
|
||||
|
||||
const SVGNS = 'http://www.w3.org/2000/svg';
|
||||
// Keys each mark, so a mark re-added while its predecessor fades out never
|
||||
// reuses the fading one's elements.
|
||||
let marks = 0;
|
||||
const PALETTE = {
|
||||
primary: '#8be9ff',
|
||||
amber: '#ffb547',
|
||||
@@ -62,18 +71,26 @@ export function createScreenAnnotationRenderer(
|
||||
{
|
||||
overlayPaintRect = getOverlayPaintRect,
|
||||
activeTrackedReadoutId = getActiveTrackedReadoutId,
|
||||
store = useScreenAnnotationsStore(applicationPinia()),
|
||||
} = {},
|
||||
) {
|
||||
injectStyles();
|
||||
const { layer, svg, defs } = buildOverlay();
|
||||
document.body.appendChild(layer);
|
||||
// The arrowhead markers the board's defs hold, in the order first used.
|
||||
const arrowMarkers = new Map();
|
||||
let viewBox = null;
|
||||
|
||||
const scene = viewer.scene;
|
||||
const occluder = new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
scene.camera.positionWC,
|
||||
);
|
||||
const records = new Map(); // anno.id -> { anno, group, parts }
|
||||
const records = new Map(); // anno.id -> { key, anno, group, parts }
|
||||
const leaving = new Map(); // key -> a removed mark's record while it fades out
|
||||
const byKey = new Map(); // key -> record, live or fading
|
||||
const timers = new Set();
|
||||
const owner = {};
|
||||
let published = '';
|
||||
// Only the renderer whose board the store shows may change or remove it.
|
||||
const shown = () => store.board?.owner === owner;
|
||||
const scratch = new Cesium.Cartesian2();
|
||||
const scratchDir = new Cesium.Cartesian3();
|
||||
// Per-point ground height cache. Marks must sit on the real surface, not at
|
||||
@@ -151,13 +168,13 @@ export function createScreenAnnotationRenderer(
|
||||
|
||||
function add(anno) {
|
||||
const c = color(anno);
|
||||
const group = svgEl('g', { class: 'gev-anno', opacity: '0' });
|
||||
const group = { opacity: '0', display: '', entered: false };
|
||||
const parts = {};
|
||||
|
||||
if (anno.type === 'area' && anno.ring && anno.ring.length >= 3) {
|
||||
// Synthesized (buffered/approximate) areas render DASHED + fainter, with no
|
||||
// "draw-on" gesture, so they never masquerade as an authoritative boundary.
|
||||
parts.poly = svgEl('path', {
|
||||
parts.poly = svgPart('path', {
|
||||
class: anno.synthesized ? 'gev-anno-area' : 'gev-anno-area gev-draw',
|
||||
fill: c,
|
||||
'fill-rule': 'evenodd',
|
||||
@@ -167,16 +184,14 @@ export function createScreenAnnotationRenderer(
|
||||
...(anno.synthesized ? { 'stroke-dasharray': '9 7' } : {}),
|
||||
filter: 'url(#gev-sketch)',
|
||||
});
|
||||
group.appendChild(parts.poly);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? outlineUnavailableLabel(anno.label)
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else if (anno.type === 'arrow' && anno.to) {
|
||||
parts.path = svgEl('path', {
|
||||
parts.path = svgPart('path', {
|
||||
class: 'gev-anno-arrow gev-draw',
|
||||
fill: 'none',
|
||||
stroke: c,
|
||||
@@ -184,22 +199,20 @@ export function createScreenAnnotationRenderer(
|
||||
'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
|
||||
filter: 'url(#gev-sketch)',
|
||||
});
|
||||
ensureArrowMarker(defs, anno.color || 'primary', c);
|
||||
group.appendChild(parts.path);
|
||||
ensureArrowMarker(arrowMarkers, anno.color || 'primary', c);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? outlineUnavailableLabel(anno.label)
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else if (
|
||||
anno.type === 'route' &&
|
||||
Array.isArray(anno.path) &&
|
||||
anno.path.length >= 2
|
||||
) {
|
||||
// Multi-waypoint path: a drawn-on polyline with a dot at each waypoint.
|
||||
parts.poly = svgEl('polyline', {
|
||||
parts.poly = svgPart('polyline', {
|
||||
class: 'gev-anno-arrow gev-draw',
|
||||
fill: 'none',
|
||||
stroke: c,
|
||||
@@ -207,37 +220,33 @@ export function createScreenAnnotationRenderer(
|
||||
'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
|
||||
filter: 'url(#gev-sketch)',
|
||||
});
|
||||
ensureArrowMarker(defs, anno.color || 'primary', c);
|
||||
group.appendChild(parts.poly);
|
||||
parts.dots = anno.path.map(() => {
|
||||
const dot = svgEl('circle', {
|
||||
ensureArrowMarker(arrowMarkers, anno.color || 'primary', c);
|
||||
parts.dots = anno.path.map(() =>
|
||||
svgPart('circle', {
|
||||
class: 'gev-anno-dot',
|
||||
fill: c,
|
||||
stroke: '#06121c',
|
||||
'stroke-width': '2',
|
||||
r: '4',
|
||||
});
|
||||
group.appendChild(dot);
|
||||
return dot;
|
||||
});
|
||||
}),
|
||||
);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? outlineUnavailableLabel(anno.label)
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else {
|
||||
// pin / highlight / label — pulsing target rings + a marker dot + callout
|
||||
if (anno.type !== 'label') {
|
||||
parts.ringOuter = svgEl('circle', {
|
||||
parts.ringOuter = svgPart('circle', {
|
||||
class: 'gev-anno-ring gev-pulse',
|
||||
fill: 'none',
|
||||
stroke: c,
|
||||
'stroke-width': '2',
|
||||
r: '34',
|
||||
});
|
||||
parts.ringInner = svgEl('circle', {
|
||||
parts.ringInner = svgPart('circle', {
|
||||
class: 'gev-anno-ring',
|
||||
fill: c,
|
||||
'fill-opacity': '0.12',
|
||||
@@ -246,70 +255,57 @@ export function createScreenAnnotationRenderer(
|
||||
r: '18',
|
||||
filter: 'url(#gev-sketch)',
|
||||
});
|
||||
group.appendChild(parts.ringOuter);
|
||||
group.appendChild(parts.ringInner);
|
||||
}
|
||||
parts.dot = svgEl('circle', {
|
||||
parts.dot = svgPart('circle', {
|
||||
class: 'gev-anno-dot',
|
||||
fill: c,
|
||||
stroke: '#06121c',
|
||||
'stroke-width': '2',
|
||||
r: anno.type === 'label' ? '4' : '5',
|
||||
});
|
||||
group.appendChild(parts.dot);
|
||||
parts.leader = svgEl('line', {
|
||||
parts.leader = svgPart('line', {
|
||||
class: 'gev-anno-leader',
|
||||
stroke: c,
|
||||
'stroke-width': '1.5',
|
||||
'stroke-opacity': '0.7',
|
||||
});
|
||||
group.appendChild(parts.leader);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? outlineUnavailableLabel(anno.label)
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
}
|
||||
|
||||
// Record the mark BEFORE it touches the live document, then unwind on any
|
||||
// throw from here down (projectAll reaches into Cesium and can fail on a
|
||||
// lost context). Previously a throw past the DOM insert left an ORPHANED
|
||||
// <g>: the engine's rollback only deletes its own map entry, so the next
|
||||
// annotate of the same geometry stacked a fresh mark on top of the corpse.
|
||||
records.set(anno.id, { anno, group, parts });
|
||||
// Record the mark BEFORE it reaches the board, then unwind on any throw
|
||||
// from here down (projectAll reaches into Cesium and can fail on a lost
|
||||
// context). Previously a throw past the insert left an ORPHANED mark: the
|
||||
// engine's rollback only deletes its own map entry, so the next annotate
|
||||
// of the same geometry stacked a fresh mark on top of the corpse.
|
||||
const rec = { key: ++marks, anno, group, parts, drawn: false };
|
||||
records.set(anno.id, rec);
|
||||
byKey.set(rec.key, rec);
|
||||
try {
|
||||
svg.appendChild(group);
|
||||
// trigger draw-on / fade-in on the next frame
|
||||
requestAnimationFrame(() => group.classList.add('gev-in'));
|
||||
// The draw-on effect uses a fixed stroke-dasharray of 1400 (see CSS). Any
|
||||
// stroke longer than 1400px (a long arrow/route on a wide window) would keep
|
||||
// a permanent dash gap — the arrowhead detaches from the truncated line (H6).
|
||||
// Once the dashoffset transition finishes the stroke is meant to be fully
|
||||
// drawn, so drop the dasharray entirely then and the stroke reads solid at
|
||||
// any length. Guarded on the specific property so an unrelated transition
|
||||
// (none here, but defensive) doesn't clear it early.
|
||||
for (const drawEl of group.querySelectorAll('.gev-draw')) {
|
||||
drawEl.addEventListener(
|
||||
'transitionend',
|
||||
(e) => {
|
||||
if (e.propertyName === 'stroke-dashoffset') {
|
||||
drawEl.style.strokeDasharray = 'none';
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
publish({ force: true });
|
||||
// Trigger draw-on / fade-in on the next frame, once the view has the
|
||||
// mark in the document. The draw-on effect uses a fixed
|
||||
// stroke-dasharray of 1400 (see CSS); the view reports the end of the
|
||||
// dashoffset transition, after which the stroke drops its dasharray so
|
||||
// a stroke longer than 1400px reads solid (H6).
|
||||
requestAnimationFrame(() => {
|
||||
group.entered = true;
|
||||
publish();
|
||||
});
|
||||
projectAll();
|
||||
} catch (error) {
|
||||
// Hard, immediate unwind — a mark that never finished has nothing to
|
||||
// fade out, and the caller (engine rollback) must find a clean board.
|
||||
records.delete(anno.id);
|
||||
byKey.delete(rec.key);
|
||||
try {
|
||||
group.remove();
|
||||
publish({ force: true });
|
||||
} catch {
|
||||
/* never inserted */
|
||||
/* the board is unreachable */
|
||||
}
|
||||
if (records.size === 0) heightCache.clear();
|
||||
throw error;
|
||||
@@ -329,12 +325,18 @@ export function createScreenAnnotationRenderer(
|
||||
}
|
||||
rec.anno = anno;
|
||||
if (anno.outlineUnavailable && rec.parts.label) {
|
||||
rec.parts.label.text.textContent = outlineUnavailableLabel(anno.label);
|
||||
sizeCallout(rec.parts.label);
|
||||
rec.parts.label.text = outlineUnavailableLabel(anno.label);
|
||||
// Re-measure once the view shows the new text.
|
||||
const label = rec.parts.label;
|
||||
publish();
|
||||
void nextTick(() => {
|
||||
if (rec.parts.label !== label || !sizeCallout(label)) return;
|
||||
projectAll();
|
||||
publish();
|
||||
});
|
||||
}
|
||||
|
||||
if (empty) {
|
||||
for (const child of Array.from(rec.group.children)) child.remove();
|
||||
rec.parts = {};
|
||||
rec.hidden = true;
|
||||
projectAll();
|
||||
@@ -347,11 +349,9 @@ export function createScreenAnnotationRenderer(
|
||||
// label, whose dot/leader/callout already exist; remove the two reticle rings and
|
||||
// keep every remaining node (especially the group and callout) alive.
|
||||
if (anno.type === 'label' && rec.parts.dot) {
|
||||
rec.parts.ringOuter?.remove();
|
||||
rec.parts.ringInner?.remove();
|
||||
delete rec.parts.ringOuter;
|
||||
delete rec.parts.ringInner;
|
||||
rec.parts.dot.setAttribute('r', String(LABEL_DOT_R));
|
||||
rec.parts.dot.set.r = String(LABEL_DOT_R);
|
||||
projectAll();
|
||||
return;
|
||||
}
|
||||
@@ -471,13 +471,16 @@ export function createScreenAnnotationRenderer(
|
||||
}
|
||||
|
||||
function projectAll() {
|
||||
if (!records.size) return;
|
||||
if (!records.size) {
|
||||
publish();
|
||||
return;
|
||||
}
|
||||
projGen += 1; // new frame: entries touched below are "hot" and survive trimming
|
||||
const trackedRect = trackedEntityRect();
|
||||
occluder.cameraPosition = scene.camera.positionWC;
|
||||
const h = scene.canvas.clientHeight || scene.canvas.height;
|
||||
const w = scene.canvas.clientWidth || scene.canvas.width;
|
||||
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
||||
viewBox = `0 0 ${w} ${h}`;
|
||||
// Shrink point-marker geometry as the camera pulls back (declutter the blob).
|
||||
const mScale = markScale(
|
||||
viewer.camera.positionCartographic?.height ?? 1000,
|
||||
@@ -486,10 +489,16 @@ export function createScreenAnnotationRenderer(
|
||||
for (const rec of records.values()) {
|
||||
const { anno, group, parts } = rec;
|
||||
if (rec.hidden) {
|
||||
group.style.display = 'none';
|
||||
group.display = 'none';
|
||||
continue;
|
||||
}
|
||||
group.setAttribute('opacity', String(anno.alpha ?? 1));
|
||||
// The last frame's tracked fade holds until this frame's is measured.
|
||||
group.opacity = String(
|
||||
(anno.alpha ?? 1) *
|
||||
(rec._trackedFade !== undefined && rec._trackedFade !== 1
|
||||
? rec._trackedFade
|
||||
: 1),
|
||||
);
|
||||
|
||||
if (anno.type === 'area' && parts.poly) {
|
||||
const paths = [];
|
||||
@@ -525,12 +534,12 @@ export function createScreenAnnotationRenderer(
|
||||
}
|
||||
}
|
||||
if (paths.length) {
|
||||
parts.poly.setAttribute('d', paths.join(' '));
|
||||
group.style.display = '';
|
||||
parts.poly.set.d = paths.join(' ');
|
||||
group.display = '';
|
||||
if (parts.label)
|
||||
positionCallout(parts.label, cx / n, cy / n - 6, true);
|
||||
} else {
|
||||
group.style.display = 'none';
|
||||
group.display = 'none';
|
||||
}
|
||||
} else if (anno.type === 'arrow' && parts.path) {
|
||||
const a = project(anno.anchor.lon, anno.anchor.lat);
|
||||
@@ -545,11 +554,8 @@ export function createScreenAnnotationRenderer(
|
||||
const bow = Math.min(60, len * 0.18);
|
||||
const ctrlX = mx - (dy / len) * bow;
|
||||
const ctrlY = my + (dx / len) * bow;
|
||||
parts.path.setAttribute(
|
||||
'd',
|
||||
`M ${a.x.toFixed(1)} ${a.y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${b.x.toFixed(1)} ${b.y.toFixed(1)}`,
|
||||
);
|
||||
group.style.display = '';
|
||||
parts.path.set.d = `M ${a.x.toFixed(1)} ${a.y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${b.x.toFixed(1)} ${b.y.toFixed(1)}`;
|
||||
group.display = '';
|
||||
// Anchor the label to the (stable) endpoint midpoint, lifted toward the bow.
|
||||
if (parts.label)
|
||||
positionCallout(
|
||||
@@ -559,7 +565,7 @@ export function createScreenAnnotationRenderer(
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
group.style.display = 'none';
|
||||
group.display = 'none';
|
||||
}
|
||||
} else if (anno.type === 'route' && parts.poly) {
|
||||
const projected = anno.path.map((pt) => project(pt.lon, pt.lat));
|
||||
@@ -568,19 +574,19 @@ export function createScreenAnnotationRenderer(
|
||||
if (p) pts.push(`${p.x.toFixed(1)},${p.y.toFixed(1)}`);
|
||||
}
|
||||
if (pts.length >= 2) {
|
||||
parts.poly.setAttribute('points', pts.join(' '));
|
||||
parts.poly.set.points = pts.join(' ');
|
||||
// dots at each resolvable waypoint; hide the rest
|
||||
parts.dots.forEach((dot, i) => {
|
||||
const p = projected[i];
|
||||
if (p) {
|
||||
dot.setAttribute('cx', p.x.toFixed(1));
|
||||
dot.setAttribute('cy', p.y.toFixed(1));
|
||||
dot.style.display = '';
|
||||
dot.set.cx = p.x.toFixed(1);
|
||||
dot.set.cy = p.y.toFixed(1);
|
||||
dot.display = '';
|
||||
} else {
|
||||
dot.style.display = 'none';
|
||||
dot.display = 'none';
|
||||
}
|
||||
});
|
||||
group.style.display = '';
|
||||
group.display = '';
|
||||
// label at the middle waypoint
|
||||
const mid =
|
||||
projected.filter(Boolean)[
|
||||
@@ -589,33 +595,22 @@ export function createScreenAnnotationRenderer(
|
||||
if (parts.label && mid)
|
||||
positionCallout(parts.label, mid.x, mid.y - 12, true);
|
||||
} else {
|
||||
group.style.display = 'none';
|
||||
group.display = 'none';
|
||||
}
|
||||
} else {
|
||||
const p = project(anno.anchor.lon, anno.anchor.lat);
|
||||
if (p) {
|
||||
group.style.display = '';
|
||||
group.display = '';
|
||||
const baseDotR = anno.type === 'label' ? LABEL_DOT_R : DOT_R;
|
||||
if (parts.dot) {
|
||||
parts.dot.setAttribute('cx', p.x.toFixed(1));
|
||||
parts.dot.setAttribute('cy', p.y.toFixed(1));
|
||||
parts.dot.setAttribute('r', (baseDotR * mScale).toFixed(1));
|
||||
}
|
||||
if (parts.ringOuter) {
|
||||
parts.ringOuter.setAttribute('cx', p.x.toFixed(1));
|
||||
parts.ringOuter.setAttribute('cy', p.y.toFixed(1));
|
||||
parts.ringOuter.setAttribute(
|
||||
'r',
|
||||
(RING_OUTER_R * mScale).toFixed(1),
|
||||
);
|
||||
}
|
||||
if (parts.ringInner) {
|
||||
parts.ringInner.setAttribute('cx', p.x.toFixed(1));
|
||||
parts.ringInner.setAttribute('cy', p.y.toFixed(1));
|
||||
parts.ringInner.setAttribute(
|
||||
'r',
|
||||
(RING_INNER_R * mScale).toFixed(1),
|
||||
);
|
||||
for (const [part, radius] of [
|
||||
[parts.dot, baseDotR],
|
||||
[parts.ringOuter, RING_OUTER_R],
|
||||
[parts.ringInner, RING_INNER_R],
|
||||
]) {
|
||||
if (!part) continue;
|
||||
part.set.cx = p.x.toFixed(1);
|
||||
part.set.cy = p.y.toFixed(1);
|
||||
part.set.r = (radius * mScale).toFixed(1);
|
||||
}
|
||||
if (parts.label) {
|
||||
// Keep the callout tucked near the (now smaller) reticle by scaling its offset.
|
||||
@@ -623,18 +618,15 @@ export function createScreenAnnotationRenderer(
|
||||
const ly = p.y - 34 * mScale;
|
||||
positionCallout(parts.label, lx, ly, false);
|
||||
if (parts.leader) {
|
||||
parts.leader.setAttribute('x1', p.x.toFixed(1));
|
||||
parts.leader.setAttribute('y1', p.y.toFixed(1));
|
||||
parts.leader.setAttribute('x2', lx.toFixed(1));
|
||||
parts.leader.setAttribute(
|
||||
'y2',
|
||||
(ly + parts.label.height).toFixed(1),
|
||||
);
|
||||
parts.leader.set.x1 = p.x.toFixed(1);
|
||||
parts.leader.set.y1 = p.y.toFixed(1);
|
||||
parts.leader.set.x2 = lx.toFixed(1);
|
||||
parts.leader.set.y2 = (ly + parts.label.height).toFixed(1);
|
||||
parts.label._leader = parts.leader; // so de-collision can re-point it
|
||||
}
|
||||
}
|
||||
} else {
|
||||
group.style.display = 'none';
|
||||
group.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -646,32 +638,53 @@ export function createScreenAnnotationRenderer(
|
||||
if (
|
||||
cal &&
|
||||
cal.sized &&
|
||||
rec.group.style.display !== 'none' &&
|
||||
rec.group.display !== 'none' &&
|
||||
Number.isFinite(cal._x)
|
||||
) {
|
||||
placed.push(cal);
|
||||
}
|
||||
}
|
||||
if (placed.length > 1) decollideCallouts(placed);
|
||||
placeCalloutsAboveVoicePreview(placed, svg, w, h);
|
||||
placeCalloutsAboveVoicePreview(placed, w, h);
|
||||
|
||||
// Tracked-entity z-order — runs AFTER all geometry + callout de-collision so each
|
||||
// group's bbox reflects its final on-screen extent. Any mark whose bbox INTERSECTS
|
||||
// the tracked entity's screen footprint is faded toward FULLY HIDDEN (target 0, not a
|
||||
// partial dim) so the tracked plane/ship reads as genuinely ON TOP. The fade is EASED
|
||||
// per frame (no abrupt pop as the entity crosses a mark) and reverses smoothly when
|
||||
// it moves off. Every screen mark here is point-like (pins/labels/reticles/arrows) —
|
||||
// the big area/route footprints live in the world layer — so fully hiding an
|
||||
// overlapped one is safe and not jarring.
|
||||
publish();
|
||||
// Tracked-entity z-order — measured once the view shows this frame's
|
||||
// geometry (still before the browser paints it).
|
||||
const fading = [...records.values()].some(
|
||||
(rec) => rec._trackedFade !== undefined && rec._trackedFade !== 1,
|
||||
);
|
||||
if ((trackedRect || fading) && !trackedPending) {
|
||||
trackedPending = true;
|
||||
void nextTick(() => {
|
||||
trackedPending = false;
|
||||
fadeTracked(trackedRect);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let trackedPending = false;
|
||||
|
||||
// Runs AFTER all geometry + callout de-collision are in the document so each
|
||||
// group's bbox reflects its final on-screen extent. Any mark whose bbox
|
||||
// INTERSECTS the tracked entity's screen footprint is faded toward FULLY
|
||||
// HIDDEN (target 0, not a partial dim) so the tracked plane/ship reads as
|
||||
// genuinely ON TOP. The fade is EASED per frame (no abrupt pop as the entity
|
||||
// crosses a mark) and reverses smoothly when it moves off. Every screen mark
|
||||
// here is point-like (pins/labels/reticles/arrows) — the big area/route
|
||||
// footprints live in the world layer — so fully hiding an overlapped one is
|
||||
// safe and not jarring.
|
||||
function fadeTracked(trackedRect) {
|
||||
if (!shown()) return;
|
||||
let changed = false;
|
||||
for (const rec of records.values()) {
|
||||
const { anno, group } = rec;
|
||||
if (group.style.display === 'none') continue;
|
||||
if (group.display === 'none') continue;
|
||||
const cur = rec._trackedFade ?? 1;
|
||||
let overlapping = false;
|
||||
if (trackedRect) {
|
||||
let bb = null;
|
||||
try {
|
||||
bb = group.getBBox();
|
||||
bb = rec.element?.getBBox() ?? null;
|
||||
} catch {
|
||||
bb = null;
|
||||
}
|
||||
@@ -692,32 +705,109 @@ export function createScreenAnnotationRenderer(
|
||||
const target = overlapping ? 0 : 1;
|
||||
const next = cur + (target - cur) * TRACKED_FADE_EASE;
|
||||
rec._trackedFade = Math.abs(next - target) < 0.02 ? target : next;
|
||||
// Only override when faded — otherwise leave the base alpha (set above) intact.
|
||||
// (The CSS `transition: opacity` was removed from .gev-anno so this per-frame JS ease
|
||||
// is the ONLY easing — previously the two fought and produced an opacity oscillation.)
|
||||
if (rec._trackedFade !== 1) {
|
||||
group.setAttribute(
|
||||
'opacity',
|
||||
String((anno.alpha ?? 1) * rec._trackedFade),
|
||||
);
|
||||
// The base alpha applies unless faded. (The CSS `transition: opacity` was
|
||||
// removed from .gev-anno so this per-frame JS ease is the ONLY easing —
|
||||
// previously the two fought and produced an opacity oscillation.)
|
||||
const opacity = String(
|
||||
(anno.alpha ?? 1) * (rec._trackedFade !== 1 ? rec._trackedFade : 1),
|
||||
);
|
||||
if (opacity !== group.opacity) {
|
||||
group.opacity = opacity;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) publish();
|
||||
}
|
||||
|
||||
/** The board as its view renders it: one entry per live or fading mark. */
|
||||
function boardView() {
|
||||
const view = (rec, fading) => ({
|
||||
key: rec.key,
|
||||
className: fading
|
||||
? 'gev-anno gev-out'
|
||||
: rec.group.entered
|
||||
? 'gev-anno gev-in'
|
||||
: 'gev-anno',
|
||||
opacity: rec.group.opacity,
|
||||
display: rec.group.display,
|
||||
drawn: rec.drawn,
|
||||
children: partViews(rec.parts),
|
||||
});
|
||||
return {
|
||||
owner,
|
||||
viewBox,
|
||||
markers: [...arrowMarkers].map(([key, fill]) => ({ key, fill })),
|
||||
// In the order the marks were added, fading ones where they were.
|
||||
marks: [
|
||||
...[...records.values()].map((rec) => view(rec, false)),
|
||||
...[...leaving.values()].map((rec) => rec.view),
|
||||
].sort((a, b) => a.key - b.key),
|
||||
};
|
||||
}
|
||||
|
||||
/** Present the board; an unchanged one is not sent again unless forced. */
|
||||
function publish({ force = false } = {}) {
|
||||
if (!shown()) return;
|
||||
const board = boardView();
|
||||
const signature = JSON.stringify(board, (name, value) =>
|
||||
name === 'owner' ? undefined : value,
|
||||
);
|
||||
if (!force && signature === published) return;
|
||||
published = signature;
|
||||
store.publish(board);
|
||||
}
|
||||
|
||||
/** Take (or, with null, drop) an element the view rendered for a mark. */
|
||||
function attach(key, kind, element) {
|
||||
const rec = byKey.get(key);
|
||||
if (!rec) return;
|
||||
if (kind === 'group') {
|
||||
rec.element = element;
|
||||
// Lay the new mark out before its next-frame `gev-in`, so the draw-on
|
||||
// transition starts from the undrawn stroke.
|
||||
try {
|
||||
element?.getBBox();
|
||||
} catch {
|
||||
/* not laid out */
|
||||
}
|
||||
} else if (kind === 'text' && rec.parts.label) {
|
||||
rec.parts.label.element = element;
|
||||
// Size the card to its text once the text is in the document.
|
||||
if (element && sizeCallout(rec.parts.label) && records.has(rec.anno.id))
|
||||
projectAll();
|
||||
}
|
||||
}
|
||||
|
||||
/** The draw-on stroke finished: it reads solid from now on. */
|
||||
function drawn(key) {
|
||||
const rec = byKey.get(key);
|
||||
if (!rec || rec.drawn) return;
|
||||
rec.drawn = true;
|
||||
publish();
|
||||
}
|
||||
|
||||
function remove(anno) {
|
||||
const rec = records.get(anno.id);
|
||||
if (!rec) return;
|
||||
rec.group.classList.remove('gev-in');
|
||||
rec.group.classList.add('gev-out');
|
||||
const node = rec.group;
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
node.remove();
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
}, 360);
|
||||
// The fading mark keeps the look it had; it leaves the board after 360 ms.
|
||||
rec.view = { ...boardView().marks.find((mark) => mark.key === rec.key) };
|
||||
rec.view.className = 'gev-anno gev-out';
|
||||
leaving.set(rec.key, rec);
|
||||
let timer;
|
||||
try {
|
||||
timer = window.setTimeout(() => {
|
||||
timers.delete(timer);
|
||||
leaving.delete(rec.key);
|
||||
byKey.delete(rec.key);
|
||||
publish();
|
||||
}, 360);
|
||||
} catch (error) {
|
||||
leaving.delete(rec.key);
|
||||
throw error;
|
||||
}
|
||||
if (leaving.has(rec.key)) timers.add(timer);
|
||||
records.delete(anno.id);
|
||||
publish();
|
||||
// Board emptied → drop accumulated height samples (natural reset point).
|
||||
if (records.size === 0) heightCache.clear();
|
||||
}
|
||||
@@ -733,87 +823,104 @@ export function createScreenAnnotationRenderer(
|
||||
} catch {
|
||||
/* torn down */
|
||||
}
|
||||
try {
|
||||
layer.remove();
|
||||
} catch {
|
||||
/* gone */
|
||||
for (const timer of timers) clearTimeout(timer);
|
||||
timers.clear();
|
||||
if (shown()) {
|
||||
store.connect(null);
|
||||
store.publish(null);
|
||||
}
|
||||
records.clear();
|
||||
leaving.clear();
|
||||
byKey.clear();
|
||||
heightCache.clear();
|
||||
}
|
||||
|
||||
// Claim the board; from here on only this renderer's own board changes.
|
||||
store.publish(boardView());
|
||||
store.connect({
|
||||
attach: (from, key, kind, element) => {
|
||||
if (from === owner && shown()) attach(key, kind, element);
|
||||
},
|
||||
drawn: (from, key) => {
|
||||
if (from === owner && shown()) drawn(key);
|
||||
},
|
||||
});
|
||||
|
||||
return { add, update, remove, sync, destroy };
|
||||
}
|
||||
|
||||
// --- SVG helpers ------------------------------------------------------------
|
||||
|
||||
function buildOverlay() {
|
||||
const layer = document.createElement('div');
|
||||
layer.className = 'gev-screen-whiteboard';
|
||||
const svg = document.createElementNS(SVGNS, 'svg');
|
||||
svg.setAttribute('class', 'gev-screen-whiteboard-svg');
|
||||
svg.setAttribute('preserveAspectRatio', 'none');
|
||||
const defs = document.createElementNS(SVGNS, 'defs');
|
||||
// subtle hand-drawn wobble for strokes
|
||||
defs.innerHTML = `
|
||||
<filter id="gev-sketch" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.014" numOctaves="2" seed="7" result="n"/>
|
||||
<feDisplacementMap in="SourceGraphic" in2="n" scale="2.2" xChannelSelector="R" yChannelSelector="G"/>
|
||||
</filter>`;
|
||||
svg.appendChild(defs);
|
||||
layer.appendChild(svg);
|
||||
return { layer, svg, defs };
|
||||
/**
|
||||
* One SVG element of a mark as plain data: the attributes it starts with, the
|
||||
* ones each frame sets, and whether it is shown.
|
||||
*/
|
||||
function svgPart(tag, attrs) {
|
||||
return { tag, attrs, set: {}, display: '' };
|
||||
}
|
||||
|
||||
/** A mark's parts in document order, as the view renders them. */
|
||||
function partViews(parts) {
|
||||
const views = [];
|
||||
const element = (key, part) =>
|
||||
views.push({
|
||||
key,
|
||||
tag: part.tag,
|
||||
attrs: { ...part.attrs, ...part.set },
|
||||
display: part.display,
|
||||
});
|
||||
if (parts.poly) element('poly', parts.poly);
|
||||
if (parts.path) element('path', parts.path);
|
||||
parts.dots?.forEach((dot, index) => element(`dot-${index}`, dot));
|
||||
if (parts.ringOuter) element('ring-outer', parts.ringOuter);
|
||||
if (parts.ringInner) element('ring-inner', parts.ringInner);
|
||||
if (parts.dot) element('dot', parts.dot);
|
||||
if (parts.leader) element('leader', parts.leader);
|
||||
const label = parts.label;
|
||||
if (label)
|
||||
views.push({
|
||||
key: 'callout',
|
||||
tag: 'callout',
|
||||
text: label.text,
|
||||
fill: label.fill,
|
||||
transform: label.transform,
|
||||
// The card is sized to its text once the text has been measured.
|
||||
card: label.sized
|
||||
? { width: String(label.width), height: String(label.height) }
|
||||
: {},
|
||||
accent: label.sized
|
||||
? { height: String(label.height - 8), x: '0', y: '4' }
|
||||
: {},
|
||||
});
|
||||
return views;
|
||||
}
|
||||
|
||||
function makeCallout(text, c) {
|
||||
if (!text) return null;
|
||||
const node = document.createElementNS(SVGNS, 'g');
|
||||
node.setAttribute('class', 'gev-anno-callout');
|
||||
const rect = document.createElementNS(SVGNS, 'rect');
|
||||
rect.setAttribute('rx', '5');
|
||||
rect.setAttribute('class', 'gev-anno-card');
|
||||
const t = document.createElementNS(SVGNS, 'text');
|
||||
t.setAttribute('class', 'gev-anno-text');
|
||||
t.setAttribute('x', '9');
|
||||
t.setAttribute('y', '16');
|
||||
t.textContent = text;
|
||||
const accent = document.createElementNS(SVGNS, 'rect');
|
||||
accent.setAttribute('class', 'gev-anno-accent');
|
||||
accent.setAttribute('width', '3');
|
||||
accent.setAttribute('rx', '1.5');
|
||||
accent.setAttribute('fill', c);
|
||||
node.appendChild(rect);
|
||||
node.appendChild(accent);
|
||||
node.appendChild(t);
|
||||
const callout = {
|
||||
node,
|
||||
rect,
|
||||
text: t,
|
||||
accent,
|
||||
return {
|
||||
text,
|
||||
fill: c,
|
||||
element: null,
|
||||
transform: null,
|
||||
width: 0,
|
||||
height: 26,
|
||||
sized: false,
|
||||
};
|
||||
// size the card to the text once it is in the DOM
|
||||
requestAnimationFrame(() => sizeCallout(callout));
|
||||
return callout;
|
||||
}
|
||||
|
||||
/** Size a callout to its rendered text; false until the text is laid out. */
|
||||
function sizeCallout(callout) {
|
||||
try {
|
||||
const bbox = callout.text.getBBox();
|
||||
const bbox = callout.element.getBBox();
|
||||
const w = Math.max(24, bbox.width + 18);
|
||||
const h = Math.max(22, bbox.height + 10);
|
||||
callout.rect.setAttribute('width', String(w));
|
||||
callout.rect.setAttribute('height', String(h));
|
||||
callout.accent.setAttribute('height', String(h - 8));
|
||||
callout.accent.setAttribute('x', '0');
|
||||
callout.accent.setAttribute('y', '4');
|
||||
callout.width = w;
|
||||
callout.height = h;
|
||||
callout.sized = true;
|
||||
return true;
|
||||
} catch {
|
||||
/* not laid out yet */
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -822,10 +929,7 @@ function positionCallout(callout, x, y, center) {
|
||||
const tx = center ? x - callout.width / 2 : x;
|
||||
callout._x = tx;
|
||||
callout._y = y;
|
||||
callout.node.setAttribute(
|
||||
'transform',
|
||||
`translate(${tx.toFixed(1)}, ${y.toFixed(1)})`,
|
||||
);
|
||||
callout.transform = `translate(${tx.toFixed(1)}, ${y.toFixed(1)})`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -843,13 +947,10 @@ function decollideCallouts(callouts) {
|
||||
const overlapY = a._y < b._y + b.height && a._y + a.height > b._y;
|
||||
if (overlapX && overlapY) {
|
||||
a._y = b._y + b.height + 5;
|
||||
a.node.setAttribute(
|
||||
'transform',
|
||||
`translate(${a._x.toFixed(1)}, ${a._y.toFixed(1)})`,
|
||||
);
|
||||
a.transform = `translate(${a._x.toFixed(1)}, ${a._y.toFixed(1)})`;
|
||||
if (a._leader) {
|
||||
a._leader.setAttribute('x2', a._x.toFixed(1));
|
||||
a._leader.setAttribute('y2', a._y.toFixed(1));
|
||||
a._leader.set.x2 = a._x.toFixed(1);
|
||||
a._leader.set.y2 = a._y.toFixed(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -857,11 +958,13 @@ function decollideCallouts(callouts) {
|
||||
}
|
||||
|
||||
/** Keep an overlapping callout above the actual open Voice Preview, when it fits. */
|
||||
function placeCalloutsAboveVoicePreview(callouts, svg, width, height) {
|
||||
function placeCalloutsAboveVoicePreview(callouts, width, height) {
|
||||
if (!callouts.length) return;
|
||||
const panel = document.getElementById('gev-voice-panel');
|
||||
const svg = document.querySelector('.gev-screen-whiteboard-svg');
|
||||
if (
|
||||
!panel ||
|
||||
!svg ||
|
||||
panel.hidden ||
|
||||
panel.dataset.view !== 'preview' ||
|
||||
panel.checkVisibility?.({
|
||||
@@ -911,67 +1014,13 @@ function placeCalloutsAboveVoicePreview(callouts, svg, width, height) {
|
||||
continue;
|
||||
positionCallout(callout, candidate._x, candidate._y, false);
|
||||
if (callout._leader) {
|
||||
callout._leader.setAttribute('x2', candidate._x.toFixed(1));
|
||||
callout._leader.setAttribute(
|
||||
'y2',
|
||||
(candidate._y + candidate.height).toFixed(1),
|
||||
);
|
||||
callout._leader.set.x2 = candidate._x.toFixed(1);
|
||||
callout._leader.set.y2 = (candidate._y + candidate.height).toFixed(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureArrowMarker(defs, key, c) {
|
||||
if (defs.querySelector(`#gev-arrow-${key}`)) return;
|
||||
const marker = document.createElementNS(SVGNS, 'marker');
|
||||
marker.setAttribute('id', `gev-arrow-${key}`);
|
||||
marker.setAttribute('viewBox', '0 0 10 10');
|
||||
marker.setAttribute('refX', '8');
|
||||
marker.setAttribute('refY', '5');
|
||||
marker.setAttribute('markerWidth', '7');
|
||||
marker.setAttribute('markerHeight', '7');
|
||||
marker.setAttribute('orient', 'auto-start-reverse');
|
||||
const path = document.createElementNS(SVGNS, 'path');
|
||||
path.setAttribute('d', 'M 0 0 L 10 5 L 0 10 z');
|
||||
path.setAttribute('fill', c);
|
||||
marker.appendChild(path);
|
||||
defs.appendChild(marker);
|
||||
}
|
||||
|
||||
function svgEl(tag, attrs) {
|
||||
const el = document.createElementNS(SVGNS, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
|
||||
return el;
|
||||
}
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById('gev-screen-whiteboard-styles')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'gev-screen-whiteboard-styles';
|
||||
style.textContent = `
|
||||
.gev-screen-whiteboard { position: fixed; inset: 0; pointer-events: none; z-index: 90; }
|
||||
.gev-screen-whiteboard-svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
|
||||
/* No CSS opacity transition here on purpose: group opacity is driven per-frame in JS
|
||||
(the 260ms fade-in via computeAlpha, and the tracked-entity z-order ease). A CSS
|
||||
transition fought those per-frame writes and made the tracked-fade opacity oscillate
|
||||
(flicker). Fade-OUT keeps its own transition via .gev-anno.gev-out below. */
|
||||
.gev-anno-ring { filter: drop-shadow(0 0 6px currentColor); }
|
||||
.gev-anno-dot { filter: drop-shadow(0 0 5px rgba(255,255,255,0.6)); }
|
||||
.gev-anno-area { filter: drop-shadow(0 0 5px currentColor); }
|
||||
.gev-anno-arrow { filter: drop-shadow(0 0 4px currentColor); stroke-linecap: round; }
|
||||
.gev-anno-leader { stroke-dasharray: 2 3; }
|
||||
.gev-anno-card { fill: rgba(8,18,28,0.78); stroke: rgba(255,255,255,0.14); stroke-width: 1; }
|
||||
.gev-anno-text { fill: #eaf6ff; font: 600 13px "JetBrains Mono", ui-monospace, monospace; letter-spacing: 0.02em; }
|
||||
/* draw-on: outlined shapes reveal their stroke */
|
||||
.gev-draw { stroke-dasharray: 1400; stroke-dashoffset: 1400; }
|
||||
.gev-anno.gev-in .gev-draw { transition: stroke-dashoffset 900ms ease-out; stroke-dashoffset: 0; }
|
||||
/* opacity-only fade-in: a CSS transform here would override the SVG transform
|
||||
attribute used to POSITION the callout and snap it to 0,0. */
|
||||
.gev-anno-callout { opacity: 0; }
|
||||
.gev-anno.gev-in .gev-anno-callout { animation: gev-pop 320ms ease-out both; }
|
||||
.gev-anno.gev-out { opacity: 0 !important; transition: opacity 320ms ease; }
|
||||
.gev-pulse { animation: gev-ring-pulse 1.8s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
|
||||
@keyframes gev-ring-pulse { 0%,100% { opacity: 0.9; stroke-width: 2; } 50% { opacity: 0.35; stroke-width: 3.5; } }
|
||||
@keyframes gev-pop { from { opacity: 0; } to { opacity: 1; } }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
/** Record the arrowhead marker for a color, unless one is already defined. */
|
||||
function ensureArrowMarker(markers, key, c) {
|
||||
if (!markers.has(key)) markers.set(key, c);
|
||||
}
|
||||
Reference in new issue
Block a user