vizcraft 1.3.1 → 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,14 @@
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
+
3
12
  ## 1.3.1
4
13
 
5
14
  ### Patch 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') {
@@ -786,6 +813,22 @@ class VizBuilderImpl {
786
813
  applyEdgeOptions(eb, idOrOpts);
787
814
  return this;
788
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
+ }
789
832
  /**
790
833
  * Hydrates the builder from an existing VizScene.
791
834
  * @param scene The scene to hydrate from
@@ -833,10 +876,12 @@ class VizBuilderImpl {
833
876
  */
834
877
  build() {
835
878
  this._edges.forEach((edge) => {
836
- 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)) {
837
882
  console.warn(`VizBuilder: Edge ${edge.id} references missing source node ${edge.from}`);
838
883
  }
839
- if (!this._nodes.has(edge.to)) {
884
+ if (edge.to && !this._nodes.has(edge.to)) {
840
885
  console.warn(`VizBuilder: Edge ${edge.id} references missing target node ${edge.to}`);
841
886
  }
842
887
  });
@@ -1271,10 +1316,10 @@ class VizBuilderImpl {
1271
1316
  });
1272
1317
  const processedEdgeIds = new Set();
1273
1318
  edges.forEach((edge) => {
1274
- const start = nodesById.get(edge.from);
1275
- const end = nodesById.get(edge.to);
1276
- if (!start || !end)
1319
+ const resolved = resolveDanglingEdge(edge, nodesById);
1320
+ if (!resolved)
1277
1321
  return;
1322
+ const { start, end } = resolved;
1278
1323
  processedEdgeIds.add(edge.id);
1279
1324
  let group = existingEdgesMap.get(edge.id);
1280
1325
  if (!group) {
@@ -1322,7 +1367,7 @@ class VizBuilderImpl {
1322
1367
  group.setAttribute('class', classes);
1323
1368
  // Use effective positions (handles runtime overrides internally via helper)
1324
1369
  let edgePath;
1325
- if (start === end) {
1370
+ if (start && end && start === end) {
1326
1371
  edgePath = computeSelfLoop(start, edge);
1327
1372
  }
1328
1373
  else {
@@ -1332,13 +1377,12 @@ class VizBuilderImpl {
1332
1377
  // Allow consumer override of the SVG path `d` string.
1333
1378
  if (this._edgePathResolver) {
1334
1379
  const defaultResolver = (e) => {
1335
- const s = nodesById.get(e.from);
1336
- const t = nodesById.get(e.to);
1337
- if (!s || !t)
1380
+ const r = resolveDanglingEdge(e, nodesById);
1381
+ if (!r)
1338
1382
  return '';
1339
- if (s === t)
1340
- return computeSelfLoop(s, e).d;
1341
- 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);
1342
1386
  return computeEdgePath(endpoints.start, endpoints.end, e.routing, e.waypoints).d;
1343
1387
  };
1344
1388
  try {
@@ -1945,10 +1989,10 @@ class VizBuilderImpl {
1945
1989
  // Render Edges
1946
1990
  svgContent += '<g class="viz-layer-edges" data-viz-layer="edges">';
1947
1991
  exportEdges.forEach((edge) => {
1948
- const start = nodesById.get(edge.from);
1949
- const end = nodesById.get(edge.to);
1950
- if (!start || !end)
1992
+ const resolved = resolveDanglingEdge(edge, nodesById);
1993
+ if (!resolved)
1951
1994
  return;
1995
+ const { start, end } = resolved;
1952
1996
  // Animations
1953
1997
  let animClasses = '';
1954
1998
  let animStyleStr = '';
@@ -1983,7 +2027,7 @@ class VizBuilderImpl {
1983
2027
  ? `marker-start="url(#${markerIdFor(edge.markerStart, edge.style?.stroke, 'start')})"`
1984
2028
  : '';
1985
2029
  let edgePath;
1986
- if (start === end) {
2030
+ if (start && end && start === end) {
1987
2031
  edgePath = computeSelfLoop(start, edge);
1988
2032
  }
1989
2033
  else {
@@ -1992,13 +2036,12 @@ class VizBuilderImpl {
1992
2036
  }
1993
2037
  if (this._edgePathResolver) {
1994
2038
  const defaultResolver = (e) => {
1995
- const s = nodesById.get(e.from);
1996
- const t = nodesById.get(e.to);
1997
- if (!s || !t)
2039
+ const r = resolveDanglingEdge(e, nodesById);
2040
+ if (!r)
1998
2041
  return '';
1999
- if (s === t)
2000
- return computeSelfLoop(s, e).d;
2001
- 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);
2002
2045
  return computeEdgePath(endpoints.start, endpoints.end, e.routing, e.waypoints).d;
2003
2046
  };
2004
2047
  try {
@@ -2625,6 +2668,9 @@ class NodeBuilderImpl {
2625
2668
  edge(from, to, idOrOpts) {
2626
2669
  return this._builder.edge(from, to, idOrOpts);
2627
2670
  }
2671
+ danglingEdge(id, opts) {
2672
+ return this._builder.danglingEdge(id, opts);
2673
+ }
2628
2674
  overlay(arg1, arg2, arg3) {
2629
2675
  if (typeof arg1 === 'function')
2630
2676
  return this._builder.overlay(arg1);
@@ -2667,6 +2713,22 @@ class EdgeBuilderImpl {
2667
2713
  this.edgeDef.waypoints.push({ x, y });
2668
2714
  return this;
2669
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
+ }
2670
2732
  label(text, opts) {
2671
2733
  const lbl = { position: 'mid', text, dy: -10, ...opts };
2672
2734
  // Accumulate into the labels array
@@ -2738,6 +2800,14 @@ class EdgeBuilderImpl {
2738
2800
  this.edgeDef.toPort = portId;
2739
2801
  return this;
2740
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
+ }
2741
2811
  fill(color) {
2742
2812
  this.edgeDef.style = {
2743
2813
  ...(this.edgeDef.style || {}),
@@ -2854,6 +2924,9 @@ class EdgeBuilderImpl {
2854
2924
  edge(from, to, idOrOpts) {
2855
2925
  return this.parent.edge(from, to, idOrOpts);
2856
2926
  }
2927
+ danglingEdge(id, opts) {
2928
+ return this.parent.danglingEdge(id, opts);
2929
+ }
2857
2930
  overlay(arg1, arg2, arg3) {
2858
2931
  if (typeof arg1 === 'function')
2859
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';
@@ -571,12 +571,16 @@ export function patchRuntime(scene, ctx) {
571
571
  const line = ctx.edgeLinesById.get(edge.id);
572
572
  if (!group || !line)
573
573
  continue;
574
- const start = nodesById.get(edge.from);
575
- const end = nodesById.get(edge.to);
576
- 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)
577
581
  continue;
578
582
  let edgePath;
579
- if (start === end) {
583
+ if (start && end && start === end) {
580
584
  edgePath = computeSelfLoop(start, edge);
581
585
  }
582
586
  else {
@@ -585,11 +589,15 @@ export function patchRuntime(scene, ctx) {
585
589
  }
586
590
  if (edgePathResolver) {
587
591
  const defaultResolver = (e) => {
588
- const s = nodesById.get(e.from);
589
- const t = nodesById.get(e.to);
590
- 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)
591
599
  return '';
592
- if (s === t)
600
+ if (s && t && s === t)
593
601
  return computeSelfLoop(s, e).d;
594
602
  const endpoints = computeEdgeEndpoints(s, t, e);
595
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.1",
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",