@gblp/circle-of-fifths 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,274 @@
1
1
  import * as i0 from '@angular/core';
2
- import { input, computed, Component, signal } from '@angular/core';
2
+ import { input, output, computed, Component, inject, signal, effect } from '@angular/core';
3
3
  import { NgClass } from '@angular/common';
4
+ import { ChordService, ChordDiagram } from '@gblp/chord-finder';
5
+
6
+ const KEYS = [
7
+ { index: 0, major: 'C', minor: 'Am', sharps: 0, flats: 0 },
8
+ { index: 1, major: 'G', minor: 'Em', sharps: 1, flats: 0 },
9
+ { index: 2, major: 'D', minor: 'Bm', sharps: 2, flats: 0 },
10
+ { index: 3, major: 'A', minor: 'F♯m', sharps: 3, flats: 0 },
11
+ { index: 4, major: 'E', minor: 'C♯m', sharps: 4, flats: 0 },
12
+ { index: 5, major: 'B', minor: 'G♯m', sharps: 5, flats: 0 },
13
+ { index: 6, major: 'F♯', minor: 'D♯m', sharps: 6, flats: 0 },
14
+ { index: 7, major: 'D♭', minor: 'B♭m', sharps: 0, flats: 5 },
15
+ { index: 8, major: 'A♭', minor: 'Fm', sharps: 0, flats: 4 },
16
+ { index: 9, major: 'E♭', minor: 'Cm', sharps: 0, flats: 3 },
17
+ { index: 10, major: 'B♭', minor: 'Gm', sharps: 0, flats: 2 },
18
+ { index: 11, major: 'F', minor: 'Dm', sharps: 0, flats: 1 },
19
+ ];
20
+ const COPY = {
21
+ en: {
22
+ title: 'Circle of Fifths',
23
+ subtitle: 'Click any major or minor key to highlight its diatonic chords',
24
+ ariaLabel: 'Circle of Fifths',
25
+ clickKey: 'Click a key',
26
+ seeChords: 'to see its chords',
27
+ deselect: '↩ click to deselect',
28
+ tonicLegend: 'Tonic (I / i)',
29
+ majorLegend: 'Major chords (IV, V)',
30
+ minorLegend: 'Minor chords (ii, iii, vi)',
31
+ diminishedLegend: 'Diminished (vii° / ii°)',
32
+ diatonicChords: 'Diatonic Chords',
33
+ commonProgressions: 'Common Progressions in',
34
+ generateProgressions: 'Shuffle',
35
+ chorus: 'Chorus',
36
+ bridge: 'Bridge',
37
+ outro: 'Outro',
38
+ verse: 'Verse',
39
+ copy: 'Copy',
40
+ expand: 'Expand',
41
+ collapse: 'Collapse',
42
+ transposeLabel: 'Transpose to:',
43
+ spiceLabels: ['🌶 Spice', '🌶🌶 Spicier', '🔥 Hot!', '↩ Plain'],
44
+ major: 'Major',
45
+ minor: 'Minor',
46
+ diminished: 'diminished',
47
+ relativeMinor: 'Relative minor',
48
+ relativeMajor: 'Relative major',
49
+ showChordDiagrams: 'Show chord diagrams',
50
+ hideChordDiagrams: 'Hide chord diagrams',
51
+ previousPosition: 'Previous position',
52
+ nextPosition: 'Next position',
53
+ previousPositionAll: 'Previous position (all diagrams)',
54
+ nextPositionAll: 'Next position (all diagrams)',
55
+ roles: {
56
+ tonic: 'Tonic',
57
+ supertonic: 'Supertonic',
58
+ mediant: 'Mediant',
59
+ subdominant: 'Subdominant',
60
+ dominant: 'Dominant',
61
+ submediant: 'Submediant',
62
+ leadingTone: 'Leading Tone',
63
+ subtonic: 'Subtonic',
64
+ },
65
+ },
66
+ es: {
67
+ title: 'Círculo de quintas',
68
+ subtitle: 'Haz clic en una tonalidad mayor o menor para resaltar sus acordes diatónicos',
69
+ ariaLabel: 'Círculo de quintas',
70
+ clickKey: 'Haz clic en una tonalidad',
71
+ seeChords: 'para ver sus acordes',
72
+ deselect: '↩ haz clic para deseleccionar',
73
+ tonicLegend: 'Tónica (I / i)',
74
+ majorLegend: 'Acordes mayores (IV, V)',
75
+ minorLegend: 'Acordes menores (ii, iii, vi)',
76
+ diminishedLegend: 'Disminuido (vii° / ii°)',
77
+ diatonicChords: 'Acordes diatónicos',
78
+ commonProgressions: 'Progresiones comunes en',
79
+ generateProgressions: 'Generar nuevos',
80
+ chorus: 'Coro',
81
+ bridge: 'Puente',
82
+ outro: 'Final',
83
+ verse: 'Estrofa',
84
+ copy: 'Copiar',
85
+ expand: 'Expandir',
86
+ collapse: 'Colapsar',
87
+ transposeLabel: 'Transponer a:',
88
+ spiceLabels: ['🌶 Condimentar', '🌶🌶 Más sabor', '🔥 ¡Picante!', '↩ Simple'],
89
+ major: 'Mayor',
90
+ minor: 'Menor',
91
+ diminished: 'disminuido',
92
+ relativeMinor: 'Relativa menor',
93
+ relativeMajor: 'Relativa mayor',
94
+ showChordDiagrams: 'Mostrar acordes de diagramas',
95
+ hideChordDiagrams: 'Ocultar acordes de diagramas',
96
+ previousPosition: 'Posición anterior',
97
+ nextPosition: 'Siguiente posición',
98
+ previousPositionAll: 'Posición anterior (todos)',
99
+ nextPositionAll: 'Siguiente posición (todos)',
100
+ roles: {
101
+ tonic: 'Tónica',
102
+ supertonic: 'Supertónica',
103
+ mediant: 'Mediante',
104
+ subdominant: 'Subdominante',
105
+ dominant: 'Dominante',
106
+ submediant: 'Submediante',
107
+ leadingTone: 'Sensible',
108
+ subtonic: 'Subtónica',
109
+ },
110
+ },
111
+ };
112
+
113
+ class CircleOfFifthsWheel {
114
+ language = input('en', /* @ts-ignore */
115
+ ...(ngDevMode ? [{ debugName: "language" }] : /* istanbul ignore next */ []));
116
+ selectedIndex = input(null, /* @ts-ignore */
117
+ ...(ngDevMode ? [{ debugName: "selectedIndex" }] : /* istanbul ignore next */ []));
118
+ selectedType = input(null, /* @ts-ignore */
119
+ ...(ngDevMode ? [{ debugName: "selectedType" }] : /* istanbul ignore next */ []));
120
+ interactive = input(true, /* @ts-ignore */
121
+ ...(ngDevMode ? [{ debugName: "interactive" }] : /* istanbul ignore next */ []));
122
+ keySelected = output();
123
+ text = computed(() => COPY[this.language()], /* @ts-ignore */
124
+ ...(ngDevMode ? [{ debugName: "text" }] : /* istanbul ignore next */ []));
125
+ keys = KEYS;
126
+ CX = 300;
127
+ CY = 300;
128
+ KEY_SIG = { inner: 250, outer: 288 };
129
+ MAJOR = { inner: 172, outer: 250 };
130
+ MINOR = { inner: 102, outer: 172 };
131
+ get slice() {
132
+ const idx = this.selectedIndex();
133
+ if (idx === null)
134
+ return null;
135
+ return {
136
+ prev: (idx - 1 + 12) % 12,
137
+ curr: idx,
138
+ next: (idx + 1) % 12,
139
+ };
140
+ }
141
+ onKeyClick(index, type) {
142
+ if (!this.interactive())
143
+ return;
144
+ this.keySelected.emit({ index, type });
145
+ }
146
+ getMajorState(index) {
147
+ const s = this.slice;
148
+ if (!s)
149
+ return '';
150
+ const type = this.selectedType();
151
+ if (index === s.curr)
152
+ return type === 'major' ? 'tonic' : 'scale-major';
153
+ if (index === s.prev || index === s.next)
154
+ return 'scale-major';
155
+ return '';
156
+ }
157
+ getMinorState(index) {
158
+ const s = this.slice;
159
+ if (!s)
160
+ return '';
161
+ const type = this.selectedType();
162
+ const dimIdx = (s.curr + 2) % 12;
163
+ if (index === s.curr)
164
+ return type === 'minor' ? 'tonic' : 'scale-minor';
165
+ if (index === s.prev || index === s.next)
166
+ return 'scale-minor';
167
+ if (index === dimIdx)
168
+ return 'scale-diminished';
169
+ return '';
170
+ }
171
+ getMajorRole(index) {
172
+ const s = this.slice;
173
+ if (!s)
174
+ return '';
175
+ const type = this.selectedType();
176
+ if (type === 'major') {
177
+ if (index === s.curr)
178
+ return 'I';
179
+ if (index === s.prev)
180
+ return 'IV';
181
+ if (index === s.next)
182
+ return 'V';
183
+ }
184
+ else {
185
+ if (index === s.curr)
186
+ return 'III';
187
+ if (index === s.prev)
188
+ return 'VI';
189
+ if (index === s.next)
190
+ return 'VII';
191
+ }
192
+ return '';
193
+ }
194
+ getMinorRole(index) {
195
+ const s = this.slice;
196
+ if (!s)
197
+ return '';
198
+ const type = this.selectedType();
199
+ const dimIdx = (s.curr + 2) % 12;
200
+ if (type === 'major') {
201
+ if (index === s.curr)
202
+ return 'vi';
203
+ if (index === s.prev)
204
+ return 'ii';
205
+ if (index === s.next)
206
+ return 'iii';
207
+ if (index === dimIdx)
208
+ return 'vii°';
209
+ }
210
+ else {
211
+ if (index === s.curr)
212
+ return 'i';
213
+ if (index === s.prev)
214
+ return 'iv';
215
+ if (index === s.next)
216
+ return 'v';
217
+ if (index === dimIdx)
218
+ return 'ii°';
219
+ }
220
+ return '';
221
+ }
222
+ arcPath(innerR, outerR, index, gap = 1.5) {
223
+ const mid = index * 30 - 90;
224
+ const s = (mid - 15 + gap) * (Math.PI / 180);
225
+ const e = (mid + 15 - gap) * (Math.PI / 180);
226
+ const cx = this.CX, cy = this.CY;
227
+ const x1 = cx + outerR * Math.cos(s), y1 = cy + outerR * Math.sin(s);
228
+ const x2 = cx + outerR * Math.cos(e), y2 = cy + outerR * Math.sin(e);
229
+ const x3 = cx + innerR * Math.cos(e), y3 = cy + innerR * Math.sin(e);
230
+ const x4 = cx + innerR * Math.cos(s), y4 = cy + innerR * Math.sin(s);
231
+ const f = (n) => n.toFixed(2);
232
+ return `M${f(x1)} ${f(y1)} A${outerR} ${outerR} 0 0 1 ${f(x2)} ${f(y2)} L${f(x3)} ${f(y3)} A${innerR} ${innerR} 0 0 0 ${f(x4)} ${f(y4)}Z`;
233
+ }
234
+ textPos(midR, index) {
235
+ const a = (index * 30 - 90) * (Math.PI / 180);
236
+ return {
237
+ x: this.CX + midR * Math.cos(a),
238
+ y: this.CY + midR * Math.sin(a),
239
+ };
240
+ }
241
+ getAccidentalText(key) {
242
+ if (key.sharps > 0)
243
+ return `${key.sharps}♯`;
244
+ if (key.flats > 0)
245
+ return `${key.flats}♭`;
246
+ return '—';
247
+ }
248
+ get selectedInfo() {
249
+ const idx = this.selectedIndex();
250
+ const type = this.selectedType();
251
+ if (idx === null || type === null)
252
+ return null;
253
+ const key = KEYS[idx];
254
+ const copy = this.text();
255
+ const scale = type === 'major' ? copy.major : copy.minor;
256
+ return {
257
+ name: type === 'major' ? key.major : key.minor,
258
+ scale,
259
+ fullName: `${type === 'major' ? key.major : key.minor} ${scale}`,
260
+ relativeKey: type === 'major'
261
+ ? `${copy.relativeMinor}: ${key.minor}`
262
+ : `${copy.relativeMajor}: ${key.major}`,
263
+ };
264
+ }
265
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: CircleOfFifthsWheel, deps: [], target: i0.ɵɵFactoryTarget.Component });
266
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.2", type: CircleOfFifthsWheel, isStandalone: true, selector: "circle-of-fifths-wheel", inputs: { language: { classPropertyName: "language", publicName: "language", isSignal: true, isRequired: false, transformFunction: null }, selectedIndex: { classPropertyName: "selectedIndex", publicName: "selectedIndex", isSignal: true, isRequired: false, transformFunction: null }, selectedType: { classPropertyName: "selectedType", publicName: "selectedType", isSignal: true, isRequired: false, transformFunction: null }, interactive: { classPropertyName: "interactive", publicName: "interactive", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { keySelected: "keySelected" }, host: { properties: { "class.readonly": "!interactive()" } }, ngImport: i0, template: "<div class=\"circle-wrapper\">\n <svg\n viewBox=\"0 0 600 600\"\n xmlns=\"http://www.w3.org/2000/svg\"\n [attr.aria-label]=\"text().ariaLabel\"\n >\n <!-- \u2500\u2500 Key Signature Ring \u2500\u2500 -->\n @for (key of keys; track key.index) {\n <path [attr.d]=\"arcPath(KEY_SIG.inner, KEY_SIG.outer, key.index)\" class=\"keysig-segment\" />\n <text\n [attr.x]=\"textPos((KEY_SIG.inner + KEY_SIG.outer) / 2, key.index).x\"\n [attr.y]=\"textPos((KEY_SIG.inner + KEY_SIG.outer) / 2, key.index).y\"\n class=\"keysig-label\"\n >\n {{ getAccidentalText(key) }}\n </text>\n }\n\n <!-- \u2500\u2500 Major Keys Ring \u2500\u2500 -->\n @for (key of keys; track key.index) {\n <g\n [ngClass]=\"['major-group', getMajorState(key.index)]\"\n (click)=\"onKeyClick(key.index, 'major')\"\n >\n <path [attr.d]=\"arcPath(MAJOR.inner, MAJOR.outer, key.index)\" />\n\n <text\n [attr.x]=\"textPos((MAJOR.inner + MAJOR.outer) / 2, key.index).x\"\n [attr.y]=\"\n textPos((MAJOR.inner + MAJOR.outer) / 2, key.index).y +\n (getMajorRole(key.index) ? -9 : 0)\n \"\n class=\"major-label\"\n >\n {{ key.major }}\n </text>\n\n @if (getMajorRole(key.index)) {\n <text\n [attr.x]=\"textPos((MAJOR.inner + MAJOR.outer) / 2, key.index).x\"\n [attr.y]=\"textPos((MAJOR.inner + MAJOR.outer) / 2, key.index).y + 11\"\n class=\"role-label\"\n >\n {{ getMajorRole(key.index) }}\n </text>\n }\n </g>\n }\n\n <!-- \u2500\u2500 Minor Keys Ring \u2500\u2500 -->\n @for (key of keys; track key.index) {\n <g\n [ngClass]=\"['minor-group', getMinorState(key.index)]\"\n (click)=\"onKeyClick(key.index, 'minor')\"\n >\n <path [attr.d]=\"arcPath(MINOR.inner, MINOR.outer, key.index)\" />\n\n <text\n [attr.x]=\"textPos((MINOR.inner + MINOR.outer) / 2, key.index).x\"\n [attr.y]=\"\n textPos((MINOR.inner + MINOR.outer) / 2, key.index).y +\n (getMinorRole(key.index) ? -8 : 0)\n \"\n class=\"minor-label\"\n >\n {{ key.minor }}\n </text>\n\n @if (getMinorRole(key.index)) {\n <text\n [attr.x]=\"textPos((MINOR.inner + MINOR.outer) / 2, key.index).x\"\n [attr.y]=\"textPos((MINOR.inner + MINOR.outer) / 2, key.index).y + 9\"\n class=\"role-label\"\n >\n {{ getMinorRole(key.index) }}\n </text>\n }\n </g>\n }\n\n <!-- \u2500\u2500 Outer border \u2500\u2500 -->\n <circle cx=\"300\" cy=\"300\" r=\"289\" class=\"outer-border\" />\n\n <!-- \u2500\u2500 Center Circle \u2500\u2500 -->\n <circle cx=\"300\" cy=\"300\" r=\"99\" class=\"center-circle\" />\n\n @if (selectedInfo) {\n <text x=\"300\" y=\"283\" class=\"center-name\">{{ selectedInfo.name }}</text>\n <text x=\"300\" y=\"304\" class=\"center-scale\">{{ selectedInfo.scale }}</text>\n <text x=\"300\" y=\"322\" class=\"center-relative\">{{ selectedInfo.relativeKey }}</text>\n @if (interactive()) {\n <text x=\"300\" y=\"338\" class=\"center-deselect\">{{ text().deselect }}</text>\n }\n } @else if (interactive()) {\n <text x=\"300\" y=\"293\" class=\"center-hint\">{{ text().clickKey }}</text>\n <text x=\"300\" y=\"312\" class=\"center-hint-sm\">{{ text().seeChords }}</text>\n }\n </svg>\n</div>\n\n<!-- \u2500\u2500 Legend \u2500\u2500 -->\n<div class=\"legend\">\n <span class=\"legend-item tonic\">{{ text().tonicLegend }}</span>\n <span class=\"legend-item scale-major\">{{ text().majorLegend }}</span>\n <span class=\"legend-item scale-minor\">{{ text().minorLegend }}</span>\n <span class=\"legend-item scale-diminished\">{{ text().diminishedLegend }}</span>\n</div>\n", styles: [":host{--_chords-background: var(--chords-background, #f0f4f8);--_chords-background-alt: var(--chords-background-alt, #f8fafc);--_chords-surface: var(--chords-surface, #ffffff);--_chords-surface-elevated: var(--chords-surface-elevated, #f8fafc);--_chords-text: var(--chords-text, #1e293b);--_chords-muted: var(--chords-muted, #64748b);--_chords-primary: var(--chords-primary, #3b82f6);--_chords-secondary: var(--chords-secondary, #06b6d4);--_chords-highlight: var(--chords-highlight, #f59e0b);--_chords-danger: var(--chords-danger, #a855f7);--_chords-border: var(--chords-border, #e2e8f0);--_chords-on-primary: var(--chords-on-primary, #ffffff);display:block;font-family:Segoe UI,system-ui,sans-serif}:host(.readonly) .major-group,:host(.readonly) .minor-group{cursor:default;pointer-events:none}.circle-wrapper{max-width:580px;margin:0 auto}.circle-wrapper svg{display:block;width:100%;height:auto;overflow:visible}.keysig-segment{fill:var(--_chords-text)}.keysig-label{fill:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background));font-size:12px;font-weight:500;text-anchor:middle;dominant-baseline:central;pointer-events:none}.major-group{cursor:pointer}.major-group path{fill:color-mix(in srgb,var(--_chords-primary) 14%,var(--_chords-surface));stroke:var(--_chords-surface);stroke-width:2;transition:fill .18s ease}.major-group:hover path{fill:color-mix(in srgb,var(--_chords-primary) 24%,var(--_chords-surface))}.major-group .major-label{fill:var(--_chords-text);font-size:15px;font-weight:700;text-anchor:middle;dominant-baseline:central;pointer-events:none;transition:fill .18s ease}.major-group.tonic path{fill:var(--_chords-highlight)}.major-group.tonic:hover path{fill:color-mix(in srgb,var(--_chords-highlight) 84%,var(--_chords-text))}.major-group.tonic .major-label{fill:var(--_chords-on-primary)}.major-group.scale-major path{fill:var(--_chords-primary)}.major-group.scale-major:hover path{fill:color-mix(in srgb,var(--_chords-primary) 82%,var(--_chords-text))}.major-group.scale-major .major-label{fill:var(--_chords-on-primary)}.minor-group{cursor:pointer}.minor-group path{fill:color-mix(in srgb,var(--_chords-secondary) 10%,var(--_chords-surface));stroke:var(--_chords-surface);stroke-width:2;transition:fill .18s ease}.minor-group:hover path{fill:color-mix(in srgb,var(--_chords-secondary) 20%,var(--_chords-surface))}.minor-group .minor-label{fill:color-mix(in srgb,var(--_chords-text) 78%,var(--_chords-muted));font-size:12px;font-weight:600;text-anchor:middle;dominant-baseline:central;pointer-events:none;transition:fill .18s ease}.minor-group.tonic path{fill:var(--_chords-highlight)}.minor-group.tonic:hover path{fill:color-mix(in srgb,var(--_chords-highlight) 84%,var(--_chords-text))}.minor-group.tonic .minor-label{fill:var(--_chords-on-primary)}.minor-group.scale-minor path{fill:var(--_chords-secondary)}.minor-group.scale-minor:hover path{fill:color-mix(in srgb,var(--_chords-secondary) 82%,var(--_chords-text))}.minor-group.scale-minor .minor-label{fill:var(--_chords-on-primary)}.minor-group.scale-diminished path{fill:var(--_chords-danger)}.minor-group.scale-diminished:hover path{fill:color-mix(in srgb,var(--_chords-danger) 82%,var(--_chords-text))}.minor-group.scale-diminished .minor-label{fill:var(--_chords-on-primary)}.role-label{font-size:10px;font-style:italic;font-weight:600;text-anchor:middle;dominant-baseline:central;pointer-events:none;fill:color-mix(in srgb,var(--_chords-on-primary) 85%,transparent)}.outer-border{fill:none;stroke:var(--_chords-text);stroke-width:2.5}.center-circle{fill:var(--_chords-surface);stroke:var(--_chords-border);stroke-width:2;filter:drop-shadow(0 2px 10px color-mix(in srgb,var(--_chords-text) 8%,transparent))}.center-name{font-size:30px;font-weight:800;text-anchor:middle;dominant-baseline:auto;fill:var(--_chords-text);letter-spacing:-.02em}.center-scale{font-size:13px;font-weight:600;text-anchor:middle;dominant-baseline:auto;fill:var(--_chords-muted);text-transform:uppercase;letter-spacing:.08em}.center-relative{font-size:9.5px;font-weight:500;text-anchor:middle;dominant-baseline:auto;fill:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background))}.center-deselect{font-size:8px;text-anchor:middle;dominant-baseline:auto;fill:color-mix(in srgb,var(--_chords-muted) 42%,var(--_chords-background))}.center-hint{font-size:16px;font-weight:600;text-anchor:middle;dominant-baseline:auto;fill:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background))}.center-hint-sm{font-size:11px;text-anchor:middle;dominant-baseline:auto;fill:color-mix(in srgb,var(--_chords-muted) 42%,var(--_chords-background))}.legend{display:flex;justify-content:center;gap:16px;flex-wrap:wrap;margin:16px 0 0}.legend .legend-item{display:flex;align-items:center;gap:6px;font-size:.8rem;color:var(--_chords-muted)}.legend .legend-item:before{content:\"\";display:inline-block;width:14px;height:14px;border-radius:3px}.legend .legend-item.tonic:before{background:var(--_chords-highlight)}.legend .legend-item.scale-major:before{background:var(--_chords-primary)}.legend .legend-item.scale-minor:before{background:var(--_chords-secondary)}.legend .legend-item.scale-diminished:before{background:var(--_chords-danger)}\n"], dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }] });
267
+ }
268
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: CircleOfFifthsWheel, decorators: [{
269
+ type: Component,
270
+ args: [{ selector: 'circle-of-fifths-wheel', standalone: true, imports: [NgClass], host: { '[class.readonly]': '!interactive()' }, template: "<div class=\"circle-wrapper\">\n <svg\n viewBox=\"0 0 600 600\"\n xmlns=\"http://www.w3.org/2000/svg\"\n [attr.aria-label]=\"text().ariaLabel\"\n >\n <!-- \u2500\u2500 Key Signature Ring \u2500\u2500 -->\n @for (key of keys; track key.index) {\n <path [attr.d]=\"arcPath(KEY_SIG.inner, KEY_SIG.outer, key.index)\" class=\"keysig-segment\" />\n <text\n [attr.x]=\"textPos((KEY_SIG.inner + KEY_SIG.outer) / 2, key.index).x\"\n [attr.y]=\"textPos((KEY_SIG.inner + KEY_SIG.outer) / 2, key.index).y\"\n class=\"keysig-label\"\n >\n {{ getAccidentalText(key) }}\n </text>\n }\n\n <!-- \u2500\u2500 Major Keys Ring \u2500\u2500 -->\n @for (key of keys; track key.index) {\n <g\n [ngClass]=\"['major-group', getMajorState(key.index)]\"\n (click)=\"onKeyClick(key.index, 'major')\"\n >\n <path [attr.d]=\"arcPath(MAJOR.inner, MAJOR.outer, key.index)\" />\n\n <text\n [attr.x]=\"textPos((MAJOR.inner + MAJOR.outer) / 2, key.index).x\"\n [attr.y]=\"\n textPos((MAJOR.inner + MAJOR.outer) / 2, key.index).y +\n (getMajorRole(key.index) ? -9 : 0)\n \"\n class=\"major-label\"\n >\n {{ key.major }}\n </text>\n\n @if (getMajorRole(key.index)) {\n <text\n [attr.x]=\"textPos((MAJOR.inner + MAJOR.outer) / 2, key.index).x\"\n [attr.y]=\"textPos((MAJOR.inner + MAJOR.outer) / 2, key.index).y + 11\"\n class=\"role-label\"\n >\n {{ getMajorRole(key.index) }}\n </text>\n }\n </g>\n }\n\n <!-- \u2500\u2500 Minor Keys Ring \u2500\u2500 -->\n @for (key of keys; track key.index) {\n <g\n [ngClass]=\"['minor-group', getMinorState(key.index)]\"\n (click)=\"onKeyClick(key.index, 'minor')\"\n >\n <path [attr.d]=\"arcPath(MINOR.inner, MINOR.outer, key.index)\" />\n\n <text\n [attr.x]=\"textPos((MINOR.inner + MINOR.outer) / 2, key.index).x\"\n [attr.y]=\"\n textPos((MINOR.inner + MINOR.outer) / 2, key.index).y +\n (getMinorRole(key.index) ? -8 : 0)\n \"\n class=\"minor-label\"\n >\n {{ key.minor }}\n </text>\n\n @if (getMinorRole(key.index)) {\n <text\n [attr.x]=\"textPos((MINOR.inner + MINOR.outer) / 2, key.index).x\"\n [attr.y]=\"textPos((MINOR.inner + MINOR.outer) / 2, key.index).y + 9\"\n class=\"role-label\"\n >\n {{ getMinorRole(key.index) }}\n </text>\n }\n </g>\n }\n\n <!-- \u2500\u2500 Outer border \u2500\u2500 -->\n <circle cx=\"300\" cy=\"300\" r=\"289\" class=\"outer-border\" />\n\n <!-- \u2500\u2500 Center Circle \u2500\u2500 -->\n <circle cx=\"300\" cy=\"300\" r=\"99\" class=\"center-circle\" />\n\n @if (selectedInfo) {\n <text x=\"300\" y=\"283\" class=\"center-name\">{{ selectedInfo.name }}</text>\n <text x=\"300\" y=\"304\" class=\"center-scale\">{{ selectedInfo.scale }}</text>\n <text x=\"300\" y=\"322\" class=\"center-relative\">{{ selectedInfo.relativeKey }}</text>\n @if (interactive()) {\n <text x=\"300\" y=\"338\" class=\"center-deselect\">{{ text().deselect }}</text>\n }\n } @else if (interactive()) {\n <text x=\"300\" y=\"293\" class=\"center-hint\">{{ text().clickKey }}</text>\n <text x=\"300\" y=\"312\" class=\"center-hint-sm\">{{ text().seeChords }}</text>\n }\n </svg>\n</div>\n\n<!-- \u2500\u2500 Legend \u2500\u2500 -->\n<div class=\"legend\">\n <span class=\"legend-item tonic\">{{ text().tonicLegend }}</span>\n <span class=\"legend-item scale-major\">{{ text().majorLegend }}</span>\n <span class=\"legend-item scale-minor\">{{ text().minorLegend }}</span>\n <span class=\"legend-item scale-diminished\">{{ text().diminishedLegend }}</span>\n</div>\n", styles: [":host{--_chords-background: var(--chords-background, #f0f4f8);--_chords-background-alt: var(--chords-background-alt, #f8fafc);--_chords-surface: var(--chords-surface, #ffffff);--_chords-surface-elevated: var(--chords-surface-elevated, #f8fafc);--_chords-text: var(--chords-text, #1e293b);--_chords-muted: var(--chords-muted, #64748b);--_chords-primary: var(--chords-primary, #3b82f6);--_chords-secondary: var(--chords-secondary, #06b6d4);--_chords-highlight: var(--chords-highlight, #f59e0b);--_chords-danger: var(--chords-danger, #a855f7);--_chords-border: var(--chords-border, #e2e8f0);--_chords-on-primary: var(--chords-on-primary, #ffffff);display:block;font-family:Segoe UI,system-ui,sans-serif}:host(.readonly) .major-group,:host(.readonly) .minor-group{cursor:default;pointer-events:none}.circle-wrapper{max-width:580px;margin:0 auto}.circle-wrapper svg{display:block;width:100%;height:auto;overflow:visible}.keysig-segment{fill:var(--_chords-text)}.keysig-label{fill:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background));font-size:12px;font-weight:500;text-anchor:middle;dominant-baseline:central;pointer-events:none}.major-group{cursor:pointer}.major-group path{fill:color-mix(in srgb,var(--_chords-primary) 14%,var(--_chords-surface));stroke:var(--_chords-surface);stroke-width:2;transition:fill .18s ease}.major-group:hover path{fill:color-mix(in srgb,var(--_chords-primary) 24%,var(--_chords-surface))}.major-group .major-label{fill:var(--_chords-text);font-size:15px;font-weight:700;text-anchor:middle;dominant-baseline:central;pointer-events:none;transition:fill .18s ease}.major-group.tonic path{fill:var(--_chords-highlight)}.major-group.tonic:hover path{fill:color-mix(in srgb,var(--_chords-highlight) 84%,var(--_chords-text))}.major-group.tonic .major-label{fill:var(--_chords-on-primary)}.major-group.scale-major path{fill:var(--_chords-primary)}.major-group.scale-major:hover path{fill:color-mix(in srgb,var(--_chords-primary) 82%,var(--_chords-text))}.major-group.scale-major .major-label{fill:var(--_chords-on-primary)}.minor-group{cursor:pointer}.minor-group path{fill:color-mix(in srgb,var(--_chords-secondary) 10%,var(--_chords-surface));stroke:var(--_chords-surface);stroke-width:2;transition:fill .18s ease}.minor-group:hover path{fill:color-mix(in srgb,var(--_chords-secondary) 20%,var(--_chords-surface))}.minor-group .minor-label{fill:color-mix(in srgb,var(--_chords-text) 78%,var(--_chords-muted));font-size:12px;font-weight:600;text-anchor:middle;dominant-baseline:central;pointer-events:none;transition:fill .18s ease}.minor-group.tonic path{fill:var(--_chords-highlight)}.minor-group.tonic:hover path{fill:color-mix(in srgb,var(--_chords-highlight) 84%,var(--_chords-text))}.minor-group.tonic .minor-label{fill:var(--_chords-on-primary)}.minor-group.scale-minor path{fill:var(--_chords-secondary)}.minor-group.scale-minor:hover path{fill:color-mix(in srgb,var(--_chords-secondary) 82%,var(--_chords-text))}.minor-group.scale-minor .minor-label{fill:var(--_chords-on-primary)}.minor-group.scale-diminished path{fill:var(--_chords-danger)}.minor-group.scale-diminished:hover path{fill:color-mix(in srgb,var(--_chords-danger) 82%,var(--_chords-text))}.minor-group.scale-diminished .minor-label{fill:var(--_chords-on-primary)}.role-label{font-size:10px;font-style:italic;font-weight:600;text-anchor:middle;dominant-baseline:central;pointer-events:none;fill:color-mix(in srgb,var(--_chords-on-primary) 85%,transparent)}.outer-border{fill:none;stroke:var(--_chords-text);stroke-width:2.5}.center-circle{fill:var(--_chords-surface);stroke:var(--_chords-border);stroke-width:2;filter:drop-shadow(0 2px 10px color-mix(in srgb,var(--_chords-text) 8%,transparent))}.center-name{font-size:30px;font-weight:800;text-anchor:middle;dominant-baseline:auto;fill:var(--_chords-text);letter-spacing:-.02em}.center-scale{font-size:13px;font-weight:600;text-anchor:middle;dominant-baseline:auto;fill:var(--_chords-muted);text-transform:uppercase;letter-spacing:.08em}.center-relative{font-size:9.5px;font-weight:500;text-anchor:middle;dominant-baseline:auto;fill:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background))}.center-deselect{font-size:8px;text-anchor:middle;dominant-baseline:auto;fill:color-mix(in srgb,var(--_chords-muted) 42%,var(--_chords-background))}.center-hint{font-size:16px;font-weight:600;text-anchor:middle;dominant-baseline:auto;fill:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background))}.center-hint-sm{font-size:11px;text-anchor:middle;dominant-baseline:auto;fill:color-mix(in srgb,var(--_chords-muted) 42%,var(--_chords-background))}.legend{display:flex;justify-content:center;gap:16px;flex-wrap:wrap;margin:16px 0 0}.legend .legend-item{display:flex;align-items:center;gap:6px;font-size:.8rem;color:var(--_chords-muted)}.legend .legend-item:before{content:\"\";display:inline-block;width:14px;height:14px;border-radius:3px}.legend .legend-item.tonic:before{background:var(--_chords-highlight)}.legend .legend-item.scale-major:before{background:var(--_chords-primary)}.legend .legend-item.scale-minor:before{background:var(--_chords-secondary)}.legend .legend-item.scale-diminished:before{background:var(--_chords-danger)}\n"] }]
271
+ }], propDecorators: { language: [{ type: i0.Input, args: [{ isSignal: true, alias: "language", required: false }] }], selectedIndex: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectedIndex", required: false }] }], selectedType: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectedType", required: false }] }], interactive: [{ type: i0.Input, args: [{ isSignal: true, alias: "interactive", required: false }] }], keySelected: [{ type: i0.Output, args: ["keySelected"] }] } });
4
272
 
5
273
  const PROGRESSIONS = {
6
274
  major: [
@@ -104,96 +372,6 @@ const PROGRESSIONS = {
104
372
  },
105
373
  ],
106
374
  };
107
- const COPY = {
108
- en: {
109
- title: 'Circle of Fifths',
110
- subtitle: 'Click any major or minor key to highlight its diatonic chords',
111
- ariaLabel: 'Circle of Fifths',
112
- clickKey: 'Click a key',
113
- seeChords: 'to see its chords',
114
- deselect: '↩ click to deselect',
115
- tonicLegend: 'Tonic (I / i)',
116
- majorLegend: 'Major chords (IV, V)',
117
- minorLegend: 'Minor chords (ii, iii, vi)',
118
- diminishedLegend: 'Diminished (vii° / ii°)',
119
- diatonicChords: 'Diatonic Chords',
120
- commonProgressions: 'Common Progressions in',
121
- generateProgressions: 'Shuffle',
122
- chorus: 'Chorus',
123
- bridge: 'Bridge',
124
- outro: 'Outro',
125
- verse: 'Verse',
126
- copy: 'Copy',
127
- expand: 'Expand',
128
- collapse: 'Collapse',
129
- major: 'Major',
130
- minor: 'Minor',
131
- diminished: 'diminished',
132
- relativeMinor: 'Relative minor',
133
- relativeMajor: 'Relative major',
134
- roles: {
135
- tonic: 'Tonic',
136
- supertonic: 'Supertonic',
137
- mediant: 'Mediant',
138
- subdominant: 'Subdominant',
139
- dominant: 'Dominant',
140
- submediant: 'Submediant',
141
- leadingTone: 'Leading Tone',
142
- subtonic: 'Subtonic',
143
- },
144
- },
145
- es: {
146
- title: 'Círculo de quintas',
147
- subtitle: 'Haz clic en una tonalidad mayor o menor para resaltar sus acordes diatónicos',
148
- ariaLabel: 'Círculo de quintas',
149
- clickKey: 'Haz clic en una tonalidad',
150
- seeChords: 'para ver sus acordes',
151
- deselect: '↩ haz clic para deseleccionar',
152
- tonicLegend: 'Tónica (I / i)',
153
- majorLegend: 'Acordes mayores (IV, V)',
154
- minorLegend: 'Acordes menores (ii, iii, vi)',
155
- diminishedLegend: 'Disminuido (vii° / ii°)',
156
- diatonicChords: 'Acordes diatónicos',
157
- commonProgressions: 'Progresiones comunes en',
158
- generateProgressions: 'Generar nuevos',
159
- chorus: 'Coro',
160
- bridge: 'Puente',
161
- outro: 'Final',
162
- verse: 'Estrofa',
163
- copy: 'Copiar',
164
- expand: 'Expandir',
165
- collapse: 'Colapsar',
166
- major: 'Mayor',
167
- minor: 'Menor',
168
- diminished: 'disminuido',
169
- relativeMinor: 'Relativa menor',
170
- relativeMajor: 'Relativa mayor',
171
- roles: {
172
- tonic: 'Tónica',
173
- supertonic: 'Supertónica',
174
- mediant: 'Mediante',
175
- subdominant: 'Subdominante',
176
- dominant: 'Dominante',
177
- submediant: 'Submediante',
178
- leadingTone: 'Sensible',
179
- subtonic: 'Subtónica',
180
- },
181
- },
182
- };
183
- const KEYS = [
184
- { index: 0, major: 'C', minor: 'Am', sharps: 0, flats: 0 },
185
- { index: 1, major: 'G', minor: 'Em', sharps: 1, flats: 0 },
186
- { index: 2, major: 'D', minor: 'Bm', sharps: 2, flats: 0 },
187
- { index: 3, major: 'A', minor: 'F♯m', sharps: 3, flats: 0 },
188
- { index: 4, major: 'E', minor: 'C♯m', sharps: 4, flats: 0 },
189
- { index: 5, major: 'B', minor: 'G♯m', sharps: 5, flats: 0 },
190
- { index: 6, major: 'F♯', minor: 'D♯m', sharps: 6, flats: 0 },
191
- { index: 7, major: 'D♭', minor: 'B♭m', sharps: 0, flats: 5 },
192
- { index: 8, major: 'A♭', minor: 'Fm', sharps: 0, flats: 4 },
193
- { index: 9, major: 'E♭', minor: 'Cm', sharps: 0, flats: 3 },
194
- { index: 10, major: 'B♭', minor: 'Gm', sharps: 0, flats: 2 },
195
- { index: 11, major: 'F', minor: 'Dm', sharps: 0, flats: 1 },
196
- ];
197
375
  function dimChord(idx) {
198
376
  // The diminished chord root is the minor key two steps clockwise (idx+2)
199
377
  return KEYS[(idx + 2) % 12].minor.replace(/m$/, '°');
@@ -224,69 +402,129 @@ function buildChordRows(idx, type, copy) {
224
402
  ];
225
403
  }
226
404
  }
405
+ // Suffix to append per numeral at spice levels 1, 2, 3.
406
+ // Minor chords already carry 'm' (e.g. "Dm"), so suffix starts from the number.
407
+ // Diminished chords carry '°' which gets replaced entirely.
408
+ const SPICE_SUFFIXES = {
409
+ 'I': ['maj7', 'maj9', 'maj9'],
410
+ 'ii': ['7', '9', '11'],
411
+ 'iii': ['7', '7', '7'],
412
+ 'IV': ['maj7', 'maj9', 'maj7♯11'],
413
+ 'V': ['7', '9', '13'],
414
+ 'vi': ['7', '9', '11'],
415
+ 'vii°': ['m7♭5', 'm7♭5', 'm7♭5'],
416
+ 'i': ['7', '9', '9'],
417
+ 'iv': ['7', '9', '11'],
418
+ 'v': ['7', '7', '7'],
419
+ 'III': ['maj7', 'maj7', 'maj7'],
420
+ 'VI': ['maj7', 'maj9', 'maj9'],
421
+ 'VII': ['7', '9', '13'],
422
+ 'ii°': ['m7♭5', 'm7♭5', 'm7♭5'],
423
+ };
424
+ function spiceChord(chord, numeral, level) {
425
+ if (level === 0)
426
+ return chord;
427
+ const suffixes = SPICE_SUFFIXES[numeral];
428
+ if (!suffixes)
429
+ return chord;
430
+ const suffix = suffixes[level - 1];
431
+ // Diminished chords: strip '°', then the suffix already starts with 'm'
432
+ return chord.endsWith('°') ? chord.slice(0, -1) + suffix : chord + suffix;
433
+ }
227
434
  class ChordSectionComponent {
228
- chords = input([], /* @ts-ignore */
229
- ...(ngDevMode ? [{ debugName: "chords" }] : /* istanbul ignore next */ []));
435
+ chordService = inject(ChordService);
436
+ selectedIndex = input(null, /* @ts-ignore */
437
+ ...(ngDevMode ? [{ debugName: "selectedIndex" }] : /* istanbul ignore next */ []));
438
+ selectedType = input(null, /* @ts-ignore */
439
+ ...(ngDevMode ? [{ debugName: "selectedType" }] : /* istanbul ignore next */ []));
230
440
  language = input('en', /* @ts-ignore */
231
441
  ...(ngDevMode ? [{ debugName: "language" }] : /* istanbul ignore next */ []));
232
442
  text = computed(() => COPY[this.language()], /* @ts-ignore */
233
443
  ...(ngDevMode ? [{ debugName: "text" }] : /* istanbul ignore next */ []));
234
- match = computed(() => {
235
- const input = this.chords();
236
- if (!input.length)
237
- return null;
238
- const inputSet = new Set(input);
239
- let best = null;
240
- for (let idx = 0; idx < 12; idx++) {
241
- for (const type of ['major', 'minor']) {
242
- // chord names are language-independent; COPY.en is a dummy for label fields
243
- const score = buildChordRows(idx, type, COPY.en).filter((r) => inputSet.has(r.chord)).length;
244
- if (!best || score > best.score)
245
- best = { idx, type, score };
444
+ diagramsExpanded = signal(false, /* @ts-ignore */
445
+ ...(ngDevMode ? [{ debugName: "diagramsExpanded" }] : /* istanbul ignore next */ []));
446
+ // Per-diagram fret position, keyed by ChordSearchResult.id.
447
+ positionIndices = signal({}, /* @ts-ignore */
448
+ ...(ngDevMode ? [{ debugName: "positionIndices" }] : /* istanbul ignore next */ []));
449
+ constructor() {
450
+ // A new key means a new set of diagrams — don't carry stale position
451
+ // choices over from the previous key's chords.
452
+ effect(() => {
453
+ this.selectedIndex();
454
+ this.selectedType();
455
+ this.positionIndices.set({});
456
+ });
457
+ }
458
+ toggleDiagrams() {
459
+ this.diagramsExpanded.update((v) => !v);
460
+ }
461
+ positionIndexFor(id) {
462
+ return this.positionIndices()[id] ?? 0;
463
+ }
464
+ stepPosition(id, total, delta) {
465
+ if (total <= 1)
466
+ return;
467
+ this.positionIndices.update((m) => ({
468
+ ...m,
469
+ [id]: ((m[id] ?? 0) + delta + total) % total,
470
+ }));
471
+ }
472
+ stepAllPositions(delta) {
473
+ this.positionIndices.update((m) => {
474
+ const next = { ...m };
475
+ for (const result of this.chordDiagrams()) {
476
+ const total = result.positions.length;
477
+ if (total <= 1)
478
+ continue;
479
+ next[result.id] = ((next[result.id] ?? 0) + delta + total) % total;
246
480
  }
247
- }
248
- return best && best.score > 0 ? best : null;
249
- }, /* @ts-ignore */
250
- ...(ngDevMode ? [{ debugName: "match" }] : /* istanbul ignore next */ []));
481
+ return next;
482
+ });
483
+ }
251
484
  rows = computed(() => {
252
- const m = this.match();
253
- if (!m)
485
+ const idx = this.selectedIndex();
486
+ const type = this.selectedType();
487
+ if (idx === null || type === null)
254
488
  return [];
255
- return buildChordRows(m.idx, m.type, this.text());
489
+ return buildChordRows(idx, type, this.text());
256
490
  }, /* @ts-ignore */
257
491
  ...(ngDevMode ? [{ debugName: "rows" }] : /* istanbul ignore next */ []));
258
492
  info = computed(() => {
259
- const m = this.match();
260
- if (!m)
493
+ const idx = this.selectedIndex();
494
+ const type = this.selectedType();
495
+ if (idx === null || type === null)
261
496
  return null;
262
497
  const copy = this.text();
263
- const key = KEYS[m.idx];
498
+ const key = KEYS[idx];
264
499
  return {
265
- fullName: `${m.type === 'major' ? key.major : key.minor} ${m.type === 'major' ? copy.major : copy.minor}`,
266
- relativeKey: m.type === 'major'
500
+ fullName: `${type === 'major' ? key.major : key.minor} ${type === 'major' ? copy.major : copy.minor}`,
501
+ relativeKey: type === 'major'
267
502
  ? `${copy.relativeMinor}: ${key.minor}`
268
503
  : `${copy.relativeMajor}: ${key.major}`,
269
504
  };
270
505
  }, /* @ts-ignore */
271
506
  ...(ngDevMode ? [{ debugName: "info" }] : /* istanbul ignore next */ []));
507
+ chordDiagrams = computed(() => {
508
+ const rows = this.rows();
509
+ if (!rows.length)
510
+ return [];
511
+ return this.chordService
512
+ .search(rows.map((r) => r.chord).join(','), this.language())
513
+ .filter((r) => !r.error && r.positions.length > 0);
514
+ }, /* @ts-ignore */
515
+ ...(ngDevMode ? [{ debugName: "chordDiagrams" }] : /* istanbul ignore next */ []));
272
516
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ChordSectionComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
273
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.2", type: ChordSectionComponent, isStandalone: true, selector: "chord-section", inputs: { chords: { classPropertyName: "chords", publicName: "chords", isSignal: true, isRequired: false, transformFunction: null }, language: { classPropertyName: "language", publicName: "language", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "@if (rows().length) {\n <section class=\"chord-section\">\n @if (info(); as info) {\n <h2>{{ info.fullName }} \u2014 {{ text().diatonicChords }}</h2>\n <p class=\"relative-label\">{{ info.relativeKey }}</p>\n }\n <div class=\"chord-grid\">\n @for (row of rows(); track row.numeral) {\n <div class=\"chord-card\" [ngClass]=\"row.state\">\n <div class=\"chord-numeral\">{{ row.numeral }}</div>\n <div class=\"chord-name\">{{ row.chord }}</div>\n <div class=\"chord-type\">{{ row.chordType }}</div>\n <div class=\"chord-role\">{{ row.role }}</div>\n </div>\n }\n </div>\n </section>\n}\n", styles: [":host{--_chords-background: var(--chords-background, #f0f4f8);--_chords-surface: var(--chords-surface, #ffffff);--_chords-surface-elevated: var(--chords-surface-elevated, #f8fafc);--_chords-text: var(--chords-text, #1e293b);--_chords-muted: var(--chords-muted, #64748b);--_chords-primary: var(--chords-primary, #3b82f6);--_chords-secondary: var(--chords-secondary, #06b6d4);--_chords-highlight: var(--chords-highlight, #f59e0b);--_chords-danger: var(--chords-danger, #a855f7);--_chords-border: var(--chords-border, #e2e8f0);--_chords-on-primary: var(--chords-on-primary, #ffffff);display:block;font-family:Segoe UI,system-ui,sans-serif}.chord-section{margin-top:36px;text-align:center}.chord-section h2{margin:0 0 4px;font-size:1.25rem;font-weight:700;color:var(--_chords-text)}.chord-section .relative-label{margin:0 0 20px;font-size:.88rem;color:var(--_chords-muted)}.chord-grid{display:flex;flex-wrap:nowrap;gap:8px;justify-content:center;overflow-x:auto;padding-bottom:4px}.chord-card{border-radius:12px;padding:12px 14px;min-width:82px;flex-shrink:0;text-align:center;background:color-mix(in srgb,var(--_chords-text) 8%,var(--_chords-surface));border:2px solid transparent;transition:transform .15s ease,box-shadow .15s ease}.chord-card:hover{transform:translateY(-2px);box-shadow:0 4px 16px color-mix(in srgb,var(--_chords-text) 10%,transparent)}.chord-card .chord-numeral{font-size:.85rem;font-weight:700;font-style:italic;color:var(--_chords-muted)}.chord-card .chord-name{font-size:1.4rem;font-weight:800;color:var(--_chords-text);margin:2px 0;letter-spacing:-.02em}.chord-card .chord-type{font-size:.65rem;text-transform:uppercase;letter-spacing:.06em;color:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background))}.chord-card .chord-role{font-size:.7rem;color:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background));margin-top:2px}.chord-card.tonic{background:color-mix(in srgb,var(--_chords-highlight) 10%,var(--_chords-surface));border-color:var(--_chords-highlight)}.chord-card.tonic .chord-numeral,.chord-card.tonic .chord-name{color:color-mix(in srgb,var(--_chords-highlight) 58%,var(--_chords-text))}.chord-card.scale-major{background:color-mix(in srgb,var(--_chords-primary) 9%,var(--_chords-surface));border-color:var(--_chords-primary)}.chord-card.scale-major .chord-numeral,.chord-card.scale-major .chord-name{color:color-mix(in srgb,var(--_chords-primary) 65%,var(--_chords-text))}.chord-card.scale-diminished{background:color-mix(in srgb,var(--_chords-danger) 9%,var(--_chords-surface));border-color:var(--_chords-danger)}.chord-card.scale-diminished .chord-numeral,.chord-card.scale-diminished .chord-name{color:color-mix(in srgb,var(--_chords-danger) 65%,var(--_chords-text))}@media(max-width:480px){.chord-card{min-width:75px;padding:10px 12px}}\n"], dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }] });
517
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.2", type: ChordSectionComponent, isStandalone: true, selector: "chord-section", inputs: { selectedIndex: { classPropertyName: "selectedIndex", publicName: "selectedIndex", isSignal: true, isRequired: false, transformFunction: null }, selectedType: { classPropertyName: "selectedType", publicName: "selectedType", isSignal: true, isRequired: false, transformFunction: null }, language: { classPropertyName: "language", publicName: "language", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "@if (rows().length) {\n <section class=\"chord-section\">\n @if (info(); as info) {\n <h2>{{ info.fullName }} \u2014 {{ text().diatonicChords }}</h2>\n <p class=\"relative-label\">{{ info.relativeKey }}</p>\n }\n <div class=\"chord-grid\">\n @for (row of rows(); track row.numeral) {\n <div class=\"chord-card\" [ngClass]=\"row.state\">\n <div class=\"chord-numeral\">{{ row.numeral }}</div>\n <div class=\"chord-name\">{{ row.chord }}</div>\n <div class=\"chord-type\">{{ row.chordType }}</div>\n <div class=\"chord-role\">{{ row.role }}</div>\n </div>\n }\n </div>\n\n <button type=\"button\" class=\"btn-diagrams-toggle\" (click)=\"toggleDiagrams()\">\n {{ diagramsExpanded() ? text().hideChordDiagrams : text().showChordDiagrams }}\n </button>\n\n @if (diagramsExpanded()) {\n <div class=\"diagram-grid-row\">\n <div class=\"diagram-grid\">\n @for (result of chordDiagrams(); track result.id) {\n @if (result.positions[positionIndexFor(result.id)]; as position) {\n <div class=\"diagram-wrap\">\n <button\n type=\"button\"\n class=\"diagram-nav-btn\"\n [disabled]=\"result.positions.length <= 1\"\n [attr.aria-label]=\"text().previousPosition\"\n (click)=\"stepPosition(result.id, result.positions.length, -1)\"\n >\n \u25B2\n </button>\n <app-chord-diagram\n [title]=\"result.displayName\"\n [position]=\"position\"\n [positionIndex]=\"positionIndexFor(result.id)\"\n />\n <button\n type=\"button\"\n class=\"diagram-nav-btn\"\n [disabled]=\"result.positions.length <= 1\"\n [attr.aria-label]=\"text().nextPosition\"\n (click)=\"stepPosition(result.id, result.positions.length, 1)\"\n >\n \u25BC\n </button>\n </div>\n }\n }\n </div>\n\n <div class=\"diagram-group-nav\">\n <button type=\"button\" [attr.aria-label]=\"text().previousPositionAll\" (click)=\"stepAllPositions(-1)\">\u25B2</button>\n <button type=\"button\" [attr.aria-label]=\"text().nextPositionAll\" (click)=\"stepAllPositions(1)\">\u25BC</button>\n </div>\n </div>\n }\n </section>\n}\n", styles: [":host{--_chords-background: var(--chords-background, #f0f4f8);--_chords-surface: var(--chords-surface, #ffffff);--_chords-surface-elevated: var(--chords-surface-elevated, #f8fafc);--_chords-text: var(--chords-text, #1e293b);--_chords-muted: var(--chords-muted, #64748b);--_chords-primary: var(--chords-primary, #3b82f6);--_chords-secondary: var(--chords-secondary, #06b6d4);--_chords-highlight: var(--chords-highlight, #f59e0b);--_chords-danger: var(--chords-danger, #a855f7);--_chords-border: var(--chords-border, #e2e8f0);--_chords-on-primary: var(--chords-on-primary, #ffffff);display:block;font-family:Segoe UI,system-ui,sans-serif}.chord-section{margin-top:36px;text-align:center}.chord-section h2{margin:0 0 4px;font-size:1.25rem;font-weight:700;color:var(--_chords-text)}.chord-section .relative-label{margin:0 0 20px;font-size:.88rem;color:var(--_chords-muted)}.chord-grid{display:flex;flex-wrap:wrap;gap:8px;justify-content:center}.chord-card{border-radius:12px;padding:12px 14px;min-width:82px;flex-shrink:0;text-align:center;background:color-mix(in srgb,var(--_chords-text) 8%,var(--_chords-surface));border:2px solid transparent;transition:transform .15s ease,box-shadow .15s ease}.chord-card:hover{transform:translateY(-2px);box-shadow:0 4px 16px color-mix(in srgb,var(--_chords-text) 10%,transparent)}.chord-card .chord-numeral{font-size:.85rem;font-weight:700;font-style:italic;color:var(--_chords-muted)}.chord-card .chord-name{font-size:1.4rem;font-weight:800;color:var(--_chords-text);margin:2px 0;letter-spacing:-.02em}.chord-card .chord-type{font-size:.65rem;text-transform:uppercase;letter-spacing:.06em;color:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background))}.chord-card .chord-role{font-size:.7rem;color:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background));margin-top:2px}.chord-card.tonic{background:color-mix(in srgb,var(--_chords-highlight) 10%,var(--_chords-surface));border-color:var(--_chords-highlight)}.chord-card.tonic .chord-numeral,.chord-card.tonic .chord-name{color:color-mix(in srgb,var(--_chords-highlight) 58%,var(--_chords-text))}.chord-card.scale-major{background:color-mix(in srgb,var(--_chords-primary) 9%,var(--_chords-surface));border-color:var(--_chords-primary)}.chord-card.scale-major .chord-numeral,.chord-card.scale-major .chord-name{color:color-mix(in srgb,var(--_chords-primary) 65%,var(--_chords-text))}.chord-card.scale-diminished{background:color-mix(in srgb,var(--_chords-danger) 9%,var(--_chords-surface));border-color:var(--_chords-danger)}.chord-card.scale-diminished .chord-numeral,.chord-card.scale-diminished .chord-name{color:color-mix(in srgb,var(--_chords-danger) 65%,var(--_chords-text))}@media(max-width:480px){.chord-card{min-width:75px;padding:10px 12px}}.btn-diagrams-toggle{display:block;width:100%;margin-top:14px;padding:10px 14px;font-size:.82rem;font-weight:600;border:1px solid var(--_chords-border);border-radius:10px;background:transparent;color:var(--_chords-text);cursor:pointer;transition:border-color .15s,color .15s}.btn-diagrams-toggle:hover{border-color:var(--_chords-primary);color:var(--_chords-primary)}.diagram-grid-row{display:flex;align-items:center;gap:10px;margin-top:16px}.diagram-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(90px,1fr));justify-items:center;gap:12px;flex:1 1 auto;min-width:0}.diagram-wrap{display:flex;flex-direction:column;align-items:center;gap:4px;width:100%;max-width:130px}.diagram-nav-btn,.diagram-group-nav button{padding:2px 10px;font-size:.7rem;line-height:1.4;border:1px solid var(--_chords-border);border-radius:8px;background:transparent;color:var(--_chords-muted);cursor:pointer;transition:border-color .15s,color .15s}.diagram-nav-btn:hover:not(:disabled),.diagram-group-nav button:hover:not(:disabled){border-color:var(--_chords-primary);color:var(--_chords-primary)}.diagram-nav-btn:disabled,.diagram-group-nav button:disabled{opacity:.35;cursor:not-allowed}.diagram-nav-btn{width:100%}.diagram-group-nav{display:flex;flex-direction:column;justify-content:center;gap:8px;flex:0 0 auto}.diagram-group-nav button{padding:10px 8px}\n"], dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "component", type: ChordDiagram, selector: "app-chord-diagram", inputs: ["title", "position", "positionIndex"] }] });
274
518
  }
275
519
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ChordSectionComponent, decorators: [{
276
520
  type: Component,
277
- args: [{ selector: 'chord-section', standalone: true, imports: [NgClass], template: "@if (rows().length) {\n <section class=\"chord-section\">\n @if (info(); as info) {\n <h2>{{ info.fullName }} \u2014 {{ text().diatonicChords }}</h2>\n <p class=\"relative-label\">{{ info.relativeKey }}</p>\n }\n <div class=\"chord-grid\">\n @for (row of rows(); track row.numeral) {\n <div class=\"chord-card\" [ngClass]=\"row.state\">\n <div class=\"chord-numeral\">{{ row.numeral }}</div>\n <div class=\"chord-name\">{{ row.chord }}</div>\n <div class=\"chord-type\">{{ row.chordType }}</div>\n <div class=\"chord-role\">{{ row.role }}</div>\n </div>\n }\n </div>\n </section>\n}\n", styles: [":host{--_chords-background: var(--chords-background, #f0f4f8);--_chords-surface: var(--chords-surface, #ffffff);--_chords-surface-elevated: var(--chords-surface-elevated, #f8fafc);--_chords-text: var(--chords-text, #1e293b);--_chords-muted: var(--chords-muted, #64748b);--_chords-primary: var(--chords-primary, #3b82f6);--_chords-secondary: var(--chords-secondary, #06b6d4);--_chords-highlight: var(--chords-highlight, #f59e0b);--_chords-danger: var(--chords-danger, #a855f7);--_chords-border: var(--chords-border, #e2e8f0);--_chords-on-primary: var(--chords-on-primary, #ffffff);display:block;font-family:Segoe UI,system-ui,sans-serif}.chord-section{margin-top:36px;text-align:center}.chord-section h2{margin:0 0 4px;font-size:1.25rem;font-weight:700;color:var(--_chords-text)}.chord-section .relative-label{margin:0 0 20px;font-size:.88rem;color:var(--_chords-muted)}.chord-grid{display:flex;flex-wrap:nowrap;gap:8px;justify-content:center;overflow-x:auto;padding-bottom:4px}.chord-card{border-radius:12px;padding:12px 14px;min-width:82px;flex-shrink:0;text-align:center;background:color-mix(in srgb,var(--_chords-text) 8%,var(--_chords-surface));border:2px solid transparent;transition:transform .15s ease,box-shadow .15s ease}.chord-card:hover{transform:translateY(-2px);box-shadow:0 4px 16px color-mix(in srgb,var(--_chords-text) 10%,transparent)}.chord-card .chord-numeral{font-size:.85rem;font-weight:700;font-style:italic;color:var(--_chords-muted)}.chord-card .chord-name{font-size:1.4rem;font-weight:800;color:var(--_chords-text);margin:2px 0;letter-spacing:-.02em}.chord-card .chord-type{font-size:.65rem;text-transform:uppercase;letter-spacing:.06em;color:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background))}.chord-card .chord-role{font-size:.7rem;color:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background));margin-top:2px}.chord-card.tonic{background:color-mix(in srgb,var(--_chords-highlight) 10%,var(--_chords-surface));border-color:var(--_chords-highlight)}.chord-card.tonic .chord-numeral,.chord-card.tonic .chord-name{color:color-mix(in srgb,var(--_chords-highlight) 58%,var(--_chords-text))}.chord-card.scale-major{background:color-mix(in srgb,var(--_chords-primary) 9%,var(--_chords-surface));border-color:var(--_chords-primary)}.chord-card.scale-major .chord-numeral,.chord-card.scale-major .chord-name{color:color-mix(in srgb,var(--_chords-primary) 65%,var(--_chords-text))}.chord-card.scale-diminished{background:color-mix(in srgb,var(--_chords-danger) 9%,var(--_chords-surface));border-color:var(--_chords-danger)}.chord-card.scale-diminished .chord-numeral,.chord-card.scale-diminished .chord-name{color:color-mix(in srgb,var(--_chords-danger) 65%,var(--_chords-text))}@media(max-width:480px){.chord-card{min-width:75px;padding:10px 12px}}\n"] }]
278
- }], propDecorators: { chords: [{ type: i0.Input, args: [{ isSignal: true, alias: "chords", required: false }] }], language: [{ type: i0.Input, args: [{ isSignal: true, alias: "language", required: false }] }] } });
521
+ args: [{ selector: 'chord-section', standalone: true, imports: [NgClass, ChordDiagram], template: "@if (rows().length) {\n <section class=\"chord-section\">\n @if (info(); as info) {\n <h2>{{ info.fullName }} \u2014 {{ text().diatonicChords }}</h2>\n <p class=\"relative-label\">{{ info.relativeKey }}</p>\n }\n <div class=\"chord-grid\">\n @for (row of rows(); track row.numeral) {\n <div class=\"chord-card\" [ngClass]=\"row.state\">\n <div class=\"chord-numeral\">{{ row.numeral }}</div>\n <div class=\"chord-name\">{{ row.chord }}</div>\n <div class=\"chord-type\">{{ row.chordType }}</div>\n <div class=\"chord-role\">{{ row.role }}</div>\n </div>\n }\n </div>\n\n <button type=\"button\" class=\"btn-diagrams-toggle\" (click)=\"toggleDiagrams()\">\n {{ diagramsExpanded() ? text().hideChordDiagrams : text().showChordDiagrams }}\n </button>\n\n @if (diagramsExpanded()) {\n <div class=\"diagram-grid-row\">\n <div class=\"diagram-grid\">\n @for (result of chordDiagrams(); track result.id) {\n @if (result.positions[positionIndexFor(result.id)]; as position) {\n <div class=\"diagram-wrap\">\n <button\n type=\"button\"\n class=\"diagram-nav-btn\"\n [disabled]=\"result.positions.length <= 1\"\n [attr.aria-label]=\"text().previousPosition\"\n (click)=\"stepPosition(result.id, result.positions.length, -1)\"\n >\n \u25B2\n </button>\n <app-chord-diagram\n [title]=\"result.displayName\"\n [position]=\"position\"\n [positionIndex]=\"positionIndexFor(result.id)\"\n />\n <button\n type=\"button\"\n class=\"diagram-nav-btn\"\n [disabled]=\"result.positions.length <= 1\"\n [attr.aria-label]=\"text().nextPosition\"\n (click)=\"stepPosition(result.id, result.positions.length, 1)\"\n >\n \u25BC\n </button>\n </div>\n }\n }\n </div>\n\n <div class=\"diagram-group-nav\">\n <button type=\"button\" [attr.aria-label]=\"text().previousPositionAll\" (click)=\"stepAllPositions(-1)\">\u25B2</button>\n <button type=\"button\" [attr.aria-label]=\"text().nextPositionAll\" (click)=\"stepAllPositions(1)\">\u25BC</button>\n </div>\n </div>\n }\n </section>\n}\n", styles: [":host{--_chords-background: var(--chords-background, #f0f4f8);--_chords-surface: var(--chords-surface, #ffffff);--_chords-surface-elevated: var(--chords-surface-elevated, #f8fafc);--_chords-text: var(--chords-text, #1e293b);--_chords-muted: var(--chords-muted, #64748b);--_chords-primary: var(--chords-primary, #3b82f6);--_chords-secondary: var(--chords-secondary, #06b6d4);--_chords-highlight: var(--chords-highlight, #f59e0b);--_chords-danger: var(--chords-danger, #a855f7);--_chords-border: var(--chords-border, #e2e8f0);--_chords-on-primary: var(--chords-on-primary, #ffffff);display:block;font-family:Segoe UI,system-ui,sans-serif}.chord-section{margin-top:36px;text-align:center}.chord-section h2{margin:0 0 4px;font-size:1.25rem;font-weight:700;color:var(--_chords-text)}.chord-section .relative-label{margin:0 0 20px;font-size:.88rem;color:var(--_chords-muted)}.chord-grid{display:flex;flex-wrap:wrap;gap:8px;justify-content:center}.chord-card{border-radius:12px;padding:12px 14px;min-width:82px;flex-shrink:0;text-align:center;background:color-mix(in srgb,var(--_chords-text) 8%,var(--_chords-surface));border:2px solid transparent;transition:transform .15s ease,box-shadow .15s ease}.chord-card:hover{transform:translateY(-2px);box-shadow:0 4px 16px color-mix(in srgb,var(--_chords-text) 10%,transparent)}.chord-card .chord-numeral{font-size:.85rem;font-weight:700;font-style:italic;color:var(--_chords-muted)}.chord-card .chord-name{font-size:1.4rem;font-weight:800;color:var(--_chords-text);margin:2px 0;letter-spacing:-.02em}.chord-card .chord-type{font-size:.65rem;text-transform:uppercase;letter-spacing:.06em;color:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background))}.chord-card .chord-role{font-size:.7rem;color:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background));margin-top:2px}.chord-card.tonic{background:color-mix(in srgb,var(--_chords-highlight) 10%,var(--_chords-surface));border-color:var(--_chords-highlight)}.chord-card.tonic .chord-numeral,.chord-card.tonic .chord-name{color:color-mix(in srgb,var(--_chords-highlight) 58%,var(--_chords-text))}.chord-card.scale-major{background:color-mix(in srgb,var(--_chords-primary) 9%,var(--_chords-surface));border-color:var(--_chords-primary)}.chord-card.scale-major .chord-numeral,.chord-card.scale-major .chord-name{color:color-mix(in srgb,var(--_chords-primary) 65%,var(--_chords-text))}.chord-card.scale-diminished{background:color-mix(in srgb,var(--_chords-danger) 9%,var(--_chords-surface));border-color:var(--_chords-danger)}.chord-card.scale-diminished .chord-numeral,.chord-card.scale-diminished .chord-name{color:color-mix(in srgb,var(--_chords-danger) 65%,var(--_chords-text))}@media(max-width:480px){.chord-card{min-width:75px;padding:10px 12px}}.btn-diagrams-toggle{display:block;width:100%;margin-top:14px;padding:10px 14px;font-size:.82rem;font-weight:600;border:1px solid var(--_chords-border);border-radius:10px;background:transparent;color:var(--_chords-text);cursor:pointer;transition:border-color .15s,color .15s}.btn-diagrams-toggle:hover{border-color:var(--_chords-primary);color:var(--_chords-primary)}.diagram-grid-row{display:flex;align-items:center;gap:10px;margin-top:16px}.diagram-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(90px,1fr));justify-items:center;gap:12px;flex:1 1 auto;min-width:0}.diagram-wrap{display:flex;flex-direction:column;align-items:center;gap:4px;width:100%;max-width:130px}.diagram-nav-btn,.diagram-group-nav button{padding:2px 10px;font-size:.7rem;line-height:1.4;border:1px solid var(--_chords-border);border-radius:8px;background:transparent;color:var(--_chords-muted);cursor:pointer;transition:border-color .15s,color .15s}.diagram-nav-btn:hover:not(:disabled),.diagram-group-nav button:hover:not(:disabled){border-color:var(--_chords-primary);color:var(--_chords-primary)}.diagram-nav-btn:disabled,.diagram-group-nav button:disabled{opacity:.35;cursor:not-allowed}.diagram-nav-btn{width:100%}.diagram-group-nav{display:flex;flex-direction:column;justify-content:center;gap:8px;flex:0 0 auto}.diagram-group-nav button{padding:10px 8px}\n"] }]
522
+ }], ctorParameters: () => [], propDecorators: { selectedIndex: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectedIndex", required: false }] }], selectedType: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectedType", required: false }] }], language: [{ type: i0.Input, args: [{ isSignal: true, alias: "language", required: false }] }] } });
279
523
  class CircleOfFifthsComponent {
280
524
  language = input('en', /* @ts-ignore */
281
525
  ...(ngDevMode ? [{ debugName: "language" }] : /* istanbul ignore next */ []));
282
526
  text = computed(() => COPY[this.language()], /* @ts-ignore */
283
527
  ...(ngDevMode ? [{ debugName: "text" }] : /* istanbul ignore next */ []));
284
- CX = 300;
285
- CY = 300;
286
- KEY_SIG = { inner: 250, outer: 288 };
287
- MAJOR = { inner: 172, outer: 250 };
288
- MINOR = { inner: 102, outer: 172 };
289
- keys = KEYS;
290
528
  selectedIndex = signal(null, /* @ts-ignore */
291
529
  ...(ngDevMode ? [{ debugName: "selectedIndex" }] : /* istanbul ignore next */ []));
292
530
  selectedType = signal(null, /* @ts-ignore */
@@ -295,15 +533,25 @@ class CircleOfFifthsComponent {
295
533
  ...(ngDevMode ? [{ debugName: "activeProgressionDefs" }] : /* istanbul ignore next */ []));
296
534
  expandedCards = signal(new Set(), /* @ts-ignore */
297
535
  ...(ngDevMode ? [{ debugName: "expandedCards" }] : /* istanbul ignore next */ []));
298
- get slice() {
299
- const idx = this.selectedIndex();
300
- if (idx === null)
301
- return null;
302
- return {
303
- prev: (idx - 1 + 12) % 12,
304
- curr: idx,
305
- next: (idx + 1) % 12,
306
- };
536
+ transposeMap = signal(new Map(), /* @ts-ignore */
537
+ ...(ngDevMode ? [{ debugName: "transposeMap" }] : /* istanbul ignore next */ []));
538
+ spiceMap = signal(new Map(), /* @ts-ignore */
539
+ ...(ngDevMode ? [{ debugName: "spiceMap" }] : /* istanbul ignore next */ []));
540
+ constructor() {
541
+ // Restore selected key from URL hash (format: #{index}-{type})
542
+ const [idxStr, type] = location.hash.slice(1).split('-');
543
+ const idx = Number(idxStr);
544
+ if (!isNaN(idx) && idx >= 0 && idx < 12 && (type === 'major' || type === 'minor')) {
545
+ this.selectedIndex.set(idx);
546
+ this.selectedType.set(type);
547
+ }
548
+ // Keep URL hash in sync with selected key
549
+ effect(() => {
550
+ const idx = this.selectedIndex();
551
+ const type = this.selectedType();
552
+ const hash = idx !== null && type !== null ? `#${idx}-${type}` : '';
553
+ history.replaceState(null, '', location.pathname + location.search + hash);
554
+ });
307
555
  }
308
556
  selectKey(index, type) {
309
557
  if (this.selectedIndex() === index && this.selectedType() === type) {
@@ -316,6 +564,8 @@ class CircleOfFifthsComponent {
316
564
  }
317
565
  this.activeProgressionDefs.set(null);
318
566
  this.expandedCards.set(new Set());
567
+ this.transposeMap.set(new Map());
568
+ this.spiceMap.set(new Map());
319
569
  }
320
570
  randomizeProgressions() {
321
571
  const type = this.selectedType();
@@ -324,6 +574,8 @@ class CircleOfFifthsComponent {
324
574
  // ponytail: sort-shuffle is fine for UI randomness
325
575
  this.activeProgressionDefs.set([...PROGRESSIONS[type]].sort(() => Math.random() - 0.5).slice(0, 4));
326
576
  this.expandedCards.set(new Set());
577
+ this.transposeMap.set(new Map());
578
+ this.spiceMap.set(new Map());
327
579
  }
328
580
  toggleCard(name) {
329
581
  this.expandedCards.update((set) => {
@@ -338,100 +590,26 @@ class CircleOfFifthsComponent {
338
590
  : prog.chords.join(', ');
339
591
  navigator.clipboard.writeText(text);
340
592
  }
341
- getMajorState(index) {
342
- const s = this.slice;
343
- if (!s)
344
- return '';
345
- const type = this.selectedType();
346
- if (index === s.curr)
347
- return type === 'major' ? 'tonic' : 'scale-major';
348
- if (index === s.prev || index === s.next)
349
- return 'scale-major';
350
- return '';
351
- }
352
- getMinorState(index) {
353
- const s = this.slice;
354
- if (!s)
355
- return '';
356
- const type = this.selectedType();
357
- const dimIdx = (s.curr + 2) % 12;
358
- if (index === s.curr)
359
- return type === 'minor' ? 'tonic' : 'scale-minor';
360
- if (index === s.prev || index === s.next)
361
- return 'scale-minor';
362
- if (index === dimIdx)
363
- return 'scale-diminished';
364
- return '';
593
+ setTranspose(nameEn, targetIdx) {
594
+ this.transposeMap.update((m) => {
595
+ const next = new Map(m);
596
+ targetIdx === this.selectedIndex() ? next.delete(nameEn) : next.set(nameEn, targetIdx);
597
+ return next;
598
+ });
365
599
  }
366
- getMajorRole(index) {
367
- const s = this.slice;
368
- if (!s)
369
- return '';
370
- const type = this.selectedType();
371
- if (type === 'major') {
372
- if (index === s.curr)
373
- return 'I';
374
- if (index === s.prev)
375
- return 'IV';
376
- if (index === s.next)
377
- return 'V';
378
- }
379
- else {
380
- if (index === s.curr)
381
- return 'III';
382
- if (index === s.prev)
383
- return 'VI';
384
- if (index === s.next)
385
- return 'VII';
386
- }
387
- return '';
600
+ cycleSpice(nameEn) {
601
+ this.spiceMap.update((m) => {
602
+ const next = new Map(m);
603
+ const nextLevel = ((next.get(nameEn) ?? 0) + 1) % 4;
604
+ nextLevel === 0 ? next.delete(nameEn) : next.set(nameEn, nextLevel);
605
+ return next;
606
+ });
388
607
  }
389
- getMinorRole(index) {
390
- const s = this.slice;
391
- if (!s)
392
- return '';
608
+ get transposeKeys() {
393
609
  const type = this.selectedType();
394
- const dimIdx = (s.curr + 2) % 12;
395
- if (type === 'major') {
396
- if (index === s.curr)
397
- return 'vi';
398
- if (index === s.prev)
399
- return 'ii';
400
- if (index === s.next)
401
- return 'iii';
402
- if (index === dimIdx)
403
- return 'vii°';
404
- }
405
- else {
406
- if (index === s.curr)
407
- return 'i';
408
- if (index === s.prev)
409
- return 'iv';
410
- if (index === s.next)
411
- return 'v';
412
- if (index === dimIdx)
413
- return 'ii°';
414
- }
415
- return '';
416
- }
417
- arcPath(innerR, outerR, index, gap = 1.5) {
418
- const mid = index * 30 - 90;
419
- const s = (mid - 15 + gap) * (Math.PI / 180);
420
- const e = (mid + 15 - gap) * (Math.PI / 180);
421
- const cx = this.CX, cy = this.CY;
422
- const x1 = cx + outerR * Math.cos(s), y1 = cy + outerR * Math.sin(s);
423
- const x2 = cx + outerR * Math.cos(e), y2 = cy + outerR * Math.sin(e);
424
- const x3 = cx + innerR * Math.cos(e), y3 = cy + innerR * Math.sin(e);
425
- const x4 = cx + innerR * Math.cos(s), y4 = cy + innerR * Math.sin(s);
426
- const f = (n) => n.toFixed(2);
427
- return `M${f(x1)} ${f(y1)} A${outerR} ${outerR} 0 0 1 ${f(x2)} ${f(y2)} L${f(x3)} ${f(y3)} A${innerR} ${innerR} 0 0 0 ${f(x4)} ${f(y4)}Z`;
428
- }
429
- textPos(midR, index) {
430
- const a = (index * 30 - 90) * (Math.PI / 180);
431
- return {
432
- x: this.CX + midR * Math.cos(a),
433
- y: this.CY + midR * Math.sin(a),
434
- };
610
+ if (!type)
611
+ return [];
612
+ return KEYS.map((k) => ({ idx: k.index, name: type === 'major' ? k.major : k.minor }));
435
613
  }
436
614
  get chordTable() {
437
615
  const idx = this.selectedIndex();
@@ -440,16 +618,6 @@ class CircleOfFifthsComponent {
440
618
  return [];
441
619
  return buildChordRows(idx, type, this.text());
442
620
  }
443
- get chordNames() {
444
- return this.chordTable.map((r) => r.chord);
445
- }
446
- getAccidentalText(key) {
447
- if (key.sharps > 0)
448
- return `${key.sharps}♯`;
449
- if (key.flats > 0)
450
- return `${key.flats}♭`;
451
- return '—';
452
- }
453
621
  get selectedInfo() {
454
622
  const idx = this.selectedIndex();
455
623
  const type = this.selectedType();
@@ -457,15 +625,7 @@ class CircleOfFifthsComponent {
457
625
  return null;
458
626
  const key = KEYS[idx];
459
627
  const copy = this.text();
460
- const scale = type === 'major' ? copy.major : copy.minor;
461
- return {
462
- name: type === 'major' ? key.major : key.minor,
463
- scale,
464
- fullName: `${type === 'major' ? key.major : key.minor} ${scale}`,
465
- relativeKey: type === 'major'
466
- ? `${copy.relativeMinor}: ${key.minor}`
467
- : `${copy.relativeMajor}: ${key.major}`,
468
- };
628
+ return { fullName: `${type === 'major' ? key.major : key.minor} ${type === 'major' ? copy.major : copy.minor}` };
469
629
  }
470
630
  deriveSections(numerals, lookup) {
471
631
  const type = this.selectedType();
@@ -495,26 +655,37 @@ class CircleOfFifthsComponent {
495
655
  const table = this.chordTable;
496
656
  if (!table.length)
497
657
  return [];
498
- const lookup = new Map(table.map((r) => [r.numeral, r.chord]));
658
+ const selIdx = this.selectedIndex();
659
+ const transposeMap = this.transposeMap();
660
+ const spiceMap = this.spiceMap();
499
661
  const language = this.language();
500
662
  const type = this.selectedType() === 'major' ? 'major' : 'minor';
501
663
  const defs = this.activeProgressionDefs() ?? PROGRESSIONS[type].slice(0, 4);
502
- return defs.map((definition) => ({
503
- name: definition.name[language],
504
- mood: definition.mood[language],
505
- genre: definition.genre[language],
506
- numerals: definition.numerals,
507
- chords: definition.numerals.map((numeral) => lookup.get(numeral) ?? numeral),
508
- sections: this.deriveSections(definition.numerals, lookup),
509
- }));
664
+ return defs.map((definition) => {
665
+ const targetIdx = transposeMap.get(definition.name.en) ?? selIdx;
666
+ const spiceLevel = spiceMap.get(definition.name.en) ?? 0;
667
+ const baseRows = buildChordRows(targetIdx, type, this.text());
668
+ const lookup = new Map(baseRows.map((r) => [r.numeral, spiceChord(r.chord, r.numeral, spiceLevel)]));
669
+ return {
670
+ nameEn: definition.name.en,
671
+ name: definition.name[language],
672
+ mood: definition.mood[language],
673
+ genre: definition.genre[language],
674
+ numerals: definition.numerals,
675
+ chords: definition.numerals.map((n) => lookup.get(n) ?? n),
676
+ sections: this.deriveSections(definition.numerals, lookup),
677
+ transposeIdx: targetIdx,
678
+ spiceLevel,
679
+ };
680
+ });
510
681
  }
511
682
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: CircleOfFifthsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
512
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.2", type: CircleOfFifthsComponent, isStandalone: true, selector: "the-chords-circle-of-fifths", inputs: { language: { classPropertyName: "language", publicName: "language", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "attr.lang": "language()" } }, ngImport: i0, template: "<div class=\"page\">\n <header class=\"page-header\">\n <h1>{{ text().title }}</h1>\n <p>{{ text().subtitle }}</p>\n </header>\n\n <div class=\"circle-wrapper\">\n <svg\n viewBox=\"0 0 600 600\"\n xmlns=\"http://www.w3.org/2000/svg\"\n [attr.aria-label]=\"text().ariaLabel\"\n >\n <!-- \u2500\u2500 Key Signature Ring \u2500\u2500 -->\n @for (key of keys; track key.index) {\n <path [attr.d]=\"arcPath(KEY_SIG.inner, KEY_SIG.outer, key.index)\" class=\"keysig-segment\" />\n <text\n [attr.x]=\"textPos((KEY_SIG.inner + KEY_SIG.outer) / 2, key.index).x\"\n [attr.y]=\"textPos((KEY_SIG.inner + KEY_SIG.outer) / 2, key.index).y\"\n class=\"keysig-label\"\n >\n {{ getAccidentalText(key) }}\n </text>\n }\n\n <!-- \u2500\u2500 Major Keys Ring \u2500\u2500 -->\n @for (key of keys; track key.index) {\n <g\n [ngClass]=\"['major-group', getMajorState(key.index)]\"\n (click)=\"selectKey(key.index, 'major')\"\n >\n <path [attr.d]=\"arcPath(MAJOR.inner, MAJOR.outer, key.index)\" />\n\n <text\n [attr.x]=\"textPos((MAJOR.inner + MAJOR.outer) / 2, key.index).x\"\n [attr.y]=\"\n textPos((MAJOR.inner + MAJOR.outer) / 2, key.index).y +\n (getMajorRole(key.index) ? -9 : 0)\n \"\n class=\"major-label\"\n >\n {{ key.major }}\n </text>\n\n @if (getMajorRole(key.index)) {\n <text\n [attr.x]=\"textPos((MAJOR.inner + MAJOR.outer) / 2, key.index).x\"\n [attr.y]=\"textPos((MAJOR.inner + MAJOR.outer) / 2, key.index).y + 11\"\n class=\"role-label\"\n >\n {{ getMajorRole(key.index) }}\n </text>\n }\n </g>\n }\n\n <!-- \u2500\u2500 Minor Keys Ring \u2500\u2500 -->\n @for (key of keys; track key.index) {\n <g\n [ngClass]=\"['minor-group', getMinorState(key.index)]\"\n (click)=\"selectKey(key.index, 'minor')\"\n >\n <path [attr.d]=\"arcPath(MINOR.inner, MINOR.outer, key.index)\" />\n\n <text\n [attr.x]=\"textPos((MINOR.inner + MINOR.outer) / 2, key.index).x\"\n [attr.y]=\"\n textPos((MINOR.inner + MINOR.outer) / 2, key.index).y +\n (getMinorRole(key.index) ? -8 : 0)\n \"\n class=\"minor-label\"\n >\n {{ key.minor }}\n </text>\n\n @if (getMinorRole(key.index)) {\n <text\n [attr.x]=\"textPos((MINOR.inner + MINOR.outer) / 2, key.index).x\"\n [attr.y]=\"textPos((MINOR.inner + MINOR.outer) / 2, key.index).y + 9\"\n class=\"role-label\"\n >\n {{ getMinorRole(key.index) }}\n </text>\n }\n </g>\n }\n\n <!-- \u2500\u2500 Outer border \u2500\u2500 -->\n <circle cx=\"300\" cy=\"300\" r=\"289\" class=\"outer-border\" />\n\n <!-- \u2500\u2500 Center Circle \u2500\u2500 -->\n <circle cx=\"300\" cy=\"300\" r=\"99\" class=\"center-circle\" />\n\n @if (selectedInfo) {\n <text x=\"300\" y=\"283\" class=\"center-name\">{{ selectedInfo.name }}</text>\n <text x=\"300\" y=\"304\" class=\"center-scale\">{{ selectedInfo.scale }}</text>\n <text x=\"300\" y=\"322\" class=\"center-relative\">{{ selectedInfo.relativeKey }}</text>\n <text x=\"300\" y=\"338\" class=\"center-deselect\">{{ text().deselect }}</text>\n } @else {\n <text x=\"300\" y=\"293\" class=\"center-hint\">{{ text().clickKey }}</text>\n <text x=\"300\" y=\"312\" class=\"center-hint-sm\">{{ text().seeChords }}</text>\n }\n </svg>\n </div>\n\n <!-- \u2500\u2500 Legend \u2500\u2500 -->\n <div class=\"legend\">\n <span class=\"legend-item tonic\">{{ text().tonicLegend }}</span>\n <span class=\"legend-item scale-major\">{{ text().majorLegend }}</span>\n <span class=\"legend-item scale-minor\">{{ text().minorLegend }}</span>\n <span class=\"legend-item scale-diminished\">{{ text().diminishedLegend }}</span>\n </div>\n\n <chord-section [chords]=\"chordNames\" [language]=\"language()\"></chord-section>\n\n <!-- \u2500\u2500 Chord Progressions \u2500\u2500 -->\n @if (progressions.length) {\n <section class=\"progressions-section\">\n <div class=\"progressions-header\">\n <h2>{{ text().commonProgressions }} {{ selectedInfo?.fullName }}</h2>\n <button class=\"btn-randomize\" (click)=\"randomizeProgressions()\">{{ text().generateProgressions }}</button>\n </div>\n <div class=\"progression-list\">\n @for (prog of progressions; track prog.name) {\n <div class=\"progression-card\">\n <div class=\"prog-header\">\n <span class=\"prog-mood\">{{ prog.mood }}</span>\n <span class=\"prog-name\">{{ prog.name }}</span>\n <span class=\"prog-genre\">{{ prog.genre }}</span>\n </div>\n <div class=\"prog-chords\">\n @for (chord of prog.chords; track $index; let last = $last) {\n <span class=\"prog-chord\">{{ chord }}</span>\n @if (!last) {\n <span class=\"prog-arrow\">\u2192</span>\n }\n }\n </div>\n <div class=\"prog-numerals\">\n @for (num of prog.numerals; track $index; let last = $last) {\n <span>{{ num }}</span>\n @if (!last) {\n <span class=\"prog-arrow\">\u2192</span>\n }\n }\n </div>\n <div class=\"prog-expand-row\">\n <button class=\"btn-expand\" (click)=\"toggleCard(prog.name)\">\n {{ expandedCards().has(prog.name) ? text().collapse : text().expand }}\n </button>\n <button class=\"btn-expand\" (click)=\"copyChords(prog)\">\n {{ text().copy }}\n </button>\n </div>\n @if (expandedCards().has(prog.name)) {\n <div class=\"prog-sections\">\n @for (section of prog.sections; track section.label) {\n <div class=\"prog-section\">\n <div class=\"prog-section-label\">{{ section.label }}</div>\n <div class=\"prog-chords\">\n @for (chord of section.chords; track $index; let last = $last) {\n <span class=\"prog-chord\">{{ chord }}</span>\n @if (!last) { <span class=\"prog-arrow\">\u2192</span> }\n }\n </div>\n <div class=\"prog-numerals\">\n @for (num of section.numerals; track $index; let last = $last) {\n <span>{{ num }}</span>\n @if (!last) { <span class=\"prog-arrow\">\u2192</span> }\n }\n </div>\n </div>\n }\n </div>\n }\n </div>\n }\n </div>\n </section>\n }\n</div>\n", styles: [":host{--_chords-background: var(--chords-background, #f0f4f8);--_chords-background-alt: var(--chords-background-alt, #f8fafc);--_chords-surface: var(--chords-surface, #ffffff);--_chords-surface-elevated: var(--chords-surface-elevated, #f8fafc);--_chords-text: var(--chords-text, #1e293b);--_chords-muted: var(--chords-muted, #64748b);--_chords-primary: var(--chords-primary, #3b82f6);--_chords-secondary: var(--chords-secondary, #06b6d4);--_chords-highlight: var(--chords-highlight, #f59e0b);--_chords-danger: var(--chords-danger, #a855f7);--_chords-border: var(--chords-border, #e2e8f0);--_chords-on-primary: var(--chords-on-primary, #ffffff);box-sizing:border-box;display:block;min-height:100%;background:var(--_chords-background);color:var(--_chords-text)}:host *,:host *:before,:host *:after{box-sizing:inherit}.page{max-width:720px;margin:0 auto;padding:24px 16px 48px;font-family:Segoe UI,system-ui,sans-serif}.page-header{text-align:center;margin-bottom:28px}.page-header h1{margin:0 0 6px;font-size:2rem;font-weight:800;color:var(--_chords-text);letter-spacing:-.02em}.page-header p{margin:0;color:var(--_chords-muted);font-size:.95rem}.circle-wrapper{max-width:580px;margin:0 auto}.circle-wrapper svg{display:block;width:100%;height:auto;overflow:visible}.keysig-segment{fill:var(--_chords-text)}.keysig-label{fill:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background));font-size:12px;font-weight:500;text-anchor:middle;dominant-baseline:central;pointer-events:none}.major-group{cursor:pointer}.major-group path{fill:color-mix(in srgb,var(--_chords-primary) 14%,var(--_chords-surface));stroke:var(--_chords-surface);stroke-width:2;transition:fill .18s ease}.major-group:hover path{fill:color-mix(in srgb,var(--_chords-primary) 24%,var(--_chords-surface))}.major-group .major-label{fill:var(--_chords-text);font-size:15px;font-weight:700;text-anchor:middle;dominant-baseline:central;pointer-events:none;transition:fill .18s ease}.major-group.tonic path{fill:var(--_chords-highlight)}.major-group.tonic:hover path{fill:color-mix(in srgb,var(--_chords-highlight) 84%,var(--_chords-text))}.major-group.tonic .major-label{fill:var(--_chords-on-primary)}.major-group.scale-major path{fill:var(--_chords-primary)}.major-group.scale-major:hover path{fill:color-mix(in srgb,var(--_chords-primary) 82%,var(--_chords-text))}.major-group.scale-major .major-label{fill:var(--_chords-on-primary)}.minor-group{cursor:pointer}.minor-group path{fill:color-mix(in srgb,var(--_chords-secondary) 10%,var(--_chords-surface));stroke:var(--_chords-surface);stroke-width:2;transition:fill .18s ease}.minor-group:hover path{fill:color-mix(in srgb,var(--_chords-secondary) 20%,var(--_chords-surface))}.minor-group .minor-label{fill:color-mix(in srgb,var(--_chords-text) 78%,var(--_chords-muted));font-size:12px;font-weight:600;text-anchor:middle;dominant-baseline:central;pointer-events:none;transition:fill .18s ease}.minor-group.tonic path{fill:var(--_chords-highlight)}.minor-group.tonic:hover path{fill:color-mix(in srgb,var(--_chords-highlight) 84%,var(--_chords-text))}.minor-group.tonic .minor-label{fill:var(--_chords-on-primary)}.minor-group.scale-minor path{fill:var(--_chords-secondary)}.minor-group.scale-minor:hover path{fill:color-mix(in srgb,var(--_chords-secondary) 82%,var(--_chords-text))}.minor-group.scale-minor .minor-label{fill:var(--_chords-on-primary)}.minor-group.scale-diminished path{fill:var(--_chords-danger)}.minor-group.scale-diminished:hover path{fill:color-mix(in srgb,var(--_chords-danger) 82%,var(--_chords-text))}.minor-group.scale-diminished .minor-label{fill:var(--_chords-on-primary)}.role-label{font-size:10px;font-style:italic;font-weight:600;text-anchor:middle;dominant-baseline:central;pointer-events:none;fill:color-mix(in srgb,var(--_chords-on-primary) 85%,transparent)}.outer-border{fill:none;stroke:var(--_chords-text);stroke-width:2.5}.center-circle{fill:var(--_chords-surface);stroke:var(--_chords-border);stroke-width:2;filter:drop-shadow(0 2px 10px color-mix(in srgb,var(--_chords-text) 8%,transparent))}.center-name{font-size:30px;font-weight:800;text-anchor:middle;dominant-baseline:auto;fill:var(--_chords-text);letter-spacing:-.02em}.center-scale{font-size:13px;font-weight:600;text-anchor:middle;dominant-baseline:auto;fill:var(--_chords-muted);text-transform:uppercase;letter-spacing:.08em}.center-relative{font-size:9.5px;font-weight:500;text-anchor:middle;dominant-baseline:auto;fill:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background))}.center-deselect{font-size:8px;text-anchor:middle;dominant-baseline:auto;fill:color-mix(in srgb,var(--_chords-muted) 42%,var(--_chords-background))}.center-hint{font-size:16px;font-weight:600;text-anchor:middle;dominant-baseline:auto;fill:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background))}.center-hint-sm{font-size:11px;text-anchor:middle;dominant-baseline:auto;fill:color-mix(in srgb,var(--_chords-muted) 42%,var(--_chords-background))}.legend{display:flex;justify-content:center;gap:16px;flex-wrap:wrap;margin:16px 0 0}.legend .legend-item{display:flex;align-items:center;gap:6px;font-size:.8rem;color:var(--_chords-muted)}.legend .legend-item:before{content:\"\";display:inline-block;width:14px;height:14px;border-radius:3px}.legend .legend-item.tonic:before{background:var(--_chords-highlight)}.legend .legend-item.scale-major:before{background:var(--_chords-primary)}.legend .legend-item.scale-minor:before{background:var(--_chords-secondary)}.legend .legend-item.scale-diminished:before{background:var(--_chords-danger)}.progressions-section{margin-top:36px;text-align:center}.progressions-header{display:flex;align-items:center;justify-content:center;gap:12px;margin-bottom:20px;flex-wrap:wrap}.progressions-header h2{margin:0;font-size:1.25rem;font-weight:700;color:var(--_chords-text)}.btn-randomize{padding:6px 14px;font-size:.8rem;font-weight:600;border:1.5px solid var(--_chords-primary);border-radius:20px;background:transparent;color:var(--_chords-primary);cursor:pointer;transition:background .15s,color .15s;white-space:nowrap}.btn-randomize:hover{background:var(--_chords-primary);color:var(--_chords-on-primary)}.progression-list{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}@media(max-width:480px){.progression-list{grid-template-columns:1fr}}.progression-card{background:var(--_chords-surface);border:1.5px solid var(--_chords-border);border-radius:14px;padding:16px 18px;text-align:left;transition:box-shadow .15s ease,transform .15s ease}.progression-card:hover{box-shadow:0 4px 16px color-mix(in srgb,var(--_chords-text) 8%,transparent);transform:translateY(-2px)}.prog-header{display:flex;align-items:baseline;gap:8px;margin-bottom:12px;flex-wrap:wrap}.prog-mood{font-size:.95rem;font-weight:700;color:var(--_chords-text)}.prog-name{font-size:.75rem;font-weight:600;color:var(--_chords-muted);text-transform:uppercase;letter-spacing:.06em}.prog-genre{margin-left:auto;font-size:.7rem;color:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background));font-style:italic}.prog-chords{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:6px}.prog-chord{font-size:1.15rem;font-weight:800;color:var(--_chords-text);letter-spacing:-.02em}.prog-numerals{display:flex;align-items:center;gap:6px;font-size:.75rem;font-style:italic;color:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background))}.prog-expand-row{display:flex;justify-content:flex-end;margin-top:10px}.btn-expand{padding:3px 10px;font-size:.72rem;font-weight:600;border:1px solid var(--_chords-border);border-radius:12px;background:transparent;color:var(--_chords-muted);cursor:pointer;transition:border-color .15s,color .15s}.btn-expand:hover{border-color:var(--_chords-primary);color:var(--_chords-primary)}.prog-sections{margin-top:12px;border-top:1px solid var(--_chords-border);padding-top:10px;display:flex;flex-direction:column;gap:10px}.prog-section{padding:8px 10px;border-radius:8px;background:color-mix(in srgb,var(--_chords-text) 4%,var(--_chords-surface))}.prog-section-label{font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--_chords-muted);margin-bottom:5px}.prog-arrow{color:color-mix(in srgb,var(--_chords-muted) 42%,var(--_chords-background));font-size:.8rem}@media(max-width:480px){.page-header h1{font-size:1.5rem}}\n"], dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "component", type: ChordSectionComponent, selector: "chord-section", inputs: ["chords", "language"] }] });
683
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.2", type: CircleOfFifthsComponent, isStandalone: true, selector: "the-chords-circle-of-fifths", inputs: { language: { classPropertyName: "language", publicName: "language", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "attr.lang": "language()" } }, ngImport: i0, template: "<div class=\"page\">\n <header class=\"page-header\">\n <h1>{{ text().title }}</h1>\n <p>{{ text().subtitle }}</p>\n </header>\n\n <div class=\"circle-and-chords\">\n <circle-of-fifths-wheel\n class=\"circle-and-chords-wheel\"\n [language]=\"language()\"\n [selectedIndex]=\"selectedIndex()\"\n [selectedType]=\"selectedType()\"\n (keySelected)=\"selectKey($event.index, $event.type)\"\n />\n\n <chord-section\n class=\"circle-and-chords-section\"\n [selectedIndex]=\"selectedIndex()\"\n [selectedType]=\"selectedType()\"\n [language]=\"language()\"\n ></chord-section>\n </div>\n\n <!-- \u2500\u2500 Chord Progressions \u2500\u2500 -->\n @if (progressions.length) {\n <section class=\"progressions-section\">\n <div class=\"progressions-header\">\n <h2>{{ text().commonProgressions }} {{ selectedInfo?.fullName }}</h2>\n <button class=\"btn-randomize\" (click)=\"randomizeProgressions()\">{{ text().generateProgressions }}</button>\n </div>\n <div class=\"progression-list\">\n @for (prog of progressions; track prog.name) {\n <div class=\"progression-card\">\n <div class=\"prog-header\">\n <span class=\"prog-mood\">{{ prog.mood }}</span>\n <span class=\"prog-name\">{{ prog.name }}</span>\n <span class=\"prog-genre\">{{ prog.genre }}</span>\n </div>\n <div class=\"prog-chords\">\n @for (chord of prog.chords; track $index; let last = $last) {\n <span class=\"prog-chord\">{{ chord }}</span>\n @if (!last) {\n <span class=\"prog-arrow\">\u2192</span>\n }\n }\n </div>\n <div class=\"prog-numerals\">\n @for (num of prog.numerals; track $index; let last = $last) {\n <span>{{ num }}</span>\n @if (!last) {\n <span class=\"prog-arrow\">\u2192</span>\n }\n }\n </div>\n <div class=\"prog-transpose-row\">\n <label class=\"prog-transpose-label\">{{ text().transposeLabel }}</label>\n <select class=\"prog-transpose-select\" (change)=\"setTranspose(prog.nameEn, +$any($event.target).value)\">\n @for (key of transposeKeys; track key.idx) {\n <option [value]=\"key.idx\" [selected]=\"key.idx === prog.transposeIdx\">{{ key.name }}</option>\n }\n </select>\n </div>\n <div class=\"prog-expand-row\">\n <button class=\"btn-expand\" (click)=\"toggleCard(prog.name)\">\n {{ expandedCards().has(prog.name) ? text().collapse : text().expand }}\n </button>\n <button class=\"btn-expand\" (click)=\"copyChords(prog)\">\n {{ text().copy }}\n </button>\n <button class=\"btn-spice\" [class.btn-spice--active]=\"prog.spiceLevel > 0\" (click)=\"cycleSpice(prog.nameEn)\">\n {{ text().spiceLabels[prog.spiceLevel] }}\n </button>\n </div>\n @if (expandedCards().has(prog.name)) {\n <div class=\"prog-sections\">\n @for (section of prog.sections; track section.label) {\n <div class=\"prog-section\">\n <div class=\"prog-section-label\">{{ section.label }}</div>\n <div class=\"prog-chords\">\n @for (chord of section.chords; track $index; let last = $last) {\n <span class=\"prog-chord\">{{ chord }}</span>\n @if (!last) { <span class=\"prog-arrow\">\u2192</span> }\n }\n </div>\n <div class=\"prog-numerals\">\n @for (num of section.numerals; track $index; let last = $last) {\n <span>{{ num }}</span>\n @if (!last) { <span class=\"prog-arrow\">\u2192</span> }\n }\n </div>\n </div>\n }\n </div>\n }\n </div>\n }\n </div>\n </section>\n }\n</div>\n", styles: [":host{--_chords-background: var(--chords-background, #f0f4f8);--_chords-background-alt: var(--chords-background-alt, #f8fafc);--_chords-surface: var(--chords-surface, #ffffff);--_chords-surface-elevated: var(--chords-surface-elevated, #f8fafc);--_chords-text: var(--chords-text, #1e293b);--_chords-muted: var(--chords-muted, #64748b);--_chords-primary: var(--chords-primary, #3b82f6);--_chords-secondary: var(--chords-secondary, #06b6d4);--_chords-highlight: var(--chords-highlight, #f59e0b);--_chords-danger: var(--chords-danger, #a855f7);--_chords-border: var(--chords-border, #e2e8f0);--_chords-on-primary: var(--chords-on-primary, #ffffff);box-sizing:border-box;display:block;min-height:100%;background:var(--_chords-background);color:var(--_chords-text)}:host *,:host *:before,:host *:after{box-sizing:inherit}.page{max-width:720px;margin:0 auto;padding:24px 16px 48px;font-family:Segoe UI,system-ui,sans-serif}@media(min-width:900px){.page{max-width:1320px}}.circle-and-chords{display:flex;flex-direction:column;gap:24px}@media(min-width:900px){.circle-and-chords{flex-direction:row;align-items:flex-start}}@media(min-width:900px){.circle-and-chords-wheel{flex:0 1 460px}}.circle-and-chords-section{min-width:0}@media(min-width:900px){.circle-and-chords-section{flex:1 1 auto}}.page-header{text-align:center;margin-bottom:28px}.page-header h1{margin:0 0 6px;font-size:2rem;font-weight:800;color:var(--_chords-text);letter-spacing:-.02em}.page-header p{margin:0;color:var(--_chords-muted);font-size:.95rem}.progressions-section{margin-top:36px;text-align:center}.progressions-header{display:flex;align-items:center;justify-content:center;gap:12px;margin-bottom:20px;flex-wrap:wrap}.progressions-header h2{margin:0;font-size:1.25rem;font-weight:700;color:var(--_chords-text)}.btn-randomize{padding:6px 14px;font-size:.8rem;font-weight:600;border:1.5px solid var(--_chords-primary);border-radius:20px;background:transparent;color:var(--_chords-primary);cursor:pointer;transition:background .15s,color .15s;white-space:nowrap}.btn-randomize:hover{background:var(--_chords-primary);color:var(--_chords-on-primary)}.progression-list{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}@media(max-width:480px){.progression-list{grid-template-columns:1fr}}.progression-card{background:var(--_chords-surface);border:1.5px solid var(--_chords-border);border-radius:14px;padding:16px 18px;text-align:left;transition:box-shadow .15s ease,transform .15s ease}.progression-card:hover{box-shadow:0 4px 16px color-mix(in srgb,var(--_chords-text) 8%,transparent);transform:translateY(-2px)}.prog-header{display:flex;align-items:baseline;gap:8px;margin-bottom:12px;flex-wrap:wrap}.prog-mood{font-size:.95rem;font-weight:700;color:var(--_chords-text)}.prog-name{font-size:.75rem;font-weight:600;color:var(--_chords-muted);text-transform:uppercase;letter-spacing:.06em}.prog-genre{margin-left:auto;font-size:.7rem;color:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background));font-style:italic}.prog-chords{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:6px}.prog-chord{font-size:1.15rem;font-weight:800;color:var(--_chords-text);letter-spacing:-.02em}.prog-numerals{display:flex;align-items:center;gap:6px;font-size:.75rem;font-style:italic;color:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background))}.prog-transpose-row{display:flex;align-items:center;gap:6px;margin-top:8px}.prog-transpose-label{font-size:.68rem;font-weight:600;color:var(--_chords-muted);white-space:nowrap}.prog-transpose-select{font-size:.72rem;font-weight:600;color:var(--_chords-muted);background:transparent;border:1px solid var(--_chords-border);border-radius:8px;padding:2px 6px;cursor:pointer;transition:border-color .15s,color .15s;appearance:auto}.prog-transpose-select:hover,.prog-transpose-select:focus{border-color:var(--_chords-primary);color:var(--_chords-primary);outline:none}.prog-expand-row{display:flex;justify-content:flex-end;margin-top:10px}.btn-spice{background:transparent;border:1px solid var(--cof-accent, #e09030);border-radius:6px;color:var(--cof-accent, #e09030);cursor:pointer;font-size:.75rem;padding:4px 10px;transition:background .2s,color .2s}.btn-spice:hover{background:var(--cof-accent, #e09030);color:#fff}.btn-spice.btn-spice--active{background:var(--cof-accent, #e09030);color:#fff;font-weight:600}.btn-expand{padding:3px 10px;font-size:.72rem;font-weight:600;border:1px solid var(--_chords-border);border-radius:12px;background:transparent;color:var(--_chords-muted);cursor:pointer;transition:border-color .15s,color .15s}.btn-expand:hover{border-color:var(--_chords-primary);color:var(--_chords-primary)}.prog-sections{margin-top:12px;border-top:1px solid var(--_chords-border);padding-top:10px;display:flex;flex-direction:column;gap:10px}.prog-section{padding:8px 10px;border-radius:8px;background:color-mix(in srgb,var(--_chords-text) 4%,var(--_chords-surface))}.prog-section-label{font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--_chords-muted);margin-bottom:5px}.prog-arrow{color:color-mix(in srgb,var(--_chords-muted) 42%,var(--_chords-background));font-size:.8rem}@media(max-width:480px){.page-header h1{font-size:1.5rem}}\n"], dependencies: [{ kind: "component", type: ChordSectionComponent, selector: "chord-section", inputs: ["selectedIndex", "selectedType", "language"] }, { kind: "component", type: CircleOfFifthsWheel, selector: "circle-of-fifths-wheel", inputs: ["language", "selectedIndex", "selectedType", "interactive"], outputs: ["keySelected"] }] });
513
684
  }
514
685
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: CircleOfFifthsComponent, decorators: [{
515
686
  type: Component,
516
- args: [{ selector: 'the-chords-circle-of-fifths', standalone: true, imports: [NgClass, ChordSectionComponent], host: { '[attr.lang]': 'language()' }, template: "<div class=\"page\">\n <header class=\"page-header\">\n <h1>{{ text().title }}</h1>\n <p>{{ text().subtitle }}</p>\n </header>\n\n <div class=\"circle-wrapper\">\n <svg\n viewBox=\"0 0 600 600\"\n xmlns=\"http://www.w3.org/2000/svg\"\n [attr.aria-label]=\"text().ariaLabel\"\n >\n <!-- \u2500\u2500 Key Signature Ring \u2500\u2500 -->\n @for (key of keys; track key.index) {\n <path [attr.d]=\"arcPath(KEY_SIG.inner, KEY_SIG.outer, key.index)\" class=\"keysig-segment\" />\n <text\n [attr.x]=\"textPos((KEY_SIG.inner + KEY_SIG.outer) / 2, key.index).x\"\n [attr.y]=\"textPos((KEY_SIG.inner + KEY_SIG.outer) / 2, key.index).y\"\n class=\"keysig-label\"\n >\n {{ getAccidentalText(key) }}\n </text>\n }\n\n <!-- \u2500\u2500 Major Keys Ring \u2500\u2500 -->\n @for (key of keys; track key.index) {\n <g\n [ngClass]=\"['major-group', getMajorState(key.index)]\"\n (click)=\"selectKey(key.index, 'major')\"\n >\n <path [attr.d]=\"arcPath(MAJOR.inner, MAJOR.outer, key.index)\" />\n\n <text\n [attr.x]=\"textPos((MAJOR.inner + MAJOR.outer) / 2, key.index).x\"\n [attr.y]=\"\n textPos((MAJOR.inner + MAJOR.outer) / 2, key.index).y +\n (getMajorRole(key.index) ? -9 : 0)\n \"\n class=\"major-label\"\n >\n {{ key.major }}\n </text>\n\n @if (getMajorRole(key.index)) {\n <text\n [attr.x]=\"textPos((MAJOR.inner + MAJOR.outer) / 2, key.index).x\"\n [attr.y]=\"textPos((MAJOR.inner + MAJOR.outer) / 2, key.index).y + 11\"\n class=\"role-label\"\n >\n {{ getMajorRole(key.index) }}\n </text>\n }\n </g>\n }\n\n <!-- \u2500\u2500 Minor Keys Ring \u2500\u2500 -->\n @for (key of keys; track key.index) {\n <g\n [ngClass]=\"['minor-group', getMinorState(key.index)]\"\n (click)=\"selectKey(key.index, 'minor')\"\n >\n <path [attr.d]=\"arcPath(MINOR.inner, MINOR.outer, key.index)\" />\n\n <text\n [attr.x]=\"textPos((MINOR.inner + MINOR.outer) / 2, key.index).x\"\n [attr.y]=\"\n textPos((MINOR.inner + MINOR.outer) / 2, key.index).y +\n (getMinorRole(key.index) ? -8 : 0)\n \"\n class=\"minor-label\"\n >\n {{ key.minor }}\n </text>\n\n @if (getMinorRole(key.index)) {\n <text\n [attr.x]=\"textPos((MINOR.inner + MINOR.outer) / 2, key.index).x\"\n [attr.y]=\"textPos((MINOR.inner + MINOR.outer) / 2, key.index).y + 9\"\n class=\"role-label\"\n >\n {{ getMinorRole(key.index) }}\n </text>\n }\n </g>\n }\n\n <!-- \u2500\u2500 Outer border \u2500\u2500 -->\n <circle cx=\"300\" cy=\"300\" r=\"289\" class=\"outer-border\" />\n\n <!-- \u2500\u2500 Center Circle \u2500\u2500 -->\n <circle cx=\"300\" cy=\"300\" r=\"99\" class=\"center-circle\" />\n\n @if (selectedInfo) {\n <text x=\"300\" y=\"283\" class=\"center-name\">{{ selectedInfo.name }}</text>\n <text x=\"300\" y=\"304\" class=\"center-scale\">{{ selectedInfo.scale }}</text>\n <text x=\"300\" y=\"322\" class=\"center-relative\">{{ selectedInfo.relativeKey }}</text>\n <text x=\"300\" y=\"338\" class=\"center-deselect\">{{ text().deselect }}</text>\n } @else {\n <text x=\"300\" y=\"293\" class=\"center-hint\">{{ text().clickKey }}</text>\n <text x=\"300\" y=\"312\" class=\"center-hint-sm\">{{ text().seeChords }}</text>\n }\n </svg>\n </div>\n\n <!-- \u2500\u2500 Legend \u2500\u2500 -->\n <div class=\"legend\">\n <span class=\"legend-item tonic\">{{ text().tonicLegend }}</span>\n <span class=\"legend-item scale-major\">{{ text().majorLegend }}</span>\n <span class=\"legend-item scale-minor\">{{ text().minorLegend }}</span>\n <span class=\"legend-item scale-diminished\">{{ text().diminishedLegend }}</span>\n </div>\n\n <chord-section [chords]=\"chordNames\" [language]=\"language()\"></chord-section>\n\n <!-- \u2500\u2500 Chord Progressions \u2500\u2500 -->\n @if (progressions.length) {\n <section class=\"progressions-section\">\n <div class=\"progressions-header\">\n <h2>{{ text().commonProgressions }} {{ selectedInfo?.fullName }}</h2>\n <button class=\"btn-randomize\" (click)=\"randomizeProgressions()\">{{ text().generateProgressions }}</button>\n </div>\n <div class=\"progression-list\">\n @for (prog of progressions; track prog.name) {\n <div class=\"progression-card\">\n <div class=\"prog-header\">\n <span class=\"prog-mood\">{{ prog.mood }}</span>\n <span class=\"prog-name\">{{ prog.name }}</span>\n <span class=\"prog-genre\">{{ prog.genre }}</span>\n </div>\n <div class=\"prog-chords\">\n @for (chord of prog.chords; track $index; let last = $last) {\n <span class=\"prog-chord\">{{ chord }}</span>\n @if (!last) {\n <span class=\"prog-arrow\">\u2192</span>\n }\n }\n </div>\n <div class=\"prog-numerals\">\n @for (num of prog.numerals; track $index; let last = $last) {\n <span>{{ num }}</span>\n @if (!last) {\n <span class=\"prog-arrow\">\u2192</span>\n }\n }\n </div>\n <div class=\"prog-expand-row\">\n <button class=\"btn-expand\" (click)=\"toggleCard(prog.name)\">\n {{ expandedCards().has(prog.name) ? text().collapse : text().expand }}\n </button>\n <button class=\"btn-expand\" (click)=\"copyChords(prog)\">\n {{ text().copy }}\n </button>\n </div>\n @if (expandedCards().has(prog.name)) {\n <div class=\"prog-sections\">\n @for (section of prog.sections; track section.label) {\n <div class=\"prog-section\">\n <div class=\"prog-section-label\">{{ section.label }}</div>\n <div class=\"prog-chords\">\n @for (chord of section.chords; track $index; let last = $last) {\n <span class=\"prog-chord\">{{ chord }}</span>\n @if (!last) { <span class=\"prog-arrow\">\u2192</span> }\n }\n </div>\n <div class=\"prog-numerals\">\n @for (num of section.numerals; track $index; let last = $last) {\n <span>{{ num }}</span>\n @if (!last) { <span class=\"prog-arrow\">\u2192</span> }\n }\n </div>\n </div>\n }\n </div>\n }\n </div>\n }\n </div>\n </section>\n }\n</div>\n", styles: [":host{--_chords-background: var(--chords-background, #f0f4f8);--_chords-background-alt: var(--chords-background-alt, #f8fafc);--_chords-surface: var(--chords-surface, #ffffff);--_chords-surface-elevated: var(--chords-surface-elevated, #f8fafc);--_chords-text: var(--chords-text, #1e293b);--_chords-muted: var(--chords-muted, #64748b);--_chords-primary: var(--chords-primary, #3b82f6);--_chords-secondary: var(--chords-secondary, #06b6d4);--_chords-highlight: var(--chords-highlight, #f59e0b);--_chords-danger: var(--chords-danger, #a855f7);--_chords-border: var(--chords-border, #e2e8f0);--_chords-on-primary: var(--chords-on-primary, #ffffff);box-sizing:border-box;display:block;min-height:100%;background:var(--_chords-background);color:var(--_chords-text)}:host *,:host *:before,:host *:after{box-sizing:inherit}.page{max-width:720px;margin:0 auto;padding:24px 16px 48px;font-family:Segoe UI,system-ui,sans-serif}.page-header{text-align:center;margin-bottom:28px}.page-header h1{margin:0 0 6px;font-size:2rem;font-weight:800;color:var(--_chords-text);letter-spacing:-.02em}.page-header p{margin:0;color:var(--_chords-muted);font-size:.95rem}.circle-wrapper{max-width:580px;margin:0 auto}.circle-wrapper svg{display:block;width:100%;height:auto;overflow:visible}.keysig-segment{fill:var(--_chords-text)}.keysig-label{fill:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background));font-size:12px;font-weight:500;text-anchor:middle;dominant-baseline:central;pointer-events:none}.major-group{cursor:pointer}.major-group path{fill:color-mix(in srgb,var(--_chords-primary) 14%,var(--_chords-surface));stroke:var(--_chords-surface);stroke-width:2;transition:fill .18s ease}.major-group:hover path{fill:color-mix(in srgb,var(--_chords-primary) 24%,var(--_chords-surface))}.major-group .major-label{fill:var(--_chords-text);font-size:15px;font-weight:700;text-anchor:middle;dominant-baseline:central;pointer-events:none;transition:fill .18s ease}.major-group.tonic path{fill:var(--_chords-highlight)}.major-group.tonic:hover path{fill:color-mix(in srgb,var(--_chords-highlight) 84%,var(--_chords-text))}.major-group.tonic .major-label{fill:var(--_chords-on-primary)}.major-group.scale-major path{fill:var(--_chords-primary)}.major-group.scale-major:hover path{fill:color-mix(in srgb,var(--_chords-primary) 82%,var(--_chords-text))}.major-group.scale-major .major-label{fill:var(--_chords-on-primary)}.minor-group{cursor:pointer}.minor-group path{fill:color-mix(in srgb,var(--_chords-secondary) 10%,var(--_chords-surface));stroke:var(--_chords-surface);stroke-width:2;transition:fill .18s ease}.minor-group:hover path{fill:color-mix(in srgb,var(--_chords-secondary) 20%,var(--_chords-surface))}.minor-group .minor-label{fill:color-mix(in srgb,var(--_chords-text) 78%,var(--_chords-muted));font-size:12px;font-weight:600;text-anchor:middle;dominant-baseline:central;pointer-events:none;transition:fill .18s ease}.minor-group.tonic path{fill:var(--_chords-highlight)}.minor-group.tonic:hover path{fill:color-mix(in srgb,var(--_chords-highlight) 84%,var(--_chords-text))}.minor-group.tonic .minor-label{fill:var(--_chords-on-primary)}.minor-group.scale-minor path{fill:var(--_chords-secondary)}.minor-group.scale-minor:hover path{fill:color-mix(in srgb,var(--_chords-secondary) 82%,var(--_chords-text))}.minor-group.scale-minor .minor-label{fill:var(--_chords-on-primary)}.minor-group.scale-diminished path{fill:var(--_chords-danger)}.minor-group.scale-diminished:hover path{fill:color-mix(in srgb,var(--_chords-danger) 82%,var(--_chords-text))}.minor-group.scale-diminished .minor-label{fill:var(--_chords-on-primary)}.role-label{font-size:10px;font-style:italic;font-weight:600;text-anchor:middle;dominant-baseline:central;pointer-events:none;fill:color-mix(in srgb,var(--_chords-on-primary) 85%,transparent)}.outer-border{fill:none;stroke:var(--_chords-text);stroke-width:2.5}.center-circle{fill:var(--_chords-surface);stroke:var(--_chords-border);stroke-width:2;filter:drop-shadow(0 2px 10px color-mix(in srgb,var(--_chords-text) 8%,transparent))}.center-name{font-size:30px;font-weight:800;text-anchor:middle;dominant-baseline:auto;fill:var(--_chords-text);letter-spacing:-.02em}.center-scale{font-size:13px;font-weight:600;text-anchor:middle;dominant-baseline:auto;fill:var(--_chords-muted);text-transform:uppercase;letter-spacing:.08em}.center-relative{font-size:9.5px;font-weight:500;text-anchor:middle;dominant-baseline:auto;fill:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background))}.center-deselect{font-size:8px;text-anchor:middle;dominant-baseline:auto;fill:color-mix(in srgb,var(--_chords-muted) 42%,var(--_chords-background))}.center-hint{font-size:16px;font-weight:600;text-anchor:middle;dominant-baseline:auto;fill:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background))}.center-hint-sm{font-size:11px;text-anchor:middle;dominant-baseline:auto;fill:color-mix(in srgb,var(--_chords-muted) 42%,var(--_chords-background))}.legend{display:flex;justify-content:center;gap:16px;flex-wrap:wrap;margin:16px 0 0}.legend .legend-item{display:flex;align-items:center;gap:6px;font-size:.8rem;color:var(--_chords-muted)}.legend .legend-item:before{content:\"\";display:inline-block;width:14px;height:14px;border-radius:3px}.legend .legend-item.tonic:before{background:var(--_chords-highlight)}.legend .legend-item.scale-major:before{background:var(--_chords-primary)}.legend .legend-item.scale-minor:before{background:var(--_chords-secondary)}.legend .legend-item.scale-diminished:before{background:var(--_chords-danger)}.progressions-section{margin-top:36px;text-align:center}.progressions-header{display:flex;align-items:center;justify-content:center;gap:12px;margin-bottom:20px;flex-wrap:wrap}.progressions-header h2{margin:0;font-size:1.25rem;font-weight:700;color:var(--_chords-text)}.btn-randomize{padding:6px 14px;font-size:.8rem;font-weight:600;border:1.5px solid var(--_chords-primary);border-radius:20px;background:transparent;color:var(--_chords-primary);cursor:pointer;transition:background .15s,color .15s;white-space:nowrap}.btn-randomize:hover{background:var(--_chords-primary);color:var(--_chords-on-primary)}.progression-list{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}@media(max-width:480px){.progression-list{grid-template-columns:1fr}}.progression-card{background:var(--_chords-surface);border:1.5px solid var(--_chords-border);border-radius:14px;padding:16px 18px;text-align:left;transition:box-shadow .15s ease,transform .15s ease}.progression-card:hover{box-shadow:0 4px 16px color-mix(in srgb,var(--_chords-text) 8%,transparent);transform:translateY(-2px)}.prog-header{display:flex;align-items:baseline;gap:8px;margin-bottom:12px;flex-wrap:wrap}.prog-mood{font-size:.95rem;font-weight:700;color:var(--_chords-text)}.prog-name{font-size:.75rem;font-weight:600;color:var(--_chords-muted);text-transform:uppercase;letter-spacing:.06em}.prog-genre{margin-left:auto;font-size:.7rem;color:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background));font-style:italic}.prog-chords{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:6px}.prog-chord{font-size:1.15rem;font-weight:800;color:var(--_chords-text);letter-spacing:-.02em}.prog-numerals{display:flex;align-items:center;gap:6px;font-size:.75rem;font-style:italic;color:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background))}.prog-expand-row{display:flex;justify-content:flex-end;margin-top:10px}.btn-expand{padding:3px 10px;font-size:.72rem;font-weight:600;border:1px solid var(--_chords-border);border-radius:12px;background:transparent;color:var(--_chords-muted);cursor:pointer;transition:border-color .15s,color .15s}.btn-expand:hover{border-color:var(--_chords-primary);color:var(--_chords-primary)}.prog-sections{margin-top:12px;border-top:1px solid var(--_chords-border);padding-top:10px;display:flex;flex-direction:column;gap:10px}.prog-section{padding:8px 10px;border-radius:8px;background:color-mix(in srgb,var(--_chords-text) 4%,var(--_chords-surface))}.prog-section-label{font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--_chords-muted);margin-bottom:5px}.prog-arrow{color:color-mix(in srgb,var(--_chords-muted) 42%,var(--_chords-background));font-size:.8rem}@media(max-width:480px){.page-header h1{font-size:1.5rem}}\n"] }]
517
- }], propDecorators: { language: [{ type: i0.Input, args: [{ isSignal: true, alias: "language", required: false }] }] } });
687
+ args: [{ selector: 'the-chords-circle-of-fifths', standalone: true, imports: [ChordSectionComponent, CircleOfFifthsWheel], host: { '[attr.lang]': 'language()' }, template: "<div class=\"page\">\n <header class=\"page-header\">\n <h1>{{ text().title }}</h1>\n <p>{{ text().subtitle }}</p>\n </header>\n\n <div class=\"circle-and-chords\">\n <circle-of-fifths-wheel\n class=\"circle-and-chords-wheel\"\n [language]=\"language()\"\n [selectedIndex]=\"selectedIndex()\"\n [selectedType]=\"selectedType()\"\n (keySelected)=\"selectKey($event.index, $event.type)\"\n />\n\n <chord-section\n class=\"circle-and-chords-section\"\n [selectedIndex]=\"selectedIndex()\"\n [selectedType]=\"selectedType()\"\n [language]=\"language()\"\n ></chord-section>\n </div>\n\n <!-- \u2500\u2500 Chord Progressions \u2500\u2500 -->\n @if (progressions.length) {\n <section class=\"progressions-section\">\n <div class=\"progressions-header\">\n <h2>{{ text().commonProgressions }} {{ selectedInfo?.fullName }}</h2>\n <button class=\"btn-randomize\" (click)=\"randomizeProgressions()\">{{ text().generateProgressions }}</button>\n </div>\n <div class=\"progression-list\">\n @for (prog of progressions; track prog.name) {\n <div class=\"progression-card\">\n <div class=\"prog-header\">\n <span class=\"prog-mood\">{{ prog.mood }}</span>\n <span class=\"prog-name\">{{ prog.name }}</span>\n <span class=\"prog-genre\">{{ prog.genre }}</span>\n </div>\n <div class=\"prog-chords\">\n @for (chord of prog.chords; track $index; let last = $last) {\n <span class=\"prog-chord\">{{ chord }}</span>\n @if (!last) {\n <span class=\"prog-arrow\">\u2192</span>\n }\n }\n </div>\n <div class=\"prog-numerals\">\n @for (num of prog.numerals; track $index; let last = $last) {\n <span>{{ num }}</span>\n @if (!last) {\n <span class=\"prog-arrow\">\u2192</span>\n }\n }\n </div>\n <div class=\"prog-transpose-row\">\n <label class=\"prog-transpose-label\">{{ text().transposeLabel }}</label>\n <select class=\"prog-transpose-select\" (change)=\"setTranspose(prog.nameEn, +$any($event.target).value)\">\n @for (key of transposeKeys; track key.idx) {\n <option [value]=\"key.idx\" [selected]=\"key.idx === prog.transposeIdx\">{{ key.name }}</option>\n }\n </select>\n </div>\n <div class=\"prog-expand-row\">\n <button class=\"btn-expand\" (click)=\"toggleCard(prog.name)\">\n {{ expandedCards().has(prog.name) ? text().collapse : text().expand }}\n </button>\n <button class=\"btn-expand\" (click)=\"copyChords(prog)\">\n {{ text().copy }}\n </button>\n <button class=\"btn-spice\" [class.btn-spice--active]=\"prog.spiceLevel > 0\" (click)=\"cycleSpice(prog.nameEn)\">\n {{ text().spiceLabels[prog.spiceLevel] }}\n </button>\n </div>\n @if (expandedCards().has(prog.name)) {\n <div class=\"prog-sections\">\n @for (section of prog.sections; track section.label) {\n <div class=\"prog-section\">\n <div class=\"prog-section-label\">{{ section.label }}</div>\n <div class=\"prog-chords\">\n @for (chord of section.chords; track $index; let last = $last) {\n <span class=\"prog-chord\">{{ chord }}</span>\n @if (!last) { <span class=\"prog-arrow\">\u2192</span> }\n }\n </div>\n <div class=\"prog-numerals\">\n @for (num of section.numerals; track $index; let last = $last) {\n <span>{{ num }}</span>\n @if (!last) { <span class=\"prog-arrow\">\u2192</span> }\n }\n </div>\n </div>\n }\n </div>\n }\n </div>\n }\n </div>\n </section>\n }\n</div>\n", styles: [":host{--_chords-background: var(--chords-background, #f0f4f8);--_chords-background-alt: var(--chords-background-alt, #f8fafc);--_chords-surface: var(--chords-surface, #ffffff);--_chords-surface-elevated: var(--chords-surface-elevated, #f8fafc);--_chords-text: var(--chords-text, #1e293b);--_chords-muted: var(--chords-muted, #64748b);--_chords-primary: var(--chords-primary, #3b82f6);--_chords-secondary: var(--chords-secondary, #06b6d4);--_chords-highlight: var(--chords-highlight, #f59e0b);--_chords-danger: var(--chords-danger, #a855f7);--_chords-border: var(--chords-border, #e2e8f0);--_chords-on-primary: var(--chords-on-primary, #ffffff);box-sizing:border-box;display:block;min-height:100%;background:var(--_chords-background);color:var(--_chords-text)}:host *,:host *:before,:host *:after{box-sizing:inherit}.page{max-width:720px;margin:0 auto;padding:24px 16px 48px;font-family:Segoe UI,system-ui,sans-serif}@media(min-width:900px){.page{max-width:1320px}}.circle-and-chords{display:flex;flex-direction:column;gap:24px}@media(min-width:900px){.circle-and-chords{flex-direction:row;align-items:flex-start}}@media(min-width:900px){.circle-and-chords-wheel{flex:0 1 460px}}.circle-and-chords-section{min-width:0}@media(min-width:900px){.circle-and-chords-section{flex:1 1 auto}}.page-header{text-align:center;margin-bottom:28px}.page-header h1{margin:0 0 6px;font-size:2rem;font-weight:800;color:var(--_chords-text);letter-spacing:-.02em}.page-header p{margin:0;color:var(--_chords-muted);font-size:.95rem}.progressions-section{margin-top:36px;text-align:center}.progressions-header{display:flex;align-items:center;justify-content:center;gap:12px;margin-bottom:20px;flex-wrap:wrap}.progressions-header h2{margin:0;font-size:1.25rem;font-weight:700;color:var(--_chords-text)}.btn-randomize{padding:6px 14px;font-size:.8rem;font-weight:600;border:1.5px solid var(--_chords-primary);border-radius:20px;background:transparent;color:var(--_chords-primary);cursor:pointer;transition:background .15s,color .15s;white-space:nowrap}.btn-randomize:hover{background:var(--_chords-primary);color:var(--_chords-on-primary)}.progression-list{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}@media(max-width:480px){.progression-list{grid-template-columns:1fr}}.progression-card{background:var(--_chords-surface);border:1.5px solid var(--_chords-border);border-radius:14px;padding:16px 18px;text-align:left;transition:box-shadow .15s ease,transform .15s ease}.progression-card:hover{box-shadow:0 4px 16px color-mix(in srgb,var(--_chords-text) 8%,transparent);transform:translateY(-2px)}.prog-header{display:flex;align-items:baseline;gap:8px;margin-bottom:12px;flex-wrap:wrap}.prog-mood{font-size:.95rem;font-weight:700;color:var(--_chords-text)}.prog-name{font-size:.75rem;font-weight:600;color:var(--_chords-muted);text-transform:uppercase;letter-spacing:.06em}.prog-genre{margin-left:auto;font-size:.7rem;color:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background));font-style:italic}.prog-chords{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:6px}.prog-chord{font-size:1.15rem;font-weight:800;color:var(--_chords-text);letter-spacing:-.02em}.prog-numerals{display:flex;align-items:center;gap:6px;font-size:.75rem;font-style:italic;color:color-mix(in srgb,var(--_chords-muted) 72%,var(--_chords-background))}.prog-transpose-row{display:flex;align-items:center;gap:6px;margin-top:8px}.prog-transpose-label{font-size:.68rem;font-weight:600;color:var(--_chords-muted);white-space:nowrap}.prog-transpose-select{font-size:.72rem;font-weight:600;color:var(--_chords-muted);background:transparent;border:1px solid var(--_chords-border);border-radius:8px;padding:2px 6px;cursor:pointer;transition:border-color .15s,color .15s;appearance:auto}.prog-transpose-select:hover,.prog-transpose-select:focus{border-color:var(--_chords-primary);color:var(--_chords-primary);outline:none}.prog-expand-row{display:flex;justify-content:flex-end;margin-top:10px}.btn-spice{background:transparent;border:1px solid var(--cof-accent, #e09030);border-radius:6px;color:var(--cof-accent, #e09030);cursor:pointer;font-size:.75rem;padding:4px 10px;transition:background .2s,color .2s}.btn-spice:hover{background:var(--cof-accent, #e09030);color:#fff}.btn-spice.btn-spice--active{background:var(--cof-accent, #e09030);color:#fff;font-weight:600}.btn-expand{padding:3px 10px;font-size:.72rem;font-weight:600;border:1px solid var(--_chords-border);border-radius:12px;background:transparent;color:var(--_chords-muted);cursor:pointer;transition:border-color .15s,color .15s}.btn-expand:hover{border-color:var(--_chords-primary);color:var(--_chords-primary)}.prog-sections{margin-top:12px;border-top:1px solid var(--_chords-border);padding-top:10px;display:flex;flex-direction:column;gap:10px}.prog-section{padding:8px 10px;border-radius:8px;background:color-mix(in srgb,var(--_chords-text) 4%,var(--_chords-surface))}.prog-section-label{font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--_chords-muted);margin-bottom:5px}.prog-arrow{color:color-mix(in srgb,var(--_chords-muted) 42%,var(--_chords-background));font-size:.8rem}@media(max-width:480px){.page-header h1{font-size:1.5rem}}\n"] }]
688
+ }], ctorParameters: () => [], propDecorators: { language: [{ type: i0.Input, args: [{ isSignal: true, alias: "language", required: false }] }] } });
518
689
 
519
690
  /*
520
691
  * Public API Surface of circle-of-fifths-lib
@@ -524,5 +695,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImpor
524
695
  * Generated bundle index. Do not edit.
525
696
  */
526
697
 
527
- export { ChordSectionComponent, CircleOfFifthsComponent };
698
+ export { ChordSectionComponent, CircleOfFifthsComponent, CircleOfFifthsWheel };
528
699
  //# sourceMappingURL=gblp-circle-of-fifths.mjs.map