@diagrammo/dgmo 0.6.0 → 0.6.2

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.
@@ -99,13 +99,6 @@ export interface InfraTagGroup {
99
99
  lineNumber: number;
100
100
  }
101
101
 
102
- export interface InfraScenario {
103
- name: string;
104
- /** Node property overrides: nodeId -> { key: value } */
105
- overrides: Record<string, Record<string, string | number>>;
106
- lineNumber: number;
107
- }
108
-
109
102
  export interface ParsedInfra {
110
103
  type: 'infra';
111
104
  title: string | null;
@@ -115,7 +108,6 @@ export interface ParsedInfra {
115
108
  edges: InfraEdge[];
116
109
  groups: InfraGroup[];
117
110
  tagGroups: InfraTagGroup[];
118
- scenarios: InfraScenario[];
119
111
  options: Record<string, string>;
120
112
  diagnostics: DgmoError[];
121
113
  error: string | null;
@@ -128,9 +120,7 @@ export interface ParsedInfra {
128
120
  export interface InfraComputeParams {
129
121
  rps?: number; // override edge rps (for slider)
130
122
  instanceOverrides?: Record<string, number>; // nodeId -> instance count override
131
- scenario?: InfraScenario | null; // apply a named scenario's overrides
132
- /** Per-node property overrides: nodeId -> { propertyKey: numericValue }.
133
- * Applied after scenario overrides. Lets sliders adjust cache-hit, etc. */
123
+ /** Per-node property overrides: nodeId -> { propertyKey: numericValue }. */
134
124
  propertyOverrides?: Record<string, Record<string, number>>;
135
125
  /** Set of group IDs that should be treated as collapsed (virtual nodes). */
136
126
  collapsedGroups?: Set<string>;
@@ -28,8 +28,8 @@ export interface ISLayoutEdge {
28
28
  status: import('./types').InitiativeStatus;
29
29
  lineNumber: number;
30
30
  // Layout contract for points[]:
31
- // Back-edges: 3 points — [src.bottom/top_center, arc_control, tgt.bottom/top_center]
32
- // Y-displaced: 3 points — [src.bottom/top_center, diagonal_mid, tgt.left_center]
31
+ // Back-edges: 5 points — [src.top/bottom_center, depart_ctrl, arc_control, approach_ctrl, tgt.top/bottom_center]
32
+ // Top/bottom-exit: 4 points — [src.top/bottom_center, depart_ctrl, tgt_approach, tgt.left_center]
33
33
  // 4-point elbow: points[0] and points[last] pinned at node center Y; interior fans via yOffset
34
34
  // fixedDagrePoints: points[0]=src.right, points[last]=tgt.left; interior from dagre
35
35
  points: { x: number; y: number }[];
@@ -81,6 +81,7 @@ const PARALLEL_EDGE_MARGIN = 12; // total vertical margin reserved at top+bottom
81
81
  const MAX_PARALLEL_EDGES = 5; // at most this many edges rendered between any directed source→target pair
82
82
  const BACK_EDGE_MARGIN = 40; // clearance below/above nodes for back-edge arcs (~half NODESEP)
83
83
  const BACK_EDGE_MIN_SPREAD = Math.round(NODE_WIDTH * 0.75); // minimum horizontal arc spread for near-same-X back-edges
84
+ const TOP_EXIT_STEP = 10; // px: control-point offset giving near-vertical departure tangent for top/bottom-exit elbows
84
85
  const CHAR_WIDTH_RATIO = 0.6;
85
86
  const NODE_FONT_SIZE = 13;
86
87
  const NODE_TEXT_PADDING = 12;
@@ -221,15 +222,14 @@ export function layoutInitiativeStatus(
221
222
  const dagreEdge = g.edge(edge.source, edge.target, `e${i}`);
222
223
  const dagrePoints: { x: number; y: number }[] = dagreEdge?.points ?? [];
223
224
  const hasIntermediateRank = allNodeX.some((x) => x > src.x + 20 && x < tgt.x - 20);
224
- const step = Math.min((enterX - exitX) * 0.15, 20);
225
+ const step = Math.max(0, Math.min((enterX - exitX) * 0.15, 20)); // clamped ≥0: guards overlapping nodes
225
226
 
226
- // 4-branch routing: isBackEdge → isYDisplaced → 4-point elbow → fixedDagrePoints
227
- const isBackEdge = tgt.x < src.x - 5; // 5px epsilon: same-rank same-X nodes must not false-match
228
- const isYDisplaced = !isBackEdge
229
- && Math.abs(tgt.y - src.y) > NODESEP;
230
- // Note: hasIntermediateRank guard intentionally omitted from isYDisplaced the > NODESEP threshold
231
- // already filters normal adjacent-rank fans (which spread by ~NODESEP); the guard would block the
232
- // original use case (fan targets far below source in the same adjacent rank).
227
+ // 5-branch routing: isBackEdge → isTopExitisBottomExit → 4-point elbow → fixedDagrePoints
228
+ const isBackEdge = tgt.x < src.x - 5; // 5px epsilon: same-rank same-X nodes must not false-match
229
+ // Guards: tgt.x > src.x (strict) keeps step positive; !hasIntermediateRank defers multi-rank
230
+ // displaced edges to fixedDagrePoints so dagre can route around intermediate nodes.
231
+ const isTopExit = !isBackEdge && tgt.x > src.x && !hasIntermediateRank && tgt.y < src.y - NODESEP;
232
+ const isBottomExit = !isBackEdge && tgt.x > src.x && !hasIntermediateRank && tgt.y > src.y + NODESEP;
233
233
 
234
234
  let points: { x: number; y: number }[];
235
235
 
@@ -248,33 +248,52 @@ export function layoutInitiativeStatus(
248
248
  ? rawMidX + spreadDir * BACK_EDGE_MIN_SPREAD
249
249
  : rawMidX;
250
250
  const midX = Math.min(src.x, Math.max(tgt.x, unclamped));
251
+ // Clamped departure/approach control points give near-orthogonal tangents at node edges.
252
+ // For narrow back-edges (|src.x - tgt.x| < 2*TOP_EXIT_STEP), clamps degrade to midX±1 — valid.
253
+ const srcDepart = Math.max(midX + 1, src.x - TOP_EXIT_STEP);
254
+ const tgtApproach = Math.min(midX - 1, tgt.x + TOP_EXIT_STEP);
251
255
  if (routeAbove) {
252
256
  const arcY = Math.min(src.y - srcHalfH, tgt.y - tgtHalfH) - BACK_EDGE_MARGIN;
253
257
  points = [
254
- { x: src.x, y: src.y - srcHalfH },
255
- { x: midX, y: arcY },
256
- { x: tgt.x, y: tgt.y - tgtHalfH },
258
+ { x: src.x, y: src.y - srcHalfH },
259
+ { x: srcDepart, y: src.y - srcHalfH - TOP_EXIT_STEP },
260
+ { x: midX, y: arcY },
261
+ { x: tgtApproach, y: tgt.y - tgtHalfH - TOP_EXIT_STEP },
262
+ { x: tgt.x, y: tgt.y - tgtHalfH },
257
263
  ];
258
264
  } else {
259
265
  const arcY = Math.max(src.y + srcHalfH, tgt.y + tgtHalfH) + BACK_EDGE_MARGIN;
260
266
  points = [
261
- { x: src.x, y: src.y + srcHalfH },
262
- { x: midX, y: arcY },
263
- { x: tgt.x, y: tgt.y + tgtHalfH },
267
+ { x: src.x, y: src.y + srcHalfH },
268
+ { x: srcDepart, y: src.y + srcHalfH + TOP_EXIT_STEP },
269
+ { x: midX, y: arcY },
270
+ { x: tgtApproach, y: tgt.y + tgtHalfH + TOP_EXIT_STEP },
271
+ { x: tgt.x, y: tgt.y + tgtHalfH },
264
272
  ];
265
273
  }
266
- } else if (isYDisplaced) {
267
- // 3-point diagonal: exit bottom/top-center of source, enter left-center of target.
268
- // Using src.x (center) not exitX (right side) avoids overlapping the parallel bundle.
269
- const exitY = tgt.y > src.y + NODESEP
270
- ? src.y + src.height / 2 // target is below exit bottom
271
- : src.y - src.height / 2; // target is above exit top
272
- const midX = Math.max(src.x + 1, (src.x + enterX) / 2); // +1 ensures strictly increasing X
273
- const midY = (exitY + tgt.y) / 2;
274
+ } else if (isTopExit) {
275
+ // 4-point top-exit elbow: exits top of source ~vertically, arrives left of target horizontally.
276
+ // Top exit keeps this edge ABOVE the horizontal right-exit bundle avoids crossings.
277
+ // yOffset repurposed as X-spread for top/bottom-exit branches (same magnitude, different axis).
278
+ // p1x: floor at src.x prevents negative-yOffset edges from going left of origin (breaks monotone X);
279
+ // ceiling at midpoint-1 prevents overshooting for large positive yOffset (±32px for 5 parallel edges).
280
+ const exitY = src.y - src.height / 2;
281
+ const p1x = Math.min(Math.max(src.x, src.x + yOffset + TOP_EXIT_STEP), (src.x + enterX) / 2 - 1);
274
282
  points = [
275
- { x: src.x, y: exitY },
276
- { x: midX, y: midY },
277
- { x: enterX, y: tgt.y },
283
+ { x: src.x, y: exitY },
284
+ { x: p1x, y: exitY - TOP_EXIT_STEP },
285
+ { x: enterX - step, y: tgt.y + yOffset },
286
+ { x: enterX, y: tgt.y },
287
+ ];
288
+ } else if (isBottomExit) {
289
+ // 4-point bottom-exit elbow: mirror of top-exit. Keeps edge BELOW the horizontal bundle.
290
+ const exitY = src.y + src.height / 2;
291
+ const p1x = Math.min(Math.max(src.x, src.x + yOffset + TOP_EXIT_STEP), (src.x + enterX) / 2 - 1);
292
+ points = [
293
+ { x: src.x, y: exitY },
294
+ { x: p1x, y: exitY + TOP_EXIT_STEP },
295
+ { x: enterX - step, y: tgt.y + yOffset },
296
+ { x: enterX, y: tgt.y },
278
297
  ];
279
298
  } else if (tgt.x > src.x && !hasIntermediateRank) {
280
299
  // 4-point elbow: adjacent-rank forward edges (unchanged)
@@ -10,6 +10,13 @@ import { renderInlineText } from '../utils/inline-markdown';
10
10
  import type { ParsedKanban, KanbanColumn, KanbanCard, KanbanTagGroup } from './types';
11
11
  import { parseKanban } from './parser';
12
12
  import { isArchiveColumn } from './mutations';
13
+ import {
14
+ LEGEND_HEIGHT,
15
+ LEGEND_PILL_FONT_SIZE,
16
+ LEGEND_DOT_R,
17
+ LEGEND_ENTRY_FONT_SIZE,
18
+ LEGEND_CAPSULE_PAD,
19
+ } from '../utils/legend-constants';
13
20
 
14
21
  // ============================================================
15
22
  // Constants
@@ -36,10 +43,6 @@ const CARD_META_FONT_SIZE = 10;
36
43
  const WIP_FONT_SIZE = 10;
37
44
  const COLUMN_RADIUS = 8;
38
45
  const COLUMN_HEADER_RADIUS = 8;
39
- const LEGEND_HEIGHT = 28;
40
- const LEGEND_FONT_SIZE = 11;
41
- const LEGEND_DOT_R = 4;
42
- const LEGEND_ENTRY_FONT_SIZE = 10;
43
46
 
44
47
  // ============================================================
45
48
  // Tag color resolution
@@ -106,9 +109,8 @@ function computeLayout(
106
109
  parsed: ParsedKanban,
107
110
  _palette: PaletteColors
108
111
  ): { columns: ColumnLayout[]; totalWidth: number; totalHeight: number } {
109
- // Title and legend share one row
110
- const hasHeader = !!parsed.title || parsed.tagGroups.length > 0;
111
- const headerHeight = hasHeader ? Math.max(TITLE_HEIGHT, LEGEND_HEIGHT) + 8 : 0;
112
+ // Title row
113
+ const headerHeight = parsed.title ? TITLE_HEIGHT + 8 : 0;
112
114
  const startY = DIAGRAM_PADDING + headerHeight;
113
115
 
114
116
  // Estimate column widths based on content
@@ -189,7 +191,8 @@ function computeLayout(
189
191
  }
190
192
 
191
193
  const totalWidth = currentX - COLUMN_GAP + DIAGRAM_PADDING;
192
- const totalHeight = startY + maxColumnHeight + DIAGRAM_PADDING;
194
+ const legendSpace = parsed.tagGroups.length > 0 ? LEGEND_HEIGHT : 0;
195
+ const totalHeight = startY + maxColumnHeight + DIAGRAM_PADDING + legendSpace;
193
196
 
194
197
  return { columns: columnLayouts, totalWidth, totalHeight };
195
198
  }
@@ -237,18 +240,19 @@ export function renderKanban(
237
240
  .text(parsed.title);
238
241
  }
239
242
 
240
- // Legend (same row as title)
243
+ // Legend (bottom of diagram)
241
244
  if (parsed.tagGroups.length > 0) {
242
- const legendY = DIAGRAM_PADDING;
243
- // Start legend after title text
244
- const titleTextWidth = parsed.title
245
- ? parsed.title.length * TITLE_FONT_SIZE * 0.6 + 16
246
- : 0;
247
- let legendX = DIAGRAM_PADDING + titleTextWidth;
245
+ const legendY = height - LEGEND_HEIGHT;
246
+ let legendX = DIAGRAM_PADDING;
248
247
  const groupBg = isDark
249
248
  ? mix(palette.surface, palette.bg, 50)
250
249
  : mix(palette.surface, palette.bg, 30);
251
- const capsulePad = 4;
250
+ const capsulePad = LEGEND_CAPSULE_PAD;
251
+
252
+ const legendContainer = svg.append('g').attr('class', 'kanban-legend');
253
+ if (activeTagGroup) {
254
+ legendContainer.attr('data-legend-active', activeTagGroup.toLowerCase());
255
+ }
252
256
 
253
257
  for (const group of parsed.tagGroups) {
254
258
  const isActive =
@@ -257,7 +261,7 @@ export function renderKanban(
257
261
  // When a group is active, skip all other groups entirely
258
262
  if (activeTagGroup != null && !isActive) continue;
259
263
 
260
- const pillTextWidth = group.name.length * LEGEND_FONT_SIZE * 0.6;
264
+ const pillTextWidth = group.name.length * LEGEND_PILL_FONT_SIZE * 0.6;
261
265
  const pillWidth = pillTextWidth + 16;
262
266
 
263
267
  // Measure total capsule width for active groups (pill + entries)
@@ -272,7 +276,7 @@ export function renderKanban(
272
276
 
273
277
  // Outer capsule background for active group
274
278
  if (isActive) {
275
- svg
279
+ legendContainer
276
280
  .append('rect')
277
281
  .attr('x', legendX)
278
282
  .attr('y', legendY)
@@ -286,7 +290,7 @@ export function renderKanban(
286
290
 
287
291
  // Pill background
288
292
  const pillBg = isActive ? palette.bg : groupBg;
289
- svg
293
+ legendContainer
290
294
  .append('rect')
291
295
  .attr('x', pillX)
292
296
  .attr('y', legendY + (isActive ? capsulePad : 0))
@@ -298,7 +302,7 @@ export function renderKanban(
298
302
  .attr('data-legend-group', group.name.toLowerCase());
299
303
 
300
304
  if (isActive) {
301
- svg
305
+ legendContainer
302
306
  .append('rect')
303
307
  .attr('x', pillX)
304
308
  .attr('y', legendY + capsulePad)
@@ -311,11 +315,11 @@ export function renderKanban(
311
315
  }
312
316
 
313
317
  // Pill text
314
- svg
318
+ legendContainer
315
319
  .append('text')
316
320
  .attr('x', pillX + pillWidth / 2)
317
- .attr('y', legendY + LEGEND_HEIGHT / 2 + LEGEND_FONT_SIZE / 2 - 2)
318
- .attr('font-size', LEGEND_FONT_SIZE)
321
+ .attr('y', legendY + LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
322
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
319
323
  .attr('font-weight', '500')
320
324
  .attr('fill', isActive ? palette.text : palette.textMuted)
321
325
  .attr('text-anchor', 'middle')
@@ -325,7 +329,7 @@ export function renderKanban(
325
329
  if (isActive) {
326
330
  let entryX = pillX + pillWidth + 4;
327
331
  for (const entry of group.entries) {
328
- const entryG = svg
332
+ const entryG = legendContainer
329
333
  .append('g')
330
334
  .attr('data-legend-entry', entry.value.toLowerCase())
331
335
  .style('cursor', 'pointer');
@@ -10,6 +10,23 @@ import type { ParsedOrg } from './parser';
10
10
  import type { OrgLayoutResult, OrgLayoutNode } from './layout';
11
11
  import { parseOrg } from './parser';
12
12
  import { layoutOrg } from './layout';
13
+ import {
14
+ LEGEND_HEIGHT,
15
+ LEGEND_PILL_PAD,
16
+ LEGEND_PILL_FONT_SIZE,
17
+ LEGEND_PILL_FONT_W,
18
+ LEGEND_CAPSULE_PAD,
19
+ LEGEND_DOT_R,
20
+ LEGEND_ENTRY_FONT_SIZE,
21
+ LEGEND_ENTRY_FONT_W,
22
+ LEGEND_ENTRY_DOT_GAP,
23
+ LEGEND_ENTRY_TRAIL,
24
+ LEGEND_GROUP_GAP,
25
+ LEGEND_EYE_SIZE,
26
+ LEGEND_EYE_GAP,
27
+ EYE_OPEN_PATH,
28
+ EYE_CLOSED_PATH,
29
+ } from '../utils/legend-constants';
13
30
 
14
31
  // ============================================================
15
32
  // Constants
@@ -37,27 +54,7 @@ const CONTAINER_HEADER_HEIGHT = 28;
37
54
  const COLLAPSE_BAR_HEIGHT = 6;
38
55
  const COLLAPSE_BAR_INSET = 0;
39
56
 
40
- // Legend (kanban-style pills)
41
- const LEGEND_HEIGHT = 28;
42
- const LEGEND_PILL_PAD = 16;
43
- const LEGEND_PILL_FONT_SIZE = 11;
44
- const LEGEND_PILL_FONT_W = LEGEND_PILL_FONT_SIZE * 0.6;
45
- const LEGEND_CAPSULE_PAD = 4;
46
- const LEGEND_DOT_R = 4;
47
- const LEGEND_ENTRY_FONT_SIZE = 10;
48
- const LEGEND_ENTRY_FONT_W = LEGEND_ENTRY_FONT_SIZE * 0.6;
49
- const LEGEND_ENTRY_DOT_GAP = 4;
50
- const LEGEND_ENTRY_TRAIL = 8;
51
- const LEGEND_GROUP_GAP = 12;
52
- const LEGEND_EYE_SIZE = 14;
53
- const LEGEND_EYE_GAP = 6;
54
- const LEGEND_FIXED_GAP = 8; // gap between fixed legend and scaled diagram
55
-
56
- // Eye icon SVG paths (14×14 viewBox)
57
- const EYE_OPEN_PATH =
58
- 'M1 7s2.5-5 6-5 6 5 6 5-2.5 5-6 5-6-5-6-5z M7 9.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z';
59
- const EYE_CLOSED_PATH =
60
- 'M2.5 2.5l9 9 M1.5 7s2.2-4 5.5-4c1.2 0 2.2.5 3 1.1 M12.5 7s-2.2 4-5.5 4c-1.2 0-2.2-.5-3-1.1';
57
+ const LEGEND_FIXED_GAP = 8; // gap between fixed legend and scaled diagram — local, not shared
61
58
 
62
59
  // ============================================================
63
60
  // Color helpers
@@ -117,7 +114,7 @@ export function renderOrg(
117
114
 
118
115
  const titleOffset = parsed.title ? TITLE_HEIGHT : 0;
119
116
  const legendOnly = layout.nodes.length === 0;
120
- const legendPosition = parsed.options?.['legend-position'] ?? 'top';
117
+ const legendPosition = parsed.options?.['legend-position'] ?? 'bottom';
121
118
  const hasLegend = layout.legend.length > 0;
122
119
 
123
120
  // In app mode (not export), render the legend at a fixed size outside the
@@ -512,7 +509,7 @@ export function renderOrg(
512
509
  }
513
510
 
514
511
  // Choose parent: unscaled group for fixedLegend, contentG for legend-only
515
- const legendParent = fixedLegend
512
+ const legendParentBase = fixedLegend
516
513
  ? svg
517
514
  .append('g')
518
515
  .attr('class', 'org-legend-fixed')
@@ -523,6 +520,10 @@ export function renderOrg(
523
520
  : `translate(0, ${DIAGRAM_PADDING + titleReserve})`
524
521
  )
525
522
  : contentG;
523
+ const legendParent = legendParentBase;
524
+ if (fixedLegend && activeTagGroup) {
525
+ legendParentBase.attr('data-legend-active', activeTagGroup.toLowerCase());
526
+ }
526
527
 
527
528
  for (const group of visibleGroups) {
528
529
  const isActive =
package/src/render.ts CHANGED
@@ -52,7 +52,7 @@ export async function render(
52
52
  c4Level?: 'context' | 'containers' | 'components' | 'deployment';
53
53
  c4System?: string;
54
54
  c4Container?: string;
55
- scenario?: string;
55
+ tagGroup?: string;
56
56
  },
57
57
  ): Promise<string> {
58
58
  const theme = options?.theme ?? 'light';
@@ -75,6 +75,6 @@ export async function render(
75
75
  c4Level: options?.c4Level,
76
76
  c4System: options?.c4System,
77
77
  c4Container: options?.c4Container,
78
- scenario: options?.scenario,
78
+ tagGroup: options?.tagGroup,
79
79
  });
80
80
  }
@@ -25,6 +25,19 @@ import type {
25
25
  import { isSequenceBlock, isSequenceSection, isSequenceNote } from './parser';
26
26
  import { resolveSequenceTags } from './tag-resolution';
27
27
  import type { ResolvedTagMap } from './tag-resolution';
28
+ import {
29
+ LEGEND_HEIGHT,
30
+ LEGEND_PILL_PAD,
31
+ LEGEND_PILL_FONT_SIZE,
32
+ LEGEND_PILL_FONT_W,
33
+ LEGEND_CAPSULE_PAD,
34
+ LEGEND_DOT_R,
35
+ LEGEND_ENTRY_FONT_SIZE,
36
+ LEGEND_ENTRY_FONT_W,
37
+ LEGEND_ENTRY_DOT_GAP,
38
+ LEGEND_ENTRY_TRAIL,
39
+ LEGEND_GROUP_GAP,
40
+ } from '../utils/legend-constants';
28
41
 
29
42
  // ============================================================
30
43
  // Layout Constants
@@ -54,19 +67,6 @@ const NOTE_CHARS_PER_LINE = Math.floor((NOTE_MAX_W - NOTE_PAD_H * 2 - NOTE_FOLD)
54
67
  const COLLAPSED_NOTE_H = 20;
55
68
  const COLLAPSED_NOTE_W = 40;
56
69
 
57
- // Legend rendering constants (consistent with org chart legend)
58
- const LEGEND_HEIGHT = 28;
59
- const LEGEND_PILL_PAD = 16;
60
- const LEGEND_PILL_FONT_SIZE = 11;
61
- const LEGEND_PILL_FONT_W = LEGEND_PILL_FONT_SIZE * 0.6;
62
- const LEGEND_CAPSULE_PAD = 4;
63
- const LEGEND_DOT_R = 4;
64
- const LEGEND_ENTRY_FONT_SIZE = 10;
65
- const LEGEND_ENTRY_FONT_W = LEGEND_ENTRY_FONT_SIZE * 0.6;
66
- const LEGEND_ENTRY_DOT_GAP = 4;
67
- const LEGEND_ENTRY_TRAIL = 8;
68
- const LEGEND_GROUP_GAP = 12;
69
- const LEGEND_BOTTOM_GAP = 8;
70
70
 
71
71
 
72
72
  function wrapTextLines(text: string, maxChars: number): string[] {
@@ -1282,12 +1282,10 @@ export function renderSequenceDiagram(
1282
1282
 
1283
1283
  // Compute cumulative Y positions for each step, with section dividers as stable anchors
1284
1284
  const titleOffset = title ? TITLE_HEIGHT : 0;
1285
- const legendOffset =
1286
- parsed.tagGroups.length > 0 ? LEGEND_HEIGHT + LEGEND_BOTTOM_GAP : 0;
1287
1285
  const groupOffset =
1288
1286
  groups.length > 0 ? GROUP_PADDING_TOP + GROUP_LABEL_SIZE : 0;
1289
1287
  const participantStartY =
1290
- TOP_MARGIN + titleOffset + legendOffset + PARTICIPANT_Y_OFFSET + groupOffset;
1288
+ TOP_MARGIN + titleOffset + PARTICIPANT_Y_OFFSET + groupOffset;
1291
1289
  const lifelineStartY0 = participantStartY + PARTICIPANT_BOX_HEIGHT;
1292
1290
  const hasActors = participants.some((p) => p.type === 'actor');
1293
1291
  const messageStartOffset = MESSAGE_START_OFFSET + (hasActors ? 20 : 0);
@@ -1388,11 +1386,13 @@ export function renderSequenceDiagram(
1388
1386
  participants.length * PARTICIPANT_GAP,
1389
1387
  PARTICIPANT_BOX_WIDTH + 40
1390
1388
  );
1391
- const totalHeight =
1389
+ const contentHeight =
1392
1390
  participantStartY +
1393
1391
  PARTICIPANT_BOX_HEIGHT +
1394
1392
  Math.max(lifelineLength, 40) +
1395
1393
  40;
1394
+ const legendSpace = parsed.tagGroups.length > 0 ? LEGEND_HEIGHT : 0;
1395
+ const totalHeight = contentHeight + legendSpace;
1396
1396
 
1397
1397
  const containerWidth = options?.exportWidth ?? container.getBoundingClientRect().width;
1398
1398
  const svgWidth = Math.max(totalWidth, containerWidth);
@@ -1571,7 +1571,7 @@ export function renderSequenceDiagram(
1571
1571
 
1572
1572
  // Render legend pills for tag groups
1573
1573
  if (parsed.tagGroups.length > 0) {
1574
- const legendY = TOP_MARGIN + titleOffset;
1574
+ const legendY = contentHeight;
1575
1575
  const groupBg = isDark
1576
1576
  ? mix(palette.surface, palette.bg, 50)
1577
1577
  : mix(palette.surface, palette.bg, 30);
@@ -1615,8 +1615,13 @@ export function renderSequenceDiagram(
1615
1615
  (legendItems.length - 1) * LEGEND_GROUP_GAP;
1616
1616
  let legendX = (svgWidth - totalLegendWidth) / 2;
1617
1617
 
1618
+ const legendContainer = svg.append('g').attr('class', 'sequence-legend');
1619
+ if (activeTagGroup) {
1620
+ legendContainer.attr('data-legend-active', activeTagGroup.toLowerCase());
1621
+ }
1622
+
1618
1623
  for (const item of legendItems) {
1619
- const gEl = svg
1624
+ const gEl = legendContainer
1620
1625
  .append('g')
1621
1626
  .attr('transform', `translate(${legendX}, ${legendY})`)
1622
1627
  .attr('class', 'sequence-legend-group')
@@ -699,32 +699,25 @@ export function layoutSitemap(
699
699
  activeTagGroup != null || allExpanded ? g.width : g.minifiedWidth;
700
700
 
701
701
  if (visibleGroups.length > 0) {
702
- // Top position: horizontal row above chart
702
+ // Bottom position: horizontal row below chart content
703
703
  const legendShift = LEGEND_HEIGHT + LEGEND_GROUP_GAP;
704
704
 
705
- // Push chart content down
706
- for (const n of layoutNodes) n.y += legendShift;
707
- for (const c of layoutContainers) c.y += legendShift;
708
- for (const e of layoutEdges) {
709
- for (const p of e.points) p.y += legendShift;
710
- }
711
-
712
705
  const totalGroupsWidth =
713
706
  visibleGroups.reduce((s, g) => s + effectiveW(g), 0) +
714
707
  (visibleGroups.length - 1) * LEGEND_GROUP_GAP;
715
708
 
716
- let cx = MARGIN;
709
+ // Center legend groups horizontally
710
+ const neededWidth = totalGroupsWidth + MARGIN * 2;
711
+ if (neededWidth > totalWidth) totalWidth = neededWidth;
712
+ let cx = (totalWidth - totalGroupsWidth) / 2;
713
+ const legendY = totalHeight + LEGEND_GROUP_GAP;
717
714
  for (const g of visibleGroups) {
718
715
  g.x = cx;
719
- g.y = MARGIN;
716
+ g.y = legendY;
720
717
  cx += effectiveW(g) + LEGEND_GROUP_GAP;
721
718
  }
722
719
 
723
720
  totalHeight += legendShift;
724
- const neededWidth = totalGroupsWidth + MARGIN * 2;
725
- if (neededWidth > totalWidth) {
726
- totalWidth = neededWidth;
727
- }
728
721
  }
729
722
 
730
723
  return {
@@ -15,6 +15,23 @@ import type {
15
15
  SitemapContainerBounds,
16
16
  SitemapLegendGroup,
17
17
  } from './layout';
18
+ import {
19
+ LEGEND_HEIGHT,
20
+ LEGEND_PILL_PAD,
21
+ LEGEND_PILL_FONT_SIZE,
22
+ LEGEND_PILL_FONT_W,
23
+ LEGEND_CAPSULE_PAD,
24
+ LEGEND_DOT_R,
25
+ LEGEND_ENTRY_FONT_SIZE,
26
+ LEGEND_ENTRY_FONT_W,
27
+ LEGEND_ENTRY_DOT_GAP,
28
+ LEGEND_ENTRY_TRAIL,
29
+ LEGEND_GROUP_GAP,
30
+ LEGEND_EYE_SIZE,
31
+ LEGEND_EYE_GAP,
32
+ EYE_OPEN_PATH,
33
+ EYE_CLOSED_PATH,
34
+ } from '../utils/legend-constants';
18
35
 
19
36
  // ============================================================
20
37
  // Constants
@@ -44,21 +61,7 @@ const EDGE_LABEL_FONT_SIZE = 11;
44
61
  // Collapsed-node accent bar
45
62
  const COLLAPSE_BAR_HEIGHT = 6;
46
63
 
47
- // Legend
48
- const LEGEND_HEIGHT = 28;
49
- const LEGEND_FIXED_GAP = 8;
50
- const LEGEND_PILL_PAD = 16;
51
- const LEGEND_PILL_FONT_SIZE = 11;
52
- const LEGEND_PILL_FONT_W = LEGEND_PILL_FONT_SIZE * 0.6;
53
- const LEGEND_CAPSULE_PAD = 4;
54
- const LEGEND_DOT_R = 4;
55
- const LEGEND_ENTRY_FONT_SIZE = 10;
56
- const LEGEND_ENTRY_FONT_W = LEGEND_ENTRY_FONT_SIZE * 0.6;
57
- const LEGEND_ENTRY_DOT_GAP = 4;
58
- const LEGEND_ENTRY_TRAIL = 8;
59
- const LEGEND_GROUP_GAP = 12;
60
- const LEGEND_EYE_SIZE = 14;
61
- const LEGEND_EYE_GAP = 6;
64
+ const LEGEND_FIXED_GAP = 8; // gap between fixed legend and scaled diagram — local, not shared
62
65
 
63
66
  // ============================================================
64
67
  // Color helpers
@@ -119,16 +122,17 @@ export function renderSitemap(
119
122
 
120
123
  const hasLegend = layout.legend.length > 0;
121
124
 
122
- // In app mode (not export), render the title and legend at fixed size
123
- // outside the scaled group so they stay legible on large sitemaps.
124
- // Layout order: Title → LegendDiagram content.
125
+ // In app mode (not export), render the title at fixed size outside the scaled group
126
+ // and legend at fixed size at the bottom.
127
+ // Layout order: Title → Diagram content Legend (bottom).
125
128
  const layoutLegendShift = LEGEND_HEIGHT + LEGEND_GROUP_GAP; // 40px — what layout added
126
129
  const fixedLegend = !exportDims && hasLegend;
127
130
  const fixedTitle = fixedLegend && !!parsed.title;
128
131
  const fixedTitleH = fixedTitle ? TITLE_HEIGHT : 0;
129
132
  const legendReserveH = fixedLegend ? LEGEND_HEIGHT + LEGEND_FIXED_GAP : 0;
130
- // Total fixed pixel space above the scaled content
131
- const fixedReserve = fixedTitleH + legendReserveH;
133
+ // Space reserved above content (title only), and below content (legend)
134
+ const fixedReserveTop = fixedTitleH;
135
+ const fixedReserveBottom = legendReserveH;
132
136
  // Title inside scaled group only when legend is NOT fixed
133
137
  const titleOffset = !fixedTitle && parsed.title ? TITLE_HEIGHT : 0;
134
138
 
@@ -139,14 +143,14 @@ export function renderSitemap(
139
143
  // Remove the legend space from diagram height — legend is rendered separately
140
144
  diagramH -= layoutLegendShift;
141
145
  }
142
- const availH = height - DIAGRAM_PADDING * 2 - fixedReserve;
146
+ const availH = height - DIAGRAM_PADDING * 2 - fixedReserveTop - fixedReserveBottom;
143
147
  const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
144
148
  const scaleY = availH / diagramH;
145
149
  const scale = Math.min(MAX_SCALE, scaleX, scaleY);
146
150
 
147
151
  const scaledW = diagramW * scale;
148
152
  const offsetX = (width - scaledW) / 2;
149
- const offsetY = DIAGRAM_PADDING + fixedReserve;
153
+ const offsetY = DIAGRAM_PADDING + fixedReserveTop;
150
154
 
151
155
  // Create SVG
152
156
  const svg = d3Selection
@@ -542,7 +546,10 @@ export function renderSitemap(
542
546
  const legendParent = svg
543
547
  .append('g')
544
548
  .attr('class', 'sitemap-legend-fixed')
545
- .attr('transform', `translate(0, ${DIAGRAM_PADDING + fixedTitleH})`);
549
+ .attr('transform', `translate(0, ${height - DIAGRAM_PADDING - LEGEND_HEIGHT})`);
550
+ if (activeTagGroup) {
551
+ legendParent.attr('data-legend-active', activeTagGroup.toLowerCase());
552
+ }
546
553
  renderLegend(legendParent, layout.legend, palette, isDark, activeTagGroup, width, hiddenAttributes);
547
554
  }
548
555
  }
@@ -551,12 +558,6 @@ export function renderSitemap(
551
558
  // Legend rendering
552
559
  // ============================================================
553
560
 
554
- // Eye icon SVG paths (14×14 viewBox)
555
- const EYE_OPEN_PATH =
556
- 'M1 7s2.5-5 6-5 6 5 6 5-2.5 5-6 5-6-5-6-5z M7 9.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z';
557
- const EYE_CLOSED_PATH =
558
- 'M2.5 2.5l9 9 M1.5 7s2.2-4 5.5-4c1.2 0 2.2.5 3 1.1 M12.5 7s-2.2 4-5.5 4c-1.2 0-2.2-.5-3-1.1';
559
-
560
561
  function renderLegend(
561
562
  parent: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
562
563
  legendGroups: SitemapLegendGroup[],
@@ -0,0 +1,25 @@
1
+ // ============================================================
2
+ // Shared legend rendering constants
3
+ // All renderers import from here to stay in sync.
4
+ // ============================================================
5
+
6
+ export const LEGEND_HEIGHT = 28;
7
+ export const LEGEND_PILL_PAD = 16;
8
+ export const LEGEND_PILL_FONT_SIZE = 11;
9
+ export const LEGEND_PILL_FONT_W = LEGEND_PILL_FONT_SIZE * 0.6;
10
+ export const LEGEND_CAPSULE_PAD = 4;
11
+ export const LEGEND_DOT_R = 4;
12
+ export const LEGEND_ENTRY_FONT_SIZE = 10;
13
+ export const LEGEND_ENTRY_FONT_W = LEGEND_ENTRY_FONT_SIZE * 0.6;
14
+ export const LEGEND_ENTRY_DOT_GAP = 4;
15
+ export const LEGEND_ENTRY_TRAIL = 8;
16
+ export const LEGEND_GROUP_GAP = 12;
17
+ export const LEGEND_EYE_SIZE = 14;
18
+ export const LEGEND_EYE_GAP = 6;
19
+
20
+ // Eye icon SVG paths (14×14 viewBox)
21
+ // Present only in org and sitemap legends (metadata visibility toggle)
22
+ export const EYE_OPEN_PATH =
23
+ 'M1 7s2.5-5 6-5 6 5 6 5-2.5 5-6 5-6-5-6-5z M7 9.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z';
24
+ export const EYE_CLOSED_PATH =
25
+ 'M2.5 2.5l9 9 M1.5 7s2.2-4 5.5-4c1.2 0 2.2.5 3 1.1 M12.5 7s-2.2 4-5.5 4c-1.2 0-2.2-.5-3-1.1';