@diagrammo/dgmo 0.6.3 → 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.
@@ -1,9 +1,9 @@
1
1
  // ============================================================
2
2
  // Initiative Status Diagram — Layout
3
3
  //
4
- // Uses dagre for rank assignment, node ordering, and edge
5
- // routing. Edge waypoints are taken directly from dagre's
6
- // output without modification.
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 NODESEP = 80;
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 label of collapsedGroupLabels) {
166
- const pos = g.node(label);
167
- if (pos) posMap.set(label, { x: pos.x, y: pos.y, width: pos.width, height: pos.height });
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 (line 209) and available here.
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;