@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/index.js CHANGED
@@ -1,4 +1,152 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropNames = Object.getOwnPropertyNames;
3
+ var __esm = (fn, res) => function __init() {
4
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
5
+ };
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+
11
+ // src/compare.ts
12
+ var compare_exports = {};
13
+ __export(compare_exports, {
14
+ compareImages: () => compareImages
15
+ });
16
+ import sharp from "sharp";
17
+ function clampUnit(value) {
18
+ if (value < 0) {
19
+ return 0;
20
+ }
21
+ if (value > 1) {
22
+ return 1;
23
+ }
24
+ return value;
25
+ }
26
+ function toRegionLabel(row, column) {
27
+ const letter = String.fromCharCode(65 + row);
28
+ return `${letter}${column + 1}`;
29
+ }
30
+ function validateGrid(grid) {
31
+ if (!Number.isInteger(grid) || grid <= 0) {
32
+ throw new Error(`Invalid grid value "${grid}". Expected a positive integer.`);
33
+ }
34
+ if (grid > 26) {
35
+ throw new Error(`Invalid grid value "${grid}". Maximum supported grid is 26.`);
36
+ }
37
+ return grid;
38
+ }
39
+ function validateThreshold(threshold) {
40
+ if (!Number.isFinite(threshold) || threshold < 0 || threshold > 1) {
41
+ throw new Error(`Invalid threshold value "${threshold}". Expected a number between 0 and 1.`);
42
+ }
43
+ return threshold;
44
+ }
45
+ async function readDimensions(path) {
46
+ const metadata = await sharp(path).metadata();
47
+ if (!metadata.width || !metadata.height) {
48
+ throw new Error(`Unable to read image dimensions for "${path}".`);
49
+ }
50
+ return {
51
+ width: metadata.width,
52
+ height: metadata.height
53
+ };
54
+ }
55
+ async function normalizeToRaw(path, width, height) {
56
+ const normalized = await sharp(path).rotate().resize(width, height, {
57
+ fit: "contain",
58
+ position: "centre",
59
+ background: { r: 0, g: 0, b: 0, alpha: 0 }
60
+ }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
61
+ return {
62
+ data: normalized.data,
63
+ width: normalized.info.width,
64
+ height: normalized.info.height
65
+ };
66
+ }
67
+ function scorePixelDifference(a, b, offset) {
68
+ const redDiff = Math.abs(a.data[offset] - b.data[offset]);
69
+ const greenDiff = Math.abs(a.data[offset + 1] - b.data[offset + 1]);
70
+ const blueDiff = Math.abs(a.data[offset + 2] - b.data[offset + 2]);
71
+ const alphaDiff = Math.abs(a.data[offset + 3] - b.data[offset + 3]);
72
+ const rgbDelta = (redDiff + greenDiff + blueDiff) / (3 * 255);
73
+ const alphaDelta = alphaDiff / 255;
74
+ return rgbDelta * 0.75 + alphaDelta * 0.25;
75
+ }
76
+ async function compareImages(target, rendered, options = {}) {
77
+ const grid = validateGrid(options.grid ?? DEFAULT_GRID);
78
+ const threshold = validateThreshold(options.threshold ?? DEFAULT_THRESHOLD);
79
+ const closeThreshold = clampUnit(threshold - (options.closeMargin ?? DEFAULT_CLOSE_MARGIN));
80
+ const targetDimensions = await readDimensions(target);
81
+ const renderedDimensions = await readDimensions(rendered);
82
+ const normalizedWidth = Math.max(targetDimensions.width, renderedDimensions.width);
83
+ const normalizedHeight = Math.max(targetDimensions.height, renderedDimensions.height);
84
+ const [targetImage, renderedImage] = await Promise.all([
85
+ normalizeToRaw(target, normalizedWidth, normalizedHeight),
86
+ normalizeToRaw(rendered, normalizedWidth, normalizedHeight)
87
+ ]);
88
+ const regionDiffSums = new Array(grid * grid).fill(0);
89
+ const regionCounts = new Array(grid * grid).fill(0);
90
+ let totalDiff = 0;
91
+ for (let y = 0; y < normalizedHeight; y += 1) {
92
+ const row = Math.min(Math.floor(y * grid / normalizedHeight), grid - 1);
93
+ for (let x = 0; x < normalizedWidth; x += 1) {
94
+ const column = Math.min(Math.floor(x * grid / normalizedWidth), grid - 1);
95
+ const regionIndex = row * grid + column;
96
+ const offset = (y * normalizedWidth + x) * 4;
97
+ const diff = scorePixelDifference(targetImage, renderedImage, offset);
98
+ totalDiff += diff;
99
+ regionDiffSums[regionIndex] += diff;
100
+ regionCounts[regionIndex] += 1;
101
+ }
102
+ }
103
+ const pixelCount = normalizedWidth * normalizedHeight;
104
+ const similarity = clampUnit(1 - totalDiff / pixelCount);
105
+ const regions = [];
106
+ for (let row = 0; row < grid; row += 1) {
107
+ for (let column = 0; column < grid; column += 1) {
108
+ const regionIndex = row * grid + column;
109
+ const regionCount = regionCounts[regionIndex];
110
+ const regionSimilarity = regionCount > 0 ? clampUnit(1 - regionDiffSums[regionIndex] / regionCount) : 1;
111
+ regions.push({
112
+ label: toRegionLabel(row, column),
113
+ row,
114
+ column,
115
+ similarity: regionSimilarity
116
+ });
117
+ }
118
+ }
119
+ const verdict = similarity >= threshold ? "match" : similarity >= closeThreshold ? "close" : "mismatch";
120
+ return {
121
+ targetPath: target,
122
+ renderedPath: rendered,
123
+ targetDimensions,
124
+ renderedDimensions,
125
+ normalizedDimensions: {
126
+ width: normalizedWidth,
127
+ height: normalizedHeight
128
+ },
129
+ dimensionMismatch: targetDimensions.width !== renderedDimensions.width || targetDimensions.height !== renderedDimensions.height,
130
+ grid,
131
+ threshold,
132
+ closeThreshold,
133
+ similarity,
134
+ verdict,
135
+ regions
136
+ };
137
+ }
138
+ var DEFAULT_GRID, DEFAULT_THRESHOLD, DEFAULT_CLOSE_MARGIN;
139
+ var init_compare = __esm({
140
+ "src/compare.ts"() {
141
+ "use strict";
142
+ DEFAULT_GRID = 3;
143
+ DEFAULT_THRESHOLD = 0.8;
144
+ DEFAULT_CLOSE_MARGIN = 0.1;
145
+ }
146
+ });
147
+
1
148
  // src/cli.ts
149
+ init_compare();
2
150
  import { readFileSync, realpathSync } from "fs";
3
151
  import { mkdir as mkdir2, readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
4
152
  import { basename as basename4, dirname as dirname3, resolve as resolve4 } from "path";
@@ -196,7 +344,7 @@ async function publishToGitHub(options) {
196
344
  // src/qa.ts
197
345
  import { readFile as readFile3 } from "fs/promises";
198
346
  import { resolve } from "path";
199
- import sharp from "sharp";
347
+ import sharp2 from "sharp";
200
348
 
201
349
  // src/code-style.ts
202
350
  var CARBON_SURROUND_COLOR = "rgba(171, 184, 195, 1)";
@@ -336,6 +484,10 @@ function contrastRatio(foreground, background) {
336
484
  const darker = Math.min(fg, bg);
337
485
  return (lighter + 0.05) / (darker + 0.05);
338
486
  }
487
+ function withAlpha(hexColor, opacity) {
488
+ const rgb = parseHexColor(hexColor);
489
+ return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${opacity})`;
490
+ }
339
491
  function blendColorWithOpacity(foreground, background, opacity) {
340
492
  const fg = parseHexColor(foreground);
341
493
  const bg = parseHexColor(background);
@@ -709,6 +861,15 @@ var drawGradientRectSchema = z2.object({
709
861
  radius: z2.number().min(0).max(256).default(0),
710
862
  opacity: z2.number().min(0).max(1).default(1)
711
863
  }).strict();
864
+ var drawGridSchema = z2.object({
865
+ type: z2.literal("grid"),
866
+ spacing: z2.number().min(5).max(200).default(40),
867
+ color: colorHexSchema2.default("#1E2D4A"),
868
+ width: z2.number().min(0.1).max(4).default(0.5),
869
+ opacity: z2.number().min(0).max(1).default(0.2),
870
+ offsetX: z2.number().default(0),
871
+ offsetY: z2.number().default(0)
872
+ }).strict();
712
873
  var drawCommandSchema = z2.discriminatedUnion("type", [
713
874
  drawRectSchema,
714
875
  drawCircleSchema,
@@ -717,7 +878,8 @@ var drawCommandSchema = z2.discriminatedUnion("type", [
717
878
  drawBezierSchema,
718
879
  drawPathSchema,
719
880
  drawBadgeSchema,
720
- drawGradientRectSchema
881
+ drawGradientRectSchema,
882
+ drawGridSchema
721
883
  ]);
722
884
  var defaultCanvas = {
723
885
  width: 1200,
@@ -782,10 +944,26 @@ var cardElementSchema = z2.object({
782
944
  tone: z2.enum(["neutral", "accent", "success", "warning", "error"]).default("neutral"),
783
945
  icon: z2.string().min(1).max(64).optional()
784
946
  }).strict();
947
+ var flowNodeShadowSchema = z2.object({
948
+ color: colorHexSchema2.optional(),
949
+ blur: z2.number().min(0).max(64).default(8),
950
+ offsetX: z2.number().min(-32).max(32).default(0),
951
+ offsetY: z2.number().min(-32).max(32).default(0),
952
+ opacity: z2.number().min(0).max(1).default(0.3)
953
+ }).strict();
785
954
  var flowNodeElementSchema = z2.object({
786
955
  type: z2.literal("flow-node"),
787
956
  id: z2.string().min(1).max(120),
788
- shape: z2.enum(["box", "rounded-box", "diamond", "circle", "pill", "cylinder", "parallelogram"]),
957
+ shape: z2.enum([
958
+ "box",
959
+ "rounded-box",
960
+ "diamond",
961
+ "circle",
962
+ "pill",
963
+ "cylinder",
964
+ "parallelogram",
965
+ "hexagon"
966
+ ]).default("rounded-box"),
789
967
  label: z2.string().min(1).max(200),
790
968
  sublabel: z2.string().min(1).max(300).optional(),
791
969
  sublabelColor: colorHexSchema2.optional(),
@@ -805,20 +983,35 @@ var flowNodeElementSchema = z2.object({
805
983
  badgeText: z2.string().min(1).max(32).optional(),
806
984
  badgeColor: colorHexSchema2.optional(),
807
985
  badgeBackground: colorHexSchema2.optional(),
808
- badgePosition: z2.enum(["top", "inside-top"]).default("inside-top")
986
+ badgePosition: z2.enum(["top", "inside-top"]).default("inside-top"),
987
+ shadow: flowNodeShadowSchema.optional()
809
988
  }).strict();
989
+ var anchorHintSchema = z2.union([
990
+ z2.enum(["top", "bottom", "left", "right", "center"]),
991
+ z2.object({
992
+ x: z2.number().min(-1).max(1),
993
+ y: z2.number().min(-1).max(1)
994
+ }).strict()
995
+ ]);
810
996
  var connectionElementSchema = z2.object({
811
997
  type: z2.literal("connection"),
812
998
  from: z2.string().min(1).max(120),
813
999
  to: z2.string().min(1).max(120),
814
1000
  style: z2.enum(["solid", "dashed", "dotted"]).default("solid"),
1001
+ strokeStyle: z2.enum(["solid", "dashed", "dotted"]).default("solid"),
815
1002
  arrow: z2.enum(["end", "start", "both", "none"]).default("end"),
816
1003
  label: z2.string().min(1).max(200).optional(),
817
1004
  labelPosition: z2.enum(["start", "middle", "end"]).default("middle"),
818
1005
  color: colorHexSchema2.optional(),
819
- width: z2.number().min(0.5).max(8).optional(),
1006
+ width: z2.number().min(0.5).max(10).optional(),
1007
+ strokeWidth: z2.number().min(0.5).max(10).default(2),
820
1008
  arrowSize: z2.number().min(4).max(32).optional(),
821
- opacity: z2.number().min(0).max(1).default(1)
1009
+ arrowPlacement: z2.enum(["endpoint", "boundary"]).default("endpoint"),
1010
+ opacity: z2.number().min(0).max(1).default(1),
1011
+ routing: z2.enum(["auto", "orthogonal", "curve", "arc"]).default("auto"),
1012
+ tension: z2.number().min(0.1).max(0.8).default(0.35),
1013
+ fromAnchor: anchorHintSchema.optional(),
1014
+ toAnchor: anchorHintSchema.optional()
822
1015
  }).strict();
823
1016
  var codeBlockStyleSchema = z2.object({
824
1017
  paddingVertical: z2.number().min(0).max(128).default(56),
@@ -887,6 +1080,10 @@ var elementSchema = z2.discriminatedUnion("type", [
887
1080
  shapeElementSchema,
888
1081
  imageElementSchema
889
1082
  ]);
1083
+ var diagramCenterSchema = z2.object({
1084
+ x: z2.number(),
1085
+ y: z2.number()
1086
+ }).strict();
890
1087
  var autoLayoutConfigSchema = z2.object({
891
1088
  mode: z2.literal("auto"),
892
1089
  algorithm: z2.enum(["layered", "stress", "force", "radial", "box"]).default("layered"),
@@ -902,7 +1099,9 @@ var autoLayoutConfigSchema = z2.object({
902
1099
  /** Compaction strategy for radial layout. Only relevant when algorithm is 'radial'. */
903
1100
  radialCompaction: z2.enum(["none", "radial", "wedge"]).optional(),
904
1101
  /** Sort strategy for radial layout node ordering. Only relevant when algorithm is 'radial'. */
905
- radialSortBy: z2.enum(["id", "connections"]).optional()
1102
+ radialSortBy: z2.enum(["id", "connections"]).optional(),
1103
+ /** Explicit center used by curve/arc connection routing. */
1104
+ diagramCenter: diagramCenterSchema.optional()
906
1105
  }).strict();
907
1106
  var gridLayoutConfigSchema = z2.object({
908
1107
  mode: z2.literal("grid"),
@@ -910,13 +1109,17 @@ var gridLayoutConfigSchema = z2.object({
910
1109
  gap: z2.number().int().min(0).max(256).default(24),
911
1110
  cardMinHeight: z2.number().int().min(32).max(4096).optional(),
912
1111
  cardMaxHeight: z2.number().int().min(32).max(4096).optional(),
913
- equalHeight: z2.boolean().default(false)
1112
+ equalHeight: z2.boolean().default(false),
1113
+ /** Explicit center used by curve/arc connection routing. */
1114
+ diagramCenter: diagramCenterSchema.optional()
914
1115
  }).strict();
915
1116
  var stackLayoutConfigSchema = z2.object({
916
1117
  mode: z2.literal("stack"),
917
1118
  direction: z2.enum(["vertical", "horizontal"]).default("vertical"),
918
1119
  gap: z2.number().int().min(0).max(256).default(24),
919
- alignment: z2.enum(["start", "center", "end", "stretch"]).default("stretch")
1120
+ alignment: z2.enum(["start", "center", "end", "stretch"]).default("stretch"),
1121
+ /** Explicit center used by curve/arc connection routing. */
1122
+ diagramCenter: diagramCenterSchema.optional()
920
1123
  }).strict();
921
1124
  var manualPositionSchema = z2.object({
922
1125
  x: z2.number().int(),
@@ -926,7 +1129,9 @@ var manualPositionSchema = z2.object({
926
1129
  }).strict();
927
1130
  var manualLayoutConfigSchema = z2.object({
928
1131
  mode: z2.literal("manual"),
929
- positions: z2.record(z2.string().min(1), manualPositionSchema).default({})
1132
+ positions: z2.record(z2.string().min(1), manualPositionSchema).default({}),
1133
+ /** Explicit center used by curve/arc connection routing. */
1134
+ diagramCenter: diagramCenterSchema.optional()
930
1135
  }).strict();
931
1136
  var layoutConfigSchema = z2.discriminatedUnion("mode", [
932
1137
  autoLayoutConfigSchema,
@@ -978,6 +1183,31 @@ var canvasSchema = z2.object({
978
1183
  padding: z2.number().int().min(0).max(256).default(defaultCanvas.padding)
979
1184
  }).strict();
980
1185
  var themeInputSchema = z2.union([builtInThemeSchema, themeSchema]);
1186
+ var diagramPositionSchema = z2.object({
1187
+ x: z2.number(),
1188
+ y: z2.number(),
1189
+ width: z2.number().positive(),
1190
+ height: z2.number().positive()
1191
+ }).strict();
1192
+ var diagramElementSchema = z2.discriminatedUnion("type", [
1193
+ flowNodeElementSchema,
1194
+ connectionElementSchema
1195
+ ]);
1196
+ var diagramLayoutSchema = z2.object({
1197
+ mode: z2.enum(["manual", "auto"]).default("manual"),
1198
+ positions: z2.record(z2.string(), diagramPositionSchema).optional(),
1199
+ diagramCenter: diagramCenterSchema.optional()
1200
+ }).strict();
1201
+ var diagramSpecSchema = z2.object({
1202
+ version: z2.literal(1),
1203
+ canvas: z2.object({
1204
+ width: z2.number().int().min(320).max(4096).default(1200),
1205
+ height: z2.number().int().min(180).max(4096).default(675)
1206
+ }).default({ width: 1200, height: 675 }),
1207
+ theme: themeSchema.optional(),
1208
+ elements: z2.array(diagramElementSchema).min(1),
1209
+ layout: diagramLayoutSchema.default({ mode: "manual" })
1210
+ }).strict();
981
1211
  var designSpecSchema = z2.object({
982
1212
  version: z2.literal(2).default(2),
983
1213
  canvas: canvasSchema.default(defaultCanvas),
@@ -1002,6 +1232,9 @@ function deriveSafeFrame(spec) {
1002
1232
  height: spec.canvas.height - spec.canvas.padding * 2
1003
1233
  };
1004
1234
  }
1235
+ function parseDiagramSpec(input) {
1236
+ return diagramSpecSchema.parse(input);
1237
+ }
1005
1238
  function parseDesignSpec(input) {
1006
1239
  return designSpecSchema.parse(input);
1007
1240
  }
@@ -1051,7 +1284,7 @@ async function runQa(options) {
1051
1284
  const imagePath = resolve(options.imagePath);
1052
1285
  const expectedSafeFrame = deriveSafeFrame(spec);
1053
1286
  const expectedCanvas = canvasRect(spec);
1054
- const imageMetadata = await sharp(imagePath).metadata();
1287
+ const imageMetadata = await sharp2(imagePath).metadata();
1055
1288
  const issues = [];
1056
1289
  const expectedScale = options.metadata?.canvas.scale ?? resolveRenderScale(spec);
1057
1290
  const expectedWidth = spec.canvas.width * expectedScale;
@@ -1202,6 +1435,31 @@ async function runQa(options) {
1202
1435
  });
1203
1436
  }
1204
1437
  }
1438
+ let referenceResult;
1439
+ if (options.referencePath) {
1440
+ const { compareImages: compareImages2 } = await Promise.resolve().then(() => (init_compare(), compare_exports));
1441
+ const comparison = await compareImages2(options.referencePath, imagePath);
1442
+ referenceResult = {
1443
+ similarity: comparison.similarity,
1444
+ verdict: comparison.verdict,
1445
+ regions: comparison.regions.map((region) => ({
1446
+ label: region.label,
1447
+ similarity: region.similarity
1448
+ }))
1449
+ };
1450
+ if (comparison.verdict === "mismatch") {
1451
+ const severity = comparison.similarity < 0.5 ? "error" : "warning";
1452
+ issues.push({
1453
+ code: "REFERENCE_MISMATCH",
1454
+ severity,
1455
+ message: `Reference image comparison ${severity === "error" ? "failed" : "warned"}: similarity ${comparison.similarity.toFixed(4)} with verdict "${comparison.verdict}".`,
1456
+ details: {
1457
+ similarity: comparison.similarity,
1458
+ verdict: comparison.verdict
1459
+ }
1460
+ });
1461
+ }
1462
+ }
1205
1463
  const footerSpacingPx = options.metadata?.layout.elements ? (() => {
1206
1464
  const footer = options.metadata.layout.elements.find((element) => element.id === "footer");
1207
1465
  if (!footer) {
@@ -1234,7 +1492,8 @@ async function runQa(options) {
1234
1492
  ...imageMetadata.height !== void 0 ? { height: imageMetadata.height } : {},
1235
1493
  ...footerSpacingPx !== void 0 ? { footerSpacingPx } : {}
1236
1494
  },
1237
- issues
1495
+ issues,
1496
+ ...referenceResult ? { reference: referenceResult } : {}
1238
1497
  };
1239
1498
  }
1240
1499
 
@@ -1301,9 +1560,9 @@ function drawRoundedRect(ctx, rect, radius, fill, stroke) {
1301
1560
  roundRectPath(ctx, rect, radius);
1302
1561
  fillAndStroke(ctx, fill, stroke);
1303
1562
  }
1304
- function drawCircle(ctx, center2, radius, fill, stroke) {
1563
+ function drawCircle(ctx, center, radius, fill, stroke) {
1305
1564
  ctx.beginPath();
1306
- ctx.arc(center2.x, center2.y, Math.max(0, radius), 0, Math.PI * 2);
1565
+ ctx.arc(center.x, center.y, Math.max(0, radius), 0, Math.PI * 2);
1307
1566
  ctx.closePath();
1308
1567
  fillAndStroke(ctx, fill, stroke);
1309
1568
  }
@@ -1547,15 +1806,34 @@ function renderFlowNode(ctx, node, bounds, theme) {
1547
1806
  const badgeBackground = node.badgeBackground ?? borderColor ?? theme.accent;
1548
1807
  ctx.save();
1549
1808
  ctx.lineWidth = borderWidth;
1809
+ if (node.shadow) {
1810
+ const shadowColor = node.shadow.color ?? borderColor ?? theme.accent;
1811
+ ctx.shadowColor = withAlpha(shadowColor, node.shadow.opacity);
1812
+ ctx.shadowBlur = node.shadow.blur;
1813
+ ctx.shadowOffsetX = node.shadow.offsetX;
1814
+ ctx.shadowOffsetY = node.shadow.offsetY;
1815
+ }
1550
1816
  if (fillOpacity < 1) {
1551
1817
  ctx.globalAlpha = node.opacity * fillOpacity;
1552
1818
  drawNodeShape(ctx, node.shape, bounds, fillColor, void 0, cornerRadius);
1819
+ if (node.shadow) {
1820
+ ctx.shadowColor = "transparent";
1821
+ ctx.shadowBlur = 0;
1822
+ ctx.shadowOffsetX = 0;
1823
+ ctx.shadowOffsetY = 0;
1824
+ }
1553
1825
  ctx.globalAlpha = node.opacity;
1554
1826
  drawNodeShape(ctx, node.shape, bounds, "rgba(0,0,0,0)", borderColor, cornerRadius);
1555
1827
  } else {
1556
1828
  ctx.globalAlpha = node.opacity;
1557
1829
  drawNodeShape(ctx, node.shape, bounds, fillColor, borderColor, cornerRadius);
1558
1830
  }
1831
+ if (node.shadow) {
1832
+ ctx.shadowColor = "transparent";
1833
+ ctx.shadowBlur = 0;
1834
+ ctx.shadowOffsetX = 0;
1835
+ ctx.shadowOffsetY = 0;
1836
+ }
1559
1837
  const headingFont = resolveFont(theme.fonts.heading, "heading");
1560
1838
  const bodyFont = resolveFont(theme.fonts.body, "body");
1561
1839
  const monoFont = resolveFont(theme.fonts.mono, "mono");
@@ -2227,7 +2505,7 @@ function parseHexColor2(color) {
2227
2505
  a: normalized.length === 8 ? parseChannel2(6) / 255 : 1
2228
2506
  };
2229
2507
  }
2230
- function withAlpha(color, alpha) {
2508
+ function withAlpha2(color, alpha) {
2231
2509
  const parsed = parseHexColor2(color);
2232
2510
  const effectiveAlpha = clamp01(parsed.a * alpha);
2233
2511
  return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${effectiveAlpha})`;
@@ -2284,9 +2562,9 @@ function drawVignette(ctx, width, height, intensity = 0.3, color = "#000000") {
2284
2562
  centerY,
2285
2563
  outerRadius
2286
2564
  );
2287
- vignette.addColorStop(0, withAlpha(color, 0));
2288
- vignette.addColorStop(0.6, withAlpha(color, 0));
2289
- vignette.addColorStop(1, withAlpha(color, clamp01(intensity)));
2565
+ vignette.addColorStop(0, withAlpha2(color, 0));
2566
+ vignette.addColorStop(0.6, withAlpha2(color, 0));
2567
+ vignette.addColorStop(1, withAlpha2(color, clamp01(intensity)));
2290
2568
  ctx.save();
2291
2569
  ctx.fillStyle = vignette;
2292
2570
  ctx.fillRect(0, 0, width, height);
@@ -2417,12 +2695,12 @@ var MACOS_DOTS = [
2417
2695
  { fill: "#27C93F", stroke: "#1AAB29" }
2418
2696
  ];
2419
2697
  function drawMacosDots(ctx, x, y) {
2420
- for (const [index, dot] of MACOS_DOTS.entries()) {
2698
+ for (const [index, dot2] of MACOS_DOTS.entries()) {
2421
2699
  ctx.beginPath();
2422
2700
  ctx.arc(x + index * DOT_SPACING, y, DOT_RADIUS, 0, Math.PI * 2);
2423
2701
  ctx.closePath();
2424
- ctx.fillStyle = dot.fill;
2425
- ctx.strokeStyle = dot.stroke;
2702
+ ctx.fillStyle = dot2.fill;
2703
+ ctx.strokeStyle = dot2.stroke;
2426
2704
  ctx.lineWidth = DOT_STROKE_WIDTH;
2427
2705
  ctx.fill();
2428
2706
  ctx.stroke();
@@ -2843,25 +3121,203 @@ function drawOrthogonalPath(ctx, from, to, style) {
2843
3121
  }
2844
3122
 
2845
3123
  // src/renderers/connection.ts
2846
- function center(rect) {
3124
+ var ELLIPSE_KAPPA = 4 * (Math.sqrt(2) - 1) / 3;
3125
+ function rectCenter(rect) {
2847
3126
  return {
2848
3127
  x: rect.x + rect.width / 2,
2849
3128
  y: rect.y + rect.height / 2
2850
3129
  };
2851
3130
  }
2852
- function edgeAnchor(rect, target) {
2853
- const c = center(rect);
3131
+ function edgeAnchor(bounds, target) {
3132
+ const c = rectCenter(bounds);
2854
3133
  const dx = target.x - c.x;
2855
3134
  const dy = target.y - c.y;
2856
- if (Math.abs(dx) >= Math.abs(dy)) {
2857
- return {
2858
- x: dx >= 0 ? rect.x + rect.width : rect.x,
2859
- y: c.y
2860
- };
3135
+ if (dx === 0 && dy === 0) {
3136
+ return { x: c.x, y: c.y - bounds.height / 2 };
3137
+ }
3138
+ const hw = bounds.width / 2;
3139
+ const hh = bounds.height / 2;
3140
+ const absDx = Math.abs(dx);
3141
+ const absDy = Math.abs(dy);
3142
+ const t = absDx * hh > absDy * hw ? hw / absDx : hh / absDy;
3143
+ return { x: c.x + dx * t, y: c.y + dy * t };
3144
+ }
3145
+ function resolveAnchor(bounds, anchor, fallbackTarget) {
3146
+ if (!anchor) return edgeAnchor(bounds, fallbackTarget);
3147
+ if (typeof anchor === "string") {
3148
+ const c2 = rectCenter(bounds);
3149
+ switch (anchor) {
3150
+ case "top":
3151
+ return { x: c2.x, y: bounds.y };
3152
+ case "bottom":
3153
+ return { x: c2.x, y: bounds.y + bounds.height };
3154
+ case "left":
3155
+ return { x: bounds.x, y: c2.y };
3156
+ case "right":
3157
+ return { x: bounds.x + bounds.width, y: c2.y };
3158
+ case "center":
3159
+ return c2;
3160
+ }
3161
+ }
3162
+ const c = rectCenter(bounds);
3163
+ return {
3164
+ x: c.x + anchor.x * (bounds.width / 2),
3165
+ y: c.y + anchor.y * (bounds.height / 2)
3166
+ };
3167
+ }
3168
+ function anchorNormal(anchor, point, diagramCenter) {
3169
+ if (typeof anchor === "string") {
3170
+ switch (anchor) {
3171
+ case "top":
3172
+ return { x: 0, y: -1 };
3173
+ case "bottom":
3174
+ return { x: 0, y: 1 };
3175
+ case "left":
3176
+ return { x: -1, y: 0 };
3177
+ case "right":
3178
+ return { x: 1, y: 0 };
3179
+ case "center":
3180
+ return outwardNormal(point, diagramCenter);
3181
+ }
2861
3182
  }
3183
+ return outwardNormal(point, diagramCenter);
3184
+ }
3185
+ function outwardNormal(point, diagramCenter) {
3186
+ const dx = point.x - diagramCenter.x;
3187
+ const dy = point.y - diagramCenter.y;
3188
+ const len = Math.hypot(dx, dy) || 1;
3189
+ return { x: dx / len, y: dy / len };
3190
+ }
3191
+ function curveRoute(fromBounds, toBounds, diagramCenter, tension, fromAnchor, toAnchor) {
3192
+ const fromCenter = rectCenter(fromBounds);
3193
+ const toCenter = rectCenter(toBounds);
3194
+ const p0 = resolveAnchor(fromBounds, fromAnchor, toCenter);
3195
+ const p3 = resolveAnchor(toBounds, toAnchor, fromCenter);
3196
+ const dist = Math.hypot(p3.x - p0.x, p3.y - p0.y);
3197
+ const offset = dist * tension;
3198
+ const n0 = anchorNormal(fromAnchor, p0, diagramCenter);
3199
+ const n3 = anchorNormal(toAnchor, p3, diagramCenter);
3200
+ const cp1 = { x: p0.x + n0.x * offset, y: p0.y + n0.y * offset };
3201
+ const cp2 = { x: p3.x + n3.x * offset, y: p3.y + n3.y * offset };
3202
+ return [p0, cp1, cp2, p3];
3203
+ }
3204
+ function dot(a, b) {
3205
+ return a.x * b.x + a.y * b.y;
3206
+ }
3207
+ function localToWorld(origin, axisX, axisY, local) {
2862
3208
  return {
2863
- x: c.x,
2864
- y: dy >= 0 ? rect.y + rect.height : rect.y
3209
+ x: origin.x + axisX.x * local.x + axisY.x * local.y,
3210
+ y: origin.y + axisX.y * local.x + axisY.y * local.y
3211
+ };
3212
+ }
3213
+ function arcRoute(fromBounds, toBounds, diagramCenter, tension, fromAnchor, toAnchor) {
3214
+ const fromCenter = rectCenter(fromBounds);
3215
+ const toCenter = rectCenter(toBounds);
3216
+ const start = resolveAnchor(fromBounds, fromAnchor, toCenter);
3217
+ const end = resolveAnchor(toBounds, toAnchor, fromCenter);
3218
+ const chord = { x: end.x - start.x, y: end.y - start.y };
3219
+ const chordLength = Math.hypot(chord.x, chord.y);
3220
+ if (chordLength < 1e-6) {
3221
+ const mid = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
3222
+ return [
3223
+ [start, start, mid, mid],
3224
+ [mid, mid, end, end]
3225
+ ];
3226
+ }
3227
+ const axisX = { x: chord.x / chordLength, y: chord.y / chordLength };
3228
+ let axisY = { x: -axisX.y, y: axisX.x };
3229
+ const midpoint = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
3230
+ const outwardHint = outwardNormal(midpoint, diagramCenter);
3231
+ if (dot(axisY, outwardHint) < 0) {
3232
+ axisY = { x: -axisY.x, y: -axisY.y };
3233
+ }
3234
+ const semiMajor = chordLength / 2;
3235
+ const semiMinor = Math.max(12, chordLength * tension * 0.75);
3236
+ const p0Local = { x: -semiMajor, y: 0 };
3237
+ const cp1Local = { x: -semiMajor, y: ELLIPSE_KAPPA * semiMinor };
3238
+ const cp2Local = { x: -ELLIPSE_KAPPA * semiMajor, y: semiMinor };
3239
+ const pMidLocal = { x: 0, y: semiMinor };
3240
+ const cp3Local = { x: ELLIPSE_KAPPA * semiMajor, y: semiMinor };
3241
+ const cp4Local = { x: semiMajor, y: ELLIPSE_KAPPA * semiMinor };
3242
+ const p3Local = { x: semiMajor, y: 0 };
3243
+ const p0 = localToWorld(midpoint, axisX, axisY, p0Local);
3244
+ const cp1 = localToWorld(midpoint, axisX, axisY, cp1Local);
3245
+ const cp2 = localToWorld(midpoint, axisX, axisY, cp2Local);
3246
+ const pMid = localToWorld(midpoint, axisX, axisY, pMidLocal);
3247
+ const cp3 = localToWorld(midpoint, axisX, axisY, cp3Local);
3248
+ const cp4 = localToWorld(midpoint, axisX, axisY, cp4Local);
3249
+ const p3 = localToWorld(midpoint, axisX, axisY, p3Local);
3250
+ return [
3251
+ [p0, cp1, cp2, pMid],
3252
+ [pMid, cp3, cp4, p3]
3253
+ ];
3254
+ }
3255
+ function orthogonalRoute(fromBounds, toBounds, fromAnchor, toAnchor) {
3256
+ const fromC = rectCenter(fromBounds);
3257
+ const toC = rectCenter(toBounds);
3258
+ const p0 = resolveAnchor(fromBounds, fromAnchor, toC);
3259
+ const p3 = resolveAnchor(toBounds, toAnchor, fromC);
3260
+ const midX = (p0.x + p3.x) / 2;
3261
+ return [p0, { x: midX, y: p0.y }, { x: midX, y: p3.y }, p3];
3262
+ }
3263
+ function bezierPointAt(p0, cp1, cp2, p3, t) {
3264
+ const mt = 1 - t;
3265
+ return {
3266
+ x: mt * mt * mt * p0.x + 3 * mt * mt * t * cp1.x + 3 * mt * t * t * cp2.x + t * t * t * p3.x,
3267
+ y: mt * mt * mt * p0.y + 3 * mt * mt * t * cp1.y + 3 * mt * t * t * cp2.y + t * t * t * p3.y
3268
+ };
3269
+ }
3270
+ function bezierTangentAt(p0, cp1, cp2, p3, t) {
3271
+ const mt = 1 - t;
3272
+ return {
3273
+ x: 3 * mt * mt * (cp1.x - p0.x) + 6 * mt * t * (cp2.x - cp1.x) + 3 * t * t * (p3.x - cp2.x),
3274
+ y: 3 * mt * mt * (cp1.y - p0.y) + 6 * mt * t * (cp2.y - cp1.y) + 3 * t * t * (p3.y - cp2.y)
3275
+ };
3276
+ }
3277
+ function isInsideRect(point, rect) {
3278
+ return point.x >= rect.x && point.x <= rect.x + rect.width && point.y >= rect.y && point.y <= rect.y + rect.height;
3279
+ }
3280
+ function findBoundaryIntersection(p0, cp1, cp2, p3, targetRect, searchFromEnd) {
3281
+ const step = 5e-3;
3282
+ if (searchFromEnd) {
3283
+ for (let t = 0.95; t >= 0.5; t -= step) {
3284
+ const pt = bezierPointAt(p0, cp1, cp2, p3, t);
3285
+ if (!isInsideRect(pt, targetRect)) {
3286
+ return t;
3287
+ }
3288
+ }
3289
+ } else {
3290
+ for (let t = 0.05; t <= 0.5; t += step) {
3291
+ const pt = bezierPointAt(p0, cp1, cp2, p3, t);
3292
+ if (!isInsideRect(pt, targetRect)) {
3293
+ return t;
3294
+ }
3295
+ }
3296
+ }
3297
+ return void 0;
3298
+ }
3299
+ function pointAlongArc(route, t) {
3300
+ const [first, second] = route;
3301
+ if (t <= 0.5) {
3302
+ const localT2 = Math.max(0, Math.min(1, t * 2));
3303
+ return bezierPointAt(first[0], first[1], first[2], first[3], localT2);
3304
+ }
3305
+ const localT = Math.max(0, Math.min(1, (t - 0.5) * 2));
3306
+ return bezierPointAt(second[0], second[1], second[2], second[3], localT);
3307
+ }
3308
+ function computeDiagramCenter(nodeBounds, canvasCenter) {
3309
+ if (nodeBounds.length === 0) {
3310
+ return canvasCenter ?? { x: 0, y: 0 };
3311
+ }
3312
+ let totalX = 0;
3313
+ let totalY = 0;
3314
+ for (const bounds of nodeBounds) {
3315
+ totalX += bounds.x + bounds.width / 2;
3316
+ totalY += bounds.y + bounds.height / 2;
3317
+ }
3318
+ return {
3319
+ x: totalX / nodeBounds.length,
3320
+ y: totalY / nodeBounds.length
2865
3321
  };
2866
3322
  }
2867
3323
  function dashFromStyle(style) {
@@ -2945,51 +3401,148 @@ function polylineBounds(points) {
2945
3401
  height: Math.max(1, maxY - minY)
2946
3402
  };
2947
3403
  }
2948
- function renderConnection(ctx, conn, fromBounds, toBounds, theme, edgeRoute) {
2949
- const fromCenter = center(fromBounds);
2950
- const toCenter = center(toBounds);
2951
- const from = edgeAnchor(fromBounds, toCenter);
2952
- const to = edgeAnchor(toBounds, fromCenter);
2953
- const dash = dashFromStyle(conn.style);
3404
+ function renderConnection(ctx, conn, fromBounds, toBounds, theme, edgeRoute, options) {
3405
+ const routing = conn.routing ?? "auto";
3406
+ const strokeStyle = conn.strokeStyle ?? conn.style ?? "solid";
3407
+ const strokeWidth = conn.width ?? conn.strokeWidth ?? 2;
3408
+ const tension = conn.tension ?? 0.35;
3409
+ const dash = dashFromStyle(strokeStyle);
2954
3410
  const style = {
2955
3411
  color: conn.color ?? theme.borderMuted,
2956
- width: conn.width ?? 2,
3412
+ width: strokeWidth,
2957
3413
  headSize: conn.arrowSize ?? 10,
2958
3414
  ...dash ? { dash } : {}
2959
3415
  };
2960
- 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];
2961
- const startSegment = points[1] ?? points[0];
2962
- const endStart = points[points.length - 2] ?? points[0];
2963
- const end = points[points.length - 1] ?? points[0];
2964
- let startAngle = Math.atan2(startSegment.y - points[0].y, startSegment.x - points[0].x) + Math.PI;
2965
- let endAngle = Math.atan2(end.y - endStart.y, end.x - endStart.x);
3416
+ const labelT = conn.labelPosition === "start" ? 0.2 : conn.labelPosition === "end" ? 0.8 : 0.5;
3417
+ const diagramCenter = options?.diagramCenter ?? computeDiagramCenter([fromBounds, toBounds]);
3418
+ let linePoints;
3419
+ let startPoint;
3420
+ let endPoint;
3421
+ let startAngle;
3422
+ let endAngle;
3423
+ let labelPoint;
3424
+ ctx.save();
3425
+ ctx.globalAlpha = conn.opacity;
3426
+ const arrowPlacement = conn.arrowPlacement ?? "endpoint";
3427
+ if (routing === "curve") {
3428
+ const [p0, cp1, cp2, p3] = curveRoute(
3429
+ fromBounds,
3430
+ toBounds,
3431
+ diagramCenter,
3432
+ tension,
3433
+ conn.fromAnchor,
3434
+ conn.toAnchor
3435
+ );
3436
+ ctx.strokeStyle = style.color;
3437
+ ctx.lineWidth = style.width;
3438
+ ctx.setLineDash(style.dash ?? []);
3439
+ ctx.beginPath();
3440
+ ctx.moveTo(p0.x, p0.y);
3441
+ ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, p3.x, p3.y);
3442
+ ctx.stroke();
3443
+ linePoints = [p0, cp1, cp2, p3];
3444
+ startPoint = p0;
3445
+ endPoint = p3;
3446
+ startAngle = Math.atan2(p0.y - cp1.y, p0.x - cp1.x);
3447
+ endAngle = Math.atan2(p3.y - cp2.y, p3.x - cp2.x);
3448
+ labelPoint = bezierPointAt(p0, cp1, cp2, p3, labelT);
3449
+ if (arrowPlacement === "boundary") {
3450
+ if (conn.arrow === "end" || conn.arrow === "both") {
3451
+ const tEnd = findBoundaryIntersection(p0, cp1, cp2, p3, toBounds, true);
3452
+ if (tEnd !== void 0) {
3453
+ endPoint = bezierPointAt(p0, cp1, cp2, p3, tEnd);
3454
+ const tangent = bezierTangentAt(p0, cp1, cp2, p3, tEnd);
3455
+ endAngle = Math.atan2(tangent.y, tangent.x);
3456
+ }
3457
+ }
3458
+ if (conn.arrow === "start" || conn.arrow === "both") {
3459
+ const tStart = findBoundaryIntersection(p0, cp1, cp2, p3, fromBounds, false);
3460
+ if (tStart !== void 0) {
3461
+ startPoint = bezierPointAt(p0, cp1, cp2, p3, tStart);
3462
+ const tangent = bezierTangentAt(p0, cp1, cp2, p3, tStart);
3463
+ startAngle = Math.atan2(tangent.y, tangent.x) + Math.PI;
3464
+ }
3465
+ }
3466
+ }
3467
+ } else if (routing === "arc") {
3468
+ const [first, second] = arcRoute(
3469
+ fromBounds,
3470
+ toBounds,
3471
+ diagramCenter,
3472
+ tension,
3473
+ conn.fromAnchor,
3474
+ conn.toAnchor
3475
+ );
3476
+ const [p0, cp1, cp2, pMid] = first;
3477
+ const [, cp3, cp4, p3] = second;
3478
+ ctx.strokeStyle = style.color;
3479
+ ctx.lineWidth = style.width;
3480
+ ctx.setLineDash(style.dash ?? []);
3481
+ ctx.beginPath();
3482
+ ctx.moveTo(p0.x, p0.y);
3483
+ ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, pMid.x, pMid.y);
3484
+ ctx.bezierCurveTo(cp3.x, cp3.y, cp4.x, cp4.y, p3.x, p3.y);
3485
+ ctx.stroke();
3486
+ linePoints = [p0, cp1, cp2, pMid, cp3, cp4, p3];
3487
+ startPoint = p0;
3488
+ endPoint = p3;
3489
+ startAngle = Math.atan2(p0.y - cp1.y, p0.x - cp1.x);
3490
+ endAngle = Math.atan2(p3.y - cp4.y, p3.x - cp4.x);
3491
+ labelPoint = pointAlongArc([first, second], labelT);
3492
+ if (arrowPlacement === "boundary") {
3493
+ if (conn.arrow === "end" || conn.arrow === "both") {
3494
+ const [, s_cp3, s_cp4, s_p3] = second;
3495
+ const tEnd = findBoundaryIntersection(pMid, s_cp3, s_cp4, s_p3, toBounds, true);
3496
+ if (tEnd !== void 0) {
3497
+ endPoint = bezierPointAt(pMid, s_cp3, s_cp4, s_p3, tEnd);
3498
+ const tangent = bezierTangentAt(pMid, s_cp3, s_cp4, s_p3, tEnd);
3499
+ endAngle = Math.atan2(tangent.y, tangent.x);
3500
+ }
3501
+ }
3502
+ if (conn.arrow === "start" || conn.arrow === "both") {
3503
+ const tStart = findBoundaryIntersection(p0, cp1, cp2, pMid, fromBounds, false);
3504
+ if (tStart !== void 0) {
3505
+ startPoint = bezierPointAt(p0, cp1, cp2, pMid, tStart);
3506
+ const tangent = bezierTangentAt(p0, cp1, cp2, pMid, tStart);
3507
+ startAngle = Math.atan2(tangent.y, tangent.x) + Math.PI;
3508
+ }
3509
+ }
3510
+ }
3511
+ } else {
3512
+ const hasAnchorHints = conn.fromAnchor !== void 0 || conn.toAnchor !== void 0;
3513
+ const useElkRoute = routing === "auto" && !hasAnchorHints && (edgeRoute?.points.length ?? 0) >= 2;
3514
+ linePoints = useElkRoute ? edgeRoute?.points ?? orthogonalRoute(fromBounds, toBounds, conn.fromAnchor, conn.toAnchor) : orthogonalRoute(fromBounds, toBounds, conn.fromAnchor, conn.toAnchor);
3515
+ startPoint = linePoints[0];
3516
+ const startSegment = linePoints[1] ?? linePoints[0];
3517
+ const endStart = linePoints[linePoints.length - 2] ?? linePoints[0];
3518
+ endPoint = linePoints[linePoints.length - 1] ?? linePoints[0];
3519
+ startAngle = Math.atan2(startSegment.y - linePoints[0].y, startSegment.x - linePoints[0].x) + Math.PI;
3520
+ endAngle = Math.atan2(endPoint.y - endStart.y, endPoint.x - endStart.x);
3521
+ if (useElkRoute) {
3522
+ drawCubicInterpolatedPath(ctx, linePoints, style);
3523
+ } else {
3524
+ drawOrthogonalPath(ctx, startPoint, endPoint, style);
3525
+ }
3526
+ labelPoint = pointAlongPolyline(linePoints, labelT);
3527
+ }
2966
3528
  if (!Number.isFinite(startAngle)) {
2967
3529
  startAngle = 0;
2968
3530
  }
2969
3531
  if (!Number.isFinite(endAngle)) {
2970
3532
  endAngle = 0;
2971
3533
  }
2972
- const t = conn.labelPosition === "start" ? 0.2 : conn.labelPosition === "end" ? 0.8 : 0.5;
2973
- const labelPoint = pointAlongPolyline(points, t);
2974
- ctx.save();
2975
- ctx.globalAlpha = conn.opacity;
2976
- if (edgeRoute && edgeRoute.points.length >= 2) {
2977
- drawCubicInterpolatedPath(ctx, points, style);
2978
- } else {
2979
- drawOrthogonalPath(ctx, points[0], points[points.length - 1], style);
2980
- }
2981
3534
  if (conn.arrow === "start" || conn.arrow === "both") {
2982
- drawArrowhead(ctx, points[0], startAngle, style.headSize, style.color);
3535
+ drawArrowhead(ctx, startPoint, startAngle, style.headSize, style.color);
2983
3536
  }
2984
3537
  if (conn.arrow === "end" || conn.arrow === "both") {
2985
- drawArrowhead(ctx, end, endAngle, style.headSize, style.color);
3538
+ drawArrowhead(ctx, endPoint, endAngle, style.headSize, style.color);
2986
3539
  }
2987
3540
  ctx.restore();
2988
3541
  const elements = [
2989
3542
  {
2990
3543
  id: `connection-${conn.from}-${conn.to}`,
2991
3544
  kind: "connection",
2992
- bounds: polylineBounds(points),
3545
+ bounds: polylineBounds(linePoints),
2993
3546
  foregroundColor: style.color
2994
3547
  }
2995
3548
  ];
@@ -3615,6 +4168,36 @@ function renderDrawCommands(ctx, commands, theme) {
3615
4168
  });
3616
4169
  break;
3617
4170
  }
4171
+ case "grid": {
4172
+ const canvasWidth = ctx.canvas.width;
4173
+ const canvasHeight = ctx.canvas.height;
4174
+ withOpacity(ctx, command.opacity, () => {
4175
+ ctx.strokeStyle = command.color;
4176
+ ctx.lineWidth = command.width;
4177
+ const startX = command.offsetX % command.spacing;
4178
+ for (let x = startX; x <= canvasWidth; x += command.spacing) {
4179
+ ctx.beginPath();
4180
+ ctx.moveTo(x, 0);
4181
+ ctx.lineTo(x, canvasHeight);
4182
+ ctx.stroke();
4183
+ }
4184
+ const startY = command.offsetY % command.spacing;
4185
+ for (let y = startY; y <= canvasHeight; y += command.spacing) {
4186
+ ctx.beginPath();
4187
+ ctx.moveTo(0, y);
4188
+ ctx.lineTo(canvasWidth, y);
4189
+ ctx.stroke();
4190
+ }
4191
+ });
4192
+ rendered.push({
4193
+ id,
4194
+ kind: "draw",
4195
+ bounds: { x: 0, y: 0, width: canvasWidth, height: canvasHeight },
4196
+ foregroundColor: command.color,
4197
+ allowOverlap: true
4198
+ });
4199
+ break;
4200
+ }
3618
4201
  }
3619
4202
  }
3620
4203
  return rendered;
@@ -4203,6 +4786,10 @@ async function renderDesign(input, options = {}) {
4203
4786
  break;
4204
4787
  }
4205
4788
  }
4789
+ const diagramCenter = spec.layout.diagramCenter ?? computeDiagramCenter(
4790
+ spec.elements.filter((element) => element.type !== "connection").map((element) => elementRects.get(element.id)).filter((rect) => rect != null),
4791
+ { x: spec.canvas.width / 2, y: spec.canvas.height / 2 }
4792
+ );
4206
4793
  for (const element of spec.elements) {
4207
4794
  if (element.type !== "connection") {
4208
4795
  continue;
@@ -4215,7 +4802,9 @@ async function renderDesign(input, options = {}) {
4215
4802
  );
4216
4803
  }
4217
4804
  const edgeRoute = edgeRoutes?.get(`${element.from}-${element.to}`);
4218
- elements.push(...renderConnection(ctx, element, fromRect, toRect, theme, edgeRoute));
4805
+ elements.push(
4806
+ ...renderConnection(ctx, element, fromRect, toRect, theme, edgeRoute, { diagramCenter })
4807
+ );
4219
4808
  }
4220
4809
  if (footerRect && spec.footer) {
4221
4810
  const footerText = spec.footer.tagline ? `${spec.footer.text} \u2022 ${spec.footer.tagline}` : spec.footer.text;
@@ -4578,6 +5167,36 @@ var renderOutputSchema = z3.object({
4578
5167
  )
4579
5168
  })
4580
5169
  });
5170
+ var compareOutputSchema = z3.object({
5171
+ targetPath: z3.string(),
5172
+ renderedPath: z3.string(),
5173
+ targetDimensions: z3.object({
5174
+ width: z3.number().int().positive(),
5175
+ height: z3.number().int().positive()
5176
+ }),
5177
+ renderedDimensions: z3.object({
5178
+ width: z3.number().int().positive(),
5179
+ height: z3.number().int().positive()
5180
+ }),
5181
+ normalizedDimensions: z3.object({
5182
+ width: z3.number().int().positive(),
5183
+ height: z3.number().int().positive()
5184
+ }),
5185
+ dimensionMismatch: z3.boolean(),
5186
+ grid: z3.number().int().positive(),
5187
+ threshold: z3.number(),
5188
+ closeThreshold: z3.number(),
5189
+ similarity: z3.number(),
5190
+ verdict: z3.enum(["match", "close", "mismatch"]),
5191
+ regions: z3.array(
5192
+ z3.object({
5193
+ label: z3.string(),
5194
+ row: z3.number().int().nonnegative(),
5195
+ column: z3.number().int().nonnegative(),
5196
+ similarity: z3.number()
5197
+ })
5198
+ )
5199
+ });
4581
5200
  async function readJson(path) {
4582
5201
  if (path === "-") {
4583
5202
  const chunks = [];
@@ -4680,6 +5299,44 @@ cli.command("render", {
4680
5299
  return c.ok(runReport);
4681
5300
  }
4682
5301
  });
5302
+ cli.command("compare", {
5303
+ description: "Compare a rendered design against a target image using structural similarity scoring.",
5304
+ options: z3.object({
5305
+ target: z3.string().describe("Path to target image (baseline)"),
5306
+ rendered: z3.string().describe("Path to rendered image to evaluate"),
5307
+ grid: z3.number().int().positive().default(3).describe("Grid size for per-region scoring"),
5308
+ threshold: z3.number().min(0).max(1).default(0.8).describe("Minimum similarity score required for a match verdict")
5309
+ }),
5310
+ output: compareOutputSchema,
5311
+ examples: [
5312
+ {
5313
+ options: {
5314
+ target: "./designs/target.png",
5315
+ rendered: "./output/design-v2-g0.4.0-sabc123.png",
5316
+ grid: 3,
5317
+ threshold: 0.8
5318
+ },
5319
+ description: "Compare two images and report overall + per-region similarity scores"
5320
+ }
5321
+ ],
5322
+ async run(c) {
5323
+ try {
5324
+ return c.ok(
5325
+ await compareImages(c.options.target, c.options.rendered, {
5326
+ grid: c.options.grid,
5327
+ threshold: c.options.threshold
5328
+ })
5329
+ );
5330
+ } catch (error) {
5331
+ const message = error instanceof Error ? error.message : String(error);
5332
+ return c.error({
5333
+ code: "COMPARE_FAILED",
5334
+ message: `Unable to compare images: ${message}`,
5335
+ retryable: false
5336
+ });
5337
+ }
5338
+ }
5339
+ });
4683
5340
  var template = Cli.create("template", {
4684
5341
  description: "Generate common design templates and run the full render \u2192 QA pipeline."
4685
5342
  });
@@ -4921,7 +5578,8 @@ cli.command("qa", {
4921
5578
  options: z3.object({
4922
5579
  in: z3.string().describe("Path to rendered PNG"),
4923
5580
  spec: z3.string().describe("Path to normalized DesignSpec JSON"),
4924
- meta: z3.string().optional().describe("Optional sidecar metadata path (.meta.json)")
5581
+ meta: z3.string().optional().describe("Optional sidecar metadata path (.meta.json)"),
5582
+ reference: z3.string().optional().describe("Optional reference image path for visual comparison")
4925
5583
  }),
4926
5584
  output: z3.object({
4927
5585
  pass: z3.boolean(),
@@ -4935,7 +5593,18 @@ cli.command("qa", {
4935
5593
  message: z3.string(),
4936
5594
  elementId: z3.string().optional()
4937
5595
  })
4938
- )
5596
+ ),
5597
+ reference: z3.object({
5598
+ similarity: z3.number(),
5599
+ verdict: z3.enum(["match", "close", "mismatch"]),
5600
+ regions: z3.array(
5601
+ z3.object({
5602
+ label: z3.string(),
5603
+ similarity: z3.number(),
5604
+ description: z3.string().optional()
5605
+ })
5606
+ )
5607
+ }).optional()
4939
5608
  }),
4940
5609
  examples: [
4941
5610
  {
@@ -4958,14 +5627,16 @@ cli.command("qa", {
4958
5627
  const report = await runQa({
4959
5628
  imagePath: c.options.in,
4960
5629
  spec,
4961
- ...metadata ? { metadata } : {}
5630
+ ...metadata ? { metadata } : {},
5631
+ ...c.options.reference ? { referencePath: c.options.reference } : {}
4962
5632
  });
4963
5633
  const response = {
4964
5634
  pass: report.pass,
4965
5635
  checkedAt: report.checkedAt,
4966
5636
  imagePath: report.imagePath,
4967
5637
  issueCount: report.issues.length,
4968
- issues: report.issues
5638
+ issues: report.issues,
5639
+ ...report.reference ? { reference: report.reference } : {}
4969
5640
  };
4970
5641
  if (!report.pass) {
4971
5642
  return c.error({
@@ -5099,9 +5770,14 @@ var isMain = (() => {
5099
5770
  if (isMain) {
5100
5771
  cli.serve();
5101
5772
  }
5773
+
5774
+ // src/index.ts
5775
+ init_compare();
5102
5776
  export {
5103
5777
  DEFAULT_GENERATOR_VERSION,
5104
5778
  DEFAULT_RAINBOW_COLORS,
5779
+ arcRoute,
5780
+ bezierPointAt,
5105
5781
  buildCardsSpec,
5106
5782
  buildCodeSpec,
5107
5783
  buildFlowchartSpec,
@@ -5109,7 +5785,11 @@ export {
5109
5785
  builtInThemeBackgrounds,
5110
5786
  builtInThemes,
5111
5787
  cli,
5788
+ compareImages,
5789
+ computeDiagramCenter,
5112
5790
  computeSpecHash,
5791
+ connectionElementSchema,
5792
+ curveRoute,
5113
5793
  defaultAutoLayout,
5114
5794
  defaultCanvas,
5115
5795
  defaultConstraints,
@@ -5119,19 +5799,29 @@ export {
5119
5799
  defaultTheme,
5120
5800
  deriveSafeFrame,
5121
5801
  designSpecSchema,
5802
+ diagramElementSchema,
5803
+ diagramLayoutSchema,
5804
+ diagramSpecSchema,
5122
5805
  disposeHighlighter,
5123
5806
  drawGradientRect,
5124
5807
  drawRainbowRule,
5125
5808
  drawVignette,
5809
+ edgeAnchor,
5810
+ flowNodeElementSchema,
5126
5811
  highlightCode,
5127
5812
  inferLayout,
5128
5813
  inferSidecarPath,
5129
5814
  initHighlighter,
5130
5815
  loadFonts,
5816
+ orthogonalRoute,
5817
+ outwardNormal,
5131
5818
  parseDesignSpec,
5819
+ parseDiagramSpec,
5132
5820
  publishToGist,
5133
5821
  publishToGitHub,
5134
5822
  readMetadata,
5823
+ rectCenter,
5824
+ renderConnection,
5135
5825
  renderDesign,
5136
5826
  renderDrawCommands,
5137
5827
  resolveShikiTheme,