@vkcha/svg-core 0.1.0 → 0.1.2

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/src/SvgCore.ts ADDED
@@ -0,0 +1,623 @@
1
+ import type { PanZoomListener, PanZoomOptions, PanZoomState } from "./canvas/PanZoomCanvas";
2
+ import { PanZoomCanvas } from "./canvas/PanZoomCanvas";
3
+ import type { Node } from "./scene/Node";
4
+ import { measureFragmentMetrics, parseFragmentElements, sanitizeFragment } from "./scene/fragment";
5
+
6
+ export type CullingStats = {
7
+ visible: number;
8
+ hidden: number;
9
+ total: number;
10
+ };
11
+
12
+ export type CullingOptions = {
13
+ /** Default: true */
14
+ enabled?: boolean;
15
+ /** Default: 30 */
16
+ overscanPx?: number;
17
+ };
18
+
19
+ export type InitOptions = {
20
+ panZoom?: Partial<PanZoomOptions>;
21
+ culling?: boolean | CullingOptions;
22
+ };
23
+
24
+ /**
25
+ * SvgCore entrypoint.
26
+ *
27
+ * Usage:
28
+ * const v = new SvgCore(svgElement)
29
+ */
30
+ export class SvgCore {
31
+ private canvas: PanZoomCanvas;
32
+ private nodesLayer: SVGGElement;
33
+
34
+ private nodes: Node[] = [];
35
+ private nodeIdToIndex = new Map<string, number>();
36
+ private nodeBounds: Array<{
37
+ x0: number;
38
+ y0: number;
39
+ x1: number;
40
+ y1: number;
41
+ }> | null = null;
42
+
43
+ private cullingEnabled = true;
44
+ private cullingOverscanPx = 30;
45
+ private resizeObserver: ResizeObserver | null = null;
46
+ private unsubPanZoom: (() => void) | null = null;
47
+ private unsubSvgEvents: (() => void) | null = null;
48
+ private svgClickTimer: number | null = null;
49
+ private suppressNextClick = false;
50
+ private dragWatch: {
51
+ pointerId: number;
52
+ startClientX: number;
53
+ startClientY: number;
54
+ moved: boolean;
55
+ } | null = null;
56
+
57
+ private cullingListeners = new Set<(stats: Readonly<CullingStats>) => void>();
58
+ private lastCullingStats: CullingStats = { visible: 0, hidden: 0, total: 0 };
59
+ private cullingNotifyScheduled = false;
60
+
61
+ /** SVG root passed to the constructor. */
62
+ get svg(): SVGSVGElement {
63
+ return this.canvas.svg;
64
+ }
65
+
66
+ /** World layer (<g>) that you draw into. */
67
+ get world(): SVGGElement {
68
+ return this.canvas.world;
69
+ }
70
+
71
+ /** Current pan/zoom state. */
72
+ get state(): PanZoomState {
73
+ return this.canvas.state;
74
+ }
75
+
76
+ /** Current pan/zoom options (includes minZoom/maxZoom). */
77
+ get panZoomOptions(): Readonly<PanZoomOptions> {
78
+ return this.canvas.options;
79
+ }
80
+
81
+ constructor(svg: SVGSVGElement, opts?: InitOptions) {
82
+ this.canvas = new PanZoomCanvas(svg, opts?.panZoom);
83
+
84
+ this.nodesLayer = document.createElementNS("http://www.w3.org/2000/svg", "g");
85
+ this.nodesLayer.dataset.layer = "nodes";
86
+ this.world.appendChild(this.nodesLayer);
87
+ this.world.style.pointerEvents = "none";
88
+
89
+ const c = opts?.culling;
90
+ if (typeof c === "boolean") {
91
+ this.cullingEnabled = c;
92
+ } else if (c) {
93
+ if (typeof c.enabled === "boolean") this.cullingEnabled = c.enabled;
94
+ if (typeof c.overscanPx === "number") this.cullingOverscanPx = Math.max(0, c.overscanPx);
95
+ }
96
+
97
+ // Core-owned: keep culling in sync with pan/zoom changes.
98
+ this.unsubPanZoom = this.canvas.subscribe(() => this.applyCulling());
99
+
100
+ // Core-owned: keep culling correct when viewport size changes.
101
+ this.resizeObserver = new ResizeObserver(() => this.applyCulling());
102
+ this.resizeObserver.observe(this.svg);
103
+
104
+ // Core-owned: basic SVG interaction events (for now just log).
105
+ // Notes:
106
+ // - Drag-to-pan emits a "click" after pointerup; we suppress that when movement exceeds a threshold.
107
+ // - We do NOT use the native "dblclick" event. Instead:
108
+ // - 1st click starts a short timer
109
+ // - 2nd click within that window becomes "doubleclick" and cancels the pending single-click
110
+ const CLICK_DELAY_MS = 300;
111
+ const DRAG_THRESHOLD_PX = 5;
112
+
113
+ const clearClickTimer = () => {
114
+ if (this.svgClickTimer !== null) {
115
+ window.clearTimeout(this.svgClickTimer);
116
+ this.svgClickTimer = null;
117
+ }
118
+ };
119
+
120
+ const onPointerDown = (e: PointerEvent) => {
121
+ // Only track left-button drags for click suppression.
122
+ if (e.button !== 0) return;
123
+ this.dragWatch = {
124
+ pointerId: e.pointerId,
125
+ startClientX: e.clientX,
126
+ startClientY: e.clientY,
127
+ moved: false,
128
+ };
129
+ };
130
+
131
+ const onPointerMove = (e: PointerEvent) => {
132
+ const w = this.dragWatch;
133
+ if (!w) return;
134
+ if (e.pointerId !== w.pointerId) return;
135
+ // Only while left button is held.
136
+ if ((e.buttons & 1) !== 1) return;
137
+
138
+ const dx = e.clientX - w.startClientX;
139
+ const dy = e.clientY - w.startClientY;
140
+ if (!w.moved && Math.hypot(dx, dy) >= DRAG_THRESHOLD_PX) w.moved = true;
141
+ };
142
+
143
+ const onPointerEnd = (e: PointerEvent) => {
144
+ const w = this.dragWatch;
145
+ if (!w) return;
146
+ if (e.pointerId !== w.pointerId) return;
147
+ this.dragWatch = null;
148
+ if (w.moved) {
149
+ this.suppressNextClick = true;
150
+ clearClickTimer();
151
+ }
152
+ };
153
+
154
+ const onClick = (e: MouseEvent) => {
155
+ if (this.suppressNextClick) {
156
+ this.suppressNextClick = false;
157
+ return;
158
+ }
159
+ if (this.svgClickTimer !== null) {
160
+ // Second click within the window => treat as "doubleclick".
161
+ clearClickTimer();
162
+ const hit = this.hitTestVisibleNodeAtClient(e.clientX, e.clientY);
163
+ if (hit?.onDoubleClick) {
164
+ hit.onDoubleClick(hit);
165
+ }
166
+ return;
167
+ }
168
+ // First click => delay, so a potential second click can convert it to "doubleclick".
169
+ this.svgClickTimer = window.setTimeout(() => {
170
+ this.svgClickTimer = null;
171
+ const hit = this.hitTestVisibleNodeAtClient(e.clientX, e.clientY);
172
+ if (hit?.onClick) {
173
+ hit.onClick(hit);
174
+ }
175
+ }, CLICK_DELAY_MS);
176
+ };
177
+
178
+ const onRightClick = (e: MouseEvent) => {
179
+ e.preventDefault(); // treat this as "rightclick" without opening the context menu
180
+ clearClickTimer();
181
+ const hit = this.hitTestVisibleNodeAtClient(e.clientX, e.clientY);
182
+ if (hit?.onRightClick) {
183
+ hit.onRightClick(hit);
184
+ }
185
+ };
186
+
187
+ this.svg.addEventListener("click", onClick);
188
+ this.svg.addEventListener("contextmenu", onRightClick);
189
+ this.svg.addEventListener("pointerdown", onPointerDown);
190
+ this.svg.addEventListener("pointermove", onPointerMove);
191
+ this.svg.addEventListener("pointerup", onPointerEnd);
192
+ this.svg.addEventListener("pointercancel", onPointerEnd);
193
+ this.unsubSvgEvents = () => {
194
+ this.svg.removeEventListener("click", onClick);
195
+ this.svg.removeEventListener("contextmenu", onRightClick);
196
+ this.svg.removeEventListener("pointerdown", onPointerDown);
197
+ this.svg.removeEventListener("pointermove", onPointerMove);
198
+ this.svg.removeEventListener("pointerup", onPointerEnd);
199
+ this.svg.removeEventListener("pointercancel", onPointerEnd);
200
+ clearClickTimer();
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Set zoom while keeping a chosen screen-space anchor stable.
206
+ * By default anchors at the viewport center.
207
+ */
208
+ setZoom(nextZoom: number, anchor?: { x: number; y: number }) {
209
+ const minZ = this.canvas.options.minZoom;
210
+ const maxZ = this.canvas.options.maxZoom;
211
+ const z = Math.min(maxZ, Math.max(minZ, nextZoom));
212
+
213
+ const r = this.svg.getBoundingClientRect();
214
+ const ax = anchor?.x ?? Math.max(1, r.width) / 2;
215
+ const ay = anchor?.y ?? Math.max(1, r.height) / 2;
216
+
217
+ const cur = this.state;
218
+ // screen = world * zoom + pan => world = (screen - pan) / zoom
219
+ const worldX = (ax - cur.panX) / Math.max(1e-9, cur.zoom);
220
+ const worldY = (ay - cur.panY) / Math.max(1e-9, cur.zoom);
221
+
222
+ // keep world point under anchor stable:
223
+ // pan = screen - world * zoom
224
+ const nextPanX = ax - worldX * z;
225
+ const nextPanY = ay - worldY * z;
226
+
227
+ this.setState({ zoom: z, panX: nextPanX, panY: nextPanY });
228
+ }
229
+
230
+ /**
231
+ * Convert a pointer position (client px) into canvas/world coordinates,
232
+ * using the current pan/zoom state.
233
+ */
234
+ clientToCanvas(clientX: number, clientY: number): { x: number; y: number } {
235
+ const r = this.svg.getBoundingClientRect();
236
+ const sx = clientX - r.left;
237
+ const sy = clientY - r.top;
238
+ const { panX, panY, zoom } = this.state;
239
+ const z = Math.max(1e-9, zoom);
240
+ return { x: (sx - panX) / z, y: (sy - panY) / z };
241
+ }
242
+
243
+ /**
244
+ * Fast hit-test using the culling output: only checks nodes currently attached to `nodesLayer`
245
+ * (i.e. the visible subset after culling).
246
+ *
247
+ * Returns the topmost hit node (based on render order), or null.
248
+ */
249
+ hitTestVisibleNodeAtClient(clientX: number, clientY: number): Node | null {
250
+ if (!this.nodeBounds || this.nodes.length === 0) return null;
251
+ const p = this.clientToCanvas(clientX, clientY);
252
+ const kids = this.nodesLayer.children;
253
+ // Scan from topmost to bottommost: last child is visually on top.
254
+ for (let k = kids.length - 1; k >= 0; k--) {
255
+ const el = kids.item(k) as SVGGElement | null;
256
+ if (!el) continue;
257
+ const id = el.dataset.nodeId;
258
+ if (!id) continue;
259
+ const idx = this.nodeIdToIndex.get(id);
260
+ if (idx === undefined) continue;
261
+ const b = this.nodeBounds[idx];
262
+ if (!b) continue;
263
+ if (p.x >= b.x0 && p.x <= b.x1 && p.y >= b.y0 && p.y <= b.y1) return this.nodes[idx];
264
+ }
265
+ return null;
266
+ }
267
+
268
+ zoomBy(factor: number, anchor?: { x: number; y: number }) {
269
+ const f = Number.isFinite(factor) ? factor : 1;
270
+ if (f <= 0) return;
271
+ this.setZoom(this.state.zoom * f, anchor);
272
+ }
273
+
274
+ setState(next: Partial<PanZoomState>) {
275
+ this.canvas.setState(next);
276
+ }
277
+
278
+ resetView() {
279
+ this.canvas.reset();
280
+ }
281
+
282
+ configurePanZoom(opts: Partial<PanZoomOptions>) {
283
+ this.canvas.setOptions(opts);
284
+ }
285
+
286
+ setNodes(nodes: Node[]) {
287
+ // Warn if node IDs are not unique
288
+ const seenIds = new Set<string>();
289
+ const duplicateIds = new Set<string>();
290
+ for (let i = 0; i < nodes.length; i++) {
291
+ const id = nodes[i].id;
292
+ if (seenIds.has(id)) {
293
+ duplicateIds.add(id);
294
+ } else {
295
+ seenIds.add(id);
296
+ }
297
+ }
298
+ if (duplicateIds.size > 0) {
299
+ console.warn(
300
+ `Duplicate node ids found: ${Array.from(duplicateIds)
301
+ .map((id) => `"${id}"`)
302
+ .join(", ")}. Each node should have a unique id.`,
303
+ );
304
+ }
305
+
306
+ this.nodes = nodes;
307
+ this.nodeIdToIndex.clear();
308
+ // Build map of node id to index for fast lookup.
309
+ // Note: If there are duplicate IDs, the last occurrence will overwrite previous ones.
310
+ for (let i = 0; i < nodes.length; i++) {
311
+ this.nodeIdToIndex.set(nodes[i].id, i);
312
+ }
313
+ this.redraw();
314
+ }
315
+
316
+ /**
317
+ * Redraw the currently assigned nodes.
318
+ *
319
+ * Call this if you mutate node properties in-place (e.g. `node.x = ...` or `node.fragment = ...`).
320
+ *
321
+ * @param ids Optional array of node ids to redraw. If provided, only these nodes will be redrawn.
322
+ * If not provided, all nodes will be redrawn.
323
+ */
324
+ redraw(ids?: string[]) {
325
+ if (Array.isArray(ids) && ids.length > 0) {
326
+ this.renderNodes(ids);
327
+ // After selective render, we still need to apply culling to all nodes
328
+ this.applyCulling();
329
+ } else {
330
+ this.renderNodes();
331
+ this.applyCulling();
332
+ }
333
+ }
334
+
335
+ setCullingEnabled(enabled: boolean) {
336
+ this.cullingEnabled = enabled;
337
+ this.applyCulling();
338
+ }
339
+
340
+ setCullingOverscanPx(px: number) {
341
+ this.cullingOverscanPx = Math.max(0, px);
342
+ this.applyCulling();
343
+ }
344
+
345
+ /** Subscribe to culling stats updates (event-driven). */
346
+ onCullingStatsChange(fn: (stats: Readonly<CullingStats>) => void): () => void {
347
+ this.cullingListeners.add(fn);
348
+ fn(this.lastCullingStats);
349
+ return () => this.cullingListeners.delete(fn);
350
+ }
351
+
352
+ /** Subscribe to pan/zoom updates (event-driven). */
353
+ onPanZoomChange(fn: PanZoomListener): () => void {
354
+ return this.canvas.subscribe(fn);
355
+ }
356
+
357
+ /**
358
+ * Remove nodes from the scene.
359
+ *
360
+ * @param ids Optional array of node ids to remove. If not provided, removes all nodes.
361
+ */
362
+ remove(ids?: string[]) {
363
+ if (!ids || ids.length === 0) {
364
+ // Remove all nodes
365
+ this.nodes = [];
366
+ this.nodeIdToIndex.clear();
367
+ this.nodesLayer.replaceChildren();
368
+ this.nodeBounds = null;
369
+ this.setCullingStats({ visible: 0, hidden: 0, total: 0 });
370
+ return;
371
+ }
372
+
373
+ // Remove specific nodes by ids
374
+ const indicesToRemove = new Set<number>();
375
+
376
+ for (const id of ids) {
377
+ const idx = this.nodeIdToIndex.get(id);
378
+ if (idx !== undefined) {
379
+ indicesToRemove.add(idx);
380
+ }
381
+ }
382
+
383
+ if (indicesToRemove.size === 0) return;
384
+
385
+ // Remove nodes in reverse order to maintain indices
386
+ const sortedIndices = Array.from(indicesToRemove).sort((a, b) => b - a);
387
+ for (const idx of sortedIndices) {
388
+ const node = this.nodes[idx];
389
+ if (node) {
390
+ // Remove from DOM
391
+ if (node.el.parentElement) {
392
+ node.el.remove();
393
+ }
394
+ // Remove from map
395
+ this.nodeIdToIndex.delete(node.id);
396
+ }
397
+ this.nodes.splice(idx, 1);
398
+ }
399
+
400
+ // Rebuild index map
401
+ this.nodeIdToIndex.clear();
402
+ for (let i = 0; i < this.nodes.length; i++) {
403
+ this.nodeIdToIndex.set(this.nodes[i].id, i);
404
+ }
405
+
406
+ // Rebuild bounds array
407
+ if (this.nodeBounds) {
408
+ const newBounds: Array<{ x0: number; y0: number; x1: number; y1: number }> = [];
409
+ for (let i = 0; i < this.nodes.length; i++) {
410
+ const node = this.nodes[i];
411
+ const metrics = measureFragmentMetrics(node.fragment);
412
+ const bbox = metrics?.bbox ?? { x: 0, y: 0, width: 240, height: 160 };
413
+ const pad = metrics?.pad ?? 0;
414
+ const w = node.width ?? Math.max(1, bbox.width + pad * 2);
415
+ const h = node.height ?? Math.max(1, bbox.height + pad * 2);
416
+ newBounds.push({ x0: node.x, y0: node.y, x1: node.x + w, y1: node.y + h });
417
+ }
418
+ this.nodeBounds = newBounds;
419
+ }
420
+
421
+ this.applyCulling();
422
+ }
423
+
424
+ destroy() {
425
+ this.resizeObserver?.disconnect();
426
+ this.resizeObserver = null;
427
+ this.unsubPanZoom?.();
428
+ this.unsubPanZoom = null;
429
+ this.unsubSvgEvents?.();
430
+ this.unsubSvgEvents = null;
431
+ this.cullingListeners.clear();
432
+ this.canvas.destroy();
433
+ }
434
+
435
+ private renderNodes(ids?: string[]) {
436
+ // If ids are provided, only update those specific nodes
437
+ if (ids && ids.length > 0) {
438
+ for (const id of ids) {
439
+ const idx = this.nodeIdToIndex.get(id);
440
+ if (idx === undefined) continue;
441
+ const node = this.nodes[idx];
442
+ if (!node) continue;
443
+
444
+ const g = node.el;
445
+ g.replaceChildren();
446
+ g.setAttribute("transform", `translate(${node.x} ${node.y})`);
447
+
448
+ const cleaned = sanitizeFragment(node.fragment);
449
+ if (cleaned) {
450
+ const children = parseFragmentElements(cleaned);
451
+ const metrics = measureFragmentMetrics(cleaned);
452
+ const bbox = metrics?.bbox ?? { x: 0, y: 0, width: 240, height: 160 };
453
+ const pad = metrics?.pad ?? 0;
454
+ const w = Math.max(1, bbox.width + pad * 2);
455
+ const h = Math.max(1, bbox.height + pad * 2);
456
+ const offsetX = -bbox.x + pad;
457
+ const offsetY = -bbox.y + pad;
458
+
459
+ const inner = document.createElementNS("http://www.w3.org/2000/svg", "g");
460
+ inner.setAttribute("transform", `translate(${offsetX} ${offsetY})`);
461
+ for (const child of children) inner.appendChild(child.cloneNode(true));
462
+ g.appendChild(inner);
463
+
464
+ // Update bounds
465
+ if (this.nodeBounds) {
466
+ const nodeW = node.width ?? w;
467
+ const nodeH = node.height ?? h;
468
+ this.nodeBounds[idx] = {
469
+ x0: node.x,
470
+ y0: node.y,
471
+ x1: node.x + nodeW,
472
+ y1: node.y + nodeH,
473
+ };
474
+ }
475
+ }
476
+
477
+ // Ensure node is attached if it's not already
478
+ if (!g.parentElement) {
479
+ this.nodesLayer.appendChild(g);
480
+ }
481
+ }
482
+ return; // Culling will be applied by redraw()
483
+ }
484
+
485
+ // Full render: clear and rebuild everything
486
+ this.nodesLayer.replaceChildren();
487
+ this.nodeBounds = null;
488
+ if (this.nodes.length === 0) return;
489
+
490
+ // Cache fragment -> parsed children and metrics.
491
+ // This allows each node to carry its own fragment while still keeping render fast.
492
+ const fragmentCache = new Map<
493
+ string,
494
+ {
495
+ children: Element[];
496
+ w: number;
497
+ h: number;
498
+ offsetX: number;
499
+ offsetY: number;
500
+ }
501
+ >();
502
+
503
+ for (const node of this.nodes) {
504
+ const cleaned = sanitizeFragment(node.fragment);
505
+ if (!cleaned) continue;
506
+ if (fragmentCache.has(cleaned)) continue;
507
+ const children = parseFragmentElements(cleaned);
508
+ const metrics = measureFragmentMetrics(cleaned);
509
+ const bbox = metrics?.bbox ?? { x: 0, y: 0, width: 240, height: 160 };
510
+ const pad = metrics?.pad ?? 0;
511
+ const w = Math.max(1, bbox.width + pad * 2);
512
+ const h = Math.max(1, bbox.height + pad * 2);
513
+ // Normalize fragment so its bbox starts at (0,0) with padding applied.
514
+ const offsetX = -bbox.x + pad;
515
+ const offsetY = -bbox.y + pad;
516
+ fragmentCache.set(cleaned, { children, w, h, offsetX, offsetY });
517
+ }
518
+
519
+ const count = this.nodes.length;
520
+
521
+ const frag = document.createDocumentFragment();
522
+ const bounds: Array<{ x0: number; y0: number; x1: number; y1: number }> = new Array(count);
523
+ for (let i = 0; i < count; i++) {
524
+ const node = this.nodes[i];
525
+ const g = node.el;
526
+ g.replaceChildren();
527
+ g.setAttribute("transform", `translate(${node.x} ${node.y})`);
528
+ const cleaned = sanitizeFragment(node.fragment);
529
+ const cached = cleaned ? fragmentCache.get(cleaned) : null;
530
+ if (cached) {
531
+ // Insert the fragment content directly into the node group (no nested <svg>),
532
+ // but normalize it to (0,0) using a wrapper group.
533
+ const inner = document.createElementNS("http://www.w3.org/2000/svg", "g");
534
+ inner.setAttribute("transform", `translate(${cached.offsetX} ${cached.offsetY})`);
535
+ for (const child of cached.children) inner.appendChild(child.cloneNode(true));
536
+ g.appendChild(inner);
537
+ }
538
+ const w = node.width ?? cached?.w ?? 240;
539
+ const h = node.height ?? cached?.h ?? 160;
540
+ bounds[i] = { x0: node.x, y0: node.y, x1: node.x + w, y1: node.y + h };
541
+ frag.appendChild(g);
542
+ }
543
+ this.nodesLayer.appendChild(frag);
544
+ this.nodeBounds = bounds;
545
+ }
546
+
547
+ private applyCulling() {
548
+ if (!this.nodeBounds) {
549
+ this.setCullingStats({ visible: 0, hidden: 0, total: this.nodes.length });
550
+ return;
551
+ }
552
+ const count = this.nodes.length;
553
+
554
+ // If disabled, ensure everything is visible/attached.
555
+ if (!this.cullingEnabled) {
556
+ this.nodesLayer.replaceChildren(...this.nodes.map((n) => n.el));
557
+ for (const n of this.nodes) n.el.removeAttribute("display");
558
+ this.setCullingStats({ visible: count, hidden: 0, total: count });
559
+ return;
560
+ }
561
+
562
+ const vp = this.getWorldViewport(this.state, this.cullingOverscanPx);
563
+ const visibleNodes: SVGGElement[] = [];
564
+ for (let i = 0; i < count; i++) {
565
+ const rect = this.nodeBounds[i];
566
+ if (rect && this.rectsIntersect(rect, vp)) {
567
+ const el = this.nodes[i].el;
568
+ el.removeAttribute("display");
569
+ visibleNodes.push(el);
570
+ }
571
+ }
572
+ this.nodesLayer.replaceChildren(...visibleNodes);
573
+ this.setCullingStats({
574
+ visible: visibleNodes.length,
575
+ hidden: count - visibleNodes.length,
576
+ total: count,
577
+ });
578
+ }
579
+
580
+ private setCullingStats(next: CullingStats) {
581
+ const prev = this.lastCullingStats;
582
+ if (prev.visible === next.visible && prev.hidden === next.hidden && prev.total === next.total)
583
+ return;
584
+ this.lastCullingStats = next;
585
+ this.scheduleCullingNotify();
586
+ }
587
+
588
+ private scheduleCullingNotify() {
589
+ if (this.cullingNotifyScheduled) return;
590
+ this.cullingNotifyScheduled = true;
591
+ requestAnimationFrame(() => {
592
+ this.cullingNotifyScheduled = false;
593
+ for (const fn of this.cullingListeners) fn(this.lastCullingStats);
594
+ });
595
+ }
596
+
597
+ private rectsIntersect(
598
+ a: { x0: number; y0: number; x1: number; y1: number },
599
+ b: {
600
+ x0: number;
601
+ y0: number;
602
+ x1: number;
603
+ y1: number;
604
+ },
605
+ ) {
606
+ return !(a.x1 < b.x0 || a.x0 > b.x1 || a.y1 < b.y0 || a.y0 > b.y1);
607
+ }
608
+
609
+ private getWorldViewport(s: PanZoomState, overscanPx: number) {
610
+ const r = this.svg.getBoundingClientRect();
611
+ const w = Math.max(1, r.width);
612
+ const h = Math.max(1, r.height);
613
+ const z = Math.max(1e-9, s.zoom);
614
+
615
+ // screen = world * zoom + pan
616
+ const o = Math.max(0, overscanPx) / z; // expand in world units
617
+ const x0 = -s.panX / z - o;
618
+ const y0 = -s.panY / z - o;
619
+ const x1 = (w - s.panX) / z + o;
620
+ const y1 = (h - s.panY) / z + o;
621
+ return { x0, y0, x1, y1 };
622
+ }
623
+ }