@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/index.js
CHANGED
|
@@ -1,4 +1,152 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
+
var __esm = (fn, res) => function __init() {
|
|
4
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
5
|
+
};
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// src/compare.ts
|
|
12
|
+
var compare_exports = {};
|
|
13
|
+
__export(compare_exports, {
|
|
14
|
+
compareImages: () => compareImages
|
|
15
|
+
});
|
|
16
|
+
import sharp from "sharp";
|
|
17
|
+
function clampUnit(value) {
|
|
18
|
+
if (value < 0) {
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
if (value > 1) {
|
|
22
|
+
return 1;
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
function toRegionLabel(row, column) {
|
|
27
|
+
const letter = String.fromCharCode(65 + row);
|
|
28
|
+
return `${letter}${column + 1}`;
|
|
29
|
+
}
|
|
30
|
+
function validateGrid(grid) {
|
|
31
|
+
if (!Number.isInteger(grid) || grid <= 0) {
|
|
32
|
+
throw new Error(`Invalid grid value "${grid}". Expected a positive integer.`);
|
|
33
|
+
}
|
|
34
|
+
if (grid > 26) {
|
|
35
|
+
throw new Error(`Invalid grid value "${grid}". Maximum supported grid is 26.`);
|
|
36
|
+
}
|
|
37
|
+
return grid;
|
|
38
|
+
}
|
|
39
|
+
function validateThreshold(threshold) {
|
|
40
|
+
if (!Number.isFinite(threshold) || threshold < 0 || threshold > 1) {
|
|
41
|
+
throw new Error(`Invalid threshold value "${threshold}". Expected a number between 0 and 1.`);
|
|
42
|
+
}
|
|
43
|
+
return threshold;
|
|
44
|
+
}
|
|
45
|
+
async function readDimensions(path) {
|
|
46
|
+
const metadata = await sharp(path).metadata();
|
|
47
|
+
if (!metadata.width || !metadata.height) {
|
|
48
|
+
throw new Error(`Unable to read image dimensions for "${path}".`);
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
width: metadata.width,
|
|
52
|
+
height: metadata.height
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
async function normalizeToRaw(path, width, height) {
|
|
56
|
+
const normalized = await sharp(path).rotate().resize(width, height, {
|
|
57
|
+
fit: "contain",
|
|
58
|
+
position: "centre",
|
|
59
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
60
|
+
}).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
61
|
+
return {
|
|
62
|
+
data: normalized.data,
|
|
63
|
+
width: normalized.info.width,
|
|
64
|
+
height: normalized.info.height
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function scorePixelDifference(a, b, offset) {
|
|
68
|
+
const redDiff = Math.abs(a.data[offset] - b.data[offset]);
|
|
69
|
+
const greenDiff = Math.abs(a.data[offset + 1] - b.data[offset + 1]);
|
|
70
|
+
const blueDiff = Math.abs(a.data[offset + 2] - b.data[offset + 2]);
|
|
71
|
+
const alphaDiff = Math.abs(a.data[offset + 3] - b.data[offset + 3]);
|
|
72
|
+
const rgbDelta = (redDiff + greenDiff + blueDiff) / (3 * 255);
|
|
73
|
+
const alphaDelta = alphaDiff / 255;
|
|
74
|
+
return rgbDelta * 0.75 + alphaDelta * 0.25;
|
|
75
|
+
}
|
|
76
|
+
async function compareImages(target, rendered, options = {}) {
|
|
77
|
+
const grid = validateGrid(options.grid ?? DEFAULT_GRID);
|
|
78
|
+
const threshold = validateThreshold(options.threshold ?? DEFAULT_THRESHOLD);
|
|
79
|
+
const closeThreshold = clampUnit(threshold - (options.closeMargin ?? DEFAULT_CLOSE_MARGIN));
|
|
80
|
+
const targetDimensions = await readDimensions(target);
|
|
81
|
+
const renderedDimensions = await readDimensions(rendered);
|
|
82
|
+
const normalizedWidth = Math.max(targetDimensions.width, renderedDimensions.width);
|
|
83
|
+
const normalizedHeight = Math.max(targetDimensions.height, renderedDimensions.height);
|
|
84
|
+
const [targetImage, renderedImage] = await Promise.all([
|
|
85
|
+
normalizeToRaw(target, normalizedWidth, normalizedHeight),
|
|
86
|
+
normalizeToRaw(rendered, normalizedWidth, normalizedHeight)
|
|
87
|
+
]);
|
|
88
|
+
const regionDiffSums = new Array(grid * grid).fill(0);
|
|
89
|
+
const regionCounts = new Array(grid * grid).fill(0);
|
|
90
|
+
let totalDiff = 0;
|
|
91
|
+
for (let y = 0; y < normalizedHeight; y += 1) {
|
|
92
|
+
const row = Math.min(Math.floor(y * grid / normalizedHeight), grid - 1);
|
|
93
|
+
for (let x = 0; x < normalizedWidth; x += 1) {
|
|
94
|
+
const column = Math.min(Math.floor(x * grid / normalizedWidth), grid - 1);
|
|
95
|
+
const regionIndex = row * grid + column;
|
|
96
|
+
const offset = (y * normalizedWidth + x) * 4;
|
|
97
|
+
const diff = scorePixelDifference(targetImage, renderedImage, offset);
|
|
98
|
+
totalDiff += diff;
|
|
99
|
+
regionDiffSums[regionIndex] += diff;
|
|
100
|
+
regionCounts[regionIndex] += 1;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const pixelCount = normalizedWidth * normalizedHeight;
|
|
104
|
+
const similarity = clampUnit(1 - totalDiff / pixelCount);
|
|
105
|
+
const regions = [];
|
|
106
|
+
for (let row = 0; row < grid; row += 1) {
|
|
107
|
+
for (let column = 0; column < grid; column += 1) {
|
|
108
|
+
const regionIndex = row * grid + column;
|
|
109
|
+
const regionCount = regionCounts[regionIndex];
|
|
110
|
+
const regionSimilarity = regionCount > 0 ? clampUnit(1 - regionDiffSums[regionIndex] / regionCount) : 1;
|
|
111
|
+
regions.push({
|
|
112
|
+
label: toRegionLabel(row, column),
|
|
113
|
+
row,
|
|
114
|
+
column,
|
|
115
|
+
similarity: regionSimilarity
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const verdict = similarity >= threshold ? "match" : similarity >= closeThreshold ? "close" : "mismatch";
|
|
120
|
+
return {
|
|
121
|
+
targetPath: target,
|
|
122
|
+
renderedPath: rendered,
|
|
123
|
+
targetDimensions,
|
|
124
|
+
renderedDimensions,
|
|
125
|
+
normalizedDimensions: {
|
|
126
|
+
width: normalizedWidth,
|
|
127
|
+
height: normalizedHeight
|
|
128
|
+
},
|
|
129
|
+
dimensionMismatch: targetDimensions.width !== renderedDimensions.width || targetDimensions.height !== renderedDimensions.height,
|
|
130
|
+
grid,
|
|
131
|
+
threshold,
|
|
132
|
+
closeThreshold,
|
|
133
|
+
similarity,
|
|
134
|
+
verdict,
|
|
135
|
+
regions
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
var DEFAULT_GRID, DEFAULT_THRESHOLD, DEFAULT_CLOSE_MARGIN;
|
|
139
|
+
var init_compare = __esm({
|
|
140
|
+
"src/compare.ts"() {
|
|
141
|
+
"use strict";
|
|
142
|
+
DEFAULT_GRID = 3;
|
|
143
|
+
DEFAULT_THRESHOLD = 0.8;
|
|
144
|
+
DEFAULT_CLOSE_MARGIN = 0.1;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
1
148
|
// src/cli.ts
|
|
149
|
+
init_compare();
|
|
2
150
|
import { readFileSync, realpathSync } from "fs";
|
|
3
151
|
import { mkdir as mkdir2, readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
|
|
4
152
|
import { basename as basename4, dirname as dirname3, resolve as resolve4 } from "path";
|
|
@@ -196,7 +344,7 @@ async function publishToGitHub(options) {
|
|
|
196
344
|
// src/qa.ts
|
|
197
345
|
import { readFile as readFile3 } from "fs/promises";
|
|
198
346
|
import { resolve } from "path";
|
|
199
|
-
import
|
|
347
|
+
import sharp2 from "sharp";
|
|
200
348
|
|
|
201
349
|
// src/code-style.ts
|
|
202
350
|
var CARBON_SURROUND_COLOR = "rgba(171, 184, 195, 1)";
|
|
@@ -257,7 +405,110 @@ import { z as z2 } from "zod";
|
|
|
257
405
|
|
|
258
406
|
// src/themes/builtin.ts
|
|
259
407
|
import { z } from "zod";
|
|
260
|
-
|
|
408
|
+
|
|
409
|
+
// src/utils/color.ts
|
|
410
|
+
function parseChannel(hex, offset) {
|
|
411
|
+
return Number.parseInt(hex.slice(offset, offset + 2), 16);
|
|
412
|
+
}
|
|
413
|
+
function parseHexColor(hexColor) {
|
|
414
|
+
const normalized = hexColor.startsWith("#") ? hexColor.slice(1) : hexColor;
|
|
415
|
+
if (normalized.length !== 6 && normalized.length !== 8) {
|
|
416
|
+
throw new Error(`Unsupported color format: ${hexColor}`);
|
|
417
|
+
}
|
|
418
|
+
return {
|
|
419
|
+
r: parseChannel(normalized, 0),
|
|
420
|
+
g: parseChannel(normalized, 2),
|
|
421
|
+
b: parseChannel(normalized, 4)
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
var rgbaRegex = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*([01](?:\.\d+)?|0?\.\d+)\s*)?\)$/;
|
|
425
|
+
var hexColorRegex = /^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
|
426
|
+
function toHex(n) {
|
|
427
|
+
return n.toString(16).padStart(2, "0");
|
|
428
|
+
}
|
|
429
|
+
function parseRgbaToHex(color) {
|
|
430
|
+
const match = rgbaRegex.exec(color);
|
|
431
|
+
if (!match) {
|
|
432
|
+
throw new Error(`Invalid rgb/rgba color: ${color}`);
|
|
433
|
+
}
|
|
434
|
+
const r = Number.parseInt(match[1], 10);
|
|
435
|
+
const g = Number.parseInt(match[2], 10);
|
|
436
|
+
const b = Number.parseInt(match[3], 10);
|
|
437
|
+
if (r > 255 || g > 255 || b > 255) {
|
|
438
|
+
throw new Error(`RGB channel values must be 0-255, got: ${color}`);
|
|
439
|
+
}
|
|
440
|
+
if (match[4] !== void 0) {
|
|
441
|
+
const a = Number.parseFloat(match[4]);
|
|
442
|
+
if (a < 0 || a > 1) {
|
|
443
|
+
throw new Error(`Alpha value must be 0-1, got: ${a}`);
|
|
444
|
+
}
|
|
445
|
+
const alphaByte = Math.round(a * 255);
|
|
446
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(alphaByte)}`;
|
|
447
|
+
}
|
|
448
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
449
|
+
}
|
|
450
|
+
function isRgbaColor(color) {
|
|
451
|
+
return rgbaRegex.test(color);
|
|
452
|
+
}
|
|
453
|
+
function isHexColor(color) {
|
|
454
|
+
return hexColorRegex.test(color);
|
|
455
|
+
}
|
|
456
|
+
function normalizeColor(color) {
|
|
457
|
+
if (isHexColor(color)) {
|
|
458
|
+
return color;
|
|
459
|
+
}
|
|
460
|
+
if (isRgbaColor(color)) {
|
|
461
|
+
return parseRgbaToHex(color);
|
|
462
|
+
}
|
|
463
|
+
throw new Error(`Expected #RRGGBB, #RRGGBBAA, rgb(), or rgba() color, got: ${color}`);
|
|
464
|
+
}
|
|
465
|
+
function srgbToLinear(channel) {
|
|
466
|
+
const normalized = channel / 255;
|
|
467
|
+
if (normalized <= 0.03928) {
|
|
468
|
+
return normalized / 12.92;
|
|
469
|
+
}
|
|
470
|
+
return ((normalized + 0.055) / 1.055) ** 2.4;
|
|
471
|
+
}
|
|
472
|
+
function relativeLuminance(hexColor) {
|
|
473
|
+
const normalized = isRgbaColor(hexColor) ? parseRgbaToHex(hexColor) : hexColor;
|
|
474
|
+
const rgb = parseHexColor(normalized);
|
|
475
|
+
const r = srgbToLinear(rgb.r);
|
|
476
|
+
const g = srgbToLinear(rgb.g);
|
|
477
|
+
const b = srgbToLinear(rgb.b);
|
|
478
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
479
|
+
}
|
|
480
|
+
function contrastRatio(foreground, background) {
|
|
481
|
+
const fg = relativeLuminance(foreground);
|
|
482
|
+
const bg = relativeLuminance(background);
|
|
483
|
+
const lighter = Math.max(fg, bg);
|
|
484
|
+
const darker = Math.min(fg, bg);
|
|
485
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
486
|
+
}
|
|
487
|
+
function withAlpha(hexColor, opacity) {
|
|
488
|
+
const rgb = parseHexColor(hexColor);
|
|
489
|
+
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${opacity})`;
|
|
490
|
+
}
|
|
491
|
+
function blendColorWithOpacity(foreground, background, opacity) {
|
|
492
|
+
const fg = parseHexColor(foreground);
|
|
493
|
+
const bg = parseHexColor(background);
|
|
494
|
+
const r = Math.round(fg.r * opacity + bg.r * (1 - opacity));
|
|
495
|
+
const g = Math.round(fg.g * opacity + bg.g * (1 - opacity));
|
|
496
|
+
const b = Math.round(fg.b * opacity + bg.b * (1 - opacity));
|
|
497
|
+
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`.toUpperCase();
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// src/themes/builtin.ts
|
|
501
|
+
var colorHexSchema = z.string().refine(
|
|
502
|
+
(v) => {
|
|
503
|
+
try {
|
|
504
|
+
normalizeColor(v);
|
|
505
|
+
return true;
|
|
506
|
+
} catch {
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
{ message: "Expected #RRGGBB, #RRGGBBAA, rgb(), or rgba() color" }
|
|
511
|
+
).transform((v) => normalizeColor(v));
|
|
261
512
|
var fontFamilySchema = z.string().min(1).max(120);
|
|
262
513
|
var codeThemeSchema = z.object({
|
|
263
514
|
background: colorHexSchema,
|
|
@@ -488,7 +739,17 @@ function resolveTheme(theme) {
|
|
|
488
739
|
}
|
|
489
740
|
|
|
490
741
|
// src/spec.schema.ts
|
|
491
|
-
var colorHexSchema2 = z2.string().
|
|
742
|
+
var colorHexSchema2 = z2.string().refine(
|
|
743
|
+
(v) => {
|
|
744
|
+
try {
|
|
745
|
+
normalizeColor(v);
|
|
746
|
+
return true;
|
|
747
|
+
} catch {
|
|
748
|
+
return false;
|
|
749
|
+
}
|
|
750
|
+
},
|
|
751
|
+
{ message: "Expected #RRGGBB, #RRGGBBAA, rgb(), or rgba() color" }
|
|
752
|
+
).transform((v) => normalizeColor(v));
|
|
492
753
|
var gradientStopSchema = z2.object({
|
|
493
754
|
offset: z2.number().min(0).max(1),
|
|
494
755
|
color: colorHexSchema2
|
|
@@ -673,13 +934,32 @@ var cardElementSchema = z2.object({
|
|
|
673
934
|
tone: z2.enum(["neutral", "accent", "success", "warning", "error"]).default("neutral"),
|
|
674
935
|
icon: z2.string().min(1).max(64).optional()
|
|
675
936
|
}).strict();
|
|
937
|
+
var flowNodeShadowSchema = z2.object({
|
|
938
|
+
color: colorHexSchema2.optional(),
|
|
939
|
+
blur: z2.number().min(0).max(64).default(8),
|
|
940
|
+
offsetX: z2.number().min(-32).max(32).default(0),
|
|
941
|
+
offsetY: z2.number().min(-32).max(32).default(0),
|
|
942
|
+
opacity: z2.number().min(0).max(1).default(0.3)
|
|
943
|
+
}).strict();
|
|
676
944
|
var flowNodeElementSchema = z2.object({
|
|
677
945
|
type: z2.literal("flow-node"),
|
|
678
946
|
id: z2.string().min(1).max(120),
|
|
679
|
-
shape: z2.enum([
|
|
947
|
+
shape: z2.enum([
|
|
948
|
+
"box",
|
|
949
|
+
"rounded-box",
|
|
950
|
+
"diamond",
|
|
951
|
+
"circle",
|
|
952
|
+
"pill",
|
|
953
|
+
"cylinder",
|
|
954
|
+
"parallelogram",
|
|
955
|
+
"hexagon"
|
|
956
|
+
]).default("rounded-box"),
|
|
680
957
|
label: z2.string().min(1).max(200),
|
|
681
958
|
sublabel: z2.string().min(1).max(300).optional(),
|
|
682
959
|
sublabelColor: colorHexSchema2.optional(),
|
|
960
|
+
sublabel2: z2.string().min(1).max(300).optional(),
|
|
961
|
+
sublabel2Color: colorHexSchema2.optional(),
|
|
962
|
+
sublabel2FontSize: z2.number().min(8).max(32).optional(),
|
|
683
963
|
labelColor: colorHexSchema2.optional(),
|
|
684
964
|
labelFontSize: z2.number().min(10).max(48).optional(),
|
|
685
965
|
color: colorHexSchema2.optional(),
|
|
@@ -688,20 +968,30 @@ var flowNodeElementSchema = z2.object({
|
|
|
688
968
|
cornerRadius: z2.number().min(0).max(64).optional(),
|
|
689
969
|
width: z2.number().int().min(40).max(800).optional(),
|
|
690
970
|
height: z2.number().int().min(30).max(600).optional(),
|
|
691
|
-
|
|
971
|
+
fillOpacity: z2.number().min(0).max(1).default(1),
|
|
972
|
+
opacity: z2.number().min(0).max(1).default(1),
|
|
973
|
+
badgeText: z2.string().min(1).max(32).optional(),
|
|
974
|
+
badgeColor: colorHexSchema2.optional(),
|
|
975
|
+
badgeBackground: colorHexSchema2.optional(),
|
|
976
|
+
badgePosition: z2.enum(["top", "inside-top"]).default("inside-top"),
|
|
977
|
+
shadow: flowNodeShadowSchema.optional()
|
|
692
978
|
}).strict();
|
|
693
979
|
var connectionElementSchema = z2.object({
|
|
694
980
|
type: z2.literal("connection"),
|
|
695
981
|
from: z2.string().min(1).max(120),
|
|
696
982
|
to: z2.string().min(1).max(120),
|
|
697
983
|
style: z2.enum(["solid", "dashed", "dotted"]).default("solid"),
|
|
984
|
+
strokeStyle: z2.enum(["solid", "dashed", "dotted"]).default("solid"),
|
|
698
985
|
arrow: z2.enum(["end", "start", "both", "none"]).default("end"),
|
|
699
986
|
label: z2.string().min(1).max(200).optional(),
|
|
700
987
|
labelPosition: z2.enum(["start", "middle", "end"]).default("middle"),
|
|
701
988
|
color: colorHexSchema2.optional(),
|
|
702
|
-
width: z2.number().min(0.5).max(
|
|
989
|
+
width: z2.number().min(0.5).max(10).optional(),
|
|
990
|
+
strokeWidth: z2.number().min(0.5).max(10).default(2),
|
|
703
991
|
arrowSize: z2.number().min(4).max(32).optional(),
|
|
704
|
-
opacity: z2.number().min(0).max(1).default(1)
|
|
992
|
+
opacity: z2.number().min(0).max(1).default(1),
|
|
993
|
+
routing: z2.enum(["auto", "orthogonal", "curve", "arc"]).default("auto"),
|
|
994
|
+
tension: z2.number().min(0.1).max(0.8).default(0.35)
|
|
705
995
|
}).strict();
|
|
706
996
|
var codeBlockStyleSchema = z2.object({
|
|
707
997
|
paddingVertical: z2.number().min(0).max(128).default(56),
|
|
@@ -770,6 +1060,10 @@ var elementSchema = z2.discriminatedUnion("type", [
|
|
|
770
1060
|
shapeElementSchema,
|
|
771
1061
|
imageElementSchema
|
|
772
1062
|
]);
|
|
1063
|
+
var diagramCenterSchema = z2.object({
|
|
1064
|
+
x: z2.number(),
|
|
1065
|
+
y: z2.number()
|
|
1066
|
+
}).strict();
|
|
773
1067
|
var autoLayoutConfigSchema = z2.object({
|
|
774
1068
|
mode: z2.literal("auto"),
|
|
775
1069
|
algorithm: z2.enum(["layered", "stress", "force", "radial", "box"]).default("layered"),
|
|
@@ -777,7 +1071,17 @@ var autoLayoutConfigSchema = z2.object({
|
|
|
777
1071
|
nodeSpacing: z2.number().int().min(0).max(512).default(80),
|
|
778
1072
|
rankSpacing: z2.number().int().min(0).max(512).default(120),
|
|
779
1073
|
edgeRouting: z2.enum(["orthogonal", "polyline", "spline"]).default("polyline"),
|
|
780
|
-
aspectRatio: z2.number().min(0.5).max(3).optional()
|
|
1074
|
+
aspectRatio: z2.number().min(0.5).max(3).optional(),
|
|
1075
|
+
/** ID of the root node for radial layout. Only relevant when algorithm is 'radial'. */
|
|
1076
|
+
radialRoot: z2.string().min(1).max(120).optional(),
|
|
1077
|
+
/** Fixed radius in pixels for radial layout. Only relevant when algorithm is 'radial'. */
|
|
1078
|
+
radialRadius: z2.number().positive().optional(),
|
|
1079
|
+
/** Compaction strategy for radial layout. Only relevant when algorithm is 'radial'. */
|
|
1080
|
+
radialCompaction: z2.enum(["none", "radial", "wedge"]).optional(),
|
|
1081
|
+
/** Sort strategy for radial layout node ordering. Only relevant when algorithm is 'radial'. */
|
|
1082
|
+
radialSortBy: z2.enum(["id", "connections"]).optional(),
|
|
1083
|
+
/** Explicit center used by curve/arc connection routing. */
|
|
1084
|
+
diagramCenter: diagramCenterSchema.optional()
|
|
781
1085
|
}).strict();
|
|
782
1086
|
var gridLayoutConfigSchema = z2.object({
|
|
783
1087
|
mode: z2.literal("grid"),
|
|
@@ -785,13 +1089,17 @@ var gridLayoutConfigSchema = z2.object({
|
|
|
785
1089
|
gap: z2.number().int().min(0).max(256).default(24),
|
|
786
1090
|
cardMinHeight: z2.number().int().min(32).max(4096).optional(),
|
|
787
1091
|
cardMaxHeight: z2.number().int().min(32).max(4096).optional(),
|
|
788
|
-
equalHeight: z2.boolean().default(false)
|
|
1092
|
+
equalHeight: z2.boolean().default(false),
|
|
1093
|
+
/** Explicit center used by curve/arc connection routing. */
|
|
1094
|
+
diagramCenter: diagramCenterSchema.optional()
|
|
789
1095
|
}).strict();
|
|
790
1096
|
var stackLayoutConfigSchema = z2.object({
|
|
791
1097
|
mode: z2.literal("stack"),
|
|
792
1098
|
direction: z2.enum(["vertical", "horizontal"]).default("vertical"),
|
|
793
1099
|
gap: z2.number().int().min(0).max(256).default(24),
|
|
794
|
-
alignment: z2.enum(["start", "center", "end", "stretch"]).default("stretch")
|
|
1100
|
+
alignment: z2.enum(["start", "center", "end", "stretch"]).default("stretch"),
|
|
1101
|
+
/** Explicit center used by curve/arc connection routing. */
|
|
1102
|
+
diagramCenter: diagramCenterSchema.optional()
|
|
795
1103
|
}).strict();
|
|
796
1104
|
var manualPositionSchema = z2.object({
|
|
797
1105
|
x: z2.number().int(),
|
|
@@ -801,7 +1109,9 @@ var manualPositionSchema = z2.object({
|
|
|
801
1109
|
}).strict();
|
|
802
1110
|
var manualLayoutConfigSchema = z2.object({
|
|
803
1111
|
mode: z2.literal("manual"),
|
|
804
|
-
positions: z2.record(z2.string().min(1), manualPositionSchema).default({})
|
|
1112
|
+
positions: z2.record(z2.string().min(1), manualPositionSchema).default({}),
|
|
1113
|
+
/** Explicit center used by curve/arc connection routing. */
|
|
1114
|
+
diagramCenter: diagramCenterSchema.optional()
|
|
805
1115
|
}).strict();
|
|
806
1116
|
var layoutConfigSchema = z2.discriminatedUnion("mode", [
|
|
807
1117
|
autoLayoutConfigSchema,
|
|
@@ -853,6 +1163,31 @@ var canvasSchema = z2.object({
|
|
|
853
1163
|
padding: z2.number().int().min(0).max(256).default(defaultCanvas.padding)
|
|
854
1164
|
}).strict();
|
|
855
1165
|
var themeInputSchema = z2.union([builtInThemeSchema, themeSchema]);
|
|
1166
|
+
var diagramPositionSchema = z2.object({
|
|
1167
|
+
x: z2.number(),
|
|
1168
|
+
y: z2.number(),
|
|
1169
|
+
width: z2.number().positive(),
|
|
1170
|
+
height: z2.number().positive()
|
|
1171
|
+
}).strict();
|
|
1172
|
+
var diagramElementSchema = z2.discriminatedUnion("type", [
|
|
1173
|
+
flowNodeElementSchema,
|
|
1174
|
+
connectionElementSchema
|
|
1175
|
+
]);
|
|
1176
|
+
var diagramLayoutSchema = z2.object({
|
|
1177
|
+
mode: z2.enum(["manual", "auto"]).default("manual"),
|
|
1178
|
+
positions: z2.record(z2.string(), diagramPositionSchema).optional(),
|
|
1179
|
+
diagramCenter: diagramCenterSchema.optional()
|
|
1180
|
+
}).strict();
|
|
1181
|
+
var diagramSpecSchema = z2.object({
|
|
1182
|
+
version: z2.literal(1),
|
|
1183
|
+
canvas: z2.object({
|
|
1184
|
+
width: z2.number().int().min(320).max(4096).default(1200),
|
|
1185
|
+
height: z2.number().int().min(180).max(4096).default(675)
|
|
1186
|
+
}).default({ width: 1200, height: 675 }),
|
|
1187
|
+
theme: themeSchema.optional(),
|
|
1188
|
+
elements: z2.array(diagramElementSchema).min(1),
|
|
1189
|
+
layout: diagramLayoutSchema.default({ mode: "manual" })
|
|
1190
|
+
}).strict();
|
|
856
1191
|
var designSpecSchema = z2.object({
|
|
857
1192
|
version: z2.literal(2).default(2),
|
|
858
1193
|
canvas: canvasSchema.default(defaultCanvas),
|
|
@@ -877,47 +1212,13 @@ function deriveSafeFrame(spec) {
|
|
|
877
1212
|
height: spec.canvas.height - spec.canvas.padding * 2
|
|
878
1213
|
};
|
|
879
1214
|
}
|
|
1215
|
+
function parseDiagramSpec(input) {
|
|
1216
|
+
return diagramSpecSchema.parse(input);
|
|
1217
|
+
}
|
|
880
1218
|
function parseDesignSpec(input) {
|
|
881
1219
|
return designSpecSchema.parse(input);
|
|
882
1220
|
}
|
|
883
1221
|
|
|
884
|
-
// src/utils/color.ts
|
|
885
|
-
function parseChannel(hex, offset) {
|
|
886
|
-
return Number.parseInt(hex.slice(offset, offset + 2), 16);
|
|
887
|
-
}
|
|
888
|
-
function parseHexColor(hexColor) {
|
|
889
|
-
const normalized = hexColor.startsWith("#") ? hexColor.slice(1) : hexColor;
|
|
890
|
-
if (normalized.length !== 6 && normalized.length !== 8) {
|
|
891
|
-
throw new Error(`Unsupported color format: ${hexColor}`);
|
|
892
|
-
}
|
|
893
|
-
return {
|
|
894
|
-
r: parseChannel(normalized, 0),
|
|
895
|
-
g: parseChannel(normalized, 2),
|
|
896
|
-
b: parseChannel(normalized, 4)
|
|
897
|
-
};
|
|
898
|
-
}
|
|
899
|
-
function srgbToLinear(channel) {
|
|
900
|
-
const normalized = channel / 255;
|
|
901
|
-
if (normalized <= 0.03928) {
|
|
902
|
-
return normalized / 12.92;
|
|
903
|
-
}
|
|
904
|
-
return ((normalized + 0.055) / 1.055) ** 2.4;
|
|
905
|
-
}
|
|
906
|
-
function relativeLuminance(hexColor) {
|
|
907
|
-
const rgb = parseHexColor(hexColor);
|
|
908
|
-
const r = srgbToLinear(rgb.r);
|
|
909
|
-
const g = srgbToLinear(rgb.g);
|
|
910
|
-
const b = srgbToLinear(rgb.b);
|
|
911
|
-
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
912
|
-
}
|
|
913
|
-
function contrastRatio(foreground, background) {
|
|
914
|
-
const fg = relativeLuminance(foreground);
|
|
915
|
-
const bg = relativeLuminance(background);
|
|
916
|
-
const lighter = Math.max(fg, bg);
|
|
917
|
-
const darker = Math.min(fg, bg);
|
|
918
|
-
return (lighter + 0.05) / (darker + 0.05);
|
|
919
|
-
}
|
|
920
|
-
|
|
921
1222
|
// src/qa.ts
|
|
922
1223
|
function rectWithin(outer, inner) {
|
|
923
1224
|
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;
|
|
@@ -963,7 +1264,7 @@ async function runQa(options) {
|
|
|
963
1264
|
const imagePath = resolve(options.imagePath);
|
|
964
1265
|
const expectedSafeFrame = deriveSafeFrame(spec);
|
|
965
1266
|
const expectedCanvas = canvasRect(spec);
|
|
966
|
-
const imageMetadata = await
|
|
1267
|
+
const imageMetadata = await sharp2(imagePath).metadata();
|
|
967
1268
|
const issues = [];
|
|
968
1269
|
const expectedScale = options.metadata?.canvas.scale ?? resolveRenderScale(spec);
|
|
969
1270
|
const expectedWidth = spec.canvas.width * expectedScale;
|
|
@@ -1114,6 +1415,31 @@ async function runQa(options) {
|
|
|
1114
1415
|
});
|
|
1115
1416
|
}
|
|
1116
1417
|
}
|
|
1418
|
+
let referenceResult;
|
|
1419
|
+
if (options.referencePath) {
|
|
1420
|
+
const { compareImages: compareImages2 } = await Promise.resolve().then(() => (init_compare(), compare_exports));
|
|
1421
|
+
const comparison = await compareImages2(options.referencePath, imagePath);
|
|
1422
|
+
referenceResult = {
|
|
1423
|
+
similarity: comparison.similarity,
|
|
1424
|
+
verdict: comparison.verdict,
|
|
1425
|
+
regions: comparison.regions.map((region) => ({
|
|
1426
|
+
label: region.label,
|
|
1427
|
+
similarity: region.similarity
|
|
1428
|
+
}))
|
|
1429
|
+
};
|
|
1430
|
+
if (comparison.verdict === "mismatch") {
|
|
1431
|
+
const severity = comparison.similarity < 0.5 ? "error" : "warning";
|
|
1432
|
+
issues.push({
|
|
1433
|
+
code: "REFERENCE_MISMATCH",
|
|
1434
|
+
severity,
|
|
1435
|
+
message: `Reference image comparison ${severity === "error" ? "failed" : "warned"}: similarity ${comparison.similarity.toFixed(4)} with verdict "${comparison.verdict}".`,
|
|
1436
|
+
details: {
|
|
1437
|
+
similarity: comparison.similarity,
|
|
1438
|
+
verdict: comparison.verdict
|
|
1439
|
+
}
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1117
1443
|
const footerSpacingPx = options.metadata?.layout.elements ? (() => {
|
|
1118
1444
|
const footer = options.metadata.layout.elements.find((element) => element.id === "footer");
|
|
1119
1445
|
if (!footer) {
|
|
@@ -1146,7 +1472,8 @@ async function runQa(options) {
|
|
|
1146
1472
|
...imageMetadata.height !== void 0 ? { height: imageMetadata.height } : {},
|
|
1147
1473
|
...footerSpacingPx !== void 0 ? { footerSpacingPx } : {}
|
|
1148
1474
|
},
|
|
1149
|
-
issues
|
|
1475
|
+
issues,
|
|
1476
|
+
...referenceResult ? { reference: referenceResult } : {}
|
|
1150
1477
|
};
|
|
1151
1478
|
}
|
|
1152
1479
|
|
|
@@ -1184,87 +1511,482 @@ function loadFonts() {
|
|
|
1184
1511
|
// src/layout/elk.ts
|
|
1185
1512
|
import ELK from "elkjs";
|
|
1186
1513
|
|
|
1187
|
-
// src/
|
|
1188
|
-
function
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
return 220;
|
|
1204
|
-
case "connection":
|
|
1205
|
-
return 0;
|
|
1206
|
-
}
|
|
1514
|
+
// src/primitives/shapes.ts
|
|
1515
|
+
function roundRectPath(ctx, rect, radius) {
|
|
1516
|
+
const r = Math.max(0, Math.min(radius, rect.width / 2, rect.height / 2));
|
|
1517
|
+
const right = rect.x + rect.width;
|
|
1518
|
+
const bottom = rect.y + rect.height;
|
|
1519
|
+
ctx.beginPath();
|
|
1520
|
+
ctx.moveTo(rect.x + r, rect.y);
|
|
1521
|
+
ctx.lineTo(right - r, rect.y);
|
|
1522
|
+
ctx.quadraticCurveTo(right, rect.y, right, rect.y + r);
|
|
1523
|
+
ctx.lineTo(right, bottom - r);
|
|
1524
|
+
ctx.quadraticCurveTo(right, bottom, right - r, bottom);
|
|
1525
|
+
ctx.lineTo(rect.x + r, bottom);
|
|
1526
|
+
ctx.quadraticCurveTo(rect.x, bottom, rect.x, bottom - r);
|
|
1527
|
+
ctx.lineTo(rect.x, rect.y + r);
|
|
1528
|
+
ctx.quadraticCurveTo(rect.x, rect.y, rect.x + r, rect.y);
|
|
1529
|
+
ctx.closePath();
|
|
1207
1530
|
}
|
|
1208
|
-
function
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
case "code-block":
|
|
1215
|
-
return 420;
|
|
1216
|
-
case "terminal":
|
|
1217
|
-
return 420;
|
|
1218
|
-
case "text":
|
|
1219
|
-
return 360;
|
|
1220
|
-
case "shape":
|
|
1221
|
-
return 280;
|
|
1222
|
-
case "image":
|
|
1223
|
-
return 320;
|
|
1224
|
-
case "connection":
|
|
1225
|
-
return 0;
|
|
1531
|
+
function fillAndStroke(ctx, fill, stroke) {
|
|
1532
|
+
ctx.fillStyle = fill;
|
|
1533
|
+
ctx.fill();
|
|
1534
|
+
if (stroke) {
|
|
1535
|
+
ctx.strokeStyle = stroke;
|
|
1536
|
+
ctx.stroke();
|
|
1226
1537
|
}
|
|
1227
1538
|
}
|
|
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
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1539
|
+
function drawRoundedRect(ctx, rect, radius, fill, stroke) {
|
|
1540
|
+
roundRectPath(ctx, rect, radius);
|
|
1541
|
+
fillAndStroke(ctx, fill, stroke);
|
|
1542
|
+
}
|
|
1543
|
+
function drawCircle(ctx, center, radius, fill, stroke) {
|
|
1544
|
+
ctx.beginPath();
|
|
1545
|
+
ctx.arc(center.x, center.y, Math.max(0, radius), 0, Math.PI * 2);
|
|
1546
|
+
ctx.closePath();
|
|
1547
|
+
fillAndStroke(ctx, fill, stroke);
|
|
1548
|
+
}
|
|
1549
|
+
function drawDiamond(ctx, bounds, fill, stroke) {
|
|
1550
|
+
const cx = bounds.x + bounds.width / 2;
|
|
1551
|
+
const cy = bounds.y + bounds.height / 2;
|
|
1552
|
+
ctx.beginPath();
|
|
1553
|
+
ctx.moveTo(cx, bounds.y);
|
|
1554
|
+
ctx.lineTo(bounds.x + bounds.width, cy);
|
|
1555
|
+
ctx.lineTo(cx, bounds.y + bounds.height);
|
|
1556
|
+
ctx.lineTo(bounds.x, cy);
|
|
1557
|
+
ctx.closePath();
|
|
1558
|
+
fillAndStroke(ctx, fill, stroke);
|
|
1559
|
+
}
|
|
1560
|
+
function drawPill(ctx, bounds, fill, stroke) {
|
|
1561
|
+
drawRoundedRect(ctx, bounds, Math.min(bounds.width, bounds.height) / 2, fill, stroke);
|
|
1562
|
+
}
|
|
1563
|
+
function drawEllipse(ctx, bounds, fill, stroke) {
|
|
1564
|
+
const cx = bounds.x + bounds.width / 2;
|
|
1565
|
+
const cy = bounds.y + bounds.height / 2;
|
|
1566
|
+
ctx.beginPath();
|
|
1567
|
+
ctx.ellipse(
|
|
1568
|
+
cx,
|
|
1569
|
+
cy,
|
|
1570
|
+
Math.max(0, bounds.width / 2),
|
|
1571
|
+
Math.max(0, bounds.height / 2),
|
|
1572
|
+
0,
|
|
1573
|
+
0,
|
|
1574
|
+
Math.PI * 2
|
|
1575
|
+
);
|
|
1576
|
+
ctx.closePath();
|
|
1577
|
+
fillAndStroke(ctx, fill, stroke);
|
|
1578
|
+
}
|
|
1579
|
+
function drawCylinder(ctx, bounds, fill, stroke) {
|
|
1580
|
+
const rx = Math.max(2, bounds.width / 2);
|
|
1581
|
+
const ry = Math.max(2, Math.min(bounds.height * 0.18, 16));
|
|
1582
|
+
const cx = bounds.x + bounds.width / 2;
|
|
1583
|
+
const topCy = bounds.y + ry;
|
|
1584
|
+
const bottomCy = bounds.y + bounds.height - ry;
|
|
1585
|
+
ctx.beginPath();
|
|
1586
|
+
ctx.moveTo(bounds.x, topCy);
|
|
1587
|
+
ctx.ellipse(cx, topCy, rx, ry, 0, Math.PI, 0, true);
|
|
1588
|
+
ctx.lineTo(bounds.x + bounds.width, bottomCy);
|
|
1589
|
+
ctx.ellipse(cx, bottomCy, rx, ry, 0, 0, Math.PI, false);
|
|
1590
|
+
ctx.closePath();
|
|
1591
|
+
fillAndStroke(ctx, fill, stroke);
|
|
1592
|
+
if (stroke) {
|
|
1593
|
+
ctx.beginPath();
|
|
1594
|
+
ctx.ellipse(cx, topCy, rx, ry, 0, 0, Math.PI * 2);
|
|
1595
|
+
ctx.closePath();
|
|
1596
|
+
ctx.strokeStyle = stroke;
|
|
1597
|
+
ctx.stroke();
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
function drawParallelogram(ctx, bounds, fill, stroke, skew) {
|
|
1601
|
+
const maxSkew = bounds.width * 0.45;
|
|
1602
|
+
const skewX = Math.max(-maxSkew, Math.min(maxSkew, skew ?? bounds.width * 0.18));
|
|
1603
|
+
ctx.beginPath();
|
|
1604
|
+
ctx.moveTo(bounds.x + skewX, bounds.y);
|
|
1605
|
+
ctx.lineTo(bounds.x + bounds.width, bounds.y);
|
|
1606
|
+
ctx.lineTo(bounds.x + bounds.width - skewX, bounds.y + bounds.height);
|
|
1607
|
+
ctx.lineTo(bounds.x, bounds.y + bounds.height);
|
|
1608
|
+
ctx.closePath();
|
|
1609
|
+
fillAndStroke(ctx, fill, stroke);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// src/primitives/text.ts
|
|
1613
|
+
var SUPPORTED_FONT_FAMILIES = /* @__PURE__ */ new Set(["Inter", "JetBrains Mono", "Space Grotesk"]);
|
|
1614
|
+
function resolveFont(requested, role) {
|
|
1615
|
+
if (SUPPORTED_FONT_FAMILIES.has(requested)) {
|
|
1616
|
+
return requested;
|
|
1617
|
+
}
|
|
1618
|
+
if (role === "mono" || /mono|code|terminal|console/iu.test(requested)) {
|
|
1619
|
+
return "JetBrains Mono";
|
|
1620
|
+
}
|
|
1621
|
+
if (role === "heading" || /display|grotesk|headline/iu.test(requested)) {
|
|
1622
|
+
return "Space Grotesk";
|
|
1623
|
+
}
|
|
1624
|
+
return "Inter";
|
|
1625
|
+
}
|
|
1626
|
+
function applyFont(ctx, options) {
|
|
1627
|
+
ctx.font = `${options.weight} ${options.size}px ${options.family}`;
|
|
1628
|
+
}
|
|
1629
|
+
function wrapText(ctx, text, maxWidth, maxLines) {
|
|
1630
|
+
const trimmed = text.trim();
|
|
1631
|
+
if (!trimmed) {
|
|
1632
|
+
return { lines: [], truncated: false };
|
|
1633
|
+
}
|
|
1634
|
+
const words = trimmed.split(/\s+/u);
|
|
1635
|
+
const lines = [];
|
|
1636
|
+
let current = "";
|
|
1637
|
+
for (const word of words) {
|
|
1638
|
+
const trial = current.length > 0 ? `${current} ${word}` : word;
|
|
1639
|
+
if (ctx.measureText(trial).width <= maxWidth) {
|
|
1640
|
+
current = trial;
|
|
1641
|
+
continue;
|
|
1642
|
+
}
|
|
1643
|
+
if (current.length > 0) {
|
|
1644
|
+
lines.push(current);
|
|
1645
|
+
current = word;
|
|
1646
|
+
} else {
|
|
1647
|
+
lines.push(word);
|
|
1648
|
+
current = "";
|
|
1649
|
+
}
|
|
1650
|
+
if (lines.length >= maxLines) {
|
|
1651
|
+
break;
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
if (lines.length < maxLines && current.length > 0) {
|
|
1655
|
+
lines.push(current);
|
|
1656
|
+
}
|
|
1657
|
+
const wasTruncated = lines.length >= maxLines && words.join(" ") !== lines.join(" ");
|
|
1658
|
+
if (!wasTruncated) {
|
|
1659
|
+
return { lines, truncated: false };
|
|
1660
|
+
}
|
|
1661
|
+
const lastIndex = lines.length - 1;
|
|
1662
|
+
let truncatedLine = `${lines[lastIndex]}\u2026`;
|
|
1663
|
+
while (truncatedLine.length > 1 && ctx.measureText(truncatedLine).width > maxWidth) {
|
|
1664
|
+
truncatedLine = `${truncatedLine.slice(0, -2)}\u2026`;
|
|
1665
|
+
}
|
|
1666
|
+
lines[lastIndex] = truncatedLine;
|
|
1667
|
+
return { lines, truncated: true };
|
|
1668
|
+
}
|
|
1669
|
+
function drawTextBlock(ctx, options) {
|
|
1670
|
+
applyFont(ctx, { size: options.fontSize, weight: options.fontWeight, family: options.family });
|
|
1671
|
+
const wrapped = wrapText(ctx, options.text, options.maxWidth, options.maxLines);
|
|
1672
|
+
ctx.fillStyle = options.color;
|
|
1673
|
+
for (const [index, line] of wrapped.lines.entries()) {
|
|
1674
|
+
ctx.fillText(line, options.x, options.y + index * options.lineHeight);
|
|
1675
|
+
}
|
|
1676
|
+
return {
|
|
1677
|
+
height: wrapped.lines.length * options.lineHeight,
|
|
1678
|
+
truncated: wrapped.truncated
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
function drawTextLabel(ctx, text, position, options) {
|
|
1682
|
+
applyFont(ctx, { size: options.fontSize, weight: 600, family: options.fontFamily });
|
|
1683
|
+
const textWidth = Math.ceil(ctx.measureText(text).width);
|
|
1684
|
+
const rect = {
|
|
1685
|
+
x: Math.round(position.x - (textWidth + options.padding * 2) / 2),
|
|
1686
|
+
y: Math.round(position.y - (options.fontSize + options.padding * 2) / 2),
|
|
1687
|
+
width: textWidth + options.padding * 2,
|
|
1688
|
+
height: options.fontSize + options.padding * 2
|
|
1689
|
+
};
|
|
1690
|
+
drawRoundedRect(ctx, rect, options.borderRadius, options.backgroundColor);
|
|
1691
|
+
ctx.fillStyle = options.color;
|
|
1692
|
+
ctx.fillText(text, rect.x + options.padding, rect.y + rect.height - options.padding);
|
|
1693
|
+
return rect;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// src/renderers/flow-node.ts
|
|
1697
|
+
var BADGE_FONT_SIZE = 10;
|
|
1698
|
+
var BADGE_FONT_WEIGHT = 600;
|
|
1699
|
+
var BADGE_LETTER_SPACING = 1;
|
|
1700
|
+
var BADGE_PADDING_X = 8;
|
|
1701
|
+
var BADGE_PADDING_Y = 3;
|
|
1702
|
+
var BADGE_BORDER_RADIUS = 12;
|
|
1703
|
+
var BADGE_DEFAULT_COLOR = "#FFFFFF";
|
|
1704
|
+
var BADGE_PILL_HEIGHT = BADGE_FONT_SIZE + BADGE_PADDING_Y * 2;
|
|
1705
|
+
var BADGE_INSIDE_TOP_EXTRA = BADGE_PILL_HEIGHT + 6;
|
|
1706
|
+
function drawNodeShape(ctx, shape, bounds, fill, stroke, cornerRadius) {
|
|
1707
|
+
switch (shape) {
|
|
1708
|
+
case "box":
|
|
1709
|
+
drawRoundedRect(ctx, bounds, 0, fill, stroke);
|
|
1710
|
+
break;
|
|
1711
|
+
case "rounded-box":
|
|
1712
|
+
drawRoundedRect(ctx, bounds, cornerRadius, fill, stroke);
|
|
1713
|
+
break;
|
|
1714
|
+
case "diamond":
|
|
1715
|
+
drawDiamond(ctx, bounds, fill, stroke);
|
|
1716
|
+
break;
|
|
1717
|
+
case "circle": {
|
|
1718
|
+
const radius = Math.min(bounds.width, bounds.height) / 2;
|
|
1719
|
+
drawCircle(
|
|
1720
|
+
ctx,
|
|
1721
|
+
{ x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 },
|
|
1722
|
+
radius,
|
|
1723
|
+
fill,
|
|
1724
|
+
stroke
|
|
1725
|
+
);
|
|
1726
|
+
break;
|
|
1727
|
+
}
|
|
1728
|
+
case "pill":
|
|
1729
|
+
drawPill(ctx, bounds, fill, stroke);
|
|
1730
|
+
break;
|
|
1731
|
+
case "cylinder":
|
|
1732
|
+
drawCylinder(ctx, bounds, fill, stroke);
|
|
1733
|
+
break;
|
|
1734
|
+
case "parallelogram":
|
|
1735
|
+
drawParallelogram(ctx, bounds, fill, stroke);
|
|
1736
|
+
break;
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
function measureSpacedText(ctx, text, letterSpacing) {
|
|
1740
|
+
const base = ctx.measureText(text).width;
|
|
1741
|
+
const extraChars = [...text].length - 1;
|
|
1742
|
+
return extraChars > 0 ? base + extraChars * letterSpacing : base;
|
|
1743
|
+
}
|
|
1744
|
+
function drawSpacedText(ctx, text, centerX, centerY, letterSpacing) {
|
|
1745
|
+
const chars = [...text];
|
|
1746
|
+
if (chars.length === 0) return;
|
|
1747
|
+
const totalWidth = measureSpacedText(ctx, text, letterSpacing);
|
|
1748
|
+
let cursorX = centerX - totalWidth / 2;
|
|
1749
|
+
ctx.textAlign = "left";
|
|
1750
|
+
for (let i = 0; i < chars.length; i++) {
|
|
1751
|
+
ctx.fillText(chars[i], cursorX, centerY);
|
|
1752
|
+
cursorX += ctx.measureText(chars[i]).width + (i < chars.length - 1 ? letterSpacing : 0);
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
function renderBadgePill(ctx, centerX, centerY, text, textColor, background, monoFont) {
|
|
1756
|
+
ctx.save();
|
|
1757
|
+
applyFont(ctx, { size: BADGE_FONT_SIZE, weight: BADGE_FONT_WEIGHT, family: monoFont });
|
|
1758
|
+
const textWidth = measureSpacedText(ctx, text, BADGE_LETTER_SPACING);
|
|
1759
|
+
const pillWidth = textWidth + BADGE_PADDING_X * 2;
|
|
1760
|
+
const pillHeight = BADGE_PILL_HEIGHT;
|
|
1761
|
+
const pillX = centerX - pillWidth / 2;
|
|
1762
|
+
const pillY = centerY - pillHeight / 2;
|
|
1763
|
+
ctx.fillStyle = background;
|
|
1764
|
+
ctx.beginPath();
|
|
1765
|
+
ctx.roundRect(pillX, pillY, pillWidth, pillHeight, BADGE_BORDER_RADIUS);
|
|
1766
|
+
ctx.fill();
|
|
1767
|
+
ctx.fillStyle = textColor;
|
|
1768
|
+
ctx.textBaseline = "middle";
|
|
1769
|
+
applyFont(ctx, { size: BADGE_FONT_SIZE, weight: BADGE_FONT_WEIGHT, family: monoFont });
|
|
1770
|
+
drawSpacedText(ctx, text, centerX, centerY, BADGE_LETTER_SPACING);
|
|
1771
|
+
ctx.restore();
|
|
1772
|
+
return pillWidth;
|
|
1773
|
+
}
|
|
1774
|
+
function renderFlowNode(ctx, node, bounds, theme) {
|
|
1775
|
+
const fillColor = node.color ?? theme.surfaceElevated;
|
|
1776
|
+
const borderColor = node.borderColor ?? theme.border;
|
|
1777
|
+
const borderWidth = node.borderWidth ?? 2;
|
|
1778
|
+
const cornerRadius = node.cornerRadius ?? 16;
|
|
1779
|
+
const labelColor = node.labelColor ?? theme.text;
|
|
1780
|
+
const sublabelColor = node.sublabelColor ?? theme.textMuted;
|
|
1781
|
+
const labelFontSize = node.labelFontSize ?? 20;
|
|
1782
|
+
const fillOpacity = node.fillOpacity ?? 1;
|
|
1783
|
+
const hasBadge = !!node.badgeText;
|
|
1784
|
+
const badgePosition = node.badgePosition ?? "inside-top";
|
|
1785
|
+
const badgeColor = node.badgeColor ?? BADGE_DEFAULT_COLOR;
|
|
1786
|
+
const badgeBackground = node.badgeBackground ?? borderColor ?? theme.accent;
|
|
1787
|
+
ctx.save();
|
|
1788
|
+
ctx.lineWidth = borderWidth;
|
|
1789
|
+
if (node.shadow) {
|
|
1790
|
+
const shadowColor = node.shadow.color ?? borderColor ?? theme.accent;
|
|
1791
|
+
ctx.shadowColor = withAlpha(shadowColor, node.shadow.opacity);
|
|
1792
|
+
ctx.shadowBlur = node.shadow.blur;
|
|
1793
|
+
ctx.shadowOffsetX = node.shadow.offsetX;
|
|
1794
|
+
ctx.shadowOffsetY = node.shadow.offsetY;
|
|
1795
|
+
}
|
|
1796
|
+
if (fillOpacity < 1) {
|
|
1797
|
+
ctx.globalAlpha = node.opacity * fillOpacity;
|
|
1798
|
+
drawNodeShape(ctx, node.shape, bounds, fillColor, void 0, cornerRadius);
|
|
1799
|
+
if (node.shadow) {
|
|
1800
|
+
ctx.shadowColor = "transparent";
|
|
1801
|
+
ctx.shadowBlur = 0;
|
|
1802
|
+
ctx.shadowOffsetX = 0;
|
|
1803
|
+
ctx.shadowOffsetY = 0;
|
|
1804
|
+
}
|
|
1805
|
+
ctx.globalAlpha = node.opacity;
|
|
1806
|
+
drawNodeShape(ctx, node.shape, bounds, "rgba(0,0,0,0)", borderColor, cornerRadius);
|
|
1807
|
+
} else {
|
|
1808
|
+
ctx.globalAlpha = node.opacity;
|
|
1809
|
+
drawNodeShape(ctx, node.shape, bounds, fillColor, borderColor, cornerRadius);
|
|
1810
|
+
}
|
|
1811
|
+
if (node.shadow) {
|
|
1812
|
+
ctx.shadowColor = "transparent";
|
|
1813
|
+
ctx.shadowBlur = 0;
|
|
1814
|
+
ctx.shadowOffsetX = 0;
|
|
1815
|
+
ctx.shadowOffsetY = 0;
|
|
1816
|
+
}
|
|
1817
|
+
const headingFont = resolveFont(theme.fonts.heading, "heading");
|
|
1818
|
+
const bodyFont = resolveFont(theme.fonts.body, "body");
|
|
1819
|
+
const monoFont = resolveFont(theme.fonts.mono, "mono");
|
|
1820
|
+
const centerX = bounds.x + bounds.width / 2;
|
|
1821
|
+
const centerY = bounds.y + bounds.height / 2;
|
|
1822
|
+
const insideTopShift = hasBadge && badgePosition === "inside-top" ? BADGE_INSIDE_TOP_EXTRA / 2 : 0;
|
|
1823
|
+
const sublabelFontSize = Math.max(12, Math.round(labelFontSize * 0.68));
|
|
1824
|
+
const sublabel2FontSize = node.sublabel2FontSize ?? 11;
|
|
1825
|
+
const sublabel2Color = node.sublabel2Color ?? sublabelColor;
|
|
1826
|
+
const lineCount = node.sublabel2 ? 3 : node.sublabel ? 2 : 1;
|
|
1827
|
+
const labelToSublabelGap = Math.max(20, sublabelFontSize + 6);
|
|
1828
|
+
const sublabelToSublabel2Gap = sublabel2FontSize + 4;
|
|
1829
|
+
let textBlockHeight;
|
|
1830
|
+
if (lineCount === 1) {
|
|
1831
|
+
textBlockHeight = labelFontSize;
|
|
1832
|
+
} else if (lineCount === 2) {
|
|
1833
|
+
textBlockHeight = labelFontSize + labelToSublabelGap;
|
|
1834
|
+
} else {
|
|
1835
|
+
textBlockHeight = labelFontSize + labelToSublabelGap + sublabelToSublabel2Gap;
|
|
1836
|
+
}
|
|
1837
|
+
const labelY = lineCount === 1 ? centerY + labelFontSize * 0.3 + insideTopShift : centerY - textBlockHeight / 2 + labelFontSize * 0.8 + insideTopShift;
|
|
1838
|
+
ctx.textAlign = "center";
|
|
1839
|
+
applyFont(ctx, { size: labelFontSize, weight: 700, family: headingFont });
|
|
1840
|
+
ctx.fillStyle = labelColor;
|
|
1841
|
+
ctx.fillText(node.label, centerX, labelY);
|
|
1842
|
+
let textBoundsY = bounds.y + bounds.height / 2 - 18;
|
|
1843
|
+
let textBoundsHeight = 36;
|
|
1844
|
+
if (node.sublabel) {
|
|
1845
|
+
applyFont(ctx, { size: sublabelFontSize, weight: 500, family: bodyFont });
|
|
1846
|
+
ctx.fillStyle = sublabelColor;
|
|
1847
|
+
ctx.fillText(node.sublabel, centerX, labelY + labelToSublabelGap);
|
|
1848
|
+
textBoundsY = bounds.y + bounds.height / 2 - 24;
|
|
1849
|
+
textBoundsHeight = 56;
|
|
1850
|
+
}
|
|
1851
|
+
if (node.sublabel2) {
|
|
1852
|
+
applyFont(ctx, { size: sublabel2FontSize, weight: 500, family: bodyFont });
|
|
1853
|
+
ctx.fillStyle = sublabel2Color;
|
|
1854
|
+
const sublabel2Y = node.sublabel ? labelY + labelToSublabelGap + sublabelToSublabel2Gap : labelY + labelToSublabelGap;
|
|
1855
|
+
ctx.fillText(node.sublabel2, centerX, sublabel2Y);
|
|
1856
|
+
textBoundsY = bounds.y + bounds.height / 2 - 30;
|
|
1857
|
+
textBoundsHeight = 72;
|
|
1858
|
+
}
|
|
1859
|
+
if (hasBadge && node.badgeText) {
|
|
1860
|
+
if (badgePosition === "inside-top") {
|
|
1861
|
+
const badgeCenterY = bounds.y + BADGE_PILL_HEIGHT / 2 + 8;
|
|
1862
|
+
renderBadgePill(
|
|
1863
|
+
ctx,
|
|
1864
|
+
centerX,
|
|
1865
|
+
badgeCenterY,
|
|
1866
|
+
node.badgeText,
|
|
1867
|
+
badgeColor,
|
|
1868
|
+
badgeBackground,
|
|
1869
|
+
monoFont
|
|
1870
|
+
);
|
|
1871
|
+
} else {
|
|
1872
|
+
const badgeCenterY = bounds.y - BADGE_PILL_HEIGHT / 2 - 4;
|
|
1873
|
+
renderBadgePill(
|
|
1874
|
+
ctx,
|
|
1875
|
+
centerX,
|
|
1876
|
+
badgeCenterY,
|
|
1877
|
+
node.badgeText,
|
|
1878
|
+
badgeColor,
|
|
1879
|
+
badgeBackground,
|
|
1880
|
+
monoFont
|
|
1881
|
+
);
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
ctx.restore();
|
|
1885
|
+
const effectiveBg = fillOpacity < 1 ? blendColorWithOpacity(fillColor, theme.background, fillOpacity) : fillColor;
|
|
1886
|
+
return [
|
|
1887
|
+
{
|
|
1888
|
+
id: `flow-node-${node.id}`,
|
|
1889
|
+
kind: "flow-node",
|
|
1890
|
+
bounds,
|
|
1891
|
+
foregroundColor: labelColor,
|
|
1892
|
+
backgroundColor: effectiveBg
|
|
1893
|
+
},
|
|
1894
|
+
{
|
|
1895
|
+
id: `flow-node-${node.id}-label`,
|
|
1896
|
+
kind: "text",
|
|
1897
|
+
bounds: {
|
|
1898
|
+
x: bounds.x + 8,
|
|
1899
|
+
y: textBoundsY,
|
|
1900
|
+
width: bounds.width - 16,
|
|
1901
|
+
height: textBoundsHeight
|
|
1902
|
+
},
|
|
1903
|
+
foregroundColor: labelColor,
|
|
1904
|
+
backgroundColor: effectiveBg
|
|
1905
|
+
}
|
|
1906
|
+
];
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
// src/layout/estimates.ts
|
|
1910
|
+
function estimateElementHeight(element) {
|
|
1911
|
+
switch (element.type) {
|
|
1912
|
+
case "card":
|
|
1913
|
+
return 220;
|
|
1914
|
+
case "flow-node":
|
|
1915
|
+
return element.shape === "circle" || element.shape === "diamond" ? 160 : 130;
|
|
1916
|
+
case "code-block":
|
|
1917
|
+
return 260;
|
|
1918
|
+
case "terminal":
|
|
1919
|
+
return 245;
|
|
1920
|
+
case "text":
|
|
1921
|
+
return element.style === "heading" ? 140 : element.style === "subheading" ? 110 : 90;
|
|
1922
|
+
case "shape":
|
|
1923
|
+
return 130;
|
|
1924
|
+
case "image":
|
|
1925
|
+
return 220;
|
|
1926
|
+
case "connection":
|
|
1927
|
+
return 0;
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
function estimateElementWidth(element) {
|
|
1931
|
+
switch (element.type) {
|
|
1932
|
+
case "card":
|
|
1933
|
+
return 320;
|
|
1934
|
+
case "flow-node":
|
|
1935
|
+
return element.shape === "circle" || element.shape === "diamond" ? 160 : 220;
|
|
1936
|
+
case "code-block":
|
|
1937
|
+
return 420;
|
|
1938
|
+
case "terminal":
|
|
1939
|
+
return 420;
|
|
1940
|
+
case "text":
|
|
1941
|
+
return 360;
|
|
1942
|
+
case "shape":
|
|
1943
|
+
return 280;
|
|
1944
|
+
case "image":
|
|
1945
|
+
return 320;
|
|
1946
|
+
case "connection":
|
|
1947
|
+
return 0;
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
// src/layout/stack.ts
|
|
1952
|
+
function computeStackLayout(elements, config, safeFrame) {
|
|
1953
|
+
const placeable = elements.filter((element) => element.type !== "connection");
|
|
1954
|
+
const positions = /* @__PURE__ */ new Map();
|
|
1955
|
+
if (placeable.length === 0) {
|
|
1956
|
+
return { positions };
|
|
1957
|
+
}
|
|
1958
|
+
const gap = config.gap;
|
|
1959
|
+
if (config.direction === "vertical") {
|
|
1960
|
+
const estimatedHeights = placeable.map((element) => estimateElementHeight(element));
|
|
1961
|
+
const totalEstimated2 = estimatedHeights.reduce((sum, value) => sum + value, 0);
|
|
1962
|
+
const available2 = Math.max(0, safeFrame.height - gap * (placeable.length - 1));
|
|
1963
|
+
const scale2 = totalEstimated2 > 0 ? Math.min(1, available2 / totalEstimated2) : 1;
|
|
1964
|
+
let y = safeFrame.y;
|
|
1965
|
+
for (const [index, element] of placeable.entries()) {
|
|
1966
|
+
const stretched = config.alignment === "stretch";
|
|
1967
|
+
const width = stretched ? safeFrame.width : Math.min(safeFrame.width, Math.floor(estimateElementWidth(element)));
|
|
1968
|
+
const height = Math.max(48, Math.floor(estimatedHeights[index] * scale2));
|
|
1969
|
+
let x2 = safeFrame.x;
|
|
1970
|
+
if (!stretched) {
|
|
1971
|
+
if (config.alignment === "center") {
|
|
1972
|
+
x2 = safeFrame.x + Math.floor((safeFrame.width - width) / 2);
|
|
1973
|
+
} else if (config.alignment === "end") {
|
|
1974
|
+
x2 = safeFrame.x + safeFrame.width - width;
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
positions.set(element.id, { x: x2, y, width, height });
|
|
1978
|
+
y += height + gap;
|
|
1979
|
+
}
|
|
1980
|
+
return { positions };
|
|
1981
|
+
}
|
|
1982
|
+
const estimatedWidths = placeable.map((element) => estimateElementWidth(element));
|
|
1983
|
+
const totalEstimated = estimatedWidths.reduce((sum, value) => sum + value, 0);
|
|
1984
|
+
const available = Math.max(0, safeFrame.width - gap * (placeable.length - 1));
|
|
1985
|
+
const scale = totalEstimated > 0 ? Math.min(1, available / totalEstimated) : 1;
|
|
1986
|
+
let x = safeFrame.x;
|
|
1987
|
+
for (const [index, element] of placeable.entries()) {
|
|
1988
|
+
const stretched = config.alignment === "stretch";
|
|
1989
|
+
const height = stretched ? safeFrame.height : Math.min(safeFrame.height, Math.floor(estimateElementHeight(element)));
|
|
1268
1990
|
const width = Math.max(64, Math.floor(estimatedWidths[index] * scale));
|
|
1269
1991
|
let y = safeFrame.y;
|
|
1270
1992
|
if (!stretched) {
|
|
@@ -1282,33 +2004,37 @@ function computeStackLayout(elements, config, safeFrame) {
|
|
|
1282
2004
|
|
|
1283
2005
|
// src/layout/elk.ts
|
|
1284
2006
|
function estimateFlowNodeSize(node) {
|
|
2007
|
+
const badgeExtra = node.badgeText && (node.badgePosition ?? "inside-top") === "inside-top" ? BADGE_INSIDE_TOP_EXTRA : 0;
|
|
2008
|
+
const sublabel2Extra = node.sublabel2 ? (node.sublabel2FontSize ?? 11) + 4 : 0;
|
|
2009
|
+
const extra = badgeExtra + sublabel2Extra;
|
|
1285
2010
|
if (node.width && node.height) {
|
|
1286
|
-
return { width: node.width, height: node.height };
|
|
2011
|
+
return { width: node.width, height: node.height + extra };
|
|
1287
2012
|
}
|
|
1288
2013
|
if (node.width) {
|
|
2014
|
+
const baseHeight = node.shape === "diamond" || node.shape === "circle" ? node.width : 60;
|
|
1289
2015
|
return {
|
|
1290
2016
|
width: node.width,
|
|
1291
|
-
height:
|
|
2017
|
+
height: baseHeight + extra
|
|
1292
2018
|
};
|
|
1293
2019
|
}
|
|
1294
2020
|
if (node.height) {
|
|
1295
2021
|
return {
|
|
1296
2022
|
width: node.shape === "diamond" || node.shape === "circle" ? node.height : 160,
|
|
1297
|
-
height: node.height
|
|
2023
|
+
height: node.height + extra
|
|
1298
2024
|
};
|
|
1299
2025
|
}
|
|
1300
2026
|
switch (node.shape) {
|
|
1301
2027
|
case "diamond":
|
|
1302
2028
|
case "circle":
|
|
1303
|
-
return { width: 100, height: 100 };
|
|
2029
|
+
return { width: 100 + extra, height: 100 + extra };
|
|
1304
2030
|
case "pill":
|
|
1305
|
-
return { width: 180, height: 56 };
|
|
2031
|
+
return { width: 180, height: 56 + extra };
|
|
1306
2032
|
case "cylinder":
|
|
1307
|
-
return { width: 140, height: 92 };
|
|
2033
|
+
return { width: 140, height: 92 + extra };
|
|
1308
2034
|
case "parallelogram":
|
|
1309
|
-
return { width: 180, height: 72 };
|
|
2035
|
+
return { width: 180, height: 72 + extra };
|
|
1310
2036
|
default:
|
|
1311
|
-
return { width: 170, height: 64 };
|
|
2037
|
+
return { width: 170, height: 64 + extra };
|
|
1312
2038
|
}
|
|
1313
2039
|
}
|
|
1314
2040
|
function splitLayoutFrames(safeFrame, direction, hasAuxiliary) {
|
|
@@ -1426,6 +2152,40 @@ function directionToElk(direction) {
|
|
|
1426
2152
|
return "DOWN";
|
|
1427
2153
|
}
|
|
1428
2154
|
}
|
|
2155
|
+
function radialCompactionToElk(compaction) {
|
|
2156
|
+
switch (compaction) {
|
|
2157
|
+
case "radial":
|
|
2158
|
+
return "RADIAL_COMPACTION";
|
|
2159
|
+
case "wedge":
|
|
2160
|
+
return "WEDGE_COMPACTION";
|
|
2161
|
+
default:
|
|
2162
|
+
return "NONE";
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
function radialSortByToElk(sortBy) {
|
|
2166
|
+
switch (sortBy) {
|
|
2167
|
+
case "connections":
|
|
2168
|
+
return "POLAR_COORDINATE";
|
|
2169
|
+
default:
|
|
2170
|
+
return "ID";
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
function buildRadialOptions(config) {
|
|
2174
|
+
const options = {};
|
|
2175
|
+
if (config.radialRoot) {
|
|
2176
|
+
options["elk.radial.centerOnRoot"] = "true";
|
|
2177
|
+
}
|
|
2178
|
+
if (config.radialRadius != null) {
|
|
2179
|
+
options["elk.radial.radius"] = String(config.radialRadius);
|
|
2180
|
+
}
|
|
2181
|
+
if (config.radialCompaction) {
|
|
2182
|
+
options["elk.radial.compaction.strategy"] = radialCompactionToElk(config.radialCompaction);
|
|
2183
|
+
}
|
|
2184
|
+
if (config.radialSortBy) {
|
|
2185
|
+
options["elk.radial.orderId"] = radialSortByToElk(config.radialSortBy);
|
|
2186
|
+
}
|
|
2187
|
+
return options;
|
|
2188
|
+
}
|
|
1429
2189
|
function fallbackForNoFlowNodes(nonFlow, safeFrame) {
|
|
1430
2190
|
const fallbackConfig = {
|
|
1431
2191
|
mode: "stack",
|
|
@@ -1461,6 +2221,11 @@ async function computeElkLayout(elements, config, safeFrame) {
|
|
|
1461
2221
|
elkNodeSizes.set(node.id, estimateFlowNodeSize(node));
|
|
1462
2222
|
}
|
|
1463
2223
|
const edgeIdToRouteKey = /* @__PURE__ */ new Map();
|
|
2224
|
+
const radialOptions = config.algorithm === "radial" ? buildRadialOptions(config) : {};
|
|
2225
|
+
const orderedFlowNodes = config.radialRoot && config.algorithm === "radial" ? [
|
|
2226
|
+
...flowNodes.filter((node) => node.id === config.radialRoot),
|
|
2227
|
+
...flowNodes.filter((node) => node.id !== config.radialRoot)
|
|
2228
|
+
] : flowNodes;
|
|
1464
2229
|
const elkGraph = {
|
|
1465
2230
|
id: "root",
|
|
1466
2231
|
layoutOptions: {
|
|
@@ -1470,9 +2235,10 @@ async function computeElkLayout(elements, config, safeFrame) {
|
|
|
1470
2235
|
"elk.layered.spacing.nodeNodeBetweenLayers": String(config.rankSpacing),
|
|
1471
2236
|
"elk.edgeRouting": edgeRoutingToElk(config.edgeRouting),
|
|
1472
2237
|
...config.aspectRatio ? { "elk.aspectRatio": String(config.aspectRatio) } : {},
|
|
1473
|
-
...config.algorithm === "stress" ? { "elk.stress.desiredEdgeLength": String(config.rankSpacing + config.nodeSpacing) } : {}
|
|
2238
|
+
...config.algorithm === "stress" ? { "elk.stress.desiredEdgeLength": String(config.rankSpacing + config.nodeSpacing) } : {},
|
|
2239
|
+
...radialOptions
|
|
1474
2240
|
},
|
|
1475
|
-
children:
|
|
2241
|
+
children: orderedFlowNodes.map((node) => {
|
|
1476
2242
|
const size = elkNodeSizes.get(node.id) ?? { width: 160, height: 60 };
|
|
1477
2243
|
return {
|
|
1478
2244
|
id: node.id,
|
|
@@ -1712,259 +2478,77 @@ function parseHexColor2(color) {
|
|
|
1712
2478
|
throw new Error(`Expected #RRGGBB or #RRGGBBAA color, received ${color}`);
|
|
1713
2479
|
}
|
|
1714
2480
|
const parseChannel2 = (offset) => Number.parseInt(normalized.slice(offset, offset + 2), 16);
|
|
1715
|
-
return {
|
|
1716
|
-
r: parseChannel2(0),
|
|
1717
|
-
g: parseChannel2(2),
|
|
1718
|
-
b: parseChannel2(4),
|
|
1719
|
-
a: normalized.length === 8 ? parseChannel2(6) / 255 : 1
|
|
1720
|
-
};
|
|
1721
|
-
}
|
|
1722
|
-
function withAlpha(color, alpha) {
|
|
1723
|
-
const parsed = parseHexColor2(color);
|
|
1724
|
-
const effectiveAlpha = clamp01(parsed.a * alpha);
|
|
1725
|
-
return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${effectiveAlpha})`;
|
|
1726
|
-
}
|
|
1727
|
-
function drawGradientRect(ctx, rect, gradient, borderRadius = 0) {
|
|
1728
|
-
const fill = gradient.type === "linear" ? createLinearRectGradient(ctx, rect, gradient.angle ?? 180) : ctx.createRadialGradient(
|
|
1729
|
-
rect.x + rect.width / 2,
|
|
1730
|
-
rect.y + rect.height / 2,
|
|
1731
|
-
0,
|
|
1732
|
-
rect.x + rect.width / 2,
|
|
1733
|
-
rect.y + rect.height / 2,
|
|
1734
|
-
Math.max(rect.width, rect.height) / 2
|
|
1735
|
-
);
|
|
1736
|
-
addGradientStops(fill, gradient.stops);
|
|
1737
|
-
ctx.save();
|
|
1738
|
-
ctx.fillStyle = fill;
|
|
1739
|
-
if (borderRadius > 0) {
|
|
1740
|
-
roundedRectPath(ctx, rect.x, rect.y, rect.width, rect.height, borderRadius);
|
|
1741
|
-
ctx.fill();
|
|
1742
|
-
} else {
|
|
1743
|
-
ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
1744
|
-
}
|
|
1745
|
-
ctx.restore();
|
|
1746
|
-
}
|
|
1747
|
-
function drawRainbowRule(ctx, x, y, width, thickness = 2, colors = [...DEFAULT_RAINBOW_COLORS], borderRadius = thickness / 2) {
|
|
1748
|
-
if (width <= 0 || thickness <= 0) {
|
|
1749
|
-
return;
|
|
1750
|
-
}
|
|
1751
|
-
const gradient = ctx.createLinearGradient(x, y, x + width, y);
|
|
1752
|
-
const stops = colors.length >= 2 ? colors : [...DEFAULT_RAINBOW_COLORS];
|
|
1753
|
-
for (const [index, color] of stops.entries()) {
|
|
1754
|
-
gradient.addColorStop(index / (stops.length - 1), color);
|
|
1755
|
-
}
|
|
1756
|
-
const ruleTop = y - thickness / 2;
|
|
1757
|
-
ctx.save();
|
|
1758
|
-
roundedRectPath(ctx, x, ruleTop, width, thickness, borderRadius);
|
|
1759
|
-
ctx.fillStyle = gradient;
|
|
1760
|
-
ctx.fill();
|
|
1761
|
-
ctx.restore();
|
|
1762
|
-
}
|
|
1763
|
-
function drawVignette(ctx, width, height, intensity = 0.3, color = "#000000") {
|
|
1764
|
-
if (width <= 0 || height <= 0 || intensity <= 0) {
|
|
1765
|
-
return;
|
|
1766
|
-
}
|
|
1767
|
-
const centerX = width / 2;
|
|
1768
|
-
const centerY = height / 2;
|
|
1769
|
-
const outerRadius = Math.max(width, height) / 2;
|
|
1770
|
-
const innerRadius = Math.min(width, height) * 0.2;
|
|
1771
|
-
const vignette = ctx.createRadialGradient(
|
|
1772
|
-
centerX,
|
|
1773
|
-
centerY,
|
|
1774
|
-
innerRadius,
|
|
1775
|
-
centerX,
|
|
1776
|
-
centerY,
|
|
1777
|
-
outerRadius
|
|
1778
|
-
);
|
|
1779
|
-
vignette.addColorStop(0, withAlpha(color, 0));
|
|
1780
|
-
vignette.addColorStop(0.6, withAlpha(color, 0));
|
|
1781
|
-
vignette.addColorStop(1, withAlpha(color, clamp01(intensity)));
|
|
1782
|
-
ctx.save();
|
|
1783
|
-
ctx.fillStyle = vignette;
|
|
1784
|
-
ctx.fillRect(0, 0, width, height);
|
|
1785
|
-
ctx.restore();
|
|
1786
|
-
}
|
|
1787
|
-
|
|
1788
|
-
// src/primitives/shapes.ts
|
|
1789
|
-
function roundRectPath(ctx, rect, radius) {
|
|
1790
|
-
const r = Math.max(0, Math.min(radius, rect.width / 2, rect.height / 2));
|
|
1791
|
-
const right = rect.x + rect.width;
|
|
1792
|
-
const bottom = rect.y + rect.height;
|
|
1793
|
-
ctx.beginPath();
|
|
1794
|
-
ctx.moveTo(rect.x + r, rect.y);
|
|
1795
|
-
ctx.lineTo(right - r, rect.y);
|
|
1796
|
-
ctx.quadraticCurveTo(right, rect.y, right, rect.y + r);
|
|
1797
|
-
ctx.lineTo(right, bottom - r);
|
|
1798
|
-
ctx.quadraticCurveTo(right, bottom, right - r, bottom);
|
|
1799
|
-
ctx.lineTo(rect.x + r, bottom);
|
|
1800
|
-
ctx.quadraticCurveTo(rect.x, bottom, rect.x, bottom - r);
|
|
1801
|
-
ctx.lineTo(rect.x, rect.y + r);
|
|
1802
|
-
ctx.quadraticCurveTo(rect.x, rect.y, rect.x + r, rect.y);
|
|
1803
|
-
ctx.closePath();
|
|
1804
|
-
}
|
|
1805
|
-
function fillAndStroke(ctx, fill, stroke) {
|
|
1806
|
-
ctx.fillStyle = fill;
|
|
1807
|
-
ctx.fill();
|
|
1808
|
-
if (stroke) {
|
|
1809
|
-
ctx.strokeStyle = stroke;
|
|
1810
|
-
ctx.stroke();
|
|
1811
|
-
}
|
|
1812
|
-
}
|
|
1813
|
-
function drawRoundedRect(ctx, rect, radius, fill, stroke) {
|
|
1814
|
-
roundRectPath(ctx, rect, radius);
|
|
1815
|
-
fillAndStroke(ctx, fill, stroke);
|
|
1816
|
-
}
|
|
1817
|
-
function drawCircle(ctx, center2, radius, fill, stroke) {
|
|
1818
|
-
ctx.beginPath();
|
|
1819
|
-
ctx.arc(center2.x, center2.y, Math.max(0, radius), 0, Math.PI * 2);
|
|
1820
|
-
ctx.closePath();
|
|
1821
|
-
fillAndStroke(ctx, fill, stroke);
|
|
1822
|
-
}
|
|
1823
|
-
function drawDiamond(ctx, bounds, fill, stroke) {
|
|
1824
|
-
const cx = bounds.x + bounds.width / 2;
|
|
1825
|
-
const cy = bounds.y + bounds.height / 2;
|
|
1826
|
-
ctx.beginPath();
|
|
1827
|
-
ctx.moveTo(cx, bounds.y);
|
|
1828
|
-
ctx.lineTo(bounds.x + bounds.width, cy);
|
|
1829
|
-
ctx.lineTo(cx, bounds.y + bounds.height);
|
|
1830
|
-
ctx.lineTo(bounds.x, cy);
|
|
1831
|
-
ctx.closePath();
|
|
1832
|
-
fillAndStroke(ctx, fill, stroke);
|
|
1833
|
-
}
|
|
1834
|
-
function drawPill(ctx, bounds, fill, stroke) {
|
|
1835
|
-
drawRoundedRect(ctx, bounds, Math.min(bounds.width, bounds.height) / 2, fill, stroke);
|
|
1836
|
-
}
|
|
1837
|
-
function drawEllipse(ctx, bounds, fill, stroke) {
|
|
1838
|
-
const cx = bounds.x + bounds.width / 2;
|
|
1839
|
-
const cy = bounds.y + bounds.height / 2;
|
|
1840
|
-
ctx.beginPath();
|
|
1841
|
-
ctx.ellipse(
|
|
1842
|
-
cx,
|
|
1843
|
-
cy,
|
|
1844
|
-
Math.max(0, bounds.width / 2),
|
|
1845
|
-
Math.max(0, bounds.height / 2),
|
|
1846
|
-
0,
|
|
1847
|
-
0,
|
|
1848
|
-
Math.PI * 2
|
|
1849
|
-
);
|
|
1850
|
-
ctx.closePath();
|
|
1851
|
-
fillAndStroke(ctx, fill, stroke);
|
|
1852
|
-
}
|
|
1853
|
-
function drawCylinder(ctx, bounds, fill, stroke) {
|
|
1854
|
-
const rx = Math.max(2, bounds.width / 2);
|
|
1855
|
-
const ry = Math.max(2, Math.min(bounds.height * 0.18, 16));
|
|
1856
|
-
const cx = bounds.x + bounds.width / 2;
|
|
1857
|
-
const topCy = bounds.y + ry;
|
|
1858
|
-
const bottomCy = bounds.y + bounds.height - ry;
|
|
1859
|
-
ctx.beginPath();
|
|
1860
|
-
ctx.moveTo(bounds.x, topCy);
|
|
1861
|
-
ctx.ellipse(cx, topCy, rx, ry, 0, Math.PI, 0, true);
|
|
1862
|
-
ctx.lineTo(bounds.x + bounds.width, bottomCy);
|
|
1863
|
-
ctx.ellipse(cx, bottomCy, rx, ry, 0, 0, Math.PI, false);
|
|
1864
|
-
ctx.closePath();
|
|
1865
|
-
fillAndStroke(ctx, fill, stroke);
|
|
1866
|
-
if (stroke) {
|
|
1867
|
-
ctx.beginPath();
|
|
1868
|
-
ctx.ellipse(cx, topCy, rx, ry, 0, 0, Math.PI * 2);
|
|
1869
|
-
ctx.closePath();
|
|
1870
|
-
ctx.strokeStyle = stroke;
|
|
1871
|
-
ctx.stroke();
|
|
1872
|
-
}
|
|
1873
|
-
}
|
|
1874
|
-
function drawParallelogram(ctx, bounds, fill, stroke, skew) {
|
|
1875
|
-
const maxSkew = bounds.width * 0.45;
|
|
1876
|
-
const skewX = Math.max(-maxSkew, Math.min(maxSkew, skew ?? bounds.width * 0.18));
|
|
1877
|
-
ctx.beginPath();
|
|
1878
|
-
ctx.moveTo(bounds.x + skewX, bounds.y);
|
|
1879
|
-
ctx.lineTo(bounds.x + bounds.width, bounds.y);
|
|
1880
|
-
ctx.lineTo(bounds.x + bounds.width - skewX, bounds.y + bounds.height);
|
|
1881
|
-
ctx.lineTo(bounds.x, bounds.y + bounds.height);
|
|
1882
|
-
ctx.closePath();
|
|
1883
|
-
fillAndStroke(ctx, fill, stroke);
|
|
1884
|
-
}
|
|
1885
|
-
|
|
1886
|
-
// src/primitives/text.ts
|
|
1887
|
-
var SUPPORTED_FONT_FAMILIES = /* @__PURE__ */ new Set(["Inter", "JetBrains Mono", "Space Grotesk"]);
|
|
1888
|
-
function resolveFont(requested, role) {
|
|
1889
|
-
if (SUPPORTED_FONT_FAMILIES.has(requested)) {
|
|
1890
|
-
return requested;
|
|
1891
|
-
}
|
|
1892
|
-
if (role === "mono" || /mono|code|terminal|console/iu.test(requested)) {
|
|
1893
|
-
return "JetBrains Mono";
|
|
1894
|
-
}
|
|
1895
|
-
if (role === "heading" || /display|grotesk|headline/iu.test(requested)) {
|
|
1896
|
-
return "Space Grotesk";
|
|
1897
|
-
}
|
|
1898
|
-
return "Inter";
|
|
2481
|
+
return {
|
|
2482
|
+
r: parseChannel2(0),
|
|
2483
|
+
g: parseChannel2(2),
|
|
2484
|
+
b: parseChannel2(4),
|
|
2485
|
+
a: normalized.length === 8 ? parseChannel2(6) / 255 : 1
|
|
2486
|
+
};
|
|
1899
2487
|
}
|
|
1900
|
-
function
|
|
1901
|
-
|
|
2488
|
+
function withAlpha2(color, alpha) {
|
|
2489
|
+
const parsed = parseHexColor2(color);
|
|
2490
|
+
const effectiveAlpha = clamp01(parsed.a * alpha);
|
|
2491
|
+
return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${effectiveAlpha})`;
|
|
1902
2492
|
}
|
|
1903
|
-
function
|
|
1904
|
-
const
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
} else {
|
|
1921
|
-
lines.push(word);
|
|
1922
|
-
current = "";
|
|
1923
|
-
}
|
|
1924
|
-
if (lines.length >= maxLines) {
|
|
1925
|
-
break;
|
|
1926
|
-
}
|
|
1927
|
-
}
|
|
1928
|
-
if (lines.length < maxLines && current.length > 0) {
|
|
1929
|
-
lines.push(current);
|
|
2493
|
+
function drawGradientRect(ctx, rect, gradient, borderRadius = 0) {
|
|
2494
|
+
const fill = gradient.type === "linear" ? createLinearRectGradient(ctx, rect, gradient.angle ?? 180) : ctx.createRadialGradient(
|
|
2495
|
+
rect.x + rect.width / 2,
|
|
2496
|
+
rect.y + rect.height / 2,
|
|
2497
|
+
0,
|
|
2498
|
+
rect.x + rect.width / 2,
|
|
2499
|
+
rect.y + rect.height / 2,
|
|
2500
|
+
Math.max(rect.width, rect.height) / 2
|
|
2501
|
+
);
|
|
2502
|
+
addGradientStops(fill, gradient.stops);
|
|
2503
|
+
ctx.save();
|
|
2504
|
+
ctx.fillStyle = fill;
|
|
2505
|
+
if (borderRadius > 0) {
|
|
2506
|
+
roundedRectPath(ctx, rect.x, rect.y, rect.width, rect.height, borderRadius);
|
|
2507
|
+
ctx.fill();
|
|
2508
|
+
} else {
|
|
2509
|
+
ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
1930
2510
|
}
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
2511
|
+
ctx.restore();
|
|
2512
|
+
}
|
|
2513
|
+
function drawRainbowRule(ctx, x, y, width, thickness = 2, colors = [...DEFAULT_RAINBOW_COLORS], borderRadius = thickness / 2) {
|
|
2514
|
+
if (width <= 0 || thickness <= 0) {
|
|
2515
|
+
return;
|
|
1934
2516
|
}
|
|
1935
|
-
const
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
2517
|
+
const gradient = ctx.createLinearGradient(x, y, x + width, y);
|
|
2518
|
+
const stops = colors.length >= 2 ? colors : [...DEFAULT_RAINBOW_COLORS];
|
|
2519
|
+
for (const [index, color] of stops.entries()) {
|
|
2520
|
+
gradient.addColorStop(index / (stops.length - 1), color);
|
|
1939
2521
|
}
|
|
1940
|
-
|
|
1941
|
-
|
|
2522
|
+
const ruleTop = y - thickness / 2;
|
|
2523
|
+
ctx.save();
|
|
2524
|
+
roundedRectPath(ctx, x, ruleTop, width, thickness, borderRadius);
|
|
2525
|
+
ctx.fillStyle = gradient;
|
|
2526
|
+
ctx.fill();
|
|
2527
|
+
ctx.restore();
|
|
1942
2528
|
}
|
|
1943
|
-
function
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
ctx.fillStyle = options.color;
|
|
1947
|
-
for (const [index, line] of wrapped.lines.entries()) {
|
|
1948
|
-
ctx.fillText(line, options.x, options.y + index * options.lineHeight);
|
|
2529
|
+
function drawVignette(ctx, width, height, intensity = 0.3, color = "#000000") {
|
|
2530
|
+
if (width <= 0 || height <= 0 || intensity <= 0) {
|
|
2531
|
+
return;
|
|
1949
2532
|
}
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
ctx.
|
|
1966
|
-
ctx.
|
|
1967
|
-
|
|
2533
|
+
const centerX = width / 2;
|
|
2534
|
+
const centerY = height / 2;
|
|
2535
|
+
const outerRadius = Math.max(width, height) / 2;
|
|
2536
|
+
const innerRadius = Math.min(width, height) * 0.2;
|
|
2537
|
+
const vignette = ctx.createRadialGradient(
|
|
2538
|
+
centerX,
|
|
2539
|
+
centerY,
|
|
2540
|
+
innerRadius,
|
|
2541
|
+
centerX,
|
|
2542
|
+
centerY,
|
|
2543
|
+
outerRadius
|
|
2544
|
+
);
|
|
2545
|
+
vignette.addColorStop(0, withAlpha2(color, 0));
|
|
2546
|
+
vignette.addColorStop(0.6, withAlpha2(color, 0));
|
|
2547
|
+
vignette.addColorStop(1, withAlpha2(color, clamp01(intensity)));
|
|
2548
|
+
ctx.save();
|
|
2549
|
+
ctx.fillStyle = vignette;
|
|
2550
|
+
ctx.fillRect(0, 0, width, height);
|
|
2551
|
+
ctx.restore();
|
|
1968
2552
|
}
|
|
1969
2553
|
|
|
1970
2554
|
// src/renderers/card.ts
|
|
@@ -2091,12 +2675,12 @@ var MACOS_DOTS = [
|
|
|
2091
2675
|
{ fill: "#27C93F", stroke: "#1AAB29" }
|
|
2092
2676
|
];
|
|
2093
2677
|
function drawMacosDots(ctx, x, y) {
|
|
2094
|
-
for (const [index,
|
|
2678
|
+
for (const [index, dot2] of MACOS_DOTS.entries()) {
|
|
2095
2679
|
ctx.beginPath();
|
|
2096
2680
|
ctx.arc(x + index * DOT_SPACING, y, DOT_RADIUS, 0, Math.PI * 2);
|
|
2097
2681
|
ctx.closePath();
|
|
2098
|
-
ctx.fillStyle =
|
|
2099
|
-
ctx.strokeStyle =
|
|
2682
|
+
ctx.fillStyle = dot2.fill;
|
|
2683
|
+
ctx.strokeStyle = dot2.stroke;
|
|
2100
2684
|
ctx.lineWidth = DOT_STROKE_WIDTH;
|
|
2101
2685
|
ctx.fill();
|
|
2102
2686
|
ctx.stroke();
|
|
@@ -2517,25 +3101,134 @@ function drawOrthogonalPath(ctx, from, to, style) {
|
|
|
2517
3101
|
}
|
|
2518
3102
|
|
|
2519
3103
|
// src/renderers/connection.ts
|
|
2520
|
-
|
|
3104
|
+
var ELLIPSE_KAPPA = 4 * (Math.sqrt(2) - 1) / 3;
|
|
3105
|
+
function rectCenter(rect) {
|
|
2521
3106
|
return {
|
|
2522
3107
|
x: rect.x + rect.width / 2,
|
|
2523
3108
|
y: rect.y + rect.height / 2
|
|
2524
3109
|
};
|
|
2525
3110
|
}
|
|
2526
|
-
function edgeAnchor(
|
|
2527
|
-
const c =
|
|
3111
|
+
function edgeAnchor(bounds, target) {
|
|
3112
|
+
const c = rectCenter(bounds);
|
|
2528
3113
|
const dx = target.x - c.x;
|
|
2529
3114
|
const dy = target.y - c.y;
|
|
2530
|
-
if (
|
|
2531
|
-
return {
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
3115
|
+
if (dx === 0 && dy === 0) {
|
|
3116
|
+
return { x: c.x, y: c.y - bounds.height / 2 };
|
|
3117
|
+
}
|
|
3118
|
+
const hw = bounds.width / 2;
|
|
3119
|
+
const hh = bounds.height / 2;
|
|
3120
|
+
const absDx = Math.abs(dx);
|
|
3121
|
+
const absDy = Math.abs(dy);
|
|
3122
|
+
const t = absDx * hh > absDy * hw ? hw / absDx : hh / absDy;
|
|
3123
|
+
return { x: c.x + dx * t, y: c.y + dy * t };
|
|
3124
|
+
}
|
|
3125
|
+
function outwardNormal(point, diagramCenter) {
|
|
3126
|
+
const dx = point.x - diagramCenter.x;
|
|
3127
|
+
const dy = point.y - diagramCenter.y;
|
|
3128
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
3129
|
+
return { x: dx / len, y: dy / len };
|
|
3130
|
+
}
|
|
3131
|
+
function curveRoute(fromBounds, toBounds, diagramCenter, tension) {
|
|
3132
|
+
const fromCenter = rectCenter(fromBounds);
|
|
3133
|
+
const toCenter = rectCenter(toBounds);
|
|
3134
|
+
const p0 = edgeAnchor(fromBounds, toCenter);
|
|
3135
|
+
const p3 = edgeAnchor(toBounds, fromCenter);
|
|
3136
|
+
const dist = Math.hypot(p3.x - p0.x, p3.y - p0.y);
|
|
3137
|
+
const offset = dist * tension;
|
|
3138
|
+
const n0 = outwardNormal(p0, diagramCenter);
|
|
3139
|
+
const n3 = outwardNormal(p3, diagramCenter);
|
|
3140
|
+
const cp1 = { x: p0.x + n0.x * offset, y: p0.y + n0.y * offset };
|
|
3141
|
+
const cp2 = { x: p3.x + n3.x * offset, y: p3.y + n3.y * offset };
|
|
3142
|
+
return [p0, cp1, cp2, p3];
|
|
3143
|
+
}
|
|
3144
|
+
function dot(a, b) {
|
|
3145
|
+
return a.x * b.x + a.y * b.y;
|
|
3146
|
+
}
|
|
3147
|
+
function localToWorld(origin, axisX, axisY, local) {
|
|
3148
|
+
return {
|
|
3149
|
+
x: origin.x + axisX.x * local.x + axisY.x * local.y,
|
|
3150
|
+
y: origin.y + axisX.y * local.x + axisY.y * local.y
|
|
3151
|
+
};
|
|
3152
|
+
}
|
|
3153
|
+
function arcRoute(fromBounds, toBounds, diagramCenter, tension) {
|
|
3154
|
+
const fromCenter = rectCenter(fromBounds);
|
|
3155
|
+
const toCenter = rectCenter(toBounds);
|
|
3156
|
+
const start = edgeAnchor(fromBounds, toCenter);
|
|
3157
|
+
const end = edgeAnchor(toBounds, fromCenter);
|
|
3158
|
+
const chord = { x: end.x - start.x, y: end.y - start.y };
|
|
3159
|
+
const chordLength = Math.hypot(chord.x, chord.y);
|
|
3160
|
+
if (chordLength < 1e-6) {
|
|
3161
|
+
const mid = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
|
|
3162
|
+
return [
|
|
3163
|
+
[start, start, mid, mid],
|
|
3164
|
+
[mid, mid, end, end]
|
|
3165
|
+
];
|
|
3166
|
+
}
|
|
3167
|
+
const axisX = { x: chord.x / chordLength, y: chord.y / chordLength };
|
|
3168
|
+
let axisY = { x: -axisX.y, y: axisX.x };
|
|
3169
|
+
const midpoint = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
|
|
3170
|
+
const outwardHint = outwardNormal(midpoint, diagramCenter);
|
|
3171
|
+
if (dot(axisY, outwardHint) < 0) {
|
|
3172
|
+
axisY = { x: -axisY.x, y: -axisY.y };
|
|
3173
|
+
}
|
|
3174
|
+
const semiMajor = chordLength / 2;
|
|
3175
|
+
const semiMinor = Math.max(12, chordLength * tension * 0.75);
|
|
3176
|
+
const p0Local = { x: -semiMajor, y: 0 };
|
|
3177
|
+
const cp1Local = { x: -semiMajor, y: ELLIPSE_KAPPA * semiMinor };
|
|
3178
|
+
const cp2Local = { x: -ELLIPSE_KAPPA * semiMajor, y: semiMinor };
|
|
3179
|
+
const pMidLocal = { x: 0, y: semiMinor };
|
|
3180
|
+
const cp3Local = { x: ELLIPSE_KAPPA * semiMajor, y: semiMinor };
|
|
3181
|
+
const cp4Local = { x: semiMajor, y: ELLIPSE_KAPPA * semiMinor };
|
|
3182
|
+
const p3Local = { x: semiMajor, y: 0 };
|
|
3183
|
+
const p0 = localToWorld(midpoint, axisX, axisY, p0Local);
|
|
3184
|
+
const cp1 = localToWorld(midpoint, axisX, axisY, cp1Local);
|
|
3185
|
+
const cp2 = localToWorld(midpoint, axisX, axisY, cp2Local);
|
|
3186
|
+
const pMid = localToWorld(midpoint, axisX, axisY, pMidLocal);
|
|
3187
|
+
const cp3 = localToWorld(midpoint, axisX, axisY, cp3Local);
|
|
3188
|
+
const cp4 = localToWorld(midpoint, axisX, axisY, cp4Local);
|
|
3189
|
+
const p3 = localToWorld(midpoint, axisX, axisY, p3Local);
|
|
3190
|
+
return [
|
|
3191
|
+
[p0, cp1, cp2, pMid],
|
|
3192
|
+
[pMid, cp3, cp4, p3]
|
|
3193
|
+
];
|
|
3194
|
+
}
|
|
3195
|
+
function orthogonalRoute(fromBounds, toBounds) {
|
|
3196
|
+
const fromC = rectCenter(fromBounds);
|
|
3197
|
+
const toC = rectCenter(toBounds);
|
|
3198
|
+
const p0 = edgeAnchor(fromBounds, toC);
|
|
3199
|
+
const p3 = edgeAnchor(toBounds, fromC);
|
|
3200
|
+
const midX = (p0.x + p3.x) / 2;
|
|
3201
|
+
return [p0, { x: midX, y: p0.y }, { x: midX, y: p3.y }, p3];
|
|
3202
|
+
}
|
|
3203
|
+
function bezierPointAt(p0, cp1, cp2, p3, t) {
|
|
3204
|
+
const mt = 1 - t;
|
|
3205
|
+
return {
|
|
3206
|
+
x: mt * mt * mt * p0.x + 3 * mt * mt * t * cp1.x + 3 * mt * t * t * cp2.x + t * t * t * p3.x,
|
|
3207
|
+
y: mt * mt * mt * p0.y + 3 * mt * mt * t * cp1.y + 3 * mt * t * t * cp2.y + t * t * t * p3.y
|
|
3208
|
+
};
|
|
3209
|
+
}
|
|
3210
|
+
function pointAlongArc(route, t) {
|
|
3211
|
+
const [first, second] = route;
|
|
3212
|
+
if (t <= 0.5) {
|
|
3213
|
+
const localT2 = Math.max(0, Math.min(1, t * 2));
|
|
3214
|
+
return bezierPointAt(first[0], first[1], first[2], first[3], localT2);
|
|
3215
|
+
}
|
|
3216
|
+
const localT = Math.max(0, Math.min(1, (t - 0.5) * 2));
|
|
3217
|
+
return bezierPointAt(second[0], second[1], second[2], second[3], localT);
|
|
3218
|
+
}
|
|
3219
|
+
function computeDiagramCenter(nodeBounds, canvasCenter) {
|
|
3220
|
+
if (nodeBounds.length === 0) {
|
|
3221
|
+
return canvasCenter ?? { x: 0, y: 0 };
|
|
3222
|
+
}
|
|
3223
|
+
let totalX = 0;
|
|
3224
|
+
let totalY = 0;
|
|
3225
|
+
for (const bounds of nodeBounds) {
|
|
3226
|
+
totalX += bounds.x + bounds.width / 2;
|
|
3227
|
+
totalY += bounds.y + bounds.height / 2;
|
|
2535
3228
|
}
|
|
2536
3229
|
return {
|
|
2537
|
-
x:
|
|
2538
|
-
y:
|
|
3230
|
+
x: totalX / nodeBounds.length,
|
|
3231
|
+
y: totalY / nodeBounds.length
|
|
2539
3232
|
};
|
|
2540
3233
|
}
|
|
2541
3234
|
function dashFromStyle(style) {
|
|
@@ -2619,51 +3312,95 @@ function polylineBounds(points) {
|
|
|
2619
3312
|
height: Math.max(1, maxY - minY)
|
|
2620
3313
|
};
|
|
2621
3314
|
}
|
|
2622
|
-
function renderConnection(ctx, conn, fromBounds, toBounds, theme, edgeRoute) {
|
|
2623
|
-
const
|
|
2624
|
-
const
|
|
2625
|
-
const
|
|
2626
|
-
const
|
|
2627
|
-
const dash = dashFromStyle(
|
|
3315
|
+
function renderConnection(ctx, conn, fromBounds, toBounds, theme, edgeRoute, options) {
|
|
3316
|
+
const routing = conn.routing ?? "auto";
|
|
3317
|
+
const strokeStyle = conn.strokeStyle ?? conn.style ?? "solid";
|
|
3318
|
+
const strokeWidth = conn.width ?? conn.strokeWidth ?? 2;
|
|
3319
|
+
const tension = conn.tension ?? 0.35;
|
|
3320
|
+
const dash = dashFromStyle(strokeStyle);
|
|
2628
3321
|
const style = {
|
|
2629
3322
|
color: conn.color ?? theme.borderMuted,
|
|
2630
|
-
width:
|
|
3323
|
+
width: strokeWidth,
|
|
2631
3324
|
headSize: conn.arrowSize ?? 10,
|
|
2632
3325
|
...dash ? { dash } : {}
|
|
2633
3326
|
};
|
|
2634
|
-
const
|
|
2635
|
-
const
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
let
|
|
2639
|
-
let
|
|
3327
|
+
const labelT = conn.labelPosition === "start" ? 0.2 : conn.labelPosition === "end" ? 0.8 : 0.5;
|
|
3328
|
+
const diagramCenter = options?.diagramCenter ?? computeDiagramCenter([fromBounds, toBounds]);
|
|
3329
|
+
let linePoints;
|
|
3330
|
+
let startPoint;
|
|
3331
|
+
let endPoint;
|
|
3332
|
+
let startAngle;
|
|
3333
|
+
let endAngle;
|
|
3334
|
+
let labelPoint;
|
|
3335
|
+
ctx.save();
|
|
3336
|
+
ctx.globalAlpha = conn.opacity;
|
|
3337
|
+
if (routing === "curve") {
|
|
3338
|
+
const [p0, cp1, cp2, p3] = curveRoute(fromBounds, toBounds, diagramCenter, tension);
|
|
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, p3.x, p3.y);
|
|
3345
|
+
ctx.stroke();
|
|
3346
|
+
linePoints = [p0, cp1, cp2, p3];
|
|
3347
|
+
startPoint = p0;
|
|
3348
|
+
endPoint = p3;
|
|
3349
|
+
startAngle = Math.atan2(p0.y - cp1.y, p0.x - cp1.x);
|
|
3350
|
+
endAngle = Math.atan2(p3.y - cp2.y, p3.x - cp2.x);
|
|
3351
|
+
labelPoint = bezierPointAt(p0, cp1, cp2, p3, labelT);
|
|
3352
|
+
} else if (routing === "arc") {
|
|
3353
|
+
const [first, second] = arcRoute(fromBounds, toBounds, diagramCenter, tension);
|
|
3354
|
+
const [p0, cp1, cp2, pMid] = first;
|
|
3355
|
+
const [, cp3, cp4, p3] = second;
|
|
3356
|
+
ctx.strokeStyle = style.color;
|
|
3357
|
+
ctx.lineWidth = style.width;
|
|
3358
|
+
ctx.setLineDash(style.dash ?? []);
|
|
3359
|
+
ctx.beginPath();
|
|
3360
|
+
ctx.moveTo(p0.x, p0.y);
|
|
3361
|
+
ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, pMid.x, pMid.y);
|
|
3362
|
+
ctx.bezierCurveTo(cp3.x, cp3.y, cp4.x, cp4.y, p3.x, p3.y);
|
|
3363
|
+
ctx.stroke();
|
|
3364
|
+
linePoints = [p0, cp1, cp2, pMid, cp3, cp4, p3];
|
|
3365
|
+
startPoint = p0;
|
|
3366
|
+
endPoint = p3;
|
|
3367
|
+
startAngle = Math.atan2(p0.y - cp1.y, p0.x - cp1.x);
|
|
3368
|
+
endAngle = Math.atan2(p3.y - cp4.y, p3.x - cp4.x);
|
|
3369
|
+
labelPoint = pointAlongArc([first, second], labelT);
|
|
3370
|
+
} else {
|
|
3371
|
+
const useElkRoute = routing === "auto" && (edgeRoute?.points.length ?? 0) >= 2;
|
|
3372
|
+
linePoints = useElkRoute ? edgeRoute?.points ?? orthogonalRoute(fromBounds, toBounds) : orthogonalRoute(fromBounds, toBounds);
|
|
3373
|
+
startPoint = linePoints[0];
|
|
3374
|
+
const startSegment = linePoints[1] ?? linePoints[0];
|
|
3375
|
+
const endStart = linePoints[linePoints.length - 2] ?? linePoints[0];
|
|
3376
|
+
endPoint = linePoints[linePoints.length - 1] ?? linePoints[0];
|
|
3377
|
+
startAngle = Math.atan2(startSegment.y - linePoints[0].y, startSegment.x - linePoints[0].x) + Math.PI;
|
|
3378
|
+
endAngle = Math.atan2(endPoint.y - endStart.y, endPoint.x - endStart.x);
|
|
3379
|
+
if (useElkRoute) {
|
|
3380
|
+
drawCubicInterpolatedPath(ctx, linePoints, style);
|
|
3381
|
+
} else {
|
|
3382
|
+
drawOrthogonalPath(ctx, startPoint, endPoint, style);
|
|
3383
|
+
}
|
|
3384
|
+
labelPoint = pointAlongPolyline(linePoints, labelT);
|
|
3385
|
+
}
|
|
2640
3386
|
if (!Number.isFinite(startAngle)) {
|
|
2641
3387
|
startAngle = 0;
|
|
2642
3388
|
}
|
|
2643
3389
|
if (!Number.isFinite(endAngle)) {
|
|
2644
3390
|
endAngle = 0;
|
|
2645
3391
|
}
|
|
2646
|
-
const t = conn.labelPosition === "start" ? 0.2 : conn.labelPosition === "end" ? 0.8 : 0.5;
|
|
2647
|
-
const labelPoint = pointAlongPolyline(points, t);
|
|
2648
|
-
ctx.save();
|
|
2649
|
-
ctx.globalAlpha = conn.opacity;
|
|
2650
|
-
if (edgeRoute && edgeRoute.points.length >= 2) {
|
|
2651
|
-
drawCubicInterpolatedPath(ctx, points, style);
|
|
2652
|
-
} else {
|
|
2653
|
-
drawOrthogonalPath(ctx, points[0], points[points.length - 1], style);
|
|
2654
|
-
}
|
|
2655
3392
|
if (conn.arrow === "start" || conn.arrow === "both") {
|
|
2656
|
-
drawArrowhead(ctx,
|
|
3393
|
+
drawArrowhead(ctx, startPoint, startAngle, style.headSize, style.color);
|
|
2657
3394
|
}
|
|
2658
3395
|
if (conn.arrow === "end" || conn.arrow === "both") {
|
|
2659
|
-
drawArrowhead(ctx,
|
|
3396
|
+
drawArrowhead(ctx, endPoint, endAngle, style.headSize, style.color);
|
|
2660
3397
|
}
|
|
2661
3398
|
ctx.restore();
|
|
2662
3399
|
const elements = [
|
|
2663
3400
|
{
|
|
2664
3401
|
id: `connection-${conn.from}-${conn.to}`,
|
|
2665
3402
|
kind: "connection",
|
|
2666
|
-
bounds: polylineBounds(
|
|
3403
|
+
bounds: polylineBounds(linePoints),
|
|
2667
3404
|
foregroundColor: style.color
|
|
2668
3405
|
}
|
|
2669
3406
|
];
|
|
@@ -3294,92 +4031,6 @@ function renderDrawCommands(ctx, commands, theme) {
|
|
|
3294
4031
|
return rendered;
|
|
3295
4032
|
}
|
|
3296
4033
|
|
|
3297
|
-
// src/renderers/flow-node.ts
|
|
3298
|
-
function renderFlowNode(ctx, node, bounds, theme) {
|
|
3299
|
-
const fillColor = node.color ?? theme.surfaceElevated;
|
|
3300
|
-
const borderColor = node.borderColor ?? theme.border;
|
|
3301
|
-
const borderWidth = node.borderWidth ?? 2;
|
|
3302
|
-
const cornerRadius = node.cornerRadius ?? 16;
|
|
3303
|
-
const labelColor = node.labelColor ?? theme.text;
|
|
3304
|
-
const sublabelColor = node.sublabelColor ?? theme.textMuted;
|
|
3305
|
-
const labelFontSize = node.labelFontSize ?? 20;
|
|
3306
|
-
ctx.save();
|
|
3307
|
-
ctx.globalAlpha = node.opacity;
|
|
3308
|
-
ctx.lineWidth = borderWidth;
|
|
3309
|
-
switch (node.shape) {
|
|
3310
|
-
case "box":
|
|
3311
|
-
drawRoundedRect(ctx, bounds, 0, fillColor, borderColor);
|
|
3312
|
-
break;
|
|
3313
|
-
case "rounded-box":
|
|
3314
|
-
drawRoundedRect(ctx, bounds, cornerRadius, fillColor, borderColor);
|
|
3315
|
-
break;
|
|
3316
|
-
case "diamond":
|
|
3317
|
-
drawDiamond(ctx, bounds, fillColor, borderColor);
|
|
3318
|
-
break;
|
|
3319
|
-
case "circle": {
|
|
3320
|
-
const radius = Math.min(bounds.width, bounds.height) / 2;
|
|
3321
|
-
drawCircle(
|
|
3322
|
-
ctx,
|
|
3323
|
-
{ x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 },
|
|
3324
|
-
radius,
|
|
3325
|
-
fillColor,
|
|
3326
|
-
borderColor
|
|
3327
|
-
);
|
|
3328
|
-
break;
|
|
3329
|
-
}
|
|
3330
|
-
case "pill":
|
|
3331
|
-
drawPill(ctx, bounds, fillColor, borderColor);
|
|
3332
|
-
break;
|
|
3333
|
-
case "cylinder":
|
|
3334
|
-
drawCylinder(ctx, bounds, fillColor, borderColor);
|
|
3335
|
-
break;
|
|
3336
|
-
case "parallelogram":
|
|
3337
|
-
drawParallelogram(ctx, bounds, fillColor, borderColor);
|
|
3338
|
-
break;
|
|
3339
|
-
}
|
|
3340
|
-
const headingFont = resolveFont(theme.fonts.heading, "heading");
|
|
3341
|
-
const bodyFont = resolveFont(theme.fonts.body, "body");
|
|
3342
|
-
const centerX = bounds.x + bounds.width / 2;
|
|
3343
|
-
const centerY = bounds.y + bounds.height / 2;
|
|
3344
|
-
const labelY = node.sublabel ? centerY - Math.max(4, labelFontSize * 0.2) : centerY + labelFontSize * 0.3;
|
|
3345
|
-
ctx.textAlign = "center";
|
|
3346
|
-
applyFont(ctx, { size: labelFontSize, weight: 700, family: headingFont });
|
|
3347
|
-
ctx.fillStyle = labelColor;
|
|
3348
|
-
ctx.fillText(node.label, centerX, labelY);
|
|
3349
|
-
let textBoundsY = bounds.y + bounds.height / 2 - 18;
|
|
3350
|
-
let textBoundsHeight = 36;
|
|
3351
|
-
if (node.sublabel) {
|
|
3352
|
-
const sublabelFontSize = Math.max(12, Math.round(labelFontSize * 0.68));
|
|
3353
|
-
applyFont(ctx, { size: sublabelFontSize, weight: 500, family: bodyFont });
|
|
3354
|
-
ctx.fillStyle = sublabelColor;
|
|
3355
|
-
ctx.fillText(node.sublabel, centerX, labelY + Math.max(20, sublabelFontSize + 6));
|
|
3356
|
-
textBoundsY = bounds.y + bounds.height / 2 - 24;
|
|
3357
|
-
textBoundsHeight = 56;
|
|
3358
|
-
}
|
|
3359
|
-
ctx.restore();
|
|
3360
|
-
return [
|
|
3361
|
-
{
|
|
3362
|
-
id: `flow-node-${node.id}`,
|
|
3363
|
-
kind: "flow-node",
|
|
3364
|
-
bounds,
|
|
3365
|
-
foregroundColor: labelColor,
|
|
3366
|
-
backgroundColor: fillColor
|
|
3367
|
-
},
|
|
3368
|
-
{
|
|
3369
|
-
id: `flow-node-${node.id}-label`,
|
|
3370
|
-
kind: "text",
|
|
3371
|
-
bounds: {
|
|
3372
|
-
x: bounds.x + 8,
|
|
3373
|
-
y: textBoundsY,
|
|
3374
|
-
width: bounds.width - 16,
|
|
3375
|
-
height: textBoundsHeight
|
|
3376
|
-
},
|
|
3377
|
-
foregroundColor: labelColor,
|
|
3378
|
-
backgroundColor: fillColor
|
|
3379
|
-
}
|
|
3380
|
-
];
|
|
3381
|
-
}
|
|
3382
|
-
|
|
3383
4034
|
// src/renderers/image.ts
|
|
3384
4035
|
import { loadImage } from "@napi-rs/canvas";
|
|
3385
4036
|
function roundedRectPath2(ctx, bounds, radius) {
|
|
@@ -3963,6 +4614,10 @@ async function renderDesign(input, options = {}) {
|
|
|
3963
4614
|
break;
|
|
3964
4615
|
}
|
|
3965
4616
|
}
|
|
4617
|
+
const diagramCenter = spec.layout.diagramCenter ?? computeDiagramCenter(
|
|
4618
|
+
spec.elements.filter((element) => element.type !== "connection").map((element) => elementRects.get(element.id)).filter((rect) => rect != null),
|
|
4619
|
+
{ x: spec.canvas.width / 2, y: spec.canvas.height / 2 }
|
|
4620
|
+
);
|
|
3966
4621
|
for (const element of spec.elements) {
|
|
3967
4622
|
if (element.type !== "connection") {
|
|
3968
4623
|
continue;
|
|
@@ -3975,7 +4630,9 @@ async function renderDesign(input, options = {}) {
|
|
|
3975
4630
|
);
|
|
3976
4631
|
}
|
|
3977
4632
|
const edgeRoute = edgeRoutes?.get(`${element.from}-${element.to}`);
|
|
3978
|
-
elements.push(
|
|
4633
|
+
elements.push(
|
|
4634
|
+
...renderConnection(ctx, element, fromRect, toRect, theme, edgeRoute, { diagramCenter })
|
|
4635
|
+
);
|
|
3979
4636
|
}
|
|
3980
4637
|
if (footerRect && spec.footer) {
|
|
3981
4638
|
const footerText = spec.footer.tagline ? `${spec.footer.text} \u2022 ${spec.footer.tagline}` : spec.footer.text;
|
|
@@ -4338,6 +4995,36 @@ var renderOutputSchema = z3.object({
|
|
|
4338
4995
|
)
|
|
4339
4996
|
})
|
|
4340
4997
|
});
|
|
4998
|
+
var compareOutputSchema = z3.object({
|
|
4999
|
+
targetPath: z3.string(),
|
|
5000
|
+
renderedPath: z3.string(),
|
|
5001
|
+
targetDimensions: z3.object({
|
|
5002
|
+
width: z3.number().int().positive(),
|
|
5003
|
+
height: z3.number().int().positive()
|
|
5004
|
+
}),
|
|
5005
|
+
renderedDimensions: z3.object({
|
|
5006
|
+
width: z3.number().int().positive(),
|
|
5007
|
+
height: z3.number().int().positive()
|
|
5008
|
+
}),
|
|
5009
|
+
normalizedDimensions: z3.object({
|
|
5010
|
+
width: z3.number().int().positive(),
|
|
5011
|
+
height: z3.number().int().positive()
|
|
5012
|
+
}),
|
|
5013
|
+
dimensionMismatch: z3.boolean(),
|
|
5014
|
+
grid: z3.number().int().positive(),
|
|
5015
|
+
threshold: z3.number(),
|
|
5016
|
+
closeThreshold: z3.number(),
|
|
5017
|
+
similarity: z3.number(),
|
|
5018
|
+
verdict: z3.enum(["match", "close", "mismatch"]),
|
|
5019
|
+
regions: z3.array(
|
|
5020
|
+
z3.object({
|
|
5021
|
+
label: z3.string(),
|
|
5022
|
+
row: z3.number().int().nonnegative(),
|
|
5023
|
+
column: z3.number().int().nonnegative(),
|
|
5024
|
+
similarity: z3.number()
|
|
5025
|
+
})
|
|
5026
|
+
)
|
|
5027
|
+
});
|
|
4341
5028
|
async function readJson(path) {
|
|
4342
5029
|
if (path === "-") {
|
|
4343
5030
|
const chunks = [];
|
|
@@ -4440,6 +5127,44 @@ cli.command("render", {
|
|
|
4440
5127
|
return c.ok(runReport);
|
|
4441
5128
|
}
|
|
4442
5129
|
});
|
|
5130
|
+
cli.command("compare", {
|
|
5131
|
+
description: "Compare a rendered design against a target image using structural similarity scoring.",
|
|
5132
|
+
options: z3.object({
|
|
5133
|
+
target: z3.string().describe("Path to target image (baseline)"),
|
|
5134
|
+
rendered: z3.string().describe("Path to rendered image to evaluate"),
|
|
5135
|
+
grid: z3.number().int().positive().default(3).describe("Grid size for per-region scoring"),
|
|
5136
|
+
threshold: z3.number().min(0).max(1).default(0.8).describe("Minimum similarity score required for a match verdict")
|
|
5137
|
+
}),
|
|
5138
|
+
output: compareOutputSchema,
|
|
5139
|
+
examples: [
|
|
5140
|
+
{
|
|
5141
|
+
options: {
|
|
5142
|
+
target: "./designs/target.png",
|
|
5143
|
+
rendered: "./output/design-v2-g0.4.0-sabc123.png",
|
|
5144
|
+
grid: 3,
|
|
5145
|
+
threshold: 0.8
|
|
5146
|
+
},
|
|
5147
|
+
description: "Compare two images and report overall + per-region similarity scores"
|
|
5148
|
+
}
|
|
5149
|
+
],
|
|
5150
|
+
async run(c) {
|
|
5151
|
+
try {
|
|
5152
|
+
return c.ok(
|
|
5153
|
+
await compareImages(c.options.target, c.options.rendered, {
|
|
5154
|
+
grid: c.options.grid,
|
|
5155
|
+
threshold: c.options.threshold
|
|
5156
|
+
})
|
|
5157
|
+
);
|
|
5158
|
+
} catch (error) {
|
|
5159
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5160
|
+
return c.error({
|
|
5161
|
+
code: "COMPARE_FAILED",
|
|
5162
|
+
message: `Unable to compare images: ${message}`,
|
|
5163
|
+
retryable: false
|
|
5164
|
+
});
|
|
5165
|
+
}
|
|
5166
|
+
}
|
|
5167
|
+
});
|
|
4443
5168
|
var template = Cli.create("template", {
|
|
4444
5169
|
description: "Generate common design templates and run the full render \u2192 QA pipeline."
|
|
4445
5170
|
});
|
|
@@ -4681,7 +5406,8 @@ cli.command("qa", {
|
|
|
4681
5406
|
options: z3.object({
|
|
4682
5407
|
in: z3.string().describe("Path to rendered PNG"),
|
|
4683
5408
|
spec: z3.string().describe("Path to normalized DesignSpec JSON"),
|
|
4684
|
-
meta: z3.string().optional().describe("Optional sidecar metadata path (.meta.json)")
|
|
5409
|
+
meta: z3.string().optional().describe("Optional sidecar metadata path (.meta.json)"),
|
|
5410
|
+
reference: z3.string().optional().describe("Optional reference image path for visual comparison")
|
|
4685
5411
|
}),
|
|
4686
5412
|
output: z3.object({
|
|
4687
5413
|
pass: z3.boolean(),
|
|
@@ -4695,7 +5421,18 @@ cli.command("qa", {
|
|
|
4695
5421
|
message: z3.string(),
|
|
4696
5422
|
elementId: z3.string().optional()
|
|
4697
5423
|
})
|
|
4698
|
-
)
|
|
5424
|
+
),
|
|
5425
|
+
reference: z3.object({
|
|
5426
|
+
similarity: z3.number(),
|
|
5427
|
+
verdict: z3.enum(["match", "close", "mismatch"]),
|
|
5428
|
+
regions: z3.array(
|
|
5429
|
+
z3.object({
|
|
5430
|
+
label: z3.string(),
|
|
5431
|
+
similarity: z3.number(),
|
|
5432
|
+
description: z3.string().optional()
|
|
5433
|
+
})
|
|
5434
|
+
)
|
|
5435
|
+
}).optional()
|
|
4699
5436
|
}),
|
|
4700
5437
|
examples: [
|
|
4701
5438
|
{
|
|
@@ -4718,14 +5455,16 @@ cli.command("qa", {
|
|
|
4718
5455
|
const report = await runQa({
|
|
4719
5456
|
imagePath: c.options.in,
|
|
4720
5457
|
spec,
|
|
4721
|
-
...metadata ? { metadata } : {}
|
|
5458
|
+
...metadata ? { metadata } : {},
|
|
5459
|
+
...c.options.reference ? { referencePath: c.options.reference } : {}
|
|
4722
5460
|
});
|
|
4723
5461
|
const response = {
|
|
4724
5462
|
pass: report.pass,
|
|
4725
5463
|
checkedAt: report.checkedAt,
|
|
4726
5464
|
imagePath: report.imagePath,
|
|
4727
5465
|
issueCount: report.issues.length,
|
|
4728
|
-
issues: report.issues
|
|
5466
|
+
issues: report.issues,
|
|
5467
|
+
...report.reference ? { reference: report.reference } : {}
|
|
4729
5468
|
};
|
|
4730
5469
|
if (!report.pass) {
|
|
4731
5470
|
return c.error({
|
|
@@ -4859,9 +5598,14 @@ var isMain = (() => {
|
|
|
4859
5598
|
if (isMain) {
|
|
4860
5599
|
cli.serve();
|
|
4861
5600
|
}
|
|
5601
|
+
|
|
5602
|
+
// src/index.ts
|
|
5603
|
+
init_compare();
|
|
4862
5604
|
export {
|
|
4863
5605
|
DEFAULT_GENERATOR_VERSION,
|
|
4864
5606
|
DEFAULT_RAINBOW_COLORS,
|
|
5607
|
+
arcRoute,
|
|
5608
|
+
bezierPointAt,
|
|
4865
5609
|
buildCardsSpec,
|
|
4866
5610
|
buildCodeSpec,
|
|
4867
5611
|
buildFlowchartSpec,
|
|
@@ -4869,7 +5613,11 @@ export {
|
|
|
4869
5613
|
builtInThemeBackgrounds,
|
|
4870
5614
|
builtInThemes,
|
|
4871
5615
|
cli,
|
|
5616
|
+
compareImages,
|
|
5617
|
+
computeDiagramCenter,
|
|
4872
5618
|
computeSpecHash,
|
|
5619
|
+
connectionElementSchema,
|
|
5620
|
+
curveRoute,
|
|
4873
5621
|
defaultAutoLayout,
|
|
4874
5622
|
defaultCanvas,
|
|
4875
5623
|
defaultConstraints,
|
|
@@ -4879,19 +5627,29 @@ export {
|
|
|
4879
5627
|
defaultTheme,
|
|
4880
5628
|
deriveSafeFrame,
|
|
4881
5629
|
designSpecSchema,
|
|
5630
|
+
diagramElementSchema,
|
|
5631
|
+
diagramLayoutSchema,
|
|
5632
|
+
diagramSpecSchema,
|
|
4882
5633
|
disposeHighlighter,
|
|
4883
5634
|
drawGradientRect,
|
|
4884
5635
|
drawRainbowRule,
|
|
4885
5636
|
drawVignette,
|
|
5637
|
+
edgeAnchor,
|
|
5638
|
+
flowNodeElementSchema,
|
|
4886
5639
|
highlightCode,
|
|
4887
5640
|
inferLayout,
|
|
4888
5641
|
inferSidecarPath,
|
|
4889
5642
|
initHighlighter,
|
|
4890
5643
|
loadFonts,
|
|
5644
|
+
orthogonalRoute,
|
|
5645
|
+
outwardNormal,
|
|
4891
5646
|
parseDesignSpec,
|
|
5647
|
+
parseDiagramSpec,
|
|
4892
5648
|
publishToGist,
|
|
4893
5649
|
publishToGitHub,
|
|
4894
5650
|
readMetadata,
|
|
5651
|
+
rectCenter,
|
|
5652
|
+
renderConnection,
|
|
4895
5653
|
renderDesign,
|
|
4896
5654
|
renderDrawCommands,
|
|
4897
5655
|
resolveShikiTheme,
|