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>
This commit is contained in:
@@ -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 (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user