@spectratools/graphic-designer-cli 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +559 -319
- package/dist/index.d.ts +2 -2
- package/dist/index.js +559 -319
- package/dist/qa.d.ts +1 -1
- package/dist/qa.js +121 -41
- package/dist/renderer.d.ts +1 -1
- package/dist/renderer.js +627 -389
- package/dist/{spec.schema-DhAI-tE8.d.ts → spec.schema-BUTof436.d.ts} +625 -457
- package/dist/spec.schema.d.ts +1 -1
- package/dist/spec.schema.js +85 -4
- package/package.json +1 -1
package/dist/renderer.js
CHANGED
|
@@ -86,6 +86,462 @@ function loadFonts() {
|
|
|
86
86
|
// src/layout/elk.ts
|
|
87
87
|
import ELK from "elkjs";
|
|
88
88
|
|
|
89
|
+
// src/primitives/shapes.ts
|
|
90
|
+
function roundRectPath(ctx, rect, radius) {
|
|
91
|
+
const r = Math.max(0, Math.min(radius, rect.width / 2, rect.height / 2));
|
|
92
|
+
const right = rect.x + rect.width;
|
|
93
|
+
const bottom = rect.y + rect.height;
|
|
94
|
+
ctx.beginPath();
|
|
95
|
+
ctx.moveTo(rect.x + r, rect.y);
|
|
96
|
+
ctx.lineTo(right - r, rect.y);
|
|
97
|
+
ctx.quadraticCurveTo(right, rect.y, right, rect.y + r);
|
|
98
|
+
ctx.lineTo(right, bottom - r);
|
|
99
|
+
ctx.quadraticCurveTo(right, bottom, right - r, bottom);
|
|
100
|
+
ctx.lineTo(rect.x + r, bottom);
|
|
101
|
+
ctx.quadraticCurveTo(rect.x, bottom, rect.x, bottom - r);
|
|
102
|
+
ctx.lineTo(rect.x, rect.y + r);
|
|
103
|
+
ctx.quadraticCurveTo(rect.x, rect.y, rect.x + r, rect.y);
|
|
104
|
+
ctx.closePath();
|
|
105
|
+
}
|
|
106
|
+
function fillAndStroke(ctx, fill, stroke) {
|
|
107
|
+
ctx.fillStyle = fill;
|
|
108
|
+
ctx.fill();
|
|
109
|
+
if (stroke) {
|
|
110
|
+
ctx.strokeStyle = stroke;
|
|
111
|
+
ctx.stroke();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function drawRoundedRect(ctx, rect, radius, fill, stroke) {
|
|
115
|
+
roundRectPath(ctx, rect, radius);
|
|
116
|
+
fillAndStroke(ctx, fill, stroke);
|
|
117
|
+
}
|
|
118
|
+
function drawCircle(ctx, center2, radius, fill, stroke) {
|
|
119
|
+
ctx.beginPath();
|
|
120
|
+
ctx.arc(center2.x, center2.y, Math.max(0, radius), 0, Math.PI * 2);
|
|
121
|
+
ctx.closePath();
|
|
122
|
+
fillAndStroke(ctx, fill, stroke);
|
|
123
|
+
}
|
|
124
|
+
function drawDiamond(ctx, bounds, fill, stroke) {
|
|
125
|
+
const cx = bounds.x + bounds.width / 2;
|
|
126
|
+
const cy = bounds.y + bounds.height / 2;
|
|
127
|
+
ctx.beginPath();
|
|
128
|
+
ctx.moveTo(cx, bounds.y);
|
|
129
|
+
ctx.lineTo(bounds.x + bounds.width, cy);
|
|
130
|
+
ctx.lineTo(cx, bounds.y + bounds.height);
|
|
131
|
+
ctx.lineTo(bounds.x, cy);
|
|
132
|
+
ctx.closePath();
|
|
133
|
+
fillAndStroke(ctx, fill, stroke);
|
|
134
|
+
}
|
|
135
|
+
function drawPill(ctx, bounds, fill, stroke) {
|
|
136
|
+
drawRoundedRect(ctx, bounds, Math.min(bounds.width, bounds.height) / 2, fill, stroke);
|
|
137
|
+
}
|
|
138
|
+
function drawEllipse(ctx, bounds, fill, stroke) {
|
|
139
|
+
const cx = bounds.x + bounds.width / 2;
|
|
140
|
+
const cy = bounds.y + bounds.height / 2;
|
|
141
|
+
ctx.beginPath();
|
|
142
|
+
ctx.ellipse(
|
|
143
|
+
cx,
|
|
144
|
+
cy,
|
|
145
|
+
Math.max(0, bounds.width / 2),
|
|
146
|
+
Math.max(0, bounds.height / 2),
|
|
147
|
+
0,
|
|
148
|
+
0,
|
|
149
|
+
Math.PI * 2
|
|
150
|
+
);
|
|
151
|
+
ctx.closePath();
|
|
152
|
+
fillAndStroke(ctx, fill, stroke);
|
|
153
|
+
}
|
|
154
|
+
function drawCylinder(ctx, bounds, fill, stroke) {
|
|
155
|
+
const rx = Math.max(2, bounds.width / 2);
|
|
156
|
+
const ry = Math.max(2, Math.min(bounds.height * 0.18, 16));
|
|
157
|
+
const cx = bounds.x + bounds.width / 2;
|
|
158
|
+
const topCy = bounds.y + ry;
|
|
159
|
+
const bottomCy = bounds.y + bounds.height - ry;
|
|
160
|
+
ctx.beginPath();
|
|
161
|
+
ctx.moveTo(bounds.x, topCy);
|
|
162
|
+
ctx.ellipse(cx, topCy, rx, ry, 0, Math.PI, 0, true);
|
|
163
|
+
ctx.lineTo(bounds.x + bounds.width, bottomCy);
|
|
164
|
+
ctx.ellipse(cx, bottomCy, rx, ry, 0, 0, Math.PI, false);
|
|
165
|
+
ctx.closePath();
|
|
166
|
+
fillAndStroke(ctx, fill, stroke);
|
|
167
|
+
if (stroke) {
|
|
168
|
+
ctx.beginPath();
|
|
169
|
+
ctx.ellipse(cx, topCy, rx, ry, 0, 0, Math.PI * 2);
|
|
170
|
+
ctx.closePath();
|
|
171
|
+
ctx.strokeStyle = stroke;
|
|
172
|
+
ctx.stroke();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function drawParallelogram(ctx, bounds, fill, stroke, skew) {
|
|
176
|
+
const maxSkew = bounds.width * 0.45;
|
|
177
|
+
const skewX = Math.max(-maxSkew, Math.min(maxSkew, skew ?? bounds.width * 0.18));
|
|
178
|
+
ctx.beginPath();
|
|
179
|
+
ctx.moveTo(bounds.x + skewX, bounds.y);
|
|
180
|
+
ctx.lineTo(bounds.x + bounds.width, bounds.y);
|
|
181
|
+
ctx.lineTo(bounds.x + bounds.width - skewX, bounds.y + bounds.height);
|
|
182
|
+
ctx.lineTo(bounds.x, bounds.y + bounds.height);
|
|
183
|
+
ctx.closePath();
|
|
184
|
+
fillAndStroke(ctx, fill, stroke);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// src/primitives/text.ts
|
|
188
|
+
var SUPPORTED_FONT_FAMILIES = /* @__PURE__ */ new Set(["Inter", "JetBrains Mono", "Space Grotesk"]);
|
|
189
|
+
function resolveFont(requested, role) {
|
|
190
|
+
if (SUPPORTED_FONT_FAMILIES.has(requested)) {
|
|
191
|
+
return requested;
|
|
192
|
+
}
|
|
193
|
+
if (role === "mono" || /mono|code|terminal|console/iu.test(requested)) {
|
|
194
|
+
return "JetBrains Mono";
|
|
195
|
+
}
|
|
196
|
+
if (role === "heading" || /display|grotesk|headline/iu.test(requested)) {
|
|
197
|
+
return "Space Grotesk";
|
|
198
|
+
}
|
|
199
|
+
return "Inter";
|
|
200
|
+
}
|
|
201
|
+
function applyFont(ctx, options) {
|
|
202
|
+
ctx.font = `${options.weight} ${options.size}px ${options.family}`;
|
|
203
|
+
}
|
|
204
|
+
function wrapText(ctx, text, maxWidth, maxLines) {
|
|
205
|
+
const trimmed = text.trim();
|
|
206
|
+
if (!trimmed) {
|
|
207
|
+
return { lines: [], truncated: false };
|
|
208
|
+
}
|
|
209
|
+
const words = trimmed.split(/\s+/u);
|
|
210
|
+
const lines = [];
|
|
211
|
+
let current = "";
|
|
212
|
+
for (const word of words) {
|
|
213
|
+
const trial = current.length > 0 ? `${current} ${word}` : word;
|
|
214
|
+
if (ctx.measureText(trial).width <= maxWidth) {
|
|
215
|
+
current = trial;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (current.length > 0) {
|
|
219
|
+
lines.push(current);
|
|
220
|
+
current = word;
|
|
221
|
+
} else {
|
|
222
|
+
lines.push(word);
|
|
223
|
+
current = "";
|
|
224
|
+
}
|
|
225
|
+
if (lines.length >= maxLines) {
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (lines.length < maxLines && current.length > 0) {
|
|
230
|
+
lines.push(current);
|
|
231
|
+
}
|
|
232
|
+
const wasTruncated = lines.length >= maxLines && words.join(" ") !== lines.join(" ");
|
|
233
|
+
if (!wasTruncated) {
|
|
234
|
+
return { lines, truncated: false };
|
|
235
|
+
}
|
|
236
|
+
const lastIndex = lines.length - 1;
|
|
237
|
+
let truncatedLine = `${lines[lastIndex]}\u2026`;
|
|
238
|
+
while (truncatedLine.length > 1 && ctx.measureText(truncatedLine).width > maxWidth) {
|
|
239
|
+
truncatedLine = `${truncatedLine.slice(0, -2)}\u2026`;
|
|
240
|
+
}
|
|
241
|
+
lines[lastIndex] = truncatedLine;
|
|
242
|
+
return { lines, truncated: true };
|
|
243
|
+
}
|
|
244
|
+
function drawTextBlock(ctx, options) {
|
|
245
|
+
applyFont(ctx, { size: options.fontSize, weight: options.fontWeight, family: options.family });
|
|
246
|
+
const wrapped = wrapText(ctx, options.text, options.maxWidth, options.maxLines);
|
|
247
|
+
ctx.fillStyle = options.color;
|
|
248
|
+
for (const [index, line] of wrapped.lines.entries()) {
|
|
249
|
+
ctx.fillText(line, options.x, options.y + index * options.lineHeight);
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
height: wrapped.lines.length * options.lineHeight,
|
|
253
|
+
truncated: wrapped.truncated
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
function drawTextLabel(ctx, text, position, options) {
|
|
257
|
+
applyFont(ctx, { size: options.fontSize, weight: 600, family: options.fontFamily });
|
|
258
|
+
const textWidth = Math.ceil(ctx.measureText(text).width);
|
|
259
|
+
const rect = {
|
|
260
|
+
x: Math.round(position.x - (textWidth + options.padding * 2) / 2),
|
|
261
|
+
y: Math.round(position.y - (options.fontSize + options.padding * 2) / 2),
|
|
262
|
+
width: textWidth + options.padding * 2,
|
|
263
|
+
height: options.fontSize + options.padding * 2
|
|
264
|
+
};
|
|
265
|
+
drawRoundedRect(ctx, rect, options.borderRadius, options.backgroundColor);
|
|
266
|
+
ctx.fillStyle = options.color;
|
|
267
|
+
ctx.fillText(text, rect.x + options.padding, rect.y + rect.height - options.padding);
|
|
268
|
+
return rect;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// src/utils/color.ts
|
|
272
|
+
function parseChannel(hex, offset) {
|
|
273
|
+
return Number.parseInt(hex.slice(offset, offset + 2), 16);
|
|
274
|
+
}
|
|
275
|
+
function parseHexColor(hexColor) {
|
|
276
|
+
const normalized = hexColor.startsWith("#") ? hexColor.slice(1) : hexColor;
|
|
277
|
+
if (normalized.length !== 6 && normalized.length !== 8) {
|
|
278
|
+
throw new Error(`Unsupported color format: ${hexColor}`);
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
r: parseChannel(normalized, 0),
|
|
282
|
+
g: parseChannel(normalized, 2),
|
|
283
|
+
b: parseChannel(normalized, 4)
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
var rgbaRegex = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*([01](?:\.\d+)?|0?\.\d+)\s*)?\)$/;
|
|
287
|
+
var hexColorRegex = /^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
|
288
|
+
function toHex(n) {
|
|
289
|
+
return n.toString(16).padStart(2, "0");
|
|
290
|
+
}
|
|
291
|
+
function parseRgbaToHex(color) {
|
|
292
|
+
const match = rgbaRegex.exec(color);
|
|
293
|
+
if (!match) {
|
|
294
|
+
throw new Error(`Invalid rgb/rgba color: ${color}`);
|
|
295
|
+
}
|
|
296
|
+
const r = Number.parseInt(match[1], 10);
|
|
297
|
+
const g = Number.parseInt(match[2], 10);
|
|
298
|
+
const b = Number.parseInt(match[3], 10);
|
|
299
|
+
if (r > 255 || g > 255 || b > 255) {
|
|
300
|
+
throw new Error(`RGB channel values must be 0-255, got: ${color}`);
|
|
301
|
+
}
|
|
302
|
+
if (match[4] !== void 0) {
|
|
303
|
+
const a = Number.parseFloat(match[4]);
|
|
304
|
+
if (a < 0 || a > 1) {
|
|
305
|
+
throw new Error(`Alpha value must be 0-1, got: ${a}`);
|
|
306
|
+
}
|
|
307
|
+
const alphaByte = Math.round(a * 255);
|
|
308
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(alphaByte)}`;
|
|
309
|
+
}
|
|
310
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
311
|
+
}
|
|
312
|
+
function isRgbaColor(color) {
|
|
313
|
+
return rgbaRegex.test(color);
|
|
314
|
+
}
|
|
315
|
+
function isHexColor(color) {
|
|
316
|
+
return hexColorRegex.test(color);
|
|
317
|
+
}
|
|
318
|
+
function normalizeColor(color) {
|
|
319
|
+
if (isHexColor(color)) {
|
|
320
|
+
return color;
|
|
321
|
+
}
|
|
322
|
+
if (isRgbaColor(color)) {
|
|
323
|
+
return parseRgbaToHex(color);
|
|
324
|
+
}
|
|
325
|
+
throw new Error(`Expected #RRGGBB, #RRGGBBAA, rgb(), or rgba() color, got: ${color}`);
|
|
326
|
+
}
|
|
327
|
+
function srgbToLinear(channel) {
|
|
328
|
+
const normalized = channel / 255;
|
|
329
|
+
if (normalized <= 0.03928) {
|
|
330
|
+
return normalized / 12.92;
|
|
331
|
+
}
|
|
332
|
+
return ((normalized + 0.055) / 1.055) ** 2.4;
|
|
333
|
+
}
|
|
334
|
+
function relativeLuminance(hexColor) {
|
|
335
|
+
const normalized = isRgbaColor(hexColor) ? parseRgbaToHex(hexColor) : hexColor;
|
|
336
|
+
const rgb = parseHexColor(normalized);
|
|
337
|
+
const r = srgbToLinear(rgb.r);
|
|
338
|
+
const g = srgbToLinear(rgb.g);
|
|
339
|
+
const b = srgbToLinear(rgb.b);
|
|
340
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
341
|
+
}
|
|
342
|
+
function blendColorWithOpacity(foreground, background, opacity) {
|
|
343
|
+
const fg = parseHexColor(foreground);
|
|
344
|
+
const bg = parseHexColor(background);
|
|
345
|
+
const r = Math.round(fg.r * opacity + bg.r * (1 - opacity));
|
|
346
|
+
const g = Math.round(fg.g * opacity + bg.g * (1 - opacity));
|
|
347
|
+
const b = Math.round(fg.b * opacity + bg.b * (1 - opacity));
|
|
348
|
+
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`.toUpperCase();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// src/renderers/flow-node.ts
|
|
352
|
+
var BADGE_FONT_SIZE = 10;
|
|
353
|
+
var BADGE_FONT_WEIGHT = 600;
|
|
354
|
+
var BADGE_LETTER_SPACING = 1;
|
|
355
|
+
var BADGE_PADDING_X = 8;
|
|
356
|
+
var BADGE_PADDING_Y = 3;
|
|
357
|
+
var BADGE_BORDER_RADIUS = 12;
|
|
358
|
+
var BADGE_DEFAULT_COLOR = "#FFFFFF";
|
|
359
|
+
var BADGE_PILL_HEIGHT = BADGE_FONT_SIZE + BADGE_PADDING_Y * 2;
|
|
360
|
+
var BADGE_INSIDE_TOP_EXTRA = BADGE_PILL_HEIGHT + 6;
|
|
361
|
+
function drawNodeShape(ctx, shape, bounds, fill, stroke, cornerRadius) {
|
|
362
|
+
switch (shape) {
|
|
363
|
+
case "box":
|
|
364
|
+
drawRoundedRect(ctx, bounds, 0, fill, stroke);
|
|
365
|
+
break;
|
|
366
|
+
case "rounded-box":
|
|
367
|
+
drawRoundedRect(ctx, bounds, cornerRadius, fill, stroke);
|
|
368
|
+
break;
|
|
369
|
+
case "diamond":
|
|
370
|
+
drawDiamond(ctx, bounds, fill, stroke);
|
|
371
|
+
break;
|
|
372
|
+
case "circle": {
|
|
373
|
+
const radius = Math.min(bounds.width, bounds.height) / 2;
|
|
374
|
+
drawCircle(
|
|
375
|
+
ctx,
|
|
376
|
+
{ x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 },
|
|
377
|
+
radius,
|
|
378
|
+
fill,
|
|
379
|
+
stroke
|
|
380
|
+
);
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
case "pill":
|
|
384
|
+
drawPill(ctx, bounds, fill, stroke);
|
|
385
|
+
break;
|
|
386
|
+
case "cylinder":
|
|
387
|
+
drawCylinder(ctx, bounds, fill, stroke);
|
|
388
|
+
break;
|
|
389
|
+
case "parallelogram":
|
|
390
|
+
drawParallelogram(ctx, bounds, fill, stroke);
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
function measureSpacedText(ctx, text, letterSpacing) {
|
|
395
|
+
const base = ctx.measureText(text).width;
|
|
396
|
+
const extraChars = [...text].length - 1;
|
|
397
|
+
return extraChars > 0 ? base + extraChars * letterSpacing : base;
|
|
398
|
+
}
|
|
399
|
+
function drawSpacedText(ctx, text, centerX, centerY, letterSpacing) {
|
|
400
|
+
const chars = [...text];
|
|
401
|
+
if (chars.length === 0) return;
|
|
402
|
+
const totalWidth = measureSpacedText(ctx, text, letterSpacing);
|
|
403
|
+
let cursorX = centerX - totalWidth / 2;
|
|
404
|
+
ctx.textAlign = "left";
|
|
405
|
+
for (let i = 0; i < chars.length; i++) {
|
|
406
|
+
ctx.fillText(chars[i], cursorX, centerY);
|
|
407
|
+
cursorX += ctx.measureText(chars[i]).width + (i < chars.length - 1 ? letterSpacing : 0);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
function renderBadgePill(ctx, centerX, centerY, text, textColor, background, monoFont) {
|
|
411
|
+
ctx.save();
|
|
412
|
+
applyFont(ctx, { size: BADGE_FONT_SIZE, weight: BADGE_FONT_WEIGHT, family: monoFont });
|
|
413
|
+
const textWidth = measureSpacedText(ctx, text, BADGE_LETTER_SPACING);
|
|
414
|
+
const pillWidth = textWidth + BADGE_PADDING_X * 2;
|
|
415
|
+
const pillHeight = BADGE_PILL_HEIGHT;
|
|
416
|
+
const pillX = centerX - pillWidth / 2;
|
|
417
|
+
const pillY = centerY - pillHeight / 2;
|
|
418
|
+
ctx.fillStyle = background;
|
|
419
|
+
ctx.beginPath();
|
|
420
|
+
ctx.roundRect(pillX, pillY, pillWidth, pillHeight, BADGE_BORDER_RADIUS);
|
|
421
|
+
ctx.fill();
|
|
422
|
+
ctx.fillStyle = textColor;
|
|
423
|
+
ctx.textBaseline = "middle";
|
|
424
|
+
applyFont(ctx, { size: BADGE_FONT_SIZE, weight: BADGE_FONT_WEIGHT, family: monoFont });
|
|
425
|
+
drawSpacedText(ctx, text, centerX, centerY, BADGE_LETTER_SPACING);
|
|
426
|
+
ctx.restore();
|
|
427
|
+
return pillWidth;
|
|
428
|
+
}
|
|
429
|
+
function renderFlowNode(ctx, node, bounds, theme) {
|
|
430
|
+
const fillColor = node.color ?? theme.surfaceElevated;
|
|
431
|
+
const borderColor = node.borderColor ?? theme.border;
|
|
432
|
+
const borderWidth = node.borderWidth ?? 2;
|
|
433
|
+
const cornerRadius = node.cornerRadius ?? 16;
|
|
434
|
+
const labelColor = node.labelColor ?? theme.text;
|
|
435
|
+
const sublabelColor = node.sublabelColor ?? theme.textMuted;
|
|
436
|
+
const labelFontSize = node.labelFontSize ?? 20;
|
|
437
|
+
const fillOpacity = node.fillOpacity ?? 1;
|
|
438
|
+
const hasBadge = !!node.badgeText;
|
|
439
|
+
const badgePosition = node.badgePosition ?? "inside-top";
|
|
440
|
+
const badgeColor = node.badgeColor ?? BADGE_DEFAULT_COLOR;
|
|
441
|
+
const badgeBackground = node.badgeBackground ?? borderColor ?? theme.accent;
|
|
442
|
+
ctx.save();
|
|
443
|
+
ctx.lineWidth = borderWidth;
|
|
444
|
+
if (fillOpacity < 1) {
|
|
445
|
+
ctx.globalAlpha = node.opacity * fillOpacity;
|
|
446
|
+
drawNodeShape(ctx, node.shape, bounds, fillColor, void 0, cornerRadius);
|
|
447
|
+
ctx.globalAlpha = node.opacity;
|
|
448
|
+
drawNodeShape(ctx, node.shape, bounds, "rgba(0,0,0,0)", borderColor, cornerRadius);
|
|
449
|
+
} else {
|
|
450
|
+
ctx.globalAlpha = node.opacity;
|
|
451
|
+
drawNodeShape(ctx, node.shape, bounds, fillColor, borderColor, cornerRadius);
|
|
452
|
+
}
|
|
453
|
+
const headingFont = resolveFont(theme.fonts.heading, "heading");
|
|
454
|
+
const bodyFont = resolveFont(theme.fonts.body, "body");
|
|
455
|
+
const monoFont = resolveFont(theme.fonts.mono, "mono");
|
|
456
|
+
const centerX = bounds.x + bounds.width / 2;
|
|
457
|
+
const centerY = bounds.y + bounds.height / 2;
|
|
458
|
+
const insideTopShift = hasBadge && badgePosition === "inside-top" ? BADGE_INSIDE_TOP_EXTRA / 2 : 0;
|
|
459
|
+
const sublabelFontSize = Math.max(12, Math.round(labelFontSize * 0.68));
|
|
460
|
+
const sublabel2FontSize = node.sublabel2FontSize ?? 11;
|
|
461
|
+
const sublabel2Color = node.sublabel2Color ?? sublabelColor;
|
|
462
|
+
const lineCount = node.sublabel2 ? 3 : node.sublabel ? 2 : 1;
|
|
463
|
+
const labelToSublabelGap = Math.max(20, sublabelFontSize + 6);
|
|
464
|
+
const sublabelToSublabel2Gap = sublabel2FontSize + 4;
|
|
465
|
+
let textBlockHeight;
|
|
466
|
+
if (lineCount === 1) {
|
|
467
|
+
textBlockHeight = labelFontSize;
|
|
468
|
+
} else if (lineCount === 2) {
|
|
469
|
+
textBlockHeight = labelFontSize + labelToSublabelGap;
|
|
470
|
+
} else {
|
|
471
|
+
textBlockHeight = labelFontSize + labelToSublabelGap + sublabelToSublabel2Gap;
|
|
472
|
+
}
|
|
473
|
+
const labelY = lineCount === 1 ? centerY + labelFontSize * 0.3 + insideTopShift : centerY - textBlockHeight / 2 + labelFontSize * 0.8 + insideTopShift;
|
|
474
|
+
ctx.textAlign = "center";
|
|
475
|
+
applyFont(ctx, { size: labelFontSize, weight: 700, family: headingFont });
|
|
476
|
+
ctx.fillStyle = labelColor;
|
|
477
|
+
ctx.fillText(node.label, centerX, labelY);
|
|
478
|
+
let textBoundsY = bounds.y + bounds.height / 2 - 18;
|
|
479
|
+
let textBoundsHeight = 36;
|
|
480
|
+
if (node.sublabel) {
|
|
481
|
+
applyFont(ctx, { size: sublabelFontSize, weight: 500, family: bodyFont });
|
|
482
|
+
ctx.fillStyle = sublabelColor;
|
|
483
|
+
ctx.fillText(node.sublabel, centerX, labelY + labelToSublabelGap);
|
|
484
|
+
textBoundsY = bounds.y + bounds.height / 2 - 24;
|
|
485
|
+
textBoundsHeight = 56;
|
|
486
|
+
}
|
|
487
|
+
if (node.sublabel2) {
|
|
488
|
+
applyFont(ctx, { size: sublabel2FontSize, weight: 500, family: bodyFont });
|
|
489
|
+
ctx.fillStyle = sublabel2Color;
|
|
490
|
+
const sublabel2Y = node.sublabel ? labelY + labelToSublabelGap + sublabelToSublabel2Gap : labelY + labelToSublabelGap;
|
|
491
|
+
ctx.fillText(node.sublabel2, centerX, sublabel2Y);
|
|
492
|
+
textBoundsY = bounds.y + bounds.height / 2 - 30;
|
|
493
|
+
textBoundsHeight = 72;
|
|
494
|
+
}
|
|
495
|
+
if (hasBadge && node.badgeText) {
|
|
496
|
+
if (badgePosition === "inside-top") {
|
|
497
|
+
const badgeCenterY = bounds.y + BADGE_PILL_HEIGHT / 2 + 8;
|
|
498
|
+
renderBadgePill(
|
|
499
|
+
ctx,
|
|
500
|
+
centerX,
|
|
501
|
+
badgeCenterY,
|
|
502
|
+
node.badgeText,
|
|
503
|
+
badgeColor,
|
|
504
|
+
badgeBackground,
|
|
505
|
+
monoFont
|
|
506
|
+
);
|
|
507
|
+
} else {
|
|
508
|
+
const badgeCenterY = bounds.y - BADGE_PILL_HEIGHT / 2 - 4;
|
|
509
|
+
renderBadgePill(
|
|
510
|
+
ctx,
|
|
511
|
+
centerX,
|
|
512
|
+
badgeCenterY,
|
|
513
|
+
node.badgeText,
|
|
514
|
+
badgeColor,
|
|
515
|
+
badgeBackground,
|
|
516
|
+
monoFont
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
ctx.restore();
|
|
521
|
+
const effectiveBg = fillOpacity < 1 ? blendColorWithOpacity(fillColor, theme.background, fillOpacity) : fillColor;
|
|
522
|
+
return [
|
|
523
|
+
{
|
|
524
|
+
id: `flow-node-${node.id}`,
|
|
525
|
+
kind: "flow-node",
|
|
526
|
+
bounds,
|
|
527
|
+
foregroundColor: labelColor,
|
|
528
|
+
backgroundColor: effectiveBg
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
id: `flow-node-${node.id}-label`,
|
|
532
|
+
kind: "text",
|
|
533
|
+
bounds: {
|
|
534
|
+
x: bounds.x + 8,
|
|
535
|
+
y: textBoundsY,
|
|
536
|
+
width: bounds.width - 16,
|
|
537
|
+
height: textBoundsHeight
|
|
538
|
+
},
|
|
539
|
+
foregroundColor: labelColor,
|
|
540
|
+
backgroundColor: effectiveBg
|
|
541
|
+
}
|
|
542
|
+
];
|
|
543
|
+
}
|
|
544
|
+
|
|
89
545
|
// src/layout/estimates.ts
|
|
90
546
|
function estimateElementHeight(element) {
|
|
91
547
|
switch (element.type) {
|
|
@@ -184,33 +640,37 @@ function computeStackLayout(elements, config, safeFrame) {
|
|
|
184
640
|
|
|
185
641
|
// src/layout/elk.ts
|
|
186
642
|
function estimateFlowNodeSize(node) {
|
|
643
|
+
const badgeExtra = node.badgeText && (node.badgePosition ?? "inside-top") === "inside-top" ? BADGE_INSIDE_TOP_EXTRA : 0;
|
|
644
|
+
const sublabel2Extra = node.sublabel2 ? (node.sublabel2FontSize ?? 11) + 4 : 0;
|
|
645
|
+
const extra = badgeExtra + sublabel2Extra;
|
|
187
646
|
if (node.width && node.height) {
|
|
188
|
-
return { width: node.width, height: node.height };
|
|
647
|
+
return { width: node.width, height: node.height + extra };
|
|
189
648
|
}
|
|
190
649
|
if (node.width) {
|
|
650
|
+
const baseHeight = node.shape === "diamond" || node.shape === "circle" ? node.width : 60;
|
|
191
651
|
return {
|
|
192
652
|
width: node.width,
|
|
193
|
-
height:
|
|
653
|
+
height: baseHeight + extra
|
|
194
654
|
};
|
|
195
655
|
}
|
|
196
656
|
if (node.height) {
|
|
197
657
|
return {
|
|
198
658
|
width: node.shape === "diamond" || node.shape === "circle" ? node.height : 160,
|
|
199
|
-
height: node.height
|
|
659
|
+
height: node.height + extra
|
|
200
660
|
};
|
|
201
661
|
}
|
|
202
662
|
switch (node.shape) {
|
|
203
663
|
case "diamond":
|
|
204
664
|
case "circle":
|
|
205
|
-
return { width: 100, height: 100 };
|
|
665
|
+
return { width: 100 + extra, height: 100 + extra };
|
|
206
666
|
case "pill":
|
|
207
|
-
return { width: 180, height: 56 };
|
|
667
|
+
return { width: 180, height: 56 + extra };
|
|
208
668
|
case "cylinder":
|
|
209
|
-
return { width: 140, height: 92 };
|
|
669
|
+
return { width: 140, height: 92 + extra };
|
|
210
670
|
case "parallelogram":
|
|
211
|
-
return { width: 180, height: 72 };
|
|
671
|
+
return { width: 180, height: 72 + extra };
|
|
212
672
|
default:
|
|
213
|
-
return { width: 170, height: 64 };
|
|
673
|
+
return { width: 170, height: 64 + extra };
|
|
214
674
|
}
|
|
215
675
|
}
|
|
216
676
|
function splitLayoutFrames(safeFrame, direction, hasAuxiliary) {
|
|
@@ -328,6 +788,40 @@ function directionToElk(direction) {
|
|
|
328
788
|
return "DOWN";
|
|
329
789
|
}
|
|
330
790
|
}
|
|
791
|
+
function radialCompactionToElk(compaction) {
|
|
792
|
+
switch (compaction) {
|
|
793
|
+
case "radial":
|
|
794
|
+
return "RADIAL_COMPACTION";
|
|
795
|
+
case "wedge":
|
|
796
|
+
return "WEDGE_COMPACTION";
|
|
797
|
+
default:
|
|
798
|
+
return "NONE";
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
function radialSortByToElk(sortBy) {
|
|
802
|
+
switch (sortBy) {
|
|
803
|
+
case "connections":
|
|
804
|
+
return "POLAR_COORDINATE";
|
|
805
|
+
default:
|
|
806
|
+
return "ID";
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
function buildRadialOptions(config) {
|
|
810
|
+
const options = {};
|
|
811
|
+
if (config.radialRoot) {
|
|
812
|
+
options["elk.radial.centerOnRoot"] = "true";
|
|
813
|
+
}
|
|
814
|
+
if (config.radialRadius != null) {
|
|
815
|
+
options["elk.radial.radius"] = String(config.radialRadius);
|
|
816
|
+
}
|
|
817
|
+
if (config.radialCompaction) {
|
|
818
|
+
options["elk.radial.compaction.strategy"] = radialCompactionToElk(config.radialCompaction);
|
|
819
|
+
}
|
|
820
|
+
if (config.radialSortBy) {
|
|
821
|
+
options["elk.radial.orderId"] = radialSortByToElk(config.radialSortBy);
|
|
822
|
+
}
|
|
823
|
+
return options;
|
|
824
|
+
}
|
|
331
825
|
function fallbackForNoFlowNodes(nonFlow, safeFrame) {
|
|
332
826
|
const fallbackConfig = {
|
|
333
827
|
mode: "stack",
|
|
@@ -363,6 +857,11 @@ async function computeElkLayout(elements, config, safeFrame) {
|
|
|
363
857
|
elkNodeSizes.set(node.id, estimateFlowNodeSize(node));
|
|
364
858
|
}
|
|
365
859
|
const edgeIdToRouteKey = /* @__PURE__ */ new Map();
|
|
860
|
+
const radialOptions = config.algorithm === "radial" ? buildRadialOptions(config) : {};
|
|
861
|
+
const orderedFlowNodes = config.radialRoot && config.algorithm === "radial" ? [
|
|
862
|
+
...flowNodes.filter((node) => node.id === config.radialRoot),
|
|
863
|
+
...flowNodes.filter((node) => node.id !== config.radialRoot)
|
|
864
|
+
] : flowNodes;
|
|
366
865
|
const elkGraph = {
|
|
367
866
|
id: "root",
|
|
368
867
|
layoutOptions: {
|
|
@@ -372,9 +871,10 @@ async function computeElkLayout(elements, config, safeFrame) {
|
|
|
372
871
|
"elk.layered.spacing.nodeNodeBetweenLayers": String(config.rankSpacing),
|
|
373
872
|
"elk.edgeRouting": edgeRoutingToElk(config.edgeRouting),
|
|
374
873
|
...config.aspectRatio ? { "elk.aspectRatio": String(config.aspectRatio) } : {},
|
|
375
|
-
...config.algorithm === "stress" ? { "elk.stress.desiredEdgeLength": String(config.rankSpacing + config.nodeSpacing) } : {}
|
|
874
|
+
...config.algorithm === "stress" ? { "elk.stress.desiredEdgeLength": String(config.rankSpacing + config.nodeSpacing) } : {},
|
|
875
|
+
...radialOptions
|
|
376
876
|
},
|
|
377
|
-
children:
|
|
877
|
+
children: orderedFlowNodes.map((node) => {
|
|
378
878
|
const size = elkNodeSizes.get(node.id) ?? { width: 160, height: 60 };
|
|
379
879
|
return {
|
|
380
880
|
id: node.id,
|
|
@@ -598,275 +1098,93 @@ function roundedRectPath(ctx, x, y, width, height, radius) {
|
|
|
598
1098
|
const safeRadius = Math.max(0, Math.min(radius, width / 2, height / 2));
|
|
599
1099
|
ctx.beginPath();
|
|
600
1100
|
ctx.moveTo(x + safeRadius, y);
|
|
601
|
-
ctx.lineTo(x + width - safeRadius, y);
|
|
602
|
-
ctx.quadraticCurveTo(x + width, y, x + width, y + safeRadius);
|
|
603
|
-
ctx.lineTo(x + width, y + height - safeRadius);
|
|
604
|
-
ctx.quadraticCurveTo(x + width, y + height, x + width - safeRadius, y + height);
|
|
605
|
-
ctx.lineTo(x + safeRadius, y + height);
|
|
606
|
-
ctx.quadraticCurveTo(x, y + height, x, y + height - safeRadius);
|
|
607
|
-
ctx.lineTo(x, y + safeRadius);
|
|
608
|
-
ctx.quadraticCurveTo(x, y, x + safeRadius, y);
|
|
609
|
-
ctx.closePath();
|
|
610
|
-
}
|
|
611
|
-
function parseHexColor(color) {
|
|
612
|
-
const normalized = color.startsWith("#") ? color.slice(1) : color;
|
|
613
|
-
if (normalized.length !== 6 && normalized.length !== 8) {
|
|
614
|
-
throw new Error(`Expected #RRGGBB or #RRGGBBAA color, received ${color}`);
|
|
615
|
-
}
|
|
616
|
-
const parseChannel2 = (offset) => Number.parseInt(normalized.slice(offset, offset + 2), 16);
|
|
617
|
-
return {
|
|
618
|
-
r: parseChannel2(0),
|
|
619
|
-
g: parseChannel2(2),
|
|
620
|
-
b: parseChannel2(4),
|
|
621
|
-
a: normalized.length === 8 ? parseChannel2(6) / 255 : 1
|
|
622
|
-
};
|
|
623
|
-
}
|
|
624
|
-
function withAlpha(color, alpha) {
|
|
625
|
-
const parsed = parseHexColor(color);
|
|
626
|
-
const effectiveAlpha = clamp01(parsed.a * alpha);
|
|
627
|
-
return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${effectiveAlpha})`;
|
|
628
|
-
}
|
|
629
|
-
function drawGradientRect(ctx, rect, gradient, borderRadius = 0) {
|
|
630
|
-
const fill = gradient.type === "linear" ? createLinearRectGradient(ctx, rect, gradient.angle ?? 180) : ctx.createRadialGradient(
|
|
631
|
-
rect.x + rect.width / 2,
|
|
632
|
-
rect.y + rect.height / 2,
|
|
633
|
-
0,
|
|
634
|
-
rect.x + rect.width / 2,
|
|
635
|
-
rect.y + rect.height / 2,
|
|
636
|
-
Math.max(rect.width, rect.height) / 2
|
|
637
|
-
);
|
|
638
|
-
addGradientStops(fill, gradient.stops);
|
|
639
|
-
ctx.save();
|
|
640
|
-
ctx.fillStyle = fill;
|
|
641
|
-
if (borderRadius > 0) {
|
|
642
|
-
roundedRectPath(ctx, rect.x, rect.y, rect.width, rect.height, borderRadius);
|
|
643
|
-
ctx.fill();
|
|
644
|
-
} else {
|
|
645
|
-
ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
646
|
-
}
|
|
647
|
-
ctx.restore();
|
|
648
|
-
}
|
|
649
|
-
function drawRainbowRule(ctx, x, y, width, thickness = 2, colors = [...DEFAULT_RAINBOW_COLORS], borderRadius = thickness / 2) {
|
|
650
|
-
if (width <= 0 || thickness <= 0) {
|
|
651
|
-
return;
|
|
652
|
-
}
|
|
653
|
-
const gradient = ctx.createLinearGradient(x, y, x + width, y);
|
|
654
|
-
const stops = colors.length >= 2 ? colors : [...DEFAULT_RAINBOW_COLORS];
|
|
655
|
-
for (const [index, color] of stops.entries()) {
|
|
656
|
-
gradient.addColorStop(index / (stops.length - 1), color);
|
|
657
|
-
}
|
|
658
|
-
const ruleTop = y - thickness / 2;
|
|
659
|
-
ctx.save();
|
|
660
|
-
roundedRectPath(ctx, x, ruleTop, width, thickness, borderRadius);
|
|
661
|
-
ctx.fillStyle = gradient;
|
|
662
|
-
ctx.fill();
|
|
663
|
-
ctx.restore();
|
|
664
|
-
}
|
|
665
|
-
function drawVignette(ctx, width, height, intensity = 0.3, color = "#000000") {
|
|
666
|
-
if (width <= 0 || height <= 0 || intensity <= 0) {
|
|
667
|
-
return;
|
|
668
|
-
}
|
|
669
|
-
const centerX = width / 2;
|
|
670
|
-
const centerY = height / 2;
|
|
671
|
-
const outerRadius = Math.max(width, height) / 2;
|
|
672
|
-
const innerRadius = Math.min(width, height) * 0.2;
|
|
673
|
-
const vignette = ctx.createRadialGradient(
|
|
674
|
-
centerX,
|
|
675
|
-
centerY,
|
|
676
|
-
innerRadius,
|
|
677
|
-
centerX,
|
|
678
|
-
centerY,
|
|
679
|
-
outerRadius
|
|
680
|
-
);
|
|
681
|
-
vignette.addColorStop(0, withAlpha(color, 0));
|
|
682
|
-
vignette.addColorStop(0.6, withAlpha(color, 0));
|
|
683
|
-
vignette.addColorStop(1, withAlpha(color, clamp01(intensity)));
|
|
684
|
-
ctx.save();
|
|
685
|
-
ctx.fillStyle = vignette;
|
|
686
|
-
ctx.fillRect(0, 0, width, height);
|
|
687
|
-
ctx.restore();
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
// src/primitives/shapes.ts
|
|
691
|
-
function roundRectPath(ctx, rect, radius) {
|
|
692
|
-
const r = Math.max(0, Math.min(radius, rect.width / 2, rect.height / 2));
|
|
693
|
-
const right = rect.x + rect.width;
|
|
694
|
-
const bottom = rect.y + rect.height;
|
|
695
|
-
ctx.beginPath();
|
|
696
|
-
ctx.moveTo(rect.x + r, rect.y);
|
|
697
|
-
ctx.lineTo(right - r, rect.y);
|
|
698
|
-
ctx.quadraticCurveTo(right, rect.y, right, rect.y + r);
|
|
699
|
-
ctx.lineTo(right, bottom - r);
|
|
700
|
-
ctx.quadraticCurveTo(right, bottom, right - r, bottom);
|
|
701
|
-
ctx.lineTo(rect.x + r, bottom);
|
|
702
|
-
ctx.quadraticCurveTo(rect.x, bottom, rect.x, bottom - r);
|
|
703
|
-
ctx.lineTo(rect.x, rect.y + r);
|
|
704
|
-
ctx.quadraticCurveTo(rect.x, rect.y, rect.x + r, rect.y);
|
|
705
|
-
ctx.closePath();
|
|
706
|
-
}
|
|
707
|
-
function fillAndStroke(ctx, fill, stroke) {
|
|
708
|
-
ctx.fillStyle = fill;
|
|
709
|
-
ctx.fill();
|
|
710
|
-
if (stroke) {
|
|
711
|
-
ctx.strokeStyle = stroke;
|
|
712
|
-
ctx.stroke();
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
function drawRoundedRect(ctx, rect, radius, fill, stroke) {
|
|
716
|
-
roundRectPath(ctx, rect, radius);
|
|
717
|
-
fillAndStroke(ctx, fill, stroke);
|
|
718
|
-
}
|
|
719
|
-
function drawCircle(ctx, center2, radius, fill, stroke) {
|
|
720
|
-
ctx.beginPath();
|
|
721
|
-
ctx.arc(center2.x, center2.y, Math.max(0, radius), 0, Math.PI * 2);
|
|
722
|
-
ctx.closePath();
|
|
723
|
-
fillAndStroke(ctx, fill, stroke);
|
|
724
|
-
}
|
|
725
|
-
function drawDiamond(ctx, bounds, fill, stroke) {
|
|
726
|
-
const cx = bounds.x + bounds.width / 2;
|
|
727
|
-
const cy = bounds.y + bounds.height / 2;
|
|
728
|
-
ctx.beginPath();
|
|
729
|
-
ctx.moveTo(cx, bounds.y);
|
|
730
|
-
ctx.lineTo(bounds.x + bounds.width, cy);
|
|
731
|
-
ctx.lineTo(cx, bounds.y + bounds.height);
|
|
732
|
-
ctx.lineTo(bounds.x, cy);
|
|
733
|
-
ctx.closePath();
|
|
734
|
-
fillAndStroke(ctx, fill, stroke);
|
|
735
|
-
}
|
|
736
|
-
function drawPill(ctx, bounds, fill, stroke) {
|
|
737
|
-
drawRoundedRect(ctx, bounds, Math.min(bounds.width, bounds.height) / 2, fill, stroke);
|
|
738
|
-
}
|
|
739
|
-
function drawEllipse(ctx, bounds, fill, stroke) {
|
|
740
|
-
const cx = bounds.x + bounds.width / 2;
|
|
741
|
-
const cy = bounds.y + bounds.height / 2;
|
|
742
|
-
ctx.beginPath();
|
|
743
|
-
ctx.ellipse(
|
|
744
|
-
cx,
|
|
745
|
-
cy,
|
|
746
|
-
Math.max(0, bounds.width / 2),
|
|
747
|
-
Math.max(0, bounds.height / 2),
|
|
748
|
-
0,
|
|
749
|
-
0,
|
|
750
|
-
Math.PI * 2
|
|
751
|
-
);
|
|
752
|
-
ctx.closePath();
|
|
753
|
-
fillAndStroke(ctx, fill, stroke);
|
|
754
|
-
}
|
|
755
|
-
function drawCylinder(ctx, bounds, fill, stroke) {
|
|
756
|
-
const rx = Math.max(2, bounds.width / 2);
|
|
757
|
-
const ry = Math.max(2, Math.min(bounds.height * 0.18, 16));
|
|
758
|
-
const cx = bounds.x + bounds.width / 2;
|
|
759
|
-
const topCy = bounds.y + ry;
|
|
760
|
-
const bottomCy = bounds.y + bounds.height - ry;
|
|
761
|
-
ctx.beginPath();
|
|
762
|
-
ctx.moveTo(bounds.x, topCy);
|
|
763
|
-
ctx.ellipse(cx, topCy, rx, ry, 0, Math.PI, 0, true);
|
|
764
|
-
ctx.lineTo(bounds.x + bounds.width, bottomCy);
|
|
765
|
-
ctx.ellipse(cx, bottomCy, rx, ry, 0, 0, Math.PI, false);
|
|
766
|
-
ctx.closePath();
|
|
767
|
-
fillAndStroke(ctx, fill, stroke);
|
|
768
|
-
if (stroke) {
|
|
769
|
-
ctx.beginPath();
|
|
770
|
-
ctx.ellipse(cx, topCy, rx, ry, 0, 0, Math.PI * 2);
|
|
771
|
-
ctx.closePath();
|
|
772
|
-
ctx.strokeStyle = stroke;
|
|
773
|
-
ctx.stroke();
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
function drawParallelogram(ctx, bounds, fill, stroke, skew) {
|
|
777
|
-
const maxSkew = bounds.width * 0.45;
|
|
778
|
-
const skewX = Math.max(-maxSkew, Math.min(maxSkew, skew ?? bounds.width * 0.18));
|
|
779
|
-
ctx.beginPath();
|
|
780
|
-
ctx.moveTo(bounds.x + skewX, bounds.y);
|
|
781
|
-
ctx.lineTo(bounds.x + bounds.width, bounds.y);
|
|
782
|
-
ctx.lineTo(bounds.x + bounds.width - skewX, bounds.y + bounds.height);
|
|
783
|
-
ctx.lineTo(bounds.x, bounds.y + bounds.height);
|
|
1101
|
+
ctx.lineTo(x + width - safeRadius, y);
|
|
1102
|
+
ctx.quadraticCurveTo(x + width, y, x + width, y + safeRadius);
|
|
1103
|
+
ctx.lineTo(x + width, y + height - safeRadius);
|
|
1104
|
+
ctx.quadraticCurveTo(x + width, y + height, x + width - safeRadius, y + height);
|
|
1105
|
+
ctx.lineTo(x + safeRadius, y + height);
|
|
1106
|
+
ctx.quadraticCurveTo(x, y + height, x, y + height - safeRadius);
|
|
1107
|
+
ctx.lineTo(x, y + safeRadius);
|
|
1108
|
+
ctx.quadraticCurveTo(x, y, x + safeRadius, y);
|
|
784
1109
|
ctx.closePath();
|
|
785
|
-
fillAndStroke(ctx, fill, stroke);
|
|
786
1110
|
}
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
if (SUPPORTED_FONT_FAMILIES.has(requested)) {
|
|
792
|
-
return requested;
|
|
793
|
-
}
|
|
794
|
-
if (role === "mono" || /mono|code|terminal|console/iu.test(requested)) {
|
|
795
|
-
return "JetBrains Mono";
|
|
796
|
-
}
|
|
797
|
-
if (role === "heading" || /display|grotesk|headline/iu.test(requested)) {
|
|
798
|
-
return "Space Grotesk";
|
|
1111
|
+
function parseHexColor2(color) {
|
|
1112
|
+
const normalized = color.startsWith("#") ? color.slice(1) : color;
|
|
1113
|
+
if (normalized.length !== 6 && normalized.length !== 8) {
|
|
1114
|
+
throw new Error(`Expected #RRGGBB or #RRGGBBAA color, received ${color}`);
|
|
799
1115
|
}
|
|
800
|
-
|
|
1116
|
+
const parseChannel2 = (offset) => Number.parseInt(normalized.slice(offset, offset + 2), 16);
|
|
1117
|
+
return {
|
|
1118
|
+
r: parseChannel2(0),
|
|
1119
|
+
g: parseChannel2(2),
|
|
1120
|
+
b: parseChannel2(4),
|
|
1121
|
+
a: normalized.length === 8 ? parseChannel2(6) / 255 : 1
|
|
1122
|
+
};
|
|
801
1123
|
}
|
|
802
|
-
function
|
|
803
|
-
|
|
1124
|
+
function withAlpha(color, alpha) {
|
|
1125
|
+
const parsed = parseHexColor2(color);
|
|
1126
|
+
const effectiveAlpha = clamp01(parsed.a * alpha);
|
|
1127
|
+
return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${effectiveAlpha})`;
|
|
804
1128
|
}
|
|
805
|
-
function
|
|
806
|
-
const
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
} else {
|
|
823
|
-
lines.push(word);
|
|
824
|
-
current = "";
|
|
825
|
-
}
|
|
826
|
-
if (lines.length >= maxLines) {
|
|
827
|
-
break;
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
if (lines.length < maxLines && current.length > 0) {
|
|
831
|
-
lines.push(current);
|
|
1129
|
+
function drawGradientRect(ctx, rect, gradient, borderRadius = 0) {
|
|
1130
|
+
const fill = gradient.type === "linear" ? createLinearRectGradient(ctx, rect, gradient.angle ?? 180) : ctx.createRadialGradient(
|
|
1131
|
+
rect.x + rect.width / 2,
|
|
1132
|
+
rect.y + rect.height / 2,
|
|
1133
|
+
0,
|
|
1134
|
+
rect.x + rect.width / 2,
|
|
1135
|
+
rect.y + rect.height / 2,
|
|
1136
|
+
Math.max(rect.width, rect.height) / 2
|
|
1137
|
+
);
|
|
1138
|
+
addGradientStops(fill, gradient.stops);
|
|
1139
|
+
ctx.save();
|
|
1140
|
+
ctx.fillStyle = fill;
|
|
1141
|
+
if (borderRadius > 0) {
|
|
1142
|
+
roundedRectPath(ctx, rect.x, rect.y, rect.width, rect.height, borderRadius);
|
|
1143
|
+
ctx.fill();
|
|
1144
|
+
} else {
|
|
1145
|
+
ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
832
1146
|
}
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
1147
|
+
ctx.restore();
|
|
1148
|
+
}
|
|
1149
|
+
function drawRainbowRule(ctx, x, y, width, thickness = 2, colors = [...DEFAULT_RAINBOW_COLORS], borderRadius = thickness / 2) {
|
|
1150
|
+
if (width <= 0 || thickness <= 0) {
|
|
1151
|
+
return;
|
|
836
1152
|
}
|
|
837
|
-
const
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
1153
|
+
const gradient = ctx.createLinearGradient(x, y, x + width, y);
|
|
1154
|
+
const stops = colors.length >= 2 ? colors : [...DEFAULT_RAINBOW_COLORS];
|
|
1155
|
+
for (const [index, color] of stops.entries()) {
|
|
1156
|
+
gradient.addColorStop(index / (stops.length - 1), color);
|
|
841
1157
|
}
|
|
842
|
-
|
|
843
|
-
|
|
1158
|
+
const ruleTop = y - thickness / 2;
|
|
1159
|
+
ctx.save();
|
|
1160
|
+
roundedRectPath(ctx, x, ruleTop, width, thickness, borderRadius);
|
|
1161
|
+
ctx.fillStyle = gradient;
|
|
1162
|
+
ctx.fill();
|
|
1163
|
+
ctx.restore();
|
|
844
1164
|
}
|
|
845
|
-
function
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
ctx.fillStyle = options.color;
|
|
849
|
-
for (const [index, line] of wrapped.lines.entries()) {
|
|
850
|
-
ctx.fillText(line, options.x, options.y + index * options.lineHeight);
|
|
1165
|
+
function drawVignette(ctx, width, height, intensity = 0.3, color = "#000000") {
|
|
1166
|
+
if (width <= 0 || height <= 0 || intensity <= 0) {
|
|
1167
|
+
return;
|
|
851
1168
|
}
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
ctx.
|
|
868
|
-
ctx.
|
|
869
|
-
|
|
1169
|
+
const centerX = width / 2;
|
|
1170
|
+
const centerY = height / 2;
|
|
1171
|
+
const outerRadius = Math.max(width, height) / 2;
|
|
1172
|
+
const innerRadius = Math.min(width, height) * 0.2;
|
|
1173
|
+
const vignette = ctx.createRadialGradient(
|
|
1174
|
+
centerX,
|
|
1175
|
+
centerY,
|
|
1176
|
+
innerRadius,
|
|
1177
|
+
centerX,
|
|
1178
|
+
centerY,
|
|
1179
|
+
outerRadius
|
|
1180
|
+
);
|
|
1181
|
+
vignette.addColorStop(0, withAlpha(color, 0));
|
|
1182
|
+
vignette.addColorStop(0.6, withAlpha(color, 0));
|
|
1183
|
+
vignette.addColorStop(1, withAlpha(color, clamp01(intensity)));
|
|
1184
|
+
ctx.save();
|
|
1185
|
+
ctx.fillStyle = vignette;
|
|
1186
|
+
ctx.fillRect(0, 0, width, height);
|
|
1187
|
+
ctx.restore();
|
|
870
1188
|
}
|
|
871
1189
|
|
|
872
1190
|
// src/renderers/card.ts
|
|
@@ -981,36 +1299,6 @@ function renderCard(ctx, card, rect, theme) {
|
|
|
981
1299
|
return elements;
|
|
982
1300
|
}
|
|
983
1301
|
|
|
984
|
-
// src/utils/color.ts
|
|
985
|
-
function parseChannel(hex, offset) {
|
|
986
|
-
return Number.parseInt(hex.slice(offset, offset + 2), 16);
|
|
987
|
-
}
|
|
988
|
-
function parseHexColor2(hexColor) {
|
|
989
|
-
const normalized = hexColor.startsWith("#") ? hexColor.slice(1) : hexColor;
|
|
990
|
-
if (normalized.length !== 6 && normalized.length !== 8) {
|
|
991
|
-
throw new Error(`Unsupported color format: ${hexColor}`);
|
|
992
|
-
}
|
|
993
|
-
return {
|
|
994
|
-
r: parseChannel(normalized, 0),
|
|
995
|
-
g: parseChannel(normalized, 2),
|
|
996
|
-
b: parseChannel(normalized, 4)
|
|
997
|
-
};
|
|
998
|
-
}
|
|
999
|
-
function srgbToLinear(channel) {
|
|
1000
|
-
const normalized = channel / 255;
|
|
1001
|
-
if (normalized <= 0.03928) {
|
|
1002
|
-
return normalized / 12.92;
|
|
1003
|
-
}
|
|
1004
|
-
return ((normalized + 0.055) / 1.055) ** 2.4;
|
|
1005
|
-
}
|
|
1006
|
-
function relativeLuminance(hexColor) {
|
|
1007
|
-
const rgb = parseHexColor2(hexColor);
|
|
1008
|
-
const r = srgbToLinear(rgb.r);
|
|
1009
|
-
const g = srgbToLinear(rgb.g);
|
|
1010
|
-
const b = srgbToLinear(rgb.b);
|
|
1011
|
-
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
1302
|
// src/primitives/window-chrome.ts
|
|
1015
1303
|
var WINDOW_CHROME_HEIGHT = 34;
|
|
1016
1304
|
var WINDOW_CHROME_LEFT_MARGIN = 14;
|
|
@@ -1160,7 +1448,17 @@ async function highlightCode(code, language, themeName) {
|
|
|
1160
1448
|
|
|
1161
1449
|
// src/themes/builtin.ts
|
|
1162
1450
|
import { z } from "zod";
|
|
1163
|
-
var colorHexSchema = z.string().
|
|
1451
|
+
var colorHexSchema = z.string().refine(
|
|
1452
|
+
(v) => {
|
|
1453
|
+
try {
|
|
1454
|
+
normalizeColor(v);
|
|
1455
|
+
return true;
|
|
1456
|
+
} catch {
|
|
1457
|
+
return false;
|
|
1458
|
+
}
|
|
1459
|
+
},
|
|
1460
|
+
{ message: "Expected #RRGGBB, #RRGGBBAA, rgb(), or rgba() color" }
|
|
1461
|
+
).transform((v) => normalizeColor(v));
|
|
1164
1462
|
var fontFamilySchema = z.string().min(1).max(120);
|
|
1165
1463
|
var codeThemeSchema = z.object({
|
|
1166
1464
|
background: colorHexSchema,
|
|
@@ -2444,92 +2742,6 @@ function renderDrawCommands(ctx, commands, theme) {
|
|
|
2444
2742
|
return rendered;
|
|
2445
2743
|
}
|
|
2446
2744
|
|
|
2447
|
-
// src/renderers/flow-node.ts
|
|
2448
|
-
function renderFlowNode(ctx, node, bounds, theme) {
|
|
2449
|
-
const fillColor = node.color ?? theme.surfaceElevated;
|
|
2450
|
-
const borderColor = node.borderColor ?? theme.border;
|
|
2451
|
-
const borderWidth = node.borderWidth ?? 2;
|
|
2452
|
-
const cornerRadius = node.cornerRadius ?? 16;
|
|
2453
|
-
const labelColor = node.labelColor ?? theme.text;
|
|
2454
|
-
const sublabelColor = node.sublabelColor ?? theme.textMuted;
|
|
2455
|
-
const labelFontSize = node.labelFontSize ?? 20;
|
|
2456
|
-
ctx.save();
|
|
2457
|
-
ctx.globalAlpha = node.opacity;
|
|
2458
|
-
ctx.lineWidth = borderWidth;
|
|
2459
|
-
switch (node.shape) {
|
|
2460
|
-
case "box":
|
|
2461
|
-
drawRoundedRect(ctx, bounds, 0, fillColor, borderColor);
|
|
2462
|
-
break;
|
|
2463
|
-
case "rounded-box":
|
|
2464
|
-
drawRoundedRect(ctx, bounds, cornerRadius, fillColor, borderColor);
|
|
2465
|
-
break;
|
|
2466
|
-
case "diamond":
|
|
2467
|
-
drawDiamond(ctx, bounds, fillColor, borderColor);
|
|
2468
|
-
break;
|
|
2469
|
-
case "circle": {
|
|
2470
|
-
const radius = Math.min(bounds.width, bounds.height) / 2;
|
|
2471
|
-
drawCircle(
|
|
2472
|
-
ctx,
|
|
2473
|
-
{ x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 },
|
|
2474
|
-
radius,
|
|
2475
|
-
fillColor,
|
|
2476
|
-
borderColor
|
|
2477
|
-
);
|
|
2478
|
-
break;
|
|
2479
|
-
}
|
|
2480
|
-
case "pill":
|
|
2481
|
-
drawPill(ctx, bounds, fillColor, borderColor);
|
|
2482
|
-
break;
|
|
2483
|
-
case "cylinder":
|
|
2484
|
-
drawCylinder(ctx, bounds, fillColor, borderColor);
|
|
2485
|
-
break;
|
|
2486
|
-
case "parallelogram":
|
|
2487
|
-
drawParallelogram(ctx, bounds, fillColor, borderColor);
|
|
2488
|
-
break;
|
|
2489
|
-
}
|
|
2490
|
-
const headingFont = resolveFont(theme.fonts.heading, "heading");
|
|
2491
|
-
const bodyFont = resolveFont(theme.fonts.body, "body");
|
|
2492
|
-
const centerX = bounds.x + bounds.width / 2;
|
|
2493
|
-
const centerY = bounds.y + bounds.height / 2;
|
|
2494
|
-
const labelY = node.sublabel ? centerY - Math.max(4, labelFontSize * 0.2) : centerY + labelFontSize * 0.3;
|
|
2495
|
-
ctx.textAlign = "center";
|
|
2496
|
-
applyFont(ctx, { size: labelFontSize, weight: 700, family: headingFont });
|
|
2497
|
-
ctx.fillStyle = labelColor;
|
|
2498
|
-
ctx.fillText(node.label, centerX, labelY);
|
|
2499
|
-
let textBoundsY = bounds.y + bounds.height / 2 - 18;
|
|
2500
|
-
let textBoundsHeight = 36;
|
|
2501
|
-
if (node.sublabel) {
|
|
2502
|
-
const sublabelFontSize = Math.max(12, Math.round(labelFontSize * 0.68));
|
|
2503
|
-
applyFont(ctx, { size: sublabelFontSize, weight: 500, family: bodyFont });
|
|
2504
|
-
ctx.fillStyle = sublabelColor;
|
|
2505
|
-
ctx.fillText(node.sublabel, centerX, labelY + Math.max(20, sublabelFontSize + 6));
|
|
2506
|
-
textBoundsY = bounds.y + bounds.height / 2 - 24;
|
|
2507
|
-
textBoundsHeight = 56;
|
|
2508
|
-
}
|
|
2509
|
-
ctx.restore();
|
|
2510
|
-
return [
|
|
2511
|
-
{
|
|
2512
|
-
id: `flow-node-${node.id}`,
|
|
2513
|
-
kind: "flow-node",
|
|
2514
|
-
bounds,
|
|
2515
|
-
foregroundColor: labelColor,
|
|
2516
|
-
backgroundColor: fillColor
|
|
2517
|
-
},
|
|
2518
|
-
{
|
|
2519
|
-
id: `flow-node-${node.id}-label`,
|
|
2520
|
-
kind: "text",
|
|
2521
|
-
bounds: {
|
|
2522
|
-
x: bounds.x + 8,
|
|
2523
|
-
y: textBoundsY,
|
|
2524
|
-
width: bounds.width - 16,
|
|
2525
|
-
height: textBoundsHeight
|
|
2526
|
-
},
|
|
2527
|
-
foregroundColor: labelColor,
|
|
2528
|
-
backgroundColor: fillColor
|
|
2529
|
-
}
|
|
2530
|
-
];
|
|
2531
|
-
}
|
|
2532
|
-
|
|
2533
2745
|
// src/renderers/image.ts
|
|
2534
2746
|
import { loadImage } from "@napi-rs/canvas";
|
|
2535
2747
|
function roundedRectPath2(ctx, bounds, radius) {
|
|
@@ -2784,7 +2996,17 @@ function renderTextElement(ctx, textEl, bounds, theme) {
|
|
|
2784
2996
|
|
|
2785
2997
|
// src/spec.schema.ts
|
|
2786
2998
|
import { z as z2 } from "zod";
|
|
2787
|
-
var colorHexSchema2 = z2.string().
|
|
2999
|
+
var colorHexSchema2 = z2.string().refine(
|
|
3000
|
+
(v) => {
|
|
3001
|
+
try {
|
|
3002
|
+
normalizeColor(v);
|
|
3003
|
+
return true;
|
|
3004
|
+
} catch {
|
|
3005
|
+
return false;
|
|
3006
|
+
}
|
|
3007
|
+
},
|
|
3008
|
+
{ message: "Expected #RRGGBB, #RRGGBBAA, rgb(), or rgba() color" }
|
|
3009
|
+
).transform((v) => normalizeColor(v));
|
|
2788
3010
|
var gradientStopSchema = z2.object({
|
|
2789
3011
|
offset: z2.number().min(0).max(1),
|
|
2790
3012
|
color: colorHexSchema2
|
|
@@ -2975,6 +3197,9 @@ var flowNodeElementSchema = z2.object({
|
|
|
2975
3197
|
label: z2.string().min(1).max(200),
|
|
2976
3198
|
sublabel: z2.string().min(1).max(300).optional(),
|
|
2977
3199
|
sublabelColor: colorHexSchema2.optional(),
|
|
3200
|
+
sublabel2: z2.string().min(1).max(300).optional(),
|
|
3201
|
+
sublabel2Color: colorHexSchema2.optional(),
|
|
3202
|
+
sublabel2FontSize: z2.number().min(8).max(32).optional(),
|
|
2978
3203
|
labelColor: colorHexSchema2.optional(),
|
|
2979
3204
|
labelFontSize: z2.number().min(10).max(48).optional(),
|
|
2980
3205
|
color: colorHexSchema2.optional(),
|
|
@@ -2983,7 +3208,12 @@ var flowNodeElementSchema = z2.object({
|
|
|
2983
3208
|
cornerRadius: z2.number().min(0).max(64).optional(),
|
|
2984
3209
|
width: z2.number().int().min(40).max(800).optional(),
|
|
2985
3210
|
height: z2.number().int().min(30).max(600).optional(),
|
|
2986
|
-
|
|
3211
|
+
fillOpacity: z2.number().min(0).max(1).default(1),
|
|
3212
|
+
opacity: z2.number().min(0).max(1).default(1),
|
|
3213
|
+
badgeText: z2.string().min(1).max(32).optional(),
|
|
3214
|
+
badgeColor: colorHexSchema2.optional(),
|
|
3215
|
+
badgeBackground: colorHexSchema2.optional(),
|
|
3216
|
+
badgePosition: z2.enum(["top", "inside-top"]).default("inside-top")
|
|
2987
3217
|
}).strict();
|
|
2988
3218
|
var connectionElementSchema = z2.object({
|
|
2989
3219
|
type: z2.literal("connection"),
|
|
@@ -3072,7 +3302,15 @@ var autoLayoutConfigSchema = z2.object({
|
|
|
3072
3302
|
nodeSpacing: z2.number().int().min(0).max(512).default(80),
|
|
3073
3303
|
rankSpacing: z2.number().int().min(0).max(512).default(120),
|
|
3074
3304
|
edgeRouting: z2.enum(["orthogonal", "polyline", "spline"]).default("polyline"),
|
|
3075
|
-
aspectRatio: z2.number().min(0.5).max(3).optional()
|
|
3305
|
+
aspectRatio: z2.number().min(0.5).max(3).optional(),
|
|
3306
|
+
/** ID of the root node for radial layout. Only relevant when algorithm is 'radial'. */
|
|
3307
|
+
radialRoot: z2.string().min(1).max(120).optional(),
|
|
3308
|
+
/** Fixed radius in pixels for radial layout. Only relevant when algorithm is 'radial'. */
|
|
3309
|
+
radialRadius: z2.number().positive().optional(),
|
|
3310
|
+
/** Compaction strategy for radial layout. Only relevant when algorithm is 'radial'. */
|
|
3311
|
+
radialCompaction: z2.enum(["none", "radial", "wedge"]).optional(),
|
|
3312
|
+
/** Sort strategy for radial layout node ordering. Only relevant when algorithm is 'radial'. */
|
|
3313
|
+
radialSortBy: z2.enum(["id", "connections"]).optional()
|
|
3076
3314
|
}).strict();
|
|
3077
3315
|
var gridLayoutConfigSchema = z2.object({
|
|
3078
3316
|
mode: z2.literal("grid"),
|