vizcraft 0.1.1

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/builder.ts ADDED
@@ -0,0 +1,946 @@
1
+ import type {
2
+ VizScene,
3
+ VizNode,
4
+ VizEdge,
5
+ NodeLabel,
6
+ EdgeLabel,
7
+ AnimationConfig,
8
+ VizOverlaySpec,
9
+ VizGridConfig,
10
+ } from './types';
11
+ import { DEFAULT_VIZ_CSS } from './styles';
12
+ import { defaultCoreAnimationRegistry } from './animations';
13
+ import { defaultCoreOverlayRegistry } from './overlays';
14
+
15
+ interface VizBuilder {
16
+ view(w: number, h: number): VizBuilder;
17
+ grid(
18
+ cols: number,
19
+ rows: number,
20
+ padding?: { x: number; y: number }
21
+ ): VizBuilder;
22
+ overlay<T>(id: string, params: T, key?: string): VizBuilder;
23
+ node(id: string): NodeBuilder;
24
+ edge(from: string, to: string, id?: string): EdgeBuilder;
25
+ build(): VizScene;
26
+
27
+ // Internal helper for NodeBuilder to access grid config
28
+ _getGridConfig(): VizGridConfig | null;
29
+ _getViewBox(): { w: number; h: number };
30
+ svg(): string;
31
+ mount(container: HTMLElement): void;
32
+ }
33
+
34
+ interface NodeBuilder {
35
+ at(x: number, y: number): NodeBuilder;
36
+ cell(
37
+ col: number,
38
+ row: number,
39
+ align?: 'center' | 'start' | 'end'
40
+ ): NodeBuilder;
41
+ circle(r: number): NodeBuilder;
42
+ rect(w: number, h: number, rx?: number): NodeBuilder;
43
+ diamond(w: number, h: number): NodeBuilder;
44
+ label(text: string, opts?: Partial<NodeLabel>): NodeBuilder;
45
+ class(name: string): NodeBuilder;
46
+ animate(type: string, config?: AnimationConfig): NodeBuilder;
47
+ data(payload: unknown): NodeBuilder;
48
+ onClick(handler: (id: string, node: VizNode) => void): NodeBuilder;
49
+ done(): VizBuilder;
50
+
51
+ // Seamless chaining extensions
52
+ node(id: string): NodeBuilder;
53
+ edge(from: string, to: string, id?: string): EdgeBuilder;
54
+ overlay<T>(id: string, params: T, key?: string): VizBuilder;
55
+ build(): VizScene;
56
+ svg(): string;
57
+ }
58
+
59
+ interface EdgeBuilder {
60
+ straight(): EdgeBuilder;
61
+ label(text: string, opts?: Partial<EdgeLabel>): EdgeBuilder;
62
+ arrow(enabled?: boolean): EdgeBuilder;
63
+ class(name: string): EdgeBuilder;
64
+ hitArea(px: number): EdgeBuilder;
65
+ animate(type: string, config?: AnimationConfig): EdgeBuilder;
66
+ data(payload: unknown): EdgeBuilder;
67
+ onClick(handler: (id: string, edge: VizEdge) => void): EdgeBuilder;
68
+ done(): VizBuilder;
69
+
70
+ // Seamless chaining extensions
71
+ node(id: string): NodeBuilder;
72
+ edge(from: string, to: string, id?: string): EdgeBuilder;
73
+ overlay<T>(id: string, params: T, key?: string): VizBuilder;
74
+ build(): VizScene;
75
+ svg(): string;
76
+ }
77
+
78
+ class VizBuilderImpl implements VizBuilder {
79
+ private _viewBox = { w: 800, h: 600 };
80
+ private _nodes = new Map<string, Partial<VizNode>>();
81
+ private _edges = new Map<string, Partial<VizEdge>>();
82
+ private _overlays: VizOverlaySpec[] = [];
83
+ private _nodeOrder: string[] = [];
84
+ private _edgeOrder: string[] = [];
85
+ private _gridConfig: VizGridConfig | null = null;
86
+
87
+ /**
88
+ * Sets the view box.
89
+ * @param w The width of the view box
90
+ * @param h The height of the view box
91
+ * @returns The builder
92
+ */
93
+ view(w: number, h: number): VizBuilder {
94
+ this._viewBox = { w, h };
95
+ return this;
96
+ }
97
+
98
+ /**
99
+ * Sets the grid configuration.
100
+ * @param cols The number of columns
101
+ * @param rows The number of rows
102
+ * @param padding The padding of the grid
103
+ * @returns The builder
104
+ */
105
+ grid(
106
+ cols: number,
107
+ rows: number,
108
+ padding: { x: number; y: number } = { x: 20, y: 20 }
109
+ ): VizBuilder {
110
+ this._gridConfig = { cols, rows, padding };
111
+ return this;
112
+ }
113
+
114
+ /**
115
+ * Adds an overlay to the scene.
116
+ * @param id The ID of the overlay
117
+ * @param params The parameters of the overlay
118
+ * @param key The key of the overlay
119
+ * @returns The builder
120
+ */
121
+ overlay<T>(id: string, params: T, key?: string): VizBuilder {
122
+ this._overlays.push({ id, params, key });
123
+ return this;
124
+ }
125
+
126
+ /**
127
+ * Creates a node.
128
+ * @param id The ID of the node
129
+ * @returns The node builder
130
+ */
131
+ node(id: string): NodeBuilder {
132
+ if (!this._nodes.has(id)) {
133
+ // Set default position and shape
134
+ this._nodes.set(id, {
135
+ id,
136
+ pos: { x: 0, y: 0 },
137
+ shape: { kind: 'circle', r: 10 },
138
+ });
139
+ this._nodeOrder.push(id);
140
+ }
141
+ return new NodeBuilderImpl(this, this._nodes.get(id)!); // The ! asserts that the node exists, because we just added it
142
+ }
143
+
144
+ /**
145
+ * Creates an edge between two nodes.
146
+ * @param from The source node
147
+ * @param to The target node
148
+ * @param id The ID of the edge
149
+ * @returns The edge builder
150
+ */
151
+ edge(from: string, to: string, id?: string): EdgeBuilder {
152
+ const edgeId = id || `${from}->${to}`;
153
+ if (!this._edges.has(edgeId)) {
154
+ this._edges.set(edgeId, { id: edgeId, from, to });
155
+ this._edgeOrder.push(edgeId);
156
+ }
157
+ return new EdgeBuilderImpl(this, this._edges.get(edgeId)!);
158
+ }
159
+
160
+ /**
161
+ * Builds the scene.
162
+ * @returns The scene
163
+ */
164
+ build(): VizScene {
165
+ this._edges.forEach((edge) => {
166
+ if (!this._nodes.has(edge.from!)) {
167
+ console.warn(
168
+ `VizBuilder: Edge ${edge.id} references missing source node ${edge.from}`
169
+ );
170
+ }
171
+ if (!this._nodes.has(edge.to!)) {
172
+ console.warn(
173
+ `VizBuilder: Edge ${edge.id} references missing target node ${edge.to}`
174
+ );
175
+ }
176
+ });
177
+
178
+ const nodes = this._nodeOrder.map((id) => this._nodes.get(id) as VizNode);
179
+ const edges = this._edgeOrder.map((id) => this._edges.get(id) as VizEdge);
180
+
181
+ return {
182
+ viewBox: this._viewBox,
183
+ grid: this._gridConfig || undefined,
184
+ nodes,
185
+ edges,
186
+ overlays: this._overlays,
187
+ };
188
+ }
189
+
190
+ _getGridConfig(): VizGridConfig | null {
191
+ return this._gridConfig;
192
+ }
193
+
194
+ _getViewBox() {
195
+ return this._viewBox;
196
+ }
197
+
198
+ /**
199
+ * Returns the SVG string representation of the scene.
200
+ * @deprecated Use `mount` instead
201
+ */
202
+ svg(): string {
203
+ const scene = this.build();
204
+ return this._renderSceneToSvg(scene);
205
+ }
206
+
207
+ /**
208
+ * Mounts the scene to the DOM.
209
+ * @param container The container to mount the scene into
210
+ */
211
+ mount(container: HTMLElement) {
212
+ const scene = this.build();
213
+ this._renderSceneToDOM(scene, container);
214
+ }
215
+
216
+ /**
217
+ * Renders the scene to the DOM.
218
+ * @param scene The scene to render
219
+ * @param container The container to render the scene into
220
+ */
221
+ private _renderSceneToDOM(scene: VizScene, container: HTMLElement) {
222
+ const { viewBox, nodes, edges, overlays } = scene;
223
+ const nodesById = new Map(nodes.map((n) => [n.id, n]));
224
+
225
+ const svgNS = 'http://www.w3.org/2000/svg';
226
+ let svg = container.querySelector('svg') as SVGSVGElement;
227
+
228
+ // Initial Render if SVG doesn't exist
229
+ if (!svg) {
230
+ container.innerHTML = ''; // Safety clear
231
+ svg = document.createElementNS(svgNS, 'svg');
232
+ svg.style.width = '100%';
233
+ svg.style.height = '100%';
234
+ svg.style.overflow = 'visible';
235
+
236
+ // Inject Styles
237
+ const style = document.createElement('style');
238
+ style.textContent = DEFAULT_VIZ_CSS;
239
+ svg.appendChild(style);
240
+
241
+ // Defs
242
+ const defs = document.createElementNS(svgNS, 'defs');
243
+ const marker = document.createElementNS(svgNS, 'marker');
244
+ marker.setAttribute('id', 'viz-arrow');
245
+ marker.setAttribute('markerWidth', '10');
246
+ marker.setAttribute('markerHeight', '7');
247
+ marker.setAttribute('refX', '9');
248
+ marker.setAttribute('refY', '3.5');
249
+ marker.setAttribute('orient', 'auto');
250
+ const poly = document.createElementNS(svgNS, 'polygon');
251
+ poly.setAttribute('points', '0 0, 10 3.5, 0 7');
252
+ poly.setAttribute('fill', 'currentColor');
253
+ marker.appendChild(poly);
254
+ defs.appendChild(marker);
255
+ svg.appendChild(defs);
256
+
257
+ // Layers
258
+ const edgeLayer = document.createElementNS(svgNS, 'g');
259
+ edgeLayer.setAttribute('class', 'viz-layer-edges');
260
+ svg.appendChild(edgeLayer);
261
+
262
+ const nodeLayer = document.createElementNS(svgNS, 'g');
263
+ nodeLayer.setAttribute('class', 'viz-layer-nodes');
264
+ svg.appendChild(nodeLayer);
265
+
266
+ const overlayLayer = document.createElementNS(svgNS, 'g');
267
+ overlayLayer.setAttribute('class', 'viz-layer-overlays');
268
+ svg.appendChild(overlayLayer);
269
+
270
+ container.appendChild(svg);
271
+ }
272
+
273
+ // Update ViewBox
274
+ svg.setAttribute('viewBox', `0 0 ${viewBox.w} ${viewBox.h}`);
275
+
276
+ const edgeLayer = svg.querySelector('.viz-layer-edges')!;
277
+ const nodeLayer = svg.querySelector('.viz-layer-nodes')!;
278
+ const overlayLayer = svg.querySelector('.viz-layer-overlays')!;
279
+
280
+ // --- 1. Reconcile Edges ---
281
+ const existingEdgeGroups = Array.from(edgeLayer.children).filter(
282
+ (el) => el.tagName === 'g'
283
+ ) as SVGGElement[];
284
+ const existingEdgesMap = new Map<string, SVGGElement>();
285
+ existingEdgeGroups.forEach((el) => {
286
+ const id = el.getAttribute('data-id');
287
+ if (id) existingEdgesMap.set(id, el);
288
+ });
289
+
290
+ const processedEdgeIds = new Set<string>();
291
+
292
+ edges.forEach((edge) => {
293
+ const start = nodesById.get(edge.from);
294
+ const end = nodesById.get(edge.to);
295
+ if (!start || !end) return;
296
+
297
+ processedEdgeIds.add(edge.id);
298
+
299
+ let group = existingEdgesMap.get(edge.id);
300
+ if (!group) {
301
+ group = document.createElementNS(svgNS, 'g');
302
+ group.setAttribute('data-id', edge.id);
303
+ edgeLayer.appendChild(group);
304
+
305
+ // Initial creation of children
306
+ const line = document.createElementNS(svgNS, 'line');
307
+ line.setAttribute('class', 'viz-edge');
308
+ group.appendChild(line);
309
+
310
+ // Optional parts created on demand later, but structure expected
311
+ }
312
+
313
+ // Compute Classes & Styles
314
+ let classes = `viz-edge-group ${edge.className || ''}`;
315
+ // Reset styles
316
+ group.removeAttribute('style');
317
+
318
+ if (edge.animations) {
319
+ edge.animations.forEach((spec) => {
320
+ const renderer = defaultCoreAnimationRegistry.getEdgeRenderer(
321
+ spec.id
322
+ );
323
+ if (renderer) {
324
+ if (renderer.getClass)
325
+ classes += ` ${renderer.getClass({ spec, element: edge })}`;
326
+ if (renderer.getStyle) {
327
+ const s = renderer.getStyle({ spec, element: edge });
328
+ Object.entries(s).forEach(([k, v]) => {
329
+ group!.style.setProperty(k, String(v));
330
+ });
331
+ }
332
+ }
333
+ });
334
+ }
335
+ group.setAttribute('class', classes);
336
+
337
+ // Update Line
338
+ const line = group.querySelector('.viz-edge') as SVGLineElement;
339
+ line.setAttribute('x1', String(start.pos.x));
340
+ line.setAttribute('y1', String(start.pos.y));
341
+ line.setAttribute('x2', String(end.pos.x));
342
+ line.setAttribute('y2', String(end.pos.y));
343
+ line.setAttribute('stroke', 'currentColor');
344
+ if (edge.markerEnd === 'arrow') {
345
+ line.setAttribute('marker-end', 'url(#viz-arrow)');
346
+ } else {
347
+ line.removeAttribute('marker-end');
348
+ }
349
+
350
+ const oldHit = group.querySelector('.viz-edge-hit');
351
+ if (oldHit) oldHit.remove();
352
+
353
+ if (edge.hitArea || edge.onClick) {
354
+ const hit = document.createElementNS(svgNS, 'line');
355
+ hit.setAttribute('class', 'viz-edge-hit'); // Add class for selection
356
+ hit.setAttribute('x1', String(start.pos.x));
357
+ hit.setAttribute('y1', String(start.pos.y));
358
+ hit.setAttribute('x2', String(end.pos.x));
359
+ hit.setAttribute('y2', String(end.pos.y));
360
+ hit.setAttribute('stroke', 'transparent');
361
+ hit.setAttribute('stroke-width', String(edge.hitArea || 10));
362
+ hit.style.cursor = edge.onClick ? 'pointer' : '';
363
+ if (edge.onClick) {
364
+ hit.addEventListener('click', (e) => {
365
+ e.stopPropagation();
366
+ edge.onClick!(edge.id, edge);
367
+ });
368
+ }
369
+ group.appendChild(hit);
370
+ }
371
+
372
+ // Label (Recreate vs Update)
373
+ const oldLabel = group.querySelector('.viz-edge-label');
374
+ if (oldLabel) oldLabel.remove();
375
+
376
+ if (edge.label) {
377
+ const text = document.createElementNS(svgNS, 'text');
378
+ const mx = (start.pos.x + end.pos.x) / 2 + (edge.label.dx || 0);
379
+ const my = (start.pos.y + end.pos.y) / 2 + (edge.label.dy || 0);
380
+ text.setAttribute('x', String(mx));
381
+ text.setAttribute('y', String(my));
382
+ text.setAttribute(
383
+ 'class',
384
+ `viz-edge-label ${edge.label.className || ''}`
385
+ );
386
+ text.setAttribute('text-anchor', 'middle');
387
+ text.setAttribute('dominant-baseline', 'middle');
388
+ text.textContent = edge.label.text;
389
+ group.appendChild(text);
390
+ }
391
+ });
392
+
393
+ // Remove stale edges
394
+ existingEdgeGroups.forEach((el) => {
395
+ const id = el.getAttribute('data-id');
396
+ if (id && !processedEdgeIds.has(id)) {
397
+ el.remove();
398
+ }
399
+ });
400
+
401
+ // --- 2. Reconcile Nodes ---
402
+ const existingNodeGroups = Array.from(nodeLayer.children).filter(
403
+ (el) => el.tagName === 'g'
404
+ ) as SVGGElement[];
405
+ const existingNodesMap = new Map<string, SVGGElement>();
406
+ existingNodeGroups.forEach((el) => {
407
+ const id = el.getAttribute('data-id');
408
+ if (id) existingNodesMap.set(id, el);
409
+ });
410
+
411
+ const processedNodeIds = new Set<string>();
412
+
413
+ nodes.forEach((node) => {
414
+ processedNodeIds.add(node.id);
415
+
416
+ let group = existingNodesMap.get(node.id);
417
+
418
+ if (!group) {
419
+ group = document.createElementNS(svgNS, 'g');
420
+ group.setAttribute('data-id', node.id);
421
+ nodeLayer.appendChild(group);
422
+ }
423
+
424
+ // Calculate Anim Classes
425
+ let classes = `viz-node-group ${node.className || ''}`;
426
+ group.removeAttribute('style');
427
+
428
+ if (node.animations) {
429
+ node.animations.forEach((spec) => {
430
+ const renderer = defaultCoreAnimationRegistry.getNodeRenderer(
431
+ spec.id
432
+ );
433
+ if (renderer) {
434
+ if (renderer.getClass)
435
+ classes += ` ${renderer.getClass({ spec, element: node })}`;
436
+ if (renderer.getStyle) {
437
+ const s = renderer.getStyle({ spec, element: node });
438
+ Object.entries(s).forEach(([k, v]) => {
439
+ group!.style.setProperty(k, String(v));
440
+ });
441
+ }
442
+ }
443
+ });
444
+ }
445
+ group.setAttribute('class', classes);
446
+
447
+ // @ts-expect-error: Property _clickHandler does not exist on SVGGElement
448
+ group._clickHandler = node.onClick
449
+ ? (e: MouseEvent) => {
450
+ e.stopPropagation();
451
+ node.onClick!(node.id, node);
452
+ }
453
+ : null;
454
+
455
+ if (!group.hasAttribute('data-click-initialized')) {
456
+ group.addEventListener('click', (e) => {
457
+ // @ts-expect-error: Property _clickHandler does not exist on SVGGElement
458
+ if (group._clickHandler) group._clickHandler(e);
459
+ });
460
+ group.setAttribute('data-click-initialized', 'true');
461
+ }
462
+
463
+ group.style.cursor = node.onClick ? 'pointer' : '';
464
+
465
+ // Shape (Update geometry)
466
+ const { x, y } = node.pos;
467
+
468
+ // Ideally we reuse the shape element if the kind hasn't changed.
469
+ // Assuming kind rarely changes for same ID.
470
+ let shape = group.querySelector('.viz-node-shape') as SVGElement;
471
+
472
+ // If shape doesn't exist or kind changed (simplified check: just recreate if kind mismatch logic needed,
473
+ // but here we just check tag name for simplicity or assume kind is stable).
474
+ const kindMap: Record<string, string> = {
475
+ circle: 'circle',
476
+ rect: 'rect',
477
+ diamond: 'polygon',
478
+ };
479
+ const expectedTag = kindMap[node.shape.kind];
480
+
481
+ if (!shape || shape.tagName !== expectedTag) {
482
+ if (shape) shape.remove();
483
+ if (node.shape.kind === 'circle') {
484
+ shape = document.createElementNS(svgNS, 'circle');
485
+ } else if (node.shape.kind === 'rect') {
486
+ shape = document.createElementNS(svgNS, 'rect');
487
+ } else if (node.shape.kind === 'diamond') {
488
+ shape = document.createElementNS(svgNS, 'polygon');
489
+ }
490
+ shape!.setAttribute('class', 'viz-node-shape');
491
+ group.prepend(shape!); // Shape always at bottom
492
+ }
493
+
494
+ // Update Shape Attributes
495
+ if (node.shape.kind === 'circle') {
496
+ shape!.setAttribute('cx', String(x));
497
+ shape!.setAttribute('cy', String(y));
498
+ shape!.setAttribute('r', String(node.shape.r));
499
+ } else if (node.shape.kind === 'rect') {
500
+ shape!.setAttribute('x', String(x - node.shape.w / 2));
501
+ shape!.setAttribute('y', String(y - node.shape.h / 2));
502
+ shape!.setAttribute('width', String(node.shape.w));
503
+ shape!.setAttribute('height', String(node.shape.h));
504
+ if (node.shape.rx) shape!.setAttribute('rx', String(node.shape.rx));
505
+ } else if (node.shape.kind === 'diamond') {
506
+ const hw = node.shape.w / 2;
507
+ const hh = node.shape.h / 2;
508
+ const pts = `${x},${y - hh} ${x + hw},${y} ${x},${y + hh} ${x - hw},${y}`;
509
+ shape!.setAttribute('points', pts);
510
+ }
511
+
512
+ // Label (Recreate for simplicity as usually just text/pos changes)
513
+ let label = group.querySelector('.viz-node-label') as SVGTextElement;
514
+ if (!label && node.label) {
515
+ label = document.createElementNS(svgNS, 'text');
516
+ label.setAttribute('class', 'viz-node-label');
517
+ label.setAttribute('text-anchor', 'middle');
518
+ label.setAttribute('dominant-baseline', 'middle');
519
+ group.appendChild(label);
520
+ }
521
+
522
+ if (node.label) {
523
+ const lx = x + (node.label.dx || 0);
524
+ const ly = y + (node.label.dy || 0);
525
+ label!.setAttribute('x', String(lx));
526
+ label!.setAttribute('y', String(ly));
527
+
528
+ // Update class carefully to preserve 'viz-node-label'
529
+ label!.setAttribute(
530
+ 'class',
531
+ `viz-node-label ${node.label.className || ''}`
532
+ );
533
+ label!.textContent = node.label.text;
534
+ } else if (label) {
535
+ label.remove();
536
+ }
537
+ });
538
+
539
+ // Remove stale nodes
540
+ existingNodeGroups.forEach((el) => {
541
+ const id = el.getAttribute('data-id');
542
+ if (id && !processedNodeIds.has(id)) {
543
+ el.remove();
544
+ }
545
+ });
546
+
547
+ // --- 3. Reconcile Overlays (Smart) ---
548
+
549
+ // 1. Map existing overlay groups
550
+ const existingOverlayGroups = Array.from(overlayLayer.children).filter(
551
+ (el) => el.tagName === 'g'
552
+ ) as SVGGElement[];
553
+ const existingOverlaysMap = new Map<string, SVGGElement>();
554
+ existingOverlayGroups.forEach((el) => {
555
+ const id = el.getAttribute('data-overlay-id');
556
+ if (id) existingOverlaysMap.set(id, el);
557
+ });
558
+
559
+ const processedOverlayIds = new Set<string>();
560
+
561
+ if (overlays && overlays.length > 0) {
562
+ overlays.forEach((spec) => {
563
+ const renderer = defaultCoreOverlayRegistry.get(spec.id);
564
+ if (renderer) {
565
+ const uniqueKey = spec.key || spec.id;
566
+ processedOverlayIds.add(uniqueKey);
567
+
568
+ let group = existingOverlaysMap.get(uniqueKey);
569
+ if (!group) {
570
+ group = document.createElementNS(svgNS, 'g');
571
+ group.setAttribute('data-overlay-id', uniqueKey);
572
+ group.setAttribute('class', `viz-overlay-${spec.id}`);
573
+ overlayLayer.appendChild(group);
574
+ }
575
+
576
+ const ctx = {
577
+ spec,
578
+ nodesById,
579
+ edgesById: new Map(edges.map((e) => [e.id, e])),
580
+ scene,
581
+ };
582
+
583
+ if (renderer.update) {
584
+ renderer.update(ctx, group);
585
+ } else {
586
+ // Fallback: full re-render of this overlay's content
587
+ group.innerHTML = renderer.render(ctx);
588
+ }
589
+ }
590
+ });
591
+ }
592
+
593
+ // Remove stale overlays
594
+ existingOverlayGroups.forEach((el) => {
595
+ const id = el.getAttribute('data-overlay-id');
596
+ if (id && !processedOverlayIds.has(id)) {
597
+ el.remove();
598
+ }
599
+ });
600
+ }
601
+
602
+ /**
603
+ * Returns the SVG string representation of the scene.
604
+ * @deprecated The use of this method is deprecated. Use `mount` instead.
605
+ * @param scene The scene to render
606
+ * @returns The SVG string representation of the scene
607
+ */
608
+ private _renderSceneToSvg(scene: VizScene): string {
609
+ const { viewBox, nodes, edges, overlays } = scene;
610
+ const nodesById = new Map(nodes.map((n) => [n.id, n]));
611
+ const edgesById = new Map(edges.map((e) => [e.id, e]));
612
+
613
+ let svgContent = `<svg viewBox="0 0 ${viewBox.w} ${viewBox.h}" xmlns="http://www.w3.org/2000/svg">`;
614
+
615
+ // Inject Styles
616
+ svgContent += `<style>${DEFAULT_VIZ_CSS}</style>`;
617
+
618
+ // Defs (Arrow Marker)
619
+ svgContent += `
620
+ <defs>
621
+ <marker id="viz-arrow" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
622
+ <polygon points="0 0, 10 3.5, 0 7" fill="currentColor" />
623
+ </marker>
624
+ </defs>`;
625
+
626
+ // Render Edges
627
+ svgContent += '<g class="viz-layer-edges">';
628
+ edges.forEach((edge) => {
629
+ const start = nodesById.get(edge.from);
630
+ const end = nodesById.get(edge.to);
631
+ if (!start || !end) return;
632
+
633
+ // Animations
634
+ let animClasses = '';
635
+ let animStyleStr = '';
636
+
637
+ if (edge.animations) {
638
+ edge.animations.forEach((spec) => {
639
+ const renderer = defaultCoreAnimationRegistry.getEdgeRenderer(
640
+ spec.id
641
+ );
642
+ if (renderer) {
643
+ if (renderer.getClass) {
644
+ animClasses += ` ${renderer.getClass({ spec, element: edge })}`;
645
+ }
646
+ if (renderer.getStyle) {
647
+ const styles = renderer.getStyle({ spec, element: edge });
648
+ Object.entries(styles).forEach(([k, v]) => {
649
+ animStyleStr += `${k}: ${v}; `;
650
+ });
651
+ }
652
+ }
653
+ });
654
+ }
655
+
656
+ const markerEnd =
657
+ edge.markerEnd === 'arrow' ? 'marker-end="url(#viz-arrow)"' : '';
658
+
659
+ svgContent += `<g class="viz-edge-group ${edge.className || ''} ${animClasses}" style="${animStyleStr}">`;
660
+ svgContent += `<line x1="${start.pos.x}" y1="${start.pos.y}" x2="${end.pos.x}" y2="${end.pos.y}" class="viz-edge" ${markerEnd} stroke="currentColor" />`;
661
+
662
+ // Edge Label
663
+ if (edge.label) {
664
+ const mx = (start.pos.x + end.pos.x) / 2 + (edge.label.dx || 0);
665
+ const my = (start.pos.y + end.pos.y) / 2 + (edge.label.dy || 0);
666
+ const labelClass = `viz-edge-label ${edge.label.className || ''}`;
667
+ svgContent += `<text x="${mx}" y="${my}" class="${labelClass}" text-anchor="middle" dominant-baseline="middle">${edge.label.text}</text>`;
668
+ }
669
+ svgContent += '</g>';
670
+ });
671
+ svgContent += '</g>';
672
+
673
+ // Render Nodes
674
+ svgContent += '<g class="viz-layer-nodes">';
675
+ nodes.forEach((node) => {
676
+ const { x, y } = node.pos;
677
+ const { shape } = node;
678
+
679
+ // Animations (Nodes)
680
+ let animClasses = '';
681
+ let animStyleStr = '';
682
+
683
+ if (node.animations) {
684
+ node.animations.forEach((spec) => {
685
+ const renderer = defaultCoreAnimationRegistry.getNodeRenderer(
686
+ spec.id
687
+ );
688
+ if (renderer) {
689
+ if (renderer.getClass) {
690
+ animClasses += ` ${renderer.getClass({ spec, element: node })}`;
691
+ }
692
+ if (renderer.getStyle) {
693
+ const styles = renderer.getStyle({ spec, element: node });
694
+ Object.entries(styles).forEach(([k, v]) => {
695
+ animStyleStr += `${k}: ${v}; `;
696
+ });
697
+ }
698
+ }
699
+ });
700
+ }
701
+
702
+ const className = `viz-node-group ${node.className || ''} ${animClasses}`;
703
+
704
+ svgContent += `<g class="${className}" style="${animStyleStr}">`;
705
+
706
+ // Shape
707
+ if (shape.kind === 'circle') {
708
+ svgContent += `<circle cx="${x}" cy="${y}" r="${shape.r}" class="viz-node-shape" />`;
709
+ } else if (shape.kind === 'rect') {
710
+ svgContent += `<rect x="${x - shape.w / 2}" y="${y - shape.h / 2}" width="${shape.w}" height="${shape.h}" rx="${shape.rx || 0}" class="viz-node-shape" />`;
711
+ } else if (shape.kind === 'diamond') {
712
+ const hw = shape.w / 2;
713
+ const hh = shape.h / 2;
714
+ const pts = `${x},${y - hh} ${x + hw},${y} ${x},${y + hh} ${x - hw},${y}`;
715
+ svgContent += `<polygon points="${pts}" class="viz-node-shape" />`;
716
+ }
717
+
718
+ // Label
719
+ if (node.label) {
720
+ const lx = x + (node.label.dx || 0);
721
+ const ly = y + (node.label.dy || 0);
722
+ const labelClass = `viz-node-label ${node.label.className || ''}`;
723
+ svgContent += `<text x="${lx}" y="${ly}" class="${labelClass}" text-anchor="middle" dominant-baseline="middle">${node.label.text}</text>`;
724
+ }
725
+
726
+ svgContent += '</g>';
727
+ });
728
+ svgContent += '</g>';
729
+
730
+ // Render Overlays
731
+ if (overlays && overlays.length > 0) {
732
+ svgContent += '<g class="viz-layer-overlays">';
733
+ overlays.forEach((spec) => {
734
+ const renderer = defaultCoreOverlayRegistry.get(spec.id);
735
+ if (renderer) {
736
+ svgContent += renderer.render({ spec, nodesById, edgesById, scene });
737
+ }
738
+ });
739
+ svgContent += '</g>';
740
+ }
741
+
742
+ svgContent += '</svg>';
743
+ return svgContent;
744
+ }
745
+ }
746
+
747
+ class NodeBuilderImpl implements NodeBuilder {
748
+ private parent: VizBuilder;
749
+ private nodeDef: Partial<VizNode>;
750
+
751
+ constructor(parent: VizBuilder, nodeDef: Partial<VizNode>) {
752
+ this.parent = parent;
753
+ this.nodeDef = nodeDef;
754
+ }
755
+
756
+ at(x: number, y: number): NodeBuilder {
757
+ this.nodeDef.pos = { x, y };
758
+ return this;
759
+ }
760
+
761
+ cell(
762
+ col: number,
763
+ row: number,
764
+ align: 'center' | 'start' | 'end' = 'center'
765
+ ): NodeBuilder {
766
+ const grid = this.parent._getGridConfig();
767
+ if (!grid) {
768
+ console.warn(
769
+ 'VizBuilder: .cell() called but no grid configured. Use .grid() first.'
770
+ );
771
+ return this;
772
+ }
773
+
774
+ const view = this.parent._getViewBox();
775
+ const availableW = view.w - grid.padding.x * 2;
776
+ const availableH = view.h - grid.padding.y * 2;
777
+
778
+ const cellW = availableW / grid.cols;
779
+ const cellH = availableH / grid.rows;
780
+
781
+ let x = grid.padding.x + col * cellW;
782
+ let y = grid.padding.y + row * cellH;
783
+
784
+ // Alignment adjustments
785
+ if (align === 'center') {
786
+ x += cellW / 2;
787
+ y += cellH / 2;
788
+ } else if (align === 'end') {
789
+ x += cellW;
790
+ y += cellH;
791
+ }
792
+
793
+ this.nodeDef.pos = { x, y };
794
+ return this;
795
+ }
796
+
797
+ circle(r: number): NodeBuilder {
798
+ this.nodeDef.shape = { kind: 'circle', r };
799
+ return this;
800
+ }
801
+
802
+ rect(w: number, h: number, rx?: number): NodeBuilder {
803
+ this.nodeDef.shape = { kind: 'rect', w, h, rx };
804
+ return this;
805
+ }
806
+
807
+ diamond(w: number, h: number): NodeBuilder {
808
+ this.nodeDef.shape = { kind: 'diamond', w, h };
809
+ return this;
810
+ }
811
+
812
+ label(text: string, opts?: Partial<NodeLabel>): NodeBuilder {
813
+ this.nodeDef.label = { text, ...opts };
814
+ return this;
815
+ }
816
+
817
+ class(name: string): NodeBuilder {
818
+ if (this.nodeDef.className) {
819
+ this.nodeDef.className += ` ${name}`;
820
+ } else {
821
+ this.nodeDef.className = name;
822
+ }
823
+ return this;
824
+ }
825
+
826
+ animate(type: string, config?: AnimationConfig): NodeBuilder {
827
+ if (!this.nodeDef.animations) {
828
+ this.nodeDef.animations = [];
829
+ }
830
+ this.nodeDef.animations.push({ id: type, params: config });
831
+ return this;
832
+ }
833
+
834
+ data(payload: unknown): NodeBuilder {
835
+ this.nodeDef.data = payload;
836
+ return this;
837
+ }
838
+
839
+ onClick(handler: (id: string, node: VizNode) => void): NodeBuilder {
840
+ this.nodeDef.onClick = handler;
841
+ return this;
842
+ }
843
+
844
+ done(): VizBuilder {
845
+ return this.parent;
846
+ }
847
+
848
+ // Chaining
849
+ node(id: string): NodeBuilder {
850
+ return this.parent.node(id);
851
+ }
852
+ edge(from: string, to: string, id?: string): EdgeBuilder {
853
+ return this.parent.edge(from, to, id);
854
+ }
855
+ overlay<T>(id: string, params: T, key?: string): VizBuilder {
856
+ return this.parent.overlay(id, params, key);
857
+ }
858
+ build(): VizScene {
859
+ return this.parent.build();
860
+ }
861
+ svg(): string {
862
+ return this.parent.svg();
863
+ }
864
+ }
865
+
866
+ class EdgeBuilderImpl implements EdgeBuilder {
867
+ private parent: VizBuilder;
868
+ private edgeDef: Partial<VizEdge>;
869
+
870
+ constructor(parent: VizBuilder, edgeDef: Partial<VizEdge>) {
871
+ this.parent = parent;
872
+ this.edgeDef = edgeDef;
873
+ }
874
+
875
+ straight(): EdgeBuilder {
876
+ // No-op for now as it is default
877
+ return this;
878
+ }
879
+
880
+ label(text: string, opts?: Partial<EdgeLabel>): EdgeBuilder {
881
+ this.edgeDef.label = { position: 'mid', text, ...opts };
882
+ return this;
883
+ }
884
+
885
+ arrow(enabled: boolean = true): EdgeBuilder {
886
+ this.edgeDef.markerEnd = enabled ? 'arrow' : 'none';
887
+ return this;
888
+ }
889
+
890
+ class(name: string): EdgeBuilder {
891
+ if (this.edgeDef.className) {
892
+ this.edgeDef.className += ` ${name}`;
893
+ } else {
894
+ this.edgeDef.className = name;
895
+ }
896
+ return this;
897
+ }
898
+
899
+ animate(type: string, config?: AnimationConfig): EdgeBuilder {
900
+ if (!this.edgeDef.animations) {
901
+ this.edgeDef.animations = [];
902
+ }
903
+ this.edgeDef.animations.push({ id: type, params: config });
904
+ return this;
905
+ }
906
+
907
+ hitArea(px: number): EdgeBuilder {
908
+ this.edgeDef.hitArea = px;
909
+ return this;
910
+ }
911
+
912
+ data(payload: unknown): EdgeBuilder {
913
+ this.edgeDef.data = payload;
914
+ return this;
915
+ }
916
+
917
+ onClick(handler: (id: string, edge: VizEdge) => void): EdgeBuilder {
918
+ this.edgeDef.onClick = handler;
919
+ return this;
920
+ }
921
+
922
+ done(): VizBuilder {
923
+ return this.parent;
924
+ }
925
+
926
+ // Chaining
927
+ node(id: string): NodeBuilder {
928
+ return this.parent.node(id);
929
+ }
930
+ edge(from: string, to: string, id?: string): EdgeBuilder {
931
+ return this.parent.edge(from, to, id);
932
+ }
933
+ overlay<T>(id: string, params: T, key?: string): VizBuilder {
934
+ return this.parent.overlay(id, params, key);
935
+ }
936
+ build(): VizScene {
937
+ return this.parent.build();
938
+ }
939
+ svg(): string {
940
+ return this.parent.svg();
941
+ }
942
+ }
943
+
944
+ export function viz(): VizBuilder {
945
+ return new VizBuilderImpl();
946
+ }