mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
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:
1 parent
265aa605bd
commit
662f51fdf3
13 files changed
+768
-672
No files matched your search
+2
-2
@@ -210,8 +210,8 @@ such as `provider-settings`, and their hosts are removed. Leaving out
|
|||||||
out `scene-chrome` the before/after imagery dividers (the imagery still splits,
|
out `scene-chrome` the before/after imagery dividers (the imagery still splits,
|
||||||
with no line or handle to move it), the status chips, the toast, the ORBIT
|
with no line or handle to move it), the status chips, the toast, the ORBIT
|
||||||
indicator, the FPS readout, the Scene actions panel, the data-pack attribution
|
indicator, the FPS readout, the Scene actions panel, the data-pack attribution
|
||||||
cards, the wind inspection marker, the Space Missions replay vehicle and
|
cards, the wind inspection marker, the Space Missions replay vehicle, the
|
||||||
the full-globe celestial ring;
|
full-globe celestial ring and the screen-space map annotations;
|
||||||
leaving out `command-dock` the voice control and its pointer reticle; and
|
leaving out `command-dock` the voice control and its pointer reticle; and
|
||||||
leaving out `context` the Bhote Koshi reconstruction panel. An unknown
|
leaving out `context` the Bhote Koshi reconstruction panel. An unknown
|
||||||
view name is an error. A host that does not initialize Core's locale passes
|
view name is an error. A host that does not initialize Core's locale passes
|
||||||
|
|||||||
@@ -177,7 +177,13 @@ a feature supplying only its accent.
|
|||||||
in `src/celestialRing.js` publishes to the `gev-celestial-ring` store and
|
in `src/celestialRing.js` publishes to the `gev-celestial-ring` store and
|
||||||
hands its two canvases back for the owner to paint; the application shell
|
hands its two canvases back for the owner to paint; the application shell
|
||||||
passes the store in, so the keyhole helpers other packages import need no
|
passes the store in, so the keyhole helpers other packages import need no
|
||||||
Vue), then
|
Vue), the screen-space map annotations (`ScreenAnnotations` teleports to
|
||||||
|
the body the board the screen annotation renderer publishes to the
|
||||||
|
`gev-screen-annotations` store after each rendered frame: each mark's SVG
|
||||||
|
parts as attribute records, the fading marks until they leave; the view
|
||||||
|
hands back the callout text and mark groups the renderer measures, and
|
||||||
|
the tracked-entity fade is measured once the frame is in the document;
|
||||||
|
the styles it injected now live in `src/ui/styles/annotations.css`), then
|
||||||
Cockpit (in progress: `CockpitBriefing` renders the briefing card's
|
Cockpit (in progress: `CockpitBriefing` renders the briefing card's
|
||||||
pages, cycle toggle, regional news and local conditions, and
|
pages, cycle toggle, regional news and local conditions, and
|
||||||
`CockpitContext` the Contact card's scope, navigation, counts, nearest
|
`CockpitContext` the Contact card's scope, navigation, counts, nearest
|
||||||
|
|||||||
@@ -1148,5 +1148,6 @@
|
|||||||
"src/ui/stores/bhoteKoshiPanel.js",
|
"src/ui/stores/bhoteKoshiPanel.js",
|
||||||
"src/data/bhoteKoshiPanelView.test.mjs",
|
"src/data/bhoteKoshiPanelView.test.mjs",
|
||||||
"src/ui/stores/celestialRing.js",
|
"src/ui/stores/celestialRing.js",
|
||||||
"src/celestialRingView.test.mjs"
|
"src/celestialRingView.test.mjs",
|
||||||
|
"src/ui/stores/screenAnnotations.js"
|
||||||
]
|
]
|
||||||
@@ -110,7 +110,9 @@
|
|||||||
"src/ui/stores/bhoteKoshiPanel.js",
|
"src/ui/stores/bhoteKoshiPanel.js",
|
||||||
"src/ui/views/panels/BhoteKoshiPanel.vue",
|
"src/ui/views/panels/BhoteKoshiPanel.vue",
|
||||||
"src/ui/stores/celestialRing.js",
|
"src/ui/stores/celestialRing.js",
|
||||||
"src/ui/views/panels/CelestialRing.vue"
|
"src/ui/views/panels/CelestialRing.vue",
|
||||||
|
"src/ui/stores/screenAnnotations.js",
|
||||||
|
"src/ui/views/panels/ScreenAnnotations.vue"
|
||||||
],
|
],
|
||||||
"external": ["vue", "pinia"]
|
"external": ["vue", "pinia"]
|
||||||
},
|
},
|
||||||
@@ -3030,7 +3032,8 @@
|
|||||||
"src/layers/wind/inspectionMarker.js",
|
"src/layers/wind/inspectionMarker.js",
|
||||||
"src/ui/stores/missionReplay.js",
|
"src/ui/stores/missionReplay.js",
|
||||||
"src/ui/stores/bhoteKoshiPanel.js",
|
"src/ui/stores/bhoteKoshiPanel.js",
|
||||||
"src/ui/stores/celestialRing.js"
|
"src/ui/stores/celestialRing.js",
|
||||||
|
"src/ui/stores/screenAnnotations.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
|
|||||||
@@ -16,11 +16,15 @@ import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
|
|||||||
* label-less) so every caption shares one consistent style.
|
* label-less) so every caption shares one consistent style.
|
||||||
*
|
*
|
||||||
* The hybrid surface honors add/update/remove/sync/destroy; update coordinates the
|
* 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 world = createWorldAnnotationRenderer(viewer);
|
||||||
const screen = createScreenAnnotationRenderer(viewer);
|
const screen = createScreenAnnotationRenderer(viewer, screenOptions);
|
||||||
// anno.id -> { worldProxy?, screenProxy? } — proxies own the sub-renderer state.
|
// anno.id -> { worldProxy?, screenProxy? } — proxies own the sub-renderer state.
|
||||||
const routed = new Map();
|
const routed = new Map();
|
||||||
|
|
||||||
|
|||||||
@@ -1,143 +1,24 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { createPinia } from 'pinia';
|
||||||
|
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
|
||||||
import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
|
import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
|
||||||
|
|
||||||
class FakeClassList {
|
/** A hybrid renderer whose screen board is presented to its own store. */
|
||||||
constructor(owner) {
|
function hybridWithBoard(viewer) {
|
||||||
this.owner = owner;
|
const store = useScreenAnnotationsStore(createPinia());
|
||||||
this.names = new Set();
|
const renderer = createHybridAnnotationRenderer(viewer, { screen: { store } });
|
||||||
}
|
return { store, renderer };
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeElement {
|
/** The screen marks the board shows, fading ones included. */
|
||||||
constructor(tagName) {
|
function annotationGroups(store) {
|
||||||
this.tagName = tagName;
|
return store.board?.marks ?? [];
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeDocument() {
|
const childrenWithClass = (mark, name) =>
|
||||||
const head = new FakeElement('head');
|
mark.children.filter((child) => child.attrs?.class?.split(' ').includes(name));
|
||||||
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')),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function cameraLookingAt(lon, lat) {
|
function cameraLookingAt(lon, lat) {
|
||||||
const target = Cesium.Cartesian3.fromDegrees(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. */
|
/** Install the browser globals both renderers touch, restored after the test. */
|
||||||
function installBrowserGlobals(t) {
|
function installBrowserGlobals(t) {
|
||||||
const originalDocument = globalThis.document;
|
|
||||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||||
const originalWindow = globalThis.window;
|
const originalWindow = globalThis.window;
|
||||||
const originalBrowserConstructors = new Map([
|
const originalBrowserConstructors = new Map([
|
||||||
@@ -193,13 +73,10 @@ function installBrowserGlobals(t) {
|
|||||||
['ImageBitmap', globalThis.ImageBitmap],
|
['ImageBitmap', globalThis.ImageBitmap],
|
||||||
['OffscreenCanvas', globalThis.OffscreenCanvas],
|
['OffscreenCanvas', globalThis.OffscreenCanvas],
|
||||||
]);
|
]);
|
||||||
globalThis.document = fakeDocument();
|
|
||||||
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
||||||
globalThis.window = { setTimeout: (fn) => { fn(); return 1; } };
|
globalThis.window = { setTimeout: (fn) => { fn(); return 1; } };
|
||||||
for (const name of originalBrowserConstructors.keys()) globalThis[name] = class {};
|
for (const name of originalBrowserConstructors.keys()) globalThis[name] = class {};
|
||||||
t.after(() => {
|
t.after(() => {
|
||||||
if (originalDocument === undefined) delete globalThis.document;
|
|
||||||
else globalThis.document = originalDocument;
|
|
||||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
||||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||||
if (originalWindow === undefined) delete globalThis.window;
|
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) {
|
function naiveRingCentroid(ring) {
|
||||||
const totals = ring.reduce(
|
const totals = ring.reduce(
|
||||||
@@ -257,7 +131,6 @@ function naiveRingCentroid(ring) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test('hybrid outline upgrade preserves the screen group and adds world geometry', (t) => {
|
test('hybrid outline upgrade preserves the screen group and adds world geometry', (t) => {
|
||||||
const originalDocument = globalThis.document;
|
|
||||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||||
const originalBrowserConstructors = new Map([
|
const originalBrowserConstructors = new Map([
|
||||||
['HTMLCanvasElement', globalThis.HTMLCanvasElement],
|
['HTMLCanvasElement', globalThis.HTMLCanvasElement],
|
||||||
@@ -265,12 +138,9 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
|
|||||||
['ImageBitmap', globalThis.ImageBitmap],
|
['ImageBitmap', globalThis.ImageBitmap],
|
||||||
['OffscreenCanvas', globalThis.OffscreenCanvas],
|
['OffscreenCanvas', globalThis.OffscreenCanvas],
|
||||||
]);
|
]);
|
||||||
globalThis.document = fakeDocument();
|
|
||||||
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
||||||
for (const name of originalBrowserConstructors.keys()) globalThis[name] = class {};
|
for (const name of originalBrowserConstructors.keys()) globalThis[name] = class {};
|
||||||
t.after(() => {
|
t.after(() => {
|
||||||
if (originalDocument === undefined) delete globalThis.document;
|
|
||||||
else globalThis.document = originalDocument;
|
|
||||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
||||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||||
for (const [name, original] of originalBrowserConstructors) {
|
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 = {
|
const anno = {
|
||||||
id: 'anno-hybrid-fb3',
|
id: 'anno-hybrid-fb3',
|
||||||
type: 'area',
|
type: 'area',
|
||||||
@@ -319,10 +189,8 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
|
|||||||
};
|
};
|
||||||
|
|
||||||
renderer.add(anno);
|
renderer.add(anno);
|
||||||
const before = findAnnotationGroup(globalThis.document);
|
const [before] = annotationGroups(store);
|
||||||
const originalCallout = before.group.querySelector('.gev-anno-callout');
|
assert.equal(childrenWithClass(before, 'gev-anno-ring').length, 2);
|
||||||
const originalDot = before.group.querySelector('.gev-anno-dot');
|
|
||||||
assert.equal(before.group.querySelectorAll('.gev-anno-ring').length, 2);
|
|
||||||
assert.equal(dataSources.length, 1);
|
assert.equal(dataSources.length, 1);
|
||||||
assert.equal(dataSources[0].entities.values.length, 0);
|
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';
|
anno.footprintKind = 'area';
|
||||||
renderer.update(anno);
|
renderer.update(anno);
|
||||||
|
|
||||||
const after = findAnnotationGroup(globalThis.document);
|
const [after, ...others] = annotationGroups(store);
|
||||||
const expectedWindow = Cesium.SceneTransforms.worldToWindowCoordinates(
|
const expectedWindow = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||||
scene,
|
scene,
|
||||||
Cesium.Cartesian3.fromDegrees(centroid.lon, centroid.lat, centroid.height),
|
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.key, before.key, 'the real hybrid keeps the existing screen mark');
|
||||||
assert.equal(after.svg.children.filter((child) => child.classList.contains('gev-anno')).length, 1);
|
assert.equal(others.length, 0);
|
||||||
assert.equal(after.group.querySelectorAll('.gev-anno-ring').length, 0, 'screen reticle rings are removed');
|
assert.equal(childrenWithClass(after, '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.deepEqual(
|
||||||
assert.equal(after.group.querySelector('.gev-anno-dot'), originalDot, 'the anchor dot is retained');
|
after.children.map((child) => child.key),
|
||||||
assert.equal(originalDot.getAttribute('cx'), expectedWindow.x.toFixed(1));
|
['dot', 'leader', 'callout'],
|
||||||
assert.equal(originalDot.getAttribute('cy'), expectedWindow.y.toFixed(1));
|
'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.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.length, 2, 'world area adds one fill and one outline');
|
||||||
assert.equal(dataSources[0].entities.values.filter((entity) => entity.polygon).length, 1);
|
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) => {
|
test('a sub-renderer throw mid-add leaves state the rollback can still remove', (t) => {
|
||||||
installBrowserGlobals(t);
|
installBrowserGlobals(t);
|
||||||
const { viewer, dataSources } = fakeViewer(-99, 31);
|
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 ring = [[-106, 25], [-93, 25], [-93, 36], [-106, 36], [-106, 25]];
|
||||||
const anno = {
|
const anno = {
|
||||||
id: 'anno-partial-add',
|
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
|
// 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.
|
// context does, AFTER the world geometry is already on the board.
|
||||||
const { svg } = findAnnotationGroup(globalThis.document);
|
const publish = store.publish;
|
||||||
const appendChild = svg.appendChild.bind(svg);
|
|
||||||
let failNextInsert = true;
|
let failNextInsert = true;
|
||||||
svg.appendChild = (child) => {
|
store.publish = (board) => {
|
||||||
if (failNextInsert) {
|
if (failNextInsert) {
|
||||||
failNextInsert = false;
|
failNextInsert = false;
|
||||||
throw new Error('screen insert failed');
|
throw new Error('screen insert failed');
|
||||||
}
|
}
|
||||||
return appendChild(child);
|
return publish(board);
|
||||||
};
|
};
|
||||||
|
|
||||||
assert.throws(() => renderer.add(anno), /screen insert failed/);
|
assert.throws(() => renderer.add(anno), /screen insert failed/);
|
||||||
assert.equal(dataSources[0].entities.values.length, 2, 'the world drape is already live');
|
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.
|
// The engine's rollback path — it must reach the partial state by id.
|
||||||
renderer.remove(anno);
|
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.
|
// an orphan nothing owns.
|
||||||
renderer.add(anno);
|
renderer.add(anno);
|
||||||
assert.equal(dataSources[0].entities.values.length, 2, 'the retry draws exactly one world drape');
|
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);
|
renderer.remove(anno);
|
||||||
assert.equal(dataSources[0].entities.values.length, 0);
|
assert.equal(dataSources[0].entities.values.length, 0);
|
||||||
renderer.destroy();
|
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) => {
|
test('a release that throws keeps the mark addressable for a retry cleanup', (t) => {
|
||||||
installBrowserGlobals(t);
|
installBrowserGlobals(t);
|
||||||
const { viewer, dataSources } = fakeViewer(-99, 31);
|
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 ring = [[-106, 25], [-93, 25], [-93, 36], [-106, 36], [-106, 25]];
|
||||||
const anno = {
|
const anno = {
|
||||||
id: 'anno-release-throw',
|
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);
|
renderer.add(anno);
|
||||||
const { svg } = findAnnotationGroup(globalThis.document);
|
|
||||||
assert.equal(dataSources[0].entities.values.length, 2);
|
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
|
// 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;
|
const workingSetTimeout = globalThis.window.setTimeout;
|
||||||
globalThis.window.setTimeout = () => { throw new Error('teardown scheduling failed'); };
|
globalThis.window.setTimeout = () => { throw new Error('teardown scheduling failed'); };
|
||||||
assert.throws(() => renderer.remove(anno), /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(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
|
// Dropping the routing entry here would make that orphan permanently
|
||||||
// unreachable. It must still be addressable, so a retry finishes the job.
|
// unreachable. It must still be addressable, so a retry finishes the job.
|
||||||
globalThis.window.setTimeout = workingSetTimeout;
|
globalThis.window.setTimeout = workingSetTimeout;
|
||||||
renderer.remove(anno);
|
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');
|
assert.equal(dataSources[0].entities.values.length, 0, 'without double-releasing the world route');
|
||||||
|
|
||||||
// Fully released now — a third call is inert.
|
// Fully released now — a third call is inert.
|
||||||
renderer.remove(anno);
|
renderer.remove(anno);
|
||||||
assert.equal(annotationGroups(svg).length, 0);
|
assert.equal(annotationGroups(store).length, 0);
|
||||||
renderer.destroy();
|
renderer.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a world add that fails mid-way leaves its landed entities removable', (t) => {
|
test('a world add that fails mid-way leaves its landed entities removable', (t) => {
|
||||||
installBrowserGlobals(t);
|
installBrowserGlobals(t);
|
||||||
const { viewer, dataSources } = fakeViewer(-99, 31);
|
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 ring = [[-106, 25], [-93, 25], [-93, 36], [-106, 36], [-106, 25]];
|
||||||
const anno = {
|
const anno = {
|
||||||
id: 'anno-partial-world-add',
|
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,
|
assert.equal(entities.values.length, 0,
|
||||||
'the rollback must see the entities that landed before the throw');
|
'the rollback must see the entities that landed before the throw');
|
||||||
|
|
||||||
const { svg } = findAnnotationGroup(globalThis.document);
|
|
||||||
renderer.add(anno);
|
renderer.add(anno);
|
||||||
assert.equal(entities.values.length, 2, 'the retry draws a complete mark');
|
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();
|
renderer.destroy();
|
||||||
});
|
});
|
||||||
@@ -5,6 +5,9 @@ import {
|
|||||||
TRACKED_OVERLAY_SOURCE_ID,
|
TRACKED_OVERLAY_SOURCE_ID,
|
||||||
} from '../data/trackedReadout.js';
|
} from '../data/trackedReadout.js';
|
||||||
import { t } from '../i18n/index.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. */
|
/** A label whose detailed outline could not be resolved. */
|
||||||
function outlineUnavailableLabel(label) {
|
function outlineUnavailableLabel(label) {
|
||||||
@@ -27,11 +30,17 @@ function outlineUnavailableLabel(label) {
|
|||||||
* Marks behind the globe horizon are culled with an EllipsoidalOccluder, and
|
* Marks behind the globe horizon are culled with an EllipsoidalOccluder, and
|
||||||
* anything that projects off-screen / behind the camera is hidden.
|
* 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):
|
* Screen-renderer contract (update is used by the hybrid outline-upgrade path):
|
||||||
* add(anno) / update(anno) / remove(anno) / sync(map) / destroy()
|
* 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 = {
|
const PALETTE = {
|
||||||
primary: '#8be9ff',
|
primary: '#8be9ff',
|
||||||
amber: '#ffb547',
|
amber: '#ffb547',
|
||||||
@@ -62,18 +71,26 @@ export function createScreenAnnotationRenderer(
|
|||||||
{
|
{
|
||||||
overlayPaintRect = getOverlayPaintRect,
|
overlayPaintRect = getOverlayPaintRect,
|
||||||
activeTrackedReadoutId = getActiveTrackedReadoutId,
|
activeTrackedReadoutId = getActiveTrackedReadoutId,
|
||||||
|
store = useScreenAnnotationsStore(applicationPinia()),
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
injectStyles();
|
// The arrowhead markers the board's defs hold, in the order first used.
|
||||||
const { layer, svg, defs } = buildOverlay();
|
const arrowMarkers = new Map();
|
||||||
document.body.appendChild(layer);
|
let viewBox = null;
|
||||||
|
|
||||||
const scene = viewer.scene;
|
const scene = viewer.scene;
|
||||||
const occluder = new Cesium.EllipsoidalOccluder(
|
const occluder = new Cesium.EllipsoidalOccluder(
|
||||||
Cesium.Ellipsoid.WGS84,
|
Cesium.Ellipsoid.WGS84,
|
||||||
scene.camera.positionWC,
|
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 scratch = new Cesium.Cartesian2();
|
||||||
const scratchDir = new Cesium.Cartesian3();
|
const scratchDir = new Cesium.Cartesian3();
|
||||||
// Per-point ground height cache. Marks must sit on the real surface, not at
|
// Per-point ground height cache. Marks must sit on the real surface, not at
|
||||||
@@ -151,13 +168,13 @@ export function createScreenAnnotationRenderer(
|
|||||||
|
|
||||||
function add(anno) {
|
function add(anno) {
|
||||||
const c = color(anno);
|
const c = color(anno);
|
||||||
const group = svgEl('g', { class: 'gev-anno', opacity: '0' });
|
const group = { opacity: '0', display: '', entered: false };
|
||||||
const parts = {};
|
const parts = {};
|
||||||
|
|
||||||
if (anno.type === 'area' && anno.ring && anno.ring.length >= 3) {
|
if (anno.type === 'area' && anno.ring && anno.ring.length >= 3) {
|
||||||
// Synthesized (buffered/approximate) areas render DASHED + fainter, with no
|
// Synthesized (buffered/approximate) areas render DASHED + fainter, with no
|
||||||
// "draw-on" gesture, so they never masquerade as an authoritative boundary.
|
// "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',
|
class: anno.synthesized ? 'gev-anno-area' : 'gev-anno-area gev-draw',
|
||||||
fill: c,
|
fill: c,
|
||||||
stroke: c,
|
stroke: c,
|
||||||
@@ -166,16 +183,14 @@ export function createScreenAnnotationRenderer(
|
|||||||
...(anno.synthesized ? { 'stroke-dasharray': '9 7' } : {}),
|
...(anno.synthesized ? { 'stroke-dasharray': '9 7' } : {}),
|
||||||
filter: 'url(#gev-sketch)',
|
filter: 'url(#gev-sketch)',
|
||||||
});
|
});
|
||||||
group.appendChild(parts.poly);
|
|
||||||
parts.label = makeCallout(
|
parts.label = makeCallout(
|
||||||
anno.outlineUnavailable
|
anno.outlineUnavailable
|
||||||
? outlineUnavailableLabel(anno.label)
|
? outlineUnavailableLabel(anno.label)
|
||||||
: anno.label,
|
: anno.label,
|
||||||
c,
|
c,
|
||||||
);
|
);
|
||||||
if (parts.label) group.appendChild(parts.label.node);
|
|
||||||
} else if (anno.type === 'arrow' && anno.to) {
|
} else if (anno.type === 'arrow' && anno.to) {
|
||||||
parts.path = svgEl('path', {
|
parts.path = svgPart('path', {
|
||||||
class: 'gev-anno-arrow gev-draw',
|
class: 'gev-anno-arrow gev-draw',
|
||||||
fill: 'none',
|
fill: 'none',
|
||||||
stroke: c,
|
stroke: c,
|
||||||
@@ -183,22 +198,20 @@ export function createScreenAnnotationRenderer(
|
|||||||
'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
|
'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
|
||||||
filter: 'url(#gev-sketch)',
|
filter: 'url(#gev-sketch)',
|
||||||
});
|
});
|
||||||
ensureArrowMarker(defs, anno.color || 'primary', c);
|
ensureArrowMarker(arrowMarkers, anno.color || 'primary', c);
|
||||||
group.appendChild(parts.path);
|
|
||||||
parts.label = makeCallout(
|
parts.label = makeCallout(
|
||||||
anno.outlineUnavailable
|
anno.outlineUnavailable
|
||||||
? outlineUnavailableLabel(anno.label)
|
? outlineUnavailableLabel(anno.label)
|
||||||
: anno.label,
|
: anno.label,
|
||||||
c,
|
c,
|
||||||
);
|
);
|
||||||
if (parts.label) group.appendChild(parts.label.node);
|
|
||||||
} else if (
|
} else if (
|
||||||
anno.type === 'route' &&
|
anno.type === 'route' &&
|
||||||
Array.isArray(anno.path) &&
|
Array.isArray(anno.path) &&
|
||||||
anno.path.length >= 2
|
anno.path.length >= 2
|
||||||
) {
|
) {
|
||||||
// Multi-waypoint path: a drawn-on polyline with a dot at each waypoint.
|
// 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',
|
class: 'gev-anno-arrow gev-draw',
|
||||||
fill: 'none',
|
fill: 'none',
|
||||||
stroke: c,
|
stroke: c,
|
||||||
@@ -206,37 +219,33 @@ export function createScreenAnnotationRenderer(
|
|||||||
'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
|
'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
|
||||||
filter: 'url(#gev-sketch)',
|
filter: 'url(#gev-sketch)',
|
||||||
});
|
});
|
||||||
ensureArrowMarker(defs, anno.color || 'primary', c);
|
ensureArrowMarker(arrowMarkers, anno.color || 'primary', c);
|
||||||
group.appendChild(parts.poly);
|
parts.dots = anno.path.map(() =>
|
||||||
parts.dots = anno.path.map(() => {
|
svgPart('circle', {
|
||||||
const dot = svgEl('circle', {
|
|
||||||
class: 'gev-anno-dot',
|
class: 'gev-anno-dot',
|
||||||
fill: c,
|
fill: c,
|
||||||
stroke: '#06121c',
|
stroke: '#06121c',
|
||||||
'stroke-width': '2',
|
'stroke-width': '2',
|
||||||
r: '4',
|
r: '4',
|
||||||
});
|
}),
|
||||||
group.appendChild(dot);
|
);
|
||||||
return dot;
|
|
||||||
});
|
|
||||||
parts.label = makeCallout(
|
parts.label = makeCallout(
|
||||||
anno.outlineUnavailable
|
anno.outlineUnavailable
|
||||||
? outlineUnavailableLabel(anno.label)
|
? outlineUnavailableLabel(anno.label)
|
||||||
: anno.label,
|
: anno.label,
|
||||||
c,
|
c,
|
||||||
);
|
);
|
||||||
if (parts.label) group.appendChild(parts.label.node);
|
|
||||||
} else {
|
} else {
|
||||||
// pin / highlight / label — pulsing target rings + a marker dot + callout
|
// pin / highlight / label — pulsing target rings + a marker dot + callout
|
||||||
if (anno.type !== 'label') {
|
if (anno.type !== 'label') {
|
||||||
parts.ringOuter = svgEl('circle', {
|
parts.ringOuter = svgPart('circle', {
|
||||||
class: 'gev-anno-ring gev-pulse',
|
class: 'gev-anno-ring gev-pulse',
|
||||||
fill: 'none',
|
fill: 'none',
|
||||||
stroke: c,
|
stroke: c,
|
||||||
'stroke-width': '2',
|
'stroke-width': '2',
|
||||||
r: '34',
|
r: '34',
|
||||||
});
|
});
|
||||||
parts.ringInner = svgEl('circle', {
|
parts.ringInner = svgPart('circle', {
|
||||||
class: 'gev-anno-ring',
|
class: 'gev-anno-ring',
|
||||||
fill: c,
|
fill: c,
|
||||||
'fill-opacity': '0.12',
|
'fill-opacity': '0.12',
|
||||||
@@ -245,70 +254,57 @@ export function createScreenAnnotationRenderer(
|
|||||||
r: '18',
|
r: '18',
|
||||||
filter: 'url(#gev-sketch)',
|
filter: 'url(#gev-sketch)',
|
||||||
});
|
});
|
||||||
group.appendChild(parts.ringOuter);
|
|
||||||
group.appendChild(parts.ringInner);
|
|
||||||
}
|
}
|
||||||
parts.dot = svgEl('circle', {
|
parts.dot = svgPart('circle', {
|
||||||
class: 'gev-anno-dot',
|
class: 'gev-anno-dot',
|
||||||
fill: c,
|
fill: c,
|
||||||
stroke: '#06121c',
|
stroke: '#06121c',
|
||||||
'stroke-width': '2',
|
'stroke-width': '2',
|
||||||
r: anno.type === 'label' ? '4' : '5',
|
r: anno.type === 'label' ? '4' : '5',
|
||||||
});
|
});
|
||||||
group.appendChild(parts.dot);
|
parts.leader = svgPart('line', {
|
||||||
parts.leader = svgEl('line', {
|
|
||||||
class: 'gev-anno-leader',
|
class: 'gev-anno-leader',
|
||||||
stroke: c,
|
stroke: c,
|
||||||
'stroke-width': '1.5',
|
'stroke-width': '1.5',
|
||||||
'stroke-opacity': '0.7',
|
'stroke-opacity': '0.7',
|
||||||
});
|
});
|
||||||
group.appendChild(parts.leader);
|
|
||||||
parts.label = makeCallout(
|
parts.label = makeCallout(
|
||||||
anno.outlineUnavailable
|
anno.outlineUnavailable
|
||||||
? outlineUnavailableLabel(anno.label)
|
? outlineUnavailableLabel(anno.label)
|
||||||
: anno.label,
|
: anno.label,
|
||||||
c,
|
c,
|
||||||
);
|
);
|
||||||
if (parts.label) group.appendChild(parts.label.node);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record the mark BEFORE it touches the live document, then unwind on any
|
// Record the mark BEFORE it reaches the board, then unwind on any throw
|
||||||
// throw from here down (projectAll reaches into Cesium and can fail on a
|
// from here down (projectAll reaches into Cesium and can fail on a lost
|
||||||
// lost context). Previously a throw past the DOM insert left an ORPHANED
|
// context). Previously a throw past the insert left an ORPHANED mark: the
|
||||||
// <g>: the engine's rollback only deletes its own map entry, so the next
|
// engine's rollback only deletes its own map entry, so the next annotate
|
||||||
// annotate of the same geometry stacked a fresh mark on top of the corpse.
|
// of the same geometry stacked a fresh mark on top of the corpse.
|
||||||
records.set(anno.id, { anno, group, parts });
|
const rec = { key: ++marks, anno, group, parts, drawn: false };
|
||||||
|
records.set(anno.id, rec);
|
||||||
|
byKey.set(rec.key, rec);
|
||||||
try {
|
try {
|
||||||
svg.appendChild(group);
|
publish({ force: true });
|
||||||
// trigger draw-on / fade-in on the next frame
|
// Trigger draw-on / fade-in on the next frame, once the view has the
|
||||||
requestAnimationFrame(() => group.classList.add('gev-in'));
|
// mark in the document. The draw-on effect uses a fixed
|
||||||
// The draw-on effect uses a fixed stroke-dasharray of 1400 (see CSS). Any
|
// stroke-dasharray of 1400 (see CSS); the view reports the end of the
|
||||||
// stroke longer than 1400px (a long arrow/route on a wide window) would keep
|
// dashoffset transition, after which the stroke drops its dasharray so
|
||||||
// a permanent dash gap — the arrowhead detaches from the truncated line (H6).
|
// a stroke longer than 1400px reads solid (H6).
|
||||||
// Once the dashoffset transition finishes the stroke is meant to be fully
|
requestAnimationFrame(() => {
|
||||||
// drawn, so drop the dasharray entirely then and the stroke reads solid at
|
group.entered = true;
|
||||||
// any length. Guarded on the specific property so an unrelated transition
|
publish();
|
||||||
// (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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
projectAll();
|
projectAll();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Hard, immediate unwind — a mark that never finished has nothing to
|
// Hard, immediate unwind — a mark that never finished has nothing to
|
||||||
// fade out, and the caller (engine rollback) must find a clean board.
|
// fade out, and the caller (engine rollback) must find a clean board.
|
||||||
records.delete(anno.id);
|
records.delete(anno.id);
|
||||||
|
byKey.delete(rec.key);
|
||||||
try {
|
try {
|
||||||
group.remove();
|
publish({ force: true });
|
||||||
} catch {
|
} catch {
|
||||||
/* never inserted */
|
/* the board is unreachable */
|
||||||
}
|
}
|
||||||
if (records.size === 0) heightCache.clear();
|
if (records.size === 0) heightCache.clear();
|
||||||
throw error;
|
throw error;
|
||||||
@@ -328,12 +324,18 @@ export function createScreenAnnotationRenderer(
|
|||||||
}
|
}
|
||||||
rec.anno = anno;
|
rec.anno = anno;
|
||||||
if (anno.outlineUnavailable && rec.parts.label) {
|
if (anno.outlineUnavailable && rec.parts.label) {
|
||||||
rec.parts.label.text.textContent = outlineUnavailableLabel(anno.label);
|
rec.parts.label.text = outlineUnavailableLabel(anno.label);
|
||||||
sizeCallout(rec.parts.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) {
|
if (empty) {
|
||||||
for (const child of Array.from(rec.group.children)) child.remove();
|
|
||||||
rec.parts = {};
|
rec.parts = {};
|
||||||
rec.hidden = true;
|
rec.hidden = true;
|
||||||
projectAll();
|
projectAll();
|
||||||
@@ -346,11 +348,9 @@ export function createScreenAnnotationRenderer(
|
|||||||
// label, whose dot/leader/callout already exist; remove the two reticle rings and
|
// label, whose dot/leader/callout already exist; remove the two reticle rings and
|
||||||
// keep every remaining node (especially the group and callout) alive.
|
// keep every remaining node (especially the group and callout) alive.
|
||||||
if (anno.type === 'label' && rec.parts.dot) {
|
if (anno.type === 'label' && rec.parts.dot) {
|
||||||
rec.parts.ringOuter?.remove();
|
|
||||||
rec.parts.ringInner?.remove();
|
|
||||||
delete rec.parts.ringOuter;
|
delete rec.parts.ringOuter;
|
||||||
delete rec.parts.ringInner;
|
delete rec.parts.ringInner;
|
||||||
rec.parts.dot.setAttribute('r', String(LABEL_DOT_R));
|
rec.parts.dot.set.r = String(LABEL_DOT_R);
|
||||||
projectAll();
|
projectAll();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -466,13 +466,16 @@ export function createScreenAnnotationRenderer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function projectAll() {
|
function projectAll() {
|
||||||
if (!records.size) return;
|
if (!records.size) {
|
||||||
|
publish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
projGen += 1; // new frame: entries touched below are "hot" and survive trimming
|
projGen += 1; // new frame: entries touched below are "hot" and survive trimming
|
||||||
const trackedRect = trackedEntityRect();
|
const trackedRect = trackedEntityRect();
|
||||||
occluder.cameraPosition = scene.camera.positionWC;
|
occluder.cameraPosition = scene.camera.positionWC;
|
||||||
const h = scene.canvas.clientHeight || scene.canvas.height;
|
const h = scene.canvas.clientHeight || scene.canvas.height;
|
||||||
const w = scene.canvas.clientWidth || scene.canvas.width;
|
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).
|
// Shrink point-marker geometry as the camera pulls back (declutter the blob).
|
||||||
const mScale = markScale(
|
const mScale = markScale(
|
||||||
viewer.camera.positionCartographic?.height ?? 1000,
|
viewer.camera.positionCartographic?.height ?? 1000,
|
||||||
@@ -481,10 +484,16 @@ export function createScreenAnnotationRenderer(
|
|||||||
for (const rec of records.values()) {
|
for (const rec of records.values()) {
|
||||||
const { anno, group, parts } = rec;
|
const { anno, group, parts } = rec;
|
||||||
if (rec.hidden) {
|
if (rec.hidden) {
|
||||||
group.style.display = 'none';
|
group.display = 'none';
|
||||||
continue;
|
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) {
|
if (anno.type === 'area' && parts.poly) {
|
||||||
const pts = [];
|
const pts = [];
|
||||||
@@ -500,12 +509,12 @@ export function createScreenAnnotationRenderer(
|
|||||||
n++;
|
n++;
|
||||||
}
|
}
|
||||||
if (pts.length >= 3) {
|
if (pts.length >= 3) {
|
||||||
parts.poly.setAttribute('points', pts.join(' '));
|
parts.poly.set.points = pts.join(' ');
|
||||||
group.style.display = '';
|
group.display = '';
|
||||||
if (parts.label)
|
if (parts.label)
|
||||||
positionCallout(parts.label, cx / n, cy / n - 6, true);
|
positionCallout(parts.label, cx / n, cy / n - 6, true);
|
||||||
} else {
|
} else {
|
||||||
group.style.display = 'none';
|
group.display = 'none';
|
||||||
}
|
}
|
||||||
} else if (anno.type === 'arrow' && parts.path) {
|
} else if (anno.type === 'arrow' && parts.path) {
|
||||||
const a = project(anno.anchor.lon, anno.anchor.lat);
|
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 bow = Math.min(60, len * 0.18);
|
||||||
const ctrlX = mx - (dy / len) * bow;
|
const ctrlX = mx - (dy / len) * bow;
|
||||||
const ctrlY = my + (dx / len) * bow;
|
const ctrlY = my + (dx / len) * bow;
|
||||||
parts.path.setAttribute(
|
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)}`;
|
||||||
'd',
|
group.display = '';
|
||||||
`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 = '';
|
|
||||||
// Anchor the label to the (stable) endpoint midpoint, lifted toward the bow.
|
// Anchor the label to the (stable) endpoint midpoint, lifted toward the bow.
|
||||||
if (parts.label)
|
if (parts.label)
|
||||||
positionCallout(
|
positionCallout(
|
||||||
@@ -534,7 +540,7 @@ export function createScreenAnnotationRenderer(
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
group.style.display = 'none';
|
group.display = 'none';
|
||||||
}
|
}
|
||||||
} else if (anno.type === 'route' && parts.poly) {
|
} else if (anno.type === 'route' && parts.poly) {
|
||||||
const projected = anno.path.map((pt) => project(pt.lon, pt.lat));
|
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 (p) pts.push(`${p.x.toFixed(1)},${p.y.toFixed(1)}`);
|
||||||
}
|
}
|
||||||
if (pts.length >= 2) {
|
if (pts.length >= 2) {
|
||||||
parts.poly.setAttribute('points', pts.join(' '));
|
parts.poly.set.points = pts.join(' ');
|
||||||
// dots at each resolvable waypoint; hide the rest
|
// dots at each resolvable waypoint; hide the rest
|
||||||
parts.dots.forEach((dot, i) => {
|
parts.dots.forEach((dot, i) => {
|
||||||
const p = projected[i];
|
const p = projected[i];
|
||||||
if (p) {
|
if (p) {
|
||||||
dot.setAttribute('cx', p.x.toFixed(1));
|
dot.set.cx = p.x.toFixed(1);
|
||||||
dot.setAttribute('cy', p.y.toFixed(1));
|
dot.set.cy = p.y.toFixed(1);
|
||||||
dot.style.display = '';
|
dot.display = '';
|
||||||
} else {
|
} else {
|
||||||
dot.style.display = 'none';
|
dot.display = 'none';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
group.style.display = '';
|
group.display = '';
|
||||||
// label at the middle waypoint
|
// label at the middle waypoint
|
||||||
const mid =
|
const mid =
|
||||||
projected.filter(Boolean)[
|
projected.filter(Boolean)[
|
||||||
@@ -564,33 +570,22 @@ export function createScreenAnnotationRenderer(
|
|||||||
if (parts.label && mid)
|
if (parts.label && mid)
|
||||||
positionCallout(parts.label, mid.x, mid.y - 12, true);
|
positionCallout(parts.label, mid.x, mid.y - 12, true);
|
||||||
} else {
|
} else {
|
||||||
group.style.display = 'none';
|
group.display = 'none';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const p = project(anno.anchor.lon, anno.anchor.lat);
|
const p = project(anno.anchor.lon, anno.anchor.lat);
|
||||||
if (p) {
|
if (p) {
|
||||||
group.style.display = '';
|
group.display = '';
|
||||||
const baseDotR = anno.type === 'label' ? LABEL_DOT_R : DOT_R;
|
const baseDotR = anno.type === 'label' ? LABEL_DOT_R : DOT_R;
|
||||||
if (parts.dot) {
|
for (const [part, radius] of [
|
||||||
parts.dot.setAttribute('cx', p.x.toFixed(1));
|
[parts.dot, baseDotR],
|
||||||
parts.dot.setAttribute('cy', p.y.toFixed(1));
|
[parts.ringOuter, RING_OUTER_R],
|
||||||
parts.dot.setAttribute('r', (baseDotR * mScale).toFixed(1));
|
[parts.ringInner, RING_INNER_R],
|
||||||
}
|
]) {
|
||||||
if (parts.ringOuter) {
|
if (!part) continue;
|
||||||
parts.ringOuter.setAttribute('cx', p.x.toFixed(1));
|
part.set.cx = p.x.toFixed(1);
|
||||||
parts.ringOuter.setAttribute('cy', p.y.toFixed(1));
|
part.set.cy = p.y.toFixed(1);
|
||||||
parts.ringOuter.setAttribute(
|
part.set.r = (radius * mScale).toFixed(1);
|
||||||
'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),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (parts.label) {
|
if (parts.label) {
|
||||||
// Keep the callout tucked near the (now smaller) reticle by scaling its offset.
|
// 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;
|
const ly = p.y - 34 * mScale;
|
||||||
positionCallout(parts.label, lx, ly, false);
|
positionCallout(parts.label, lx, ly, false);
|
||||||
if (parts.leader) {
|
if (parts.leader) {
|
||||||
parts.leader.setAttribute('x1', p.x.toFixed(1));
|
parts.leader.set.x1 = p.x.toFixed(1);
|
||||||
parts.leader.setAttribute('y1', p.y.toFixed(1));
|
parts.leader.set.y1 = p.y.toFixed(1);
|
||||||
parts.leader.setAttribute('x2', lx.toFixed(1));
|
parts.leader.set.x2 = lx.toFixed(1);
|
||||||
parts.leader.setAttribute(
|
parts.leader.set.y2 = (ly + parts.label.height).toFixed(1);
|
||||||
'y2',
|
|
||||||
(ly + parts.label.height).toFixed(1),
|
|
||||||
);
|
|
||||||
parts.label._leader = parts.leader; // so de-collision can re-point it
|
parts.label._leader = parts.leader; // so de-collision can re-point it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
group.style.display = 'none';
|
group.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -621,7 +613,7 @@ export function createScreenAnnotationRenderer(
|
|||||||
if (
|
if (
|
||||||
cal &&
|
cal &&
|
||||||
cal.sized &&
|
cal.sized &&
|
||||||
rec.group.style.display !== 'none' &&
|
rec.group.display !== 'none' &&
|
||||||
Number.isFinite(cal._x)
|
Number.isFinite(cal._x)
|
||||||
) {
|
) {
|
||||||
placed.push(cal);
|
placed.push(cal);
|
||||||
@@ -629,23 +621,44 @@ export function createScreenAnnotationRenderer(
|
|||||||
}
|
}
|
||||||
if (placed.length > 1) decollideCallouts(placed);
|
if (placed.length > 1) decollideCallouts(placed);
|
||||||
|
|
||||||
// Tracked-entity z-order — runs AFTER all geometry + callout de-collision so each
|
publish();
|
||||||
// group's bbox reflects its final on-screen extent. Any mark whose bbox INTERSECTS
|
// Tracked-entity z-order — measured once the view shows this frame's
|
||||||
// the tracked entity's screen footprint is faded toward FULLY HIDDEN (target 0, not a
|
// geometry (still before the browser paints it).
|
||||||
// partial dim) so the tracked plane/ship reads as genuinely ON TOP. The fade is EASED
|
const fading = [...records.values()].some(
|
||||||
// per frame (no abrupt pop as the entity crosses a mark) and reverses smoothly when
|
(rec) => rec._trackedFade !== undefined && rec._trackedFade !== 1,
|
||||||
// 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
|
if ((trackedRect || fading) && !trackedPending) {
|
||||||
// overlapped one is safe and not jarring.
|
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()) {
|
for (const rec of records.values()) {
|
||||||
const { anno, group } = rec;
|
const { anno, group } = rec;
|
||||||
if (group.style.display === 'none') continue;
|
if (group.display === 'none') continue;
|
||||||
const cur = rec._trackedFade ?? 1;
|
const cur = rec._trackedFade ?? 1;
|
||||||
let overlapping = false;
|
let overlapping = false;
|
||||||
if (trackedRect) {
|
if (trackedRect) {
|
||||||
let bb = null;
|
let bb = null;
|
||||||
try {
|
try {
|
||||||
bb = group.getBBox();
|
bb = rec.element?.getBBox() ?? null;
|
||||||
} catch {
|
} catch {
|
||||||
bb = null;
|
bb = null;
|
||||||
}
|
}
|
||||||
@@ -666,32 +679,109 @@ export function createScreenAnnotationRenderer(
|
|||||||
const target = overlapping ? 0 : 1;
|
const target = overlapping ? 0 : 1;
|
||||||
const next = cur + (target - cur) * TRACKED_FADE_EASE;
|
const next = cur + (target - cur) * TRACKED_FADE_EASE;
|
||||||
rec._trackedFade = Math.abs(next - target) < 0.02 ? target : next;
|
rec._trackedFade = Math.abs(next - target) < 0.02 ? target : next;
|
||||||
// Only override when faded — otherwise leave the base alpha (set above) intact.
|
// The base alpha applies unless faded. (The CSS `transition: opacity` was
|
||||||
// (The CSS `transition: opacity` was removed from .gev-anno so this per-frame JS ease
|
// removed from .gev-anno so this per-frame JS ease is the ONLY easing —
|
||||||
// is the ONLY easing — previously the two fought and produced an opacity oscillation.)
|
// previously the two fought and produced an opacity oscillation.)
|
||||||
if (rec._trackedFade !== 1) {
|
const opacity = String(
|
||||||
group.setAttribute(
|
(anno.alpha ?? 1) * (rec._trackedFade !== 1 ? rec._trackedFade : 1),
|
||||||
'opacity',
|
);
|
||||||
String((anno.alpha ?? 1) * rec._trackedFade),
|
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) {
|
function remove(anno) {
|
||||||
const rec = records.get(anno.id);
|
const rec = records.get(anno.id);
|
||||||
if (!rec) return;
|
if (!rec) return;
|
||||||
rec.group.classList.remove('gev-in');
|
// The fading mark keeps the look it had; it leaves the board after 360 ms.
|
||||||
rec.group.classList.add('gev-out');
|
rec.view = { ...boardView().marks.find((mark) => mark.key === rec.key) };
|
||||||
const node = rec.group;
|
rec.view.className = 'gev-anno gev-out';
|
||||||
window.setTimeout(() => {
|
leaving.set(rec.key, rec);
|
||||||
try {
|
let timer;
|
||||||
node.remove();
|
try {
|
||||||
} catch {
|
timer = window.setTimeout(() => {
|
||||||
/* gone */
|
timers.delete(timer);
|
||||||
}
|
leaving.delete(rec.key);
|
||||||
}, 360);
|
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);
|
records.delete(anno.id);
|
||||||
|
publish();
|
||||||
// Board emptied → drop accumulated height samples (natural reset point).
|
// Board emptied → drop accumulated height samples (natural reset point).
|
||||||
if (records.size === 0) heightCache.clear();
|
if (records.size === 0) heightCache.clear();
|
||||||
}
|
}
|
||||||
@@ -707,87 +797,104 @@ export function createScreenAnnotationRenderer(
|
|||||||
} catch {
|
} catch {
|
||||||
/* torn down */
|
/* torn down */
|
||||||
}
|
}
|
||||||
try {
|
for (const timer of timers) clearTimeout(timer);
|
||||||
layer.remove();
|
timers.clear();
|
||||||
} catch {
|
if (shown()) {
|
||||||
/* gone */
|
store.connect(null);
|
||||||
|
store.publish(null);
|
||||||
}
|
}
|
||||||
records.clear();
|
records.clear();
|
||||||
|
leaving.clear();
|
||||||
|
byKey.clear();
|
||||||
heightCache.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 };
|
return { add, update, remove, sync, destroy };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- SVG helpers ------------------------------------------------------------
|
// --- SVG helpers ------------------------------------------------------------
|
||||||
|
|
||||||
function buildOverlay() {
|
/**
|
||||||
const layer = document.createElement('div');
|
* One SVG element of a mark as plain data: the attributes it starts with, the
|
||||||
layer.className = 'gev-screen-whiteboard';
|
* ones each frame sets, and whether it is shown.
|
||||||
const svg = document.createElementNS(SVGNS, 'svg');
|
*/
|
||||||
svg.setAttribute('class', 'gev-screen-whiteboard-svg');
|
function svgPart(tag, attrs) {
|
||||||
svg.setAttribute('preserveAspectRatio', 'none');
|
return { tag, attrs, set: {}, display: '' };
|
||||||
const defs = document.createElementNS(SVGNS, 'defs');
|
}
|
||||||
// subtle hand-drawn wobble for strokes
|
|
||||||
defs.innerHTML = `
|
/** A mark's parts in document order, as the view renders them. */
|
||||||
<filter id="gev-sketch" x="-20%" y="-20%" width="140%" height="140%">
|
function partViews(parts) {
|
||||||
<feTurbulence type="fractalNoise" baseFrequency="0.014" numOctaves="2" seed="7" result="n"/>
|
const views = [];
|
||||||
<feDisplacementMap in="SourceGraphic" in2="n" scale="2.2" xChannelSelector="R" yChannelSelector="G"/>
|
const element = (key, part) =>
|
||||||
</filter>`;
|
views.push({
|
||||||
svg.appendChild(defs);
|
key,
|
||||||
layer.appendChild(svg);
|
tag: part.tag,
|
||||||
return { layer, svg, defs };
|
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) {
|
function makeCallout(text, c) {
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
const node = document.createElementNS(SVGNS, 'g');
|
return {
|
||||||
node.setAttribute('class', 'gev-anno-callout');
|
text,
|
||||||
const rect = document.createElementNS(SVGNS, 'rect');
|
fill: c,
|
||||||
rect.setAttribute('rx', '5');
|
element: null,
|
||||||
rect.setAttribute('class', 'gev-anno-card');
|
transform: null,
|
||||||
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,
|
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 26,
|
height: 26,
|
||||||
sized: false,
|
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) {
|
function sizeCallout(callout) {
|
||||||
try {
|
try {
|
||||||
const bbox = callout.text.getBBox();
|
const bbox = callout.element.getBBox();
|
||||||
const w = Math.max(24, bbox.width + 18);
|
const w = Math.max(24, bbox.width + 18);
|
||||||
const h = Math.max(22, bbox.height + 10);
|
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.width = w;
|
||||||
callout.height = h;
|
callout.height = h;
|
||||||
callout.sized = true;
|
callout.sized = true;
|
||||||
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
/* not laid out yet */
|
/* not laid out yet */
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -796,10 +903,7 @@ function positionCallout(callout, x, y, center) {
|
|||||||
const tx = center ? x - callout.width / 2 : x;
|
const tx = center ? x - callout.width / 2 : x;
|
||||||
callout._x = tx;
|
callout._x = tx;
|
||||||
callout._y = y;
|
callout._y = y;
|
||||||
callout.node.setAttribute(
|
callout.transform = `translate(${tx.toFixed(1)}, ${y.toFixed(1)})`;
|
||||||
'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;
|
const overlapY = a._y < b._y + b.height && a._y + a.height > b._y;
|
||||||
if (overlapX && overlapY) {
|
if (overlapX && overlapY) {
|
||||||
a._y = b._y + b.height + 5;
|
a._y = b._y + b.height + 5;
|
||||||
a.node.setAttribute(
|
a.transform = `translate(${a._x.toFixed(1)}, ${a._y.toFixed(1)})`;
|
||||||
'transform',
|
|
||||||
`translate(${a._x.toFixed(1)}, ${a._y.toFixed(1)})`,
|
|
||||||
);
|
|
||||||
if (a._leader) {
|
if (a._leader) {
|
||||||
a._leader.setAttribute('x2', a._x.toFixed(1));
|
a._leader.set.x2 = a._x.toFixed(1);
|
||||||
a._leader.setAttribute('y2', a._y.toFixed(1));
|
a._leader.set.y2 = a._y.toFixed(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureArrowMarker(defs, key, c) {
|
/** Record the arrowhead marker for a color, unless one is already defined. */
|
||||||
if (defs.querySelector(`#gev-arrow-${key}`)) return;
|
function ensureArrowMarker(markers, key, c) {
|
||||||
const marker = document.createElementNS(SVGNS, 'marker');
|
if (!markers.has(key)) markers.set(key, c);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
@@ -1,164 +1,177 @@
|
|||||||
|
import '../testSupport/registerDom.mjs';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import * as Cesium from 'cesium';
|
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';
|
import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
|
||||||
|
|
||||||
class FakeClassList {
|
// happy-dom does not lay out SVG; every element measures as a 40 x 16 box.
|
||||||
constructor(owner) {
|
for (const type of [globalThis.SVGElement, globalThis.SVGGraphicsElement]) {
|
||||||
this.owner = owner;
|
if (type) type.prototype.getBBox = () => ({ x: 0, y: 0, width: 40, height: 16 });
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeElement {
|
const settle = async () => {
|
||||||
constructor(tagName) {
|
for (let flush = 0; flush < 4; flush += 1) await nextTick();
|
||||||
this.tagName = tagName;
|
};
|
||||||
this.attributes = new Map();
|
|
||||||
this.children = [];
|
|
||||||
this.parentNode = null;
|
|
||||||
this.style = {};
|
|
||||||
this.classList = new FakeClassList(this);
|
|
||||||
this.textContent = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
setAttribute(name, value) {
|
/** Mount the board's view; animation frames run when `frames()` is called. */
|
||||||
this.attributes.set(name, String(value));
|
function board(t) {
|
||||||
if (name === 'class') this.classList.reset(value);
|
document.body.replaceChildren();
|
||||||
}
|
const pinia = createPinia();
|
||||||
|
const store = useScreenAnnotationsStore(pinia);
|
||||||
getAttribute(name) {
|
const app = createApp(ScreenAnnotations).use(pinia);
|
||||||
return this.attributes.get(name) ?? null;
|
app.mount(document.body.appendChild(document.createElement('div')));
|
||||||
}
|
const queued = [];
|
||||||
|
|
||||||
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;
|
|
||||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||||
globalThis.document = fakeDocument();
|
globalThis.requestAnimationFrame = (callback) => queued.push(callback);
|
||||||
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
|
||||||
t.after(() => {
|
t.after(() => {
|
||||||
if (originalDocument === undefined) delete globalThis.document;
|
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||||
else globalThis.document = originalDocument;
|
app.unmount();
|
||||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
|
||||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
|
||||||
});
|
});
|
||||||
|
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 = {
|
const camera = {
|
||||||
positionWC: Cesium.Cartesian3.ZERO,
|
positionWC: Cesium.Cartesian3.ZERO,
|
||||||
directionWC: Cesium.Cartesian3.ZERO,
|
directionWC: Cesium.Cartesian3.ZERO,
|
||||||
positionCartographic: { height: 1000 },
|
positionCartographic: { height: 1000 },
|
||||||
};
|
};
|
||||||
const sampledAnchors = [];
|
const sampledAnchors = [];
|
||||||
const scene = {
|
const scene = sceneFor(camera, {
|
||||||
camera,
|
|
||||||
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
|
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
|
||||||
clampToHeightSupported: true,
|
clampToHeightSupported: true,
|
||||||
clampToHeight(world) {
|
clampToHeight(world) {
|
||||||
@@ -169,9 +182,12 @@ test('outline upgrade preserves the existing SVG group identity', (t) => {
|
|||||||
]);
|
]);
|
||||||
return world;
|
return world;
|
||||||
},
|
},
|
||||||
postRender: { addEventListener() {}, removeEventListener() {} },
|
});
|
||||||
};
|
const renderer = createScreenAnnotationRenderer(
|
||||||
const renderer = createScreenAnnotationRenderer({ scene, camera, trackedEntity: null });
|
{ scene, camera, trackedEntity: null },
|
||||||
|
{ store, activeTrackedReadoutId: () => null },
|
||||||
|
);
|
||||||
|
t.after(() => renderer.destroy());
|
||||||
const anno = {
|
const anno = {
|
||||||
id: 'anno-fb3',
|
id: 'anno-fb3',
|
||||||
type: 'area',
|
type: 'area',
|
||||||
@@ -182,8 +198,11 @@ test('outline upgrade preserves the existing SVG group identity', (t) => {
|
|||||||
ring: null,
|
ring: null,
|
||||||
};
|
};
|
||||||
renderer.add(anno);
|
renderer.add(anno);
|
||||||
const before = findAnnotationGroup(globalThis.document);
|
await settle();
|
||||||
assert.ok(before.group.querySelector('.gev-anno-ring'), 'pending area starts as a reticle');
|
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), {
|
const labelProxy = Object.assign(Object.create(anno), {
|
||||||
type: 'label',
|
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 },
|
anchor: { lon: -97.5, lat: 31.2, height: 0 },
|
||||||
});
|
});
|
||||||
renderer.update(labelProxy);
|
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.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(groups().length, 1);
|
||||||
assert.equal(after.group.querySelector('.gev-anno-ring'), null, 'reticle rings are removed in place');
|
assert.equal(after.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');
|
assert.ok(after.querySelector('.gev-anno-callout') === callout, 'the original callout remains in the group');
|
||||||
renderer.destroy();
|
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) => {
|
test('a callout whose text changes is sized to the new text', async (t) => {
|
||||||
const originalDocument = globalThis.document;
|
const { store, groups } = board(t);
|
||||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
projectTo(t, { x: 100, y: 100 });
|
||||||
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
|
// Text measures 7px a character; everything else keeps the shared box.
|
||||||
globalThis.document = fakeDocument();
|
const original = globalThis.SVGGraphicsElement.prototype.getBBox;
|
||||||
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
globalThis.SVGGraphicsElement.prototype.getBBox = function () {
|
||||||
Cesium.SceneTransforms.worldToWindowCoordinates = () => ({ x: 0, y: 0 });
|
return this.tagName.toLowerCase() === 'text'
|
||||||
t.after(() => {
|
? { x: 0, y: 0, width: this.textContent.length * 7, height: 16 }
|
||||||
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
|
: original.call(this);
|
||||||
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() {} },
|
|
||||||
};
|
};
|
||||||
|
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 paintRectCalls = [];
|
||||||
const renderer = createScreenAnnotationRenderer(
|
const renderer = createScreenAnnotationRenderer(
|
||||||
{ scene, camera, trackedEntity: null },
|
{ scene: sceneFor(camera), camera, trackedEntity: null },
|
||||||
{
|
{
|
||||||
|
store,
|
||||||
activeTrackedReadoutId: () => 'installations:test',
|
activeTrackedReadoutId: () => 'installations:test',
|
||||||
overlayPaintRect(sourceId, entryId) {
|
overlayPaintRect(sourceId, entryId) {
|
||||||
paintRectCalls.push({ 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({
|
renderer.add({
|
||||||
id: 'anno-overlap',
|
id: 'anno-overlap',
|
||||||
type: 'label',
|
type: 'label',
|
||||||
@@ -247,7 +285,8 @@ test('annotation fade consumes the actual tracked host paint rectangle after lay
|
|||||||
alpha: 1,
|
alpha: 1,
|
||||||
anchor: { lon: 0, lat: 0, height: 0 },
|
anchor: { lon: 0, lat: 0, height: 0 },
|
||||||
});
|
});
|
||||||
const { group } = findAnnotationGroup(globalThis.document);
|
await settle();
|
||||||
|
const [group] = groups();
|
||||||
assert.deepEqual(paintRectCalls.at(-1), {
|
assert.deepEqual(paintRectCalls.at(-1), {
|
||||||
sourceId: 'tracked',
|
sourceId: 'tracked',
|
||||||
entryId: 'installations:test',
|
entryId: 'installations:test',
|
||||||
@@ -256,44 +295,59 @@ test('annotation fade consumes the actual tracked host paint rectangle after lay
|
|||||||
Number(group.getAttribute('opacity')) < 1,
|
Number(group.getAttribute('opacity')) < 1,
|
||||||
'final annotation bbox fades against the complete painted card rectangle',
|
'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();
|
renderer.destroy();
|
||||||
|
await settle();
|
||||||
|
assert.equal(document.querySelector('.gev-screen-whiteboard'), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Partial-add unwind (review round 2) ───────────────────────────────────────
|
// ── Partial-add unwind (review round 2) ───────────────────────────────────────
|
||||||
//
|
//
|
||||||
// add() inserts the group and records it, then does more live-document work
|
// add() records the mark, then does more work (presenting it, the first
|
||||||
// (draw-on wiring, the first projection pass). A throw in that tail used to
|
// projection pass). A throw in that tail used to leave an ORPHANED mark plus a
|
||||||
// leave an ORPHANED <g> plus a record nobody owned — the engine's rollback only
|
// record nobody owned — the engine's rollback only deletes its own map entry —
|
||||||
// deletes its own map entry — so re-annotating the same place stacked a second
|
// so re-annotating the same place stacked a second mark over the corpse.
|
||||||
// mark over the corpse.
|
|
||||||
|
|
||||||
test('an add that throws after inserting its group unwinds instead of orphaning it', (t) => {
|
test('an add that throws after inserting its group unwinds instead of orphaning it', async (t) => {
|
||||||
const originalDocument = globalThis.document;
|
const { store, groups } = board(t);
|
||||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
const camera = cameraOver(0, 0);
|
||||||
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
|
const renderer = createScreenAnnotationRenderer(
|
||||||
globalThis.document = fakeDocument();
|
{ scene: sceneFor(camera, { canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 } }), camera, trackedEntity: null },
|
||||||
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
{ store, activeTrackedReadoutId: () => null },
|
||||||
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 } };
|
t.after(() => renderer.destroy());
|
||||||
const scene = {
|
|
||||||
camera,
|
|
||||||
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
|
|
||||||
clampToHeightSupported: false,
|
|
||||||
postRender: { addEventListener() {}, removeEventListener() {} },
|
|
||||||
};
|
|
||||||
const renderer = createScreenAnnotationRenderer({ scene, camera, trackedEntity: null });
|
|
||||||
const anno = {
|
const anno = {
|
||||||
id: 'anno-partial-screen',
|
id: 'anno-partial-screen',
|
||||||
type: 'pin',
|
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 },
|
anchor: { lon: 0, lat: 0, height: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
// The group is in the document and recorded by the time add() reaches its
|
// The mark is on the board by the time add() reaches its first projection
|
||||||
// first projection pass — so a throw there is real partial state, not a
|
// pass — so a throw there is real partial state, not a clean bail-out.
|
||||||
// clean bail-out.
|
projectTo(t, () => {
|
||||||
Cesium.SceneTransforms.worldToWindowCoordinates = () => { throw new Error('projection failed'); };
|
throw new Error('projection failed');
|
||||||
|
});
|
||||||
assert.throws(() => renderer.add(anno), /projection failed/);
|
assert.throws(() => renderer.add(anno), /projection failed/);
|
||||||
Cesium.SceneTransforms.worldToWindowCoordinates = () => ({ x: 0, y: 0 });
|
Cesium.SceneTransforms.worldToWindowCoordinates = () => ({ x: 0, y: 0 });
|
||||||
const { svg } = findAnnotationGroup(globalThis.document);
|
await settle();
|
||||||
assert.equal(
|
assert.equal(groups().length, 0, 'the half-built group must not stay in the document');
|
||||||
svg.children.filter((child) => child.classList.contains('gev-anno')).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
|
// 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).
|
// owns it (a leaked record would leave a second group behind).
|
||||||
renderer.add(anno);
|
renderer.add(anno);
|
||||||
assert.equal(
|
await settle();
|
||||||
svg.children.filter((child) => child.classList.contains('gev-anno')).length,
|
assert.equal(groups().length, 1, 'the retry must draw one mark, not stack over an orphan');
|
||||||
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) => {
|
||||||
renderer.destroy();
|
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');
|
||||||
});
|
});
|
||||||
@@ -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 };
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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; } }
|
||||||
@@ -12,6 +12,7 @@ import SceneInteractions from './panels/SceneInteractions.vue';
|
|||||||
import SplitFlapText from './panels/SplitFlapText.vue';
|
import SplitFlapText from './panels/SplitFlapText.vue';
|
||||||
import WindInspectionMarker from './panels/WindInspectionMarker.vue';
|
import WindInspectionMarker from './panels/WindInspectionMarker.vue';
|
||||||
import CelestialRing from './panels/CelestialRing.vue';
|
import CelestialRing from './panels/CelestialRing.vue';
|
||||||
|
import ScreenAnnotations from './panels/ScreenAnnotations.vue';
|
||||||
|
|
||||||
// The ACTIVE STYLE corner shows the style VisualSettings publishes.
|
// The ACTIVE STYLE corner shows the style VisualSettings publishes.
|
||||||
const { style } = storeToRefs(useDisplayStore());
|
const { style } = storeToRefs(useDisplayStore());
|
||||||
@@ -106,4 +107,5 @@ const vToastText = {
|
|||||||
<SceneInteractions />
|
<SceneInteractions />
|
||||||
<WindInspectionMarker />
|
<WindInspectionMarker />
|
||||||
<MissionReplayOverlay />
|
<MissionReplayOverlay />
|
||||||
|
<ScreenAnnotations />
|
||||||
</template>
|
</template>
|
||||||
@@ -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>
|
||||||
@@ -28,11 +28,11 @@
|
|||||||
@import './src/ui/styles/first-run.css';
|
@import './src/ui/styles/first-run.css';
|
||||||
@import './src/ui/styles/provider-settings.css';
|
@import './src/ui/styles/provider-settings.css';
|
||||||
@import './src/ui/styles/locale-picker.css';
|
@import './src/ui/styles/locale-picker.css';
|
||||||
|
|
||||||
@import './src/ui/styles/weather.css';
|
@import './src/ui/styles/weather.css';
|
||||||
@import './src/ui/styles/panel-surfaces.css';
|
@import './src/ui/styles/panel-surfaces.css';
|
||||||
@import './src/ui/styles/segmented-control.css';
|
@import './src/ui/styles/segmented-control.css';
|
||||||
@import './src/ui/styles/kit.css';
|
@import './src/ui/styles/kit.css';
|
||||||
@import './src/ui/styles/cjk.css';
|
@import './src/ui/styles/cjk.css';
|
||||||
@import './src/ui/styles/mobile-shell.css';
|
@import './src/ui/styles/mobile-shell.css';
|
||||||
|
@import './src/ui/styles/annotations.css';
|
||||||
@import './src/ui/styles/cyber.css';
|
@import './src/ui/styles/cyber.css';
|
||||||
Reference in new issue
Block a user