@diagrammo/dgmo 0.8.20 → 0.8.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.cjs +92 -90
- package/dist/editor.cjs +13 -1
- package/dist/editor.cjs.map +1 -1
- package/dist/editor.js +13 -1
- package/dist/editor.js.map +1 -1
- package/dist/highlight.cjs +13 -1
- package/dist/highlight.cjs.map +1 -1
- package/dist/highlight.js +13 -1
- package/dist/highlight.js.map +1 -1
- package/dist/index.cjs +4144 -940
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +318 -84
- package/dist/index.d.ts +318 -84
- package/dist/index.js +4132 -938
- package/dist/index.js.map +1 -1
- package/docs/guide/chart-mindmap.md +198 -0
- package/docs/guide/chart-sequence.md +23 -1
- package/docs/guide/chart-wireframe.md +100 -0
- package/docs/guide/index.md +8 -0
- package/docs/language-reference.md +137 -2
- package/package.json +1 -1
- package/src/boxes-and-lines/collapse.ts +21 -3
- package/src/boxes-and-lines/layout.ts +51 -9
- package/src/boxes-and-lines/parser.ts +8 -1
- package/src/boxes-and-lines/renderer.ts +121 -23
- package/src/boxes-and-lines/types.ts +1 -0
- package/src/completion.ts +26 -0
- package/src/d3.ts +153 -32
- package/src/dgmo-router.ts +6 -0
- package/src/editor/keywords.ts +12 -0
- package/src/graph/layout.ts +73 -9
- package/src/graph/state-collapse.ts +78 -0
- package/src/graph/state-renderer.ts +139 -34
- package/src/index.ts +28 -0
- package/src/kanban/renderer.ts +303 -57
- package/src/mindmap/collapse.ts +88 -0
- package/src/mindmap/layout.ts +605 -0
- package/src/mindmap/parser.ts +379 -0
- package/src/mindmap/renderer.ts +543 -0
- package/src/mindmap/text-wrap.ts +207 -0
- package/src/mindmap/types.ts +55 -0
- package/src/render.ts +18 -21
- package/src/sequence/renderer.ts +129 -18
- package/src/sharing.ts +2 -0
- package/src/sitemap/layout.ts +35 -12
- package/src/utils/export-container.ts +3 -2
- package/src/utils/legend-d3.ts +1 -0
- package/src/utils/legend-layout.ts +2 -2
- package/src/utils/parsing.ts +2 -0
- package/src/wireframe/layout.ts +460 -0
- package/src/wireframe/parser.ts +956 -0
- package/src/wireframe/renderer.ts +1293 -0
- package/src/wireframe/types.ts +110 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Mindmap Text Wrapping
|
|
3
|
+
// ============================================================
|
|
4
|
+
//
|
|
5
|
+
// Shared logic for wrapping node labels and descriptions into
|
|
6
|
+
// multiple lines. Used by both layout (for sizing) and renderer
|
|
7
|
+
// (for drawing). Ensures both agree on line breaks and font size.
|
|
8
|
+
|
|
9
|
+
const CHAR_WIDTH_RATIO = 0.58; // avg char width / fontSize for Helvetica
|
|
10
|
+
const H_PAD = 16; // 8px padding each side
|
|
11
|
+
const MAX_LABEL_LINES = 3;
|
|
12
|
+
const MAX_DESC_LINES = 2;
|
|
13
|
+
|
|
14
|
+
/** Split text into tokens, keeping hyphens attached to the left word. */
|
|
15
|
+
function tokenize(text: string): string[] {
|
|
16
|
+
const tokens: string[] = [];
|
|
17
|
+
// Split on spaces, and after hyphens (keep hyphen with left token)
|
|
18
|
+
const parts = text.split(/(\s+)/);
|
|
19
|
+
for (const part of parts) {
|
|
20
|
+
if (/^\s+$/.test(part)) continue; // skip whitespace tokens
|
|
21
|
+
// Further split on hyphens: "well-known" → ["well-", "known"]
|
|
22
|
+
const hyphenParts = part.split(/(?<=-)(?=\S)/);
|
|
23
|
+
tokens.push(...hyphenParts);
|
|
24
|
+
}
|
|
25
|
+
return tokens;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Wrap text into lines that fit within maxWidth at the given fontSize.
|
|
30
|
+
* Returns null if the text doesn't fit within maxLines.
|
|
31
|
+
*/
|
|
32
|
+
function tryWrap(
|
|
33
|
+
tokens: string[],
|
|
34
|
+
maxWidth: number,
|
|
35
|
+
fontSize: number,
|
|
36
|
+
maxLines: number
|
|
37
|
+
): string[] | null {
|
|
38
|
+
const availWidth = maxWidth - H_PAD;
|
|
39
|
+
const charWidth = fontSize * CHAR_WIDTH_RATIO;
|
|
40
|
+
const maxChars = Math.max(1, Math.floor(availWidth / charWidth));
|
|
41
|
+
|
|
42
|
+
const lines: string[] = [];
|
|
43
|
+
let currentLine = '';
|
|
44
|
+
|
|
45
|
+
for (const token of tokens) {
|
|
46
|
+
// After a hyphen-ending token, don't add a space
|
|
47
|
+
const sep = currentLine && !currentLine.endsWith('-') ? ' ' : '';
|
|
48
|
+
const candidate = currentLine + sep + token;
|
|
49
|
+
|
|
50
|
+
if (candidate.length <= maxChars) {
|
|
51
|
+
currentLine = candidate;
|
|
52
|
+
} else if (!currentLine) {
|
|
53
|
+
// Single token exceeds line — force it onto this line (will be truncated later if needed)
|
|
54
|
+
currentLine = token;
|
|
55
|
+
} else {
|
|
56
|
+
// Push current line, start new one
|
|
57
|
+
lines.push(currentLine);
|
|
58
|
+
if (lines.length >= maxLines) {
|
|
59
|
+
// Can't fit — return null to signal overflow
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
currentLine = token;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (currentLine) {
|
|
66
|
+
lines.push(currentLine);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (lines.length > maxLines) return null;
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Truncate the last line of a lines array with ellipsis to fit maxChars. */
|
|
74
|
+
function truncateLastLine(
|
|
75
|
+
lines: string[],
|
|
76
|
+
maxWidth: number,
|
|
77
|
+
fontSize: number
|
|
78
|
+
): string[] {
|
|
79
|
+
const availWidth = maxWidth - H_PAD;
|
|
80
|
+
const charWidth = fontSize * CHAR_WIDTH_RATIO;
|
|
81
|
+
const maxChars = Math.max(1, Math.floor(availWidth / charWidth));
|
|
82
|
+
|
|
83
|
+
const result = [...lines];
|
|
84
|
+
const last = result[result.length - 1];
|
|
85
|
+
if (last.length > maxChars) {
|
|
86
|
+
result[result.length - 1] = last.substring(0, maxChars - 1) + '\u2026';
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface WrapResult {
|
|
92
|
+
lines: string[];
|
|
93
|
+
fontSize: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Wrap text to fit within a node of the given maxWidth.
|
|
98
|
+
* Tries wrapping at baseFontSize first. If text doesn't fit within
|
|
99
|
+
* maxLines, reduces font size by 1px at a time down to minFontSize.
|
|
100
|
+
* As a last resort, truncates the final line with ellipsis.
|
|
101
|
+
*/
|
|
102
|
+
export function wrapText(
|
|
103
|
+
text: string,
|
|
104
|
+
maxWidth: number,
|
|
105
|
+
baseFontSize: number,
|
|
106
|
+
minFontSize: number,
|
|
107
|
+
maxLines: number = MAX_LABEL_LINES
|
|
108
|
+
): WrapResult {
|
|
109
|
+
if (!text) return { lines: [''], fontSize: baseFontSize };
|
|
110
|
+
|
|
111
|
+
const tokens = tokenize(text);
|
|
112
|
+
|
|
113
|
+
// Try at each font size from base down to min
|
|
114
|
+
for (let fs = baseFontSize; fs >= minFontSize; fs--) {
|
|
115
|
+
const lines = tryWrap(tokens, maxWidth, fs, maxLines);
|
|
116
|
+
if (lines) {
|
|
117
|
+
// Ensure each line fits (truncate overly long single tokens)
|
|
118
|
+
return { lines: truncateLastLine(lines, maxWidth, fs), fontSize: fs };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Last resort: wrap at minFontSize with unlimited lines, then take first maxLines
|
|
123
|
+
const allLines = tryWrap(tokens, maxWidth, minFontSize, 999) ?? [text];
|
|
124
|
+
const capped = allLines.slice(0, maxLines);
|
|
125
|
+
const truncated = truncateLastLine(capped, maxWidth, minFontSize);
|
|
126
|
+
// If we dropped lines, append ellipsis to indicate overflow
|
|
127
|
+
if (allLines.length > maxLines) {
|
|
128
|
+
const last = truncated[truncated.length - 1];
|
|
129
|
+
if (!last.endsWith('\u2026')) {
|
|
130
|
+
const availWidth = maxWidth - H_PAD;
|
|
131
|
+
const charWidth = minFontSize * CHAR_WIDTH_RATIO;
|
|
132
|
+
const maxChars = Math.max(1, Math.floor(availWidth / charWidth));
|
|
133
|
+
if (last.length >= maxChars - 1) {
|
|
134
|
+
truncated[truncated.length - 1] =
|
|
135
|
+
last.substring(0, maxChars - 1) + '\u2026';
|
|
136
|
+
} else {
|
|
137
|
+
truncated[truncated.length - 1] = last + '\u2026';
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
lines: truncated,
|
|
143
|
+
fontSize: minFontSize,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ============================================================
|
|
148
|
+
// Compute full node text layout (shared between layout + renderer)
|
|
149
|
+
// ============================================================
|
|
150
|
+
|
|
151
|
+
const ROOT_FONT_SIZE = 17;
|
|
152
|
+
const MIN_FONT_SIZE = 9;
|
|
153
|
+
const FONT_STEP = 3;
|
|
154
|
+
const DESC_FONT_SIZE = 10;
|
|
155
|
+
|
|
156
|
+
function labelFontSize(depth: number): number {
|
|
157
|
+
return Math.max(MIN_FONT_SIZE, ROOT_FONT_SIZE - depth * FONT_STEP);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
interface NodeTextLayout {
|
|
161
|
+
labelLines: string[];
|
|
162
|
+
labelFontSize: number;
|
|
163
|
+
descLines: string[];
|
|
164
|
+
descFontSize: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Compute wrapped text layout for a mindmap node.
|
|
169
|
+
* Called by both layout (for height) and renderer (for drawing).
|
|
170
|
+
*/
|
|
171
|
+
export function computeNodeText(
|
|
172
|
+
label: string,
|
|
173
|
+
description: string | undefined,
|
|
174
|
+
depth: number,
|
|
175
|
+
nodeWidth: number,
|
|
176
|
+
hideDescriptions: boolean
|
|
177
|
+
): NodeTextLayout {
|
|
178
|
+
const baseFontSize = labelFontSize(depth);
|
|
179
|
+
const labelResult = wrapText(
|
|
180
|
+
label,
|
|
181
|
+
nodeWidth,
|
|
182
|
+
baseFontSize,
|
|
183
|
+
MIN_FONT_SIZE,
|
|
184
|
+
MAX_LABEL_LINES
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
let descLines: string[] = [];
|
|
188
|
+
let descFontSize = DESC_FONT_SIZE;
|
|
189
|
+
if (!hideDescriptions && description) {
|
|
190
|
+
const descResult = wrapText(
|
|
191
|
+
description,
|
|
192
|
+
nodeWidth,
|
|
193
|
+
DESC_FONT_SIZE,
|
|
194
|
+
DESC_FONT_SIZE, // don't shrink descriptions
|
|
195
|
+
MAX_DESC_LINES
|
|
196
|
+
);
|
|
197
|
+
descLines = descResult.lines;
|
|
198
|
+
descFontSize = descResult.fontSize;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
labelLines: labelResult.lines,
|
|
203
|
+
labelFontSize: labelResult.fontSize,
|
|
204
|
+
descLines,
|
|
205
|
+
descFontSize,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { DgmoError } from '../diagnostics.js';
|
|
2
|
+
import type { TagGroup } from '../utils/tag-groups.js';
|
|
3
|
+
|
|
4
|
+
export interface MindmapNode {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
metadata: Record<string, string>;
|
|
9
|
+
children: MindmapNode[];
|
|
10
|
+
parentId: string | null;
|
|
11
|
+
lineNumber: number;
|
|
12
|
+
color?: string;
|
|
13
|
+
collapsed?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ParsedMindmap {
|
|
17
|
+
title: string | null;
|
|
18
|
+
titleLineNumber: number | null;
|
|
19
|
+
roots: MindmapNode[];
|
|
20
|
+
tagGroups: TagGroup[];
|
|
21
|
+
options: Record<string, string>;
|
|
22
|
+
diagnostics: DgmoError[];
|
|
23
|
+
error: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MindmapLayoutNode {
|
|
27
|
+
id: string;
|
|
28
|
+
label: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
metadata: Record<string, string>;
|
|
31
|
+
lineNumber: number;
|
|
32
|
+
color?: string;
|
|
33
|
+
x: number;
|
|
34
|
+
y: number;
|
|
35
|
+
width: number;
|
|
36
|
+
height: number;
|
|
37
|
+
depth: number;
|
|
38
|
+
angle: number;
|
|
39
|
+
radius: number;
|
|
40
|
+
hiddenCount?: number;
|
|
41
|
+
hasChildren?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface MindmapLayoutEdge {
|
|
45
|
+
sourceId: string;
|
|
46
|
+
targetId: string;
|
|
47
|
+
path: string; // SVG path d attribute
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface MindmapLayoutResult {
|
|
51
|
+
nodes: MindmapLayoutNode[];
|
|
52
|
+
edges: MindmapLayoutEdge[];
|
|
53
|
+
width: number;
|
|
54
|
+
height: number;
|
|
55
|
+
}
|
package/src/render.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from './dgmo-router';
|
|
8
8
|
import type { DgmoError } from './diagnostics';
|
|
9
9
|
import { getPalette } from './palettes/registry';
|
|
10
|
+
import type { CompactViewState } from './sharing';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Ensures DOM globals are available for D3 renderers.
|
|
@@ -73,6 +74,8 @@ export async function render(
|
|
|
73
74
|
tagGroup?: string;
|
|
74
75
|
/** Legend state for export — controls which tag group is shown in exported SVG. */
|
|
75
76
|
legendState?: { activeGroup?: string; hiddenAttributes?: string[] };
|
|
77
|
+
/** View state for export — controls interactive state (collapse, swimlanes, etc.) */
|
|
78
|
+
viewState?: CompactViewState;
|
|
76
79
|
}
|
|
77
80
|
): Promise<{ svg: string; diagnostics: DgmoError[] }> {
|
|
78
81
|
const theme = options?.theme ?? 'light';
|
|
@@ -86,15 +89,15 @@ export async function render(
|
|
|
86
89
|
const chartType = parseDgmoChartType(content);
|
|
87
90
|
const category = chartType ? getRenderCategory(chartType) : null;
|
|
88
91
|
|
|
89
|
-
// Build
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
:
|
|
96
|
-
|
|
97
|
-
|
|
92
|
+
// Build viewState from legendState (backwards compat) or use provided viewState
|
|
93
|
+
const viewState: CompactViewState | undefined =
|
|
94
|
+
options?.viewState ??
|
|
95
|
+
(options?.legendState
|
|
96
|
+
? {
|
|
97
|
+
tag: options.legendState.activeGroup ?? undefined,
|
|
98
|
+
ha: options.legendState.hiddenAttributes,
|
|
99
|
+
}
|
|
100
|
+
: undefined);
|
|
98
101
|
|
|
99
102
|
if (category === 'data-chart') {
|
|
100
103
|
const svg = await renderExtendedChartForExport(
|
|
@@ -107,17 +110,11 @@ export async function render(
|
|
|
107
110
|
|
|
108
111
|
// Visualization/diagram and unknown/null types all go through the unified renderer
|
|
109
112
|
await ensureDom();
|
|
110
|
-
const svg = await renderForExport(
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
c4Level: options?.c4Level,
|
|
117
|
-
c4System: options?.c4System,
|
|
118
|
-
c4Container: options?.c4Container,
|
|
119
|
-
tagGroup: options?.tagGroup,
|
|
120
|
-
}
|
|
121
|
-
);
|
|
113
|
+
const svg = await renderForExport(content, theme, paletteColors, viewState, {
|
|
114
|
+
c4Level: options?.c4Level,
|
|
115
|
+
c4System: options?.c4System,
|
|
116
|
+
c4Container: options?.c4Container,
|
|
117
|
+
tagGroup: options?.tagGroup,
|
|
118
|
+
});
|
|
122
119
|
return { svg, diagnostics };
|
|
123
120
|
}
|
package/src/sequence/renderer.ts
CHANGED
|
@@ -60,6 +60,11 @@ const NOTE_CHARS_PER_LINE = Math.floor(
|
|
|
60
60
|
);
|
|
61
61
|
const COLLAPSED_NOTE_H = 20;
|
|
62
62
|
const COLLAPSED_NOTE_W = 40;
|
|
63
|
+
const ACTIVATION_WIDTH = 10;
|
|
64
|
+
const SELF_CALL_HEIGHT = 25;
|
|
65
|
+
const SELF_CALL_WIDTH = 30;
|
|
66
|
+
// Max note width that keeps a note within one participant lane
|
|
67
|
+
const NOTE_LANE_MAX = PARTICIPANT_GAP - ACTIVATION_WIDTH - NOTE_GAP; // 135px
|
|
63
68
|
|
|
64
69
|
function wrapTextLines(text: string, maxChars: number): string[] {
|
|
65
70
|
const rawLines = text.split('\n');
|
|
@@ -953,6 +958,37 @@ export function renderSequenceDiagram(
|
|
|
953
958
|
);
|
|
954
959
|
if (participants.length === 0) return;
|
|
955
960
|
|
|
961
|
+
// Participant index lookup — used to clamp note width within one lane
|
|
962
|
+
const participantIndexMap = new Map<string, number>();
|
|
963
|
+
participants.forEach((p, i) => participantIndexMap.set(p.id, i));
|
|
964
|
+
|
|
965
|
+
// Extra X shift for notes after self-calls
|
|
966
|
+
const SELF_CALL_NOTE_X_SHIFT =
|
|
967
|
+
ACTIVATION_WIDTH / 2 +
|
|
968
|
+
SELF_CALL_WIDTH +
|
|
969
|
+
NOTE_GAP -
|
|
970
|
+
(ACTIVATION_WIDTH + NOTE_GAP); // 25px
|
|
971
|
+
|
|
972
|
+
const noteEffectiveMaxW = (
|
|
973
|
+
participantId: string,
|
|
974
|
+
position: 'right' | 'left',
|
|
975
|
+
afterSelfCall = false
|
|
976
|
+
): number => {
|
|
977
|
+
const idx = participantIndexMap.get(participantId);
|
|
978
|
+
if (idx === undefined) return NOTE_MAX_W;
|
|
979
|
+
const hasNeighbor =
|
|
980
|
+
position === 'right' ? idx < participants.length - 1 : idx > 0;
|
|
981
|
+
if (!hasNeighbor) return NOTE_MAX_W;
|
|
982
|
+
const laneMax =
|
|
983
|
+
afterSelfCall && position === 'right'
|
|
984
|
+
? NOTE_LANE_MAX - SELF_CALL_NOTE_X_SHIFT
|
|
985
|
+
: NOTE_LANE_MAX;
|
|
986
|
+
return Math.min(NOTE_MAX_W, laneMax);
|
|
987
|
+
};
|
|
988
|
+
|
|
989
|
+
const charsForWidth = (maxW: number): number =>
|
|
990
|
+
Math.floor((maxW - NOTE_PAD_H * 2 - NOTE_FOLD) / NOTE_CHAR_W);
|
|
991
|
+
|
|
956
992
|
const activationsOff = parsedOptions.activations?.toLowerCase() === 'off';
|
|
957
993
|
|
|
958
994
|
// Tag resolution — shared utility handles priority chain:
|
|
@@ -1049,7 +1085,39 @@ export function renderSequenceDiagram(
|
|
|
1049
1085
|
return msgToLastStep.get(closestMsgIndex) ?? -1;
|
|
1050
1086
|
};
|
|
1051
1087
|
|
|
1052
|
-
//
|
|
1088
|
+
// Check whether a note's preceding message is a self-call.
|
|
1089
|
+
// Self-call loopback arrows extend SELF_CALL_HEIGHT below the step Y,
|
|
1090
|
+
// so notes after self-calls need a larger vertical offset.
|
|
1091
|
+
const isNoteAfterSelfCall = (note: SequenceNote): boolean => {
|
|
1092
|
+
let closestMsgIndex = -1;
|
|
1093
|
+
let closestLine = -1;
|
|
1094
|
+
for (let mi = 0; mi < messages.length; mi++) {
|
|
1095
|
+
if (
|
|
1096
|
+
messages[mi].lineNumber < note.lineNumber &&
|
|
1097
|
+
messages[mi].lineNumber > closestLine
|
|
1098
|
+
) {
|
|
1099
|
+
closestLine = messages[mi].lineNumber;
|
|
1100
|
+
closestMsgIndex = mi;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
if (closestMsgIndex < 0) return false;
|
|
1104
|
+
const msg = messages[closestMsgIndex];
|
|
1105
|
+
return msg.from === msg.to;
|
|
1106
|
+
};
|
|
1107
|
+
|
|
1108
|
+
// Extra gap below self-call loop before note starts
|
|
1109
|
+
const SELF_CALL_NOTE_GAP = 8;
|
|
1110
|
+
const noteOffsetBelow = (note: SequenceNote): number =>
|
|
1111
|
+
isNoteAfterSelfCall(note)
|
|
1112
|
+
? SELF_CALL_HEIGHT + NOTE_OFFSET_BELOW + SELF_CALL_NOTE_GAP
|
|
1113
|
+
: NOTE_OFFSET_BELOW;
|
|
1114
|
+
|
|
1115
|
+
// Find the first visible message index in an element subtree.
|
|
1116
|
+
// Use lineNumber lookup instead of indexOf — collapse projection creates
|
|
1117
|
+
// separate spread copies for messages[] and elements[], breaking reference equality.
|
|
1118
|
+
const msgLineToIdx = new Map<number, number>();
|
|
1119
|
+
messages.forEach((m, i) => msgLineToIdx.set(m.lineNumber, i));
|
|
1120
|
+
|
|
1053
1121
|
const findFirstMsgIndex = (els: SequenceElement[]): number => {
|
|
1054
1122
|
for (const el of els) {
|
|
1055
1123
|
if (isSequenceBlock(el)) {
|
|
@@ -1064,7 +1132,7 @@ export function renderSequenceDiagram(
|
|
|
1064
1132
|
const elseIdx = findFirstMsgIndex(el.elseChildren);
|
|
1065
1133
|
if (elseIdx >= 0) return elseIdx;
|
|
1066
1134
|
} else if (!isSequenceSection(el) && !isSequenceNote(el)) {
|
|
1067
|
-
const idx =
|
|
1135
|
+
const idx = msgLineToIdx.get(el.lineNumber) ?? -1;
|
|
1068
1136
|
if (idx >= 0 && !hiddenMsgIndices.has(idx)) return idx;
|
|
1069
1137
|
}
|
|
1070
1138
|
}
|
|
@@ -1121,8 +1189,11 @@ export function renderSequenceDiagram(
|
|
|
1121
1189
|
// When notes share horizontal space with subsequent arrows, generous vertical clearance
|
|
1122
1190
|
// is needed so note boxes don't visually cover message labels.
|
|
1123
1191
|
const NOTE_TRAILING_GAP = 35;
|
|
1124
|
-
const computeNoteHeight = (
|
|
1125
|
-
|
|
1192
|
+
const computeNoteHeight = (
|
|
1193
|
+
text: string,
|
|
1194
|
+
maxChars: number = NOTE_CHARS_PER_LINE
|
|
1195
|
+
): number => {
|
|
1196
|
+
const lines = wrapTextLines(text, maxChars);
|
|
1126
1197
|
return lines.length * NOTE_LINE_H + NOTE_PAD_V * 2;
|
|
1127
1198
|
};
|
|
1128
1199
|
let trailingNoteSpace = 0; // extra space for notes at the end with no following message
|
|
@@ -1131,15 +1202,18 @@ export function renderSequenceDiagram(
|
|
|
1131
1202
|
const el = els[i];
|
|
1132
1203
|
if (isSequenceNote(el)) {
|
|
1133
1204
|
// Total vertical extent of notes from the message arrow:
|
|
1134
|
-
//
|
|
1205
|
+
// offset (gap above first note — larger after self-calls)
|
|
1135
1206
|
// + each note's height + NOTE_OFFSET_BELOW (inter-note gap)
|
|
1136
1207
|
// + NOTE_TRAILING_GAP (gap below last note — clears next message label)
|
|
1137
|
-
|
|
1208
|
+
const firstOffset = noteOffsetBelow(el as SequenceNote);
|
|
1209
|
+
let totalExtent = firstOffset;
|
|
1138
1210
|
let j = i;
|
|
1139
1211
|
while (j < els.length && isSequenceNote(els[j])) {
|
|
1140
1212
|
const note = els[j] as SequenceNote;
|
|
1213
|
+
const sc = isNoteAfterSelfCall(note);
|
|
1214
|
+
const maxW = noteEffectiveMaxW(note.participantId, note.position, sc);
|
|
1141
1215
|
const noteH = isNoteExpanded(note)
|
|
1142
|
-
? computeNoteHeight(note.text)
|
|
1216
|
+
? computeNoteHeight(note.text, charsForWidth(maxW))
|
|
1143
1217
|
: COLLAPSED_NOTE_H;
|
|
1144
1218
|
totalExtent += noteH + NOTE_OFFSET_BELOW;
|
|
1145
1219
|
j++;
|
|
@@ -1401,13 +1475,18 @@ export function renderSequenceDiagram(
|
|
|
1401
1475
|
let noteTopY: number;
|
|
1402
1476
|
if (prevNoteY !== undefined && prevNote) {
|
|
1403
1477
|
// Stack below previous note
|
|
1478
|
+
const prevMaxW = noteEffectiveMaxW(
|
|
1479
|
+
prevNote.participantId,
|
|
1480
|
+
prevNote.position,
|
|
1481
|
+
isNoteAfterSelfCall(prevNote)
|
|
1482
|
+
);
|
|
1404
1483
|
const prevNoteH = isNoteExpanded(prevNote)
|
|
1405
|
-
? computeNoteHeight(prevNote.text)
|
|
1484
|
+
? computeNoteHeight(prevNote.text, charsForWidth(prevMaxW))
|
|
1406
1485
|
: COLLAPSED_NOTE_H;
|
|
1407
1486
|
noteTopY = prevNoteY + prevNoteH + NOTE_OFFSET_BELOW;
|
|
1408
1487
|
} else {
|
|
1409
|
-
// First note after a message
|
|
1410
|
-
noteTopY = stepY(si) +
|
|
1488
|
+
// First note after a message — use larger offset after self-calls
|
|
1489
|
+
noteTopY = stepY(si) + noteOffsetBelow(el);
|
|
1411
1490
|
}
|
|
1412
1491
|
noteYMap.set(el, noteTopY);
|
|
1413
1492
|
} else if (isSequenceBlock(el)) {
|
|
@@ -1435,8 +1514,13 @@ export function renderSequenceDiagram(
|
|
|
1435
1514
|
)
|
|
1436
1515
|
: layoutEndY;
|
|
1437
1516
|
for (const [note, noteTopY] of noteYMap) {
|
|
1517
|
+
const maxW = noteEffectiveMaxW(
|
|
1518
|
+
note.participantId,
|
|
1519
|
+
note.position,
|
|
1520
|
+
isNoteAfterSelfCall(note)
|
|
1521
|
+
);
|
|
1438
1522
|
const noteH = isNoteExpanded(note)
|
|
1439
|
-
? computeNoteHeight(note.text)
|
|
1523
|
+
? computeNoteHeight(note.text, charsForWidth(maxW))
|
|
1440
1524
|
: COLLAPSED_NOTE_H;
|
|
1441
1525
|
contentBottomY = Math.max(
|
|
1442
1526
|
contentBottomY,
|
|
@@ -2093,7 +2177,6 @@ export function renderSequenceDiagram(
|
|
|
2093
2177
|
}
|
|
2094
2178
|
|
|
2095
2179
|
// Render activation rectangles (behind arrows)
|
|
2096
|
-
const ACTIVATION_WIDTH = 10;
|
|
2097
2180
|
const ACTIVATION_NEST_OFFSET = 6;
|
|
2098
2181
|
activations.forEach((act) => {
|
|
2099
2182
|
const px = participantX.get(act.participantId);
|
|
@@ -2285,8 +2368,7 @@ export function renderSequenceDiagram(
|
|
|
2285
2368
|
}
|
|
2286
2369
|
|
|
2287
2370
|
// Render steps (calls and returns in stack-inferred order)
|
|
2288
|
-
|
|
2289
|
-
const SELF_CALL_HEIGHT = 25;
|
|
2371
|
+
// SELF_CALL_WIDTH is now a module-level constant
|
|
2290
2372
|
renderSteps.forEach((step, i) => {
|
|
2291
2373
|
const fromX = participantX.get(step.from);
|
|
2292
2374
|
const toX = participantX.get(step.to);
|
|
@@ -2354,6 +2436,10 @@ export function renderSequenceDiagram(
|
|
|
2354
2436
|
.attr('y', y + SELF_CALL_HEIGHT / 2 + 4)
|
|
2355
2437
|
.attr('text-anchor', 'start')
|
|
2356
2438
|
.attr('fill', arrowColor)
|
|
2439
|
+
.attr('paint-order', 'stroke fill')
|
|
2440
|
+
.attr('stroke', palette.bg)
|
|
2441
|
+
.attr('stroke-width', 4)
|
|
2442
|
+
.attr('stroke-linejoin', 'round')
|
|
2357
2443
|
.attr('font-size', 12)
|
|
2358
2444
|
.attr('class', 'message-label')
|
|
2359
2445
|
.attr(
|
|
@@ -2424,6 +2510,10 @@ export function renderSequenceDiagram(
|
|
|
2424
2510
|
.attr('y', y - 8)
|
|
2425
2511
|
.attr('text-anchor', 'middle')
|
|
2426
2512
|
.attr('fill', arrowColor)
|
|
2513
|
+
.attr('paint-order', 'stroke fill')
|
|
2514
|
+
.attr('stroke', palette.bg)
|
|
2515
|
+
.attr('stroke-width', 4)
|
|
2516
|
+
.attr('stroke-linejoin', 'round')
|
|
2427
2517
|
.attr('font-size', 12)
|
|
2428
2518
|
.attr('class', 'message-label')
|
|
2429
2519
|
.attr(
|
|
@@ -2498,6 +2588,10 @@ export function renderSequenceDiagram(
|
|
|
2498
2588
|
.attr('y', y - 6)
|
|
2499
2589
|
.attr('text-anchor', 'middle')
|
|
2500
2590
|
.attr('fill', returnColor)
|
|
2591
|
+
.attr('paint-order', 'stroke fill')
|
|
2592
|
+
.attr('stroke', palette.bg)
|
|
2593
|
+
.attr('stroke-width', 4)
|
|
2594
|
+
.attr('stroke-linejoin', 'round')
|
|
2501
2595
|
.attr('font-size', 11)
|
|
2502
2596
|
.attr('class', 'message-label')
|
|
2503
2597
|
.attr(
|
|
@@ -2539,15 +2633,27 @@ export function renderSequenceDiagram(
|
|
|
2539
2633
|
|
|
2540
2634
|
if (expanded) {
|
|
2541
2635
|
// --- Expanded note: full folded-corner box with wrapped text ---
|
|
2542
|
-
const
|
|
2636
|
+
const afterSelfCall = isNoteAfterSelfCall(el);
|
|
2637
|
+
const maxW = noteEffectiveMaxW(
|
|
2638
|
+
el.participantId,
|
|
2639
|
+
el.position,
|
|
2640
|
+
afterSelfCall
|
|
2641
|
+
);
|
|
2642
|
+
const maxChars = charsForWidth(maxW);
|
|
2643
|
+
const wrappedLines = wrapTextLines(el.text, maxChars);
|
|
2543
2644
|
const noteH = wrappedLines.length * NOTE_LINE_H + NOTE_PAD_V * 2;
|
|
2544
2645
|
const maxLineLen = Math.max(...wrappedLines.map((l) => l.length));
|
|
2545
2646
|
const noteW = Math.min(
|
|
2546
|
-
|
|
2647
|
+
maxW,
|
|
2547
2648
|
Math.max(80, maxLineLen * NOTE_CHAR_W + NOTE_PAD_H * 2 + NOTE_FOLD)
|
|
2548
2649
|
);
|
|
2650
|
+
// Shift notes past self-call loopback when applicable
|
|
2651
|
+
const rightOffset =
|
|
2652
|
+
afterSelfCall && isRight
|
|
2653
|
+
? ACTIVATION_WIDTH / 2 + SELF_CALL_WIDTH + NOTE_GAP
|
|
2654
|
+
: ACTIVATION_WIDTH + NOTE_GAP;
|
|
2549
2655
|
const noteX = isRight
|
|
2550
|
-
? px +
|
|
2656
|
+
? px + rightOffset
|
|
2551
2657
|
: px - ACTIVATION_WIDTH - NOTE_GAP - noteW;
|
|
2552
2658
|
|
|
2553
2659
|
const noteG = svg
|
|
@@ -2621,8 +2727,13 @@ export function renderSequenceDiagram(
|
|
|
2621
2727
|
} else {
|
|
2622
2728
|
// --- Collapsed note: compact indicator ---
|
|
2623
2729
|
const cFold = 6;
|
|
2730
|
+
const afterSelfCallC = isNoteAfterSelfCall(el);
|
|
2731
|
+
const rightOffsetC =
|
|
2732
|
+
afterSelfCallC && isRight
|
|
2733
|
+
? ACTIVATION_WIDTH / 2 + SELF_CALL_WIDTH + NOTE_GAP
|
|
2734
|
+
: ACTIVATION_WIDTH + NOTE_GAP;
|
|
2624
2735
|
const noteX = isRight
|
|
2625
|
-
? px +
|
|
2736
|
+
? px + rightOffsetC
|
|
2626
2737
|
: px - ACTIVATION_WIDTH - NOTE_GAP - COLLAPSED_NOTE_W;
|
|
2627
2738
|
|
|
2628
2739
|
const noteG = svg
|
package/src/sharing.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface CompactViewState {
|
|
|
30
30
|
rps?: number; // RPS multiplier (infra)
|
|
31
31
|
spd?: number; // playback speed (infra)
|
|
32
32
|
io?: Record<string, number>; // instance overrides (infra)
|
|
33
|
+
hd?: boolean; // hide descriptions (mindmap)
|
|
34
|
+
cbd?: boolean; // color by depth (mindmap)
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export interface DecodedDiagramUrl {
|
package/src/sitemap/layout.ts
CHANGED
|
@@ -396,15 +396,15 @@ export function layoutSitemap(
|
|
|
396
396
|
const pageNodeIds = new Set<string>();
|
|
397
397
|
const collapsedContainerIds = new Set<string>();
|
|
398
398
|
|
|
399
|
-
// Identify containers vs pages, and detect collapsed
|
|
399
|
+
// Identify containers vs pages, and detect collapsed containers.
|
|
400
|
+
// A container is collapsed iff hiddenCounts records it with a positive count
|
|
401
|
+
// (meaning collapseSitemapTree pruned its descendants). Source-level empty
|
|
402
|
+
// containers (never had children) are NOT treated as collapsed.
|
|
400
403
|
for (const flat of flatNodes) {
|
|
401
404
|
if (flat.sitemapNode.isContainer) {
|
|
402
405
|
containerIds.add(flat.sitemapNode.id);
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
(f) => f.parentContainerId === flat.sitemapNode.id
|
|
406
|
-
);
|
|
407
|
-
if (!hasAnyChild) {
|
|
406
|
+
const hidden = hiddenCounts?.get(flat.sitemapNode.id) ?? 0;
|
|
407
|
+
if (hidden > 0) {
|
|
408
408
|
collapsedContainerIds.add(flat.sitemapNode.id);
|
|
409
409
|
}
|
|
410
410
|
} else {
|
|
@@ -412,16 +412,31 @@ export function layoutSitemap(
|
|
|
412
412
|
}
|
|
413
413
|
}
|
|
414
414
|
|
|
415
|
+
// Sibling-page floor for collapsed containers — prevents collapsed containers
|
|
416
|
+
// from rendering smaller than meta-rich page cards in the same layout.
|
|
417
|
+
let pageMaxW = 0;
|
|
418
|
+
let pageMaxH = 0;
|
|
419
|
+
for (const f of flatNodes) {
|
|
420
|
+
if (!f.sitemapNode.isContainer) {
|
|
421
|
+
if (f.width > pageMaxW) pageMaxW = f.width;
|
|
422
|
+
if (f.height > pageMaxH) pageMaxH = f.height;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
415
426
|
// Add nodes to dagre
|
|
416
427
|
for (const flat of flatNodes) {
|
|
417
428
|
const node = flat.sitemapNode;
|
|
418
429
|
if (node.isContainer) {
|
|
419
430
|
if (collapsedContainerIds.has(node.id)) {
|
|
420
|
-
// Collapsed container — regular node with explicit dimensions
|
|
431
|
+
// Collapsed container — regular node with explicit dimensions.
|
|
432
|
+
// Floor to max page-card dims so collapsed containers never look
|
|
433
|
+
// smaller than sibling page cards.
|
|
434
|
+
const flooredW = Math.max(flat.width, pageMaxW);
|
|
435
|
+
const flooredH = Math.max(flat.height, pageMaxH);
|
|
421
436
|
g.setNode(node.id, {
|
|
422
437
|
label: node.label,
|
|
423
|
-
width:
|
|
424
|
-
height:
|
|
438
|
+
width: flooredW,
|
|
439
|
+
height: flooredH,
|
|
425
440
|
});
|
|
426
441
|
} else {
|
|
427
442
|
// Regular container — compound node with padding for child layout
|
|
@@ -546,7 +561,15 @@ export function layoutSitemap(
|
|
|
546
561
|
node.children.length > 0 || (hc != null && hc > 0) || undefined,
|
|
547
562
|
});
|
|
548
563
|
} else {
|
|
549
|
-
// Fallback
|
|
564
|
+
// Fallback — still apply the floor for consistency
|
|
565
|
+
const isCollapsed = collapsedContainerIds.has(node.id);
|
|
566
|
+
const flooredW = isCollapsed
|
|
567
|
+
? Math.max(flat.width, pageMaxW)
|
|
568
|
+
: flat.width;
|
|
569
|
+
const fallbackH = isCollapsed
|
|
570
|
+
? flat.height
|
|
571
|
+
: labelHeight + CONTAINER_PAD_BOTTOM;
|
|
572
|
+
const flooredH = isCollapsed ? Math.max(fallbackH, pageMaxH) : fallbackH;
|
|
550
573
|
layoutContainers.push({
|
|
551
574
|
nodeId: node.id,
|
|
552
575
|
label: node.label,
|
|
@@ -556,8 +579,8 @@ export function layoutSitemap(
|
|
|
556
579
|
tagMetadata: flat.fullMeta,
|
|
557
580
|
x: MARGIN,
|
|
558
581
|
y: MARGIN,
|
|
559
|
-
width:
|
|
560
|
-
height:
|
|
582
|
+
width: flooredW,
|
|
583
|
+
height: flooredH,
|
|
561
584
|
labelHeight,
|
|
562
585
|
hiddenCount: hc,
|
|
563
586
|
hasChildren:
|