@neta-art/cohub 5.3.2 → 5.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.
Files changed (44) hide show
  1. package/dist/board/codec.d.ts +2 -0
  2. package/dist/board/codec.js +18 -13
  3. package/dist/board/core/arrow-geometry.d.ts +23 -0
  4. package/dist/board/core/arrow-geometry.js +109 -0
  5. package/dist/board/core/connections.d.ts +83 -0
  6. package/dist/board/core/connections.js +399 -0
  7. package/dist/board/core/export-plan.d.ts +15 -4
  8. package/dist/board/core/export-plan.js +49 -16
  9. package/dist/board/core/shape-definition.js +1 -1
  10. package/dist/board/core/shape-types.d.ts +8 -2
  11. package/dist/board/core/shape-types.js +1 -1
  12. package/dist/board/core/tool-styles.d.ts +9 -3
  13. package/dist/board/core/tool-styles.js +10 -6
  14. package/dist/board/export/index.js +1 -0
  15. package/dist/board/export/scene.d.ts +3 -0
  16. package/dist/board/export/scene.js +18 -1
  17. package/dist/board/index.d.ts +8 -5
  18. package/dist/board/index.js +8 -5
  19. package/dist/board/render/connection-layer.d.ts +48 -0
  20. package/dist/board/render/connection-layer.js +223 -0
  21. package/dist/board/render/index.d.ts +2 -1
  22. package/dist/board/render/index.js +3 -2
  23. package/dist/board/render/renderers/arrow-card-renderer.js +35 -48
  24. package/dist/chunks/http.d.ts +53 -1
  25. package/dist/chunks/http.js +287 -1
  26. package/dist/chunks/websocket.d.ts +296 -57
  27. package/dist/http.d.ts +1 -1
  28. package/dist/index.d.ts +9 -3
  29. package/dist/index.js +22 -58
  30. package/dist/protocol/dist/board-connection.d.ts +310 -0
  31. package/dist/protocol/dist/board-connection.js +215 -0
  32. package/dist/protocol/dist/board-constants.d.ts +11 -1
  33. package/dist/protocol/dist/board-constants.js +15 -1
  34. package/dist/protocol/dist/board-document.d.ts +106 -60
  35. package/dist/protocol/dist/board-document.js +57 -17
  36. package/dist/protocol/dist/board.d.ts +1 -0
  37. package/dist/protocol/dist/board.js +3 -0
  38. package/dist/protocol/dist/index.d.ts +2 -1
  39. package/dist/protocol/dist/index.js +2 -1
  40. package/dist/protocol/dist/realtime/board-awareness.js +15 -15
  41. package/docs/work-runtime-guide.md +1 -1
  42. package/package.json +1 -1
  43. package/dist/board/core/bindings.d.ts +0 -45
  44. package/dist/board/core/bindings.js +0 -162
@@ -1,3 +1,4 @@
1
+ import { BoardConnectionRecord } from "../protocol/dist/board-connection.js";
1
2
  import { BoardManifest, BoardNodeInput, BoardNodeRecord, BoardRecord, InvalidBoardFileError, isBoardPath, parseBoardManifest, serializeBoardManifest } from "../protocol/dist/board.js";
2
3
  import "../protocol/dist/index.js";
3
4
  import { BoardAppearance, BoardDocument, BoardItem } from "../protocol/dist/board-document.js";
@@ -20,6 +21,7 @@ declare function boardNodeToItem(node: BoardNodeRecord): BoardItem;
20
21
  declare function boardBootstrapToDocument(input: {
21
22
  board: BoardRecord;
22
23
  nodes: BoardNodeRecord[];
24
+ connections?: BoardConnectionRecord[];
23
25
  }): BoardDocument;
24
26
  //#endregion
25
27
  export { BOARD_NODE_SOURCE, type BoardManifest, type BoardNodeRecord, type BoardRecord, DEFAULT_BOARD_APPEARANCE, ITEM_BASE_KEYS, InvalidBoardFileError, WireBackedBoardItem, boardBootstrapToDocument, boardNodeToItem, isBoardPath, isRecord, nodeInputFromRecord, parseBoardManifest, serializeBoardManifest, sourceForItem };
@@ -1,5 +1,5 @@
1
1
  import { BOARD_DOCUMENT_KIND, InvalidBoardFileError, isBoardPath, parseBoardManifest, serializeBoardManifest } from "../protocol/dist/board.js";
2
- import { BoardAppearanceSchema, BoardDocumentSchema, BoardFileSnapshotSchema, UNKNOWN_BOARD_ITEM_TYPE } from "../protocol/dist/board-document.js";
2
+ import { BoardAppearanceSchema, BoardFileSnapshotSchema, UNKNOWN_BOARD_ITEM_TYPE, parseBoardDocument } from "../protocol/dist/board-document.js";
3
3
  import { TEXT_FONT_SIZE, clampBoardTextFontSize } from "./core/text-metrics.js";
4
4
  import "../protocol/dist/index.js";
5
5
  //#region src/board/codec.ts
@@ -72,6 +72,17 @@ function spaceFilePathFromNode(node) {
72
72
  if (typeof node.refPath === "string" && node.refPath) return node.refPath;
73
73
  return null;
74
74
  }
75
+ /** Read a stored world point, defaulting to the origin when absent. */
76
+ function pointFromData(value) {
77
+ if (isRecord(value) && typeof value.x === "number" && typeof value.y === "number") return {
78
+ x: value.x,
79
+ y: value.y
80
+ };
81
+ return {
82
+ x: 0,
83
+ y: 0
84
+ };
85
+ }
75
86
  function boardNodeToItemValue(node) {
76
87
  const frame = frameFromNode(node);
77
88
  const style = styleFromNode(node);
@@ -112,16 +123,8 @@ function boardNodeToItemValue(node) {
112
123
  case "arrow": return {
113
124
  id: node.nodeId,
114
125
  type: "arrow",
115
- start: data.start ?? {
116
- kind: "point",
117
- x: 0,
118
- y: 0
119
- },
120
- end: data.end ?? {
121
- kind: "point",
122
- x: 0,
123
- y: 0
124
- },
126
+ start: pointFromData(data.start),
127
+ end: pointFromData(data.end),
125
128
  bend: typeof data.bend === "number" ? data.bend : 0,
126
129
  color: typeof data.color === "string" ? data.color : "brand",
127
130
  size: typeof data.size === "number" ? data.size : 2.5,
@@ -219,7 +222,8 @@ function boardNodeToItem(node) {
219
222
  }
220
223
  function boardBootstrapToDocument(input) {
221
224
  const appearance = BoardAppearanceSchema.safeParse(input.board.metadata.appearance);
222
- const document = BoardDocumentSchema.parse({
225
+ const connections = (input.connections ?? []).map(({ boardId: _boardId, revision: _revision, createdAt: _createdAt, updatedAt: _updatedAt, ...connection }) => connection);
226
+ const document = parseBoardDocument({
223
227
  kind: BOARD_DOCUMENT_KIND,
224
228
  version: 1,
225
229
  appearance: appearance.success ? appearance.data : DEFAULT_BOARD_APPEARANCE,
@@ -228,7 +232,8 @@ function boardBootstrapToDocument(input) {
228
232
  y: 0,
229
233
  zoom: 1
230
234
  },
231
- items: input.nodes.map(boardNodeToItem)
235
+ items: input.nodes.map(boardNodeToItem),
236
+ connections
232
237
  });
233
238
  const sources = new Map(input.nodes.map((node) => [node.nodeId, nodeInputFromRecord(node)]));
234
239
  return {
@@ -0,0 +1,23 @@
1
+ import { BoardArrowItem, BoardFrame } from "../../protocol/dist/board-document.js";
2
+ import { Rect, WorldPoint } from "../geometry.js";
3
+ //#region src/board/core/arrow-geometry.d.ts
4
+ type ResolvedArrow = {
5
+ start: WorldPoint;
6
+ end: WorldPoint;
7
+ /** Quadratic control point (already bent); the midpoint when bend is 0. */
8
+ control: WorldPoint;
9
+ };
10
+ /** Resolve an arrow's endpoints and its bent control point. */
11
+ declare function resolveArrow(item: BoardArrowItem): ResolvedArrow;
12
+ /** Sample an arrow's quadratic curve into world points. */
13
+ declare function sampleArrow(resolved: ResolvedArrow, segments: number): WorldPoint[];
14
+ /** World bounds of an arrow, padded for its stroke. */
15
+ declare function arrowBounds(item: BoardArrowItem): Rect;
16
+ /** The frame an arrow should carry, derived from its endpoints. */
17
+ declare function arrowFrame(item: BoardArrowItem): BoardFrame;
18
+ /** Distance from a world point to an arrow's curve (hit testing). */
19
+ declare function distanceToArrow(item: BoardArrowItem, point: WorldPoint): number;
20
+ /** Translate an arrow by a delta, keeping its frame consistent. */
21
+ declare function translateArrow(item: BoardArrowItem, dx: number, dy: number): BoardArrowItem;
22
+ //#endregion
23
+ export { ResolvedArrow, arrowBounds, arrowFrame, distanceToArrow, resolveArrow, sampleArrow, translateArrow };
@@ -0,0 +1,109 @@
1
+ import { worldPoint } from "../geometry.js";
2
+ //#region src/board/core/arrow-geometry.ts
3
+ /**
4
+ * Free-arrow geometry.
5
+ *
6
+ * An arrow is a standalone annotation stroke between two world points — it does
7
+ * not relate nodes. Node relations are `BoardConnection`s, which resolve their
8
+ * geometry from the nodes they join (see ./connections). Keeping the two apart is
9
+ * what makes each one simple: an arrow owns absolute coordinates and needs no
10
+ * lookup, while a connection owns no coordinates at all.
11
+ */
12
+ /** Resolve an arrow's endpoints and its bent control point. */
13
+ function resolveArrow(item) {
14
+ const start = worldPoint(item.start.x, item.start.y);
15
+ const end = worldPoint(item.end.x, item.end.y);
16
+ const mid = worldPoint((start.x + end.x) / 2, (start.y + end.y) / 2);
17
+ if (!item.bend) return {
18
+ start,
19
+ end,
20
+ control: mid
21
+ };
22
+ const dx = end.x - start.x;
23
+ const dy = end.y - start.y;
24
+ const length = Math.hypot(dx, dy) || 1;
25
+ const offset = item.bend * length;
26
+ return {
27
+ start,
28
+ end,
29
+ control: worldPoint(mid.x + -dy / length * offset, mid.y + dx / length * offset)
30
+ };
31
+ }
32
+ /** Sample an arrow's quadratic curve into world points. */
33
+ function sampleArrow(resolved, segments) {
34
+ const { start, control, end } = resolved;
35
+ const out = [];
36
+ for (let index = 0; index <= segments; index += 1) {
37
+ const t = index / segments;
38
+ const mt = 1 - t;
39
+ out.push(worldPoint(mt * mt * start.x + 2 * mt * t * control.x + t * t * end.x, mt * mt * start.y + 2 * mt * t * control.y + t * t * end.y));
40
+ }
41
+ return out;
42
+ }
43
+ /** World bounds of an arrow, padded for its stroke. */
44
+ function arrowBounds(item) {
45
+ const samples = sampleArrow(resolveArrow(item), 12);
46
+ let minX = Number.POSITIVE_INFINITY;
47
+ let minY = Number.POSITIVE_INFINITY;
48
+ let maxX = Number.NEGATIVE_INFINITY;
49
+ let maxY = Number.NEGATIVE_INFINITY;
50
+ for (const point of samples) {
51
+ minX = Math.min(minX, point.x);
52
+ minY = Math.min(minY, point.y);
53
+ maxX = Math.max(maxX, point.x);
54
+ maxY = Math.max(maxY, point.y);
55
+ }
56
+ const pad = Math.max(8, item.size * 2);
57
+ return {
58
+ x: minX - pad,
59
+ y: minY - pad,
60
+ width: Math.max(1, maxX - minX + pad * 2),
61
+ height: Math.max(1, maxY - minY + pad * 2)
62
+ };
63
+ }
64
+ /** The frame an arrow should carry, derived from its endpoints. */
65
+ function arrowFrame(item) {
66
+ return {
67
+ ...arrowBounds(item),
68
+ rotation: 0
69
+ };
70
+ }
71
+ /** Distance from a world point to an arrow's curve (hit testing). */
72
+ function distanceToArrow(item, point) {
73
+ const samples = sampleArrow(resolveArrow(item), 16);
74
+ let min = Number.POSITIVE_INFINITY;
75
+ for (let index = 0; index < samples.length - 1; index += 1) {
76
+ const from = samples[index];
77
+ const to = samples[index + 1];
78
+ if (!from || !to) continue;
79
+ const distance = segmentDistance(point, from, to);
80
+ if (distance < min) min = distance;
81
+ }
82
+ return min;
83
+ }
84
+ function segmentDistance(p, a, b) {
85
+ const dx = b.x - a.x;
86
+ const dy = b.y - a.y;
87
+ const lengthSq = dx * dx + dy * dy;
88
+ if (lengthSq === 0) return Math.hypot(p.x - a.x, p.y - a.y);
89
+ const t = Math.min(1, Math.max(0, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lengthSq));
90
+ return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
91
+ }
92
+ /** Translate an arrow by a delta, keeping its frame consistent. */
93
+ function translateArrow(item, dx, dy) {
94
+ const move = (point) => ({
95
+ x: point.x + dx,
96
+ y: point.y + dy
97
+ });
98
+ const next = {
99
+ ...item,
100
+ start: move(item.start),
101
+ end: move(item.end)
102
+ };
103
+ return {
104
+ ...next,
105
+ frame: arrowFrame(next)
106
+ };
107
+ }
108
+ //#endregion
109
+ export { arrowBounds, arrowFrame, distanceToArrow, resolveArrow, sampleArrow, translateArrow };
@@ -0,0 +1,83 @@
1
+ import { BoardConnection, BoardConnectionSide } from "../../protocol/dist/board-connection.js";
2
+ import { BoardFrame } from "../../protocol/dist/board-document.js";
3
+ import { Rect, WorldPoint } from "../geometry.js";
4
+ //#region src/board/core/connections.d.ts
5
+ type FrameLookup = (id: string) => BoardFrame | undefined;
6
+ /**
7
+ * Gap between a node's edge and the line's endpoint, in world units.
8
+ *
9
+ * A connection that touches the border reads as part of the node; a small gap
10
+ * makes the relation legible as its own object and keeps an arrowhead from being
11
+ * swallowed by the card it points at.
12
+ */
13
+ declare const CONNECTION_ENDPOINT_GAP = 4;
14
+ type ResolvedConnectionEndpoint = {
15
+ point: WorldPoint;
16
+ /** Outward normal at the attachment, used to aim arrowheads and elbows. */
17
+ normal: WorldPoint;
18
+ side: BoardConnectionSide;
19
+ };
20
+ type ResolvedConnection = {
21
+ source: ResolvedConnectionEndpoint;
22
+ target: ResolvedConnectionEndpoint;
23
+ /** Sampled world-space path, always at least two points. */
24
+ path: WorldPoint[];
25
+ /** Midpoint of the path, for labels and the drag handle. */
26
+ mid: WorldPoint;
27
+ };
28
+ /** Denormalize a (nx, ny) anchor to a world point on a possibly rotated frame. */
29
+ declare function anchorToWorld(frame: BoardFrame, nx: number, ny: number): WorldPoint;
30
+ /** Normalize a world point into a (nx, ny) anchor on a frame, clamped to it. */
31
+ declare function worldToAnchor(frame: BoardFrame, point: WorldPoint): {
32
+ nx: number;
33
+ ny: number;
34
+ };
35
+ /**
36
+ * Choose the side an `auto` anchor should use.
37
+ *
38
+ * The dominant axis of the center-to-center vector wins, weighted by the frame's
39
+ * own proportions so a wide card prefers its long edges. Comparing raw dx/dy
40
+ * instead would make a wide node attach to its short side for most angles, which
41
+ * is the classic "line leaves from the corner" artifact.
42
+ */
43
+ declare function autoConnectionSide(from: BoardFrame, to: BoardFrame): BoardConnectionSide;
44
+ /**
45
+ * Resolve a connection into world-space geometry, or null if either node is gone.
46
+ *
47
+ * A null result means "not drawable right now", never "delete this": the caller
48
+ * may be looking at a viewport-culled read where an endpoint simply was not
49
+ * fetched.
50
+ */
51
+ declare function resolveConnection(connection: BoardConnection, getFrame: FrameLookup, options?: {
52
+ gap?: number;
53
+ }): ResolvedConnection | null;
54
+ /** Midpoint by arc length, so a label sits visually centered on the line. */
55
+ declare function pathMidpoint(path: WorldPoint[]): WorldPoint;
56
+ /** World bounds of a resolved connection, padded for its stroke. */
57
+ declare function connectionBounds(resolved: ResolvedConnection, strokeSize: number): Rect;
58
+ /** Distance from a world point to a resolved connection's path. */
59
+ declare function distanceToConnection(resolved: ResolvedConnection, point: WorldPoint): number;
60
+ /** Hit test a connection with a zoom-independent screen-space tolerance. */
61
+ declare function connectionHitTest(resolved: ResolvedConnection, point: WorldPoint, strokeSize: number): boolean;
62
+ /** Whether the connection draws an arrowhead at its source / target. */
63
+ declare function connectionArrowheads(connection: BoardConnection): {
64
+ atSource: boolean;
65
+ atTarget: boolean;
66
+ };
67
+ /**
68
+ * Index of connections by the nodes they touch.
69
+ *
70
+ * Built once per document revision and consulted per gesture frame: moving a node
71
+ * must cost its own degree, not a scan of every connection on the board. Without
72
+ * this, dragging one node in a densely connected board is O(connections) per
73
+ * frame, which is exactly the cost that shows up as drag lag.
74
+ */
75
+ type ConnectionIndex = {
76
+ /** Connection ids touching a node (either endpoint). */
77
+ byNode: (nodeId: string) => readonly string[];
78
+ get: (connectionId: string) => BoardConnection | undefined;
79
+ readonly all: readonly BoardConnection[];
80
+ };
81
+ declare function createConnectionIndex(connections: readonly BoardConnection[]): ConnectionIndex;
82
+ //#endregion
83
+ export { CONNECTION_ENDPOINT_GAP, ConnectionIndex, FrameLookup, ResolvedConnection, ResolvedConnectionEndpoint, anchorToWorld, autoConnectionSide, connectionArrowheads, connectionBounds, connectionHitTest, createConnectionIndex, distanceToConnection, pathMidpoint, resolveConnection, worldToAnchor };
@@ -0,0 +1,399 @@
1
+ import { degToRad, frameRect, rectCenter, rotatePointAround, worldPoint } from "../geometry.js";
2
+ //#region src/board/core/connections.ts
3
+ /**
4
+ * Connection geometry — pure resolution of a relation into a drawable path.
5
+ *
6
+ * A `BoardConnection` stores no coordinates: it names two nodes, how it attaches
7
+ * to each, and how the line should travel. Everything spatial is derived here from
8
+ * the live node frames, which is what makes a connection incapable of going stale.
9
+ * No PixiJS, no DOM, no editor state — so the editor, the far-LOD batch, the
10
+ * headless exporter and the tests all resolve a connection the same way.
11
+ *
12
+ * The `auto` anchor is the interesting part: rather than storing a side, it picks
13
+ * one per resolve from the relative position of the two frames, so a connection
14
+ * keeps its shortest sensible route through any layout change. A stored side is
15
+ * honoured exactly, because a user who pinned one is stating intent that geometry
16
+ * must not override.
17
+ */
18
+ /**
19
+ * Gap between a node's edge and the line's endpoint, in world units.
20
+ *
21
+ * A connection that touches the border reads as part of the node; a small gap
22
+ * makes the relation legible as its own object and keeps an arrowhead from being
23
+ * swallowed by the card it points at.
24
+ */
25
+ const CONNECTION_ENDPOINT_GAP = 4;
26
+ /** Perpendicular fraction of the span used for an `orthogonal` elbow. */
27
+ const ORTHOGONAL_SNAP = .5;
28
+ const SIDE_NORMALS = {
29
+ top: {
30
+ x: 0,
31
+ y: -1
32
+ },
33
+ right: {
34
+ x: 1,
35
+ y: 0
36
+ },
37
+ bottom: {
38
+ x: 0,
39
+ y: 1
40
+ },
41
+ left: {
42
+ x: -1,
43
+ y: 0
44
+ }
45
+ };
46
+ /** Normalized position of a point on a frame side, as (nx, ny). */
47
+ function sideAnchorPoint(side, offset) {
48
+ switch (side) {
49
+ case "top": return {
50
+ nx: offset,
51
+ ny: 0
52
+ };
53
+ case "bottom": return {
54
+ nx: offset,
55
+ ny: 1
56
+ };
57
+ case "left": return {
58
+ nx: 0,
59
+ ny: offset
60
+ };
61
+ default: return {
62
+ nx: 1,
63
+ ny: offset
64
+ };
65
+ }
66
+ }
67
+ /** Denormalize a (nx, ny) anchor to a world point on a possibly rotated frame. */
68
+ function anchorToWorld(frame, nx, ny) {
69
+ const unrotated = worldPoint(frame.x + nx * frame.width, frame.y + ny * frame.height);
70
+ if (!frame.rotation) return unrotated;
71
+ return rotatePointAround(unrotated, rectCenter(frameRect(frame)), degToRad(frame.rotation));
72
+ }
73
+ /** Normalize a world point into a (nx, ny) anchor on a frame, clamped to it. */
74
+ function worldToAnchor(frame, point) {
75
+ const local = frame.rotation ? rotatePointAround(point, rectCenter(frameRect(frame)), -degToRad(frame.rotation)) : point;
76
+ return {
77
+ nx: clamp01((local.x - frame.x) / Math.max(frame.width, 1e-4)),
78
+ ny: clamp01((local.y - frame.y) / Math.max(frame.height, 1e-4))
79
+ };
80
+ }
81
+ function clamp01(value) {
82
+ return Math.min(1, Math.max(0, value));
83
+ }
84
+ /**
85
+ * Choose the side an `auto` anchor should use.
86
+ *
87
+ * The dominant axis of the center-to-center vector wins, weighted by the frame's
88
+ * own proportions so a wide card prefers its long edges. Comparing raw dx/dy
89
+ * instead would make a wide node attach to its short side for most angles, which
90
+ * is the classic "line leaves from the corner" artifact.
91
+ */
92
+ function autoConnectionSide(from, to) {
93
+ const a = rectCenter(frameRect(from));
94
+ const b = rectCenter(frameRect(to));
95
+ const dx = b.x - a.x;
96
+ const dy = b.y - a.y;
97
+ if (Math.abs(dx) / Math.max(from.width / 2, 1e-4) >= Math.abs(dy) / Math.max(from.height / 2, 1e-4)) return dx >= 0 ? "right" : "left";
98
+ return dy >= 0 ? "bottom" : "top";
99
+ }
100
+ /** Resolve one endpoint of a connection against its node frame. */
101
+ function resolveEndpoint(anchor, frame, otherFrame, gap) {
102
+ let side;
103
+ let nx;
104
+ let ny;
105
+ if (anchor.kind === "fixed") {
106
+ nx = anchor.nx;
107
+ ny = anchor.ny;
108
+ side = nearestSide(nx, ny);
109
+ } else if (anchor.kind === "side") {
110
+ side = anchor.side;
111
+ const point = sideAnchorPoint(side, anchor.offset);
112
+ nx = point.nx;
113
+ ny = point.ny;
114
+ } else {
115
+ side = autoConnectionSide(frame, otherFrame);
116
+ const point = sideAnchorPoint(side, .5);
117
+ nx = point.nx;
118
+ ny = point.ny;
119
+ }
120
+ const base = anchorToWorld(frame, nx, ny);
121
+ const local = SIDE_NORMALS[side];
122
+ const normal = frame.rotation ? rotateVector(local, degToRad(frame.rotation)) : worldPoint(local.x, local.y);
123
+ return {
124
+ point: worldPoint(base.x + normal.x * gap, base.y + normal.y * gap),
125
+ normal,
126
+ side
127
+ };
128
+ }
129
+ function rotateVector(vector, angleRad) {
130
+ const cos = Math.cos(angleRad);
131
+ const sin = Math.sin(angleRad);
132
+ return worldPoint(vector.x * cos - vector.y * sin, vector.x * sin + vector.y * cos);
133
+ }
134
+ function nearestSide(nx, ny) {
135
+ const distances = [
136
+ ["left", nx],
137
+ ["right", 1 - nx],
138
+ ["top", ny],
139
+ ["bottom", 1 - ny]
140
+ ];
141
+ distances.sort((a, b) => a[1] - b[1]);
142
+ return distances[0]?.[0] ?? "right";
143
+ }
144
+ /**
145
+ * A self-loop's path: a lobe leaving and re-entering the same node.
146
+ *
147
+ * Handled separately because every straight/curve formula degenerates when both
148
+ * endpoints coincide, and a self-relation is meaningful enough (a node that
149
+ * depends on itself, a retry edge) that dropping it would lose real data.
150
+ */
151
+ function selfLoopPath(frame, gap) {
152
+ const rect = frameRect(frame);
153
+ const size = Math.max(Math.min(rect.width, rect.height) * .45, 24);
154
+ const right = rect.x + rect.width + gap;
155
+ const top = rect.y - gap;
156
+ const midY = rect.y + rect.height * .3;
157
+ const midX = rect.x + rect.width * .7;
158
+ return [
159
+ worldPoint(midX, top),
160
+ worldPoint(midX, top - size),
161
+ worldPoint(right + size, top - size),
162
+ worldPoint(right + size, midY),
163
+ worldPoint(right, midY)
164
+ ];
165
+ }
166
+ function quadraticPoint(start, control, end, t) {
167
+ const mt = 1 - t;
168
+ return worldPoint(mt * mt * start.x + 2 * mt * t * control.x + t * t * end.x, mt * mt * start.y + 2 * mt * t * control.y + t * t * end.y);
169
+ }
170
+ /** Sample a quadratic curve into a polyline. */
171
+ function sampleQuadratic(start, control, end, segments) {
172
+ const out = [];
173
+ for (let index = 0; index <= segments; index += 1) out.push(quadraticPoint(start, control, end, index / segments));
174
+ return out;
175
+ }
176
+ /**
177
+ * Build an orthogonal elbow between two endpoints, leaving each along its normal.
178
+ *
179
+ * The turn happens on the axis each endpoint exits by, so the line never starts
180
+ * parallel to the edge it is attached to (which would read as touching the node
181
+ * rather than leaving it).
182
+ */
183
+ function orthogonalPath(source, target) {
184
+ const horizontalStart = Math.abs(source.normal.x) > Math.abs(source.normal.y);
185
+ const horizontalEnd = Math.abs(target.normal.x) > Math.abs(target.normal.y);
186
+ const a = source.point;
187
+ const b = target.point;
188
+ if (horizontalStart && horizontalEnd) {
189
+ const midX = a.x + (b.x - a.x) * ORTHOGONAL_SNAP;
190
+ return [
191
+ a,
192
+ worldPoint(midX, a.y),
193
+ worldPoint(midX, b.y),
194
+ b
195
+ ];
196
+ }
197
+ if (!horizontalStart && !horizontalEnd) {
198
+ const midY = a.y + (b.y - a.y) * ORTHOGONAL_SNAP;
199
+ return [
200
+ a,
201
+ worldPoint(a.x, midY),
202
+ worldPoint(b.x, midY),
203
+ b
204
+ ];
205
+ }
206
+ return horizontalStart ? [
207
+ a,
208
+ worldPoint(b.x, a.y),
209
+ b
210
+ ] : [
211
+ a,
212
+ worldPoint(a.x, b.y),
213
+ b
214
+ ];
215
+ }
216
+ /** Curve samples per connection. Enough to read as smooth at typical zoom. */
217
+ const CURVE_SEGMENTS = 20;
218
+ /**
219
+ * Resolve a connection into world-space geometry, or null if either node is gone.
220
+ *
221
+ * A null result means "not drawable right now", never "delete this": the caller
222
+ * may be looking at a viewport-culled read where an endpoint simply was not
223
+ * fetched.
224
+ */
225
+ function resolveConnection(connection, getFrame, options = {}) {
226
+ const sourceFrame = getFrame(connection.source.nodeId);
227
+ const targetFrame = getFrame(connection.target.nodeId);
228
+ if (!sourceFrame || !targetFrame) return null;
229
+ const gap = options.gap ?? 4;
230
+ if (connection.source.nodeId === connection.target.nodeId) {
231
+ const path = selfLoopPath(sourceFrame, gap);
232
+ const first = path[0];
233
+ const last = path[path.length - 1];
234
+ return {
235
+ source: {
236
+ point: first,
237
+ normal: worldPoint(0, -1),
238
+ side: "top"
239
+ },
240
+ target: {
241
+ point: last,
242
+ normal: worldPoint(1, 0),
243
+ side: "right"
244
+ },
245
+ path,
246
+ mid: path[Math.floor(path.length / 2)] ?? first
247
+ };
248
+ }
249
+ const source = resolveEndpoint(connection.source.anchor, sourceFrame, targetFrame, gap);
250
+ const target = resolveEndpoint(connection.target.anchor, targetFrame, sourceFrame, gap);
251
+ const waypoints = connection.routing.waypoints;
252
+ let path;
253
+ if (waypoints.length > 0) path = [
254
+ source.point,
255
+ ...waypoints.map((point) => worldPoint(point.x, point.y)),
256
+ target.point
257
+ ];
258
+ else if (connection.routing.kind === "orthogonal") path = orthogonalPath(source, target);
259
+ else if (connection.routing.kind === "straight" && !connection.routing.bend) path = [source.point, target.point];
260
+ else path = sampleQuadratic(source.point, curveControl(source, target, connection.routing.bend), target.point, CURVE_SEGMENTS);
261
+ return {
262
+ source,
263
+ target,
264
+ path,
265
+ mid: pathMidpoint(path)
266
+ };
267
+ }
268
+ /**
269
+ * Control point for a curved connection.
270
+ *
271
+ * With no explicit bend the bow is derived from the endpoint normals, so the line
272
+ * leaves each node perpendicular to its edge and reads as attached rather than
273
+ * merely adjacent. An explicit bend is a user-set bow and takes over entirely.
274
+ */
275
+ function curveControl(source, target, bend) {
276
+ const mid = worldPoint((source.point.x + target.point.x) / 2, (source.point.y + target.point.y) / 2);
277
+ const dx = target.point.x - source.point.x;
278
+ const dy = target.point.y - source.point.y;
279
+ const length = Math.hypot(dx, dy) || 1;
280
+ if (bend) {
281
+ const offset = bend * length;
282
+ return worldPoint(mid.x + -dy / length * offset, mid.y + dx / length * offset);
283
+ }
284
+ const nx = (source.normal.x + target.normal.x) / 2;
285
+ const ny = (source.normal.y + target.normal.y) / 2;
286
+ const magnitude = Math.hypot(nx, ny);
287
+ if (magnitude < 1e-4) return mid;
288
+ const strength = Math.min(length * .18, 96);
289
+ return worldPoint(mid.x + nx / magnitude * strength, mid.y + ny / magnitude * strength);
290
+ }
291
+ /** Midpoint by arc length, so a label sits visually centered on the line. */
292
+ function pathMidpoint(path) {
293
+ if (path.length === 0) return worldPoint(0, 0);
294
+ if (path.length === 1) return path[0];
295
+ let total = 0;
296
+ for (let index = 0; index < path.length - 1; index += 1) {
297
+ const from = path[index];
298
+ const to = path[index + 1];
299
+ total += Math.hypot(to.x - from.x, to.y - from.y);
300
+ }
301
+ let travelled = 0;
302
+ const half = total / 2;
303
+ for (let index = 0; index < path.length - 1; index += 1) {
304
+ const from = path[index];
305
+ const to = path[index + 1];
306
+ const segment = Math.hypot(to.x - from.x, to.y - from.y);
307
+ if (travelled + segment >= half) {
308
+ const t = segment > 0 ? (half - travelled) / segment : 0;
309
+ return worldPoint(from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t);
310
+ }
311
+ travelled += segment;
312
+ }
313
+ return path[path.length - 1];
314
+ }
315
+ /** World bounds of a resolved connection, padded for its stroke. */
316
+ function connectionBounds(resolved, strokeSize) {
317
+ let minX = Number.POSITIVE_INFINITY;
318
+ let minY = Number.POSITIVE_INFINITY;
319
+ let maxX = Number.NEGATIVE_INFINITY;
320
+ let maxY = Number.NEGATIVE_INFINITY;
321
+ for (const point of resolved.path) {
322
+ minX = Math.min(minX, point.x);
323
+ minY = Math.min(minY, point.y);
324
+ maxX = Math.max(maxX, point.x);
325
+ maxY = Math.max(maxY, point.y);
326
+ }
327
+ const pad = Math.max(8, strokeSize * 2);
328
+ return {
329
+ x: minX - pad,
330
+ y: minY - pad,
331
+ width: Math.max(1, maxX - minX + pad * 2),
332
+ height: Math.max(1, maxY - minY + pad * 2)
333
+ };
334
+ }
335
+ /** Distance from a world point to a resolved connection's path. */
336
+ function distanceToConnection(resolved, point) {
337
+ let min = Number.POSITIVE_INFINITY;
338
+ for (let index = 0; index < resolved.path.length - 1; index += 1) {
339
+ const from = resolved.path[index];
340
+ const to = resolved.path[index + 1];
341
+ if (!from || !to) continue;
342
+ const distance = segmentDistance(point, from, to);
343
+ if (distance < min) min = distance;
344
+ }
345
+ return min;
346
+ }
347
+ function segmentDistance(p, a, b) {
348
+ const dx = b.x - a.x;
349
+ const dy = b.y - a.y;
350
+ const lengthSq = dx * dx + dy * dy;
351
+ if (lengthSq === 0) return Math.hypot(p.x - a.x, p.y - a.y);
352
+ const t = Math.min(1, Math.max(0, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lengthSq));
353
+ return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
354
+ }
355
+ /** Hit test a connection with a zoom-independent screen-space tolerance. */
356
+ function connectionHitTest(resolved, point, strokeSize) {
357
+ return distanceToConnection(resolved, point) <= Math.max(8, strokeSize * 2.5);
358
+ }
359
+ /** Whether the connection draws an arrowhead at its source / target. */
360
+ function connectionArrowheads(connection) {
361
+ switch (connection.direction) {
362
+ case "forward": return {
363
+ atSource: false,
364
+ atTarget: true
365
+ };
366
+ case "backward": return {
367
+ atSource: true,
368
+ atTarget: false
369
+ };
370
+ case "both": return {
371
+ atSource: true,
372
+ atTarget: true
373
+ };
374
+ default: return {
375
+ atSource: false,
376
+ atTarget: false
377
+ };
378
+ }
379
+ }
380
+ const NO_CONNECTIONS = [];
381
+ function createConnectionIndex(connections) {
382
+ const byNode = /* @__PURE__ */ new Map();
383
+ const byId = /* @__PURE__ */ new Map();
384
+ for (const connection of connections) {
385
+ byId.set(connection.id, connection);
386
+ for (const nodeId of /* @__PURE__ */ new Set([connection.source.nodeId, connection.target.nodeId])) {
387
+ const list = byNode.get(nodeId);
388
+ if (list) list.push(connection.id);
389
+ else byNode.set(nodeId, [connection.id]);
390
+ }
391
+ }
392
+ return {
393
+ byNode: (nodeId) => byNode.get(nodeId) ?? NO_CONNECTIONS,
394
+ get: (connectionId) => byId.get(connectionId),
395
+ all: connections
396
+ };
397
+ }
398
+ //#endregion
399
+ export { CONNECTION_ENDPOINT_GAP, anchorToWorld, autoConnectionSide, connectionArrowheads, connectionBounds, connectionHitTest, createConnectionIndex, distanceToConnection, pathMidpoint, resolveConnection, worldToAnchor };