@diagrammo/dgmo 0.0.1 → 0.1.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.
@@ -392,6 +392,71 @@ function renderGatewayParticipant(
392
392
  renderRectParticipant(g, palette, isDark);
393
393
  }
394
394
 
395
+ // ============================================================
396
+ // Collapsible Section Support
397
+ // ============================================================
398
+
399
+ export interface SectionMessageGroup {
400
+ section: import('./parser').SequenceSection;
401
+ messageIndices: number[]; // indices into messages[]
402
+ }
403
+
404
+ export interface SequenceRenderOptions {
405
+ collapsedSections?: Set<number>; // keyed by section lineNumber
406
+ }
407
+
408
+ /**
409
+ * Group messages by the top-level section that precedes them.
410
+ * Messages before the first section are ungrouped (always visible).
411
+ * Only top-level sections are collapsible — sections inside blocks are excluded.
412
+ */
413
+ export function groupMessagesBySection(
414
+ elements: SequenceElement[],
415
+ messages: SequenceMessage[]
416
+ ): SectionMessageGroup[] {
417
+ const groups: SectionMessageGroup[] = [];
418
+ let currentGroup: SectionMessageGroup | null = null;
419
+
420
+ // Recursively collect all message indices from an element subtree
421
+ const collectIndices = (els: SequenceElement[]): number[] => {
422
+ const indices: number[] = [];
423
+ for (const el of els) {
424
+ if (isSequenceBlock(el)) {
425
+ indices.push(
426
+ ...collectIndices(el.children),
427
+ ...collectIndices(el.elseChildren)
428
+ );
429
+ } else if (isSequenceSection(el)) {
430
+ // Sections inside blocks are not top-level — skip
431
+ continue;
432
+ } else {
433
+ const idx = messages.indexOf(el);
434
+ if (idx >= 0) indices.push(idx);
435
+ }
436
+ }
437
+ return indices;
438
+ };
439
+
440
+ for (const el of elements) {
441
+ if (isSequenceSection(el)) {
442
+ // Start a new group for this top-level section
443
+ currentGroup = { section: el, messageIndices: [] };
444
+ groups.push(currentGroup);
445
+ } else if (currentGroup) {
446
+ // Collect messages from this element into the current group
447
+ if (isSequenceBlock(el)) {
448
+ currentGroup.messageIndices.push(...collectIndices([el]));
449
+ } else {
450
+ const idx = messages.indexOf(el);
451
+ if (idx >= 0) currentGroup.messageIndices.push(idx);
452
+ }
453
+ }
454
+ // Messages before the first section are ungrouped — skip
455
+ }
456
+
457
+ return groups;
458
+ }
459
+
395
460
  // ============================================================
396
461
  // Render Sequence Builder (stack-based return placement)
397
462
  // ============================================================
@@ -650,21 +715,41 @@ export function renderSequenceDiagram(
650
715
  parsed: ParsedSequenceDgmo,
651
716
  palette: PaletteColors,
652
717
  isDark: boolean,
653
- _onNavigateToLine?: (line: number) => void
718
+ _onNavigateToLine?: (line: number) => void,
719
+ options?: SequenceRenderOptions
654
720
  ): void {
655
721
  // Clear previous content
656
722
  d3Selection.select(container).selectAll('*').remove();
657
723
 
658
- const { title, messages, elements, groups, options } = parsed;
724
+ const { title, messages, elements, groups, options: parsedOptions } = parsed;
725
+ const collapsedSections = options?.collapsedSections;
659
726
  const participants = applyPositionOverrides(
660
727
  applyGroupOrdering(parsed.participants, groups)
661
728
  );
662
729
  if (participants.length === 0) return;
663
730
 
664
- const activationsOff = options.activations?.toLowerCase() === 'off';
731
+ const activationsOff = parsedOptions.activations?.toLowerCase() === 'off';
732
+
733
+ // Build hidden message set for collapse support
734
+ const hiddenMsgIndices = new Set<number>();
735
+ if (collapsedSections && collapsedSections.size > 0) {
736
+ const sectionGroups = groupMessagesBySection(elements, messages);
737
+ for (const grp of sectionGroups) {
738
+ if (collapsedSections.has(grp.section.lineNumber)) {
739
+ for (const idx of grp.messageIndices) {
740
+ hiddenMsgIndices.add(idx);
741
+ }
742
+ }
743
+ }
744
+ }
665
745
 
666
746
  // Build render sequence with stack-based return placement
667
- const renderSteps = buildRenderSequence(messages);
747
+ // Run on ALL messages first (preserves call stack correctness), then filter
748
+ const allRenderSteps = buildRenderSequence(messages);
749
+ const renderSteps =
750
+ hiddenMsgIndices.size > 0
751
+ ? allRenderSteps.filter((s) => !hiddenMsgIndices.has(s.messageIndex))
752
+ : allRenderSteps;
668
753
  const activations = activationsOff ? [] : computeActivations(renderSteps);
669
754
  const stepSpacing = 35;
670
755
 
@@ -683,7 +768,7 @@ export function renderSequenceDiagram(
683
768
  msgToLastStep.set(step.messageIndex, si);
684
769
  });
685
770
 
686
- // Find the first message index in an element subtree
771
+ // Find the first visible message index in an element subtree
687
772
  const findFirstMsgIndex = (els: SequenceElement[]): number => {
688
773
  for (const el of els) {
689
774
  if (isSequenceBlock(el)) {
@@ -691,58 +776,37 @@ export function renderSequenceDiagram(
691
776
  if (idx >= 0) return idx;
692
777
  } else if (!isSequenceSection(el)) {
693
778
  const idx = messages.indexOf(el);
694
- if (idx >= 0) return idx;
779
+ if (idx >= 0 && !hiddenMsgIndices.has(idx)) return idx;
695
780
  }
696
781
  }
697
782
  return -1;
698
783
  };
699
784
 
700
- // Compute extra Y offset needed before each message
701
- const SECTION_SPACING = 40;
785
+ // Section layout constants
786
+ const SECTION_TOP_PAD = 35; // space above section divider line (matches stepSpacing)
787
+ const SECTION_BOTTOM_PAD = 45; // space below section divider line before next content
788
+
789
+ // Block spacing via extraBeforeMsg (sections handled separately below)
702
790
  const extraBeforeMsg = new Map<number, number>();
703
791
  const addExtra = (msgIdx: number, amount: number) => {
704
792
  extraBeforeMsg.set(msgIdx, (extraBeforeMsg.get(msgIdx) || 0) + amount);
705
793
  };
706
794
 
707
- // Track sections mapped to the message index they precede
708
- const sectionBeforeMsg = new Map<
709
- number,
710
- import('./parser').SequenceSection[]
711
- >();
712
-
713
- const markElementSpacing = (els: SequenceElement[]): void => {
795
+ const markBlockSpacing = (els: SequenceElement[]): void => {
714
796
  for (let i = 0; i < els.length; i++) {
715
797
  const el = els[i];
716
-
717
- // Handle sections — add spacing before the next message
718
- if (isSequenceSection(el)) {
719
- // Find the next message after this section
720
- const nextMsgIdx =
721
- i + 1 < els.length ? findFirstMsgIndex(els.slice(i + 1)) : -1;
722
- if (nextMsgIdx >= 0) {
723
- addExtra(nextMsgIdx, SECTION_SPACING);
724
- const existing = sectionBeforeMsg.get(nextMsgIdx) || [];
725
- existing.push(el);
726
- sectionBeforeMsg.set(nextMsgIdx, existing);
727
- }
728
- continue;
729
- }
730
-
798
+ if (isSequenceSection(el)) continue; // sections handled separately
731
799
  if (!isSequenceBlock(el)) continue;
732
800
 
733
- // First message in this block needs header space
734
801
  const firstIdx = findFirstMsgIndex(el.children);
735
802
  if (firstIdx >= 0) addExtra(firstIdx, BLOCK_HEADER_SPACE);
736
803
 
737
- // First message in else section needs header space
738
804
  const firstElseIdx = findFirstMsgIndex(el.elseChildren);
739
805
  if (firstElseIdx >= 0) addExtra(firstElseIdx, BLOCK_HEADER_SPACE);
740
806
 
741
- // Recurse into nested blocks and sections
742
- markElementSpacing(el.children);
743
- markElementSpacing(el.elseChildren);
807
+ markBlockSpacing(el.children);
808
+ markBlockSpacing(el.elseChildren);
744
809
 
745
- // Next sibling after this block needs after-block spacing
746
810
  if (i + 1 < els.length) {
747
811
  const nextIdx = findFirstMsgIndex([els[i + 1]]);
748
812
  if (nextIdx >= 0) addExtra(nextIdx, BLOCK_AFTER_SPACE);
@@ -751,7 +815,119 @@ export function renderSequenceDiagram(
751
815
  };
752
816
 
753
817
  if (elements && elements.length > 0) {
754
- markElementSpacing(elements);
818
+ markBlockSpacing(elements);
819
+ }
820
+
821
+ // --- Section-aware Y layout ---
822
+ // Sections get their own Y positions computed from content above them (not anchored
823
+ // to messages below). This ensures toggling collapse/expand doesn't move the divider.
824
+
825
+ // Walk top-level elements to build section regions
826
+ interface SectionRegion {
827
+ section: import('./parser').SequenceSection;
828
+ msgIndices: number[]; // message indices belonging to this section
829
+ }
830
+ const preSectionMsgIndices: number[] = [];
831
+ const sectionRegions: SectionRegion[] = [];
832
+ {
833
+ const collectMsgIndicesFromBlock = (
834
+ block: import('./parser').SequenceBlock
835
+ ): number[] => {
836
+ const indices: number[] = [];
837
+ for (const child of block.children) {
838
+ if (isSequenceBlock(child)) {
839
+ indices.push(...collectMsgIndicesFromBlock(child));
840
+ } else if (!isSequenceSection(child)) {
841
+ const idx = messages.indexOf(child);
842
+ if (idx >= 0) indices.push(idx);
843
+ }
844
+ }
845
+ for (const child of block.elseChildren) {
846
+ if (isSequenceBlock(child)) {
847
+ indices.push(...collectMsgIndicesFromBlock(child));
848
+ } else if (!isSequenceSection(child)) {
849
+ const idx = messages.indexOf(child);
850
+ if (idx >= 0) indices.push(idx);
851
+ }
852
+ }
853
+ return indices;
854
+ };
855
+
856
+ let currentTarget = preSectionMsgIndices;
857
+ for (const el of elements) {
858
+ if (isSequenceSection(el)) {
859
+ const region: SectionRegion = { section: el, msgIndices: [] };
860
+ sectionRegions.push(region);
861
+ currentTarget = region.msgIndices;
862
+ } else if (isSequenceBlock(el)) {
863
+ currentTarget.push(...collectMsgIndicesFromBlock(el));
864
+ } else {
865
+ const idx = messages.indexOf(el);
866
+ if (idx >= 0) currentTarget.push(idx);
867
+ }
868
+ }
869
+ }
870
+
871
+ // Build mapping from original (all) render step index → filtered step index
872
+ const allMsgToFirstStep = new Map<number, number>();
873
+ allRenderSteps.forEach((step, si) => {
874
+ if (!allMsgToFirstStep.has(step.messageIndex)) {
875
+ allMsgToFirstStep.set(step.messageIndex, si);
876
+ }
877
+ });
878
+
879
+ const originalToFiltered = new Map<number, number>();
880
+ {
881
+ let fi = 0;
882
+ for (let oi = 0; oi < allRenderSteps.length; oi++) {
883
+ if (!hiddenMsgIndices.has(allRenderSteps[oi].messageIndex)) {
884
+ originalToFiltered.set(oi, fi);
885
+ fi++;
886
+ }
887
+ }
888
+ }
889
+
890
+ // For each section, find the filtered step index where its padding should be inserted
891
+ const findFilteredInsertionPoint = (origStep: number): number | null => {
892
+ for (let i = origStep; i < allRenderSteps.length; i++) {
893
+ const fi = originalToFiltered.get(i);
894
+ if (fi !== undefined) return fi;
895
+ }
896
+ return null;
897
+ };
898
+
899
+ // Map: filtered step index → sections to insert before it (in document order)
900
+ const sectionsBeforeStep = new Map<
901
+ number,
902
+ import('./parser').SequenceSection[]
903
+ >();
904
+ const trailingSections: import('./parser').SequenceSection[] = [];
905
+
906
+ for (const region of sectionRegions) {
907
+ if (region.msgIndices.length === 0) {
908
+ trailingSections.push(region.section);
909
+ continue;
910
+ }
911
+ const firstMsgIdx = region.msgIndices[0];
912
+ const origStep = allMsgToFirstStep.get(firstMsgIdx);
913
+ if (origStep === undefined) {
914
+ trailingSections.push(region.section);
915
+ continue;
916
+ }
917
+ const filteredStep = findFilteredInsertionPoint(origStep);
918
+ if (filteredStep === null) {
919
+ trailingSections.push(region.section);
920
+ continue;
921
+ }
922
+ const existing = sectionsBeforeStep.get(filteredStep) || [];
923
+ existing.push(region.section);
924
+ sectionsBeforeStep.set(filteredStep, existing);
925
+ }
926
+
927
+ // Section message counts for collapsed labels
928
+ const sectionMsgCounts = new Map<number, number>();
929
+ for (const region of sectionRegions) {
930
+ sectionMsgCounts.set(region.section.lineNumber, region.msgIndices.length);
755
931
  }
756
932
 
757
933
  // Group box layout constants (needed early for Y offset)
@@ -760,7 +936,7 @@ export function renderSequenceDiagram(
760
936
  const GROUP_PADDING_BOTTOM = 8;
761
937
  const GROUP_LABEL_SIZE = 11;
762
938
 
763
- // Compute cumulative Y positions for each step
939
+ // Compute cumulative Y positions for each step, with section dividers as stable anchors
764
940
  const titleOffset = title ? TITLE_HEIGHT : 0;
765
941
  const groupOffset =
766
942
  groups.length > 0 ? GROUP_PADDING_TOP + GROUP_LABEL_SIZE : 0;
@@ -770,11 +946,23 @@ export function renderSequenceDiagram(
770
946
  const hasActors = participants.some((p) => p.type === 'actor');
771
947
  const messageStartOffset = MESSAGE_START_OFFSET + (hasActors ? 20 : 0);
772
948
  const stepYPositions: number[] = [];
949
+ const sectionYPositions = new Map<number, number>(); // section lineNumber → Y
950
+ let layoutEndY: number; // final Y after all steps and trailing sections
773
951
  {
774
952
  let curY = lifelineStartY0 + messageStartOffset;
775
953
  for (let i = 0; i < renderSteps.length; i++) {
954
+ // Insert section padding before this step if needed
955
+ const sections = sectionsBeforeStep.get(i);
956
+ if (sections) {
957
+ for (const sec of sections) {
958
+ curY += SECTION_TOP_PAD;
959
+ sectionYPositions.set(sec.lineNumber, curY);
960
+ curY += SECTION_BOTTOM_PAD;
961
+ }
962
+ }
963
+
776
964
  const step = renderSteps[i];
777
- // Add extra spacing before the first render step of a flagged message
965
+ // Add extra spacing before the first render step of a flagged message (block spacing)
778
966
  if (msgToFirstStep.get(step.messageIndex) === i) {
779
967
  const extra = extraBeforeMsg.get(step.messageIndex) || 0;
780
968
  curY += extra;
@@ -782,14 +970,23 @@ export function renderSequenceDiagram(
782
970
  stepYPositions.push(curY);
783
971
  curY += stepSpacing;
784
972
  }
973
+ // Handle trailing sections (after all steps)
974
+ for (const sec of trailingSections) {
975
+ curY += SECTION_TOP_PAD;
976
+ sectionYPositions.set(sec.lineNumber, curY);
977
+ curY += SECTION_BOTTOM_PAD;
978
+ }
979
+ layoutEndY = curY;
785
980
  }
786
981
 
787
- const messageAreaHeight =
982
+ const contentBottomY =
788
983
  renderSteps.length > 0
789
- ? stepYPositions[stepYPositions.length - 1] -
790
- lifelineStartY0 +
791
- stepSpacing
792
- : 0;
984
+ ? Math.max(
985
+ stepYPositions[stepYPositions.length - 1] + stepSpacing,
986
+ layoutEndY
987
+ )
988
+ : layoutEndY;
989
+ const messageAreaHeight = contentBottomY - lifelineStartY0;
793
990
  const lifelineLength = messageAreaHeight + LIFELINE_TAIL;
794
991
  const totalWidth = Math.max(
795
992
  participants.length * PARTICIPANT_GAP,
@@ -1138,6 +1335,14 @@ export function renderSequenceDiagram(
1138
1335
  const y1 = stepY(act.startStep);
1139
1336
  const y2 = stepY(act.endStep);
1140
1337
 
1338
+ // Collect message line numbers covered by this activation
1339
+ const coveredLines: number[] = [];
1340
+ for (let si = act.startStep; si <= act.endStep; si++) {
1341
+ const step = renderSteps[si];
1342
+ const msg = messages[step.messageIndex];
1343
+ if (msg) coveredLines.push(msg.lineNumber);
1344
+ }
1345
+
1141
1346
  // Opaque background to mask the lifeline
1142
1347
  svg
1143
1348
  .append('rect')
@@ -1158,6 +1363,8 @@ export function renderSequenceDiagram(
1158
1363
  .attr('stroke', palette.primary)
1159
1364
  .attr('stroke-width', 1)
1160
1365
  .attr('stroke-opacity', 0.5)
1366
+ .attr('data-participant-id', act.participantId)
1367
+ .attr('data-msg-lines', coveredLines.join(','))
1161
1368
  .attr('class', 'activation');
1162
1369
  });
1163
1370
 
@@ -1227,62 +1434,95 @@ export function renderSequenceDiagram(
1227
1434
  const sectionLineX1 = leftmostX - PARTICIPANT_BOX_WIDTH / 2 - 10;
1228
1435
  const sectionLineX2 = rightmostX + PARTICIPANT_BOX_WIDTH / 2 + 10;
1229
1436
 
1230
- for (const [msgIdx, secs] of sectionBeforeMsg.entries()) {
1231
- const firstStep = msgToFirstStep.get(msgIdx);
1232
- if (firstStep === undefined) continue;
1233
- const nextY = stepY(firstStep);
1234
-
1235
- for (let si = 0; si < secs.length; si++) {
1236
- const sec = secs[si];
1237
- const secY = nextY - SECTION_SPACING / 2 + si * 20;
1238
- const lineColor = sec.color
1239
- ? resolveColor(sec.color, palette)
1240
- : palette.textMuted;
1241
-
1242
- // Horizontal divider line
1243
- svg
1244
- .append('line')
1245
- .attr('x1', sectionLineX1)
1246
- .attr('y1', secY)
1247
- .attr('x2', sectionLineX2)
1248
- .attr('y2', secY)
1249
- .attr('stroke', lineColor)
1250
- .attr('stroke-width', 1)
1251
- .attr('stroke-dasharray', '6 3')
1252
- .attr('opacity', 0.6)
1253
- .attr('class', 'section-divider')
1254
- .attr('data-line-number', String(sec.lineNumber))
1255
- .attr('data-section', '');
1256
-
1257
- // Label background knockout
1258
- const labelText = sec.label;
1259
- const labelWidth = labelText.length * 7 + 16;
1260
- const labelX = (sectionLineX1 + sectionLineX2) / 2;
1261
- svg
1262
- .append('rect')
1263
- .attr('x', labelX - labelWidth / 2)
1264
- .attr('y', secY - 8)
1265
- .attr('width', labelWidth)
1266
- .attr('height', 16)
1267
- .attr('fill', isDark ? palette.surface : palette.bg)
1268
- .attr('class', 'section-label-bg')
1269
- .attr('data-line-number', String(sec.lineNumber))
1270
- .attr('data-section', '');
1271
-
1272
- // Centered label text
1273
- svg
1274
- .append('text')
1275
- .attr('x', labelX)
1276
- .attr('y', secY + 4)
1277
- .attr('text-anchor', 'middle')
1278
- .attr('fill', lineColor)
1279
- .attr('font-size', 11)
1280
- .attr('font-weight', 'bold')
1281
- .attr('class', 'section-label')
1282
- .attr('data-line-number', String(sec.lineNumber))
1283
- .attr('data-section', '')
1284
- .text(labelText);
1437
+ for (const region of sectionRegions) {
1438
+ const sec = region.section;
1439
+ const secY = sectionYPositions.get(sec.lineNumber);
1440
+ if (secY === undefined) continue;
1441
+
1442
+ const isCollapsed = collapsedSections?.has(sec.lineNumber) ?? false;
1443
+ const lineColor = sec.color
1444
+ ? resolveColor(sec.color, palette)
1445
+ : palette.textMuted;
1446
+
1447
+ // Wrap section elements in a <g> for toggle.
1448
+ // IMPORTANT: only the <g> carries data-line-number / data-section —
1449
+ // children must NOT have them, otherwise the click walk-up resolves
1450
+ // to a line-number navigation before reaching data-section-toggle.
1451
+ const HIT_AREA_HEIGHT = 36;
1452
+ const sectionG = svg
1453
+ .append('g')
1454
+ .attr('data-section-toggle', '')
1455
+ .attr('data-line-number', String(sec.lineNumber))
1456
+ .attr('data-section', '')
1457
+ .attr('tabindex', '0')
1458
+ .attr('role', 'button')
1459
+ .attr('aria-expanded', String(!isCollapsed));
1460
+
1461
+ // Full-width tinted band
1462
+ const BAND_HEIGHT = 22;
1463
+ const bandX = sectionLineX1 - 10;
1464
+ const bandWidth = sectionLineX2 - sectionLineX1 + 20;
1465
+ const bandOpacity = isCollapsed
1466
+ ? (isDark ? 0.35 : 0.25)
1467
+ : (isDark ? 0.1 : 0.08);
1468
+ sectionG
1469
+ .append('rect')
1470
+ .attr('x', bandX)
1471
+ .attr('y', secY - BAND_HEIGHT / 2)
1472
+ .attr('width', bandWidth)
1473
+ .attr('height', BAND_HEIGHT)
1474
+ .attr('fill', lineColor)
1475
+ .attr('opacity', bandOpacity)
1476
+ .attr('rx', 2)
1477
+ .attr('class', 'section-divider');
1478
+
1479
+ // Build display label
1480
+ const msgCount = sectionMsgCounts.get(sec.lineNumber) ?? 0;
1481
+ const labelText = isCollapsed
1482
+ ? `${sec.label} (${msgCount} ${msgCount === 1 ? 'message' : 'messages'})`
1483
+ : sec.label;
1484
+
1485
+ // Collapsed sections use white text for contrast against the darker band
1486
+ const labelColor = isCollapsed ? '#ffffff' : lineColor;
1487
+
1488
+ // Chevron indicator
1489
+ const chevronSpace = 14;
1490
+ const labelX = (sectionLineX1 + sectionLineX2) / 2;
1491
+ const chevronX = labelX - (labelText.length * 3.5 + 8 + chevronSpace / 2);
1492
+ const chevronY = secY;
1493
+ if (isCollapsed) {
1494
+ // Right-pointing triangle ▶
1495
+ sectionG
1496
+ .append('path')
1497
+ .attr(
1498
+ 'd',
1499
+ `M ${chevronX} ${chevronY - 4} L ${chevronX + 6} ${chevronY} L ${chevronX} ${chevronY + 4} Z`
1500
+ )
1501
+ .attr('fill', labelColor)
1502
+ .attr('class', 'section-chevron');
1503
+ } else {
1504
+ // Down-pointing triangle ▼
1505
+ sectionG
1506
+ .append('path')
1507
+ .attr(
1508
+ 'd',
1509
+ `M ${chevronX - 1} ${chevronY - 3} L ${chevronX + 7} ${chevronY - 3} L ${chevronX + 3} ${chevronY + 3} Z`
1510
+ )
1511
+ .attr('fill', labelColor)
1512
+ .attr('class', 'section-chevron');
1285
1513
  }
1514
+
1515
+ // Centered label text
1516
+ sectionG
1517
+ .append('text')
1518
+ .attr('x', labelX + chevronSpace / 2)
1519
+ .attr('y', secY + 4)
1520
+ .attr('text-anchor', 'middle')
1521
+ .attr('fill', labelColor)
1522
+ .attr('font-size', 11)
1523
+ .attr('font-weight', 'bold')
1524
+ .attr('class', 'section-label')
1525
+ .text(labelText);
1286
1526
  }
1287
1527
 
1288
1528
  // Render steps (calls and returns in stack-inferred order)