tonus 0.1.6 → 0.5.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.
Files changed (161) hide show
  1. package/BIBLIOGRAPHY.md +132 -108
  2. package/CHANGELOG.md +610 -1
  3. package/LICENSE +133 -29
  4. package/README.md +106 -83
  5. package/dist/data/am.js +2669 -11196
  6. package/dist/data/ams.d.ts +5 -0
  7. package/dist/data/ams.js +122 -0
  8. package/dist/data/attestation.d.ts +19 -0
  9. package/dist/data/attestation.js +15716 -0
  10. package/dist/data/attestation.json +15711 -0
  11. package/dist/data/cadentiae.d.ts +43 -0
  12. package/dist/data/cadentiae.js +174 -0
  13. package/dist/data/cal.js +48 -0
  14. package/dist/data/census.d.ts +12 -0
  15. package/dist/data/census.js +36 -0
  16. package/dist/data/commune-office.d.ts +4 -0
  17. package/dist/data/commune-office.js +2371 -0
  18. package/dist/data/commune-office.json +2365 -0
  19. package/dist/data/commune.js +181 -11
  20. package/dist/data/corpus-overlap.d.ts +24 -0
  21. package/dist/data/corpus-overlap.js +347 -0
  22. package/dist/data/cot.d.ts +5 -0
  23. package/dist/data/cot.js +172 -0
  24. package/dist/data/cse.d.ts +5 -0
  25. package/dist/data/cse.js +122 -0
  26. package/dist/data/gabc-glyphs.d.ts +45 -0
  27. package/dist/data/gabc-glyphs.js +122 -0
  28. package/dist/data/gr.js +757 -6394
  29. package/dist/data/kyriale.js +116 -116
  30. package/dist/data/la.js +802 -13419
  31. package/dist/data/lh.js +116 -3473
  32. package/dist/data/lu.js +935 -17632
  33. package/dist/data/nocturnale-romanum.d.ts +5 -0
  34. package/dist/data/nocturnale-romanum.js +3772 -0
  35. package/dist/data/office-ferial.d.ts +4 -0
  36. package/dist/data/office-ferial.js +396 -0
  37. package/dist/data/office-ferial.json +391 -0
  38. package/dist/data/office-monastic.d.ts +17 -1
  39. package/dist/data/office-monastic.js +1403 -466
  40. package/dist/data/office-psalms-monastic.d.ts +13 -1
  41. package/dist/data/office-psalms-monastic.js +9 -0
  42. package/dist/data/propers.js +1 -1
  43. package/dist/data/psalms.js +22919 -5
  44. package/dist/data/psm.d.ts +5 -0
  45. package/dist/data/psm.js +122 -0
  46. package/dist/data/seasonal-respbreve.d.ts +5 -0
  47. package/dist/data/seasonal-respbreve.js +41 -0
  48. package/dist/data/seasonal-respbreve.json +35 -0
  49. package/dist/data/smufl-glyphs.d.ts +17 -0
  50. package/dist/data/smufl-glyphs.js +1546 -0
  51. package/dist/data/smufl-glyphs.json +1530 -0
  52. package/dist/engines/cal/calendar.d.ts +3 -2
  53. package/dist/engines/cal/calendar.js +105 -29
  54. package/dist/engines/cal/data/eras.d.ts +35 -0
  55. package/dist/engines/cal/data/eras.js +128 -0
  56. package/dist/engines/cal/date.js +44 -0
  57. package/dist/engines/cal/types.d.ts +15 -3
  58. package/dist/engines/cal/types.js +5 -5
  59. package/dist/engines/census/census.d.ts +7 -0
  60. package/dist/engines/census/census.js +179 -0
  61. package/dist/engines/census/types.d.ts +55 -0
  62. package/dist/engines/census/types.js +8 -0
  63. package/dist/engines/chant/attest.d.ts +39 -0
  64. package/dist/engines/chant/attest.js +90 -0
  65. package/dist/engines/chant/chant.d.ts +22 -4
  66. package/dist/engines/chant/chant.js +274 -14
  67. package/dist/engines/chant/data/compline.js +2 -1
  68. package/dist/engines/chant/data/masses.d.ts +56 -4
  69. package/dist/engines/chant/data/masses.js +305 -80
  70. package/dist/engines/chant/data/prime.js +1 -1
  71. package/dist/engines/chant/hour.js +279 -58
  72. package/dist/engines/chant/ordinary.d.ts +2 -0
  73. package/dist/engines/chant/ordinary.js +336 -56
  74. package/dist/engines/chant/propers.js +55 -5
  75. package/dist/engines/chant/psalm.d.ts +4 -4
  76. package/dist/engines/chant/psalm.js +25 -11
  77. package/dist/engines/chant/syllabify.d.ts +1 -0
  78. package/dist/engines/chant/syllabify.js +90 -17
  79. package/dist/engines/chant/types.d.ts +145 -10
  80. package/dist/engines/chant/types.js +38 -3
  81. package/dist/engines/harmonia/api.js +4 -0
  82. package/dist/engines/harmonia/data/doctrines.js +3 -1
  83. package/dist/engines/harmonia/tabula.d.ts +3 -0
  84. package/dist/engines/harmonia/tabula.js +1 -0
  85. package/dist/engines/harmonia/voice.d.ts +4 -0
  86. package/dist/engines/harmonia/voice.js +8 -4
  87. package/dist/engines/imprint.js +14 -1
  88. package/dist/engines/planet/orbital.js +4 -4
  89. package/dist/engines/planet/planet.d.ts +10 -0
  90. package/dist/engines/planet/planet.js +30 -3
  91. package/dist/engines/planet/position.js +13 -10
  92. package/dist/engines/planet/types.d.ts +1 -0
  93. package/dist/engines/score/api.d.ts +2 -13
  94. package/dist/engines/score/api.js +21 -8
  95. package/dist/engines/score/articulation.js +2 -2
  96. package/dist/engines/score/cadence.d.ts +76 -0
  97. package/dist/engines/score/cadence.js +96 -0
  98. package/dist/engines/score/emitters/accidentals.d.ts +21 -0
  99. package/dist/engines/score/emitters/accidentals.js +88 -0
  100. package/dist/engines/score/emitters/atramentum.d.ts +107 -0
  101. package/dist/engines/score/emitters/atramentum.js +239 -0
  102. package/dist/engines/score/emitters/breaking.d.ts +62 -0
  103. package/dist/engines/score/emitters/breaking.js +80 -0
  104. package/dist/engines/score/emitters/moderna.d.ts +38 -0
  105. package/dist/engines/score/emitters/moderna.js +612 -0
  106. package/dist/engines/score/emitters/svg.d.ts +143 -0
  107. package/dist/engines/score/emitters/svg.js +1328 -0
  108. package/dist/engines/score/emitters/tracks.d.ts +104 -0
  109. package/dist/engines/score/emitters/tracks.js +728 -0
  110. package/dist/engines/score/infer.d.ts +3 -3
  111. package/dist/engines/score/infer.js +2 -2
  112. package/dist/engines/score/inscriptio.d.ts +69 -0
  113. package/dist/engines/score/inscriptio.js +138 -0
  114. package/dist/engines/score/ir.d.ts +2 -2
  115. package/dist/engines/score/ir.js +59 -12
  116. package/dist/engines/score/lyric.d.ts +23 -0
  117. package/dist/engines/score/lyric.js +234 -0
  118. package/dist/engines/score/meta.d.ts +2 -2
  119. package/dist/engines/score/modulation.d.ts +12 -0
  120. package/dist/engines/score/modulation.js +49 -0
  121. package/dist/engines/score/neume.js +35 -4
  122. package/dist/engines/score/parse.js +150 -9
  123. package/dist/engines/score/phrasing.js +4 -3
  124. package/dist/engines/score/prosody.d.ts +38 -0
  125. package/dist/engines/score/prosody.js +70 -6
  126. package/dist/engines/score/tabula.d.ts +37 -5
  127. package/dist/engines/score/tabula.js +18 -0
  128. package/dist/engines/score/types.d.ts +88 -1
  129. package/dist/engines/temper/api.d.ts +4 -1
  130. package/dist/engines/temper/api.js +28 -5
  131. package/dist/engines/temper/data/guido.js +6 -2
  132. package/dist/engines/temper/data/modes.d.ts +6 -0
  133. package/dist/engines/temper/data/modes.js +42 -0
  134. package/dist/engines/temper/data/tones.d.ts +1 -1
  135. package/dist/engines/temper/data/tones.js +20 -11
  136. package/dist/engines/temper/interval.js +4 -3
  137. package/dist/engines/temper/modality.d.ts +11 -2
  138. package/dist/engines/temper/modality.js +74 -2
  139. package/dist/engines/temper/modes.d.ts +1 -1
  140. package/dist/engines/temper/pitch.d.ts +1 -1
  141. package/dist/engines/temper/pitch.js +12 -2
  142. package/dist/engines/temper/scale.d.ts +53 -0
  143. package/dist/engines/temper/scale.js +107 -8
  144. package/dist/index.d.ts +28 -6
  145. package/dist/index.js +39 -3
  146. package/docs/api/calendar.md +279 -0
  147. package/docs/api/census.md +288 -0
  148. package/docs/api/chant.md +657 -0
  149. package/docs/api/heavens.md +346 -0
  150. package/docs/api/index.md +263 -0
  151. package/docs/api/score.md +873 -0
  152. package/docs/api/tuning.md +619 -0
  153. package/package.json +11 -5
  154. package/dist/data/office-psalms-roman.d.ts +0 -15
  155. package/dist/data/office-psalms-roman.js +0 -28
  156. package/dist/data/office-roman.d.ts +0 -19
  157. package/dist/data/office-roman.js +0 -13792
  158. package/dist/engines/score/emitters/midi.d.ts +0 -65
  159. package/dist/engines/score/emitters/midi.js +0 -162
  160. package/dist/engines/score/emitters/musicxml.d.ts +0 -18
  161. package/dist/engines/score/emitters/musicxml.js +0 -166
@@ -0,0 +1,728 @@
1
+ import { cadentiaFamilia, CADENTIAE_POPULATION, } from "../../../data/cadentiae.js";
2
+ import { INK, STRATUM, CONF_FLOOR, sc, esc, HOUSE_SERIF, HOUSE_MONO, sampleCubic, crSamples, velocityAt, velocityCeiling, ribbonPath, } from "./atramentum.js";
3
+ /** THE STACK. Every track rides either species and all may ride one score
4
+ * together, so the band room is the sum of what each asks for. The order is
5
+ * fixed here, not by the caller's array: the prosodia rides FIRST, directly
6
+ * under the lyric line — it reads the text itself, so it stays closest to
7
+ * it; the chironomia next, its wave an extension of the lyric line's
8
+ * rhythm; the tonarium below, a panel whose label row is its bottom edge
9
+ * and wants nothing under it. Requesting ["tonarium", "chironomia"]
10
+ * therefore draws exactly what ["chironomia", "tonarium"] draws. */
11
+ export function trackBands(tracks, k) {
12
+ const wantsPros = tracks?.includes("prosodia") ?? false;
13
+ const wantsChiron = tracks?.includes("chironomia") ?? false;
14
+ const wantsTon = tracks?.includes("tonarium") ?? false;
15
+ let top = 0;
16
+ let prosodia = null;
17
+ let chironomia = null;
18
+ let tonarium = null;
19
+ if (wantsPros) {
20
+ prosodia = { top, height: prosodiaExtra(k) };
21
+ top += prosodia.height;
22
+ }
23
+ if (wantsChiron) {
24
+ chironomia = { top, height: chironomiaExtra(k) };
25
+ top += chironomia.height;
26
+ }
27
+ if (wantsTon) {
28
+ tonarium = { top, height: tonariumExtra(k) };
29
+ top += tonarium.height;
30
+ }
31
+ return { prosodia, chironomia, tonarium, extra: top };
32
+ }
33
+ /** Band room the prosodia reserves below each system's lyric line. The 8
34
+ * beyond the band's own ink is AIR: without it the chironomia's crest
35
+ * letters sat visibly tighter to this band than the tonarium sits to the
36
+ * wave's troughs, and the stack read as unevenly spaced. */
37
+ export function prosodiaExtra(k) {
38
+ return 72 * k;
39
+ }
40
+ // The lane's vertical constants, from the band top (k = 1).
41
+ const P_WORD_BASE = 38; // the word lane's baseline
42
+ // The tent's swell, as two points on the nib. At the word's edges it is
43
+ // thinner than the old flat 0.8; at the accent it is half again as thick,
44
+ // so the hairpin is legible as pressure and not only as outline.
45
+ const P_TENT_VN_MIN = 0.05;
46
+ const P_TENT_VN_MAX = 0.55;
47
+ // The accent dot. The word's claim is the loudest thing the prosodia says
48
+ // and it was the smallest mark drawn — 1.9 read as a tick on the crest
49
+ // rather than the peak's own mark.
50
+ const P_ACC_R = 2.5;
51
+ const P_TENT_ACC = 16; // an accented word's crest — above the rule
52
+ const P_TENT_PLAIN = 8; // an unaccented word's crest — ON the rule
53
+ const P_RAIL = 58; // the fence's rail
54
+ const P_STEM_W = 1.4; // the one mark width (not a nib — nothing varies)
55
+ /** The fence's height law, shared by stems and blocks: notes → px. */
56
+ const prosH = (n, k) => Math.min(14, 5 + (n - 1) * 0.9) * k;
57
+ /** Group placed notes into syllables, in singing order. */
58
+ function prosSyllables(notes) {
59
+ const order = [];
60
+ const by = new Map();
61
+ for (const n of notes) {
62
+ const key = `${n.row.phraseIndex}.${n.row.syllableIndex}`;
63
+ if (!by.has(key)) {
64
+ by.set(key, []);
65
+ order.push(key);
66
+ }
67
+ by.get(key).push(n);
68
+ }
69
+ const out = [];
70
+ for (const key of order) {
71
+ const ns = by.get(key);
72
+ // The system the syllable begins in — NOT the min-x note's system: a
73
+ // wrapped melisma's continuation starts at the next system's left margin,
74
+ // which is the smaller x (the lab shipped that bug once).
75
+ const system = Math.min(...ns.map((n) => n.system));
76
+ const own = ns.filter((n) => n.system === system);
77
+ const tailSystem = Math.max(...ns.map((n) => n.system));
78
+ let tail = null;
79
+ if (tailSystem !== system) {
80
+ const tn = ns.filter((n) => n.system === tailSystem);
81
+ tail = {
82
+ system: tailSystem,
83
+ x0: Math.min(...tn.map((n) => n.inkLeft)),
84
+ x1: Math.max(...tn.map((n) => n.inkRight)),
85
+ };
86
+ }
87
+ const tenorN = ns.filter((n) => n.row.role === "tenor").length;
88
+ out.push({
89
+ notes: ns,
90
+ n: ns.length,
91
+ system,
92
+ x0: Math.min(...own.map((n) => n.inkLeft)),
93
+ x1: Math.max(...own.map((n) => n.inkRight)),
94
+ tail,
95
+ accent: ns.some((n) => n.row.accent),
96
+ arsic: ns[0].row.rhythmicShape === "arsic",
97
+ onTenor: tenorN > ns.length / 2,
98
+ divisio: ns[ns.length - 1].row.divisio != null,
99
+ wordStart: ns[0].row.wordStart,
100
+ });
101
+ }
102
+ return out;
103
+ }
104
+ /** The prosodia track, every system. */
105
+ export function buildProsodia(notes, cfg) {
106
+ if (notes.length === 0)
107
+ return "";
108
+ const k = cfg.k;
109
+ const sylls = prosSyllables(notes);
110
+ const systemYs = new Map();
111
+ for (const n of notes)
112
+ if (!systemYs.has(n.system))
113
+ systemYs.set(n.system, n.systemY);
114
+ const railYOf = (s) => (systemYs.get(s) ?? 0) + cfg.laneTop + P_RAIL * k;
115
+ const wordBaseOf = (s) => (systemYs.get(s) ?? 0) + cfg.laneTop + P_WORD_BASE * k;
116
+ // Per-system extents — tails included, so a system whose only prosodia ink
117
+ // is a wrapped melisma's continuation still gets its rule and rail.
118
+ const extent = new Map();
119
+ const grow = (s, x0, x1) => {
120
+ const e = extent.get(s) ?? { min: Infinity, max: -Infinity };
121
+ e.min = Math.min(e.min, x0);
122
+ e.max = Math.max(e.max, x1);
123
+ extent.set(s, e);
124
+ };
125
+ for (const s of sylls) {
126
+ grow(s.system, s.x0, s.x1);
127
+ if (s.tail)
128
+ grow(s.tail.system, s.tail.x0, s.tail.x1);
129
+ }
130
+ const g = ['<g class="prosodia">'];
131
+ // ── the rule and the rail, per system ──
132
+ for (const [s, e] of extent) {
133
+ const xL = e.min - 2 * k;
134
+ const xR = Math.min(e.max + 2 * k, cfg.rightFor(s));
135
+ const yRule = wordBaseOf(s) - P_TENT_PLAIN * k;
136
+ g.push(`<line x1="${xL.toFixed(1)}" y1="${yRule.toFixed(1)}" x2="${xR.toFixed(1)}" y2="${yRule.toFixed(1)}" ` +
137
+ `stroke="${INK}" stroke-opacity="${STRATUM.rule}" stroke-width="${sc(0.5 * k)}"/>`);
138
+ const yRail = railYOf(s);
139
+ g.push(`<line x1="${xL.toFixed(1)}" y1="${yRail.toFixed(1)}" x2="${xR.toFixed(1)}" y2="${yRail.toFixed(1)}" ` +
140
+ `stroke="${INK}" stroke-opacity="${STRATUM.rail}" stroke-width="${sc(0.5 * k)}"/>`);
141
+ }
142
+ // ── the fence: stems, tenor dashes, the melisma ridge, divisio ──
143
+ const mel = sylls.filter((s) => s.n >= 4);
144
+ const melBySys = new Map();
145
+ for (const m of mel) {
146
+ const a = melBySys.get(m.system) ?? [];
147
+ a.push(m);
148
+ melBySys.set(m.system, a);
149
+ }
150
+ for (const [s, arr] of melBySys) {
151
+ arr.sort((a, b) => a.x0 - b.x0);
152
+ const yB = railYOf(s);
153
+ const pts = arr.map((p) => [(p.x0 + p.x1) / 2, prosH(p.n, k)]);
154
+ // The ridge: piecewise-linear through the peaks, flat beyond the ends.
155
+ const ridge = (x) => {
156
+ if (x <= pts[0][0])
157
+ return pts[0][1];
158
+ if (x >= pts[pts.length - 1][0])
159
+ return pts[pts.length - 1][1];
160
+ for (let i = 1; i < pts.length; i++) {
161
+ if (x <= pts[i][0]) {
162
+ const [xa, ha] = pts[i - 1];
163
+ const [xb, hb] = pts[i];
164
+ return ha + (hb - ha) * ((x - xa) / (xb - xa));
165
+ }
166
+ }
167
+ return pts[pts.length - 1][1];
168
+ };
169
+ arr.forEach((p, i) => {
170
+ const xc = (p.x0 + p.x1) / 2;
171
+ const h = prosH(p.n, k);
172
+ // The block: the area under the ridge across its real extent.
173
+ g.push(`<path d="M${p.x0.toFixed(1)} ${yB.toFixed(1)} ` +
174
+ `L${p.x0.toFixed(1)} ${(yB - ridge(p.x0)).toFixed(1)} ` +
175
+ `L${xc.toFixed(1)} ${(yB - h).toFixed(1)} ` +
176
+ `L${p.x1.toFixed(1)} ${(yB - ridge(p.x1)).toFixed(1)} ` +
177
+ `L${p.x1.toFixed(1)} ${yB.toFixed(1)} Z" fill="${INK}" fill-opacity="${STRATUM.block}"/>`);
178
+ if (p.n >= 8) {
179
+ g.push(`<text x="${xc.toFixed(1)}" y="${(yB - h / 2 + 2.6 * k).toFixed(1)}" ` +
180
+ `font-size="${sc(7 * k)}" text-anchor="middle" fill="${INK}" ` +
181
+ `opacity="${STRATUM.letters}" font-family="${esc(HOUSE_MONO)}">${p.n}</text>`);
182
+ }
183
+ // The thread: the same ridge, crossing the gap to the next block.
184
+ if (i + 1 < arr.length) {
185
+ const q = arr[i + 1];
186
+ g.push(`<line x1="${p.x1.toFixed(1)}" y1="${(yB - ridge(p.x1)).toFixed(1)}" ` +
187
+ `x2="${q.x0.toFixed(1)}" y2="${(yB - ridge(q.x0)).toFixed(1)}" ` +
188
+ `stroke="${INK}" stroke-opacity="${STRATUM.rail}" stroke-width="${sc(0.5 * k)}"/>`);
189
+ }
190
+ });
191
+ }
192
+ for (const p of sylls) {
193
+ const yB = railYOf(p.system);
194
+ if (p.n < 4) {
195
+ if (p.onTenor) {
196
+ // Recitation is flatness: the syllable lies down.
197
+ g.push(`<line x1="${p.x0.toFixed(1)}" y1="${(yB - 3 * k).toFixed(1)}" ` +
198
+ `x2="${Math.min(p.x1, p.x0 + 9 * k).toFixed(1)}" y2="${(yB - 3 * k).toFixed(1)}" ` +
199
+ `stroke="${INK}" stroke-opacity="${STRATUM.letters}" stroke-width="${sc(P_STEM_W * k)}"/>`);
200
+ }
201
+ else {
202
+ g.push(`<line x1="${p.x0.toFixed(1)}" y1="${yB.toFixed(1)}" ` +
203
+ `x2="${p.x0.toFixed(1)}" y2="${(yB - prosH(p.n, k)).toFixed(1)}" ` +
204
+ `stroke="${INK}" stroke-opacity="${STRATUM.letters}" stroke-width="${sc(P_STEM_W * k)}"/>`);
205
+ }
206
+ }
207
+ if (p.divisio) {
208
+ // One hairline through both lanes, ending exactly at the rail.
209
+ const yTop = wordBaseOf(p.system) - P_TENT_ACC * k;
210
+ g.push(`<line x1="${(p.x1 + 4 * k).toFixed(1)}" y1="${yTop.toFixed(1)}" ` +
211
+ `x2="${(p.x1 + 4 * k).toFixed(1)}" y2="${yB.toFixed(1)}" ` +
212
+ `stroke="${INK}" stroke-opacity="${STRATUM.rail}" stroke-width="${sc(0.5 * k)}"/>`);
213
+ }
214
+ }
215
+ const words = [];
216
+ for (const s of sylls) {
217
+ if (s.wordStart || words.length === 0)
218
+ words.push([]);
219
+ words[words.length - 1].push(s);
220
+ }
221
+ for (const word of words) {
222
+ const segs = new Map();
223
+ const seg = (s) => {
224
+ const e = segs.get(s) ?? { x0: Infinity, x1: -Infinity, accX: null, arsic: false };
225
+ segs.set(s, e);
226
+ return e;
227
+ };
228
+ for (const s of word) {
229
+ const e = seg(s.system);
230
+ e.x0 = Math.min(e.x0, s.x0);
231
+ e.x1 = Math.max(e.x1, s.x1);
232
+ if (s.accent) {
233
+ e.accX = s.x0;
234
+ e.arsic = s.arsic;
235
+ }
236
+ if (s.tail) {
237
+ const te = seg(s.tail.system);
238
+ te.x0 = Math.min(te.x0, s.tail.x0);
239
+ te.x1 = Math.max(te.x1, s.tail.x1);
240
+ }
241
+ }
242
+ for (const [s, e] of segs) {
243
+ const yW = wordBaseOf(s);
244
+ // The crest: an accented word rises past the rule; a plain one touches
245
+ // it. Narrow words tent gently rather than spiking.
246
+ const target = (e.accX != null ? P_TENT_ACC : P_TENT_PLAIN) * k;
247
+ const hT = Math.max(4 * k, Math.min(target, (e.x1 - e.x0) * 0.55));
248
+ const yT = yW - hT;
249
+ const xa = e.accX != null
250
+ ? Math.min(Math.max(e.accX, e.x0 + 3 * k), e.x1 - 3 * k)
251
+ : (e.x0 + e.x1) / 2;
252
+ // Straight sides, a slight roundness at the crest: a small quadratic
253
+ // cap tangent to both slopes. The rubrica dot sits on the curve's own
254
+ // peak (the quadratic midpoint), not the sharp corner it replaced.
255
+ const lw = xa - e.x0;
256
+ const rw = e.x1 - xa;
257
+ const r = Math.min(3.5 * k, lw * 0.4, rw * 0.4);
258
+ let d;
259
+ let yPeak;
260
+ if (r > 0.8 * k) {
261
+ const yL = yW - hT * (1 - r / lw);
262
+ const yR = yW - hT * (1 - r / rw);
263
+ d = `M${e.x0.toFixed(1)} ${yW.toFixed(1)} L${(xa - r).toFixed(1)} ${yL.toFixed(1)} ` +
264
+ `Q${xa.toFixed(1)} ${yT.toFixed(1)} ${(xa + r).toFixed(1)} ${yR.toFixed(1)} ` +
265
+ `L${e.x1.toFixed(1)} ${yW.toFixed(1)}`;
266
+ yPeak = (yL + 2 * yT + yR) / 4;
267
+ }
268
+ else {
269
+ d = `M${e.x0.toFixed(1)} ${yW.toFixed(1)} L${xa.toFixed(1)} ${yT.toFixed(1)} ` +
270
+ `L${e.x1.toFixed(1)} ${yW.toFixed(1)}`;
271
+ yPeak = yT;
272
+ }
273
+ // THE TENT TAKES THE NIB, like the chironomy ribbon and the tonarium
274
+ // sparkline — and it takes the nib the way they do, as a WIDTH THAT
275
+ // VARIES. The word is a swell, and the mark now swells with it: thin
276
+ // where the word begins, thickest at the accent, thin again at its
277
+ // end. That is the hairpin the shape was always drawing in outline
278
+ // only, said in pressure as well.
279
+ //
280
+ // A stroke cannot vary its own width, so the tent is a RIBBON: the
281
+ // same builder the chironomy uses, sampled along the path it used to
282
+ // stroke. `vat` is distance from the accent rather than a velocity —
283
+ // the nib is a pressure law, and what presses here is the accent.
284
+ const pts = [];
285
+ const STEPS = 24;
286
+ for (let i = 0; i <= STEPS; i++) {
287
+ const t = i / STEPS;
288
+ // Walk the two slopes, left of the crest then right of it.
289
+ const x = e.x0 + (e.x1 - e.x0) * t;
290
+ const y = x <= xa
291
+ ? yW - hT * (lw > 0 ? (x - e.x0) / lw : 1)
292
+ : yW - hT * (rw > 0 ? (e.x1 - x) / rw : 1);
293
+ pts.push([x, y]);
294
+ }
295
+ const halfW = Math.max(lw, rw) || 1;
296
+ g.push(`<path d="${ribbonPath(pts, (x) => P_TENT_VN_MIN
297
+ + (P_TENT_VN_MAX - P_TENT_VN_MIN)
298
+ * Math.max(0, 1 - Math.abs(x - xa) / halfW), 1, k)}" fill="${INK}" fill-opacity="${STRATUM.margin}"/>`);
299
+ if (e.accX != null) {
300
+ g.push(e.arsic
301
+ ? `<circle cx="${xa.toFixed(1)}" cy="${yPeak.toFixed(1)}" r="${sc(P_ACC_R * k)}" fill="${cfg.rubricaColor}"/>`
302
+ : `<circle cx="${xa.toFixed(1)}" cy="${yPeak.toFixed(1)}" r="${sc(P_ACC_R * k)}" fill="none" ` +
303
+ `stroke="${cfg.rubricaColor}" stroke-width="${sc(0.9 * k)}"/>`);
304
+ }
305
+ }
306
+ }
307
+ g.push("</g>");
308
+ return g.join("");
309
+ }
310
+ /** Band room the chironomia reserves below each system's lyric line. */
311
+ export function chironomiaExtra(k) {
312
+ return 50 * k;
313
+ }
314
+ /** Group a system's anchors into compound beats and place their crests. */
315
+ function beatsToPts(anchors, k) {
316
+ const beats = [];
317
+ for (let j = 0; j < anchors.length; j++) {
318
+ const a = anchors[j];
319
+ if (a.idx === 1 || beats.length === 0) {
320
+ beats.push({ x0: a.x + 4.5 * k, shape: a.shape, n: 0, endDiv: false, endX: null });
321
+ }
322
+ beats[beats.length - 1].n += 1;
323
+ if (a.end) {
324
+ beats[beats.length - 1].endDiv = true;
325
+ beats[beats.length - 1].endX = a.x + 10 * k;
326
+ }
327
+ }
328
+ const last = anchors[anchors.length - 1].x + 10 * k;
329
+ const pts = [];
330
+ for (let i = 0; i < beats.length; i++) {
331
+ const b = beats[i];
332
+ const x1 = b.endDiv ? b.endX : i < beats.length - 1 ? beats[i + 1].x0 : last;
333
+ const span = x1 - b.x0;
334
+ let amp;
335
+ let up;
336
+ let labs;
337
+ if (b.shape === "arsic") {
338
+ amp = Math.min(8.2 * k + 2.2 * k * (b.n - 1) + span * 0.022, 14.5 * k);
339
+ up = true;
340
+ labs = ["A"];
341
+ }
342
+ else {
343
+ const passing = b.n === 1;
344
+ amp = passing ? 4.4 * k : Math.min(6.6 * k + 1.7 * k * (b.n - 1) + span * 0.017, 10.8 * k);
345
+ up = false;
346
+ labs = passing ? ["PT"] : ["T"];
347
+ }
348
+ pts.push({ x: b.x0 + span * 0.42, amp, labs, up, bx1: x1, div: b.endDiv, y: 0 });
349
+ }
350
+ return { pts, last };
351
+ }
352
+ /** The chiron-12 wave engine: merge, breathe, chain, loop, settle. */
353
+ function waveEngine(ptsIn, last, yM, k, vat, vmax) {
354
+ // Close thetic neighbors merge into one broader trough (never across a div).
355
+ const pts = [];
356
+ for (const p of ptsIn) {
357
+ const prev = pts[pts.length - 1];
358
+ if (prev && !prev.up && !p.up && !prev.div && p.x - prev.x < 38 * k) {
359
+ prev.x = (prev.x + p.x) / 2;
360
+ prev.amp = Math.max(prev.amp, p.amp) + 0.6 * k;
361
+ prev.labs.push(...p.labs);
362
+ prev.bx1 = p.bx1;
363
+ prev.div = p.div;
364
+ }
365
+ else {
366
+ pts.push({ ...p, labs: [...p.labs] });
367
+ }
368
+ }
369
+ // Tight opposite-direction neighbors both relax.
370
+ for (let i = 0; i + 1 < pts.length; i++) {
371
+ if (pts[i + 1].x - pts[i].x < 18 * k && pts[i].up !== pts[i + 1].up) {
372
+ pts[i].amp *= 0.8;
373
+ pts[i + 1].amp *= 0.8;
374
+ }
375
+ }
376
+ for (const p of pts)
377
+ p.y = p.up ? yM - p.amp : yM + p.amp;
378
+ for (let i = 1; i < pts.length; i++) {
379
+ if (pts[i].x - pts[i - 1].x < 13 * k) {
380
+ pts[i].x = Math.min(pts[i - 1].x + 13 * k, pts[i].bx1 + 8 * k);
381
+ }
382
+ }
383
+ const nodes = [["pt", [pts[0].x, pts[0].y]]];
384
+ for (let i = 0; i + 1 < pts.length; i++) {
385
+ const p = pts[i];
386
+ const nxt = pts[i + 1];
387
+ if (p.div) {
388
+ nodes.push(["pt", [p.bx1 + 2 * k, yM]]);
389
+ }
390
+ else if (p.up && nxt.up) {
391
+ const gap = nxt.x - p.x;
392
+ if (gap >= 15 * k && gap < 34 * k)
393
+ nodes.push(["loop", null]);
394
+ else if (gap >= 34 * k) {
395
+ const keep = 0.42 * Math.min(Math.abs(p.y - yM), Math.abs(nxt.y - yM));
396
+ nodes.push(["pt", [(p.x + nxt.x) / 2, yM - keep]]);
397
+ }
398
+ }
399
+ else if (!p.up && !nxt.up) {
400
+ const keep = 0.42 * Math.min(Math.abs(p.y - yM), Math.abs(nxt.y - yM));
401
+ nodes.push(["pt", [(p.x + nxt.x) / 2, yM + keep]]);
402
+ }
403
+ nodes.push(["pt", [nxt.x, nxt.y]]);
404
+ }
405
+ nodes.push(["pt", [last + 8 * k, yM + 1.5 * k]]);
406
+ const chains = [[]];
407
+ for (const [kind, val] of nodes) {
408
+ if (kind === "loop")
409
+ chains.push([]);
410
+ else
411
+ chains[chains.length - 1].push(val);
412
+ }
413
+ // The entry arc — the hand's pick-up into the first beat.
414
+ const samples = [];
415
+ const first = chains[0][0];
416
+ const r = 5.5 * k + 0.55 * Math.abs(first[1] - yM);
417
+ const s0 = [first[0] - 10 * k, yM + 3 * k];
418
+ const cy = yM - r - 3 * k;
419
+ const lead = [first[0] - 8 * k, first[1] + (first[1] < yM ? 4 * k : -4 * k)];
420
+ samples.push(...sampleCubic(s0, [first[0] + r * 0.55, cy], [first[0] - 10 * k - r, cy], lead, 18));
421
+ chains[0] = [lead, ...chains[0]];
422
+ // Chains joined by pick-up loops (the little backward arc between arses).
423
+ for (let ci = 0; ci < chains.length; ci++) {
424
+ samples.push(...crSamples(chains[ci], 8));
425
+ if (ci < chains.length - 1) {
426
+ const a = chains[ci][chains[ci].length - 1];
427
+ const b = chains[ci + 1][0];
428
+ const g2 = Math.max(8 * k, (b[0] - a[0]) * 0.6);
429
+ const ly = Math.max(a[1], b[1]) + g2 * 0.95;
430
+ samples.push(...sampleCubic(a, [b[0] + g2, ly], [a[0] - g2, ly], b, 18));
431
+ }
432
+ }
433
+ // The governing nib at the wave stratum.
434
+ const d = ribbonPath(samples, vat, vmax, k);
435
+ let out = `<g class="chironomia">` +
436
+ `<path d="${d}" fill="${INK}" fill-opacity="${STRATUM.wave}" fill-rule="nonzero"/></g>`;
437
+ // Pierik letters at the crests/troughs (a merged trough lists each thesis).
438
+ const letters = ['<g class="chironomia-letters">'];
439
+ for (const p of pts) {
440
+ const labs = [...new Set(p.labs)];
441
+ for (let j = 0; j < labs.length; j++) {
442
+ const lx = p.x + (j - (labs.length - 1) / 2) * 13 * k;
443
+ const ly = p.up ? p.y - 4 * k : p.y + 11.5 * k;
444
+ letters.push(`<text x="${lx.toFixed(1)}" y="${ly.toFixed(1)}" font-size="${(9 * k).toFixed(1)}" ` +
445
+ `text-anchor="middle" fill="${INK}" opacity="${STRATUM.letters}" ` +
446
+ `font-family="${esc(HOUSE_SERIF)}">${labs[j]}</text>`);
447
+ }
448
+ }
449
+ letters.push("</g>");
450
+ out += letters.join("");
451
+ return out;
452
+ }
453
+ /** The chironomy track: wave + letters, every system. */
454
+ export function buildChironomia(notes, cfg) {
455
+ if (notes.length === 0)
456
+ return "";
457
+ const { k } = cfg;
458
+ const vmax = velocityCeiling(notes.map((n) => n.row.velocity));
459
+ const systems = [...new Set(notes.map((n) => n.system))].sort((a, b) => a - b);
460
+ const out = [];
461
+ for (const s of systems) {
462
+ const sysNotes = notes.filter((n) => n.system === s);
463
+ const yM = sysNotes[0].systemY + cfg.waveMidY;
464
+ // Anchors: one per note; `end` marks the last note of a divisio phrase.
465
+ const anchors = sysNotes.map((n, i) => ({
466
+ x: n.x,
467
+ idx: n.row.rhythmicIndex,
468
+ shape: n.row.rhythmicShape,
469
+ end: (i + 1 >= sysNotes.length || sysNotes[i + 1].row.phraseIndex !== n.row.phraseIndex) &&
470
+ n.row.divisio != null,
471
+ }));
472
+ const velpts = sysNotes.map((n) => [n.x, n.row.velocity ?? 0.3]);
473
+ const vat = velocityAt(velpts);
474
+ const { pts, last } = beatsToPts(anchors, k);
475
+ if (pts.length === 0)
476
+ continue;
477
+ out.push(waveEngine(pts, last, yM, k, vat, vmax));
478
+ }
479
+ return out.join("");
480
+ }
481
+ const LANE_H = 42;
482
+ /** Band room the tonarium reserves below each system's lyric line. */
483
+ export function tonariumExtra(k) {
484
+ return 78 * k;
485
+ }
486
+ const MODE_FINAL = { 1: "D", 2: "D", 3: "E", 4: "E", 5: "F", 6: "F", 7: "G", 8: "G" };
487
+ const RAIL_ORDER = ["D", "E", "F", "G"]; // the finals ladder, D on the bottom
488
+ const ROMAN = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII"];
489
+ // A family's occurrences in one mode must reach this before its in-mode share
490
+ // is printed. Under it the figure is a rumour: one or two chants deciding a
491
+ // percentage that reads like a measurement.
492
+ const SHARE_FLOOR = 10;
493
+ /**
494
+ * What the label says about a cadence: HOW OFTEN THIS FAMILY ENDS A CHANT IN
495
+ * THIS MODE. "3.9%" — the frequency a singer actually meets it at.
496
+ *
497
+ * It used to print a lift, the family's in-mode share over its corpus share
498
+ * ("×2.3"). That number answered a real question — is this close distinctive
499
+ * of this mode — but it read as a verdict it could not support. Measured over
500
+ * the corpus: 89% of cadences score above ×1 and the median is ×2.29, so a
501
+ * "×1.9" that looks like a strong claim is in fact below average. Worse, it
502
+ * ORDERS WRONGLY: in mode 8 the family at ×1.8 ends 3.9% of chants where the
503
+ * one at ×2.3 ends 2.0%, so the more distinctive-looking label marks the
504
+ * rarer close. A share has neither problem — it is directly readable, needs
505
+ * no baseline held in the reader's head, and sorts the way a singer meets
506
+ * them. The lift stays available on the group (`data-lift`) for a caller who
507
+ * wants distinctiveness rather than frequency.
508
+ *
509
+ * The share is read against the CHANT'S mode even where the mode line above
510
+ * shows a governing modulation at that phrase. Deliberate: the question is
511
+ * "how common is this close in this chant's mode?", and re-basing per-phrase
512
+ * would make two adjacent labels incomparable.
513
+ */
514
+ function cadenceLabel(fam, mode) {
515
+ // NO CATALOGUE FAMILY IS ITSELF A MEASUREMENT. CADENTIAE holds the families
516
+ // above a floor of fifty corpus occurrences — 122 of them — so a close that
517
+ // fails to join is not unknown, it is RARER than anything the catalogue
518
+ // records. A third of inked cadences land here, and leaving them bare made
519
+ // the rarest closes look like the ones the analysis had nothing to say
520
+ // about.
521
+ //
522
+ // A WORD, NOT A NUMBER. The shares printed beside it run down to 0.2%, so
523
+ // any numeral here would be read on their scale — and "<1%" put an
524
+ // uncatalogued close ABOVE a catalogued 0.3%, exactly backwards. `rara`
525
+ // cannot be compared to a percentage by accident, agrees with the cadentia
526
+ // it describes, and sits in the Latin the rest of the page already speaks
527
+ // (finalis, tenor, diapason).
528
+ if (!fam)
529
+ return "rara";
530
+ const share = (n) => `${(n * 100).toFixed(1)}%`;
531
+ if (mode == null)
532
+ return share(fam.share);
533
+ const inMode = fam.modes[String(mode)] ?? 0;
534
+ const modeEnds = CADENTIAE_POPULATION.byMode[String(mode)] ?? 0;
535
+ // Too thin to divide, or no mode population: fall back to the corpus share,
536
+ // which is the same KIND of number — a frequency, not a ratio.
537
+ if (inMode < SHARE_FLOOR || !modeEnds)
538
+ return share(fam.share);
539
+ return share(inMode / modeEnds);
540
+ }
541
+ /** The family's lift — its in-mode share against its corpus share. Not shown
542
+ * on the page (see cadenceLabel), but carried on the group so a caller can
543
+ * ask how DISTINCTIVE a close is rather than how common. */
544
+ function cadenceLift(fam, mode) {
545
+ if (!fam || mode == null || !fam.share)
546
+ return null;
547
+ const inMode = fam.modes[String(mode)] ?? 0;
548
+ const modeEnds = CADENTIAE_POPULATION.byMode[String(mode)] ?? 0;
549
+ if (inMode < SHARE_FLOOR || !modeEnds)
550
+ return null;
551
+ return ((inMode / modeEnds) / fam.share).toFixed(2);
552
+ }
553
+ /** The tonarium track, every system. */
554
+ export function buildTonarium(notes, data, cfg) {
555
+ if (notes.length === 0)
556
+ return "";
557
+ const out = [];
558
+ const k = cfg.k;
559
+ const laneH = LANE_H * k;
560
+ // The lane's own axis: the chant's ambitus, compressed. True pitch heights.
561
+ const midis = notes.map((n) => n.row.midi);
562
+ const lo = Math.min(...midis);
563
+ const hi = Math.max(...midis, lo + 1);
564
+ const laneY = (systemY, midi) => systemY + cfg.laneTop + laneH - 4 * k - ((midi - lo) / (hi - lo)) * (laneH - 8 * k);
565
+ const railY = (systemY, letter) => systemY + cfg.laneTop + 33 * k - RAIL_ORDER.indexOf(letter) * 9 * k;
566
+ const vmax = velocityCeiling(notes.map((n) => n.row.velocity));
567
+ const home = data.mode != null && MODE_FINAL[data.mode] ? data.mode : undefined;
568
+ // The governing mode of a phrase: the strongest modulation covering it at or
569
+ // above the floor, else home. Kind rides along — transpositions draw dashed.
570
+ const governing = (p) => {
571
+ let best = null;
572
+ for (const m of data.modulations) {
573
+ if (m.confidence >= 0.4 && m.startPhrase <= p && p <= m.endPhrase &&
574
+ (!best || m.confidence > best.confidence))
575
+ best = m;
576
+ }
577
+ if (best)
578
+ return { mode: best.toMode, conf: best.confidence, kind: best.kind };
579
+ return home != null ? { mode: home, conf: 1, kind: "home" } : null;
580
+ };
581
+ const systems = [...new Set(notes.map((n) => n.system))].sort((a, b) => a - b);
582
+ for (const s of systems) {
583
+ const sysNotes = notes.filter((n) => n.system === s);
584
+ const sysY = sysNotes[0].systemY;
585
+ const xs = sysNotes.map((n) => n.x);
586
+ const xL = Math.min(...xs) - 4 * k;
587
+ const xR = Math.min(Math.max(...xs) + 10 * k, cfg.rightFor(s));
588
+ const g = ['<g class="tonarium">'];
589
+ // ── the mode staff: all four maneriae rails, always ──
590
+ for (const letter of RAIL_ORDER) {
591
+ const y = railY(sysY, letter);
592
+ g.push(`<line x1="${xL.toFixed(1)}" y1="${y.toFixed(1)}" x2="${xR.toFixed(1)}" y2="${y.toFixed(1)}" ` +
593
+ `stroke="${INK}" stroke-opacity="${STRATUM.rail}" stroke-width="${sc(0.55 * k)}"/>`);
594
+ }
595
+ // ── the sparkline: the governing nib at the spark stratum ──
596
+ const velpts = sysNotes.map((n) => [n.x, n.row.velocity ?? 0.3]);
597
+ const vat = velocityAt(velpts);
598
+ const phrases = [...new Set(sysNotes.map((n) => n.row.phraseIndex))];
599
+ const spark = [];
600
+ const samplesByPhrase = new Map();
601
+ for (const p of phrases) {
602
+ const pnotes = sysNotes.filter((n) => n.row.phraseIndex === p);
603
+ const pts = pnotes.map((n) => [n.x, laneY(sysY, n.row.midi)]);
604
+ spark.push(...pts);
605
+ const samples = crSamples(pts, 10);
606
+ samplesByPhrase.set(p, samples);
607
+ if (samples.length < 2)
608
+ continue;
609
+ const d = ribbonPath(samples, vat, vmax, 1);
610
+ g.push(`<path d="${d}" fill="${INK}" fill-opacity="${STRATUM.spark}"/>`);
611
+ }
612
+ // ── the mode line: rubrica, stepping between maneriae rails ──
613
+ const STRIP_Y = railY(sysY, "G") - 5.5 * k;
614
+ const stripX = (x0) => {
615
+ for (const cand of [x0 + 3.5 * k, x0 + 21 * k, x0 + 39 * k]) {
616
+ if (spark.every(([px, py]) => Math.abs(px - cand) > 13 * k || py > STRIP_Y + 4.5 * k))
617
+ return cand;
618
+ }
619
+ return x0 + 3.5 * k;
620
+ };
621
+ const segs = [];
622
+ for (const p of phrases) {
623
+ const gov = governing(p);
624
+ if (!gov)
625
+ continue;
626
+ const px = sysNotes.filter((n) => n.row.phraseIndex === p).map((n) => n.x);
627
+ const prev = segs[segs.length - 1];
628
+ if (prev && prev.mode === gov.mode && prev.kind === gov.kind) {
629
+ prev.x1 = Math.max(...px) + 8 * k;
630
+ }
631
+ else {
632
+ segs.push({ ...gov, x0: Math.min(...px) - 2 * k, x1: Math.max(...px) + 8 * k });
633
+ }
634
+ }
635
+ for (const sg of segs) {
636
+ const y = railY(sysY, MODE_FINAL[sg.mode]);
637
+ const op = 0.55 + 0.45 * sg.conf;
638
+ const dash = sg.kind === "transposition"
639
+ ? ` stroke-dasharray="${sc(4 * k)} ${sc(2.6 * k)}"` : "";
640
+ g.push(`<line x1="${sg.x0.toFixed(1)}" y1="${y.toFixed(1)}" x2="${(sg.x1 - 3 * k).toFixed(1)}" y2="${y.toFixed(1)}" ` +
641
+ `stroke="${cfg.rubricaColor}" stroke-width="${sc(1.05 * k)}" opacity="${op.toFixed(2)}"${dash} stroke-linecap="round"/>`);
642
+ g.push(`<text x="${stripX(sg.x0).toFixed(1)}" y="${STRIP_Y.toFixed(1)}" font-size="${sc(10.5 * k)}" ` +
643
+ `opacity="${op.toFixed(2)}" fill="${cfg.rubricaColor}" font-family="${esc(cfg.serifFamily)}" ` +
644
+ `font-style="italic">${ROMAN[sg.mode]}</text>`);
645
+ }
646
+ // ── cadences: the sparkline's own ending, re-inked at full strength ──
647
+ const yC = sysY + cfg.laneTop + laneH + 8 * k;
648
+ // Close-set labels dodge to a second row instead of colliding.
649
+ const labelRight = [-Infinity, -Infinity];
650
+ for (let ci = 0; ci < data.cadences.length; ci++) {
651
+ const cad = data.cadences[ci];
652
+ if (cad.confidence < CONF_FLOOR)
653
+ continue; // don't ink weak claims
654
+ const fig = sysNotes.filter((n) => n.row.cadenceRef === ci);
655
+ if (fig.length === 0)
656
+ continue;
657
+ // The figure's real extent. This was anchor-to-anchor with a 2k fudge
658
+ // standing in for the ink the mapping had discarded.
659
+ const x0 = Math.min(...fig.map((n) => n.inkLeft));
660
+ const x1 = Math.max(...fig.map((n) => n.inkRight));
661
+ const op = 0.45 + 0.5 * cad.confidence;
662
+ const fam = cad.signature ? cadentiaFamilia(cad.signature) : undefined;
663
+ // The score builder already joined finality; read it rather than
664
+ // re-deriving. A cadence handed in straight from the detector carries
665
+ // null here, and falls back to the modal target — see TrackData.cadences.
666
+ const closes = (cad.finality ?? (cad.target === "finalis" ? 1 : 0)) >= 0.5;
667
+ // The family key rides the group, not the page: the label now carries
668
+ // the measure, so the NAME lives here — machine-readable, the join back
669
+ // to CADENTIAE, and the provenance a margin gloss can print.
670
+ const lift = cadenceLift(fam, data.mode);
671
+ g.push(cad.signature
672
+ ? `<g data-cadentia="${esc(cad.signature)}"${lift ? ` data-lift="${lift}"` : ""}>`
673
+ : "<g>");
674
+ // The figure's slice of its phrase's own samples — the same curve at
675
+ // the same width, the ink change alone marking the claim.
676
+ const samples = (samplesByPhrase.get(fig[0].row.phraseIndex) ?? [])
677
+ .filter(([px]) => px >= x0 && px <= x1 + 2 * k);
678
+ // Where the cadence LANDS — the closing dot, which the label centres on.
679
+ let dot;
680
+ if (samples.length >= 2) {
681
+ const d = ribbonPath(samples, vat, vmax, 1);
682
+ g.push(`<path d="${d}" fill="${INK}" fill-opacity="${(STRATUM.cadence * op).toFixed(2)}"/>`);
683
+ const [nx, ny] = samples[samples.length - 1];
684
+ dot = nx;
685
+ const r = sc(1.8 * k);
686
+ g.push(closes
687
+ ? `<circle cx="${nx.toFixed(1)}" cy="${ny.toFixed(1)}" r="${r}" fill="${INK}" opacity="${op.toFixed(2)}"/>`
688
+ : `<circle cx="${nx.toFixed(1)}" cy="${ny.toFixed(1)}" r="${r}" fill="none" stroke="${INK}" ` +
689
+ `stroke-width="${sc(0.9 * k)}" opacity="${op.toFixed(2)}"/>`);
690
+ }
691
+ // The label: how characteristic this close is OF THIS CHANT'S MODE —
692
+ // the family's in-mode share over its corpus share ("×2.1"). The raw
693
+ // key is the family's name, but a name is not the question the diagram
694
+ // asks; it moves to the margin (data-cadentia on the group) and the
695
+ // reader gets the measure instead. It always FOLLOWS its figure; at the
696
+ // system's edge it clamps to the margin rather than jumping to the
697
+ // figure's other side. A light end-ticked bracket ties it to the span.
698
+ const lab = cadenceLabel(fam, data.mode);
699
+ if (lab) {
700
+ // The label sits UNDER THE CLOSING DOT, centred on it. The dot is
701
+ // where the cadence lands — the one point the measure is about — so
702
+ // the number belongs beneath it rather than trailing the figure at
703
+ // the end of a bracket. The bracket is gone with it: it drew the
704
+ // figure's span, which the re-inked sparkline above already draws,
705
+ // and two marks for one extent read as two claims.
706
+ const estW = lab.length * 5.6 * k; // 9px mono advance, measured
707
+ const dotX = dot ?? x1;
708
+ const left = xL;
709
+ const right = cfg.rightFor(s) - 2 * k;
710
+ // Centred, then clamped into the system rather than allowed to hang
711
+ // off either edge.
712
+ const anchor = Math.max(left, Math.min(dotX - estW / 2, right - estW));
713
+ const span = [anchor, anchor + estW];
714
+ const row = span[0] < labelRight[0] + 8 * k ? 1 : 0;
715
+ labelRight[row] = Math.max(labelRight[row], span[1]);
716
+ const yRow = yC + row * 9.5 * k;
717
+ g.push(`<text x="${anchor.toFixed(1)}" y="${(yRow + 2.8 * k).toFixed(1)}" font-size="${sc(9 * k)}" ` +
718
+ `opacity="${(STRATUM.label * op).toFixed(2)}" fill="${INK}" ` +
719
+ `font-family="${esc(HOUSE_MONO)}">${esc(lab)}</text>`);
720
+ }
721
+ g.push("</g>");
722
+ }
723
+ g.push("</g>");
724
+ out.push(g.join(""));
725
+ }
726
+ return out.join("");
727
+ }
728
+ //# sourceMappingURL=tracks.js.map