@bfra.me/workspace-analyzer 0.1.0 → 0.2.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.
- package/README.md +218 -0
- package/lib/{chunk-WOJ4C7N7.js → chunk-4V5KYQED.js} +3697 -10
- package/lib/cli.js +315 -9
- package/lib/index.d.ts +566 -5
- package/lib/index.js +49 -1
- package/package.json +2 -1
- package/src/cli/commands/analyze.ts +8 -5
- package/src/cli/commands/visualize.ts +406 -0
- package/src/cli/index.ts +45 -1
- package/src/cli/types.ts +43 -0
- package/src/config/schema.ts +3 -3
- package/src/index.ts +49 -0
- package/src/visualizer/graph-builder.ts +397 -0
- package/src/visualizer/html-renderer.ts +556 -0
- package/src/visualizer/index.ts +83 -0
- package/src/visualizer/mermaid-exporter.ts +234 -0
- package/src/visualizer/templates/d3-bundle.ts +1566 -0
- package/src/visualizer/templates/graph-template.ts +959 -0
- package/src/visualizer/templates/styles.ts +928 -0
- package/src/visualizer/types.ts +226 -0
- package/src/visualizer/violation-collector.ts +246 -0
|
@@ -0,0 +1,959 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D3.js force-directed graph template for dependency visualization.
|
|
3
|
+
*
|
|
4
|
+
* This module generates the JavaScript code that initializes and renders
|
|
5
|
+
* the interactive dependency graph using D3.js force simulation.
|
|
6
|
+
* The generated code is embedded directly in the HTML output.
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - Force-directed layout for automatic node positioning
|
|
10
|
+
* - Node rendering with severity-based colors and cycle highlighting
|
|
11
|
+
* - Edge rendering with directional arrows and type styling
|
|
12
|
+
* - Interactive tooltips with node/edge details
|
|
13
|
+
* - Zoom and pan controls
|
|
14
|
+
* - Node dragging
|
|
15
|
+
* - Client-side filtering (layer, severity, package)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type {VisualizationData} from '../types'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Configuration for the graph visualization.
|
|
22
|
+
*/
|
|
23
|
+
export interface GraphConfig {
|
|
24
|
+
/** Width of the SVG container */
|
|
25
|
+
readonly width: number
|
|
26
|
+
/** Height of the SVG container */
|
|
27
|
+
readonly height: number
|
|
28
|
+
/** Node radius (base size) */
|
|
29
|
+
readonly nodeRadius: number
|
|
30
|
+
/** Link distance for force simulation */
|
|
31
|
+
readonly linkDistance: number
|
|
32
|
+
/** Charge strength for force simulation (negative = repulsion) */
|
|
33
|
+
readonly chargeStrength: number
|
|
34
|
+
/** Center force strength */
|
|
35
|
+
readonly centerStrength: number
|
|
36
|
+
/** Collision force radius multiplier */
|
|
37
|
+
readonly collisionRadiusMultiplier: number
|
|
38
|
+
/** Zoom scale extent [min, max] */
|
|
39
|
+
readonly zoomExtent: readonly [number, number]
|
|
40
|
+
/** Alpha decay rate for simulation */
|
|
41
|
+
readonly alphaDecay: number
|
|
42
|
+
/** Velocity decay rate for simulation */
|
|
43
|
+
readonly velocityDecay: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Default graph configuration.
|
|
48
|
+
*/
|
|
49
|
+
export const DEFAULT_GRAPH_CONFIG: GraphConfig = {
|
|
50
|
+
width: 1200,
|
|
51
|
+
height: 800,
|
|
52
|
+
nodeRadius: 8,
|
|
53
|
+
linkDistance: 80,
|
|
54
|
+
chargeStrength: -300,
|
|
55
|
+
centerStrength: 0.05,
|
|
56
|
+
collisionRadiusMultiplier: 1.5,
|
|
57
|
+
zoomExtent: [0.1, 4],
|
|
58
|
+
alphaDecay: 0.0228,
|
|
59
|
+
velocityDecay: 0.4,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Generates the main graph initialization JavaScript code.
|
|
64
|
+
*
|
|
65
|
+
* This code is designed to be embedded in the HTML output and executed
|
|
66
|
+
* after the D3.js library is loaded. It reads visualization data from
|
|
67
|
+
* a global variable and renders the force-directed graph.
|
|
68
|
+
*
|
|
69
|
+
* @returns JavaScript code as a string
|
|
70
|
+
*/
|
|
71
|
+
export function generateGraphInitScript(): string {
|
|
72
|
+
return `
|
|
73
|
+
/**
|
|
74
|
+
* Dependency Graph Visualization
|
|
75
|
+
* Generated by @bfra.me/workspace-analyzer
|
|
76
|
+
*/
|
|
77
|
+
(function() {
|
|
78
|
+
'use strict';
|
|
79
|
+
|
|
80
|
+
// Get visualization data from global scope
|
|
81
|
+
const visualizationData = window.VISUALIZATION_DATA;
|
|
82
|
+
if (!visualizationData) {
|
|
83
|
+
console.error('Visualization data not found');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Extract data
|
|
88
|
+
const { nodes, edges, cycles, statistics, layers, metadata } = visualizationData;
|
|
89
|
+
|
|
90
|
+
// Configuration
|
|
91
|
+
const config = {
|
|
92
|
+
nodeRadius: 8,
|
|
93
|
+
linkDistance: 80,
|
|
94
|
+
chargeStrength: -300,
|
|
95
|
+
centerStrength: 0.05,
|
|
96
|
+
collisionRadiusMultiplier: 1.5,
|
|
97
|
+
zoomExtent: [0.1, 4],
|
|
98
|
+
alphaDecay: 0.0228,
|
|
99
|
+
velocityDecay: 0.4
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// State management
|
|
103
|
+
const state = {
|
|
104
|
+
filters: {
|
|
105
|
+
layers: new Set(layers.map(l => l.name)),
|
|
106
|
+
severities: new Set(['critical', 'error', 'warning', 'info']),
|
|
107
|
+
packages: new Set(),
|
|
108
|
+
showCyclesOnly: false,
|
|
109
|
+
showViolationsOnly: false,
|
|
110
|
+
searchQuery: ''
|
|
111
|
+
},
|
|
112
|
+
selectedNode: null,
|
|
113
|
+
highlightedNodes: new Set(),
|
|
114
|
+
transform: d3.zoomIdentity
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Color scales
|
|
118
|
+
const severityColors = {
|
|
119
|
+
critical: '#dc2626',
|
|
120
|
+
error: '#ea580c',
|
|
121
|
+
warning: '#ca8a04',
|
|
122
|
+
info: '#2563eb'
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const layerColors = {
|
|
126
|
+
domain: '#8b5cf6',
|
|
127
|
+
application: '#06b6d4',
|
|
128
|
+
infrastructure: '#84cc16',
|
|
129
|
+
presentation: '#f97316',
|
|
130
|
+
shared: '#6b7280',
|
|
131
|
+
unknown: '#9ca3af'
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Get container dimensions
|
|
135
|
+
const container = document.querySelector('.graph-container');
|
|
136
|
+
const width = container.clientWidth;
|
|
137
|
+
const height = container.clientHeight;
|
|
138
|
+
|
|
139
|
+
// Create SVG
|
|
140
|
+
const svg = d3.select('.graph-canvas')
|
|
141
|
+
.attr('viewBox', [0, 0, width, height])
|
|
142
|
+
.attr('preserveAspectRatio', 'xMidYMid meet');
|
|
143
|
+
|
|
144
|
+
// Create groups for layering
|
|
145
|
+
const g = svg.append('g').attr('class', 'graph-layer');
|
|
146
|
+
const edgeGroup = g.append('g').attr('class', 'edges');
|
|
147
|
+
const nodeGroup = g.append('g').attr('class', 'nodes');
|
|
148
|
+
|
|
149
|
+
// Arrow marker definition
|
|
150
|
+
svg.append('defs').append('marker')
|
|
151
|
+
.attr('id', 'arrow')
|
|
152
|
+
.attr('viewBox', '0 -5 10 10')
|
|
153
|
+
.attr('refX', 20)
|
|
154
|
+
.attr('refY', 0)
|
|
155
|
+
.attr('markerWidth', 6)
|
|
156
|
+
.attr('markerHeight', 6)
|
|
157
|
+
.attr('orient', 'auto')
|
|
158
|
+
.append('path')
|
|
159
|
+
.attr('d', 'M0,-5L10,0L0,5')
|
|
160
|
+
.attr('class', 'edge-arrow');
|
|
161
|
+
|
|
162
|
+
// Arrow marker for cycle edges
|
|
163
|
+
svg.select('defs').append('marker')
|
|
164
|
+
.attr('id', 'arrow-cycle')
|
|
165
|
+
.attr('viewBox', '0 -5 10 10')
|
|
166
|
+
.attr('refX', 20)
|
|
167
|
+
.attr('refY', 0)
|
|
168
|
+
.attr('markerWidth', 6)
|
|
169
|
+
.attr('markerHeight', 6)
|
|
170
|
+
.attr('orient', 'auto')
|
|
171
|
+
.append('path')
|
|
172
|
+
.attr('d', 'M0,-5L10,0L0,5')
|
|
173
|
+
.attr('class', 'edge-arrow cycle');
|
|
174
|
+
|
|
175
|
+
// Create simulation data with mutable copies
|
|
176
|
+
const simNodes = nodes.map(n => ({...n}));
|
|
177
|
+
const simEdges = edges.map(e => ({
|
|
178
|
+
...e,
|
|
179
|
+
source: e.source,
|
|
180
|
+
target: e.target
|
|
181
|
+
}));
|
|
182
|
+
|
|
183
|
+
// Create node ID lookup
|
|
184
|
+
const nodeById = new Map(simNodes.map(n => [n.id, n]));
|
|
185
|
+
|
|
186
|
+
// Force simulation
|
|
187
|
+
const simulation = d3.forceSimulation(simNodes)
|
|
188
|
+
.force('link', d3.forceLink(simEdges)
|
|
189
|
+
.id(d => d.id)
|
|
190
|
+
.distance(config.linkDistance))
|
|
191
|
+
.force('charge', d3.forceManyBody()
|
|
192
|
+
.strength(config.chargeStrength))
|
|
193
|
+
.force('center', d3.forceCenter(width / 2, height / 2)
|
|
194
|
+
.strength(config.centerStrength))
|
|
195
|
+
.force('collide', d3.forceCollide()
|
|
196
|
+
.radius(config.nodeRadius * config.collisionRadiusMultiplier))
|
|
197
|
+
.alphaDecay(config.alphaDecay)
|
|
198
|
+
.velocityDecay(config.velocityDecay);
|
|
199
|
+
|
|
200
|
+
// Zoom behavior
|
|
201
|
+
const zoom = d3.zoom()
|
|
202
|
+
.scaleExtent(config.zoomExtent)
|
|
203
|
+
.on('zoom', (event) => {
|
|
204
|
+
state.transform = event.transform;
|
|
205
|
+
g.attr('transform', 'translate(' + event.transform.x + ',' + event.transform.y + ') scale(' + event.transform.k + ')');
|
|
206
|
+
updateZoomDisplay();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
svg.call(zoom);
|
|
210
|
+
|
|
211
|
+
// Draw edges
|
|
212
|
+
function drawEdges() {
|
|
213
|
+
const filteredEdges = getFilteredEdges();
|
|
214
|
+
|
|
215
|
+
const edge = edgeGroup.selectAll('.graph-edge')
|
|
216
|
+
.data(filteredEdges, d => d.source.id + '-' + d.target.id);
|
|
217
|
+
|
|
218
|
+
edge.exit().remove();
|
|
219
|
+
|
|
220
|
+
const edgeEnter = edge.enter()
|
|
221
|
+
.append('g')
|
|
222
|
+
.attr('class', 'graph-edge');
|
|
223
|
+
|
|
224
|
+
// Edge line
|
|
225
|
+
edgeEnter.append('line')
|
|
226
|
+
.attr('class', d => getEdgeClass(d))
|
|
227
|
+
.attr('marker-end', d => d.isInCycle ? 'url(#arrow-cycle)' : 'url(#arrow)')
|
|
228
|
+
.on('mouseenter', handleEdgeMouseEnter)
|
|
229
|
+
.on('mouseleave', handleEdgeMouseLeave)
|
|
230
|
+
.on('click', handleEdgeClick);
|
|
231
|
+
|
|
232
|
+
// Merge and update
|
|
233
|
+
const edgeMerged = edgeEnter.merge(edge);
|
|
234
|
+
|
|
235
|
+
edgeMerged.select('line')
|
|
236
|
+
.attr('class', d => getEdgeClass(d));
|
|
237
|
+
|
|
238
|
+
return edgeMerged;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Draw nodes
|
|
242
|
+
function drawNodes() {
|
|
243
|
+
const filteredNodes = getFilteredNodes();
|
|
244
|
+
|
|
245
|
+
const node = nodeGroup.selectAll('.graph-node')
|
|
246
|
+
.data(filteredNodes, d => d.id);
|
|
247
|
+
|
|
248
|
+
node.exit().remove();
|
|
249
|
+
|
|
250
|
+
const nodeEnter = node.enter()
|
|
251
|
+
.append('g')
|
|
252
|
+
.attr('class', 'graph-node')
|
|
253
|
+
.call(drag(simulation));
|
|
254
|
+
|
|
255
|
+
// Node circle
|
|
256
|
+
nodeEnter.append('circle')
|
|
257
|
+
.attr('r', config.nodeRadius)
|
|
258
|
+
.attr('class', d => getNodeClass(d));
|
|
259
|
+
|
|
260
|
+
// Node label
|
|
261
|
+
nodeEnter.append('text')
|
|
262
|
+
.attr('class', 'node-label')
|
|
263
|
+
.attr('dy', config.nodeRadius + 12)
|
|
264
|
+
.text(d => getNodeLabel(d));
|
|
265
|
+
|
|
266
|
+
// Merge and update
|
|
267
|
+
const nodeMerged = nodeEnter.merge(node);
|
|
268
|
+
|
|
269
|
+
nodeMerged.select('circle')
|
|
270
|
+
.attr('class', d => getNodeClass(d));
|
|
271
|
+
|
|
272
|
+
// Event handlers
|
|
273
|
+
nodeMerged
|
|
274
|
+
.on('mouseenter', handleNodeMouseEnter)
|
|
275
|
+
.on('mouseleave', handleNodeMouseLeave)
|
|
276
|
+
.on('click', handleNodeClick);
|
|
277
|
+
|
|
278
|
+
return nodeMerged;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Get CSS class for edge
|
|
282
|
+
function getEdgeClass(edge) {
|
|
283
|
+
const classes = ['edge-line'];
|
|
284
|
+
if (edge.isInCycle) classes.push('cycle');
|
|
285
|
+
classes.push('type-' + edge.type);
|
|
286
|
+
return classes.join(' ');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Get CSS class for node
|
|
290
|
+
function getNodeClass(node) {
|
|
291
|
+
const classes = ['node-circle'];
|
|
292
|
+
|
|
293
|
+
// Cycle highlighting
|
|
294
|
+
if (node.isInCycle) classes.push('cycle');
|
|
295
|
+
|
|
296
|
+
// Severity-based color (takes precedence)
|
|
297
|
+
if (node.highestViolationSeverity) {
|
|
298
|
+
classes.push('severity-' + node.highestViolationSeverity);
|
|
299
|
+
} else if (node.layer) {
|
|
300
|
+
// Layer-based color
|
|
301
|
+
classes.push('layer-' + node.layer.toLowerCase());
|
|
302
|
+
} else {
|
|
303
|
+
classes.push('default');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return classes.join(' ');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Get display label for node
|
|
310
|
+
function getNodeLabel(node) {
|
|
311
|
+
// Show just the filename
|
|
312
|
+
const parts = node.name.split('/');
|
|
313
|
+
return parts[parts.length - 1] || node.name;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Filtering
|
|
317
|
+
function getFilteredNodes() {
|
|
318
|
+
return simNodes.filter(node => {
|
|
319
|
+
// Layer filter
|
|
320
|
+
if (node.layer && !state.filters.layers.has(node.layer)) {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Severity filter - only filter out nodes with violations if their highest severity is not selected
|
|
325
|
+
if (node.highestViolationSeverity && !state.filters.severities.has(node.highestViolationSeverity)) {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Cycles only
|
|
330
|
+
if (state.filters.showCyclesOnly && !node.isInCycle) {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Violations only
|
|
335
|
+
if (state.filters.showViolationsOnly && node.violations.length === 0) {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Search query
|
|
340
|
+
if (state.filters.searchQuery) {
|
|
341
|
+
const query = state.filters.searchQuery.toLowerCase();
|
|
342
|
+
if (!node.name.toLowerCase().includes(query) &&
|
|
343
|
+
!node.filePath.toLowerCase().includes(query) &&
|
|
344
|
+
!(node.packageName && node.packageName.toLowerCase().includes(query))) {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return true;
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function getFilteredEdges() {
|
|
354
|
+
const filteredNodeIds = new Set(getFilteredNodes().map(n => n.id));
|
|
355
|
+
return simEdges.filter(edge => {
|
|
356
|
+
const sourceId = typeof edge.source === 'object' ? edge.source.id : edge.source;
|
|
357
|
+
const targetId = typeof edge.target === 'object' ? edge.target.id : edge.target;
|
|
358
|
+
return filteredNodeIds.has(sourceId) && filteredNodeIds.has(targetId);
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Drag behavior
|
|
363
|
+
function drag(simulation) {
|
|
364
|
+
function dragstarted(event) {
|
|
365
|
+
if (!event.active) simulation.alphaTarget(0.3).restart();
|
|
366
|
+
event.subject.fx = event.subject.x;
|
|
367
|
+
event.subject.fy = event.subject.y;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function dragged(event) {
|
|
371
|
+
event.subject.fx = event.x;
|
|
372
|
+
event.subject.fy = event.y;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function dragended(event) {
|
|
376
|
+
if (!event.active) simulation.alphaTarget(0);
|
|
377
|
+
event.subject.fx = null;
|
|
378
|
+
event.subject.fy = null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return d3.drag()
|
|
382
|
+
.on('start', dragstarted)
|
|
383
|
+
.on('drag', dragged)
|
|
384
|
+
.on('end', dragended);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Tooltip
|
|
388
|
+
const tooltip = d3.select('.tooltip');
|
|
389
|
+
|
|
390
|
+
function handleNodeMouseEnter(event, d) {
|
|
391
|
+
// Show tooltip
|
|
392
|
+
showTooltip(event, d);
|
|
393
|
+
|
|
394
|
+
// Highlight connected nodes
|
|
395
|
+
highlightConnectedNodes(d);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function handleNodeMouseLeave(event, d) {
|
|
399
|
+
hideTooltip();
|
|
400
|
+
clearHighlights();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function handleNodeClick(event, d) {
|
|
404
|
+
state.selectedNode = state.selectedNode === d ? null : d;
|
|
405
|
+
if (state.selectedNode) {
|
|
406
|
+
centerOnNode(d);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function handleEdgeMouseEnter(event, d) {
|
|
411
|
+
showEdgeTooltip(event, d);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function handleEdgeMouseLeave(event, d) {
|
|
415
|
+
hideTooltip();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function handleEdgeClick(event, d) {
|
|
419
|
+
event.stopPropagation();
|
|
420
|
+
showEdgeTooltip(event, d);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function showTooltip(event, node) {
|
|
424
|
+
const iconClass = node.highestViolationSeverity
|
|
425
|
+
? 'severity-' + node.highestViolationSeverity
|
|
426
|
+
: node.isInCycle ? 'cycle' : 'default';
|
|
427
|
+
|
|
428
|
+
let violationsHtml = '';
|
|
429
|
+
if (node.violations.length > 0) {
|
|
430
|
+
violationsHtml = '<div class="tooltip-violations">' +
|
|
431
|
+
'<div class="tooltip-violations-title">Violations</div>' +
|
|
432
|
+
node.violations.slice(0, 5).map(v =>
|
|
433
|
+
'<div class="tooltip-violation">' +
|
|
434
|
+
'<div class="tooltip-violation-dot severity-dot ' + v.severity + '"></div>' +
|
|
435
|
+
'<div class="tooltip-violation-text">' + escapeHtml(v.message) + '</div>' +
|
|
436
|
+
'</div>'
|
|
437
|
+
).join('') +
|
|
438
|
+
(node.violations.length > 5 ? '<div class="tooltip-violation"><div class="tooltip-violation-text">... and ' + (node.violations.length - 5) + ' more</div></div>' : '') +
|
|
439
|
+
'</div>';
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const html = '<div class="tooltip-header">' +
|
|
443
|
+
'<div class="tooltip-icon ' + iconClass + '">' +
|
|
444
|
+
(node.isInCycle ? '↻' : node.violations.length > 0 ? '!' : '○') +
|
|
445
|
+
'</div>' +
|
|
446
|
+
'<div>' +
|
|
447
|
+
'<div class="tooltip-title">' + escapeHtml(node.name) + '</div>' +
|
|
448
|
+
'<div class="tooltip-subtitle">' + escapeHtml(node.filePath) + '</div>' +
|
|
449
|
+
'</div>' +
|
|
450
|
+
'</div>' +
|
|
451
|
+
'<div class="tooltip-content">' +
|
|
452
|
+
'<div class="tooltip-row">' +
|
|
453
|
+
'<span class="tooltip-label">Package</span>' +
|
|
454
|
+
'<span class="tooltip-value">' + escapeHtml(node.packageName || 'N/A') + '</span>' +
|
|
455
|
+
'</div>' +
|
|
456
|
+
'<div class="tooltip-row">' +
|
|
457
|
+
'<span class="tooltip-label">Layer</span>' +
|
|
458
|
+
'<span class="tooltip-value">' + escapeHtml(node.layer || 'Unknown') + '</span>' +
|
|
459
|
+
'</div>' +
|
|
460
|
+
'<div class="tooltip-divider"></div>' +
|
|
461
|
+
'<div class="tooltip-row">' +
|
|
462
|
+
'<span class="tooltip-label">Imports</span>' +
|
|
463
|
+
'<span class="tooltip-value">' + node.importsCount + '</span>' +
|
|
464
|
+
'</div>' +
|
|
465
|
+
'<div class="tooltip-row">' +
|
|
466
|
+
'<span class="tooltip-label">Imported by</span>' +
|
|
467
|
+
'<span class="tooltip-value">' + node.importedByCount + '</span>' +
|
|
468
|
+
'</div>' +
|
|
469
|
+
(node.isInCycle ? '<div class="tooltip-row"><span class="tooltip-label">In Cycle</span><span class="tooltip-value" style="color: #ef4444;">Yes</span></div>' : '') +
|
|
470
|
+
'</div>' +
|
|
471
|
+
violationsHtml;
|
|
472
|
+
|
|
473
|
+
tooltip.html(html);
|
|
474
|
+
|
|
475
|
+
// Position tooltip
|
|
476
|
+
const tooltipNode = tooltip.node();
|
|
477
|
+
const tooltipRect = tooltipNode.getBoundingClientRect();
|
|
478
|
+
const viewportWidth = window.innerWidth;
|
|
479
|
+
const viewportHeight = window.innerHeight;
|
|
480
|
+
|
|
481
|
+
let left = event.clientX + 10;
|
|
482
|
+
let top = event.clientY + 10;
|
|
483
|
+
|
|
484
|
+
// Adjust if tooltip would overflow
|
|
485
|
+
if (left + tooltipRect.width > viewportWidth - 20) {
|
|
486
|
+
left = event.clientX - tooltipRect.width - 10;
|
|
487
|
+
}
|
|
488
|
+
if (top + tooltipRect.height > viewportHeight - 20) {
|
|
489
|
+
top = event.clientY - tooltipRect.height - 10;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
tooltip
|
|
493
|
+
.style('left', left + 'px')
|
|
494
|
+
.style('top', top + 'px')
|
|
495
|
+
.classed('visible', true);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function showEdgeTooltip(event, edge) {
|
|
499
|
+
const sourceId = typeof edge.source === 'object' ? edge.source.id : edge.source;
|
|
500
|
+
const targetId = typeof edge.target === 'object' ? edge.target.id : edge.target;
|
|
501
|
+
const sourceNode = nodeById.get(sourceId);
|
|
502
|
+
const targetNode = nodeById.get(targetId);
|
|
503
|
+
|
|
504
|
+
if (!sourceNode || !targetNode) return;
|
|
505
|
+
|
|
506
|
+
const iconClass = edge.isInCycle ? 'cycle' : 'default';
|
|
507
|
+
const importType = edge.type === 'static' ? 'Static Import' :
|
|
508
|
+
edge.type === 'dynamic' ? 'Dynamic Import' :
|
|
509
|
+
edge.type === 'type-only' ? 'Type-Only Import' :
|
|
510
|
+
'Require Import';
|
|
511
|
+
|
|
512
|
+
const html = '<div class=\"tooltip-header\">' +
|
|
513
|
+
'<div class=\"tooltip-icon ' + iconClass + '\">' +
|
|
514
|
+
(edge.isInCycle ? '↻' : '→') +
|
|
515
|
+
'</div>' +
|
|
516
|
+
'<div>' +
|
|
517
|
+
'<div class=\"tooltip-title\">Import Relationship</div>' +
|
|
518
|
+
'<div class=\"tooltip-subtitle\">' + escapeHtml(importType) + '</div>' +
|
|
519
|
+
'</div>' +
|
|
520
|
+
'</div>' +
|
|
521
|
+
'<div class=\"tooltip-content\">' +
|
|
522
|
+
'<div class=\"tooltip-row\">' +
|
|
523
|
+
'<span class=\"tooltip-label\">From</span>' +
|
|
524
|
+
'<span class=\"tooltip-value\">' + escapeHtml(sourceNode.name) + '</span>' +
|
|
525
|
+
'</div>' +
|
|
526
|
+
'<div class=\"tooltip-row\">' +
|
|
527
|
+
'<span class=\"tooltip-label\">To</span>' +
|
|
528
|
+
'<span class=\"tooltip-value\">' + escapeHtml(targetNode.name) + '</span>' +
|
|
529
|
+
'</div>' +
|
|
530
|
+
'<div class=\"tooltip-divider\"></div>' +
|
|
531
|
+
'<div class=\"tooltip-row\">' +
|
|
532
|
+
'<span class=\"tooltip-label\">Type</span>' +
|
|
533
|
+
'<span class=\"tooltip-value\">' + escapeHtml(edge.type) + '</span>' +
|
|
534
|
+
'</div>' +
|
|
535
|
+
(edge.isInCycle ? '<div class=\"tooltip-row\"><span class=\"tooltip-label\">Part of Cycle</span><span class=\"tooltip-value\" style=\"color: #ef4444;\">Yes</span></div>' : '') +
|
|
536
|
+
(edge.cycleId ? '<div class=\"tooltip-row\"><span class=\"tooltip-label\">Cycle ID</span><span class=\"tooltip-value\">' + escapeHtml(edge.cycleId) + '</span></div>' : '') +
|
|
537
|
+
'</div>';
|
|
538
|
+
|
|
539
|
+
tooltip.html(html);
|
|
540
|
+
|
|
541
|
+
// Position tooltip
|
|
542
|
+
const tooltipNode = tooltip.node();
|
|
543
|
+
const tooltipRect = tooltipNode.getBoundingClientRect();
|
|
544
|
+
const viewportWidth = window.innerWidth;
|
|
545
|
+
const viewportHeight = window.innerHeight;
|
|
546
|
+
|
|
547
|
+
let left = event.clientX + 10;
|
|
548
|
+
let top = event.clientY + 10;
|
|
549
|
+
|
|
550
|
+
// Adjust if tooltip would overflow
|
|
551
|
+
if (left + tooltipRect.width > viewportWidth - 20) {
|
|
552
|
+
left = event.clientX - tooltipRect.width - 10;
|
|
553
|
+
}
|
|
554
|
+
if (top + tooltipRect.height > viewportHeight - 20) {
|
|
555
|
+
top = event.clientY - tooltipRect.height - 10;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
tooltip
|
|
559
|
+
.style('left', left + 'px')
|
|
560
|
+
.style('top', top + 'px')
|
|
561
|
+
.classed('visible', true);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function hideTooltip() {
|
|
565
|
+
tooltip.classed('visible', false);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Highlighting
|
|
569
|
+
function highlightConnectedNodes(node) {
|
|
570
|
+
const connectedIds = new Set([node.id]);
|
|
571
|
+
|
|
572
|
+
// Find connected nodes
|
|
573
|
+
simEdges.forEach(edge => {
|
|
574
|
+
const sourceId = typeof edge.source === 'object' ? edge.source.id : edge.source;
|
|
575
|
+
const targetId = typeof edge.target === 'object' ? edge.target.id : edge.target;
|
|
576
|
+
if (sourceId === node.id) connectedIds.add(targetId);
|
|
577
|
+
if (targetId === node.id) connectedIds.add(sourceId);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
state.highlightedNodes = connectedIds;
|
|
581
|
+
|
|
582
|
+
// Update visual state
|
|
583
|
+
nodeGroup.selectAll('.graph-node')
|
|
584
|
+
.classed('dimmed', d => !connectedIds.has(d.id))
|
|
585
|
+
.classed('highlighted', d => connectedIds.has(d.id));
|
|
586
|
+
|
|
587
|
+
edgeGroup.selectAll('.graph-edge')
|
|
588
|
+
.classed('dimmed', d => {
|
|
589
|
+
const sourceId = typeof d.source === 'object' ? d.source.id : d.source;
|
|
590
|
+
const targetId = typeof d.target === 'object' ? d.target.id : d.target;
|
|
591
|
+
return sourceId !== node.id && targetId !== node.id;
|
|
592
|
+
})
|
|
593
|
+
.classed('highlighted', d => {
|
|
594
|
+
const sourceId = typeof d.source === 'object' ? d.source.id : d.source;
|
|
595
|
+
const targetId = typeof d.target === 'object' ? d.target.id : d.target;
|
|
596
|
+
return sourceId === node.id || targetId === node.id;
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function clearHighlights() {
|
|
601
|
+
state.highlightedNodes.clear();
|
|
602
|
+
nodeGroup.selectAll('.graph-node')
|
|
603
|
+
.classed('dimmed', false)
|
|
604
|
+
.classed('highlighted', false);
|
|
605
|
+
edgeGroup.selectAll('.graph-edge')
|
|
606
|
+
.classed('dimmed', false)
|
|
607
|
+
.classed('highlighted', false);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Center on node
|
|
611
|
+
function centerOnNode(node) {
|
|
612
|
+
const scale = state.transform.k;
|
|
613
|
+
const x = width / 2 - node.x * scale;
|
|
614
|
+
const y = height / 2 - node.y * scale;
|
|
615
|
+
svg.transition()
|
|
616
|
+
.duration(500)
|
|
617
|
+
.call(zoom.transform, d3.zoomIdentity.translate(x, y).scale(scale));
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Zoom controls
|
|
621
|
+
function zoomIn() {
|
|
622
|
+
svg.transition().call(zoom.scaleBy, 1.3);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function zoomOut() {
|
|
626
|
+
svg.transition().call(zoom.scaleBy, 0.7);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function zoomReset() {
|
|
630
|
+
svg.transition().call(zoom.transform, d3.zoomIdentity);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function updateZoomDisplay() {
|
|
634
|
+
const percentage = Math.round(state.transform.k * 100);
|
|
635
|
+
const zoomLevel = document.querySelector('.zoom-level');
|
|
636
|
+
if (zoomLevel) {
|
|
637
|
+
zoomLevel.textContent = percentage + '%';
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Filter controls
|
|
642
|
+
function setLayerFilter(layer, enabled) {
|
|
643
|
+
if (enabled) {
|
|
644
|
+
state.filters.layers.add(layer);
|
|
645
|
+
} else {
|
|
646
|
+
state.filters.layers.delete(layer);
|
|
647
|
+
}
|
|
648
|
+
updateGraph();
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function setSeverityFilter(severity, enabled) {
|
|
652
|
+
if (enabled) {
|
|
653
|
+
state.filters.severities.add(severity);
|
|
654
|
+
} else {
|
|
655
|
+
state.filters.severities.delete(severity);
|
|
656
|
+
}
|
|
657
|
+
updateGraph();
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function setViewMode(mode) {
|
|
661
|
+
state.filters.showCyclesOnly = mode === 'cycles';
|
|
662
|
+
state.filters.showViolationsOnly = mode === 'violations';
|
|
663
|
+
updateGraph();
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function setSearchQuery(query) {
|
|
667
|
+
state.filters.searchQuery = query;
|
|
668
|
+
updateGraph();
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Update graph when filters change
|
|
672
|
+
function updateGraph() {
|
|
673
|
+
drawEdges();
|
|
674
|
+
drawNodes();
|
|
675
|
+
simulation.alpha(0.3).restart();
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Simulation tick handler
|
|
679
|
+
simulation.on('tick', () => {
|
|
680
|
+
edgeGroup.selectAll('.graph-edge line')
|
|
681
|
+
.attr('x1', d => d.source.x)
|
|
682
|
+
.attr('y1', d => d.source.y)
|
|
683
|
+
.attr('x2', d => d.target.x)
|
|
684
|
+
.attr('y2', d => d.target.y);
|
|
685
|
+
|
|
686
|
+
nodeGroup.selectAll('.graph-node')
|
|
687
|
+
.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')');
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
// Helper functions
|
|
691
|
+
function escapeHtml(str) {
|
|
692
|
+
if (!str) return '';
|
|
693
|
+
return str
|
|
694
|
+
.replace(/&/g, '&')
|
|
695
|
+
.replace(/</g, '<')
|
|
696
|
+
.replace(/>/g, '>')
|
|
697
|
+
.replace(/"/g, '"')
|
|
698
|
+
.replace(/'/g, ''');
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Initialize
|
|
702
|
+
drawEdges();
|
|
703
|
+
drawNodes();
|
|
704
|
+
|
|
705
|
+
// Update statistics display
|
|
706
|
+
function updateStatistics() {
|
|
707
|
+
const filtered = getFilteredNodes();
|
|
708
|
+
const filteredEdges = getFilteredEdges();
|
|
709
|
+
|
|
710
|
+
document.getElementById('stat-nodes').textContent = filtered.length;
|
|
711
|
+
document.getElementById('stat-edges').textContent = filteredEdges.length;
|
|
712
|
+
document.getElementById('stat-cycles').textContent = cycles.length;
|
|
713
|
+
|
|
714
|
+
// Update severity counts
|
|
715
|
+
const severityCounts = { critical: 0, error: 0, warning: 0, info: 0 };
|
|
716
|
+
filtered.forEach(n => {
|
|
717
|
+
n.violations.forEach(v => {
|
|
718
|
+
severityCounts[v.severity]++;
|
|
719
|
+
});
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
Object.keys(severityCounts).forEach(severity => {
|
|
723
|
+
const el = document.getElementById('stat-' + severity);
|
|
724
|
+
if (el) el.textContent = severityCounts[severity];
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
updateStatistics();
|
|
729
|
+
|
|
730
|
+
// Expose API for control panel
|
|
731
|
+
window.graphAPI = {
|
|
732
|
+
zoomIn,
|
|
733
|
+
zoomOut,
|
|
734
|
+
zoomReset,
|
|
735
|
+
setLayerFilter,
|
|
736
|
+
setSeverityFilter,
|
|
737
|
+
setViewMode,
|
|
738
|
+
setSearchQuery,
|
|
739
|
+
updateGraph,
|
|
740
|
+
centerOnNode: (nodeId) => {
|
|
741
|
+
const node = nodeById.get(nodeId);
|
|
742
|
+
if (node) centerOnNode(node);
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
// Hide loading overlay
|
|
747
|
+
const loadingOverlay = document.querySelector('.loading-overlay');
|
|
748
|
+
if (loadingOverlay) {
|
|
749
|
+
loadingOverlay.style.display = 'none';
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
console.log('Dependency graph visualization initialized with', nodes.length, 'nodes and', edges.length, 'edges');
|
|
753
|
+
})();
|
|
754
|
+
`
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Generates the control panel JavaScript code for filter interactions.
|
|
759
|
+
*
|
|
760
|
+
* @returns JavaScript code for control panel functionality
|
|
761
|
+
*/
|
|
762
|
+
export function generateControlPanelScript(): string {
|
|
763
|
+
return `
|
|
764
|
+
/**
|
|
765
|
+
* Control Panel Script
|
|
766
|
+
* Handles user interactions with filter controls
|
|
767
|
+
*/
|
|
768
|
+
(function() {
|
|
769
|
+
'use strict';
|
|
770
|
+
|
|
771
|
+
// Wait for graph API to be available
|
|
772
|
+
function waitForGraphAPI(callback) {
|
|
773
|
+
if (window.graphAPI) {
|
|
774
|
+
callback();
|
|
775
|
+
} else {
|
|
776
|
+
setTimeout(() => waitForGraphAPI(callback), 100);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
waitForGraphAPI(() => {
|
|
781
|
+
const api = window.graphAPI;
|
|
782
|
+
|
|
783
|
+
// Zoom controls
|
|
784
|
+
document.getElementById('zoom-in')?.addEventListener('click', api.zoomIn);
|
|
785
|
+
document.getElementById('zoom-out')?.addEventListener('click', api.zoomOut);
|
|
786
|
+
document.getElementById('zoom-reset')?.addEventListener('click', api.zoomReset);
|
|
787
|
+
|
|
788
|
+
// View mode buttons
|
|
789
|
+
document.querySelectorAll('.view-mode-btn').forEach(btn => {
|
|
790
|
+
btn.addEventListener('click', () => {
|
|
791
|
+
document.querySelectorAll('.view-mode-btn').forEach(b => b.classList.remove('active'));
|
|
792
|
+
btn.classList.add('active');
|
|
793
|
+
api.setViewMode(btn.dataset.mode);
|
|
794
|
+
});
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
// Search input
|
|
798
|
+
const searchInput = document.getElementById('search-input');
|
|
799
|
+
if (searchInput) {
|
|
800
|
+
let debounceTimer;
|
|
801
|
+
searchInput.addEventListener('input', (e) => {
|
|
802
|
+
clearTimeout(debounceTimer);
|
|
803
|
+
debounceTimer = setTimeout(() => {
|
|
804
|
+
api.setSearchQuery(e.target.value);
|
|
805
|
+
}, 300);
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Layer checkboxes
|
|
810
|
+
document.querySelectorAll('.layer-checkbox').forEach(checkbox => {
|
|
811
|
+
checkbox.addEventListener('change', (e) => {
|
|
812
|
+
api.setLayerFilter(e.target.dataset.layer, e.target.checked);
|
|
813
|
+
});
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
// Severity checkboxes
|
|
817
|
+
document.querySelectorAll('.severity-checkbox').forEach(checkbox => {
|
|
818
|
+
checkbox.addEventListener('change', (e) => {
|
|
819
|
+
api.setSeverityFilter(e.target.dataset.severity, e.target.checked);
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
// Keyboard shortcuts
|
|
824
|
+
document.addEventListener('keydown', (e) => {
|
|
825
|
+
// Only if not focused on input
|
|
826
|
+
if (document.activeElement.tagName === 'INPUT') return;
|
|
827
|
+
|
|
828
|
+
switch (e.key) {
|
|
829
|
+
case '+':
|
|
830
|
+
case '=':
|
|
831
|
+
api.zoomIn();
|
|
832
|
+
break;
|
|
833
|
+
case '-':
|
|
834
|
+
case '_':
|
|
835
|
+
api.zoomOut();
|
|
836
|
+
break;
|
|
837
|
+
case '0':
|
|
838
|
+
api.zoomReset();
|
|
839
|
+
break;
|
|
840
|
+
case 'Escape':
|
|
841
|
+
api.setSearchQuery('');
|
|
842
|
+
if (searchInput) searchInput.value = '';
|
|
843
|
+
break;
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
console.log('Control panel initialized');
|
|
848
|
+
});
|
|
849
|
+
})();
|
|
850
|
+
`
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Generates the complete HTML template with all visualization scripts and styles.
|
|
855
|
+
*
|
|
856
|
+
* @param _data - The visualization data to embed (used by html-renderer.ts)
|
|
857
|
+
* @param title - The title for the HTML document
|
|
858
|
+
* @returns Complete HTML document as a string
|
|
859
|
+
*/
|
|
860
|
+
export function generateHtmlTemplate(_data: VisualizationData, title: string): string {
|
|
861
|
+
// This is a template function - actual implementation is in html-renderer.ts
|
|
862
|
+
// This provides the structure for the HTML output
|
|
863
|
+
return `<!DOCTYPE html>
|
|
864
|
+
<html lang="en">
|
|
865
|
+
<head>
|
|
866
|
+
<meta charset="UTF-8">
|
|
867
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
868
|
+
<title>${title}</title>
|
|
869
|
+
<!-- Styles will be injected here -->
|
|
870
|
+
</head>
|
|
871
|
+
<body>
|
|
872
|
+
<div class="app-container">
|
|
873
|
+
<!-- Sidebar -->
|
|
874
|
+
<aside class="sidebar">
|
|
875
|
+
<!-- Statistics and filters -->
|
|
876
|
+
</aside>
|
|
877
|
+
<!-- Main content -->
|
|
878
|
+
<main class="main-content">
|
|
879
|
+
<!-- Control panel -->
|
|
880
|
+
<div class="control-panel">
|
|
881
|
+
<!-- Filter controls -->
|
|
882
|
+
</div>
|
|
883
|
+
<!-- Graph canvas -->
|
|
884
|
+
<div class="graph-container">
|
|
885
|
+
<svg class="graph-canvas"></svg>
|
|
886
|
+
<div class="loading-overlay">
|
|
887
|
+
<div class="loading-spinner"></div>
|
|
888
|
+
</div>
|
|
889
|
+
</div>
|
|
890
|
+
</main>
|
|
891
|
+
</div>
|
|
892
|
+
<!-- Tooltip -->
|
|
893
|
+
<div class="tooltip"></div>
|
|
894
|
+
<!-- D3.js and visualization scripts will be injected here -->
|
|
895
|
+
</body>
|
|
896
|
+
</html>`
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Gets the node radius based on its characteristics.
|
|
901
|
+
*
|
|
902
|
+
* @param node - Node data
|
|
903
|
+
* @param node.importsCount - Number of imports from this node
|
|
904
|
+
* @param node.importedByCount - Number of nodes that import this one
|
|
905
|
+
* @param node.violations - Array of violations for this node
|
|
906
|
+
* @param baseRadius - Base radius size
|
|
907
|
+
* @returns Calculated radius for the node
|
|
908
|
+
*/
|
|
909
|
+
export function calculateNodeRadius(
|
|
910
|
+
node: {importsCount: number; importedByCount: number; violations: readonly unknown[]},
|
|
911
|
+
baseRadius: number,
|
|
912
|
+
): number {
|
|
913
|
+
// Scale by connectivity (clamped)
|
|
914
|
+
const connectivity = Math.min(node.importsCount + node.importedByCount, 20)
|
|
915
|
+
const connectivityScale = 1 + connectivity * 0.05
|
|
916
|
+
|
|
917
|
+
// Boost for violations
|
|
918
|
+
const violationBoost = node.violations.length > 0 ? 1.2 : 1
|
|
919
|
+
|
|
920
|
+
return Math.round(baseRadius * connectivityScale * violationBoost)
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Calculates the optimal link distance based on graph density.
|
|
925
|
+
*
|
|
926
|
+
* @param nodeCount - Number of nodes
|
|
927
|
+
* @param edgeCount - Number of edges
|
|
928
|
+
* @param baseDistance - Base link distance
|
|
929
|
+
* @returns Optimal link distance
|
|
930
|
+
*/
|
|
931
|
+
export function calculateLinkDistance(
|
|
932
|
+
nodeCount: number,
|
|
933
|
+
edgeCount: number,
|
|
934
|
+
baseDistance: number,
|
|
935
|
+
): number {
|
|
936
|
+
// Denser graphs need more spacing
|
|
937
|
+
const density = edgeCount / Math.max(nodeCount, 1)
|
|
938
|
+
const densityFactor = Math.min(1 + density * 0.1, 2)
|
|
939
|
+
|
|
940
|
+
return Math.round(baseDistance * densityFactor)
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Calculates charge strength based on graph size.
|
|
945
|
+
*
|
|
946
|
+
* @param nodeCount - Number of nodes
|
|
947
|
+
* @param baseStrength - Base charge strength (negative for repulsion)
|
|
948
|
+
* @returns Optimal charge strength
|
|
949
|
+
*/
|
|
950
|
+
export function calculateChargeStrength(nodeCount: number, baseStrength: number): number {
|
|
951
|
+
// Larger graphs need less repulsion to avoid spreading too far
|
|
952
|
+
if (nodeCount > 200) {
|
|
953
|
+
return baseStrength * 0.5
|
|
954
|
+
}
|
|
955
|
+
if (nodeCount > 100) {
|
|
956
|
+
return baseStrength * 0.7
|
|
957
|
+
}
|
|
958
|
+
return baseStrength
|
|
959
|
+
}
|