@diagrammo/dgmo 0.6.3 → 0.7.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.
- package/dist/cli.cjs +180 -178
- package/dist/index.cjs +5447 -2229
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +236 -16
- package/dist/index.d.ts +236 -16
- package/dist/index.js +5439 -2228
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/c4/parser.ts +3 -2
- package/src/c4/renderer.ts +6 -6
- package/src/class/renderer.ts +183 -7
- package/src/cli.ts +3 -11
- package/src/colors.ts +3 -3
- package/src/d3.ts +132 -29
- package/src/dgmo-router.ts +3 -1
- package/src/er/parser.ts +5 -3
- package/src/er/renderer.ts +11 -5
- package/src/gantt/calculator.ts +717 -0
- package/src/gantt/parser.ts +767 -0
- package/src/gantt/renderer.ts +2251 -0
- package/src/gantt/resolver.ts +144 -0
- package/src/gantt/types.ts +168 -0
- package/src/index.ts +27 -0
- package/src/infra/renderer.ts +48 -12
- package/src/initiative-status/filter.ts +63 -0
- package/src/initiative-status/layout.ts +319 -67
- package/src/initiative-status/parser.ts +200 -25
- package/src/initiative-status/renderer.ts +293 -10
- package/src/initiative-status/types.ts +6 -0
- package/src/org/layout.ts +22 -55
- package/src/org/parser.ts +7 -5
- package/src/org/renderer.ts +4 -8
- package/src/palettes/dracula.ts +60 -0
- package/src/palettes/index.ts +8 -6
- package/src/palettes/monokai.ts +60 -0
- package/src/palettes/registry.ts +4 -2
- package/src/sequence/parser.ts +10 -9
- package/src/sequence/renderer.ts +5 -4
- package/src/sharing.ts +8 -0
- package/src/sitemap/parser.ts +5 -3
- package/src/sitemap/renderer.ts +4 -4
- package/src/utils/duration.ts +212 -0
- package/src/utils/legend-constants.ts +1 -0
- package/src/utils/parsing.ts +23 -12
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Gantt Dot-Notation Task Resolver
|
|
3
|
+
// ============================================================
|
|
4
|
+
//
|
|
5
|
+
// Resolves `-> TargetName` dependency references to actual tasks.
|
|
6
|
+
// Implements greedy right-to-left dot splitting for disambiguation.
|
|
7
|
+
|
|
8
|
+
import type { GanttTask, GanttNode } from './types';
|
|
9
|
+
|
|
10
|
+
export interface ResolverMatch {
|
|
11
|
+
task: GanttTask;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ResolverError {
|
|
15
|
+
kind: 'not_found' | 'ambiguous';
|
|
16
|
+
message: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type ResolverResult = ResolverMatch | ResolverError;
|
|
20
|
+
|
|
21
|
+
export function isResolverError(r: ResolverResult): r is ResolverError {
|
|
22
|
+
return 'kind' in r;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Collect all tasks from a tree of GanttNodes, annotating each with its
|
|
27
|
+
* fully qualified group path (e.g., ["Backend", "API"]).
|
|
28
|
+
*/
|
|
29
|
+
export function collectTasks(nodes: GanttNode[]): GanttTask[] {
|
|
30
|
+
const tasks: GanttTask[] = [];
|
|
31
|
+
function walk(children: GanttNode[]) {
|
|
32
|
+
for (const node of children) {
|
|
33
|
+
if (node.kind === 'task') {
|
|
34
|
+
tasks.push(node);
|
|
35
|
+
} else if (node.kind === 'group' || node.kind === 'parallel') {
|
|
36
|
+
walk(node.children);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
walk(nodes);
|
|
41
|
+
return tasks;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve a dependency target name to a task.
|
|
46
|
+
*
|
|
47
|
+
* Resolution strategy (greedy right-to-left):
|
|
48
|
+
* 1. Try the full string as an exact task label match
|
|
49
|
+
* 2. If no match, split at the last dot → group prefix + task label
|
|
50
|
+
* 3. Recurse for deeper paths
|
|
51
|
+
*
|
|
52
|
+
* Returns a match or an error with helpful suggestions.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveTaskName(
|
|
55
|
+
name: string,
|
|
56
|
+
allTasks: GanttTask[],
|
|
57
|
+
): ResolverResult {
|
|
58
|
+
const trimmed = name.trim();
|
|
59
|
+
|
|
60
|
+
// 1. Try exact label match (no dots involved)
|
|
61
|
+
const exactMatches = allTasks.filter(t => t.label === trimmed);
|
|
62
|
+
if (exactMatches.length === 1) {
|
|
63
|
+
return { task: exactMatches[0] };
|
|
64
|
+
}
|
|
65
|
+
if (exactMatches.length > 1) {
|
|
66
|
+
// Multiple tasks with same name — need disambiguation
|
|
67
|
+
const suggestions = exactMatches.map(t =>
|
|
68
|
+
t.groupPath.length > 0 ? `${t.groupPath.join('.')}.${t.label}` : t.label
|
|
69
|
+
);
|
|
70
|
+
return {
|
|
71
|
+
kind: 'ambiguous',
|
|
72
|
+
message: `Multiple tasks match "${trimmed}". Did you mean ${suggestions.map(s => `\`${s}\``).join(' or ')}?`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 2. Try dot-notation: split at last dot (greedy right-to-left)
|
|
77
|
+
const lastDotIdx = trimmed.lastIndexOf('.');
|
|
78
|
+
if (lastDotIdx > 0) {
|
|
79
|
+
const groupPrefix = trimmed.substring(0, lastDotIdx);
|
|
80
|
+
const taskLabel = trimmed.substring(lastDotIdx + 1);
|
|
81
|
+
|
|
82
|
+
// Find tasks whose label matches and whose group path ends with the prefix
|
|
83
|
+
const matches = allTasks.filter(t => {
|
|
84
|
+
if (t.label !== taskLabel) return false;
|
|
85
|
+
return matchesGroupPath(t.groupPath, groupPrefix);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (matches.length === 1) {
|
|
89
|
+
return { task: matches[0] };
|
|
90
|
+
}
|
|
91
|
+
if (matches.length > 1) {
|
|
92
|
+
const suggestions = matches.map(t =>
|
|
93
|
+
t.groupPath.length > 0 ? `${t.groupPath.join('.')}.${t.label}` : t.label
|
|
94
|
+
);
|
|
95
|
+
return {
|
|
96
|
+
kind: 'ambiguous',
|
|
97
|
+
message: `Multiple tasks match "${trimmed}". Did you mean ${suggestions.map(s => `\`${s}\``).join(' or ')}?`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Try further left splits (for dots in group names)
|
|
102
|
+
// e.g., "U.S. Operations.Task A" — last dot split tried "U.S. Operations" + "Task A"
|
|
103
|
+
// Now try "U.S." + "Operations.Task A" — but that doesn't help.
|
|
104
|
+
// The greedy approach handles this: "U.S. Operations" is the group name.
|
|
105
|
+
// If the group name itself contains dots, the last dot split already tried the correct split.
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 3. No match found — try case-insensitive as a fallback for suggestions
|
|
109
|
+
const caseInsensitive = allTasks.filter(t =>
|
|
110
|
+
t.label.toLowerCase() === trimmed.toLowerCase()
|
|
111
|
+
);
|
|
112
|
+
if (caseInsensitive.length > 0) {
|
|
113
|
+
return {
|
|
114
|
+
kind: 'not_found',
|
|
115
|
+
message: `No task found with name "${trimmed}". Did you mean "${caseInsensitive[0].label}" (case mismatch)?`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
kind: 'not_found',
|
|
121
|
+
message: `No task found with name "${trimmed}".`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Check if a task's group path matches a dot-separated prefix.
|
|
127
|
+
* The prefix can be a single group name or a dot-separated path.
|
|
128
|
+
* Matching is done from the end of the group path.
|
|
129
|
+
*
|
|
130
|
+
* Example: groupPath = ["Backend", "API"], prefix = "Backend" → true
|
|
131
|
+
* Example: groupPath = ["Backend", "API"], prefix = "API" → true
|
|
132
|
+
* Example: groupPath = ["Backend", "API"], prefix = "Backend.API" → true
|
|
133
|
+
*/
|
|
134
|
+
function matchesGroupPath(groupPath: string[], prefix: string): boolean {
|
|
135
|
+
// Simple case: prefix is a single segment
|
|
136
|
+
if (!prefix.includes('.')) {
|
|
137
|
+
return groupPath.some(g => g === prefix);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Multi-segment prefix: try matching from the start of the group path
|
|
141
|
+
const pathStr = groupPath.join('.');
|
|
142
|
+
// Check if the full prefix matches any contiguous section of the path
|
|
143
|
+
return pathStr === prefix || pathStr.endsWith('.' + prefix) || pathStr.startsWith(prefix + '.') || pathStr.includes('.' + prefix + '.');
|
|
144
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Gantt Chart Types
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
import type { DgmoError } from '../diagnostics';
|
|
6
|
+
import type { TagGroup } from '../utils/tag-groups';
|
|
7
|
+
|
|
8
|
+
// ── Duration ────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/** Calendar units: d (days), w (weeks), m (months), q (quarters), y (years). bd = business days. */
|
|
11
|
+
export type DurationUnit = 'd' | 'bd' | 'w' | 'm' | 'q' | 'y';
|
|
12
|
+
|
|
13
|
+
export interface Duration {
|
|
14
|
+
amount: number;
|
|
15
|
+
unit: DurationUnit;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Offset {
|
|
19
|
+
duration: Duration;
|
|
20
|
+
direction: 1 | -1;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── Parsed Elements ─────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export interface GanttDependency {
|
|
26
|
+
targetName: string; // raw string from `-> X` or `-> Group.X`
|
|
27
|
+
offset?: Offset;
|
|
28
|
+
lineNumber: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface GanttTask {
|
|
32
|
+
id: string; // unique, generated during parse (e.g. "group:taskIdx")
|
|
33
|
+
label: string;
|
|
34
|
+
duration: Duration | null; // null for explicit-date-only tasks
|
|
35
|
+
explicitStart?: string; // YYYY-MM-DD from `2024-01-15 -> 30d:` or `2024-01-15:`
|
|
36
|
+
uncertain: boolean;
|
|
37
|
+
progress: number | null; // 0-100 or null
|
|
38
|
+
offset?: Offset; // task-level offset: shifts start date forward (+) or backward (-)
|
|
39
|
+
dependencies: GanttDependency[];
|
|
40
|
+
metadata: Record<string, string>; // tag values from pipe metadata
|
|
41
|
+
lineNumber: number;
|
|
42
|
+
groupPath: string[]; // e.g. ["Backend", "API"] for nested groups
|
|
43
|
+
comment?: string; // accumulated // comment lines
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface GanttGroup {
|
|
47
|
+
name: string;
|
|
48
|
+
color: string | null;
|
|
49
|
+
metadata: Record<string, string>;
|
|
50
|
+
lineNumber: number;
|
|
51
|
+
children: GanttNode[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface GanttParallelBlock {
|
|
55
|
+
kind: 'parallel';
|
|
56
|
+
lineNumber: number;
|
|
57
|
+
children: GanttNode[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A node in the gantt tree: either a task, group, or parallel block. */
|
|
61
|
+
export type GanttNode =
|
|
62
|
+
| ({ kind: 'task' } & GanttTask)
|
|
63
|
+
| ({ kind: 'group' } & GanttGroup)
|
|
64
|
+
| GanttParallelBlock;
|
|
65
|
+
|
|
66
|
+
// ── Holidays ────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
export type Weekday = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun';
|
|
69
|
+
|
|
70
|
+
export interface HolidayDate {
|
|
71
|
+
date: string; // YYYY-MM-DD
|
|
72
|
+
label: string;
|
|
73
|
+
lineNumber: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface HolidayRange {
|
|
77
|
+
startDate: string; // YYYY-MM-DD
|
|
78
|
+
endDate: string; // YYYY-MM-DD
|
|
79
|
+
label: string;
|
|
80
|
+
lineNumber: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface GanttHolidays {
|
|
84
|
+
dates: HolidayDate[];
|
|
85
|
+
ranges: HolidayRange[];
|
|
86
|
+
workweek: Weekday[]; // default: ['mon', 'tue', 'wed', 'thu', 'fri']
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Eras & Markers (reuse timeline types) ───────────────────
|
|
90
|
+
|
|
91
|
+
export interface GanttEra {
|
|
92
|
+
startDate: string;
|
|
93
|
+
endDate: string;
|
|
94
|
+
label: string;
|
|
95
|
+
color: string | null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface GanttMarker {
|
|
99
|
+
date: string;
|
|
100
|
+
label: string;
|
|
101
|
+
color: string | null;
|
|
102
|
+
lineNumber: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Chart Options ───────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
export interface GanttOptions {
|
|
108
|
+
start: string | null; // YYYY[-MM[-DD]] or null for relative timeline
|
|
109
|
+
title: string | null;
|
|
110
|
+
titleLineNumber: number | null;
|
|
111
|
+
orientation: 'horizontal' | 'vertical';
|
|
112
|
+
todayMarker: 'off' | 'on' | string; // 'on' = current date, string = YYYY-MM-DD
|
|
113
|
+
criticalPath: boolean;
|
|
114
|
+
dependencies: boolean;
|
|
115
|
+
sort: 'default' | 'tag';
|
|
116
|
+
defaultSwimlaneGroup: string | null; // tag group name from `sort: tag:Team`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Parsed Result ───────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
export interface ParsedGantt {
|
|
122
|
+
nodes: GanttNode[]; // top-level tree (groups, tasks, parallel blocks)
|
|
123
|
+
holidays: GanttHolidays;
|
|
124
|
+
tagGroups: TagGroup[];
|
|
125
|
+
eras: GanttEra[];
|
|
126
|
+
markers: GanttMarker[];
|
|
127
|
+
options: GanttOptions;
|
|
128
|
+
diagnostics: DgmoError[];
|
|
129
|
+
error: string | null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Resolved Schedule ───────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
export interface ResolvedTask {
|
|
135
|
+
task: GanttTask;
|
|
136
|
+
startDate: Date;
|
|
137
|
+
endDate: Date;
|
|
138
|
+
isCriticalPath: boolean;
|
|
139
|
+
isUncertain: boolean; // true if task.uncertain OR any predecessor is uncertain
|
|
140
|
+
isMilestone: boolean;
|
|
141
|
+
groupPath: string[];
|
|
142
|
+
effectiveMetadata: Record<string, string>; // merged with inherited tags
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface ResolvedGroup {
|
|
146
|
+
name: string;
|
|
147
|
+
color: string | null;
|
|
148
|
+
metadata: Record<string, string>;
|
|
149
|
+
startDate: Date;
|
|
150
|
+
endDate: Date;
|
|
151
|
+
progress: number | null; // aggregate progress (weighted average)
|
|
152
|
+
lineNumber: number;
|
|
153
|
+
depth: number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface ResolvedSchedule {
|
|
157
|
+
tasks: ResolvedTask[];
|
|
158
|
+
groups: ResolvedGroup[];
|
|
159
|
+
startDate: Date;
|
|
160
|
+
endDate: Date;
|
|
161
|
+
holidays: GanttHolidays;
|
|
162
|
+
tagGroups: TagGroup[];
|
|
163
|
+
eras: GanttEra[];
|
|
164
|
+
markers: GanttMarker[];
|
|
165
|
+
options: GanttOptions;
|
|
166
|
+
diagnostics: DgmoError[];
|
|
167
|
+
error: string | null;
|
|
168
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -217,10 +217,13 @@ export type {
|
|
|
217
217
|
} from './initiative-status/layout';
|
|
218
218
|
|
|
219
219
|
export { renderInitiativeStatus, renderInitiativeStatusForExport } from './initiative-status/renderer';
|
|
220
|
+
export type { ISRenderOptions } from './initiative-status/renderer';
|
|
220
221
|
|
|
221
222
|
export { collapseInitiativeStatus } from './initiative-status/collapse';
|
|
222
223
|
export type { CollapseResult } from './initiative-status/collapse';
|
|
223
224
|
|
|
225
|
+
export { filterInitiativeStatusByTags } from './initiative-status/filter';
|
|
226
|
+
|
|
224
227
|
export { parseSitemap, looksLikeSitemap } from './sitemap/parser';
|
|
225
228
|
|
|
226
229
|
export type {
|
|
@@ -259,6 +262,30 @@ export { renderInfra, parseAndLayoutInfra, computeInfraLegendGroups } from './in
|
|
|
259
262
|
export type { InfraLegendGroup } from './infra/renderer';
|
|
260
263
|
export type { CollapsedSitemapResult } from './sitemap/collapse';
|
|
261
264
|
|
|
265
|
+
// ── Gantt Chart ───────────────────────────────────────────
|
|
266
|
+
export { parseGantt } from './gantt/parser';
|
|
267
|
+
export { calculateSchedule } from './gantt/calculator';
|
|
268
|
+
export { renderGantt, buildTagLaneRowList } from './gantt/renderer';
|
|
269
|
+
export type { GanttInteractiveOptions, GanttRow, GanttGroupRow, GanttTaskRow, GanttLaneHeaderRow } from './gantt/renderer';
|
|
270
|
+
export { resolveTaskName, collectTasks } from './gantt/resolver';
|
|
271
|
+
export type {
|
|
272
|
+
ParsedGantt,
|
|
273
|
+
GanttTask,
|
|
274
|
+
GanttGroup,
|
|
275
|
+
GanttParallelBlock,
|
|
276
|
+
GanttNode,
|
|
277
|
+
GanttDependency,
|
|
278
|
+
GanttHolidays,
|
|
279
|
+
GanttEra,
|
|
280
|
+
GanttMarker,
|
|
281
|
+
GanttOptions,
|
|
282
|
+
Duration,
|
|
283
|
+
DurationUnit,
|
|
284
|
+
ResolvedSchedule,
|
|
285
|
+
ResolvedTask,
|
|
286
|
+
ResolvedGroup,
|
|
287
|
+
} from './gantt/types';
|
|
288
|
+
|
|
262
289
|
export { collapseOrgTree } from './org/collapse';
|
|
263
290
|
export type { CollapsedOrgResult } from './org/collapse';
|
|
264
291
|
|
package/src/infra/renderer.ts
CHANGED
|
@@ -1667,8 +1667,8 @@ function renderLegend(
|
|
|
1667
1667
|
const isActive = activeGroup != null && group.name.toLowerCase() === activeGroup.toLowerCase();
|
|
1668
1668
|
|
|
1669
1669
|
const groupBg = isDark
|
|
1670
|
-
? mix(palette.
|
|
1671
|
-
: mix(palette.
|
|
1670
|
+
? mix(palette.surface, palette.bg, 50)
|
|
1671
|
+
: mix(palette.surface, palette.bg, 30);
|
|
1672
1672
|
|
|
1673
1673
|
const pillLabel = group.name;
|
|
1674
1674
|
const pillWidth = pillLabel.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
|
|
@@ -1798,12 +1798,42 @@ export function renderInfra(
|
|
|
1798
1798
|
|
|
1799
1799
|
const shouldAnimate = animate !== false;
|
|
1800
1800
|
|
|
1801
|
+
// In app mode with legend + title, render the title as a separate fixed-size SVG
|
|
1802
|
+
// so the legend can be inserted between title and diagram.
|
|
1803
|
+
const fixedTitleH = fixedLegend && title ? 40 : 0;
|
|
1804
|
+
const diagramViewHeight = fixedLegend
|
|
1805
|
+
? layout.height + (title && !fixedTitleH ? titleOffset : 0) + legendOffset
|
|
1806
|
+
: totalHeight;
|
|
1807
|
+
|
|
1808
|
+
if (fixedTitleH) {
|
|
1809
|
+
const titleSvg = d3Selection.select(container)
|
|
1810
|
+
.append('svg')
|
|
1811
|
+
.attr('class', 'infra-title-fixed')
|
|
1812
|
+
.attr('width', '100%')
|
|
1813
|
+
.attr('height', fixedTitleH)
|
|
1814
|
+
.attr('viewBox', `0 0 ${totalWidth} ${fixedTitleH}`)
|
|
1815
|
+
.attr('preserveAspectRatio', 'xMidYMid meet')
|
|
1816
|
+
.style('display', 'block');
|
|
1817
|
+
titleSvg.append('text')
|
|
1818
|
+
.attr('class', 'chart-title')
|
|
1819
|
+
.attr('x', totalWidth / 2)
|
|
1820
|
+
.attr('y', 28)
|
|
1821
|
+
.attr('text-anchor', 'middle')
|
|
1822
|
+
.attr('font-family', FONT_FAMILY)
|
|
1823
|
+
.attr('font-size', 18)
|
|
1824
|
+
.attr('font-weight', '700')
|
|
1825
|
+
.attr('fill', palette.text)
|
|
1826
|
+
.attr('data-line-number', titleLineNumber != null ? titleLineNumber : '')
|
|
1827
|
+
.text(title!);
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
const fixedOverheadH = (fixedLegend ? LEGEND_HEIGHT + LEGEND_FIXED_GAP : 0) + fixedTitleH;
|
|
1801
1831
|
const rootSvg = d3Selection.select(container)
|
|
1802
1832
|
.append('svg')
|
|
1803
1833
|
.attr('xmlns', 'http://www.w3.org/2000/svg')
|
|
1804
1834
|
.attr('width', '100%')
|
|
1805
|
-
.attr('height',
|
|
1806
|
-
.attr('viewBox', `0 0 ${totalWidth} ${
|
|
1835
|
+
.attr('height', fixedOverheadH > 0 ? `calc(100% - ${fixedOverheadH}px)` : '100%')
|
|
1836
|
+
.attr('viewBox', `0 0 ${totalWidth} ${diagramViewHeight}`)
|
|
1807
1837
|
.attr('preserveAspectRatio', 'xMidYMid meet');
|
|
1808
1838
|
|
|
1809
1839
|
// Inject animation keyframes + edge label hover styles
|
|
@@ -1850,11 +1880,13 @@ export function renderInfra(
|
|
|
1850
1880
|
`);
|
|
1851
1881
|
}
|
|
1852
1882
|
|
|
1883
|
+
// Content group offset: skip title space (unless title was extracted to fixed SVG)
|
|
1884
|
+
const contentTitleOffset = fixedTitleH ? 0 : titleOffset;
|
|
1853
1885
|
const svg = rootSvg.append('g')
|
|
1854
|
-
.attr('transform', `translate(0, ${
|
|
1886
|
+
.attr('transform', `translate(0, ${contentTitleOffset + legendOffset})`);
|
|
1855
1887
|
|
|
1856
|
-
// Title
|
|
1857
|
-
if (title) {
|
|
1888
|
+
// Title (inside rootSvg when not using fixed title)
|
|
1889
|
+
if (title && !fixedTitleH) {
|
|
1858
1890
|
rootSvg.append('text')
|
|
1859
1891
|
.attr('class', 'chart-title')
|
|
1860
1892
|
.attr('x', totalWidth / 2)
|
|
@@ -1887,22 +1919,26 @@ export function renderInfra(
|
|
|
1887
1919
|
}
|
|
1888
1920
|
renderEdgeLabels(svg, layout.edges, layout.nodes, layout.groups, palette, isDark, shouldAnimate, layout.direction);
|
|
1889
1921
|
|
|
1890
|
-
// Legend at
|
|
1922
|
+
// Legend at top
|
|
1891
1923
|
if (hasLegend) {
|
|
1892
1924
|
if (fixedLegend) {
|
|
1893
|
-
// Render legend in a separate SVG that stays at fixed pixel size
|
|
1925
|
+
// Render legend in a separate SVG that stays at fixed pixel size, inserted between title and diagram
|
|
1894
1926
|
const containerWidth = container.clientWidth || totalWidth;
|
|
1895
1927
|
const legendSvg = d3Selection.select(container)
|
|
1896
|
-
.
|
|
1928
|
+
.insert('svg', 'svg:last-of-type')
|
|
1897
1929
|
.attr('class', 'infra-legend-fixed')
|
|
1898
1930
|
.attr('width', '100%')
|
|
1899
1931
|
.attr('height', LEGEND_HEIGHT + LEGEND_FIXED_GAP)
|
|
1900
1932
|
.attr('viewBox', `0 0 ${containerWidth} ${LEGEND_HEIGHT + LEGEND_FIXED_GAP}`)
|
|
1901
1933
|
.attr('preserveAspectRatio', 'xMidYMid meet')
|
|
1902
|
-
.style('display', 'block')
|
|
1934
|
+
.style('display', 'block')
|
|
1935
|
+
.style('pointer-events', 'none');
|
|
1903
1936
|
renderLegend(legendSvg, legendGroups, containerWidth, LEGEND_FIXED_GAP / 2, palette, isDark, activeGroup ?? null);
|
|
1937
|
+
// Re-enable pointer events on interactive legend elements
|
|
1938
|
+
legendSvg.selectAll('.infra-legend-group').style('pointer-events', 'auto');
|
|
1904
1939
|
} else {
|
|
1905
|
-
|
|
1940
|
+
// Export mode: render legend at top (below title)
|
|
1941
|
+
renderLegend(rootSvg, legendGroups, totalWidth, titleOffset, palette, isDark, activeGroup ?? null);
|
|
1906
1942
|
}
|
|
1907
1943
|
}
|
|
1908
1944
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Initiative Status — Tag-Based Filter
|
|
3
|
+
//
|
|
4
|
+
// Immutable graph transform: returns a new ParsedInitiativeStatus
|
|
5
|
+
// with hidden-value nodes removed, their edges dropped,
|
|
6
|
+
// and group.nodeLabels cleaned.
|
|
7
|
+
// ============================================================
|
|
8
|
+
|
|
9
|
+
import type { ParsedInitiativeStatus } from './types';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Filter an initiative-status graph by hiding nodes whose tag metadata
|
|
13
|
+
* matches any hidden value. Returns a new (immutable copy) ParsedInitiativeStatus.
|
|
14
|
+
*
|
|
15
|
+
* @param parsed Fully-resolved parsed result (defaults already injected)
|
|
16
|
+
* @param hiddenTagValues Map<groupKey, Set<hiddenValues>> — all keys/values lowercase
|
|
17
|
+
* @returns Filtered copy; original is not mutated
|
|
18
|
+
*/
|
|
19
|
+
export function filterInitiativeStatusByTags(
|
|
20
|
+
parsed: ParsedInitiativeStatus,
|
|
21
|
+
hiddenTagValues: Map<string, Set<string>>
|
|
22
|
+
): ParsedInitiativeStatus {
|
|
23
|
+
// Fast path: no filtering
|
|
24
|
+
if (hiddenTagValues.size === 0) return parsed;
|
|
25
|
+
|
|
26
|
+
// Build set of hidden node labels
|
|
27
|
+
const hiddenNodeLabels = new Set<string>();
|
|
28
|
+
for (const node of parsed.nodes) {
|
|
29
|
+
for (const [groupKey, hiddenValues] of hiddenTagValues) {
|
|
30
|
+
const nodeValue = node.metadata[groupKey];
|
|
31
|
+
if (nodeValue && hiddenValues.has(nodeValue.toLowerCase())) {
|
|
32
|
+
hiddenNodeLabels.add(node.label);
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// No nodes hidden — return input unchanged
|
|
39
|
+
if (hiddenNodeLabels.size === 0) return parsed;
|
|
40
|
+
|
|
41
|
+
// Filter nodes
|
|
42
|
+
const nodes = parsed.nodes.filter((n) => !hiddenNodeLabels.has(n.label));
|
|
43
|
+
|
|
44
|
+
// Filter edges: remove edges where source OR target is hidden
|
|
45
|
+
const edges = parsed.edges.filter(
|
|
46
|
+
(e) => !hiddenNodeLabels.has(e.source) && !hiddenNodeLabels.has(e.target)
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Clean group nodeLabels; remove empty groups
|
|
50
|
+
const groups = parsed.groups
|
|
51
|
+
.map((g) => ({
|
|
52
|
+
...g,
|
|
53
|
+
nodeLabels: g.nodeLabels.filter((l) => !hiddenNodeLabels.has(l)),
|
|
54
|
+
}))
|
|
55
|
+
.filter((g) => g.nodeLabels.length > 0);
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
...parsed,
|
|
59
|
+
nodes,
|
|
60
|
+
edges,
|
|
61
|
+
groups,
|
|
62
|
+
};
|
|
63
|
+
}
|