@spectratools/graphic-designer-cli 0.3.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -2
- package/dist/cli.js +1252 -517
- package/dist/index.d.ts +105 -5
- package/dist/index.js +1271 -513
- package/dist/qa.d.ts +14 -3
- package/dist/qa.js +361 -50
- package/dist/renderer.d.ts +1 -1
- package/dist/renderer.js +909 -431
- package/dist/{spec.schema-DhAI-tE8.d.ts → spec.schema-Dm_wOLTd.d.ts} +2028 -599
- package/dist/spec.schema.d.ts +1 -1
- package/dist/spec.schema.js +158 -10
- 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)";
|
|
@@ -259,7 +406,110 @@ import { z as z2 } from "zod";
|
|
|
259
406
|
|
|
260
407
|
// src/themes/builtin.ts
|
|
261
408
|
import { z } from "zod";
|
|
262
|
-
|
|
409
|
+
|
|
410
|
+
// src/utils/color.ts
|
|
411
|
+
function parseChannel(hex, offset) {
|
|
412
|
+
return Number.parseInt(hex.slice(offset, offset + 2), 16);
|
|
413
|
+
}
|
|
414
|
+
function parseHexColor(hexColor) {
|
|
415
|
+
const normalized = hexColor.startsWith("#") ? hexColor.slice(1) : hexColor;
|
|
416
|
+
if (normalized.length !== 6 && normalized.length !== 8) {
|
|
417
|
+
throw new Error(`Unsupported color format: ${hexColor}`);
|
|
418
|
+
}
|
|
419
|
+
return {
|
|
420
|
+
r: parseChannel(normalized, 0),
|
|
421
|
+
g: parseChannel(normalized, 2),
|
|
422
|
+
b: parseChannel(normalized, 4)
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
var rgbaRegex = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*([01](?:\.\d+)?|0?\.\d+)\s*)?\)$/;
|
|
426
|
+
var hexColorRegex = /^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
|
427
|
+
function toHex(n) {
|
|
428
|
+
return n.toString(16).padStart(2, "0");
|
|
429
|
+
}
|
|
430
|
+
function parseRgbaToHex(color) {
|
|
431
|
+
const match = rgbaRegex.exec(color);
|
|
432
|
+
if (!match) {
|
|
433
|
+
throw new Error(`Invalid rgb/rgba color: ${color}`);
|
|
434
|
+
}
|
|
435
|
+
const r = Number.parseInt(match[1], 10);
|
|
436
|
+
const g = Number.parseInt(match[2], 10);
|
|
437
|
+
const b = Number.parseInt(match[3], 10);
|
|
438
|
+
if (r > 255 || g > 255 || b > 255) {
|
|
439
|
+
throw new Error(`RGB channel values must be 0-255, got: ${color}`);
|
|
440
|
+
}
|
|
441
|
+
if (match[4] !== void 0) {
|
|
442
|
+
const a = Number.parseFloat(match[4]);
|
|
443
|
+
if (a < 0 || a > 1) {
|
|
444
|
+
throw new Error(`Alpha value must be 0-1, got: ${a}`);
|
|
445
|
+
}
|
|
446
|
+
const alphaByte = Math.round(a * 255);
|
|
447
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(alphaByte)}`;
|
|
448
|
+
}
|
|
449
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
450
|
+
}
|
|
451
|
+
function isRgbaColor(color) {
|
|
452
|
+
return rgbaRegex.test(color);
|
|
453
|
+
}
|
|
454
|
+
function isHexColor(color) {
|
|
455
|
+
return hexColorRegex.test(color);
|
|
456
|
+
}
|
|
457
|
+
function normalizeColor(color) {
|
|
458
|
+
if (isHexColor(color)) {
|
|
459
|
+
return color;
|
|
460
|
+
}
|
|
461
|
+
if (isRgbaColor(color)) {
|
|
462
|
+
return parseRgbaToHex(color);
|
|
463
|
+
}
|
|
464
|
+
throw new Error(`Expected #RRGGBB, #RRGGBBAA, rgb(), or rgba() color, got: ${color}`);
|
|
465
|
+
}
|
|
466
|
+
function srgbToLinear(channel) {
|
|
467
|
+
const normalized = channel / 255;
|
|
468
|
+
if (normalized <= 0.03928) {
|
|
469
|
+
return normalized / 12.92;
|
|
470
|
+
}
|
|
471
|
+
return ((normalized + 0.055) / 1.055) ** 2.4;
|
|
472
|
+
}
|
|
473
|
+
function relativeLuminance(hexColor) {
|
|
474
|
+
const normalized = isRgbaColor(hexColor) ? parseRgbaToHex(hexColor) : hexColor;
|
|
475
|
+
const rgb = parseHexColor(normalized);
|
|
476
|
+
const r = srgbToLinear(rgb.r);
|
|
477
|
+
const g = srgbToLinear(rgb.g);
|
|
478
|
+
const b = srgbToLinear(rgb.b);
|
|
479
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
480
|
+
}
|
|
481
|
+
function contrastRatio(foreground, background) {
|
|
482
|
+
const fg = relativeLuminance(foreground);
|
|
483
|
+
const bg = relativeLuminance(background);
|
|
484
|
+
const lighter = Math.max(fg, bg);
|
|
485
|
+
const darker = Math.min(fg, bg);
|
|
486
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
487
|
+
}
|
|
488
|
+
function withAlpha(hexColor, opacity) {
|
|
489
|
+
const rgb = parseHexColor(hexColor);
|
|
490
|
+
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${opacity})`;
|
|
491
|
+
}
|
|
492
|
+
function blendColorWithOpacity(foreground, background, opacity) {
|
|
493
|
+
const fg = parseHexColor(foreground);
|
|
494
|
+
const bg = parseHexColor(background);
|
|
495
|
+
const r = Math.round(fg.r * opacity + bg.r * (1 - opacity));
|
|
496
|
+
const g = Math.round(fg.g * opacity + bg.g * (1 - opacity));
|
|
497
|
+
const b = Math.round(fg.b * opacity + bg.b * (1 - opacity));
|
|
498
|
+
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`.toUpperCase();
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// src/themes/builtin.ts
|
|
502
|
+
var colorHexSchema = z.string().refine(
|
|
503
|
+
(v) => {
|
|
504
|
+
try {
|
|
505
|
+
normalizeColor(v);
|
|
506
|
+
return true;
|
|
507
|
+
} catch {
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
},
|
|
511
|
+
{ message: "Expected #RRGGBB, #RRGGBBAA, rgb(), or rgba() color" }
|
|
512
|
+
).transform((v) => normalizeColor(v));
|
|
263
513
|
var fontFamilySchema = z.string().min(1).max(120);
|
|
264
514
|
var codeThemeSchema = z.object({
|
|
265
515
|
background: colorHexSchema,
|
|
@@ -480,7 +730,17 @@ function resolveTheme(theme) {
|
|
|
480
730
|
}
|
|
481
731
|
|
|
482
732
|
// src/spec.schema.ts
|
|
483
|
-
var colorHexSchema2 = z2.string().
|
|
733
|
+
var colorHexSchema2 = z2.string().refine(
|
|
734
|
+
(v) => {
|
|
735
|
+
try {
|
|
736
|
+
normalizeColor(v);
|
|
737
|
+
return true;
|
|
738
|
+
} catch {
|
|
739
|
+
return false;
|
|
740
|
+
}
|
|
741
|
+
},
|
|
742
|
+
{ message: "Expected #RRGGBB, #RRGGBBAA, rgb(), or rgba() color" }
|
|
743
|
+
).transform((v) => normalizeColor(v));
|
|
484
744
|
var gradientStopSchema = z2.object({
|
|
485
745
|
offset: z2.number().min(0).max(1),
|
|
486
746
|
color: colorHexSchema2
|
|
@@ -664,13 +924,32 @@ var cardElementSchema = z2.object({
|
|
|
664
924
|
tone: z2.enum(["neutral", "accent", "success", "warning", "error"]).default("neutral"),
|
|
665
925
|
icon: z2.string().min(1).max(64).optional()
|
|
666
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();
|
|
667
934
|
var flowNodeElementSchema = z2.object({
|
|
668
935
|
type: z2.literal("flow-node"),
|
|
669
936
|
id: z2.string().min(1).max(120),
|
|
670
|
-
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"),
|
|
671
947
|
label: z2.string().min(1).max(200),
|
|
672
948
|
sublabel: z2.string().min(1).max(300).optional(),
|
|
673
949
|
sublabelColor: colorHexSchema2.optional(),
|
|
950
|
+
sublabel2: z2.string().min(1).max(300).optional(),
|
|
951
|
+
sublabel2Color: colorHexSchema2.optional(),
|
|
952
|
+
sublabel2FontSize: z2.number().min(8).max(32).optional(),
|
|
674
953
|
labelColor: colorHexSchema2.optional(),
|
|
675
954
|
labelFontSize: z2.number().min(10).max(48).optional(),
|
|
676
955
|
color: colorHexSchema2.optional(),
|
|
@@ -679,20 +958,30 @@ var flowNodeElementSchema = z2.object({
|
|
|
679
958
|
cornerRadius: z2.number().min(0).max(64).optional(),
|
|
680
959
|
width: z2.number().int().min(40).max(800).optional(),
|
|
681
960
|
height: z2.number().int().min(30).max(600).optional(),
|
|
682
|
-
|
|
961
|
+
fillOpacity: z2.number().min(0).max(1).default(1),
|
|
962
|
+
opacity: z2.number().min(0).max(1).default(1),
|
|
963
|
+
badgeText: z2.string().min(1).max(32).optional(),
|
|
964
|
+
badgeColor: colorHexSchema2.optional(),
|
|
965
|
+
badgeBackground: colorHexSchema2.optional(),
|
|
966
|
+
badgePosition: z2.enum(["top", "inside-top"]).default("inside-top"),
|
|
967
|
+
shadow: flowNodeShadowSchema.optional()
|
|
683
968
|
}).strict();
|
|
684
969
|
var connectionElementSchema = z2.object({
|
|
685
970
|
type: z2.literal("connection"),
|
|
686
971
|
from: z2.string().min(1).max(120),
|
|
687
972
|
to: z2.string().min(1).max(120),
|
|
688
973
|
style: z2.enum(["solid", "dashed", "dotted"]).default("solid"),
|
|
974
|
+
strokeStyle: z2.enum(["solid", "dashed", "dotted"]).default("solid"),
|
|
689
975
|
arrow: z2.enum(["end", "start", "both", "none"]).default("end"),
|
|
690
976
|
label: z2.string().min(1).max(200).optional(),
|
|
691
977
|
labelPosition: z2.enum(["start", "middle", "end"]).default("middle"),
|
|
692
978
|
color: colorHexSchema2.optional(),
|
|
693
|
-
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),
|
|
694
981
|
arrowSize: z2.number().min(4).max(32).optional(),
|
|
695
|
-
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)
|
|
696
985
|
}).strict();
|
|
697
986
|
var codeBlockStyleSchema = z2.object({
|
|
698
987
|
paddingVertical: z2.number().min(0).max(128).default(56),
|
|
@@ -761,6 +1050,10 @@ var elementSchema = z2.discriminatedUnion("type", [
|
|
|
761
1050
|
shapeElementSchema,
|
|
762
1051
|
imageElementSchema
|
|
763
1052
|
]);
|
|
1053
|
+
var diagramCenterSchema = z2.object({
|
|
1054
|
+
x: z2.number(),
|
|
1055
|
+
y: z2.number()
|
|
1056
|
+
}).strict();
|
|
764
1057
|
var autoLayoutConfigSchema = z2.object({
|
|
765
1058
|
mode: z2.literal("auto"),
|
|
766
1059
|
algorithm: z2.enum(["layered", "stress", "force", "radial", "box"]).default("layered"),
|
|
@@ -768,7 +1061,17 @@ var autoLayoutConfigSchema = z2.object({
|
|
|
768
1061
|
nodeSpacing: z2.number().int().min(0).max(512).default(80),
|
|
769
1062
|
rankSpacing: z2.number().int().min(0).max(512).default(120),
|
|
770
1063
|
edgeRouting: z2.enum(["orthogonal", "polyline", "spline"]).default("polyline"),
|
|
771
|
-
aspectRatio: z2.number().min(0.5).max(3).optional()
|
|
1064
|
+
aspectRatio: z2.number().min(0.5).max(3).optional(),
|
|
1065
|
+
/** ID of the root node for radial layout. Only relevant when algorithm is 'radial'. */
|
|
1066
|
+
radialRoot: z2.string().min(1).max(120).optional(),
|
|
1067
|
+
/** Fixed radius in pixels for radial layout. Only relevant when algorithm is 'radial'. */
|
|
1068
|
+
radialRadius: z2.number().positive().optional(),
|
|
1069
|
+
/** Compaction strategy for radial layout. Only relevant when algorithm is 'radial'. */
|
|
1070
|
+
radialCompaction: z2.enum(["none", "radial", "wedge"]).optional(),
|
|
1071
|
+
/** Sort strategy for radial layout node ordering. Only relevant when algorithm is 'radial'. */
|
|
1072
|
+
radialSortBy: z2.enum(["id", "connections"]).optional(),
|
|
1073
|
+
/** Explicit center used by curve/arc connection routing. */
|
|
1074
|
+
diagramCenter: diagramCenterSchema.optional()
|
|
772
1075
|
}).strict();
|
|
773
1076
|
var gridLayoutConfigSchema = z2.object({
|
|
774
1077
|
mode: z2.literal("grid"),
|
|
@@ -776,13 +1079,17 @@ var gridLayoutConfigSchema = z2.object({
|
|
|
776
1079
|
gap: z2.number().int().min(0).max(256).default(24),
|
|
777
1080
|
cardMinHeight: z2.number().int().min(32).max(4096).optional(),
|
|
778
1081
|
cardMaxHeight: z2.number().int().min(32).max(4096).optional(),
|
|
779
|
-
equalHeight: z2.boolean().default(false)
|
|
1082
|
+
equalHeight: z2.boolean().default(false),
|
|
1083
|
+
/** Explicit center used by curve/arc connection routing. */
|
|
1084
|
+
diagramCenter: diagramCenterSchema.optional()
|
|
780
1085
|
}).strict();
|
|
781
1086
|
var stackLayoutConfigSchema = z2.object({
|
|
782
1087
|
mode: z2.literal("stack"),
|
|
783
1088
|
direction: z2.enum(["vertical", "horizontal"]).default("vertical"),
|
|
784
1089
|
gap: z2.number().int().min(0).max(256).default(24),
|
|
785
|
-
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()
|
|
786
1093
|
}).strict();
|
|
787
1094
|
var manualPositionSchema = z2.object({
|
|
788
1095
|
x: z2.number().int(),
|
|
@@ -792,7 +1099,9 @@ var manualPositionSchema = z2.object({
|
|
|
792
1099
|
}).strict();
|
|
793
1100
|
var manualLayoutConfigSchema = z2.object({
|
|
794
1101
|
mode: z2.literal("manual"),
|
|
795
|
-
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()
|
|
796
1105
|
}).strict();
|
|
797
1106
|
var layoutConfigSchema = z2.discriminatedUnion("mode", [
|
|
798
1107
|
autoLayoutConfigSchema,
|
|
@@ -844,6 +1153,31 @@ var canvasSchema = z2.object({
|
|
|
844
1153
|
padding: z2.number().int().min(0).max(256).default(defaultCanvas.padding)
|
|
845
1154
|
}).strict();
|
|
846
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();
|
|
847
1181
|
var designSpecSchema = z2.object({
|
|
848
1182
|
version: z2.literal(2).default(2),
|
|
849
1183
|
canvas: canvasSchema.default(defaultCanvas),
|
|
@@ -872,43 +1206,6 @@ function parseDesignSpec(input) {
|
|
|
872
1206
|
return designSpecSchema.parse(input);
|
|
873
1207
|
}
|
|
874
1208
|
|
|
875
|
-
// src/utils/color.ts
|
|
876
|
-
function parseChannel(hex, offset) {
|
|
877
|
-
return Number.parseInt(hex.slice(offset, offset + 2), 16);
|
|
878
|
-
}
|
|
879
|
-
function parseHexColor(hexColor) {
|
|
880
|
-
const normalized = hexColor.startsWith("#") ? hexColor.slice(1) : hexColor;
|
|
881
|
-
if (normalized.length !== 6 && normalized.length !== 8) {
|
|
882
|
-
throw new Error(`Unsupported color format: ${hexColor}`);
|
|
883
|
-
}
|
|
884
|
-
return {
|
|
885
|
-
r: parseChannel(normalized, 0),
|
|
886
|
-
g: parseChannel(normalized, 2),
|
|
887
|
-
b: parseChannel(normalized, 4)
|
|
888
|
-
};
|
|
889
|
-
}
|
|
890
|
-
function srgbToLinear(channel) {
|
|
891
|
-
const normalized = channel / 255;
|
|
892
|
-
if (normalized <= 0.03928) {
|
|
893
|
-
return normalized / 12.92;
|
|
894
|
-
}
|
|
895
|
-
return ((normalized + 0.055) / 1.055) ** 2.4;
|
|
896
|
-
}
|
|
897
|
-
function relativeLuminance(hexColor) {
|
|
898
|
-
const rgb = parseHexColor(hexColor);
|
|
899
|
-
const r = srgbToLinear(rgb.r);
|
|
900
|
-
const g = srgbToLinear(rgb.g);
|
|
901
|
-
const b = srgbToLinear(rgb.b);
|
|
902
|
-
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
903
|
-
}
|
|
904
|
-
function contrastRatio(foreground, background) {
|
|
905
|
-
const fg = relativeLuminance(foreground);
|
|
906
|
-
const bg = relativeLuminance(background);
|
|
907
|
-
const lighter = Math.max(fg, bg);
|
|
908
|
-
const darker = Math.min(fg, bg);
|
|
909
|
-
return (lighter + 0.05) / (darker + 0.05);
|
|
910
|
-
}
|
|
911
|
-
|
|
912
1209
|
// src/qa.ts
|
|
913
1210
|
function rectWithin(outer, inner) {
|
|
914
1211
|
return inner.x >= outer.x && inner.y >= outer.y && inner.x + inner.width <= outer.x + outer.width && inner.y + inner.height <= outer.y + outer.height;
|
|
@@ -954,7 +1251,7 @@ async function runQa(options) {
|
|
|
954
1251
|
const imagePath = resolve(options.imagePath);
|
|
955
1252
|
const expectedSafeFrame = deriveSafeFrame(spec);
|
|
956
1253
|
const expectedCanvas = canvasRect(spec);
|
|
957
|
-
const imageMetadata = await
|
|
1254
|
+
const imageMetadata = await sharp2(imagePath).metadata();
|
|
958
1255
|
const issues = [];
|
|
959
1256
|
const expectedScale = options.metadata?.canvas.scale ?? resolveRenderScale(spec);
|
|
960
1257
|
const expectedWidth = spec.canvas.width * expectedScale;
|
|
@@ -1105,6 +1402,31 @@ async function runQa(options) {
|
|
|
1105
1402
|
});
|
|
1106
1403
|
}
|
|
1107
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
|
+
}
|
|
1108
1430
|
const footerSpacingPx = options.metadata?.layout.elements ? (() => {
|
|
1109
1431
|
const footer = options.metadata.layout.elements.find((element) => element.id === "footer");
|
|
1110
1432
|
if (!footer) {
|
|
@@ -1137,7 +1459,8 @@ async function runQa(options) {
|
|
|
1137
1459
|
...imageMetadata.height !== void 0 ? { height: imageMetadata.height } : {},
|
|
1138
1460
|
...footerSpacingPx !== void 0 ? { footerSpacingPx } : {}
|
|
1139
1461
|
},
|
|
1140
|
-
issues
|
|
1462
|
+
issues,
|
|
1463
|
+
...referenceResult ? { reference: referenceResult } : {}
|
|
1141
1464
|
};
|
|
1142
1465
|
}
|
|
1143
1466
|
|
|
@@ -1175,89 +1498,484 @@ function loadFonts() {
|
|
|
1175
1498
|
// src/layout/elk.ts
|
|
1176
1499
|
import ELK from "elkjs";
|
|
1177
1500
|
|
|
1178
|
-
// src/
|
|
1179
|
-
function
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
return 220;
|
|
1195
|
-
case "connection":
|
|
1196
|
-
return 0;
|
|
1197
|
-
}
|
|
1501
|
+
// src/primitives/shapes.ts
|
|
1502
|
+
function roundRectPath(ctx, rect, radius) {
|
|
1503
|
+
const r = Math.max(0, Math.min(radius, rect.width / 2, rect.height / 2));
|
|
1504
|
+
const right = rect.x + rect.width;
|
|
1505
|
+
const bottom = rect.y + rect.height;
|
|
1506
|
+
ctx.beginPath();
|
|
1507
|
+
ctx.moveTo(rect.x + r, rect.y);
|
|
1508
|
+
ctx.lineTo(right - r, rect.y);
|
|
1509
|
+
ctx.quadraticCurveTo(right, rect.y, right, rect.y + r);
|
|
1510
|
+
ctx.lineTo(right, bottom - r);
|
|
1511
|
+
ctx.quadraticCurveTo(right, bottom, right - r, bottom);
|
|
1512
|
+
ctx.lineTo(rect.x + r, bottom);
|
|
1513
|
+
ctx.quadraticCurveTo(rect.x, bottom, rect.x, bottom - r);
|
|
1514
|
+
ctx.lineTo(rect.x, rect.y + r);
|
|
1515
|
+
ctx.quadraticCurveTo(rect.x, rect.y, rect.x + r, rect.y);
|
|
1516
|
+
ctx.closePath();
|
|
1198
1517
|
}
|
|
1199
|
-
function
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
case "code-block":
|
|
1206
|
-
return 420;
|
|
1207
|
-
case "terminal":
|
|
1208
|
-
return 420;
|
|
1209
|
-
case "text":
|
|
1210
|
-
return 360;
|
|
1211
|
-
case "shape":
|
|
1212
|
-
return 280;
|
|
1213
|
-
case "image":
|
|
1214
|
-
return 320;
|
|
1215
|
-
case "connection":
|
|
1216
|
-
return 0;
|
|
1518
|
+
function fillAndStroke(ctx, fill, stroke) {
|
|
1519
|
+
ctx.fillStyle = fill;
|
|
1520
|
+
ctx.fill();
|
|
1521
|
+
if (stroke) {
|
|
1522
|
+
ctx.strokeStyle = stroke;
|
|
1523
|
+
ctx.stroke();
|
|
1217
1524
|
}
|
|
1218
1525
|
}
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1526
|
+
function drawRoundedRect(ctx, rect, radius, fill, stroke) {
|
|
1527
|
+
roundRectPath(ctx, rect, radius);
|
|
1528
|
+
fillAndStroke(ctx, fill, stroke);
|
|
1529
|
+
}
|
|
1530
|
+
function drawCircle(ctx, center, radius, fill, stroke) {
|
|
1531
|
+
ctx.beginPath();
|
|
1532
|
+
ctx.arc(center.x, center.y, Math.max(0, radius), 0, Math.PI * 2);
|
|
1533
|
+
ctx.closePath();
|
|
1534
|
+
fillAndStroke(ctx, fill, stroke);
|
|
1535
|
+
}
|
|
1536
|
+
function drawDiamond(ctx, bounds, fill, stroke) {
|
|
1537
|
+
const cx = bounds.x + bounds.width / 2;
|
|
1538
|
+
const cy = bounds.y + bounds.height / 2;
|
|
1539
|
+
ctx.beginPath();
|
|
1540
|
+
ctx.moveTo(cx, bounds.y);
|
|
1541
|
+
ctx.lineTo(bounds.x + bounds.width, cy);
|
|
1542
|
+
ctx.lineTo(cx, bounds.y + bounds.height);
|
|
1543
|
+
ctx.lineTo(bounds.x, cy);
|
|
1544
|
+
ctx.closePath();
|
|
1545
|
+
fillAndStroke(ctx, fill, stroke);
|
|
1546
|
+
}
|
|
1547
|
+
function drawPill(ctx, bounds, fill, stroke) {
|
|
1548
|
+
drawRoundedRect(ctx, bounds, Math.min(bounds.width, bounds.height) / 2, fill, stroke);
|
|
1549
|
+
}
|
|
1550
|
+
function drawEllipse(ctx, bounds, fill, stroke) {
|
|
1551
|
+
const cx = bounds.x + bounds.width / 2;
|
|
1552
|
+
const cy = bounds.y + bounds.height / 2;
|
|
1553
|
+
ctx.beginPath();
|
|
1554
|
+
ctx.ellipse(
|
|
1555
|
+
cx,
|
|
1556
|
+
cy,
|
|
1557
|
+
Math.max(0, bounds.width / 2),
|
|
1558
|
+
Math.max(0, bounds.height / 2),
|
|
1559
|
+
0,
|
|
1560
|
+
0,
|
|
1561
|
+
Math.PI * 2
|
|
1562
|
+
);
|
|
1563
|
+
ctx.closePath();
|
|
1564
|
+
fillAndStroke(ctx, fill, stroke);
|
|
1565
|
+
}
|
|
1566
|
+
function drawCylinder(ctx, bounds, fill, stroke) {
|
|
1567
|
+
const rx = Math.max(2, bounds.width / 2);
|
|
1568
|
+
const ry = Math.max(2, Math.min(bounds.height * 0.18, 16));
|
|
1569
|
+
const cx = bounds.x + bounds.width / 2;
|
|
1570
|
+
const topCy = bounds.y + ry;
|
|
1571
|
+
const bottomCy = bounds.y + bounds.height - ry;
|
|
1572
|
+
ctx.beginPath();
|
|
1573
|
+
ctx.moveTo(bounds.x, topCy);
|
|
1574
|
+
ctx.ellipse(cx, topCy, rx, ry, 0, Math.PI, 0, true);
|
|
1575
|
+
ctx.lineTo(bounds.x + bounds.width, bottomCy);
|
|
1576
|
+
ctx.ellipse(cx, bottomCy, rx, ry, 0, 0, Math.PI, false);
|
|
1577
|
+
ctx.closePath();
|
|
1578
|
+
fillAndStroke(ctx, fill, stroke);
|
|
1579
|
+
if (stroke) {
|
|
1580
|
+
ctx.beginPath();
|
|
1581
|
+
ctx.ellipse(cx, topCy, rx, ry, 0, 0, Math.PI * 2);
|
|
1582
|
+
ctx.closePath();
|
|
1583
|
+
ctx.strokeStyle = stroke;
|
|
1584
|
+
ctx.stroke();
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
function drawParallelogram(ctx, bounds, fill, stroke, skew) {
|
|
1588
|
+
const maxSkew = bounds.width * 0.45;
|
|
1589
|
+
const skewX = Math.max(-maxSkew, Math.min(maxSkew, skew ?? bounds.width * 0.18));
|
|
1590
|
+
ctx.beginPath();
|
|
1591
|
+
ctx.moveTo(bounds.x + skewX, bounds.y);
|
|
1592
|
+
ctx.lineTo(bounds.x + bounds.width, bounds.y);
|
|
1593
|
+
ctx.lineTo(bounds.x + bounds.width - skewX, bounds.y + bounds.height);
|
|
1594
|
+
ctx.lineTo(bounds.x, bounds.y + bounds.height);
|
|
1595
|
+
ctx.closePath();
|
|
1596
|
+
fillAndStroke(ctx, fill, stroke);
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
// src/primitives/text.ts
|
|
1600
|
+
var SUPPORTED_FONT_FAMILIES = /* @__PURE__ */ new Set(["Inter", "JetBrains Mono", "Space Grotesk"]);
|
|
1601
|
+
function resolveFont(requested, role) {
|
|
1602
|
+
if (SUPPORTED_FONT_FAMILIES.has(requested)) {
|
|
1603
|
+
return requested;
|
|
1604
|
+
}
|
|
1605
|
+
if (role === "mono" || /mono|code|terminal|console/iu.test(requested)) {
|
|
1606
|
+
return "JetBrains Mono";
|
|
1607
|
+
}
|
|
1608
|
+
if (role === "heading" || /display|grotesk|headline/iu.test(requested)) {
|
|
1609
|
+
return "Space Grotesk";
|
|
1610
|
+
}
|
|
1611
|
+
return "Inter";
|
|
1612
|
+
}
|
|
1613
|
+
function applyFont(ctx, options) {
|
|
1614
|
+
ctx.font = `${options.weight} ${options.size}px ${options.family}`;
|
|
1615
|
+
}
|
|
1616
|
+
function wrapText(ctx, text, maxWidth, maxLines) {
|
|
1617
|
+
const trimmed = text.trim();
|
|
1618
|
+
if (!trimmed) {
|
|
1619
|
+
return { lines: [], truncated: false };
|
|
1620
|
+
}
|
|
1621
|
+
const words = trimmed.split(/\s+/u);
|
|
1622
|
+
const lines = [];
|
|
1623
|
+
let current = "";
|
|
1624
|
+
for (const word of words) {
|
|
1625
|
+
const trial = current.length > 0 ? `${current} ${word}` : word;
|
|
1626
|
+
if (ctx.measureText(trial).width <= maxWidth) {
|
|
1627
|
+
current = trial;
|
|
1628
|
+
continue;
|
|
1629
|
+
}
|
|
1630
|
+
if (current.length > 0) {
|
|
1631
|
+
lines.push(current);
|
|
1632
|
+
current = word;
|
|
1633
|
+
} else {
|
|
1634
|
+
lines.push(word);
|
|
1635
|
+
current = "";
|
|
1636
|
+
}
|
|
1637
|
+
if (lines.length >= maxLines) {
|
|
1638
|
+
break;
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
if (lines.length < maxLines && current.length > 0) {
|
|
1642
|
+
lines.push(current);
|
|
1643
|
+
}
|
|
1644
|
+
const wasTruncated = lines.length >= maxLines && words.join(" ") !== lines.join(" ");
|
|
1645
|
+
if (!wasTruncated) {
|
|
1646
|
+
return { lines, truncated: false };
|
|
1647
|
+
}
|
|
1648
|
+
const lastIndex = lines.length - 1;
|
|
1649
|
+
let truncatedLine = `${lines[lastIndex]}\u2026`;
|
|
1650
|
+
while (truncatedLine.length > 1 && ctx.measureText(truncatedLine).width > maxWidth) {
|
|
1651
|
+
truncatedLine = `${truncatedLine.slice(0, -2)}\u2026`;
|
|
1652
|
+
}
|
|
1653
|
+
lines[lastIndex] = truncatedLine;
|
|
1654
|
+
return { lines, truncated: true };
|
|
1655
|
+
}
|
|
1656
|
+
function drawTextBlock(ctx, options) {
|
|
1657
|
+
applyFont(ctx, { size: options.fontSize, weight: options.fontWeight, family: options.family });
|
|
1658
|
+
const wrapped = wrapText(ctx, options.text, options.maxWidth, options.maxLines);
|
|
1659
|
+
ctx.fillStyle = options.color;
|
|
1660
|
+
for (const [index, line] of wrapped.lines.entries()) {
|
|
1661
|
+
ctx.fillText(line, options.x, options.y + index * options.lineHeight);
|
|
1662
|
+
}
|
|
1663
|
+
return {
|
|
1664
|
+
height: wrapped.lines.length * options.lineHeight,
|
|
1665
|
+
truncated: wrapped.truncated
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
function drawTextLabel(ctx, text, position, options) {
|
|
1669
|
+
applyFont(ctx, { size: options.fontSize, weight: 600, family: options.fontFamily });
|
|
1670
|
+
const textWidth = Math.ceil(ctx.measureText(text).width);
|
|
1671
|
+
const rect = {
|
|
1672
|
+
x: Math.round(position.x - (textWidth + options.padding * 2) / 2),
|
|
1673
|
+
y: Math.round(position.y - (options.fontSize + options.padding * 2) / 2),
|
|
1674
|
+
width: textWidth + options.padding * 2,
|
|
1675
|
+
height: options.fontSize + options.padding * 2
|
|
1676
|
+
};
|
|
1677
|
+
drawRoundedRect(ctx, rect, options.borderRadius, options.backgroundColor);
|
|
1678
|
+
ctx.fillStyle = options.color;
|
|
1679
|
+
ctx.fillText(text, rect.x + options.padding, rect.y + rect.height - options.padding);
|
|
1680
|
+
return rect;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// src/renderers/flow-node.ts
|
|
1684
|
+
var BADGE_FONT_SIZE = 10;
|
|
1685
|
+
var BADGE_FONT_WEIGHT = 600;
|
|
1686
|
+
var BADGE_LETTER_SPACING = 1;
|
|
1687
|
+
var BADGE_PADDING_X = 8;
|
|
1688
|
+
var BADGE_PADDING_Y = 3;
|
|
1689
|
+
var BADGE_BORDER_RADIUS = 12;
|
|
1690
|
+
var BADGE_DEFAULT_COLOR = "#FFFFFF";
|
|
1691
|
+
var BADGE_PILL_HEIGHT = BADGE_FONT_SIZE + BADGE_PADDING_Y * 2;
|
|
1692
|
+
var BADGE_INSIDE_TOP_EXTRA = BADGE_PILL_HEIGHT + 6;
|
|
1693
|
+
function drawNodeShape(ctx, shape, bounds, fill, stroke, cornerRadius) {
|
|
1694
|
+
switch (shape) {
|
|
1695
|
+
case "box":
|
|
1696
|
+
drawRoundedRect(ctx, bounds, 0, fill, stroke);
|
|
1697
|
+
break;
|
|
1698
|
+
case "rounded-box":
|
|
1699
|
+
drawRoundedRect(ctx, bounds, cornerRadius, fill, stroke);
|
|
1700
|
+
break;
|
|
1701
|
+
case "diamond":
|
|
1702
|
+
drawDiamond(ctx, bounds, fill, stroke);
|
|
1703
|
+
break;
|
|
1704
|
+
case "circle": {
|
|
1705
|
+
const radius = Math.min(bounds.width, bounds.height) / 2;
|
|
1706
|
+
drawCircle(
|
|
1707
|
+
ctx,
|
|
1708
|
+
{ x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 },
|
|
1709
|
+
radius,
|
|
1710
|
+
fill,
|
|
1711
|
+
stroke
|
|
1712
|
+
);
|
|
1713
|
+
break;
|
|
1714
|
+
}
|
|
1715
|
+
case "pill":
|
|
1716
|
+
drawPill(ctx, bounds, fill, stroke);
|
|
1717
|
+
break;
|
|
1718
|
+
case "cylinder":
|
|
1719
|
+
drawCylinder(ctx, bounds, fill, stroke);
|
|
1720
|
+
break;
|
|
1721
|
+
case "parallelogram":
|
|
1722
|
+
drawParallelogram(ctx, bounds, fill, stroke);
|
|
1723
|
+
break;
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
function measureSpacedText(ctx, text, letterSpacing) {
|
|
1727
|
+
const base = ctx.measureText(text).width;
|
|
1728
|
+
const extraChars = [...text].length - 1;
|
|
1729
|
+
return extraChars > 0 ? base + extraChars * letterSpacing : base;
|
|
1730
|
+
}
|
|
1731
|
+
function drawSpacedText(ctx, text, centerX, centerY, letterSpacing) {
|
|
1732
|
+
const chars = [...text];
|
|
1733
|
+
if (chars.length === 0) return;
|
|
1734
|
+
const totalWidth = measureSpacedText(ctx, text, letterSpacing);
|
|
1735
|
+
let cursorX = centerX - totalWidth / 2;
|
|
1736
|
+
ctx.textAlign = "left";
|
|
1737
|
+
for (let i = 0; i < chars.length; i++) {
|
|
1738
|
+
ctx.fillText(chars[i], cursorX, centerY);
|
|
1739
|
+
cursorX += ctx.measureText(chars[i]).width + (i < chars.length - 1 ? letterSpacing : 0);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
function renderBadgePill(ctx, centerX, centerY, text, textColor, background, monoFont) {
|
|
1743
|
+
ctx.save();
|
|
1744
|
+
applyFont(ctx, { size: BADGE_FONT_SIZE, weight: BADGE_FONT_WEIGHT, family: monoFont });
|
|
1745
|
+
const textWidth = measureSpacedText(ctx, text, BADGE_LETTER_SPACING);
|
|
1746
|
+
const pillWidth = textWidth + BADGE_PADDING_X * 2;
|
|
1747
|
+
const pillHeight = BADGE_PILL_HEIGHT;
|
|
1748
|
+
const pillX = centerX - pillWidth / 2;
|
|
1749
|
+
const pillY = centerY - pillHeight / 2;
|
|
1750
|
+
ctx.fillStyle = background;
|
|
1751
|
+
ctx.beginPath();
|
|
1752
|
+
ctx.roundRect(pillX, pillY, pillWidth, pillHeight, BADGE_BORDER_RADIUS);
|
|
1753
|
+
ctx.fill();
|
|
1754
|
+
ctx.fillStyle = textColor;
|
|
1755
|
+
ctx.textBaseline = "middle";
|
|
1756
|
+
applyFont(ctx, { size: BADGE_FONT_SIZE, weight: BADGE_FONT_WEIGHT, family: monoFont });
|
|
1757
|
+
drawSpacedText(ctx, text, centerX, centerY, BADGE_LETTER_SPACING);
|
|
1758
|
+
ctx.restore();
|
|
1759
|
+
return pillWidth;
|
|
1760
|
+
}
|
|
1761
|
+
function renderFlowNode(ctx, node, bounds, theme) {
|
|
1762
|
+
const fillColor = node.color ?? theme.surfaceElevated;
|
|
1763
|
+
const borderColor = node.borderColor ?? theme.border;
|
|
1764
|
+
const borderWidth = node.borderWidth ?? 2;
|
|
1765
|
+
const cornerRadius = node.cornerRadius ?? 16;
|
|
1766
|
+
const labelColor = node.labelColor ?? theme.text;
|
|
1767
|
+
const sublabelColor = node.sublabelColor ?? theme.textMuted;
|
|
1768
|
+
const labelFontSize = node.labelFontSize ?? 20;
|
|
1769
|
+
const fillOpacity = node.fillOpacity ?? 1;
|
|
1770
|
+
const hasBadge = !!node.badgeText;
|
|
1771
|
+
const badgePosition = node.badgePosition ?? "inside-top";
|
|
1772
|
+
const badgeColor = node.badgeColor ?? BADGE_DEFAULT_COLOR;
|
|
1773
|
+
const badgeBackground = node.badgeBackground ?? borderColor ?? theme.accent;
|
|
1774
|
+
ctx.save();
|
|
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
|
+
}
|
|
1783
|
+
if (fillOpacity < 1) {
|
|
1784
|
+
ctx.globalAlpha = node.opacity * fillOpacity;
|
|
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
|
+
}
|
|
1792
|
+
ctx.globalAlpha = node.opacity;
|
|
1793
|
+
drawNodeShape(ctx, node.shape, bounds, "rgba(0,0,0,0)", borderColor, cornerRadius);
|
|
1794
|
+
} else {
|
|
1795
|
+
ctx.globalAlpha = node.opacity;
|
|
1796
|
+
drawNodeShape(ctx, node.shape, bounds, fillColor, borderColor, cornerRadius);
|
|
1797
|
+
}
|
|
1798
|
+
if (node.shadow) {
|
|
1799
|
+
ctx.shadowColor = "transparent";
|
|
1800
|
+
ctx.shadowBlur = 0;
|
|
1801
|
+
ctx.shadowOffsetX = 0;
|
|
1802
|
+
ctx.shadowOffsetY = 0;
|
|
1803
|
+
}
|
|
1804
|
+
const headingFont = resolveFont(theme.fonts.heading, "heading");
|
|
1805
|
+
const bodyFont = resolveFont(theme.fonts.body, "body");
|
|
1806
|
+
const monoFont = resolveFont(theme.fonts.mono, "mono");
|
|
1807
|
+
const centerX = bounds.x + bounds.width / 2;
|
|
1808
|
+
const centerY = bounds.y + bounds.height / 2;
|
|
1809
|
+
const insideTopShift = hasBadge && badgePosition === "inside-top" ? BADGE_INSIDE_TOP_EXTRA / 2 : 0;
|
|
1810
|
+
const sublabelFontSize = Math.max(12, Math.round(labelFontSize * 0.68));
|
|
1811
|
+
const sublabel2FontSize = node.sublabel2FontSize ?? 11;
|
|
1812
|
+
const sublabel2Color = node.sublabel2Color ?? sublabelColor;
|
|
1813
|
+
const lineCount = node.sublabel2 ? 3 : node.sublabel ? 2 : 1;
|
|
1814
|
+
const labelToSublabelGap = Math.max(20, sublabelFontSize + 6);
|
|
1815
|
+
const sublabelToSublabel2Gap = sublabel2FontSize + 4;
|
|
1816
|
+
let textBlockHeight;
|
|
1817
|
+
if (lineCount === 1) {
|
|
1818
|
+
textBlockHeight = labelFontSize;
|
|
1819
|
+
} else if (lineCount === 2) {
|
|
1820
|
+
textBlockHeight = labelFontSize + labelToSublabelGap;
|
|
1821
|
+
} else {
|
|
1822
|
+
textBlockHeight = labelFontSize + labelToSublabelGap + sublabelToSublabel2Gap;
|
|
1823
|
+
}
|
|
1824
|
+
const labelY = lineCount === 1 ? centerY + labelFontSize * 0.3 + insideTopShift : centerY - textBlockHeight / 2 + labelFontSize * 0.8 + insideTopShift;
|
|
1825
|
+
ctx.textAlign = "center";
|
|
1826
|
+
applyFont(ctx, { size: labelFontSize, weight: 700, family: headingFont });
|
|
1827
|
+
ctx.fillStyle = labelColor;
|
|
1828
|
+
ctx.fillText(node.label, centerX, labelY);
|
|
1829
|
+
let textBoundsY = bounds.y + bounds.height / 2 - 18;
|
|
1830
|
+
let textBoundsHeight = 36;
|
|
1831
|
+
if (node.sublabel) {
|
|
1832
|
+
applyFont(ctx, { size: sublabelFontSize, weight: 500, family: bodyFont });
|
|
1833
|
+
ctx.fillStyle = sublabelColor;
|
|
1834
|
+
ctx.fillText(node.sublabel, centerX, labelY + labelToSublabelGap);
|
|
1835
|
+
textBoundsY = bounds.y + bounds.height / 2 - 24;
|
|
1836
|
+
textBoundsHeight = 56;
|
|
1837
|
+
}
|
|
1838
|
+
if (node.sublabel2) {
|
|
1839
|
+
applyFont(ctx, { size: sublabel2FontSize, weight: 500, family: bodyFont });
|
|
1840
|
+
ctx.fillStyle = sublabel2Color;
|
|
1841
|
+
const sublabel2Y = node.sublabel ? labelY + labelToSublabelGap + sublabelToSublabel2Gap : labelY + labelToSublabelGap;
|
|
1842
|
+
ctx.fillText(node.sublabel2, centerX, sublabel2Y);
|
|
1843
|
+
textBoundsY = bounds.y + bounds.height / 2 - 30;
|
|
1844
|
+
textBoundsHeight = 72;
|
|
1845
|
+
}
|
|
1846
|
+
if (hasBadge && node.badgeText) {
|
|
1847
|
+
if (badgePosition === "inside-top") {
|
|
1848
|
+
const badgeCenterY = bounds.y + BADGE_PILL_HEIGHT / 2 + 8;
|
|
1849
|
+
renderBadgePill(
|
|
1850
|
+
ctx,
|
|
1851
|
+
centerX,
|
|
1852
|
+
badgeCenterY,
|
|
1853
|
+
node.badgeText,
|
|
1854
|
+
badgeColor,
|
|
1855
|
+
badgeBackground,
|
|
1856
|
+
monoFont
|
|
1857
|
+
);
|
|
1858
|
+
} else {
|
|
1859
|
+
const badgeCenterY = bounds.y - BADGE_PILL_HEIGHT / 2 - 4;
|
|
1860
|
+
renderBadgePill(
|
|
1861
|
+
ctx,
|
|
1862
|
+
centerX,
|
|
1863
|
+
badgeCenterY,
|
|
1864
|
+
node.badgeText,
|
|
1865
|
+
badgeColor,
|
|
1866
|
+
badgeBackground,
|
|
1867
|
+
monoFont
|
|
1868
|
+
);
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
ctx.restore();
|
|
1872
|
+
const effectiveBg = fillOpacity < 1 ? blendColorWithOpacity(fillColor, theme.background, fillOpacity) : fillColor;
|
|
1873
|
+
return [
|
|
1874
|
+
{
|
|
1875
|
+
id: `flow-node-${node.id}`,
|
|
1876
|
+
kind: "flow-node",
|
|
1877
|
+
bounds,
|
|
1878
|
+
foregroundColor: labelColor,
|
|
1879
|
+
backgroundColor: effectiveBg
|
|
1880
|
+
},
|
|
1881
|
+
{
|
|
1882
|
+
id: `flow-node-${node.id}-label`,
|
|
1883
|
+
kind: "text",
|
|
1884
|
+
bounds: {
|
|
1885
|
+
x: bounds.x + 8,
|
|
1886
|
+
y: textBoundsY,
|
|
1887
|
+
width: bounds.width - 16,
|
|
1888
|
+
height: textBoundsHeight
|
|
1889
|
+
},
|
|
1890
|
+
foregroundColor: labelColor,
|
|
1891
|
+
backgroundColor: effectiveBg
|
|
1892
|
+
}
|
|
1893
|
+
];
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
// src/layout/estimates.ts
|
|
1897
|
+
function estimateElementHeight(element) {
|
|
1898
|
+
switch (element.type) {
|
|
1899
|
+
case "card":
|
|
1900
|
+
return 220;
|
|
1901
|
+
case "flow-node":
|
|
1902
|
+
return element.shape === "circle" || element.shape === "diamond" ? 160 : 130;
|
|
1903
|
+
case "code-block":
|
|
1904
|
+
return 260;
|
|
1905
|
+
case "terminal":
|
|
1906
|
+
return 245;
|
|
1907
|
+
case "text":
|
|
1908
|
+
return element.style === "heading" ? 140 : element.style === "subheading" ? 110 : 90;
|
|
1909
|
+
case "shape":
|
|
1910
|
+
return 130;
|
|
1911
|
+
case "image":
|
|
1912
|
+
return 220;
|
|
1913
|
+
case "connection":
|
|
1914
|
+
return 0;
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
function estimateElementWidth(element) {
|
|
1918
|
+
switch (element.type) {
|
|
1919
|
+
case "card":
|
|
1920
|
+
return 320;
|
|
1921
|
+
case "flow-node":
|
|
1922
|
+
return element.shape === "circle" || element.shape === "diamond" ? 160 : 220;
|
|
1923
|
+
case "code-block":
|
|
1924
|
+
return 420;
|
|
1925
|
+
case "terminal":
|
|
1926
|
+
return 420;
|
|
1927
|
+
case "text":
|
|
1928
|
+
return 360;
|
|
1929
|
+
case "shape":
|
|
1930
|
+
return 280;
|
|
1931
|
+
case "image":
|
|
1932
|
+
return 320;
|
|
1933
|
+
case "connection":
|
|
1934
|
+
return 0;
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
// src/layout/stack.ts
|
|
1939
|
+
function computeStackLayout(elements, config, safeFrame) {
|
|
1940
|
+
const placeable = elements.filter((element) => element.type !== "connection");
|
|
1941
|
+
const positions = /* @__PURE__ */ new Map();
|
|
1942
|
+
if (placeable.length === 0) {
|
|
1943
|
+
return { positions };
|
|
1944
|
+
}
|
|
1945
|
+
const gap = config.gap;
|
|
1946
|
+
if (config.direction === "vertical") {
|
|
1947
|
+
const estimatedHeights = placeable.map((element) => estimateElementHeight(element));
|
|
1948
|
+
const totalEstimated2 = estimatedHeights.reduce((sum, value) => sum + value, 0);
|
|
1949
|
+
const available2 = Math.max(0, safeFrame.height - gap * (placeable.length - 1));
|
|
1950
|
+
const scale2 = totalEstimated2 > 0 ? Math.min(1, available2 / totalEstimated2) : 1;
|
|
1951
|
+
let y = safeFrame.y;
|
|
1952
|
+
for (const [index, element] of placeable.entries()) {
|
|
1953
|
+
const stretched = config.alignment === "stretch";
|
|
1954
|
+
const width = stretched ? safeFrame.width : Math.min(safeFrame.width, Math.floor(estimateElementWidth(element)));
|
|
1955
|
+
const height = Math.max(48, Math.floor(estimatedHeights[index] * scale2));
|
|
1956
|
+
let x2 = safeFrame.x;
|
|
1957
|
+
if (!stretched) {
|
|
1958
|
+
if (config.alignment === "center") {
|
|
1959
|
+
x2 = safeFrame.x + Math.floor((safeFrame.width - width) / 2);
|
|
1960
|
+
} else if (config.alignment === "end") {
|
|
1961
|
+
x2 = safeFrame.x + safeFrame.width - width;
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
positions.set(element.id, { x: x2, y, width, height });
|
|
1965
|
+
y += height + gap;
|
|
1966
|
+
}
|
|
1967
|
+
return { positions };
|
|
1968
|
+
}
|
|
1969
|
+
const estimatedWidths = placeable.map((element) => estimateElementWidth(element));
|
|
1970
|
+
const totalEstimated = estimatedWidths.reduce((sum, value) => sum + value, 0);
|
|
1971
|
+
const available = Math.max(0, safeFrame.width - gap * (placeable.length - 1));
|
|
1972
|
+
const scale = totalEstimated > 0 ? Math.min(1, available / totalEstimated) : 1;
|
|
1973
|
+
let x = safeFrame.x;
|
|
1974
|
+
for (const [index, element] of placeable.entries()) {
|
|
1975
|
+
const stretched = config.alignment === "stretch";
|
|
1976
|
+
const height = stretched ? safeFrame.height : Math.min(safeFrame.height, Math.floor(estimateElementHeight(element)));
|
|
1977
|
+
const width = Math.max(64, Math.floor(estimatedWidths[index] * scale));
|
|
1978
|
+
let y = safeFrame.y;
|
|
1261
1979
|
if (!stretched) {
|
|
1262
1980
|
if (config.alignment === "center") {
|
|
1263
1981
|
y = safeFrame.y + Math.floor((safeFrame.height - height) / 2);
|
|
@@ -1273,33 +1991,37 @@ function computeStackLayout(elements, config, safeFrame) {
|
|
|
1273
1991
|
|
|
1274
1992
|
// src/layout/elk.ts
|
|
1275
1993
|
function estimateFlowNodeSize(node) {
|
|
1994
|
+
const badgeExtra = node.badgeText && (node.badgePosition ?? "inside-top") === "inside-top" ? BADGE_INSIDE_TOP_EXTRA : 0;
|
|
1995
|
+
const sublabel2Extra = node.sublabel2 ? (node.sublabel2FontSize ?? 11) + 4 : 0;
|
|
1996
|
+
const extra = badgeExtra + sublabel2Extra;
|
|
1276
1997
|
if (node.width && node.height) {
|
|
1277
|
-
return { width: node.width, height: node.height };
|
|
1998
|
+
return { width: node.width, height: node.height + extra };
|
|
1278
1999
|
}
|
|
1279
2000
|
if (node.width) {
|
|
2001
|
+
const baseHeight = node.shape === "diamond" || node.shape === "circle" ? node.width : 60;
|
|
1280
2002
|
return {
|
|
1281
2003
|
width: node.width,
|
|
1282
|
-
height:
|
|
2004
|
+
height: baseHeight + extra
|
|
1283
2005
|
};
|
|
1284
2006
|
}
|
|
1285
2007
|
if (node.height) {
|
|
1286
2008
|
return {
|
|
1287
2009
|
width: node.shape === "diamond" || node.shape === "circle" ? node.height : 160,
|
|
1288
|
-
height: node.height
|
|
2010
|
+
height: node.height + extra
|
|
1289
2011
|
};
|
|
1290
2012
|
}
|
|
1291
2013
|
switch (node.shape) {
|
|
1292
2014
|
case "diamond":
|
|
1293
2015
|
case "circle":
|
|
1294
|
-
return { width: 100, height: 100 };
|
|
2016
|
+
return { width: 100 + extra, height: 100 + extra };
|
|
1295
2017
|
case "pill":
|
|
1296
|
-
return { width: 180, height: 56 };
|
|
2018
|
+
return { width: 180, height: 56 + extra };
|
|
1297
2019
|
case "cylinder":
|
|
1298
|
-
return { width: 140, height: 92 };
|
|
2020
|
+
return { width: 140, height: 92 + extra };
|
|
1299
2021
|
case "parallelogram":
|
|
1300
|
-
return { width: 180, height: 72 };
|
|
2022
|
+
return { width: 180, height: 72 + extra };
|
|
1301
2023
|
default:
|
|
1302
|
-
return { width: 170, height: 64 };
|
|
2024
|
+
return { width: 170, height: 64 + extra };
|
|
1303
2025
|
}
|
|
1304
2026
|
}
|
|
1305
2027
|
function splitLayoutFrames(safeFrame, direction, hasAuxiliary) {
|
|
@@ -1417,6 +2139,40 @@ function directionToElk(direction) {
|
|
|
1417
2139
|
return "DOWN";
|
|
1418
2140
|
}
|
|
1419
2141
|
}
|
|
2142
|
+
function radialCompactionToElk(compaction) {
|
|
2143
|
+
switch (compaction) {
|
|
2144
|
+
case "radial":
|
|
2145
|
+
return "RADIAL_COMPACTION";
|
|
2146
|
+
case "wedge":
|
|
2147
|
+
return "WEDGE_COMPACTION";
|
|
2148
|
+
default:
|
|
2149
|
+
return "NONE";
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
function radialSortByToElk(sortBy) {
|
|
2153
|
+
switch (sortBy) {
|
|
2154
|
+
case "connections":
|
|
2155
|
+
return "POLAR_COORDINATE";
|
|
2156
|
+
default:
|
|
2157
|
+
return "ID";
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
function buildRadialOptions(config) {
|
|
2161
|
+
const options = {};
|
|
2162
|
+
if (config.radialRoot) {
|
|
2163
|
+
options["elk.radial.centerOnRoot"] = "true";
|
|
2164
|
+
}
|
|
2165
|
+
if (config.radialRadius != null) {
|
|
2166
|
+
options["elk.radial.radius"] = String(config.radialRadius);
|
|
2167
|
+
}
|
|
2168
|
+
if (config.radialCompaction) {
|
|
2169
|
+
options["elk.radial.compaction.strategy"] = radialCompactionToElk(config.radialCompaction);
|
|
2170
|
+
}
|
|
2171
|
+
if (config.radialSortBy) {
|
|
2172
|
+
options["elk.radial.orderId"] = radialSortByToElk(config.radialSortBy);
|
|
2173
|
+
}
|
|
2174
|
+
return options;
|
|
2175
|
+
}
|
|
1420
2176
|
function fallbackForNoFlowNodes(nonFlow, safeFrame) {
|
|
1421
2177
|
const fallbackConfig = {
|
|
1422
2178
|
mode: "stack",
|
|
@@ -1452,6 +2208,11 @@ async function computeElkLayout(elements, config, safeFrame) {
|
|
|
1452
2208
|
elkNodeSizes.set(node.id, estimateFlowNodeSize(node));
|
|
1453
2209
|
}
|
|
1454
2210
|
const edgeIdToRouteKey = /* @__PURE__ */ new Map();
|
|
2211
|
+
const radialOptions = config.algorithm === "radial" ? buildRadialOptions(config) : {};
|
|
2212
|
+
const orderedFlowNodes = config.radialRoot && config.algorithm === "radial" ? [
|
|
2213
|
+
...flowNodes.filter((node) => node.id === config.radialRoot),
|
|
2214
|
+
...flowNodes.filter((node) => node.id !== config.radialRoot)
|
|
2215
|
+
] : flowNodes;
|
|
1455
2216
|
const elkGraph = {
|
|
1456
2217
|
id: "root",
|
|
1457
2218
|
layoutOptions: {
|
|
@@ -1461,9 +2222,10 @@ async function computeElkLayout(elements, config, safeFrame) {
|
|
|
1461
2222
|
"elk.layered.spacing.nodeNodeBetweenLayers": String(config.rankSpacing),
|
|
1462
2223
|
"elk.edgeRouting": edgeRoutingToElk(config.edgeRouting),
|
|
1463
2224
|
...config.aspectRatio ? { "elk.aspectRatio": String(config.aspectRatio) } : {},
|
|
1464
|
-
...config.algorithm === "stress" ? { "elk.stress.desiredEdgeLength": String(config.rankSpacing + config.nodeSpacing) } : {}
|
|
2225
|
+
...config.algorithm === "stress" ? { "elk.stress.desiredEdgeLength": String(config.rankSpacing + config.nodeSpacing) } : {},
|
|
2226
|
+
...radialOptions
|
|
1465
2227
|
},
|
|
1466
|
-
children:
|
|
2228
|
+
children: orderedFlowNodes.map((node) => {
|
|
1467
2229
|
const size = elkNodeSizes.get(node.id) ?? { width: 160, height: 60 };
|
|
1468
2230
|
return {
|
|
1469
2231
|
id: node.id,
|
|
@@ -1700,262 +2462,80 @@ function roundedRectPath(ctx, x, y, width, height, radius) {
|
|
|
1700
2462
|
function parseHexColor2(color) {
|
|
1701
2463
|
const normalized = color.startsWith("#") ? color.slice(1) : color;
|
|
1702
2464
|
if (normalized.length !== 6 && normalized.length !== 8) {
|
|
1703
|
-
throw new Error(`Expected #RRGGBB or #RRGGBBAA color, received ${color}`);
|
|
1704
|
-
}
|
|
1705
|
-
const parseChannel2 = (offset) => Number.parseInt(normalized.slice(offset, offset + 2), 16);
|
|
1706
|
-
return {
|
|
1707
|
-
r: parseChannel2(0),
|
|
1708
|
-
g: parseChannel2(2),
|
|
1709
|
-
b: parseChannel2(4),
|
|
1710
|
-
a: normalized.length === 8 ? parseChannel2(6) / 255 : 1
|
|
1711
|
-
};
|
|
1712
|
-
}
|
|
1713
|
-
function withAlpha(color, alpha) {
|
|
1714
|
-
const parsed = parseHexColor2(color);
|
|
1715
|
-
const effectiveAlpha = clamp01(parsed.a * alpha);
|
|
1716
|
-
return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${effectiveAlpha})`;
|
|
1717
|
-
}
|
|
1718
|
-
function drawGradientRect(ctx, rect, gradient, borderRadius = 0) {
|
|
1719
|
-
const fill = gradient.type === "linear" ? createLinearRectGradient(ctx, rect, gradient.angle ?? 180) : ctx.createRadialGradient(
|
|
1720
|
-
rect.x + rect.width / 2,
|
|
1721
|
-
rect.y + rect.height / 2,
|
|
1722
|
-
0,
|
|
1723
|
-
rect.x + rect.width / 2,
|
|
1724
|
-
rect.y + rect.height / 2,
|
|
1725
|
-
Math.max(rect.width, rect.height) / 2
|
|
1726
|
-
);
|
|
1727
|
-
addGradientStops(fill, gradient.stops);
|
|
1728
|
-
ctx.save();
|
|
1729
|
-
ctx.fillStyle = fill;
|
|
1730
|
-
if (borderRadius > 0) {
|
|
1731
|
-
roundedRectPath(ctx, rect.x, rect.y, rect.width, rect.height, borderRadius);
|
|
1732
|
-
ctx.fill();
|
|
1733
|
-
} else {
|
|
1734
|
-
ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
1735
|
-
}
|
|
1736
|
-
ctx.restore();
|
|
1737
|
-
}
|
|
1738
|
-
function drawRainbowRule(ctx, x, y, width, thickness = 2, colors = [...DEFAULT_RAINBOW_COLORS], borderRadius = thickness / 2) {
|
|
1739
|
-
if (width <= 0 || thickness <= 0) {
|
|
1740
|
-
return;
|
|
1741
|
-
}
|
|
1742
|
-
const gradient = ctx.createLinearGradient(x, y, x + width, y);
|
|
1743
|
-
const stops = colors.length >= 2 ? colors : [...DEFAULT_RAINBOW_COLORS];
|
|
1744
|
-
for (const [index, color] of stops.entries()) {
|
|
1745
|
-
gradient.addColorStop(index / (stops.length - 1), color);
|
|
1746
|
-
}
|
|
1747
|
-
const ruleTop = y - thickness / 2;
|
|
1748
|
-
ctx.save();
|
|
1749
|
-
roundedRectPath(ctx, x, ruleTop, width, thickness, borderRadius);
|
|
1750
|
-
ctx.fillStyle = gradient;
|
|
1751
|
-
ctx.fill();
|
|
1752
|
-
ctx.restore();
|
|
1753
|
-
}
|
|
1754
|
-
function drawVignette(ctx, width, height, intensity = 0.3, color = "#000000") {
|
|
1755
|
-
if (width <= 0 || height <= 0 || intensity <= 0) {
|
|
1756
|
-
return;
|
|
1757
|
-
}
|
|
1758
|
-
const centerX = width / 2;
|
|
1759
|
-
const centerY = height / 2;
|
|
1760
|
-
const outerRadius = Math.max(width, height) / 2;
|
|
1761
|
-
const innerRadius = Math.min(width, height) * 0.2;
|
|
1762
|
-
const vignette = ctx.createRadialGradient(
|
|
1763
|
-
centerX,
|
|
1764
|
-
centerY,
|
|
1765
|
-
innerRadius,
|
|
1766
|
-
centerX,
|
|
1767
|
-
centerY,
|
|
1768
|
-
outerRadius
|
|
1769
|
-
);
|
|
1770
|
-
vignette.addColorStop(0, withAlpha(color, 0));
|
|
1771
|
-
vignette.addColorStop(0.6, withAlpha(color, 0));
|
|
1772
|
-
vignette.addColorStop(1, withAlpha(color, clamp01(intensity)));
|
|
1773
|
-
ctx.save();
|
|
1774
|
-
ctx.fillStyle = vignette;
|
|
1775
|
-
ctx.fillRect(0, 0, width, height);
|
|
1776
|
-
ctx.restore();
|
|
1777
|
-
}
|
|
1778
|
-
|
|
1779
|
-
// src/primitives/shapes.ts
|
|
1780
|
-
function roundRectPath(ctx, rect, radius) {
|
|
1781
|
-
const r = Math.max(0, Math.min(radius, rect.width / 2, rect.height / 2));
|
|
1782
|
-
const right = rect.x + rect.width;
|
|
1783
|
-
const bottom = rect.y + rect.height;
|
|
1784
|
-
ctx.beginPath();
|
|
1785
|
-
ctx.moveTo(rect.x + r, rect.y);
|
|
1786
|
-
ctx.lineTo(right - r, rect.y);
|
|
1787
|
-
ctx.quadraticCurveTo(right, rect.y, right, rect.y + r);
|
|
1788
|
-
ctx.lineTo(right, bottom - r);
|
|
1789
|
-
ctx.quadraticCurveTo(right, bottom, right - r, bottom);
|
|
1790
|
-
ctx.lineTo(rect.x + r, bottom);
|
|
1791
|
-
ctx.quadraticCurveTo(rect.x, bottom, rect.x, bottom - r);
|
|
1792
|
-
ctx.lineTo(rect.x, rect.y + r);
|
|
1793
|
-
ctx.quadraticCurveTo(rect.x, rect.y, rect.x + r, rect.y);
|
|
1794
|
-
ctx.closePath();
|
|
1795
|
-
}
|
|
1796
|
-
function fillAndStroke(ctx, fill, stroke) {
|
|
1797
|
-
ctx.fillStyle = fill;
|
|
1798
|
-
ctx.fill();
|
|
1799
|
-
if (stroke) {
|
|
1800
|
-
ctx.strokeStyle = stroke;
|
|
1801
|
-
ctx.stroke();
|
|
1802
|
-
}
|
|
1803
|
-
}
|
|
1804
|
-
function drawRoundedRect(ctx, rect, radius, fill, stroke) {
|
|
1805
|
-
roundRectPath(ctx, rect, radius);
|
|
1806
|
-
fillAndStroke(ctx, fill, stroke);
|
|
1807
|
-
}
|
|
1808
|
-
function drawCircle(ctx, center2, radius, fill, stroke) {
|
|
1809
|
-
ctx.beginPath();
|
|
1810
|
-
ctx.arc(center2.x, center2.y, Math.max(0, radius), 0, Math.PI * 2);
|
|
1811
|
-
ctx.closePath();
|
|
1812
|
-
fillAndStroke(ctx, fill, stroke);
|
|
1813
|
-
}
|
|
1814
|
-
function drawDiamond(ctx, bounds, fill, stroke) {
|
|
1815
|
-
const cx = bounds.x + bounds.width / 2;
|
|
1816
|
-
const cy = bounds.y + bounds.height / 2;
|
|
1817
|
-
ctx.beginPath();
|
|
1818
|
-
ctx.moveTo(cx, bounds.y);
|
|
1819
|
-
ctx.lineTo(bounds.x + bounds.width, cy);
|
|
1820
|
-
ctx.lineTo(cx, bounds.y + bounds.height);
|
|
1821
|
-
ctx.lineTo(bounds.x, cy);
|
|
1822
|
-
ctx.closePath();
|
|
1823
|
-
fillAndStroke(ctx, fill, stroke);
|
|
1824
|
-
}
|
|
1825
|
-
function drawPill(ctx, bounds, fill, stroke) {
|
|
1826
|
-
drawRoundedRect(ctx, bounds, Math.min(bounds.width, bounds.height) / 2, fill, stroke);
|
|
1827
|
-
}
|
|
1828
|
-
function drawEllipse(ctx, bounds, fill, stroke) {
|
|
1829
|
-
const cx = bounds.x + bounds.width / 2;
|
|
1830
|
-
const cy = bounds.y + bounds.height / 2;
|
|
1831
|
-
ctx.beginPath();
|
|
1832
|
-
ctx.ellipse(
|
|
1833
|
-
cx,
|
|
1834
|
-
cy,
|
|
1835
|
-
Math.max(0, bounds.width / 2),
|
|
1836
|
-
Math.max(0, bounds.height / 2),
|
|
1837
|
-
0,
|
|
1838
|
-
0,
|
|
1839
|
-
Math.PI * 2
|
|
1840
|
-
);
|
|
1841
|
-
ctx.closePath();
|
|
1842
|
-
fillAndStroke(ctx, fill, stroke);
|
|
1843
|
-
}
|
|
1844
|
-
function drawCylinder(ctx, bounds, fill, stroke) {
|
|
1845
|
-
const rx = Math.max(2, bounds.width / 2);
|
|
1846
|
-
const ry = Math.max(2, Math.min(bounds.height * 0.18, 16));
|
|
1847
|
-
const cx = bounds.x + bounds.width / 2;
|
|
1848
|
-
const topCy = bounds.y + ry;
|
|
1849
|
-
const bottomCy = bounds.y + bounds.height - ry;
|
|
1850
|
-
ctx.beginPath();
|
|
1851
|
-
ctx.moveTo(bounds.x, topCy);
|
|
1852
|
-
ctx.ellipse(cx, topCy, rx, ry, 0, Math.PI, 0, true);
|
|
1853
|
-
ctx.lineTo(bounds.x + bounds.width, bottomCy);
|
|
1854
|
-
ctx.ellipse(cx, bottomCy, rx, ry, 0, 0, Math.PI, false);
|
|
1855
|
-
ctx.closePath();
|
|
1856
|
-
fillAndStroke(ctx, fill, stroke);
|
|
1857
|
-
if (stroke) {
|
|
1858
|
-
ctx.beginPath();
|
|
1859
|
-
ctx.ellipse(cx, topCy, rx, ry, 0, 0, Math.PI * 2);
|
|
1860
|
-
ctx.closePath();
|
|
1861
|
-
ctx.strokeStyle = stroke;
|
|
1862
|
-
ctx.stroke();
|
|
1863
|
-
}
|
|
1864
|
-
}
|
|
1865
|
-
function drawParallelogram(ctx, bounds, fill, stroke, skew) {
|
|
1866
|
-
const maxSkew = bounds.width * 0.45;
|
|
1867
|
-
const skewX = Math.max(-maxSkew, Math.min(maxSkew, skew ?? bounds.width * 0.18));
|
|
1868
|
-
ctx.beginPath();
|
|
1869
|
-
ctx.moveTo(bounds.x + skewX, bounds.y);
|
|
1870
|
-
ctx.lineTo(bounds.x + bounds.width, bounds.y);
|
|
1871
|
-
ctx.lineTo(bounds.x + bounds.width - skewX, bounds.y + bounds.height);
|
|
1872
|
-
ctx.lineTo(bounds.x, bounds.y + bounds.height);
|
|
1873
|
-
ctx.closePath();
|
|
1874
|
-
fillAndStroke(ctx, fill, stroke);
|
|
1875
|
-
}
|
|
1876
|
-
|
|
1877
|
-
// src/primitives/text.ts
|
|
1878
|
-
var SUPPORTED_FONT_FAMILIES = /* @__PURE__ */ new Set(["Inter", "JetBrains Mono", "Space Grotesk"]);
|
|
1879
|
-
function resolveFont(requested, role) {
|
|
1880
|
-
if (SUPPORTED_FONT_FAMILIES.has(requested)) {
|
|
1881
|
-
return requested;
|
|
1882
|
-
}
|
|
1883
|
-
if (role === "mono" || /mono|code|terminal|console/iu.test(requested)) {
|
|
1884
|
-
return "JetBrains Mono";
|
|
1885
|
-
}
|
|
1886
|
-
if (role === "heading" || /display|grotesk|headline/iu.test(requested)) {
|
|
1887
|
-
return "Space Grotesk";
|
|
2465
|
+
throw new Error(`Expected #RRGGBB or #RRGGBBAA color, received ${color}`);
|
|
1888
2466
|
}
|
|
1889
|
-
|
|
2467
|
+
const parseChannel2 = (offset) => Number.parseInt(normalized.slice(offset, offset + 2), 16);
|
|
2468
|
+
return {
|
|
2469
|
+
r: parseChannel2(0),
|
|
2470
|
+
g: parseChannel2(2),
|
|
2471
|
+
b: parseChannel2(4),
|
|
2472
|
+
a: normalized.length === 8 ? parseChannel2(6) / 255 : 1
|
|
2473
|
+
};
|
|
1890
2474
|
}
|
|
1891
|
-
function
|
|
1892
|
-
|
|
2475
|
+
function withAlpha2(color, alpha) {
|
|
2476
|
+
const parsed = parseHexColor2(color);
|
|
2477
|
+
const effectiveAlpha = clamp01(parsed.a * alpha);
|
|
2478
|
+
return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${effectiveAlpha})`;
|
|
1893
2479
|
}
|
|
1894
|
-
function
|
|
1895
|
-
const
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
} else {
|
|
1912
|
-
lines.push(word);
|
|
1913
|
-
current = "";
|
|
1914
|
-
}
|
|
1915
|
-
if (lines.length >= maxLines) {
|
|
1916
|
-
break;
|
|
1917
|
-
}
|
|
1918
|
-
}
|
|
1919
|
-
if (lines.length < maxLines && current.length > 0) {
|
|
1920
|
-
lines.push(current);
|
|
2480
|
+
function drawGradientRect(ctx, rect, gradient, borderRadius = 0) {
|
|
2481
|
+
const fill = gradient.type === "linear" ? createLinearRectGradient(ctx, rect, gradient.angle ?? 180) : ctx.createRadialGradient(
|
|
2482
|
+
rect.x + rect.width / 2,
|
|
2483
|
+
rect.y + rect.height / 2,
|
|
2484
|
+
0,
|
|
2485
|
+
rect.x + rect.width / 2,
|
|
2486
|
+
rect.y + rect.height / 2,
|
|
2487
|
+
Math.max(rect.width, rect.height) / 2
|
|
2488
|
+
);
|
|
2489
|
+
addGradientStops(fill, gradient.stops);
|
|
2490
|
+
ctx.save();
|
|
2491
|
+
ctx.fillStyle = fill;
|
|
2492
|
+
if (borderRadius > 0) {
|
|
2493
|
+
roundedRectPath(ctx, rect.x, rect.y, rect.width, rect.height, borderRadius);
|
|
2494
|
+
ctx.fill();
|
|
2495
|
+
} else {
|
|
2496
|
+
ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
1921
2497
|
}
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
2498
|
+
ctx.restore();
|
|
2499
|
+
}
|
|
2500
|
+
function drawRainbowRule(ctx, x, y, width, thickness = 2, colors = [...DEFAULT_RAINBOW_COLORS], borderRadius = thickness / 2) {
|
|
2501
|
+
if (width <= 0 || thickness <= 0) {
|
|
2502
|
+
return;
|
|
1925
2503
|
}
|
|
1926
|
-
const
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
2504
|
+
const gradient = ctx.createLinearGradient(x, y, x + width, y);
|
|
2505
|
+
const stops = colors.length >= 2 ? colors : [...DEFAULT_RAINBOW_COLORS];
|
|
2506
|
+
for (const [index, color] of stops.entries()) {
|
|
2507
|
+
gradient.addColorStop(index / (stops.length - 1), color);
|
|
1930
2508
|
}
|
|
1931
|
-
|
|
1932
|
-
|
|
2509
|
+
const ruleTop = y - thickness / 2;
|
|
2510
|
+
ctx.save();
|
|
2511
|
+
roundedRectPath(ctx, x, ruleTop, width, thickness, borderRadius);
|
|
2512
|
+
ctx.fillStyle = gradient;
|
|
2513
|
+
ctx.fill();
|
|
2514
|
+
ctx.restore();
|
|
1933
2515
|
}
|
|
1934
|
-
function
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
ctx.fillStyle = options.color;
|
|
1938
|
-
for (const [index, line] of wrapped.lines.entries()) {
|
|
1939
|
-
ctx.fillText(line, options.x, options.y + index * options.lineHeight);
|
|
2516
|
+
function drawVignette(ctx, width, height, intensity = 0.3, color = "#000000") {
|
|
2517
|
+
if (width <= 0 || height <= 0 || intensity <= 0) {
|
|
2518
|
+
return;
|
|
1940
2519
|
}
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
ctx.
|
|
1957
|
-
ctx.
|
|
1958
|
-
|
|
2520
|
+
const centerX = width / 2;
|
|
2521
|
+
const centerY = height / 2;
|
|
2522
|
+
const outerRadius = Math.max(width, height) / 2;
|
|
2523
|
+
const innerRadius = Math.min(width, height) * 0.2;
|
|
2524
|
+
const vignette = ctx.createRadialGradient(
|
|
2525
|
+
centerX,
|
|
2526
|
+
centerY,
|
|
2527
|
+
innerRadius,
|
|
2528
|
+
centerX,
|
|
2529
|
+
centerY,
|
|
2530
|
+
outerRadius
|
|
2531
|
+
);
|
|
2532
|
+
vignette.addColorStop(0, withAlpha2(color, 0));
|
|
2533
|
+
vignette.addColorStop(0.6, withAlpha2(color, 0));
|
|
2534
|
+
vignette.addColorStop(1, withAlpha2(color, clamp01(intensity)));
|
|
2535
|
+
ctx.save();
|
|
2536
|
+
ctx.fillStyle = vignette;
|
|
2537
|
+
ctx.fillRect(0, 0, width, height);
|
|
2538
|
+
ctx.restore();
|
|
1959
2539
|
}
|
|
1960
2540
|
|
|
1961
2541
|
// src/renderers/card.ts
|
|
@@ -2082,12 +2662,12 @@ var MACOS_DOTS = [
|
|
|
2082
2662
|
{ fill: "#27C93F", stroke: "#1AAB29" }
|
|
2083
2663
|
];
|
|
2084
2664
|
function drawMacosDots(ctx, x, y) {
|
|
2085
|
-
for (const [index,
|
|
2665
|
+
for (const [index, dot2] of MACOS_DOTS.entries()) {
|
|
2086
2666
|
ctx.beginPath();
|
|
2087
2667
|
ctx.arc(x + index * DOT_SPACING, y, DOT_RADIUS, 0, Math.PI * 2);
|
|
2088
2668
|
ctx.closePath();
|
|
2089
|
-
ctx.fillStyle =
|
|
2090
|
-
ctx.strokeStyle =
|
|
2669
|
+
ctx.fillStyle = dot2.fill;
|
|
2670
|
+
ctx.strokeStyle = dot2.stroke;
|
|
2091
2671
|
ctx.lineWidth = DOT_STROKE_WIDTH;
|
|
2092
2672
|
ctx.fill();
|
|
2093
2673
|
ctx.stroke();
|
|
@@ -2504,25 +3084,134 @@ function drawOrthogonalPath(ctx, from, to, style) {
|
|
|
2504
3084
|
}
|
|
2505
3085
|
|
|
2506
3086
|
// src/renderers/connection.ts
|
|
2507
|
-
|
|
3087
|
+
var ELLIPSE_KAPPA = 4 * (Math.sqrt(2) - 1) / 3;
|
|
3088
|
+
function rectCenter(rect) {
|
|
2508
3089
|
return {
|
|
2509
3090
|
x: rect.x + rect.width / 2,
|
|
2510
3091
|
y: rect.y + rect.height / 2
|
|
2511
3092
|
};
|
|
2512
3093
|
}
|
|
2513
|
-
function edgeAnchor(
|
|
2514
|
-
const c =
|
|
3094
|
+
function edgeAnchor(bounds, target) {
|
|
3095
|
+
const c = rectCenter(bounds);
|
|
2515
3096
|
const dx = target.x - c.x;
|
|
2516
3097
|
const dy = target.y - c.y;
|
|
2517
|
-
if (
|
|
2518
|
-
return {
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
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
|
+
];
|
|
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;
|
|
3188
|
+
return {
|
|
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;
|
|
2522
3211
|
}
|
|
2523
3212
|
return {
|
|
2524
|
-
x:
|
|
2525
|
-
y:
|
|
3213
|
+
x: totalX / nodeBounds.length,
|
|
3214
|
+
y: totalY / nodeBounds.length
|
|
2526
3215
|
};
|
|
2527
3216
|
}
|
|
2528
3217
|
function dashFromStyle(style) {
|
|
@@ -2606,51 +3295,95 @@ function polylineBounds(points) {
|
|
|
2606
3295
|
height: Math.max(1, maxY - minY)
|
|
2607
3296
|
};
|
|
2608
3297
|
}
|
|
2609
|
-
function renderConnection(ctx, conn, fromBounds, toBounds, theme, edgeRoute) {
|
|
2610
|
-
const
|
|
2611
|
-
const
|
|
2612
|
-
const
|
|
2613
|
-
const
|
|
2614
|
-
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);
|
|
2615
3304
|
const style = {
|
|
2616
3305
|
color: conn.color ?? theme.borderMuted,
|
|
2617
|
-
width:
|
|
3306
|
+
width: strokeWidth,
|
|
2618
3307
|
headSize: conn.arrowSize ?? 10,
|
|
2619
3308
|
...dash ? { dash } : {}
|
|
2620
3309
|
};
|
|
2621
|
-
const
|
|
2622
|
-
const
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
let
|
|
2626
|
-
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
|
+
}
|
|
2627
3369
|
if (!Number.isFinite(startAngle)) {
|
|
2628
3370
|
startAngle = 0;
|
|
2629
3371
|
}
|
|
2630
3372
|
if (!Number.isFinite(endAngle)) {
|
|
2631
3373
|
endAngle = 0;
|
|
2632
3374
|
}
|
|
2633
|
-
const t = conn.labelPosition === "start" ? 0.2 : conn.labelPosition === "end" ? 0.8 : 0.5;
|
|
2634
|
-
const labelPoint = pointAlongPolyline(points, t);
|
|
2635
|
-
ctx.save();
|
|
2636
|
-
ctx.globalAlpha = conn.opacity;
|
|
2637
|
-
if (edgeRoute && edgeRoute.points.length >= 2) {
|
|
2638
|
-
drawCubicInterpolatedPath(ctx, points, style);
|
|
2639
|
-
} else {
|
|
2640
|
-
drawOrthogonalPath(ctx, points[0], points[points.length - 1], style);
|
|
2641
|
-
}
|
|
2642
3375
|
if (conn.arrow === "start" || conn.arrow === "both") {
|
|
2643
|
-
drawArrowhead(ctx,
|
|
3376
|
+
drawArrowhead(ctx, startPoint, startAngle, style.headSize, style.color);
|
|
2644
3377
|
}
|
|
2645
3378
|
if (conn.arrow === "end" || conn.arrow === "both") {
|
|
2646
|
-
drawArrowhead(ctx,
|
|
3379
|
+
drawArrowhead(ctx, endPoint, endAngle, style.headSize, style.color);
|
|
2647
3380
|
}
|
|
2648
3381
|
ctx.restore();
|
|
2649
3382
|
const elements = [
|
|
2650
3383
|
{
|
|
2651
3384
|
id: `connection-${conn.from}-${conn.to}`,
|
|
2652
3385
|
kind: "connection",
|
|
2653
|
-
bounds: polylineBounds(
|
|
3386
|
+
bounds: polylineBounds(linePoints),
|
|
2654
3387
|
foregroundColor: style.color
|
|
2655
3388
|
}
|
|
2656
3389
|
];
|
|
@@ -3281,92 +4014,6 @@ function renderDrawCommands(ctx, commands, theme) {
|
|
|
3281
4014
|
return rendered;
|
|
3282
4015
|
}
|
|
3283
4016
|
|
|
3284
|
-
// src/renderers/flow-node.ts
|
|
3285
|
-
function renderFlowNode(ctx, node, bounds, theme) {
|
|
3286
|
-
const fillColor = node.color ?? theme.surfaceElevated;
|
|
3287
|
-
const borderColor = node.borderColor ?? theme.border;
|
|
3288
|
-
const borderWidth = node.borderWidth ?? 2;
|
|
3289
|
-
const cornerRadius = node.cornerRadius ?? 16;
|
|
3290
|
-
const labelColor = node.labelColor ?? theme.text;
|
|
3291
|
-
const sublabelColor = node.sublabelColor ?? theme.textMuted;
|
|
3292
|
-
const labelFontSize = node.labelFontSize ?? 20;
|
|
3293
|
-
ctx.save();
|
|
3294
|
-
ctx.globalAlpha = node.opacity;
|
|
3295
|
-
ctx.lineWidth = borderWidth;
|
|
3296
|
-
switch (node.shape) {
|
|
3297
|
-
case "box":
|
|
3298
|
-
drawRoundedRect(ctx, bounds, 0, fillColor, borderColor);
|
|
3299
|
-
break;
|
|
3300
|
-
case "rounded-box":
|
|
3301
|
-
drawRoundedRect(ctx, bounds, cornerRadius, fillColor, borderColor);
|
|
3302
|
-
break;
|
|
3303
|
-
case "diamond":
|
|
3304
|
-
drawDiamond(ctx, bounds, fillColor, borderColor);
|
|
3305
|
-
break;
|
|
3306
|
-
case "circle": {
|
|
3307
|
-
const radius = Math.min(bounds.width, bounds.height) / 2;
|
|
3308
|
-
drawCircle(
|
|
3309
|
-
ctx,
|
|
3310
|
-
{ x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 },
|
|
3311
|
-
radius,
|
|
3312
|
-
fillColor,
|
|
3313
|
-
borderColor
|
|
3314
|
-
);
|
|
3315
|
-
break;
|
|
3316
|
-
}
|
|
3317
|
-
case "pill":
|
|
3318
|
-
drawPill(ctx, bounds, fillColor, borderColor);
|
|
3319
|
-
break;
|
|
3320
|
-
case "cylinder":
|
|
3321
|
-
drawCylinder(ctx, bounds, fillColor, borderColor);
|
|
3322
|
-
break;
|
|
3323
|
-
case "parallelogram":
|
|
3324
|
-
drawParallelogram(ctx, bounds, fillColor, borderColor);
|
|
3325
|
-
break;
|
|
3326
|
-
}
|
|
3327
|
-
const headingFont = resolveFont(theme.fonts.heading, "heading");
|
|
3328
|
-
const bodyFont = resolveFont(theme.fonts.body, "body");
|
|
3329
|
-
const centerX = bounds.x + bounds.width / 2;
|
|
3330
|
-
const centerY = bounds.y + bounds.height / 2;
|
|
3331
|
-
const labelY = node.sublabel ? centerY - Math.max(4, labelFontSize * 0.2) : centerY + labelFontSize * 0.3;
|
|
3332
|
-
ctx.textAlign = "center";
|
|
3333
|
-
applyFont(ctx, { size: labelFontSize, weight: 700, family: headingFont });
|
|
3334
|
-
ctx.fillStyle = labelColor;
|
|
3335
|
-
ctx.fillText(node.label, centerX, labelY);
|
|
3336
|
-
let textBoundsY = bounds.y + bounds.height / 2 - 18;
|
|
3337
|
-
let textBoundsHeight = 36;
|
|
3338
|
-
if (node.sublabel) {
|
|
3339
|
-
const sublabelFontSize = Math.max(12, Math.round(labelFontSize * 0.68));
|
|
3340
|
-
applyFont(ctx, { size: sublabelFontSize, weight: 500, family: bodyFont });
|
|
3341
|
-
ctx.fillStyle = sublabelColor;
|
|
3342
|
-
ctx.fillText(node.sublabel, centerX, labelY + Math.max(20, sublabelFontSize + 6));
|
|
3343
|
-
textBoundsY = bounds.y + bounds.height / 2 - 24;
|
|
3344
|
-
textBoundsHeight = 56;
|
|
3345
|
-
}
|
|
3346
|
-
ctx.restore();
|
|
3347
|
-
return [
|
|
3348
|
-
{
|
|
3349
|
-
id: `flow-node-${node.id}`,
|
|
3350
|
-
kind: "flow-node",
|
|
3351
|
-
bounds,
|
|
3352
|
-
foregroundColor: labelColor,
|
|
3353
|
-
backgroundColor: fillColor
|
|
3354
|
-
},
|
|
3355
|
-
{
|
|
3356
|
-
id: `flow-node-${node.id}-label`,
|
|
3357
|
-
kind: "text",
|
|
3358
|
-
bounds: {
|
|
3359
|
-
x: bounds.x + 8,
|
|
3360
|
-
y: textBoundsY,
|
|
3361
|
-
width: bounds.width - 16,
|
|
3362
|
-
height: textBoundsHeight
|
|
3363
|
-
},
|
|
3364
|
-
foregroundColor: labelColor,
|
|
3365
|
-
backgroundColor: fillColor
|
|
3366
|
-
}
|
|
3367
|
-
];
|
|
3368
|
-
}
|
|
3369
|
-
|
|
3370
4017
|
// src/renderers/image.ts
|
|
3371
4018
|
import { loadImage } from "@napi-rs/canvas";
|
|
3372
4019
|
function roundedRectPath2(ctx, bounds, radius) {
|
|
@@ -3950,6 +4597,10 @@ async function renderDesign(input, options = {}) {
|
|
|
3950
4597
|
break;
|
|
3951
4598
|
}
|
|
3952
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
|
+
);
|
|
3953
4604
|
for (const element of spec.elements) {
|
|
3954
4605
|
if (element.type !== "connection") {
|
|
3955
4606
|
continue;
|
|
@@ -3962,7 +4613,9 @@ async function renderDesign(input, options = {}) {
|
|
|
3962
4613
|
);
|
|
3963
4614
|
}
|
|
3964
4615
|
const edgeRoute = edgeRoutes?.get(`${element.from}-${element.to}`);
|
|
3965
|
-
elements.push(
|
|
4616
|
+
elements.push(
|
|
4617
|
+
...renderConnection(ctx, element, fromRect, toRect, theme, edgeRoute, { diagramCenter })
|
|
4618
|
+
);
|
|
3966
4619
|
}
|
|
3967
4620
|
if (footerRect && spec.footer) {
|
|
3968
4621
|
const footerText = spec.footer.tagline ? `${spec.footer.text} \u2022 ${spec.footer.tagline}` : spec.footer.text;
|
|
@@ -4325,6 +4978,36 @@ var renderOutputSchema = z3.object({
|
|
|
4325
4978
|
)
|
|
4326
4979
|
})
|
|
4327
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
|
+
});
|
|
4328
5011
|
async function readJson(path) {
|
|
4329
5012
|
if (path === "-") {
|
|
4330
5013
|
const chunks = [];
|
|
@@ -4427,6 +5110,44 @@ cli.command("render", {
|
|
|
4427
5110
|
return c.ok(runReport);
|
|
4428
5111
|
}
|
|
4429
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
|
+
});
|
|
4430
5151
|
var template = Cli.create("template", {
|
|
4431
5152
|
description: "Generate common design templates and run the full render \u2192 QA pipeline."
|
|
4432
5153
|
});
|
|
@@ -4668,7 +5389,8 @@ cli.command("qa", {
|
|
|
4668
5389
|
options: z3.object({
|
|
4669
5390
|
in: z3.string().describe("Path to rendered PNG"),
|
|
4670
5391
|
spec: z3.string().describe("Path to normalized DesignSpec JSON"),
|
|
4671
|
-
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")
|
|
4672
5394
|
}),
|
|
4673
5395
|
output: z3.object({
|
|
4674
5396
|
pass: z3.boolean(),
|
|
@@ -4682,7 +5404,18 @@ cli.command("qa", {
|
|
|
4682
5404
|
message: z3.string(),
|
|
4683
5405
|
elementId: z3.string().optional()
|
|
4684
5406
|
})
|
|
4685
|
-
)
|
|
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()
|
|
4686
5419
|
}),
|
|
4687
5420
|
examples: [
|
|
4688
5421
|
{
|
|
@@ -4705,14 +5438,16 @@ cli.command("qa", {
|
|
|
4705
5438
|
const report = await runQa({
|
|
4706
5439
|
imagePath: c.options.in,
|
|
4707
5440
|
spec,
|
|
4708
|
-
...metadata ? { metadata } : {}
|
|
5441
|
+
...metadata ? { metadata } : {},
|
|
5442
|
+
...c.options.reference ? { referencePath: c.options.reference } : {}
|
|
4709
5443
|
});
|
|
4710
5444
|
const response = {
|
|
4711
5445
|
pass: report.pass,
|
|
4712
5446
|
checkedAt: report.checkedAt,
|
|
4713
5447
|
imagePath: report.imagePath,
|
|
4714
5448
|
issueCount: report.issues.length,
|
|
4715
|
-
issues: report.issues
|
|
5449
|
+
issues: report.issues,
|
|
5450
|
+
...report.reference ? { reference: report.reference } : {}
|
|
4716
5451
|
};
|
|
4717
5452
|
if (!report.pass) {
|
|
4718
5453
|
return c.error({
|