@spectratools/graphic-designer-cli 0.3.2 → 0.6.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/renderer.js CHANGED
@@ -86,6 +86,485 @@ function loadFonts() {
86
86
  // src/layout/elk.ts
87
87
  import ELK from "elkjs";
88
88
 
89
+ // src/primitives/shapes.ts
90
+ function roundRectPath(ctx, rect, radius) {
91
+ const r = Math.max(0, Math.min(radius, rect.width / 2, rect.height / 2));
92
+ const right = rect.x + rect.width;
93
+ const bottom = rect.y + rect.height;
94
+ ctx.beginPath();
95
+ ctx.moveTo(rect.x + r, rect.y);
96
+ ctx.lineTo(right - r, rect.y);
97
+ ctx.quadraticCurveTo(right, rect.y, right, rect.y + r);
98
+ ctx.lineTo(right, bottom - r);
99
+ ctx.quadraticCurveTo(right, bottom, right - r, bottom);
100
+ ctx.lineTo(rect.x + r, bottom);
101
+ ctx.quadraticCurveTo(rect.x, bottom, rect.x, bottom - r);
102
+ ctx.lineTo(rect.x, rect.y + r);
103
+ ctx.quadraticCurveTo(rect.x, rect.y, rect.x + r, rect.y);
104
+ ctx.closePath();
105
+ }
106
+ function fillAndStroke(ctx, fill, stroke) {
107
+ ctx.fillStyle = fill;
108
+ ctx.fill();
109
+ if (stroke) {
110
+ ctx.strokeStyle = stroke;
111
+ ctx.stroke();
112
+ }
113
+ }
114
+ function drawRoundedRect(ctx, rect, radius, fill, stroke) {
115
+ roundRectPath(ctx, rect, radius);
116
+ fillAndStroke(ctx, fill, stroke);
117
+ }
118
+ function drawCircle(ctx, center, radius, fill, stroke) {
119
+ ctx.beginPath();
120
+ ctx.arc(center.x, center.y, Math.max(0, radius), 0, Math.PI * 2);
121
+ ctx.closePath();
122
+ fillAndStroke(ctx, fill, stroke);
123
+ }
124
+ function drawDiamond(ctx, bounds, fill, stroke) {
125
+ const cx = bounds.x + bounds.width / 2;
126
+ const cy = bounds.y + bounds.height / 2;
127
+ ctx.beginPath();
128
+ ctx.moveTo(cx, bounds.y);
129
+ ctx.lineTo(bounds.x + bounds.width, cy);
130
+ ctx.lineTo(cx, bounds.y + bounds.height);
131
+ ctx.lineTo(bounds.x, cy);
132
+ ctx.closePath();
133
+ fillAndStroke(ctx, fill, stroke);
134
+ }
135
+ function drawPill(ctx, bounds, fill, stroke) {
136
+ drawRoundedRect(ctx, bounds, Math.min(bounds.width, bounds.height) / 2, fill, stroke);
137
+ }
138
+ function drawEllipse(ctx, bounds, fill, stroke) {
139
+ const cx = bounds.x + bounds.width / 2;
140
+ const cy = bounds.y + bounds.height / 2;
141
+ ctx.beginPath();
142
+ ctx.ellipse(
143
+ cx,
144
+ cy,
145
+ Math.max(0, bounds.width / 2),
146
+ Math.max(0, bounds.height / 2),
147
+ 0,
148
+ 0,
149
+ Math.PI * 2
150
+ );
151
+ ctx.closePath();
152
+ fillAndStroke(ctx, fill, stroke);
153
+ }
154
+ function drawCylinder(ctx, bounds, fill, stroke) {
155
+ const rx = Math.max(2, bounds.width / 2);
156
+ const ry = Math.max(2, Math.min(bounds.height * 0.18, 16));
157
+ const cx = bounds.x + bounds.width / 2;
158
+ const topCy = bounds.y + ry;
159
+ const bottomCy = bounds.y + bounds.height - ry;
160
+ ctx.beginPath();
161
+ ctx.moveTo(bounds.x, topCy);
162
+ ctx.ellipse(cx, topCy, rx, ry, 0, Math.PI, 0, true);
163
+ ctx.lineTo(bounds.x + bounds.width, bottomCy);
164
+ ctx.ellipse(cx, bottomCy, rx, ry, 0, 0, Math.PI, false);
165
+ ctx.closePath();
166
+ fillAndStroke(ctx, fill, stroke);
167
+ if (stroke) {
168
+ ctx.beginPath();
169
+ ctx.ellipse(cx, topCy, rx, ry, 0, 0, Math.PI * 2);
170
+ ctx.closePath();
171
+ ctx.strokeStyle = stroke;
172
+ ctx.stroke();
173
+ }
174
+ }
175
+ function drawParallelogram(ctx, bounds, fill, stroke, skew) {
176
+ const maxSkew = bounds.width * 0.45;
177
+ const skewX = Math.max(-maxSkew, Math.min(maxSkew, skew ?? bounds.width * 0.18));
178
+ ctx.beginPath();
179
+ ctx.moveTo(bounds.x + skewX, bounds.y);
180
+ ctx.lineTo(bounds.x + bounds.width, bounds.y);
181
+ ctx.lineTo(bounds.x + bounds.width - skewX, bounds.y + bounds.height);
182
+ ctx.lineTo(bounds.x, bounds.y + bounds.height);
183
+ ctx.closePath();
184
+ fillAndStroke(ctx, fill, stroke);
185
+ }
186
+
187
+ // src/primitives/text.ts
188
+ var SUPPORTED_FONT_FAMILIES = /* @__PURE__ */ new Set(["Inter", "JetBrains Mono", "Space Grotesk"]);
189
+ function resolveFont(requested, role) {
190
+ if (SUPPORTED_FONT_FAMILIES.has(requested)) {
191
+ return requested;
192
+ }
193
+ if (role === "mono" || /mono|code|terminal|console/iu.test(requested)) {
194
+ return "JetBrains Mono";
195
+ }
196
+ if (role === "heading" || /display|grotesk|headline/iu.test(requested)) {
197
+ return "Space Grotesk";
198
+ }
199
+ return "Inter";
200
+ }
201
+ function applyFont(ctx, options) {
202
+ ctx.font = `${options.weight} ${options.size}px ${options.family}`;
203
+ }
204
+ function wrapText(ctx, text, maxWidth, maxLines) {
205
+ const trimmed = text.trim();
206
+ if (!trimmed) {
207
+ return { lines: [], truncated: false };
208
+ }
209
+ const words = trimmed.split(/\s+/u);
210
+ const lines = [];
211
+ let current = "";
212
+ for (const word of words) {
213
+ const trial = current.length > 0 ? `${current} ${word}` : word;
214
+ if (ctx.measureText(trial).width <= maxWidth) {
215
+ current = trial;
216
+ continue;
217
+ }
218
+ if (current.length > 0) {
219
+ lines.push(current);
220
+ current = word;
221
+ } else {
222
+ lines.push(word);
223
+ current = "";
224
+ }
225
+ if (lines.length >= maxLines) {
226
+ break;
227
+ }
228
+ }
229
+ if (lines.length < maxLines && current.length > 0) {
230
+ lines.push(current);
231
+ }
232
+ const wasTruncated = lines.length >= maxLines && words.join(" ") !== lines.join(" ");
233
+ if (!wasTruncated) {
234
+ return { lines, truncated: false };
235
+ }
236
+ const lastIndex = lines.length - 1;
237
+ let truncatedLine = `${lines[lastIndex]}\u2026`;
238
+ while (truncatedLine.length > 1 && ctx.measureText(truncatedLine).width > maxWidth) {
239
+ truncatedLine = `${truncatedLine.slice(0, -2)}\u2026`;
240
+ }
241
+ lines[lastIndex] = truncatedLine;
242
+ return { lines, truncated: true };
243
+ }
244
+ function drawTextBlock(ctx, options) {
245
+ applyFont(ctx, { size: options.fontSize, weight: options.fontWeight, family: options.family });
246
+ const wrapped = wrapText(ctx, options.text, options.maxWidth, options.maxLines);
247
+ ctx.fillStyle = options.color;
248
+ for (const [index, line] of wrapped.lines.entries()) {
249
+ ctx.fillText(line, options.x, options.y + index * options.lineHeight);
250
+ }
251
+ return {
252
+ height: wrapped.lines.length * options.lineHeight,
253
+ truncated: wrapped.truncated
254
+ };
255
+ }
256
+ function drawTextLabel(ctx, text, position, options) {
257
+ applyFont(ctx, { size: options.fontSize, weight: 600, family: options.fontFamily });
258
+ const textWidth = Math.ceil(ctx.measureText(text).width);
259
+ const rect = {
260
+ x: Math.round(position.x - (textWidth + options.padding * 2) / 2),
261
+ y: Math.round(position.y - (options.fontSize + options.padding * 2) / 2),
262
+ width: textWidth + options.padding * 2,
263
+ height: options.fontSize + options.padding * 2
264
+ };
265
+ drawRoundedRect(ctx, rect, options.borderRadius, options.backgroundColor);
266
+ ctx.fillStyle = options.color;
267
+ ctx.fillText(text, rect.x + options.padding, rect.y + rect.height - options.padding);
268
+ return rect;
269
+ }
270
+
271
+ // src/utils/color.ts
272
+ function parseChannel(hex, offset) {
273
+ return Number.parseInt(hex.slice(offset, offset + 2), 16);
274
+ }
275
+ function parseHexColor(hexColor) {
276
+ const normalized = hexColor.startsWith("#") ? hexColor.slice(1) : hexColor;
277
+ if (normalized.length !== 6 && normalized.length !== 8) {
278
+ throw new Error(`Unsupported color format: ${hexColor}`);
279
+ }
280
+ return {
281
+ r: parseChannel(normalized, 0),
282
+ g: parseChannel(normalized, 2),
283
+ b: parseChannel(normalized, 4)
284
+ };
285
+ }
286
+ var rgbaRegex = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*([01](?:\.\d+)?|0?\.\d+)\s*)?\)$/;
287
+ var hexColorRegex = /^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
288
+ function toHex(n) {
289
+ return n.toString(16).padStart(2, "0");
290
+ }
291
+ function parseRgbaToHex(color) {
292
+ const match = rgbaRegex.exec(color);
293
+ if (!match) {
294
+ throw new Error(`Invalid rgb/rgba color: ${color}`);
295
+ }
296
+ const r = Number.parseInt(match[1], 10);
297
+ const g = Number.parseInt(match[2], 10);
298
+ const b = Number.parseInt(match[3], 10);
299
+ if (r > 255 || g > 255 || b > 255) {
300
+ throw new Error(`RGB channel values must be 0-255, got: ${color}`);
301
+ }
302
+ if (match[4] !== void 0) {
303
+ const a = Number.parseFloat(match[4]);
304
+ if (a < 0 || a > 1) {
305
+ throw new Error(`Alpha value must be 0-1, got: ${a}`);
306
+ }
307
+ const alphaByte = Math.round(a * 255);
308
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(alphaByte)}`;
309
+ }
310
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
311
+ }
312
+ function isRgbaColor(color) {
313
+ return rgbaRegex.test(color);
314
+ }
315
+ function isHexColor(color) {
316
+ return hexColorRegex.test(color);
317
+ }
318
+ function normalizeColor(color) {
319
+ if (isHexColor(color)) {
320
+ return color;
321
+ }
322
+ if (isRgbaColor(color)) {
323
+ return parseRgbaToHex(color);
324
+ }
325
+ throw new Error(`Expected #RRGGBB, #RRGGBBAA, rgb(), or rgba() color, got: ${color}`);
326
+ }
327
+ function srgbToLinear(channel) {
328
+ const normalized = channel / 255;
329
+ if (normalized <= 0.03928) {
330
+ return normalized / 12.92;
331
+ }
332
+ return ((normalized + 0.055) / 1.055) ** 2.4;
333
+ }
334
+ function relativeLuminance(hexColor) {
335
+ const normalized = isRgbaColor(hexColor) ? parseRgbaToHex(hexColor) : hexColor;
336
+ const rgb = parseHexColor(normalized);
337
+ const r = srgbToLinear(rgb.r);
338
+ const g = srgbToLinear(rgb.g);
339
+ const b = srgbToLinear(rgb.b);
340
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
341
+ }
342
+ function withAlpha(hexColor, opacity) {
343
+ const rgb = parseHexColor(hexColor);
344
+ return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${opacity})`;
345
+ }
346
+ function blendColorWithOpacity(foreground, background, opacity) {
347
+ const fg = parseHexColor(foreground);
348
+ const bg = parseHexColor(background);
349
+ const r = Math.round(fg.r * opacity + bg.r * (1 - opacity));
350
+ const g = Math.round(fg.g * opacity + bg.g * (1 - opacity));
351
+ const b = Math.round(fg.b * opacity + bg.b * (1 - opacity));
352
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`.toUpperCase();
353
+ }
354
+
355
+ // src/renderers/flow-node.ts
356
+ var BADGE_FONT_SIZE = 10;
357
+ var BADGE_FONT_WEIGHT = 600;
358
+ var BADGE_LETTER_SPACING = 1;
359
+ var BADGE_PADDING_X = 8;
360
+ var BADGE_PADDING_Y = 3;
361
+ var BADGE_BORDER_RADIUS = 12;
362
+ var BADGE_DEFAULT_COLOR = "#FFFFFF";
363
+ var BADGE_PILL_HEIGHT = BADGE_FONT_SIZE + BADGE_PADDING_Y * 2;
364
+ var BADGE_INSIDE_TOP_EXTRA = BADGE_PILL_HEIGHT + 6;
365
+ function drawNodeShape(ctx, shape, bounds, fill, stroke, cornerRadius) {
366
+ switch (shape) {
367
+ case "box":
368
+ drawRoundedRect(ctx, bounds, 0, fill, stroke);
369
+ break;
370
+ case "rounded-box":
371
+ drawRoundedRect(ctx, bounds, cornerRadius, fill, stroke);
372
+ break;
373
+ case "diamond":
374
+ drawDiamond(ctx, bounds, fill, stroke);
375
+ break;
376
+ case "circle": {
377
+ const radius = Math.min(bounds.width, bounds.height) / 2;
378
+ drawCircle(
379
+ ctx,
380
+ { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 },
381
+ radius,
382
+ fill,
383
+ stroke
384
+ );
385
+ break;
386
+ }
387
+ case "pill":
388
+ drawPill(ctx, bounds, fill, stroke);
389
+ break;
390
+ case "cylinder":
391
+ drawCylinder(ctx, bounds, fill, stroke);
392
+ break;
393
+ case "parallelogram":
394
+ drawParallelogram(ctx, bounds, fill, stroke);
395
+ break;
396
+ }
397
+ }
398
+ function measureSpacedText(ctx, text, letterSpacing) {
399
+ const base = ctx.measureText(text).width;
400
+ const extraChars = [...text].length - 1;
401
+ return extraChars > 0 ? base + extraChars * letterSpacing : base;
402
+ }
403
+ function drawSpacedText(ctx, text, centerX, centerY, letterSpacing) {
404
+ const chars = [...text];
405
+ if (chars.length === 0) return;
406
+ const totalWidth = measureSpacedText(ctx, text, letterSpacing);
407
+ let cursorX = centerX - totalWidth / 2;
408
+ ctx.textAlign = "left";
409
+ for (let i = 0; i < chars.length; i++) {
410
+ ctx.fillText(chars[i], cursorX, centerY);
411
+ cursorX += ctx.measureText(chars[i]).width + (i < chars.length - 1 ? letterSpacing : 0);
412
+ }
413
+ }
414
+ function renderBadgePill(ctx, centerX, centerY, text, textColor, background, monoFont) {
415
+ ctx.save();
416
+ applyFont(ctx, { size: BADGE_FONT_SIZE, weight: BADGE_FONT_WEIGHT, family: monoFont });
417
+ const textWidth = measureSpacedText(ctx, text, BADGE_LETTER_SPACING);
418
+ const pillWidth = textWidth + BADGE_PADDING_X * 2;
419
+ const pillHeight = BADGE_PILL_HEIGHT;
420
+ const pillX = centerX - pillWidth / 2;
421
+ const pillY = centerY - pillHeight / 2;
422
+ ctx.fillStyle = background;
423
+ ctx.beginPath();
424
+ ctx.roundRect(pillX, pillY, pillWidth, pillHeight, BADGE_BORDER_RADIUS);
425
+ ctx.fill();
426
+ ctx.fillStyle = textColor;
427
+ ctx.textBaseline = "middle";
428
+ applyFont(ctx, { size: BADGE_FONT_SIZE, weight: BADGE_FONT_WEIGHT, family: monoFont });
429
+ drawSpacedText(ctx, text, centerX, centerY, BADGE_LETTER_SPACING);
430
+ ctx.restore();
431
+ return pillWidth;
432
+ }
433
+ function renderFlowNode(ctx, node, bounds, theme) {
434
+ const fillColor = node.color ?? theme.surfaceElevated;
435
+ const borderColor = node.borderColor ?? theme.border;
436
+ const borderWidth = node.borderWidth ?? 2;
437
+ const cornerRadius = node.cornerRadius ?? 16;
438
+ const labelColor = node.labelColor ?? theme.text;
439
+ const sublabelColor = node.sublabelColor ?? theme.textMuted;
440
+ const labelFontSize = node.labelFontSize ?? 20;
441
+ const fillOpacity = node.fillOpacity ?? 1;
442
+ const hasBadge = !!node.badgeText;
443
+ const badgePosition = node.badgePosition ?? "inside-top";
444
+ const badgeColor = node.badgeColor ?? BADGE_DEFAULT_COLOR;
445
+ const badgeBackground = node.badgeBackground ?? borderColor ?? theme.accent;
446
+ ctx.save();
447
+ ctx.lineWidth = borderWidth;
448
+ if (node.shadow) {
449
+ const shadowColor = node.shadow.color ?? borderColor ?? theme.accent;
450
+ ctx.shadowColor = withAlpha(shadowColor, node.shadow.opacity);
451
+ ctx.shadowBlur = node.shadow.blur;
452
+ ctx.shadowOffsetX = node.shadow.offsetX;
453
+ ctx.shadowOffsetY = node.shadow.offsetY;
454
+ }
455
+ if (fillOpacity < 1) {
456
+ ctx.globalAlpha = node.opacity * fillOpacity;
457
+ drawNodeShape(ctx, node.shape, bounds, fillColor, void 0, cornerRadius);
458
+ if (node.shadow) {
459
+ ctx.shadowColor = "transparent";
460
+ ctx.shadowBlur = 0;
461
+ ctx.shadowOffsetX = 0;
462
+ ctx.shadowOffsetY = 0;
463
+ }
464
+ ctx.globalAlpha = node.opacity;
465
+ drawNodeShape(ctx, node.shape, bounds, "rgba(0,0,0,0)", borderColor, cornerRadius);
466
+ } else {
467
+ ctx.globalAlpha = node.opacity;
468
+ drawNodeShape(ctx, node.shape, bounds, fillColor, borderColor, cornerRadius);
469
+ }
470
+ if (node.shadow) {
471
+ ctx.shadowColor = "transparent";
472
+ ctx.shadowBlur = 0;
473
+ ctx.shadowOffsetX = 0;
474
+ ctx.shadowOffsetY = 0;
475
+ }
476
+ const headingFont = resolveFont(theme.fonts.heading, "heading");
477
+ const bodyFont = resolveFont(theme.fonts.body, "body");
478
+ const monoFont = resolveFont(theme.fonts.mono, "mono");
479
+ const centerX = bounds.x + bounds.width / 2;
480
+ const centerY = bounds.y + bounds.height / 2;
481
+ const insideTopShift = hasBadge && badgePosition === "inside-top" ? BADGE_INSIDE_TOP_EXTRA / 2 : 0;
482
+ const sublabelFontSize = Math.max(12, Math.round(labelFontSize * 0.68));
483
+ const sublabel2FontSize = node.sublabel2FontSize ?? 11;
484
+ const sublabel2Color = node.sublabel2Color ?? sublabelColor;
485
+ const lineCount = node.sublabel2 ? 3 : node.sublabel ? 2 : 1;
486
+ const labelToSublabelGap = Math.max(20, sublabelFontSize + 6);
487
+ const sublabelToSublabel2Gap = sublabel2FontSize + 4;
488
+ let textBlockHeight;
489
+ if (lineCount === 1) {
490
+ textBlockHeight = labelFontSize;
491
+ } else if (lineCount === 2) {
492
+ textBlockHeight = labelFontSize + labelToSublabelGap;
493
+ } else {
494
+ textBlockHeight = labelFontSize + labelToSublabelGap + sublabelToSublabel2Gap;
495
+ }
496
+ const labelY = lineCount === 1 ? centerY + labelFontSize * 0.3 + insideTopShift : centerY - textBlockHeight / 2 + labelFontSize * 0.8 + insideTopShift;
497
+ ctx.textAlign = "center";
498
+ applyFont(ctx, { size: labelFontSize, weight: 700, family: headingFont });
499
+ ctx.fillStyle = labelColor;
500
+ ctx.fillText(node.label, centerX, labelY);
501
+ let textBoundsY = bounds.y + bounds.height / 2 - 18;
502
+ let textBoundsHeight = 36;
503
+ if (node.sublabel) {
504
+ applyFont(ctx, { size: sublabelFontSize, weight: 500, family: bodyFont });
505
+ ctx.fillStyle = sublabelColor;
506
+ ctx.fillText(node.sublabel, centerX, labelY + labelToSublabelGap);
507
+ textBoundsY = bounds.y + bounds.height / 2 - 24;
508
+ textBoundsHeight = 56;
509
+ }
510
+ if (node.sublabel2) {
511
+ applyFont(ctx, { size: sublabel2FontSize, weight: 500, family: bodyFont });
512
+ ctx.fillStyle = sublabel2Color;
513
+ const sublabel2Y = node.sublabel ? labelY + labelToSublabelGap + sublabelToSublabel2Gap : labelY + labelToSublabelGap;
514
+ ctx.fillText(node.sublabel2, centerX, sublabel2Y);
515
+ textBoundsY = bounds.y + bounds.height / 2 - 30;
516
+ textBoundsHeight = 72;
517
+ }
518
+ if (hasBadge && node.badgeText) {
519
+ if (badgePosition === "inside-top") {
520
+ const badgeCenterY = bounds.y + BADGE_PILL_HEIGHT / 2 + 8;
521
+ renderBadgePill(
522
+ ctx,
523
+ centerX,
524
+ badgeCenterY,
525
+ node.badgeText,
526
+ badgeColor,
527
+ badgeBackground,
528
+ monoFont
529
+ );
530
+ } else {
531
+ const badgeCenterY = bounds.y - BADGE_PILL_HEIGHT / 2 - 4;
532
+ renderBadgePill(
533
+ ctx,
534
+ centerX,
535
+ badgeCenterY,
536
+ node.badgeText,
537
+ badgeColor,
538
+ badgeBackground,
539
+ monoFont
540
+ );
541
+ }
542
+ }
543
+ ctx.restore();
544
+ const effectiveBg = fillOpacity < 1 ? blendColorWithOpacity(fillColor, theme.background, fillOpacity) : fillColor;
545
+ return [
546
+ {
547
+ id: `flow-node-${node.id}`,
548
+ kind: "flow-node",
549
+ bounds,
550
+ foregroundColor: labelColor,
551
+ backgroundColor: effectiveBg
552
+ },
553
+ {
554
+ id: `flow-node-${node.id}-label`,
555
+ kind: "text",
556
+ bounds: {
557
+ x: bounds.x + 8,
558
+ y: textBoundsY,
559
+ width: bounds.width - 16,
560
+ height: textBoundsHeight
561
+ },
562
+ foregroundColor: labelColor,
563
+ backgroundColor: effectiveBg
564
+ }
565
+ ];
566
+ }
567
+
89
568
  // src/layout/estimates.ts
90
569
  function estimateElementHeight(element) {
91
570
  switch (element.type) {
@@ -184,33 +663,37 @@ function computeStackLayout(elements, config, safeFrame) {
184
663
 
185
664
  // src/layout/elk.ts
186
665
  function estimateFlowNodeSize(node) {
666
+ const badgeExtra = node.badgeText && (node.badgePosition ?? "inside-top") === "inside-top" ? BADGE_INSIDE_TOP_EXTRA : 0;
667
+ const sublabel2Extra = node.sublabel2 ? (node.sublabel2FontSize ?? 11) + 4 : 0;
668
+ const extra = badgeExtra + sublabel2Extra;
187
669
  if (node.width && node.height) {
188
- return { width: node.width, height: node.height };
670
+ return { width: node.width, height: node.height + extra };
189
671
  }
190
672
  if (node.width) {
673
+ const baseHeight = node.shape === "diamond" || node.shape === "circle" ? node.width : 60;
191
674
  return {
192
675
  width: node.width,
193
- height: node.shape === "diamond" || node.shape === "circle" ? node.width : 60
676
+ height: baseHeight + extra
194
677
  };
195
678
  }
196
679
  if (node.height) {
197
680
  return {
198
681
  width: node.shape === "diamond" || node.shape === "circle" ? node.height : 160,
199
- height: node.height
682
+ height: node.height + extra
200
683
  };
201
684
  }
202
685
  switch (node.shape) {
203
686
  case "diamond":
204
687
  case "circle":
205
- return { width: 100, height: 100 };
688
+ return { width: 100 + extra, height: 100 + extra };
206
689
  case "pill":
207
- return { width: 180, height: 56 };
690
+ return { width: 180, height: 56 + extra };
208
691
  case "cylinder":
209
- return { width: 140, height: 92 };
692
+ return { width: 140, height: 92 + extra };
210
693
  case "parallelogram":
211
- return { width: 180, height: 72 };
694
+ return { width: 180, height: 72 + extra };
212
695
  default:
213
- return { width: 170, height: 64 };
696
+ return { width: 170, height: 64 + extra };
214
697
  }
215
698
  }
216
699
  function splitLayoutFrames(safeFrame, direction, hasAuxiliary) {
@@ -328,6 +811,40 @@ function directionToElk(direction) {
328
811
  return "DOWN";
329
812
  }
330
813
  }
814
+ function radialCompactionToElk(compaction) {
815
+ switch (compaction) {
816
+ case "radial":
817
+ return "RADIAL_COMPACTION";
818
+ case "wedge":
819
+ return "WEDGE_COMPACTION";
820
+ default:
821
+ return "NONE";
822
+ }
823
+ }
824
+ function radialSortByToElk(sortBy) {
825
+ switch (sortBy) {
826
+ case "connections":
827
+ return "POLAR_COORDINATE";
828
+ default:
829
+ return "ID";
830
+ }
831
+ }
832
+ function buildRadialOptions(config) {
833
+ const options = {};
834
+ if (config.radialRoot) {
835
+ options["elk.radial.centerOnRoot"] = "true";
836
+ }
837
+ if (config.radialRadius != null) {
838
+ options["elk.radial.radius"] = String(config.radialRadius);
839
+ }
840
+ if (config.radialCompaction) {
841
+ options["elk.radial.compaction.strategy"] = radialCompactionToElk(config.radialCompaction);
842
+ }
843
+ if (config.radialSortBy) {
844
+ options["elk.radial.orderId"] = radialSortByToElk(config.radialSortBy);
845
+ }
846
+ return options;
847
+ }
331
848
  function fallbackForNoFlowNodes(nonFlow, safeFrame) {
332
849
  const fallbackConfig = {
333
850
  mode: "stack",
@@ -363,6 +880,11 @@ async function computeElkLayout(elements, config, safeFrame) {
363
880
  elkNodeSizes.set(node.id, estimateFlowNodeSize(node));
364
881
  }
365
882
  const edgeIdToRouteKey = /* @__PURE__ */ new Map();
883
+ const radialOptions = config.algorithm === "radial" ? buildRadialOptions(config) : {};
884
+ const orderedFlowNodes = config.radialRoot && config.algorithm === "radial" ? [
885
+ ...flowNodes.filter((node) => node.id === config.radialRoot),
886
+ ...flowNodes.filter((node) => node.id !== config.radialRoot)
887
+ ] : flowNodes;
366
888
  const elkGraph = {
367
889
  id: "root",
368
890
  layoutOptions: {
@@ -372,9 +894,10 @@ async function computeElkLayout(elements, config, safeFrame) {
372
894
  "elk.layered.spacing.nodeNodeBetweenLayers": String(config.rankSpacing),
373
895
  "elk.edgeRouting": edgeRoutingToElk(config.edgeRouting),
374
896
  ...config.aspectRatio ? { "elk.aspectRatio": String(config.aspectRatio) } : {},
375
- ...config.algorithm === "stress" ? { "elk.stress.desiredEdgeLength": String(config.rankSpacing + config.nodeSpacing) } : {}
897
+ ...config.algorithm === "stress" ? { "elk.stress.desiredEdgeLength": String(config.rankSpacing + config.nodeSpacing) } : {},
898
+ ...radialOptions
376
899
  },
377
- children: flowNodes.map((node) => {
900
+ children: orderedFlowNodes.map((node) => {
378
901
  const size = elkNodeSizes.get(node.id) ?? { width: 160, height: 60 };
379
902
  return {
380
903
  id: node.id,
@@ -605,268 +1128,86 @@ function roundedRectPath(ctx, x, y, width, height, radius) {
605
1128
  ctx.lineTo(x + safeRadius, y + height);
606
1129
  ctx.quadraticCurveTo(x, y + height, x, y + height - safeRadius);
607
1130
  ctx.lineTo(x, y + safeRadius);
608
- ctx.quadraticCurveTo(x, y, x + safeRadius, y);
609
- ctx.closePath();
610
- }
611
- function parseHexColor(color) {
612
- const normalized = color.startsWith("#") ? color.slice(1) : color;
613
- if (normalized.length !== 6 && normalized.length !== 8) {
614
- throw new Error(`Expected #RRGGBB or #RRGGBBAA color, received ${color}`);
615
- }
616
- const parseChannel2 = (offset) => Number.parseInt(normalized.slice(offset, offset + 2), 16);
617
- return {
618
- r: parseChannel2(0),
619
- g: parseChannel2(2),
620
- b: parseChannel2(4),
621
- a: normalized.length === 8 ? parseChannel2(6) / 255 : 1
622
- };
623
- }
624
- function withAlpha(color, alpha) {
625
- const parsed = parseHexColor(color);
626
- const effectiveAlpha = clamp01(parsed.a * alpha);
627
- return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${effectiveAlpha})`;
628
- }
629
- function drawGradientRect(ctx, rect, gradient, borderRadius = 0) {
630
- const fill = gradient.type === "linear" ? createLinearRectGradient(ctx, rect, gradient.angle ?? 180) : ctx.createRadialGradient(
631
- rect.x + rect.width / 2,
632
- rect.y + rect.height / 2,
633
- 0,
634
- rect.x + rect.width / 2,
635
- rect.y + rect.height / 2,
636
- Math.max(rect.width, rect.height) / 2
637
- );
638
- addGradientStops(fill, gradient.stops);
639
- ctx.save();
640
- ctx.fillStyle = fill;
641
- if (borderRadius > 0) {
642
- roundedRectPath(ctx, rect.x, rect.y, rect.width, rect.height, borderRadius);
643
- ctx.fill();
644
- } else {
645
- ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
646
- }
647
- ctx.restore();
648
- }
649
- function drawRainbowRule(ctx, x, y, width, thickness = 2, colors = [...DEFAULT_RAINBOW_COLORS], borderRadius = thickness / 2) {
650
- if (width <= 0 || thickness <= 0) {
651
- return;
652
- }
653
- const gradient = ctx.createLinearGradient(x, y, x + width, y);
654
- const stops = colors.length >= 2 ? colors : [...DEFAULT_RAINBOW_COLORS];
655
- for (const [index, color] of stops.entries()) {
656
- gradient.addColorStop(index / (stops.length - 1), color);
657
- }
658
- const ruleTop = y - thickness / 2;
659
- ctx.save();
660
- roundedRectPath(ctx, x, ruleTop, width, thickness, borderRadius);
661
- ctx.fillStyle = gradient;
662
- ctx.fill();
663
- ctx.restore();
664
- }
665
- function drawVignette(ctx, width, height, intensity = 0.3, color = "#000000") {
666
- if (width <= 0 || height <= 0 || intensity <= 0) {
667
- return;
668
- }
669
- const centerX = width / 2;
670
- const centerY = height / 2;
671
- const outerRadius = Math.max(width, height) / 2;
672
- const innerRadius = Math.min(width, height) * 0.2;
673
- const vignette = ctx.createRadialGradient(
674
- centerX,
675
- centerY,
676
- innerRadius,
677
- centerX,
678
- centerY,
679
- outerRadius
680
- );
681
- vignette.addColorStop(0, withAlpha(color, 0));
682
- vignette.addColorStop(0.6, withAlpha(color, 0));
683
- vignette.addColorStop(1, withAlpha(color, clamp01(intensity)));
684
- ctx.save();
685
- ctx.fillStyle = vignette;
686
- ctx.fillRect(0, 0, width, height);
687
- ctx.restore();
688
- }
689
-
690
- // src/primitives/shapes.ts
691
- function roundRectPath(ctx, rect, radius) {
692
- const r = Math.max(0, Math.min(radius, rect.width / 2, rect.height / 2));
693
- const right = rect.x + rect.width;
694
- const bottom = rect.y + rect.height;
695
- ctx.beginPath();
696
- ctx.moveTo(rect.x + r, rect.y);
697
- ctx.lineTo(right - r, rect.y);
698
- ctx.quadraticCurveTo(right, rect.y, right, rect.y + r);
699
- ctx.lineTo(right, bottom - r);
700
- ctx.quadraticCurveTo(right, bottom, right - r, bottom);
701
- ctx.lineTo(rect.x + r, bottom);
702
- ctx.quadraticCurveTo(rect.x, bottom, rect.x, bottom - r);
703
- ctx.lineTo(rect.x, rect.y + r);
704
- ctx.quadraticCurveTo(rect.x, rect.y, rect.x + r, rect.y);
705
- ctx.closePath();
706
- }
707
- function fillAndStroke(ctx, fill, stroke) {
708
- ctx.fillStyle = fill;
709
- ctx.fill();
710
- if (stroke) {
711
- ctx.strokeStyle = stroke;
712
- ctx.stroke();
713
- }
714
- }
715
- function drawRoundedRect(ctx, rect, radius, fill, stroke) {
716
- roundRectPath(ctx, rect, radius);
717
- fillAndStroke(ctx, fill, stroke);
718
- }
719
- function drawCircle(ctx, center2, radius, fill, stroke) {
720
- ctx.beginPath();
721
- ctx.arc(center2.x, center2.y, Math.max(0, radius), 0, Math.PI * 2);
722
- ctx.closePath();
723
- fillAndStroke(ctx, fill, stroke);
724
- }
725
- function drawDiamond(ctx, bounds, fill, stroke) {
726
- const cx = bounds.x + bounds.width / 2;
727
- const cy = bounds.y + bounds.height / 2;
728
- ctx.beginPath();
729
- ctx.moveTo(cx, bounds.y);
730
- ctx.lineTo(bounds.x + bounds.width, cy);
731
- ctx.lineTo(cx, bounds.y + bounds.height);
732
- ctx.lineTo(bounds.x, cy);
733
- ctx.closePath();
734
- fillAndStroke(ctx, fill, stroke);
735
- }
736
- function drawPill(ctx, bounds, fill, stroke) {
737
- drawRoundedRect(ctx, bounds, Math.min(bounds.width, bounds.height) / 2, fill, stroke);
738
- }
739
- function drawEllipse(ctx, bounds, fill, stroke) {
740
- const cx = bounds.x + bounds.width / 2;
741
- const cy = bounds.y + bounds.height / 2;
742
- ctx.beginPath();
743
- ctx.ellipse(
744
- cx,
745
- cy,
746
- Math.max(0, bounds.width / 2),
747
- Math.max(0, bounds.height / 2),
748
- 0,
749
- 0,
750
- Math.PI * 2
751
- );
752
- ctx.closePath();
753
- fillAndStroke(ctx, fill, stroke);
754
- }
755
- function drawCylinder(ctx, bounds, fill, stroke) {
756
- const rx = Math.max(2, bounds.width / 2);
757
- const ry = Math.max(2, Math.min(bounds.height * 0.18, 16));
758
- const cx = bounds.x + bounds.width / 2;
759
- const topCy = bounds.y + ry;
760
- const bottomCy = bounds.y + bounds.height - ry;
761
- ctx.beginPath();
762
- ctx.moveTo(bounds.x, topCy);
763
- ctx.ellipse(cx, topCy, rx, ry, 0, Math.PI, 0, true);
764
- ctx.lineTo(bounds.x + bounds.width, bottomCy);
765
- ctx.ellipse(cx, bottomCy, rx, ry, 0, 0, Math.PI, false);
766
- ctx.closePath();
767
- fillAndStroke(ctx, fill, stroke);
768
- if (stroke) {
769
- ctx.beginPath();
770
- ctx.ellipse(cx, topCy, rx, ry, 0, 0, Math.PI * 2);
771
- ctx.closePath();
772
- ctx.strokeStyle = stroke;
773
- ctx.stroke();
774
- }
775
- }
776
- function drawParallelogram(ctx, bounds, fill, stroke, skew) {
777
- const maxSkew = bounds.width * 0.45;
778
- const skewX = Math.max(-maxSkew, Math.min(maxSkew, skew ?? bounds.width * 0.18));
779
- ctx.beginPath();
780
- ctx.moveTo(bounds.x + skewX, bounds.y);
781
- ctx.lineTo(bounds.x + bounds.width, bounds.y);
782
- ctx.lineTo(bounds.x + bounds.width - skewX, bounds.y + bounds.height);
783
- ctx.lineTo(bounds.x, bounds.y + bounds.height);
784
- ctx.closePath();
785
- fillAndStroke(ctx, fill, stroke);
786
- }
787
-
788
- // src/primitives/text.ts
789
- var SUPPORTED_FONT_FAMILIES = /* @__PURE__ */ new Set(["Inter", "JetBrains Mono", "Space Grotesk"]);
790
- function resolveFont(requested, role) {
791
- if (SUPPORTED_FONT_FAMILIES.has(requested)) {
792
- return requested;
793
- }
794
- if (role === "mono" || /mono|code|terminal|console/iu.test(requested)) {
795
- return "JetBrains Mono";
796
- }
797
- if (role === "heading" || /display|grotesk|headline/iu.test(requested)) {
798
- return "Space Grotesk";
799
- }
800
- return "Inter";
801
- }
802
- function applyFont(ctx, options) {
803
- ctx.font = `${options.weight} ${options.size}px ${options.family}`;
804
- }
805
- function wrapText(ctx, text, maxWidth, maxLines) {
806
- const trimmed = text.trim();
807
- if (!trimmed) {
808
- return { lines: [], truncated: false };
809
- }
810
- const words = trimmed.split(/\s+/u);
811
- const lines = [];
812
- let current = "";
813
- for (const word of words) {
814
- const trial = current.length > 0 ? `${current} ${word}` : word;
815
- if (ctx.measureText(trial).width <= maxWidth) {
816
- current = trial;
817
- continue;
818
- }
819
- if (current.length > 0) {
820
- lines.push(current);
821
- current = word;
822
- } else {
823
- lines.push(word);
824
- current = "";
825
- }
826
- if (lines.length >= maxLines) {
827
- break;
828
- }
1131
+ ctx.quadraticCurveTo(x, y, x + safeRadius, y);
1132
+ ctx.closePath();
1133
+ }
1134
+ function parseHexColor2(color) {
1135
+ const normalized = color.startsWith("#") ? color.slice(1) : color;
1136
+ if (normalized.length !== 6 && normalized.length !== 8) {
1137
+ throw new Error(`Expected #RRGGBB or #RRGGBBAA color, received ${color}`);
829
1138
  }
830
- if (lines.length < maxLines && current.length > 0) {
831
- lines.push(current);
1139
+ const parseChannel2 = (offset) => Number.parseInt(normalized.slice(offset, offset + 2), 16);
1140
+ return {
1141
+ r: parseChannel2(0),
1142
+ g: parseChannel2(2),
1143
+ b: parseChannel2(4),
1144
+ a: normalized.length === 8 ? parseChannel2(6) / 255 : 1
1145
+ };
1146
+ }
1147
+ function withAlpha2(color, alpha) {
1148
+ const parsed = parseHexColor2(color);
1149
+ const effectiveAlpha = clamp01(parsed.a * alpha);
1150
+ return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${effectiveAlpha})`;
1151
+ }
1152
+ function drawGradientRect(ctx, rect, gradient, borderRadius = 0) {
1153
+ const fill = gradient.type === "linear" ? createLinearRectGradient(ctx, rect, gradient.angle ?? 180) : ctx.createRadialGradient(
1154
+ rect.x + rect.width / 2,
1155
+ rect.y + rect.height / 2,
1156
+ 0,
1157
+ rect.x + rect.width / 2,
1158
+ rect.y + rect.height / 2,
1159
+ Math.max(rect.width, rect.height) / 2
1160
+ );
1161
+ addGradientStops(fill, gradient.stops);
1162
+ ctx.save();
1163
+ ctx.fillStyle = fill;
1164
+ if (borderRadius > 0) {
1165
+ roundedRectPath(ctx, rect.x, rect.y, rect.width, rect.height, borderRadius);
1166
+ ctx.fill();
1167
+ } else {
1168
+ ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
832
1169
  }
833
- const wasTruncated = lines.length >= maxLines && words.join(" ") !== lines.join(" ");
834
- if (!wasTruncated) {
835
- return { lines, truncated: false };
1170
+ ctx.restore();
1171
+ }
1172
+ function drawRainbowRule(ctx, x, y, width, thickness = 2, colors = [...DEFAULT_RAINBOW_COLORS], borderRadius = thickness / 2) {
1173
+ if (width <= 0 || thickness <= 0) {
1174
+ return;
836
1175
  }
837
- const lastIndex = lines.length - 1;
838
- let truncatedLine = `${lines[lastIndex]}\u2026`;
839
- while (truncatedLine.length > 1 && ctx.measureText(truncatedLine).width > maxWidth) {
840
- truncatedLine = `${truncatedLine.slice(0, -2)}\u2026`;
1176
+ const gradient = ctx.createLinearGradient(x, y, x + width, y);
1177
+ const stops = colors.length >= 2 ? colors : [...DEFAULT_RAINBOW_COLORS];
1178
+ for (const [index, color] of stops.entries()) {
1179
+ gradient.addColorStop(index / (stops.length - 1), color);
841
1180
  }
842
- lines[lastIndex] = truncatedLine;
843
- return { lines, truncated: true };
1181
+ const ruleTop = y - thickness / 2;
1182
+ ctx.save();
1183
+ roundedRectPath(ctx, x, ruleTop, width, thickness, borderRadius);
1184
+ ctx.fillStyle = gradient;
1185
+ ctx.fill();
1186
+ ctx.restore();
844
1187
  }
845
- function drawTextBlock(ctx, options) {
846
- applyFont(ctx, { size: options.fontSize, weight: options.fontWeight, family: options.family });
847
- const wrapped = wrapText(ctx, options.text, options.maxWidth, options.maxLines);
848
- ctx.fillStyle = options.color;
849
- for (const [index, line] of wrapped.lines.entries()) {
850
- ctx.fillText(line, options.x, options.y + index * options.lineHeight);
1188
+ function drawVignette(ctx, width, height, intensity = 0.3, color = "#000000") {
1189
+ if (width <= 0 || height <= 0 || intensity <= 0) {
1190
+ return;
851
1191
  }
852
- return {
853
- height: wrapped.lines.length * options.lineHeight,
854
- truncated: wrapped.truncated
855
- };
856
- }
857
- function drawTextLabel(ctx, text, position, options) {
858
- applyFont(ctx, { size: options.fontSize, weight: 600, family: options.fontFamily });
859
- const textWidth = Math.ceil(ctx.measureText(text).width);
860
- const rect = {
861
- x: Math.round(position.x - (textWidth + options.padding * 2) / 2),
862
- y: Math.round(position.y - (options.fontSize + options.padding * 2) / 2),
863
- width: textWidth + options.padding * 2,
864
- height: options.fontSize + options.padding * 2
865
- };
866
- drawRoundedRect(ctx, rect, options.borderRadius, options.backgroundColor);
867
- ctx.fillStyle = options.color;
868
- ctx.fillText(text, rect.x + options.padding, rect.y + rect.height - options.padding);
869
- return rect;
1192
+ const centerX = width / 2;
1193
+ const centerY = height / 2;
1194
+ const outerRadius = Math.max(width, height) / 2;
1195
+ const innerRadius = Math.min(width, height) * 0.2;
1196
+ const vignette = ctx.createRadialGradient(
1197
+ centerX,
1198
+ centerY,
1199
+ innerRadius,
1200
+ centerX,
1201
+ centerY,
1202
+ outerRadius
1203
+ );
1204
+ vignette.addColorStop(0, withAlpha2(color, 0));
1205
+ vignette.addColorStop(0.6, withAlpha2(color, 0));
1206
+ vignette.addColorStop(1, withAlpha2(color, clamp01(intensity)));
1207
+ ctx.save();
1208
+ ctx.fillStyle = vignette;
1209
+ ctx.fillRect(0, 0, width, height);
1210
+ ctx.restore();
870
1211
  }
871
1212
 
872
1213
  // src/renderers/card.ts
@@ -981,36 +1322,6 @@ function renderCard(ctx, card, rect, theme) {
981
1322
  return elements;
982
1323
  }
983
1324
 
984
- // src/utils/color.ts
985
- function parseChannel(hex, offset) {
986
- return Number.parseInt(hex.slice(offset, offset + 2), 16);
987
- }
988
- function parseHexColor2(hexColor) {
989
- const normalized = hexColor.startsWith("#") ? hexColor.slice(1) : hexColor;
990
- if (normalized.length !== 6 && normalized.length !== 8) {
991
- throw new Error(`Unsupported color format: ${hexColor}`);
992
- }
993
- return {
994
- r: parseChannel(normalized, 0),
995
- g: parseChannel(normalized, 2),
996
- b: parseChannel(normalized, 4)
997
- };
998
- }
999
- function srgbToLinear(channel) {
1000
- const normalized = channel / 255;
1001
- if (normalized <= 0.03928) {
1002
- return normalized / 12.92;
1003
- }
1004
- return ((normalized + 0.055) / 1.055) ** 2.4;
1005
- }
1006
- function relativeLuminance(hexColor) {
1007
- const rgb = parseHexColor2(hexColor);
1008
- const r = srgbToLinear(rgb.r);
1009
- const g = srgbToLinear(rgb.g);
1010
- const b = srgbToLinear(rgb.b);
1011
- return 0.2126 * r + 0.7152 * g + 0.0722 * b;
1012
- }
1013
-
1014
1325
  // src/primitives/window-chrome.ts
1015
1326
  var WINDOW_CHROME_HEIGHT = 34;
1016
1327
  var WINDOW_CHROME_LEFT_MARGIN = 14;
@@ -1023,12 +1334,12 @@ var MACOS_DOTS = [
1023
1334
  { fill: "#27C93F", stroke: "#1AAB29" }
1024
1335
  ];
1025
1336
  function drawMacosDots(ctx, x, y) {
1026
- for (const [index, dot] of MACOS_DOTS.entries()) {
1337
+ for (const [index, dot2] of MACOS_DOTS.entries()) {
1027
1338
  ctx.beginPath();
1028
1339
  ctx.arc(x + index * DOT_SPACING, y, DOT_RADIUS, 0, Math.PI * 2);
1029
1340
  ctx.closePath();
1030
- ctx.fillStyle = dot.fill;
1031
- ctx.strokeStyle = dot.stroke;
1341
+ ctx.fillStyle = dot2.fill;
1342
+ ctx.strokeStyle = dot2.stroke;
1032
1343
  ctx.lineWidth = DOT_STROKE_WIDTH;
1033
1344
  ctx.fill();
1034
1345
  ctx.stroke();
@@ -1160,7 +1471,17 @@ async function highlightCode(code, language, themeName) {
1160
1471
 
1161
1472
  // src/themes/builtin.ts
1162
1473
  import { z } from "zod";
1163
- var colorHexSchema = z.string().regex(/^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/, "Expected #RRGGBB or #RRGGBBAA color");
1474
+ var colorHexSchema = z.string().refine(
1475
+ (v) => {
1476
+ try {
1477
+ normalizeColor(v);
1478
+ return true;
1479
+ } catch {
1480
+ return false;
1481
+ }
1482
+ },
1483
+ { message: "Expected #RRGGBB, #RRGGBBAA, rgb(), or rgba() color" }
1484
+ ).transform((v) => normalizeColor(v));
1164
1485
  var fontFamilySchema = z.string().min(1).max(120);
1165
1486
  var codeThemeSchema = z.object({
1166
1487
  background: colorHexSchema,
@@ -1667,25 +1988,134 @@ function drawOrthogonalPath(ctx, from, to, style) {
1667
1988
  }
1668
1989
 
1669
1990
  // src/renderers/connection.ts
1670
- function center(rect) {
1991
+ var ELLIPSE_KAPPA = 4 * (Math.sqrt(2) - 1) / 3;
1992
+ function rectCenter(rect) {
1671
1993
  return {
1672
1994
  x: rect.x + rect.width / 2,
1673
1995
  y: rect.y + rect.height / 2
1674
1996
  };
1675
1997
  }
1676
- function edgeAnchor(rect, target) {
1677
- const c = center(rect);
1998
+ function edgeAnchor(bounds, target) {
1999
+ const c = rectCenter(bounds);
1678
2000
  const dx = target.x - c.x;
1679
2001
  const dy = target.y - c.y;
1680
- if (Math.abs(dx) >= Math.abs(dy)) {
1681
- return {
1682
- x: dx >= 0 ? rect.x + rect.width : rect.x,
1683
- y: c.y
1684
- };
2002
+ if (dx === 0 && dy === 0) {
2003
+ return { x: c.x, y: c.y - bounds.height / 2 };
2004
+ }
2005
+ const hw = bounds.width / 2;
2006
+ const hh = bounds.height / 2;
2007
+ const absDx = Math.abs(dx);
2008
+ const absDy = Math.abs(dy);
2009
+ const t = absDx * hh > absDy * hw ? hw / absDx : hh / absDy;
2010
+ return { x: c.x + dx * t, y: c.y + dy * t };
2011
+ }
2012
+ function outwardNormal(point, diagramCenter) {
2013
+ const dx = point.x - diagramCenter.x;
2014
+ const dy = point.y - diagramCenter.y;
2015
+ const len = Math.hypot(dx, dy) || 1;
2016
+ return { x: dx / len, y: dy / len };
2017
+ }
2018
+ function curveRoute(fromBounds, toBounds, diagramCenter, tension) {
2019
+ const fromCenter = rectCenter(fromBounds);
2020
+ const toCenter = rectCenter(toBounds);
2021
+ const p0 = edgeAnchor(fromBounds, toCenter);
2022
+ const p3 = edgeAnchor(toBounds, fromCenter);
2023
+ const dist = Math.hypot(p3.x - p0.x, p3.y - p0.y);
2024
+ const offset = dist * tension;
2025
+ const n0 = outwardNormal(p0, diagramCenter);
2026
+ const n3 = outwardNormal(p3, diagramCenter);
2027
+ const cp1 = { x: p0.x + n0.x * offset, y: p0.y + n0.y * offset };
2028
+ const cp2 = { x: p3.x + n3.x * offset, y: p3.y + n3.y * offset };
2029
+ return [p0, cp1, cp2, p3];
2030
+ }
2031
+ function dot(a, b) {
2032
+ return a.x * b.x + a.y * b.y;
2033
+ }
2034
+ function localToWorld(origin, axisX, axisY, local) {
2035
+ return {
2036
+ x: origin.x + axisX.x * local.x + axisY.x * local.y,
2037
+ y: origin.y + axisX.y * local.x + axisY.y * local.y
2038
+ };
2039
+ }
2040
+ function arcRoute(fromBounds, toBounds, diagramCenter, tension) {
2041
+ const fromCenter = rectCenter(fromBounds);
2042
+ const toCenter = rectCenter(toBounds);
2043
+ const start = edgeAnchor(fromBounds, toCenter);
2044
+ const end = edgeAnchor(toBounds, fromCenter);
2045
+ const chord = { x: end.x - start.x, y: end.y - start.y };
2046
+ const chordLength = Math.hypot(chord.x, chord.y);
2047
+ if (chordLength < 1e-6) {
2048
+ const mid = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
2049
+ return [
2050
+ [start, start, mid, mid],
2051
+ [mid, mid, end, end]
2052
+ ];
2053
+ }
2054
+ const axisX = { x: chord.x / chordLength, y: chord.y / chordLength };
2055
+ let axisY = { x: -axisX.y, y: axisX.x };
2056
+ const midpoint = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
2057
+ const outwardHint = outwardNormal(midpoint, diagramCenter);
2058
+ if (dot(axisY, outwardHint) < 0) {
2059
+ axisY = { x: -axisY.x, y: -axisY.y };
2060
+ }
2061
+ const semiMajor = chordLength / 2;
2062
+ const semiMinor = Math.max(12, chordLength * tension * 0.75);
2063
+ const p0Local = { x: -semiMajor, y: 0 };
2064
+ const cp1Local = { x: -semiMajor, y: ELLIPSE_KAPPA * semiMinor };
2065
+ const cp2Local = { x: -ELLIPSE_KAPPA * semiMajor, y: semiMinor };
2066
+ const pMidLocal = { x: 0, y: semiMinor };
2067
+ const cp3Local = { x: ELLIPSE_KAPPA * semiMajor, y: semiMinor };
2068
+ const cp4Local = { x: semiMajor, y: ELLIPSE_KAPPA * semiMinor };
2069
+ const p3Local = { x: semiMajor, y: 0 };
2070
+ const p0 = localToWorld(midpoint, axisX, axisY, p0Local);
2071
+ const cp1 = localToWorld(midpoint, axisX, axisY, cp1Local);
2072
+ const cp2 = localToWorld(midpoint, axisX, axisY, cp2Local);
2073
+ const pMid = localToWorld(midpoint, axisX, axisY, pMidLocal);
2074
+ const cp3 = localToWorld(midpoint, axisX, axisY, cp3Local);
2075
+ const cp4 = localToWorld(midpoint, axisX, axisY, cp4Local);
2076
+ const p3 = localToWorld(midpoint, axisX, axisY, p3Local);
2077
+ return [
2078
+ [p0, cp1, cp2, pMid],
2079
+ [pMid, cp3, cp4, p3]
2080
+ ];
2081
+ }
2082
+ function orthogonalRoute(fromBounds, toBounds) {
2083
+ const fromC = rectCenter(fromBounds);
2084
+ const toC = rectCenter(toBounds);
2085
+ const p0 = edgeAnchor(fromBounds, toC);
2086
+ const p3 = edgeAnchor(toBounds, fromC);
2087
+ const midX = (p0.x + p3.x) / 2;
2088
+ return [p0, { x: midX, y: p0.y }, { x: midX, y: p3.y }, p3];
2089
+ }
2090
+ function bezierPointAt(p0, cp1, cp2, p3, t) {
2091
+ const mt = 1 - t;
2092
+ return {
2093
+ x: mt * mt * mt * p0.x + 3 * mt * mt * t * cp1.x + 3 * mt * t * t * cp2.x + t * t * t * p3.x,
2094
+ y: mt * mt * mt * p0.y + 3 * mt * mt * t * cp1.y + 3 * mt * t * t * cp2.y + t * t * t * p3.y
2095
+ };
2096
+ }
2097
+ function pointAlongArc(route, t) {
2098
+ const [first, second] = route;
2099
+ if (t <= 0.5) {
2100
+ const localT2 = Math.max(0, Math.min(1, t * 2));
2101
+ return bezierPointAt(first[0], first[1], first[2], first[3], localT2);
2102
+ }
2103
+ const localT = Math.max(0, Math.min(1, (t - 0.5) * 2));
2104
+ return bezierPointAt(second[0], second[1], second[2], second[3], localT);
2105
+ }
2106
+ function computeDiagramCenter(nodeBounds, canvasCenter) {
2107
+ if (nodeBounds.length === 0) {
2108
+ return canvasCenter ?? { x: 0, y: 0 };
2109
+ }
2110
+ let totalX = 0;
2111
+ let totalY = 0;
2112
+ for (const bounds of nodeBounds) {
2113
+ totalX += bounds.x + bounds.width / 2;
2114
+ totalY += bounds.y + bounds.height / 2;
1685
2115
  }
1686
2116
  return {
1687
- x: c.x,
1688
- y: dy >= 0 ? rect.y + rect.height : rect.y
2117
+ x: totalX / nodeBounds.length,
2118
+ y: totalY / nodeBounds.length
1689
2119
  };
1690
2120
  }
1691
2121
  function dashFromStyle(style) {
@@ -1769,51 +2199,95 @@ function polylineBounds(points) {
1769
2199
  height: Math.max(1, maxY - minY)
1770
2200
  };
1771
2201
  }
1772
- function renderConnection(ctx, conn, fromBounds, toBounds, theme, edgeRoute) {
1773
- const fromCenter = center(fromBounds);
1774
- const toCenter = center(toBounds);
1775
- const from = edgeAnchor(fromBounds, toCenter);
1776
- const to = edgeAnchor(toBounds, fromCenter);
1777
- const dash = dashFromStyle(conn.style);
2202
+ function renderConnection(ctx, conn, fromBounds, toBounds, theme, edgeRoute, options) {
2203
+ const routing = conn.routing ?? "auto";
2204
+ const strokeStyle = conn.strokeStyle ?? conn.style ?? "solid";
2205
+ const strokeWidth = conn.width ?? conn.strokeWidth ?? 2;
2206
+ const tension = conn.tension ?? 0.35;
2207
+ const dash = dashFromStyle(strokeStyle);
1778
2208
  const style = {
1779
2209
  color: conn.color ?? theme.borderMuted,
1780
- width: conn.width ?? 2,
2210
+ width: strokeWidth,
1781
2211
  headSize: conn.arrowSize ?? 10,
1782
2212
  ...dash ? { dash } : {}
1783
2213
  };
1784
- const points = edgeRoute && edgeRoute.points.length >= 2 ? edgeRoute.points : [from, { x: (from.x + to.x) / 2, y: from.y }, { x: (from.x + to.x) / 2, y: to.y }, to];
1785
- const startSegment = points[1] ?? points[0];
1786
- const endStart = points[points.length - 2] ?? points[0];
1787
- const end = points[points.length - 1] ?? points[0];
1788
- let startAngle = Math.atan2(startSegment.y - points[0].y, startSegment.x - points[0].x) + Math.PI;
1789
- let endAngle = Math.atan2(end.y - endStart.y, end.x - endStart.x);
2214
+ const labelT = conn.labelPosition === "start" ? 0.2 : conn.labelPosition === "end" ? 0.8 : 0.5;
2215
+ const diagramCenter = options?.diagramCenter ?? computeDiagramCenter([fromBounds, toBounds]);
2216
+ let linePoints;
2217
+ let startPoint;
2218
+ let endPoint;
2219
+ let startAngle;
2220
+ let endAngle;
2221
+ let labelPoint;
2222
+ ctx.save();
2223
+ ctx.globalAlpha = conn.opacity;
2224
+ if (routing === "curve") {
2225
+ const [p0, cp1, cp2, p3] = curveRoute(fromBounds, toBounds, diagramCenter, tension);
2226
+ ctx.strokeStyle = style.color;
2227
+ ctx.lineWidth = style.width;
2228
+ ctx.setLineDash(style.dash ?? []);
2229
+ ctx.beginPath();
2230
+ ctx.moveTo(p0.x, p0.y);
2231
+ ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, p3.x, p3.y);
2232
+ ctx.stroke();
2233
+ linePoints = [p0, cp1, cp2, p3];
2234
+ startPoint = p0;
2235
+ endPoint = p3;
2236
+ startAngle = Math.atan2(p0.y - cp1.y, p0.x - cp1.x);
2237
+ endAngle = Math.atan2(p3.y - cp2.y, p3.x - cp2.x);
2238
+ labelPoint = bezierPointAt(p0, cp1, cp2, p3, labelT);
2239
+ } else if (routing === "arc") {
2240
+ const [first, second] = arcRoute(fromBounds, toBounds, diagramCenter, tension);
2241
+ const [p0, cp1, cp2, pMid] = first;
2242
+ const [, cp3, cp4, p3] = second;
2243
+ ctx.strokeStyle = style.color;
2244
+ ctx.lineWidth = style.width;
2245
+ ctx.setLineDash(style.dash ?? []);
2246
+ ctx.beginPath();
2247
+ ctx.moveTo(p0.x, p0.y);
2248
+ ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, pMid.x, pMid.y);
2249
+ ctx.bezierCurveTo(cp3.x, cp3.y, cp4.x, cp4.y, p3.x, p3.y);
2250
+ ctx.stroke();
2251
+ linePoints = [p0, cp1, cp2, pMid, cp3, cp4, p3];
2252
+ startPoint = p0;
2253
+ endPoint = p3;
2254
+ startAngle = Math.atan2(p0.y - cp1.y, p0.x - cp1.x);
2255
+ endAngle = Math.atan2(p3.y - cp4.y, p3.x - cp4.x);
2256
+ labelPoint = pointAlongArc([first, second], labelT);
2257
+ } else {
2258
+ const useElkRoute = routing === "auto" && (edgeRoute?.points.length ?? 0) >= 2;
2259
+ linePoints = useElkRoute ? edgeRoute?.points ?? orthogonalRoute(fromBounds, toBounds) : orthogonalRoute(fromBounds, toBounds);
2260
+ startPoint = linePoints[0];
2261
+ const startSegment = linePoints[1] ?? linePoints[0];
2262
+ const endStart = linePoints[linePoints.length - 2] ?? linePoints[0];
2263
+ endPoint = linePoints[linePoints.length - 1] ?? linePoints[0];
2264
+ startAngle = Math.atan2(startSegment.y - linePoints[0].y, startSegment.x - linePoints[0].x) + Math.PI;
2265
+ endAngle = Math.atan2(endPoint.y - endStart.y, endPoint.x - endStart.x);
2266
+ if (useElkRoute) {
2267
+ drawCubicInterpolatedPath(ctx, linePoints, style);
2268
+ } else {
2269
+ drawOrthogonalPath(ctx, startPoint, endPoint, style);
2270
+ }
2271
+ labelPoint = pointAlongPolyline(linePoints, labelT);
2272
+ }
1790
2273
  if (!Number.isFinite(startAngle)) {
1791
2274
  startAngle = 0;
1792
2275
  }
1793
2276
  if (!Number.isFinite(endAngle)) {
1794
2277
  endAngle = 0;
1795
2278
  }
1796
- const t = conn.labelPosition === "start" ? 0.2 : conn.labelPosition === "end" ? 0.8 : 0.5;
1797
- const labelPoint = pointAlongPolyline(points, t);
1798
- ctx.save();
1799
- ctx.globalAlpha = conn.opacity;
1800
- if (edgeRoute && edgeRoute.points.length >= 2) {
1801
- drawCubicInterpolatedPath(ctx, points, style);
1802
- } else {
1803
- drawOrthogonalPath(ctx, points[0], points[points.length - 1], style);
1804
- }
1805
2279
  if (conn.arrow === "start" || conn.arrow === "both") {
1806
- drawArrowhead(ctx, points[0], startAngle, style.headSize, style.color);
2280
+ drawArrowhead(ctx, startPoint, startAngle, style.headSize, style.color);
1807
2281
  }
1808
2282
  if (conn.arrow === "end" || conn.arrow === "both") {
1809
- drawArrowhead(ctx, end, endAngle, style.headSize, style.color);
2283
+ drawArrowhead(ctx, endPoint, endAngle, style.headSize, style.color);
1810
2284
  }
1811
2285
  ctx.restore();
1812
2286
  const elements = [
1813
2287
  {
1814
2288
  id: `connection-${conn.from}-${conn.to}`,
1815
2289
  kind: "connection",
1816
- bounds: polylineBounds(points),
2290
+ bounds: polylineBounds(linePoints),
1817
2291
  foregroundColor: style.color
1818
2292
  }
1819
2293
  ];
@@ -2444,92 +2918,6 @@ function renderDrawCommands(ctx, commands, theme) {
2444
2918
  return rendered;
2445
2919
  }
2446
2920
 
2447
- // src/renderers/flow-node.ts
2448
- function renderFlowNode(ctx, node, bounds, theme) {
2449
- const fillColor = node.color ?? theme.surfaceElevated;
2450
- const borderColor = node.borderColor ?? theme.border;
2451
- const borderWidth = node.borderWidth ?? 2;
2452
- const cornerRadius = node.cornerRadius ?? 16;
2453
- const labelColor = node.labelColor ?? theme.text;
2454
- const sublabelColor = node.sublabelColor ?? theme.textMuted;
2455
- const labelFontSize = node.labelFontSize ?? 20;
2456
- ctx.save();
2457
- ctx.globalAlpha = node.opacity;
2458
- ctx.lineWidth = borderWidth;
2459
- switch (node.shape) {
2460
- case "box":
2461
- drawRoundedRect(ctx, bounds, 0, fillColor, borderColor);
2462
- break;
2463
- case "rounded-box":
2464
- drawRoundedRect(ctx, bounds, cornerRadius, fillColor, borderColor);
2465
- break;
2466
- case "diamond":
2467
- drawDiamond(ctx, bounds, fillColor, borderColor);
2468
- break;
2469
- case "circle": {
2470
- const radius = Math.min(bounds.width, bounds.height) / 2;
2471
- drawCircle(
2472
- ctx,
2473
- { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 },
2474
- radius,
2475
- fillColor,
2476
- borderColor
2477
- );
2478
- break;
2479
- }
2480
- case "pill":
2481
- drawPill(ctx, bounds, fillColor, borderColor);
2482
- break;
2483
- case "cylinder":
2484
- drawCylinder(ctx, bounds, fillColor, borderColor);
2485
- break;
2486
- case "parallelogram":
2487
- drawParallelogram(ctx, bounds, fillColor, borderColor);
2488
- break;
2489
- }
2490
- const headingFont = resolveFont(theme.fonts.heading, "heading");
2491
- const bodyFont = resolveFont(theme.fonts.body, "body");
2492
- const centerX = bounds.x + bounds.width / 2;
2493
- const centerY = bounds.y + bounds.height / 2;
2494
- const labelY = node.sublabel ? centerY - Math.max(4, labelFontSize * 0.2) : centerY + labelFontSize * 0.3;
2495
- ctx.textAlign = "center";
2496
- applyFont(ctx, { size: labelFontSize, weight: 700, family: headingFont });
2497
- ctx.fillStyle = labelColor;
2498
- ctx.fillText(node.label, centerX, labelY);
2499
- let textBoundsY = bounds.y + bounds.height / 2 - 18;
2500
- let textBoundsHeight = 36;
2501
- if (node.sublabel) {
2502
- const sublabelFontSize = Math.max(12, Math.round(labelFontSize * 0.68));
2503
- applyFont(ctx, { size: sublabelFontSize, weight: 500, family: bodyFont });
2504
- ctx.fillStyle = sublabelColor;
2505
- ctx.fillText(node.sublabel, centerX, labelY + Math.max(20, sublabelFontSize + 6));
2506
- textBoundsY = bounds.y + bounds.height / 2 - 24;
2507
- textBoundsHeight = 56;
2508
- }
2509
- ctx.restore();
2510
- return [
2511
- {
2512
- id: `flow-node-${node.id}`,
2513
- kind: "flow-node",
2514
- bounds,
2515
- foregroundColor: labelColor,
2516
- backgroundColor: fillColor
2517
- },
2518
- {
2519
- id: `flow-node-${node.id}-label`,
2520
- kind: "text",
2521
- bounds: {
2522
- x: bounds.x + 8,
2523
- y: textBoundsY,
2524
- width: bounds.width - 16,
2525
- height: textBoundsHeight
2526
- },
2527
- foregroundColor: labelColor,
2528
- backgroundColor: fillColor
2529
- }
2530
- ];
2531
- }
2532
-
2533
2921
  // src/renderers/image.ts
2534
2922
  import { loadImage } from "@napi-rs/canvas";
2535
2923
  function roundedRectPath2(ctx, bounds, radius) {
@@ -2784,7 +3172,17 @@ function renderTextElement(ctx, textEl, bounds, theme) {
2784
3172
 
2785
3173
  // src/spec.schema.ts
2786
3174
  import { z as z2 } from "zod";
2787
- var colorHexSchema2 = z2.string().regex(/^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/, "Expected #RRGGBB or #RRGGBBAA color");
3175
+ var colorHexSchema2 = z2.string().refine(
3176
+ (v) => {
3177
+ try {
3178
+ normalizeColor(v);
3179
+ return true;
3180
+ } catch {
3181
+ return false;
3182
+ }
3183
+ },
3184
+ { message: "Expected #RRGGBB, #RRGGBBAA, rgb(), or rgba() color" }
3185
+ ).transform((v) => normalizeColor(v));
2788
3186
  var gradientStopSchema = z2.object({
2789
3187
  offset: z2.number().min(0).max(1),
2790
3188
  color: colorHexSchema2
@@ -2968,13 +3366,32 @@ var cardElementSchema = z2.object({
2968
3366
  tone: z2.enum(["neutral", "accent", "success", "warning", "error"]).default("neutral"),
2969
3367
  icon: z2.string().min(1).max(64).optional()
2970
3368
  }).strict();
3369
+ var flowNodeShadowSchema = z2.object({
3370
+ color: colorHexSchema2.optional(),
3371
+ blur: z2.number().min(0).max(64).default(8),
3372
+ offsetX: z2.number().min(-32).max(32).default(0),
3373
+ offsetY: z2.number().min(-32).max(32).default(0),
3374
+ opacity: z2.number().min(0).max(1).default(0.3)
3375
+ }).strict();
2971
3376
  var flowNodeElementSchema = z2.object({
2972
3377
  type: z2.literal("flow-node"),
2973
3378
  id: z2.string().min(1).max(120),
2974
- shape: z2.enum(["box", "rounded-box", "diamond", "circle", "pill", "cylinder", "parallelogram"]),
3379
+ shape: z2.enum([
3380
+ "box",
3381
+ "rounded-box",
3382
+ "diamond",
3383
+ "circle",
3384
+ "pill",
3385
+ "cylinder",
3386
+ "parallelogram",
3387
+ "hexagon"
3388
+ ]).default("rounded-box"),
2975
3389
  label: z2.string().min(1).max(200),
2976
3390
  sublabel: z2.string().min(1).max(300).optional(),
2977
3391
  sublabelColor: colorHexSchema2.optional(),
3392
+ sublabel2: z2.string().min(1).max(300).optional(),
3393
+ sublabel2Color: colorHexSchema2.optional(),
3394
+ sublabel2FontSize: z2.number().min(8).max(32).optional(),
2978
3395
  labelColor: colorHexSchema2.optional(),
2979
3396
  labelFontSize: z2.number().min(10).max(48).optional(),
2980
3397
  color: colorHexSchema2.optional(),
@@ -2983,20 +3400,30 @@ var flowNodeElementSchema = z2.object({
2983
3400
  cornerRadius: z2.number().min(0).max(64).optional(),
2984
3401
  width: z2.number().int().min(40).max(800).optional(),
2985
3402
  height: z2.number().int().min(30).max(600).optional(),
2986
- opacity: z2.number().min(0).max(1).default(1)
3403
+ fillOpacity: z2.number().min(0).max(1).default(1),
3404
+ opacity: z2.number().min(0).max(1).default(1),
3405
+ badgeText: z2.string().min(1).max(32).optional(),
3406
+ badgeColor: colorHexSchema2.optional(),
3407
+ badgeBackground: colorHexSchema2.optional(),
3408
+ badgePosition: z2.enum(["top", "inside-top"]).default("inside-top"),
3409
+ shadow: flowNodeShadowSchema.optional()
2987
3410
  }).strict();
2988
3411
  var connectionElementSchema = z2.object({
2989
3412
  type: z2.literal("connection"),
2990
3413
  from: z2.string().min(1).max(120),
2991
3414
  to: z2.string().min(1).max(120),
2992
3415
  style: z2.enum(["solid", "dashed", "dotted"]).default("solid"),
3416
+ strokeStyle: z2.enum(["solid", "dashed", "dotted"]).default("solid"),
2993
3417
  arrow: z2.enum(["end", "start", "both", "none"]).default("end"),
2994
3418
  label: z2.string().min(1).max(200).optional(),
2995
3419
  labelPosition: z2.enum(["start", "middle", "end"]).default("middle"),
2996
3420
  color: colorHexSchema2.optional(),
2997
- width: z2.number().min(0.5).max(8).optional(),
3421
+ width: z2.number().min(0.5).max(10).optional(),
3422
+ strokeWidth: z2.number().min(0.5).max(10).default(2),
2998
3423
  arrowSize: z2.number().min(4).max(32).optional(),
2999
- opacity: z2.number().min(0).max(1).default(1)
3424
+ opacity: z2.number().min(0).max(1).default(1),
3425
+ routing: z2.enum(["auto", "orthogonal", "curve", "arc"]).default("auto"),
3426
+ tension: z2.number().min(0.1).max(0.8).default(0.35)
3000
3427
  }).strict();
3001
3428
  var codeBlockStyleSchema = z2.object({
3002
3429
  paddingVertical: z2.number().min(0).max(128).default(56),
@@ -3065,6 +3492,10 @@ var elementSchema = z2.discriminatedUnion("type", [
3065
3492
  shapeElementSchema,
3066
3493
  imageElementSchema
3067
3494
  ]);
3495
+ var diagramCenterSchema = z2.object({
3496
+ x: z2.number(),
3497
+ y: z2.number()
3498
+ }).strict();
3068
3499
  var autoLayoutConfigSchema = z2.object({
3069
3500
  mode: z2.literal("auto"),
3070
3501
  algorithm: z2.enum(["layered", "stress", "force", "radial", "box"]).default("layered"),
@@ -3072,7 +3503,17 @@ var autoLayoutConfigSchema = z2.object({
3072
3503
  nodeSpacing: z2.number().int().min(0).max(512).default(80),
3073
3504
  rankSpacing: z2.number().int().min(0).max(512).default(120),
3074
3505
  edgeRouting: z2.enum(["orthogonal", "polyline", "spline"]).default("polyline"),
3075
- aspectRatio: z2.number().min(0.5).max(3).optional()
3506
+ aspectRatio: z2.number().min(0.5).max(3).optional(),
3507
+ /** ID of the root node for radial layout. Only relevant when algorithm is 'radial'. */
3508
+ radialRoot: z2.string().min(1).max(120).optional(),
3509
+ /** Fixed radius in pixels for radial layout. Only relevant when algorithm is 'radial'. */
3510
+ radialRadius: z2.number().positive().optional(),
3511
+ /** Compaction strategy for radial layout. Only relevant when algorithm is 'radial'. */
3512
+ radialCompaction: z2.enum(["none", "radial", "wedge"]).optional(),
3513
+ /** Sort strategy for radial layout node ordering. Only relevant when algorithm is 'radial'. */
3514
+ radialSortBy: z2.enum(["id", "connections"]).optional(),
3515
+ /** Explicit center used by curve/arc connection routing. */
3516
+ diagramCenter: diagramCenterSchema.optional()
3076
3517
  }).strict();
3077
3518
  var gridLayoutConfigSchema = z2.object({
3078
3519
  mode: z2.literal("grid"),
@@ -3080,13 +3521,17 @@ var gridLayoutConfigSchema = z2.object({
3080
3521
  gap: z2.number().int().min(0).max(256).default(24),
3081
3522
  cardMinHeight: z2.number().int().min(32).max(4096).optional(),
3082
3523
  cardMaxHeight: z2.number().int().min(32).max(4096).optional(),
3083
- equalHeight: z2.boolean().default(false)
3524
+ equalHeight: z2.boolean().default(false),
3525
+ /** Explicit center used by curve/arc connection routing. */
3526
+ diagramCenter: diagramCenterSchema.optional()
3084
3527
  }).strict();
3085
3528
  var stackLayoutConfigSchema = z2.object({
3086
3529
  mode: z2.literal("stack"),
3087
3530
  direction: z2.enum(["vertical", "horizontal"]).default("vertical"),
3088
3531
  gap: z2.number().int().min(0).max(256).default(24),
3089
- alignment: z2.enum(["start", "center", "end", "stretch"]).default("stretch")
3532
+ alignment: z2.enum(["start", "center", "end", "stretch"]).default("stretch"),
3533
+ /** Explicit center used by curve/arc connection routing. */
3534
+ diagramCenter: diagramCenterSchema.optional()
3090
3535
  }).strict();
3091
3536
  var manualPositionSchema = z2.object({
3092
3537
  x: z2.number().int(),
@@ -3096,7 +3541,9 @@ var manualPositionSchema = z2.object({
3096
3541
  }).strict();
3097
3542
  var manualLayoutConfigSchema = z2.object({
3098
3543
  mode: z2.literal("manual"),
3099
- positions: z2.record(z2.string().min(1), manualPositionSchema).default({})
3544
+ positions: z2.record(z2.string().min(1), manualPositionSchema).default({}),
3545
+ /** Explicit center used by curve/arc connection routing. */
3546
+ diagramCenter: diagramCenterSchema.optional()
3100
3547
  }).strict();
3101
3548
  var layoutConfigSchema = z2.discriminatedUnion("mode", [
3102
3549
  autoLayoutConfigSchema,
@@ -3148,6 +3595,31 @@ var canvasSchema = z2.object({
3148
3595
  padding: z2.number().int().min(0).max(256).default(defaultCanvas.padding)
3149
3596
  }).strict();
3150
3597
  var themeInputSchema = z2.union([builtInThemeSchema, themeSchema]);
3598
+ var diagramPositionSchema = z2.object({
3599
+ x: z2.number(),
3600
+ y: z2.number(),
3601
+ width: z2.number().positive(),
3602
+ height: z2.number().positive()
3603
+ }).strict();
3604
+ var diagramElementSchema = z2.discriminatedUnion("type", [
3605
+ flowNodeElementSchema,
3606
+ connectionElementSchema
3607
+ ]);
3608
+ var diagramLayoutSchema = z2.object({
3609
+ mode: z2.enum(["manual", "auto"]).default("manual"),
3610
+ positions: z2.record(z2.string(), diagramPositionSchema).optional(),
3611
+ diagramCenter: diagramCenterSchema.optional()
3612
+ }).strict();
3613
+ var diagramSpecSchema = z2.object({
3614
+ version: z2.literal(1),
3615
+ canvas: z2.object({
3616
+ width: z2.number().int().min(320).max(4096).default(1200),
3617
+ height: z2.number().int().min(180).max(4096).default(675)
3618
+ }).default({ width: 1200, height: 675 }),
3619
+ theme: themeSchema.optional(),
3620
+ elements: z2.array(diagramElementSchema).min(1),
3621
+ layout: diagramLayoutSchema.default({ mode: "manual" })
3622
+ }).strict();
3151
3623
  var designSpecSchema = z2.object({
3152
3624
  version: z2.literal(2).default(2),
3153
3625
  canvas: canvasSchema.default(defaultCanvas),
@@ -3507,6 +3979,10 @@ async function renderDesign(input, options = {}) {
3507
3979
  break;
3508
3980
  }
3509
3981
  }
3982
+ const diagramCenter = spec.layout.diagramCenter ?? computeDiagramCenter(
3983
+ spec.elements.filter((element) => element.type !== "connection").map((element) => elementRects.get(element.id)).filter((rect) => rect != null),
3984
+ { x: spec.canvas.width / 2, y: spec.canvas.height / 2 }
3985
+ );
3510
3986
  for (const element of spec.elements) {
3511
3987
  if (element.type !== "connection") {
3512
3988
  continue;
@@ -3519,7 +3995,9 @@ async function renderDesign(input, options = {}) {
3519
3995
  );
3520
3996
  }
3521
3997
  const edgeRoute = edgeRoutes?.get(`${element.from}-${element.to}`);
3522
- elements.push(...renderConnection(ctx, element, fromRect, toRect, theme, edgeRoute));
3998
+ elements.push(
3999
+ ...renderConnection(ctx, element, fromRect, toRect, theme, edgeRoute, { diagramCenter })
4000
+ );
3523
4001
  }
3524
4002
  if (footerRect && spec.footer) {
3525
4003
  const footerText = spec.footer.tagline ? `${spec.footer.text} \u2022 ${spec.footer.tagline}` : spec.footer.text;