@diagrammo/dgmo 0.5.2 → 0.5.4

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.
@@ -8,6 +8,7 @@
8
8
 
9
9
  import dagre from '@dagrejs/dagre';
10
10
  import type { ParsedInitiativeStatus, InitiativeStatus } from './types';
11
+ import type { CollapseResult } from './collapse';
11
12
 
12
13
  export interface ISLayoutNode {
13
14
  label: string;
@@ -26,7 +27,13 @@ export interface ISLayoutEdge {
26
27
  label?: string;
27
28
  status: import('./types').InitiativeStatus;
28
29
  lineNumber: number;
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]
33
+ // 4-point elbow: points[0] and points[last] pinned at node center Y; interior fans via yOffset
34
+ // fixedDagrePoints: points[0]=src.right, points[last]=tgt.left; interior from dagre
29
35
  points: { x: number; y: number }[];
36
+ parallelCount: number; // 1 for unique edges, >1 for parallel groups — used by renderer to narrow hit area
30
37
  }
31
38
 
32
39
  export interface ISLayoutGroup {
@@ -37,6 +44,7 @@ export interface ISLayoutGroup {
37
44
  width: number;
38
45
  height: number;
39
46
  lineNumber: number;
47
+ collapsed: boolean;
40
48
  }
41
49
 
42
50
  export interface ISLayoutResult {
@@ -49,7 +57,7 @@ export interface ISLayoutResult {
49
57
 
50
58
  const STATUS_PRIORITY: Record<string, number> = { todo: 3, wip: 2, done: 1, na: 0 };
51
59
 
52
- function rollUpStatus(members: ISLayoutNode[]): InitiativeStatus {
60
+ export function rollUpStatus(members: { status: InitiativeStatus }[]): InitiativeStatus {
53
61
  let worst: InitiativeStatus = null;
54
62
  let worstPri = -1;
55
63
  for (const m of members) {
@@ -68,22 +76,54 @@ const NODE_WIDTH = Math.round(NODE_HEIGHT * PHI);
68
76
  const GROUP_PADDING = 20;
69
77
  const NODESEP = 80;
70
78
  const RANKSEP = 160;
79
+ const PARALLEL_SPACING = 16; // px between parallel edges sharing same source→target (~27% of NODE_HEIGHT)
80
+ const PARALLEL_EDGE_MARGIN = 12; // total vertical margin reserved at top+bottom of node for edge bundles (6px each side)
81
+ const MAX_PARALLEL_EDGES = 5; // at most this many edges rendered between any directed source→target pair
82
+ const BACK_EDGE_MARGIN = 40; // clearance below/above nodes for back-edge arcs (~half NODESEP)
83
+ const BACK_EDGE_MIN_SPREAD = Math.round(NODE_WIDTH * 0.75); // minimum horizontal arc spread for near-same-X back-edges
84
+ const CHAR_WIDTH_RATIO = 0.6;
85
+ const NODE_FONT_SIZE = 13;
86
+ const NODE_TEXT_PADDING = 12;
71
87
 
72
88
  // ============================================================
73
89
  // Main layout function
74
90
  // ============================================================
75
91
 
76
- export function layoutInitiativeStatus(parsed: ParsedInitiativeStatus): ISLayoutResult {
77
- if (parsed.nodes.length === 0) {
92
+ export function layoutInitiativeStatus(
93
+ parsed: ParsedInitiativeStatus,
94
+ collapseResult?: CollapseResult
95
+ ): ISLayoutResult {
96
+ if (parsed.nodes.length === 0 && (!collapseResult || collapseResult.collapsedGroupStatuses.size === 0)) {
78
97
  return { nodes: [], edges: [], groups: [], width: 0, height: 0 };
79
98
  }
80
99
 
100
+ // Derive collapse context
101
+ const originalGroups = collapseResult?.originalGroups ?? parsed.groups;
102
+ const collapsedGroupStatuses = collapseResult?.collapsedGroupStatuses ?? new Map<string, InitiativeStatus>();
103
+ const collapsedGroupLabels = new Set(
104
+ originalGroups
105
+ .map((g) => g.label)
106
+ .filter((l) => !parsed.groups.some((g) => g.label === l))
107
+ );
108
+
81
109
  // Build and run dagre graph
82
- const hasGroups = parsed.groups.length > 0;
110
+ const hasGroups = parsed.groups.length > 0 || collapsedGroupLabels.size > 0;
83
111
  const g = new dagre.graphlib.Graph({ multigraph: true, compound: hasGroups });
84
112
  g.setGraph({ rankdir: 'LR', nodesep: NODESEP, ranksep: RANKSEP });
85
113
  g.setDefaultEdgeLabel(() => ({}));
86
114
 
115
+ // Collapsed groups → regular dagre nodes (no compound parent)
116
+ for (const group of originalGroups) {
117
+ if (collapsedGroupLabels.has(group.label)) {
118
+ const collapsedW = Math.max(
119
+ NODE_WIDTH,
120
+ Math.ceil(group.label.length * CHAR_WIDTH_RATIO * NODE_FONT_SIZE) + NODE_TEXT_PADDING * 2
121
+ );
122
+ g.setNode(group.label, { label: group.label, width: collapsedW, height: NODE_HEIGHT });
123
+ }
124
+ }
125
+
126
+ // Expanded groups → compound parents
87
127
  for (const group of parsed.groups) {
88
128
  g.setNode(`__group_${group.label}`, { label: group.label, clusterLabelPos: 'top' });
89
129
  }
@@ -118,15 +158,64 @@ export function layoutInitiativeStatus(parsed: ParsedInitiativeStatus): ISLayout
118
158
  };
119
159
  });
120
160
 
121
- const nodeMap = new Map(layoutNodes.map((n) => [n.label, n]));
122
- const allNodeX = layoutNodes.map((n) => n.x);
161
+ // Build a unified position map covering both regular nodes and collapsed groups
162
+ interface NodePos { x: number; y: number; width: number; height: number }
163
+ const posMap = new Map<string, NodePos>(layoutNodes.map((n) => [n.label, n]));
164
+ for (const label of collapsedGroupLabels) {
165
+ const pos = g.node(label);
166
+ if (pos) posMap.set(label, { x: pos.x, y: pos.y, width: pos.width, height: pos.height });
167
+ }
168
+
169
+ const allNodeX = [...posMap.values()].map((n) => n.x);
170
+ // avgNodeY / avgNodeX: O(1) scalars used for back-edge above/below heuristic and arc spread direction.
171
+ // layoutNodes.length === 0 is unreachable here (early-return guard at line 92 exits for empty diagrams).
172
+ const avgNodeY = layoutNodes.length > 0
173
+ ? layoutNodes.reduce((s, n) => s + n.y, 0) / layoutNodes.length
174
+ : 0;
175
+ const avgNodeX = layoutNodes.length > 0
176
+ ? layoutNodes.reduce((s, n) => s + n.x, 0) / layoutNodes.length
177
+ : 0;
123
178
 
124
179
  // Adjacent-rank edges: 4-point elbow (perpendicular exit/entry, no crossings).
125
180
  // Multi-rank edges: dagre's interior waypoints for obstacle avoidance, with
126
181
  // first/last points pinned to exact node boundaries at node-center Y.
127
- const layoutEdges: ISLayoutEdge[] = parsed.edges.map((edge, i) => {
128
- const src = nodeMap.get(edge.source)!;
129
- const tgt = nodeMap.get(edge.target)!;
182
+
183
+ // Precompute Y offsets and parallel counts for parallel edges (same directed source→target).
184
+ // Edges beyond MAX_PARALLEL_EDGES in a group are marked with parallelCount=0 and excluded from layout.
185
+ const edgeYOffsets: number[] = new Array(parsed.edges.length).fill(0);
186
+ const edgeParallelCounts: number[] = new Array(parsed.edges.length).fill(1);
187
+ const parallelGroups = new Map<string, number[]>();
188
+ for (let i = 0; i < parsed.edges.length; i++) {
189
+ const edge = parsed.edges[i];
190
+ const key = `${edge.source}\x00${edge.target}`; // null-byte separator — safe in all label strings
191
+ parallelGroups.set(key, parallelGroups.get(key) ?? []);
192
+ parallelGroups.get(key)!.push(i);
193
+ }
194
+ for (const group of parallelGroups.values()) {
195
+ // Cap group to MAX_PARALLEL_EDGES; mark excess edges for exclusion
196
+ const capped = group.slice(0, MAX_PARALLEL_EDGES);
197
+ for (const idx of group.slice(MAX_PARALLEL_EDGES)) {
198
+ edgeParallelCounts[idx] = 0; // sentinel: exclude from layout
199
+ }
200
+ if (capped.length < 2) continue;
201
+ // Clamp spacing so the bundle fits within node bounds regardless of edge count
202
+ const effectiveSpacing = Math.min(PARALLEL_SPACING, (NODE_HEIGHT - PARALLEL_EDGE_MARGIN) / (capped.length - 1));
203
+ for (let j = 0; j < capped.length; j++) {
204
+ edgeYOffsets[capped[j]] = (j - (capped.length - 1) / 2) * effectiveSpacing;
205
+ edgeParallelCounts[capped[j]] = capped.length;
206
+ }
207
+ }
208
+
209
+ const layoutEdges: ISLayoutEdge[] = [];
210
+ for (let i = 0; i < parsed.edges.length; i++) {
211
+ const edge = parsed.edges[i];
212
+ const src = posMap.get(edge.source);
213
+ const tgt = posMap.get(edge.target);
214
+ // Exclude edges beyond the parallel cap and edges with missing node positions
215
+ if (edgeParallelCounts[i] === 0) continue;
216
+ if (!src || !tgt) continue;
217
+ const yOffset = edgeYOffsets[i];
218
+ const parallelCount = edgeParallelCounts[i];
130
219
  const exitX = src.x + src.width / 2;
131
220
  const enterX = tgt.x - tgt.width / 2;
132
221
  const dagreEdge = g.edge(edge.source, edge.target, `e${i}`);
@@ -134,26 +223,102 @@ export function layoutInitiativeStatus(parsed: ParsedInitiativeStatus): ISLayout
134
223
  const hasIntermediateRank = allNodeX.some((x) => x > src.x + 20 && x < tgt.x - 20);
135
224
  const step = Math.min((enterX - exitX) * 0.15, 20);
136
225
 
137
- const fixedDagrePoints = dagrePoints.length >= 2 ? [
138
- { x: exitX, y: src.y },
139
- ...dagrePoints.slice(1, -1),
140
- { x: enterX, y: tgt.y },
141
- ] : dagrePoints;
142
-
143
- const points = (tgt.x > src.x && !hasIntermediateRank)
144
- ? [
145
- { x: exitX, y: src.y },
146
- { x: exitX + step, y: src.y },
147
- { x: enterX - step, y: tgt.y },
148
- { x: enterX, y: tgt.y },
149
- ]
150
- : fixedDagrePoints;
151
- return { source: edge.source, target: edge.target, label: edge.label,
152
- status: edge.status, lineNumber: edge.lineNumber, points };
153
- });
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).
233
+
234
+ let points: { x: number; y: number }[];
235
+
236
+ if (isBackEdge) {
237
+ // 3-point arc via bottom (or top) of both nodes — bypasses dagre entirely so arrowhead is visible.
238
+ // curveMonotoneX requires monotone-decreasing X (src.x > tgt.x for back-edges) ✓
239
+ // Parallel back-edges share the same arc (yOffset ignored) — acknowledged limitation, out of scope.
240
+ const routeAbove = Math.min(src.y, tgt.y) > avgNodeY;
241
+ const srcHalfH = src.height / 2;
242
+ const tgtHalfH = tgt.height / 2;
243
+ const rawMidX = (src.x + tgt.x) / 2;
244
+ const spreadDir = avgNodeX < rawMidX ? 1 : -1;
245
+ // Clamp midX to [tgt.x, src.x] to preserve monotone-decreasing X for curveMonotoneX.
246
+ // When nodes are near-same-X the arc stays narrow but valid.
247
+ const unclamped = Math.abs(src.x - tgt.x) < NODE_WIDTH
248
+ ? rawMidX + spreadDir * BACK_EDGE_MIN_SPREAD
249
+ : rawMidX;
250
+ const midX = Math.min(src.x, Math.max(tgt.x, unclamped));
251
+ if (routeAbove) {
252
+ const arcY = Math.min(src.y - srcHalfH, tgt.y - tgtHalfH) - BACK_EDGE_MARGIN;
253
+ points = [
254
+ { x: src.x, y: src.y - srcHalfH },
255
+ { x: midX, y: arcY },
256
+ { x: tgt.x, y: tgt.y - tgtHalfH },
257
+ ];
258
+ } else {
259
+ const arcY = Math.max(src.y + srcHalfH, tgt.y + tgtHalfH) + BACK_EDGE_MARGIN;
260
+ points = [
261
+ { x: src.x, y: src.y + srcHalfH },
262
+ { x: midX, y: arcY },
263
+ { x: tgt.x, y: tgt.y + tgtHalfH },
264
+ ];
265
+ }
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
+ points = [
275
+ { x: src.x, y: exitY },
276
+ { x: midX, y: midY },
277
+ { x: enterX, y: tgt.y },
278
+ ];
279
+ } else if (tgt.x > src.x && !hasIntermediateRank) {
280
+ // 4-point elbow: adjacent-rank forward edges (unchanged)
281
+ points = [
282
+ { x: exitX, y: src.y }, // exits node center — stays pinned
283
+ { x: exitX + step, y: src.y + yOffset }, // fans out
284
+ { x: enterX - step, y: tgt.y + yOffset }, // still fanned
285
+ { x: enterX, y: tgt.y }, // enters node center — stays pinned
286
+ ];
287
+ } else {
288
+ // fixedDagrePoints: multi-rank forward edges — dagre interior waypoints for obstacle avoidance.
289
+ // dagrePoints is still fetched above (line 209) and available here.
290
+ points = dagrePoints.length >= 2 ? [
291
+ { x: exitX, y: src.y + yOffset },
292
+ ...dagrePoints.slice(1, -1),
293
+ { x: enterX, y: tgt.y + yOffset },
294
+ ] : dagrePoints;
295
+ }
296
+ layoutEdges.push({ source: edge.source, target: edge.target, label: edge.label,
297
+ status: edge.status, lineNumber: edge.lineNumber, points, parallelCount });
298
+ }
154
299
 
155
300
  // Compute group bounding boxes
156
301
  const layoutGroups: ISLayoutGroup[] = [];
302
+
303
+ // Collapsed groups: dagre placed them as regular nodes → normalize to top-left
304
+ for (const group of originalGroups) {
305
+ if (collapsedGroupLabels.has(group.label)) {
306
+ const pos = g.node(group.label);
307
+ if (!pos) continue;
308
+ layoutGroups.push({
309
+ label: group.label,
310
+ status: collapsedGroupStatuses.get(group.label) ?? null,
311
+ x: pos.x - pos.width / 2,
312
+ y: pos.y - pos.height / 2,
313
+ width: pos.width,
314
+ height: pos.height,
315
+ lineNumber: group.lineNumber,
316
+ collapsed: true,
317
+ });
318
+ }
319
+ }
320
+
321
+ // Expanded groups: bounding box from member positions
157
322
  if (parsed.groups.length > 0) {
158
323
  const nMap = new Map(layoutNodes.map((n) => [n.label, n]));
159
324
  for (const group of parsed.groups) {
@@ -182,10 +347,12 @@ export function layoutInitiativeStatus(parsed: ParsedInitiativeStatus): ISLayout
182
347
  width: maxX - minX + GROUP_PADDING * 2,
183
348
  height: maxY - minY + GROUP_PADDING * 2,
184
349
  lineNumber: group.lineNumber,
350
+ collapsed: false,
185
351
  });
186
352
  }
187
353
  }
188
354
 
355
+
189
356
  // Compute total dimensions
190
357
  let totalWidth = 0;
191
358
  let totalHeight = 0;
@@ -25,13 +25,14 @@ const EDGE_LABEL_FONT_SIZE = 11;
25
25
  const EDGE_STROKE_WIDTH = 2;
26
26
  const NODE_STROKE_WIDTH = 2;
27
27
  const NODE_RX = 8;
28
- const ARROWHEAD_W = 10;
29
- const ARROWHEAD_H = 7;
28
+ const ARROWHEAD_W = 5;
29
+ const ARROWHEAD_H = 4;
30
30
  const CHAR_WIDTH_RATIO = 0.6; // approx char width / font size for Helvetica
31
31
  const NODE_TEXT_PADDING = 12; // horizontal padding inside node for text
32
32
  const SERVICE_RX = 10;
33
33
  const GROUP_EXTRA_PADDING = 8;
34
34
  const GROUP_LABEL_FONT_SIZE = 11;
35
+ const COLLAPSE_BAR_HEIGHT = 6;
35
36
 
36
37
  // ============================================================
37
38
  // Color helpers
@@ -602,51 +603,98 @@ export function renderInitiativeStatus(
602
603
 
603
604
  // Render groups (background layer, before edges and nodes)
604
605
  for (const group of layout.groups) {
605
- if (group.width === 0 && group.height === 0) continue;
606
- const gx = group.x - GROUP_EXTRA_PADDING;
607
- const gy = group.y - GROUP_EXTRA_PADDING - GROUP_LABEL_FONT_SIZE - 4;
608
- const gw = group.width + GROUP_EXTRA_PADDING * 2;
609
- const gh = group.height + GROUP_EXTRA_PADDING * 2 + GROUP_LABEL_FONT_SIZE + 4;
610
-
611
- const groupStatusColor = group.status
612
- ? statusColor(group.status, palette, isDark)
613
- : palette.textMuted;
614
- // More subdued than nodes: 15% status color vs 30% for nodes
615
- const fillColor = mix(groupStatusColor, isDark ? palette.surface : palette.bg, 15);
616
- const strokeColor = mix(groupStatusColor, palette.textMuted, 50);
617
-
618
- const groupG = contentG
619
- .append('g')
620
- .attr('class', 'is-group')
621
- .attr('data-line-number', String(group.lineNumber));
606
+ if (group.collapsed) {
607
+ // ── Collapsed: node-like box (same fill/stroke as nodes) + drill-bar ──
608
+ const fillCol = nodeFill(group.status, palette, isDark);
609
+ const strokeCol = nodeStroke(group.status, palette, isDark);
610
+ const textCol = nodeTextColor(group.status, palette, isDark);
611
+ const clipId = `clip-group-${group.lineNumber}`;
612
+
613
+ const groupG = contentG
614
+ .append('g')
615
+ .attr('class', 'is-group is-group-collapsed')
616
+ .attr('data-line-number', String(group.lineNumber))
617
+ .attr('data-group-toggle', group.label)
618
+ .style('cursor', 'pointer');
619
+
620
+ // Clip path for drill-bar rounded corners
621
+ groupG.append('clipPath').attr('id', clipId)
622
+ .append('rect')
623
+ .attr('x', group.x).attr('y', group.y)
624
+ .attr('width', group.width).attr('height', group.height)
625
+ .attr('rx', NODE_RX);
626
+
627
+ // Main box
628
+ groupG.append('rect')
629
+ .attr('x', group.x).attr('y', group.y)
630
+ .attr('width', group.width).attr('height', group.height)
631
+ .attr('rx', NODE_RX)
632
+ .attr('fill', fillCol)
633
+ .attr('stroke', strokeCol)
634
+ .attr('stroke-width', NODE_STROKE_WIDTH);
635
+
636
+ // Drill-bar (6px bottom stripe, clipped to rounded corners)
637
+ groupG.append('rect')
638
+ .attr('x', group.x)
639
+ .attr('y', group.y + group.height - COLLAPSE_BAR_HEIGHT)
640
+ .attr('width', group.width)
641
+ .attr('height', COLLAPSE_BAR_HEIGHT)
642
+ .attr('fill', strokeCol)
643
+ .attr('clip-path', `url(#${clipId})`)
644
+ .attr('class', 'is-collapse-bar');
645
+
646
+ // Label centered (above drill-bar)
647
+ groupG.append('text')
648
+ .attr('x', group.x + group.width / 2)
649
+ .attr('y', group.y + group.height / 2 - COLLAPSE_BAR_HEIGHT / 2)
650
+ .attr('text-anchor', 'middle')
651
+ .attr('dominant-baseline', 'central')
652
+ .attr('fill', textCol)
653
+ .attr('font-size', NODE_FONT_SIZE)
654
+ .attr('font-weight', 'bold')
655
+ .attr('font-family', FONT_FAMILY)
656
+ .text(group.label);
622
657
 
623
- groupG
624
- .append('rect')
625
- .attr('x', gx)
626
- .attr('y', gy)
627
- .attr('width', gw)
628
- .attr('height', gh)
629
- .attr('rx', 6)
630
- .attr('fill', fillColor)
631
- .attr('stroke', strokeColor)
632
- .attr('stroke-width', 1)
633
- .attr('stroke-opacity', 0.5);
634
-
635
- groupG
636
- .append('text')
637
- .attr('x', gx + 8)
638
- .attr('y', gy + GROUP_LABEL_FONT_SIZE + 4)
639
- .attr('fill', strokeColor)
640
- .attr('font-size', GROUP_LABEL_FONT_SIZE)
641
- .attr('font-weight', 'bold')
642
- .attr('opacity', 0.7)
643
- .attr('class', 'is-group-label')
644
- .text(group.label);
658
+ } else {
659
+ // ── Expanded: neutral background (no status color bleed) ──
660
+ if (group.width === 0 && group.height === 0) continue;
661
+ const gx = group.x - GROUP_EXTRA_PADDING;
662
+ const gy = group.y - GROUP_EXTRA_PADDING - GROUP_LABEL_FONT_SIZE - 4;
663
+ const gw = group.width + GROUP_EXTRA_PADDING * 2;
664
+ const gh = group.height + GROUP_EXTRA_PADDING * 2 + GROUP_LABEL_FONT_SIZE + 4;
665
+
666
+ const fillColor = isDark ? palette.surface : palette.bg;
667
+ const strokeColor = palette.textMuted;
668
+
669
+ const groupG = contentG
670
+ .append('g')
671
+ .attr('class', 'is-group')
672
+ .attr('data-line-number', String(group.lineNumber))
673
+ .attr('data-group-toggle', group.label)
674
+ .style('cursor', 'pointer');
675
+
676
+ groupG
677
+ .append('rect')
678
+ .attr('x', gx)
679
+ .attr('y', gy)
680
+ .attr('width', gw)
681
+ .attr('height', gh)
682
+ .attr('rx', 6)
683
+ .attr('fill', fillColor)
684
+ .attr('stroke', strokeColor)
685
+ .attr('stroke-opacity', 0.5);
686
+
687
+ groupG
688
+ .append('text')
689
+ .attr('x', gx + 8)
690
+ .attr('y', gy + GROUP_LABEL_FONT_SIZE + 4)
691
+ .attr('fill', strokeColor)
692
+ .attr('font-size', GROUP_LABEL_FONT_SIZE)
693
+ .attr('font-weight', 'bold')
694
+ .attr('opacity', 0.7)
695
+ .attr('class', 'is-group-label')
696
+ .text(group.label);
645
697
 
646
- if (onClickItem) {
647
- groupG.style('cursor', 'pointer').on('click', () => {
648
- onClickItem(group.lineNumber);
649
- });
650
698
  }
651
699
  }
652
700
 
@@ -670,7 +718,7 @@ export function renderInitiativeStatus(
670
718
  .attr('d', pathD)
671
719
  .attr('fill', 'none')
672
720
  .attr('stroke', 'transparent')
673
- .attr('stroke-width', 16);
721
+ .attr('stroke-width', Math.max(6, Math.round(16 / (edge.parallelCount ?? 1))));
674
722
 
675
723
  edgeG
676
724
  .append('path')
package/src/org/layout.ts CHANGED
@@ -110,6 +110,8 @@ const LEGEND_ENTRY_FONT_W = 10 * 0.6;
110
110
  const LEGEND_ENTRY_DOT_GAP = 4;
111
111
  const LEGEND_ENTRY_TRAIL = 8;
112
112
  const LEGEND_GROUP_GAP = 12;
113
+ const LEGEND_EYE_SIZE = 14;
114
+ const LEGEND_EYE_GAP = 6;
113
115
 
114
116
  // ============================================================
115
117
  // Helpers
@@ -263,7 +265,7 @@ function centerHeavyChildren(node: TreeNode): void {
263
265
 
264
266
  function computeLegendGroups(
265
267
  tagGroups: OrgTagGroup[],
266
- _showEyeIcons: boolean,
268
+ showEyeIcons: boolean,
267
269
  usedValuesByGroup?: Map<string, Set<string>>
268
270
  ): OrgLegendGroup[] {
269
271
  const groups: OrgLegendGroup[] = [];
@@ -291,8 +293,9 @@ function computeLegendGroups(
291
293
  entry.value.length * LEGEND_ENTRY_FONT_W +
292
294
  LEGEND_ENTRY_TRAIL;
293
295
  }
296
+ const eyeSpace = showEyeIcons ? LEGEND_EYE_SIZE + LEGEND_EYE_GAP : 0;
294
297
  const capsuleWidth =
295
- LEGEND_CAPSULE_PAD * 2 + pillWidth + 4 + entriesWidth;
298
+ LEGEND_CAPSULE_PAD * 2 + pillWidth + 4 + eyeSpace + entriesWidth;
296
299
 
297
300
  groups.push({
298
301
  name: group.name,
@@ -49,8 +49,16 @@ const LEGEND_ENTRY_FONT_W = LEGEND_ENTRY_FONT_SIZE * 0.6;
49
49
  const LEGEND_ENTRY_DOT_GAP = 4;
50
50
  const LEGEND_ENTRY_TRAIL = 8;
51
51
  const LEGEND_GROUP_GAP = 12;
52
+ const LEGEND_EYE_SIZE = 14;
53
+ const LEGEND_EYE_GAP = 6;
52
54
  const LEGEND_FIXED_GAP = 8; // gap between fixed legend and scaled diagram
53
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';
61
+
54
62
  // ============================================================
55
63
  // Color helpers
56
64
  // ============================================================
@@ -122,14 +130,19 @@ export function renderOrg(
122
130
  const fixedLegend = !exportDims && hasLegend && !legendOnly;
123
131
  const legendReserve = fixedLegend ? LEGEND_HEIGHT + LEGEND_FIXED_GAP : 0;
124
132
 
133
+ // Similarly, render the title at fixed size outside the scaled group in
134
+ // non-export mode so it stays legible regardless of how small the chart scale is.
135
+ const fixedTitle = !exportDims && !!parsed.title;
136
+ const titleReserve = fixedTitle ? TITLE_HEIGHT : 0;
137
+
125
138
  // Compute scale to fit diagram in viewport
126
139
  const diagramW = layout.width;
127
- let diagramH = layout.height + titleOffset;
140
+ let diagramH = layout.height + (fixedTitle ? 0 : titleOffset);
128
141
  if (fixedLegend) {
129
142
  // Remove the legend space from diagram height — legend is rendered separately
130
143
  diagramH -= layoutLegendShift;
131
144
  }
132
- const availH = height - DIAGRAM_PADDING * 2 - legendReserve;
145
+ const availH = height - DIAGRAM_PADDING * 2 - legendReserve - titleReserve;
133
146
  const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
134
147
  const scaleY = availH / diagramH;
135
148
  const scale = Math.min(MAX_SCALE, scaleX, scaleY);
@@ -139,8 +152,8 @@ export function renderOrg(
139
152
  const offsetX = (width - scaledW) / 2;
140
153
  const offsetY =
141
154
  legendPosition === 'top' && fixedLegend
142
- ? DIAGRAM_PADDING + legendReserve
143
- : DIAGRAM_PADDING;
155
+ ? DIAGRAM_PADDING + legendReserve + titleReserve
156
+ : DIAGRAM_PADDING + titleReserve;
144
157
 
145
158
  // Create SVG
146
159
  const svg = d3Selection
@@ -156,11 +169,17 @@ export function renderOrg(
156
169
  .attr('transform', `translate(${offsetX}, ${offsetY}) scale(${scale})`);
157
170
 
158
171
  // Title
172
+ // In non-export mode (fixedTitle), render at native size directly on the SVG
173
+ // so it stays legible regardless of chart scale. In export mode, render inside
174
+ // mainG so it scales with the diagram to match the exported dimensions.
159
175
  if (parsed.title) {
160
- const titleEl = mainG
176
+ const titleParent = fixedTitle ? svg : mainG;
177
+ const titleX = fixedTitle ? width / 2 : diagramW / 2;
178
+ const titleY = fixedTitle ? DIAGRAM_PADDING + TITLE_FONT_SIZE : TITLE_FONT_SIZE;
179
+ const titleEl = titleParent
161
180
  .append('text')
162
- .attr('x', diagramW / 2)
163
- .attr('y', TITLE_FONT_SIZE)
181
+ .attr('x', titleX)
182
+ .attr('y', titleY)
164
183
  .attr('text-anchor', 'middle')
165
184
  .attr('fill', palette.text)
166
185
  .attr('font-size', TITLE_FONT_SIZE)
@@ -187,10 +206,10 @@ export function renderOrg(
187
206
  }
188
207
  }
189
208
 
190
- // Content group (offset by title)
209
+ // Content group (offset by title — only when title is inside the scaled group)
191
210
  const contentG = mainG
192
211
  .append('g')
193
- .attr('transform', `translate(0, ${titleOffset})`);
212
+ .attr('transform', `translate(0, ${fixedTitle ? 0 : titleOffset})`);
194
213
 
195
214
  // Build display name map from tag groups (lowercase key → original casing)
196
215
  const displayNames = new Map<string, string>();
@@ -501,7 +520,7 @@ export function renderOrg(
501
520
  'transform',
502
521
  legendPosition === 'bottom'
503
522
  ? `translate(0, ${height - DIAGRAM_PADDING - LEGEND_HEIGHT})`
504
- : `translate(0, ${DIAGRAM_PADDING})`
523
+ : `translate(0, ${DIAGRAM_PADDING + titleReserve})`
505
524
  )
506
525
  : contentG;
507
526
 
@@ -578,9 +597,44 @@ export function renderOrg(
578
597
  .attr('text-anchor', 'middle')
579
598
  .text(pillLabel);
580
599
 
600
+ // Eye icon for visibility toggle (active only, app mode)
601
+ if (isActive && fixedLegend) {
602
+ const groupKey = group.name.toLowerCase();
603
+ const isHidden = hiddenAttributes?.has(groupKey) ?? false;
604
+ const eyeX = pillXOff + pillWidth + LEGEND_EYE_GAP;
605
+ const eyeY = (LEGEND_HEIGHT - LEGEND_EYE_SIZE) / 2;
606
+ const hitPad = 6;
607
+
608
+ const eyeG = gEl
609
+ .append('g')
610
+ .attr('class', 'org-legend-eye')
611
+ .attr('data-legend-visibility', groupKey)
612
+ .style('cursor', 'pointer')
613
+ .attr('opacity', isHidden ? 0.4 : 0.7);
614
+
615
+ // Transparent hit area for easier clicking
616
+ eyeG.append('rect')
617
+ .attr('x', eyeX - hitPad)
618
+ .attr('y', eyeY - hitPad)
619
+ .attr('width', LEGEND_EYE_SIZE + hitPad * 2)
620
+ .attr('height', LEGEND_EYE_SIZE + hitPad * 2)
621
+ .attr('fill', 'transparent')
622
+ .attr('pointer-events', 'all');
623
+
624
+ eyeG.append('path')
625
+ .attr('d', isHidden ? EYE_CLOSED_PATH : EYE_OPEN_PATH)
626
+ .attr('transform', `translate(${eyeX}, ${eyeY})`)
627
+ .attr('fill', 'none')
628
+ .attr('stroke', palette.textMuted)
629
+ .attr('stroke-width', 1.2)
630
+ .attr('stroke-linecap', 'round')
631
+ .attr('stroke-linejoin', 'round');
632
+ }
633
+
581
634
  // Entries inside capsule (active only)
582
635
  if (isActive) {
583
- let entryX = pillXOff + pillWidth + 4;
636
+ const eyeShift = fixedLegend ? LEGEND_EYE_SIZE + LEGEND_EYE_GAP : 0;
637
+ let entryX = pillXOff + pillWidth + 4 + eyeShift;
584
638
  for (const entry of group.entries) {
585
639
  const entryG = gEl
586
640
  .append('g')
@@ -228,7 +228,7 @@ async function resolveFile(
228
228
  continue;
229
229
  }
230
230
  if (isTagBlockHeading(trimmed)) continue; // skip inline tag group headings
231
- if (lines[i] !== trimmed) continue; // skip tag group entries (indented lines)
231
+ if (/^\s/.test(lines[i])) continue; // skip tag group entries (indented lines)
232
232
 
233
233
  const tagsMatch = trimmed.match(TAGS_RE);
234
234
  if (tagsMatch) {