@sudobility/music_drawing 0.0.1

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 (57) hide show
  1. package/dist/canvas-renderer.d.ts +180 -0
  2. package/dist/canvas-renderer.d.ts.map +1 -0
  3. package/dist/canvas-renderer.js +831 -0
  4. package/dist/canvas-renderer.js.map +1 -0
  5. package/dist/convert.d.ts +122 -0
  6. package/dist/convert.d.ts.map +1 -0
  7. package/dist/convert.js +426 -0
  8. package/dist/convert.js.map +1 -0
  9. package/dist/display-timing.d.ts +68 -0
  10. package/dist/display-timing.d.ts.map +1 -0
  11. package/dist/display-timing.js +192 -0
  12. package/dist/display-timing.js.map +1 -0
  13. package/dist/icon-canvas.d.ts +12 -0
  14. package/dist/icon-canvas.d.ts.map +1 -0
  15. package/dist/icon-canvas.js +62 -0
  16. package/dist/icon-canvas.js.map +1 -0
  17. package/dist/index.d.ts +30 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +30 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/layout.d.ts +159 -0
  22. package/dist/layout.d.ts.map +1 -0
  23. package/dist/layout.js +362 -0
  24. package/dist/layout.js.map +1 -0
  25. package/dist/measure-content.d.ts +114 -0
  26. package/dist/measure-content.d.ts.map +1 -0
  27. package/dist/measure-content.js +553 -0
  28. package/dist/measure-content.js.map +1 -0
  29. package/dist/note-color.d.ts +48 -0
  30. package/dist/note-color.d.ts.map +1 -0
  31. package/dist/note-color.js +81 -0
  32. package/dist/note-color.js.map +1 -0
  33. package/dist/pagination.d.ts +57 -0
  34. package/dist/pagination.d.ts.map +1 -0
  35. package/dist/pagination.js +152 -0
  36. package/dist/pagination.js.map +1 -0
  37. package/dist/percussion.d.ts +47 -0
  38. package/dist/percussion.d.ts.map +1 -0
  39. package/dist/percussion.js +172 -0
  40. package/dist/percussion.js.map +1 -0
  41. package/dist/playhead.d.ts +37 -0
  42. package/dist/playhead.d.ts.map +1 -0
  43. package/dist/playhead.js +89 -0
  44. package/dist/playhead.js.map +1 -0
  45. package/dist/test/canvas-stub.d.ts +21 -0
  46. package/dist/test/canvas-stub.d.ts.map +1 -0
  47. package/dist/test/canvas-stub.js +51 -0
  48. package/dist/test/canvas-stub.js.map +1 -0
  49. package/dist/test/fixtures.d.ts +47 -0
  50. package/dist/test/fixtures.d.ts.map +1 -0
  51. package/dist/test/fixtures.js +433 -0
  52. package/dist/test/fixtures.js.map +1 -0
  53. package/dist/types.d.ts +74 -0
  54. package/dist/types.d.ts.map +1 -0
  55. package/dist/types.js +9 -0
  56. package/dist/types.js.map +1 -0
  57. package/package.json +62 -0
@@ -0,0 +1,831 @@
1
+ /**
2
+ * Windowed canvas score renderer (spec: canvas-notation-renderer design):
3
+ * draws ONLY the systems intersecting the caller's viewport into a
4
+ * caller-managed 2D context, through VexFlow's `CanvasContext`. Per-frame
5
+ * cost is O(visible systems); the one O(n) pass (`computeLayout`) is cached
6
+ * and reused until score/zoom/layout-mode/width/trackIds change — a
7
+ * viewport change alone never recomputes layout.
8
+ *
9
+ * No DOM anywhere: bounding boxes come from the VexFlow objects themselves
10
+ * (`element.getBoundingBox()`), in zoom-scaled CSS px, content coordinates.
11
+ * The theme foreground is set as the context's fill/stroke before drawing.
12
+ * A draw failure in one system logs and skips that system, and the rest
13
+ * still draw — a corrupt measure must not blank the whole canvas.
14
+ *
15
+ * Pure canvas adapter: no store/React imports (spec §3, §37).
16
+ */
17
+ import { CanvasContext, Formatter, Stave, StaveConnector, } from 'vexflow';
18
+ import { noteColorFor, noteEmphasisFor, resolveNoteColorRole, } from './note-color.js';
19
+ import { trackInstrumentIcon } from '@sudobility/music_types';
20
+ import { strokeInstrumentIcon } from './icon-canvas.js';
21
+ import { buildMeasureContent, buildGlissandos, buildHairpins, buildOttavas, buildSlurs, buildTies, } from './measure-content.js';
22
+ import { MEASURE_HEADER_HEIGHT, STAVE_HEIGHT, STAVE_TOP_LINE_OFFSET, TRACK_INFO_WIDTH, computeLayout, resolveZoom, } from './layout.js';
23
+ import { barNumberAt } from '@sudobility/music_types';
24
+ /** Width kept free at the end of each measure's note area so the final glyph never crosses the barline — see the joint-format comment in `drawSystem`. */
25
+ const BARLINE_CLEARANCE = 12;
26
+ /** Measure-number type, and where it sits inside the gutter band. */
27
+ const GUTTER_FONT = '11px sans-serif';
28
+ const GUTTER_FONT_SELECTED = 'bold 11px sans-serif';
29
+ const GUTTER_TEXT_INSET = 3;
30
+ /** Distance from the band's bottom edge up to the text baseline, so numbers sit just above the stave. */
31
+ const GUTTER_TEXT_BASELINE_INSET = 5;
32
+ /** Selected-measure tint opacity: enough to read as "selected", light enough to keep the notes over it legible. */
33
+ const MEASURE_SELECTION_ALPHA = 0.16;
34
+ /**
35
+ * Track-info gutter type and insets.
36
+ *
37
+ * Sized for the 220px column `layout.ts` reserves rather than for the margin
38
+ * this text used to live in: at 11-12px it read as a caption on a panel that
39
+ * has room for a label, and the instrument line in particular is something you
40
+ * check while reading the staff beside it.
41
+ */
42
+ const TRACK_INFO_NAME_FONT = 'bold 17px sans-serif';
43
+ const TRACK_INFO_DETAIL_FONT = '15px sans-serif';
44
+ /** Side of the square the instrument's line art is drawn into. */
45
+ const TRACK_INFO_ICON_SIZE = 20;
46
+ /** Space the instrument icon occupies before its name. */
47
+ const TRACK_INFO_ICON_WIDTH = 28;
48
+ const TRACK_INFO_INSET = 10;
49
+ /** Gap from the stave's top line up to the track name's baseline. */
50
+ const TRACK_INFO_NAME_GAP = 9;
51
+ /** Gap from the instrument row's baseline down to the mute/solo row's. */
52
+ const TRACK_INFO_LINE_GAP = 20;
53
+ /** Cap height as a fraction of font size, where the canvas cannot report it. */
54
+ const FALLBACK_CAP_RATIO = 0.72;
55
+ /**
56
+ * Where the instrument row's text and icon go, relative to the stave's top line.
57
+ *
58
+ * Two rules, and they fix everything else:
59
+ *
60
+ * - the **top of the instrument name** sits on the stave's top line, and
61
+ * - the **icon's centre** sits on the name's centre.
62
+ *
63
+ * "Top of the name" means the top of its capitals, not the top of the font's
64
+ * em box — the em box carries room for accents that nothing here draws, so
65
+ * aligning to it leaves a visible gap that reads as misalignment. Hence
66
+ * `capHeight`, measured from the canvas rather than assumed: a baseline placed
67
+ * by arithmetic put the icon on the line and the text five pixels under it.
68
+ *
69
+ * Pure, and exported, because this is the part worth pinning exactly — the
70
+ * drawing around it is not.
71
+ */
72
+ export function trackInfoRowLayout(staveTopLine, capHeight, iconSize) {
73
+ // With an alphabetic baseline the capitals rise `capHeight` above it, so
74
+ // putting their top on the line means dropping the baseline by that much.
75
+ const textBaseline = staveTopLine + capHeight;
76
+ const rowCenter = staveTopLine + capHeight / 2;
77
+ return { textBaseline, rowCenter, iconTop: rowCenter - iconSize / 2 };
78
+ }
79
+ /** The height of a capital in the context's current font, from the canvas itself. */
80
+ function capHeightOf(ctx, fallbackFontSize) {
81
+ // 'H' rather than the name itself: a per-string measurement moves the row
82
+ // whenever a track is renamed, and every instrument name starts with a capital.
83
+ const metrics = ctx.measureText('H');
84
+ const ascent = metrics?.actualBoundingBoxAscent ?? metrics?.fontBoundingBoxAscent;
85
+ return ascent && ascent > 0 ? ascent : fallbackFontSize * FALLBACK_CAP_RATIO;
86
+ }
87
+ export class CanvasScoreRenderer {
88
+ constructor() {
89
+ this.cache = null;
90
+ /**
91
+ * The last frame's built objects, reused when only colour changed.
92
+ *
93
+ * The single most expensive thing this renderer does is construct and format
94
+ * VexFlow objects, and playback asks it to redraw on every note boundary to
95
+ * move one notehead's colour. Keyed on everything geometry depends on —
96
+ * score identity, zoom, layout mode, width, track set, viewport — and
97
+ * deliberately *not* on the theme, the note colours, the active track or the
98
+ * selection, which are exactly what a repaint changes.
99
+ *
100
+ * One frame, not an LRU: during playback the viewport is still, so
101
+ * consecutive repaints hit it; scrolling misses it and rebuilds, which is the
102
+ * same work it did before. Bounded by construction, since it only ever holds
103
+ * what was last drawn.
104
+ */
105
+ this.frame = null;
106
+ }
107
+ planFor(score, options) {
108
+ const key = JSON.stringify([
109
+ options.zoom,
110
+ options.layoutMode,
111
+ options.width,
112
+ options.trackIds ?? null,
113
+ ]);
114
+ if (this.cache && this.cache.score === score && this.cache.key === key)
115
+ return this.cache.plan;
116
+ const plan = computeLayout(score, options);
117
+ this.cache = { key, score, plan };
118
+ return plan;
119
+ }
120
+ render(score, ctx, options) {
121
+ const z = resolveZoom(options.zoom);
122
+ const dpr = options.devicePixelRatio ?? 1;
123
+ const plan = this.planFor(score, options);
124
+ const viewportLeft = options.viewport.left ?? 0;
125
+ const viewportRight = options.viewport.right ?? Number.POSITIVE_INFINITY;
126
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
127
+ ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
128
+ // `+ 0` normalizes -0 (from a zero scroll offset) to 0.
129
+ ctx.setTransform(z * dpr, 0, 0, z * dpr, -viewportLeft * z * dpr + 0, -options.viewport.top * z * dpr + 0);
130
+ const vexCtx = new CanvasContext(ctx);
131
+ vexCtx.setFillStyle(options.theme.foreground);
132
+ vexCtx.setStrokeStyle(options.theme.foreground);
133
+ const visibleSystems = plan.systems.filter(s => s.yBottom >= options.viewport.top && s.yTop <= options.viewport.bottom);
134
+ // Everything geometry depends on, and nothing colour does. A repaint driven
135
+ // by a note starting or a selection moving produces the same key and skips
136
+ // straight to painting.
137
+ const frameKey = JSON.stringify([
138
+ options.zoom,
139
+ options.layoutMode,
140
+ options.width,
141
+ options.trackIds ?? null,
142
+ options.showTrackInfo ?? true,
143
+ options.viewport.top,
144
+ options.viewport.bottom,
145
+ viewportLeft,
146
+ viewportRight === Number.POSITIVE_INFINITY ? 'inf' : viewportRight,
147
+ visibleSystems.map(s => s.measureIndices[0]),
148
+ ]);
149
+ const reusable = this.frame && this.frame.key === frameKey && this.frame.score === score;
150
+ /** trackId -> voiceOrdinal -> accumulated channel, across every drawn system in order (cross-system tie continuity within the window). */
151
+ let channelsByTrack;
152
+ let drawings;
153
+ let idToBBox;
154
+ // `reusable` is only ever true when `this.frame` is set — it is derived
155
+ // from it — but the compiler cannot see that through the intermediate
156
+ // boolean, so the frame is re-read here rather than asserted.
157
+ const cached = reusable ? this.frame : null;
158
+ if (cached) {
159
+ ({ channelsByTrack, drawings, idToBBox } = cached);
160
+ }
161
+ else {
162
+ channelsByTrack = new Map();
163
+ for (const track of plan.tracks)
164
+ channelsByTrack.set(track.id, new Map());
165
+ drawings = [];
166
+ for (const system of visibleSystems) {
167
+ try {
168
+ drawings.push(this.buildSystem(system, plan, score, z, channelsByTrack, viewportLeft, viewportRight));
169
+ }
170
+ catch (error) {
171
+ // One corrupt system must not blank the rest of the sheet.
172
+ console.error('CanvasScoreRenderer: skipping system after draw failure', system.measureIndices, error);
173
+ }
174
+ }
175
+ idToBBox = new Map();
176
+ this.frame = {
177
+ key: frameKey,
178
+ score,
179
+ theme: options.theme,
180
+ drawings,
181
+ channelsByTrack,
182
+ idToBBox,
183
+ };
184
+ }
185
+ const measureIdToBBox = new Map();
186
+ const drawnMeasureIndices = new Set();
187
+ // Which staves this frame actually put ink on. Ties are drawn after every
188
+ // system, so they need the whole frame's answer, not one system's.
189
+ const drawnStaves = new Set();
190
+ for (const drawing of drawings) {
191
+ for (const [id, box] of drawing.measureIdToBBox)
192
+ measureIdToBBox.set(id, box);
193
+ for (const index of drawing.drawnMeasureIndices)
194
+ drawnMeasureIndices.add(index);
195
+ try {
196
+ this.paintSystem(drawing, plan, vexCtx, ctx, channelsByTrack, options, drawnStaves);
197
+ }
198
+ catch (error) {
199
+ console.error('CanvasScoreRenderer: skipping system after draw failure', drawing.system.measureIndices, error);
200
+ }
201
+ }
202
+ // Ties span measures (and systems) within the drawn window; draw last, on top.
203
+ for (const [trackId, channels] of channelsByTrack) {
204
+ const tieColor = this.trackColor(this.notesDimmed(trackId, options), options);
205
+ for (const channel of channels.values()) {
206
+ // A tie takes its Y from the notes it joins, so both must have been
207
+ // drawn. Culling here is what the rest of the paint path already does
208
+ // per stave; ties were the one thing exempt, and a tie onto an
209
+ // off-screen stave threw `NoYValues` on every frame of a tall score.
210
+ const ties = buildTies(channel, note => {
211
+ const stave = note.getStave();
212
+ return !!stave && drawnStaves.has(stave);
213
+ });
214
+ // Per tie, not per channel: one bad tie used to abandon every
215
+ // remaining tie in the channel, so visible ties vanished too.
216
+ for (const tie of ties) {
217
+ try {
218
+ tie.setStyle({ fillStyle: tieColor, strokeStyle: tieColor });
219
+ tie.setContext(vexCtx);
220
+ tie.draw();
221
+ }
222
+ catch (error) {
223
+ console.error('CanvasScoreRenderer: skipping a tie after draw failure', error);
224
+ }
225
+ }
226
+ // Phrase marks, drawn the same way and for the same reasons: culled to
227
+ // drawn staves, and each guarded on its own so one failure does not
228
+ // take the rest of the channel's curves with it.
229
+ const slurs = buildSlurs(channel, note => {
230
+ const stave = note.getStave();
231
+ return !!stave && drawnStaves.has(stave);
232
+ });
233
+ for (const slur of slurs) {
234
+ try {
235
+ slur.setStyle({ fillStyle: tieColor, strokeStyle: tieColor });
236
+ slur.setContext(vexCtx);
237
+ slur.draw();
238
+ }
239
+ catch (error) {
240
+ console.error('CanvasScoreRenderer: skipping a slur after draw failure', error);
241
+ }
242
+ }
243
+ // Hairpins, on the same terms: culled to drawn staves and guarded one
244
+ // at a time, so a wedge that cannot be positioned does not take the
245
+ // rest of the channel's dynamics with it.
246
+ const hairpins = buildHairpins(channel, note => {
247
+ const stave = note.getStave();
248
+ return !!stave && drawnStaves.has(stave);
249
+ });
250
+ // Octave brackets and slides, culled and guarded on the same terms
251
+ // as the curves and wedges above.
252
+ for (const span of [
253
+ ...buildOttavas(channel, note => {
254
+ const stave = note.getStave();
255
+ return !!stave && drawnStaves.has(stave);
256
+ }),
257
+ ...buildGlissandos(channel, note => {
258
+ const stave = note.getStave();
259
+ return !!stave && drawnStaves.has(stave);
260
+ }),
261
+ ]) {
262
+ try {
263
+ span.setContext(vexCtx);
264
+ span.draw();
265
+ }
266
+ catch (error) {
267
+ console.error('CanvasScoreRenderer: skipping an octave bracket or slide after draw failure', error);
268
+ }
269
+ }
270
+ for (const hairpin of hairpins) {
271
+ try {
272
+ hairpin.setContext(vexCtx);
273
+ hairpin.draw();
274
+ }
275
+ catch (error) {
276
+ console.error('CanvasScoreRenderer: skipping a hairpin after draw failure', error);
277
+ }
278
+ }
279
+ }
280
+ }
281
+ if (!reusable) {
282
+ // After painting, not before: VexFlow reports a bounding box only once an
283
+ // object has been drawn, so recording these off freshly-built objects
284
+ // yielded zeros. They are geometry, so they are computed once per built
285
+ // frame and reused by every repaint of it.
286
+ for (const channels of channelsByTrack.values()) {
287
+ for (const channel of channels.values()) {
288
+ for (const entry of channel) {
289
+ this.recordEventBBox(entry.note, entry.meta, z, idToBBox);
290
+ }
291
+ }
292
+ }
293
+ }
294
+ // Last, so it overlays any content that scrolled underneath it — and not
295
+ // at all for print, where there is nothing to click.
296
+ if (options.showTrackInfo ?? true) {
297
+ this.drawTrackInfoGutter(plan, ctx, z, dpr, visibleSystems, options);
298
+ }
299
+ return {
300
+ idToBBox,
301
+ measureIdToBBox,
302
+ drawnMeasureIndices,
303
+ plan,
304
+ theme: options.theme,
305
+ };
306
+ }
307
+ /**
308
+ * The track-info gutter: name, instrument and mute/solo state beside every
309
+ * stave, for every visible system.
310
+ *
311
+ * Pinned to the viewport's left edge rather than drawn at content x=0. In
312
+ * continuous mode the score is one very wide system scrolled horizontally, so
313
+ * a gutter in content space would slide out of view — the one thing a
314
+ * permanent label column cannot do. The vertical scroll is kept, so the
315
+ * labels still track their staves.
316
+ *
317
+ * Reads `name`/`instrumentName`/`muted`/`solo` straight off the live `Track`,
318
+ * so there is no render option to keep in sync. Alignment is structural: the
319
+ * gutter is the space `layout.ts` reserved via `TRACK_INFO_WIDTH`, in the
320
+ * same coordinate space as the staves.
321
+ */
322
+ drawTrackInfoGutter(plan, ctx, z, dpr, visibleSystems, options) {
323
+ if (plan.trackLayouts.length === 0)
324
+ return;
325
+ const previousFill = ctx.fillStyle;
326
+ const previousStroke = ctx.strokeStyle;
327
+ const previousFont = ctx.font;
328
+ // Same transform, minus the horizontal scroll: pins x, keeps y tracking.
329
+ ctx.setTransform(z * dpr, 0, 0, z * dpr, 0, -options.viewport.top * z * dpr + 0);
330
+ for (const system of visibleSystems) {
331
+ const measureIndex = system.measureIndices[0];
332
+ // `clearRect`, not a fill: clearing shows whatever is behind the canvas,
333
+ // which is exactly the surface the sheet sits on — so the gutter's
334
+ // background matches the sheet's by construction and cannot drift from
335
+ // it the way a theme colour could. It also occludes the content that
336
+ // scrolled underneath, which is the other thing this needs to do.
337
+ ctx.clearRect(0, system.gutterTop, TRACK_INFO_WIDTH, system.yBottom - system.gutterTop);
338
+ for (const trackLayout of plan.trackLayouts) {
339
+ const placement = trackLayout.measures.find(m => m.measureIndex === measureIndex);
340
+ if (!placement)
341
+ continue;
342
+ const track = trackLayout.track;
343
+ const isActive = options.activeTrackId != null && track.id === options.activeTrackId;
344
+ ctx.fillStyle = isActive
345
+ ? options.theme.staveActive
346
+ : options.theme.staveInactive;
347
+ // The instrument row is aligned to the stave's **top line** — the
348
+ // first thing the reader's eye lands on — rather than to the top of the
349
+ // track's box, which is headroom VexFlow reserves and nothing is drawn
350
+ // in. The track name then sits on the line above it, inside that same
351
+ // headroom, so the pair reads downward into the music: whose staff this
352
+ // is, then what it sounds like, then the staff itself.
353
+ const staveTopLine = placement.box.y + STAVE_TOP_LINE_OFFSET;
354
+ // The instrument row first: its own metrics decide where everything in
355
+ // this block sits, including the name above it.
356
+ ctx.font = TRACK_INFO_DETAIL_FONT;
357
+ const row = trackInfoRowLayout(staveTopLine, capHeightOf(ctx, 15), TRACK_INFO_ICON_SIZE);
358
+ // Icon then instrument name, so the glyph reads as a label for the text
359
+ // beside it rather than decoration floating on its own. The icon is
360
+ // stroked, so it takes the same active/inactive colour as the text, and
361
+ // it is centred on the text rather than sharing an edge with it — a
362
+ // 20px square and a 15px capital share no edge that looks deliberate.
363
+ ctx.strokeStyle = ctx.fillStyle;
364
+ strokeInstrumentIcon(ctx,
365
+ // Through the track: on a percussion track `midiProgram` addresses a
366
+ // drum kit, so the melodic art at that number is the wrong picture.
367
+ trackInstrumentIcon(track), TRACK_INFO_INSET, row.iconTop, TRACK_INFO_ICON_SIZE);
368
+ ctx.fillText(track.instrumentName, TRACK_INFO_INSET + TRACK_INFO_ICON_WIDTH, row.textBaseline);
369
+ // The track name goes above that row, in the headroom VexFlow leaves
370
+ // over the staff and draws nothing in.
371
+ ctx.font = TRACK_INFO_NAME_FONT;
372
+ ctx.fillText(track.name, TRACK_INFO_INSET, staveTopLine - TRACK_INFO_NAME_GAP);
373
+ // Mute/solo are the only state here that changes what you hear, so they
374
+ // are worth showing without making the track active first.
375
+ ctx.font = TRACK_INFO_DETAIL_FONT;
376
+ const stateBaseline = row.textBaseline + TRACK_INFO_LINE_GAP;
377
+ if (track.muted)
378
+ ctx.fillText('M', TRACK_INFO_INSET, stateBaseline);
379
+ if (track.solo)
380
+ ctx.fillText('S', TRACK_INFO_INSET + 16, stateBaseline);
381
+ }
382
+ }
383
+ ctx.fillStyle = previousFill;
384
+ ctx.strokeStyle = previousStroke;
385
+ ctx.font = previousFont;
386
+ }
387
+ /**
388
+ * Styles one VexFlow note by the highest-precedence role among the domain
389
+ * events it represents (>1 for a chord, or for one segment of a
390
+ * duration-decomposed long note).
391
+ *
392
+ * Both `fillStyle` and `strokeStyle` are set: noteheads fill, stems and
393
+ * flags stroke, and a note whose stem stayed the default color would read
394
+ * as half-highlighted.
395
+ *
396
+ * `lineWidth` carries the same state redundantly *without* color (spec §27)
397
+ * — see `noteEmphasisFor`, including why this is not a shadow.
398
+ */
399
+ styleNote(note, meta, dimmed, options) {
400
+ const role = resolveNoteColorRole(meta.eventIds, options.noteColors);
401
+ const color = noteColorFor(role, options.theme, !dimmed);
402
+ note.setStyle({
403
+ fillStyle: color,
404
+ strokeStyle: color,
405
+ lineWidth: noteEmphasisFor(role).lineWidth,
406
+ });
407
+ }
408
+ /**
409
+ * Whether `trackId` is *the* active track. With no active track set, nothing
410
+ * is active, so every stave draws in `staveInactive` — the neutral state.
411
+ */
412
+ isActiveTrack(trackId, options) {
413
+ return options.activeTrackId != null && trackId === options.activeTrackId;
414
+ }
415
+ /**
416
+ * Whether a track's notes should dim.
417
+ *
418
+ * Deliberately not `!isActiveTrack`. Dimming is *relative* — it says "this
419
+ * track is not the one you are working on" — so with no active track set
420
+ * there is nothing to be relative to and no note dims. Stave lines can sit in
421
+ * their neutral colour without looking wrong; every note in the score going
422
+ * grey would just look washed out.
423
+ */
424
+ notesDimmed(trackId, options) {
425
+ return options.activeTrackId != null && trackId !== options.activeTrackId;
426
+ }
427
+ /** The unstyled-note color for a track, which its beams and ties follow. */
428
+ trackColor(dimmed, options) {
429
+ return dimmed ? options.theme.noteInactive : options.theme.noteNormal;
430
+ }
431
+ recordEventBBox(note, meta, z, idToBBox) {
432
+ let box = null;
433
+ try {
434
+ const bb = note.getBoundingBox();
435
+ if (bb)
436
+ box = {
437
+ x: bb.getX() * z,
438
+ y: bb.getY() * z,
439
+ width: bb.getW() * z,
440
+ height: bb.getH() * z,
441
+ };
442
+ }
443
+ catch {
444
+ box = null; // a missing bbox only disables clicking this glyph
445
+ }
446
+ if (!box)
447
+ return;
448
+ for (const eventId of meta.eventIds) {
449
+ if (!idToBBox.has(eventId))
450
+ idToBBox.set(eventId, box);
451
+ }
452
+ }
453
+ /**
454
+ * The contiguous slice of `system.measureIndices` whose stave boxes
455
+ * intersect `[left, right]`, found by binary search over the x-sorted
456
+ * measure layouts — O(log n), so a continuous-mode frame never scans the
457
+ * whole single-system score (the horizontal analogue of the y-based
458
+ * system filter).
459
+ */
460
+ visibleMeasureIndices(system, plan, left, right) {
461
+ const indices = system.measureIndices;
462
+ const measures = plan.trackLayouts[0]?.measures;
463
+ if (!measures || indices.length === 0)
464
+ return indices;
465
+ if (left <= 0 && right === Number.POSITIVE_INFINITY)
466
+ return indices;
467
+ // First index whose box right edge reaches `left`.
468
+ let lo = 0;
469
+ let hi = indices.length - 1;
470
+ let start = indices.length;
471
+ while (lo <= hi) {
472
+ const mid = (lo + hi) >> 1;
473
+ const box = measures[indices[mid]]?.box;
474
+ if (!box || box.x + box.width < left)
475
+ lo = mid + 1;
476
+ else {
477
+ start = mid;
478
+ hi = mid - 1;
479
+ }
480
+ }
481
+ // Last index whose box left edge is within `right`.
482
+ lo = start;
483
+ hi = indices.length - 1;
484
+ let end = start - 1;
485
+ while (lo <= hi) {
486
+ const mid = (lo + hi) >> 1;
487
+ const box = measures[indices[mid]]?.box;
488
+ if (!box || box.x <= right) {
489
+ end = mid;
490
+ lo = mid + 1;
491
+ }
492
+ else
493
+ hi = mid - 1;
494
+ }
495
+ return indices.slice(start, end + 1);
496
+ }
497
+ /**
498
+ * Builds one system's VexFlow objects, without deciding what colour anything
499
+ * is.
500
+ *
501
+ * Split from painting so the result can be cached: constructing and
502
+ * formatting notes is what costs milliseconds (measured at 119ms for a
503
+ * twelve-track sixteenth-note score at zoom 0.5), and a colour change — a
504
+ * note starting, a selection moving — changes none of it. See `render`.
505
+ */
506
+ buildSystem(system, plan, score, z, channelsByTrack, viewportLeft, viewportRight) {
507
+ const staves = [];
508
+ const measureIdToBBox = new Map();
509
+ const drawnMeasureIndices = new Set();
510
+ const voicesToDraw = [];
511
+ const beamsToDraw = [];
512
+ const tupletsToDraw = [];
513
+ const restsToDraw = [];
514
+ /** trackIndex -> the system's first-measure stave, for the brace connector. */
515
+ const firstStaveByTrack = new Map();
516
+ const allMetas = []; // buildMeasureContent appends; unused here (channels carry the metas)
517
+ // Measure-first iteration: every track's content for one measure index
518
+ // is built and then formatted through ONE shared Formatter, so
519
+ // simultaneous events land at the same x on every stave (VexFlow's
520
+ // multi-stave contract: joinVoices per stave, one format() over all).
521
+ // Formatting each track's measure independently — the old structure —
522
+ // let a dense track distribute its notes on its own timeline, visually
523
+ // desynchronized from the other tracks' staves.
524
+ const windowIndices = this.visibleMeasureIndices(system, plan, viewportLeft, viewportRight);
525
+ for (const measureIndex of windowIndices) {
526
+ const measureStaves = [];
527
+ const voiceGroups = [];
528
+ plan.trackLayouts.forEach(({ track }, trackIndex) => {
529
+ const placement = plan.trackLayouts[trackIndex].measures[measureIndex];
530
+ const measure = track.measures[measureIndex];
531
+ if (!placement || !measure)
532
+ return;
533
+ // Seeded for every track in the plan just above, so this cannot
534
+ // miss; an empty map is the right answer if a track ever arrived
535
+ // late, and it keeps a missing entry from taking down the frame.
536
+ const channels = channelsByTrack.get(track.id) ?? new Map();
537
+ const prevMeasure = track.measures[measureIndex - 1];
538
+ // The next bar, for deriving where a volta bracket ends. Taken from
539
+ // the *score*, not from what is on screen: a bracket that closed at
540
+ // the edge of the drawn window would change shape as you scroll.
541
+ const nextMeasure = track.measures[measureIndex + 1];
542
+ const { stave, voices, beams, tuplets, multiMeasureRest } = buildMeasureContent(measure, track, measureIndex, placement, prevMeasure, nextMeasure, score.ppq, channels, allMetas);
543
+ // Formatting only. Which colour this stave draws in depends on the
544
+ // active track, which is a paint-time decision — keeping it out of here
545
+ // is what lets the built objects survive a selection change.
546
+ stave.format();
547
+ staves.push({ stave, trackId: track.id });
548
+ measureStaves.push(stave);
549
+ for (const beam of beams)
550
+ beamsToDraw.push({ beam, trackId: track.id });
551
+ for (const tuplet of tuplets)
552
+ tupletsToDraw.push({ tuplet, trackId: track.id });
553
+ if (multiMeasureRest) {
554
+ multiMeasureRest.setStave(stave);
555
+ restsToDraw.push(multiMeasureRest);
556
+ }
557
+ if (measureIndex === system.measureIndices[0])
558
+ firstStaveByTrack.set(trackIndex, stave);
559
+ if (voices.length > 0) {
560
+ voices.forEach(v => v.setStave(stave));
561
+ voiceGroups.push(voices);
562
+ }
563
+ measureIdToBBox.set(measure.id, {
564
+ x: placement.box.x * z,
565
+ y: placement.box.y * z,
566
+ width: placement.box.width * z,
567
+ height: placement.box.height * z,
568
+ });
569
+ if (trackIndex === 0)
570
+ drawnMeasureIndices.add(measureIndex);
571
+ });
572
+ // Align the clef/key/time blocks so every stave's note area starts at
573
+ // the same x, then joint-format to the narrowest note area. The
574
+ // clearance keeps the last event's glyph (notehead + stem/flag, drawn
575
+ // rightward of its tick x) short of the barline: format() justifies
576
+ // ticks across the FULL given width, so without it the final glyph
577
+ // always overhangs into the next measure regardless of measure width.
578
+ if (measureStaves.length > 1)
579
+ Stave.formatBegModifiers(measureStaves);
580
+ if (voiceGroups.length > 0) {
581
+ const formatter = new Formatter();
582
+ for (const group of voiceGroups)
583
+ formatter.joinVoices(group);
584
+ const justifyWidth = Math.max(20, Math.min(...measureStaves.map(s => s.getNoteEndX() - s.getNoteStartX())) - BARLINE_CLEARANCE);
585
+ formatter.format(voiceGroups.flat(), justifyWidth);
586
+ voicesToDraw.push(...voiceGroups.flat());
587
+ }
588
+ }
589
+ return {
590
+ system,
591
+ windowIndices,
592
+ staves,
593
+ voicesToDraw,
594
+ beamsToDraw,
595
+ tupletsToDraw,
596
+ restsToDraw,
597
+ firstStaveByTrack,
598
+ measureIdToBBox,
599
+ drawnMeasureIndices,
600
+ };
601
+ }
602
+ /**
603
+ * Paints an already-built system.
604
+ *
605
+ * Everything here is a colour decision or a context write, so it is what runs
606
+ * again when a note starts sounding or the selection moves — cheap beside the
607
+ * building it no longer has to repeat.
608
+ */
609
+ paintSystem(drawing, plan, vexCtx,
610
+ /** The raw 2D context behind `vexCtx`: the measure gutter is a number and a rect, so it needs nothing VexFlow provides. */
611
+ ctx, channelsByTrack, options,
612
+ /** Collects the staves this system actually drew, for the frame's ties. */
613
+ drawnStaves) {
614
+ const { system, windowIndices, staves, voicesToDraw, beamsToDraw, tupletsToDraw, restsToDraw, firstStaveByTrack, } = drawing;
615
+ this.paintSelectedMeasures(system, plan, ctx, windowIndices, options);
616
+ // Which staves are actually on screen.
617
+ //
618
+ // Culling at *draw* time rather than at build time is the whole trick. The
619
+ // formatter always saw every track in the column, so geometry is identical
620
+ // whatever is visible — an earlier attempt that skipped building off-screen
621
+ // tracks lost their tick contexts and slid the visible notes by a measured
622
+ // 13.5px as you scrolled. Here nothing moves; there is simply less ink.
623
+ //
624
+ // It matters at scale: one system of two hundred tracks is some 24,000px
625
+ // tall, so without this every frame draws two hundred staves to show eight.
626
+ const { top, bottom } = options.viewport;
627
+ const onScreen = (stave) => {
628
+ if (!Number.isFinite(top) && !Number.isFinite(bottom))
629
+ return true;
630
+ const y = stave.getYForLine(0);
631
+ return y + STAVE_HEIGHT >= top && y <= bottom;
632
+ };
633
+ // Color every note in the window before anything draws. `StaveNote.draw`
634
+ // wraps its whole body (noteheads, stem, flag, and its modifiers) in
635
+ // applyStyle/restoreStyle, so one setStyle per note is enough — the
636
+ // accidentals and dots inherit it from the context.
637
+ //
638
+ // Walks the accumulated channels rather than just this measure's notes:
639
+ // entries carried over from earlier systems in the same frame get
640
+ // restyled too, which is idempotent and cheap (channels only ever hold
641
+ // the drawn window, so this stays O(visible)).
642
+ for (const [trackId, channels] of channelsByTrack) {
643
+ const dimmed = this.notesDimmed(trackId, options);
644
+ for (const channel of channels.values()) {
645
+ for (const entry of channel) {
646
+ this.styleNote(entry.note, entry.meta, dimmed, options);
647
+ }
648
+ }
649
+ }
650
+ // Draw order: staves, then notes/voices, then beams on top (ties drawn later, cross-system).
651
+ // Per stave, not once for the frame: a stave's modifiers (clef, key and
652
+ // time signature) draw from the context, so this is the only place they can
653
+ // pick up their track's dimming. Without it a dimmed track kept a
654
+ // full-strength clef sitting over its greyed notes, which read as a
655
+ // rendering fault rather than as an inactive track.
656
+ const visibleStaves = new Set();
657
+ staves.forEach(({ stave, trackId }) => {
658
+ if (!onScreen(stave))
659
+ return;
660
+ visibleStaves.add(stave);
661
+ drawnStaves.add(stave);
662
+ const dimmed = this.notesDimmed(trackId, options);
663
+ stave.setStyle({
664
+ strokeStyle: this.isActiveTrack(trackId, options)
665
+ ? options.theme.staveActive
666
+ : options.theme.staveInactive,
667
+ });
668
+ // Re-bound every frame: a cached stave holds the previous frame's
669
+ // context, and `CanvasContext` is rebuilt per render.
670
+ stave.setContext(vexCtx);
671
+ const color = dimmed
672
+ ? options.theme.noteInactive
673
+ : options.theme.foreground;
674
+ vexCtx.setFillStyle(color);
675
+ vexCtx.setStrokeStyle(color);
676
+ stave.draw();
677
+ });
678
+ vexCtx.setFillStyle(options.theme.foreground);
679
+ vexCtx.setStrokeStyle(options.theme.foreground);
680
+ voicesToDraw.forEach(v => {
681
+ const stave = v.getStave();
682
+ if (stave && !visibleStaves.has(stave))
683
+ return;
684
+ v.draw(vexCtx);
685
+ });
686
+ // After the formatter has run, so the stave's final geometry is settled.
687
+ for (const rest of restsToDraw) {
688
+ const stave = rest.getStave?.();
689
+ if (stave && !visibleStaves.has(stave))
690
+ continue;
691
+ rest.setContext(vexCtx).draw();
692
+ }
693
+ beamsToDraw.forEach(({ beam, trackId }) => {
694
+ const stave = beam.getNotes()[0]?.getStave();
695
+ if (stave && !visibleStaves.has(stave))
696
+ return;
697
+ const color = this.trackColor(this.notesDimmed(trackId, options), options);
698
+ beam.setStyle({ fillStyle: color, strokeStyle: color });
699
+ beam.setContext(vexCtx);
700
+ beam.draw();
701
+ });
702
+ /*
703
+ Brackets and their numbers, drawn after the beams they sit beside and
704
+ culled to visible staves for the same reason everything else is: a
705
+ tuplet takes its position from notes that must already have been drawn.
706
+ */
707
+ tupletsToDraw.forEach(({ tuplet, trackId }) => {
708
+ const stave = tuplet.getNotes()[0]?.getStave();
709
+ if (stave && !visibleStaves.has(stave))
710
+ return;
711
+ const color = this.trackColor(this.notesDimmed(trackId, options), options);
712
+ tuplet.setStyle({ fillStyle: color, strokeStyle: color });
713
+ tuplet.setContext(vexCtx);
714
+ tuplet.draw();
715
+ });
716
+ if (plan.tracks.length > 1) {
717
+ const topStave = firstStaveByTrack.get(0);
718
+ const bottomStave = firstStaveByTrack.get(plan.tracks.length - 1);
719
+ // Culled like everything else. Without this the brace was drawn even
720
+ // when both of the staves it joins were scrolled off — on a tall score
721
+ // it stretched thousands of pixels and the slice inside the viewport
722
+ // read as a stray vertical mark in the corner rather than as notation.
723
+ if (topStave &&
724
+ bottomStave &&
725
+ topStave !== bottomStave &&
726
+ visibleStaves.has(topStave) &&
727
+ visibleStaves.has(bottomStave)) {
728
+ const connector = new StaveConnector(topStave, bottomStave);
729
+ connector.setType('brace');
730
+ connector.setContext(vexCtx);
731
+ connector.draw();
732
+ }
733
+ }
734
+ this.drawMeasureGutter(system, plan, ctx, windowIndices, options);
735
+ }
736
+ /**
737
+ * Tints every selected measure across the full height of the system.
738
+ *
739
+ * The whole bar, not a mark near it: a selection's *extent* is the thing that
740
+ * has to be legible — "these four bars, on every staff" — and the 2px rule
741
+ * this replaces stated that in the one strip of the sheet nobody is looking
742
+ * at. Spanning the system rather than one track's stave is deliberate, and
743
+ * matches what the selection means: the measure gutter is one band per
744
+ * system, and replacing a measure region acts on every track in it.
745
+ *
746
+ * Drawn **before** the staves and notes so it reads as paper the music sits
747
+ * on. Over the top it would tint the noteheads themselves, and a selected
748
+ * bar's notes would not match the same notes anywhere else.
749
+ */
750
+ paintSelectedMeasures(system, plan, ctx, windowIndices, options) {
751
+ const selected = options.selectedMeasureIds;
752
+ if (!selected || selected.size === 0)
753
+ return;
754
+ const measures = plan.trackLayouts[0]?.measures;
755
+ const track = plan.tracks[0];
756
+ if (!measures || !track)
757
+ return;
758
+ const previousFill = ctx.fillStyle;
759
+ const previousAlpha = ctx.globalAlpha;
760
+ ctx.fillStyle = options.theme.noteSelected;
761
+ ctx.globalAlpha = MEASURE_SELECTION_ALPHA;
762
+ for (const measureIndex of windowIndices) {
763
+ const measure = track.measures[measureIndex];
764
+ if (!measure || !selected.has(measure.id))
765
+ continue;
766
+ const box = measures.find(m => m.measureIndex === measureIndex)?.box;
767
+ if (!box)
768
+ continue;
769
+ // From the number band down to the foot of the last stave, so the tint
770
+ // takes in the measure number as well as every track's bar. Full width
771
+ // and flush with the next bar, so a multi-measure selection is one block
772
+ // rather than a row of stripes.
773
+ ctx.fillRect(box.x, system.gutterTop, box.width, system.yBottom - system.gutterTop);
774
+ }
775
+ ctx.globalAlpha = previousAlpha;
776
+ ctx.fillStyle = previousFill;
777
+ }
778
+ /**
779
+ * Measure numbers in the band above the system's top stave, with any
780
+ * measure in `selectedMeasureIds` picked out in the selection colour.
781
+ *
782
+ * Extent is carried by `paintSelectedMeasures`, which tints the bar itself;
783
+ * this only has to say which number belongs to it. It used to draw a 2px rule
784
+ * under the number instead, which was the only thing showing how far a
785
+ * selection reached and was easy to miss entirely.
786
+ *
787
+ * Drawn straight to the 2D context: there is no VexFlow object for "the
788
+ * space above a stave", and a number plus a rect needs none. Measure
789
+ * geometry comes off track 0 because every track shares one measure grid
790
+ * (see `rebuildMeasureTicks`), which is what lets one gutter serve the
791
+ * whole system.
792
+ */
793
+ drawMeasureGutter(system, plan, ctx, windowIndices, options) {
794
+ const measures = plan.trackLayouts[0]?.measures;
795
+ const track = plan.tracks[0];
796
+ if (!measures || !track)
797
+ return;
798
+ const previousFill = ctx.fillStyle;
799
+ const previousFont = ctx.font;
800
+ const baselineY = system.gutterTop + MEASURE_HEADER_HEIGHT - GUTTER_TEXT_BASELINE_INSET;
801
+ for (const measureIndex of windowIndices) {
802
+ const placement = measures.find(m => m.measureIndex === measureIndex);
803
+ const measure = track.measures[measureIndex];
804
+ if (!placement || !measure)
805
+ continue;
806
+ const box = placement.box;
807
+ const selected = options.selectedMeasureIds?.has(measure.id) ?? false;
808
+ ctx.fillStyle = selected
809
+ ? options.theme.noteSelected
810
+ : options.theme.foreground;
811
+ ctx.font = selected ? GUTTER_FONT_SELECTED : GUTTER_FONT;
812
+ /*
813
+ Not `index + 1`: a pickup is not counted, so the bar after one is bar
814
+ 1 and the pickup itself has no number at all. `barNumberAt` answers
815
+ `null` there and nothing is drawn, which is what an engraver does —
816
+ printing "1" over an anacrusis claims it is a bar the player counts.
817
+ */
818
+ const barNumber = barNumberAt(track.measures, measureIndex);
819
+ if (barNumber !== null) {
820
+ ctx.fillText(String(barNumber), box.x + GUTTER_TEXT_INSET, baselineY);
821
+ }
822
+ }
823
+ ctx.fillStyle = previousFill;
824
+ ctx.font = previousFont;
825
+ }
826
+ dispose() {
827
+ this.cache = null;
828
+ this.frame = null;
829
+ }
830
+ }
831
+ //# sourceMappingURL=canvas-renderer.js.map