@diagrammo/dgmo 0.2.20 → 0.2.22

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/src/org/layout.ts CHANGED
@@ -52,10 +52,12 @@ export interface OrgContainerBounds {
52
52
  export interface OrgLegendEntry {
53
53
  value: string;
54
54
  color: string;
55
+ isDefault?: boolean;
55
56
  }
56
57
 
57
58
  export interface OrgLegendGroup {
58
59
  name: string;
60
+ alias?: string;
59
61
  entries: OrgLegendEntry[];
60
62
  x: number;
61
63
  y: number;
@@ -97,18 +99,17 @@ const CONTAINER_META_LINE_HEIGHT = 16;
97
99
  const STACK_V_GAP = 20;
98
100
 
99
101
 
100
- // Legend
102
+ // Legend (kanban-style pills)
101
103
  const LEGEND_GAP = 30;
102
- const LEGEND_DOT_R = 5;
103
- const LEGEND_DOT_TEXT_GAP = 6;
104
- const LEGEND_ENTRY_GAP = 12;
105
- const LEGEND_PAD = 10;
106
- const LEGEND_HEADER_H = 20;
107
- const LEGEND_ENTRY_H = 18;
108
- const LEGEND_MAX_PER_ROW = 3;
109
- const LEGEND_V_GAP = 12;
110
- const EYE_ICON_WIDTH = 16;
111
- const EYE_ICON_GAP = 6;
104
+ const LEGEND_HEIGHT = 28;
105
+ const LEGEND_PILL_PAD = 16;
106
+ const LEGEND_PILL_FONT_W = 11 * 0.6;
107
+ const LEGEND_CAPSULE_PAD = 4;
108
+ const LEGEND_DOT_R = 4;
109
+ const LEGEND_ENTRY_FONT_W = 10 * 0.6;
110
+ const LEGEND_ENTRY_DOT_GAP = 4;
111
+ const LEGEND_ENTRY_TRAIL = 8;
112
+ const LEGEND_GROUP_GAP = 12;
112
113
 
113
114
  // ============================================================
114
115
  // Helpers
@@ -271,49 +272,48 @@ function centerHeavyChildren(node: TreeNode): void {
271
272
  // Layout
272
273
  // ============================================================
273
274
 
274
- function computeLegendGroups(tagGroups: OrgTagGroup[], showEyeIcons: boolean): OrgLegendGroup[] {
275
+ function computeLegendGroups(tagGroups: OrgTagGroup[], _showEyeIcons: boolean): OrgLegendGroup[] {
275
276
  const groups: OrgLegendGroup[] = [];
276
277
 
277
278
  for (const group of tagGroups) {
278
279
  if (group.entries.length === 0) continue;
279
280
 
280
- const entryWidths = group.entries.map(
281
- (e) =>
282
- LEGEND_DOT_R * 2 + LEGEND_DOT_TEXT_GAP + e.value.length * CHAR_WIDTH
283
- );
284
-
285
- // Compute max width per column so columns align across rows
286
- const numRows = Math.ceil(entryWidths.length / LEGEND_MAX_PER_ROW);
287
- const colWidths: number[] = [];
288
- for (let col = 0; col < LEGEND_MAX_PER_ROW; col++) {
289
- let maxW = 0;
290
- for (let row = 0; row < numRows; row++) {
291
- const idx = row * LEGEND_MAX_PER_ROW + col;
292
- if (idx < entryWidths.length && entryWidths[idx] > maxW) {
293
- maxW = entryWidths[idx];
294
- }
295
- }
296
- if (maxW > 0) colWidths.push(maxW);
281
+ // Pill label includes alias if present (e.g., "Rank (r)")
282
+ const pillLabel = group.alias ? `${group.name} (${group.alias})` : group.name;
283
+ const pillWidth = pillLabel.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
284
+ // Minified pill shows just the group name (no alias)
285
+ const minPillWidth = group.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
286
+
287
+ // Capsule: pad + pill + gap + entries + pad
288
+ const isDefaultValue = group.defaultValue?.toLowerCase();
289
+ let entriesWidth = 0;
290
+ for (const entry of group.entries) {
291
+ const entryLabel = isDefaultValue === entry.value.toLowerCase()
292
+ ? `${entry.value} (default)`
293
+ : entry.value;
294
+ entriesWidth +=
295
+ LEGEND_DOT_R * 2 +
296
+ LEGEND_ENTRY_DOT_GAP +
297
+ entryLabel.length * LEGEND_ENTRY_FONT_W +
298
+ LEGEND_ENTRY_TRAIL;
297
299
  }
298
-
299
- const eyeExtra = showEyeIcons ? EYE_ICON_GAP + EYE_ICON_WIDTH : 0;
300
- const headerWidth = group.name.length * CHAR_WIDTH + eyeExtra;
301
- const totalColumnsWidth =
302
- colWidths.reduce((s, w) => s + w, 0) +
303
- (colWidths.length - 1) * LEGEND_ENTRY_GAP;
304
- const maxRowWidth = Math.max(headerWidth, totalColumnsWidth);
305
-
306
- const minifiedWidth = group.name.length * CHAR_WIDTH + LEGEND_PAD * 2;
300
+ const capsuleWidth =
301
+ LEGEND_CAPSULE_PAD * 2 + pillWidth + 4 + entriesWidth;
307
302
 
308
303
  groups.push({
309
304
  name: group.name,
310
- entries: group.entries.map((e) => ({ value: e.value, color: e.color })),
305
+ alias: group.alias,
306
+ entries: group.entries.map((e) => ({
307
+ value: e.value,
308
+ color: e.color,
309
+ isDefault: group.defaultValue?.toLowerCase() === e.value.toLowerCase() || undefined,
310
+ })),
311
311
  x: 0,
312
312
  y: 0,
313
- width: maxRowWidth + LEGEND_PAD * 2,
314
- height: LEGEND_HEADER_H + numRows * LEGEND_ENTRY_H + LEGEND_PAD,
315
- minifiedWidth,
316
- minifiedHeight: LEGEND_HEADER_H + LEGEND_PAD,
313
+ width: capsuleWidth,
314
+ height: LEGEND_HEIGHT,
315
+ minifiedWidth: minPillWidth,
316
+ minifiedHeight: LEGEND_HEIGHT,
317
317
  });
318
318
  }
319
319
 
@@ -356,7 +356,30 @@ export function layoutOrg(
356
356
  hiddenAttributes?: Set<string>
357
357
  ): OrgLayoutResult {
358
358
  if (parsed.roots.length === 0) {
359
- return { nodes: [], edges: [], containers: [], legend: [], width: 0, height: 0 };
359
+ // Legend-only: compute and position legend groups even without nodes
360
+ const showEyeIcons = hiddenAttributes !== undefined;
361
+ const legendGroups = computeLegendGroups(parsed.tagGroups, showEyeIcons);
362
+ if (legendGroups.length === 0) {
363
+ return { nodes: [], edges: [], containers: [], legend: [], width: 0, height: 0 };
364
+ }
365
+
366
+ // Legend-only mode: stack groups vertically, all expanded
367
+ let cy = MARGIN;
368
+ let maxWidth = 0;
369
+ for (const g of legendGroups) {
370
+ g.x = MARGIN;
371
+ g.y = cy;
372
+ cy += LEGEND_HEIGHT + LEGEND_GROUP_GAP;
373
+ if (g.width > maxWidth) maxWidth = g.width;
374
+ }
375
+ return {
376
+ nodes: [],
377
+ edges: [],
378
+ containers: [],
379
+ legend: legendGroups,
380
+ width: maxWidth + MARGIN * 2,
381
+ height: cy - LEGEND_GROUP_GAP + MARGIN,
382
+ };
360
383
  }
361
384
 
362
385
  // Inject default tag group values into node metadata for display.
@@ -1084,7 +1107,7 @@ export function layoutOrg(
1084
1107
  let finalWidth = totalWidth;
1085
1108
  let finalHeight = totalHeight;
1086
1109
 
1087
- const legendPosition = parsed.options?.['legend-position'] ?? 'bottom';
1110
+ const legendPosition = parsed.options?.['legend-position'] ?? 'top';
1088
1111
 
1089
1112
  // When a tag group is active, only that group is laid out (full size).
1090
1113
  // When none is active, all groups are laid out minified.
@@ -1101,7 +1124,7 @@ export function layoutOrg(
1101
1124
  // Bottom: center legend groups horizontally below diagram content
1102
1125
  const totalGroupsWidth =
1103
1126
  visibleGroups.reduce((s, g) => s + effectiveW(g), 0) +
1104
- (visibleGroups.length - 1) * H_GAP;
1127
+ (visibleGroups.length - 1) * LEGEND_GROUP_GAP;
1105
1128
  const neededWidth = totalGroupsWidth + MARGIN * 2;
1106
1129
 
1107
1130
  if (neededWidth > totalWidth) {
@@ -1119,36 +1142,39 @@ export function layoutOrg(
1119
1142
  const startX = (finalWidth - totalGroupsWidth) / 2;
1120
1143
 
1121
1144
  let cx = startX;
1122
- let maxH = 0;
1123
1145
  for (const g of visibleGroups) {
1124
1146
  g.x = cx;
1125
1147
  g.y = legendY;
1126
- cx += effectiveW(g) + H_GAP;
1127
- const h = effectiveH(g);
1128
- if (h > maxH) maxH = h;
1148
+ cx += effectiveW(g) + LEGEND_GROUP_GAP;
1129
1149
  }
1130
1150
 
1131
- finalHeight = totalHeight + LEGEND_GAP + maxH;
1151
+ finalHeight = totalHeight + LEGEND_GAP + LEGEND_HEIGHT;
1132
1152
  } else {
1133
- // Top (default): stack legend groups vertically at top-right
1134
- const maxLegendWidth = Math.max(...visibleGroups.map((g) => effectiveW(g)));
1135
- const legendStartX = totalWidth - MARGIN + LEGEND_GAP;
1136
- let legendY = MARGIN;
1137
-
1138
- for (const g of visibleGroups) {
1139
- g.x = legendStartX;
1140
- g.y = legendY;
1141
- legendY += effectiveH(g) + LEGEND_V_GAP;
1153
+ // Top: horizontal row above chart content, left-aligned
1154
+ const legendShift = LEGEND_HEIGHT + LEGEND_GROUP_GAP;
1155
+
1156
+ // Push all chart content down
1157
+ for (const n of layoutNodes) n.y += legendShift;
1158
+ for (const c of containers) c.y += legendShift;
1159
+ for (const e of layoutEdges) {
1160
+ for (const p of e.points) p.y += legendShift;
1142
1161
  }
1143
1162
 
1144
- const legendRight = legendStartX + maxLegendWidth + MARGIN;
1145
- if (legendRight > finalWidth) {
1146
- finalWidth = legendRight;
1163
+ const totalGroupsWidth =
1164
+ visibleGroups.reduce((s, g) => s + effectiveW(g), 0) +
1165
+ (visibleGroups.length - 1) * LEGEND_GROUP_GAP;
1166
+
1167
+ let cx = MARGIN;
1168
+ for (const g of visibleGroups) {
1169
+ g.x = cx;
1170
+ g.y = MARGIN;
1171
+ cx += effectiveW(g) + LEGEND_GROUP_GAP;
1147
1172
  }
1148
1173
 
1149
- const legendBottom = legendY - LEGEND_V_GAP + MARGIN;
1150
- if (legendBottom > finalHeight) {
1151
- finalHeight = legendBottom;
1174
+ finalHeight += legendShift;
1175
+ const neededWidth = totalGroupsWidth + MARGIN * 2;
1176
+ if (neededWidth > finalWidth) {
1177
+ finalWidth = neededWidth;
1152
1178
  }
1153
1179
  }
1154
1180
  }
package/src/org/parser.ts CHANGED
@@ -79,6 +79,20 @@ const CHART_TYPE_RE = /^chart\s*:\s*(.+)/i;
79
79
  const TITLE_RE = /^title\s*:\s*(.+)/i;
80
80
  const OPTION_RE = /^([a-z][a-z0-9-]*)\s*:\s*(.+)$/i;
81
81
 
82
+ // ============================================================
83
+ // Inference
84
+ // ============================================================
85
+
86
+ /** Returns true if content contains tag group headings (`## ...`), suggesting an org chart. */
87
+ export function looksLikeOrg(content: string): boolean {
88
+ for (const line of content.split('\n')) {
89
+ const trimmed = line.trim();
90
+ if (!trimmed || trimmed.startsWith('//')) continue;
91
+ if (GROUP_HEADING_RE.test(trimmed)) return true;
92
+ }
93
+ return false;
94
+ }
95
+
82
96
  // ============================================================
83
97
  // Parser
84
98
  // ============================================================
@@ -301,7 +315,7 @@ export function parseOrg(
301
315
  }
302
316
  }
303
317
 
304
- if (result.roots.length === 0 && !result.error) {
318
+ if (result.roots.length === 0 && result.tagGroups.length === 0 && !result.error) {
305
319
  const diag = makeDgmoError(1, 'No nodes found in org chart');
306
320
  result.diagnostics.push(diag);
307
321
  result.error = formatDgmoError(diag);
@@ -36,29 +36,17 @@ const CONTAINER_HEADER_HEIGHT = 28;
36
36
  const COLLAPSE_BAR_HEIGHT = 6;
37
37
  const COLLAPSE_BAR_INSET = 0;
38
38
 
39
- // Legend
40
- const LEGEND_RADIUS = 6;
41
- const LEGEND_DOT_R = 5;
42
- const LEGEND_DOT_TEXT_GAP = 6;
43
- const LEGEND_ENTRY_GAP = 12;
44
- const LEGEND_PAD = 10;
45
- const LEGEND_HEADER_H = 20;
46
- const LEGEND_ENTRY_H = 18;
47
- const LEGEND_FONT_SIZE = 11;
48
- const LEGEND_MAX_PER_ROW = 3;
49
- const LEGEND_CHAR_WIDTH = 7.5;
50
-
51
- // Eye icon (12×12 viewBox, scaled from 0,0 to 12,12)
52
- const EYE_ICON_SIZE = 12;
53
- const EYE_ICON_GAP = 6;
54
- // Open eye: elliptical outline + circle pupil
55
- const EYE_OPEN_PATH =
56
- 'M1 6C1 6 3 2 6 2C9 2 11 6 11 6C11 6 9 10 6 10C3 10 1 6 1 6Z';
57
- const EYE_PUPIL_CX = 6;
58
- const EYE_PUPIL_CY = 6;
59
- const EYE_PUPIL_R = 1.8;
60
- // Closed eye: same outline + diagonal slash
61
- const EYE_SLASH_PATH = 'M2 2L10 10';
39
+ // Legend (kanban-style pills)
40
+ const LEGEND_HEIGHT = 28;
41
+ const LEGEND_PILL_PAD = 16;
42
+ const LEGEND_PILL_FONT_SIZE = 11;
43
+ const LEGEND_PILL_FONT_W = LEGEND_PILL_FONT_SIZE * 0.6;
44
+ const LEGEND_CAPSULE_PAD = 4;
45
+ const LEGEND_DOT_R = 4;
46
+ const LEGEND_ENTRY_FONT_SIZE = 10;
47
+ const LEGEND_ENTRY_FONT_W = LEGEND_ENTRY_FONT_SIZE * 0.6;
48
+ const LEGEND_ENTRY_DOT_GAP = 4;
49
+ const LEGEND_ENTRY_TRAIL = 8;
62
50
 
63
51
  // ============================================================
64
52
  // Color helpers (inline to avoid cross-module import issues)
@@ -457,170 +445,109 @@ export function renderOrg(
457
445
 
458
446
  }
459
447
 
460
- // Render legend — skip entirely in export mode.
461
- // No active group: all groups rendered minified (compact chips).
462
- // Active group selected: only that group rendered (full size), others hidden.
463
- if (!exportDims) for (const group of layout.legend) {
448
+ // Render legend — kanban-style pills.
449
+ // Skip in export mode (unless legend-only chart).
450
+ // Legend-only (no nodes): all groups rendered as expanded capsules.
451
+ // Active group: only that group rendered as capsule (pill + entries).
452
+ // No active group: all groups rendered as standalone pills.
453
+ const legendOnly = layout.nodes.length === 0;
454
+ if (!exportDims || legendOnly) for (const group of layout.legend) {
464
455
  const isActive =
465
- activeTagGroup != null &&
466
- group.name.toLowerCase() === activeTagGroup.toLowerCase();
456
+ legendOnly ||
457
+ (activeTagGroup != null &&
458
+ group.name.toLowerCase() === activeTagGroup.toLowerCase());
467
459
 
468
- // When a group is active, skip all other groups entirely
469
- if (activeTagGroup != null && !isActive) continue;
460
+ // When a group is active, skip all other groups entirely (not in legend-only mode)
461
+ if (!legendOnly && activeTagGroup != null && !isActive) continue;
470
462
 
471
- // No active group → minified; active group → full
472
- const isMinified = activeTagGroup == null;
463
+ const groupBg = isDark
464
+ ? mix(palette.surface, palette.bg, 50)
465
+ : mix(palette.surface, palette.bg, 30);
473
466
 
474
- const renderW = isMinified ? group.minifiedWidth : group.width;
475
- const renderH = isMinified ? group.minifiedHeight : group.height;
467
+ // Pill label: include alias when expanded (e.g., "Rank (r)")
468
+ const pillLabel = isActive && group.alias ? `${group.name} (${group.alias})` : group.name;
469
+ const pillWidth =
470
+ pillLabel.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
476
471
 
477
472
  const gEl = contentG
478
473
  .append('g')
479
474
  .attr('transform', `translate(${group.x}, ${group.y})`)
480
475
  .attr('class', 'org-legend-group')
481
476
  .attr('data-legend-group', group.name.toLowerCase())
482
- .style('cursor', 'pointer');
477
+ .style('cursor', legendOnly ? 'default' : 'pointer');
483
478
 
484
- // Background rect
485
- const legendFill = mix(palette.surface, palette.bg, 40);
486
- const bgRect = gEl
487
- .append('rect')
488
- .attr('x', 0)
489
- .attr('y', 0)
490
- .attr('width', renderW)
491
- .attr('height', renderH)
492
- .attr('rx', LEGEND_RADIUS)
493
- .attr('fill', legendFill);
479
+ // Outer capsule background (active only)
480
+ if (isActive) {
481
+ gEl
482
+ .append('rect')
483
+ .attr('width', group.width)
484
+ .attr('height', LEGEND_HEIGHT)
485
+ .attr('rx', LEGEND_HEIGHT / 2)
486
+ .attr('fill', groupBg);
487
+ }
494
488
 
489
+ const pillX = isActive ? LEGEND_CAPSULE_PAD : 0;
490
+ const pillY = isActive ? LEGEND_CAPSULE_PAD : 0;
491
+ const pillH = LEGEND_HEIGHT - (isActive ? LEGEND_CAPSULE_PAD * 2 : 0);
492
+
493
+ // Pill background
494
+ gEl
495
+ .append('rect')
496
+ .attr('x', pillX)
497
+ .attr('y', pillY)
498
+ .attr('width', pillWidth)
499
+ .attr('height', pillH)
500
+ .attr('rx', pillH / 2)
501
+ .attr('fill', isActive ? palette.bg : groupBg);
502
+
503
+ // Active pill border
495
504
  if (isActive) {
496
- bgRect
497
- .attr('stroke', palette.primary)
498
- .attr('stroke-opacity', 0.8)
499
- .attr('stroke-width', 2);
500
- } else {
501
- bgRect
502
- .attr('stroke', palette.textMuted)
503
- .attr('stroke-opacity', 0.35)
504
- .attr('stroke-width', NODE_STROKE_WIDTH);
505
+ gEl
506
+ .append('rect')
507
+ .attr('x', pillX)
508
+ .attr('y', pillY)
509
+ .attr('width', pillWidth)
510
+ .attr('height', pillH)
511
+ .attr('rx', pillH / 2)
512
+ .attr('fill', 'none')
513
+ .attr('stroke', mix(palette.textMuted, palette.bg, 50))
514
+ .attr('stroke-width', 0.75);
505
515
  }
506
516
 
507
- // Group name header
517
+ // Pill text
508
518
  gEl
509
519
  .append('text')
510
- .attr('x', LEGEND_PAD)
511
- .attr('y', LEGEND_HEADER_H / 2 + LEGEND_FONT_SIZE / 2 - 2)
512
- .attr('fill', isMinified ? palette.textMuted : palette.text)
513
- .attr('font-size', LEGEND_FONT_SIZE)
514
- .attr('font-weight', 'bold')
515
- .text(group.name);
516
-
517
- // Minified groups only show the header — skip entries and eye icon
518
- if (isMinified) continue;
519
-
520
- // Eye icon for visibility toggle (interactive only, not export)
521
- if (hiddenAttributes !== undefined && !exportDims) {
522
- const groupKey = group.name.toLowerCase();
523
- const isHidden = hiddenAttributes.has(groupKey);
524
- const eyeX =
525
- LEGEND_PAD + group.name.length * LEGEND_CHAR_WIDTH + EYE_ICON_GAP;
526
- const eyeY = (LEGEND_HEADER_H - EYE_ICON_SIZE) / 2;
527
-
528
- const eyeG = gEl
529
- .append('g')
530
- .attr('class', 'org-legend-eye')
531
- .attr('data-legend-visibility', groupKey)
532
- .attr('transform', `translate(${eyeX}, ${eyeY})`);
533
-
534
- // Transparent hit area
535
- eyeG
536
- .append('rect')
537
- .attr('x', -4)
538
- .attr('y', -4)
539
- .attr('width', EYE_ICON_SIZE + 8)
540
- .attr('height', EYE_ICON_SIZE + 8)
541
- .attr('fill', 'transparent');
542
-
543
- // Eye outline
544
- eyeG
545
- .append('path')
546
- .attr('d', EYE_OPEN_PATH)
547
- .attr('fill', isHidden ? 'none' : palette.textMuted)
548
- .attr('fill-opacity', isHidden ? 0 : 0.15)
549
- .attr('stroke', palette.textMuted)
550
- .attr('stroke-width', 1.2)
551
- .attr('opacity', isHidden ? 0.5 : 0.7);
552
-
553
- if (!isHidden) {
554
- // Pupil (only when visible)
555
- eyeG
520
+ .attr('x', pillX + pillWidth / 2)
521
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
522
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
523
+ .attr('font-weight', '500')
524
+ .attr('fill', isActive ? palette.text : palette.textMuted)
525
+ .attr('text-anchor', 'middle')
526
+ .text(pillLabel);
527
+
528
+ // Entries inside capsule (active only)
529
+ if (isActive) {
530
+ let entryX = pillX + pillWidth + 4;
531
+ for (const entry of group.entries) {
532
+ gEl
556
533
  .append('circle')
557
- .attr('cx', EYE_PUPIL_CX)
558
- .attr('cy', EYE_PUPIL_CY)
559
- .attr('r', EYE_PUPIL_R)
534
+ .attr('cx', entryX + LEGEND_DOT_R)
535
+ .attr('cy', LEGEND_HEIGHT / 2)
536
+ .attr('r', LEGEND_DOT_R)
537
+ .attr('fill', entry.color);
538
+
539
+ const textX = entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP;
540
+ const entryLabel = entry.isDefault ? `${entry.value} (default)` : entry.value;
541
+ gEl
542
+ .append('text')
543
+ .attr('x', textX)
544
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
545
+ .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
560
546
  .attr('fill', palette.textMuted)
561
- .attr('opacity', 0.7);
562
- } else {
563
- // Slash through the eye (hidden state)
564
- eyeG
565
- .append('line')
566
- .attr('x1', 2)
567
- .attr('y1', 2)
568
- .attr('x2', 10)
569
- .attr('y2', 10)
570
- .attr('stroke', palette.textMuted)
571
- .attr('stroke-width', 1.5)
572
- .attr('opacity', 0.5);
573
- }
574
- }
547
+ .text(entryLabel);
575
548
 
576
- // Pre-compute column widths so dots align across rows
577
- const entryWidths = group.entries.map(
578
- (e) =>
579
- LEGEND_DOT_R * 2 + LEGEND_DOT_TEXT_GAP + e.value.length * LEGEND_CHAR_WIDTH
580
- );
581
- const numRows = Math.ceil(group.entries.length / LEGEND_MAX_PER_ROW);
582
- const colWidths: number[] = [];
583
- for (let col = 0; col < LEGEND_MAX_PER_ROW; col++) {
584
- let maxW = 0;
585
- for (let r = 0; r < numRows; r++) {
586
- const idx = r * LEGEND_MAX_PER_ROW + col;
587
- if (idx < entryWidths.length && entryWidths[idx] > maxW) {
588
- maxW = entryWidths[idx];
589
- }
549
+ entryX = textX + entryLabel.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
590
550
  }
591
- if (maxW > 0) colWidths.push(maxW);
592
- }
593
- const colX: number[] = [LEGEND_PAD];
594
- for (let c = 1; c < colWidths.length; c++) {
595
- colX.push(colX[c - 1] + colWidths[c - 1] + LEGEND_ENTRY_GAP);
596
- }
597
-
598
- // Entries: colored dot + value label
599
- for (let i = 0; i < group.entries.length; i++) {
600
- const entry = group.entries[i];
601
- const row = Math.floor(i / LEGEND_MAX_PER_ROW);
602
- const col = i % LEGEND_MAX_PER_ROW;
603
- const entryX = colX[col];
604
-
605
- const entryY =
606
- LEGEND_HEADER_H + row * LEGEND_ENTRY_H + LEGEND_ENTRY_H / 2;
607
-
608
- // Colored dot
609
- gEl
610
- .append('circle')
611
- .attr('cx', entryX + LEGEND_DOT_R)
612
- .attr('cy', entryY)
613
- .attr('r', LEGEND_DOT_R)
614
- .attr('fill', entry.color);
615
-
616
- // Value label
617
- gEl
618
- .append('text')
619
- .attr('x', entryX + LEGEND_DOT_R * 2 + LEGEND_DOT_TEXT_GAP)
620
- .attr('y', entryY + LEGEND_FONT_SIZE / 2 - 2)
621
- .attr('fill', palette.text)
622
- .attr('font-size', LEGEND_FONT_SIZE)
623
- .text(entry.value);
624
551
  }
625
552
  }
626
553
  }
@@ -635,7 +562,7 @@ export function renderOrgForExport(
635
562
  palette: PaletteColors
636
563
  ): string {
637
564
  const parsed = parseOrg(content, palette);
638
- if (parsed.error || parsed.roots.length === 0) return '';
565
+ if (parsed.error) return '';
639
566
 
640
567
  // Extract hide option for export: cards sized without hidden attributes
641
568
  const hideOption = parsed.options?.['hide'];
@@ -911,11 +911,13 @@ export function renderSequenceDiagram(
911
911
  msgToLastStep.set(step.messageIndex, si);
912
912
  });
913
913
 
914
- // Map a note to the first render-step index of its preceding message
915
- // (the forward/call arrow, not the return arrow).
914
+ // Map a note to the last render-step index of its preceding message
915
+ // (the return arrow if present, otherwise the call arrow).
916
+ // This ensures notes are positioned below the return arrow so they
917
+ // don't overlap it.
916
918
  // If the note's closest preceding message is hidden (collapsed section), return -1
917
919
  // so the note is hidden along with its section.
918
- const findAssociatedFirstStep = (note: SequenceNote): number => {
920
+ const findAssociatedLastStep = (note: SequenceNote): number => {
919
921
  // First find the closest preceding message (ignoring hidden filter)
920
922
  let closestMsgIndex = -1;
921
923
  let closestLine = -1;
@@ -933,7 +935,7 @@ export function renderSequenceDiagram(
933
935
  return -1;
934
936
  }
935
937
  if (closestMsgIndex < 0) return -1;
936
- return msgToFirstStep.get(closestMsgIndex) ?? -1;
938
+ return msgToLastStep.get(closestMsgIndex) ?? -1;
937
939
  };
938
940
 
939
941
  // Find the first visible message index in an element subtree
@@ -1259,7 +1261,7 @@ export function renderSequenceDiagram(
1259
1261
  for (let i = 0; i < els.length; i++) {
1260
1262
  const el = els[i];
1261
1263
  if (isSequenceNote(el)) {
1262
- const si = findAssociatedFirstStep(el);
1264
+ const si = findAssociatedLastStep(el);
1263
1265
  if (si < 0) continue;
1264
1266
  // Check if there's a preceding note that we should stack below
1265
1267
  const prevNote = i > 0 && isSequenceNote(els[i - 1]) ? (els[i - 1] as SequenceNote) : null;