diff --git a/src/components/RoadmapTrack.jsx b/src/components/RoadmapTrack.jsx new file mode 100644 index 0000000..243ab30 --- /dev/null +++ b/src/components/RoadmapTrack.jsx @@ -0,0 +1,339 @@ +import { NOTES, CHORD_TYPES, guideTones, voiceLeadingPairs, soloScale } from '../lib/theory' + +// ─── RoadmapTrack (D-01) ────────────────────────────────────────────────────── +// +// The heart of the "Roadmap" Jam Guide concept (docs/design/jam-guide-concept-c.md): +// the live loop rendered as a horizontal improv highway. Each KB progression +// station carries a chord name + Roman numeral + solo-scale label, a guide-tone +// lane (3rd/7th dots), and voice-leading rails drawn *between* adjacent stations +// (the 7→3 falls-a-half-step thread). A playhead + beat grid sit underneath; the +// station at `position` is "now", the next gets a subtle lookahead glow. +// +// Pure / presentational: no audio, no data fetching. Everything derives from +// props + theory.js. Default-exported. Luthier (D-02) wires it into JamGuide.jsx. +// +// Prop contract (honoured exactly — other agents build against it): +// progression KB progression object { id, name, rn, degrees, qualities, bars, mode, ... } +// keyRoot tonic pitch class 0–11 +// keyMode 'major' | 'minor' +// position index of the current station (playhead); -1 if none +// bpm optional, for the beat grid; tolerate undefined + +// Pitch class → note name. Sharps via NOTES (the app's canonical spelling, and +// what Fretboard.jsx uses); kept to one source so the roadmap matches the neck. +const pcName = pc => NOTES[((pc % 12) + 12) % 12] + +// Build the full display chord name from a pitch class + a CHORD_TYPES quality +// key (e.g. 7 + 'dom7' → "G7", 2 + 'min7' → "Dm7"). Falls back to a bare major +// triad spelling if the quality is unknown, so the panel never renders blank. +const chordName = (rootPc, quality) => + pcName(rootPc) + (CHORD_TYPES[quality]?.suffix ?? '') + +// A readable mode word for the SCALE lane: theory.js returns snake_case names +// ('phrygian_dominant'); the design wants "G mixolydian". +const prettyScale = (rootPc, quality, keyMode) => { + const { name } = soloScale(quality, keyMode) + return `${pcName(rootPc)} ${name.replace(/_/g, ' ')}` +} + +// ─── Layout constants (px in the SVG-free flex layout) ──────────────────────── +const STATION_MIN_W = 168 // each station's min width; loops longer than the + // viewport scroll horizontally (12-bar blues etc.) +const RAIL_W = 34 // width of the gap a voice-leading rail bridges +const RAIL_H = 40 // rail SVG height + +// Render the small arrow rail between two stations. `pair` is one entry from +// voiceLeadingPairs: { from, to, semitones }. We emphasise the half-step motion +// — a 0-semitone move is a held common tone ("holds"), ±1 a half-step, ±2 a +// whole step. Drawn in accent purple to match the guide-tone dots it connects. +function Rail({ pair }) { + if (!pair) return null + const { from, to, semitones } = pair + const held = semitones === 0 + const dir = semitones < 0 ? 'down' : semitones > 0 ? 'up' : 'hold' + const label = held + ? `${pcName(from)} holds` + : `${pcName(from)}→${pcName(to)}` // C→B + const motion = held + ? 'common tone' + : `${Math.abs(semitones) === 1 ? '½' : Math.abs(semitones)} step ${dir === 'down' ? 'down' : 'up'}` + + return ( + + ) +} + +// A single guide-tone dot with its honest label. `kind` is '3rd' / '7th' / '5th'. +function GuideDot({ pc, kind, filled }) { + return ( +
+ + {pcName(pc)} + + {kind} +
+ ) +} + +// A single station on the highway. +function Station({ + index, rootPc, quality, rn, scaleLabel, isNow, isNext, width, +}) { + const g = guideTones(rootPc, quality) + // hasSeventh:false → label the fallback honestly ("5th"), never call a 5th a 7th. + const seventhKind = g.hasSeventh ? '7th' : '5th' + + // Tier the dimming exactly like ProgressionBanner: "now" is full accent, the + // lookahead "next" is a softer glow, everything else recedes — but never below + // a legibility floor (AA contrast on bg-panel). + const stateClass = isNow + ? 'border-accent bg-accent/10 ring-2 ring-accent' + : isNext + ? 'border-accent/50 bg-accent/5' + : 'border-border bg-surface' + const opacity = isNow ? 1 : isNext ? 0.92 : 0.7 + + return ( +
+ {/* Header: chord name + Roman numeral, with the lookahead flag */} +
+
+ + {chordName(rootPc, quality)} + + {rn} +
+ {isNow && ( + + now + + )} + {isNext && ( + + next + + )} +
+ + {/* SCALE lane: the scale to blow over (muted, per the design) */} +
+ solo  + {scaleLabel} +
+ + {/* TARGET lane: the 3rd & 7th guide-tone dots */} +
+ aim + + +
+
+ ) +} + +// The playhead + beat grid under the whole track. Total beats = Σ bars × 4. +// The current beat is the start of the active station (coarse, chord-accurate — +// matches ProgressionBanner; fine beat interpolation is a later D-02 polish). +function BeatGrid({ bars, position, bpm }) { + const beatsPerStation = bars.map(b => (b || 1) * 4) + const totalBeats = beatsPerStation.reduce((s, n) => s + n, 0) + // first beat index of each station + const stationStart = [] + let acc = 0 + for (const n of beatsPerStation) { stationStart.push(acc); acc += n } + const nowBeat = position >= 0 && position < stationStart.length ? stationStart[position] : -1 + const pct = nowBeat >= 0 && totalBeats > 0 ? (nowBeat + 0.5) / totalBeats : 0 + + return ( +
+ {/* playhead track */} +
= 0 ? `Beat ${nowBeat + 1} of ${totalBeats}` : 'Loop not playing' + } + > +
+ {nowBeat >= 0 && ( +
+ )} +
+ + {/* beat cells */} + + ) +} + +export default function RoadmapTrack({ + progression, + keyRoot = 0, + keyMode = 'major', + position = -1, + bpm, +}) { + // Tolerate a missing / malformed progression — the panel is never empty-crashed. + if (!progression || !Array.isArray(progression.degrees) || progression.degrees.length === 0) { + return ( +
+ No loop to map yet — play a progression. +
+ ) + } + + const { degrees, qualities = [], rn = [], bars = [], name, id } = progression + const n = degrees.length + + // Resolve each station to an absolute chord in the current key. + const stations = degrees.map((deg, i) => { + const rootPc = (((keyRoot + deg) % 12) + 12) % 12 + const quality = qualities[i] ?? 'maj' + return { + rootPc, + quality, + rn: rn[i] ?? '', + scaleLabel: prettyScale(rootPc, quality, keyMode), + bars: bars[i] ?? 1, + } + }) + + // Voice-leading rails between adjacent stations, plus a wrap-around rail from + // the last station back to the first (the loop is a wheel — a nice touch the + // design calls for: "B holds → next loop"). Index i = rail leaving station i. + const rails = stations.map((s, i) => { + const next = stations[(i + 1) % n] + return voiceLeadingPairs( + { root: s.rootPc, quality: s.quality }, + { root: next.rootPc, quality: next.quality }, + )[0] ?? null // the headline rail is the 7→3 (voiceLeadingPairs lists 7th first) + }) + + const nextPos = position >= 0 ? (position + 1) % n : -1 + + return ( +
+ {/* Header strip: loop name + station chord summary */} +
+

+ {name ?? 'Loop'} +

+ + {stations.map(s => chordName(s.rootPc, s.quality)).join(' → ')} + +
+ + {/* The highway: stations interleaved with voice-leading rails. Scrolls + horizontally when the loop is longer than the viewport. */} +
+
+ {stations.map((s, i) => ( +
+ + {/* rail to the next station (inter-station rails only; the + wrap-around rail is drawn separately after the last station) */} + {i < n - 1 && } +
+ ))} + {/* wrap-around rail back to station 1, rendered after the last station */} + {n > 1 && ( + + )} +
+
+ + {/* Playhead + beat grid */} + s.bars)} position={position} bpm={bpm} /> +
+ ) +}