Files
gods-eye-view/src/data/cctvCards.js
T

428 lines
17 KiB
JavaScript

/**
* @module cctvCards
* @description Screen-space thumbnail cards for the citywide ambient CCTV
* tier (design: docs/superpowers/specs/2026-07-29-cctv-ambient-cards-design.md).
* Replaces the rejected world-space static-plane ring with small canvas cards
* anchored to each LOD-selected camera's screen position, showing its latest
* paced static frame.
*
* The shared world-overlay host owns projection, collision, paint, and hit
* rectangles. This module owns the source-side entry policy and every frame
* lifecycle concern: fetching, stable slots, retry pacing, persistence, and
* cache pruning.
*
* Division of labor mirrors vesselLabels: cctv.js OWNS selection and frame
* fetching (moveEnd-driven LOD + grace + the policy-gated pacer: cold-fill
* burst, then the 1-fetch/s steady gate — never per
* frame); this module owns the pure card policy helpers (declutter, frame
* persistence, retry pacing, cache pruning) and supplies ready-to-draw stable
* frame-slot references to the host.
*
* Zero-flicker contract (owner requirement):
* - An AMBIENT entry whose frame slot has no drawn frame yet (`stamp === 0`)
* renders NOTHING — no placeholder, no chip. The camera icon alone carries
* it. Sole documented exception (owner round 2, item B): a PINNED entry
* (hover-summoned, `entry.pinned === true`) paints its chrome immediately —
* explicit user gesture wants instant feedback — with an empty thumb area
* until its fast-tracked frame lands.
* - Entries hold a LIVE reference to their per-camera frame slot; when a
* fetched frame lands (applyFrameResult assigned onto the stable slot) the
* card appears/updates on the next postRender without any entry rebuild.
* - A failed fetch never clears a drawn frame (applyFrameResult persistence).
*
* Edge fade consistency (owner note): cards fade toward the screen edges via
* the SAME radial keyhole ramp the detection overlay uses
* (keyholeLabelAlphaFromGeometry — opaque inside the central keyhole, linear
* feather outward), multiplied with the shared distance ramp (cardAlpha).
*/
import { CCTV_THUMBNAIL_STYLE } from '../overlays/worldOverlayTokens.js';
// ─── Card geometry / style constants ───────────────────────────────────────
/** Thumbnail display size (CSS px, 16:9). */
export const CCTV_CARD_THUMB_W = 96;
export const CCTV_CARD_THUMB_H = 54;
/** Offscreen frame-canvas size (2x thumb for DPI crispness). */
export const CCTV_FRAME_CANVAS_W = 192;
export const CCTV_FRAME_CANVAS_H = 108;
/**
* Min screen separation between accepted card anchors (greedy declutter).
* Owner field test 2026-07-30: 130 read too sparse once the HUD safe-zone
* filter started dropping cards as well. 112 still exceeds the card box width
* (104 px) so accepted boxes cannot overlap. This is THE density knob.
*/
export const CCTV_CARD_MIN_SEP_PX = 112;
/** Ambient cards yield the top HUD band to mission text and controls. */
export const CCTV_CARD_SAFE_TOP_RATIO = 0.18;
export const CCTV_CARD_SAFE_TOP_MAX_PX = 150;
/** Bounded thumbnail cache (frame slots kept beyond the live card set). */
export const CCTV_FRAME_CACHE_MAX = 96;
// ─── Altitude scaling (owner field-test finding 5, 2026-07-29) ──────────────
// Owner-decided curve: cards are full size at street level, "start to get
// smaller" from ~1,800 m, "scale down progressively" to ~0.45 by 6,000 m,
// keep shrinking slightly and alpha-fade out across 7,500→9,500 m, and are
// fully hidden above that ("just the icons" at the highest zooms).
export const CCTV_CARD_SCALE_FULL_M = 1_800;
export const CCTV_CARD_SCALE_MID_M = 6_000;
export const CCTV_CARD_FADE_START_M = 7_500;
export const CCTV_CARD_FADE_END_M = 9_500;
export const CCTV_CARD_SCALE_AT_MID = 0.45;
export const CCTV_CARD_SCALE_MIN = 0.35;
// ─── Frame-fetch pacing (owner field-test finding 3, 2026-07-29) ────────────
/** Steady-state global gate: one card-frame fetch per second. */
export const CCTV_CARD_FETCH_STEADY_SPACING_MS = 1_000;
/** Cold-fill burst spacing between fetch launches. */
export const CCTV_CARD_FETCH_BURST_SPACING_MS = 250;
/** Cold-fill burst max concurrent in-flight fetches. */
export const CCTV_CARD_FETCH_BURST_LIMIT = 4;
/** Distance fade: full inside 70%, gone past this (metro scale stays live). */
const FADE_DISTANCE_M = 150000;
/** Base per-camera retry spacing; doubles per consecutive failure. */
const RETRY_BASE_MS = 15_000;
const RETRY_MAX_MS = 5 * 60 * 1000;
export const CCTV_OVERLAY_SOURCE_ID = 'cctv';
export const CCTV_THUMBNAIL_ALTITUDE_SCALE = Object.freeze({
fullEnd: CCTV_CARD_SCALE_FULL_M,
midEnd: CCTV_CARD_SCALE_MID_M,
end: CCTV_CARD_FADE_END_M,
midValue: CCTV_CARD_SCALE_AT_MID,
endValue: CCTV_CARD_SCALE_MIN,
smoothToMid: true,
});
// ─── Pure helpers (exported for unit tests — no Cesium, no DOM) ────────────
/**
* Returns whether an ambient card anchor is outside the protected top HUD
* band. A user-pinned hover card may intentionally enter the band because it
* is temporary and requested; persistent ambient cards yield to the HUD.
*
* @param {Object} input
* @param {number} input.sy - Anchor Y in CSS pixels.
* @param {number} input.viewH - Viewport height in CSS pixels.
* @param {boolean} [input.pinned=false]
* @returns {boolean}
*/
export function isCctvCardAnchorSafe({ sy, viewH, pinned = false } = {}) {
if (pinned) return Number.isFinite(sy);
if (!Number.isFinite(sy) || !Number.isFinite(viewH) || viewH <= 0)
return false;
const safeTop = Math.min(
CCTV_CARD_SAFE_TOP_MAX_PX,
viewH * CCTV_CARD_SAFE_TOP_RATIO,
);
return sy >= safeTop;
}
/**
* Greedy nearest-first screen-space declutter over LOD-selected candidates
* (the vessel/FIRMS min-separation idiom): accept a candidate only when its
* anchor keeps `minSepPx` from every already-accepted anchor, so cards never
* pile onto each other or bury neighboring camera icons.
* @param {Array<{id:string,sx:number,sy:number,distanceKm:number}>} candidates
* @param {Object} [options]
* @param {number} [options.minSepPx]
* @param {number} [options.limit]
* @returns {string[]} Accepted ids, nearest-first.
*/
export function declutterCctvCards(
candidates,
{ minSepPx = CCTV_CARD_MIN_SEP_PX, limit = Infinity } = {},
) {
const valid = (Array.isArray(candidates) ? candidates : [])
.filter(
(c) =>
c &&
typeof c.id === 'string' &&
c.id &&
Number.isFinite(c.sx) &&
Number.isFinite(c.sy),
)
.sort(
(a, b) =>
(a.distanceKm ?? Infinity) - (b.distanceKm ?? Infinity) ||
a.id.localeCompare(b.id),
);
const minSq = minSepPx * minSepPx;
const accepted = [];
const acceptedIds = [];
for (const candidate of valid) {
if (acceptedIds.length >= limit) break;
let clear = true;
for (let i = 0; i < accepted.length; i++) {
const dx = candidate.sx - accepted[i].sx;
const dy = candidate.sy - accepted[i].sy;
if (dx * dx + dy * dy < minSq) {
clear = false;
break;
}
}
if (!clear) continue;
accepted.push(candidate);
acceptedIds.push(candidate.id);
}
return acceptedIds;
}
/**
* Altitude-driven card scale + opacity (owner field-test finding 5,
* 2026-07-29 — curve constants above). Piecewise, monotonic non-increasing
* in both channels:
* - ≤1,800 m: full size, fully opaque.
* - 1,800→6,000 m: smoothstep shrink 1.0 → 0.45 (opaque).
* - 6,000→9,500 m: slight continued linear shrink 0.45 → 0.35; opaque
* until 7,500 m, then a linear alpha fade to 0 at 9,500 m.
* - ≥9,500 m: fully hidden (alpha 0) — entries may persist, nothing paints.
* @param {number} cameraHeightM - Viewer camera height above the ellipsoid.
* @returns {{scale:number, alpha:number}}
*/
export function cardScaleForAltitude(cameraHeightM) {
const h = Number.isFinite(cameraHeightM) ? Math.max(0, cameraHeightM) : 0;
if (h <= CCTV_CARD_SCALE_FULL_M) return { scale: 1, alpha: 1 };
if (h <= CCTV_CARD_SCALE_MID_M) {
const t =
(h - CCTV_CARD_SCALE_FULL_M) /
(CCTV_CARD_SCALE_MID_M - CCTV_CARD_SCALE_FULL_M);
const s = t * t * (3 - 2 * t); // smoothstep — no visible kink at either end
return { scale: 1 + (CCTV_CARD_SCALE_AT_MID - 1) * s, alpha: 1 };
}
const t = Math.min(
1,
(h - CCTV_CARD_SCALE_MID_M) /
(CCTV_CARD_FADE_END_M - CCTV_CARD_SCALE_MID_M),
);
const scale =
CCTV_CARD_SCALE_AT_MID + (CCTV_CARD_SCALE_MIN - CCTV_CARD_SCALE_AT_MID) * t;
if (h >= CCTV_CARD_FADE_END_M)
return { scale: CCTV_CARD_SCALE_MIN, alpha: 0 };
if (h <= CCTV_CARD_FADE_START_M) return { scale, alpha: 1 };
const alpha =
1 -
(h - CCTV_CARD_FADE_START_M) /
(CCTV_CARD_FADE_END_M - CCTV_CARD_FADE_START_M);
return { scale, alpha };
}
/**
* Cold-fill burst pacing policy (owner field-test finding 3, 2026-07-29), as
* a pure decision so the pacer tick stays trivially testable. While any
* selected card still lacks its FIRST frame (`coldFill`), up to
* `CCTV_CARD_FETCH_BURST_LIMIT` fetches may be in flight with
* `CCTV_CARD_FETCH_BURST_SPACING_MS` between launches; once every selected
* card has a first frame the layer drops back to the steady-state global
* gate (single in-flight fetch, one launch per second — an in-flight fetch
* still blocks the tick, so slow responses only lower the rate).
* @param {Object} [input]
* @param {boolean} [input.coldFill] - A selected card lacks its first frame.
* @param {number} [input.inFlight] - Current in-flight fetch count.
* @param {number} [input.sinceLastLaunchMs] - Ms since the last fetch launch.
* @returns {{mode:('burst'|'steady'), launch:boolean}}
*/
export function cardFetchPolicy({
coldFill = false,
inFlight = 0,
sinceLastLaunchMs = Infinity,
} = {}) {
if (coldFill) {
return {
mode: 'burst',
launch:
inFlight < CCTV_CARD_FETCH_BURST_LIMIT &&
sinceLastLaunchMs >= CCTV_CARD_FETCH_BURST_SPACING_MS,
};
}
return {
mode: 'steady',
launch:
inFlight === 0 && sinceLastLaunchMs >= CCTV_CARD_FETCH_STEADY_SPACING_MS,
};
}
/**
* Creates an empty per-camera frame slot. Slots are STABLE objects — card
* entries reference them directly, and fetch results are Object.assign-ed
* onto them (via applyFrameResult) so the renderer sees new frames without
* an entry rebuild.
* @returns {{frame:*, stamp:number, failCount:number, lastAttemptAt:number}}
*/
export function createFrameSlot() {
return { frame: null, stamp: 0, failCount: 0, lastAttemptAt: 0 };
}
/**
* The no-flicker persistence rule, as a pure state transition. A successful
* fetch replaces the frame and stamps it; a FAILED fetch keeps the previous
* frame and stamp untouched (the drawn card persists) and only bumps the
* failure count for retry backoff.
* @param {{frame:*, stamp:number, failCount:number, lastAttemptAt:number}} prev
* @param {{ok:boolean, frame?:*}} result
* @param {number} nowMs
* @returns {{frame:*, stamp:number, failCount:number, lastAttemptAt:number}}
*/
export function applyFrameResult(prev, result, nowMs) {
const base = prev || createFrameSlot();
if (result?.ok && result.frame) {
return {
frame: result.frame,
stamp: nowMs,
failCount: 0,
lastAttemptAt: nowMs,
};
}
return {
frame: base.frame,
stamp: base.stamp,
failCount: (base.failCount || 0) + 1,
lastAttemptAt: nowMs,
};
}
/**
* Per-camera attempt spacing: a base gap between attempts, doubling per
* consecutive failure, capped (a dead source settles at one try per 5 min).
* @param {number} failCount
* @returns {number}
*/
export function frameRetryDelayMs(failCount) {
const fails = Math.max(0, Number(failCount) || 0);
return Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** fails);
}
/**
* Whether a frame fetch should be attempted for a slot: the slot must be
* stale against its source cadence (or never filled), and past the retry
* spacing since its last attempt.
* @param {{stamp:number, failCount:number, lastAttemptAt:number}|null} slot
* @param {number} refreshMs - Source cadence (staticFrameRefreshMs).
* @param {number} nowMs
* @returns {boolean}
*/
export function frameFetchDue(slot, refreshMs, nowMs) {
if (!slot) return false;
const stale = !(slot.stamp > 0) || nowMs - slot.stamp >= refreshMs;
if (!stale) return false;
if (!(slot.lastAttemptAt > 0)) return true;
return nowMs - slot.lastAttemptAt >= frameRetryDelayMs(slot.failCount);
}
/**
* Plans the bounded-LRU prune of the thumbnail cache: live card ids are
* NEVER dropped (their persisted frames are the no-flicker guarantee, grace
* included); beyond them the newest-stamped slots fill the cap and the rest
* are dropped.
* @param {Array<{id:string, stamp:number}>} slots - Cached slot ids + stamps.
* @param {Iterable<string>} keepIds - Live card ids (selection + grace).
* @param {number} [cap]
* @returns {string[]} Ids to drop from the cache.
*/
export function planFrameCachePrune(
slots,
keepIds,
cap = CCTV_FRAME_CACHE_MAX,
) {
const keep = new Set(keepIds);
const spare = (Array.isArray(slots) ? slots : [])
.filter((slot) => slot && typeof slot.id === 'string' && !keep.has(slot.id))
.sort(
(a, b) => (b.stamp || 0) - (a.stamp || 0) || a.id.localeCompare(b.id),
);
const capacity = Math.max(0, cap - keep.size);
return spare.slice(capacity).map((slot) => slot.id);
}
/**
* Build the normalized source-side presentation for one CCTV thumbnail. The
* stable frame slot is passed by reference; the host reads `slot.frame` on
* every paint, so a successful fetch appears without rebuilding the entry and
* a failed fetch cannot clear the last successful pixels.
* @param {object} input
* @param {string} input.id
* @param {object} input.position
* @param {string} input.title
* @param {{frame:*,stamp:number}} input.frameSlot
* @param {number} [input.rank=0]
* @param {boolean} [input.pinned=false]
* @param {boolean} [input.active=false]
* @param {number} [input.gapPx=16]
* @returns {object}
*/
export function createCctvThumbnailOverlayEntry({
id,
position,
title,
frameSlot,
rank = 0,
pinned = false,
active = false,
gapPx = 16,
} = {}) {
const hostGap = Math.max(14, (Number(gapPx) || 14) + 6);
return {
id,
position,
variant: 'thumbnail',
paintLane: 'thumbnail',
title,
details: [],
image: frameSlot,
requireImage: true,
accent: CCTV_THUMBNAIL_STYLE.accent,
priority: active ? 1_000_000 : pinned ? 900_000 : 100_000 - rank,
selected: false,
pinned,
protected: active,
active,
collisionGroup: 'ambient-card',
zIndex: 50,
interactive: true,
minDistance: 0,
maxDistance: FADE_DISTANCE_M,
distanceFadeStartRatio: 0.7,
altitudeScale: CCTV_THUMBNAIL_ALTITUDE_SCALE,
altitudeFadeStart: CCTV_CARD_FADE_START_M,
altitudeFadeEnd: CCTV_CARD_FADE_END_M,
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
gapPx: hostGap,
leaderOffsetPx: Math.max(2, hostGap - 6),
verticalOnly: true,
// CCTV shipped as a STATELESS per-frame rebuild: cards popped in and out on
// the frame the geometry said so, and above/below was re-decided from
// geometry every frame with no memory. Opting out of the shared arbiter's
// min-lifetime, re-entry cooldown, fades and sticky corner restores that
// feel — the hysteresis is what produced sticky mid-screen below-placements
// and the ~1.5 s delay before a card could come back after a nadir sweep.
stateless: true,
// The shipped per-frame pass rejected cards whose ANCHORS were closer than
// this (scaled with the card). Rectangle overlap alone let them stack about
// twice as densely, because the leader gap does not shrink with the card.
minAnchorSeparationPx: CCTV_CARD_MIN_SEP_PX,
viewportMargin: 4,
viewportPadding: 60,
safeTopRatio: CCTV_CARD_SAFE_TOP_RATIO,
safeTopMaxPx: CCTV_CARD_SAFE_TOP_MAX_PX,
pinnedBypassesSafeTop: true,
thumbnailWidth: CCTV_CARD_THUMB_W,
thumbnailHeight: CCTV_CARD_THUMB_H,
thumbnailPadX: CCTV_THUMBNAIL_STYLE.padding,
thumbnailPadTop: CCTV_THUMBNAIL_STYLE.padding,
thumbnailPadBottom: CCTV_THUMBNAIL_STYLE.padding,
thumbnailTitleGap: 2,
thumbnailTitleHeight: CCTV_THUMBNAIL_STYLE.titleHeight,
thumbnailTitleChars: CCTV_THUMBNAIL_STYLE.titleChars,
thumbnailBackground: CCTV_THUMBNAIL_STYLE.background,
thumbnailTitleColor: CCTV_THUMBNAIL_STYLE.titleColor,
thumbnailTitleFont: CCTV_THUMBNAIL_STYLE.titleFont,
thumbnailLeaderColor: CCTV_THUMBNAIL_STYLE.leader,
thumbnailRuleColor: CCTV_THUMBNAIL_STYLE.rule,
thumbnailRuleHeight: CCTV_THUMBNAIL_STYLE.ruleHeight,
thumbnailRadius: CCTV_THUMBNAIL_STYLE.radius,
};
}