vizcraft 1.5.0 → 1.7.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,17 @@
1
1
  # vizcraft
2
2
 
3
+ ## 1.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#98](https://github.com/ChipiKaf/vizcraft/pull/98) [`b513c21`](https://github.com/ChipiKaf/vizcraft/commit/b513c21308d115b0634cf63a0c63b3708684a172) Thanks [@ChipiKaf](https://github.com/ChipiKaf)! - Add `resolveEdgeGeometry(scene, edgeId)` convenience function that resolves all rendered geometry for an edge in a single call — node lookup, self-loop detection, port/angle/boundary anchors, waypoints, routing, SVG path, midpoint, and label positions. Also exports `resolveEdgeGeometryFromData` for batch processing with a pre-built node map.
8
+
9
+ ## 1.6.0
10
+
11
+ ### Minor Changes
12
+
13
+ - [`8020286`](https://github.com/ChipiKaf/vizcraft/commit/8020286d96d210f932a493184d820111e8c9ac7d) Thanks [@ChipiKaf](https://github.com/ChipiKaf)! - Added fromProt and toPort to attach edges to specific edges. Updated documentation to follow Diátaxis structure
14
+
3
15
  ## 1.5.0
4
16
 
5
17
  ### Minor Changes
package/README.md CHANGED
@@ -309,11 +309,16 @@ b.edge('srv', 'db').fromPort('out-1').toPort('in').arrow();
309
309
  // Default ports (no .port() needed) — every shape has built-in ports
310
310
  b.edge('a', 'b').fromPort('right').toPort('left').arrow();
311
311
 
312
- // Equidistant port distribution — N evenly spaced ports by perimeter arc length
313
- import { getEquidistantPorts, toNodePorts } from 'vizcraft';
314
- const ports = getEquidistantPorts({ kind: 'hexagon', r: 40 }, 6); // 6 ports
312
+ // Equidistant port distribution — stable, location-based IDs
313
+ import { getEquidistantPorts, toNodePorts, findPortNearest } from 'vizcraft';
314
+ const ports = getEquidistantPorts({ kind: 'rect', w: 120, h: 60 }, 8);
315
+ // → [{ id: 'top-0', … }, { id: 'top-1', … }, { id: 'right-0', … }, …]
315
316
  const nodePorts = toNodePorts(ports); // → NodePort[] ready for node.ports
316
317
 
318
+ // Snap to nearest port (node-local coordinates)
319
+ const nearest = findPortNearest(node, clickX - node.pos.x, clickY - node.pos.y);
320
+ if (nearest) b.edge('a', 'b').toPort(nearest.id);
321
+
317
322
  // Dangling edges — one or both endpoints at a free coordinate
318
323
  b.danglingEdge('preview').from('srv').toAt({ x: 300, y: 200 }).arrow().dashed();
319
324
 
@@ -321,6 +326,26 @@ b.danglingEdge('preview').from('srv').toAt({ x: 300, y: 200 }).arrow().dashed();
321
326
  b.danglingEdge('e1', { from: 'srv', toAt: { x: 300, y: 200 }, arrow: true });
322
327
  ```
323
328
 
329
+ #### Resolving edge geometry
330
+
331
+ `resolveEdgeGeometry(scene, edgeId)` resolves all rendered geometry for an edge in a single call — anchor points, SVG path, midpoint, label positions, waypoints, and self-loop detection:
332
+
333
+ ```ts
334
+ import { resolveEdgeGeometry } from 'vizcraft';
335
+
336
+ const geo = resolveEdgeGeometry(scene, 'edge-1');
337
+ if (!geo) return; // edge not found or unresolvable
338
+
339
+ overlayPath.setAttribute('d', geo.d); // SVG path
340
+ positionToolbar(geo.mid); // midpoint
341
+ drawHandle(geo.startAnchor); // source anchor
342
+ drawHandle(geo.endAnchor); // target anchor
343
+ geo.waypoints.forEach(drawDot); // waypoints
344
+ if (geo.isSelfLoop) {
345
+ /* ... */
346
+ } // self-loop flag
347
+ ```
348
+
324
349
  | Method | Description |
325
350
  | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
326
351
  | `.straight()` | Direct line (default). With waypoints → polyline. |
@@ -1,4 +1,5 @@
1
1
  export * from './paths';
2
2
  export * from './labels';
3
3
  export * from './styles';
4
+ export * from './resolveEdgeGeometry';
4
5
  export { EdgeBuilderImpl, applyEdgeOptions } from './builder';
@@ -1,4 +1,5 @@
1
1
  export * from './paths';
2
2
  export * from './labels';
3
3
  export * from './styles';
4
+ export * from './resolveEdgeGeometry';
4
5
  export { EdgeBuilderImpl, applyEdgeOptions } from './builder';
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Convenience function that resolves the full rendered geometry for an edge
3
+ * in a single call. Handles node lookup, dangling edges, self-loop detection,
4
+ * port/angle/boundary anchors, waypoints, and routing.
5
+ *
6
+ * @module
7
+ */
8
+ import type { Vec2, VizScene, VizEdge, VizNode } from '../types';
9
+ import type { EdgePathResult } from './paths';
10
+ /**
11
+ * Fully resolved edge geometry returned by {@link resolveEdgeGeometry}.
12
+ *
13
+ * Extends `EdgePathResult` with extra convenience fields so consumers
14
+ * never need to orchestrate multiple helpers manually.
15
+ */
16
+ export interface ResolvedEdgeGeometry extends EdgePathResult {
17
+ /** Source anchor position (alias of `start` from EdgePathResult, ~15% along path). */
18
+ startAnchor: Vec2;
19
+ /** Target anchor position (alias of `end` from EdgePathResult, ~85% along path). */
20
+ endAnchor: Vec2;
21
+ /** Waypoints used for the path (empty array when none). */
22
+ waypoints: Vec2[];
23
+ /** Whether this edge is a self-loop (same source and target node). */
24
+ isSelfLoop: boolean;
25
+ }
26
+ /**
27
+ * Resolve all rendered geometry for a single edge in one call.
28
+ *
29
+ * Returns `null` when:
30
+ * - The edge id is not found in the scene.
31
+ * - A referenced `from` / `to` node id does not exist.
32
+ * - Both endpoints are missing (no `from`/`to` and no `fromAt`/`toAt`).
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * import { resolveEdgeGeometry } from 'vizcraft';
37
+ *
38
+ * const geo = resolveEdgeGeometry(scene, 'edge-1');
39
+ * if (!geo) return;
40
+ * overlay.setAttribute('d', geo.d);
41
+ * positionToolbar(geo.mid);
42
+ * ```
43
+ */
44
+ export declare function resolveEdgeGeometry(scene: VizScene, edgeId: string): ResolvedEdgeGeometry | null;
45
+ /**
46
+ * Lower-level helper: resolve geometry from an edge + a node lookup map.
47
+ *
48
+ * Useful when the caller already has a `Map` built (e.g. inside a render loop
49
+ * processing many edges).
50
+ *
51
+ * @internal exported for advanced consumers and testing — prefer
52
+ * {@link resolveEdgeGeometry} for typical usage.
53
+ */
54
+ export declare function resolveEdgeGeometryFromData(edge: VizEdge, nodesById: Map<string, VizNode>): ResolvedEdgeGeometry | null;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Convenience function that resolves the full rendered geometry for an edge
3
+ * in a single call. Handles node lookup, dangling edges, self-loop detection,
4
+ * port/angle/boundary anchors, waypoints, and routing.
5
+ *
6
+ * @module
7
+ */
8
+ import { computeEdgeEndpoints, computeEdgePath, computeSelfLoop, } from './paths';
9
+ // ── Implementation ──────────────────────────────────────────────────────────
10
+ /**
11
+ * Resolve all rendered geometry for a single edge in one call.
12
+ *
13
+ * Returns `null` when:
14
+ * - The edge id is not found in the scene.
15
+ * - A referenced `from` / `to` node id does not exist.
16
+ * - Both endpoints are missing (no `from`/`to` and no `fromAt`/`toAt`).
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * import { resolveEdgeGeometry } from 'vizcraft';
21
+ *
22
+ * const geo = resolveEdgeGeometry(scene, 'edge-1');
23
+ * if (!geo) return;
24
+ * overlay.setAttribute('d', geo.d);
25
+ * positionToolbar(geo.mid);
26
+ * ```
27
+ */
28
+ export function resolveEdgeGeometry(scene, edgeId) {
29
+ const edge = scene.edges.find((e) => e.id === edgeId);
30
+ if (!edge)
31
+ return null;
32
+ const nodesById = new Map(scene.nodes.map((n) => [n.id, n]));
33
+ return resolveEdgeGeometryFromData(edge, nodesById);
34
+ }
35
+ /**
36
+ * Lower-level helper: resolve geometry from an edge + a node lookup map.
37
+ *
38
+ * Useful when the caller already has a `Map` built (e.g. inside a render loop
39
+ * processing many edges).
40
+ *
41
+ * @internal exported for advanced consumers and testing — prefer
42
+ * {@link resolveEdgeGeometry} for typical usage.
43
+ */
44
+ export function resolveEdgeGeometryFromData(edge, nodesById) {
45
+ // ── Node lookup (null-safe for dangling edges) ──────────────────────────
46
+ const startNode = edge.from ? (nodesById.get(edge.from) ?? null) : null;
47
+ const endNode = edge.to ? (nodesById.get(edge.to) ?? null) : null;
48
+ // Bail out if a referenced node id doesn't exist.
49
+ if (edge.from && !startNode)
50
+ return null;
51
+ if (edge.to && !endNode)
52
+ return null;
53
+ // Bail out if both endpoints are entirely unresolvable.
54
+ if (!startNode && !edge.fromAt && !endNode && !edge.toAt)
55
+ return null;
56
+ // ── Self-loop detection ─────────────────────────────────────────────────
57
+ const isSelfLoop = !!(startNode && endNode && startNode === endNode);
58
+ let pathResult;
59
+ if (isSelfLoop) {
60
+ pathResult = computeSelfLoop(startNode, edge);
61
+ }
62
+ else {
63
+ const endpoints = computeEdgeEndpoints(startNode, endNode, edge);
64
+ pathResult = computeEdgePath(endpoints.start, endpoints.end, edge.routing, edge.waypoints);
65
+ }
66
+ return {
67
+ ...pathResult,
68
+ startAnchor: pathResult.start,
69
+ endAnchor: pathResult.end,
70
+ waypoints: edge.waypoints ?? [],
71
+ isSelfLoop,
72
+ };
73
+ }
package/dist/index.d.ts CHANGED
@@ -8,6 +8,7 @@ export * from './overlays/builder';
8
8
  export * from './edges/paths';
9
9
  export * from './edges/labels';
10
10
  export * from './edges/styles';
11
+ export * from './edges/resolveEdgeGeometry';
11
12
  export { getDefaultPorts, getNodePorts, findPort, resolvePortPosition, computeNodeAnchorAtAngle, } from './shapes/geometry';
12
13
  export * from './animation/spec';
13
14
  export * from './animation/builder';
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ export * from './overlays/builder';
8
8
  export * from './edges/paths';
9
9
  export * from './edges/labels';
10
10
  export * from './edges/styles';
11
+ export * from './edges/resolveEdgeGeometry';
11
12
  export { getDefaultPorts, getNodePorts, findPort, resolvePortPosition, computeNodeAnchorAtAngle, } from './shapes/geometry';
12
13
  export * from './animation/spec';
13
14
  export * from './animation/builder';
@@ -1,2 +1,2 @@
1
1
  export type { EquidistantPort, PerimeterStrategy } from './types';
2
- export { getEquidistantPorts, toNodePorts, registerPerimeterStrategy, } from './registry';
2
+ export { getEquidistantPorts, toNodePorts, findPortNearest, registerPerimeterStrategy, } from './registry';
@@ -1 +1 @@
1
- export { getEquidistantPorts, toNodePorts, registerPerimeterStrategy, } from './registry';
1
+ export { getEquidistantPorts, toNodePorts, findPortNearest, registerPerimeterStrategy, } from './registry';
@@ -1,4 +1,4 @@
1
- import type { NodePort, NodeShape } from '../../types';
1
+ import type { NodePort, NodeShape, VizNode } from '../../types';
2
2
  import type { EquidistantPort, PerimeterStrategy } from './types';
3
3
  /** Register (or replace) a {@link PerimeterStrategy} for a shape kind. */
4
4
  export declare function registerPerimeterStrategy<K extends NodeShape['kind']>(strategy: PerimeterStrategy<K>): void;
@@ -7,9 +7,31 @@ export declare function registerPerimeterStrategy<K extends NodeShape['kind']>(s
7
7
  * Delegates to a registered {@link PerimeterStrategy} or falls back to
8
8
  * a bounding-box rectangle approximation.
9
9
  *
10
+ * Port IDs are **location-based** and stable across count changes:
11
+ * - Polygon shapes with named sides: `{side}-{index}` (e.g. `top-0`,
12
+ * `right-1`).
13
+ * - Curved / complex shapes: `{angleBucket}-{index}` (e.g. `0-0`,
14
+ * `270-1`).
15
+ *
10
16
  * @param shape - The node shape specification.
11
17
  * @param count - Number of ports (uses a shape-specific default when omitted).
12
18
  */
13
19
  export declare function getEquidistantPorts(shape: NodeShape, count?: number): EquidistantPort[];
14
20
  /** Convert equidistant ports to `NodePort[]` with `offset` and `direction`. */
15
21
  export declare function toNodePorts(ports: readonly EquidistantPort[]): NodePort[];
22
+ /**
23
+ * Return the port on `node` closest to the given point (in **node-local
24
+ * coordinates**, i.e. relative to the node center).
25
+ *
26
+ * Uses the node's effective ports ({@link getNodePorts} — explicit
27
+ * `node.ports` when set, otherwise shape defaults).
28
+ *
29
+ * Returns `undefined` when the node has no ports.
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * const port = findPortNearest(node, clickX - node.x, clickY - node.y);
34
+ * if (port) edgeBuilder.toPort(port.id);
35
+ * ```
36
+ */
37
+ export declare function findPortNearest(node: VizNode, x: number, y: number): NodePort | undefined;
@@ -1,3 +1,4 @@
1
+ import { getNodePorts } from '../../shapes/geometry';
1
2
  import { walkPolygonEquidistant } from './utils';
2
3
  import { builtInStrategies } from './strategies';
3
4
  const FALLBACK_COUNT = 8;
@@ -63,6 +64,7 @@ function shapeBoundingBox(shape) {
63
64
  return { hw: 0, hh: 0 };
64
65
  }
65
66
  }
67
+ const BBOX_SIDES = ['top', 'right', 'bottom', 'left'];
66
68
  function boundingBoxFallback(shape, count) {
67
69
  const { hw, hh } = shapeBoundingBox(shape);
68
70
  return walkPolygonEquidistant([
@@ -70,13 +72,19 @@ function boundingBoxFallback(shape, count) {
70
72
  { x: hw, y: -hh },
71
73
  { x: hw, y: hh },
72
74
  { x: -hw, y: hh },
73
- ], count);
75
+ ], count, BBOX_SIDES);
74
76
  }
75
77
  /**
76
78
  * Compute N equidistant points along a shape's perimeter by arc length.
77
79
  * Delegates to a registered {@link PerimeterStrategy} or falls back to
78
80
  * a bounding-box rectangle approximation.
79
81
  *
82
+ * Port IDs are **location-based** and stable across count changes:
83
+ * - Polygon shapes with named sides: `{side}-{index}` (e.g. `top-0`,
84
+ * `right-1`).
85
+ * - Curved / complex shapes: `{angleBucket}-{index}` (e.g. `0-0`,
86
+ * `270-1`).
87
+ *
80
88
  * @param shape - The node shape specification.
81
89
  * @param count - Number of ports (uses a shape-specific default when omitted).
82
90
  */
@@ -102,3 +110,35 @@ export function toNodePorts(ports) {
102
110
  direction: p.angle,
103
111
  }));
104
112
  }
113
+ /**
114
+ * Return the port on `node` closest to the given point (in **node-local
115
+ * coordinates**, i.e. relative to the node center).
116
+ *
117
+ * Uses the node's effective ports ({@link getNodePorts} — explicit
118
+ * `node.ports` when set, otherwise shape defaults).
119
+ *
120
+ * Returns `undefined` when the node has no ports.
121
+ *
122
+ * @example
123
+ * ```ts
124
+ * const port = findPortNearest(node, clickX - node.x, clickY - node.y);
125
+ * if (port) edgeBuilder.toPort(port.id);
126
+ * ```
127
+ */
128
+ export function findPortNearest(node, x, y) {
129
+ const ports = getNodePorts(node);
130
+ if (ports.length === 0)
131
+ return undefined;
132
+ let nearest;
133
+ let bestDist = Infinity;
134
+ for (const port of ports) {
135
+ const dx = port.offset.x - x;
136
+ const dy = port.offset.y - y;
137
+ const d = dx * dx + dy * dy;
138
+ if (d < bestDist) {
139
+ bestDist = d;
140
+ nearest = port;
141
+ }
142
+ }
143
+ return nearest;
144
+ }
@@ -1,4 +1,4 @@
1
- import { RAD, portFromPoint } from '../utils';
1
+ import { RAD, portFromPoint, assignAngleBucketIds } from '../utils';
2
2
  function circleEquidistant(r, count) {
3
3
  const ports = [];
4
4
  for (let i = 0; i < count; i++) {
@@ -6,7 +6,7 @@ function circleEquidistant(r, count) {
6
6
  const aRad = aDeg * RAD;
7
7
  ports.push(portFromPoint({ x: r * Math.cos(aRad), y: r * Math.sin(aRad) }, i, i / count));
8
8
  }
9
- return ports;
9
+ return assignAngleBucketIds(ports);
10
10
  }
11
11
  export const circleStrategy = {
12
12
  kind: 'circle',
@@ -9,4 +9,10 @@ function diamondVertices(w, h) {
9
9
  { x: -hw, y: 0 },
10
10
  ];
11
11
  }
12
- export const diamondStrategy = polygonStrategy('diamond', 4, (s) => diamondVertices(s.w, s.h));
12
+ const DIAMOND_SIDES = [
13
+ 'top-right',
14
+ 'bottom-right',
15
+ 'bottom-left',
16
+ 'top-left',
17
+ ];
18
+ export const diamondStrategy = polygonStrategy('diamond', 4, (s) => diamondVertices(s.w, s.h), () => DIAMOND_SIDES);
@@ -8,4 +8,24 @@ function hexagonVertices(r, orientation) {
8
8
  }
9
9
  return verts;
10
10
  }
11
- export const hexagonStrategy = polygonStrategy('hexagon', 6, (s) => hexagonVertices(s.r, s.orientation ?? 'pointy'));
11
+ function hexagonSideLabels(orientation) {
12
+ if (orientation === 'pointy') {
13
+ return [
14
+ 'top-right',
15
+ 'right',
16
+ 'bottom-right',
17
+ 'bottom-left',
18
+ 'left',
19
+ 'top-left',
20
+ ];
21
+ }
22
+ return [
23
+ 'bottom-right',
24
+ 'bottom',
25
+ 'bottom-left',
26
+ 'top-left',
27
+ 'top',
28
+ 'top-right',
29
+ ];
30
+ }
31
+ export const hexagonStrategy = polygonStrategy('hexagon', 6, (s) => hexagonVertices(s.r, s.orientation ?? 'pointy'), (s) => hexagonSideLabels(s.orientation ?? 'pointy'));
@@ -10,4 +10,5 @@ function parallelogramVertices(w, h, skew) {
10
10
  { x: -hw - half, y: hh },
11
11
  ];
12
12
  }
13
- export const parallelogramStrategy = polygonStrategy('parallelogram', 8, (s) => parallelogramVertices(s.w, s.h, s.skew ?? Math.round(s.w * 0.2)));
13
+ const PARALLELOGRAM_SIDES = ['top', 'right', 'bottom', 'left'];
14
+ export const parallelogramStrategy = polygonStrategy('parallelogram', 8, (s) => parallelogramVertices(s.w, s.h, s.skew ?? Math.round(s.w * 0.2)), () => PARALLELOGRAM_SIDES);
@@ -9,4 +9,5 @@ function rectVertices(w, h) {
9
9
  { x: -hw, y: hh },
10
10
  ];
11
11
  }
12
- export const rectStrategy = polygonStrategy('rect', 8, (s) => rectVertices(s.w, s.h));
12
+ const RECT_SIDES = ['top', 'right', 'bottom', 'left'];
13
+ export const rectStrategy = polygonStrategy('rect', 8, (s) => rectVertices(s.w, s.h), () => RECT_SIDES);
@@ -10,4 +10,5 @@ function trapezoidVertices(topW, bottomW, h) {
10
10
  { x: -hbw, y: hh },
11
11
  ];
12
12
  }
13
- export const trapezoidStrategy = polygonStrategy('trapezoid', 8, (s) => trapezoidVertices(s.topW, s.bottomW, s.h));
13
+ const TRAPEZOID_SIDES = ['top', 'right', 'bottom', 'left'];
14
+ export const trapezoidStrategy = polygonStrategy('trapezoid', 8, (s) => trapezoidVertices(s.topW, s.bottomW, s.h), () => TRAPEZOID_SIDES);
@@ -29,4 +29,16 @@ function triangleVertices(w, h, direction) {
29
29
  ];
30
30
  }
31
31
  }
32
- export const triangleStrategy = polygonStrategy('triangle', 6, (s) => triangleVertices(s.w, s.h, s.direction ?? 'up'));
32
+ function triangleSideLabels(direction) {
33
+ switch (direction) {
34
+ case 'up':
35
+ return ['right', 'bottom', 'left'];
36
+ case 'down':
37
+ return ['left', 'top', 'right'];
38
+ case 'left':
39
+ return ['top', 'right', 'bottom'];
40
+ case 'right':
41
+ return ['bottom', 'left', 'top'];
42
+ }
43
+ }
44
+ export const triangleStrategy = polygonStrategy('triangle', 6, (s) => triangleVertices(s.w, s.h, s.direction ?? 'up'), (s) => triangleSideLabels(s.direction ?? 'up'));
@@ -4,15 +4,45 @@ export declare const DEG: number;
4
4
  export declare const RAD: number;
5
5
  export declare const ARC_SAMPLES = 720;
6
6
  export declare function portFromPoint(pt: Vec2, i: number, t: number): EquidistantPort;
7
- /** Place `count` equidistant points along a closed polygon by arc length. */
8
- export declare function walkPolygonEquidistant(vertices: Vec2[], count: number): EquidistantPort[];
9
- /** Create a polygon strategy from a vertex extractor. */
7
+ /**
8
+ * Assign angle-bucket IDs to ports based on their angle from center.
9
+ *
10
+ * Each port is bucketed into a 90° quadrant (0, 90, 180, 270) and given
11
+ * an index within that quadrant. For example, a port at 45° becomes `0-0`
12
+ * (first port in the 0°–90° quadrant).
13
+ *
14
+ * This scheme is stable across count changes — ports near the same angle
15
+ * keep the same ID prefix regardless of total port count.
16
+ */
17
+ export declare function assignAngleBucketIds(ports: readonly EquidistantPort[]): EquidistantPort[];
18
+ /**
19
+ * Place `count` equidistant points along a closed polygon by arc length.
20
+ *
21
+ * When `sideLabels` is provided (one label per edge), each port receives a
22
+ * location-based ID in the format `{label}-{indexWithinSide}` (e.g.
23
+ * `top-0`, `right-1`). Without labels, ports fall back to sequential
24
+ * `p0`–`pN` IDs.
25
+ */
26
+ export declare function walkPolygonEquidistant(vertices: Vec2[], count: number, sideLabels?: readonly string[]): EquidistantPort[];
27
+ /**
28
+ * Create a polygon strategy from a vertex extractor.
29
+ *
30
+ * When `extractSideLabels` is provided, ports receive location-based IDs
31
+ * (e.g. `top-0`, `right-1`). Otherwise, angle-bucket IDs are assigned
32
+ * (e.g. `0-0`, `90-1`).
33
+ */
10
34
  export declare function polygonStrategy<K extends NodeShape['kind']>(kind: K, defaultCount: number | ((shape: Extract<NodeShape, {
11
35
  kind: K;
12
36
  }>) => number), extractVertices: (shape: Extract<NodeShape, {
13
37
  kind: K;
14
- }>) => Vec2[]): PerimeterStrategy<K>;
15
- /** Create a curved-shape strategy from a perimeter sampler. */
38
+ }>) => Vec2[], extractSideLabels?: (shape: Extract<NodeShape, {
39
+ kind: K;
40
+ }>) => readonly string[]): PerimeterStrategy<K>;
41
+ /**
42
+ * Create a curved-shape strategy from a perimeter sampler.
43
+ *
44
+ * Ports receive angle-bucket IDs (e.g. `0-0`, `90-1`).
45
+ */
16
46
  export declare function sampledCurveStrategy<K extends NodeShape['kind']>(kind: K, defaultCount: number, samplePerimeter: (shape: Extract<NodeShape, {
17
47
  kind: K;
18
48
  }>) => Vec2[]): PerimeterStrategy<K>;
@@ -22,8 +22,34 @@ export function portFromPoint(pt, i, t) {
22
22
  y: pt.y,
23
23
  };
24
24
  }
25
- /** Place `count` equidistant points along a closed polygon by arc length. */
26
- export function walkPolygonEquidistant(vertices, count) {
25
+ /**
26
+ * Assign angle-bucket IDs to ports based on their angle from center.
27
+ *
28
+ * Each port is bucketed into a 90° quadrant (0, 90, 180, 270) and given
29
+ * an index within that quadrant. For example, a port at 45° becomes `0-0`
30
+ * (first port in the 0°–90° quadrant).
31
+ *
32
+ * This scheme is stable across count changes — ports near the same angle
33
+ * keep the same ID prefix regardless of total port count.
34
+ */
35
+ export function assignAngleBucketIds(ports) {
36
+ const bucketCounts = new Map();
37
+ return ports.map((p) => {
38
+ const bucket = Math.floor(normalizeAngle(p.angle) / 90) * 90;
39
+ const idx = bucketCounts.get(bucket) ?? 0;
40
+ bucketCounts.set(bucket, idx + 1);
41
+ return { ...p, id: `${bucket}-${idx}` };
42
+ });
43
+ }
44
+ /**
45
+ * Place `count` equidistant points along a closed polygon by arc length.
46
+ *
47
+ * When `sideLabels` is provided (one label per edge), each port receives a
48
+ * location-based ID in the format `{label}-{indexWithinSide}` (e.g.
49
+ * `top-0`, `right-1`). Without labels, ports fall back to sequential
50
+ * `p0`–`pN` IDs.
51
+ */
52
+ export function walkPolygonEquidistant(vertices, count, sideLabels) {
27
53
  const n = vertices.length;
28
54
  if (n === 0 || count <= 0)
29
55
  return [];
@@ -38,6 +64,8 @@ export function walkPolygonEquidistant(vertices, count) {
38
64
  const segLen = perimeter / count;
39
65
  const ports = [];
40
66
  let edgeIdx = 0;
67
+ // Track per-side port counts for side-based IDs
68
+ const sideCounts = sideLabels ? new Array(n).fill(0) : [];
41
69
  for (let i = 0; i < count; i++) {
42
70
  const target = i * segLen;
43
71
  while (edgeIdx < n - 1 && cumDist[edgeIdx + 1] <= target)
@@ -48,7 +76,24 @@ export function walkPolygonEquidistant(vertices, count) {
48
76
  const frac = edgeLen > 0 ? (target - edgeStart) / edgeLen : 0;
49
77
  const a = vertices[edgeIdx];
50
78
  const b = vertices[(edgeIdx + 1) % n];
51
- ports.push(portFromPoint(lerp(a, b, frac), i, target / perimeter));
79
+ const pt = lerp(a, b, frac);
80
+ let id;
81
+ if (sideLabels) {
82
+ const sideLabel = sideLabels[edgeIdx];
83
+ const sideIdx = sideCounts[edgeIdx];
84
+ sideCounts[edgeIdx] = sideIdx + 1;
85
+ id = `${sideLabel}-${sideIdx}`;
86
+ }
87
+ else {
88
+ id = `p${i}`;
89
+ }
90
+ ports.push({
91
+ id,
92
+ angle: normalizeAngle(angleDeg(pt)),
93
+ t: target / perimeter,
94
+ x: pt.x,
95
+ y: pt.y,
96
+ });
52
97
  }
53
98
  return ports;
54
99
  }
@@ -90,19 +135,34 @@ function walkSampledCurveEquidistant(pts, count) {
90
135
  }
91
136
  return ports;
92
137
  }
93
- /** Create a polygon strategy from a vertex extractor. */
94
- export function polygonStrategy(kind, defaultCount, extractVertices) {
138
+ /**
139
+ * Create a polygon strategy from a vertex extractor.
140
+ *
141
+ * When `extractSideLabels` is provided, ports receive location-based IDs
142
+ * (e.g. `top-0`, `right-1`). Otherwise, angle-bucket IDs are assigned
143
+ * (e.g. `0-0`, `90-1`).
144
+ */
145
+ export function polygonStrategy(kind, defaultCount, extractVertices, extractSideLabels) {
95
146
  return {
96
147
  kind,
97
148
  defaultCount,
98
- computePorts: (shape, count) => walkPolygonEquidistant(extractVertices(shape), count),
149
+ computePorts: (shape, count) => {
150
+ const vertices = extractVertices(shape);
151
+ const labels = extractSideLabels?.(shape);
152
+ const ports = walkPolygonEquidistant(vertices, count, labels);
153
+ return labels ? ports : assignAngleBucketIds(ports);
154
+ },
99
155
  };
100
156
  }
101
- /** Create a curved-shape strategy from a perimeter sampler. */
157
+ /**
158
+ * Create a curved-shape strategy from a perimeter sampler.
159
+ *
160
+ * Ports receive angle-bucket IDs (e.g. `0-0`, `90-1`).
161
+ */
102
162
  export function sampledCurveStrategy(kind, defaultCount, samplePerimeter) {
103
163
  return {
104
164
  kind,
105
165
  defaultCount,
106
- computePorts: (shape, count) => walkSampledCurveEquidistant(samplePerimeter(shape), count),
166
+ computePorts: (shape, count) => assignAngleBucketIds(walkSampledCurveEquidistant(samplePerimeter(shape), count)),
107
167
  };
108
168
  }
@@ -40,6 +40,11 @@ export declare function getDefaultPorts(shape: NodeShape): NodePort[];
40
40
  export declare function getNodePorts(node: VizNode): NodePort[];
41
41
  /**
42
42
  * Find a port on a node by its id. Returns `undefined` if not found.
43
+ *
44
+ * **Legacy compatibility:** if `portId` matches the old sequential format
45
+ * (`p0`, `p1`, …) and no port with that exact ID exists, the port at
46
+ * that numeric index is returned instead. This eases migration from the
47
+ * previous `p0`–`pN` naming to the new location-based IDs.
43
48
  */
44
49
  export declare function findPort(node: VizNode, portId: string): NodePort | undefined;
45
50
  /**
@@ -1372,9 +1372,25 @@ export function getNodePorts(node) {
1372
1372
  }
1373
1373
  /**
1374
1374
  * Find a port on a node by its id. Returns `undefined` if not found.
1375
+ *
1376
+ * **Legacy compatibility:** if `portId` matches the old sequential format
1377
+ * (`p0`, `p1`, …) and no port with that exact ID exists, the port at
1378
+ * that numeric index is returned instead. This eases migration from the
1379
+ * previous `p0`–`pN` naming to the new location-based IDs.
1375
1380
  */
1376
1381
  export function findPort(node, portId) {
1377
- return getNodePorts(node).find((p) => p.id === portId);
1382
+ const ports = getNodePorts(node);
1383
+ const exact = ports.find((p) => p.id === portId);
1384
+ if (exact)
1385
+ return exact;
1386
+ // Legacy fallback: treat 'p3' as "port at index 3"
1387
+ const legacy = /^p(\d+)$/.exec(portId);
1388
+ if (legacy) {
1389
+ const idx = Number(legacy[1]);
1390
+ if (idx >= 0 && idx < ports.length)
1391
+ return ports[idx];
1392
+ }
1393
+ return undefined;
1378
1394
  }
1379
1395
  /**
1380
1396
  * Resolve a port to an absolute position (node center + port offset).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vizcraft",
3
- "version": "1.5.0",
3
+ "version": "1.7.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",