From c999c958cb5ec8083ed97e6b91399bac32e3950d Mon Sep 17 00:00:00 2001 From: vadimwit Date: Sat, 11 Jul 2026 15:42:22 +0100 Subject: [PATCH] =?UTF-8?q?feat(explore):=20circle=20of=20fifths=20?= =?UTF-8?q?=E2=80=94=20live=20key=20wedge,=20safe-neighbour=20tiers,=20mod?= =?UTF-8?q?al=20parent-major=20hub=20(task=20D-61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SVG circle in the Knowledge Center Explore section: 12 majors outer ring with key-signature glyphs, relative minors inner, live/locked key solid accent with subdominant/dominant/relative soft-tiered; diatonic chord chips inline (tap -> ChordDetailModal); wedge tap = local preview only, app key state never touched; modal keys light the correct ring and the hub teaches the parent-major relationship ("1# - G major's notes" for A dorian; 72/72 mode x root uniqueness proven). Critic returned once (hub showed the wedge signature, not the mode's), fixed, PASS on scoped re-gate. Co-Authored-By: Claude Fable 5 --- src/components/CircleOfFifths.jsx | 391 ++++++++++++++++++++++++++++++ src/components/ExplorePanel.jsx | 6 + 2 files changed, 397 insertions(+) create mode 100644 src/components/CircleOfFifths.jsx diff --git a/src/components/CircleOfFifths.jsx b/src/components/CircleOfFifths.jsx new file mode 100644 index 0000000..d2de7f8 --- /dev/null +++ b/src/components/CircleOfFifths.jsx @@ -0,0 +1,391 @@ +// CircleOfFifths — the Knowledge Center's live key map (task D-61). +// +// A read-only SVG circle of fifths for a jamming musician, not a textbook +// poster: +// +// · Outer ring: the 12 major keys, C at 12 o'clock, fifths clockwise. +// Each wedge carries its key-signature glyph (♮ / n♯ / n♭). +// · Inner ring: the relative minors, aligned with their majors. +// · LIVE: the detected/locked key (keyInfo = App's effectiveKey, flowing +// through KnowledgeDock → ExploreSection) lights its wedge in accent; its +// fifths neighbours (subdominant, dominant) and its relative get a soft +// accent tier — the "safe keys to wander to" story with zero interaction. +// Modal keys pick their ring by the mode's third (dorian/phrygian → the +// minor ring), derived from theory.js SCALES — never re-derived here. +// · The highlighted key's diatonic chords (theory.js getChordsInKey) are +// listed beside the circle inline — no hover, no click required. +// · OPTIONAL tap: any wedge previews that key's diatonics in the side panel +// (dashed outline marks the previewed wedge; the live highlight never +// moves). Tapping NEVER changes app key state — this surface is read-only. +// · No key detected → neutral circle with honest microcopy. +// +// Purely presentational: props in ({ keyInfo, onChordClick }), nothing out. +// Keyboard: every wedge is a focusable button (Enter/Space previews); focus +// draws an explicit accent-soft stroke (SVG-safe — no reliance on box-shadow). +// +// Design tokens (tailwind.config.js) — literal here because SVG paint +// attributes can't read Tailwind classes (MiniPiano/Fretboard precedent): +// accent #a855f7, surface #0f0f0f, panel #1a1a1a, border #2a2a2a. No new +// colours: #c084fc is MiniPiano's established soft accent; greys are the +// Tailwind gray-300/400 already used across the app's SVGs. + +import { useMemo, useState } from 'react' +import { NOTES, SCALES, getChordsInKey, toRomanNumeral } from '../lib/theory' +import { parseChord } from '../lib/voicings' + +// ─── Token literals (SVG paint attrs; see header) ───────────────────────────── +const ACCENT = '#a855f7' // bg-accent — the live key wedge +const ACCENT_SOFT = '#c084fc' // MiniPiano's soft accent — neighbour-tier text +const SURFACE = '#0f0f0f' // bg-surface — wedge gaps + text on accent (AA 4.84:1) +const PANEL = '#1a1a1a' // bg-panel — idle wedge fill +const BORDER = '#2a2a2a' // border-border — centre hub stroke +const TEXT_MAIN = '#d1d5db' // gray-300 — idle key names (11.4:1 on panel) +const TEXT_DIM = '#9ca3af' // gray-400 — signature glyphs, microcopy (6.4:1) + +// ─── The circle, index 0 = 12 o'clock, fifths clockwise ─────────────────────── +// Display spelling is the conventional poster mix (flats on the flat side); +// all LOGIC runs on pitch classes so detection's sharp spellings match fine. +const MAJOR_LABELS = ['C','G','D','A','E','B','F♯','D♭','A♭','E♭','B♭','F'] +const MINOR_LABELS = ['Am','Em','Bm','F♯m','C♯m','G♯m','E♭m','B♭m','Fm','Cm','Gm','Dm'] +const SIG_GLYPHS = ['♮','1♯','2♯','3♯','4♯','5♯','6♯','5♭','4♭','3♭','2♭','1♭'] + +const majorPcAt = (i) => (i * 7) % 12 // wedge index → major tonic pc +const minorPcAt = (i) => (i * 7 + 9) % 12 // wedge index → relative minor pc +const majorIdxOf = (pc) => (pc * 7) % 12 // 7·7 ≡ 1 (mod 12): self-inverse +const minorIdxOf = (pc) => majorIdxOf((pc + 3) % 12) + +const sigWords = (i) => + i === 0 ? 'no sharps or flats' : i <= 6 ? `${i} sharp${i > 1 ? 's' : ''}` : `${12 - i} flat${12 - i > 1 ? 's' : ''}` + +// The MODE's parent major: the unique major scale whose pc-set equals the +// mode's pc-set — ITS signature is the mode's true signature (A dorian = +// G major's notes = 1♯, not A major's 3♯, and not the tonic wedge's glyph). +// Uniqueness proof: the diatonic pc-set has no transpositional symmetry, so +// its 12 transpositions are 12 DISTINCT 7-note sets — a given 7-note set can +// therefore equal AT MOST one of them. Every key-dropdown mode (major, minor, +// dorian, phrygian, lydian, mixolydian) is by definition a rotation of the +// diatonic set, so for all 6 modes × 12 roots exactly one parent major exists +// (existence: rotating the mode back to its parent). Non-heptatonic scales +// (pentatonics, blues) match none — we return null and the hub omits the +// signature line rather than guessing. +function parentMajorPc(tonicPc, scale) { + const pcs = new Set(scale.map((s) => (tonicPc + s) % 12)) + if (pcs.size !== 7) return null + const hits = [] + for (let p = 0; p < 12; p++) { + if (SCALES.major.every((s) => pcs.has((p + s) % 12))) hits.push(p) + } + return hits.length === 1 ? hits[0] : null +} + +// ─── Geometry (viewBox 0 0 300 300, centre 150) ─────────────────────────────── +const CX = 150, CY = 150 +const R_OUT = 142, R_MID = 96, R_IN = 58, R_HUB = 54 + +function pt(r, deg) { + const t = (deg * Math.PI) / 180 // 0° = 12 o'clock, clockwise + return `${(CX + r * Math.sin(t)).toFixed(2)},${(CY - r * Math.cos(t)).toFixed(2)}` +} + +function wedgePath(i, r0, r1) { + const a0 = i * 30 - 15, a1 = i * 30 + 15 + return `M ${pt(r1, a0)} A ${r1},${r1} 0 0 1 ${pt(r1, a1)} L ${pt(r0, a1)} A ${r0},${r0} 0 0 0 ${pt(r0, a0)} Z` +} + +function labelXY(i, r) { + const t = (i * 30 * Math.PI) / 180 + return { x: CX + r * Math.sin(t), y: CY - r * Math.cos(t) } +} + +// ─── Diatonic chord chips (inline — the core, zero-click payload) ───────────── +function ChordChips({ root, mode, onChordClick }) { + const chords = getChordsInKey(root, mode) + if (!chords.length) return null + return ( +
+ {chords.map((c) => { + const rn = toRomanNumeral(c, root, mode) + const inner = ( + <> + {c} + {rn && rn !== '?' && ( + {rn} + )} + + ) + return onChordClick ? ( + + ) : ( + + {inner} + + ) + })} +
+ ) +} + +// ─── The component ──────────────────────────────────────────────────────────── +export default function CircleOfFifths({ keyInfo, onChordClick }) { + const [preview, setPreview] = useState(null) // { ring, idx } | null — local only + const [hot, setHot] = useState(null) // 'ring-idx' hovered/focused wedge + + // Live key → ring + wedge index. Ring by the MODE'S THIRD (SCALES, theory.js): + // minor/dorian/phrygian sit on the minor ring at their tonic — A minor (or + // A dorian) lights the inner "Am" wedge, NOT outer C. + const live = useMemo(() => { + if (!keyInfo?.root) return null + const pc = parseChord(keyInfo.root)?.rootPc + if (pc == null) return null + const mode = keyInfo.mode ?? 'major' + const scale = SCALES[mode] ?? SCALES.major + // Minor third AND no major third — a bare scale[2] === 3 check would + // misfile the hexatonic blues scale [0,3,5,6,7,10] (carries both colours) + // if it ever reached the key dropdown. + const minorish = scale.includes(3) && !scale.includes(4) + const parentPc = parentMajorPc(pc, scale) + return { + pc, mode, + ring: minorish ? 'minor' : 'major', + idx: minorish ? minorIdxOf(pc) : majorIdxOf(pc), + // circle position of the mode's PARENT MAJOR → the true signature glyph + sigIdx: parentPc == null ? null : majorIdxOf(parentPc), + } + }, [keyInfo]) + + // Tier per wedge: 2 = the live key (accent), 1 = its safe neighbours + // (subdominant · dominant on the same ring, relative on the other), 0 = idle. + function tierOf(ring, idx) { + if (!live) return 0 + if (live.ring === ring) { + if (idx === live.idx) return 2 + if (idx === (live.idx + 1) % 12 || idx === (live.idx + 11) % 12) return 1 + return 0 + } + return idx === live.idx ? 1 : 0 + } + + const isLiveWedge = (ring, idx) => live && live.ring === ring && live.idx === idx + + function tapWedge(ring, idx) { + if (isLiveWedge(ring, idx)) { setPreview(null); return } // tapping home = back to live + setPreview((p) => (p && p.ring === ring && p.idx === idx ? null : { ring, idx })) + } + + // Build both rings' wedges; paint order = idle → neighbours → live → preview + // → hot, so highlight strokes always win the shared edges. + const wedges = [] + for (const ring of ['major', 'minor']) { + const outer = ring === 'major' + for (let idx = 0; idx < 12; idx++) { + const tier = tierOf(ring, idx) + const previewed = preview && preview.ring === ring && preview.idx === idx + const id = `${ring}-${idx}` + const name = outer ? MAJOR_LABELS[idx] : MINOR_LABELS[idx] + const aria = outer + ? `${name} major, ${sigWords(idx)}${tier === 2 ? ' — the live key' : ''}. Preview its chords.` + : `${MINOR_LABELS[idx].replace(/m$/, '')} minor, relative of ${MAJOR_LABELS[idx]} major${tier === 2 ? ' — the live key' : ''}. Preview its chords.` + wedges.push({ + ring, idx, id, tier, previewed, outer, aria, + z: hot === id ? 5 : previewed ? 4 : tier + 1, + }) + } + } + wedges.sort((a, b) => a.z - b.z) + + // Side panel: the previewed key wins the panel; the LIVE view stays complete + // without any tap (live highlight + its chords render by default). + // Preview title is spelled SHARP-side from NOTES, matching its chips: + // getChordsInKey emits the app-wide sharp spelling (theory.js noteName), and + // those names flow into onChordClick → ChordDetailModal, so re-spelling the + // chips flat would add a divergent naming layer in front of the modal. One + // spelling authority (NOTES) for title + chips + modal; the WEDGE keeps its + // poster label (E♭) for at-a-glance reading. + let shown = null + if (preview) { + const root = NOTES[preview.ring === 'major' ? majorPcAt(preview.idx) : minorPcAt(preview.idx)] + const mode = preview.ring === 'major' ? 'major' : 'minor' + shown = { root, mode, title: `${root} ${mode}`, isPreview: true } + } else if (live) { + shown = { root: keyInfo.root, mode: live.mode, title: `${keyInfo.root} ${live.mode}`, isPreview: false } + } + + // Neighbour names for the live key (the tier-1 wedges, spelled out). + const neighbours = live && { + sub: (live.ring === 'major' ? MAJOR_LABELS : MINOR_LABELS)[(live.idx + 11) % 12], + dom: (live.ring === 'major' ? MAJOR_LABELS : MINOR_LABELS)[(live.idx + 1) % 12], + rel: (live.ring === 'major' ? MINOR_LABELS : MAJOR_LABELS)[live.idx], + relWord: live.ring === 'major' ? 'relative minor' : 'relative major', + } + + return ( +
+ {/* ── The circle ── */} + + {/* wedges (paint-ordered) */} + {wedges.map((w) => { + const [r0, r1] = w.outer ? [R_MID, R_OUT] : [R_IN, R_MID] + const stroke = hot === w.id ? ACCENT_SOFT : w.previewed ? ACCENT : SURFACE + return ( + tapWedge(w.ring, w.idx)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); tapWedge(w.ring, w.idx) } + }} + onPointerEnter={() => setHot(w.id)} + onPointerLeave={() => setHot((h) => (h === w.id ? null : h))} + onFocus={() => setHot(w.id)} + onBlur={() => setHot((h) => (h === w.id ? null : h))} + /> + ) + })} + + {/* labels (own layer, never swallow clicks) */} + {Array.from({ length: 12 }, (_, i) => { + const tMaj = tierOf('major', i), tMin = tierOf('minor', i) + const name = labelXY(i, 123) + const glyph = labelXY(i, 106) + const minor = labelXY(i, 77) + return ( + + + {MAJOR_LABELS[i]} + + + {SIG_GLYPHS[i]} + + + {MINOR_LABELS[i]} + + + ) + })} + + {/* centre hub — always the LIVE state (the glance anchor) */} + + {live ? ( + + + {keyInfo.root} {live.mode} + + {/* The MODE's signature (parent major), not the wedge's — A dorian + reads "1♯ · G major's notes", matching the F♯ in its chips. */} + {live.sigIdx != null && ( + + {SIG_GLYPHS[live.sigIdx]} · {live.mode === 'major' + ? sigWords(live.sigIdx) + : `${MAJOR_LABELS[live.sigIdx]} major's notes`} + + )} + + LIVE + + + ) : ( + + + No key yet + + + play a few chords — + + + the circle lights up + + + )} + + + {/* ── Beside the circle: the highlighted key's diatonics, inline ── */} +
+

+ Circle of fifths +

+ + {shown ? ( + <> +
+ {shown.title} + {shown.isPreview ? ( + <> + + preview + + + + ) : ( + + live + + )} +
+ + + + {!shown.isPreview && neighbours && ( +

+ Safe keys to wander to:{' '} + {neighbours.sub} (subdominant) ·{' '} + {neighbours.dom} (dominant) ·{' '} + {neighbours.rel} ({neighbours.relWord}) +

+ )} + + ) : ( +

+ When a key is detected (or locked), its wedge lights up here with the chords that + live in it. Tap any wedge to peek at another key meanwhile. +

+ )} + +

+ Keys next to each other on the circle share six of their seven notes, so sliding one + step — clockwise to the dominant, counter-clockwise to the subdominant — barely moves + the ground under the band. The inner ring is each key's relative minor: the same + notes with a darker home base. The further apart two keys sit, the bolder the jump sounds. +

+
+
+ ) +} diff --git a/src/components/ExplorePanel.jsx b/src/components/ExplorePanel.jsx index 3a2196d..6b9ce98 100644 --- a/src/components/ExplorePanel.jsx +++ b/src/components/ExplorePanel.jsx @@ -16,6 +16,7 @@ import { useEffect, useMemo, useState } from 'react' import ChordBox from './ChordBox' +import CircleOfFifths from './CircleOfFifths' import MiniPiano from './MiniPiano' import VoicingBrowser from './VoicingBrowser' import kb from '../data/kb/index.js' @@ -346,6 +347,11 @@ export function ExploreSection({ keyInfo, levels, onToggleLevel, onChordClick }) + {/* Circle of fifths — live key map with inline diatonics (task D-61). + keyInfo here IS App's effectiveKey (App → KnowledgeDock → this section); + the circle is read-only — tapping wedges never touches key state. */} + +

Chords shown in {NOTES[keyRootPc]} {keyMode}{keyInfo?.root ? '' : ' (no key detected yet)'} · tap any chord for voicings