vizcraft 0.2.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/shapes.js CHANGED
@@ -87,10 +87,796 @@ const diamondBehavior = {
87
87
  };
88
88
  },
89
89
  };
90
+ function cylinderGeometry(shape, pos) {
91
+ const rx = shape.w / 2;
92
+ const ry = shape.arcHeight ?? Math.round(shape.h * 0.15);
93
+ const topY = pos.y - shape.h / 2;
94
+ const bottomY = pos.y + shape.h / 2;
95
+ const x0 = pos.x - rx;
96
+ const x1 = pos.x + rx;
97
+ const bodyD = `M ${x0} ${topY} A ${rx} ${ry} 0 0 1 ${x1} ${topY} V ${bottomY} A ${rx} ${ry} 0 0 1 ${x0} ${bottomY} V ${topY} Z`;
98
+ return { rx, ry, topY, bottomY, x0, x1, bodyD };
99
+ }
100
+ const SVG_NS = 'http://www.w3.org/2000/svg';
101
+ const cylinderBehavior = {
102
+ kind: 'cylinder',
103
+ tagName: 'g',
104
+ applyGeometry(el, shape, pos) {
105
+ const { rx, ry, topY, bodyD } = cylinderGeometry(shape, pos);
106
+ // Get or create body path
107
+ let body = el.querySelector('[data-viz-cyl="body"]');
108
+ if (!body) {
109
+ body = document.createElementNS(SVG_NS, 'path');
110
+ body.setAttribute('data-viz-cyl', 'body');
111
+ el.appendChild(body);
112
+ }
113
+ body.setAttribute('d', bodyD);
114
+ // Get or create top cap ellipse (drawn on top for 3D effect)
115
+ let cap = el.querySelector('[data-viz-cyl="cap"]');
116
+ if (!cap) {
117
+ cap = document.createElementNS(SVG_NS, 'ellipse');
118
+ cap.setAttribute('data-viz-cyl', 'cap');
119
+ el.appendChild(cap);
120
+ }
121
+ cap.setAttribute('cx', String(pos.x));
122
+ cap.setAttribute('cy', String(topY));
123
+ cap.setAttribute('rx', String(rx));
124
+ cap.setAttribute('ry', String(ry));
125
+ },
126
+ svgMarkup(shape, pos, attrs) {
127
+ const { rx, ry, topY, bodyD } = cylinderGeometry(shape, pos);
128
+ const end = '</g>';
129
+ return (`<g class="viz-node-shape" data-viz-role="node-shape"${attrs}>` +
130
+ `<path d="${bodyD}" data-viz-cyl="body"/>` +
131
+ `<ellipse cx="${pos.x}" cy="${topY}" rx="${rx}" ry="${ry}" data-viz-cyl="cap"/>` +
132
+ end);
133
+ },
134
+ anchorBoundary(pos, target, shape) {
135
+ // Approximate as rectangle bounding box
136
+ const dx = target.x - pos.x;
137
+ const dy = target.y - pos.y;
138
+ if (dx === 0 && dy === 0)
139
+ return { x: pos.x, y: pos.y };
140
+ const hw = shape.w / 2;
141
+ const hh = shape.h / 2;
142
+ const scale = Math.min(hw / Math.abs(dx || 1e-6), hh / Math.abs(dy || 1e-6));
143
+ return {
144
+ x: pos.x + dx * scale,
145
+ y: pos.y + dy * scale,
146
+ };
147
+ },
148
+ };
149
+ function hexagonPoints(pos, r, orientation) {
150
+ const pts = [];
151
+ // pointy-top: first vertex at top (angle offset -90°)
152
+ // flat-top: first vertex at right (angle offset 0°)
153
+ const angleOffset = orientation === 'pointy' ? -Math.PI / 2 : 0;
154
+ for (let i = 0; i < 6; i++) {
155
+ const angle = angleOffset + (Math.PI / 3) * i;
156
+ const px = pos.x + r * Math.cos(angle);
157
+ const py = pos.y + r * Math.sin(angle);
158
+ pts.push(`${px},${py}`);
159
+ }
160
+ return pts.join(' ');
161
+ }
162
+ const hexagonBehavior = {
163
+ kind: 'hexagon',
164
+ tagName: 'polygon',
165
+ applyGeometry(el, shape, pos) {
166
+ const orientation = shape.orientation ?? 'pointy';
167
+ el.setAttribute('points', hexagonPoints(pos, shape.r, orientation));
168
+ },
169
+ svgMarkup(shape, pos, attrs) {
170
+ const orientation = shape.orientation ?? 'pointy';
171
+ const pts = hexagonPoints(pos, shape.r, orientation);
172
+ return `<polygon points="${pts}" class="viz-node-shape" data-viz-role="node-shape"${attrs} />`;
173
+ },
174
+ anchorBoundary(pos, target, shape) {
175
+ // Use the circumscribed circle as the boundary approximation
176
+ const dx = target.x - pos.x;
177
+ const dy = target.y - pos.y;
178
+ if (dx === 0 && dy === 0)
179
+ return { x: pos.x, y: pos.y };
180
+ const dist = Math.hypot(dx, dy) || 1;
181
+ const scale = shape.r / dist;
182
+ return {
183
+ x: pos.x + dx * scale,
184
+ y: pos.y + dy * scale,
185
+ };
186
+ },
187
+ };
188
+ const ellipseBehavior = {
189
+ kind: 'ellipse',
190
+ tagName: 'ellipse',
191
+ applyGeometry(el, shape, pos) {
192
+ el.setAttribute('cx', String(pos.x));
193
+ el.setAttribute('cy', String(pos.y));
194
+ el.setAttribute('rx', String(shape.rx));
195
+ el.setAttribute('ry', String(shape.ry));
196
+ },
197
+ svgMarkup(shape, pos, attrs) {
198
+ return `<ellipse cx="${pos.x}" cy="${pos.y}" rx="${shape.rx}" ry="${shape.ry}" class="viz-node-shape" data-viz-role="node-shape"${attrs} />`;
199
+ },
200
+ anchorBoundary(pos, target, shape) {
201
+ const dx = target.x - pos.x;
202
+ const dy = target.y - pos.y;
203
+ if (dx === 0 && dy === 0)
204
+ return { x: pos.x, y: pos.y };
205
+ const denom = Math.sqrt(shape.rx * shape.rx * dy * dy + shape.ry * shape.ry * dx * dx) || 1;
206
+ return {
207
+ x: pos.x + (shape.rx * shape.ry * dx) / denom,
208
+ y: pos.y + (shape.rx * shape.ry * dy) / denom,
209
+ };
210
+ },
211
+ };
212
+ function arcPathD(shape, pos) {
213
+ const toRad = Math.PI / 180;
214
+ const s = shape.startAngle * toRad;
215
+ const e = shape.endAngle * toRad;
216
+ const r = shape.r;
217
+ const sx = pos.x + r * Math.cos(s);
218
+ const sy = pos.y + r * Math.sin(s);
219
+ const ex = pos.x + r * Math.cos(e);
220
+ const ey = pos.y + r * Math.sin(e);
221
+ const sweep = shape.endAngle - shape.startAngle;
222
+ const largeArc = ((sweep % 360) + 360) % 360 > 180 ? 1 : 0;
223
+ const closed = shape.closed !== false;
224
+ if (closed) {
225
+ return `M ${pos.x} ${pos.y} L ${sx} ${sy} A ${r} ${r} 0 ${largeArc} 1 ${ex} ${ey} Z`;
226
+ }
227
+ return `M ${sx} ${sy} A ${r} ${r} 0 ${largeArc} 1 ${ex} ${ey}`;
228
+ }
229
+ const arcBehavior = {
230
+ kind: 'arc',
231
+ tagName: 'path',
232
+ applyGeometry(el, shape, pos) {
233
+ el.setAttribute('d', arcPathD(shape, pos));
234
+ },
235
+ svgMarkup(shape, pos, attrs) {
236
+ const d = arcPathD(shape, pos);
237
+ return `<path d="${d}" class="viz-node-shape" data-viz-role="node-shape"${attrs} />`;
238
+ },
239
+ anchorBoundary(pos, target, shape) {
240
+ // Approximate as circumscribed circle
241
+ const dx = target.x - pos.x;
242
+ const dy = target.y - pos.y;
243
+ if (dx === 0 && dy === 0)
244
+ return { x: pos.x, y: pos.y };
245
+ const dist = Math.hypot(dx, dy) || 1;
246
+ const scale = shape.r / dist;
247
+ return {
248
+ x: pos.x + dx * scale,
249
+ y: pos.y + dy * scale,
250
+ };
251
+ },
252
+ };
253
+ function blockArrowPoints(shape, pos) {
254
+ const halfBody = shape.bodyWidth / 2;
255
+ const halfHead = shape.headWidth / 2;
256
+ const halfLen = shape.length / 2;
257
+ const neckX = halfLen - shape.headLength;
258
+ const dir = shape.direction ?? 'right';
259
+ const angle = dir === 'left'
260
+ ? Math.PI
261
+ : dir === 'up'
262
+ ? -Math.PI / 2
263
+ : dir === 'down'
264
+ ? Math.PI / 2
265
+ : 0;
266
+ const cos = Math.cos(angle);
267
+ const sin = Math.sin(angle);
268
+ const basePts = [
269
+ [-halfLen, -halfBody],
270
+ [neckX, -halfBody],
271
+ [neckX, -halfHead],
272
+ [halfLen, 0],
273
+ [neckX, halfHead],
274
+ [neckX, halfBody],
275
+ [-halfLen, halfBody],
276
+ ];
277
+ return basePts
278
+ .map(([px, py]) => {
279
+ const rx = px * cos - py * sin;
280
+ const ry = px * sin + py * cos;
281
+ return `${pos.x + rx},${pos.y + ry}`;
282
+ })
283
+ .join(' ');
284
+ }
285
+ const blockArrowBehavior = {
286
+ kind: 'blockArrow',
287
+ tagName: 'polygon',
288
+ applyGeometry(el, shape, pos) {
289
+ el.setAttribute('points', blockArrowPoints(shape, pos));
290
+ },
291
+ svgMarkup(shape, pos, attrs) {
292
+ const pts = blockArrowPoints(shape, pos);
293
+ return `<polygon points="${pts}" class="viz-node-shape" data-viz-role="node-shape"${attrs} />`;
294
+ },
295
+ anchorBoundary(pos, target, shape) {
296
+ const dx = target.x - pos.x;
297
+ const dy = target.y - pos.y;
298
+ if (dx === 0 && dy === 0)
299
+ return { x: pos.x, y: pos.y };
300
+ const dir = shape.direction ?? 'right';
301
+ const hw = dir === 'up' || dir === 'down' ? shape.headWidth / 2 : shape.length / 2;
302
+ const hh = dir === 'up' || dir === 'down' ? shape.length / 2 : shape.headWidth / 2;
303
+ const scale = Math.min(hw / Math.abs(dx || 1e-6), hh / Math.abs(dy || 1e-6));
304
+ return {
305
+ x: pos.x + dx * scale,
306
+ y: pos.y + dy * scale,
307
+ };
308
+ },
309
+ };
310
+ function calloutPathD(shape, pos) {
311
+ const hw = shape.w / 2;
312
+ const hh = shape.h / 2;
313
+ const r = Math.min(shape.rx ?? 0, hw, hh);
314
+ const side = shape.pointerSide ?? 'bottom';
315
+ const pH = shape.pointerHeight ?? Math.round(shape.h * 0.25);
316
+ const pW = shape.pointerWidth ?? Math.round(shape.w * 0.2);
317
+ const pp = shape.pointerPosition ?? 0.3;
318
+ const left = pos.x - hw;
319
+ const right = pos.x + hw;
320
+ const top = pos.y - hh;
321
+ const bottom = pos.y + hh;
322
+ // pointer base coords along the side (0..sideLen)
323
+ const segments = [];
324
+ const arc = (cx, cy, startAngle) => {
325
+ if (r === 0)
326
+ return '';
327
+ const s = startAngle;
328
+ const e = s + Math.PI / 2;
329
+ const ex = cx + r * Math.cos(e);
330
+ const ey = cy + r * Math.sin(e);
331
+ return `A ${r} ${r} 0 0 1 ${ex} ${ey}`;
332
+ };
333
+ // build CW from top-left
334
+ // top-left corner
335
+ segments.push(`M ${left + r} ${top}`);
336
+ // top edge
337
+ if (side === 'top') {
338
+ const sideLen = shape.w - 2 * r;
339
+ const b1 = left + r + sideLen * pp;
340
+ const b2 = b1 + pW;
341
+ segments.push(`L ${b1} ${top}`);
342
+ segments.push(`L ${(b1 + b2) / 2} ${top - pH}`);
343
+ segments.push(`L ${Math.min(b2, right - r)} ${top}`);
344
+ }
345
+ segments.push(`L ${right - r} ${top}`);
346
+ // top-right corner
347
+ segments.push(arc(right - r, top + r, -Math.PI / 2));
348
+ // right edge
349
+ if (side === 'right') {
350
+ const sideLen = shape.h - 2 * r;
351
+ const b1 = top + r + sideLen * pp;
352
+ const b2 = b1 + pW;
353
+ segments.push(`L ${right} ${b1}`);
354
+ segments.push(`L ${right + pH} ${(b1 + b2) / 2}`);
355
+ segments.push(`L ${right} ${Math.min(b2, bottom - r)}`);
356
+ }
357
+ segments.push(`L ${right} ${bottom - r}`);
358
+ // bottom-right corner
359
+ segments.push(arc(right - r, bottom - r, 0));
360
+ // bottom edge
361
+ if (side === 'bottom') {
362
+ const sideLen = shape.w - 2 * r;
363
+ const b2 = right - r - sideLen * pp;
364
+ const b1 = b2 - pW;
365
+ segments.push(`L ${b2} ${bottom}`);
366
+ segments.push(`L ${(b1 + b2) / 2} ${bottom + pH}`);
367
+ segments.push(`L ${Math.max(b1, left + r)} ${bottom}`);
368
+ }
369
+ segments.push(`L ${left + r} ${bottom}`);
370
+ // bottom-left corner
371
+ segments.push(arc(left + r, bottom - r, Math.PI / 2));
372
+ // left edge
373
+ if (side === 'left') {
374
+ const sideLen = shape.h - 2 * r;
375
+ const b2 = bottom - r - sideLen * pp;
376
+ const b1 = b2 - pW;
377
+ segments.push(`L ${left} ${b2}`);
378
+ segments.push(`L ${left - pH} ${(b1 + b2) / 2}`);
379
+ segments.push(`L ${left} ${Math.max(b1, top + r)}`);
380
+ }
381
+ segments.push(`L ${left} ${top + r}`);
382
+ // close back to top-left (arc for last corner)
383
+ segments.push(arc(left + r, top + r, Math.PI));
384
+ segments.push('Z');
385
+ return segments.filter(Boolean).join(' ');
386
+ }
387
+ const calloutBehavior = {
388
+ kind: 'callout',
389
+ tagName: 'path',
390
+ applyGeometry(el, shape, pos) {
391
+ el.setAttribute('d', calloutPathD(shape, pos));
392
+ },
393
+ svgMarkup(shape, pos, attrs) {
394
+ const d = calloutPathD(shape, pos);
395
+ return `<path d="${d}" class="viz-node-shape" data-viz-role="node-shape"${attrs} />`;
396
+ },
397
+ anchorBoundary(pos, target, shape) {
398
+ const dx = target.x - pos.x;
399
+ const dy = target.y - pos.y;
400
+ if (dx === 0 && dy === 0)
401
+ return { x: pos.x, y: pos.y };
402
+ const hw = shape.w / 2;
403
+ const hh = shape.h / 2;
404
+ const scale = Math.min(hw / Math.abs(dx || 1e-6), hh / Math.abs(dy || 1e-6));
405
+ return {
406
+ x: pos.x + dx * scale,
407
+ y: pos.y + dy * scale,
408
+ };
409
+ },
410
+ };
411
+ /**
412
+ * Compute the SVG path for a cloud shape that fits within a w×h bounding box
413
+ * centered at `pos`. The outline is built from 6 cubic Bézier bumps.
414
+ */
415
+ function cloudPathD(shape, pos) {
416
+ const hw = shape.w / 2;
417
+ const hh = shape.h / 2;
418
+ const cx = pos.x;
419
+ const cy = pos.y;
420
+ // 6 control-point "bumps" expressed as fractions of half-width / half-height.
421
+ // Each bump: [startX, startY, cp1x, cp1y, cp2x, cp2y, endX, endY]
422
+ const bumps = [
423
+ [-0.35, -0.85, -0.75, -1.2, -1.1, -0.5, -0.95, -0.2],
424
+ [-0.95, -0.2, -1.2, 0.3, -0.9, 0.9, -0.45, 0.85],
425
+ [-0.45, 0.85, -0.15, 1.15, 0.35, 1.15, 0.55, 0.8],
426
+ [0.55, 0.8, 0.85, 0.95, 1.15, 0.45, 1.0, 0.05],
427
+ [1.0, 0.05, 1.2, -0.45, 0.85, -0.95, 0.4, -0.85],
428
+ [0.4, -0.85, 0.05, -1.2, -0.45, -1.1, -0.35, -0.85],
429
+ ];
430
+ const parts = [
431
+ `M ${cx + bumps[0][0] * hw} ${cy + bumps[0][1] * hh}`,
432
+ ];
433
+ for (const [, , c1x, c1y, c2x, c2y, ex, ey] of bumps) {
434
+ parts.push(`C ${cx + c1x * hw} ${cy + c1y * hh} ${cx + c2x * hw} ${cy + c2y * hh} ${cx + ex * hw} ${cy + ey * hh}`);
435
+ }
436
+ parts.push('Z');
437
+ return parts.join(' ');
438
+ }
439
+ const cloudBehavior = {
440
+ kind: 'cloud',
441
+ tagName: 'path',
442
+ applyGeometry(el, shape, pos) {
443
+ el.setAttribute('d', cloudPathD(shape, pos));
444
+ },
445
+ svgMarkup(shape, pos, attrs) {
446
+ const d = cloudPathD(shape, pos);
447
+ return `<path d="${d}" class="viz-node-shape" data-viz-role="node-shape"${attrs} />`;
448
+ },
449
+ anchorBoundary(pos, target, shape) {
450
+ // Bounding-ellipse approximation
451
+ const dx = target.x - pos.x;
452
+ const dy = target.y - pos.y;
453
+ if (dx === 0 && dy === 0)
454
+ return { x: pos.x, y: pos.y };
455
+ const a = shape.w / 2;
456
+ const b = shape.h / 2;
457
+ const denom = Math.sqrt(a * a * dy * dy + b * b * dx * dx) || 1;
458
+ return {
459
+ x: pos.x + (a * b * dx) / denom,
460
+ y: pos.y + (a * b * dy) / denom,
461
+ };
462
+ },
463
+ };
464
+ function crossPoints(shape, pos) {
465
+ const hs = shape.size / 2;
466
+ const bw = (shape.barWidth ?? Math.round(shape.size / 3)) / 2;
467
+ // 12-vertex plus sign, CW from top-left of vertical bar
468
+ return [
469
+ `${pos.x - bw},${pos.y - hs}`,
470
+ `${pos.x + bw},${pos.y - hs}`,
471
+ `${pos.x + bw},${pos.y - bw}`,
472
+ `${pos.x + hs},${pos.y - bw}`,
473
+ `${pos.x + hs},${pos.y + bw}`,
474
+ `${pos.x + bw},${pos.y + bw}`,
475
+ `${pos.x + bw},${pos.y + hs}`,
476
+ `${pos.x - bw},${pos.y + hs}`,
477
+ `${pos.x - bw},${pos.y + bw}`,
478
+ `${pos.x - hs},${pos.y + bw}`,
479
+ `${pos.x - hs},${pos.y - bw}`,
480
+ `${pos.x - bw},${pos.y - bw}`,
481
+ ].join(' ');
482
+ }
483
+ const crossBehavior = {
484
+ kind: 'cross',
485
+ tagName: 'polygon',
486
+ applyGeometry(el, shape, pos) {
487
+ el.setAttribute('points', crossPoints(shape, pos));
488
+ },
489
+ svgMarkup(shape, pos, attrs) {
490
+ const pts = crossPoints(shape, pos);
491
+ return `<polygon points="${pts}" class="viz-node-shape" data-viz-role="node-shape"${attrs} />`;
492
+ },
493
+ anchorBoundary(pos, target, shape) {
494
+ const dx = target.x - pos.x;
495
+ const dy = target.y - pos.y;
496
+ if (dx === 0 && dy === 0)
497
+ return { x: pos.x, y: pos.y };
498
+ const hs = shape.size / 2;
499
+ const scale = Math.min(hs / Math.abs(dx || 1e-6), hs / Math.abs(dy || 1e-6));
500
+ return {
501
+ x: pos.x + dx * scale,
502
+ y: pos.y + dy * scale,
503
+ };
504
+ },
505
+ };
506
+ function cubeVertices(shape, pos) {
507
+ const hw = shape.w / 2;
508
+ const hh = shape.h / 2;
509
+ const d = shape.depth ?? Math.round(shape.w * 0.2);
510
+ // Front face centered at pos so labels align naturally
511
+ const ftl = { x: pos.x - hw, y: pos.y - hh };
512
+ const ftr = { x: pos.x + hw, y: pos.y - hh };
513
+ const fbr = { x: pos.x + hw, y: pos.y + hh };
514
+ const fbl = { x: pos.x - hw, y: pos.y + hh };
515
+ // Back top corners (shifted by +d in x, -d in y)
516
+ const btl = { x: ftl.x + d, y: ftl.y - d };
517
+ const btr = { x: ftr.x + d, y: ftr.y - d };
518
+ // Back bottom-right (for right face)
519
+ const bbr = { x: fbr.x + d, y: fbr.y - d };
520
+ return { ftl, ftr, fbr, fbl, btl, btr, bbr, d };
521
+ }
522
+ function polyStr(...pts) {
523
+ return pts.map((p) => `${p.x},${p.y}`).join(' ');
524
+ }
525
+ const cubeBehavior = {
526
+ kind: 'cube',
527
+ tagName: 'g',
528
+ applyGeometry(el, shape, pos) {
529
+ const { ftl, ftr, fbr, fbl, btl, btr, bbr } = cubeVertices(shape, pos);
530
+ let front = el.querySelector('[data-viz-cube="front"]');
531
+ if (!front) {
532
+ front = document.createElementNS(SVG_NS, 'polygon');
533
+ front.setAttribute('data-viz-cube', 'front');
534
+ el.appendChild(front);
535
+ }
536
+ front.setAttribute('points', polyStr(ftl, ftr, fbr, fbl));
537
+ let top = el.querySelector('[data-viz-cube="top"]');
538
+ if (!top) {
539
+ top = document.createElementNS(SVG_NS, 'polygon');
540
+ top.setAttribute('data-viz-cube', 'top');
541
+ el.appendChild(top);
542
+ }
543
+ top.setAttribute('points', polyStr(ftl, ftr, btr, btl));
544
+ top.style.filter = 'brightness(0.85)';
545
+ let right = el.querySelector('[data-viz-cube="right"]');
546
+ if (!right) {
547
+ right = document.createElementNS(SVG_NS, 'polygon');
548
+ right.setAttribute('data-viz-cube', 'right');
549
+ el.appendChild(right);
550
+ }
551
+ right.setAttribute('points', polyStr(ftr, btr, bbr, fbr));
552
+ right.style.filter = 'brightness(0.7)';
553
+ },
554
+ svgMarkup(shape, pos, attrs) {
555
+ const { ftl, ftr, fbr, fbl, btl, btr, bbr } = cubeVertices(shape, pos);
556
+ const end = '</g>';
557
+ return (`<g class="viz-node-shape" data-viz-role="node-shape"${attrs}>` +
558
+ `<polygon points="${polyStr(ftl, ftr, fbr, fbl)}" data-viz-cube="front"/>` +
559
+ `<polygon points="${polyStr(ftl, ftr, btr, btl)}" data-viz-cube="top" style="filter:brightness(0.85)"/>` +
560
+ `<polygon points="${polyStr(ftr, btr, bbr, fbr)}" data-viz-cube="right" style="filter:brightness(0.7)"/>` +
561
+ end);
562
+ },
563
+ anchorBoundary(pos, target, shape) {
564
+ const dx = target.x - pos.x;
565
+ const dy = target.y - pos.y;
566
+ if (dx === 0 && dy === 0)
567
+ return { x: pos.x, y: pos.y };
568
+ const d = shape.depth ?? Math.round(shape.w * 0.2);
569
+ const hw = shape.w / 2 + d / 2;
570
+ const hh = shape.h / 2 + d / 2;
571
+ const scale = Math.min(hw / Math.abs(dx || 1e-6), hh / Math.abs(dy || 1e-6));
572
+ return {
573
+ x: pos.x + dx * scale,
574
+ y: pos.y + dy * scale,
575
+ };
576
+ },
577
+ };
578
+ const pathBehavior = {
579
+ kind: 'path',
580
+ tagName: 'path',
581
+ applyGeometry(el, shape, pos) {
582
+ el.setAttribute('d', shape.d);
583
+ el.setAttribute('transform', `translate(${pos.x - shape.w / 2},${pos.y - shape.h / 2})`);
584
+ },
585
+ svgMarkup(shape, pos, attrs) {
586
+ const tx = pos.x - shape.w / 2;
587
+ const ty = pos.y - shape.h / 2;
588
+ return `<path d="${shape.d}" transform="translate(${tx},${ty})" class="viz-node-shape" data-viz-role="node-shape"${attrs} />`;
589
+ },
590
+ anchorBoundary(pos, target, shape) {
591
+ const dx = target.x - pos.x;
592
+ const dy = target.y - pos.y;
593
+ if (dx === 0 && dy === 0)
594
+ return { x: pos.x, y: pos.y };
595
+ const hw = shape.w / 2;
596
+ const hh = shape.h / 2;
597
+ const scale = Math.min(hw / Math.abs(dx || 1e-6), hh / Math.abs(dy || 1e-6));
598
+ return {
599
+ x: pos.x + dx * scale,
600
+ y: pos.y + dy * scale,
601
+ };
602
+ },
603
+ };
604
+ function documentPathD(shape, pos) {
605
+ const hw = shape.w / 2;
606
+ const hh = shape.h / 2;
607
+ const wh = shape.waveHeight ?? Math.round(shape.h * 0.1);
608
+ const x0 = pos.x - hw;
609
+ const x1 = pos.x + hw;
610
+ const y0 = pos.y - hh;
611
+ const y1 = pos.y + hh - wh;
612
+ // Top-left → top-right → bottom-right → wavy bottom → close
613
+ return (`M ${x0} ${y0}` +
614
+ ` H ${x1}` +
615
+ ` V ${y1}` +
616
+ ` C ${x1 - hw * 0.5} ${y1 + wh * 2}, ${x0 + hw * 0.5} ${y1 - wh}, ${x0} ${y1}` +
617
+ ' Z');
618
+ }
619
+ const documentBehavior = {
620
+ kind: 'document',
621
+ tagName: 'path',
622
+ applyGeometry(el, shape, pos) {
623
+ el.setAttribute('d', documentPathD(shape, pos));
624
+ },
625
+ svgMarkup(shape, pos, attrs) {
626
+ const d = documentPathD(shape, pos);
627
+ return `<path d="${d}" class="viz-node-shape" data-viz-role="node-shape"${attrs} />`;
628
+ },
629
+ anchorBoundary(pos, target, shape) {
630
+ const dx = target.x - pos.x;
631
+ const dy = target.y - pos.y;
632
+ if (dx === 0 && dy === 0)
633
+ return { x: pos.x, y: pos.y };
634
+ const hw = shape.w / 2;
635
+ const hh = shape.h / 2;
636
+ const scale = Math.min(hw / Math.abs(dx || 1e-6), hh / Math.abs(dy || 1e-6));
637
+ return {
638
+ x: pos.x + dx * scale,
639
+ y: pos.y + dy * scale,
640
+ };
641
+ },
642
+ };
643
+ function noteVertices(shape, pos) {
644
+ const hw = shape.w / 2;
645
+ const hh = shape.h / 2;
646
+ const f = shape.foldSize ?? 15;
647
+ const x0 = pos.x - hw;
648
+ const x1 = pos.x + hw;
649
+ const y0 = pos.y - hh;
650
+ const y1 = pos.y + hh;
651
+ return { x0, x1, y0, y1, f };
652
+ }
653
+ const noteBehavior = {
654
+ kind: 'note',
655
+ tagName: 'g',
656
+ applyGeometry(el, shape, pos) {
657
+ const { x0, x1, y0, y1, f } = noteVertices(shape, pos);
658
+ let body = el.querySelector('[data-viz-note="body"]');
659
+ if (!body) {
660
+ body = document.createElementNS(SVG_NS, 'polygon');
661
+ body.setAttribute('data-viz-note', 'body');
662
+ el.appendChild(body);
663
+ }
664
+ body.setAttribute('points', `${x0},${y0} ${x1 - f},${y0} ${x1},${y0 + f} ${x1},${y1} ${x0},${y1}`);
665
+ let fold = el.querySelector('[data-viz-note="fold"]');
666
+ if (!fold) {
667
+ fold = document.createElementNS(SVG_NS, 'polygon');
668
+ fold.setAttribute('data-viz-note', 'fold');
669
+ el.appendChild(fold);
670
+ }
671
+ fold.setAttribute('points', `${x1 - f},${y0} ${x1 - f},${y0 + f} ${x1},${y0 + f}`);
672
+ fold.style.filter = 'brightness(0.8)';
673
+ },
674
+ svgMarkup(shape, pos, attrs) {
675
+ const { x0, x1, y0, y1, f } = noteVertices(shape, pos);
676
+ const bodyPts = `${x0},${y0} ${x1 - f},${y0} ${x1},${y0 + f} ${x1},${y1} ${x0},${y1}`;
677
+ const foldPts = `${x1 - f},${y0} ${x1 - f},${y0 + f} ${x1},${y0 + f}`;
678
+ const end = '</g>';
679
+ return (`<g class="viz-node-shape" data-viz-role="node-shape"${attrs}>` +
680
+ `<polygon points="${bodyPts}" data-viz-note="body"/>` +
681
+ `<polygon points="${foldPts}" data-viz-note="fold" style="filter:brightness(0.8)"/>` +
682
+ end);
683
+ },
684
+ anchorBoundary(pos, target, shape) {
685
+ const dx = target.x - pos.x;
686
+ const dy = target.y - pos.y;
687
+ if (dx === 0 && dy === 0)
688
+ return { x: pos.x, y: pos.y };
689
+ const hw = shape.w / 2;
690
+ const hh = shape.h / 2;
691
+ const scale = Math.min(hw / Math.abs(dx || 1e-6), hh / Math.abs(dy || 1e-6));
692
+ return {
693
+ x: pos.x + dx * scale,
694
+ y: pos.y + dy * scale,
695
+ };
696
+ },
697
+ };
698
+ function parallelogramPoints(shape, pos) {
699
+ const hw = shape.w / 2;
700
+ const hh = shape.h / 2;
701
+ const sk = shape.skew ?? Math.round(shape.w * 0.2);
702
+ const half = sk / 2;
703
+ return [
704
+ `${pos.x - hw - half},${pos.y + hh}`,
705
+ `${pos.x + hw - half},${pos.y + hh}`,
706
+ `${pos.x + hw + half},${pos.y - hh}`,
707
+ `${pos.x - hw + half},${pos.y - hh}`,
708
+ ].join(' ');
709
+ }
710
+ const parallelogramBehavior = {
711
+ kind: 'parallelogram',
712
+ tagName: 'polygon',
713
+ applyGeometry(el, shape, pos) {
714
+ el.setAttribute('points', parallelogramPoints(shape, pos));
715
+ },
716
+ svgMarkup(shape, pos, attrs) {
717
+ const pts = parallelogramPoints(shape, pos);
718
+ return `<polygon points="${pts}" class="viz-node-shape" data-viz-role="node-shape"${attrs} />`;
719
+ },
720
+ anchorBoundary(pos, target, shape) {
721
+ const dx = target.x - pos.x;
722
+ const dy = target.y - pos.y;
723
+ if (dx === 0 && dy === 0)
724
+ return { x: pos.x, y: pos.y };
725
+ const sk = shape.skew ?? Math.round(shape.w * 0.2);
726
+ const hw = shape.w / 2 + sk / 2;
727
+ const hh = shape.h / 2;
728
+ const scale = Math.min(hw / Math.abs(dx || 1e-6), hh / Math.abs(dy || 1e-6));
729
+ return {
730
+ x: pos.x + dx * scale,
731
+ y: pos.y + dy * scale,
732
+ };
733
+ },
734
+ };
735
+ function starPoints(shape, pos) {
736
+ const n = shape.points;
737
+ const outer = shape.outerR;
738
+ const inner = shape.innerR ?? Math.round(outer * 0.4);
739
+ const verts = [];
740
+ for (let i = 0; i < n * 2; i++) {
741
+ const r = i % 2 === 0 ? outer : inner;
742
+ const angle = (Math.PI * i) / n - Math.PI / 2;
743
+ verts.push(`${pos.x + r * Math.cos(angle)},${pos.y + r * Math.sin(angle)}`);
744
+ }
745
+ return verts.join(' ');
746
+ }
747
+ const starBehavior = {
748
+ kind: 'star',
749
+ tagName: 'polygon',
750
+ applyGeometry(el, shape, pos) {
751
+ el.setAttribute('points', starPoints(shape, pos));
752
+ },
753
+ svgMarkup(shape, pos, attrs) {
754
+ const pts = starPoints(shape, pos);
755
+ return `<polygon points="${pts}" class="viz-node-shape" data-viz-role="node-shape"${attrs} />`;
756
+ },
757
+ anchorBoundary(pos, target, shape) {
758
+ const dx = target.x - pos.x;
759
+ const dy = target.y - pos.y;
760
+ if (dx === 0 && dy === 0)
761
+ return { x: pos.x, y: pos.y };
762
+ const r = shape.outerR;
763
+ const scale = r / Math.sqrt(dx * dx + dy * dy);
764
+ return {
765
+ x: pos.x + dx * scale,
766
+ y: pos.y + dy * scale,
767
+ };
768
+ },
769
+ };
770
+ function trapezoidPoints(shape, pos) {
771
+ const htw = shape.topW / 2;
772
+ const hbw = shape.bottomW / 2;
773
+ const hh = shape.h / 2;
774
+ return [
775
+ `${pos.x - htw},${pos.y - hh}`,
776
+ `${pos.x + htw},${pos.y - hh}`,
777
+ `${pos.x + hbw},${pos.y + hh}`,
778
+ `${pos.x - hbw},${pos.y + hh}`,
779
+ ].join(' ');
780
+ }
781
+ const trapezoidBehavior = {
782
+ kind: 'trapezoid',
783
+ tagName: 'polygon',
784
+ applyGeometry(el, shape, pos) {
785
+ el.setAttribute('points', trapezoidPoints(shape, pos));
786
+ },
787
+ svgMarkup(shape, pos, attrs) {
788
+ const pts = trapezoidPoints(shape, pos);
789
+ return `<polygon points="${pts}" class="viz-node-shape" data-viz-role="node-shape"${attrs} />`;
790
+ },
791
+ anchorBoundary(pos, target, shape) {
792
+ const dx = target.x - pos.x;
793
+ const dy = target.y - pos.y;
794
+ if (dx === 0 && dy === 0)
795
+ return { x: pos.x, y: pos.y };
796
+ const hw = Math.max(shape.topW, shape.bottomW) / 2;
797
+ const hh = shape.h / 2;
798
+ const scale = Math.min(hw / Math.abs(dx || 1e-6), hh / Math.abs(dy || 1e-6));
799
+ return {
800
+ x: pos.x + dx * scale,
801
+ y: pos.y + dy * scale,
802
+ };
803
+ },
804
+ };
805
+ function trianglePoints(shape, pos) {
806
+ const hw = shape.w / 2;
807
+ const hh = shape.h / 2;
808
+ const dir = shape.direction ?? 'up';
809
+ switch (dir) {
810
+ case 'up':
811
+ return [
812
+ `${pos.x},${pos.y - hh}`,
813
+ `${pos.x + hw},${pos.y + hh}`,
814
+ `${pos.x - hw},${pos.y + hh}`,
815
+ ].join(' ');
816
+ case 'down':
817
+ return [
818
+ `${pos.x},${pos.y + hh}`,
819
+ `${pos.x - hw},${pos.y - hh}`,
820
+ `${pos.x + hw},${pos.y - hh}`,
821
+ ].join(' ');
822
+ case 'left':
823
+ return [
824
+ `${pos.x - hw},${pos.y}`,
825
+ `${pos.x + hw},${pos.y - hh}`,
826
+ `${pos.x + hw},${pos.y + hh}`,
827
+ ].join(' ');
828
+ case 'right':
829
+ return [
830
+ `${pos.x + hw},${pos.y}`,
831
+ `${pos.x - hw},${pos.y + hh}`,
832
+ `${pos.x - hw},${pos.y - hh}`,
833
+ ].join(' ');
834
+ }
835
+ }
836
+ const triangleBehavior = {
837
+ kind: 'triangle',
838
+ tagName: 'polygon',
839
+ applyGeometry(el, shape, pos) {
840
+ el.setAttribute('points', trianglePoints(shape, pos));
841
+ },
842
+ svgMarkup(shape, pos, attrs) {
843
+ const pts = trianglePoints(shape, pos);
844
+ return `<polygon points="${pts}" class="viz-node-shape" data-viz-role="node-shape"${attrs} />`;
845
+ },
846
+ anchorBoundary(pos, target, shape) {
847
+ const dx = target.x - pos.x;
848
+ const dy = target.y - pos.y;
849
+ if (dx === 0 && dy === 0)
850
+ return { x: pos.x, y: pos.y };
851
+ const hw = shape.w / 2;
852
+ const hh = shape.h / 2;
853
+ const scale = Math.min(hw / Math.abs(dx || 1e-6), hh / Math.abs(dy || 1e-6));
854
+ return {
855
+ x: pos.x + dx * scale,
856
+ y: pos.y + dy * scale,
857
+ };
858
+ },
859
+ };
90
860
  const shapeBehaviorRegistry = {
91
861
  circle: circleBehavior,
92
862
  rect: rectBehavior,
93
863
  diamond: diamondBehavior,
864
+ cylinder: cylinderBehavior,
865
+ hexagon: hexagonBehavior,
866
+ ellipse: ellipseBehavior,
867
+ arc: arcBehavior,
868
+ blockArrow: blockArrowBehavior,
869
+ callout: calloutBehavior,
870
+ cloud: cloudBehavior,
871
+ cross: crossBehavior,
872
+ cube: cubeBehavior,
873
+ path: pathBehavior,
874
+ document: documentBehavior,
875
+ note: noteBehavior,
876
+ parallelogram: parallelogramBehavior,
877
+ star: starBehavior,
878
+ trapezoid: trapezoidBehavior,
879
+ triangle: triangleBehavior,
94
880
  };
95
881
  export function getShapeBehavior(shape) {
96
882
  return shapeBehaviorRegistry[shape.kind];
@@ -111,3 +897,226 @@ export function computeNodeAnchor(node, target, anchor) {
111
897
  const behavior = getShapeBehavior(node.shape);
112
898
  return behavior.anchorBoundary(pos, target, node.shape);
113
899
  }
900
+ // ── Connection Ports ────────────────────────────────────────────────────────
901
+ /**
902
+ * Return the default (implicit) ports for a node shape.
903
+ *
904
+ * Every shape provides at least `top`, `right`, `bottom`, `left`.
905
+ * Hexagons also include diagonal vertices.
906
+ * Default ports are **offsets relative to the node center**.
907
+ */
908
+ export function getDefaultPorts(shape) {
909
+ switch (shape.kind) {
910
+ case 'circle':
911
+ return [
912
+ { id: 'top', offset: { x: 0, y: -shape.r }, direction: 270 },
913
+ { id: 'right', offset: { x: shape.r, y: 0 }, direction: 0 },
914
+ { id: 'bottom', offset: { x: 0, y: shape.r }, direction: 90 },
915
+ { id: 'left', offset: { x: -shape.r, y: 0 }, direction: 180 },
916
+ ];
917
+ case 'rect':
918
+ return [
919
+ { id: 'top', offset: { x: 0, y: -shape.h / 2 }, direction: 270 },
920
+ { id: 'right', offset: { x: shape.w / 2, y: 0 }, direction: 0 },
921
+ { id: 'bottom', offset: { x: 0, y: shape.h / 2 }, direction: 90 },
922
+ { id: 'left', offset: { x: -shape.w / 2, y: 0 }, direction: 180 },
923
+ ];
924
+ case 'diamond':
925
+ return [
926
+ { id: 'top', offset: { x: 0, y: -shape.h / 2 }, direction: 270 },
927
+ { id: 'right', offset: { x: shape.w / 2, y: 0 }, direction: 0 },
928
+ { id: 'bottom', offset: { x: 0, y: shape.h / 2 }, direction: 90 },
929
+ { id: 'left', offset: { x: -shape.w / 2, y: 0 }, direction: 180 },
930
+ ];
931
+ case 'ellipse':
932
+ return [
933
+ { id: 'top', offset: { x: 0, y: -shape.ry }, direction: 270 },
934
+ { id: 'right', offset: { x: shape.rx, y: 0 }, direction: 0 },
935
+ { id: 'bottom', offset: { x: 0, y: shape.ry }, direction: 90 },
936
+ { id: 'left', offset: { x: -shape.rx, y: 0 }, direction: 180 },
937
+ ];
938
+ case 'hexagon': {
939
+ const r = shape.r;
940
+ const orientation = shape.orientation ?? 'pointy';
941
+ if (orientation === 'pointy') {
942
+ // Pointy-top: vertices at top, top-right, bottom-right, bottom, bottom-left, top-left
943
+ const sin60 = r * Math.sin(Math.PI / 3); // ≈ 0.866r
944
+ const cos60 = r * Math.cos(Math.PI / 3); // = 0.5r
945
+ return [
946
+ { id: 'top', offset: { x: 0, y: -r }, direction: 270 },
947
+ { id: 'top-right', offset: { x: sin60, y: -cos60 }, direction: 330 },
948
+ {
949
+ id: 'bottom-right',
950
+ offset: { x: sin60, y: cos60 },
951
+ direction: 30,
952
+ },
953
+ { id: 'bottom', offset: { x: 0, y: r }, direction: 90 },
954
+ {
955
+ id: 'bottom-left',
956
+ offset: { x: -sin60, y: cos60 },
957
+ direction: 150,
958
+ },
959
+ {
960
+ id: 'top-left',
961
+ offset: { x: -sin60, y: -cos60 },
962
+ direction: 210,
963
+ },
964
+ ];
965
+ }
966
+ else {
967
+ // Flat-top: vertices at right, bottom-right, bottom-left, left, top-left, top-right
968
+ const sin60 = r * Math.sin(Math.PI / 3);
969
+ const cos60 = r * Math.cos(Math.PI / 3);
970
+ return [
971
+ { id: 'right', offset: { x: r, y: 0 }, direction: 0 },
972
+ {
973
+ id: 'bottom-right',
974
+ offset: { x: cos60, y: sin60 },
975
+ direction: 60,
976
+ },
977
+ {
978
+ id: 'bottom-left',
979
+ offset: { x: -cos60, y: sin60 },
980
+ direction: 120,
981
+ },
982
+ { id: 'left', offset: { x: -r, y: 0 }, direction: 180 },
983
+ {
984
+ id: 'top-left',
985
+ offset: { x: -cos60, y: -sin60 },
986
+ direction: 240,
987
+ },
988
+ {
989
+ id: 'top-right',
990
+ offset: { x: cos60, y: -sin60 },
991
+ direction: 300,
992
+ },
993
+ ];
994
+ }
995
+ }
996
+ case 'cylinder':
997
+ return [
998
+ { id: 'top', offset: { x: 0, y: -shape.h / 2 }, direction: 270 },
999
+ { id: 'right', offset: { x: shape.w / 2, y: 0 }, direction: 0 },
1000
+ { id: 'bottom', offset: { x: 0, y: shape.h / 2 }, direction: 90 },
1001
+ { id: 'left', offset: { x: -shape.w / 2, y: 0 }, direction: 180 },
1002
+ ];
1003
+ case 'triangle': {
1004
+ const hw = shape.w / 2;
1005
+ const hh = shape.h / 2;
1006
+ const dir = shape.direction ?? 'up';
1007
+ // Three vertices based on direction
1008
+ switch (dir) {
1009
+ case 'up':
1010
+ return [
1011
+ { id: 'top', offset: { x: 0, y: -hh }, direction: 270 },
1012
+ { id: 'bottom-right', offset: { x: hw, y: hh }, direction: 30 },
1013
+ { id: 'bottom-left', offset: { x: -hw, y: hh }, direction: 150 },
1014
+ { id: 'bottom', offset: { x: 0, y: hh }, direction: 90 },
1015
+ ];
1016
+ case 'down':
1017
+ return [
1018
+ { id: 'top-left', offset: { x: -hw, y: -hh }, direction: 210 },
1019
+ { id: 'top-right', offset: { x: hw, y: -hh }, direction: 330 },
1020
+ { id: 'bottom', offset: { x: 0, y: hh }, direction: 90 },
1021
+ { id: 'top', offset: { x: 0, y: -hh }, direction: 270 },
1022
+ ];
1023
+ case 'left':
1024
+ return [
1025
+ { id: 'left', offset: { x: -hw, y: 0 }, direction: 180 },
1026
+ { id: 'top-right', offset: { x: hw, y: -hh }, direction: 330 },
1027
+ { id: 'bottom-right', offset: { x: hw, y: hh }, direction: 30 },
1028
+ { id: 'right', offset: { x: hw, y: 0 }, direction: 0 },
1029
+ ];
1030
+ case 'right':
1031
+ return [
1032
+ { id: 'top-left', offset: { x: -hw, y: -hh }, direction: 210 },
1033
+ { id: 'right', offset: { x: hw, y: 0 }, direction: 0 },
1034
+ { id: 'bottom-left', offset: { x: -hw, y: hh }, direction: 150 },
1035
+ { id: 'left', offset: { x: -hw, y: 0 }, direction: 180 },
1036
+ ];
1037
+ }
1038
+ break;
1039
+ }
1040
+ // Fallback for all remaining shapes: approximate as bounding-box midpoints
1041
+ default: {
1042
+ const bb = shapeBoundingBox(shape);
1043
+ return [
1044
+ { id: 'top', offset: { x: 0, y: -bb.hh }, direction: 270 },
1045
+ { id: 'right', offset: { x: bb.hw, y: 0 }, direction: 0 },
1046
+ { id: 'bottom', offset: { x: 0, y: bb.hh }, direction: 90 },
1047
+ { id: 'left', offset: { x: -bb.hw, y: 0 }, direction: 180 },
1048
+ ];
1049
+ }
1050
+ }
1051
+ }
1052
+ /**
1053
+ * Get the effective ports for a node: explicit `node.ports` if set,
1054
+ * otherwise the shape's default ports.
1055
+ */
1056
+ export function getNodePorts(node) {
1057
+ return node.ports ?? getDefaultPorts(node.shape);
1058
+ }
1059
+ /**
1060
+ * Find a port on a node by its id. Returns `undefined` if not found.
1061
+ */
1062
+ export function findPort(node, portId) {
1063
+ return getNodePorts(node).find((p) => p.id === portId);
1064
+ }
1065
+ /**
1066
+ * Resolve a port to an absolute position (node center + port offset).
1067
+ * Returns `undefined` if the port id is not found.
1068
+ */
1069
+ export function resolvePortPosition(node, portId) {
1070
+ const port = findPort(node, portId);
1071
+ if (!port)
1072
+ return undefined;
1073
+ const pos = effectivePos(node);
1074
+ return { x: pos.x + port.offset.x, y: pos.y + port.offset.y };
1075
+ }
1076
+ // ── Helpers ─────────────────────────────────────────────────────────────────
1077
+ /** Approximate half-width / half-height for shapes that have a bounding box. */
1078
+ function shapeBoundingBox(shape) {
1079
+ switch (shape.kind) {
1080
+ case 'circle':
1081
+ return { hw: shape.r, hh: shape.r };
1082
+ case 'rect':
1083
+ return { hw: shape.w / 2, hh: shape.h / 2 };
1084
+ case 'diamond':
1085
+ return { hw: shape.w / 2, hh: shape.h / 2 };
1086
+ case 'ellipse':
1087
+ return { hw: shape.rx, hh: shape.ry };
1088
+ case 'hexagon':
1089
+ return { hw: shape.r, hh: shape.r };
1090
+ case 'cylinder':
1091
+ return { hw: shape.w / 2, hh: shape.h / 2 };
1092
+ case 'arc':
1093
+ return { hw: shape.r, hh: shape.r };
1094
+ case 'blockArrow':
1095
+ return { hw: shape.length / 2, hh: shape.headWidth / 2 };
1096
+ case 'callout':
1097
+ return { hw: shape.w / 2, hh: shape.h / 2 };
1098
+ case 'cloud':
1099
+ return { hw: shape.w / 2, hh: shape.h / 2 };
1100
+ case 'cross':
1101
+ return { hw: shape.size / 2, hh: shape.size / 2 };
1102
+ case 'cube':
1103
+ return { hw: shape.w / 2, hh: shape.h / 2 };
1104
+ case 'path':
1105
+ return { hw: shape.w / 2, hh: shape.h / 2 };
1106
+ case 'document':
1107
+ return { hw: shape.w / 2, hh: shape.h / 2 };
1108
+ case 'note':
1109
+ return { hw: shape.w / 2, hh: shape.h / 2 };
1110
+ case 'parallelogram':
1111
+ return { hw: shape.w / 2, hh: shape.h / 2 };
1112
+ case 'star':
1113
+ return { hw: shape.outerR, hh: shape.outerR };
1114
+ case 'trapezoid':
1115
+ return {
1116
+ hw: Math.max(shape.topW, shape.bottomW) / 2,
1117
+ hh: shape.h / 2,
1118
+ };
1119
+ case 'triangle':
1120
+ return { hw: shape.w / 2, hh: shape.h / 2 };
1121
+ }
1122
+ }