mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-15 13:17:25 +00:00
feat(highway): render teaching marks fg/ch/sd on 2D + 3D (§6.2.2) (#538)
Render the three per-note teaching marks on both highways, mirroring the bend-curve render (#532). Display only — no scoring / NoteVerifier coupling. - 2D static/highway.js: fg renders by default as a small finger numeral hugging the gem (T = thumb, 1..4); sd (degree label) and ch (strum bracket connecting notes that share a ch key, arrow direction from pkd) are opt-in behind a new `showTeachingMarks` toggle (exposed via toggle/get/set + the bundle's `teachingMarksVisible` flag). Pure helpers teachingFingerLabel / teachingDegreeLabel / strumGroupBuckets drive the glyphs. ch bracket is note-stream-only (chord notes already read as one gesture). - 3D plugins/highway_3d/screen.js: fg (default) + sd (opt-in, mirrors the 2D toggle via bundle.teachingMarksVisible) render next to the per-note fret label via a new pooled sprite (pTeachMarkLbl); _scrChordNote resets fg/sd so chord notes don't inherit stale marks. ch strum brackets are deferred in 3D (no cross-note batch pass in the per-note render); 2D covers ch. Tests: tests/js/highway_teaching_marks.test.js extracts the pure helpers from both files (extract-and-eval) and asserts label mapping + strum-group bucketing. Part of got-feedback/feedback#334 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
6ee5da3d8b
commit
0f1006972b
@@ -2534,6 +2534,9 @@
|
|||||||
let _drawRecentByString = null;
|
let _drawRecentByString = null;
|
||||||
/** Snapshotted in update() — drawNote() is a sibling of update(), not nested in its closure. */
|
/** Snapshotted in update() — drawNote() is a sibling of update(), not nested in its closure. */
|
||||||
let _drawChordTemplates = null;
|
let _drawChordTemplates = null;
|
||||||
|
/** Teaching marks sd/ch overlay pref (§6.2.2), mirrored from the 2D
|
||||||
|
* highway's `teachingMarksVisible` bundle flag. fg renders regardless. */
|
||||||
|
let _drawTeachingMarks = false;
|
||||||
let _laneTargetColor = null;
|
let _laneTargetColor = null;
|
||||||
let _renderScale = 1;
|
let _renderScale = 1;
|
||||||
let lyricsCanvas = null, lyricsCtx = null;
|
let lyricsCanvas = null, lyricsCtx = null;
|
||||||
@@ -3017,6 +3020,7 @@
|
|||||||
let gPMXLines = null, pMuteXLines = null; // PM X lines combined geometry (8 segs as quads)
|
let gPMXLines = null, pMuteXLines = null; // PM X lines combined geometry (8 segs as quads)
|
||||||
let gFHXLines = null, pFHXLines = null; // FH X lines combined geometry
|
let gFHXLines = null, pFHXLines = null; // FH X lines combined geometry
|
||||||
let pNoteFretLabel, pConnectorLine, pDropLine, pTapChevron, pAccentHalo;
|
let pNoteFretLabel, pConnectorLine, pDropLine, pTapChevron, pAccentHalo;
|
||||||
|
let pTeachMarkLbl; // teaching marks fg/sd label sprites (§6.2.2)
|
||||||
let pHaloBar = null, gHaloBar = null; // gradient halo bar geometry — replaces per-shell pChordAccentHalo
|
let pHaloBar = null, gHaloBar = null; // gradient halo bar geometry — replaces per-shell pChordAccentHalo
|
||||||
let gArpBracket = null; // shared 1×1×1 box geometry for pArpBracket; built once, disposed in teardown
|
let gArpBracket = null; // shared 1×1×1 box geometry for pArpBracket; built once, disposed in teardown
|
||||||
let pSusRibbon = null, pSusRibbonOl = null;
|
let pSusRibbon = null, pSusRibbonOl = null;
|
||||||
@@ -6095,6 +6099,14 @@
|
|||||||
_nfl.material.depthTest = false;
|
_nfl.material.depthTest = false;
|
||||||
return _nfl;
|
return _nfl;
|
||||||
});
|
});
|
||||||
|
// Teaching marks fg/sd labels (§6.2.2). One pool, two get()s per note
|
||||||
|
// (finger + degree); the texture is swapped per draw via material.map.
|
||||||
|
pTeachMarkLbl = pool(lblG, () => {
|
||||||
|
const _tml = new T.Sprite(txtMat('0', '#7fd1ff', false, 'teachMark').clone());
|
||||||
|
_tml.material.fog = false;
|
||||||
|
_tml.material.depthTest = false;
|
||||||
|
return _tml;
|
||||||
|
});
|
||||||
pConnectorLine = pool(noteG, () => new T.Line(
|
pConnectorLine = pool(noteG, () => new T.Line(
|
||||||
new T.BufferGeometry().setFromPoints([new T.Vector3(0, 0, 0), new T.Vector3(0, 1, 0)]),
|
new T.BufferGeometry().setFromPoints([new T.Vector3(0, 0, 0), new T.Vector3(0, 1, 0)]),
|
||||||
new T.LineBasicMaterial({ color: 0xaaaaaa, transparent: true, opacity: 0.5, depthTest: false }),
|
new T.LineBasicMaterial({ color: 0xaaaaaa, transparent: true, opacity: 0.5, depthTest: false }),
|
||||||
@@ -6153,6 +6165,7 @@
|
|||||||
pSusRailBloom.warm(_WARM_CHORD);
|
pSusRailBloom.warm(_WARM_CHORD);
|
||||||
pTechPlane.warm(_WARM_CHORD);
|
pTechPlane.warm(_WARM_CHORD);
|
||||||
pNoteFretLabel.warm(_WARM_NOTE);
|
pNoteFretLabel.warm(_WARM_NOTE);
|
||||||
|
pTeachMarkLbl.warm(_WARM_NOTE);
|
||||||
pChordFrameFill.warm(_WARM_CHORD);
|
pChordFrameFill.warm(_WARM_CHORD);
|
||||||
pChordBox.warm(_WARM_CHORD);
|
pChordBox.warm(_WARM_CHORD);
|
||||||
pChordLbl.warm(_WARM_CHORD);
|
pChordLbl.warm(_WARM_CHORD);
|
||||||
@@ -8353,6 +8366,7 @@
|
|||||||
if (pMuteXLines) pMuteXLines.reset();
|
if (pMuteXLines) pMuteXLines.reset();
|
||||||
if (pFHXLines) pFHXLines.reset();
|
if (pFHXLines) pFHXLines.reset();
|
||||||
pNoteFretLabel.reset(); pConnectorLine.reset(); pDropLine.reset();
|
pNoteFretLabel.reset(); pConnectorLine.reset(); pDropLine.reset();
|
||||||
|
pTeachMarkLbl.reset();
|
||||||
pFretColMarker.reset(); pSusRail.reset(); pSusRailBloom.reset(); pTechPlane.reset();
|
pFretColMarker.reset(); pSusRail.reset(); pSusRailBloom.reset(); pTechPlane.reset();
|
||||||
// Clear per-frame queues in-place (avoid reallocating the array object).
|
// Clear per-frame queues in-place (avoid reallocating the array object).
|
||||||
_ndLabels.length = 0;
|
_ndLabels.length = 0;
|
||||||
@@ -8816,6 +8830,7 @@
|
|||||||
|
|
||||||
_drawNextByString = nextNoteByString;
|
_drawNextByString = nextNoteByString;
|
||||||
_drawChordTemplates = bundle.chordTemplates ?? null;
|
_drawChordTemplates = bundle.chordTemplates ?? null;
|
||||||
|
_drawTeachingMarks = !!bundle.teachingMarksVisible;
|
||||||
|
|
||||||
// ── Recent-past event per string (for _nextAnyT deadline) ─────
|
// ── Recent-past event per string (for _nextAnyT deadline) ─────
|
||||||
// Once a note/chord passes `now` it leaves _drawNextByString,
|
// Once a note/chord passes `now` it leaves _drawNextByString,
|
||||||
@@ -9830,6 +9845,12 @@
|
|||||||
// apply the wrong contour). Reset explicitly.
|
// apply the wrong contour). Reset explicitly.
|
||||||
_scrChordNote.bnv = Array.isArray(cn.bnv) ? cn.bnv : undefined;
|
_scrChordNote.bnv = Array.isArray(cn.bnv) ? cn.bnv : undefined;
|
||||||
_scrChordNote.bt = cn.bt || 0;
|
_scrChordNote.bt = cn.bt || 0;
|
||||||
|
// Same stale-scratch hazard for the teaching marks
|
||||||
|
// (§6.2.2): fg/sd are omit-when-default on the wire,
|
||||||
|
// so a chord note without them must reset to -1 or it
|
||||||
|
// inherits the previous note's finger/degree label.
|
||||||
|
_scrChordNote.fg = Number.isInteger(cn.fg) ? cn.fg : -1;
|
||||||
|
_scrChordNote.sd = Number.isInteger(cn.sd) ? cn.sd : -1;
|
||||||
drawNote(
|
drawNote(
|
||||||
_scrChordNote,
|
_scrChordNote,
|
||||||
now,
|
now,
|
||||||
@@ -11316,6 +11337,22 @@
|
|||||||
return visualIdx >= (nStr - 1) * 0.5 ? -1 : 1;
|
return visualIdx >= (nStr - 1) * 0.5 ? -1 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Teaching marks (§6.2.2) — display only, never grading. Pure label
|
||||||
|
// helpers, mirroring static/highway.js so the two highways agree;
|
||||||
|
// node-tested via tests/js/highway_teaching_marks.test.js.
|
||||||
|
function teachingFingerLabel(fg) {
|
||||||
|
// fret-hand finger: '' when unset/out of range; 0 -> 'T' (thumb),
|
||||||
|
// 1..4 -> '1'..'4'.
|
||||||
|
if (!Number.isInteger(fg) || fg < 0 || fg > 4) return '';
|
||||||
|
return fg === 0 ? 'T' : String(fg);
|
||||||
|
}
|
||||||
|
function teachingDegreeLabel(sd) {
|
||||||
|
// scale degree: chromatic 0..11 above the active key tonic; '' when
|
||||||
|
// unset/out of range.
|
||||||
|
if (!Number.isInteger(sd) || sd < 0 || sd > 11) return '';
|
||||||
|
return String(sd);
|
||||||
|
}
|
||||||
|
|
||||||
function bnvSampleAt(bnv, t) {
|
function bnvSampleAt(bnv, t) {
|
||||||
// Linear interpolation of a bend curve [{t, v}] (§6.2.1; t is
|
// Linear interpolation of a bend curve [{t, v}] (§6.2.1; t is
|
||||||
// seconds from the note onset) at elapsed time t. Clamps to the
|
// seconds from the note onset) at elapsed time t. Clamps to the
|
||||||
@@ -12260,6 +12297,32 @@
|
|||||||
fretLabel.scale.set(flS, flS, 1);
|
fretLabel.scale.set(flS, flS, 1);
|
||||||
fretLabel.material.opacity = alpha;
|
fretLabel.material.opacity = alpha;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Teaching marks (§6.2.2) — display only, never grading. The
|
||||||
|
// fret-hand finger (fg) renders by default to the right of the
|
||||||
|
// fret label; the scale degree (sd) is opt-in (mirrors the 2D
|
||||||
|
// `teachingMarksVisible` toggle) and renders to the left.
|
||||||
|
if (alpha > 0 && n.f > 0) {
|
||||||
|
const _tmS = 5.0 * K * _textSizeMul * fretLabelScaleForFret(n.f);
|
||||||
|
const _drawTeachMark = (text, colorHex, dx, cacheKey) => {
|
||||||
|
if (!text) return;
|
||||||
|
const spr = pTeachMarkLbl.get();
|
||||||
|
const m = txtMat(text, colorHex, false, cacheKey);
|
||||||
|
if (spr.material.map !== m.map) {
|
||||||
|
spr.material.map = m.map;
|
||||||
|
spr.material.needsUpdate = true;
|
||||||
|
}
|
||||||
|
spr.position.set(x + dx, labelY, noteZ);
|
||||||
|
spr.renderOrder = renderOrderForLayerAtZ(noteZ,
|
||||||
|
_isArpNote ? 'ARP_NOTE_FRET_LABEL' : 'NOTE_FRET_LABEL');
|
||||||
|
spr.scale.set(_tmS, _tmS, 1);
|
||||||
|
spr.material.opacity = alpha;
|
||||||
|
};
|
||||||
|
_drawTeachMark(teachingFingerLabel(n.fg), '#7fd1ff', NW * 0.95, 'teachFg');
|
||||||
|
if (_drawTeachingMarks) {
|
||||||
|
_drawTeachMark(teachingDegreeLabel(n.sd), '#ffcc66', -NW * 0.95, 'teachSd');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12973,7 +13036,7 @@
|
|||||||
_renderScale = 1;
|
_renderScale = 1;
|
||||||
mBeatM = mBeatQ = null;
|
mBeatM = mBeatQ = null;
|
||||||
pNote = pNoteEdge = pSus = pSusOutline = pSusRibbon = pSusRibbonOl = pLbl = pBeat = pSec = null;
|
pNote = pNoteEdge = pSus = pSusOutline = pSusRibbon = pSusRibbonOl = pLbl = pBeat = pSec = null;
|
||||||
pFretLbl = pLane = pLaneDivider = pGhostFretLbl = pChordBox = pChordFrameFill = pChordLbl = pBarreLine = pArpBracket = pNoteFretLabel = pConnectorLine = pDropLine = pTapChevron = pAccentHalo = pHaloBar = pPMXFill = pFHXFill = pMuteXLines = pFHXLines = null;
|
pFretLbl = pLane = pLaneDivider = pGhostFretLbl = pChordBox = pChordFrameFill = pChordLbl = pBarreLine = pArpBracket = pNoteFretLabel = pConnectorLine = pDropLine = pTapChevron = pAccentHalo = pHaloBar = pPMXFill = pFHXFill = pMuteXLines = pFHXLines = pTeachMarkLbl = null;
|
||||||
if (gPMXFill) { gPMXFill.dispose(); gPMXFill = null; }
|
if (gPMXFill) { gPMXFill.dispose(); gPMXFill = null; }
|
||||||
if (gFHXFill) { gFHXFill.dispose(); gFHXFill = null; }
|
if (gFHXFill) { gFHXFill.dispose(); gFHXFill = null; }
|
||||||
if (gPMXLines) { gPMXLines.dispose(); gPMXLines = null; }
|
if (gPMXLines) { gPMXLines.dispose(); gPMXLines = null; }
|
||||||
|
|||||||
+120
-1
@@ -205,6 +205,11 @@ function createHighway() {
|
|||||||
// have any.
|
// have any.
|
||||||
let _phrasesHaveHandShapes = false;
|
let _phrasesHaveHandShapes = false;
|
||||||
let showLyrics = localStorage.getItem('showLyrics') !== 'false';
|
let showLyrics = localStorage.getItem('showLyrics') !== 'false';
|
||||||
|
// Teaching marks (§6.2.2): the fret-hand finger numeral (fg) renders by
|
||||||
|
// default (small, on the gem), but the scale-degree (sd) + strum-group (ch)
|
||||||
|
// overlays are opt-in so the default highway stays uncluttered. Display only
|
||||||
|
// — never used for grading.
|
||||||
|
let _showTeachingMarks = localStorage.getItem('showTeachingMarks') === 'true';
|
||||||
let _drawHooks = []; // plugin draw callbacks: fn(ctx, W, H)
|
let _drawHooks = []; // plugin draw callbacks: fn(ctx, W, H)
|
||||||
// slopsmith#254 — per-note judgment overlay. A plugin (note_detect)
|
// slopsmith#254 — per-note judgment overlay. A plugin (note_detect)
|
||||||
// registers fn(note, chartTime) -> 'hit' | 'active' | 'miss' | null
|
// registers fn(note, chartTime) -> 'hit' | 'active' | 'miss' | null
|
||||||
@@ -485,6 +490,37 @@ function createHighway() {
|
|||||||
return bnv.map(p => ({ x: span > 0 ? (p.t - t0) / span : 0, v: p.v }));
|
return bnv.map(p => ({ x: span > 0 ? (p.t - t0) / span : 0, v: p.v }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Teaching mark (§6.2.2): fret-hand-finger label for a note's `fg`.
|
||||||
|
* '' when unset/out of range; 0 → 'T' (thumb), 1..4 → '1'..'4'. Pure. */
|
||||||
|
function teachingFingerLabel(fg) {
|
||||||
|
if (!Number.isInteger(fg) || fg < 0 || fg > 4) return '';
|
||||||
|
return fg === 0 ? 'T' : String(fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Teaching mark (§6.2.2): scale-degree label for a note's `sd` (chromatic
|
||||||
|
* 0..11 above the active key tonic). '' when unset/out of range. Pure. */
|
||||||
|
function teachingDegreeLabel(sd) {
|
||||||
|
if (!Number.isInteger(sd) || sd < 0 || sd > 11) return '';
|
||||||
|
return String(sd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Teaching mark (§6.2.2): bucket drawn notes by their strum-group key `ch`.
|
||||||
|
* Returns the groups (in first-seen order) for each ch value >= 0 that has
|
||||||
|
* at least two members — a lone note is not a strum gesture. Pure; drives
|
||||||
|
* the strum-bracket overlay and is node-tested. */
|
||||||
|
function strumGroupBuckets(items) {
|
||||||
|
if (!Array.isArray(items)) return [];
|
||||||
|
const order = [];
|
||||||
|
const byKey = new Map();
|
||||||
|
for (const it of items) {
|
||||||
|
const ch = it && Number.isInteger(it.ch) ? it.ch : -1;
|
||||||
|
if (ch < 0) continue;
|
||||||
|
if (!byKey.has(ch)) { byKey.set(ch, []); order.push(ch); }
|
||||||
|
byKey.get(ch).push(it);
|
||||||
|
}
|
||||||
|
return order.map(k => byKey.get(k)).filter(g => g.length >= 2);
|
||||||
|
}
|
||||||
|
|
||||||
/** Call while lefty mirror transform is active; keeps glyphs readable. */
|
/** Call while lefty mirror transform is active; keeps glyphs readable. */
|
||||||
function fillTextReadable(text, x, y) {
|
function fillTextReadable(text, x, y) {
|
||||||
// ctx may be null when the 2D context was never acquired
|
// ctx may be null when the 2D context was never acquired
|
||||||
@@ -737,6 +773,9 @@ function createHighway() {
|
|||||||
lefty: _lefty,
|
lefty: _lefty,
|
||||||
renderScale: _effectiveRenderScale(),
|
renderScale: _effectiveRenderScale(),
|
||||||
lyricsVisible: showLyrics,
|
lyricsVisible: showLyrics,
|
||||||
|
// Teaching marks sd/ch overlay pref (§6.2.2) so custom renderers
|
||||||
|
// (e.g. the 3D highway) can mirror the 2D opt-in toggle.
|
||||||
|
teachingMarksVisible: _showTeachingMarks,
|
||||||
|
|
||||||
// 2D-style helpers (renderers that don't need these can ignore).
|
// 2D-style helpers (renderers that don't need these can ignore).
|
||||||
// `fillTextUnmirrored` is deliberately NOT exposed here —
|
// `fillTextUnmirrored` is deliberately NOT exposed here —
|
||||||
@@ -1687,6 +1726,29 @@ function createHighway() {
|
|||||||
|
|
||||||
if (sz < 14) return; // Skip small technique labels
|
if (sz < 14) return; // Skip small technique labels
|
||||||
|
|
||||||
|
// Teaching marks (§6.2.2) — display only, never grading. The fret-hand
|
||||||
|
// finger (fg) renders by default as a small numeral hugging the gem's
|
||||||
|
// right edge (T = thumb, 1..4); the scale degree (sd) is opt-in and sits
|
||||||
|
// on the left edge so the two never collide with the centred fret number.
|
||||||
|
const fgLabel = teachingFingerLabel(opts?.fg);
|
||||||
|
if (fgLabel) {
|
||||||
|
ctx.fillStyle = '#7fd1ff';
|
||||||
|
ctx.font = `bold ${Math.max(8, sz * 0.26) | 0}px sans-serif`;
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
fillTextReadable(fgLabel, x + half + 2, y + half * 0.5);
|
||||||
|
}
|
||||||
|
if (_showTeachingMarks) {
|
||||||
|
const sdLabel = teachingDegreeLabel(opts?.sd);
|
||||||
|
if (sdLabel) {
|
||||||
|
ctx.fillStyle = '#ffcc66';
|
||||||
|
ctx.font = `bold ${Math.max(8, sz * 0.26) | 0}px sans-serif`;
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
fillTextReadable(sdLabel, x - half - 2, y + half * 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Slide indicator (diagonal arrow). Pitched (sl) draws a solid arrow to
|
// Slide indicator (diagonal arrow). Pitched (sl) draws a solid arrow to
|
||||||
// the target fret; unpitched (slu) draws a dashed diagonal with no
|
// the target fret; unpitched (slu) draws a dashed diagonal with no
|
||||||
// arrowhead (no definite target pitch). The two are mutually exclusive
|
// arrowhead (no definite target pitch). The two are mutually exclusive
|
||||||
@@ -1879,11 +1941,56 @@ function createHighway() {
|
|||||||
|
|
||||||
const x = fretX(n.f, p.scale, W);
|
const x = fretX(n.f, p.scale, W);
|
||||||
drawNote(W, H, x, p.y * H, p.scale, n.s, n.f, n, _noteStateProvider ? _noteState(n, n.t) : null);
|
drawNote(W, H, x, p.y * H, p.scale, n.s, n.f, n, _noteStateProvider ? _noteState(n, n.t) : null);
|
||||||
drawnNotes.push({ t: n.t, s: n.s, f: n.f, bn: n.bn || 0, x, y: p.y * H, scale: p.scale });
|
drawnNotes.push({
|
||||||
|
t: n.t, s: n.s, f: n.f, bn: n.bn || 0, x, y: p.y * H, scale: p.scale,
|
||||||
|
ch: Number.isInteger(n.ch) ? n.ch : -1,
|
||||||
|
pkd: Number.isInteger(n.pkd) ? n.pkd : -1,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw unison bend connectors
|
// Draw unison bend connectors
|
||||||
drawUnisonBends(W, H, drawnNotes);
|
drawUnisonBends(W, H, drawnNotes);
|
||||||
|
// Strum-group brackets (teaching mark ch, §6.2.2) — opt-in overlay.
|
||||||
|
// Scoped to standalone notes (the stream drawNotes renders); chord-note
|
||||||
|
// strum groups aren't bracketed (the editor authors ch over single-note
|
||||||
|
// selections, and chord notes already read as one simultaneous gesture).
|
||||||
|
if (_showTeachingMarks) drawStrumGroups(W, H, drawnNotes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawStrumGroups(W, H, drawnNotes) {
|
||||||
|
// Teaching mark (§6.2.2): notes sharing a `ch` key >= 0 are one
|
||||||
|
// strum/rake gesture. Connect each group's gems with a bracket and a
|
||||||
|
// single arrowhead whose direction comes from `pkd` (0 = down-strum,
|
||||||
|
// 1 = up-strum). Display only — never grading.
|
||||||
|
for (const group of strumGroupBuckets(drawnNotes)) {
|
||||||
|
const pts = group.slice().sort((a, b) => a.y - b.y || a.x - b.x);
|
||||||
|
const scale = pts[0].scale;
|
||||||
|
const sz = Math.max(12, 80 * scale * (H / 900));
|
||||||
|
if (sz < 14) continue;
|
||||||
|
const pkd = (group.find(p => p.pkd === 0 || p.pkd === 1) || {}).pkd;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = '#c89bff';
|
||||||
|
ctx.lineWidth = Math.max(2, sz / 12);
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.beginPath();
|
||||||
|
pts.forEach((p, i) => (i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y)));
|
||||||
|
ctx.stroke();
|
||||||
|
// Arrowhead at the gesture start: down-strum (pkd 0) points toward
|
||||||
|
// the last gem, up-strum (pkd 1) toward the first.
|
||||||
|
if (pkd === 0 || pkd === 1) {
|
||||||
|
const head = pkd === 1 ? pts[0] : pts[pts.length - 1];
|
||||||
|
const from = pkd === 1 ? pts[1] : pts[pts.length - 2];
|
||||||
|
const dy = Math.sign(head.y - from.y) || 1;
|
||||||
|
const a = sz * 0.18;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(head.x - a, head.y - dy * a);
|
||||||
|
ctx.lineTo(head.x, head.y);
|
||||||
|
ctx.lineTo(head.x + a, head.y - dy * a);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawUnisonBends(W, H, drawnNotes) {
|
function drawUnisonBends(W, H, drawnNotes) {
|
||||||
@@ -3675,6 +3782,18 @@ function createHighway() {
|
|||||||
},
|
},
|
||||||
setOnLyricsChange(fn) { _onLyricsChange = fn; },
|
setOnLyricsChange(fn) { _onLyricsChange = fn; },
|
||||||
|
|
||||||
|
// Teaching marks (§6.2.2): toggle the opt-in sd/ch overlays. The fg
|
||||||
|
// numeral is unaffected (always on). Persisted to localStorage.
|
||||||
|
getTeachingMarksVisible() { return _showTeachingMarks; },
|
||||||
|
toggleTeachingMarks() {
|
||||||
|
_showTeachingMarks = !_showTeachingMarks;
|
||||||
|
localStorage.setItem('showTeachingMarks', String(_showTeachingMarks));
|
||||||
|
},
|
||||||
|
setTeachingMarksVisible(v) {
|
||||||
|
_showTeachingMarks = !!v;
|
||||||
|
localStorage.setItem('showTeachingMarks', String(_showTeachingMarks));
|
||||||
|
},
|
||||||
|
|
||||||
reconnect(filename, arrangement) {
|
reconnect(filename, arrangement) {
|
||||||
// Close old WS but keep audio + animation running
|
// Close old WS but keep audio + animation running
|
||||||
if (ws) { ws.close(); ws = null; }
|
if (ws) { ws.close(); ws = null; }
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Behavioural tests for the teaching-marks (§6.2.2) render helpers:
|
||||||
|
// teachingFingerLabel / teachingDegreeLabel (both highways) and
|
||||||
|
// strumGroupBuckets (2D, drives the strum bracket). All pure, so we extract
|
||||||
|
// the function source by brace-matching and eval it in isolation — same
|
||||||
|
// pattern as highway_bend_curve.test.js.
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
function extractFn(src, name) {
|
||||||
|
const start = src.indexOf('function ' + name);
|
||||||
|
assert.ok(start >= 0, `function ${name} must exist`);
|
||||||
|
const open = src.indexOf('{', start);
|
||||||
|
let depth = 0;
|
||||||
|
for (let i = open; i < src.length; i++) {
|
||||||
|
if (src[i] === '{') depth++;
|
||||||
|
else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1);
|
||||||
|
}
|
||||||
|
throw new Error(`unbalanced braces extracting ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFn(file, name) {
|
||||||
|
const src = fs.readFileSync(path.join(__dirname, '..', '..', file), 'utf8');
|
||||||
|
return new Function('"use strict";' + extractFn(src, name) + `\nreturn ${name};`)();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fingerLabel2D = loadFn('static/highway.js', 'teachingFingerLabel');
|
||||||
|
const degreeLabel2D = loadFn('static/highway.js', 'teachingDegreeLabel');
|
||||||
|
const fingerLabel3D = loadFn('plugins/highway_3d/screen.js', 'teachingFingerLabel');
|
||||||
|
const degreeLabel3D = loadFn('plugins/highway_3d/screen.js', 'teachingDegreeLabel');
|
||||||
|
const strumGroupBuckets = loadFn('static/highway.js', 'strumGroupBuckets');
|
||||||
|
|
||||||
|
// ── teachingFingerLabel (fg) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
for (const [name, fn] of [['2D', fingerLabel2D], ['3D', fingerLabel3D]]) {
|
||||||
|
test(`teachingFingerLabel (${name}) maps 0->T, 1..4->digit, else ''`, () => {
|
||||||
|
assert.equal(fn(0), 'T'); // thumb
|
||||||
|
assert.equal(fn(1), '1');
|
||||||
|
assert.equal(fn(4), '4'); // pinky
|
||||||
|
assert.equal(fn(-1), ''); // unset
|
||||||
|
assert.equal(fn(5), ''); // out of range
|
||||||
|
assert.equal(fn(1.5), ''); // non-integer
|
||||||
|
assert.equal(fn(undefined), '');
|
||||||
|
assert.equal(fn(null), '');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── teachingDegreeLabel (sd) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
for (const [name, fn] of [['2D', degreeLabel2D], ['3D', degreeLabel3D]]) {
|
||||||
|
test(`teachingDegreeLabel (${name}) shows 0..11, else ''`, () => {
|
||||||
|
assert.equal(fn(0), '0'); // tonic
|
||||||
|
assert.equal(fn(7), '7'); // fifth
|
||||||
|
assert.equal(fn(11), '11');
|
||||||
|
assert.equal(fn(-1), ''); // unset
|
||||||
|
assert.equal(fn(12), ''); // out of range
|
||||||
|
assert.equal(fn(3.2), ''); // non-integer
|
||||||
|
assert.equal(fn(undefined), '');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── strumGroupBuckets (ch) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('strumGroupBuckets groups notes sharing a ch >= 0, dropping lone notes', () => {
|
||||||
|
const items = [
|
||||||
|
{ id: 'a', ch: 5 },
|
||||||
|
{ id: 'b', ch: -1 }, // ungrouped
|
||||||
|
{ id: 'c', ch: 5 },
|
||||||
|
{ id: 'd', ch: 7 }, // lone group (only one member) -> dropped
|
||||||
|
{ id: 'e', ch: 5 },
|
||||||
|
];
|
||||||
|
const groups = strumGroupBuckets(items);
|
||||||
|
assert.equal(groups.length, 1);
|
||||||
|
assert.deepEqual(groups[0].map(n => n.id), ['a', 'c', 'e']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strumGroupBuckets preserves first-seen group order and handles multiple groups', () => {
|
||||||
|
const items = [
|
||||||
|
{ id: 'a', ch: 2 }, { id: 'b', ch: 9 },
|
||||||
|
{ id: 'c', ch: 2 }, { id: 'd', ch: 9 },
|
||||||
|
];
|
||||||
|
const groups = strumGroupBuckets(items);
|
||||||
|
assert.deepEqual(groups.map(g => g.map(n => n.id)), [['a', 'c'], ['b', 'd']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strumGroupBuckets ignores non-integer / negative ch and bad input', () => {
|
||||||
|
assert.deepEqual(strumGroupBuckets([{ ch: -1 }, { ch: 1.5 }, { ch: null }, {}]), []);
|
||||||
|
assert.deepEqual(strumGroupBuckets([]), []);
|
||||||
|
assert.deepEqual(strumGroupBuckets(null), []);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user