feat: implement scale-of-fifth based key signature picker

This commit is contained in:
Xiaohan-Tian
2026-05-23 13:36:59 -07:00
parent fb3c6cde70
commit 247c76237d
12 changed files with 854 additions and 30 deletions
@@ -0,0 +1,92 @@
import { KEY_SIGNATURE_MAP } from '../constants/coreConstants';
import type { KeySignature } from '../core/KGProject';
type KeyQuality = 'major' | 'minor';
interface KeySignatureSlotDefinition {
id: string;
majorKeys: KeySignature[];
minorKeys: KeySignature[];
}
export interface KeySignatureCircleItem {
keySignature: KeySignature;
label: string;
quality: KeyQuality;
}
export interface KeySignatureCircleSlot {
id: string;
angleDeg: number;
outerItems: KeySignatureCircleItem[];
innerItems: KeySignatureCircleItem[];
accidentalLabel: string;
}
const CIRCLE_SLOT_DEFINITIONS: KeySignatureSlotDefinition[] = [
{ id: 'c', majorKeys: ['C major'], minorKeys: ['A minor'] },
{ id: 'g', majorKeys: ['G major'], minorKeys: ['E minor'] },
{ id: 'd', majorKeys: ['D major'], minorKeys: ['B minor'] },
{ id: 'a', majorKeys: ['A major'], minorKeys: ['F# minor'] },
{ id: 'e', majorKeys: ['E major'], minorKeys: ['C# minor'] },
{ id: 'b-cb', majorKeys: ['B major', 'Cb major'], minorKeys: ['G# minor', 'Ab minor'] },
{ id: 'gb-fsharp', majorKeys: ['Gb major', 'F# major'], minorKeys: ['Eb minor', 'D# minor'] },
{ id: 'db-csharp', majorKeys: ['Db major', 'C# major'], minorKeys: ['Bb minor', 'A# minor'] },
{ id: 'ab', majorKeys: ['Ab major'], minorKeys: ['F minor'] },
{ id: 'eb', majorKeys: ['Eb major'], minorKeys: ['C minor'] },
{ id: 'bb', majorKeys: ['Bb major'], minorKeys: ['G minor'] },
{ id: 'f', majorKeys: ['F major'], minorKeys: ['D minor'] },
];
function assertKnownKeySignature(keySignature: KeySignature): KeySignature {
if (!(keySignature in KEY_SIGNATURE_MAP)) {
throw new Error(`Unknown key signature in circle layout: ${keySignature}`);
}
return keySignature;
}
function getLabelForKeySignature(keySignature: KeySignature): string {
const [tonic, quality] = keySignature.split(' ');
return quality === 'major' ? tonic : tonic.toLowerCase();
}
function formatAccidentalCount(keySignatures: KeySignature[]): string {
const segments = keySignatures.map((keySignature) => {
const entry = KEY_SIGNATURE_MAP[keySignature];
if (entry.sharps > 0) {
return `${entry.sharps}`;
}
if (entry.flats > 0) {
return `${entry.flats}`;
}
return 'natural';
});
return [...new Set(segments)].join(' / ');
}
export function buildKeySignatureCircleSlots(): KeySignatureCircleSlot[] {
const totalSlots = CIRCLE_SLOT_DEFINITIONS.length;
return CIRCLE_SLOT_DEFINITIONS.map((slot, index) => {
const outerItems = slot.majorKeys.map((keySignature) => ({
keySignature: assertKnownKeySignature(keySignature),
label: getLabelForKeySignature(keySignature),
quality: 'major' as const,
}));
const innerItems = slot.minorKeys.map((keySignature) => ({
keySignature: assertKnownKeySignature(keySignature),
label: getLabelForKeySignature(keySignature),
quality: 'minor' as const,
}));
return {
id: slot.id,
angleDeg: -90 + (360 / totalSlots) * index,
outerItems,
innerItems,
accidentalLabel: formatAccidentalCount(slot.majorKeys),
};
});
}