@unpunnyfuns/d3-blockmap 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +139 -2
- package/dist/chunk-6BBJWE5O.js +1288 -0
- package/dist/chunk-6BBJWE5O.js.map +1 -0
- package/dist/index.d.ts +114 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/react/index.d.ts +28 -0
- package/dist/react/index.js +99 -0
- package/dist/react/index.js.map +1 -0
- package/package.json +88 -4
|
@@ -0,0 +1,1288 @@
|
|
|
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 FULL_BYTE = 255;
|
|
9
|
+
var BYTES_PER_PIXEL = 4;
|
|
10
|
+
var OPAQUE_ALPHA = 255;
|
|
11
|
+
var COLOR_TABLE_SIZE = 256;
|
|
12
|
+
var CANVAS_ROWS = 1;
|
|
13
|
+
var PERCENT = 100;
|
|
14
|
+
var PERCENT_FRACTION_DIGITS = 1;
|
|
15
|
+
var TOOLTIP_OFFSET_PX = 8;
|
|
16
|
+
var CSS_VAR_MISSING = "--blockmap-missing";
|
|
17
|
+
var CSS_VAR_PARTIAL_LOW = "--blockmap-partial-low";
|
|
18
|
+
var CSS_VAR_PARTIAL_HIGH = "--blockmap-partial-high";
|
|
19
|
+
var CSS_VAR_PARTIAL = "--blockmap-partial";
|
|
20
|
+
var CSS_VAR_COMPLETE = "--blockmap-complete";
|
|
21
|
+
var CSS_VAR_HIGHLIGHT = "--blockmap-highlight";
|
|
22
|
+
var CSS_VAR_BRUSH = "--blockmap-brush";
|
|
23
|
+
var DEFAULT_THEME = {
|
|
24
|
+
missing: "#2b2b2b",
|
|
25
|
+
partialLow: "#5b3a00",
|
|
26
|
+
partialHigh: "#e0a500",
|
|
27
|
+
partial: "#e0a500",
|
|
28
|
+
complete: "#2ecc71",
|
|
29
|
+
highlight: "#ff4d4f",
|
|
30
|
+
brush: "#ffffff"
|
|
31
|
+
};
|
|
32
|
+
var BRUSH_FILL_OPACITY = 0.3;
|
|
33
|
+
var THEME_ATTRIBUTES = ["class", "style", "data-theme"];
|
|
34
|
+
var DARK_SCHEME_QUERY = "(prefers-color-scheme: dark)";
|
|
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";
|
|
45
|
+
var CSS_VAR_AXIS_COLOR = "--blockmap-axis-color";
|
|
46
|
+
var TICK_SPACING_PX = 80;
|
|
47
|
+
var MIN_TICKS = 2;
|
|
48
|
+
var EDGE_ANCHOR_RATIO = 0.5;
|
|
49
|
+
var DECIMAL_BASE = 10;
|
|
50
|
+
var RANGE_PAIR = 2;
|
|
51
|
+
var MAX_BLOCKS = 4294967295;
|
|
52
|
+
|
|
53
|
+
// src/dom.ts
|
|
54
|
+
var ensureRelative = (container) => {
|
|
55
|
+
const position = getComputedStyle(container).position;
|
|
56
|
+
if (position === "static" || !position) container.style.position = "relative";
|
|
57
|
+
};
|
|
58
|
+
var canvasX = (canvas, event) => event.clientX - canvas.getBoundingClientRect().left;
|
|
59
|
+
|
|
60
|
+
// src/view.ts
|
|
61
|
+
var fullView = (totalBlocks) => ({ start: 0, end: totalBlocks });
|
|
62
|
+
var viewLength = (view) => view.end - view.start;
|
|
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
|
+
};
|
|
73
|
+
var clampView = (view, totalBlocks) => {
|
|
74
|
+
let start = Math.round(Math.min(view.start, view.end));
|
|
75
|
+
let end = Math.round(Math.max(view.start, view.end));
|
|
76
|
+
if (end - start < MIN_VIEW_LENGTH) end = start + MIN_VIEW_LENGTH;
|
|
77
|
+
const length = Math.min(end - start, totalBlocks);
|
|
78
|
+
start = Math.max(0, Math.min(start, totalBlocks - length));
|
|
79
|
+
return { start, end: start + length };
|
|
80
|
+
};
|
|
81
|
+
var legalView = (view, totalBlocks) => Number.isFinite(view.start) && Number.isFinite(view.end) ? clampView(view, totalBlocks) : void 0;
|
|
82
|
+
var zoomView = (view, anchor, factor, totalBlocks) => {
|
|
83
|
+
const length = viewLength(view);
|
|
84
|
+
const scaled = length * factor;
|
|
85
|
+
const nextLength = Math.max(
|
|
86
|
+
MIN_VIEW_LENGTH,
|
|
87
|
+
Math.min(totalBlocks, factor < 1 ? Math.floor(scaled) : Math.ceil(scaled))
|
|
88
|
+
);
|
|
89
|
+
const ratio = (anchor - view.start) / length;
|
|
90
|
+
const start = Math.round(anchor - ratio * nextLength);
|
|
91
|
+
return clampView({ start, end: start + nextLength }, totalBlocks);
|
|
92
|
+
};
|
|
93
|
+
var panView = (view, deltaBlocks, totalBlocks) => {
|
|
94
|
+
const length = viewLength(view);
|
|
95
|
+
const start = Math.round(view.start + deltaBlocks);
|
|
96
|
+
return clampView({ start, end: start + length }, totalBlocks);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// src/axis.ts
|
|
100
|
+
var AXIS_ATTRIBUTE = "data-blockmap-axis";
|
|
101
|
+
var TICK_ATTRIBUTE = "data-blockmap-tick";
|
|
102
|
+
var FULL = "100%";
|
|
103
|
+
var CENTER = "translateX(-50%)";
|
|
104
|
+
var LEFT_EDGE = "translateX(0)";
|
|
105
|
+
var RIGHT_EDGE = "translateX(-100%)";
|
|
106
|
+
var COMPACT_THRESHOLD = 1e3;
|
|
107
|
+
var MAGNITUDE_STEP = 3;
|
|
108
|
+
var MAX_COMPACT_FRACTION_DIGITS = 3;
|
|
109
|
+
var groupedFormat = new Intl.NumberFormat("en-US");
|
|
110
|
+
var compactFormatters = /* @__PURE__ */ new Map();
|
|
111
|
+
var compactFormatterFor = (digits) => {
|
|
112
|
+
const cached = compactFormatters.get(digits);
|
|
113
|
+
if (cached) return cached;
|
|
114
|
+
const formatter = new Intl.NumberFormat("en-US", {
|
|
115
|
+
notation: "compact",
|
|
116
|
+
maximumFractionDigits: digits
|
|
117
|
+
});
|
|
118
|
+
compactFormatters.set(digits, formatter);
|
|
119
|
+
return formatter;
|
|
120
|
+
};
|
|
121
|
+
var defaultFormat = (block, step = 1) => {
|
|
122
|
+
if (block < COMPACT_THRESHOLD) return groupedFormat.format(block);
|
|
123
|
+
const magnitude = Math.floor(Math.log10(Math.max(Math.abs(block), 1)) / MAGNITUDE_STEP) * MAGNITUDE_STEP;
|
|
124
|
+
const unit = DECIMAL_BASE ** magnitude;
|
|
125
|
+
const digits = Math.max(0, Math.ceil(Math.log10(unit / step)));
|
|
126
|
+
if (digits > MAX_COMPACT_FRACTION_DIGITS) return groupedFormat.format(block);
|
|
127
|
+
return compactFormatterFor(digits).format(block);
|
|
128
|
+
};
|
|
129
|
+
var computeTicks = (view, cssWidth, tickCount) => {
|
|
130
|
+
if (cssWidth <= 0) return [];
|
|
131
|
+
const count = tickCount ?? Math.max(MIN_TICKS, Math.floor(cssWidth / TICK_SPACING_PX));
|
|
132
|
+
const scale = createScale(view, cssWidth);
|
|
133
|
+
const rawStep = tickStep(view.start, view.end, count);
|
|
134
|
+
const step = Math.max(1, rawStep);
|
|
135
|
+
const first = Math.ceil(view.start / step) * step;
|
|
136
|
+
const result = [];
|
|
137
|
+
for (let value = first; value <= view.end; value += step) {
|
|
138
|
+
result.push({ block: value, x: scale.x(value), step });
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
};
|
|
142
|
+
var anchorFor = (x, cssWidth) => {
|
|
143
|
+
const edge = TICK_SPACING_PX * EDGE_ANCHOR_RATIO;
|
|
144
|
+
const nearLeft = x < edge;
|
|
145
|
+
const nearRight = cssWidth - x < edge;
|
|
146
|
+
if (nearLeft && nearRight) return x < cssWidth - x ? LEFT_EDGE : RIGHT_EDGE;
|
|
147
|
+
if (nearLeft) return LEFT_EDGE;
|
|
148
|
+
if (nearRight) return RIGHT_EDGE;
|
|
149
|
+
return CENTER;
|
|
150
|
+
};
|
|
151
|
+
var createAxis = (container, options) => {
|
|
152
|
+
const format = options.format ?? defaultFormat;
|
|
153
|
+
ensureRelative(container);
|
|
154
|
+
const root = document.createElement("div");
|
|
155
|
+
root.setAttribute(AXIS_ATTRIBUTE, "");
|
|
156
|
+
root.setAttribute("aria-hidden", "true");
|
|
157
|
+
Object.assign(root.style, {
|
|
158
|
+
position: "absolute",
|
|
159
|
+
left: "0",
|
|
160
|
+
right: "0",
|
|
161
|
+
top: FULL,
|
|
162
|
+
pointerEvents: "none",
|
|
163
|
+
userSelect: "none",
|
|
164
|
+
color: `var(${CSS_VAR_AXIS_COLOR}, currentColor)`
|
|
165
|
+
});
|
|
166
|
+
container.append(root);
|
|
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.
|
|
171
|
+
update: (view, cssWidth) => {
|
|
172
|
+
const spans = computeTicks(view, cssWidth, options.tickCount).map((tick) => {
|
|
173
|
+
const span = document.createElement("span");
|
|
174
|
+
span.setAttribute(TICK_ATTRIBUTE, "");
|
|
175
|
+
span.dataset.block = String(tick.block);
|
|
176
|
+
span.textContent = format(tick.block, tick.step);
|
|
177
|
+
Object.assign(span.style, {
|
|
178
|
+
display: "block",
|
|
179
|
+
position: "absolute",
|
|
180
|
+
top: "0",
|
|
181
|
+
left: `${tick.x}px`,
|
|
182
|
+
transform: anchorFor(tick.x, cssWidth),
|
|
183
|
+
whiteSpace: "nowrap"
|
|
184
|
+
});
|
|
185
|
+
return span;
|
|
186
|
+
});
|
|
187
|
+
root.replaceChildren(...spans);
|
|
188
|
+
},
|
|
189
|
+
destroy: () => {
|
|
190
|
+
root.remove();
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
};
|
|
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
|
+
|
|
397
|
+
// src/colors.ts
|
|
398
|
+
import { rgb } from "d3-color";
|
|
399
|
+
import { interpolateRgb, piecewise } from "d3-interpolate";
|
|
400
|
+
var THEME_VARS = {
|
|
401
|
+
missing: CSS_VAR_MISSING,
|
|
402
|
+
partialLow: CSS_VAR_PARTIAL_LOW,
|
|
403
|
+
partialHigh: CSS_VAR_PARTIAL_HIGH,
|
|
404
|
+
partial: CSS_VAR_PARTIAL,
|
|
405
|
+
complete: CSS_VAR_COMPLETE,
|
|
406
|
+
highlight: CSS_VAR_HIGHLIGHT,
|
|
407
|
+
brush: CSS_VAR_BRUSH
|
|
408
|
+
};
|
|
409
|
+
var LAST_INDEX = COLOR_TABLE_SIZE - 1;
|
|
410
|
+
var FIRST_PARTIAL_INDEX = 1;
|
|
411
|
+
var LAST_PARTIAL_INDEX = LAST_INDEX - 1;
|
|
412
|
+
var parseOr = (value, fallback) => {
|
|
413
|
+
try {
|
|
414
|
+
return rgb(value).displayable() ? value : fallback;
|
|
415
|
+
} catch {
|
|
416
|
+
return fallback;
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
var writeColor = (table, index, css) => {
|
|
420
|
+
const color = rgb(css);
|
|
421
|
+
const offset = index * BYTES_PER_PIXEL;
|
|
422
|
+
table.set([color.r, color.g, color.b, OPAQUE_ALPHA], offset);
|
|
423
|
+
};
|
|
424
|
+
var readTheme = (el) => {
|
|
425
|
+
const style = getComputedStyle(el);
|
|
426
|
+
const read = (key) => style.getPropertyValue(THEME_VARS[key]).trim() || DEFAULT_THEME[key];
|
|
427
|
+
return {
|
|
428
|
+
missing: read("missing"),
|
|
429
|
+
partialLow: read("partialLow"),
|
|
430
|
+
partialHigh: read("partialHigh"),
|
|
431
|
+
partial: read("partial"),
|
|
432
|
+
complete: read("complete"),
|
|
433
|
+
highlight: read("highlight"),
|
|
434
|
+
brush: read("brush")
|
|
435
|
+
};
|
|
436
|
+
};
|
|
437
|
+
var createColorScale = (theme, partial = "coverage") => {
|
|
438
|
+
const table = new Uint8ClampedArray(COLOR_TABLE_SIZE * BYTES_PER_PIXEL);
|
|
439
|
+
const missing = parseOr(theme.missing, DEFAULT_THEME.missing);
|
|
440
|
+
const complete = parseOr(theme.complete, DEFAULT_THEME.complete);
|
|
441
|
+
const partialScale = partial === "coverage" ? piecewise(interpolateRgb, [
|
|
442
|
+
missing,
|
|
443
|
+
parseOr(theme.partial, DEFAULT_THEME.partial),
|
|
444
|
+
complete
|
|
445
|
+
]) : interpolateRgb(
|
|
446
|
+
parseOr(theme.partialLow, DEFAULT_THEME.partialLow),
|
|
447
|
+
parseOr(theme.partialHigh, DEFAULT_THEME.partialHigh)
|
|
448
|
+
);
|
|
449
|
+
writeColor(table, 0, missing);
|
|
450
|
+
writeColor(table, LAST_INDEX, complete);
|
|
451
|
+
for (let i = FIRST_PARTIAL_INDEX; i <= LAST_PARTIAL_INDEX; i += 1) {
|
|
452
|
+
writeColor(table, i, partialScale(i / LAST_INDEX));
|
|
453
|
+
}
|
|
454
|
+
return (fraction) => {
|
|
455
|
+
let index = Math.round(fraction * LAST_INDEX);
|
|
456
|
+
if (fraction > 0 && index < FIRST_PARTIAL_INDEX) index = FIRST_PARTIAL_INDEX;
|
|
457
|
+
if (fraction < 1 && index > LAST_PARTIAL_INDEX) index = LAST_PARTIAL_INDEX;
|
|
458
|
+
const offset = index * BYTES_PER_PIXEL;
|
|
459
|
+
return table.subarray(offset, offset + BYTES_PER_PIXEL);
|
|
460
|
+
};
|
|
461
|
+
};
|
|
462
|
+
var highlightColor = (theme) => {
|
|
463
|
+
const color = rgb(parseOr(theme.highlight, DEFAULT_THEME.highlight));
|
|
464
|
+
return Uint8ClampedArray.of(color.r, color.g, color.b, OPAQUE_ALPHA);
|
|
465
|
+
};
|
|
466
|
+
var watchTheme = (onChange) => {
|
|
467
|
+
const observer = new MutationObserver(onChange);
|
|
468
|
+
observer.observe(document.documentElement, {
|
|
469
|
+
attributes: true,
|
|
470
|
+
attributeFilter: [...THEME_ATTRIBUTES]
|
|
471
|
+
});
|
|
472
|
+
const media = matchMedia(DARK_SCHEME_QUERY);
|
|
473
|
+
media.addEventListener("change", onChange);
|
|
474
|
+
return () => {
|
|
475
|
+
observer.disconnect();
|
|
476
|
+
media.removeEventListener("change", onChange);
|
|
477
|
+
};
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
// src/interaction.ts
|
|
481
|
+
var PRIMARY_BUTTON = 0;
|
|
482
|
+
var TOUCH_POINTER = "touch";
|
|
483
|
+
var GRABBING = "grabbing";
|
|
484
|
+
var NO_POINTER = -1;
|
|
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) => {
|
|
490
|
+
let drag;
|
|
491
|
+
const columnsToBlocks = (px, view) => px * deps.getDpr() / deps.getBinCount() * viewLength(view);
|
|
492
|
+
const onWheel = (event) => {
|
|
493
|
+
if (!(event.ctrlKey || event.metaKey)) return;
|
|
494
|
+
if (drag?.active) return;
|
|
495
|
+
if (deps.getBinCount() === 0) return;
|
|
496
|
+
event.preventDefault();
|
|
497
|
+
const view = deps.getView();
|
|
498
|
+
const anchor = view.start + columnsToBlocks(canvasX(canvas, event), view);
|
|
499
|
+
const factor = event.deltaY > 0 ? ZOOM_STEP : 1 / ZOOM_STEP;
|
|
500
|
+
deps.setView(zoomView(view, anchor, factor, deps.getTotalBlocks()));
|
|
501
|
+
};
|
|
502
|
+
const onPointerDown = (event) => {
|
|
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;
|
|
508
|
+
drag = {
|
|
509
|
+
downX: canvasX(canvas, event),
|
|
510
|
+
view: deps.getView(),
|
|
511
|
+
pointerId: event.pointerId ?? NO_POINTER,
|
|
512
|
+
active: false
|
|
513
|
+
};
|
|
514
|
+
};
|
|
515
|
+
const onPointerMove = (event) => {
|
|
516
|
+
if (!drag) return;
|
|
517
|
+
const dx = canvasX(canvas, event) - drag.downX;
|
|
518
|
+
if (!drag.active) {
|
|
519
|
+
if (Math.abs(dx) < DRAG_THRESHOLD_PX) return;
|
|
520
|
+
drag.active = true;
|
|
521
|
+
if (typeof surface.setPointerCapture === "function" && drag.pointerId !== NO_POINTER) {
|
|
522
|
+
surface.setPointerCapture(drag.pointerId);
|
|
523
|
+
}
|
|
524
|
+
surface.style.cursor = GRABBING;
|
|
525
|
+
deps.onDragStart();
|
|
526
|
+
}
|
|
527
|
+
if (deps.getBinCount() === 0) return;
|
|
528
|
+
deps.setView(panView(drag.view, -columnsToBlocks(dx, drag.view), deps.getTotalBlocks()));
|
|
529
|
+
};
|
|
530
|
+
const endDrag = () => {
|
|
531
|
+
if (!drag) return;
|
|
532
|
+
if (drag.active) {
|
|
533
|
+
const hasCapture = drag.pointerId !== NO_POINTER && (surface.hasPointerCapture?.(drag.pointerId) ?? true);
|
|
534
|
+
if (typeof surface.releasePointerCapture === "function" && hasCapture) {
|
|
535
|
+
surface.releasePointerCapture(drag.pointerId);
|
|
536
|
+
}
|
|
537
|
+
surface.style.cursor = "";
|
|
538
|
+
deps.onDragEnd();
|
|
539
|
+
}
|
|
540
|
+
drag = void 0;
|
|
541
|
+
};
|
|
542
|
+
const onDblClick = () => deps.resetView();
|
|
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);
|
|
572
|
+
return () => {
|
|
573
|
+
endDrag();
|
|
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);
|
|
581
|
+
};
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
// src/renderer.ts
|
|
585
|
+
var NO_FRAME = 0;
|
|
586
|
+
var NO_TIMER = 0;
|
|
587
|
+
var COMPLETE = 1;
|
|
588
|
+
var RAF_SCHEDULER = {
|
|
589
|
+
request: (callback) => requestAnimationFrame(callback),
|
|
590
|
+
cancel: (handle) => cancelAnimationFrame(handle)
|
|
591
|
+
};
|
|
592
|
+
var WINDOW_TIMER = {
|
|
593
|
+
set: (callback, ms) => window.setInterval(callback, ms),
|
|
594
|
+
clear: (id) => window.clearInterval(id)
|
|
595
|
+
};
|
|
596
|
+
var createRenderer = (ctx, source, scheduler = RAF_SCHEDULER, timer = WINDOW_TIMER) => {
|
|
597
|
+
let image;
|
|
598
|
+
let scale;
|
|
599
|
+
let frame = NO_FRAME;
|
|
600
|
+
let allDirty = false;
|
|
601
|
+
let highlight;
|
|
602
|
+
let phase = false;
|
|
603
|
+
let blinkId = NO_TIMER;
|
|
604
|
+
const dirty = /* @__PURE__ */ new Set();
|
|
605
|
+
const ensureImage = () => {
|
|
606
|
+
const { binCount } = source;
|
|
607
|
+
if (binCount === 0) return void 0;
|
|
608
|
+
if (!image || image.width !== binCount) {
|
|
609
|
+
image = ctx.createImageData(binCount, CANVAS_ROWS);
|
|
610
|
+
allDirty = true;
|
|
611
|
+
}
|
|
612
|
+
return image;
|
|
613
|
+
};
|
|
614
|
+
const colorFor = (colors, fraction) => highlight && phase && fraction < COMPLETE ? highlight : colors(fraction);
|
|
615
|
+
const writeColumn = (target, column, color) => {
|
|
616
|
+
target.data.set(color, column * BYTES_PER_PIXEL);
|
|
617
|
+
};
|
|
618
|
+
const paintDiscrete = (target, colors) => {
|
|
619
|
+
const { binCount } = source;
|
|
620
|
+
const view = source.getView();
|
|
621
|
+
const length = viewLength(view);
|
|
622
|
+
for (let block = 0; block < length; block += 1) {
|
|
623
|
+
const color = colorFor(colors, source.has(view.start + block) ? COMPLETE : 0);
|
|
624
|
+
const start = discreteColumn(block, length, binCount);
|
|
625
|
+
const end = discreteColumn(block + 1, length, binCount);
|
|
626
|
+
for (let column = start; column < end; column += 1) writeColumn(target, column, color);
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
const paintBins = (target, colors) => {
|
|
630
|
+
if (allDirty) {
|
|
631
|
+
for (let bin = 0; bin < source.binCount; bin += 1)
|
|
632
|
+
writeColumn(target, bin, colorFor(colors, source.fraction(bin)));
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
for (const bin of dirty) {
|
|
636
|
+
if (bin >= target.width) continue;
|
|
637
|
+
writeColumn(target, bin, colorFor(colors, source.fraction(bin)));
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
const paint = () => {
|
|
641
|
+
frame = NO_FRAME;
|
|
642
|
+
const target = ensureImage();
|
|
643
|
+
if (!target || !scale) {
|
|
644
|
+
dirty.clear();
|
|
645
|
+
allDirty = false;
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (viewLength(source.getView()) < source.binCount) paintDiscrete(target, scale);
|
|
649
|
+
else paintBins(target, scale);
|
|
650
|
+
ctx.putImageData(target, 0, 0);
|
|
651
|
+
dirty.clear();
|
|
652
|
+
allDirty = false;
|
|
653
|
+
};
|
|
654
|
+
const schedule = () => {
|
|
655
|
+
if (frame !== NO_FRAME) return;
|
|
656
|
+
frame = scheduler.request(paint);
|
|
657
|
+
};
|
|
658
|
+
const markAllDirty = () => {
|
|
659
|
+
allDirty = true;
|
|
660
|
+
schedule();
|
|
661
|
+
};
|
|
662
|
+
const stopBlink = () => {
|
|
663
|
+
if (blinkId !== NO_TIMER) timer.clear(blinkId);
|
|
664
|
+
blinkId = NO_TIMER;
|
|
665
|
+
};
|
|
666
|
+
return {
|
|
667
|
+
setColors: (next) => {
|
|
668
|
+
scale = next;
|
|
669
|
+
markAllDirty();
|
|
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.
|
|
674
|
+
setHighlight: (on, color, blink) => {
|
|
675
|
+
if (!on && highlight === void 0) return;
|
|
676
|
+
stopBlink();
|
|
677
|
+
highlight = on ? color : void 0;
|
|
678
|
+
phase = on;
|
|
679
|
+
if (on && blink) {
|
|
680
|
+
blinkId = timer.set(() => {
|
|
681
|
+
phase = !phase;
|
|
682
|
+
markAllDirty();
|
|
683
|
+
}, BLINK_INTERVAL_MS);
|
|
684
|
+
}
|
|
685
|
+
markAllDirty();
|
|
686
|
+
},
|
|
687
|
+
// Theme changed mid-highlight; the blink phase keeps running untouched.
|
|
688
|
+
setHighlightColor: (color) => {
|
|
689
|
+
if (highlight === void 0) return;
|
|
690
|
+
highlight = color;
|
|
691
|
+
markAllDirty();
|
|
692
|
+
},
|
|
693
|
+
markDirty: (bins) => {
|
|
694
|
+
for (let i = 0; i < bins.length; i += 1) dirty.add(bins[i]);
|
|
695
|
+
if (bins.length > 0) schedule();
|
|
696
|
+
},
|
|
697
|
+
markAllDirty,
|
|
698
|
+
destroy: () => {
|
|
699
|
+
stopBlink();
|
|
700
|
+
if (frame !== NO_FRAME) scheduler.cancel(frame);
|
|
701
|
+
frame = NO_FRAME;
|
|
702
|
+
dirty.clear();
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
// src/bitset.ts
|
|
708
|
+
var maskRange = (from, to) => (1 << to) - 1 & ~((1 << from) - 1);
|
|
709
|
+
var POPCOUNT = new Uint8Array(1 << BITS_PER_BYTE);
|
|
710
|
+
for (let i = 1; i < POPCOUNT.length; i += 1) {
|
|
711
|
+
POPCOUNT[i] = POPCOUNT[i >> 1] + (i & 1);
|
|
712
|
+
}
|
|
713
|
+
var createBitset = (size) => {
|
|
714
|
+
const bytes = new Uint8Array(Math.ceil(size / BITS_PER_BYTE));
|
|
715
|
+
let count = 0;
|
|
716
|
+
return {
|
|
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
|
+
*/
|
|
722
|
+
set: (index) => {
|
|
723
|
+
const byte = index >>> BYTE_SHIFT;
|
|
724
|
+
const mask = 1 << (index & BYTE_MASK);
|
|
725
|
+
if ((bytes[byte] & mask) !== 0) return false;
|
|
726
|
+
bytes[byte] |= mask;
|
|
727
|
+
count += 1;
|
|
728
|
+
return true;
|
|
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
|
+
*/
|
|
735
|
+
fill: (start, end) => {
|
|
736
|
+
if (end <= start) return;
|
|
737
|
+
const firstByte = start >>> BYTE_SHIFT;
|
|
738
|
+
const lastByte = end - 1 >>> BYTE_SHIFT;
|
|
739
|
+
const fromBit = start & BYTE_MASK;
|
|
740
|
+
const toBit = (end - 1 & BYTE_MASK) + 1;
|
|
741
|
+
if (firstByte === lastByte) {
|
|
742
|
+
bytes[firstByte] |= maskRange(fromBit, toBit);
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
bytes[firstByte] |= maskRange(fromBit, BITS_PER_BYTE);
|
|
746
|
+
bytes.fill(FULL_BYTE, firstByte + 1, lastByte);
|
|
747
|
+
bytes[lastByte] |= maskRange(0, toBit);
|
|
748
|
+
},
|
|
749
|
+
// Same head/middle/tail shape as fill, with the masks inverted.
|
|
750
|
+
clearRange: (start, end) => {
|
|
751
|
+
if (end <= start) return;
|
|
752
|
+
const firstByte = start >>> BYTE_SHIFT;
|
|
753
|
+
const lastByte = end - 1 >>> BYTE_SHIFT;
|
|
754
|
+
const fromBit = start & BYTE_MASK;
|
|
755
|
+
const toBit = (end - 1 & BYTE_MASK) + 1;
|
|
756
|
+
if (firstByte === lastByte) {
|
|
757
|
+
bytes[firstByte] &= ~maskRange(fromBit, toBit);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
bytes[firstByte] &= ~maskRange(fromBit, BITS_PER_BYTE);
|
|
761
|
+
bytes.fill(0, firstByte + 1, lastByte);
|
|
762
|
+
bytes[lastByte] &= ~maskRange(0, toBit);
|
|
763
|
+
},
|
|
764
|
+
// Head and tail are masked before counting; the middle bytes are counted
|
|
765
|
+
// whole, one table lookup each.
|
|
766
|
+
countRange: (start, end) => {
|
|
767
|
+
if (end <= start) return 0;
|
|
768
|
+
const firstByte = start >>> BYTE_SHIFT;
|
|
769
|
+
const lastByte = end - 1 >>> BYTE_SHIFT;
|
|
770
|
+
const fromBit = start & BYTE_MASK;
|
|
771
|
+
const toBit = (end - 1 & BYTE_MASK) + 1;
|
|
772
|
+
if (firstByte === lastByte) {
|
|
773
|
+
return POPCOUNT[bytes[firstByte] & maskRange(fromBit, toBit)];
|
|
774
|
+
}
|
|
775
|
+
let total = POPCOUNT[bytes[firstByte] & maskRange(fromBit, BITS_PER_BYTE)];
|
|
776
|
+
for (let b = firstByte + 1; b < lastByte; b += 1) total += POPCOUNT[bytes[b]];
|
|
777
|
+
total += POPCOUNT[bytes[lastByte] & maskRange(0, toBit)];
|
|
778
|
+
return total;
|
|
779
|
+
},
|
|
780
|
+
clear: () => {
|
|
781
|
+
bytes.fill(0);
|
|
782
|
+
count = 0;
|
|
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
|
+
*/
|
|
789
|
+
setCount: (next) => {
|
|
790
|
+
count = next;
|
|
791
|
+
},
|
|
792
|
+
get count() {
|
|
793
|
+
return count;
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
};
|
|
797
|
+
|
|
798
|
+
// src/ranges.ts
|
|
799
|
+
var isFlatPairs = (input) => ArrayBuffer.isView(input) || typeof input[0] === "number";
|
|
800
|
+
var readPairs = (input) => {
|
|
801
|
+
const pairs = [];
|
|
802
|
+
if (isFlatPairs(input)) {
|
|
803
|
+
const pairCount = Math.floor(input.length / RANGE_PAIR);
|
|
804
|
+
for (let i = 0; i < pairCount; i += 1) {
|
|
805
|
+
pairs.push([input[i * RANGE_PAIR], input[i * RANGE_PAIR + 1]]);
|
|
806
|
+
}
|
|
807
|
+
return pairs;
|
|
808
|
+
}
|
|
809
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
810
|
+
const tuple = input[i];
|
|
811
|
+
pairs.push([tuple[0], tuple[1]]);
|
|
812
|
+
}
|
|
813
|
+
return pairs;
|
|
814
|
+
};
|
|
815
|
+
var normalizeRanges = (input, totalBlocks) => {
|
|
816
|
+
const clamped = [];
|
|
817
|
+
for (const [rawStart, rawEnd] of readPairs(input)) {
|
|
818
|
+
if (!Number.isInteger(rawStart) || !Number.isInteger(rawEnd)) continue;
|
|
819
|
+
const start = Math.max(0, rawStart);
|
|
820
|
+
const end = Math.min(totalBlocks, rawEnd);
|
|
821
|
+
if (end > start) clamped.push([start, end]);
|
|
822
|
+
}
|
|
823
|
+
clamped.sort((a, b) => a[0] - b[0]);
|
|
824
|
+
const merged = [];
|
|
825
|
+
for (const [start, end] of clamped) {
|
|
826
|
+
const lastIndex = merged.length - 1;
|
|
827
|
+
if (merged.length > 0 && start <= merged[lastIndex]) {
|
|
828
|
+
merged[lastIndex] = Math.max(merged[lastIndex], end);
|
|
829
|
+
} else {
|
|
830
|
+
merged.push(start, end);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
return Uint32Array.from(merged);
|
|
834
|
+
};
|
|
835
|
+
var rangesLength = (flat) => {
|
|
836
|
+
let total = 0;
|
|
837
|
+
for (let i = 0; i < flat.length; i += RANGE_PAIR) total += flat[i + 1] - flat[i];
|
|
838
|
+
return total;
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
// src/state.ts
|
|
842
|
+
var EMPTY_DIRTY = new Uint32Array(0);
|
|
843
|
+
var isValidIndex = (index, totalBlocks) => Number.isInteger(index) && index >= 0 && index < totalBlocks;
|
|
844
|
+
var createBlockState = (totalBlocks) => {
|
|
845
|
+
if (!Number.isInteger(totalBlocks) || totalBlocks < 1) {
|
|
846
|
+
throw new RangeError(`totalBlocks must be a positive integer, got ${totalBlocks}`);
|
|
847
|
+
}
|
|
848
|
+
if (totalBlocks > MAX_BLOCKS) {
|
|
849
|
+
throw new RangeError(
|
|
850
|
+
`totalBlocks must fit in an unsigned 32-bit integer (<= ${MAX_BLOCKS}), got ${totalBlocks}`
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
const bitset = createBitset(totalBlocks);
|
|
854
|
+
let view = fullView(totalBlocks);
|
|
855
|
+
let bins = createBins(view, 0);
|
|
856
|
+
const inView = (index) => index >= view.start && index < view.end;
|
|
857
|
+
const add = (indices) => {
|
|
858
|
+
const hasBins = bins.binCount > 0;
|
|
859
|
+
const dirty = /* @__PURE__ */ new Set();
|
|
860
|
+
for (let i = 0; i < indices.length; i += 1) {
|
|
861
|
+
const index = indices[i];
|
|
862
|
+
if (!isValidIndex(index, totalBlocks) || !bitset.set(index)) continue;
|
|
863
|
+
if (!hasBins || !inView(index)) continue;
|
|
864
|
+
const bin = bins.binOf(index);
|
|
865
|
+
bins.counts[bin] += 1;
|
|
866
|
+
dirty.add(bin);
|
|
867
|
+
}
|
|
868
|
+
return dirty.size === 0 ? EMPTY_DIRTY : Uint32Array.from(dirty).sort();
|
|
869
|
+
};
|
|
870
|
+
const applyRanges = (input, complete) => {
|
|
871
|
+
const flat = normalizeRanges(input, totalBlocks);
|
|
872
|
+
const dirty = /* @__PURE__ */ new Set();
|
|
873
|
+
let delta = 0;
|
|
874
|
+
for (let i = 0; i < flat.length; i += RANGE_PAIR) {
|
|
875
|
+
const start = flat[i];
|
|
876
|
+
const end = flat[i + 1];
|
|
877
|
+
const present = bitset.countRange(start, end);
|
|
878
|
+
if (complete) {
|
|
879
|
+
bitset.fill(start, end);
|
|
880
|
+
delta += end - start - present;
|
|
881
|
+
} else {
|
|
882
|
+
bitset.clearRange(start, end);
|
|
883
|
+
delta -= present;
|
|
884
|
+
}
|
|
885
|
+
for (const bin of bins.binsTouching(start, end)) dirty.add(bin);
|
|
886
|
+
}
|
|
887
|
+
bins.recountBins(dirty, bitset.countRange);
|
|
888
|
+
bitset.setCount(bitset.count + delta);
|
|
889
|
+
return dirty.size === 0 ? EMPTY_DIRTY : Uint32Array.from(dirty).sort();
|
|
890
|
+
};
|
|
891
|
+
return {
|
|
892
|
+
totalBlocks,
|
|
893
|
+
get binCount() {
|
|
894
|
+
return bins.binCount;
|
|
895
|
+
},
|
|
896
|
+
// Column count changed: the bin boundaries move, so rebuild from the
|
|
897
|
+
// bitset rather than trying to redistribute the old counts.
|
|
898
|
+
resize: (binCount) => {
|
|
899
|
+
bins = createBins(view, binCount);
|
|
900
|
+
bins.rebuild(bitset.countRange);
|
|
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.
|
|
905
|
+
setView: (next) => {
|
|
906
|
+
if (sameView(next, view)) return false;
|
|
907
|
+
view = { ...next };
|
|
908
|
+
bins = createBins(view, bins.binCount);
|
|
909
|
+
bins.rebuild(bitset.countRange);
|
|
910
|
+
return true;
|
|
911
|
+
},
|
|
912
|
+
// A copy, because `view` is live and handed to external callbacks.
|
|
913
|
+
getView: () => ({ ...view }),
|
|
914
|
+
add,
|
|
915
|
+
replace: (indices) => {
|
|
916
|
+
bitset.clear();
|
|
917
|
+
bins.counts.fill(0);
|
|
918
|
+
add(indices);
|
|
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
|
+
*/
|
|
925
|
+
replaceRanges: (input) => {
|
|
926
|
+
const flat = normalizeRanges(input, totalBlocks);
|
|
927
|
+
bitset.clear();
|
|
928
|
+
bins.counts.fill(0);
|
|
929
|
+
for (let i = 0; i < flat.length; i += RANGE_PAIR) {
|
|
930
|
+
bitset.fill(flat[i], flat[i + 1]);
|
|
931
|
+
bins.addRange(flat[i], flat[i + 1]);
|
|
932
|
+
}
|
|
933
|
+
bitset.setCount(rangesLength(flat));
|
|
934
|
+
},
|
|
935
|
+
addRanges: (input) => applyRanges(input, true),
|
|
936
|
+
removeRanges: (input) => applyRanges(input, false),
|
|
937
|
+
has: bitset.has,
|
|
938
|
+
// Coverage in [0, 1], which is what the colour scale is indexed by.
|
|
939
|
+
fraction: (bin) => {
|
|
940
|
+
const size = bins.sizeOf(bin);
|
|
941
|
+
return size === 0 ? 0 : bins.counts[bin] / size;
|
|
942
|
+
},
|
|
943
|
+
countOf: (bin) => bins.counts[bin],
|
|
944
|
+
rangeOf: (bin) => bins.rangeOf(bin),
|
|
945
|
+
progress: () => ({ completed: bitset.count, total: totalBlocks })
|
|
946
|
+
};
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
// src/tooltip.ts
|
|
950
|
+
var LOCALE = "en-US";
|
|
951
|
+
var TOOLTIP_ATTRIBUTE = "data-blockmap-tooltip";
|
|
952
|
+
var SEPARATOR = " \xB7 ";
|
|
953
|
+
var EN_DASH = "\u2013";
|
|
954
|
+
var countFormat = new Intl.NumberFormat(LOCALE);
|
|
955
|
+
var MIN_PARTIAL_STEP = 1 / DECIMAL_BASE ** PERCENT_FRACTION_DIGITS;
|
|
956
|
+
var MAX_PARTIAL = (PERCENT - MIN_PARTIAL_STEP).toFixed(PERCENT_FRACTION_DIGITS);
|
|
957
|
+
var MIN_PARTIAL = MIN_PARTIAL_STEP.toFixed(PERCENT_FRACTION_DIGITS);
|
|
958
|
+
var MAX_FIXED = PERCENT.toFixed(PERCENT_FRACTION_DIGITS);
|
|
959
|
+
var MIN_FIXED = 0 .toFixed(PERCENT_FRACTION_DIGITS);
|
|
960
|
+
var formatPercent = (count, size) => {
|
|
961
|
+
if (count === 0) return "0%";
|
|
962
|
+
if (count === size) return "100%";
|
|
963
|
+
const value = count / size * PERCENT;
|
|
964
|
+
const text = value.toFixed(PERCENT_FRACTION_DIGITS);
|
|
965
|
+
if (text === MAX_FIXED) {
|
|
966
|
+
return `>${MAX_PARTIAL}%`;
|
|
967
|
+
}
|
|
968
|
+
if (text === MIN_FIXED) {
|
|
969
|
+
return `<${MIN_PARTIAL}%`;
|
|
970
|
+
}
|
|
971
|
+
return `${text}%`;
|
|
972
|
+
};
|
|
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)}`;
|
|
976
|
+
return `${label}${SEPARATOR}${countFormat.format(count)} / ${countFormat.format(size)} (${formatPercent(count, size)})`;
|
|
977
|
+
};
|
|
978
|
+
var formatBlockTooltip = (index, complete) => `Block ${countFormat.format(index)}${SEPARATOR}${complete ? "complete" : "missing"}`;
|
|
979
|
+
var createTooltip = (container) => {
|
|
980
|
+
const el = document.createElement("div");
|
|
981
|
+
el.setAttribute(TOOLTIP_ATTRIBUTE, "");
|
|
982
|
+
Object.assign(el.style, {
|
|
983
|
+
position: "absolute",
|
|
984
|
+
top: "0",
|
|
985
|
+
left: "0",
|
|
986
|
+
display: "none",
|
|
987
|
+
pointerEvents: "none",
|
|
988
|
+
whiteSpace: "nowrap",
|
|
989
|
+
transform: "translateY(-100%)"
|
|
990
|
+
});
|
|
991
|
+
ensureRelative(container);
|
|
992
|
+
container.append(el);
|
|
993
|
+
return {
|
|
994
|
+
// x is in container-relative CSS pixels; the offset keeps the label from
|
|
995
|
+
// sitting directly under the cursor.
|
|
996
|
+
show: (x, text) => {
|
|
997
|
+
el.textContent = text;
|
|
998
|
+
el.style.left = `${x + TOOLTIP_OFFSET_PX}px`;
|
|
999
|
+
el.style.display = "block";
|
|
1000
|
+
},
|
|
1001
|
+
hide: () => {
|
|
1002
|
+
el.style.display = "none";
|
|
1003
|
+
},
|
|
1004
|
+
destroy: () => {
|
|
1005
|
+
el.remove();
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
// src/blockMap.ts
|
|
1011
|
+
var FULL2 = "100%";
|
|
1012
|
+
var CONTEXT_ID = "2d";
|
|
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
|
+
};
|
|
1023
|
+
var dprQuery = (dpr) => `(resolution: ${dpr}dppx)`;
|
|
1024
|
+
var createBlockMap = (container, options) => {
|
|
1025
|
+
const state = createBlockState(options.totalBlocks);
|
|
1026
|
+
const initialView = options.view && legalView(options.view, state.totalBlocks);
|
|
1027
|
+
if (initialView) state.setView(initialView);
|
|
1028
|
+
const canvas = document.createElement("canvas");
|
|
1029
|
+
Object.assign(canvas.style, {
|
|
1030
|
+
display: "block",
|
|
1031
|
+
width: FULL2,
|
|
1032
|
+
height: FULL2,
|
|
1033
|
+
touchAction: "none"
|
|
1034
|
+
});
|
|
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
|
+
}
|
|
1042
|
+
container.append(canvas);
|
|
1043
|
+
const ctx = canvas.getContext(CONTEXT_ID);
|
|
1044
|
+
if (!ctx) {
|
|
1045
|
+
canvas.remove();
|
|
1046
|
+
throw new Error(`Could not acquire a ${CONTEXT_ID} canvas context`);
|
|
1047
|
+
}
|
|
1048
|
+
const renderer = createRenderer(ctx, state);
|
|
1049
|
+
let destroyed = false;
|
|
1050
|
+
let cssWidth = 0;
|
|
1051
|
+
let cssHeight = 0;
|
|
1052
|
+
let dpr = devicePixelRatio;
|
|
1053
|
+
let dprMedia;
|
|
1054
|
+
let theme = readTheme(container);
|
|
1055
|
+
let highlightOn = false;
|
|
1056
|
+
let dragging = false;
|
|
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;
|
|
1071
|
+
const applyHighlight = () => {
|
|
1072
|
+
renderer.setHighlight(highlightOn, highlightColor(theme), blinkAllowed());
|
|
1073
|
+
};
|
|
1074
|
+
const onMotionChange = () => {
|
|
1075
|
+
if (highlightOn) applyHighlight();
|
|
1076
|
+
};
|
|
1077
|
+
motionMedia.addEventListener("change", onMotionChange);
|
|
1078
|
+
const applyTheme = () => {
|
|
1079
|
+
theme = readTheme(container);
|
|
1080
|
+
renderer.setColors(createColorScale(theme, options.partial));
|
|
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();
|
|
1090
|
+
};
|
|
1091
|
+
let pendingView;
|
|
1092
|
+
let viewFrame = NO_FRAME2;
|
|
1093
|
+
const getView = () => ({ ...pendingView ?? state.getView() });
|
|
1094
|
+
const commitView = () => {
|
|
1095
|
+
viewFrame = NO_FRAME2;
|
|
1096
|
+
const next = pendingView;
|
|
1097
|
+
pendingView = void 0;
|
|
1098
|
+
if (!next || !state.setView(next)) return;
|
|
1099
|
+
renderer.markAllDirty();
|
|
1100
|
+
updateChrome();
|
|
1101
|
+
options.onViewChange?.(state.getView());
|
|
1102
|
+
};
|
|
1103
|
+
const setView = (view) => {
|
|
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);
|
|
1109
|
+
};
|
|
1110
|
+
const resetView = () => setView(fullView(state.totalBlocks));
|
|
1111
|
+
const layout = () => {
|
|
1112
|
+
if (cssWidth === 0) return;
|
|
1113
|
+
const binCount = Math.round(cssWidth * dpr);
|
|
1114
|
+
if (binCount !== state.binCount) {
|
|
1115
|
+
canvas.width = binCount;
|
|
1116
|
+
state.resize(binCount);
|
|
1117
|
+
renderer.markAllDirty();
|
|
1118
|
+
}
|
|
1119
|
+
updateChrome();
|
|
1120
|
+
};
|
|
1121
|
+
const onDprChange = () => {
|
|
1122
|
+
dpr = devicePixelRatio;
|
|
1123
|
+
layout();
|
|
1124
|
+
watchDpr();
|
|
1125
|
+
};
|
|
1126
|
+
const watchDpr = () => {
|
|
1127
|
+
dprMedia?.removeEventListener("change", onDprChange);
|
|
1128
|
+
dprMedia = matchMedia(dprQuery(dpr));
|
|
1129
|
+
dprMedia.addEventListener("change", onDprChange, { once: true });
|
|
1130
|
+
};
|
|
1131
|
+
const resizeObserver = new ResizeObserver((entries) => {
|
|
1132
|
+
const entry = entries[0];
|
|
1133
|
+
if (!entry) return;
|
|
1134
|
+
cssWidth = entry.contentRect.width;
|
|
1135
|
+
cssHeight = entry.contentRect.height;
|
|
1136
|
+
layout();
|
|
1137
|
+
});
|
|
1138
|
+
resizeObserver.observe(container);
|
|
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();
|
|
1145
|
+
const length = viewLength(view);
|
|
1146
|
+
if (length < binCount) {
|
|
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
|
+
};
|
|
1154
|
+
}
|
|
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);
|
|
1173
|
+
};
|
|
1174
|
+
const onPointerLeave = () => tooltip.hide();
|
|
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, {
|
|
1192
|
+
getView,
|
|
1193
|
+
setView,
|
|
1194
|
+
resetView,
|
|
1195
|
+
getBinCount: () => state.binCount,
|
|
1196
|
+
getDpr: () => dpr,
|
|
1197
|
+
getTotalBlocks: () => state.totalBlocks,
|
|
1198
|
+
panRequiresShift: () => overlay !== void 0,
|
|
1199
|
+
onDragStart: () => {
|
|
1200
|
+
dragging = true;
|
|
1201
|
+
tooltip.hide();
|
|
1202
|
+
},
|
|
1203
|
+
onDragEnd: () => {
|
|
1204
|
+
dragging = false;
|
|
1205
|
+
}
|
|
1206
|
+
});
|
|
1207
|
+
const unwatchTheme = watchTheme(applyTheme);
|
|
1208
|
+
watchDpr();
|
|
1209
|
+
applyTheme();
|
|
1210
|
+
if (options.initialRanges) state.replaceRanges(options.initialRanges);
|
|
1211
|
+
if (options.initialBlocks) renderer.markDirty(state.add(options.initialBlocks));
|
|
1212
|
+
writeLabel();
|
|
1213
|
+
const live = (method) => (...args) => {
|
|
1214
|
+
if (!destroyed) method(...args);
|
|
1215
|
+
};
|
|
1216
|
+
return {
|
|
1217
|
+
setBlocks: live((indices) => {
|
|
1218
|
+
state.replace(indices);
|
|
1219
|
+
renderer.markAllDirty();
|
|
1220
|
+
writeLabel();
|
|
1221
|
+
}),
|
|
1222
|
+
setRanges: live((ranges) => {
|
|
1223
|
+
state.replaceRanges(ranges);
|
|
1224
|
+
renderer.markAllDirty();
|
|
1225
|
+
writeLabel();
|
|
1226
|
+
}),
|
|
1227
|
+
// The incremental paths repaint only the bins the state reports changed.
|
|
1228
|
+
addBlocks: live((indices) => {
|
|
1229
|
+
renderer.markDirty(state.add(indices));
|
|
1230
|
+
writeLabel();
|
|
1231
|
+
}),
|
|
1232
|
+
addRanges: live((ranges) => {
|
|
1233
|
+
renderer.markDirty(state.addRanges(ranges));
|
|
1234
|
+
writeLabel();
|
|
1235
|
+
}),
|
|
1236
|
+
removeRanges: live((ranges) => {
|
|
1237
|
+
renderer.markDirty(state.removeRanges(ranges));
|
|
1238
|
+
writeLabel();
|
|
1239
|
+
}),
|
|
1240
|
+
setView: live(setView),
|
|
1241
|
+
resetView: live(resetView),
|
|
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.
|
|
1245
|
+
getScale: () => createScale(state.getView(), cssWidth),
|
|
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) => {
|
|
1253
|
+
highlightOn = on;
|
|
1254
|
+
applyHighlight();
|
|
1255
|
+
}),
|
|
1256
|
+
getProgress: () => destroyed ? { completed: 0, total: state.totalBlocks } : state.progress(),
|
|
1257
|
+
// Idempotent, and unwinds in the reverse order of construction: pending
|
|
1258
|
+
// frame, observers, listeners, owned children, then the canvas itself.
|
|
1259
|
+
destroy: live(() => {
|
|
1260
|
+
destroyed = true;
|
|
1261
|
+
if (viewFrame !== NO_FRAME2) cancelAnimationFrame(viewFrame);
|
|
1262
|
+
viewFrame = NO_FRAME2;
|
|
1263
|
+
pendingView = void 0;
|
|
1264
|
+
resizeObserver.disconnect();
|
|
1265
|
+
unwatchTheme();
|
|
1266
|
+
dprMedia?.removeEventListener("change", onDprChange);
|
|
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);
|
|
1272
|
+
detachInteraction?.();
|
|
1273
|
+
renderer.destroy();
|
|
1274
|
+
tooltip.destroy();
|
|
1275
|
+
axis?.destroy();
|
|
1276
|
+
overlay?.destroy();
|
|
1277
|
+
canvas.remove();
|
|
1278
|
+
})
|
|
1279
|
+
};
|
|
1280
|
+
};
|
|
1281
|
+
|
|
1282
|
+
export {
|
|
1283
|
+
fullView,
|
|
1284
|
+
createScale,
|
|
1285
|
+
defaultLabel,
|
|
1286
|
+
createBlockMap
|
|
1287
|
+
};
|
|
1288
|
+
//# sourceMappingURL=chunk-6BBJWE5O.js.map
|