mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
The screen annotation renderer keeps each mark's SVG parts as attribute records and presents the board to a store after every rendered frame; a view teleports it to the page and hands back the callout text and mark groups the renderer measures. The tracked-entity fade is measured once the frame is in the document, removed marks fade out before they leave, and the styles it injected move to annotations.css.
393 lines
15 KiB
JavaScript
393 lines
15 KiB
JavaScript
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';
|
|
|
|
// 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 });
|
|
}
|
|
|
|
const settle = async () => {
|
|
for (let flush = 0; flush < 4; flush += 1) await nextTick();
|
|
};
|
|
|
|
/** 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.requestAnimationFrame = (callback) => queued.push(callback);
|
|
t.after(() => {
|
|
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 = sceneFor(camera, {
|
|
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
|
|
clampToHeightSupported: true,
|
|
clampToHeight(world) {
|
|
const cartographic = Cesium.Cartographic.fromCartesian(world);
|
|
sampledAnchors.push([
|
|
Number(Cesium.Math.toDegrees(cartographic.longitude).toFixed(4)),
|
|
Number(Cesium.Math.toDegrees(cartographic.latitude).toFixed(4)),
|
|
]);
|
|
return world;
|
|
},
|
|
});
|
|
const renderer = createScreenAnnotationRenderer(
|
|
{ scene, camera, trackedEntity: null },
|
|
{ store, activeTrackedReadoutId: () => null },
|
|
);
|
|
t.after(() => renderer.destroy());
|
|
const anno = {
|
|
id: 'anno-fb3',
|
|
type: 'area',
|
|
color: 'primary',
|
|
label: 'Texas',
|
|
alpha: 1,
|
|
anchor: { lon: -99, lat: 31, height: 0 },
|
|
ring: null,
|
|
};
|
|
renderer.add(anno);
|
|
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',
|
|
ring: null,
|
|
anchor: { lon: -97.5, lat: 31.2, height: 0 },
|
|
});
|
|
renderer.update(labelProxy);
|
|
await settle();
|
|
const [after] = groups();
|
|
|
|
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(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('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: sceneFor(camera), camera, trackedEntity: null },
|
|
{
|
|
store,
|
|
activeTrackedReadoutId: () => 'installations:test',
|
|
overlayPaintRect(sourceId, entryId) {
|
|
paintRectCalls.push({ sourceId, entryId });
|
|
return { x: -10, y: -10, w: 120, h: 80 };
|
|
},
|
|
},
|
|
);
|
|
t.after(() => renderer.destroy());
|
|
renderer.add({
|
|
id: 'anno-overlap',
|
|
type: 'label',
|
|
color: 'primary',
|
|
label: 'OVERLAP',
|
|
alpha: 1,
|
|
anchor: { lon: 0, lat: 0, height: 0 },
|
|
});
|
|
await settle();
|
|
const [group] = groups();
|
|
assert.deepEqual(paintRectCalls.at(-1), {
|
|
sourceId: 'tracked',
|
|
entryId: 'installations:test',
|
|
});
|
|
assert.ok(
|
|
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() 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', 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 },
|
|
);
|
|
t.after(() => renderer.destroy());
|
|
const anno = {
|
|
id: 'anno-partial-screen',
|
|
type: 'pin',
|
|
color: 'primary',
|
|
label: 'Capitol',
|
|
alpha: 1,
|
|
anchor: { lon: 0, lat: 0, height: 0 },
|
|
};
|
|
|
|
// 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 });
|
|
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);
|
|
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');
|
|
});
|