@unpunnyfuns/d3-blockmap 0.1.0 → 0.2.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.
@@ -5,53 +5,71 @@ import { tickStep } from "d3-array";
5
5
  var BITS_PER_BYTE = 8;
6
6
  var BYTE_SHIFT = 3;
7
7
  var BYTE_MASK = 7;
8
+ var FULL_BYTE = 255;
8
9
  var BYTES_PER_PIXEL = 4;
9
10
  var OPAQUE_ALPHA = 255;
10
11
  var COLOR_TABLE_SIZE = 256;
12
+ var CANVAS_ROWS = 1;
11
13
  var PERCENT = 100;
12
14
  var PERCENT_FRACTION_DIGITS = 1;
13
15
  var TOOLTIP_OFFSET_PX = 8;
14
- var CANVAS_ROWS = 1;
15
16
  var CSS_VAR_MISSING = "--blockmap-missing";
16
17
  var CSS_VAR_PARTIAL_LOW = "--blockmap-partial-low";
17
18
  var CSS_VAR_PARTIAL_HIGH = "--blockmap-partial-high";
18
19
  var CSS_VAR_PARTIAL = "--blockmap-partial";
19
20
  var CSS_VAR_COMPLETE = "--blockmap-complete";
20
21
  var CSS_VAR_HIGHLIGHT = "--blockmap-highlight";
21
- var REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
22
- var ZOOM_STEP = 1.25;
23
- var DRAG_THRESHOLD_PX = 3;
24
- var BLINK_INTERVAL_MS = 500;
25
- var MIN_VIEW_LENGTH = 1;
22
+ var CSS_VAR_BRUSH = "--blockmap-brush";
26
23
  var DEFAULT_THEME = {
27
24
  missing: "#2b2b2b",
28
25
  partialLow: "#5b3a00",
29
26
  partialHigh: "#e0a500",
30
27
  partial: "#e0a500",
31
28
  complete: "#2ecc71",
32
- highlight: "#ff4d4f"
29
+ highlight: "#ff4d4f",
30
+ brush: "#ffffff"
33
31
  };
32
+ var BRUSH_FILL_OPACITY = 0.3;
34
33
  var THEME_ATTRIBUTES = ["class", "style", "data-theme"];
35
34
  var DARK_SCHEME_QUERY = "(prefers-color-scheme: dark)";
36
- var RANGE_PAIR = 2;
37
- var FULL_BYTE = 255;
38
- var MAX_BLOCKS = 4294967295;
35
+ var ZOOM_STEP = 1.25;
36
+ var DRAG_THRESHOLD_PX = 3;
37
+ var BLINK_INTERVAL_MS = 500;
38
+ var REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
39
+ var MIN_VIEW_LENGTH = 1;
40
+ var KEY_PAN_FRACTION = 0.1;
41
+ var KEY_PAN_FRACTION_FAST = 0.5;
42
+ var ARIA_LABEL_DEFAULT_PERCENT_DIGITS = 0;
43
+ var ARIA_ROLEDESCRIPTION = "zoomable block map";
44
+ var ARIA_KEYSHORTCUTS = "Plus - ArrowLeft ArrowRight Home";
39
45
  var CSS_VAR_AXIS_COLOR = "--blockmap-axis-color";
40
46
  var TICK_SPACING_PX = 80;
41
47
  var MIN_TICKS = 2;
42
48
  var EDGE_ANCHOR_RATIO = 0.5;
43
49
  var DECIMAL_BASE = 10;
50
+ var RANGE_PAIR = 2;
51
+ var MAX_BLOCKS = 4294967295;
44
52
 
45
53
  // src/dom.ts
46
54
  var ensureRelative = (container) => {
47
55
  const position = getComputedStyle(container).position;
48
56
  if (position === "static" || !position) container.style.position = "relative";
49
57
  };
58
+ var canvasX = (canvas, event) => event.clientX - canvas.getBoundingClientRect().left;
50
59
 
51
60
  // src/view.ts
52
61
  var fullView = (totalBlocks) => ({ start: 0, end: totalBlocks });
53
62
  var viewLength = (view) => view.end - view.start;
54
63
  var sameView = (a, b) => a.start === b.start && a.end === b.end;
64
+ var createScale = (view, cssWidth) => {
65
+ const length = viewLength(view);
66
+ return {
67
+ domain: [view.start, view.end],
68
+ range: [0, cssWidth],
69
+ x: (block) => cssWidth === 0 ? 0 : (block - view.start) / length * cssWidth,
70
+ block: (x) => cssWidth === 0 ? view.start : view.start + x / cssWidth * length
71
+ };
72
+ };
55
73
  var clampView = (view, totalBlocks) => {
56
74
  let start = Math.round(Math.min(view.start, view.end));
57
75
  let end = Math.round(Math.max(view.start, view.end));
@@ -60,6 +78,7 @@ var clampView = (view, totalBlocks) => {
60
78
  start = Math.max(0, Math.min(start, totalBlocks - length));
61
79
  return { start, end: start + length };
62
80
  };
81
+ var legalView = (view, totalBlocks) => Number.isFinite(view.start) && Number.isFinite(view.end) ? clampView(view, totalBlocks) : void 0;
63
82
  var zoomView = (view, anchor, factor, totalBlocks) => {
64
83
  const length = viewLength(view);
65
84
  const scaled = length * factor;
@@ -107,15 +126,6 @@ var defaultFormat = (block, step = 1) => {
107
126
  if (digits > MAX_COMPACT_FRACTION_DIGITS) return groupedFormat.format(block);
108
127
  return compactFormatterFor(digits).format(block);
109
128
  };
110
- var createScale = (view, cssWidth) => {
111
- const length = viewLength(view);
112
- return {
113
- domain: [view.start, view.end],
114
- range: [0, cssWidth],
115
- x: (block) => cssWidth === 0 ? 0 : (block - view.start) / length * cssWidth,
116
- block: (x) => cssWidth === 0 ? view.start : view.start + x / cssWidth * length
117
- };
118
- };
119
129
  var computeTicks = (view, cssWidth, tickCount) => {
120
130
  if (cssWidth <= 0) return [];
121
131
  const count = tickCount ?? Math.max(MIN_TICKS, Math.floor(cssWidth / TICK_SPACING_PX));
@@ -155,6 +165,9 @@ var createAxis = (container, options) => {
155
165
  });
156
166
  container.append(root);
157
167
  return {
168
+ // Rebuilds every label on each update. There are only a handful of ticks,
169
+ // so a single replaceChildren beats diffing, and it cannot leave stale
170
+ // labels behind after a zoom.
158
171
  update: (view, cssWidth) => {
159
172
  const spans = computeTicks(view, cssWidth, options.tickCount).map((tick) => {
160
173
  const span = document.createElement("span");
@@ -179,6 +192,208 @@ var createAxis = (container, options) => {
179
192
  };
180
193
  };
181
194
 
195
+ // src/bins.ts
196
+ var discreteColumn = (block, length, binCount) => Math.floor(block * binCount / length);
197
+ var discreteBlock = (column, length, binCount) => Math.ceil((column + 1) * length / binCount) - 1;
198
+ var clipToView = (rawStart, rawEnd, viewStart, viewEnd) => [Math.max(rawStart, viewStart), Math.min(rawEnd, viewEnd)];
199
+ var createBins = (view, binCount) => {
200
+ const { start: viewStart, end: viewEnd } = view;
201
+ const length = viewEnd - viewStart;
202
+ const counts = new Uint32Array(binCount);
203
+ const binOf = (index) => Math.floor((index - viewStart) * binCount / length);
204
+ const rangeOf = (bin) => [
205
+ viewStart + Math.ceil(bin * length / binCount),
206
+ viewStart + Math.ceil((bin + 1) * length / binCount)
207
+ ];
208
+ const sizeOf = (bin) => {
209
+ const [start, end] = rangeOf(bin);
210
+ return end - start;
211
+ };
212
+ return {
213
+ binCount,
214
+ counts,
215
+ binOf,
216
+ rangeOf,
217
+ sizeOf,
218
+ // Full recount from the bitset, used after a resize or a view change.
219
+ rebuild: (countInRange) => {
220
+ counts.fill(0);
221
+ if (binCount === 0) return;
222
+ for (let bin = 0; bin < binCount; bin += 1) {
223
+ const [start, end] = rangeOf(bin);
224
+ counts[bin] = countInRange(start, end);
225
+ }
226
+ },
227
+ /**
228
+ * Adds a wholly-new range's blocks to the bin counts without consulting
229
+ * the bitset: the first and last bins get the part of themselves the
230
+ * range covers, the bins between get their full size.
231
+ */
232
+ addRange: (rawStart, rawEnd) => {
233
+ const [start, end] = clipToView(rawStart, rawEnd, viewStart, viewEnd);
234
+ if (binCount === 0 || end <= start) return;
235
+ const first = binOf(start);
236
+ const last = binOf(end - 1);
237
+ if (first === last) {
238
+ counts[first] += end - start;
239
+ return;
240
+ }
241
+ counts[first] += rangeOf(first)[1] - start;
242
+ for (let bin = first + 1; bin < last; bin += 1) counts[bin] += sizeOf(bin);
243
+ counts[last] += end - rangeOf(last)[0];
244
+ },
245
+ // Which bins a range overlaps, so callers can mark exactly those dirty.
246
+ binsTouching: (rawStart, rawEnd) => {
247
+ const [start, end] = clipToView(rawStart, rawEnd, viewStart, viewEnd);
248
+ if (binCount === 0 || end <= start) return [];
249
+ const first = binOf(start);
250
+ const last = binOf(end - 1);
251
+ const touched = [];
252
+ for (let bin = first; bin <= last; bin += 1) touched.push(bin);
253
+ return touched;
254
+ },
255
+ recountBins: (bins, countInRange) => {
256
+ for (const bin of bins) {
257
+ const [start, end] = rangeOf(bin);
258
+ counts[bin] = countInRange(start, end);
259
+ }
260
+ }
261
+ };
262
+ };
263
+
264
+ // src/brush.ts
265
+ import { brushX } from "d3-brush";
266
+ import { select } from "d3-selection";
267
+ var BRUSH_ATTRIBUTE = "data-blockmap-brush";
268
+ var SVG_NAMESPACE = "http://www.w3.org/2000/svg";
269
+ var SELECTION_CLASS = ".selection";
270
+ var GESTURE_LISTENERS = "mousemove.brush mouseup.brush keydown.brush keyup.brush dragstart.drag selectstart.drag";
271
+ var clamp = (value, max) => Math.max(0, Math.min(max, value));
272
+ var toPixels = (selection, view, cssWidth) => {
273
+ if (cssWidth === 0) return null;
274
+ if (selection[1] <= view.start || selection[0] >= view.end) return null;
275
+ const scale = createScale(view, cssWidth);
276
+ return [clamp(scale.x(selection[0]), cssWidth), clamp(scale.x(selection[1]), cssWidth)];
277
+ };
278
+ var toBlocks = (pixels, view, cssWidth, totalBlocks) => {
279
+ const scale = createScale(view, cssWidth);
280
+ const start = clamp(Math.round(scale.block(pixels[0])), totalBlocks);
281
+ let end = clamp(Math.round(scale.block(pixels[1])), totalBlocks);
282
+ if (end <= start) {
283
+ end = Math.min(start + 1, totalBlocks);
284
+ if (end <= start) return [Math.max(0, end - 1), end];
285
+ }
286
+ return [start, end];
287
+ };
288
+ var normalize = (selection, totalBlocks) => {
289
+ if (!selection) return null;
290
+ const start = clamp(Math.round(selection[0]), totalBlocks);
291
+ const end = clamp(Math.round(selection[1]), totalBlocks);
292
+ return end > start ? [start, end] : null;
293
+ };
294
+ var xSpan = (selection) => {
295
+ if (!selection) return null;
296
+ const [x0, x1] = selection;
297
+ if (typeof x0 !== "number" || typeof x1 !== "number") return null;
298
+ return [x0, x1];
299
+ };
300
+ var sameRange = (a, b) => a === b || a !== null && b !== null && a[0] === b[0] && a[1] === b[1];
301
+ var createBrushOverlay = (container, deps) => {
302
+ ensureRelative(container);
303
+ const svg = document.createElementNS(SVG_NAMESPACE, "svg");
304
+ svg.setAttribute(BRUSH_ATTRIBUTE, "");
305
+ Object.assign(svg.style, {
306
+ position: "absolute",
307
+ inset: "0",
308
+ width: "100%",
309
+ height: "100%",
310
+ touchAction: "none",
311
+ overflow: "visible",
312
+ display: "block"
313
+ });
314
+ container.append(svg);
315
+ const g = select(svg).append("g");
316
+ const onMouseDown = (event) => {
317
+ event.preventDefault();
318
+ };
319
+ svg.addEventListener("mousedown", onMouseDown, true);
320
+ const brush = brushX().touchable(() => false).filter(
321
+ (event) => !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.button
322
+ );
323
+ let selection = null;
324
+ let color = DEFAULT_THEME.brush;
325
+ let silent = false;
326
+ let active = false;
327
+ const paint = () => {
328
+ g.select(SELECTION_CLASS).attr("fill", color).attr("stroke", color).attr("fill-opacity", BRUSH_FILL_OPACITY);
329
+ };
330
+ const update = () => {
331
+ const width = deps.getWidth();
332
+ brush.extent([
333
+ [0, 0],
334
+ [width, deps.getHeight()]
335
+ ]);
336
+ g.call(brush);
337
+ paint();
338
+ silent = true;
339
+ try {
340
+ g.call(brush.move, selection ? toPixels(selection, deps.getView(), width) : null);
341
+ } finally {
342
+ silent = false;
343
+ }
344
+ };
345
+ const blocksFrom = (pixels) => {
346
+ const span = xSpan(pixels);
347
+ if (!span) return null;
348
+ return toBlocks(span, deps.getView(), deps.getWidth(), deps.getTotalBlocks());
349
+ };
350
+ brush.on("start", (event) => {
351
+ if (!event.sourceEvent || silent) return;
352
+ active = true;
353
+ });
354
+ brush.on("brush", (event) => {
355
+ if (!event.sourceEvent || silent) return;
356
+ deps.onBrush(blocksFrom(event.selection));
357
+ });
358
+ brush.on("end", (event) => {
359
+ if (!event.sourceEvent || silent) return;
360
+ active = false;
361
+ const next = normalize(blocksFrom(event.selection), deps.getTotalBlocks());
362
+ const changed = !sameRange(selection, next);
363
+ selection = next;
364
+ update();
365
+ if (changed) deps.onBrushEnd(selection);
366
+ });
367
+ update();
368
+ return {
369
+ update,
370
+ setSelection: (next) => {
371
+ selection = normalize(next, deps.getTotalBlocks());
372
+ update();
373
+ },
374
+ getSelection: () => selection ? [selection[0], selection[1]] : null,
375
+ setColor: (next) => {
376
+ color = next;
377
+ paint();
378
+ },
379
+ // d3-brush only drops its window listeners and re-enables dragging from
380
+ // inside its own gesture end, so destroying mid-drag has to do it by
381
+ // hand or they outlive the map. Every targeted browser has
382
+ // onselectstart, so d3-drag suppresses selection with a listener rather
383
+ // than an inline style and removing the listeners is the whole cleanup.
384
+ // Those listeners are removed by name, which would take another
385
+ // component's brush or drag down with them, so this only runs for a
386
+ // gesture that is actually in flight.
387
+ destroy: () => {
388
+ brush.on("start", null).on("brush", null).on("end", null);
389
+ g.on(".brush", null);
390
+ svg.removeEventListener("mousedown", onMouseDown, true);
391
+ if (active) select(window).on(GESTURE_LISTENERS, null);
392
+ svg.remove();
393
+ }
394
+ };
395
+ };
396
+
182
397
  // src/colors.ts
183
398
  import { rgb } from "d3-color";
184
399
  import { interpolateRgb, piecewise } from "d3-interpolate";
@@ -188,7 +403,8 @@ var THEME_VARS = {
188
403
  partialHigh: CSS_VAR_PARTIAL_HIGH,
189
404
  partial: CSS_VAR_PARTIAL,
190
405
  complete: CSS_VAR_COMPLETE,
191
- highlight: CSS_VAR_HIGHLIGHT
406
+ highlight: CSS_VAR_HIGHLIGHT,
407
+ brush: CSS_VAR_BRUSH
192
408
  };
193
409
  var LAST_INDEX = COLOR_TABLE_SIZE - 1;
194
410
  var FIRST_PARTIAL_INDEX = 1;
@@ -214,7 +430,8 @@ var readTheme = (el) => {
214
430
  partialHigh: read("partialHigh"),
215
431
  partial: read("partial"),
216
432
  complete: read("complete"),
217
- highlight: read("highlight")
433
+ highlight: read("highlight"),
434
+ brush: read("brush")
218
435
  };
219
436
  };
220
437
  var createColorScale = (theme, partial = "coverage") => {
@@ -262,9 +479,14 @@ var watchTheme = (onChange) => {
262
479
 
263
480
  // src/interaction.ts
264
481
  var PRIMARY_BUTTON = 0;
482
+ var TOUCH_POINTER = "touch";
265
483
  var GRABBING = "grabbing";
266
484
  var NO_POINTER = -1;
267
- var attachInteraction = (canvas, deps) => {
485
+ var HALF = 2;
486
+ var ZOOM_IN_KEYS = /* @__PURE__ */ new Set(["+", "="]);
487
+ var ZOOM_OUT_KEYS = /* @__PURE__ */ new Set(["-", "_"]);
488
+ var RESET_KEYS = /* @__PURE__ */ new Set(["0", "Home"]);
489
+ var attachInteraction = (surface, canvas, deps) => {
268
490
  let drag;
269
491
  const columnsToBlocks = (px, view) => px * deps.getDpr() / deps.getBinCount() * viewLength(view);
270
492
  const onWheel = (event) => {
@@ -273,14 +495,18 @@ var attachInteraction = (canvas, deps) => {
273
495
  if (deps.getBinCount() === 0) return;
274
496
  event.preventDefault();
275
497
  const view = deps.getView();
276
- const anchor = view.start + columnsToBlocks(event.offsetX, view);
498
+ const anchor = view.start + columnsToBlocks(canvasX(canvas, event), view);
277
499
  const factor = event.deltaY > 0 ? ZOOM_STEP : 1 / ZOOM_STEP;
278
500
  deps.setView(zoomView(view, anchor, factor, deps.getTotalBlocks()));
279
501
  };
280
502
  const onPointerDown = (event) => {
281
503
  if (event.button !== PRIMARY_BUTTON) return;
504
+ if (document.activeElement !== canvas && typeof canvas.focus === "function") {
505
+ canvas.focus({ preventScroll: true });
506
+ }
507
+ if (deps.panRequiresShift() && event.pointerType !== TOUCH_POINTER && !event.shiftKey) return;
282
508
  drag = {
283
- downX: event.offsetX,
509
+ downX: canvasX(canvas, event),
284
510
  view: deps.getView(),
285
511
  pointerId: event.pointerId ?? NO_POINTER,
286
512
  active: false
@@ -288,14 +514,14 @@ var attachInteraction = (canvas, deps) => {
288
514
  };
289
515
  const onPointerMove = (event) => {
290
516
  if (!drag) return;
291
- const dx = event.offsetX - drag.downX;
517
+ const dx = canvasX(canvas, event) - drag.downX;
292
518
  if (!drag.active) {
293
519
  if (Math.abs(dx) < DRAG_THRESHOLD_PX) return;
294
520
  drag.active = true;
295
- if (typeof canvas.setPointerCapture === "function" && drag.pointerId !== NO_POINTER) {
296
- canvas.setPointerCapture(drag.pointerId);
521
+ if (typeof surface.setPointerCapture === "function" && drag.pointerId !== NO_POINTER) {
522
+ surface.setPointerCapture(drag.pointerId);
297
523
  }
298
- canvas.style.cursor = GRABBING;
524
+ surface.style.cursor = GRABBING;
299
525
  deps.onDragStart();
300
526
  }
301
527
  if (deps.getBinCount() === 0) return;
@@ -304,30 +530,54 @@ var attachInteraction = (canvas, deps) => {
304
530
  const endDrag = () => {
305
531
  if (!drag) return;
306
532
  if (drag.active) {
307
- const hasCapture = drag.pointerId !== NO_POINTER && (canvas.hasPointerCapture?.(drag.pointerId) ?? true);
308
- if (typeof canvas.releasePointerCapture === "function" && hasCapture) {
309
- canvas.releasePointerCapture(drag.pointerId);
533
+ const hasCapture = drag.pointerId !== NO_POINTER && (surface.hasPointerCapture?.(drag.pointerId) ?? true);
534
+ if (typeof surface.releasePointerCapture === "function" && hasCapture) {
535
+ surface.releasePointerCapture(drag.pointerId);
310
536
  }
311
- canvas.style.cursor = "";
537
+ surface.style.cursor = "";
312
538
  deps.onDragEnd();
313
539
  }
314
540
  drag = void 0;
315
541
  };
316
542
  const onDblClick = () => deps.resetView();
317
- canvas.addEventListener("wheel", onWheel, { passive: false });
318
- canvas.addEventListener("pointerdown", onPointerDown);
319
- canvas.addEventListener("pointermove", onPointerMove);
320
- canvas.addEventListener("pointerup", endDrag);
321
- canvas.addEventListener("pointercancel", endDrag);
322
- canvas.addEventListener("dblclick", onDblClick);
543
+ const onKeyDown = (event) => {
544
+ if (event.ctrlKey || event.metaKey || event.altKey) return;
545
+ if (drag?.active) return;
546
+ const view = deps.getView();
547
+ const length = viewLength(view);
548
+ const centre = view.start + length / HALF;
549
+ const total = deps.getTotalBlocks();
550
+ if (ZOOM_IN_KEYS.has(event.key)) {
551
+ deps.setView(zoomView(view, centre, 1 / ZOOM_STEP, total));
552
+ } else if (ZOOM_OUT_KEYS.has(event.key)) {
553
+ deps.setView(zoomView(view, centre, ZOOM_STEP, total));
554
+ } else if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
555
+ const fraction = event.shiftKey ? KEY_PAN_FRACTION_FAST : KEY_PAN_FRACTION;
556
+ const delta = length * fraction * (event.key === "ArrowLeft" ? -1 : 1);
557
+ deps.setView(panView(view, delta, total));
558
+ } else if (RESET_KEYS.has(event.key)) {
559
+ deps.resetView();
560
+ } else {
561
+ return;
562
+ }
563
+ event.preventDefault();
564
+ };
565
+ surface.addEventListener("wheel", onWheel, { passive: false });
566
+ surface.addEventListener("pointerdown", onPointerDown);
567
+ surface.addEventListener("pointermove", onPointerMove);
568
+ surface.addEventListener("pointerup", endDrag);
569
+ surface.addEventListener("pointercancel", endDrag);
570
+ surface.addEventListener("dblclick", onDblClick);
571
+ canvas.addEventListener("keydown", onKeyDown);
323
572
  return () => {
324
573
  endDrag();
325
- canvas.removeEventListener("wheel", onWheel);
326
- canvas.removeEventListener("pointerdown", onPointerDown);
327
- canvas.removeEventListener("pointermove", onPointerMove);
328
- canvas.removeEventListener("pointerup", endDrag);
329
- canvas.removeEventListener("pointercancel", endDrag);
330
- canvas.removeEventListener("dblclick", onDblClick);
574
+ surface.removeEventListener("wheel", onWheel);
575
+ surface.removeEventListener("pointerdown", onPointerDown);
576
+ surface.removeEventListener("pointermove", onPointerMove);
577
+ surface.removeEventListener("pointerup", endDrag);
578
+ surface.removeEventListener("pointercancel", endDrag);
579
+ surface.removeEventListener("dblclick", onDblClick);
580
+ canvas.removeEventListener("keydown", onKeyDown);
331
581
  };
332
582
  };
333
583
 
@@ -347,7 +597,6 @@ var createRenderer = (ctx, source, scheduler = RAF_SCHEDULER, timer = WINDOW_TIM
347
597
  let image;
348
598
  let scale;
349
599
  let frame = NO_FRAME;
350
- let scheduled = false;
351
600
  let allDirty = false;
352
601
  let highlight;
353
602
  let phase = false;
@@ -367,12 +616,13 @@ var createRenderer = (ctx, source, scheduler = RAF_SCHEDULER, timer = WINDOW_TIM
367
616
  target.data.set(color, column * BYTES_PER_PIXEL);
368
617
  };
369
618
  const paintDiscrete = (target, colors) => {
370
- const { view, binCount } = source;
619
+ const { binCount } = source;
620
+ const view = source.getView();
371
621
  const length = viewLength(view);
372
622
  for (let block = 0; block < length; block += 1) {
373
623
  const color = colorFor(colors, source.has(view.start + block) ? COMPLETE : 0);
374
- const start = Math.floor(block * binCount / length);
375
- const end = Math.floor((block + 1) * binCount / length);
624
+ const start = discreteColumn(block, length, binCount);
625
+ const end = discreteColumn(block + 1, length, binCount);
376
626
  for (let column = start; column < end; column += 1) writeColumn(target, column, color);
377
627
  }
378
628
  };
@@ -388,7 +638,6 @@ var createRenderer = (ctx, source, scheduler = RAF_SCHEDULER, timer = WINDOW_TIM
388
638
  }
389
639
  };
390
640
  const paint = () => {
391
- scheduled = false;
392
641
  frame = NO_FRAME;
393
642
  const target = ensureImage();
394
643
  if (!target || !scale) {
@@ -396,15 +645,14 @@ var createRenderer = (ctx, source, scheduler = RAF_SCHEDULER, timer = WINDOW_TIM
396
645
  allDirty = false;
397
646
  return;
398
647
  }
399
- if (viewLength(source.view) < source.binCount) paintDiscrete(target, scale);
648
+ if (viewLength(source.getView()) < source.binCount) paintDiscrete(target, scale);
400
649
  else paintBins(target, scale);
401
650
  ctx.putImageData(target, 0, 0);
402
651
  dirty.clear();
403
652
  allDirty = false;
404
653
  };
405
654
  const schedule = () => {
406
- if (scheduled) return;
407
- scheduled = true;
655
+ if (frame !== NO_FRAME) return;
408
656
  frame = scheduler.request(paint);
409
657
  };
410
658
  const markAllDirty = () => {
@@ -420,6 +668,9 @@ var createRenderer = (ctx, source, scheduler = RAF_SCHEDULER, timer = WINDOW_TIM
420
668
  scale = next;
421
669
  markAllDirty();
422
670
  },
671
+ // Restarts cleanly on every call: the old interval is always cleared, so
672
+ // repeated enables cannot stack up blink timers. Turning the highlight
673
+ // off when it was never on is a no-op, avoiding a pointless repaint.
423
674
  setHighlight: (on, color, blink) => {
424
675
  if (!on && highlight === void 0) return;
425
676
  stopBlink();
@@ -433,6 +684,7 @@ var createRenderer = (ctx, source, scheduler = RAF_SCHEDULER, timer = WINDOW_TIM
433
684
  }
434
685
  markAllDirty();
435
686
  },
687
+ // Theme changed mid-highlight; the blink phase keeps running untouched.
436
688
  setHighlightColor: (color) => {
437
689
  if (highlight === void 0) return;
438
690
  highlight = color;
@@ -445,8 +697,7 @@ var createRenderer = (ctx, source, scheduler = RAF_SCHEDULER, timer = WINDOW_TIM
445
697
  markAllDirty,
446
698
  destroy: () => {
447
699
  stopBlink();
448
- if (scheduled) scheduler.cancel(frame);
449
- scheduled = false;
700
+ if (frame !== NO_FRAME) scheduler.cancel(frame);
450
701
  frame = NO_FRAME;
451
702
  dirty.clear();
452
703
  }
@@ -464,6 +715,10 @@ var createBitset = (size) => {
464
715
  let count = 0;
465
716
  return {
466
717
  has: (index) => (bytes[index >>> BYTE_SHIFT] & 1 << (index & BYTE_MASK)) !== 0,
718
+ /**
719
+ * Returns false when the bit was already set, so callers can skip the
720
+ * dirty-marking work for blocks that did not actually change.
721
+ */
467
722
  set: (index) => {
468
723
  const byte = index >>> BYTE_SHIFT;
469
724
  const mask = 1 << (index & BYTE_MASK);
@@ -472,6 +727,11 @@ var createBitset = (size) => {
472
727
  count += 1;
473
728
  return true;
474
729
  },
730
+ /**
731
+ * Head/middle/tail: OR a partial mask into the first byte, memset the
732
+ * whole bytes in between, OR a partial mask into the last. `end - 1` is
733
+ * the last included bit, so `toBit` is its offset plus one.
734
+ */
475
735
  fill: (start, end) => {
476
736
  if (end <= start) return;
477
737
  const firstByte = start >>> BYTE_SHIFT;
@@ -486,6 +746,7 @@ var createBitset = (size) => {
486
746
  bytes.fill(FULL_BYTE, firstByte + 1, lastByte);
487
747
  bytes[lastByte] |= maskRange(0, toBit);
488
748
  },
749
+ // Same head/middle/tail shape as fill, with the masks inverted.
489
750
  clearRange: (start, end) => {
490
751
  if (end <= start) return;
491
752
  const firstByte = start >>> BYTE_SHIFT;
@@ -500,6 +761,8 @@ var createBitset = (size) => {
500
761
  bytes.fill(0, firstByte + 1, lastByte);
501
762
  bytes[lastByte] &= ~maskRange(0, toBit);
502
763
  },
764
+ // Head and tail are masked before counting; the middle bytes are counted
765
+ // whole, one table lookup each.
503
766
  countRange: (start, end) => {
504
767
  if (end <= start) return 0;
505
768
  const firstByte = start >>> BYTE_SHIFT;
@@ -518,6 +781,11 @@ var createBitset = (size) => {
518
781
  bytes.fill(0);
519
782
  count = 0;
520
783
  },
784
+ /**
785
+ * fill and clearRange do not maintain `count`, because recounting a
786
+ * million-bit span per call would defeat the point. Callers that use them
787
+ * compute the delta themselves and write the total back here.
788
+ */
521
789
  setCount: (next) => {
522
790
  count = next;
523
791
  },
@@ -527,70 +795,6 @@ var createBitset = (size) => {
527
795
  };
528
796
  };
529
797
 
530
- // src/bins.ts
531
- var clipToView = (rawStart, rawEnd, viewStart, viewEnd) => ({
532
- start: Math.max(rawStart, viewStart),
533
- end: Math.min(rawEnd, viewEnd)
534
- });
535
- var createBins = (view, binCount) => {
536
- const { start: viewStart, end: viewEnd } = view;
537
- const length = viewEnd - viewStart;
538
- const counts = new Uint32Array(binCount);
539
- const binOf = (index) => Math.floor((index - viewStart) * binCount / length);
540
- const rangeOf = (bin) => ({
541
- start: viewStart + Math.ceil(bin * length / binCount),
542
- end: viewStart + Math.ceil((bin + 1) * length / binCount)
543
- });
544
- const sizeOf = (bin) => {
545
- const { start, end } = rangeOf(bin);
546
- return end - start;
547
- };
548
- return {
549
- view,
550
- binCount,
551
- counts,
552
- binOf,
553
- rangeOf,
554
- sizeOf,
555
- rebuild: (countInRange) => {
556
- counts.fill(0);
557
- if (binCount === 0) return;
558
- for (let bin = 0; bin < binCount; bin += 1) {
559
- const { start, end } = rangeOf(bin);
560
- counts[bin] = countInRange(start, end);
561
- }
562
- },
563
- addRange: (rawStart, rawEnd) => {
564
- const { start, end } = clipToView(rawStart, rawEnd, viewStart, viewEnd);
565
- if (binCount === 0 || end <= start) return;
566
- const first = binOf(start);
567
- const last = binOf(end - 1);
568
- if (first === last) {
569
- counts[first] += end - start;
570
- return;
571
- }
572
- counts[first] += rangeOf(first).end - start;
573
- for (let bin = first + 1; bin < last; bin += 1) counts[bin] += sizeOf(bin);
574
- counts[last] += end - rangeOf(last).start;
575
- },
576
- binsTouching: (rawStart, rawEnd) => {
577
- const { start, end } = clipToView(rawStart, rawEnd, viewStart, viewEnd);
578
- if (binCount === 0 || end <= start) return [];
579
- const first = binOf(start);
580
- const last = binOf(end - 1);
581
- const touched = [];
582
- for (let bin = first; bin <= last; bin += 1) touched.push(bin);
583
- return touched;
584
- },
585
- recountBins: (bins, countInRange) => {
586
- for (const bin of bins) {
587
- const range = rangeOf(bin);
588
- counts[bin] = countInRange(range.start, range.end);
589
- }
590
- }
591
- };
592
- };
593
-
594
798
  // src/ranges.ts
595
799
  var isFlatPairs = (input) => ArrayBuffer.isView(input) || typeof input[0] === "number";
596
800
  var readPairs = (input) => {
@@ -689,22 +893,23 @@ var createBlockState = (totalBlocks) => {
689
893
  get binCount() {
690
894
  return bins.binCount;
691
895
  },
692
- get view() {
693
- return view;
694
- },
896
+ // Column count changed: the bin boundaries move, so rebuild from the
897
+ // bitset rather than trying to redistribute the old counts.
695
898
  resize: (binCount) => {
696
899
  bins = createBins(view, binCount);
697
900
  bins.rebuild(bitset.countRange);
698
901
  },
902
+ // Takes a view legalView has already checked, so the façade validates once
903
+ // per request rather than again at commit. Returns whether anything moved,
904
+ // so callers can skip a repaint.
699
905
  setView: (next) => {
700
- if (!Number.isFinite(next.start) || !Number.isFinite(next.end)) return false;
701
- const clamped = clampView(next, totalBlocks);
702
- if (sameView(clamped, view)) return false;
703
- view = clamped;
906
+ if (sameView(next, view)) return false;
907
+ view = { ...next };
704
908
  bins = createBins(view, bins.binCount);
705
909
  bins.rebuild(bitset.countRange);
706
910
  return true;
707
911
  },
912
+ // A copy, because `view` is live and handed to external callbacks.
708
913
  getView: () => ({ ...view }),
709
914
  add,
710
915
  replace: (indices) => {
@@ -712,6 +917,11 @@ var createBlockState = (totalBlocks) => {
712
917
  bins.counts.fill(0);
713
918
  add(indices);
714
919
  },
920
+ /**
921
+ * Ranges are already merged and disjoint, so each one's blocks are new to
922
+ * the bitset: bins.addRange can add sizes arithmetically and the total is
923
+ * just the summed range lengths, with no counting pass.
924
+ */
715
925
  replaceRanges: (input) => {
716
926
  const flat = normalizeRanges(input, totalBlocks);
717
927
  bitset.clear();
@@ -725,12 +935,12 @@ var createBlockState = (totalBlocks) => {
725
935
  addRanges: (input) => applyRanges(input, true),
726
936
  removeRanges: (input) => applyRanges(input, false),
727
937
  has: bitset.has,
938
+ // Coverage in [0, 1], which is what the colour scale is indexed by.
728
939
  fraction: (bin) => {
729
940
  const size = bins.sizeOf(bin);
730
941
  return size === 0 ? 0 : bins.counts[bin] / size;
731
942
  },
732
943
  countOf: (bin) => bins.counts[bin],
733
- sizeOf: (bin) => bins.sizeOf(bin),
734
944
  rangeOf: (bin) => bins.rangeOf(bin),
735
945
  progress: () => ({ completed: bitset.count, total: totalBlocks })
736
946
  };
@@ -760,9 +970,9 @@ var formatPercent = (count, size) => {
760
970
  }
761
971
  return `${text}%`;
762
972
  };
763
- var formatBinTooltip = (range, count) => {
764
- const size = range.end - range.start;
765
- const label = size === 1 ? `Block ${countFormat.format(range.start)}` : `Blocks ${countFormat.format(range.start)}${EN_DASH}${countFormat.format(range.end - 1)}`;
973
+ var formatBinTooltip = ([start, end], count) => {
974
+ const size = end - start;
975
+ const label = size === 1 ? `Block ${countFormat.format(start)}` : `Blocks ${countFormat.format(start)}${EN_DASH}${countFormat.format(end - 1)}`;
766
976
  return `${label}${SEPARATOR}${countFormat.format(count)} / ${countFormat.format(size)} (${formatPercent(count, size)})`;
767
977
  };
768
978
  var formatBlockTooltip = (index, complete) => `Block ${countFormat.format(index)}${SEPARATOR}${complete ? "complete" : "missing"}`;
@@ -781,6 +991,8 @@ var createTooltip = (container) => {
781
991
  ensureRelative(container);
782
992
  container.append(el);
783
993
  return {
994
+ // x is in container-relative CSS pixels; the offset keeps the label from
995
+ // sitting directly under the cursor.
784
996
  show: (x, text) => {
785
997
  el.textContent = text;
786
998
  el.style.left = `${x + TOOLTIP_OFFSET_PX}px`;
@@ -799,11 +1011,20 @@ var createTooltip = (container) => {
799
1011
  var FULL2 = "100%";
800
1012
  var CONTEXT_ID = "2d";
801
1013
  var NO_FRAME2 = 0;
1014
+ var LOCALE2 = "en-US";
1015
+ var ARIA_LABEL_ATTRIBUTE = "aria-label";
1016
+ var numberFormat = new Intl.NumberFormat(LOCALE2);
1017
+ var defaultLabel = (progress) => {
1018
+ const percent = Math.floor(progress.completed / progress.total * PERCENT).toFixed(
1019
+ ARIA_LABEL_DEFAULT_PERCENT_DIGITS
1020
+ );
1021
+ return `Download progress: ${numberFormat.format(progress.completed)} of ${numberFormat.format(progress.total)} blocks (${percent}%)`;
1022
+ };
802
1023
  var dprQuery = (dpr) => `(resolution: ${dpr}dppx)`;
803
- var discreteBlock = (column, length, binCount, start) => start + Math.ceil((column + 1) * length / binCount) - 1;
804
1024
  var createBlockMap = (container, options) => {
805
1025
  const state = createBlockState(options.totalBlocks);
806
- if (options.view) state.setView(options.view);
1026
+ const initialView = options.view && legalView(options.view, state.totalBlocks);
1027
+ if (initialView) state.setView(initialView);
807
1028
  const canvas = document.createElement("canvas");
808
1029
  Object.assign(canvas.style, {
809
1030
  display: "block",
@@ -812,6 +1033,12 @@ var createBlockMap = (container, options) => {
812
1033
  touchAction: "none"
813
1034
  });
814
1035
  canvas.height = CANVAS_ROWS;
1036
+ canvas.setAttribute("role", "img");
1037
+ canvas.setAttribute("aria-roledescription", ARIA_ROLEDESCRIPTION);
1038
+ if (options.interactive !== false) {
1039
+ canvas.tabIndex = 0;
1040
+ canvas.setAttribute("aria-keyshortcuts", ARIA_KEYSHORTCUTS);
1041
+ }
815
1042
  container.append(canvas);
816
1043
  const ctx = canvas.getContext(CONTEXT_ID);
817
1044
  if (!ctx) {
@@ -819,53 +1046,77 @@ var createBlockMap = (container, options) => {
819
1046
  throw new Error(`Could not acquire a ${CONTEXT_ID} canvas context`);
820
1047
  }
821
1048
  const renderer = createRenderer(ctx, state);
822
- const tooltip = createTooltip(container);
823
- const axisOptions = options.axis === true ? {} : options.axis === false || options.axis === void 0 ? void 0 : options.axis;
824
- const axis = axisOptions ? createAxis(container, axisOptions) : void 0;
825
1049
  let destroyed = false;
826
1050
  let cssWidth = 0;
1051
+ let cssHeight = 0;
827
1052
  let dpr = devicePixelRatio;
828
1053
  let dprMedia;
829
1054
  let theme = readTheme(container);
830
1055
  let highlightOn = false;
831
1056
  let dragging = false;
832
- const blinkAllowed = () => !matchMedia(REDUCED_MOTION_QUERY).matches;
1057
+ let downX = Number.NaN;
1058
+ const overlay = options.brush === true ? createBrushOverlay(container, {
1059
+ getView: () => state.getView(),
1060
+ getWidth: () => cssWidth,
1061
+ getHeight: () => cssHeight,
1062
+ getTotalBlocks: () => state.totalBlocks,
1063
+ onBrush: (selection) => options.onBrush?.(selection),
1064
+ onBrushEnd: (selection) => options.onBrushEnd?.(selection)
1065
+ }) : void 0;
1066
+ const tooltip = createTooltip(container);
1067
+ const axisOptions = options.axis === true ? {} : options.axis === false || options.axis === void 0 ? void 0 : options.axis;
1068
+ const axis = axisOptions ? createAxis(container, axisOptions) : void 0;
1069
+ const motionMedia = matchMedia(REDUCED_MOTION_QUERY);
1070
+ const blinkAllowed = () => !motionMedia.matches;
833
1071
  const applyHighlight = () => {
834
1072
  renderer.setHighlight(highlightOn, highlightColor(theme), blinkAllowed());
835
1073
  };
1074
+ const onMotionChange = () => {
1075
+ if (highlightOn) applyHighlight();
1076
+ };
1077
+ motionMedia.addEventListener("change", onMotionChange);
836
1078
  const applyTheme = () => {
837
1079
  theme = readTheme(container);
838
1080
  renderer.setColors(createColorScale(theme, options.partial));
839
1081
  if (highlightOn) renderer.setHighlightColor(highlightColor(theme));
1082
+ overlay?.setColor(theme.brush);
1083
+ };
1084
+ const writeLabel = () => {
1085
+ canvas.setAttribute(ARIA_LABEL_ATTRIBUTE, (options.label ?? defaultLabel)(state.progress()));
1086
+ };
1087
+ const updateChrome = () => {
1088
+ axis?.update(state.getView(), cssWidth);
1089
+ overlay?.update();
840
1090
  };
841
1091
  let pendingView;
842
1092
  let viewFrame = NO_FRAME2;
843
- const getView = () => ({ ...pendingView ?? state.view });
1093
+ const getView = () => ({ ...pendingView ?? state.getView() });
844
1094
  const commitView = () => {
845
1095
  viewFrame = NO_FRAME2;
846
1096
  const next = pendingView;
847
1097
  pendingView = void 0;
848
1098
  if (!next || !state.setView(next)) return;
849
1099
  renderer.markAllDirty();
850
- axis?.update(state.view, cssWidth);
1100
+ updateChrome();
851
1101
  options.onViewChange?.(state.getView());
852
1102
  };
853
1103
  const setView = (view) => {
854
- if (!Number.isFinite(view.start) || !Number.isFinite(view.end)) return;
855
- const clamped = clampView(view, state.totalBlocks);
856
- if (sameView(clamped, pendingView ?? state.view)) return;
857
- const hasPendingFrame = pendingView !== void 0;
858
- pendingView = clamped;
859
- if (!hasPendingFrame) viewFrame = requestAnimationFrame(commitView);
1104
+ const legal = legalView(view, state.totalBlocks);
1105
+ if (!legal || sameView(legal, pendingView ?? state.getView())) return;
1106
+ pendingView = legal;
1107
+ if (viewFrame !== NO_FRAME2) return;
1108
+ viewFrame = requestAnimationFrame(commitView);
860
1109
  };
861
1110
  const resetView = () => setView(fullView(state.totalBlocks));
862
1111
  const layout = () => {
1112
+ if (cssWidth === 0) return;
863
1113
  const binCount = Math.round(cssWidth * dpr);
864
- if (binCount === state.binCount) return;
865
- canvas.width = binCount;
866
- state.resize(binCount);
867
- renderer.markAllDirty();
868
- axis?.update(state.view, cssWidth);
1114
+ if (binCount !== state.binCount) {
1115
+ canvas.width = binCount;
1116
+ state.resize(binCount);
1117
+ renderer.markAllDirty();
1118
+ }
1119
+ updateChrome();
869
1120
  };
870
1121
  const onDprChange = () => {
871
1122
  dpr = devicePixelRatio;
@@ -881,34 +1132,70 @@ var createBlockMap = (container, options) => {
881
1132
  const entry = entries[0];
882
1133
  if (!entry) return;
883
1134
  cssWidth = entry.contentRect.width;
1135
+ cssHeight = entry.contentRect.height;
884
1136
  layout();
885
1137
  });
886
1138
  resizeObserver.observe(container);
887
- const onPointerMove = (event) => {
888
- if (dragging) return;
889
- const { binCount, view } = state;
890
- if (binCount === 0) return;
891
- const column = Math.min(binCount - 1, Math.max(0, Math.floor(event.offsetX * dpr)));
1139
+ const locate = (x) => {
1140
+ const { binCount } = state;
1141
+ if (binCount === 0) return void 0;
1142
+ if (x < 0 || x >= cssWidth) return void 0;
1143
+ const column = Math.min(binCount - 1, Math.max(0, Math.floor(x * dpr)));
1144
+ const view = state.getView();
892
1145
  const length = viewLength(view);
893
- let text;
894
1146
  if (length < binCount) {
895
- const block = discreteBlock(column, length, binCount, view.start);
896
- text = formatBlockTooltip(block, state.has(block));
897
- } else {
898
- text = formatBinTooltip(state.rangeOf(column), state.countOf(column));
1147
+ const block = view.start + discreteBlock(column, length, binCount);
1148
+ return {
1149
+ block,
1150
+ range: [block, block + 1],
1151
+ completed: state.has(block) ? 1 : 0,
1152
+ total: 1
1153
+ };
899
1154
  }
900
- tooltip.show(event.offsetX, text);
1155
+ const [start, end] = state.rangeOf(column);
1156
+ return {
1157
+ block: start,
1158
+ range: [start, end],
1159
+ completed: state.countOf(column),
1160
+ total: end - start
1161
+ };
1162
+ };
1163
+ const onPointerMove = (event) => {
1164
+ if (dragging) return;
1165
+ const x = canvasX(canvas, event);
1166
+ const hit = locate(x);
1167
+ if (!hit) {
1168
+ tooltip.hide();
1169
+ return;
1170
+ }
1171
+ const text = viewLength(state.getView()) < state.binCount ? formatBlockTooltip(hit.block, hit.completed === 1) : formatBinTooltip(hit.range, hit.completed);
1172
+ tooltip.show(x, text);
901
1173
  };
902
1174
  const onPointerLeave = () => tooltip.hide();
903
- canvas.addEventListener("pointermove", onPointerMove);
904
- canvas.addEventListener("pointerleave", onPointerLeave);
905
- const detachInteraction = options.interactive === false ? void 0 : attachInteraction(canvas, {
1175
+ const onPointerDown = (event) => {
1176
+ downX = event.clientX;
1177
+ };
1178
+ const onClick = (event) => {
1179
+ const travelled = Math.abs(event.clientX - downX);
1180
+ downX = Number.NaN;
1181
+ if (travelled >= DRAG_THRESHOLD_PX) return;
1182
+ if (!options.onBlockClick) return;
1183
+ const hit = locate(canvasX(canvas, event));
1184
+ if (!hit) return;
1185
+ options.onBlockClick({ ...hit, originalEvent: event });
1186
+ };
1187
+ container.addEventListener("pointermove", onPointerMove);
1188
+ container.addEventListener("pointerleave", onPointerLeave);
1189
+ container.addEventListener("pointerdown", onPointerDown);
1190
+ container.addEventListener("click", onClick);
1191
+ const detachInteraction = options.interactive === false ? void 0 : attachInteraction(container, canvas, {
906
1192
  getView,
907
1193
  setView,
908
1194
  resetView,
909
1195
  getBinCount: () => state.binCount,
910
1196
  getDpr: () => dpr,
911
1197
  getTotalBlocks: () => state.totalBlocks,
1198
+ panRequiresShift: () => overlay !== void 0,
912
1199
  onDragStart: () => {
913
1200
  dragging = true;
914
1201
  tooltip.hide();
@@ -922,47 +1209,54 @@ var createBlockMap = (container, options) => {
922
1209
  applyTheme();
923
1210
  if (options.initialRanges) state.replaceRanges(options.initialRanges);
924
1211
  if (options.initialBlocks) renderer.markDirty(state.add(options.initialBlocks));
1212
+ writeLabel();
1213
+ const live = (method) => (...args) => {
1214
+ if (!destroyed) method(...args);
1215
+ };
925
1216
  return {
926
- setBlocks: (indices) => {
927
- if (destroyed) return;
1217
+ setBlocks: live((indices) => {
928
1218
  state.replace(indices);
929
1219
  renderer.markAllDirty();
930
- },
931
- setRanges: (ranges) => {
932
- if (destroyed) return;
1220
+ writeLabel();
1221
+ }),
1222
+ setRanges: live((ranges) => {
933
1223
  state.replaceRanges(ranges);
934
1224
  renderer.markAllDirty();
935
- },
936
- addBlocks: (indices) => {
937
- if (destroyed) return;
1225
+ writeLabel();
1226
+ }),
1227
+ // The incremental paths repaint only the bins the state reports changed.
1228
+ addBlocks: live((indices) => {
938
1229
  renderer.markDirty(state.add(indices));
939
- },
940
- addRanges: (ranges) => {
941
- if (destroyed) return;
1230
+ writeLabel();
1231
+ }),
1232
+ addRanges: live((ranges) => {
942
1233
  renderer.markDirty(state.addRanges(ranges));
943
- },
944
- removeRanges: (ranges) => {
945
- if (destroyed) return;
1234
+ writeLabel();
1235
+ }),
1236
+ removeRanges: live((ranges) => {
946
1237
  renderer.markDirty(state.removeRanges(ranges));
947
- },
948
- setView: (view) => {
949
- if (destroyed) return;
950
- setView(view);
951
- },
952
- resetView: () => {
953
- if (destroyed) return;
954
- resetView();
955
- },
1238
+ writeLabel();
1239
+ }),
1240
+ setView: live(setView),
1241
+ resetView: live(resetView),
956
1242
  getView,
1243
+ // Built from the committed view, so the scale always matches what is on
1244
+ // screen rather than a change still waiting for its frame.
957
1245
  getScale: () => createScale(state.getView(), cssWidth),
958
- setHighlight: (on) => {
959
- if (destroyed) return;
1246
+ // Selections are only meaningful while there is an overlay to draw them:
1247
+ // without one setBrush has nothing to store and getBrush nothing to
1248
+ // report. A destroyed map reports none either, rather than whatever the
1249
+ // detached overlay was last holding.
1250
+ setBrush: live((selection) => overlay?.setSelection(selection)),
1251
+ getBrush: () => destroyed ? null : overlay?.getSelection() ?? null,
1252
+ setHighlight: live((on) => {
960
1253
  highlightOn = on;
961
1254
  applyHighlight();
962
- },
1255
+ }),
963
1256
  getProgress: () => destroyed ? { completed: 0, total: state.totalBlocks } : state.progress(),
964
- destroy: () => {
965
- if (destroyed) return;
1257
+ // Idempotent, and unwinds in the reverse order of construction: pending
1258
+ // frame, observers, listeners, owned children, then the canvas itself.
1259
+ destroy: live(() => {
966
1260
  destroyed = true;
967
1261
  if (viewFrame !== NO_FRAME2) cancelAnimationFrame(viewFrame);
968
1262
  viewFrame = NO_FRAME2;
@@ -970,18 +1264,25 @@ var createBlockMap = (container, options) => {
970
1264
  resizeObserver.disconnect();
971
1265
  unwatchTheme();
972
1266
  dprMedia?.removeEventListener("change", onDprChange);
973
- canvas.removeEventListener("pointermove", onPointerMove);
974
- canvas.removeEventListener("pointerleave", onPointerLeave);
1267
+ motionMedia.removeEventListener("change", onMotionChange);
1268
+ container.removeEventListener("pointermove", onPointerMove);
1269
+ container.removeEventListener("pointerleave", onPointerLeave);
1270
+ container.removeEventListener("pointerdown", onPointerDown);
1271
+ container.removeEventListener("click", onClick);
975
1272
  detachInteraction?.();
976
1273
  renderer.destroy();
977
1274
  tooltip.destroy();
978
1275
  axis?.destroy();
1276
+ overlay?.destroy();
979
1277
  canvas.remove();
980
- }
1278
+ })
981
1279
  };
982
1280
  };
983
1281
 
984
1282
  export {
1283
+ fullView,
1284
+ createScale,
1285
+ defaultLabel,
985
1286
  createBlockMap
986
1287
  };
987
- //# sourceMappingURL=chunk-WIDC3EN2.js.map
1288
+ //# sourceMappingURL=chunk-6BBJWE5O.js.map