@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/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