@spectratools/graphic-designer-cli 0.12.3 → 0.14.1

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/renderer.js CHANGED
@@ -614,6 +614,8 @@ function estimateElementHeight(element) {
614
614
  return 130;
615
615
  case "image":
616
616
  return 220;
617
+ case "ring":
618
+ return element.radius * 2 + element.glowRadius * 2 + 16;
617
619
  case "connection":
618
620
  return 0;
619
621
  }
@@ -634,6 +636,8 @@ function estimateElementWidth(element) {
634
636
  return 280;
635
637
  case "image":
636
638
  return 320;
639
+ case "ring":
640
+ return element.radius * 2 + element.glowRadius * 2 + 16;
637
641
  case "connection":
638
642
  return 0;
639
643
  }
@@ -1248,6 +1252,30 @@ function drawRainbowRule(ctx, x, y, width, thickness = 2, colors = [...DEFAULT_R
1248
1252
  ctx.fill();
1249
1253
  ctx.restore();
1250
1254
  }
1255
+ function drawEdgeVignette(ctx, width, height, color = "#000000", topHeight = 35, bottomHeight = 55, topOpacity = 0.3, bottomOpacity = 0.4) {
1256
+ if (width <= 0 || height <= 0) {
1257
+ return;
1258
+ }
1259
+ if (topHeight > 0 && topOpacity > 0) {
1260
+ const topGradient = ctx.createLinearGradient(0, 0, 0, topHeight);
1261
+ topGradient.addColorStop(0, withAlpha2(color, clamp01(topOpacity)));
1262
+ topGradient.addColorStop(1, withAlpha2(color, 0));
1263
+ ctx.save();
1264
+ ctx.fillStyle = topGradient;
1265
+ ctx.fillRect(0, 0, width, topHeight);
1266
+ ctx.restore();
1267
+ }
1268
+ if (bottomHeight > 0 && bottomOpacity > 0) {
1269
+ const bottomY = height - bottomHeight;
1270
+ const bottomGradient = ctx.createLinearGradient(0, bottomY, 0, height);
1271
+ bottomGradient.addColorStop(0, withAlpha2(color, 0));
1272
+ bottomGradient.addColorStop(1, withAlpha2(color, clamp01(bottomOpacity)));
1273
+ ctx.save();
1274
+ ctx.fillStyle = bottomGradient;
1275
+ ctx.fillRect(0, bottomY, width, bottomHeight);
1276
+ ctx.restore();
1277
+ }
1278
+ }
1251
1279
  function drawVignette(ctx, width, height, intensity = 0.3, color = "#000000") {
1252
1280
  if (width <= 0 || height <= 0 || intensity <= 0) {
1253
1281
  return;
@@ -3330,10 +3358,118 @@ function renderDrawCommands(ctx, commands, theme) {
3330
3358
  });
3331
3359
  break;
3332
3360
  }
3361
+ case "stats-bar": {
3362
+ const barRect = renderStatsBar(ctx, command, theme);
3363
+ rendered.push({
3364
+ id,
3365
+ kind: "draw",
3366
+ bounds: barRect,
3367
+ foregroundColor: command.valueColor,
3368
+ backgroundColor: theme.background
3369
+ });
3370
+ break;
3371
+ }
3333
3372
  }
3334
3373
  }
3335
3374
  return rendered;
3336
3375
  }
3376
+ function renderStatsBar(ctx, command, theme) {
3377
+ const canvasWidth = ctx.canvas.width;
3378
+ const valueFontFamily = resolveDrawFont(theme, command.valueFontFamily);
3379
+ const labelFontFamily = resolveDrawFont(theme, command.labelFontFamily);
3380
+ const spaceWidth = 4;
3381
+ const measuredItems = [];
3382
+ for (const item of command.items) {
3383
+ applyFont(ctx, {
3384
+ size: command.valueFontSize,
3385
+ weight: command.valueFontWeight,
3386
+ family: valueFontFamily
3387
+ });
3388
+ const valueWidth = ctx.measureText(item.value).width;
3389
+ applyFont(ctx, {
3390
+ size: command.labelFontSize,
3391
+ weight: command.labelFontWeight,
3392
+ family: labelFontFamily
3393
+ });
3394
+ const labelWidth = ctx.measureText(item.label).width;
3395
+ measuredItems.push({
3396
+ valueWidth,
3397
+ labelWidth,
3398
+ totalWidth: valueWidth + spaceWidth + labelWidth
3399
+ });
3400
+ }
3401
+ let separatorWidth = 0;
3402
+ let separatorText = "";
3403
+ if (command.separator !== "none" && command.items.length > 1) {
3404
+ separatorText = command.separator === "dot" ? "\xB7" : "|";
3405
+ const sepFontSize = Math.max(command.valueFontSize, command.labelFontSize);
3406
+ applyFont(ctx, { size: sepFontSize, weight: 400, family: labelFontFamily });
3407
+ separatorWidth = ctx.measureText(separatorText).width;
3408
+ }
3409
+ const itemsWidth = measuredItems.reduce((sum, m) => sum + m.totalWidth, 0);
3410
+ const gapCount = command.items.length - 1;
3411
+ const totalWidth = itemsWidth + gapCount * (command.gap + separatorWidth);
3412
+ let cursorX = (canvasWidth - totalWidth) / 2;
3413
+ const startX = cursorX;
3414
+ applyFont(ctx, {
3415
+ size: command.valueFontSize,
3416
+ weight: command.valueFontWeight,
3417
+ family: valueFontFamily
3418
+ });
3419
+ const valueMetrics = ctx.measureText("M");
3420
+ const valueAscent = valueMetrics.actualBoundingBoxAscent || command.valueFontSize * 0.75;
3421
+ const valueDescent = valueMetrics.actualBoundingBoxDescent || command.valueFontSize * 0.25;
3422
+ applyFont(ctx, {
3423
+ size: command.labelFontSize,
3424
+ weight: command.labelFontWeight,
3425
+ family: labelFontFamily
3426
+ });
3427
+ const labelMetrics = ctx.measureText("M");
3428
+ const labelAscent = labelMetrics.actualBoundingBoxAscent || command.labelFontSize * 0.75;
3429
+ const labelDescent = labelMetrics.actualBoundingBoxDescent || command.labelFontSize * 0.25;
3430
+ const maxAscent = Math.max(valueAscent, labelAscent);
3431
+ const maxDescent = Math.max(valueDescent, labelDescent);
3432
+ withOpacity(ctx, command.opacity, () => {
3433
+ for (let i = 0; i < command.items.length; i++) {
3434
+ const item = command.items[i];
3435
+ const measured = measuredItems[i];
3436
+ applyFont(ctx, {
3437
+ size: command.valueFontSize,
3438
+ weight: command.valueFontWeight,
3439
+ family: valueFontFamily
3440
+ });
3441
+ ctx.fillStyle = command.valueColor;
3442
+ ctx.textAlign = "left";
3443
+ ctx.textBaseline = "alphabetic";
3444
+ ctx.fillText(item.value, cursorX, command.y);
3445
+ cursorX += measured.valueWidth + spaceWidth;
3446
+ applyFont(ctx, {
3447
+ size: command.labelFontSize,
3448
+ weight: command.labelFontWeight,
3449
+ family: labelFontFamily
3450
+ });
3451
+ ctx.fillStyle = command.labelColor;
3452
+ ctx.fillText(item.label, cursorX, command.y);
3453
+ cursorX += measured.labelWidth;
3454
+ if (i < command.items.length - 1 && command.separator !== "none") {
3455
+ const sepFontSize = Math.max(command.valueFontSize, command.labelFontSize);
3456
+ applyFont(ctx, { size: sepFontSize, weight: 400, family: labelFontFamily });
3457
+ ctx.fillStyle = command.separatorColor;
3458
+ ctx.textAlign = "center";
3459
+ ctx.fillText(separatorText, cursorX + command.gap / 2 + separatorWidth / 2, command.y);
3460
+ ctx.textAlign = "left";
3461
+ cursorX += command.gap + separatorWidth;
3462
+ }
3463
+ }
3464
+ });
3465
+ const height = Math.max(1, maxAscent + maxDescent);
3466
+ return {
3467
+ x: startX,
3468
+ y: command.y - maxAscent,
3469
+ width: Math.max(1, totalWidth),
3470
+ height
3471
+ };
3472
+ }
3337
3473
 
3338
3474
  // src/renderers/image.ts
3339
3475
  import { loadImage } from "@napi-rs/canvas";
@@ -3420,6 +3556,110 @@ async function renderImageElement(ctx, image, bounds, theme) {
3420
3556
  }
3421
3557
  }
3422
3558
 
3559
+ // src/renderers/ring.ts
3560
+ function renderRingElement(ctx, ring, bounds, theme) {
3561
+ const cx = bounds.x + bounds.width / 2;
3562
+ const cy = bounds.y + bounds.height / 2;
3563
+ const { radius, strokeWidth, segments } = ring;
3564
+ if (ring.glowRadius > 0) {
3565
+ const glowColor = ring.glowColor ?? segments[0].color;
3566
+ ctx.save();
3567
+ ctx.globalAlpha = 0.15;
3568
+ ctx.beginPath();
3569
+ ctx.arc(cx, cy, radius + ring.glowRadius, 0, Math.PI * 2);
3570
+ ctx.fillStyle = glowColor;
3571
+ ctx.fill();
3572
+ ctx.restore();
3573
+ }
3574
+ if (ring.fill || ring.fillOpacity > 0) {
3575
+ const fillColor = ring.fill ?? segments[0].color;
3576
+ ctx.save();
3577
+ ctx.globalAlpha = ring.fillOpacity;
3578
+ ctx.beginPath();
3579
+ ctx.arc(cx, cy, radius, 0, Math.PI * 2);
3580
+ ctx.fillStyle = fillColor;
3581
+ ctx.fill();
3582
+ ctx.restore();
3583
+ }
3584
+ ctx.save();
3585
+ ctx.globalAlpha = 0.2;
3586
+ ctx.beginPath();
3587
+ ctx.arc(cx, cy, radius, 0, Math.PI * 2);
3588
+ ctx.strokeStyle = theme.border;
3589
+ ctx.lineWidth = strokeWidth;
3590
+ ctx.stroke();
3591
+ ctx.restore();
3592
+ const segmentAngle = 2 * Math.PI / segments.length;
3593
+ for (let i = 0; i < segments.length; i++) {
3594
+ const startAngle = i * segmentAngle - Math.PI / 2;
3595
+ ctx.beginPath();
3596
+ ctx.arc(cx, cy, radius, startAngle, startAngle + segmentAngle);
3597
+ ctx.strokeStyle = segments[i].color;
3598
+ ctx.lineWidth = strokeWidth;
3599
+ ctx.stroke();
3600
+ }
3601
+ if (ring.showCycleArrows) {
3602
+ const arrowArcRadius = radius + strokeWidth + 4;
3603
+ const arrowColor = segments[0].color;
3604
+ const numArrows = Math.min(segments.length, 4);
3605
+ const arrowSpacing = 2 * Math.PI / numArrows;
3606
+ for (let i = 0; i < numArrows; i++) {
3607
+ const baseAngle = i * arrowSpacing - Math.PI / 2;
3608
+ const arcStart = baseAngle + 0.15;
3609
+ const arcEnd = baseAngle + arrowSpacing - 0.15;
3610
+ ctx.beginPath();
3611
+ ctx.arc(cx, cy, arrowArcRadius, arcStart, arcEnd);
3612
+ ctx.strokeStyle = arrowColor;
3613
+ ctx.lineWidth = Math.max(1, strokeWidth * 0.6);
3614
+ ctx.stroke();
3615
+ const headAngle = arcEnd;
3616
+ const headX = cx + arrowArcRadius * Math.cos(headAngle);
3617
+ const headY = cy + arrowArcRadius * Math.sin(headAngle);
3618
+ const tangentAngle = headAngle + Math.PI / 2;
3619
+ const headSize = Math.max(4, strokeWidth * 2);
3620
+ ctx.beginPath();
3621
+ ctx.moveTo(headX, headY);
3622
+ ctx.lineTo(
3623
+ headX - headSize * Math.cos(tangentAngle - 0.4),
3624
+ headY - headSize * Math.sin(tangentAngle - 0.4)
3625
+ );
3626
+ ctx.lineTo(
3627
+ headX - headSize * Math.cos(tangentAngle + 0.4),
3628
+ headY - headSize * Math.sin(tangentAngle + 0.4)
3629
+ );
3630
+ ctx.closePath();
3631
+ ctx.fillStyle = arrowColor;
3632
+ ctx.fill();
3633
+ }
3634
+ }
3635
+ if (ring.label) {
3636
+ const labelColor = ring.labelColor ?? theme.text;
3637
+ const labelSize = ring.labelSize;
3638
+ const bodyFont = resolveFont(theme.fonts.body, "body");
3639
+ applyFont(ctx, { size: labelSize, weight: 500, family: bodyFont });
3640
+ ctx.fillStyle = labelColor;
3641
+ ctx.textAlign = "center";
3642
+ ctx.textBaseline = "middle";
3643
+ const lines = ring.label.split("\\n");
3644
+ const lineHeight = labelSize * 1.3;
3645
+ const totalHeight = lines.length * lineHeight;
3646
+ const startY = cy - totalHeight / 2 + lineHeight / 2;
3647
+ for (let i = 0; i < lines.length; i++) {
3648
+ ctx.fillText(lines[i], cx, startY + i * lineHeight);
3649
+ }
3650
+ ctx.textAlign = "left";
3651
+ ctx.textBaseline = "alphabetic";
3652
+ }
3653
+ return [
3654
+ {
3655
+ id: `ring-${ring.id}`,
3656
+ kind: "shape",
3657
+ bounds,
3658
+ foregroundColor: segments[0].color
3659
+ }
3660
+ ];
3661
+ }
3662
+
3423
3663
  // src/renderers/shape.ts
3424
3664
  function renderShapeElement(ctx, shape, bounds, theme) {
3425
3665
  const fill = shape.fill ?? theme.surfaceMuted;
@@ -3779,6 +4019,27 @@ var drawTextRowSchema = z2.object({
3779
4019
  defaultColor: colorHexSchema2.default("#FFFFFF"),
3780
4020
  opacity: z2.number().min(0).max(1).default(1)
3781
4021
  }).strict();
4022
+ var drawStatsBarItemSchema = z2.object({
4023
+ value: z2.string().min(1).max(50),
4024
+ label: z2.string().min(1).max(100)
4025
+ }).strict();
4026
+ var drawStatsBarSchema = z2.object({
4027
+ type: z2.literal("stats-bar"),
4028
+ y: z2.number().describe("Vertical position of the stats bar"),
4029
+ items: z2.array(drawStatsBarItemSchema).min(1).max(8),
4030
+ separator: z2.enum(["dot", "pipe", "none"]).default("dot"),
4031
+ valueColor: colorHexSchema2.default("#FFFFFF"),
4032
+ valueFontSize: z2.number().min(8).max(72).default(18),
4033
+ valueFontWeight: z2.number().int().min(100).max(900).default(700),
4034
+ valueFontFamily: drawFontFamilySchema.default("mono"),
4035
+ labelColor: colorHexSchema2.default("#AAAAAA"),
4036
+ labelFontSize: z2.number().min(8).max(72).default(14),
4037
+ labelFontWeight: z2.number().int().min(100).max(900).default(400),
4038
+ labelFontFamily: drawFontFamilySchema.default("body"),
4039
+ separatorColor: colorHexSchema2.default("#666666"),
4040
+ gap: z2.number().min(0).max(100).default(24),
4041
+ opacity: z2.number().min(0).max(1).default(1)
4042
+ }).strict();
3782
4043
  var drawCommandSchema = z2.discriminatedUnion("type", [
3783
4044
  drawRectSchema,
3784
4045
  drawCircleSchema,
@@ -3790,7 +4051,8 @@ var drawCommandSchema = z2.discriminatedUnion("type", [
3790
4051
  drawBadgeSchema,
3791
4052
  drawGradientRectSchema,
3792
4053
  drawGridSchema,
3793
- drawTextRowSchema
4054
+ drawTextRowSchema,
4055
+ drawStatsBarSchema
3794
4056
  ]);
3795
4057
  var defaultCanvas = {
3796
4058
  width: 1200,
@@ -3991,6 +4253,24 @@ var imageElementSchema = z2.object({
3991
4253
  fit: z2.enum(["contain", "cover", "fill", "none"]).default("contain"),
3992
4254
  borderRadius: z2.number().min(0).default(0)
3993
4255
  }).strict();
4256
+ var ringSegmentSchema = z2.object({
4257
+ color: colorHexSchema2
4258
+ }).strict();
4259
+ var ringElementSchema = z2.object({
4260
+ type: z2.literal("ring"),
4261
+ id: z2.string().min(1).max(120),
4262
+ radius: z2.number().min(8).max(512).default(48),
4263
+ strokeWidth: z2.number().min(1).max(32).default(2),
4264
+ label: z2.string().max(100).optional(),
4265
+ labelColor: colorHexSchema2.optional(),
4266
+ labelSize: z2.number().min(8).max(48).default(12),
4267
+ segments: z2.array(ringSegmentSchema).min(1).max(24).default([{ color: "#4A7BF7" }]),
4268
+ glowRadius: z2.number().min(0).max(64).default(0),
4269
+ glowColor: colorHexSchema2.optional(),
4270
+ showCycleArrows: z2.boolean().default(false),
4271
+ fill: colorHexSchema2.optional(),
4272
+ fillOpacity: z2.number().min(0).max(1).default(0.05)
4273
+ }).strict();
3994
4274
  var elementSchema = z2.discriminatedUnion("type", [
3995
4275
  cardElementSchema,
3996
4276
  flowNodeElementSchema,
@@ -3999,7 +4279,8 @@ var elementSchema = z2.discriminatedUnion("type", [
3999
4279
  terminalElementSchema,
4000
4280
  textElementSchema,
4001
4281
  shapeElementSchema,
4002
- imageElementSchema
4282
+ imageElementSchema,
4283
+ ringElementSchema
4003
4284
  ]);
4004
4285
  var diagramCenterSchema = z2.object({
4005
4286
  x: z2.number(),
@@ -4120,8 +4401,13 @@ var decoratorSchema = z2.discriminatedUnion("type", [
4120
4401
  }).strict(),
4121
4402
  z2.object({
4122
4403
  type: z2.literal("vignette"),
4404
+ mode: z2.enum(["radial", "edge"]).default("radial"),
4123
4405
  intensity: z2.number().min(0).max(1).default(0.3),
4124
- color: colorHexSchema2.default("#000000")
4406
+ color: colorHexSchema2.default("#000000"),
4407
+ edgeTopHeight: z2.number().min(0).max(200).default(35),
4408
+ edgeBottomHeight: z2.number().min(0).max(200).default(55),
4409
+ edgeTopOpacity: z2.number().min(0).max(1).default(0.3),
4410
+ edgeBottomOpacity: z2.number().min(0).max(1).default(0.4)
4125
4411
  }).strict(),
4126
4412
  z2.object({
4127
4413
  type: z2.literal("gradient-overlay"),
@@ -4460,7 +4746,16 @@ async function renderDesign(input, options = {}) {
4460
4746
  const deferredVignettes = [];
4461
4747
  for (const [index, decorator] of spec.decorators.entries()) {
4462
4748
  if (decorator.type === "vignette") {
4463
- deferredVignettes.push({ index, intensity: decorator.intensity, color: decorator.color });
4749
+ deferredVignettes.push({
4750
+ index,
4751
+ mode: decorator.mode,
4752
+ intensity: decorator.intensity,
4753
+ color: decorator.color,
4754
+ edgeTopHeight: decorator.edgeTopHeight,
4755
+ edgeBottomHeight: decorator.edgeBottomHeight,
4756
+ edgeTopOpacity: decorator.edgeTopOpacity,
4757
+ edgeBottomOpacity: decorator.edgeBottomOpacity
4758
+ });
4464
4759
  continue;
4465
4760
  }
4466
4761
  if (decorator.type === "gradient-overlay") {
@@ -4533,6 +4828,9 @@ async function renderDesign(input, options = {}) {
4533
4828
  case "image":
4534
4829
  elements.push(...await renderImageElement(ctx, element, rect, theme));
4535
4830
  break;
4831
+ case "ring":
4832
+ elements.push(...renderRingElement(ctx, element, rect, theme));
4833
+ break;
4536
4834
  }
4537
4835
  }
4538
4836
  const nodeBounds = spec.elements.filter((element) => element.type !== "connection").map((element) => elementRects.get(element.id)).filter((rect) => rect != null);
@@ -4587,7 +4885,20 @@ async function renderDesign(input, options = {}) {
4587
4885
  }
4588
4886
  elements.push(...renderDrawCommands(ctx, spec.draw, theme));
4589
4887
  for (const vignette of deferredVignettes) {
4590
- drawVignette(ctx, spec.canvas.width, spec.canvas.height, vignette.intensity, vignette.color);
4888
+ if (vignette.mode === "edge") {
4889
+ drawEdgeVignette(
4890
+ ctx,
4891
+ spec.canvas.width,
4892
+ spec.canvas.height,
4893
+ vignette.color,
4894
+ vignette.edgeTopHeight,
4895
+ vignette.edgeBottomHeight,
4896
+ vignette.edgeTopOpacity,
4897
+ vignette.edgeBottomOpacity
4898
+ );
4899
+ } else {
4900
+ drawVignette(ctx, spec.canvas.width, spec.canvas.height, vignette.intensity, vignette.color);
4901
+ }
4591
4902
  elements.push({
4592
4903
  id: `decorator-vignette-${vignette.index}`,
4593
4904
  kind: "vignette",