@graph-artifact/core 0.1.13 → 0.1.15

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.
@@ -47,6 +47,10 @@ export declare function generateSmoothPath(points: Point[], straightEnds?: boole
47
47
  /**
48
48
  * Basis spline path (d3 curveBasis style): smoother than Catmull-Rom and
49
49
  * naturally reduces sharp elbows in orthogonal-ish routes.
50
+ *
51
+ * Starts and ends with straight segments to ensure the path connects exactly
52
+ * to the computed node boundary intersection points, then uses B-spline
53
+ * interpolation for smooth curves through the middle waypoints.
50
54
  */
51
55
  export declare function generateBasisPath(points: Point[]): string;
52
56
  /**
@@ -182,9 +182,67 @@ export function generateSmoothPath(points, straightEnds = false) {
182
182
  }
183
183
  return path;
184
184
  }
185
+ /**
186
+ * Attempt to add intermediate points along long segments to create more
187
+ * natural curves through the B-spline. This helps smooth out L-shaped
188
+ * or Z-shaped routes that would otherwise have sharp corners.
189
+ */
190
+ function subdivideIfNeeded(points, maxSegmentLen = 80) {
191
+ if (points.length < 2)
192
+ return points;
193
+ const result = [points[0]];
194
+ for (let i = 1; i < points.length; i++) {
195
+ const prev = points[i - 1];
196
+ const curr = points[i];
197
+ const dx = curr.x - prev.x;
198
+ const dy = curr.y - prev.y;
199
+ const len = Math.hypot(dx, dy);
200
+ if (len > maxSegmentLen) {
201
+ // Add midpoint(s) to break up long segments
202
+ const subdivisions = Math.ceil(len / maxSegmentLen);
203
+ for (let j = 1; j < subdivisions; j++) {
204
+ const t = j / subdivisions;
205
+ result.push({
206
+ x: prev.x + dx * t,
207
+ y: prev.y + dy * t,
208
+ });
209
+ }
210
+ }
211
+ result.push(curr);
212
+ }
213
+ return result;
214
+ }
215
+ /**
216
+ * Check if all points are nearly collinear (forming a straight line).
217
+ * Used to avoid unnecessary B-spline curves on edges that should be straight.
218
+ */
219
+ function isNearlyStraight(points, threshold = 8) {
220
+ if (points.length <= 2)
221
+ return true;
222
+ const first = points[0];
223
+ const last = points[points.length - 1];
224
+ const dx = last.x - first.x;
225
+ const dy = last.y - first.y;
226
+ const len = Math.hypot(dx, dy);
227
+ if (len < 1e-6)
228
+ return true; // coincident endpoints
229
+ // Check perpendicular distance of each inner point from the first→last line
230
+ for (let i = 1; i < points.length - 1; i++) {
231
+ const px = points[i].x - first.x;
232
+ const py = points[i].y - first.y;
233
+ const dist = Math.abs(px * dy - py * dx) / len;
234
+ if (dist > threshold)
235
+ return false;
236
+ }
237
+ return true;
238
+ }
185
239
  /**
186
240
  * Basis spline path (d3 curveBasis style): smoother than Catmull-Rom and
187
241
  * naturally reduces sharp elbows in orthogonal-ish routes.
242
+ *
243
+ * Starts and ends with straight segments to ensure the path connects exactly
244
+ * to the computed node boundary intersection points, then uses B-spline
245
+ * interpolation for smooth curves through the middle waypoints.
188
246
  */
189
247
  export function generateBasisPath(points) {
190
248
  if (points.length < 2)
@@ -192,26 +250,96 @@ export function generateBasisPath(points) {
192
250
  if (points.length === 2) {
193
251
  return `M${points[0].x},${points[0].y}L${points[1].x},${points[1].y}`;
194
252
  }
195
- // Repeat endpoints to mirror open basis behavior.
196
- const p = [points[0], points[0], ...points, points[points.length - 1], points[points.length - 1]];
197
- let path = '';
198
- let started = false;
253
+ // If all points are nearly collinear, use a straight line instead of B-spline
254
+ // This avoids unnecessary curves on edges that should be straight (e.g., start→Dashboard)
255
+ if (isNearlyStraight(points)) {
256
+ const first = points[0];
257
+ const last = points[points.length - 1];
258
+ return `M${first.x},${first.y}L${last.x},${last.y}`;
259
+ }
260
+ // For 3-point L-shaped routes, create a smooth curve using cubic bezier
261
+ // that rounds the corner instead of making a sharp turn
262
+ if (points.length === 3) {
263
+ const [p0, corner, p2] = points;
264
+ // Calculate distances to determine curve tightness
265
+ const d1 = Math.hypot(corner.x - p0.x, corner.y - p0.y);
266
+ const d2 = Math.hypot(p2.x - corner.x, p2.y - corner.y);
267
+ const minDist = Math.min(d1, d2);
268
+ // Control point offset - how far along each segment to place control points
269
+ // Larger values = tighter corner, smaller = rounder corner
270
+ const tension = Math.min(0.4, minDist * 0.3 / Math.max(d1, d2, 1));
271
+ const offset1 = Math.max(20, d1 * tension);
272
+ const offset2 = Math.max(20, d2 * tension);
273
+ // Direction vectors
274
+ const dir1x = (corner.x - p0.x) / d1;
275
+ const dir1y = (corner.y - p0.y) / d1;
276
+ const dir2x = (p2.x - corner.x) / d2;
277
+ const dir2y = (p2.y - corner.y) / d2;
278
+ // Control points pulled back from corner along each segment
279
+ const cp1 = {
280
+ x: corner.x - dir1x * offset1,
281
+ y: corner.y - dir1y * offset1,
282
+ };
283
+ const cp2 = {
284
+ x: corner.x + dir2x * offset2,
285
+ y: corner.y + dir2y * offset2,
286
+ };
287
+ // Use cubic bezier: start -> line to approach -> curve through corner -> line to end
288
+ return `M${p0.x},${p0.y}L${cp1.x},${cp1.y}Q${corner.x},${corner.y},${cp2.x},${cp2.y}L${p2.x},${p2.y}`;
289
+ }
290
+ // For 4+ points, subdivide long segments then apply B-spline
291
+ const subdivided = subdivideIfNeeded(points, 60);
292
+ // Start path at exact first point
293
+ const first = subdivided[0];
294
+ const last = subdivided[subdivided.length - 1];
295
+ let path = `M${first.x},${first.y}`;
296
+ // For B-spline middle section, we need at least 4 points.
297
+ // Use inner points (excluding first and last) for the spline.
298
+ const inner = subdivided.slice(1, -1);
299
+ if (inner.length < 2) {
300
+ // Not enough inner points for B-spline, use the 3-point logic
301
+ if (subdivided.length === 3) {
302
+ const [p0, corner, p2] = subdivided;
303
+ const d1 = Math.hypot(corner.x - p0.x, corner.y - p0.y);
304
+ const d2 = Math.hypot(p2.x - corner.x, p2.y - corner.y);
305
+ const offset = Math.min(d1, d2) * 0.3;
306
+ const dir1x = d1 > 0 ? (corner.x - p0.x) / d1 : 0;
307
+ const dir1y = d1 > 0 ? (corner.y - p0.y) / d1 : 0;
308
+ const dir2x = d2 > 0 ? (p2.x - corner.x) / d2 : 0;
309
+ const dir2y = d2 > 0 ? (p2.y - corner.y) / d2 : 0;
310
+ const cp1 = { x: corner.x - dir1x * offset, y: corner.y - dir1y * offset };
311
+ const cp2 = { x: corner.x + dir2x * offset, y: corner.y + dir2y * offset };
312
+ return `M${p0.x},${p0.y}L${cp1.x},${cp1.y}Q${corner.x},${corner.y},${cp2.x},${cp2.y}L${p2.x},${p2.y}`;
313
+ }
314
+ // Fallback to straight lines
315
+ for (let i = 1; i < subdivided.length; i++) {
316
+ path += `L${subdivided[i].x},${subdivided[i].y}`;
317
+ }
318
+ return path;
319
+ }
320
+ // Draw a short straight segment toward the first inner point
321
+ // to ensure smooth transition from exact start point
322
+ const toFirst = inner[0];
323
+ const startBlend = {
324
+ x: first.x + (toFirst.x - first.x) * 0.25,
325
+ y: first.y + (toFirst.y - first.y) * 0.25,
326
+ };
327
+ path += `L${startBlend.x},${startBlend.y}`;
328
+ // B-spline through inner points with repeated endpoints for clamping
329
+ const p = [startBlend, startBlend, ...inner, inner[inner.length - 1], inner[inner.length - 1]];
199
330
  for (let i = 0; i <= p.length - 4; i++) {
200
331
  const p0 = p[i];
201
332
  const p1 = p[i + 1];
202
333
  const p2 = p[i + 2];
203
334
  const p3 = p[i + 3];
204
335
  // Cubic Bézier equivalent of uniform cubic B-spline segment.
205
- const b0 = { x: (p0.x + 4 * p1.x + p2.x) / 6, y: (p0.y + 4 * p1.y + p2.y) / 6 };
206
336
  const b1 = { x: (4 * p1.x + 2 * p2.x) / 6, y: (4 * p1.y + 2 * p2.y) / 6 };
207
337
  const b2 = { x: (2 * p1.x + 4 * p2.x) / 6, y: (2 * p1.y + 4 * p2.y) / 6 };
208
338
  const b3 = { x: (p1.x + 4 * p2.x + p3.x) / 6, y: (p1.y + 4 * p2.y + p3.y) / 6 };
209
- if (!started) {
210
- path = `M${b0.x},${b0.y}`;
211
- started = true;
212
- }
213
339
  path += `C${b1.x},${b1.y},${b2.x},${b2.y},${b3.x},${b3.y}`;
214
340
  }
341
+ // End with straight segment to exact last point
342
+ path += `L${last.x},${last.y}`;
215
343
  return path;
216
344
  }
217
345
  // ─── Rounded Polyline Path (for orthogonal-ish ER routes) ──────────────────
@@ -95,6 +95,25 @@ export function layoutNodes(parsed, options, nodeSizeOverrides) {
95
95
  height: bounds.height,
96
96
  });
97
97
  nodes.unshift(buildSubgraphNode(subgraph, bounds, theme));
98
+ // Center start/end nodes within the subgraph (state diagrams)
99
+ if (parsed.diagramType === 'state') {
100
+ for (const nodeId of subgraph.nodeIds) {
101
+ // Check if this is a start or end node for this subgraph
102
+ if (nodeId.startsWith('[*]_start_') || nodeId.startsWith('[*]_end_')) {
103
+ const nodePos = positions.get(nodeId);
104
+ if (nodePos) {
105
+ // Center horizontally within subgraph
106
+ const centeredX = bounds.x + (bounds.width - nodePos.width) / 2;
107
+ positions.set(nodeId, { ...nodePos, x: centeredX });
108
+ // Update the corresponding React Flow node
109
+ const rfNode = nodes.find(n => n.id === nodeId);
110
+ if (rfNode) {
111
+ rfNode.position.x = centeredX;
112
+ }
113
+ }
114
+ }
115
+ }
116
+ }
98
117
  }
99
118
  }
100
119
  const nodeShapes = new Map(parsed.nodes.map(n => [n.id, n.shape]));
@@ -28,7 +28,8 @@ const LABEL_LINE_HEIGHT = 16;
28
28
  const LABEL_GAP_BELOW = 6;
29
29
  const TOP_PADDING = 20;
30
30
  const LEFT_PADDING = 40;
31
- const LIFELINE_START_OFFSET = 40;
31
+ const LIFELINE_START_OFFSET = 0; // Lifelines connect directly to participant bottom
32
+ const FIRST_MESSAGE_PADDING = 50; // Space below participants before first message row
32
33
  const BOTTOM_PARTICIPANT_GAP = 40;
33
34
  const ANCHOR_WIDTH = 2;
34
35
  const ANCHOR_HEIGHT = 2;
@@ -131,10 +132,12 @@ export function layoutSequenceDiagram(sequence, theme, metadata) {
131
132
  // ── Compute message Y positions ──
132
133
  // Each message row is tall enough for its label above the line.
133
134
  // Notes consume their own vertical rows before the message they precede.
135
+ // Lifeline starts at participant bottom (visually connects)
134
136
  const lifelineStartY = seq.topPadding + seq.participantHeight + seq.lifelineStartOffset;
135
137
  const messageY = [];
136
138
  const noteYPositions = new Map();
137
- let currentY = lifelineStartY;
139
+ // Messages start with padding below participants (room for labels, numbers, arrows)
140
+ let currentY = lifelineStartY + FIRST_MESSAGE_PADDING;
138
141
  const noteSpaceBefore = new Map();
139
142
  if (sequence.notes) {
140
143
  for (let ni = 0; ni < sequence.notes.length; ni++) {
@@ -2,67 +2,206 @@ import { prep } from './helpers.js';
2
2
  // Matches node IDs: regular words OR the special [*] start/end marker
3
3
  const NODE_ID = /(\[\*\]|[\w]+)/;
4
4
  const EDGE_RE = new RegExp(`^${NODE_ID.source}\\s*-->\\s*${NODE_ID.source}(?:\\s*:\\s*(.+))?`);
5
+ // Matches composite state declarations: state StateName { or state "Label" as StateName {
6
+ const COMPOSITE_STATE_RE = /^state\s+(?:"([^"]+)"\s+as\s+)?(\w+)\s*\{/i;
5
7
  export function parseStateDiagram(syntax) {
6
8
  const lines = prep(syntax);
7
9
  if (!lines.some(l => /^stateDiagram(-v2)?$/i.test(l)))
8
10
  return null;
11
+ // Pre-scan: identify all composite states before parsing edges
12
+ // This is needed because edges like `A --> CompositeState` may appear
13
+ // before the `state CompositeState { }` declaration.
14
+ const compositeStates = new Set();
15
+ for (const line of lines) {
16
+ const compositeMatch = line.match(COMPOSITE_STATE_RE);
17
+ if (compositeMatch) {
18
+ compositeStates.add(compositeMatch[2]);
19
+ }
20
+ }
9
21
  const nodeMap = new Map();
10
22
  const edges = [];
11
- // Track [*] usage: first occurrence as source = start, as target = end
12
- let hasStart = false;
13
- let hasEnd = false;
14
- function ensureStartNode() {
15
- if (!hasStart) {
16
- hasStart = true;
17
- nodeMap.set('[*]_start', { id: '[*]_start', label: '', shape: 'circle' });
23
+ const subgraphs = [];
24
+ // Track composite state nesting
25
+ const stateStack = []; // stack of parent state IDs
26
+ const nodeParent = new Map(); // nodeId -> parent composite state ID
27
+ // Track [*] usage per context (global or within composite state)
28
+ const startNodes = new Map(); // context -> start node ID
29
+ const endNodes = new Map(); // context -> end node ID
30
+ function getContext() {
31
+ return stateStack.length > 0 ? stateStack[stateStack.length - 1] : '__global__';
32
+ }
33
+ function ensureStartNode(context) {
34
+ if (!startNodes.has(context)) {
35
+ const id = context === '__global__' ? '[*]_start' : `[*]_start_${context}`;
36
+ startNodes.set(context, id);
37
+ nodeMap.set(id, { id, label: '', shape: 'circle' });
38
+ if (context !== '__global__') {
39
+ nodeParent.set(id, context);
40
+ }
18
41
  }
42
+ return startNodes.get(context);
19
43
  }
20
- function ensureEndNode() {
21
- if (!hasEnd) {
22
- hasEnd = true;
23
- nodeMap.set('[*]_end', { id: '[*]_end', label: '', shape: 'doublecircle' });
44
+ function ensureEndNode(context) {
45
+ if (!endNodes.has(context)) {
46
+ const id = context === '__global__' ? '[*]_end' : `[*]_end_${context}`;
47
+ endNodes.set(context, id);
48
+ nodeMap.set(id, { id, label: '', shape: 'doublecircle' });
49
+ if (context !== '__global__') {
50
+ nodeParent.set(id, context);
51
+ }
24
52
  }
53
+ return endNodes.get(context);
25
54
  }
26
- function ensureNode(id) {
55
+ function ensureNode(id, context) {
27
56
  if (id === '[*]')
28
57
  return; // handled per-edge based on position
58
+ if (compositeStates.has(id))
59
+ return; // composite states are subgraphs, not nodes
29
60
  if (nodeMap.has(id))
30
61
  return;
31
62
  nodeMap.set(id, { id, label: id, shape: 'round' });
63
+ if (context !== '__global__') {
64
+ nodeParent.set(id, context);
65
+ }
32
66
  }
33
- for (const line of lines) {
34
- if (/^(stateDiagram|state|note|direction)\s/i.test(line))
35
- continue;
67
+ for (let i = 0; i < lines.length; i++) {
68
+ const line = lines[i];
69
+ // Skip diagram declaration and direction
36
70
  if (/^stateDiagram(-v2)?$/i.test(line))
37
71
  continue;
72
+ if (/^direction\s/i.test(line))
73
+ continue;
74
+ if (/^note\s/i.test(line))
75
+ continue;
76
+ // Check for closing brace (end of composite state)
77
+ if (line === '}') {
78
+ stateStack.pop();
79
+ continue;
80
+ }
81
+ // Check for composite state declaration: state StateName {
82
+ const compositeMatch = line.match(COMPOSITE_STATE_RE);
83
+ if (compositeMatch) {
84
+ const label = compositeMatch[1] || compositeMatch[2];
85
+ const id = compositeMatch[2];
86
+ const context = getContext();
87
+ // Create subgraph entry
88
+ subgraphs.push({
89
+ id,
90
+ label,
91
+ nodeIds: [], // will be populated later
92
+ });
93
+ // If nested inside another composite state, track parent
94
+ if (context !== '__global__') {
95
+ nodeParent.set(id, context);
96
+ }
97
+ // Push onto stack for child nodes
98
+ stateStack.push(id);
99
+ continue;
100
+ }
101
+ // Skip simple state declarations (state StateName) without braces
102
+ if (/^state\s+/i.test(line) && !line.includes('{'))
103
+ continue;
104
+ // Parse transitions
38
105
  const m = line.match(EDGE_RE);
39
106
  if (m) {
40
107
  const rawSource = m[1];
41
108
  const rawTarget = m[2];
42
109
  const label = m[3]?.trim();
110
+ const context = getContext();
43
111
  // Resolve [*] to start or end based on edge position
44
112
  let source;
45
113
  let target;
114
+ let sourceSubgraphId;
115
+ let targetSubgraphId;
46
116
  if (rawSource === '[*]') {
47
- ensureStartNode();
48
- source = '[*]_start';
117
+ source = ensureStartNode(context);
118
+ }
119
+ else if (compositeStates.has(rawSource)) {
120
+ // Edge from composite state: use subgraph boundary
121
+ source = rawSource; // temporary, will be replaced
122
+ sourceSubgraphId = rawSource;
49
123
  }
50
124
  else {
51
- ensureNode(rawSource);
125
+ ensureNode(rawSource, context);
52
126
  source = rawSource;
53
127
  }
54
128
  if (rawTarget === '[*]') {
55
- ensureEndNode();
56
- target = '[*]_end';
129
+ target = ensureEndNode(context);
130
+ }
131
+ else if (compositeStates.has(rawTarget)) {
132
+ // Edge to composite state: use subgraph boundary
133
+ target = rawTarget; // temporary, will be replaced
134
+ targetSubgraphId = rawTarget;
57
135
  }
58
136
  else {
59
- ensureNode(rawTarget);
137
+ ensureNode(rawTarget, context);
60
138
  target = rawTarget;
61
139
  }
62
- edges.push({ source, target, label });
140
+ edges.push({ source, target, label, sourceSubgraphId, targetSubgraphId });
141
+ }
142
+ }
143
+ // Populate subgraph nodeIds based on nodeParent mapping
144
+ for (const subgraph of subgraphs) {
145
+ subgraph.nodeIds = [];
146
+ for (const [nodeId, parentId] of nodeParent.entries()) {
147
+ if (parentId === subgraph.id) {
148
+ subgraph.nodeIds.push(nodeId);
149
+ }
150
+ }
151
+ }
152
+ // Build maps for incoming vs outgoing edge representative nodes
153
+ // Use start node for incoming edges (so they enter from above)
154
+ // Use end node for outgoing edges ONLY if an explicit end node exists
155
+ // (i.e., there's a [*] transition inside the subgraph). Otherwise use start node.
156
+ // This prevents orphan end nodes that dagre positions randomly.
157
+ const subgraphIncomingRep = new Map(); // for targetSubgraphId
158
+ const subgraphOutgoingRep = new Map(); // for sourceSubgraphId
159
+ for (const subgraph of subgraphs) {
160
+ // For incoming edges: use start node (top of subgraph)
161
+ const startNode = startNodes.get(subgraph.id);
162
+ if (startNode) {
163
+ subgraphIncomingRep.set(subgraph.id, startNode);
164
+ }
165
+ else if (subgraph.nodeIds.length > 0) {
166
+ subgraphIncomingRep.set(subgraph.id, subgraph.nodeIds[0]);
167
+ }
168
+ // For outgoing edges: use end node (now guaranteed to exist for subgraphs with outgoing edges)
169
+ const endNode = endNodes.get(subgraph.id);
170
+ if (endNode) {
171
+ subgraphOutgoingRep.set(subgraph.id, endNode);
172
+ }
173
+ else if (startNode) {
174
+ subgraphOutgoingRep.set(subgraph.id, startNode);
175
+ }
176
+ else if (subgraph.nodeIds.length > 0) {
177
+ subgraphOutgoingRep.set(subgraph.id, subgraph.nodeIds[0]);
178
+ }
179
+ }
180
+ // Fix edges that reference composite states: replace subgraph ID with appropriate representative node
181
+ for (const edge of edges) {
182
+ if (edge.sourceSubgraphId && edge.source === edge.sourceSubgraphId) {
183
+ // Outgoing edge: use end node (bottom) so target renders below subgraph
184
+ const repNode = subgraphOutgoingRep.get(edge.sourceSubgraphId);
185
+ if (repNode) {
186
+ edge.source = repNode;
187
+ }
188
+ }
189
+ if (edge.targetSubgraphId && edge.target === edge.targetSubgraphId) {
190
+ // Incoming edge: use start node (top) so edge enters from above
191
+ const repNode = subgraphIncomingRep.get(edge.targetSubgraphId);
192
+ if (repNode) {
193
+ edge.target = repNode;
194
+ }
63
195
  }
64
196
  }
65
197
  if (nodeMap.size === 0)
66
198
  return null;
67
- return { kind: 'graph', direction: 'TB', diagramType: 'state', nodes: Array.from(nodeMap.values()), edges };
199
+ return {
200
+ kind: 'graph',
201
+ direction: 'TB',
202
+ diagramType: 'state',
203
+ nodes: Array.from(nodeMap.values()),
204
+ edges,
205
+ subgraphs: subgraphs.length > 0 ? subgraphs : undefined,
206
+ };
68
207
  }
@@ -25,14 +25,14 @@ const font = {
25
25
  family: "'Roboto', sans-serif",
26
26
  mono: "'Roboto Mono', monospace",
27
27
  size: {
28
- xxs: '9px',
29
- xs: '10px',
30
- sm: '11px',
31
- md: '12px',
32
- base: '13px',
33
- lg: '14px',
34
- xl: '16px',
35
- xxl: '18px',
28
+ xxs: '10px',
29
+ xs: '11px',
30
+ sm: '12px',
31
+ md: '13px',
32
+ base: '14px',
33
+ lg: '16px',
34
+ xl: '18px',
35
+ xxl: '20px',
36
36
  },
37
37
  weight: {
38
38
  regular: 400,
@@ -98,7 +98,7 @@ const edgeDefaults = {
98
98
  style: { stroke: color.gray3, strokeWidth: 1.5 },
99
99
  labelStyle: {
100
100
  fill: color.gray3,
101
- fontSize: 12,
101
+ fontSize: 13,
102
102
  fontWeight: font.weight.medium,
103
103
  fontFamily: font.family,
104
104
  },
@@ -145,7 +145,7 @@ const nodeBase = {
145
145
  // ─── Per-Node-Type Sizing ───────────────────────────────────────────────────
146
146
  const nodeStyles = {
147
147
  flow: {
148
- diamond: { labelMaxWidth: '100px' },
148
+ diamond: { labelMaxWidth: '140px' },
149
149
  markdownCode: { backgroundColor: color.darkBg3, padding: `${space[1]} ${space[2]}` },
150
150
  },
151
151
  state: {
@@ -165,7 +165,7 @@ const nodeStyles = {
165
165
  subgraph: {
166
166
  pointerEvents: 'none',
167
167
  borderStyle: '1px dashed',
168
- bgOpacity: '44',
168
+ bgOpacity: '88',
169
169
  labelOffset: 28,
170
170
  },
171
171
  detail: {
@@ -25,14 +25,14 @@ const font = {
25
25
  family: "'Roboto', sans-serif",
26
26
  mono: "'Roboto Mono', monospace",
27
27
  size: {
28
- xxs: '9px',
29
- xs: '10px',
30
- sm: '11px',
31
- md: '12px',
32
- base: '13px',
33
- lg: '14px',
34
- xl: '16px',
35
- xxl: '18px',
28
+ xxs: '10px',
29
+ xs: '11px',
30
+ sm: '12px',
31
+ md: '13px',
32
+ base: '14px',
33
+ lg: '16px',
34
+ xl: '18px',
35
+ xxl: '20px',
36
36
  },
37
37
  weight: {
38
38
  regular: 400,
@@ -98,7 +98,7 @@ const edgeDefaults = {
98
98
  style: { stroke: color.gray3, strokeWidth: 1.5 },
99
99
  labelStyle: {
100
100
  fill: color.gray4,
101
- fontSize: 12,
101
+ fontSize: 13,
102
102
  fontWeight: font.weight.medium,
103
103
  fontFamily: font.family,
104
104
  },
@@ -145,7 +145,7 @@ const nodeBase = {
145
145
  // ─── Per-Node-Type Sizing ───────────────────────────────────────────────────
146
146
  const nodeStyles = {
147
147
  flow: {
148
- diamond: { labelMaxWidth: '100px' },
148
+ diamond: { labelMaxWidth: '140px' },
149
149
  markdownCode: { backgroundColor: color.darkBg3, padding: `${space[1]} ${space[2]}` },
150
150
  },
151
151
  state: {
@@ -0,0 +1,9 @@
1
+ export interface BoxShadowExtent {
2
+ left: number;
3
+ right: number;
4
+ top: number;
5
+ bottom: number;
6
+ max: number;
7
+ }
8
+ export declare function getBoxShadowExtent(boxShadow: string | null | undefined): BoxShadowExtent;
9
+ export declare function getBoxShadowMaxExtent(boxShadow: string | null | undefined): number;
@@ -0,0 +1,103 @@
1
+ function splitOutsideParens(input, delimiter) {
2
+ const out = [];
3
+ let buf = '';
4
+ let depth = 0;
5
+ for (let i = 0; i < input.length; i++) {
6
+ const ch = input[i];
7
+ if (ch === '(')
8
+ depth++;
9
+ if (ch === ')')
10
+ depth = Math.max(0, depth - 1);
11
+ if (depth === 0 && ch === delimiter) {
12
+ const s = buf.trim();
13
+ if (s)
14
+ out.push(s);
15
+ buf = '';
16
+ continue;
17
+ }
18
+ buf += ch;
19
+ }
20
+ const s = buf.trim();
21
+ if (s)
22
+ out.push(s);
23
+ return out;
24
+ }
25
+ function splitWhitespaceOutsideParens(input) {
26
+ const out = [];
27
+ let buf = '';
28
+ let depth = 0;
29
+ for (let i = 0; i < input.length; i++) {
30
+ const ch = input[i];
31
+ if (ch === '(')
32
+ depth++;
33
+ if (ch === ')')
34
+ depth = Math.max(0, depth - 1);
35
+ const isWs = ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
36
+ if (depth === 0 && isWs) {
37
+ const s = buf.trim();
38
+ if (s)
39
+ out.push(s);
40
+ buf = '';
41
+ continue;
42
+ }
43
+ buf += ch;
44
+ }
45
+ const s = buf.trim();
46
+ if (s)
47
+ out.push(s);
48
+ return out;
49
+ }
50
+ function isLengthToken(token) {
51
+ // Keep intentionally conservative: we only care about px/unitless values.
52
+ return /^-?\d*\.?\d+(px)?$/i.test(token);
53
+ }
54
+ function parsePx(token) {
55
+ const t = token.trim().toLowerCase();
56
+ const n = t.endsWith('px') ? parseFloat(t.slice(0, -2)) : parseFloat(t);
57
+ return Number.isFinite(n) ? n : 0;
58
+ }
59
+ export function getBoxShadowExtent(boxShadow) {
60
+ if (!boxShadow)
61
+ return { left: 0, right: 0, top: 0, bottom: 0, max: 0 };
62
+ const src = String(boxShadow).trim();
63
+ if (!src || src === 'none')
64
+ return { left: 0, right: 0, top: 0, bottom: 0, max: 0 };
65
+ let left = 0;
66
+ let right = 0;
67
+ let top = 0;
68
+ let bottom = 0;
69
+ // box-shadow is a comma-separated list, but colors can contain commas (rgba()).
70
+ const shadows = splitOutsideParens(src, ',');
71
+ for (const shadow of shadows) {
72
+ const tokens = splitWhitespaceOutsideParens(shadow);
73
+ if (tokens.length < 2)
74
+ continue;
75
+ if (tokens.includes('inset'))
76
+ continue;
77
+ const lengths = [];
78
+ for (const tok of tokens) {
79
+ if (!isLengthToken(tok))
80
+ continue;
81
+ lengths.push(parsePx(tok));
82
+ if (lengths.length >= 4)
83
+ break;
84
+ }
85
+ if (lengths.length < 2)
86
+ continue;
87
+ const offsetX = lengths[0];
88
+ const offsetY = lengths[1];
89
+ const blur = Math.max(0, lengths[2] ?? 0);
90
+ const spread = lengths[3] ?? 0;
91
+ const base = Math.max(0, blur + spread);
92
+ // Extents outside the element box in each direction.
93
+ left = Math.max(left, Math.max(0, base - offsetX));
94
+ right = Math.max(right, Math.max(0, base + offsetX));
95
+ top = Math.max(top, Math.max(0, base - offsetY));
96
+ bottom = Math.max(bottom, Math.max(0, base + offsetY));
97
+ }
98
+ const max = Math.max(left, right, top, bottom);
99
+ return { left, right, top, bottom, max };
100
+ }
101
+ export function getBoxShadowMaxExtent(boxShadow) {
102
+ return getBoxShadowExtent(boxShadow).max;
103
+ }