@diagrammo/dgmo 0.6.0 → 0.6.1

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.
@@ -16,6 +16,19 @@ import type { InfraRole } from './roles';
16
16
  import { parseInfra } from './parser';
17
17
  import { computeInfra } from './compute';
18
18
  import { layoutInfra } from './layout';
19
+ import {
20
+ LEGEND_HEIGHT,
21
+ LEGEND_PILL_PAD,
22
+ LEGEND_PILL_FONT_SIZE,
23
+ LEGEND_PILL_FONT_W,
24
+ LEGEND_CAPSULE_PAD,
25
+ LEGEND_DOT_R,
26
+ LEGEND_ENTRY_FONT_SIZE,
27
+ LEGEND_ENTRY_FONT_W,
28
+ LEGEND_ENTRY_DOT_GAP,
29
+ LEGEND_ENTRY_TRAIL,
30
+ LEGEND_GROUP_GAP,
31
+ } from '../utils/legend-constants';
19
32
 
20
33
  // ============================================================
21
34
  // Constants
@@ -39,22 +52,7 @@ const NODE_PAD_BOTTOM = 10;
39
52
  const COLLAPSE_BAR_HEIGHT = 6;
40
53
  const COLLAPSE_BAR_INSET = 0;
41
54
 
42
- // Legend pill/capsule constants (matching org chart style)
43
- const LEGEND_HEIGHT = 28;
44
- const LEGEND_PILL_PAD = 16;
45
- const LEGEND_PILL_FONT_SIZE = 11;
46
- const LEGEND_PILL_FONT_W = LEGEND_PILL_FONT_SIZE * 0.6;
47
- const LEGEND_CAPSULE_PAD = 4;
48
- const LEGEND_DOT_R = 4;
49
- const LEGEND_ENTRY_FONT_SIZE = 10;
50
- const LEGEND_ENTRY_FONT_W = LEGEND_ENTRY_FONT_SIZE * 0.6;
51
- const LEGEND_ENTRY_DOT_GAP = 4;
52
- const LEGEND_ENTRY_TRAIL = 8;
53
- const LEGEND_GROUP_GAP = 12;
54
- const LEGEND_FIXED_GAP = 16; // gap between fixed legend and scaled diagram
55
- const SPEED_BADGE_H_PAD = 5; // horizontal padding inside active speed badge
56
- const SPEED_BADGE_V_PAD = 3; // vertical padding inside active speed badge
57
- const SPEED_BADGE_GAP = 6; // gap between speed option slots
55
+ const LEGEND_FIXED_GAP = 16; // gap between fixed legend and scaled diagram — local, not shared
58
56
 
59
57
  // Health colors (from UX spec)
60
58
  const COLOR_HEALTHY = '#22c55e';
@@ -1311,21 +1309,6 @@ export function computeInfraLegendGroups(
1311
1309
  return groups;
1312
1310
  }
1313
1311
 
1314
- /** Compute total width for the playback pill (speed only). */
1315
- function computePlaybackWidth(playback: InfraPlaybackState | undefined): number {
1316
- if (!playback) return 0;
1317
- const pillWidth = 'Playback'.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
1318
- if (!playback.expanded) return pillWidth;
1319
-
1320
- let entriesW = 8; // gap after pill
1321
- entriesW += LEGEND_PILL_FONT_SIZE * 0.8 + 6; // play/pause
1322
- for (const s of playback.speedOptions) {
1323
- entriesW += `${s}x`.length * LEGEND_ENTRY_FONT_W + SPEED_BADGE_H_PAD * 2 + SPEED_BADGE_GAP;
1324
- }
1325
- return LEGEND_CAPSULE_PAD * 2 + pillWidth + entriesW;
1326
- }
1327
-
1328
- /** Whether a separate Scenario pill should render. */
1329
1312
  function renderLegend(
1330
1313
  rootSvg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
1331
1314
  legendGroups: InfraLegendGroup[],
@@ -1334,21 +1317,21 @@ function renderLegend(
1334
1317
  palette: PaletteColors,
1335
1318
  isDark: boolean,
1336
1319
  activeGroup: string | null,
1337
- playback?: InfraPlaybackState,
1338
1320
  ) {
1339
- if (legendGroups.length === 0 && !playback) return;
1321
+ if (legendGroups.length === 0) return;
1340
1322
 
1341
1323
  const legendG = rootSvg.append('g')
1342
1324
  .attr('transform', `translate(0, ${legendY})`);
1343
1325
 
1326
+ if (activeGroup) {
1327
+ legendG.attr('data-legend-active', activeGroup.toLowerCase());
1328
+ }
1329
+
1344
1330
  // Compute centered positions
1345
1331
  const effectiveW = (g: InfraLegendGroup) =>
1346
1332
  activeGroup != null && g.name.toLowerCase() === activeGroup.toLowerCase() ? g.width : g.minifiedWidth;
1347
- const playbackW = computePlaybackWidth(playback);
1348
- const trailingGaps = legendGroups.length > 0 && playbackW > 0 ? LEGEND_GROUP_GAP : 0;
1349
1333
  const totalLegendW = legendGroups.reduce((s, g) => s + effectiveW(g), 0)
1350
- + (legendGroups.length - 1) * LEGEND_GROUP_GAP
1351
- + trailingGaps + playbackW;
1334
+ + (legendGroups.length - 1) * LEGEND_GROUP_GAP;
1352
1335
  let cursorX = (totalWidth - totalLegendW) / 2;
1353
1336
 
1354
1337
  for (const group of legendGroups) {
@@ -1366,7 +1349,6 @@ function renderLegend(
1366
1349
  .attr('transform', `translate(${cursorX}, 0)`)
1367
1350
  .attr('class', 'infra-legend-group')
1368
1351
  .attr('data-legend-group', group.name.toLowerCase())
1369
- .attr('data-legend-type', group.type)
1370
1352
  .style('cursor', 'pointer');
1371
1353
 
1372
1354
  // Outer capsule background (active only)
@@ -1400,7 +1382,7 @@ function renderLegend(
1400
1382
  .attr('height', pillH)
1401
1383
  .attr('rx', pillH / 2)
1402
1384
  .attr('fill', 'none')
1403
- .attr('stroke', isDark ? mix(palette.textMuted, palette.bg, 50) : mix(palette.textMuted, palette.bg, 50))
1385
+ .attr('stroke', mix(palette.textMuted, palette.bg, 50))
1404
1386
  .attr('stroke-width', 0.75);
1405
1387
  }
1406
1388
 
@@ -1422,15 +1404,12 @@ function renderLegend(
1422
1404
  const entryG = gEl
1423
1405
  .append('g')
1424
1406
  .attr('class', 'infra-legend-entry')
1425
- .attr('data-legend-entry', entry.key)
1426
- .attr('data-legend-type', group.type)
1407
+ .attr('data-legend-entry', entry.key.toLowerCase())
1427
1408
  .attr('data-legend-color', entry.color)
1409
+ .attr('data-legend-type', group.type)
1410
+ .attr('data-legend-tag-group', group.type === 'tag' ? (group.tagKey ?? '') : null)
1428
1411
  .style('cursor', 'pointer');
1429
1412
 
1430
- if (group.type === 'tag' && group.tagKey) {
1431
- entryG.attr('data-legend-tag-group', group.tagKey);
1432
- }
1433
-
1434
1413
  entryG.append('circle')
1435
1414
  .attr('cx', entryX + LEGEND_DOT_R)
1436
1415
  .attr('cy', LEGEND_HEIGHT / 2)
@@ -1453,127 +1432,12 @@ function renderLegend(
1453
1432
  cursorX += effectiveW(group) + LEGEND_GROUP_GAP;
1454
1433
  }
1455
1434
 
1456
- // Playback pill — speed + pause only
1457
- if (playback) {
1458
- const isExpanded = playback.expanded;
1459
- const groupBg = isDark
1460
- ? mix(palette.bg, palette.text, 85)
1461
- : mix(palette.bg, palette.text, 92);
1462
-
1463
- const pillLabel = 'Playback';
1464
- const pillWidth = pillLabel.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
1465
- const fullW = computePlaybackWidth(playback);
1466
-
1467
- const pbG = legendG
1468
- .append('g')
1469
- .attr('transform', `translate(${cursorX}, 0)`)
1470
- .attr('class', 'infra-legend-group infra-playback-pill')
1471
- .style('cursor', 'pointer');
1472
-
1473
- if (isExpanded) {
1474
- pbG.append('rect')
1475
- .attr('width', fullW)
1476
- .attr('height', LEGEND_HEIGHT)
1477
- .attr('rx', LEGEND_HEIGHT / 2)
1478
- .attr('fill', groupBg);
1479
- }
1480
-
1481
- const pillXOff = isExpanded ? LEGEND_CAPSULE_PAD : 0;
1482
- const pillYOff = isExpanded ? LEGEND_CAPSULE_PAD : 0;
1483
- const pillH = LEGEND_HEIGHT - (isExpanded ? LEGEND_CAPSULE_PAD * 2 : 0);
1484
-
1485
- pbG.append('rect')
1486
- .attr('x', pillXOff).attr('y', pillYOff)
1487
- .attr('width', pillWidth).attr('height', pillH)
1488
- .attr('rx', pillH / 2)
1489
- .attr('fill', isExpanded ? palette.bg : groupBg);
1490
-
1491
- if (isExpanded) {
1492
- pbG.append('rect')
1493
- .attr('x', pillXOff).attr('y', pillYOff)
1494
- .attr('width', pillWidth).attr('height', pillH)
1495
- .attr('rx', pillH / 2)
1496
- .attr('fill', 'none')
1497
- .attr('stroke', mix(palette.textMuted, palette.bg, 50))
1498
- .attr('stroke-width', 0.75);
1499
- }
1500
-
1501
- pbG.append('text')
1502
- .attr('x', pillXOff + pillWidth / 2)
1503
- .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
1504
- .attr('font-family', FONT_FAMILY)
1505
- .attr('font-size', LEGEND_PILL_FONT_SIZE)
1506
- .attr('font-weight', '500')
1507
- .attr('fill', isExpanded ? palette.text : palette.textMuted)
1508
- .attr('text-anchor', 'middle')
1509
- .text(pillLabel);
1510
-
1511
- if (isExpanded) {
1512
- let entryX = pillXOff + pillWidth + 8;
1513
- const entryY = LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1;
1514
-
1515
- const ppLabel = playback.paused ? '▶' : '⏸';
1516
- pbG.append('text')
1517
- .attr('x', entryX).attr('y', entryY)
1518
- .attr('font-family', FONT_FAMILY)
1519
- .attr('font-size', LEGEND_PILL_FONT_SIZE)
1520
- .attr('fill', palette.textMuted)
1521
- .attr('data-playback-action', 'toggle-pause')
1522
- .style('cursor', 'pointer')
1523
- .text(ppLabel);
1524
- entryX += LEGEND_PILL_FONT_SIZE * 0.8 + 6;
1525
-
1526
- for (const s of playback.speedOptions) {
1527
- const label = `${s}x`;
1528
- const isActive = playback.speed === s;
1529
- const slotW = label.length * LEGEND_ENTRY_FONT_W + SPEED_BADGE_H_PAD * 2;
1530
- const badgeH = LEGEND_ENTRY_FONT_SIZE + SPEED_BADGE_V_PAD * 2;
1531
- const badgeY = (LEGEND_HEIGHT - badgeH) / 2;
1532
-
1533
- // Wrap in <g> with data attrs so a single element carries the action,
1534
- // and both rect and text inherit the hit target cleanly.
1535
- const speedG = pbG.append('g')
1536
- .attr('data-playback-action', 'set-speed')
1537
- .attr('data-playback-value', String(s))
1538
- .style('cursor', 'pointer');
1539
-
1540
- // Badge rect: filled for active, transparent hit-target for inactive
1541
- speedG.append('rect')
1542
- .attr('x', entryX)
1543
- .attr('y', badgeY)
1544
- .attr('width', slotW)
1545
- .attr('height', badgeH)
1546
- .attr('rx', badgeH / 2)
1547
- .attr('fill', isActive ? palette.primary : 'transparent');
1548
-
1549
- speedG.append('text')
1550
- .attr('x', entryX + slotW / 2).attr('y', entryY)
1551
- .attr('font-family', FONT_FAMILY)
1552
- .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
1553
- .attr('font-weight', isActive ? '600' : '400')
1554
- .attr('fill', isActive ? palette.bg : palette.textMuted)
1555
- .attr('text-anchor', 'middle')
1556
- .text(label);
1557
- entryX += slotW + SPEED_BADGE_GAP;
1558
- }
1559
- }
1560
-
1561
- cursorX += fullW + LEGEND_GROUP_GAP;
1562
- }
1563
-
1564
1435
  }
1565
1436
 
1566
1437
  // ============================================================
1567
1438
  // Main render
1568
1439
  // ============================================================
1569
1440
 
1570
- export interface InfraPlaybackState {
1571
- expanded: boolean;
1572
- paused: boolean;
1573
- speed: number;
1574
- speedOptions: readonly number[];
1575
- }
1576
-
1577
1441
  export function renderInfra(
1578
1442
  container: HTMLDivElement,
1579
1443
  layout: InfraLayoutResult,
@@ -1584,7 +1448,7 @@ export function renderInfra(
1584
1448
  tagGroups?: InfraTagGroup[],
1585
1449
  activeGroup?: string | null,
1586
1450
  animate?: boolean,
1587
- playback?: InfraPlaybackState | null,
1451
+ _playback?: unknown,
1588
1452
  expandedNodeIds?: Set<string> | null,
1589
1453
  exportMode?: boolean,
1590
1454
  collapsedNodes?: Set<string> | null,
@@ -1594,7 +1458,7 @@ export function renderInfra(
1594
1458
 
1595
1459
  // Build legend groups
1596
1460
  const legendGroups = computeInfraLegendGroups(layout.nodes, tagGroups ?? [], palette, layout.edges);
1597
- const hasLegend = legendGroups.length > 0 || !!playback;
1461
+ const hasLegend = legendGroups.length > 0;
1598
1462
  // In app mode (not export), legend is rendered as a separate fixed-size SVG
1599
1463
  const fixedLegend = !exportMode && hasLegend;
1600
1464
  const legendOffset = hasLegend && !fixedLegend ? LEGEND_HEIGHT : 0;
@@ -1707,9 +1571,9 @@ export function renderInfra(
1707
1571
  .attr('viewBox', `0 0 ${containerWidth} ${LEGEND_HEIGHT + LEGEND_FIXED_GAP}`)
1708
1572
  .attr('preserveAspectRatio', 'xMidYMid meet')
1709
1573
  .style('display', 'block');
1710
- renderLegend(legendSvg, legendGroups, containerWidth, LEGEND_FIXED_GAP / 2, palette, isDark, activeGroup ?? null, playback ?? undefined);
1574
+ renderLegend(legendSvg, legendGroups, containerWidth, LEGEND_FIXED_GAP / 2, palette, isDark, activeGroup ?? null);
1711
1575
  } else {
1712
- renderLegend(rootSvg, legendGroups, totalWidth, titleOffset + layout.height + 4, palette, isDark, activeGroup ?? null, playback ?? undefined);
1576
+ renderLegend(rootSvg, legendGroups, totalWidth, titleOffset + layout.height + 4, palette, isDark, activeGroup ?? null);
1713
1577
  }
1714
1578
  }
1715
1579
  }
@@ -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>;
@@ -265,16 +265,19 @@ export function layoutInitiativeStatus(
265
265
  }
266
266
  } else if (isYDisplaced) {
267
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.
268
+ // yOffset applied as X-spread at exit AND Y-spread at entry so parallel edges maintain
269
+ // a consistent visual gap along their entire length (not just at one end).
269
270
  const exitY = tgt.y > src.y + NODESEP
270
271
  ? src.y + src.height / 2 // target is below — exit bottom
271
272
  : 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;
273
+ const spreadExitX = src.x + yOffset;
274
+ const spreadEntryY = tgt.y + yOffset;
275
+ const midX = (spreadExitX + enterX) / 2; // always monotone ✓ (yOffset << node gap)
276
+ const midY = (exitY + spreadEntryY) / 2;
274
277
  points = [
275
- { x: src.x, y: exitY },
276
- { x: midX, y: midY },
277
- { x: enterX, y: tgt.y },
278
+ { x: spreadExitX, y: exitY },
279
+ { x: midX, y: midY },
280
+ { x: enterX, y: spreadEntryY },
278
281
  ];
279
282
  } else if (tgt.x > src.x && !hasIntermediateRank) {
280
283
  // 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')