gpucanvas 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,1651 @@
1
+ // src/camera.ts
2
+ var MIN_ZOOM = 0.02;
3
+ var MAX_ZOOM = 16;
4
+ var clampZoom = (zoom) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));
5
+ function zoomAt(camera, screenX, screenY, factor) {
6
+ const zoom = clampZoom(camera.zoom * factor);
7
+ const worldX = camera.x + screenX / camera.zoom;
8
+ const worldY = camera.y + screenY / camera.zoom;
9
+ return { x: worldX - screenX / zoom, y: worldY - screenY / zoom, zoom };
10
+ }
11
+ function visibleWorldRect(camera, viewportWidth, viewportHeight) {
12
+ return {
13
+ x: camera.x,
14
+ y: camera.y,
15
+ width: viewportWidth / camera.zoom,
16
+ height: viewportHeight / camera.zoom
17
+ };
18
+ }
19
+ var intersects = (a, b) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
20
+ function fitCamera(bounds, viewportWidth, viewportHeight, padding = 40) {
21
+ const zoom = clampZoom(
22
+ Math.min(
23
+ (viewportWidth - padding * 2) / Math.max(1, bounds.width),
24
+ (viewportHeight - padding * 2) / Math.max(1, bounds.height)
25
+ )
26
+ );
27
+ return {
28
+ x: bounds.x + bounds.width / 2 - viewportWidth / 2 / zoom,
29
+ y: bounds.y + bounds.height / 2 - viewportHeight / 2 / zoom,
30
+ zoom
31
+ };
32
+ }
33
+ function bindCameraInput(element, getCamera, onChange) {
34
+ const onWheel = (event) => {
35
+ event.preventDefault();
36
+ const bounds = element.getBoundingClientRect();
37
+ const camera = getCamera();
38
+ if (event.ctrlKey || event.metaKey) {
39
+ const factor = Math.exp(-event.deltaY * (event.deltaMode === 1 ? 0.05 : 0.01));
40
+ onChange(zoomAt(camera, event.clientX - bounds.left, event.clientY - bounds.top, factor));
41
+ } else {
42
+ onChange({
43
+ ...camera,
44
+ x: camera.x + event.deltaX / camera.zoom,
45
+ y: camera.y + event.deltaY / camera.zoom
46
+ });
47
+ }
48
+ };
49
+ let drag = null;
50
+ const onPointerDown = (event) => {
51
+ if (event.button !== 0 && event.button !== 1) return;
52
+ if (event.target !== element && !event.target.hasAttribute?.("data-gpucanvas-surface"))
53
+ return;
54
+ drag = { pointerId: event.pointerId, x: event.clientX, y: event.clientY };
55
+ element.setPointerCapture(event.pointerId);
56
+ };
57
+ const onPointerMove = (event) => {
58
+ if (!drag || event.pointerId !== drag.pointerId) return;
59
+ const camera = getCamera();
60
+ onChange({
61
+ ...camera,
62
+ x: camera.x - (event.clientX - drag.x) / camera.zoom,
63
+ y: camera.y - (event.clientY - drag.y) / camera.zoom
64
+ });
65
+ drag.x = event.clientX;
66
+ drag.y = event.clientY;
67
+ };
68
+ const onPointerUp = (event) => {
69
+ if (drag?.pointerId === event.pointerId) drag = null;
70
+ };
71
+ element.addEventListener("wheel", onWheel, { passive: false });
72
+ element.addEventListener("pointerdown", onPointerDown);
73
+ element.addEventListener("pointermove", onPointerMove);
74
+ element.addEventListener("pointerup", onPointerUp);
75
+ element.addEventListener("pointercancel", onPointerUp);
76
+ return () => {
77
+ element.removeEventListener("wheel", onWheel);
78
+ element.removeEventListener("pointerdown", onPointerDown);
79
+ element.removeEventListener("pointermove", onPointerMove);
80
+ element.removeEventListener("pointerup", onPointerUp);
81
+ element.removeEventListener("pointercancel", onPointerUp);
82
+ };
83
+ }
84
+
85
+ // src/node-style.ts
86
+ function applyNodeSize(element, rect) {
87
+ element.style.width = `${rect.width}px`;
88
+ element.style.height = `${rect.height}px`;
89
+ }
90
+ function applyNodeBase(element) {
91
+ element.style.boxSizing = "border-box";
92
+ element.style.contain = "strict";
93
+ element.style.overflow = "hidden";
94
+ }
95
+ function boundsOf(rects) {
96
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
97
+ for (const rect of rects) {
98
+ minX = Math.min(minX, rect.x);
99
+ minY = Math.min(minY, rect.y);
100
+ maxX = Math.max(maxX, rect.x + rect.width);
101
+ maxY = Math.max(maxY, rect.y + rect.height);
102
+ }
103
+ return minX === Infinity ? null : { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
104
+ }
105
+
106
+ // src/types.ts
107
+ var DEFAULT_CAMERA = { x: 0, y: 0, zoom: 1 };
108
+
109
+ // src/dom-backend.ts
110
+ function createDomCanvas(host, options) {
111
+ const surface = document.createElement("div");
112
+ surface.dataset.gpucanvasSurface = "";
113
+ Object.assign(surface.style, {
114
+ position: "relative",
115
+ width: "100%",
116
+ height: "100%",
117
+ overflow: "hidden",
118
+ touchAction: "none",
119
+ background: options.background ?? "transparent"
120
+ });
121
+ const world = document.createElement("div");
122
+ world.dataset.gpucanvasSurface = "";
123
+ Object.assign(world.style, {
124
+ position: "absolute",
125
+ left: "0",
126
+ top: "0",
127
+ transformOrigin: "0 0",
128
+ willChange: "transform"
129
+ });
130
+ surface.append(world);
131
+ host.append(surface);
132
+ const nodes = /* @__PURE__ */ new Map();
133
+ let camera = options.camera ?? DEFAULT_CAMERA;
134
+ let frameMs = 0;
135
+ const apply = () => {
136
+ const start = performance.now();
137
+ world.style.transform = `scale(${camera.zoom}) translate(${-camera.x}px, ${-camera.y}px)`;
138
+ frameMs = performance.now() - start;
139
+ };
140
+ const setCamera = (next) => {
141
+ camera = next;
142
+ apply();
143
+ options.onCameraChange?.(camera);
144
+ };
145
+ const unbindInput = bindCameraInput(surface, () => camera, setCamera);
146
+ apply();
147
+ return {
148
+ mode: "dom",
149
+ surface,
150
+ addNode(element, rect) {
151
+ applyNodeBase(element);
152
+ element.style.position = "absolute";
153
+ nodes.set(element, rect);
154
+ this.updateNode(element, rect);
155
+ world.append(element);
156
+ },
157
+ updateNode(element, rect) {
158
+ nodes.set(element, rect);
159
+ element.style.left = `${rect.x}px`;
160
+ element.style.top = `${rect.y}px`;
161
+ applyNodeSize(element, rect);
162
+ },
163
+ removeNode(element) {
164
+ nodes.delete(element);
165
+ element.remove();
166
+ },
167
+ getNodeRect: (element) => nodes.get(element) ?? null,
168
+ getCamera: () => camera,
169
+ setCamera,
170
+ // The browser repaints DOM nodes itself.
171
+ invalidate() {
172
+ },
173
+ setBackground(color) {
174
+ surface.style.background = color;
175
+ },
176
+ fit(padding) {
177
+ const bounds = boundsOf(nodes.values());
178
+ if (bounds) setCamera(fitCamera(bounds, surface.clientWidth, surface.clientHeight, padding));
179
+ },
180
+ getStats() {
181
+ const view = visibleWorldRect(camera, surface.clientWidth, surface.clientHeight);
182
+ let visible = 0;
183
+ for (const rect of nodes.values()) if (intersects(rect, view)) visible++;
184
+ return {
185
+ mode: "dom",
186
+ nodes: nodes.size,
187
+ visible,
188
+ pending: 0,
189
+ rasterizedLastFrame: 0,
190
+ rasterMs: 0,
191
+ atlasLayersUsed: 0,
192
+ frameMs
193
+ };
194
+ },
195
+ destroy() {
196
+ unbindInput();
197
+ surface.remove();
198
+ }
199
+ };
200
+ }
201
+
202
+ // src/atlas.ts
203
+ var PADDING = 1;
204
+ var Atlas = class {
205
+ constructor(device, size, layerCount) {
206
+ this.device = device;
207
+ this.size = size;
208
+ this.texture = device.createTexture({
209
+ label: "gpucanvas atlas",
210
+ size: [size, size, layerCount],
211
+ format: "rgba8unorm",
212
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
213
+ });
214
+ this.view = this.texture.createView({ dimension: "2d-array" });
215
+ this.layers = Array.from({ length: layerCount }, () => ({
216
+ shelves: [],
217
+ nextY: 0,
218
+ generation: 0,
219
+ lastUsed: -1,
220
+ lastAllocated: -1
221
+ }));
222
+ this.spare = layerCount - 1;
223
+ }
224
+ device;
225
+ texture;
226
+ view;
227
+ size;
228
+ /** Slots to rescue when their layer is recycled, most important first. */
229
+ keep = () => [];
230
+ layers;
231
+ spare;
232
+ frame = 0;
233
+ /** The layer `allocatePinned` fills; it is never recycled. */
234
+ pinned = 0;
235
+ get layerCount() {
236
+ return this.layers.length;
237
+ }
238
+ get usedLayers() {
239
+ return this.layers.filter((layer, index) => index !== this.spare && layer.nextY > 0).length;
240
+ }
241
+ /** How much of the pinned layer is taken, 0 to 1. */
242
+ get pinnedFill() {
243
+ return this.layers[this.pinned].nextY / this.size;
244
+ }
245
+ nextFrame() {
246
+ this.frame++;
247
+ }
248
+ /** Marks a layer as drawn from this frame, so eviction prefers others. */
249
+ touch(layer) {
250
+ this.layers[layer].lastUsed = this.frame;
251
+ }
252
+ isValid(slot) {
253
+ return slot !== null && this.layers[slot.layer].generation === slot.generation;
254
+ }
255
+ allocate(width, height) {
256
+ const w = Math.ceil(width) + PADDING;
257
+ const h = Math.ceil(height) + PADDING;
258
+ if (w > this.size || h > this.size) return null;
259
+ for (let index = 0; index < this.layers.length; index++) {
260
+ if (index === this.spare || index === this.pinned) continue;
261
+ const slot = this.allocateIn(index, w, h);
262
+ if (slot) return slot;
263
+ }
264
+ let stalest = -1;
265
+ for (let index = 0; index < this.layers.length; index++) {
266
+ const layer = this.layers[index];
267
+ if (index === this.spare || index === this.pinned || layer.lastAllocated === this.frame)
268
+ continue;
269
+ if (stalest < 0 || layer.lastUsed < this.layers[stalest].lastUsed) stalest = index;
270
+ }
271
+ if (stalest < 0) return null;
272
+ const target = this.recycle(stalest);
273
+ return this.allocateIn(target, w, h);
274
+ }
275
+ /** Space in the pinned layer, or null once it is full. */
276
+ allocatePinned(width, height) {
277
+ return this.allocateIn(this.pinned, Math.ceil(width) + PADDING, Math.ceil(height) + PADDING);
278
+ }
279
+ isPinned(slot) {
280
+ return slot !== null && slot.layer === this.pinned;
281
+ }
282
+ /** Moves the kept slots of `index` into the spare layer, wipes `index`, and swaps the two. */
283
+ recycle(index) {
284
+ const target = this.spare;
285
+ const from = this.layers[index];
286
+ this.evict(target);
287
+ const encoder = this.device.createCommandEncoder({ label: "gpucanvas atlas rescue" });
288
+ let copies = 0;
289
+ const seen = /* @__PURE__ */ new Set();
290
+ for (const slot of this.keep()) {
291
+ if (!slot || seen.has(slot) || slot.layer !== index || slot.generation !== from.generation) {
292
+ continue;
293
+ }
294
+ seen.add(slot);
295
+ const x0 = Math.floor(slot.x);
296
+ const y0 = Math.floor(slot.y);
297
+ const x1 = Math.min(this.size, Math.ceil(slot.x + slot.width));
298
+ const y1 = Math.min(this.size, Math.ceil(slot.y + slot.height));
299
+ const moved = this.allocateIn(target, x1 - x0 + PADDING, y1 - y0 + PADDING);
300
+ if (!moved) break;
301
+ encoder.copyTextureToTexture(
302
+ { texture: this.texture, origin: { x: x0, y: y0, z: index } },
303
+ { texture: this.texture, origin: { x: moved.x, y: moved.y, z: target } },
304
+ [x1 - x0, y1 - y0, 1]
305
+ );
306
+ copies++;
307
+ slot.layer = target;
308
+ slot.x = moved.x + (slot.x - x0);
309
+ slot.y = moved.y + (slot.y - y0);
310
+ slot.generation = moved.generation;
311
+ }
312
+ if (copies > 0) this.device.queue.submit([encoder.finish()]);
313
+ this.evict(index);
314
+ this.spare = index;
315
+ return target;
316
+ }
317
+ allocateIn(index, w, h) {
318
+ const layer = this.layers[index];
319
+ let shelf = layer.shelves.find(
320
+ (candidate) => candidate.height >= h && candidate.height <= h * 1.5 && candidate.x + w <= this.size
321
+ );
322
+ if (!shelf) {
323
+ if (layer.nextY + h > this.size) return null;
324
+ shelf = { y: layer.nextY, height: h, x: 0 };
325
+ layer.shelves.push(shelf);
326
+ layer.nextY += h;
327
+ }
328
+ const slot = {
329
+ layer: index,
330
+ x: shelf.x,
331
+ y: shelf.y,
332
+ width: w - PADDING,
333
+ height: h - PADDING,
334
+ generation: layer.generation
335
+ };
336
+ shelf.x += w;
337
+ layer.lastUsed = this.frame;
338
+ layer.lastAllocated = this.frame;
339
+ return slot;
340
+ }
341
+ evict(index) {
342
+ const layer = this.layers[index];
343
+ layer.shelves = [];
344
+ layer.nextY = 0;
345
+ layer.generation++;
346
+ }
347
+ destroy() {
348
+ this.texture.destroy();
349
+ }
350
+ };
351
+
352
+ // src/composite-shader.ts
353
+ var compositeShader = (
354
+ /* wgsl */
355
+ `
356
+ struct View {
357
+ size: vec2f, // canvas size in CSS pixels
358
+ origin: vec2f, // camera position, world units
359
+ zoom: f32,
360
+ dpr: f32,
361
+ atlasSize: f32,
362
+ _pad: f32,
363
+ placeholder: vec4f, // premultiplied color for nodes without a snapshot
364
+ }
365
+
366
+ @group(0) @binding(0) var<uniform> view: View;
367
+ @group(0) @binding(1) var atlas: texture_2d_array<f32>;
368
+ @group(0) @binding(2) var atlasSampler: sampler;
369
+
370
+ struct Instance {
371
+ @location(0) rect: vec4f, // world x, y, width, height
372
+ @location(1) uv: vec4f, // atlas texels x0, y0, x1, y1
373
+ @location(2) info: vec4f, // layer, placeholder flag
374
+ }
375
+
376
+ struct Varyings {
377
+ @builtin(position) position: vec4f,
378
+ @location(0) texel: vec2f,
379
+ @location(1) @interpolate(flat) bounds: vec4f,
380
+ @location(2) @interpolate(flat) info: vec2f,
381
+ }
382
+
383
+ @vertex
384
+ fn vs(@builtin(vertex_index) vertex: u32, instance: Instance) -> Varyings {
385
+ let corner = vec2f(f32(vertex & 1u), f32(vertex >> 1u));
386
+ // Snap edges to device pixels so a snapshot drawn at its raster scale stays crisp.
387
+ let scale = view.zoom * view.dpr;
388
+ let topLeft = round((instance.rect.xy - view.origin) * scale) / view.dpr;
389
+ let bottomRight = round((instance.rect.xy + instance.rect.zw - view.origin) * scale) / view.dpr;
390
+ let point = mix(topLeft, bottomRight, corner);
391
+ var out: Varyings;
392
+ out.position = vec4f(point.x / view.size.x * 2.0 - 1.0, 1.0 - point.y / view.size.y * 2.0, 0.0, 1.0);
393
+ out.texel = mix(instance.uv.xy, instance.uv.zw, corner);
394
+ // Half a texel inside the node's rectangle: the outermost sample center that cannot blend
395
+ // with whatever sits next to it in the atlas.
396
+ out.bounds = vec4f(instance.uv.xy + 0.5, instance.uv.zw - 0.5);
397
+ out.info = instance.info.xy;
398
+ return out;
399
+ }
400
+
401
+ @fragment
402
+ fn fs(in: Varyings) -> @location(0) vec4f {
403
+ // Derivatives first: WGSL only allows them in uniform control flow.
404
+ let dx = dpdx(in.texel);
405
+ let dy = dpdy(in.texel);
406
+ if (in.info.y > 0.5) {
407
+ return view.placeholder;
408
+ }
409
+ let layer = i32(in.info.x);
410
+ // Texels covered by this pixel along each screen axis.
411
+ let footprint = vec2f(length(vec2f(dx.x, dy.x)), length(vec2f(dx.y, dy.y)));
412
+ let taps = vec2u(clamp(ceil(footprint), vec2f(1.0), vec2f(4.0)));
413
+ var color = vec4f(0.0);
414
+ for (var j = 0u; j < taps.y; j++) {
415
+ for (var i = 0u; i < taps.x; i++) {
416
+ let offset = (vec2f(f32(i), f32(j)) + 0.5) / vec2f(taps) - 0.5;
417
+ let texel = clamp(in.texel + offset * footprint, in.bounds.xy, in.bounds.zw);
418
+ color += textureSampleLevel(atlas, atlasSampler, texel / view.atlasSize, layer, 0.0);
419
+ }
420
+ }
421
+ return color / f32(taps.x * taps.y);
422
+ }
423
+ `
424
+ );
425
+
426
+ // src/gpu-renderer.ts
427
+ var FLOATS_PER_INSTANCE = 12;
428
+ var CompositeRenderer = class {
429
+ constructor(device, context, format, atlas, background, placeholder) {
430
+ this.device = device;
431
+ this.context = context;
432
+ this.atlas = atlas;
433
+ this.background = background;
434
+ this.placeholder = placeholder;
435
+ const module = device.createShaderModule({
436
+ label: "gpucanvas composite",
437
+ code: compositeShader
438
+ });
439
+ this.pipeline = device.createRenderPipeline({
440
+ label: "gpucanvas composite",
441
+ layout: "auto",
442
+ vertex: {
443
+ module,
444
+ entryPoint: "vs",
445
+ buffers: [
446
+ {
447
+ arrayStride: FLOATS_PER_INSTANCE * 4,
448
+ stepMode: "instance",
449
+ attributes: [
450
+ { shaderLocation: 0, offset: 0, format: "float32x4" },
451
+ { shaderLocation: 1, offset: 16, format: "float32x4" },
452
+ { shaderLocation: 2, offset: 32, format: "float32x4" }
453
+ ]
454
+ }
455
+ ]
456
+ },
457
+ fragment: {
458
+ module,
459
+ entryPoint: "fs",
460
+ targets: [
461
+ {
462
+ format,
463
+ // Snapshots are requested premultiplied.
464
+ blend: {
465
+ color: { srcFactor: "one", dstFactor: "one-minus-src-alpha" },
466
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha" }
467
+ }
468
+ }
469
+ ]
470
+ },
471
+ primitive: { topology: "triangle-strip" }
472
+ });
473
+ this.uniforms = device.createBuffer({
474
+ size: this.uniformData.byteLength,
475
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
476
+ });
477
+ this.bindGroup = device.createBindGroup({
478
+ layout: this.pipeline.getBindGroupLayout(0),
479
+ entries: [
480
+ { binding: 0, resource: { buffer: this.uniforms } },
481
+ { binding: 1, resource: atlas.view },
482
+ {
483
+ binding: 2,
484
+ resource: device.createSampler({ magFilter: "linear", minFilter: "linear" })
485
+ }
486
+ ]
487
+ });
488
+ this.instanceBuffer = this.createInstanceBuffer();
489
+ }
490
+ device;
491
+ context;
492
+ atlas;
493
+ background;
494
+ placeholder;
495
+ instances = new Float32Array(FLOATS_PER_INSTANCE * 4096);
496
+ instanceBuffer;
497
+ uniforms;
498
+ uniformData = new Float32Array(12);
499
+ pipeline;
500
+ bindGroup;
501
+ /** Grows the CPU and GPU instance storage to hold at least `count` instances. */
502
+ reserve(count) {
503
+ const floats = count * FLOATS_PER_INSTANCE;
504
+ if (this.instances.length >= floats) return;
505
+ this.instances = new Float32Array(Math.max(floats, this.instances.length * 2));
506
+ this.instanceBuffer.destroy();
507
+ this.instanceBuffer = this.createInstanceBuffer();
508
+ }
509
+ draw(camera, cssWidth, cssHeight, dpr, count) {
510
+ const { device } = this;
511
+ this.uniformData.set([
512
+ cssWidth,
513
+ cssHeight,
514
+ camera.x,
515
+ camera.y,
516
+ camera.zoom,
517
+ dpr,
518
+ this.atlas.size,
519
+ 0,
520
+ ...this.placeholder
521
+ ]);
522
+ device.queue.writeBuffer(this.uniforms, 0, this.uniformData);
523
+ if (count > 0) {
524
+ device.queue.writeBuffer(
525
+ this.instanceBuffer,
526
+ 0,
527
+ this.instances,
528
+ 0,
529
+ count * FLOATS_PER_INSTANCE
530
+ );
531
+ }
532
+ const [r, g, b] = this.background;
533
+ const encoder = device.createCommandEncoder();
534
+ const pass = encoder.beginRenderPass({
535
+ colorAttachments: [
536
+ {
537
+ view: this.context.getCurrentTexture().createView(),
538
+ clearValue: { r, g, b, a: 1 },
539
+ loadOp: "clear",
540
+ storeOp: "store"
541
+ }
542
+ ]
543
+ });
544
+ if (count > 0) {
545
+ pass.setPipeline(this.pipeline);
546
+ pass.setBindGroup(0, this.bindGroup);
547
+ pass.setVertexBuffer(0, this.instanceBuffer);
548
+ pass.draw(4, count);
549
+ }
550
+ pass.end();
551
+ device.queue.submit([encoder.finish()]);
552
+ }
553
+ destroy() {
554
+ this.instanceBuffer.destroy();
555
+ this.uniforms.destroy();
556
+ }
557
+ createInstanceBuffer() {
558
+ return this.device.createBuffer({
559
+ label: "gpucanvas instances",
560
+ size: this.instances.byteLength,
561
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
562
+ });
563
+ }
564
+ };
565
+
566
+ // src/page.ts
567
+ var GAP = 4;
568
+ var Page = class {
569
+ element;
570
+ width;
571
+ height;
572
+ /** Nodes currently placed on this page. */
573
+ count = 0;
574
+ /** Has the `drawable` attribute and is rendered; parked pages use `content-visibility: hidden`. */
575
+ drawable = false;
576
+ /** Frame in which the page became drawable. Its snapshot exists from that frame's paint on. */
577
+ promotedFrame = -1;
578
+ /** Kept drawable for the live (hit-testable) or focused node. */
579
+ pinned = false;
580
+ /** Registered for hit testing with `updateElementGeometry`. */
581
+ registered = false;
582
+ shelves = [];
583
+ nextY = 0;
584
+ constructor(width, height) {
585
+ this.width = width;
586
+ this.height = height;
587
+ this.element = document.createElement("div");
588
+ this.element.dataset.gpucanvasPage = "";
589
+ Object.assign(this.element.style, {
590
+ position: "relative",
591
+ width: `${width}px`,
592
+ height: `${height}px`,
593
+ // Pages share the canvas origin: each one pulls the next back up by its own height.
594
+ marginBottom: `${-height}px`,
595
+ contain: "strict",
596
+ overflow: "hidden",
597
+ // Parked pages keep their layout but skip style, layout updates and paint. Unlike
598
+ // `display: none`, their nodes keep their size, so components that measure themselves
599
+ // (charts with a ResizeObserver) don't re-render empty every time a page parks.
600
+ contentVisibility: "hidden"
601
+ });
602
+ }
603
+ /** Reserves a spot for a `width` x `height` node, or returns null when the page is full. */
604
+ place(width, height) {
605
+ const w = Math.ceil(width) + GAP;
606
+ const h = Math.ceil(height) + GAP;
607
+ if (w > this.width + GAP || h > this.height + GAP) return null;
608
+ let shelf = this.shelves.find(
609
+ (candidate) => candidate.height >= h && candidate.height <= h * 1.25 && candidate.x + w <= this.width + GAP
610
+ );
611
+ if (!shelf) {
612
+ if (this.nextY + h > this.height + GAP) return null;
613
+ shelf = { y: this.nextY, height: h, x: 0 };
614
+ this.shelves.push(shelf);
615
+ this.nextY += h;
616
+ }
617
+ const slot = { page: this, x: shelf.x, y: shelf.y };
618
+ shelf.x += w;
619
+ this.count++;
620
+ return slot;
621
+ }
622
+ release() {
623
+ this.count--;
624
+ }
625
+ setDrawable(drawable, frame) {
626
+ if (this.drawable === drawable) return;
627
+ this.drawable = drawable;
628
+ this.registered = false;
629
+ if (drawable) {
630
+ this.promotedFrame = frame;
631
+ this.element.style.contentVisibility = "";
632
+ this.element.setAttribute("drawable", "");
633
+ } else {
634
+ this.element.removeAttribute("drawable");
635
+ this.element.style.contentVisibility = "hidden";
636
+ this.element.style.transform = "";
637
+ }
638
+ }
639
+ };
640
+
641
+ // src/gpu-backend.ts
642
+ var MIN_RASTER_SCALE = 1 / 64;
643
+ var MAX_RASTER_SCALE = 2;
644
+ var MAX_DETAIL_SCALE = 64;
645
+ var TILE_PIXELS = 512;
646
+ var CONTENT_SETTLE_MS = 150;
647
+ var SMALL_NODE_PX = 200;
648
+ var SMALL_REFRESH_MS = 250;
649
+ var right = (rect) => rect.x + rect.width;
650
+ var bottom = (rect) => rect.y + rect.height;
651
+ var SETTLE_MS = 120;
652
+ var USE_EVENTS = ["click", "input", "change", "keydown"];
653
+ var USED_MS = 1e3;
654
+ var LINGER_MS = 300;
655
+ var MAX_OVERSAMPLING = 4;
656
+ var DEFAULT_PAGE_SIZE = 2048;
657
+ var THUMBNAIL_SCALE = 1 / 16;
658
+ var THUMBNAIL_REFRESH_MS = 1e3;
659
+ var MAX_PROMOTIONS_PER_FRAME = 8;
660
+ var SLOW_FRAME_MS = 22;
661
+ var FAST_FRAME_MS = 12;
662
+ var MISSING = 0;
663
+ var DIRTY = 1;
664
+ var BLURRY = 2;
665
+ var OVERSAMPLED = 3;
666
+ function supportsGpuCanvas() {
667
+ return typeof navigator !== "undefined" && "gpu" in navigator && typeof HTMLCanvasElement !== "undefined" && "requestPaint" in HTMLCanvasElement.prototype && "getElementTransform" in HTMLCanvasElement.prototype && typeof GPUQueue !== "undefined" && "drawElementImageToTexture" in GPUQueue.prototype;
668
+ }
669
+ function rasterScaleFor(deviceScale) {
670
+ const scale = 2 ** Math.ceil(Math.log2(deviceScale) - 0.14);
671
+ return Math.min(MAX_DETAIL_SCALE, Math.max(MIN_RASTER_SCALE, scale));
672
+ }
673
+ var intersection = (a, b) => {
674
+ const x = Math.max(a.x, b.x);
675
+ const y = Math.max(a.y, b.y);
676
+ const right2 = Math.min(a.x + a.width, b.x + b.width);
677
+ const bottom2 = Math.min(a.y + a.height, b.y + b.height);
678
+ return right2 > x && bottom2 > y ? { x, y, width: right2 - x, height: bottom2 - y } : null;
679
+ };
680
+ var contains = (outer, inner) => inner.x >= outer.x && inner.y >= outer.y && inner.x + inner.width <= outer.x + outer.width && inner.y + inner.height <= outer.y + outer.height;
681
+ function cssColorToRgba(color) {
682
+ const context = document.createElement("canvas").getContext("2d");
683
+ context.fillStyle = color;
684
+ context.fillRect(0, 0, 1, 1);
685
+ const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data;
686
+ return [r / 255, g / 255, b / 255, a / 255];
687
+ }
688
+ var distanceToCenter = (rect, view) => Math.hypot(
689
+ rect.x + rect.width / 2 - (view.x + view.width / 2),
690
+ rect.y + rect.height / 2 - (view.y + view.height / 2)
691
+ );
692
+ async function createGpuCanvas(host, options) {
693
+ const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
694
+ if (!adapter) throw new Error("gpucanvas: no WebGPU adapter");
695
+ const device = await adapter.requestDevice();
696
+ const surface = document.createElement("div");
697
+ surface.dataset.gpucanvasSurface = "";
698
+ Object.assign(surface.style, {
699
+ position: "relative",
700
+ width: "100%",
701
+ height: "100%",
702
+ overflow: "hidden",
703
+ touchAction: "none"
704
+ });
705
+ const canvas = document.createElement("canvas");
706
+ canvas.setAttribute("content", "drawable");
707
+ canvas.dataset.gpucanvasSurface = "";
708
+ Object.assign(canvas.style, { display: "block", width: "100%", height: "100%" });
709
+ surface.append(canvas);
710
+ host.append(surface);
711
+ const context = canvas.getContext("webgpu");
712
+ const format = navigator.gpu.getPreferredCanvasFormat();
713
+ context.configure({ device, format, alphaMode: "opaque" });
714
+ const atlasSize = Math.min(options.atlasSize ?? 4096, device.limits.maxTextureDimension2D);
715
+ const atlas = new Atlas(
716
+ device,
717
+ atlasSize,
718
+ Math.min(options.atlasLayers ?? 5, device.limits.maxTextureArrayLayers)
719
+ );
720
+ const pageSize = Math.min(options.pageSize ?? DEFAULT_PAGE_SIZE, atlasSize);
721
+ const maxRasterCalls = options.maxRastersPerFrame ?? 12;
722
+ const rasterTimeBudgetMs = options.rasterTimeBudgetMs ?? 6;
723
+ const rasterPixelBudget = options.rasterPixelBudget ?? 24e6;
724
+ const colorsFor = (css) => {
725
+ const background = cssColorToRgba(css);
726
+ const shade = background[0] + background[1] + background[2] > 1.5 ? 0.92 : 1.25;
727
+ const placeholder = background.map(
728
+ (channel) => Math.min(1, channel * shade + (shade > 1 ? 0.02 : 0))
729
+ );
730
+ placeholder[3] = 1;
731
+ return [background, placeholder];
732
+ };
733
+ const renderer = new CompositeRenderer(
734
+ device,
735
+ context,
736
+ format,
737
+ atlas,
738
+ ...colorsFor(options.background ?? "#f4f4f5")
739
+ );
740
+ const nodes = /* @__PURE__ */ new Map();
741
+ const pages = /* @__PURE__ */ new Map();
742
+ atlas.keep = function* () {
743
+ const onScreen = (tile) => intersects(tile.rect, frameView);
744
+ for (const node of visible) yield node.slot;
745
+ for (const node of visible) {
746
+ for (const tile of node.tiles) {
747
+ if (tile.scale === detailScale && !tile.stale && onScreen(tile)) yield tile.slot;
748
+ }
749
+ }
750
+ for (const node of visible) {
751
+ yield node.preview;
752
+ for (const tile of node.tiles) if (onScreen(tile)) yield tile.slot;
753
+ }
754
+ };
755
+ const pageByElement = /* @__PURE__ */ new Map();
756
+ let openPage = null;
757
+ let camera = options.camera ?? DEFAULT_CAMERA;
758
+ let lastCameraChange = -Infinity;
759
+ let frameNumber = 0;
760
+ let promotionsPerFrame = 2;
761
+ let lastFrameAt = 0;
762
+ let rafId = 0;
763
+ let destroyed = false;
764
+ let live = null;
765
+ let livePositioned = false;
766
+ let lingering = null;
767
+ let lingerTimer = 0;
768
+ let focused = null;
769
+ let pointer = null;
770
+ let settleTimer = 0;
771
+ const stats = {
772
+ visible: 0,
773
+ pending: 0,
774
+ rasterizedLastFrame: 0,
775
+ rasterCallsLastFrame: 0,
776
+ rasterMs: 0,
777
+ frameMs: 0,
778
+ frames: 0,
779
+ lostPixels: 0,
780
+ lostDetail: 0,
781
+ onThumbnail: 0,
782
+ /** Last plan's nodes per need class: missing, dirty, blurry, oversampled. */
783
+ needs: [0, 0, 0, 0]
784
+ };
785
+ const visible = [];
786
+ const plans = /* @__PURE__ */ new Map();
787
+ const schedule = () => {
788
+ if (rafId || destroyed) return;
789
+ rafId = requestAnimationFrame(() => {
790
+ rafId = 0;
791
+ frameNumber++;
792
+ frame(false);
793
+ });
794
+ };
795
+ const resizeObserver = new ResizeObserver(([entry]) => {
796
+ const size = entry.devicePixelContentBoxSize?.[0];
797
+ canvas.width = Math.max(
798
+ 1,
799
+ size ? size.inlineSize : Math.round(entry.contentRect.width * devicePixelRatio)
800
+ );
801
+ canvas.height = Math.max(
802
+ 1,
803
+ size ? size.blockSize : Math.round(entry.contentRect.height * devicePixelRatio)
804
+ );
805
+ schedule();
806
+ });
807
+ resizeObserver.observe(canvas, { box: "device-pixel-content-box" });
808
+ const transitioning = /* @__PURE__ */ new Map();
809
+ const isPinned = (page) => live?.page === page || lingering?.page === page || focused?.page === page || [...transitioning.keys()].some((node) => node.page === page);
810
+ let detailScale = 1;
811
+ let frameView = { x: 0, y: 0, width: 0, height: 0 };
812
+ let contentTimer = 0;
813
+ const scheduleAfterContentSettles = () => {
814
+ clearTimeout(contentTimer);
815
+ contentTimer = window.setTimeout(schedule, CONTENT_SETTLE_MS + 10);
816
+ };
817
+ let refreshTimer = 0;
818
+ let refreshAt = Infinity;
819
+ const refreshWait = (node, now) => Math.max(node.rect.width, node.rect.height) * camera.zoom < SMALL_NODE_PX ? node.refreshedAt + SMALL_REFRESH_MS - now : 0;
820
+ const refreshLater = (wait) => {
821
+ const at = performance.now() + wait;
822
+ if (at >= refreshAt) return;
823
+ clearTimeout(refreshTimer);
824
+ refreshAt = at;
825
+ refreshTimer = window.setTimeout(() => {
826
+ refreshAt = Infinity;
827
+ schedule();
828
+ }, wait);
829
+ };
830
+ const planFrame = (view, desiredScale, deviceScale, moving) => {
831
+ const now = performance.now();
832
+ detailScale = Math.min(deviceScale, MAX_DETAIL_SCALE);
833
+ frameView = view;
834
+ visible.length = 0;
835
+ plans.clear();
836
+ stats.needs = [0, 0, 0, 0];
837
+ for (const node of nodes.values()) {
838
+ if (!intersects(node.rect, view)) continue;
839
+ visible.push(node);
840
+ const scale = Math.min(
841
+ desiredScale,
842
+ MAX_RASTER_SCALE,
843
+ (atlasSize - 1) / node.rect.width,
844
+ (atlasSize - 1) / node.rect.height
845
+ );
846
+ if (node.dirty) node.changedAt = now;
847
+ const thumbnailEnough = scale <= THUMBNAIL_SCALE;
848
+ if (thumbnailEnough && freshThumb(node) && !node.dirty && !node.wholeStale) {
849
+ node.slot = node.thumb;
850
+ node.slotScale = THUMBNAIL_SCALE;
851
+ }
852
+ const animating = now - node.changedAt < CONTENT_SETTLE_MS;
853
+ const deep = desiredScale > scale;
854
+ if (deep && !node.solo) lift(node);
855
+ const jobs = deep ? planDetail(node, view, animating, moving) : [];
856
+ if (!deep) {
857
+ node.preview = null;
858
+ node.tiles.length = 0;
859
+ }
860
+ const valid = atlas.isValid(node.slot);
861
+ let need = -1;
862
+ if (!valid) need = atlas.isValid(node.thumb) ? DIRTY : MISSING;
863
+ else if (deep) need = node.slotScale < scale / (moving ? 4 : 2) ? BLURRY : -1;
864
+ else if (node.dirty || node.wholeStale) {
865
+ const wait = refreshWait(node, now);
866
+ if (wait > 0) refreshLater(wait);
867
+ else need = DIRTY;
868
+ } else if (node.slotScale < (moving ? scale / 2 : scale)) need = BLURRY;
869
+ else if (!moving && node.slotScale > scale * MAX_OVERSAMPLING) need = OVERSAMPLED;
870
+ if (need >= 0) stats.needs[need]++;
871
+ if (need < 0 && !node.previewTarget && jobs.length === 0) continue;
872
+ node.targetScale = thumbnailEnough ? THUMBNAIL_SCALE : scale;
873
+ node.priority = (need < 0 ? BLURRY : need) * 1e12 + distanceToCenter(node.rect, view);
874
+ let plan = plans.get(node.page);
875
+ if (!plan) {
876
+ plan = { page: node.page, nodes: [], previews: [], tiles: [], priority: node.priority };
877
+ plans.set(node.page, plan);
878
+ }
879
+ plan.priority = Math.min(plan.priority, node.priority);
880
+ if (need >= 0) plan.nodes.push(node);
881
+ if (node.previewTarget) plan.previews.push(node);
882
+ plan.tiles.push(...jobs);
883
+ }
884
+ for (const plan of plans.values()) plan.tiles.sort((a, b) => a.priority - b.priority);
885
+ };
886
+ const planDetail = (node, view, animating, moving) => {
887
+ const shown = intersection(node.rect, view);
888
+ node.previewTarget = null;
889
+ node.tiles = node.tiles.filter((tile) => atlas.isValid(tile.slot));
890
+ if (node.dirty) for (const tile of node.tiles) tile.stale = true;
891
+ if (!atlas.isValid(node.preview)) node.preview = null;
892
+ if (animating) {
893
+ const scale = detailScale / 2;
894
+ const stale = !node.preview || node.dirty || node.previewScale !== scale || !contains(node.previewRect, shown);
895
+ if (stale) {
896
+ node.previewTarget = fitPreview(shown, scale);
897
+ node.previewTargetScale = scale;
898
+ }
899
+ scheduleAfterContentSettles();
900
+ return [];
901
+ }
902
+ const current = node.tiles.filter((tile) => tile.scale === detailScale && !tile.stale);
903
+ if (moving && current.length === 0) return [];
904
+ const size = Math.max(1, Math.floor(TILE_PIXELS / detailScale));
905
+ const jobs = [];
906
+ const first = (edge, origin) => Math.floor((edge - origin) / size);
907
+ for (let row = first(shown.y, node.rect.y); row <= first(bottom(shown) - 1e-6, node.rect.y); row++) {
908
+ for (let column = first(shown.x, node.rect.x); column <= first(right(shown) - 1e-6, node.rect.x); column++) {
909
+ if (current.some((tile) => tile.column === column && tile.row === row)) continue;
910
+ const rect = intersection(node.rect, {
911
+ x: node.rect.x + column * size,
912
+ y: node.rect.y + row * size,
913
+ width: size,
914
+ height: size
915
+ });
916
+ jobs.push({
917
+ node,
918
+ rect,
919
+ scale: detailScale,
920
+ column,
921
+ row,
922
+ priority: distanceToCenter(rect, view)
923
+ });
924
+ }
925
+ }
926
+ if (jobs.length === 0) {
927
+ node.tiles = current;
928
+ node.preview = null;
929
+ }
930
+ return jobs;
931
+ };
932
+ const fitPreview = (shown, scale) => {
933
+ const limit = (atlasSize - 2) / scale;
934
+ const fit = (start, size) => size <= limit ? [start, size] : [start + (size - limit) / 2, limit];
935
+ const [x, width] = fit(shown.x, shown.width);
936
+ const [y, height] = fit(shown.y, shown.height);
937
+ return { x, y, width, height };
938
+ };
939
+ const drawSnapshot = (page, source, slot) => {
940
+ try {
941
+ device.queue.drawElementImageToTexture(
942
+ source ? {
943
+ source: page.element,
944
+ sourceX: source.x,
945
+ sourceY: source.y,
946
+ sourceWidth: source.width,
947
+ sourceHeight: source.height
948
+ } : { source: page.element },
949
+ {
950
+ texture: atlas.texture,
951
+ origin: { x: slot.x, y: slot.y, z: slot.layer },
952
+ size: { width: slot.width, height: slot.height },
953
+ premultipliedAlpha: true
954
+ }
955
+ );
956
+ return true;
957
+ } catch {
958
+ return false;
959
+ }
960
+ };
961
+ const slotFor = (previous, width, height, thumbnail = false) => atlas.isValid(previous) && atlas.isPinned(previous) === thumbnail && previous.width === width && previous.height === height && Number.isInteger(previous.x) && Number.isInteger(previous.y) ? previous : (thumbnail ? atlas.allocatePinned(width, height) : null) ?? atlas.allocate(width, height);
962
+ const freshThumb = (node) => atlas.isValid(node.thumb) && !node.thumbStale;
963
+ const drawn = (node, slot, scale, now) => {
964
+ node.slot = slot;
965
+ node.slotScale = scale;
966
+ if (scale === THUMBNAIL_SCALE) {
967
+ node.thumb = slot;
968
+ node.thumbStale = false;
969
+ node.thumbAt = now;
970
+ } else if (node.dirty) node.thumbStale = true;
971
+ if (node.dirty) {
972
+ node.preview = null;
973
+ node.refreshedAt = now;
974
+ }
975
+ node.dirty = false;
976
+ node.wholeStale = false;
977
+ };
978
+ const pageSlots = /* @__PURE__ */ new Map();
979
+ const rasterize = (start) => {
980
+ let calls = 0;
981
+ let pixels = 0;
982
+ let rasterized = 0;
983
+ let pending = 0;
984
+ const overBudget = () => calls > 0 && (calls >= maxRasterCalls || pixels >= rasterPixelBudget || performance.now() - start >= rasterTimeBudgetMs);
985
+ const ordered = [...plans.values()].sort((a, b) => a.priority - b.priority);
986
+ for (const plan of ordered) {
987
+ const { page } = plan;
988
+ if (!page.drawable || page.promotedFrame >= frameNumber || overBudget()) {
989
+ pending += plan.nodes.length + plan.previews.length + plan.tiles.length;
990
+ continue;
991
+ }
992
+ const scale = plan.nodes.reduce((max, node) => Math.max(max, node.targetScale), 0);
993
+ const members = pages.get(page);
994
+ const neededArea = plan.nodes.reduce((sum, n) => sum + n.rect.width * n.rect.height, 0);
995
+ const wholeFits = page.width * scale <= atlasSize / 2 && page.height * scale <= atlasSize / 2;
996
+ const whole = wholeFits && (plan.nodes.length >= 6 || neededArea >= 0.25 * page.width * page.height);
997
+ const wholeSlot = whole ? slotFor(
998
+ pageSlots.get(page) ?? null,
999
+ Math.ceil(page.width * scale),
1000
+ Math.ceil(page.height * scale),
1001
+ scale === THUMBNAIL_SCALE
1002
+ ) : null;
1003
+ if (wholeSlot) pageSlots.set(page, wholeSlot);
1004
+ if (wholeSlot && !drawSnapshot(page, null, wholeSlot)) {
1005
+ pending += plan.nodes.length + plan.previews.length + plan.tiles.length;
1006
+ continue;
1007
+ }
1008
+ if (wholeSlot) {
1009
+ const slot = wholeSlot;
1010
+ calls++;
1011
+ pixels += slot.width * slot.height;
1012
+ for (const node of members) {
1013
+ const part = {
1014
+ layer: slot.layer,
1015
+ generation: slot.generation,
1016
+ x: slot.x + node.pageX * scale,
1017
+ y: slot.y + node.pageY * scale,
1018
+ width: node.rect.width * scale,
1019
+ height: node.rect.height * scale
1020
+ };
1021
+ drawn(node, part, scale, start);
1022
+ }
1023
+ rasterized += members.size;
1024
+ } else {
1025
+ for (const node of plan.nodes) {
1026
+ if (overBudget()) {
1027
+ pending++;
1028
+ continue;
1029
+ }
1030
+ const thumbnail = node.targetScale === THUMBNAIL_SCALE;
1031
+ const slot = slotFor(
1032
+ thumbnail ? node.thumb : node.slot,
1033
+ Math.max(1, Math.round(node.rect.width * node.targetScale)),
1034
+ Math.max(1, Math.round(node.rect.height * node.targetScale)),
1035
+ thumbnail
1036
+ );
1037
+ const region = {
1038
+ x: node.pageX,
1039
+ y: node.pageY,
1040
+ width: node.rect.width,
1041
+ height: node.rect.height
1042
+ };
1043
+ if (!slot || !drawSnapshot(page, region, slot)) {
1044
+ pending++;
1045
+ continue;
1046
+ }
1047
+ calls++;
1048
+ pixels += slot.width * slot.height;
1049
+ drawn(node, slot, node.targetScale, start);
1050
+ rasterized++;
1051
+ }
1052
+ }
1053
+ const warm = /* @__PURE__ */ new Set();
1054
+ const drawRegion = (node, rect, scale2, previous) => {
1055
+ if (!warm.has(scale2) && overBudget()) return null;
1056
+ const slot = slotFor(
1057
+ previous,
1058
+ Math.max(1, Math.round(rect.width * scale2)),
1059
+ Math.max(1, Math.round(rect.height * scale2))
1060
+ );
1061
+ const region = {
1062
+ x: node.pageX + rect.x - node.rect.x,
1063
+ y: node.pageY + rect.y - node.rect.y,
1064
+ width: rect.width,
1065
+ height: rect.height
1066
+ };
1067
+ if (!slot || !drawSnapshot(page, region, slot)) return null;
1068
+ calls++;
1069
+ warm.add(scale2);
1070
+ pixels += slot.width * slot.height;
1071
+ if (node.dirty) {
1072
+ node.dirty = false;
1073
+ node.wholeStale = true;
1074
+ node.thumbStale = true;
1075
+ node.refreshedAt = start;
1076
+ }
1077
+ return slot;
1078
+ };
1079
+ for (const node of plan.previews) {
1080
+ const slot = drawRegion(node, node.previewTarget, node.previewTargetScale, node.preview);
1081
+ if (!slot) {
1082
+ pending++;
1083
+ continue;
1084
+ }
1085
+ node.preview = slot;
1086
+ node.previewRect = node.previewTarget;
1087
+ node.previewScale = node.previewTargetScale;
1088
+ node.previewTarget = null;
1089
+ }
1090
+ let tilesLeft = 0;
1091
+ for (const job of plan.tiles) {
1092
+ const { node, column, row, scale: scale2 } = job;
1093
+ const old = node.tiles.findIndex(
1094
+ (tile) => tile.stale && tile.scale === scale2 && tile.column === column && tile.row === row
1095
+ );
1096
+ const previous = old >= 0 ? node.tiles[old].slot : null;
1097
+ const slot = node.dirty ? null : drawRegion(node, job.rect, scale2, previous);
1098
+ if (!slot) {
1099
+ tilesLeft++;
1100
+ continue;
1101
+ }
1102
+ if (old >= 0) node.tiles.splice(old, 1);
1103
+ node.tiles.push({ slot, rect: job.rect, scale: scale2, column, row, stale: false });
1104
+ }
1105
+ pending += tilesLeft;
1106
+ for (const node of members) {
1107
+ if (freshThumb(node) || node.dirty) continue;
1108
+ if (atlas.isValid(node.thumb) && start - node.thumbAt < THUMBNAIL_REFRESH_MS) continue;
1109
+ const slot = slotFor(
1110
+ node.thumb,
1111
+ Math.max(1, Math.round(node.rect.width * THUMBNAIL_SCALE)),
1112
+ Math.max(1, Math.round(node.rect.height * THUMBNAIL_SCALE)),
1113
+ true
1114
+ );
1115
+ const region = {
1116
+ x: node.pageX,
1117
+ y: node.pageY,
1118
+ width: node.rect.width,
1119
+ height: node.rect.height
1120
+ };
1121
+ if (!slot || !drawSnapshot(page, region, slot)) continue;
1122
+ calls++;
1123
+ node.thumb = slot;
1124
+ node.thumbStale = false;
1125
+ node.thumbAt = start;
1126
+ }
1127
+ const done = plan.nodes.every((node) => !node.dirty && atlas.isValid(node.slot)) && plan.previews.every((node) => node.previewTarget === null) && tilesLeft === 0;
1128
+ if (done && !isPinned(page)) page.setDrawable(false, frameNumber);
1129
+ }
1130
+ stats.rasterizedLastFrame = rasterized;
1131
+ stats.rasterCallsLastFrame = calls;
1132
+ return pending;
1133
+ };
1134
+ const composite = () => {
1135
+ let instances = visible.length;
1136
+ for (const node of visible) instances += node.tiles.length + (node.preview ? 1 : 0);
1137
+ renderer.reserve(instances);
1138
+ const data = renderer.instances;
1139
+ let count = 0;
1140
+ const push = (rect, slot) => {
1141
+ const offset = count++ * FLOATS_PER_INSTANCE;
1142
+ data[offset] = rect.x;
1143
+ data[offset + 1] = rect.y;
1144
+ data[offset + 2] = rect.width;
1145
+ data[offset + 3] = rect.height;
1146
+ if (slot) {
1147
+ atlas.touch(slot.layer);
1148
+ data[offset + 4] = slot.x;
1149
+ data[offset + 5] = slot.y;
1150
+ data[offset + 6] = slot.x + slot.width;
1151
+ data[offset + 7] = slot.y + slot.height;
1152
+ data[offset + 8] = slot.layer;
1153
+ data[offset + 9] = 0;
1154
+ } else {
1155
+ data[offset + 9] = 1;
1156
+ }
1157
+ };
1158
+ let onThumbnail = 0;
1159
+ for (const node of visible) {
1160
+ const base = atlas.isValid(node.slot) ? node.slot : atlas.isValid(node.thumb) ? node.thumb : null;
1161
+ if (base && base !== node.slot) onThumbnail++;
1162
+ const hasSlot = base !== null;
1163
+ const sharp = node.tiles.length > 0 || node.preview !== null && atlas.isValid(node.preview);
1164
+ const shown = sharp ? 3 : hasSlot ? 2 : 1;
1165
+ const before = node.shownFrame === stats.frames - 1 ? node.shown : 0;
1166
+ if (before >= 2 && shown === 1) stats.lostPixels++;
1167
+ if (before === 3 && shown === 2) stats.lostDetail++;
1168
+ node.shown = shown;
1169
+ node.shownFrame = stats.frames;
1170
+ push(node.rect, base);
1171
+ const current = (tile) => tile.scale === detailScale && !tile.stale;
1172
+ for (const tile of node.tiles)
1173
+ if (!current(tile) && atlas.isValid(tile.slot)) push(tile.rect, tile.slot);
1174
+ if (node.preview && atlas.isValid(node.preview)) push(node.previewRect, node.preview);
1175
+ for (const tile of node.tiles)
1176
+ if (current(tile) && atlas.isValid(tile.slot)) push(tile.rect, tile.slot);
1177
+ }
1178
+ stats.onThumbnail = onThumbnail;
1179
+ const cssWidth = canvas.clientWidth;
1180
+ const dpr = canvas.width / Math.max(1, cssWidth);
1181
+ renderer.draw(camera, cssWidth, canvas.clientHeight, dpr, count);
1182
+ };
1183
+ const frame = (inPaint) => {
1184
+ if (destroyed) return;
1185
+ const start = performance.now();
1186
+ atlas.nextFrame();
1187
+ const cssWidth = canvas.clientWidth;
1188
+ const dpr = canvas.width / Math.max(1, cssWidth);
1189
+ const view = visibleWorldRect(camera, cssWidth, canvas.clientHeight);
1190
+ const moving = start - lastCameraChange < SETTLE_MS;
1191
+ planFrame(view, rasterScaleFor(camera.zoom * dpr), camera.zoom * dpr, moving);
1192
+ if (!inPaint && plans.size > 0) {
1193
+ const interval = start - lastFrameAt;
1194
+ if (interval < 100) {
1195
+ if (interval > SLOW_FRAME_MS) promotionsPerFrame = Math.max(1, promotionsPerFrame >> 1);
1196
+ else if (interval < FAST_FRAME_MS)
1197
+ promotionsPerFrame = Math.min(MAX_PROMOTIONS_PER_FRAME, promotionsPerFrame + 1);
1198
+ }
1199
+ lastFrameAt = start;
1200
+ let promoted = 0;
1201
+ for (const plan of [...plans.values()].sort((a, b) => a.priority - b.priority)) {
1202
+ if (plan.page.drawable) continue;
1203
+ if (promoted++ >= promotionsPerFrame) break;
1204
+ plan.page.setDrawable(true, frameNumber);
1205
+ }
1206
+ awaitingPaint = true;
1207
+ canvas.requestPaint();
1208
+ return;
1209
+ }
1210
+ let pending = 0;
1211
+ stats.rasterizedLastFrame = 0;
1212
+ stats.rasterCallsLastFrame = 0;
1213
+ if (inPaint && plans.size > 0) pending = rasterize(start);
1214
+ for (const page of pages.keys()) {
1215
+ if (page.drawable && !plans.has(page) && page.promotedFrame < frameNumber && !isPinned(page))
1216
+ page.setDrawable(false, frameNumber);
1217
+ }
1218
+ stats.rasterMs = performance.now() - start;
1219
+ composite();
1220
+ stats.frames++;
1221
+ positionLivePage();
1222
+ stats.visible = visible.length;
1223
+ stats.pending = pending;
1224
+ stats.frameMs = performance.now() - start;
1225
+ if (pending > 0) schedule();
1226
+ };
1227
+ let awaitingPaint = false;
1228
+ canvas.onpaint = (event) => {
1229
+ let changed = false;
1230
+ for (const element of event.changedElements) {
1231
+ const page = pageByElement.get(element);
1232
+ if (!page) continue;
1233
+ for (const node of [live, lingering, focused, ...transitioning.keys()]) {
1234
+ if (node?.page === page) {
1235
+ node.dirty = true;
1236
+ changed = true;
1237
+ }
1238
+ }
1239
+ }
1240
+ if (!awaitingPaint && !changed) return;
1241
+ awaitingPaint = false;
1242
+ frame(true);
1243
+ };
1244
+ const viewNow = () => visibleWorldRect(camera, canvas.clientWidth, canvas.clientHeight);
1245
+ const nodeOf = (target) => {
1246
+ let element = target instanceof HTMLElement ? target : target.parentElement;
1247
+ while (element?.parentElement && !pageByElement.has(element.parentElement)) {
1248
+ element = element.parentElement;
1249
+ }
1250
+ return element ? nodes.get(element) : void 0;
1251
+ };
1252
+ const mutations = new MutationObserver((records) => {
1253
+ let changed = false;
1254
+ for (const record of records) {
1255
+ const target = record.target;
1256
+ if (record.type === "attributes" && (nodes.has(target) || pageByElement.has(target) || target === canvas)) {
1257
+ continue;
1258
+ }
1259
+ const node = nodeOf(record.target);
1260
+ if (node && !node.dirty) {
1261
+ node.dirty = true;
1262
+ if (!changed) changed = intersects(node.rect, viewNow());
1263
+ }
1264
+ }
1265
+ if (changed) schedule();
1266
+ });
1267
+ mutations.observe(canvas, {
1268
+ subtree: true,
1269
+ childList: true,
1270
+ characterData: true,
1271
+ attributes: true
1272
+ });
1273
+ const unpin = (page) => {
1274
+ if (isPinned(page)) return;
1275
+ if (page.registered) canvas.clearElementGeometry(page.element);
1276
+ page.registered = false;
1277
+ page.element.style.transform = "";
1278
+ schedule();
1279
+ };
1280
+ const positionLivePage = () => {
1281
+ if (!live || livePositioned) return;
1282
+ const dpr = canvas.width / Math.max(1, canvas.clientWidth);
1283
+ const { zoom } = camera;
1284
+ const drawn2 = new DOMMatrix([
1285
+ zoom,
1286
+ 0,
1287
+ 0,
1288
+ zoom,
1289
+ ((live.rect.x - camera.x) * zoom - live.pageX * zoom) * dpr,
1290
+ ((live.rect.y - camera.y) * zoom - live.pageY * zoom) * dpr
1291
+ ]);
1292
+ try {
1293
+ live.page.element.style.transform = canvas.getElementTransform(live.page.element, drawn2).toString();
1294
+ live.element.style.pointerEvents = "auto";
1295
+ livePositioned = true;
1296
+ } catch {
1297
+ schedule();
1298
+ }
1299
+ };
1300
+ const setLive = (node) => {
1301
+ if (node === live) return;
1302
+ const previous = live;
1303
+ live = node;
1304
+ if (previous) {
1305
+ previous.element.style.pointerEvents = "none";
1306
+ clearTimeout(lingerTimer);
1307
+ const earlier = lingering;
1308
+ lingering = previous;
1309
+ if (earlier && earlier !== previous) {
1310
+ earlier.dirty = true;
1311
+ unpin(earlier.page);
1312
+ }
1313
+ lingerTimer = window.setTimeout(() => {
1314
+ const last = lingering;
1315
+ if (!last) return;
1316
+ lingering = null;
1317
+ last.dirty = true;
1318
+ unpin(last.page);
1319
+ }, LINGER_MS);
1320
+ unpin(previous.page);
1321
+ }
1322
+ if (!node) return;
1323
+ const { page } = node;
1324
+ page.setDrawable(true, frameNumber);
1325
+ if (!page.registered) {
1326
+ canvas.updateElementGeometry(page.element, {});
1327
+ page.registered = true;
1328
+ }
1329
+ livePositioned = false;
1330
+ positionLivePage();
1331
+ schedule();
1332
+ };
1333
+ const pickLive = () => {
1334
+ if (!pointer) return setLive(null);
1335
+ const worldX = camera.x + pointer.x / camera.zoom;
1336
+ const worldY = camera.y + pointer.y / camera.zoom;
1337
+ let hit = null;
1338
+ for (const node of nodes.values()) {
1339
+ const { x, y, width, height } = node.rect;
1340
+ if (worldX >= x && worldX < x + width && worldY >= y && worldY < y + height) hit = node;
1341
+ }
1342
+ setLive(hit);
1343
+ };
1344
+ const onPointerMove = (event) => {
1345
+ const bounds = canvas.getBoundingClientRect();
1346
+ pointer = { x: event.clientX - bounds.left, y: event.clientY - bounds.top };
1347
+ if (event.buttons === 0) pickLive();
1348
+ };
1349
+ const onPointerLeave = () => {
1350
+ pointer = null;
1351
+ setLive(null);
1352
+ };
1353
+ const onFocusIn = (event) => {
1354
+ const node = nodeOf(event.target) ?? null;
1355
+ if (node === focused) return;
1356
+ const previous = focused;
1357
+ focused = node;
1358
+ if (previous) {
1359
+ previous.dirty = true;
1360
+ unpin(previous.page);
1361
+ }
1362
+ if (node) {
1363
+ node.page.setDrawable(true, frameNumber);
1364
+ node.dirty = true;
1365
+ schedule();
1366
+ }
1367
+ };
1368
+ const onFocusOut = (event) => {
1369
+ if (event.relatedTarget && canvas.contains(event.relatedTarget)) return;
1370
+ const previous = focused;
1371
+ focused = null;
1372
+ if (previous) {
1373
+ previous.dirty = true;
1374
+ unpin(previous.page);
1375
+ }
1376
+ };
1377
+ const onResourceLoad = (event) => {
1378
+ const node = nodeOf(event.target);
1379
+ if (!node) return;
1380
+ node.dirty = true;
1381
+ schedule();
1382
+ };
1383
+ canvas.addEventListener("load", onResourceLoad, true);
1384
+ const onFontsLoaded = () => {
1385
+ for (const node of nodes.values()) node.dirty = true;
1386
+ schedule();
1387
+ };
1388
+ document.fonts.addEventListener("loadingdone", onFontsLoaded);
1389
+ surface.addEventListener("pointermove", onPointerMove);
1390
+ surface.addEventListener("pointerleave", onPointerLeave);
1391
+ canvas.addEventListener("focusin", onFocusIn);
1392
+ canvas.addEventListener("focusout", onFocusOut);
1393
+ const usedAt = /* @__PURE__ */ new WeakMap();
1394
+ const onUse = (event) => {
1395
+ const node = nodeOf(event.target);
1396
+ if (node) usedAt.set(node, performance.now());
1397
+ };
1398
+ const transitionMs = (element) => {
1399
+ const style = getComputedStyle(element);
1400
+ const seconds = (list) => list.split(",").map((value) => parseFloat(value) || 0);
1401
+ const durations = seconds(style.transitionDuration);
1402
+ const delays = seconds(style.transitionDelay);
1403
+ const longest = Math.max(...durations.map((d, i) => d + (delays[i % delays.length] ?? 0)));
1404
+ return Math.min(1e3, longest * 1e3);
1405
+ };
1406
+ const onTransitionRun = (event) => {
1407
+ const node = nodeOf(event.target);
1408
+ const now = performance.now();
1409
+ if (!node || now - (usedAt.get(node) ?? -Infinity) > USED_MS) return;
1410
+ if (!intersects(node.rect, viewNow())) return;
1411
+ if (Math.max(node.rect.width, node.rect.height) * camera.zoom < SMALL_NODE_PX) return;
1412
+ const ms = transitionMs(event.target) + 32;
1413
+ const end = now + ms;
1414
+ if ((transitioning.get(node) ?? 0) >= end) return;
1415
+ transitioning.set(node, end);
1416
+ node.dirty = true;
1417
+ schedule();
1418
+ setTimeout(() => {
1419
+ if (transitioning.get(node) !== end) return;
1420
+ transitioning.delete(node);
1421
+ node.dirty = true;
1422
+ unpin(node.page);
1423
+ schedule();
1424
+ }, ms);
1425
+ };
1426
+ for (const type of USE_EVENTS) canvas.addEventListener(type, onUse, true);
1427
+ canvas.addEventListener("transitionrun", onTransitionRun);
1428
+ const setCamera = (next) => {
1429
+ camera = next;
1430
+ lastCameraChange = performance.now();
1431
+ setLive(null);
1432
+ clearTimeout(settleTimer);
1433
+ settleTimer = window.setTimeout(() => {
1434
+ pickLive();
1435
+ schedule();
1436
+ }, SETTLE_MS);
1437
+ schedule();
1438
+ options.onCameraChange?.(camera);
1439
+ };
1440
+ const unbindInput = bindCameraInput(surface, () => camera, setCamera);
1441
+ const placeNode = (element, rect) => {
1442
+ let slot = openPage?.place(rect.width, rect.height) ?? null;
1443
+ if (!slot) {
1444
+ const page = new Page(
1445
+ Math.max(pageSize, Math.ceil(rect.width)),
1446
+ Math.max(pageSize, Math.ceil(rect.height))
1447
+ );
1448
+ pages.set(page, /* @__PURE__ */ new Set());
1449
+ pageByElement.set(page.element, page);
1450
+ canvas.append(page.element);
1451
+ if (page.width === pageSize && page.height === pageSize) openPage = page;
1452
+ slot = page.place(rect.width, rect.height);
1453
+ }
1454
+ Object.assign(element.style, {
1455
+ position: "absolute",
1456
+ left: `${slot.x}px`,
1457
+ top: `${slot.y}px`,
1458
+ width: `${rect.width}px`,
1459
+ height: `${rect.height}px`
1460
+ });
1461
+ slot.page.element.append(element);
1462
+ return slot;
1463
+ };
1464
+ const releaseNode = (node) => {
1465
+ const members = pages.get(node.page);
1466
+ members.delete(node);
1467
+ node.page.release();
1468
+ if (members.size === 0 && node.page !== openPage) {
1469
+ pages.delete(node.page);
1470
+ pageByElement.delete(node.page.element);
1471
+ node.page.element.remove();
1472
+ }
1473
+ };
1474
+ const lift = (node) => {
1475
+ const previous = node.page;
1476
+ const wasLive = live === node;
1477
+ if (wasLive) setLive(null);
1478
+ const page = new Page(Math.ceil(node.rect.width), Math.ceil(node.rect.height));
1479
+ pages.set(page, /* @__PURE__ */ new Set());
1480
+ pageByElement.set(page.element, page);
1481
+ canvas.append(page.element);
1482
+ page.place(node.rect.width, node.rect.height);
1483
+ const target = page.element;
1484
+ if (target.moveBefore) target.moveBefore(node.element, null);
1485
+ else target.append(node.element);
1486
+ node.element.style.left = "0px";
1487
+ node.element.style.top = "0px";
1488
+ releaseNode(node);
1489
+ node.page = page;
1490
+ node.pageX = 0;
1491
+ node.pageY = 0;
1492
+ node.solo = true;
1493
+ pages.get(page).add(node);
1494
+ if (pages.has(previous) && !isPinned(previous)) {
1495
+ unpin(previous);
1496
+ previous.setDrawable(false, frameNumber);
1497
+ }
1498
+ if (wasLive) pickLive();
1499
+ };
1500
+ const attach = (node) => {
1501
+ const slot = placeNode(node.element, node.rect);
1502
+ node.page = slot.page;
1503
+ node.pageX = slot.x;
1504
+ node.pageY = slot.y;
1505
+ node.slot = null;
1506
+ node.thumb = null;
1507
+ node.solo = false;
1508
+ node.dirty = true;
1509
+ pages.get(slot.page).add(node);
1510
+ };
1511
+ return {
1512
+ mode: "gpu",
1513
+ surface,
1514
+ addNode(element, rect) {
1515
+ Object.assign(element.style, {
1516
+ boxSizing: "border-box",
1517
+ contain: "strict",
1518
+ overflow: "hidden",
1519
+ pointerEvents: "none"
1520
+ });
1521
+ const node = {
1522
+ element,
1523
+ rect,
1524
+ slotScale: 0,
1525
+ thumb: null,
1526
+ thumbStale: false,
1527
+ thumbAt: -Infinity,
1528
+ shown: 0,
1529
+ shownFrame: -1,
1530
+ targetScale: 0,
1531
+ tiles: [],
1532
+ preview: null,
1533
+ previewRect: null,
1534
+ previewScale: 0,
1535
+ previewTarget: null,
1536
+ previewTargetScale: 0,
1537
+ changedAt: -Infinity,
1538
+ refreshedAt: -Infinity,
1539
+ wholeStale: false,
1540
+ solo: false,
1541
+ priority: 0
1542
+ };
1543
+ attach(node);
1544
+ nodes.set(element, node);
1545
+ schedule();
1546
+ },
1547
+ updateNode(element, rect) {
1548
+ const node = nodes.get(element);
1549
+ if (!node) return;
1550
+ const resized = node.rect.width !== rect.width || node.rect.height !== rect.height;
1551
+ node.rect = rect;
1552
+ if (resized) {
1553
+ if (live === node) setLive(null);
1554
+ releaseNode(node);
1555
+ attach(node);
1556
+ }
1557
+ schedule();
1558
+ },
1559
+ removeNode(element) {
1560
+ const node = nodes.get(element);
1561
+ if (!node) return;
1562
+ if (live === node) setLive(null);
1563
+ if (lingering === node) lingering = null;
1564
+ if (focused === node) focused = null;
1565
+ transitioning.delete(node);
1566
+ nodes.delete(element);
1567
+ releaseNode(node);
1568
+ element.remove();
1569
+ schedule();
1570
+ },
1571
+ getNodeRect: (element) => {
1572
+ const node = nodes.get(element);
1573
+ return node ? { ...node.rect } : null;
1574
+ },
1575
+ getCamera: () => camera,
1576
+ setCamera,
1577
+ invalidate: onFontsLoaded,
1578
+ setBackground(color) {
1579
+ [renderer.background, renderer.placeholder] = colorsFor(color);
1580
+ schedule();
1581
+ },
1582
+ fit(padding) {
1583
+ const bounds = boundsOf([...nodes.values()].map((node) => node.rect));
1584
+ if (bounds) setCamera(fitCamera(bounds, canvas.clientWidth, canvas.clientHeight, padding));
1585
+ },
1586
+ getStats: () => ({
1587
+ mode: "gpu",
1588
+ nodes: nodes.size,
1589
+ visible: stats.visible,
1590
+ pending: stats.pending,
1591
+ rasterizedLastFrame: stats.rasterizedLastFrame,
1592
+ rasterCallsLastFrame: stats.rasterCallsLastFrame,
1593
+ rasterMs: stats.rasterMs,
1594
+ pages: pages.size,
1595
+ drawablePages: [...pages.keys()].filter((page) => page.drawable).length,
1596
+ atlasLayersUsed: atlas.usedLayers,
1597
+ frameMs: stats.frameMs,
1598
+ frames: stats.frames,
1599
+ lostPixels: stats.lostPixels,
1600
+ lostDetail: stats.lostDetail,
1601
+ thumbnailFill: atlas.pinnedFill,
1602
+ onThumbnail: stats.onThumbnail,
1603
+ needs: stats.needs
1604
+ }),
1605
+ destroy() {
1606
+ destroyed = true;
1607
+ cancelAnimationFrame(rafId);
1608
+ clearTimeout(settleTimer);
1609
+ clearTimeout(lingerTimer);
1610
+ clearTimeout(contentTimer);
1611
+ mutations.disconnect();
1612
+ document.fonts.removeEventListener("loadingdone", onFontsLoaded);
1613
+ canvas.removeEventListener("load", onResourceLoad, true);
1614
+ surface.removeEventListener("pointermove", onPointerMove);
1615
+ surface.removeEventListener("pointerleave", onPointerLeave);
1616
+ canvas.removeEventListener("focusin", onFocusIn);
1617
+ canvas.removeEventListener("focusout", onFocusOut);
1618
+ canvas.removeEventListener("transitionrun", onTransitionRun);
1619
+ for (const type of USE_EVENTS) canvas.removeEventListener(type, onUse, true);
1620
+ unbindInput();
1621
+ resizeObserver.disconnect();
1622
+ canvas.onpaint = null;
1623
+ surface.remove();
1624
+ renderer.destroy();
1625
+ atlas.destroy();
1626
+ device.destroy();
1627
+ }
1628
+ };
1629
+ }
1630
+
1631
+ // src/index.ts
1632
+ async function createGpuCanvas2(host, options = {}) {
1633
+ const mode = options.mode ?? "auto";
1634
+ if (mode === "dom" || mode === "auto" && !supportsGpuCanvas()) {
1635
+ return createDomCanvas(host, options);
1636
+ }
1637
+ try {
1638
+ return await createGpuCanvas(host, options);
1639
+ } catch (error) {
1640
+ if (mode === "gpu") throw error;
1641
+ return createDomCanvas(host, options);
1642
+ }
1643
+ }
1644
+
1645
+ export {
1646
+ zoomAt,
1647
+ fitCamera,
1648
+ supportsGpuCanvas,
1649
+ createGpuCanvas2 as createGpuCanvas
1650
+ };
1651
+ //# sourceMappingURL=chunk-JQQ44OTN.js.map