mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 04:34:30 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
a993d2b291
commit
0a91f4f1b1
@@ -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
@@ -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 ~5–6 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 canvasH–boxH 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 4338–4352) co-moved — only called by drawChordDiagram
|
||||
// • DIAG_SIZE_MIN, DIAG_SIZE_MAX, DIAG_CELL_MAX deleted from screen.js lines 573–575
|
||||
// • _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() {
|
||||
|
||||
@@ -0,0 +1,924 @@
|
||||
// src/overlay.js — h3d-carve-7
|
||||
// Lyrics + HUD overlay extracted from screen.js O-section (lines 4338–5209
|
||||
// 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 4338–4352) co-moved — only called by drawChordDiagram
|
||||
// • DIAG_SIZE_MIN, DIAG_SIZE_MAX, DIAG_CELL_MAX moved here and DELETED from
|
||||
// screen.js lines 573–575 (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 573–575) ─────────────
|
||||
// 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 ~5–6 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 canvasH–boxH 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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
// ── 6b–8: 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 });
|
||||
|
||||
Reference in New Issue
Block a user