@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.
Files changed (53) hide show
  1. package/dist/cli.cjs +92 -90
  2. package/dist/editor.cjs +13 -1
  3. package/dist/editor.cjs.map +1 -1
  4. package/dist/editor.js +13 -1
  5. package/dist/editor.js.map +1 -1
  6. package/dist/highlight.cjs +13 -1
  7. package/dist/highlight.cjs.map +1 -1
  8. package/dist/highlight.js +13 -1
  9. package/dist/highlight.js.map +1 -1
  10. package/dist/index.cjs +4144 -940
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +318 -84
  13. package/dist/index.d.ts +318 -84
  14. package/dist/index.js +4132 -938
  15. package/dist/index.js.map +1 -1
  16. package/docs/guide/chart-mindmap.md +198 -0
  17. package/docs/guide/chart-sequence.md +23 -1
  18. package/docs/guide/chart-wireframe.md +100 -0
  19. package/docs/guide/index.md +8 -0
  20. package/docs/language-reference.md +137 -2
  21. package/package.json +1 -1
  22. package/src/boxes-and-lines/collapse.ts +21 -3
  23. package/src/boxes-and-lines/layout.ts +51 -9
  24. package/src/boxes-and-lines/parser.ts +8 -1
  25. package/src/boxes-and-lines/renderer.ts +121 -23
  26. package/src/boxes-and-lines/types.ts +1 -0
  27. package/src/completion.ts +26 -0
  28. package/src/d3.ts +153 -32
  29. package/src/dgmo-router.ts +6 -0
  30. package/src/editor/keywords.ts +12 -0
  31. package/src/graph/layout.ts +73 -9
  32. package/src/graph/state-collapse.ts +78 -0
  33. package/src/graph/state-renderer.ts +139 -34
  34. package/src/index.ts +28 -0
  35. package/src/kanban/renderer.ts +303 -57
  36. package/src/mindmap/collapse.ts +88 -0
  37. package/src/mindmap/layout.ts +605 -0
  38. package/src/mindmap/parser.ts +379 -0
  39. package/src/mindmap/renderer.ts +543 -0
  40. package/src/mindmap/text-wrap.ts +207 -0
  41. package/src/mindmap/types.ts +55 -0
  42. package/src/render.ts +18 -21
  43. package/src/sequence/renderer.ts +129 -18
  44. package/src/sharing.ts +2 -0
  45. package/src/sitemap/layout.ts +35 -12
  46. package/src/utils/export-container.ts +3 -2
  47. package/src/utils/legend-d3.ts +1 -0
  48. package/src/utils/legend-layout.ts +2 -2
  49. package/src/utils/parsing.ts +2 -0
  50. package/src/wireframe/layout.ts +460 -0
  51. package/src/wireframe/parser.ts +956 -0
  52. package/src/wireframe/renderer.ts +1293 -0
  53. 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 orgExportState from legendState if provided
90
- const legendExportState = options?.legendState
91
- ? {
92
- activeTagGroup: options.legendState.activeGroup ?? null,
93
- hiddenAttributes: options.legendState.hiddenAttributes
94
- ? new Set(options.legendState.hiddenAttributes)
95
- : undefined,
96
- }
97
- : undefined;
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
- content,
112
- theme,
113
- paletteColors,
114
- legendExportState,
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
  }
@@ -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
- // Find the first visible message index in an element subtree
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 = messages.indexOf(el as SequenceMessage);
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 = (text: string): number => {
1125
- const lines = wrapTextLines(text, NOTE_CHARS_PER_LINE);
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
- // NOTE_OFFSET_BELOW (gap above first note)
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
- let totalExtent = NOTE_OFFSET_BELOW;
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) + NOTE_OFFSET_BELOW;
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
- const SELF_CALL_WIDTH = 30;
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 wrappedLines = wrapTextLines(el.text, NOTE_CHARS_PER_LINE);
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
- NOTE_MAX_W,
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 + ACTIVATION_WIDTH + NOTE_GAP
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 + ACTIVATION_WIDTH + NOTE_GAP
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 {
@@ -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 (empty) containers
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
- // A container is "collapsed" if it has no children at all in the flat list
404
- const hasAnyChild = flatNodes.some(
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: flat.width,
424
- height: flat.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: flat.width,
560
- height: labelHeight + CONTAINER_PAD_BOTTOM,
582
+ width: flooredW,
583
+ height: flooredH,
561
584
  labelHeight,
562
585
  hiddenCount: hc,
563
586
  hasChildren: