@spectratools/graphic-designer-cli 0.10.0 → 0.12.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 CHANGED
@@ -125,6 +125,7 @@ Plus a **freestyle draw layer** with 8 draw command types: `rect`, `circle`, `te
125
125
  - **auto** — ELK.js layout engine (layered, stress, force, radial, box algorithms)
126
126
  - **grid** — column-based grid layout
127
127
  - **stack** — vertical or horizontal stack
128
+ - **ellipse** — evenly spaced nodes on a configurable ellipse
128
129
  - **manual** — explicit x/y coordinates
129
130
 
130
131
  ### Connection Routing Modes
@@ -138,6 +139,36 @@ Per-connection `routing` supports:
138
139
 
139
140
  `layout.diagramCenter` can optionally override the center point used by `curve` and `arc` routing. When omitted, center is derived from the laid-out element centroid (fallback: canvas center).
140
141
 
142
+ ### Hexagonal Preset via Ellipse Layout
143
+
144
+ A six-node hexagonal arrangement is just an ellipse layout with equal angular spacing and matching routing hints:
145
+
146
+ ```ts
147
+ const spec = parseDesignSpec({
148
+ version: 2,
149
+ theme: 'dark',
150
+ elements: [
151
+ { type: 'flow-node', id: 'triage', label: 'Triage', shape: 'rounded-box' },
152
+ { type: 'flow-node', id: 'design', label: 'Design', shape: 'rounded-box' },
153
+ { type: 'flow-node', id: 'build', label: 'Build', shape: 'rounded-box' },
154
+ { type: 'flow-node', id: 'test', label: 'Test', shape: 'rounded-box' },
155
+ { type: 'flow-node', id: 'deploy', label: 'Deploy', shape: 'rounded-box' },
156
+ { type: 'flow-node', id: 'observe', label: 'Observe', shape: 'rounded-box' },
157
+ ],
158
+ layout: {
159
+ mode: 'ellipse',
160
+ cx: 600,
161
+ cy: 340,
162
+ rx: 280,
163
+ ry: 180,
164
+ startAngle: -90,
165
+ diagramCenter: { x: 600, y: 340 },
166
+ ellipseRx: 280,
167
+ ellipseRy: 180,
168
+ },
169
+ });
170
+ ```
171
+
141
172
  ## Programmatic Usage
142
173
 
143
174
  ```ts
@@ -157,7 +188,17 @@ const spec = parseDesignSpec({
157
188
  { type: 'flow-node', id: 'b', label: 'End', shape: 'rounded-box', color: '#059669' },
158
189
  { type: 'connection', from: 'a', to: 'b', label: 'next', routing: 'arc' },
159
190
  ],
160
- layout: { mode: 'auto', algorithm: 'layered', diagramCenter: { x: 600, y: 340 } },
191
+ layout: {
192
+ mode: 'ellipse',
193
+ cx: 600,
194
+ cy: 340,
195
+ rx: 280,
196
+ ry: 180,
197
+ startAngle: -90,
198
+ diagramCenter: { x: 600, y: 340 },
199
+ ellipseRx: 280,
200
+ ellipseRy: 180,
201
+ },
161
202
  });
162
203
 
163
204
  const render = await renderDesign(spec, { generatorVersion: '0.3.0' });
package/dist/cli.js CHANGED
@@ -766,6 +766,10 @@ var drawShadowSchema = z2.object({
766
766
  offsetY: z2.number().default(4)
767
767
  }).strict();
768
768
  var drawFontFamilySchema = z2.enum(["heading", "body", "mono"]);
769
+ var strokeGradientSchema = z2.object({
770
+ from: colorHexSchema2,
771
+ to: colorHexSchema2
772
+ }).strict();
769
773
  var drawRectSchema = z2.object({
770
774
  type: z2.literal("rect"),
771
775
  x: z2.number(),
@@ -813,6 +817,7 @@ var drawLineSchema = z2.object({
813
817
  x2: z2.number(),
814
818
  y2: z2.number(),
815
819
  color: colorHexSchema2.default("#FFFFFF"),
820
+ strokeGradient: strokeGradientSchema.optional(),
816
821
  width: z2.number().min(0.5).max(32).default(2),
817
822
  dash: z2.array(z2.number()).max(6).optional(),
818
823
  arrow: z2.enum(["none", "end", "start", "both"]).default("none"),
@@ -843,6 +848,7 @@ var drawBezierSchema = z2.object({
843
848
  type: z2.literal("bezier"),
844
849
  points: z2.array(drawPointSchema).min(2).max(20),
845
850
  color: colorHexSchema2.default("#FFFFFF"),
851
+ strokeGradient: strokeGradientSchema.optional(),
846
852
  width: z2.number().min(0.5).max(32).default(2),
847
853
  dash: z2.array(z2.number()).max(6).optional(),
848
854
  arrow: z2.enum(["none", "end", "start", "both"]).default("none"),
@@ -1050,7 +1056,8 @@ var connectionElementSchema = z2.object({
1050
1056
  from: z2.string().min(1).max(120),
1051
1057
  to: z2.string().min(1).max(120),
1052
1058
  style: z2.enum(["solid", "dashed", "dotted"]).default("solid"),
1053
- strokeStyle: z2.enum(["solid", "dashed", "dotted"]).default("solid"),
1059
+ /** @deprecated Use `style` instead. */
1060
+ strokeStyle: z2.enum(["solid", "dashed", "dotted"]).optional(),
1054
1061
  arrow: z2.enum(["end", "start", "both", "none"]).default("end"),
1055
1062
  label: z2.string().min(1).max(200).optional(),
1056
1063
  labelPosition: z2.enum(["start", "middle", "end"]).default("middle"),
@@ -1062,7 +1069,8 @@ var connectionElementSchema = z2.object({
1062
1069
  arrowSize: z2.number().min(4).max(32).optional(),
1063
1070
  arrowPlacement: z2.enum(["endpoint", "boundary"]).default("endpoint"),
1064
1071
  opacity: z2.number().min(0).max(1).default(1),
1065
- routing: z2.enum(["auto", "orthogonal", "curve", "arc"]).default("auto"),
1072
+ routing: z2.enum(["auto", "orthogonal", "curve", "arc", "straight"]).default("auto"),
1073
+ curveMode: z2.enum(["normal", "ellipse"]).default("normal"),
1066
1074
  tension: z2.number().min(0.1).max(0.8).default(0.35),
1067
1075
  fromAnchor: anchorHintSchema.optional(),
1068
1076
  toAnchor: anchorHintSchema.optional()
@@ -1155,7 +1163,11 @@ var autoLayoutConfigSchema = z2.object({
1155
1163
  /** Sort strategy for radial layout node ordering. Only relevant when algorithm is 'radial'. */
1156
1164
  radialSortBy: z2.enum(["id", "connections"]).optional(),
1157
1165
  /** Explicit center used by curve/arc connection routing. */
1158
- diagramCenter: diagramCenterSchema.optional()
1166
+ diagramCenter: diagramCenterSchema.optional(),
1167
+ /** Horizontal radius for shared ellipse used by curveMode: 'ellipse'. */
1168
+ ellipseRx: z2.number().positive().optional(),
1169
+ /** Vertical radius for shared ellipse used by curveMode: 'ellipse'. */
1170
+ ellipseRy: z2.number().positive().optional()
1159
1171
  }).strict();
1160
1172
  var gridLayoutConfigSchema = z2.object({
1161
1173
  mode: z2.literal("grid"),
@@ -1165,7 +1177,11 @@ var gridLayoutConfigSchema = z2.object({
1165
1177
  cardMaxHeight: z2.number().int().min(32).max(4096).optional(),
1166
1178
  equalHeight: z2.boolean().default(false),
1167
1179
  /** Explicit center used by curve/arc connection routing. */
1168
- diagramCenter: diagramCenterSchema.optional()
1180
+ diagramCenter: diagramCenterSchema.optional(),
1181
+ /** Horizontal radius for shared ellipse used by curveMode: 'ellipse'. */
1182
+ ellipseRx: z2.number().positive().optional(),
1183
+ /** Vertical radius for shared ellipse used by curveMode: 'ellipse'. */
1184
+ ellipseRy: z2.number().positive().optional()
1169
1185
  }).strict();
1170
1186
  var stackLayoutConfigSchema = z2.object({
1171
1187
  mode: z2.literal("stack"),
@@ -1173,7 +1189,25 @@ var stackLayoutConfigSchema = z2.object({
1173
1189
  gap: z2.number().int().min(0).max(256).default(24),
1174
1190
  alignment: z2.enum(["start", "center", "end", "stretch"]).default("stretch"),
1175
1191
  /** Explicit center used by curve/arc connection routing. */
1176
- diagramCenter: diagramCenterSchema.optional()
1192
+ diagramCenter: diagramCenterSchema.optional(),
1193
+ /** Horizontal radius for shared ellipse used by curveMode: 'ellipse'. */
1194
+ ellipseRx: z2.number().positive().optional(),
1195
+ /** Vertical radius for shared ellipse used by curveMode: 'ellipse'. */
1196
+ ellipseRy: z2.number().positive().optional()
1197
+ }).strict();
1198
+ var ellipseLayoutConfigSchema = z2.object({
1199
+ mode: z2.literal("ellipse"),
1200
+ cx: z2.number().optional(),
1201
+ cy: z2.number().optional(),
1202
+ rx: z2.number().positive(),
1203
+ ry: z2.number().positive(),
1204
+ startAngle: z2.number().default(-90),
1205
+ /** Explicit center used by curve/arc connection routing. */
1206
+ diagramCenter: diagramCenterSchema.optional(),
1207
+ /** Horizontal radius for shared ellipse used by curveMode: 'ellipse'. */
1208
+ ellipseRx: z2.number().positive().optional(),
1209
+ /** Vertical radius for shared ellipse used by curveMode: 'ellipse'. */
1210
+ ellipseRy: z2.number().positive().optional()
1177
1211
  }).strict();
1178
1212
  var manualPositionSchema = z2.object({
1179
1213
  x: z2.number().int(),
@@ -1185,12 +1219,17 @@ var manualLayoutConfigSchema = z2.object({
1185
1219
  mode: z2.literal("manual"),
1186
1220
  positions: z2.record(z2.string().min(1), manualPositionSchema).default({}),
1187
1221
  /** Explicit center used by curve/arc connection routing. */
1188
- diagramCenter: diagramCenterSchema.optional()
1222
+ diagramCenter: diagramCenterSchema.optional(),
1223
+ /** Horizontal radius for shared ellipse used by curveMode: 'ellipse'. */
1224
+ ellipseRx: z2.number().positive().optional(),
1225
+ /** Vertical radius for shared ellipse used by curveMode: 'ellipse'. */
1226
+ ellipseRy: z2.number().positive().optional()
1189
1227
  }).strict();
1190
1228
  var layoutConfigSchema = z2.discriminatedUnion("mode", [
1191
1229
  autoLayoutConfigSchema,
1192
1230
  gridLayoutConfigSchema,
1193
1231
  stackLayoutConfigSchema,
1232
+ ellipseLayoutConfigSchema,
1194
1233
  manualLayoutConfigSchema
1195
1234
  ]);
1196
1235
  var constraintsSchema = z2.object({
@@ -1250,7 +1289,11 @@ var diagramElementSchema = z2.discriminatedUnion("type", [
1250
1289
  var diagramLayoutSchema = z2.object({
1251
1290
  mode: z2.enum(["manual", "auto"]).default("manual"),
1252
1291
  positions: z2.record(z2.string(), diagramPositionSchema).optional(),
1253
- diagramCenter: diagramCenterSchema.optional()
1292
+ diagramCenter: diagramCenterSchema.optional(),
1293
+ /** Horizontal radius for shared ellipse used by curveMode: 'ellipse'. */
1294
+ ellipseRx: z2.number().positive().optional(),
1295
+ /** Vertical radius for shared ellipse used by curveMode: 'ellipse'. */
1296
+ ellipseRy: z2.number().positive().optional()
1254
1297
  }).strict();
1255
1298
  var diagramSpecSchema = z2.object({
1256
1299
  version: z2.literal(1),
@@ -2417,6 +2460,35 @@ async function computeElkLayout(elements, config, safeFrame) {
2417
2460
  };
2418
2461
  }
2419
2462
 
2463
+ // src/layout/ellipse.ts
2464
+ function clampDimension(estimated, max) {
2465
+ return Math.max(1, Math.min(max, Math.floor(estimated)));
2466
+ }
2467
+ function computeEllipseLayout(elements, config, safeFrame) {
2468
+ const placeable = elements.filter((element) => element.type !== "connection");
2469
+ const positions = /* @__PURE__ */ new Map();
2470
+ if (placeable.length === 0) {
2471
+ return { positions };
2472
+ }
2473
+ const cx = config.cx ?? safeFrame.x + safeFrame.width / 2;
2474
+ const cy = config.cy ?? safeFrame.y + safeFrame.height / 2;
2475
+ const stepDegrees = 360 / placeable.length;
2476
+ for (const [index, element] of placeable.entries()) {
2477
+ const angleRadians = (config.startAngle + index * stepDegrees) * Math.PI / 180;
2478
+ const centerX = cx + config.rx * Math.cos(angleRadians);
2479
+ const centerY = cy + config.ry * Math.sin(angleRadians);
2480
+ const width = clampDimension(estimateElementWidth(element), safeFrame.width);
2481
+ const height = clampDimension(estimateElementHeight(element), safeFrame.height);
2482
+ positions.set(element.id, {
2483
+ x: Math.round(centerX - width / 2),
2484
+ y: Math.round(centerY - height / 2),
2485
+ width,
2486
+ height
2487
+ });
2488
+ }
2489
+ return { positions };
2490
+ }
2491
+
2420
2492
  // src/layout/grid.ts
2421
2493
  function computeGridLayout(elements, config, safeFrame) {
2422
2494
  const placeable = elements.filter((element) => element.type !== "connection");
@@ -2512,6 +2584,8 @@ async function computeLayout(elements, layout, safeFrame) {
2512
2584
  return computeGridLayout(elements, layout, safeFrame);
2513
2585
  case "stack":
2514
2586
  return computeStackLayout(elements, layout, safeFrame);
2587
+ case "ellipse":
2588
+ return computeEllipseLayout(elements, layout, safeFrame);
2515
2589
  case "manual":
2516
2590
  return computeManualLayout(elements, layout, safeFrame);
2517
2591
  default:
@@ -2778,12 +2852,12 @@ var MACOS_DOTS = [
2778
2852
  { fill: "#27C93F", stroke: "#1AAB29" }
2779
2853
  ];
2780
2854
  function drawMacosDots(ctx, x, y) {
2781
- for (const [index, dot2] of MACOS_DOTS.entries()) {
2855
+ for (const [index, dot] of MACOS_DOTS.entries()) {
2782
2856
  ctx.beginPath();
2783
2857
  ctx.arc(x + index * DOT_SPACING, y, DOT_RADIUS, 0, Math.PI * 2);
2784
2858
  ctx.closePath();
2785
- ctx.fillStyle = dot2.fill;
2786
- ctx.strokeStyle = dot2.stroke;
2859
+ ctx.fillStyle = dot.fill;
2860
+ ctx.strokeStyle = dot.stroke;
2787
2861
  ctx.lineWidth = DOT_STROKE_WIDTH;
2788
2862
  ctx.fill();
2789
2863
  ctx.stroke();
@@ -3270,56 +3344,71 @@ function curveRoute(fromBounds, toBounds, diagramCenter, tension, fromAnchor, to
3270
3344
  const cp2 = { x: p3.x + n3.x * offset, y: p3.y + n3.y * offset };
3271
3345
  return [p0, cp1, cp2, p3];
3272
3346
  }
3273
- function dot(a, b) {
3274
- return a.x * b.x + a.y * b.y;
3275
- }
3276
- function localToWorld(origin, axisX, axisY, local) {
3277
- return {
3278
- x: origin.x + axisX.x * local.x + axisY.x * local.y,
3279
- y: origin.y + axisX.y * local.x + axisY.y * local.y
3280
- };
3347
+ function inferEllipseParams(nodeBounds, explicitCenter, explicitRx, explicitRy) {
3348
+ if (nodeBounds.length === 0) {
3349
+ return {
3350
+ cx: explicitCenter?.x ?? 0,
3351
+ cy: explicitCenter?.y ?? 0,
3352
+ rx: explicitRx ?? 1,
3353
+ ry: explicitRy ?? 1
3354
+ };
3355
+ }
3356
+ const centers = nodeBounds.map(rectCenter);
3357
+ const cx = explicitCenter?.x ?? centers.reduce((sum, c) => sum + c.x, 0) / centers.length;
3358
+ const cy = explicitCenter?.y ?? centers.reduce((sum, c) => sum + c.y, 0) / centers.length;
3359
+ const rx = explicitRx ?? Math.max(1, ...centers.map((c) => Math.abs(c.x - cx)));
3360
+ const ry = explicitRy ?? Math.max(1, ...centers.map((c) => Math.abs(c.y - cy)));
3361
+ return { cx, cy, rx, ry };
3281
3362
  }
3282
- function arcRoute(fromBounds, toBounds, diagramCenter, tension, fromAnchor, toAnchor) {
3363
+ function ellipseRoute(fromBounds, toBounds, ellipse, fromAnchor, toAnchor) {
3283
3364
  const fromCenter = rectCenter(fromBounds);
3284
3365
  const toCenter = rectCenter(toBounds);
3285
- const start = resolveAnchor(fromBounds, fromAnchor, toCenter);
3286
- const end = resolveAnchor(toBounds, toAnchor, fromCenter);
3287
- const chord = { x: end.x - start.x, y: end.y - start.y };
3288
- const chordLength = Math.hypot(chord.x, chord.y);
3289
- if (chordLength < 1e-6) {
3290
- const mid = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
3291
- return [
3292
- [start, start, mid, mid],
3293
- [mid, mid, end, end]
3294
- ];
3295
- }
3296
- const axisX = { x: chord.x / chordLength, y: chord.y / chordLength };
3297
- let axisY = { x: -axisX.y, y: axisX.x };
3298
- const midpoint = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
3299
- const outwardHint = outwardNormal(midpoint, diagramCenter);
3300
- if (dot(axisY, outwardHint) < 0) {
3301
- axisY = { x: -axisY.x, y: -axisY.y };
3302
- }
3303
- const semiMajor = chordLength / 2;
3304
- const semiMinor = Math.max(12, chordLength * tension * 0.75);
3305
- const p0Local = { x: -semiMajor, y: 0 };
3306
- const cp1Local = { x: -semiMajor, y: ELLIPSE_KAPPA * semiMinor };
3307
- const cp2Local = { x: -ELLIPSE_KAPPA * semiMajor, y: semiMinor };
3308
- const pMidLocal = { x: 0, y: semiMinor };
3309
- const cp3Local = { x: ELLIPSE_KAPPA * semiMajor, y: semiMinor };
3310
- const cp4Local = { x: semiMajor, y: ELLIPSE_KAPPA * semiMinor };
3311
- const p3Local = { x: semiMajor, y: 0 };
3312
- const p0 = localToWorld(midpoint, axisX, axisY, p0Local);
3313
- const cp1 = localToWorld(midpoint, axisX, axisY, cp1Local);
3314
- const cp2 = localToWorld(midpoint, axisX, axisY, cp2Local);
3315
- const pMid = localToWorld(midpoint, axisX, axisY, pMidLocal);
3316
- const cp3 = localToWorld(midpoint, axisX, axisY, cp3Local);
3317
- const cp4 = localToWorld(midpoint, axisX, axisY, cp4Local);
3318
- const p3 = localToWorld(midpoint, axisX, axisY, p3Local);
3319
- return [
3320
- [p0, cp1, cp2, pMid],
3321
- [pMid, cp3, cp4, p3]
3322
- ];
3366
+ const p0 = resolveAnchor(fromBounds, fromAnchor, toCenter);
3367
+ const p3 = resolveAnchor(toBounds, toAnchor, fromCenter);
3368
+ const theta1 = Math.atan2(
3369
+ (fromCenter.y - ellipse.cy) / ellipse.ry,
3370
+ (fromCenter.x - ellipse.cx) / ellipse.rx
3371
+ );
3372
+ const theta2 = Math.atan2(
3373
+ (toCenter.y - ellipse.cy) / ellipse.ry,
3374
+ (toCenter.x - ellipse.cx) / ellipse.rx
3375
+ );
3376
+ let angularSpan = theta2 - theta1;
3377
+ while (angularSpan > Math.PI) angularSpan -= 2 * Math.PI;
3378
+ while (angularSpan <= -Math.PI) angularSpan += 2 * Math.PI;
3379
+ const absSpan = Math.abs(angularSpan);
3380
+ const kappa = absSpan < 1e-6 ? 0 : 4 / 3 * Math.tan(absSpan / 4);
3381
+ const tangent1 = {
3382
+ x: -ellipse.rx * Math.sin(theta1),
3383
+ y: ellipse.ry * Math.cos(theta1)
3384
+ };
3385
+ const tangent2 = {
3386
+ x: -ellipse.rx * Math.sin(theta2),
3387
+ y: ellipse.ry * Math.cos(theta2)
3388
+ };
3389
+ const len1 = Math.hypot(tangent1.x, tangent1.y) || 1;
3390
+ const len2 = Math.hypot(tangent2.x, tangent2.y) || 1;
3391
+ const norm1 = { x: tangent1.x / len1, y: tangent1.y / len1 };
3392
+ const norm2 = { x: tangent2.x / len2, y: tangent2.y / len2 };
3393
+ const chordLength = Math.hypot(p3.x - p0.x, p3.y - p0.y);
3394
+ const cpDistance = chordLength * kappa * 0.5;
3395
+ const sign = angularSpan >= 0 ? 1 : -1;
3396
+ const cp1 = {
3397
+ x: p0.x + norm1.x * cpDistance * sign,
3398
+ y: p0.y + norm1.y * cpDistance * sign
3399
+ };
3400
+ const cp2 = {
3401
+ x: p3.x - norm2.x * cpDistance * sign,
3402
+ y: p3.y - norm2.y * cpDistance * sign
3403
+ };
3404
+ return [p0, cp1, cp2, p3];
3405
+ }
3406
+ function straightRoute(fromBounds, toBounds, fromAnchor, toAnchor) {
3407
+ const fromC = rectCenter(fromBounds);
3408
+ const toC = rectCenter(toBounds);
3409
+ const p0 = resolveAnchor(fromBounds, fromAnchor, toC);
3410
+ const p3 = resolveAnchor(toBounds, toAnchor, fromC);
3411
+ return [p0, p3];
3323
3412
  }
3324
3413
  function orthogonalRoute(fromBounds, toBounds, fromAnchor, toAnchor) {
3325
3414
  const fromC = rectCenter(fromBounds);
@@ -3365,15 +3454,6 @@ function findBoundaryIntersection(p0, cp1, cp2, p3, targetRect, searchFromEnd) {
3365
3454
  }
3366
3455
  return void 0;
3367
3456
  }
3368
- function pointAlongArc(route, t) {
3369
- const [first, second] = route;
3370
- if (t <= 0.5) {
3371
- const localT2 = Math.max(0, Math.min(1, t * 2));
3372
- return bezierPointAt(first[0], first[1], first[2], first[3], localT2);
3373
- }
3374
- const localT = Math.max(0, Math.min(1, (t - 0.5) * 2));
3375
- return bezierPointAt(second[0], second[1], second[2], second[3], localT);
3376
- }
3377
3457
  function computeDiagramCenter(nodeBounds, canvasCenter) {
3378
3458
  if (nodeBounds.length === 0) {
3379
3459
  return canvasCenter ?? { x: 0, y: 0 };
@@ -3496,8 +3576,19 @@ function polylineBounds(points) {
3496
3576
  };
3497
3577
  }
3498
3578
  function renderConnection(ctx, conn, fromBounds, toBounds, theme, edgeRoute, options) {
3499
- const routing = conn.routing ?? "auto";
3500
- const strokeStyle = conn.strokeStyle ?? conn.style ?? "solid";
3579
+ let routing = conn.routing ?? "auto";
3580
+ let curveMode = conn.curveMode ?? "normal";
3581
+ if (conn.strokeStyle !== void 0) {
3582
+ console.warn("connection.strokeStyle is deprecated, use style instead");
3583
+ }
3584
+ if (routing === "arc") {
3585
+ console.warn(
3586
+ "connection routing: 'arc' is deprecated. Use routing: 'curve' with curveMode: 'ellipse' instead."
3587
+ );
3588
+ routing = "curve";
3589
+ curveMode = "ellipse";
3590
+ }
3591
+ const strokeStyle = conn.style ?? conn.strokeStyle ?? "solid";
3501
3592
  const strokeWidth = conn.width ?? conn.strokeWidth ?? 2;
3502
3593
  const tension = conn.tension ?? 0.35;
3503
3594
  const dash = dashFromStyle(strokeStyle);
@@ -3519,14 +3610,29 @@ function renderConnection(ctx, conn, fromBounds, toBounds, theme, edgeRoute, opt
3519
3610
  ctx.globalAlpha = conn.opacity;
3520
3611
  const arrowPlacement = conn.arrowPlacement ?? "endpoint";
3521
3612
  if (routing === "curve") {
3522
- const [p0, cp1, cp2, p3] = curveRoute(
3523
- fromBounds,
3524
- toBounds,
3525
- diagramCenter,
3526
- tension,
3527
- conn.fromAnchor,
3528
- conn.toAnchor
3529
- );
3613
+ let p0;
3614
+ let cp1;
3615
+ let cp2;
3616
+ let p3;
3617
+ if (curveMode === "ellipse") {
3618
+ const ellipse = options?.ellipseParams ?? inferEllipseParams([fromBounds, toBounds]);
3619
+ [p0, cp1, cp2, p3] = ellipseRoute(
3620
+ fromBounds,
3621
+ toBounds,
3622
+ ellipse,
3623
+ conn.fromAnchor,
3624
+ conn.toAnchor
3625
+ );
3626
+ } else {
3627
+ [p0, cp1, cp2, p3] = curveRoute(
3628
+ fromBounds,
3629
+ toBounds,
3630
+ diagramCenter,
3631
+ tension,
3632
+ conn.fromAnchor,
3633
+ conn.toAnchor
3634
+ );
3635
+ }
3530
3636
  const stroke = resolveConnectionStroke(ctx, p0, p3, conn.fromColor, style.color, conn.toColor);
3531
3637
  ctx.strokeStyle = stroke;
3532
3638
  ctx.lineWidth = style.width;
@@ -3559,51 +3665,22 @@ function renderConnection(ctx, conn, fromBounds, toBounds, theme, edgeRoute, opt
3559
3665
  }
3560
3666
  }
3561
3667
  }
3562
- } else if (routing === "arc") {
3563
- const [first, second] = arcRoute(
3564
- fromBounds,
3565
- toBounds,
3566
- diagramCenter,
3567
- tension,
3568
- conn.fromAnchor,
3569
- conn.toAnchor
3570
- );
3571
- const [p0, cp1, cp2, pMid] = first;
3572
- const [, cp3, cp4, p3] = second;
3668
+ } else if (routing === "straight") {
3669
+ const [p0, p3] = straightRoute(fromBounds, toBounds, conn.fromAnchor, conn.toAnchor);
3573
3670
  const stroke = resolveConnectionStroke(ctx, p0, p3, conn.fromColor, style.color, conn.toColor);
3574
3671
  ctx.strokeStyle = stroke;
3575
3672
  ctx.lineWidth = style.width;
3576
3673
  ctx.setLineDash(style.dash ?? []);
3577
3674
  ctx.beginPath();
3578
3675
  ctx.moveTo(p0.x, p0.y);
3579
- ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, pMid.x, pMid.y);
3580
- ctx.bezierCurveTo(cp3.x, cp3.y, cp4.x, cp4.y, p3.x, p3.y);
3676
+ ctx.lineTo(p3.x, p3.y);
3581
3677
  ctx.stroke();
3582
- linePoints = [p0, cp1, cp2, pMid, cp3, cp4, p3];
3678
+ linePoints = [p0, p3];
3583
3679
  startPoint = p0;
3584
3680
  endPoint = p3;
3585
- startAngle = Math.atan2(p0.y - cp1.y, p0.x - cp1.x);
3586
- endAngle = Math.atan2(p3.y - cp4.y, p3.x - cp4.x);
3587
- labelPoint = pointAlongArc([first, second], labelT);
3588
- if (arrowPlacement === "boundary") {
3589
- if (conn.arrow === "end" || conn.arrow === "both") {
3590
- const [, s_cp3, s_cp4, s_p3] = second;
3591
- const tEnd = findBoundaryIntersection(pMid, s_cp3, s_cp4, s_p3, toBounds, true);
3592
- if (tEnd !== void 0) {
3593
- endPoint = bezierPointAt(pMid, s_cp3, s_cp4, s_p3, tEnd);
3594
- const tangent = bezierTangentAt(pMid, s_cp3, s_cp4, s_p3, tEnd);
3595
- endAngle = Math.atan2(tangent.y, tangent.x);
3596
- }
3597
- }
3598
- if (conn.arrow === "start" || conn.arrow === "both") {
3599
- const tStart = findBoundaryIntersection(p0, cp1, cp2, pMid, fromBounds, false);
3600
- if (tStart !== void 0) {
3601
- startPoint = bezierPointAt(p0, cp1, cp2, pMid, tStart);
3602
- const tangent = bezierTangentAt(p0, cp1, cp2, pMid, tStart);
3603
- startAngle = Math.atan2(tangent.y, tangent.x) + Math.PI;
3604
- }
3605
- }
3606
- }
3681
+ startAngle = Math.atan2(p0.y - p3.y, p0.x - p3.x);
3682
+ endAngle = Math.atan2(p3.y - p0.y, p3.x - p0.x);
3683
+ labelPoint = pointAlongPolyline(linePoints, labelT);
3607
3684
  } else {
3608
3685
  const hasAnchorHints = conn.fromAnchor !== void 0 || conn.toAnchor !== void 0;
3609
3686
  const useElkRoute = routing === "auto" && !hasAnchorHints && (edgeRoute?.points.length ?? 0) >= 2;
@@ -3869,6 +3946,24 @@ function fromPoints(points) {
3869
3946
  function resolveDrawFont(theme, family) {
3870
3947
  return resolveFont(theme.fonts[family], family);
3871
3948
  }
3949
+ function createDrawStrokeGradient(ctx, start, end, strokeGradient) {
3950
+ const gradient = ctx.createLinearGradient(start.x, start.y, end.x, end.y);
3951
+ gradient.addColorStop(0, strokeGradient.from);
3952
+ gradient.addColorStop(1, strokeGradient.to);
3953
+ return gradient;
3954
+ }
3955
+ function resolveDrawStroke(ctx, start, end, color, strokeGradient) {
3956
+ if (!strokeGradient) {
3957
+ return color;
3958
+ }
3959
+ return createDrawStrokeGradient(ctx, start, end, strokeGradient);
3960
+ }
3961
+ function resolveArrowFill(color, strokeGradient, position) {
3962
+ if (!strokeGradient) {
3963
+ return color;
3964
+ }
3965
+ return position === "start" ? strokeGradient.from : strokeGradient.to;
3966
+ }
3872
3967
  function measureSpacedTextWidth(ctx, text, letterSpacing) {
3873
3968
  const chars = [...text];
3874
3969
  if (chars.length === 0) {
@@ -4147,18 +4242,31 @@ function renderDrawCommands(ctx, commands, theme) {
4147
4242
  const from = { x: command.x1, y: command.y1 };
4148
4243
  const to = { x: command.x2, y: command.y2 };
4149
4244
  const lineAngle = angleBetween(from, to);
4245
+ const stroke = resolveDrawStroke(ctx, from, to, command.color, command.strokeGradient);
4150
4246
  withOpacity(ctx, command.opacity, () => {
4151
4247
  applyDrawShadow(ctx, command.shadow);
4152
4248
  drawLine(ctx, from, to, {
4153
- color: command.color,
4249
+ color: stroke,
4154
4250
  width: command.width,
4155
4251
  ...command.dash ? { dash: command.dash } : {}
4156
4252
  });
4157
4253
  if (command.arrow === "end" || command.arrow === "both") {
4158
- drawArrowhead(ctx, to, lineAngle, command.arrowSize, command.color);
4254
+ drawArrowhead(
4255
+ ctx,
4256
+ to,
4257
+ lineAngle,
4258
+ command.arrowSize,
4259
+ resolveArrowFill(command.color, command.strokeGradient, "end")
4260
+ );
4159
4261
  }
4160
4262
  if (command.arrow === "start" || command.arrow === "both") {
4161
- drawArrowhead(ctx, from, lineAngle + Math.PI, command.arrowSize, command.color);
4263
+ drawArrowhead(
4264
+ ctx,
4265
+ from,
4266
+ lineAngle + Math.PI,
4267
+ command.arrowSize,
4268
+ resolveArrowFill(command.color, command.strokeGradient, "start")
4269
+ );
4162
4270
  }
4163
4271
  });
4164
4272
  const arrowPadding = command.arrow === "none" ? 0 : command.arrowSize;
@@ -4166,7 +4274,7 @@ function renderDrawCommands(ctx, commands, theme) {
4166
4274
  id,
4167
4275
  kind: "draw",
4168
4276
  bounds: expandRect(fromPoints([from, to]), Math.max(command.width / 2, arrowPadding)),
4169
- foregroundColor: command.color
4277
+ foregroundColor: command.strokeGradient?.from ?? command.color
4170
4278
  });
4171
4279
  break;
4172
4280
  }
@@ -4200,10 +4308,17 @@ function renderDrawCommands(ctx, commands, theme) {
4200
4308
  }
4201
4309
  case "bezier": {
4202
4310
  const points = command.points;
4311
+ const stroke = resolveDrawStroke(
4312
+ ctx,
4313
+ points[0],
4314
+ points[points.length - 1],
4315
+ command.color,
4316
+ command.strokeGradient
4317
+ );
4203
4318
  withOpacity(ctx, command.opacity, () => {
4204
4319
  applyDrawShadow(ctx, command.shadow);
4205
4320
  drawBezier(ctx, points, {
4206
- color: command.color,
4321
+ color: stroke,
4207
4322
  width: command.width,
4208
4323
  ...command.dash ? { dash: command.dash } : {}
4209
4324
  });
@@ -4215,11 +4330,17 @@ function renderDrawCommands(ctx, commands, theme) {
4215
4330
  points[points.length - 1],
4216
4331
  endAngle,
4217
4332
  command.arrowSize,
4218
- command.color
4333
+ resolveArrowFill(command.color, command.strokeGradient, "end")
4219
4334
  );
4220
4335
  }
4221
4336
  if (command.arrow === "start" || command.arrow === "both") {
4222
- drawArrowhead(ctx, points[0], startAngle + Math.PI, command.arrowSize, command.color);
4337
+ drawArrowhead(
4338
+ ctx,
4339
+ points[0],
4340
+ startAngle + Math.PI,
4341
+ command.arrowSize,
4342
+ resolveArrowFill(command.color, command.strokeGradient, "start")
4343
+ );
4223
4344
  }
4224
4345
  });
4225
4346
  const arrowPadding = command.arrow === "none" ? 0 : command.arrowSize;
@@ -4227,7 +4348,7 @@ function renderDrawCommands(ctx, commands, theme) {
4227
4348
  id,
4228
4349
  kind: "draw",
4229
4350
  bounds: expandRect(fromPoints(points), Math.max(command.width / 2, arrowPadding)),
4230
- foregroundColor: command.color
4351
+ foregroundColor: command.strokeGradient?.from ?? command.color
4231
4352
  });
4232
4353
  break;
4233
4354
  }
@@ -5032,10 +5153,19 @@ async function renderDesign(input, options = {}) {
5032
5153
  break;
5033
5154
  }
5034
5155
  }
5035
- const diagramCenter = spec.layout.diagramCenter ?? computeDiagramCenter(
5036
- spec.elements.filter((element) => element.type !== "connection").map((element) => elementRects.get(element.id)).filter((rect) => rect != null),
5037
- { x: spec.canvas.width / 2, y: spec.canvas.height / 2 }
5156
+ const nodeBounds = spec.elements.filter((element) => element.type !== "connection").map((element) => elementRects.get(element.id)).filter((rect) => rect != null);
5157
+ const diagramCenter = spec.layout.diagramCenter ?? computeDiagramCenter(nodeBounds, { x: spec.canvas.width / 2, y: spec.canvas.height / 2 });
5158
+ const layoutEllipseRx = "ellipseRx" in spec.layout ? spec.layout.ellipseRx : void 0;
5159
+ const layoutEllipseRy = "ellipseRy" in spec.layout ? spec.layout.ellipseRy : void 0;
5160
+ const hasEllipseConnections = spec.elements.some(
5161
+ (el) => el.type === "connection" && (el.curveMode === "ellipse" || el.routing === "arc")
5038
5162
  );
5163
+ const ellipseParams = hasEllipseConnections ? inferEllipseParams(
5164
+ nodeBounds,
5165
+ spec.layout.diagramCenter ?? diagramCenter,
5166
+ layoutEllipseRx,
5167
+ layoutEllipseRy
5168
+ ) : void 0;
5039
5169
  for (const element of spec.elements) {
5040
5170
  if (element.type !== "connection") {
5041
5171
  continue;
@@ -5049,7 +5179,15 @@ async function renderDesign(input, options = {}) {
5049
5179
  }
5050
5180
  const edgeRoute = edgeRoutes?.get(`${element.from}-${element.to}`);
5051
5181
  elements.push(
5052
- ...renderConnection(ctx, element, fromRect, toRect, theme, edgeRoute, { diagramCenter })
5182
+ ...renderConnection(
5183
+ ctx,
5184
+ element,
5185
+ fromRect,
5186
+ toRect,
5187
+ theme,
5188
+ edgeRoute,
5189
+ ellipseParams ? { diagramCenter, ellipseParams } : { diagramCenter }
5190
+ )
5053
5191
  );
5054
5192
  }
5055
5193
  if (footerRect && spec.footer) {