refactor(h3d-carve-7): extract O-section (lyrics + HUD overlay) → src/overlay.js

Move longestConsecutiveRun, drawChordDiagram, _drawDiagramCached,
drawSectionHud, drawToneHud, drawLyrics out of screen.js IIFE into
plugins/highway_3d/src/overlay.js as export function createOverlay({ diagRenderCache }).

Surprises vs approved contract:
  • longestConsecutiveRun (lines 4338–4352) co-moved — called only by drawChordDiagram
  • DIAG_SIZE_MIN, DIAG_SIZE_MAX, DIAG_CELL_MAX DELETED from screen.js (lines 573–575);
    only users were inside the O-section; moved not copied to avoid drift
  • _DIAG_CACHE_MAX DELETED from screen.js factory scope (line 3256); moved not copied

Beyond-subst rewires (2):
  1. Factory wrapper createOverlay({ diagRenderCache })
  2. _diagRenderCache → diagRenderCache (DI param) in 3 sites in _drawDiagramCached

Base run stated: 253/253 on a993d2b
Post-cut: 262/262 (+9 tests — 8 overlay class-killers + 1 panel-controls stub)

Named mutations verified RED:
  • createOverlay({ diagRenderCache: new Map() }) → test 6 RED (ref-severance)
  • void _DIAG_CACHE_MAX in screen.js         → test 4 RED (bare private)

plugin.json: 3.42.0 → 3.43.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
This commit is contained in:
byrongamatos
2026-09-05 14:53:03 +02:00
co-authored by Claude Sonnet 4.6
parent a993d2b291
commit 0a91f4f1b1
5 changed files with 1180 additions and 886 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "highway_3d",
"name": "3D Highway",
"version": "3.42.0",
"version": "3.43.0",
"type": "visualization",
"scriptType": "module",
"bundled": true,
+13 -885
View File
@@ -12,6 +12,7 @@ import { _h3dHexToInt, _clampByteI, _darkenInt, _lightenInt, resolveStringCount
import { _bcIsDesktop, _bcCreateController, _bcLoadSettings, _bcFfIdx } from './src/bc-panel.js'; // h3d-carve-4
import { createBgControl } from './src/bg-control.js'; // h3d-carve-5
import { createMaterialBuilders } from './src/materials.js'; // h3d-carve-6
import { createOverlay } from './src/overlay.js'; // h3d-carve-7
(function () {
'use strict';
@@ -570,9 +571,6 @@ import { createMaterialBuilders } from './src/materials.js'; // h3d-carve-6
const DIAG_LINGER_S = 0.55;
const DIAG_ENTRANCE_S = 0.20;
const DIAG_CROSSFADE_S = 0.15;
const DIAG_SIZE_MIN = 0.08;
const DIAG_SIZE_MAX = 0.16;
const DIAG_CELL_MAX = 34;
// 'bl' and 'br' removed — diagram is top-only. Legacy localStorage values
// that contain 'bl'/'br' will fall back to BG_DEFAULTS.chordDiagramPosition
// via _bgCoerce (which rejects values not in this list).
@@ -3248,12 +3246,6 @@ import { createMaterialBuilders } from './src/materials.js'; // h3d-carve-6
// Cleared on canvas resize (bx/by depend on canvasW/H/lyricsBottom)
// and on teardown/destroy.
const _diagRenderCache = new Map();
// Cap chosen to cover the ~56 active chord shapes per phrase while
// keeping the cached-OffscreenCanvas footprint bounded (~50 MB per
// panel at typical 1920×1080). A structural fix — caching a
// tightly-sized box surface instead of the full overlay canvas —
// is tracked as a follow-up.
const _DIAG_CACHE_MAX = 6;
let pSusRail = null, gSusRail = null, mSusRailBase = null;
let pSusRailBloom = null, gSusRailBloom = null, mSusRailBloomBase = null, _bloomGaussTex = null;
let pTechPlane = null, gTechPlane = null;
@@ -4329,884 +4321,20 @@ import { createMaterialBuilders } from './src/materials.js'; // h3d-carve-6
// Returns indices of the longest consecutive run in a sorted integer
// array as { start, len } — `sorted[start..start+len)` is the run.
// Avoids the two per-call sub-array allocations of the previous
// implementation (best + cur arrays grown via .push), at the cost
// of one small 2-key result object. Net: callers in the chord-
// diagram render path no longer churn arrays per visible chord.
function longestConsecutiveRun(sorted) {
let bestStart = -1, bestLen = 0;
let curStart = -1, curLen = 0;
for (let i = 0; i < sorted.length; i++) {
if (curLen === 0 || sorted[i] === sorted[curStart + curLen - 1] + 1) {
if (curLen === 0) curStart = i;
curLen++;
} else {
if (curLen > bestLen) { bestLen = curLen; bestStart = curStart; }
curStart = i; curLen = 1;
}
}
if (curLen > bestLen) { bestLen = curLen; bestStart = curStart; }
return { start: bestStart, len: bestLen };
}
/* ── Lyrics overlay (2D canvas on top of WebGL) ─────────────────── */
function drawChordDiagram(ctx, opts) {
const {
name, frets,
opacity = 1,
entranceT = 1.0,
canvasW = 600, canvasH = 400,
inverted = false,
sizeSlider = 0.5,
position = 'tl',
nStr = 6,
lyricsBottom = 0,
stackOffset = 0,
} = opts;
// Responsive sizing — CELL derived from panel height + user slider.
// COLS is the resolved string count from the caller (via resolveStringCount)
// so bass (4), extended (7/8) arrangements render correctly.
const COLS = nStr, ROWS = 4;
// Minimum column span required for PATH B (bracket extension / detection).
// Math.min(COLS-1, 4) scales with string count:
// 4-string bass → 3 (max possible span, so 2-4-4-2 shapes qualify)
// 6-string → 4 (excludes D major span=2 / common 2-string coincidences)
// 8-string → 4 (muted outer strings still leave span ≥ 4 for real barres)
const MIN_BARRE_SPAN = Math.min(COLS - 1, 4);
// Maps diagram column index → chord-template frets-array index.
// Templates are high-e-first: frets[0]=high e, frets[COLS-1]=low E.
// Non-inverted display (col 0 = high e): getStrIdx(0) = 0 → frets[0] = high e.
// Inverted display (col 0 = low E): getStrIdx(0) = COLS-1 → frets[COLS-1] = low E.
const getStrIdx = col => inverted ? (COLS - 1 - col) : col;
const sizeF = DIAG_SIZE_MIN + (DIAG_SIZE_MAX - DIAG_SIZE_MIN) * sizeSlider;
// startFret / isFirstPos must be known before CELL so that fretLabelW
// can be measured and factored into the width cap. The old
// canvasW/(COLS+1.5) guard only approximated 2*PAD and ignored the
// extra left padding reserved for non-first-position "Nfr" labels.
const playedFrets = frets.filter(f => f > 0);
const minFret = playedFrets.length > 0 ? Math.min(...playedFrets) : 1;
const startFret = Math.max(1, minFret);
const isFirstPos = startFret === 1;
// Phase 1 — height + hard-cap estimate, used only to size the label font.
// Cap against the vertical space available below lyricsBottom so that the
// diagram does not overflow into the lyrics banner on short split panels with
// wrapped lyric rows. Only top-corner positions can overlap the lyrics banner,
// so lyricsBottom is only subtracted when position is 'tl' or 'tr'; for 'bl'
// and 'br' the full canvas height is available.
// Clamp to at least 1 so font/box calculations never receive 0-px input
// on very short panels (e.g. tiny split cells < 44 px tall).
const isTopCorner = position === 'tl' || position === 'tr';
const availH = canvasH - (isTopCorner ? lyricsBottom : 0);
const cellEst = Math.max(1, Math.min(
Math.round(availH * sizeF / (ROWS + 3)),
DIAG_CELL_MAX,
));
// Extra left padding for the "Nfr" label on non-first-position chords.
// Measured with ctx.measureText at cellEst so the estimate is exact.
let fretLabelW = 0;
if (!isFirstPos) {
// Measure inside a save/restore so this font assignment does not
// leak to the caller (the outer ctx.save() happens after CELL is derived).
ctx.save();
ctx.font = `italic ${Math.round(cellEst * 0.55)}px sans-serif`;
fretLabelW = Math.ceil(ctx.measureText(startFret + 'fr').width) + 6;
ctx.restore();
}
// Phase 2 — final CELL: cap against panel height, hard max, and panel width.
// Two width constraints are needed because PAD has a hard floor of 6:
// A) when PAD = CELL*0.65 (large CELL): CELL*(COLS+0.3) + fretLabelW ≤ canvasW
// B) when PAD = 6 floor (small CELL): CELL*(COLS-1) + 12 + fretLabelW ≤ canvasW
// Both are included so boxW ≤ canvasW in every regime.
// fretLabelW was measured at cellEst ≥ CELL, so the cap is conservative.
const CELL = Math.max(1, Math.min(
cellEst,
Math.floor((canvasW - fretLabelW) / (COLS + 0.3)),
Math.floor((canvasW - 2 * 6 - fretLabelW) / Math.max(1, COLS - 1)),
));
const HEADER = Math.round(CELL * 1.6);
const MARKER = Math.round(CELL * 0.7);
const DOT_R = CELL * 0.3;
const PAD = Math.max(6, Math.round(CELL * 0.65));
const gridW = CELL * (COLS - 1);
const gridH = CELL * ROWS;
const PAD_L = PAD + fretLabelW;
const boxW = gridW + PAD_L + PAD;
const boxH = HEADER + MARKER + gridH + PAD;
// Anchor to chosen corner. Top positions get extra vertical offset
// to clear the timeline plugin and song name displayed at the top.
// lyricsBottom is the actual bottom Y of the lyrics banner (returned by
// drawLyrics), so TOP_Y steps down past all lyric rows regardless of
// how many wrap lines the current panel width produces.
const E = PAD;
const TOP_Y = Math.round(Math.max(E + canvasH * 0.06, lyricsBottom + E));
let bx, by;
if (position === 'tr') { bx = canvasW - boxW - E; by = TOP_Y + stackOffset; }
else if (position === 'bl') { bx = E; by = canvasH - boxH - E - stackOffset; }
else if (position === 'br') { bx = canvasW - boxW - E; by = canvasH - boxH - E - stackOffset; }
else { bx = E; by = TOP_Y + stackOffset; }
// Clamp so the box never bleeds off-canvas on narrow panels or wide string counts.
bx = Math.max(0, Math.min(canvasW - boxW, bx));
by = Math.max(0, Math.min(canvasH - boxH, by));
// Guard: the canvasHboxH clamp above can push `by` above lyricsBottom when
// wrapped lyrics consume nearly the full panel height. This applies to ALL
// corner positions: a bottom-corner diagram anchored near the canvas bottom can
// still reach up into the lyrics banner on very short or narrow panels where
// boxH is larger than the space below the lyrics. In those cases skip drawing
// entirely rather than painting on top of the lyrics banner.
if (lyricsBottom > 0 && by < lyricsBottom) return 0;
const gx = bx + PAD_L, gy = by + HEADER + MARKER;
// Ease-out quadratic entrance scale: 0.85 → 1.0.
const scale = 1 - 0.15 * (1 - entranceT) * (1 - entranceT);
ctx.save();
ctx.globalAlpha = opacity;
if (scale !== 1.0) {
const cx = bx + boxW / 2, cy = by + boxH / 2;
ctx.translate(cx, cy);
ctx.scale(scale, scale);
ctx.translate(-cx, -cy);
}
// Background + border.
ctx.fillStyle = 'rgba(8, 14, 22, 0.88)';
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.stroke();
// Split-root typography: "Dm7" → "D" large bold + "m7" smaller.
const rootMatch = name.match(/^([A-G][#b]?)(.*)/);
const root = rootMatch ? rootMatch[1] : name;
const quality = rootMatch ? rootMatch[2] : '';
const rootSize = Math.round(CELL * 1.25);
const qualSize = Math.round(rootSize * 0.65);
ctx.textBaseline = 'middle';
const nameY = by + HEADER * 0.55;
ctx.font = `bold ${rootSize}px sans-serif`;
const rootW = ctx.measureText(root).width;
ctx.font = `${qualSize}px sans-serif`;
const qualW = quality ? ctx.measureText(quality).width : 0;
const nameBlockW = rootW + (quality ? qualW + 2 : 0);
const nameStartX = bx + boxW / 2 - nameBlockW / 2;
ctx.fillStyle = '#e8d080';
ctx.font = `bold ${rootSize}px sans-serif`;
ctx.textAlign = 'left';
ctx.fillText(root, nameStartX, nameY);
if (quality) {
ctx.font = `${qualSize}px sans-serif`;
ctx.fillStyle = 'rgba(232,208,128,0.75)';
ctx.fillText(quality, nameStartX + rootW + 2, nameY);
}
// Nut: CELL-proportional filled rect + subtle highlight line.
// Thickness is 40% of CELL, floored at 2 px so it stays visible on
// the smallest diagrams (CELL=1 on compact split panels).
const NUT_H = Math.round(Math.max(2, CELL * 0.4));
if (isFirstPos) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(gx, gy - NUT_H, gridW, NUT_H);
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.fillRect(gx, gy - NUT_H, gridW, Math.max(1, Math.round(NUT_H * 0.25)));
}
// Fret label for non-first-position chords.
if (!isFirstPos) {
ctx.fillStyle = 'rgba(220,200,120,0.9)';
ctx.font = `italic ${Math.round(CELL * 0.55)}px sans-serif`;
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(startFret + 'fr', gx - 4, gy + CELL * 0.5);
}
// Fret lines.
ctx.strokeStyle = 'rgba(255,255,255,0.22)'; ctx.lineWidth = 1;
for (let r = (isFirstPos ? 1 : 0); r <= ROWS; r++) {
ctx.beginPath();
ctx.moveTo(gx, gy + r * CELL);
ctx.lineTo(gx + gridW, gy + r * CELL);
ctx.stroke();
}
// String lines with varying weight: low E heavier, high e lighter.
// With getStrIdx(col) = col (non-inverted): col 0 (high e) → strIdx=0 → t=0 thin;
// col COLS-1 (low E) → strIdx=COLS-1 → t=1 thick. Inverted mode naturally mirrors.
// Weights scale with CELL so strings never bleed into adjacent columns on
// small-CELL diagrams (e.g. CELL=1 on compact split panels).
for (let col = 0; col < COLS; col++) {
const strIdx = getStrIdx(col);
const t = COLS > 1 ? strIdx / (COLS - 1) : 1; // 1=low E (thick), 0=high e (thin); guard COLS=1
ctx.lineWidth = Math.max(0.5, CELL * (0.05 + t * 0.10));
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.beginPath();
ctx.moveTo(gx + col * CELL, gy);
ctx.lineTo(gx + col * CELL, gy + ROWS * CELL);
ctx.stroke();
}
// Barre detection — two complementary paths:
//
// PATH A (F-shape / mini-barre): at least two ADJACENT columns are at startFret.
// Bracket is initially set to the consecutive run's own endpoints (not the full
// startFretCols range) so isolated bass notes at the same fret can't pull the
// bracket across an open gap (e.g. "2 0 2 2 0 0" stays bracketed at cols 2..3).
//
// PATH B (full-span barre / extension):
// When PATH A fired: extend the bracket outward to the full outer startFret span
// if the span ≥ MIN_BARRE_SPAN and every column between the outer startFret
// columns is fretted (f > 0).
// When PATH A did NOT fire: detect standalone full barres (e.g. x24442, x46654)
// where only the two outermost strings sit at startFret. An additional check
// ensures that no intermediate column is itself at startFret — this rules out
// alternating-fret voicings like "1 3 1 3 1 0" (col 2 at startFret would fire
// incorrectly) while still catching B-major-style shapes where the barre
// finger covers only the outer two strings.
//
// Templates are high-e-first: frets[0]=high e, frets[COLS-1]=low E.
// Examples (6-string, MIN_BARRE_SPAN=4):
// F major [1,1,2,3,3,1]: PATH A run=[4,5] → bracket 4..5; PATH B span=5, all fretted → extends to 0..5 ✓
// B major x24442: PATH A no run; PATH B span=4, all fretted, no inner at startFret → 1..5 ✓
// mini-A x02220: PATH A run=[2,3,4] → bracket 2..4; PATH B span=2<4 → no extension ✓
// D major xx0232: PATH A run length=1 → no PATH A; PATH B span<4 → no bracket ✓
// 2 0 2 2 0 0: PATH A run=[2,3] → bracket 2..3; PATH B span=3<4 → no extension ✓
// 1 3 1 3 1 0: PATH A no run; PATH B: inner col 2 at startFret → no bracket ✓
const startFretCols = [];
for (let col = 0; col < COLS; col++) {
if (frets[getStrIdx(col)] === startFret) startFretCols.push(col);
}
const barreRun = longestConsecutiveRun(startFretCols);
let hasBarreArc = barreRun.len >= 2; // PATH A
let barreMinCol = hasBarreArc ? startFretCols[barreRun.start] : -1;
let barreMaxCol = hasBarreArc ? startFretCols[barreRun.start + barreRun.len - 1] : -1;
if (startFretCols.length >= 2) { // PATH B
const minC = startFretCols[0];
const maxC = startFretCols[startFretCols.length - 1];
if (maxC - minC >= MIN_BARRE_SPAN) {
let allFretted = true;
for (let col = minC; col <= maxC; col++) {
if (frets[getStrIdx(col)] <= 0) { allFretted = false; break; }
}
if (allFretted) {
if (hasBarreArc) {
// PATH A fired: always safe to extend to full outer span.
barreMinCol = minC;
barreMaxCol = maxC;
} else {
// PATH A did not fire: only draw a bracket when no intermediate
// column sits at startFret. Intermediate startFret columns would
// indicate a scattered/alternating voicing rather than a clean
// outer-edge barre (e.g. "1 3 1 3 1 0" has col 2 at startFret).
let noInnerAtStartFret = true;
for (let col = minC + 1; col < maxC; col++) {
if (frets[getStrIdx(col)] === startFret) { noInnerAtStartFret = false; break; }
}
if (noInnerAtStartFret) {
hasBarreArc = true;
barreMinCol = minC;
barreMaxCol = maxC;
}
}
}
}
}
if (hasBarreArc) {
const barreY = gy + CELL * 0.5;
const capH = CELL * 0.22; // vertical offset from barreY to the bracket line
const capHalf = Math.max(1, Math.round(CELL * 0.3)); // half-height of the vertical end caps
// Straight bracket: a horizontal line with short vertical end caps.
// Stroke scales with CELL so it doesn't swamp tiny cells (floor at 1 px).
ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = Math.max(1, CELL * 0.2);
ctx.beginPath();
ctx.moveTo(gx + barreMinCol * CELL, barreY - capH);
ctx.lineTo(gx + barreMaxCol * CELL, barreY - capH);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(gx + barreMinCol * CELL, barreY - capH - capHalf);
ctx.lineTo(gx + barreMinCol * CELL, barreY - capH + capHalf);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(gx + barreMaxCol * CELL, barreY - capH - capHalf);
ctx.lineTo(gx + barreMaxCol * CELL, barreY - capH + capHalf);
ctx.stroke();
}
// Open/muted markers + finger dots.
// Non-inverted: col 0 = high e → getStrIdx(0)=0 → frets[0]; col COLS-1 = low E → frets[COLS-1].
// Inverted: col 0 = low E → getStrIdx(0)=COLS-1 → frets[COLS-1]; col COLS-1 = high e → frets[0].
for (let col = 0; col < COLS; col++) {
const f = frets[getStrIdx(col)];
const sx = gx + col * CELL;
const markerY = gy - MARKER * 0.5;
if (f < 0) {
const r = CELL * 0.20;
ctx.strokeStyle = '#cc4444'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(sx - r, markerY - r); ctx.lineTo(sx + r, markerY + r); ctx.stroke();
ctx.beginPath(); ctx.moveTo(sx + r, markerY - r); ctx.lineTo(sx - r, markerY + r); ctx.stroke();
} else if (f === 0) {
ctx.strokeStyle = '#88bbff'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(sx, markerY, CELL * 0.22, 0, Math.PI * 2); ctx.stroke();
} else {
const row = f - startFret;
if (row >= 0 && row < ROWS) {
const isBarreCol = hasBarreArc && f === startFret &&
col >= barreMinCol && col <= barreMaxCol;
ctx.shadowColor = 'rgba(0,0,0,0.5)';
ctx.shadowBlur = Math.min(4, CELL * 0.4);
ctx.shadowOffsetX = Math.max(0.5, CELL * 0.1);
ctx.shadowOffsetY = Math.max(0.5, CELL * 0.1);
ctx.fillStyle = isBarreCol ? 'rgba(255,255,255,0.85)' : '#ffffff';
ctx.beginPath();
ctx.arc(sx, gy + row * CELL + CELL * 0.5, DOT_R, 0, Math.PI * 2);
ctx.fill();
ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0;
ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 0;
}
}
}
ctx.restore();
return boxH;
}
// Cached wrapper for drawChordDiagram. When entranceT === 1 (scale
// transform is identity) the diagram is rendered once to an
// OffscreenCanvas and reused every subsequent frame via drawImage +
// globalAlpha. During the 0.2 s entrance animation (entranceT < 1)
// the scale transform is non-trivial so we fall through to a fresh
// render — that window is ~12 frames at 60 fps, negligible.
// ── h3d-carve-7: lyrics + HUD overlay ──────────────────────────────────────
// longestConsecutiveRun, drawChordDiagram, _drawDiagramCached,
// drawSectionHud, drawToneHud, drawLyrics → src/overlay.js (createOverlay)
//
// Returns boxH (diagram card height in px) so the draw loop can
// accumulate per-corner stack offsets when multiple overlays share
// the same corner position.
function _drawDiagramCached(ctx, opts) {
const { opacity = 1, entranceT = 1.0, canvasW, canvasH } = opts;
if (opacity <= 0) return 0;
if (entranceT < 1.0) {
return drawChordDiagram(ctx, opts) || 0;
}
const { name, frets, nStr, inverted, sizeSlider, position, lyricsBottom = 0, stackOffset = 0 } = opts;
const key = name + '|' + (frets || []).join(',') + '|' + nStr + '|' +
(inverted ? 1 : 0) + '|' + sizeSlider + '|' + position + '|' +
canvasW + '|' + canvasH + '|' + lyricsBottom + '|' + stackOffset;
let entry = _diagRenderCache.get(key);
if (!entry) {
let oc;
try { oc = new OffscreenCanvas(canvasW, canvasH); }
catch (_) { oc = document.createElement('canvas'); oc.width = canvasW; oc.height = canvasH; }
const boxH = drawChordDiagram(oc.getContext('2d'), { ...opts, opacity: 1, entranceT: 1 }) || 0;
if (_diagRenderCache.size >= _DIAG_CACHE_MAX) {
_diagRenderCache.delete(_diagRenderCache.keys().next().value);
}
entry = { oc, boxH };
_diagRenderCache.set(key, entry);
}
ctx.save();
ctx.globalAlpha = opacity;
ctx.drawImage(entry.oc, 0, 0);
ctx.restore();
return entry.boxH;
}
// Two-line section card. Top line is "Now: <current>", bottom line
// is "Up Next: <next> in <countdown>". Explicit labels disambiguate
// current vs upcoming — earlier single-line variant rendered both
// states with the same word and was confusing during playback.
// Surprises vs contract:
// • longestConsecutiveRun (lines 43384352) co-moved — only called by drawChordDiagram
// • DIAG_SIZE_MIN, DIAG_SIZE_MAX, DIAG_CELL_MAX deleted from screen.js lines 573575
// • _DIAG_CACHE_MAX deleted from factory scope line 3256 (moved to overlay.js)
//
// Returns boxH on draw, 0 when nothing rendered. Position / size
// mirror the chord-diagram contract: 'tl' / 'tr' / 'bl' / 'br'
// anchor corners, sizeSlider in [0,1] scales card height.
//
// Hidden when:
// - no sections array, or
// - playback has not yet reached the first section AND there's
// no upcoming-only fallback rendered (we still show "Up Next"
// during the pre-roll so the user sees what's coming).
function drawSectionHud(ctx, opts) {
const {
sections, currentTime,
canvasW, canvasH,
position = 'tr',
sizeSlider = 0.5,
lyricsBottom = 0,
stackOffset = 0,
} = opts;
if (!sections || !sections.length) return 0;
// sections are time-ordered server-side; single forward scan.
let curIdx = -1;
for (let i = 0; i < sections.length; i++) {
if (sections[i].time <= currentTime) curIdx = i;
else break;
}
const cur = curIdx >= 0 ? sections[curIdx] : null;
const next = (curIdx + 1 < sections.length) ? sections[curIdx + 1] : null;
// Pre-first-section: nothing playing yet but next is coming —
// still useful to render "Up Next" alone so the user gets the
// anticipatory cue during the song's intro silence.
if (!cur && !next) return 0;
const nowName = cur ? cur.name : '';
// Render countdown as a separate span so it can take a calmer
// grey-white treatment while the section name itself stays
// cyan. Combining them into one string would inherit the cyan
// fill across both, defeating the visual hierarchy promised
// in the FR.
let nextName = '';
let nextCountdown = '';
if (next) {
const dt = next.time - currentTime;
nextName = next.name;
nextCountdown = dt > 10
? 'in ' + Math.round(dt) + 's'
: 'in ' + Math.max(0, dt).toFixed(1) + 's';
}
const sizeF = 0.65 + 0.85 * sizeSlider; // 0.65 .. 1.5
const baseH = Math.max(34, Math.min(72, Math.round(canvasH * 0.085 * sizeF)));
const PAD_X = Math.round(baseH * 0.45);
const PAD_Y = Math.round(baseH * 0.20);
// Per-text-element scale applied to nameSize / tagSize / lineH
// when the unscaled card would overflow a narrow panel
// (splitscreen quad layout, ultra-tall portrait). Computed
// below from the measured contentW vs the available width.
let textScale = 1.0;
const baseLineH = Math.round(baseH * 0.46);
const baseNameSize = Math.round(baseH * 0.36);
const baseTagSize = Math.round(baseH * 0.24);
const baseTagGap = Math.round(baseH * 0.14);
const TAG_NOW = 'Now:';
const TAG_NEXT = 'Up Next:';
// Phase-1 measurement at the unscaled font sizes — used to
// decide whether textScale needs to drop, and to lay out the
// final draw at whatever scale we land on.
ctx.save();
ctx.font = `${baseTagSize}px sans-serif`;
const tagNowWBase = ctx.measureText(TAG_NOW).width;
const tagNextWBase = ctx.measureText(TAG_NEXT).width;
const countdownWBase = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
ctx.font = `bold ${baseNameSize}px sans-serif`;
const nowNameWBase = nowName ? ctx.measureText(nowName).width : 0;
const nextNameWBase = nextName ? ctx.measureText(nextName).width : 0;
ctx.restore();
const lineNowWBase = nowName ? tagNowWBase + baseTagGap + nowNameWBase : 0;
const lineNextWBase = nextName
? tagNextWBase + baseTagGap + nextNameWBase
+ (nextCountdown ? baseTagGap + countdownWBase : 0)
: 0;
const contentWBase = Math.max(lineNowWBase, lineNextWBase);
const numLines = (nowName ? 1 : 0) + (nextName ? 1 : 0);
if (numLines === 0) return 0;
// Target width budget: cap at canvasW - 16 and reserve PAD_X
// either side. If contentWBase exceeds the budget, scale the
// font proportionally — clamped to 0.55 so labels stay legible
// even on extreme split-panel widths.
const maxBoxW = Math.max(40, canvasW - 16);
const availContentW = Math.max(1, maxBoxW - PAD_X * 2);
if (contentWBase > availContentW) {
textScale = Math.max(0.55, availContentW / contentWBase);
}
const lineH = Math.max(1, Math.round(baseLineH * textScale));
const nameSize = Math.max(1, Math.round(baseNameSize * textScale));
const tagSize = Math.max(1, Math.round(baseTagSize * textScale));
const TAG_GAP = Math.max(1, Math.round(baseTagGap * textScale));
// Phase-2 re-measurement at the scaled font sizes for the
// final layout. measureText doesn't scale linearly with font
// size on every glyph, so re-measuring is cheaper than
// multiplying the base widths by textScale and risking a
// half-pixel overflow.
ctx.save();
ctx.font = `${tagSize}px sans-serif`;
const tagNowW = ctx.measureText(TAG_NOW).width;
const tagNextW = ctx.measureText(TAG_NEXT).width;
const countdownW = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
ctx.font = `bold ${nameSize}px sans-serif`;
const nowNameW = nowName ? ctx.measureText(nowName).width : 0;
const nextNameW = nextName ? ctx.measureText(nextName).width : 0;
ctx.restore();
const lineNowW = nowName ? tagNowW + TAG_GAP + nowNameW : 0;
const lineNextW = nextName
? tagNextW + TAG_GAP + nextNameW + (nextCountdown ? TAG_GAP + countdownW : 0)
: 0;
const contentW = Math.max(lineNowW, lineNextW);
const boxW = Math.min(maxBoxW, Math.round(contentW + PAD_X * 2));
const boxH = Math.round(numLines * lineH + PAD_Y * 2);
const E = Math.round(baseH * 0.25);
const TOP_Y = Math.round(Math.max(E + canvasH * 0.06, lyricsBottom + E));
let bx, by;
if (position === 'tr') { bx = canvasW - boxW - E; by = TOP_Y + stackOffset; }
else if (position === 'bl') { bx = E; by = canvasH - boxH - E - stackOffset; }
else if (position === 'br') { bx = canvasW - boxW - E; by = canvasH - boxH - E - stackOffset; }
else { bx = E; by = TOP_Y + stackOffset; }
bx = Math.max(0, Math.min(canvasW - boxW, bx));
by = Math.max(0, Math.min(canvasH - boxH, by));
// Suppress overlap with the wrapped lyrics banner regardless
// of corner. Bottom-corner cards on short panels can still
// reach up into the banner once boxH exceeds the space below
// the lyrics — same shape the chord diagram uses.
if (lyricsBottom > 0 && by < lyricsBottom) return 0;
ctx.save();
ctx.fillStyle = 'rgba(8, 14, 22, 0.88)';
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.stroke();
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
// Layout each line with tag left-aligned, name in cyan after a
// small gap. Both lines share the same x origin (bx + PAD_X)
// so the tag column visually aligns vertically.
const lineX = bx + PAD_X;
let lineY = by + PAD_Y + lineH / 2;
const TAG_COLOR = 'rgba(180,190,205,0.85)';
const NAME_COLOR = '#00cccc';
const TIME_COLOR = 'rgba(220,225,235,0.9)';
if (nowName) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TAG_COLOR;
ctx.fillText(TAG_NOW, lineX, lineY);
ctx.font = `bold ${nameSize}px sans-serif`;
ctx.fillStyle = NAME_COLOR;
ctx.fillText(nowName, lineX + tagNowW + TAG_GAP, lineY);
lineY += lineH;
}
if (nextName) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TAG_COLOR;
ctx.fillText(TAG_NEXT, lineX, lineY);
const nextX = lineX + tagNextW + TAG_GAP;
ctx.font = `bold ${nameSize}px sans-serif`;
ctx.fillStyle = NAME_COLOR;
ctx.fillText(nextName, nextX, lineY);
if (nextCountdown) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TIME_COLOR;
ctx.fillText(nextCountdown, nextX + nextNameW + TAG_GAP, lineY);
}
}
ctx.restore();
return boxH;
}
// Tone-change HUD — card showing the active tone and the next upcoming
// tone with a countdown. Mirrors drawSectionHud's layout contract
// (position, size slider, lyricsBottom) but uses an amber accent colour
// so it reads as distinct from the cyan section card.
function drawToneHud(ctx, opts) {
const {
toneChanges, toneBase = '',
currentTime,
canvasW, canvasH,
position = 'tl',
sizeSlider = 0.5,
lyricsBottom = 0,
stackOffset = 0,
} = opts;
// Resolve active tone: toneBase before all changes, else the most
// recent change at or before currentTime.
// toneChanges items use { t, name } (not { time, name }) — both
// the legacy import path (server.py xml_tone_changes) and the sloppak
// path (lib/tones.py sloppak_tone_changes) emit "t" as the key.
let curName = toneBase;
let nextChange = null;
if (toneChanges && toneChanges.length) {
for (let i = 0; i < toneChanges.length; i++) {
if (toneChanges[i].t <= currentTime) {
curName = toneChanges[i].name;
} else {
nextChange = toneChanges[i];
break;
}
}
}
if (!curName && !nextChange) return 0;
let nextName = '';
let nextCountdown = '';
if (nextChange) {
const dt = nextChange.t - currentTime;
nextName = nextChange.name;
nextCountdown = dt > 10
? 'in ' + Math.round(dt) + 's'
: 'in ' + Math.max(0, dt).toFixed(1) + 's';
}
const sizeF = 0.65 + 0.85 * sizeSlider;
const baseH = Math.max(34, Math.min(72, Math.round(canvasH * 0.085 * sizeF)));
const PAD_X = Math.round(baseH * 0.45);
const PAD_Y = Math.round(baseH * 0.20);
let textScale = 1.0;
const baseLineH = Math.round(baseH * 0.46);
const baseNameSize = Math.round(baseH * 0.36);
const baseTagSize = Math.round(baseH * 0.24);
const baseTagGap = Math.round(baseH * 0.14);
const TAG_CUR = 'Tone:';
const TAG_NEXT = 'Next:';
ctx.save();
ctx.font = `${baseTagSize}px sans-serif`;
const tagCurWBase = ctx.measureText(TAG_CUR).width;
const tagNextWBase = ctx.measureText(TAG_NEXT).width;
const countdownWBase = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
ctx.font = `bold ${baseNameSize}px sans-serif`;
const curNameWBase = curName ? ctx.measureText(curName).width : 0;
const nextNameWBase = nextName ? ctx.measureText(nextName).width : 0;
ctx.restore();
const lineCurWBase = curName ? tagCurWBase + baseTagGap + curNameWBase : 0;
const lineNextWBase = nextName
? tagNextWBase + baseTagGap + nextNameWBase
+ (nextCountdown ? baseTagGap + countdownWBase : 0)
: 0;
const contentWBase = Math.max(lineCurWBase, lineNextWBase);
const numLines = (curName ? 1 : 0) + (nextName ? 1 : 0);
if (numLines === 0) return 0;
const maxBoxW = Math.max(40, canvasW - 16);
const availContentW = Math.max(1, maxBoxW - PAD_X * 2);
if (contentWBase > availContentW) {
textScale = Math.max(0.55, availContentW / contentWBase);
}
const lineH = Math.max(1, Math.round(baseLineH * textScale));
const nameSize = Math.max(1, Math.round(baseNameSize * textScale));
const tagSize = Math.max(1, Math.round(baseTagSize * textScale));
const TAG_GAP = Math.max(1, Math.round(baseTagGap * textScale));
ctx.save();
ctx.font = `${tagSize}px sans-serif`;
const tagCurW = ctx.measureText(TAG_CUR).width;
const tagNextW = ctx.measureText(TAG_NEXT).width;
const countdownW = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
ctx.font = `bold ${nameSize}px sans-serif`;
const curNameW = curName ? ctx.measureText(curName).width : 0;
const nextNameW = nextName ? ctx.measureText(nextName).width : 0;
ctx.restore();
const lineCurW = curName ? tagCurW + TAG_GAP + curNameW : 0;
const lineNextW = nextName
? tagNextW + TAG_GAP + nextNameW + (nextCountdown ? TAG_GAP + countdownW : 0)
: 0;
const contentW = Math.max(lineCurW, lineNextW);
const boxW = Math.min(maxBoxW, Math.round(contentW + PAD_X * 2));
const boxH = Math.round(numLines * lineH + PAD_Y * 2);
const E = Math.round(baseH * 0.25);
const TOP_Y = Math.round(Math.max(E + canvasH * 0.06, lyricsBottom + E));
let bx, by;
if (position === 'tr') { bx = canvasW - boxW - E; by = TOP_Y + stackOffset; }
else if (position === 'bl') { bx = E; by = canvasH - boxH - E - stackOffset; }
else if (position === 'br') { bx = canvasW - boxW - E; by = canvasH - boxH - E - stackOffset; }
else { bx = E; by = TOP_Y + stackOffset; } // 'tl' default
bx = Math.max(0, Math.min(canvasW - boxW, bx));
by = Math.max(0, Math.min(canvasH - boxH, by));
if (lyricsBottom > 0 && by < lyricsBottom) return 0;
ctx.save();
ctx.fillStyle = 'rgba(8, 14, 22, 0.88)';
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.stroke();
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
const lineX = bx + PAD_X;
let lineY = by + PAD_Y + lineH / 2;
const TAG_COLOR = 'rgba(180,190,205,0.85)';
const NAME_COLOR = '#ff9a3c'; // amber — distinct from section cyan
const TIME_COLOR = 'rgba(220,225,235,0.9)';
if (curName) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TAG_COLOR;
ctx.fillText(TAG_CUR, lineX, lineY);
ctx.font = `bold ${nameSize}px sans-serif`;
ctx.fillStyle = NAME_COLOR;
ctx.fillText(curName, lineX + tagCurW + TAG_GAP, lineY);
lineY += lineH;
}
if (nextName) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TAG_COLOR;
ctx.fillText(TAG_NEXT, lineX, lineY);
const nextX = lineX + tagNextW + TAG_GAP;
ctx.font = `bold ${nameSize}px sans-serif`;
ctx.fillStyle = NAME_COLOR;
ctx.fillText(nextName, nextX, lineY);
if (nextCountdown) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TIME_COLOR;
ctx.fillText(nextCountdown, nextX + nextNameW + TAG_GAP, lineY);
}
}
ctx.restore();
return boxH;
}
// Lyrics layout cache — measureText per syllable + row wrapping
// only changes when the displayed line(s), font size, or canvas
// width change, not per frame. Keyed below; the per-frame work is
// just drawing over the cached widths.
let _lyrRowsCache = null;
function drawLyrics(lyrics, currentTime, ctx, W, H) {
if (!lyrics._lines) {
const lines = [];
let line = null, word = null;
const flushWord = () => { if (word && word.length) line.words.push(word); word = null; };
const flushLine = () => { flushWord(); if (line && line.words.length) lines.push(line); line = null; };
for (let i = 0; i < lyrics.length; i++) {
const l = lyrics[i];
const raw = l.w || '';
const endsLine = raw.endsWith('+');
const continuesWord = raw.endsWith('-');
if (line && i > 0 && l.t - (lyrics[i - 1].t + lyrics[i - 1].d) > 4.0) flushLine();
if (!line) line = { words: [], start: l.t, end: l.t + l.d };
if (!word) word = [];
word.push(l);
line.end = Math.max(line.end, l.t + l.d);
if (!continuesWord) flushWord();
if (endsLine) flushLine();
}
flushLine();
lyrics._lines = lines;
}
const allLines = lyrics._lines;
if (!allLines.length) return 0;
let currentIdx = -1;
for (let i = 0; i < allLines.length; i++) {
if (allLines[i].start <= currentTime) currentIdx = i;
else break;
}
if (currentIdx === -1) {
if (allLines[0].start - currentTime > 2.0) return 0;
currentIdx = 0;
}
const currentLine = allLines[currentIdx];
const nextLine = allLines[currentIdx + 1] || null;
const gapToNext = nextLine ? (nextLine.start - currentLine.end) : Infinity;
if (currentTime > currentLine.end + 0.5 && gapToNext > 3.0) return 0;
const linesToShow = [currentLine];
if (nextLine && gapToNext <= 3.0) linesToShow.push(nextLine);
const fontSize = Math.max(18, H * 0.028) | 0;
const lineY = H * 0.04;
const sylText = s => { const t = s.w || ''; return (t.endsWith('+') || t.endsWith('-')) ? t.slice(0, -1) : t; };
ctx.font = `bold ${fontSize}px sans-serif`;
let rows, spaceWidth, bgWidth;
const _lc = _lyrRowsCache;
if (_lc && _lc.lyricsRef === lyrics && _lc.idx === currentIdx
&& _lc.shown === linesToShow.length
&& _lc.fontSize === fontSize && _lc.W === W) {
rows = _lc.rows; spaceWidth = _lc.spaceWidth; bgWidth = _lc.bgWidth;
} else {
spaceWidth = ctx.measureText(' ').width;
const maxWidth = W * 0.8;
rows = [];
for (const authoredLine of linesToShow) {
let row = [], rowWidth = 0;
for (const wordSyls of authoredLine.words) {
const parts = [];
let wordWidth = 0;
for (const s of wordSyls) {
const text = sylText(s);
const w = ctx.measureText(text).width;
parts.push({ syl: s, text, width: w });
wordWidth += w;
}
const advance = wordWidth + spaceWidth;
if (row.length > 0 && rowWidth + advance > maxWidth) { rows.push(row); row = []; rowWidth = 0; }
row.push({ parts, advance });
rowWidth += advance;
}
if (row.length) rows.push(row);
}
bgWidth = 0;
for (const row of rows) {
const rw = row.reduce((s, w) => s + w.advance, 0) - spaceWidth;
if (rw > bgWidth) bgWidth = rw;
}
bgWidth = Math.min(bgWidth + 30, W * 0.85);
_lyrRowsCache = {
lyricsRef: lyrics, idx: currentIdx,
shown: linesToShow.length, fontSize, W,
rows, spaceWidth, bgWidth,
};
}
const rowHeight = fontSize + 6;
const totalHeight = rows.length * rowHeight + 10;
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.beginPath();
const bx = W / 2 - bgWidth / 2, by = lineY - 4, br = 8;
ctx.moveTo(bx + br, by); ctx.lineTo(bx + bgWidth - br, by);
ctx.quadraticCurveTo(bx + bgWidth, by, bx + bgWidth, by + br);
ctx.lineTo(bx + bgWidth, by + totalHeight - br);
ctx.quadraticCurveTo(bx + bgWidth, by + totalHeight, bx + bgWidth - br, by + totalHeight);
ctx.lineTo(bx + br, by + totalHeight);
ctx.quadraticCurveTo(bx, by + totalHeight, bx, by + totalHeight - br);
ctx.lineTo(bx, by + br);
ctx.quadraticCurveTo(bx, by, bx + br, by);
ctx.closePath();
ctx.fill();
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
for (let r = 0; r < rows.length; r++) {
const row = rows[r];
const rowWidth = row.reduce((s, w) => s + w.advance, 0) - spaceWidth;
let xPos = W / 2 - rowWidth / 2;
const yPos = lineY + r * rowHeight + 2;
for (const w of row) {
for (const part of w.parts) {
const l = part.syl;
const isActive = currentTime >= l.t && currentTime < l.t + l.d;
const isPast = currentTime >= l.t + l.d;
ctx.fillStyle = isActive ? '#4ae0ff' : isPast ? '#8899aa' : '#556677';
ctx.font = `${isActive ? 'bold' : 'normal'} ${fontSize}px sans-serif`;
ctx.fillText(part.text, xPos, yPos);
xPos += part.width;
}
xPos += spaceWidth;
}
}
// Return the actual bottom Y of the rendered background box so callers
// (e.g. drawChordDiagram) can avoid overlapping it.
return Math.round(by + totalHeight);
}
// Beyond-subst (2):
// 1. Factory wrapper createOverlay({ diagRenderCache })
// 2. _diagRenderCache → diagRenderCache (3 sites in _drawDiagramCached)
const { drawChordDiagram, _drawDiagramCached, drawSectionHud, drawToneHud, drawLyrics } =
createOverlay({ diagRenderCache: _diagRenderCache });
/* ── Scene initialisation ─────────────────────────────────────────── */
function initScene() {
+924
View File
@@ -0,0 +1,924 @@
// src/overlay.js — h3d-carve-7
// Lyrics + HUD overlay extracted from screen.js O-section (lines 43385209
// post-cut-6). Pure 2D canvas drawing — no Three.js dependency.
//
// screen.js usage (factory scope, once per createFactory() invocation):
// const { drawChordDiagram, _drawDiagramCached, drawSectionHud,
// drawToneHud, drawLyrics } = createOverlay({
// diagRenderCache, // stable const Map ref (screen.js factory-scope)
// // teardown calls .clear() on it directly; the shared
// // ref makes that visible to the overlay without a callback
// });
//
// Surprises vs contract:
// • longestConsecutiveRun (lines 43384352) co-moved — only called by drawChordDiagram
// • DIAG_SIZE_MIN, DIAG_SIZE_MAX, DIAG_CELL_MAX moved here and DELETED from
// screen.js lines 573575 (only used inside O-section)
// • _DIAG_CACHE_MAX moved inside createOverlay body and deleted from
// screen.js factory scope (line 3256) — same factory-scope level, different file
//
// Beyond-subst rewires (2):
// 1. Factory wrapper createOverlay({ diagRenderCache })
// 2. _diagRenderCache → diagRenderCache (DI param) in 3 sites in _drawDiagramCached
// ── Constants moved from screen.js module scope (lines 573575) ─────────────
// Only users were inside the O-section; no other callers remain in screen.js.
const DIAG_SIZE_MIN = 0.08;
const DIAG_SIZE_MAX = 0.16;
const DIAG_CELL_MAX = 34;
export function createOverlay({ diagRenderCache }) {
// ── Moved from screen.js factory scope (line 3256) ───────────────────────
// Cap chosen to cover the ~56 active chord shapes per phrase while
// keeping the cached-OffscreenCanvas footprint bounded (~50 MB per
// panel at typical 1920×1080). A structural fix — caching a
// tightly-sized box surface instead of the full overlay canvas —
// is tracked as a follow-up.
const _DIAG_CACHE_MAX = 6;
// ── Lyrics layout cache — moved from factory scope (line 5077) ───────────
// measureText per syllable + row wrapping only changes when the displayed
// line(s), font size, or canvas width change, not per frame.
let _lyrRowsCache = null;
// Returns indices of the longest consecutive run in a sorted integer
// array as { start, len } — `sorted[start..start+len)` is the run.
// Avoids the two per-call sub-array allocations of the previous
// implementation (best + cur arrays grown via .push), at the cost
// of one small 2-key result object. Net: callers in the chord-
// diagram render path no longer churn arrays per visible chord.
function longestConsecutiveRun(sorted) {
let bestStart = -1, bestLen = 0;
let curStart = -1, curLen = 0;
for (let i = 0; i < sorted.length; i++) {
if (curLen === 0 || sorted[i] === sorted[curStart + curLen - 1] + 1) {
if (curLen === 0) curStart = i;
curLen++;
} else {
if (curLen > bestLen) { bestLen = curLen; bestStart = curStart; }
curStart = i; curLen = 1;
}
}
if (curLen > bestLen) { bestLen = curLen; bestStart = curStart; }
return { start: bestStart, len: bestLen };
}
/* ── Lyrics overlay (2D canvas on top of WebGL) ─────────────────── */
function drawChordDiagram(ctx, opts) {
const {
name, frets,
opacity = 1,
entranceT = 1.0,
canvasW = 600, canvasH = 400,
inverted = false,
sizeSlider = 0.5,
position = 'tl',
nStr = 6,
lyricsBottom = 0,
stackOffset = 0,
} = opts;
// Responsive sizing — CELL derived from panel height + user slider.
// COLS is the resolved string count from the caller (via resolveStringCount)
// so bass (4), extended (7/8) arrangements render correctly.
const COLS = nStr, ROWS = 4;
// Minimum column span required for PATH B (bracket extension / detection).
// Math.min(COLS-1, 4) scales with string count:
// 4-string bass → 3 (max possible span, so 2-4-4-2 shapes qualify)
// 6-string → 4 (excludes D major span=2 / common 2-string coincidences)
// 8-string → 4 (muted outer strings still leave span ≥ 4 for real barres)
const MIN_BARRE_SPAN = Math.min(COLS - 1, 4);
// Maps diagram column index → chord-template frets-array index.
// Templates are high-e-first: frets[0]=high e, frets[COLS-1]=low E.
// Non-inverted display (col 0 = high e): getStrIdx(0) = 0 → frets[0] = high e.
// Inverted display (col 0 = low E): getStrIdx(0) = COLS-1 → frets[COLS-1] = low E.
const getStrIdx = col => inverted ? (COLS - 1 - col) : col;
const sizeF = DIAG_SIZE_MIN + (DIAG_SIZE_MAX - DIAG_SIZE_MIN) * sizeSlider;
// startFret / isFirstPos must be known before CELL so that fretLabelW
// can be measured and factored into the width cap. The old
// canvasW/(COLS+1.5) guard only approximated 2*PAD and ignored the
// extra left padding reserved for non-first-position "Nfr" labels.
const playedFrets = frets.filter(f => f > 0);
const minFret = playedFrets.length > 0 ? Math.min(...playedFrets) : 1;
const startFret = Math.max(1, minFret);
const isFirstPos = startFret === 1;
// Phase 1 — height + hard-cap estimate, used only to size the label font.
// Cap against the vertical space available below lyricsBottom so that the
// diagram does not overflow into the lyrics banner on short split panels with
// wrapped lyric rows. Only top-corner positions can overlap the lyrics banner,
// so lyricsBottom is only subtracted when position is 'tl' or 'tr'; for 'bl'
// and 'br' the full canvas height is available.
// Clamp to at least 1 so font/box calculations never receive 0-px input
// on very short panels (e.g. tiny split cells < 44 px tall).
const isTopCorner = position === 'tl' || position === 'tr';
const availH = canvasH - (isTopCorner ? lyricsBottom : 0);
const cellEst = Math.max(1, Math.min(
Math.round(availH * sizeF / (ROWS + 3)),
DIAG_CELL_MAX,
));
// Extra left padding for the "Nfr" label on non-first-position chords.
// Measured with ctx.measureText at cellEst so the estimate is exact.
let fretLabelW = 0;
if (!isFirstPos) {
// Measure inside a save/restore so this font assignment does not
// leak to the caller (the outer ctx.save() happens after CELL is derived).
ctx.save();
ctx.font = `italic ${Math.round(cellEst * 0.55)}px sans-serif`;
fretLabelW = Math.ceil(ctx.measureText(startFret + 'fr').width) + 6;
ctx.restore();
}
// Phase 2 — final CELL: cap against panel height, hard max, and panel width.
// Two width constraints are needed because PAD has a hard floor of 6:
// A) when PAD = CELL*0.65 (large CELL): CELL*(COLS+0.3) + fretLabelW ≤ canvasW
// B) when PAD = 6 floor (small CELL): CELL*(COLS-1) + 12 + fretLabelW ≤ canvasW
// Both are included so boxW ≤ canvasW in every regime.
// fretLabelW was measured at cellEst ≥ CELL, so the cap is conservative.
const CELL = Math.max(1, Math.min(
cellEst,
Math.floor((canvasW - fretLabelW) / (COLS + 0.3)),
Math.floor((canvasW - 2 * 6 - fretLabelW) / Math.max(1, COLS - 1)),
));
const HEADER = Math.round(CELL * 1.6);
const MARKER = Math.round(CELL * 0.7);
const DOT_R = CELL * 0.3;
const PAD = Math.max(6, Math.round(CELL * 0.65));
const gridW = CELL * (COLS - 1);
const gridH = CELL * ROWS;
const PAD_L = PAD + fretLabelW;
const boxW = gridW + PAD_L + PAD;
const boxH = HEADER + MARKER + gridH + PAD;
// Anchor to chosen corner. Top positions get extra vertical offset
// to clear the timeline plugin and song name displayed at the top.
// lyricsBottom is the actual bottom Y of the lyrics banner (returned by
// drawLyrics), so TOP_Y steps down past all lyric rows regardless of
// how many wrap lines the current panel width produces.
const E = PAD;
const TOP_Y = Math.round(Math.max(E + canvasH * 0.06, lyricsBottom + E));
let bx, by;
if (position === 'tr') { bx = canvasW - boxW - E; by = TOP_Y + stackOffset; }
else if (position === 'bl') { bx = E; by = canvasH - boxH - E - stackOffset; }
else if (position === 'br') { bx = canvasW - boxW - E; by = canvasH - boxH - E - stackOffset; }
else { bx = E; by = TOP_Y + stackOffset; }
// Clamp so the box never bleeds off-canvas on narrow panels or wide string counts.
bx = Math.max(0, Math.min(canvasW - boxW, bx));
by = Math.max(0, Math.min(canvasH - boxH, by));
// Guard: the canvasHboxH clamp above can push `by` above lyricsBottom when
// wrapped lyrics consume nearly the full panel height. This applies to ALL
// corner positions: a bottom-corner diagram anchored near the canvas bottom can
// still reach up into the lyrics banner on very short or narrow panels where
// boxH is larger than the space below the lyrics. In those cases skip drawing
// entirely rather than painting on top of the lyrics banner.
if (lyricsBottom > 0 && by < lyricsBottom) return 0;
const gx = bx + PAD_L, gy = by + HEADER + MARKER;
// Ease-out quadratic entrance scale: 0.85 → 1.0.
const scale = 1 - 0.15 * (1 - entranceT) * (1 - entranceT);
ctx.save();
ctx.globalAlpha = opacity;
if (scale !== 1.0) {
const cx = bx + boxW / 2, cy = by + boxH / 2;
ctx.translate(cx, cy);
ctx.scale(scale, scale);
ctx.translate(-cx, -cy);
}
// Background + border.
ctx.fillStyle = 'rgba(8, 14, 22, 0.88)';
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.stroke();
// Split-root typography: "Dm7" → "D" large bold + "m7" smaller.
const rootMatch = name.match(/^([A-G][#b]?)(.*)/);
const root = rootMatch ? rootMatch[1] : name;
const quality = rootMatch ? rootMatch[2] : '';
const rootSize = Math.round(CELL * 1.25);
const qualSize = Math.round(rootSize * 0.65);
ctx.textBaseline = 'middle';
const nameY = by + HEADER * 0.55;
ctx.font = `bold ${rootSize}px sans-serif`;
const rootW = ctx.measureText(root).width;
ctx.font = `${qualSize}px sans-serif`;
const qualW = quality ? ctx.measureText(quality).width : 0;
const nameBlockW = rootW + (quality ? qualW + 2 : 0);
const nameStartX = bx + boxW / 2 - nameBlockW / 2;
ctx.fillStyle = '#e8d080';
ctx.font = `bold ${rootSize}px sans-serif`;
ctx.textAlign = 'left';
ctx.fillText(root, nameStartX, nameY);
if (quality) {
ctx.font = `${qualSize}px sans-serif`;
ctx.fillStyle = 'rgba(232,208,128,0.75)';
ctx.fillText(quality, nameStartX + rootW + 2, nameY);
}
// Nut: CELL-proportional filled rect + subtle highlight line.
// Thickness is 40% of CELL, floored at 2 px so it stays visible on
// the smallest diagrams (CELL=1 on compact split panels).
const NUT_H = Math.round(Math.max(2, CELL * 0.4));
if (isFirstPos) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(gx, gy - NUT_H, gridW, NUT_H);
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.fillRect(gx, gy - NUT_H, gridW, Math.max(1, Math.round(NUT_H * 0.25)));
}
// Fret label for non-first-position chords.
if (!isFirstPos) {
ctx.fillStyle = 'rgba(220,200,120,0.9)';
ctx.font = `italic ${Math.round(CELL * 0.55)}px sans-serif`;
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(startFret + 'fr', gx - 4, gy + CELL * 0.5);
}
// Fret lines.
ctx.strokeStyle = 'rgba(255,255,255,0.22)'; ctx.lineWidth = 1;
for (let r = (isFirstPos ? 1 : 0); r <= ROWS; r++) {
ctx.beginPath();
ctx.moveTo(gx, gy + r * CELL);
ctx.lineTo(gx + gridW, gy + r * CELL);
ctx.stroke();
}
// String lines with varying weight: low E heavier, high e lighter.
// With getStrIdx(col) = col (non-inverted): col 0 (high e) → strIdx=0 → t=0 thin;
// col COLS-1 (low E) → strIdx=COLS-1 → t=1 thick. Inverted mode naturally mirrors.
// Weights scale with CELL so strings never bleed into adjacent columns on
// small-CELL diagrams (e.g. CELL=1 on compact split panels).
for (let col = 0; col < COLS; col++) {
const strIdx = getStrIdx(col);
const t = COLS > 1 ? strIdx / (COLS - 1) : 1; // 1=low E (thick), 0=high e (thin); guard COLS=1
ctx.lineWidth = Math.max(0.5, CELL * (0.05 + t * 0.10));
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.beginPath();
ctx.moveTo(gx + col * CELL, gy);
ctx.lineTo(gx + col * CELL, gy + ROWS * CELL);
ctx.stroke();
}
// Barre detection — two complementary paths:
//
// PATH A (F-shape / mini-barre): at least two ADJACENT columns are at startFret.
// Bracket is initially set to the consecutive run's own endpoints (not the full
// startFretCols range) so isolated bass notes at the same fret can't pull the
// bracket across an open gap (e.g. "2 0 2 2 0 0" stays bracketed at cols 2..3).
//
// PATH B (full-span barre / extension):
// When PATH A fired: extend the bracket outward to the full outer startFret span
// if the span ≥ MIN_BARRE_SPAN and every column between the outer startFret
// columns is fretted (f > 0).
// When PATH A did NOT fire: detect standalone full barres (e.g. x24442, x46654)
// where only the two outermost strings sit at startFret. An additional check
// ensures that no intermediate column is itself at startFret — this rules out
// alternating-fret voicings like "1 3 1 3 1 0" (col 2 at startFret would fire
// incorrectly) while still catching B-major-style shapes where the barre
// finger covers only the outer two strings.
//
// Templates are high-e-first: frets[0]=high e, frets[COLS-1]=low E.
// Examples (6-string, MIN_BARRE_SPAN=4):
// F major [1,1,2,3,3,1]: PATH A run=[4,5] → bracket 4..5; PATH B span=5, all fretted → extends to 0..5 ✓
// B major x24442: PATH A no run; PATH B span=4, all fretted, no inner at startFret → 1..5 ✓
// mini-A x02220: PATH A run=[2,3,4] → bracket 2..4; PATH B span=2<4 → no extension ✓
// D major xx0232: PATH A run length=1 → no PATH A; PATH B span<4 → no bracket ✓
// 2 0 2 2 0 0: PATH A run=[2,3] → bracket 2..3; PATH B span=3<4 → no extension ✓
// 1 3 1 3 1 0: PATH A no run; PATH B: inner col 2 at startFret → no bracket ✓
const startFretCols = [];
for (let col = 0; col < COLS; col++) {
if (frets[getStrIdx(col)] === startFret) startFretCols.push(col);
}
const barreRun = longestConsecutiveRun(startFretCols);
let hasBarreArc = barreRun.len >= 2; // PATH A
let barreMinCol = hasBarreArc ? startFretCols[barreRun.start] : -1;
let barreMaxCol = hasBarreArc ? startFretCols[barreRun.start + barreRun.len - 1] : -1;
if (startFretCols.length >= 2) { // PATH B
const minC = startFretCols[0];
const maxC = startFretCols[startFretCols.length - 1];
if (maxC - minC >= MIN_BARRE_SPAN) {
let allFretted = true;
for (let col = minC; col <= maxC; col++) {
if (frets[getStrIdx(col)] <= 0) { allFretted = false; break; }
}
if (allFretted) {
if (hasBarreArc) {
// PATH A fired: always safe to extend to full outer span.
barreMinCol = minC;
barreMaxCol = maxC;
} else {
// PATH A did not fire: only draw a bracket when no intermediate
// column sits at startFret. Intermediate startFret columns would
// indicate a scattered/alternating voicing rather than a clean
// outer-edge barre (e.g. "1 3 1 3 1 0" has col 2 at startFret).
let noInnerAtStartFret = true;
for (let col = minC + 1; col < maxC; col++) {
if (frets[getStrIdx(col)] === startFret) { noInnerAtStartFret = false; break; }
}
if (noInnerAtStartFret) {
hasBarreArc = true;
barreMinCol = minC;
barreMaxCol = maxC;
}
}
}
}
}
if (hasBarreArc) {
const barreY = gy + CELL * 0.5;
const capH = CELL * 0.22; // vertical offset from barreY to the bracket line
const capHalf = Math.max(1, Math.round(CELL * 0.3)); // half-height of the vertical end caps
// Straight bracket: a horizontal line with short vertical end caps.
// Stroke scales with CELL so it doesn't swamp tiny cells (floor at 1 px).
ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = Math.max(1, CELL * 0.2);
ctx.beginPath();
ctx.moveTo(gx + barreMinCol * CELL, barreY - capH);
ctx.lineTo(gx + barreMaxCol * CELL, barreY - capH);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(gx + barreMinCol * CELL, barreY - capH - capHalf);
ctx.lineTo(gx + barreMinCol * CELL, barreY - capH + capHalf);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(gx + barreMaxCol * CELL, barreY - capH - capHalf);
ctx.lineTo(gx + barreMaxCol * CELL, barreY - capH + capHalf);
ctx.stroke();
}
// Open/muted markers + finger dots.
// Non-inverted: col 0 = high e → getStrIdx(0)=0 → frets[0]; col COLS-1 = low E → frets[COLS-1].
// Inverted: col 0 = low E → getStrIdx(0)=COLS-1 → frets[COLS-1]; col COLS-1 = high e → frets[0].
for (let col = 0; col < COLS; col++) {
const f = frets[getStrIdx(col)];
const sx = gx + col * CELL;
const markerY = gy - MARKER * 0.5;
if (f < 0) {
const r = CELL * 0.20;
ctx.strokeStyle = '#cc4444'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(sx - r, markerY - r); ctx.lineTo(sx + r, markerY + r); ctx.stroke();
ctx.beginPath(); ctx.moveTo(sx + r, markerY - r); ctx.lineTo(sx - r, markerY + r); ctx.stroke();
} else if (f === 0) {
ctx.strokeStyle = '#88bbff'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(sx, markerY, CELL * 0.22, 0, Math.PI * 2); ctx.stroke();
} else {
const row = f - startFret;
if (row >= 0 && row < ROWS) {
const isBarreCol = hasBarreArc && f === startFret &&
col >= barreMinCol && col <= barreMaxCol;
ctx.shadowColor = 'rgba(0,0,0,0.5)';
ctx.shadowBlur = Math.min(4, CELL * 0.4);
ctx.shadowOffsetX = Math.max(0.5, CELL * 0.1);
ctx.shadowOffsetY = Math.max(0.5, CELL * 0.1);
ctx.fillStyle = isBarreCol ? 'rgba(255,255,255,0.85)' : '#ffffff';
ctx.beginPath();
ctx.arc(sx, gy + row * CELL + CELL * 0.5, DOT_R, 0, Math.PI * 2);
ctx.fill();
ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0;
ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 0;
}
}
}
ctx.restore();
return boxH;
}
// Cached wrapper for drawChordDiagram. When entranceT === 1 (scale
// transform is identity) the diagram is rendered once to an
// OffscreenCanvas and reused every subsequent frame via drawImage +
// globalAlpha. During the 0.2 s entrance animation (entranceT < 1)
// the scale transform is non-trivial so we fall through to a fresh
// render — that window is ~12 frames at 60 fps, negligible.
//
// Returns boxH (diagram card height in px) so the draw loop can
// accumulate per-corner stack offsets when multiple overlays share
// the same corner position.
function _drawDiagramCached(ctx, opts) {
const { opacity = 1, entranceT = 1.0, canvasW, canvasH } = opts;
if (opacity <= 0) return 0;
if (entranceT < 1.0) {
return drawChordDiagram(ctx, opts) || 0;
}
const { name, frets, nStr, inverted, sizeSlider, position, lyricsBottom = 0, stackOffset = 0 } = opts;
const key = name + '|' + (frets || []).join(',') + '|' + nStr + '|' +
(inverted ? 1 : 0) + '|' + sizeSlider + '|' + position + '|' +
canvasW + '|' + canvasH + '|' + lyricsBottom + '|' + stackOffset;
let entry = diagRenderCache.get(key);
if (!entry) {
let oc;
try { oc = new OffscreenCanvas(canvasW, canvasH); }
catch (_) { oc = document.createElement('canvas'); oc.width = canvasW; oc.height = canvasH; }
const boxH = drawChordDiagram(oc.getContext('2d'), { ...opts, opacity: 1, entranceT: 1 }) || 0;
if (diagRenderCache.size >= _DIAG_CACHE_MAX) {
diagRenderCache.delete(diagRenderCache.keys().next().value);
}
entry = { oc, boxH };
diagRenderCache.set(key, entry);
}
ctx.save();
ctx.globalAlpha = opacity;
ctx.drawImage(entry.oc, 0, 0);
ctx.restore();
return entry.boxH;
}
// Two-line section card. Top line is "Now: <current>", bottom line
// is "Up Next: <next> in <countdown>". Explicit labels disambiguate
// current vs upcoming — earlier single-line variant rendered both
// states with the same word and was confusing during playback.
//
// Returns boxH on draw, 0 when nothing rendered. Position / size
// mirror the chord-diagram contract: 'tl' / 'tr' / 'bl' / 'br'
// anchor corners, sizeSlider in [0,1] scales card height.
//
// Hidden when:
// - no sections array, or
// - playback has not yet reached the first section AND there's
// no upcoming-only fallback rendered (we still show "Up Next"
// during the pre-roll so the user sees what's coming).
function drawSectionHud(ctx, opts) {
const {
sections, currentTime,
canvasW, canvasH,
position = 'tr',
sizeSlider = 0.5,
lyricsBottom = 0,
stackOffset = 0,
} = opts;
if (!sections || !sections.length) return 0;
// sections are time-ordered server-side; single forward scan.
let curIdx = -1;
for (let i = 0; i < sections.length; i++) {
if (sections[i].time <= currentTime) curIdx = i;
else break;
}
const cur = curIdx >= 0 ? sections[curIdx] : null;
const next = (curIdx + 1 < sections.length) ? sections[curIdx + 1] : null;
// Pre-first-section: nothing playing yet but next is coming —
// still useful to render "Up Next" alone so the user gets the
// anticipatory cue during the song's intro silence.
if (!cur && !next) return 0;
const nowName = cur ? cur.name : '';
// Render countdown as a separate span so it can take a calmer
// grey-white treatment while the section name itself stays
// cyan. Combining them into one string would inherit the cyan
// fill across both, defeating the visual hierarchy promised
// in the FR.
let nextName = '';
let nextCountdown = '';
if (next) {
const dt = next.time - currentTime;
nextName = next.name;
nextCountdown = dt > 10
? 'in ' + Math.round(dt) + 's'
: 'in ' + Math.max(0, dt).toFixed(1) + 's';
}
const sizeF = 0.65 + 0.85 * sizeSlider; // 0.65 .. 1.5
const baseH = Math.max(34, Math.min(72, Math.round(canvasH * 0.085 * sizeF)));
const PAD_X = Math.round(baseH * 0.45);
const PAD_Y = Math.round(baseH * 0.20);
// Per-text-element scale applied to nameSize / tagSize / lineH
// when the unscaled card would overflow a narrow panel
// (splitscreen quad layout, ultra-tall portrait). Computed
// below from the measured contentW vs the available width.
let textScale = 1.0;
const baseLineH = Math.round(baseH * 0.46);
const baseNameSize = Math.round(baseH * 0.36);
const baseTagSize = Math.round(baseH * 0.24);
const baseTagGap = Math.round(baseH * 0.14);
const TAG_NOW = 'Now:';
const TAG_NEXT = 'Up Next:';
// Phase-1 measurement at the unscaled font sizes — used to
// decide whether textScale needs to drop, and to lay out the
// final draw at whatever scale we land on.
ctx.save();
ctx.font = `${baseTagSize}px sans-serif`;
const tagNowWBase = ctx.measureText(TAG_NOW).width;
const tagNextWBase = ctx.measureText(TAG_NEXT).width;
const countdownWBase = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
ctx.font = `bold ${baseNameSize}px sans-serif`;
const nowNameWBase = nowName ? ctx.measureText(nowName).width : 0;
const nextNameWBase = nextName ? ctx.measureText(nextName).width : 0;
ctx.restore();
const lineNowWBase = nowName ? tagNowWBase + baseTagGap + nowNameWBase : 0;
const lineNextWBase = nextName
? tagNextWBase + baseTagGap + nextNameWBase
+ (nextCountdown ? baseTagGap + countdownWBase : 0)
: 0;
const contentWBase = Math.max(lineNowWBase, lineNextWBase);
const numLines = (nowName ? 1 : 0) + (nextName ? 1 : 0);
if (numLines === 0) return 0;
// Target width budget: cap at canvasW - 16 and reserve PAD_X
// either side. If contentWBase exceeds the budget, scale the
// font proportionally — clamped to 0.55 so labels stay legible
// even on extreme split-panel widths.
const maxBoxW = Math.max(40, canvasW - 16);
const availContentW = Math.max(1, maxBoxW - PAD_X * 2);
if (contentWBase > availContentW) {
textScale = Math.max(0.55, availContentW / contentWBase);
}
const lineH = Math.max(1, Math.round(baseLineH * textScale));
const nameSize = Math.max(1, Math.round(baseNameSize * textScale));
const tagSize = Math.max(1, Math.round(baseTagSize * textScale));
const TAG_GAP = Math.max(1, Math.round(baseTagGap * textScale));
// Phase-2 re-measurement at the scaled font sizes for the
// final layout. measureText doesn't scale linearly with font
// size on every glyph, so re-measuring is cheaper than
// multiplying the base widths by textScale and risking a
// half-pixel overflow.
ctx.save();
ctx.font = `${tagSize}px sans-serif`;
const tagNowW = ctx.measureText(TAG_NOW).width;
const tagNextW = ctx.measureText(TAG_NEXT).width;
const countdownW = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
ctx.font = `bold ${nameSize}px sans-serif`;
const nowNameW = nowName ? ctx.measureText(nowName).width : 0;
const nextNameW = nextName ? ctx.measureText(nextName).width : 0;
ctx.restore();
const lineNowW = nowName ? tagNowW + TAG_GAP + nowNameW : 0;
const lineNextW = nextName
? tagNextW + TAG_GAP + nextNameW + (nextCountdown ? TAG_GAP + countdownW : 0)
: 0;
const contentW = Math.max(lineNowW, lineNextW);
const boxW = Math.min(maxBoxW, Math.round(contentW + PAD_X * 2));
const boxH = Math.round(numLines * lineH + PAD_Y * 2);
const E = Math.round(baseH * 0.25);
const TOP_Y = Math.round(Math.max(E + canvasH * 0.06, lyricsBottom + E));
let bx, by;
if (position === 'tr') { bx = canvasW - boxW - E; by = TOP_Y + stackOffset; }
else if (position === 'bl') { bx = E; by = canvasH - boxH - E - stackOffset; }
else if (position === 'br') { bx = canvasW - boxW - E; by = canvasH - boxH - E - stackOffset; }
else { bx = E; by = TOP_Y + stackOffset; }
bx = Math.max(0, Math.min(canvasW - boxW, bx));
by = Math.max(0, Math.min(canvasH - boxH, by));
// Suppress overlap with the wrapped lyrics banner regardless
// of corner. Bottom-corner cards on short panels can still
// reach up into the banner once boxH exceeds the space below
// the lyrics — same shape the chord diagram uses.
if (lyricsBottom > 0 && by < lyricsBottom) return 0;
ctx.save();
ctx.fillStyle = 'rgba(8, 14, 22, 0.88)';
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.stroke();
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
// Layout each line with tag left-aligned, name in cyan after a
// small gap. Both lines share the same x origin (bx + PAD_X)
// so the tag column visually aligns vertically.
const lineX = bx + PAD_X;
let lineY = by + PAD_Y + lineH / 2;
const TAG_COLOR = 'rgba(180,190,205,0.85)';
const NAME_COLOR = '#00cccc';
const TIME_COLOR = 'rgba(220,225,235,0.9)';
if (nowName) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TAG_COLOR;
ctx.fillText(TAG_NOW, lineX, lineY);
ctx.font = `bold ${nameSize}px sans-serif`;
ctx.fillStyle = NAME_COLOR;
ctx.fillText(nowName, lineX + tagNowW + TAG_GAP, lineY);
lineY += lineH;
}
if (nextName) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TAG_COLOR;
ctx.fillText(TAG_NEXT, lineX, lineY);
const nextX = lineX + tagNextW + TAG_GAP;
ctx.font = `bold ${nameSize}px sans-serif`;
ctx.fillStyle = NAME_COLOR;
ctx.fillText(nextName, nextX, lineY);
if (nextCountdown) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TIME_COLOR;
ctx.fillText(nextCountdown, nextX + nextNameW + TAG_GAP, lineY);
}
}
ctx.restore();
return boxH;
}
// Tone-change HUD — card showing the active tone and the next upcoming
// tone with a countdown. Mirrors drawSectionHud's layout contract
// (position, size slider, lyricsBottom) but uses an amber accent colour
// so it reads as distinct from the cyan section card.
function drawToneHud(ctx, opts) {
const {
toneChanges, toneBase = '',
currentTime,
canvasW, canvasH,
position = 'tl',
sizeSlider = 0.5,
lyricsBottom = 0,
stackOffset = 0,
} = opts;
// Resolve active tone: toneBase before all changes, else the most
// recent change at or before currentTime.
// toneChanges items use { t, name } (not { time, name }) — both
// the legacy import path (server.py xml_tone_changes) and the sloppak
// path (lib/tones.py sloppak_tone_changes) emit "t" as the key.
let curName = toneBase;
let nextChange = null;
if (toneChanges && toneChanges.length) {
for (let i = 0; i < toneChanges.length; i++) {
if (toneChanges[i].t <= currentTime) {
curName = toneChanges[i].name;
} else {
nextChange = toneChanges[i];
break;
}
}
}
if (!curName && !nextChange) return 0;
let nextName = '';
let nextCountdown = '';
if (nextChange) {
const dt = nextChange.t - currentTime;
nextName = nextChange.name;
nextCountdown = dt > 10
? 'in ' + Math.round(dt) + 's'
: 'in ' + Math.max(0, dt).toFixed(1) + 's';
}
const sizeF = 0.65 + 0.85 * sizeSlider;
const baseH = Math.max(34, Math.min(72, Math.round(canvasH * 0.085 * sizeF)));
const PAD_X = Math.round(baseH * 0.45);
const PAD_Y = Math.round(baseH * 0.20);
let textScale = 1.0;
const baseLineH = Math.round(baseH * 0.46);
const baseNameSize = Math.round(baseH * 0.36);
const baseTagSize = Math.round(baseH * 0.24);
const baseTagGap = Math.round(baseH * 0.14);
const TAG_CUR = 'Tone:';
const TAG_NEXT = 'Next:';
ctx.save();
ctx.font = `${baseTagSize}px sans-serif`;
const tagCurWBase = ctx.measureText(TAG_CUR).width;
const tagNextWBase = ctx.measureText(TAG_NEXT).width;
const countdownWBase = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
ctx.font = `bold ${baseNameSize}px sans-serif`;
const curNameWBase = curName ? ctx.measureText(curName).width : 0;
const nextNameWBase = nextName ? ctx.measureText(nextName).width : 0;
ctx.restore();
const lineCurWBase = curName ? tagCurWBase + baseTagGap + curNameWBase : 0;
const lineNextWBase = nextName
? tagNextWBase + baseTagGap + nextNameWBase
+ (nextCountdown ? baseTagGap + countdownWBase : 0)
: 0;
const contentWBase = Math.max(lineCurWBase, lineNextWBase);
const numLines = (curName ? 1 : 0) + (nextName ? 1 : 0);
if (numLines === 0) return 0;
const maxBoxW = Math.max(40, canvasW - 16);
const availContentW = Math.max(1, maxBoxW - PAD_X * 2);
if (contentWBase > availContentW) {
textScale = Math.max(0.55, availContentW / contentWBase);
}
const lineH = Math.max(1, Math.round(baseLineH * textScale));
const nameSize = Math.max(1, Math.round(baseNameSize * textScale));
const tagSize = Math.max(1, Math.round(baseTagSize * textScale));
const TAG_GAP = Math.max(1, Math.round(baseTagGap * textScale));
ctx.save();
ctx.font = `${tagSize}px sans-serif`;
const tagCurW = ctx.measureText(TAG_CUR).width;
const tagNextW = ctx.measureText(TAG_NEXT).width;
const countdownW = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
ctx.font = `bold ${nameSize}px sans-serif`;
const curNameW = curName ? ctx.measureText(curName).width : 0;
const nextNameW = nextName ? ctx.measureText(nextName).width : 0;
ctx.restore();
const lineCurW = curName ? tagCurW + TAG_GAP + curNameW : 0;
const lineNextW = nextName
? tagNextW + TAG_GAP + nextNameW + (nextCountdown ? TAG_GAP + countdownW : 0)
: 0;
const contentW = Math.max(lineCurW, lineNextW);
const boxW = Math.min(maxBoxW, Math.round(contentW + PAD_X * 2));
const boxH = Math.round(numLines * lineH + PAD_Y * 2);
const E = Math.round(baseH * 0.25);
const TOP_Y = Math.round(Math.max(E + canvasH * 0.06, lyricsBottom + E));
let bx, by;
if (position === 'tr') { bx = canvasW - boxW - E; by = TOP_Y + stackOffset; }
else if (position === 'bl') { bx = E; by = canvasH - boxH - E - stackOffset; }
else if (position === 'br') { bx = canvasW - boxW - E; by = canvasH - boxH - E - stackOffset; }
else { bx = E; by = TOP_Y + stackOffset; } // 'tl' default
bx = Math.max(0, Math.min(canvasW - boxW, bx));
by = Math.max(0, Math.min(canvasH - boxH, by));
if (lyricsBottom > 0 && by < lyricsBottom) return 0;
ctx.save();
ctx.fillStyle = 'rgba(8, 14, 22, 0.88)';
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.stroke();
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
const lineX = bx + PAD_X;
let lineY = by + PAD_Y + lineH / 2;
const TAG_COLOR = 'rgba(180,190,205,0.85)';
const NAME_COLOR = '#ff9a3c'; // amber — distinct from section cyan
const TIME_COLOR = 'rgba(220,225,235,0.9)';
if (curName) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TAG_COLOR;
ctx.fillText(TAG_CUR, lineX, lineY);
ctx.font = `bold ${nameSize}px sans-serif`;
ctx.fillStyle = NAME_COLOR;
ctx.fillText(curName, lineX + tagCurW + TAG_GAP, lineY);
lineY += lineH;
}
if (nextName) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TAG_COLOR;
ctx.fillText(TAG_NEXT, lineX, lineY);
const nextX = lineX + tagNextW + TAG_GAP;
ctx.font = `bold ${nameSize}px sans-serif`;
ctx.fillStyle = NAME_COLOR;
ctx.fillText(nextName, nextX, lineY);
if (nextCountdown) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TIME_COLOR;
ctx.fillText(nextCountdown, nextX + nextNameW + TAG_GAP, lineY);
}
}
ctx.restore();
return boxH;
}
function drawLyrics(lyrics, currentTime, ctx, W, H) {
if (!lyrics._lines) {
const lines = [];
let line = null, word = null;
const flushWord = () => { if (word && word.length) line.words.push(word); word = null; };
const flushLine = () => { flushWord(); if (line && line.words.length) lines.push(line); line = null; };
for (let i = 0; i < lyrics.length; i++) {
const l = lyrics[i];
const raw = l.w || '';
const endsLine = raw.endsWith('+');
const continuesWord = raw.endsWith('-');
if (line && i > 0 && l.t - (lyrics[i - 1].t + lyrics[i - 1].d) > 4.0) flushLine();
if (!line) line = { words: [], start: l.t, end: l.t + l.d };
if (!word) word = [];
word.push(l);
line.end = Math.max(line.end, l.t + l.d);
if (!continuesWord) flushWord();
if (endsLine) flushLine();
}
flushLine();
lyrics._lines = lines;
}
const allLines = lyrics._lines;
if (!allLines.length) return 0;
let currentIdx = -1;
for (let i = 0; i < allLines.length; i++) {
if (allLines[i].start <= currentTime) currentIdx = i;
else break;
}
if (currentIdx === -1) {
if (allLines[0].start - currentTime > 2.0) return 0;
currentIdx = 0;
}
const currentLine = allLines[currentIdx];
const nextLine = allLines[currentIdx + 1] || null;
const gapToNext = nextLine ? (nextLine.start - currentLine.end) : Infinity;
if (currentTime > currentLine.end + 0.5 && gapToNext > 3.0) return 0;
const linesToShow = [currentLine];
if (nextLine && gapToNext <= 3.0) linesToShow.push(nextLine);
const fontSize = Math.max(18, H * 0.028) | 0;
const lineY = H * 0.04;
const sylText = s => { const t = s.w || ''; return (t.endsWith('+') || t.endsWith('-')) ? t.slice(0, -1) : t; };
ctx.font = `bold ${fontSize}px sans-serif`;
let rows, spaceWidth, bgWidth;
const _lc = _lyrRowsCache;
if (_lc && _lc.lyricsRef === lyrics && _lc.idx === currentIdx
&& _lc.shown === linesToShow.length
&& _lc.fontSize === fontSize && _lc.W === W) {
rows = _lc.rows; spaceWidth = _lc.spaceWidth; bgWidth = _lc.bgWidth;
} else {
spaceWidth = ctx.measureText(' ').width;
const maxWidth = W * 0.8;
rows = [];
for (const authoredLine of linesToShow) {
let row = [], rowWidth = 0;
for (const wordSyls of authoredLine.words) {
const parts = [];
let wordWidth = 0;
for (const s of wordSyls) {
const text = sylText(s);
const w = ctx.measureText(text).width;
parts.push({ syl: s, text, width: w });
wordWidth += w;
}
const advance = wordWidth + spaceWidth;
if (row.length > 0 && rowWidth + advance > maxWidth) { rows.push(row); row = []; rowWidth = 0; }
row.push({ parts, advance });
rowWidth += advance;
}
if (row.length) rows.push(row);
}
bgWidth = 0;
for (const row of rows) {
const rw = row.reduce((s, w) => s + w.advance, 0) - spaceWidth;
if (rw > bgWidth) bgWidth = rw;
}
bgWidth = Math.min(bgWidth + 30, W * 0.85);
_lyrRowsCache = {
lyricsRef: lyrics, idx: currentIdx,
shown: linesToShow.length, fontSize, W,
rows, spaceWidth, bgWidth,
};
}
const rowHeight = fontSize + 6;
const totalHeight = rows.length * rowHeight + 10;
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.beginPath();
const bx = W / 2 - bgWidth / 2, by = lineY - 4, br = 8;
ctx.moveTo(bx + br, by); ctx.lineTo(bx + bgWidth - br, by);
ctx.quadraticCurveTo(bx + bgWidth, by, bx + bgWidth, by + br);
ctx.lineTo(bx + bgWidth, by + totalHeight - br);
ctx.quadraticCurveTo(bx + bgWidth, by + totalHeight, bx + bgWidth - br, by + totalHeight);
ctx.lineTo(bx + br, by + totalHeight);
ctx.quadraticCurveTo(bx, by + totalHeight, bx, by + totalHeight - br);
ctx.lineTo(bx, by + br);
ctx.quadraticCurveTo(bx, by, bx + br, by);
ctx.closePath();
ctx.fill();
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
for (let r = 0; r < rows.length; r++) {
const row = rows[r];
const rowWidth = row.reduce((s, w) => s + w.advance, 0) - spaceWidth;
let xPos = W / 2 - rowWidth / 2;
const yPos = lineY + r * rowHeight + 2;
for (const w of row) {
for (const part of w.parts) {
const l = part.syl;
const isActive = currentTime >= l.t && currentTime < l.t + l.d;
const isPast = currentTime >= l.t + l.d;
ctx.fillStyle = isActive ? '#4ae0ff' : isPast ? '#8899aa' : '#556677';
ctx.font = `${isActive ? 'bold' : 'normal'} ${fontSize}px sans-serif`;
ctx.fillText(part.text, xPos, yPos);
xPos += part.width;
}
xPos += spaceWidth;
}
}
// Return the actual bottom Y of the rendered background box so callers
// (e.g. drawChordDiagram) can avoid overlapping it.
return Math.round(by + totalHeight);
}
return {
drawChordDiagram,
_drawDiagramCached,
drawSectionHud,
drawToneHud,
drawLyrics,
};
}
+234
View File
@@ -0,0 +1,234 @@
// Source + behavioural guards for h3d-carve-7: O-section (lyrics + HUD overlay)
// extracted to src/overlay.js.
//
// Class-killers guaranteed:
// 1. Module exports createOverlay (source)
// 2. createOverlay return set covers all 5 required symbols (source)
// 3. Stranded-caller: every returned symbol appears in screen.js destructure (source)
// 4. Private-guard: factory-depth-1 privates not bare in screen.js (source)
// 5. Moved constants absent from screen.js (source — deletion check)
// 6. _diagRenderCache ref-identity: teardown .clear() reaches the same Map passed to
// createOverlay (behavioural — mutation: new Map() breaks it → RED)
// 7. drawSectionHud returns 0 when no sections (behavioural)
// 8. drawLyrics returns a number (behavioural)
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const OVERLAY_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'overlay.js');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
function stripComments(s) {
return s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
}
function src() {
return fs.readFileSync(OVERLAY_JS, 'utf8');
}
function screenSrc() {
return fs.readFileSync(SCREEN_JS, 'utf8');
}
// ── 1. Module exports createOverlay ─────────────────────────────────────────
test('overlay.js exports createOverlay', () => {
assert.match(src(), /export\s+function\s+createOverlay\s*\(/);
});
// ── 2. Return set covers all 5 required symbols ──────────────────────────────
test('createOverlay returns all 5 required symbols', () => {
const stripped = stripComments(src());
const REQUIRED = ['drawChordDiagram', '_drawDiagramCached', 'drawSectionHud', 'drawToneHud', 'drawLyrics'];
// Match the factory-level return block (4-space indent inside createOverlay).
// Inner function returns like longestConsecutiveRun's are at 8+ spaces.
const retMatch = stripped.match(/\n {4}return\s*\{\s*\n([\s\S]+?)\n {4}\};/);
assert.ok(retMatch, 'factory-level return block must be present');
const returned = retMatch[1].split(',').map(s => s.trim()).filter(Boolean);
for (const sym of REQUIRED) {
assert.ok(returned.includes(sym), `return set must include ${sym}`);
}
});
// ── 3. Stranded-caller: returned ⊆ screen.js destructure ────────────────────
test('every createOverlay returned symbol appears in screen.js destructure', () => {
// Mutation: remove _drawDiagramCached from screen.js destructure → missing → RED.
const stripped = stripComments(src());
const retMatch = stripped.match(/\n {4}return\s*\{\s*\n([\s\S]+?)\n {4}\};/);
assert.ok(retMatch, 'factory-level return block must be present');
const returned = retMatch[1].split(',').map(s => s.trim()).filter(Boolean);
const scrRaw = screenSrc();
const destrMatch = scrRaw.match(/const\s*\{([^}]+)\}\s*=\s*createOverlay\s*\(/);
assert.ok(destrMatch, 'screen.js must have a createOverlay destructure');
const destructured = destrMatch[1].split(',').map(s => s.trim()).filter(Boolean);
for (const sym of returned) {
assert.ok(destructured.includes(sym),
`returned symbol '${sym}' must appear in screen.js createOverlay destructure`);
}
});
// ── 4. Private-guard: factory-depth-1 privates not bare in screen.js ─────────
test('factory-private symbols in overlay.js do not appear bare in screen.js', () => {
// Mutation: add bare _DIAG_CACHE_MAX to screen.js → violations → RED.
const stripped = stripComments(src());
// Collect returned symbols (factory-level return, 4-space indent).
const retMatch = stripped.match(/\n {4}return\s*\{\s*\n([\s\S]+?)\n {4}\};/);
assert.ok(retMatch, 'factory-level return block must be present');
const returned = new Set(
retMatch[1].split(',').map(s => s.trim()).filter(Boolean)
);
// Factory-depth-1 const/let: exactly 4-space indent inside createOverlay body.
const privateSyms = [];
for (const m of stripped.matchAll(/^ {4}(?:const|let)\s+(\w+)/gm)) {
const sym = m[1];
if (!returned.has(sym)) privateSyms.push(sym);
}
assert.ok(privateSyms.length > 0, 'factory must have at least one private depth-1 declaration');
let scr = screenSrc().replace(/^import\s+.*\n/gm, '');
scr = stripComments(scr);
scr = scr.replace(/const\s*\{[^}]+\}\s*=\s*createOverlay\s*\([^)]*\)\s*;/, '');
const violations = privateSyms.filter(sym =>
new RegExp('\\b' + sym + '\\b').test(scr)
);
assert.deepStrictEqual(violations, [],
'screen.js must not reference factory-private overlay.js symbols: ' + violations.join(', '));
});
// ── 5. Moved constants absent from screen.js ─────────────────────────────────
test('DIAG_SIZE_MIN, DIAG_SIZE_MAX, DIAG_CELL_MAX, _DIAG_CACHE_MAX absent from screen.js', () => {
// Mutation: add const DIAG_SIZE_MIN = 0.08 back to screen.js → RED.
const scr = stripComments(screenSrc());
for (const sym of ['DIAG_SIZE_MIN', 'DIAG_SIZE_MAX', 'DIAG_CELL_MAX', '_DIAG_CACHE_MAX']) {
assert.doesNotMatch(scr, new RegExp('const\\s+' + sym + '\\b'),
`const ${sym} must not appear in screen.js (it moved to overlay.js)`);
}
});
// ── 6. Ref-identity class-killer: source-scan ────────────────────────────────
test('screen.js passes _diagRenderCache (not a new Map) to createOverlay', () => {
// This is the primary ref-severing class-killer god requested.
// Mutation: change createOverlay({ diagRenderCache: _diagRenderCache })
// to createOverlay({ diagRenderCache: new Map() })
// → teardown .clear() on screen.js's _diagRenderCache no longer reaches the
// overlay cache → cache leaks → this test goes RED.
const scr = stripComments(screenSrc());
assert.match(
scr,
/createOverlay\s*\(\s*\{\s*diagRenderCache\s*:\s*_diagRenderCache\s*\}\s*\)/,
'screen.js must pass _diagRenderCache (not a new Map or other value) as diagRenderCache to createOverlay',
);
});
// ── 6b8: Behavioural tests in a vm sandbox ──────────────────────────────────
// createOverlay needs a diagRenderCache Map (stable ref). The functions under
// test do canvas 2D drawing; we stub ctx with the minimal surface they call.
function makeCtx() {
return {
save() {}, restore() {}, beginPath() {}, fill() {}, stroke() {},
moveTo() {}, lineTo() {}, arc() {}, roundRect() {}, closePath() {},
fillText() {}, strokeText() {}, quadraticCurveTo() {},
fillRect() {}, strokeRect() {}, drawImage() {},
measureText(t) { return { width: t.length * 7 }; },
fillStyle: '', strokeStyle: '', lineWidth: 1,
globalAlpha: 1, font: '', textAlign: '', textBaseline: '',
shadowColor: '', shadowBlur: 0, shadowOffsetX: 0, shadowOffsetY: 0,
};
}
function loadModule(diagRenderCache) {
const raw = fs.readFileSync(OVERLAY_JS, 'utf8');
// Strip the ES module export keyword so the script runs in a vm CommonJS-style.
// Use /m flag so ^ matches line starts (file begins with a comment block).
const code = raw.replace(/^export\s+function\s+createOverlay/m, 'function createOverlay');
const sandbox = {
OffscreenCanvas: class { constructor(w, h) { this.width=w; this.height=h; }
getContext() { return makeCtx(); } },
document: {
createElement() {
return { width: 0, height: 0, getContext() { return makeCtx(); } };
}
},
console,
__exports: {},
};
vm.createContext(sandbox);
vm.runInContext(code + '\n__exports.createOverlay = createOverlay;', sandbox);
return sandbox.__exports.createOverlay({ diagRenderCache });
}
// ── 6. _diagRenderCache ref-identity ────────────────────────────────────────
test('diagRenderCache passed to createOverlay is the same Map reached by teardown .clear()', () => {
// This is the class-killer god requested.
//
// Mutation: in screen.js, change
// createOverlay({ diagRenderCache: _diagRenderCache })
// to
// createOverlay({ diagRenderCache: new Map() })
// → the overlay populates its own Map, but screen.js teardown clears _diagRenderCache
// (a different object) → overlay cache leaks → both Map sizes diverge → RED.
//
// Here we verify the ref is the same Map by populating a sentinel key via
// _drawDiagramCached (which writes to diagRenderCache) and then confirming
// the original Map reference sees the write.
const sharedMap = new Map();
const { _drawDiagramCached } = loadModule(sharedMap);
const ctx = makeCtx();
// entranceT < 1 bypasses cache; entranceT = 1.0 triggers the cache write.
// Set opacity=0 to short-circuit before the cache write → use opacity=1.
_drawDiagramCached(ctx, {
name: 'Am', frets: [0, 0, 2, 2, 1, 0], nStr: 6,
inverted: false, sizeSlider: 0.5, position: 'tl',
canvasW: 600, canvasH: 400, opacity: 1, entranceT: 1.0,
lyricsBottom: 0, stackOffset: 0,
});
// The overlay must have written to sharedMap (the same ref we passed in).
assert.ok(sharedMap.size > 0,
'overlay must write to the diagRenderCache Map reference passed in via DI; ' +
'if size=0 the ref was severed (createOverlay got a different Map)');
// Simulating teardown: clear the same Map as screen.js would.
sharedMap.clear();
assert.equal(sharedMap.size, 0, 'Map cleared by teardown must now be empty');
// A second call re-populates the shared Map (not a separate internal one).
_drawDiagramCached(ctx, {
name: 'G', frets: [3, 2, 0, 0, 3, 3], nStr: 6,
inverted: false, sizeSlider: 0.5, position: 'tl',
canvasW: 600, canvasH: 400, opacity: 1, entranceT: 1.0,
lyricsBottom: 0, stackOffset: 0,
});
assert.ok(sharedMap.size > 0, 'cache repopulated via the same shared Map reference');
});
// ── 7. drawSectionHud returns 0 for no sections ───────────────────────────────
test('drawSectionHud returns 0 when sections array is empty', () => {
const { drawSectionHud } = loadModule(new Map());
const ctx = makeCtx();
const result = drawSectionHud(ctx, {
sections: [], currentTime: 10,
canvasW: 800, canvasH: 600,
});
assert.equal(result, 0);
});
// ── 8. drawLyrics returns a number ───────────────────────────────────────────
test('drawLyrics returns a finite number', () => {
const { drawLyrics } = loadModule(new Map());
const ctx = makeCtx();
const lyrics = [
{ w: 'Hel-', t: 0, d: 0.3 }, { w: 'lo+', t: 0.3, d: 0.3 },
{ w: 'World', t: 0.6, d: 0.4 },
];
const result = drawLyrics(lyrics, 0.15, ctx, 800, 600);
assert.ok(typeof result === 'number' && isFinite(result),
'drawLyrics must return a finite number (bottom Y of lyrics banner)');
});
@@ -112,6 +112,14 @@ function loadHighway3dStatics() {
_meshMatForGhostFretDigit() {}, _spriteMat2MeshMat() {},
pool: () => ({ get() {}, reset() {}, warm() { return this; } }),
}),
// h3d-carve-7: overlay (lyrics + HUD) moved to src/overlay.js.
createOverlay: () => ({
drawChordDiagram() { return 0; },
_drawDiagramCached() { return 0; },
drawSectionHud() { return 0; },
drawToneHud() { return 0; },
drawLyrics() { return 0; },
}),
};
vm.createContext(sandbox);
vm.runInContext(instrumented, sandbox, { filename: SCREEN_JS });