mbeditor 0.12.0 → 0.12.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.
@@ -2,24 +2,105 @@
2
2
 
3
3
  // ModelGraph — an SVG entity diagram of the host app's ActiveRecord models.
4
4
  //
5
- // Laid out radially rather than left-to-right: the model with the most
6
- // associations sits at the centre and everything else fans out in rings by how
7
- // many hops away it is. A Rails schema is usually a hub with satellites, and a
8
- // column layout turns that into one very wide, very short strip.
5
+ // Laid out by the Sugiyama method the same one Graphviz `dot` uses, and so
6
+ // the same one Rails ERD produces this picture with. See the layout section
7
+ // below for the phases.
8
+ //
9
+ // Two earlier attempts are worth knowing about, because both looked reasonable
10
+ // on a demo app and failed on a real one. A radial layout put the busiest model
11
+ // at the centre and fanned the rest out in rings; at 300 models that is a single
12
+ // enormous circle, fitted so far out that no box is legible. A clustered grid
13
+ // was scannable but placed models by traversal order, so a box's position
14
+ // carried no information at all. Only a layered layout derives position from
15
+ // the associations themselves.
9
16
  var ModelGraph = (function () {
10
- var NODE_W = 188;
17
+ // Boxes are sized to their contents rather than all being one width. A fixed
18
+ // 188px meant a model with a long name or long column names had its labels
19
+ // truncated while a model called Tag wasted most of its box. Clamped at both
20
+ // ends so one pathological name cannot stretch a whole layer.
21
+ var NODE_W_MIN = 168;
22
+ var NODE_W_MAX = 320;
23
+ var NODE_W = NODE_W_MIN; // fallback for anything without a measured model
11
24
  var HEADER_H = 34; // model name + table name
12
25
  var FIELD_H = 15;
13
26
  var MAX_FIELDS = 8; // the rest are one click away in the schema modal
14
- var RING_GAP = 150;
15
- var MIN_RADIUS = 210;
16
- var NODE_GAP = 56; // clear space between neighbours on the same ring
17
- var BARYCENTRE_PASSES = 4;
27
+ var ROW_GAP = 36;
28
+ var LAYER_GAP = 110; // horizontal space between layers
29
+ var ORDER_PASSES = 4; // median sweeps for crossing reduction
30
+ var STRAIGHTEN_PASSES = 3;
31
+ var MAX_ASSOC_LISTED = 12;
32
+ var MAX_SEARCH_RESULTS = 50; // the card stays readable; the rest are counted
33
+ var BLOCK_PAD = 26; // breathing room inside a cluster's boundary
34
+ var BLOCK_GAP = 56; // space between neighbouring clusters
35
+
36
+ // SVG text neither wraps nor ellipsises: a long model or column name simply
37
+ // draws straight out past the edge of its box and over whatever is beside it.
38
+ // Measure with a cached canvas context and cut to fit — accurate for whatever
39
+ // font the theme actually resolves, unlike a characters-per-pixel guess.
40
+ var _measureCtx = null;
41
+ var _measureCache = {};
42
+
43
+ function textWidth(text, font) {
44
+ var key = font + '\u0000' + text;
45
+ if (_measureCache[key] !== undefined) return _measureCache[key];
46
+ if (!_measureCtx) {
47
+ if (typeof document === 'undefined') return text.length * 6;
48
+ _measureCtx = document.createElement('canvas').getContext('2d');
49
+ }
50
+ _measureCtx.font = font;
51
+ var w = _measureCtx.measureText(text).width;
52
+ // Unbounded growth would be a leak on a large schema; the cache only needs
53
+ // to cover one render's worth of labels.
54
+ if (Object.keys(_measureCache).length > 4000) _measureCache = {};
55
+ _measureCache[key] = w;
56
+ return w;
57
+ }
58
+
59
+ function fitText(text, maxWidth, font) {
60
+ text = String(text == null ? '' : text);
61
+ if (!text || textWidth(text, font) <= maxWidth) return text;
62
+ var lo = 0, hi = text.length;
63
+ while (lo < hi) {
64
+ var mid = Math.ceil((lo + hi) / 2);
65
+ if (textWidth(text.slice(0, mid) + '…', font) <= maxWidth) lo = mid; else hi = mid - 1;
66
+ }
67
+ return lo > 0 ? text.slice(0, lo) + '…' : '…';
68
+ }
69
+
70
+ // Must match the .mg-name / .mg-table / .mg-field rules in editor.css.
71
+ var FONT_NAME = '600 12px ui-monospace, SFMono-Regular, Menlo, monospace';
72
+ var FONT_SMALL = '9px ui-monospace, SFMono-Regular, Menlo, monospace';
73
+ var FONT_FIELD = '10px ui-monospace, SFMono-Regular, Menlo, monospace';
74
+
75
+ function nodeWidth(model) {
76
+ if (model && model.__mgWidth) return model.__mgWidth;
77
+ if (!model) return NODE_W;
78
+
79
+ // Header: name plus the open-schema button, and the table/count line.
80
+ var need = textWidth(String(model.name || ''), FONT_NAME) + 20 + 26;
81
+ need = Math.max(need, textWidth(
82
+ (model.table || '—') + (model.columnCount ? ' · ' + model.columnCount + ' cols' : ''),
83
+ FONT_SMALL) + 20 + 26);
84
+
85
+ (model.columns || []).slice(0, MAX_FIELDS).forEach(function (c) {
86
+ // name on the left, type right-aligned, with a gap between them
87
+ need = Math.max(need,
88
+ textWidth(String(c.name || ''), FONT_FIELD) +
89
+ textWidth(String(c.type || ''), FONT_SMALL) + 32);
90
+ });
91
+
92
+ var w = Math.round(Math.min(NODE_W_MAX, Math.max(NODE_W_MIN, need)));
93
+ try { model.__mgWidth = w; } catch (e) { /* frozen payload — recompute */ }
94
+ return w;
95
+ }
18
96
 
19
97
  function nodeHeight(model) {
20
98
  var shown = Math.min((model.columns || []).length, MAX_FIELDS);
21
99
  var more = (model.columnCount || 0) > shown ? FIELD_H : 0;
22
- return HEADER_H + shown * FIELD_H + more + 8;
100
+ // With no columns the box still draws one line — "no database connection" —
101
+ // and not counting it left that text hanging below the box it belongs to.
102
+ var placeholder = shown === 0 ? FIELD_H : 0;
103
+ return HEADER_H + shown * FIELD_H + more + placeholder + 8;
23
104
  }
24
105
 
25
106
  // Undirected adjacency: for placement, "A belongs_to B" and "B has_many A"
@@ -35,177 +116,336 @@ var ModelGraph = (function () {
35
116
  return adj;
36
117
  }
37
118
 
38
- function degreeOf(adj, name) { return Object.keys(adj[name] || {}).length; }
119
+ // ── Layout: Sugiyama layered, the standard for entity diagrams ────────────
120
+ //
121
+ // Associations are directed (belongs_to points a child at its parent), which
122
+ // is exactly the input a layered layout wants, and it is what Graphviz `dot`
123
+ // uses — and therefore Rails ERD, the usual tool for drawing this picture.
124
+ // The four classic phases: break cycles, assign layers, order within layers
125
+ // to cut crossings, then assign coordinates.
126
+ //
127
+ // Earlier attempts placed models by traversal order poured into grid cells.
128
+ // That put related models near each other only by accident: a box's position
129
+ // carried no information, so the diagram could not actually be read.
39
130
 
40
- // Rings by hop count from the busiest model. Disconnected islands are picked
41
- // up afterwards by their own local hub, so nothing is silently dropped.
42
- function assignRings(models, adj) {
43
- var remaining = {};
44
- models.forEach(function (m) { remaining[m.name] = true; });
131
+ // Unique directed edges; self-references are dropped as placement constraints.
132
+ function directedEdges(models, edges) {
133
+ var known = {};
134
+ models.forEach(function (m) { known[m.name] = true; });
135
+ var seen = {};
136
+ var out = [];
137
+ edges.forEach(function (e) {
138
+ if (!known[e.from] || !known[e.to] || e.from === e.to) return;
139
+ var key = e.from + ' ' + e.to;
140
+ if (seen[key]) return;
141
+ seen[key] = true;
142
+ out.push({ from: e.from, to: e.to });
143
+ });
144
+ return out;
145
+ }
45
146
 
46
- var ring = {};
47
- var order = [];
147
+ // Phase 1 — break cycles. Rails schemas are full of them (a belongs_to paired
148
+ // with a has_many the other way, or a genuine loop), and layering needs a DAG.
149
+ // Depth-first search, dropping any edge that points back at a node still on
150
+ // the stack. Those edges are still drawn in their true direction; they just
151
+ // stop constraining which layer a node lands in.
152
+ function breakCycles(names, edges) {
153
+ var out = {}, state = {}; // 0 unvisited, 1 on stack, 2 done
154
+ names.forEach(function (n) { out[n] = []; state[n] = 0; });
155
+ edges.forEach(function (e) { out[e.from].push(e.to); });
156
+
157
+ var acyclic = [];
158
+ names.forEach(function (root) {
159
+ if (state[root] !== 0) return;
160
+ // Iterative: a 300-model schema nests deeper than is comfortable for
161
+ // recursion.
162
+ var stack = [{ node: root, i: 0 }];
163
+ state[root] = 1;
164
+ while (stack.length) {
165
+ var top = stack[stack.length - 1];
166
+ if (top.i < out[top.node].length) {
167
+ var next = out[top.node][top.i++];
168
+ if (state[next] === 1) continue;
169
+ acyclic.push({ from: top.node, to: next });
170
+ if (state[next] === 0) { state[next] = 1; stack.push({ node: next, i: 0 }); }
171
+ } else {
172
+ state[top.node] = 2;
173
+ stack.pop();
174
+ }
175
+ }
176
+ });
177
+ return acyclic;
178
+ }
48
179
 
49
- while (Object.keys(remaining).length > 0) {
50
- var hub = Object.keys(remaining).sort(function (a, b) {
51
- var d = degreeOf(adj, b) - degreeOf(adj, a);
52
- return d !== 0 ? d : (a < b ? -1 : 1);
53
- })[0];
180
+ // Phase 2 — longest-path layering. A node sits one layer past its deepest
181
+ // predecessor, so every edge points forwards and depth reads left to right.
182
+ function assignLayers(names, acyclic) {
183
+ var incoming = {}, outgoing = {}, indeg = {};
184
+ names.forEach(function (n) { incoming[n] = []; outgoing[n] = []; indeg[n] = 0; });
185
+ acyclic.forEach(function (e) {
186
+ outgoing[e.from].push(e.to);
187
+ incoming[e.to].push(e.from);
188
+ indeg[e.to]++;
189
+ });
54
190
 
55
- var queue = [hub];
56
- ring[hub] = order.length === 0 ? 0 : 1;
57
- delete remaining[hub];
58
- order.push(hub);
191
+ var layer = {};
192
+ var queue = names.filter(function (n) { return indeg[n] === 0; });
193
+ queue.forEach(function (n) { layer[n] = 0; });
59
194
 
60
- while (queue.length) {
61
- var current = queue.shift();
62
- Object.keys(adj[current] || {}).sort().forEach(function (next) {
63
- if (!remaining[next]) return;
64
- delete remaining[next];
65
- ring[next] = ring[current] + 1;
66
- order.push(next);
67
- queue.push(next);
195
+ var pending = {};
196
+ names.forEach(function (n) { pending[n] = indeg[n]; });
197
+
198
+ while (queue.length) {
199
+ var n = queue.shift();
200
+ outgoing[n].forEach(function (m) {
201
+ layer[m] = Math.max(layer[m] || 0, layer[n] + 1);
202
+ if (--pending[m] === 0) queue.push(m);
203
+ });
204
+ }
205
+ // Anything left sits in a knot the cycle-breaker could not fully unwind;
206
+ // park it past its deepest known predecessor rather than dropping it.
207
+ names.forEach(function (n) {
208
+ if (layer[n] !== undefined) return;
209
+ var base = 0;
210
+ incoming[n].forEach(function (p) { base = Math.max(base, (layer[p] || 0) + 1); });
211
+ layer[n] = base;
212
+ });
213
+ return layer;
214
+ }
215
+
216
+ // Phase 3 — crossing reduction by the median heuristic, swept down then up.
217
+ // Each node moves to the median position of its neighbours in the adjacent
218
+ // layer; repeated sweeps settle into far fewer crossings than any fixed
219
+ // ordering. This is the same heuristic dot uses.
220
+ function orderLayers(layers, adjIn, adjOut) {
221
+ var pos = {};
222
+ layers.forEach(function (names) {
223
+ names.forEach(function (n, i) { pos[n] = i; });
224
+ });
225
+
226
+ var medianOf = function (name, neighbours) {
227
+ var ps = (neighbours[name] || []).map(function (nb) { return pos[nb]; })
228
+ .filter(function (v) { return v !== undefined; })
229
+ .sort(function (a, b) { return a - b; });
230
+ if (!ps.length) return -1;
231
+ var mid = Math.floor(ps.length / 2);
232
+ return ps.length % 2 ? ps[mid] : (ps[mid - 1] + ps[mid]) / 2;
233
+ };
234
+
235
+ var sweep = function (from, to, step, neighbours) {
236
+ for (var li = from; li !== to; li += step) {
237
+ var names = layers[li];
238
+ if (!names || !names.length) continue;
239
+ var keyed = names.map(function (n, i) { return { n: n, m: medianOf(n, neighbours), i: i }; });
240
+ keyed.sort(function (a, b) {
241
+ // A node with no neighbour in the reference layer keeps its relative
242
+ // place rather than being bunched at one end.
243
+ if (a.m < 0 && b.m < 0) return a.i - b.i;
244
+ if (a.m < 0) return -1;
245
+ if (b.m < 0) return 1;
246
+ return a.m - b.m || a.i - b.i;
68
247
  });
248
+ layers[li] = keyed.map(function (k) { return k.n; });
249
+ layers[li].forEach(function (n, i) { pos[n] = i; });
69
250
  }
251
+ };
252
+
253
+ for (var pass = 0; pass < ORDER_PASSES; pass++) {
254
+ sweep(1, layers.length, 1, adjIn);
255
+ sweep(layers.length - 2, -1, -1, adjOut);
70
256
  }
71
- return ring;
257
+ return layers;
72
258
  }
73
259
 
74
- // Ordering within a ring is what decides how many lines cross. Repeatedly
75
- // move each node to the average angle of its already-placed neighbours
76
- // (a barycentre sweep) cheap, and it untangles most of the crossings a
77
- // naive alphabetical ring would create.
78
- function orderRing(names, angleOf, adj, ringOf, thisRing) {
79
- var ordered = names.slice();
80
- for (var pass = 0; pass < BARYCENTRE_PASSES; pass++) {
81
- var target = {};
82
- ordered.forEach(function (name) {
83
- var xs = 0, ys = 0, n = 0;
84
- Object.keys(adj[name] || {}).forEach(function (nb) {
85
- // Only anchor to neighbours already pinned by an inner ring;
86
- // same-ring ties would just chase each other.
87
- if (ringOf[nb] >= thisRing) return;
88
- var a = angleOf[nb];
89
- if (a === undefined) return;
90
- xs += Math.cos(a); ys += Math.sin(a); n++;
91
- });
92
- target[name] = n === 0 ? null : Math.atan2(ys, xs);
260
+ // Phase 4 coordinates. x is the layer; y stacks within a layer, then a few
261
+ // relaxation passes pull each node toward the average of its neighbours so
262
+ // chains come out straight instead of stepped. After each pass the layer is
263
+ // re-separated in order, so relaxation can never overlap two boxes.
264
+ function assignCoords(layers, byName, adjIn, adjOut) {
265
+ var y = {};
266
+ layers.forEach(function (names) {
267
+ if (!names) return;
268
+ var cursor = 0;
269
+ names.forEach(function (n) {
270
+ y[n] = cursor;
271
+ cursor += nodeHeight(byName[n]) + ROW_GAP;
93
272
  });
273
+ });
94
274
 
95
- var anchored = ordered.filter(function (n) { return target[n] !== null; });
96
- var floating = ordered.filter(function (n) { return target[n] === null; });
97
- anchored.sort(function (a, b) { return target[a] - target[b]; });
98
- ordered = anchored.concat(floating);
275
+ for (var pass = 0; pass < STRAIGHTEN_PASSES; pass++) {
276
+ layers.forEach(function (names) {
277
+ if (!names || !names.length) return;
278
+ names.forEach(function (n) {
279
+ var ns = (adjIn[n] || []).concat(adjOut[n] || []);
280
+ var vals = ns.map(function (nb) { return y[nb]; })
281
+ .filter(function (v) { return v !== undefined; });
282
+ if (!vals.length) return;
283
+ var want = vals.reduce(function (a, b) { return a + b; }, 0) / vals.length;
284
+ y[n] = y[n] + (want - y[n]) * 0.5;
285
+ });
286
+ var cursor = -Infinity;
287
+ names.forEach(function (n) {
288
+ if (y[n] < cursor) y[n] = cursor;
289
+ cursor = y[n] + nodeHeight(byName[n]) + ROW_GAP;
290
+ });
291
+ });
99
292
  }
100
- return ordered;
293
+
294
+ var minY = Infinity;
295
+ Object.keys(y).forEach(function (n) { minY = Math.min(minY, y[n]); });
296
+ if (!isFinite(minY)) minY = 0;
297
+
298
+ // Layers are as wide as their widest box, and each starts where the previous
299
+ // one ended. A fixed stride assumed every box was NODE_W and would overlap
300
+ // the next layer as soon as one of them grew.
301
+ var layerX = [];
302
+ var cursorX = 0;
303
+ layers.forEach(function (names, li) {
304
+ layerX[li] = cursorX;
305
+ var widest = 0;
306
+ (names || []).forEach(function (n) { widest = Math.max(widest, nodeWidth(byName[n])); });
307
+ cursorX += (widest || NODE_W_MIN) + LAYER_GAP;
308
+ });
309
+
310
+ var positions = {};
311
+ var maxX = 0, maxY = 0;
312
+ layers.forEach(function (names, li) {
313
+ if (!names) return;
314
+ names.forEach(function (n) {
315
+ var x = layerX[li];
316
+ var yy = y[n] - minY;
317
+ positions[n] = { x: x, y: yy, model: byName[n] };
318
+ maxX = Math.max(maxX, x + nodeWidth(byName[n]));
319
+ maxY = Math.max(maxY, yy + nodeHeight(byName[n]));
320
+ });
321
+ });
322
+ return { positions: positions, width: maxX, height: maxY };
323
+ }
324
+
325
+ // Connected components are laid out independently and stacked, the way dot
326
+ // handles a disconnected graph. Packing them together would interleave
327
+ // unrelated parts of the schema for no reason.
328
+ function components(names, adj) {
329
+ var seen = {}, out = [];
330
+ names.forEach(function (start) {
331
+ if (seen[start]) return;
332
+ var group = [], queue = [start];
333
+ seen[start] = true;
334
+ while (queue.length) {
335
+ var cur = queue.shift();
336
+ group.push(cur);
337
+ Object.keys(adj[cur] || {}).forEach(function (nb) {
338
+ if (seen[nb]) return;
339
+ seen[nb] = true;
340
+ queue.push(nb);
341
+ });
342
+ }
343
+ out.push(group);
344
+ });
345
+ return out.sort(function (a, b) { return b.length - a.length; });
101
346
  }
102
347
 
103
348
  function layout(models, edges) {
104
349
  var byName = {};
105
350
  models.forEach(function (m) { byName[m.name] = m; });
106
351
 
352
+ var dir = directedEdges(models, edges);
107
353
  var adj = adjacency(models, edges);
108
- var ringOf = assignRings(models, adj);
109
-
110
- var rings = [];
111
- models.forEach(function (m) {
112
- var r = ringOf[m.name] || 0;
113
- (rings[r] = rings[r] || []).push(m.name);
114
- });
354
+ var allNames = models.map(function (m) { return m.name; });
115
355
 
116
356
  var positions = {};
117
- var angleOf = {};
118
- var maxExtent = 0;
119
- // Radius and half-footprint of the ring inside this one, so each ring is
120
- // pushed clear of it. Without this an inner ring with many nodes gets a
121
- // large circumference-derived radius and the next ring lands inside it.
122
- var prevRadius = 0;
123
- var prevHalf = 0;
124
-
125
- rings.forEach(function (names, ringIndex) {
126
- if (!names) return;
357
+ var regions = [];
358
+ var pad = 60;
127
359
 
128
- if (ringIndex === 0) {
129
- names.forEach(function (name) {
130
- // Centred on the origin, like every other node. Placing the hub by
131
- // its top-left instead shifts it half a box down and right, straight
132
- // into the ring around it.
133
- var h = nodeHeight(byName[name]);
134
- positions[name] = { x: -NODE_W / 2, y: -h / 2, model: byName[name] };
135
- angleOf[name] = 0;
136
- prevHalf = Math.max(prevHalf, Math.max(NODE_W, h) / 2);
137
- });
138
- return;
139
- }
360
+ // Lay each component out first, then pack the finished blocks. Blocks used
361
+ // to be stacked in a single column, so a schema with a big connected core
362
+ // and a handful of one-model islands ran off the bottom of the canvas with
363
+ // the whole right-hand side empty. Shelf packing wraps them instead, which
364
+ // is both more compact and closer to how the eye scans a page.
365
+ var blocks = components(allNames, adj).map(function (group) {
366
+ var inGroup = {};
367
+ group.forEach(function (n) { inGroup[n] = true; });
368
+ var groupEdges = dir.filter(function (e) { return inGroup[e.from] && inGroup[e.to]; });
140
369
 
141
- var ordered = orderRing(names, angleOf, adj, ringOf, ringIndex);
370
+ var acyclic = breakCycles(group, groupEdges);
371
+ var layerOf = assignLayers(group, acyclic);
142
372
 
143
- // Boxes vary in height with their field count, so each one claims arc
144
- // proportional to its own footprint. Spacing them evenly by count is what
145
- // makes a tall box collide with its neighbours.
146
- var extents = ordered.map(function (name) {
147
- return Math.max(NODE_W, nodeHeight(byName[name])) + NODE_GAP;
373
+ var layers = [];
374
+ group.forEach(function (n) {
375
+ var l = layerOf[n] || 0;
376
+ (layers[l] = layers[l] || []).push(n);
148
377
  });
149
- var totalExtent = extents.reduce(function (a, b) { return a + b; }, 0);
150
- var thisHalf = Math.max.apply(null, extents) / 2;
151
- var radius = Math.max(
152
- MIN_RADIUS,
153
- totalExtent / (2 * Math.PI), // wide enough that neighbours clear
154
- prevRadius + prevHalf + thisHalf + 40 // and outside the ring within
155
- );
156
- prevRadius = radius;
157
- prevHalf = thisHalf;
158
-
159
- var cumulative = 0;
160
- ordered.forEach(function (name, i) {
161
- var angle = (2 * Math.PI * (cumulative + extents[i] / 2)) / totalExtent - Math.PI / 2;
162
- cumulative += extents[i];
163
- angleOf[name] = angle;
164
- var h = nodeHeight(byName[name]);
165
- positions[name] = {
166
- x: Math.cos(angle) * radius - NODE_W / 2,
167
- y: Math.sin(angle) * radius - h / 2,
168
- model: byName[name]
378
+ for (var i = 0; i < layers.length; i++) if (!layers[i]) layers[i] = [];
379
+ layers.forEach(function (names) { names.sort(); }); // deterministic start
380
+
381
+ var adjIn = {}, adjOut = {};
382
+ group.forEach(function (n) { adjIn[n] = []; adjOut[n] = []; });
383
+ acyclic.forEach(function (e) { adjOut[e.from].push(e.to); adjIn[e.to].push(e.from); });
384
+
385
+ orderLayers(layers, adjIn, adjOut);
386
+ var laid = assignCoords(layers, byName, adjIn, adjOut);
387
+
388
+ return {
389
+ laid: laid,
390
+ group: group,
391
+ w: laid.width + BLOCK_PAD * 2,
392
+ h: laid.height + BLOCK_PAD * 2
393
+ };
394
+ });
395
+
396
+ // Wrap at whichever is wider: a roughly square canvas, or the widest single
397
+ // block (which can never be split).
398
+ var totalArea = blocks.reduce(function (a, b) { return a + b.w * b.h; }, 0);
399
+ var widest = blocks.reduce(function (a, b) { return Math.max(a, b.w); }, 0);
400
+ var targetW = Math.max(widest, Math.sqrt(totalArea * 1.6));
401
+
402
+ var shelfX = pad, shelfY = pad, shelfH = 0, maxRight = pad;
403
+
404
+ blocks.forEach(function (b) {
405
+ if (shelfX > pad && shelfX + b.w > pad + targetW) {
406
+ shelfX = pad;
407
+ shelfY += shelfH + BLOCK_GAP;
408
+ shelfH = 0;
409
+ }
410
+
411
+ Object.keys(b.laid.positions).forEach(function (n) {
412
+ var q = b.laid.positions[n];
413
+ positions[n] = {
414
+ x: shelfX + BLOCK_PAD + q.x,
415
+ y: shelfY + BLOCK_PAD + q.y,
416
+ model: q.model
169
417
  };
170
- maxExtent = Math.max(maxExtent, radius + NODE_W, radius + h);
171
418
  });
172
- });
173
419
 
174
- // Shift everything positive and size the canvas to what was actually drawn.
175
- var pad = 60;
176
- var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
177
- Object.keys(positions).forEach(function (name) {
178
- var p = positions[name];
179
- var h = nodeHeight(p.model);
180
- minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
181
- maxX = Math.max(maxX, p.x + NODE_W); maxY = Math.max(maxY, p.y + h);
182
- });
183
- if (!isFinite(minX)) { minX = 0; minY = 0; maxX = NODE_W; maxY = HEADER_H; }
420
+ regions.push({
421
+ x: shelfX, y: shelfY, w: b.w, h: b.h,
422
+ label: b.group.length > 1 ? b.group.length + ' related models' : b.group[0]
423
+ });
184
424
 
185
- Object.keys(positions).forEach(function (name) {
186
- positions[name].x += pad - minX;
187
- positions[name].y += pad - minY;
425
+ maxRight = Math.max(maxRight, shelfX + b.w);
426
+ shelfH = Math.max(shelfH, b.h);
427
+ shelfX += b.w + BLOCK_GAP;
188
428
  });
189
429
 
190
430
  return {
191
431
  positions: positions,
192
- width: (maxX - minX) + pad * 2,
193
- height: (maxY - minY) + pad * 2
432
+ regions: regions,
433
+ width: maxRight + pad,
434
+ height: shelfY + shelfH + pad
194
435
  };
195
436
  }
196
437
 
197
- // Anchor on whichever side of each box faces the other, so lines leave and
198
- // arrive at the near edge instead of cutting through their own node.
199
438
  function anchors(a, b) {
200
439
  var ah = nodeHeight(a.model), bh = nodeHeight(b.model);
201
- var acx = a.x + NODE_W / 2, acy = a.y + ah / 2;
202
- var bcx = b.x + NODE_W / 2, bcy = b.y + bh / 2;
440
+ var aw = nodeWidth(a.model), bw = nodeWidth(b.model);
441
+ var acx = a.x + aw / 2, acy = a.y + ah / 2;
442
+ var bcx = b.x + bw / 2, bcy = b.y + bh / 2;
203
443
  var horizontal = Math.abs(bcx - acx) > Math.abs(bcy - acy);
204
444
 
205
445
  if (horizontal) {
206
446
  return bcx > acx
207
- ? { x1: a.x + NODE_W, y1: acy, x2: b.x, y2: bcy, h: true }
208
- : { x1: a.x, y1: acy, x2: b.x + NODE_W, y2: bcy, h: true };
447
+ ? { x1: a.x + aw, y1: acy, x2: b.x, y2: bcy, h: true }
448
+ : { x1: a.x, y1: acy, x2: b.x + bw, y2: bcy, h: true };
209
449
  }
210
450
  return bcy > acy
211
451
  ? { x1: acx, y1: a.y + ah, x2: bcx, y2: b.y, h: false }
@@ -245,7 +485,10 @@ var ModelGraph = (function () {
245
485
  has_and_belongs_to_many: 'many ↔ many'
246
486
  };
247
487
 
248
- var MIN_ZOOM = 0.2;
488
+ // Low enough that a few hundred models genuinely fit on screen. At the old
489
+ // 0.2 floor a large app could not be framed at all, so fit-to-pane produced a
490
+ // view the zoom controls then refused to honour.
491
+ var MIN_ZOOM = 0.04;
249
492
  var MAX_ZOOM = 2.5;
250
493
 
251
494
  return function ModelGraphComponent(_ref) {
@@ -254,10 +497,36 @@ var ModelGraph = (function () {
254
497
  var onRefresh = _ref.onRefresh;
255
498
  var loading = _ref.loading;
256
499
 
257
- var _view = React.useState({ k: 1, x: 0, y: 0 });
258
- var view = _view[0], setView = _view[1];
500
+ // The view is a ref, not state, and is written straight onto the <g>'s
501
+ // transform. Every pan and zoom tick used to be a setState, which re-ran the
502
+ // whole layout (it was computed in the render body) and rebuilt every box,
503
+ // field and edge — around 10,000 SVG elements on a 300-model app. That is
504
+ // what made the graph unusable at real scale rather than anything about the
505
+ // drawing itself.
506
+ var viewRef = React.useRef({ k: 1, x: 0, y: 0 });
507
+ var sceneRef = React.useRef(null);
508
+ // Which layout we have already framed, so reopening the tab refits but a
509
+ // re-render for any other reason does not yank the view back.
510
+ var fittedForRef = React.useRef(null);
511
+
512
+ var applyView = React.useCallback(function () {
513
+ var g = sceneRef.current;
514
+ if (!g) return;
515
+ var v = viewRef.current;
516
+ g.setAttribute('transform', 'translate(' + v.x + ',' + v.y + ') scale(' + v.k + ')');
517
+ }, []);
518
+
519
+ var setView = React.useCallback(function (next) {
520
+ viewRef.current = typeof next === 'function' ? next(viewRef.current) : next;
521
+ applyView();
522
+ }, [applyView]);
523
+
259
524
  var _search = React.useState('');
260
525
  var search = _search[0], setSearch = _search[1];
526
+ var _searchOpen = React.useState(false);
527
+ var searchOpen = _searchOpen[0], setSearchOpen = _searchOpen[1];
528
+ var _highlight = React.useState(0);
529
+ var highlight = _highlight[0], setHighlight = _highlight[1];
261
530
  var _focused = React.useState(null);
262
531
  var focused = _focused[0], setFocused = _focused[1];
263
532
  var _hovered = React.useState(null);
@@ -267,27 +536,119 @@ var ModelGraph = (function () {
267
536
  var dragRef = React.useRef(null);
268
537
  var svgRef = React.useRef(null);
269
538
 
270
- // Laid out before the early returns so the fit effect below can see it.
271
- var placed = (graph && graph.ok && (graph.models || []).length)
272
- ? layout(graph.models, graph.edges || [])
273
- : null;
539
+ // Memoised on the graph payload. This used to run on every render — which,
540
+ // with the view in state, meant on every wheel tick and every mousemove of a
541
+ // drag.
542
+ var placed = React.useMemo(function () {
543
+ return (graph && graph.ok && (graph.models || []).length)
544
+ ? layout(graph.models, graph.edges || [])
545
+ : null;
546
+ }, [graph]);
547
+
548
+ // Every association each model takes part in, both directions, built once.
549
+ // Scanning the edge list per hover would be 513 comparisons on every
550
+ // mouseenter across a canvas of 300 boxes.
551
+ var associationsBy = React.useMemo(function () {
552
+ var byModel = {};
553
+ (graph && graph.edges || []).forEach(function (e) {
554
+ (byModel[e.from] = byModel[e.from] || []).push({ edge: e, outgoing: true });
555
+ if (e.to !== e.from) (byModel[e.to] = byModel[e.to] || []).push({ edge: e, outgoing: false });
556
+ });
557
+ return byModel;
558
+ }, [graph]);
559
+
560
+ // Prefix matches first, then anything containing the term — the ordering a
561
+ // name-completion list is expected to have. Capped so a large schema cannot
562
+ // render a list taller than the window.
563
+ var searchMatches = React.useMemo(function () {
564
+ var all = (graph && graph.models) || [];
565
+ var q = search.trim().toLowerCase();
566
+ if (!q) return all.slice(0, MAX_SEARCH_RESULTS);
567
+ var starts = [], contains = [];
568
+ all.forEach(function (m) {
569
+ var i = m.name.toLowerCase().indexOf(q);
570
+ if (i === 0) starts.push(m); else if (i > 0) contains.push(m);
571
+ });
572
+ return starts.concat(contains).slice(0, MAX_SEARCH_RESULTS);
573
+ }, [graph, search]);
574
+
575
+ var hoverCardRef = React.useRef(null);
576
+ var _modelHover = React.useState(null);
577
+ var modelHover = _modelHover[0], setModelHover = _modelHover[1];
578
+
579
+ // Highlighting is done by touching the DOM directly. Routing it through
580
+ // React would rebuild all ~7,000 elements of the scene on every mouseenter,
581
+ // which is the same trap the pan/zoom transform was in.
582
+ var litRef = React.useRef([]);
583
+ var clearLit = React.useCallback(function () {
584
+ litRef.current.forEach(function (el) { el.classList.remove('mg-lit'); });
585
+ litRef.current = [];
586
+ if (sceneRef.current) sceneRef.current.classList.remove('mg-focus-mode');
587
+ }, []);
588
+
589
+ var moveHoverCard = React.useCallback(function (ev) {
590
+ var card = hoverCardRef.current;
591
+ var el = svgRef.current;
592
+ if (!card || !el) return;
593
+ var rect = el.getBoundingClientRect();
594
+ var x = ev.clientX - rect.left;
595
+ var y = ev.clientY - rect.top;
596
+ // Flip before the card would run off the pane rather than after.
597
+ card.style.left = (x > rect.width - 300 ? x - 290 : x + 18) + 'px';
598
+ card.style.top = Math.min(y + 18, Math.max(0, rect.height - 240)) + 'px';
599
+ }, []);
600
+
601
+ var enterModel = React.useCallback(function (name, ev) {
602
+ if (dragRef.current) return; // panning, not inspecting
603
+ clearLit();
604
+ var scene = sceneRef.current;
605
+ if (scene) {
606
+ scene.classList.add('mg-focus-mode');
607
+ var sel = '[data-from="' + name + '"],[data-to="' + name + '"]';
608
+ litRef.current = Array.prototype.slice.call(scene.querySelectorAll(sel));
609
+ litRef.current.forEach(function (el) { el.classList.add('mg-lit'); });
610
+ }
611
+ setModelHover(name);
612
+ moveHoverCard(ev);
613
+ }, [clearLit, moveHoverCard]);
614
+
615
+ var leaveModel = React.useCallback(function () {
616
+ clearLit();
617
+ setModelHover(null);
618
+ }, [clearLit]);
274
619
 
275
620
  // Frame the whole graph on load. Without this the view starts at the
276
621
  // top-left of a canvas much larger than the pane and the diagram looks
277
622
  // empty until you go looking for it.
278
- var fitToPane = React.useCallback(function () {
623
+ var fitToPane = React.useCallback(function (attempt) {
279
624
  var el = svgRef.current;
280
625
  if (!el || !placed) return;
281
626
  var rect = el.getBoundingClientRect();
282
- if (!rect.width || !rect.height) return;
627
+ // The pane can still be unsized on the commit that mounts the SVG — the
628
+ // graph tab is opening at the same time. Bailing out here left the view at
629
+ // its 1:1 default and nothing ever retried, so a 300-model graph opened
630
+ // showing one corner of itself. Retry on the next frames until it has a
631
+ // size, then give up rather than spin.
632
+ if (!rect.width || !rect.height) {
633
+ if ((attempt || 0) < 10) {
634
+ window.requestAnimationFrame(function () { fitRef.current((attempt || 0) + 1); });
635
+ }
636
+ return;
637
+ }
283
638
 
284
- var k = Math.min(1, Math.min(rect.width / placed.width, rect.height / placed.height));
639
+ // Clamped to the same range the wheel enforces. Without the MIN_ZOOM
640
+ // clamp a large graph fitted to something like k=0.04, which is below the
641
+ // floor — so the first scroll snapped it up to MIN_ZOOM, a five-fold jump
642
+ // anchored on the cursor, and the diagram appeared to leap somewhere
643
+ // random. The view must never sit outside the range the controls allow.
644
+ var raw = Math.min(rect.width / placed.width, rect.height / placed.height);
645
+ var k = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Math.min(1, raw)));
285
646
  setView({
286
647
  k: k,
287
648
  x: (rect.width - placed.width * k) / 2,
288
649
  y: (rect.height - placed.height * k) / 2
289
650
  });
290
- }, [placed && placed.width, placed && placed.height]);
651
+ }, [placed, setView]);
291
652
 
292
653
  React.useEffect(function () { fitToPane(); }, [fitToPane]);
293
654
 
@@ -307,7 +668,7 @@ var ModelGraph = (function () {
307
668
  var k = v.k >= 1 ? v.k : Math.min(MAX_ZOOM, v.k + (1 - v.k) / 2);
308
669
  return {
309
670
  k: k,
310
- x: rect.width / 2 - (pos.x + NODE_W / 2) * k,
671
+ x: rect.width / 2 - (pos.x + nodeWidth(pos.model) / 2) * k,
311
672
  y: rect.height / 2 - (pos.y + h / 2) * k
312
673
  };
313
674
  });
@@ -316,6 +677,11 @@ var ModelGraph = (function () {
316
677
  // Same mounting problem as the wheel listener: the fit effect can run
317
678
  // before the SVG exists and not again afterwards. Kept current so the
318
679
  // callback ref can fit as soon as the element is really there.
680
+ // Held in a ref so the memoised node elements can call it without listing it
681
+ // as a dependency — otherwise every centre would rebuild the whole scene.
682
+ var centreRef = React.useRef(centreOn);
683
+ centreRef.current = centreOn;
684
+
319
685
  var fitRef = React.useRef(fitToPane);
320
686
  fitRef.current = fitToPane;
321
687
 
@@ -334,9 +700,25 @@ var ModelGraph = (function () {
334
700
  svgRef.current = el;
335
701
  if (!el) return;
336
702
 
703
+ // getBoundingClientRect forces a synchronous layout, and we had just
704
+ // written a new transform onto a <g> holding thousands of elements — so
705
+ // reading it per wheel tick made the browser re-lay-out the entire scene
706
+ // before the handler could continue. Measured at 27 ms a tick on a
707
+ // 300-model graph. The pane's own rect only changes when the window or
708
+ // the panel layout does, so cache it and refresh once per frame.
709
+ var rectRef = { current: null };
710
+ var invalidateRect = function () { rectRef.current = null; };
711
+ var paneRect = function () {
712
+ if (!rectRef.current) {
713
+ rectRef.current = el.getBoundingClientRect();
714
+ window.requestAnimationFrame(invalidateRect);
715
+ }
716
+ return rectRef.current;
717
+ };
718
+
337
719
  var onWheel = function (e) {
338
720
  e.preventDefault();
339
- var rect = el.getBoundingClientRect();
721
+ var rect = paneRect();
340
722
  var px = e.clientX - rect.left;
341
723
  var py = e.clientY - rect.top;
342
724
  setView(function (v) {
@@ -352,6 +734,142 @@ var ModelGraph = (function () {
352
734
  window.requestAnimationFrame(function () { fitRef.current(); });
353
735
  }, []);
354
736
 
737
+ // Memoised so hovering a model does not rebuild every box and edge.
738
+ // The highlight itself is applied by toggling DOM classes; only the
739
+ // association card is React state, and it lives outside this subtree.
740
+ var sceneChildren = React.useMemo(function () {
741
+ if (!placed) return null;
742
+ var models = (graph && graph.models) || [];
743
+ var edges = (graph && graph.edges) || [];
744
+ return [
745
+ // Behind everything: one soft area per connected cluster, so the
746
+ // grouping is something you can see rather than infer.
747
+ (placed.regions || []).map(function (r, i) {
748
+ return React.createElement(
749
+ 'g',
750
+ { key: 'r' + i, className: 'mg-region' },
751
+ React.createElement('rect', {
752
+ className: 'mg-region-box',
753
+ x: r.x, y: r.y, width: r.w, height: r.h, rx: 10
754
+ }),
755
+ React.createElement('text', {
756
+ className: 'mg-region-label', x: r.x + 12, y: r.y + 17
757
+ }, r.label)
758
+ );
759
+ }),
760
+ edges.map(function (e, i) {
761
+ var a = placed.positions[e.from];
762
+ var b = placed.positions[e.to];
763
+ if (!a || !b || e.from === e.to) return null;
764
+ var d = edgePath(a, b);
765
+ var isHovered = hovered && hovered.index === i;
766
+ return React.createElement(
767
+ 'g',
768
+ // Tagged so a node hover can find its own edges with one DOM query
769
+ // instead of a React pass over every edge.
770
+ { key: 'e' + i, 'data-from': e.from, 'data-to': e.to },
771
+ // A transparent, much thicker copy of the line under the real
772
+ // one. A 1.2px stroke is its own hit area, which makes hovering
773
+ // an association essentially impossible without this.
774
+ React.createElement('path', {
775
+ d: d,
776
+ className: 'mg-edge-hit',
777
+ onMouseEnter: function () { setHovered({ index: i, edge: e }); },
778
+ onMouseMove: function (ev) {
779
+ var rect = svgRef.current.getBoundingClientRect();
780
+ setPointer({ x: ev.clientX - rect.left, y: ev.clientY - rect.top });
781
+ },
782
+ onMouseLeave: function () { setHovered(null); }
783
+ }),
784
+ React.createElement('path', {
785
+ d: d,
786
+ className: 'mg-edge ' + (MACRO_CLASS[e.macro] || '') + (isHovered ? ' mg-edge-hovered' : ''),
787
+ markerEnd: 'url(#mg-arrow)'
788
+ })
789
+ );
790
+ }),
791
+ models.map(function (m) {
792
+ var pos = placed.positions[m.name];
793
+ if (!pos) return null;
794
+ var fields = (m.columns || []).slice(0, MAX_FIELDS);
795
+ var hidden = (m.columnCount || 0) - fields.length;
796
+ var h = nodeHeight(m);
797
+ var w = nodeWidth(m);
798
+ return React.createElement(
799
+ 'g',
800
+ {
801
+ key: m.name,
802
+ className: 'mg-node' + (focused === m.name ? ' mg-focused' : ''),
803
+ transform: 'translate(' + pos.x + ',' + pos.y + ')',
804
+ onMouseEnter: function (ev) { enterModel(m.name, ev); },
805
+ onMouseMove: function (ev) { moveHoverCard(ev); },
806
+ onMouseLeave: function () { leaveModel(); },
807
+ onClick: function () {
808
+ // A drag that ends over a box must not also act on it.
809
+ if (dragRef.current && dragRef.current.moved) return;
810
+ // Clicking the box navigates: zoom to it. Opening the schema
811
+ // is the explicit button in the header, so a stray click while
812
+ // exploring no longer throws a modal in your way.
813
+ centreRef.current(m.name);
814
+ }
815
+ },
816
+ React.createElement('rect', { className: 'mg-box', width: w, height: h, rx: 4 }),
817
+ React.createElement('rect', { className: 'mg-box-header', width: w, height: HEADER_H, rx: 4 }),
818
+ // Budget stops short of the header button, which sits at w - 26.
819
+ React.createElement('text', { className: 'mg-name', x: 10, y: 15 },
820
+ fitText(m.name, w - 44, FONT_NAME)),
821
+ React.createElement('text', { className: 'mg-table', x: 10, y: 27 },
822
+ fitText((m.table || '—') + (m.columnCount ? ' · ' + m.columnCount + ' cols' : ''),
823
+ w - 44, FONT_SMALL)),
824
+ // Opening the full schema is now an explicit target rather than
825
+ // anything-anywhere on the box, so clicking around to navigate
826
+ // cannot keep throwing a modal at you.
827
+ React.createElement(
828
+ 'g',
829
+ {
830
+ className: 'mg-open-btn',
831
+ onClick: function (ev) {
832
+ ev.stopPropagation();
833
+ if (dragRef.current && dragRef.current.moved) return;
834
+ if (onOpenModel) onOpenModel(m);
835
+ }
836
+ },
837
+ React.createElement('title', null, 'Open the full schema for ' + m.name),
838
+ React.createElement('rect', {
839
+ className: 'mg-open-btn-bg', x: w - 26, y: 7, width: 19, height: 19, rx: 3
840
+ }),
841
+ // Three stacked bars — a table, matching what the button opens.
842
+ React.createElement('rect', { className: 'mg-open-btn-bar', x: w - 22, y: 11, width: 11, height: 2 }),
843
+ React.createElement('rect', { className: 'mg-open-btn-bar', x: w - 22, y: 15.5, width: 11, height: 2 }),
844
+ React.createElement('rect', { className: 'mg-open-btn-bar', x: w - 22, y: 20, width: 11, height: 2 })
845
+ ),
846
+ fields.map(function (c, i) {
847
+ var y = HEADER_H + 11 + i * FIELD_H;
848
+ var typeW = Math.min(64, textWidth(String(c.type == null ? '' : c.type), FONT_SMALL));
849
+ return React.createElement(
850
+ React.Fragment,
851
+ { key: c.name },
852
+ // The type is right-aligned, so the name's budget is whatever
853
+ // the type does not claim — otherwise the two met in the middle.
854
+ React.createElement('text', { className: 'mg-field', x: 10, y: y },
855
+ fitText(c.name, w - 28 - typeW, FONT_FIELD)),
856
+ React.createElement('text', { className: 'mg-field-type', x: w - 10, y: y, textAnchor: 'end' },
857
+ fitText(c.type, 64, FONT_SMALL))
858
+ );
859
+ }),
860
+ hidden > 0 && React.createElement('text', {
861
+ className: 'mg-field-more', x: 10, y: HEADER_H + 11 + fields.length * FIELD_H
862
+ }, '+' + hidden + ' more…'),
863
+ // No connection means no columns to show; say so rather than
864
+ // rendering an empty box that looks like a model with no fields.
865
+ fields.length === 0 && React.createElement('text', {
866
+ className: 'mg-field-more', x: 10, y: HEADER_H + 11
867
+ }, 'no database connection')
868
+ );
869
+ })
870
+ ];
871
+ }, [placed, graph, focused, onOpenModel]);
872
+
355
873
  if (loading) {
356
874
  return React.createElement('div', { className: 'ide-model-graph-empty' }, 'Building the model graph…');
357
875
  }
@@ -377,7 +895,9 @@ var ModelGraph = (function () {
377
895
 
378
896
  var onMouseDown = function (e) {
379
897
  if (e.button !== 0) return;
380
- dragRef.current = { sx: e.clientX, sy: e.clientY, ox: view.x, oy: view.y, moved: false };
898
+ var v = viewRef.current;
899
+ dragRef.current = { sx: e.clientX, sy: e.clientY, ox: v.x, oy: v.y, moved: false };
900
+ if (svgRef.current) svgRef.current.classList.add('mg-dragging');
381
901
  };
382
902
  var onMouseMove = function (e) {
383
903
  var d = dragRef.current;
@@ -386,7 +906,10 @@ var ModelGraph = (function () {
386
906
  if (Math.abs(dx) > 3 || Math.abs(dy) > 3) d.moved = true;
387
907
  setView(function (v) { return { k: v.k, x: d.ox + dx, y: d.oy + dy }; });
388
908
  };
389
- var endDrag = function () { dragRef.current = null; };
909
+ var endDrag = function () {
910
+ dragRef.current = null;
911
+ if (svgRef.current) svgRef.current.classList.remove('mg-dragging');
912
+ };
390
913
 
391
914
  return React.createElement(
392
915
  'div',
@@ -396,38 +919,70 @@ var ModelGraph = (function () {
396
919
  { className: 'ide-model-graph-toolbar' },
397
920
  React.createElement('span', null, models.length + ' models, ' + edges.length + ' associations'),
398
921
  graph.truncated && React.createElement('span', { className: 'ide-model-graph-warn' }, ' (truncated)'),
399
- React.createElement('span', { className: 'ide-model-graph-hint' }, 'drag to pan · scroll to zoom · click a model for its schema'),
922
+ React.createElement('span', { className: 'ide-model-graph-hint' }, 'drag to pan · scroll to zoom · click a model to zoom to it · hover for its associations'),
400
923
  React.createElement(
401
924
  'div',
402
925
  { className: 'ide-model-graph-actions' },
403
- // A native datalist rather than a bespoke dropdown: the browser gives
404
- // us the filtering and keyboard handling for free.
405
- React.createElement('input', {
406
- className: 'ide-model-graph-search',
407
- type: 'search',
408
- list: 'mg-model-names',
409
- placeholder: 'Centre on a model…',
410
- value: search,
411
- onChange: function (e) {
412
- setSearch(e.target.value);
413
- // Picking from the datalist fires change with the full name, so
414
- // an exact hit centres immediately rather than needing Enter.
415
- if (placed.positions[e.target.value]) centreOn(e.target.value);
416
- },
417
- onKeyDown: function (e) {
418
- if (e.key !== 'Enter') return;
419
- var match = models.filter(function (m) {
420
- return m.name.toLowerCase().indexOf(e.target.value.trim().toLowerCase()) === 0;
421
- })[0];
422
- if (match) { setSearch(match.name); centreOn(match.name); }
423
- }
424
- }),
926
+ // A real dropdown rather than a native <datalist>. The datalist was
927
+ // chosen to get filtering and keyboard handling for free, but the
928
+ // browser renders it as an unstyleable list in the platform's own
929
+ // chrome — wrong font, wrong colours, and no way to show which model
930
+ // is in which cluster. This is a few more lines and looks like the
931
+ // rest of the editor.
425
932
  React.createElement(
426
- 'datalist',
427
- { id: 'mg-model-names' },
428
- models.map(function (m) {
429
- return React.createElement('option', { key: m.name, value: m.name });
430
- })
933
+ 'div',
934
+ { className: 'mg-search-wrap' },
935
+ React.createElement('input', {
936
+ className: 'ide-model-graph-search',
937
+ type: 'text',
938
+ placeholder: 'Centre on a model…',
939
+ value: search,
940
+ onChange: function (e) { setSearch(e.target.value); setSearchOpen(true); setHighlight(0); },
941
+ onFocus: function () { setSearchOpen(true); },
942
+ // A click on an option would otherwise be lost: blur fires first
943
+ // and unmounts the list before mousedown lands.
944
+ onBlur: function () { window.setTimeout(function () { setSearchOpen(false); }, 120); },
945
+ onKeyDown: function (e) {
946
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
947
+ e.preventDefault();
948
+ setSearchOpen(true);
949
+ setHighlight(function (h) {
950
+ var next = h + (e.key === 'ArrowDown' ? 1 : -1);
951
+ if (next < 0) return searchMatches.length - 1;
952
+ if (next >= searchMatches.length) return 0;
953
+ return next;
954
+ });
955
+ return;
956
+ }
957
+ if (e.key === 'Escape') { setSearchOpen(false); return; }
958
+ if (e.key !== 'Enter') return;
959
+ var pick = searchMatches[highlight] || searchMatches[0];
960
+ if (pick) { setSearch(pick.name); setSearchOpen(false); centreOn(pick.name); }
961
+ }
962
+ }),
963
+ searchOpen && searchMatches.length > 0 && React.createElement(
964
+ 'ul',
965
+ { className: 'mg-search-list' },
966
+ searchMatches.map(function (m, i) {
967
+ return React.createElement(
968
+ 'li',
969
+ {
970
+ key: m.name,
971
+ className: 'mg-search-option' + (i === highlight ? ' mg-search-option-active' : ''),
972
+ onMouseEnter: function () { setHighlight(i); },
973
+ onMouseDown: function (ev) {
974
+ ev.preventDefault(); // keep focus so blur cannot beat us
975
+ setSearch(m.name);
976
+ setSearchOpen(false);
977
+ centreOn(m.name);
978
+ }
979
+ },
980
+ React.createElement('span', { className: 'mg-search-option-name' }, m.name),
981
+ React.createElement('span', { className: 'mg-search-option-meta' },
982
+ (m.table || '') + (m.columnCount ? ' · ' + m.columnCount + ' cols' : ''))
983
+ );
984
+ })
985
+ )
431
986
  ),
432
987
  React.createElement('button', {
433
988
  type: 'button', className: 'ide-model-graph-btn', title: 'Fit the whole graph',
@@ -439,6 +994,48 @@ var ModelGraph = (function () {
439
994
  }, React.createElement('i', { className: 'fas fa-sync' }))
440
995
  )
441
996
  ),
997
+ // Every association a hovered model takes part in. The graph shows that a
998
+ // line exists; this says what it actually is, which is the thing you need
999
+ // when a model has a dozen of them fanning out.
1000
+ React.createElement(
1001
+ 'div',
1002
+ {
1003
+ ref: hoverCardRef,
1004
+ className: 'mg-assoc-card' + (modelHover ? '' : ' mg-assoc-card-hidden')
1005
+ },
1006
+ modelHover && React.createElement('div', { className: 'mg-assoc-title' }, modelHover),
1007
+ modelHover && (function () {
1008
+ var list = associationsBy[modelHover] || [];
1009
+ if (!list.length) {
1010
+ return React.createElement('div', { className: 'mg-assoc-empty' }, 'No associations');
1011
+ }
1012
+ return React.createElement(
1013
+ 'div',
1014
+ { className: 'mg-assoc-list' },
1015
+ React.createElement('div', { className: 'mg-assoc-count' },
1016
+ list.length + (list.length === 1 ? ' association' : ' associations')),
1017
+ list.slice(0, MAX_ASSOC_LISTED).map(function (a, i) {
1018
+ var e = a.edge;
1019
+ var other = a.outgoing ? e.to : e.from;
1020
+ return React.createElement(
1021
+ 'div',
1022
+ { key: i, className: 'mg-assoc-row' },
1023
+ React.createElement('span', {
1024
+ className: 'mg-assoc-dot ' + (MACRO_CLASS[e.macro] || '')
1025
+ }),
1026
+ React.createElement('span', { className: 'mg-assoc-macro' }, e.macro),
1027
+ React.createElement('span', { className: 'mg-assoc-name' }, ':' + e.name),
1028
+ React.createElement('span', { className: 'mg-assoc-dir' },
1029
+ (a.outgoing ? '→ ' : '← ') + other),
1030
+ e.through && React.createElement('span', { className: 'mg-assoc-through' },
1031
+ 'through :' + e.through)
1032
+ );
1033
+ }),
1034
+ list.length > MAX_ASSOC_LISTED && React.createElement('div', { className: 'mg-assoc-more' },
1035
+ '+' + (list.length - MAX_ASSOC_LISTED) + ' more')
1036
+ );
1037
+ })()
1038
+ ),
442
1039
  hovered && React.createElement(
443
1040
  'div',
444
1041
  {
@@ -483,79 +1080,25 @@ var ModelGraph = (function () {
483
1080
  ),
484
1081
  React.createElement(
485
1082
  'g',
486
- { transform: 'translate(' + view.x + ',' + view.y + ') scale(' + view.k + ')' },
487
- edges.map(function (e, i) {
488
- var a = placed.positions[e.from];
489
- var b = placed.positions[e.to];
490
- if (!a || !b || e.from === e.to) return null;
491
- var d = edgePath(a, b);
492
- var isHovered = hovered && hovered.index === i;
493
- return React.createElement(
494
- 'g',
495
- { key: 'e' + i },
496
- // A transparent, much thicker copy of the line under the real
497
- // one. A 1.2px stroke is its own hit area, which makes hovering
498
- // an association essentially impossible without this.
499
- React.createElement('path', {
500
- d: d,
501
- className: 'mg-edge-hit',
502
- onMouseEnter: function () { setHovered({ index: i, edge: e }); },
503
- onMouseMove: function (ev) {
504
- var rect = svgRef.current.getBoundingClientRect();
505
- setPointer({ x: ev.clientX - rect.left, y: ev.clientY - rect.top });
506
- },
507
- onMouseLeave: function () { setHovered(null); }
508
- }),
509
- React.createElement('path', {
510
- d: d,
511
- className: 'mg-edge ' + (MACRO_CLASS[e.macro] || '') + (isHovered ? ' mg-edge-hovered' : ''),
512
- markerEnd: 'url(#mg-arrow)'
513
- })
514
- );
515
- }),
516
- models.map(function (m) {
517
- var pos = placed.positions[m.name];
518
- if (!pos) return null;
519
- var fields = (m.columns || []).slice(0, MAX_FIELDS);
520
- var hidden = (m.columnCount || 0) - fields.length;
521
- var h = nodeHeight(m);
522
- return React.createElement(
523
- 'g',
524
- {
525
- key: m.name,
526
- className: 'mg-node' + (focused === m.name ? ' mg-focused' : ''),
527
- transform: 'translate(' + pos.x + ',' + pos.y + ')',
528
- onClick: function () {
529
- // A drag that ends over a box must not also open it.
530
- if (dragRef.current && dragRef.current.moved) return;
531
- if (onOpenModel) onOpenModel(m);
532
- }
533
- },
534
- React.createElement('title', null, m.name + ' — click for the full schema'),
535
- React.createElement('rect', { className: 'mg-box', width: NODE_W, height: h, rx: 4 }),
536
- React.createElement('rect', { className: 'mg-box-header', width: NODE_W, height: HEADER_H, rx: 4 }),
537
- React.createElement('text', { className: 'mg-name', x: 10, y: 15 }, m.name),
538
- React.createElement('text', { className: 'mg-table', x: 10, y: 27 },
539
- (m.table || '—') + (m.columnCount ? ' · ' + m.columnCount + ' cols' : '')),
540
- fields.map(function (c, i) {
541
- var y = HEADER_H + 11 + i * FIELD_H;
542
- return React.createElement(
543
- React.Fragment,
544
- { key: c.name },
545
- React.createElement('text', { className: 'mg-field', x: 10, y: y }, c.name),
546
- React.createElement('text', { className: 'mg-field-type', x: NODE_W - 10, y: y, textAnchor: 'end' }, c.type)
547
- );
548
- }),
549
- hidden > 0 && React.createElement('text', {
550
- className: 'mg-field-more', x: 10, y: HEADER_H + 11 + fields.length * FIELD_H
551
- }, '+' + hidden + ' more…'),
552
- // No connection means no columns to show; say so rather than
553
- // rendering an empty box that looks like a model with no fields.
554
- fields.length === 0 && React.createElement('text', {
555
- className: 'mg-field-more', x: 10, y: HEADER_H + 11
556
- }, 'no database connection')
557
- );
558
- })
1083
+ {
1084
+ // No transform prop: applyView writes it directly, so panning and
1085
+ // zooming never touch React.
1086
+ ref: function (el) {
1087
+ sceneRef.current = el;
1088
+ if (!el) return;
1089
+ applyView();
1090
+ // Fit here rather than only from an effect. The effect fires
1091
+ // before the tab has been given its size, so it measured a
1092
+ // zero-width pane and bailed, leaving a 300-model graph opening at
1093
+ // 1:1 showing one corner. This runs once per layout, on the frame
1094
+ // after the scene actually exists.
1095
+ if (fittedForRef.current !== placed) {
1096
+ fittedForRef.current = placed;
1097
+ window.requestAnimationFrame(function () { fitRef.current(); });
1098
+ }
1099
+ }
1100
+ },
1101
+ sceneChildren
559
1102
  )
560
1103
  )
561
1104
  );