// 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 (
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.