Files
JamBuddy/src/components/ChordDiagram.jsx
T
vadimwit 66ccc90fdd feat(jamguide): ChordDiagram — KB guitar-shape SVG, key-aware fret placement (task D-01b)
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 <noreply@anthropic.com>
2026-06-15 00:40:44 +01:00

313 lines
11 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 011 (accepted; see note below)
// rootPc — chord root pitch class 011 (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 (
<div
className="inline-flex flex-col items-center justify-center bg-panel border border-border rounded-lg text-gray-500"
style={{ width: svgW, minHeight: svgH }}
role="img"
aria-label={label ? `${label}: voicing unavailable` : 'voicing unavailable'}
>
<span style={{ fontSize: 9 * scale }}></span>
{label && full && <span style={{ fontSize: 8 * scale }} className="mt-1">{label}</span>}
</div>
)
}
const { rows, startFret, showNut } = built
const fingers = shape?.fingers // low-E first, optional
const ariaLabel = label
? `${label} guitar chord diagram`
: 'guitar chord diagram'
return (
<div className="inline-flex flex-col items-center" role="img" aria-label={ariaLabel}>
<svg
width={svgW}
height={svgH}
viewBox={`0 0 ${svgW} ${svgH}`}
style={{ display: 'block' }}
>
{/* Board background */}
<rect
x={padL - 1}
y={padT - 1}
width={gridW + 2}
height={gridH + 2}
fill={BOARD}
rx={2 * scale}
/>
{/* Start-fret indicator ("5fr") when the grid begins above the nut */}
{!showNut && (
<text
x={padL - 4 * scale}
y={fretY(0) + cell * 0.62}
textAnchor="end"
fontSize={7 * scale}
fill={FRET_LABEL}
>
{startFret}fr
</text>
)}
{/* Frets (horizontal lines) */}
{Array.from({ length: NUM_FRETS + 1 }, (_, fi) => fi).map(fi => {
const topNut = showNut && fi === 0
return (
<line
key={fi}
x1={stringX(0)}
y1={fretY(fi)}
x2={stringX(NUM_STRINGS - 1)}
y2={fretY(fi)}
stroke={topNut ? NUT_COL : FRET_LINE}
strokeWidth={topNut ? 3 * scale : 1 * scale}
/>
)
})}
{/* Strings (vertical lines) */}
{rows.map((_, si) => (
<line
key={si}
x1={stringX(si)}
y1={fretY(0)}
x2={stringX(si)}
y2={fretY(NUM_FRETS)}
stroke={STRING_COL}
strokeWidth={(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 (
<g key={si} stroke={MUTE_OPEN} strokeWidth={1 * scale} strokeLinecap="round">
<line x1={x - r} y1={my - r} x2={x + r} y2={my + r} />
<line x1={x - r} y1={my + r} x2={x + r} y2={my - r} />
</g>
)
}
// Open string (absolute fret 0, only meaningful when nut is shown) → ○.
if (row.fret === 0) {
return (
<circle
key={si}
cx={x}
cy={padT - 4 * scale}
r={3 * scale}
fill="none"
stroke={MUTE_OPEN}
strokeWidth={1 * scale}
/>
)
}
// 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 (
<g key={si}>
<circle cx={x} cy={cy} r={r} fill={fill} />
{showFinger && (
<text
x={x}
y={cy + 3 * scale}
textAnchor="middle"
fontSize={7 * scale}
fontWeight="700"
fill={row.isRoot ? '#fff' : DOT_TEXT_DARK}
>
{finger}
</text>
)}
</g>
)
})}
</svg>
{label && (
<span
className="text-gray-300 leading-none mt-1"
style={{ fontSize: full ? 12 : 9 }}
>
{label}
</span>
)}
</div>
)
}