@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/LICENSE +21 -0
- package/README.md +283 -19
- package/package.json +9 -7
- package/src/SvgCore.ts +623 -0
- package/src/canvas/PanZoomCanvas.ts +302 -0
- package/src/index.ts +11 -0
- package/src/scene/Node.ts +66 -0
- package/src/scene/fragment.ts +146 -0
- package/src/utils/dom.ts +17 -0
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
|
+
}
|