@diagrammo/dgmo 0.5.3 → 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.
- package/README.md +16 -16
- package/dist/cli.cjs +158 -158
- package/dist/index.cjs +766 -319
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +37 -23
- package/dist/index.d.ts +37 -23
- package/dist/index.js +765 -319
- package/dist/index.js.map +1 -1
- package/docs/language-reference.md +20 -2
- package/package.json +1 -1
- package/src/d3.ts +241 -204
- package/src/index.ts +3 -0
- package/src/infra/compute.ts +88 -10
- package/src/infra/layout.ts +97 -12
- package/src/infra/parser.ts +47 -4
- package/src/infra/renderer.ts +216 -42
- package/src/infra/roles.ts +15 -0
- package/src/infra/types.ts +7 -0
- package/src/initiative-status/collapse.ts +76 -0
- package/src/initiative-status/layout.ts +193 -26
- package/src/initiative-status/renderer.ts +94 -46
- package/src/org/layout.ts +5 -2
- package/src/org/renderer.ts +65 -11
- package/src/org/resolver.ts +1 -1
- package/src/sharing.ts +12 -0
|
@@ -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:
|
|
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(
|
|
77
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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 =
|
|
29
|
-
const ARROWHEAD_H =
|
|
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.
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
.attr('
|
|
621
|
-
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
.
|
|
626
|
-
.
|
|
627
|
-
.
|
|
628
|
-
.
|
|
629
|
-
.
|
|
630
|
-
|
|
631
|
-
.
|
|
632
|
-
.
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
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,
|
package/src/org/renderer.ts
CHANGED
|
@@ -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
|
|
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',
|
|
163
|
-
.attr('y',
|
|
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
|
-
|
|
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')
|
package/src/org/resolver.ts
CHANGED
|
@@ -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]
|
|
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) {
|