diff --git a/changelog.d/0000-card-accent-bar.md b/changelog.d/0000-card-accent-bar.md new file mode 100644 index 000000000..712e3f84c --- /dev/null +++ b/changelog.d/0000-card-accent-bar.md @@ -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). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bdfefe043..ea90a479d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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°`. diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 3dcd7f63b..4a6055704 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -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 diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index a771cc26f..d01c23a7b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/annotations/screenAnnotationRenderer.js b/src/annotations/screenAnnotationRenderer.js index 0509a47a5..d3b5e8509 100644 --- a/src/annotations/screenAnnotationRenderer.js +++ b/src/annotations/screenAnnotationRenderer.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; } diff --git a/src/annotations/screenAnnotationRenderer.test.mjs b/src/annotations/screenAnnotationRenderer.test.mjs index ce1fb4058..c7e0b065f 100644 --- a/src/annotations/screenAnnotationRenderer.test.mjs +++ b/src/annotations/screenAnnotationRenderer.test.mjs @@ -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(); diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index e0ca77038..6916c4d17 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -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, diff --git a/src/overlays/worldOverlay.test.mjs b/src/overlays/worldOverlay.test.mjs index 30dd021c2..0c9f3deab 100644 --- a/src/overlays/worldOverlay.test.mjs +++ b/src/overlays/worldOverlay.test.mjs @@ -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 { diff --git a/src/overlays/worldOverlayDraw.js b/src/overlays/worldOverlayDraw.js index e5db5d5b7..b2f01c064 100644 --- a/src/overlays/worldOverlayDraw.js +++ b/src/overlays/worldOverlayDraw.js @@ -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, diff --git a/src/overlays/worldOverlayDraw.test.mjs b/src/overlays/worldOverlayDraw.test.mjs index f5d89f758..cfb3e4222 100644 --- a/src/overlays/worldOverlayDraw.test.mjs +++ b/src/overlays/worldOverlayDraw.test.mjs @@ -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(); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 0adedd7a5..09f1a8324 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -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); diff --git a/src/ui/composition.js b/src/ui/composition.js index 3b95c1a8c..f7560413c 100644 --- a/src/ui/composition.js +++ b/src/ui/composition.js @@ -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, diff --git a/src/ui/stores/worldOverlayCards.js b/src/ui/stores/worldOverlayCards.js new file mode 100644 index 000000000..9f0caec0e --- /dev/null +++ b/src/ui/stores/worldOverlayCards.js @@ -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 }; + }, +); diff --git a/src/ui/styles/annotations.css b/src/ui/styles/annotations.css index a1b48c80c..f63cd53b9 100644 --- a/src/ui/styles/annotations.css +++ b/src/ui/styles/annotations.css @@ -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; } diff --git a/src/ui/views/SceneChromeView.vue b/src/ui/views/SceneChromeView.vue index 32900f42a..66405f4e6 100644 --- a/src/ui/views/SceneChromeView.vue +++ b/src/ui/views/SceneChromeView.vue @@ -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 = {