scripts/bbox-overlay.jsjavascript
/**
* @module bbox-overlay
* @description
* Bbox match-state overlay — layer for Ghostmaxxing.
*
* Draws a bounding box around the detected face on its dedicated canvas
* (`#bboxOverlay`), colored to reflect the latest match state computed by the
* engine (matched / eluded / unclear / unknown). Renders the current metrics
* next to the box: live detection score, live distance and closest match ID.
*
* Reacts to events on `state.gstmxxEvents`. Does not mutate the engine.
*
* Color convention:
* red = identified (matched)
* green = eluded
* blue = unclear (post-makeup match below threshold against a saved face)
* grey = unknown (also the fallback for any unmapped state)
*
* Overlay suppression:
* When the engine reports a `matchStateChanged` triggered by an explicit user
* action (`source: 'scan' | 'save' | 'find' | 'efficacy'`), the corresponding
* on-screen overlay covers the video for a moment. To avoid double visuals,
* the bbox is suppressed for `OVERLAY_SUPPRESS_MS` after such an event. The
* auto-find loop (`source: 'auto'`) never suppresses.
*/
import { state } from './state.js';
import { t } from './i18n.js';
import { avgPoint, drawClosedPath, drawOpenPath, roundRect, syncMirror, syncSize } from './utils.js';
// ---------- Style constants ----------
/**
* Stroke / label color for each known match state.
* Exported so tests can assert color choices.
* @type {{ matched: string, eluded: string, unclear: string, unknown: string }}
*/
export const COLORS = {
matched: 'rgba(255, 122, 122, 0.95)',
eluded: 'rgba(61, 220, 151, 0.95)',
unclear: 'rgba(122, 192, 255, 0.95)',
'partial-elusion': 'rgba(122, 192, 255, 0.95)',
unknown: 'rgba(170, 180, 195, 0.85)',
};
const LINE_WIDTH_CSS = 2.6;
// Same font as the UI logger. Values are
// in CSS pixels and get multiplied by the canvas/CSS scale in `drawLabels` so
// the visual size stays consistent across resolutions (on mobile the canvas is
// 1920×1080 but rendered at ~350px wide: 12 canvas-pixels would be unreadable
// without scaling).
const LABEL_FONT_FAMILY = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace";
const LABEL_FONT_SIZE_CSS = 12;
const LABEL_LINE_HEIGHT_CSS = 16;
const LABEL_PADDING_CSS = 6;
const LABEL_GAP_CSS = 8;
/** Suppression window after an overlay-producing user action, in milliseconds. */
export const OVERLAY_SUPPRESS_MS = 4000;
/** Numeric `matchStateChanged.detail` fields that map 1:1 onto `view`. */
const COPYABLE_FIELDS = ['liveMinDist', 'obfMinDist', 'liveMinId', 'obfMinId'];
export const OVERLAY_MODE_STORAGE_KEY = 'ghostati-overlay-mode-v1';
export const OVERLAY_MODES = {
bbox: 'Vista: bbox',
mesh: 'Vista: mesh',
entrambi: 'Vista: entrambi',
'2d': 'Vista: 2D',
};
// ---------- Module state ----------
/** Pristine values for the rendering view; used as the reset baseline. */
const INITIAL_VIEW = Object.freeze({
matchState: 'unknown',
liveMinDist: null,
obfMinDist: null,
liveMinId: null,
obfMinId: null,
overlayMode: 'bbox',
lastLandmarks3d: null,
lastDetection: null,
});
/**
* Current rendering view — single source of truth for what `drawLabels` and
* `currentColor` display. Mutated in place (never reassigned) so the export
* keeps a stable reference for tests.
*/
export const view = { ...INITIAL_VIEW };
let canvas = null;
let overlayEl = null;
let ctx = null;
/** Monotonic deadline (`performance.now()` domain). While `now < this`, skip drawing. */
let suppressUntil = 0;
// ---------- Bootstrap ----------
window.addEventListener('ghostatiReady', init, { once: true });
/**
* Resolves the bbox canvas and overlay references after `main.js` has finished
* preparing state, DOM elements, and `window.gstmxx`, then wires the shared
* event bus that drives all bbox, mesh, and 2D scaffold repainting. The setup is
* deliberately light enough for tests to call with stub canvases in the DOM.
*
* @returns {boolean} True when required DOM nodes are present and listeners are attached.
* @see main - Dispatches `ghostatiReady` when the lab environment is ready.
* @see onDetection - Receives live face-api detection events.
* @see onLandmarks3d - Receives MediaPipe mesh events.
* @see onMatchStateChanged - Receives scan/save/find/auto match-state events.
* @see onDbChanged - Receives database changes that may reset overlay state.
*/
export function init() {
canvas = document.getElementById('bboxOverlay');
overlayEl = document.getElementById('overlay');
if (!canvas || !overlayEl) {
console.warn(t('console_bbox_overlay_missing'));
return false;
}
ctx = canvas.getContext('2d');
const persistedMode = readPersistedOverlayMode();
if (persistedMode) view.overlayMode = persistedMode;
state.gstmxxEvents.addEventListener('detection', onDetection);
state.gstmxxEvents.addEventListener('landmarks3d', onLandmarks3d);
state.gstmxxEvents.addEventListener('matchStateChanged', onMatchStateChanged);
state.gstmxxEvents.addEventListener('dbChanged', onDbChanged);
return true;
}
// ---------- Bus listeners ----------
/**
* Caches the latest face-api detection result and repaints the bbox layer. In
* Ghostmaxxing this is the bridge from the recognition engine's live camera
* result to the visual box, metrics label, or 2D landmark scaffold.
*
* @param {CustomEvent} e - `detection` event carrying the latest face-api result in `detail.result`.
* @returns {void}
* @see init - Registers this on `state.gstmxxEvents`.
* @see detectFaceInCam - Dispatches detection events from the 2D engine.
* @see renderOverlay - Repaints after the cached detection changes.
*/
export function onDetection(e) {
const result = e.detail && e.detail.result;
view.lastDetection = result || null;
renderOverlay();
}
/**
* Caches the latest 478-point MediaPipe mesh and repaints the bbox layer when
* the selected overlay mode includes the 3D mesh. This lets the overlay update
* from MediaPipe frames without asking the 2D engine to redraw.
*
* @param {CustomEvent} e - `landmarks3d` event carrying normalized mesh points in `detail.landmarks`.
* @returns {void}
* @see init - Registers this on `state.gstmxxEvents`.
* @see tick - Dispatches `landmarks3d` from `mediapipe-loop.js`.
* @see renderOverlay - Repaints after cached mesh landmarks change.
*/
export function onLandmarks3d(e) {
const landmarks = e.detail && e.detail.landmarks;
view.lastLandmarks3d = Array.isArray(landmarks) ? landmarks : null;
renderOverlay();
}
/**
* Applies a unified `matchStateChanged` payload to the overlay view and starts
* temporary suppression for explicit user-action sources. Auto-find events keep
* drawing continuously, while scan/save/find/efficacy overlays get a short
* quiet window so the bbox does not compete with the result visualization.
*
* @param {CustomEvent} e - Match-state event whose `detail` may include top-level or nested face-api metrics.
* @returns {void}
* @see init - Registers this on `state.gstmxxEvents`.
* @see tick - Dispatches auto match-state updates from `auto-find-loop.js`.
* @see main - Dispatches scan/save match-state updates during user workflows.
* @see renderOverlay - Repaints after match colors and labels are updated.
*/
export function onMatchStateChanged(e) {
const d = e.detail;
if (!d) return;
const faceapiSection = d.faceapi || null;
const nextMatchState = d.overall || d.detectionState || faceapiSection?.detectionState;
if (nextMatchState) view.matchState = nextMatchState;
for (const k of COPYABLE_FIELDS) {
if (k in d) view[k] = d[k];
else if (faceapiSection && k in faceapiSection) view[k] = faceapiSection[k];
}
if (d.source && d.source !== 'auto') suppressUntil = performance.now() + OVERLAY_SUPPRESS_MS;
renderOverlay();
}
/**
* Resets match metrics when the local face archive is cleared, while preserving
* the current overlay mode and cached camera data. Database additions and stat
* updates do not invalidate the current visual match state, so they are ignored.
*
* @param {CustomEvent} e - Database event whose `detail.count` is zero after archive clearing.
* @returns {void}
* @see init - Registers this on `state.gstmxxEvents`.
* @see clearDb - Dispatches the zero-count database event after wiping the archive.
*/
export function onDbChanged(e) {
if (e.detail?.count === 0) Object.assign(view, {
...INITIAL_VIEW,
overlayMode: view.overlayMode,
lastLandmarks3d: view.lastLandmarks3d,
lastDetection: view.lastDetection,
});
}
/**
* Changes which landmark visualization is painted on `#bboxOverlay`, persists
* the user's choice, and repaints immediately. The lab uses this to switch
* between bbox metrics, 3D mesh dots, both layers, and the detailed 2D scaffold.
*
* @param {'bbox'|'mesh'|'entrambi'|'2d'} mode - Requested overlay mode.
* @returns {'bbox'|'mesh'|'entrambi'|'2d'} Active overlay mode after validation.
* @see init - Applies the persisted mode on startup.
* @see main - Calls this from the overlay mode button.
* @see applyView - Calls this when the lab UI switches camera/points/mesh views.
* @see renderOverlay - Repaints the chosen mode.
*/
export function setOverlayMode(mode) {
if (!Object.keys(OVERLAY_MODES).includes(mode)) return view.overlayMode;
view.overlayMode = mode;
persistOverlayMode(mode);
renderOverlay();
return view.overlayMode;
}
// ---------- Geometry / rendering helpers ----------
/**
* Clears the dedicated bbox canvas before each repaint, preventing stale boxes,
* labels, or mesh dots from lingering when the current mode or detection changes.
*
* @returns {void}
* @see renderOverlay - Clears before drawing the active overlay mode.
*/
function clearBbox() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
/**
* Repaints `#bboxOverlay` from the cached detection, match, and MediaPipe mesh
* streams. It mirrors and sizes the canvas to the main overlay, honors temporary
* suppression after explicit user actions, then draws the selected bbox, mesh,
* combined, or detailed 2D scaffold view.
*
* @returns {void}
* @see onDetection - Calls this after face-api detection updates.
* @see onLandmarks3d - Calls this after MediaPipe mesh updates.
* @see onMatchStateChanged - Calls this after match-state updates.
* @see setOverlayMode - Calls this after a mode change.
*/
function renderOverlay() {
if (!canvas || !overlayEl || !ctx) return;
syncSize(canvas, overlayEl);
syncMirror(canvas, overlayEl);
clearBbox();
if (performance.now() < suppressUntil) return;
if (view.overlayMode === '2d') {
drawFaceapiScaffold2d();
return;
}
const box = getLastBox();
if (includesBbox() && box) {
drawBox(box);
drawLabels(box, extractScore(view.lastDetection));
}
if (includesMesh()) drawMesh478();
}
/**
* Reports whether the current overlay mode should draw the face bounding box
* and metric label block.
*
* @returns {boolean} True for bbox-only and combined modes.
* @see renderOverlay - Uses this to decide whether to call `drawBox` and `drawLabels`.
*/
function includesBbox() {
return view.overlayMode === 'bbox' || view.overlayMode === 'entrambi';
}
/**
* Reports whether the current overlay mode should draw the cached 478-point
* MediaPipe mesh.
*
* @returns {boolean} True for mesh-only and combined modes.
* @see renderOverlay - Uses this to decide whether to call `drawMesh478`.
*/
function includesMesh() {
return view.overlayMode === 'mesh' || view.overlayMode === 'entrambi';
}
/**
* Tells the 2D engine whether it should request detailed face-api landmarks for
* the current overlay mode. The detailed scaffold mode needs landmark chains,
* while bbox and mesh modes can use cheaper detection data.
*
* @param {'bbox'|'mesh'|'entrambi'|'2d'} [mode=view.overlayMode] - Overlay mode to inspect.
* @returns {boolean} True when detailed face-api landmarks are required.
* @see runEffectPass - Uses this to choose the face-api detection pipeline.
*/
export function overlayModeNeedsDetailedFaceapi(mode = view.overlayMode) {
return mode === '2d';
}
/**
* Resizes the latest face-api detection to the bbox canvas coordinate space and
* extracts the current bounding rectangle for drawing.
*
* @returns {{x:number, y:number, width:number, height:number}|null} Current box in canvas pixels.
* @see renderOverlay - Uses this before drawing bbox metrics.
* @see extractBox - Handles the two face-api result shapes.
*/
function getLastBox() {
if (!view.lastDetection) return null;
const resized = faceapi.resizeResults(view.lastDetection, {
width: canvas.width,
height: canvas.height,
});
return extractBox(resized) || null;
}
/**
* Reads the persisted overlay mode from localStorage and validates it against
* the known Ghostmaxxing overlay modes.
*
* @returns {'bbox'|'mesh'|'entrambi'|'2d'|null} Persisted mode, or null when unavailable.
* @see init - Applies the persisted mode during overlay startup.
*/
function readPersistedOverlayMode() {
try {
const raw = localStorage.getItem(OVERLAY_MODE_STORAGE_KEY);
return Object.keys(OVERLAY_MODES).includes(raw) ? raw : null;
} catch {
return null;
}
}
/**
* Stores the user's overlay mode preference. Storage failures are ignored
* because overlay rendering should keep working even when localStorage is
* unavailable or blocked.
*
* @param {'bbox'|'mesh'|'entrambi'|'2d'} mode - Overlay mode to persist.
* @returns {void}
* @see setOverlayMode - Persists each accepted mode change.
*/
function persistOverlayMode(mode) {
try {
localStorage.setItem(OVERLAY_MODE_STORAGE_KEY, mode);
} catch {
}
}
/**
* Pulls the bounding box out of a face-api result, handling both shapes used in
* the project: plain detections expose `result.box`, while detailed landmark
* detections expose `result.detection.box`.
*
* @param {object} resized - face-api result already passed through `resizeResults`.
* @returns {{x:number, y:number, width:number, height:number}|undefined} Extracted bounding box.
* @see getLastBox - Extracts the cached live detection box.
* @see drawFaceapiScaffold2d - Extracts the detailed scaffold box.
*/
export function extractBox(resized) {
return (resized.detection && resized.detection.box) || resized.box;
}
/**
* Pulls the detection confidence score out of a face-api result, matching the
* same plain versus detailed result shapes handled by `extractBox`.
*
* @param {object} result - face-api result to inspect.
* @returns {number|null} Detection score, or null when no numeric score exists.
* @see renderOverlay - Passes this score into bbox metric labels.
* @see drawFaceapiScaffold2d - Uses this score when falling back to bbox labels.
*/
export function extractScore(result) {
if (result.detection && typeof result.detection.score === 'number') return result.detection.score;
if (typeof result.score === 'number') return result.score;
return null;
}
/**
* Formats numeric bbox metrics for the overlay label block, using an em dash
* placeholder when a metric is missing, invalid, or not yet computed.
*
* @param {*} value - Metric value to display.
* @param {number} digits - Decimal places for finite numbers.
* @returns {string} Formatted metric or placeholder.
* @see drawLabels - Formats detection score, distance, and face id values.
*/
export function fmt(value, digits) {
return (typeof value === 'number' && Number.isFinite(value)) ? value.toFixed(digits) : '—';
}
/**
* Picks the stroke and label color for the latest match state, mapping
* identified, eluded, unclear, partial-elusion, and unknown outcomes to the
* overlay color convention documented by this module.
*
* @returns {string} CSS color for the current match state.
* @see drawBox - Uses this for the bbox stroke.
* @see drawMesh478 - Uses this for mesh dots.
* @see drawFaceapiScaffold2d - Uses this for scaffold strokes.
* @see drawLabels - Uses this for metric text.
*/
export function currentColor() {
return COLORS[view.matchState] || COLORS.unknown;
}
/**
* Computes the intrinsic-canvas to CSS-pixel scale so strokes, dots, and text
* stay visually stable on mobile, high-resolution canvases, and the lab's
* digital zoom. The bounding rect is used because it includes CSS transforms.
*
* @returns {number} Canvas-pixels per CSS pixel.
* @see drawBox - Scales bbox stroke width.
* @see drawMesh478 - Scales mesh dot radius.
* @see drawFaceapiScaffold2d - Scales scaffold strokes and anchors.
* @see drawLabels - Scales the metric label block.
*/
function cssScale() {
const rect = canvas.getBoundingClientRect();
const cssW = rect.width || canvas.clientWidth || canvas.width;
return canvas.width / cssW;
}
/**
* Strokes the live face bounding rectangle using the current match-state color
* and scaled line width, making the box readable across the responsive canvas.
*
* @param {{x:number, y:number, width:number, height:number}} box - Bounding box in canvas pixels.
* @returns {void}
* @see renderOverlay - Draws the standard bbox mode.
* @see drawFaceapiScaffold2d - Draws the fallback box for detailed 2D mode.
*/
function drawBox(box) {
const scale = cssScale();
ctx.save();
ctx.lineWidth = LINE_WIDTH_CSS * scale;
ctx.strokeStyle = currentColor();
ctx.strokeRect(box.x, box.y, box.width, box.height);
ctx.restore();
}
/**
* Draws the cached MediaPipe 478-point face mesh as colored dots on the bbox
* canvas. It only renders complete meshes so partial or malformed landmark
* payloads do not leave misleading points on the video.
*
* @returns {void}
* @see renderOverlay - Calls this for mesh and combined overlay modes.
* @see onLandmarks3d - Supplies the cached mesh data this function reads.
*/
function drawMesh478() {
if (!Array.isArray(view.lastLandmarks3d) || view.lastLandmarks3d.length !== 478) return;
const scale = cssScale();
const radius = 1.5 * scale;
ctx.save();
ctx.fillStyle = currentColor();
for (const point of view.lastLandmarks3d) {
ctx.beginPath();
ctx.arc(point.x * canvas.width, point.y * canvas.height, radius, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
/**
* Draws the detailed 2D face-api scaffold for the `2d` overlay mode: bounding
* box, eye axis, eye contours, jaw, nose, mouth, anchor dots, and a small live
* detection label. If detailed landmarks are not present, it falls back to the
* standard bbox and metrics label.
*
* @returns {void}
* @see renderOverlay - Calls this when `view.overlayMode` is `2d`.
* @see overlayModeNeedsDetailedFaceapi - Requests the detailed landmark data needed here.
* @see drawFaceapiScaffoldLabels - Draws the live-scope scaffold label.
*/
function drawFaceapiScaffold2d() {
const resized = getResizedDetection();
const box = resized && extractBox(resized);
const landmarks = resized && resized.landmarks;
if (!landmarks || typeof landmarks.getLeftEye !== 'function') {
if (box) {
drawBox(box);
drawLabels(box, extractScore(view.lastDetection));
}
return;
}
const leftEye = landmarks.getLeftEye();
const rightEye = landmarks.getRightEye();
const nose = landmarks.getNose();
const jaw = landmarks.getJawOutline();
const mouth = landmarks.getMouth();
const scale = cssScale();
ctx.save();
ctx.lineWidth = 2.2 * scale;
ctx.strokeStyle = currentColor();
ctx.strokeRect(box.x, box.y, box.width, box.height);
const leftCenter = avgPoint(leftEye);
const rightCenter = avgPoint(rightEye);
ctx.beginPath();
ctx.moveTo(leftCenter.x, leftCenter.y);
ctx.lineTo(rightCenter.x, rightCenter.y);
ctx.stroke();
drawClosedPath(ctx, leftEye, null, 'rgba(255, 122, 122, 0.85)', 2 * scale);
drawClosedPath(ctx, rightEye, null, 'rgba(255, 122, 122, 0.85)', 2 * scale);
drawOpenPath(ctx, jaw, 'rgba(159, 122, 234, 0.88)', 2 * scale);
drawOpenPath(ctx, nose, 'rgba(61, 220, 151, 0.88)', 2 * scale);
drawClosedPath(ctx, mouth, null, 'rgba(255, 204, 102, 0.88)', 2 * scale);
ctx.fillStyle = 'rgba(255, 255, 255, 0.92)';
for (const point of [leftCenter, rightCenter, avgPoint(nose.slice(3)), avgPoint(mouth.slice(0, 7))]) {
ctx.beginPath();
ctx.arc(point.x, point.y, 3.4 * scale, 0, Math.PI * 2);
ctx.fill();
}
drawFaceapiScaffoldLabels(box, resized, scale);
ctx.restore();
}
/**
* Draws the label for the detailed 2D scaffold, explicitly marking it as the
* live camera view. This matters because the top readout may describe the
* Ghostyle-composited matcher result, so "live face detected" and "matcher sees
* no face" can both be true without contradicting each other.
*
* @param {{x:number, y:number, width:number, height:number}} box - Detection box in canvas pixels.
* @param {object} resized - Detailed face-api result already resized to canvas coordinates.
* @param {number} scale - Canvas-pixels per CSS pixel.
* @returns {void}
* @see drawFaceapiScaffold2d - Calls this after drawing detailed landmarks.
*/
function drawFaceapiScaffoldLabels(box, resized, scale) {
const lines = [t('overlay_face_detected_label')];
if (typeof resized.age === 'number') lines.push(t('overlay_estimated_age_label', { age: Math.round(resized.age) }));
if (resized.gender) lines.push(t('overlay_estimated_gender_label', { gender: resized.gender }));
const fontSize = 12 * scale;
const pad = 5 * scale;
const lineHeight = 15 * scale;
ctx.font = `${fontSize}px ${LABEL_FONT_FAMILY}`;
const maxWidth = Math.max(...lines.map(line => ctx.measureText(line).width));
const labelWidth = maxWidth + pad * 2;
const labelHeight = lines.length * lineHeight + pad * 2;
const startX = Math.max(0, Math.min(box.x, canvas.width - labelWidth));
const startY = Math.max(16 * scale, box.y - labelHeight - 8 * scale);
if ((canvas.style.transform || '').includes('scaleX(-1)')) {
ctx.translate(startX + labelWidth / 2, startY + labelHeight / 2);
ctx.scale(-1, 1);
ctx.translate(-(startX + labelWidth / 2), -(startY + labelHeight / 2));
}
ctx.fillStyle = 'rgba(15, 17, 21, 0.78)';
ctx.strokeStyle = 'rgba(255,255,255,0.10)';
ctx.lineWidth = scale;
roundRect(ctx, startX, startY, labelWidth, labelHeight, 8 * scale);
ctx.fill();
ctx.stroke();
ctx.fillStyle = 'rgba(238, 242, 255, 0.96)';
lines.forEach((line, index) => {
ctx.fillText(line, startX + pad, startY + pad + index * lineHeight + lineHeight * 0.15);
});
}
/**
* Resizes the cached face-api detection to the current bbox canvas dimensions,
* preserving alignment after camera resize, device-pixel-ratio changes, or
* overlay canvas resync.
*
* @returns {object|null} Resized face-api result, or null when no detection is cached.
* @see drawFaceapiScaffold2d - Uses this for detailed 2D drawing.
* @see getLastBox - Uses the same resize behavior for bbox extraction.
*/
function getResizedDetection() {
if (!view.lastDetection) return null;
return faceapi.resizeResults(view.lastDetection, {
width: canvas.width,
height: canvas.height,
});
}
/**
* Draws the metric label block next to the bounding box, showing live detection
* confidence, current face-api distance, live face id, and obfuscated id drift
* when a Ghostyle changes the nearest saved identity. The text is un-mirrored
* when the webcam canvas is mirrored so it remains readable.
*
* @param {{x:number, y:number, width:number, height:number}} box - Bounding box in canvas pixels.
* @param {number|null} score - Live detection score from face-api.
* @returns {void}
* @see renderOverlay - Calls this for standard bbox metrics.
* @see drawFaceapiScaffold2d - Calls this when detailed landmarks are unavailable.
* @see fmt - Formats each numeric metric shown in the label block.
*/
function drawLabels(box, score) {
const lines = [
`detection ${fmt(score, 2)}`,
`distance ${fmt(view.liveMinDist, 3)}`,
`faceId ${fmt(view.liveMinId, 0)}`,
];
if (Number.isFinite(view.obfMinId) && view.obfMinId !== view.liveMinId) {
lines.push(`ObfFaceId! ${fmt(view.obfMinId, 0)}`);
}
const scale = cssScale();
const fontSize = LABEL_FONT_SIZE_CSS * scale;
const lineHeight = LABEL_LINE_HEIGHT_CSS * scale;
const padding = LABEL_PADDING_CSS * scale;
const gap = LABEL_GAP_CSS * scale;
ctx.save();
ctx.font = `${fontSize}px ${LABEL_FONT_FAMILY}`;
ctx.textBaseline = 'top';
const widths = lines.map(t => ctx.measureText(t).width);
const blockW = Math.max(...widths) + padding * 2;
const blockH = lineHeight * lines.length + padding * 2;
const aboveY = box.y - blockH - gap;
const belowY = box.y + box.height + gap;
const top = aboveY >= 0 ? aboveY : belowY;
let left = box.x;
if (left + blockW > canvas.width) left = canvas.width - blockW;
if (left < 0) left = 0;
const mirrored = (canvas.style.transform || '').includes('scaleX(-1)');
if (mirrored) {
ctx.translate(canvas.width, 0);
ctx.scale(-1, 1);
left = canvas.width - left - blockW;
}
ctx.fillStyle = 'rgba(12, 14, 22, 0.78)';
ctx.fillRect(left, top, blockW, blockH);
ctx.fillStyle = currentColor();
lines.forEach((t, i) => {
ctx.fillText(t, left + padding, top + padding + i * lineHeight);
});
ctx.restore();
}