@diagrammo/dgmo 0.6.1 → 0.6.2

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/src/er/layout.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import dagre from '@dagrejs/dagre';
2
- import type { ParsedERDiagram, ERTable } from './types';
2
+ import type { ParsedERDiagram, ERTable, ERRelationship } from './types';
3
3
 
4
4
  // ============================================================
5
5
  // Layout types
@@ -42,6 +42,9 @@ const MEMBER_LINE_HEIGHT = 18;
42
42
  const COMPARTMENT_PADDING_Y = 8;
43
43
  const SEPARATOR_HEIGHT = 1;
44
44
 
45
+ const HALF_MARGIN = 30;
46
+ const COMP_GAP = 60; // gap between packed components
47
+
45
48
  // ============================================================
46
49
  // Node sizing
47
50
  // ============================================================
@@ -52,7 +55,6 @@ function computeNodeDimensions(table: ERTable): {
52
55
  headerHeight: number;
53
56
  columnsHeight: number;
54
57
  } {
55
- // Width: max of table name, column text lengths
56
58
  let maxTextLen = table.name.length;
57
59
  for (const col of table.columns) {
58
60
  let colText = col.name;
@@ -61,7 +63,6 @@ function computeNodeDimensions(table: ERTable): {
61
63
  maxTextLen = Math.max(maxTextLen, colText.length);
62
64
  }
63
65
  const width = Math.max(MIN_WIDTH, maxTextLen * CHAR_WIDTH + PADDING_X);
64
-
65
66
  const headerHeight = HEADER_BASE;
66
67
 
67
68
  let columnsHeight = 0;
@@ -72,12 +73,210 @@ function computeNodeDimensions(table: ERTable): {
72
73
  SEPARATOR_HEIGHT;
73
74
  }
74
75
 
75
- const height =
76
- headerHeight + columnsHeight + (columnsHeight === 0 ? 4 : 0);
77
-
76
+ const height = headerHeight + columnsHeight + (columnsHeight === 0 ? 4 : 0);
78
77
  return { width, height, headerHeight, columnsHeight };
79
78
  }
80
79
 
80
+ // ============================================================
81
+ // Connected component detection (undirected BFS)
82
+ // ============================================================
83
+
84
+ function findConnectedComponents(
85
+ tableIds: string[],
86
+ relationships: ERRelationship[]
87
+ ): string[][] {
88
+ const adj = new Map<string, Set<string>>();
89
+ for (const id of tableIds) adj.set(id, new Set());
90
+ for (const rel of relationships) {
91
+ adj.get(rel.source)?.add(rel.target);
92
+ adj.get(rel.target)?.add(rel.source);
93
+ }
94
+
95
+ const visited = new Set<string>();
96
+ const components: string[][] = [];
97
+
98
+ for (const id of tableIds) {
99
+ if (visited.has(id)) continue;
100
+ const comp: string[] = [];
101
+ const queue = [id];
102
+ while (queue.length > 0) {
103
+ const cur = queue.shift()!;
104
+ if (visited.has(cur)) continue;
105
+ visited.add(cur);
106
+ comp.push(cur);
107
+ for (const nb of adj.get(cur) ?? []) {
108
+ if (!visited.has(nb)) queue.push(nb);
109
+ }
110
+ }
111
+ components.push(comp);
112
+ }
113
+ return components;
114
+ }
115
+
116
+ // ============================================================
117
+ // Per-component layout (independent dagre run)
118
+ // ============================================================
119
+
120
+ interface ComponentLayout {
121
+ /** Node center positions relative to this component's top-left origin */
122
+ nodePositions: Map<
123
+ string,
124
+ { x: number; y: number; width: number; height: number; headerHeight: number; columnsHeight: number }
125
+ >;
126
+ /** Edge waypoints keyed by relationship lineNumber, relative to origin */
127
+ edgePoints: Map<number, { x: number; y: number }[]>;
128
+ width: number;
129
+ height: number;
130
+ }
131
+
132
+ function layoutComponent(
133
+ tables: ERTable[],
134
+ rels: ERRelationship[],
135
+ dimMap: Map<string, { width: number; height: number; headerHeight: number; columnsHeight: number }>
136
+ ): ComponentLayout {
137
+ const nodePositions = new Map<
138
+ string,
139
+ { x: number; y: number; width: number; height: number; headerHeight: number; columnsHeight: number }
140
+ >();
141
+ const edgePoints = new Map<number, { x: number; y: number }[]>();
142
+
143
+ if (tables.length === 1) {
144
+ // Single node — skip dagre
145
+ const dims = dimMap.get(tables[0].id)!;
146
+ nodePositions.set(tables[0].id, { x: dims.width / 2, y: dims.height / 2, ...dims });
147
+ return { nodePositions, edgePoints, width: dims.width, height: dims.height };
148
+ }
149
+
150
+ const g = new dagre.graphlib.Graph({ multigraph: true });
151
+ g.setGraph({ rankdir: 'LR', nodesep: 40, ranksep: 80, edgesep: 20 });
152
+ g.setDefaultEdgeLabel(() => ({}));
153
+
154
+ for (const table of tables) {
155
+ const dims = dimMap.get(table.id)!;
156
+ g.setNode(table.id, { width: dims.width, height: dims.height });
157
+ }
158
+
159
+ // Use lineNumber as edge name to support multigraph (multiple edges between same pair)
160
+ for (const rel of rels) {
161
+ g.setEdge(rel.source, rel.target, { label: rel.label ?? '' }, String(rel.lineNumber));
162
+ }
163
+
164
+ dagre.layout(g);
165
+
166
+ // Compute bounding box (dagre coordinates)
167
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
168
+
169
+ for (const table of tables) {
170
+ const pos = g.node(table.id);
171
+ const dims = dimMap.get(table.id)!;
172
+ minX = Math.min(minX, pos.x - dims.width / 2);
173
+ minY = Math.min(minY, pos.y - dims.height / 2);
174
+ maxX = Math.max(maxX, pos.x + dims.width / 2);
175
+ maxY = Math.max(maxY, pos.y + dims.height / 2);
176
+ }
177
+
178
+ for (const rel of rels) {
179
+ const ed = g.edge(rel.source, rel.target, String(rel.lineNumber));
180
+ for (const pt of ed?.points ?? []) {
181
+ minX = Math.min(minX, pt.x);
182
+ minY = Math.min(minY, pt.y);
183
+ maxX = Math.max(maxX, pt.x);
184
+ maxY = Math.max(maxY, pt.y);
185
+ }
186
+ if (rel.label && (ed?.points ?? []).length > 0) {
187
+ const pts = ed!.points;
188
+ const mid = pts[Math.floor(pts.length / 2)];
189
+ const hw = (rel.label.length * 7 + 8) / 2;
190
+ minX = Math.min(minX, mid.x - hw);
191
+ maxX = Math.max(maxX, mid.x + hw);
192
+ }
193
+ }
194
+
195
+ // Normalize positions to component origin (0, 0)
196
+ for (const table of tables) {
197
+ const pos = g.node(table.id);
198
+ const dims = dimMap.get(table.id)!;
199
+ nodePositions.set(table.id, {
200
+ x: pos.x - minX,
201
+ y: pos.y - minY,
202
+ ...dims,
203
+ });
204
+ }
205
+ for (const rel of rels) {
206
+ const ed = g.edge(rel.source, rel.target, String(rel.lineNumber));
207
+ edgePoints.set(
208
+ rel.lineNumber,
209
+ (ed?.points ?? []).map((pt: { x: number; y: number }) => ({ x: pt.x - minX, y: pt.y - minY }))
210
+ );
211
+ }
212
+
213
+ return {
214
+ nodePositions,
215
+ edgePoints,
216
+ width: Math.max(0, maxX - minX),
217
+ height: Math.max(0, maxY - minY),
218
+ };
219
+ }
220
+
221
+ // ============================================================
222
+ // Row packing (Next Fit Decreasing Height)
223
+ // ============================================================
224
+
225
+ interface PackedComponent {
226
+ compIds: string[];
227
+ compLayout: ComponentLayout;
228
+ offsetX: number;
229
+ offsetY: number;
230
+ }
231
+
232
+ function packComponents(
233
+ items: Array<{ compIds: string[]; compLayout: ComponentLayout }>
234
+ ): PackedComponent[] {
235
+ if (items.length === 0) return [];
236
+
237
+ // Sort: multi-node components (have relationships, more interesting) first,
238
+ // then isolated single-node components. Within each group, sort by height descending.
239
+ const sorted = [...items].sort((a, b) => {
240
+ const aConnected = a.compIds.length > 1 ? 1 : 0;
241
+ const bConnected = b.compIds.length > 1 ? 1 : 0;
242
+ if (aConnected !== bConnected) return bConnected - aConnected;
243
+ return b.compLayout.height - a.compLayout.height;
244
+ });
245
+
246
+ // Target width: sqrt of total content area × aspect factor (~1.5 = slightly landscape)
247
+ const totalArea = items.reduce(
248
+ (s, c) => s + (c.compLayout.width || MIN_WIDTH) * (c.compLayout.height || HEADER_BASE),
249
+ 0
250
+ );
251
+ const targetW = Math.max(
252
+ Math.sqrt(totalArea) * 1.5,
253
+ sorted[0].compLayout.width // at least as wide as the widest component
254
+ );
255
+
256
+ const placements: PackedComponent[] = [];
257
+ let curX = 0;
258
+ let curY = 0;
259
+ let rowH = 0;
260
+
261
+ for (const item of sorted) {
262
+ const w = item.compLayout.width || MIN_WIDTH;
263
+ const h = item.compLayout.height || HEADER_BASE;
264
+
265
+ // Wrap to next row if this item doesn't fit (but always place if row is empty)
266
+ if (curX > 0 && curX + w > targetW) {
267
+ curY += rowH + COMP_GAP;
268
+ curX = 0;
269
+ rowH = 0;
270
+ }
271
+
272
+ placements.push({ compIds: item.compIds, compLayout: item.compLayout, offsetX: curX, offsetY: curY });
273
+ curX += w + COMP_GAP;
274
+ rowH = Math.max(rowH, h);
275
+ }
276
+
277
+ return placements;
278
+ }
279
+
81
280
  // ============================================================
82
281
  // Layout engine
83
282
  // ============================================================
@@ -87,130 +286,96 @@ export function layoutERDiagram(parsed: ParsedERDiagram): ERLayoutResult {
87
286
  return { nodes: [], edges: [], width: 0, height: 0 };
88
287
  }
89
288
 
90
- const g = new dagre.graphlib.Graph();
91
- g.setGraph({
92
- rankdir: 'TB',
93
- nodesep: 60,
94
- ranksep: 80,
95
- edgesep: 20,
96
- });
97
- g.setDefaultEdgeLabel(() => ({}));
98
-
99
- // Compute dimensions and add nodes
289
+ // ── 1. Node dimensions ──────────────────────────────────────────────────────
100
290
  const dimMap = new Map<
101
291
  string,
102
- {
103
- width: number;
104
- height: number;
105
- headerHeight: number;
106
- columnsHeight: number;
107
- }
292
+ { width: number; height: number; headerHeight: number; columnsHeight: number }
108
293
  >();
109
-
110
294
  for (const table of parsed.tables) {
111
- const dims = computeNodeDimensions(table);
112
- dimMap.set(table.id, dims);
113
- g.setNode(table.id, {
114
- label: table.name,
115
- width: dims.width,
116
- height: dims.height,
117
- });
295
+ dimMap.set(table.id, computeNodeDimensions(table));
118
296
  }
119
297
 
120
- // Add edges
121
- for (const rel of parsed.relationships) {
122
- g.setEdge(rel.source, rel.target, { label: rel.label ?? '' });
298
+ // ── 2. Find connected components ────────────────────────────────────────────
299
+ const compIdSets = findConnectedComponents(
300
+ parsed.tables.map((t) => t.id),
301
+ parsed.relationships
302
+ );
303
+
304
+ // ── 3. Layout each component independently ──────────────────────────────────
305
+ const tableById = new Map(parsed.tables.map((t) => [t.id, t]));
306
+
307
+ const componentItems = compIdSets.map((ids) => {
308
+ const tables = ids.map((id) => tableById.get(id)!);
309
+ const rels = parsed.relationships.filter((r) => ids.includes(r.source));
310
+ return { compIds: ids, compLayout: layoutComponent(tables, rels, dimMap) };
311
+ });
312
+
313
+ // ── 4. Pack component bounding boxes into rows ───────────────────────────────
314
+ const packed = packComponents(componentItems);
315
+
316
+ // Build lookup: tableId → packed placement
317
+ const placementByTableId = new Map<string, PackedComponent>();
318
+ for (const p of packed) {
319
+ for (const id of p.compIds) placementByTableId.set(id, p);
123
320
  }
124
321
 
125
- // Run layout
126
- dagre.layout(g);
322
+ // Build lookup: lineNumber → packed placement (for edges)
323
+ const placementByRelLine = new Map<number, PackedComponent>();
324
+ for (const p of packed) {
325
+ for (const lineNum of p.compLayout.edgePoints.keys()) {
326
+ placementByRelLine.set(lineNum, p);
327
+ }
328
+ }
127
329
 
128
- // Extract positioned nodes
330
+ // ── 5. Assemble final layout ─────────────────────────────────────────────────
129
331
  const layoutNodes: ERLayoutNode[] = parsed.tables.map((table) => {
130
- const pos = g.node(table.id);
131
- const dims = dimMap.get(table.id)!;
332
+ const p = placementByTableId.get(table.id)!;
333
+ const pos = p.compLayout.nodePositions.get(table.id)!;
132
334
  return {
133
335
  ...table,
134
- x: pos.x,
135
- y: pos.y,
136
- width: dims.width,
137
- height: dims.height,
138
- headerHeight: dims.headerHeight,
139
- columnsHeight: dims.columnsHeight,
336
+ x: pos.x + p.offsetX + HALF_MARGIN,
337
+ y: pos.y + p.offsetY + HALF_MARGIN,
338
+ width: pos.width,
339
+ height: pos.height,
340
+ headerHeight: pos.headerHeight,
341
+ columnsHeight: pos.columnsHeight,
140
342
  };
141
343
  });
142
344
 
143
- // Extract edge waypoints
144
345
  const layoutEdges: ERLayoutEdge[] = parsed.relationships.map((rel) => {
145
- const edgeData = g.edge(rel.source, rel.target);
346
+ const p = placementByRelLine.get(rel.lineNumber);
347
+ const pts = p?.compLayout.edgePoints.get(rel.lineNumber) ?? [];
146
348
  return {
147
349
  source: rel.source,
148
350
  target: rel.target,
149
351
  cardinality: rel.cardinality,
150
- points: edgeData?.points ?? [],
352
+ points: pts.map((pt) => ({
353
+ x: pt.x + (p?.offsetX ?? 0) + HALF_MARGIN,
354
+ y: pt.y + (p?.offsetY ?? 0) + HALF_MARGIN,
355
+ })),
151
356
  label: rel.label,
152
357
  lineNumber: rel.lineNumber,
153
358
  };
154
359
  });
155
360
 
156
- // Compute total dimensions from nodes, edge points, and labels
157
- let minX = Infinity;
158
- let minY = Infinity;
361
+ // ── 6. Total canvas dimensions ───────────────────────────────────────────────
159
362
  let maxX = 0;
160
363
  let maxY = 0;
161
364
  for (const node of layoutNodes) {
162
- const left = node.x - node.width / 2;
163
- const right = node.x + node.width / 2;
164
- const top = node.y - node.height / 2;
165
- const bottom = node.y + node.height / 2;
166
- if (left < minX) minX = left;
167
- if (right > maxX) maxX = right;
168
- if (top < minY) minY = top;
169
- if (bottom > maxY) maxY = bottom;
170
- }
171
- for (const edge of layoutEdges) {
172
- for (const pt of edge.points) {
173
- if (pt.x < minX) minX = pt.x;
174
- if (pt.x > maxX) maxX = pt.x;
175
- if (pt.y < minY) minY = pt.y;
176
- if (pt.y > maxY) maxY = pt.y;
177
- }
178
- // Edge labels extend ~50px from midpoint
179
- if (edge.label && edge.points.length > 0) {
180
- const midPt = edge.points[Math.floor(edge.points.length / 2)];
181
- const labelHalfW = (edge.label.length * 7 + 8) / 2;
182
- if (midPt.x + labelHalfW > maxX) maxX = midPt.x + labelHalfW;
183
- if (midPt.x - labelHalfW < minX) minX = midPt.x - labelHalfW;
184
- }
185
- }
186
-
187
- // Padding for cardinality markers (~25px) + edge labels
188
- const EDGE_MARGIN = 60;
189
- const HALF_MARGIN = EDGE_MARGIN / 2;
190
-
191
- // Shift all nodes and edges so content starts at HALF_MARGIN (breathing room on all sides)
192
- const shiftX = -minX + HALF_MARGIN;
193
- const shiftY = -minY + HALF_MARGIN;
194
- for (const node of layoutNodes) {
195
- node.x += shiftX;
196
- node.y += shiftY;
365
+ maxX = Math.max(maxX, node.x + node.width / 2);
366
+ maxY = Math.max(maxY, node.y + node.height / 2);
197
367
  }
198
368
  for (const edge of layoutEdges) {
199
369
  for (const pt of edge.points) {
200
- pt.x += shiftX;
201
- pt.y += shiftY;
370
+ maxX = Math.max(maxX, pt.x);
371
+ maxY = Math.max(maxY, pt.y);
202
372
  }
203
373
  }
204
- maxX += shiftX;
205
- maxY += shiftY;
206
-
207
- const totalWidth = maxX + HALF_MARGIN;
208
- const totalHeight = maxY + HALF_MARGIN;
209
374
 
210
375
  return {
211
376
  nodes: layoutNodes,
212
377
  edges: layoutEdges,
213
- width: totalWidth,
214
- height: totalHeight,
378
+ width: maxX + HALF_MARGIN,
379
+ height: maxY + HALF_MARGIN,
215
380
  };
216
381
  }