blast_radius 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +259 -0
- data/lib/blast_radius/active_record_extension.rb +62 -0
- data/lib/blast_radius/analyzer.rb +60 -0
- data/lib/blast_radius/configuration.rb +31 -0
- data/lib/blast_radius/dependency_tree.rb +87 -0
- data/lib/blast_radius/formatters/base.rb +28 -0
- data/lib/blast_radius/formatters/dot_formatter.rb +61 -0
- data/lib/blast_radius/formatters/html_formatter.rb +144 -0
- data/lib/blast_radius/formatters/json_formatter.rb +36 -0
- data/lib/blast_radius/formatters/mermaid_formatter.rb +49 -0
- data/lib/blast_radius/formatters/text_formatter.rb +43 -0
- data/lib/blast_radius/html/graph.js +852 -0
- data/lib/blast_radius/html/styles.css +475 -0
- data/lib/blast_radius/html/template.html.erb +112 -0
- data/lib/blast_radius/impact_calculator.rb +129 -0
- data/lib/blast_radius/node.rb +65 -0
- data/lib/blast_radius/railtie.rb +21 -0
- data/lib/blast_radius/version.rb +5 -0
- data/lib/blast_radius.rb +47 -0
- data/lib/tasks/blast_radius.rake +102 -0
- metadata +164 -0
|
@@ -0,0 +1,852 @@
|
|
|
1
|
+
// BlastRadius - Interactive ER Diagram with Heatmap
|
|
2
|
+
class BlastRadiusGraph {
|
|
3
|
+
constructor(data, options = {}) {
|
|
4
|
+
this.data = data;
|
|
5
|
+
this.options = {
|
|
6
|
+
width: options.width || 1200,
|
|
7
|
+
height: options.height || 800,
|
|
8
|
+
nodeWidth: 200,
|
|
9
|
+
nodeHeight: 70,
|
|
10
|
+
...options
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
this.selectedModel = options.initialSelection || null;
|
|
14
|
+
this.currentLayout = 'force'; // force, tree, radial
|
|
15
|
+
this.nodes = [];
|
|
16
|
+
this.edges = [];
|
|
17
|
+
this.svg = null;
|
|
18
|
+
this.transform = { x: 0, y: 0, scale: 1 };
|
|
19
|
+
this.isDragging = false;
|
|
20
|
+
this.dragStart = { x: 0, y: 0 };
|
|
21
|
+
|
|
22
|
+
this.init();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
init() {
|
|
26
|
+
this.svg = document.getElementById('graph-svg');
|
|
27
|
+
this.buildGraph();
|
|
28
|
+
this.render();
|
|
29
|
+
this.attachEvents();
|
|
30
|
+
this.setupZoomPan();
|
|
31
|
+
|
|
32
|
+
// Auto-select best model on initial load if none selected
|
|
33
|
+
if (!this.selectedModel) {
|
|
34
|
+
const bestModel = this.findBestRootModel();
|
|
35
|
+
if (bestModel) {
|
|
36
|
+
this.updateHeatmap(bestModel);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
buildGraph() {
|
|
42
|
+
// Build nodes from all models
|
|
43
|
+
this.nodes = this.data.nodes.map((node, index) => ({
|
|
44
|
+
id: node.name,
|
|
45
|
+
name: node.name,
|
|
46
|
+
tableName: node.table_name,
|
|
47
|
+
...node,
|
|
48
|
+
x: 0,
|
|
49
|
+
y: 0
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
// Build edges from associations
|
|
53
|
+
this.edges = this.data.edges.map(edge => ({
|
|
54
|
+
source: edge.from,
|
|
55
|
+
target: edge.to,
|
|
56
|
+
label: edge.association,
|
|
57
|
+
dependent: edge.dependent,
|
|
58
|
+
associationType: edge.type,
|
|
59
|
+
...edge
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
// Apply layout
|
|
63
|
+
this.applyLayout(this.currentLayout);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
applyLayout(layoutType) {
|
|
67
|
+
switch (layoutType) {
|
|
68
|
+
case 'tree':
|
|
69
|
+
this.applyTreeLayout();
|
|
70
|
+
break;
|
|
71
|
+
case 'force':
|
|
72
|
+
default:
|
|
73
|
+
this.applyForceLayout();
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
applyForceLayout() {
|
|
79
|
+
const width = this.options.width;
|
|
80
|
+
const height = this.options.height;
|
|
81
|
+
const centerX = width / 2;
|
|
82
|
+
const centerY = height / 2;
|
|
83
|
+
|
|
84
|
+
// Strong force-directed layout with minimal overlap
|
|
85
|
+
const iterations = 400;
|
|
86
|
+
const repulsion = 500000;
|
|
87
|
+
const attraction = 0.0003;
|
|
88
|
+
const damping = 0.7;
|
|
89
|
+
const minDistance = 300; // Minimum distance between nodes
|
|
90
|
+
|
|
91
|
+
// Initialize positions in larger circle
|
|
92
|
+
this.nodes.forEach((node, i) => {
|
|
93
|
+
const angle = (i / this.nodes.length) * 2 * Math.PI;
|
|
94
|
+
const baseRadius = Math.min(width, height) * 0.4;
|
|
95
|
+
const scaledRadius = this.nodes.length > 15 ? baseRadius * 1.8 : baseRadius * 1.2;
|
|
96
|
+
node.x = centerX + scaledRadius * Math.cos(angle);
|
|
97
|
+
node.y = centerY + scaledRadius * Math.sin(angle);
|
|
98
|
+
node.vx = 0;
|
|
99
|
+
node.vy = 0;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Simulation
|
|
103
|
+
for (let iter = 0; iter < iterations; iter++) {
|
|
104
|
+
// Repulsion between nodes with minimum distance enforcement
|
|
105
|
+
for (let i = 0; i < this.nodes.length; i++) {
|
|
106
|
+
for (let j = i + 1; j < this.nodes.length; j++) {
|
|
107
|
+
const dx = this.nodes[j].x - this.nodes[i].x;
|
|
108
|
+
const dy = this.nodes[j].y - this.nodes[i].y;
|
|
109
|
+
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
110
|
+
|
|
111
|
+
// Extra strong repulsion when too close
|
|
112
|
+
let force = repulsion / (dist * dist);
|
|
113
|
+
if (dist < minDistance) {
|
|
114
|
+
force *= 3;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
this.nodes[i].vx -= (dx / dist) * force;
|
|
118
|
+
this.nodes[i].vy -= (dy / dist) * force;
|
|
119
|
+
this.nodes[j].vx += (dx / dist) * force;
|
|
120
|
+
this.nodes[j].vy += (dy / dist) * force;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Weak attraction along edges only
|
|
125
|
+
this.edges.forEach(edge => {
|
|
126
|
+
const source = this.nodes.find(n => n.id === edge.source);
|
|
127
|
+
const target = this.nodes.find(n => n.id === edge.target);
|
|
128
|
+
if (!source || !target) return;
|
|
129
|
+
|
|
130
|
+
const dx = target.x - source.x;
|
|
131
|
+
const dy = target.y - source.y;
|
|
132
|
+
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
133
|
+
const force = dist * attraction;
|
|
134
|
+
|
|
135
|
+
source.vx += (dx / dist) * force;
|
|
136
|
+
source.vy += (dy / dist) * force;
|
|
137
|
+
target.vx -= (dx / dist) * force;
|
|
138
|
+
target.vy -= (dy / dist) * force;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Update positions
|
|
142
|
+
this.nodes.forEach(node => {
|
|
143
|
+
node.x += node.vx * damping;
|
|
144
|
+
node.y += node.vy * damping;
|
|
145
|
+
node.vx *= damping;
|
|
146
|
+
node.vy *= damping;
|
|
147
|
+
|
|
148
|
+
// Keep in bounds with more space
|
|
149
|
+
const margin = 150;
|
|
150
|
+
node.x = Math.max(margin, Math.min(width - margin, node.x));
|
|
151
|
+
node.y = Math.max(margin, Math.min(height - margin, node.y));
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
applyTreeLayout() {
|
|
157
|
+
if (!this.selectedModel) {
|
|
158
|
+
this.applyForceLayout();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const width = this.options.width;
|
|
163
|
+
const height = this.options.height;
|
|
164
|
+
const levelHeight = 250;
|
|
165
|
+
const rootX = width / 2;
|
|
166
|
+
const rootY = 100;
|
|
167
|
+
|
|
168
|
+
// Build tree from selected model
|
|
169
|
+
const visited = new Set();
|
|
170
|
+
const levels = {};
|
|
171
|
+
|
|
172
|
+
const buildTree = (modelName, depth = 0) => {
|
|
173
|
+
if (visited.has(modelName)) return;
|
|
174
|
+
visited.add(modelName);
|
|
175
|
+
|
|
176
|
+
if (!levels[depth]) levels[depth] = [];
|
|
177
|
+
levels[depth].push(modelName);
|
|
178
|
+
|
|
179
|
+
this.edges.forEach(edge => {
|
|
180
|
+
if (edge.source === modelName &&
|
|
181
|
+
['destroy', 'delete_all', 'destroy_async'].includes(edge.dependent)) {
|
|
182
|
+
buildTree(edge.target, depth + 1);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
buildTree(this.selectedModel);
|
|
188
|
+
|
|
189
|
+
// Position nodes by level with wider spacing
|
|
190
|
+
Object.keys(levels).forEach(depth => {
|
|
191
|
+
const nodesInLevel = levels[depth];
|
|
192
|
+
const levelWidth = width - 200;
|
|
193
|
+
const minSpacing = 280; // Minimum spacing between nodes
|
|
194
|
+
const calculatedSpacing = levelWidth / (nodesInLevel.length + 1);
|
|
195
|
+
const spacing = Math.max(minSpacing, calculatedSpacing);
|
|
196
|
+
|
|
197
|
+
nodesInLevel.forEach((modelName, index) => {
|
|
198
|
+
const node = this.nodes.find(n => n.id === modelName);
|
|
199
|
+
if (node) {
|
|
200
|
+
node.x = 100 + spacing * (index + 1);
|
|
201
|
+
node.y = rootY + depth * levelHeight;
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Position unvisited nodes at the bottom
|
|
207
|
+
this.nodes.forEach((node, index) => {
|
|
208
|
+
if (!visited.has(node.id)) {
|
|
209
|
+
node.x = 100 + (index % 8) * 200;
|
|
210
|
+
node.y = height - 100;
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
applyRadialLayout() {
|
|
216
|
+
if (!this.selectedModel) {
|
|
217
|
+
this.applyForceLayout();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const width = this.options.width;
|
|
222
|
+
const height = this.options.height;
|
|
223
|
+
const centerX = width / 2;
|
|
224
|
+
const centerY = height / 2;
|
|
225
|
+
|
|
226
|
+
// Build levels from selected model
|
|
227
|
+
const visited = new Set();
|
|
228
|
+
const levels = {};
|
|
229
|
+
|
|
230
|
+
const buildLevels = (modelName, depth = 0) => {
|
|
231
|
+
if (visited.has(modelName)) return;
|
|
232
|
+
visited.add(modelName);
|
|
233
|
+
|
|
234
|
+
if (!levels[depth]) levels[depth] = [];
|
|
235
|
+
levels[depth].push(modelName);
|
|
236
|
+
|
|
237
|
+
this.edges.forEach(edge => {
|
|
238
|
+
if (edge.source === modelName &&
|
|
239
|
+
['destroy', 'delete_all', 'destroy_async'].includes(edge.dependent)) {
|
|
240
|
+
buildLevels(edge.target, depth + 1);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
buildLevels(this.selectedModel);
|
|
246
|
+
|
|
247
|
+
// Calculate radii for each level with clear separation
|
|
248
|
+
const numLevels = Object.keys(levels).length;
|
|
249
|
+
const maxRadius = Math.min(width, height) * 0.4;
|
|
250
|
+
const radiusStep = numLevels > 1 ? maxRadius / numLevels : maxRadius;
|
|
251
|
+
|
|
252
|
+
// Position nodes in concentric circles
|
|
253
|
+
Object.keys(levels).forEach(depth => {
|
|
254
|
+
const nodesInLevel = levels[depth];
|
|
255
|
+
const depthNum = parseInt(depth);
|
|
256
|
+
const radius = depthNum * radiusStep;
|
|
257
|
+
|
|
258
|
+
nodesInLevel.forEach((modelName, index) => {
|
|
259
|
+
const node = this.nodes.find(n => n.id === modelName);
|
|
260
|
+
if (node) {
|
|
261
|
+
if (depthNum === 0) {
|
|
262
|
+
// Center node
|
|
263
|
+
node.x = centerX;
|
|
264
|
+
node.y = centerY;
|
|
265
|
+
} else {
|
|
266
|
+
// Distribute evenly around the circle, starting from top
|
|
267
|
+
const angleOffset = -Math.PI / 2;
|
|
268
|
+
const angleStep = (2 * Math.PI) / nodesInLevel.length;
|
|
269
|
+
const angle = angleOffset + (index * angleStep);
|
|
270
|
+
node.x = centerX + radius * Math.cos(angle);
|
|
271
|
+
node.y = centerY + radius * Math.sin(angle);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// Position unvisited nodes in outermost ring
|
|
278
|
+
const unvisited = this.nodes.filter(n => !visited.has(n.id));
|
|
279
|
+
if (unvisited.length > 0) {
|
|
280
|
+
const outerRadius = maxRadius + radiusStep;
|
|
281
|
+
const angleOffset = -Math.PI / 2;
|
|
282
|
+
const angleStep = (2 * Math.PI) / unvisited.length;
|
|
283
|
+
|
|
284
|
+
unvisited.forEach((node, index) => {
|
|
285
|
+
const angle = angleOffset + (index * angleStep);
|
|
286
|
+
node.x = centerX + outerRadius * Math.cos(angle);
|
|
287
|
+
node.y = centerY + outerRadius * Math.sin(angle);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
render() {
|
|
293
|
+
// Clear SVG
|
|
294
|
+
this.svg.innerHTML = '';
|
|
295
|
+
this.svg.setAttribute('width', this.options.width);
|
|
296
|
+
this.svg.setAttribute('height', this.options.height);
|
|
297
|
+
|
|
298
|
+
// Create defs for markers
|
|
299
|
+
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
|
300
|
+
defs.innerHTML = `
|
|
301
|
+
<marker id="arrowhead" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
|
|
302
|
+
<polygon points="0 0, 10 3, 0 6" fill="var(--edge-normal)" />
|
|
303
|
+
</marker>
|
|
304
|
+
<marker id="arrowhead-active" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
|
|
305
|
+
<polygon points="0 0, 10 3, 0 6" fill="var(--color-depth-2)" />
|
|
306
|
+
</marker>
|
|
307
|
+
`;
|
|
308
|
+
this.svg.appendChild(defs);
|
|
309
|
+
|
|
310
|
+
// Create container group for zoom/pan
|
|
311
|
+
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
312
|
+
g.setAttribute('id', 'graph-group');
|
|
313
|
+
g.setAttribute('transform', `translate(${this.transform.x}, ${this.transform.y}) scale(${this.transform.scale})`);
|
|
314
|
+
this.svg.appendChild(g);
|
|
315
|
+
|
|
316
|
+
// Render edges
|
|
317
|
+
this.edges.forEach(edge => {
|
|
318
|
+
const source = this.nodes.find(n => n.id === edge.source);
|
|
319
|
+
const target = this.nodes.find(n => n.id === edge.target);
|
|
320
|
+
if (!source || !target) return;
|
|
321
|
+
|
|
322
|
+
this.renderEdge(g, edge, source, target);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// Render nodes
|
|
326
|
+
this.nodes.forEach(node => {
|
|
327
|
+
this.renderNode(g, node);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// Update heatmap if model is selected
|
|
331
|
+
if (this.selectedModel) {
|
|
332
|
+
this.updateHeatmap(this.selectedModel);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
renderNode(container, node) {
|
|
337
|
+
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
338
|
+
g.setAttribute('class', 'node');
|
|
339
|
+
g.setAttribute('data-model', node.id);
|
|
340
|
+
g.setAttribute('transform', `translate(${node.x}, ${node.y})`);
|
|
341
|
+
|
|
342
|
+
// Rectangle
|
|
343
|
+
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
344
|
+
rect.setAttribute('x', -this.options.nodeWidth / 2);
|
|
345
|
+
rect.setAttribute('y', -this.options.nodeHeight / 2);
|
|
346
|
+
rect.setAttribute('width', this.options.nodeWidth);
|
|
347
|
+
rect.setAttribute('height', this.options.nodeHeight);
|
|
348
|
+
g.appendChild(rect);
|
|
349
|
+
|
|
350
|
+
// Text
|
|
351
|
+
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
352
|
+
text.setAttribute('text-anchor', 'middle');
|
|
353
|
+
text.setAttribute('dy', '0.35em');
|
|
354
|
+
text.textContent = node.name;
|
|
355
|
+
g.appendChild(text);
|
|
356
|
+
|
|
357
|
+
container.appendChild(g);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
renderEdge(container, edge, source, target) {
|
|
361
|
+
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
362
|
+
line.setAttribute('class', `edge ${edge.dependent || ''}`);
|
|
363
|
+
line.setAttribute('data-edge', `${edge.source}-${edge.target}`);
|
|
364
|
+
line.setAttribute('data-label', edge.label);
|
|
365
|
+
line.setAttribute('data-dependent', edge.dependent);
|
|
366
|
+
line.setAttribute('x1', source.x);
|
|
367
|
+
line.setAttribute('y1', source.y);
|
|
368
|
+
line.setAttribute('x2', target.x);
|
|
369
|
+
line.setAttribute('y2', target.y);
|
|
370
|
+
line.setAttribute('marker-end', 'url(#arrowhead)');
|
|
371
|
+
|
|
372
|
+
container.appendChild(line);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
setupZoomPan() {
|
|
376
|
+
let isPanning = false;
|
|
377
|
+
let startPoint = { x: 0, y: 0 };
|
|
378
|
+
let startTransform = { x: 0, y: 0 };
|
|
379
|
+
|
|
380
|
+
// Mouse wheel zoom
|
|
381
|
+
this.svg.addEventListener('wheel', (e) => {
|
|
382
|
+
e.preventDefault();
|
|
383
|
+
|
|
384
|
+
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
|
385
|
+
const newScale = Math.max(0.1, Math.min(5, this.transform.scale * delta));
|
|
386
|
+
|
|
387
|
+
// Zoom towards mouse position
|
|
388
|
+
const rect = this.svg.getBoundingClientRect();
|
|
389
|
+
const mouseX = e.clientX - rect.left;
|
|
390
|
+
const mouseY = e.clientY - rect.top;
|
|
391
|
+
|
|
392
|
+
this.transform.x = mouseX - (mouseX - this.transform.x) * (newScale / this.transform.scale);
|
|
393
|
+
this.transform.y = mouseY - (mouseY - this.transform.y) * (newScale / this.transform.scale);
|
|
394
|
+
this.transform.scale = newScale;
|
|
395
|
+
|
|
396
|
+
this.updateTransform();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// Pan on drag (background only)
|
|
400
|
+
this.svg.addEventListener('mousedown', (e) => {
|
|
401
|
+
if (e.target.closest('.node')) return;
|
|
402
|
+
|
|
403
|
+
isPanning = true;
|
|
404
|
+
startPoint = { x: e.clientX, y: e.clientY };
|
|
405
|
+
startTransform = { ...this.transform };
|
|
406
|
+
this.svg.style.cursor = 'grabbing';
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
document.addEventListener('mousemove', (e) => {
|
|
410
|
+
if (!isPanning) return;
|
|
411
|
+
|
|
412
|
+
const dx = e.clientX - startPoint.x;
|
|
413
|
+
const dy = e.clientY - startPoint.y;
|
|
414
|
+
|
|
415
|
+
this.transform.x = startTransform.x + dx;
|
|
416
|
+
this.transform.y = startTransform.y + dy;
|
|
417
|
+
|
|
418
|
+
this.updateTransform();
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
document.addEventListener('mouseup', () => {
|
|
422
|
+
if (isPanning) {
|
|
423
|
+
isPanning = false;
|
|
424
|
+
this.svg.style.cursor = 'default';
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
updateTransform() {
|
|
430
|
+
const g = document.getElementById('graph-group');
|
|
431
|
+
if (g) {
|
|
432
|
+
g.setAttribute('transform', `translate(${this.transform.x}, ${this.transform.y}) scale(${this.transform.scale})`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
fitToScreen() {
|
|
437
|
+
if (this.nodes.length === 0) return;
|
|
438
|
+
|
|
439
|
+
// Calculate bounding box
|
|
440
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
441
|
+
|
|
442
|
+
this.nodes.forEach(node => {
|
|
443
|
+
minX = Math.min(minX, node.x - this.options.nodeWidth / 2);
|
|
444
|
+
minY = Math.min(minY, node.y - this.options.nodeHeight / 2);
|
|
445
|
+
maxX = Math.max(maxX, node.x + this.options.nodeWidth / 2);
|
|
446
|
+
maxY = Math.max(maxY, node.y + this.options.nodeHeight / 2);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
const graphWidth = maxX - minX;
|
|
450
|
+
const graphHeight = maxY - minY;
|
|
451
|
+
const padding = 50;
|
|
452
|
+
|
|
453
|
+
// Calculate scale to fit
|
|
454
|
+
const scaleX = (this.options.width - padding * 2) / graphWidth;
|
|
455
|
+
const scaleY = (this.options.height - padding * 2) / graphHeight;
|
|
456
|
+
const scale = Math.min(scaleX, scaleY, 1);
|
|
457
|
+
|
|
458
|
+
// Calculate center offset
|
|
459
|
+
const centerX = (minX + maxX) / 2;
|
|
460
|
+
const centerY = (minY + maxY) / 2;
|
|
461
|
+
|
|
462
|
+
this.transform.scale = scale;
|
|
463
|
+
this.transform.x = this.options.width / 2 - centerX * scale;
|
|
464
|
+
this.transform.y = this.options.height / 2 - centerY * scale;
|
|
465
|
+
|
|
466
|
+
this.updateTransform();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
switchLayout(layoutType) {
|
|
470
|
+
this.currentLayout = layoutType;
|
|
471
|
+
|
|
472
|
+
// Always auto-select best model for tree layout
|
|
473
|
+
let needsHeatmapUpdate = false;
|
|
474
|
+
if (layoutType === 'tree') {
|
|
475
|
+
const bestModel = this.findBestRootModel();
|
|
476
|
+
if (bestModel) {
|
|
477
|
+
this.selectedModel = bestModel;
|
|
478
|
+
needsHeatmapUpdate = true;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
this.applyLayout(layoutType);
|
|
483
|
+
this.render();
|
|
484
|
+
|
|
485
|
+
// Update heatmap after render (so classes are applied to new elements)
|
|
486
|
+
if (needsHeatmapUpdate && this.selectedModel) {
|
|
487
|
+
this.updateHeatmap(this.selectedModel);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Update active button
|
|
491
|
+
document.querySelectorAll('.layout-btn').forEach(btn => {
|
|
492
|
+
btn.classList.remove('active');
|
|
493
|
+
});
|
|
494
|
+
const activeBtn = document.querySelector(`[data-layout="${layoutType}"]`);
|
|
495
|
+
if (activeBtn) activeBtn.classList.add('active');
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
findBestRootModel() {
|
|
499
|
+
// Find model with most outgoing destructive edges
|
|
500
|
+
const edgeCounts = {};
|
|
501
|
+
|
|
502
|
+
this.nodes.forEach(node => {
|
|
503
|
+
edgeCounts[node.id] = 0;
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
this.edges.forEach(edge => {
|
|
507
|
+
if (['destroy', 'delete_all', 'destroy_async'].includes(edge.dependent)) {
|
|
508
|
+
edgeCounts[edge.source] = (edgeCounts[edge.source] || 0) + 1;
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
let maxCount = 0;
|
|
513
|
+
let bestModel = null;
|
|
514
|
+
|
|
515
|
+
Object.entries(edgeCounts).forEach(([model, count]) => {
|
|
516
|
+
if (count > maxCount) {
|
|
517
|
+
maxCount = count;
|
|
518
|
+
bestModel = model;
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
return bestModel;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
updateHeatmap(modelName) {
|
|
526
|
+
this.selectedModel = modelName;
|
|
527
|
+
|
|
528
|
+
// Calculate cascade paths from selected model
|
|
529
|
+
const paths = this.calculateCascadePaths(modelName);
|
|
530
|
+
|
|
531
|
+
// Update node classes
|
|
532
|
+
this.nodes.forEach(node => {
|
|
533
|
+
const nodeEl = this.svg.querySelector(`.node[data-model="${node.id}"]`);
|
|
534
|
+
if (!nodeEl) return;
|
|
535
|
+
|
|
536
|
+
// Remove all depth classes
|
|
537
|
+
nodeEl.classList.remove('selected', 'depth-1', 'depth-2', 'depth-3', 'depth-4', 'unaffected');
|
|
538
|
+
|
|
539
|
+
if (node.id === modelName) {
|
|
540
|
+
nodeEl.classList.add('selected');
|
|
541
|
+
} else if (paths[node.id]) {
|
|
542
|
+
const depth = paths[node.id].depth;
|
|
543
|
+
if (depth <= 4) {
|
|
544
|
+
nodeEl.classList.add(`depth-${depth}`);
|
|
545
|
+
} else {
|
|
546
|
+
nodeEl.classList.add('depth-4');
|
|
547
|
+
}
|
|
548
|
+
} else {
|
|
549
|
+
nodeEl.classList.add('unaffected');
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
// Update edge classes
|
|
554
|
+
this.edges.forEach(edge => {
|
|
555
|
+
const edgeEl = this.svg.querySelector(`.edge[data-edge="${edge.source}-${edge.target}"]`);
|
|
556
|
+
if (!edgeEl) return;
|
|
557
|
+
|
|
558
|
+
edgeEl.classList.remove('active');
|
|
559
|
+
|
|
560
|
+
// Highlight edges in the cascade path
|
|
561
|
+
if (paths[edge.target] && (edge.source === modelName || paths[edge.source])) {
|
|
562
|
+
edgeEl.classList.add('active');
|
|
563
|
+
edgeEl.setAttribute('marker-end', 'url(#arrowhead-active)');
|
|
564
|
+
} else {
|
|
565
|
+
edgeEl.setAttribute('marker-end', 'url(#arrowhead)');
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
// Update sidebar
|
|
570
|
+
this.updateSidebar(modelName, paths);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
calculateCascadePaths(startModel) {
|
|
574
|
+
const paths = {};
|
|
575
|
+
const queue = [{model: startModel, depth: 0}];
|
|
576
|
+
const visited = new Set();
|
|
577
|
+
|
|
578
|
+
while (queue.length > 0) {
|
|
579
|
+
const {model, depth} = queue.shift();
|
|
580
|
+
|
|
581
|
+
if (visited.has(model)) continue;
|
|
582
|
+
visited.add(model);
|
|
583
|
+
|
|
584
|
+
if (model !== startModel) {
|
|
585
|
+
paths[model] = {depth};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Find outgoing destructive edges
|
|
589
|
+
this.edges.forEach(edge => {
|
|
590
|
+
if (edge.source === model &&
|
|
591
|
+
['destroy', 'delete_all', 'destroy_async'].includes(edge.dependent)) {
|
|
592
|
+
queue.push({model: edge.target, depth: depth + 1});
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return paths;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
updateSidebar(modelName, paths) {
|
|
601
|
+
const selectedInfo = document.getElementById('selected-info');
|
|
602
|
+
const impactList = document.getElementById('impact-list');
|
|
603
|
+
|
|
604
|
+
if (!selectedInfo || !impactList) return;
|
|
605
|
+
|
|
606
|
+
if (!modelName) {
|
|
607
|
+
selectedInfo.innerHTML = `
|
|
608
|
+
<h2>ℹ️ Info</h2>
|
|
609
|
+
<p style="color: var(--text-secondary); font-size: 0.875rem;">
|
|
610
|
+
Click on a model to see its cascade deletion impact.
|
|
611
|
+
</p>
|
|
612
|
+
`;
|
|
613
|
+
impactList.innerHTML = '';
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Update selected model name
|
|
618
|
+
const maxDepth = Math.max(0, ...Object.values(paths).map(p => p.depth));
|
|
619
|
+
selectedInfo.innerHTML = `
|
|
620
|
+
<h2>🔴 ${modelName}</h2>
|
|
621
|
+
<div class="stat-item">
|
|
622
|
+
<span class="stat-label">Affected Models:</span>
|
|
623
|
+
<span class="stat-value">${Object.keys(paths).length}</span>
|
|
624
|
+
</div>
|
|
625
|
+
<div class="stat-item">
|
|
626
|
+
<span class="stat-label">Max Depth:</span>
|
|
627
|
+
<span class="stat-value">${maxDepth || 'None'}</span>
|
|
628
|
+
</div>
|
|
629
|
+
`;
|
|
630
|
+
|
|
631
|
+
// Update impact list
|
|
632
|
+
const sortedPaths = Object.entries(paths).sort((a, b) => a[1].depth - b[1].depth);
|
|
633
|
+
impactList.innerHTML = sortedPaths.map(([model, info]) => `
|
|
634
|
+
<li data-model="${model}" class="impact-item">
|
|
635
|
+
<span class="dependent-badge badge-depth-${Math.min(info.depth, 4)}">
|
|
636
|
+
Depth ${info.depth}
|
|
637
|
+
</span>
|
|
638
|
+
${model}
|
|
639
|
+
</li>
|
|
640
|
+
`).join('');
|
|
641
|
+
|
|
642
|
+
// Add click handlers to impact list items
|
|
643
|
+
impactList.querySelectorAll('.impact-item').forEach(item => {
|
|
644
|
+
item.addEventListener('click', () => {
|
|
645
|
+
const model = item.getAttribute('data-model');
|
|
646
|
+
this.updateHeatmap(model);
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
showTooltip(element, x, y) {
|
|
652
|
+
const tooltip = document.getElementById('tooltip');
|
|
653
|
+
if (!tooltip) return;
|
|
654
|
+
|
|
655
|
+
let content = '';
|
|
656
|
+
|
|
657
|
+
if (element.classList.contains('node')) {
|
|
658
|
+
const modelName = element.getAttribute('data-model');
|
|
659
|
+
const node = this.nodes.find(n => n.id === modelName);
|
|
660
|
+
if (node) {
|
|
661
|
+
content = `
|
|
662
|
+
<strong>${node.name}</strong><br>
|
|
663
|
+
Table: ${node.tableName || node.name.toLowerCase() + 's'}
|
|
664
|
+
`;
|
|
665
|
+
}
|
|
666
|
+
} else if (element.classList.contains('edge')) {
|
|
667
|
+
const label = element.getAttribute('data-label');
|
|
668
|
+
const dependent = element.getAttribute('data-dependent');
|
|
669
|
+
content = `
|
|
670
|
+
Association: <strong>${label}</strong><br>
|
|
671
|
+
Dependent: <strong>${dependent}</strong>
|
|
672
|
+
`;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (content) {
|
|
676
|
+
tooltip.innerHTML = content;
|
|
677
|
+
tooltip.style.left = x + 15 + 'px';
|
|
678
|
+
tooltip.style.top = y + 15 + 'px';
|
|
679
|
+
tooltip.classList.add('visible');
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
hideTooltip() {
|
|
684
|
+
const tooltip = document.getElementById('tooltip');
|
|
685
|
+
if (tooltip) {
|
|
686
|
+
tooltip.classList.remove('visible');
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
attachEvents() {
|
|
691
|
+
// Node click
|
|
692
|
+
this.svg.addEventListener('click', (e) => {
|
|
693
|
+
const node = e.target.closest('.node');
|
|
694
|
+
if (node) {
|
|
695
|
+
const modelName = node.getAttribute('data-model');
|
|
696
|
+
this.updateHeatmap(modelName);
|
|
697
|
+
}
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
// Tooltip on hover
|
|
701
|
+
this.svg.addEventListener('mousemove', (e) => {
|
|
702
|
+
const node = e.target.closest('.node');
|
|
703
|
+
const edge = e.target.closest('.edge');
|
|
704
|
+
|
|
705
|
+
if (node || edge) {
|
|
706
|
+
this.showTooltip(node || edge, e.clientX, e.clientY);
|
|
707
|
+
} else {
|
|
708
|
+
this.hideTooltip();
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
this.svg.addEventListener('mouseleave', () => {
|
|
713
|
+
this.hideTooltip();
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
// Reset button
|
|
717
|
+
const resetBtn = document.getElementById('reset-btn');
|
|
718
|
+
if (resetBtn) {
|
|
719
|
+
resetBtn.addEventListener('click', () => {
|
|
720
|
+
this.selectedModel = null;
|
|
721
|
+
this.render();
|
|
722
|
+
this.updateSidebar(null, {});
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Fit button
|
|
727
|
+
const fitBtn = document.getElementById('fit-btn');
|
|
728
|
+
if (fitBtn) {
|
|
729
|
+
fitBtn.addEventListener('click', () => {
|
|
730
|
+
this.fitToScreen();
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Layout buttons
|
|
735
|
+
document.querySelectorAll('.layout-btn').forEach(btn => {
|
|
736
|
+
btn.addEventListener('click', () => {
|
|
737
|
+
const layout = btn.getAttribute('data-layout');
|
|
738
|
+
this.switchLayout(layout);
|
|
739
|
+
});
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
// Export dropdown
|
|
743
|
+
const exportBtn = document.getElementById('export-btn');
|
|
744
|
+
const exportMenu = document.getElementById('export-menu');
|
|
745
|
+
if (exportBtn && exportMenu) {
|
|
746
|
+
exportBtn.addEventListener('click', (e) => {
|
|
747
|
+
e.stopPropagation();
|
|
748
|
+
exportMenu.classList.toggle('show');
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
document.addEventListener('click', () => {
|
|
752
|
+
exportMenu.classList.remove('show');
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
const exportSvgBtn = document.getElementById('export-svg');
|
|
756
|
+
if (exportSvgBtn) {
|
|
757
|
+
exportSvgBtn.addEventListener('click', () => {
|
|
758
|
+
this.exportAsSVG();
|
|
759
|
+
exportMenu.classList.remove('show');
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const exportPngBtn = document.getElementById('export-png');
|
|
764
|
+
if (exportPngBtn) {
|
|
765
|
+
exportPngBtn.addEventListener('click', () => {
|
|
766
|
+
this.exportAsPNG();
|
|
767
|
+
exportMenu.classList.remove('show');
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Keyboard shortcuts
|
|
773
|
+
document.addEventListener('keydown', (e) => {
|
|
774
|
+
switch(e.key) {
|
|
775
|
+
case 'Escape':
|
|
776
|
+
this.selectedModel = null;
|
|
777
|
+
this.render();
|
|
778
|
+
this.updateSidebar(null, {});
|
|
779
|
+
break;
|
|
780
|
+
case 'f':
|
|
781
|
+
case 'F':
|
|
782
|
+
this.fitToScreen();
|
|
783
|
+
break;
|
|
784
|
+
case '+':
|
|
785
|
+
case '=':
|
|
786
|
+
this.transform.scale = Math.min(5, this.transform.scale * 1.2);
|
|
787
|
+
this.updateTransform();
|
|
788
|
+
break;
|
|
789
|
+
case '-':
|
|
790
|
+
case '_':
|
|
791
|
+
this.transform.scale = Math.max(0.1, this.transform.scale / 1.2);
|
|
792
|
+
this.updateTransform();
|
|
793
|
+
break;
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Export functionality
|
|
799
|
+
exportAsSVG() {
|
|
800
|
+
const svgData = this.svg.outerHTML;
|
|
801
|
+
const blob = new Blob([svgData], { type: 'image/svg+xml' });
|
|
802
|
+
const url = URL.createObjectURL(blob);
|
|
803
|
+
const link = document.createElement('a');
|
|
804
|
+
link.href = url;
|
|
805
|
+
link.download = 'blast_radius.svg';
|
|
806
|
+
link.click();
|
|
807
|
+
URL.revokeObjectURL(url);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
exportAsPNG() {
|
|
811
|
+
const svgData = new XMLSerializer().serializeToString(this.svg);
|
|
812
|
+
const canvas = document.createElement('canvas');
|
|
813
|
+
const ctx = canvas.getContext('2d');
|
|
814
|
+
const img = new Image();
|
|
815
|
+
|
|
816
|
+
canvas.width = this.options.width;
|
|
817
|
+
canvas.height = this.options.height;
|
|
818
|
+
|
|
819
|
+
img.onload = () => {
|
|
820
|
+
ctx.fillStyle = 'white';
|
|
821
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
822
|
+
ctx.drawImage(img, 0, 0);
|
|
823
|
+
|
|
824
|
+
canvas.toBlob((blob) => {
|
|
825
|
+
const url = URL.createObjectURL(blob);
|
|
826
|
+
const link = document.createElement('a');
|
|
827
|
+
link.href = url;
|
|
828
|
+
link.download = 'blast_radius.png';
|
|
829
|
+
link.click();
|
|
830
|
+
URL.revokeObjectURL(url);
|
|
831
|
+
});
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData)));
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Initialize when DOM is ready
|
|
839
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
840
|
+
if (typeof graphData !== 'undefined') {
|
|
841
|
+
const graph = new BlastRadiusGraph(graphData, {
|
|
842
|
+
width: window.innerWidth - 320,
|
|
843
|
+
height: window.innerHeight
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
// Expose graph instance for console access
|
|
847
|
+
window.blastRadiusGraph = graph;
|
|
848
|
+
|
|
849
|
+
// Fit to screen on load
|
|
850
|
+
setTimeout(() => graph.fitToScreen(), 100);
|
|
851
|
+
}
|
|
852
|
+
});
|