mbeditor 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +193 -0
- data/README.md +190 -3
- data/app/assets/javascripts/mbeditor/application.js +5 -0
- data/app/assets/javascripts/mbeditor/application_iife_tail.js +6 -0
- data/app/assets/javascripts/mbeditor/collaboration_identity.js +234 -0
- data/app/assets/javascripts/mbeditor/collaboration_service.js +690 -0
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +120 -19
- data/app/assets/javascripts/mbeditor/components/FileTree.js +127 -8
- data/app/assets/javascripts/mbeditor/components/GitPanel.js +12 -3
- data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +127 -0
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +916 -72
- data/app/assets/javascripts/mbeditor/components/ModelGraph.js +934 -0
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +130 -10
- data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +1 -0
- data/app/assets/javascripts/mbeditor/components/TabBar.js +4 -2
- data/app/assets/javascripts/mbeditor/editor_plugins.js +517 -111
- data/app/assets/javascripts/mbeditor/file_import.js +146 -0
- data/app/assets/javascripts/mbeditor/file_service.js +52 -3
- data/app/assets/javascripts/mbeditor/tab_manager.js +50 -1
- data/app/assets/javascripts/mbeditor/websocket_service.js +89 -0
- data/app/assets/stylesheets/mbeditor/editor.css +365 -10
- data/app/channels/mbeditor/channel_authentication.rb +94 -0
- data/app/channels/mbeditor/collaboration_channel.rb +84 -0
- data/app/channels/mbeditor/editor_channel.rb +40 -1
- data/app/controllers/mbeditor/application_controller.rb +5 -1
- data/app/controllers/mbeditor/editors_controller.rb +492 -19
- data/app/controllers/mbeditor/git_controller.rb +9 -2
- data/app/services/mbeditor/availability_probe.rb +76 -17
- data/app/services/mbeditor/code_search_service.rb +23 -3
- data/app/services/mbeditor/collaboration_doc_store.rb +116 -0
- data/app/services/mbeditor/file_import_service.rb +103 -0
- data/app/services/mbeditor/git_combined_diff_service.rb +36 -5
- data/app/services/mbeditor/git_info_service.rb +6 -0
- data/app/services/mbeditor/git_service.rb +22 -6
- data/app/services/mbeditor/lsp_diagnostics_translator.rb +99 -5
- data/app/services/mbeditor/model_graph_service.rb +232 -0
- data/app/services/mbeditor/presence_registry.rb +83 -0
- data/app/services/mbeditor/ri_definition_service.rb +39 -5
- data/app/services/mbeditor/search_replace_service.rb +24 -4
- data/app/views/layouts/mbeditor/application.html.erb +2 -0
- data/lib/mbeditor/configuration.rb +37 -3
- data/lib/mbeditor/engine.rb +34 -0
- data/lib/mbeditor/exception_log.rb +84 -0
- data/lib/mbeditor/route_map.rb +5 -0
- data/lib/mbeditor/ruby_lsp_client.rb +28 -1
- data/lib/mbeditor/version.rb +1 -1
- data/lib/mbeditor.rb +1 -0
- data/vendor/assets/javascripts/yjs-collab.js +12 -0
- metadata +15 -2
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ModelGraph — an SVG entity diagram of the host app's ActiveRecord models.
|
|
4
|
+
//
|
|
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.
|
|
16
|
+
var ModelGraph = (function () {
|
|
17
|
+
var NODE_W = 188;
|
|
18
|
+
var HEADER_H = 34; // model name + table name
|
|
19
|
+
var FIELD_H = 15;
|
|
20
|
+
var MAX_FIELDS = 8; // the rest are one click away in the schema modal
|
|
21
|
+
var ROW_GAP = 36;
|
|
22
|
+
var LAYER_GAP = 110; // horizontal space between layers
|
|
23
|
+
var ORDER_PASSES = 4; // median sweeps for crossing reduction
|
|
24
|
+
var STRAIGHTEN_PASSES = 3;
|
|
25
|
+
var MAX_ASSOC_LISTED = 12; // the card stays readable; the rest are counted
|
|
26
|
+
var BLOCK_PAD = 26; // breathing room inside a cluster's boundary
|
|
27
|
+
var BLOCK_GAP = 56; // space between neighbouring clusters
|
|
28
|
+
|
|
29
|
+
function nodeHeight(model) {
|
|
30
|
+
var shown = Math.min((model.columns || []).length, MAX_FIELDS);
|
|
31
|
+
var more = (model.columnCount || 0) > shown ? FIELD_H : 0;
|
|
32
|
+
return HEADER_H + shown * FIELD_H + more + 8;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Undirected adjacency: for placement, "A belongs_to B" and "B has_many A"
|
|
36
|
+
// are the same tie and should pull the two together once.
|
|
37
|
+
function adjacency(models, edges) {
|
|
38
|
+
var adj = {};
|
|
39
|
+
models.forEach(function (m) { adj[m.name] = {}; });
|
|
40
|
+
edges.forEach(function (e) {
|
|
41
|
+
if (!adj[e.from] || !adj[e.to] || e.from === e.to) return;
|
|
42
|
+
adj[e.from][e.to] = true;
|
|
43
|
+
adj[e.to][e.from] = true;
|
|
44
|
+
});
|
|
45
|
+
return adj;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Layout: Sugiyama layered, the standard for entity diagrams ────────────
|
|
49
|
+
//
|
|
50
|
+
// Associations are directed (belongs_to points a child at its parent), which
|
|
51
|
+
// is exactly the input a layered layout wants, and it is what Graphviz `dot`
|
|
52
|
+
// uses — and therefore Rails ERD, the usual tool for drawing this picture.
|
|
53
|
+
// The four classic phases: break cycles, assign layers, order within layers
|
|
54
|
+
// to cut crossings, then assign coordinates.
|
|
55
|
+
//
|
|
56
|
+
// Earlier attempts placed models by traversal order poured into grid cells.
|
|
57
|
+
// That put related models near each other only by accident: a box's position
|
|
58
|
+
// carried no information, so the diagram could not actually be read.
|
|
59
|
+
|
|
60
|
+
// Unique directed edges; self-references are dropped as placement constraints.
|
|
61
|
+
function directedEdges(models, edges) {
|
|
62
|
+
var known = {};
|
|
63
|
+
models.forEach(function (m) { known[m.name] = true; });
|
|
64
|
+
var seen = {};
|
|
65
|
+
var out = [];
|
|
66
|
+
edges.forEach(function (e) {
|
|
67
|
+
if (!known[e.from] || !known[e.to] || e.from === e.to) return;
|
|
68
|
+
var key = e.from + ' ' + e.to;
|
|
69
|
+
if (seen[key]) return;
|
|
70
|
+
seen[key] = true;
|
|
71
|
+
out.push({ from: e.from, to: e.to });
|
|
72
|
+
});
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Phase 1 — break cycles. Rails schemas are full of them (a belongs_to paired
|
|
77
|
+
// with a has_many the other way, or a genuine loop), and layering needs a DAG.
|
|
78
|
+
// Depth-first search, dropping any edge that points back at a node still on
|
|
79
|
+
// the stack. Those edges are still drawn in their true direction; they just
|
|
80
|
+
// stop constraining which layer a node lands in.
|
|
81
|
+
function breakCycles(names, edges) {
|
|
82
|
+
var out = {}, state = {}; // 0 unvisited, 1 on stack, 2 done
|
|
83
|
+
names.forEach(function (n) { out[n] = []; state[n] = 0; });
|
|
84
|
+
edges.forEach(function (e) { out[e.from].push(e.to); });
|
|
85
|
+
|
|
86
|
+
var acyclic = [];
|
|
87
|
+
names.forEach(function (root) {
|
|
88
|
+
if (state[root] !== 0) return;
|
|
89
|
+
// Iterative: a 300-model schema nests deeper than is comfortable for
|
|
90
|
+
// recursion.
|
|
91
|
+
var stack = [{ node: root, i: 0 }];
|
|
92
|
+
state[root] = 1;
|
|
93
|
+
while (stack.length) {
|
|
94
|
+
var top = stack[stack.length - 1];
|
|
95
|
+
if (top.i < out[top.node].length) {
|
|
96
|
+
var next = out[top.node][top.i++];
|
|
97
|
+
if (state[next] === 1) continue;
|
|
98
|
+
acyclic.push({ from: top.node, to: next });
|
|
99
|
+
if (state[next] === 0) { state[next] = 1; stack.push({ node: next, i: 0 }); }
|
|
100
|
+
} else {
|
|
101
|
+
state[top.node] = 2;
|
|
102
|
+
stack.pop();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
return acyclic;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Phase 2 — longest-path layering. A node sits one layer past its deepest
|
|
110
|
+
// predecessor, so every edge points forwards and depth reads left to right.
|
|
111
|
+
function assignLayers(names, acyclic) {
|
|
112
|
+
var incoming = {}, outgoing = {}, indeg = {};
|
|
113
|
+
names.forEach(function (n) { incoming[n] = []; outgoing[n] = []; indeg[n] = 0; });
|
|
114
|
+
acyclic.forEach(function (e) {
|
|
115
|
+
outgoing[e.from].push(e.to);
|
|
116
|
+
incoming[e.to].push(e.from);
|
|
117
|
+
indeg[e.to]++;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
var layer = {};
|
|
121
|
+
var queue = names.filter(function (n) { return indeg[n] === 0; });
|
|
122
|
+
queue.forEach(function (n) { layer[n] = 0; });
|
|
123
|
+
|
|
124
|
+
var pending = {};
|
|
125
|
+
names.forEach(function (n) { pending[n] = indeg[n]; });
|
|
126
|
+
|
|
127
|
+
while (queue.length) {
|
|
128
|
+
var n = queue.shift();
|
|
129
|
+
outgoing[n].forEach(function (m) {
|
|
130
|
+
layer[m] = Math.max(layer[m] || 0, layer[n] + 1);
|
|
131
|
+
if (--pending[m] === 0) queue.push(m);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
// Anything left sits in a knot the cycle-breaker could not fully unwind;
|
|
135
|
+
// park it past its deepest known predecessor rather than dropping it.
|
|
136
|
+
names.forEach(function (n) {
|
|
137
|
+
if (layer[n] !== undefined) return;
|
|
138
|
+
var base = 0;
|
|
139
|
+
incoming[n].forEach(function (p) { base = Math.max(base, (layer[p] || 0) + 1); });
|
|
140
|
+
layer[n] = base;
|
|
141
|
+
});
|
|
142
|
+
return layer;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Phase 3 — crossing reduction by the median heuristic, swept down then up.
|
|
146
|
+
// Each node moves to the median position of its neighbours in the adjacent
|
|
147
|
+
// layer; repeated sweeps settle into far fewer crossings than any fixed
|
|
148
|
+
// ordering. This is the same heuristic dot uses.
|
|
149
|
+
function orderLayers(layers, adjIn, adjOut) {
|
|
150
|
+
var pos = {};
|
|
151
|
+
layers.forEach(function (names) {
|
|
152
|
+
names.forEach(function (n, i) { pos[n] = i; });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
var medianOf = function (name, neighbours) {
|
|
156
|
+
var ps = (neighbours[name] || []).map(function (nb) { return pos[nb]; })
|
|
157
|
+
.filter(function (v) { return v !== undefined; })
|
|
158
|
+
.sort(function (a, b) { return a - b; });
|
|
159
|
+
if (!ps.length) return -1;
|
|
160
|
+
var mid = Math.floor(ps.length / 2);
|
|
161
|
+
return ps.length % 2 ? ps[mid] : (ps[mid - 1] + ps[mid]) / 2;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
var sweep = function (from, to, step, neighbours) {
|
|
165
|
+
for (var li = from; li !== to; li += step) {
|
|
166
|
+
var names = layers[li];
|
|
167
|
+
if (!names || !names.length) continue;
|
|
168
|
+
var keyed = names.map(function (n, i) { return { n: n, m: medianOf(n, neighbours), i: i }; });
|
|
169
|
+
keyed.sort(function (a, b) {
|
|
170
|
+
// A node with no neighbour in the reference layer keeps its relative
|
|
171
|
+
// place rather than being bunched at one end.
|
|
172
|
+
if (a.m < 0 && b.m < 0) return a.i - b.i;
|
|
173
|
+
if (a.m < 0) return -1;
|
|
174
|
+
if (b.m < 0) return 1;
|
|
175
|
+
return a.m - b.m || a.i - b.i;
|
|
176
|
+
});
|
|
177
|
+
layers[li] = keyed.map(function (k) { return k.n; });
|
|
178
|
+
layers[li].forEach(function (n, i) { pos[n] = i; });
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
for (var pass = 0; pass < ORDER_PASSES; pass++) {
|
|
183
|
+
sweep(1, layers.length, 1, adjIn);
|
|
184
|
+
sweep(layers.length - 2, -1, -1, adjOut);
|
|
185
|
+
}
|
|
186
|
+
return layers;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Phase 4 — coordinates. x is the layer; y stacks within a layer, then a few
|
|
190
|
+
// relaxation passes pull each node toward the average of its neighbours so
|
|
191
|
+
// chains come out straight instead of stepped. After each pass the layer is
|
|
192
|
+
// re-separated in order, so relaxation can never overlap two boxes.
|
|
193
|
+
function assignCoords(layers, byName, adjIn, adjOut) {
|
|
194
|
+
var y = {};
|
|
195
|
+
layers.forEach(function (names) {
|
|
196
|
+
if (!names) return;
|
|
197
|
+
var cursor = 0;
|
|
198
|
+
names.forEach(function (n) {
|
|
199
|
+
y[n] = cursor;
|
|
200
|
+
cursor += nodeHeight(byName[n]) + ROW_GAP;
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
for (var pass = 0; pass < STRAIGHTEN_PASSES; pass++) {
|
|
205
|
+
layers.forEach(function (names) {
|
|
206
|
+
if (!names || !names.length) return;
|
|
207
|
+
names.forEach(function (n) {
|
|
208
|
+
var ns = (adjIn[n] || []).concat(adjOut[n] || []);
|
|
209
|
+
var vals = ns.map(function (nb) { return y[nb]; })
|
|
210
|
+
.filter(function (v) { return v !== undefined; });
|
|
211
|
+
if (!vals.length) return;
|
|
212
|
+
var want = vals.reduce(function (a, b) { return a + b; }, 0) / vals.length;
|
|
213
|
+
y[n] = y[n] + (want - y[n]) * 0.5;
|
|
214
|
+
});
|
|
215
|
+
var cursor = -Infinity;
|
|
216
|
+
names.forEach(function (n) {
|
|
217
|
+
if (y[n] < cursor) y[n] = cursor;
|
|
218
|
+
cursor = y[n] + nodeHeight(byName[n]) + ROW_GAP;
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
var minY = Infinity;
|
|
224
|
+
Object.keys(y).forEach(function (n) { minY = Math.min(minY, y[n]); });
|
|
225
|
+
if (!isFinite(minY)) minY = 0;
|
|
226
|
+
|
|
227
|
+
var positions = {};
|
|
228
|
+
var maxX = 0, maxY = 0;
|
|
229
|
+
layers.forEach(function (names, li) {
|
|
230
|
+
if (!names) return;
|
|
231
|
+
names.forEach(function (n) {
|
|
232
|
+
var x = li * (NODE_W + LAYER_GAP);
|
|
233
|
+
var yy = y[n] - minY;
|
|
234
|
+
positions[n] = { x: x, y: yy, model: byName[n] };
|
|
235
|
+
maxX = Math.max(maxX, x + NODE_W);
|
|
236
|
+
maxY = Math.max(maxY, yy + nodeHeight(byName[n]));
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
return { positions: positions, width: maxX, height: maxY };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Connected components are laid out independently and stacked, the way dot
|
|
243
|
+
// handles a disconnected graph. Packing them together would interleave
|
|
244
|
+
// unrelated parts of the schema for no reason.
|
|
245
|
+
function components(names, adj) {
|
|
246
|
+
var seen = {}, out = [];
|
|
247
|
+
names.forEach(function (start) {
|
|
248
|
+
if (seen[start]) return;
|
|
249
|
+
var group = [], queue = [start];
|
|
250
|
+
seen[start] = true;
|
|
251
|
+
while (queue.length) {
|
|
252
|
+
var cur = queue.shift();
|
|
253
|
+
group.push(cur);
|
|
254
|
+
Object.keys(adj[cur] || {}).forEach(function (nb) {
|
|
255
|
+
if (seen[nb]) return;
|
|
256
|
+
seen[nb] = true;
|
|
257
|
+
queue.push(nb);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
out.push(group);
|
|
261
|
+
});
|
|
262
|
+
return out.sort(function (a, b) { return b.length - a.length; });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function layout(models, edges) {
|
|
266
|
+
var byName = {};
|
|
267
|
+
models.forEach(function (m) { byName[m.name] = m; });
|
|
268
|
+
|
|
269
|
+
var dir = directedEdges(models, edges);
|
|
270
|
+
var adj = adjacency(models, edges);
|
|
271
|
+
var allNames = models.map(function (m) { return m.name; });
|
|
272
|
+
|
|
273
|
+
var positions = {};
|
|
274
|
+
var regions = [];
|
|
275
|
+
var pad = 60;
|
|
276
|
+
var cursorY = pad;
|
|
277
|
+
var maxRight = pad;
|
|
278
|
+
|
|
279
|
+
components(allNames, adj).forEach(function (group) {
|
|
280
|
+
var inGroup = {};
|
|
281
|
+
group.forEach(function (n) { inGroup[n] = true; });
|
|
282
|
+
var groupEdges = dir.filter(function (e) { return inGroup[e.from] && inGroup[e.to]; });
|
|
283
|
+
|
|
284
|
+
var acyclic = breakCycles(group, groupEdges);
|
|
285
|
+
var layerOf = assignLayers(group, acyclic);
|
|
286
|
+
|
|
287
|
+
var layers = [];
|
|
288
|
+
group.forEach(function (n) {
|
|
289
|
+
var l = layerOf[n] || 0;
|
|
290
|
+
(layers[l] = layers[l] || []).push(n);
|
|
291
|
+
});
|
|
292
|
+
for (var i = 0; i < layers.length; i++) if (!layers[i]) layers[i] = [];
|
|
293
|
+
layers.forEach(function (names) { names.sort(); }); // deterministic start
|
|
294
|
+
|
|
295
|
+
var adjIn = {}, adjOut = {};
|
|
296
|
+
group.forEach(function (n) { adjIn[n] = []; adjOut[n] = []; });
|
|
297
|
+
acyclic.forEach(function (e) { adjOut[e.from].push(e.to); adjIn[e.to].push(e.from); });
|
|
298
|
+
|
|
299
|
+
orderLayers(layers, adjIn, adjOut);
|
|
300
|
+
var laid = assignCoords(layers, byName, adjIn, adjOut);
|
|
301
|
+
|
|
302
|
+
Object.keys(laid.positions).forEach(function (n) {
|
|
303
|
+
var p = laid.positions[n];
|
|
304
|
+
positions[n] = { x: pad + p.x, y: cursorY + p.y, model: p.model };
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
regions.push({
|
|
308
|
+
x: pad - BLOCK_PAD, y: cursorY - BLOCK_PAD,
|
|
309
|
+
w: laid.width + BLOCK_PAD * 2, h: laid.height + BLOCK_PAD * 2,
|
|
310
|
+
label: group.length > 1 ? group.length + ' related models' : group[0]
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
maxRight = Math.max(maxRight, pad + laid.width);
|
|
314
|
+
cursorY += laid.height + BLOCK_GAP;
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
positions: positions,
|
|
319
|
+
regions: regions,
|
|
320
|
+
width: maxRight + pad,
|
|
321
|
+
height: cursorY - BLOCK_GAP + pad
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function anchors(a, b) {
|
|
326
|
+
var ah = nodeHeight(a.model), bh = nodeHeight(b.model);
|
|
327
|
+
var acx = a.x + NODE_W / 2, acy = a.y + ah / 2;
|
|
328
|
+
var bcx = b.x + NODE_W / 2, bcy = b.y + bh / 2;
|
|
329
|
+
var horizontal = Math.abs(bcx - acx) > Math.abs(bcy - acy);
|
|
330
|
+
|
|
331
|
+
if (horizontal) {
|
|
332
|
+
return bcx > acx
|
|
333
|
+
? { x1: a.x + NODE_W, y1: acy, x2: b.x, y2: bcy, h: true }
|
|
334
|
+
: { x1: a.x, y1: acy, x2: b.x + NODE_W, y2: bcy, h: true };
|
|
335
|
+
}
|
|
336
|
+
return bcy > acy
|
|
337
|
+
? { x1: acx, y1: a.y + ah, x2: bcx, y2: b.y, h: false }
|
|
338
|
+
: { x1: acx, y1: a.y, x2: bcx, y2: b.y + bh, h: false };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function edgePath(a, b) {
|
|
342
|
+
var p = anchors(a, b);
|
|
343
|
+
var dx = Math.abs(p.x2 - p.x1), dy = Math.abs(p.y2 - p.y1);
|
|
344
|
+
if (p.h) {
|
|
345
|
+
var cx = Math.max(40, dx / 2);
|
|
346
|
+
return 'M' + p.x1 + ',' + p.y1 +
|
|
347
|
+
' C' + (p.x1 + (p.x2 > p.x1 ? cx : -cx)) + ',' + p.y1 +
|
|
348
|
+
' ' + (p.x2 + (p.x2 > p.x1 ? -cx : cx)) + ',' + p.y2 +
|
|
349
|
+
' ' + p.x2 + ',' + p.y2;
|
|
350
|
+
}
|
|
351
|
+
var cy = Math.max(40, dy / 2);
|
|
352
|
+
return 'M' + p.x1 + ',' + p.y1 +
|
|
353
|
+
' C' + p.x1 + ',' + (p.y1 + (p.y2 > p.y1 ? cy : -cy)) +
|
|
354
|
+
' ' + p.x2 + ',' + (p.y2 + (p.y2 > p.y1 ? -cy : cy)) +
|
|
355
|
+
' ' + p.x2 + ',' + p.y2;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
var MACRO_CLASS = {
|
|
359
|
+
belongs_to: 'mg-edge-belongs',
|
|
360
|
+
has_one: 'mg-edge-has-one',
|
|
361
|
+
has_many: 'mg-edge-has-many',
|
|
362
|
+
has_and_belongs_to_many: 'mg-edge-habtm'
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
// What the association means in cardinality terms, since the arrow alone
|
|
366
|
+
// only shows direction.
|
|
367
|
+
var MACRO_CARDINALITY = {
|
|
368
|
+
belongs_to: 'many → one',
|
|
369
|
+
has_one: 'one → one',
|
|
370
|
+
has_many: 'one → many',
|
|
371
|
+
has_and_belongs_to_many: 'many ↔ many'
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
// Low enough that a few hundred models genuinely fit on screen. At the old
|
|
375
|
+
// 0.2 floor a large app could not be framed at all, so fit-to-pane produced a
|
|
376
|
+
// view the zoom controls then refused to honour.
|
|
377
|
+
var MIN_ZOOM = 0.04;
|
|
378
|
+
var MAX_ZOOM = 2.5;
|
|
379
|
+
|
|
380
|
+
return function ModelGraphComponent(_ref) {
|
|
381
|
+
var graph = _ref.graph;
|
|
382
|
+
var onOpenModel = _ref.onOpenModel;
|
|
383
|
+
var onRefresh = _ref.onRefresh;
|
|
384
|
+
var loading = _ref.loading;
|
|
385
|
+
|
|
386
|
+
// The view is a ref, not state, and is written straight onto the <g>'s
|
|
387
|
+
// transform. Every pan and zoom tick used to be a setState, which re-ran the
|
|
388
|
+
// whole layout (it was computed in the render body) and rebuilt every box,
|
|
389
|
+
// field and edge — around 10,000 SVG elements on a 300-model app. That is
|
|
390
|
+
// what made the graph unusable at real scale rather than anything about the
|
|
391
|
+
// drawing itself.
|
|
392
|
+
var viewRef = React.useRef({ k: 1, x: 0, y: 0 });
|
|
393
|
+
var sceneRef = React.useRef(null);
|
|
394
|
+
// Which layout we have already framed, so reopening the tab refits but a
|
|
395
|
+
// re-render for any other reason does not yank the view back.
|
|
396
|
+
var fittedForRef = React.useRef(null);
|
|
397
|
+
|
|
398
|
+
var applyView = React.useCallback(function () {
|
|
399
|
+
var g = sceneRef.current;
|
|
400
|
+
if (!g) return;
|
|
401
|
+
var v = viewRef.current;
|
|
402
|
+
g.setAttribute('transform', 'translate(' + v.x + ',' + v.y + ') scale(' + v.k + ')');
|
|
403
|
+
}, []);
|
|
404
|
+
|
|
405
|
+
var setView = React.useCallback(function (next) {
|
|
406
|
+
viewRef.current = typeof next === 'function' ? next(viewRef.current) : next;
|
|
407
|
+
applyView();
|
|
408
|
+
}, [applyView]);
|
|
409
|
+
|
|
410
|
+
var _search = React.useState('');
|
|
411
|
+
var search = _search[0], setSearch = _search[1];
|
|
412
|
+
var _focused = React.useState(null);
|
|
413
|
+
var focused = _focused[0], setFocused = _focused[1];
|
|
414
|
+
var _hovered = React.useState(null);
|
|
415
|
+
var hovered = _hovered[0], setHovered = _hovered[1];
|
|
416
|
+
var _pointer = React.useState({ x: 0, y: 0 });
|
|
417
|
+
var pointer = _pointer[0], setPointer = _pointer[1];
|
|
418
|
+
var dragRef = React.useRef(null);
|
|
419
|
+
var svgRef = React.useRef(null);
|
|
420
|
+
|
|
421
|
+
// Memoised on the graph payload. This used to run on every render — which,
|
|
422
|
+
// with the view in state, meant on every wheel tick and every mousemove of a
|
|
423
|
+
// drag.
|
|
424
|
+
var placed = React.useMemo(function () {
|
|
425
|
+
return (graph && graph.ok && (graph.models || []).length)
|
|
426
|
+
? layout(graph.models, graph.edges || [])
|
|
427
|
+
: null;
|
|
428
|
+
}, [graph]);
|
|
429
|
+
|
|
430
|
+
// Every association each model takes part in, both directions, built once.
|
|
431
|
+
// Scanning the edge list per hover would be 513 comparisons on every
|
|
432
|
+
// mouseenter across a canvas of 300 boxes.
|
|
433
|
+
var associationsBy = React.useMemo(function () {
|
|
434
|
+
var byModel = {};
|
|
435
|
+
(graph && graph.edges || []).forEach(function (e) {
|
|
436
|
+
(byModel[e.from] = byModel[e.from] || []).push({ edge: e, outgoing: true });
|
|
437
|
+
if (e.to !== e.from) (byModel[e.to] = byModel[e.to] || []).push({ edge: e, outgoing: false });
|
|
438
|
+
});
|
|
439
|
+
return byModel;
|
|
440
|
+
}, [graph]);
|
|
441
|
+
|
|
442
|
+
var hoverCardRef = React.useRef(null);
|
|
443
|
+
var _modelHover = React.useState(null);
|
|
444
|
+
var modelHover = _modelHover[0], setModelHover = _modelHover[1];
|
|
445
|
+
|
|
446
|
+
// Highlighting is done by touching the DOM directly. Routing it through
|
|
447
|
+
// React would rebuild all ~7,000 elements of the scene on every mouseenter,
|
|
448
|
+
// which is the same trap the pan/zoom transform was in.
|
|
449
|
+
var litRef = React.useRef([]);
|
|
450
|
+
var clearLit = React.useCallback(function () {
|
|
451
|
+
litRef.current.forEach(function (el) { el.classList.remove('mg-lit'); });
|
|
452
|
+
litRef.current = [];
|
|
453
|
+
if (sceneRef.current) sceneRef.current.classList.remove('mg-focus-mode');
|
|
454
|
+
}, []);
|
|
455
|
+
|
|
456
|
+
var moveHoverCard = React.useCallback(function (ev) {
|
|
457
|
+
var card = hoverCardRef.current;
|
|
458
|
+
var el = svgRef.current;
|
|
459
|
+
if (!card || !el) return;
|
|
460
|
+
var rect = el.getBoundingClientRect();
|
|
461
|
+
var x = ev.clientX - rect.left;
|
|
462
|
+
var y = ev.clientY - rect.top;
|
|
463
|
+
// Flip before the card would run off the pane rather than after.
|
|
464
|
+
card.style.left = (x > rect.width - 300 ? x - 290 : x + 18) + 'px';
|
|
465
|
+
card.style.top = Math.min(y + 18, Math.max(0, rect.height - 240)) + 'px';
|
|
466
|
+
}, []);
|
|
467
|
+
|
|
468
|
+
var enterModel = React.useCallback(function (name, ev) {
|
|
469
|
+
if (dragRef.current) return; // panning, not inspecting
|
|
470
|
+
clearLit();
|
|
471
|
+
var scene = sceneRef.current;
|
|
472
|
+
if (scene) {
|
|
473
|
+
scene.classList.add('mg-focus-mode');
|
|
474
|
+
var sel = '[data-from="' + name + '"],[data-to="' + name + '"]';
|
|
475
|
+
litRef.current = Array.prototype.slice.call(scene.querySelectorAll(sel));
|
|
476
|
+
litRef.current.forEach(function (el) { el.classList.add('mg-lit'); });
|
|
477
|
+
}
|
|
478
|
+
setModelHover(name);
|
|
479
|
+
moveHoverCard(ev);
|
|
480
|
+
}, [clearLit, moveHoverCard]);
|
|
481
|
+
|
|
482
|
+
var leaveModel = React.useCallback(function () {
|
|
483
|
+
clearLit();
|
|
484
|
+
setModelHover(null);
|
|
485
|
+
}, [clearLit]);
|
|
486
|
+
|
|
487
|
+
// Frame the whole graph on load. Without this the view starts at the
|
|
488
|
+
// top-left of a canvas much larger than the pane and the diagram looks
|
|
489
|
+
// empty until you go looking for it.
|
|
490
|
+
var fitToPane = React.useCallback(function (attempt) {
|
|
491
|
+
var el = svgRef.current;
|
|
492
|
+
if (!el || !placed) return;
|
|
493
|
+
var rect = el.getBoundingClientRect();
|
|
494
|
+
// The pane can still be unsized on the commit that mounts the SVG — the
|
|
495
|
+
// graph tab is opening at the same time. Bailing out here left the view at
|
|
496
|
+
// its 1:1 default and nothing ever retried, so a 300-model graph opened
|
|
497
|
+
// showing one corner of itself. Retry on the next frames until it has a
|
|
498
|
+
// size, then give up rather than spin.
|
|
499
|
+
if (!rect.width || !rect.height) {
|
|
500
|
+
if ((attempt || 0) < 10) {
|
|
501
|
+
window.requestAnimationFrame(function () { fitRef.current((attempt || 0) + 1); });
|
|
502
|
+
}
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Clamped to the same range the wheel enforces. Without the MIN_ZOOM
|
|
507
|
+
// clamp a large graph fitted to something like k=0.04, which is below the
|
|
508
|
+
// floor — so the first scroll snapped it up to MIN_ZOOM, a five-fold jump
|
|
509
|
+
// anchored on the cursor, and the diagram appeared to leap somewhere
|
|
510
|
+
// random. The view must never sit outside the range the controls allow.
|
|
511
|
+
var raw = Math.min(rect.width / placed.width, rect.height / placed.height);
|
|
512
|
+
var k = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Math.min(1, raw)));
|
|
513
|
+
setView({
|
|
514
|
+
k: k,
|
|
515
|
+
x: (rect.width - placed.width * k) / 2,
|
|
516
|
+
y: (rect.height - placed.height * k) / 2
|
|
517
|
+
});
|
|
518
|
+
}, [placed, setView]);
|
|
519
|
+
|
|
520
|
+
React.useEffect(function () { fitToPane(); }, [fitToPane]);
|
|
521
|
+
|
|
522
|
+
// Bring one model to the middle of the pane at the current zoom, and mark
|
|
523
|
+
// it so it's findable in a dense graph once it gets there.
|
|
524
|
+
var centreOn = React.useCallback(function (name) {
|
|
525
|
+
var el = svgRef.current;
|
|
526
|
+
var pos = placed && placed.positions[name];
|
|
527
|
+
if (!el || !pos) return;
|
|
528
|
+
var rect = el.getBoundingClientRect();
|
|
529
|
+
var h = nodeHeight(pos.model);
|
|
530
|
+
setFocused(name);
|
|
531
|
+
setView(function (v) {
|
|
532
|
+
// Close half the distance to actual size, so a model found while
|
|
533
|
+
// fitted (often ~0.3) lands readable. Never zooms out: if you were
|
|
534
|
+
// already past 1:1 you meant to be there.
|
|
535
|
+
var k = v.k >= 1 ? v.k : Math.min(MAX_ZOOM, v.k + (1 - v.k) / 2);
|
|
536
|
+
return {
|
|
537
|
+
k: k,
|
|
538
|
+
x: rect.width / 2 - (pos.x + NODE_W / 2) * k,
|
|
539
|
+
y: rect.height / 2 - (pos.y + h / 2) * k
|
|
540
|
+
};
|
|
541
|
+
});
|
|
542
|
+
}, [placed]);
|
|
543
|
+
|
|
544
|
+
// Same mounting problem as the wheel listener: the fit effect can run
|
|
545
|
+
// before the SVG exists and not again afterwards. Kept current so the
|
|
546
|
+
// callback ref can fit as soon as the element is really there.
|
|
547
|
+
// Held in a ref so the memoised node elements can call it without listing it
|
|
548
|
+
// as a dependency — otherwise every centre would rebuild the whole scene.
|
|
549
|
+
var centreRef = React.useRef(centreOn);
|
|
550
|
+
centreRef.current = centreOn;
|
|
551
|
+
|
|
552
|
+
var fitRef = React.useRef(fitToPane);
|
|
553
|
+
fitRef.current = fitToPane;
|
|
554
|
+
|
|
555
|
+
// A callback ref rather than useRef + useEffect. The SVG mounts on a
|
|
556
|
+
// render where `graph` has not changed — loading flips false separately
|
|
557
|
+
// from the data arriving — so an effect keyed on the data never re-runs
|
|
558
|
+
// once the element finally exists, and the listener is never attached.
|
|
559
|
+
// A callback ref fires exactly when the node appears.
|
|
560
|
+
//
|
|
561
|
+
// The listener is manual and non-passive: React's onWheel is passive, so
|
|
562
|
+
// preventDefault is ignored there and the editor scrolls instead of the
|
|
563
|
+
// diagram zooming.
|
|
564
|
+
var wheelCleanup = React.useRef(null);
|
|
565
|
+
var attachSvg = React.useCallback(function (el) {
|
|
566
|
+
if (wheelCleanup.current) { wheelCleanup.current(); wheelCleanup.current = null; }
|
|
567
|
+
svgRef.current = el;
|
|
568
|
+
if (!el) return;
|
|
569
|
+
|
|
570
|
+
// getBoundingClientRect forces a synchronous layout, and we had just
|
|
571
|
+
// written a new transform onto a <g> holding thousands of elements — so
|
|
572
|
+
// reading it per wheel tick made the browser re-lay-out the entire scene
|
|
573
|
+
// before the handler could continue. Measured at 27 ms a tick on a
|
|
574
|
+
// 300-model graph. The pane's own rect only changes when the window or
|
|
575
|
+
// the panel layout does, so cache it and refresh once per frame.
|
|
576
|
+
var rectRef = { current: null };
|
|
577
|
+
var invalidateRect = function () { rectRef.current = null; };
|
|
578
|
+
var paneRect = function () {
|
|
579
|
+
if (!rectRef.current) {
|
|
580
|
+
rectRef.current = el.getBoundingClientRect();
|
|
581
|
+
window.requestAnimationFrame(invalidateRect);
|
|
582
|
+
}
|
|
583
|
+
return rectRef.current;
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
var onWheel = function (e) {
|
|
587
|
+
e.preventDefault();
|
|
588
|
+
var rect = paneRect();
|
|
589
|
+
var px = e.clientX - rect.left;
|
|
590
|
+
var py = e.clientY - rect.top;
|
|
591
|
+
setView(function (v) {
|
|
592
|
+
var k = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, v.k * (e.deltaY < 0 ? 1.12 : 1 / 1.12)));
|
|
593
|
+
// Keep the point under the cursor pinned while scaling.
|
|
594
|
+
return { k: k, x: px - (px - v.x) * (k / v.k), y: py - (py - v.y) * (k / v.k) };
|
|
595
|
+
});
|
|
596
|
+
};
|
|
597
|
+
el.addEventListener('wheel', onWheel, { passive: false });
|
|
598
|
+
wheelCleanup.current = function () { el.removeEventListener('wheel', onWheel); };
|
|
599
|
+
|
|
600
|
+
// After layout, so the pane has a measurable size to fit into.
|
|
601
|
+
window.requestAnimationFrame(function () { fitRef.current(); });
|
|
602
|
+
}, []);
|
|
603
|
+
|
|
604
|
+
// Memoised so hovering a model does not rebuild every box and edge.
|
|
605
|
+
// The highlight itself is applied by toggling DOM classes; only the
|
|
606
|
+
// association card is React state, and it lives outside this subtree.
|
|
607
|
+
var sceneChildren = React.useMemo(function () {
|
|
608
|
+
if (!placed) return null;
|
|
609
|
+
var models = (graph && graph.models) || [];
|
|
610
|
+
var edges = (graph && graph.edges) || [];
|
|
611
|
+
return [
|
|
612
|
+
// Behind everything: one soft area per connected cluster, so the
|
|
613
|
+
// grouping is something you can see rather than infer.
|
|
614
|
+
(placed.regions || []).map(function (r, i) {
|
|
615
|
+
return React.createElement(
|
|
616
|
+
'g',
|
|
617
|
+
{ key: 'r' + i, className: 'mg-region' },
|
|
618
|
+
React.createElement('rect', {
|
|
619
|
+
className: 'mg-region-box',
|
|
620
|
+
x: r.x, y: r.y, width: r.w, height: r.h, rx: 10
|
|
621
|
+
}),
|
|
622
|
+
React.createElement('text', {
|
|
623
|
+
className: 'mg-region-label', x: r.x + 12, y: r.y + 17
|
|
624
|
+
}, r.label)
|
|
625
|
+
);
|
|
626
|
+
}),
|
|
627
|
+
edges.map(function (e, i) {
|
|
628
|
+
var a = placed.positions[e.from];
|
|
629
|
+
var b = placed.positions[e.to];
|
|
630
|
+
if (!a || !b || e.from === e.to) return null;
|
|
631
|
+
var d = edgePath(a, b);
|
|
632
|
+
var isHovered = hovered && hovered.index === i;
|
|
633
|
+
return React.createElement(
|
|
634
|
+
'g',
|
|
635
|
+
// Tagged so a node hover can find its own edges with one DOM query
|
|
636
|
+
// instead of a React pass over every edge.
|
|
637
|
+
{ key: 'e' + i, 'data-from': e.from, 'data-to': e.to },
|
|
638
|
+
// A transparent, much thicker copy of the line under the real
|
|
639
|
+
// one. A 1.2px stroke is its own hit area, which makes hovering
|
|
640
|
+
// an association essentially impossible without this.
|
|
641
|
+
React.createElement('path', {
|
|
642
|
+
d: d,
|
|
643
|
+
className: 'mg-edge-hit',
|
|
644
|
+
onMouseEnter: function () { setHovered({ index: i, edge: e }); },
|
|
645
|
+
onMouseMove: function (ev) {
|
|
646
|
+
var rect = svgRef.current.getBoundingClientRect();
|
|
647
|
+
setPointer({ x: ev.clientX - rect.left, y: ev.clientY - rect.top });
|
|
648
|
+
},
|
|
649
|
+
onMouseLeave: function () { setHovered(null); }
|
|
650
|
+
}),
|
|
651
|
+
React.createElement('path', {
|
|
652
|
+
d: d,
|
|
653
|
+
className: 'mg-edge ' + (MACRO_CLASS[e.macro] || '') + (isHovered ? ' mg-edge-hovered' : ''),
|
|
654
|
+
markerEnd: 'url(#mg-arrow)'
|
|
655
|
+
})
|
|
656
|
+
);
|
|
657
|
+
}),
|
|
658
|
+
models.map(function (m) {
|
|
659
|
+
var pos = placed.positions[m.name];
|
|
660
|
+
if (!pos) return null;
|
|
661
|
+
var fields = (m.columns || []).slice(0, MAX_FIELDS);
|
|
662
|
+
var hidden = (m.columnCount || 0) - fields.length;
|
|
663
|
+
var h = nodeHeight(m);
|
|
664
|
+
return React.createElement(
|
|
665
|
+
'g',
|
|
666
|
+
{
|
|
667
|
+
key: m.name,
|
|
668
|
+
className: 'mg-node' + (focused === m.name ? ' mg-focused' : ''),
|
|
669
|
+
transform: 'translate(' + pos.x + ',' + pos.y + ')',
|
|
670
|
+
onMouseEnter: function (ev) { enterModel(m.name, ev); },
|
|
671
|
+
onMouseMove: function (ev) { moveHoverCard(ev); },
|
|
672
|
+
onMouseLeave: function () { leaveModel(); },
|
|
673
|
+
onClick: function () {
|
|
674
|
+
// A drag that ends over a box must not also act on it.
|
|
675
|
+
if (dragRef.current && dragRef.current.moved) return;
|
|
676
|
+
// Clicking the box navigates: zoom to it. Opening the schema
|
|
677
|
+
// is the explicit button in the header, so a stray click while
|
|
678
|
+
// exploring no longer throws a modal in your way.
|
|
679
|
+
centreRef.current(m.name);
|
|
680
|
+
}
|
|
681
|
+
},
|
|
682
|
+
React.createElement('rect', { className: 'mg-box', width: NODE_W, height: h, rx: 4 }),
|
|
683
|
+
React.createElement('rect', { className: 'mg-box-header', width: NODE_W, height: HEADER_H, rx: 4 }),
|
|
684
|
+
React.createElement('text', { className: 'mg-name', x: 10, y: 15 }, m.name),
|
|
685
|
+
React.createElement('text', { className: 'mg-table', x: 10, y: 27 },
|
|
686
|
+
(m.table || '—') + (m.columnCount ? ' · ' + m.columnCount + ' cols' : '')),
|
|
687
|
+
// Opening the full schema is now an explicit target rather than
|
|
688
|
+
// anything-anywhere on the box, so clicking around to navigate
|
|
689
|
+
// cannot keep throwing a modal at you.
|
|
690
|
+
React.createElement(
|
|
691
|
+
'g',
|
|
692
|
+
{
|
|
693
|
+
className: 'mg-open-btn',
|
|
694
|
+
onClick: function (ev) {
|
|
695
|
+
ev.stopPropagation();
|
|
696
|
+
if (dragRef.current && dragRef.current.moved) return;
|
|
697
|
+
if (onOpenModel) onOpenModel(m);
|
|
698
|
+
}
|
|
699
|
+
},
|
|
700
|
+
React.createElement('title', null, 'Open the full schema for ' + m.name),
|
|
701
|
+
React.createElement('rect', {
|
|
702
|
+
className: 'mg-open-btn-bg', x: NODE_W - 26, y: 7, width: 19, height: 19, rx: 3
|
|
703
|
+
}),
|
|
704
|
+
// Three stacked bars — a table, matching what the button opens.
|
|
705
|
+
React.createElement('rect', { className: 'mg-open-btn-bar', x: NODE_W - 22, y: 11, width: 11, height: 2 }),
|
|
706
|
+
React.createElement('rect', { className: 'mg-open-btn-bar', x: NODE_W - 22, y: 15.5, width: 11, height: 2 }),
|
|
707
|
+
React.createElement('rect', { className: 'mg-open-btn-bar', x: NODE_W - 22, y: 20, width: 11, height: 2 })
|
|
708
|
+
),
|
|
709
|
+
fields.map(function (c, i) {
|
|
710
|
+
var y = HEADER_H + 11 + i * FIELD_H;
|
|
711
|
+
return React.createElement(
|
|
712
|
+
React.Fragment,
|
|
713
|
+
{ key: c.name },
|
|
714
|
+
React.createElement('text', { className: 'mg-field', x: 10, y: y }, c.name),
|
|
715
|
+
React.createElement('text', { className: 'mg-field-type', x: NODE_W - 10, y: y, textAnchor: 'end' }, c.type)
|
|
716
|
+
);
|
|
717
|
+
}),
|
|
718
|
+
hidden > 0 && React.createElement('text', {
|
|
719
|
+
className: 'mg-field-more', x: 10, y: HEADER_H + 11 + fields.length * FIELD_H
|
|
720
|
+
}, '+' + hidden + ' more…'),
|
|
721
|
+
// No connection means no columns to show; say so rather than
|
|
722
|
+
// rendering an empty box that looks like a model with no fields.
|
|
723
|
+
fields.length === 0 && React.createElement('text', {
|
|
724
|
+
className: 'mg-field-more', x: 10, y: HEADER_H + 11
|
|
725
|
+
}, 'no database connection')
|
|
726
|
+
);
|
|
727
|
+
})
|
|
728
|
+
];
|
|
729
|
+
}, [placed, graph, focused, onOpenModel]);
|
|
730
|
+
|
|
731
|
+
if (loading) {
|
|
732
|
+
return React.createElement('div', { className: 'ide-model-graph-empty' }, 'Building the model graph…');
|
|
733
|
+
}
|
|
734
|
+
if (!graph) {
|
|
735
|
+
return React.createElement('div', { className: 'ide-model-graph-empty' }, 'Loading…');
|
|
736
|
+
}
|
|
737
|
+
if (!graph.ok) {
|
|
738
|
+
return React.createElement(
|
|
739
|
+
'div',
|
|
740
|
+
{ className: 'ide-model-graph-empty' },
|
|
741
|
+
React.createElement('div', null, graph.error || 'No model graph available.'),
|
|
742
|
+
React.createElement('button', {
|
|
743
|
+
type: 'button', className: 'ide-model-graph-btn', onClick: onRefresh
|
|
744
|
+
}, 'Try again')
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
var models = graph.models || [];
|
|
749
|
+
var edges = graph.edges || [];
|
|
750
|
+
if (models.length === 0 || !placed) {
|
|
751
|
+
return React.createElement('div', { className: 'ide-model-graph-empty' }, 'No ActiveRecord models found.');
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
var onMouseDown = function (e) {
|
|
755
|
+
if (e.button !== 0) return;
|
|
756
|
+
var v = viewRef.current;
|
|
757
|
+
dragRef.current = { sx: e.clientX, sy: e.clientY, ox: v.x, oy: v.y, moved: false };
|
|
758
|
+
if (svgRef.current) svgRef.current.classList.add('mg-dragging');
|
|
759
|
+
};
|
|
760
|
+
var onMouseMove = function (e) {
|
|
761
|
+
var d = dragRef.current;
|
|
762
|
+
if (!d) return;
|
|
763
|
+
var dx = e.clientX - d.sx, dy = e.clientY - d.sy;
|
|
764
|
+
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) d.moved = true;
|
|
765
|
+
setView(function (v) { return { k: v.k, x: d.ox + dx, y: d.oy + dy }; });
|
|
766
|
+
};
|
|
767
|
+
var endDrag = function () {
|
|
768
|
+
dragRef.current = null;
|
|
769
|
+
if (svgRef.current) svgRef.current.classList.remove('mg-dragging');
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
return React.createElement(
|
|
773
|
+
'div',
|
|
774
|
+
{ className: 'ide-model-graph' },
|
|
775
|
+
React.createElement(
|
|
776
|
+
'div',
|
|
777
|
+
{ className: 'ide-model-graph-toolbar' },
|
|
778
|
+
React.createElement('span', null, models.length + ' models, ' + edges.length + ' associations'),
|
|
779
|
+
graph.truncated && React.createElement('span', { className: 'ide-model-graph-warn' }, ' (truncated)'),
|
|
780
|
+
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'),
|
|
781
|
+
React.createElement(
|
|
782
|
+
'div',
|
|
783
|
+
{ className: 'ide-model-graph-actions' },
|
|
784
|
+
// A native datalist rather than a bespoke dropdown: the browser gives
|
|
785
|
+
// us the filtering and keyboard handling for free.
|
|
786
|
+
React.createElement('input', {
|
|
787
|
+
className: 'ide-model-graph-search',
|
|
788
|
+
type: 'search',
|
|
789
|
+
list: 'mg-model-names',
|
|
790
|
+
placeholder: 'Centre on a model…',
|
|
791
|
+
value: search,
|
|
792
|
+
onChange: function (e) {
|
|
793
|
+
setSearch(e.target.value);
|
|
794
|
+
// Picking from the datalist fires change with the full name, so
|
|
795
|
+
// an exact hit centres immediately rather than needing Enter.
|
|
796
|
+
if (placed.positions[e.target.value]) centreOn(e.target.value);
|
|
797
|
+
},
|
|
798
|
+
onKeyDown: function (e) {
|
|
799
|
+
if (e.key !== 'Enter') return;
|
|
800
|
+
var match = models.filter(function (m) {
|
|
801
|
+
return m.name.toLowerCase().indexOf(e.target.value.trim().toLowerCase()) === 0;
|
|
802
|
+
})[0];
|
|
803
|
+
if (match) { setSearch(match.name); centreOn(match.name); }
|
|
804
|
+
}
|
|
805
|
+
}),
|
|
806
|
+
React.createElement(
|
|
807
|
+
'datalist',
|
|
808
|
+
{ id: 'mg-model-names' },
|
|
809
|
+
models.map(function (m) {
|
|
810
|
+
return React.createElement('option', { key: m.name, value: m.name });
|
|
811
|
+
})
|
|
812
|
+
),
|
|
813
|
+
React.createElement('button', {
|
|
814
|
+
type: 'button', className: 'ide-model-graph-btn', title: 'Fit the whole graph',
|
|
815
|
+
onClick: fitToPane
|
|
816
|
+
}, React.createElement('i', { className: 'fas fa-compress-arrows-alt' })),
|
|
817
|
+
React.createElement('button', {
|
|
818
|
+
type: 'button', className: 'ide-model-graph-btn',
|
|
819
|
+
title: 'Rebuild from the current code', onClick: onRefresh
|
|
820
|
+
}, React.createElement('i', { className: 'fas fa-sync' }))
|
|
821
|
+
)
|
|
822
|
+
),
|
|
823
|
+
// Every association a hovered model takes part in. The graph shows that a
|
|
824
|
+
// line exists; this says what it actually is, which is the thing you need
|
|
825
|
+
// when a model has a dozen of them fanning out.
|
|
826
|
+
React.createElement(
|
|
827
|
+
'div',
|
|
828
|
+
{
|
|
829
|
+
ref: hoverCardRef,
|
|
830
|
+
className: 'mg-assoc-card' + (modelHover ? '' : ' mg-assoc-card-hidden')
|
|
831
|
+
},
|
|
832
|
+
modelHover && React.createElement('div', { className: 'mg-assoc-title' }, modelHover),
|
|
833
|
+
modelHover && (function () {
|
|
834
|
+
var list = associationsBy[modelHover] || [];
|
|
835
|
+
if (!list.length) {
|
|
836
|
+
return React.createElement('div', { className: 'mg-assoc-empty' }, 'No associations');
|
|
837
|
+
}
|
|
838
|
+
return React.createElement(
|
|
839
|
+
'div',
|
|
840
|
+
{ className: 'mg-assoc-list' },
|
|
841
|
+
React.createElement('div', { className: 'mg-assoc-count' },
|
|
842
|
+
list.length + (list.length === 1 ? ' association' : ' associations')),
|
|
843
|
+
list.slice(0, MAX_ASSOC_LISTED).map(function (a, i) {
|
|
844
|
+
var e = a.edge;
|
|
845
|
+
var other = a.outgoing ? e.to : e.from;
|
|
846
|
+
return React.createElement(
|
|
847
|
+
'div',
|
|
848
|
+
{ key: i, className: 'mg-assoc-row' },
|
|
849
|
+
React.createElement('span', {
|
|
850
|
+
className: 'mg-assoc-dot ' + (MACRO_CLASS[e.macro] || '')
|
|
851
|
+
}),
|
|
852
|
+
React.createElement('span', { className: 'mg-assoc-macro' }, e.macro),
|
|
853
|
+
React.createElement('span', { className: 'mg-assoc-name' }, ':' + e.name),
|
|
854
|
+
React.createElement('span', { className: 'mg-assoc-dir' },
|
|
855
|
+
(a.outgoing ? '→ ' : '← ') + other),
|
|
856
|
+
e.through && React.createElement('span', { className: 'mg-assoc-through' },
|
|
857
|
+
'through :' + e.through)
|
|
858
|
+
);
|
|
859
|
+
}),
|
|
860
|
+
list.length > MAX_ASSOC_LISTED && React.createElement('div', { className: 'mg-assoc-more' },
|
|
861
|
+
'+' + (list.length - MAX_ASSOC_LISTED) + ' more')
|
|
862
|
+
);
|
|
863
|
+
})()
|
|
864
|
+
),
|
|
865
|
+
hovered && React.createElement(
|
|
866
|
+
'div',
|
|
867
|
+
{
|
|
868
|
+
className: 'mg-tooltip',
|
|
869
|
+
// Offset from the cursor, and flipped left near the right edge so
|
|
870
|
+
// the tooltip never runs off the pane.
|
|
871
|
+
style: {
|
|
872
|
+
left: pointer.x + (svgRef.current && pointer.x > svgRef.current.clientWidth - 260 ? -240 : 14) + 'px',
|
|
873
|
+
top: (pointer.y + 14) + 'px'
|
|
874
|
+
}
|
|
875
|
+
},
|
|
876
|
+
React.createElement('div', { className: 'mg-tooltip-title' },
|
|
877
|
+
hovered.edge.from + ' → ' + hovered.edge.to),
|
|
878
|
+
React.createElement('div', { className: 'mg-tooltip-macro' },
|
|
879
|
+
hovered.edge.macro + ' :' + hovered.edge.name),
|
|
880
|
+
React.createElement('div', { className: 'mg-tooltip-meta' },
|
|
881
|
+
MACRO_CARDINALITY[hovered.edge.macro] || hovered.edge.macro),
|
|
882
|
+
hovered.edge.through && React.createElement('div', { className: 'mg-tooltip-meta' },
|
|
883
|
+
'through :' + hovered.edge.through)
|
|
884
|
+
),
|
|
885
|
+
React.createElement(
|
|
886
|
+
'svg',
|
|
887
|
+
{
|
|
888
|
+
ref: attachSvg,
|
|
889
|
+
className: 'ide-model-graph-svg' + (dragRef.current ? ' mg-dragging' : ''),
|
|
890
|
+
onMouseDown: onMouseDown,
|
|
891
|
+
onMouseMove: onMouseMove,
|
|
892
|
+
onMouseUp: endDrag,
|
|
893
|
+
onMouseLeave: endDrag
|
|
894
|
+
},
|
|
895
|
+
React.createElement(
|
|
896
|
+
'defs',
|
|
897
|
+
null,
|
|
898
|
+
React.createElement(
|
|
899
|
+
'marker',
|
|
900
|
+
{
|
|
901
|
+
id: 'mg-arrow', viewBox: '0 0 10 10', refX: '9', refY: '5',
|
|
902
|
+
markerWidth: '5', markerHeight: '5', orient: 'auto-start-reverse'
|
|
903
|
+
},
|
|
904
|
+
React.createElement('path', { d: 'M 0 0 L 10 5 L 0 10 z', className: 'mg-arrow-head' })
|
|
905
|
+
)
|
|
906
|
+
),
|
|
907
|
+
React.createElement(
|
|
908
|
+
'g',
|
|
909
|
+
{
|
|
910
|
+
// No transform prop: applyView writes it directly, so panning and
|
|
911
|
+
// zooming never touch React.
|
|
912
|
+
ref: function (el) {
|
|
913
|
+
sceneRef.current = el;
|
|
914
|
+
if (!el) return;
|
|
915
|
+
applyView();
|
|
916
|
+
// Fit here rather than only from an effect. The effect fires
|
|
917
|
+
// before the tab has been given its size, so it measured a
|
|
918
|
+
// zero-width pane and bailed, leaving a 300-model graph opening at
|
|
919
|
+
// 1:1 showing one corner. This runs once per layout, on the frame
|
|
920
|
+
// after the scene actually exists.
|
|
921
|
+
if (fittedForRef.current !== placed) {
|
|
922
|
+
fittedForRef.current = placed;
|
|
923
|
+
window.requestAnimationFrame(function () { fitRef.current(); });
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
},
|
|
927
|
+
sceneChildren
|
|
928
|
+
)
|
|
929
|
+
)
|
|
930
|
+
);
|
|
931
|
+
};
|
|
932
|
+
})();
|
|
933
|
+
|
|
934
|
+
window.ModelGraph = ModelGraph;
|