import type { JSX } from "@char/aftercare/jsx-runtime"; import { voiceTrack } from "../project/harmony.ts"; import { projectKey } from "../project/model.ts"; import { project } from "../project/store.ts"; import { analyze, prettyRoman, romanOf } from "../theory/analysis.ts"; import { type Chord, chordFrom, chordName } from "../theory/chord.ts"; import { MODES } from "../theory/key.ts"; import { mod, pcOf, spellPc } from "../theory/pitch.ts"; import { toggleSeventh } from "../theory/transform.ts"; import { nextChordAt, paletteTrack, previewChord, writeChordFromPalette } from "./actions.ts"; import { watch } from "./dom.tsx"; import { originClass } from "./origin.ts"; import { activeTrackId, cursor, paletteSevenths } from "./state.ts"; const SVG = "http://www.w3.org/2000/svg"; const svg = (tag: string, attrs: Record, ...children: Node[]) => { const el = document.createElementNS(SVG, tag); for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v)); el.append(...children); return el; }; const RINGS = { major: [72, 118], minor: [38, 72] } as const; /** a twelfth of a ring, `step` fifths clockwise from the top */ function wedge(step: number, [inner, outer]: readonly [number, number]) { const at = (r: number, deg: number) => { const a = ((deg - 90) * Math.PI) / 180; return `${(r * Math.cos(a)).toFixed(2)} ${(r * Math.sin(a)).toFixed(2)}`; }; const [from, to] = [step * 30 - 15, step * 30 + 15]; return ( `M${at(outer, from)} A${outer} ${outer} 0 0 1 ${at(outer, to)} ` + `L${at(inner, to)} A${inner} ${inner} 0 0 0 ${at(inner, from)} Z` ); } /** * the circle of fifths, turned so the current chord (the one at the cursor, or else the one * before it) is at the top: what's a fifth either way of it, and the relative minors inside */ export function Circle(): JSX.Element { const circle = svg("svg", { class: "circle", viewBox: "-120 -120 240 240" }); watch(circle, [project, cursor, activeTrackId, paletteSevenths], () => { const p = project.get(); const key = projectKey(p); const track = paletteTrack(p); const voiced = track ? voiceTrack(track, key) : []; const at = cursor.get(); const current = ( voiced.find(v => v.event.start === at) ?? voiced.findLast(v => v.event.start < at) )?.chord; // the major key at the top: the chord's own, or for a minor chord its relative major, and // for a diminished one the key it's the leading-tone chord of const tones = current?.tones ?? (MODES[key.mode].steps.includes(3) ? [0, 3, 7] : [0, 4, 7]); const root = current ? pcOf(current.root) : pcOf(key.tonic); const minor = tones.includes(3) && tones.includes(7) && !tones.includes(4); const diminished = tones.includes(3) && tones.includes(6); const top = mod(root + (minor ? 3 : diminished ? 1 : 0), 12); const here = minor ? RINGS.minor : diminished ? undefined : RINGS.major; const chip = (triad: Chord, step: number, ring: readonly [number, number]) => { // majors take whichever seventh the key has (Cmaj7, but G7 and D7 in C); minors always the // minor one, as a borrowed Fm in C is Fm7 rather than the Fm(maj7) the key would make it const seventh = paletteSevenths.get() && (ring === RINGS.minor ? chordFrom(triad.root, [0, 3, 7, 10]) : toggleSeventh(triad, key)); const chord = seventh || triad; const roman = romanOf(chord, key); const middle = (ring[0] + ring[1]) / 2; const a = ((step * 30 - 90) * Math.PI) / 180; const [x, y] = [middle * Math.cos(a), middle * Math.sin(a)]; const cls = `circle-chord ${ring === RINGS.minor ? "minor " : ""}${originClass(analyze(chord, key))}`; const g = svg( "g", { class: `${cls}${!step && ring === here ? " here" : ""}` }, svg("path", { d: wedge(step, ring) }), svg("text", { x, y: y - 1, class: "name" }, document.createTextNode(chordName(chord))), svg( "text", { x, y: y + 10, class: "roman" }, document.createTextNode(prettyRoman(roman)), ), ); // the second click of a double-click would only preview on top of the write g.addEventListener( "click", e => (e as MouseEvent).detail < 2 && previewChord(roman, nextChordAt()), ); g.addEventListener("dblclick", () => writeChordFromPalette(roman, nextChordAt())); return g; }; const chips = []; for (let step = 0; step < 12; step++) { const major = mod(top + 7 * step, 12); const relative = mod(major + 9, 12); chips.push( chip(chordFrom(spellPc(major, key.tonic), [0, 4, 7])!, step, RINGS.major), chip(chordFrom(spellPc(relative, key.tonic), [0, 3, 7])!, step, RINGS.minor), ); } circle.replaceChildren(...chips); }); return circle as JSX.Element; }