@spectratools/graphic-designer-cli 0.4.0 → 0.7.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/cli.js CHANGED
@@ -1,6 +1,153 @@
1
1
  #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // src/compare.ts
13
+ var compare_exports = {};
14
+ __export(compare_exports, {
15
+ compareImages: () => compareImages
16
+ });
17
+ import sharp from "sharp";
18
+ function clampUnit(value) {
19
+ if (value < 0) {
20
+ return 0;
21
+ }
22
+ if (value > 1) {
23
+ return 1;
24
+ }
25
+ return value;
26
+ }
27
+ function toRegionLabel(row, column) {
28
+ const letter = String.fromCharCode(65 + row);
29
+ return `${letter}${column + 1}`;
30
+ }
31
+ function validateGrid(grid) {
32
+ if (!Number.isInteger(grid) || grid <= 0) {
33
+ throw new Error(`Invalid grid value "${grid}". Expected a positive integer.`);
34
+ }
35
+ if (grid > 26) {
36
+ throw new Error(`Invalid grid value "${grid}". Maximum supported grid is 26.`);
37
+ }
38
+ return grid;
39
+ }
40
+ function validateThreshold(threshold) {
41
+ if (!Number.isFinite(threshold) || threshold < 0 || threshold > 1) {
42
+ throw new Error(`Invalid threshold value "${threshold}". Expected a number between 0 and 1.`);
43
+ }
44
+ return threshold;
45
+ }
46
+ async function readDimensions(path) {
47
+ const metadata = await sharp(path).metadata();
48
+ if (!metadata.width || !metadata.height) {
49
+ throw new Error(`Unable to read image dimensions for "${path}".`);
50
+ }
51
+ return {
52
+ width: metadata.width,
53
+ height: metadata.height
54
+ };
55
+ }
56
+ async function normalizeToRaw(path, width, height) {
57
+ const normalized = await sharp(path).rotate().resize(width, height, {
58
+ fit: "contain",
59
+ position: "centre",
60
+ background: { r: 0, g: 0, b: 0, alpha: 0 }
61
+ }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
62
+ return {
63
+ data: normalized.data,
64
+ width: normalized.info.width,
65
+ height: normalized.info.height
66
+ };
67
+ }
68
+ function scorePixelDifference(a, b, offset) {
69
+ const redDiff = Math.abs(a.data[offset] - b.data[offset]);
70
+ const greenDiff = Math.abs(a.data[offset + 1] - b.data[offset + 1]);
71
+ const blueDiff = Math.abs(a.data[offset + 2] - b.data[offset + 2]);
72
+ const alphaDiff = Math.abs(a.data[offset + 3] - b.data[offset + 3]);
73
+ const rgbDelta = (redDiff + greenDiff + blueDiff) / (3 * 255);
74
+ const alphaDelta = alphaDiff / 255;
75
+ return rgbDelta * 0.75 + alphaDelta * 0.25;
76
+ }
77
+ async function compareImages(target, rendered, options = {}) {
78
+ const grid = validateGrid(options.grid ?? DEFAULT_GRID);
79
+ const threshold = validateThreshold(options.threshold ?? DEFAULT_THRESHOLD);
80
+ const closeThreshold = clampUnit(threshold - (options.closeMargin ?? DEFAULT_CLOSE_MARGIN));
81
+ const targetDimensions = await readDimensions(target);
82
+ const renderedDimensions = await readDimensions(rendered);
83
+ const normalizedWidth = Math.max(targetDimensions.width, renderedDimensions.width);
84
+ const normalizedHeight = Math.max(targetDimensions.height, renderedDimensions.height);
85
+ const [targetImage, renderedImage] = await Promise.all([
86
+ normalizeToRaw(target, normalizedWidth, normalizedHeight),
87
+ normalizeToRaw(rendered, normalizedWidth, normalizedHeight)
88
+ ]);
89
+ const regionDiffSums = new Array(grid * grid).fill(0);
90
+ const regionCounts = new Array(grid * grid).fill(0);
91
+ let totalDiff = 0;
92
+ for (let y = 0; y < normalizedHeight; y += 1) {
93
+ const row = Math.min(Math.floor(y * grid / normalizedHeight), grid - 1);
94
+ for (let x = 0; x < normalizedWidth; x += 1) {
95
+ const column = Math.min(Math.floor(x * grid / normalizedWidth), grid - 1);
96
+ const regionIndex = row * grid + column;
97
+ const offset = (y * normalizedWidth + x) * 4;
98
+ const diff = scorePixelDifference(targetImage, renderedImage, offset);
99
+ totalDiff += diff;
100
+ regionDiffSums[regionIndex] += diff;
101
+ regionCounts[regionIndex] += 1;
102
+ }
103
+ }
104
+ const pixelCount = normalizedWidth * normalizedHeight;
105
+ const similarity = clampUnit(1 - totalDiff / pixelCount);
106
+ const regions = [];
107
+ for (let row = 0; row < grid; row += 1) {
108
+ for (let column = 0; column < grid; column += 1) {
109
+ const regionIndex = row * grid + column;
110
+ const regionCount = regionCounts[regionIndex];
111
+ const regionSimilarity = regionCount > 0 ? clampUnit(1 - regionDiffSums[regionIndex] / regionCount) : 1;
112
+ regions.push({
113
+ label: toRegionLabel(row, column),
114
+ row,
115
+ column,
116
+ similarity: regionSimilarity
117
+ });
118
+ }
119
+ }
120
+ const verdict = similarity >= threshold ? "match" : similarity >= closeThreshold ? "close" : "mismatch";
121
+ return {
122
+ targetPath: target,
123
+ renderedPath: rendered,
124
+ targetDimensions,
125
+ renderedDimensions,
126
+ normalizedDimensions: {
127
+ width: normalizedWidth,
128
+ height: normalizedHeight
129
+ },
130
+ dimensionMismatch: targetDimensions.width !== renderedDimensions.width || targetDimensions.height !== renderedDimensions.height,
131
+ grid,
132
+ threshold,
133
+ closeThreshold,
134
+ similarity,
135
+ verdict,
136
+ regions
137
+ };
138
+ }
139
+ var DEFAULT_GRID, DEFAULT_THRESHOLD, DEFAULT_CLOSE_MARGIN;
140
+ var init_compare = __esm({
141
+ "src/compare.ts"() {
142
+ "use strict";
143
+ DEFAULT_GRID = 3;
144
+ DEFAULT_THRESHOLD = 0.8;
145
+ DEFAULT_CLOSE_MARGIN = 0.1;
146
+ }
147
+ });
2
148
 
3
149
  // src/cli.ts
150
+ init_compare();
4
151
  import { readFileSync, realpathSync } from "fs";
5
152
  import { mkdir as mkdir2, readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
6
153
  import { basename as basename4, dirname as dirname3, resolve as resolve4 } from "path";
@@ -198,7 +345,7 @@ async function publishToGitHub(options) {
198
345
  // src/qa.ts
199
346
  import { readFile as readFile3 } from "fs/promises";
200
347
  import { resolve } from "path";
201
- import sharp from "sharp";
348
+ import sharp2 from "sharp";
202
349
 
203
350
  // src/code-style.ts
204
351
  var CARBON_SURROUND_COLOR = "rgba(171, 184, 195, 1)";
@@ -338,6 +485,10 @@ function contrastRatio(foreground, background) {
338
485
  const darker = Math.min(fg, bg);
339
486
  return (lighter + 0.05) / (darker + 0.05);
340
487
  }
488
+ function withAlpha(hexColor, opacity) {
489
+ const rgb = parseHexColor(hexColor);
490
+ return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${opacity})`;
491
+ }
341
492
  function blendColorWithOpacity(foreground, background, opacity) {
342
493
  const fg = parseHexColor(foreground);
343
494
  const bg = parseHexColor(background);
@@ -701,6 +852,15 @@ var drawGradientRectSchema = z2.object({
701
852
  radius: z2.number().min(0).max(256).default(0),
702
853
  opacity: z2.number().min(0).max(1).default(1)
703
854
  }).strict();
855
+ var drawGridSchema = z2.object({
856
+ type: z2.literal("grid"),
857
+ spacing: z2.number().min(5).max(200).default(40),
858
+ color: colorHexSchema2.default("#1E2D4A"),
859
+ width: z2.number().min(0.1).max(4).default(0.5),
860
+ opacity: z2.number().min(0).max(1).default(0.2),
861
+ offsetX: z2.number().default(0),
862
+ offsetY: z2.number().default(0)
863
+ }).strict();
704
864
  var drawCommandSchema = z2.discriminatedUnion("type", [
705
865
  drawRectSchema,
706
866
  drawCircleSchema,
@@ -709,7 +869,8 @@ var drawCommandSchema = z2.discriminatedUnion("type", [
709
869
  drawBezierSchema,
710
870
  drawPathSchema,
711
871
  drawBadgeSchema,
712
- drawGradientRectSchema
872
+ drawGradientRectSchema,
873
+ drawGridSchema
713
874
  ]);
714
875
  var defaultCanvas = {
715
876
  width: 1200,
@@ -773,10 +934,26 @@ var cardElementSchema = z2.object({
773
934
  tone: z2.enum(["neutral", "accent", "success", "warning", "error"]).default("neutral"),
774
935
  icon: z2.string().min(1).max(64).optional()
775
936
  }).strict();
937
+ var flowNodeShadowSchema = z2.object({
938
+ color: colorHexSchema2.optional(),
939
+ blur: z2.number().min(0).max(64).default(8),
940
+ offsetX: z2.number().min(-32).max(32).default(0),
941
+ offsetY: z2.number().min(-32).max(32).default(0),
942
+ opacity: z2.number().min(0).max(1).default(0.3)
943
+ }).strict();
776
944
  var flowNodeElementSchema = z2.object({
777
945
  type: z2.literal("flow-node"),
778
946
  id: z2.string().min(1).max(120),
779
- shape: z2.enum(["box", "rounded-box", "diamond", "circle", "pill", "cylinder", "parallelogram"]),
947
+ shape: z2.enum([
948
+ "box",
949
+ "rounded-box",
950
+ "diamond",
951
+ "circle",
952
+ "pill",
953
+ "cylinder",
954
+ "parallelogram",
955
+ "hexagon"
956
+ ]).default("rounded-box"),
780
957
  label: z2.string().min(1).max(200),
781
958
  sublabel: z2.string().min(1).max(300).optional(),
782
959
  sublabelColor: colorHexSchema2.optional(),
@@ -796,20 +973,35 @@ var flowNodeElementSchema = z2.object({
796
973
  badgeText: z2.string().min(1).max(32).optional(),
797
974
  badgeColor: colorHexSchema2.optional(),
798
975
  badgeBackground: colorHexSchema2.optional(),
799
- badgePosition: z2.enum(["top", "inside-top"]).default("inside-top")
976
+ badgePosition: z2.enum(["top", "inside-top"]).default("inside-top"),
977
+ shadow: flowNodeShadowSchema.optional()
800
978
  }).strict();
979
+ var anchorHintSchema = z2.union([
980
+ z2.enum(["top", "bottom", "left", "right", "center"]),
981
+ z2.object({
982
+ x: z2.number().min(-1).max(1),
983
+ y: z2.number().min(-1).max(1)
984
+ }).strict()
985
+ ]);
801
986
  var connectionElementSchema = z2.object({
802
987
  type: z2.literal("connection"),
803
988
  from: z2.string().min(1).max(120),
804
989
  to: z2.string().min(1).max(120),
805
990
  style: z2.enum(["solid", "dashed", "dotted"]).default("solid"),
991
+ strokeStyle: z2.enum(["solid", "dashed", "dotted"]).default("solid"),
806
992
  arrow: z2.enum(["end", "start", "both", "none"]).default("end"),
807
993
  label: z2.string().min(1).max(200).optional(),
808
994
  labelPosition: z2.enum(["start", "middle", "end"]).default("middle"),
809
995
  color: colorHexSchema2.optional(),
810
- width: z2.number().min(0.5).max(8).optional(),
996
+ width: z2.number().min(0.5).max(10).optional(),
997
+ strokeWidth: z2.number().min(0.5).max(10).default(2),
811
998
  arrowSize: z2.number().min(4).max(32).optional(),
812
- opacity: z2.number().min(0).max(1).default(1)
999
+ arrowPlacement: z2.enum(["endpoint", "boundary"]).default("endpoint"),
1000
+ opacity: z2.number().min(0).max(1).default(1),
1001
+ routing: z2.enum(["auto", "orthogonal", "curve", "arc"]).default("auto"),
1002
+ tension: z2.number().min(0.1).max(0.8).default(0.35),
1003
+ fromAnchor: anchorHintSchema.optional(),
1004
+ toAnchor: anchorHintSchema.optional()
813
1005
  }).strict();
814
1006
  var codeBlockStyleSchema = z2.object({
815
1007
  paddingVertical: z2.number().min(0).max(128).default(56),
@@ -878,6 +1070,10 @@ var elementSchema = z2.discriminatedUnion("type", [
878
1070
  shapeElementSchema,
879
1071
  imageElementSchema
880
1072
  ]);
1073
+ var diagramCenterSchema = z2.object({
1074
+ x: z2.number(),
1075
+ y: z2.number()
1076
+ }).strict();
881
1077
  var autoLayoutConfigSchema = z2.object({
882
1078
  mode: z2.literal("auto"),
883
1079
  algorithm: z2.enum(["layered", "stress", "force", "radial", "box"]).default("layered"),
@@ -893,7 +1089,9 @@ var autoLayoutConfigSchema = z2.object({
893
1089
  /** Compaction strategy for radial layout. Only relevant when algorithm is 'radial'. */
894
1090
  radialCompaction: z2.enum(["none", "radial", "wedge"]).optional(),
895
1091
  /** Sort strategy for radial layout node ordering. Only relevant when algorithm is 'radial'. */
896
- radialSortBy: z2.enum(["id", "connections"]).optional()
1092
+ radialSortBy: z2.enum(["id", "connections"]).optional(),
1093
+ /** Explicit center used by curve/arc connection routing. */
1094
+ diagramCenter: diagramCenterSchema.optional()
897
1095
  }).strict();
898
1096
  var gridLayoutConfigSchema = z2.object({
899
1097
  mode: z2.literal("grid"),
@@ -901,13 +1099,17 @@ var gridLayoutConfigSchema = z2.object({
901
1099
  gap: z2.number().int().min(0).max(256).default(24),
902
1100
  cardMinHeight: z2.number().int().min(32).max(4096).optional(),
903
1101
  cardMaxHeight: z2.number().int().min(32).max(4096).optional(),
904
- equalHeight: z2.boolean().default(false)
1102
+ equalHeight: z2.boolean().default(false),
1103
+ /** Explicit center used by curve/arc connection routing. */
1104
+ diagramCenter: diagramCenterSchema.optional()
905
1105
  }).strict();
906
1106
  var stackLayoutConfigSchema = z2.object({
907
1107
  mode: z2.literal("stack"),
908
1108
  direction: z2.enum(["vertical", "horizontal"]).default("vertical"),
909
1109
  gap: z2.number().int().min(0).max(256).default(24),
910
- alignment: z2.enum(["start", "center", "end", "stretch"]).default("stretch")
1110
+ alignment: z2.enum(["start", "center", "end", "stretch"]).default("stretch"),
1111
+ /** Explicit center used by curve/arc connection routing. */
1112
+ diagramCenter: diagramCenterSchema.optional()
911
1113
  }).strict();
912
1114
  var manualPositionSchema = z2.object({
913
1115
  x: z2.number().int(),
@@ -917,7 +1119,9 @@ var manualPositionSchema = z2.object({
917
1119
  }).strict();
918
1120
  var manualLayoutConfigSchema = z2.object({
919
1121
  mode: z2.literal("manual"),
920
- positions: z2.record(z2.string().min(1), manualPositionSchema).default({})
1122
+ positions: z2.record(z2.string().min(1), manualPositionSchema).default({}),
1123
+ /** Explicit center used by curve/arc connection routing. */
1124
+ diagramCenter: diagramCenterSchema.optional()
921
1125
  }).strict();
922
1126
  var layoutConfigSchema = z2.discriminatedUnion("mode", [
923
1127
  autoLayoutConfigSchema,
@@ -969,6 +1173,31 @@ var canvasSchema = z2.object({
969
1173
  padding: z2.number().int().min(0).max(256).default(defaultCanvas.padding)
970
1174
  }).strict();
971
1175
  var themeInputSchema = z2.union([builtInThemeSchema, themeSchema]);
1176
+ var diagramPositionSchema = z2.object({
1177
+ x: z2.number(),
1178
+ y: z2.number(),
1179
+ width: z2.number().positive(),
1180
+ height: z2.number().positive()
1181
+ }).strict();
1182
+ var diagramElementSchema = z2.discriminatedUnion("type", [
1183
+ flowNodeElementSchema,
1184
+ connectionElementSchema
1185
+ ]);
1186
+ var diagramLayoutSchema = z2.object({
1187
+ mode: z2.enum(["manual", "auto"]).default("manual"),
1188
+ positions: z2.record(z2.string(), diagramPositionSchema).optional(),
1189
+ diagramCenter: diagramCenterSchema.optional()
1190
+ }).strict();
1191
+ var diagramSpecSchema = z2.object({
1192
+ version: z2.literal(1),
1193
+ canvas: z2.object({
1194
+ width: z2.number().int().min(320).max(4096).default(1200),
1195
+ height: z2.number().int().min(180).max(4096).default(675)
1196
+ }).default({ width: 1200, height: 675 }),
1197
+ theme: themeSchema.optional(),
1198
+ elements: z2.array(diagramElementSchema).min(1),
1199
+ layout: diagramLayoutSchema.default({ mode: "manual" })
1200
+ }).strict();
972
1201
  var designSpecSchema = z2.object({
973
1202
  version: z2.literal(2).default(2),
974
1203
  canvas: canvasSchema.default(defaultCanvas),
@@ -1042,7 +1271,7 @@ async function runQa(options) {
1042
1271
  const imagePath = resolve(options.imagePath);
1043
1272
  const expectedSafeFrame = deriveSafeFrame(spec);
1044
1273
  const expectedCanvas = canvasRect(spec);
1045
- const imageMetadata = await sharp(imagePath).metadata();
1274
+ const imageMetadata = await sharp2(imagePath).metadata();
1046
1275
  const issues = [];
1047
1276
  const expectedScale = options.metadata?.canvas.scale ?? resolveRenderScale(spec);
1048
1277
  const expectedWidth = spec.canvas.width * expectedScale;
@@ -1193,6 +1422,31 @@ async function runQa(options) {
1193
1422
  });
1194
1423
  }
1195
1424
  }
1425
+ let referenceResult;
1426
+ if (options.referencePath) {
1427
+ const { compareImages: compareImages2 } = await Promise.resolve().then(() => (init_compare(), compare_exports));
1428
+ const comparison = await compareImages2(options.referencePath, imagePath);
1429
+ referenceResult = {
1430
+ similarity: comparison.similarity,
1431
+ verdict: comparison.verdict,
1432
+ regions: comparison.regions.map((region) => ({
1433
+ label: region.label,
1434
+ similarity: region.similarity
1435
+ }))
1436
+ };
1437
+ if (comparison.verdict === "mismatch") {
1438
+ const severity = comparison.similarity < 0.5 ? "error" : "warning";
1439
+ issues.push({
1440
+ code: "REFERENCE_MISMATCH",
1441
+ severity,
1442
+ message: `Reference image comparison ${severity === "error" ? "failed" : "warned"}: similarity ${comparison.similarity.toFixed(4)} with verdict "${comparison.verdict}".`,
1443
+ details: {
1444
+ similarity: comparison.similarity,
1445
+ verdict: comparison.verdict
1446
+ }
1447
+ });
1448
+ }
1449
+ }
1196
1450
  const footerSpacingPx = options.metadata?.layout.elements ? (() => {
1197
1451
  const footer = options.metadata.layout.elements.find((element) => element.id === "footer");
1198
1452
  if (!footer) {
@@ -1225,7 +1479,8 @@ async function runQa(options) {
1225
1479
  ...imageMetadata.height !== void 0 ? { height: imageMetadata.height } : {},
1226
1480
  ...footerSpacingPx !== void 0 ? { footerSpacingPx } : {}
1227
1481
  },
1228
- issues
1482
+ issues,
1483
+ ...referenceResult ? { reference: referenceResult } : {}
1229
1484
  };
1230
1485
  }
1231
1486
 
@@ -1292,9 +1547,9 @@ function drawRoundedRect(ctx, rect, radius, fill, stroke) {
1292
1547
  roundRectPath(ctx, rect, radius);
1293
1548
  fillAndStroke(ctx, fill, stroke);
1294
1549
  }
1295
- function drawCircle(ctx, center2, radius, fill, stroke) {
1550
+ function drawCircle(ctx, center, radius, fill, stroke) {
1296
1551
  ctx.beginPath();
1297
- ctx.arc(center2.x, center2.y, Math.max(0, radius), 0, Math.PI * 2);
1552
+ ctx.arc(center.x, center.y, Math.max(0, radius), 0, Math.PI * 2);
1298
1553
  ctx.closePath();
1299
1554
  fillAndStroke(ctx, fill, stroke);
1300
1555
  }
@@ -1538,15 +1793,34 @@ function renderFlowNode(ctx, node, bounds, theme) {
1538
1793
  const badgeBackground = node.badgeBackground ?? borderColor ?? theme.accent;
1539
1794
  ctx.save();
1540
1795
  ctx.lineWidth = borderWidth;
1796
+ if (node.shadow) {
1797
+ const shadowColor = node.shadow.color ?? borderColor ?? theme.accent;
1798
+ ctx.shadowColor = withAlpha(shadowColor, node.shadow.opacity);
1799
+ ctx.shadowBlur = node.shadow.blur;
1800
+ ctx.shadowOffsetX = node.shadow.offsetX;
1801
+ ctx.shadowOffsetY = node.shadow.offsetY;
1802
+ }
1541
1803
  if (fillOpacity < 1) {
1542
1804
  ctx.globalAlpha = node.opacity * fillOpacity;
1543
1805
  drawNodeShape(ctx, node.shape, bounds, fillColor, void 0, cornerRadius);
1806
+ if (node.shadow) {
1807
+ ctx.shadowColor = "transparent";
1808
+ ctx.shadowBlur = 0;
1809
+ ctx.shadowOffsetX = 0;
1810
+ ctx.shadowOffsetY = 0;
1811
+ }
1544
1812
  ctx.globalAlpha = node.opacity;
1545
1813
  drawNodeShape(ctx, node.shape, bounds, "rgba(0,0,0,0)", borderColor, cornerRadius);
1546
1814
  } else {
1547
1815
  ctx.globalAlpha = node.opacity;
1548
1816
  drawNodeShape(ctx, node.shape, bounds, fillColor, borderColor, cornerRadius);
1549
1817
  }
1818
+ if (node.shadow) {
1819
+ ctx.shadowColor = "transparent";
1820
+ ctx.shadowBlur = 0;
1821
+ ctx.shadowOffsetX = 0;
1822
+ ctx.shadowOffsetY = 0;
1823
+ }
1550
1824
  const headingFont = resolveFont(theme.fonts.heading, "heading");
1551
1825
  const bodyFont = resolveFont(theme.fonts.body, "body");
1552
1826
  const monoFont = resolveFont(theme.fonts.mono, "mono");
@@ -2218,7 +2492,7 @@ function parseHexColor2(color) {
2218
2492
  a: normalized.length === 8 ? parseChannel2(6) / 255 : 1
2219
2493
  };
2220
2494
  }
2221
- function withAlpha(color, alpha) {
2495
+ function withAlpha2(color, alpha) {
2222
2496
  const parsed = parseHexColor2(color);
2223
2497
  const effectiveAlpha = clamp01(parsed.a * alpha);
2224
2498
  return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${effectiveAlpha})`;
@@ -2275,9 +2549,9 @@ function drawVignette(ctx, width, height, intensity = 0.3, color = "#000000") {
2275
2549
  centerY,
2276
2550
  outerRadius
2277
2551
  );
2278
- vignette.addColorStop(0, withAlpha(color, 0));
2279
- vignette.addColorStop(0.6, withAlpha(color, 0));
2280
- vignette.addColorStop(1, withAlpha(color, clamp01(intensity)));
2552
+ vignette.addColorStop(0, withAlpha2(color, 0));
2553
+ vignette.addColorStop(0.6, withAlpha2(color, 0));
2554
+ vignette.addColorStop(1, withAlpha2(color, clamp01(intensity)));
2281
2555
  ctx.save();
2282
2556
  ctx.fillStyle = vignette;
2283
2557
  ctx.fillRect(0, 0, width, height);
@@ -2408,12 +2682,12 @@ var MACOS_DOTS = [
2408
2682
  { fill: "#27C93F", stroke: "#1AAB29" }
2409
2683
  ];
2410
2684
  function drawMacosDots(ctx, x, y) {
2411
- for (const [index, dot] of MACOS_DOTS.entries()) {
2685
+ for (const [index, dot2] of MACOS_DOTS.entries()) {
2412
2686
  ctx.beginPath();
2413
2687
  ctx.arc(x + index * DOT_SPACING, y, DOT_RADIUS, 0, Math.PI * 2);
2414
2688
  ctx.closePath();
2415
- ctx.fillStyle = dot.fill;
2416
- ctx.strokeStyle = dot.stroke;
2689
+ ctx.fillStyle = dot2.fill;
2690
+ ctx.strokeStyle = dot2.stroke;
2417
2691
  ctx.lineWidth = DOT_STROKE_WIDTH;
2418
2692
  ctx.fill();
2419
2693
  ctx.stroke();
@@ -2830,25 +3104,203 @@ function drawOrthogonalPath(ctx, from, to, style) {
2830
3104
  }
2831
3105
 
2832
3106
  // src/renderers/connection.ts
2833
- function center(rect) {
3107
+ var ELLIPSE_KAPPA = 4 * (Math.sqrt(2) - 1) / 3;
3108
+ function rectCenter(rect) {
2834
3109
  return {
2835
3110
  x: rect.x + rect.width / 2,
2836
3111
  y: rect.y + rect.height / 2
2837
3112
  };
2838
3113
  }
2839
- function edgeAnchor(rect, target) {
2840
- const c = center(rect);
3114
+ function edgeAnchor(bounds, target) {
3115
+ const c = rectCenter(bounds);
2841
3116
  const dx = target.x - c.x;
2842
3117
  const dy = target.y - c.y;
2843
- if (Math.abs(dx) >= Math.abs(dy)) {
2844
- return {
2845
- x: dx >= 0 ? rect.x + rect.width : rect.x,
2846
- y: c.y
2847
- };
3118
+ if (dx === 0 && dy === 0) {
3119
+ return { x: c.x, y: c.y - bounds.height / 2 };
3120
+ }
3121
+ const hw = bounds.width / 2;
3122
+ const hh = bounds.height / 2;
3123
+ const absDx = Math.abs(dx);
3124
+ const absDy = Math.abs(dy);
3125
+ const t = absDx * hh > absDy * hw ? hw / absDx : hh / absDy;
3126
+ return { x: c.x + dx * t, y: c.y + dy * t };
3127
+ }
3128
+ function resolveAnchor(bounds, anchor, fallbackTarget) {
3129
+ if (!anchor) return edgeAnchor(bounds, fallbackTarget);
3130
+ if (typeof anchor === "string") {
3131
+ const c2 = rectCenter(bounds);
3132
+ switch (anchor) {
3133
+ case "top":
3134
+ return { x: c2.x, y: bounds.y };
3135
+ case "bottom":
3136
+ return { x: c2.x, y: bounds.y + bounds.height };
3137
+ case "left":
3138
+ return { x: bounds.x, y: c2.y };
3139
+ case "right":
3140
+ return { x: bounds.x + bounds.width, y: c2.y };
3141
+ case "center":
3142
+ return c2;
3143
+ }
3144
+ }
3145
+ const c = rectCenter(bounds);
3146
+ return {
3147
+ x: c.x + anchor.x * (bounds.width / 2),
3148
+ y: c.y + anchor.y * (bounds.height / 2)
3149
+ };
3150
+ }
3151
+ function anchorNormal(anchor, point, diagramCenter) {
3152
+ if (typeof anchor === "string") {
3153
+ switch (anchor) {
3154
+ case "top":
3155
+ return { x: 0, y: -1 };
3156
+ case "bottom":
3157
+ return { x: 0, y: 1 };
3158
+ case "left":
3159
+ return { x: -1, y: 0 };
3160
+ case "right":
3161
+ return { x: 1, y: 0 };
3162
+ case "center":
3163
+ return outwardNormal(point, diagramCenter);
3164
+ }
3165
+ }
3166
+ return outwardNormal(point, diagramCenter);
3167
+ }
3168
+ function outwardNormal(point, diagramCenter) {
3169
+ const dx = point.x - diagramCenter.x;
3170
+ const dy = point.y - diagramCenter.y;
3171
+ const len = Math.hypot(dx, dy) || 1;
3172
+ return { x: dx / len, y: dy / len };
3173
+ }
3174
+ function curveRoute(fromBounds, toBounds, diagramCenter, tension, fromAnchor, toAnchor) {
3175
+ const fromCenter = rectCenter(fromBounds);
3176
+ const toCenter = rectCenter(toBounds);
3177
+ const p0 = resolveAnchor(fromBounds, fromAnchor, toCenter);
3178
+ const p3 = resolveAnchor(toBounds, toAnchor, fromCenter);
3179
+ const dist = Math.hypot(p3.x - p0.x, p3.y - p0.y);
3180
+ const offset = dist * tension;
3181
+ const n0 = anchorNormal(fromAnchor, p0, diagramCenter);
3182
+ const n3 = anchorNormal(toAnchor, p3, diagramCenter);
3183
+ const cp1 = { x: p0.x + n0.x * offset, y: p0.y + n0.y * offset };
3184
+ const cp2 = { x: p3.x + n3.x * offset, y: p3.y + n3.y * offset };
3185
+ return [p0, cp1, cp2, p3];
3186
+ }
3187
+ function dot(a, b) {
3188
+ return a.x * b.x + a.y * b.y;
3189
+ }
3190
+ function localToWorld(origin, axisX, axisY, local) {
3191
+ return {
3192
+ x: origin.x + axisX.x * local.x + axisY.x * local.y,
3193
+ y: origin.y + axisX.y * local.x + axisY.y * local.y
3194
+ };
3195
+ }
3196
+ function arcRoute(fromBounds, toBounds, diagramCenter, tension, fromAnchor, toAnchor) {
3197
+ const fromCenter = rectCenter(fromBounds);
3198
+ const toCenter = rectCenter(toBounds);
3199
+ const start = resolveAnchor(fromBounds, fromAnchor, toCenter);
3200
+ const end = resolveAnchor(toBounds, toAnchor, fromCenter);
3201
+ const chord = { x: end.x - start.x, y: end.y - start.y };
3202
+ const chordLength = Math.hypot(chord.x, chord.y);
3203
+ if (chordLength < 1e-6) {
3204
+ const mid = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
3205
+ return [
3206
+ [start, start, mid, mid],
3207
+ [mid, mid, end, end]
3208
+ ];
3209
+ }
3210
+ const axisX = { x: chord.x / chordLength, y: chord.y / chordLength };
3211
+ let axisY = { x: -axisX.y, y: axisX.x };
3212
+ const midpoint = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
3213
+ const outwardHint = outwardNormal(midpoint, diagramCenter);
3214
+ if (dot(axisY, outwardHint) < 0) {
3215
+ axisY = { x: -axisY.x, y: -axisY.y };
3216
+ }
3217
+ const semiMajor = chordLength / 2;
3218
+ const semiMinor = Math.max(12, chordLength * tension * 0.75);
3219
+ const p0Local = { x: -semiMajor, y: 0 };
3220
+ const cp1Local = { x: -semiMajor, y: ELLIPSE_KAPPA * semiMinor };
3221
+ const cp2Local = { x: -ELLIPSE_KAPPA * semiMajor, y: semiMinor };
3222
+ const pMidLocal = { x: 0, y: semiMinor };
3223
+ const cp3Local = { x: ELLIPSE_KAPPA * semiMajor, y: semiMinor };
3224
+ const cp4Local = { x: semiMajor, y: ELLIPSE_KAPPA * semiMinor };
3225
+ const p3Local = { x: semiMajor, y: 0 };
3226
+ const p0 = localToWorld(midpoint, axisX, axisY, p0Local);
3227
+ const cp1 = localToWorld(midpoint, axisX, axisY, cp1Local);
3228
+ const cp2 = localToWorld(midpoint, axisX, axisY, cp2Local);
3229
+ const pMid = localToWorld(midpoint, axisX, axisY, pMidLocal);
3230
+ const cp3 = localToWorld(midpoint, axisX, axisY, cp3Local);
3231
+ const cp4 = localToWorld(midpoint, axisX, axisY, cp4Local);
3232
+ const p3 = localToWorld(midpoint, axisX, axisY, p3Local);
3233
+ return [
3234
+ [p0, cp1, cp2, pMid],
3235
+ [pMid, cp3, cp4, p3]
3236
+ ];
3237
+ }
3238
+ function orthogonalRoute(fromBounds, toBounds, fromAnchor, toAnchor) {
3239
+ const fromC = rectCenter(fromBounds);
3240
+ const toC = rectCenter(toBounds);
3241
+ const p0 = resolveAnchor(fromBounds, fromAnchor, toC);
3242
+ const p3 = resolveAnchor(toBounds, toAnchor, fromC);
3243
+ const midX = (p0.x + p3.x) / 2;
3244
+ return [p0, { x: midX, y: p0.y }, { x: midX, y: p3.y }, p3];
3245
+ }
3246
+ function bezierPointAt(p0, cp1, cp2, p3, t) {
3247
+ const mt = 1 - t;
3248
+ return {
3249
+ x: mt * mt * mt * p0.x + 3 * mt * mt * t * cp1.x + 3 * mt * t * t * cp2.x + t * t * t * p3.x,
3250
+ y: mt * mt * mt * p0.y + 3 * mt * mt * t * cp1.y + 3 * mt * t * t * cp2.y + t * t * t * p3.y
3251
+ };
3252
+ }
3253
+ function bezierTangentAt(p0, cp1, cp2, p3, t) {
3254
+ const mt = 1 - t;
3255
+ return {
3256
+ x: 3 * mt * mt * (cp1.x - p0.x) + 6 * mt * t * (cp2.x - cp1.x) + 3 * t * t * (p3.x - cp2.x),
3257
+ y: 3 * mt * mt * (cp1.y - p0.y) + 6 * mt * t * (cp2.y - cp1.y) + 3 * t * t * (p3.y - cp2.y)
3258
+ };
3259
+ }
3260
+ function isInsideRect(point, rect) {
3261
+ return point.x >= rect.x && point.x <= rect.x + rect.width && point.y >= rect.y && point.y <= rect.y + rect.height;
3262
+ }
3263
+ function findBoundaryIntersection(p0, cp1, cp2, p3, targetRect, searchFromEnd) {
3264
+ const step = 5e-3;
3265
+ if (searchFromEnd) {
3266
+ for (let t = 0.95; t >= 0.5; t -= step) {
3267
+ const pt = bezierPointAt(p0, cp1, cp2, p3, t);
3268
+ if (!isInsideRect(pt, targetRect)) {
3269
+ return t;
3270
+ }
3271
+ }
3272
+ } else {
3273
+ for (let t = 0.05; t <= 0.5; t += step) {
3274
+ const pt = bezierPointAt(p0, cp1, cp2, p3, t);
3275
+ if (!isInsideRect(pt, targetRect)) {
3276
+ return t;
3277
+ }
3278
+ }
3279
+ }
3280
+ return void 0;
3281
+ }
3282
+ function pointAlongArc(route, t) {
3283
+ const [first, second] = route;
3284
+ if (t <= 0.5) {
3285
+ const localT2 = Math.max(0, Math.min(1, t * 2));
3286
+ return bezierPointAt(first[0], first[1], first[2], first[3], localT2);
3287
+ }
3288
+ const localT = Math.max(0, Math.min(1, (t - 0.5) * 2));
3289
+ return bezierPointAt(second[0], second[1], second[2], second[3], localT);
3290
+ }
3291
+ function computeDiagramCenter(nodeBounds, canvasCenter) {
3292
+ if (nodeBounds.length === 0) {
3293
+ return canvasCenter ?? { x: 0, y: 0 };
3294
+ }
3295
+ let totalX = 0;
3296
+ let totalY = 0;
3297
+ for (const bounds of nodeBounds) {
3298
+ totalX += bounds.x + bounds.width / 2;
3299
+ totalY += bounds.y + bounds.height / 2;
2848
3300
  }
2849
3301
  return {
2850
- x: c.x,
2851
- y: dy >= 0 ? rect.y + rect.height : rect.y
3302
+ x: totalX / nodeBounds.length,
3303
+ y: totalY / nodeBounds.length
2852
3304
  };
2853
3305
  }
2854
3306
  function dashFromStyle(style) {
@@ -2932,51 +3384,148 @@ function polylineBounds(points) {
2932
3384
  height: Math.max(1, maxY - minY)
2933
3385
  };
2934
3386
  }
2935
- function renderConnection(ctx, conn, fromBounds, toBounds, theme, edgeRoute) {
2936
- const fromCenter = center(fromBounds);
2937
- const toCenter = center(toBounds);
2938
- const from = edgeAnchor(fromBounds, toCenter);
2939
- const to = edgeAnchor(toBounds, fromCenter);
2940
- const dash = dashFromStyle(conn.style);
3387
+ function renderConnection(ctx, conn, fromBounds, toBounds, theme, edgeRoute, options) {
3388
+ const routing = conn.routing ?? "auto";
3389
+ const strokeStyle = conn.strokeStyle ?? conn.style ?? "solid";
3390
+ const strokeWidth = conn.width ?? conn.strokeWidth ?? 2;
3391
+ const tension = conn.tension ?? 0.35;
3392
+ const dash = dashFromStyle(strokeStyle);
2941
3393
  const style = {
2942
3394
  color: conn.color ?? theme.borderMuted,
2943
- width: conn.width ?? 2,
3395
+ width: strokeWidth,
2944
3396
  headSize: conn.arrowSize ?? 10,
2945
3397
  ...dash ? { dash } : {}
2946
3398
  };
2947
- 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];
2948
- const startSegment = points[1] ?? points[0];
2949
- const endStart = points[points.length - 2] ?? points[0];
2950
- const end = points[points.length - 1] ?? points[0];
2951
- let startAngle = Math.atan2(startSegment.y - points[0].y, startSegment.x - points[0].x) + Math.PI;
2952
- let endAngle = Math.atan2(end.y - endStart.y, end.x - endStart.x);
3399
+ const labelT = conn.labelPosition === "start" ? 0.2 : conn.labelPosition === "end" ? 0.8 : 0.5;
3400
+ const diagramCenter = options?.diagramCenter ?? computeDiagramCenter([fromBounds, toBounds]);
3401
+ let linePoints;
3402
+ let startPoint;
3403
+ let endPoint;
3404
+ let startAngle;
3405
+ let endAngle;
3406
+ let labelPoint;
3407
+ ctx.save();
3408
+ ctx.globalAlpha = conn.opacity;
3409
+ const arrowPlacement = conn.arrowPlacement ?? "endpoint";
3410
+ if (routing === "curve") {
3411
+ const [p0, cp1, cp2, p3] = curveRoute(
3412
+ fromBounds,
3413
+ toBounds,
3414
+ diagramCenter,
3415
+ tension,
3416
+ conn.fromAnchor,
3417
+ conn.toAnchor
3418
+ );
3419
+ ctx.strokeStyle = style.color;
3420
+ ctx.lineWidth = style.width;
3421
+ ctx.setLineDash(style.dash ?? []);
3422
+ ctx.beginPath();
3423
+ ctx.moveTo(p0.x, p0.y);
3424
+ ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, p3.x, p3.y);
3425
+ ctx.stroke();
3426
+ linePoints = [p0, cp1, cp2, p3];
3427
+ startPoint = p0;
3428
+ endPoint = p3;
3429
+ startAngle = Math.atan2(p0.y - cp1.y, p0.x - cp1.x);
3430
+ endAngle = Math.atan2(p3.y - cp2.y, p3.x - cp2.x);
3431
+ labelPoint = bezierPointAt(p0, cp1, cp2, p3, labelT);
3432
+ if (arrowPlacement === "boundary") {
3433
+ if (conn.arrow === "end" || conn.arrow === "both") {
3434
+ const tEnd = findBoundaryIntersection(p0, cp1, cp2, p3, toBounds, true);
3435
+ if (tEnd !== void 0) {
3436
+ endPoint = bezierPointAt(p0, cp1, cp2, p3, tEnd);
3437
+ const tangent = bezierTangentAt(p0, cp1, cp2, p3, tEnd);
3438
+ endAngle = Math.atan2(tangent.y, tangent.x);
3439
+ }
3440
+ }
3441
+ if (conn.arrow === "start" || conn.arrow === "both") {
3442
+ const tStart = findBoundaryIntersection(p0, cp1, cp2, p3, fromBounds, false);
3443
+ if (tStart !== void 0) {
3444
+ startPoint = bezierPointAt(p0, cp1, cp2, p3, tStart);
3445
+ const tangent = bezierTangentAt(p0, cp1, cp2, p3, tStart);
3446
+ startAngle = Math.atan2(tangent.y, tangent.x) + Math.PI;
3447
+ }
3448
+ }
3449
+ }
3450
+ } else if (routing === "arc") {
3451
+ const [first, second] = arcRoute(
3452
+ fromBounds,
3453
+ toBounds,
3454
+ diagramCenter,
3455
+ tension,
3456
+ conn.fromAnchor,
3457
+ conn.toAnchor
3458
+ );
3459
+ const [p0, cp1, cp2, pMid] = first;
3460
+ const [, cp3, cp4, p3] = second;
3461
+ ctx.strokeStyle = style.color;
3462
+ ctx.lineWidth = style.width;
3463
+ ctx.setLineDash(style.dash ?? []);
3464
+ ctx.beginPath();
3465
+ ctx.moveTo(p0.x, p0.y);
3466
+ ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, pMid.x, pMid.y);
3467
+ ctx.bezierCurveTo(cp3.x, cp3.y, cp4.x, cp4.y, p3.x, p3.y);
3468
+ ctx.stroke();
3469
+ linePoints = [p0, cp1, cp2, pMid, cp3, cp4, p3];
3470
+ startPoint = p0;
3471
+ endPoint = p3;
3472
+ startAngle = Math.atan2(p0.y - cp1.y, p0.x - cp1.x);
3473
+ endAngle = Math.atan2(p3.y - cp4.y, p3.x - cp4.x);
3474
+ labelPoint = pointAlongArc([first, second], labelT);
3475
+ if (arrowPlacement === "boundary") {
3476
+ if (conn.arrow === "end" || conn.arrow === "both") {
3477
+ const [, s_cp3, s_cp4, s_p3] = second;
3478
+ const tEnd = findBoundaryIntersection(pMid, s_cp3, s_cp4, s_p3, toBounds, true);
3479
+ if (tEnd !== void 0) {
3480
+ endPoint = bezierPointAt(pMid, s_cp3, s_cp4, s_p3, tEnd);
3481
+ const tangent = bezierTangentAt(pMid, s_cp3, s_cp4, s_p3, tEnd);
3482
+ endAngle = Math.atan2(tangent.y, tangent.x);
3483
+ }
3484
+ }
3485
+ if (conn.arrow === "start" || conn.arrow === "both") {
3486
+ const tStart = findBoundaryIntersection(p0, cp1, cp2, pMid, fromBounds, false);
3487
+ if (tStart !== void 0) {
3488
+ startPoint = bezierPointAt(p0, cp1, cp2, pMid, tStart);
3489
+ const tangent = bezierTangentAt(p0, cp1, cp2, pMid, tStart);
3490
+ startAngle = Math.atan2(tangent.y, tangent.x) + Math.PI;
3491
+ }
3492
+ }
3493
+ }
3494
+ } else {
3495
+ const hasAnchorHints = conn.fromAnchor !== void 0 || conn.toAnchor !== void 0;
3496
+ const useElkRoute = routing === "auto" && !hasAnchorHints && (edgeRoute?.points.length ?? 0) >= 2;
3497
+ linePoints = useElkRoute ? edgeRoute?.points ?? orthogonalRoute(fromBounds, toBounds, conn.fromAnchor, conn.toAnchor) : orthogonalRoute(fromBounds, toBounds, conn.fromAnchor, conn.toAnchor);
3498
+ startPoint = linePoints[0];
3499
+ const startSegment = linePoints[1] ?? linePoints[0];
3500
+ const endStart = linePoints[linePoints.length - 2] ?? linePoints[0];
3501
+ endPoint = linePoints[linePoints.length - 1] ?? linePoints[0];
3502
+ startAngle = Math.atan2(startSegment.y - linePoints[0].y, startSegment.x - linePoints[0].x) + Math.PI;
3503
+ endAngle = Math.atan2(endPoint.y - endStart.y, endPoint.x - endStart.x);
3504
+ if (useElkRoute) {
3505
+ drawCubicInterpolatedPath(ctx, linePoints, style);
3506
+ } else {
3507
+ drawOrthogonalPath(ctx, startPoint, endPoint, style);
3508
+ }
3509
+ labelPoint = pointAlongPolyline(linePoints, labelT);
3510
+ }
2953
3511
  if (!Number.isFinite(startAngle)) {
2954
3512
  startAngle = 0;
2955
3513
  }
2956
3514
  if (!Number.isFinite(endAngle)) {
2957
3515
  endAngle = 0;
2958
3516
  }
2959
- const t = conn.labelPosition === "start" ? 0.2 : conn.labelPosition === "end" ? 0.8 : 0.5;
2960
- const labelPoint = pointAlongPolyline(points, t);
2961
- ctx.save();
2962
- ctx.globalAlpha = conn.opacity;
2963
- if (edgeRoute && edgeRoute.points.length >= 2) {
2964
- drawCubicInterpolatedPath(ctx, points, style);
2965
- } else {
2966
- drawOrthogonalPath(ctx, points[0], points[points.length - 1], style);
2967
- }
2968
3517
  if (conn.arrow === "start" || conn.arrow === "both") {
2969
- drawArrowhead(ctx, points[0], startAngle, style.headSize, style.color);
3518
+ drawArrowhead(ctx, startPoint, startAngle, style.headSize, style.color);
2970
3519
  }
2971
3520
  if (conn.arrow === "end" || conn.arrow === "both") {
2972
- drawArrowhead(ctx, end, endAngle, style.headSize, style.color);
3521
+ drawArrowhead(ctx, endPoint, endAngle, style.headSize, style.color);
2973
3522
  }
2974
3523
  ctx.restore();
2975
3524
  const elements = [
2976
3525
  {
2977
3526
  id: `connection-${conn.from}-${conn.to}`,
2978
3527
  kind: "connection",
2979
- bounds: polylineBounds(points),
3528
+ bounds: polylineBounds(linePoints),
2980
3529
  foregroundColor: style.color
2981
3530
  }
2982
3531
  ];
@@ -3602,6 +4151,36 @@ function renderDrawCommands(ctx, commands, theme) {
3602
4151
  });
3603
4152
  break;
3604
4153
  }
4154
+ case "grid": {
4155
+ const canvasWidth = ctx.canvas.width;
4156
+ const canvasHeight = ctx.canvas.height;
4157
+ withOpacity(ctx, command.opacity, () => {
4158
+ ctx.strokeStyle = command.color;
4159
+ ctx.lineWidth = command.width;
4160
+ const startX = command.offsetX % command.spacing;
4161
+ for (let x = startX; x <= canvasWidth; x += command.spacing) {
4162
+ ctx.beginPath();
4163
+ ctx.moveTo(x, 0);
4164
+ ctx.lineTo(x, canvasHeight);
4165
+ ctx.stroke();
4166
+ }
4167
+ const startY = command.offsetY % command.spacing;
4168
+ for (let y = startY; y <= canvasHeight; y += command.spacing) {
4169
+ ctx.beginPath();
4170
+ ctx.moveTo(0, y);
4171
+ ctx.lineTo(canvasWidth, y);
4172
+ ctx.stroke();
4173
+ }
4174
+ });
4175
+ rendered.push({
4176
+ id,
4177
+ kind: "draw",
4178
+ bounds: { x: 0, y: 0, width: canvasWidth, height: canvasHeight },
4179
+ foregroundColor: command.color,
4180
+ allowOverlap: true
4181
+ });
4182
+ break;
4183
+ }
3605
4184
  }
3606
4185
  }
3607
4186
  return rendered;
@@ -4190,6 +4769,10 @@ async function renderDesign(input, options = {}) {
4190
4769
  break;
4191
4770
  }
4192
4771
  }
4772
+ const diagramCenter = spec.layout.diagramCenter ?? computeDiagramCenter(
4773
+ spec.elements.filter((element) => element.type !== "connection").map((element) => elementRects.get(element.id)).filter((rect) => rect != null),
4774
+ { x: spec.canvas.width / 2, y: spec.canvas.height / 2 }
4775
+ );
4193
4776
  for (const element of spec.elements) {
4194
4777
  if (element.type !== "connection") {
4195
4778
  continue;
@@ -4202,7 +4785,9 @@ async function renderDesign(input, options = {}) {
4202
4785
  );
4203
4786
  }
4204
4787
  const edgeRoute = edgeRoutes?.get(`${element.from}-${element.to}`);
4205
- elements.push(...renderConnection(ctx, element, fromRect, toRect, theme, edgeRoute));
4788
+ elements.push(
4789
+ ...renderConnection(ctx, element, fromRect, toRect, theme, edgeRoute, { diagramCenter })
4790
+ );
4206
4791
  }
4207
4792
  if (footerRect && spec.footer) {
4208
4793
  const footerText = spec.footer.tagline ? `${spec.footer.text} \u2022 ${spec.footer.tagline}` : spec.footer.text;
@@ -4565,6 +5150,36 @@ var renderOutputSchema = z3.object({
4565
5150
  )
4566
5151
  })
4567
5152
  });
5153
+ var compareOutputSchema = z3.object({
5154
+ targetPath: z3.string(),
5155
+ renderedPath: z3.string(),
5156
+ targetDimensions: z3.object({
5157
+ width: z3.number().int().positive(),
5158
+ height: z3.number().int().positive()
5159
+ }),
5160
+ renderedDimensions: z3.object({
5161
+ width: z3.number().int().positive(),
5162
+ height: z3.number().int().positive()
5163
+ }),
5164
+ normalizedDimensions: z3.object({
5165
+ width: z3.number().int().positive(),
5166
+ height: z3.number().int().positive()
5167
+ }),
5168
+ dimensionMismatch: z3.boolean(),
5169
+ grid: z3.number().int().positive(),
5170
+ threshold: z3.number(),
5171
+ closeThreshold: z3.number(),
5172
+ similarity: z3.number(),
5173
+ verdict: z3.enum(["match", "close", "mismatch"]),
5174
+ regions: z3.array(
5175
+ z3.object({
5176
+ label: z3.string(),
5177
+ row: z3.number().int().nonnegative(),
5178
+ column: z3.number().int().nonnegative(),
5179
+ similarity: z3.number()
5180
+ })
5181
+ )
5182
+ });
4568
5183
  async function readJson(path) {
4569
5184
  if (path === "-") {
4570
5185
  const chunks = [];
@@ -4667,6 +5282,44 @@ cli.command("render", {
4667
5282
  return c.ok(runReport);
4668
5283
  }
4669
5284
  });
5285
+ cli.command("compare", {
5286
+ description: "Compare a rendered design against a target image using structural similarity scoring.",
5287
+ options: z3.object({
5288
+ target: z3.string().describe("Path to target image (baseline)"),
5289
+ rendered: z3.string().describe("Path to rendered image to evaluate"),
5290
+ grid: z3.number().int().positive().default(3).describe("Grid size for per-region scoring"),
5291
+ threshold: z3.number().min(0).max(1).default(0.8).describe("Minimum similarity score required for a match verdict")
5292
+ }),
5293
+ output: compareOutputSchema,
5294
+ examples: [
5295
+ {
5296
+ options: {
5297
+ target: "./designs/target.png",
5298
+ rendered: "./output/design-v2-g0.4.0-sabc123.png",
5299
+ grid: 3,
5300
+ threshold: 0.8
5301
+ },
5302
+ description: "Compare two images and report overall + per-region similarity scores"
5303
+ }
5304
+ ],
5305
+ async run(c) {
5306
+ try {
5307
+ return c.ok(
5308
+ await compareImages(c.options.target, c.options.rendered, {
5309
+ grid: c.options.grid,
5310
+ threshold: c.options.threshold
5311
+ })
5312
+ );
5313
+ } catch (error) {
5314
+ const message = error instanceof Error ? error.message : String(error);
5315
+ return c.error({
5316
+ code: "COMPARE_FAILED",
5317
+ message: `Unable to compare images: ${message}`,
5318
+ retryable: false
5319
+ });
5320
+ }
5321
+ }
5322
+ });
4670
5323
  var template = Cli.create("template", {
4671
5324
  description: "Generate common design templates and run the full render \u2192 QA pipeline."
4672
5325
  });
@@ -4908,7 +5561,8 @@ cli.command("qa", {
4908
5561
  options: z3.object({
4909
5562
  in: z3.string().describe("Path to rendered PNG"),
4910
5563
  spec: z3.string().describe("Path to normalized DesignSpec JSON"),
4911
- meta: z3.string().optional().describe("Optional sidecar metadata path (.meta.json)")
5564
+ meta: z3.string().optional().describe("Optional sidecar metadata path (.meta.json)"),
5565
+ reference: z3.string().optional().describe("Optional reference image path for visual comparison")
4912
5566
  }),
4913
5567
  output: z3.object({
4914
5568
  pass: z3.boolean(),
@@ -4922,7 +5576,18 @@ cli.command("qa", {
4922
5576
  message: z3.string(),
4923
5577
  elementId: z3.string().optional()
4924
5578
  })
4925
- )
5579
+ ),
5580
+ reference: z3.object({
5581
+ similarity: z3.number(),
5582
+ verdict: z3.enum(["match", "close", "mismatch"]),
5583
+ regions: z3.array(
5584
+ z3.object({
5585
+ label: z3.string(),
5586
+ similarity: z3.number(),
5587
+ description: z3.string().optional()
5588
+ })
5589
+ )
5590
+ }).optional()
4926
5591
  }),
4927
5592
  examples: [
4928
5593
  {
@@ -4945,14 +5610,16 @@ cli.command("qa", {
4945
5610
  const report = await runQa({
4946
5611
  imagePath: c.options.in,
4947
5612
  spec,
4948
- ...metadata ? { metadata } : {}
5613
+ ...metadata ? { metadata } : {},
5614
+ ...c.options.reference ? { referencePath: c.options.reference } : {}
4949
5615
  });
4950
5616
  const response = {
4951
5617
  pass: report.pass,
4952
5618
  checkedAt: report.checkedAt,
4953
5619
  imagePath: report.imagePath,
4954
5620
  issueCount: report.issues.length,
4955
- issues: report.issues
5621
+ issues: report.issues,
5622
+ ...report.reference ? { reference: report.reference } : {}
4956
5623
  };
4957
5624
  if (!report.pass) {
4958
5625
  return c.error({