@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.
- package/dist/cli.cjs +5195 -0
- package/dist/index.cjs +214 -37
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +15 -2
- package/dist/index.d.ts +15 -2
- package/dist/index.js +213 -37
- package/dist/index.js.map +1 -1
- package/package.json +7 -2
- package/src/cli.ts +189 -0
- package/src/index.ts +7 -1
- package/src/sequence/parser.ts +7 -0
- package/src/sequence/renderer.ts +339 -99
package/src/sequence/renderer.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
701
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
742
|
-
|
|
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
|
-
|
|
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
|
|
982
|
+
const contentBottomY =
|
|
788
983
|
renderSteps.length > 0
|
|
789
|
-
?
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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
|
|
1231
|
-
const
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
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)
|