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.
This commit is contained in:
Sameh Khamis committed 2026-10-31 21:39:34 -07:00
1 parent 265aa605bd
commit 662f51fdf3
13 files changed
+768 -672

No files matched your search

+7 -3
View File
@@ -16,11 +16,15 @@ import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
* 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.
* one route change that spans both sub-renderers. `screen` passes options to the
* screen renderer (its store, in tests).
*/
export function createHybridAnnotationRenderer(viewer) {
export function createHybridAnnotationRenderer(
viewer,
{ screen: screenOptions } = {},
) {
const world = createWorldAnnotationRenderer(viewer);
const screen = createScreenAnnotationRenderer(viewer);
const screen = createScreenAnnotationRenderer(viewer, screenOptions);
// anno.id -> { worldProxy?, screenProxy? } — proxies own the sub-renderer state.
const routed = new Map();
+41 -172
View File
@@ -1,143 +1,24 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createPinia } from 'pinia';
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
class FakeClassList {
constructor(owner) {
this.owner = owner;
this.names = new Set();
}
reset(value) {
this.names = new Set(String(value || '').split(/\s+/).filter(Boolean));
}
add(...names) {
for (const name of names) this.names.add(name);
this.owner.attributes.set('class', Array.from(this.names).join(' '));
}
remove(...names) {
for (const name of names) this.names.delete(name);
this.owner.attributes.set('class', Array.from(this.names).join(' '));
}
contains(name) {
return this.names.has(name);
}
/** A hybrid renderer whose screen board is presented to its own store. */
function hybridWithBoard(viewer) {
const store = useScreenAnnotationsStore(createPinia());
const renderer = createHybridAnnotationRenderer(viewer, { screen: { store } });
return { store, renderer };
}
class FakeElement {
constructor(tagName) {
this.tagName = tagName;
this.attributes = new Map();
this.children = [];
this.parentNode = null;
this.style = {};
this.classList = new FakeClassList(this);
this.textContent = '';
}
setAttribute(name, value) {
this.attributes.set(name, String(value));
if (name === 'class') this.classList.reset(value);
}
getAttribute(name) {
return this.attributes.get(name) ?? null;
}
set className(value) {
this.setAttribute('class', value);
}
get className() {
return this.getAttribute('class') || '';
}
set id(value) {
this.setAttribute('id', value);
}
get id() {
return this.getAttribute('id') || '';
}
appendChild(child) {
child.parentNode = this;
this.children.push(child);
return child;
}
removeChild(child) {
const index = this.children.indexOf(child);
if (index >= 0) this.children.splice(index, 1);
child.parentNode = null;
return child;
}
remove() {
this.parentNode?.removeChild(this);
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] || null;
}
querySelectorAll(selector) {
const className = selector.startsWith('.') ? selector.slice(1) : null;
const matches = [];
const visit = (node) => {
for (const child of node.children) {
if (className && child.classList.contains(className)) matches.push(child);
visit(child);
}
};
visit(this);
return matches;
}
addEventListener() {}
getBBox() {
return { x: 0, y: 0, width: 40, height: 16 };
}
set innerHTML(value) {
this._innerHTML = value;
}
/** The screen marks the board shows, fading ones included. */
function annotationGroups(store) {
return store.board?.marks ?? [];
}
function fakeDocument() {
const head = new FakeElement('head');
const body = new FakeElement('body');
const roots = [head, body];
return {
head,
body,
createElement: (tag) => new FakeElement(tag),
createElementNS: (_namespace, tag) => new FakeElement(tag),
getElementById(id) {
let found = null;
const visit = (node) => {
if (node.getAttribute('id') === id) found = node;
for (const child of node.children) visit(child);
};
for (const root of roots) visit(root);
return found;
},
};
}
function findAnnotationGroup(document) {
const layer = document.body.children.find((child) => child.classList.contains('gev-screen-whiteboard'));
const svg = layer.children.find((child) => child.classList.contains('gev-screen-whiteboard-svg'));
return {
svg,
group: svg.children.find((child) => child.classList.contains('gev-anno')),
};
}
const childrenWithClass = (mark, name) =>
mark.children.filter((child) => child.attrs?.class?.split(' ').includes(name));
function cameraLookingAt(lon, lat) {
const target = Cesium.Cartesian3.fromDegrees(lon, lat);
@@ -184,7 +65,6 @@ function cameraLookingAt(lon, lat) {
/** Install the browser globals both renderers touch, restored after the test. */
function installBrowserGlobals(t) {
const originalDocument = globalThis.document;
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalWindow = globalThis.window;
const originalBrowserConstructors = new Map([
@@ -193,13 +73,10 @@ function installBrowserGlobals(t) {
['ImageBitmap', globalThis.ImageBitmap],
['OffscreenCanvas', globalThis.OffscreenCanvas],
]);
globalThis.document = fakeDocument();
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
globalThis.window = { setTimeout: (fn) => { fn(); return 1; } };
for (const name of originalBrowserConstructors.keys()) globalThis[name] = class {};
t.after(() => {
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
if (originalWindow === undefined) delete globalThis.window;
@@ -244,9 +121,6 @@ function fakeViewer(lon, lat) {
};
}
function annotationGroups(svg) {
return svg.children.filter((child) => child.classList.contains('gev-anno'));
}
function naiveRingCentroid(ring) {
const totals = ring.reduce(
@@ -257,7 +131,6 @@ function naiveRingCentroid(ring) {
}
test('hybrid outline upgrade preserves the screen group and adds world geometry', (t) => {
const originalDocument = globalThis.document;
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalBrowserConstructors = new Map([
['HTMLCanvasElement', globalThis.HTMLCanvasElement],
@@ -265,12 +138,9 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
['ImageBitmap', globalThis.ImageBitmap],
['OffscreenCanvas', globalThis.OffscreenCanvas],
]);
globalThis.document = fakeDocument();
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
for (const name of originalBrowserConstructors.keys()) globalThis[name] = class {};
t.after(() => {
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
for (const [name, original] of originalBrowserConstructors) {
@@ -305,7 +175,7 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
},
},
};
const renderer = createHybridAnnotationRenderer(viewer);
const { store, renderer } = hybridWithBoard(viewer);
const anno = {
id: 'anno-hybrid-fb3',
type: 'area',
@@ -319,10 +189,8 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
};
renderer.add(anno);
const before = findAnnotationGroup(globalThis.document);
const originalCallout = before.group.querySelector('.gev-anno-callout');
const originalDot = before.group.querySelector('.gev-anno-dot');
assert.equal(before.group.querySelectorAll('.gev-anno-ring').length, 2);
const [before] = annotationGroups(store);
assert.equal(childrenWithClass(before, 'gev-anno-ring').length, 2);
assert.equal(dataSources.length, 1);
assert.equal(dataSources[0].entities.values.length, 0);
@@ -339,18 +207,22 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
anno.footprintKind = 'area';
renderer.update(anno);
const after = findAnnotationGroup(globalThis.document);
const [after, ...others] = annotationGroups(store);
const expectedWindow = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
Cesium.Cartesian3.fromDegrees(centroid.lon, centroid.lat, centroid.height),
);
assert.equal(after.group, before.group, 'the real hybrid keeps the existing SVG group');
assert.equal(after.svg.children.filter((child) => child.classList.contains('gev-anno')).length, 1);
assert.equal(after.group.querySelectorAll('.gev-anno-ring').length, 0, 'screen reticle rings are removed');
assert.equal(after.group.querySelector('.gev-anno-callout'), originalCallout, 'the callout node is retained');
assert.equal(after.group.querySelector('.gev-anno-dot'), originalDot, 'the anchor dot is retained');
assert.equal(originalDot.getAttribute('cx'), expectedWindow.x.toFixed(1));
assert.equal(originalDot.getAttribute('cy'), expectedWindow.y.toFixed(1));
assert.equal(after.key, before.key, 'the real hybrid keeps the existing screen mark');
assert.equal(others.length, 0);
assert.equal(childrenWithClass(after, 'gev-anno-ring').length, 0, 'screen reticle rings are removed');
assert.deepEqual(
after.children.map((child) => child.key),
['dot', 'leader', 'callout'],
'the dot, leader and callout are retained',
);
const dot = after.children.find((child) => child.key === 'dot');
assert.equal(dot.attrs.cx, expectedWindow.x.toFixed(1));
assert.equal(dot.attrs.cy, expectedWindow.y.toFixed(1));
assert.deepEqual(centroid, { lon: -100.8, lat: 29.4, height: 0 }, 'closed-ring vertex mean stays pinned');
assert.equal(dataSources[0].entities.values.length, 2, 'world area adds one fill and one outline');
assert.equal(dataSources[0].entities.values.filter((entity) => entity.polygon).length, 1);
@@ -370,7 +242,7 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
test('a sub-renderer throw mid-add leaves state the rollback can still remove', (t) => {
installBrowserGlobals(t);
const { viewer, dataSources } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer);
const { store, renderer } = hybridWithBoard(viewer);
const ring = [[-106, 25], [-93, 25], [-93, 36], [-106, 36], [-106, 25]];
const anno = {
id: 'anno-partial-add',
@@ -386,20 +258,19 @@ test('a sub-renderer throw mid-add leaves state the rollback can still remove',
// The world drape lands first; the screen caption then fails the way a lost
// context does, AFTER the world geometry is already on the board.
const { svg } = findAnnotationGroup(globalThis.document);
const appendChild = svg.appendChild.bind(svg);
const publish = store.publish;
let failNextInsert = true;
svg.appendChild = (child) => {
store.publish = (board) => {
if (failNextInsert) {
failNextInsert = false;
throw new Error('screen insert failed');
}
return appendChild(child);
return publish(board);
};
assert.throws(() => renderer.add(anno), /screen insert failed/);
assert.equal(dataSources[0].entities.values.length, 2, 'the world drape is already live');
assert.equal(annotationGroups(svg).length, 0, 'the half-built screen group detached itself');
assert.equal(annotationGroups(store).length, 0, 'the half-built screen group detached itself');
// The engine's rollback path — it must reach the partial state by id.
renderer.remove(anno);
@@ -409,7 +280,7 @@ test('a sub-renderer throw mid-add leaves state the rollback can still remove',
// an orphan nothing owns.
renderer.add(anno);
assert.equal(dataSources[0].entities.values.length, 2, 'the retry draws exactly one world drape');
assert.equal(annotationGroups(svg).length, 1, 'and exactly one screen caption');
assert.equal(annotationGroups(store).length, 1, 'and exactly one screen caption');
renderer.remove(anno);
assert.equal(dataSources[0].entities.values.length, 0);
renderer.destroy();
@@ -418,7 +289,7 @@ test('a sub-renderer throw mid-add leaves state the rollback can still remove',
test('a release that throws keeps the mark addressable for a retry cleanup', (t) => {
installBrowserGlobals(t);
const { viewer, dataSources } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer);
const { store, renderer } = hybridWithBoard(viewer);
const ring = [[-106, 25], [-93, 25], [-93, 36], [-106, 36], [-106, 25]];
const anno = {
id: 'anno-release-throw',
@@ -433,35 +304,34 @@ test('a release that throws keeps the mark addressable for a retry cleanup', (t)
};
renderer.add(anno);
const { svg } = findAnnotationGroup(globalThis.document);
assert.equal(dataSources[0].entities.values.length, 2);
assert.equal(annotationGroups(svg).length, 1);
assert.equal(annotationGroups(store).length, 1);
// The world route releases fine, then the screen release fails part-way
// (its fade-out timer throws) — so its SVG group is still in the document.
// (its fade-out timer throws) — so its mark is still on the board.
const workingSetTimeout = globalThis.window.setTimeout;
globalThis.window.setTimeout = () => { throw new Error('teardown scheduling failed'); };
assert.throws(() => renderer.remove(anno), /teardown scheduling failed/);
assert.equal(dataSources[0].entities.values.length, 0, 'the world route did release');
assert.equal(annotationGroups(svg).length, 1, 'the screen route did NOT — that is the orphan');
assert.equal(annotationGroups(store).length, 1, 'the screen route did NOT — that is the orphan');
// Dropping the routing entry here would make that orphan permanently
// unreachable. It must still be addressable, so a retry finishes the job.
globalThis.window.setTimeout = workingSetTimeout;
renderer.remove(anno);
assert.equal(annotationGroups(svg).length, 0, 'the retry cleanup must reach the orphan');
assert.equal(annotationGroups(store).length, 0, 'the retry cleanup must reach the orphan');
assert.equal(dataSources[0].entities.values.length, 0, 'without double-releasing the world route');
// Fully released now — a third call is inert.
renderer.remove(anno);
assert.equal(annotationGroups(svg).length, 0);
assert.equal(annotationGroups(store).length, 0);
renderer.destroy();
});
test('a world add that fails mid-way leaves its landed entities removable', (t) => {
installBrowserGlobals(t);
const { viewer, dataSources } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer);
const { store, renderer } = hybridWithBoard(viewer);
const ring = [[-106, 25], [-93, 25], [-93, 36], [-106, 36], [-106, 25]];
const anno = {
id: 'anno-partial-world-add',
@@ -493,9 +363,8 @@ test('a world add that fails mid-way leaves its landed entities removable', (t)
assert.equal(entities.values.length, 0,
'the rollback must see the entities that landed before the throw');
const { svg } = findAnnotationGroup(globalThis.document);
renderer.add(anno);
assert.equal(entities.values.length, 2, 'the retry draws a complete mark');
assert.equal(annotationGroups(svg).length, 1);
assert.equal(annotationGroups(store).length, 1);
renderer.destroy();
});
+311 -261
View File
@@ -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('polygon', {
parts.poly = svgPart('polygon', {
class: anno.synthesized ? 'gev-anno-area' : 'gev-anno-area gev-draw',
fill: c,
stroke: c,
@@ -166,16 +183,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,
@@ -183,22 +198,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,
@@ -206,37 +219,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',
@@ -245,70 +254,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;
@@ -328,12 +324,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();
@@ -346,11 +348,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;
}
@@ -466,13 +466,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,
@@ -481,10 +484,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 pts = [];
@@ -500,12 +509,12 @@ export function createScreenAnnotationRenderer(
n++;
}
if (pts.length >= 3) {
parts.poly.setAttribute('points', pts.join(' '));
group.style.display = '';
parts.poly.set.points = pts.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);
@@ -520,11 +529,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(
@@ -534,7 +540,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));
@@ -543,19 +549,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)[
@@ -564,33 +570,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.
@@ -598,18 +593,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';
}
}
}
@@ -621,7 +613,7 @@ export function createScreenAnnotationRenderer(
if (
cal &&
cal.sized &&
rec.group.style.display !== 'none' &&
rec.group.display !== 'none' &&
Number.isFinite(cal._x)
) {
placed.push(cal);
@@ -629,23 +621,44 @@ export function createScreenAnnotationRenderer(
}
if (placed.length > 1) decollideCallouts(placed);
// 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;
}
@@ -666,32 +679,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();
}
@@ -707,87 +797,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;
}
}
@@ -796,10 +903,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)})`;
}
/**
@@ -817,71 +921,17 @@ 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);
}
}
}
}
}
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);
}
+293 -229
View File
@@ -1,164 +1,177 @@
import '../testSupport/registerDom.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
import ScreenAnnotations from '../ui/views/panels/ScreenAnnotations.vue';
import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
class FakeClassList {
constructor(owner) {
this.owner = owner;
this.names = new Set();
}
reset(value) {
this.names = new Set(String(value || '').split(/\s+/).filter(Boolean));
}
add(...names) {
for (const name of names) this.names.add(name);
this.owner.attributes.set('class', Array.from(this.names).join(' '));
}
remove(...names) {
for (const name of names) this.names.delete(name);
this.owner.attributes.set('class', Array.from(this.names).join(' '));
}
contains(name) {
return this.names.has(name);
}
// happy-dom does not lay out SVG; every element measures as a 40 x 16 box.
for (const type of [globalThis.SVGElement, globalThis.SVGGraphicsElement]) {
if (type) type.prototype.getBBox = () => ({ x: 0, y: 0, width: 40, height: 16 });
}
class FakeElement {
constructor(tagName) {
this.tagName = tagName;
this.attributes = new Map();
this.children = [];
this.parentNode = null;
this.style = {};
this.classList = new FakeClassList(this);
this.textContent = '';
}
const settle = async () => {
for (let flush = 0; flush < 4; flush += 1) await nextTick();
};
setAttribute(name, value) {
this.attributes.set(name, String(value));
if (name === 'class') this.classList.reset(value);
}
getAttribute(name) {
return this.attributes.get(name) ?? null;
}
set className(value) {
this.setAttribute('class', value);
}
get className() {
return this.getAttribute('class') || '';
}
set id(value) {
this.setAttribute('id', value);
}
get id() {
return this.getAttribute('id') || '';
}
appendChild(child) {
child.parentNode = this;
this.children.push(child);
return child;
}
removeChild(child) {
const index = this.children.indexOf(child);
if (index >= 0) this.children.splice(index, 1);
child.parentNode = null;
return child;
}
remove() {
this.parentNode?.removeChild(this);
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] || null;
}
querySelectorAll(selector) {
const className = selector.startsWith('.') ? selector.slice(1) : null;
const matches = [];
const visit = (node) => {
for (const child of node.children) {
if (className && child.classList.contains(className)) matches.push(child);
visit(child);
}
};
visit(this);
return matches;
}
addEventListener() {}
getBBox() {
return { x: 0, y: 0, width: 40, height: 16 };
}
set innerHTML(value) {
this._innerHTML = value;
}
}
function fakeDocument() {
const head = new FakeElement('head');
const body = new FakeElement('body');
const roots = [head, body];
return {
head,
body,
createElement: (tag) => new FakeElement(tag),
createElementNS: (_namespace, tag) => new FakeElement(tag),
getElementById(id) {
let found = null;
const visit = (node) => {
if (node.getAttribute('id') === id) found = node;
for (const child of node.children) visit(child);
};
for (const root of roots) visit(root);
return found;
},
};
}
function findAnnotationGroup(document) {
const layer = document.body.children.find((child) => child.classList.contains('gev-screen-whiteboard'));
const svg = layer.children.find((child) => child.classList.contains('gev-screen-whiteboard-svg'));
return {
svg,
group: svg.children.find((child) => child.classList.contains('gev-anno')),
};
}
test('outline upgrade preserves the existing SVG group identity', (t) => {
const originalDocument = globalThis.document;
/** Mount the board's view; animation frames run when `frames()` is called. */
function board(t) {
document.body.replaceChildren();
const pinia = createPinia();
const store = useScreenAnnotationsStore(pinia);
const app = createApp(ScreenAnnotations).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
const queued = [];
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
globalThis.document = fakeDocument();
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
globalThis.requestAnimationFrame = (callback) => queued.push(callback);
t.after(() => {
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
app.unmount();
});
const frames = () => {
for (const callback of queued.splice(0)) callback();
};
const svg = () => document.querySelector('.gev-screen-whiteboard-svg');
const groups = () => [...(svg()?.querySelectorAll('g.gev-anno') ?? [])];
return { store, frames, svg, groups };
}
function cameraOver(lon, lat) {
const positionWC = Cesium.Cartesian3.fromDegrees(lon, lat, 1000);
const directionWC = Cesium.Cartesian3.normalize(
Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()),
new Cesium.Cartesian3(),
);
// Low enough that point marks keep their full size.
return { positionWC, directionWC, positionCartographic: { height: 500 } };
}
function sceneFor(camera, extra = {}) {
return {
camera,
canvas: { clientWidth: 800, clientHeight: 600, width: 800, height: 600 },
clampToHeightSupported: false,
postRender: { addEventListener() {}, removeEventListener() {} },
...extra,
};
}
/** Project every point to the given window position for the test's length. */
function projectTo(t, at) {
const original = Cesium.SceneTransforms.worldToWindowCoordinates;
Cesium.SceneTransforms.worldToWindowCoordinates = typeof at === 'function' ? at : () => at;
t.after(() => {
Cesium.SceneTransforms.worldToWindowCoordinates = original;
});
}
test('a pin renders its reticle, leader and callout on the page board', async (t) => {
const { store, frames, svg, groups } = board(t);
projectTo(t, { x: 200, y: 300 });
const camera = cameraOver(0, 0);
const renderer = createScreenAnnotationRenderer(
{ scene: sceneFor(camera), camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
renderer.add({
id: 'pin',
type: 'pin',
color: 'amber',
label: 'Capitol',
alpha: 1,
anchor: { lon: 0, lat: 0, height: 0 },
});
await settle();
const layer = document.body.querySelector(':scope > .gev-screen-whiteboard');
assert.ok(layer);
assert.equal(svg().getAttribute('preserveAspectRatio'), 'none');
assert.equal(svg().getAttribute('viewBox'), '0 0 800 600');
assert.ok(svg().querySelector('defs > filter#gev-sketch > feTurbulence'));
const [group] = groups();
assert.equal(group.getAttribute('class'), 'gev-anno');
assert.equal(group.getAttribute('opacity'), '1');
assert.deepEqual(
[...group.children].map((child) => [child.tagName.toLowerCase(), child.getAttribute('class')]),
[
['circle', 'gev-anno-ring gev-pulse'],
['circle', 'gev-anno-ring'],
['circle', 'gev-anno-dot'],
['line', 'gev-anno-leader'],
['g', 'gev-anno-callout'],
],
);
const [outer, inner, dot, leader, callout] = group.children;
assert.equal(outer.getAttribute('stroke'), '#ffb547');
assert.equal(outer.getAttribute('cx'), '200.0');
assert.equal(outer.getAttribute('r'), '34.0');
assert.equal(inner.getAttribute('fill-opacity'), '0.12');
assert.equal(inner.getAttribute('filter'), 'url(#gev-sketch)');
assert.equal(dot.getAttribute('r'), '5.0');
assert.equal(leader.getAttribute('x1'), '200.0');
assert.equal(leader.getAttribute('x2'), '218.0');
// The card is sized to its measured text, and the leader meets its bottom.
const [card, accent, text] = callout.children;
assert.equal(text.textContent, 'Capitol');
assert.equal(text.getAttribute('x'), '9');
assert.equal(card.getAttribute('width'), '58');
assert.equal(card.getAttribute('height'), '26');
assert.equal(accent.getAttribute('height'), '18');
assert.equal(accent.getAttribute('fill'), '#ffb547');
assert.equal(callout.getAttribute('transform'), 'translate(218.0, 266.0)');
assert.equal(leader.getAttribute('y2'), '292.0');
frames();
await settle();
assert.equal(group.getAttribute('class'), 'gev-anno gev-in');
});
test('an arrow defines its arrowhead and drops the dash once drawn on', async (t) => {
const { store, frames, svg, groups } = board(t);
let calls = 0;
projectTo(t, () => (calls++ % 2 ? { x: 400, y: 100 } : { x: 100, y: 100 }));
const camera = cameraOver(0, 0);
const renderer = createScreenAnnotationRenderer(
{ scene: sceneFor(camera), camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
renderer.add({
id: 'arrow',
type: 'arrow',
color: 'green',
alpha: 1,
anchor: { lon: 0, lat: 0 },
to: { lon: 0.01, lat: 0 },
});
await settle();
const marker = svg().querySelector('defs > marker#gev-arrow-green');
assert.equal(marker.getAttribute('orient'), 'auto-start-reverse');
assert.equal(marker.querySelector('path').getAttribute('fill'), '#5dff9f');
const path = groups()[0].querySelector('path');
assert.equal(path.getAttribute('class'), 'gev-anno-arrow gev-draw');
assert.equal(path.getAttribute('marker-end'), 'url(#gev-arrow-green)');
assert.match(path.getAttribute('d'), /^M 100\.0 100\.0 Q /);
frames();
await settle();
const ended = new Event('transitionend', { bubbles: true });
ended.propertyName = 'stroke-dashoffset';
path.dispatchEvent(ended);
await settle();
assert.equal(path.style.strokeDasharray, 'none');
});
test('outline upgrade preserves the existing SVG group identity', async (t) => {
const { store, svg, groups } = board(t);
const camera = {
positionWC: Cesium.Cartesian3.ZERO,
directionWC: Cesium.Cartesian3.ZERO,
positionCartographic: { height: 1000 },
};
const sampledAnchors = [];
const scene = {
camera,
const scene = sceneFor(camera, {
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
clampToHeightSupported: true,
clampToHeight(world) {
@@ -169,9 +182,12 @@ test('outline upgrade preserves the existing SVG group identity', (t) => {
]);
return world;
},
postRender: { addEventListener() {}, removeEventListener() {} },
};
const renderer = createScreenAnnotationRenderer({ scene, camera, trackedEntity: null });
});
const renderer = createScreenAnnotationRenderer(
{ scene, camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
const anno = {
id: 'anno-fb3',
type: 'area',
@@ -182,8 +198,11 @@ test('outline upgrade preserves the existing SVG group identity', (t) => {
ring: null,
};
renderer.add(anno);
const before = findAnnotationGroup(globalThis.document);
assert.ok(before.group.querySelector('.gev-anno-ring'), 'pending area starts as a reticle');
await settle();
const [before] = groups();
assert.ok(before.querySelector('.gev-anno-ring'), 'pending area starts as a reticle');
const callout = before.querySelector('.gev-anno-callout');
const dot = before.querySelector('.gev-anno-dot');
const labelProxy = Object.assign(Object.create(anno), {
type: 'label',
@@ -191,47 +210,65 @@ test('outline upgrade preserves the existing SVG group identity', (t) => {
anchor: { lon: -97.5, lat: 31.2, height: 0 },
});
renderer.update(labelProxy);
const after = findAnnotationGroup(globalThis.document);
await settle();
const [after] = groups();
assert.equal(after.group, before.group, 'the rendered group is mutated, not replaced');
assert.ok(after === before, 'the rendered group is mutated, not replaced');
assert.deepEqual(sampledAnchors.at(-1), [-97.5, 31.2], 'the existing group projects from the re-seated anchor');
assert.equal(after.svg.children.filter((child) => child.classList.contains('gev-anno')).length, 1);
assert.equal(after.group.querySelector('.gev-anno-ring'), null, 'reticle rings are removed in place');
assert.ok(after.group.querySelector('.gev-anno-callout'), 'the original callout remains in the group');
renderer.destroy();
assert.equal(groups().length, 1);
assert.equal(after.querySelector('.gev-anno-ring'), null, 'reticle rings are removed in place');
assert.ok(after.querySelector('.gev-anno-callout') === callout, 'the original callout remains in the group');
assert.ok(after.querySelector('.gev-anno-dot') === dot, 'the anchor dot remains in the group');
assert.ok(svg());
});
test('annotation fade consumes the actual tracked host paint rectangle after layout', (t) => {
const originalDocument = globalThis.document;
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
globalThis.document = fakeDocument();
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
Cesium.SceneTransforms.worldToWindowCoordinates = () => ({ x: 0, y: 0 });
t.after(() => {
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
});
const positionWC = Cesium.Cartesian3.fromDegrees(0, 0, 1000);
const directionWC = Cesium.Cartesian3.normalize(
Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()),
new Cesium.Cartesian3(),
);
const camera = { positionWC, directionWC, positionCartographic: { height: 1000 } };
const scene = {
camera,
canvas: { clientWidth: 800, clientHeight: 600, width: 800, height: 600 },
clampToHeightSupported: false,
postRender: { addEventListener() {}, removeEventListener() {} },
test('a callout whose text changes is sized to the new text', async (t) => {
const { store, groups } = board(t);
projectTo(t, { x: 100, y: 100 });
// Text measures 7px a character; everything else keeps the shared box.
const original = globalThis.SVGGraphicsElement.prototype.getBBox;
globalThis.SVGGraphicsElement.prototype.getBBox = function () {
return this.tagName.toLowerCase() === 'text'
? { x: 0, y: 0, width: this.textContent.length * 7, height: 16 }
: original.call(this);
};
t.after(() => {
globalThis.SVGGraphicsElement.prototype.getBBox = original;
});
const camera = cameraOver(0, 0);
const renderer = createScreenAnnotationRenderer(
{ scene: sceneFor(camera), camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
const anno = {
id: 'upgrade',
type: 'label',
color: 'primary',
label: 'Texas',
alpha: 1,
anchor: { lon: 0, lat: 0 },
};
renderer.add(anno);
await settle();
const card = () => groups()[0].querySelector('.gev-anno-card');
assert.equal(card().getAttribute('width'), String(5 * 7 + 18));
renderer.update({ ...anno, outlineUnavailable: true });
await settle();
const text = 'Texas · Detailed outline unavailable';
assert.equal(groups()[0].querySelector('text').textContent, text);
assert.equal(card().getAttribute('width'), String(text.length * 7 + 18));
});
test('annotation fade consumes the actual tracked host paint rectangle after layout', async (t) => {
const { store, groups } = board(t);
projectTo(t, { x: 0, y: 0 });
const camera = cameraOver(0, 0);
const paintRectCalls = [];
const renderer = createScreenAnnotationRenderer(
{ scene, camera, trackedEntity: null },
{ scene: sceneFor(camera), camera, trackedEntity: null },
{
store,
activeTrackedReadoutId: () => 'installations:test',
overlayPaintRect(sourceId, entryId) {
paintRectCalls.push({ sourceId, entryId });
@@ -239,6 +276,7 @@ test('annotation fade consumes the actual tracked host paint rectangle after lay
},
},
);
t.after(() => renderer.destroy());
renderer.add({
id: 'anno-overlap',
type: 'label',
@@ -247,7 +285,8 @@ test('annotation fade consumes the actual tracked host paint rectangle after lay
alpha: 1,
anchor: { lon: 0, lat: 0, height: 0 },
});
const { group } = findAnnotationGroup(globalThis.document);
await settle();
const [group] = groups();
assert.deepEqual(paintRectCalls.at(-1), {
sourceId: 'tracked',
entryId: 'installations:test',
@@ -256,44 +295,59 @@ test('annotation fade consumes the actual tracked host paint rectangle after lay
Number(group.getAttribute('opacity')) < 1,
'final annotation bbox fades against the complete painted card rectangle',
);
});
test('a removed mark fades out, then leaves the board', async (t) => {
const { store, groups } = board(t);
projectTo(t, { x: 100, y: 100 });
const camera = cameraOver(0, 0);
const renderer = createScreenAnnotationRenderer(
{ scene: sceneFor(camera), camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
const anno = {
id: 'fading',
type: 'label',
color: 'primary',
label: 'Gone soon',
alpha: 1,
anchor: { lon: 0, lat: 0 },
};
renderer.add(anno);
await settle();
const [group] = groups();
renderer.remove(anno);
// A new mark for the same place gets its own group beside the fading one.
renderer.add(anno);
await settle();
assert.equal(group.getAttribute('class'), 'gev-anno gev-out');
assert.equal(groups().length, 2);
assert.ok(groups()[1] !== group);
await new Promise((resolve) => setTimeout(resolve, 400));
await settle();
assert.deepEqual(groups().length, 1);
assert.equal(group.isConnected, false);
renderer.destroy();
await settle();
assert.equal(document.querySelector('.gev-screen-whiteboard'), null);
});
// ── Partial-add unwind (review round 2) ───────────────────────────────────────
//
// add() inserts the group and records it, then does more live-document work
// (draw-on wiring, the first projection pass). A throw in that tail used to
// leave an ORPHANED <g> plus a record nobody owned — the engine's rollback only
// deletes its own map entry — so re-annotating the same place stacked a second
// mark over the corpse.
// add() records the mark, then does more work (presenting it, the first
// projection pass). A throw in that tail used to leave an ORPHANED mark plus a
// record nobody owned — the engine's rollback only deletes its own map entry —
// so re-annotating the same place stacked a second mark over the corpse.
test('an add that throws after inserting its group unwinds instead of orphaning it', (t) => {
const originalDocument = globalThis.document;
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
globalThis.document = fakeDocument();
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
t.after(() => {
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
});
const positionWC = Cesium.Cartesian3.fromDegrees(0, 0, 1000);
const directionWC = Cesium.Cartesian3.normalize(
Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()),
new Cesium.Cartesian3(),
test('an add that throws after inserting its group unwinds instead of orphaning it', async (t) => {
const { store, groups } = board(t);
const camera = cameraOver(0, 0);
const renderer = createScreenAnnotationRenderer(
{ scene: sceneFor(camera, { canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 } }), camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
const camera = { positionWC, directionWC, positionCartographic: { height: 1000 } };
const scene = {
camera,
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
clampToHeightSupported: false,
postRender: { addEventListener() {}, removeEventListener() {} },
};
const renderer = createScreenAnnotationRenderer({ scene, camera, trackedEntity: null });
t.after(() => renderer.destroy());
const anno = {
id: 'anno-partial-screen',
type: 'pin',
@@ -303,26 +357,36 @@ test('an add that throws after inserting its group unwinds instead of orphaning
anchor: { lon: 0, lat: 0, height: 0 },
};
// The group is in the document and recorded by the time add() reaches its
// first projection pass — so a throw there is real partial state, not a
// clean bail-out.
Cesium.SceneTransforms.worldToWindowCoordinates = () => { throw new Error('projection failed'); };
// The mark is on the board by the time add() reaches its first projection
// pass — so a throw there is real partial state, not a clean bail-out.
projectTo(t, () => {
throw new Error('projection failed');
});
assert.throws(() => renderer.add(anno), /projection failed/);
Cesium.SceneTransforms.worldToWindowCoordinates = () => ({ x: 0, y: 0 });
const { svg } = findAnnotationGroup(globalThis.document);
assert.equal(
svg.children.filter((child) => child.classList.contains('gev-anno')).length,
0,
'the half-built group must not stay in the document',
);
await settle();
assert.equal(groups().length, 0, 'the half-built group must not stay in the document');
// The id is free again: a retry draws exactly one mark, and remove() still
// owns it (a leaked record would leave a second group behind).
renderer.add(anno);
assert.equal(
svg.children.filter((child) => child.classList.contains('gev-anno')).length,
1,
'the retry must draw one mark, not stack over an orphan',
);
renderer.destroy();
await settle();
assert.equal(groups().length, 1, 'the retry must draw one mark, not stack over an orphan');
});
test("an earlier renderer leaves a later renderer's board alone", async (t) => {
const { store, groups } = board(t);
projectTo(t, { x: 100, y: 100 });
const camera = cameraOver(0, 0);
const viewer = { scene: sceneFor(camera), camera, trackedEntity: null };
const options = { store, activeTrackedReadoutId: () => null };
const earlier = createScreenAnnotationRenderer(viewer, options);
const later = createScreenAnnotationRenderer(viewer, options);
t.after(() => later.destroy());
later.add({ id: 'kept', type: 'label', label: 'Kept', alpha: 1, anchor: { lon: 0, lat: 0 } });
earlier.add({ id: 'ignored', type: 'label', label: 'Ignored', alpha: 1, anchor: { lon: 0, lat: 0 } });
earlier.destroy();
await settle();
assert.equal(groups().length, 1);
assert.equal(groups()[0].querySelector('text').textContent, 'Kept');
});
+38
View File
@@ -0,0 +1,38 @@
import { defineStore } from 'pinia';
import { markRaw, shallowRef } from 'vue';
/**
* The screen-space annotation board as its view renders it. The screen
* annotation renderer (src/annotations/screenAnnotationRenderer.js) presents
* every mark's SVG parts after each rendered frame; the view hands back the
* elements the renderer measures and reports when a draw-on stroke finishes.
*/
export const useScreenAnnotationsStore = defineStore(
'gev-screen-annotations',
() => {
/**
* `{owner, viewBox, markers, marks}`, or null while no renderer exists.
* Each mark is `{key, className, opacity, display, drawn, children}`;
* `owner` is passed back with each element and report.
*/
const board = shallowRef(null);
/** @param {?object} next A fresh presentation; every publish re-renders. */
function publish(next) {
board.value = next && markRaw(next);
}
/**
* The controls whose `attach(owner, key, kind, element)` and
* `drawn(owner, key)` the view calls.
*/
const controls = shallowRef(null);
/** @param {?object} next The controls, or null to disconnect. */
function connect(next) {
controls.value = next && markRaw(next);
}
return { board, publish, controls, connect };
},
);
+25
View File
@@ -0,0 +1,25 @@
/* ── Screen-space annotations (src/annotations/screenAnnotationRenderer.js) ── */
.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; } }
+2
View File
@@ -12,6 +12,7 @@ import SceneInteractions from './panels/SceneInteractions.vue';
import SplitFlapText from './panels/SplitFlapText.vue';
import WindInspectionMarker from './panels/WindInspectionMarker.vue';
import CelestialRing from './panels/CelestialRing.vue';
import ScreenAnnotations from './panels/ScreenAnnotations.vue';
// The ACTIVE STYLE corner shows the style VisualSettings publishes.
const { style } = storeToRefs(useDisplayStore());
@@ -106,4 +107,5 @@ const vToastText = {
<SceneInteractions />
<WindInspectionMarker />
<MissionReplayOverlay />
<ScreenAnnotations />
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup>
import { storeToRefs } from 'pinia';
import { useScreenAnnotationsStore } from '../../stores/screenAnnotations.js';
// The screen-space annotation board over the page, as the screen annotation
// renderer (src/annotations/screenAnnotationRenderer.js) presents it.
const { board, controls } = storeToRefs(useScreenAnnotationsStore());
// Each element goes to the renderer it was rendered for, which measures it.
const vAnnoElement = {
mounted(el, { value }) {
controls.value?.attach(value.owner, value.key, value.kind, el);
},
beforeUnmount(el, { value }) {
controls.value?.attach(value.owner, value.key, value.kind, null);
},
};
// The draw-on stroke reads solid once its dashoffset transition ends.
const onTransitionEnd = (owner, key) => (event) => {
if (event.propertyName === 'stroke-dashoffset')
controls.value?.drawn(owner, key);
};
const display = (value) => (value ? { display: value } : undefined);
const drawStyle = (mark, child) =>
mark.drawn && child.attrs.class?.includes('gev-draw')
? { strokeDasharray: 'none', ...display(child.display) }
: display(child.display);
</script>
<template>
<Teleport v-if="board" to="body">
<div class="gev-screen-whiteboard"><svg class="gev-screen-whiteboard-svg" preserveAspectRatio="none" :viewBox="board.viewBox ?? undefined"><defs><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><marker v-for="marker in board.markers" :id="`gev-arrow-${marker.key}`" :key="marker.key" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" :fill="marker.fill" /></marker></defs><g v-for="mark in board.marks" :key="mark.key" v-anno-element="{ owner: board.owner, key: mark.key, kind: 'group' }" :class="mark.className" :opacity="mark.opacity" :style="display(mark.display)" :onTransitionend="onTransitionEnd(board.owner, mark.key)"><template v-for="child in mark.children" :key="child.key"><polygon v-if="child.tag === 'polygon'" v-bind="child.attrs" :style="drawStyle(mark, child)" /><polyline v-else-if="child.tag === 'polyline'" v-bind="child.attrs" :style="drawStyle(mark, child)" /><path v-else-if="child.tag === 'path'" v-bind="child.attrs" :style="drawStyle(mark, child)" /><circle v-else-if="child.tag === 'circle'" v-bind="child.attrs" :style="display(child.display)" /><line v-else-if="child.tag === 'line'" v-bind="child.attrs" :style="display(child.display)" /><g v-else-if="child.tag === 'callout'" class="gev-anno-callout" :transform="child.transform ?? undefined"><rect rx="5" class="gev-anno-card" v-bind="child.card" /><rect class="gev-anno-accent" width="3" rx="1.5" :fill="child.fill" v-bind="child.accent" /><text v-anno-element="{ owner: board.owner, key: mark.key, kind: 'text' }" class="gev-anno-text" x="9" y="16">{{ child.text }}</text></g></template></g></svg></div>
</Teleport>
</template>