vizcraft 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # vizcraft
2
2
 
3
+ ## 1.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#86](https://github.com/ChipiKaf/vizcraft/pull/86) [`04df81d`](https://github.com/ChipiKaf/vizcraft/commit/04df81dfa0e2f6f4a54232f28382d285de3030c6) Thanks [@ChipiKaf](https://github.com/ChipiKaf)! - Support dangling edges with free endpoints (source-only or target-only) for interactive diagrams. Added `danglingEdge()` builder method, `fromAt()`/`toAt()` on `EdgeBuilder`, and made `VizEdge.from`/`VizEdge.to` optional. Dangling edges work with all edge features including routing, markers, labels, styling, hit testing, SVG export, and DOM mounting.
8
+
9
+ - [#88](https://github.com/ChipiKaf/vizcraft/pull/88) [`28390fc`](https://github.com/ChipiKaf/vizcraft/commit/28390fc49d614dbb5c5f3c6a576b52a527a2b4dc) Thanks [@ChipiKaf](https://github.com/ChipiKaf)! - - Add freeform perimeter anchors for edges (`fromAngle` / `toAngle`). Edges can now leave or arrive at a fixed angle on any node shape, overriding the default boundary projection. Supported via fluent `.fromAngle(deg)` / `.toAngle(deg)` methods and declarative `EdgeOptions`. Also exports `computeNodeAnchorAtAngle(node, angleDeg)` for advanced use.
10
+ - Support dangling edges with free endpoints (source-only or target-only) for interactive diagrams. Added `danglingEdge()` builder method, `fromAt()`/`toAt()` on `EdgeBuilder`, and made `VizEdge.from`/`VizEdge.to` optional. Dangling edges work with all edge features including routing, markers, labels, styling, hit testing, SVG export, and DOM mounting.
11
+
12
+ ## 1.3.1
13
+
14
+ ### Patch Changes
15
+
16
+ - [`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.
17
+
3
18
  ## 1.3.0
4
19
 
5
20
  ### Minor Changes
package/README.md CHANGED
@@ -20,6 +20,7 @@ VizCraft is designed to make creating beautiful, animated node-link diagrams and
20
20
  - **Two Animation Systems**: Lightweight registry/CSS animations (e.g. edge `flow`) and data-only timeline animations (`AnimationSpec`).
21
21
  - **Framework Agnostic**: The core logic is pure TypeScript and can be used with any framework or Vanilla JS.
22
22
  - **Custom Overlays**: Create complex, custom UI elements that float on top of your visualization.
23
+ - **Dangling Edges**: Create edges with free endpoints for drag-to-connect interactions.
23
24
 
24
25
  ## 📦 Installation
25
26
 
@@ -307,6 +308,12 @@ b.edge('srv', 'db').fromPort('out-1').toPort('in').arrow();
307
308
 
308
309
  // Default ports (no .port() needed) — every shape has built-in ports
309
310
  b.edge('a', 'b').fromPort('right').toPort('left').arrow();
311
+
312
+ // Dangling edges — one or both endpoints at a free coordinate
313
+ b.danglingEdge('preview').from('srv').toAt({ x: 300, y: 200 }).arrow().dashed();
314
+
315
+ // Declarative dangling edge
316
+ b.danglingEdge('e1', { from: 'srv', toAt: { x: 300, y: 200 }, arrow: true });
310
317
  ```
311
318
 
312
319
  | Method | Description |
@@ -323,6 +330,12 @@ b.edge('a', 'b').fromPort('right').toPort('left').arrow();
323
330
  | `.markerStart(type)` | Set marker type at the source end. See `EdgeMarkerType`. |
324
331
  | `.fromPort(portId)` | Connect from a specific named port on the source node. |
325
332
  | `.toPort(portId)` | Connect to a specific named port on the target node. |
333
+ | `.fromAngle(deg)` | Set a fixed perimeter angle (degrees, 0 = right, 90 = down) on the source node. |
334
+ | `.toAngle(deg)` | Set a fixed perimeter angle (degrees, 0 = right, 90 = down) on the target node. |
335
+ | `.from(nodeId)` | Attach the source end to an existing node (useful with `danglingEdge()`). |
336
+ | `.to(nodeId)` | Attach the target end to an existing node (useful with `danglingEdge()`). |
337
+ | `.fromAt(pos)` | Set the free-endpoint coordinate for the source end (`{ x, y }`). |
338
+ | `.toAt(pos)` | Set the free-endpoint coordinate for the target end (`{ x, y }`). |
326
339
  | `.stroke(color, width?)` | Set stroke color and optional width. |
327
340
  | `.fill(color)` | Set fill color. |
328
341
  | `.opacity(value)` | Set opacity (0–1). |
package/dist/builder.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { VizScene, VizNode, VizEdge, NodeLabel, EdgeLabel, RichText, RichTextToken, AnimationConfig, OverlayId, OverlayParams, VizGridConfig, ContainerConfig, EdgeRouting, EdgeMarkerType, EdgePathResolver, NodeOptions, EdgeOptions, PanZoomOptions, PanZoomController, VizSceneMutator, VizPlugin, VizEventMap, LayoutAlgorithm, SvgExportOptions } from './types';
1
+ import type { Vec2, VizScene, VizNode, VizEdge, NodeLabel, EdgeLabel, RichText, RichTextToken, AnimationConfig, OverlayId, OverlayParams, VizGridConfig, ContainerConfig, EdgeRouting, EdgeMarkerType, EdgePathResolver, NodeOptions, EdgeOptions, PanZoomOptions, PanZoomController, VizSceneMutator, VizPlugin, VizEventMap, LayoutAlgorithm, SvgExportOptions } from './types';
2
2
  import { OverlayBuilder } from './overlayBuilder';
3
3
  import type { AnimationSpec } from './anim/spec';
4
4
  import { type AnimationBuilder, type AnimatableProps, type TweenOptions } from './anim/animationBuilder';
@@ -56,6 +56,13 @@ export interface VizBuilder extends VizSceneMutator {
56
56
  edge(from: string, to: string, id?: string): EdgeBuilder;
57
57
  /** Create a fully-configured edge declaratively and return the parent VizBuilder. */
58
58
  edge(from: string, to: string, opts: EdgeOptions): VizBuilder;
59
+ /**
60
+ * Create a dangling edge with at least one free endpoint.
61
+ * The free end renders at a canvas coordinate (`fromAt`/`toAt`) rather than a node.
62
+ */
63
+ danglingEdge(id: string, opts?: EdgeOptions): EdgeBuilder;
64
+ /** Declarative overload — returns the parent VizBuilder. */
65
+ danglingEdge(id: string, opts: EdgeOptions): VizBuilder;
59
66
  /** Hydrates the builder from an existing VizScene. */
60
67
  fromScene(scene: VizScene): VizBuilder;
61
68
  build(): VizScene;
@@ -257,6 +264,8 @@ interface NodeBuilder {
257
264
  node(id: string, opts: NodeOptions): VizBuilder;
258
265
  edge(from: string, to: string, id?: string): EdgeBuilder;
259
266
  edge(from: string, to: string, opts: EdgeOptions): VizBuilder;
267
+ danglingEdge(id: string, opts?: EdgeOptions): EdgeBuilder;
268
+ danglingEdge(id: string, opts: EdgeOptions): VizBuilder;
260
269
  overlay(cb: (overlay: OverlayBuilder) => unknown): VizBuilder;
261
270
  overlay<K extends OverlayId>(id: K, params: OverlayParams<K>, key?: string): VizBuilder;
262
271
  overlay<T>(id: string, params: T, key?: string): VizBuilder;
@@ -269,6 +278,14 @@ interface EdgeBuilder {
269
278
  orthogonal(): EdgeBuilder;
270
279
  routing(mode: EdgeRouting): EdgeBuilder;
271
280
  via(x: number, y: number): EdgeBuilder;
281
+ /** Set the source node id (useful with `danglingEdge()`). */
282
+ from(nodeId: string): EdgeBuilder;
283
+ /** Set the target node id (useful with `danglingEdge()`). */
284
+ to(nodeId: string): EdgeBuilder;
285
+ /** Set the free-endpoint source position (when `from` is omitted). */
286
+ fromAt(pos: Vec2): EdgeBuilder;
287
+ /** Set the free-endpoint target position (when `to` is omitted). */
288
+ toAt(pos: Vec2): EdgeBuilder;
272
289
  label(text: string, opts?: Partial<EdgeLabel>): EdgeBuilder;
273
290
  /**
274
291
  * Create a rich text label (mixed formatting) using nested SVG <tspan> spans.
@@ -293,6 +310,10 @@ interface EdgeBuilder {
293
310
  fromPort(portId: string): EdgeBuilder;
294
311
  /** Connect the edge to a specific port on the target node. */
295
312
  toPort(portId: string): EdgeBuilder;
313
+ /** Set a fixed perimeter angle (degrees, 0 = right, 90 = down) on the source node. */
314
+ fromAngle(deg: number): EdgeBuilder;
315
+ /** Set a fixed perimeter angle (degrees, 0 = right, 90 = down) on the target node. */
316
+ toAngle(deg: number): EdgeBuilder;
296
317
  connect(anchor: 'center' | 'boundary'): EdgeBuilder;
297
318
  /** Sets the fill color of the edge path. */
298
319
  fill(color: string): EdgeBuilder;
@@ -333,6 +354,8 @@ interface EdgeBuilder {
333
354
  node(id: string, opts: NodeOptions): VizBuilder;
334
355
  edge(from: string, to: string, id?: string): EdgeBuilder;
335
356
  edge(from: string, to: string, opts: EdgeOptions): VizBuilder;
357
+ danglingEdge(id: string, opts?: EdgeOptions): EdgeBuilder;
358
+ danglingEdge(id: string, opts: EdgeOptions): VizBuilder;
336
359
  overlay(cb: (overlay: OverlayBuilder) => unknown): VizBuilder;
337
360
  overlay<K extends OverlayId>(id: K, params: OverlayParams<K>, key?: string): VizBuilder;
338
361
  overlay<T>(id: string, params: T, key?: string): VizBuilder;
package/dist/builder.js CHANGED
@@ -43,6 +43,21 @@ const SHADOW_DEFAULTS = {
43
43
  blur: 4,
44
44
  color: 'rgba(0,0,0,0.2)',
45
45
  };
46
+ /**
47
+ * Resolves edge endpoints for dangling edges.
48
+ * Returns `null` when a referenced node id doesn't exist.
49
+ */
50
+ function resolveDanglingEdge(edge, nodesById) {
51
+ const start = edge.from ? (nodesById.get(edge.from) ?? null) : null;
52
+ const end = edge.to ? (nodesById.get(edge.to) ?? null) : null;
53
+ if (edge.from && !start)
54
+ return null;
55
+ if (edge.to && !end)
56
+ return null;
57
+ if (!start && !edge.fromAt && !end && !edge.toAt)
58
+ return null;
59
+ return { start, end };
60
+ }
46
61
  /** Apply defaults to a partial shadow config. */
47
62
  function resolveShadow(shadow) {
48
63
  return {
@@ -382,8 +397,16 @@ function applyNodeOptions(nb, opts) {
382
397
  if (opts.parent)
383
398
  nb.parent(opts.parent);
384
399
  }
385
- /** Apply an `EdgeOptions` object to an `EdgeBuilder` (sugar over chaining). */
400
+ /** Applies an `EdgeOptions` object to an `EdgeBuilder`. */
386
401
  function applyEdgeOptions(eb, opts) {
402
+ if (opts.from)
403
+ eb.from(opts.from);
404
+ if (opts.to)
405
+ eb.to(opts.to);
406
+ if (opts.fromAt)
407
+ eb.fromAt(opts.fromAt);
408
+ if (opts.toAt)
409
+ eb.toAt(opts.toAt);
387
410
  // Routing
388
411
  if (opts.routing)
389
412
  eb.routing(opts.routing);
@@ -426,6 +449,10 @@ function applyEdgeOptions(eb, opts) {
426
449
  eb.fromPort(opts.fromPort);
427
450
  if (opts.toPort)
428
451
  eb.toPort(opts.toPort);
452
+ if (opts.fromAngle !== undefined)
453
+ eb.fromAngle(opts.fromAngle);
454
+ if (opts.toAngle !== undefined)
455
+ eb.toAngle(opts.toAngle);
429
456
  // Labels
430
457
  if (opts.label) {
431
458
  if (typeof opts.label === 'string') {
@@ -654,14 +681,13 @@ class VizBuilderImpl {
654
681
  // The reconciliation correctly re-uses existing SVG elements, inserts new ones, and deletes missing ones.
655
682
  const scene = this.build();
656
683
  this._renderSceneToDOM(scene, container);
657
- // Apply runtime overrides (if any)
658
- let ctx = runtimePatchCtxBySvg.get(svg);
659
- if (!ctx) {
660
- ctx = createRuntimePatchCtx(svg, {
661
- edgePathResolver: this._edgePathResolver,
662
- });
663
- runtimePatchCtxBySvg.set(svg, ctx);
664
- }
684
+ // Apply runtime overrides (if any).
685
+ // Always recreate the context after _renderSceneToDOM so patchRuntime
686
+ // never references stale / detached elements (fixes #81).
687
+ const ctx = createRuntimePatchCtx(svg, {
688
+ edgePathResolver: this._edgePathResolver,
689
+ });
690
+ runtimePatchCtxBySvg.set(svg, ctx);
665
691
  patchRuntime(scene, ctx);
666
692
  }
667
693
  /**
@@ -787,6 +813,22 @@ class VizBuilderImpl {
787
813
  applyEdgeOptions(eb, idOrOpts);
788
814
  return this;
789
815
  }
816
+ danglingEdge(id, opts) {
817
+ if (!this._edges.has(id)) {
818
+ const edgeDef = { id };
819
+ if (opts?.fromAt)
820
+ edgeDef.fromAt = opts.fromAt;
821
+ if (opts?.toAt)
822
+ edgeDef.toAt = opts.toAt;
823
+ this._edges.set(id, edgeDef);
824
+ this._edgeOrder.push(id);
825
+ }
826
+ const eb = new EdgeBuilderImpl(this, this._edges.get(id));
827
+ if (!opts)
828
+ return eb;
829
+ applyEdgeOptions(eb, opts);
830
+ return this;
831
+ }
790
832
  /**
791
833
  * Hydrates the builder from an existing VizScene.
792
834
  * @param scene The scene to hydrate from
@@ -834,10 +876,12 @@ class VizBuilderImpl {
834
876
  */
835
877
  build() {
836
878
  this._edges.forEach((edge) => {
837
- if (!this._nodes.has(edge.from)) {
879
+ // Only warn about missing nodes when a node id is specified
880
+ // (dangling edges intentionally omit from/to).
881
+ if (edge.from && !this._nodes.has(edge.from)) {
838
882
  console.warn(`VizBuilder: Edge ${edge.id} references missing source node ${edge.from}`);
839
883
  }
840
- if (!this._nodes.has(edge.to)) {
884
+ if (edge.to && !this._nodes.has(edge.to)) {
841
885
  console.warn(`VizBuilder: Edge ${edge.id} references missing target node ${edge.to}`);
842
886
  }
843
887
  });
@@ -1272,10 +1316,10 @@ class VizBuilderImpl {
1272
1316
  });
1273
1317
  const processedEdgeIds = new Set();
1274
1318
  edges.forEach((edge) => {
1275
- const start = nodesById.get(edge.from);
1276
- const end = nodesById.get(edge.to);
1277
- if (!start || !end)
1319
+ const resolved = resolveDanglingEdge(edge, nodesById);
1320
+ if (!resolved)
1278
1321
  return;
1322
+ const { start, end } = resolved;
1279
1323
  processedEdgeIds.add(edge.id);
1280
1324
  let group = existingEdgesMap.get(edge.id);
1281
1325
  if (!group) {
@@ -1323,7 +1367,7 @@ class VizBuilderImpl {
1323
1367
  group.setAttribute('class', classes);
1324
1368
  // Use effective positions (handles runtime overrides internally via helper)
1325
1369
  let edgePath;
1326
- if (start === end) {
1370
+ if (start && end && start === end) {
1327
1371
  edgePath = computeSelfLoop(start, edge);
1328
1372
  }
1329
1373
  else {
@@ -1333,13 +1377,12 @@ class VizBuilderImpl {
1333
1377
  // Allow consumer override of the SVG path `d` string.
1334
1378
  if (this._edgePathResolver) {
1335
1379
  const defaultResolver = (e) => {
1336
- const s = nodesById.get(e.from);
1337
- const t = nodesById.get(e.to);
1338
- if (!s || !t)
1380
+ const r = resolveDanglingEdge(e, nodesById);
1381
+ if (!r)
1339
1382
  return '';
1340
- if (s === t)
1341
- return computeSelfLoop(s, e).d;
1342
- const endpoints = computeEdgeEndpoints(s, t, e);
1383
+ if (r.start && r.end && r.start === r.end)
1384
+ return computeSelfLoop(r.start, e).d;
1385
+ const endpoints = computeEdgeEndpoints(r.start, r.end, e);
1343
1386
  return computeEdgePath(endpoints.start, endpoints.end, e.routing, e.waypoints).d;
1344
1387
  };
1345
1388
  try {
@@ -1946,10 +1989,10 @@ class VizBuilderImpl {
1946
1989
  // Render Edges
1947
1990
  svgContent += '<g class="viz-layer-edges" data-viz-layer="edges">';
1948
1991
  exportEdges.forEach((edge) => {
1949
- const start = nodesById.get(edge.from);
1950
- const end = nodesById.get(edge.to);
1951
- if (!start || !end)
1992
+ const resolved = resolveDanglingEdge(edge, nodesById);
1993
+ if (!resolved)
1952
1994
  return;
1995
+ const { start, end } = resolved;
1953
1996
  // Animations
1954
1997
  let animClasses = '';
1955
1998
  let animStyleStr = '';
@@ -1984,7 +2027,7 @@ class VizBuilderImpl {
1984
2027
  ? `marker-start="url(#${markerIdFor(edge.markerStart, edge.style?.stroke, 'start')})"`
1985
2028
  : '';
1986
2029
  let edgePath;
1987
- if (start === end) {
2030
+ if (start && end && start === end) {
1988
2031
  edgePath = computeSelfLoop(start, edge);
1989
2032
  }
1990
2033
  else {
@@ -1993,13 +2036,12 @@ class VizBuilderImpl {
1993
2036
  }
1994
2037
  if (this._edgePathResolver) {
1995
2038
  const defaultResolver = (e) => {
1996
- const s = nodesById.get(e.from);
1997
- const t = nodesById.get(e.to);
1998
- if (!s || !t)
2039
+ const r = resolveDanglingEdge(e, nodesById);
2040
+ if (!r)
1999
2041
  return '';
2000
- if (s === t)
2001
- return computeSelfLoop(s, e).d;
2002
- const endpoints = computeEdgeEndpoints(s, t, e);
2042
+ if (r.start && r.end && r.start === r.end)
2043
+ return computeSelfLoop(r.start, e).d;
2044
+ const endpoints = computeEdgeEndpoints(r.start, r.end, e);
2003
2045
  return computeEdgePath(endpoints.start, endpoints.end, e.routing, e.waypoints).d;
2004
2046
  };
2005
2047
  try {
@@ -2626,6 +2668,9 @@ class NodeBuilderImpl {
2626
2668
  edge(from, to, idOrOpts) {
2627
2669
  return this._builder.edge(from, to, idOrOpts);
2628
2670
  }
2671
+ danglingEdge(id, opts) {
2672
+ return this._builder.danglingEdge(id, opts);
2673
+ }
2629
2674
  overlay(arg1, arg2, arg3) {
2630
2675
  if (typeof arg1 === 'function')
2631
2676
  return this._builder.overlay(arg1);
@@ -2668,6 +2713,22 @@ class EdgeBuilderImpl {
2668
2713
  this.edgeDef.waypoints.push({ x, y });
2669
2714
  return this;
2670
2715
  }
2716
+ from(nodeId) {
2717
+ this.edgeDef.from = nodeId;
2718
+ return this;
2719
+ }
2720
+ to(nodeId) {
2721
+ this.edgeDef.to = nodeId;
2722
+ return this;
2723
+ }
2724
+ fromAt(pos) {
2725
+ this.edgeDef.fromAt = pos;
2726
+ return this;
2727
+ }
2728
+ toAt(pos) {
2729
+ this.edgeDef.toAt = pos;
2730
+ return this;
2731
+ }
2671
2732
  label(text, opts) {
2672
2733
  const lbl = { position: 'mid', text, dy: -10, ...opts };
2673
2734
  // Accumulate into the labels array
@@ -2739,6 +2800,14 @@ class EdgeBuilderImpl {
2739
2800
  this.edgeDef.toPort = portId;
2740
2801
  return this;
2741
2802
  }
2803
+ fromAngle(deg) {
2804
+ this.edgeDef.fromAngle = deg;
2805
+ return this;
2806
+ }
2807
+ toAngle(deg) {
2808
+ this.edgeDef.toAngle = deg;
2809
+ return this;
2810
+ }
2742
2811
  fill(color) {
2743
2812
  this.edgeDef.style = {
2744
2813
  ...(this.edgeDef.style || {}),
@@ -2855,6 +2924,9 @@ class EdgeBuilderImpl {
2855
2924
  edge(from, to, idOrOpts) {
2856
2925
  return this.parent.edge(from, to, idOrOpts);
2857
2926
  }
2927
+ danglingEdge(id, opts) {
2928
+ return this.parent.danglingEdge(id, opts);
2929
+ }
2858
2930
  overlay(arg1, arg2, arg3) {
2859
2931
  if (typeof arg1 === 'function')
2860
2932
  return this.parent.overlay(arg1);
@@ -24,11 +24,13 @@ export interface EdgePathResult {
24
24
  * to the port's absolute position. Otherwise the legacy `anchor` mode
25
25
  * (`'center'` | `'boundary'`) is used.
26
26
  *
27
+ * Accepts `null` for either node to support dangling edges.
28
+ *
27
29
  * This replicates the logic used internally by the core builder and
28
30
  * runtime patcher so that external renderers (e.g. React) can
29
31
  * resolve boundary anchors consistently.
30
32
  */
31
- export declare function computeEdgeEndpoints(start: VizNode, end: VizNode, edge: VizEdge): {
33
+ export declare function computeEdgeEndpoints(start: VizNode | null, end: VizNode | null, edge: VizEdge): {
32
34
  start: Vec2;
33
35
  end: Vec2;
34
36
  };
package/dist/edgePaths.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * 1. An SVG `d` attribute string (for `<path>` elements).
7
7
  * 2. A midpoint along the path (for label positioning).
8
8
  */
9
- import { computeNodeAnchor, effectivePos, effectiveShape, resolvePortPosition, } from './shapes';
9
+ import { computeNodeAnchor, computeNodeAnchorAtAngle, effectivePos, effectiveShape, resolvePortPosition, } from './shapes';
10
10
  /**
11
11
  * Compute anchor-resolved start/end points for an edge.
12
12
  *
@@ -14,23 +14,53 @@ import { computeNodeAnchor, effectivePos, effectiveShape, resolvePortPosition, }
14
14
  * to the port's absolute position. Otherwise the legacy `anchor` mode
15
15
  * (`'center'` | `'boundary'`) is used.
16
16
  *
17
+ * Accepts `null` for either node to support dangling edges.
18
+ *
17
19
  * This replicates the logic used internally by the core builder and
18
20
  * runtime patcher so that external renderers (e.g. React) can
19
21
  * resolve boundary anchors consistently.
20
22
  */
21
23
  export function computeEdgeEndpoints(start, end, edge) {
22
24
  const anchor = edge.anchor ?? 'boundary';
23
- const startPos = effectivePos(start);
24
- const endPos = effectivePos(end);
25
- // Port-based resolution takes precedence over anchor mode.
26
- const startAnchor = edge.fromPort
27
- ? (resolvePortPosition(start, edge.fromPort) ??
28
- computeNodeAnchor(start, endPos, anchor))
29
- : computeNodeAnchor(start, endPos, anchor);
30
- const endAnchor = edge.toPort
31
- ? (resolvePortPosition(end, edge.toPort) ??
32
- computeNodeAnchor(end, startPos, anchor))
33
- : computeNodeAnchor(end, startPos, anchor);
25
+ const freeStart = edge.fromAt;
26
+ const freeEnd = edge.toAt;
27
+ // Determine the "other" position for anchor resolution.
28
+ const endTarget = end ? effectivePos(end) : (freeEnd ?? { x: 0, y: 0 });
29
+ const startTarget = start
30
+ ? effectivePos(start)
31
+ : (freeStart ?? { x: 0, y: 0 });
32
+ // Source endpoint
33
+ let startAnchor;
34
+ if (start) {
35
+ if (edge.fromAngle !== undefined) {
36
+ startAnchor = computeNodeAnchorAtAngle(start, edge.fromAngle);
37
+ }
38
+ else {
39
+ startAnchor = edge.fromPort
40
+ ? (resolvePortPosition(start, edge.fromPort) ??
41
+ computeNodeAnchor(start, endTarget, anchor))
42
+ : computeNodeAnchor(start, endTarget, anchor);
43
+ }
44
+ }
45
+ else {
46
+ startAnchor = freeStart ?? { x: 0, y: 0 };
47
+ }
48
+ // Target endpoint
49
+ let endAnchor;
50
+ if (end) {
51
+ if (edge.toAngle !== undefined) {
52
+ endAnchor = computeNodeAnchorAtAngle(end, edge.toAngle);
53
+ }
54
+ else {
55
+ endAnchor = edge.toPort
56
+ ? (resolvePortPosition(end, edge.toPort) ??
57
+ computeNodeAnchor(end, startTarget, anchor))
58
+ : computeNodeAnchor(end, startTarget, anchor);
59
+ }
60
+ }
61
+ else {
62
+ endAnchor = freeEnd ?? { x: 0, y: 0 };
63
+ }
34
64
  return { start: startAnchor, end: endAnchor };
35
65
  }
36
66
  /**
package/dist/hitTest.js CHANGED
@@ -264,19 +264,31 @@ export function hitTestRect(scene, rect) {
264
264
  // Check edges
265
265
  // Roughly test if the edge's bounding box intersects the rect
266
266
  for (const edge of scene.edges) {
267
- const startNode = scene.nodes.find((n) => n.id === edge.from);
268
- const endNode = scene.nodes.find((n) => n.id === edge.to);
269
- if (!startNode || !endNode)
267
+ const startNode = edge.from
268
+ ? scene.nodes.find((n) => n.id === edge.from)
269
+ : undefined;
270
+ const endNode = edge.to
271
+ ? scene.nodes.find((n) => n.id === edge.to)
272
+ : undefined;
273
+ if (edge.from && !startNode)
274
+ continue;
275
+ if (edge.to && !endNode)
276
+ continue;
277
+ if (!startNode && !edge.fromAt && !endNode && !edge.toAt)
270
278
  continue;
271
279
  // Simplistic edge bounding box
272
- const startPos = {
273
- x: startNode.runtime?.x ?? startNode.pos.x,
274
- y: startNode.runtime?.y ?? startNode.pos.y,
275
- };
276
- const endPos = {
277
- x: endNode.runtime?.x ?? endNode.pos.x,
278
- y: endNode.runtime?.y ?? endNode.pos.y,
279
- };
280
+ const startPos = startNode
281
+ ? {
282
+ x: startNode.runtime?.x ?? startNode.pos.x,
283
+ y: startNode.runtime?.y ?? startNode.pos.y,
284
+ }
285
+ : (edge.fromAt ?? { x: 0, y: 0 });
286
+ const endPos = endNode
287
+ ? {
288
+ x: endNode.runtime?.x ?? endNode.pos.x,
289
+ y: endNode.runtime?.y ?? endNode.pos.y,
290
+ }
291
+ : (edge.toAt ?? { x: 0, y: 0 });
280
292
  const minX = Math.min(startPos.x, endPos.x) - 10;
281
293
  const maxX = Math.max(startPos.x, endPos.x) + 10;
282
294
  const minY = Math.min(startPos.y, endPos.y) - 10;
@@ -328,11 +340,17 @@ export function edgeDistance(scene, edgeId, point) {
328
340
  const edge = scene.edges.find((e) => e.id === edgeId);
329
341
  if (!edge)
330
342
  return Infinity;
331
- const startNode = scene.nodes.find((n) => n.id === edge.from);
332
- const endNode = scene.nodes.find((n) => n.id === edge.to);
333
- if (!startNode || !endNode)
343
+ const startNode = edge.from
344
+ ? scene.nodes.find((n) => n.id === edge.from)
345
+ : null;
346
+ const endNode = edge.to ? scene.nodes.find((n) => n.id === edge.to) : null;
347
+ if (edge.from && !startNode)
348
+ return Infinity;
349
+ if (edge.to && !endNode)
350
+ return Infinity;
351
+ if (!startNode && !edge.fromAt && !endNode && !edge.toAt)
334
352
  return Infinity;
335
- const { start, end } = computeEdgeEndpoints(startNode, endNode, edge);
353
+ const { start, end } = computeEdgeEndpoints(startNode ?? null, endNode ?? null, edge);
336
354
  const pathData = computeEdgePath(start, end, edge.routing, edge.waypoints);
337
355
  const polyline = samplePath(pathData.d, 10);
338
356
  return distToPolyline(point, polyline);
package/dist/index.d.ts CHANGED
@@ -8,7 +8,7 @@ export * from './overlayBuilder';
8
8
  export * from './edgePaths';
9
9
  export * from './edgeLabels';
10
10
  export * from './edgeStyles';
11
- export { getDefaultPorts, getNodePorts, findPort, resolvePortPosition, } from './shapes';
11
+ export { getDefaultPorts, getNodePorts, findPort, resolvePortPosition, computeNodeAnchorAtAngle, } from './shapes';
12
12
  export * from './anim/spec';
13
13
  export * from './anim/animationBuilder';
14
14
  export * from './anim/playback';
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ export * from './overlayBuilder';
8
8
  export * from './edgePaths';
9
9
  export * from './edgeLabels';
10
10
  export * from './edgeStyles';
11
- export { getDefaultPorts, getNodePorts, findPort, resolvePortPosition, } from './shapes';
11
+ export { getDefaultPorts, getNodePorts, findPort, resolvePortPosition, computeNodeAnchorAtAngle, } from './shapes';
12
12
  export * from './anim/spec';
13
13
  export * from './anim/animationBuilder';
14
14
  export * from './anim/playback';
@@ -511,19 +511,9 @@ export function patchRuntime(scene, ctx) {
511
511
  shape.removeAttribute('opacity');
512
512
  }
513
513
  }
514
- // Stroke-dasharray: apply resolved value to shape.
515
- if (node.style?.strokeDasharray !== undefined) {
516
- const resolved = resolveDasharray(node.style.strokeDasharray);
517
- if (resolved) {
518
- shape.setAttribute('stroke-dasharray', resolved);
519
- }
520
- else {
521
- shape.removeAttribute('stroke-dasharray');
522
- }
523
- }
524
- else {
525
- shape.removeAttribute('stroke-dasharray');
526
- }
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.
527
517
  if (node.style?.shadow) {
528
518
  const fid = ensureShadowFilter(ctx.svg, node.style.shadow);
529
519
  shape.setAttribute('filter', `url(#${fid})`);
@@ -581,12 +571,16 @@ export function patchRuntime(scene, ctx) {
581
571
  const line = ctx.edgeLinesById.get(edge.id);
582
572
  if (!group || !line)
583
573
  continue;
584
- const start = nodesById.get(edge.from);
585
- const end = nodesById.get(edge.to);
586
- if (!start || !end)
574
+ const start = edge.from ? (nodesById.get(edge.from) ?? null) : null;
575
+ const end = edge.to ? (nodesById.get(edge.to) ?? null) : null;
576
+ if (edge.from && !start)
577
+ continue;
578
+ if (edge.to && !end)
579
+ continue;
580
+ if (!start && !edge.fromAt && !end && !edge.toAt)
587
581
  continue;
588
582
  let edgePath;
589
- if (start === end) {
583
+ if (start && end && start === end) {
590
584
  edgePath = computeSelfLoop(start, edge);
591
585
  }
592
586
  else {
@@ -595,11 +589,15 @@ export function patchRuntime(scene, ctx) {
595
589
  }
596
590
  if (edgePathResolver) {
597
591
  const defaultResolver = (e) => {
598
- const s = nodesById.get(e.from);
599
- const t = nodesById.get(e.to);
600
- if (!s || !t)
592
+ const s = e.from ? (nodesById.get(e.from) ?? null) : null;
593
+ const t = e.to ? (nodesById.get(e.to) ?? null) : null;
594
+ if (e.from && !s)
595
+ return '';
596
+ if (e.to && !t)
597
+ return '';
598
+ if (!s && !e.fromAt && !t && !e.toAt)
601
599
  return '';
602
- if (s === t)
600
+ if (s && t && s === t)
603
601
  return computeSelfLoop(s, e).d;
604
602
  const endpoints = computeEdgeEndpoints(s, t, e);
605
603
  return computeEdgePath(endpoints.start, endpoints.end, e.routing, e.waypoints).d;
package/dist/shapes.d.ts CHANGED
@@ -12,6 +12,10 @@ export interface ShapeBehavior<K extends NodeShape['kind']> {
12
12
  anchorBoundary(pos: Vec2, target: Vec2, shape: Extract<NodeShape, {
13
13
  kind: K;
14
14
  }>): Vec2;
15
+ /** Resolve a perimeter point at the given angle (degrees, 0 = right, 90 = down). */
16
+ anchorAtAngle(pos: Vec2, angleDeg: number, shape: Extract<NodeShape, {
17
+ kind: K;
18
+ }>): Vec2;
15
19
  }
16
20
  export declare function effectivePos(node: VizNode): Vec2;
17
21
  export declare function effectiveShape(node: VizNode): NodeShape;
@@ -19,6 +23,8 @@ export declare function getShapeBehavior(shape: NodeShape): ShapeBehavior<"circl
19
23
  export declare function applyShapeGeometry(el: SVGElement, shape: NodeShape, pos: Vec2): void;
20
24
  export declare function shapeSvgMarkup(shape: NodeShape, pos: Vec2, attrs: string): string;
21
25
  export declare function computeNodeAnchor(node: VizNode, target: Vec2, anchor: AnchorMode): Vec2;
26
+ /** Resolve the perimeter point on a node's shape at the given angle (degrees, 0 = right, 90 = down). */
27
+ export declare function computeNodeAnchorAtAngle(node: VizNode, angleDeg: number): Vec2;
22
28
  /**
23
29
  * Return the default (implicit) ports for a node shape.
24
30
  *
package/dist/shapes.js CHANGED
@@ -30,6 +30,17 @@ export function effectiveShape(node) {
30
30
  }
31
31
  return shape;
32
32
  }
33
+ const DEG_TO_RAD = Math.PI / 180;
34
+ /** Resolve a rect boundary point at the given angle (degrees). */
35
+ function rectAnchorAtAngle(pos, angleDeg, hw, hh) {
36
+ const rad = angleDeg * DEG_TO_RAD;
37
+ const dx = Math.cos(rad);
38
+ const dy = Math.sin(rad);
39
+ if (dx === 0 && dy === 0)
40
+ return { ...pos };
41
+ const scale = Math.min(hw / Math.abs(dx || 1e-6), hh / Math.abs(dy || 1e-6));
42
+ return { x: pos.x + dx * scale, y: pos.y + dy * scale };
43
+ }
33
44
  function diamondPoints(pos, w, h) {
34
45
  const hw = w / 2;
35
46
  const hh = h / 2;
@@ -58,6 +69,13 @@ const circleBehavior = {
58
69
  y: pos.y + dy * scale,
59
70
  };
60
71
  },
72
+ anchorAtAngle(pos, angleDeg, shape) {
73
+ const rad = angleDeg * DEG_TO_RAD;
74
+ return {
75
+ x: pos.x + shape.r * Math.cos(rad),
76
+ y: pos.y + shape.r * Math.sin(rad),
77
+ };
78
+ },
61
79
  };
62
80
  const rectBehavior = {
63
81
  kind: 'rect',
@@ -87,6 +105,9 @@ const rectBehavior = {
87
105
  y: pos.y + dy * scale,
88
106
  };
89
107
  },
108
+ anchorAtAngle(pos, angleDeg, shape) {
109
+ return rectAnchorAtAngle(pos, angleDeg, shape.w / 2, shape.h / 2);
110
+ },
90
111
  };
91
112
  const diamondBehavior = {
92
113
  kind: 'diamond',
@@ -112,6 +133,18 @@ const diamondBehavior = {
112
133
  y: pos.y + dy * scale,
113
134
  };
114
135
  },
136
+ anchorAtAngle(pos, angleDeg, shape) {
137
+ const rad = angleDeg * DEG_TO_RAD;
138
+ const dx = Math.cos(rad);
139
+ const dy = Math.sin(rad);
140
+ if (dx === 0 && dy === 0)
141
+ return { ...pos };
142
+ const hw = shape.w / 2;
143
+ const hh = shape.h / 2;
144
+ const denom = Math.abs(dx) / hw + Math.abs(dy) / hh;
145
+ const scale = denom === 0 ? 0 : 1 / denom;
146
+ return { x: pos.x + dx * scale, y: pos.y + dy * scale };
147
+ },
115
148
  };
116
149
  function cylinderGeometry(shape, pos) {
117
150
  const rx = shape.w / 2;
@@ -199,6 +232,9 @@ const cylinderBehavior = {
199
232
  y: pos.y + dy * scale,
200
233
  };
201
234
  },
235
+ anchorAtAngle(pos, angleDeg, shape) {
236
+ return rectAnchorAtAngle(pos, angleDeg, shape.w / 2, shape.h / 2);
237
+ },
202
238
  };
203
239
  function hexagonPoints(pos, r, orientation) {
204
240
  const pts = [];
@@ -238,6 +274,13 @@ const hexagonBehavior = {
238
274
  y: pos.y + dy * scale,
239
275
  };
240
276
  },
277
+ anchorAtAngle(pos, angleDeg, shape) {
278
+ const rad = angleDeg * DEG_TO_RAD;
279
+ return {
280
+ x: pos.x + shape.r * Math.cos(rad),
281
+ y: pos.y + shape.r * Math.sin(rad),
282
+ };
283
+ },
241
284
  };
242
285
  const ellipseBehavior = {
243
286
  kind: 'ellipse',
@@ -262,6 +305,13 @@ const ellipseBehavior = {
262
305
  y: pos.y + (shape.rx * shape.ry * dy) / denom,
263
306
  };
264
307
  },
308
+ anchorAtAngle(pos, angleDeg, shape) {
309
+ const rad = angleDeg * DEG_TO_RAD;
310
+ return {
311
+ x: pos.x + shape.rx * Math.cos(rad),
312
+ y: pos.y + shape.ry * Math.sin(rad),
313
+ };
314
+ },
265
315
  };
266
316
  function arcPathD(shape, pos) {
267
317
  const toRad = Math.PI / 180;
@@ -303,6 +353,13 @@ const arcBehavior = {
303
353
  y: pos.y + dy * scale,
304
354
  };
305
355
  },
356
+ anchorAtAngle(pos, angleDeg, shape) {
357
+ const rad = angleDeg * DEG_TO_RAD;
358
+ return {
359
+ x: pos.x + shape.r * Math.cos(rad),
360
+ y: pos.y + shape.r * Math.sin(rad),
361
+ };
362
+ },
306
363
  };
307
364
  function blockArrowPoints(shape, pos) {
308
365
  const halfBody = shape.bodyWidth / 2;
@@ -360,6 +417,12 @@ const blockArrowBehavior = {
360
417
  y: pos.y + dy * scale,
361
418
  };
362
419
  },
420
+ anchorAtAngle(pos, angleDeg, shape) {
421
+ const dir = shape.direction ?? 'right';
422
+ const hw = dir === 'up' || dir === 'down' ? shape.headWidth / 2 : shape.length / 2;
423
+ const hh = dir === 'up' || dir === 'down' ? shape.length / 2 : shape.headWidth / 2;
424
+ return rectAnchorAtAngle(pos, angleDeg, hw, hh);
425
+ },
363
426
  };
364
427
  function calloutPathD(shape, pos) {
365
428
  const hw = shape.w / 2;
@@ -461,6 +524,9 @@ const calloutBehavior = {
461
524
  y: pos.y + dy * scale,
462
525
  };
463
526
  },
527
+ anchorAtAngle(pos, angleDeg, shape) {
528
+ return rectAnchorAtAngle(pos, angleDeg, shape.w / 2, shape.h / 2);
529
+ },
464
530
  };
465
531
  /**
466
532
  * Compute the SVG path for a cloud shape that fits within a w×h bounding box
@@ -514,6 +580,12 @@ const cloudBehavior = {
514
580
  y: pos.y + (a * b * dy) / denom,
515
581
  };
516
582
  },
583
+ anchorAtAngle(pos, angleDeg, shape) {
584
+ const rad = angleDeg * DEG_TO_RAD;
585
+ const a = shape.w / 2;
586
+ const b = shape.h / 2;
587
+ return { x: pos.x + a * Math.cos(rad), y: pos.y + b * Math.sin(rad) };
588
+ },
517
589
  };
518
590
  function crossPoints(shape, pos) {
519
591
  const hs = shape.size / 2;
@@ -556,6 +628,9 @@ const crossBehavior = {
556
628
  y: pos.y + dy * scale,
557
629
  };
558
630
  },
631
+ anchorAtAngle(pos, angleDeg, shape) {
632
+ return rectAnchorAtAngle(pos, angleDeg, shape.size / 2, shape.size / 2);
633
+ },
559
634
  };
560
635
  function cubeVertices(shape, pos) {
561
636
  const hw = shape.w / 2;
@@ -628,6 +703,10 @@ const cubeBehavior = {
628
703
  y: pos.y + dy * scale,
629
704
  };
630
705
  },
706
+ anchorAtAngle(pos, angleDeg, shape) {
707
+ const d = shape.depth ?? Math.round(shape.w * 0.2);
708
+ return rectAnchorAtAngle(pos, angleDeg, shape.w / 2 + d / 2, shape.h / 2 + d / 2);
709
+ },
631
710
  };
632
711
  const pathBehavior = {
633
712
  kind: 'path',
@@ -654,6 +733,9 @@ const pathBehavior = {
654
733
  y: pos.y + dy * scale,
655
734
  };
656
735
  },
736
+ anchorAtAngle(pos, angleDeg, shape) {
737
+ return rectAnchorAtAngle(pos, angleDeg, shape.w / 2, shape.h / 2);
738
+ },
657
739
  };
658
740
  function documentPathD(shape, pos) {
659
741
  const hw = shape.w / 2;
@@ -693,6 +775,9 @@ const documentBehavior = {
693
775
  y: pos.y + dy * scale,
694
776
  };
695
777
  },
778
+ anchorAtAngle(pos, angleDeg, shape) {
779
+ return rectAnchorAtAngle(pos, angleDeg, shape.w / 2, shape.h / 2);
780
+ },
696
781
  };
697
782
  function noteVertices(shape, pos) {
698
783
  const hw = shape.w / 2;
@@ -748,6 +833,9 @@ const noteBehavior = {
748
833
  y: pos.y + dy * scale,
749
834
  };
750
835
  },
836
+ anchorAtAngle(pos, angleDeg, shape) {
837
+ return rectAnchorAtAngle(pos, angleDeg, shape.w / 2, shape.h / 2);
838
+ },
751
839
  };
752
840
  function parallelogramPoints(shape, pos) {
753
841
  const hw = shape.w / 2;
@@ -785,6 +873,10 @@ const parallelogramBehavior = {
785
873
  y: pos.y + dy * scale,
786
874
  };
787
875
  },
876
+ anchorAtAngle(pos, angleDeg, shape) {
877
+ const sk = shape.skew ?? Math.round(shape.w * 0.2);
878
+ return rectAnchorAtAngle(pos, angleDeg, shape.w / 2 + sk / 2, shape.h / 2);
879
+ },
788
880
  };
789
881
  function starPoints(shape, pos) {
790
882
  const n = shape.points;
@@ -820,6 +912,13 @@ const starBehavior = {
820
912
  y: pos.y + dy * scale,
821
913
  };
822
914
  },
915
+ anchorAtAngle(pos, angleDeg, shape) {
916
+ const rad = angleDeg * DEG_TO_RAD;
917
+ return {
918
+ x: pos.x + shape.outerR * Math.cos(rad),
919
+ y: pos.y + shape.outerR * Math.sin(rad),
920
+ };
921
+ },
823
922
  };
824
923
  function trapezoidPoints(shape, pos) {
825
924
  const htw = shape.topW / 2;
@@ -855,6 +954,10 @@ const trapezoidBehavior = {
855
954
  y: pos.y + dy * scale,
856
955
  };
857
956
  },
957
+ anchorAtAngle(pos, angleDeg, shape) {
958
+ const hw = Math.max(shape.topW, shape.bottomW) / 2;
959
+ return rectAnchorAtAngle(pos, angleDeg, hw, shape.h / 2);
960
+ },
858
961
  };
859
962
  function trianglePoints(shape, pos) {
860
963
  const hw = shape.w / 2;
@@ -910,6 +1013,9 @@ const triangleBehavior = {
910
1013
  y: pos.y + dy * scale,
911
1014
  };
912
1015
  },
1016
+ anchorAtAngle(pos, angleDeg, shape) {
1017
+ return rectAnchorAtAngle(pos, angleDeg, shape.w / 2, shape.h / 2);
1018
+ },
913
1019
  };
914
1020
  const imageBehavior = {
915
1021
  kind: 'image',
@@ -955,6 +1061,9 @@ const imageBehavior = {
955
1061
  y: pos.y + dy * scale,
956
1062
  };
957
1063
  },
1064
+ anchorAtAngle(pos, angleDeg, shape) {
1065
+ return rectAnchorAtAngle(pos, angleDeg, shape.w / 2, shape.h / 2);
1066
+ },
958
1067
  };
959
1068
  const iconBehavior = {
960
1069
  kind: 'icon',
@@ -1006,6 +1115,9 @@ const iconBehavior = {
1006
1115
  y: pos.y + dy * scale,
1007
1116
  };
1008
1117
  },
1118
+ anchorAtAngle(pos, angleDeg, shape) {
1119
+ return rectAnchorAtAngle(pos, angleDeg, shape.w / 2, shape.h / 2);
1120
+ },
1009
1121
  };
1010
1122
  const svgBehavior = {
1011
1123
  kind: 'svg',
@@ -1044,6 +1156,9 @@ const svgBehavior = {
1044
1156
  y: pos.y + dy * scale,
1045
1157
  };
1046
1158
  },
1159
+ anchorAtAngle(pos, angleDeg, shape) {
1160
+ return rectAnchorAtAngle(pos, angleDeg, shape.w / 2, shape.h / 2);
1161
+ },
1047
1162
  };
1048
1163
  const shapeBehaviorRegistry = {
1049
1164
  circle: circleBehavior,
@@ -1089,6 +1204,13 @@ export function computeNodeAnchor(node, target, anchor) {
1089
1204
  const behavior = getShapeBehavior(shape);
1090
1205
  return behavior.anchorBoundary(pos, target, shape);
1091
1206
  }
1207
+ /** Resolve the perimeter point on a node's shape at the given angle (degrees, 0 = right, 90 = down). */
1208
+ export function computeNodeAnchorAtAngle(node, angleDeg) {
1209
+ const pos = effectivePos(node);
1210
+ const shape = effectiveShape(node);
1211
+ const behavior = getShapeBehavior(shape);
1212
+ return behavior.anchorAtAngle(pos, angleDeg, shape);
1213
+ }
1092
1214
  // ── Connection Ports ────────────────────────────────────────────────────────
1093
1215
  /**
1094
1216
  * Return the default (implicit) ports for a node shape.
package/dist/types.d.ts CHANGED
@@ -402,8 +402,18 @@ export type EdgeRouting = 'straight' | 'curved' | 'orthogonal';
402
402
  export type EdgeMarkerType = 'none' | 'arrow' | 'arrowOpen' | 'diamond' | 'diamondOpen' | 'circle' | 'circleOpen' | 'square' | 'bar' | 'halfArrow';
403
403
  export interface VizEdge {
404
404
  id: string;
405
- from: string;
406
- to: string;
405
+ /** Source node id. Optional for dangling edges (use `fromAt` instead). */
406
+ from?: string;
407
+ /** Target node id. Optional for dangling edges (use `toAt` instead). */
408
+ to?: string;
409
+ /** Free-endpoint coordinate for the source end (when `from` is omitted). */
410
+ fromAt?: Vec2;
411
+ /** Free-endpoint coordinate for the target end (when `to` is omitted). */
412
+ toAt?: Vec2;
413
+ /** Angle (degrees) for the source perimeter anchor. 0 = right, 90 = down. */
414
+ fromAngle?: number;
415
+ /** Angle (degrees) for the target perimeter anchor. 0 = right, 90 = down. */
416
+ toAngle?: number;
407
417
  /** Arbitrary consumer-defined metadata associated with the edge. */
408
418
  meta?: Record<string, unknown>;
409
419
  /** @deprecated Use `labels` for multi-position support. Kept for backwards compatibility. */
@@ -642,6 +652,18 @@ export interface NodeOptions {
642
652
  export interface EdgeOptions {
643
653
  /** Custom edge id (defaults to `"from->to"`). */
644
654
  id?: string;
655
+ /** Source node id. Use with `danglingEdge()` to attach one end to a node. */
656
+ from?: string;
657
+ /** Target node id. Use with `danglingEdge()` to attach one end to a node. */
658
+ to?: string;
659
+ /** Free-endpoint coordinate for the source end (when `from` is omitted). */
660
+ fromAt?: Vec2;
661
+ /** Free-endpoint coordinate for the target end (when `to` is omitted). */
662
+ toAt?: Vec2;
663
+ /** Angle (degrees) for the source perimeter anchor. 0 = right, 90 = down. */
664
+ fromAngle?: number;
665
+ /** Angle (degrees) for the target perimeter anchor. 0 = right, 90 = down. */
666
+ toAngle?: number;
645
667
  routing?: EdgeRouting;
646
668
  waypoints?: Vec2[];
647
669
  /** Convenience for arrow markers. `true` = markerEnd arrow, `'both'` = both ends. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vizcraft",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
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",