@kekonic/diagrams-geometry 1.0.0-rc.4
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/LICENSE +21 -0
- package/README.md +23 -0
- package/dist/index.d.mts +393 -0
- package/dist/index.mjs +2420 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +43 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2420 @@
|
|
|
1
|
+
import { BUILTIN_KIND_CATALOG, normalizeShapeId as normalizeShapeId$1 } from "@kekonic/diagrams-core";
|
|
2
|
+
//#region src/types.ts
|
|
3
|
+
const CLEAR_SPACE_PRESETS = {
|
|
4
|
+
compact: 10,
|
|
5
|
+
standard: 20,
|
|
6
|
+
presentation: 32
|
|
7
|
+
};
|
|
8
|
+
const EDGE_LAUNCH_PRESETS = {
|
|
9
|
+
compact: 10,
|
|
10
|
+
standard: 16,
|
|
11
|
+
presentation: 24
|
|
12
|
+
};
|
|
13
|
+
/** Minimum interactive target in logical units (ports / markers). */
|
|
14
|
+
const MIN_INTERACTION_TARGET = 22;
|
|
15
|
+
const DEFAULT_STROKE_WIDTH = 1.6;
|
|
16
|
+
function uniformInsets(n) {
|
|
17
|
+
return {
|
|
18
|
+
top: n,
|
|
19
|
+
right: n,
|
|
20
|
+
bottom: n,
|
|
21
|
+
left: n
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function insets(partial, fallback = 0) {
|
|
25
|
+
return {
|
|
26
|
+
top: partial.top ?? fallback,
|
|
27
|
+
right: partial.right ?? fallback,
|
|
28
|
+
bottom: partial.bottom ?? fallback,
|
|
29
|
+
left: partial.left ?? fallback
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function insetRect(r, pad) {
|
|
33
|
+
const p = typeof pad === "number" ? uniformInsets(pad) : pad;
|
|
34
|
+
return {
|
|
35
|
+
x: r.x + p.left,
|
|
36
|
+
y: r.y + p.top,
|
|
37
|
+
width: Math.max(0, r.width - p.left - p.right),
|
|
38
|
+
height: Math.max(0, r.height - p.top - p.bottom)
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function expandRectInsets(r, pad) {
|
|
42
|
+
const p = typeof pad === "number" ? uniformInsets(pad) : pad;
|
|
43
|
+
return {
|
|
44
|
+
x: r.x - p.left,
|
|
45
|
+
y: r.y - p.top,
|
|
46
|
+
width: r.width + p.left + p.right,
|
|
47
|
+
height: r.height + p.top + p.bottom
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function unionRects(rects) {
|
|
51
|
+
if (rects.length === 0) return {
|
|
52
|
+
x: 0,
|
|
53
|
+
y: 0,
|
|
54
|
+
width: 0,
|
|
55
|
+
height: 0
|
|
56
|
+
};
|
|
57
|
+
let minX = Infinity;
|
|
58
|
+
let minY = Infinity;
|
|
59
|
+
let maxX = -Infinity;
|
|
60
|
+
let maxY = -Infinity;
|
|
61
|
+
for (const r of rects) {
|
|
62
|
+
minX = Math.min(minX, r.x);
|
|
63
|
+
minY = Math.min(minY, r.y);
|
|
64
|
+
maxX = Math.max(maxX, r.x + r.width);
|
|
65
|
+
maxY = Math.max(maxY, r.y + r.height);
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
x: minX,
|
|
69
|
+
y: minY,
|
|
70
|
+
width: maxX - minX,
|
|
71
|
+
height: maxY - minY
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function rectFromSize(size, origin = {
|
|
75
|
+
x: 0,
|
|
76
|
+
y: 0
|
|
77
|
+
}) {
|
|
78
|
+
return {
|
|
79
|
+
x: origin.x,
|
|
80
|
+
y: origin.y,
|
|
81
|
+
width: size.width,
|
|
82
|
+
height: size.height
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function normalizeVector(v) {
|
|
86
|
+
const len = Math.hypot(v.x, v.y) || 1;
|
|
87
|
+
return {
|
|
88
|
+
x: v.x / len,
|
|
89
|
+
y: v.y / len
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function sideNormal(side) {
|
|
93
|
+
switch (side) {
|
|
94
|
+
case "north": return {
|
|
95
|
+
x: 0,
|
|
96
|
+
y: -1
|
|
97
|
+
};
|
|
98
|
+
case "east": return {
|
|
99
|
+
x: 1,
|
|
100
|
+
y: 0
|
|
101
|
+
};
|
|
102
|
+
case "south": return {
|
|
103
|
+
x: 0,
|
|
104
|
+
y: 1
|
|
105
|
+
};
|
|
106
|
+
case "west": return {
|
|
107
|
+
x: -1,
|
|
108
|
+
y: 0
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function sideMidpoint(bounds, side) {
|
|
113
|
+
const cx = bounds.x + bounds.width / 2;
|
|
114
|
+
const cy = bounds.y + bounds.height / 2;
|
|
115
|
+
switch (side) {
|
|
116
|
+
case "north": return {
|
|
117
|
+
x: cx,
|
|
118
|
+
y: bounds.y
|
|
119
|
+
};
|
|
120
|
+
case "east": return {
|
|
121
|
+
x: bounds.x + bounds.width,
|
|
122
|
+
y: cy
|
|
123
|
+
};
|
|
124
|
+
case "south": return {
|
|
125
|
+
x: cx,
|
|
126
|
+
y: bounds.y + bounds.height
|
|
127
|
+
};
|
|
128
|
+
case "west": return {
|
|
129
|
+
x: bounds.x,
|
|
130
|
+
y: cy
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
//#endregion
|
|
135
|
+
//#region src/math.ts
|
|
136
|
+
/** Ray from origin along direction; returns closest intersection with t >= 0. */
|
|
137
|
+
function intersectRayPolygon(polygon, origin, direction) {
|
|
138
|
+
if (polygon.length < 3) return null;
|
|
139
|
+
const dir = normalizeVector(direction);
|
|
140
|
+
let bestT = Infinity;
|
|
141
|
+
let best = null;
|
|
142
|
+
for (let i = 0; i < polygon.length; i++) {
|
|
143
|
+
const a = polygon[i];
|
|
144
|
+
const b = polygon[(i + 1) % polygon.length];
|
|
145
|
+
const hit = raySegmentIntersection(origin, dir, a, b);
|
|
146
|
+
if (hit && hit.t >= 0 && hit.t < bestT) {
|
|
147
|
+
bestT = hit.t;
|
|
148
|
+
best = hit.point;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return best;
|
|
152
|
+
}
|
|
153
|
+
function raySegmentIntersection(origin, dir, a, b) {
|
|
154
|
+
const sx = b.x - a.x;
|
|
155
|
+
const sy = b.y - a.y;
|
|
156
|
+
const denom = dir.x * sy - dir.y * sx;
|
|
157
|
+
if (Math.abs(denom) < 1e-9) return null;
|
|
158
|
+
const qx = a.x - origin.x;
|
|
159
|
+
const qy = a.y - origin.y;
|
|
160
|
+
const t = (qx * sy - qy * sx) / denom;
|
|
161
|
+
const u = (qx * dir.y - qy * dir.x) / denom;
|
|
162
|
+
if (u < -1e-9 || u > 1.000000001) return null;
|
|
163
|
+
return {
|
|
164
|
+
t,
|
|
165
|
+
point: {
|
|
166
|
+
x: origin.x + dir.x * t,
|
|
167
|
+
y: origin.y + dir.y * t
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/** Ellipse / circle ray intersection (axis-aligned). */
|
|
172
|
+
function intersectRayEllipse(bounds, origin, direction) {
|
|
173
|
+
const cx = bounds.x + bounds.width / 2;
|
|
174
|
+
const cy = bounds.y + bounds.height / 2;
|
|
175
|
+
const rx = bounds.width / 2;
|
|
176
|
+
const ry = bounds.height / 2;
|
|
177
|
+
if (rx <= 0 || ry <= 0) return null;
|
|
178
|
+
const dir = normalizeVector(direction);
|
|
179
|
+
const ox = (origin.x - cx) / rx;
|
|
180
|
+
const oy = (origin.y - cy) / ry;
|
|
181
|
+
const dx = dir.x / rx;
|
|
182
|
+
const dy = dir.y / ry;
|
|
183
|
+
const a = dx * dx + dy * dy;
|
|
184
|
+
const b = 2 * (ox * dx + oy * dy);
|
|
185
|
+
const c = ox * ox + oy * oy - 1;
|
|
186
|
+
const disc = b * b - 4 * a * c;
|
|
187
|
+
if (disc < 0 || a === 0) return null;
|
|
188
|
+
const sqrt = Math.sqrt(disc);
|
|
189
|
+
const candidates = [(-b - sqrt) / (2 * a), (-b + sqrt) / (2 * a)].filter((t) => t >= 0);
|
|
190
|
+
if (!candidates.length) return null;
|
|
191
|
+
const t = Math.min(...candidates);
|
|
192
|
+
return {
|
|
193
|
+
x: origin.x + dir.x * t,
|
|
194
|
+
y: origin.y + dir.y * t
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
/** Axis-aligned rect ray intersection. */
|
|
198
|
+
function intersectRayRect(bounds, origin, direction) {
|
|
199
|
+
return intersectRayPolygon(rectPolygon(bounds), origin, direction);
|
|
200
|
+
}
|
|
201
|
+
function rectPolygon(bounds) {
|
|
202
|
+
return [
|
|
203
|
+
{
|
|
204
|
+
x: bounds.x,
|
|
205
|
+
y: bounds.y
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
x: bounds.x + bounds.width,
|
|
209
|
+
y: bounds.y
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
x: bounds.x + bounds.width,
|
|
213
|
+
y: bounds.y + bounds.height
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
x: bounds.x,
|
|
217
|
+
y: bounds.y + bounds.height
|
|
218
|
+
}
|
|
219
|
+
];
|
|
220
|
+
}
|
|
221
|
+
function pointInPolygon(point, polygon) {
|
|
222
|
+
let inside = false;
|
|
223
|
+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
224
|
+
const pi = polygon[i];
|
|
225
|
+
const pj = polygon[j];
|
|
226
|
+
if (pi.y > point.y !== pj.y > point.y && point.x < (pj.x - pi.x) * (point.y - pi.y) / (pj.y - pi.y + 1e-12) + pi.x) inside = !inside;
|
|
227
|
+
}
|
|
228
|
+
return inside;
|
|
229
|
+
}
|
|
230
|
+
function pointInEllipse(point, bounds) {
|
|
231
|
+
const cx = bounds.x + bounds.width / 2;
|
|
232
|
+
const cy = bounds.y + bounds.height / 2;
|
|
233
|
+
const rx = bounds.width / 2;
|
|
234
|
+
const ry = bounds.height / 2;
|
|
235
|
+
if (rx <= 0 || ry <= 0) return false;
|
|
236
|
+
const dx = (point.x - cx) / rx;
|
|
237
|
+
const dy = (point.y - cy) / ry;
|
|
238
|
+
return dx * dx + dy * dy <= 1;
|
|
239
|
+
}
|
|
240
|
+
function polygonToPath(points, close = true) {
|
|
241
|
+
if (!points.length) return "";
|
|
242
|
+
const [first, ...rest] = points;
|
|
243
|
+
let d = `M ${first.x} ${first.y}`;
|
|
244
|
+
for (const p of rest) d += ` L ${p.x} ${p.y}`;
|
|
245
|
+
if (close) d += " Z";
|
|
246
|
+
return d;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Closed Catmull–Rom spline as cubic Béziers.
|
|
250
|
+
* Yields smooth SVG path data (`C` commands) from a polygon of control points.
|
|
251
|
+
*/
|
|
252
|
+
function closedCatmullRomToPath(points) {
|
|
253
|
+
if (points.length < 3) return polygonToPath(points);
|
|
254
|
+
const n = points.length;
|
|
255
|
+
const first = points[0];
|
|
256
|
+
let d = `M ${first.x} ${first.y}`;
|
|
257
|
+
for (let i = 0; i < n; i++) {
|
|
258
|
+
const p0 = points[(i - 1 + n) % n];
|
|
259
|
+
const p1 = points[i];
|
|
260
|
+
const p2 = points[(i + 1) % n];
|
|
261
|
+
const p3 = points[(i + 2) % n];
|
|
262
|
+
const c1x = p1.x + (p2.x - p0.x) / 6;
|
|
263
|
+
const c1y = p1.y + (p2.y - p0.y) / 6;
|
|
264
|
+
const c2x = p2.x - (p3.x - p1.x) / 6;
|
|
265
|
+
const c2y = p2.y - (p3.y - p1.y) / 6;
|
|
266
|
+
d += ` C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2.x} ${p2.y}`;
|
|
267
|
+
}
|
|
268
|
+
return `${d} Z`;
|
|
269
|
+
}
|
|
270
|
+
/** Inscribed content rect as a fraction of geometry bounds, centered. */
|
|
271
|
+
function centeredContentRect(bounds, widthRatio, heightRatio) {
|
|
272
|
+
const w = bounds.width * widthRatio;
|
|
273
|
+
const h = bounds.height * heightRatio;
|
|
274
|
+
return {
|
|
275
|
+
x: bounds.x + (bounds.width - w) / 2,
|
|
276
|
+
y: bounds.y + (bounds.height - h) / 2,
|
|
277
|
+
width: w,
|
|
278
|
+
height: h
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/** Stroke expands visual bounds by half stroke on each side. */
|
|
282
|
+
function strokeOutset(strokeWidth) {
|
|
283
|
+
return Math.max(0, strokeWidth / 2);
|
|
284
|
+
}
|
|
285
|
+
//#endregion
|
|
286
|
+
//#region src/shape-geometry.ts
|
|
287
|
+
/** Assemble the full node bounds model from a geometry + style + context. */
|
|
288
|
+
function buildNodeBoundsModel(geometry, bounds, style = {}, context = {}) {
|
|
289
|
+
const content = geometry.getContentBounds(bounds, style);
|
|
290
|
+
const extras = [geometry.getVisualBounds(bounds, style)];
|
|
291
|
+
if (context.externalLabels) extras.push(...context.externalLabels);
|
|
292
|
+
if (context.badges) extras.push(...context.badges);
|
|
293
|
+
const visualWithChrome = unionRects(extras);
|
|
294
|
+
const density = context.density ?? "standard";
|
|
295
|
+
const baseClear = CLEAR_SPACE_PRESETS[density];
|
|
296
|
+
const boost = geometry.clearSpaceBoost ?? 0;
|
|
297
|
+
const clearSpace = insets(context.clearSpace ?? {}, baseClear + boost);
|
|
298
|
+
const launch = context.edgeLaunch ?? EDGE_LAUNCH_PRESETS[density];
|
|
299
|
+
const footprint = expandRectInsets(visualWithChrome, {
|
|
300
|
+
top: clearSpace.top + launch,
|
|
301
|
+
right: clearSpace.right + launch,
|
|
302
|
+
bottom: clearSpace.bottom + launch,
|
|
303
|
+
left: clearSpace.left + launch
|
|
304
|
+
});
|
|
305
|
+
const interactionPad = Math.max(0, (22 - Math.min(bounds.width, bounds.height)) / 2);
|
|
306
|
+
const interaction = interactionPad > 0 ? expandRectInsets(visualWithChrome, interactionPad) : visualWithChrome;
|
|
307
|
+
return {
|
|
308
|
+
geometry: { ...bounds },
|
|
309
|
+
content,
|
|
310
|
+
visual: visualWithChrome,
|
|
311
|
+
interaction,
|
|
312
|
+
footprint,
|
|
313
|
+
clearSpace
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
/** Shared helpers for concrete geometries. */
|
|
317
|
+
function defaultVisualBounds(bounds, style = {}) {
|
|
318
|
+
let visual = expandRectInsets(bounds, strokeOutset(style.strokeWidth ?? 1.6));
|
|
319
|
+
if (style.shadowBlur || style.shadowOffsetX || style.shadowOffsetY) {
|
|
320
|
+
const blur = style.shadowBlur ?? 0;
|
|
321
|
+
const ox = style.shadowOffsetX ?? 0;
|
|
322
|
+
const oy = style.shadowOffsetY ?? 0;
|
|
323
|
+
visual = unionRects([visual, {
|
|
324
|
+
x: bounds.x + Math.min(0, ox) - blur,
|
|
325
|
+
y: bounds.y + Math.min(0, oy) - blur,
|
|
326
|
+
width: bounds.width + Math.abs(ox) + blur * 2,
|
|
327
|
+
height: bounds.height + Math.abs(oy) + blur * 2
|
|
328
|
+
}]);
|
|
329
|
+
}
|
|
330
|
+
return visual;
|
|
331
|
+
}
|
|
332
|
+
function defaultLayoutFootprint(geometry, bounds, context = {}, style = {}) {
|
|
333
|
+
return buildNodeBoundsModel(geometry, bounds, style, context).footprint;
|
|
334
|
+
}
|
|
335
|
+
function defaultSidePortPosition(port, bounds) {
|
|
336
|
+
if (port.kind === "center") return {
|
|
337
|
+
x: bounds.x + bounds.width / 2,
|
|
338
|
+
y: bounds.y + bounds.height / 2
|
|
339
|
+
};
|
|
340
|
+
if (port.kind === "corner") switch (port.corner) {
|
|
341
|
+
case "northwest": return {
|
|
342
|
+
x: bounds.x,
|
|
343
|
+
y: bounds.y
|
|
344
|
+
};
|
|
345
|
+
case "northeast": return {
|
|
346
|
+
x: bounds.x + bounds.width,
|
|
347
|
+
y: bounds.y
|
|
348
|
+
};
|
|
349
|
+
case "southeast": return {
|
|
350
|
+
x: bounds.x + bounds.width,
|
|
351
|
+
y: bounds.y + bounds.height
|
|
352
|
+
};
|
|
353
|
+
case "southwest": return {
|
|
354
|
+
x: bounds.x,
|
|
355
|
+
y: bounds.y + bounds.height
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
if (port.kind === "radial") {
|
|
359
|
+
const cx = bounds.x + bounds.width / 2;
|
|
360
|
+
const cy = bounds.y + bounds.height / 2;
|
|
361
|
+
const rad = port.angleDeg * Math.PI / 180;
|
|
362
|
+
return {
|
|
363
|
+
x: cx + Math.cos(rad) * (bounds.width / 2),
|
|
364
|
+
y: cy + Math.sin(rad) * (bounds.height / 2)
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
if (port.kind === "named") return sideMidpoint(bounds, "east");
|
|
368
|
+
const side = port.side;
|
|
369
|
+
const count = Math.max(1, port.count ?? 1);
|
|
370
|
+
const index = Math.min(Math.max(0, port.index ?? 0), count - 1);
|
|
371
|
+
if (count === 1) return sideMidpoint(bounds, side);
|
|
372
|
+
return distributedSidePort(bounds, side, index, count);
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Place a side port on a non-rect silhouette.
|
|
376
|
+
* Starts from the AABB mid/distributed point, then casts from the shape center
|
|
377
|
+
* onto the outline so FIXED_POS pins and perimeter snap share one attach truth.
|
|
378
|
+
*/
|
|
379
|
+
function projectSidePortOntoOutline(port, bounds, hitFromCenter) {
|
|
380
|
+
const bboxPt = defaultSidePortPosition(port, bounds);
|
|
381
|
+
if (port.kind !== "side") return bboxPt;
|
|
382
|
+
const cx = bounds.x + bounds.width / 2;
|
|
383
|
+
const cy = bounds.y + bounds.height / 2;
|
|
384
|
+
const direction = {
|
|
385
|
+
x: bboxPt.x - cx,
|
|
386
|
+
y: bboxPt.y - cy
|
|
387
|
+
};
|
|
388
|
+
if (Math.hypot(direction.x, direction.y) < 1e-9) return bboxPt;
|
|
389
|
+
return hitFromCenter(direction) ?? bboxPt;
|
|
390
|
+
}
|
|
391
|
+
/** Convenience: project onto a polygon silhouette via center→AABB ray. */
|
|
392
|
+
function projectSidePortOntoPolygon(port, bounds, polygon, intersect) {
|
|
393
|
+
return projectSidePortOntoOutline(port, bounds, (direction) => intersect(polygon, {
|
|
394
|
+
x: bounds.x + bounds.width / 2,
|
|
395
|
+
y: bounds.y + bounds.height / 2
|
|
396
|
+
}, direction));
|
|
397
|
+
}
|
|
398
|
+
function distributedSidePort(bounds, side, index, count, sideInset = 8) {
|
|
399
|
+
const usable = side === "north" || side === "south" ? Math.max(0, bounds.width - sideInset * 2) : Math.max(0, bounds.height - sideInset * 2);
|
|
400
|
+
const offset = sideInset + (count > 1 ? usable / (count - 1) : 0) * index;
|
|
401
|
+
switch (side) {
|
|
402
|
+
case "north": return {
|
|
403
|
+
x: bounds.x + offset,
|
|
404
|
+
y: bounds.y
|
|
405
|
+
};
|
|
406
|
+
case "south": return {
|
|
407
|
+
x: bounds.x + offset,
|
|
408
|
+
y: bounds.y + bounds.height
|
|
409
|
+
};
|
|
410
|
+
case "east": return {
|
|
411
|
+
x: bounds.x + bounds.width,
|
|
412
|
+
y: bounds.y + offset
|
|
413
|
+
};
|
|
414
|
+
case "west": return {
|
|
415
|
+
x: bounds.x,
|
|
416
|
+
y: bounds.y + offset
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
function defaultPortNormal(port, _bounds) {
|
|
421
|
+
if (port.kind === "side") return sideNormal(port.side);
|
|
422
|
+
if (port.kind === "corner") switch (port.corner) {
|
|
423
|
+
case "northwest": return normalizeVec({
|
|
424
|
+
x: -1,
|
|
425
|
+
y: -1
|
|
426
|
+
});
|
|
427
|
+
case "northeast": return normalizeVec({
|
|
428
|
+
x: 1,
|
|
429
|
+
y: -1
|
|
430
|
+
});
|
|
431
|
+
case "southeast": return normalizeVec({
|
|
432
|
+
x: 1,
|
|
433
|
+
y: 1
|
|
434
|
+
});
|
|
435
|
+
case "southwest": return normalizeVec({
|
|
436
|
+
x: -1,
|
|
437
|
+
y: 1
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
if (port.kind === "radial") {
|
|
441
|
+
const rad = port.angleDeg * Math.PI / 180;
|
|
442
|
+
return {
|
|
443
|
+
x: Math.cos(rad),
|
|
444
|
+
y: Math.sin(rad)
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
if (port.kind === "center") return {
|
|
448
|
+
x: 1,
|
|
449
|
+
y: 0
|
|
450
|
+
};
|
|
451
|
+
return {
|
|
452
|
+
x: 1,
|
|
453
|
+
y: 0
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
function normalizeVec(v) {
|
|
457
|
+
const len = Math.hypot(v.x, v.y) || 1;
|
|
458
|
+
return {
|
|
459
|
+
x: v.x / len,
|
|
460
|
+
y: v.y / len
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
//#endregion
|
|
464
|
+
//#region src/shapes/rectangle.ts
|
|
465
|
+
function resolveRadius(bounds, style, opts) {
|
|
466
|
+
if (opts.pill) return bounds.height / 2;
|
|
467
|
+
const configured = style?.cornerRadius ?? opts.cornerRadius ?? 0;
|
|
468
|
+
return Math.min(configured, bounds.width / 2, bounds.height / 2);
|
|
469
|
+
}
|
|
470
|
+
/** Sampled rounded-rect / capsule outline for ports, rays, and hit tests. */
|
|
471
|
+
function roundedRectPolygon(bounds, r, arcSamples = 6) {
|
|
472
|
+
if (r <= 0) return rectPolygon(bounds);
|
|
473
|
+
const { x, y, width, height } = bounds;
|
|
474
|
+
const radius = Math.min(r, width / 2, height / 2);
|
|
475
|
+
const pts = [];
|
|
476
|
+
const arc = (cx, cy, start, end) => {
|
|
477
|
+
for (let i = 0; i <= arcSamples; i++) {
|
|
478
|
+
const t = start + (end - start) * i / arcSamples;
|
|
479
|
+
pts.push({
|
|
480
|
+
x: cx + radius * Math.cos(t),
|
|
481
|
+
y: cy + radius * Math.sin(t)
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
arc(x + radius, y + radius, -Math.PI, -Math.PI / 2);
|
|
486
|
+
arc(x + width - radius, y + radius, -Math.PI / 2, 0);
|
|
487
|
+
arc(x + width - radius, y + height - radius, 0, Math.PI / 2);
|
|
488
|
+
arc(x + radius, y + height - radius, Math.PI / 2, Math.PI);
|
|
489
|
+
return pts;
|
|
490
|
+
}
|
|
491
|
+
function createRectangleGeometry(opts = { id: "rectangle" }) {
|
|
492
|
+
const geometry = {
|
|
493
|
+
id: opts.id,
|
|
494
|
+
defaultSize: opts.defaultSize ?? {
|
|
495
|
+
width: 160,
|
|
496
|
+
height: 72
|
|
497
|
+
},
|
|
498
|
+
minSize: opts.minSize ?? {
|
|
499
|
+
width: 48,
|
|
500
|
+
height: 32
|
|
501
|
+
},
|
|
502
|
+
defaultPadding: opts.defaultPadding ?? {
|
|
503
|
+
top: 16,
|
|
504
|
+
right: 18,
|
|
505
|
+
bottom: 16,
|
|
506
|
+
left: 18
|
|
507
|
+
},
|
|
508
|
+
contentPolicy: opts.contentPolicy ?? {
|
|
509
|
+
align: "start",
|
|
510
|
+
maxLabelLines: 2,
|
|
511
|
+
iconPlacement: "leading"
|
|
512
|
+
},
|
|
513
|
+
getPath(bounds, style = {}) {
|
|
514
|
+
const r = resolveRadius(bounds, style, opts);
|
|
515
|
+
const { x, y, width, height } = bounds;
|
|
516
|
+
if (r <= 0) {
|
|
517
|
+
const polygon = rectPolygon(bounds);
|
|
518
|
+
return {
|
|
519
|
+
d: polygonToPath(polygon),
|
|
520
|
+
polygon
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
return {
|
|
524
|
+
d: [
|
|
525
|
+
`M ${x + r} ${y}`,
|
|
526
|
+
`L ${x + width - r} ${y}`,
|
|
527
|
+
`A ${r} ${r} 0 0 1 ${x + width} ${y + r}`,
|
|
528
|
+
`L ${x + width} ${y + height - r}`,
|
|
529
|
+
`A ${r} ${r} 0 0 1 ${x + width - r} ${y + height}`,
|
|
530
|
+
`L ${x + r} ${y + height}`,
|
|
531
|
+
`A ${r} ${r} 0 0 1 ${x} ${y + height - r}`,
|
|
532
|
+
`L ${x} ${y + r}`,
|
|
533
|
+
`A ${r} ${r} 0 0 1 ${x + r} ${y}`,
|
|
534
|
+
`Z`
|
|
535
|
+
].join(" "),
|
|
536
|
+
polygon: roundedRectPolygon(bounds, r)
|
|
537
|
+
};
|
|
538
|
+
},
|
|
539
|
+
getContentBounds(bounds, style = {}) {
|
|
540
|
+
const stroke = style.strokeWidth ?? 1.6;
|
|
541
|
+
const r = resolveRadius(bounds, style, opts);
|
|
542
|
+
return insetRect(bounds, Math.max(stroke, r > 0 ? r * .35 : 0) + 2);
|
|
543
|
+
},
|
|
544
|
+
getVisualBounds(bounds, style) {
|
|
545
|
+
return defaultVisualBounds(bounds, style);
|
|
546
|
+
},
|
|
547
|
+
getLayoutFootprint(bounds, context, style) {
|
|
548
|
+
return defaultLayoutFootprint(geometry, bounds, context, style);
|
|
549
|
+
},
|
|
550
|
+
getPortPosition(port, bounds, style = {}) {
|
|
551
|
+
const r = resolveRadius(bounds, style, opts);
|
|
552
|
+
if (r <= 0) return defaultSidePortPosition(port, bounds);
|
|
553
|
+
return projectSidePortOntoPolygon(port, bounds, roundedRectPolygon(bounds, r), intersectRayPolygon);
|
|
554
|
+
},
|
|
555
|
+
getPortNormal(port, bounds) {
|
|
556
|
+
return defaultPortNormal(port, bounds);
|
|
557
|
+
},
|
|
558
|
+
intersectRay(bounds, origin, direction, style = {}) {
|
|
559
|
+
const r = resolveRadius(bounds, style, opts);
|
|
560
|
+
if (r <= 0) return intersectRayRect(bounds, origin, direction);
|
|
561
|
+
return intersectRayPolygon(roundedRectPolygon(bounds, r), origin, direction);
|
|
562
|
+
},
|
|
563
|
+
containsPoint(bounds, point, style = {}) {
|
|
564
|
+
const r = resolveRadius(bounds, style, opts);
|
|
565
|
+
if (r <= 0) return pointInPolygon(point, rectPolygon(bounds));
|
|
566
|
+
return pointInPolygon(point, roundedRectPolygon(bounds, r));
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
return geometry;
|
|
570
|
+
}
|
|
571
|
+
const rectangleGeometry = createRectangleGeometry({ id: "rectangle" });
|
|
572
|
+
const roundedRectangleGeometry = createRectangleGeometry({
|
|
573
|
+
id: "rounded",
|
|
574
|
+
cornerRadius: 12,
|
|
575
|
+
contentPolicy: {
|
|
576
|
+
align: "start",
|
|
577
|
+
maxLabelLines: 2,
|
|
578
|
+
iconPlacement: "leading"
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
const pillGeometry = createRectangleGeometry({
|
|
582
|
+
id: "pill",
|
|
583
|
+
pill: true,
|
|
584
|
+
defaultSize: {
|
|
585
|
+
width: 120,
|
|
586
|
+
height: 40
|
|
587
|
+
},
|
|
588
|
+
contentPolicy: {
|
|
589
|
+
align: "center",
|
|
590
|
+
maxLabelLines: 1,
|
|
591
|
+
iconPlacement: "leading"
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
//#endregion
|
|
595
|
+
//#region src/shapes/diamond.ts
|
|
596
|
+
/** Diamond tips sit on bbox mid-sides so LR/TD ports land on vertices. */
|
|
597
|
+
function diamondPolygon(bounds) {
|
|
598
|
+
const cx = bounds.x + bounds.width / 2;
|
|
599
|
+
const cy = bounds.y + bounds.height / 2;
|
|
600
|
+
return [
|
|
601
|
+
{
|
|
602
|
+
x: cx,
|
|
603
|
+
y: bounds.y
|
|
604
|
+
},
|
|
605
|
+
{
|
|
606
|
+
x: bounds.x + bounds.width,
|
|
607
|
+
y: cy
|
|
608
|
+
},
|
|
609
|
+
{
|
|
610
|
+
x: cx,
|
|
611
|
+
y: bounds.y + bounds.height
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
x: bounds.x,
|
|
615
|
+
y: cy
|
|
616
|
+
}
|
|
617
|
+
];
|
|
618
|
+
}
|
|
619
|
+
function diamondPointsString(bounds) {
|
|
620
|
+
return diamondPolygon(bounds).map((p) => `${p.x},${p.y}`).join(" ");
|
|
621
|
+
}
|
|
622
|
+
const diamondGeometry = {
|
|
623
|
+
id: "diamond",
|
|
624
|
+
defaultSize: {
|
|
625
|
+
width: 140,
|
|
626
|
+
height: 100
|
|
627
|
+
},
|
|
628
|
+
minSize: {
|
|
629
|
+
width: 64,
|
|
630
|
+
height: 64
|
|
631
|
+
},
|
|
632
|
+
defaultPadding: {
|
|
633
|
+
top: 12,
|
|
634
|
+
right: 12,
|
|
635
|
+
bottom: 12,
|
|
636
|
+
left: 12
|
|
637
|
+
},
|
|
638
|
+
contentPolicy: {
|
|
639
|
+
align: "center",
|
|
640
|
+
maxLabelLines: 2,
|
|
641
|
+
preferExternalLabel: false,
|
|
642
|
+
iconPlacement: "none"
|
|
643
|
+
},
|
|
644
|
+
clearSpaceBoost: 6,
|
|
645
|
+
getPath(bounds) {
|
|
646
|
+
const polygon = diamondPolygon(bounds);
|
|
647
|
+
return {
|
|
648
|
+
d: polygonToPath(polygon),
|
|
649
|
+
polygon
|
|
650
|
+
};
|
|
651
|
+
},
|
|
652
|
+
getContentBounds(bounds, style = {}) {
|
|
653
|
+
const stroke = style.strokeWidth ?? 1.6;
|
|
654
|
+
return insetRect(centeredContentRect(bounds, .5, .5), stroke);
|
|
655
|
+
},
|
|
656
|
+
getVisualBounds(bounds, style) {
|
|
657
|
+
return defaultVisualBounds(bounds, style);
|
|
658
|
+
},
|
|
659
|
+
getLayoutFootprint(bounds, context, style) {
|
|
660
|
+
return defaultLayoutFootprint(diamondGeometry, bounds, context, style);
|
|
661
|
+
},
|
|
662
|
+
getPortPosition(port, bounds) {
|
|
663
|
+
return projectSidePortOntoPolygon(port, bounds, diamondPolygon(bounds), intersectRayPolygon);
|
|
664
|
+
},
|
|
665
|
+
getPortNormal(port, bounds) {
|
|
666
|
+
return defaultPortNormal(port, bounds);
|
|
667
|
+
},
|
|
668
|
+
intersectRay(bounds, origin, direction) {
|
|
669
|
+
return intersectRayPolygon(diamondPolygon(bounds), origin, direction);
|
|
670
|
+
},
|
|
671
|
+
containsPoint(bounds, point) {
|
|
672
|
+
return pointInPolygon(point, diamondPolygon(bounds));
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
//#endregion
|
|
676
|
+
//#region src/shapes/hexagon.ts
|
|
677
|
+
/** Flat-top hexagon inset: height-led so wide short nodes keep real chamfers. */
|
|
678
|
+
function hexagonInset(width, height) {
|
|
679
|
+
return Math.min(width * .22, height * .42, width * .5 - 4);
|
|
680
|
+
}
|
|
681
|
+
function hexagonPolygon(bounds) {
|
|
682
|
+
const { x, y, width, height } = bounds;
|
|
683
|
+
const inset = hexagonInset(width, height);
|
|
684
|
+
const midY = y + height / 2;
|
|
685
|
+
return [
|
|
686
|
+
{
|
|
687
|
+
x: x + inset,
|
|
688
|
+
y
|
|
689
|
+
},
|
|
690
|
+
{
|
|
691
|
+
x: x + width - inset,
|
|
692
|
+
y
|
|
693
|
+
},
|
|
694
|
+
{
|
|
695
|
+
x: x + width,
|
|
696
|
+
y: midY
|
|
697
|
+
},
|
|
698
|
+
{
|
|
699
|
+
x: x + width - inset,
|
|
700
|
+
y: y + height
|
|
701
|
+
},
|
|
702
|
+
{
|
|
703
|
+
x: x + inset,
|
|
704
|
+
y: y + height
|
|
705
|
+
},
|
|
706
|
+
{
|
|
707
|
+
x,
|
|
708
|
+
y: midY
|
|
709
|
+
}
|
|
710
|
+
];
|
|
711
|
+
}
|
|
712
|
+
function hexagonPointsString(bounds) {
|
|
713
|
+
return hexagonPolygon(bounds).map((p) => `${p.x},${p.y}`).join(" ");
|
|
714
|
+
}
|
|
715
|
+
function createHexGeometry(id) {
|
|
716
|
+
const geometry = {
|
|
717
|
+
id,
|
|
718
|
+
defaultSize: {
|
|
719
|
+
width: 160,
|
|
720
|
+
height: 72
|
|
721
|
+
},
|
|
722
|
+
minSize: {
|
|
723
|
+
width: 72,
|
|
724
|
+
height: 40
|
|
725
|
+
},
|
|
726
|
+
defaultPadding: {
|
|
727
|
+
top: 14,
|
|
728
|
+
right: 20,
|
|
729
|
+
bottom: 14,
|
|
730
|
+
left: 20
|
|
731
|
+
},
|
|
732
|
+
contentPolicy: {
|
|
733
|
+
align: "center",
|
|
734
|
+
maxLabelLines: 2,
|
|
735
|
+
iconPlacement: "none"
|
|
736
|
+
},
|
|
737
|
+
getPath(bounds) {
|
|
738
|
+
const polygon = hexagonPolygon(bounds);
|
|
739
|
+
return {
|
|
740
|
+
d: polygonToPath(polygon),
|
|
741
|
+
polygon
|
|
742
|
+
};
|
|
743
|
+
},
|
|
744
|
+
getContentBounds(bounds, style = {}) {
|
|
745
|
+
const stroke = style.strokeWidth ?? 1.6;
|
|
746
|
+
const inset = hexagonInset(bounds.width, bounds.height);
|
|
747
|
+
return {
|
|
748
|
+
x: bounds.x + inset,
|
|
749
|
+
y: bounds.y + stroke + 4,
|
|
750
|
+
width: Math.max(0, bounds.width - inset * 2),
|
|
751
|
+
height: Math.max(0, bounds.height - (stroke + 4) * 2)
|
|
752
|
+
};
|
|
753
|
+
},
|
|
754
|
+
getVisualBounds(bounds, style) {
|
|
755
|
+
return defaultVisualBounds(bounds, style);
|
|
756
|
+
},
|
|
757
|
+
getLayoutFootprint(bounds, context, style) {
|
|
758
|
+
return defaultLayoutFootprint(geometry, bounds, context, style);
|
|
759
|
+
},
|
|
760
|
+
getPortPosition(port, bounds) {
|
|
761
|
+
return projectSidePortOntoPolygon(port, bounds, hexagonPolygon(bounds), intersectRayPolygon);
|
|
762
|
+
},
|
|
763
|
+
getPortNormal(port, bounds) {
|
|
764
|
+
return defaultPortNormal(port, bounds);
|
|
765
|
+
},
|
|
766
|
+
intersectRay(bounds, origin, direction) {
|
|
767
|
+
return intersectRayPolygon(hexagonPolygon(bounds), origin, direction);
|
|
768
|
+
},
|
|
769
|
+
containsPoint(bounds, point) {
|
|
770
|
+
return pointInPolygon(point, hexagonPolygon(bounds));
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
return geometry;
|
|
774
|
+
}
|
|
775
|
+
const hexagonGeometry = createHexGeometry("hexagon");
|
|
776
|
+
//#endregion
|
|
777
|
+
//#region src/shapes/cylinder.ts
|
|
778
|
+
function cylinderRadii(width, height) {
|
|
779
|
+
return {
|
|
780
|
+
rx: width / 2,
|
|
781
|
+
ry: Math.min(Math.max(width * .125, 7), height * .16, 13)
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Cylinder as one closed silhouette + front rim arc.
|
|
786
|
+
* Prefer a single coordinated path set for intersection/hit testing.
|
|
787
|
+
*/
|
|
788
|
+
function cylinderPaths(bounds) {
|
|
789
|
+
const { x, y, width, height } = bounds;
|
|
790
|
+
const { rx, ry } = cylinderRadii(width, height);
|
|
791
|
+
return {
|
|
792
|
+
body: [
|
|
793
|
+
`M ${x} ${y + ry}`,
|
|
794
|
+
`a ${rx} ${ry} 0 0 1 ${width} 0`,
|
|
795
|
+
`l 0 ${height - 2 * ry}`,
|
|
796
|
+
`a ${rx} ${ry} 0 0 1 ${-width} 0`,
|
|
797
|
+
`z`
|
|
798
|
+
].join(" "),
|
|
799
|
+
rim: `M ${x} ${y + ry} a ${rx} ${ry} 0 0 0 ${width} 0`
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Sampled silhouette matching `cylinderPaths` body (top arc → right side → bottom arc → left).
|
|
804
|
+
* Used for perimeter attachment so tall cylinders hit vertical walls, not a bounding ellipse.
|
|
805
|
+
*/
|
|
806
|
+
function cylinderSilhouettePolygon(bounds, samplesPerCap = 16) {
|
|
807
|
+
const { x, y, width, height } = bounds;
|
|
808
|
+
const { rx, ry } = cylinderRadii(width, height);
|
|
809
|
+
const cx = x + rx;
|
|
810
|
+
const pts = [];
|
|
811
|
+
for (let i = 0; i <= samplesPerCap; i++) {
|
|
812
|
+
const t = Math.PI - i / samplesPerCap * Math.PI;
|
|
813
|
+
pts.push({
|
|
814
|
+
x: cx + rx * Math.cos(t),
|
|
815
|
+
y: y + ry - ry * Math.sin(t)
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
for (let i = 0; i <= samplesPerCap; i++) {
|
|
819
|
+
const t = i / samplesPerCap * Math.PI;
|
|
820
|
+
pts.push({
|
|
821
|
+
x: cx + rx * Math.cos(t),
|
|
822
|
+
y: y + height - ry + ry * Math.sin(t)
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
return pts;
|
|
826
|
+
}
|
|
827
|
+
const cylinderGeometry = {
|
|
828
|
+
id: "cylinder",
|
|
829
|
+
defaultSize: {
|
|
830
|
+
width: 140,
|
|
831
|
+
height: 88
|
|
832
|
+
},
|
|
833
|
+
minSize: {
|
|
834
|
+
width: 64,
|
|
835
|
+
height: 56
|
|
836
|
+
},
|
|
837
|
+
defaultPadding: {
|
|
838
|
+
top: 22,
|
|
839
|
+
right: 16,
|
|
840
|
+
bottom: 14,
|
|
841
|
+
left: 16
|
|
842
|
+
},
|
|
843
|
+
contentPolicy: {
|
|
844
|
+
align: "center",
|
|
845
|
+
maxLabelLines: 2,
|
|
846
|
+
iconPlacement: "none"
|
|
847
|
+
},
|
|
848
|
+
getPath(bounds) {
|
|
849
|
+
const { body, rim } = cylinderPaths(bounds);
|
|
850
|
+
return {
|
|
851
|
+
d: body,
|
|
852
|
+
decorations: [{
|
|
853
|
+
d: rim,
|
|
854
|
+
role: "rim"
|
|
855
|
+
}]
|
|
856
|
+
};
|
|
857
|
+
},
|
|
858
|
+
getContentBounds(bounds, style = {}) {
|
|
859
|
+
const stroke = style.strokeWidth ?? 1.6;
|
|
860
|
+
const { ry } = cylinderRadii(bounds.width, bounds.height);
|
|
861
|
+
const topReserve = Math.max(ry * 2, bounds.height * .22);
|
|
862
|
+
return {
|
|
863
|
+
x: bounds.x + stroke + 8,
|
|
864
|
+
y: bounds.y + topReserve,
|
|
865
|
+
width: Math.max(0, bounds.width - (stroke + 8) * 2),
|
|
866
|
+
height: Math.max(0, bounds.height - topReserve - stroke - 8)
|
|
867
|
+
};
|
|
868
|
+
},
|
|
869
|
+
getVisualBounds(bounds, style) {
|
|
870
|
+
return defaultVisualBounds(bounds, style);
|
|
871
|
+
},
|
|
872
|
+
getLayoutFootprint(bounds, context, style) {
|
|
873
|
+
return defaultLayoutFootprint(cylinderGeometry, bounds, context, style);
|
|
874
|
+
},
|
|
875
|
+
getPortPosition(port, bounds) {
|
|
876
|
+
return projectSidePortOntoPolygon(port, bounds, cylinderSilhouettePolygon(bounds), intersectRayPolygon);
|
|
877
|
+
},
|
|
878
|
+
getPortNormal(port, bounds) {
|
|
879
|
+
return defaultPortNormal(port, bounds);
|
|
880
|
+
},
|
|
881
|
+
intersectRay(bounds, origin, direction) {
|
|
882
|
+
return intersectRayPolygon(cylinderSilhouettePolygon(bounds), origin, direction);
|
|
883
|
+
},
|
|
884
|
+
containsPoint(bounds, point) {
|
|
885
|
+
const { ry } = cylinderRadii(bounds.width, bounds.height);
|
|
886
|
+
const top = {
|
|
887
|
+
x: bounds.x,
|
|
888
|
+
y: bounds.y,
|
|
889
|
+
width: bounds.width,
|
|
890
|
+
height: ry * 2
|
|
891
|
+
};
|
|
892
|
+
const bottom = {
|
|
893
|
+
x: bounds.x,
|
|
894
|
+
y: bounds.y + bounds.height - ry * 2,
|
|
895
|
+
width: bounds.width,
|
|
896
|
+
height: ry * 2
|
|
897
|
+
};
|
|
898
|
+
const body = {
|
|
899
|
+
x: bounds.x,
|
|
900
|
+
y: bounds.y + ry,
|
|
901
|
+
width: bounds.width,
|
|
902
|
+
height: Math.max(0, bounds.height - ry * 2)
|
|
903
|
+
};
|
|
904
|
+
if (point.x >= body.x && point.x <= body.x + body.width && point.y >= body.y && point.y <= body.y + body.height) return true;
|
|
905
|
+
return pointInEllipse(point, top) || pointInEllipse(point, bottom);
|
|
906
|
+
}
|
|
907
|
+
};
|
|
908
|
+
//#endregion
|
|
909
|
+
//#region src/shapes/queue.ts
|
|
910
|
+
/**
|
|
911
|
+
* Horizontal pipe (sideways cylinder) — the ubiquitous message-queue silhouette.
|
|
912
|
+
* End-cap radius is height-led so short wide queues keep readable openings.
|
|
913
|
+
*/
|
|
914
|
+
function queueRadii(width, height) {
|
|
915
|
+
const ry = height / 2;
|
|
916
|
+
return {
|
|
917
|
+
rx: Math.min(Math.max(height * .125, 7), width * .16, 13),
|
|
918
|
+
ry
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Pipe body + left-end rim (front opening), matching vertical cylinder conventions.
|
|
923
|
+
*/
|
|
924
|
+
function queuePaths(bounds) {
|
|
925
|
+
const { x, y, width, height } = bounds;
|
|
926
|
+
const { rx, ry } = queueRadii(width, height);
|
|
927
|
+
return {
|
|
928
|
+
body: [
|
|
929
|
+
`M ${x + rx} ${y}`,
|
|
930
|
+
`l ${width - 2 * rx} 0`,
|
|
931
|
+
`a ${rx} ${ry} 0 0 1 0 ${height}`,
|
|
932
|
+
`l ${-(width - 2 * rx)} 0`,
|
|
933
|
+
`a ${rx} ${ry} 0 0 1 0 ${-height}`,
|
|
934
|
+
`z`
|
|
935
|
+
].join(" "),
|
|
936
|
+
rim: `M ${x + rx} ${y} a ${rx} ${ry} 0 0 0 0 ${height}`
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* Sampled silhouette for ports / ray hits (left ellipse → top → right ellipse → bottom).
|
|
941
|
+
*/
|
|
942
|
+
function queueSilhouettePolygon(bounds, samplesPerCap = 16) {
|
|
943
|
+
const { x, y, width, height } = bounds;
|
|
944
|
+
const { rx, ry } = queueRadii(width, height);
|
|
945
|
+
const cy = y + ry;
|
|
946
|
+
const pts = [];
|
|
947
|
+
for (let i = 0; i <= samplesPerCap; i++) {
|
|
948
|
+
const t = Math.PI / 2 + i / samplesPerCap * Math.PI;
|
|
949
|
+
pts.push({
|
|
950
|
+
x: x + rx + rx * Math.cos(t),
|
|
951
|
+
y: cy + ry * Math.sin(t)
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
for (let i = 0; i <= samplesPerCap; i++) {
|
|
955
|
+
const t = -Math.PI / 2 + i / samplesPerCap * Math.PI;
|
|
956
|
+
pts.push({
|
|
957
|
+
x: x + width - rx + rx * Math.cos(t),
|
|
958
|
+
y: cy + ry * Math.sin(t)
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
return pts;
|
|
962
|
+
}
|
|
963
|
+
const queueGeometry = {
|
|
964
|
+
id: "queue",
|
|
965
|
+
defaultSize: {
|
|
966
|
+
width: 160,
|
|
967
|
+
height: 64
|
|
968
|
+
},
|
|
969
|
+
minSize: {
|
|
970
|
+
width: 72,
|
|
971
|
+
height: 40
|
|
972
|
+
},
|
|
973
|
+
defaultPadding: {
|
|
974
|
+
top: 12,
|
|
975
|
+
right: 22,
|
|
976
|
+
bottom: 12,
|
|
977
|
+
left: 22
|
|
978
|
+
},
|
|
979
|
+
contentPolicy: {
|
|
980
|
+
align: "center",
|
|
981
|
+
maxLabelLines: 2,
|
|
982
|
+
iconPlacement: "leading"
|
|
983
|
+
},
|
|
984
|
+
getPath(bounds) {
|
|
985
|
+
const { body, rim } = queuePaths(bounds);
|
|
986
|
+
return {
|
|
987
|
+
d: body,
|
|
988
|
+
decorations: [{
|
|
989
|
+
d: rim,
|
|
990
|
+
role: "rim"
|
|
991
|
+
}]
|
|
992
|
+
};
|
|
993
|
+
},
|
|
994
|
+
getContentBounds(bounds, style = {}) {
|
|
995
|
+
const stroke = style.strokeWidth ?? 1.6;
|
|
996
|
+
const { rx } = queueRadii(bounds.width, bounds.height);
|
|
997
|
+
const sideReserve = Math.max(rx * 2, bounds.width * .12);
|
|
998
|
+
return {
|
|
999
|
+
x: bounds.x + sideReserve,
|
|
1000
|
+
y: bounds.y + stroke + 6,
|
|
1001
|
+
width: Math.max(0, bounds.width - sideReserve * 2),
|
|
1002
|
+
height: Math.max(0, bounds.height - (stroke + 6) * 2)
|
|
1003
|
+
};
|
|
1004
|
+
},
|
|
1005
|
+
getVisualBounds(bounds, style) {
|
|
1006
|
+
return defaultVisualBounds(bounds, style);
|
|
1007
|
+
},
|
|
1008
|
+
getLayoutFootprint(bounds, context, style) {
|
|
1009
|
+
return defaultLayoutFootprint(queueGeometry, bounds, context, style);
|
|
1010
|
+
},
|
|
1011
|
+
getPortPosition(port, bounds) {
|
|
1012
|
+
return projectSidePortOntoPolygon(port, bounds, queueSilhouettePolygon(bounds), intersectRayPolygon);
|
|
1013
|
+
},
|
|
1014
|
+
getPortNormal(port, bounds) {
|
|
1015
|
+
return defaultPortNormal(port, bounds);
|
|
1016
|
+
},
|
|
1017
|
+
intersectRay(bounds, origin, direction) {
|
|
1018
|
+
return intersectRayPolygon(queueSilhouettePolygon(bounds), origin, direction);
|
|
1019
|
+
},
|
|
1020
|
+
containsPoint(bounds, point) {
|
|
1021
|
+
const { rx } = queueRadii(bounds.width, bounds.height);
|
|
1022
|
+
const left = {
|
|
1023
|
+
x: bounds.x,
|
|
1024
|
+
y: bounds.y,
|
|
1025
|
+
width: rx * 2,
|
|
1026
|
+
height: bounds.height
|
|
1027
|
+
};
|
|
1028
|
+
const right = {
|
|
1029
|
+
x: bounds.x + bounds.width - rx * 2,
|
|
1030
|
+
y: bounds.y,
|
|
1031
|
+
width: rx * 2,
|
|
1032
|
+
height: bounds.height
|
|
1033
|
+
};
|
|
1034
|
+
const body = {
|
|
1035
|
+
x: bounds.x + rx,
|
|
1036
|
+
y: bounds.y,
|
|
1037
|
+
width: Math.max(0, bounds.width - rx * 2),
|
|
1038
|
+
height: bounds.height
|
|
1039
|
+
};
|
|
1040
|
+
if (point.x >= body.x && point.x <= body.x + body.width && point.y >= body.y && point.y <= body.y + body.height) return true;
|
|
1041
|
+
return pointInEllipse(point, left) || pointInEllipse(point, right);
|
|
1042
|
+
}
|
|
1043
|
+
};
|
|
1044
|
+
//#endregion
|
|
1045
|
+
//#region src/shapes/stream.ts
|
|
1046
|
+
/** Corner radius for the stacked-log shell. */
|
|
1047
|
+
function streamCornerRadius(height, width) {
|
|
1048
|
+
return Math.min(10, height * .18, width * .12);
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Partition y positions (absolute) for the stacked-log / Kafka-style silhouette.
|
|
1052
|
+
* Two interior rules divide the card into three record bands.
|
|
1053
|
+
*/
|
|
1054
|
+
function streamPartitionYs(bounds) {
|
|
1055
|
+
const { y, height } = bounds;
|
|
1056
|
+
return [y + height / 3, y + 2 * height / 3];
|
|
1057
|
+
}
|
|
1058
|
+
function streamShellPath(bounds) {
|
|
1059
|
+
const { x, y, width, height } = bounds;
|
|
1060
|
+
const r = streamCornerRadius(height, width);
|
|
1061
|
+
return [
|
|
1062
|
+
`M ${x + r} ${y}`,
|
|
1063
|
+
`L ${x + width - r} ${y}`,
|
|
1064
|
+
`A ${r} ${r} 0 0 1 ${x + width} ${y + r}`,
|
|
1065
|
+
`L ${x + width} ${y + height - r}`,
|
|
1066
|
+
`A ${r} ${r} 0 0 1 ${x + width - r} ${y + height}`,
|
|
1067
|
+
`L ${x + r} ${y + height}`,
|
|
1068
|
+
`A ${r} ${r} 0 0 1 ${x} ${y + height - r}`,
|
|
1069
|
+
`L ${x} ${y + r}`,
|
|
1070
|
+
`A ${r} ${r} 0 0 1 ${x + r} ${y}`,
|
|
1071
|
+
`Z`
|
|
1072
|
+
].join(" ");
|
|
1073
|
+
}
|
|
1074
|
+
/**
|
|
1075
|
+
* Short left-rail ticks — suggest stacked partitions without slicing through label/icon.
|
|
1076
|
+
*/
|
|
1077
|
+
function streamPartitionPaths(bounds) {
|
|
1078
|
+
const { x, width, height } = bounds;
|
|
1079
|
+
const tickLen = Math.min(14, Math.max(8, width * .1));
|
|
1080
|
+
const insetX = Math.max(8, streamCornerRadius(height, width) * .75);
|
|
1081
|
+
return streamPartitionYs(bounds).map((py) => `M ${x + insetX} ${py} L ${x + insetX + tickLen} ${py}`);
|
|
1082
|
+
}
|
|
1083
|
+
function streamPolygon(bounds) {
|
|
1084
|
+
const { x, y, width, height } = bounds;
|
|
1085
|
+
return [
|
|
1086
|
+
{
|
|
1087
|
+
x,
|
|
1088
|
+
y
|
|
1089
|
+
},
|
|
1090
|
+
{
|
|
1091
|
+
x: x + width,
|
|
1092
|
+
y
|
|
1093
|
+
},
|
|
1094
|
+
{
|
|
1095
|
+
x: x + width,
|
|
1096
|
+
y: y + height
|
|
1097
|
+
},
|
|
1098
|
+
{
|
|
1099
|
+
x,
|
|
1100
|
+
y: y + height
|
|
1101
|
+
}
|
|
1102
|
+
];
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Stacked-log card — ubiquitous for streams, topics, and append-only partitions.
|
|
1106
|
+
* Outer shell holds the label; interior rules suggest record bands.
|
|
1107
|
+
*/
|
|
1108
|
+
const streamGeometry = {
|
|
1109
|
+
id: "stream",
|
|
1110
|
+
defaultSize: {
|
|
1111
|
+
width: 160,
|
|
1112
|
+
height: 72
|
|
1113
|
+
},
|
|
1114
|
+
minSize: {
|
|
1115
|
+
width: 72,
|
|
1116
|
+
height: 48
|
|
1117
|
+
},
|
|
1118
|
+
defaultPadding: {
|
|
1119
|
+
top: 14,
|
|
1120
|
+
right: 16,
|
|
1121
|
+
bottom: 14,
|
|
1122
|
+
left: 16
|
|
1123
|
+
},
|
|
1124
|
+
contentPolicy: {
|
|
1125
|
+
align: "center",
|
|
1126
|
+
maxLabelLines: 2,
|
|
1127
|
+
iconPlacement: "leading"
|
|
1128
|
+
},
|
|
1129
|
+
getPath(bounds) {
|
|
1130
|
+
return {
|
|
1131
|
+
d: streamShellPath(bounds),
|
|
1132
|
+
decorations: streamPartitionPaths(bounds).map((d) => ({
|
|
1133
|
+
d,
|
|
1134
|
+
role: "partition"
|
|
1135
|
+
})),
|
|
1136
|
+
polygon: streamPolygon(bounds)
|
|
1137
|
+
};
|
|
1138
|
+
},
|
|
1139
|
+
getContentBounds(bounds, style = {}) {
|
|
1140
|
+
const stroke = style.strokeWidth ?? 1.6;
|
|
1141
|
+
const rail = Math.min(22, Math.max(16, bounds.width * .12));
|
|
1142
|
+
return {
|
|
1143
|
+
x: bounds.x + rail,
|
|
1144
|
+
y: bounds.y + stroke + 8,
|
|
1145
|
+
width: Math.max(0, bounds.width - rail - stroke - 10),
|
|
1146
|
+
height: Math.max(0, bounds.height - (stroke + 8) * 2)
|
|
1147
|
+
};
|
|
1148
|
+
},
|
|
1149
|
+
getVisualBounds(bounds, style) {
|
|
1150
|
+
return defaultVisualBounds(bounds, style);
|
|
1151
|
+
},
|
|
1152
|
+
getLayoutFootprint(bounds, context, style) {
|
|
1153
|
+
return defaultLayoutFootprint(streamGeometry, bounds, context, style);
|
|
1154
|
+
},
|
|
1155
|
+
getPortPosition(port, bounds) {
|
|
1156
|
+
return projectSidePortOntoPolygon(port, bounds, streamPolygon(bounds), intersectRayPolygon);
|
|
1157
|
+
},
|
|
1158
|
+
getPortNormal(port, bounds) {
|
|
1159
|
+
return defaultPortNormal(port, bounds);
|
|
1160
|
+
},
|
|
1161
|
+
intersectRay(bounds, origin, direction) {
|
|
1162
|
+
return intersectRayPolygon(streamPolygon(bounds), origin, direction);
|
|
1163
|
+
},
|
|
1164
|
+
containsPoint(bounds, point) {
|
|
1165
|
+
return pointInPolygon(point, streamPolygon(bounds));
|
|
1166
|
+
}
|
|
1167
|
+
};
|
|
1168
|
+
//#endregion
|
|
1169
|
+
//#region src/shapes/ellipse.ts
|
|
1170
|
+
function createEllipseGeometry(id, contentRatio, opts = {}) {
|
|
1171
|
+
const geometry = {
|
|
1172
|
+
id,
|
|
1173
|
+
defaultSize: opts.defaultSize ?? {
|
|
1174
|
+
width: 120,
|
|
1175
|
+
height: 80
|
|
1176
|
+
},
|
|
1177
|
+
minSize: {
|
|
1178
|
+
width: 32,
|
|
1179
|
+
height: 32
|
|
1180
|
+
},
|
|
1181
|
+
defaultPadding: {
|
|
1182
|
+
top: 12,
|
|
1183
|
+
right: 16,
|
|
1184
|
+
bottom: 12,
|
|
1185
|
+
left: 16
|
|
1186
|
+
},
|
|
1187
|
+
contentPolicy: {
|
|
1188
|
+
align: "center",
|
|
1189
|
+
maxLabelLines: id === "circle" ? 1 : 2,
|
|
1190
|
+
preferExternalLabel: opts.preferExternalLabel ?? id === "circle",
|
|
1191
|
+
iconPlacement: "centered"
|
|
1192
|
+
},
|
|
1193
|
+
getPath(bounds) {
|
|
1194
|
+
const cx = bounds.x + bounds.width / 2;
|
|
1195
|
+
const cy = bounds.y + bounds.height / 2;
|
|
1196
|
+
const rx = bounds.width / 2;
|
|
1197
|
+
const ry = bounds.height / 2;
|
|
1198
|
+
return { d: [
|
|
1199
|
+
`M ${cx - rx} ${cy}`,
|
|
1200
|
+
`A ${rx} ${ry} 0 1 0 ${cx + rx} ${cy}`,
|
|
1201
|
+
`A ${rx} ${ry} 0 1 0 ${cx - rx} ${cy}`,
|
|
1202
|
+
`Z`
|
|
1203
|
+
].join(" ") };
|
|
1204
|
+
},
|
|
1205
|
+
getContentBounds(bounds, style = {}) {
|
|
1206
|
+
const stroke = style.strokeWidth ?? 1.6;
|
|
1207
|
+
const content = centeredContentRect(bounds, contentRatio.w, contentRatio.h);
|
|
1208
|
+
return {
|
|
1209
|
+
x: content.x + stroke,
|
|
1210
|
+
y: content.y + stroke,
|
|
1211
|
+
width: Math.max(0, content.width - stroke * 2),
|
|
1212
|
+
height: Math.max(0, content.height - stroke * 2)
|
|
1213
|
+
};
|
|
1214
|
+
},
|
|
1215
|
+
getVisualBounds(bounds, style) {
|
|
1216
|
+
return defaultVisualBounds(bounds, style);
|
|
1217
|
+
},
|
|
1218
|
+
getLayoutFootprint(bounds, context, style) {
|
|
1219
|
+
return defaultLayoutFootprint(geometry, bounds, context, style);
|
|
1220
|
+
},
|
|
1221
|
+
getPortPosition(port, bounds) {
|
|
1222
|
+
if (port.kind === "radial") {
|
|
1223
|
+
const cx = bounds.x + bounds.width / 2;
|
|
1224
|
+
const cy = bounds.y + bounds.height / 2;
|
|
1225
|
+
const rad = port.angleDeg * Math.PI / 180;
|
|
1226
|
+
return {
|
|
1227
|
+
x: cx + Math.cos(rad) * (bounds.width / 2),
|
|
1228
|
+
y: cy + Math.sin(rad) * (bounds.height / 2)
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
return projectSidePortOntoOutline(port, bounds, (direction) => intersectRayEllipse(bounds, {
|
|
1232
|
+
x: bounds.x + bounds.width / 2,
|
|
1233
|
+
y: bounds.y + bounds.height / 2
|
|
1234
|
+
}, direction));
|
|
1235
|
+
},
|
|
1236
|
+
getPortNormal(port, bounds) {
|
|
1237
|
+
if (port.kind === "radial") {
|
|
1238
|
+
const rad = port.angleDeg * Math.PI / 180;
|
|
1239
|
+
return {
|
|
1240
|
+
x: Math.cos(rad),
|
|
1241
|
+
y: Math.sin(rad)
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
return defaultPortNormal(port, bounds);
|
|
1245
|
+
},
|
|
1246
|
+
intersectRay(bounds, origin, direction) {
|
|
1247
|
+
return intersectRayEllipse(bounds, origin, direction);
|
|
1248
|
+
},
|
|
1249
|
+
containsPoint(bounds, point) {
|
|
1250
|
+
return pointInEllipse(point, bounds);
|
|
1251
|
+
}
|
|
1252
|
+
};
|
|
1253
|
+
return geometry;
|
|
1254
|
+
}
|
|
1255
|
+
const ellipseGeometry = createEllipseGeometry("ellipse", {
|
|
1256
|
+
w: .7,
|
|
1257
|
+
h: .7
|
|
1258
|
+
});
|
|
1259
|
+
const circleGeometry = createEllipseGeometry("circle", {
|
|
1260
|
+
w: .55,
|
|
1261
|
+
h: .55
|
|
1262
|
+
}, {
|
|
1263
|
+
preferExternalLabel: true,
|
|
1264
|
+
defaultSize: {
|
|
1265
|
+
width: 40,
|
|
1266
|
+
height: 40
|
|
1267
|
+
}
|
|
1268
|
+
});
|
|
1269
|
+
//#endregion
|
|
1270
|
+
//#region src/shapes/polygons.ts
|
|
1271
|
+
function parallelogramPolygon(bounds, skewRatio = .18, direction = "right") {
|
|
1272
|
+
const skew = Math.min(bounds.width * skewRatio, bounds.width * .35);
|
|
1273
|
+
const { x, y, width, height } = bounds;
|
|
1274
|
+
if (direction === "right") return [
|
|
1275
|
+
{
|
|
1276
|
+
x: x + skew,
|
|
1277
|
+
y
|
|
1278
|
+
},
|
|
1279
|
+
{
|
|
1280
|
+
x: x + width,
|
|
1281
|
+
y
|
|
1282
|
+
},
|
|
1283
|
+
{
|
|
1284
|
+
x: x + width - skew,
|
|
1285
|
+
y: y + height
|
|
1286
|
+
},
|
|
1287
|
+
{
|
|
1288
|
+
x,
|
|
1289
|
+
y: y + height
|
|
1290
|
+
}
|
|
1291
|
+
];
|
|
1292
|
+
return [
|
|
1293
|
+
{
|
|
1294
|
+
x,
|
|
1295
|
+
y
|
|
1296
|
+
},
|
|
1297
|
+
{
|
|
1298
|
+
x: x + width - skew,
|
|
1299
|
+
y
|
|
1300
|
+
},
|
|
1301
|
+
{
|
|
1302
|
+
x: x + width,
|
|
1303
|
+
y: y + height
|
|
1304
|
+
},
|
|
1305
|
+
{
|
|
1306
|
+
x: x + skew,
|
|
1307
|
+
y: y + height
|
|
1308
|
+
}
|
|
1309
|
+
];
|
|
1310
|
+
}
|
|
1311
|
+
function createParallelogramGeometry(id = "parallelogram", direction = "right") {
|
|
1312
|
+
const geometry = {
|
|
1313
|
+
id,
|
|
1314
|
+
defaultSize: {
|
|
1315
|
+
width: 160,
|
|
1316
|
+
height: 64
|
|
1317
|
+
},
|
|
1318
|
+
minSize: {
|
|
1319
|
+
width: 72,
|
|
1320
|
+
height: 36
|
|
1321
|
+
},
|
|
1322
|
+
defaultPadding: {
|
|
1323
|
+
top: 12,
|
|
1324
|
+
right: 24,
|
|
1325
|
+
bottom: 12,
|
|
1326
|
+
left: 24
|
|
1327
|
+
},
|
|
1328
|
+
contentPolicy: {
|
|
1329
|
+
align: "center",
|
|
1330
|
+
maxLabelLines: 2,
|
|
1331
|
+
iconPlacement: "none"
|
|
1332
|
+
},
|
|
1333
|
+
getPath(bounds) {
|
|
1334
|
+
const polygon = parallelogramPolygon(bounds, .18, direction);
|
|
1335
|
+
return {
|
|
1336
|
+
d: polygonToPath(polygon),
|
|
1337
|
+
polygon
|
|
1338
|
+
};
|
|
1339
|
+
},
|
|
1340
|
+
getContentBounds(bounds, style = {}) {
|
|
1341
|
+
const stroke = style.strokeWidth ?? 1.6;
|
|
1342
|
+
const skew = Math.min(bounds.width * .18, bounds.width * .35);
|
|
1343
|
+
return {
|
|
1344
|
+
x: bounds.x + skew + stroke,
|
|
1345
|
+
y: bounds.y + stroke + 4,
|
|
1346
|
+
width: Math.max(0, bounds.width - skew * 2 - stroke * 2),
|
|
1347
|
+
height: Math.max(0, bounds.height - (stroke + 4) * 2)
|
|
1348
|
+
};
|
|
1349
|
+
},
|
|
1350
|
+
getVisualBounds(bounds, style) {
|
|
1351
|
+
return defaultVisualBounds(bounds, style);
|
|
1352
|
+
},
|
|
1353
|
+
getLayoutFootprint(bounds, context, style) {
|
|
1354
|
+
return defaultLayoutFootprint(geometry, bounds, context, style);
|
|
1355
|
+
},
|
|
1356
|
+
getPortPosition(port, bounds) {
|
|
1357
|
+
return projectSidePortOntoPolygon(port, bounds, parallelogramPolygon(bounds, .18, direction), intersectRayPolygon);
|
|
1358
|
+
},
|
|
1359
|
+
getPortNormal(port, bounds) {
|
|
1360
|
+
return defaultPortNormal(port, bounds);
|
|
1361
|
+
},
|
|
1362
|
+
intersectRay(bounds, origin, directionVec) {
|
|
1363
|
+
return intersectRayPolygon(parallelogramPolygon(bounds, .18, direction), origin, directionVec);
|
|
1364
|
+
},
|
|
1365
|
+
containsPoint(bounds, point) {
|
|
1366
|
+
return pointInPolygon(point, parallelogramPolygon(bounds, .18, direction));
|
|
1367
|
+
}
|
|
1368
|
+
};
|
|
1369
|
+
return geometry;
|
|
1370
|
+
}
|
|
1371
|
+
const parallelogramGeometry = createParallelogramGeometry();
|
|
1372
|
+
function trapezoidPolygon(bounds, narrowTop = true, insetRatio = .16) {
|
|
1373
|
+
const inset = Math.min(bounds.width * insetRatio, bounds.width * .3);
|
|
1374
|
+
const { x, y, width, height } = bounds;
|
|
1375
|
+
if (narrowTop) return [
|
|
1376
|
+
{
|
|
1377
|
+
x: x + inset,
|
|
1378
|
+
y
|
|
1379
|
+
},
|
|
1380
|
+
{
|
|
1381
|
+
x: x + width - inset,
|
|
1382
|
+
y
|
|
1383
|
+
},
|
|
1384
|
+
{
|
|
1385
|
+
x: x + width,
|
|
1386
|
+
y: y + height
|
|
1387
|
+
},
|
|
1388
|
+
{
|
|
1389
|
+
x,
|
|
1390
|
+
y: y + height
|
|
1391
|
+
}
|
|
1392
|
+
];
|
|
1393
|
+
return [
|
|
1394
|
+
{
|
|
1395
|
+
x,
|
|
1396
|
+
y
|
|
1397
|
+
},
|
|
1398
|
+
{
|
|
1399
|
+
x: x + width,
|
|
1400
|
+
y
|
|
1401
|
+
},
|
|
1402
|
+
{
|
|
1403
|
+
x: x + width - inset,
|
|
1404
|
+
y: y + height
|
|
1405
|
+
},
|
|
1406
|
+
{
|
|
1407
|
+
x: x + inset,
|
|
1408
|
+
y: y + height
|
|
1409
|
+
}
|
|
1410
|
+
];
|
|
1411
|
+
}
|
|
1412
|
+
function createTrapezoidGeometry(id = "trapezoid", narrowTop = false) {
|
|
1413
|
+
const geometry = {
|
|
1414
|
+
id,
|
|
1415
|
+
defaultSize: {
|
|
1416
|
+
width: 160,
|
|
1417
|
+
height: 64
|
|
1418
|
+
},
|
|
1419
|
+
minSize: {
|
|
1420
|
+
width: 72,
|
|
1421
|
+
height: 36
|
|
1422
|
+
},
|
|
1423
|
+
defaultPadding: {
|
|
1424
|
+
top: 12,
|
|
1425
|
+
right: 16,
|
|
1426
|
+
bottom: 12,
|
|
1427
|
+
left: 16
|
|
1428
|
+
},
|
|
1429
|
+
contentPolicy: {
|
|
1430
|
+
align: "center",
|
|
1431
|
+
maxLabelLines: 2,
|
|
1432
|
+
iconPlacement: "none"
|
|
1433
|
+
},
|
|
1434
|
+
getPath(bounds) {
|
|
1435
|
+
const polygon = trapezoidPolygon(bounds, narrowTop);
|
|
1436
|
+
return {
|
|
1437
|
+
d: polygonToPath(polygon),
|
|
1438
|
+
polygon
|
|
1439
|
+
};
|
|
1440
|
+
},
|
|
1441
|
+
getContentBounds(bounds, style = {}) {
|
|
1442
|
+
const stroke = style.strokeWidth ?? 1.6;
|
|
1443
|
+
const inset = Math.min(bounds.width * .16, bounds.width * .3);
|
|
1444
|
+
return {
|
|
1445
|
+
x: bounds.x + inset + stroke,
|
|
1446
|
+
y: bounds.y + stroke + 4,
|
|
1447
|
+
width: Math.max(0, bounds.width - inset * 2 - stroke * 2),
|
|
1448
|
+
height: Math.max(0, bounds.height - (stroke + 4) * 2)
|
|
1449
|
+
};
|
|
1450
|
+
},
|
|
1451
|
+
getVisualBounds(bounds, style) {
|
|
1452
|
+
return defaultVisualBounds(bounds, style);
|
|
1453
|
+
},
|
|
1454
|
+
getLayoutFootprint(bounds, context, style) {
|
|
1455
|
+
return defaultLayoutFootprint(geometry, bounds, context, style);
|
|
1456
|
+
},
|
|
1457
|
+
getPortPosition(port, bounds) {
|
|
1458
|
+
return projectSidePortOntoPolygon(port, bounds, trapezoidPolygon(bounds, narrowTop), intersectRayPolygon);
|
|
1459
|
+
},
|
|
1460
|
+
getPortNormal(port, bounds) {
|
|
1461
|
+
return defaultPortNormal(port, bounds);
|
|
1462
|
+
},
|
|
1463
|
+
intersectRay(bounds, origin, direction) {
|
|
1464
|
+
return intersectRayPolygon(trapezoidPolygon(bounds, narrowTop), origin, direction);
|
|
1465
|
+
},
|
|
1466
|
+
containsPoint(bounds, point) {
|
|
1467
|
+
return pointInPolygon(point, trapezoidPolygon(bounds, narrowTop));
|
|
1468
|
+
}
|
|
1469
|
+
};
|
|
1470
|
+
return geometry;
|
|
1471
|
+
}
|
|
1472
|
+
/** Manual operation: wider top. */
|
|
1473
|
+
const trapezoidGeometry = createTrapezoidGeometry("trapezoid", false);
|
|
1474
|
+
function trianglePolygon(bounds) {
|
|
1475
|
+
return [
|
|
1476
|
+
{
|
|
1477
|
+
x: bounds.x + bounds.width / 2,
|
|
1478
|
+
y: bounds.y
|
|
1479
|
+
},
|
|
1480
|
+
{
|
|
1481
|
+
x: bounds.x + bounds.width,
|
|
1482
|
+
y: bounds.y + bounds.height
|
|
1483
|
+
},
|
|
1484
|
+
{
|
|
1485
|
+
x: bounds.x,
|
|
1486
|
+
y: bounds.y + bounds.height
|
|
1487
|
+
}
|
|
1488
|
+
];
|
|
1489
|
+
}
|
|
1490
|
+
const triangleGeometry = {
|
|
1491
|
+
id: "triangle",
|
|
1492
|
+
defaultSize: {
|
|
1493
|
+
width: 72,
|
|
1494
|
+
height: 64
|
|
1495
|
+
},
|
|
1496
|
+
minSize: {
|
|
1497
|
+
width: 32,
|
|
1498
|
+
height: 28
|
|
1499
|
+
},
|
|
1500
|
+
defaultPadding: {
|
|
1501
|
+
top: 16,
|
|
1502
|
+
right: 12,
|
|
1503
|
+
bottom: 8,
|
|
1504
|
+
left: 12
|
|
1505
|
+
},
|
|
1506
|
+
contentPolicy: {
|
|
1507
|
+
align: "center",
|
|
1508
|
+
maxLabelLines: 1,
|
|
1509
|
+
preferExternalLabel: true,
|
|
1510
|
+
iconPlacement: "centered"
|
|
1511
|
+
},
|
|
1512
|
+
clearSpaceBoost: 8,
|
|
1513
|
+
getPath(bounds) {
|
|
1514
|
+
const polygon = trianglePolygon(bounds);
|
|
1515
|
+
return {
|
|
1516
|
+
d: polygonToPath(polygon),
|
|
1517
|
+
polygon
|
|
1518
|
+
};
|
|
1519
|
+
},
|
|
1520
|
+
getContentBounds(bounds, style = {}) {
|
|
1521
|
+
const stroke = style.strokeWidth ?? 1.6;
|
|
1522
|
+
return centeredContentRect({
|
|
1523
|
+
x: bounds.x + stroke,
|
|
1524
|
+
y: bounds.y + bounds.height * .35,
|
|
1525
|
+
width: Math.max(0, bounds.width - stroke * 2),
|
|
1526
|
+
height: Math.max(0, bounds.height * .45 - stroke)
|
|
1527
|
+
}, 1, 1);
|
|
1528
|
+
},
|
|
1529
|
+
getVisualBounds(bounds, style) {
|
|
1530
|
+
return defaultVisualBounds(bounds, style);
|
|
1531
|
+
},
|
|
1532
|
+
getLayoutFootprint(bounds, context, style) {
|
|
1533
|
+
return defaultLayoutFootprint(triangleGeometry, bounds, context, style);
|
|
1534
|
+
},
|
|
1535
|
+
getPortPosition(port, bounds) {
|
|
1536
|
+
return projectSidePortOntoPolygon(port, bounds, trianglePolygon(bounds), intersectRayPolygon);
|
|
1537
|
+
},
|
|
1538
|
+
getPortNormal(port, bounds) {
|
|
1539
|
+
return defaultPortNormal(port, bounds);
|
|
1540
|
+
},
|
|
1541
|
+
intersectRay(bounds, origin, direction) {
|
|
1542
|
+
return intersectRayPolygon(trianglePolygon(bounds), origin, direction);
|
|
1543
|
+
},
|
|
1544
|
+
containsPoint(bounds, point) {
|
|
1545
|
+
return pointInPolygon(point, trianglePolygon(bounds));
|
|
1546
|
+
}
|
|
1547
|
+
};
|
|
1548
|
+
//#endregion
|
|
1549
|
+
//#region src/shapes/document.ts
|
|
1550
|
+
/**
|
|
1551
|
+
* Document: rectangle with a deterministic wavy bottom.
|
|
1552
|
+
* Ray tests and south ports use the same sampled wave polygon as the path.
|
|
1553
|
+
*/
|
|
1554
|
+
function documentPolygon(bounds, samples = 8) {
|
|
1555
|
+
const { x, y, width, height } = bounds;
|
|
1556
|
+
const waveAmp = Math.min(10, height * .12);
|
|
1557
|
+
const bodyH = height - waveAmp;
|
|
1558
|
+
const pts = [
|
|
1559
|
+
{
|
|
1560
|
+
x,
|
|
1561
|
+
y
|
|
1562
|
+
},
|
|
1563
|
+
{
|
|
1564
|
+
x: x + width,
|
|
1565
|
+
y
|
|
1566
|
+
},
|
|
1567
|
+
{
|
|
1568
|
+
x: x + width,
|
|
1569
|
+
y: y + bodyH
|
|
1570
|
+
}
|
|
1571
|
+
];
|
|
1572
|
+
for (let i = samples; i >= 0; i--) {
|
|
1573
|
+
const t = i / samples;
|
|
1574
|
+
const px = x + width * t;
|
|
1575
|
+
const py = y + bodyH + Math.sin(t * Math.PI) * waveAmp;
|
|
1576
|
+
pts.push({
|
|
1577
|
+
x: px,
|
|
1578
|
+
y: py
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
return pts;
|
|
1582
|
+
}
|
|
1583
|
+
const documentGeometry = {
|
|
1584
|
+
id: "document",
|
|
1585
|
+
defaultSize: {
|
|
1586
|
+
width: 140,
|
|
1587
|
+
height: 80
|
|
1588
|
+
},
|
|
1589
|
+
minSize: {
|
|
1590
|
+
width: 64,
|
|
1591
|
+
height: 48
|
|
1592
|
+
},
|
|
1593
|
+
defaultPadding: {
|
|
1594
|
+
top: 14,
|
|
1595
|
+
right: 16,
|
|
1596
|
+
bottom: 20,
|
|
1597
|
+
left: 16
|
|
1598
|
+
},
|
|
1599
|
+
contentPolicy: {
|
|
1600
|
+
align: "start",
|
|
1601
|
+
maxLabelLines: 2,
|
|
1602
|
+
iconPlacement: "leading"
|
|
1603
|
+
},
|
|
1604
|
+
getPath(bounds) {
|
|
1605
|
+
const polygon = documentPolygon(bounds);
|
|
1606
|
+
return {
|
|
1607
|
+
d: polygonToPath(polygon),
|
|
1608
|
+
polygon
|
|
1609
|
+
};
|
|
1610
|
+
},
|
|
1611
|
+
getContentBounds(bounds, style = {}) {
|
|
1612
|
+
const stroke = style.strokeWidth ?? 1.6;
|
|
1613
|
+
const waveAmp = Math.min(10, bounds.height * .12);
|
|
1614
|
+
return {
|
|
1615
|
+
x: bounds.x + stroke + 8,
|
|
1616
|
+
y: bounds.y + stroke + 8,
|
|
1617
|
+
width: Math.max(0, bounds.width - (stroke + 8) * 2),
|
|
1618
|
+
height: Math.max(0, bounds.height - waveAmp - stroke * 2 - 16)
|
|
1619
|
+
};
|
|
1620
|
+
},
|
|
1621
|
+
getVisualBounds(bounds, style) {
|
|
1622
|
+
return defaultVisualBounds(bounds, style);
|
|
1623
|
+
},
|
|
1624
|
+
getLayoutFootprint(bounds, context, style) {
|
|
1625
|
+
return defaultLayoutFootprint(documentGeometry, bounds, context, style);
|
|
1626
|
+
},
|
|
1627
|
+
getPortPosition(port, bounds) {
|
|
1628
|
+
return projectSidePortOntoPolygon(port, bounds, documentPolygon(bounds), intersectRayPolygon);
|
|
1629
|
+
},
|
|
1630
|
+
getPortNormal(port, bounds) {
|
|
1631
|
+
return defaultPortNormal(port, bounds);
|
|
1632
|
+
},
|
|
1633
|
+
intersectRay(bounds, origin, direction) {
|
|
1634
|
+
return intersectRayPolygon(documentPolygon(bounds), origin, direction);
|
|
1635
|
+
},
|
|
1636
|
+
containsPoint(bounds, point) {
|
|
1637
|
+
return pointInPolygon(point, documentPolygon(bounds));
|
|
1638
|
+
}
|
|
1639
|
+
};
|
|
1640
|
+
/** Folded-corner note — rectangle with a clipped top-right fold. */
|
|
1641
|
+
function foldedCornerPath(bounds, fold = 14) {
|
|
1642
|
+
const { x, y, width, height } = bounds;
|
|
1643
|
+
const f = Math.min(fold, width * .25, height * .25);
|
|
1644
|
+
return {
|
|
1645
|
+
body: [
|
|
1646
|
+
`M ${x} ${y}`,
|
|
1647
|
+
`L ${x + width - f} ${y}`,
|
|
1648
|
+
`L ${x + width} ${y + f}`,
|
|
1649
|
+
`L ${x + width} ${y + height}`,
|
|
1650
|
+
`L ${x} ${y + height}`,
|
|
1651
|
+
`Z`
|
|
1652
|
+
].join(" "),
|
|
1653
|
+
foldLine: `M ${x + width - f} ${y} L ${x + width - f} ${y + f} L ${x + width} ${y + f}`
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
function foldedDocumentPolygon(bounds, fold = 14) {
|
|
1657
|
+
const { x, y, width, height } = bounds;
|
|
1658
|
+
const f = Math.min(fold, width * .25, height * .25);
|
|
1659
|
+
return [
|
|
1660
|
+
{
|
|
1661
|
+
x,
|
|
1662
|
+
y
|
|
1663
|
+
},
|
|
1664
|
+
{
|
|
1665
|
+
x: x + width - f,
|
|
1666
|
+
y
|
|
1667
|
+
},
|
|
1668
|
+
{
|
|
1669
|
+
x: x + width,
|
|
1670
|
+
y: y + f
|
|
1671
|
+
},
|
|
1672
|
+
{
|
|
1673
|
+
x: x + width,
|
|
1674
|
+
y: y + height
|
|
1675
|
+
},
|
|
1676
|
+
{
|
|
1677
|
+
x,
|
|
1678
|
+
y: y + height
|
|
1679
|
+
}
|
|
1680
|
+
];
|
|
1681
|
+
}
|
|
1682
|
+
const foldedDocumentGeometry = {
|
|
1683
|
+
id: "folded-document",
|
|
1684
|
+
defaultSize: {
|
|
1685
|
+
width: 140,
|
|
1686
|
+
height: 80
|
|
1687
|
+
},
|
|
1688
|
+
minSize: {
|
|
1689
|
+
width: 64,
|
|
1690
|
+
height: 48
|
|
1691
|
+
},
|
|
1692
|
+
defaultPadding: {
|
|
1693
|
+
top: 14,
|
|
1694
|
+
right: 18,
|
|
1695
|
+
bottom: 14,
|
|
1696
|
+
left: 14
|
|
1697
|
+
},
|
|
1698
|
+
contentPolicy: {
|
|
1699
|
+
align: "start",
|
|
1700
|
+
maxLabelLines: 3,
|
|
1701
|
+
iconPlacement: "none"
|
|
1702
|
+
},
|
|
1703
|
+
getPath(bounds) {
|
|
1704
|
+
const { body, foldLine } = foldedCornerPath(bounds);
|
|
1705
|
+
return {
|
|
1706
|
+
d: body,
|
|
1707
|
+
decorations: [{
|
|
1708
|
+
d: foldLine,
|
|
1709
|
+
role: "fold"
|
|
1710
|
+
}]
|
|
1711
|
+
};
|
|
1712
|
+
},
|
|
1713
|
+
getContentBounds(bounds, style = {}) {
|
|
1714
|
+
const stroke = style.strokeWidth ?? 1.6;
|
|
1715
|
+
const fold = Math.min(14, bounds.width * .25);
|
|
1716
|
+
return {
|
|
1717
|
+
x: bounds.x + stroke + 8,
|
|
1718
|
+
y: bounds.y + stroke + 8,
|
|
1719
|
+
width: Math.max(0, bounds.width - fold - stroke * 2 - 12),
|
|
1720
|
+
height: Math.max(0, bounds.height - (stroke + 8) * 2)
|
|
1721
|
+
};
|
|
1722
|
+
},
|
|
1723
|
+
getVisualBounds(bounds, style) {
|
|
1724
|
+
return defaultVisualBounds(bounds, style);
|
|
1725
|
+
},
|
|
1726
|
+
getLayoutFootprint(bounds, context, style) {
|
|
1727
|
+
return defaultLayoutFootprint(foldedDocumentGeometry, bounds, context, style);
|
|
1728
|
+
},
|
|
1729
|
+
getPortPosition(port, bounds) {
|
|
1730
|
+
return projectSidePortOntoPolygon(port, bounds, foldedDocumentPolygon(bounds), intersectRayPolygon);
|
|
1731
|
+
},
|
|
1732
|
+
getPortNormal(port, bounds) {
|
|
1733
|
+
return defaultPortNormal(port, bounds);
|
|
1734
|
+
},
|
|
1735
|
+
intersectRay(bounds, origin, direction) {
|
|
1736
|
+
return intersectRayPolygon(foldedDocumentPolygon(bounds), origin, direction);
|
|
1737
|
+
},
|
|
1738
|
+
containsPoint(bounds, point) {
|
|
1739
|
+
return pointInPolygon(point, foldedDocumentPolygon(bounds));
|
|
1740
|
+
}
|
|
1741
|
+
};
|
|
1742
|
+
//#endregion
|
|
1743
|
+
//#region src/shapes/cloud.ts
|
|
1744
|
+
/**
|
|
1745
|
+
* Material Design cloud scaled into bounds.
|
|
1746
|
+
* Flat base, soft lobes — proven silhouette (24×24 viewBox, content y∈[4,20]).
|
|
1747
|
+
*/
|
|
1748
|
+
const VB = {
|
|
1749
|
+
x: 0,
|
|
1750
|
+
y: 4,
|
|
1751
|
+
w: 24,
|
|
1752
|
+
h: 16
|
|
1753
|
+
};
|
|
1754
|
+
/** Intrinsic cloud aspect (viewBox content width / height). */
|
|
1755
|
+
const CLOUD_ASPECT = VB.w / VB.h;
|
|
1756
|
+
/**
|
|
1757
|
+
* Largest 3:2 rect centered in `bounds`. Non-uniform stretch turns lobes into a
|
|
1758
|
+
* sausage; letterbox so the silhouette stays cloud-shaped even if layout is tall.
|
|
1759
|
+
*/
|
|
1760
|
+
function fitCloudBounds(bounds) {
|
|
1761
|
+
if (bounds.width / Math.max(bounds.height, 1e-6) >= CLOUD_ASPECT) {
|
|
1762
|
+
const width = bounds.height * CLOUD_ASPECT;
|
|
1763
|
+
return {
|
|
1764
|
+
x: bounds.x + (bounds.width - width) / 2,
|
|
1765
|
+
y: bounds.y,
|
|
1766
|
+
width,
|
|
1767
|
+
height: bounds.height
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1770
|
+
const height = bounds.width / CLOUD_ASPECT;
|
|
1771
|
+
return {
|
|
1772
|
+
x: bounds.x,
|
|
1773
|
+
y: bounds.y + (bounds.height - height) / 2,
|
|
1774
|
+
width: bounds.width,
|
|
1775
|
+
height
|
|
1776
|
+
};
|
|
1777
|
+
}
|
|
1778
|
+
function map(bounds, px, py) {
|
|
1779
|
+
return {
|
|
1780
|
+
x: bounds.x + (px - VB.x) / VB.w * bounds.width,
|
|
1781
|
+
y: bounds.y + (py - VB.y) / VB.h * bounds.height
|
|
1782
|
+
};
|
|
1783
|
+
}
|
|
1784
|
+
function cloudSegments(bounds) {
|
|
1785
|
+
const fitted = fitCloudBounds(bounds);
|
|
1786
|
+
const p = (x, y) => map(fitted, x, y);
|
|
1787
|
+
return {
|
|
1788
|
+
start: p(19.35, 10.04),
|
|
1789
|
+
segs: [
|
|
1790
|
+
{
|
|
1791
|
+
type: "C",
|
|
1792
|
+
c1: p(18.67, 6.59),
|
|
1793
|
+
c2: p(15.64, 4),
|
|
1794
|
+
end: p(12, 4)
|
|
1795
|
+
},
|
|
1796
|
+
{
|
|
1797
|
+
type: "C",
|
|
1798
|
+
c1: p(9.11, 4),
|
|
1799
|
+
c2: p(6.6, 5.64),
|
|
1800
|
+
end: p(5.35, 8.04)
|
|
1801
|
+
},
|
|
1802
|
+
{
|
|
1803
|
+
type: "C",
|
|
1804
|
+
c1: p(2.34, 8.36),
|
|
1805
|
+
c2: p(0, 10.91),
|
|
1806
|
+
end: p(0, 14)
|
|
1807
|
+
},
|
|
1808
|
+
{
|
|
1809
|
+
type: "C",
|
|
1810
|
+
c1: p(0, 17.31),
|
|
1811
|
+
c2: p(2.69, 20),
|
|
1812
|
+
end: p(6, 20)
|
|
1813
|
+
},
|
|
1814
|
+
{
|
|
1815
|
+
type: "L",
|
|
1816
|
+
end: p(19, 20)
|
|
1817
|
+
},
|
|
1818
|
+
{
|
|
1819
|
+
type: "C",
|
|
1820
|
+
c1: p(21.76, 20),
|
|
1821
|
+
c2: p(24, 17.76),
|
|
1822
|
+
end: p(24, 15)
|
|
1823
|
+
},
|
|
1824
|
+
{
|
|
1825
|
+
type: "C",
|
|
1826
|
+
c1: p(24, 12.36),
|
|
1827
|
+
c2: p(21.95, 10.22),
|
|
1828
|
+
end: p(19.35, 10.04)
|
|
1829
|
+
}
|
|
1830
|
+
]
|
|
1831
|
+
};
|
|
1832
|
+
}
|
|
1833
|
+
function sampleCubic(p0, p1, p2, p3, steps) {
|
|
1834
|
+
const pts = [];
|
|
1835
|
+
for (let i = 1; i <= steps; i++) {
|
|
1836
|
+
const t = i / steps;
|
|
1837
|
+
const u = 1 - t;
|
|
1838
|
+
pts.push({
|
|
1839
|
+
x: u * u * u * p0.x + 3 * u * u * t * p1.x + 3 * u * t * t * p2.x + t * t * t * p3.x,
|
|
1840
|
+
y: u * u * u * p0.y + 3 * u * u * t * p1.y + 3 * u * t * t * p2.y + t * t * t * p3.y
|
|
1841
|
+
});
|
|
1842
|
+
}
|
|
1843
|
+
return pts;
|
|
1844
|
+
}
|
|
1845
|
+
/** Hit-test / port polygon matching the Material silhouette. */
|
|
1846
|
+
function cloudSamplePolygon(bounds, stepsPerCurve = 10) {
|
|
1847
|
+
const { start, segs } = cloudSegments(bounds);
|
|
1848
|
+
const pts = [start];
|
|
1849
|
+
let cur = start;
|
|
1850
|
+
for (const seg of segs) if (seg.type === "L") {
|
|
1851
|
+
pts.push(seg.end);
|
|
1852
|
+
cur = seg.end;
|
|
1853
|
+
} else {
|
|
1854
|
+
pts.push(...sampleCubic(cur, seg.c1, seg.c2, seg.end, stepsPerCurve));
|
|
1855
|
+
cur = seg.end;
|
|
1856
|
+
}
|
|
1857
|
+
return pts;
|
|
1858
|
+
}
|
|
1859
|
+
/** Material cloud path scaled into the node box. */
|
|
1860
|
+
function cloudPath(bounds) {
|
|
1861
|
+
const { start, segs } = cloudSegments(bounds);
|
|
1862
|
+
let d = `M ${start.x} ${start.y}`;
|
|
1863
|
+
for (const seg of segs) if (seg.type === "L") d += ` L ${seg.end.x} ${seg.end.y}`;
|
|
1864
|
+
else d += ` C ${seg.c1.x} ${seg.c1.y}, ${seg.c2.x} ${seg.c2.y}, ${seg.end.x} ${seg.end.y}`;
|
|
1865
|
+
return `${d} Z`;
|
|
1866
|
+
}
|
|
1867
|
+
const cloudGeometry = {
|
|
1868
|
+
id: "cloud",
|
|
1869
|
+
defaultSize: {
|
|
1870
|
+
width: 160,
|
|
1871
|
+
height: 100
|
|
1872
|
+
},
|
|
1873
|
+
minSize: {
|
|
1874
|
+
width: 80,
|
|
1875
|
+
height: 56
|
|
1876
|
+
},
|
|
1877
|
+
defaultPadding: {
|
|
1878
|
+
top: 20,
|
|
1879
|
+
right: 24,
|
|
1880
|
+
bottom: 20,
|
|
1881
|
+
left: 24
|
|
1882
|
+
},
|
|
1883
|
+
contentPolicy: {
|
|
1884
|
+
align: "center",
|
|
1885
|
+
maxLabelLines: 2,
|
|
1886
|
+
preferExternalLabel: false,
|
|
1887
|
+
iconPlacement: "centered"
|
|
1888
|
+
},
|
|
1889
|
+
getPath(bounds) {
|
|
1890
|
+
return {
|
|
1891
|
+
d: cloudPath(bounds),
|
|
1892
|
+
polygon: cloudSamplePolygon(bounds)
|
|
1893
|
+
};
|
|
1894
|
+
},
|
|
1895
|
+
getContentBounds(bounds, style = {}) {
|
|
1896
|
+
const stroke = style.strokeWidth ?? 1.6;
|
|
1897
|
+
const fitted = fitCloudBounds(bounds);
|
|
1898
|
+
const content = centeredContentRect(fitted, .55, .42);
|
|
1899
|
+
content.y = fitted.y + fitted.height * .38;
|
|
1900
|
+
return {
|
|
1901
|
+
x: content.x + stroke,
|
|
1902
|
+
y: content.y + stroke,
|
|
1903
|
+
width: Math.max(0, content.width - stroke * 2),
|
|
1904
|
+
height: Math.max(0, content.height - stroke * 2)
|
|
1905
|
+
};
|
|
1906
|
+
},
|
|
1907
|
+
getVisualBounds(bounds, style) {
|
|
1908
|
+
return defaultVisualBounds(bounds, style);
|
|
1909
|
+
},
|
|
1910
|
+
getLayoutFootprint(bounds, context, style) {
|
|
1911
|
+
return defaultLayoutFootprint(cloudGeometry, bounds, context, style);
|
|
1912
|
+
},
|
|
1913
|
+
getPortPosition(port, bounds) {
|
|
1914
|
+
return projectSidePortOntoPolygon(port, bounds, cloudSamplePolygon(bounds), intersectRayPolygon);
|
|
1915
|
+
},
|
|
1916
|
+
getPortNormal(port, bounds) {
|
|
1917
|
+
return defaultPortNormal(port, bounds);
|
|
1918
|
+
},
|
|
1919
|
+
intersectRay(bounds, origin, direction) {
|
|
1920
|
+
return intersectRayPolygon(cloudSamplePolygon(bounds), origin, direction);
|
|
1921
|
+
},
|
|
1922
|
+
containsPoint(bounds, point) {
|
|
1923
|
+
return pointInPolygon(point, cloudSamplePolygon(bounds));
|
|
1924
|
+
}
|
|
1925
|
+
};
|
|
1926
|
+
//#endregion
|
|
1927
|
+
//#region src/shapes/person.ts
|
|
1928
|
+
/** Head sits above the torso; torso uses the full node width and holds content. */
|
|
1929
|
+
const HEAD_GAP = .18;
|
|
1930
|
+
const BODY_PAD_X = 12;
|
|
1931
|
+
const BODY_PAD_Y = 10;
|
|
1932
|
+
function personLayout(bounds, stroke = DEFAULT_STROKE_WIDTH) {
|
|
1933
|
+
const { x, y, width, height } = bounds;
|
|
1934
|
+
const inset = stroke + 1;
|
|
1935
|
+
const bodyHalfW = Math.max(18, width / 2 - inset);
|
|
1936
|
+
const headR = Math.max(10, Math.min(22, bodyHalfW * .38));
|
|
1937
|
+
const headCy = y + headR + Math.max(3, inset);
|
|
1938
|
+
const bodyTop = headCy + headR + Math.max(2, headR * HEAD_GAP);
|
|
1939
|
+
const bodyBottom = y + height - inset;
|
|
1940
|
+
const bodyH = Math.max(bodyBottom - bodyTop, headR * 2.2);
|
|
1941
|
+
const bodyRx = Math.min(bodyHalfW, bodyH * .42);
|
|
1942
|
+
const cx = x + width / 2;
|
|
1943
|
+
return {
|
|
1944
|
+
cx,
|
|
1945
|
+
headR,
|
|
1946
|
+
headCy,
|
|
1947
|
+
bodyTop,
|
|
1948
|
+
bodyBottom,
|
|
1949
|
+
bodyHalfW,
|
|
1950
|
+
bodyRx,
|
|
1951
|
+
content: {
|
|
1952
|
+
x: cx - bodyHalfW + BODY_PAD_X,
|
|
1953
|
+
y: bodyTop + BODY_PAD_Y,
|
|
1954
|
+
width: Math.max(0, bodyHalfW * 2 - BODY_PAD_X * 2),
|
|
1955
|
+
height: Math.max(0, bodyBottom - bodyTop - BODY_PAD_Y * 2)
|
|
1956
|
+
}
|
|
1957
|
+
};
|
|
1958
|
+
}
|
|
1959
|
+
/** Port anchors: head (N), torso sides (E/W), feet (S). */
|
|
1960
|
+
function personPortAnchors(bounds) {
|
|
1961
|
+
const { cx, headR, headCy, bodyTop, bodyBottom, bodyHalfW, bodyRx } = personLayout(bounds);
|
|
1962
|
+
const ry = Math.min(bodyRx, (bodyBottom - bodyTop) / 2);
|
|
1963
|
+
const shoulderY = bodyTop + ry + Math.max(2, (bodyBottom - bodyTop - 2 * ry) * .12);
|
|
1964
|
+
return {
|
|
1965
|
+
north: {
|
|
1966
|
+
x: cx,
|
|
1967
|
+
y: headCy - headR
|
|
1968
|
+
},
|
|
1969
|
+
east: {
|
|
1970
|
+
x: cx + bodyHalfW,
|
|
1971
|
+
y: shoulderY
|
|
1972
|
+
},
|
|
1973
|
+
west: {
|
|
1974
|
+
x: cx - bodyHalfW,
|
|
1975
|
+
y: shoulderY
|
|
1976
|
+
},
|
|
1977
|
+
south: {
|
|
1978
|
+
x: cx,
|
|
1979
|
+
y: bodyBottom
|
|
1980
|
+
}
|
|
1981
|
+
};
|
|
1982
|
+
}
|
|
1983
|
+
/** Closed silhouette for rays / multi-port projection (head arc + rounded torso). */
|
|
1984
|
+
function personSilhouettePolygon(bounds, headSamples = 12) {
|
|
1985
|
+
const { cx, headR, headCy, bodyTop, bodyBottom, bodyHalfW, bodyRx } = personLayout(bounds);
|
|
1986
|
+
const pts = [];
|
|
1987
|
+
for (let i = 0; i <= headSamples; i++) {
|
|
1988
|
+
const t = Math.PI - i / headSamples * Math.PI;
|
|
1989
|
+
pts.push({
|
|
1990
|
+
x: cx + headR * Math.cos(t),
|
|
1991
|
+
y: headCy - headR * Math.sin(t)
|
|
1992
|
+
});
|
|
1993
|
+
}
|
|
1994
|
+
const right = cx + bodyHalfW;
|
|
1995
|
+
const left = cx - bodyHalfW;
|
|
1996
|
+
const ry = Math.min(bodyRx, (bodyBottom - bodyTop) / 2);
|
|
1997
|
+
pts.push({
|
|
1998
|
+
x: right,
|
|
1999
|
+
y: bodyTop + ry
|
|
2000
|
+
});
|
|
2001
|
+
pts.push({
|
|
2002
|
+
x: right,
|
|
2003
|
+
y: bodyBottom - ry
|
|
2004
|
+
});
|
|
2005
|
+
pts.push({
|
|
2006
|
+
x: cx + bodyHalfW - ry * .15,
|
|
2007
|
+
y: bodyBottom
|
|
2008
|
+
});
|
|
2009
|
+
pts.push({
|
|
2010
|
+
x: cx,
|
|
2011
|
+
y: bodyBottom
|
|
2012
|
+
});
|
|
2013
|
+
pts.push({
|
|
2014
|
+
x: cx - bodyHalfW + ry * .15,
|
|
2015
|
+
y: bodyBottom
|
|
2016
|
+
});
|
|
2017
|
+
pts.push({
|
|
2018
|
+
x: left,
|
|
2019
|
+
y: bodyBottom - ry
|
|
2020
|
+
});
|
|
2021
|
+
pts.push({
|
|
2022
|
+
x: left,
|
|
2023
|
+
y: bodyTop + ry
|
|
2024
|
+
});
|
|
2025
|
+
return pts;
|
|
2026
|
+
}
|
|
2027
|
+
/**
|
|
2028
|
+
* Person / actor — C4-style head + rounded torso.
|
|
2029
|
+
* The torso is the content box: it grows with label/subtitle width.
|
|
2030
|
+
*/
|
|
2031
|
+
function personPaths(bounds) {
|
|
2032
|
+
const { cx, headR, headCy, bodyTop, bodyBottom, bodyHalfW, bodyRx } = personLayout(bounds);
|
|
2033
|
+
const head = `M ${cx} ${headCy} m ${-headR} 0 a ${headR} ${headR} 0 1 0 ${headR * 2} 0 a ${headR} ${headR} 0 1 0 ${-headR * 2} 0`;
|
|
2034
|
+
const left = cx - bodyHalfW;
|
|
2035
|
+
const right = cx + bodyHalfW;
|
|
2036
|
+
const ry = Math.min(bodyRx, (bodyBottom - bodyTop) / 2);
|
|
2037
|
+
return {
|
|
2038
|
+
head,
|
|
2039
|
+
body: [
|
|
2040
|
+
`M ${left + ry} ${bodyTop}`,
|
|
2041
|
+
`H ${right - ry}`,
|
|
2042
|
+
`A ${ry} ${ry} 0 0 1 ${right} ${bodyTop + ry}`,
|
|
2043
|
+
`V ${bodyBottom - ry}`,
|
|
2044
|
+
`A ${ry} ${ry} 0 0 1 ${right - ry} ${bodyBottom}`,
|
|
2045
|
+
`H ${left + ry}`,
|
|
2046
|
+
`A ${ry} ${ry} 0 0 1 ${left} ${bodyBottom - ry}`,
|
|
2047
|
+
`V ${bodyTop + ry}`,
|
|
2048
|
+
`A ${ry} ${ry} 0 0 1 ${left + ry} ${bodyTop}`,
|
|
2049
|
+
`Z`
|
|
2050
|
+
].join(" ")
|
|
2051
|
+
};
|
|
2052
|
+
}
|
|
2053
|
+
/** Extra height above the torso content for head + neck (used by measure). */
|
|
2054
|
+
function personHeadStackHeight(bodyWidth) {
|
|
2055
|
+
const bodyHalfW = Math.max(18, bodyWidth / 2 - 2);
|
|
2056
|
+
const headR = Math.max(10, Math.min(22, bodyHalfW * .38));
|
|
2057
|
+
return headR * 2 + Math.max(2, headR * HEAD_GAP) + 6;
|
|
2058
|
+
}
|
|
2059
|
+
const personGeometry = {
|
|
2060
|
+
id: "person",
|
|
2061
|
+
defaultSize: {
|
|
2062
|
+
width: 120,
|
|
2063
|
+
height: 140
|
|
2064
|
+
},
|
|
2065
|
+
minSize: {
|
|
2066
|
+
width: 72,
|
|
2067
|
+
height: 96
|
|
2068
|
+
},
|
|
2069
|
+
defaultPadding: {
|
|
2070
|
+
top: 8,
|
|
2071
|
+
right: 12,
|
|
2072
|
+
bottom: 10,
|
|
2073
|
+
left: 12
|
|
2074
|
+
},
|
|
2075
|
+
contentPolicy: {
|
|
2076
|
+
align: "center",
|
|
2077
|
+
maxLabelLines: 2,
|
|
2078
|
+
preferExternalLabel: false,
|
|
2079
|
+
iconPlacement: "above"
|
|
2080
|
+
},
|
|
2081
|
+
clearSpaceBoost: 4,
|
|
2082
|
+
getPath(bounds) {
|
|
2083
|
+
const { head, body } = personPaths(bounds);
|
|
2084
|
+
return {
|
|
2085
|
+
d: `${head} ${body}`,
|
|
2086
|
+
decorations: [{
|
|
2087
|
+
d: head,
|
|
2088
|
+
role: "head"
|
|
2089
|
+
}, {
|
|
2090
|
+
d: body,
|
|
2091
|
+
role: "body"
|
|
2092
|
+
}]
|
|
2093
|
+
};
|
|
2094
|
+
},
|
|
2095
|
+
getContentBounds(bounds, style = {}) {
|
|
2096
|
+
return personLayout(bounds, style.strokeWidth ?? 1.6).content;
|
|
2097
|
+
},
|
|
2098
|
+
getVisualBounds(bounds, style) {
|
|
2099
|
+
return defaultVisualBounds(bounds, style);
|
|
2100
|
+
},
|
|
2101
|
+
getLayoutFootprint(bounds, context, style) {
|
|
2102
|
+
return defaultLayoutFootprint(personGeometry, bounds, context, style);
|
|
2103
|
+
},
|
|
2104
|
+
getPortPosition(port, bounds) {
|
|
2105
|
+
if (port.kind === "side" && (port.count ?? 1) <= 1) {
|
|
2106
|
+
const anchors = personPortAnchors(bounds);
|
|
2107
|
+
const side = port.side;
|
|
2108
|
+
if (side === "north" || side === "east" || side === "south" || side === "west") return anchors[side];
|
|
2109
|
+
}
|
|
2110
|
+
return projectSidePortOntoPolygon(port, bounds, personSilhouettePolygon(bounds), intersectRayPolygon);
|
|
2111
|
+
},
|
|
2112
|
+
getPortNormal(port, bounds) {
|
|
2113
|
+
return defaultPortNormal(port, bounds);
|
|
2114
|
+
},
|
|
2115
|
+
intersectRay(bounds, origin, direction) {
|
|
2116
|
+
return intersectRayPolygon(personSilhouettePolygon(bounds), origin, direction);
|
|
2117
|
+
},
|
|
2118
|
+
containsPoint(bounds, point) {
|
|
2119
|
+
return pointInPolygon(point, personSilhouettePolygon(bounds));
|
|
2120
|
+
}
|
|
2121
|
+
};
|
|
2122
|
+
const compartmentedRectangleGeometry = {
|
|
2123
|
+
id: "table",
|
|
2124
|
+
defaultSize: {
|
|
2125
|
+
width: 240,
|
|
2126
|
+
height: 160
|
|
2127
|
+
},
|
|
2128
|
+
minSize: {
|
|
2129
|
+
width: 140,
|
|
2130
|
+
height: 72
|
|
2131
|
+
},
|
|
2132
|
+
defaultPadding: {
|
|
2133
|
+
top: 8,
|
|
2134
|
+
right: 12,
|
|
2135
|
+
bottom: 8,
|
|
2136
|
+
left: 12
|
|
2137
|
+
},
|
|
2138
|
+
contentPolicy: {
|
|
2139
|
+
align: "start",
|
|
2140
|
+
maxLabelLines: 1,
|
|
2141
|
+
iconPlacement: "none"
|
|
2142
|
+
},
|
|
2143
|
+
getPath(bounds) {
|
|
2144
|
+
const { x, y, width, height } = bounds;
|
|
2145
|
+
return {
|
|
2146
|
+
d: `M ${x} ${y} H ${x + width} V ${y + height} H ${x} Z`,
|
|
2147
|
+
polygon: rectPolygon(bounds)
|
|
2148
|
+
};
|
|
2149
|
+
},
|
|
2150
|
+
getContentBounds(bounds, style = {}) {
|
|
2151
|
+
const stroke = style.strokeWidth ?? 1.6;
|
|
2152
|
+
return {
|
|
2153
|
+
x: bounds.x + stroke + 8,
|
|
2154
|
+
y: bounds.y + stroke + 8,
|
|
2155
|
+
width: Math.max(0, bounds.width - (stroke + 8) * 2),
|
|
2156
|
+
height: Math.max(0, bounds.height - (stroke + 8) * 2)
|
|
2157
|
+
};
|
|
2158
|
+
},
|
|
2159
|
+
getVisualBounds(bounds, style) {
|
|
2160
|
+
return defaultVisualBounds(bounds, style);
|
|
2161
|
+
},
|
|
2162
|
+
getLayoutFootprint(bounds, context, style) {
|
|
2163
|
+
return defaultLayoutFootprint(compartmentedRectangleGeometry, bounds, context, style);
|
|
2164
|
+
},
|
|
2165
|
+
getPortPosition(port, bounds) {
|
|
2166
|
+
return defaultSidePortPosition(port, bounds);
|
|
2167
|
+
},
|
|
2168
|
+
getPortNormal(port, bounds) {
|
|
2169
|
+
return defaultPortNormal(port, bounds);
|
|
2170
|
+
},
|
|
2171
|
+
intersectRay(bounds, origin, direction) {
|
|
2172
|
+
return intersectRayRect(bounds, origin, direction);
|
|
2173
|
+
},
|
|
2174
|
+
containsPoint(bounds, point) {
|
|
2175
|
+
return pointInPolygon(point, rectPolygon(bounds));
|
|
2176
|
+
}
|
|
2177
|
+
};
|
|
2178
|
+
const boundaryGeometry = {
|
|
2179
|
+
id: "boundary",
|
|
2180
|
+
defaultSize: {
|
|
2181
|
+
width: 320,
|
|
2182
|
+
height: 220
|
|
2183
|
+
},
|
|
2184
|
+
minSize: {
|
|
2185
|
+
width: 120,
|
|
2186
|
+
height: 80
|
|
2187
|
+
},
|
|
2188
|
+
defaultPadding: {
|
|
2189
|
+
top: 28,
|
|
2190
|
+
right: 20,
|
|
2191
|
+
bottom: 20,
|
|
2192
|
+
left: 20
|
|
2193
|
+
},
|
|
2194
|
+
contentPolicy: {
|
|
2195
|
+
align: "start",
|
|
2196
|
+
maxLabelLines: 1,
|
|
2197
|
+
iconPlacement: "none"
|
|
2198
|
+
},
|
|
2199
|
+
getPath(bounds) {
|
|
2200
|
+
return compartmentedRectangleGeometry.getPath(bounds);
|
|
2201
|
+
},
|
|
2202
|
+
getContentBounds(bounds, style) {
|
|
2203
|
+
return compartmentedRectangleGeometry.getContentBounds(bounds, style);
|
|
2204
|
+
},
|
|
2205
|
+
getVisualBounds(bounds, style) {
|
|
2206
|
+
return compartmentedRectangleGeometry.getVisualBounds(bounds, style);
|
|
2207
|
+
},
|
|
2208
|
+
getLayoutFootprint(bounds, context, style) {
|
|
2209
|
+
return defaultLayoutFootprint(boundaryGeometry, bounds, context, style);
|
|
2210
|
+
},
|
|
2211
|
+
getPortPosition(port, bounds, style) {
|
|
2212
|
+
return compartmentedRectangleGeometry.getPortPosition(port, bounds, style);
|
|
2213
|
+
},
|
|
2214
|
+
getPortNormal(port, bounds, style) {
|
|
2215
|
+
return compartmentedRectangleGeometry.getPortNormal(port, bounds, style);
|
|
2216
|
+
},
|
|
2217
|
+
intersectRay(bounds, origin, direction, style) {
|
|
2218
|
+
return compartmentedRectangleGeometry.intersectRay(bounds, origin, direction, style);
|
|
2219
|
+
},
|
|
2220
|
+
containsPoint(bounds, point, style) {
|
|
2221
|
+
return compartmentedRectangleGeometry.containsPoint(bounds, point, style);
|
|
2222
|
+
}
|
|
2223
|
+
};
|
|
2224
|
+
//#endregion
|
|
2225
|
+
//#region src/shapes/index.ts
|
|
2226
|
+
/** All built-in geometries keyed by id. */
|
|
2227
|
+
const BUILTIN_GEOMETRIES = {
|
|
2228
|
+
rectangle: rectangleGeometry,
|
|
2229
|
+
rounded: roundedRectangleGeometry,
|
|
2230
|
+
pill: pillGeometry,
|
|
2231
|
+
diamond: diamondGeometry,
|
|
2232
|
+
hexagon: hexagonGeometry,
|
|
2233
|
+
queue: queueGeometry,
|
|
2234
|
+
stream: streamGeometry,
|
|
2235
|
+
cylinder: cylinderGeometry,
|
|
2236
|
+
circle: circleGeometry,
|
|
2237
|
+
ellipse: ellipseGeometry,
|
|
2238
|
+
parallelogram: parallelogramGeometry,
|
|
2239
|
+
trapezoid: trapezoidGeometry,
|
|
2240
|
+
triangle: triangleGeometry,
|
|
2241
|
+
document: documentGeometry,
|
|
2242
|
+
"folded-document": foldedDocumentGeometry,
|
|
2243
|
+
cloud: cloudGeometry,
|
|
2244
|
+
person: personGeometry,
|
|
2245
|
+
table: compartmentedRectangleGeometry,
|
|
2246
|
+
boundary: boundaryGeometry
|
|
2247
|
+
};
|
|
2248
|
+
//#endregion
|
|
2249
|
+
//#region src/registry.ts
|
|
2250
|
+
const customShapes = /* @__PURE__ */ new Map();
|
|
2251
|
+
const customNodeTypes = /* @__PURE__ */ new Map();
|
|
2252
|
+
function definitionFromGeometry(geometry) {
|
|
2253
|
+
return {
|
|
2254
|
+
id: geometry.id,
|
|
2255
|
+
geometry,
|
|
2256
|
+
defaultSize: geometry.defaultSize ?? {
|
|
2257
|
+
width: 140,
|
|
2258
|
+
height: 72
|
|
2259
|
+
},
|
|
2260
|
+
minSize: geometry.minSize ?? {
|
|
2261
|
+
width: 40,
|
|
2262
|
+
height: 32
|
|
2263
|
+
},
|
|
2264
|
+
defaultPadding: geometry.defaultPadding ?? {
|
|
2265
|
+
top: 14,
|
|
2266
|
+
right: 16,
|
|
2267
|
+
bottom: 14,
|
|
2268
|
+
left: 16
|
|
2269
|
+
},
|
|
2270
|
+
supportedPortStrategies: [
|
|
2271
|
+
"bbox-mid",
|
|
2272
|
+
"distributed-sides",
|
|
2273
|
+
"perimeter"
|
|
2274
|
+
],
|
|
2275
|
+
contentPolicy: geometry.contentPolicy ?? {
|
|
2276
|
+
align: "center",
|
|
2277
|
+
maxLabelLines: 2
|
|
2278
|
+
}
|
|
2279
|
+
};
|
|
2280
|
+
}
|
|
2281
|
+
/** Register a custom shape without changing the renderer core. */
|
|
2282
|
+
function registerShape(definition) {
|
|
2283
|
+
customShapes.set(definition.id, definition);
|
|
2284
|
+
}
|
|
2285
|
+
function unregisterShape(id) {
|
|
2286
|
+
customShapes.delete(id);
|
|
2287
|
+
}
|
|
2288
|
+
function getShapeDefinition(id) {
|
|
2289
|
+
const normalized = normalizeShapeId$1(id);
|
|
2290
|
+
const custom = customShapes.get(normalized) ?? customShapes.get(id);
|
|
2291
|
+
if (custom) return custom;
|
|
2292
|
+
if (normalized in BUILTIN_GEOMETRIES) return definitionFromGeometry(BUILTIN_GEOMETRIES[normalized]);
|
|
2293
|
+
}
|
|
2294
|
+
function getShapeGeometry(id) {
|
|
2295
|
+
return getShapeDefinition(id)?.geometry;
|
|
2296
|
+
}
|
|
2297
|
+
/**
|
|
2298
|
+
* Resolve a shape id with fallback.
|
|
2299
|
+
* Unknown ids fall back to rounded rectangle (same as historic SVG default).
|
|
2300
|
+
*/
|
|
2301
|
+
function resolveShapeGeometry(id) {
|
|
2302
|
+
if (id) {
|
|
2303
|
+
const found = getShapeGeometry(normalizeShapeId$1(id));
|
|
2304
|
+
if (found) return found;
|
|
2305
|
+
}
|
|
2306
|
+
return BUILTIN_GEOMETRIES.rounded;
|
|
2307
|
+
}
|
|
2308
|
+
function listRegisteredShapeIds() {
|
|
2309
|
+
const builtins = Object.keys(BUILTIN_GEOMETRIES);
|
|
2310
|
+
const customs = [...customShapes.keys()];
|
|
2311
|
+
return [.../* @__PURE__ */ new Set([...builtins, ...customs])];
|
|
2312
|
+
}
|
|
2313
|
+
/** Map authored shape synonyms onto registry ids (delegates to core). */
|
|
2314
|
+
function normalizeShapeId(shape) {
|
|
2315
|
+
return normalizeShapeId$1(shape);
|
|
2316
|
+
}
|
|
2317
|
+
/** Register or override a semantic node type → shape mapping. */
|
|
2318
|
+
function registerNodeType(definition) {
|
|
2319
|
+
customNodeTypes.set(definition.id, {
|
|
2320
|
+
...definition,
|
|
2321
|
+
shapeId: normalizeShapeId$1(definition.shapeId)
|
|
2322
|
+
});
|
|
2323
|
+
}
|
|
2324
|
+
function unregisterNodeType(id) {
|
|
2325
|
+
customNodeTypes.delete(id);
|
|
2326
|
+
}
|
|
2327
|
+
function nodeTypeFromKindCatalog(id) {
|
|
2328
|
+
const defaults = BUILTIN_KIND_CATALOG[id];
|
|
2329
|
+
if (!defaults) return void 0;
|
|
2330
|
+
return {
|
|
2331
|
+
id,
|
|
2332
|
+
shapeId: defaults.shape,
|
|
2333
|
+
defaultIcon: defaults.icon,
|
|
2334
|
+
defaultStyle: defaults.cssVars,
|
|
2335
|
+
capabilities: defaults.capabilities,
|
|
2336
|
+
subtitle: defaults.subtitle,
|
|
2337
|
+
category: defaults.category
|
|
2338
|
+
};
|
|
2339
|
+
}
|
|
2340
|
+
function getNodeTypeDefinition(id) {
|
|
2341
|
+
return customNodeTypes.get(id) ?? nodeTypeFromKindCatalog(id);
|
|
2342
|
+
}
|
|
2343
|
+
/**
|
|
2344
|
+
* Resolve the geometry for a semantic kind (with optional shape override).
|
|
2345
|
+
* Kind defaults come from the DSL catalog; shape overrides win.
|
|
2346
|
+
*/
|
|
2347
|
+
function resolveNodeTypeGeometry(kind, shapeOverride) {
|
|
2348
|
+
if (shapeOverride) return resolveShapeGeometry(shapeOverride);
|
|
2349
|
+
return resolveShapeGeometry(getNodeTypeDefinition(kind)?.shapeId);
|
|
2350
|
+
}
|
|
2351
|
+
function listRegisteredNodeTypeIds() {
|
|
2352
|
+
const builtins = Object.keys(BUILTIN_KIND_CATALOG);
|
|
2353
|
+
const customs = [...customNodeTypes.keys()];
|
|
2354
|
+
return [.../* @__PURE__ */ new Set([...builtins, ...customs])].sort();
|
|
2355
|
+
}
|
|
2356
|
+
//#endregion
|
|
2357
|
+
//#region src/measure.ts
|
|
2358
|
+
/**
|
|
2359
|
+
* Given a measured content size, find geometry bounds large enough that
|
|
2360
|
+
* getContentBounds() can contain it. Uses iterative refinement because
|
|
2361
|
+
* content insets are not always linear (cylinder caps, hex chamfers, …).
|
|
2362
|
+
*/
|
|
2363
|
+
function geometrySizeForContent(geometry, content, seed) {
|
|
2364
|
+
let width = Math.max(content.width, seed?.width ?? content.width, geometry.minSize?.width ?? 0);
|
|
2365
|
+
let height = Math.max(content.height, seed?.height ?? content.height, geometry.minSize?.height ?? 0);
|
|
2366
|
+
for (let i = 0; i < 6; i++) {
|
|
2367
|
+
const bounds = {
|
|
2368
|
+
x: 0,
|
|
2369
|
+
y: 0,
|
|
2370
|
+
width,
|
|
2371
|
+
height
|
|
2372
|
+
};
|
|
2373
|
+
const box = geometry.getContentBounds(bounds);
|
|
2374
|
+
const needW = box.width > 0 ? content.width / box.width : 1;
|
|
2375
|
+
const needH = box.height > 0 ? content.height / box.height : 1;
|
|
2376
|
+
const nextW = Math.max(width * needW, geometry.minSize?.width ?? 0);
|
|
2377
|
+
const nextH = Math.max(height * needH, geometry.minSize?.height ?? 0);
|
|
2378
|
+
if (Math.abs(nextW - width) < .5 && Math.abs(nextH - height) < .5) {
|
|
2379
|
+
width = nextW;
|
|
2380
|
+
height = nextH;
|
|
2381
|
+
break;
|
|
2382
|
+
}
|
|
2383
|
+
width = nextW;
|
|
2384
|
+
height = nextH;
|
|
2385
|
+
}
|
|
2386
|
+
return {
|
|
2387
|
+
width,
|
|
2388
|
+
height
|
|
2389
|
+
};
|
|
2390
|
+
}
|
|
2391
|
+
/** Relative content box (origin at geometry top-left) for MeasuredNode.contentBox. */
|
|
2392
|
+
function relativeContentBox(geometry, width, height) {
|
|
2393
|
+
return geometry.getContentBounds({
|
|
2394
|
+
x: 0,
|
|
2395
|
+
y: 0,
|
|
2396
|
+
width,
|
|
2397
|
+
height
|
|
2398
|
+
});
|
|
2399
|
+
}
|
|
2400
|
+
//#endregion
|
|
2401
|
+
//#region src/attach.ts
|
|
2402
|
+
/** Intersection of a ray with the shape perimeter; falls back via center ray, then center. */
|
|
2403
|
+
function attachPointOnPerimeter(input) {
|
|
2404
|
+
const geometry = resolveShapeGeometry(normalizeShapeId(input.shapeId));
|
|
2405
|
+
const hit = geometry.intersectRay(input.bounds, input.origin, input.direction, input.style);
|
|
2406
|
+
if (hit) return hit;
|
|
2407
|
+
const cx = input.bounds.x + input.bounds.width / 2;
|
|
2408
|
+
const cy = input.bounds.y + input.bounds.height / 2;
|
|
2409
|
+
return geometry.intersectRay(input.bounds, {
|
|
2410
|
+
x: cx,
|
|
2411
|
+
y: cy
|
|
2412
|
+
}, input.direction, input.style) ?? {
|
|
2413
|
+
x: cx,
|
|
2414
|
+
y: cy
|
|
2415
|
+
};
|
|
2416
|
+
}
|
|
2417
|
+
//#endregion
|
|
2418
|
+
export { BUILTIN_GEOMETRIES, CLEAR_SPACE_PRESETS, DEFAULT_STROKE_WIDTH, EDGE_LAUNCH_PRESETS, MIN_INTERACTION_TARGET, attachPointOnPerimeter, boundaryGeometry, buildNodeBoundsModel, centeredContentRect, circleGeometry, closedCatmullRomToPath, cloudGeometry, compartmentedRectangleGeometry, cylinderGeometry, cylinderPaths, cylinderRadii, cylinderSilhouettePolygon, defaultLayoutFootprint, defaultPortNormal, defaultSidePortPosition, defaultVisualBounds, diamondGeometry, diamondPointsString, diamondPolygon, distributedSidePort, documentGeometry, ellipseGeometry, expandRectInsets, foldedDocumentGeometry, geometrySizeForContent, getNodeTypeDefinition, getShapeDefinition, getShapeGeometry, hexagonGeometry, hexagonInset, hexagonPointsString, hexagonPolygon, insetRect, insets, intersectRayEllipse, intersectRayPolygon, intersectRayRect, listRegisteredNodeTypeIds, listRegisteredShapeIds, normalizeShapeId, normalizeVector, parallelogramGeometry, personGeometry, personHeadStackHeight, personPaths, personPortAnchors, personSilhouettePolygon, pillGeometry, pointInEllipse, pointInPolygon, polygonToPath, projectSidePortOntoOutline, projectSidePortOntoPolygon, queueGeometry, queuePaths, queueRadii, queueSilhouettePolygon, rectFromSize, rectPolygon, rectangleGeometry, registerNodeType, registerShape, relativeContentBox, resolveNodeTypeGeometry, resolveShapeGeometry, roundedRectangleGeometry, sideMidpoint, sideNormal, streamCornerRadius, streamGeometry, streamPartitionPaths, streamPartitionYs, streamShellPath, strokeOutset, trapezoidGeometry, triangleGeometry, uniformInsets, unionRects, unregisterNodeType, unregisterShape };
|
|
2419
|
+
|
|
2420
|
+
//# sourceMappingURL=index.mjs.map
|