@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.
- package/dist/canvas-renderer.d.ts +180 -0
- package/dist/canvas-renderer.d.ts.map +1 -0
- package/dist/canvas-renderer.js +831 -0
- package/dist/canvas-renderer.js.map +1 -0
- package/dist/convert.d.ts +122 -0
- package/dist/convert.d.ts.map +1 -0
- package/dist/convert.js +426 -0
- package/dist/convert.js.map +1 -0
- package/dist/display-timing.d.ts +68 -0
- package/dist/display-timing.d.ts.map +1 -0
- package/dist/display-timing.js +192 -0
- package/dist/display-timing.js.map +1 -0
- package/dist/icon-canvas.d.ts +12 -0
- package/dist/icon-canvas.d.ts.map +1 -0
- package/dist/icon-canvas.js +62 -0
- package/dist/icon-canvas.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/layout.d.ts +159 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +362 -0
- package/dist/layout.js.map +1 -0
- package/dist/measure-content.d.ts +114 -0
- package/dist/measure-content.d.ts.map +1 -0
- package/dist/measure-content.js +553 -0
- package/dist/measure-content.js.map +1 -0
- package/dist/note-color.d.ts +48 -0
- package/dist/note-color.d.ts.map +1 -0
- package/dist/note-color.js +81 -0
- package/dist/note-color.js.map +1 -0
- package/dist/pagination.d.ts +57 -0
- package/dist/pagination.d.ts.map +1 -0
- package/dist/pagination.js +152 -0
- package/dist/pagination.js.map +1 -0
- package/dist/percussion.d.ts +47 -0
- package/dist/percussion.d.ts.map +1 -0
- package/dist/percussion.js +172 -0
- package/dist/percussion.js.map +1 -0
- package/dist/playhead.d.ts +37 -0
- package/dist/playhead.d.ts.map +1 -0
- package/dist/playhead.js +89 -0
- package/dist/playhead.js.map +1 -0
- package/dist/test/canvas-stub.d.ts +21 -0
- package/dist/test/canvas-stub.d.ts.map +1 -0
- package/dist/test/canvas-stub.js +51 -0
- package/dist/test/canvas-stub.js.map +1 -0
- package/dist/test/fixtures.d.ts +47 -0
- package/dist/test/fixtures.d.ts.map +1 -0
- package/dist/test/fixtures.js +433 -0
- package/dist/test/fixtures.js.map +1 -0
- package/dist/types.d.ts +74 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- 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
|