@unpunnyfuns/d3-blockmap 0.0.0-stage → 0.1.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.
@@ -0,0 +1,987 @@
1
+ // src/axis.ts
2
+ import { tickStep } from "d3-array";
3
+
4
+ // src/constants.ts
5
+ var BITS_PER_BYTE = 8;
6
+ var BYTE_SHIFT = 3;
7
+ var BYTE_MASK = 7;
8
+ var BYTES_PER_PIXEL = 4;
9
+ var OPAQUE_ALPHA = 255;
10
+ var COLOR_TABLE_SIZE = 256;
11
+ var PERCENT = 100;
12
+ var PERCENT_FRACTION_DIGITS = 1;
13
+ var TOOLTIP_OFFSET_PX = 8;
14
+ var CANVAS_ROWS = 1;
15
+ var CSS_VAR_MISSING = "--blockmap-missing";
16
+ var CSS_VAR_PARTIAL_LOW = "--blockmap-partial-low";
17
+ var CSS_VAR_PARTIAL_HIGH = "--blockmap-partial-high";
18
+ var CSS_VAR_PARTIAL = "--blockmap-partial";
19
+ var CSS_VAR_COMPLETE = "--blockmap-complete";
20
+ 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;
26
+ var DEFAULT_THEME = {
27
+ missing: "#2b2b2b",
28
+ partialLow: "#5b3a00",
29
+ partialHigh: "#e0a500",
30
+ partial: "#e0a500",
31
+ complete: "#2ecc71",
32
+ highlight: "#ff4d4f"
33
+ };
34
+ var THEME_ATTRIBUTES = ["class", "style", "data-theme"];
35
+ var DARK_SCHEME_QUERY = "(prefers-color-scheme: dark)";
36
+ var RANGE_PAIR = 2;
37
+ var FULL_BYTE = 255;
38
+ var MAX_BLOCKS = 4294967295;
39
+ var CSS_VAR_AXIS_COLOR = "--blockmap-axis-color";
40
+ var TICK_SPACING_PX = 80;
41
+ var MIN_TICKS = 2;
42
+ var EDGE_ANCHOR_RATIO = 0.5;
43
+ var DECIMAL_BASE = 10;
44
+
45
+ // src/dom.ts
46
+ var ensureRelative = (container) => {
47
+ const position = getComputedStyle(container).position;
48
+ if (position === "static" || !position) container.style.position = "relative";
49
+ };
50
+
51
+ // src/view.ts
52
+ var fullView = (totalBlocks) => ({ start: 0, end: totalBlocks });
53
+ var viewLength = (view) => view.end - view.start;
54
+ var sameView = (a, b) => a.start === b.start && a.end === b.end;
55
+ var clampView = (view, totalBlocks) => {
56
+ let start = Math.round(Math.min(view.start, view.end));
57
+ let end = Math.round(Math.max(view.start, view.end));
58
+ if (end - start < MIN_VIEW_LENGTH) end = start + MIN_VIEW_LENGTH;
59
+ const length = Math.min(end - start, totalBlocks);
60
+ start = Math.max(0, Math.min(start, totalBlocks - length));
61
+ return { start, end: start + length };
62
+ };
63
+ var zoomView = (view, anchor, factor, totalBlocks) => {
64
+ const length = viewLength(view);
65
+ const scaled = length * factor;
66
+ const nextLength = Math.max(
67
+ MIN_VIEW_LENGTH,
68
+ Math.min(totalBlocks, factor < 1 ? Math.floor(scaled) : Math.ceil(scaled))
69
+ );
70
+ const ratio = (anchor - view.start) / length;
71
+ const start = Math.round(anchor - ratio * nextLength);
72
+ return clampView({ start, end: start + nextLength }, totalBlocks);
73
+ };
74
+ var panView = (view, deltaBlocks, totalBlocks) => {
75
+ const length = viewLength(view);
76
+ const start = Math.round(view.start + deltaBlocks);
77
+ return clampView({ start, end: start + length }, totalBlocks);
78
+ };
79
+
80
+ // src/axis.ts
81
+ var AXIS_ATTRIBUTE = "data-blockmap-axis";
82
+ var TICK_ATTRIBUTE = "data-blockmap-tick";
83
+ var FULL = "100%";
84
+ var CENTER = "translateX(-50%)";
85
+ var LEFT_EDGE = "translateX(0)";
86
+ var RIGHT_EDGE = "translateX(-100%)";
87
+ var COMPACT_THRESHOLD = 1e3;
88
+ var MAGNITUDE_STEP = 3;
89
+ var MAX_COMPACT_FRACTION_DIGITS = 3;
90
+ var groupedFormat = new Intl.NumberFormat("en-US");
91
+ var compactFormatters = /* @__PURE__ */ new Map();
92
+ var compactFormatterFor = (digits) => {
93
+ const cached = compactFormatters.get(digits);
94
+ if (cached) return cached;
95
+ const formatter = new Intl.NumberFormat("en-US", {
96
+ notation: "compact",
97
+ maximumFractionDigits: digits
98
+ });
99
+ compactFormatters.set(digits, formatter);
100
+ return formatter;
101
+ };
102
+ var defaultFormat = (block, step = 1) => {
103
+ if (block < COMPACT_THRESHOLD) return groupedFormat.format(block);
104
+ const magnitude = Math.floor(Math.log10(Math.max(Math.abs(block), 1)) / MAGNITUDE_STEP) * MAGNITUDE_STEP;
105
+ const unit = DECIMAL_BASE ** magnitude;
106
+ const digits = Math.max(0, Math.ceil(Math.log10(unit / step)));
107
+ if (digits > MAX_COMPACT_FRACTION_DIGITS) return groupedFormat.format(block);
108
+ return compactFormatterFor(digits).format(block);
109
+ };
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
+ var computeTicks = (view, cssWidth, tickCount) => {
120
+ if (cssWidth <= 0) return [];
121
+ const count = tickCount ?? Math.max(MIN_TICKS, Math.floor(cssWidth / TICK_SPACING_PX));
122
+ const scale = createScale(view, cssWidth);
123
+ const rawStep = tickStep(view.start, view.end, count);
124
+ const step = Math.max(1, rawStep);
125
+ const first = Math.ceil(view.start / step) * step;
126
+ const result = [];
127
+ for (let value = first; value <= view.end; value += step) {
128
+ result.push({ block: value, x: scale.x(value), step });
129
+ }
130
+ return result;
131
+ };
132
+ var anchorFor = (x, cssWidth) => {
133
+ const edge = TICK_SPACING_PX * EDGE_ANCHOR_RATIO;
134
+ const nearLeft = x < edge;
135
+ const nearRight = cssWidth - x < edge;
136
+ if (nearLeft && nearRight) return x < cssWidth - x ? LEFT_EDGE : RIGHT_EDGE;
137
+ if (nearLeft) return LEFT_EDGE;
138
+ if (nearRight) return RIGHT_EDGE;
139
+ return CENTER;
140
+ };
141
+ var createAxis = (container, options) => {
142
+ const format = options.format ?? defaultFormat;
143
+ ensureRelative(container);
144
+ const root = document.createElement("div");
145
+ root.setAttribute(AXIS_ATTRIBUTE, "");
146
+ root.setAttribute("aria-hidden", "true");
147
+ Object.assign(root.style, {
148
+ position: "absolute",
149
+ left: "0",
150
+ right: "0",
151
+ top: FULL,
152
+ pointerEvents: "none",
153
+ userSelect: "none",
154
+ color: `var(${CSS_VAR_AXIS_COLOR}, currentColor)`
155
+ });
156
+ container.append(root);
157
+ return {
158
+ update: (view, cssWidth) => {
159
+ const spans = computeTicks(view, cssWidth, options.tickCount).map((tick) => {
160
+ const span = document.createElement("span");
161
+ span.setAttribute(TICK_ATTRIBUTE, "");
162
+ span.dataset.block = String(tick.block);
163
+ span.textContent = format(tick.block, tick.step);
164
+ Object.assign(span.style, {
165
+ display: "block",
166
+ position: "absolute",
167
+ top: "0",
168
+ left: `${tick.x}px`,
169
+ transform: anchorFor(tick.x, cssWidth),
170
+ whiteSpace: "nowrap"
171
+ });
172
+ return span;
173
+ });
174
+ root.replaceChildren(...spans);
175
+ },
176
+ destroy: () => {
177
+ root.remove();
178
+ }
179
+ };
180
+ };
181
+
182
+ // src/colors.ts
183
+ import { rgb } from "d3-color";
184
+ import { interpolateRgb, piecewise } from "d3-interpolate";
185
+ var THEME_VARS = {
186
+ missing: CSS_VAR_MISSING,
187
+ partialLow: CSS_VAR_PARTIAL_LOW,
188
+ partialHigh: CSS_VAR_PARTIAL_HIGH,
189
+ partial: CSS_VAR_PARTIAL,
190
+ complete: CSS_VAR_COMPLETE,
191
+ highlight: CSS_VAR_HIGHLIGHT
192
+ };
193
+ var LAST_INDEX = COLOR_TABLE_SIZE - 1;
194
+ var FIRST_PARTIAL_INDEX = 1;
195
+ var LAST_PARTIAL_INDEX = LAST_INDEX - 1;
196
+ var parseOr = (value, fallback) => {
197
+ try {
198
+ return rgb(value).displayable() ? value : fallback;
199
+ } catch {
200
+ return fallback;
201
+ }
202
+ };
203
+ var writeColor = (table, index, css) => {
204
+ const color = rgb(css);
205
+ const offset = index * BYTES_PER_PIXEL;
206
+ table.set([color.r, color.g, color.b, OPAQUE_ALPHA], offset);
207
+ };
208
+ var readTheme = (el) => {
209
+ const style = getComputedStyle(el);
210
+ const read = (key) => style.getPropertyValue(THEME_VARS[key]).trim() || DEFAULT_THEME[key];
211
+ return {
212
+ missing: read("missing"),
213
+ partialLow: read("partialLow"),
214
+ partialHigh: read("partialHigh"),
215
+ partial: read("partial"),
216
+ complete: read("complete"),
217
+ highlight: read("highlight")
218
+ };
219
+ };
220
+ var createColorScale = (theme, partial = "coverage") => {
221
+ const table = new Uint8ClampedArray(COLOR_TABLE_SIZE * BYTES_PER_PIXEL);
222
+ const missing = parseOr(theme.missing, DEFAULT_THEME.missing);
223
+ const complete = parseOr(theme.complete, DEFAULT_THEME.complete);
224
+ const partialScale = partial === "coverage" ? piecewise(interpolateRgb, [
225
+ missing,
226
+ parseOr(theme.partial, DEFAULT_THEME.partial),
227
+ complete
228
+ ]) : interpolateRgb(
229
+ parseOr(theme.partialLow, DEFAULT_THEME.partialLow),
230
+ parseOr(theme.partialHigh, DEFAULT_THEME.partialHigh)
231
+ );
232
+ writeColor(table, 0, missing);
233
+ writeColor(table, LAST_INDEX, complete);
234
+ for (let i = FIRST_PARTIAL_INDEX; i <= LAST_PARTIAL_INDEX; i += 1) {
235
+ writeColor(table, i, partialScale(i / LAST_INDEX));
236
+ }
237
+ return (fraction) => {
238
+ let index = Math.round(fraction * LAST_INDEX);
239
+ if (fraction > 0 && index < FIRST_PARTIAL_INDEX) index = FIRST_PARTIAL_INDEX;
240
+ if (fraction < 1 && index > LAST_PARTIAL_INDEX) index = LAST_PARTIAL_INDEX;
241
+ const offset = index * BYTES_PER_PIXEL;
242
+ return table.subarray(offset, offset + BYTES_PER_PIXEL);
243
+ };
244
+ };
245
+ var highlightColor = (theme) => {
246
+ const color = rgb(parseOr(theme.highlight, DEFAULT_THEME.highlight));
247
+ return Uint8ClampedArray.of(color.r, color.g, color.b, OPAQUE_ALPHA);
248
+ };
249
+ var watchTheme = (onChange) => {
250
+ const observer = new MutationObserver(onChange);
251
+ observer.observe(document.documentElement, {
252
+ attributes: true,
253
+ attributeFilter: [...THEME_ATTRIBUTES]
254
+ });
255
+ const media = matchMedia(DARK_SCHEME_QUERY);
256
+ media.addEventListener("change", onChange);
257
+ return () => {
258
+ observer.disconnect();
259
+ media.removeEventListener("change", onChange);
260
+ };
261
+ };
262
+
263
+ // src/interaction.ts
264
+ var PRIMARY_BUTTON = 0;
265
+ var GRABBING = "grabbing";
266
+ var NO_POINTER = -1;
267
+ var attachInteraction = (canvas, deps) => {
268
+ let drag;
269
+ const columnsToBlocks = (px, view) => px * deps.getDpr() / deps.getBinCount() * viewLength(view);
270
+ const onWheel = (event) => {
271
+ if (!(event.ctrlKey || event.metaKey)) return;
272
+ if (drag?.active) return;
273
+ if (deps.getBinCount() === 0) return;
274
+ event.preventDefault();
275
+ const view = deps.getView();
276
+ const anchor = view.start + columnsToBlocks(event.offsetX, view);
277
+ const factor = event.deltaY > 0 ? ZOOM_STEP : 1 / ZOOM_STEP;
278
+ deps.setView(zoomView(view, anchor, factor, deps.getTotalBlocks()));
279
+ };
280
+ const onPointerDown = (event) => {
281
+ if (event.button !== PRIMARY_BUTTON) return;
282
+ drag = {
283
+ downX: event.offsetX,
284
+ view: deps.getView(),
285
+ pointerId: event.pointerId ?? NO_POINTER,
286
+ active: false
287
+ };
288
+ };
289
+ const onPointerMove = (event) => {
290
+ if (!drag) return;
291
+ const dx = event.offsetX - drag.downX;
292
+ if (!drag.active) {
293
+ if (Math.abs(dx) < DRAG_THRESHOLD_PX) return;
294
+ drag.active = true;
295
+ if (typeof canvas.setPointerCapture === "function" && drag.pointerId !== NO_POINTER) {
296
+ canvas.setPointerCapture(drag.pointerId);
297
+ }
298
+ canvas.style.cursor = GRABBING;
299
+ deps.onDragStart();
300
+ }
301
+ if (deps.getBinCount() === 0) return;
302
+ deps.setView(panView(drag.view, -columnsToBlocks(dx, drag.view), deps.getTotalBlocks()));
303
+ };
304
+ const endDrag = () => {
305
+ if (!drag) return;
306
+ 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);
310
+ }
311
+ canvas.style.cursor = "";
312
+ deps.onDragEnd();
313
+ }
314
+ drag = void 0;
315
+ };
316
+ 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);
323
+ return () => {
324
+ 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);
331
+ };
332
+ };
333
+
334
+ // src/renderer.ts
335
+ var NO_FRAME = 0;
336
+ var NO_TIMER = 0;
337
+ var COMPLETE = 1;
338
+ var RAF_SCHEDULER = {
339
+ request: (callback) => requestAnimationFrame(callback),
340
+ cancel: (handle) => cancelAnimationFrame(handle)
341
+ };
342
+ var WINDOW_TIMER = {
343
+ set: (callback, ms) => window.setInterval(callback, ms),
344
+ clear: (id) => window.clearInterval(id)
345
+ };
346
+ var createRenderer = (ctx, source, scheduler = RAF_SCHEDULER, timer = WINDOW_TIMER) => {
347
+ let image;
348
+ let scale;
349
+ let frame = NO_FRAME;
350
+ let scheduled = false;
351
+ let allDirty = false;
352
+ let highlight;
353
+ let phase = false;
354
+ let blinkId = NO_TIMER;
355
+ const dirty = /* @__PURE__ */ new Set();
356
+ const ensureImage = () => {
357
+ const { binCount } = source;
358
+ if (binCount === 0) return void 0;
359
+ if (!image || image.width !== binCount) {
360
+ image = ctx.createImageData(binCount, CANVAS_ROWS);
361
+ allDirty = true;
362
+ }
363
+ return image;
364
+ };
365
+ const colorFor = (colors, fraction) => highlight && phase && fraction < COMPLETE ? highlight : colors(fraction);
366
+ const writeColumn = (target, column, color) => {
367
+ target.data.set(color, column * BYTES_PER_PIXEL);
368
+ };
369
+ const paintDiscrete = (target, colors) => {
370
+ const { view, binCount } = source;
371
+ const length = viewLength(view);
372
+ for (let block = 0; block < length; block += 1) {
373
+ 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);
376
+ for (let column = start; column < end; column += 1) writeColumn(target, column, color);
377
+ }
378
+ };
379
+ const paintBins = (target, colors) => {
380
+ if (allDirty) {
381
+ for (let bin = 0; bin < source.binCount; bin += 1)
382
+ writeColumn(target, bin, colorFor(colors, source.fraction(bin)));
383
+ return;
384
+ }
385
+ for (const bin of dirty) {
386
+ if (bin >= target.width) continue;
387
+ writeColumn(target, bin, colorFor(colors, source.fraction(bin)));
388
+ }
389
+ };
390
+ const paint = () => {
391
+ scheduled = false;
392
+ frame = NO_FRAME;
393
+ const target = ensureImage();
394
+ if (!target || !scale) {
395
+ dirty.clear();
396
+ allDirty = false;
397
+ return;
398
+ }
399
+ if (viewLength(source.view) < source.binCount) paintDiscrete(target, scale);
400
+ else paintBins(target, scale);
401
+ ctx.putImageData(target, 0, 0);
402
+ dirty.clear();
403
+ allDirty = false;
404
+ };
405
+ const schedule = () => {
406
+ if (scheduled) return;
407
+ scheduled = true;
408
+ frame = scheduler.request(paint);
409
+ };
410
+ const markAllDirty = () => {
411
+ allDirty = true;
412
+ schedule();
413
+ };
414
+ const stopBlink = () => {
415
+ if (blinkId !== NO_TIMER) timer.clear(blinkId);
416
+ blinkId = NO_TIMER;
417
+ };
418
+ return {
419
+ setColors: (next) => {
420
+ scale = next;
421
+ markAllDirty();
422
+ },
423
+ setHighlight: (on, color, blink) => {
424
+ if (!on && highlight === void 0) return;
425
+ stopBlink();
426
+ highlight = on ? color : void 0;
427
+ phase = on;
428
+ if (on && blink) {
429
+ blinkId = timer.set(() => {
430
+ phase = !phase;
431
+ markAllDirty();
432
+ }, BLINK_INTERVAL_MS);
433
+ }
434
+ markAllDirty();
435
+ },
436
+ setHighlightColor: (color) => {
437
+ if (highlight === void 0) return;
438
+ highlight = color;
439
+ markAllDirty();
440
+ },
441
+ markDirty: (bins) => {
442
+ for (let i = 0; i < bins.length; i += 1) dirty.add(bins[i]);
443
+ if (bins.length > 0) schedule();
444
+ },
445
+ markAllDirty,
446
+ destroy: () => {
447
+ stopBlink();
448
+ if (scheduled) scheduler.cancel(frame);
449
+ scheduled = false;
450
+ frame = NO_FRAME;
451
+ dirty.clear();
452
+ }
453
+ };
454
+ };
455
+
456
+ // src/bitset.ts
457
+ var maskRange = (from, to) => (1 << to) - 1 & ~((1 << from) - 1);
458
+ var POPCOUNT = new Uint8Array(1 << BITS_PER_BYTE);
459
+ for (let i = 1; i < POPCOUNT.length; i += 1) {
460
+ POPCOUNT[i] = POPCOUNT[i >> 1] + (i & 1);
461
+ }
462
+ var createBitset = (size) => {
463
+ const bytes = new Uint8Array(Math.ceil(size / BITS_PER_BYTE));
464
+ let count = 0;
465
+ return {
466
+ has: (index) => (bytes[index >>> BYTE_SHIFT] & 1 << (index & BYTE_MASK)) !== 0,
467
+ set: (index) => {
468
+ const byte = index >>> BYTE_SHIFT;
469
+ const mask = 1 << (index & BYTE_MASK);
470
+ if ((bytes[byte] & mask) !== 0) return false;
471
+ bytes[byte] |= mask;
472
+ count += 1;
473
+ return true;
474
+ },
475
+ fill: (start, end) => {
476
+ if (end <= start) return;
477
+ const firstByte = start >>> BYTE_SHIFT;
478
+ const lastByte = end - 1 >>> BYTE_SHIFT;
479
+ const fromBit = start & BYTE_MASK;
480
+ const toBit = (end - 1 & BYTE_MASK) + 1;
481
+ if (firstByte === lastByte) {
482
+ bytes[firstByte] |= maskRange(fromBit, toBit);
483
+ return;
484
+ }
485
+ bytes[firstByte] |= maskRange(fromBit, BITS_PER_BYTE);
486
+ bytes.fill(FULL_BYTE, firstByte + 1, lastByte);
487
+ bytes[lastByte] |= maskRange(0, toBit);
488
+ },
489
+ clearRange: (start, end) => {
490
+ if (end <= start) return;
491
+ const firstByte = start >>> BYTE_SHIFT;
492
+ const lastByte = end - 1 >>> BYTE_SHIFT;
493
+ const fromBit = start & BYTE_MASK;
494
+ const toBit = (end - 1 & BYTE_MASK) + 1;
495
+ if (firstByte === lastByte) {
496
+ bytes[firstByte] &= ~maskRange(fromBit, toBit);
497
+ return;
498
+ }
499
+ bytes[firstByte] &= ~maskRange(fromBit, BITS_PER_BYTE);
500
+ bytes.fill(0, firstByte + 1, lastByte);
501
+ bytes[lastByte] &= ~maskRange(0, toBit);
502
+ },
503
+ countRange: (start, end) => {
504
+ if (end <= start) return 0;
505
+ const firstByte = start >>> BYTE_SHIFT;
506
+ const lastByte = end - 1 >>> BYTE_SHIFT;
507
+ const fromBit = start & BYTE_MASK;
508
+ const toBit = (end - 1 & BYTE_MASK) + 1;
509
+ if (firstByte === lastByte) {
510
+ return POPCOUNT[bytes[firstByte] & maskRange(fromBit, toBit)];
511
+ }
512
+ let total = POPCOUNT[bytes[firstByte] & maskRange(fromBit, BITS_PER_BYTE)];
513
+ for (let b = firstByte + 1; b < lastByte; b += 1) total += POPCOUNT[bytes[b]];
514
+ total += POPCOUNT[bytes[lastByte] & maskRange(0, toBit)];
515
+ return total;
516
+ },
517
+ clear: () => {
518
+ bytes.fill(0);
519
+ count = 0;
520
+ },
521
+ setCount: (next) => {
522
+ count = next;
523
+ },
524
+ get count() {
525
+ return count;
526
+ }
527
+ };
528
+ };
529
+
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
+ // src/ranges.ts
595
+ var isFlatPairs = (input) => ArrayBuffer.isView(input) || typeof input[0] === "number";
596
+ var readPairs = (input) => {
597
+ const pairs = [];
598
+ if (isFlatPairs(input)) {
599
+ const pairCount = Math.floor(input.length / RANGE_PAIR);
600
+ for (let i = 0; i < pairCount; i += 1) {
601
+ pairs.push([input[i * RANGE_PAIR], input[i * RANGE_PAIR + 1]]);
602
+ }
603
+ return pairs;
604
+ }
605
+ for (let i = 0; i < input.length; i += 1) {
606
+ const tuple = input[i];
607
+ pairs.push([tuple[0], tuple[1]]);
608
+ }
609
+ return pairs;
610
+ };
611
+ var normalizeRanges = (input, totalBlocks) => {
612
+ const clamped = [];
613
+ for (const [rawStart, rawEnd] of readPairs(input)) {
614
+ if (!Number.isInteger(rawStart) || !Number.isInteger(rawEnd)) continue;
615
+ const start = Math.max(0, rawStart);
616
+ const end = Math.min(totalBlocks, rawEnd);
617
+ if (end > start) clamped.push([start, end]);
618
+ }
619
+ clamped.sort((a, b) => a[0] - b[0]);
620
+ const merged = [];
621
+ for (const [start, end] of clamped) {
622
+ const lastIndex = merged.length - 1;
623
+ if (merged.length > 0 && start <= merged[lastIndex]) {
624
+ merged[lastIndex] = Math.max(merged[lastIndex], end);
625
+ } else {
626
+ merged.push(start, end);
627
+ }
628
+ }
629
+ return Uint32Array.from(merged);
630
+ };
631
+ var rangesLength = (flat) => {
632
+ let total = 0;
633
+ for (let i = 0; i < flat.length; i += RANGE_PAIR) total += flat[i + 1] - flat[i];
634
+ return total;
635
+ };
636
+
637
+ // src/state.ts
638
+ var EMPTY_DIRTY = new Uint32Array(0);
639
+ var isValidIndex = (index, totalBlocks) => Number.isInteger(index) && index >= 0 && index < totalBlocks;
640
+ var createBlockState = (totalBlocks) => {
641
+ if (!Number.isInteger(totalBlocks) || totalBlocks < 1) {
642
+ throw new RangeError(`totalBlocks must be a positive integer, got ${totalBlocks}`);
643
+ }
644
+ if (totalBlocks > MAX_BLOCKS) {
645
+ throw new RangeError(
646
+ `totalBlocks must fit in an unsigned 32-bit integer (<= ${MAX_BLOCKS}), got ${totalBlocks}`
647
+ );
648
+ }
649
+ const bitset = createBitset(totalBlocks);
650
+ let view = fullView(totalBlocks);
651
+ let bins = createBins(view, 0);
652
+ const inView = (index) => index >= view.start && index < view.end;
653
+ const add = (indices) => {
654
+ const hasBins = bins.binCount > 0;
655
+ const dirty = /* @__PURE__ */ new Set();
656
+ for (let i = 0; i < indices.length; i += 1) {
657
+ const index = indices[i];
658
+ if (!isValidIndex(index, totalBlocks) || !bitset.set(index)) continue;
659
+ if (!hasBins || !inView(index)) continue;
660
+ const bin = bins.binOf(index);
661
+ bins.counts[bin] += 1;
662
+ dirty.add(bin);
663
+ }
664
+ return dirty.size === 0 ? EMPTY_DIRTY : Uint32Array.from(dirty).sort();
665
+ };
666
+ const applyRanges = (input, complete) => {
667
+ const flat = normalizeRanges(input, totalBlocks);
668
+ const dirty = /* @__PURE__ */ new Set();
669
+ let delta = 0;
670
+ for (let i = 0; i < flat.length; i += RANGE_PAIR) {
671
+ const start = flat[i];
672
+ const end = flat[i + 1];
673
+ const present = bitset.countRange(start, end);
674
+ if (complete) {
675
+ bitset.fill(start, end);
676
+ delta += end - start - present;
677
+ } else {
678
+ bitset.clearRange(start, end);
679
+ delta -= present;
680
+ }
681
+ for (const bin of bins.binsTouching(start, end)) dirty.add(bin);
682
+ }
683
+ bins.recountBins(dirty, bitset.countRange);
684
+ bitset.setCount(bitset.count + delta);
685
+ return dirty.size === 0 ? EMPTY_DIRTY : Uint32Array.from(dirty).sort();
686
+ };
687
+ return {
688
+ totalBlocks,
689
+ get binCount() {
690
+ return bins.binCount;
691
+ },
692
+ get view() {
693
+ return view;
694
+ },
695
+ resize: (binCount) => {
696
+ bins = createBins(view, binCount);
697
+ bins.rebuild(bitset.countRange);
698
+ },
699
+ 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;
704
+ bins = createBins(view, bins.binCount);
705
+ bins.rebuild(bitset.countRange);
706
+ return true;
707
+ },
708
+ getView: () => ({ ...view }),
709
+ add,
710
+ replace: (indices) => {
711
+ bitset.clear();
712
+ bins.counts.fill(0);
713
+ add(indices);
714
+ },
715
+ replaceRanges: (input) => {
716
+ const flat = normalizeRanges(input, totalBlocks);
717
+ bitset.clear();
718
+ bins.counts.fill(0);
719
+ for (let i = 0; i < flat.length; i += RANGE_PAIR) {
720
+ bitset.fill(flat[i], flat[i + 1]);
721
+ bins.addRange(flat[i], flat[i + 1]);
722
+ }
723
+ bitset.setCount(rangesLength(flat));
724
+ },
725
+ addRanges: (input) => applyRanges(input, true),
726
+ removeRanges: (input) => applyRanges(input, false),
727
+ has: bitset.has,
728
+ fraction: (bin) => {
729
+ const size = bins.sizeOf(bin);
730
+ return size === 0 ? 0 : bins.counts[bin] / size;
731
+ },
732
+ countOf: (bin) => bins.counts[bin],
733
+ sizeOf: (bin) => bins.sizeOf(bin),
734
+ rangeOf: (bin) => bins.rangeOf(bin),
735
+ progress: () => ({ completed: bitset.count, total: totalBlocks })
736
+ };
737
+ };
738
+
739
+ // src/tooltip.ts
740
+ var LOCALE = "en-US";
741
+ var TOOLTIP_ATTRIBUTE = "data-blockmap-tooltip";
742
+ var SEPARATOR = " \xB7 ";
743
+ var EN_DASH = "\u2013";
744
+ var countFormat = new Intl.NumberFormat(LOCALE);
745
+ var MIN_PARTIAL_STEP = 1 / DECIMAL_BASE ** PERCENT_FRACTION_DIGITS;
746
+ var MAX_PARTIAL = (PERCENT - MIN_PARTIAL_STEP).toFixed(PERCENT_FRACTION_DIGITS);
747
+ var MIN_PARTIAL = MIN_PARTIAL_STEP.toFixed(PERCENT_FRACTION_DIGITS);
748
+ var MAX_FIXED = PERCENT.toFixed(PERCENT_FRACTION_DIGITS);
749
+ var MIN_FIXED = 0 .toFixed(PERCENT_FRACTION_DIGITS);
750
+ var formatPercent = (count, size) => {
751
+ if (count === 0) return "0%";
752
+ if (count === size) return "100%";
753
+ const value = count / size * PERCENT;
754
+ const text = value.toFixed(PERCENT_FRACTION_DIGITS);
755
+ if (text === MAX_FIXED) {
756
+ return `>${MAX_PARTIAL}%`;
757
+ }
758
+ if (text === MIN_FIXED) {
759
+ return `<${MIN_PARTIAL}%`;
760
+ }
761
+ return `${text}%`;
762
+ };
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)}`;
766
+ return `${label}${SEPARATOR}${countFormat.format(count)} / ${countFormat.format(size)} (${formatPercent(count, size)})`;
767
+ };
768
+ var formatBlockTooltip = (index, complete) => `Block ${countFormat.format(index)}${SEPARATOR}${complete ? "complete" : "missing"}`;
769
+ var createTooltip = (container) => {
770
+ const el = document.createElement("div");
771
+ el.setAttribute(TOOLTIP_ATTRIBUTE, "");
772
+ Object.assign(el.style, {
773
+ position: "absolute",
774
+ top: "0",
775
+ left: "0",
776
+ display: "none",
777
+ pointerEvents: "none",
778
+ whiteSpace: "nowrap",
779
+ transform: "translateY(-100%)"
780
+ });
781
+ ensureRelative(container);
782
+ container.append(el);
783
+ return {
784
+ show: (x, text) => {
785
+ el.textContent = text;
786
+ el.style.left = `${x + TOOLTIP_OFFSET_PX}px`;
787
+ el.style.display = "block";
788
+ },
789
+ hide: () => {
790
+ el.style.display = "none";
791
+ },
792
+ destroy: () => {
793
+ el.remove();
794
+ }
795
+ };
796
+ };
797
+
798
+ // src/blockMap.ts
799
+ var FULL2 = "100%";
800
+ var CONTEXT_ID = "2d";
801
+ var NO_FRAME2 = 0;
802
+ var dprQuery = (dpr) => `(resolution: ${dpr}dppx)`;
803
+ var discreteBlock = (column, length, binCount, start) => start + Math.ceil((column + 1) * length / binCount) - 1;
804
+ var createBlockMap = (container, options) => {
805
+ const state = createBlockState(options.totalBlocks);
806
+ if (options.view) state.setView(options.view);
807
+ const canvas = document.createElement("canvas");
808
+ Object.assign(canvas.style, {
809
+ display: "block",
810
+ width: FULL2,
811
+ height: FULL2,
812
+ touchAction: "none"
813
+ });
814
+ canvas.height = CANVAS_ROWS;
815
+ container.append(canvas);
816
+ const ctx = canvas.getContext(CONTEXT_ID);
817
+ if (!ctx) {
818
+ canvas.remove();
819
+ throw new Error(`Could not acquire a ${CONTEXT_ID} canvas context`);
820
+ }
821
+ 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
+ let destroyed = false;
826
+ let cssWidth = 0;
827
+ let dpr = devicePixelRatio;
828
+ let dprMedia;
829
+ let theme = readTheme(container);
830
+ let highlightOn = false;
831
+ let dragging = false;
832
+ const blinkAllowed = () => !matchMedia(REDUCED_MOTION_QUERY).matches;
833
+ const applyHighlight = () => {
834
+ renderer.setHighlight(highlightOn, highlightColor(theme), blinkAllowed());
835
+ };
836
+ const applyTheme = () => {
837
+ theme = readTheme(container);
838
+ renderer.setColors(createColorScale(theme, options.partial));
839
+ if (highlightOn) renderer.setHighlightColor(highlightColor(theme));
840
+ };
841
+ let pendingView;
842
+ let viewFrame = NO_FRAME2;
843
+ const getView = () => ({ ...pendingView ?? state.view });
844
+ const commitView = () => {
845
+ viewFrame = NO_FRAME2;
846
+ const next = pendingView;
847
+ pendingView = void 0;
848
+ if (!next || !state.setView(next)) return;
849
+ renderer.markAllDirty();
850
+ axis?.update(state.view, cssWidth);
851
+ options.onViewChange?.(state.getView());
852
+ };
853
+ 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);
860
+ };
861
+ const resetView = () => setView(fullView(state.totalBlocks));
862
+ const layout = () => {
863
+ 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);
869
+ };
870
+ const onDprChange = () => {
871
+ dpr = devicePixelRatio;
872
+ layout();
873
+ watchDpr();
874
+ };
875
+ const watchDpr = () => {
876
+ dprMedia?.removeEventListener("change", onDprChange);
877
+ dprMedia = matchMedia(dprQuery(dpr));
878
+ dprMedia.addEventListener("change", onDprChange, { once: true });
879
+ };
880
+ const resizeObserver = new ResizeObserver((entries) => {
881
+ const entry = entries[0];
882
+ if (!entry) return;
883
+ cssWidth = entry.contentRect.width;
884
+ layout();
885
+ });
886
+ 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)));
892
+ const length = viewLength(view);
893
+ let text;
894
+ 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));
899
+ }
900
+ tooltip.show(event.offsetX, text);
901
+ };
902
+ const onPointerLeave = () => tooltip.hide();
903
+ canvas.addEventListener("pointermove", onPointerMove);
904
+ canvas.addEventListener("pointerleave", onPointerLeave);
905
+ const detachInteraction = options.interactive === false ? void 0 : attachInteraction(canvas, {
906
+ getView,
907
+ setView,
908
+ resetView,
909
+ getBinCount: () => state.binCount,
910
+ getDpr: () => dpr,
911
+ getTotalBlocks: () => state.totalBlocks,
912
+ onDragStart: () => {
913
+ dragging = true;
914
+ tooltip.hide();
915
+ },
916
+ onDragEnd: () => {
917
+ dragging = false;
918
+ }
919
+ });
920
+ const unwatchTheme = watchTheme(applyTheme);
921
+ watchDpr();
922
+ applyTheme();
923
+ if (options.initialRanges) state.replaceRanges(options.initialRanges);
924
+ if (options.initialBlocks) renderer.markDirty(state.add(options.initialBlocks));
925
+ return {
926
+ setBlocks: (indices) => {
927
+ if (destroyed) return;
928
+ state.replace(indices);
929
+ renderer.markAllDirty();
930
+ },
931
+ setRanges: (ranges) => {
932
+ if (destroyed) return;
933
+ state.replaceRanges(ranges);
934
+ renderer.markAllDirty();
935
+ },
936
+ addBlocks: (indices) => {
937
+ if (destroyed) return;
938
+ renderer.markDirty(state.add(indices));
939
+ },
940
+ addRanges: (ranges) => {
941
+ if (destroyed) return;
942
+ renderer.markDirty(state.addRanges(ranges));
943
+ },
944
+ removeRanges: (ranges) => {
945
+ if (destroyed) return;
946
+ 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
+ },
956
+ getView,
957
+ getScale: () => createScale(state.getView(), cssWidth),
958
+ setHighlight: (on) => {
959
+ if (destroyed) return;
960
+ highlightOn = on;
961
+ applyHighlight();
962
+ },
963
+ getProgress: () => destroyed ? { completed: 0, total: state.totalBlocks } : state.progress(),
964
+ destroy: () => {
965
+ if (destroyed) return;
966
+ destroyed = true;
967
+ if (viewFrame !== NO_FRAME2) cancelAnimationFrame(viewFrame);
968
+ viewFrame = NO_FRAME2;
969
+ pendingView = void 0;
970
+ resizeObserver.disconnect();
971
+ unwatchTheme();
972
+ dprMedia?.removeEventListener("change", onDprChange);
973
+ canvas.removeEventListener("pointermove", onPointerMove);
974
+ canvas.removeEventListener("pointerleave", onPointerLeave);
975
+ detachInteraction?.();
976
+ renderer.destroy();
977
+ tooltip.destroy();
978
+ axis?.destroy();
979
+ canvas.remove();
980
+ }
981
+ };
982
+ };
983
+
984
+ export {
985
+ createBlockMap
986
+ };
987
+ //# sourceMappingURL=chunk-WIDC3EN2.js.map