@spectratools/graphic-designer-cli 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -2
- package/dist/cli.js +555 -60
- package/dist/index.d.ts +105 -5
- package/dist/index.js +578 -60
- package/dist/qa.d.ts +14 -3
- package/dist/qa.js +242 -11
- package/dist/renderer.d.ts +1 -1
- package/dist/renderer.js +293 -53
- package/dist/{spec.schema-BUTof436.d.ts → spec.schema-Dm_wOLTd.d.ts} +1375 -114
- package/dist/spec.schema.d.ts +1 -1
- package/dist/spec.schema.js +75 -8
- package/package.json +1 -1
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
|
|
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);
|
|
@@ -773,10 +924,26 @@ var cardElementSchema = z2.object({
|
|
|
773
924
|
tone: z2.enum(["neutral", "accent", "success", "warning", "error"]).default("neutral"),
|
|
774
925
|
icon: z2.string().min(1).max(64).optional()
|
|
775
926
|
}).strict();
|
|
927
|
+
var flowNodeShadowSchema = z2.object({
|
|
928
|
+
color: colorHexSchema2.optional(),
|
|
929
|
+
blur: z2.number().min(0).max(64).default(8),
|
|
930
|
+
offsetX: z2.number().min(-32).max(32).default(0),
|
|
931
|
+
offsetY: z2.number().min(-32).max(32).default(0),
|
|
932
|
+
opacity: z2.number().min(0).max(1).default(0.3)
|
|
933
|
+
}).strict();
|
|
776
934
|
var flowNodeElementSchema = z2.object({
|
|
777
935
|
type: z2.literal("flow-node"),
|
|
778
936
|
id: z2.string().min(1).max(120),
|
|
779
|
-
shape: z2.enum([
|
|
937
|
+
shape: z2.enum([
|
|
938
|
+
"box",
|
|
939
|
+
"rounded-box",
|
|
940
|
+
"diamond",
|
|
941
|
+
"circle",
|
|
942
|
+
"pill",
|
|
943
|
+
"cylinder",
|
|
944
|
+
"parallelogram",
|
|
945
|
+
"hexagon"
|
|
946
|
+
]).default("rounded-box"),
|
|
780
947
|
label: z2.string().min(1).max(200),
|
|
781
948
|
sublabel: z2.string().min(1).max(300).optional(),
|
|
782
949
|
sublabelColor: colorHexSchema2.optional(),
|
|
@@ -796,20 +963,25 @@ var flowNodeElementSchema = z2.object({
|
|
|
796
963
|
badgeText: z2.string().min(1).max(32).optional(),
|
|
797
964
|
badgeColor: colorHexSchema2.optional(),
|
|
798
965
|
badgeBackground: colorHexSchema2.optional(),
|
|
799
|
-
badgePosition: z2.enum(["top", "inside-top"]).default("inside-top")
|
|
966
|
+
badgePosition: z2.enum(["top", "inside-top"]).default("inside-top"),
|
|
967
|
+
shadow: flowNodeShadowSchema.optional()
|
|
800
968
|
}).strict();
|
|
801
969
|
var connectionElementSchema = z2.object({
|
|
802
970
|
type: z2.literal("connection"),
|
|
803
971
|
from: z2.string().min(1).max(120),
|
|
804
972
|
to: z2.string().min(1).max(120),
|
|
805
973
|
style: z2.enum(["solid", "dashed", "dotted"]).default("solid"),
|
|
974
|
+
strokeStyle: z2.enum(["solid", "dashed", "dotted"]).default("solid"),
|
|
806
975
|
arrow: z2.enum(["end", "start", "both", "none"]).default("end"),
|
|
807
976
|
label: z2.string().min(1).max(200).optional(),
|
|
808
977
|
labelPosition: z2.enum(["start", "middle", "end"]).default("middle"),
|
|
809
978
|
color: colorHexSchema2.optional(),
|
|
810
|
-
width: z2.number().min(0.5).max(
|
|
979
|
+
width: z2.number().min(0.5).max(10).optional(),
|
|
980
|
+
strokeWidth: z2.number().min(0.5).max(10).default(2),
|
|
811
981
|
arrowSize: z2.number().min(4).max(32).optional(),
|
|
812
|
-
opacity: z2.number().min(0).max(1).default(1)
|
|
982
|
+
opacity: z2.number().min(0).max(1).default(1),
|
|
983
|
+
routing: z2.enum(["auto", "orthogonal", "curve", "arc"]).default("auto"),
|
|
984
|
+
tension: z2.number().min(0.1).max(0.8).default(0.35)
|
|
813
985
|
}).strict();
|
|
814
986
|
var codeBlockStyleSchema = z2.object({
|
|
815
987
|
paddingVertical: z2.number().min(0).max(128).default(56),
|
|
@@ -878,6 +1050,10 @@ var elementSchema = z2.discriminatedUnion("type", [
|
|
|
878
1050
|
shapeElementSchema,
|
|
879
1051
|
imageElementSchema
|
|
880
1052
|
]);
|
|
1053
|
+
var diagramCenterSchema = z2.object({
|
|
1054
|
+
x: z2.number(),
|
|
1055
|
+
y: z2.number()
|
|
1056
|
+
}).strict();
|
|
881
1057
|
var autoLayoutConfigSchema = z2.object({
|
|
882
1058
|
mode: z2.literal("auto"),
|
|
883
1059
|
algorithm: z2.enum(["layered", "stress", "force", "radial", "box"]).default("layered"),
|
|
@@ -893,7 +1069,9 @@ var autoLayoutConfigSchema = z2.object({
|
|
|
893
1069
|
/** Compaction strategy for radial layout. Only relevant when algorithm is 'radial'. */
|
|
894
1070
|
radialCompaction: z2.enum(["none", "radial", "wedge"]).optional(),
|
|
895
1071
|
/** Sort strategy for radial layout node ordering. Only relevant when algorithm is 'radial'. */
|
|
896
|
-
radialSortBy: z2.enum(["id", "connections"]).optional()
|
|
1072
|
+
radialSortBy: z2.enum(["id", "connections"]).optional(),
|
|
1073
|
+
/** Explicit center used by curve/arc connection routing. */
|
|
1074
|
+
diagramCenter: diagramCenterSchema.optional()
|
|
897
1075
|
}).strict();
|
|
898
1076
|
var gridLayoutConfigSchema = z2.object({
|
|
899
1077
|
mode: z2.literal("grid"),
|
|
@@ -901,13 +1079,17 @@ var gridLayoutConfigSchema = z2.object({
|
|
|
901
1079
|
gap: z2.number().int().min(0).max(256).default(24),
|
|
902
1080
|
cardMinHeight: z2.number().int().min(32).max(4096).optional(),
|
|
903
1081
|
cardMaxHeight: z2.number().int().min(32).max(4096).optional(),
|
|
904
|
-
equalHeight: z2.boolean().default(false)
|
|
1082
|
+
equalHeight: z2.boolean().default(false),
|
|
1083
|
+
/** Explicit center used by curve/arc connection routing. */
|
|
1084
|
+
diagramCenter: diagramCenterSchema.optional()
|
|
905
1085
|
}).strict();
|
|
906
1086
|
var stackLayoutConfigSchema = z2.object({
|
|
907
1087
|
mode: z2.literal("stack"),
|
|
908
1088
|
direction: z2.enum(["vertical", "horizontal"]).default("vertical"),
|
|
909
1089
|
gap: z2.number().int().min(0).max(256).default(24),
|
|
910
|
-
alignment: z2.enum(["start", "center", "end", "stretch"]).default("stretch")
|
|
1090
|
+
alignment: z2.enum(["start", "center", "end", "stretch"]).default("stretch"),
|
|
1091
|
+
/** Explicit center used by curve/arc connection routing. */
|
|
1092
|
+
diagramCenter: diagramCenterSchema.optional()
|
|
911
1093
|
}).strict();
|
|
912
1094
|
var manualPositionSchema = z2.object({
|
|
913
1095
|
x: z2.number().int(),
|
|
@@ -917,7 +1099,9 @@ var manualPositionSchema = z2.object({
|
|
|
917
1099
|
}).strict();
|
|
918
1100
|
var manualLayoutConfigSchema = z2.object({
|
|
919
1101
|
mode: z2.literal("manual"),
|
|
920
|
-
positions: z2.record(z2.string().min(1), manualPositionSchema).default({})
|
|
1102
|
+
positions: z2.record(z2.string().min(1), manualPositionSchema).default({}),
|
|
1103
|
+
/** Explicit center used by curve/arc connection routing. */
|
|
1104
|
+
diagramCenter: diagramCenterSchema.optional()
|
|
921
1105
|
}).strict();
|
|
922
1106
|
var layoutConfigSchema = z2.discriminatedUnion("mode", [
|
|
923
1107
|
autoLayoutConfigSchema,
|
|
@@ -969,6 +1153,31 @@ var canvasSchema = z2.object({
|
|
|
969
1153
|
padding: z2.number().int().min(0).max(256).default(defaultCanvas.padding)
|
|
970
1154
|
}).strict();
|
|
971
1155
|
var themeInputSchema = z2.union([builtInThemeSchema, themeSchema]);
|
|
1156
|
+
var diagramPositionSchema = z2.object({
|
|
1157
|
+
x: z2.number(),
|
|
1158
|
+
y: z2.number(),
|
|
1159
|
+
width: z2.number().positive(),
|
|
1160
|
+
height: z2.number().positive()
|
|
1161
|
+
}).strict();
|
|
1162
|
+
var diagramElementSchema = z2.discriminatedUnion("type", [
|
|
1163
|
+
flowNodeElementSchema,
|
|
1164
|
+
connectionElementSchema
|
|
1165
|
+
]);
|
|
1166
|
+
var diagramLayoutSchema = z2.object({
|
|
1167
|
+
mode: z2.enum(["manual", "auto"]).default("manual"),
|
|
1168
|
+
positions: z2.record(z2.string(), diagramPositionSchema).optional(),
|
|
1169
|
+
diagramCenter: diagramCenterSchema.optional()
|
|
1170
|
+
}).strict();
|
|
1171
|
+
var diagramSpecSchema = z2.object({
|
|
1172
|
+
version: z2.literal(1),
|
|
1173
|
+
canvas: z2.object({
|
|
1174
|
+
width: z2.number().int().min(320).max(4096).default(1200),
|
|
1175
|
+
height: z2.number().int().min(180).max(4096).default(675)
|
|
1176
|
+
}).default({ width: 1200, height: 675 }),
|
|
1177
|
+
theme: themeSchema.optional(),
|
|
1178
|
+
elements: z2.array(diagramElementSchema).min(1),
|
|
1179
|
+
layout: diagramLayoutSchema.default({ mode: "manual" })
|
|
1180
|
+
}).strict();
|
|
972
1181
|
var designSpecSchema = z2.object({
|
|
973
1182
|
version: z2.literal(2).default(2),
|
|
974
1183
|
canvas: canvasSchema.default(defaultCanvas),
|
|
@@ -1042,7 +1251,7 @@ async function runQa(options) {
|
|
|
1042
1251
|
const imagePath = resolve(options.imagePath);
|
|
1043
1252
|
const expectedSafeFrame = deriveSafeFrame(spec);
|
|
1044
1253
|
const expectedCanvas = canvasRect(spec);
|
|
1045
|
-
const imageMetadata = await
|
|
1254
|
+
const imageMetadata = await sharp2(imagePath).metadata();
|
|
1046
1255
|
const issues = [];
|
|
1047
1256
|
const expectedScale = options.metadata?.canvas.scale ?? resolveRenderScale(spec);
|
|
1048
1257
|
const expectedWidth = spec.canvas.width * expectedScale;
|
|
@@ -1193,6 +1402,31 @@ async function runQa(options) {
|
|
|
1193
1402
|
});
|
|
1194
1403
|
}
|
|
1195
1404
|
}
|
|
1405
|
+
let referenceResult;
|
|
1406
|
+
if (options.referencePath) {
|
|
1407
|
+
const { compareImages: compareImages2 } = await Promise.resolve().then(() => (init_compare(), compare_exports));
|
|
1408
|
+
const comparison = await compareImages2(options.referencePath, imagePath);
|
|
1409
|
+
referenceResult = {
|
|
1410
|
+
similarity: comparison.similarity,
|
|
1411
|
+
verdict: comparison.verdict,
|
|
1412
|
+
regions: comparison.regions.map((region) => ({
|
|
1413
|
+
label: region.label,
|
|
1414
|
+
similarity: region.similarity
|
|
1415
|
+
}))
|
|
1416
|
+
};
|
|
1417
|
+
if (comparison.verdict === "mismatch") {
|
|
1418
|
+
const severity = comparison.similarity < 0.5 ? "error" : "warning";
|
|
1419
|
+
issues.push({
|
|
1420
|
+
code: "REFERENCE_MISMATCH",
|
|
1421
|
+
severity,
|
|
1422
|
+
message: `Reference image comparison ${severity === "error" ? "failed" : "warned"}: similarity ${comparison.similarity.toFixed(4)} with verdict "${comparison.verdict}".`,
|
|
1423
|
+
details: {
|
|
1424
|
+
similarity: comparison.similarity,
|
|
1425
|
+
verdict: comparison.verdict
|
|
1426
|
+
}
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1196
1430
|
const footerSpacingPx = options.metadata?.layout.elements ? (() => {
|
|
1197
1431
|
const footer = options.metadata.layout.elements.find((element) => element.id === "footer");
|
|
1198
1432
|
if (!footer) {
|
|
@@ -1225,7 +1459,8 @@ async function runQa(options) {
|
|
|
1225
1459
|
...imageMetadata.height !== void 0 ? { height: imageMetadata.height } : {},
|
|
1226
1460
|
...footerSpacingPx !== void 0 ? { footerSpacingPx } : {}
|
|
1227
1461
|
},
|
|
1228
|
-
issues
|
|
1462
|
+
issues,
|
|
1463
|
+
...referenceResult ? { reference: referenceResult } : {}
|
|
1229
1464
|
};
|
|
1230
1465
|
}
|
|
1231
1466
|
|
|
@@ -1292,9 +1527,9 @@ function drawRoundedRect(ctx, rect, radius, fill, stroke) {
|
|
|
1292
1527
|
roundRectPath(ctx, rect, radius);
|
|
1293
1528
|
fillAndStroke(ctx, fill, stroke);
|
|
1294
1529
|
}
|
|
1295
|
-
function drawCircle(ctx,
|
|
1530
|
+
function drawCircle(ctx, center, radius, fill, stroke) {
|
|
1296
1531
|
ctx.beginPath();
|
|
1297
|
-
ctx.arc(
|
|
1532
|
+
ctx.arc(center.x, center.y, Math.max(0, radius), 0, Math.PI * 2);
|
|
1298
1533
|
ctx.closePath();
|
|
1299
1534
|
fillAndStroke(ctx, fill, stroke);
|
|
1300
1535
|
}
|
|
@@ -1538,15 +1773,34 @@ function renderFlowNode(ctx, node, bounds, theme) {
|
|
|
1538
1773
|
const badgeBackground = node.badgeBackground ?? borderColor ?? theme.accent;
|
|
1539
1774
|
ctx.save();
|
|
1540
1775
|
ctx.lineWidth = borderWidth;
|
|
1776
|
+
if (node.shadow) {
|
|
1777
|
+
const shadowColor = node.shadow.color ?? borderColor ?? theme.accent;
|
|
1778
|
+
ctx.shadowColor = withAlpha(shadowColor, node.shadow.opacity);
|
|
1779
|
+
ctx.shadowBlur = node.shadow.blur;
|
|
1780
|
+
ctx.shadowOffsetX = node.shadow.offsetX;
|
|
1781
|
+
ctx.shadowOffsetY = node.shadow.offsetY;
|
|
1782
|
+
}
|
|
1541
1783
|
if (fillOpacity < 1) {
|
|
1542
1784
|
ctx.globalAlpha = node.opacity * fillOpacity;
|
|
1543
1785
|
drawNodeShape(ctx, node.shape, bounds, fillColor, void 0, cornerRadius);
|
|
1786
|
+
if (node.shadow) {
|
|
1787
|
+
ctx.shadowColor = "transparent";
|
|
1788
|
+
ctx.shadowBlur = 0;
|
|
1789
|
+
ctx.shadowOffsetX = 0;
|
|
1790
|
+
ctx.shadowOffsetY = 0;
|
|
1791
|
+
}
|
|
1544
1792
|
ctx.globalAlpha = node.opacity;
|
|
1545
1793
|
drawNodeShape(ctx, node.shape, bounds, "rgba(0,0,0,0)", borderColor, cornerRadius);
|
|
1546
1794
|
} else {
|
|
1547
1795
|
ctx.globalAlpha = node.opacity;
|
|
1548
1796
|
drawNodeShape(ctx, node.shape, bounds, fillColor, borderColor, cornerRadius);
|
|
1549
1797
|
}
|
|
1798
|
+
if (node.shadow) {
|
|
1799
|
+
ctx.shadowColor = "transparent";
|
|
1800
|
+
ctx.shadowBlur = 0;
|
|
1801
|
+
ctx.shadowOffsetX = 0;
|
|
1802
|
+
ctx.shadowOffsetY = 0;
|
|
1803
|
+
}
|
|
1550
1804
|
const headingFont = resolveFont(theme.fonts.heading, "heading");
|
|
1551
1805
|
const bodyFont = resolveFont(theme.fonts.body, "body");
|
|
1552
1806
|
const monoFont = resolveFont(theme.fonts.mono, "mono");
|
|
@@ -2218,7 +2472,7 @@ function parseHexColor2(color) {
|
|
|
2218
2472
|
a: normalized.length === 8 ? parseChannel2(6) / 255 : 1
|
|
2219
2473
|
};
|
|
2220
2474
|
}
|
|
2221
|
-
function
|
|
2475
|
+
function withAlpha2(color, alpha) {
|
|
2222
2476
|
const parsed = parseHexColor2(color);
|
|
2223
2477
|
const effectiveAlpha = clamp01(parsed.a * alpha);
|
|
2224
2478
|
return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${effectiveAlpha})`;
|
|
@@ -2275,9 +2529,9 @@ function drawVignette(ctx, width, height, intensity = 0.3, color = "#000000") {
|
|
|
2275
2529
|
centerY,
|
|
2276
2530
|
outerRadius
|
|
2277
2531
|
);
|
|
2278
|
-
vignette.addColorStop(0,
|
|
2279
|
-
vignette.addColorStop(0.6,
|
|
2280
|
-
vignette.addColorStop(1,
|
|
2532
|
+
vignette.addColorStop(0, withAlpha2(color, 0));
|
|
2533
|
+
vignette.addColorStop(0.6, withAlpha2(color, 0));
|
|
2534
|
+
vignette.addColorStop(1, withAlpha2(color, clamp01(intensity)));
|
|
2281
2535
|
ctx.save();
|
|
2282
2536
|
ctx.fillStyle = vignette;
|
|
2283
2537
|
ctx.fillRect(0, 0, width, height);
|
|
@@ -2408,12 +2662,12 @@ var MACOS_DOTS = [
|
|
|
2408
2662
|
{ fill: "#27C93F", stroke: "#1AAB29" }
|
|
2409
2663
|
];
|
|
2410
2664
|
function drawMacosDots(ctx, x, y) {
|
|
2411
|
-
for (const [index,
|
|
2665
|
+
for (const [index, dot2] of MACOS_DOTS.entries()) {
|
|
2412
2666
|
ctx.beginPath();
|
|
2413
2667
|
ctx.arc(x + index * DOT_SPACING, y, DOT_RADIUS, 0, Math.PI * 2);
|
|
2414
2668
|
ctx.closePath();
|
|
2415
|
-
ctx.fillStyle =
|
|
2416
|
-
ctx.strokeStyle =
|
|
2669
|
+
ctx.fillStyle = dot2.fill;
|
|
2670
|
+
ctx.strokeStyle = dot2.stroke;
|
|
2417
2671
|
ctx.lineWidth = DOT_STROKE_WIDTH;
|
|
2418
2672
|
ctx.fill();
|
|
2419
2673
|
ctx.stroke();
|
|
@@ -2830,25 +3084,134 @@ function drawOrthogonalPath(ctx, from, to, style) {
|
|
|
2830
3084
|
}
|
|
2831
3085
|
|
|
2832
3086
|
// src/renderers/connection.ts
|
|
2833
|
-
|
|
3087
|
+
var ELLIPSE_KAPPA = 4 * (Math.sqrt(2) - 1) / 3;
|
|
3088
|
+
function rectCenter(rect) {
|
|
2834
3089
|
return {
|
|
2835
3090
|
x: rect.x + rect.width / 2,
|
|
2836
3091
|
y: rect.y + rect.height / 2
|
|
2837
3092
|
};
|
|
2838
3093
|
}
|
|
2839
|
-
function edgeAnchor(
|
|
2840
|
-
const c =
|
|
3094
|
+
function edgeAnchor(bounds, target) {
|
|
3095
|
+
const c = rectCenter(bounds);
|
|
2841
3096
|
const dx = target.x - c.x;
|
|
2842
3097
|
const dy = target.y - c.y;
|
|
2843
|
-
if (
|
|
2844
|
-
return {
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
3098
|
+
if (dx === 0 && dy === 0) {
|
|
3099
|
+
return { x: c.x, y: c.y - bounds.height / 2 };
|
|
3100
|
+
}
|
|
3101
|
+
const hw = bounds.width / 2;
|
|
3102
|
+
const hh = bounds.height / 2;
|
|
3103
|
+
const absDx = Math.abs(dx);
|
|
3104
|
+
const absDy = Math.abs(dy);
|
|
3105
|
+
const t = absDx * hh > absDy * hw ? hw / absDx : hh / absDy;
|
|
3106
|
+
return { x: c.x + dx * t, y: c.y + dy * t };
|
|
3107
|
+
}
|
|
3108
|
+
function outwardNormal(point, diagramCenter) {
|
|
3109
|
+
const dx = point.x - diagramCenter.x;
|
|
3110
|
+
const dy = point.y - diagramCenter.y;
|
|
3111
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
3112
|
+
return { x: dx / len, y: dy / len };
|
|
3113
|
+
}
|
|
3114
|
+
function curveRoute(fromBounds, toBounds, diagramCenter, tension) {
|
|
3115
|
+
const fromCenter = rectCenter(fromBounds);
|
|
3116
|
+
const toCenter = rectCenter(toBounds);
|
|
3117
|
+
const p0 = edgeAnchor(fromBounds, toCenter);
|
|
3118
|
+
const p3 = edgeAnchor(toBounds, fromCenter);
|
|
3119
|
+
const dist = Math.hypot(p3.x - p0.x, p3.y - p0.y);
|
|
3120
|
+
const offset = dist * tension;
|
|
3121
|
+
const n0 = outwardNormal(p0, diagramCenter);
|
|
3122
|
+
const n3 = outwardNormal(p3, diagramCenter);
|
|
3123
|
+
const cp1 = { x: p0.x + n0.x * offset, y: p0.y + n0.y * offset };
|
|
3124
|
+
const cp2 = { x: p3.x + n3.x * offset, y: p3.y + n3.y * offset };
|
|
3125
|
+
return [p0, cp1, cp2, p3];
|
|
3126
|
+
}
|
|
3127
|
+
function dot(a, b) {
|
|
3128
|
+
return a.x * b.x + a.y * b.y;
|
|
3129
|
+
}
|
|
3130
|
+
function localToWorld(origin, axisX, axisY, local) {
|
|
3131
|
+
return {
|
|
3132
|
+
x: origin.x + axisX.x * local.x + axisY.x * local.y,
|
|
3133
|
+
y: origin.y + axisX.y * local.x + axisY.y * local.y
|
|
3134
|
+
};
|
|
3135
|
+
}
|
|
3136
|
+
function arcRoute(fromBounds, toBounds, diagramCenter, tension) {
|
|
3137
|
+
const fromCenter = rectCenter(fromBounds);
|
|
3138
|
+
const toCenter = rectCenter(toBounds);
|
|
3139
|
+
const start = edgeAnchor(fromBounds, toCenter);
|
|
3140
|
+
const end = edgeAnchor(toBounds, fromCenter);
|
|
3141
|
+
const chord = { x: end.x - start.x, y: end.y - start.y };
|
|
3142
|
+
const chordLength = Math.hypot(chord.x, chord.y);
|
|
3143
|
+
if (chordLength < 1e-6) {
|
|
3144
|
+
const mid = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
|
|
3145
|
+
return [
|
|
3146
|
+
[start, start, mid, mid],
|
|
3147
|
+
[mid, mid, end, end]
|
|
3148
|
+
];
|
|
2848
3149
|
}
|
|
3150
|
+
const axisX = { x: chord.x / chordLength, y: chord.y / chordLength };
|
|
3151
|
+
let axisY = { x: -axisX.y, y: axisX.x };
|
|
3152
|
+
const midpoint = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
|
|
3153
|
+
const outwardHint = outwardNormal(midpoint, diagramCenter);
|
|
3154
|
+
if (dot(axisY, outwardHint) < 0) {
|
|
3155
|
+
axisY = { x: -axisY.x, y: -axisY.y };
|
|
3156
|
+
}
|
|
3157
|
+
const semiMajor = chordLength / 2;
|
|
3158
|
+
const semiMinor = Math.max(12, chordLength * tension * 0.75);
|
|
3159
|
+
const p0Local = { x: -semiMajor, y: 0 };
|
|
3160
|
+
const cp1Local = { x: -semiMajor, y: ELLIPSE_KAPPA * semiMinor };
|
|
3161
|
+
const cp2Local = { x: -ELLIPSE_KAPPA * semiMajor, y: semiMinor };
|
|
3162
|
+
const pMidLocal = { x: 0, y: semiMinor };
|
|
3163
|
+
const cp3Local = { x: ELLIPSE_KAPPA * semiMajor, y: semiMinor };
|
|
3164
|
+
const cp4Local = { x: semiMajor, y: ELLIPSE_KAPPA * semiMinor };
|
|
3165
|
+
const p3Local = { x: semiMajor, y: 0 };
|
|
3166
|
+
const p0 = localToWorld(midpoint, axisX, axisY, p0Local);
|
|
3167
|
+
const cp1 = localToWorld(midpoint, axisX, axisY, cp1Local);
|
|
3168
|
+
const cp2 = localToWorld(midpoint, axisX, axisY, cp2Local);
|
|
3169
|
+
const pMid = localToWorld(midpoint, axisX, axisY, pMidLocal);
|
|
3170
|
+
const cp3 = localToWorld(midpoint, axisX, axisY, cp3Local);
|
|
3171
|
+
const cp4 = localToWorld(midpoint, axisX, axisY, cp4Local);
|
|
3172
|
+
const p3 = localToWorld(midpoint, axisX, axisY, p3Local);
|
|
3173
|
+
return [
|
|
3174
|
+
[p0, cp1, cp2, pMid],
|
|
3175
|
+
[pMid, cp3, cp4, p3]
|
|
3176
|
+
];
|
|
3177
|
+
}
|
|
3178
|
+
function orthogonalRoute(fromBounds, toBounds) {
|
|
3179
|
+
const fromC = rectCenter(fromBounds);
|
|
3180
|
+
const toC = rectCenter(toBounds);
|
|
3181
|
+
const p0 = edgeAnchor(fromBounds, toC);
|
|
3182
|
+
const p3 = edgeAnchor(toBounds, fromC);
|
|
3183
|
+
const midX = (p0.x + p3.x) / 2;
|
|
3184
|
+
return [p0, { x: midX, y: p0.y }, { x: midX, y: p3.y }, p3];
|
|
3185
|
+
}
|
|
3186
|
+
function bezierPointAt(p0, cp1, cp2, p3, t) {
|
|
3187
|
+
const mt = 1 - t;
|
|
2849
3188
|
return {
|
|
2850
|
-
x:
|
|
2851
|
-
y:
|
|
3189
|
+
x: mt * mt * mt * p0.x + 3 * mt * mt * t * cp1.x + 3 * mt * t * t * cp2.x + t * t * t * p3.x,
|
|
3190
|
+
y: mt * mt * mt * p0.y + 3 * mt * mt * t * cp1.y + 3 * mt * t * t * cp2.y + t * t * t * p3.y
|
|
3191
|
+
};
|
|
3192
|
+
}
|
|
3193
|
+
function pointAlongArc(route, t) {
|
|
3194
|
+
const [first, second] = route;
|
|
3195
|
+
if (t <= 0.5) {
|
|
3196
|
+
const localT2 = Math.max(0, Math.min(1, t * 2));
|
|
3197
|
+
return bezierPointAt(first[0], first[1], first[2], first[3], localT2);
|
|
3198
|
+
}
|
|
3199
|
+
const localT = Math.max(0, Math.min(1, (t - 0.5) * 2));
|
|
3200
|
+
return bezierPointAt(second[0], second[1], second[2], second[3], localT);
|
|
3201
|
+
}
|
|
3202
|
+
function computeDiagramCenter(nodeBounds, canvasCenter) {
|
|
3203
|
+
if (nodeBounds.length === 0) {
|
|
3204
|
+
return canvasCenter ?? { x: 0, y: 0 };
|
|
3205
|
+
}
|
|
3206
|
+
let totalX = 0;
|
|
3207
|
+
let totalY = 0;
|
|
3208
|
+
for (const bounds of nodeBounds) {
|
|
3209
|
+
totalX += bounds.x + bounds.width / 2;
|
|
3210
|
+
totalY += bounds.y + bounds.height / 2;
|
|
3211
|
+
}
|
|
3212
|
+
return {
|
|
3213
|
+
x: totalX / nodeBounds.length,
|
|
3214
|
+
y: totalY / nodeBounds.length
|
|
2852
3215
|
};
|
|
2853
3216
|
}
|
|
2854
3217
|
function dashFromStyle(style) {
|
|
@@ -2932,51 +3295,95 @@ function polylineBounds(points) {
|
|
|
2932
3295
|
height: Math.max(1, maxY - minY)
|
|
2933
3296
|
};
|
|
2934
3297
|
}
|
|
2935
|
-
function renderConnection(ctx, conn, fromBounds, toBounds, theme, edgeRoute) {
|
|
2936
|
-
const
|
|
2937
|
-
const
|
|
2938
|
-
const
|
|
2939
|
-
const
|
|
2940
|
-
const dash = dashFromStyle(
|
|
3298
|
+
function renderConnection(ctx, conn, fromBounds, toBounds, theme, edgeRoute, options) {
|
|
3299
|
+
const routing = conn.routing ?? "auto";
|
|
3300
|
+
const strokeStyle = conn.strokeStyle ?? conn.style ?? "solid";
|
|
3301
|
+
const strokeWidth = conn.width ?? conn.strokeWidth ?? 2;
|
|
3302
|
+
const tension = conn.tension ?? 0.35;
|
|
3303
|
+
const dash = dashFromStyle(strokeStyle);
|
|
2941
3304
|
const style = {
|
|
2942
3305
|
color: conn.color ?? theme.borderMuted,
|
|
2943
|
-
width:
|
|
3306
|
+
width: strokeWidth,
|
|
2944
3307
|
headSize: conn.arrowSize ?? 10,
|
|
2945
3308
|
...dash ? { dash } : {}
|
|
2946
3309
|
};
|
|
2947
|
-
const
|
|
2948
|
-
const
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
let
|
|
2952
|
-
let
|
|
3310
|
+
const labelT = conn.labelPosition === "start" ? 0.2 : conn.labelPosition === "end" ? 0.8 : 0.5;
|
|
3311
|
+
const diagramCenter = options?.diagramCenter ?? computeDiagramCenter([fromBounds, toBounds]);
|
|
3312
|
+
let linePoints;
|
|
3313
|
+
let startPoint;
|
|
3314
|
+
let endPoint;
|
|
3315
|
+
let startAngle;
|
|
3316
|
+
let endAngle;
|
|
3317
|
+
let labelPoint;
|
|
3318
|
+
ctx.save();
|
|
3319
|
+
ctx.globalAlpha = conn.opacity;
|
|
3320
|
+
if (routing === "curve") {
|
|
3321
|
+
const [p0, cp1, cp2, p3] = curveRoute(fromBounds, toBounds, diagramCenter, tension);
|
|
3322
|
+
ctx.strokeStyle = style.color;
|
|
3323
|
+
ctx.lineWidth = style.width;
|
|
3324
|
+
ctx.setLineDash(style.dash ?? []);
|
|
3325
|
+
ctx.beginPath();
|
|
3326
|
+
ctx.moveTo(p0.x, p0.y);
|
|
3327
|
+
ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, p3.x, p3.y);
|
|
3328
|
+
ctx.stroke();
|
|
3329
|
+
linePoints = [p0, cp1, cp2, p3];
|
|
3330
|
+
startPoint = p0;
|
|
3331
|
+
endPoint = p3;
|
|
3332
|
+
startAngle = Math.atan2(p0.y - cp1.y, p0.x - cp1.x);
|
|
3333
|
+
endAngle = Math.atan2(p3.y - cp2.y, p3.x - cp2.x);
|
|
3334
|
+
labelPoint = bezierPointAt(p0, cp1, cp2, p3, labelT);
|
|
3335
|
+
} else if (routing === "arc") {
|
|
3336
|
+
const [first, second] = arcRoute(fromBounds, toBounds, diagramCenter, tension);
|
|
3337
|
+
const [p0, cp1, cp2, pMid] = first;
|
|
3338
|
+
const [, cp3, cp4, p3] = second;
|
|
3339
|
+
ctx.strokeStyle = style.color;
|
|
3340
|
+
ctx.lineWidth = style.width;
|
|
3341
|
+
ctx.setLineDash(style.dash ?? []);
|
|
3342
|
+
ctx.beginPath();
|
|
3343
|
+
ctx.moveTo(p0.x, p0.y);
|
|
3344
|
+
ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, pMid.x, pMid.y);
|
|
3345
|
+
ctx.bezierCurveTo(cp3.x, cp3.y, cp4.x, cp4.y, p3.x, p3.y);
|
|
3346
|
+
ctx.stroke();
|
|
3347
|
+
linePoints = [p0, cp1, cp2, pMid, cp3, cp4, p3];
|
|
3348
|
+
startPoint = p0;
|
|
3349
|
+
endPoint = p3;
|
|
3350
|
+
startAngle = Math.atan2(p0.y - cp1.y, p0.x - cp1.x);
|
|
3351
|
+
endAngle = Math.atan2(p3.y - cp4.y, p3.x - cp4.x);
|
|
3352
|
+
labelPoint = pointAlongArc([first, second], labelT);
|
|
3353
|
+
} else {
|
|
3354
|
+
const useElkRoute = routing === "auto" && (edgeRoute?.points.length ?? 0) >= 2;
|
|
3355
|
+
linePoints = useElkRoute ? edgeRoute?.points ?? orthogonalRoute(fromBounds, toBounds) : orthogonalRoute(fromBounds, toBounds);
|
|
3356
|
+
startPoint = linePoints[0];
|
|
3357
|
+
const startSegment = linePoints[1] ?? linePoints[0];
|
|
3358
|
+
const endStart = linePoints[linePoints.length - 2] ?? linePoints[0];
|
|
3359
|
+
endPoint = linePoints[linePoints.length - 1] ?? linePoints[0];
|
|
3360
|
+
startAngle = Math.atan2(startSegment.y - linePoints[0].y, startSegment.x - linePoints[0].x) + Math.PI;
|
|
3361
|
+
endAngle = Math.atan2(endPoint.y - endStart.y, endPoint.x - endStart.x);
|
|
3362
|
+
if (useElkRoute) {
|
|
3363
|
+
drawCubicInterpolatedPath(ctx, linePoints, style);
|
|
3364
|
+
} else {
|
|
3365
|
+
drawOrthogonalPath(ctx, startPoint, endPoint, style);
|
|
3366
|
+
}
|
|
3367
|
+
labelPoint = pointAlongPolyline(linePoints, labelT);
|
|
3368
|
+
}
|
|
2953
3369
|
if (!Number.isFinite(startAngle)) {
|
|
2954
3370
|
startAngle = 0;
|
|
2955
3371
|
}
|
|
2956
3372
|
if (!Number.isFinite(endAngle)) {
|
|
2957
3373
|
endAngle = 0;
|
|
2958
3374
|
}
|
|
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
3375
|
if (conn.arrow === "start" || conn.arrow === "both") {
|
|
2969
|
-
drawArrowhead(ctx,
|
|
3376
|
+
drawArrowhead(ctx, startPoint, startAngle, style.headSize, style.color);
|
|
2970
3377
|
}
|
|
2971
3378
|
if (conn.arrow === "end" || conn.arrow === "both") {
|
|
2972
|
-
drawArrowhead(ctx,
|
|
3379
|
+
drawArrowhead(ctx, endPoint, endAngle, style.headSize, style.color);
|
|
2973
3380
|
}
|
|
2974
3381
|
ctx.restore();
|
|
2975
3382
|
const elements = [
|
|
2976
3383
|
{
|
|
2977
3384
|
id: `connection-${conn.from}-${conn.to}`,
|
|
2978
3385
|
kind: "connection",
|
|
2979
|
-
bounds: polylineBounds(
|
|
3386
|
+
bounds: polylineBounds(linePoints),
|
|
2980
3387
|
foregroundColor: style.color
|
|
2981
3388
|
}
|
|
2982
3389
|
];
|
|
@@ -4190,6 +4597,10 @@ async function renderDesign(input, options = {}) {
|
|
|
4190
4597
|
break;
|
|
4191
4598
|
}
|
|
4192
4599
|
}
|
|
4600
|
+
const diagramCenter = spec.layout.diagramCenter ?? computeDiagramCenter(
|
|
4601
|
+
spec.elements.filter((element) => element.type !== "connection").map((element) => elementRects.get(element.id)).filter((rect) => rect != null),
|
|
4602
|
+
{ x: spec.canvas.width / 2, y: spec.canvas.height / 2 }
|
|
4603
|
+
);
|
|
4193
4604
|
for (const element of spec.elements) {
|
|
4194
4605
|
if (element.type !== "connection") {
|
|
4195
4606
|
continue;
|
|
@@ -4202,7 +4613,9 @@ async function renderDesign(input, options = {}) {
|
|
|
4202
4613
|
);
|
|
4203
4614
|
}
|
|
4204
4615
|
const edgeRoute = edgeRoutes?.get(`${element.from}-${element.to}`);
|
|
4205
|
-
elements.push(
|
|
4616
|
+
elements.push(
|
|
4617
|
+
...renderConnection(ctx, element, fromRect, toRect, theme, edgeRoute, { diagramCenter })
|
|
4618
|
+
);
|
|
4206
4619
|
}
|
|
4207
4620
|
if (footerRect && spec.footer) {
|
|
4208
4621
|
const footerText = spec.footer.tagline ? `${spec.footer.text} \u2022 ${spec.footer.tagline}` : spec.footer.text;
|
|
@@ -4565,6 +4978,36 @@ var renderOutputSchema = z3.object({
|
|
|
4565
4978
|
)
|
|
4566
4979
|
})
|
|
4567
4980
|
});
|
|
4981
|
+
var compareOutputSchema = z3.object({
|
|
4982
|
+
targetPath: z3.string(),
|
|
4983
|
+
renderedPath: z3.string(),
|
|
4984
|
+
targetDimensions: z3.object({
|
|
4985
|
+
width: z3.number().int().positive(),
|
|
4986
|
+
height: z3.number().int().positive()
|
|
4987
|
+
}),
|
|
4988
|
+
renderedDimensions: z3.object({
|
|
4989
|
+
width: z3.number().int().positive(),
|
|
4990
|
+
height: z3.number().int().positive()
|
|
4991
|
+
}),
|
|
4992
|
+
normalizedDimensions: z3.object({
|
|
4993
|
+
width: z3.number().int().positive(),
|
|
4994
|
+
height: z3.number().int().positive()
|
|
4995
|
+
}),
|
|
4996
|
+
dimensionMismatch: z3.boolean(),
|
|
4997
|
+
grid: z3.number().int().positive(),
|
|
4998
|
+
threshold: z3.number(),
|
|
4999
|
+
closeThreshold: z3.number(),
|
|
5000
|
+
similarity: z3.number(),
|
|
5001
|
+
verdict: z3.enum(["match", "close", "mismatch"]),
|
|
5002
|
+
regions: z3.array(
|
|
5003
|
+
z3.object({
|
|
5004
|
+
label: z3.string(),
|
|
5005
|
+
row: z3.number().int().nonnegative(),
|
|
5006
|
+
column: z3.number().int().nonnegative(),
|
|
5007
|
+
similarity: z3.number()
|
|
5008
|
+
})
|
|
5009
|
+
)
|
|
5010
|
+
});
|
|
4568
5011
|
async function readJson(path) {
|
|
4569
5012
|
if (path === "-") {
|
|
4570
5013
|
const chunks = [];
|
|
@@ -4667,6 +5110,44 @@ cli.command("render", {
|
|
|
4667
5110
|
return c.ok(runReport);
|
|
4668
5111
|
}
|
|
4669
5112
|
});
|
|
5113
|
+
cli.command("compare", {
|
|
5114
|
+
description: "Compare a rendered design against a target image using structural similarity scoring.",
|
|
5115
|
+
options: z3.object({
|
|
5116
|
+
target: z3.string().describe("Path to target image (baseline)"),
|
|
5117
|
+
rendered: z3.string().describe("Path to rendered image to evaluate"),
|
|
5118
|
+
grid: z3.number().int().positive().default(3).describe("Grid size for per-region scoring"),
|
|
5119
|
+
threshold: z3.number().min(0).max(1).default(0.8).describe("Minimum similarity score required for a match verdict")
|
|
5120
|
+
}),
|
|
5121
|
+
output: compareOutputSchema,
|
|
5122
|
+
examples: [
|
|
5123
|
+
{
|
|
5124
|
+
options: {
|
|
5125
|
+
target: "./designs/target.png",
|
|
5126
|
+
rendered: "./output/design-v2-g0.4.0-sabc123.png",
|
|
5127
|
+
grid: 3,
|
|
5128
|
+
threshold: 0.8
|
|
5129
|
+
},
|
|
5130
|
+
description: "Compare two images and report overall + per-region similarity scores"
|
|
5131
|
+
}
|
|
5132
|
+
],
|
|
5133
|
+
async run(c) {
|
|
5134
|
+
try {
|
|
5135
|
+
return c.ok(
|
|
5136
|
+
await compareImages(c.options.target, c.options.rendered, {
|
|
5137
|
+
grid: c.options.grid,
|
|
5138
|
+
threshold: c.options.threshold
|
|
5139
|
+
})
|
|
5140
|
+
);
|
|
5141
|
+
} catch (error) {
|
|
5142
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5143
|
+
return c.error({
|
|
5144
|
+
code: "COMPARE_FAILED",
|
|
5145
|
+
message: `Unable to compare images: ${message}`,
|
|
5146
|
+
retryable: false
|
|
5147
|
+
});
|
|
5148
|
+
}
|
|
5149
|
+
}
|
|
5150
|
+
});
|
|
4670
5151
|
var template = Cli.create("template", {
|
|
4671
5152
|
description: "Generate common design templates and run the full render \u2192 QA pipeline."
|
|
4672
5153
|
});
|
|
@@ -4908,7 +5389,8 @@ cli.command("qa", {
|
|
|
4908
5389
|
options: z3.object({
|
|
4909
5390
|
in: z3.string().describe("Path to rendered PNG"),
|
|
4910
5391
|
spec: z3.string().describe("Path to normalized DesignSpec JSON"),
|
|
4911
|
-
meta: z3.string().optional().describe("Optional sidecar metadata path (.meta.json)")
|
|
5392
|
+
meta: z3.string().optional().describe("Optional sidecar metadata path (.meta.json)"),
|
|
5393
|
+
reference: z3.string().optional().describe("Optional reference image path for visual comparison")
|
|
4912
5394
|
}),
|
|
4913
5395
|
output: z3.object({
|
|
4914
5396
|
pass: z3.boolean(),
|
|
@@ -4922,7 +5404,18 @@ cli.command("qa", {
|
|
|
4922
5404
|
message: z3.string(),
|
|
4923
5405
|
elementId: z3.string().optional()
|
|
4924
5406
|
})
|
|
4925
|
-
)
|
|
5407
|
+
),
|
|
5408
|
+
reference: z3.object({
|
|
5409
|
+
similarity: z3.number(),
|
|
5410
|
+
verdict: z3.enum(["match", "close", "mismatch"]),
|
|
5411
|
+
regions: z3.array(
|
|
5412
|
+
z3.object({
|
|
5413
|
+
label: z3.string(),
|
|
5414
|
+
similarity: z3.number(),
|
|
5415
|
+
description: z3.string().optional()
|
|
5416
|
+
})
|
|
5417
|
+
)
|
|
5418
|
+
}).optional()
|
|
4926
5419
|
}),
|
|
4927
5420
|
examples: [
|
|
4928
5421
|
{
|
|
@@ -4945,14 +5438,16 @@ cli.command("qa", {
|
|
|
4945
5438
|
const report = await runQa({
|
|
4946
5439
|
imagePath: c.options.in,
|
|
4947
5440
|
spec,
|
|
4948
|
-
...metadata ? { metadata } : {}
|
|
5441
|
+
...metadata ? { metadata } : {},
|
|
5442
|
+
...c.options.reference ? { referencePath: c.options.reference } : {}
|
|
4949
5443
|
});
|
|
4950
5444
|
const response = {
|
|
4951
5445
|
pass: report.pass,
|
|
4952
5446
|
checkedAt: report.checkedAt,
|
|
4953
5447
|
imagePath: report.imagePath,
|
|
4954
5448
|
issueCount: report.issues.length,
|
|
4955
|
-
issues: report.issues
|
|
5449
|
+
issues: report.issues,
|
|
5450
|
+
...report.reference ? { reference: report.reference } : {}
|
|
4956
5451
|
};
|
|
4957
5452
|
if (!report.pass) {
|
|
4958
5453
|
return c.error({
|