@diagrammo/dgmo 0.47.0 → 0.48.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/auto.js +1 -1
- package/dist/auto.mjs +1 -1
- package/dist/element.js +1 -1
- package/dist/element.mjs +1 -1
- package/package.json +19 -70
- package/dist/advanced.cjs +0 -71648
- package/dist/advanced.d.cts +0 -5719
- package/dist/auto.cjs +0 -70648
- package/dist/auto.d.cts +0 -54
- package/dist/block.cjs +0 -70208
- package/dist/block.d.cts +0 -115
- package/dist/editor.cjs +0 -526
- package/dist/editor.d.cts +0 -26
- package/dist/element.cjs +0 -70530
- package/dist/element.d.cts +0 -62
- package/dist/highlight.cjs +0 -852
- package/dist/highlight.d.cts +0 -49
- package/dist/index.cjs +0 -70491
- package/dist/index.d.cts +0 -160
- package/dist/pert.cjs +0 -325
- package/dist/pert.d.cts +0 -559
- package/dist/themes-1-CuKpeH.d.cts +0 -908
- package/src/advanced.ts +0 -873
- package/src/arc/parser.ts +0 -15
- package/src/arc/renderer.ts +0 -633
- package/src/auto/index.ts +0 -621
- package/src/auto/shared.ts +0 -370
- package/src/auto/styles.ts +0 -73
- package/src/block/layout.ts +0 -198
- package/src/block/parser.ts +0 -427
- package/src/block/renderer.ts +0 -484
- package/src/block/types.ts +0 -67
- package/src/boxes-and-lines/collapse.ts +0 -96
- package/src/boxes-and-lines/focus.ts +0 -257
- package/src/boxes-and-lines/label-placement.ts +0 -339
- package/src/boxes-and-lines/layout-grouped.ts +0 -912
- package/src/boxes-and-lines/layout-layered.ts +0 -722
- package/src/boxes-and-lines/layout-search.ts +0 -1678
- package/src/boxes-and-lines/layout.ts +0 -398
- package/src/boxes-and-lines/parser.ts +0 -1181
- package/src/boxes-and-lines/renderer.ts +0 -1465
- package/src/boxes-and-lines/types.ts +0 -66
- package/src/c4/layout.ts +0 -2441
- package/src/c4/parser.ts +0 -1158
- package/src/c4/renderer.ts +0 -2008
- package/src/c4/types.ts +0 -89
- package/src/chart-type-registry.ts +0 -529
- package/src/chart-types.ts +0 -225
- package/src/chart.ts +0 -685
- package/src/charts-d3/bar.ts +0 -338
- package/src/charts-d3/chord.ts +0 -140
- package/src/charts-d3/function.ts +0 -147
- package/src/charts-d3/funnel.ts +0 -104
- package/src/charts-d3/heatmap.ts +0 -145
- package/src/charts-d3/index.ts +0 -337
- package/src/charts-d3/interactions.ts +0 -477
- package/src/charts-d3/line.ts +0 -344
- package/src/charts-d3/mount.ts +0 -151
- package/src/charts-d3/pie.ts +0 -117
- package/src/charts-d3/polar.ts +0 -89
- package/src/charts-d3/radar.ts +0 -0
- package/src/charts-d3/sankey.ts +0 -0
- package/src/charts-d3/scatter.ts +0 -185
- package/src/charts-d3/shared.ts +0 -208
- package/src/class/layout.ts +0 -326
- package/src/class/parser.ts +0 -635
- package/src/class/renderer.ts +0 -793
- package/src/class/types.ts +0 -58
- package/src/cli-banner.ts +0 -106
- package/src/cli.ts +0 -1080
- package/src/colors-diagnostics.ts +0 -35
- package/src/colors.ts +0 -379
- package/src/completion-types.ts +0 -27
- package/src/completion.ts +0 -2668
- package/src/cycle/layout.ts +0 -1026
- package/src/cycle/parser.ts +0 -387
- package/src/cycle/renderer.ts +0 -653
- package/src/cycle/types.ts +0 -101
- package/src/d3.ts +0 -1434
- package/src/data-chart-parser.ts +0 -1007
- package/src/dgmo-router.ts +0 -296
- package/src/diagnostics-registry.ts +0 -108
- package/src/diagnostics.ts +0 -0
- package/src/dimensions.ts +0 -20
- package/src/directives-registry.ts +0 -396
- package/src/editor/dgmo.grammar +0 -76
- package/src/editor/dgmo.grammar.d.ts +0 -2
- package/src/editor/dgmo.grammar.js +0 -18
- package/src/editor/dgmo.grammar.terms.d.ts +0 -5
- package/src/editor/dgmo.grammar.terms.js +0 -35
- package/src/editor/highlight-api.ts +0 -596
- package/src/editor/highlight.ts +0 -36
- package/src/editor/index.ts +0 -27
- package/src/editor/keywords.ts +0 -128
- package/src/editor/tokens.ts +0 -30
- package/src/element/index.ts +0 -432
- package/src/embed/css.ts +0 -312
- package/src/embed/escape.ts +0 -15
- package/src/embed/index.ts +0 -280
- package/src/er/classify.ts +0 -228
- package/src/er/layout.ts +0 -530
- package/src/er/parser.ts +0 -750
- package/src/er/renderer.ts +0 -725
- package/src/er/types.ts +0 -52
- package/src/error-card.ts +0 -269
- package/src/event-line/diagnostics.ts +0 -117
- package/src/event-line/parser.ts +0 -643
- package/src/event-line/renderer.ts +0 -1742
- package/src/event-line/types.ts +0 -74
- package/src/fonts.ts +0 -3
- package/src/gantt/calculator.ts +0 -979
- package/src/gantt/parser.ts +0 -1641
- package/src/gantt/renderer.ts +0 -3787
- package/src/gantt/resolver.ts +0 -172
- package/src/gantt/types.ts +0 -206
- package/src/graph/diagnostics.ts +0 -64
- package/src/graph/edge-spline.ts +0 -29
- package/src/graph/flowchart-parser.ts +0 -759
- package/src/graph/flowchart-renderer.ts +0 -817
- package/src/graph/layout.ts +0 -472
- package/src/graph/notes.ts +0 -21
- package/src/graph/state-collapse.ts +0 -78
- package/src/graph/state-parser.ts +0 -574
- package/src/graph/state-renderer.ts +0 -653
- package/src/graph/types.ts +0 -65
- package/src/index.ts +0 -261
- package/src/infra/compute.ts +0 -1385
- package/src/infra/layout.ts +0 -888
- package/src/infra/parser.ts +0 -1234
- package/src/infra/renderer.ts +0 -2534
- package/src/infra/roles.ts +0 -75
- package/src/infra/types.ts +0 -216
- package/src/infra/validation.ts +0 -201
- package/src/journey-map/layout.ts +0 -490
- package/src/journey-map/parser.ts +0 -557
- package/src/journey-map/renderer.ts +0 -1580
- package/src/journey-map/types.ts +0 -47
- package/src/kanban/mutations.ts +0 -175
- package/src/kanban/parser.ts +0 -509
- package/src/kanban/renderer.ts +0 -1449
- package/src/kanban/types.ts +0 -39
- package/src/label-layout.ts +0 -368
- package/src/map/colorize.ts +0 -54
- package/src/map/completion.ts +0 -316
- package/src/map/context-labels.ts +0 -609
- package/src/map/data/PROVENANCE.json +0 -1
- package/src/map/data/README.md +0 -40
- package/src/map/data/airport-collisions.json +0 -1
- package/src/map/data/airports.json +0 -1
- package/src/map/data/gazetteer.json +0 -1
- package/src/map/data/lakes.json +0 -1
- package/src/map/data/mountain-ranges.json +0 -1
- package/src/map/data/na-lakes.json +0 -1
- package/src/map/data/na-land.json +0 -1
- package/src/map/data/region-names.json +0 -1
- package/src/map/data/rivers.json +0 -1
- package/src/map/data/types.ts +0 -121
- package/src/map/data/us-states.json +0 -1
- package/src/map/data/water-bodies.json +0 -1
- package/src/map/data/world-coarse.json +0 -1
- package/src/map/data/world-detail.json +0 -1
- package/src/map/diagnostics.ts +0 -134
- package/src/map/dimensions.ts +0 -150
- package/src/map/geo-query.ts +0 -295
- package/src/map/geo.ts +0 -438
- package/src/map/invert.ts +0 -111
- package/src/map/layout.ts +0 -5069
- package/src/map/legend-band.ts +0 -127
- package/src/map/load-data.ts +0 -186
- package/src/map/parser.ts +0 -783
- package/src/map/renderer.ts +0 -1445
- package/src/map/resolved-types.ts +0 -160
- package/src/map/resolver.ts +0 -1146
- package/src/map/types.ts +0 -194
- package/src/mindmap/collapse.ts +0 -67
- package/src/mindmap/layout.ts +0 -649
- package/src/mindmap/parser.ts +0 -430
- package/src/mindmap/renderer.ts +0 -637
- package/src/mindmap/text-wrap.ts +0 -218
- package/src/mindmap/types.ts +0 -56
- package/src/org/collapse.ts +0 -151
- package/src/org/layout.ts +0 -1262
- package/src/org/parser.ts +0 -519
- package/src/org/renderer.ts +0 -866
- package/src/org/resolver.ts +0 -636
- package/src/palettes/atlas.ts +0 -77
- package/src/palettes/blueprint.ts +0 -73
- package/src/palettes/catppuccin.ts +0 -84
- package/src/palettes/color-utils.ts +0 -443
- package/src/palettes/index.ts +0 -67
- package/src/palettes/nord.ts +0 -67
- package/src/palettes/registry.ts +0 -114
- package/src/palettes/slate.ts +0 -73
- package/src/palettes/tidewater.ts +0 -73
- package/src/palettes/tokyo-night.ts +0 -86
- package/src/palettes/types.ts +0 -88
- package/src/pert/analyzer.ts +0 -1183
- package/src/pert/diagnostics.ts +0 -110
- package/src/pert/index.ts +0 -43
- package/src/pert/internal.ts +0 -252
- package/src/pert/layout.ts +0 -971
- package/src/pert/monte-carlo.ts +0 -446
- package/src/pert/parser.ts +0 -1606
- package/src/pert/renderer.ts +0 -4009
- package/src/pert/share-normalize.ts +0 -62
- package/src/pert/types.ts +0 -604
- package/src/pyramid/parser.ts +0 -185
- package/src/pyramid/renderer.ts +0 -768
- package/src/pyramid/types.ts +0 -28
- package/src/quadrant/parser.ts +0 -18
- package/src/quadrant/renderer.ts +0 -638
- package/src/raci/diagnostics.ts +0 -213
- package/src/raci/index.ts +0 -23
- package/src/raci/mutations.ts +0 -321
- package/src/raci/parser.ts +0 -699
- package/src/raci/renderer.ts +0 -1627
- package/src/raci/types.ts +0 -82
- package/src/raci/variants.ts +0 -320
- package/src/render.ts +0 -268
- package/src/ring/parser.ts +0 -212
- package/src/ring/renderer.ts +0 -490
- package/src/ring/types.ts +0 -27
- package/src/sequence/collapse.ts +0 -174
- package/src/sequence/parser.ts +0 -1596
- package/src/sequence/participant-inference.ts +0 -152
- package/src/sequence/renderer.ts +0 -3168
- package/src/sequence/tag-resolution.ts +0 -164
- package/src/sharing.ts +0 -211
- package/src/sitemap/collapse.ts +0 -100
- package/src/sitemap/layout.ts +0 -925
- package/src/sitemap/parser.ts +0 -755
- package/src/sitemap/renderer.ts +0 -828
- package/src/sitemap/types.ts +0 -42
- package/src/slope/parser.ts +0 -18
- package/src/slope/renderer.ts +0 -378
- package/src/swimlane/diagnostics.ts +0 -94
- package/src/swimlane/layout.ts +0 -597
- package/src/swimlane/parser.ts +0 -613
- package/src/swimlane/renderer.ts +0 -681
- package/src/swimlane/types.ts +0 -151
- package/src/tech-radar/interactive.ts +0 -1129
- package/src/tech-radar/layout.ts +0 -197
- package/src/tech-radar/parser.ts +0 -436
- package/src/tech-radar/renderer.ts +0 -1239
- package/src/tech-radar/shared.ts +0 -151
- package/src/tech-radar/types.ts +0 -86
- package/src/themes.ts +0 -22
- package/src/timeline/parser.ts +0 -472
- package/src/timeline/renderer.ts +0 -3024
- package/src/timeline/types.ts +0 -33
- package/src/timeline/viz-parser.ts +0 -21
- package/src/treemap/diagnostics.ts +0 -62
- package/src/treemap/layout-radial.ts +0 -233
- package/src/treemap/layout.ts +0 -189
- package/src/treemap/parser.ts +0 -463
- package/src/treemap/renderer-radial.ts +0 -438
- package/src/treemap/renderer.ts +0 -572
- package/src/treemap/treemap-shared.ts +0 -309
- package/src/treemap/types.ts +0 -60
- package/src/utils/arrow-markers.ts +0 -51
- package/src/utils/arrows-diagnostics.ts +0 -62
- package/src/utils/arrows.ts +0 -227
- package/src/utils/brand.ts +0 -61
- package/src/utils/card.ts +0 -183
- package/src/utils/collapse-engine/tree-edges.ts +0 -115
- package/src/utils/collapse-engine/tree.ts +0 -117
- package/src/utils/d3-helpers.ts +0 -219
- package/src/utils/d3-types.ts +0 -9
- package/src/utils/description-helpers.ts +0 -38
- package/src/utils/duration.ts +0 -312
- package/src/utils/export-container.ts +0 -41
- package/src/utils/expr-eval.ts +0 -27
- package/src/utils/fit-canvas.ts +0 -64
- package/src/utils/hover-styles.ts +0 -673
- package/src/utils/inline-markdown.ts +0 -96
- package/src/utils/legend-constants.ts +0 -48
- package/src/utils/legend-d3.ts +0 -618
- package/src/utils/legend-integration.ts +0 -107
- package/src/utils/legend-layout.ts +0 -776
- package/src/utils/legend-svg.ts +0 -214
- package/src/utils/legend-types.ts +0 -272
- package/src/utils/name-normalize.ts +0 -163
- package/src/utils/note-box/constants.ts +0 -25
- package/src/utils/note-box/index.ts +0 -11
- package/src/utils/note-box/metrics.ts +0 -90
- package/src/utils/note-box/svg.ts +0 -331
- package/src/utils/notes/bounds.ts +0 -30
- package/src/utils/notes/build.ts +0 -131
- package/src/utils/notes/index.ts +0 -18
- package/src/utils/notes/model.ts +0 -19
- package/src/utils/notes/parse.ts +0 -131
- package/src/utils/notes/place.ts +0 -177
- package/src/utils/notes/resolve.ts +0 -88
- package/src/utils/number-format.ts +0 -36
- package/src/utils/parsing.ts +0 -953
- package/src/utils/reserved-key-registry.ts +0 -239
- package/src/utils/safe-href.ts +0 -38
- package/src/utils/scaling.ts +0 -97
- package/src/utils/svg-embed.ts +0 -251
- package/src/utils/tag-groups.ts +0 -607
- package/src/utils/text-measure.ts +0 -122
- package/src/utils/time-ticks.ts +0 -253
- package/src/utils/title-constants.ts +0 -24
- package/src/utils/visual-conventions.ts +0 -61
- package/src/utils/wrapped-desc.ts +0 -87
- package/src/venn/parser.ts +0 -18
- package/src/venn/renderer.ts +0 -1047
- package/src/version-control/diagnostics.ts +0 -67
- package/src/version-control/parser.ts +0 -544
- package/src/version-control/renderer.ts +0 -724
- package/src/version-control/types.ts +0 -100
- package/src/visualizations/parse.ts +0 -1560
- package/src/visualizations/types.ts +0 -262
- package/src/wireframe/layout.ts +0 -479
- package/src/wireframe/parser.ts +0 -1114
- package/src/wireframe/renderer.ts +0 -1318
- package/src/wireframe/types.ts +0 -110
- package/src/wordcloud/parser.ts +0 -18
- package/src/wordcloud/renderer.ts +0 -357
package/src/sequence/renderer.ts
DELETED
|
@@ -1,3168 +0,0 @@
|
|
|
1
|
-
// ============================================================
|
|
2
|
-
// Sequence Diagram SVG Renderer
|
|
3
|
-
// ============================================================
|
|
4
|
-
|
|
5
|
-
import { tagAttrKey } from '../utils/tag-groups';
|
|
6
|
-
import * as d3Selection from 'd3-selection';
|
|
7
|
-
import type { PaletteColors } from '../palettes';
|
|
8
|
-
import {
|
|
9
|
-
contrastText,
|
|
10
|
-
mix,
|
|
11
|
-
shapeFill,
|
|
12
|
-
themeBaseBg,
|
|
13
|
-
} from '../palettes/color-utils';
|
|
14
|
-
import {
|
|
15
|
-
parseInlineMarkdown,
|
|
16
|
-
truncateBareUrl,
|
|
17
|
-
renderInlineText,
|
|
18
|
-
} from '../utils/inline-markdown';
|
|
19
|
-
export { parseInlineMarkdown, truncateBareUrl };
|
|
20
|
-
import { FONT_FAMILY } from '../fonts';
|
|
21
|
-
import type {
|
|
22
|
-
ParsedSequenceDgmo,
|
|
23
|
-
SequenceElement,
|
|
24
|
-
SequenceGroup,
|
|
25
|
-
SequenceMessage,
|
|
26
|
-
SequenceNote,
|
|
27
|
-
SequenceParticipant,
|
|
28
|
-
} from './parser';
|
|
29
|
-
import { isSequenceBlock, isSequenceSection, isSequenceNote } from './parser';
|
|
30
|
-
import { applyCollapseProjection } from './collapse';
|
|
31
|
-
import type { CollapsedView } from './collapse';
|
|
32
|
-
import {
|
|
33
|
-
wrapDescriptionLines,
|
|
34
|
-
type WrappedDescLine,
|
|
35
|
-
} from '../utils/wrapped-desc';
|
|
36
|
-
import { measureText, truncateText } from '../utils/text-measure';
|
|
37
|
-
import { resolveSequenceTags } from './tag-resolution';
|
|
38
|
-
import type { ResolvedTagMap } from './tag-resolution';
|
|
39
|
-
import { resolveActiveTagGroup } from '../utils/tag-groups';
|
|
40
|
-
import { getMaxLegendReservedHeight } from '../utils/legend-layout';
|
|
41
|
-
import { renderIntegratedLegend } from '../utils/legend-integration';
|
|
42
|
-
import type { LegendCallbacks, LegendConfig } from '../utils/legend-types';
|
|
43
|
-
import {
|
|
44
|
-
TITLE_FONT_SIZE,
|
|
45
|
-
TITLE_FONT_WEIGHT,
|
|
46
|
-
TITLE_Y,
|
|
47
|
-
} from '../utils/title-constants';
|
|
48
|
-
import { ScaleContext } from '../utils/scaling';
|
|
49
|
-
|
|
50
|
-
// ============================================================
|
|
51
|
-
// Layout Constants
|
|
52
|
-
// ============================================================
|
|
53
|
-
|
|
54
|
-
const PARTICIPANT_GAP = 160;
|
|
55
|
-
const PARTICIPANT_BOX_WIDTH = 120;
|
|
56
|
-
const PARTICIPANT_BOX_HEIGHT = 50;
|
|
57
|
-
const LABEL_FONT_SIZE = 13;
|
|
58
|
-
const TOP_MARGIN = 20;
|
|
59
|
-
const TITLE_HEIGHT = 30;
|
|
60
|
-
const PARTICIPANT_Y_OFFSET = 10;
|
|
61
|
-
const MESSAGE_START_OFFSET = 50;
|
|
62
|
-
const LIFELINE_TAIL = 30;
|
|
63
|
-
const ARROWHEAD_SIZE = 8;
|
|
64
|
-
|
|
65
|
-
// Note rendering constants
|
|
66
|
-
const NOTE_MAX_W = 200;
|
|
67
|
-
const NOTE_FOLD = 10;
|
|
68
|
-
const NOTE_PAD_H = 8;
|
|
69
|
-
const NOTE_PAD_V = 6;
|
|
70
|
-
const NOTE_FONT_SIZE = 10;
|
|
71
|
-
const NOTE_LINE_H = 14;
|
|
72
|
-
const NOTE_GAP = 15;
|
|
73
|
-
const ACTIVATION_WIDTH = 10;
|
|
74
|
-
const SELF_CALL_HEIGHT = 25;
|
|
75
|
-
const SELF_CALL_WIDTH = 30;
|
|
76
|
-
// Actors render their label below the stick figure (at boxH + 14). Their
|
|
77
|
-
// lifeline starts this far below the box so the dashes clear the label text.
|
|
78
|
-
const ACTOR_LABEL_CLEARANCE = 22;
|
|
79
|
-
function wrapTextLines(
|
|
80
|
-
text: string,
|
|
81
|
-
maxWidth: number,
|
|
82
|
-
fontSize: number
|
|
83
|
-
): WrappedDescLine[] {
|
|
84
|
-
// Convert leading "- " to the canonical bullet prefix so the shared wrap
|
|
85
|
-
// helper can split bullet lines into bullet-first / bullet-cont kinds and
|
|
86
|
-
// give us hanging-indent alignment on continuation lines.
|
|
87
|
-
const rawLines = text
|
|
88
|
-
.split('\n')
|
|
89
|
-
.map((l) => (l.startsWith('- ') ? '• ' + l.slice(2) : l));
|
|
90
|
-
// Drive the shared bullet-aware wrapper by pixel width: passing the note's
|
|
91
|
-
// available text width as the limit and a glyph-accurate measurer as the
|
|
92
|
-
// length function turns its char-count comparison into a true pixel wrap.
|
|
93
|
-
return wrapDescriptionLines(rawLines, maxWidth, (s) =>
|
|
94
|
-
measureText(s, fontSize)
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Available pixel width for a participant label inside a box of the given
|
|
100
|
-
* width (the 10px accounts for the same left/right inset the layout uses).
|
|
101
|
-
*/
|
|
102
|
-
const labelTextWidth = (boxW: number): number => boxW - 10;
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Split a participant label into multiple lines if it exceeds the box width.
|
|
106
|
-
* Splits on spaces first, then dashes, then camelCase boundaries.
|
|
107
|
-
*/
|
|
108
|
-
function splitParticipantLabel(
|
|
109
|
-
label: string,
|
|
110
|
-
maxWidth: number,
|
|
111
|
-
fontSize: number
|
|
112
|
-
): string[] {
|
|
113
|
-
if (measureText(label, fontSize) <= maxWidth) return [label];
|
|
114
|
-
|
|
115
|
-
// Split on spaces
|
|
116
|
-
if (label.includes(' ')) {
|
|
117
|
-
return wrapLabelWords(label.split(' '), maxWidth, fontSize);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Split on dashes/underscores/colons/slashes
|
|
121
|
-
if (/[-_:/]/.test(label)) {
|
|
122
|
-
const parts = label.split(/[-_:/]+/);
|
|
123
|
-
return wrapLabelWords(parts, maxWidth, fontSize);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Split on camelCase boundaries: "UserLookupCloudFx" → ["User", "Lookup", "Cloud", "Fx"]
|
|
127
|
-
const camelParts = label
|
|
128
|
-
.replace(/([a-z])([A-Z])/g, '$1\x00$2')
|
|
129
|
-
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1\x00$2')
|
|
130
|
-
.split('\x00');
|
|
131
|
-
if (camelParts.length > 1) {
|
|
132
|
-
return wrapLabelWords(camelParts, maxWidth, fontSize);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return [label];
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** Greedily join word parts into lines that fit within maxWidth pixels. */
|
|
139
|
-
function wrapLabelWords(
|
|
140
|
-
words: string[],
|
|
141
|
-
maxWidth: number,
|
|
142
|
-
fontSize: number
|
|
143
|
-
): string[] {
|
|
144
|
-
const lines: string[] = [];
|
|
145
|
-
let current = '';
|
|
146
|
-
for (const word of words) {
|
|
147
|
-
const test = current ? current + word : word;
|
|
148
|
-
if (measureText(test, fontSize) > maxWidth && current) {
|
|
149
|
-
lines.push(current);
|
|
150
|
-
current = word;
|
|
151
|
-
} else {
|
|
152
|
-
current = test;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
if (current) lines.push(current);
|
|
156
|
-
return lines;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Shared fill/stroke helpers — accept optional color override for per-participant coloring.
|
|
160
|
-
// When `color` is provided we use the canonical shapeFill() 25% tint.
|
|
161
|
-
// When omitted we fall back to a subtle-neutral surface highlight (out of scope for the
|
|
162
|
-
// shape-fill standardization spec — see TD-2 / F11). Participants without a color should
|
|
163
|
-
// recede; bumping them to 25% intent would defeat that intent.
|
|
164
|
-
const fill = (
|
|
165
|
-
palette: PaletteColors,
|
|
166
|
-
isDark: boolean,
|
|
167
|
-
color?: string,
|
|
168
|
-
solid?: boolean
|
|
169
|
-
): string =>
|
|
170
|
-
color
|
|
171
|
-
? shapeFill(palette, color, isDark, {
|
|
172
|
-
...(solid !== undefined && { solid }),
|
|
173
|
-
})
|
|
174
|
-
: isDark
|
|
175
|
-
? mix(palette.overlay, palette.surface, 50)
|
|
176
|
-
: mix(palette.bg, palette.surface, 50);
|
|
177
|
-
const stroke = (palette: PaletteColors, color?: string): string =>
|
|
178
|
-
color || palette.border;
|
|
179
|
-
const SW = 1.5;
|
|
180
|
-
const W = PARTICIPANT_BOX_WIDTH;
|
|
181
|
-
const H = PARTICIPANT_BOX_HEIGHT;
|
|
182
|
-
|
|
183
|
-
// ============================================================
|
|
184
|
-
// Participant Shape Renderers
|
|
185
|
-
// ============================================================
|
|
186
|
-
|
|
187
|
-
function renderRectParticipant(
|
|
188
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
189
|
-
palette: PaletteColors,
|
|
190
|
-
isDark: boolean,
|
|
191
|
-
color?: string,
|
|
192
|
-
solid?: boolean,
|
|
193
|
-
w: number = W,
|
|
194
|
-
h: number = H
|
|
195
|
-
): void {
|
|
196
|
-
g.append('rect')
|
|
197
|
-
.attr('x', -w / 2)
|
|
198
|
-
.attr('y', 0)
|
|
199
|
-
.attr('width', w)
|
|
200
|
-
.attr('height', h)
|
|
201
|
-
.attr('rx', 2)
|
|
202
|
-
.attr('ry', 2)
|
|
203
|
-
.attr('fill', fill(palette, isDark, color, solid))
|
|
204
|
-
.attr('stroke', stroke(palette, color))
|
|
205
|
-
.attr('stroke-width', SW);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function renderActorParticipant(
|
|
209
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
210
|
-
palette: PaletteColors,
|
|
211
|
-
color?: string,
|
|
212
|
-
h: number = H
|
|
213
|
-
): void {
|
|
214
|
-
// Stick figure — no background. Every dimension is scaled by sc = h / H so
|
|
215
|
-
// the figure keeps its proportions at any box height. The box height itself
|
|
216
|
-
// is compressed by ScaleContext when the diagram is wider than its container;
|
|
217
|
-
// scaling head/arms/legs by the same factor (instead of hardcoding 8/16/12)
|
|
218
|
-
// prevents the head from ballooning over a collapsed body when compressed.
|
|
219
|
-
const sc = h / H;
|
|
220
|
-
const headR = 8 * sc;
|
|
221
|
-
const cx = 0;
|
|
222
|
-
const headY = 10 * sc;
|
|
223
|
-
const bodyTopY = 19 * sc;
|
|
224
|
-
const bodyBottomY = h * 0.65;
|
|
225
|
-
const legY = h - 2 * sc;
|
|
226
|
-
const armY = 24 * sc;
|
|
227
|
-
const armSpan = 16 * sc;
|
|
228
|
-
const legSpan = 12 * sc;
|
|
229
|
-
const s = stroke(palette, color);
|
|
230
|
-
const actorSW = Math.max(1.2, 2.5 * sc);
|
|
231
|
-
|
|
232
|
-
g.append('circle')
|
|
233
|
-
.attr('cx', cx)
|
|
234
|
-
.attr('cy', headY)
|
|
235
|
-
.attr('r', headR)
|
|
236
|
-
.attr('fill', 'none')
|
|
237
|
-
.attr('stroke', s)
|
|
238
|
-
.attr('stroke-width', actorSW);
|
|
239
|
-
|
|
240
|
-
g.append('line')
|
|
241
|
-
.attr('x1', cx)
|
|
242
|
-
.attr('y1', bodyTopY)
|
|
243
|
-
.attr('x2', cx)
|
|
244
|
-
.attr('y2', bodyBottomY)
|
|
245
|
-
.attr('stroke', s)
|
|
246
|
-
.attr('stroke-width', actorSW);
|
|
247
|
-
|
|
248
|
-
g.append('line')
|
|
249
|
-
.attr('x1', cx - armSpan)
|
|
250
|
-
.attr('y1', armY)
|
|
251
|
-
.attr('x2', cx + armSpan)
|
|
252
|
-
.attr('y2', armY)
|
|
253
|
-
.attr('stroke', s)
|
|
254
|
-
.attr('stroke-width', actorSW);
|
|
255
|
-
|
|
256
|
-
g.append('line')
|
|
257
|
-
.attr('x1', cx)
|
|
258
|
-
.attr('y1', bodyBottomY)
|
|
259
|
-
.attr('x2', cx - legSpan)
|
|
260
|
-
.attr('y2', legY)
|
|
261
|
-
.attr('stroke', s)
|
|
262
|
-
.attr('stroke-width', actorSW);
|
|
263
|
-
|
|
264
|
-
g.append('line')
|
|
265
|
-
.attr('x1', cx)
|
|
266
|
-
.attr('y1', bodyBottomY)
|
|
267
|
-
.attr('x2', cx + legSpan)
|
|
268
|
-
.attr('y2', legY)
|
|
269
|
-
.attr('stroke', s)
|
|
270
|
-
.attr('stroke-width', actorSW);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function renderDatabaseParticipant(
|
|
274
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
275
|
-
palette: PaletteColors,
|
|
276
|
-
isDark: boolean,
|
|
277
|
-
color?: string,
|
|
278
|
-
solid?: boolean,
|
|
279
|
-
w: number = W,
|
|
280
|
-
h: number = H
|
|
281
|
-
): void {
|
|
282
|
-
// Cylinder fitting within w x h
|
|
283
|
-
const ry = 7;
|
|
284
|
-
const topY = ry;
|
|
285
|
-
const bodyH = h - ry * 2;
|
|
286
|
-
const f = fill(palette, isDark, color, solid);
|
|
287
|
-
const s = stroke(palette, color);
|
|
288
|
-
|
|
289
|
-
// Bottom ellipse (drawn first — rect will cover its top arc)
|
|
290
|
-
g.append('ellipse')
|
|
291
|
-
.attr('cx', 0)
|
|
292
|
-
.attr('cy', topY + bodyH)
|
|
293
|
-
.attr('rx', w / 2)
|
|
294
|
-
.attr('ry', ry)
|
|
295
|
-
.attr('fill', f)
|
|
296
|
-
.attr('stroke', s)
|
|
297
|
-
.attr('stroke-width', SW);
|
|
298
|
-
|
|
299
|
-
// Filled body (no stroke) to hide the top arc of the bottom ellipse
|
|
300
|
-
g.append('rect')
|
|
301
|
-
.attr('class', 'participant-body')
|
|
302
|
-
.attr('x', -w / 2)
|
|
303
|
-
.attr('y', topY)
|
|
304
|
-
.attr('width', w)
|
|
305
|
-
.attr('height', bodyH)
|
|
306
|
-
.attr('fill', f)
|
|
307
|
-
.attr('stroke', 'none');
|
|
308
|
-
|
|
309
|
-
// Side lines
|
|
310
|
-
g.append('line')
|
|
311
|
-
.attr('x1', -w / 2)
|
|
312
|
-
.attr('y1', topY)
|
|
313
|
-
.attr('x2', -w / 2)
|
|
314
|
-
.attr('y2', topY + bodyH)
|
|
315
|
-
.attr('stroke', s)
|
|
316
|
-
.attr('stroke-width', SW);
|
|
317
|
-
g.append('line')
|
|
318
|
-
.attr('x1', w / 2)
|
|
319
|
-
.attr('y1', topY)
|
|
320
|
-
.attr('x2', w / 2)
|
|
321
|
-
.attr('y2', topY + bodyH)
|
|
322
|
-
.attr('stroke', s)
|
|
323
|
-
.attr('stroke-width', SW);
|
|
324
|
-
|
|
325
|
-
// Top ellipse cap (drawn last, on top)
|
|
326
|
-
g.append('ellipse')
|
|
327
|
-
.attr('cx', 0)
|
|
328
|
-
.attr('cy', topY)
|
|
329
|
-
.attr('rx', w / 2)
|
|
330
|
-
.attr('ry', ry)
|
|
331
|
-
.attr('fill', f)
|
|
332
|
-
.attr('stroke', s)
|
|
333
|
-
.attr('stroke-width', SW);
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function renderQueueParticipant(
|
|
337
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
338
|
-
palette: PaletteColors,
|
|
339
|
-
isDark: boolean,
|
|
340
|
-
color?: string,
|
|
341
|
-
solid?: boolean,
|
|
342
|
-
w: number = W,
|
|
343
|
-
h: number = H
|
|
344
|
-
): void {
|
|
345
|
-
// Horizontal cylinder (pipe) — like database rotated 90 degrees
|
|
346
|
-
const rx = 10;
|
|
347
|
-
const leftX = -w / 2 + rx;
|
|
348
|
-
const bodyW = w - rx * 2;
|
|
349
|
-
const f = fill(palette, isDark, color, solid);
|
|
350
|
-
const s = stroke(palette, color);
|
|
351
|
-
|
|
352
|
-
// Right ellipse (back face, drawn first — rect will cover its left arc)
|
|
353
|
-
g.append('ellipse')
|
|
354
|
-
.attr('cx', leftX + bodyW)
|
|
355
|
-
.attr('cy', h / 2)
|
|
356
|
-
.attr('rx', rx)
|
|
357
|
-
.attr('ry', h / 2)
|
|
358
|
-
.attr('fill', f)
|
|
359
|
-
.attr('stroke', s)
|
|
360
|
-
.attr('stroke-width', SW);
|
|
361
|
-
|
|
362
|
-
// Body rect (no stroke) to hide left arc of right ellipse
|
|
363
|
-
g.append('rect')
|
|
364
|
-
.attr('class', 'participant-body')
|
|
365
|
-
.attr('x', leftX)
|
|
366
|
-
.attr('y', 0)
|
|
367
|
-
.attr('width', bodyW)
|
|
368
|
-
.attr('height', h)
|
|
369
|
-
.attr('fill', f)
|
|
370
|
-
.attr('stroke', 'none');
|
|
371
|
-
|
|
372
|
-
// Top and bottom lines
|
|
373
|
-
g.append('line')
|
|
374
|
-
.attr('x1', leftX)
|
|
375
|
-
.attr('y1', 0)
|
|
376
|
-
.attr('x2', leftX + bodyW)
|
|
377
|
-
.attr('y2', 0)
|
|
378
|
-
.attr('stroke', s)
|
|
379
|
-
.attr('stroke-width', SW);
|
|
380
|
-
g.append('line')
|
|
381
|
-
.attr('x1', leftX)
|
|
382
|
-
.attr('y1', h)
|
|
383
|
-
.attr('x2', leftX + bodyW)
|
|
384
|
-
.attr('y2', h)
|
|
385
|
-
.attr('stroke', s)
|
|
386
|
-
.attr('stroke-width', SW);
|
|
387
|
-
|
|
388
|
-
// Left ellipse (front face, drawn last)
|
|
389
|
-
g.append('ellipse')
|
|
390
|
-
.attr('cx', leftX)
|
|
391
|
-
.attr('cy', h / 2)
|
|
392
|
-
.attr('rx', rx)
|
|
393
|
-
.attr('ry', h / 2)
|
|
394
|
-
.attr('fill', f)
|
|
395
|
-
.attr('stroke', s)
|
|
396
|
-
.attr('stroke-width', SW);
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
function renderCacheParticipant(
|
|
400
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
401
|
-
palette: PaletteColors,
|
|
402
|
-
isDark: boolean,
|
|
403
|
-
color?: string,
|
|
404
|
-
solid?: boolean,
|
|
405
|
-
w: number = W,
|
|
406
|
-
h: number = H
|
|
407
|
-
): void {
|
|
408
|
-
// Dashed cylinder — variation of database to convey ephemeral storage
|
|
409
|
-
const ry = 7;
|
|
410
|
-
const topY = ry;
|
|
411
|
-
const bodyH = h - ry * 2;
|
|
412
|
-
const f = fill(palette, isDark, color, solid);
|
|
413
|
-
const s = stroke(palette, color);
|
|
414
|
-
const dash = '4 3';
|
|
415
|
-
|
|
416
|
-
// Bottom ellipse (back face)
|
|
417
|
-
g.append('ellipse')
|
|
418
|
-
.attr('cx', 0)
|
|
419
|
-
.attr('cy', topY + bodyH)
|
|
420
|
-
.attr('rx', w / 2)
|
|
421
|
-
.attr('ry', ry)
|
|
422
|
-
.attr('fill', f)
|
|
423
|
-
.attr('stroke', s)
|
|
424
|
-
.attr('stroke-width', SW)
|
|
425
|
-
.attr('stroke-dasharray', dash);
|
|
426
|
-
|
|
427
|
-
g.append('rect')
|
|
428
|
-
.attr('class', 'participant-body')
|
|
429
|
-
.attr('x', -w / 2)
|
|
430
|
-
.attr('y', topY)
|
|
431
|
-
.attr('width', w)
|
|
432
|
-
.attr('height', bodyH)
|
|
433
|
-
.attr('fill', f)
|
|
434
|
-
.attr('stroke', 'none');
|
|
435
|
-
|
|
436
|
-
g.append('line')
|
|
437
|
-
.attr('x1', -w / 2)
|
|
438
|
-
.attr('y1', topY)
|
|
439
|
-
.attr('x2', -w / 2)
|
|
440
|
-
.attr('y2', topY + bodyH)
|
|
441
|
-
.attr('stroke', s)
|
|
442
|
-
.attr('stroke-width', SW)
|
|
443
|
-
.attr('stroke-dasharray', dash);
|
|
444
|
-
g.append('line')
|
|
445
|
-
.attr('x1', w / 2)
|
|
446
|
-
.attr('y1', topY)
|
|
447
|
-
.attr('x2', w / 2)
|
|
448
|
-
.attr('y2', topY + bodyH)
|
|
449
|
-
.attr('stroke', s)
|
|
450
|
-
.attr('stroke-width', SW)
|
|
451
|
-
.attr('stroke-dasharray', dash);
|
|
452
|
-
|
|
453
|
-
// Top ellipse cap
|
|
454
|
-
g.append('ellipse')
|
|
455
|
-
.attr('cx', 0)
|
|
456
|
-
.attr('cy', topY)
|
|
457
|
-
.attr('rx', w / 2)
|
|
458
|
-
.attr('ry', ry)
|
|
459
|
-
.attr('fill', f)
|
|
460
|
-
.attr('stroke', s)
|
|
461
|
-
.attr('stroke-width', SW)
|
|
462
|
-
.attr('stroke-dasharray', dash);
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
// ============================================================
|
|
466
|
-
// Collapsible Section Support
|
|
467
|
-
// ============================================================
|
|
468
|
-
|
|
469
|
-
export interface SectionMessageGroup {
|
|
470
|
-
section: import('./parser').SequenceSection;
|
|
471
|
-
messageIndices: number[]; // indices into messages[]
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
export interface SequenceRenderOptions {
|
|
475
|
-
collapsedSections?: Set<number>; // keyed by section lineNumber
|
|
476
|
-
collapsedGroups?: Set<number>; // keyed by group lineNumber
|
|
477
|
-
exportWidth?: number; // Explicit width for CLI/export rendering (bypasses getBoundingClientRect)
|
|
478
|
-
activeTagGroup?: string | null; // Active tag group name for tag-driven recoloring; null = explicitly none
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
/**
|
|
482
|
-
* Group messages by the top-level section that precedes them.
|
|
483
|
-
* Messages before the first section are ungrouped (always visible).
|
|
484
|
-
* Only top-level sections are collapsible — sections inside blocks are excluded.
|
|
485
|
-
*/
|
|
486
|
-
export function groupMessagesBySection(
|
|
487
|
-
elements: readonly SequenceElement[],
|
|
488
|
-
messages: readonly SequenceMessage[]
|
|
489
|
-
): SectionMessageGroup[] {
|
|
490
|
-
const groups: SectionMessageGroup[] = [];
|
|
491
|
-
let currentGroup: SectionMessageGroup | null = null;
|
|
492
|
-
|
|
493
|
-
// Look up by lineNumber — collapse projection creates separate spread copies
|
|
494
|
-
// for messages[] and the messages embedded in elements[], breaking reference
|
|
495
|
-
// equality. lineNumber is preserved across spreads.
|
|
496
|
-
const msgLineToIdx = new Map<number, number>();
|
|
497
|
-
messages.forEach((m, i) => msgLineToIdx.set(m.lineNumber, i));
|
|
498
|
-
|
|
499
|
-
// Recursively collect all message indices from an element subtree
|
|
500
|
-
const collectIndices = (els: readonly SequenceElement[]): number[] => {
|
|
501
|
-
const indices: number[] = [];
|
|
502
|
-
for (const el of els) {
|
|
503
|
-
if (isSequenceBlock(el)) {
|
|
504
|
-
indices.push(
|
|
505
|
-
...collectIndices(el.children),
|
|
506
|
-
...collectIndices(el.elseChildren)
|
|
507
|
-
);
|
|
508
|
-
if (el.elseIfBranches) {
|
|
509
|
-
for (const branch of el.elseIfBranches) {
|
|
510
|
-
indices.push(...collectIndices(branch.children));
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
} else if (isSequenceSection(el) || isSequenceNote(el)) {
|
|
514
|
-
// Sections and notes inside blocks are not messages — skip
|
|
515
|
-
continue;
|
|
516
|
-
} else {
|
|
517
|
-
const idx = msgLineToIdx.get(el.lineNumber) ?? -1;
|
|
518
|
-
if (idx >= 0) indices.push(idx);
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
return indices;
|
|
522
|
-
};
|
|
523
|
-
|
|
524
|
-
for (const el of elements) {
|
|
525
|
-
if (isSequenceSection(el)) {
|
|
526
|
-
// Start a new group for this top-level section
|
|
527
|
-
currentGroup = { section: el, messageIndices: [] };
|
|
528
|
-
groups.push(currentGroup);
|
|
529
|
-
} else if (currentGroup) {
|
|
530
|
-
// Collect messages from this element into the current group
|
|
531
|
-
if (isSequenceBlock(el)) {
|
|
532
|
-
currentGroup.messageIndices.push(...collectIndices([el]));
|
|
533
|
-
} else if (!isSequenceNote(el)) {
|
|
534
|
-
const idx = msgLineToIdx.get(el.lineNumber) ?? -1;
|
|
535
|
-
if (idx >= 0) currentGroup.messageIndices.push(idx);
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
// Messages before the first section are ungrouped — skip
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
return groups;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
// ============================================================
|
|
545
|
-
// Render Sequence Builder (stack-based return placement)
|
|
546
|
-
// ============================================================
|
|
547
|
-
|
|
548
|
-
export interface RenderStep {
|
|
549
|
-
type: 'call' | 'return';
|
|
550
|
-
from: string;
|
|
551
|
-
to: string;
|
|
552
|
-
label: string;
|
|
553
|
-
messageIndex: number;
|
|
554
|
-
async?: boolean;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
/**
|
|
558
|
-
* Build an ordered render sequence from flat messages.
|
|
559
|
-
* Uses a call stack to infer where returns should be placed:
|
|
560
|
-
* returns appear after all nested sub-calls complete.
|
|
561
|
-
*/
|
|
562
|
-
export function buildRenderSequence(
|
|
563
|
-
messages: readonly SequenceMessage[]
|
|
564
|
-
): RenderStep[] {
|
|
565
|
-
const steps: RenderStep[] = [];
|
|
566
|
-
const stack: {
|
|
567
|
-
from: string;
|
|
568
|
-
to: string;
|
|
569
|
-
messageIndex: number;
|
|
570
|
-
}[] = [];
|
|
571
|
-
|
|
572
|
-
for (let mi = 0; mi < messages.length; mi++) {
|
|
573
|
-
// In-bounds by loop guard.
|
|
574
|
-
const msg = messages[mi]!;
|
|
575
|
-
// Pop returns for callees that are no longer the sender
|
|
576
|
-
while (stack.length > 0) {
|
|
577
|
-
// In-bounds: stack.length > 0 guard above.
|
|
578
|
-
const top = stack[stack.length - 1]!;
|
|
579
|
-
if (top.to === msg.from) break; // callee is still working
|
|
580
|
-
stack.pop();
|
|
581
|
-
steps.push({
|
|
582
|
-
type: 'return',
|
|
583
|
-
from: top.to,
|
|
584
|
-
to: top.from,
|
|
585
|
-
label: '',
|
|
586
|
-
messageIndex: top.messageIndex,
|
|
587
|
-
});
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
// Response heuristic: if B sends back to A and the top of the
|
|
591
|
-
// stack is A→B, treat this as a response (pop) rather than a new
|
|
592
|
-
// call. Covers the common request-response ping-pong pattern.
|
|
593
|
-
if (stack.length > 0 && !msg.async && msg.from !== msg.to) {
|
|
594
|
-
const top = stack[stack.length - 1]!;
|
|
595
|
-
if (top.from === msg.to && top.to === msg.from) {
|
|
596
|
-
stack.pop();
|
|
597
|
-
steps.push({
|
|
598
|
-
type: 'return',
|
|
599
|
-
from: msg.from,
|
|
600
|
-
to: msg.to,
|
|
601
|
-
label: msg.label,
|
|
602
|
-
messageIndex: mi,
|
|
603
|
-
});
|
|
604
|
-
continue;
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
// Emit call
|
|
609
|
-
steps.push({
|
|
610
|
-
type: 'call',
|
|
611
|
-
from: msg.from,
|
|
612
|
-
to: msg.to,
|
|
613
|
-
label: msg.label,
|
|
614
|
-
messageIndex: mi,
|
|
615
|
-
...(msg.async ? { async: true } : {}),
|
|
616
|
-
});
|
|
617
|
-
|
|
618
|
-
// Async messages: no return arrow, no activation on target
|
|
619
|
-
if (msg.async) {
|
|
620
|
-
continue;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
if (msg.from === msg.to) {
|
|
624
|
-
// Self-call: immediately emit return (completes instantly)
|
|
625
|
-
steps.push({
|
|
626
|
-
type: 'return',
|
|
627
|
-
from: msg.to,
|
|
628
|
-
to: msg.from,
|
|
629
|
-
label: '',
|
|
630
|
-
messageIndex: mi,
|
|
631
|
-
});
|
|
632
|
-
} else {
|
|
633
|
-
// Push onto stack for pending return
|
|
634
|
-
stack.push({
|
|
635
|
-
from: msg.from,
|
|
636
|
-
to: msg.to,
|
|
637
|
-
messageIndex: mi,
|
|
638
|
-
});
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
// Flush remaining returns
|
|
643
|
-
while (stack.length > 0) {
|
|
644
|
-
const top = stack.pop()!;
|
|
645
|
-
steps.push({
|
|
646
|
-
type: 'return',
|
|
647
|
-
from: top.to,
|
|
648
|
-
to: top.from,
|
|
649
|
-
label: '',
|
|
650
|
-
messageIndex: top.messageIndex,
|
|
651
|
-
});
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
return steps;
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
// ============================================================
|
|
658
|
-
// Activation Computation
|
|
659
|
-
// ============================================================
|
|
660
|
-
|
|
661
|
-
export interface Activation {
|
|
662
|
-
participantId: string;
|
|
663
|
-
startStep: number;
|
|
664
|
-
endStep: number;
|
|
665
|
-
depth: number;
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
/**
|
|
669
|
-
* Compute activation rectangles from render steps.
|
|
670
|
-
* Each call pushes onto the callee's stack; each return pops it.
|
|
671
|
-
*/
|
|
672
|
-
export function computeActivations(steps: RenderStep[]): Activation[] {
|
|
673
|
-
const activations: Activation[] = [];
|
|
674
|
-
// Per-participant stack of open activations (step index)
|
|
675
|
-
const stacks = new Map<string, number[]>();
|
|
676
|
-
|
|
677
|
-
const getStack = (id: string): number[] => {
|
|
678
|
-
if (!stacks.has(id)) stacks.set(id, []);
|
|
679
|
-
return stacks.get(id)!;
|
|
680
|
-
};
|
|
681
|
-
|
|
682
|
-
for (let i = 0; i < steps.length; i++) {
|
|
683
|
-
// In-bounds by loop guard.
|
|
684
|
-
const step = steps[i]!;
|
|
685
|
-
if (step.type === 'call') {
|
|
686
|
-
const s = getStack(step.to);
|
|
687
|
-
s.push(i);
|
|
688
|
-
} else {
|
|
689
|
-
// return: step.from is the callee returning
|
|
690
|
-
const s = getStack(step.from);
|
|
691
|
-
if (s.length > 0) {
|
|
692
|
-
const startIdx = s.pop()!;
|
|
693
|
-
activations.push({
|
|
694
|
-
participantId: step.from,
|
|
695
|
-
startStep: startIdx,
|
|
696
|
-
endStep: i,
|
|
697
|
-
depth: s.length,
|
|
698
|
-
});
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
return activations;
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
// ============================================================
|
|
707
|
-
// Position Override Sorting
|
|
708
|
-
// ============================================================
|
|
709
|
-
|
|
710
|
-
/**
|
|
711
|
-
* Reorder participants based on explicit `position` overrides.
|
|
712
|
-
* Positive positions are 0-based from the left; negative positions count from the right (-1 = last).
|
|
713
|
-
* Unpositioned participants maintain their relative order, filling remaining slots.
|
|
714
|
-
*/
|
|
715
|
-
export function applyPositionOverrides(
|
|
716
|
-
participants: readonly SequenceParticipant[]
|
|
717
|
-
): SequenceParticipant[] {
|
|
718
|
-
// Copy to a mutable array on the no-op path so callers always get a
|
|
719
|
-
// fresh `SequenceParticipant[]` regardless of input mutability.
|
|
720
|
-
if (!participants.some((p) => p.position !== undefined))
|
|
721
|
-
return [...participants];
|
|
722
|
-
|
|
723
|
-
const total = participants.length;
|
|
724
|
-
const positioned: { participant: SequenceParticipant; index: number }[] = [];
|
|
725
|
-
const unpositioned: SequenceParticipant[] = [];
|
|
726
|
-
|
|
727
|
-
for (const p of participants) {
|
|
728
|
-
if (p.position !== undefined) {
|
|
729
|
-
// Resolve negative: -1 → last, -2 → second-to-last
|
|
730
|
-
let idx = p.position < 0 ? total + p.position : p.position;
|
|
731
|
-
// Clamp to valid range
|
|
732
|
-
idx = Math.max(0, Math.min(total - 1, idx));
|
|
733
|
-
positioned.push({ participant: p, index: idx });
|
|
734
|
-
} else {
|
|
735
|
-
unpositioned.push(p);
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
// Sort positioned by target index for deterministic placement
|
|
740
|
-
positioned.sort((a, b) => a.index - b.index);
|
|
741
|
-
|
|
742
|
-
// Place positioned participants, resolving conflicts by finding nearest free slot
|
|
743
|
-
const result: (SequenceParticipant | null)[] = new Array(total).fill(null);
|
|
744
|
-
const usedIndices = new Set<number>();
|
|
745
|
-
|
|
746
|
-
for (const { participant, index } of positioned) {
|
|
747
|
-
let idx = index;
|
|
748
|
-
if (usedIndices.has(idx)) {
|
|
749
|
-
// Find nearest free slot
|
|
750
|
-
for (let offset = 1; offset < total; offset++) {
|
|
751
|
-
if (idx + offset < total && !usedIndices.has(idx + offset)) {
|
|
752
|
-
idx = idx + offset;
|
|
753
|
-
break;
|
|
754
|
-
}
|
|
755
|
-
if (idx - offset >= 0 && !usedIndices.has(idx - offset)) {
|
|
756
|
-
idx = idx - offset;
|
|
757
|
-
break;
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
result[idx] = participant;
|
|
762
|
-
usedIndices.add(idx);
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
// Fill remaining slots with unpositioned participants in order
|
|
766
|
-
let uIdx = 0;
|
|
767
|
-
for (let i = 0; i < total; i++) {
|
|
768
|
-
if (result[i] === null) {
|
|
769
|
-
// In-bounds: positioned.length + unpositioned.length === total, so the
|
|
770
|
-
// remaining `null` slots equal unpositioned.length.
|
|
771
|
-
result[i] = unpositioned[uIdx++]!;
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
return result as SequenceParticipant[];
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
// Group Ordering
|
|
779
|
-
// ============================================================
|
|
780
|
-
|
|
781
|
-
/**
|
|
782
|
-
* Reorder participants so that members of the same group are adjacent.
|
|
783
|
-
* Groups are positioned at the point where their first member would naturally
|
|
784
|
-
* appear based on message order (first-occurrence positioning). This prevents
|
|
785
|
-
* groups declared at the top of the file from being placed before participants
|
|
786
|
-
* that appear in messages earlier.
|
|
787
|
-
*
|
|
788
|
-
* Explicit `position` overrides are handled separately by `applyPositionOverrides`.
|
|
789
|
-
*/
|
|
790
|
-
export function applyGroupOrdering(
|
|
791
|
-
participants: readonly SequenceParticipant[],
|
|
792
|
-
groups: readonly SequenceGroup[],
|
|
793
|
-
messages: readonly SequenceMessage[] = []
|
|
794
|
-
): SequenceParticipant[] {
|
|
795
|
-
// Copy to a mutable array on the no-op path so callers always get a
|
|
796
|
-
// fresh `SequenceParticipant[]` regardless of input mutability.
|
|
797
|
-
if (groups.length === 0) return [...participants];
|
|
798
|
-
|
|
799
|
-
// Build a map: participantId → group
|
|
800
|
-
const idToGroup = new Map<string, SequenceGroup>();
|
|
801
|
-
for (const group of groups) {
|
|
802
|
-
for (const id of group.participantIds) {
|
|
803
|
-
idToGroup.set(id, group);
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
// Build first-appearance index from messages (order in which participants
|
|
808
|
-
// are first referenced). Participants not in any message keep their
|
|
809
|
-
// declaration order from the participants array.
|
|
810
|
-
const appearanceOrder: string[] = [];
|
|
811
|
-
const seen = new Set<string>();
|
|
812
|
-
for (const msg of messages) {
|
|
813
|
-
for (const id of [msg.from, msg.to]) {
|
|
814
|
-
if (!seen.has(id)) {
|
|
815
|
-
seen.add(id);
|
|
816
|
-
appearanceOrder.push(id);
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
}
|
|
820
|
-
// Append any participants not referenced in messages (declaration-only)
|
|
821
|
-
for (const p of participants) {
|
|
822
|
-
if (!seen.has(p.id)) {
|
|
823
|
-
seen.add(p.id);
|
|
824
|
-
appearanceOrder.push(p.id);
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
// Walk appearance order; when we encounter a grouped participant,
|
|
829
|
-
// insert the entire group at that position (if not already placed).
|
|
830
|
-
const result: SequenceParticipant[] = [];
|
|
831
|
-
const placed = new Set<string>();
|
|
832
|
-
const placedGroups = new Set<SequenceGroup>();
|
|
833
|
-
|
|
834
|
-
for (const id of appearanceOrder) {
|
|
835
|
-
if (placed.has(id)) continue;
|
|
836
|
-
|
|
837
|
-
const group = idToGroup.get(id);
|
|
838
|
-
if (group && !placedGroups.has(group)) {
|
|
839
|
-
// Place entire group here
|
|
840
|
-
placedGroups.add(group);
|
|
841
|
-
for (const gid of group.participantIds) {
|
|
842
|
-
const p = participants.find((pp) => pp.id === gid);
|
|
843
|
-
if (p && !placed.has(gid)) {
|
|
844
|
-
result.push(p);
|
|
845
|
-
placed.add(gid);
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
} else if (!group) {
|
|
849
|
-
// Ungrouped participant
|
|
850
|
-
const p = participants.find((pp) => pp.id === id);
|
|
851
|
-
if (p) {
|
|
852
|
-
result.push(p);
|
|
853
|
-
placed.add(id);
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
// If group already placed, skip (member already included)
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
return result;
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
// Main Renderer
|
|
863
|
-
// ============================================================
|
|
864
|
-
|
|
865
|
-
/**
|
|
866
|
-
* Render a sequence diagram into the given container element.
|
|
867
|
-
*/
|
|
868
|
-
export function renderSequenceDiagram(
|
|
869
|
-
container: HTMLDivElement,
|
|
870
|
-
parsed: ParsedSequenceDgmo,
|
|
871
|
-
palette: PaletteColors,
|
|
872
|
-
isDark: boolean,
|
|
873
|
-
_onNavigateToLine?: (line: number) => void,
|
|
874
|
-
options?: SequenceRenderOptions
|
|
875
|
-
): void {
|
|
876
|
-
// Clear previous content
|
|
877
|
-
d3Selection.select(container).selectAll('*').remove();
|
|
878
|
-
|
|
879
|
-
const { title, options: parsedOptions } = parsed;
|
|
880
|
-
const solid = parsedOptions['solid-fill'] === 'on';
|
|
881
|
-
|
|
882
|
-
// Effective collapsed groups. `options.collapsedGroups` is the AUTHORITATIVE
|
|
883
|
-
// desired set when supplied — the app seeds it from the source `collapsed:
|
|
884
|
-
// true` markers and mutates it on toggle (write-through keeps source in sync),
|
|
885
|
-
// so it must NOT be XOR'd against the syntax-declared set (doing so cancelled
|
|
886
|
-
// a seeded set back to expanded). With no set supplied (bare render / CLI),
|
|
887
|
-
// default to the syntax-declared collapsed groups. Mirrors gantt/kanban/state.
|
|
888
|
-
const effectiveCollapsedGroups = options?.collapsedGroups
|
|
889
|
-
? new Set(options.collapsedGroups)
|
|
890
|
-
: new Set(
|
|
891
|
-
parsed.groups.filter((g) => g.collapsed).map((g) => g.lineNumber)
|
|
892
|
-
);
|
|
893
|
-
|
|
894
|
-
// Apply collapse projection before participant ordering
|
|
895
|
-
const collapsed: CollapsedView | null =
|
|
896
|
-
effectiveCollapsedGroups.size > 0
|
|
897
|
-
? applyCollapseProjection(parsed, effectiveCollapsedGroups)
|
|
898
|
-
: null;
|
|
899
|
-
|
|
900
|
-
const messages = collapsed ? collapsed.messages : parsed.messages;
|
|
901
|
-
const elements = collapsed ? collapsed.elements : parsed.elements;
|
|
902
|
-
const groups = collapsed ? collapsed.groups : parsed.groups;
|
|
903
|
-
const collapsedSections = options?.collapsedSections;
|
|
904
|
-
|
|
905
|
-
const sourceParticipants = collapsed
|
|
906
|
-
? collapsed.participants
|
|
907
|
-
: parsed.participants;
|
|
908
|
-
const participants = applyPositionOverrides(
|
|
909
|
-
applyGroupOrdering(sourceParticipants, groups, messages)
|
|
910
|
-
);
|
|
911
|
-
if (participants.length === 0) return;
|
|
912
|
-
|
|
913
|
-
// Compute group boundaries for inter-group spacing redistribution.
|
|
914
|
-
// A gap is "between" (wider) whenever the two adjacent lifelines are not
|
|
915
|
-
// members of the same group — this includes group→group, group→loose, and
|
|
916
|
-
// loose→group transitions. Only two lifelines inside the same group, or two
|
|
917
|
-
// adjacent loose lifelines, get the tighter "within" gap. Treating a loose
|
|
918
|
-
// participant as its own non-group prevents it from being jammed against an
|
|
919
|
-
// adjacent group box (whose frame extends GROUP_PADDING_X past its members).
|
|
920
|
-
const groupBoundaryIds = new Set<string>();
|
|
921
|
-
if (groups.length > 0) {
|
|
922
|
-
const pToG = new Map<string, number>();
|
|
923
|
-
for (let gi = 0; gi < groups.length; gi++) {
|
|
924
|
-
for (const pid of groups[gi]!.participantIds) pToG.set(pid, gi);
|
|
925
|
-
}
|
|
926
|
-
const LOOSE = -1;
|
|
927
|
-
for (let i = 1; i < participants.length; i++) {
|
|
928
|
-
const prevGi = pToG.get(participants[i - 1]!.id) ?? LOOSE;
|
|
929
|
-
const gi = pToG.get(participants[i]!.id) ?? LOOSE;
|
|
930
|
-
// Different group membership → boundary. Two loose lifelines (both LOOSE)
|
|
931
|
-
// stay tight since neither carries a group frame.
|
|
932
|
-
if (gi !== prevGi && !(gi === LOOSE && prevGi === LOOSE)) {
|
|
933
|
-
groupBoundaryIds.add(participants[i]!.id);
|
|
934
|
-
}
|
|
935
|
-
}
|
|
936
|
-
}
|
|
937
|
-
const numGroupGaps = groupBoundaryIds.size;
|
|
938
|
-
|
|
939
|
-
// Content-adaptive box width: measure widest label, use uniform width for all
|
|
940
|
-
const MAX_BOX_WIDTH = 225;
|
|
941
|
-
let uniformBoxWidth = PARTICIPANT_BOX_WIDTH;
|
|
942
|
-
for (const p of participants) {
|
|
943
|
-
const lines = splitParticipantLabel(
|
|
944
|
-
p.label,
|
|
945
|
-
labelTextWidth(PARTICIPANT_BOX_WIDTH),
|
|
946
|
-
LABEL_FONT_SIZE
|
|
947
|
-
);
|
|
948
|
-
if (lines.length === 0) continue;
|
|
949
|
-
const widest = Math.max(
|
|
950
|
-
...lines.map((l) => measureText(l, LABEL_FONT_SIZE))
|
|
951
|
-
);
|
|
952
|
-
const labelWidth = widest + 10;
|
|
953
|
-
uniformBoxWidth = Math.max(uniformBoxWidth, labelWidth);
|
|
954
|
-
}
|
|
955
|
-
uniformBoxWidth = Math.min(MAX_BOX_WIDTH, uniformBoxWidth);
|
|
956
|
-
const effectiveGap = Math.max(PARTICIPANT_GAP, uniformBoxWidth + 30);
|
|
957
|
-
|
|
958
|
-
const idealWidth = Math.max(
|
|
959
|
-
participants.length * effectiveGap,
|
|
960
|
-
uniformBoxWidth + 40
|
|
961
|
-
);
|
|
962
|
-
const containerWidth =
|
|
963
|
-
options?.exportWidth ?? container.getBoundingClientRect().width;
|
|
964
|
-
const ctx = options?.exportWidth
|
|
965
|
-
? ScaleContext.identity()
|
|
966
|
-
: ScaleContext.from(containerWidth, idealWidth);
|
|
967
|
-
|
|
968
|
-
const sGap = ctx.structural(effectiveGap);
|
|
969
|
-
const sBoxW = ctx.structural(uniformBoxWidth);
|
|
970
|
-
const sBoxH = ctx.structural(PARTICIPANT_BOX_HEIGHT);
|
|
971
|
-
const sTopMargin = ctx.aesthetic(TOP_MARGIN);
|
|
972
|
-
const sTitleHeight = ctx.aesthetic(TITLE_HEIGHT);
|
|
973
|
-
const sParticipantYOffset = ctx.aesthetic(PARTICIPANT_Y_OFFSET);
|
|
974
|
-
const sMsgStartOffset = ctx.structural(MESSAGE_START_OFFSET);
|
|
975
|
-
const sLifelineTail = ctx.structural(LIFELINE_TAIL);
|
|
976
|
-
const sArrowheadSize = ctx.structural(ARROWHEAD_SIZE);
|
|
977
|
-
const sNoteMaxW = ctx.structural(NOTE_MAX_W);
|
|
978
|
-
const sNoteFold = ctx.structural(NOTE_FOLD);
|
|
979
|
-
const sNotePadH = ctx.structural(NOTE_PAD_H);
|
|
980
|
-
const sNotePadV = ctx.structural(NOTE_PAD_V);
|
|
981
|
-
const sNoteFontSize = ctx.text(NOTE_FONT_SIZE);
|
|
982
|
-
const sNoteLineH = ctx.structural(NOTE_LINE_H);
|
|
983
|
-
const sNoteGap = ctx.structural(NOTE_GAP);
|
|
984
|
-
const sActivationWidth = ctx.structural(ACTIVATION_WIDTH);
|
|
985
|
-
const sSelfCallHeight = SELF_CALL_HEIGHT;
|
|
986
|
-
const sSelfCallWidth = ctx.structural(SELF_CALL_WIDTH);
|
|
987
|
-
// Pixel width available for note text inside the widest allowed note box
|
|
988
|
-
// (box width minus the horizontal padding on both sides and the fold cut).
|
|
989
|
-
const sNoteTextWidthMax = sNoteMaxW - sNotePadH * 2 - sNoteFold;
|
|
990
|
-
const sNoteLaneMax = sGap - sActivationWidth - sNoteGap;
|
|
991
|
-
const sLabelFontSize = ctx.text(LABEL_FONT_SIZE);
|
|
992
|
-
// Pixel width available for a participant label inside the scaled box.
|
|
993
|
-
const sLabelTextWidth = labelTextWidth(sBoxW);
|
|
994
|
-
|
|
995
|
-
// Participant index lookup — used to clamp note width within one lane
|
|
996
|
-
const participantIndexMap = new Map<string, number>();
|
|
997
|
-
participants.forEach((p, i) => participantIndexMap.set(p.id, i));
|
|
998
|
-
|
|
999
|
-
// Notes anchored to the outermost participant on the side they'd extend
|
|
1000
|
-
// toward have no neighbor lane to fit into, so they would overflow the SVG.
|
|
1001
|
-
// Flip them to the inside when there's a participant on the other side.
|
|
1002
|
-
const effectiveNotePosition = (note: SequenceNote): 'left' | 'right' => {
|
|
1003
|
-
const idx = participantIndexMap.get(note.participantId);
|
|
1004
|
-
if (idx === undefined) return note.position;
|
|
1005
|
-
if (note.position === 'right' && idx === participants.length - 1 && idx > 0)
|
|
1006
|
-
return 'left';
|
|
1007
|
-
if (note.position === 'left' && idx === 0 && participants.length > 1)
|
|
1008
|
-
return 'right';
|
|
1009
|
-
return note.position;
|
|
1010
|
-
};
|
|
1011
|
-
|
|
1012
|
-
// Extra X shift for notes after self-calls
|
|
1013
|
-
const SELF_CALL_NOTE_X_SHIFT =
|
|
1014
|
-
sActivationWidth / 2 +
|
|
1015
|
-
sSelfCallWidth +
|
|
1016
|
-
sNoteGap -
|
|
1017
|
-
(sActivationWidth + sNoteGap);
|
|
1018
|
-
|
|
1019
|
-
const noteEffectiveMaxW = (
|
|
1020
|
-
participantId: string,
|
|
1021
|
-
position: 'right' | 'left',
|
|
1022
|
-
afterSelfCall = false
|
|
1023
|
-
): number => {
|
|
1024
|
-
const idx = participantIndexMap.get(participantId);
|
|
1025
|
-
if (idx === undefined) return sNoteMaxW;
|
|
1026
|
-
const hasNeighbor =
|
|
1027
|
-
position === 'right' ? idx < participants.length - 1 : idx > 0;
|
|
1028
|
-
if (!hasNeighbor) return sNoteMaxW;
|
|
1029
|
-
const laneMax =
|
|
1030
|
-
afterSelfCall && position === 'right'
|
|
1031
|
-
? sNoteLaneMax - SELF_CALL_NOTE_X_SHIFT
|
|
1032
|
-
: sNoteLaneMax;
|
|
1033
|
-
return Math.min(sNoteMaxW, laneMax);
|
|
1034
|
-
};
|
|
1035
|
-
|
|
1036
|
-
// Pixel width available for note text inside a note box of the given outer
|
|
1037
|
-
// width — outer width minus horizontal padding (both sides) and the fold cut.
|
|
1038
|
-
const noteTextWidth = (maxW: number): number =>
|
|
1039
|
-
maxW - sNotePadH * 2 - sNoteFold;
|
|
1040
|
-
|
|
1041
|
-
const activationsOff = parsedOptions['activations']?.toLowerCase() === 'off';
|
|
1042
|
-
|
|
1043
|
-
// Tag resolution — shared utility handles priority chain:
|
|
1044
|
-
// programmatic override → diagram-level active-tag → auto-activate first group
|
|
1045
|
-
const activeTagGroup =
|
|
1046
|
-
resolveActiveTagGroup(
|
|
1047
|
-
parsed.tagGroups.filter((tg) => tg.entries.length > 0),
|
|
1048
|
-
parsedOptions['active-tag'],
|
|
1049
|
-
options?.activeTagGroup
|
|
1050
|
-
) ?? undefined;
|
|
1051
|
-
let tagMap: ResolvedTagMap | undefined;
|
|
1052
|
-
const tagValueToColor = new Map<string, string>();
|
|
1053
|
-
if (activeTagGroup) {
|
|
1054
|
-
tagMap = resolveSequenceTags(parsed, activeTagGroup);
|
|
1055
|
-
const tg = parsed.tagGroups.find(
|
|
1056
|
-
(g) => g.name.toLowerCase() === activeTagGroup.toLowerCase()
|
|
1057
|
-
);
|
|
1058
|
-
if (tg) {
|
|
1059
|
-
for (const entry of tg.entries) {
|
|
1060
|
-
tagValueToColor.set(entry.value.toLowerCase(), entry.color);
|
|
1061
|
-
}
|
|
1062
|
-
}
|
|
1063
|
-
}
|
|
1064
|
-
const getTagColor = (value: string | undefined): string | undefined =>
|
|
1065
|
-
value ? tagValueToColor.get(value.toLowerCase()) : undefined;
|
|
1066
|
-
const tagKey = activeTagGroup ? tagAttrKey(activeTagGroup) : undefined;
|
|
1067
|
-
|
|
1068
|
-
// Build hidden message set for collapse support
|
|
1069
|
-
const hiddenMsgIndices = new Set<number>();
|
|
1070
|
-
if (collapsedSections && collapsedSections.size > 0) {
|
|
1071
|
-
const sectionGroups = groupMessagesBySection(elements, messages);
|
|
1072
|
-
for (const grp of sectionGroups) {
|
|
1073
|
-
if (collapsedSections.has(grp.section.lineNumber)) {
|
|
1074
|
-
for (const idx of grp.messageIndices) {
|
|
1075
|
-
hiddenMsgIndices.add(idx);
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
// Build render sequence with stack-based return placement
|
|
1082
|
-
// Run on ALL messages first (preserves call stack correctness), then filter
|
|
1083
|
-
const allRenderSteps = buildRenderSequence(messages);
|
|
1084
|
-
// A step survives into the laid-out sequence when it is not hidden by a
|
|
1085
|
-
// collapsed section AND it is either a call or a *labeled* return. Unlabeled
|
|
1086
|
-
// returns are dropped from the layout — they add vertical noise without
|
|
1087
|
-
// conveying information — but they still balance the activation stack, so
|
|
1088
|
-
// dropping them before computeActivations would corrupt nesting depth
|
|
1089
|
-
// (self-call returns are unlabeled; their pushes would never pop, shifting
|
|
1090
|
-
// the parent activation to a bogus depth). Compute depth on the full
|
|
1091
|
-
// balanced sequence, then remap step indices to the laid-out array.
|
|
1092
|
-
const stepSurvives = (s: RenderStep): boolean =>
|
|
1093
|
-
(hiddenMsgIndices.size === 0 || !hiddenMsgIndices.has(s.messageIndex)) &&
|
|
1094
|
-
(s.type === 'call' || !!s.label);
|
|
1095
|
-
const renderSteps: RenderStep[] = [];
|
|
1096
|
-
// allRenderSteps index → laid-out (renderSteps) index. A dropped step maps to
|
|
1097
|
-
// the next surviving step, so an activation whose closing return was dropped
|
|
1098
|
-
// still extends to the following event rather than collapsing to zero height.
|
|
1099
|
-
const allToFiltered: number[] = new Array(allRenderSteps.length);
|
|
1100
|
-
for (let j = 0; j < allRenderSteps.length; j++) {
|
|
1101
|
-
const s = allRenderSteps[j]!;
|
|
1102
|
-
allToFiltered[j] = renderSteps.length;
|
|
1103
|
-
if (stepSurvives(s)) renderSteps.push(s);
|
|
1104
|
-
}
|
|
1105
|
-
const lastStepIdx = Math.max(renderSteps.length - 1, 0);
|
|
1106
|
-
const clampStep = (i: number): number =>
|
|
1107
|
-
Math.min(Math.max(i, 0), lastStepIdx);
|
|
1108
|
-
// Activations computed on the balanced sequence for correct nesting depth,
|
|
1109
|
-
// then remapped to laid-out indices for Y positioning.
|
|
1110
|
-
const activations = activationsOff
|
|
1111
|
-
? []
|
|
1112
|
-
: computeActivations(allRenderSteps).map((a) => ({
|
|
1113
|
-
...a,
|
|
1114
|
-
startStep: clampStep(allToFiltered[a.startStep] ?? 0),
|
|
1115
|
-
endStep: clampStep(allToFiltered[a.endStep] ?? 0),
|
|
1116
|
-
}));
|
|
1117
|
-
// Vertical spacing is NOT compressed by ScaleContext — the container scrolls
|
|
1118
|
-
// vertically, so keeping full spacing preserves message readability at all scales.
|
|
1119
|
-
const stepSpacing = 35;
|
|
1120
|
-
|
|
1121
|
-
// --- Block-aware Y spacing ---
|
|
1122
|
-
const BLOCK_HEADER_SPACE = 30;
|
|
1123
|
-
const BLOCK_AFTER_SPACE = 15;
|
|
1124
|
-
const FRAME_PADDING_TOP = 42;
|
|
1125
|
-
|
|
1126
|
-
// Build maps from messageIndex to render step indices (needed early for spacing)
|
|
1127
|
-
const msgToFirstStep = new Map<number, number>();
|
|
1128
|
-
const msgToLastStep = new Map<number, number>();
|
|
1129
|
-
renderSteps.forEach((step, si) => {
|
|
1130
|
-
if (!msgToFirstStep.has(step.messageIndex)) {
|
|
1131
|
-
msgToFirstStep.set(step.messageIndex, si);
|
|
1132
|
-
}
|
|
1133
|
-
msgToLastStep.set(step.messageIndex, si);
|
|
1134
|
-
});
|
|
1135
|
-
|
|
1136
|
-
// Map a note to the last render-step index of its preceding message
|
|
1137
|
-
// (the return arrow if present, otherwise the call arrow).
|
|
1138
|
-
// This ensures notes are positioned below the return arrow so they
|
|
1139
|
-
// don't overlap it.
|
|
1140
|
-
// If the note's closest preceding message is hidden (collapsed section), return -1
|
|
1141
|
-
// so the note is hidden along with its section.
|
|
1142
|
-
const findAssociatedLastStep = (note: SequenceNote): number => {
|
|
1143
|
-
// First find the closest preceding message (ignoring hidden filter)
|
|
1144
|
-
let closestMsgIndex = -1;
|
|
1145
|
-
let closestLine = -1;
|
|
1146
|
-
for (let mi = 0; mi < messages.length; mi++) {
|
|
1147
|
-
// In-bounds by loop guard.
|
|
1148
|
-
const m = messages[mi]!;
|
|
1149
|
-
if (m.lineNumber < note.lineNumber && m.lineNumber > closestLine) {
|
|
1150
|
-
closestLine = m.lineNumber;
|
|
1151
|
-
closestMsgIndex = mi;
|
|
1152
|
-
}
|
|
1153
|
-
}
|
|
1154
|
-
// If the closest preceding message is hidden, hide the note too
|
|
1155
|
-
if (closestMsgIndex >= 0 && hiddenMsgIndices.has(closestMsgIndex)) {
|
|
1156
|
-
return -1;
|
|
1157
|
-
}
|
|
1158
|
-
if (closestMsgIndex < 0) return -1;
|
|
1159
|
-
return msgToLastStep.get(closestMsgIndex) ?? -1;
|
|
1160
|
-
};
|
|
1161
|
-
|
|
1162
|
-
// Check whether a note's preceding message is a self-call.
|
|
1163
|
-
// Self-call loopback arrows extend SELF_CALL_HEIGHT below the step Y,
|
|
1164
|
-
// so notes after self-calls need a larger vertical offset.
|
|
1165
|
-
const isNoteAfterSelfCall = (note: SequenceNote): boolean => {
|
|
1166
|
-
let closestMsgIndex = -1;
|
|
1167
|
-
let closestLine = -1;
|
|
1168
|
-
for (let mi = 0; mi < messages.length; mi++) {
|
|
1169
|
-
// In-bounds by loop guard.
|
|
1170
|
-
const m = messages[mi]!;
|
|
1171
|
-
if (m.lineNumber < note.lineNumber && m.lineNumber > closestLine) {
|
|
1172
|
-
closestLine = m.lineNumber;
|
|
1173
|
-
closestMsgIndex = mi;
|
|
1174
|
-
}
|
|
1175
|
-
}
|
|
1176
|
-
if (closestMsgIndex < 0) return false;
|
|
1177
|
-
// In-bounds: closestMsgIndex was set from a valid `mi` above.
|
|
1178
|
-
const msg = messages[closestMsgIndex]!;
|
|
1179
|
-
return msg.from === msg.to;
|
|
1180
|
-
};
|
|
1181
|
-
|
|
1182
|
-
// Extra gap below self-call loop before note starts
|
|
1183
|
-
const SELF_CALL_NOTE_GAP = ctx.structural(8);
|
|
1184
|
-
const noteOffsetBelow = (note: SequenceNote): number =>
|
|
1185
|
-
isNoteAfterSelfCall(note)
|
|
1186
|
-
? sSelfCallHeight + NOTE_OFFSET_BELOW + SELF_CALL_NOTE_GAP
|
|
1187
|
-
: NOTE_OFFSET_BELOW;
|
|
1188
|
-
|
|
1189
|
-
// Find the first visible message index in an element subtree.
|
|
1190
|
-
// Use lineNumber lookup instead of indexOf — collapse projection creates
|
|
1191
|
-
// separate spread copies for messages[] and elements[], breaking reference equality.
|
|
1192
|
-
const msgLineToIdx = new Map<number, number>();
|
|
1193
|
-
messages.forEach((m, i) => msgLineToIdx.set(m.lineNumber, i));
|
|
1194
|
-
|
|
1195
|
-
const findFirstMsgIndex = (els: readonly SequenceElement[]): number => {
|
|
1196
|
-
for (const el of els) {
|
|
1197
|
-
if (isSequenceBlock(el)) {
|
|
1198
|
-
const idx = findFirstMsgIndex(el.children);
|
|
1199
|
-
if (idx >= 0) return idx;
|
|
1200
|
-
if (el.elseIfBranches) {
|
|
1201
|
-
for (const branch of el.elseIfBranches) {
|
|
1202
|
-
const branchIdx = findFirstMsgIndex(branch.children);
|
|
1203
|
-
if (branchIdx >= 0) return branchIdx;
|
|
1204
|
-
}
|
|
1205
|
-
}
|
|
1206
|
-
const elseIdx = findFirstMsgIndex(el.elseChildren);
|
|
1207
|
-
if (elseIdx >= 0) return elseIdx;
|
|
1208
|
-
} else if (!isSequenceSection(el) && !isSequenceNote(el)) {
|
|
1209
|
-
const idx = msgLineToIdx.get(el.lineNumber) ?? -1;
|
|
1210
|
-
if (idx >= 0 && !hiddenMsgIndices.has(idx)) return idx;
|
|
1211
|
-
}
|
|
1212
|
-
}
|
|
1213
|
-
return -1;
|
|
1214
|
-
};
|
|
1215
|
-
|
|
1216
|
-
// Section layout constants
|
|
1217
|
-
const SECTION_TOP_PAD = 35;
|
|
1218
|
-
const SECTION_BOTTOM_PAD = 45;
|
|
1219
|
-
|
|
1220
|
-
// Block spacing via extraBeforeMsg (sections handled separately below)
|
|
1221
|
-
const extraBeforeMsg = new Map<number, number>();
|
|
1222
|
-
const addExtra = (msgIdx: number, amount: number) => {
|
|
1223
|
-
extraBeforeMsg.set(msgIdx, (extraBeforeMsg.get(msgIdx) || 0) + amount);
|
|
1224
|
-
};
|
|
1225
|
-
|
|
1226
|
-
const markBlockSpacing = (els: readonly SequenceElement[]): void => {
|
|
1227
|
-
for (let i = 0; i < els.length; i++) {
|
|
1228
|
-
// In-bounds by loop guard.
|
|
1229
|
-
const el = els[i]!;
|
|
1230
|
-
if (isSequenceSection(el)) continue; // sections handled separately
|
|
1231
|
-
if (!isSequenceBlock(el)) continue;
|
|
1232
|
-
|
|
1233
|
-
const firstIdx = findFirstMsgIndex(el.children);
|
|
1234
|
-
if (firstIdx >= 0) addExtra(firstIdx, BLOCK_HEADER_SPACE);
|
|
1235
|
-
|
|
1236
|
-
if (el.elseIfBranches) {
|
|
1237
|
-
for (const branch of el.elseIfBranches) {
|
|
1238
|
-
const firstBranchIdx = findFirstMsgIndex(branch.children);
|
|
1239
|
-
if (firstBranchIdx >= 0) addExtra(firstBranchIdx, BLOCK_HEADER_SPACE);
|
|
1240
|
-
markBlockSpacing(branch.children);
|
|
1241
|
-
}
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
const firstElseIdx = findFirstMsgIndex(el.elseChildren);
|
|
1245
|
-
if (firstElseIdx >= 0) addExtra(firstElseIdx, BLOCK_HEADER_SPACE);
|
|
1246
|
-
|
|
1247
|
-
markBlockSpacing(el.children);
|
|
1248
|
-
markBlockSpacing(el.elseChildren);
|
|
1249
|
-
|
|
1250
|
-
if (i + 1 < els.length) {
|
|
1251
|
-
// In-bounds: i + 1 < els.length guard.
|
|
1252
|
-
const nextIdx = findFirstMsgIndex([els[i + 1]!]);
|
|
1253
|
-
if (nextIdx >= 0) addExtra(nextIdx, BLOCK_AFTER_SPACE);
|
|
1254
|
-
}
|
|
1255
|
-
}
|
|
1256
|
-
};
|
|
1257
|
-
|
|
1258
|
-
if (elements && elements.length > 0) {
|
|
1259
|
-
markBlockSpacing(elements);
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
// Note spacing — add vertical room after messages that have notes attached
|
|
1263
|
-
const NOTE_OFFSET_BELOW = ctx.structural(14);
|
|
1264
|
-
// The next message label extends ~17px above its arrow line (8px offset + 9px cap height).
|
|
1265
|
-
// When notes share horizontal space with subsequent arrows, generous vertical clearance
|
|
1266
|
-
// is needed so note boxes don't visually cover message labels.
|
|
1267
|
-
const NOTE_TRAILING_GAP = ctx.aesthetic(35);
|
|
1268
|
-
const computeNoteHeight = (
|
|
1269
|
-
text: string,
|
|
1270
|
-
textWidth: number = sNoteTextWidthMax
|
|
1271
|
-
): number => {
|
|
1272
|
-
const lines = wrapTextLines(text, textWidth, sNoteFontSize);
|
|
1273
|
-
return lines.length * sNoteLineH + sNotePadV * 2;
|
|
1274
|
-
};
|
|
1275
|
-
let trailingNoteSpace = 0; // extra space for notes at the end with no following message
|
|
1276
|
-
const markNoteSpacing = (els: readonly SequenceElement[]): void => {
|
|
1277
|
-
for (let i = 0; i < els.length; i++) {
|
|
1278
|
-
// In-bounds by loop guard.
|
|
1279
|
-
const el = els[i]!;
|
|
1280
|
-
if (isSequenceNote(el)) {
|
|
1281
|
-
// Total vertical extent of notes from the message arrow:
|
|
1282
|
-
// offset (gap above first note — larger after self-calls)
|
|
1283
|
-
// + each note's height + NOTE_OFFSET_BELOW (inter-note gap)
|
|
1284
|
-
// + NOTE_TRAILING_GAP (gap below last note — clears next message label)
|
|
1285
|
-
const firstOffset = noteOffsetBelow(el as SequenceNote);
|
|
1286
|
-
let totalExtent = firstOffset;
|
|
1287
|
-
let j = i;
|
|
1288
|
-
// In-bounds: j < els.length guard inside the while.
|
|
1289
|
-
while (j < els.length && isSequenceNote(els[j]!)) {
|
|
1290
|
-
// In-bounds: same guard as while.
|
|
1291
|
-
const note = els[j]! as SequenceNote;
|
|
1292
|
-
const sc = isNoteAfterSelfCall(note);
|
|
1293
|
-
const maxW = noteEffectiveMaxW(note.participantId, note.position, sc);
|
|
1294
|
-
const noteH = computeNoteHeight(note.text, noteTextWidth(maxW));
|
|
1295
|
-
totalExtent += noteH + NOTE_OFFSET_BELOW;
|
|
1296
|
-
j++;
|
|
1297
|
-
}
|
|
1298
|
-
// Replace the final inter-note gap with a proportional trailing gap
|
|
1299
|
-
// so tall/stacked notes get extra clearance while short notes keep baseline
|
|
1300
|
-
const trailingGap = Math.max(NOTE_TRAILING_GAP, totalExtent * 0.3);
|
|
1301
|
-
totalExtent += trailingGap - NOTE_OFFSET_BELOW;
|
|
1302
|
-
// Only reserve space beyond the existing stepSpacing gap
|
|
1303
|
-
let extraNeeded = Math.max(0, totalExtent - stepSpacing);
|
|
1304
|
-
// Scan forward past sections, blocks, and other non-message elements to find next message
|
|
1305
|
-
let nextMsgIdx = -1;
|
|
1306
|
-
for (let k = j; k < els.length; k++) {
|
|
1307
|
-
// In-bounds by loop guard.
|
|
1308
|
-
nextMsgIdx = findFirstMsgIndex([els[k]!]);
|
|
1309
|
-
if (nextMsgIdx >= 0) break;
|
|
1310
|
-
}
|
|
1311
|
-
// If a block follows, its frame extends FRAME_PADDING_TOP above the first
|
|
1312
|
-
// message but only BLOCK_HEADER_SPACE is reserved. Add the difference so
|
|
1313
|
-
// the note doesn't overlap the frame.
|
|
1314
|
-
// In-bounds: j < els.length guard.
|
|
1315
|
-
if (j < els.length && isSequenceBlock(els[j]!)) {
|
|
1316
|
-
extraNeeded += FRAME_PADDING_TOP - BLOCK_HEADER_SPACE;
|
|
1317
|
-
}
|
|
1318
|
-
if (nextMsgIdx >= 0) {
|
|
1319
|
-
addExtra(nextMsgIdx, extraNeeded);
|
|
1320
|
-
} else {
|
|
1321
|
-
// Notes at the end — reserve only the excess beyond stepSpacing
|
|
1322
|
-
trailingNoteSpace = Math.max(trailingNoteSpace, extraNeeded);
|
|
1323
|
-
}
|
|
1324
|
-
// Skip over the consecutive notes we just processed
|
|
1325
|
-
i = j - 1;
|
|
1326
|
-
} else if (isSequenceBlock(el)) {
|
|
1327
|
-
markNoteSpacing(el.children);
|
|
1328
|
-
if (el.elseIfBranches) {
|
|
1329
|
-
for (const branch of el.elseIfBranches) {
|
|
1330
|
-
markNoteSpacing(branch.children);
|
|
1331
|
-
}
|
|
1332
|
-
}
|
|
1333
|
-
markNoteSpacing(el.elseChildren);
|
|
1334
|
-
}
|
|
1335
|
-
}
|
|
1336
|
-
};
|
|
1337
|
-
if (elements && elements.length > 0) {
|
|
1338
|
-
markNoteSpacing(elements);
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
|
-
// --- Section-aware Y layout ---
|
|
1342
|
-
// Sections get their own Y positions computed from content above them (not anchored
|
|
1343
|
-
// to messages below). This ensures toggling collapse/expand doesn't move the divider.
|
|
1344
|
-
|
|
1345
|
-
// Walk top-level elements to build section regions
|
|
1346
|
-
interface SectionRegion {
|
|
1347
|
-
section: import('./parser').SequenceSection;
|
|
1348
|
-
msgIndices: number[]; // message indices belonging to this section
|
|
1349
|
-
}
|
|
1350
|
-
const preSectionMsgIndices: number[] = [];
|
|
1351
|
-
const sectionRegions: SectionRegion[] = [];
|
|
1352
|
-
{
|
|
1353
|
-
// Build lineNumber → message index lookup. This is used instead of
|
|
1354
|
-
// messages.indexOf() because collapse projection creates spread copies
|
|
1355
|
-
// of messages, breaking reference equality.
|
|
1356
|
-
const msgLineToIndex = new Map<number, number>();
|
|
1357
|
-
messages.forEach((m, i) => msgLineToIndex.set(m.lineNumber, i));
|
|
1358
|
-
|
|
1359
|
-
const findMsgIndex = (child: SequenceElement): number =>
|
|
1360
|
-
msgLineToIndex.get(child.lineNumber) ?? -1;
|
|
1361
|
-
|
|
1362
|
-
const collectMsgIndicesFromBlock = (
|
|
1363
|
-
block: import('./parser').SequenceBlock
|
|
1364
|
-
): number[] => {
|
|
1365
|
-
const indices: number[] = [];
|
|
1366
|
-
for (const child of block.children) {
|
|
1367
|
-
if (isSequenceBlock(child)) {
|
|
1368
|
-
indices.push(...collectMsgIndicesFromBlock(child));
|
|
1369
|
-
} else if (!isSequenceSection(child) && !isSequenceNote(child)) {
|
|
1370
|
-
const idx = findMsgIndex(child);
|
|
1371
|
-
if (idx >= 0) indices.push(idx);
|
|
1372
|
-
}
|
|
1373
|
-
}
|
|
1374
|
-
if (block.elseIfBranches) {
|
|
1375
|
-
for (const branch of block.elseIfBranches) {
|
|
1376
|
-
for (const child of branch.children) {
|
|
1377
|
-
if (isSequenceBlock(child)) {
|
|
1378
|
-
indices.push(...collectMsgIndicesFromBlock(child));
|
|
1379
|
-
} else if (!isSequenceSection(child) && !isSequenceNote(child)) {
|
|
1380
|
-
const idx = findMsgIndex(child);
|
|
1381
|
-
if (idx >= 0) indices.push(idx);
|
|
1382
|
-
}
|
|
1383
|
-
}
|
|
1384
|
-
}
|
|
1385
|
-
}
|
|
1386
|
-
for (const child of block.elseChildren) {
|
|
1387
|
-
if (isSequenceBlock(child)) {
|
|
1388
|
-
indices.push(...collectMsgIndicesFromBlock(child));
|
|
1389
|
-
} else if (!isSequenceSection(child) && !isSequenceNote(child)) {
|
|
1390
|
-
const idx = findMsgIndex(child);
|
|
1391
|
-
if (idx >= 0) indices.push(idx);
|
|
1392
|
-
}
|
|
1393
|
-
}
|
|
1394
|
-
return indices;
|
|
1395
|
-
};
|
|
1396
|
-
|
|
1397
|
-
let currentTarget = preSectionMsgIndices;
|
|
1398
|
-
for (const el of elements) {
|
|
1399
|
-
if (isSequenceSection(el)) {
|
|
1400
|
-
const region: SectionRegion = { section: el, msgIndices: [] };
|
|
1401
|
-
sectionRegions.push(region);
|
|
1402
|
-
currentTarget = region.msgIndices;
|
|
1403
|
-
} else if (isSequenceBlock(el)) {
|
|
1404
|
-
currentTarget.push(...collectMsgIndicesFromBlock(el));
|
|
1405
|
-
} else {
|
|
1406
|
-
const idx = findMsgIndex(el);
|
|
1407
|
-
if (idx >= 0) currentTarget.push(idx);
|
|
1408
|
-
}
|
|
1409
|
-
}
|
|
1410
|
-
}
|
|
1411
|
-
|
|
1412
|
-
// Build mapping from original (all) render step index → filtered step index
|
|
1413
|
-
const allMsgToFirstStep = new Map<number, number>();
|
|
1414
|
-
allRenderSteps.forEach((step, si) => {
|
|
1415
|
-
if (!allMsgToFirstStep.has(step.messageIndex)) {
|
|
1416
|
-
allMsgToFirstStep.set(step.messageIndex, si);
|
|
1417
|
-
}
|
|
1418
|
-
});
|
|
1419
|
-
|
|
1420
|
-
const originalToFiltered = new Map<number, number>();
|
|
1421
|
-
{
|
|
1422
|
-
let fi = 0;
|
|
1423
|
-
for (let oi = 0; oi < allRenderSteps.length; oi++) {
|
|
1424
|
-
// In-bounds by loop guard.
|
|
1425
|
-
const step = allRenderSteps[oi]!;
|
|
1426
|
-
if (
|
|
1427
|
-
!hiddenMsgIndices.has(step.messageIndex) &&
|
|
1428
|
-
(step.type === 'call' || step.label)
|
|
1429
|
-
) {
|
|
1430
|
-
originalToFiltered.set(oi, fi);
|
|
1431
|
-
fi++;
|
|
1432
|
-
}
|
|
1433
|
-
}
|
|
1434
|
-
}
|
|
1435
|
-
|
|
1436
|
-
// For each section, find the filtered step index where its padding should be inserted
|
|
1437
|
-
const findFilteredInsertionPoint = (origStep: number): number | null => {
|
|
1438
|
-
for (let i = origStep; i < allRenderSteps.length; i++) {
|
|
1439
|
-
const fi = originalToFiltered.get(i);
|
|
1440
|
-
if (fi !== undefined) return fi;
|
|
1441
|
-
}
|
|
1442
|
-
return null;
|
|
1443
|
-
};
|
|
1444
|
-
|
|
1445
|
-
// Map: filtered step index → sections to insert before it (in document order)
|
|
1446
|
-
const sectionsBeforeStep = new Map<
|
|
1447
|
-
number,
|
|
1448
|
-
import('./parser').SequenceSection[]
|
|
1449
|
-
>();
|
|
1450
|
-
const trailingSections: import('./parser').SequenceSection[] = [];
|
|
1451
|
-
|
|
1452
|
-
for (const region of sectionRegions) {
|
|
1453
|
-
if (region.msgIndices.length === 0) {
|
|
1454
|
-
trailingSections.push(region.section);
|
|
1455
|
-
continue;
|
|
1456
|
-
}
|
|
1457
|
-
// In-bounds: msgIndices.length === 0 guard above.
|
|
1458
|
-
const firstMsgIdx = region.msgIndices[0]!;
|
|
1459
|
-
const origStep = allMsgToFirstStep.get(firstMsgIdx);
|
|
1460
|
-
if (origStep === undefined) {
|
|
1461
|
-
trailingSections.push(region.section);
|
|
1462
|
-
continue;
|
|
1463
|
-
}
|
|
1464
|
-
const filteredStep = findFilteredInsertionPoint(origStep);
|
|
1465
|
-
if (filteredStep === null) {
|
|
1466
|
-
trailingSections.push(region.section);
|
|
1467
|
-
continue;
|
|
1468
|
-
}
|
|
1469
|
-
const existing = sectionsBeforeStep.get(filteredStep) || [];
|
|
1470
|
-
existing.push(region.section);
|
|
1471
|
-
sectionsBeforeStep.set(filteredStep, existing);
|
|
1472
|
-
}
|
|
1473
|
-
|
|
1474
|
-
// Section message counts for collapsed labels
|
|
1475
|
-
const sectionMsgCounts = new Map<number, number>();
|
|
1476
|
-
for (const region of sectionRegions) {
|
|
1477
|
-
sectionMsgCounts.set(region.section.lineNumber, region.msgIndices.length);
|
|
1478
|
-
}
|
|
1479
|
-
|
|
1480
|
-
// Group box layout constants (needed early for Y offset)
|
|
1481
|
-
const GROUP_PADDING_X = 15;
|
|
1482
|
-
const GROUP_PADDING_TOP = 22;
|
|
1483
|
-
const GROUP_PADDING_BOTTOM = 8;
|
|
1484
|
-
const GROUP_LABEL_SIZE = 11;
|
|
1485
|
-
|
|
1486
|
-
// Compute cumulative Y positions for each step, with section dividers as stable anchors
|
|
1487
|
-
const showTitle = !!title && parsedOptions['no-title'] !== 'on';
|
|
1488
|
-
const titleOffset = showTitle ? sTitleHeight : 0;
|
|
1489
|
-
const LEGEND_FIXED_GAP = 8;
|
|
1490
|
-
const resolvedGroups = parsed.tagGroups
|
|
1491
|
-
.filter((tg) => tg.entries.length > 0)
|
|
1492
|
-
.map((tg) => ({
|
|
1493
|
-
name: tg.name,
|
|
1494
|
-
entries: tg.entries.map((e) => ({ value: e.value, color: e.color })),
|
|
1495
|
-
}));
|
|
1496
|
-
const legendConfig: LegendConfig = {
|
|
1497
|
-
groups: resolvedGroups,
|
|
1498
|
-
position: { placement: 'top-center', titleRelation: 'below-title' },
|
|
1499
|
-
mode: 'preview',
|
|
1500
|
-
};
|
|
1501
|
-
const legendTopSpace =
|
|
1502
|
-
parsed.tagGroups.length > 0
|
|
1503
|
-
? getMaxLegendReservedHeight(legendConfig, containerWidth) +
|
|
1504
|
-
LEGEND_FIXED_GAP
|
|
1505
|
-
: 0;
|
|
1506
|
-
// Use parsed.groups (not projected groups) to keep vertical space consistent
|
|
1507
|
-
// even when all groups are collapsed into virtual participants
|
|
1508
|
-
const groupOffset =
|
|
1509
|
-
parsed.groups.length > 0 ? GROUP_PADDING_TOP + GROUP_LABEL_SIZE : 0;
|
|
1510
|
-
const participantStartY =
|
|
1511
|
-
sTopMargin +
|
|
1512
|
-
titleOffset +
|
|
1513
|
-
legendTopSpace +
|
|
1514
|
-
sParticipantYOffset +
|
|
1515
|
-
groupOffset;
|
|
1516
|
-
const lifelineStartY0 = participantStartY + sBoxH;
|
|
1517
|
-
const hasActors = participants.some((p) => p.type === 'actor');
|
|
1518
|
-
const messageStartOffset =
|
|
1519
|
-
sMsgStartOffset +
|
|
1520
|
-
(hasActors ? 20 : 0) +
|
|
1521
|
-
(parsed.groups.length > 0 ? GROUP_PADDING_BOTTOM : 0);
|
|
1522
|
-
const stepYPositions: number[] = [];
|
|
1523
|
-
const sectionYPositions = new Map<number, number>(); // section lineNumber → Y
|
|
1524
|
-
let layoutEndY: number; // final Y after all steps and trailing sections
|
|
1525
|
-
{
|
|
1526
|
-
let curY = lifelineStartY0 + messageStartOffset;
|
|
1527
|
-
for (let i = 0; i < renderSteps.length; i++) {
|
|
1528
|
-
// Insert section padding before this step if needed
|
|
1529
|
-
const sections = sectionsBeforeStep.get(i);
|
|
1530
|
-
if (sections) {
|
|
1531
|
-
for (const sec of sections) {
|
|
1532
|
-
curY += SECTION_TOP_PAD;
|
|
1533
|
-
sectionYPositions.set(sec.lineNumber, curY);
|
|
1534
|
-
curY += SECTION_BOTTOM_PAD;
|
|
1535
|
-
}
|
|
1536
|
-
}
|
|
1537
|
-
|
|
1538
|
-
// In-bounds by loop guard.
|
|
1539
|
-
const step = renderSteps[i]!;
|
|
1540
|
-
// Add extra spacing before the first render step of a flagged message (block spacing)
|
|
1541
|
-
if (msgToFirstStep.get(step.messageIndex) === i) {
|
|
1542
|
-
const extra = extraBeforeMsg.get(step.messageIndex) || 0;
|
|
1543
|
-
curY += extra;
|
|
1544
|
-
}
|
|
1545
|
-
stepYPositions.push(curY);
|
|
1546
|
-
const isSelfCall = step.type === 'call' && step.from === step.to;
|
|
1547
|
-
curY += isSelfCall ? sSelfCallHeight + 25 : stepSpacing;
|
|
1548
|
-
}
|
|
1549
|
-
// Handle trailing sections (after all steps)
|
|
1550
|
-
for (const sec of trailingSections) {
|
|
1551
|
-
curY += SECTION_TOP_PAD;
|
|
1552
|
-
sectionYPositions.set(sec.lineNumber, curY);
|
|
1553
|
-
curY += SECTION_BOTTOM_PAD;
|
|
1554
|
-
}
|
|
1555
|
-
// Extend for trailing notes that have no following message
|
|
1556
|
-
curY += trailingNoteSpace;
|
|
1557
|
-
layoutEndY = curY;
|
|
1558
|
-
}
|
|
1559
|
-
|
|
1560
|
-
// Helper: compute Y for a step index
|
|
1561
|
-
// Callers always pass a valid filtered step index from renderSteps (which is
|
|
1562
|
-
// 1:1 with stepYPositions by construction in the layout loop above).
|
|
1563
|
-
const stepY = (i: number) => stepYPositions[i]!;
|
|
1564
|
-
|
|
1565
|
-
// Compute absolute Y positions for each note element
|
|
1566
|
-
const noteYMap = new Map<SequenceNote, number>();
|
|
1567
|
-
{
|
|
1568
|
-
const computeNotePositions = (els: readonly SequenceElement[]): void => {
|
|
1569
|
-
for (let i = 0; i < els.length; i++) {
|
|
1570
|
-
// In-bounds by loop guard.
|
|
1571
|
-
const el = els[i]!;
|
|
1572
|
-
if (isSequenceNote(el)) {
|
|
1573
|
-
const si = findAssociatedLastStep(el);
|
|
1574
|
-
if (si < 0) continue;
|
|
1575
|
-
// Check if there's a preceding note that we should stack below
|
|
1576
|
-
// In-bounds: i > 0 guard.
|
|
1577
|
-
const prevNote =
|
|
1578
|
-
i > 0 && isSequenceNote(els[i - 1]!)
|
|
1579
|
-
? (els[i - 1]! as SequenceNote)
|
|
1580
|
-
: null;
|
|
1581
|
-
const prevNoteY = prevNote ? noteYMap.get(prevNote) : undefined;
|
|
1582
|
-
let noteTopY: number;
|
|
1583
|
-
if (prevNoteY !== undefined && prevNote) {
|
|
1584
|
-
// Stack below previous note
|
|
1585
|
-
const prevMaxW = noteEffectiveMaxW(
|
|
1586
|
-
prevNote.participantId,
|
|
1587
|
-
effectiveNotePosition(prevNote),
|
|
1588
|
-
isNoteAfterSelfCall(prevNote)
|
|
1589
|
-
);
|
|
1590
|
-
const prevNoteH = computeNoteHeight(
|
|
1591
|
-
prevNote.text,
|
|
1592
|
-
noteTextWidth(prevMaxW)
|
|
1593
|
-
);
|
|
1594
|
-
noteTopY = prevNoteY + prevNoteH + NOTE_OFFSET_BELOW;
|
|
1595
|
-
} else {
|
|
1596
|
-
// First note after a message — use larger offset after self-calls
|
|
1597
|
-
noteTopY = stepY(si) + noteOffsetBelow(el);
|
|
1598
|
-
}
|
|
1599
|
-
noteYMap.set(el, noteTopY);
|
|
1600
|
-
} else if (isSequenceBlock(el)) {
|
|
1601
|
-
computeNotePositions(el.children);
|
|
1602
|
-
if (el.elseIfBranches) {
|
|
1603
|
-
for (const branch of el.elseIfBranches) {
|
|
1604
|
-
computeNotePositions(branch.children);
|
|
1605
|
-
}
|
|
1606
|
-
}
|
|
1607
|
-
computeNotePositions(el.elseChildren);
|
|
1608
|
-
}
|
|
1609
|
-
}
|
|
1610
|
-
};
|
|
1611
|
-
if (elements && elements.length > 0) {
|
|
1612
|
-
computeNotePositions(elements);
|
|
1613
|
-
}
|
|
1614
|
-
}
|
|
1615
|
-
|
|
1616
|
-
// Ensure contentBottomY accounts for all note extents
|
|
1617
|
-
const lastStep = renderSteps[renderSteps.length - 1];
|
|
1618
|
-
const lastIsSelfCall =
|
|
1619
|
-
lastStep?.type === 'call' && lastStep.from === lastStep.to;
|
|
1620
|
-
const lastStepTrailing = lastIsSelfCall ? sSelfCallHeight + 25 : stepSpacing;
|
|
1621
|
-
let contentBottomY =
|
|
1622
|
-
renderSteps.length > 0
|
|
1623
|
-
? Math.max(
|
|
1624
|
-
// In-bounds: renderSteps.length > 0 guarantees stepYPositions is
|
|
1625
|
-
// non-empty (1:1 with renderSteps in the layout loop above).
|
|
1626
|
-
stepYPositions[stepYPositions.length - 1]! + lastStepTrailing,
|
|
1627
|
-
layoutEndY
|
|
1628
|
-
)
|
|
1629
|
-
: layoutEndY;
|
|
1630
|
-
for (const [note, noteTopY] of noteYMap) {
|
|
1631
|
-
const maxW = noteEffectiveMaxW(
|
|
1632
|
-
note.participantId,
|
|
1633
|
-
effectiveNotePosition(note),
|
|
1634
|
-
isNoteAfterSelfCall(note)
|
|
1635
|
-
);
|
|
1636
|
-
const noteH = computeNoteHeight(note.text, noteTextWidth(maxW));
|
|
1637
|
-
contentBottomY = Math.max(
|
|
1638
|
-
contentBottomY,
|
|
1639
|
-
noteTopY + noteH + NOTE_TRAILING_GAP
|
|
1640
|
-
);
|
|
1641
|
-
}
|
|
1642
|
-
const messageAreaHeight = contentBottomY - lifelineStartY0;
|
|
1643
|
-
const lifelineLength = messageAreaHeight + sLifelineTail;
|
|
1644
|
-
// Redistribute gap: tighter within groups, wider between groups.
|
|
1645
|
-
// Total width stays exactly participants.length * sGap — no viewBox change.
|
|
1646
|
-
const totalGaps = participants.length > 1 ? participants.length - 1 : 0;
|
|
1647
|
-
const numWithinGaps = totalGaps - numGroupGaps;
|
|
1648
|
-
let sWithinGap = sGap;
|
|
1649
|
-
let sBetweenGap = sGap;
|
|
1650
|
-
if (numGroupGaps > 0 && totalGaps > 0) {
|
|
1651
|
-
sWithinGap = sGap * 0.88;
|
|
1652
|
-
sBetweenGap =
|
|
1653
|
-
(totalGaps * sGap - numWithinGaps * sWithinGap) / numGroupGaps;
|
|
1654
|
-
}
|
|
1655
|
-
|
|
1656
|
-
// Compute right-edge projection: how far content extends past the rightmost lifeline
|
|
1657
|
-
const rightmostId = participants[participants.length - 1]?.id;
|
|
1658
|
-
let rightProjection = sBoxW / 2;
|
|
1659
|
-
if (rightmostId) {
|
|
1660
|
-
// Group padding if rightmost participant is in a group
|
|
1661
|
-
if (
|
|
1662
|
-
parsed.groups.some((g: SequenceGroup) =>
|
|
1663
|
-
g.participantIds.includes(rightmostId)
|
|
1664
|
-
)
|
|
1665
|
-
)
|
|
1666
|
-
rightProjection = Math.max(rightProjection, sBoxW / 2 + GROUP_PADDING_X);
|
|
1667
|
-
// Self-calls on rightmost: loop + label projection (+ activation nesting buffer)
|
|
1668
|
-
for (const step of renderSteps) {
|
|
1669
|
-
if (step.from === step.to && step.from === rightmostId) {
|
|
1670
|
-
const selfProj = sActivationWidth + sSelfCallWidth;
|
|
1671
|
-
let labelProj = 0;
|
|
1672
|
-
// Self-call labels render at the fixed 12px message-label size.
|
|
1673
|
-
if (step.label) labelProj = measureText(step.label, 12) + 15;
|
|
1674
|
-
rightProjection = Math.max(rightProjection, selfProj + labelProj);
|
|
1675
|
-
}
|
|
1676
|
-
}
|
|
1677
|
-
// Block frames spanning rightmost participant (must match FRAME_PADDING_X = 30 in block rendering below)
|
|
1678
|
-
const blockFramePadX = 30;
|
|
1679
|
-
const hasBlockWithRightmost = (
|
|
1680
|
-
els: readonly SequenceElement[]
|
|
1681
|
-
): boolean => {
|
|
1682
|
-
for (const el of els) {
|
|
1683
|
-
if (isSequenceBlock(el)) {
|
|
1684
|
-
const involvesRightmost = (
|
|
1685
|
-
children: readonly SequenceElement[]
|
|
1686
|
-
): boolean =>
|
|
1687
|
-
children.some(
|
|
1688
|
-
(c) =>
|
|
1689
|
-
!isSequenceBlock(c) &&
|
|
1690
|
-
!isSequenceNote(c) &&
|
|
1691
|
-
!isSequenceSection(c) &&
|
|
1692
|
-
'from' in c &&
|
|
1693
|
-
(c.from === rightmostId || c.to === rightmostId)
|
|
1694
|
-
);
|
|
1695
|
-
if (
|
|
1696
|
-
involvesRightmost(el.children) ||
|
|
1697
|
-
involvesRightmost(el.elseChildren) ||
|
|
1698
|
-
el.elseIfBranches?.some((b) => involvesRightmost(b.children))
|
|
1699
|
-
)
|
|
1700
|
-
return true;
|
|
1701
|
-
if (
|
|
1702
|
-
hasBlockWithRightmost(el.children) ||
|
|
1703
|
-
hasBlockWithRightmost(el.elseChildren) ||
|
|
1704
|
-
el.elseIfBranches?.some((b) => hasBlockWithRightmost(b.children))
|
|
1705
|
-
)
|
|
1706
|
-
return true;
|
|
1707
|
-
}
|
|
1708
|
-
}
|
|
1709
|
-
return false;
|
|
1710
|
-
};
|
|
1711
|
-
if (elements && hasBlockWithRightmost(elements))
|
|
1712
|
-
rightProjection = Math.max(rightProjection, sBoxW / 2 + blockFramePadX);
|
|
1713
|
-
}
|
|
1714
|
-
let rightMargin = Math.max(rightProjection + 10, sGap / 2);
|
|
1715
|
-
|
|
1716
|
-
let leftMargin = sGap / 2;
|
|
1717
|
-
const diagramWidth = participants.length * sGap;
|
|
1718
|
-
let totalWidth = Math.max(
|
|
1719
|
-
leftMargin + diagramWidth + rightMargin,
|
|
1720
|
-
sBoxW + 40
|
|
1721
|
-
);
|
|
1722
|
-
const contentHeight =
|
|
1723
|
-
participantStartY + sBoxH + Math.max(lifelineLength, 40) + 40;
|
|
1724
|
-
const totalHeight = contentHeight;
|
|
1725
|
-
|
|
1726
|
-
let svgWidth = Math.max(totalWidth, containerWidth);
|
|
1727
|
-
|
|
1728
|
-
let offsetX = leftMargin + Math.max(0, (svgWidth - totalWidth) / 2);
|
|
1729
|
-
|
|
1730
|
-
// Build participant x-position lookup with redistributed gaps
|
|
1731
|
-
const participantX = new Map<string, number>();
|
|
1732
|
-
const buildParticipantX = (): void => {
|
|
1733
|
-
let px = offsetX;
|
|
1734
|
-
participantX.clear();
|
|
1735
|
-
participants.forEach((p, i) => {
|
|
1736
|
-
participantX.set(p.id, px);
|
|
1737
|
-
if (i < participants.length - 1) {
|
|
1738
|
-
const nextId = participants[i + 1]!.id;
|
|
1739
|
-
px += groupBoundaryIds.has(nextId) ? sBetweenGap : sWithinGap;
|
|
1740
|
-
}
|
|
1741
|
-
});
|
|
1742
|
-
};
|
|
1743
|
-
buildParticipantX();
|
|
1744
|
-
|
|
1745
|
-
// Post-layout content scan: detect labels/notes that overflow the SVG boundaries.
|
|
1746
|
-
// Message labels render at a fixed 12px font (unscaled) so they can extend past
|
|
1747
|
-
// the scaled participant grid at small scale factors.
|
|
1748
|
-
let contentLeft = 0;
|
|
1749
|
-
let contentRight = svgWidth;
|
|
1750
|
-
for (const step of renderSteps) {
|
|
1751
|
-
if (!step.label) continue;
|
|
1752
|
-
const labelW = measureText(step.label, 12);
|
|
1753
|
-
if (step.from === step.to) {
|
|
1754
|
-
const px = participantX.get(step.from);
|
|
1755
|
-
if (px !== undefined) {
|
|
1756
|
-
const loopRight =
|
|
1757
|
-
px + sActivationWidth / 2 + sSelfCallWidth + 5 + labelW;
|
|
1758
|
-
contentRight = Math.max(contentRight, loopRight);
|
|
1759
|
-
}
|
|
1760
|
-
} else {
|
|
1761
|
-
const fromX = participantX.get(step.from);
|
|
1762
|
-
const toX = participantX.get(step.to);
|
|
1763
|
-
if (fromX !== undefined && toX !== undefined) {
|
|
1764
|
-
const midX = (fromX + toX) / 2;
|
|
1765
|
-
contentLeft = Math.min(contentLeft, midX - labelW / 2);
|
|
1766
|
-
contentRight = Math.max(contentRight, midX + labelW / 2);
|
|
1767
|
-
}
|
|
1768
|
-
}
|
|
1769
|
-
}
|
|
1770
|
-
// Scan right-positioned notes for overflow past the right edge
|
|
1771
|
-
if (elements) {
|
|
1772
|
-
const scanNotes = (els: readonly SequenceElement[]): void => {
|
|
1773
|
-
for (const el of els) {
|
|
1774
|
-
if (isSequenceNote(el)) {
|
|
1775
|
-
const note = el as SequenceNote;
|
|
1776
|
-
const pos = effectiveNotePosition(note);
|
|
1777
|
-
if (pos === 'right') {
|
|
1778
|
-
const px = participantX.get(note.participantId);
|
|
1779
|
-
if (px !== undefined) {
|
|
1780
|
-
const sc = isNoteAfterSelfCall(note);
|
|
1781
|
-
const rOff = sc
|
|
1782
|
-
? sActivationWidth / 2 + sSelfCallWidth + sNoteGap
|
|
1783
|
-
: sActivationWidth + sNoteGap;
|
|
1784
|
-
const maxW = noteEffectiveMaxW(note.participantId, pos, sc);
|
|
1785
|
-
contentRight = Math.max(contentRight, px + rOff + maxW);
|
|
1786
|
-
}
|
|
1787
|
-
}
|
|
1788
|
-
} else if (isSequenceBlock(el)) {
|
|
1789
|
-
scanNotes(el.children);
|
|
1790
|
-
scanNotes(el.elseChildren);
|
|
1791
|
-
if (el.elseIfBranches) {
|
|
1792
|
-
for (const b of el.elseIfBranches) scanNotes(b.children);
|
|
1793
|
-
}
|
|
1794
|
-
}
|
|
1795
|
-
}
|
|
1796
|
-
};
|
|
1797
|
-
scanNotes(elements);
|
|
1798
|
-
}
|
|
1799
|
-
const neededLeftPad = Math.max(0, -contentLeft);
|
|
1800
|
-
const neededRightPad = Math.max(0, contentRight - svgWidth);
|
|
1801
|
-
if (neededLeftPad > 0 || neededRightPad > 0) {
|
|
1802
|
-
leftMargin += neededLeftPad;
|
|
1803
|
-
rightMargin += neededRightPad;
|
|
1804
|
-
totalWidth = Math.max(leftMargin + diagramWidth + rightMargin, sBoxW + 40);
|
|
1805
|
-
svgWidth = Math.max(totalWidth, containerWidth);
|
|
1806
|
-
offsetX = leftMargin + Math.max(0, (svgWidth - totalWidth) / 2);
|
|
1807
|
-
buildParticipantX();
|
|
1808
|
-
}
|
|
1809
|
-
|
|
1810
|
-
const svg = d3Selection
|
|
1811
|
-
.select(container)
|
|
1812
|
-
.append('svg')
|
|
1813
|
-
.attr('width', '100%')
|
|
1814
|
-
.attr('height', totalHeight)
|
|
1815
|
-
.attr('viewBox', `0 0 ${svgWidth} ${totalHeight}`)
|
|
1816
|
-
.attr('preserveAspectRatio', 'xMidYMin meet')
|
|
1817
|
-
.attr('class', 'sequence-diagram')
|
|
1818
|
-
.style('font-family', FONT_FAMILY);
|
|
1819
|
-
|
|
1820
|
-
// Define arrowhead markers
|
|
1821
|
-
const defs = svg.append('defs');
|
|
1822
|
-
|
|
1823
|
-
// Filled arrowhead for call arrows
|
|
1824
|
-
defs
|
|
1825
|
-
.append('marker')
|
|
1826
|
-
.attr('id', 'seq-arrowhead')
|
|
1827
|
-
.attr('viewBox', `0 0 ${sArrowheadSize} ${sArrowheadSize}`)
|
|
1828
|
-
.attr('refX', sArrowheadSize)
|
|
1829
|
-
.attr('refY', sArrowheadSize / 2)
|
|
1830
|
-
.attr('markerWidth', sArrowheadSize)
|
|
1831
|
-
.attr('markerHeight', sArrowheadSize)
|
|
1832
|
-
.attr('orient', 'auto')
|
|
1833
|
-
.append('polygon')
|
|
1834
|
-
.attr(
|
|
1835
|
-
'points',
|
|
1836
|
-
`0,0 ${sArrowheadSize},${sArrowheadSize / 2} 0,${sArrowheadSize}`
|
|
1837
|
-
)
|
|
1838
|
-
.attr('fill', palette.text);
|
|
1839
|
-
|
|
1840
|
-
// Open arrowhead for return arrows
|
|
1841
|
-
defs
|
|
1842
|
-
.append('marker')
|
|
1843
|
-
.attr('id', 'seq-arrowhead-open')
|
|
1844
|
-
.attr('viewBox', `0 0 ${sArrowheadSize} ${sArrowheadSize}`)
|
|
1845
|
-
.attr('refX', sArrowheadSize)
|
|
1846
|
-
.attr('refY', sArrowheadSize / 2)
|
|
1847
|
-
.attr('markerWidth', sArrowheadSize)
|
|
1848
|
-
.attr('markerHeight', sArrowheadSize)
|
|
1849
|
-
.attr('orient', 'auto')
|
|
1850
|
-
.append('polyline')
|
|
1851
|
-
.attr(
|
|
1852
|
-
'points',
|
|
1853
|
-
`0,0 ${sArrowheadSize},${sArrowheadSize / 2} 0,${sArrowheadSize}`
|
|
1854
|
-
)
|
|
1855
|
-
.attr('fill', 'none')
|
|
1856
|
-
.attr('stroke', palette.textMuted)
|
|
1857
|
-
.attr('stroke-width', 1.2);
|
|
1858
|
-
|
|
1859
|
-
// Open arrowhead for async (fire-and-forget) arrows — same as return but text color
|
|
1860
|
-
defs
|
|
1861
|
-
.append('marker')
|
|
1862
|
-
.attr('id', 'seq-arrowhead-async')
|
|
1863
|
-
.attr('viewBox', `0 0 ${sArrowheadSize} ${sArrowheadSize}`)
|
|
1864
|
-
.attr('refX', sArrowheadSize)
|
|
1865
|
-
.attr('refY', sArrowheadSize / 2)
|
|
1866
|
-
.attr('markerWidth', sArrowheadSize)
|
|
1867
|
-
.attr('markerHeight', sArrowheadSize)
|
|
1868
|
-
.attr('orient', 'auto')
|
|
1869
|
-
.append('polyline')
|
|
1870
|
-
.attr(
|
|
1871
|
-
'points',
|
|
1872
|
-
`0,0 ${sArrowheadSize},${sArrowheadSize / 2} 0,${sArrowheadSize}`
|
|
1873
|
-
)
|
|
1874
|
-
.attr('fill', 'none')
|
|
1875
|
-
.attr('stroke', palette.text)
|
|
1876
|
-
.attr('stroke-width', 1.2);
|
|
1877
|
-
|
|
1878
|
-
// Per-color arrowhead markers for tag-driven coloring
|
|
1879
|
-
const arrowPoints = `0,0 ${sArrowheadSize},${sArrowheadSize / 2} 0,${sArrowheadSize}`;
|
|
1880
|
-
for (const [, color] of tagValueToColor) {
|
|
1881
|
-
const hex = color.replace('#', '');
|
|
1882
|
-
// Filled arrowhead (call arrows)
|
|
1883
|
-
defs
|
|
1884
|
-
.append('marker')
|
|
1885
|
-
.attr('id', `seq-arrowhead-c${hex}`)
|
|
1886
|
-
.attr('viewBox', `0 0 ${sArrowheadSize} ${sArrowheadSize}`)
|
|
1887
|
-
.attr('refX', sArrowheadSize)
|
|
1888
|
-
.attr('refY', sArrowheadSize / 2)
|
|
1889
|
-
.attr('markerWidth', sArrowheadSize)
|
|
1890
|
-
.attr('markerHeight', sArrowheadSize)
|
|
1891
|
-
.attr('orient', 'auto')
|
|
1892
|
-
.append('polygon')
|
|
1893
|
-
.attr('points', arrowPoints)
|
|
1894
|
-
.attr('fill', color);
|
|
1895
|
-
// Open arrowhead (async arrows)
|
|
1896
|
-
defs
|
|
1897
|
-
.append('marker')
|
|
1898
|
-
.attr('id', `seq-arrowhead-async-c${hex}`)
|
|
1899
|
-
.attr('viewBox', `0 0 ${sArrowheadSize} ${sArrowheadSize}`)
|
|
1900
|
-
.attr('refX', sArrowheadSize)
|
|
1901
|
-
.attr('refY', sArrowheadSize / 2)
|
|
1902
|
-
.attr('markerWidth', sArrowheadSize)
|
|
1903
|
-
.attr('markerHeight', sArrowheadSize)
|
|
1904
|
-
.attr('orient', 'auto')
|
|
1905
|
-
.append('polyline')
|
|
1906
|
-
.attr('points', arrowPoints)
|
|
1907
|
-
.attr('fill', 'none')
|
|
1908
|
-
.attr('stroke', color)
|
|
1909
|
-
.attr('stroke-width', 1.2);
|
|
1910
|
-
// Open arrowhead (return arrows)
|
|
1911
|
-
defs
|
|
1912
|
-
.append('marker')
|
|
1913
|
-
.attr('id', `seq-arrowhead-open-c${hex}`)
|
|
1914
|
-
.attr('viewBox', `0 0 ${sArrowheadSize} ${sArrowheadSize}`)
|
|
1915
|
-
.attr('refX', sArrowheadSize)
|
|
1916
|
-
.attr('refY', sArrowheadSize / 2)
|
|
1917
|
-
.attr('markerWidth', sArrowheadSize)
|
|
1918
|
-
.attr('markerHeight', sArrowheadSize)
|
|
1919
|
-
.attr('orient', 'auto')
|
|
1920
|
-
.append('polyline')
|
|
1921
|
-
.attr('points', arrowPoints)
|
|
1922
|
-
.attr('fill', 'none')
|
|
1923
|
-
.attr('stroke', color)
|
|
1924
|
-
.attr('stroke-width', 1.2);
|
|
1925
|
-
}
|
|
1926
|
-
|
|
1927
|
-
// Helper: resolve marker ref for tag-colored arrows
|
|
1928
|
-
const coloredMarker = (
|
|
1929
|
-
type: 'call' | 'async' | 'return',
|
|
1930
|
-
tagColor?: string
|
|
1931
|
-
): string => {
|
|
1932
|
-
if (tagColor) {
|
|
1933
|
-
const hex = tagColor.replace('#', '');
|
|
1934
|
-
switch (type) {
|
|
1935
|
-
case 'call':
|
|
1936
|
-
return `url(#seq-arrowhead-c${hex})`;
|
|
1937
|
-
case 'async':
|
|
1938
|
-
return `url(#seq-arrowhead-async-c${hex})`;
|
|
1939
|
-
case 'return':
|
|
1940
|
-
return `url(#seq-arrowhead-open-c${hex})`;
|
|
1941
|
-
}
|
|
1942
|
-
}
|
|
1943
|
-
switch (type) {
|
|
1944
|
-
case 'call':
|
|
1945
|
-
return 'url(#seq-arrowhead)';
|
|
1946
|
-
case 'async':
|
|
1947
|
-
return 'url(#seq-arrowhead-async)';
|
|
1948
|
-
case 'return':
|
|
1949
|
-
return 'url(#seq-arrowhead-open)';
|
|
1950
|
-
}
|
|
1951
|
-
};
|
|
1952
|
-
|
|
1953
|
-
// Render title
|
|
1954
|
-
if (showTitle) {
|
|
1955
|
-
const titleEl = svg
|
|
1956
|
-
.append('text')
|
|
1957
|
-
.attr('class', 'chart-title')
|
|
1958
|
-
.attr('x', svgWidth / 2)
|
|
1959
|
-
.attr('y', ctx.structural(TITLE_Y))
|
|
1960
|
-
.attr('text-anchor', 'middle')
|
|
1961
|
-
.attr('fill', palette.text)
|
|
1962
|
-
.attr('font-size', ctx.text(TITLE_FONT_SIZE))
|
|
1963
|
-
.attr('font-weight', TITLE_FONT_WEIGHT)
|
|
1964
|
-
.text(title);
|
|
1965
|
-
|
|
1966
|
-
if (parsed.titleLineNumber) {
|
|
1967
|
-
titleEl.attr('data-line-number', parsed.titleLineNumber);
|
|
1968
|
-
}
|
|
1969
|
-
}
|
|
1970
|
-
|
|
1971
|
-
const hasTagGroups = parsed.tagGroups.length > 0;
|
|
1972
|
-
|
|
1973
|
-
// Build set of collapsed group names for drill-bar rendering
|
|
1974
|
-
const collapsedGroupNames = new Set<string>();
|
|
1975
|
-
const collapsedGroupMeta = new Map<
|
|
1976
|
-
string,
|
|
1977
|
-
{ lineNumber: number; metadata?: Record<string, string> }
|
|
1978
|
-
>();
|
|
1979
|
-
for (const group of parsed.groups) {
|
|
1980
|
-
if (effectiveCollapsedGroups.has(group.lineNumber)) {
|
|
1981
|
-
collapsedGroupNames.add(group.name);
|
|
1982
|
-
collapsedGroupMeta.set(group.name, {
|
|
1983
|
-
lineNumber: group.lineNumber,
|
|
1984
|
-
...(group.metadata !== undefined && { metadata: group.metadata }),
|
|
1985
|
-
});
|
|
1986
|
-
}
|
|
1987
|
-
}
|
|
1988
|
-
|
|
1989
|
-
// Render group boxes (behind participant shapes) — skip collapsed groups
|
|
1990
|
-
for (const group of groups) {
|
|
1991
|
-
if (group.participantIds.length === 0) continue;
|
|
1992
|
-
|
|
1993
|
-
// Find X bounds from member participant positions
|
|
1994
|
-
const memberXs = group.participantIds
|
|
1995
|
-
.map((id) => participantX.get(id))
|
|
1996
|
-
.filter((x): x is number => x !== undefined);
|
|
1997
|
-
if (memberXs.length === 0) continue;
|
|
1998
|
-
|
|
1999
|
-
const minX = Math.min(...memberXs) - sBoxW / 2 - GROUP_PADDING_X;
|
|
2000
|
-
const maxX = Math.max(...memberXs) + sBoxW / 2 + GROUP_PADDING_X;
|
|
2001
|
-
const boxY = participantStartY - GROUP_PADDING_TOP;
|
|
2002
|
-
const boxH = sBoxH + GROUP_PADDING_TOP + GROUP_PADDING_BOTTOM;
|
|
2003
|
-
|
|
2004
|
-
// Group box background — use tag color if group has metadata for the active tag group.
|
|
2005
|
-
// Intentionally 15-20% (not the canonical 25% shapeFill): group boxes are
|
|
2006
|
-
// CONTAINERS that wrap multiple participants, so they should recede behind
|
|
2007
|
-
// the tinted participant shapes inside them. Promoting to 25% would make
|
|
2008
|
-
// the wrapper compete with its contents.
|
|
2009
|
-
const groupTagValue = tagKey && group.metadata?.[tagKey];
|
|
2010
|
-
const groupTagColor = getTagColor(groupTagValue || undefined);
|
|
2011
|
-
const fillColor = groupTagColor
|
|
2012
|
-
? mix(groupTagColor, themeBaseBg(palette, isDark), isDark ? 15 : 20)
|
|
2013
|
-
: isDark
|
|
2014
|
-
? palette.surface
|
|
2015
|
-
: palette.bg;
|
|
2016
|
-
const strokeColor = groupTagColor || palette.textMuted;
|
|
2017
|
-
|
|
2018
|
-
const groupG = svg
|
|
2019
|
-
.append('g')
|
|
2020
|
-
.attr('class', 'group-box-wrapper')
|
|
2021
|
-
.attr('data-group-toggle', '')
|
|
2022
|
-
.attr('data-group-line', String(group.lineNumber))
|
|
2023
|
-
.attr('tabindex', '0')
|
|
2024
|
-
.attr('role', 'button')
|
|
2025
|
-
.attr('aria-expanded', 'true')
|
|
2026
|
-
.attr('cursor', 'pointer');
|
|
2027
|
-
groupG.append('title').text('Click to collapse');
|
|
2028
|
-
|
|
2029
|
-
// Visual group frame — pointer-events:none so it never intercepts clicks.
|
|
2030
|
-
// The box is rendered behind the participant shapes, so the only reliable
|
|
2031
|
-
// toggle target is the header strip above the participants (hit area below).
|
|
2032
|
-
groupG
|
|
2033
|
-
.append('rect')
|
|
2034
|
-
.attr('x', minX)
|
|
2035
|
-
.attr('y', boxY)
|
|
2036
|
-
.attr('width', maxX - minX)
|
|
2037
|
-
.attr('height', boxH)
|
|
2038
|
-
.attr('rx', 6)
|
|
2039
|
-
.attr('fill', fillColor)
|
|
2040
|
-
.attr('stroke', strokeColor)
|
|
2041
|
-
.attr('stroke-width', 1)
|
|
2042
|
-
.attr('stroke-opacity', 0.5)
|
|
2043
|
-
.attr('pointer-events', 'none')
|
|
2044
|
-
.attr('class', 'group-box');
|
|
2045
|
-
|
|
2046
|
-
// Transparent hit area over the header strip (above the participant boxes,
|
|
2047
|
-
// so it is never occluded). Gives the toggle a generous, discoverable
|
|
2048
|
-
// click target — mirrors the section-label-hit pattern.
|
|
2049
|
-
groupG
|
|
2050
|
-
.append('rect')
|
|
2051
|
-
.attr('x', minX)
|
|
2052
|
-
.attr('y', boxY)
|
|
2053
|
-
.attr('width', maxX - minX)
|
|
2054
|
-
.attr('height', participantStartY - boxY)
|
|
2055
|
-
.attr('fill', 'transparent')
|
|
2056
|
-
.attr('class', 'group-label-hit');
|
|
2057
|
-
|
|
2058
|
-
// Group label — centered across the header strip.
|
|
2059
|
-
groupG
|
|
2060
|
-
.append('text')
|
|
2061
|
-
.attr('x', (minX + maxX) / 2)
|
|
2062
|
-
.attr('y', boxY + GROUP_LABEL_SIZE + 4)
|
|
2063
|
-
.attr('text-anchor', 'middle')
|
|
2064
|
-
.attr('fill', strokeColor)
|
|
2065
|
-
.attr('font-size', GROUP_LABEL_SIZE)
|
|
2066
|
-
.attr('font-weight', 'bold')
|
|
2067
|
-
.attr('opacity', 0.7)
|
|
2068
|
-
.attr('pointer-events', 'none')
|
|
2069
|
-
.attr('class', 'group-label')
|
|
2070
|
-
.text(group.name);
|
|
2071
|
-
}
|
|
2072
|
-
|
|
2073
|
-
// Render each participant
|
|
2074
|
-
const lifelineStartY = lifelineStartY0;
|
|
2075
|
-
participants.forEach((participant) => {
|
|
2076
|
-
const cx = participantX.get(participant.id)!;
|
|
2077
|
-
const cy = participantStartY;
|
|
2078
|
-
|
|
2079
|
-
const pTagValue = tagMap?.participants.get(participant.id);
|
|
2080
|
-
const pTagColor = getTagColor(pTagValue);
|
|
2081
|
-
const pTagAttr =
|
|
2082
|
-
tagKey && pTagValue
|
|
2083
|
-
? { key: tagKey, value: pTagValue.toLowerCase() }
|
|
2084
|
-
: undefined;
|
|
2085
|
-
// For collapsed group participants, resolve tag color from group metadata
|
|
2086
|
-
const isCollapsedGroup = collapsedGroupNames.has(participant.id);
|
|
2087
|
-
let effectiveTagColor = pTagColor;
|
|
2088
|
-
if (isCollapsedGroup && !effectiveTagColor) {
|
|
2089
|
-
const meta = collapsedGroupMeta.get(participant.id);
|
|
2090
|
-
if (meta?.metadata && tagKey) {
|
|
2091
|
-
effectiveTagColor = getTagColor(meta.metadata[tagKey]);
|
|
2092
|
-
}
|
|
2093
|
-
}
|
|
2094
|
-
|
|
2095
|
-
renderParticipant(
|
|
2096
|
-
svg,
|
|
2097
|
-
participant,
|
|
2098
|
-
cx,
|
|
2099
|
-
cy,
|
|
2100
|
-
palette,
|
|
2101
|
-
isDark,
|
|
2102
|
-
effectiveTagColor,
|
|
2103
|
-
pTagAttr,
|
|
2104
|
-
solid,
|
|
2105
|
-
sBoxW,
|
|
2106
|
-
sBoxH,
|
|
2107
|
-
sLabelTextWidth,
|
|
2108
|
-
sLabelFontSize
|
|
2109
|
-
);
|
|
2110
|
-
|
|
2111
|
-
// Collapsed group: re-render participant box at full group height + drill-bar
|
|
2112
|
-
if (isCollapsedGroup) {
|
|
2113
|
-
const meta = collapsedGroupMeta.get(participant.id)!;
|
|
2114
|
-
const drillColor = effectiveTagColor || palette.textMuted;
|
|
2115
|
-
const drillBarH = 6;
|
|
2116
|
-
const boxW = sBoxW;
|
|
2117
|
-
// Match the group box dimensions
|
|
2118
|
-
const fullH = sBoxH + GROUP_PADDING_TOP + GROUP_PADDING_BOTTOM;
|
|
2119
|
-
const clipId = `clip-drill-group-${participant.id.replace(/[^a-zA-Z0-9-]/g, '-')}`;
|
|
2120
|
-
|
|
2121
|
-
// Add toggle attributes to the participant <g> so any click on it
|
|
2122
|
-
// (overlay rect, label, drill-bar) walks up and triggers the toggle
|
|
2123
|
-
const participantG = svg.select<SVGGElement>(
|
|
2124
|
-
`.participant[data-participant-id="${participant.id}"]`
|
|
2125
|
-
);
|
|
2126
|
-
participantG
|
|
2127
|
-
.attr('data-group-toggle', '')
|
|
2128
|
-
.attr('data-group-line', String(meta.lineNumber))
|
|
2129
|
-
.attr('tabindex', '0')
|
|
2130
|
-
.attr('role', 'button')
|
|
2131
|
-
.attr('aria-expanded', 'false')
|
|
2132
|
-
.attr('cursor', 'pointer');
|
|
2133
|
-
participantG.append('title').text('Click to expand');
|
|
2134
|
-
|
|
2135
|
-
// Overlay a taller rect to replace the standard participant box
|
|
2136
|
-
const pFill = effectiveTagColor
|
|
2137
|
-
? mix(effectiveTagColor, themeBaseBg(palette, isDark), isDark ? 30 : 40)
|
|
2138
|
-
: isDark
|
|
2139
|
-
? mix(palette.overlay, palette.surface, 50)
|
|
2140
|
-
: mix(palette.bg, palette.surface, 50);
|
|
2141
|
-
const pStroke = effectiveTagColor || palette.border;
|
|
2142
|
-
|
|
2143
|
-
// Taller box inside the participant <g> (local coords, y=0 is participant cy)
|
|
2144
|
-
participantG
|
|
2145
|
-
.append('rect')
|
|
2146
|
-
.attr('x', -boxW / 2)
|
|
2147
|
-
.attr('y', -GROUP_PADDING_TOP)
|
|
2148
|
-
.attr('width', boxW)
|
|
2149
|
-
.attr('height', fullH)
|
|
2150
|
-
.attr('rx', 6)
|
|
2151
|
-
.attr('fill', pFill)
|
|
2152
|
-
.attr('stroke', pStroke)
|
|
2153
|
-
.attr('stroke-width', 1.5);
|
|
2154
|
-
|
|
2155
|
-
// Re-render label centered in the taller box (local coords)
|
|
2156
|
-
participantG
|
|
2157
|
-
.append('text')
|
|
2158
|
-
.attr('x', 0)
|
|
2159
|
-
.attr('y', -GROUP_PADDING_TOP + fullH / 2)
|
|
2160
|
-
.attr('text-anchor', 'middle')
|
|
2161
|
-
.attr('dominant-baseline', 'central')
|
|
2162
|
-
.attr('fill', palette.text)
|
|
2163
|
-
.attr('font-size', ctx.text(13))
|
|
2164
|
-
.attr('font-weight', 500)
|
|
2165
|
-
.text(participant.label);
|
|
2166
|
-
|
|
2167
|
-
// Drill-bar at bottom (local coords)
|
|
2168
|
-
participantG
|
|
2169
|
-
.append('clipPath')
|
|
2170
|
-
.attr('id', clipId)
|
|
2171
|
-
.append('rect')
|
|
2172
|
-
.attr('x', -boxW / 2)
|
|
2173
|
-
.attr('y', -GROUP_PADDING_TOP)
|
|
2174
|
-
.attr('width', boxW)
|
|
2175
|
-
.attr('height', fullH)
|
|
2176
|
-
.attr('rx', 6);
|
|
2177
|
-
|
|
2178
|
-
participantG
|
|
2179
|
-
.append('rect')
|
|
2180
|
-
.attr('class', 'sequence-drill-bar')
|
|
2181
|
-
.attr('x', -boxW / 2)
|
|
2182
|
-
.attr('y', -GROUP_PADDING_TOP + fullH - drillBarH)
|
|
2183
|
-
.attr('width', boxW)
|
|
2184
|
-
.attr('height', drillBarH)
|
|
2185
|
-
.attr('fill', drillColor)
|
|
2186
|
-
.attr('clip-path', `url(#${clipId})`);
|
|
2187
|
-
}
|
|
2188
|
-
|
|
2189
|
-
// Render lifeline — collapsed groups start below the taller box; actors
|
|
2190
|
-
// carry their label *below* the stick figure (at boxH + 14), so their
|
|
2191
|
-
// lifeline must start below that label or the dashes run through the text.
|
|
2192
|
-
const llY = isCollapsedGroup
|
|
2193
|
-
? lifelineStartY + GROUP_PADDING_BOTTOM
|
|
2194
|
-
: participant.type === 'actor'
|
|
2195
|
-
? lifelineStartY + ACTOR_LABEL_CLEARANCE
|
|
2196
|
-
: lifelineStartY;
|
|
2197
|
-
const llColor = isCollapsedGroup
|
|
2198
|
-
? effectiveTagColor || palette.textMuted
|
|
2199
|
-
: pTagColor || palette.textMuted;
|
|
2200
|
-
const lifelineEl = svg
|
|
2201
|
-
.append('line')
|
|
2202
|
-
.attr('x1', cx)
|
|
2203
|
-
.attr('y1', llY)
|
|
2204
|
-
.attr('x2', cx)
|
|
2205
|
-
.attr('y2', lifelineStartY + lifelineLength)
|
|
2206
|
-
.attr('stroke', llColor)
|
|
2207
|
-
.attr('stroke-width', 1)
|
|
2208
|
-
.attr('stroke-dasharray', '6 4')
|
|
2209
|
-
.attr('class', 'lifeline')
|
|
2210
|
-
.attr('data-participant-id', participant.id);
|
|
2211
|
-
if (tagKey && pTagValue) {
|
|
2212
|
-
lifelineEl.attr(`data-tag-${tagKey}`, pTagValue.toLowerCase());
|
|
2213
|
-
}
|
|
2214
|
-
});
|
|
2215
|
-
|
|
2216
|
-
// Render block frames (behind everything else)
|
|
2217
|
-
const FRAME_PADDING_X = 30;
|
|
2218
|
-
// FRAME_PADDING_TOP declared earlier (near BLOCK_HEADER_SPACE)
|
|
2219
|
-
const FRAME_PADDING_BOTTOM = 15;
|
|
2220
|
-
const FRAME_LABEL_HEIGHT = 18;
|
|
2221
|
-
// Self-loop projects ACTIVATION_WIDTH/2 + SELF_CALL_WIDTH (=35) past the
|
|
2222
|
-
// lifeline; FRAME_PADDING_X (=30) leaves no breathing room. When a block
|
|
2223
|
-
// contains a self-arrow, extend the frame on the loop's side so the loop
|
|
2224
|
-
// sits comfortably inside.
|
|
2225
|
-
const SELF_ARROW_PROJECTION = sActivationWidth / 2 + sSelfCallWidth;
|
|
2226
|
-
const SELF_ARROW_FRAME_PAD = 10;
|
|
2227
|
-
const frameRightmostX = Math.max(...Array.from(participantX.values()));
|
|
2228
|
-
|
|
2229
|
-
// Collect message indices from an element subtree
|
|
2230
|
-
const collectMsgIndices = (els: readonly SequenceElement[]): number[] => {
|
|
2231
|
-
const indices: number[] = [];
|
|
2232
|
-
for (const el of els) {
|
|
2233
|
-
if (isSequenceBlock(el)) {
|
|
2234
|
-
indices.push(
|
|
2235
|
-
...collectMsgIndices(el.children),
|
|
2236
|
-
...collectMsgIndices(el.elseChildren)
|
|
2237
|
-
);
|
|
2238
|
-
if (el.elseIfBranches) {
|
|
2239
|
-
for (const branch of el.elseIfBranches) {
|
|
2240
|
-
indices.push(...collectMsgIndices(branch.children));
|
|
2241
|
-
}
|
|
2242
|
-
}
|
|
2243
|
-
} else if (!isSequenceSection(el) && !isSequenceNote(el)) {
|
|
2244
|
-
// Narrowed to SequenceMessage by the discriminator (kind === 'message')
|
|
2245
|
-
const idx = messages.indexOf(el);
|
|
2246
|
-
if (idx >= 0) indices.push(idx);
|
|
2247
|
-
}
|
|
2248
|
-
}
|
|
2249
|
-
return indices;
|
|
2250
|
-
};
|
|
2251
|
-
|
|
2252
|
-
// Collect deferred draws (rendered after activations so they appear on top)
|
|
2253
|
-
const deferredLabels: Array<{
|
|
2254
|
-
x: number;
|
|
2255
|
-
y: number;
|
|
2256
|
-
text: string;
|
|
2257
|
-
bold: boolean;
|
|
2258
|
-
italic: boolean;
|
|
2259
|
-
blockLine?: number;
|
|
2260
|
-
}> = [];
|
|
2261
|
-
const deferredLines: Array<{
|
|
2262
|
-
x1: number;
|
|
2263
|
-
y1: number;
|
|
2264
|
-
x2: number;
|
|
2265
|
-
y2: number;
|
|
2266
|
-
blockLine?: number;
|
|
2267
|
-
}> = [];
|
|
2268
|
-
|
|
2269
|
-
// Recursive block renderer — draws borders/dividers now, defers label text
|
|
2270
|
-
const renderBlockFrames = (
|
|
2271
|
-
els: readonly SequenceElement[],
|
|
2272
|
-
depth: number
|
|
2273
|
-
): void => {
|
|
2274
|
-
for (const el of els) {
|
|
2275
|
-
if (!isSequenceBlock(el)) continue;
|
|
2276
|
-
|
|
2277
|
-
const ifIndices = collectMsgIndices(el.children);
|
|
2278
|
-
const elseIfBranchData: {
|
|
2279
|
-
label: string;
|
|
2280
|
-
indices: number[];
|
|
2281
|
-
lineNumber: number;
|
|
2282
|
-
}[] = [];
|
|
2283
|
-
if (el.elseIfBranches) {
|
|
2284
|
-
for (const branch of el.elseIfBranches) {
|
|
2285
|
-
elseIfBranchData.push({
|
|
2286
|
-
label: branch.label,
|
|
2287
|
-
indices: collectMsgIndices(branch.children),
|
|
2288
|
-
lineNumber: branch.lineNumber,
|
|
2289
|
-
});
|
|
2290
|
-
}
|
|
2291
|
-
}
|
|
2292
|
-
const elseIndices = collectMsgIndices(el.elseChildren);
|
|
2293
|
-
const allIndices = [
|
|
2294
|
-
...ifIndices,
|
|
2295
|
-
...elseIfBranchData.flatMap((b) => b.indices),
|
|
2296
|
-
...elseIndices,
|
|
2297
|
-
];
|
|
2298
|
-
if (allIndices.length === 0) continue;
|
|
2299
|
-
|
|
2300
|
-
// Find render step range
|
|
2301
|
-
let minStep = Infinity;
|
|
2302
|
-
let maxStep = -Infinity;
|
|
2303
|
-
for (const mi of allIndices) {
|
|
2304
|
-
const first = msgToFirstStep.get(mi);
|
|
2305
|
-
const last = msgToLastStep.get(mi);
|
|
2306
|
-
if (first !== undefined) minStep = Math.min(minStep, first);
|
|
2307
|
-
if (last !== undefined) maxStep = Math.max(maxStep, last);
|
|
2308
|
-
}
|
|
2309
|
-
if (minStep === Infinity) continue;
|
|
2310
|
-
|
|
2311
|
-
// Find participant X range
|
|
2312
|
-
const involved = new Set<string>();
|
|
2313
|
-
for (const mi of allIndices) {
|
|
2314
|
-
// In-bounds: allIndices is built from valid message indices via
|
|
2315
|
-
// collectMsgIndices (only pushes `idx >= 0` from messages.indexOf).
|
|
2316
|
-
const m = messages[mi]!;
|
|
2317
|
-
involved.add(m.from);
|
|
2318
|
-
involved.add(m.to);
|
|
2319
|
-
}
|
|
2320
|
-
let minPX = Infinity;
|
|
2321
|
-
let maxPX = -Infinity;
|
|
2322
|
-
for (const pid of involved) {
|
|
2323
|
-
const px = participantX.get(pid);
|
|
2324
|
-
if (px !== undefined) {
|
|
2325
|
-
minPX = Math.min(minPX, px);
|
|
2326
|
-
maxPX = Math.max(maxPX, px);
|
|
2327
|
-
}
|
|
2328
|
-
}
|
|
2329
|
-
|
|
2330
|
-
// Self-arrow geometry: extend frame on the loop's side so loops sit
|
|
2331
|
-
// comfortably inside, and extend vertically if the block's last step
|
|
2332
|
-
// is a self-call (whose loop drops SELF_CALL_HEIGHT below stepY).
|
|
2333
|
-
let extraRight = 0;
|
|
2334
|
-
let maxStepIsSelfCall = false;
|
|
2335
|
-
for (const mi of allIndices) {
|
|
2336
|
-
// In-bounds: same guarantee as above (allIndices built from valid indices).
|
|
2337
|
-
const m = messages[mi]!;
|
|
2338
|
-
if (m.from === m.to) {
|
|
2339
|
-
const px = participantX.get(m.from);
|
|
2340
|
-
if (px !== undefined) {
|
|
2341
|
-
const loopMax = px + SELF_ARROW_PROJECTION;
|
|
2342
|
-
const need =
|
|
2343
|
-
loopMax - (maxPX + FRAME_PADDING_X) + SELF_ARROW_FRAME_PAD;
|
|
2344
|
-
if (need > 0) extraRight = Math.max(extraRight, need);
|
|
2345
|
-
}
|
|
2346
|
-
if (msgToLastStep.get(mi) === maxStep) {
|
|
2347
|
-
maxStepIsSelfCall = true;
|
|
2348
|
-
}
|
|
2349
|
-
}
|
|
2350
|
-
}
|
|
2351
|
-
|
|
2352
|
-
const frameX = minPX - FRAME_PADDING_X;
|
|
2353
|
-
const frameY = stepY(minStep) - FRAME_PADDING_TOP;
|
|
2354
|
-
const frameW = maxPX - minPX + FRAME_PADDING_X * 2 + extraRight;
|
|
2355
|
-
const frameH =
|
|
2356
|
-
stepY(maxStep) -
|
|
2357
|
-
stepY(minStep) +
|
|
2358
|
-
FRAME_PADDING_TOP +
|
|
2359
|
-
FRAME_PADDING_BOTTOM +
|
|
2360
|
-
(maxStepIsSelfCall ? sSelfCallHeight : 0);
|
|
2361
|
-
|
|
2362
|
-
// Frame border
|
|
2363
|
-
svg
|
|
2364
|
-
.append('rect')
|
|
2365
|
-
.attr('x', frameX)
|
|
2366
|
-
.attr('y', frameY)
|
|
2367
|
-
.attr('width', frameW)
|
|
2368
|
-
.attr('height', frameH)
|
|
2369
|
-
.attr('fill', 'none')
|
|
2370
|
-
.attr('stroke', palette.textMuted)
|
|
2371
|
-
.attr('stroke-width', 1)
|
|
2372
|
-
.attr('stroke-dasharray', '2 3')
|
|
2373
|
-
.attr('rx', 3)
|
|
2374
|
-
.attr('ry', 3)
|
|
2375
|
-
.attr('class', 'block-frame')
|
|
2376
|
-
.attr('data-block-line', String(el.lineNumber));
|
|
2377
|
-
|
|
2378
|
-
// Defer label text (rendered on top of activations later)
|
|
2379
|
-
deferredLabels.push({
|
|
2380
|
-
x: frameX + 6,
|
|
2381
|
-
y: frameY + FRAME_LABEL_HEIGHT - 4,
|
|
2382
|
-
text: `${el.type} ${el.label}`,
|
|
2383
|
-
bold: false,
|
|
2384
|
-
italic: false,
|
|
2385
|
-
blockLine: el.lineNumber,
|
|
2386
|
-
});
|
|
2387
|
-
|
|
2388
|
-
// Else-if dividers
|
|
2389
|
-
for (const branchData of elseIfBranchData) {
|
|
2390
|
-
if (branchData.indices.length > 0) {
|
|
2391
|
-
let firstBranchStep = Infinity;
|
|
2392
|
-
for (const mi of branchData.indices) {
|
|
2393
|
-
const first = msgToFirstStep.get(mi);
|
|
2394
|
-
if (first !== undefined)
|
|
2395
|
-
firstBranchStep = Math.min(firstBranchStep, first);
|
|
2396
|
-
}
|
|
2397
|
-
if (firstBranchStep < Infinity) {
|
|
2398
|
-
const dividerY = stepY(firstBranchStep) - BLOCK_HEADER_SPACE;
|
|
2399
|
-
deferredLines.push({
|
|
2400
|
-
x1: frameX,
|
|
2401
|
-
y1: dividerY,
|
|
2402
|
-
x2: frameX + frameW,
|
|
2403
|
-
y2: dividerY,
|
|
2404
|
-
blockLine: branchData.lineNumber,
|
|
2405
|
-
});
|
|
2406
|
-
deferredLabels.push({
|
|
2407
|
-
x: frameX + 6,
|
|
2408
|
-
y: dividerY + 14,
|
|
2409
|
-
text: `else if ${branchData.label}`,
|
|
2410
|
-
bold: false,
|
|
2411
|
-
italic: false,
|
|
2412
|
-
blockLine: branchData.lineNumber,
|
|
2413
|
-
});
|
|
2414
|
-
}
|
|
2415
|
-
}
|
|
2416
|
-
}
|
|
2417
|
-
|
|
2418
|
-
// Else divider
|
|
2419
|
-
if (elseIndices.length > 0) {
|
|
2420
|
-
let firstElseStep = Infinity;
|
|
2421
|
-
for (const mi of elseIndices) {
|
|
2422
|
-
const first = msgToFirstStep.get(mi);
|
|
2423
|
-
if (first !== undefined)
|
|
2424
|
-
firstElseStep = Math.min(firstElseStep, first);
|
|
2425
|
-
}
|
|
2426
|
-
if (firstElseStep < Infinity) {
|
|
2427
|
-
const dividerY = stepY(firstElseStep) - BLOCK_HEADER_SPACE;
|
|
2428
|
-
deferredLines.push({
|
|
2429
|
-
x1: frameX,
|
|
2430
|
-
y1: dividerY,
|
|
2431
|
-
x2: frameX + frameW,
|
|
2432
|
-
y2: dividerY,
|
|
2433
|
-
...(el.elseLineNumber !== undefined && {
|
|
2434
|
-
blockLine: el.elseLineNumber,
|
|
2435
|
-
}),
|
|
2436
|
-
});
|
|
2437
|
-
deferredLabels.push({
|
|
2438
|
-
x: frameX + 6,
|
|
2439
|
-
y: dividerY + 14,
|
|
2440
|
-
text: 'else',
|
|
2441
|
-
bold: false,
|
|
2442
|
-
italic: false,
|
|
2443
|
-
...(el.elseLineNumber !== undefined && {
|
|
2444
|
-
blockLine: el.elseLineNumber,
|
|
2445
|
-
}),
|
|
2446
|
-
});
|
|
2447
|
-
}
|
|
2448
|
-
}
|
|
2449
|
-
|
|
2450
|
-
// Recurse into nested blocks
|
|
2451
|
-
renderBlockFrames(el.children, depth + 1);
|
|
2452
|
-
if (el.elseIfBranches) {
|
|
2453
|
-
for (const branch of el.elseIfBranches) {
|
|
2454
|
-
renderBlockFrames(branch.children, depth + 1);
|
|
2455
|
-
}
|
|
2456
|
-
}
|
|
2457
|
-
renderBlockFrames(el.elseChildren, depth + 1);
|
|
2458
|
-
}
|
|
2459
|
-
};
|
|
2460
|
-
|
|
2461
|
-
if (elements && elements.length > 0) {
|
|
2462
|
-
renderBlockFrames(elements, 0);
|
|
2463
|
-
}
|
|
2464
|
-
|
|
2465
|
-
// Render activation rectangles (behind arrows)
|
|
2466
|
-
const ACTIVATION_NEST_OFFSET = ctx.structural(6);
|
|
2467
|
-
activations.forEach((act) => {
|
|
2468
|
-
const px = participantX.get(act.participantId);
|
|
2469
|
-
if (px === undefined) return;
|
|
2470
|
-
|
|
2471
|
-
const x = px - sActivationWidth / 2 + act.depth * ACTIVATION_NEST_OFFSET;
|
|
2472
|
-
const y1 = stepY(act.startStep);
|
|
2473
|
-
const y2 = stepY(act.endStep);
|
|
2474
|
-
|
|
2475
|
-
// Collect message line numbers covered by this activation
|
|
2476
|
-
const coveredLines: number[] = [];
|
|
2477
|
-
for (let si = act.startStep; si <= act.endStep; si++) {
|
|
2478
|
-
const step = renderSteps[si];
|
|
2479
|
-
if (!step) continue;
|
|
2480
|
-
const msg = messages[step.messageIndex];
|
|
2481
|
-
if (msg) coveredLines.push(msg.lineNumber);
|
|
2482
|
-
}
|
|
2483
|
-
|
|
2484
|
-
// Determine activation color from triggering message's tag
|
|
2485
|
-
const triggerStep = renderSteps[act.startStep];
|
|
2486
|
-
const triggerMsg =
|
|
2487
|
-
triggerStep !== undefined
|
|
2488
|
-
? messages[triggerStep.messageIndex]
|
|
2489
|
-
: undefined;
|
|
2490
|
-
const actTagValue = triggerMsg
|
|
2491
|
-
? tagMap?.messages.get(triggerMsg.lineNumber)
|
|
2492
|
-
: undefined;
|
|
2493
|
-
const actTagColor = getTagColor(actTagValue);
|
|
2494
|
-
const actBaseColor = actTagColor || palette.primary;
|
|
2495
|
-
|
|
2496
|
-
// Opaque background to mask the lifeline
|
|
2497
|
-
svg
|
|
2498
|
-
.append('rect')
|
|
2499
|
-
.attr('x', x)
|
|
2500
|
-
.attr('y', y1)
|
|
2501
|
-
.attr('width', sActivationWidth)
|
|
2502
|
-
.attr('height', y2 - y1)
|
|
2503
|
-
.attr('fill', themeBaseBg(palette, isDark));
|
|
2504
|
-
|
|
2505
|
-
// Canonical 25% tint via shapeFill() (or full intent when solid-fill is on).
|
|
2506
|
-
const actFill = shapeFill(palette, actBaseColor, isDark, { solid });
|
|
2507
|
-
const actRect = svg
|
|
2508
|
-
.append('rect')
|
|
2509
|
-
.attr('x', x)
|
|
2510
|
-
.attr('y', y1)
|
|
2511
|
-
.attr('width', sActivationWidth)
|
|
2512
|
-
.attr('height', y2 - y1)
|
|
2513
|
-
.attr('fill', actFill)
|
|
2514
|
-
.attr('stroke', actBaseColor)
|
|
2515
|
-
.attr('stroke-width', 1)
|
|
2516
|
-
.attr('stroke-opacity', 0.5)
|
|
2517
|
-
.attr('data-participant-id', act.participantId)
|
|
2518
|
-
.attr('data-msg-lines', coveredLines.join(','))
|
|
2519
|
-
.attr('data-line-number', coveredLines[0] ?? '')
|
|
2520
|
-
.attr('class', 'activation');
|
|
2521
|
-
if (tagKey && actTagValue) {
|
|
2522
|
-
actRect.attr(`data-tag-${tagKey}`, actTagValue.toLowerCase());
|
|
2523
|
-
}
|
|
2524
|
-
});
|
|
2525
|
-
|
|
2526
|
-
// Render deferred else dividers (on top of activations)
|
|
2527
|
-
for (const ln of deferredLines) {
|
|
2528
|
-
const line = svg
|
|
2529
|
-
.append('line')
|
|
2530
|
-
.attr('x1', ln.x1)
|
|
2531
|
-
.attr('y1', ln.y1)
|
|
2532
|
-
.attr('x2', ln.x2)
|
|
2533
|
-
.attr('y2', ln.y2)
|
|
2534
|
-
.attr('stroke', palette.textMuted)
|
|
2535
|
-
.attr('stroke-width', 1)
|
|
2536
|
-
.attr('stroke-dasharray', '2 3')
|
|
2537
|
-
.attr('class', 'block-divider');
|
|
2538
|
-
if (ln.blockLine !== undefined)
|
|
2539
|
-
line.attr('data-block-line', String(ln.blockLine));
|
|
2540
|
-
}
|
|
2541
|
-
|
|
2542
|
-
// Render deferred block labels (on top of activations)
|
|
2543
|
-
for (const lbl of deferredLabels) {
|
|
2544
|
-
const t = svg
|
|
2545
|
-
.append('text')
|
|
2546
|
-
.attr('x', lbl.x)
|
|
2547
|
-
.attr('y', lbl.y)
|
|
2548
|
-
.attr('fill', palette.text)
|
|
2549
|
-
.attr('font-size', 11)
|
|
2550
|
-
.attr('class', 'block-label')
|
|
2551
|
-
.text(lbl.text);
|
|
2552
|
-
if (lbl.bold) t.attr('font-weight', 'bold');
|
|
2553
|
-
if (lbl.italic) t.attr('font-style', 'italic');
|
|
2554
|
-
if (lbl.blockLine !== undefined)
|
|
2555
|
-
t.attr('data-block-line', String(lbl.blockLine));
|
|
2556
|
-
}
|
|
2557
|
-
|
|
2558
|
-
// Helper: find max active activation depth for a participant at a step
|
|
2559
|
-
const activeDepthAt = (pid: string, stepIdx: number): number => {
|
|
2560
|
-
let maxDepth = -1;
|
|
2561
|
-
for (const act of activations) {
|
|
2562
|
-
if (
|
|
2563
|
-
act.participantId === pid &&
|
|
2564
|
-
act.startStep <= stepIdx &&
|
|
2565
|
-
stepIdx <= act.endStep &&
|
|
2566
|
-
act.depth > maxDepth
|
|
2567
|
-
) {
|
|
2568
|
-
maxDepth = act.depth;
|
|
2569
|
-
}
|
|
2570
|
-
}
|
|
2571
|
-
return maxDepth;
|
|
2572
|
-
};
|
|
2573
|
-
|
|
2574
|
-
// Helper: compute arrow endpoint X, snapping to activation box edge
|
|
2575
|
-
const arrowEdgeX = (
|
|
2576
|
-
pid: string,
|
|
2577
|
-
stepIdx: number,
|
|
2578
|
-
side: 'left' | 'right'
|
|
2579
|
-
): number => {
|
|
2580
|
-
const px = participantX.get(pid)!;
|
|
2581
|
-
const depth = activeDepthAt(pid, stepIdx);
|
|
2582
|
-
if (depth < 0) return px;
|
|
2583
|
-
const offset = depth * ACTIVATION_NEST_OFFSET;
|
|
2584
|
-
return side === 'right'
|
|
2585
|
-
? px + sActivationWidth / 2 + offset
|
|
2586
|
-
: px - sActivationWidth / 2 + offset;
|
|
2587
|
-
};
|
|
2588
|
-
|
|
2589
|
-
// Render section dividers
|
|
2590
|
-
const leftmostX = Math.min(...Array.from(participantX.values()));
|
|
2591
|
-
const rightmostX = frameRightmostX;
|
|
2592
|
-
const sectionLineX1 = leftmostX - sBoxW / 2 - 10;
|
|
2593
|
-
const sectionLineX2 = rightmostX + sBoxW / 2 + 10;
|
|
2594
|
-
|
|
2595
|
-
for (const region of sectionRegions) {
|
|
2596
|
-
const sec = region.section;
|
|
2597
|
-
const secY = sectionYPositions.get(sec.lineNumber);
|
|
2598
|
-
if (secY === undefined) continue;
|
|
2599
|
-
|
|
2600
|
-
const isCollapsed = collapsedSections?.has(sec.lineNumber) ?? false;
|
|
2601
|
-
const lineColor = palette.textMuted;
|
|
2602
|
-
|
|
2603
|
-
// Wrap section elements in a <g> for toggle.
|
|
2604
|
-
// IMPORTANT: only the <g> carries data-line-number / data-section —
|
|
2605
|
-
// children must NOT have them, otherwise the click walk-up resolves
|
|
2606
|
-
// to a line-number navigation before reaching data-section-toggle.
|
|
2607
|
-
const sectionG = svg
|
|
2608
|
-
.append('g')
|
|
2609
|
-
.attr('data-section-toggle', '')
|
|
2610
|
-
.attr('data-line-number', String(sec.lineNumber))
|
|
2611
|
-
.attr('data-section', '')
|
|
2612
|
-
.attr('tabindex', '0')
|
|
2613
|
-
.attr('role', 'button')
|
|
2614
|
-
.attr('aria-expanded', String(!isCollapsed));
|
|
2615
|
-
|
|
2616
|
-
// Full-width tinted band
|
|
2617
|
-
const BAND_HEIGHT = 22;
|
|
2618
|
-
const bandX = sectionLineX1 - 10;
|
|
2619
|
-
const bandWidth = sectionLineX2 - sectionLineX1 + 20;
|
|
2620
|
-
const bandOpacity = isCollapsed
|
|
2621
|
-
? isDark
|
|
2622
|
-
? 0.35
|
|
2623
|
-
: 0.25
|
|
2624
|
-
: isDark
|
|
2625
|
-
? 0.1
|
|
2626
|
-
: 0.08;
|
|
2627
|
-
// Visual band — pointer-events:none so it never intercepts clicks
|
|
2628
|
-
// intended for elements rendered earlier (participants, lifelines, etc.).
|
|
2629
|
-
// Toggle hit area is the label rect below.
|
|
2630
|
-
sectionG
|
|
2631
|
-
.append('rect')
|
|
2632
|
-
.attr('x', bandX)
|
|
2633
|
-
.attr('y', secY - BAND_HEIGHT / 2)
|
|
2634
|
-
.attr('width', bandWidth)
|
|
2635
|
-
.attr('height', BAND_HEIGHT)
|
|
2636
|
-
.attr('fill', lineColor)
|
|
2637
|
-
.attr('opacity', bandOpacity)
|
|
2638
|
-
.attr('rx', 2)
|
|
2639
|
-
.attr('pointer-events', 'none')
|
|
2640
|
-
.attr('class', 'section-divider');
|
|
2641
|
-
|
|
2642
|
-
// Build display label
|
|
2643
|
-
const msgCount = sectionMsgCounts.get(sec.lineNumber) ?? 0;
|
|
2644
|
-
const labelText = isCollapsed
|
|
2645
|
-
? `${sec.label} (${msgCount} ${msgCount === 1 ? 'message' : 'messages'})`
|
|
2646
|
-
: sec.label;
|
|
2647
|
-
|
|
2648
|
-
// Centered label text
|
|
2649
|
-
const labelX = (sectionLineX1 + sectionLineX2) / 2;
|
|
2650
|
-
|
|
2651
|
-
// Transparent hit area scoped to the label so the toggle stays clickable
|
|
2652
|
-
// without the band swallowing clicks across the full diagram width.
|
|
2653
|
-
// Label renders at the fixed 11px section-label size.
|
|
2654
|
-
const labelHitW = Math.max(80, measureText(labelText, 11) + 24);
|
|
2655
|
-
sectionG
|
|
2656
|
-
.append('rect')
|
|
2657
|
-
.attr('x', labelX - labelHitW / 2)
|
|
2658
|
-
.attr('y', secY - BAND_HEIGHT / 2)
|
|
2659
|
-
.attr('width', labelHitW)
|
|
2660
|
-
.attr('height', BAND_HEIGHT)
|
|
2661
|
-
.attr('fill', 'transparent')
|
|
2662
|
-
.attr('class', 'section-label-hit');
|
|
2663
|
-
|
|
2664
|
-
sectionG
|
|
2665
|
-
.append('text')
|
|
2666
|
-
.attr('x', labelX)
|
|
2667
|
-
.attr('y', secY + 4)
|
|
2668
|
-
.attr('text-anchor', 'middle')
|
|
2669
|
-
.attr('fill', lineColor)
|
|
2670
|
-
.attr('font-size', 11)
|
|
2671
|
-
.attr('font-weight', 'bold')
|
|
2672
|
-
.attr('class', 'section-label')
|
|
2673
|
-
.text(labelText);
|
|
2674
|
-
}
|
|
2675
|
-
|
|
2676
|
-
// Render steps (calls and returns in stack-inferred order)
|
|
2677
|
-
// Self-call rendering uses scaled constants
|
|
2678
|
-
renderSteps.forEach((step, i) => {
|
|
2679
|
-
const fromX = participantX.get(step.from);
|
|
2680
|
-
const toX = participantX.get(step.to);
|
|
2681
|
-
if (fromX === undefined || toX === undefined) return;
|
|
2682
|
-
|
|
2683
|
-
const y = stepY(i);
|
|
2684
|
-
|
|
2685
|
-
const HIT_H = 20; // transparent hit area height (10px above + below arrow)
|
|
2686
|
-
|
|
2687
|
-
// Resolve tag color for this message
|
|
2688
|
-
// In-bounds: step.messageIndex is built from a valid messages[] index by
|
|
2689
|
-
// buildRenderSequence/computeActivations (no synthetic step indices exist).
|
|
2690
|
-
const msg = messages[step.messageIndex]!;
|
|
2691
|
-
const msgTagValue = msg ? tagMap?.messages.get(msg.lineNumber) : undefined;
|
|
2692
|
-
const msgTagColor = getTagColor(msgTagValue);
|
|
2693
|
-
|
|
2694
|
-
if (step.type === 'call') {
|
|
2695
|
-
const arrowColor = msgTagColor || palette.text;
|
|
2696
|
-
|
|
2697
|
-
if (step.from === step.to) {
|
|
2698
|
-
// Self-call: loopback arrow — always loops to the right.
|
|
2699
|
-
// Canvas width extends to accommodate via right-edge projection.
|
|
2700
|
-
const x = arrowEdgeX(step.from, i, 'right');
|
|
2701
|
-
const loopX = x + sSelfCallWidth;
|
|
2702
|
-
const hitX = x;
|
|
2703
|
-
|
|
2704
|
-
// Hit area for self-call
|
|
2705
|
-
svg
|
|
2706
|
-
.append('rect')
|
|
2707
|
-
.attr('x', hitX)
|
|
2708
|
-
.attr('y', y - 5)
|
|
2709
|
-
.attr('width', sSelfCallWidth)
|
|
2710
|
-
.attr('height', sSelfCallHeight + 10)
|
|
2711
|
-
.attr('fill', 'transparent')
|
|
2712
|
-
.attr('class', 'message-hit-area')
|
|
2713
|
-
.attr('data-line-number', String(msg.lineNumber))
|
|
2714
|
-
.attr('data-msg-index', String(step.messageIndex))
|
|
2715
|
-
.attr('data-step-index', String(i));
|
|
2716
|
-
|
|
2717
|
-
const selfCallEl = svg
|
|
2718
|
-
.append('path')
|
|
2719
|
-
.attr('d', `M ${x} ${y} H ${loopX} V ${y + sSelfCallHeight} H ${x}`)
|
|
2720
|
-
.attr('fill', 'none')
|
|
2721
|
-
.attr('stroke', arrowColor)
|
|
2722
|
-
.attr('stroke-width', 1.2)
|
|
2723
|
-
.attr('marker-end', coloredMarker('call', msgTagColor))
|
|
2724
|
-
.attr('class', 'message-arrow self-call')
|
|
2725
|
-
.attr('data-line-number', String(msg.lineNumber))
|
|
2726
|
-
.attr('data-msg-index', String(step.messageIndex))
|
|
2727
|
-
.attr('data-step-index', String(i))
|
|
2728
|
-
.attr('data-from', step.from)
|
|
2729
|
-
.attr('data-to', step.to);
|
|
2730
|
-
if (tagKey && msgTagValue) {
|
|
2731
|
-
selfCallEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());
|
|
2732
|
-
}
|
|
2733
|
-
|
|
2734
|
-
if (step.label) {
|
|
2735
|
-
const labelEl = svg
|
|
2736
|
-
.append('text')
|
|
2737
|
-
.attr('x', loopX + 5)
|
|
2738
|
-
.attr('y', y + sSelfCallHeight / 2 + 4)
|
|
2739
|
-
.attr('text-anchor', 'start')
|
|
2740
|
-
.attr('fill', arrowColor)
|
|
2741
|
-
.attr('paint-order', 'stroke fill')
|
|
2742
|
-
.attr('stroke', palette.bg)
|
|
2743
|
-
.attr('stroke-width', 4)
|
|
2744
|
-
.attr('stroke-linejoin', 'round')
|
|
2745
|
-
.attr('font-size', 12)
|
|
2746
|
-
.attr('class', 'message-label')
|
|
2747
|
-
.attr('data-line-number', String(msg.lineNumber))
|
|
2748
|
-
.attr('data-msg-index', String(step.messageIndex))
|
|
2749
|
-
.attr('data-step-index', String(i));
|
|
2750
|
-
if (tagKey && msgTagValue) {
|
|
2751
|
-
labelEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());
|
|
2752
|
-
}
|
|
2753
|
-
// TD-1: in-arrow labels render as plain text (no markdown interpretation).
|
|
2754
|
-
// Fixes the `location[]`-style silent character drop.
|
|
2755
|
-
labelEl.text(step.label);
|
|
2756
|
-
}
|
|
2757
|
-
} else {
|
|
2758
|
-
// Normal call arrow — snap to activation box edges
|
|
2759
|
-
const goingRight = fromX < toX;
|
|
2760
|
-
const x1 = arrowEdgeX(step.from, i, goingRight ? 'right' : 'left');
|
|
2761
|
-
const x2 = arrowEdgeX(step.to, i, goingRight ? 'left' : 'right');
|
|
2762
|
-
|
|
2763
|
-
// Hit area for call arrow
|
|
2764
|
-
svg
|
|
2765
|
-
.append('rect')
|
|
2766
|
-
.attr('x', Math.min(x1, x2))
|
|
2767
|
-
.attr('y', y - HIT_H / 2)
|
|
2768
|
-
.attr('width', Math.abs(x2 - x1))
|
|
2769
|
-
.attr('height', HIT_H)
|
|
2770
|
-
.attr('fill', 'transparent')
|
|
2771
|
-
.attr('class', 'message-hit-area')
|
|
2772
|
-
.attr('data-line-number', String(msg.lineNumber))
|
|
2773
|
-
.attr('data-msg-index', String(step.messageIndex))
|
|
2774
|
-
.attr('data-step-index', String(i));
|
|
2775
|
-
|
|
2776
|
-
const markerRef = step.async
|
|
2777
|
-
? coloredMarker('async', msgTagColor)
|
|
2778
|
-
: coloredMarker('call', msgTagColor);
|
|
2779
|
-
const arrowEl = svg
|
|
2780
|
-
.append('line')
|
|
2781
|
-
.attr('x1', x1)
|
|
2782
|
-
.attr('y1', y)
|
|
2783
|
-
.attr('x2', x2)
|
|
2784
|
-
.attr('y2', y)
|
|
2785
|
-
.attr('stroke', arrowColor)
|
|
2786
|
-
.attr('stroke-width', 1.2)
|
|
2787
|
-
.attr('marker-end', markerRef)
|
|
2788
|
-
.attr('class', 'message-arrow')
|
|
2789
|
-
.attr('data-line-number', String(msg.lineNumber))
|
|
2790
|
-
.attr('data-msg-index', String(step.messageIndex))
|
|
2791
|
-
.attr('data-step-index', String(i))
|
|
2792
|
-
.attr('data-from', step.from)
|
|
2793
|
-
.attr('data-to', step.to);
|
|
2794
|
-
if (tagKey && msgTagValue) {
|
|
2795
|
-
arrowEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());
|
|
2796
|
-
}
|
|
2797
|
-
|
|
2798
|
-
if (step.label) {
|
|
2799
|
-
const midX = (x1 + x2) / 2;
|
|
2800
|
-
const labelEl = svg
|
|
2801
|
-
.append('text')
|
|
2802
|
-
.attr('x', midX)
|
|
2803
|
-
.attr('y', y - 8)
|
|
2804
|
-
.attr('text-anchor', 'middle')
|
|
2805
|
-
.attr('fill', arrowColor)
|
|
2806
|
-
.attr('paint-order', 'stroke fill')
|
|
2807
|
-
.attr('stroke', palette.bg)
|
|
2808
|
-
.attr('stroke-width', 4)
|
|
2809
|
-
.attr('stroke-linejoin', 'round')
|
|
2810
|
-
.attr('font-size', 12)
|
|
2811
|
-
.attr('class', 'message-label')
|
|
2812
|
-
.attr('data-line-number', String(msg.lineNumber))
|
|
2813
|
-
.attr('data-msg-index', String(step.messageIndex))
|
|
2814
|
-
.attr('data-step-index', String(i));
|
|
2815
|
-
if (tagKey && msgTagValue) {
|
|
2816
|
-
labelEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());
|
|
2817
|
-
}
|
|
2818
|
-
// TD-1: in-arrow labels render as plain text (no markdown interpretation).
|
|
2819
|
-
// Fixes the `location[]`-style silent character drop.
|
|
2820
|
-
labelEl.text(step.label);
|
|
2821
|
-
}
|
|
2822
|
-
}
|
|
2823
|
-
} else {
|
|
2824
|
-
if (step.from === step.to) {
|
|
2825
|
-
// Self-call return — already handled by the loopback path, skip
|
|
2826
|
-
return;
|
|
2827
|
-
}
|
|
2828
|
-
// Return arrow — snap to activation box edges
|
|
2829
|
-
const goingRight = fromX < toX;
|
|
2830
|
-
const x1 = arrowEdgeX(step.from, i, goingRight ? 'right' : 'left');
|
|
2831
|
-
const x2 = arrowEdgeX(step.to, i, goingRight ? 'left' : 'right');
|
|
2832
|
-
const returnColor = msgTagColor || palette.textMuted;
|
|
2833
|
-
|
|
2834
|
-
// Hit area for return arrow
|
|
2835
|
-
svg
|
|
2836
|
-
.append('rect')
|
|
2837
|
-
.attr('x', Math.min(x1, x2))
|
|
2838
|
-
.attr('y', y - HIT_H / 2)
|
|
2839
|
-
.attr('width', Math.abs(x2 - x1))
|
|
2840
|
-
.attr('height', HIT_H)
|
|
2841
|
-
.attr('fill', 'transparent')
|
|
2842
|
-
.attr('class', 'message-hit-area')
|
|
2843
|
-
.attr('data-line-number', String(msg.lineNumber))
|
|
2844
|
-
.attr('data-msg-index', String(step.messageIndex))
|
|
2845
|
-
.attr('data-step-index', String(i));
|
|
2846
|
-
|
|
2847
|
-
const returnEl = svg
|
|
2848
|
-
.append('line')
|
|
2849
|
-
.attr('x1', x1)
|
|
2850
|
-
.attr('y1', y)
|
|
2851
|
-
.attr('x2', x2)
|
|
2852
|
-
.attr('y2', y)
|
|
2853
|
-
.attr('stroke', returnColor)
|
|
2854
|
-
.attr('stroke-width', 1)
|
|
2855
|
-
.attr('stroke-dasharray', '6 4')
|
|
2856
|
-
.attr('marker-end', coloredMarker('return', msgTagColor))
|
|
2857
|
-
.attr('class', 'return-arrow')
|
|
2858
|
-
.attr('data-line-number', String(msg.lineNumber))
|
|
2859
|
-
.attr('data-msg-index', String(step.messageIndex))
|
|
2860
|
-
.attr('data-step-index', String(i))
|
|
2861
|
-
.attr('data-from', step.from)
|
|
2862
|
-
.attr('data-to', step.to);
|
|
2863
|
-
if (tagKey && msgTagValue) {
|
|
2864
|
-
returnEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());
|
|
2865
|
-
}
|
|
2866
|
-
|
|
2867
|
-
if (step.label) {
|
|
2868
|
-
const midX = (x1 + x2) / 2;
|
|
2869
|
-
const labelEl = svg
|
|
2870
|
-
.append('text')
|
|
2871
|
-
.attr('x', midX)
|
|
2872
|
-
.attr('y', y - 6)
|
|
2873
|
-
.attr('text-anchor', 'middle')
|
|
2874
|
-
.attr('fill', returnColor)
|
|
2875
|
-
.attr('paint-order', 'stroke fill')
|
|
2876
|
-
.attr('stroke', palette.bg)
|
|
2877
|
-
.attr('stroke-width', 4)
|
|
2878
|
-
.attr('stroke-linejoin', 'round')
|
|
2879
|
-
.attr('font-size', 11)
|
|
2880
|
-
.attr('class', 'message-label')
|
|
2881
|
-
.attr('data-line-number', String(msg.lineNumber))
|
|
2882
|
-
.attr('data-msg-index', String(step.messageIndex))
|
|
2883
|
-
.attr('data-step-index', String(i));
|
|
2884
|
-
if (tagKey && msgTagValue) {
|
|
2885
|
-
labelEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());
|
|
2886
|
-
}
|
|
2887
|
-
// TD-1: in-arrow labels render as plain text (no markdown
|
|
2888
|
-
// interpretation). Return-arrow labels are currently always empty
|
|
2889
|
-
// (buildRenderSequence sets them to '') but this path is kept in
|
|
2890
|
-
// sync with the call/self-call sites above to prevent a future
|
|
2891
|
-
// change resurrecting the location[] silent-drop bug.
|
|
2892
|
-
labelEl.text(step.label);
|
|
2893
|
-
}
|
|
2894
|
-
}
|
|
2895
|
-
});
|
|
2896
|
-
|
|
2897
|
-
// Render notes — folded-corner boxes attached to participant lifelines
|
|
2898
|
-
const noteFill = isDark
|
|
2899
|
-
? mix(palette.surface, palette.bg, 50)
|
|
2900
|
-
: mix(palette.bg, palette.surface, 15);
|
|
2901
|
-
|
|
2902
|
-
const renderNoteElements = (els: readonly SequenceElement[]): void => {
|
|
2903
|
-
for (const el of els) {
|
|
2904
|
-
if (isSequenceNote(el)) {
|
|
2905
|
-
const px = participantX.get(el.participantId);
|
|
2906
|
-
if (px === undefined) continue;
|
|
2907
|
-
const noteTopY = noteYMap.get(el);
|
|
2908
|
-
if (noteTopY === undefined) continue;
|
|
2909
|
-
|
|
2910
|
-
const position = effectiveNotePosition(el);
|
|
2911
|
-
const isRight = position === 'right';
|
|
2912
|
-
const afterSelfCall = isNoteAfterSelfCall(el);
|
|
2913
|
-
const maxW = noteEffectiveMaxW(
|
|
2914
|
-
el.participantId,
|
|
2915
|
-
position,
|
|
2916
|
-
afterSelfCall
|
|
2917
|
-
);
|
|
2918
|
-
const textWidth = noteTextWidth(maxW);
|
|
2919
|
-
const wrappedLines = wrapTextLines(el.text, textWidth, sNoteFontSize);
|
|
2920
|
-
const noteH = wrappedLines.length * sNoteLineH + sNotePadV * 2;
|
|
2921
|
-
const maxLineW = Math.max(
|
|
2922
|
-
...wrappedLines.map((l) => measureText(l.text, sNoteFontSize))
|
|
2923
|
-
);
|
|
2924
|
-
const noteW = Math.min(
|
|
2925
|
-
maxW,
|
|
2926
|
-
Math.max(80, maxLineW + sNotePadH * 2 + sNoteFold)
|
|
2927
|
-
);
|
|
2928
|
-
// Shift notes past self-call loopback when applicable
|
|
2929
|
-
const rightOffset =
|
|
2930
|
-
afterSelfCall && isRight
|
|
2931
|
-
? sActivationWidth / 2 + sSelfCallWidth + sNoteGap
|
|
2932
|
-
: sActivationWidth + sNoteGap;
|
|
2933
|
-
const noteX = isRight
|
|
2934
|
-
? px + rightOffset
|
|
2935
|
-
: px - sActivationWidth - sNoteGap - noteW;
|
|
2936
|
-
|
|
2937
|
-
const noteG = svg
|
|
2938
|
-
.append('g')
|
|
2939
|
-
.attr('class', 'note')
|
|
2940
|
-
.attr('data-note-toggle', '')
|
|
2941
|
-
.attr('data-line-number', String(el.lineNumber))
|
|
2942
|
-
.attr('data-line-end', String(el.endLineNumber));
|
|
2943
|
-
|
|
2944
|
-
// Folded-corner path
|
|
2945
|
-
noteG
|
|
2946
|
-
.append('path')
|
|
2947
|
-
.attr(
|
|
2948
|
-
'd',
|
|
2949
|
-
[
|
|
2950
|
-
`M ${noteX} ${noteTopY}`,
|
|
2951
|
-
`L ${noteX + noteW - sNoteFold} ${noteTopY}`,
|
|
2952
|
-
`L ${noteX + noteW} ${noteTopY + sNoteFold}`,
|
|
2953
|
-
`L ${noteX + noteW} ${noteTopY + noteH}`,
|
|
2954
|
-
`L ${noteX} ${noteTopY + noteH}`,
|
|
2955
|
-
'Z',
|
|
2956
|
-
].join(' ')
|
|
2957
|
-
)
|
|
2958
|
-
.attr('fill', noteFill)
|
|
2959
|
-
.attr('stroke', palette.textMuted)
|
|
2960
|
-
.attr('stroke-width', 0.75)
|
|
2961
|
-
.attr('class', 'note-box');
|
|
2962
|
-
|
|
2963
|
-
// Fold triangle
|
|
2964
|
-
noteG
|
|
2965
|
-
.append('path')
|
|
2966
|
-
.attr(
|
|
2967
|
-
'd',
|
|
2968
|
-
[
|
|
2969
|
-
`M ${noteX + noteW - sNoteFold} ${noteTopY}`,
|
|
2970
|
-
`L ${noteX + noteW - sNoteFold} ${noteTopY + sNoteFold}`,
|
|
2971
|
-
`L ${noteX + noteW} ${noteTopY + sNoteFold}`,
|
|
2972
|
-
].join(' ')
|
|
2973
|
-
)
|
|
2974
|
-
.attr('fill', 'none')
|
|
2975
|
-
.attr('stroke', palette.textMuted)
|
|
2976
|
-
.attr('stroke-width', 0.75)
|
|
2977
|
-
.attr('class', 'note-fold');
|
|
2978
|
-
|
|
2979
|
-
// Render text with inline markdown. Bullet first lines get a "\u2022"
|
|
2980
|
-
// glyph at the left edge with body text indented; bullet continuation
|
|
2981
|
-
// lines render at the same indented body column for hanging alignment.
|
|
2982
|
-
const BULLET_BODY_INDENT = 10;
|
|
2983
|
-
wrappedLines.forEach((line, li) => {
|
|
2984
|
-
const textY = noteTopY + sNotePadV + (li + 1) * sNoteLineH - 3;
|
|
2985
|
-
const indent = line.kind === 'plain' ? 0 : BULLET_BODY_INDENT;
|
|
2986
|
-
if (line.kind === 'bullet-first') {
|
|
2987
|
-
noteG
|
|
2988
|
-
.append('text')
|
|
2989
|
-
.attr('x', noteX + sNotePadH)
|
|
2990
|
-
.attr('y', textY)
|
|
2991
|
-
.attr('fill', palette.text)
|
|
2992
|
-
.attr('font-size', sNoteFontSize)
|
|
2993
|
-
.text('\u2022');
|
|
2994
|
-
}
|
|
2995
|
-
const textEl = noteG
|
|
2996
|
-
.append('text')
|
|
2997
|
-
.attr('x', noteX + sNotePadH + indent)
|
|
2998
|
-
.attr('y', textY)
|
|
2999
|
-
.attr('fill', palette.text)
|
|
3000
|
-
.attr('font-size', sNoteFontSize)
|
|
3001
|
-
.attr('class', 'note-text');
|
|
3002
|
-
renderInlineText(textEl, line.text, palette, sNoteFontSize);
|
|
3003
|
-
});
|
|
3004
|
-
} else if (isSequenceBlock(el)) {
|
|
3005
|
-
renderNoteElements(el.children);
|
|
3006
|
-
if (el.elseIfBranches) {
|
|
3007
|
-
for (const branch of el.elseIfBranches) {
|
|
3008
|
-
renderNoteElements(branch.children);
|
|
3009
|
-
}
|
|
3010
|
-
}
|
|
3011
|
-
renderNoteElements(el.elseChildren);
|
|
3012
|
-
}
|
|
3013
|
-
}
|
|
3014
|
-
};
|
|
3015
|
-
|
|
3016
|
-
if (elements && elements.length > 0) {
|
|
3017
|
-
renderNoteElements(elements);
|
|
3018
|
-
}
|
|
3019
|
-
|
|
3020
|
-
// Render legend LAST so it sits on top of all other SVG elements
|
|
3021
|
-
// (group boxes, lifelines, participants, etc.) and can receive clicks.
|
|
3022
|
-
if (hasTagGroups) {
|
|
3023
|
-
const legendY = sTopMargin + titleOffset;
|
|
3024
|
-
const legendCallbacks: LegendCallbacks = {};
|
|
3025
|
-
|
|
3026
|
-
const legendG = svg
|
|
3027
|
-
.append('g')
|
|
3028
|
-
.attr('class', 'sequence-legend')
|
|
3029
|
-
.attr('transform', `translate(0,${legendY})`);
|
|
3030
|
-
renderIntegratedLegend(legendG, {
|
|
3031
|
-
...legendConfig,
|
|
3032
|
-
palette,
|
|
3033
|
-
isDark,
|
|
3034
|
-
width: svgWidth,
|
|
3035
|
-
activeGroup: activeTagGroup ?? null,
|
|
3036
|
-
controlsExpanded: false,
|
|
3037
|
-
callbacks: legendCallbacks,
|
|
3038
|
-
});
|
|
3039
|
-
}
|
|
3040
|
-
}
|
|
3041
|
-
|
|
3042
|
-
/**
|
|
3043
|
-
* Build a mapping from each note's lineNumber to the lineNumber of its
|
|
3044
|
-
* associated message (the last message before the note in document order).
|
|
3045
|
-
* Used by the app to highlight the associated message when cursor is on a note.
|
|
3046
|
-
*/
|
|
3047
|
-
export function buildNoteMessageMap(
|
|
3048
|
-
elements: readonly SequenceElement[]
|
|
3049
|
-
): Map<number, number> {
|
|
3050
|
-
const map = new Map<number, number>();
|
|
3051
|
-
let lastMessageLine = -1;
|
|
3052
|
-
|
|
3053
|
-
const walk = (els: readonly SequenceElement[]): void => {
|
|
3054
|
-
for (const el of els) {
|
|
3055
|
-
if (isSequenceNote(el)) {
|
|
3056
|
-
if (lastMessageLine >= 0) {
|
|
3057
|
-
map.set(el.lineNumber, lastMessageLine);
|
|
3058
|
-
}
|
|
3059
|
-
} else if (isSequenceBlock(el)) {
|
|
3060
|
-
walk(el.children);
|
|
3061
|
-
if (el.elseIfBranches) {
|
|
3062
|
-
for (const branch of el.elseIfBranches) {
|
|
3063
|
-
walk(branch.children);
|
|
3064
|
-
}
|
|
3065
|
-
}
|
|
3066
|
-
walk(el.elseChildren);
|
|
3067
|
-
} else if (!isSequenceSection(el)) {
|
|
3068
|
-
// Narrowed to SequenceMessage by the discriminator (kind === 'message')
|
|
3069
|
-
lastMessageLine = el.lineNumber;
|
|
3070
|
-
}
|
|
3071
|
-
}
|
|
3072
|
-
};
|
|
3073
|
-
walk(elements);
|
|
3074
|
-
return map;
|
|
3075
|
-
}
|
|
3076
|
-
|
|
3077
|
-
function renderParticipant(
|
|
3078
|
-
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
|
|
3079
|
-
participant: SequenceParticipant,
|
|
3080
|
-
cx: number,
|
|
3081
|
-
cy: number,
|
|
3082
|
-
palette: PaletteColors,
|
|
3083
|
-
isDark: boolean,
|
|
3084
|
-
color?: string,
|
|
3085
|
-
tagAttr?: { key: string; value: string },
|
|
3086
|
-
solid?: boolean,
|
|
3087
|
-
boxW: number = W,
|
|
3088
|
-
boxH: number = H,
|
|
3089
|
-
labelTextW: number = labelTextWidth(W),
|
|
3090
|
-
labelFontSize: number = LABEL_FONT_SIZE
|
|
3091
|
-
): void {
|
|
3092
|
-
const g = svg
|
|
3093
|
-
.append('g')
|
|
3094
|
-
.attr('transform', `translate(${cx}, ${cy})`)
|
|
3095
|
-
.attr('class', 'participant')
|
|
3096
|
-
.attr('data-participant-id', participant.id);
|
|
3097
|
-
|
|
3098
|
-
// Set data-tag attribute for legend hover dimming
|
|
3099
|
-
if (tagAttr) {
|
|
3100
|
-
g.attr(`data-tag-${tagAttr.key}`, tagAttr.value);
|
|
3101
|
-
}
|
|
3102
|
-
|
|
3103
|
-
// Render shape based on type
|
|
3104
|
-
switch (participant.type) {
|
|
3105
|
-
case 'actor':
|
|
3106
|
-
renderActorParticipant(g, palette, color, boxH);
|
|
3107
|
-
break;
|
|
3108
|
-
case 'database':
|
|
3109
|
-
renderDatabaseParticipant(g, palette, isDark, color, solid, boxW, boxH);
|
|
3110
|
-
break;
|
|
3111
|
-
case 'queue':
|
|
3112
|
-
renderQueueParticipant(g, palette, isDark, color, solid, boxW, boxH);
|
|
3113
|
-
break;
|
|
3114
|
-
case 'cache':
|
|
3115
|
-
renderCacheParticipant(g, palette, isDark, color, solid, boxW, boxH);
|
|
3116
|
-
break;
|
|
3117
|
-
default:
|
|
3118
|
-
renderRectParticipant(g, palette, isDark, color, solid, boxW, boxH);
|
|
3119
|
-
break;
|
|
3120
|
-
}
|
|
3121
|
-
|
|
3122
|
-
// Render label — below the shape for actors, centered inside for others
|
|
3123
|
-
const isActor = participant.type === 'actor';
|
|
3124
|
-
const labelLines = splitParticipantLabel(
|
|
3125
|
-
participant.label,
|
|
3126
|
-
labelTextW,
|
|
3127
|
-
labelFontSize
|
|
3128
|
-
);
|
|
3129
|
-
const fontSize = labelFontSize;
|
|
3130
|
-
const lineHeight = fontSize + 2;
|
|
3131
|
-
const labelFill = isActor
|
|
3132
|
-
? palette.text
|
|
3133
|
-
: contrastText(
|
|
3134
|
-
fill(palette, isDark, color, solid),
|
|
3135
|
-
palette.textOnFillLight,
|
|
3136
|
-
palette.textOnFillDark
|
|
3137
|
-
);
|
|
3138
|
-
const textEl = g
|
|
3139
|
-
.append('text')
|
|
3140
|
-
.attr('x', 0)
|
|
3141
|
-
.attr('text-anchor', 'middle')
|
|
3142
|
-
.attr('fill', labelFill)
|
|
3143
|
-
.attr('font-size', fontSize)
|
|
3144
|
-
.attr('font-weight', 500);
|
|
3145
|
-
|
|
3146
|
-
const maxLabelW = labelTextWidth(boxW);
|
|
3147
|
-
const truncLine = (text: string): string =>
|
|
3148
|
-
truncateText(text, fontSize, maxLabelW);
|
|
3149
|
-
|
|
3150
|
-
if (labelLines.length === 1) {
|
|
3151
|
-
textEl
|
|
3152
|
-
.attr('y', isActor ? boxH + 14 : boxH / 2 + 5)
|
|
3153
|
-
.text(truncLine(participant.label));
|
|
3154
|
-
} else {
|
|
3155
|
-
const totalHeight = labelLines.length * lineHeight;
|
|
3156
|
-
const baseY = isActor
|
|
3157
|
-
? boxH + 14 - ((labelLines.length - 1) * lineHeight) / 2
|
|
3158
|
-
: boxH / 2 + 5 - (totalHeight - lineHeight) / 2;
|
|
3159
|
-
|
|
3160
|
-
labelLines.forEach((line, i) => {
|
|
3161
|
-
textEl
|
|
3162
|
-
.append('tspan')
|
|
3163
|
-
.attr('x', 0)
|
|
3164
|
-
.attr('dy', i === 0 ? `${baseY}px` : `${lineHeight}px`)
|
|
3165
|
-
.text(truncLine(line));
|
|
3166
|
-
});
|
|
3167
|
-
}
|
|
3168
|
-
}
|