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.
@@ -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
+ });