@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.
Files changed (44) hide show
  1. package/dist/cli.cjs +180 -178
  2. package/dist/index.cjs +5447 -2229
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +236 -16
  5. package/dist/index.d.ts +236 -16
  6. package/dist/index.js +5439 -2228
  7. package/dist/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/c4/parser.ts +3 -2
  10. package/src/c4/renderer.ts +6 -6
  11. package/src/class/renderer.ts +183 -7
  12. package/src/cli.ts +3 -11
  13. package/src/colors.ts +3 -3
  14. package/src/d3.ts +132 -29
  15. package/src/dgmo-router.ts +3 -1
  16. package/src/er/parser.ts +5 -3
  17. package/src/er/renderer.ts +11 -5
  18. package/src/gantt/calculator.ts +717 -0
  19. package/src/gantt/parser.ts +767 -0
  20. package/src/gantt/renderer.ts +2251 -0
  21. package/src/gantt/resolver.ts +144 -0
  22. package/src/gantt/types.ts +168 -0
  23. package/src/index.ts +27 -0
  24. package/src/infra/renderer.ts +48 -12
  25. package/src/initiative-status/filter.ts +63 -0
  26. package/src/initiative-status/layout.ts +319 -67
  27. package/src/initiative-status/parser.ts +200 -25
  28. package/src/initiative-status/renderer.ts +293 -10
  29. package/src/initiative-status/types.ts +6 -0
  30. package/src/org/layout.ts +22 -55
  31. package/src/org/parser.ts +7 -5
  32. package/src/org/renderer.ts +4 -8
  33. package/src/palettes/dracula.ts +60 -0
  34. package/src/palettes/index.ts +8 -6
  35. package/src/palettes/monokai.ts +60 -0
  36. package/src/palettes/registry.ts +4 -2
  37. package/src/sequence/parser.ts +10 -9
  38. package/src/sequence/renderer.ts +5 -4
  39. package/src/sharing.ts +8 -0
  40. package/src/sitemap/parser.ts +5 -3
  41. package/src/sitemap/renderer.ts +4 -4
  42. package/src/utils/duration.ts +212 -0
  43. package/src/utils/legend-constants.ts +1 -0
  44. 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
 
@@ -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.bg, palette.text, 85)
1671
- : mix(palette.bg, palette.text, 92);
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', fixedLegend ? `calc(100% - ${LEGEND_HEIGHT + LEGEND_FIXED_GAP}px)` : '100%')
1806
- .attr('viewBox', `0 0 ${totalWidth} ${totalHeight}`)
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, ${titleOffset})`);
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 bottom
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
- .append('svg')
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
- renderLegend(rootSvg, legendGroups, totalWidth, titleOffset + layout.height + 4, palette, isDark, activeGroup ?? null);
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
+ }