diff --git a/src/components/KeySignaturePickerPopup.css b/src/components/KeySignaturePickerPopup.css new file mode 100644 index 0000000..bd10e65 --- /dev/null +++ b/src/components/KeySignaturePickerPopup.css @@ -0,0 +1,184 @@ +.key-signature-popup { + min-width: 270px; + padding: 12px 12px 10px; +} + +.key-signature-picker { + width: 252px; + height: 224px; + position: relative; + color: #e0e0e0; + font-family: Georgia, 'Times New Roman', serif; +} + +.key-signature-picker-title { + position: absolute; + left: 50%; + transform: translateX(-50%); + margin: 0; + color: #f0f0f0; + letter-spacing: 0.4px; + pointer-events: none; + font-weight: 600; +} + +.key-signature-picker-title.major { + top: 2px; + font-size: 10px; +} + +.key-signature-picker-title.minor { + top: 60px; + font-size: 9px; + color: #9fc89c; +} + +.key-signature-picker-ring { + position: absolute; + inset: 32px 40px 30px; + border-radius: 50%; + background: + radial-gradient(circle at center, transparent 0 38%, rgba(33, 33, 33, 0.96) 38% 49%, transparent 49%), + conic-gradient( + from 0deg, + rgba(84, 84, 84, 0.28), + rgba(69, 69, 69, 0.5), + rgba(84, 84, 84, 0.28) + ); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06); + pointer-events: none; +} + +.key-signature-picker-slot { + position: absolute; + transform: translate(-50%, -50%); +} + +.key-signature-picker-slot-outer, +.key-signature-picker-slot-inner, +.key-signature-picker-slot-count { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); +} + +.key-signature-picker-slot-count { + font-size: 7px; + color: #b8b8b8; + white-space: nowrap; + pointer-events: none; +} + +.key-signature-picker-item-stack { + display: flex; + flex-direction: column; + gap: 6px; + align-items: center; +} + +.key-signature-picker-paired-row { + display: inline-flex; + align-items: baseline; + gap: 0; + white-space: nowrap; +} + +.key-signature-picker-key { + display: inline-flex; + align-items: center; +} + +.key-signature-picker-divider { + color: #b8b8b8; + font-size: 9px; + line-height: 1; + padding: 0 1px; +} + +.key-signature-picker-button-paired { + padding: 0 2px; + border-radius: 999px; +} + +.key-signature-picker-button { + appearance: none; + -webkit-appearance: none; + border: none; + background: transparent; + background-color: transparent; + box-shadow: none; + padding: 0; + font: inherit; + cursor: pointer; + line-height: 1; + transition: transform 0.16s ease, opacity 0.16s ease, text-shadow 0.16s ease; +} + +.key-signature-picker-button:hover { + background: transparent; + background-color: transparent; + transform: scale(1.05); + text-shadow: 0 0 10px currentColor; +} + +.key-signature-picker-button:focus-visible { + outline: 2px solid #2d2d2d; + outline-offset: 4px; + border-radius: 6px; +} + +.key-signature-picker-button:focus, +.key-signature-picker-button:active { + background: transparent; + background-color: transparent; + box-shadow: none; +} + +.key-signature-picker-button.major { + color: #e08b94; + font-size: 13px; +} + +.key-signature-picker-button.minor { + color: #8fba88; + font-size: 11px; +} + +.key-signature-picker-button.selected { + text-decoration: underline; + text-underline-offset: 3px; +} + +.key-signature-picker-label { + display: inline-flex; + align-items: flex-start; +} + +.key-signature-picker-accidental { + font-size: 0.58em; + line-height: 1; + margin-left: 1px; + position: relative; + top: -0.15em; +} + +@media (max-width: 900px) { + .key-signature-popup { + min-width: min(92vw, 270px); + padding: 12px 10px 10px; + } + + .key-signature-picker { + width: min(82vw, 252px); + height: min(72vw, 224px); + } + + .key-signature-picker-button.major { + font-size: 12px; + } + + .key-signature-picker-button.minor { + font-size: 10px; + } +} diff --git a/src/components/KeySignaturePickerPopup.test.tsx b/src/components/KeySignaturePickerPopup.test.tsx new file mode 100644 index 0000000..cb099c6 --- /dev/null +++ b/src/components/KeySignaturePickerPopup.test.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import KeySignaturePickerPopup from './KeySignaturePickerPopup'; + +vi.mock('vexflow', () => { + class MockRenderer { + static Backends = { SVG: 'svg' }; + private readonly host: HTMLElement; + + constructor(host: HTMLElement) { + this.host = host; + } + + resize() {} + + getContext() { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + this.host.appendChild(svg); + return {}; + } + } + + class MockStave { + constructor() {} + + addClef() { + return this; + } + + addKeySignature() { + return this; + } + + setContext() { + return this; + } + + draw() { + return this; + } + } + + return { + Renderer: MockRenderer, + Stave: MockStave, + }; +}); + +describe('KeySignaturePickerPopup', () => { + it('selects major and minor key signatures', () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Select G major' })); + fireEvent.click(screen.getByRole('button', { name: 'Select E minor' })); + + expect(onChange).toHaveBeenNthCalledWith(1, 'G major'); + expect(onChange).toHaveBeenNthCalledWith(2, 'E minor'); + }); + + it('renders both exact options for shared enharmonic slots', () => { + render(); + + expect(screen.getByRole('button', { name: 'Select Gb major or F# major' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Select Eb minor or D# minor' })).toBeInTheDocument(); + }); + + it('toggles between paired enharmonic options from a single button', () => { + const onChange = vi.fn(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole('button', { name: 'Select Gb major or F# major' })); + expect(onChange).toHaveBeenCalledWith('Gb major'); + + rerender(); + fireEvent.click(screen.getByRole('button', { name: 'Select Gb major or F# major' })); + expect(onChange).toHaveBeenLastCalledWith('F# major'); + }); +}); diff --git a/src/components/KeySignaturePickerPopup.tsx b/src/components/KeySignaturePickerPopup.tsx new file mode 100644 index 0000000..38d22a0 --- /dev/null +++ b/src/components/KeySignaturePickerPopup.tsx @@ -0,0 +1,164 @@ +import React from 'react'; +import type { KeySignature } from '../core/KGProject'; +import { buildKeySignatureCircleSlots } from './keySignaturePickerLayout'; +import './KeySignaturePickerPopup.css'; + +interface KeySignaturePickerPopupProps { + value: KeySignature; + // Keep the callback typed to project key signatures without duplicating the union locally. + // eslint-disable-next-line no-unused-vars + onChange: (keySignature: KeySignature) => void; +} + +const OUTER_RADIUS = 87; +const INNER_RADIUS = 55; +const COUNT_RADIUS = 69; +const CENTER_X = 126; +const CENTER_Y = 112; + +function getPosition(angleDeg: number, radius: number) { + const angle = (angleDeg * Math.PI) / 180; + return { + left: CENTER_X + radius * Math.cos(angle), + top: CENTER_Y + radius * Math.sin(angle), + }; +} + +function splitAccidental(label: string): { base: string; accidental: string | null } { + if (label.includes('#')) { + return { base: label.replace('#', ''), accidental: '♯' }; + } + if (label.includes('b')) { + return { base: label.replace('b', ''), accidental: '♭' }; + } + return { base: label, accidental: null }; +} + +const KeyLabel: React.FC<{ label: string }> = ({ label }) => { + const { base, accidental } = splitAccidental(label); + return ( + + {base} + {accidental && {accidental}} + + ); +}; + +const CombinedKeyLabel: React.FC<{ labels: string[] }> = ({ labels }) => ( + + {labels.map((label, index) => ( + + + {index < labels.length - 1 && /} + + ))} + +); + +const KeySignaturePickerPopup: React.FC = ({ value, onChange }) => { + const slots = React.useMemo(() => buildKeySignatureCircleSlots(), []); + + const handlePairedSelection = React.useCallback((keySignatures: KeySignature[]) => { + if (keySignatures.length === 0) { + return; + } + + const activeIndex = keySignatures.indexOf(value); + if (activeIndex >= 0) { + onChange(keySignatures[(activeIndex + 1) % keySignatures.length]); + return; + } + + onChange(keySignatures[0]); + }, [onChange, value]); + + return ( +
+
+ +
+ ); +}; + +export default KeySignaturePickerPopup; diff --git a/src/components/Toolbar.css b/src/components/Toolbar.css index aac08f2..efed859 100644 --- a/src/components/Toolbar.css +++ b/src/components/Toolbar.css @@ -195,29 +195,57 @@ color: #fff; } -.current-key-signature { +.toolbar button.current-key-signature { + appearance: none; + border: none; + background: transparent; + color: inherit; + display: inline-block; + width: auto; + height: auto; + min-height: 0; + margin: 0; font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-weight: normal; letter-spacing: 0.5px; + line-height: 1; cursor: pointer; transition: background-color 0.2s ease; padding: 2px 4px; border-radius: 2px; + white-space: nowrap; } -.current-key-signature:hover { +.toolbar button.current-key-signature:hover { background-color: #4a4a4a; color: #fff; } +.toolbar button.current-key-signature:focus-visible { + outline: 1px solid #e0e0e0; + outline-offset: 1px; +} + .export-dropdown .quant-dropdown { width: 250px; left: 0; } -.key-signature-dropdown .quant-dropdown { - width: 100px; - left: 0; +.key-signature-popup-anchor { + display: inline-flex; +} + +.key-signature-popup-surface { + --floating-popup-bg: #2d2d2d; + --floating-popup-border: #555; +} + +.key-signature-popup-panel { + overflow: visible; +} + +.key-signature-popup-arrow { + left: 50%; } /* Clickable zoom styling */ diff --git a/src/components/Toolbar.test.tsx b/src/components/Toolbar.test.tsx index 871d847..a254d3e 100644 --- a/src/components/Toolbar.test.tsx +++ b/src/components/Toolbar.test.tsx @@ -62,6 +62,7 @@ const storeState = { }; type StoreState = typeof storeState; +// eslint-disable-next-line no-unused-vars type StoreSelector = (state: StoreState) => unknown; vi.mock('../stores/projectStore', () => ({ @@ -83,7 +84,16 @@ vi.mock('../constants/coreConstants', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - KEY_SIGNATURE_MAP: { ...actual.KEY_SIGNATURE_MAP, 'C major': [] }, + KEY_SIGNATURE_MAP: { + ...actual.KEY_SIGNATURE_MAP, + 'C major': actual.KEY_SIGNATURE_MAP['C major'], + 'G major': actual.KEY_SIGNATURE_MAP['G major'], + 'E minor': actual.KEY_SIGNATURE_MAP['E minor'], + 'Gb major': actual.KEY_SIGNATURE_MAP['Gb major'], + 'F# major': actual.KEY_SIGNATURE_MAP['F# major'], + 'Eb minor': actual.KEY_SIGNATURE_MAP['Eb minor'], + 'D# minor': actual.KEY_SIGNATURE_MAP['D# minor'], + }, }; }); @@ -125,7 +135,6 @@ vi.mock('../util/midiUtil', () => ({ convertMidiToProject: vi.fn(), })); vi.mock('../core/audio-interface/KGOfflineRenderer', () => ({ KGOfflineRenderer: { instance: vi.fn(() => ({})) } })); -vi.mock('./common/KGDropdown', () => ({ default: () => null })); vi.mock('./common/FileImportModal', () => ({ default: () => null })); vi.mock('./common/LoadingOverlay', () => ({ default: () => null })); vi.mock('./common/OpenProjectModal', () => ({ default: () => null })); @@ -140,6 +149,49 @@ vi.mock('../util/dialogUtil', () => ({ showPrompt: vi.fn(), showTimeSigPrompt: vi.fn(), })); +vi.mock('vexflow', () => { + class MockRenderer { + static Backends = { SVG: 'svg' }; + private readonly host: HTMLElement; + + constructor(host: HTMLElement) { + this.host = host; + } + + resize() {} + + getContext() { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + this.host.appendChild(svg); + return {}; + } + } + + class MockStave { + constructor() {} + + addClef() { + return this; + } + + addKeySignature() { + return this; + } + + setContext() { + return this; + } + + draw() { + return this; + } + } + + return { + Renderer: MockRenderer, + Stave: MockStave, + }; +}); describe('Toolbar settings side-panel behavior', () => { beforeEach(() => { @@ -154,6 +206,8 @@ describe('Toolbar settings side-panel behavior', () => { storeState.showChatBox = true; storeState.showKGOnePanel = true; storeState.showEventListPanel = false; + storeState.keySignature = 'C major'; + storeState.setKeySignature.mockClear(); }); it('suppresses active styling for side-panel buttons while Settings is visible', () => { @@ -193,4 +247,30 @@ describe('Toolbar settings side-panel behavior', () => { expect(storeState.setStatus).toHaveBeenCalledWith('Split 1 note at beat 12.00'); }); }); + + it('opens and closes the key signature popup from the toolbar trigger', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /choose key signature/i })); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Major')).toBeInTheDocument(); + + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('updates the store when a major or minor key is selected', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /choose key signature/i })); + fireEvent.click(screen.getByRole('button', { name: 'Select G major' })); + expect(storeState.setKeySignature).toHaveBeenCalledWith('G major'); + expect(storeState.setStatus).toHaveBeenCalledWith('Key signature changed to G major'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /choose key signature/i })); + fireEvent.click(screen.getByRole('button', { name: 'Select E minor' })); + expect(storeState.setKeySignature).toHaveBeenCalledWith('E minor'); + expect(storeState.setStatus).toHaveBeenCalledWith('Key signature changed to E minor'); + }); }); diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 19f4c66..00bd63c 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -24,12 +24,13 @@ import { KGMainContentState } from '../core/state/KGMainContentState'; import { regionDeleteManager } from '../util/regionDeleteUtil'; import { handleCopyOperation, handlePasteOperation } from '../util/copyPasteUtil'; import { convertProjectToMidi, convertMidiToProject } from '../util/midiUtil'; -import { KEY_SIGNATURE_MAP } from '../constants/coreConstants'; import { KGOfflineRenderer } from '../core/audio-interface/KGOfflineRenderer'; import KGDropdown from './common/KGDropdown'; +import FloatingPopup from './common/FloatingPopup'; import FileImportModal from './common/FileImportModal'; import LoadingOverlay from './common/LoadingOverlay'; import OpenProjectModal from './common/OpenProjectModal'; +import KeySignaturePickerPopup from './KeySignaturePickerPopup'; import { clearChatHistoryAndUI } from '../util/chatUtil'; import PianoIcon from './common/icons/PianoIcon'; import MetronomeIcon from './common/icons/MetronomeIcon'; @@ -91,9 +92,6 @@ const Toolbar: React.FC = () => { return () => document.removeEventListener('mousedown', handleClickOutside); }, [showZoomSlider]); - // Key signature options - const keySignatureOptions = Object.keys(KEY_SIGNATURE_MAP) as KeySignature[]; - // Export options const exportOptions = ["Export to KGStudio file", "Export to MIDI file", "Export to WAV", "Export to MP3"]; const lastSelectedRegionId = selectedRegionIds[selectedRegionIds.length - 1] ?? null; @@ -280,7 +278,7 @@ const Toolbar: React.FC = () => { } }; - const handleConfirmOpenProject = async (_projectNameToLoad: string) => { + const handleConfirmOpenProject = async () => { const confirmed = await showConfirm( 'Open this project? Any unsaved changes in the current project will be lost.' ); @@ -1154,25 +1152,29 @@ const Toolbar: React.FC = () => { {timeSignature.numerator + "/" + timeSignature.denominator}
- setShowKeySignatureDropdown(!showKeySignatureDropdown)} - style={{ cursor: 'pointer' }} + setShowKeySignatureDropdown(false)} + placement="bottom" + className="key-signature-popup-anchor" + contentClassName="key-signature-popup-surface" + panelClassName="key-signature-popup-panel" + arrowClassName="key-signature-popup-arrow" + trigger={( + + )} > - {keySignature} - -
- -
+ +
} + > +
Popup body
+ + + + ); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + fireEvent.mouseDown(screen.getByRole('button', { name: 'Outside' })); + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(onClose).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/components/common/FloatingPopup.tsx b/src/components/common/FloatingPopup.tsx new file mode 100644 index 0000000..c746d8b --- /dev/null +++ b/src/components/common/FloatingPopup.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import './FloatingPopup.css'; + +type FloatingPopupPlacement = 'bottom' | 'top' | 'left' | 'right'; + +interface FloatingPopupProps { + isOpen: boolean; + onClose: () => void; + trigger: React.ReactNode; + children: React.ReactNode; + placement?: FloatingPopupPlacement; + className?: string; + triggerClassName?: string; + contentClassName?: string; + panelClassName?: string; + arrowClassName?: string; +} + +const FloatingPopup: React.FC = ({ + isOpen, + onClose, + trigger, + children, + placement = 'bottom', + className = '', + triggerClassName = '', + contentClassName = '', + panelClassName = '', + arrowClassName = '', +}) => { + const rootRef = React.useRef(null); + + React.useEffect(() => { + if (!isOpen) { + return; + } + + const handleMouseDown = (event: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(event.target as Node)) { + onClose(); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('mousedown', handleMouseDown); + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('mousedown', handleMouseDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen, onClose]); + + return ( +
+
+ {trigger} +
+ {isOpen && ( +
+ + )} +
+ ); +}; + +export default FloatingPopup; diff --git a/src/components/common/index.ts b/src/components/common/index.ts index 093aaf4..e43862f 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -5,5 +5,6 @@ export { default as LoadingOverlay } from './LoadingOverlay'; export { default as OpenProjectModal } from './OpenProjectModal'; export { default as DialogProvider } from './DialogProvider'; export { default as TrackCreateDialog } from './TrackCreateDialog'; +export { default as FloatingPopup } from './FloatingPopup'; export { showAlert, showConfirm, showPrompt, showTimeSigPrompt } from '../../util/dialogUtil'; export type { ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil'; diff --git a/src/components/keySignaturePickerLayout.test.ts b/src/components/keySignaturePickerLayout.test.ts new file mode 100644 index 0000000..a568587 --- /dev/null +++ b/src/components/keySignaturePickerLayout.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { buildKeySignatureCircleSlots } from './keySignaturePickerLayout'; + +describe('keySignaturePickerLayout', () => { + it('builds shared enharmonic slots with both exact key signatures', () => { + const slots = buildKeySignatureCircleSlots(); + const sharedMajorSlot = slots.find(slot => slot.outerItems.some(item => item.keySignature === 'Gb major')); + const sharedMinorSlot = slots.find(slot => slot.innerItems.some(item => item.keySignature === 'D# minor')); + + expect(sharedMajorSlot?.outerItems.map(item => item.keySignature)).toEqual(['Gb major', 'F# major']); + expect(sharedMinorSlot?.innerItems.map(item => item.keySignature)).toEqual(['Eb minor', 'D# minor']); + }); + + it('uses accidental counts derived from the key signature map', () => { + const slots = buildKeySignatureCircleSlots(); + const cSlot = slots.find(slot => slot.id === 'c'); + const gbSlot = slots.find(slot => slot.id === 'gb-fsharp'); + + expect(cSlot?.accidentalLabel).toBe('natural'); + expect(gbSlot?.accidentalLabel).toBe('6♭ / 6♯'); + }); +}); diff --git a/src/components/keySignaturePickerLayout.ts b/src/components/keySignaturePickerLayout.ts new file mode 100644 index 0000000..e1a0354 --- /dev/null +++ b/src/components/keySignaturePickerLayout.ts @@ -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), + }; + }); +}