@diagrammo/dgmo 0.2.20 → 0.2.21

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
@@ -97,18 +97,17 @@ const CONTAINER_META_LINE_HEIGHT = 16;
97
97
  const STACK_V_GAP = 20;
98
98
 
99
99
 
100
- // Legend
100
+ // Legend (kanban-style pills)
101
101
  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;
102
+ const LEGEND_HEIGHT = 28;
103
+ const LEGEND_PILL_PAD = 16;
104
+ const LEGEND_PILL_FONT_W = 11 * 0.6;
105
+ const LEGEND_CAPSULE_PAD = 4;
106
+ const LEGEND_DOT_R = 4;
107
+ const LEGEND_ENTRY_FONT_W = 10 * 0.6;
108
+ const LEGEND_ENTRY_DOT_GAP = 4;
109
+ const LEGEND_ENTRY_TRAIL = 8;
110
+ const LEGEND_GROUP_GAP = 12;
112
111
 
113
112
  // ============================================================
114
113
  // Helpers
@@ -271,49 +270,35 @@ function centerHeavyChildren(node: TreeNode): void {
271
270
  // Layout
272
271
  // ============================================================
273
272
 
274
- function computeLegendGroups(tagGroups: OrgTagGroup[], showEyeIcons: boolean): OrgLegendGroup[] {
273
+ function computeLegendGroups(tagGroups: OrgTagGroup[], _showEyeIcons: boolean): OrgLegendGroup[] {
275
274
  const groups: OrgLegendGroup[] = [];
276
275
 
277
276
  for (const group of tagGroups) {
278
277
  if (group.entries.length === 0) continue;
279
278
 
280
- const entryWidths = group.entries.map(
281
- (e) =>
282
- LEGEND_DOT_R * 2 + LEGEND_DOT_TEXT_GAP + e.value.length * CHAR_WIDTH
283
- );
279
+ const pillWidth = group.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
284
280
 
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
+ // Capsule: pad + pill + gap + entries + pad
282
+ let entriesWidth = 0;
283
+ for (const entry of group.entries) {
284
+ entriesWidth +=
285
+ LEGEND_DOT_R * 2 +
286
+ LEGEND_ENTRY_DOT_GAP +
287
+ entry.value.length * LEGEND_ENTRY_FONT_W +
288
+ LEGEND_ENTRY_TRAIL;
297
289
  }
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;
290
+ const capsuleWidth =
291
+ LEGEND_CAPSULE_PAD * 2 + pillWidth + 4 + entriesWidth;
307
292
 
308
293
  groups.push({
309
294
  name: group.name,
310
295
  entries: group.entries.map((e) => ({ value: e.value, color: e.color })),
311
296
  x: 0,
312
297
  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,
298
+ width: capsuleWidth,
299
+ height: LEGEND_HEIGHT,
300
+ minifiedWidth: pillWidth,
301
+ minifiedHeight: LEGEND_HEIGHT,
317
302
  });
318
303
  }
319
304
 
@@ -356,7 +341,28 @@ export function layoutOrg(
356
341
  hiddenAttributes?: Set<string>
357
342
  ): OrgLayoutResult {
358
343
  if (parsed.roots.length === 0) {
359
- return { nodes: [], edges: [], containers: [], legend: [], width: 0, height: 0 };
344
+ // Legend-only: compute and position legend groups even without nodes
345
+ const showEyeIcons = hiddenAttributes !== undefined;
346
+ const legendGroups = computeLegendGroups(parsed.tagGroups, showEyeIcons);
347
+ if (legendGroups.length === 0) {
348
+ return { nodes: [], edges: [], containers: [], legend: [], width: 0, height: 0 };
349
+ }
350
+
351
+ // Layout legend groups horizontally (all minified when no nodes)
352
+ let cx = MARGIN;
353
+ for (const g of legendGroups) {
354
+ g.x = cx;
355
+ g.y = MARGIN;
356
+ cx += g.minifiedWidth + LEGEND_GROUP_GAP;
357
+ }
358
+ return {
359
+ nodes: [],
360
+ edges: [],
361
+ containers: [],
362
+ legend: legendGroups,
363
+ width: cx - LEGEND_GROUP_GAP + MARGIN,
364
+ height: LEGEND_HEIGHT + MARGIN * 2,
365
+ };
360
366
  }
361
367
 
362
368
  // Inject default tag group values into node metadata for display.
@@ -1101,7 +1107,7 @@ export function layoutOrg(
1101
1107
  // Bottom: center legend groups horizontally below diagram content
1102
1108
  const totalGroupsWidth =
1103
1109
  visibleGroups.reduce((s, g) => s + effectiveW(g), 0) +
1104
- (visibleGroups.length - 1) * H_GAP;
1110
+ (visibleGroups.length - 1) * LEGEND_GROUP_GAP;
1105
1111
  const neededWidth = totalGroupsWidth + MARGIN * 2;
1106
1112
 
1107
1113
  if (neededWidth > totalWidth) {
@@ -1119,34 +1125,34 @@ export function layoutOrg(
1119
1125
  const startX = (finalWidth - totalGroupsWidth) / 2;
1120
1126
 
1121
1127
  let cx = startX;
1122
- let maxH = 0;
1123
1128
  for (const g of visibleGroups) {
1124
1129
  g.x = cx;
1125
1130
  g.y = legendY;
1126
- cx += effectiveW(g) + H_GAP;
1127
- const h = effectiveH(g);
1128
- if (h > maxH) maxH = h;
1131
+ cx += effectiveW(g) + LEGEND_GROUP_GAP;
1129
1132
  }
1130
1133
 
1131
- finalHeight = totalHeight + LEGEND_GAP + maxH;
1134
+ finalHeight = totalHeight + LEGEND_GAP + LEGEND_HEIGHT;
1132
1135
  } else {
1133
- // Top (default): stack legend groups vertically at top-right
1134
- const maxLegendWidth = Math.max(...visibleGroups.map((g) => effectiveW(g)));
1136
+ // Top: horizontal row at top-right
1137
+ const totalGroupsWidth =
1138
+ visibleGroups.reduce((s, g) => s + effectiveW(g), 0) +
1139
+ (visibleGroups.length - 1) * LEGEND_GROUP_GAP;
1135
1140
  const legendStartX = totalWidth - MARGIN + LEGEND_GAP;
1136
- let legendY = MARGIN;
1141
+ const legendY = MARGIN;
1137
1142
 
1143
+ let cx = legendStartX;
1138
1144
  for (const g of visibleGroups) {
1139
- g.x = legendStartX;
1145
+ g.x = cx;
1140
1146
  g.y = legendY;
1141
- legendY += effectiveH(g) + LEGEND_V_GAP;
1147
+ cx += effectiveW(g) + LEGEND_GROUP_GAP;
1142
1148
  }
1143
1149
 
1144
- const legendRight = legendStartX + maxLegendWidth + MARGIN;
1150
+ const legendRight = legendStartX + totalGroupsWidth + MARGIN;
1145
1151
  if (legendRight > finalWidth) {
1146
1152
  finalWidth = legendRight;
1147
1153
  }
1148
1154
 
1149
- const legendBottom = legendY - LEGEND_V_GAP + MARGIN;
1155
+ const legendBottom = legendY + LEGEND_HEIGHT + MARGIN;
1150
1156
  if (legendBottom > finalHeight) {
1151
1157
  finalHeight = legendBottom;
1152
1158
  }
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,10 +445,11 @@ 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
+ // Active group: only that group rendered as capsule (pill + entries).
451
+ // No active group: all groups rendered as standalone pills.
452
+ if (!exportDims || layout.nodes.length === 0) for (const group of layout.legend) {
464
453
  const isActive =
465
454
  activeTagGroup != null &&
466
455
  group.name.toLowerCase() === activeTagGroup.toLowerCase();
@@ -468,11 +457,10 @@ export function renderOrg(
468
457
  // When a group is active, skip all other groups entirely
469
458
  if (activeTagGroup != null && !isActive) continue;
470
459
 
471
- // No active group minified; active group full
472
- const isMinified = activeTagGroup == null;
460
+ const groupBg = mix(palette.surface, palette.bg, isDark ? 35 : 20);
473
461
 
474
- const renderW = isMinified ? group.minifiedWidth : group.width;
475
- const renderH = isMinified ? group.minifiedHeight : group.height;
462
+ const pillWidth =
463
+ group.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
476
464
 
477
465
  const gEl = contentG
478
466
  .append('g')
@@ -481,146 +469,77 @@ export function renderOrg(
481
469
  .attr('data-legend-group', group.name.toLowerCase())
482
470
  .style('cursor', 'pointer');
483
471
 
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);
472
+ // Outer capsule background (active only)
473
+ if (isActive) {
474
+ gEl
475
+ .append('rect')
476
+ .attr('width', group.width)
477
+ .attr('height', LEGEND_HEIGHT)
478
+ .attr('rx', LEGEND_HEIGHT / 2)
479
+ .attr('fill', groupBg);
480
+ }
494
481
 
482
+ const pillX = isActive ? LEGEND_CAPSULE_PAD : 0;
483
+ const pillY = isActive ? LEGEND_CAPSULE_PAD : 0;
484
+ const pillH = LEGEND_HEIGHT - (isActive ? LEGEND_CAPSULE_PAD * 2 : 0);
485
+
486
+ // Pill background
487
+ gEl
488
+ .append('rect')
489
+ .attr('x', pillX)
490
+ .attr('y', pillY)
491
+ .attr('width', pillWidth)
492
+ .attr('height', pillH)
493
+ .attr('rx', pillH / 2)
494
+ .attr('fill', isActive ? palette.bg : groupBg);
495
+
496
+ // Active pill border
495
497
  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);
498
+ gEl
499
+ .append('rect')
500
+ .attr('x', pillX)
501
+ .attr('y', pillY)
502
+ .attr('width', pillWidth)
503
+ .attr('height', pillH)
504
+ .attr('rx', pillH / 2)
505
+ .attr('fill', 'none')
506
+ .attr('stroke', mix(palette.textMuted, palette.bg, 50))
507
+ .attr('stroke-width', 0.75);
505
508
  }
506
509
 
507
- // Group name header
510
+ // Pill text
508
511
  gEl
509
512
  .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')
513
+ .attr('x', pillX + pillWidth / 2)
514
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
515
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
516
+ .attr('font-weight', '500')
517
+ .attr('fill', isActive ? palette.text : palette.textMuted)
518
+ .attr('text-anchor', 'middle')
515
519
  .text(group.name);
516
520
 
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
521
+ // Entries inside capsule (active only)
522
+ if (isActive) {
523
+ let entryX = pillX + pillWidth + 4;
524
+ for (const entry of group.entries) {
525
+ gEl
556
526
  .append('circle')
557
- .attr('cx', EYE_PUPIL_CX)
558
- .attr('cy', EYE_PUPIL_CY)
559
- .attr('r', EYE_PUPIL_R)
527
+ .attr('cx', entryX + LEGEND_DOT_R)
528
+ .attr('cy', LEGEND_HEIGHT / 2)
529
+ .attr('r', LEGEND_DOT_R)
530
+ .attr('fill', entry.color);
531
+
532
+ const textX = entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP;
533
+ gEl
534
+ .append('text')
535
+ .attr('x', textX)
536
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
537
+ .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
560
538
  .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
- }
539
+ .text(entry.value);
575
540
 
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
- }
541
+ entryX = textX + entry.value.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
590
542
  }
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
543
  }
625
544
  }
626
545
  }
@@ -635,7 +554,7 @@ export function renderOrgForExport(
635
554
  palette: PaletteColors
636
555
  ): string {
637
556
  const parsed = parseOrg(content, palette);
638
- if (parsed.error || parsed.roots.length === 0) return '';
557
+ if (parsed.error) return '';
639
558
 
640
559
  // Extract hide option for export: cards sized without hidden attributes
641
560
  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;