@diagrammo/dgmo 0.6.2 → 0.7.0
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/.claude/commands/dgmo.md +231 -13
- package/AGENTS.md +148 -0
- package/dist/cli.cjs +341 -165
- package/dist/index.cjs +4900 -1685
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +259 -18
- package/dist/index.d.ts +259 -18
- package/dist/index.js +4642 -1436
- package/dist/index.js.map +1 -1
- package/package.json +5 -3
- package/src/c4/layout.ts +0 -5
- package/src/c4/parser.ts +0 -16
- package/src/c4/renderer.ts +7 -11
- package/src/class/layout.ts +0 -1
- package/src/class/parser.ts +28 -0
- package/src/class/renderer.ts +189 -34
- package/src/cli.ts +566 -25
- package/src/colors.ts +3 -3
- package/src/completion.ts +58 -0
- package/src/d3.ts +179 -122
- package/src/dgmo-router.ts +3 -58
- package/src/echarts.ts +96 -55
- package/src/er/parser.ts +30 -1
- package/src/er/renderer.ts +12 -7
- package/src/gantt/calculator.ts +677 -0
- package/src/gantt/parser.ts +761 -0
- package/src/gantt/renderer.ts +2125 -0
- package/src/gantt/resolver.ts +144 -0
- package/src/gantt/types.ts +168 -0
- package/src/graph/flowchart-parser.ts +27 -4
- package/src/graph/flowchart-renderer.ts +1 -2
- package/src/graph/state-parser.ts +0 -1
- package/src/graph/state-renderer.ts +1 -3
- package/src/index.ts +37 -0
- package/src/infra/compute.ts +0 -7
- package/src/infra/layout.ts +0 -2
- package/src/infra/parser.ts +46 -4
- package/src/infra/renderer.ts +49 -27
- 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 +298 -35
- package/src/initiative-status/types.ts +6 -0
- package/src/kanban/parser.ts +0 -2
- package/src/org/layout.ts +22 -59
- package/src/org/renderer.ts +11 -36
- 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 +14 -11
- package/src/sequence/renderer.ts +5 -6
- package/src/sequence/tag-resolution.ts +0 -1
- package/src/sharing.ts +8 -0
- package/src/sitemap/layout.ts +1 -14
- package/src/sitemap/parser.ts +1 -2
- package/src/sitemap/renderer.ts +4 -7
- package/src/utils/arrows.ts +7 -7
- package/src/utils/duration.ts +212 -0
- package/src/utils/export-container.ts +40 -0
- package/src/utils/legend-constants.ts +1 -0
package/src/infra/renderer.ts
CHANGED
|
@@ -7,12 +7,10 @@ import * as d3Shape from 'd3-shape';
|
|
|
7
7
|
import { FONT_FAMILY } from '../fonts';
|
|
8
8
|
import type { PaletteColors } from '../palettes';
|
|
9
9
|
import { mix } from '../palettes/color-utils';
|
|
10
|
-
import type {
|
|
10
|
+
import type { InfraTagGroup } from './types';
|
|
11
11
|
import { resolveColor } from '../colors';
|
|
12
|
-
import type { ComputedInfraModel } from './types';
|
|
13
12
|
import type { InfraLayoutResult, InfraLayoutNode, InfraLayoutEdge, InfraLayoutGroup } from './layout';
|
|
14
13
|
import { inferRoles, collectDiagramRoles, collectFanoutSourceIds, FANOUT_ROLE } from './roles';
|
|
15
|
-
import type { InfraRole } from './roles';
|
|
16
14
|
import { parseInfra } from './parser';
|
|
17
15
|
import { computeInfra } from './compute';
|
|
18
16
|
import { layoutInfra } from './layout';
|
|
@@ -34,11 +32,9 @@ import {
|
|
|
34
32
|
// Constants
|
|
35
33
|
// ============================================================
|
|
36
34
|
|
|
37
|
-
const DIAGRAM_PADDING = 20;
|
|
38
35
|
const NODE_FONT_SIZE = 13;
|
|
39
36
|
const META_FONT_SIZE = 10;
|
|
40
37
|
const META_LINE_HEIGHT = 14;
|
|
41
|
-
const RPS_FONT_SIZE = 11;
|
|
42
38
|
const EDGE_LABEL_FONT_SIZE = 11;
|
|
43
39
|
const GROUP_LABEL_FONT_SIZE = 14;
|
|
44
40
|
const NODE_BORDER_RADIUS = 8;
|
|
@@ -58,8 +54,6 @@ const LEGEND_FIXED_GAP = 16; // gap between fixed legend and scaled diagram —
|
|
|
58
54
|
const COLOR_HEALTHY = '#22c55e';
|
|
59
55
|
const COLOR_WARNING = '#eab308';
|
|
60
56
|
const COLOR_OVERLOADED = '#ef4444';
|
|
61
|
-
const COLOR_NEUTRAL = '#94a3b8';
|
|
62
|
-
|
|
63
57
|
/** SLO thresholds resolved for a single node (chart-level + per-node override). */
|
|
64
58
|
interface NodeSlo {
|
|
65
59
|
availThreshold: number | null; // fraction e.g. 0.999
|
|
@@ -106,11 +100,8 @@ interface ComputedRow {
|
|
|
106
100
|
}
|
|
107
101
|
|
|
108
102
|
// Animation constants
|
|
109
|
-
const FLOW_DASH = '8 6'; // dash-array for animated edges
|
|
110
|
-
const FLOW_DASH_LEN = 14; // sum of dash + gap (for offset keyframe)
|
|
111
103
|
const FLOW_SPEED_MIN = 2.5; // seconds at max RPS
|
|
112
104
|
const FLOW_SPEED_MAX = 6; // seconds at min RPS
|
|
113
|
-
const OVERLOAD_PULSE_SPEED = 0.8; // seconds for overload pulse cycle
|
|
114
105
|
const PARTICLE_R = 5; // particle circle radius
|
|
115
106
|
const PARTICLE_COUNT_MIN = 1; // min particles per edge
|
|
116
107
|
const PARTICLE_COUNT_MAX = 4; // max particles per edge (at max RPS)
|
|
@@ -742,11 +733,6 @@ function getDisplayProps(node: InfraLayoutNode, expanded: boolean, diagramOption
|
|
|
742
733
|
}
|
|
743
734
|
|
|
744
735
|
|
|
745
|
-
function formatRps(rps: number): string {
|
|
746
|
-
if (rps >= 1000) return `${(rps / 1000).toFixed(1)}k rps`;
|
|
747
|
-
return `${Math.round(rps)} rps`;
|
|
748
|
-
}
|
|
749
|
-
|
|
750
736
|
/** RPS value without "rps" suffix — for key-value rows where the key already says "RPS". */
|
|
751
737
|
function formatRpsShort(rps: number): string {
|
|
752
738
|
if (rps >= 1000) return `${(rps / 1000).toFixed(1)}k`;
|
|
@@ -1681,8 +1667,8 @@ function renderLegend(
|
|
|
1681
1667
|
const isActive = activeGroup != null && group.name.toLowerCase() === activeGroup.toLowerCase();
|
|
1682
1668
|
|
|
1683
1669
|
const groupBg = isDark
|
|
1684
|
-
? mix(palette.
|
|
1685
|
-
: mix(palette.
|
|
1670
|
+
? mix(palette.surface, palette.bg, 50)
|
|
1671
|
+
: mix(palette.surface, palette.bg, 30);
|
|
1686
1672
|
|
|
1687
1673
|
const pillLabel = group.name;
|
|
1688
1674
|
const pillWidth = pillLabel.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
|
|
@@ -1812,12 +1798,42 @@ export function renderInfra(
|
|
|
1812
1798
|
|
|
1813
1799
|
const shouldAnimate = animate !== false;
|
|
1814
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;
|
|
1815
1831
|
const rootSvg = d3Selection.select(container)
|
|
1816
1832
|
.append('svg')
|
|
1817
1833
|
.attr('xmlns', 'http://www.w3.org/2000/svg')
|
|
1818
1834
|
.attr('width', '100%')
|
|
1819
|
-
.attr('height',
|
|
1820
|
-
.attr('viewBox', `0 0 ${totalWidth} ${
|
|
1835
|
+
.attr('height', fixedOverheadH > 0 ? `calc(100% - ${fixedOverheadH}px)` : '100%')
|
|
1836
|
+
.attr('viewBox', `0 0 ${totalWidth} ${diagramViewHeight}`)
|
|
1821
1837
|
.attr('preserveAspectRatio', 'xMidYMid meet');
|
|
1822
1838
|
|
|
1823
1839
|
// Inject animation keyframes + edge label hover styles
|
|
@@ -1864,11 +1880,13 @@ export function renderInfra(
|
|
|
1864
1880
|
`);
|
|
1865
1881
|
}
|
|
1866
1882
|
|
|
1883
|
+
// Content group offset: skip title space (unless title was extracted to fixed SVG)
|
|
1884
|
+
const contentTitleOffset = fixedTitleH ? 0 : titleOffset;
|
|
1867
1885
|
const svg = rootSvg.append('g')
|
|
1868
|
-
.attr('transform', `translate(0, ${
|
|
1886
|
+
.attr('transform', `translate(0, ${contentTitleOffset + legendOffset})`);
|
|
1869
1887
|
|
|
1870
|
-
// Title
|
|
1871
|
-
if (title) {
|
|
1888
|
+
// Title (inside rootSvg when not using fixed title)
|
|
1889
|
+
if (title && !fixedTitleH) {
|
|
1872
1890
|
rootSvg.append('text')
|
|
1873
1891
|
.attr('class', 'chart-title')
|
|
1874
1892
|
.attr('x', totalWidth / 2)
|
|
@@ -1901,22 +1919,26 @@ export function renderInfra(
|
|
|
1901
1919
|
}
|
|
1902
1920
|
renderEdgeLabels(svg, layout.edges, layout.nodes, layout.groups, palette, isDark, shouldAnimate, layout.direction);
|
|
1903
1921
|
|
|
1904
|
-
// Legend at
|
|
1922
|
+
// Legend at top
|
|
1905
1923
|
if (hasLegend) {
|
|
1906
1924
|
if (fixedLegend) {
|
|
1907
|
-
// 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
|
|
1908
1926
|
const containerWidth = container.clientWidth || totalWidth;
|
|
1909
1927
|
const legendSvg = d3Selection.select(container)
|
|
1910
|
-
.
|
|
1928
|
+
.insert('svg', 'svg:last-of-type')
|
|
1911
1929
|
.attr('class', 'infra-legend-fixed')
|
|
1912
1930
|
.attr('width', '100%')
|
|
1913
1931
|
.attr('height', LEGEND_HEIGHT + LEGEND_FIXED_GAP)
|
|
1914
1932
|
.attr('viewBox', `0 0 ${containerWidth} ${LEGEND_HEIGHT + LEGEND_FIXED_GAP}`)
|
|
1915
1933
|
.attr('preserveAspectRatio', 'xMidYMid meet')
|
|
1916
|
-
.style('display', 'block')
|
|
1934
|
+
.style('display', 'block')
|
|
1935
|
+
.style('pointer-events', 'none');
|
|
1917
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');
|
|
1918
1939
|
} else {
|
|
1919
|
-
|
|
1940
|
+
// Export mode: render legend at top (below title)
|
|
1941
|
+
renderLegend(rootSvg, legendGroups, totalWidth, titleOffset, palette, isDark, activeGroup ?? null);
|
|
1920
1942
|
}
|
|
1921
1943
|
}
|
|
1922
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
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// ============================================================
|
|
2
2
|
// Initiative Status Diagram — Layout
|
|
3
3
|
//
|
|
4
|
-
// Uses dagre for rank assignment
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// Uses dagre for rank assignment and crossing minimization.
|
|
5
|
+
// Post-dagre grid quantization snaps Y positions to a fixed
|
|
6
|
+
// grid for horizontal alignment across columns.
|
|
7
7
|
// ============================================================
|
|
8
8
|
|
|
9
9
|
import dagre from '@dagrejs/dagre';
|
|
@@ -19,6 +19,7 @@ export interface ISLayoutNode {
|
|
|
19
19
|
y: number;
|
|
20
20
|
width: number;
|
|
21
21
|
height: number;
|
|
22
|
+
metadata: Record<string, string>;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export interface ISLayoutEdge {
|
|
@@ -74,7 +75,8 @@ const PHI = 1.618;
|
|
|
74
75
|
const NODE_HEIGHT = 60;
|
|
75
76
|
const NODE_WIDTH = Math.round(NODE_HEIGHT * PHI);
|
|
76
77
|
const GROUP_PADDING = 20;
|
|
77
|
-
const
|
|
78
|
+
const GROUP_LABEL_HEIGHT = 20; // approximate height of the group label text rendered above the box
|
|
79
|
+
const NODESEP = 100;
|
|
78
80
|
const RANKSEP = 160;
|
|
79
81
|
const PARALLEL_SPACING = 16; // px between parallel edges sharing same source→target (~27% of NODE_HEIGHT)
|
|
80
82
|
const PARALLEL_EDGE_MARGIN = 12; // total vertical margin reserved at top+bottom of node for edge bundles (6px each side)
|
|
@@ -85,6 +87,138 @@ const TOP_EXIT_STEP = 10; // px: control-point offset giving near-vertical depar
|
|
|
85
87
|
const CHAR_WIDTH_RATIO = 0.6;
|
|
86
88
|
const NODE_FONT_SIZE = 13;
|
|
87
89
|
const NODE_TEXT_PADDING = 12;
|
|
90
|
+
const GRID_ROW_HEIGHT = NODESEP; // 80px — one node (60px) + gap (20px)
|
|
91
|
+
const COLUMN_X_TOLERANCE = 5; // px — dagre may offset same-rank nodes slightly
|
|
92
|
+
|
|
93
|
+
// ============================================================
|
|
94
|
+
// Grid quantization — replaces dagre's freeform Y with a fixed
|
|
95
|
+
// grid while preserving dagre's rank assignment and crossing-
|
|
96
|
+
// minimized within-column ordering.
|
|
97
|
+
// ============================================================
|
|
98
|
+
|
|
99
|
+
interface GridNode {
|
|
100
|
+
label: string;
|
|
101
|
+
x: number;
|
|
102
|
+
y: number;
|
|
103
|
+
width: number;
|
|
104
|
+
height: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Find nearest available grid row, searching outward. Tie-breaks downward. */
|
|
108
|
+
function findNearestAvailable(preferred: number, taken: Set<number>): number {
|
|
109
|
+
if (!taken.has(preferred)) return preferred;
|
|
110
|
+
for (let delta = 1; ; delta++) {
|
|
111
|
+
if (!taken.has(preferred + delta)) return preferred + delta;
|
|
112
|
+
if (!taken.has(preferred - delta)) return preferred - delta;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function gridQuantize(
|
|
117
|
+
nodes: GridNode[],
|
|
118
|
+
edges: { source: string; target: string }[]
|
|
119
|
+
): void {
|
|
120
|
+
if (nodes.length === 0) return;
|
|
121
|
+
|
|
122
|
+
// 1. Cluster columns by X with tolerance
|
|
123
|
+
const columns: GridNode[][] = [];
|
|
124
|
+
const sorted = [...nodes].sort((a, b) => a.x - b.x);
|
|
125
|
+
for (const node of sorted) {
|
|
126
|
+
const lastCol = columns[columns.length - 1];
|
|
127
|
+
if (lastCol && Math.abs(node.x - lastCol[0].x) <= COLUMN_X_TOLERANCE) {
|
|
128
|
+
lastCol.push(node);
|
|
129
|
+
} else {
|
|
130
|
+
columns.push([node]);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Normalize X within each column to the mean, sort nodes by dagre Y
|
|
135
|
+
for (const col of columns) {
|
|
136
|
+
const meanX = col.reduce((s, n) => s + n.x, 0) / col.length;
|
|
137
|
+
for (const n of col) n.x = meanX;
|
|
138
|
+
col.sort((a, b) => a.y - b.y);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 2. Build upstream map: target → source labels
|
|
142
|
+
const upstreamMap = new Map<string, string[]>();
|
|
143
|
+
for (const edge of edges) {
|
|
144
|
+
const list = upstreamMap.get(edge.target);
|
|
145
|
+
if (list) list.push(edge.source);
|
|
146
|
+
else upstreamMap.set(edge.target, [edge.source]);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 3. Assign grid rows column by column, left to right
|
|
150
|
+
const rowAssignment = new Map<string, number>();
|
|
151
|
+
|
|
152
|
+
for (const col of columns) {
|
|
153
|
+
const takenRows = new Set<number>();
|
|
154
|
+
const preferredRows: number[] = [];
|
|
155
|
+
|
|
156
|
+
for (const node of col) {
|
|
157
|
+
const upstreams = upstreamMap.get(node.label);
|
|
158
|
+
let preferred: number;
|
|
159
|
+
|
|
160
|
+
if (upstreams && upstreams.length > 0) {
|
|
161
|
+
const upstreamRows = upstreams
|
|
162
|
+
.map((l) => rowAssignment.get(l))
|
|
163
|
+
.filter((r): r is number => r !== undefined);
|
|
164
|
+
|
|
165
|
+
if (upstreamRows.length === 1) {
|
|
166
|
+
preferred = upstreamRows[0];
|
|
167
|
+
} else if (upstreamRows.length > 1) {
|
|
168
|
+
upstreamRows.sort((a, b) => a - b);
|
|
169
|
+
const mid = Math.floor(upstreamRows.length / 2);
|
|
170
|
+
preferred =
|
|
171
|
+
upstreamRows.length % 2 === 0
|
|
172
|
+
? Math.round((upstreamRows[mid - 1] + upstreamRows[mid]) / 2)
|
|
173
|
+
: upstreamRows[mid];
|
|
174
|
+
} else {
|
|
175
|
+
preferred = preferredRows.length;
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
preferred = preferredRows.length;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
preferredRows.push(preferred);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Order preservation: preferred rows must be monotonically non-decreasing
|
|
185
|
+
let monotone = true;
|
|
186
|
+
for (let i = 1; i < preferredRows.length; i++) {
|
|
187
|
+
if (preferredRows[i] < preferredRows[i - 1]) {
|
|
188
|
+
monotone = false;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!monotone) {
|
|
194
|
+
const minRow = Math.min(...preferredRows);
|
|
195
|
+
for (let i = 0; i < col.length; i++) {
|
|
196
|
+
preferredRows[i] = minRow + i;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Resolve collisions
|
|
201
|
+
for (let i = 0; i < col.length; i++) {
|
|
202
|
+
const row = findNearestAvailable(preferredRows[i], takenRows);
|
|
203
|
+
takenRows.add(row);
|
|
204
|
+
rowAssignment.set(col[i].label, row);
|
|
205
|
+
col[i].y = row * GRID_ROW_HEIGHT;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 4. Vertical centering, ensure minY >= 20
|
|
210
|
+
const allY = nodes.map((n) => n.y);
|
|
211
|
+
const minY = Math.min(...allY);
|
|
212
|
+
const maxY = Math.max(...allY);
|
|
213
|
+
const centerOffset = -(minY + maxY) / 2;
|
|
214
|
+
for (const n of nodes) n.y += centerOffset;
|
|
215
|
+
|
|
216
|
+
const adjustedMinY = Math.min(...nodes.map((n) => n.y));
|
|
217
|
+
if (adjustedMinY < 20) {
|
|
218
|
+
const shift = 20 - adjustedMinY;
|
|
219
|
+
for (const n of nodes) n.y += shift;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
88
222
|
|
|
89
223
|
// ============================================================
|
|
90
224
|
// Main layout function
|
|
@@ -144,7 +278,7 @@ export function layoutInitiativeStatus(
|
|
|
144
278
|
|
|
145
279
|
dagre.layout(g);
|
|
146
280
|
|
|
147
|
-
// Extract node positions
|
|
281
|
+
// Extract node positions from dagre
|
|
148
282
|
const layoutNodes: ISLayoutNode[] = parsed.nodes.map((node) => {
|
|
149
283
|
const pos = g.node(node.label);
|
|
150
284
|
return {
|
|
@@ -156,20 +290,194 @@ export function layoutInitiativeStatus(
|
|
|
156
290
|
y: pos.y,
|
|
157
291
|
width: pos.width,
|
|
158
292
|
height: pos.height,
|
|
293
|
+
metadata: node.metadata,
|
|
159
294
|
};
|
|
160
295
|
});
|
|
161
296
|
|
|
297
|
+
// Collect collapsed group positions for grid quantization
|
|
298
|
+
const collapsedGroupPositions: GridNode[] = [];
|
|
299
|
+
for (const label of collapsedGroupLabels) {
|
|
300
|
+
const pos = g.node(label);
|
|
301
|
+
if (pos) collapsedGroupPositions.push({ label, x: pos.x, y: pos.y, width: pos.width, height: pos.height });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Grid-quantize all node positions (regular + collapsed groups)
|
|
305
|
+
const allGridNodes: GridNode[] = [
|
|
306
|
+
...layoutNodes.map((n) => ({ label: n.label, x: n.x, y: n.y, width: n.width, height: n.height })),
|
|
307
|
+
...collapsedGroupPositions,
|
|
308
|
+
];
|
|
309
|
+
gridQuantize(allGridNodes, parsed.edges);
|
|
310
|
+
|
|
311
|
+
// Write quantized positions back
|
|
312
|
+
const quantizedMap = new Map(allGridNodes.map((n) => [n.label, n]));
|
|
313
|
+
for (const node of layoutNodes) {
|
|
314
|
+
const q = quantizedMap.get(node.label)!;
|
|
315
|
+
node.x = q.x;
|
|
316
|
+
node.y = q.y;
|
|
317
|
+
}
|
|
318
|
+
for (const cgp of collapsedGroupPositions) {
|
|
319
|
+
const q = quantizedMap.get(cgp.label)!;
|
|
320
|
+
cgp.x = q.x;
|
|
321
|
+
cgp.y = q.y;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
|
|
162
325
|
// Build a unified position map covering both regular nodes and collapsed groups
|
|
163
326
|
interface NodePos { x: number; y: number; width: number; height: number }
|
|
164
327
|
const posMap = new Map<string, NodePos>(layoutNodes.map((n) => [n.label, n]));
|
|
165
|
-
for (const
|
|
166
|
-
|
|
167
|
-
|
|
328
|
+
for (const cgp of collapsedGroupPositions) {
|
|
329
|
+
posMap.set(cgp.label, { x: cgp.x, y: cgp.y, width: cgp.width, height: cgp.height });
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Compute group bounding boxes BEFORE edge routing so overlap resolution
|
|
333
|
+
// can fix node positions before edges are computed.
|
|
334
|
+
const layoutGroups: ISLayoutGroup[] = [];
|
|
335
|
+
|
|
336
|
+
// Collapsed groups
|
|
337
|
+
for (const group of originalGroups) {
|
|
338
|
+
if (collapsedGroupLabels.has(group.label)) {
|
|
339
|
+
const cgp = collapsedGroupPositions.find((p) => p.label === group.label);
|
|
340
|
+
if (!cgp) continue;
|
|
341
|
+
layoutGroups.push({
|
|
342
|
+
label: group.label,
|
|
343
|
+
status: collapsedGroupStatuses.get(group.label) ?? null,
|
|
344
|
+
x: cgp.x - cgp.width / 2,
|
|
345
|
+
y: cgp.y - cgp.height / 2,
|
|
346
|
+
width: cgp.width,
|
|
347
|
+
height: cgp.height,
|
|
348
|
+
lineNumber: group.lineNumber,
|
|
349
|
+
collapsed: true,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Expanded groups: bounding box from member positions
|
|
355
|
+
if (parsed.groups.length > 0) {
|
|
356
|
+
const nMap = new Map(layoutNodes.map((n) => [n.label, n]));
|
|
357
|
+
for (const group of parsed.groups) {
|
|
358
|
+
const members = group.nodeLabels
|
|
359
|
+
.map((label) => nMap.get(label))
|
|
360
|
+
.filter((n): n is ISLayoutNode => n !== undefined);
|
|
361
|
+
if (members.length === 0) continue;
|
|
362
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
363
|
+
for (const member of members) {
|
|
364
|
+
const left = member.x - member.width / 2;
|
|
365
|
+
const right = member.x + member.width / 2;
|
|
366
|
+
const top = member.y - member.height / 2;
|
|
367
|
+
const bottom = member.y + member.height / 2;
|
|
368
|
+
if (left < minX) minX = left;
|
|
369
|
+
if (right > maxX) maxX = right;
|
|
370
|
+
if (top < minY) minY = top;
|
|
371
|
+
if (bottom > maxY) maxY = bottom;
|
|
372
|
+
}
|
|
373
|
+
layoutGroups.push({
|
|
374
|
+
label: group.label,
|
|
375
|
+
status: rollUpStatus(members),
|
|
376
|
+
x: minX - GROUP_PADDING,
|
|
377
|
+
y: minY - GROUP_PADDING,
|
|
378
|
+
width: maxX - minX + GROUP_PADDING * 2,
|
|
379
|
+
height: maxY - minY + GROUP_PADDING * 2,
|
|
380
|
+
lineNumber: group.lineNumber,
|
|
381
|
+
collapsed: false,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Resolve overlaps between expanded group boxes and non-member nodes.
|
|
387
|
+
// Must happen BEFORE edge routing so edges use final node positions.
|
|
388
|
+
if (layoutGroups.length > 0) {
|
|
389
|
+
const groupMemberLabels = new Set(parsed.groups.flatMap((gr) => gr.nodeLabels));
|
|
390
|
+
let changed = true;
|
|
391
|
+
let iterations = 0;
|
|
392
|
+
while (changed && iterations < 10) {
|
|
393
|
+
changed = false;
|
|
394
|
+
iterations++;
|
|
395
|
+
for (const group of layoutGroups) {
|
|
396
|
+
if (group.collapsed) continue;
|
|
397
|
+
// Use rendered group bounds (includes GROUP_EXTRA_PADDING + label)
|
|
398
|
+
const gTop = group.y - GROUP_LABEL_HEIGHT - GROUP_PADDING;
|
|
399
|
+
const gBottom = group.y + group.height + GROUP_PADDING;
|
|
400
|
+
const gLeft = group.x - GROUP_PADDING;
|
|
401
|
+
const gRight = group.x + group.width + GROUP_PADDING;
|
|
402
|
+
for (const node of layoutNodes) {
|
|
403
|
+
if (groupMemberLabels.has(node.label)) continue;
|
|
404
|
+
const nTop = node.y - node.height / 2;
|
|
405
|
+
const nBottom = node.y + node.height / 2;
|
|
406
|
+
const nLeft = node.x - node.width / 2;
|
|
407
|
+
const nRight = node.x + node.width / 2;
|
|
408
|
+
if (nRight <= gLeft || nLeft >= gRight) continue;
|
|
409
|
+
if (nBottom < gTop || nTop > gBottom) continue;
|
|
410
|
+
const groupCenterY = group.y + group.height / 2;
|
|
411
|
+
if (node.y < groupCenterY) {
|
|
412
|
+
node.y = gTop - node.height / 2 - GROUP_PADDING;
|
|
413
|
+
} else {
|
|
414
|
+
node.y = gBottom + node.height / 2 + GROUP_PADDING;
|
|
415
|
+
}
|
|
416
|
+
const pm = posMap.get(node.label);
|
|
417
|
+
if (pm) pm.y = node.y;
|
|
418
|
+
changed = true;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (changed) {
|
|
422
|
+
const nMap = new Map(layoutNodes.map((n) => [n.label, n]));
|
|
423
|
+
for (const group of layoutGroups) {
|
|
424
|
+
if (group.collapsed) continue;
|
|
425
|
+
const pg = parsed.groups.find((gr) => gr.label === group.label);
|
|
426
|
+
if (!pg) continue;
|
|
427
|
+
const members = pg.nodeLabels
|
|
428
|
+
.map((label) => nMap.get(label))
|
|
429
|
+
.filter((n): n is ISLayoutNode => n !== undefined);
|
|
430
|
+
if (members.length === 0) continue;
|
|
431
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
432
|
+
for (const member of members) {
|
|
433
|
+
const left = member.x - member.width / 2;
|
|
434
|
+
const right = member.x + member.width / 2;
|
|
435
|
+
const top = member.y - member.height / 2;
|
|
436
|
+
const bottom = member.y + member.height / 2;
|
|
437
|
+
if (left < minX) minX = left;
|
|
438
|
+
if (right > maxX) maxX = right;
|
|
439
|
+
if (top < minY) minY = top;
|
|
440
|
+
if (bottom > maxY) maxY = bottom;
|
|
441
|
+
}
|
|
442
|
+
group.x = minX - GROUP_PADDING;
|
|
443
|
+
group.y = minY - GROUP_PADDING;
|
|
444
|
+
group.width = maxX - minX + GROUP_PADDING * 2;
|
|
445
|
+
group.height = maxY - minY + GROUP_PADDING * 2;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Normalize Y: ensure all coordinates are non-negative after overlap resolution
|
|
452
|
+
{
|
|
453
|
+
let minNodeY = Infinity;
|
|
454
|
+
for (const node of layoutNodes) {
|
|
455
|
+
const top = node.y - node.height / 2;
|
|
456
|
+
if (top < minNodeY) minNodeY = top;
|
|
457
|
+
}
|
|
458
|
+
for (const group of layoutGroups) {
|
|
459
|
+
const top = group.collapsed ? group.y : group.y - GROUP_LABEL_HEIGHT;
|
|
460
|
+
if (top < minNodeY) minNodeY = top;
|
|
461
|
+
}
|
|
462
|
+
if (minNodeY < 20) {
|
|
463
|
+
const shift = 20 - minNodeY;
|
|
464
|
+
for (const node of layoutNodes) {
|
|
465
|
+
node.y += shift;
|
|
466
|
+
const pm = posMap.get(node.label);
|
|
467
|
+
if (pm) pm.y = node.y;
|
|
468
|
+
}
|
|
469
|
+
for (const group of layoutGroups) {
|
|
470
|
+
group.y += shift;
|
|
471
|
+
}
|
|
472
|
+
for (const cgp of collapsedGroupPositions) {
|
|
473
|
+
cgp.y += shift;
|
|
474
|
+
const pm = posMap.get(cgp.label);
|
|
475
|
+
if (pm) pm.y = cgp.y;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
168
478
|
}
|
|
169
479
|
|
|
170
480
|
const allNodeX = [...posMap.values()].map((n) => n.x);
|
|
171
|
-
// avgNodeY / avgNodeX: O(1) scalars used for back-edge above/below heuristic and arc spread direction.
|
|
172
|
-
// layoutNodes.length === 0 is unreachable here (early-return guard at line 92 exits for empty diagrams).
|
|
173
481
|
const avgNodeY = layoutNodes.length > 0
|
|
174
482
|
? layoutNodes.reduce((s, n) => s + n.y, 0) / layoutNodes.length
|
|
175
483
|
: 0;
|
|
@@ -305,7 +613,7 @@ export function layoutInitiativeStatus(
|
|
|
305
613
|
];
|
|
306
614
|
} else {
|
|
307
615
|
// fixedDagrePoints: multi-rank forward edges — dagre interior waypoints for obstacle avoidance.
|
|
308
|
-
// dagrePoints is still fetched above
|
|
616
|
+
// dagrePoints is still fetched above and available here.
|
|
309
617
|
points = dagrePoints.length >= 2 ? [
|
|
310
618
|
{ x: exitX, y: src.y + yOffset },
|
|
311
619
|
...dagrePoints.slice(1, -1),
|
|
@@ -316,62 +624,6 @@ export function layoutInitiativeStatus(
|
|
|
316
624
|
status: edge.status, lineNumber: edge.lineNumber, points, parallelCount });
|
|
317
625
|
}
|
|
318
626
|
|
|
319
|
-
// Compute group bounding boxes
|
|
320
|
-
const layoutGroups: ISLayoutGroup[] = [];
|
|
321
|
-
|
|
322
|
-
// Collapsed groups: dagre placed them as regular nodes → normalize to top-left
|
|
323
|
-
for (const group of originalGroups) {
|
|
324
|
-
if (collapsedGroupLabels.has(group.label)) {
|
|
325
|
-
const pos = g.node(group.label);
|
|
326
|
-
if (!pos) continue;
|
|
327
|
-
layoutGroups.push({
|
|
328
|
-
label: group.label,
|
|
329
|
-
status: collapsedGroupStatuses.get(group.label) ?? null,
|
|
330
|
-
x: pos.x - pos.width / 2,
|
|
331
|
-
y: pos.y - pos.height / 2,
|
|
332
|
-
width: pos.width,
|
|
333
|
-
height: pos.height,
|
|
334
|
-
lineNumber: group.lineNumber,
|
|
335
|
-
collapsed: true,
|
|
336
|
-
});
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// Expanded groups: bounding box from member positions
|
|
341
|
-
if (parsed.groups.length > 0) {
|
|
342
|
-
const nMap = new Map(layoutNodes.map((n) => [n.label, n]));
|
|
343
|
-
for (const group of parsed.groups) {
|
|
344
|
-
const members = group.nodeLabels
|
|
345
|
-
.map((label) => nMap.get(label))
|
|
346
|
-
.filter((n): n is ISLayoutNode => n !== undefined);
|
|
347
|
-
if (members.length === 0) continue;
|
|
348
|
-
|
|
349
|
-
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
350
|
-
for (const member of members) {
|
|
351
|
-
const left = member.x - member.width / 2;
|
|
352
|
-
const right = member.x + member.width / 2;
|
|
353
|
-
const top = member.y - member.height / 2;
|
|
354
|
-
const bottom = member.y + member.height / 2;
|
|
355
|
-
if (left < minX) minX = left;
|
|
356
|
-
if (right > maxX) maxX = right;
|
|
357
|
-
if (top < minY) minY = top;
|
|
358
|
-
if (bottom > maxY) maxY = bottom;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
layoutGroups.push({
|
|
362
|
-
label: group.label,
|
|
363
|
-
status: rollUpStatus(members),
|
|
364
|
-
x: minX - GROUP_PADDING,
|
|
365
|
-
y: minY - GROUP_PADDING,
|
|
366
|
-
width: maxX - minX + GROUP_PADDING * 2,
|
|
367
|
-
height: maxY - minY + GROUP_PADDING * 2,
|
|
368
|
-
lineNumber: group.lineNumber,
|
|
369
|
-
collapsed: false,
|
|
370
|
-
});
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
|
|
375
627
|
// Compute total dimensions
|
|
376
628
|
let totalWidth = 0;
|
|
377
629
|
let totalHeight = 0;
|