From 66ccc90fddf7573d6b9b387ead9df5141f2959dc Mon Sep 17 00:00:00 2001 From: vadimwit Date: Mon, 15 Jun 2026 00:40:44 +0100 Subject: [PATCH] =?UTF-8?q?feat(jamguide):=20ChordDiagram=20=E2=80=94=20KB?= =?UTF-8?q?=20guitar-shape=20SVG,=20key-aware=20fret=20placement=20(task?= =?UTF-8?q?=20D-01b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders movable {rootStr,offsets} and open {frets,onlyRoot} KB shapes; computes movable base fret from chord rootPc in standard tuning (E-shape A7 -> fret 5); root in accent purple; thumb/full sizes; graceful '-' fallback for unplaceable shapes. Critic PASS. Co-Authored-By: Claude Fable 5 --- src/components/ChordDiagram.jsx | 312 ++++++++++++++++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 src/components/ChordDiagram.jsx diff --git a/src/components/ChordDiagram.jsx b/src/components/ChordDiagram.jsx new file mode 100644 index 0000000..3647f0b --- /dev/null +++ b/src/components/ChordDiagram.jsx @@ -0,0 +1,312 @@ +// Compact SVG guitar chord-diagram. Renders the KB guitar shape format (D-01b). +// Secondary per-station voicing thumbnail in the Roadmap panel. +// +// Two shape forms (see src/data/kb/SCHEMA.md): +// movable: { rootStr, offsets, fingers } +// - offsets: 6 entries low-E→high-E, integers = fret offset from the barre +// (base) fret, 'x' = muted. The base fret is derived from where the chord +// root (rootPc) sits on rootStr in standard tuning. +// open: { frets, onlyRoot, fingers } +// - frets: 6 entries low-E→high-E, 0 = open, 'x' = muted, integers = absolute. +// +// Props: +// shape — movable or open form above +// keyRoot — tonic pitch class 0–11 (accepted; see note below) +// rootPc — chord root pitch class 0–11 (drives movable placement) +// size — 'thumb' (compact ~64px grid) | 'full' (enlarged + finger #s + label) +// label — optional chord label shown under the grid +// +// Note on key-awareness: the SCHEMA derives the movable base fret from where the +// root note sits "for the current key". In practice the absolute fret depends only +// on the chord root pitch class on rootStr, which is supplied directly as `rootPc`. +// keyRoot is accepted for contract compatibility and used as a fallback for rootPc. + +// Standard tuning open-string pitch classes, indexed low-E (0) → high-E (5). +const OPEN_PCS = [4, 9, 2, 7, 11, 4] // E A D G B E + +// We render strings top→bottom as high-E first (matches Fretboard.jsx idiom), +// so display index 0 = high E, 5 = low E. Data arrays are low-E first, so the +// data index for display row `di` is `5 - di`. + +const ACCENT = '#a855f7' // chord-tone tier (root highlight) +const DOT = '#e5e7eb' // non-root finger dots (light gray, AA on dark board) +const DOT_TEXT_DARK = '#1a1a1a' +const BOARD = '#1a120b' // matches Fretboard board fill +const FRET_LINE = '#4a3a2a' +const NUT_COL = '#c0b090' +const STRING_COL = '#9ca3af' +const MUTE_OPEN = '#9ca3af' +const FRET_LABEL = '#9ca3af' + +const NUM_STRINGS = 6 +const NUM_FRETS = 5 // visible fret rows in the grid + +// ── Fret resolution ────────────────────────────────────────────────────────── +// Returns { frets: number|'x' per display row (high-E first), baseFret, rootRow } +// where baseFret is the absolute fret of the top visible grid line (1 = nut shown). +function resolveShape(shape, rootPc, keyRoot) { + if (!shape) return null + + // Open shape: absolute frets, low-E first. + if (Array.isArray(shape.frets)) { + const abs = shape.frets // low-E first + const fretted = abs.filter(f => typeof f === 'number' && f > 0) + const minFret = fretted.length ? Math.min(...fretted) : 0 + const maxFret = fretted.length ? Math.max(...fretted) : 0 + // Show the nut (baseFret 1) when the shape reaches up to fret ~4 from the nut. + const baseFret = maxFret <= NUM_FRETS ? 1 : minFret + return { + open: true, + absLowE: abs, + baseFret, + rootPc: typeof shape.onlyRoot === 'number' ? shape.onlyRoot : rootPc, + } + } + + // Movable shape: offsets relative to a base (barre) fret on rootStr. + if (Array.isArray(shape.offsets)) { + const rootStr = shape.rootStr // 6 = low E … 1 = high E + const rootStrIdx = 6 - rootStr // → low-E-first array index + const targetPc = typeof rootPc === 'number' ? rootPc + : typeof keyRoot === 'number' ? keyRoot : 0 + const openPc = OPEN_PCS[rootStrIdx] ?? 4 + // Smallest fret >= 1 where the root pc lands on rootStr. + let baseFret = ((targetPc - openPc) % 12 + 12) % 12 + if (baseFret === 0) baseFret = 12 // root at open string → use the octave barre + return { + open: false, + offsets: shape.offsets, // low-E first + baseFret, + rootStrIdx, + rootPc: targetPc, + } + } + + return null +} + +// Build per-display-row absolute fret + root flag from a resolved shape. +// Returns null if the shape cannot be placed gracefully (root above ~fret 12). +function buildRows(resolved) { + if (!resolved) return null + + // Absolute fret per low-E-first data index. + let absLowE + if (resolved.open) { + absLowE = resolved.absLowE + } else { + absLowE = resolved.offsets.map(o => + o === 'x' || o == null ? 'x' : resolved.baseFret + o + ) + } + + // Highest fretted note — degrade if unplayably high. + const fretted = absLowE.filter(f => typeof f === 'number' && f > 0) + const maxFret = fretted.length ? Math.max(...fretted) : 0 + if (maxFret > 15) return null + + // Window: lowest visible fret of the grid. + // Show the nut if everything fits within NUM_FRETS of it; else start at the + // lowest fretted note so the grip sits at the top of the window. + const minFret = fretted.length ? Math.min(...fretted) : 0 + const startFret = maxFret <= NUM_FRETS ? 1 : minFret + + // Root pitch class for colouring. + const rootPc = resolved.rootPc + + // Convert to display rows (high-E first → reverse of low-E-first). + const rows = [] + for (let di = 0; di < NUM_STRINGS; di++) { + const dataIdx = NUM_STRINGS - 1 - di + const f = absLowE[dataIdx] + const stringPc = (OPEN_PCS[dataIdx] + (typeof f === 'number' ? f : 0)) % 12 + const isRoot = typeof f === 'number' && f >= 0 && stringPc === rootPc + rows.push({ fret: f, isRoot, stringPc }) + } + + return { rows, startFret, showNut: startFret === 1 } +} + +export default function ChordDiagram({ + shape, + keyRoot, + rootPc, + size = 'thumb', + label, +}) { + const resolved = resolveShape(shape, rootPc, keyRoot) + const built = buildRows(resolved) + + const full = size === 'full' + + // Geometry. thumb grid ~64px wide; full ~2x. + const scale = full ? 2 : 1 + const cell = 11 * scale // px per fret row (vertical) + const sw = 11 * scale // px per string gap (horizontal) + const padL = 14 * scale // left pad (mute/open markers + start-fret label) + const padR = 6 * scale + const padT = 11 * scale // top pad (mute/open marker row) + const padB = (full ? 16 : 6) * scale // bottom pad (finger numbers / breathing room) + + const gridW = (NUM_STRINGS - 1) * sw + const gridH = NUM_FRETS * cell + const svgW = padL + gridW + padR + const svgH = padT + gridH + padB + + const stringX = si => padL + si * sw // si: 0 = high E (left) … 5 = low E + const fretY = fi => padT + fi * cell // fi: 0 = top line … NUM_FRETS + + if (!built) { + // Graceful degradation: shape can't be placed. + return ( +
+ + {label && full && {label}} +
+ ) + } + + const { rows, startFret, showNut } = built + + const fingers = shape?.fingers // low-E first, optional + + const ariaLabel = label + ? `${label} guitar chord diagram` + : 'guitar chord diagram' + + return ( +
+ + {/* Board background */} + + + {/* Start-fret indicator ("5fr") when the grid begins above the nut */} + {!showNut && ( + + {startFret}fr + + )} + + {/* Frets (horizontal lines) */} + {Array.from({ length: NUM_FRETS + 1 }, (_, fi) => fi).map(fi => { + const topNut = showNut && fi === 0 + return ( + + ) + })} + + {/* Strings (vertical lines) */} + {rows.map((_, si) => ( + = 4 ? 1.4 : si >= 2 ? 1.1 : 0.8) * scale} + /> + ))} + + {/* Per-string markers: mute ✕ / open ○ above the nut, dots on the grid */} + {rows.map((row, si) => { + const x = stringX(si) + const dataIdx = NUM_STRINGS - 1 - si + const finger = fingers ? fingers[dataIdx] : 0 + + // Muted string → ✕ above the board. + if (row.fret === 'x' || row.fret == null) { + const my = padT - 4 * scale + const r = 3 * scale + return ( + + + + + ) + } + + // Open string (absolute fret 0, only meaningful when nut is shown) → ○. + if (row.fret === 0) { + return ( + + ) + } + + // Fretted note → dot, positioned in its fret row within the window. + const rowInWindow = row.fret - startFret // 0-based row from top + if (rowInWindow < 0 || rowInWindow >= NUM_FRETS) return null + const cy = fretY(rowInWindow) + cell / 2 + const r = (full ? 4 : 3.5) * scale + const fill = row.isRoot ? ACCENT : DOT + const showFinger = full && finger > 0 + return ( + + + {showFinger && ( + + {finger} + + )} + + ) + })} + + + {label && ( + + {label} + + )} +
+ ) +}