@spectratools/graphic-designer-cli 0.3.2 → 0.4.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,462 @@ 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, center2, radius, fill, stroke) {
119
+ ctx.beginPath();
120
+ ctx.arc(center2.x, center2.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 blendColorWithOpacity(foreground, background, opacity) {
343
+ const fg = parseHexColor(foreground);
344
+ const bg = parseHexColor(background);
345
+ const r = Math.round(fg.r * opacity + bg.r * (1 - opacity));
346
+ const g = Math.round(fg.g * opacity + bg.g * (1 - opacity));
347
+ const b = Math.round(fg.b * opacity + bg.b * (1 - opacity));
348
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`.toUpperCase();
349
+ }
350
+
351
+ // src/renderers/flow-node.ts
352
+ var BADGE_FONT_SIZE = 10;
353
+ var BADGE_FONT_WEIGHT = 600;
354
+ var BADGE_LETTER_SPACING = 1;
355
+ var BADGE_PADDING_X = 8;
356
+ var BADGE_PADDING_Y = 3;
357
+ var BADGE_BORDER_RADIUS = 12;
358
+ var BADGE_DEFAULT_COLOR = "#FFFFFF";
359
+ var BADGE_PILL_HEIGHT = BADGE_FONT_SIZE + BADGE_PADDING_Y * 2;
360
+ var BADGE_INSIDE_TOP_EXTRA = BADGE_PILL_HEIGHT + 6;
361
+ function drawNodeShape(ctx, shape, bounds, fill, stroke, cornerRadius) {
362
+ switch (shape) {
363
+ case "box":
364
+ drawRoundedRect(ctx, bounds, 0, fill, stroke);
365
+ break;
366
+ case "rounded-box":
367
+ drawRoundedRect(ctx, bounds, cornerRadius, fill, stroke);
368
+ break;
369
+ case "diamond":
370
+ drawDiamond(ctx, bounds, fill, stroke);
371
+ break;
372
+ case "circle": {
373
+ const radius = Math.min(bounds.width, bounds.height) / 2;
374
+ drawCircle(
375
+ ctx,
376
+ { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 },
377
+ radius,
378
+ fill,
379
+ stroke
380
+ );
381
+ break;
382
+ }
383
+ case "pill":
384
+ drawPill(ctx, bounds, fill, stroke);
385
+ break;
386
+ case "cylinder":
387
+ drawCylinder(ctx, bounds, fill, stroke);
388
+ break;
389
+ case "parallelogram":
390
+ drawParallelogram(ctx, bounds, fill, stroke);
391
+ break;
392
+ }
393
+ }
394
+ function measureSpacedText(ctx, text, letterSpacing) {
395
+ const base = ctx.measureText(text).width;
396
+ const extraChars = [...text].length - 1;
397
+ return extraChars > 0 ? base + extraChars * letterSpacing : base;
398
+ }
399
+ function drawSpacedText(ctx, text, centerX, centerY, letterSpacing) {
400
+ const chars = [...text];
401
+ if (chars.length === 0) return;
402
+ const totalWidth = measureSpacedText(ctx, text, letterSpacing);
403
+ let cursorX = centerX - totalWidth / 2;
404
+ ctx.textAlign = "left";
405
+ for (let i = 0; i < chars.length; i++) {
406
+ ctx.fillText(chars[i], cursorX, centerY);
407
+ cursorX += ctx.measureText(chars[i]).width + (i < chars.length - 1 ? letterSpacing : 0);
408
+ }
409
+ }
410
+ function renderBadgePill(ctx, centerX, centerY, text, textColor, background, monoFont) {
411
+ ctx.save();
412
+ applyFont(ctx, { size: BADGE_FONT_SIZE, weight: BADGE_FONT_WEIGHT, family: monoFont });
413
+ const textWidth = measureSpacedText(ctx, text, BADGE_LETTER_SPACING);
414
+ const pillWidth = textWidth + BADGE_PADDING_X * 2;
415
+ const pillHeight = BADGE_PILL_HEIGHT;
416
+ const pillX = centerX - pillWidth / 2;
417
+ const pillY = centerY - pillHeight / 2;
418
+ ctx.fillStyle = background;
419
+ ctx.beginPath();
420
+ ctx.roundRect(pillX, pillY, pillWidth, pillHeight, BADGE_BORDER_RADIUS);
421
+ ctx.fill();
422
+ ctx.fillStyle = textColor;
423
+ ctx.textBaseline = "middle";
424
+ applyFont(ctx, { size: BADGE_FONT_SIZE, weight: BADGE_FONT_WEIGHT, family: monoFont });
425
+ drawSpacedText(ctx, text, centerX, centerY, BADGE_LETTER_SPACING);
426
+ ctx.restore();
427
+ return pillWidth;
428
+ }
429
+ function renderFlowNode(ctx, node, bounds, theme) {
430
+ const fillColor = node.color ?? theme.surfaceElevated;
431
+ const borderColor = node.borderColor ?? theme.border;
432
+ const borderWidth = node.borderWidth ?? 2;
433
+ const cornerRadius = node.cornerRadius ?? 16;
434
+ const labelColor = node.labelColor ?? theme.text;
435
+ const sublabelColor = node.sublabelColor ?? theme.textMuted;
436
+ const labelFontSize = node.labelFontSize ?? 20;
437
+ const fillOpacity = node.fillOpacity ?? 1;
438
+ const hasBadge = !!node.badgeText;
439
+ const badgePosition = node.badgePosition ?? "inside-top";
440
+ const badgeColor = node.badgeColor ?? BADGE_DEFAULT_COLOR;
441
+ const badgeBackground = node.badgeBackground ?? borderColor ?? theme.accent;
442
+ ctx.save();
443
+ ctx.lineWidth = borderWidth;
444
+ if (fillOpacity < 1) {
445
+ ctx.globalAlpha = node.opacity * fillOpacity;
446
+ drawNodeShape(ctx, node.shape, bounds, fillColor, void 0, cornerRadius);
447
+ ctx.globalAlpha = node.opacity;
448
+ drawNodeShape(ctx, node.shape, bounds, "rgba(0,0,0,0)", borderColor, cornerRadius);
449
+ } else {
450
+ ctx.globalAlpha = node.opacity;
451
+ drawNodeShape(ctx, node.shape, bounds, fillColor, borderColor, cornerRadius);
452
+ }
453
+ const headingFont = resolveFont(theme.fonts.heading, "heading");
454
+ const bodyFont = resolveFont(theme.fonts.body, "body");
455
+ const monoFont = resolveFont(theme.fonts.mono, "mono");
456
+ const centerX = bounds.x + bounds.width / 2;
457
+ const centerY = bounds.y + bounds.height / 2;
458
+ const insideTopShift = hasBadge && badgePosition === "inside-top" ? BADGE_INSIDE_TOP_EXTRA / 2 : 0;
459
+ const sublabelFontSize = Math.max(12, Math.round(labelFontSize * 0.68));
460
+ const sublabel2FontSize = node.sublabel2FontSize ?? 11;
461
+ const sublabel2Color = node.sublabel2Color ?? sublabelColor;
462
+ const lineCount = node.sublabel2 ? 3 : node.sublabel ? 2 : 1;
463
+ const labelToSublabelGap = Math.max(20, sublabelFontSize + 6);
464
+ const sublabelToSublabel2Gap = sublabel2FontSize + 4;
465
+ let textBlockHeight;
466
+ if (lineCount === 1) {
467
+ textBlockHeight = labelFontSize;
468
+ } else if (lineCount === 2) {
469
+ textBlockHeight = labelFontSize + labelToSublabelGap;
470
+ } else {
471
+ textBlockHeight = labelFontSize + labelToSublabelGap + sublabelToSublabel2Gap;
472
+ }
473
+ const labelY = lineCount === 1 ? centerY + labelFontSize * 0.3 + insideTopShift : centerY - textBlockHeight / 2 + labelFontSize * 0.8 + insideTopShift;
474
+ ctx.textAlign = "center";
475
+ applyFont(ctx, { size: labelFontSize, weight: 700, family: headingFont });
476
+ ctx.fillStyle = labelColor;
477
+ ctx.fillText(node.label, centerX, labelY);
478
+ let textBoundsY = bounds.y + bounds.height / 2 - 18;
479
+ let textBoundsHeight = 36;
480
+ if (node.sublabel) {
481
+ applyFont(ctx, { size: sublabelFontSize, weight: 500, family: bodyFont });
482
+ ctx.fillStyle = sublabelColor;
483
+ ctx.fillText(node.sublabel, centerX, labelY + labelToSublabelGap);
484
+ textBoundsY = bounds.y + bounds.height / 2 - 24;
485
+ textBoundsHeight = 56;
486
+ }
487
+ if (node.sublabel2) {
488
+ applyFont(ctx, { size: sublabel2FontSize, weight: 500, family: bodyFont });
489
+ ctx.fillStyle = sublabel2Color;
490
+ const sublabel2Y = node.sublabel ? labelY + labelToSublabelGap + sublabelToSublabel2Gap : labelY + labelToSublabelGap;
491
+ ctx.fillText(node.sublabel2, centerX, sublabel2Y);
492
+ textBoundsY = bounds.y + bounds.height / 2 - 30;
493
+ textBoundsHeight = 72;
494
+ }
495
+ if (hasBadge && node.badgeText) {
496
+ if (badgePosition === "inside-top") {
497
+ const badgeCenterY = bounds.y + BADGE_PILL_HEIGHT / 2 + 8;
498
+ renderBadgePill(
499
+ ctx,
500
+ centerX,
501
+ badgeCenterY,
502
+ node.badgeText,
503
+ badgeColor,
504
+ badgeBackground,
505
+ monoFont
506
+ );
507
+ } else {
508
+ const badgeCenterY = bounds.y - BADGE_PILL_HEIGHT / 2 - 4;
509
+ renderBadgePill(
510
+ ctx,
511
+ centerX,
512
+ badgeCenterY,
513
+ node.badgeText,
514
+ badgeColor,
515
+ badgeBackground,
516
+ monoFont
517
+ );
518
+ }
519
+ }
520
+ ctx.restore();
521
+ const effectiveBg = fillOpacity < 1 ? blendColorWithOpacity(fillColor, theme.background, fillOpacity) : fillColor;
522
+ return [
523
+ {
524
+ id: `flow-node-${node.id}`,
525
+ kind: "flow-node",
526
+ bounds,
527
+ foregroundColor: labelColor,
528
+ backgroundColor: effectiveBg
529
+ },
530
+ {
531
+ id: `flow-node-${node.id}-label`,
532
+ kind: "text",
533
+ bounds: {
534
+ x: bounds.x + 8,
535
+ y: textBoundsY,
536
+ width: bounds.width - 16,
537
+ height: textBoundsHeight
538
+ },
539
+ foregroundColor: labelColor,
540
+ backgroundColor: effectiveBg
541
+ }
542
+ ];
543
+ }
544
+
89
545
  // src/layout/estimates.ts
90
546
  function estimateElementHeight(element) {
91
547
  switch (element.type) {
@@ -184,33 +640,37 @@ function computeStackLayout(elements, config, safeFrame) {
184
640
 
185
641
  // src/layout/elk.ts
186
642
  function estimateFlowNodeSize(node) {
643
+ const badgeExtra = node.badgeText && (node.badgePosition ?? "inside-top") === "inside-top" ? BADGE_INSIDE_TOP_EXTRA : 0;
644
+ const sublabel2Extra = node.sublabel2 ? (node.sublabel2FontSize ?? 11) + 4 : 0;
645
+ const extra = badgeExtra + sublabel2Extra;
187
646
  if (node.width && node.height) {
188
- return { width: node.width, height: node.height };
647
+ return { width: node.width, height: node.height + extra };
189
648
  }
190
649
  if (node.width) {
650
+ const baseHeight = node.shape === "diamond" || node.shape === "circle" ? node.width : 60;
191
651
  return {
192
652
  width: node.width,
193
- height: node.shape === "diamond" || node.shape === "circle" ? node.width : 60
653
+ height: baseHeight + extra
194
654
  };
195
655
  }
196
656
  if (node.height) {
197
657
  return {
198
658
  width: node.shape === "diamond" || node.shape === "circle" ? node.height : 160,
199
- height: node.height
659
+ height: node.height + extra
200
660
  };
201
661
  }
202
662
  switch (node.shape) {
203
663
  case "diamond":
204
664
  case "circle":
205
- return { width: 100, height: 100 };
665
+ return { width: 100 + extra, height: 100 + extra };
206
666
  case "pill":
207
- return { width: 180, height: 56 };
667
+ return { width: 180, height: 56 + extra };
208
668
  case "cylinder":
209
- return { width: 140, height: 92 };
669
+ return { width: 140, height: 92 + extra };
210
670
  case "parallelogram":
211
- return { width: 180, height: 72 };
671
+ return { width: 180, height: 72 + extra };
212
672
  default:
213
- return { width: 170, height: 64 };
673
+ return { width: 170, height: 64 + extra };
214
674
  }
215
675
  }
216
676
  function splitLayoutFrames(safeFrame, direction, hasAuxiliary) {
@@ -328,6 +788,40 @@ function directionToElk(direction) {
328
788
  return "DOWN";
329
789
  }
330
790
  }
791
+ function radialCompactionToElk(compaction) {
792
+ switch (compaction) {
793
+ case "radial":
794
+ return "RADIAL_COMPACTION";
795
+ case "wedge":
796
+ return "WEDGE_COMPACTION";
797
+ default:
798
+ return "NONE";
799
+ }
800
+ }
801
+ function radialSortByToElk(sortBy) {
802
+ switch (sortBy) {
803
+ case "connections":
804
+ return "POLAR_COORDINATE";
805
+ default:
806
+ return "ID";
807
+ }
808
+ }
809
+ function buildRadialOptions(config) {
810
+ const options = {};
811
+ if (config.radialRoot) {
812
+ options["elk.radial.centerOnRoot"] = "true";
813
+ }
814
+ if (config.radialRadius != null) {
815
+ options["elk.radial.radius"] = String(config.radialRadius);
816
+ }
817
+ if (config.radialCompaction) {
818
+ options["elk.radial.compaction.strategy"] = radialCompactionToElk(config.radialCompaction);
819
+ }
820
+ if (config.radialSortBy) {
821
+ options["elk.radial.orderId"] = radialSortByToElk(config.radialSortBy);
822
+ }
823
+ return options;
824
+ }
331
825
  function fallbackForNoFlowNodes(nonFlow, safeFrame) {
332
826
  const fallbackConfig = {
333
827
  mode: "stack",
@@ -363,6 +857,11 @@ async function computeElkLayout(elements, config, safeFrame) {
363
857
  elkNodeSizes.set(node.id, estimateFlowNodeSize(node));
364
858
  }
365
859
  const edgeIdToRouteKey = /* @__PURE__ */ new Map();
860
+ const radialOptions = config.algorithm === "radial" ? buildRadialOptions(config) : {};
861
+ const orderedFlowNodes = config.radialRoot && config.algorithm === "radial" ? [
862
+ ...flowNodes.filter((node) => node.id === config.radialRoot),
863
+ ...flowNodes.filter((node) => node.id !== config.radialRoot)
864
+ ] : flowNodes;
366
865
  const elkGraph = {
367
866
  id: "root",
368
867
  layoutOptions: {
@@ -372,9 +871,10 @@ async function computeElkLayout(elements, config, safeFrame) {
372
871
  "elk.layered.spacing.nodeNodeBetweenLayers": String(config.rankSpacing),
373
872
  "elk.edgeRouting": edgeRoutingToElk(config.edgeRouting),
374
873
  ...config.aspectRatio ? { "elk.aspectRatio": String(config.aspectRatio) } : {},
375
- ...config.algorithm === "stress" ? { "elk.stress.desiredEdgeLength": String(config.rankSpacing + config.nodeSpacing) } : {}
874
+ ...config.algorithm === "stress" ? { "elk.stress.desiredEdgeLength": String(config.rankSpacing + config.nodeSpacing) } : {},
875
+ ...radialOptions
376
876
  },
377
- children: flowNodes.map((node) => {
877
+ children: orderedFlowNodes.map((node) => {
378
878
  const size = elkNodeSizes.get(node.id) ?? { width: 160, height: 60 };
379
879
  return {
380
880
  id: node.id,
@@ -598,275 +1098,93 @@ function roundedRectPath(ctx, x, y, width, height, radius) {
598
1098
  const safeRadius = Math.max(0, Math.min(radius, width / 2, height / 2));
599
1099
  ctx.beginPath();
600
1100
  ctx.moveTo(x + safeRadius, y);
601
- ctx.lineTo(x + width - safeRadius, y);
602
- ctx.quadraticCurveTo(x + width, y, x + width, y + safeRadius);
603
- ctx.lineTo(x + width, y + height - safeRadius);
604
- ctx.quadraticCurveTo(x + width, y + height, x + width - safeRadius, y + height);
605
- ctx.lineTo(x + safeRadius, y + height);
606
- ctx.quadraticCurveTo(x, y + height, x, y + height - safeRadius);
607
- 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);
1101
+ ctx.lineTo(x + width - safeRadius, y);
1102
+ ctx.quadraticCurveTo(x + width, y, x + width, y + safeRadius);
1103
+ ctx.lineTo(x + width, y + height - safeRadius);
1104
+ ctx.quadraticCurveTo(x + width, y + height, x + width - safeRadius, y + height);
1105
+ ctx.lineTo(x + safeRadius, y + height);
1106
+ ctx.quadraticCurveTo(x, y + height, x, y + height - safeRadius);
1107
+ ctx.lineTo(x, y + safeRadius);
1108
+ ctx.quadraticCurveTo(x, y, x + safeRadius, y);
784
1109
  ctx.closePath();
785
- fillAndStroke(ctx, fill, stroke);
786
1110
  }
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";
1111
+ function parseHexColor2(color) {
1112
+ const normalized = color.startsWith("#") ? color.slice(1) : color;
1113
+ if (normalized.length !== 6 && normalized.length !== 8) {
1114
+ throw new Error(`Expected #RRGGBB or #RRGGBBAA color, received ${color}`);
799
1115
  }
800
- return "Inter";
1116
+ const parseChannel2 = (offset) => Number.parseInt(normalized.slice(offset, offset + 2), 16);
1117
+ return {
1118
+ r: parseChannel2(0),
1119
+ g: parseChannel2(2),
1120
+ b: parseChannel2(4),
1121
+ a: normalized.length === 8 ? parseChannel2(6) / 255 : 1
1122
+ };
801
1123
  }
802
- function applyFont(ctx, options) {
803
- ctx.font = `${options.weight} ${options.size}px ${options.family}`;
1124
+ function withAlpha(color, alpha) {
1125
+ const parsed = parseHexColor2(color);
1126
+ const effectiveAlpha = clamp01(parsed.a * alpha);
1127
+ return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${effectiveAlpha})`;
804
1128
  }
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
- }
829
- }
830
- if (lines.length < maxLines && current.length > 0) {
831
- lines.push(current);
1129
+ function drawGradientRect(ctx, rect, gradient, borderRadius = 0) {
1130
+ const fill = gradient.type === "linear" ? createLinearRectGradient(ctx, rect, gradient.angle ?? 180) : ctx.createRadialGradient(
1131
+ rect.x + rect.width / 2,
1132
+ rect.y + rect.height / 2,
1133
+ 0,
1134
+ rect.x + rect.width / 2,
1135
+ rect.y + rect.height / 2,
1136
+ Math.max(rect.width, rect.height) / 2
1137
+ );
1138
+ addGradientStops(fill, gradient.stops);
1139
+ ctx.save();
1140
+ ctx.fillStyle = fill;
1141
+ if (borderRadius > 0) {
1142
+ roundedRectPath(ctx, rect.x, rect.y, rect.width, rect.height, borderRadius);
1143
+ ctx.fill();
1144
+ } else {
1145
+ ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
832
1146
  }
833
- const wasTruncated = lines.length >= maxLines && words.join(" ") !== lines.join(" ");
834
- if (!wasTruncated) {
835
- return { lines, truncated: false };
1147
+ ctx.restore();
1148
+ }
1149
+ function drawRainbowRule(ctx, x, y, width, thickness = 2, colors = [...DEFAULT_RAINBOW_COLORS], borderRadius = thickness / 2) {
1150
+ if (width <= 0 || thickness <= 0) {
1151
+ return;
836
1152
  }
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`;
1153
+ const gradient = ctx.createLinearGradient(x, y, x + width, y);
1154
+ const stops = colors.length >= 2 ? colors : [...DEFAULT_RAINBOW_COLORS];
1155
+ for (const [index, color] of stops.entries()) {
1156
+ gradient.addColorStop(index / (stops.length - 1), color);
841
1157
  }
842
- lines[lastIndex] = truncatedLine;
843
- return { lines, truncated: true };
1158
+ const ruleTop = y - thickness / 2;
1159
+ ctx.save();
1160
+ roundedRectPath(ctx, x, ruleTop, width, thickness, borderRadius);
1161
+ ctx.fillStyle = gradient;
1162
+ ctx.fill();
1163
+ ctx.restore();
844
1164
  }
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);
1165
+ function drawVignette(ctx, width, height, intensity = 0.3, color = "#000000") {
1166
+ if (width <= 0 || height <= 0 || intensity <= 0) {
1167
+ return;
851
1168
  }
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;
1169
+ const centerX = width / 2;
1170
+ const centerY = height / 2;
1171
+ const outerRadius = Math.max(width, height) / 2;
1172
+ const innerRadius = Math.min(width, height) * 0.2;
1173
+ const vignette = ctx.createRadialGradient(
1174
+ centerX,
1175
+ centerY,
1176
+ innerRadius,
1177
+ centerX,
1178
+ centerY,
1179
+ outerRadius
1180
+ );
1181
+ vignette.addColorStop(0, withAlpha(color, 0));
1182
+ vignette.addColorStop(0.6, withAlpha(color, 0));
1183
+ vignette.addColorStop(1, withAlpha(color, clamp01(intensity)));
1184
+ ctx.save();
1185
+ ctx.fillStyle = vignette;
1186
+ ctx.fillRect(0, 0, width, height);
1187
+ ctx.restore();
870
1188
  }
871
1189
 
872
1190
  // src/renderers/card.ts
@@ -981,36 +1299,6 @@ function renderCard(ctx, card, rect, theme) {
981
1299
  return elements;
982
1300
  }
983
1301
 
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
1302
  // src/primitives/window-chrome.ts
1015
1303
  var WINDOW_CHROME_HEIGHT = 34;
1016
1304
  var WINDOW_CHROME_LEFT_MARGIN = 14;
@@ -1160,7 +1448,17 @@ async function highlightCode(code, language, themeName) {
1160
1448
 
1161
1449
  // src/themes/builtin.ts
1162
1450
  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");
1451
+ var colorHexSchema = z.string().refine(
1452
+ (v) => {
1453
+ try {
1454
+ normalizeColor(v);
1455
+ return true;
1456
+ } catch {
1457
+ return false;
1458
+ }
1459
+ },
1460
+ { message: "Expected #RRGGBB, #RRGGBBAA, rgb(), or rgba() color" }
1461
+ ).transform((v) => normalizeColor(v));
1164
1462
  var fontFamilySchema = z.string().min(1).max(120);
1165
1463
  var codeThemeSchema = z.object({
1166
1464
  background: colorHexSchema,
@@ -2444,92 +2742,6 @@ function renderDrawCommands(ctx, commands, theme) {
2444
2742
  return rendered;
2445
2743
  }
2446
2744
 
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
2745
  // src/renderers/image.ts
2534
2746
  import { loadImage } from "@napi-rs/canvas";
2535
2747
  function roundedRectPath2(ctx, bounds, radius) {
@@ -2784,7 +2996,17 @@ function renderTextElement(ctx, textEl, bounds, theme) {
2784
2996
 
2785
2997
  // src/spec.schema.ts
2786
2998
  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");
2999
+ var colorHexSchema2 = z2.string().refine(
3000
+ (v) => {
3001
+ try {
3002
+ normalizeColor(v);
3003
+ return true;
3004
+ } catch {
3005
+ return false;
3006
+ }
3007
+ },
3008
+ { message: "Expected #RRGGBB, #RRGGBBAA, rgb(), or rgba() color" }
3009
+ ).transform((v) => normalizeColor(v));
2788
3010
  var gradientStopSchema = z2.object({
2789
3011
  offset: z2.number().min(0).max(1),
2790
3012
  color: colorHexSchema2
@@ -2975,6 +3197,9 @@ var flowNodeElementSchema = z2.object({
2975
3197
  label: z2.string().min(1).max(200),
2976
3198
  sublabel: z2.string().min(1).max(300).optional(),
2977
3199
  sublabelColor: colorHexSchema2.optional(),
3200
+ sublabel2: z2.string().min(1).max(300).optional(),
3201
+ sublabel2Color: colorHexSchema2.optional(),
3202
+ sublabel2FontSize: z2.number().min(8).max(32).optional(),
2978
3203
  labelColor: colorHexSchema2.optional(),
2979
3204
  labelFontSize: z2.number().min(10).max(48).optional(),
2980
3205
  color: colorHexSchema2.optional(),
@@ -2983,7 +3208,12 @@ var flowNodeElementSchema = z2.object({
2983
3208
  cornerRadius: z2.number().min(0).max(64).optional(),
2984
3209
  width: z2.number().int().min(40).max(800).optional(),
2985
3210
  height: z2.number().int().min(30).max(600).optional(),
2986
- opacity: z2.number().min(0).max(1).default(1)
3211
+ fillOpacity: z2.number().min(0).max(1).default(1),
3212
+ opacity: z2.number().min(0).max(1).default(1),
3213
+ badgeText: z2.string().min(1).max(32).optional(),
3214
+ badgeColor: colorHexSchema2.optional(),
3215
+ badgeBackground: colorHexSchema2.optional(),
3216
+ badgePosition: z2.enum(["top", "inside-top"]).default("inside-top")
2987
3217
  }).strict();
2988
3218
  var connectionElementSchema = z2.object({
2989
3219
  type: z2.literal("connection"),
@@ -3072,7 +3302,15 @@ var autoLayoutConfigSchema = z2.object({
3072
3302
  nodeSpacing: z2.number().int().min(0).max(512).default(80),
3073
3303
  rankSpacing: z2.number().int().min(0).max(512).default(120),
3074
3304
  edgeRouting: z2.enum(["orthogonal", "polyline", "spline"]).default("polyline"),
3075
- aspectRatio: z2.number().min(0.5).max(3).optional()
3305
+ aspectRatio: z2.number().min(0.5).max(3).optional(),
3306
+ /** ID of the root node for radial layout. Only relevant when algorithm is 'radial'. */
3307
+ radialRoot: z2.string().min(1).max(120).optional(),
3308
+ /** Fixed radius in pixels for radial layout. Only relevant when algorithm is 'radial'. */
3309
+ radialRadius: z2.number().positive().optional(),
3310
+ /** Compaction strategy for radial layout. Only relevant when algorithm is 'radial'. */
3311
+ radialCompaction: z2.enum(["none", "radial", "wedge"]).optional(),
3312
+ /** Sort strategy for radial layout node ordering. Only relevant when algorithm is 'radial'. */
3313
+ radialSortBy: z2.enum(["id", "connections"]).optional()
3076
3314
  }).strict();
3077
3315
  var gridLayoutConfigSchema = z2.object({
3078
3316
  mode: z2.literal("grid"),