feat(overlay): draw selected and tracked map cards as a Vue component, without the accent bar

Detail and selected cards and annotation callouts no longer carry a coloured
bar down their left edge. The world overlay still solves each card's place and
paints its leader; WorldOverlayCards draws the plate and text of selected and
tracked cards at that rectangle, positioned by a transform written each
painted frame. ?overlayCards=all draws ambient detail cards the same way and
?overlayCards=off paints every card on the canvas.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
This commit is contained in:
Mazeyar Moeini FeizabadiandClaude Fable 5.2 committed 2026-11-12 19:29:19 +01:00
1 parent 10a788db78
commit aa9021f246
18 files changed
+660 -15

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
- Detail and selected cards on the globe, and annotation callouts, no longer
carry a coloured bar down their left edge; an annotation callout is outlined
in its mark's colour instead. The selected and tracked cards are now drawn by
a Vue component (`WorldOverlayCards`) at the place the overlay solves for
them; the overlay still paints their leader lines. `?overlayCards=all` draws
the ambient detail cards the same way and `?overlayCards=off` returns every
card to the canvas (mazy1998).
+1
View File
@@ -6293,6 +6293,7 @@ silently demoting every later lookup for the session.
- Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk.
- Context selection transfers camera ownership by subject type: selecting a civilian or military flight keeps that layer's moving follow camera, while selecting an AIS vessel or mapped installation first releases any prior aircraft tracker and performs only the source layer's one-time framing. The camera therefore remains user-controlled after non-aircraft selection instead of continuing to move with the previously selected plane.
- Selected AIS vessels use their layer-owned full-detail presentation model in the shared world-overlay host; mapped installations promote their own entry to a selected card. Both remain crisp above post-processing without duplicate selected labels; non-selected AIS cards retain their source-owned grid/visibility selection and are host-batched with other world cards.
- Selected and tracked world cards are elements, not canvas paint: the host solves each card's rectangle and paints its leader, and `WorldOverlayCards` (scene chrome, `gev-world-overlay-cards` store) draws the plate and text at that rectangle, positioned by a transform written each painted frame. Ambient detail cards, labels, tracks, thumbnails and tactical cards stay on the canvas. Detail and selected cards carry no accent bar on their left edge, and an annotation callout is outlined in its mark's colour instead of carrying one; detection callouts keep their tier bar. `?overlayCards=all` also draws ambient detail cards as elements and `?overlayCards=off` paints every card on the canvas; a composition without the presenter paints every card on the canvas. Element cards were compared with canvas cards only under software rendering.
- Space Mission ascent replay uses the compact rocket/thrust overlay only through orbit insertion. Once the replay enters its orbit phase, that vehicle glyph is replaced by a fixed-size cyan dot following the same orbit path and callout.
- While a subject is selected, an inner keyhole compass rotates against camera heading and up to three cyan shafted bearing arrows lock to its single faint tick-marked rim, with their labels inset just inside the circle. Labels explicitly separate the geographic bearing (`BRG`) from the contact's reported course (`CRS`) so the pointer direction is not confused with aircraft heading. They point toward the nearest observed/mapped examples; each cohort displays up to ten examples while retaining the complete locally loaded in-range cohort for navigation, with three visible at a time and a ten-second page rotation shared by the panel and arrows. This distinct neutral-context color avoids implying that all context indicators are military-flight symbols. `PREVIOUS`, `FOCUS`, and `NEXT` controls navigate selection history or the next nearby cohort example through the existing tracker. NEXT uses a cycle-scoped visited set and starts a deterministic new walk after exhausting the current candidates instead of re-admitting the nearest visited contact.
- Awareness `BRG` and `CRS` labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `000°` rather than `360°`.
+8 -1
View File
@@ -274,7 +274,14 @@ a feature supplying only its accent.
the application shell publishes to the `gev-world-overlay-actions` store
and answers with `activateWorldOverlayAction()`; the module needs no Vue
for the portable transit entry, and its card and detection canvases stay
painting surfaces it draws on, like the Cesium canvas), the Global
painting surfaces it draws on, like the Cesium canvas), the selected and
tracked map cards (`WorldOverlayCards` in the scene chrome draws them as
elements from the list the world overlay presents through
`subscribeWorldOverlayCards()`, which the application shell publishes to
the `gev-world-overlay-cards` store; the overlay still solves each card's
place and paints its leader, and writes every frame's positions straight
to the elements, so a camera move re-renders nothing; without a presenter
the overlay paints these cards on its canvas), the Global
Context compass and contact bearings (`AwarenessDirection` teleports into
the globe container what the awareness layer presents through
`subscribeDirectionOverlay()`, which the application shell publishes to
+4
View File
@@ -136,6 +136,8 @@
"src/ui/stores/screenAnnotations.js",
"src/ui/views/panels/ScreenAnnotations.vue",
"src/ui/stores/worldOverlayActions.js",
"src/ui/stores/worldOverlayCards.js",
"src/ui/views/panels/WorldOverlayCards.vue",
"src/ui/stores/awarenessDirection.js",
"src/ui/views/panels/AwarenessDirection.vue",
"src/ui/stores/hud.js",
@@ -1295,6 +1297,7 @@
"src/ui/stores/missionReplay.js",
"src/ui/stores/celestialRing.js",
"src/ui/stores/worldOverlayActions.js",
"src/ui/stores/worldOverlayCards.js",
"src/ui/stores/awarenessDirection.js",
"src/ui/stores/hud.js",
"src/ui/stores/panelChrome.js",
@@ -3548,6 +3551,7 @@
"src/ui/stores/celestialRing.js",
"src/ui/stores/screenAnnotations.js",
"src/ui/stores/worldOverlayActions.js",
"src/ui/stores/worldOverlayCards.js",
"src/ui/stores/awarenessDirection.js",
"src/hudReadouts.js",
"src/ui/stores/hud.js",
@@ -890,9 +890,6 @@ function partViews(parts) {
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;
}
@@ -114,13 +114,13 @@ test('a pin renders its reticle, leader and callout on the page board', async (t
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(callout.children.length, 2, 'a card and its text, no accent bar');
const [card, 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(card.style.stroke, '#ffb547', 'outlined in the mark\u2019s colour');
assert.equal(callout.getAttribute('transform'), 'translate(218.0, 266.0)');
assert.equal(leader.getAttribute('y2'), '292.0');
frames();
+168 -2
View File
@@ -19,7 +19,9 @@ import {
distanceFade,
distanceScale,
measureOverlayEntry,
overlayEntryViewCardKind,
paintOverlayEntry,
paintOverlayEntryConnector,
placementVariants,
truncateOverlayTitle,
} from './worldOverlayDraw.js';
@@ -118,6 +120,19 @@ let _ctx = null;
let _actions = null;
const _actionsListeners = new Set();
let _accessibilitySignature = '';
/** The view that draws cards as elements: `{ content(view), frame(frames, count) }`. */
let _cardPresenter = null;
/** Which cards the view draws: 'focus' (selected, tracked), 'all' or 'off'. */
let _viewCardMode = 'focus';
/** The cards last handed to the view, with the tokens to draw them in. */
let _viewCards = Object.freeze({
owner: {},
style: WORLD_OVERLAY_STYLE,
cards: Object.freeze([]),
});
/** This frame's view cards, pooled: where and how visibly each one sits. */
const _viewCardFrames = [];
let _viewCardCount = 0;
const _accessibleActivatorByKey = new Map();
/** @type {HTMLCanvasElement|null} Host-owned detection blend-isolation surface. */
let _detectionSurface = null;
@@ -237,6 +252,8 @@ const _diagnostics = {
projectionMs: 0,
solveMs: 0,
paintMs: 0,
viewCardCount: 0,
viewCardMs: 0,
solveRevision: 0,
paintItemPoolSize: 0,
paintRectPoolSize: 0,
@@ -2446,6 +2463,131 @@ function syncAccessibleActions() {
presentActions({ ..._actions, items });
}
/**
* Draw cards as elements. The presenter receives the card list whenever a
* card's content or size changes (`content`), and every painted frame the
* pooled positions (`frame`): `{ key, x, y, scale, alpha }` in CSS pixels,
* valid only during the call. Without a presenter every card is painted on
* the canvas.
* @param {{content: (view: {owner: object, style: object, cards: object[]}) => void,
* frame: (frames: object[], count: number) => void}} presenter
* @returns {() => void} Unsubscribe.
*/
export function subscribeWorldOverlayCards(presenter) {
_cardPresenter = presenter;
presenter.content(_viewCards);
_viewer?.scene?.requestRender?.();
return () => {
if (_cardPresenter !== presenter) return;
_cardPresenter = null;
_viewer?.scene?.requestRender?.();
};
}
/**
* Choose which cards the presenter draws: `'focus'` (selected and tracked
* cards, the default), `'all'` (ambient detail cards too) or `'off'`.
* @param {'focus'|'all'|'off'} mode
*/
export function setWorldOverlayViewCardMode(mode) {
const next = mode === 'all' || mode === 'off' ? mode : 'focus';
if (next === _viewCardMode) return;
_viewCardMode = next;
_viewer?.scene?.requestRender?.();
}
/** The kind of view card `entry` is drawn as this frame, or null for canvas. */
function viewCardKind(entry) {
if (!_cardPresenter || _viewCardMode === 'off') return null;
const kind = overlayEntryViewCardKind(entry);
return kind === 'card' && _viewCardMode !== 'all' ? null : kind;
}
function queueViewCard(item, kind, alpha) {
const { record, placement } = item;
const frame =
_viewCardFrames[_viewCardCount] || (_viewCardFrames[_viewCardCount] = {});
_viewCardCount++;
frame.key = record.key;
frame.kind = kind;
frame.entry = record.entry;
frame.x = placement.rect.x;
frame.y = placement.rect.y;
frame.scale = record.paintScale;
frame.w = placement.rect.w / record.paintScale;
frame.h = placement.rect.h / record.paintScale;
frame.alpha = alpha;
}
function viewCardMatches(card, frame) {
const entry = frame.entry;
if (
card.key !== frame.key ||
card.kind !== frame.kind ||
card.title !== entry.title ||
card.accent !== entry.accent ||
card.w !== frame.w ||
card.h !== frame.h ||
card.details.length !== entry.details.length
)
return false;
for (let i = 0; i < card.details.length; i++)
if (card.details[i] !== entry.details[i]) return false;
return true;
}
/** Hand the presenter this frame's cards: content on change, positions always. */
function syncViewCards() {
if (!_cardPresenter) {
_viewCardCount = 0;
if (_viewCards.cards.length)
_viewCards = Object.freeze({
owner: _viewCards.owner,
style: WORLD_OVERLAY_STYLE,
cards: Object.freeze([]),
});
return;
}
const started = nowMs();
const shown = _viewCards.cards;
let changed = shown.length !== _viewCardCount;
for (let i = 0; !changed && i < _viewCardCount; i++)
changed = !viewCardMatches(shown[i], _viewCardFrames[i]);
if (changed) {
const cards = [];
for (let i = 0; i < _viewCardCount; i++) {
const frame = _viewCardFrames[i];
const entry = frame.entry;
const layout = entry._overlayLayout || {};
cards.push(
Object.freeze({
key: frame.key,
kind: frame.kind,
title: entry.title,
details: Object.freeze(entry.details.slice()),
accent: entry.accent,
w: frame.w,
h: frame.h,
padX: layout.padX || 0,
padY: layout.padY || 0,
titleH: layout.titleH || 0,
lineH: layout.lineH || 0,
}),
);
}
_viewCards = Object.freeze({
owner: _viewCards.owner,
style: WORLD_OVERLAY_STYLE,
cards: Object.freeze(cards),
});
_cardPresenter.content(_viewCards);
}
if (_viewCardCount > 0 || changed)
_cardPresenter.frame(_viewCardFrames, _viewCardCount);
_diagnostics.viewCardCount = _viewCardCount;
_diagnostics.viewCardMs = nowMs() - started;
}
let _detectionSurfacePrepared = false;
function paintCustomLane(lane) {
@@ -2512,8 +2654,12 @@ function paintEntryItem(item, keyhole, sonar) {
keyholeAlpha *
(sonar ? sonar.label(sonar.at(placement.anchorX, placement.anchorY)) : 1);
if (finalAlpha <= 0.001) return;
// A view card keeps its leader here; the view draws its plate and text.
const viewKind = viewCardKind(entry);
const paint = viewKind ? paintOverlayEntryConnector : paintOverlayEntry;
let viewAlpha;
if (record.paintScale === 1) {
paintOverlayEntry(
viewAlpha = paint(
_ctx,
entry,
placement,
@@ -2530,7 +2676,7 @@ function paintEntryItem(item, keyhole, sonar) {
_ctx.save();
_ctx.translate(placement.rect.x, placement.rect.y);
_ctx.scale(record.paintScale, record.paintScale);
paintOverlayEntry(
viewAlpha = paint(
_ctx,
entry,
scaled,
@@ -2540,6 +2686,7 @@ function paintEntryItem(item, keyhole, sonar) {
);
_ctx.restore();
}
if (viewKind) queueViewCard(item, viewKind, viewAlpha);
publishPaintRect(item);
if (_paintedBySource[entry.source] === undefined) {
_paintedBySource[entry.source] = 0;
@@ -2646,6 +2793,7 @@ function paintFrame(keyhole) {
clearCanvas(false, true);
_paintRectCount = 0;
_hitRectCount = 0;
_viewCardCount = 0;
declutterYieldingEntries();
sortPooledRange(_paintQueue, _paintCount, comparePaintItems);
// The shared canvas is not clipped either. Cards already avoid solid chrome
@@ -2669,6 +2817,7 @@ function paintFrame(keyhole) {
_diagnostics.paintRectPoolSize = _paintRectPool.length;
_diagnostics.paintMs = nowMs() - started;
syncAccessibleActions();
syncViewCards();
syncPaintVisibility();
_canvasNeedsClear =
_paintRectCount > 0 || activeCustomPaintLaneCount(PAINT_TARGET_SHARED) > 0;
@@ -2697,6 +2846,8 @@ function localizeScaledPlacement(placement, scale, out) {
}
function resetFrameDiagnostics() {
_diagnostics.viewCardCount = 0;
_diagnostics.viewCardMs = 0;
_diagnostics.candidateCount = 0;
_diagnostics.projectedCount = 0;
_diagnostics.selectedCount = 0;
@@ -2726,6 +2877,10 @@ function drawWorldOverlay() {
_hitRectCount = 0;
syncAccessibleActions();
}
if (_viewCards.cards.length) {
_viewCardCount = 0;
syncViewCards();
}
syncPaintVisibility(false);
return;
}
@@ -2888,6 +3043,15 @@ export function destroyWorldOverlay() {
_hitRects.length = 0;
_accessibleActivatorByKey.clear();
_accessibilitySignature = '';
// The presenter outlives a host: empty its list, then start a fresh one.
_viewCardCount = 0;
if (_viewCards.cards.length) syncViewCards();
_viewCards = Object.freeze({
owner: {},
style: WORLD_OVERLAY_STYLE,
cards: Object.freeze([]),
});
_cardPresenter?.content(_viewCards);
_paintRectByKey.clear();
_queuedItemBySource.clear();
_paintCount = 0;
@@ -2954,6 +3118,8 @@ export function destroyWorldOverlay() {
projectionMs: 0,
solveMs: 0,
paintMs: 0,
viewCardCount: 0,
viewCardMs: 0,
solveRevision: 0,
paintItemPoolSize: 0,
paintRectPoolSize: 0,
+60 -2
View File
@@ -33,6 +33,8 @@ import {
activateWorldOverlayAction,
initWorldOverlay,
subscribeWorldOverlayActions,
subscribeWorldOverlayCards,
setWorldOverlayViewCardMode,
getQueuedOverlayEntryAlpha,
isOverlayPointVisible,
normalizeOverlayEntry,
@@ -2485,8 +2487,8 @@ test('diagnostics facade preserves the complete binding shape', () => {
const fields = [
'sourceCount', 'entryCount', 'candidateCount', 'projectedCount', 'selectedCount',
'fadingCount', 'paintedCount', 'hitRectCount', 'projectionMs', 'solveMs',
'paintMs', 'solveRevision', 'paintItemPoolSize', 'paintRectPoolSize',
'candidateIndexSize', 'entriesBySource', 'paintedBySource',
'paintMs', 'viewCardCount', 'viewCardMs', 'solveRevision', 'paintItemPoolSize',
'paintRectPoolSize', 'candidateIndexSize', 'entriesBySource', 'paintedBySource',
];
assert.deepEqual(Object.keys(diagnostics).sort(), fields.sort());
});
@@ -2548,6 +2550,62 @@ test('installation titles share persistent card arbitration and retain a bounded
});
test('a card presenter draws selected cards as elements while the canvas keeps their leaders', () => {
const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 });
const views = [];
const frames = [];
const unsubscribe = subscribeWorldOverlayCards({
content: (view) => views.push(view),
frame: (list, count) => frames.push(list.slice(0, count).map(({ key, x, y, scale, alpha }) => ({ key, x, y, scale, alpha }))),
});
try {
initWorldOverlay(env.viewer);
const texts = () => env.ctx.calls.filter(([op]) => op === 'fillText').map(([, text]) => text);
const paint = () => { env.ctx.calls.length = 0; env.postRender.raise(); };
const ambient = { id: 'site', position: positionAtScreen(120, 220), title: 'Ambient site', variant: 'card', collisionGroup: 'ambient-card', horizonCull: false, edgeFade: false };
setOverlayEntries('view-cards', [selectedEntry('picked', { title: 'Picked site', details: ['OPENSTREETMAP'], position: positionAtScreen(200, 150) }), ambient]);
paint();
env.advanceTime(200);
frames.length = 0;
paint();
const { cards } = views.at(-1);
assert.deepEqual(cards.map((card) => [card.kind, card.title, card.details]), [['selected', 'Picked site', ['OPENSTREETMAP']]]);
const rect = getOverlayPaintRect('view-cards', 'picked');
assert.deepEqual([cards[0].w, cards[0].h], [rect.w, rect.h], 'the element is the rectangle the overlay solved with');
assert.deepEqual(frames.at(-1), [{ key: cards[0].key, x: rect.x, y: rect.y, scale: 1, alpha: 1 }]);
assert.deepEqual(texts(), ['Ambient site'], 'the selected card paints no text; the ambient card stays on the canvas');
assert.ok(env.ctx.calls.some(([op]) => op === 'stroke'), 'its leader is still painted');
const published = views.length;
paint();
assert.equal(views.length, published, 'an unchanged card republishes no content');
assert.equal(frames.length, 2, 'every painted frame writes positions');
assert.equal(getWorldOverlayDiagnostics().viewCardCount, 1);
setWorldOverlayViewCardMode('all');
paint();
assert.deepEqual(views.at(-1).cards.map((card) => card.kind).sort(), ['card', 'selected']);
assert.deepEqual(texts(), []);
setWorldOverlayViewCardMode('off');
paint();
assert.deepEqual(views.at(-1).cards, []);
assert.deepEqual(texts().sort(), ['Ambient site', 'OPENSTREETMAP', 'Picked site']);
setWorldOverlayViewCardMode('focus');
paint();
assert.equal(views.at(-1).cards.length, 1);
unsubscribe();
paint();
assert.deepEqual(texts().sort(), ['Ambient site', 'OPENSTREETMAP', 'Picked site'], 'without a presenter every card paints on the canvas');
} finally {
unsubscribe();
setWorldOverlayViewCardMode('focus');
env.cleanup();
}
});
test('promoting an ambient entry to selected paints it once with no fading duplicate', () => {
const env = installMockEnvironment();
try {
+51 -2
View File
@@ -645,8 +645,6 @@ function drawCardChrome(
: WORLD_OVERLAY_STYLE.border;
ctx.lineWidth = selected ? 1.25 : 1;
ctx.stroke();
ctx.fillStyle = accent;
ctx.fillRect(x, y + 3, selected ? 3 : 2, Math.max(1, h - 6));
}
function drawCardText(ctx, entry, placement, selected = false, topOffset = 0) {
@@ -1203,6 +1201,57 @@ export function paintDetectionCallout(ctx, callout, alpha = 1) {
}
}
/**
* The kind of card a view may draw in place of the canvas plate and text:
* `'tracked'`, `'selected'` or `'card'`, or null for the variants that stay
* on the canvas (labels, tracks, thumbnails and tactical cards).
* @param {object} entry A normalized entry.
* @returns {?('tracked'|'selected'|'card')}
*/
export function overlayEntryViewCardKind(entry) {
if (entry.cardStyle === 'tactical' || entry.variant === 'thumbnail')
return null;
if (entry.variant === 'tracked') return 'tracked';
if (entry.selected || entry.variant === 'selected') return 'selected';
return entry.variant === 'card' ? 'card' : null;
}
/**
* Paint only what joins a view-drawn card to its target: the leader and, for
* a staged card, its anchor dot. Same arguments as `paintOverlayEntry`.
* @returns {number} The alpha the card itself shows at.
*/
export function paintOverlayEntryConnector(
ctx,
entry,
placement,
alpha = 1,
leaderProgress = 1,
contentAlpha = 1,
) {
const accent = entry.accent || WORLD_OVERLAY_STYLE.accent;
const staged = entry.anchorDot && overlayEntryViewCardKind(entry) === 'card';
ctx.save();
ctx.globalAlpha = alpha;
if (staged) {
drawLeader(
ctx,
placement,
accent,
leaderProgress,
WORLD_OVERLAY_STYLE.leaderWidth,
entry.leaderStyle,
);
drawAnchorDot(ctx, entry, placement);
} else {
drawLeader(ctx, placement, accent, 1, 1, entry.leaderStyle);
}
ctx.restore();
return staged
? alpha * Math.max(0, Math.min(1, Number(contentAlpha) || 0))
: alpha;
}
/** Dispatch a normalized entry to its pure variant painter. */
export function paintOverlayEntry(
ctx,
+27
View File
@@ -506,6 +506,33 @@ test('variant measurement and all six painters remain renderer-local', () => {
assert.ok(trackLayout.w >= 'CAMERA 12 · LIVE'.length * 6);
});
test('detail and selected cards paint no accent bar down their left edge', () => {
// The accent belongs to the leader, the anchor dot and the selected border.
for (const [variant, paint] of [
['card', paintCard],
['selected', paintSelected],
]) {
const ctx = mockContext();
const entry = {
title: 'DATA CENTER',
details: ['OPENSTREETMAP'],
accent: '#6be8ff',
variant,
};
const layout = measureOverlayEntry(ctx, entry);
paint(ctx, entry, {
rect: { x: 40, y: 40, w: layout.w, h: layout.h },
anchor: { x: 20, y: 90 },
leader: { x1: 20, y1: 90, x2: 40, y2: 60 },
});
assert.deepEqual(
ctx.calls.filter(([name]) => name === 'fillRect'),
[],
`${variant} card`,
);
}
});
test('thumbnail painter preserves the shipped CCTV 104x77 geometry and drawing coordinates', () => {
const ctx = mockContext();
const frameSlot = createFrameSlot();
+19
View File
@@ -60,6 +60,7 @@ import { useContextStore } from './stores/context.js';
import { useMissionReplayStore } from './stores/missionReplay.js';
import { useCelestialRingStore } from './stores/celestialRing.js';
import { useWorldOverlayActionsStore } from './stores/worldOverlayActions.js';
import { useWorldOverlayCardsStore } from './stores/worldOverlayCards.js';
import { useAwarenessDirectionStore } from './stores/awarenessDirection.js';
import { useHudStore } from './stores/hud.js';
import { useDisplayStore } from './stores/display.js';
@@ -119,6 +120,8 @@ export class StyleManager extends ShellFacade {
initWorldOverlay,
subscribeWorldOverlayActions,
activateWorldOverlayAction,
subscribeWorldOverlayCards,
setWorldOverlayViewCardMode,
initDetection,
setDetectionStyle,
trafficLayer,
@@ -532,6 +535,19 @@ export class StyleManager extends ShellFacade {
this._overlayActionsUnsubscribe = subscribeWorldOverlayActions((view) =>
overlayActionsStore.publish(view),
);
// The selected and tracked cards, which the scene chrome draws as
// elements; `?overlayCards=all|off` widens or disables that for comparison.
const overlayCardsStore = useWorldOverlayCardsStore(applicationPinia());
// A composition that predates view cards keeps every card on the canvas.
setWorldOverlayViewCardMode?.(
new URLSearchParams(globalThis.location?.search ?? '').get(
'overlayCards',
),
);
this._overlayCardsUnsubscribe = subscribeWorldOverlayCards?.({
content: (view) => overlayCardsStore.publish(view),
frame: (frames, count) => overlayCardsStore.writeFrame(frames, count),
});
// Initialize detection overlay BEFORE style stages so the composite
// stage is first in the post-process pipeline
@@ -1922,6 +1938,9 @@ export class StyleManager extends ShellFacade {
destroyDetection();
this._overlayActionsUnsubscribe?.();
this._overlayActionsUnsubscribe = null;
this._overlayCardsUnsubscribe?.();
this._overlayCardsUnsubscribe = null;
useWorldOverlayCardsStore(applicationPinia()).publish(null);
destroyWorldOverlay();
const overlayActionsStore = useWorldOverlayActionsStore(applicationPinia());
overlayActionsStore.connect(null);
+4
View File
@@ -30,7 +30,9 @@ import {
activateWorldOverlayAction,
destroyWorldOverlay,
initWorldOverlay,
setWorldOverlayViewCardMode,
subscribeWorldOverlayActions,
subscribeWorldOverlayCards,
} from '../overlays/worldOverlay.js';
import {
destroyDetection,
@@ -92,6 +94,8 @@ export class StyleManager extends ApplicationShell {
initWorldOverlay,
subscribeWorldOverlayActions,
activateWorldOverlayAction,
subscribeWorldOverlayCards,
setWorldOverlayViewCardMode,
destroyDetection,
initDetection,
cycleDetectionMode,
+41
View File
@@ -0,0 +1,41 @@
import { defineStore } from 'pinia';
import { markRaw, shallowRef } from 'vue';
/**
* The map cards the world overlay (src/overlays/worldOverlay.js) hands to its
* view to draw as elements. The application shell publishes the card list
* here whenever a card's content changes, and passes every painted frame's
* positions straight to the view's writer, outside reactivity.
*/
export const useWorldOverlayCardsStore = defineStore(
'gev-world-overlay-cards',
() => {
/**
* `{owner, style, cards: [{key, kind, title, details, accent, w, h, padX, padY,
* titleH, lineH}]}`, or null without an overlay host.
*/
const view = shallowRef(null);
/** @param {?object} next A fresh card list. */
function publish(next) {
view.value = next && markRaw(next);
}
let writer = null;
/**
* The view's frame writer, or null when it unmounts.
* @param {?(frames: object[], count: number) => void} next
*/
function connectWriter(next) {
writer = next;
}
/** Pass one frame's pooled positions to the view; they are not retained. */
function writeFrame(frames, count) {
writer?.(frames, count);
}
return { view, publish, connectWriter, writeFrame };
},
);
+2 -1
View File
@@ -10,7 +10,8 @@
.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; }
/* Outlined in its mark's colour (set inline), which ties the card to the mark. */
.gev-anno-card { fill: rgba(8,18,28,0.78); stroke: rgba(255,255,255,0.14); stroke-opacity: 0.6; 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; }
+2
View File
@@ -19,6 +19,7 @@ import AwarenessDirection from './panels/AwarenessDirection.vue';
import UiButton from '../kit/UiButton.vue';
import GlobeActions from './panels/GlobeActions.vue';
import WorldTimeline from './panels/WorldTimeline.vue';
import WorldOverlayCards from './panels/WorldOverlayCards.vue';
// The ACTIVE STYLE corner shows the style VisualSettings publishes.
const { style } = storeToRefs(useDisplayStore());
@@ -63,6 +64,7 @@ const vToastText = {
<div id="cesiumContainer"></div>
<div id="world-overlay-root" aria-hidden="true">
<canvas id="world-overlay-canvas"></canvas>
<WorldOverlayCards />
</div>
<div id="world-overlay-actions" class="world-overlay-accessibility" role="region" :aria-label="t('Visible map targets')">
<div id="world-overlay-action-list"><UiButton v-for="item in targets?.items ?? []" :key="item.key" :aria-label="item.label" :pressed="Boolean(item.selected)" :data-overlay-action-key="item.key" :onClick="onOverlayAction(targets.owner, item.key)">{{ item.label }}</UiButton></div>
+1 -1
View File
@@ -29,6 +29,6 @@ const drawStyle = (mark, child) =>
<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>
<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" :style="{ stroke: child.fill }" v-bind="child.card" /><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>
@@ -0,0 +1,84 @@
import { after, before, test } from 'node:test';
import assert from 'node:assert/strict';
import { installVueDom } from '../../../testSupport/vueDom.mjs';
let mount;
let nextTick;
let createPinia;
let WorldOverlayCards;
let useWorldOverlayCardsStore;
let uninstall;
before(async () => {
uninstall = installVueDom();
({ mount } = await import('@vue/test-utils'));
({ nextTick } = await import('vue'));
({ createPinia } = await import('pinia'));
WorldOverlayCards = (await import('./WorldOverlayCards.vue')).default;
({ useWorldOverlayCardsStore } =
await import('../../stores/worldOverlayCards.js'));
});
after(() => uninstall());
const style = { background: 'rgba(4, 12, 16, 0.82)', accent: '#6be8ff' };
const card = (overrides = {}) => ({
key: 'site:1',
kind: 'selected',
title: 'Picked site',
details: ['OPENSTREETMAP', 'Baton Rouge'],
accent: '#6be8ff',
w: 180,
h: 61,
padX: 12,
padY: 8,
titleH: 15,
lineH: 15,
...overrides,
});
test('map cards are drawn at their solved size and moved without re-rendering', async () => {
const pinia = createPinia();
const store = useWorldOverlayCardsStore(pinia);
const wrapper = mount(WorldOverlayCards, { global: { plugins: [pinia] } });
assert.equal(wrapper.findAll('.world-overlay-card').length, 0);
// The frame can arrive before the element exists: it is placed on mount.
store.writeFrame([{ key: 'site:1', x: 40.3, y: 52, scale: 1, alpha: 0.5 }], 1);
store.publish({ owner: {}, style, cards: [card()] });
await nextTick();
const element = wrapper.get('.world-overlay-card').element;
assert.deepEqual(
wrapper.findAll('.world-overlay-card p').map((line) => line.text()),
['Picked site', 'OPENSTREETMAP', 'Baton Rouge'],
);
assert.ok(element.classList.contains('is-selected'));
assert.equal(element.style.width, '180px');
assert.equal(element.style.height, '61px');
assert.equal(
wrapper.element.style.getPropertyValue('--overlay-card-bg'),
style.background,
'the plate uses the overlay\u2019s own tokens',
);
assert.equal(element.style.transform, 'translate3d(40px, 52px, 0)');
assert.equal(element.style.opacity, '0.5');
let renders = 0;
const observer = new MutationObserver(() => renders++);
observer.observe(wrapper.element, { childList: true, subtree: true });
store.writeFrame([{ key: 'site:1', x: 90, y: 20, scale: 0.8, alpha: 1 }], 1);
assert.equal(element.style.transform, 'translate3d(90px, 20px, 0) scale(0.8)');
assert.equal(element.style.opacity, '1');
await nextTick();
observer.disconnect();
assert.equal(renders, 0, 'a position write adds and removes no elements');
store.publish({ owner: {}, style, cards: [card({ key: 'jet:2', kind: 'tracked' })] });
await nextTick();
const cards = wrapper.findAll('.world-overlay-card');
assert.equal(cards.length, 1);
assert.ok(cards[0].element.classList.contains('is-tracked'));
assert.equal(cards[0].element.style.transform, '', 'a new card waits for its own frame');
wrapper.unmount();
store.writeFrame([{ key: 'jet:2', x: 1, y: 1, scale: 1, alpha: 1 }], 1);
});
+178
View File
@@ -0,0 +1,178 @@
<script setup>
import { computed, onBeforeUnmount } from 'vue';
import { storeToRefs } from 'pinia';
import { useWorldOverlayCardsStore } from '../../stores/worldOverlayCards.js';
// The selected and tracked map cards, drawn as elements. The world overlay
// still solves where each card goes and paints its leader; it publishes the
// card list here and writes every frame's position through `write`, so a
// camera move never re-renders this component.
const store = useWorldOverlayCardsStore();
const { view } = storeToRefs(store);
// The overlay's own tokens, so these cards and its canvas cards match.
const tokens = computed(() => {
const style = view.value?.style;
return style
? {
'--overlay-card-bg': style.background,
'--overlay-card-bg-selected': style.selectedBackground,
'--overlay-card-border': style.border,
'--overlay-card-border-selected': style.selectedBorder,
'--overlay-card-title': style.title,
'--overlay-card-detail': style.detail,
'--overlay-card-accent': style.accent,
'--overlay-card-font-card': style.fontTitle,
'--overlay-card-font-selected': style.fontSelected,
'--overlay-card-font-tracked': style.fontTrackedTitle,
'--overlay-card-font-detail': style.fontDetail,
'--overlay-card-font-tracked-detail': style.fontTrackedDetail,
}
: null;
});
const elements = new Map();
// The last position written for each card, so a card that mounts after its
// first frame is placed at once and an unchanged frame writes nothing.
const placed = new Map();
let stamp = 0;
function apply(element, state) {
const ratio = globalThis.devicePixelRatio || 1;
const x = Math.round(state.x * ratio) / ratio;
const y = Math.round(state.y * ratio) / ratio;
element.style.transform =
state.scale === 1
? `translate3d(${x}px, ${y}px, 0)`
: `translate3d(${x}px, ${y}px, 0) scale(${state.scale})`;
element.style.opacity = String(state.alpha);
}
function write(frames, count) {
stamp++;
for (let i = 0; i < count; i++) {
const frame = frames[i];
let state = placed.get(frame.key);
if (!state) {
state = { x: NaN, y: NaN, scale: NaN, alpha: NaN, stamp: 0 };
placed.set(frame.key, state);
}
state.stamp = stamp;
if (
state.x === frame.x &&
state.y === frame.y &&
state.scale === frame.scale &&
state.alpha === frame.alpha
)
continue;
state.x = frame.x;
state.y = frame.y;
state.scale = frame.scale;
state.alpha = frame.alpha;
const element = elements.get(frame.key);
if (element) apply(element, state);
}
if (placed.size > count)
for (const [key, state] of placed)
if (state.stamp !== stamp) placed.delete(key);
}
function bind(key, element) {
if (!element) {
elements.delete(key);
return;
}
elements.set(key, element);
const state = placed.get(key);
if (state) apply(element, state);
}
const box = (card) => ({
width: `${card.w}px`,
height: `${card.h}px`,
...(card.accent ? { '--overlay-card-accent': card.accent } : null),
'--overlay-card-pad-x': `${card.padX}px`,
'--overlay-card-pad-y': `${card.padY}px`,
'--overlay-card-title-h': `${card.titleH}px`,
'--overlay-card-line-h': `${card.lineH}px`,
});
store.connectWriter(write);
onBeforeUnmount(() => store.connectWriter(null));
</script>
<template>
<div id="world-overlay-cards" :style="tokens">
<div v-for="card in view?.cards ?? []" :key="card.key" :ref="(element) => bind(card.key, element)" class="world-overlay-card" :class="`is-${card.kind}`" :data-overlay-card="card.key" :style="box(card)">
<p class="world-overlay-card-title">{{ card.title }}</p>
<p v-for="(line, index) in card.details" :key="index" class="world-overlay-card-detail">{{ line }}</p>
</div>
</div>
</template>
<style scoped>
#world-overlay-cards {
position: absolute;
inset: 0;
pointer-events: none;
}
/* Sized to the rectangle the overlay solved with, so what is drawn is what
collided. Hidden until the first frame places it. */
.world-overlay-card {
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
box-sizing: border-box;
overflow: hidden;
padding: calc(var(--overlay-card-pad-y) - 1px) calc(var(--overlay-card-pad-x) - 1px);
border: 1px solid var(--overlay-card-border);
border-radius: 4px;
background: var(--overlay-card-bg);
opacity: 0;
transform-origin: 0 0;
will-change: transform, opacity;
contain: strict;
direction: ltr;
text-align: left;
white-space: nowrap;
}
.world-overlay-card p {
margin: 0;
unicode-bidi: plaintext;
}
.world-overlay-card-title {
height: var(--overlay-card-title-h);
color: var(--overlay-card-title);
font: var(--overlay-card-font-card);
line-height: var(--overlay-card-title-h);
}
.world-overlay-card-detail {
height: var(--overlay-card-line-h);
color: var(--overlay-card-detail);
font: var(--overlay-card-font-detail);
line-height: var(--overlay-card-line-h);
}
.world-overlay-card.is-selected {
border-color: var(--overlay-card-border-selected);
background: var(--overlay-card-bg-selected);
box-shadow: 0 4px 18px rgb(0 0 0 / 35%);
}
.world-overlay-card.is-selected .world-overlay-card-title {
font: var(--overlay-card-font-selected);
line-height: var(--overlay-card-title-h);
}
.world-overlay-card.is-tracked {
border-color: color-mix(in srgb, var(--overlay-card-accent) 70%, transparent);
border-radius: 5px;
box-shadow: 0 4px 18px rgb(0 0 0 / 35%);
text-align: center;
}
.world-overlay-card.is-tracked .world-overlay-card-title {
font: var(--overlay-card-font-tracked);
line-height: var(--overlay-card-title-h);
}
.world-overlay-card.is-tracked .world-overlay-card-detail {
font: var(--overlay-card-font-tracked-detail);
line-height: var(--overlay-card-line-h);
}
</style>