vizcraft 1.2.0 → 1.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # vizcraft
2
2
 
3
+ ## 1.3.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [`086ef9e`](https://github.com/ChipiKaf/vizcraft/commit/086ef9e7b5d505cbb03f955e8d24297fb60a6b3e) Thanks [@ChipiKaf](https://github.com/ChipiKaf)! - The fix prevents commit() from using a stale cached runtimePatchCtx (which could reference detached DOM elements) by always recreating it after \_renderSceneToDOM, and removes the redundant strokeDasharray write from patchRuntime so that base style is owned by a single write path.
8
+
9
+ ## 1.3.0
10
+
11
+ ### Minor Changes
12
+
13
+ - [`3f55212`](https://github.com/ChipiKaf/vizcraft/commit/3f55212e56557994710d65a99fe339c6826cb2a7) Thanks [@ChipiKaf](https://github.com/ChipiKaf)! - Add new visual styling options for nodes and edges: a hand-drawn "sketch" rendering mode, configurable drop-shadow support for nodes, and node stroke dasharray (dashed strokes)
14
+
3
15
  ## 1.2.0
4
16
 
5
17
  ### Minor Changes
package/README.md CHANGED
@@ -196,6 +196,16 @@ b.node('n1')
196
196
  .image(href, w, h, opts?) // Embed an <image> inside the node
197
197
  .icon(id, opts?) // Embed an icon from the icon registry (see registerIcon)
198
198
  .svgContent(svg, opts) // Embed inline SVG content inside the node
199
+ .fill('#f0f0f0') // Fill color
200
+ .stroke('#333', 2) // Stroke color and optional width
201
+ .opacity(0.8) // Opacity
202
+ .dashed() // Dashed border (8, 4)
203
+ .dotted() // Dotted border (2, 4)
204
+ .dash('12, 3, 3, 3') // Custom dash pattern
205
+ .shadow() // Drop shadow (default: dx=2 dy=2 blur=4)
206
+ .shadow({ dx: 4, dy: 4, blur: 10, color: 'rgba(0,0,0,0.35)' }) // Custom shadow
207
+ .sketch() // Sketch / hand-drawn look (SVG turbulence filter)
208
+ .sketch({ seed: 42 }) // Sketch with explicit seed for deterministic jitter
199
209
  .class('css-class') // Custom CSS class
200
210
  .data({ ... }) // Attach custom data
201
211
  .port('out', { x: 50, y: 0 }) // Named connection port
@@ -262,6 +272,9 @@ b.edge('a', 'b').dashed().stroke('#6c7086'); // dashed line
262
272
  b.edge('a', 'b').dotted(); // dotted line
263
273
  b.edge('a', 'b').dash('12, 3, 3, 3').stroke('#cba6f7'); // custom pattern
264
274
 
275
+ // Sketch / hand-drawn edges
276
+ b.edge('a', 'b').sketch(); // sketchy look
277
+
265
278
  // Multi-position edge labels (start / mid / end)
266
279
  b.edge('a', 'b')
267
280
  .label('1', { position: 'start' })
package/dist/builder.d.ts CHANGED
@@ -37,6 +37,8 @@ export interface VizBuilder extends VizSceneMutator {
37
37
  x: number;
38
38
  y: number;
39
39
  }): VizBuilder;
40
+ /** Enable global sketch / hand-drawn rendering for all nodes and edges. */
41
+ sketch(enabled?: boolean, seed?: number): VizBuilder;
40
42
  /**
41
43
  * Fluent, data-only animation authoring. Compiles immediately to an `AnimationSpec`.
42
44
  * The compiled spec is also stored on the built scene as `scene.animationSpecs`.
@@ -209,6 +211,27 @@ interface NodeBuilder {
209
211
  fill(color: string): NodeBuilder;
210
212
  stroke(color: string, width?: number): NodeBuilder;
211
213
  opacity(value: number): NodeBuilder;
214
+ /** Apply a dashed stroke pattern (`8, 4`). */
215
+ dashed(): NodeBuilder;
216
+ /** Apply a dotted stroke pattern (`2, 4`). */
217
+ dotted(): NodeBuilder;
218
+ /** Apply a custom SVG `stroke-dasharray` value, or a preset name (`'dashed'`, `'dotted'`, `'dash-dot'`). */
219
+ dash(pattern: 'solid' | 'dashed' | 'dotted' | 'dash-dot' | string): NodeBuilder;
220
+ /**
221
+ * Add a drop shadow behind the node shape.
222
+ *
223
+ * Call with no arguments for a sensible default, or pass a config object.
224
+ */
225
+ shadow(config?: {
226
+ dx?: number;
227
+ dy?: number;
228
+ blur?: number;
229
+ color?: string;
230
+ }): NodeBuilder;
231
+ /** Render this node with a hand-drawn / sketchy appearance. */
232
+ sketch(config?: {
233
+ seed?: number;
234
+ }): NodeBuilder;
212
235
  class(name: string): NodeBuilder;
213
236
  zIndex(value: number): NodeBuilder;
214
237
  animate(type: string, config?: AnimationConfig): NodeBuilder;
@@ -283,6 +306,8 @@ interface EdgeBuilder {
283
306
  dotted(): EdgeBuilder;
284
307
  /** Apply a custom SVG `stroke-dasharray` value, or a preset name (`'dashed'`, `'dotted'`, `'dash-dot'`). */
285
308
  dash(pattern: 'solid' | 'dashed' | 'dotted' | 'dash-dot' | string): EdgeBuilder;
309
+ /** Render this edge with a hand-drawn / sketchy appearance. */
310
+ sketch(): EdgeBuilder;
286
311
  class(name: string): EdgeBuilder;
287
312
  hitArea(px: number): EdgeBuilder;
288
313
  animate(type: string, config?: AnimationConfig): EdgeBuilder;
package/dist/builder.js CHANGED
@@ -37,6 +37,74 @@ function markerIdFor(markerType, stroke, position = 'end') {
37
37
  function arrowMarkerIdFor(stroke) {
38
38
  return markerIdFor('arrow', stroke);
39
39
  }
40
+ const SHADOW_DEFAULTS = {
41
+ dx: 2,
42
+ dy: 2,
43
+ blur: 4,
44
+ color: 'rgba(0,0,0,0.2)',
45
+ };
46
+ /** Apply defaults to a partial shadow config. */
47
+ function resolveShadow(shadow) {
48
+ return {
49
+ dx: shadow.dx ?? SHADOW_DEFAULTS.dx,
50
+ dy: shadow.dy ?? SHADOW_DEFAULTS.dy,
51
+ blur: shadow.blur ?? SHADOW_DEFAULTS.blur,
52
+ color: shadow.color ?? SHADOW_DEFAULTS.color,
53
+ };
54
+ }
55
+ /** Deterministic filter id keyed by config tuple for dedup. */
56
+ function shadowFilterId(cfg) {
57
+ const colorSuffix = cfg.color.replace(/[^a-zA-Z0-9]/g, '_');
58
+ return `viz-shadow-${cfg.dx}-${cfg.dy}-${cfg.blur}-${colorSuffix}`;
59
+ }
60
+ /** SVG markup for a drop-shadow `<filter>`. */
61
+ function shadowFilterSvg(id, cfg) {
62
+ return `<filter id="${id}" x="-50%" y="-50%" width="200%" height="200%"><feDropShadow dx="${cfg.dx}" dy="${cfg.dy}" stdDeviation="${cfg.blur}" flood-color="${escapeXmlAttr(cfg.color)}" flood-opacity="1"/></filter>`;
63
+ }
64
+ /** Deterministic filter id for sketch displacement keyed by seed. */
65
+ function sketchFilterId(seed) {
66
+ return `viz-sketch-${seed}`;
67
+ }
68
+ /** Simple seeded float in [0, 1) derived from a seed via xorshift-like mix. */
69
+ function sketchRand(seed, salt) {
70
+ let s = ((seed ^ (salt * 2654435761)) >>> 0) | 1;
71
+ s ^= s << 13;
72
+ s ^= s >>> 17;
73
+ s ^= s << 5;
74
+ return (s >>> 0) / 4294967296;
75
+ }
76
+ /** Lerp a value between min and max using a seeded random. */
77
+ function sketchLerp(seed, salt, min, max) {
78
+ return min + sketchRand(seed, salt) * (max - min);
79
+ }
80
+ /** SVG markup for a sketch `<filter>` using dual-pass displacement for a hand-drawn double-stroke look. */
81
+ function sketchFilterSvg(id, seed) {
82
+ const s2 = seed + 37;
83
+ // Derive unique per-seed parameters
84
+ const freq2 = sketchLerp(seed, 1, 0.009, 0.015).toFixed(4);
85
+ const scale1 = sketchLerp(seed, 2, 2.5, 4).toFixed(1);
86
+ const scale2 = sketchLerp(seed, 3, 3, 5).toFixed(1);
87
+ const dx = sketchLerp(seed, 4, 0.3, 1.6).toFixed(2);
88
+ const dy = sketchLerp(seed, 5, 0.2, 1.3).toFixed(2);
89
+ return (`<filter id="${id}" filterUnits="userSpaceOnUse" x="-10000" y="-10000" width="20000" height="20000">` +
90
+ `<feTurbulence type="fractalNoise" baseFrequency="0.008" numOctaves="2" seed="${seed}" result="n1"/>` +
91
+ `<feTurbulence type="fractalNoise" baseFrequency="${freq2}" numOctaves="2" seed="${s2}" result="n2"/>` +
92
+ `<feDisplacementMap in="SourceGraphic" in2="n1" scale="${scale1}" xChannelSelector="R" yChannelSelector="G" result="s1"/>` +
93
+ `<feDisplacementMap in="SourceGraphic" in2="n2" scale="${scale2}" xChannelSelector="G" yChannelSelector="R" result="s2"/>` +
94
+ `<feOffset in="s2" dx="${dx}" dy="${dy}" result="s2off"/>` +
95
+ '<feComposite in="s1" in2="s2off" operator="over"/>' +
96
+ '</filter>');
97
+ }
98
+ /** Resolve the effective sketch seed for a node, falling back to the hash of its id. */
99
+ function resolveSketchSeed(nodeStyle, id) {
100
+ if (nodeStyle?.sketchSeed !== undefined)
101
+ return nodeStyle.sketchSeed;
102
+ let h = 0;
103
+ for (let i = 0; i < id.length; i++) {
104
+ h = (Math.imul(31, h) + id.charCodeAt(i)) | 0;
105
+ }
106
+ return Math.abs(h);
107
+ }
40
108
  /**
41
109
  * Generate SVG markup for a single marker definition.
42
110
  * @param markerType The type of marker
@@ -251,6 +319,14 @@ function applyNodeOptions(nb, opts) {
251
319
  }
252
320
  if (opts.opacity !== undefined)
253
321
  nb.opacity(opts.opacity);
322
+ if (opts.dash)
323
+ nb.dash(opts.dash);
324
+ if (opts.shadow !== undefined && opts.shadow !== false) {
325
+ nb.shadow(opts.shadow === true ? {} : opts.shadow);
326
+ }
327
+ if (opts.sketch !== undefined && opts.sketch !== false) {
328
+ nb.sketch(opts.sketch === true ? {} : opts.sketch);
329
+ }
254
330
  if (opts.className)
255
331
  nb.class(opts.className);
256
332
  if (opts.zIndex !== undefined)
@@ -339,6 +415,8 @@ function applyEdgeOptions(eb, opts) {
339
415
  eb.opacity(opts.opacity);
340
416
  if (opts.dash)
341
417
  eb.dash(opts.dash);
418
+ if (opts.sketch)
419
+ eb.sketch();
342
420
  if (opts.className)
343
421
  eb.class(opts.className);
344
422
  // Anchor & ports
@@ -422,6 +500,7 @@ class VizBuilderImpl {
422
500
  _nodeOrder = [];
423
501
  _edgeOrder = [];
424
502
  _gridConfig = null;
503
+ _sketch = null;
425
504
  _animationSpecs = [];
426
505
  _mountedContainer = null;
427
506
  _panZoomController;
@@ -575,14 +654,13 @@ class VizBuilderImpl {
575
654
  // The reconciliation correctly re-uses existing SVG elements, inserts new ones, and deletes missing ones.
576
655
  const scene = this.build();
577
656
  this._renderSceneToDOM(scene, container);
578
- // Apply runtime overrides (if any)
579
- let ctx = runtimePatchCtxBySvg.get(svg);
580
- if (!ctx) {
581
- ctx = createRuntimePatchCtx(svg, {
582
- edgePathResolver: this._edgePathResolver,
583
- });
584
- runtimePatchCtxBySvg.set(svg, ctx);
585
- }
657
+ // Apply runtime overrides (if any).
658
+ // Always recreate the context after _renderSceneToDOM so patchRuntime
659
+ // never references stale / detached elements (fixes #81).
660
+ const ctx = createRuntimePatchCtx(svg, {
661
+ edgePathResolver: this._edgePathResolver,
662
+ });
663
+ runtimePatchCtxBySvg.set(svg, ctx);
586
664
  patchRuntime(scene, ctx);
587
665
  }
588
666
  /**
@@ -656,6 +734,10 @@ class VizBuilderImpl {
656
734
  this._gridConfig = { cols, rows, padding };
657
735
  return this;
658
736
  }
737
+ sketch(enabled = true, seed) {
738
+ this._sketch = enabled ? { enabled: true, seed } : null;
739
+ return this;
740
+ }
659
741
  overlay(arg1, arg2, arg3) {
660
742
  if (typeof arg1 === 'function') {
661
743
  const overlay = new OverlayBuilder();
@@ -740,6 +822,9 @@ class VizBuilderImpl {
740
822
  if (scene.animationSpecs) {
741
823
  this._animationSpecs = [...scene.animationSpecs];
742
824
  }
825
+ this._sketch = scene.sketch
826
+ ? { ...scene.sketch, enabled: scene.sketch.enabled ?? true }
827
+ : null;
743
828
  return this;
744
829
  }
745
830
  /**
@@ -764,6 +849,7 @@ class VizBuilderImpl {
764
849
  edges,
765
850
  overlays: this._overlays,
766
851
  animationSpecs: this._animationSpecs.length > 0 ? [...this._animationSpecs] : undefined,
852
+ sketch: this._sketch ?? undefined,
767
853
  };
768
854
  this._dispatchEvent('build', { scene });
769
855
  return scene;
@@ -1100,6 +1186,49 @@ class VizBuilderImpl {
1100
1186
  }
1101
1187
  defs.appendChild(markerEl);
1102
1188
  });
1189
+ const neededShadows = new Map();
1190
+ nodes.forEach((n) => {
1191
+ if (n.style?.shadow) {
1192
+ const cfg = resolveShadow(n.style.shadow);
1193
+ const fid = shadowFilterId(cfg);
1194
+ if (!neededShadows.has(fid)) {
1195
+ neededShadows.set(fid, cfg);
1196
+ }
1197
+ }
1198
+ });
1199
+ neededShadows.forEach((cfg, fid) => {
1200
+ const tmp = document.createElementNS(svgNS, 'svg');
1201
+ tmp.innerHTML = shadowFilterSvg(fid, cfg);
1202
+ const filterEl = tmp.querySelector('filter');
1203
+ if (filterEl) {
1204
+ defs.appendChild(filterEl);
1205
+ }
1206
+ });
1207
+ const neededSketchSeeds = new Set();
1208
+ const globalSketch = scene.sketch?.enabled;
1209
+ nodes.forEach((n) => {
1210
+ if (n.style?.sketch || globalSketch) {
1211
+ neededSketchSeeds.add(resolveSketchSeed(n.style, n.id));
1212
+ }
1213
+ });
1214
+ edges.forEach((e) => {
1215
+ if (e.style?.sketch || globalSketch) {
1216
+ let h = 0;
1217
+ for (let i = 0; i < e.id.length; i++) {
1218
+ h = (Math.imul(31, h) + e.id.charCodeAt(i)) | 0;
1219
+ }
1220
+ neededSketchSeeds.add(Math.abs(h));
1221
+ }
1222
+ });
1223
+ neededSketchSeeds.forEach((seed) => {
1224
+ const fid = sketchFilterId(seed);
1225
+ const tmp = document.createElementNS(svgNS, 'svg');
1226
+ tmp.innerHTML = sketchFilterSvg(fid, seed);
1227
+ const filterEl = tmp.querySelector('filter');
1228
+ if (filterEl) {
1229
+ defs.appendChild(filterEl);
1230
+ }
1231
+ });
1103
1232
  svg.appendChild(defs);
1104
1233
  // Layers
1105
1234
  const viewport = document.createElementNS(svgNS, 'g');
@@ -1162,6 +1291,9 @@ class VizBuilderImpl {
1162
1291
  }
1163
1292
  // Compute Classes & Styles
1164
1293
  let classes = `viz-edge-group ${edge.className || ''}`;
1294
+ const edgeSketched = edge.style?.sketch || scene.sketch?.enabled;
1295
+ if (edgeSketched)
1296
+ classes += ' viz-sketch';
1165
1297
  // Reset styles
1166
1298
  group.removeAttribute('style');
1167
1299
  if (edge.animations) {
@@ -1281,6 +1413,17 @@ class VizBuilderImpl {
1281
1413
  else {
1282
1414
  line.style.removeProperty('stroke-dasharray');
1283
1415
  }
1416
+ if (edgeSketched) {
1417
+ let h = 0;
1418
+ for (let i = 0; i < edge.id.length; i++) {
1419
+ h = (Math.imul(31, h) + edge.id.charCodeAt(i)) | 0;
1420
+ }
1421
+ const seed = Math.abs(h);
1422
+ line.setAttribute('filter', `url(#${sketchFilterId(seed)})`);
1423
+ }
1424
+ else {
1425
+ line.removeAttribute('filter');
1426
+ }
1284
1427
  const oldHit = group.querySelector('[data-viz-role="edge-hit"]') ||
1285
1428
  group.querySelector('.viz-edge-hit');
1286
1429
  if (oldHit)
@@ -1311,6 +1454,10 @@ class VizBuilderImpl {
1311
1454
  const labelClass = `viz-edge-label ${lbl.className || ''}`;
1312
1455
  const edgeLabelSvg = renderSvgText(pos.x, pos.y, lbl.rich ?? lbl.text, {
1313
1456
  className: labelClass,
1457
+ fill: lbl.fill,
1458
+ fontSize: lbl.fontSize,
1459
+ fontWeight: lbl.fontWeight,
1460
+ fontFamily: lbl.fontFamily,
1314
1461
  textAnchor: 'middle',
1315
1462
  dominantBaseline: 'middle',
1316
1463
  maxWidth: lbl.maxWidth,
@@ -1370,6 +1517,9 @@ class VizBuilderImpl {
1370
1517
  const isContainer = !!node.container;
1371
1518
  // Calculate Anim Classes
1372
1519
  let classes = `viz-node-group${isContainer ? ' viz-container' : ''} ${node.className || ''}`;
1520
+ const nodeSketched = node.style?.sketch || scene.sketch?.enabled;
1521
+ if (nodeSketched)
1522
+ classes += ' viz-sketch';
1373
1523
  group.removeAttribute('style');
1374
1524
  if (node.animations) {
1375
1525
  node.animations.forEach((spec) => {
@@ -1426,12 +1576,25 @@ class VizBuilderImpl {
1426
1576
  group.prepend(shape);
1427
1577
  }
1428
1578
  applyShapeGeometry(shape, node.shape, { x, y });
1579
+ const resolvedNodeDash = resolveDasharray(node.style?.strokeDasharray);
1580
+ const nodeShadowFilter = node.style?.shadow
1581
+ ? `url(#${shadowFilterId(resolveShadow(node.style.shadow))})`
1582
+ : undefined;
1429
1583
  setSvgAttributes(shape, {
1430
1584
  fill: node.style?.fill ?? 'none',
1431
1585
  stroke: node.style?.stroke ?? '#111',
1432
1586
  'stroke-width': node.style?.strokeWidth ?? 2,
1433
1587
  opacity: node.runtime?.opacity ?? node.style?.opacity,
1588
+ 'stroke-dasharray': resolvedNodeDash || undefined,
1589
+ filter: nodeShadowFilter,
1434
1590
  });
1591
+ if (nodeSketched) {
1592
+ const seed = resolveSketchSeed(node.style, node.id);
1593
+ group.setAttribute('filter', `url(#${sketchFilterId(seed)})`);
1594
+ }
1595
+ else {
1596
+ group.removeAttribute('filter');
1597
+ }
1435
1598
  // Embedded media (rendered alongside the base shape)
1436
1599
  const existingImg = group.querySelector(':scope > [data-viz-role="node-image"], :scope > .viz-node-image');
1437
1600
  if (existingImg)
@@ -1558,6 +1721,7 @@ class VizBuilderImpl {
1558
1721
  fill: node.label.fill,
1559
1722
  fontSize: node.label.fontSize,
1560
1723
  fontWeight: node.label.fontWeight,
1724
+ fontFamily: node.label.fontFamily,
1561
1725
  textAnchor: node.label.textAnchor || 'middle',
1562
1726
  dominantBaseline: node.label.dominantBaseline || 'middle',
1563
1727
  maxWidth: node.label.maxWidth,
@@ -1744,6 +1908,38 @@ class VizBuilderImpl {
1744
1908
  }
1745
1909
  }
1746
1910
  });
1911
+ const exportShadows = new Map();
1912
+ exportNodes.forEach((n) => {
1913
+ if (n.style?.shadow) {
1914
+ const cfg = resolveShadow(n.style.shadow);
1915
+ const fid = shadowFilterId(cfg);
1916
+ if (!exportShadows.has(fid)) {
1917
+ exportShadows.set(fid, cfg);
1918
+ }
1919
+ }
1920
+ });
1921
+ exportShadows.forEach((cfg, fid) => {
1922
+ svgContent += shadowFilterSvg(fid, cfg);
1923
+ });
1924
+ const exportSketchSeeds = new Set();
1925
+ const globalSketchExport = exportScene.sketch?.enabled;
1926
+ exportNodes.forEach((n) => {
1927
+ if (n.style?.sketch || globalSketchExport) {
1928
+ exportSketchSeeds.add(resolveSketchSeed(n.style, n.id));
1929
+ }
1930
+ });
1931
+ exportEdges.forEach((e) => {
1932
+ if (e.style?.sketch || globalSketchExport) {
1933
+ let h = 0;
1934
+ for (let i = 0; i < e.id.length; i++) {
1935
+ h = (Math.imul(31, h) + e.id.charCodeAt(i)) | 0;
1936
+ }
1937
+ exportSketchSeeds.add(Math.abs(h));
1938
+ }
1939
+ });
1940
+ exportSketchSeeds.forEach((seed) => {
1941
+ svgContent += sketchFilterSvg(sketchFilterId(seed), seed);
1942
+ });
1747
1943
  svgContent += `
1748
1944
  </defs>`;
1749
1945
  // Render Edges
@@ -1825,7 +2021,9 @@ class VizBuilderImpl {
1825
2021
  lineRuntimeStyle += `stroke-dashoffset: ${edge.runtime.strokeDashoffset}; `;
1826
2022
  lineRuntimeAttrs += ` stroke-dashoffset="${edge.runtime.strokeDashoffset}"`;
1827
2023
  }
1828
- svgContent += `<g data-id="${edge.id}" data-viz-role="edge-group" class="viz-edge-group ${edge.className || ''} ${animClasses}" style="${animStyleStr}${runtimeStyle}">`;
2024
+ const edgeSketched = edge.style?.sketch || globalSketchExport;
2025
+ const sketchClass = edgeSketched ? ' viz-sketch' : '';
2026
+ svgContent += `<g data-id="${edge.id}" data-viz-role="edge-group" class="viz-edge-group${sketchClass} ${edge.className || ''} ${animClasses}" style="${animStyleStr}${runtimeStyle}">`;
1829
2027
  let edgeInlineStyle = lineRuntimeStyle;
1830
2028
  if (edge.style?.stroke !== undefined)
1831
2029
  edgeInlineStyle += `stroke: ${edge.style.stroke}; `;
@@ -1841,7 +2039,15 @@ class VizBuilderImpl {
1841
2039
  if (resolved)
1842
2040
  edgeInlineStyle += `stroke-dasharray: ${resolved}; `;
1843
2041
  }
1844
- svgContent += `<path d="${edgePath.d}" class="viz-edge" data-viz-role="edge-line" ${markerEnd} ${markerStart} style="${edgeInlineStyle}"${lineRuntimeAttrs} />`;
2042
+ let edgeSketchFilterAttr = '';
2043
+ if (edgeSketched) {
2044
+ let h = 0;
2045
+ for (let i = 0; i < edge.id.length; i++) {
2046
+ h = (Math.imul(31, h) + edge.id.charCodeAt(i)) | 0;
2047
+ }
2048
+ edgeSketchFilterAttr = ` filter="url(#${sketchFilterId(Math.abs(h))})"`;
2049
+ }
2050
+ svgContent += `<path d="${edgePath.d}" class="viz-edge" data-viz-role="edge-line" ${markerEnd} ${markerStart} style="${edgeInlineStyle}"${lineRuntimeAttrs}${edgeSketchFilterAttr} />`;
1845
2051
  // Edge Labels (multi-position)
1846
2052
  const allLabels = collectEdgeLabels(edge);
1847
2053
  allLabels.forEach((lbl, idx) => {
@@ -1849,6 +2055,10 @@ class VizBuilderImpl {
1849
2055
  const labelClass = `viz-edge-label ${lbl.className || ''}`;
1850
2056
  const edgeLabelSvg = renderSvgText(pos.x, pos.y, lbl.rich ?? lbl.text, {
1851
2057
  className: labelClass,
2058
+ fill: lbl.fill,
2059
+ fontSize: lbl.fontSize,
2060
+ fontWeight: lbl.fontWeight,
2061
+ fontFamily: lbl.fontFamily,
1852
2062
  textAnchor: 'middle',
1853
2063
  dominantBaseline: 'middle',
1854
2064
  maxWidth: lbl.maxWidth,
@@ -1913,18 +2123,30 @@ class VizBuilderImpl {
1913
2123
  });
1914
2124
  }
1915
2125
  const isContainer = !!node.container;
1916
- const className = `viz-node-group${isContainer ? ' viz-container' : ''} ${node.className || ''} ${animClasses}`;
2126
+ const nodeSketched = node.style?.sketch || globalSketchExport;
2127
+ const className = `viz-node-group${isContainer ? ' viz-container' : ''}${nodeSketched ? ' viz-sketch' : ''} ${node.className || ''} ${animClasses}`;
1917
2128
  const scale = node.runtime?.scale;
1918
2129
  const rotation = node.runtime?.rotation;
2130
+ let groupFilterAttr = '';
2131
+ if (nodeSketched) {
2132
+ const seed = resolveSketchSeed(node.style, node.id);
2133
+ groupFilterAttr = ` filter="url(#${sketchFilterId(seed)})"`;
2134
+ }
1919
2135
  const transformAttr = scale !== undefined || rotation !== undefined
1920
2136
  ? ` transform="translate(${x} ${y}) rotate(${rotation ?? 0}) scale(${scale ?? 1}) translate(${-x} ${-y})"`
1921
2137
  : '';
1922
- content += `<g data-id="${node.id}" data-viz-role="node-group" class="${className}" style="${animStyleStr}"${transformAttr}>`;
2138
+ content += `<g data-id="${node.id}" data-viz-role="node-group" class="${className}" style="${animStyleStr}"${transformAttr}${groupFilterAttr}>`;
2139
+ const resolvedExportNodeDash = resolveDasharray(node.style?.strokeDasharray);
2140
+ const exportShadowFilter = node.style?.shadow
2141
+ ? `url(#${shadowFilterId(resolveShadow(node.style.shadow))})`
2142
+ : undefined;
1923
2143
  const shapeStyleAttrs = svgAttributeString({
1924
2144
  fill: node.style?.fill ?? 'none',
1925
2145
  stroke: node.style?.stroke ?? '#111',
1926
2146
  'stroke-width': node.style?.strokeWidth ?? 2,
1927
2147
  opacity: node.runtime?.opacity !== undefined ? undefined : node.style?.opacity,
2148
+ 'stroke-dasharray': resolvedExportNodeDash || undefined,
2149
+ filter: exportShadowFilter,
1928
2150
  });
1929
2151
  // Shape
1930
2152
  content += shapeSvgMarkup(shape, { x, y }, shapeStyleAttrs);
@@ -1997,6 +2219,7 @@ class VizBuilderImpl {
1997
2219
  fill: node.label.fill,
1998
2220
  fontSize: node.label.fontSize,
1999
2221
  fontWeight: node.label.fontWeight,
2222
+ fontFamily: node.label.fontFamily,
2000
2223
  textAnchor: node.label.textAnchor || 'middle',
2001
2224
  dominantBaseline: node.label.dominantBaseline || 'middle',
2002
2225
  maxWidth: node.label.maxWidth,
@@ -2297,6 +2520,42 @@ class NodeBuilderImpl {
2297
2520
  };
2298
2521
  return this;
2299
2522
  }
2523
+ dashed() {
2524
+ this.nodeDef.style = {
2525
+ ...(this.nodeDef.style || {}),
2526
+ strokeDasharray: 'dashed',
2527
+ };
2528
+ return this;
2529
+ }
2530
+ dotted() {
2531
+ this.nodeDef.style = {
2532
+ ...(this.nodeDef.style || {}),
2533
+ strokeDasharray: 'dotted',
2534
+ };
2535
+ return this;
2536
+ }
2537
+ dash(pattern) {
2538
+ this.nodeDef.style = {
2539
+ ...(this.nodeDef.style || {}),
2540
+ strokeDasharray: pattern,
2541
+ };
2542
+ return this;
2543
+ }
2544
+ shadow(config) {
2545
+ this.nodeDef.style = {
2546
+ ...(this.nodeDef.style || {}),
2547
+ shadow: config ?? {},
2548
+ };
2549
+ return this;
2550
+ }
2551
+ sketch(config) {
2552
+ this.nodeDef.style = {
2553
+ ...(this.nodeDef.style || {}),
2554
+ sketch: true,
2555
+ sketchSeed: config?.seed,
2556
+ };
2557
+ return this;
2558
+ }
2300
2559
  class(name) {
2301
2560
  if (this.nodeDef.className) {
2302
2561
  this.nodeDef.className += ` ${name}`;
@@ -2522,6 +2781,13 @@ class EdgeBuilderImpl {
2522
2781
  };
2523
2782
  return this;
2524
2783
  }
2784
+ sketch() {
2785
+ this.edgeDef.style = {
2786
+ ...(this.edgeDef.style || {}),
2787
+ sketch: true,
2788
+ };
2789
+ return this;
2790
+ }
2525
2791
  class(name) {
2526
2792
  if (this.edgeDef.className) {
2527
2793
  this.edgeDef.className += ` ${name}`;
@@ -3,6 +3,146 @@ import { computeEdgePath, computeEdgeEndpoints, computeSelfLoop, } from './edgeP
3
3
  import { resolveEdgeLabelPosition, collectEdgeLabels } from './edgeLabels';
4
4
  import { resolveDasharray } from './edgeStyles';
5
5
  const svgNS = 'http://www.w3.org/2000/svg';
6
+ const SHADOW_DEFAULTS = {
7
+ dx: 2,
8
+ dy: 2,
9
+ blur: 4,
10
+ color: 'rgba(0,0,0,0.2)',
11
+ };
12
+ function resolveShadow(shadow) {
13
+ return {
14
+ dx: shadow.dx ?? SHADOW_DEFAULTS.dx,
15
+ dy: shadow.dy ?? SHADOW_DEFAULTS.dy,
16
+ blur: shadow.blur ?? SHADOW_DEFAULTS.blur,
17
+ color: shadow.color ?? SHADOW_DEFAULTS.color,
18
+ };
19
+ }
20
+ function shadowFilterId(cfg) {
21
+ const colorSuffix = cfg.color.replace(/[^a-zA-Z0-9]/g, '_');
22
+ return `viz-shadow-${cfg.dx}-${cfg.dy}-${cfg.blur}-${colorSuffix}`;
23
+ }
24
+ /** Lazily ensure a shadow `<filter>` definition exists in `<defs>`. */
25
+ function ensureShadowFilter(svg, shadow) {
26
+ const cfg = resolveShadow(shadow);
27
+ const fid = shadowFilterId(cfg);
28
+ if (!svg.querySelector(`#${CSS.escape(fid)}`)) {
29
+ const defs = svg.querySelector('defs');
30
+ if (defs) {
31
+ const filter = document.createElementNS(svgNS, 'filter');
32
+ filter.setAttribute('id', fid);
33
+ filter.setAttribute('x', '-50%');
34
+ filter.setAttribute('y', '-50%');
35
+ filter.setAttribute('width', '200%');
36
+ filter.setAttribute('height', '200%');
37
+ const drop = document.createElementNS(svgNS, 'feDropShadow');
38
+ drop.setAttribute('dx', String(cfg.dx));
39
+ drop.setAttribute('dy', String(cfg.dy));
40
+ drop.setAttribute('stdDeviation', String(cfg.blur));
41
+ drop.setAttribute('flood-color', cfg.color);
42
+ drop.setAttribute('flood-opacity', '1');
43
+ filter.appendChild(drop);
44
+ defs.appendChild(filter);
45
+ }
46
+ }
47
+ return fid;
48
+ }
49
+ function sketchFilterId(seed) {
50
+ return `viz-sketch-${seed}`;
51
+ }
52
+ /** Simple seeded float in [0, 1) derived from a seed via xorshift-like mix. */
53
+ function sketchRand(seed, salt) {
54
+ let s = ((seed ^ (salt * 2654435761)) >>> 0) | 1;
55
+ s ^= s << 13;
56
+ s ^= s >>> 17;
57
+ s ^= s << 5;
58
+ return (s >>> 0) / 4294967296;
59
+ }
60
+ /** Lerp a value between min and max using a seeded random. */
61
+ function sketchLerp(seed, salt, min, max) {
62
+ return min + sketchRand(seed, salt) * (max - min);
63
+ }
64
+ /** Lazily ensure a sketch `<filter>` definition exists in `<defs>`. */
65
+ function ensureSketchFilter(svg, seed) {
66
+ const fid = sketchFilterId(seed);
67
+ if (!svg.querySelector(`#${CSS.escape(fid)}`)) {
68
+ const defs = svg.querySelector('defs');
69
+ if (defs) {
70
+ const filter = document.createElementNS(svgNS, 'filter');
71
+ filter.setAttribute('id', fid);
72
+ filter.setAttribute('filterUnits', 'userSpaceOnUse');
73
+ filter.setAttribute('x', '-10000');
74
+ filter.setAttribute('y', '-10000');
75
+ filter.setAttribute('width', '20000');
76
+ filter.setAttribute('height', '20000');
77
+ const s2 = seed + 37;
78
+ // Derive unique per-seed parameters
79
+ const freq2 = sketchLerp(seed, 1, 0.009, 0.015).toFixed(4);
80
+ const scale1 = sketchLerp(seed, 2, 2.5, 4).toFixed(1);
81
+ const scale2 = sketchLerp(seed, 3, 3, 5).toFixed(1);
82
+ const dx = sketchLerp(seed, 4, 0.3, 1.6).toFixed(2);
83
+ const dy = sketchLerp(seed, 5, 0.2, 1.3).toFixed(2);
84
+ // First noise
85
+ const turb1 = document.createElementNS(svgNS, 'feTurbulence');
86
+ turb1.setAttribute('type', 'fractalNoise');
87
+ turb1.setAttribute('baseFrequency', '0.008');
88
+ turb1.setAttribute('numOctaves', '2');
89
+ turb1.setAttribute('seed', String(seed));
90
+ turb1.setAttribute('result', 'n1');
91
+ filter.appendChild(turb1);
92
+ // Second noise (different seed + frequency)
93
+ const turb2 = document.createElementNS(svgNS, 'feTurbulence');
94
+ turb2.setAttribute('type', 'fractalNoise');
95
+ turb2.setAttribute('baseFrequency', freq2);
96
+ turb2.setAttribute('numOctaves', '2');
97
+ turb2.setAttribute('seed', String(s2));
98
+ turb2.setAttribute('result', 'n2');
99
+ filter.appendChild(turb2);
100
+ // First stroke pass
101
+ const disp1 = document.createElementNS(svgNS, 'feDisplacementMap');
102
+ disp1.setAttribute('in', 'SourceGraphic');
103
+ disp1.setAttribute('in2', 'n1');
104
+ disp1.setAttribute('scale', scale1);
105
+ disp1.setAttribute('xChannelSelector', 'R');
106
+ disp1.setAttribute('yChannelSelector', 'G');
107
+ disp1.setAttribute('result', 's1');
108
+ filter.appendChild(disp1);
109
+ // Second stroke pass (different channels)
110
+ const disp2 = document.createElementNS(svgNS, 'feDisplacementMap');
111
+ disp2.setAttribute('in', 'SourceGraphic');
112
+ disp2.setAttribute('in2', 'n2');
113
+ disp2.setAttribute('scale', scale2);
114
+ disp2.setAttribute('xChannelSelector', 'G');
115
+ disp2.setAttribute('yChannelSelector', 'R');
116
+ disp2.setAttribute('result', 's2');
117
+ filter.appendChild(disp2);
118
+ // Offset second pass — variable gap
119
+ const offset = document.createElementNS(svgNS, 'feOffset');
120
+ offset.setAttribute('in', 's2');
121
+ offset.setAttribute('dx', dx);
122
+ offset.setAttribute('dy', dy);
123
+ offset.setAttribute('result', 's2off');
124
+ filter.appendChild(offset);
125
+ // Merge both passes
126
+ const comp = document.createElementNS(svgNS, 'feComposite');
127
+ comp.setAttribute('in', 's1');
128
+ comp.setAttribute('in2', 's2off');
129
+ comp.setAttribute('operator', 'over');
130
+ filter.appendChild(comp);
131
+ defs.appendChild(filter);
132
+ }
133
+ }
134
+ return fid;
135
+ }
136
+ /** Resolve the effective sketch seed for a node, falling back to the hash of its id. */
137
+ function resolveSketchSeed(nodeStyle, id) {
138
+ if (nodeStyle?.sketchSeed !== undefined)
139
+ return nodeStyle.sketchSeed;
140
+ let h = 0;
141
+ for (let i = 0; i < id.length; i++) {
142
+ h = (Math.imul(31, h) + id.charCodeAt(i)) | 0;
143
+ }
144
+ return Math.abs(h);
145
+ }
6
146
  /** Sanitise a CSS color for use as a marker ID suffix. */
7
147
  function colorToMarkerSuffix(color) {
8
148
  return color.replace(/[^a-zA-Z0-9]/g, '_');
@@ -371,6 +511,35 @@ export function patchRuntime(scene, ctx) {
371
511
  shape.removeAttribute('opacity');
372
512
  }
373
513
  }
514
+ // NOTE: strokeDasharray is a static base style — written exclusively by
515
+ // _renderSceneToDOM via setSvgAttributes. patchRuntime must NOT duplicate
516
+ // that write to avoid the stale-context overwrite described in #81.
517
+ if (node.style?.shadow) {
518
+ const fid = ensureShadowFilter(ctx.svg, node.style.shadow);
519
+ shape.setAttribute('filter', `url(#${fid})`);
520
+ }
521
+ else {
522
+ shape.removeAttribute('filter');
523
+ }
524
+ const nodeSketched = node.style?.sketch || scene.sketch?.enabled;
525
+ if (nodeSketched) {
526
+ const seed = resolveSketchSeed(node.style, node.id);
527
+ const fid = ensureSketchFilter(ctx.svg, seed);
528
+ group.setAttribute('filter', `url(#${fid})`);
529
+ if (!group.classList.contains('viz-sketch')) {
530
+ group.classList.add('viz-sketch');
531
+ }
532
+ }
533
+ else {
534
+ if (group.classList.contains('viz-sketch')) {
535
+ group.classList.remove('viz-sketch');
536
+ }
537
+ // Only remove group filter if it was a sketch filter
538
+ const cur = group.getAttribute('filter');
539
+ if (cur && cur.startsWith('url(#viz-sketch-')) {
540
+ group.removeAttribute('filter');
541
+ }
542
+ }
374
543
  // Transform conflict rule: runtime wins if it writes transform.
375
544
  const scale = node.runtime?.scale;
376
545
  const rotation = node.runtime?.rotation;
@@ -502,6 +671,28 @@ export function patchRuntime(scene, ctx) {
502
671
  line.style.removeProperty('stroke-dashoffset');
503
672
  line.removeAttribute('stroke-dashoffset');
504
673
  }
674
+ const edgeSketched = edge.style?.sketch || scene.sketch?.enabled;
675
+ if (edgeSketched) {
676
+ let h = 0;
677
+ for (let i = 0; i < edge.id.length; i++) {
678
+ h = (Math.imul(31, h) + edge.id.charCodeAt(i)) | 0;
679
+ }
680
+ const seed = Math.abs(h);
681
+ const fid = ensureSketchFilter(ctx.svg, seed);
682
+ line.setAttribute('filter', `url(#${fid})`);
683
+ if (!group.classList.contains('viz-sketch')) {
684
+ group.classList.add('viz-sketch');
685
+ }
686
+ }
687
+ else {
688
+ const cur = line.getAttribute('filter');
689
+ if (cur && cur.startsWith('url(#viz-sketch-')) {
690
+ line.removeAttribute('filter');
691
+ }
692
+ if (group.classList.contains('viz-sketch')) {
693
+ group.classList.remove('viz-sketch');
694
+ }
695
+ }
505
696
  }
506
697
  // Ensure DOM order matches zIndex order for node layer children
507
698
  // We use insertBefore to minimize DOM thrashing in the animation loop
package/dist/styles.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const DEFAULT_VIZ_CSS = "\n.viz-canvas {\n width: 100%;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n}\n\n.viz-node-label,\n.viz-edge-label {\n text-anchor: middle;\n dominant-baseline: middle;\n alignment-baseline: middle;\n transform: translateY(0);\n}\n\n.viz-canvas svg {\n width: 100%;\n height: 100%;\n overflow: visible;\n}\n\n/* Keyframes */\n@keyframes vizFlow {\n from {\n stroke-dashoffset: 20;\n }\n to {\n stroke-dashoffset: 0;\n }\n}\n\n/* Animation Classes */\n\n/* Flow Animation (Dashed line moving) */\n.viz-anim-flow .viz-edge {\n stroke-dasharray: 5, 5;\n animation: vizFlow var(--viz-anim-duration, 2s) linear infinite;\n}\n\n/* Edge base styling (path elements need explicit fill:none) */\n.viz-edge {\n fill: none;\n stroke: currentColor;\n}\n\n.viz-edge-hit {\n fill: none;\n}\n\n/* Node Transition */\n.viz-node-group {\n transition: transform 0.3s ease-out, opacity 0.3s ease-out;\n}\n\n/* Overlay Classes */\n.viz-grid-label {\n fill: #6B7280;\n font-size: 14px;\n font-weight: 600;\n opacity: 1;\n}\n\n.viz-signal {\n fill: #3B82F6;\n cursor: pointer;\n pointer-events: all; \n transition: transform 0.2s ease-out, fill 0.2s ease-out;\n}\n\n.viz-signal .viz-signal-shape {\n fill: inherit;\n}\n\n.viz-signal:hover {\n fill: #60A5FA;\n transform: scale(1.5);\n}\n\n.viz-data-point {\n fill: #F59E0B;\n transition: cx 0.3s ease-out, cy 0.3s ease-out;\n}\n\n/* Connection ports (hidden by default, shown on node hover) */\n.viz-port {\n fill: #3B82F6;\n stroke: white;\n stroke-width: 1.5;\n opacity: 0;\n pointer-events: all;\n cursor: crosshair;\n transition: opacity 0.15s ease-out;\n}\n.viz-node-group:hover .viz-port {\n opacity: 1;\n}\n";
1
+ export declare const DEFAULT_VIZ_CSS = "\n.viz-canvas {\n width: 100%;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n}\n\n.viz-node-label,\n.viz-edge-label {\n text-anchor: middle;\n dominant-baseline: middle;\n alignment-baseline: middle;\n transform: translateY(0);\n}\n\n.viz-canvas svg {\n width: 100%;\n height: 100%;\n overflow: visible;\n}\n\n/* Keyframes */\n@keyframes vizFlow {\n from {\n stroke-dashoffset: 20;\n }\n to {\n stroke-dashoffset: 0;\n }\n}\n\n/* Animation Classes */\n\n/* Flow Animation (Dashed line moving) */\n.viz-anim-flow .viz-edge {\n stroke-dasharray: 5, 5;\n animation: vizFlow var(--viz-anim-duration, 2s) linear infinite;\n}\n\n/* Edge base styling (path elements need explicit fill:none) */\n.viz-edge {\n fill: none;\n stroke: currentColor;\n}\n\n.viz-edge-hit {\n fill: none;\n}\n\n/* Node Transition */\n.viz-node-group {\n transition: transform 0.3s ease-out, opacity 0.3s ease-out;\n}\n\n/* Overlay Classes */\n.viz-grid-label {\n fill: #6B7280;\n font-size: 14px;\n font-weight: 600;\n opacity: 1;\n}\n\n.viz-signal {\n fill: #3B82F6;\n cursor: pointer;\n pointer-events: all; \n transition: transform 0.2s ease-out, fill 0.2s ease-out;\n}\n\n.viz-signal .viz-signal-shape {\n fill: inherit;\n}\n\n.viz-signal:hover {\n fill: #60A5FA;\n transform: scale(1.5);\n}\n\n.viz-data-point {\n fill: #F59E0B;\n transition: cx 0.3s ease-out, cy 0.3s ease-out;\n}\n\n/* Connection ports (hidden by default, shown on node hover) */\n.viz-port {\n fill: #3B82F6;\n stroke: white;\n stroke-width: 1.5;\n opacity: 0;\n pointer-events: all;\n cursor: crosshair;\n transition: opacity 0.15s ease-out;\n}\n.viz-node-group:hover .viz-port {\n opacity: 1;\n}\n\n/* Sketch / hand-drawn rendering */\n.viz-sketch {\n stroke-linecap: round;\n stroke-linejoin: round;\n stroke-width: 2;\n}\n";
package/dist/styles.js CHANGED
@@ -96,4 +96,11 @@ export const DEFAULT_VIZ_CSS = `
96
96
  .viz-node-group:hover .viz-port {
97
97
  opacity: 1;
98
98
  }
99
+
100
+ /* Sketch / hand-drawn rendering */
101
+ .viz-sketch {
102
+ stroke-linecap: round;
103
+ stroke-linejoin: round;
104
+ stroke-width: 2;
105
+ }
99
106
  `;
@@ -19,6 +19,7 @@ export interface RenderTextOptions {
19
19
  fill?: string;
20
20
  fontSize?: number | string;
21
21
  fontWeight?: number | string;
22
+ fontFamily?: string;
22
23
  textAnchor?: 'start' | 'middle' | 'end';
23
24
  dominantBaseline?: string;
24
25
  maxWidth?: number;
package/dist/textUtils.js CHANGED
@@ -63,7 +63,7 @@ export const DEFAULT_LINE_HEIGHT = 1.2;
63
63
  * @param options layout and styling options
64
64
  */
65
65
  export function renderSvgText(x, y, text, options = {}) {
66
- const { className = '', fill, fontSize, fontWeight, textAnchor = 'middle', dominantBaseline = 'middle', maxWidth, lineHeight = DEFAULT_LINE_HEIGHT, verticalAlign = 'middle', overflow, } = options;
66
+ const { className = '', fill, fontSize, fontWeight, fontFamily, textAnchor = 'middle', dominantBaseline = 'middle', maxWidth, lineHeight = DEFAULT_LINE_HEIGHT, verticalAlign = 'middle', overflow, } = options;
67
67
  // Resolve numeric font size for wrapping
68
68
  let numericFontSize = 12; // fallback
69
69
  if (typeof fontSize === 'number') {
@@ -106,6 +106,8 @@ export function renderSvgText(x, y, text, options = {}) {
106
106
  attrs.push(`font-size="${fontSize}"`);
107
107
  if (fontWeight !== undefined)
108
108
  attrs.push(`font-weight="${fontWeight}"`);
109
+ if (fontFamily !== undefined)
110
+ attrs.push(`font-family="${escapeXmlAttr(fontFamily)}"`);
109
111
  attrs.push(`text-anchor="${textAnchor}"`);
110
112
  // Only apply dominant-baseline if it evaluates to a single line safely,
111
113
  // or apply to the group. Usually best on the <text> element.
package/dist/types.d.ts CHANGED
@@ -188,6 +188,7 @@ export type NodeLabel = {
188
188
  fill?: string;
189
189
  fontSize?: number | string;
190
190
  fontWeight?: number | string;
191
+ fontFamily?: string;
191
192
  textAnchor?: 'start' | 'middle' | 'end';
192
193
  dominantBaseline?: string;
193
194
  /** Maximum width for text wrapping (in px). If set, text wraps within this width. */
@@ -313,6 +314,31 @@ export interface VizNode {
313
314
  stroke?: string;
314
315
  strokeWidth?: number;
315
316
  opacity?: number;
317
+ /**
318
+ * SVG `stroke-dasharray` value.
319
+ * Use the presets `'dashed'` (`8,4`), `'dotted'` (`2,4`), `'dash-dot'` (`8,4,2,4`),
320
+ * or pass any valid SVG dasharray string (e.g. `'12, 3, 3, 3'`).
321
+ * `'solid'` (or omitting the property) renders a continuous stroke.
322
+ */
323
+ strokeDasharray?: 'solid' | 'dashed' | 'dotted' | 'dash-dot' | string;
324
+ /**
325
+ * Drop shadow rendered behind the node shape via an SVG `<filter>`.
326
+ *
327
+ * - `dx` — horizontal offset (default `2`)
328
+ * - `dy` — vertical offset (default `2`)
329
+ * - `blur` — Gaussian blur radius / stdDeviation (default `4`)
330
+ * - `color` — shadow color (default `'rgba(0,0,0,0.2)'`)
331
+ */
332
+ shadow?: {
333
+ dx?: number;
334
+ dy?: number;
335
+ blur?: number;
336
+ color?: string;
337
+ };
338
+ /** Render the node with a hand-drawn / sketchy appearance. */
339
+ sketch?: boolean;
340
+ /** Seed for deterministic sketch jitter (same seed → same wobble). */
341
+ sketchSeed?: number;
316
342
  };
317
343
  className?: string;
318
344
  data?: unknown;
@@ -344,6 +370,10 @@ export interface EdgeLabel {
344
370
  className?: string;
345
371
  dx?: number;
346
372
  dy?: number;
373
+ fill?: string;
374
+ fontSize?: number | string;
375
+ fontWeight?: number | string;
376
+ fontFamily?: string;
347
377
  /** Maximum width for text wrapping (in px). If set, text wraps within this width. */
348
378
  maxWidth?: number;
349
379
  /** Line height multiplier (default: 1.2) */
@@ -407,6 +437,8 @@ export interface VizEdge {
407
437
  * `'solid'` (or omitting the property) renders a continuous stroke.
408
438
  */
409
439
  strokeDasharray?: 'solid' | 'dashed' | 'dotted' | 'dash-dot' | string;
440
+ /** Render the edge with a hand-drawn / sketchy appearance. */
441
+ sketch?: boolean;
410
442
  };
411
443
  className?: string;
412
444
  hitArea?: number;
@@ -561,6 +593,25 @@ export interface NodeOptions {
561
593
  width?: number;
562
594
  };
563
595
  opacity?: number;
596
+ /** Dash pattern preset or custom SVG dasharray string. */
597
+ dash?: 'solid' | 'dashed' | 'dotted' | 'dash-dot' | string;
598
+ /**
599
+ * Drop shadow behind the node shape.
600
+ * `true` for default shadow, or a config object `{ dx, dy, blur, color }`.
601
+ */
602
+ shadow?: boolean | {
603
+ dx?: number;
604
+ dy?: number;
605
+ blur?: number;
606
+ color?: string;
607
+ };
608
+ /**
609
+ * Hand-drawn / sketchy rendering for this node.
610
+ * `true` uses a default seed; pass `{ seed }` for deterministic jitter.
611
+ */
612
+ sketch?: boolean | {
613
+ seed?: number;
614
+ };
564
615
  /** Explicit render order. Higher values render on top. Default: 0. */
565
616
  zIndex?: number;
566
617
  className?: string;
@@ -608,6 +659,8 @@ export interface EdgeOptions {
608
659
  opacity?: number;
609
660
  /** Dash pattern preset or custom SVG dasharray string. */
610
661
  dash?: 'solid' | 'dashed' | 'dotted' | 'dash-dot' | string;
662
+ /** Hand-drawn / sketchy rendering for this edge. */
663
+ sketch?: boolean;
611
664
  className?: string;
612
665
  anchor?: 'center' | 'boundary';
613
666
  fromPort?: string;
@@ -697,6 +750,11 @@ export type VizScene = {
697
750
  * Generated by the fluent AnimationBuilder API.
698
751
  */
699
752
  animationSpecs?: AnimationSpec[];
753
+ /** Global sketch / hand-drawn rendering mode. Applies to all nodes and edges. */
754
+ sketch?: {
755
+ enabled?: boolean;
756
+ seed?: number;
757
+ };
700
758
  };
701
759
  export interface PanZoomOptions {
702
760
  /** Enable pan & zoom (default: false) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vizcraft",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "A fluent, type-safe SVG scene builder for composing nodes, edges, animations, and overlays with incremental DOM updates and no framework dependency.",
5
5
  "keywords": [
6
6
  "visualization",