@neta-art/cohub 5.3.3 → 5.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/board/codec.d.ts +2 -0
- package/dist/board/codec.js +18 -13
- package/dist/board/core/arrow-geometry.d.ts +23 -0
- package/dist/board/core/arrow-geometry.js +109 -0
- package/dist/board/core/connections.d.ts +91 -0
- package/dist/board/core/connections.js +414 -0
- package/dist/board/core/export-plan.d.ts +15 -4
- package/dist/board/core/export-plan.js +49 -16
- package/dist/board/core/shape-definition.js +1 -1
- package/dist/board/core/shape-types.d.ts +8 -2
- package/dist/board/core/shape-types.js +1 -1
- package/dist/board/core/tool-styles.d.ts +9 -3
- package/dist/board/core/tool-styles.js +10 -6
- package/dist/board/export/index.js +1 -0
- package/dist/board/export/scene.d.ts +3 -0
- package/dist/board/export/scene.js +18 -1
- package/dist/board/index.d.ts +8 -5
- package/dist/board/index.js +8 -5
- package/dist/board/render/connection-layer.d.ts +48 -0
- package/dist/board/render/connection-layer.js +223 -0
- package/dist/board/render/index.d.ts +2 -1
- package/dist/board/render/index.js +3 -2
- package/dist/board/render/renderers/arrow-card-renderer.js +35 -48
- package/dist/chunks/http.d.ts +53 -1
- package/dist/chunks/http.js +287 -1
- package/dist/chunks/websocket.d.ts +296 -57
- package/dist/http.d.ts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +17 -56
- package/dist/protocol/dist/board-connection.d.ts +310 -0
- package/dist/protocol/dist/board-connection.js +215 -0
- package/dist/protocol/dist/board-constants.d.ts +11 -1
- package/dist/protocol/dist/board-constants.js +15 -1
- package/dist/protocol/dist/board-document.d.ts +106 -60
- package/dist/protocol/dist/board-document.js +57 -17
- package/dist/protocol/dist/board.d.ts +1 -0
- package/dist/protocol/dist/board.js +3 -0
- package/dist/protocol/dist/index.d.ts +2 -1
- package/dist/protocol/dist/index.js +2 -1
- package/dist/protocol/dist/realtime/board-awareness.js +15 -15
- package/package.json +1 -1
- package/dist/board/core/bindings.d.ts +0 -45
- package/dist/board/core/bindings.js +0 -162
|
@@ -0,0 +1,414 @@
|
|
|
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
|
+
/**
|
|
101
|
+
* World point of an anchor on a frame, ignoring the gap and the facing node.
|
|
102
|
+
*
|
|
103
|
+
* For a live drag the other end is the pointer, not a node, so `auto` has no
|
|
104
|
+
* frame to face: it resolves to the node's centre, which is the only honest
|
|
105
|
+
* answer while the relation has nowhere to point yet.
|
|
106
|
+
*/
|
|
107
|
+
function anchorPointOnFrame(anchor, frame) {
|
|
108
|
+
if (anchor.kind === "fixed") return anchorToWorld(frame, anchor.nx, anchor.ny);
|
|
109
|
+
if (anchor.kind === "side") {
|
|
110
|
+
const point = sideAnchorPoint(anchor.side, anchor.offset);
|
|
111
|
+
return anchorToWorld(frame, point.nx, point.ny);
|
|
112
|
+
}
|
|
113
|
+
return anchorToWorld(frame, .5, .5);
|
|
114
|
+
}
|
|
115
|
+
/** Resolve one endpoint of a connection against its node frame. */
|
|
116
|
+
function resolveEndpoint(anchor, frame, otherFrame, gap) {
|
|
117
|
+
let side;
|
|
118
|
+
let nx;
|
|
119
|
+
let ny;
|
|
120
|
+
if (anchor.kind === "fixed") {
|
|
121
|
+
nx = anchor.nx;
|
|
122
|
+
ny = anchor.ny;
|
|
123
|
+
side = nearestSide(nx, ny);
|
|
124
|
+
} else if (anchor.kind === "side") {
|
|
125
|
+
side = anchor.side;
|
|
126
|
+
const point = sideAnchorPoint(side, anchor.offset);
|
|
127
|
+
nx = point.nx;
|
|
128
|
+
ny = point.ny;
|
|
129
|
+
} else {
|
|
130
|
+
side = autoConnectionSide(frame, otherFrame);
|
|
131
|
+
const point = sideAnchorPoint(side, .5);
|
|
132
|
+
nx = point.nx;
|
|
133
|
+
ny = point.ny;
|
|
134
|
+
}
|
|
135
|
+
const base = anchorToWorld(frame, nx, ny);
|
|
136
|
+
const local = SIDE_NORMALS[side];
|
|
137
|
+
const normal = frame.rotation ? rotateVector(local, degToRad(frame.rotation)) : worldPoint(local.x, local.y);
|
|
138
|
+
return {
|
|
139
|
+
point: worldPoint(base.x + normal.x * gap, base.y + normal.y * gap),
|
|
140
|
+
normal,
|
|
141
|
+
side
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function rotateVector(vector, angleRad) {
|
|
145
|
+
const cos = Math.cos(angleRad);
|
|
146
|
+
const sin = Math.sin(angleRad);
|
|
147
|
+
return worldPoint(vector.x * cos - vector.y * sin, vector.x * sin + vector.y * cos);
|
|
148
|
+
}
|
|
149
|
+
function nearestSide(nx, ny) {
|
|
150
|
+
const distances = [
|
|
151
|
+
["left", nx],
|
|
152
|
+
["right", 1 - nx],
|
|
153
|
+
["top", ny],
|
|
154
|
+
["bottom", 1 - ny]
|
|
155
|
+
];
|
|
156
|
+
distances.sort((a, b) => a[1] - b[1]);
|
|
157
|
+
return distances[0]?.[0] ?? "right";
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* A self-loop's path: a lobe leaving and re-entering the same node.
|
|
161
|
+
*
|
|
162
|
+
* Handled separately because every straight/curve formula degenerates when both
|
|
163
|
+
* endpoints coincide, and a self-relation is meaningful enough (a node that
|
|
164
|
+
* depends on itself, a retry edge) that dropping it would lose real data.
|
|
165
|
+
*/
|
|
166
|
+
function selfLoopPath(frame, gap) {
|
|
167
|
+
const rect = frameRect(frame);
|
|
168
|
+
const size = Math.max(Math.min(rect.width, rect.height) * .45, 24);
|
|
169
|
+
const right = rect.x + rect.width + gap;
|
|
170
|
+
const top = rect.y - gap;
|
|
171
|
+
const midY = rect.y + rect.height * .3;
|
|
172
|
+
const midX = rect.x + rect.width * .7;
|
|
173
|
+
return [
|
|
174
|
+
worldPoint(midX, top),
|
|
175
|
+
worldPoint(midX, top - size),
|
|
176
|
+
worldPoint(right + size, top - size),
|
|
177
|
+
worldPoint(right + size, midY),
|
|
178
|
+
worldPoint(right, midY)
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
function quadraticPoint(start, control, end, t) {
|
|
182
|
+
const mt = 1 - t;
|
|
183
|
+
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);
|
|
184
|
+
}
|
|
185
|
+
/** Sample a quadratic curve into a polyline. */
|
|
186
|
+
function sampleQuadratic(start, control, end, segments) {
|
|
187
|
+
const out = [];
|
|
188
|
+
for (let index = 0; index <= segments; index += 1) out.push(quadraticPoint(start, control, end, index / segments));
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Build an orthogonal elbow between two endpoints, leaving each along its normal.
|
|
193
|
+
*
|
|
194
|
+
* The turn happens on the axis each endpoint exits by, so the line never starts
|
|
195
|
+
* parallel to the edge it is attached to (which would read as touching the node
|
|
196
|
+
* rather than leaving it).
|
|
197
|
+
*/
|
|
198
|
+
function orthogonalPath(source, target) {
|
|
199
|
+
const horizontalStart = Math.abs(source.normal.x) > Math.abs(source.normal.y);
|
|
200
|
+
const horizontalEnd = Math.abs(target.normal.x) > Math.abs(target.normal.y);
|
|
201
|
+
const a = source.point;
|
|
202
|
+
const b = target.point;
|
|
203
|
+
if (horizontalStart && horizontalEnd) {
|
|
204
|
+
const midX = a.x + (b.x - a.x) * ORTHOGONAL_SNAP;
|
|
205
|
+
return [
|
|
206
|
+
a,
|
|
207
|
+
worldPoint(midX, a.y),
|
|
208
|
+
worldPoint(midX, b.y),
|
|
209
|
+
b
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
if (!horizontalStart && !horizontalEnd) {
|
|
213
|
+
const midY = a.y + (b.y - a.y) * ORTHOGONAL_SNAP;
|
|
214
|
+
return [
|
|
215
|
+
a,
|
|
216
|
+
worldPoint(a.x, midY),
|
|
217
|
+
worldPoint(b.x, midY),
|
|
218
|
+
b
|
|
219
|
+
];
|
|
220
|
+
}
|
|
221
|
+
return horizontalStart ? [
|
|
222
|
+
a,
|
|
223
|
+
worldPoint(b.x, a.y),
|
|
224
|
+
b
|
|
225
|
+
] : [
|
|
226
|
+
a,
|
|
227
|
+
worldPoint(a.x, b.y),
|
|
228
|
+
b
|
|
229
|
+
];
|
|
230
|
+
}
|
|
231
|
+
/** Curve samples per connection. Enough to read as smooth at typical zoom. */
|
|
232
|
+
const CURVE_SEGMENTS = 20;
|
|
233
|
+
/**
|
|
234
|
+
* Resolve a connection into world-space geometry, or null if either node is gone.
|
|
235
|
+
*
|
|
236
|
+
* A null result means "not drawable right now", never "delete this": the caller
|
|
237
|
+
* may be looking at a viewport-culled read where an endpoint simply was not
|
|
238
|
+
* fetched.
|
|
239
|
+
*/
|
|
240
|
+
function resolveConnection(connection, getFrame, options = {}) {
|
|
241
|
+
const sourceFrame = getFrame(connection.source.nodeId);
|
|
242
|
+
const targetFrame = getFrame(connection.target.nodeId);
|
|
243
|
+
if (!sourceFrame || !targetFrame) return null;
|
|
244
|
+
const gap = options.gap ?? 4;
|
|
245
|
+
if (connection.source.nodeId === connection.target.nodeId) {
|
|
246
|
+
const path = selfLoopPath(sourceFrame, gap);
|
|
247
|
+
const first = path[0];
|
|
248
|
+
const last = path[path.length - 1];
|
|
249
|
+
return {
|
|
250
|
+
source: {
|
|
251
|
+
point: first,
|
|
252
|
+
normal: worldPoint(0, -1),
|
|
253
|
+
side: "top"
|
|
254
|
+
},
|
|
255
|
+
target: {
|
|
256
|
+
point: last,
|
|
257
|
+
normal: worldPoint(1, 0),
|
|
258
|
+
side: "right"
|
|
259
|
+
},
|
|
260
|
+
path,
|
|
261
|
+
mid: path[Math.floor(path.length / 2)] ?? first
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
const source = resolveEndpoint(connection.source.anchor, sourceFrame, targetFrame, gap);
|
|
265
|
+
const target = resolveEndpoint(connection.target.anchor, targetFrame, sourceFrame, gap);
|
|
266
|
+
const waypoints = connection.routing.waypoints;
|
|
267
|
+
let path;
|
|
268
|
+
if (waypoints.length > 0) path = [
|
|
269
|
+
source.point,
|
|
270
|
+
...waypoints.map((point) => worldPoint(point.x, point.y)),
|
|
271
|
+
target.point
|
|
272
|
+
];
|
|
273
|
+
else if (connection.routing.kind === "orthogonal") path = orthogonalPath(source, target);
|
|
274
|
+
else if (connection.routing.kind === "straight" && !connection.routing.bend) path = [source.point, target.point];
|
|
275
|
+
else path = sampleQuadratic(source.point, curveControl(source, target, connection.routing.bend), target.point, CURVE_SEGMENTS);
|
|
276
|
+
return {
|
|
277
|
+
source,
|
|
278
|
+
target,
|
|
279
|
+
path,
|
|
280
|
+
mid: pathMidpoint(path)
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Control point for a curved connection.
|
|
285
|
+
*
|
|
286
|
+
* With no explicit bend the bow is derived from the endpoint normals, so the line
|
|
287
|
+
* leaves each node perpendicular to its edge and reads as attached rather than
|
|
288
|
+
* merely adjacent. An explicit bend is a user-set bow and takes over entirely.
|
|
289
|
+
*/
|
|
290
|
+
function curveControl(source, target, bend) {
|
|
291
|
+
const mid = worldPoint((source.point.x + target.point.x) / 2, (source.point.y + target.point.y) / 2);
|
|
292
|
+
const dx = target.point.x - source.point.x;
|
|
293
|
+
const dy = target.point.y - source.point.y;
|
|
294
|
+
const length = Math.hypot(dx, dy) || 1;
|
|
295
|
+
if (bend) {
|
|
296
|
+
const offset = bend * length;
|
|
297
|
+
return worldPoint(mid.x + -dy / length * offset, mid.y + dx / length * offset);
|
|
298
|
+
}
|
|
299
|
+
const nx = (source.normal.x + target.normal.x) / 2;
|
|
300
|
+
const ny = (source.normal.y + target.normal.y) / 2;
|
|
301
|
+
const magnitude = Math.hypot(nx, ny);
|
|
302
|
+
if (magnitude < 1e-4) return mid;
|
|
303
|
+
const strength = Math.min(length * .18, 96);
|
|
304
|
+
return worldPoint(mid.x + nx / magnitude * strength, mid.y + ny / magnitude * strength);
|
|
305
|
+
}
|
|
306
|
+
/** Midpoint by arc length, so a label sits visually centered on the line. */
|
|
307
|
+
function pathMidpoint(path) {
|
|
308
|
+
if (path.length === 0) return worldPoint(0, 0);
|
|
309
|
+
if (path.length === 1) return path[0];
|
|
310
|
+
let total = 0;
|
|
311
|
+
for (let index = 0; index < path.length - 1; index += 1) {
|
|
312
|
+
const from = path[index];
|
|
313
|
+
const to = path[index + 1];
|
|
314
|
+
total += Math.hypot(to.x - from.x, to.y - from.y);
|
|
315
|
+
}
|
|
316
|
+
let travelled = 0;
|
|
317
|
+
const half = total / 2;
|
|
318
|
+
for (let index = 0; index < path.length - 1; index += 1) {
|
|
319
|
+
const from = path[index];
|
|
320
|
+
const to = path[index + 1];
|
|
321
|
+
const segment = Math.hypot(to.x - from.x, to.y - from.y);
|
|
322
|
+
if (travelled + segment >= half) {
|
|
323
|
+
const t = segment > 0 ? (half - travelled) / segment : 0;
|
|
324
|
+
return worldPoint(from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t);
|
|
325
|
+
}
|
|
326
|
+
travelled += segment;
|
|
327
|
+
}
|
|
328
|
+
return path[path.length - 1];
|
|
329
|
+
}
|
|
330
|
+
/** World bounds of a resolved connection, padded for its stroke. */
|
|
331
|
+
function connectionBounds(resolved, strokeSize) {
|
|
332
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
333
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
334
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
335
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
336
|
+
for (const point of resolved.path) {
|
|
337
|
+
minX = Math.min(minX, point.x);
|
|
338
|
+
minY = Math.min(minY, point.y);
|
|
339
|
+
maxX = Math.max(maxX, point.x);
|
|
340
|
+
maxY = Math.max(maxY, point.y);
|
|
341
|
+
}
|
|
342
|
+
const pad = Math.max(8, strokeSize * 2);
|
|
343
|
+
return {
|
|
344
|
+
x: minX - pad,
|
|
345
|
+
y: minY - pad,
|
|
346
|
+
width: Math.max(1, maxX - minX + pad * 2),
|
|
347
|
+
height: Math.max(1, maxY - minY + pad * 2)
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
/** Distance from a world point to a resolved connection's path. */
|
|
351
|
+
function distanceToConnection(resolved, point) {
|
|
352
|
+
let min = Number.POSITIVE_INFINITY;
|
|
353
|
+
for (let index = 0; index < resolved.path.length - 1; index += 1) {
|
|
354
|
+
const from = resolved.path[index];
|
|
355
|
+
const to = resolved.path[index + 1];
|
|
356
|
+
if (!from || !to) continue;
|
|
357
|
+
const distance = segmentDistance(point, from, to);
|
|
358
|
+
if (distance < min) min = distance;
|
|
359
|
+
}
|
|
360
|
+
return min;
|
|
361
|
+
}
|
|
362
|
+
function segmentDistance(p, a, b) {
|
|
363
|
+
const dx = b.x - a.x;
|
|
364
|
+
const dy = b.y - a.y;
|
|
365
|
+
const lengthSq = dx * dx + dy * dy;
|
|
366
|
+
if (lengthSq === 0) return Math.hypot(p.x - a.x, p.y - a.y);
|
|
367
|
+
const t = Math.min(1, Math.max(0, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lengthSq));
|
|
368
|
+
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
|
|
369
|
+
}
|
|
370
|
+
/** Hit test a connection with a zoom-independent screen-space tolerance. */
|
|
371
|
+
function connectionHitTest(resolved, point, strokeSize) {
|
|
372
|
+
return distanceToConnection(resolved, point) <= Math.max(8, strokeSize * 2.5);
|
|
373
|
+
}
|
|
374
|
+
/** Whether the connection draws an arrowhead at its source / target. */
|
|
375
|
+
function connectionArrowheads(connection) {
|
|
376
|
+
switch (connection.direction) {
|
|
377
|
+
case "forward": return {
|
|
378
|
+
atSource: false,
|
|
379
|
+
atTarget: true
|
|
380
|
+
};
|
|
381
|
+
case "backward": return {
|
|
382
|
+
atSource: true,
|
|
383
|
+
atTarget: false
|
|
384
|
+
};
|
|
385
|
+
case "both": return {
|
|
386
|
+
atSource: true,
|
|
387
|
+
atTarget: true
|
|
388
|
+
};
|
|
389
|
+
default: return {
|
|
390
|
+
atSource: false,
|
|
391
|
+
atTarget: false
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const NO_CONNECTIONS = [];
|
|
396
|
+
function createConnectionIndex(connections) {
|
|
397
|
+
const byNode = /* @__PURE__ */ new Map();
|
|
398
|
+
const byId = /* @__PURE__ */ new Map();
|
|
399
|
+
for (const connection of connections) {
|
|
400
|
+
byId.set(connection.id, connection);
|
|
401
|
+
for (const nodeId of /* @__PURE__ */ new Set([connection.source.nodeId, connection.target.nodeId])) {
|
|
402
|
+
const list = byNode.get(nodeId);
|
|
403
|
+
if (list) list.push(connection.id);
|
|
404
|
+
else byNode.set(nodeId, [connection.id]);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
byNode: (nodeId) => byNode.get(nodeId) ?? NO_CONNECTIONS,
|
|
409
|
+
get: (connectionId) => byId.get(connectionId),
|
|
410
|
+
all: connections
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
//#endregion
|
|
414
|
+
export { CONNECTION_ENDPOINT_GAP, anchorPointOnFrame, anchorToWorld, autoConnectionSide, connectionArrowheads, connectionBounds, connectionHitTest, createConnectionIndex, distanceToConnection, pathMidpoint, resolveConnection, worldToAnchor };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { BoardConnection } from "../../protocol/dist/board-connection.js";
|
|
1
2
|
import { BoardDocument, BoardItem } from "../../protocol/dist/board-document.js";
|
|
2
3
|
import { Rect } from "../geometry.js";
|
|
3
|
-
import { FrameLookup } from "./
|
|
4
|
+
import { FrameLookup } from "./connections.js";
|
|
4
5
|
//#region src/board/core/export-plan.d.ts
|
|
5
6
|
/** What part of the board to capture. */
|
|
6
7
|
type BoardExportRegion =
|
|
@@ -45,6 +46,14 @@ type BoardExportPlan = {
|
|
|
45
46
|
height: number;
|
|
46
47
|
/** Items to draw, in document order. */
|
|
47
48
|
items: BoardItem[];
|
|
49
|
+
/**
|
|
50
|
+
* Connections to draw.
|
|
51
|
+
*
|
|
52
|
+
* A relation is only included when both of its nodes are in `items`: half a
|
|
53
|
+
* connection would render as a line into empty space, which reads as a defect
|
|
54
|
+
* rather than as a clipped edge.
|
|
55
|
+
*/
|
|
56
|
+
connections: BoardConnection[];
|
|
48
57
|
/** True when `scale` had to be reduced to fit the size budget. */
|
|
49
58
|
clamped: boolean;
|
|
50
59
|
};
|
|
@@ -73,12 +82,14 @@ declare const BOARD_EXPORT_DEFAULT_PADDING = 32;
|
|
|
73
82
|
/** Above this, an export is still produced but the caller is warned. */
|
|
74
83
|
declare const BOARD_EXPORT_ITEM_WARN_THRESHOLD = 2000;
|
|
75
84
|
declare function boardFrameLookup(document: BoardDocument): FrameLookup;
|
|
76
|
-
/** Bounds of a single item
|
|
77
|
-
declare function exportItemBounds(item: BoardItem,
|
|
85
|
+
/** Bounds of a single item. Arrows resolve through their own curve geometry. */
|
|
86
|
+
declare function exportItemBounds(item: BoardItem, _getFrame?: FrameLookup): Rect;
|
|
87
|
+
/** Bounds of a connection, resolved against the document's node frames. */
|
|
88
|
+
declare function exportConnectionBounds(connection: BoardConnection, getFrame: FrameLookup): Rect | null;
|
|
78
89
|
/**
|
|
79
90
|
* Resolve a region into a concrete capture plan, or null when there is nothing
|
|
80
91
|
* to draw. Callers treat null as "empty selection", not as an error.
|
|
81
92
|
*/
|
|
82
93
|
declare function planBoardExport(input: BoardExportPlanInput): BoardExportPlan | null;
|
|
83
94
|
//#endregion
|
|
84
|
-
export { BOARD_EXPORT_DEFAULT_PADDING, BOARD_EXPORT_DEFAULT_SCALE, BOARD_EXPORT_ITEM_WARN_THRESHOLD, BOARD_EXPORT_MAX_EDGE, BOARD_EXPORT_MAX_PIXELS, BoardExportPlan, BoardExportPlanInput, BoardExportRegion, boardFrameLookup, exportItemBounds, normalizeBoardDocument, planBoardExport };
|
|
95
|
+
export { BOARD_EXPORT_DEFAULT_PADDING, BOARD_EXPORT_DEFAULT_SCALE, BOARD_EXPORT_ITEM_WARN_THRESHOLD, BOARD_EXPORT_MAX_EDGE, BOARD_EXPORT_MAX_PIXELS, BoardExportPlan, BoardExportPlanInput, BoardExportRegion, boardFrameLookup, exportConnectionBounds, exportItemBounds, normalizeBoardDocument, planBoardExport };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseBoardDocument } from "../../protocol/dist/board-document.js";
|
|
2
2
|
import { itemBounds, rectsIntersect, unionRects } from "../geometry.js";
|
|
3
|
-
import { arrowBounds } from "./
|
|
3
|
+
import { arrowBounds } from "./arrow-geometry.js";
|
|
4
|
+
import { connectionBounds, resolveConnection } from "./connections.js";
|
|
4
5
|
//#region src/board/core/export-plan.ts
|
|
5
6
|
/**
|
|
6
7
|
* Export planning — pure geometry, no renderer.
|
|
@@ -20,7 +21,7 @@ import { arrowBounds } from "./bindings.js";
|
|
|
20
21
|
* error instead of a render-time crash.
|
|
21
22
|
*/
|
|
22
23
|
function normalizeBoardDocument(document) {
|
|
23
|
-
return
|
|
24
|
+
return parseBoardDocument(document);
|
|
24
25
|
}
|
|
25
26
|
/**
|
|
26
27
|
* Hard ceilings.
|
|
@@ -40,24 +41,41 @@ function boardFrameLookup(document) {
|
|
|
40
41
|
const frames = new Map(document.items.map((item) => [item.id, item.frame]));
|
|
41
42
|
return (id) => frames.get(id);
|
|
42
43
|
}
|
|
43
|
-
/** Bounds of a single item
|
|
44
|
-
function exportItemBounds(item,
|
|
45
|
-
if (item.type === "arrow") return arrowBounds(item
|
|
44
|
+
/** Bounds of a single item. Arrows resolve through their own curve geometry. */
|
|
45
|
+
function exportItemBounds(item, _getFrame) {
|
|
46
|
+
if (item.type === "arrow") return arrowBounds(item);
|
|
46
47
|
return itemBounds(item.frame);
|
|
47
48
|
}
|
|
49
|
+
/** Bounds of a connection, resolved against the document's node frames. */
|
|
50
|
+
function exportConnectionBounds(connection, getFrame) {
|
|
51
|
+
const resolved = resolveConnection(connection, getFrame);
|
|
52
|
+
return resolved ? connectionBounds(resolved, connection.style.size) : null;
|
|
53
|
+
}
|
|
54
|
+
/** Connections whose endpoints are both inside the given item set. */
|
|
55
|
+
function connectionsWithin(document, items) {
|
|
56
|
+
if (document.connections.length === 0) return [];
|
|
57
|
+
const present = new Set(items.map((item) => item.id));
|
|
58
|
+
return document.connections.filter((connection) => present.has(connection.source.nodeId) && present.has(connection.target.nodeId));
|
|
59
|
+
}
|
|
48
60
|
function resolveRegion(document, region, getFrame) {
|
|
49
61
|
switch (region.kind) {
|
|
50
|
-
case "all":
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
62
|
+
case "all": {
|
|
63
|
+
const connections = document.connections;
|
|
64
|
+
return {
|
|
65
|
+
items: document.items,
|
|
66
|
+
connections,
|
|
67
|
+
rect: unionRects([...document.items.map((item) => exportItemBounds(item)), ...connectionBoundsList(connections, getFrame)]),
|
|
68
|
+
padding: null
|
|
69
|
+
};
|
|
70
|
+
}
|
|
55
71
|
case "items": {
|
|
56
72
|
const wanted = new Set(region.ids);
|
|
57
73
|
const items = document.items.filter((item) => wanted.has(item.id));
|
|
74
|
+
const connections = connectionsWithin(document, items);
|
|
58
75
|
return {
|
|
59
76
|
items,
|
|
60
|
-
|
|
77
|
+
connections,
|
|
78
|
+
rect: unionRects([...items.map((item) => exportItemBounds(item)), ...connectionBoundsList(connections, getFrame)]),
|
|
61
79
|
padding: null
|
|
62
80
|
};
|
|
63
81
|
}
|
|
@@ -65,26 +83,39 @@ function resolveRegion(document, region, getFrame) {
|
|
|
65
83
|
const frame = document.items.find((item) => item.id === region.id);
|
|
66
84
|
if (!frame) return {
|
|
67
85
|
items: [],
|
|
86
|
+
connections: [],
|
|
68
87
|
rect: null,
|
|
69
88
|
padding: null
|
|
70
89
|
};
|
|
71
90
|
const rect = itemBounds(frame.frame);
|
|
91
|
+
const items = document.items.filter((item) => item.id !== region.id && rectsIntersect(exportItemBounds(item), rect));
|
|
72
92
|
return {
|
|
73
|
-
items
|
|
93
|
+
items,
|
|
94
|
+
connections: connectionsWithin(document, items),
|
|
74
95
|
rect,
|
|
75
96
|
padding: 0
|
|
76
97
|
};
|
|
77
98
|
}
|
|
78
99
|
case "rect": {
|
|
79
100
|
const rect = region.rect;
|
|
101
|
+
const items = document.items.filter((item) => rectsIntersect(exportItemBounds(item), rect));
|
|
80
102
|
return {
|
|
81
|
-
items
|
|
103
|
+
items,
|
|
104
|
+
connections: connectionsWithin(document, items),
|
|
82
105
|
rect,
|
|
83
106
|
padding: 0
|
|
84
107
|
};
|
|
85
108
|
}
|
|
86
109
|
}
|
|
87
110
|
}
|
|
111
|
+
function connectionBoundsList(connections, getFrame) {
|
|
112
|
+
const rects = [];
|
|
113
|
+
for (const connection of connections) {
|
|
114
|
+
const bounds = exportConnectionBounds(connection, getFrame);
|
|
115
|
+
if (bounds) rects.push(bounds);
|
|
116
|
+
}
|
|
117
|
+
return rects;
|
|
118
|
+
}
|
|
88
119
|
/**
|
|
89
120
|
* Largest scale that keeps the output inside both the edge and pixel budgets.
|
|
90
121
|
*
|
|
@@ -102,7 +133,8 @@ function clampScale(requested, world, maxEdge, maxPixels) {
|
|
|
102
133
|
* to draw. Callers treat null as "empty selection", not as an error.
|
|
103
134
|
*/
|
|
104
135
|
function planBoardExport(input) {
|
|
105
|
-
const {
|
|
136
|
+
const { region, scale: requestedScale = 2, maxEdge = BOARD_EXPORT_MAX_EDGE, maxPixels = BOARD_EXPORT_MAX_PIXELS } = input;
|
|
137
|
+
const document = normalizeBoardDocument(input.document);
|
|
106
138
|
const resolved = resolveRegion(document, region, boardFrameLookup(document));
|
|
107
139
|
if (!resolved.rect) return null;
|
|
108
140
|
const padding = input.padding ?? resolved.padding ?? 32;
|
|
@@ -121,8 +153,9 @@ function planBoardExport(input) {
|
|
|
121
153
|
width: Math.max(1, Math.floor(world.width * scale)),
|
|
122
154
|
height: Math.max(1, Math.floor(world.height * scale)),
|
|
123
155
|
items: resolved.items,
|
|
156
|
+
connections: resolved.connections,
|
|
124
157
|
clamped: scale < safeRequest - 1e-6
|
|
125
158
|
};
|
|
126
159
|
}
|
|
127
160
|
//#endregion
|
|
128
|
-
export { BOARD_EXPORT_DEFAULT_PADDING, BOARD_EXPORT_DEFAULT_SCALE, BOARD_EXPORT_ITEM_WARN_THRESHOLD, BOARD_EXPORT_MAX_EDGE, BOARD_EXPORT_MAX_PIXELS, boardFrameLookup, exportItemBounds, normalizeBoardDocument, planBoardExport };
|
|
161
|
+
export { BOARD_EXPORT_DEFAULT_PADDING, BOARD_EXPORT_DEFAULT_SCALE, BOARD_EXPORT_ITEM_WARN_THRESHOLD, BOARD_EXPORT_MAX_EDGE, BOARD_EXPORT_MAX_PIXELS, boardFrameLookup, exportConnectionBounds, exportItemBounds, normalizeBoardDocument, planBoardExport };
|
|
@@ -49,8 +49,14 @@ type ShapeCapabilities = {
|
|
|
49
49
|
canRotate: boolean;
|
|
50
50
|
/** Supports double-click inline editing. */
|
|
51
51
|
canEdit: boolean;
|
|
52
|
-
/**
|
|
53
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Can be an endpoint of a connection.
|
|
54
|
+
*
|
|
55
|
+
* False for shapes that are themselves annotation strokes (draw, arrow): a
|
|
56
|
+
* relation between two scribbles has no meaning the model could express, and
|
|
57
|
+
* allowing it would produce edges no reader could interpret.
|
|
58
|
+
*/
|
|
59
|
+
canConnect: boolean;
|
|
54
60
|
/** Participates in snapping as a target. */
|
|
55
61
|
canSnap: boolean;
|
|
56
62
|
/** Can be locked against accidental edits. */
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
+
import { BOARD_STROKE_MAX_SIZE, BOARD_STROKE_MIN_SIZE, clampBoardStrokeSize } from "../../protocol/dist/board-constants.js";
|
|
1
2
|
import { BoardColorId } from "./palette.js";
|
|
2
3
|
import { GeoKind } from "./shape-types.js";
|
|
3
4
|
//#region src/board/core/tool-styles.d.ts
|
|
4
|
-
declare const BOARD_STROKE_MIN_SIZE = 1;
|
|
5
|
-
declare const BOARD_STROKE_MAX_SIZE = 64;
|
|
6
5
|
type BoardToolStyleMap = {
|
|
7
6
|
text: {
|
|
8
7
|
color: BoardColorId;
|
|
@@ -19,6 +18,10 @@ type BoardToolStyleMap = {
|
|
|
19
18
|
color: BoardColorId;
|
|
20
19
|
size: number;
|
|
21
20
|
};
|
|
21
|
+
connection: {
|
|
22
|
+
color: BoardColorId;
|
|
23
|
+
size: number;
|
|
24
|
+
};
|
|
22
25
|
frame: {
|
|
23
26
|
color: BoardColorId;
|
|
24
27
|
};
|
|
@@ -41,11 +44,14 @@ declare const DEFAULT_BOARD_TOOL_STYLES: {
|
|
|
41
44
|
readonly color: "brand";
|
|
42
45
|
readonly size: 2.5;
|
|
43
46
|
};
|
|
47
|
+
readonly connection: {
|
|
48
|
+
readonly color: "neutral";
|
|
49
|
+
readonly size: 2.5;
|
|
50
|
+
};
|
|
44
51
|
readonly frame: {
|
|
45
52
|
readonly color: "neutral";
|
|
46
53
|
};
|
|
47
54
|
};
|
|
48
|
-
declare function clampBoardStrokeSize(size: number): number;
|
|
49
55
|
/** Return a mutable, validated style map for an editor or another Board client. */
|
|
50
56
|
declare function createBoardToolStyles(patch?: BoardToolStylePatch): BoardToolStyleMap;
|
|
51
57
|
//#endregion
|