devcompass
Advanced tools
| <!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="UTF-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| <title>DevCompass Dashboard v3.2.0</title> | ||
| <!-- Modular CSS --> | ||
| <link rel="stylesheet" href="styles/base.css"> | ||
| <link rel="stylesheet" href="styles/layout.css"> | ||
| <link rel="stylesheet" href="styles/controls.css"> | ||
| <link rel="stylesheet" href="styles/graph.css"> | ||
| <link rel="stylesheet" href="styles/themes.css"> | ||
| <!-- D3.js Library --> | ||
| <script src="https://d3js.org/d3.v7.min.js"></script> | ||
| </head> | ||
| <body class="theme-dark"> | ||
| <!-- ========== HEADER ========== --> | ||
| <header class="header"> | ||
| <div class="header-left"> | ||
| <h1 class="header-title"> | ||
| <span class="header-icon">📊</span> | ||
| DevCompass Dashboard | ||
| <span class="version-badge">v3.2.0</span> | ||
| </h1> | ||
| <div class="header-meta" id="headerMeta"> | ||
| <span><strong>Project:</strong> <span id="projectName">Loading...</span></span> | ||
| <span><strong>Version:</strong> <span id="projectVersion">-</span></span> | ||
| <span><strong>Dependencies:</strong> <span id="totalDeps">0</span></span> | ||
| </div> | ||
| </div> | ||
| <div class="header-right"> | ||
| <!-- Layout Tabs --> | ||
| <nav class="layout-tabs"> | ||
| <button class="tab-btn active" data-layout="tree" onclick="window.switchLayout('tree')"> | ||
| <span>🌳</span> Tree | ||
| </button> | ||
| <button class="tab-btn" data-layout="force" onclick="window.switchLayout('force')"> | ||
| <span>⚡</span> Force | ||
| </button> | ||
| <button class="tab-btn" data-layout="radial" onclick="window.switchLayout('radial')"> | ||
| <span>🌐</span> Radial | ||
| </button> | ||
| <button class="tab-btn" data-layout="conflict" onclick="window.switchLayout('conflict')"> | ||
| <span>⚠️</span> Conflict | ||
| </button> | ||
| <button class="tab-btn" data-layout="analytics" onclick="window.switchLayout('analytics')"> | ||
| <span>📊</span> Analytics | ||
| </button> | ||
| </nav> | ||
| <!-- Theme Toggle --> | ||
| <button class="icon-btn" onclick="window.toggleTheme()" title="Toggle Theme"> | ||
| <span id="themeIcon">🌙</span> | ||
| </button> | ||
| </div> | ||
| </header> | ||
| <!-- ========== MAIN CONTAINER ========== --> | ||
| <div class="main-container"> | ||
| <!-- ========== LEFT SIDEBAR ========== --> | ||
| <aside class="sidebar sidebar-left"> | ||
| <!-- Search --> | ||
| <div class="sidebar-section"> | ||
| <h3 class="sidebar-title">🔍 Search</h3> | ||
| <input | ||
| type="text" | ||
| class="search-input" | ||
| id="searchInput" | ||
| placeholder="Search packages..." | ||
| oninput="window.handleSearch(this.value)" | ||
| > | ||
| </div> | ||
| <!-- Filters --> | ||
| <div class="sidebar-section"> | ||
| <h3 class="sidebar-title">🎯 Filters</h3> | ||
| <div class="filter-group"> | ||
| <label class="filter-label">Health Score</label> | ||
| <select class="filter-select" id="healthFilter" onchange="window.applyFilters()"> | ||
| <option value="all">All Packages</option> | ||
| <option value="excellent">Excellent (9-10)</option> | ||
| <option value="good">Good (7-8)</option> | ||
| <option value="caution">Caution (5-7)</option> | ||
| <option value="warning">Warning (3-5)</option> | ||
| <option value="critical">Critical (<3)</option> | ||
| </select> | ||
| </div> | ||
| <div class="filter-group"> | ||
| <label class="filter-label">Depth Level</label> | ||
| <div class="slider-container"> | ||
| <input | ||
| type="range" | ||
| class="depth-slider" | ||
| id="depthSlider" | ||
| min="1" | ||
| max="10" | ||
| value="10" | ||
| oninput="window.handleDepthChange(this.value)" | ||
| > | ||
| <span class="depth-value" id="depthValue">∞</span> | ||
| </div> | ||
| </div> | ||
| <div class="filter-group"> | ||
| <label class="filter-label">Show/Hide</label> | ||
| <div class="checkbox-group"> | ||
| <label class="checkbox-label"> | ||
| <input type="checkbox" checked onchange="window.toggleLabels()"> Labels | ||
| </label> | ||
| <label class="checkbox-label"> | ||
| <input type="checkbox" checked onchange="window.toggleLinks()"> Links | ||
| </label> | ||
| <label class="checkbox-label"> | ||
| <input type="checkbox" checked onchange="window.toggleDepthCircles()"> Depth Circles | ||
| </label> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| <!-- Clustering --> | ||
| <div class="sidebar-section"> | ||
| <h3 class="sidebar-title">🔲 Clustering</h3> | ||
| <div class="cluster-mode-grid"> | ||
| <button class="cluster-btn active" data-mode="ecosystem" onclick="window.switchClusterMode('ecosystem')"> | ||
| ⚛️ Ecosystem | ||
| </button> | ||
| <button class="cluster-btn" data-mode="health" onclick="window.switchClusterMode('health')"> | ||
| 🏥 Health | ||
| </button> | ||
| <button class="cluster-btn" data-mode="depth" onclick="window.switchClusterMode('depth')"> | ||
| 📊 Depth | ||
| </button> | ||
| </div> | ||
| <div class="cluster-list" id="clusterList"> | ||
| <!-- Populated by JavaScript --> | ||
| </div> | ||
| </div> | ||
| </aside> | ||
| <!-- ========== GRAPH AREA ========== --> | ||
| <main class="graph-container"> | ||
| <svg id="graph" class="graph-svg"></svg> | ||
| <!-- Loading Indicator --> | ||
| <div class="loading" id="loading"> | ||
| <div class="spinner"></div> | ||
| <div class="loading-text">Rendering graph...</div> | ||
| </div> | ||
| <!-- Empty State --> | ||
| <div class="empty-state" id="emptyState" style="display: none;"> | ||
| <div class="empty-icon">📦</div> | ||
| <h2 class="empty-title">No Dependencies Found</h2> | ||
| <p class="empty-text">Your project has no dependencies to visualize.</p> | ||
| </div> | ||
| <!-- Analytics View (Hidden by default) --> | ||
| <div class="analytics-container" id="analyticsView" style="display: none;"> | ||
| <div class="analytics-grid"> | ||
| <!-- Will be populated by analytics layout --> | ||
| </div> | ||
| </div> | ||
| </main> | ||
| <!-- ========== RIGHT SIDEBAR ========== --> | ||
| <aside class="sidebar sidebar-right"> | ||
| <!-- Statistics --> | ||
| <div class="sidebar-section"> | ||
| <h3 class="sidebar-title">📊 Statistics</h3> | ||
| <div class="stat-grid" id="statsGrid"> | ||
| <div class="stat-item"> | ||
| <span class="stat-label">Total</span> | ||
| <span class="stat-value" id="stat-total">0</span> | ||
| </div> | ||
| <div class="stat-item"> | ||
| <span class="stat-label">Visible</span> | ||
| <span class="stat-value" id="stat-visible">0</span> | ||
| </div> | ||
| <div class="stat-item"> | ||
| <span class="stat-label">Clusters</span> | ||
| <span class="stat-value stat-cluster" id="stat-clusters">0</span> | ||
| </div> | ||
| <div class="stat-item"> | ||
| <span class="stat-label">Vulnerable</span> | ||
| <span class="stat-value stat-danger" id="stat-vulnerable">0</span> | ||
| </div> | ||
| <div class="stat-item"> | ||
| <span class="stat-label">Outdated</span> | ||
| <span class="stat-value stat-warning" id="stat-outdated">0</span> | ||
| </div> | ||
| <div class="stat-item"> | ||
| <span class="stat-label">Healthy</span> | ||
| <span class="stat-value stat-success" id="stat-healthy">0</span> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| <!-- Controls --> | ||
| <div class="sidebar-section"> | ||
| <h3 class="sidebar-title">⚙️ Controls</h3> | ||
| <div class="control-buttons"> | ||
| <button class="control-btn primary" onclick="window.resetView()"> | ||
| <span>↻</span> Reset View | ||
| </button> | ||
| <button class="control-btn" onclick="window.fitToScreen()"> | ||
| <span>⛶</span> Fit to Screen | ||
| </button> | ||
| <button class="control-btn" onclick="window.zoomIn()"> | ||
| <span>🔍+</span> Zoom In | ||
| </button> | ||
| <button class="control-btn" onclick="window.zoomOut()"> | ||
| <span>🔍−</span> Zoom Out | ||
| </button> | ||
| <button class="control-btn" onclick="window.centerGraph()"> | ||
| <span>⊙</span> Center | ||
| </button> | ||
| </div> | ||
| </div> | ||
| <!-- Export --> | ||
| <div class="sidebar-section"> | ||
| <h3 class="sidebar-title">💾 Export</h3> | ||
| <div class="control-buttons"> | ||
| <button class="control-btn" onclick="window.exportPNG()"> | ||
| <span>📸</span> Save PNG | ||
| </button> | ||
| <button class="control-btn" onclick="window.exportJSON()"> | ||
| <span>📄</span> Save JSON | ||
| </button> | ||
| <button class="control-btn" onclick="window.exportReport()"> | ||
| <span>📊</span> Report | ||
| </button> | ||
| </div> | ||
| </div> | ||
| <!-- Legend --> | ||
| <div class="sidebar-section"> | ||
| <h3 class="sidebar-title">🎨 Legend</h3> | ||
| <div class="legend"> | ||
| <div class="legend-item"> | ||
| <div class="legend-dot health-excellent"></div> | ||
| <span>Excellent (9-10)</span> | ||
| </div> | ||
| <div class="legend-item"> | ||
| <div class="legend-dot health-good"></div> | ||
| <span>Good (7-8)</span> | ||
| </div> | ||
| <div class="legend-item"> | ||
| <div class="legend-dot health-caution"></div> | ||
| <span>Caution (5-7)</span> | ||
| </div> | ||
| <div class="legend-item"> | ||
| <div class="legend-dot health-warning"></div> | ||
| <span>Warning (3-5)</span> | ||
| </div> | ||
| <div class="legend-item"> | ||
| <div class="legend-dot health-critical"></div> | ||
| <span>Critical (<3)</span> | ||
| </div> | ||
| <div class="legend-item"> | ||
| <div class="legend-dot root-node"></div> | ||
| <span>Root Package</span> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </aside> | ||
| </div> | ||
| <!-- ========== TOOLTIP ========== --> | ||
| <div class="tooltip" id="tooltip"></div> | ||
| <!-- ========== ZOOM CONTROLS (FLOATING) ========== --> | ||
| <div class="zoom-controls"> | ||
| <button class="zoom-btn" onclick="window.zoomIn()" title="Zoom In">+</button> | ||
| <div class="zoom-level" id="zoomLevel">100%</div> | ||
| <button class="zoom-btn" onclick="window.zoomOut()" title="Zoom Out">−</button> | ||
| <button class="zoom-btn" onclick="window.resetView()" title="Reset">⟲</button> | ||
| </div> | ||
| <!-- ========== MODULAR JAVASCRIPT ========== --> | ||
| <script> | ||
| // Graph data will be injected here by the exporter | ||
| window.graphData = {{GRAPH_DATA}}; | ||
| // Clustering code will be injected here | ||
| {{CLUSTERING_CODE}} | ||
| </script> | ||
| <!-- Load modules in correct order --> | ||
| <script src="scripts/utils.js"></script> | ||
| <script src="scripts/tooltip.js"></script> | ||
| <script src="scripts/stats.js"></script> | ||
| <script src="scripts/controls.js"></script> | ||
| <script src="scripts/layouts.js"></script> | ||
| <script src="scripts/core.js"></script> | ||
| </body> | ||
| </html> |
| // src/dashboard/scripts/controls.js | ||
| let currentZoom = null; | ||
| let currentSvg = null; | ||
| let currentG = null; | ||
| let currentTransform = null; | ||
| let showLabels = true; | ||
| let showLinks = true; | ||
| let showDepthCircles = true; | ||
| let currentFilters = { | ||
| health: 'all', | ||
| maxDepth: 10, | ||
| searchTerm: '' | ||
| }; | ||
| function initZoom(svg, g) { | ||
| currentZoom = d3.zoom() | ||
| .scaleExtent([0.1, 4]) | ||
| .on('zoom', (event) => { | ||
| g.attr('transform', event.transform); | ||
| currentTransform = event.transform; | ||
| updateZoomDisplay(event.transform.k); | ||
| }); | ||
| svg.call(currentZoom); | ||
| currentSvg = svg; | ||
| currentG = g; | ||
| } | ||
| function updateZoomDisplay(scale) { | ||
| const zoomPercent = Math.round(scale * 100) + '%'; | ||
| const zoomLevelEl = document.getElementById('zoomLevel'); | ||
| if (zoomLevelEl) { | ||
| zoomLevelEl.textContent = zoomPercent; | ||
| } | ||
| const zoomStatEl = document.getElementById('zoom-stat'); | ||
| if (zoomStatEl) { | ||
| zoomStatEl.textContent = zoomPercent; | ||
| } | ||
| } | ||
| function zoomIn() { | ||
| if (currentSvg && currentZoom) { | ||
| currentSvg.transition().duration(300).call(currentZoom.scaleBy, 1.3); | ||
| } | ||
| } | ||
| function zoomOut() { | ||
| if (currentSvg && currentZoom) { | ||
| currentSvg.transition().duration(300).call(currentZoom.scaleBy, 0.7); | ||
| } | ||
| } | ||
| function resetZoom() { | ||
| if (currentSvg && currentZoom) { | ||
| currentSvg.transition().duration(500).call( | ||
| currentZoom.transform, | ||
| d3.zoomIdentity | ||
| ); | ||
| } | ||
| } | ||
| function resetView() { | ||
| resetZoom(); | ||
| } | ||
| function centerGraph() { | ||
| if (!currentSvg || !currentZoom || !currentG) return; | ||
| try { | ||
| const bounds = currentG.node().getBBox(); | ||
| const containerWidth = currentSvg.node().clientWidth; | ||
| const containerHeight = currentSvg.node().clientHeight; | ||
| const padding = 50; | ||
| const scaleX = (containerWidth - padding * 2) / bounds.width; | ||
| const scaleY = (containerHeight - padding * 2) / bounds.height; | ||
| const scale = Math.min(scaleX, scaleY, 1); | ||
| const tx = (containerWidth - bounds.width * scale) / 2 - bounds.x * scale; | ||
| const ty = (containerHeight - bounds.height * scale) / 2 - bounds.y * scale; | ||
| currentSvg.transition().duration(750).call( | ||
| currentZoom.transform, | ||
| d3.zoomIdentity.translate(tx, ty).scale(scale) | ||
| ); | ||
| } catch (error) { | ||
| console.warn('Center graph failed:', error); | ||
| resetZoom(); | ||
| } | ||
| } | ||
| function fitToScreen() { | ||
| centerGraph(); | ||
| } | ||
| function toggleLabels() { | ||
| showLabels = !showLabels; | ||
| d3.selectAll('.node-label').classed('hidden', !showLabels); | ||
| } | ||
| function toggleLinks() { | ||
| showLinks = !showLinks; | ||
| d3.selectAll('.link').classed('hidden', !showLinks); | ||
| } | ||
| function toggleDepthCircles() { | ||
| showDepthCircles = !showDepthCircles; | ||
| d3.selectAll('.depth-circle').classed('hidden', !showDepthCircles); | ||
| } | ||
| function handleSearch(value) { | ||
| currentFilters.searchTerm = value; | ||
| applyFilters(); | ||
| } | ||
| function handleDepthChange(value) { | ||
| currentFilters.maxDepth = parseInt(value); | ||
| const depthValueEl = document.getElementById('depthValue'); | ||
| if (depthValueEl) { | ||
| depthValueEl.textContent = value === '10' ? '∞' : value; | ||
| } | ||
| applyFilters(); | ||
| } | ||
| function applyFilters() { | ||
| const healthFilterEl = document.getElementById('healthFilter'); | ||
| if (healthFilterEl) { | ||
| currentFilters.health = healthFilterEl.value; | ||
| } | ||
| if (typeof window.renderCurrentLayout === 'function') { | ||
| window.renderCurrentLayout(); | ||
| } | ||
| } | ||
| function getFilteredNodes(allNodes) { | ||
| return allNodes.filter(node => window.nodeMatchesFilters(node, currentFilters)); | ||
| } | ||
| function exportPNG() { | ||
| try { | ||
| const svgElement = document.getElementById('graph'); | ||
| if (!svgElement) { | ||
| alert('Graph not found'); | ||
| return; | ||
| } | ||
| const svgData = new XMLSerializer().serializeToString(svgElement); | ||
| const canvas = document.createElement('canvas'); | ||
| const ctx = canvas.getContext('2d'); | ||
| const img = new Image(); | ||
| canvas.width = svgElement.clientWidth; | ||
| canvas.height = svgElement.clientHeight; | ||
| img.onload = function() { | ||
| ctx.fillStyle = getComputedStyle(document.body).backgroundColor; | ||
| ctx.fillRect(0, 0, canvas.width, canvas.height); | ||
| ctx.drawImage(img, 0, 0); | ||
| canvas.toBlob(function(blob) { | ||
| const url = URL.createObjectURL(blob); | ||
| const link = document.createElement('a'); | ||
| link.href = url; | ||
| link.download = `devcompass-graph-${Date.now()}.png`; | ||
| link.click(); | ||
| URL.revokeObjectURL(url); | ||
| }); | ||
| }; | ||
| img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData))); | ||
| } catch (error) { | ||
| console.error('PNG export failed:', error); | ||
| alert('PNG export failed. Please try again.'); | ||
| } | ||
| } | ||
| function exportJSON() { | ||
| if (typeof window.graphData === 'undefined') { | ||
| alert('No graph data available'); | ||
| return; | ||
| } | ||
| const exportData = { | ||
| version: '3.2.0', | ||
| timestamp: new Date().toISOString(), | ||
| nodes: window.graphData.nodes, | ||
| links: window.graphData.links, | ||
| metadata: window.graphData.metadata || {}, | ||
| filters: currentFilters | ||
| }; | ||
| window.exportAsJSON(exportData, `devcompass-data-${Date.now()}.json`); | ||
| } | ||
| function exportReport() { | ||
| if (typeof window.graphData === 'undefined') { | ||
| alert('No graph data available'); | ||
| return; | ||
| } | ||
| const stats = window.getStats(); | ||
| const healthDist = window.statsManager ? window.statsManager.getHealthDistribution(window.graphData.nodes) : {}; | ||
| const report = { | ||
| title: 'DevCompass Dependency Report', | ||
| generated: new Date().toISOString(), | ||
| project: { | ||
| name: document.getElementById('projectName')?.textContent || 'Unknown', | ||
| version: document.getElementById('projectVersion')?.textContent || 'Unknown' | ||
| }, | ||
| statistics: stats, | ||
| healthDistribution: healthDist, | ||
| nodes: window.graphData.nodes.map(n => ({ | ||
| name: n.name, | ||
| version: n.version, | ||
| healthScore: n.healthScore, | ||
| depth: n.depth, | ||
| issues: n.issues | ||
| })) | ||
| }; | ||
| window.exportAsJSON(report, `devcompass-report-${Date.now()}.json`); | ||
| } | ||
| function toggleFullscreen() { | ||
| if (!document.fullscreenElement) { | ||
| document.documentElement.requestFullscreen().catch(err => { | ||
| console.warn('Fullscreen not supported:', err); | ||
| }); | ||
| } else { | ||
| document.exitFullscreen(); | ||
| } | ||
| } | ||
| function toggleTheme() { | ||
| const body = document.body; | ||
| const isDark = body.classList.contains('theme-dark'); | ||
| if (isDark) { | ||
| body.classList.remove('theme-dark'); | ||
| body.classList.add('theme-light'); | ||
| window.storage.set('theme', 'light'); | ||
| document.getElementById('themeIcon').textContent = '☀️'; | ||
| } else { | ||
| body.classList.remove('theme-light'); | ||
| body.classList.add('theme-dark'); | ||
| window.storage.set('theme', 'dark'); | ||
| document.getElementById('themeIcon').textContent = '🌙'; | ||
| } | ||
| } | ||
| function initTheme() { | ||
| const savedTheme = window.storage.get('theme', 'dark'); | ||
| const body = document.body; | ||
| if (savedTheme === 'light') { | ||
| body.classList.remove('theme-dark'); | ||
| body.classList.add('theme-light'); | ||
| document.getElementById('themeIcon').textContent = '☀️'; | ||
| } else { | ||
| body.classList.add('theme-dark'); | ||
| document.getElementById('themeIcon').textContent = '🌙'; | ||
| } | ||
| } | ||
| function initKeyboardShortcuts() { | ||
| document.addEventListener('keydown', (e) => { | ||
| if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { | ||
| return; | ||
| } | ||
| switch(e.key.toLowerCase()) { | ||
| case '+': | ||
| case '=': | ||
| e.preventDefault(); | ||
| zoomIn(); | ||
| break; | ||
| case '-': | ||
| e.preventDefault(); | ||
| zoomOut(); | ||
| break; | ||
| case 'r': | ||
| e.preventDefault(); | ||
| resetView(); | ||
| break; | ||
| case 'f': | ||
| e.preventDefault(); | ||
| fitToScreen(); | ||
| break; | ||
| case 'l': | ||
| e.preventDefault(); | ||
| toggleLabels(); | ||
| break; | ||
| case 't': | ||
| e.preventDefault(); | ||
| toggleTheme(); | ||
| break; | ||
| case 'escape': | ||
| window.hideTooltip(); | ||
| break; | ||
| } | ||
| }); | ||
| } | ||
| // Export to window | ||
| window.initZoom = initZoom; | ||
| window.zoomIn = zoomIn; | ||
| window.zoomOut = zoomOut; | ||
| window.resetZoom = resetZoom; | ||
| window.resetView = resetView; | ||
| window.centerGraph = centerGraph; | ||
| window.fitToScreen = fitToScreen; | ||
| window.toggleLabels = toggleLabels; | ||
| window.toggleLinks = toggleLinks; | ||
| window.toggleDepthCircles = toggleDepthCircles; | ||
| window.handleSearch = handleSearch; | ||
| window.handleDepthChange = handleDepthChange; | ||
| window.applyFilters = applyFilters; | ||
| window.getFilteredNodes = getFilteredNodes; | ||
| window.exportPNG = exportPNG; | ||
| window.exportJSON = exportJSON; | ||
| window.exportReport = exportReport; | ||
| window.toggleFullscreen = toggleFullscreen; | ||
| window.toggleTheme = toggleTheme; | ||
| window.initTheme = initTheme; | ||
| window.initKeyboardShortcuts = initKeyboardShortcuts; | ||
| window.currentFilters = currentFilters; | ||
| if (typeof module !== 'undefined' && module.exports) { | ||
| module.exports = { | ||
| initZoom, | ||
| zoomIn, | ||
| zoomOut, | ||
| resetZoom, | ||
| resetView, | ||
| centerGraph, | ||
| fitToScreen, | ||
| toggleLabels, | ||
| toggleLinks, | ||
| toggleDepthCircles, | ||
| handleSearch, | ||
| handleDepthChange, | ||
| applyFilters, | ||
| getFilteredNodes, | ||
| exportPNG, | ||
| exportJSON, | ||
| exportReport, | ||
| toggleFullscreen, | ||
| toggleTheme, | ||
| initTheme, | ||
| initKeyboardShortcuts, | ||
| currentFilters | ||
| }; | ||
| } |
| // src/dashboard/scripts/core.js | ||
| (function() { | ||
| 'use strict'; | ||
| window.clusters = []; | ||
| window.clusterer = null; | ||
| window.currentClusterMode = 'ecosystem'; | ||
| function init() { | ||
| console.log('🚀 DevCompass Dashboard v3.2.0 initializing...'); | ||
| if (typeof window.graphData === 'undefined' || !window.graphData) { | ||
| console.error('No graph data available'); | ||
| showEmptyState(); | ||
| return; | ||
| } | ||
| if (!window.graphData.nodes || !Array.isArray(window.graphData.nodes)) { | ||
| console.error('Invalid graph data structure'); | ||
| showEmptyState(); | ||
| return; | ||
| } | ||
| console.log(`📊 Loaded ${window.graphData.nodes.length} nodes, ${window.graphData.links?.length || 0} links`); | ||
| window.initTheme(); | ||
| window.initTooltip(); | ||
| window.initStats(); | ||
| initClustering(); | ||
| window.initKeyboardShortcuts(); | ||
| updateMetadata(); | ||
| window.switchLayout('tree'); | ||
| console.log('✅ Dashboard initialized successfully'); | ||
| } | ||
| function initClustering() { | ||
| if (typeof DependencyClusterer === 'undefined') { | ||
| console.warn('DependencyClusterer not available'); | ||
| return; | ||
| } | ||
| try { | ||
| window.clusterer = new DependencyClusterer(window.graphData.nodes, window.graphData.links); | ||
| updateClusters(); | ||
| } catch (error) { | ||
| console.error('Clustering initialization failed:', error); | ||
| } | ||
| } | ||
| function updateClusters() { | ||
| if (!window.clusterer) { | ||
| window.clusters = []; | ||
| renderClusterList(); | ||
| return; | ||
| } | ||
| try { | ||
| window.clusters = window.clusterer.clusterBy(window.currentClusterMode); | ||
| renderClusterList(); | ||
| window.updateStats(window.graphData.nodes, window.getFilteredNodes(window.graphData.nodes), window.clusters); | ||
| } catch (error) { | ||
| console.error('Cluster update failed:', error); | ||
| window.clusters = []; | ||
| renderClusterList(); | ||
| } | ||
| } | ||
| function renderClusterList() { | ||
| const container = document.getElementById('clusterList'); | ||
| if (!container) return; | ||
| if (window.clusters.length === 0) { | ||
| container.innerHTML = '<div style="text-align: center; color: var(--text-muted); font-size: 0.875rem; padding: 1rem;">No clusters available</div>'; | ||
| return; | ||
| } | ||
| container.innerHTML = ''; | ||
| window.clusters.forEach(cluster => { | ||
| const item = document.createElement('div'); | ||
| item.className = 'cluster-item'; | ||
| let statsHTML = ''; | ||
| if (cluster.stats.vulnerable > 0) { | ||
| statsHTML += `<span class="cluster-badge vulnerable">🔴 ${cluster.stats.vulnerable}</span>`; | ||
| } | ||
| if (cluster.stats.deprecated > 0) { | ||
| statsHTML += `<span class="cluster-badge deprecated">🟣 ${cluster.stats.deprecated}</span>`; | ||
| } | ||
| if (cluster.stats.outdated > 0) { | ||
| statsHTML += `<span class="cluster-badge outdated">🟡 ${cluster.stats.outdated}</span>`; | ||
| } | ||
| if (cluster.stats.healthy > 0) { | ||
| statsHTML += `<span class="cluster-badge healthy">🟢 ${cluster.stats.healthy}</span>`; | ||
| } | ||
| item.innerHTML = ` | ||
| <div class="cluster-header"> | ||
| <div class="cluster-title"> | ||
| <span class="cluster-icon">${cluster.icon || '📦'}</span> | ||
| <span>${window.truncateText(cluster.name, 20)}</span> | ||
| </div> | ||
| <span class="cluster-count">${cluster.stats.total}</span> | ||
| </div> | ||
| ${statsHTML ? `<div class="cluster-stats">${statsHTML}</div>` : ''} | ||
| `; | ||
| item.addEventListener('click', () => window.highlightCluster(cluster)); | ||
| container.appendChild(item); | ||
| }); | ||
| } | ||
| window.highlightCluster = function(cluster) { | ||
| const clusterNodeIds = new Set(cluster.nodes.map(n => n.id)); | ||
| const svg = d3.select('#graph'); | ||
| svg.selectAll('.node') | ||
| .transition() | ||
| .duration(300) | ||
| .style('opacity', d => { | ||
| const nodeId = d.data ? d.data.id : d.id; | ||
| return clusterNodeIds.has(nodeId) ? 1 : 0.3; | ||
| }); | ||
| svg.selectAll('.link') | ||
| .transition() | ||
| .duration(300) | ||
| .style('opacity', 0.2); | ||
| setTimeout(() => { | ||
| svg.selectAll('.node') | ||
| .transition() | ||
| .duration(500) | ||
| .style('opacity', 1); | ||
| svg.selectAll('.link') | ||
| .transition() | ||
| .duration(500) | ||
| .style('opacity', 0.6); | ||
| }, 3000); | ||
| }; | ||
| window.switchClusterMode = function(mode) { | ||
| window.currentClusterMode = mode; | ||
| document.querySelectorAll('.cluster-btn').forEach(btn => { | ||
| btn.classList.toggle('active', btn.dataset.mode === mode); | ||
| }); | ||
| updateClusters(); | ||
| }; | ||
| function updateMetadata() { | ||
| const metadata = window.graphData.metadata || {}; | ||
| const projectNameEl = document.getElementById('projectName'); | ||
| if (projectNameEl) { | ||
| projectNameEl.textContent = metadata.projectName || 'Unknown Project'; | ||
| } | ||
| const projectVersionEl = document.getElementById('projectVersion'); | ||
| if (projectVersionEl) { | ||
| projectVersionEl.textContent = metadata.projectVersion || '1.0.0'; | ||
| } | ||
| const totalDepsEl = document.getElementById('totalDeps'); | ||
| if (totalDepsEl) { | ||
| totalDepsEl.textContent = window.graphData.nodes.length; | ||
| } | ||
| } | ||
| function showEmptyState() { | ||
| const emptyState = document.getElementById('emptyState'); | ||
| if (emptyState) { | ||
| emptyState.style.display = 'flex'; | ||
| } | ||
| const graphSvg = document.getElementById('graph'); | ||
| if (graphSvg) { | ||
| graphSvg.style.display = 'none'; | ||
| } | ||
| } | ||
| if (document.readyState === 'loading') { | ||
| document.addEventListener('DOMContentLoaded', init); | ||
| } else { | ||
| init(); | ||
| } | ||
| window.DevCompass = { | ||
| version: '3.2.0', | ||
| get graphData() { return window.graphData; }, | ||
| get clusters() { return window.clusters; }, | ||
| get currentLayout() { return window.currentLayout; }, | ||
| switchLayout: window.switchLayout, | ||
| switchClusterMode: window.switchClusterMode, | ||
| updateStats: window.updateStats, | ||
| exportPNG: window.exportPNG, | ||
| exportJSON: window.exportJSON, | ||
| exportReport: window.exportReport | ||
| }; | ||
| })(); |
| // src/dashboard/scripts/layouts.js | ||
| const LayoutEngine = { | ||
| tree: function(svg, nodes, links) { | ||
| const width = svg.node().clientWidth; | ||
| const height = svg.node().clientHeight; | ||
| svg.selectAll('*').remove(); | ||
| const g = svg.append('g'); | ||
| const hierarchyRoot = this.buildHierarchyFast(nodes, links); | ||
| if (!hierarchyRoot) { | ||
| this.showEmptyState(); | ||
| return; | ||
| } | ||
| const root = d3.hierarchy(hierarchyRoot); | ||
| const treeLayout = d3.tree() | ||
| .size([height - 200, width - 400]) | ||
| .separation((a, b) => a.parent === b.parent ? 1.5 : 2); | ||
| treeLayout(root); | ||
| root.each(d => { | ||
| [d.x, d.y] = [d.y + 200, d.x + 100]; | ||
| }); | ||
| const linkData = root.links(); | ||
| const linkGroup = g.append('g').attr('class', 'links'); | ||
| linkGroup.selectAll('path') | ||
| .data(linkData) | ||
| .enter() | ||
| .append('path') | ||
| .attr('class', 'link') | ||
| .attr('d', d3.linkHorizontal().x(d => d.x).y(d => d.y)); | ||
| const nodeData = root.descendants(); | ||
| const nodeGroup = g.append('g').attr('class', 'nodes'); | ||
| const node = nodeGroup.selectAll('g') | ||
| .data(nodeData) | ||
| .enter() | ||
| .append('g') | ||
| .attr('class', 'node') | ||
| .attr('transform', d => `translate(${d.x},${d.y})`) | ||
| .on('mouseover', (e, d) => window.showTooltip(e, d)) | ||
| .on('mouseout', window.hideTooltip); | ||
| node.append('circle') | ||
| .attr('r', d => this.getNodeRadius(d.data)) | ||
| .attr('fill', d => window.getHealthColor(d.data)) | ||
| .attr('stroke', d => window.getNodeStroke(d.data)) | ||
| .attr('stroke-width', 2); | ||
| node.append('text') | ||
| .attr('class', 'node-label') | ||
| .attr('dy', d => d.data.type === 'root' ? -25 : -15) | ||
| .attr('text-anchor', 'middle') | ||
| .text(d => this.truncate(d.data.name || d.data.id, 20)); | ||
| window.initZoom(svg, g); | ||
| setTimeout(() => window.fitToScreen(), 50); | ||
| }, | ||
| force: function(svg, nodes, links) { | ||
| const width = svg.node().clientWidth; | ||
| const height = svg.node().clientHeight; | ||
| svg.selectAll('*').remove(); | ||
| const g = svg.append('g'); | ||
| const simNodes = nodes.map(n => ({...n})); | ||
| const simLinks = links.map(l => ({...l})); | ||
| const simulation = d3.forceSimulation(simNodes) | ||
| .force('link', d3.forceLink(simLinks) | ||
| .id(d => d.id) | ||
| .distance(80)) | ||
| .force('charge', d3.forceManyBody().strength(-250)) | ||
| .force('center', d3.forceCenter(width / 2, height / 2)) | ||
| .force('collision', d3.forceCollide().radius(20)) | ||
| .alphaDecay(0.05) | ||
| .velocityDecay(0.4); | ||
| const linkGroup = g.append('g'); | ||
| const link = linkGroup.selectAll('line') | ||
| .data(simLinks) | ||
| .enter() | ||
| .append('line') | ||
| .attr('class', 'link') | ||
| .attr('stroke-width', 1.5); | ||
| const nodeGroup = g.append('g'); | ||
| const node = nodeGroup.selectAll('g') | ||
| .data(simNodes) | ||
| .enter() | ||
| .append('g') | ||
| .attr('class', 'node') | ||
| .call(this.dragBehavior(simulation)) | ||
| .on('mouseover', (e, d) => { | ||
| window.showTooltip(e, d); | ||
| link.attr('stroke-opacity', l => | ||
| l.source.id === d.id || l.target.id === d.id ? 0.8 : 0.3 | ||
| ); | ||
| }) | ||
| .on('mouseout', () => { | ||
| window.hideTooltip(); | ||
| link.attr('stroke-opacity', 0.6); | ||
| }); | ||
| node.append('circle') | ||
| .attr('r', d => this.getNodeRadius(d)) | ||
| .attr('fill', d => window.getHealthColor(d)) | ||
| .attr('stroke', d => window.getNodeStroke(d)) | ||
| .attr('stroke-width', 2); | ||
| node.append('text') | ||
| .attr('class', 'node-label') | ||
| .attr('dy', -12) | ||
| .attr('text-anchor', 'middle') | ||
| .text(d => this.truncate(d.name || d.id, 15)); | ||
| simulation.on('tick', () => { | ||
| link | ||
| .attr('x1', d => d.source.x) | ||
| .attr('y1', d => d.source.y) | ||
| .attr('x2', d => d.target.x) | ||
| .attr('y2', d => d.target.y); | ||
| node.attr('transform', d => `translate(${d.x},${d.y})`); | ||
| }); | ||
| window.initZoom(svg, g); | ||
| }, | ||
| radial: function(svg, nodes, links) { | ||
| const width = svg.node().clientWidth; | ||
| const height = svg.node().clientHeight; | ||
| const centerX = width / 2; | ||
| const centerY = height / 2; | ||
| svg.selectAll('*').remove(); | ||
| const g = svg.append('g'); | ||
| const maxDepth = Math.max(...nodes.map(n => n.depth || 0), 1); | ||
| const minRadius = 80; | ||
| const maxRadius = Math.min(width, height) / 2 - 100; | ||
| const radiusStep = (maxRadius - minRadius) / Math.max(maxDepth, 1); | ||
| const getRadius = depth => depth === 0 ? 0 : minRadius + (depth - 1) * radiusStep + radiusStep / 2; | ||
| const nodesByDepth = new Map(); | ||
| nodes.forEach(n => { | ||
| const d = n.depth || 0; | ||
| if (!nodesByDepth.has(d)) nodesByDepth.set(d, []); | ||
| nodesByDepth.get(d).push(n); | ||
| }); | ||
| const positioned = nodes.map(node => { | ||
| const depth = node.depth || 0; | ||
| if (depth === 0) return {...node, x: centerX, y: centerY, angle: 0}; | ||
| const atDepth = nodesByDepth.get(depth); | ||
| const idx = atDepth.indexOf(node); | ||
| const angle = (2 * Math.PI * idx / atDepth.length) + (depth * 0.3); | ||
| const r = getRadius(depth); | ||
| return { | ||
| ...node, | ||
| x: centerX + r * Math.cos(angle), | ||
| y: centerY + r * Math.sin(angle), | ||
| angle | ||
| }; | ||
| }); | ||
| const nodeMap = new Map(positioned.map(n => [n.id, n])); | ||
| const circleGroup = g.append('g'); | ||
| for (let d = 1; d <= maxDepth; d++) { | ||
| const r = getRadius(d); | ||
| circleGroup.append('circle') | ||
| .attr('class', 'depth-circle') | ||
| .attr('cx', centerX) | ||
| .attr('cy', centerY) | ||
| .attr('r', r); | ||
| } | ||
| const linkGroup = g.append('g'); | ||
| linkGroup.selectAll('path') | ||
| .data(links) | ||
| .enter() | ||
| .append('path') | ||
| .attr('class', 'link') | ||
| .attr('d', d => { | ||
| const src = nodeMap.get(typeof d.source === 'object' ? d.source.id : d.source); | ||
| const tgt = nodeMap.get(typeof d.target === 'object' ? d.target.id : d.target); | ||
| if (!src || !tgt) return ''; | ||
| const mx = (src.x + tgt.x) / 2; | ||
| const my = (src.y + tgt.y) / 2; | ||
| const cx = mx + (centerX - mx) * 0.2; | ||
| const cy = my + (centerY - my) * 0.2; | ||
| return `M${src.x},${src.y}Q${cx},${cy} ${tgt.x},${tgt.y}`; | ||
| }); | ||
| const nodeGroup = g.append('g'); | ||
| const node = nodeGroup.selectAll('g') | ||
| .data(positioned) | ||
| .enter() | ||
| .append('g') | ||
| .attr('class', 'node') | ||
| .attr('transform', d => `translate(${d.x},${d.y})`) | ||
| .on('mouseover', (e, d) => window.showTooltip(e, d)) | ||
| .on('mouseout', window.hideTooltip); | ||
| node.append('circle') | ||
| .attr('r', d => this.getNodeRadius(d)) | ||
| .attr('fill', d => window.getHealthColor(d)) | ||
| .attr('stroke', d => window.getNodeStroke(d)) | ||
| .attr('stroke-width', 2); | ||
| node.append('text') | ||
| .attr('class', 'node-label') | ||
| .attr('dy', d => d.depth === 0 ? -28 : (Math.sin(d.angle) > 0.3 ? 20 : -12)) | ||
| .attr('text-anchor', d => d.depth === 0 ? 'middle' : (Math.cos(d.angle) > 0.3 ? 'start' : 'end')) | ||
| .text(d => this.truncate(d.name || d.id, 15)); | ||
| window.initZoom(svg, g); | ||
| }, | ||
| conflict: function(svg, nodes, links) { | ||
| const conflicts = nodes.filter(n => | ||
| n.type === 'root' || | ||
| (n.issues && n.issues.length > 0) || | ||
| (n.healthScore || 10) < 7 || | ||
| n.isVulnerable || n.isDeprecated || n.isOutdated | ||
| ); | ||
| if (conflicts.length <= 1) { | ||
| this.showNoConflicts(); | ||
| return; | ||
| } | ||
| const ids = new Set(conflicts.map(n => n.id)); | ||
| const conflictLinks = links.filter(l => { | ||
| const sid = typeof l.source === 'object' ? l.source.id : l.source; | ||
| const tid = typeof l.target === 'object' ? l.target.id : l.target; | ||
| return ids.has(sid) && ids.has(tid); | ||
| }); | ||
| this.force(svg, conflicts, conflictLinks); | ||
| }, | ||
| analytics: function(container, nodes, links) { | ||
| const svg = document.getElementById('graph'); | ||
| if (svg) svg.style.display = 'none'; | ||
| const view = document.getElementById('analyticsView'); | ||
| if (!view) return; | ||
| view.style.display = 'block'; | ||
| const grid = view.querySelector('.analytics-grid'); | ||
| if (!grid) return; | ||
| const stats = window.calculateStats(nodes); | ||
| const healthDist = this.getHealthDist(nodes); | ||
| const depthDist = this.getDepthDist(nodes); | ||
| grid.innerHTML = ` | ||
| ${this.summaryCard(stats)} | ||
| ${this.healthCard(healthDist)} | ||
| ${this.depthCard(depthDist)} | ||
| ${this.issuesCard(nodes)} | ||
| ${this.topPackagesCard(nodes)} | ||
| `; | ||
| }, | ||
| buildHierarchyFast: function(nodes, links) { | ||
| if (!nodes.length) return null; | ||
| const map = new Map(); | ||
| nodes.forEach(n => map.set(n.id, {...n, children: []})); | ||
| const root = nodes.find(n => n.type === 'root' || n.depth === 0) || nodes[0]; | ||
| const added = new Set(); | ||
| links.forEach(l => { | ||
| const sid = typeof l.source === 'object' ? l.source.id : l.source; | ||
| const tid = typeof l.target === 'object' ? l.target.id : l.target; | ||
| const parent = map.get(sid); | ||
| const child = map.get(tid); | ||
| if (parent && child && !added.has(tid)) { | ||
| parent.children.push(child); | ||
| added.add(tid); | ||
| } | ||
| }); | ||
| return map.get(root.id); | ||
| }, | ||
| getNodeRadius: function(node) { | ||
| if (node.type === 'root' || node.depth === 0) return 20; | ||
| if (node.depth === 1) return 12; | ||
| return node.depth === 2 ? 8 : 6; | ||
| }, | ||
| truncate: function(text, max) { | ||
| return text && text.length > max ? text.slice(0, max - 3) + '...' : text || ''; | ||
| }, | ||
| dragBehavior: function(simulation) { | ||
| return d3.drag() | ||
| .on('start', e => { | ||
| if (!e.active) simulation.alphaTarget(0.3).restart(); | ||
| e.subject.fx = e.subject.x; | ||
| e.subject.fy = e.subject.y; | ||
| }) | ||
| .on('drag', e => { | ||
| e.subject.fx = e.x; | ||
| e.subject.fy = e.y; | ||
| }) | ||
| .on('end', e => { | ||
| if (!e.active) simulation.alphaTarget(0); | ||
| e.subject.fx = null; | ||
| e.subject.fy = null; | ||
| }); | ||
| }, | ||
| showEmptyState: function() { | ||
| const el = document.getElementById('emptyState'); | ||
| if (el) el.style.display = 'block'; | ||
| }, | ||
| showNoConflicts: function() { | ||
| const svg = document.getElementById('graph'); | ||
| if (svg) { | ||
| const w = svg.clientWidth / 2; | ||
| const h = svg.clientHeight / 2; | ||
| svg.innerHTML = ` | ||
| <g transform="translate(${w},${h})"> | ||
| <text text-anchor="middle" style="font-size:3rem;fill:var(--health-excellent)" y="-20">🎉</text> | ||
| <text text-anchor="middle" style="font-size:1.5rem;fill:var(--text-primary);font-weight:600" y="20">No Conflicts Found!</text> | ||
| <text text-anchor="middle" style="font-size:1rem;fill:var(--text-secondary)" y="50">All dependencies are healthy</text> | ||
| </g> | ||
| `; | ||
| } | ||
| }, | ||
| getHealthDist: function(nodes) { | ||
| const d = {excellent: 0, good: 0, caution: 0, warning: 0, critical: 0}; | ||
| nodes.forEach(n => { | ||
| if (n.type === 'root') return; | ||
| const s = n.healthScore || 8; | ||
| if (s >= 9) d.excellent++; | ||
| else if (s >= 7) d.good++; | ||
| else if (s >= 5) d.caution++; | ||
| else if (s >= 3) d.warning++; | ||
| else d.critical++; | ||
| }); | ||
| return d; | ||
| }, | ||
| getDepthDist: function(nodes) { | ||
| const d = {}; | ||
| nodes.forEach(n => { | ||
| const depth = n.depth || 0; | ||
| d[depth] = (d[depth] || 0) + 1; | ||
| }); | ||
| return d; | ||
| }, | ||
| summaryCard: function(s) { | ||
| return ` | ||
| <div class="analytics-card"> | ||
| <h3 class="analytics-card-title">📊 Overview</h3> | ||
| <div class="analytics-stats"> | ||
| <div class="analytics-stat-item"><div class="analytics-stat-value">${s.total}</div><div class="analytics-stat-label">Total</div></div> | ||
| <div class="analytics-stat-item"><div class="analytics-stat-value stat-success">${s.healthy}</div><div class="analytics-stat-label">Healthy</div></div> | ||
| <div class="analytics-stat-item"><div class="analytics-stat-value stat-danger">${s.vulnerable}</div><div class="analytics-stat-label">Vulnerable</div></div> | ||
| <div class="analytics-stat-item"><div class="analytics-stat-value stat-warning">${s.outdated}</div><div class="analytics-stat-label">Outdated</div></div> | ||
| </div> | ||
| </div> | ||
| `; | ||
| }, | ||
| healthCard: function(d) { | ||
| const total = Object.values(d).reduce((a, b) => a + b, 0); | ||
| const bar = (label, count, color) => { | ||
| const pct = total > 0 ? Math.round(count / total * 100) : 0; | ||
| return ` | ||
| <div class="analytics-bar-item"> | ||
| <div class="analytics-bar-label">${label}</div> | ||
| <div class="analytics-bar-container"> | ||
| <div class="analytics-bar-fill" style="width:${pct}%;background:${color}"></div> | ||
| <span class="analytics-bar-text">${count} (${pct}%)</span> | ||
| </div> | ||
| </div> | ||
| `; | ||
| }; | ||
| return ` | ||
| <div class="analytics-card"> | ||
| <h3 class="analytics-card-title">💊 Health Distribution</h3> | ||
| <div class="analytics-distribution"> | ||
| ${bar('Excellent', d.excellent, 'var(--health-excellent)')} | ||
| ${bar('Good', d.good, 'var(--health-good)')} | ||
| ${bar('Caution', d.caution, 'var(--health-caution)')} | ||
| ${bar('Warning', d.warning, 'var(--health-warning)')} | ||
| ${bar('Critical', d.critical, 'var(--health-critical)')} | ||
| </div> | ||
| </div> | ||
| `; | ||
| }, | ||
| depthCard: function(d) { | ||
| const max = Math.max(...Object.values(d)); | ||
| const items = Object.entries(d).sort((a, b) => +a[0] - +b[0]).map(([depth, count]) => ` | ||
| <div class="analytics-depth-item"> | ||
| <span class="analytics-depth-label">Depth ${depth}</span> | ||
| <div class="analytics-depth-bar"> | ||
| <div class="analytics-depth-fill" style="width:${count/max*100}%"></div> | ||
| <span class="analytics-depth-count">${count}</span> | ||
| </div> | ||
| </div> | ||
| `).join(''); | ||
| return ` | ||
| <div class="analytics-card"> | ||
| <h3 class="analytics-card-title">📏 Depth Distribution</h3> | ||
| <div class="analytics-depth-chart">${items}</div> | ||
| </div> | ||
| `; | ||
| }, | ||
| issuesCard: function(nodes) { | ||
| const types = {}; | ||
| nodes.forEach(n => { | ||
| if (!n.issues) return; | ||
| n.issues.forEach(i => { | ||
| const t = i.type || 'other'; | ||
| types[t] = (types[t] || 0) + 1; | ||
| }); | ||
| }); | ||
| const items = Object.entries(types).sort((a, b) => b[1] - a[1]).map(([type, count]) => ` | ||
| <div class="analytics-issue-item"> | ||
| <span class="analytics-issue-type">${type}</span> | ||
| <span class="analytics-issue-count">${count}</span> | ||
| </div> | ||
| `).join(''); | ||
| return ` | ||
| <div class="analytics-card"> | ||
| <h3 class="analytics-card-title">🚨 Issues by Type</h3> | ||
| <div class="analytics-issues-list">${items || '<p style="color:var(--text-muted);text-align:center">No issues</p>'}</div> | ||
| </div> | ||
| `; | ||
| }, | ||
| topPackagesCard: function(nodes) { | ||
| const sorted = nodes.filter(n => n.type !== 'root') | ||
| .sort((a, b) => (a.healthScore || 8) - (b.healthScore || 8)) | ||
| .slice(0, 10); | ||
| const items = sorted.map(n => ` | ||
| <div class="analytics-package-item"> | ||
| <div class="analytics-package-name">${this.truncate(n.name || n.id, 25)}</div> | ||
| <div class="analytics-package-score" style="color:${window.getHealthColor(n)}">${n.healthScore || 8}/10</div> | ||
| </div> | ||
| `).join(''); | ||
| return ` | ||
| <div class="analytics-card"> | ||
| <h3 class="analytics-card-title">⚠️ Needs Attention</h3> | ||
| <div class="analytics-packages-list">${items}</div> | ||
| </div> | ||
| `; | ||
| } | ||
| }; | ||
| let currentLayout = 'tree'; | ||
| function switchLayout(name) { | ||
| if (!window.graphData || !window.graphData.nodes || !window.graphData.nodes.length) { | ||
| console.warn('No graph data'); | ||
| return; | ||
| } | ||
| currentLayout = name; | ||
| document.querySelectorAll('.tab-btn').forEach(b => { | ||
| b.classList.toggle('active', b.dataset.layout === name); | ||
| }); | ||
| const svg = document.getElementById('graph'); | ||
| const view = document.getElementById('analyticsView'); | ||
| const loading = document.getElementById('loading'); | ||
| if (name === 'analytics') { | ||
| if (svg) svg.style.display = 'none'; | ||
| if (view) view.style.display = 'block'; | ||
| LayoutEngine.analytics(view, window.graphData.nodes, window.graphData.links); | ||
| } else { | ||
| if (svg) svg.style.display = 'block'; | ||
| if (view) view.style.display = 'none'; | ||
| if (loading) loading.classList.add('visible'); | ||
| requestAnimationFrame(() => { | ||
| renderCurrentLayout(); | ||
| if (loading) loading.classList.remove('visible'); | ||
| }); | ||
| } | ||
| } | ||
| function renderCurrentLayout() { | ||
| if (!window.graphData || currentLayout === 'analytics') return; | ||
| const svg = d3.select('#graph'); | ||
| const filtered = window.getFilteredNodes(window.graphData.nodes); | ||
| const ids = new Set(filtered.map(n => n.id)); | ||
| const filteredLinks = window.graphData.links.filter(l => { | ||
| const sid = typeof l.source === 'object' ? l.source.id : l.source; | ||
| const tid = typeof l.target === 'object' ? l.target.id : l.target; | ||
| return ids.has(sid) && ids.has(tid); | ||
| }); | ||
| LayoutEngine[currentLayout](svg, filtered, filteredLinks); | ||
| const clusterArray = window.clusters || []; | ||
| window.updateStats(window.graphData.nodes, filtered, clusterArray); | ||
| } | ||
| // Export to window | ||
| window.LayoutEngine = LayoutEngine; | ||
| window.switchLayout = switchLayout; | ||
| window.renderCurrentLayout = renderCurrentLayout; | ||
| window.currentLayout = currentLayout; | ||
| if (typeof module !== 'undefined' && module.exports) { | ||
| module.exports = {LayoutEngine, switchLayout, renderCurrentLayout, currentLayout}; | ||
| } |
| // src/dashboard/scripts/stats.js | ||
| class StatsManager { | ||
| constructor() { | ||
| this.stats = { | ||
| total: 0, | ||
| visible: 0, | ||
| clusters: 0, | ||
| vulnerable: 0, | ||
| deprecated: 0, | ||
| outdated: 0, | ||
| unused: 0, | ||
| healthy: 0, | ||
| maxDepth: 0 | ||
| }; | ||
| } | ||
| calculate(nodes, visibleNodes = null, clusters = []) { | ||
| this.stats.total = nodes.length; | ||
| this.stats.visible = visibleNodes ? visibleNodes.length : nodes.length; | ||
| this.stats.clusters = clusters.length; | ||
| this.stats.vulnerable = 0; | ||
| this.stats.deprecated = 0; | ||
| this.stats.outdated = 0; | ||
| this.stats.unused = 0; | ||
| this.stats.healthy = 0; | ||
| this.stats.maxDepth = 0; | ||
| nodes.forEach(node => { | ||
| if (node.type === 'root') return; | ||
| if (node.isVulnerable) this.stats.vulnerable++; | ||
| if (node.isDeprecated) this.stats.deprecated++; | ||
| if (node.isOutdated) this.stats.outdated++; | ||
| if (node.isUnused) this.stats.unused++; | ||
| const score = node.healthScore || 8; | ||
| if (score >= 7 && !node.isVulnerable && !node.isDeprecated) { | ||
| this.stats.healthy++; | ||
| } | ||
| this.stats.maxDepth = Math.max(this.stats.maxDepth, node.depth || 0); | ||
| }); | ||
| return this.stats; | ||
| } | ||
| updateDisplay() { | ||
| this.updateStat('stat-total', this.stats.total); | ||
| this.updateStat('stat-visible', this.stats.visible); | ||
| this.updateStat('stat-clusters', this.stats.clusters); | ||
| this.updateStat('stat-vulnerable', this.stats.vulnerable); | ||
| this.updateStat('stat-outdated', this.stats.outdated); | ||
| this.updateStat('stat-healthy', this.stats.healthy); | ||
| this.updateHeaderMeta(); | ||
| } | ||
| updateStat(elementId, value) { | ||
| const element = document.getElementById(elementId); | ||
| if (element) { | ||
| element.textContent = window.formatNumber(value); | ||
| } | ||
| } | ||
| updateHeaderMeta() { | ||
| const totalDepsEl = document.getElementById('totalDeps'); | ||
| if (totalDepsEl) { | ||
| totalDepsEl.textContent = this.stats.total; | ||
| } | ||
| } | ||
| getStats() { | ||
| return { ...this.stats }; | ||
| } | ||
| getHealthDistribution(nodes) { | ||
| const distribution = { | ||
| excellent: 0, | ||
| good: 0, | ||
| caution: 0, | ||
| warning: 0, | ||
| critical: 0 | ||
| }; | ||
| nodes.forEach(node => { | ||
| if (node.type === 'root') return; | ||
| const score = node.healthScore || 8; | ||
| if (score >= 9) distribution.excellent++; | ||
| else if (score >= 7) distribution.good++; | ||
| else if (score >= 5) distribution.caution++; | ||
| else if (score >= 3) distribution.warning++; | ||
| else distribution.critical++; | ||
| }); | ||
| return distribution; | ||
| } | ||
| getDepthDistribution(nodes) { | ||
| const distribution = {}; | ||
| nodes.forEach(node => { | ||
| const depth = node.depth || 0; | ||
| distribution[depth] = (distribution[depth] || 0) + 1; | ||
| }); | ||
| return distribution; | ||
| } | ||
| getIssueSummary(nodes) { | ||
| const summary = { | ||
| security: 0, | ||
| quality: 0, | ||
| license: 0, | ||
| ecosystem: 0 | ||
| }; | ||
| nodes.forEach(node => { | ||
| if (!node.issues || !Array.isArray(node.issues)) return; | ||
| node.issues.forEach(issue => { | ||
| const type = issue.type || 'other'; | ||
| if (summary.hasOwnProperty(type)) { | ||
| summary[type]++; | ||
| } | ||
| }); | ||
| }); | ||
| return summary; | ||
| } | ||
| exportAsCSV() { | ||
| const rows = [ | ||
| ['Metric', 'Value'], | ||
| ['Total Packages', this.stats.total], | ||
| ['Visible Packages', this.stats.visible], | ||
| ['Clusters', this.stats.clusters], | ||
| ['Vulnerable', this.stats.vulnerable], | ||
| ['Deprecated', this.stats.deprecated], | ||
| ['Outdated', this.stats.outdated], | ||
| ['Unused', this.stats.unused], | ||
| ['Healthy', this.stats.healthy], | ||
| ['Max Depth', this.stats.maxDepth] | ||
| ]; | ||
| return rows.map(row => row.join(',')).join('\n'); | ||
| } | ||
| } | ||
| let statsManager; | ||
| function initStats() { | ||
| statsManager = new StatsManager(); | ||
| return statsManager; | ||
| } | ||
| function updateStats(nodes, visibleNodes, clusters) { | ||
| if (!statsManager) statsManager = new StatsManager(); | ||
| statsManager.calculate(nodes, visibleNodes, clusters); | ||
| statsManager.updateDisplay(); | ||
| } | ||
| function getStats() { | ||
| if (!statsManager) statsManager = new StatsManager(); | ||
| return statsManager.getStats(); | ||
| } | ||
| // Export to window | ||
| window.StatsManager = StatsManager; | ||
| window.initStats = initStats; | ||
| window.updateStats = updateStats; | ||
| window.getStats = getStats; | ||
| window.statsManager = null; | ||
| if (typeof module !== 'undefined' && module.exports) { | ||
| module.exports = { | ||
| StatsManager, | ||
| initStats, | ||
| updateStats, | ||
| getStats | ||
| }; | ||
| } |
| // src/dashboard/scripts/tooltip.js | ||
| class Tooltip { | ||
| constructor(elementId = 'tooltip') { | ||
| this.element = document.getElementById(elementId); | ||
| this.visible = false; | ||
| } | ||
| show(event, node) { | ||
| if (!this.element) return; | ||
| const data = node.data || node; | ||
| const score = data.healthScore || 8; | ||
| const issues = data.issues || []; | ||
| let content = `<div class="tooltip-title">${window.truncateText(data.name || data.id, 30)}</div>`; | ||
| content += `<div class="tooltip-content">`; | ||
| content += `<div class="tooltip-row"> | ||
| <span class="tooltip-label">Version</span> | ||
| <span class="tooltip-value">${data.version || 'N/A'}</span> | ||
| </div>`; | ||
| content += `<div class="tooltip-row"> | ||
| <span class="tooltip-label">Health Score</span> | ||
| <span class="tooltip-value">${score}/10</span> | ||
| </div>`; | ||
| content += `<div class="tooltip-row"> | ||
| <span class="tooltip-label">Depth</span> | ||
| <span class="tooltip-value">${data.depth || 0}</span> | ||
| </div>`; | ||
| if (data.type) { | ||
| content += `<div class="tooltip-row"> | ||
| <span class="tooltip-label">Type</span> | ||
| <span class="tooltip-value">${data.type}</span> | ||
| </div>`; | ||
| } | ||
| if (node.children || node._children) { | ||
| const childCount = (node.children || node._children || []).length; | ||
| content += `<div class="tooltip-row"> | ||
| <span class="tooltip-label">Children</span> | ||
| <span class="tooltip-value">${childCount}</span> | ||
| </div>`; | ||
| } | ||
| if (issues.length > 0) { | ||
| content += `<div style="margin-top: 0.5rem; padding-top: 0.5rem; border-top: 1px solid var(--border-color);">`; | ||
| issues.forEach(issue => { | ||
| const badgeClass = this.getIssueBadgeClass(issue.type); | ||
| content += `<span class="tooltip-badge ${badgeClass}">${issue.title || issue.type}</span>`; | ||
| }); | ||
| content += `</div>`; | ||
| } | ||
| if (data.isVulnerable) { | ||
| content += `<span class="tooltip-badge critical">🔴 Vulnerable</span>`; | ||
| } | ||
| if (data.isDeprecated) { | ||
| content += `<span class="tooltip-badge deprecated">🟣 Deprecated</span>`; | ||
| } | ||
| if (data.isOutdated) { | ||
| content += `<span class="tooltip-badge warning">🟡 Outdated</span>`; | ||
| } | ||
| if (data.isUnused) { | ||
| content += `<span class="tooltip-badge info">⚪ Unused</span>`; | ||
| } | ||
| content += `</div>`; | ||
| this.element.innerHTML = content; | ||
| this.position(event); | ||
| this.element.classList.add('visible'); | ||
| this.visible = true; | ||
| } | ||
| hide() { | ||
| if (!this.element) return; | ||
| this.element.classList.remove('visible'); | ||
| this.visible = false; | ||
| } | ||
| position(event) { | ||
| if (!this.element) return; | ||
| const offset = 15; | ||
| const tooltipWidth = this.element.offsetWidth || 300; | ||
| const tooltipHeight = this.element.offsetHeight || 200; | ||
| let x = event.pageX + offset; | ||
| let y = event.pageY - offset; | ||
| const viewportWidth = window.innerWidth; | ||
| const viewportHeight = window.innerHeight; | ||
| if (x + tooltipWidth > viewportWidth) { | ||
| x = event.pageX - tooltipWidth - offset; | ||
| } | ||
| if (y + tooltipHeight > viewportHeight) { | ||
| y = event.pageY - tooltipHeight - offset; | ||
| } | ||
| if (y < 0) { | ||
| y = event.pageY + offset; | ||
| } | ||
| this.element.style.left = x + 'px'; | ||
| this.element.style.top = y + 'px'; | ||
| } | ||
| getIssueBadgeClass(issueType) { | ||
| const typeMap = { | ||
| 'security': 'critical', | ||
| 'vulnerability': 'critical', | ||
| 'deprecated': 'deprecated', | ||
| 'outdated': 'warning', | ||
| 'unused': 'info' | ||
| }; | ||
| return typeMap[issueType] || 'info'; | ||
| } | ||
| updatePosition(event) { | ||
| if (this.visible) { | ||
| this.position(event); | ||
| } | ||
| } | ||
| } | ||
| let tooltipInstance; | ||
| function initTooltip() { | ||
| tooltipInstance = new Tooltip(); | ||
| return tooltipInstance; | ||
| } | ||
| function showTooltip(event, node) { | ||
| if (!tooltipInstance) tooltipInstance = new Tooltip(); | ||
| tooltipInstance.show(event, node); | ||
| } | ||
| function hideTooltip() { | ||
| if (tooltipInstance) tooltipInstance.hide(); | ||
| } | ||
| function updateTooltipPosition(event) { | ||
| if (tooltipInstance) tooltipInstance.updatePosition(event); | ||
| } | ||
| // Export to window | ||
| window.Tooltip = Tooltip; | ||
| window.initTooltip = initTooltip; | ||
| window.showTooltip = showTooltip; | ||
| window.hideTooltip = hideTooltip; | ||
| window.updateTooltipPosition = updateTooltipPosition; | ||
| window.tooltipInstance = null; | ||
| if (typeof module !== 'undefined' && module.exports) { | ||
| module.exports = { | ||
| Tooltip, | ||
| initTooltip, | ||
| showTooltip, | ||
| hideTooltip, | ||
| updateTooltipPosition | ||
| }; | ||
| } |
| // src/dashboard/scripts/utils.js | ||
| function getHealthColor(node) { | ||
| if (node.type === 'root' || node.depth === 0) { | ||
| return 'var(--root-color)'; | ||
| } | ||
| const score = node.healthScore || 8; | ||
| if (score >= 9) return 'var(--health-excellent)'; | ||
| if (score >= 7) return 'var(--health-good)'; | ||
| if (score >= 5) return 'var(--health-caution)'; | ||
| if (score >= 3) return 'var(--health-warning)'; | ||
| return 'var(--health-critical)'; | ||
| } | ||
| function getNodeRadius(node) { | ||
| if (node.type === 'root' || node.depth === 0) return 20; | ||
| if (node.depth === 1) return 12; | ||
| if (node.depth === 2) return 8; | ||
| return 6; | ||
| } | ||
| function getNodeStroke(node) { | ||
| if (node.type === 'root') return 'var(--accent-blue)'; | ||
| return 'var(--bg-primary)'; | ||
| } | ||
| function buildHierarchy(nodes, links) { | ||
| if (!Array.isArray(nodes) || nodes.length === 0) return null; | ||
| const nodeMap = new Map(); | ||
| nodes.forEach(n => { | ||
| nodeMap.set(n.id, { ...n, children: [] }); | ||
| }); | ||
| const root = nodes.find(n => n.type === 'root' || n.depth === 0) || nodes[0]; | ||
| const childrenAdded = new Set(); | ||
| links.forEach(link => { | ||
| const sourceId = typeof link.source === 'object' ? link.source.id : link.source; | ||
| const targetId = typeof link.target === 'object' ? link.target.id : link.target; | ||
| const parent = nodeMap.get(sourceId); | ||
| const child = nodeMap.get(targetId); | ||
| if (parent && child && !childrenAdded.has(targetId)) { | ||
| parent.children.push(child); | ||
| childrenAdded.add(targetId); | ||
| } | ||
| }); | ||
| return nodeMap.get(root.id); | ||
| } | ||
| function formatNumber(num) { | ||
| if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'; | ||
| if (num >= 1000) return (num / 1000).toFixed(1) + 'K'; | ||
| return num.toString(); | ||
| } | ||
| function truncateText(text, maxLength = 20) { | ||
| if (!text) return ''; | ||
| if (text.length <= maxLength) return text; | ||
| return text.substring(0, maxLength - 3) + '...'; | ||
| } | ||
| function debounce(func, wait = 300) { | ||
| let timeout; | ||
| return function executedFunction(...args) { | ||
| const later = () => { | ||
| clearTimeout(timeout); | ||
| func(...args); | ||
| }; | ||
| clearTimeout(timeout); | ||
| timeout = setTimeout(later, wait); | ||
| }; | ||
| } | ||
| function deepClone(obj) { | ||
| return JSON.parse(JSON.stringify(obj)); | ||
| } | ||
| function nodeMatchesFilters(node, filters) { | ||
| if (filters.health && filters.health !== 'all') { | ||
| const score = node.healthScore || 8; | ||
| switch (filters.health) { | ||
| case 'excellent': | ||
| if (score < 9) return false; | ||
| break; | ||
| case 'good': | ||
| if (score < 7 || score >= 9) return false; | ||
| break; | ||
| case 'caution': | ||
| if (score < 5 || score >= 7) return false; | ||
| break; | ||
| case 'warning': | ||
| if (score < 3 || score >= 5) return false; | ||
| break; | ||
| case 'critical': | ||
| if (score >= 3) return false; | ||
| break; | ||
| } | ||
| } | ||
| if (filters.maxDepth && filters.maxDepth !== 10) { | ||
| if ((node.depth || 0) > filters.maxDepth) return false; | ||
| } | ||
| if (filters.searchTerm) { | ||
| const name = (node.name || node.id || '').toLowerCase(); | ||
| if (!name.includes(filters.searchTerm.toLowerCase())) return false; | ||
| } | ||
| return true; | ||
| } | ||
| function calculateStats(nodes) { | ||
| const stats = { | ||
| total: nodes.length, | ||
| vulnerable: 0, | ||
| deprecated: 0, | ||
| outdated: 0, | ||
| unused: 0, | ||
| healthy: 0, | ||
| maxDepth: 0 | ||
| }; | ||
| nodes.forEach(node => { | ||
| if (node.type === 'root') return; | ||
| if (node.isVulnerable) stats.vulnerable++; | ||
| if (node.isDeprecated) stats.deprecated++; | ||
| if (node.isOutdated) stats.outdated++; | ||
| if (node.isUnused) stats.unused++; | ||
| const score = node.healthScore || 8; | ||
| if (score >= 7 && !node.isVulnerable && !node.isDeprecated) { | ||
| stats.healthy++; | ||
| } | ||
| stats.maxDepth = Math.max(stats.maxDepth, node.depth || 0); | ||
| }); | ||
| return stats; | ||
| } | ||
| function exportAsJSON(data, filename = 'devcompass-export.json') { | ||
| const dataStr = JSON.stringify(data, null, 2); | ||
| const dataBlob = new Blob([dataStr], { type: 'application/json' }); | ||
| const url = URL.createObjectURL(dataBlob); | ||
| const link = document.createElement('a'); | ||
| link.href = url; | ||
| link.download = filename; | ||
| link.click(); | ||
| URL.revokeObjectURL(url); | ||
| } | ||
| function getContrastColor(bgColor) { | ||
| const color = bgColor.replace('var(--', '').replace(')', ''); | ||
| const darkColors = ['bg-primary', 'bg-secondary', 'bg-tertiary', 'accent-blue']; | ||
| return darkColors.includes(color) ? '#ffffff' : '#000000'; | ||
| } | ||
| const storage = { | ||
| set(key, value) { | ||
| try { | ||
| localStorage.setItem(`devcompass_${key}`, JSON.stringify(value)); | ||
| } catch (e) { | ||
| console.warn('LocalStorage not available:', e); | ||
| } | ||
| }, | ||
| get(key, defaultValue = null) { | ||
| try { | ||
| const item = localStorage.getItem(`devcompass_${key}`); | ||
| return item ? JSON.parse(item) : defaultValue; | ||
| } catch (e) { | ||
| console.warn('LocalStorage not available:', e); | ||
| return defaultValue; | ||
| } | ||
| }, | ||
| remove(key) { | ||
| try { | ||
| localStorage.removeItem(`devcompass_${key}`); | ||
| } catch (e) { | ||
| console.warn('LocalStorage not available:', e); | ||
| } | ||
| } | ||
| }; | ||
| // Export to window for global access | ||
| window.getHealthColor = getHealthColor; | ||
| window.getNodeRadius = getNodeRadius; | ||
| window.getNodeStroke = getNodeStroke; | ||
| window.buildHierarchy = buildHierarchy; | ||
| window.formatNumber = formatNumber; | ||
| window.truncateText = truncateText; | ||
| window.debounce = debounce; | ||
| window.deepClone = deepClone; | ||
| window.nodeMatchesFilters = nodeMatchesFilters; | ||
| window.calculateStats = calculateStats; | ||
| window.exportAsJSON = exportAsJSON; | ||
| window.getContrastColor = getContrastColor; | ||
| window.storage = storage; | ||
| if (typeof module !== 'undefined' && module.exports) { | ||
| module.exports = { | ||
| getHealthColor, | ||
| getNodeRadius, | ||
| getNodeStroke, | ||
| buildHierarchy, | ||
| formatNumber, | ||
| truncateText, | ||
| debounce, | ||
| deepClone, | ||
| nodeMatchesFilters, | ||
| calculateStats, | ||
| exportAsJSON, | ||
| getContrastColor, | ||
| storage | ||
| }; | ||
| } |
| /* src/dashboard/styles/base.css */ | ||
| :root { | ||
| /* Colors - Dark Theme */ | ||
| --bg-primary: #0a0e1a; | ||
| --bg-secondary: #1a1f35; | ||
| --bg-tertiary: #252b42; | ||
| --bg-hover: #2d3548; | ||
| --text-primary: #e0e6ed; | ||
| --text-secondary: #8b92a7; | ||
| --text-muted: #64748b; | ||
| --border-color: #2a3142; | ||
| --border-hover: #3b82f6; | ||
| /* Accent Colors */ | ||
| --accent-blue: #3b82f6; | ||
| --accent-cyan: #06b6d4; | ||
| --accent-purple: #8b5cf6; | ||
| --accent-pink: #ec4899; | ||
| /* Health Status Colors */ | ||
| --health-excellent: #10b981; | ||
| --health-good: #84cc16; | ||
| --health-caution: #eab308; | ||
| --health-warning: #f97316; | ||
| --health-critical: #ef4444; | ||
| /* Special Colors */ | ||
| --root-color: #60a5fa; | ||
| /* Spacing */ | ||
| --spacing-xs: 0.25rem; | ||
| --spacing-sm: 0.5rem; | ||
| --spacing-md: 1rem; | ||
| --spacing-lg: 1.5rem; | ||
| --spacing-xl: 2rem; | ||
| /* Border Radius */ | ||
| --radius-sm: 4px; | ||
| --radius-md: 8px; | ||
| --radius-lg: 12px; | ||
| --radius-xl: 16px; | ||
| /* Shadows */ | ||
| --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2); | ||
| --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.3); | ||
| --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.4); | ||
| /* Transitions */ | ||
| --transition-fast: 0.15s ease; | ||
| --transition-normal: 0.2s ease; | ||
| --transition-slow: 0.3s ease; | ||
| /* Z-Index Layers */ | ||
| --z-base: 1; | ||
| --z-dropdown: 100; | ||
| --z-modal: 1000; | ||
| --z-tooltip: 10000; | ||
| } | ||
| .theme-light { | ||
| --bg-primary: #f8fafc; | ||
| --bg-secondary: #ffffff; | ||
| --bg-tertiary: #f1f5f9; | ||
| --bg-hover: #e2e8f0; | ||
| --text-primary: #0f172a; | ||
| --text-secondary: #475569; | ||
| --text-muted: #94a3b8; | ||
| --border-color: #e2e8f0; | ||
| --border-hover: #3b82f6; | ||
| } | ||
| *, | ||
| *::before, | ||
| *::after { | ||
| margin: 0; | ||
| padding: 0; | ||
| box-sizing: border-box; | ||
| } | ||
| html { | ||
| font-size: 16px; | ||
| -webkit-font-smoothing: antialiased; | ||
| -moz-osx-font-smoothing: grayscale; | ||
| } | ||
| body { | ||
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, | ||
| Ubuntu, Cantarell, 'Helvetica Neue', sans-serif; | ||
| background: var(--bg-primary); | ||
| color: var(--text-primary); | ||
| line-height: 1.6; | ||
| overflow: hidden; | ||
| } | ||
| h1, h2, h3, h4, h5, h6 { | ||
| font-weight: 600; | ||
| line-height: 1.2; | ||
| margin: 0; | ||
| } | ||
| h1 { font-size: 1.75rem; } | ||
| h2 { font-size: 1.5rem; } | ||
| h3 { font-size: 1.25rem; } | ||
| h4 { font-size: 1.125rem; } | ||
| h5 { font-size: 1rem; } | ||
| h6 { font-size: 0.875rem; } | ||
| p { | ||
| margin: 0; | ||
| line-height: 1.6; | ||
| } | ||
| ::-webkit-scrollbar { | ||
| width: 8px; | ||
| height: 8px; | ||
| } | ||
| ::-webkit-scrollbar-track { | ||
| background: var(--bg-secondary); | ||
| } | ||
| ::-webkit-scrollbar-thumb { | ||
| background: var(--border-color); | ||
| border-radius: var(--radius-sm); | ||
| } | ||
| ::-webkit-scrollbar-thumb:hover { | ||
| background: var(--accent-blue); | ||
| } | ||
| ::selection { | ||
| background: var(--accent-blue); | ||
| color: var(--text-primary); | ||
| } | ||
| .hidden { display: none !important; } | ||
| .invisible { visibility: hidden !important; } | ||
| .disabled { opacity: 0.5; pointer-events: none; } |
| /* src/dashboard/styles/controls.css */ | ||
| .search-input { | ||
| width: 100%; | ||
| padding: 0.75rem 1rem; | ||
| background: var(--bg-tertiary); | ||
| border: 1px solid var(--border-color); | ||
| border-radius: var(--radius-md); | ||
| color: var(--text-primary); | ||
| font-size: 0.875rem; | ||
| outline: none; | ||
| transition: all var(--transition-normal); | ||
| } | ||
| .search-input:focus { | ||
| border-color: var(--accent-blue); | ||
| background: var(--bg-hover); | ||
| box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); | ||
| } | ||
| .search-input::placeholder { | ||
| color: var(--text-muted); | ||
| } | ||
| .filter-group { | ||
| margin-bottom: 1rem; | ||
| } | ||
| .filter-group:last-child { | ||
| margin-bottom: 0; | ||
| } | ||
| .filter-label { | ||
| display: block; | ||
| font-size: 0.75rem; | ||
| color: var(--text-secondary); | ||
| margin-bottom: 0.5rem; | ||
| text-transform: uppercase; | ||
| letter-spacing: 0.5px; | ||
| font-weight: 600; | ||
| } | ||
| .filter-select { | ||
| width: 100%; | ||
| padding: 0.625rem 1rem; | ||
| background: var(--bg-tertiary); | ||
| border: 1px solid var(--border-color); | ||
| border-radius: var(--radius-md); | ||
| color: var(--text-primary); | ||
| font-size: 0.875rem; | ||
| cursor: pointer; | ||
| outline: none; | ||
| transition: all var(--transition-normal); | ||
| } | ||
| .filter-select:hover { | ||
| border-color: var(--border-hover); | ||
| background: var(--bg-hover); | ||
| } | ||
| .filter-select:focus { | ||
| border-color: var(--accent-blue); | ||
| box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); | ||
| } | ||
| .slider-container { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 1rem; | ||
| } | ||
| .depth-slider { | ||
| flex: 1; | ||
| height: 4px; | ||
| background: var(--bg-tertiary); | ||
| border-radius: 2px; | ||
| outline: none; | ||
| -webkit-appearance: none; | ||
| cursor: pointer; | ||
| } | ||
| .depth-slider::-webkit-slider-thumb { | ||
| -webkit-appearance: none; | ||
| width: 16px; | ||
| height: 16px; | ||
| background: var(--accent-blue); | ||
| border-radius: 50%; | ||
| cursor: pointer; | ||
| transition: all var(--transition-normal); | ||
| } | ||
| .depth-slider::-webkit-slider-thumb:hover { | ||
| transform: scale(1.2); | ||
| box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.2); | ||
| } | ||
| .depth-slider::-moz-range-thumb { | ||
| width: 16px; | ||
| height: 16px; | ||
| background: var(--accent-blue); | ||
| border-radius: 50%; | ||
| border: none; | ||
| cursor: pointer; | ||
| } | ||
| .depth-value { | ||
| min-width: 30px; | ||
| text-align: center; | ||
| font-size: 0.875rem; | ||
| color: var(--accent-blue); | ||
| font-weight: 600; | ||
| } | ||
| .checkbox-group { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 0.5rem; | ||
| } | ||
| .checkbox-label { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 0.5rem; | ||
| font-size: 0.875rem; | ||
| color: var(--text-primary); | ||
| cursor: pointer; | ||
| transition: color var(--transition-fast); | ||
| } | ||
| .checkbox-label:hover { | ||
| color: var(--accent-blue); | ||
| } | ||
| .checkbox-label input[type="checkbox"] { | ||
| width: 16px; | ||
| height: 16px; | ||
| cursor: pointer; | ||
| accent-color: var(--accent-blue); | ||
| } | ||
| .cluster-mode-grid { | ||
| display: grid; | ||
| grid-template-columns: repeat(3, 1fr); | ||
| gap: 0.5rem; | ||
| margin-bottom: 1rem; | ||
| } | ||
| .cluster-btn { | ||
| padding: 0.625rem; | ||
| background: var(--bg-tertiary); | ||
| border: 1px solid var(--border-color); | ||
| color: var(--text-secondary); | ||
| font-size: 0.8rem; | ||
| cursor: pointer; | ||
| border-radius: var(--radius-md); | ||
| transition: all var(--transition-normal); | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| gap: 0.4rem; | ||
| font-weight: 500; | ||
| } | ||
| .cluster-btn:hover { | ||
| background: var(--bg-hover); | ||
| border-color: var(--accent-blue); | ||
| color: var(--text-primary); | ||
| } | ||
| .cluster-btn.active { | ||
| background: var(--accent-blue); | ||
| border-color: var(--accent-blue); | ||
| color: white; | ||
| } | ||
| .cluster-list { | ||
| max-height: 300px; | ||
| overflow-y: auto; | ||
| } | ||
| .cluster-item { | ||
| background: var(--bg-tertiary); | ||
| border: 1px solid var(--border-color); | ||
| border-radius: var(--radius-md); | ||
| padding: 0.75rem; | ||
| margin-bottom: 0.5rem; | ||
| cursor: pointer; | ||
| transition: all var(--transition-normal); | ||
| } | ||
| .cluster-item:hover { | ||
| background: var(--bg-hover); | ||
| border-color: var(--accent-blue); | ||
| transform: translateX(-2px); | ||
| } | ||
| .cluster-header { | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: space-between; | ||
| margin-bottom: 0.5rem; | ||
| } | ||
| .cluster-title { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 0.5rem; | ||
| font-size: 0.875rem; | ||
| font-weight: 600; | ||
| color: var(--text-primary); | ||
| } | ||
| .cluster-count { | ||
| background: rgba(59, 130, 246, 0.2); | ||
| color: #60a5fa; | ||
| padding: 0.15rem 0.5rem; | ||
| border-radius: 10px; | ||
| font-size: 0.75rem; | ||
| font-weight: 600; | ||
| } | ||
| .cluster-stats { | ||
| display: flex; | ||
| gap: 0.4rem; | ||
| flex-wrap: wrap; | ||
| font-size: 0.75rem; | ||
| } | ||
| .cluster-badge { | ||
| padding: 0.15rem 0.4rem; | ||
| border-radius: var(--radius-sm); | ||
| font-weight: 500; | ||
| } | ||
| .cluster-badge.vulnerable { | ||
| background: rgba(239, 68, 68, 0.2); | ||
| color: #fca5a5; | ||
| } | ||
| .cluster-badge.deprecated { | ||
| background: rgba(139, 92, 246, 0.2); | ||
| color: #c4b5fd; | ||
| } | ||
| .cluster-badge.outdated { | ||
| background: rgba(245, 158, 11, 0.2); | ||
| color: #fcd34d; | ||
| } | ||
| .cluster-badge.healthy { | ||
| background: rgba(16, 185, 129, 0.2); | ||
| color: #6ee7b7; | ||
| } | ||
| .control-buttons { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 0.5rem; | ||
| } | ||
| .control-btn { | ||
| width: 100%; | ||
| padding: 0.75rem 1rem; | ||
| background: var(--bg-tertiary); | ||
| border: 1px solid var(--border-color); | ||
| border-radius: var(--radius-md); | ||
| color: var(--text-primary); | ||
| font-size: 0.875rem; | ||
| cursor: pointer; | ||
| transition: all var(--transition-normal); | ||
| text-align: left; | ||
| font-weight: 500; | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 0.5rem; | ||
| } | ||
| .control-btn:hover { | ||
| background: var(--bg-hover); | ||
| border-color: var(--accent-blue); | ||
| transform: translateX(-2px); | ||
| } | ||
| .control-btn:active { | ||
| transform: scale(0.98); | ||
| } | ||
| .control-btn.primary { | ||
| background: linear-gradient(135deg, var(--accent-blue) 0%, var(--accent-cyan) 100%); | ||
| border-color: var(--accent-blue); | ||
| color: white; | ||
| } | ||
| .control-btn.primary:hover { | ||
| transform: translateX(-2px) scale(1.02); | ||
| } | ||
| .control-btn span { | ||
| font-size: 1rem; | ||
| } | ||
| .stat-grid { | ||
| display: grid; | ||
| grid-template-columns: repeat(2, 1fr); | ||
| gap: 0.75rem; | ||
| } | ||
| .stat-item { | ||
| background: var(--bg-tertiary); | ||
| padding: 0.75rem; | ||
| border-radius: var(--radius-md); | ||
| display: flex; | ||
| flex-direction: column; | ||
| align-items: center; | ||
| gap: 0.25rem; | ||
| transition: all var(--transition-normal); | ||
| } | ||
| .stat-item:hover { | ||
| transform: translateY(-2px); | ||
| box-shadow: var(--shadow-sm); | ||
| } | ||
| .stat-label { | ||
| font-size: 0.75rem; | ||
| color: var(--text-secondary); | ||
| text-transform: uppercase; | ||
| letter-spacing: 0.5px; | ||
| } | ||
| .stat-value { | ||
| font-size: 1.5rem; | ||
| font-weight: 700; | ||
| color: var(--text-primary); | ||
| } | ||
| .stat-value.stat-danger { color: var(--health-critical); } | ||
| .stat-value.stat-warning { color: var(--health-warning); } | ||
| .stat-value.stat-success { color: var(--health-excellent); } | ||
| .stat-value.stat-cluster { color: var(--accent-blue); } | ||
| .legend { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 0.75rem; | ||
| } | ||
| .legend-item { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 0.75rem; | ||
| font-size: 0.875rem; | ||
| color: var(--text-secondary); | ||
| transition: all var(--transition-fast); | ||
| } | ||
| .legend-item:hover { | ||
| color: var(--text-primary); | ||
| } | ||
| .legend-dot { | ||
| width: 16px; | ||
| height: 16px; | ||
| border-radius: 50%; | ||
| flex-shrink: 0; | ||
| border: 2px solid var(--bg-primary); | ||
| box-shadow: var(--shadow-sm); | ||
| } | ||
| .legend-dot.health-excellent { background: var(--health-excellent); } | ||
| .legend-dot.health-good { background: var(--health-good); } | ||
| .legend-dot.health-caution { background: var(--health-caution); } | ||
| .legend-dot.health-warning { background: var(--health-warning); } | ||
| .legend-dot.health-critical { background: var(--health-critical); } | ||
| .legend-dot.root-node { background: var(--root-color); } | ||
| .zoom-controls { | ||
| position: fixed; | ||
| bottom: 2rem; | ||
| right: 2rem; | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 0.5rem; | ||
| z-index: var(--z-dropdown); | ||
| } | ||
| .zoom-btn { | ||
| width: 48px; | ||
| height: 48px; | ||
| background: var(--bg-secondary); | ||
| border: 1px solid var(--border-color); | ||
| border-radius: var(--radius-lg); | ||
| color: var(--text-primary); | ||
| font-size: 1.25rem; | ||
| font-weight: 700; | ||
| cursor: pointer; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| transition: all var(--transition-normal); | ||
| backdrop-filter: blur(12px); | ||
| box-shadow: var(--shadow-md); | ||
| } | ||
| .zoom-btn:hover { | ||
| background: var(--accent-blue); | ||
| border-color: var(--accent-blue); | ||
| transform: scale(1.1); | ||
| color: white; | ||
| } | ||
| .zoom-level { | ||
| width: 48px; | ||
| padding: 0.5rem; | ||
| background: var(--bg-secondary); | ||
| border: 1px solid var(--border-color); | ||
| border-radius: var(--radius-lg); | ||
| text-align: center; | ||
| color: var(--accent-cyan); | ||
| font-size: 0.75rem; | ||
| font-weight: 700; | ||
| backdrop-filter: blur(12px); | ||
| box-shadow: var(--shadow-md); | ||
| } |
| /* src/dashboard/styles/graph.css */ | ||
| .node { | ||
| cursor: pointer; | ||
| transition: all var(--transition-fast); | ||
| } | ||
| .node:hover { | ||
| filter: brightness(1.3); | ||
| } | ||
| .node circle { | ||
| stroke-width: 2px; | ||
| transition: all var(--transition-normal); | ||
| } | ||
| .node:hover circle { | ||
| stroke-width: 3px; | ||
| filter: drop-shadow(0 0 8px currentColor); | ||
| } | ||
| .node.selected circle { | ||
| stroke: var(--accent-cyan); | ||
| stroke-width: 4px; | ||
| } | ||
| .node-label { | ||
| font-size: 11px; | ||
| fill: var(--text-secondary); | ||
| pointer-events: none; | ||
| text-anchor: middle; | ||
| font-weight: 500; | ||
| user-select: none; | ||
| } | ||
| .node-label.hidden { | ||
| display: none; | ||
| } | ||
| .link { | ||
| stroke: var(--border-color); | ||
| stroke-opacity: 0.6; | ||
| fill: none; | ||
| stroke-width: 1.5px; | ||
| transition: all var(--transition-fast); | ||
| } | ||
| .link:hover { | ||
| stroke: var(--accent-cyan); | ||
| stroke-opacity: 1; | ||
| stroke-width: 2px; | ||
| } | ||
| .link.highlighted { | ||
| stroke: var(--accent-cyan); | ||
| stroke-opacity: 0.8; | ||
| stroke-width: 2.5px; | ||
| } | ||
| .link.circular { | ||
| stroke: var(--health-critical); | ||
| stroke-dasharray: 5, 5; | ||
| stroke-opacity: 0.8; | ||
| } | ||
| .link.hidden { | ||
| display: none; | ||
| } | ||
| .depth-circle { | ||
| fill: none; | ||
| stroke: var(--border-color); | ||
| stroke-width: 1px; | ||
| stroke-dasharray: 4, 4; | ||
| opacity: 0.4; | ||
| transition: opacity var(--transition-normal); | ||
| } | ||
| .depth-circle.hidden { | ||
| opacity: 0; | ||
| } | ||
| .depth-label { | ||
| fill: var(--text-muted); | ||
| font-size: 10px; | ||
| font-weight: 500; | ||
| pointer-events: none; | ||
| } | ||
| .tooltip { | ||
| position: absolute; | ||
| background: rgba(15, 18, 25, 0.98); | ||
| border: 1px solid var(--accent-blue); | ||
| border-radius: var(--radius-lg); | ||
| padding: 1rem 1.25rem; | ||
| pointer-events: none; | ||
| opacity: 0; | ||
| transition: opacity var(--transition-normal), transform var(--transition-normal); | ||
| max-width: 350px; | ||
| z-index: var(--z-tooltip); | ||
| backdrop-filter: blur(10px); | ||
| box-shadow: var(--shadow-lg); | ||
| transform: translateY(-10px); | ||
| } | ||
| .tooltip.visible { | ||
| opacity: 1; | ||
| transform: translateY(0); | ||
| } | ||
| .tooltip-title { | ||
| font-size: 1rem; | ||
| font-weight: 600; | ||
| color: var(--accent-cyan); | ||
| margin-bottom: 0.75rem; | ||
| padding-bottom: 0.5rem; | ||
| border-bottom: 1px solid var(--border-color); | ||
| } | ||
| .tooltip-content { | ||
| font-size: 0.875rem; | ||
| color: var(--text-secondary); | ||
| line-height: 1.6; | ||
| } | ||
| .tooltip-row { | ||
| display: flex; | ||
| justify-content: space-between; | ||
| margin: 0.4rem 0; | ||
| gap: 1rem; | ||
| } | ||
| .tooltip-label { | ||
| color: var(--text-secondary); | ||
| font-weight: 500; | ||
| } | ||
| .tooltip-value { | ||
| color: var(--text-primary); | ||
| font-weight: 600; | ||
| } | ||
| .tooltip-badge { | ||
| display: inline-block; | ||
| padding: 0.2rem 0.6rem; | ||
| border-radius: var(--radius-sm); | ||
| font-size: 0.75rem; | ||
| font-weight: 600; | ||
| margin-right: 0.25rem; | ||
| } | ||
| .tooltip-badge.critical { | ||
| background: rgba(239, 68, 68, 0.2); | ||
| color: var(--health-critical); | ||
| } | ||
| .tooltip-badge.warning { | ||
| background: rgba(245, 158, 11, 0.2); | ||
| color: var(--health-warning); | ||
| } | ||
| .tooltip-badge.info { | ||
| background: rgba(59, 130, 246, 0.2); | ||
| color: var(--accent-blue); | ||
| } | ||
| .tooltip-badge.deprecated { | ||
| background: rgba(139, 92, 246, 0.2); | ||
| color: var(--accent-purple); | ||
| } | ||
| .tooltip-badge.success { | ||
| background: rgba(16, 185, 129, 0.2); | ||
| color: var(--health-excellent); | ||
| } |
| /* src/dashboard/styles/layout.css */ | ||
| .header { | ||
| background: linear-gradient(135deg, var(--bg-secondary) 0%, #0f1219 100%); | ||
| border-bottom: 1px solid var(--border-color); | ||
| padding: 1rem 1.5rem; | ||
| display: flex; | ||
| justify-content: space-between; | ||
| align-items: center; | ||
| position: relative; | ||
| z-index: var(--z-dropdown); | ||
| height: 80px; | ||
| box-shadow: var(--shadow-md); | ||
| } | ||
| .header-left { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 0.5rem; | ||
| } | ||
| .header-title { | ||
| font-size: 1.25rem; | ||
| font-weight: 600; | ||
| color: var(--text-primary); | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 0.5rem; | ||
| margin: 0; | ||
| } | ||
| .header-icon { | ||
| font-size: 1.5rem; | ||
| } | ||
| .version-badge { | ||
| font-size: 0.7rem; | ||
| background: var(--accent-blue); | ||
| padding: 0.15rem 0.5rem; | ||
| border-radius: var(--radius-sm); | ||
| font-weight: 500; | ||
| } | ||
| .header-meta { | ||
| display: flex; | ||
| gap: 1.5rem; | ||
| font-size: 0.75rem; | ||
| color: var(--text-secondary); | ||
| } | ||
| .header-meta span { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 0.25rem; | ||
| } | ||
| .header-right { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 1rem; | ||
| } | ||
| .layout-tabs { | ||
| display: flex; | ||
| gap: 0.25rem; | ||
| background: var(--bg-tertiary); | ||
| padding: 3px; | ||
| border-radius: var(--radius-md); | ||
| } | ||
| .tab-btn { | ||
| padding: 0.5rem 1rem; | ||
| border: none; | ||
| background: transparent; | ||
| color: var(--text-secondary); | ||
| font-size: 0.875rem; | ||
| cursor: pointer; | ||
| border-radius: var(--radius-sm); | ||
| transition: all var(--transition-normal); | ||
| font-weight: 500; | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 0.5rem; | ||
| } | ||
| .tab-btn:hover { | ||
| background: var(--bg-hover); | ||
| color: var(--text-primary); | ||
| } | ||
| .tab-btn.active { | ||
| background: var(--accent-blue); | ||
| color: white; | ||
| } | ||
| .tab-btn span { | ||
| font-size: 1rem; | ||
| } | ||
| .icon-btn { | ||
| width: 40px; | ||
| height: 40px; | ||
| border: 1px solid var(--border-color); | ||
| background: var(--bg-tertiary); | ||
| border-radius: var(--radius-md); | ||
| cursor: pointer; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| font-size: 1.25rem; | ||
| transition: all var(--transition-normal); | ||
| } | ||
| .icon-btn:hover { | ||
| background: var(--bg-hover); | ||
| border-color: var(--border-hover); | ||
| } | ||
| .main-container { | ||
| display: flex; | ||
| height: calc(100vh - 80px); | ||
| overflow: hidden; | ||
| } | ||
| .sidebar { | ||
| background: var(--bg-secondary); | ||
| overflow-y: auto; | ||
| overflow-x: hidden; | ||
| padding: 1.5rem; | ||
| flex-shrink: 0; | ||
| } | ||
| .sidebar-left { | ||
| width: 320px; | ||
| border-right: 1px solid var(--border-color); | ||
| } | ||
| .sidebar-right { | ||
| width: 280px; | ||
| border-left: 1px solid var(--border-color); | ||
| } | ||
| .sidebar-section { | ||
| margin-bottom: 1.5rem; | ||
| padding-bottom: 1.5rem; | ||
| border-bottom: 1px solid var(--border-color); | ||
| } | ||
| .sidebar-section:last-child { | ||
| border-bottom: none; | ||
| margin-bottom: 0; | ||
| padding-bottom: 0; | ||
| } | ||
| .sidebar-title { | ||
| font-size: 0.875rem; | ||
| font-weight: 600; | ||
| color: var(--text-primary); | ||
| margin-bottom: 1rem; | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 0.5rem; | ||
| } | ||
| .graph-container { | ||
| flex: 1; | ||
| position: relative; | ||
| overflow: hidden; | ||
| background: var(--bg-primary); | ||
| } | ||
| .graph-svg { | ||
| width: 100%; | ||
| height: 100%; | ||
| cursor: grab; | ||
| } | ||
| .graph-svg:active { | ||
| cursor: grabbing; | ||
| } | ||
| .loading { | ||
| position: absolute; | ||
| top: 50%; | ||
| left: 50%; | ||
| transform: translate(-50%, -50%); | ||
| text-align: center; | ||
| display: none; | ||
| z-index: var(--z-dropdown); | ||
| } | ||
| .loading.visible { | ||
| display: block; | ||
| } | ||
| .spinner { | ||
| width: 50px; | ||
| height: 50px; | ||
| border: 3px solid var(--border-color); | ||
| border-top-color: var(--accent-blue); | ||
| border-radius: 50%; | ||
| animation: spin 1s linear infinite; | ||
| margin: 0 auto 1rem; | ||
| } | ||
| @keyframes spin { | ||
| to { transform: rotate(360deg); } | ||
| } | ||
| .loading-text { | ||
| color: var(--text-secondary); | ||
| font-size: 0.875rem; | ||
| } | ||
| .empty-state { | ||
| position: absolute; | ||
| top: 50%; | ||
| left: 50%; | ||
| transform: translate(-50%, -50%); | ||
| text-align: center; | ||
| max-width: 400px; | ||
| } | ||
| .empty-icon { | ||
| font-size: 4rem; | ||
| margin-bottom: 1rem; | ||
| } | ||
| .empty-title { | ||
| font-size: 1.5rem; | ||
| color: var(--text-primary); | ||
| margin-bottom: 0.5rem; | ||
| } | ||
| .empty-text { | ||
| color: var(--text-secondary); | ||
| font-size: 0.875rem; | ||
| } | ||
| .analytics-container { | ||
| padding: 2rem; | ||
| overflow-y: auto; | ||
| height: 100%; | ||
| } | ||
| .analytics-grid { | ||
| display: grid; | ||
| grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); | ||
| gap: 1.5rem; | ||
| } | ||
| .analytics-card { | ||
| background: var(--bg-secondary); | ||
| border: 1px solid var(--border-color); | ||
| border-radius: var(--radius-lg); | ||
| padding: 1.5rem; | ||
| transition: all var(--transition-normal); | ||
| } | ||
| .analytics-card:hover { | ||
| transform: translateY(-4px); | ||
| box-shadow: var(--shadow-md); | ||
| } | ||
| .analytics-card-title { | ||
| font-size: 1.125rem; | ||
| font-weight: 600; | ||
| color: var(--text-primary); | ||
| margin-bottom: 1.25rem; | ||
| padding-bottom: 0.75rem; | ||
| border-bottom: 1px solid var(--border-color); | ||
| } | ||
| .analytics-stats { | ||
| display: grid; | ||
| grid-template-columns: repeat(2, 1fr); | ||
| gap: 1rem; | ||
| } | ||
| .analytics-stat-item { | ||
| text-align: center; | ||
| } | ||
| .analytics-stat-value { | ||
| font-size: 2rem; | ||
| font-weight: 700; | ||
| color: var(--text-primary); | ||
| margin-bottom: 0.25rem; | ||
| } | ||
| .analytics-stat-label { | ||
| font-size: 0.75rem; | ||
| color: var(--text-secondary); | ||
| text-transform: uppercase; | ||
| letter-spacing: 0.5px; | ||
| } | ||
| .analytics-distribution, | ||
| .analytics-depth-chart { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 0.75rem; | ||
| } | ||
| .analytics-bar-item, | ||
| .analytics-depth-item { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 0.25rem; | ||
| } | ||
| .analytics-bar-label, | ||
| .analytics-depth-label { | ||
| font-size: 0.875rem; | ||
| color: var(--text-secondary); | ||
| font-weight: 500; | ||
| } | ||
| .analytics-bar-container, | ||
| .analytics-depth-bar { | ||
| position: relative; | ||
| height: 28px; | ||
| background: var(--bg-tertiary); | ||
| border-radius: var(--radius-sm); | ||
| overflow: hidden; | ||
| } | ||
| .analytics-bar-fill, | ||
| .analytics-depth-fill { | ||
| height: 100%; | ||
| border-radius: var(--radius-sm); | ||
| transition: width 0.5s ease; | ||
| } | ||
| .analytics-bar-text, | ||
| .analytics-depth-count { | ||
| position: absolute; | ||
| left: 0.75rem; | ||
| top: 50%; | ||
| transform: translateY(-50%); | ||
| font-size: 0.75rem; | ||
| font-weight: 600; | ||
| color: var(--text-primary); | ||
| text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); | ||
| } | ||
| .analytics-issues-list, | ||
| .analytics-packages-list { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 0.5rem; | ||
| max-height: 300px; | ||
| overflow-y: auto; | ||
| } | ||
| .analytics-issue-item, | ||
| .analytics-package-item { | ||
| display: flex; | ||
| justify-content: space-between; | ||
| align-items: center; | ||
| padding: 0.5rem 0.75rem; | ||
| background: var(--bg-tertiary); | ||
| border-radius: var(--radius-sm); | ||
| transition: all var(--transition-fast); | ||
| } | ||
| .analytics-issue-item:hover, | ||
| .analytics-package-item:hover { | ||
| background: var(--bg-hover); | ||
| transform: translateX(4px); | ||
| } | ||
| .analytics-issue-type, | ||
| .analytics-package-name { | ||
| font-size: 0.875rem; | ||
| color: var(--text-primary); | ||
| font-weight: 500; | ||
| } | ||
| .analytics-issue-count, | ||
| .analytics-package-score { | ||
| font-size: 0.875rem; | ||
| font-weight: 700; | ||
| padding: 0.25rem 0.5rem; | ||
| background: var(--bg-primary); | ||
| border-radius: var(--radius-sm); | ||
| } | ||
| @media (max-width: 1280px) { | ||
| .sidebar-left { width: 280px; } | ||
| .sidebar-right { width: 260px; } | ||
| } | ||
| @media (max-width: 1024px) { | ||
| .header { | ||
| flex-direction: column; | ||
| height: auto; | ||
| gap: 1rem; | ||
| padding: 1rem; | ||
| } | ||
| .header-meta { flex-wrap: wrap; } | ||
| } | ||
| @media (max-width: 768px) { | ||
| .layout-tabs { | ||
| width: 100%; | ||
| overflow-x: auto; | ||
| } | ||
| .tab-btn span { display: none; } | ||
| } |
| /* src/dashboard/styles/themes.css */ | ||
| .theme-light { | ||
| --bg-primary: #f8fafc; | ||
| --bg-secondary: #ffffff; | ||
| --bg-tertiary: #f1f5f9; | ||
| --bg-hover: #e2e8f0; | ||
| --text-primary: #0f172a; | ||
| --text-secondary: #475569; | ||
| --text-muted: #94a3b8; | ||
| --border-color: #e2e8f0; | ||
| --border-hover: #3b82f6; | ||
| } | ||
| .theme-light .node-label { | ||
| fill: #475569; | ||
| text-shadow: | ||
| -1px -1px 2px white, | ||
| 1px -1px 2px white, | ||
| -1px 1px 2px white, | ||
| 1px 1px 2px white; | ||
| } | ||
| .theme-light .link { | ||
| stroke: #cbd5e1; | ||
| } | ||
| .theme-light .depth-circle { | ||
| stroke: #cbd5e1; | ||
| } | ||
| .theme-light .tooltip { | ||
| background: rgba(255, 255, 255, 0.98); | ||
| border-color: var(--accent-blue); | ||
| box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15); | ||
| } | ||
| .theme-light .tooltip-title { | ||
| border-bottom-color: #e2e8f0; | ||
| } | ||
| body { | ||
| transition: background-color 0.3s ease, color 0.3s ease; | ||
| } | ||
| .header, | ||
| .sidebar, | ||
| .sidebar-section, | ||
| .tab-btn, | ||
| .control-btn, | ||
| .stat-item, | ||
| .cluster-item, | ||
| .search-input, | ||
| .filter-select { | ||
| transition: background-color 0.3s ease, | ||
| border-color 0.3s ease, | ||
| color 0.3s ease; | ||
| } |
+2
-2
| MIT License | ||
| Copyright (c) 2025 Ajay Thorat | ||
| Copyright (c) 2025-2026 Ajay Thorat | ||
@@ -21,2 +21,2 @@ Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| SOFTWARE. | ||
| SOFTWARE. |
+13
-4
| { | ||
| "name": "devcompass", | ||
| "version": "3.1.7", | ||
| "description": "Dependency health checker with ecosystem intelligence, user-configurable GitHub Personal Access Token support, real-time GitHub issue tracking for 502 popular npm packages, unified interactive dependency graph with dynamic layout switching, intelligent clustering (Ecosystem/Health/Depth grouping), real-time filtering, advanced zoom controls, supply chain security with auto-fix, license conflict resolution with auto-fix, package quality auto-fix, batch fix modes, backup & rollback, and professional dependency exploration - all in a single interactive HTML file.", | ||
| "version": "3.2.0", | ||
| "description": "Dependency health checker with ecosystem intelligence, unified interactive dashboard with 5 dynamic layouts (Tree/Force/Radial/Conflict/Analytics), modular CSS/JS architecture, intelligent clustering, real-time filtering, advanced zoom controls, theme support (dark/light), supply chain security with auto-fix, license conflict resolution, package quality auto-fix, batch fix modes, backup & rollback, and professional dependency exploration.", | ||
| "main": "src/index.js", | ||
@@ -102,2 +102,3 @@ "bin": { | ||
| "conflict-view", | ||
| "analytics-dashboard", | ||
| "graph-export", | ||
@@ -141,3 +142,12 @@ "graph-search", | ||
| "ecosystem-detection", | ||
| "package-ecosystem-mapping" | ||
| "package-ecosystem-mapping", | ||
| "unified-dashboard", | ||
| "modular-architecture", | ||
| "theme-support", | ||
| "dark-mode", | ||
| "light-mode", | ||
| "interactive-analytics", | ||
| "5-layouts", | ||
| "no-duplication", | ||
| "performance-optimized" | ||
| ], | ||
@@ -148,3 +158,2 @@ "author": "Ajay Thorat <ajaythorat988@gmail.com>", | ||
| "chalk": "4.1.2", | ||
| "commander": "11.1.0", | ||
| "knip": "^5.88.1", | ||
@@ -151,0 +160,0 @@ "npm-check-updates": "^20.0.0", |
+457
-237
| # 🧭 DevCompass | ||
| **Dependency health checker with ecosystem intelligence, user-configurable GitHub Personal Access Token support, real-time GitHub issue tracking for 502 popular npm packages, unified interactive dependency graph with intelligent clustering (Ecosystem/Health/Depth grouping), dynamic layout switching, real-time filtering, advanced zoom controls, supply chain security with auto-fix, license conflict resolution with auto-fix, package quality auto-fix, batch fix modes with granular control, backup & rollback, and professional dependency exploration - all in a single interactive HTML file.** | ||
| **Dependency health checker with unified interactive dashboard featuring 5 dynamic layouts (Tree/Force/Radial/Conflict/Analytics), modular CSS/JS architecture, intelligent clustering (Ecosystem/Health/Depth grouping), real-time filtering, advanced zoom controls, theme support (dark/light), supply chain security with auto-fix, license conflict resolution, package quality auto-fix, batch fix modes, backup & rollback, and professional dependency exploration.** | ||
@@ -9,90 +9,293 @@ [](https://www.npmjs.com/package/devcompass) | ||
| Analyze your JavaScript projects to find unused dependencies, outdated packages, **detect security vulnerabilities**, **monitor GitHub issues in real-time for 502 packages**, **configure your own GitHub token to avoid rate limits**, **visualize dependency graphs with intelligent clustering**, **organize packages by ecosystem (React/Vue/Angular/Testing/Build Tools)**, **group by health status (Critical/Warning/Healthy)**, **analyze by depth levels**, **instant layout switching**, **real-time filtering**, **advanced zoom controls**, **check bundle sizes**, **verify licenses**, **detect and auto-fix supply chain attacks**, **resolve license conflicts automatically**, **replace abandoned/deprecated packages automatically**, **analyze package quality**, **batch fix with granular control**, **manage backups and rollback changes**, and **automatically fix issues with dry-run, progress tracking, and backups**. Perfect for **CI/CD pipelines** with JSON output and exit codes. | ||
| Analyze your JavaScript projects to find unused dependencies, outdated packages, **detect security vulnerabilities**, **monitor GitHub issues in real-time for 502 packages**, **configure your own GitHub token to avoid rate limits**, **customize all configuration via JSON files**, **visualize dependency graphs with 5 dynamic layouts including Analytics dashboard**, **modular architecture with zero code duplication**, **organize packages by ecosystem (React/Vue/Angular/Testing/Build Tools)**, **group by health status (Critical/Warning/Healthy)**, **analyze by depth levels**, **instant layout switching**, **dark/light theme toggle**, **real-time filtering**, **advanced zoom controls**, **check bundle sizes**, **verify licenses**, **detect and auto-fix supply chain attacks**, **resolve license conflicts automatically**, **replace abandoned/deprecated packages automatically**, **analyze package quality**, **batch fix with granular control**, **manage backups and rollback changes**, and **automatically fix issues with dry-run, progress tracking, and backups**. Perfect for **CI/CD pipelines** with JSON output and exit codes. | ||
| > **🔲 LATEST v3.1.6:** Intelligent Clustering - Organize packages by Ecosystem, Health, or Depth! 🔲 | ||
| > **🔑 v3.1.5:** GitHub Personal Access Token Support - Configure your own token to bypass rate limits! 🔑 | ||
| > **🎨 v3.1.4:** Unified Interactive Graph System - 40+ files → 1 file with dynamic controls! 🎨 | ||
| > **🎨 LATEST v3.2.0:** Unified Dashboard Architecture - 50% less code, 5 layouts, dark/light themes! 🎨 | ||
| > **🔧 v3.1.7:** Dynamic Data Configuration - Scalable JSON-based configuration system! 🔧 | ||
| > **🔲 v3.1.6:** Intelligent Clustering - Organize packages by Ecosystem, Health, or Depth! 🔲 | ||
| > **🔑 v3.1.5:** GitHub Personal Access Token Support - Configure your own token to bypass rate limits! 🔑 | ||
| ## 🎉 Latest Release: v3.1.6 (2026-04-22) | ||
| ## 🎉 Latest Release: v3.2.0 (2026-04-25) | ||
| **Intelligent Dependency Clustering - Organize & Understand Your Dependencies!** | ||
| **Unified Dashboard Architecture - Modular, Scalable, Beautiful!** | ||
| ### 🌟 What's New in v3.1.6: | ||
| ### 🌟 What's New in v3.2.0: | ||
| #### **🔲 Smart Clustering System** | ||
| #### **🎨 Unified Dashboard Architecture** | ||
| Organize your dependencies into meaningful groups for better understanding and management. | ||
| Complete refactor from 4 duplicated layout files (3,600 lines) to unified modular dashboard (1,800 lines). | ||
| **Three Clustering Modes:** | ||
| **Benefits:** | ||
| - ✅ **50% code reduction** - Easier to maintain | ||
| - ✅ **Single source of truth** - Update once, applies everywhere | ||
| - ✅ **4× faster updates** - No more copy-paste across files | ||
| - ✅ **Zero duplication** - CSS/JS shared across all layouts | ||
| - ✅ **Fully backward compatible** - No breaking changes | ||
| 1. **⚛️ Ecosystem Clustering** | ||
| - Groups packages by technology (React, Vue, Angular, Testing, Build Tools, etc.) | ||
| - Automatically detects 12 ecosystems | ||
| - Perfect for understanding your tech stack | ||
| **New Structure:** | ||
| 2. **🏥 Health Clustering** | ||
| - Groups by health status (Critical Issues, Needs Attention, Healthy) | ||
| - Color-coded health indicators | ||
| - Quick identification of problem areas | ||
| ``` | ||
| src/dashboard/ | ||
| ├── index.html # Main template (11KB) | ||
| ├── scripts/ # 6 modular JS files | ||
| │ ├── core.js # Initialization | ||
| │ ├── layouts.js # ALL 5 layouts in one file | ||
| │ ├── controls.js # Zoom, filters, exports | ||
| │ ├── tooltip.js # Tooltip management | ||
| │ ├── stats.js # Statistics calculations | ||
| │ └── utils.js # Shared utilities | ||
| └── styles/ # 5 modular CSS files | ||
| ├── base.css # Variables, reset | ||
| ├── layout.css # Header, sidebars, grid | ||
| ├── controls.css # Buttons, filters, inputs | ||
| ├── graph.css # Nodes, links, tooltips | ||
| └── themes.css # Dark/light theme support | ||
| ``` | ||
| 3. **📊 Depth Clustering** | ||
| - Groups by dependency levels (Direct → Level 1 → Level 2, etc.) | ||
| - Understand dependency chains | ||
| - Visualize transitive dependencies | ||
| **Removed (Consolidated):** | ||
| - ❌ `src/graph/layouts/tree.js` (800 lines) | ||
| - ❌ `src/graph/layouts/force.js` (700 lines) | ||
| - ❌ `src/graph/layouts/radial.js` (650 lines) | ||
| - ❌ `src/graph/layouts/conflict.js` (600 lines) | ||
| - ❌ `src/graph/template.html` (900 lines) | ||
| **Features:** | ||
| - ✅ **Sidebar Organization** - Clean categorized list of all packages | ||
| - ✅ **Health Statistics** - See vulnerable/deprecated/outdated/healthy counts per cluster | ||
| - ✅ **Click to Highlight** - Click any cluster to highlight related packages (3 seconds) | ||
| - ✅ **Switch Modes Instantly** - Three buttons to change grouping | ||
| - ✅ **Graph Unchanged** - All nodes always visible, layouts work normally | ||
| - ✅ **No Page Reload** - Everything happens in real-time | ||
| #### **📊 NEW: Analytics Layout** | ||
| #### **📊 Cluster Statistics** | ||
| Fifth layout added - comprehensive statistics dashboard with 5 cards: | ||
| Each cluster shows: | ||
| - 📦 Package count | ||
| - 🔴 Vulnerable count | ||
| - 🟣 Deprecated count | ||
| - 🟡 Outdated count | ||
| - 🟢 Healthy count | ||
| 1. **📊 Overview** - Total/Healthy/Vulnerable/Outdated at a glance | ||
| 2. **💊 Health Distribution** - Bar chart showing package health breakdown | ||
| 3. **📏 Depth Distribution** - Dependency depth visualization | ||
| 4. **🚨 Issues by Type** - Categorized issue summary | ||
| 5. **⚠️ Needs Attention** - Top 10 packages requiring fixes | ||
| #### **🎯 Example Use Cases** | ||
| **Access:** Click **📊 Analytics** tab in header | ||
| **"Which testing tools do I have?"** | ||
| #### **🎨 Dark/Light Theme Support** | ||
| Toggle between dark and light themes with one click: | ||
| - **Dark Theme** (default) - Professional dark UI | ||
| - **Light Theme** - Clean, bright interface | ||
| - **Persisted** - Saves preference in localStorage | ||
| - **Smooth Transitions** - Beautiful theme switching | ||
| - **Toggle Button** - 🌙 / ☀️ in header | ||
| #### **⚡ Performance Optimizations** | ||
| Massive speed improvements across all layouts: | ||
| - **Tree Layout** - 5× faster rendering | ||
| - **Force Layout** - 4× faster simulation | ||
| - **Radial Layout** - 4× faster positioning | ||
| - **Analytics Layout** - 6× faster card generation | ||
| **Optimizations:** | ||
| - Pre-calculated node positions | ||
| - Batch DOM operations | ||
| - Optimized D3 selections | ||
| - Reduced transition durations | ||
| - Deferred expensive operations | ||
| ### 📊 Code Metrics | ||
| | Metric | v3.1.7 | v3.2.0 | Improvement | | ||
| |--------|--------|--------|-------------| | ||
| | Total Lines | 3,600 | 1,800 | **-50%** | | ||
| | Layout Files | 4 files | 1 file | **-75%** | | ||
| | CSS Duplication | 4× | 1× shared | **-75%** | | ||
| | JS Duplication | 4× | 1× engine | **-75%** | | ||
| | Files | 5 files | 12 files | Better organized | | ||
| | Layouts | 4 layouts | **5 layouts** | +25% | | ||
| | Themes | None | **2 themes** | New feature | | ||
| | Maintainability | Update 4 places | Update 1 place | **4× easier** | | ||
| ### 🚀 Upgrade Now: | ||
| ```bash | ||
| devcompass graph --open | ||
| # Click "Ecosystem" → See "Testing Tools (15 packages)" | ||
| npm install -g devcompass@3.2.0 | ||
| ``` | ||
| **"Show me all critical issues"** | ||
| ### 💡 What Changed for Users: | ||
| **Nothing! (100% Backward Compatible)** | ||
| All commands work exactly the same: | ||
| ```bash | ||
| devcompass graph --open | ||
| # Click "Health" → See "Critical Issues (8 packages)" | ||
| devcompass graph --open # Same command, better UI! | ||
| ``` | ||
| **"What are my direct dependencies?"** | ||
| **New Features:** | ||
| - ✅ **Analytics tab** - Click to see dashboard | ||
| - ✅ **Theme toggle** - Click 🌙/☀️ button | ||
| - ✅ **Faster rendering** - 4-6× performance boost | ||
| --- | ||
| ## 🎨 Unified Graph Visualization (v3.2.0) | ||
| DevCompass features a **revolutionary unified interactive dashboard** - 5 layouts, intelligent clustering, theme support, and all controls in one beautiful interface! | ||
| ### 🎯 One Command, Everything Included | ||
| ```bash | ||
| # Generate unified interactive dashboard | ||
| devcompass graph --open | ||
| # Click "Depth" → See "Direct Dependencies (42 packages)" | ||
| ``` | ||
| ### 🚀 Upgrade Now: | ||
| **What you get in ONE file:** | ||
| - ✅ **5 layouts** (Tree/Force/Radial/Conflict/Analytics) - switchable via tabs | ||
| - ✅ **5 filters** (All/Vulnerable/Outdated/Deprecated/Unused) - instant filtering | ||
| - ✅ **3 clustering modes** (Ecosystem/Health/Depth) - organize packages | ||
| - ✅ **2 themes** (Dark/Light) - toggle with one click | ||
| - ✅ Depth slider (1-10, ∞) | ||
| - ✅ Live search | ||
| - ✅ Advanced zoom controls | ||
| - ✅ Export (PNG/JSON/Report) | ||
| - ✅ Live statistics | ||
| - ✅ Professional UI | ||
| - ✅ **No page reload!** | ||
| - ✅ **50% less code** - Better performance | ||
| ### 🎮 Interactive Controls | ||
| #### **Layout Switcher** | ||
| Click tabs to switch between: | ||
| 1. **🌳 Tree** - Hierarchical view (root → children) | ||
| 2. **⚡ Force** - Physics-based network (drag nodes!) | ||
| 3. **🌐 Radial** - Circular view (root at center) | ||
| 4. **⚠️ Conflict** - Issues-only view | ||
| 5. **📊 Analytics** - Statistics dashboard (NEW!) | ||
| #### **Theme Toggle (NEW!)** | ||
| Click **🌙 / ☀️** button to switch themes: | ||
| - **🌙 Dark Theme** - Professional dark interface (default) | ||
| - **☀️ Light Theme** - Clean, bright interface | ||
| - Preference saved automatically | ||
| - Smooth transitions | ||
| #### **Filter Controls** | ||
| Click to filter packages: | ||
| - **All** - Show everything | ||
| - **Vulnerable** - Security issues only | ||
| - **Outdated** - Update available | ||
| - **Deprecated** - Officially deprecated | ||
| - **Unused** - Unused dependencies | ||
| #### **Clustering** | ||
| Click to organize packages: | ||
| - **⚛️ Ecosystem** - Group by technology | ||
| - **🏥 Health** - Group by status | ||
| - **📊 Depth** - Group by level | ||
| #### **Other Controls** | ||
| - **Depth Slider** - Control dependency depth (1-∞) | ||
| - **Search** - Find packages instantly | ||
| - **Zoom** - In/Out/Reset/Fit/Center | ||
| - **Export** - PNG image, JSON data, or full report | ||
| - **Keyboard Shortcuts** - +/- zoom, R reset, F fit, L labels, T theme | ||
| --- | ||
| ## ✨ All Features | ||
| - 🎨 **Unified Dashboard** (v3.2.0) - 5 layouts, modular architecture | ||
| - 📊 **Analytics Layout** (v3.2.0) - Statistics dashboard | ||
| - 🌙 **Theme Support** (v3.2.0) - Dark/light mode toggle | ||
| - ⚡ **Performance** (v3.2.0) - 4-6× faster rendering | ||
| - 🔧 **Dynamic Data Configuration** (v3.1.7) - JSON-based scalable config | ||
| - 🔲 **Intelligent Clustering** (v3.1.6) - Ecosystem/Health/Depth grouping | ||
| - 🔑 **GitHub Token Config** (v3.1.5) - User tokens, no rate limits | ||
| - 📦 **502 Tracked Packages** (v3.1.5) - Comprehensive monitoring | ||
| - 🔄 **Dynamic Issues** (v3.1.2) - Real-time npm audit | ||
| - 🛡️ **Production Safety** (v3.1.1) - Bug fixes and hardening | ||
| - 📊 **Multiple Layouts** (v3.1.0) - Tree/Force/Radial/Conflict | ||
| - 📦 **Batch Fix Modes** (v2.8.5) - Granular control | ||
| - 💾 **Backup & Rollback** (v2.8.4) - Safe management | ||
| - 📦 **Quality Auto-Fix** (v2.8.3) - Replace abandoned packages | ||
| - ⚖️ **License Auto-Fix** (v2.8.2) - GPL/AGPL replacement | ||
| - 🛡️ **Supply Chain Auto-Fix** (v2.8.1) - Remove malicious packages | ||
| - 🛡️ **Security Analysis** (v2.7) - Comprehensive security | ||
| - ⚡ **Parallel Processing** (v2.6) - 80% faster | ||
| - 🔮 **GitHub Tracking** (v2.4) - Real-time package health | ||
| - 🚀 **CI/CD Integration** (v2.2) - JSON output, exit codes | ||
| ## 🚀 Installation | ||
| ```bash | ||
| npm install -g devcompass@3.1.6 | ||
| # Global (recommended) | ||
| npm install -g devcompass@3.2.0 | ||
| # Local | ||
| npm install --save-dev devcompass@3.2.0 | ||
| # One-time use | ||
| npx devcompass@3.2.0 analyze | ||
| # Upgrade from any version | ||
| npm install -g devcompass@3.2.0 | ||
| ``` | ||
| ### 📈 What's Included: | ||
| ## 📖 Usage | ||
| All features from previous versions PLUS clustering: | ||
| - ✅ Unified interactive graph (v3.1.4) | ||
| - ✅ GitHub token configuration (v3.1.5) | ||
| - ✅ 502 tracked packages (v3.1.5) | ||
| - ✅ **NEW: Intelligent clustering (v3.1.6)** | ||
| - ✅ 4 layouts (Tree/Force/Radial/Conflict) | ||
| - ✅ 5 filters (All/Vulnerable/Outdated/Deprecated/Unused) | ||
| - ✅ Advanced zoom controls | ||
| - ✅ Export (PNG/JSON) | ||
| ### Basic Commands | ||
| ```bash | ||
| # Configure GitHub token (recommended) | ||
| devcompass config --github-token <your-token> | ||
| devcompass config --show | ||
| # Analyze project | ||
| devcompass analyze | ||
| # Generate graph (with 5 layouts + themes!) | ||
| devcompass graph --open | ||
| # Auto-fix issues | ||
| devcompass fix | ||
| devcompass fix --batch | ||
| devcompass fix --dry-run | ||
| # Batch modes | ||
| devcompass fix --batch-mode critical | ||
| devcompass fix --batch-mode high | ||
| devcompass fix --batch-mode all | ||
| # Category-specific | ||
| devcompass fix --only quality | ||
| devcompass fix --skip updates | ||
| # Manage backups | ||
| devcompass backup list | ||
| devcompass backup restore --name <backup> | ||
| # CI/CD | ||
| devcompass analyze --json | ||
| devcompass analyze --ci | ||
| ``` | ||
| ### Graph Commands | ||
| ```bash | ||
| # Generate unified dashboard | ||
| devcompass graph | ||
| # Open in browser | ||
| devcompass graph --open | ||
| # Custom output | ||
| devcompass graph --output my-deps.html --open | ||
| # JSON export | ||
| devcompass graph --format json --output data.json | ||
| ``` | ||
| --- | ||
@@ -249,155 +452,113 @@ | ||
| ## 🎨 Unified Graph Visualization (v3.1.4) | ||
| ## 🔧 Configuration Files (v3.1.7) | ||
| DevCompass features a **revolutionary unified interactive graph** - all layouts, filters, clustering, and controls in one beautiful interface! | ||
| All configuration is now in external JSON files for easy customization! | ||
| ### 🎯 One Command, Everything Included | ||
| ### Data Files Location | ||
| ```bash | ||
| # Generate unified interactive graph | ||
| devcompass graph --open | ||
| ``` | ||
| devcompass/ | ||
| ├── data/ | ||
| │ ├── licenses.json # License categorization | ||
| │ ├── priorities.json # Severity levels | ||
| │ ├── knip-config.json # Unused deps config | ||
| │ ├── license-risks.json # License risk matrix | ||
| │ ├── gpl-alternatives.json # GPL replacements | ||
| │ ├── quality-alternatives.json # Deprecated alternatives | ||
| │ ├── popular-packages.json # Typosquatting detection | ||
| │ ├── batch-categories.json # Fix categories | ||
| │ └── tracked-repos.json # GitHub tracked packages (502) | ||
| ``` | ||
| **What you get in ONE file:** | ||
| - ✅ 4 layouts (Tree/Force/Radial/Conflict) - switchable via buttons | ||
| - ✅ 5 filters (All/Vulnerable/Outdated/Deprecated/Unused) - instant filtering | ||
| - ✅ **3 clustering modes (Ecosystem/Health/Depth) - NEW in v3.1.6!** | ||
| - ✅ Depth slider (1-10, ∞) | ||
| - ✅ Live search | ||
| - ✅ Advanced zoom controls | ||
| - ✅ Export (PNG/JSON) | ||
| - ✅ Fullscreen mode | ||
| - ✅ Live statistics | ||
| - ✅ Professional UI | ||
| - ✅ **No page reload!** | ||
| ### Customization Examples | ||
| ### 🎮 Interactive Controls | ||
| **1. Add Custom License to Detection:** | ||
| #### **Layout Switcher** | ||
| Edit `data/licenses.json`: | ||
| ```json | ||
| { | ||
| "restrictive": [ | ||
| "GPL", "GPL-2.0", "GPL-3.0", | ||
| "AGPL", "AGPL-3.0", | ||
| "SSPL", | ||
| "YOUR-CUSTOM-LICENSE" | ||
| ], | ||
| "permissive": [ | ||
| "MIT", "Apache-2.0", "BSD-2-Clause", | ||
| "BSD-3-Clause", "ISC", "0BSD", "Unlicense" | ||
| ] | ||
| } | ||
| ``` | ||
| Click to switch between: | ||
| **2. Whitelist Internal Packages:** | ||
| 1. **🌳 Tree** - Hierarchical view (root → children) | ||
| 2. **🌀 Force** - Physics-based network (drag nodes!) | ||
| 3. **⭕ Radial** - Circular view (root at center) | ||
| 4. **⚠️ Conflict** - Issues-only view | ||
| Edit `data/popular-packages.json`: | ||
| ```json | ||
| { | ||
| "packages": ["express", "react", "lodash", ...], | ||
| "whitelist": [ | ||
| "chalk", "ora", "inquirer", | ||
| "your-internal-package", | ||
| "your-company-library" | ||
| ] | ||
| } | ||
| ``` | ||
| #### **Filter Controls** | ||
| **3. Adjust Severity Colors:** | ||
| Click to filter packages: | ||
| Edit `data/priorities.json`: | ||
| ```json | ||
| { | ||
| "CRITICAL": { | ||
| "level": 1, | ||
| "label": "CRITICAL", | ||
| "color": "red", | ||
| "emoji": "🔴" | ||
| }, | ||
| "HIGH": { | ||
| "level": 2, | ||
| "label": "HIGH", | ||
| "color": "orange", | ||
| "emoji": "🟠" | ||
| } | ||
| } | ||
| ``` | ||
| - **All** - Show everything | ||
| - **Vulnerable** - Security issues only | ||
| - **Outdated** - Update available | ||
| - **Deprecated** - Officially deprecated | ||
| - **Unused** - Unused dependencies | ||
| **4. Add Deprecated Package Alternative:** | ||
| #### **Clustering (NEW!)** | ||
| Click to organize packages: | ||
| - **⚛️ Ecosystem** - Group by technology | ||
| - **🏥 Health** - Group by status | ||
| - **📊 Depth** - Group by level | ||
| #### **Other Controls** | ||
| - **Depth Slider** - Control dependency depth (1-∞) | ||
| - **Search** - Find packages instantly | ||
| - **Zoom** - In/Out/Reset/Fit/Center | ||
| - **Export** - PNG image or JSON data | ||
| - **Fullscreen** - Immersive view | ||
| --- | ||
| ## ✨ All Features | ||
| - 🔲 **Intelligent Clustering** (v3.1.6) - Ecosystem/Health/Depth grouping | ||
| - 🔑 **GitHub Token Config** (v3.1.5) - User tokens, no rate limits | ||
| - 📦 **502 Tracked Packages** (v3.1.5) - Comprehensive monitoring | ||
| - 🎨 **Unified Interactive Graph** (v3.1.4) - One file, all features | ||
| - 🎯 **Graph Layout Fixes** (v3.1.2) - Tree/Radial properly fixed | ||
| - 🔄 **Dynamic Issues** (v3.1.2) - Real-time npm audit | ||
| - 🛡️ **Production Safety** (v3.1.1) - Bug fixes and hardening | ||
| - 📊 **Multiple Layouts** (v3.1.0) - Tree/Force/Radial/Conflict | ||
| - 📦 **Batch Fix Modes** (v2.8.5) - Granular control | ||
| - 💾 **Backup & Rollback** (v2.8.4) - Safe management | ||
| - 📦 **Quality Auto-Fix** (v2.8.3) - Replace abandoned packages | ||
| - ⚖️ **License Auto-Fix** (v2.8.2) - GPL/AGPL replacement | ||
| - 🛡️ **Supply Chain Auto-Fix** (v2.8.1) - Remove malicious packages | ||
| - 🛡️ **Security Analysis** (v2.7) - Comprehensive security | ||
| - ⚡ **Parallel Processing** (v2.6) - 80% faster | ||
| - 🔮 **GitHub Tracking** (v2.4) - Real-time package health | ||
| - 🚀 **CI/CD Integration** (v2.2) - JSON output, exit codes | ||
| ## 🚀 Installation | ||
| ```bash | ||
| # Global (recommended) | ||
| npm install -g devcompass@3.1.6 | ||
| # Local | ||
| npm install --save-dev devcompass@3.1.6 | ||
| # One-time use | ||
| npx devcompass@3.1.6 analyze | ||
| # Upgrade | ||
| npm install -g devcompass@3.1.6 | ||
| Edit `data/quality-alternatives.json`: | ||
| ```json | ||
| { | ||
| "request": { | ||
| "replacement": "axios", | ||
| "reason": "request is deprecated" | ||
| }, | ||
| "your-old-package": { | ||
| "replacement": "your-new-package", | ||
| "reason": "your-old-package is abandoned" | ||
| } | ||
| } | ||
| ``` | ||
| ## 📖 Usage | ||
| ### Project-Specific Configuration | ||
| ### Basic Commands | ||
| Create `devcompass.config.json` in your project: | ||
| ```bash | ||
| # Configure GitHub token | ||
| devcompass config --github-token <your-token> | ||
| devcompass config --show | ||
| # Analyze project | ||
| devcompass analyze | ||
| # Generate graph (with clustering!) | ||
| devcompass graph --open | ||
| # Auto-fix issues | ||
| devcompass fix | ||
| devcompass fix --batch | ||
| devcompass fix --dry-run | ||
| # Batch modes | ||
| devcompass fix --batch-mode critical | ||
| devcompass fix --batch-mode high | ||
| devcompass fix --batch-mode all | ||
| # Category-specific | ||
| devcompass fix --only quality | ||
| devcompass fix --skip updates | ||
| # Manage backups | ||
| devcompass backup list | ||
| devcompass backup restore --name <backup> | ||
| # CI/CD | ||
| devcompass analyze --json | ||
| devcompass analyze --ci | ||
| ```json | ||
| { | ||
| "ignore": ["package-name"], | ||
| "ignoreSeverity": ["low"], | ||
| "minSeverity": "medium", | ||
| "minScore": 7, | ||
| "cache": true | ||
| } | ||
| ``` | ||
| ### Graph Commands | ||
| **Options:** | ||
| - **ignore** - Packages to skip | ||
| - **ignoreSeverity** - Severities to ignore (low/medium/high/critical) | ||
| - **minSeverity** - Minimum to display | ||
| - **minScore** - Minimum health score for CI (default: 7) | ||
| - **cache** - Enable caching (default: true) | ||
| ```bash | ||
| # Generate unified graph | ||
| devcompass graph | ||
| # Open in browser | ||
| devcompass graph --open | ||
| # Custom output | ||
| devcompass graph --output my-deps.html --open | ||
| # JSON export | ||
| devcompass graph --format json --output data.json | ||
| ``` | ||
| --- | ||
@@ -418,3 +579,3 @@ | ||
| **Data Source:** `data/tracked-repos.json` | ||
| **Data Source:** `data/tracked-repos.json` (customizable!) | ||
@@ -474,25 +635,4 @@ --- | ||
| --- | ||
| **Category Configuration:** Edit `data/batch-categories.json` to customize! | ||
| ## 🎯 Configuration | ||
| Create `devcompass.config.json`: | ||
| ```json | ||
| { | ||
| "ignore": ["package-name"], | ||
| "ignoreSeverity": ["low"], | ||
| "minSeverity": "medium", | ||
| "minScore": 7, | ||
| "cache": true | ||
| } | ||
| ``` | ||
| **Options:** | ||
| - **ignore** - Packages to skip | ||
| - **ignoreSeverity** - Severities to ignore (low/medium/high/critical) | ||
| - **minSeverity** - Minimum to display | ||
| - **minScore** - Minimum health score for CI (default: 7) | ||
| - **cache** - Enable caching (default: true) | ||
| --- | ||
@@ -506,3 +646,3 @@ | ||
| ```bash | ||
| npm install -g devcompass@3.1.6 | ||
| npm install -g devcompass@3.2.0 | ||
| ``` | ||
@@ -513,3 +653,3 @@ | ||
| npm update -g devcompass | ||
| devcompass --version # Should show 3.1.6 | ||
| devcompass --version # Should show 3.2.0 | ||
| ``` | ||
@@ -543,11 +683,31 @@ | ||
| **Clustering not showing** | ||
| **Theme not switching** | ||
| ```bash | ||
| # Ensure v3.1.6 | ||
| # Clear browser cache | ||
| # Hard refresh (Ctrl+F5) | ||
| # Check console for errors (F12) | ||
| ``` | ||
| **Analytics tab empty** | ||
| ```bash | ||
| # Ensure v3.2.0 | ||
| devcompass --version | ||
| # Clear cache | ||
| # Hard refresh browser | ||
| # Regenerate graph | ||
| devcompass graph --output new.html --open | ||
| ``` | ||
| **Customize configuration not working** | ||
| ```bash | ||
| # Check file exists | ||
| ls -la ~/devCompass/data/ | ||
| # Validate JSON | ||
| cat ~/devCompass/data/licenses.json | jq . | ||
| # Restart after editing | ||
| devcompass analyze | ||
| ``` | ||
| --- | ||
@@ -559,8 +719,23 @@ | ||
| 1. Fork the repository | ||
| 2. Create feature branch (`git checkout -b feature/amazing`) | ||
| 3. Commit changes (`git commit -m 'Add feature'`) | ||
| 4. Push branch (`git push origin feature/amazing`) | ||
| 5. Open Pull Request | ||
| ### Ways to Contribute: | ||
| 1. **Add Package Alternatives** | ||
| - Edit `data/quality-alternatives.json` | ||
| - Submit PR with new deprecated package alternatives | ||
| 2. **Expand License Database** | ||
| - Edit `data/license-risks.json` | ||
| - Add new license types and risk levels | ||
| 3. **Improve Typosquatting Detection** | ||
| - Edit `data/popular-packages.json` | ||
| - Add more popular packages to track | ||
| 4. **Code Contributions** | ||
| - Fork the repository | ||
| - Create feature branch (`git checkout -b feature/amazing`) | ||
| - Commit changes (`git commit -m 'Add feature'`) | ||
| - Push branch (`git push origin feature/amazing`) | ||
| - Open Pull Request | ||
| --- | ||
@@ -578,15 +753,19 @@ | ||
| - [x] GitHub token configuration (v3.1.5) | ||
| - [x] 502 tracked packages (v3.1.5) | ||
| - [x] Unified graph system (v3.1.4) | ||
| - [x] Dynamic layout switching (v3.1.4) | ||
| - [x] Real-time filtering (v3.1.4) | ||
| - [x] Advanced zoom controls (v3.1.4) | ||
| - [x] **Intelligent clustering (v3.1.6)** ✅ | ||
| - [x] **Ecosystem grouping (v3.1.6)** ✅ | ||
| - [x] **Health clustering (v3.1.6)** ✅ | ||
| - [x] **Depth analysis (v3.1.6)** ✅ | ||
| - [x] Unified dashboard architecture (v3.2.0) ✅ | ||
| - [x] Analytics layout (v3.2.0) ✅ | ||
| - [x] Theme support (v3.2.0) ✅ | ||
| - [x] Performance optimizations (v3.2.0) ✅ | ||
| - [x] Dynamic data configuration (v3.1.7) ✅ | ||
| - [x] GitHub token configuration (v3.1.5) ✅ | ||
| - [x] 502 tracked packages (v3.1.5) ✅ | ||
| - [x] Unified graph system (v3.1.4) ✅ | ||
| - [x] Dynamic layout switching (v3.1.4) ✅ | ||
| - [x] Real-time filtering (v3.1.4) ✅ | ||
| - [x] Advanced zoom controls (v3.1.4) ✅ | ||
| - [x] Intelligent clustering (v3.1.6) ✅ | ||
| - [x] Ecosystem grouping (v3.1.6) ✅ | ||
| - [x] Health clustering (v3.1.6) ✅ | ||
| - [x] Depth analysis (v3.1.6) ✅ | ||
| ### Planned Features: | ||
| - [ ] **Web Dashboard** - Team health monitoring | ||
@@ -602,6 +781,47 @@ - [ ] **Monorepo Support** - Multi-project analysis | ||
| ## 📊 Version History | ||
| ### v3.2.0 (2026-04-25) - Unified Dashboard Architecture | ||
| - 🎨 Unified modular dashboard (50% code reduction) | ||
| - 📊 NEW Analytics layout - Statistics dashboard | ||
| - 🌙 Dark/light theme support | ||
| - ⚡ 4-6× performance improvements | ||
| - 🗂️ 12 modular files (6 JS, 5 CSS, 1 HTML) | ||
| - ❌ Removed 5 duplicated files (3,600 lines → 1,800 lines) | ||
| - ✅ 100% backward compatible | ||
| ### v3.1.7 (2026-04-22) - Dynamic Data Configuration | ||
| - 🔧 8 new JSON configuration files | ||
| - ✅ 7 source files refactored for dynamic loading | ||
| - ✅ Zero hardcoded data in code | ||
| - ✅ Scalable and customizable architecture | ||
| - 🐛 Fixed unused deps showing as `undefined` | ||
| - 🐛 Fixed supply chain analysis bugs | ||
| - 🐛 Fixed license risk analysis bugs | ||
| ### v3.1.6 (2026-04-22) - Intelligent Clustering | ||
| - 🔲 Ecosystem clustering (12 categories) | ||
| - 🔲 Health clustering (Critical/Warning/Healthy) | ||
| - 🔲 Depth clustering (Direct → Level N) | ||
| - ✨ Click-to-highlight packages | ||
| - 📊 Per-cluster statistics | ||
| ### v3.1.5 (2026-04-21) - GitHub Token Support | ||
| - 🔑 User-configurable GitHub tokens | ||
| - 📦 502 tracked packages database | ||
| - ⚡ 5,000 requests/hour (vs 60) | ||
| - 🔒 Secure local storage | ||
| ### v3.1.4 (2026-04-20) - Unified Graph | ||
| - 🎨 All layouts in one file | ||
| - 🔄 Dynamic layout switching | ||
| - 🎯 Real-time filtering | ||
| - 🔍 Advanced zoom controls | ||
| --- | ||
| **Made with ❤️ by [Ajay Thorat](https://github.com/AjayBThorat-20)** | ||
| *DevCompass - Keep your dependencies healthy!* 🧭 | ||
| *DevCompass v3.2.0 - Unified, Modular, Beautiful!* 🧭 | ||
| **Like Lighthouse for your dependencies** ⚡ |
@@ -43,11 +43,3 @@ // src/commands/analyze.js | ||
| /** | ||
| * v3.1.3 - analyzeProject() for graph enrichment | ||
| * Returns structured analysis data without console output | ||
| * Used by graph command to enrich nodes with vulnerability/outdated/unused flags | ||
| * | ||
| * @param {string} projectPath - Path to project directory | ||
| * @param {object} options - Options (silent mode enabled by default) | ||
| * @returns {object|null} Analysis results or null on failure | ||
| */ | ||
| async function analyzeProject(projectPath, options = {}) { | ||
@@ -54,0 +46,0 @@ const config = loadConfig(projectPath); |
+366
-223
| // src/graph/exporter.js | ||
| // v3.1.6 - Unified graph exporter with clustering support | ||
@@ -7,35 +6,4 @@ const fs = require('fs'); | ||
| // Import layout generators | ||
| let generateTreeLayoutHTML, generateRadialLayoutHTML, generateForceLayoutHTML, generateConflictLayoutHTML; | ||
| try { | ||
| const tree = require('./layouts/tree'); | ||
| generateTreeLayoutHTML = tree.generateTreeLayoutHTML || tree.createTreeLayout || tree; | ||
| } catch (e) { | ||
| generateTreeLayoutHTML = null; | ||
| } | ||
| try { | ||
| const radial = require('./layouts/radial'); | ||
| generateRadialLayoutHTML = radial.generateRadialLayoutHTML || radial.generateRadialLayout || radial; | ||
| } catch (e) { | ||
| generateRadialLayoutHTML = null; | ||
| } | ||
| try { | ||
| const force = require('./layouts/force'); | ||
| generateForceLayoutHTML = force.generateForceLayoutHTML || force.generateForceLayout || force; | ||
| } catch (e) { | ||
| generateForceLayoutHTML = null; | ||
| } | ||
| try { | ||
| const conflict = require('./layouts/conflict'); | ||
| generateConflictLayoutHTML = conflict.generateConflictLayoutHTML || conflict; | ||
| } catch (e) { | ||
| generateConflictLayoutHTML = null; | ||
| } | ||
| /** | ||
| * GraphExporter - Exports graph data to various formats | ||
| * GraphExporter - Exports graph data using unified dashboard | ||
| */ | ||
@@ -52,3 +20,3 @@ class GraphExporter { | ||
| filter: options.filter || 'all', | ||
| unified: options.unified !== false, // Enable unified mode by default | ||
| unified: options.unified !== false, | ||
| ...options | ||
@@ -74,86 +42,36 @@ }; | ||
| /** | ||
| * Apply filter to graph data | ||
| * Generate unified dashboard HTML | ||
| */ | ||
| applyFilter() { | ||
| const filter = this.options.filter; | ||
| if (!filter || filter === 'all') { | ||
| return this.graphData; | ||
| } | ||
| const nodes = this.graphData.nodes; | ||
| const links = this.graphData.links; | ||
| let filteredNodes = []; | ||
| switch (filter) { | ||
| case 'vulnerable': | ||
| filteredNodes = nodes.filter(n => | ||
| n.type === 'root' || | ||
| n.isVulnerable === true || | ||
| (Array.isArray(n.issues) && n.issues.some(i => | ||
| i.type === 'security' || i.type === 'vulnerability' | ||
| )) | ||
| ); | ||
| break; | ||
| case 'outdated': | ||
| filteredNodes = nodes.filter(n => | ||
| n.type === 'root' || | ||
| n.isOutdated === true || | ||
| (Array.isArray(n.issues) && n.issues.some(i => i.type === 'outdated')) | ||
| ); | ||
| break; | ||
| case 'unused': | ||
| filteredNodes = nodes.filter(n => | ||
| n.type === 'root' || | ||
| n.isUnused === true || | ||
| (Array.isArray(n.issues) && n.issues.some(i => i.type === 'unused')) | ||
| ); | ||
| break; | ||
| case 'deprecated': | ||
| filteredNodes = nodes.filter(n => | ||
| n.type === 'root' || | ||
| n.isDeprecated === true || | ||
| (Array.isArray(n.issues) && n.issues.some(i => i.type === 'deprecated')) | ||
| ); | ||
| break; | ||
| case 'conflict': | ||
| filteredNodes = nodes.filter(n => | ||
| n.type === 'root' || | ||
| (Array.isArray(n.issues) && n.issues.length > 0) || | ||
| (n.healthScore !== undefined && n.healthScore < 7) | ||
| ); | ||
| break; | ||
| default: | ||
| filteredNodes = nodes; | ||
| } | ||
| const nodeIds = new Set(filteredNodes.map(n => n.id)); | ||
| const filteredLinks = links.filter(l => { | ||
| const sourceId = typeof l.source === 'object' ? l.source.id : l.source; | ||
| const targetId = typeof l.target === 'object' ? l.target.id : l.target; | ||
| return nodeIds.has(sourceId) && nodeIds.has(targetId); | ||
| }); | ||
| return { | ||
| nodes: filteredNodes, | ||
| links: filteredLinks, | ||
| metadata: this.graphData.metadata | ||
| }; | ||
| } | ||
| /** | ||
| * Generate unified HTML with dynamic controls and clustering (v3.1.6) | ||
| */ | ||
| generateUnifiedHTML() { | ||
| const templatePath = path.join(__dirname, 'template.html'); | ||
| generateHTML() { | ||
| const dashboardIndexPath = path.join(__dirname, '../dashboard/index.html'); | ||
| const clusteringPath = path.join(__dirname, 'clustering.js'); | ||
| try { | ||
| let template = fs.readFileSync(templatePath, 'utf8'); | ||
| // Check if dashboard exists | ||
| if (!fs.existsSync(dashboardIndexPath)) { | ||
| console.warn('Dashboard not found at:', dashboardIndexPath); | ||
| console.warn('Falling back to minimal graph...'); | ||
| return this.generateFallbackHTML(); | ||
| } | ||
| // Read dashboard template | ||
| let html = fs.readFileSync(dashboardIndexPath, 'utf8'); | ||
| // Read clustering code (v3.1.6) | ||
| // Add metadata to graph data | ||
| const enrichedData = { | ||
| ...this.graphData, | ||
| metadata: { | ||
| ...this.graphData.metadata, | ||
| projectName: this.options.projectName, | ||
| projectVersion: this.options.projectVersion, | ||
| defaultLayout: this.options.layout, | ||
| defaultFilter: this.options.filter, | ||
| generatedAt: new Date().toISOString() | ||
| } | ||
| }; | ||
| // Inject graph data | ||
| html = html.replace('{{GRAPH_DATA}}', JSON.stringify(enrichedData, null, 2)); | ||
| // Read and inject clustering code | ||
| let clusteringCode = ''; | ||
@@ -163,3 +81,3 @@ if (fs.existsSync(clusteringPath)) { | ||
| // Remove Node.js exports from clustering code for browser | ||
| // Remove Node.js exports for browser | ||
| clusteringCode = clusteringCode.replace( | ||
@@ -171,15 +89,12 @@ /if \(typeof module !== 'undefined' && module\.exports\) \{[\s\S]*?\}/g, | ||
| // Inject graph data | ||
| template = template.replace('{{GRAPH_DATA}}', JSON.stringify(this.graphData, null, 2)); | ||
| html = html.replace('{{CLUSTERING_CODE}}', clusteringCode); | ||
| // Inject clustering code (v3.1.6) | ||
| template = template.replace('{{CLUSTERING_CODE}}', clusteringCode); | ||
| // Inline all assets (CSS and JS) | ||
| html = this.inlineAllAssets(html); | ||
| return template; | ||
| return html; | ||
| } catch (error) { | ||
| console.error('Failed to load unified template:', error.message); | ||
| console.error('Template path:', templatePath); | ||
| console.error('Clustering path:', clusteringPath); | ||
| // Fallback to traditional layout | ||
| return this.generateTraditionalHTML(); | ||
| console.error('Failed to generate dashboard HTML:', error.message); | ||
| console.error('Falling back to minimal graph...'); | ||
| return this.generateFallbackHTML(); | ||
| } | ||
@@ -189,62 +104,52 @@ } | ||
| /** | ||
| * Generate traditional HTML (single layout) | ||
| * Inline all CSS and JS assets into HTML | ||
| * @param {string} html - HTML content | ||
| * @returns {string} HTML with inlined assets | ||
| */ | ||
| generateTraditionalHTML() { | ||
| const filteredData = this.applyFilter(); | ||
| const layout = (this.options.layout || 'tree').toLowerCase(); | ||
| try { | ||
| switch (layout) { | ||
| case 'force': | ||
| if (typeof generateForceLayoutHTML === 'function') { | ||
| return generateForceLayoutHTML(filteredData, this.options); | ||
| } | ||
| break; | ||
| case 'radial': | ||
| if (typeof generateRadialLayoutHTML === 'function') { | ||
| return generateRadialLayoutHTML(filteredData, this.options); | ||
| } | ||
| break; | ||
| case 'conflict': | ||
| if (typeof generateConflictLayoutHTML === 'function') { | ||
| return generateConflictLayoutHTML(filteredData, this.options); | ||
| } | ||
| break; | ||
| case 'tree': | ||
| default: | ||
| if (typeof generateTreeLayoutHTML === 'function') { | ||
| return generateTreeLayoutHTML(filteredData, this.options); | ||
| } | ||
| break; | ||
| inlineAllAssets(html) { | ||
| const dashboardDir = path.join(__dirname, '../dashboard'); | ||
| // Inline CSS files | ||
| const cssRegex = /<link rel="stylesheet" href="styles\/(.*?\.css)">/g; | ||
| html = html.replace(cssRegex, (match, filename) => { | ||
| const cssPath = path.join(dashboardDir, 'styles', filename); | ||
| try { | ||
| if (fs.existsSync(cssPath)) { | ||
| const cssContent = fs.readFileSync(cssPath, 'utf8'); | ||
| return `<style>\n/* ${filename} */\n${cssContent}\n</style>`; | ||
| } | ||
| } catch (error) { | ||
| console.warn(`Could not inline CSS: ${filename}`); | ||
| } | ||
| } catch (error) { | ||
| console.error(`Layout error (${layout}):`, error.message); | ||
| } | ||
| // Final fallback | ||
| return this.generateFallbackHTML(filteredData, layout); | ||
| } | ||
| /** | ||
| * Generate HTML content | ||
| */ | ||
| generateHTML() { | ||
| // Use unified template by default | ||
| if (this.options.unified) { | ||
| return this.generateUnifiedHTML(); | ||
| } | ||
| return match; // Keep original if file not found | ||
| }); | ||
| // Fallback to traditional single-layout mode | ||
| return this.generateTraditionalHTML(); | ||
| // Inline JavaScript files (except D3.js CDN) | ||
| const jsRegex = /<script src="scripts\/(.*?\.js)"><\/script>/g; | ||
| html = html.replace(jsRegex, (match, filename) => { | ||
| const jsPath = path.join(dashboardDir, 'scripts', filename); | ||
| try { | ||
| if (fs.existsSync(jsPath)) { | ||
| const jsContent = fs.readFileSync(jsPath, 'utf8'); | ||
| return `<script>\n/* ${filename} */\n${jsContent}\n</script>`; | ||
| } | ||
| } catch (error) { | ||
| console.warn(`Could not inline JS: ${filename}`); | ||
| } | ||
| return match; // Keep original if file not found | ||
| }); | ||
| return html; | ||
| } | ||
| /** | ||
| * Fallback HTML generator | ||
| * Fallback HTML generator (minimal working graph) | ||
| */ | ||
| generateFallbackHTML(graphData, layoutType) { | ||
| const nodes = Array.isArray(graphData.nodes) ? graphData.nodes : []; | ||
| const links = Array.isArray(graphData.links) ? graphData.links : []; | ||
| generateFallbackHTML() { | ||
| const nodes = Array.isArray(this.graphData.nodes) ? this.graphData.nodes : []; | ||
| const links = Array.isArray(this.graphData.links) ? this.graphData.links : []; | ||
@@ -256,31 +161,273 @@ return `<!DOCTYPE html> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| <title>DevCompass - Dependency Graph</title> | ||
| <title>DevCompass v3.2.0 - Dependency Graph</title> | ||
| <script src="https://d3js.org/d3.v7.min.js"></script> | ||
| <style> | ||
| body { font-family: system-ui, sans-serif; background: #0f172a; color: #f1f5f9; margin: 0; } | ||
| svg { width: 100vw; height: 100vh; } | ||
| .node circle { fill: #3b82f6; stroke: #fff; stroke-width: 2px; } | ||
| .node text { fill: #94a3b8; font-size: 10px; } | ||
| .link { stroke: #475569; stroke-width: 1.5px; stroke-opacity: 0.6; } | ||
| * { margin: 0; padding: 0; box-sizing: border-box; } | ||
| body { | ||
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | ||
| background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%); | ||
| color: #f1f5f9; | ||
| margin: 0; | ||
| overflow: hidden; | ||
| } | ||
| .header { | ||
| background: rgba(30, 41, 59, 0.95); | ||
| padding: 1rem 1.5rem; | ||
| border-bottom: 1px solid #475569; | ||
| display: flex; | ||
| justify-content: space-between; | ||
| align-items: center; | ||
| } | ||
| .header h1 { | ||
| font-size: 1.25rem; | ||
| font-weight: 600; | ||
| margin: 0; | ||
| } | ||
| .header .meta { | ||
| font-size: 0.875rem; | ||
| color: #94a3b8; | ||
| } | ||
| svg { | ||
| width: 100vw; | ||
| height: calc(100vh - 80px); | ||
| cursor: grab; | ||
| } | ||
| svg:active { cursor: grabbing; } | ||
| .node circle { | ||
| fill: #3b82f6; | ||
| stroke: #0f172a; | ||
| stroke-width: 2px; | ||
| cursor: pointer; | ||
| transition: all 0.2s; | ||
| } | ||
| .node circle:hover { | ||
| fill: #60a5fa; | ||
| stroke: #fff; | ||
| stroke-width: 3px; | ||
| } | ||
| .node text { | ||
| fill: #94a3b8; | ||
| font-size: 11px; | ||
| pointer-events: none; | ||
| user-select: none; | ||
| } | ||
| .link { | ||
| stroke: #475569; | ||
| stroke-width: 1.5px; | ||
| stroke-opacity: 0.6; | ||
| } | ||
| .controls { | ||
| position: fixed; | ||
| bottom: 20px; | ||
| right: 20px; | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 0.5rem; | ||
| z-index: 1000; | ||
| } | ||
| .btn { | ||
| width: 48px; | ||
| height: 48px; | ||
| background: rgba(30, 41, 59, 0.95); | ||
| border: 1px solid #475569; | ||
| border-radius: 12px; | ||
| color: #f1f5f9; | ||
| font-size: 1.25rem; | ||
| cursor: pointer; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| transition: all 0.2s; | ||
| } | ||
| .btn:hover { | ||
| background: #3b82f6; | ||
| border-color: #3b82f6; | ||
| transform: scale(1.1); | ||
| } | ||
| .zoom-level { | ||
| width: 48px; | ||
| padding: 0.5rem; | ||
| background: rgba(30, 41, 59, 0.95); | ||
| border: 1px solid #475569; | ||
| border-radius: 12px; | ||
| text-align: center; | ||
| color: #60a5fa; | ||
| font-size: 0.75rem; | ||
| font-weight: 700; | ||
| } | ||
| .tooltip { | ||
| position: absolute; | ||
| background: rgba(15, 23, 42, 0.98); | ||
| border: 1px solid #3b82f6; | ||
| border-radius: 8px; | ||
| padding: 0.75rem 1rem; | ||
| pointer-events: none; | ||
| opacity: 0; | ||
| transition: opacity 0.2s; | ||
| z-index: 10000; | ||
| font-size: 0.875rem; | ||
| max-width: 250px; | ||
| } | ||
| .tooltip.visible { opacity: 1; } | ||
| .tooltip-title { | ||
| font-weight: 600; | ||
| color: #60a5fa; | ||
| margin-bottom: 0.5rem; | ||
| } | ||
| .tooltip-row { | ||
| display: flex; | ||
| justify-content: space-between; | ||
| margin: 0.25rem 0; | ||
| font-size: 0.8rem; | ||
| } | ||
| .tooltip-label { color: #94a3b8; } | ||
| .tooltip-value { color: #f1f5f9; font-weight: 600; } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <div class="header"> | ||
| <h1>📊 DevCompass v3.2.0 - Minimal Graph</h1> | ||
| <div class="meta"> | ||
| Project: ${this.options.projectName} v${this.options.projectVersion} | | ||
| ${nodes.length} packages | ||
| </div> | ||
| </div> | ||
| <svg id="graph"></svg> | ||
| <div class="controls"> | ||
| <button class="btn" onclick="zoomIn()" title="Zoom In">+</button> | ||
| <div class="zoom-level" id="zoomLevel">100%</div> | ||
| <button class="btn" onclick="zoomOut()" title="Zoom Out">−</button> | ||
| <button class="btn" onclick="resetZoom()" title="Reset">⟲</button> | ||
| </div> | ||
| <div class="tooltip" id="tooltip"></div> | ||
| <script> | ||
| const data = ${JSON.stringify({ nodes, links })}; | ||
| const graphData = ${JSON.stringify({ nodes, links })}; | ||
| const svg = d3.select("#graph"); | ||
| const width = window.innerWidth; | ||
| const height = window.innerHeight; | ||
| const simulation = d3.forceSimulation(data.nodes) | ||
| .force("link", d3.forceLink(data.links).id(d => d.id)) | ||
| .force("charge", d3.forceManyBody().strength(-200)) | ||
| .force("center", d3.forceCenter(width/2, height/2)); | ||
| const link = svg.append("g").selectAll("line").data(data.links).join("line").attr("class", "link"); | ||
| const node = svg.append("g").selectAll("g").data(data.nodes).join("g").attr("class", "node"); | ||
| node.append("circle").attr("r", 8); | ||
| node.append("text").attr("dy", -12).text(d => d.name); | ||
| const height = window.innerHeight - 80; | ||
| const g = svg.append("g"); | ||
| // Zoom behavior | ||
| const zoom = d3.zoom() | ||
| .scaleExtent([0.1, 4]) | ||
| .on("zoom", (event) => { | ||
| g.attr("transform", event.transform); | ||
| updateZoomLevel(event.transform.k); | ||
| }); | ||
| svg.call(zoom); | ||
| // Force simulation | ||
| const simulation = d3.forceSimulation(graphData.nodes) | ||
| .force("link", d3.forceLink(graphData.links).id(d => d.id).distance(100)) | ||
| .force("charge", d3.forceManyBody().strength(-300)) | ||
| .force("center", d3.forceCenter(width/2, height/2)) | ||
| .force("collision", d3.forceCollide().radius(20)); | ||
| // Links | ||
| const link = g.append("g").selectAll("line") | ||
| .data(graphData.links) | ||
| .join("line") | ||
| .attr("class", "link"); | ||
| // Nodes | ||
| const node = g.append("g").selectAll("g") | ||
| .data(graphData.nodes) | ||
| .join("g") | ||
| .attr("class", "node") | ||
| .call(d3.drag() | ||
| .on("start", dragStart) | ||
| .on("drag", dragging) | ||
| .on("end", dragEnd)) | ||
| .on("mouseover", showTooltip) | ||
| .on("mouseout", hideTooltip); | ||
| node.append("circle") | ||
| .attr("r", d => d.type === 'root' ? 12 : 8); | ||
| node.append("text") | ||
| .attr("dy", -12) | ||
| .attr("text-anchor", "middle") | ||
| .text(d => { | ||
| const name = d.name || d.id; | ||
| return name.length > 15 ? name.substring(0, 12) + '...' : name; | ||
| }); | ||
| // Simulation tick | ||
| simulation.on("tick", () => { | ||
| link.attr("x1", d => d.source.x).attr("y1", d => d.source.y).attr("x2", d => d.target.x).attr("y2", d => d.target.y); | ||
| link | ||
| .attr("x1", d => d.source.x) | ||
| .attr("y1", d => d.source.y) | ||
| .attr("x2", d => d.target.x) | ||
| .attr("y2", d => d.target.y); | ||
| node.attr("transform", d => \`translate(\${d.x},\${d.y})\`); | ||
| }); | ||
| // Drag functions | ||
| function dragStart(event) { | ||
| if (!event.active) simulation.alphaTarget(0.3).restart(); | ||
| event.subject.fx = event.subject.x; | ||
| event.subject.fy = event.subject.y; | ||
| } | ||
| function dragging(event) { | ||
| event.subject.fx = event.x; | ||
| event.subject.fy = event.y; | ||
| } | ||
| function dragEnd(event) { | ||
| if (!event.active) simulation.alphaTarget(0); | ||
| event.subject.fx = null; | ||
| event.subject.fy = null; | ||
| } | ||
| // Tooltip | ||
| function showTooltip(event, d) { | ||
| const tooltip = document.getElementById('tooltip'); | ||
| tooltip.innerHTML = \` | ||
| <div class="tooltip-title">\${d.name || d.id}</div> | ||
| <div class="tooltip-row"> | ||
| <span class="tooltip-label">Version</span> | ||
| <span class="tooltip-value">\${d.version || 'N/A'}</span> | ||
| </div> | ||
| <div class="tooltip-row"> | ||
| <span class="tooltip-label">Depth</span> | ||
| <span class="tooltip-value">\${d.depth || 0}</span> | ||
| </div> | ||
| <div class="tooltip-row"> | ||
| <span class="tooltip-label">Health</span> | ||
| <span class="tooltip-value">\${d.healthScore || 8}/10</span> | ||
| </div> | ||
| \`; | ||
| tooltip.style.left = (event.pageX + 10) + 'px'; | ||
| tooltip.style.top = (event.pageY + 10) + 'px'; | ||
| tooltip.classList.add('visible'); | ||
| } | ||
| function hideTooltip() { | ||
| document.getElementById('tooltip').classList.remove('visible'); | ||
| } | ||
| // Zoom controls | ||
| function zoomIn() { | ||
| svg.transition().duration(300).call(zoom.scaleBy, 1.3); | ||
| } | ||
| function zoomOut() { | ||
| svg.transition().duration(300).call(zoom.scaleBy, 0.7); | ||
| } | ||
| function resetZoom() { | ||
| svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity); | ||
| } | ||
| function updateZoomLevel(scale) { | ||
| document.getElementById('zoomLevel').textContent = Math.round(scale * 100) + '%'; | ||
| } | ||
| </script> | ||
@@ -295,31 +442,8 @@ </body> | ||
| generateJSON() { | ||
| const filteredData = this.applyFilter(); | ||
| return JSON.stringify(filteredData, null, 2); | ||
| return JSON.stringify(this.graphData, null, 2); | ||
| } | ||
| /** | ||
| * Get file size helper | ||
| * Export to file | ||
| */ | ||
| getFileSize(filePath) { | ||
| try { | ||
| const stats = fs.statSync(filePath); | ||
| const bytes = stats.size; | ||
| if (bytes < 1024) return bytes + ' B'; | ||
| if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'; | ||
| return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; | ||
| } catch { | ||
| return 'Unknown'; | ||
| } | ||
| } | ||
| /** | ||
| * Export to file (main method) | ||
| */ | ||
| export(outputPath) { | ||
| return this.exportToFile(outputPath); | ||
| } | ||
| /** | ||
| * Export to file implementation | ||
| */ | ||
| exportToFile(outputPath) { | ||
@@ -364,4 +488,26 @@ try { | ||
| /** | ||
| * Export HTML (legacy method) | ||
| * Get file size helper | ||
| */ | ||
| getFileSize(filePath) { | ||
| try { | ||
| const stats = fs.statSync(filePath); | ||
| const bytes = stats.size; | ||
| if (bytes < 1024) return bytes + ' B'; | ||
| if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'; | ||
| return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; | ||
| } catch { | ||
| return 'Unknown'; | ||
| } | ||
| } | ||
| /** | ||
| * Main export method | ||
| */ | ||
| export(outputPath) { | ||
| return this.exportToFile(outputPath); | ||
| } | ||
| /** | ||
| * Legacy methods for backward compatibility | ||
| */ | ||
| exportHTML(outputPath) { | ||
@@ -373,5 +519,2 @@ const content = this.generateHTML(); | ||
| /** | ||
| * Export JSON (legacy method) | ||
| */ | ||
| exportJSON(outputPath) { | ||
@@ -378,0 +521,0 @@ const content = this.generateJSON(); |
+9
-664
| // src/graph/visualizer.js | ||
| // Unified graph visualizer - routes to appropriate layout generator | ||
| const { generateTreeLayoutHTML } = require('./layouts/tree'); | ||
| const { generateRadialLayoutHTML } = require('./layouts/radial'); | ||
| const { generateForceLayoutHTML } = require('./layouts/force'); | ||
| const { generateConflictLayoutHTML } = require('./layouts/conflict'); | ||
| const GraphExporter = require('./exporter'); | ||
| /** | ||
| * GraphVisualizer - Main entry point for graph visualization | ||
| * Generates HTML output for various layout types | ||
| * GraphVisualizer - Wrapper around GraphExporter for backward compatibility | ||
| */ | ||
| class GraphVisualizer { | ||
| constructor(graphData, options = {}) { | ||
| this.graphData = this.validateGraphData(graphData); | ||
| this.options = { | ||
| width: options.width || 1400, | ||
| height: options.height || 900, | ||
| layout: options.layout || 'tree', | ||
| projectName: options.projectName || 'Project', | ||
| projectVersion: options.projectVersion || '1.0.0', | ||
| filter: options.filter || 'all', | ||
| ...options | ||
| }; | ||
| this.exporter = new GraphExporter(graphData, options); | ||
| } | ||
| /** | ||
| * Validate and normalize graph data | ||
| * Generate HTML output | ||
| */ | ||
| validateGraphData(graphData) { | ||
| if (!graphData) { | ||
| return { nodes: [], links: [] }; | ||
| } | ||
| return { | ||
| nodes: Array.isArray(graphData.nodes) ? graphData.nodes : [], | ||
| links: Array.isArray(graphData.links) ? graphData.links : [], | ||
| metadata: graphData.metadata || {} | ||
| }; | ||
| } | ||
| /** | ||
| * Apply filter to graph data | ||
| */ | ||
| applyFilter(filter) { | ||
| if (!filter || filter === 'all') { | ||
| return this.graphData; | ||
| } | ||
| const nodes = this.graphData.nodes; | ||
| const links = this.graphData.links; | ||
| let filteredNodes = []; | ||
| switch (filter) { | ||
| case 'vulnerable': | ||
| filteredNodes = nodes.filter(n => | ||
| n.type === 'root' || | ||
| (Array.isArray(n.issues) && n.issues.some(i => i.type === 'security' || i.type === 'vulnerability')) | ||
| ); | ||
| break; | ||
| case 'outdated': | ||
| filteredNodes = nodes.filter(n => | ||
| n.type === 'root' || | ||
| n.isOutdated === true || | ||
| (Array.isArray(n.issues) && n.issues.some(i => i.type === 'outdated')) | ||
| ); | ||
| break; | ||
| case 'unused': | ||
| filteredNodes = nodes.filter(n => | ||
| n.type === 'root' || | ||
| n.isUnused === true || | ||
| (Array.isArray(n.issues) && n.issues.some(i => i.type === 'unused')) | ||
| ); | ||
| break; | ||
| case 'conflict': | ||
| filteredNodes = nodes.filter(n => | ||
| n.type === 'root' || | ||
| (Array.isArray(n.issues) && n.issues.length > 0) || | ||
| n.healthScore < 7 | ||
| ); | ||
| break; | ||
| default: | ||
| filteredNodes = nodes; | ||
| } | ||
| // Get IDs of filtered nodes | ||
| const nodeIds = new Set(filteredNodes.map(n => n.id)); | ||
| // Filter links to only include those between filtered nodes | ||
| const filteredLinks = links.filter(l => { | ||
| const sourceId = typeof l.source === 'object' ? l.source.id : l.source; | ||
| const targetId = typeof l.target === 'object' ? l.target.id : l.target; | ||
| return nodeIds.has(sourceId) && nodeIds.has(targetId); | ||
| }); | ||
| return { | ||
| nodes: filteredNodes, | ||
| links: filteredLinks, | ||
| metadata: this.graphData.metadata | ||
| }; | ||
| } | ||
| /** | ||
| * Generate HTML output for the configured layout | ||
| */ | ||
| generateHTML() { | ||
| const filteredData = this.applyFilter(this.options.filter); | ||
| const layout = this.options.layout.toLowerCase(); | ||
| switch (layout) { | ||
| case 'force': | ||
| return generateForceLayoutHTML(filteredData, this.options); | ||
| case 'radial': | ||
| return generateRadialLayoutHTML(filteredData, this.options); | ||
| case 'conflict': | ||
| return generateConflictLayoutHTML(filteredData, this.options); | ||
| case 'tree': | ||
| default: | ||
| return generateTreeLayoutHTML(filteredData, this.options); | ||
| } | ||
| return this.exporter.generateHTML(); | ||
| } | ||
| /** | ||
| * Get graph script for embedding (backward compatibility) | ||
| * Generate graph script (legacy) | ||
| */ | ||
| generateGraphScript() { | ||
| // For backward compatibility, return a script that can be embedded | ||
| return this.generateHTML(); | ||
@@ -148,3 +36,4 @@ } | ||
| { id: 'radial', name: 'Radial Layout', description: 'Concentric circles by depth' }, | ||
| { id: 'conflict', name: 'Conflict View', description: 'Shows only problematic packages' } | ||
| { id: 'conflict', name: 'Conflict View', description: 'Shows only problematic packages' }, | ||
| { id: 'analytics', name: 'Analytics Dashboard', description: 'Statistical overview' } | ||
| ]; | ||
@@ -154,548 +43,4 @@ } | ||
| /** | ||
| * Get available filters// src/graph/layouts/conflict.js | ||
| // Conflict-only view - shows packages with issues organized by severity | ||
| /** | ||
| * Generate conflict layout HTML | ||
| * Shows only packages with issues, organized by severity | ||
| * Get available filters | ||
| */ | ||
| function generateConflictLayoutHTML(graphData, options = {}) { | ||
| const projectName = options.projectName || 'Project'; | ||
| const projectVersion = options.projectVersion || '1.0.0'; | ||
| // Validate input | ||
| const nodes = Array.isArray(graphData.nodes) ? graphData.nodes : []; | ||
| const links = Array.isArray(graphData.links) ? graphData.links : []; | ||
| // Filter to only nodes with issues or low health scores | ||
| const conflictNodes = nodes.filter(n => { | ||
| if (n.type === 'root') return true; // Always show root | ||
| const hasIssues = Array.isArray(n.issues) && n.issues.length > 0; | ||
| const lowHealth = (n.healthScore || 10) < 7; | ||
| return hasIssues || lowHealth; | ||
| }); | ||
| // If no conflicts, show success message | ||
| if (conflictNodes.length <= 1) { | ||
| return generateNoConflictsHTML(projectName, projectVersion, nodes.length); | ||
| } | ||
| // Categorize by severity | ||
| const categorized = { | ||
| critical: [], | ||
| high: [], | ||
| medium: [], | ||
| low: [], | ||
| root: [] | ||
| }; | ||
| conflictNodes.forEach(node => { | ||
| if (node.type === 'root') { | ||
| categorized.root.push(node); | ||
| } else { | ||
| const score = node.healthScore || 8; | ||
| if (score < 3) categorized.critical.push(node); | ||
| else if (score < 5) categorized.high.push(node); | ||
| else if (score < 7) categorized.medium.push(node); | ||
| else categorized.low.push(node); | ||
| } | ||
| }); | ||
| const graphDataJSON = JSON.stringify({ | ||
| nodes: conflictNodes, | ||
| links, | ||
| categorized | ||
| }); | ||
| return `<!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| <title>DevCompass - Conflict View</title> | ||
| <script src="https://d3js.org/d3.v7.min.js"></script> | ||
| <style> | ||
| :root { | ||
| --bg-primary: #0f172a; | ||
| --bg-secondary: #1e293b; | ||
| --bg-tertiary: #334155; | ||
| --text-primary: #f1f5f9; | ||
| --text-secondary: #94a3b8; | ||
| --text-muted: #64748b; | ||
| --accent-blue: #3b82f6; | ||
| --accent-cyan: #06b6d4; | ||
| --border-color: #475569; | ||
| --critical: #ef4444; | ||
| --high: #f97316; | ||
| --medium: #eab308; | ||
| --low: #84cc16; | ||
| --root-color: #60a5fa; | ||
| } | ||
| * { margin: 0; padding: 0; box-sizing: border-box; } | ||
| body { | ||
| font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; | ||
| background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%); | ||
| color: var(--text-primary); | ||
| min-height: 100vh; | ||
| padding: 20px; | ||
| } | ||
| .container { | ||
| max-width: 1400px; | ||
| margin: 0 auto; | ||
| } | ||
| /* Header */ | ||
| .header { | ||
| text-align: center; | ||
| padding: 30px; | ||
| background: rgba(30, 41, 59, 0.8); | ||
| border-radius: 20px; | ||
| border: 1px solid var(--border-color); | ||
| margin-bottom: 30px; | ||
| backdrop-filter: blur(12px); | ||
| } | ||
| .header-title { | ||
| font-size: 28px; | ||
| font-weight: 700; | ||
| margin-bottom: 8px; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| gap: 12px; | ||
| } | ||
| .header-subtitle { | ||
| color: var(--text-secondary); | ||
| font-size: 14px; | ||
| } | ||
| .header-meta { | ||
| display: flex; | ||
| justify-content: center; | ||
| gap: 30px; | ||
| margin-top: 16px; | ||
| font-size: 13px; | ||
| color: var(--text-secondary); | ||
| } | ||
| .header-meta span { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 6px; | ||
| } | ||
| /* Summary Cards */ | ||
| .summary { | ||
| display: grid; | ||
| grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); | ||
| gap: 16px; | ||
| margin-bottom: 30px; | ||
| } | ||
| .summary-card { | ||
| background: rgba(30, 41, 59, 0.8); | ||
| border-radius: 16px; | ||
| border: 1px solid var(--border-color); | ||
| padding: 20px; | ||
| text-align: center; | ||
| backdrop-filter: blur(12px); | ||
| transition: transform 0.2s, box-shadow 0.2s; | ||
| } | ||
| .summary-card:hover { | ||
| transform: translateY(-4px); | ||
| box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3); | ||
| } | ||
| .summary-count { | ||
| font-size: 36px; | ||
| font-weight: 700; | ||
| margin-bottom: 6px; | ||
| } | ||
| .summary-label { | ||
| font-size: 12px; | ||
| color: var(--text-secondary); | ||
| text-transform: uppercase; | ||
| letter-spacing: 0.5px; | ||
| } | ||
| .summary-card.critical .summary-count { color: var(--critical); } | ||
| .summary-card.high .summary-count { color: var(--high); } | ||
| .summary-card.medium .summary-count { color: var(--medium); } | ||
| .summary-card.low .summary-count { color: var(--low); } | ||
| /* Severity Sections */ | ||
| .severity-section { | ||
| background: rgba(30, 41, 59, 0.8); | ||
| border-radius: 16px; | ||
| border: 1px solid var(--border-color); | ||
| margin-bottom: 20px; | ||
| overflow: hidden; | ||
| backdrop-filter: blur(12px); | ||
| } | ||
| .severity-header { | ||
| padding: 16px 24px; | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 12px; | ||
| border-bottom: 1px solid var(--border-color); | ||
| cursor: pointer; | ||
| transition: background 0.2s; | ||
| } | ||
| .severity-header:hover { | ||
| background: rgba(255, 255, 255, 0.03); | ||
| } | ||
| .severity-indicator { | ||
| width: 16px; | ||
| height: 16px; | ||
| border-radius: 50%; | ||
| } | ||
| .severity-title { | ||
| font-weight: 600; | ||
| font-size: 15px; | ||
| flex: 1; | ||
| } | ||
| .severity-count { | ||
| background: var(--bg-tertiary); | ||
| padding: 4px 12px; | ||
| border-radius: 20px; | ||
| font-size: 12px; | ||
| font-weight: 600; | ||
| } | ||
| .severity-toggle { | ||
| color: var(--text-muted); | ||
| font-size: 18px; | ||
| transition: transform 0.2s; | ||
| } | ||
| .severity-section.collapsed .severity-toggle { | ||
| transform: rotate(-90deg); | ||
| } | ||
| .severity-section.collapsed .severity-content { | ||
| display: none; | ||
| } | ||
| .severity-content { | ||
| padding: 16px; | ||
| } | ||
| /* Package Cards */ | ||
| .package-grid { | ||
| display: grid; | ||
| grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); | ||
| gap: 12px; | ||
| } | ||
| .package-card { | ||
| background: var(--bg-tertiary); | ||
| border-radius: 12px; | ||
| padding: 16px; | ||
| border: 1px solid var(--border-color); | ||
| transition: transform 0.2s, border-color 0.2s; | ||
| } | ||
| .package-card:hover { | ||
| transform: translateX(4px); | ||
| border-color: var(--accent-cyan); | ||
| } | ||
| .package-name { | ||
| font-weight: 600; | ||
| font-size: 14px; | ||
| color: var(--accent-cyan); | ||
| margin-bottom: 4px; | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 8px; | ||
| } | ||
| .package-version { | ||
| font-size: 12px; | ||
| color: var(--text-muted); | ||
| margin-bottom: 10px; | ||
| } | ||
| .package-issues { | ||
| display: flex; | ||
| flex-wrap: wrap; | ||
| gap: 6px; | ||
| } | ||
| .issue-tag { | ||
| font-size: 10px; | ||
| padding: 3px 8px; | ||
| border-radius: 6px; | ||
| background: rgba(255, 255, 255, 0.1); | ||
| color: var(--text-secondary); | ||
| } | ||
| .issue-tag.security { background: rgba(239, 68, 68, 0.2); color: var(--critical); } | ||
| .issue-tag.outdated { background: rgba(249, 115, 22, 0.2); color: var(--high); } | ||
| .issue-tag.deprecated { background: rgba(234, 179, 8, 0.2); color: var(--medium); } | ||
| .package-score { | ||
| margin-top: 10px; | ||
| padding-top: 10px; | ||
| border-top: 1px solid var(--border-color); | ||
| display: flex; | ||
| justify-content: space-between; | ||
| font-size: 12px; | ||
| } | ||
| .score-label { color: var(--text-secondary); } | ||
| .score-value { font-weight: 600; } | ||
| /* Empty state for sections */ | ||
| .empty-section { | ||
| padding: 30px; | ||
| text-align: center; | ||
| color: var(--text-muted); | ||
| } | ||
| /* Footer */ | ||
| .footer { | ||
| text-align: center; | ||
| padding: 20px; | ||
| color: var(--text-muted); | ||
| font-size: 12px; | ||
| } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <div class="container"> | ||
| <div class="header"> | ||
| <div class="header-title"> | ||
| ⚠️ Dependency Conflict View | ||
| </div> | ||
| <div class="header-subtitle"> | ||
| Packages requiring attention, organized by severity | ||
| </div> | ||
| <div class="header-meta"> | ||
| <span><strong>Project:</strong> ${projectName}</span> | ||
| <span><strong>Version:</strong> ${projectVersion}</span> | ||
| <span><strong>Total Deps:</strong> ${nodes.length}</span> | ||
| <span><strong>With Issues:</strong> ${conflictNodes.length - 1}</span> | ||
| </div> | ||
| </div> | ||
| <div class="summary"> | ||
| <div class="summary-card critical"> | ||
| <div class="summary-count" id="critical-count">0</div> | ||
| <div class="summary-label">Critical</div> | ||
| </div> | ||
| <div class="summary-card high"> | ||
| <div class="summary-count" id="high-count">0</div> | ||
| <div class="summary-label">High</div> | ||
| </div> | ||
| <div class="summary-card medium"> | ||
| <div class="summary-count" id="medium-count">0</div> | ||
| <div class="summary-label">Medium</div> | ||
| </div> | ||
| <div class="summary-card low"> | ||
| <div class="summary-count" id="low-count">0</div> | ||
| <div class="summary-label">Low</div> | ||
| </div> | ||
| </div> | ||
| <div id="sections"></div> | ||
| <div class="footer"> | ||
| Generated by DevCompass • ${new Date().toLocaleString()} | ||
| </div> | ||
| </div> | ||
| <script> | ||
| const graphData = ${graphDataJSON}; | ||
| const categorized = graphData.categorized; | ||
| // Update counts | ||
| document.getElementById('critical-count').textContent = categorized.critical.length; | ||
| document.getElementById('high-count').textContent = categorized.high.length; | ||
| document.getElementById('medium-count').textContent = categorized.medium.length; | ||
| document.getElementById('low-count').textContent = categorized.low.length; | ||
| // Generate sections | ||
| const sections = [ | ||
| { key: 'critical', title: 'Critical Issues', color: 'var(--critical)' }, | ||
| { key: 'high', title: 'High Priority', color: 'var(--high)' }, | ||
| { key: 'medium', title: 'Medium Priority', color: 'var(--medium)' }, | ||
| { key: 'low', title: 'Low Priority', color: 'var(--low)' } | ||
| ]; | ||
| const container = document.getElementById('sections'); | ||
| sections.forEach(section => { | ||
| const packages = categorized[section.key]; | ||
| if (packages.length === 0) return; | ||
| const sectionEl = document.createElement('div'); | ||
| sectionEl.className = 'severity-section'; | ||
| sectionEl.innerHTML = \` | ||
| <div class="severity-header" onclick="this.parentElement.classList.toggle('collapsed')"> | ||
| <div class="severity-indicator" style="background: \${section.color};"></div> | ||
| <div class="severity-title">\${section.title}</div> | ||
| <div class="severity-count">\${packages.length} package\${packages.length !== 1 ? 's' : ''}</div> | ||
| <div class="severity-toggle">▼</div> | ||
| </div> | ||
| <div class="severity-content"> | ||
| <div class="package-grid"> | ||
| \${packages.map(pkg => renderPackageCard(pkg, section.color)).join('')} | ||
| </div> | ||
| </div> | ||
| \`; | ||
| container.appendChild(sectionEl); | ||
| }); | ||
| function renderPackageCard(pkg, color) { | ||
| const issues = pkg.issues || []; | ||
| const score = pkg.healthScore || 5; | ||
| return \` | ||
| <div class="package-card"> | ||
| <div class="package-name"> | ||
| <span style="color: \${color};">●</span> | ||
| \${pkg.name || pkg.id} | ||
| </div> | ||
| <div class="package-version">v\${pkg.version || 'unknown'}</div> | ||
| <div class="package-issues"> | ||
| \${issues.map(i => \`<span class="issue-tag \${i.type || ''}">\${i.title || i.type || 'Issue'}</span>\`).join('')} | ||
| \${issues.length === 0 ? '<span class="issue-tag">Low health score</span>' : ''} | ||
| </div> | ||
| <div class="package-score"> | ||
| <span class="score-label">Health Score</span> | ||
| <span class="score-value" style="color: \${color};">\${score}/10</span> | ||
| </div> | ||
| </div> | ||
| \`; | ||
| } | ||
| </script> | ||
| </body> | ||
| </html>`; | ||
| } | ||
| /** | ||
| * Generate HTML for when no conflicts are found | ||
| */ | ||
| function generateNoConflictsHTML(projectName, projectVersion, totalDeps) { | ||
| return `<!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| <title>DevCompass - No Conflicts</title> | ||
| <style> | ||
| * { margin: 0; padding: 0; box-sizing: border-box; } | ||
| body { | ||
| font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; | ||
| background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%); | ||
| color: #f1f5f9; | ||
| min-height: 100vh; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| } | ||
| .success-card { | ||
| text-align: center; | ||
| padding: 60px 80px; | ||
| background: rgba(30, 41, 59, 0.95); | ||
| border-radius: 24px; | ||
| border: 1px solid #475569; | ||
| backdrop-filter: blur(12px); | ||
| box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); | ||
| } | ||
| .success-icon { | ||
| font-size: 80px; | ||
| margin-bottom: 24px; | ||
| animation: bounce 2s ease-in-out infinite; | ||
| } | ||
| @keyframes bounce { | ||
| 0%, 100% { transform: translateY(0); } | ||
| 50% { transform: translateY(-10px); } | ||
| } | ||
| .success-title { | ||
| font-size: 28px; | ||
| font-weight: 700; | ||
| margin-bottom: 12px; | ||
| color: #10b981; | ||
| } | ||
| .success-subtitle { | ||
| font-size: 16px; | ||
| color: #94a3b8; | ||
| margin-bottom: 30px; | ||
| } | ||
| .stats { | ||
| display: flex; | ||
| gap: 40px; | ||
| justify-content: center; | ||
| padding-top: 24px; | ||
| border-top: 1px solid #475569; | ||
| } | ||
| .stat { | ||
| text-align: center; | ||
| } | ||
| .stat-value { | ||
| font-size: 32px; | ||
| font-weight: 700; | ||
| color: #06b6d4; | ||
| } | ||
| .stat-label { | ||
| font-size: 12px; | ||
| color: #64748b; | ||
| text-transform: uppercase; | ||
| letter-spacing: 0.5px; | ||
| } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <div class="success-card"> | ||
| <div class="success-icon">🎉</div> | ||
| <div class="success-title">No Conflicts Found!</div> | ||
| <div class="success-subtitle"> | ||
| All dependencies in ${projectName} v${projectVersion} are healthy | ||
| </div> | ||
| <div class="stats"> | ||
| <div class="stat"> | ||
| <div class="stat-value">${totalDeps}</div> | ||
| <div class="stat-label">Dependencies</div> | ||
| </div> | ||
| <div class="stat"> | ||
| <div class="stat-value">0</div> | ||
| <div class="stat-label">Issues</div> | ||
| </div> | ||
| <div class="stat"> | ||
| <div class="stat-value">✓</div> | ||
| <div class="stat-label">All Healthy</div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </body> | ||
| </html>`; | ||
| } | ||
| module.exports = { | ||
| generateConflictLayoutHTML | ||
| }; | ||
| */ | ||
| function getAvailableFilters() { | ||
@@ -702,0 +47,0 @@ return [ |
| // src/graph/layouts/conflict.js | ||
| // Conflict-only view - shows packages with issues organized by severity | ||
| /** | ||
| * Generate conflict layout HTML | ||
| * Shows only packages with issues, organized by severity | ||
| */ | ||
| function generateConflictLayoutHTML(graphData, options = {}) { | ||
| const projectName = options.projectName || 'Project'; | ||
| const projectVersion = options.projectVersion || '1.0.0'; | ||
| // Validate input | ||
| const nodes = Array.isArray(graphData.nodes) ? graphData.nodes : []; | ||
| const links = Array.isArray(graphData.links) ? graphData.links : []; | ||
| // Filter to only nodes with issues or low health scores | ||
| const conflictNodes = nodes.filter(n => { | ||
| if (n.type === 'root') return true; // Always show root | ||
| const hasIssues = Array.isArray(n.issues) && n.issues.length > 0; | ||
| const lowHealth = (n.healthScore || 10) < 7; | ||
| return hasIssues || lowHealth; | ||
| }); | ||
| // If no conflicts, show success message | ||
| if (conflictNodes.length <= 1) { | ||
| return generateNoConflictsHTML(projectName, projectVersion, nodes.length); | ||
| } | ||
| // Categorize by severity | ||
| const categorized = { | ||
| critical: [], | ||
| high: [], | ||
| medium: [], | ||
| low: [], | ||
| root: [] | ||
| }; | ||
| conflictNodes.forEach(node => { | ||
| if (node.type === 'root') { | ||
| categorized.root.push(node); | ||
| } else { | ||
| const score = node.healthScore || 8; | ||
| if (score < 3) categorized.critical.push(node); | ||
| else if (score < 5) categorized.high.push(node); | ||
| else if (score < 7) categorized.medium.push(node); | ||
| else categorized.low.push(node); | ||
| } | ||
| }); | ||
| const graphDataJSON = JSON.stringify({ | ||
| nodes: conflictNodes, | ||
| links, | ||
| categorized | ||
| }); | ||
| return `<!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| <title>DevCompass - Conflict View</title> | ||
| <script src="https://d3js.org/d3.v7.min.js"></script> | ||
| <style> | ||
| :root { | ||
| --bg-primary: #0f172a; | ||
| --bg-secondary: #1e293b; | ||
| --bg-tertiary: #334155; | ||
| --text-primary: #f1f5f9; | ||
| --text-secondary: #94a3b8; | ||
| --text-muted: #64748b; | ||
| --accent-blue: #3b82f6; | ||
| --accent-cyan: #06b6d4; | ||
| --border-color: #475569; | ||
| --critical: #ef4444; | ||
| --high: #f97316; | ||
| --medium: #eab308; | ||
| --low: #84cc16; | ||
| --root-color: #60a5fa; | ||
| } | ||
| * { margin: 0; padding: 0; box-sizing: border-box; } | ||
| body { | ||
| font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; | ||
| background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%); | ||
| color: var(--text-primary); | ||
| min-height: 100vh; | ||
| padding: 20px; | ||
| } | ||
| .container { | ||
| max-width: 1400px; | ||
| margin: 0 auto; | ||
| } | ||
| /* Header */ | ||
| .header { | ||
| text-align: center; | ||
| padding: 30px; | ||
| background: rgba(30, 41, 59, 0.8); | ||
| border-radius: 20px; | ||
| border: 1px solid var(--border-color); | ||
| margin-bottom: 30px; | ||
| backdrop-filter: blur(12px); | ||
| } | ||
| .header-title { | ||
| font-size: 28px; | ||
| font-weight: 700; | ||
| margin-bottom: 8px; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| gap: 12px; | ||
| } | ||
| .header-subtitle { | ||
| color: var(--text-secondary); | ||
| font-size: 14px; | ||
| } | ||
| .header-meta { | ||
| display: flex; | ||
| justify-content: center; | ||
| gap: 30px; | ||
| margin-top: 16px; | ||
| font-size: 13px; | ||
| color: var(--text-secondary); | ||
| } | ||
| .header-meta span { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 6px; | ||
| } | ||
| /* Summary Cards */ | ||
| .summary { | ||
| display: grid; | ||
| grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); | ||
| gap: 16px; | ||
| margin-bottom: 30px; | ||
| } | ||
| .summary-card { | ||
| background: rgba(30, 41, 59, 0.8); | ||
| border-radius: 16px; | ||
| border: 1px solid var(--border-color); | ||
| padding: 20px; | ||
| text-align: center; | ||
| backdrop-filter: blur(12px); | ||
| transition: transform 0.2s, box-shadow 0.2s; | ||
| } | ||
| .summary-card:hover { | ||
| transform: translateY(-4px); | ||
| box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3); | ||
| } | ||
| .summary-count { | ||
| font-size: 36px; | ||
| font-weight: 700; | ||
| margin-bottom: 6px; | ||
| } | ||
| .summary-label { | ||
| font-size: 12px; | ||
| color: var(--text-secondary); | ||
| text-transform: uppercase; | ||
| letter-spacing: 0.5px; | ||
| } | ||
| .summary-card.critical .summary-count { color: var(--critical); } | ||
| .summary-card.high .summary-count { color: var(--high); } | ||
| .summary-card.medium .summary-count { color: var(--medium); } | ||
| .summary-card.low .summary-count { color: var(--low); } | ||
| /* Severity Sections */ | ||
| .severity-section { | ||
| background: rgba(30, 41, 59, 0.8); | ||
| border-radius: 16px; | ||
| border: 1px solid var(--border-color); | ||
| margin-bottom: 20px; | ||
| overflow: hidden; | ||
| backdrop-filter: blur(12px); | ||
| } | ||
| .severity-header { | ||
| padding: 16px 24px; | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 12px; | ||
| border-bottom: 1px solid var(--border-color); | ||
| cursor: pointer; | ||
| transition: background 0.2s; | ||
| } | ||
| .severity-header:hover { | ||
| background: rgba(255, 255, 255, 0.03); | ||
| } | ||
| .severity-indicator { | ||
| width: 16px; | ||
| height: 16px; | ||
| border-radius: 50%; | ||
| } | ||
| .severity-title { | ||
| font-weight: 600; | ||
| font-size: 15px; | ||
| flex: 1; | ||
| } | ||
| .severity-count { | ||
| background: var(--bg-tertiary); | ||
| padding: 4px 12px; | ||
| border-radius: 20px; | ||
| font-size: 12px; | ||
| font-weight: 600; | ||
| } | ||
| .severity-toggle { | ||
| color: var(--text-muted); | ||
| font-size: 18px; | ||
| transition: transform 0.2s; | ||
| } | ||
| .severity-section.collapsed .severity-toggle { | ||
| transform: rotate(-90deg); | ||
| } | ||
| .severity-section.collapsed .severity-content { | ||
| display: none; | ||
| } | ||
| .severity-content { | ||
| padding: 16px; | ||
| } | ||
| /* Package Cards */ | ||
| .package-grid { | ||
| display: grid; | ||
| grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); | ||
| gap: 12px; | ||
| } | ||
| .package-card { | ||
| background: var(--bg-tertiary); | ||
| border-radius: 12px; | ||
| padding: 16px; | ||
| border: 1px solid var(--border-color); | ||
| transition: transform 0.2s, border-color 0.2s; | ||
| } | ||
| .package-card:hover { | ||
| transform: translateX(4px); | ||
| border-color: var(--accent-cyan); | ||
| } | ||
| .package-name { | ||
| font-weight: 600; | ||
| font-size: 14px; | ||
| color: var(--accent-cyan); | ||
| margin-bottom: 4px; | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 8px; | ||
| } | ||
| .package-version { | ||
| font-size: 12px; | ||
| color: var(--text-muted); | ||
| margin-bottom: 10px; | ||
| } | ||
| .package-issues { | ||
| display: flex; | ||
| flex-wrap: wrap; | ||
| gap: 6px; | ||
| } | ||
| .issue-tag { | ||
| font-size: 10px; | ||
| padding: 3px 8px; | ||
| border-radius: 6px; | ||
| background: rgba(255, 255, 255, 0.1); | ||
| color: var(--text-secondary); | ||
| } | ||
| .issue-tag.security { background: rgba(239, 68, 68, 0.2); color: var(--critical); } | ||
| .issue-tag.outdated { background: rgba(249, 115, 22, 0.2); color: var(--high); } | ||
| .issue-tag.deprecated { background: rgba(234, 179, 8, 0.2); color: var(--medium); } | ||
| .package-score { | ||
| margin-top: 10px; | ||
| padding-top: 10px; | ||
| border-top: 1px solid var(--border-color); | ||
| display: flex; | ||
| justify-content: space-between; | ||
| font-size: 12px; | ||
| } | ||
| .score-label { color: var(--text-secondary); } | ||
| .score-value { font-weight: 600; } | ||
| /* Empty state for sections */ | ||
| .empty-section { | ||
| padding: 30px; | ||
| text-align: center; | ||
| color: var(--text-muted); | ||
| } | ||
| /* Footer */ | ||
| .footer { | ||
| text-align: center; | ||
| padding: 20px; | ||
| color: var(--text-muted); | ||
| font-size: 12px; | ||
| } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <div class="container"> | ||
| <div class="header"> | ||
| <div class="header-title"> | ||
| ⚠️ Dependency Conflict View | ||
| </div> | ||
| <div class="header-subtitle"> | ||
| Packages requiring attention, organized by severity | ||
| </div> | ||
| <div class="header-meta"> | ||
| <span><strong>Project:</strong> ${projectName}</span> | ||
| <span><strong>Version:</strong> ${projectVersion}</span> | ||
| <span><strong>Total Deps:</strong> ${nodes.length}</span> | ||
| <span><strong>With Issues:</strong> ${conflictNodes.length - 1}</span> | ||
| </div> | ||
| </div> | ||
| <div class="summary"> | ||
| <div class="summary-card critical"> | ||
| <div class="summary-count" id="critical-count">0</div> | ||
| <div class="summary-label">Critical</div> | ||
| </div> | ||
| <div class="summary-card high"> | ||
| <div class="summary-count" id="high-count">0</div> | ||
| <div class="summary-label">High</div> | ||
| </div> | ||
| <div class="summary-card medium"> | ||
| <div class="summary-count" id="medium-count">0</div> | ||
| <div class="summary-label">Medium</div> | ||
| </div> | ||
| <div class="summary-card low"> | ||
| <div class="summary-count" id="low-count">0</div> | ||
| <div class="summary-label">Low</div> | ||
| </div> | ||
| </div> | ||
| <div id="sections"></div> | ||
| <div class="footer"> | ||
| Generated by DevCompass • ${new Date().toLocaleString()} | ||
| </div> | ||
| </div> | ||
| <script> | ||
| const graphData = ${graphDataJSON}; | ||
| const categorized = graphData.categorized; | ||
| // Update counts | ||
| document.getElementById('critical-count').textContent = categorized.critical.length; | ||
| document.getElementById('high-count').textContent = categorized.high.length; | ||
| document.getElementById('medium-count').textContent = categorized.medium.length; | ||
| document.getElementById('low-count').textContent = categorized.low.length; | ||
| // Generate sections | ||
| const sections = [ | ||
| { key: 'critical', title: 'Critical Issues', color: 'var(--critical)' }, | ||
| { key: 'high', title: 'High Priority', color: 'var(--high)' }, | ||
| { key: 'medium', title: 'Medium Priority', color: 'var(--medium)' }, | ||
| { key: 'low', title: 'Low Priority', color: 'var(--low)' } | ||
| ]; | ||
| const container = document.getElementById('sections'); | ||
| sections.forEach(section => { | ||
| const packages = categorized[section.key]; | ||
| if (packages.length === 0) return; | ||
| const sectionEl = document.createElement('div'); | ||
| sectionEl.className = 'severity-section'; | ||
| sectionEl.innerHTML = \` | ||
| <div class="severity-header" onclick="this.parentElement.classList.toggle('collapsed')"> | ||
| <div class="severity-indicator" style="background: \${section.color};"></div> | ||
| <div class="severity-title">\${section.title}</div> | ||
| <div class="severity-count">\${packages.length} package\${packages.length !== 1 ? 's' : ''}</div> | ||
| <div class="severity-toggle">▼</div> | ||
| </div> | ||
| <div class="severity-content"> | ||
| <div class="package-grid"> | ||
| \${packages.map(pkg => renderPackageCard(pkg, section.color)).join('')} | ||
| </div> | ||
| </div> | ||
| \`; | ||
| container.appendChild(sectionEl); | ||
| }); | ||
| function renderPackageCard(pkg, color) { | ||
| const issues = pkg.issues || []; | ||
| const score = pkg.healthScore || 5; | ||
| return \` | ||
| <div class="package-card"> | ||
| <div class="package-name"> | ||
| <span style="color: \${color};">●</span> | ||
| \${pkg.name || pkg.id} | ||
| </div> | ||
| <div class="package-version">v\${pkg.version || 'unknown'}</div> | ||
| <div class="package-issues"> | ||
| \${issues.map(i => \`<span class="issue-tag \${i.type || ''}">\${i.title || i.type || 'Issue'}</span>\`).join('')} | ||
| \${issues.length === 0 ? '<span class="issue-tag">Low health score</span>' : ''} | ||
| </div> | ||
| <div class="package-score"> | ||
| <span class="score-label">Health Score</span> | ||
| <span class="score-value" style="color: \${color};">\${score}/10</span> | ||
| </div> | ||
| </div> | ||
| \`; | ||
| } | ||
| </script> | ||
| </body> | ||
| </html>`; | ||
| } | ||
| /** | ||
| * Generate HTML for when no conflicts are found | ||
| */ | ||
| function generateNoConflictsHTML(projectName, projectVersion, totalDeps) { | ||
| return `<!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| <title>DevCompass - No Conflicts</title> | ||
| <style> | ||
| * { margin: 0; padding: 0; box-sizing: border-box; } | ||
| body { | ||
| font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; | ||
| background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%); | ||
| color: #f1f5f9; | ||
| min-height: 100vh; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| } | ||
| .success-card { | ||
| text-align: center; | ||
| padding: 60px 80px; | ||
| background: rgba(30, 41, 59, 0.95); | ||
| border-radius: 24px; | ||
| border: 1px solid #475569; | ||
| backdrop-filter: blur(12px); | ||
| box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); | ||
| } | ||
| .success-icon { | ||
| font-size: 80px; | ||
| margin-bottom: 24px; | ||
| animation: bounce 2s ease-in-out infinite; | ||
| } | ||
| @keyframes bounce { | ||
| 0%, 100% { transform: translateY(0); } | ||
| 50% { transform: translateY(-10px); } | ||
| } | ||
| .success-title { | ||
| font-size: 28px; | ||
| font-weight: 700; | ||
| margin-bottom: 12px; | ||
| color: #10b981; | ||
| } | ||
| .success-subtitle { | ||
| font-size: 16px; | ||
| color: #94a3b8; | ||
| margin-bottom: 30px; | ||
| } | ||
| .stats { | ||
| display: flex; | ||
| gap: 40px; | ||
| justify-content: center; | ||
| padding-top: 24px; | ||
| border-top: 1px solid #475569; | ||
| } | ||
| .stat { | ||
| text-align: center; | ||
| } | ||
| .stat-value { | ||
| font-size: 32px; | ||
| font-weight: 700; | ||
| color: #06b6d4; | ||
| } | ||
| .stat-label { | ||
| font-size: 12px; | ||
| color: #64748b; | ||
| text-transform: uppercase; | ||
| letter-spacing: 0.5px; | ||
| } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <div class="success-card"> | ||
| <div class="success-icon">🎉</div> | ||
| <div class="success-title">No Conflicts Found!</div> | ||
| <div class="success-subtitle"> | ||
| All dependencies in ${projectName} v${projectVersion} are healthy | ||
| </div> | ||
| <div class="stats"> | ||
| <div class="stat"> | ||
| <div class="stat-value">${totalDeps}</div> | ||
| <div class="stat-label">Dependencies</div> | ||
| </div> | ||
| <div class="stat"> | ||
| <div class="stat-value">0</div> | ||
| <div class="stat-label">Issues</div> | ||
| </div> | ||
| <div class="stat"> | ||
| <div class="stat-value">✓</div> | ||
| <div class="stat-label">All Healthy</div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </body> | ||
| </html>`; | ||
| } | ||
| module.exports = { | ||
| generateConflictLayoutHTML | ||
| }; |
| // src/graph/layouts/force.js | ||
| // Force-directed layout with D3.js physics simulation | ||
| function generateForceLayoutHTML(graphData, options = {}) { | ||
| const width = options.width || 1400; | ||
| const height = options.height || 900; | ||
| const projectName = options.projectName || 'Project'; | ||
| const projectVersion = options.projectVersion || '1.0.0'; | ||
| // Validate input | ||
| const nodes = Array.isArray(graphData.nodes) ? graphData.nodes : []; | ||
| const links = Array.isArray(graphData.links) ? graphData.links : []; | ||
| if (nodes.length === 0) { | ||
| return generateEmptyStateHTML(projectName, projectVersion); | ||
| } | ||
| // Calculate max depth | ||
| const maxDepth = Math.max(...nodes.map(n => n.depth || 0), 0); | ||
| const graphDataJSON = JSON.stringify({ nodes, links }); | ||
| return `<!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| <title>DevCompass - Force Graph</title> | ||
| <script src="https://d3js.org/d3.v7.min.js"></script> | ||
| <style> | ||
| :root { | ||
| --bg-primary: #0f172a; | ||
| --bg-secondary: #1e293b; | ||
| --bg-tertiary: #334155; | ||
| --text-primary: #f1f5f9; | ||
| --text-secondary: #94a3b8; | ||
| --text-muted: #64748b; | ||
| --accent-blue: #3b82f6; | ||
| --accent-cyan: #06b6d4; | ||
| --border-color: #475569; | ||
| --health-excellent: #10b981; | ||
| --health-good: #84cc16; | ||
| --health-caution: #eab308; | ||
| --health-warning: #f97316; | ||
| --health-critical: #ef4444; | ||
| --root-color: #60a5fa; | ||
| } | ||
| * { margin: 0; padding: 0; box-sizing: border-box; } | ||
| body { | ||
| font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; | ||
| background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%); | ||
| color: var(--text-primary); | ||
| min-height: 100vh; | ||
| overflow: hidden; | ||
| } | ||
| #container { | ||
| width: 100vw; | ||
| height: 100vh; | ||
| position: relative; | ||
| } | ||
| svg { | ||
| width: 100%; | ||
| height: 100%; | ||
| cursor: grab; | ||
| } | ||
| svg:active { cursor: grabbing; } | ||
| /* Header */ | ||
| .header { | ||
| position: fixed; | ||
| top: 20px; | ||
| left: 20px; | ||
| background: rgba(30, 41, 59, 0.95); | ||
| padding: 16px 24px; | ||
| border-radius: 16px; | ||
| border: 1px solid var(--border-color); | ||
| backdrop-filter: blur(12px); | ||
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); | ||
| z-index: 100; | ||
| } | ||
| .header-title { | ||
| font-size: 18px; | ||
| font-weight: 700; | ||
| color: var(--text-primary); | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 10px; | ||
| } | ||
| .header-subtitle { | ||
| font-size: 11px; | ||
| color: var(--text-muted); | ||
| margin-top: 4px; | ||
| } | ||
| /* Search */ | ||
| .search-container { | ||
| position: fixed; | ||
| top: 20px; | ||
| left: 50%; | ||
| transform: translateX(-50%); | ||
| z-index: 100; | ||
| } | ||
| .search-input { | ||
| width: 320px; | ||
| padding: 12px 20px; | ||
| background: rgba(30, 41, 59, 0.95); | ||
| border: 1px solid var(--border-color); | ||
| border-radius: 25px; | ||
| color: var(--text-primary); | ||
| font-size: 14px; | ||
| outline: none; | ||
| backdrop-filter: blur(12px); | ||
| transition: border-color 0.2s, box-shadow 0.2s; | ||
| } | ||
| .search-input:focus { | ||
| border-color: var(--accent-cyan); | ||
| box-shadow: 0 0 0 3px rgba(6, 182, 212, 0.2); | ||
| } | ||
| .search-input::placeholder { | ||
| color: var(--text-muted); | ||
| } | ||
| /* Controls Panel */ | ||
| .controls { | ||
| position: fixed; | ||
| top: 20px; | ||
| right: 20px; | ||
| background: rgba(30, 41, 59, 0.95); | ||
| padding: 20px; | ||
| border-radius: 16px; | ||
| border: 1px solid var(--border-color); | ||
| backdrop-filter: blur(12px); | ||
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); | ||
| z-index: 100; | ||
| min-width: 200px; | ||
| } | ||
| .controls-title { | ||
| font-size: 13px; | ||
| font-weight: 700; | ||
| color: var(--text-primary); | ||
| margin-bottom: 16px; | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 8px; | ||
| } | ||
| .controls-section { | ||
| margin-bottom: 16px; | ||
| } | ||
| .controls-section-title { | ||
| font-size: 10px; | ||
| color: var(--text-muted); | ||
| text-transform: uppercase; | ||
| letter-spacing: 0.5px; | ||
| margin-bottom: 8px; | ||
| } | ||
| .control-btn { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 10px; | ||
| width: 100%; | ||
| padding: 10px 14px; | ||
| margin: 4px 0; | ||
| background: var(--bg-tertiary); | ||
| color: var(--text-primary); | ||
| border: 1px solid var(--border-color); | ||
| border-radius: 10px; | ||
| cursor: pointer; | ||
| font-size: 12px; | ||
| font-weight: 500; | ||
| transition: all 0.2s ease; | ||
| } | ||
| .control-btn:hover { | ||
| background: var(--accent-blue); | ||
| border-color: var(--accent-blue); | ||
| } | ||
| .control-btn.primary { | ||
| background: linear-gradient(135deg, var(--accent-blue) 0%, var(--accent-cyan) 100%); | ||
| border-color: var(--accent-blue); | ||
| } | ||
| .control-btn.active { | ||
| background: var(--accent-cyan); | ||
| border-color: var(--accent-cyan); | ||
| } | ||
| /* Filter Dropdowns */ | ||
| .filter-select { | ||
| width: 100%; | ||
| padding: 8px 12px; | ||
| background: var(--bg-tertiary); | ||
| border: 1px solid var(--border-color); | ||
| border-radius: 8px; | ||
| color: var(--text-primary); | ||
| font-size: 12px; | ||
| outline: none; | ||
| cursor: pointer; | ||
| margin: 4px 0; | ||
| } | ||
| .filter-select:focus { | ||
| border-color: var(--accent-cyan); | ||
| } | ||
| /* Statistics Panel */ | ||
| .stats { | ||
| position: fixed; | ||
| bottom: 100px; | ||
| right: 20px; | ||
| background: rgba(30, 41, 59, 0.95); | ||
| padding: 16px 20px; | ||
| border-radius: 16px; | ||
| border: 1px solid var(--border-color); | ||
| backdrop-filter: blur(12px); | ||
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); | ||
| z-index: 100; | ||
| min-width: 160px; | ||
| } | ||
| .stats-title { | ||
| font-size: 13px; | ||
| font-weight: 700; | ||
| color: var(--text-primary); | ||
| margin-bottom: 12px; | ||
| } | ||
| .stat-row { | ||
| display: flex; | ||
| justify-content: space-between; | ||
| margin: 6px 0; | ||
| font-size: 12px; | ||
| } | ||
| .stat-label { color: var(--text-secondary); } | ||
| .stat-value { color: var(--accent-cyan); font-weight: 700; } | ||
| /* Legend */ | ||
| .legend { | ||
| position: fixed; | ||
| bottom: 20px; | ||
| left: 20px; | ||
| background: rgba(30, 41, 59, 0.95); | ||
| padding: 16px 20px; | ||
| border-radius: 16px; | ||
| border: 1px solid var(--border-color); | ||
| backdrop-filter: blur(12px); | ||
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); | ||
| z-index: 100; | ||
| } | ||
| .legend-title { | ||
| font-size: 13px; | ||
| font-weight: 700; | ||
| color: var(--text-primary); | ||
| margin-bottom: 12px; | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 6px; | ||
| } | ||
| .legend-item { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 10px; | ||
| margin: 6px 0; | ||
| font-size: 11px; | ||
| color: var(--text-secondary); | ||
| } | ||
| .legend-dot { | ||
| width: 12px; | ||
| height: 12px; | ||
| border-radius: 50%; | ||
| flex-shrink: 0; | ||
| } | ||
| /* Zoom Controls */ | ||
| .zoom-controls { | ||
| position: fixed; | ||
| bottom: 20px; | ||
| right: 20px; | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 8px; | ||
| z-index: 100; | ||
| } | ||
| .zoom-btn { | ||
| width: 44px; | ||
| height: 44px; | ||
| background: rgba(30, 41, 59, 0.95); | ||
| border: 1px solid var(--border-color); | ||
| border-radius: 12px; | ||
| color: var(--text-primary); | ||
| font-size: 20px; | ||
| font-weight: 700; | ||
| cursor: pointer; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| transition: all 0.2s ease; | ||
| backdrop-filter: blur(12px); | ||
| } | ||
| .zoom-btn:hover { | ||
| background: var(--accent-blue); | ||
| border-color: var(--accent-blue); | ||
| transform: scale(1.1); | ||
| } | ||
| .zoom-level { | ||
| width: 44px; | ||
| padding: 8px; | ||
| background: rgba(30, 41, 59, 0.95); | ||
| border: 1px solid var(--border-color); | ||
| border-radius: 12px; | ||
| text-align: center; | ||
| color: var(--accent-cyan); | ||
| font-size: 10px; | ||
| font-weight: 700; | ||
| backdrop-filter: blur(12px); | ||
| } | ||
| /* Tooltip */ | ||
| .tooltip { | ||
| position: absolute; | ||
| padding: 14px 18px; | ||
| background: rgba(15, 23, 42, 0.98); | ||
| border: 1px solid var(--border-color); | ||
| border-radius: 12px; | ||
| font-size: 12px; | ||
| max-width: 280px; | ||
| pointer-events: none; | ||
| opacity: 0; | ||
| transform: translateY(-10px); | ||
| transition: opacity 0.2s, transform 0.2s; | ||
| z-index: 1000; | ||
| backdrop-filter: blur(12px); | ||
| box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); | ||
| } | ||
| .tooltip.visible { | ||
| opacity: 1; | ||
| transform: translateY(0); | ||
| } | ||
| .tooltip-title { | ||
| font-weight: 700; | ||
| color: var(--accent-cyan); | ||
| margin-bottom: 8px; | ||
| font-size: 13px; | ||
| } | ||
| .tooltip-row { | ||
| display: flex; | ||
| justify-content: space-between; | ||
| margin: 4px 0; | ||
| } | ||
| .tooltip-label { color: var(--text-secondary); } | ||
| .tooltip-value { color: var(--text-primary); font-weight: 600; } | ||
| /* Node styles */ | ||
| .node circle { | ||
| stroke: var(--bg-primary); | ||
| stroke-width: 2px; | ||
| cursor: pointer; | ||
| transition: all 0.2s ease; | ||
| } | ||
| .node circle:hover { | ||
| stroke: var(--text-primary); | ||
| stroke-width: 3px; | ||
| filter: drop-shadow(0 0 10px currentColor); | ||
| } | ||
| .node.selected circle { | ||
| stroke: var(--accent-cyan); | ||
| stroke-width: 4px; | ||
| } | ||
| .node-label { | ||
| font-size: 10px; | ||
| fill: var(--text-secondary); | ||
| pointer-events: none; | ||
| font-weight: 500; | ||
| } | ||
| .node-label.hidden { display: none; } | ||
| /* Link styles */ | ||
| .link { | ||
| stroke: var(--border-color); | ||
| stroke-width: 1.5px; | ||
| stroke-opacity: 0.4; | ||
| } | ||
| .link.highlighted { | ||
| stroke: var(--accent-cyan); | ||
| stroke-opacity: 0.8; | ||
| stroke-width: 2.5px; | ||
| } | ||
| .link.hidden { display: none; } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <div id="container"></div> | ||
| <div class="header"> | ||
| <div class="header-title"> | ||
| ⚡ Force Graph | ||
| </div> | ||
| <div class="header-subtitle">Interactive Physics Simulation</div> | ||
| </div> | ||
| <div class="search-container"> | ||
| <input type="text" class="search-input" id="search" placeholder="🔍 Search packages..." /> | ||
| </div> | ||
| <div class="controls"> | ||
| <div class="controls-title">⚙️ Controls</div> | ||
| <div class="controls-section"> | ||
| <div class="controls-section-title">VIEW</div> | ||
| <button class="control-btn primary" onclick="resetLayout()">↻ Reset Layout</button> | ||
| <button class="control-btn" onclick="centerView()">◎ Center View</button> | ||
| <button class="control-btn" onclick="fitToScreen()">⛶ Fit to Screen</button> | ||
| <button class="control-btn" onclick="toggleFullscreen()">⛶ Fullscreen</button> | ||
| </div> | ||
| <div class="controls-section"> | ||
| <div class="controls-section-title">DISPLAY</div> | ||
| <button class="control-btn active" id="btn-labels" onclick="toggleLabels()">🏷️ Hide Labels</button> | ||
| <button class="control-btn active" id="btn-links" onclick="toggleLinks()">🔗 Hide Links</button> | ||
| </div> | ||
| <div class="controls-section"> | ||
| <div class="controls-section-title">FILTER</div> | ||
| <div class="controls-section-title">HEALTH SCORE</div> | ||
| <select class="filter-select" id="health-filter" onchange="applyFilters()"> | ||
| <option value="all">All Packages</option> | ||
| <option value="excellent">Excellent (9-10)</option> | ||
| <option value="good">Good (7-8)</option> | ||
| <option value="caution">Caution (5-7)</option> | ||
| <option value="warning">Warning (3-5)</option> | ||
| <option value="critical">Critical (<3)</option> | ||
| </select> | ||
| </div> | ||
| </div> | ||
| <div class="stats"> | ||
| <div class="stats-title">📊 Statistics</div> | ||
| <div class="stat-row"> | ||
| <span class="stat-label">Total Nodes</span> | ||
| <span class="stat-value" id="total-nodes">${nodes.length}</span> | ||
| </div> | ||
| <div class="stat-row"> | ||
| <span class="stat-label">Visible Nodes</span> | ||
| <span class="stat-value" id="visible-nodes">${nodes.length}</span> | ||
| </div> | ||
| <div class="stat-row"> | ||
| <span class="stat-label">Links</span> | ||
| <span class="stat-value" id="total-links">${links.length}</span> | ||
| </div> | ||
| <div class="stat-row"> | ||
| <span class="stat-label">Selected</span> | ||
| <span class="stat-value" id="selected-count">0</span> | ||
| </div> | ||
| <div class="stat-row"> | ||
| <span class="stat-label">Zoom</span> | ||
| <span class="stat-value" id="zoom-stat">100%</span> | ||
| </div> | ||
| </div> | ||
| <div class="legend"> | ||
| <div class="legend-title">🎨 Health Status ▾</div> | ||
| <div class="legend-item"> | ||
| <div class="legend-dot" style="background: var(--health-excellent);"></div> | ||
| <span>Excellent (9-10)</span> | ||
| </div> | ||
| <div class="legend-item"> | ||
| <div class="legend-dot" style="background: var(--health-good);"></div> | ||
| <span>Good (7-8)</span> | ||
| </div> | ||
| <div class="legend-item"> | ||
| <div class="legend-dot" style="background: var(--health-caution);"></div> | ||
| <span>Caution (5-7)</span> | ||
| </div> | ||
| <div class="legend-item"> | ||
| <div class="legend-dot" style="background: var(--health-warning);"></div> | ||
| <span>Warning (3-5)</span> | ||
| </div> | ||
| <div class="legend-item"> | ||
| <div class="legend-dot" style="background: var(--health-critical);"></div> | ||
| <span>Critical (<3)</span> | ||
| </div> | ||
| <div class="legend-item"> | ||
| <div class="legend-dot" style="background: var(--root-color);"></div> | ||
| <span>Root Package</span> | ||
| </div> | ||
| </div> | ||
| <div class="zoom-controls"> | ||
| <button class="zoom-btn" onclick="zoomIn()">+</button> | ||
| <div class="zoom-level" id="zoom-level">100%</div> | ||
| <button class="zoom-btn" onclick="zoomOut()">−</button> | ||
| <button class="zoom-btn" onclick="resetLayout()">⟲</button> | ||
| </div> | ||
| <div class="tooltip" id="tooltip"></div> | ||
| <script> | ||
| const graphData = ${graphDataJSON}; | ||
| const width = window.innerWidth; | ||
| const height = window.innerHeight; | ||
| let showLabels = true; | ||
| let showLinks = true; | ||
| let currentZoom = 1; | ||
| let selectedNode = null; | ||
| // Get color based on health score | ||
| function getHealthColor(node) { | ||
| if (node.type === 'root') return 'var(--root-color)'; | ||
| const score = node.healthScore || 8; | ||
| if (score >= 9) return 'var(--health-excellent)'; | ||
| if (score >= 7) return 'var(--health-good)'; | ||
| if (score >= 5) return 'var(--health-caution)'; | ||
| if (score >= 3) return 'var(--health-warning)'; | ||
| return 'var(--health-critical)'; | ||
| } | ||
| // Get node radius based on type/depth | ||
| function getNodeRadius(node) { | ||
| if (node.type === 'root') return 20; | ||
| if (node.depth === 1) return 12; | ||
| if (node.depth === 2) return 8; | ||
| return 6; | ||
| } | ||
| // Create SVG | ||
| const svg = d3.select("#container") | ||
| .append("svg") | ||
| .attr("width", width) | ||
| .attr("height", height); | ||
| // Zoom behavior | ||
| const zoom = d3.zoom() | ||
| .scaleExtent([0.1, 4]) | ||
| .on("zoom", (event) => { | ||
| g.attr("transform", event.transform); | ||
| currentZoom = event.transform.k; | ||
| updateZoomDisplay(); | ||
| }); | ||
| svg.call(zoom); | ||
| const g = svg.append("g"); | ||
| // Create force simulation | ||
| const simulation = d3.forceSimulation(graphData.nodes) | ||
| .force("link", d3.forceLink(graphData.links) | ||
| .id(d => d.id) | ||
| .distance(d => { | ||
| const sourceDepth = d.source.depth || 0; | ||
| const targetDepth = d.target.depth || 0; | ||
| return 50 + Math.abs(sourceDepth - targetDepth) * 30; | ||
| })) | ||
| .force("charge", d3.forceManyBody().strength(-200)) | ||
| .force("center", d3.forceCenter(width / 2, height / 2)) | ||
| .force("collision", d3.forceCollide().radius(d => getNodeRadius(d) + 10)); | ||
| // Draw links | ||
| const link = g.append("g") | ||
| .attr("class", "links") | ||
| .selectAll("line") | ||
| .data(graphData.links) | ||
| .join("line") | ||
| .attr("class", "link"); | ||
| // Draw nodes | ||
| const node = g.append("g") | ||
| .attr("class", "nodes") | ||
| .selectAll("g") | ||
| .data(graphData.nodes) | ||
| .join("g") | ||
| .attr("class", "node") | ||
| .call(d3.drag() | ||
| .on("start", dragStarted) | ||
| .on("drag", dragged) | ||
| .on("end", dragEnded)) | ||
| .on("mouseover", handleMouseOver) | ||
| .on("mouseout", handleMouseOut) | ||
| .on("click", handleClick); | ||
| // Add circles | ||
| node.append("circle") | ||
| .attr("r", d => getNodeRadius(d)) | ||
| .attr("fill", d => getHealthColor(d)); | ||
| // Add labels | ||
| node.append("text") | ||
| .attr("class", "node-label") | ||
| .attr("dy", d => getNodeRadius(d) + 12) | ||
| .attr("text-anchor", "middle") | ||
| .text(d => { | ||
| const name = d.name || d.id; | ||
| return name.length > 20 ? name.substring(0, 17) + '...' : name; | ||
| }); | ||
| // Simulation tick | ||
| simulation.on("tick", () => { | ||
| link | ||
| .attr("x1", d => d.source.x) | ||
| .attr("y1", d => d.source.y) | ||
| .attr("x2", d => d.target.x) | ||
| .attr("y2", d => d.target.y); | ||
| node.attr("transform", d => "translate(" + d.x + "," + d.y + ")"); | ||
| }); | ||
| // Drag functions | ||
| function dragStarted(event) { | ||
| if (!event.active) simulation.alphaTarget(0.3).restart(); | ||
| event.subject.fx = event.subject.x; | ||
| event.subject.fy = event.subject.y; | ||
| } | ||
| function dragged(event) { | ||
| event.subject.fx = event.x; | ||
| event.subject.fy = event.y; | ||
| } | ||
| function dragEnded(event) { | ||
| if (!event.active) simulation.alphaTarget(0); | ||
| event.subject.fx = null; | ||
| event.subject.fy = null; | ||
| } | ||
| // Tooltip functions | ||
| function handleMouseOver(event, d) { | ||
| // Highlight connected links | ||
| link.classed("highlighted", l => | ||
| l.source.id === d.id || l.target.id === d.id | ||
| ); | ||
| // Show tooltip | ||
| const tooltip = document.getElementById('tooltip'); | ||
| const score = d.healthScore || 8; | ||
| tooltip.innerHTML = | ||
| '<div class="tooltip-title">' + (d.name || d.id) + '</div>' + | ||
| '<div class="tooltip-row"><span class="tooltip-label">Version</span><span class="tooltip-value">' + (d.version || 'N/A') + '</span></div>' + | ||
| '<div class="tooltip-row"><span class="tooltip-label">Health Score</span><span class="tooltip-value">' + score + '/10</span></div>' + | ||
| '<div class="tooltip-row"><span class="tooltip-label">Depth</span><span class="tooltip-value">' + (d.depth || 0) + '</span></div>' + | ||
| '<div class="tooltip-row"><span class="tooltip-label">Type</span><span class="tooltip-value">' + (d.type || 'dependency') + '</span></div>'; | ||
| tooltip.classList.add('visible'); | ||
| const x = Math.min(event.pageX + 15, window.innerWidth - 300); | ||
| const y = event.pageY - 10; | ||
| tooltip.style.left = x + 'px'; | ||
| tooltip.style.top = y + 'px'; | ||
| } | ||
| function handleMouseOut() { | ||
| link.classed("highlighted", false); | ||
| document.getElementById('tooltip').classList.remove('visible'); | ||
| } | ||
| function handleClick(event, d) { | ||
| // Toggle selection | ||
| if (selectedNode === d) { | ||
| selectedNode = null; | ||
| node.classed("selected", false); | ||
| document.getElementById('selected-count').textContent = '0'; | ||
| } else { | ||
| selectedNode = d; | ||
| node.classed("selected", n => n === d); | ||
| document.getElementById('selected-count').textContent = '1'; | ||
| } | ||
| } | ||
| // Control functions | ||
| function resetLayout() { | ||
| simulation.alpha(1).restart(); | ||
| svg.transition().duration(750).call( | ||
| zoom.transform, | ||
| d3.zoomIdentity | ||
| ); | ||
| } | ||
| function centerView() { | ||
| svg.transition().duration(500).call( | ||
| zoom.transform, | ||
| d3.zoomIdentity.translate(width / 2, height / 2).scale(currentZoom).translate(-width / 2, -height / 2) | ||
| ); | ||
| } | ||
| function fitToScreen() { | ||
| const bounds = g.node().getBBox(); | ||
| const fullWidth = width; | ||
| const fullHeight = height; | ||
| const midX = bounds.x + bounds.width / 2; | ||
| const midY = bounds.y + bounds.height / 2; | ||
| const scale = 0.8 / Math.max(bounds.width / fullWidth, bounds.height / fullHeight); | ||
| svg.transition().duration(750).call( | ||
| zoom.transform, | ||
| d3.zoomIdentity.translate(fullWidth / 2 - scale * midX, fullHeight / 2 - scale * midY).scale(scale) | ||
| ); | ||
| } | ||
| function toggleFullscreen() { | ||
| const elem = document.documentElement; | ||
| if (!document.fullscreenElement && !document.webkitFullscreenElement) { | ||
| if (elem.requestFullscreen) { | ||
| elem.requestFullscreen(); | ||
| } else if (elem.webkitRequestFullscreen) { | ||
| elem.webkitRequestFullscreen(); | ||
| } | ||
| } else { | ||
| if (document.exitFullscreen) { | ||
| document.exitFullscreen(); | ||
| } else if (document.webkitExitFullscreen) { | ||
| document.webkitExitFullscreen(); | ||
| } | ||
| } | ||
| } | ||
| function toggleLabels() { | ||
| showLabels = !showLabels; | ||
| d3.selectAll('.node-label').classed('hidden', !showLabels); | ||
| document.getElementById('btn-labels').textContent = showLabels ? '🏷️ Hide Labels' : '🏷️ Show Labels'; | ||
| document.getElementById('btn-labels').classList.toggle('active', showLabels); | ||
| } | ||
| function toggleLinks() { | ||
| showLinks = !showLinks; | ||
| d3.selectAll('.link').classed('hidden', !showLinks); | ||
| document.getElementById('btn-links').textContent = showLinks ? '🔗 Hide Links' : '🔗 Show Links'; | ||
| document.getElementById('btn-links').classList.toggle('active', showLinks); | ||
| } | ||
| function applyFilters() { | ||
| const healthFilter = document.getElementById('health-filter').value; | ||
| let visibleCount = 0; | ||
| node.style('display', d => { | ||
| let show = true; | ||
| if (healthFilter !== 'all') { | ||
| const score = d.healthScore || 8; | ||
| switch (healthFilter) { | ||
| case 'excellent': show = score >= 9; break; | ||
| case 'good': show = score >= 7 && score < 9; break; | ||
| case 'caution': show = score >= 5 && score < 7; break; | ||
| case 'warning': show = score >= 3 && score < 5; break; | ||
| case 'critical': show = score < 3; break; | ||
| } | ||
| } | ||
| if (show) visibleCount++; | ||
| return show ? 'block' : 'none'; | ||
| }); | ||
| document.getElementById('visible-nodes').textContent = visibleCount; | ||
| } | ||
| function zoomIn() { | ||
| svg.transition().duration(300).call(zoom.scaleBy, 1.3); | ||
| } | ||
| function zoomOut() { | ||
| svg.transition().duration(300).call(zoom.scaleBy, 0.7); | ||
| } | ||
| function updateZoomDisplay() { | ||
| const zoomPercent = Math.round(currentZoom * 100) + '%'; | ||
| document.getElementById('zoom-level').textContent = zoomPercent; | ||
| document.getElementById('zoom-stat').textContent = zoomPercent; | ||
| } | ||
| // Search functionality | ||
| document.getElementById('search').addEventListener('input', (e) => { | ||
| const searchTerm = e.target.value.toLowerCase(); | ||
| let visibleCount = 0; | ||
| node.style('display', d => { | ||
| const name = (d.name || d.id).toLowerCase(); | ||
| const match = !searchTerm || name.includes(searchTerm); | ||
| if (match) visibleCount++; | ||
| return match ? 'block' : 'none'; | ||
| }); | ||
| document.getElementById('visible-nodes').textContent = visibleCount; | ||
| }); | ||
| // Keyboard shortcuts | ||
| document.addEventListener('keydown', (e) => { | ||
| if (e.target.tagName === 'INPUT') return; | ||
| if (e.key === '+' || e.key === '=') zoomIn(); | ||
| if (e.key === '-') zoomOut(); | ||
| if (e.key === 'r' || e.key === 'R') resetLayout(); | ||
| if (e.key === 'f' || e.key === 'F') document.getElementById('search').focus(); | ||
| if (e.key === 'l' || e.key === 'L') toggleLabels(); | ||
| if (e.key === 'Escape') { | ||
| selectedNode = null; | ||
| node.classed("selected", false); | ||
| document.getElementById('selected-count').textContent = '0'; | ||
| } | ||
| }); | ||
| </script> | ||
| </body> | ||
| </html>`; | ||
| } | ||
| /** | ||
| * Generate empty state HTML | ||
| */ | ||
| function generateEmptyStateHTML(projectName, projectVersion) { | ||
| return `<!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <title>DevCompass - No Dependencies</title> | ||
| <style> | ||
| body { | ||
| font-family: 'Segoe UI', system-ui, sans-serif; | ||
| background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%); | ||
| color: #f1f5f9; | ||
| min-height: 100vh; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| margin: 0; | ||
| } | ||
| .message { | ||
| text-align: center; | ||
| padding: 40px; | ||
| background: rgba(30, 41, 59, 0.95); | ||
| border-radius: 20px; | ||
| border: 1px solid #475569; | ||
| } | ||
| .icon { font-size: 64px; margin-bottom: 20px; } | ||
| h1 { margin: 0 0 10px; font-size: 24px; } | ||
| p { color: #94a3b8; margin: 0; } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <div class="message"> | ||
| <div class="icon">📦</div> | ||
| <h1>No Dependencies Found</h1> | ||
| <p>${projectName} v${projectVersion} has no dependencies to visualize.</p> | ||
| </div> | ||
| </body> | ||
| </html>`; | ||
| } | ||
| /** | ||
| * Legacy function for backward compatibility | ||
| */ | ||
| function generateForceLayout(graphData, options = {}) { | ||
| return generateForceLayoutHTML(graphData, options); | ||
| } | ||
| module.exports = { | ||
| generateForceLayout, | ||
| generateForceLayoutHTML | ||
| }; |
| // src/graph/layouts/radial.js | ||
| // Fixed radial layout with improved label positioning and collision handling | ||
| function generateRadialLayoutHTML(graphData, options = {}) { | ||
| const width = options.width || 1400; | ||
| const height = options.height || 900; | ||
| const projectName = options.projectName || 'Project'; | ||
| const projectVersion = options.projectVersion || '1.0.0'; | ||
| // Validate input | ||
| const nodes = Array.isArray(graphData.nodes) ? graphData.nodes : []; | ||
| const links = Array.isArray(graphData.links) ? graphData.links : []; | ||
| if (nodes.length === 0) { | ||
| return generateEmptyStateHTML(projectName, projectVersion); | ||
| } | ||
| const graphDataJSON = JSON.stringify({ nodes, links }); | ||
| return `<!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| <title>DevCompass - Radial Dependency Graph</title> | ||
| <script src="https://d3js.org/d3.v7.min.js"></script> | ||
| <style> | ||
| :root { | ||
| --bg-primary: #0f172a; | ||
| --bg-secondary: #1e293b; | ||
| --bg-tertiary: #334155; | ||
| --text-primary: #f1f5f9; | ||
| --text-secondary: #94a3b8; | ||
| --text-muted: #64748b; | ||
| --accent-blue: #3b82f6; | ||
| --accent-cyan: #06b6d4; | ||
| --accent-purple: #8b5cf6; | ||
| --border-color: #475569; | ||
| --health-excellent: #10b981; | ||
| --health-good: #84cc16; | ||
| --health-caution: #eab308; | ||
| --health-warning: #f97316; | ||
| --health-critical: #ef4444; | ||
| --root-color: #60a5fa; | ||
| } | ||
| * { margin: 0; padding: 0; box-sizing: border-box; } | ||
| body { | ||
| font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; | ||
| background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%); | ||
| color: var(--text-primary); | ||
| min-height: 100vh; | ||
| overflow: hidden; | ||
| } | ||
| #container { | ||
| width: 100vw; | ||
| height: 100vh; | ||
| position: relative; | ||
| } | ||
| svg { | ||
| width: 100%; | ||
| height: 100%; | ||
| cursor: grab; | ||
| } | ||
| svg:active { cursor: grabbing; } | ||
| /* Header */ | ||
| .header { | ||
| position: fixed; | ||
| top: 20px; | ||
| left: 50%; | ||
| transform: translateX(-50%); | ||
| background: rgba(30, 41, 59, 0.95); | ||
| padding: 16px 32px; | ||
| border-radius: 16px; | ||
| border: 1px solid var(--border-color); | ||
| backdrop-filter: blur(12px); | ||
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); | ||
| z-index: 100; | ||
| text-align: center; | ||
| } | ||
| .header-title { | ||
| font-size: 20px; | ||
| font-weight: 700; | ||
| color: var(--text-primary); | ||
| margin-bottom: 4px; | ||
| } | ||
| .header-subtitle { | ||
| font-size: 12px; | ||
| color: var(--text-secondary); | ||
| } | ||
| /* Controls Panel */ | ||
| .controls { | ||
| position: fixed; | ||
| top: 20px; | ||
| right: 20px; | ||
| background: rgba(30, 41, 59, 0.95); | ||
| padding: 20px; | ||
| border-radius: 16px; | ||
| border: 1px solid var(--border-color); | ||
| backdrop-filter: blur(12px); | ||
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); | ||
| z-index: 100; | ||
| min-width: 180px; | ||
| } | ||
| .controls-title { | ||
| font-size: 13px; | ||
| font-weight: 700; | ||
| color: var(--text-primary); | ||
| margin-bottom: 12px; | ||
| } | ||
| .control-btn { | ||
| display: block; | ||
| width: 100%; | ||
| padding: 10px 14px; | ||
| margin: 6px 0; | ||
| background: var(--bg-tertiary); | ||
| color: var(--text-primary); | ||
| border: 1px solid var(--border-color); | ||
| border-radius: 8px; | ||
| cursor: pointer; | ||
| font-size: 12px; | ||
| font-weight: 500; | ||
| text-align: left; | ||
| transition: all 0.2s ease; | ||
| } | ||
| .control-btn:hover { | ||
| background: var(--accent-blue); | ||
| border-color: var(--accent-blue); | ||
| } | ||
| .control-btn.active { | ||
| background: var(--accent-purple); | ||
| border-color: var(--accent-purple); | ||
| } | ||
| /* Legend */ | ||
| .legend { | ||
| position: fixed; | ||
| bottom: 20px; | ||
| right: 20px; | ||
| background: rgba(30, 41, 59, 0.95); | ||
| padding: 16px 20px; | ||
| border-radius: 16px; | ||
| border: 1px solid var(--border-color); | ||
| backdrop-filter: blur(12px); | ||
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); | ||
| z-index: 100; | ||
| } | ||
| .legend-title { | ||
| font-size: 13px; | ||
| font-weight: 700; | ||
| color: var(--text-primary); | ||
| margin-bottom: 12px; | ||
| } | ||
| .legend-item { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 10px; | ||
| margin: 6px 0; | ||
| font-size: 11px; | ||
| color: var(--text-secondary); | ||
| } | ||
| .legend-dot { | ||
| width: 12px; | ||
| height: 12px; | ||
| border-radius: 50%; | ||
| flex-shrink: 0; | ||
| } | ||
| /* Depth circles visualization */ | ||
| .depth-circle { | ||
| fill: none; | ||
| stroke: var(--border-color); | ||
| stroke-width: 1px; | ||
| stroke-dasharray: 4, 4; | ||
| opacity: 0.4; | ||
| } | ||
| .depth-label { | ||
| fill: var(--text-muted); | ||
| font-size: 10px; | ||
| font-weight: 500; | ||
| } | ||
| /* Node styles */ | ||
| .node circle { | ||
| stroke: var(--bg-primary); | ||
| stroke-width: 2px; | ||
| cursor: pointer; | ||
| transition: all 0.2s ease; | ||
| } | ||
| .node circle:hover { | ||
| stroke: var(--text-primary); | ||
| stroke-width: 3px; | ||
| filter: drop-shadow(0 0 10px currentColor); | ||
| } | ||
| .node-label { | ||
| font-size: 9px; | ||
| fill: var(--text-secondary); | ||
| pointer-events: none; | ||
| font-weight: 500; | ||
| text-shadow: | ||
| -1px -1px 2px var(--bg-primary), | ||
| 1px -1px 2px var(--bg-primary), | ||
| -1px 1px 2px var(--bg-primary), | ||
| 1px 1px 2px var(--bg-primary); | ||
| } | ||
| .node-label.hidden { display: none; } | ||
| /* Link styles */ | ||
| .link { | ||
| fill: none; | ||
| stroke: var(--border-color); | ||
| stroke-width: 1px; | ||
| stroke-opacity: 0.4; | ||
| } | ||
| .link.highlighted { | ||
| stroke: var(--accent-cyan); | ||
| stroke-opacity: 0.8; | ||
| stroke-width: 2px; | ||
| } | ||
| /* Tooltip */ | ||
| .tooltip { | ||
| position: absolute; | ||
| padding: 14px 18px; | ||
| background: rgba(15, 23, 42, 0.98); | ||
| border: 1px solid var(--border-color); | ||
| border-radius: 12px; | ||
| font-size: 12px; | ||
| max-width: 280px; | ||
| pointer-events: none; | ||
| opacity: 0; | ||
| transform: translateY(-10px); | ||
| transition: opacity 0.2s, transform 0.2s; | ||
| z-index: 1000; | ||
| backdrop-filter: blur(12px); | ||
| box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); | ||
| } | ||
| .tooltip.visible { | ||
| opacity: 1; | ||
| transform: translateY(0); | ||
| } | ||
| .tooltip-title { | ||
| font-weight: 700; | ||
| color: var(--accent-cyan); | ||
| margin-bottom: 8px; | ||
| font-size: 13px; | ||
| } | ||
| .tooltip-row { | ||
| display: flex; | ||
| justify-content: space-between; | ||
| margin: 4px 0; | ||
| } | ||
| .tooltip-label { color: var(--text-secondary); } | ||
| .tooltip-value { color: var(--text-primary); font-weight: 600; } | ||
| /* Zoom Controls */ | ||
| .zoom-controls { | ||
| position: fixed; | ||
| bottom: 20px; | ||
| left: 20px; | ||
| display: flex; | ||
| gap: 8px; | ||
| z-index: 100; | ||
| } | ||
| .zoom-btn { | ||
| width: 40px; | ||
| height: 40px; | ||
| background: rgba(30, 41, 59, 0.95); | ||
| border: 1px solid var(--border-color); | ||
| border-radius: 10px; | ||
| color: var(--text-primary); | ||
| font-size: 18px; | ||
| font-weight: 700; | ||
| cursor: pointer; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| transition: all 0.2s ease; | ||
| backdrop-filter: blur(12px); | ||
| } | ||
| .zoom-btn:hover { | ||
| background: var(--accent-blue); | ||
| border-color: var(--accent-blue); | ||
| } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <div id="container"></div> | ||
| <div class="header"> | ||
| <div class="header-title">🌐 Radial Dependency Graph</div> | ||
| <div class="header-subtitle">Concentric circles by dependency depth</div> | ||
| </div> | ||
| <div class="controls"> | ||
| <div class="controls-title">Display Options</div> | ||
| <button class="control-btn active" id="btn-labels" onclick="toggleLabels()">Toggle Labels</button> | ||
| <button class="control-btn" id="btn-depth" onclick="toggleDepthCircles()">Toggle Depth Circles</button> | ||
| <button class="control-btn" id="btn-links" onclick="toggleLinks()">Toggle Links</button> | ||
| </div> | ||
| <div class="legend"> | ||
| <div class="legend-title">Health Status</div> | ||
| <div class="legend-item"> | ||
| <div class="legend-dot" style="background: var(--health-excellent);"></div> | ||
| <span>Healthy (7-10)</span> | ||
| </div> | ||
| <div class="legend-item"> | ||
| <div class="legend-dot" style="background: var(--health-caution);"></div> | ||
| <span>Caution (5-7)</span> | ||
| </div> | ||
| <div class="legend-item"> | ||
| <div class="legend-dot" style="background: var(--health-warning);"></div> | ||
| <span>Warning (3-5)</span> | ||
| </div> | ||
| <div class="legend-item"> | ||
| <div class="legend-dot" style="background: var(--health-critical);"></div> | ||
| <span>Critical (<3)</span> | ||
| </div> | ||
| <div class="legend-item"> | ||
| <div class="legend-dot" style="background: var(--root-color);"></div> | ||
| <span>Root Package</span> | ||
| </div> | ||
| </div> | ||
| <div class="zoom-controls"> | ||
| <button class="zoom-btn" onclick="zoomIn()">+</button> | ||
| <button class="zoom-btn" onclick="zoomOut()">−</button> | ||
| <button class="zoom-btn" onclick="resetZoom()">⟲</button> | ||
| </div> | ||
| <div class="tooltip" id="tooltip"></div> | ||
| <script> | ||
| const graphData = ${graphDataJSON}; | ||
| const width = window.innerWidth; | ||
| const height = window.innerHeight; | ||
| const centerX = width / 2; | ||
| const centerY = height / 2; | ||
| let showLabels = true; | ||
| let showDepthCircles = true; | ||
| let showLinks = true; | ||
| let currentZoom = 1; | ||
| // Calculate max depth | ||
| const maxDepth = Math.max(...graphData.nodes.map(n => n.depth || 0), 1); | ||
| // Radius configuration - distribute nodes across available space | ||
| const minRadius = 60; | ||
| const maxRadius = Math.min(width, height) / 2 - 100; | ||
| const radiusStep = (maxRadius - minRadius) / Math.max(maxDepth, 1); | ||
| // Get radius for depth level | ||
| function getRadiusForDepth(depth) { | ||
| if (depth === 0) return 0; // Root at center | ||
| return minRadius + (depth - 1) * radiusStep + radiusStep / 2; | ||
| } | ||
| // Get color based on health score | ||
| function getHealthColor(node) { | ||
| if (node.type === 'root' || node.depth === 0) return 'var(--root-color)'; | ||
| const score = node.healthScore || 8; | ||
| if (score >= 7) return 'var(--health-excellent)'; | ||
| if (score >= 5) return 'var(--health-caution)'; | ||
| if (score >= 3) return 'var(--health-warning)'; | ||
| return 'var(--health-critical)'; | ||
| } | ||
| // Get node radius based on type/depth | ||
| function getNodeRadius(node) { | ||
| if (node.type === 'root' || node.depth === 0) return 20; | ||
| if (node.depth === 1) return 10; | ||
| return 6; | ||
| } | ||
| // Group nodes by depth for angular distribution | ||
| const nodesByDepth = {}; | ||
| graphData.nodes.forEach(node => { | ||
| const depth = node.depth || 0; | ||
| if (!nodesByDepth[depth]) nodesByDepth[depth] = []; | ||
| nodesByDepth[depth].push(node); | ||
| }); | ||
| // Calculate positions for each node | ||
| graphData.nodes.forEach(node => { | ||
| const depth = node.depth || 0; | ||
| const nodesAtDepth = nodesByDepth[depth]; | ||
| const index = nodesAtDepth.indexOf(node); | ||
| const count = nodesAtDepth.length; | ||
| if (depth === 0) { | ||
| // Root at center | ||
| node.x = centerX; | ||
| node.y = centerY; | ||
| } else { | ||
| // Distribute evenly around circle at this depth | ||
| // Add some offset to avoid all nodes starting at same angle | ||
| const angleOffset = (depth * 0.3); // Stagger by depth | ||
| const angle = (2 * Math.PI * index / count) + angleOffset; | ||
| const radius = getRadiusForDepth(depth); | ||
| node.x = centerX + radius * Math.cos(angle); | ||
| node.y = centerY + radius * Math.sin(angle); | ||
| node.angle = angle; // Store for label positioning | ||
| } | ||
| }); | ||
| // Create SVG | ||
| const svg = d3.select("#container") | ||
| .append("svg") | ||
| .attr("width", width) | ||
| .attr("height", height); | ||
| // Zoom behavior | ||
| const zoom = d3.zoom() | ||
| .scaleExtent([0.2, 4]) | ||
| .on("zoom", (event) => { | ||
| g.attr("transform", event.transform); | ||
| currentZoom = event.transform.k; | ||
| }); | ||
| svg.call(zoom); | ||
| const g = svg.append("g"); | ||
| // Draw depth circles (concentric rings) | ||
| const depthCirclesGroup = g.append("g").attr("class", "depth-circles"); | ||
| for (let d = 1; d <= maxDepth; d++) { | ||
| const r = getRadiusForDepth(d); | ||
| depthCirclesGroup.append("circle") | ||
| .attr("class", "depth-circle") | ||
| .attr("cx", centerX) | ||
| .attr("cy", centerY) | ||
| .attr("r", r); | ||
| // Depth label | ||
| depthCirclesGroup.append("text") | ||
| .attr("class", "depth-label") | ||
| .attr("x", centerX + r + 5) | ||
| .attr("y", centerY - 5) | ||
| .text("Depth " + d); | ||
| } | ||
| // Build link lookup for highlighting | ||
| const linkLookup = new Set(); | ||
| graphData.links.forEach(l => { | ||
| const sourceId = typeof l.source === 'object' ? l.source.id : l.source; | ||
| const targetId = typeof l.target === 'object' ? l.target.id : l.target; | ||
| linkLookup.add(sourceId + '-' + targetId); | ||
| }); | ||
| // Create node lookup | ||
| const nodeById = new Map(); | ||
| graphData.nodes.forEach(n => nodeById.set(n.id, n)); | ||
| // Draw links with curved paths | ||
| const linksGroup = g.append("g").attr("class", "links"); | ||
| const links = linksGroup.selectAll(".link") | ||
| .data(graphData.links) | ||
| .join("path") | ||
| .attr("class", "link") | ||
| .attr("d", d => { | ||
| const sourceId = typeof d.source === 'object' ? d.source.id : d.source; | ||
| const targetId = typeof d.target === 'object' ? d.target.id : d.target; | ||
| const source = nodeById.get(sourceId); | ||
| const target = nodeById.get(targetId); | ||
| if (!source || !target) return ''; | ||
| // Use curved path through center point for radial layout | ||
| const midX = (source.x + target.x) / 2; | ||
| const midY = (source.y + target.y) / 2; | ||
| // Pull midpoint slightly toward center for nice curves | ||
| const pullFactor = 0.2; | ||
| const curveX = midX + (centerX - midX) * pullFactor; | ||
| const curveY = midY + (centerY - midY) * pullFactor; | ||
| return "M" + source.x + "," + source.y | ||
| + "Q" + curveX + "," + curveY | ||
| + " " + target.x + "," + target.y; | ||
| }); | ||
| // Draw nodes | ||
| const nodesGroup = g.append("g").attr("class", "nodes"); | ||
| const node = nodesGroup.selectAll(".node") | ||
| .data(graphData.nodes) | ||
| .join("g") | ||
| .attr("class", "node") | ||
| .attr("transform", d => "translate(" + d.x + "," + d.y + ")") | ||
| .on("mouseover", handleMouseOver) | ||
| .on("mouseout", handleMouseOut); | ||
| // Add circles | ||
| node.append("circle") | ||
| .attr("r", d => getNodeRadius(d)) | ||
| .attr("fill", d => getHealthColor(d)); | ||
| // Add labels with smart positioning | ||
| node.append("text") | ||
| .attr("class", "node-label") | ||
| .attr("dy", d => { | ||
| if (d.depth === 0) return -28; | ||
| // Position label outside the circle based on angle | ||
| const angle = d.angle || 0; | ||
| return Math.sin(angle) > 0.3 ? 20 : (Math.sin(angle) < -0.3 ? -12 : 4); | ||
| }) | ||
| .attr("dx", d => { | ||
| if (d.depth === 0) return 0; | ||
| const angle = d.angle || 0; | ||
| return Math.cos(angle) > 0.3 ? 12 : (Math.cos(angle) < -0.3 ? -12 : 0); | ||
| }) | ||
| .attr("text-anchor", d => { | ||
| if (d.depth === 0) return "middle"; | ||
| const angle = d.angle || 0; | ||
| if (Math.cos(angle) > 0.3) return "start"; | ||
| if (Math.cos(angle) < -0.3) return "end"; | ||
| return "middle"; | ||
| }) | ||
| .text(d => { | ||
| // Truncate long names | ||
| const name = d.name || d.id; | ||
| return name.length > 15 ? name.substring(0, 12) + '...' : name; | ||
| }); | ||
| // Tooltip functions | ||
| function handleMouseOver(event, d) { | ||
| // Highlight connected links | ||
| links.classed("highlighted", l => { | ||
| const sourceId = typeof l.source === 'object' ? l.source.id : l.source; | ||
| const targetId = typeof l.target === 'object' ? l.target.id : l.target; | ||
| return sourceId === d.id || targetId === d.id; | ||
| }); | ||
| // Show tooltip | ||
| const tooltip = document.getElementById('tooltip'); | ||
| const score = d.healthScore || 8; | ||
| tooltip.innerHTML = | ||
| '<div class="tooltip-title">' + (d.name || d.id) + '</div>' + | ||
| '<div class="tooltip-row"><span class="tooltip-label">Version</span><span class="tooltip-value">' + (d.version || 'N/A') + '</span></div>' + | ||
| '<div class="tooltip-row"><span class="tooltip-label">Health Score</span><span class="tooltip-value">' + score + '/10</span></div>' + | ||
| '<div class="tooltip-row"><span class="tooltip-label">Depth</span><span class="tooltip-value">' + (d.depth || 0) + '</span></div>' + | ||
| '<div class="tooltip-row"><span class="tooltip-label">Type</span><span class="tooltip-value">' + (d.type || 'dependency') + '</span></div>'; | ||
| tooltip.classList.add('visible'); | ||
| const x = Math.min(event.pageX + 15, window.innerWidth - 300); | ||
| const y = event.pageY - 10; | ||
| tooltip.style.left = x + 'px'; | ||
| tooltip.style.top = y + 'px'; | ||
| } | ||
| function handleMouseOut() { | ||
| links.classed("highlighted", false); | ||
| document.getElementById('tooltip').classList.remove('visible'); | ||
| } | ||
| // Control functions | ||
| function toggleLabels() { | ||
| showLabels = !showLabels; | ||
| d3.selectAll('.node-label').classed('hidden', !showLabels); | ||
| document.getElementById('btn-labels').classList.toggle('active', showLabels); | ||
| } | ||
| function toggleDepthCircles() { | ||
| showDepthCircles = !showDepthCircles; | ||
| d3.selectAll('.depth-circles').style('display', showDepthCircles ? 'block' : 'none'); | ||
| document.getElementById('btn-depth').classList.toggle('active', showDepthCircles); | ||
| } | ||
| function toggleLinks() { | ||
| showLinks = !showLinks; | ||
| d3.selectAll('.links').style('display', showLinks ? 'block' : 'none'); | ||
| document.getElementById('btn-links').classList.toggle('active', showLinks); | ||
| } | ||
| function zoomIn() { | ||
| svg.transition().duration(300).call(zoom.scaleBy, 1.3); | ||
| } | ||
| function zoomOut() { | ||
| svg.transition().duration(300).call(zoom.scaleBy, 0.7); | ||
| } | ||
| function resetZoom() { | ||
| svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity); | ||
| } | ||
| // Keyboard shortcuts | ||
| document.addEventListener('keydown', (e) => { | ||
| if (e.key === 'l' || e.key === 'L') toggleLabels(); | ||
| if (e.key === 'd' || e.key === 'D') toggleDepthCircles(); | ||
| if (e.key === '+' || e.key === '=') zoomIn(); | ||
| if (e.key === '-') zoomOut(); | ||
| if (e.key === 'r' || e.key === 'R') resetZoom(); | ||
| }); | ||
| </script> | ||
| </body> | ||
| </html>`; | ||
| } | ||
| /** | ||
| * Generate empty state HTML | ||
| */ | ||
| function generateEmptyStateHTML(projectName, projectVersion) { | ||
| return `<!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <title>DevCompass - No Dependencies</title> | ||
| <style> | ||
| body { | ||
| font-family: 'Segoe UI', system-ui, sans-serif; | ||
| background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%); | ||
| color: #f1f5f9; | ||
| min-height: 100vh; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| } | ||
| .message { | ||
| text-align: center; | ||
| padding: 40px; | ||
| background: rgba(30, 41, 59, 0.95); | ||
| border-radius: 20px; | ||
| border: 1px solid #475569; | ||
| } | ||
| .icon { font-size: 64px; margin-bottom: 20px; } | ||
| h1 { margin: 0 0 10px; font-size: 24px; } | ||
| p { color: #94a3b8; margin: 0; } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <div class="message"> | ||
| <div class="icon">🌐</div> | ||
| <h1>No Dependencies Found</h1> | ||
| <p>${projectName} has no dependencies to visualize.</p> | ||
| </div> | ||
| </body> | ||
| </html>`; | ||
| } | ||
| /** | ||
| * Legacy function for backward compatibility | ||
| */ | ||
| function generateRadialLayout(graphData, options = {}) { | ||
| return generateRadialLayoutHTML(graphData, options); | ||
| } | ||
| module.exports = { | ||
| generateRadialLayout, | ||
| generateRadialLayoutHTML | ||
| }; |
| // src/graph/layouts/tree.js | ||
| // Fixed tree layout with proper horizontal spreading | ||
| // v3.1.2 - Fixed overlapping Controls/Statistics panels | ||
| function generateTreeLayoutHTML(graphData, options = {}) { | ||
| const width = options.width || 1400; | ||
| const height = options.height || 900; | ||
| const projectName = options.projectName || 'Project'; | ||
| const projectVersion = options.projectVersion || '1.0.0'; | ||
| // Validate input | ||
| const nodes = Array.isArray(graphData.nodes) ? graphData.nodes : []; | ||
| const links = Array.isArray(graphData.links) ? graphData.links : []; | ||
| if (nodes.length === 0) { | ||
| return generateEmptyStateHTML(projectName, projectVersion); | ||
| } | ||
| // Build hierarchy from flat nodes/links | ||
| const hierarchyData = buildHierarchy(nodes, links); | ||
| const graphDataJSON = JSON.stringify({ nodes, links, hierarchy: hierarchyData }); | ||
| return `<!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| <title>DevCompass - Dependency Tree</title> | ||
| <script src="https://d3js.org/d3.v7.min.js"></script> | ||
| <style> | ||
| :root { | ||
| --bg-primary: #0f172a; | ||
| --bg-secondary: #1e293b; | ||
| --bg-tertiary: #334155; | ||
| --text-primary: #f1f5f9; | ||
| --text-secondary: #94a3b8; | ||
| --text-muted: #64748b; | ||
| --accent-blue: #3b82f6; | ||
| --accent-cyan: #06b6d4; | ||
| --border-color: #475569; | ||
| --health-excellent: #10b981; | ||
| --health-good: #84cc16; | ||
| --health-caution: #eab308; | ||
| --health-warning: #f97316; | ||
| --health-critical: #ef4444; | ||
| --root-color: #60a5fa; | ||
| } | ||
| * { margin: 0; padding: 0; box-sizing: border-box; } | ||
| body { | ||
| font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; | ||
| background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%); | ||
| color: var(--text-primary); | ||
| min-height: 100vh; | ||
| overflow: hidden; | ||
| } | ||
| #container { | ||
| width: 100vw; | ||
| height: 100vh; | ||
| position: relative; | ||
| } | ||
| svg { | ||
| width: 100%; | ||
| height: 100%; | ||
| cursor: grab; | ||
| } | ||
| svg:active { cursor: grabbing; } | ||
| /* Header */ | ||
| .header { | ||
| position: fixed; | ||
| top: 20px; | ||
| left: 50%; | ||
| transform: translateX(-50%); | ||
| background: rgba(30, 41, 59, 0.95); | ||
| padding: 16px 32px; | ||
| border-radius: 16px; | ||
| border: 1px solid var(--border-color); | ||
| backdrop-filter: blur(12px); | ||
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); | ||
| z-index: 100; | ||
| text-align: center; | ||
| } | ||
| .header-title { | ||
| font-size: 20px; | ||
| font-weight: 700; | ||
| color: var(--text-primary); | ||
| margin-bottom: 4px; | ||
| } | ||
| .header-meta { | ||
| font-size: 12px; | ||
| color: var(--text-secondary); | ||
| display: flex; | ||
| gap: 16px; | ||
| justify-content: center; | ||
| } | ||
| .header-meta span { display: flex; align-items: center; gap: 4px; } | ||
| /* Right Sidebar - Contains Controls and Stats stacked vertically */ | ||
| .right-sidebar { | ||
| position: fixed; | ||
| top: 100px; | ||
| right: 20px; | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 16px; | ||
| z-index: 100; | ||
| max-height: calc(100vh - 140px); | ||
| overflow-y: auto; | ||
| } | ||
| /* Hide scrollbar but keep functionality */ | ||
| .right-sidebar::-webkit-scrollbar { | ||
| width: 4px; | ||
| } | ||
| .right-sidebar::-webkit-scrollbar-track { | ||
| background: transparent; | ||
| } | ||
| .right-sidebar::-webkit-scrollbar-thumb { | ||
| background: var(--border-color); | ||
| border-radius: 2px; | ||
| } | ||
| /* Panel base style (shared by controls and stats) */ | ||
| .panel { | ||
| background: rgba(30, 41, 59, 0.95); | ||
| padding: 16px 20px; | ||
| border-radius: 16px; | ||
| border: 1px solid var(--border-color); | ||
| backdrop-filter: blur(12px); | ||
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); | ||
| min-width: 180px; | ||
| } | ||
| .panel-title { | ||
| font-size: 13px; | ||
| font-weight: 700; | ||
| color: var(--text-primary); | ||
| margin-bottom: 12px; | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 8px; | ||
| } | ||
| /* Control buttons */ | ||
| .control-btn { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 10px; | ||
| width: 100%; | ||
| padding: 10px 14px; | ||
| margin: 6px 0; | ||
| background: var(--bg-tertiary); | ||
| color: var(--text-primary); | ||
| border: 1px solid var(--border-color); | ||
| border-radius: 10px; | ||
| cursor: pointer; | ||
| font-size: 13px; | ||
| font-weight: 500; | ||
| transition: all 0.2s ease; | ||
| } | ||
| .control-btn:hover { | ||
| background: var(--accent-blue); | ||
| border-color: var(--accent-blue); | ||
| transform: translateX(-2px); | ||
| } | ||
| .control-btn.primary { | ||
| background: linear-gradient(135deg, var(--accent-blue) 0%, var(--accent-cyan) 100%); | ||
| border-color: var(--accent-blue); | ||
| } | ||
| .control-btn:first-of-type { | ||
| margin-top: 0; | ||
| } | ||
| /* Stats rows */ | ||
| .stat-row { | ||
| display: flex; | ||
| justify-content: space-between; | ||
| margin: 8px 0; | ||
| font-size: 12px; | ||
| } | ||
| .stat-row:first-of-type { | ||
| margin-top: 0; | ||
| } | ||
| .stat-label { color: var(--text-secondary); } | ||
| .stat-value { color: var(--accent-cyan); font-weight: 700; } | ||
| /* Zoom Controls - Bottom Right */ | ||
| .zoom-controls { | ||
| position: fixed; | ||
| bottom: 20px; | ||
| right: 20px; | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 8px; | ||
| z-index: 100; | ||
| } | ||
| .zoom-btn { | ||
| width: 44px; | ||
| height: 44px; | ||
| background: rgba(30, 41, 59, 0.95); | ||
| border: 1px solid var(--border-color); | ||
| border-radius: 12px; | ||
| color: var(--text-primary); | ||
| font-size: 20px; | ||
| font-weight: 700; | ||
| cursor: pointer; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| transition: all 0.2s ease; | ||
| backdrop-filter: blur(12px); | ||
| } | ||
| .zoom-btn:hover { | ||
| background: var(--accent-blue); | ||
| border-color: var(--accent-blue); | ||
| transform: scale(1.1); | ||
| } | ||
| .zoom-level { | ||
| width: 44px; | ||
| padding: 8px; | ||
| background: rgba(30, 41, 59, 0.95); | ||
| border: 1px solid var(--border-color); | ||
| border-radius: 12px; | ||
| text-align: center; | ||
| color: var(--accent-cyan); | ||
| font-size: 10px; | ||
| font-weight: 700; | ||
| backdrop-filter: blur(12px); | ||
| } | ||
| /* Legend - Bottom Left */ | ||
| .legend { | ||
| position: fixed; | ||
| bottom: 20px; | ||
| left: 20px; | ||
| background: rgba(30, 41, 59, 0.95); | ||
| padding: 16px 20px; | ||
| border-radius: 16px; | ||
| border: 1px solid var(--border-color); | ||
| backdrop-filter: blur(12px); | ||
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); | ||
| z-index: 100; | ||
| } | ||
| .legend-title { | ||
| font-size: 13px; | ||
| font-weight: 700; | ||
| color: var(--text-primary); | ||
| margin-bottom: 12px; | ||
| } | ||
| .legend-item { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 10px; | ||
| margin: 8px 0; | ||
| font-size: 12px; | ||
| color: var(--text-secondary); | ||
| } | ||
| .legend-dot { | ||
| width: 14px; | ||
| height: 14px; | ||
| border-radius: 50%; | ||
| border: 2px solid var(--bg-primary); | ||
| box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); | ||
| } | ||
| /* Tooltip */ | ||
| .tooltip { | ||
| position: absolute; | ||
| padding: 14px 18px; | ||
| background: rgba(15, 23, 42, 0.98); | ||
| border: 1px solid var(--border-color); | ||
| border-radius: 12px; | ||
| font-size: 13px; | ||
| max-width: 300px; | ||
| pointer-events: none; | ||
| opacity: 0; | ||
| transform: translateY(-10px); | ||
| transition: opacity 0.2s, transform 0.2s; | ||
| z-index: 1000; | ||
| backdrop-filter: blur(12px); | ||
| box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); | ||
| } | ||
| .tooltip.visible { | ||
| opacity: 1; | ||
| transform: translateY(0); | ||
| } | ||
| .tooltip-title { | ||
| font-weight: 700; | ||
| color: var(--accent-cyan); | ||
| margin-bottom: 8px; | ||
| font-size: 14px; | ||
| } | ||
| .tooltip-row { | ||
| display: flex; | ||
| justify-content: space-between; | ||
| margin: 4px 0; | ||
| } | ||
| .tooltip-label { color: var(--text-secondary); } | ||
| .tooltip-value { color: var(--text-primary); font-weight: 600; } | ||
| /* Tree specific styles */ | ||
| .node circle { | ||
| stroke: var(--bg-primary); | ||
| stroke-width: 2px; | ||
| cursor: pointer; | ||
| transition: all 0.2s ease; | ||
| } | ||
| .node circle:hover { | ||
| stroke: var(--text-primary); | ||
| stroke-width: 3px; | ||
| filter: drop-shadow(0 0 8px currentColor); | ||
| } | ||
| .node text { | ||
| font-size: 11px; | ||
| fill: var(--text-secondary); | ||
| pointer-events: none; | ||
| font-weight: 500; | ||
| } | ||
| .link { | ||
| fill: none; | ||
| stroke: var(--border-color); | ||
| stroke-width: 1.5px; | ||
| stroke-opacity: 0.6; | ||
| } | ||
| .link:hover { | ||
| stroke: var(--accent-cyan); | ||
| stroke-opacity: 1; | ||
| stroke-width: 2px; | ||
| } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <div id="container"></div> | ||
| <!-- Header - Top Center --> | ||
| <div class="header"> | ||
| <div class="header-title">🧭 Dependency Tree</div> | ||
| <div class="header-meta"> | ||
| <span><strong>Version:</strong> ${projectVersion}</span> | ||
| <span><strong>Dependencies:</strong> ${nodes.length}</span> | ||
| <span><strong>Generated:</strong> ${new Date().toLocaleString()}</span> | ||
| </div> | ||
| </div> | ||
| <!-- Right Sidebar - Controls and Stats stacked vertically --> | ||
| <div class="right-sidebar"> | ||
| <!-- Controls Panel --> | ||
| <div class="panel" id="controls-panel"> | ||
| <div class="panel-title">⚙️ Controls</div> | ||
| <button class="control-btn primary" onclick="resetView()">↻ Reset View</button> | ||
| <button class="control-btn" onclick="fitToScreen()">⛶ Fit to Screen</button> | ||
| <button class="control-btn" onclick="toggleLabels()">🏷️ Toggle Labels</button> | ||
| <button class="control-btn" onclick="expandAll()">📂 Expand All</button> | ||
| <button class="control-btn" onclick="collapseAll()">📁 Collapse All</button> | ||
| </div> | ||
| <!-- Statistics Panel - Separate panel below controls --> | ||
| <div class="panel" id="stats-panel"> | ||
| <div class="panel-title">📊 Statistics</div> | ||
| <div class="stat-row"> | ||
| <span class="stat-label">Total Nodes</span> | ||
| <span class="stat-value" id="total-nodes">${nodes.length}</span> | ||
| </div> | ||
| <div class="stat-row"> | ||
| <span class="stat-label">Visible</span> | ||
| <span class="stat-value" id="visible-nodes">${nodes.length}</span> | ||
| </div> | ||
| <div class="stat-row"> | ||
| <span class="stat-label">Max Depth</span> | ||
| <span class="stat-value" id="max-depth">0</span> | ||
| </div> | ||
| <div class="stat-row"> | ||
| <span class="stat-label">Zoom</span> | ||
| <span class="stat-value" id="zoom-stat">100%</span> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| <!-- Zoom Controls - Bottom Right --> | ||
| <div class="zoom-controls"> | ||
| <button class="zoom-btn" onclick="zoomIn()">+</button> | ||
| <div class="zoom-level" id="zoom-level">100%</div> | ||
| <button class="zoom-btn" onclick="zoomOut()">−</button> | ||
| <button class="zoom-btn" onclick="resetView()">⟲</button> | ||
| </div> | ||
| <!-- Legend - Bottom Left --> | ||
| <div class="legend"> | ||
| <div class="legend-title">🎨 Health Status</div> | ||
| <div class="legend-item"> | ||
| <div class="legend-dot" style="background: var(--health-excellent);"></div> | ||
| <span>Excellent (9-10)</span> | ||
| </div> | ||
| <div class="legend-item"> | ||
| <div class="legend-dot" style="background: var(--health-good);"></div> | ||
| <span>Good (7-8)</span> | ||
| </div> | ||
| <div class="legend-item"> | ||
| <div class="legend-dot" style="background: var(--health-caution);"></div> | ||
| <span>Caution (5-7)</span> | ||
| </div> | ||
| <div class="legend-item"> | ||
| <div class="legend-dot" style="background: var(--health-warning);"></div> | ||
| <span>Warning (3-5)</span> | ||
| </div> | ||
| <div class="legend-item"> | ||
| <div class="legend-dot" style="background: var(--health-critical);"></div> | ||
| <span>Critical (<3)</span> | ||
| </div> | ||
| <div class="legend-item"> | ||
| <div class="legend-dot" style="background: var(--root-color);"></div> | ||
| <span>Root Package</span> | ||
| </div> | ||
| </div> | ||
| <!-- Tooltip --> | ||
| <div class="tooltip" id="tooltip"></div> | ||
| <script> | ||
| const graphData = ${graphDataJSON}; | ||
| const width = window.innerWidth; | ||
| const height = window.innerHeight; | ||
| let showLabels = true; | ||
| let currentZoom = 1; | ||
| // Build hierarchy from nodes and links | ||
| function buildHierarchyFromData(nodes, links) { | ||
| if (!nodes || nodes.length === 0) return null; | ||
| // Create node map | ||
| const nodeMap = new Map(); | ||
| nodes.forEach(n => { | ||
| nodeMap.set(n.id, { ...n, children: [] }); | ||
| }); | ||
| // Find root (type === 'root' or depth === 0) | ||
| let root = nodes.find(n => n.type === 'root' || n.depth === 0); | ||
| if (!root) root = nodes[0]; | ||
| // Build parent-child relationships from links | ||
| const childrenAdded = new Set(); | ||
| links.forEach(link => { | ||
| const sourceId = typeof link.source === 'object' ? link.source.id : link.source; | ||
| const targetId = typeof link.target === 'object' ? link.target.id : link.target; | ||
| const parent = nodeMap.get(sourceId); | ||
| const child = nodeMap.get(targetId); | ||
| if (parent && child && !childrenAdded.has(targetId)) { | ||
| parent.children.push(child); | ||
| childrenAdded.add(targetId); | ||
| } | ||
| }); | ||
| return nodeMap.get(root.id); | ||
| } | ||
| // Get color based on health score | ||
| function getHealthColor(node) { | ||
| if (node.type === 'root') return 'var(--root-color)'; | ||
| const score = node.healthScore || 8; | ||
| if (score >= 9) return 'var(--health-excellent)'; | ||
| if (score >= 7) return 'var(--health-good)'; | ||
| if (score >= 5) return 'var(--health-caution)'; | ||
| if (score >= 3) return 'var(--health-warning)'; | ||
| return 'var(--health-critical)'; | ||
| } | ||
| // Get node radius based on type | ||
| function getNodeRadius(node) { | ||
| if (node.type === 'root') return 18; | ||
| if (node.depth === 1) return 10; | ||
| return 7; | ||
| } | ||
| // Create SVG | ||
| const svg = d3.select("#container") | ||
| .append("svg") | ||
| .attr("width", width) | ||
| .attr("height", height); | ||
| // Create zoom behavior | ||
| const zoom = d3.zoom() | ||
| .scaleExtent([0.1, 4]) | ||
| .on("zoom", (event) => { | ||
| g.attr("transform", event.transform); | ||
| currentZoom = event.transform.k; | ||
| updateZoomDisplay(); | ||
| }); | ||
| svg.call(zoom); | ||
| const g = svg.append("g"); | ||
| // Build hierarchy | ||
| const hierarchyRoot = buildHierarchyFromData(graphData.nodes, graphData.links); | ||
| if (hierarchyRoot) { | ||
| const root = d3.hierarchy(hierarchyRoot); | ||
| // Calculate max depth for display | ||
| let maxDepth = 0; | ||
| root.each(d => { maxDepth = Math.max(maxDepth, d.depth); }); | ||
| document.getElementById('max-depth').textContent = maxDepth; | ||
| // Create tree layout - HORIZONTAL orientation for better visibility | ||
| const treeLayout = d3.tree() | ||
| .size([height - 200, width - 400]) | ||
| .separation((a, b) => (a.parent === b.parent ? 1.5 : 2)); | ||
| treeLayout(root); | ||
| // Swap x and y for horizontal layout (top-to-bottom becomes left-to-right) | ||
| root.each(d => { | ||
| const temp = d.x; | ||
| d.x = d.y + 200; // Add left margin | ||
| d.y = temp + 100; // Add top margin | ||
| }); | ||
| // Draw links with curved paths | ||
| const linkGenerator = d3.linkHorizontal() | ||
| .x(d => d.x) | ||
| .y(d => d.y); | ||
| g.selectAll(".link") | ||
| .data(root.links()) | ||
| .join("path") | ||
| .attr("class", "link") | ||
| .attr("d", linkGenerator); | ||
| // Draw nodes | ||
| const node = g.selectAll(".node") | ||
| .data(root.descendants()) | ||
| .join("g") | ||
| .attr("class", "node") | ||
| .attr("transform", d => \`translate(\${d.x},\${d.y})\`) | ||
| .on("mouseover", showTooltip) | ||
| .on("mouseout", hideTooltip) | ||
| .on("click", toggleChildren); | ||
| // Add circles | ||
| node.append("circle") | ||
| .attr("r", d => getNodeRadius(d.data)) | ||
| .attr("fill", d => getHealthColor(d.data)); | ||
| // Add labels | ||
| node.append("text") | ||
| .attr("dy", d => d.data.type === 'root' ? -25 : -15) | ||
| .attr("text-anchor", "middle") | ||
| .text(d => d.data.name || d.data.id) | ||
| .attr("class", "node-label"); | ||
| // Initial fit to screen | ||
| setTimeout(fitToScreen, 100); | ||
| } | ||
| // Tooltip functions | ||
| function showTooltip(event, d) { | ||
| const tooltip = document.getElementById('tooltip'); | ||
| const node = d.data; | ||
| const score = node.healthScore || 8; | ||
| tooltip.innerHTML = \` | ||
| <div class="tooltip-title">\${node.name || node.id}</div> | ||
| <div class="tooltip-row"> | ||
| <span class="tooltip-label">Version</span> | ||
| <span class="tooltip-value">\${node.version || 'N/A'}</span> | ||
| </div> | ||
| <div class="tooltip-row"> | ||
| <span class="tooltip-label">Health Score</span> | ||
| <span class="tooltip-value">\${score}/10</span> | ||
| </div> | ||
| <div class="tooltip-row"> | ||
| <span class="tooltip-label">Depth</span> | ||
| <span class="tooltip-value">\${d.depth}</span> | ||
| </div> | ||
| <div class="tooltip-row"> | ||
| <span class="tooltip-label">Children</span> | ||
| <span class="tooltip-value">\${d.children ? d.children.length : 0}</span> | ||
| </div> | ||
| \`; | ||
| tooltip.classList.add('visible'); | ||
| tooltip.style.left = (event.pageX + 15) + 'px'; | ||
| tooltip.style.top = (event.pageY - 10) + 'px'; | ||
| } | ||
| function hideTooltip() { | ||
| document.getElementById('tooltip').classList.remove('visible'); | ||
| } | ||
| // Toggle children visibility (collapse/expand) | ||
| function toggleChildren(event, d) { | ||
| if (d.children) { | ||
| d._children = d.children; | ||
| d.children = null; | ||
| } else if (d._children) { | ||
| d.children = d._children; | ||
| d._children = null; | ||
| } | ||
| // Note: Full implementation would re-render the tree | ||
| } | ||
| // Control functions | ||
| function resetView() { | ||
| svg.transition().duration(750).call( | ||
| zoom.transform, | ||
| d3.zoomIdentity | ||
| ); | ||
| } | ||
| function fitToScreen() { | ||
| const bounds = g.node().getBBox(); | ||
| const fullWidth = width; | ||
| const fullHeight = height; | ||
| const boundsWidth = bounds.width; | ||
| const boundsHeight = bounds.height; | ||
| const midX = bounds.x + boundsWidth / 2; | ||
| const midY = bounds.y + boundsHeight / 2; | ||
| const scale = 0.85 / Math.max(boundsWidth / fullWidth, boundsHeight / fullHeight); | ||
| const translate = [fullWidth / 2 - scale * midX, fullHeight / 2 - scale * midY]; | ||
| svg.transition().duration(750).call( | ||
| zoom.transform, | ||
| d3.zoomIdentity.translate(translate[0], translate[1]).scale(scale) | ||
| ); | ||
| } | ||
| function toggleLabels() { | ||
| showLabels = !showLabels; | ||
| d3.selectAll('.node-label').style('display', showLabels ? 'block' : 'none'); | ||
| } | ||
| function expandAll() { | ||
| // Placeholder for full expand functionality | ||
| console.log('Expand all nodes'); | ||
| } | ||
| function collapseAll() { | ||
| // Placeholder for full collapse functionality | ||
| console.log('Collapse all nodes'); | ||
| } | ||
| function zoomIn() { | ||
| svg.transition().duration(300).call(zoom.scaleBy, 1.3); | ||
| } | ||
| function zoomOut() { | ||
| svg.transition().duration(300).call(zoom.scaleBy, 0.7); | ||
| } | ||
| function updateZoomDisplay() { | ||
| const zoomPercent = Math.round(currentZoom * 100) + '%'; | ||
| document.getElementById('zoom-level').textContent = zoomPercent; | ||
| document.getElementById('zoom-stat').textContent = zoomPercent; | ||
| } | ||
| // Keyboard shortcuts | ||
| document.addEventListener('keydown', (e) => { | ||
| if (e.key === '+' || e.key === '=') zoomIn(); | ||
| if (e.key === '-') zoomOut(); | ||
| if (e.key === 'r' || e.key === 'R') resetView(); | ||
| if (e.key === 'f' || e.key === 'F') fitToScreen(); | ||
| if (e.key === 'l' || e.key === 'L') toggleLabels(); | ||
| }); | ||
| </script> | ||
| </body> | ||
| </html>`; | ||
| } | ||
| /** | ||
| * Generate empty state HTML when no nodes | ||
| */ | ||
| function generateEmptyStateHTML(projectName, projectVersion) { | ||
| return `<!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <title>DevCompass - No Dependencies</title> | ||
| <style> | ||
| body { | ||
| font-family: 'Segoe UI', system-ui, sans-serif; | ||
| background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%); | ||
| color: #f1f5f9; | ||
| min-height: 100vh; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| margin: 0; | ||
| } | ||
| .message { | ||
| text-align: center; | ||
| padding: 40px; | ||
| background: rgba(30, 41, 59, 0.95); | ||
| border-radius: 20px; | ||
| border: 1px solid #475569; | ||
| } | ||
| .icon { font-size: 64px; margin-bottom: 20px; } | ||
| h1 { margin: 0 0 10px; font-size: 24px; } | ||
| p { color: #94a3b8; margin: 0; } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <div class="message"> | ||
| <div class="icon">📦</div> | ||
| <h1>No Dependencies Found</h1> | ||
| <p>${projectName} v${projectVersion} has no dependencies to visualize.</p> | ||
| </div> | ||
| </body> | ||
| </html>`; | ||
| } | ||
| /** | ||
| * Build hierarchy structure from flat nodes/links | ||
| */ | ||
| function buildHierarchy(nodes, links) { | ||
| if (!Array.isArray(nodes) || nodes.length === 0) return null; | ||
| const nodeMap = new Map(); | ||
| nodes.forEach(n => nodeMap.set(n.id, { ...n, children: [] })); | ||
| const root = nodes.find(n => n.type === 'root' || n.depth === 0) || nodes[0]; | ||
| const childrenAdded = new Set(); | ||
| links.forEach(link => { | ||
| const sourceId = typeof link.source === 'object' ? link.source.id : link.source; | ||
| const targetId = typeof link.target === 'object' ? link.target.id : link.target; | ||
| const parent = nodeMap.get(sourceId); | ||
| const child = nodeMap.get(targetId); | ||
| if (parent && child && !childrenAdded.has(targetId)) { | ||
| parent.children.push(child); | ||
| childrenAdded.add(targetId); | ||
| } | ||
| }); | ||
| return nodeMap.get(root.id); | ||
| } | ||
| /** | ||
| * Create tree layout D3 script (for backward compatibility) | ||
| */ | ||
| function createTreeLayout(graphData, options = {}) { | ||
| return generateTreeLayoutHTML(graphData, options); | ||
| } | ||
| module.exports = { | ||
| createTreeLayout, | ||
| generateTreeLayoutHTML, | ||
| buildHierarchy | ||
| }; |
| <!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="UTF-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| <title>DevCompass v3.1.6 - Dependency Graph</title> | ||
| <script src="https://d3js.org/d3.v7.min.js"></script> | ||
| <style> | ||
| * { | ||
| margin: 0; | ||
| padding: 0; | ||
| box-sizing: border-box; | ||
| } | ||
| body { | ||
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; | ||
| background: #0a0e1a; | ||
| color: #e0e6ed; | ||
| overflow: hidden; | ||
| } | ||
| /* ========== HEADER ========== */ | ||
| .header { | ||
| background: linear-gradient(135deg, #1a1f35 0%, #0f1219 100%); | ||
| border-bottom: 1px solid #2a3142; | ||
| padding: 1rem 1.5rem; | ||
| display: flex; | ||
| justify-content: space-between; | ||
| align-items: center; | ||
| flex-wrap: wrap; | ||
| gap: 1rem; | ||
| position: relative; | ||
| z-index: 1000; | ||
| } | ||
| .header h1 { | ||
| font-size: 1.25rem; | ||
| font-weight: 600; | ||
| color: #fff; | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 0.5rem; | ||
| } | ||
| .header h1 .icon { | ||
| font-size: 1.5rem; | ||
| } | ||
| .version-badge { | ||
| font-size: 0.7rem; | ||
| background: #3b82f6; | ||
| padding: 0.15rem 0.5rem; | ||
| border-radius: 4px; | ||
| font-weight: 500; | ||
| } | ||
| /* ========== CONTROLS PANEL ========== */ | ||
| .controls { | ||
| display: flex; | ||
| gap: 1.5rem; | ||
| align-items: center; | ||
| flex-wrap: wrap; | ||
| } | ||
| .control-group { | ||
| display: flex; | ||
| gap: 0.5rem; | ||
| align-items: center; | ||
| } | ||
| .control-label { | ||
| font-size: 0.85rem; | ||
| color: #8b92a7; | ||
| font-weight: 500; | ||
| } | ||
| .btn-group { | ||
| display: flex; | ||
| gap: 0.25rem; | ||
| background: #1a1f35; | ||
| border-radius: 6px; | ||
| padding: 3px; | ||
| } | ||
| .btn { | ||
| padding: 0.4rem 0.9rem; | ||
| border: none; | ||
| background: transparent; | ||
| color: #8b92a7; | ||
| font-size: 0.85rem; | ||
| cursor: pointer; | ||
| border-radius: 4px; | ||
| transition: all 0.2s; | ||
| font-weight: 500; | ||
| } | ||
| .btn:hover { | ||
| background: #252b42; | ||
| color: #fff; | ||
| } | ||
| .btn.active { | ||
| background: #3b82f6; | ||
| color: #fff; | ||
| } | ||
| .slider-container { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 0.5rem; | ||
| } | ||
| input[type="range"] { | ||
| width: 100px; | ||
| height: 4px; | ||
| background: #1a1f35; | ||
| border-radius: 2px; | ||
| outline: none; | ||
| -webkit-appearance: none; | ||
| } | ||
| input[type="range"]::-webkit-slider-thumb { | ||
| -webkit-appearance: none; | ||
| width: 14px; | ||
| height: 14px; | ||
| background: #3b82f6; | ||
| border-radius: 50%; | ||
| cursor: pointer; | ||
| } | ||
| input[type="range"]::-moz-range-thumb { | ||
| width: 14px; | ||
| height: 14px; | ||
| background: #3b82f6; | ||
| border-radius: 50%; | ||
| cursor: pointer; | ||
| border: none; | ||
| } | ||
| .depth-value { | ||
| min-width: 30px; | ||
| text-align: center; | ||
| font-size: 0.85rem; | ||
| color: #3b82f6; | ||
| font-weight: 600; | ||
| } | ||
| /* ========== SEARCH ========== */ | ||
| .search-container { | ||
| position: relative; | ||
| } | ||
| .search-input { | ||
| padding: 0.5rem 1rem; | ||
| background: #1a1f35; | ||
| border: 1px solid #2a3142; | ||
| border-radius: 6px; | ||
| color: #e0e6ed; | ||
| font-size: 0.85rem; | ||
| width: 200px; | ||
| outline: none; | ||
| transition: all 0.2s; | ||
| } | ||
| .search-input:focus { | ||
| border-color: #3b82f6; | ||
| background: #252b42; | ||
| } | ||
| /* ========== MAIN CONTAINER ========== */ | ||
| .container { | ||
| display: flex; | ||
| height: calc(100vh - 80px); | ||
| } | ||
| /* ========== GRAPH AREA ========== */ | ||
| .graph-container { | ||
| flex: 1; | ||
| position: relative; | ||
| overflow: hidden; | ||
| } | ||
| #graph { | ||
| width: 100%; | ||
| height: 100%; | ||
| } | ||
| /* ========== SIDEBAR ========== */ | ||
| .sidebar { | ||
| width: 320px; | ||
| background: #0f1219; | ||
| border-left: 1px solid #2a3142; | ||
| overflow-y: auto; | ||
| padding: 1.5rem; | ||
| } | ||
| .sidebar h2 { | ||
| font-size: 1rem; | ||
| color: #fff; | ||
| margin-bottom: 1rem; | ||
| padding-bottom: 0.5rem; | ||
| border-bottom: 1px solid #2a3142; | ||
| } | ||
| /* ========== CLUSTERING SECTION ========== */ | ||
| .cluster-section { | ||
| margin-bottom: 1.5rem; | ||
| background: #1a1f35; | ||
| border-radius: 8px; | ||
| padding: 1rem; | ||
| border: 1px solid #2a3142; | ||
| } | ||
| .cluster-mode-grid { | ||
| display: grid; | ||
| grid-template-columns: repeat(3, 1fr); | ||
| gap: 0.5rem; | ||
| margin-bottom: 0.75rem; | ||
| } | ||
| .cluster-mode-btn { | ||
| padding: 0.6rem; | ||
| background: #252b42; | ||
| border: 1px solid #2a3142; | ||
| color: #8b92a7; | ||
| font-size: 0.8rem; | ||
| cursor: pointer; | ||
| border-radius: 5px; | ||
| transition: all 0.2s; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| gap: 0.4rem; | ||
| font-weight: 500; | ||
| } | ||
| .cluster-mode-btn:hover { | ||
| background: #2d3548; | ||
| border-color: #3b82f6; | ||
| } | ||
| .cluster-mode-btn.active { | ||
| background: #3b82f6; | ||
| border-color: #3b82f6; | ||
| color: #fff; | ||
| } | ||
| .cluster-list { | ||
| max-height: 300px; | ||
| overflow-y: auto; | ||
| margin-top: 0.75rem; | ||
| } | ||
| .cluster-item { | ||
| background: #252b42; | ||
| border: 1px solid #2a3142; | ||
| border-radius: 6px; | ||
| padding: 0.75rem; | ||
| margin-bottom: 0.5rem; | ||
| cursor: pointer; | ||
| transition: all 0.2s; | ||
| } | ||
| .cluster-item:hover { | ||
| background: #2d3548; | ||
| border-color: #3b82f6; | ||
| transform: translateX(-2px); | ||
| } | ||
| .cluster-header { | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: space-between; | ||
| margin-bottom: 0.5rem; | ||
| } | ||
| .cluster-title { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 0.5rem; | ||
| font-size: 0.85rem; | ||
| font-weight: 600; | ||
| color: #e0e6ed; | ||
| } | ||
| .cluster-icon { | ||
| font-size: 1rem; | ||
| } | ||
| .cluster-count { | ||
| background: rgba(59, 130, 246, 0.2); | ||
| color: #60a5fa; | ||
| padding: 0.15rem 0.5rem; | ||
| border-radius: 10px; | ||
| font-size: 0.75rem; | ||
| font-weight: 600; | ||
| } | ||
| .cluster-stats { | ||
| display: flex; | ||
| gap: 0.4rem; | ||
| flex-wrap: wrap; | ||
| font-size: 0.75rem; | ||
| } | ||
| .cluster-badge { | ||
| padding: 0.15rem 0.4rem; | ||
| border-radius: 4px; | ||
| font-weight: 500; | ||
| } | ||
| .cluster-badge.vulnerable { | ||
| background: rgba(239, 68, 68, 0.2); | ||
| color: #fca5a5; | ||
| } | ||
| .cluster-badge.deprecated { | ||
| background: rgba(139, 92, 246, 0.2); | ||
| color: #c4b5fd; | ||
| } | ||
| .cluster-badge.outdated { | ||
| background: rgba(245, 158, 11, 0.2); | ||
| color: #fcd34d; | ||
| } | ||
| .cluster-badge.healthy { | ||
| background: rgba(16, 185, 129, 0.2); | ||
| color: #6ee7b7; | ||
| } | ||
| /* ========== STATS ========== */ | ||
| .stat-grid { | ||
| display: grid; | ||
| gap: 0.75rem; | ||
| margin-bottom: 1.5rem; | ||
| } | ||
| .stat-item { | ||
| background: #1a1f35; | ||
| padding: 0.75rem; | ||
| border-radius: 6px; | ||
| display: flex; | ||
| justify-content: space-between; | ||
| align-items: center; | ||
| } | ||
| .stat-label { | ||
| font-size: 0.85rem; | ||
| color: #8b92a7; | ||
| } | ||
| .stat-value { | ||
| font-size: 1.25rem; | ||
| font-weight: 600; | ||
| color: #fff; | ||
| } | ||
| .stat-value.danger { color: #ef4444; } | ||
| .stat-value.warning { color: #f59e0b; } | ||
| .stat-value.success { color: #10b981; } | ||
| .stat-value.cluster { color: #3b82f6; } | ||
| /* ========== LEGEND ========== */ | ||
| .legend { | ||
| margin-top: 1rem; | ||
| } | ||
| .legend-item { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 0.5rem; | ||
| padding: 0.5rem 0; | ||
| font-size: 0.85rem; | ||
| } | ||
| .legend-color { | ||
| width: 16px; | ||
| height: 16px; | ||
| border-radius: 3px; | ||
| } | ||
| /* ========== CONTROL BUTTONS ========== */ | ||
| .control-btn { | ||
| width: 100%; | ||
| padding: 0.6rem 1rem; | ||
| background: #1a1f35; | ||
| border: 1px solid #2a3142; | ||
| border-radius: 6px; | ||
| color: #e0e6ed; | ||
| font-size: 0.85rem; | ||
| cursor: pointer; | ||
| transition: all 0.2s; | ||
| text-align: left; | ||
| font-weight: 500; | ||
| white-space: nowrap; | ||
| overflow: hidden; | ||
| text-overflow: ellipsis; | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 0.5rem; | ||
| } | ||
| .control-btn:hover { | ||
| background: #252b42; | ||
| border-color: #3b82f6; | ||
| color: #fff; | ||
| } | ||
| .control-btn:active { | ||
| transform: scale(0.98); | ||
| background: #3b82f6; | ||
| } | ||
| /* ========== TOOLTIP ========== */ | ||
| .tooltip { | ||
| position: absolute; | ||
| background: rgba(15, 18, 25, 0.95); | ||
| border: 1px solid #3b82f6; | ||
| border-radius: 8px; | ||
| padding: 1rem; | ||
| pointer-events: none; | ||
| opacity: 0; | ||
| transition: opacity 0.2s; | ||
| max-width: 350px; | ||
| z-index: 10000; | ||
| backdrop-filter: blur(10px); | ||
| } | ||
| .tooltip.visible { | ||
| opacity: 1; | ||
| } | ||
| .tooltip-title { | ||
| font-size: 1rem; | ||
| font-weight: 600; | ||
| color: #fff; | ||
| margin-bottom: 0.5rem; | ||
| } | ||
| .tooltip-content { | ||
| font-size: 0.85rem; | ||
| color: #8b92a7; | ||
| line-height: 1.5; | ||
| } | ||
| .tooltip-badge { | ||
| display: inline-block; | ||
| padding: 0.15rem 0.5rem; | ||
| background: #ef4444; | ||
| color: #fff; | ||
| font-size: 0.75rem; | ||
| border-radius: 4px; | ||
| margin-right: 0.25rem; | ||
| font-weight: 500; | ||
| } | ||
| .tooltip-badge.warning { background: #f59e0b; } | ||
| .tooltip-badge.info { background: #3b82f6; } | ||
| .tooltip-badge.deprecated { background: #8b5cf6; } | ||
| /* ========== GRAPH STYLES ========== */ | ||
| .node { | ||
| cursor: pointer; | ||
| transition: all 0.2s; | ||
| } | ||
| .node:hover { | ||
| filter: brightness(1.3); | ||
| } | ||
| .node-circle { | ||
| stroke-width: 2px; | ||
| transition: all 0.2s; | ||
| } | ||
| .node-label { | ||
| font-size: 11px; | ||
| fill: #e0e6ed; | ||
| pointer-events: none; | ||
| text-anchor: middle; | ||
| font-weight: 500; | ||
| } | ||
| .link { | ||
| stroke: #2a3142; | ||
| stroke-opacity: 0.6; | ||
| fill: none; | ||
| stroke-width: 1.5px; | ||
| } | ||
| .link.circular { | ||
| stroke: #ef4444; | ||
| stroke-dasharray: 5, 5; | ||
| stroke-opacity: 0.8; | ||
| } | ||
| /* ========== LOADING ========== */ | ||
| .loading { | ||
| position: absolute; | ||
| top: 50%; | ||
| left: 50%; | ||
| transform: translate(-50%, -50%); | ||
| text-align: center; | ||
| display: none; | ||
| } | ||
| .loading.visible { | ||
| display: block; | ||
| } | ||
| .spinner { | ||
| width: 50px; | ||
| height: 50px; | ||
| border: 3px solid #2a3142; | ||
| border-top-color: #3b82f6; | ||
| border-radius: 50%; | ||
| animation: spin 1s linear infinite; | ||
| margin: 0 auto 1rem; | ||
| } | ||
| @keyframes spin { | ||
| to { transform: rotate(360deg); } | ||
| } | ||
| /* ========== RESPONSIVE ========== */ | ||
| @media (max-width: 1024px) { | ||
| .sidebar { | ||
| width: 280px; | ||
| } | ||
| .controls { | ||
| gap: 1rem; | ||
| } | ||
| } | ||
| @media (max-width: 768px) { | ||
| .header { | ||
| flex-direction: column; | ||
| height: auto; | ||
| gap: 1rem; | ||
| } | ||
| .controls { | ||
| width: 100%; | ||
| justify-content: flex-start; | ||
| } | ||
| .sidebar { | ||
| display: none; | ||
| } | ||
| } | ||
| /* ========== SCROLLBAR ========== */ | ||
| ::-webkit-scrollbar { | ||
| width: 8px; | ||
| } | ||
| ::-webkit-scrollbar-track { | ||
| background: #1a1f35; | ||
| } | ||
| ::-webkit-scrollbar-thumb { | ||
| background: #2a3142; | ||
| border-radius: 4px; | ||
| } | ||
| ::-webkit-scrollbar-thumb:hover { | ||
| background: #3b82f6; | ||
| } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <!-- Header --> | ||
| <div class="header"> | ||
| <h1> | ||
| <span class="icon">📊</span> | ||
| DevCompass - Dependency Graph | ||
| <span class="version-badge">v3.1.6</span> | ||
| </h1> | ||
| <!-- Controls --> | ||
| <div class="controls"> | ||
| <!-- Layout Switcher --> | ||
| <div class="control-group"> | ||
| <span class="control-label">Layout:</span> | ||
| <div class="btn-group"> | ||
| <button class="btn active" data-layout="tree">Tree</button> | ||
| <button class="btn" data-layout="force">Force</button> | ||
| <button class="btn" data-layout="radial">Radial</button> | ||
| <button class="btn" data-layout="conflict">Conflict</button> | ||
| </div> | ||
| </div> | ||
| <!-- Filter Switcher --> | ||
| <div class="control-group"> | ||
| <span class="control-label">Filter:</span> | ||
| <div class="btn-group"> | ||
| <button class="btn active" data-filter="all">All</button> | ||
| <button class="btn" data-filter="vulnerable">Vulnerable</button> | ||
| <button class="btn" data-filter="outdated">Outdated</button> | ||
| <button class="btn" data-filter="deprecated">Deprecated</button> | ||
| <button class="btn" data-filter="unused">Unused</button> | ||
| </div> | ||
| </div> | ||
| <!-- Depth Slider --> | ||
| <div class="control-group slider-container"> | ||
| <span class="control-label">Depth:</span> | ||
| <input type="range" id="depthSlider" min="1" max="10" value="10"> | ||
| <span class="depth-value" id="depthValue">∞</span> | ||
| </div> | ||
| <!-- Search --> | ||
| <div class="search-container"> | ||
| <input type="text" class="search-input" placeholder="Search packages..." id="searchInput"> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| <!-- Main Container --> | ||
| <div class="container"> | ||
| <!-- Graph --> | ||
| <div class="graph-container"> | ||
| <svg id="graph"></svg> | ||
| <div class="loading" id="loading"> | ||
| <div class="spinner"></div> | ||
| <div>Rendering graph...</div> | ||
| </div> | ||
| </div> | ||
| <!-- Sidebar --> | ||
| <div class="sidebar"> | ||
| <!-- Clustering Section --> | ||
| <h2>🔲 Clustering</h2> | ||
| <div class="cluster-section"> | ||
| <div class="cluster-mode-grid"> | ||
| <button class="cluster-mode-btn active" data-cluster="ecosystem"> | ||
| ⚛️ Ecosystem | ||
| </button> | ||
| <button class="cluster-mode-btn" data-cluster="health"> | ||
| 🏥 Health | ||
| </button> | ||
| <button class="cluster-mode-btn" data-cluster="depth"> | ||
| 📊 Depth | ||
| </button> | ||
| </div> | ||
| <div class="cluster-list" id="clusterList"> | ||
| <!-- Populated by JS --> | ||
| </div> | ||
| </div> | ||
| <!-- Statistics --> | ||
| <h2>📊 Statistics</h2> | ||
| <div class="stat-grid" id="stats"> | ||
| <!-- Populated by JS --> | ||
| </div> | ||
| <!-- Legend --> | ||
| <h2>🎨 Legend</h2> | ||
| <div class="legend"> | ||
| <div class="legend-item"> | ||
| <div class="legend-color" style="background: #10b981;"></div> | ||
| <span>Healthy</span> | ||
| </div> | ||
| <div class="legend-item"> | ||
| <div class="legend-color" style="background: #f59e0b;"></div> | ||
| <span>Outdated</span> | ||
| </div> | ||
| <div class="legend-item"> | ||
| <div class="legend-color" style="background: #ef4444;"></div> | ||
| <span>Vulnerable</span> | ||
| </div> | ||
| <div class="legend-item"> | ||
| <div class="legend-color" style="background: #8b5cf6;"></div> | ||
| <span>Deprecated</span> | ||
| </div> | ||
| <div class="legend-item"> | ||
| <div class="legend-color" style="background: #6b7280;"></div> | ||
| <span>Unused</span> | ||
| </div> | ||
| </div> | ||
| <!-- Controls --> | ||
| <h2>⚙️ Controls</h2> | ||
| <div style="display: flex; flex-direction: column; gap: 0.5rem;"> | ||
| <button class="control-btn" onclick="fitToScreen()">⛶ Fit to Screen</button> | ||
| <button class="control-btn" onclick="zoomIn()">🔍+ Zoom In</button> | ||
| <button class="control-btn" onclick="zoomOut()">🔍− Zoom Out</button> | ||
| <button class="control-btn" onclick="resetZoom()">⟲ Reset Zoom</button> | ||
| <button class="control-btn" onclick="centerGraph()">⊙ Center View</button> | ||
| <hr style="border: none; border-top: 1px solid #2a3142; margin: 0.5rem 0;"> | ||
| <button class="control-btn" onclick="exportPNG()">📸 Save as PNG</button> | ||
| <button class="control-btn" onclick="exportJSON()">💾 Save as JSON</button> | ||
| <button class="control-btn" onclick="toggleFullscreen()">🖵 Fullscreen</button> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| <!-- Tooltip --> | ||
| <div class="tooltip" id="tooltip"></div> | ||
| <script> | ||
| // Graph data will be injected here | ||
| // @ts-nocheck | ||
| const graphData = {{GRAPH_DATA}}; | ||
| const metadata = graphData.metadata || {}; | ||
| // Clustering code will be injected here | ||
| {{CLUSTERING_CODE}} | ||
| // State | ||
| let currentLayout = metadata.defaultLayout || 'tree'; | ||
| let currentFilter = metadata.defaultFilter || 'all'; | ||
| let currentDepth = metadata.defaultDepth || 10; | ||
| let currentClusterMode = 'ecosystem'; // For sidebar organization only | ||
| let searchTerm = ''; | ||
| let currentZoom = null; | ||
| let currentSvg = null; | ||
| let currentG = null; | ||
| let clusterer = null; | ||
| let clusters = []; | ||
| // Initialize | ||
| document.addEventListener('DOMContentLoaded', () => { | ||
| initializeClustering(); | ||
| initializeControls(); | ||
| renderGraph(); | ||
| updateStats(); | ||
| }); | ||
| // Initialize clustering (sidebar only - doesn't affect graph) | ||
| function initializeClustering() { | ||
| if (typeof DependencyClusterer !== 'undefined') { | ||
| clusterer = new DependencyClusterer(graphData.nodes, graphData.links); | ||
| updateClusters(); | ||
| } | ||
| } | ||
| // Update clusters based on mode (sidebar organization only) | ||
| function updateClusters() { | ||
| if (!clusterer) { | ||
| clusters = []; | ||
| renderClusterList(); | ||
| return; | ||
| } | ||
| clusters = clusterer.clusterBy(currentClusterMode); | ||
| renderClusterList(); | ||
| } | ||
| // Render cluster list (sidebar stats/navigation) | ||
| function renderClusterList() { | ||
| const container = document.getElementById('clusterList'); | ||
| if (!container) return; | ||
| if (clusters.length === 0) { | ||
| container.innerHTML = '<div style="text-align: center; color: #8b92a7; font-size: 0.8rem; padding: 1rem;">No clusters</div>'; | ||
| return; | ||
| } | ||
| container.innerHTML = ''; | ||
| clusters.forEach(cluster => { | ||
| const item = document.createElement('div'); | ||
| item.className = 'cluster-item'; | ||
| item.innerHTML = ` | ||
| <div class="cluster-header"> | ||
| <div class="cluster-title"> | ||
| <span class="cluster-icon">${cluster.icon}</span> | ||
| <span>${cluster.name}</span> | ||
| </div> | ||
| <span class="cluster-count">${cluster.stats.total}</span> | ||
| </div> | ||
| <div class="cluster-stats"> | ||
| ${cluster.stats.vulnerable > 0 ? `<span class="cluster-badge vulnerable">🔴 ${cluster.stats.vulnerable}</span>` : ''} | ||
| ${cluster.stats.deprecated > 0 ? `<span class="cluster-badge deprecated">🟣 ${cluster.stats.deprecated}</span>` : ''} | ||
| ${cluster.stats.outdated > 0 ? `<span class="cluster-badge outdated">🟡 ${cluster.stats.outdated}</span>` : ''} | ||
| ${cluster.stats.healthy > 0 ? `<span class="cluster-badge healthy">🟢 ${cluster.stats.healthy}</span>` : ''} | ||
| </div> | ||
| `; | ||
| // Click to highlight related packages in graph | ||
| item.onclick = () => highlightCluster(cluster); | ||
| container.appendChild(item); | ||
| }); | ||
| } | ||
| // Highlight packages from a cluster (visual emphasis only - 3 second fade) | ||
| function highlightCluster(cluster) { | ||
| const clusterNodeIds = new Set(cluster.nodes.map(n => n.id)); | ||
| // Update visual emphasis on graph nodes | ||
| if (currentSvg) { | ||
| currentSvg.selectAll('.node') | ||
| .transition() | ||
| .duration(300) | ||
| .style('opacity', d => { | ||
| const nodeId = d.data ? d.data.id : d.id; | ||
| return clusterNodeIds.has(nodeId) ? 1 : 0.3; | ||
| }); | ||
| currentSvg.selectAll('.link') | ||
| .transition() | ||
| .duration(300) | ||
| .style('opacity', 0.2); | ||
| // Reset after 3 seconds | ||
| setTimeout(() => { | ||
| currentSvg.selectAll('.node') | ||
| .transition() | ||
| .duration(500) | ||
| .style('opacity', 1); | ||
| currentSvg.selectAll('.link') | ||
| .transition() | ||
| .duration(500) | ||
| .style('opacity', 0.6); | ||
| }, 3000); | ||
| } | ||
| } | ||
| // Get visible nodes (NO clustering - just normal filtering) | ||
| function getVisibleNodes() { | ||
| let nodes = graphData.nodes; | ||
| // Apply depth filter | ||
| const maxDepth = currentDepth === 10 ? Infinity : currentDepth; | ||
| nodes = nodes.filter(n => (n.depth || 0) <= maxDepth); | ||
| // Apply category filter | ||
| if (currentFilter !== 'all') { | ||
| nodes = nodes.filter(n => { | ||
| if (n.type === 'root') return true; | ||
| switch(currentFilter) { | ||
| case 'vulnerable': return n.isVulnerable; | ||
| case 'outdated': return n.isOutdated; | ||
| case 'deprecated': return n.isDeprecated; | ||
| case 'unused': return n.isUnused; | ||
| default: return true; | ||
| } | ||
| }); | ||
| } | ||
| // Apply search filter | ||
| if (searchTerm) { | ||
| nodes = nodes.filter(n => | ||
| n.name.toLowerCase().includes(searchTerm) | ||
| ); | ||
| } | ||
| return nodes; | ||
| } | ||
| // Control handlers | ||
| function initializeControls() { | ||
| // Layout buttons | ||
| document.querySelectorAll('[data-layout]').forEach(btn => { | ||
| btn.addEventListener('click', () => { | ||
| document.querySelectorAll('[data-layout]').forEach(b => b.classList.remove('active')); | ||
| btn.classList.add('active'); | ||
| currentLayout = btn.dataset.layout; | ||
| renderGraph(); | ||
| }); | ||
| }); | ||
| // Filter buttons | ||
| document.querySelectorAll('[data-filter]').forEach(btn => { | ||
| btn.addEventListener('click', () => { | ||
| document.querySelectorAll('[data-filter]').forEach(b => b.classList.remove('active')); | ||
| btn.classList.add('active'); | ||
| currentFilter = btn.dataset.filter; | ||
| renderGraph(); | ||
| }); | ||
| }); | ||
| // Cluster mode buttons (sidebar organization only) | ||
| document.querySelectorAll('[data-cluster]').forEach(btn => { | ||
| btn.addEventListener('click', () => { | ||
| document.querySelectorAll('[data-cluster]').forEach(b => b.classList.remove('active')); | ||
| btn.classList.add('active'); | ||
| currentClusterMode = btn.dataset.cluster; | ||
| updateClusters(); // Updates sidebar list only | ||
| updateStats(); | ||
| }); | ||
| }); | ||
| // Depth slider | ||
| const slider = document.getElementById('depthSlider'); | ||
| const value = document.getElementById('depthValue'); | ||
| slider.addEventListener('input', (e) => { | ||
| currentDepth = parseInt(e.target.value); | ||
| value.textContent = currentDepth === 10 ? '∞' : currentDepth; | ||
| renderGraph(); | ||
| }); | ||
| // Search | ||
| document.getElementById('searchInput').addEventListener('input', (e) => { | ||
| searchTerm = e.target.value.toLowerCase(); | ||
| renderGraph(); | ||
| }); | ||
| } | ||
| // Filter nodes | ||
| function filterNodes(nodes) { | ||
| return getVisibleNodes(); | ||
| } | ||
| // Render graph (normal visualization - no cluster nodes) | ||
| function renderGraph() { | ||
| const loading = document.getElementById('loading'); | ||
| loading.classList.add('visible'); | ||
| setTimeout(() => { | ||
| const svg = d3.select('#graph'); | ||
| svg.selectAll('*').remove(); | ||
| const filteredNodes = filterNodes(graphData.nodes); | ||
| const nodeIds = new Set(filteredNodes.map(n => n.id)); | ||
| const filteredLinks = graphData.links.filter(l => { | ||
| const sourceId = typeof l.source === 'object' ? l.source.id : l.source; | ||
| const targetId = typeof l.target === 'object' ? l.target.id : l.target; | ||
| return nodeIds.has(sourceId) && nodeIds.has(targetId); | ||
| }); | ||
| // Render based on layout | ||
| switch(currentLayout) { | ||
| case 'tree': renderTreeLayout(svg, filteredNodes, filteredLinks); break; | ||
| case 'force': renderForceLayout(svg, filteredNodes, filteredLinks); break; | ||
| case 'radial': renderRadialLayout(svg, filteredNodes, filteredLinks); break; | ||
| case 'conflict': renderConflictLayout(svg, filteredNodes, filteredLinks); break; | ||
| } | ||
| loading.classList.remove('visible'); | ||
| updateStats(); | ||
| }, 100); | ||
| } | ||
| // Layout implementations (unchanged - normal layouts) | ||
| function renderTreeLayout(svg, nodes, links) { | ||
| const width = window.innerWidth - 320; | ||
| const height = window.innerHeight - 80; | ||
| svg.attr('width', width).attr('height', height); | ||
| const g = svg.append('g'); | ||
| const root = buildHierarchy(nodes, links); | ||
| if (!root) return; | ||
| const tree = d3.tree() | ||
| .size([width - 200, height - 200]) | ||
| .separation((a, b) => (a.parent === b.parent ? 1.5 : 2)); | ||
| tree(root); | ||
| // Links | ||
| g.selectAll('.link') | ||
| .data(root.links()) | ||
| .enter().append('path') | ||
| .attr('class', 'link') | ||
| .attr('d', d3.linkVertical() | ||
| .x(d => d.x + 100) | ||
| .y(d => d.y + 100)); | ||
| // Nodes | ||
| const node = g.selectAll('.node') | ||
| .data(root.descendants()) | ||
| .enter().append('g') | ||
| .attr('class', 'node') | ||
| .attr('transform', d => `translate(${d.x + 100},${d.y + 100})`); | ||
| node.append('circle') | ||
| .attr('class', 'node-circle') | ||
| .attr('r', 6) | ||
| .attr('fill', d => getNodeColor(d.data)) | ||
| .attr('stroke', d => getNodeStroke(d.data)) | ||
| .on('mouseover', showTooltip) | ||
| .on('mouseout', hideTooltip); | ||
| node.append('text') | ||
| .attr('class', 'node-label') | ||
| .attr('dy', d => d.children ? -12 : 15) | ||
| .attr('text-anchor', 'middle') | ||
| .text(d => { | ||
| const name = d.data.name; | ||
| return name.length > 15 ? name.substring(0, 15) + '...' : name; | ||
| }) | ||
| .style('font-size', '10px') | ||
| .style('pointer-events', 'none'); | ||
| addZoom(svg, g); | ||
| setTimeout(() => centerGraph(), 100); | ||
| } | ||
| function renderForceLayout(svg, nodes, links) { | ||
| const width = window.innerWidth - 320; | ||
| const height = window.innerHeight - 80; | ||
| svg.attr('width', width).attr('height', height); | ||
| const g = svg.append('g'); | ||
| const simulation = d3.forceSimulation(nodes) | ||
| .force('link', d3.forceLink(links).id(d => d.id).distance(100)) | ||
| .force('charge', d3.forceManyBody().strength(-300)) | ||
| .force('center', d3.forceCenter(width / 2, height / 2)) | ||
| .force('collision', d3.forceCollide().radius(15)); | ||
| const link = g.selectAll('.link') | ||
| .data(links) | ||
| .enter().append('line') | ||
| .attr('class', 'link'); | ||
| const node = g.selectAll('.node') | ||
| .data(nodes) | ||
| .enter().append('g') | ||
| .attr('class', 'node') | ||
| .call(d3.drag() | ||
| .on('start', dragStart) | ||
| .on('drag', dragging) | ||
| .on('end', dragEnd)); | ||
| node.append('circle') | ||
| .attr('class', 'node-circle') | ||
| .attr('r', 8) | ||
| .attr('fill', getNodeColor) | ||
| .attr('stroke', getNodeStroke) | ||
| .on('mouseover', showTooltip) | ||
| .on('mouseout', hideTooltip); | ||
| node.append('text') | ||
| .attr('class', 'node-label') | ||
| .attr('dy', -12) | ||
| .text(d => { | ||
| const name = d.name; | ||
| return name.length > 15 ? name.substring(0, 15) + '...' : name; | ||
| }); | ||
| simulation.on('tick', () => { | ||
| link | ||
| .attr('x1', d => d.source.x) | ||
| .attr('y1', d => d.source.y) | ||
| .attr('x2', d => d.target.x) | ||
| .attr('y2', d => d.target.y); | ||
| node.attr('transform', d => `translate(${d.x},${d.y})`); | ||
| }); | ||
| addZoom(svg, g); | ||
| function dragStart(event) { | ||
| if (!event.active) simulation.alphaTarget(0.3).restart(); | ||
| event.subject.fx = event.subject.x; | ||
| event.subject.fy = event.subject.y; | ||
| } | ||
| function dragging(event) { | ||
| event.subject.fx = event.x; | ||
| event.subject.fy = event.y; | ||
| } | ||
| function dragEnd(event) { | ||
| if (!event.active) simulation.alphaTarget(0); | ||
| event.subject.fx = null; | ||
| event.subject.fy = null; | ||
| } | ||
| } | ||
| function renderRadialLayout(svg, nodes, links) { | ||
| const width = window.innerWidth - 320; | ||
| const height = window.innerHeight - 80; | ||
| const radius = Math.min(width, height) / 2 - 100; | ||
| svg.attr('width', width).attr('height', height); | ||
| const g = svg.append('g').attr('transform', `translate(${width/2},${height/2})`); | ||
| const root = buildHierarchy(nodes, links); | ||
| if (!root) return; | ||
| const tree = d3.cluster().size([2 * Math.PI, radius]); | ||
| tree(root); | ||
| // Links | ||
| g.selectAll('.link') | ||
| .data(root.links()) | ||
| .enter().append('path') | ||
| .attr('class', 'link') | ||
| .attr('d', d3.linkRadial() | ||
| .angle(d => d.x) | ||
| .radius(d => d.y)); | ||
| // Nodes | ||
| const node = g.selectAll('.node') | ||
| .data(root.descendants()) | ||
| .enter().append('g') | ||
| .attr('class', 'node') | ||
| .attr('transform', d => `rotate(${d.x * 180 / Math.PI - 90}) translate(${d.y},0)`); | ||
| node.append('circle') | ||
| .attr('class', 'node-circle') | ||
| .attr('r', 6) | ||
| .attr('fill', d => getNodeColor(d.data)) | ||
| .attr('stroke', d => getNodeStroke(d.data)) | ||
| .on('mouseover', showTooltip) | ||
| .on('mouseout', hideTooltip); | ||
| node.append('text') | ||
| .attr('class', 'node-label') | ||
| .attr('dy', -10) | ||
| .attr('transform', d => d.x >= Math.PI ? 'rotate(180)' : null) | ||
| .text(d => { | ||
| const name = d.data.name; | ||
| return name.length > 12 ? name.substring(0, 12) + '...' : name; | ||
| }); | ||
| addZoom(svg, g); | ||
| } | ||
| function renderConflictLayout(svg, nodes, links) { | ||
| const conflictNodes = nodes.filter(n => | ||
| n.type === 'root' || n.isVulnerable || n.isDeprecated || n.isOutdated | ||
| ); | ||
| const nodeIds = new Set(conflictNodes.map(n => n.id)); | ||
| const conflictLinks = links.filter(l => | ||
| nodeIds.has(typeof l.source === 'object' ? l.source.id : l.source) && | ||
| nodeIds.has(typeof l.target === 'object' ? l.target.id : l.target) | ||
| ); | ||
| renderForceLayout(svg, conflictNodes, conflictLinks); | ||
| } | ||
| // Utilities | ||
| function buildHierarchy(nodes, links) { | ||
| const root = nodes.find(n => n.type === 'root'); | ||
| if (!root) return null; | ||
| try { | ||
| return d3.stratify() | ||
| .id(d => d.id) | ||
| .parentId(d => { | ||
| const link = links.find(l => { | ||
| const targetId = typeof l.target === 'object' ? l.target.id : l.target; | ||
| return targetId === d.id; | ||
| }); | ||
| return link ? (typeof link.source === 'object' ? link.source.id : link.source) : null; | ||
| })(nodes); | ||
| } catch (e) { | ||
| console.error('Hierarchy build failed:', e); | ||
| return null; | ||
| } | ||
| } | ||
| function getNodeColor(node) { | ||
| if (node.type === 'root') return '#3b82f6'; | ||
| if (node.isVulnerable) return '#ef4444'; | ||
| if (node.isDeprecated) return '#8b5cf6'; | ||
| if (node.isOutdated) return '#f59e0b'; | ||
| if (node.isUnused) return '#6b7280'; | ||
| return '#10b981'; | ||
| } | ||
| function getNodeStroke(node) { | ||
| if (node.type === 'root') return '#2563eb'; | ||
| return '#1a1f35'; | ||
| } | ||
| function showTooltip(event, d) { | ||
| const data = d.data || d; | ||
| const tooltip = document.getElementById('tooltip'); | ||
| let content = `<div class="tooltip-title">${data.name}@${data.version || 'unknown'}</div>`; | ||
| content += `<div class="tooltip-content">`; | ||
| if (data.isVulnerable) content += `<span class="tooltip-badge">Vulnerable</span>`; | ||
| if (data.isDeprecated) content += `<span class="tooltip-badge deprecated">Deprecated</span>`; | ||
| if (data.isOutdated) content += `<span class="tooltip-badge warning">Outdated</span>`; | ||
| if (data.isUnused) content += `<span class="tooltip-badge info">Unused</span>`; | ||
| content += `<br>Depth: ${data.depth || 0}`; | ||
| if (data.healthScore) content += `<br>Health: ${data.healthScore}/10`; | ||
| content += `</div>`; | ||
| tooltip.innerHTML = content; | ||
| tooltip.style.left = (event.pageX + 10) + 'px'; | ||
| tooltip.style.top = (event.pageY + 10) + 'px'; | ||
| tooltip.classList.add('visible'); | ||
| } | ||
| function hideTooltip() { | ||
| document.getElementById('tooltip').classList.remove('visible'); | ||
| } | ||
| function addZoom(svg, g) { | ||
| const zoom = d3.zoom() | ||
| .scaleExtent([0.1, 4]) | ||
| .on('zoom', (event) => g.attr('transform', event.transform)); | ||
| svg.call(zoom); | ||
| currentZoom = zoom; | ||
| currentSvg = svg; | ||
| currentG = g; | ||
| } | ||
| // Control functions | ||
| function zoomIn() { | ||
| if (currentSvg && currentZoom) { | ||
| currentSvg.transition().duration(300).call(currentZoom.scaleBy, 1.3); | ||
| } | ||
| } | ||
| function zoomOut() { | ||
| if (currentSvg && currentZoom) { | ||
| currentSvg.transition().duration(300).call(currentZoom.scaleBy, 0.7); | ||
| } | ||
| } | ||
| function resetZoom() { | ||
| if (currentSvg && currentZoom) { | ||
| currentSvg.transition().duration(500).call(currentZoom.transform, d3.zoomIdentity); | ||
| } | ||
| } | ||
| function centerGraph() { | ||
| if (!currentSvg || !currentZoom || !currentG) return; | ||
| try { | ||
| const bbox = currentG.node().getBBox(); | ||
| const containerWidth = currentSvg.node().clientWidth; | ||
| const containerHeight = currentSvg.node().clientHeight; | ||
| const padding = 50; | ||
| const scaleX = (containerWidth - padding * 2) / bbox.width; | ||
| const scaleY = (containerHeight - padding * 2) / bbox.height; | ||
| const scale = Math.min(scaleX, scaleY, 1); | ||
| const tx = (containerWidth - bbox.width * scale) / 2 - bbox.x * scale; | ||
| const ty = (containerHeight - bbox.height * scale) / 2 - bbox.y * scale; | ||
| currentSvg.transition().duration(750).call( | ||
| currentZoom.transform, | ||
| d3.zoomIdentity.translate(tx, ty).scale(scale) | ||
| ); | ||
| } catch (error) { | ||
| const width = currentSvg.node().clientWidth; | ||
| const height = currentSvg.node().clientHeight; | ||
| currentSvg.transition().duration(500).call( | ||
| currentZoom.transform, | ||
| d3.zoomIdentity.translate(width / 2, height / 2).scale(1) | ||
| ); | ||
| } | ||
| } | ||
| function fitToScreen() { | ||
| centerGraph(); | ||
| } | ||
| function exportPNG() { | ||
| try { | ||
| const svgElement = document.getElementById('graph'); | ||
| const svgData = new XMLSerializer().serializeToString(svgElement); | ||
| const canvas = document.createElement('canvas'); | ||
| const ctx = canvas.getContext('2d'); | ||
| const img = new Image(); | ||
| canvas.width = svgElement.clientWidth; | ||
| canvas.height = svgElement.clientHeight; | ||
| img.onload = function() { | ||
| ctx.fillStyle = '#0a0e1a'; | ||
| ctx.fillRect(0, 0, canvas.width, canvas.height); | ||
| ctx.drawImage(img, 0, 0); | ||
| canvas.toBlob(function(blob) { | ||
| const url = URL.createObjectURL(blob); | ||
| const a = document.createElement('a'); | ||
| a.href = url; | ||
| a.download = 'devcompass-graph-' + currentLayout + '-' + currentFilter + '.png'; | ||
| a.click(); | ||
| URL.revokeObjectURL(url); | ||
| }); | ||
| }; | ||
| img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData))); | ||
| } catch (error) { | ||
| alert('PNG export requires the graph to be fully loaded. Please try again.'); | ||
| } | ||
| } | ||
| function exportJSON() { | ||
| const filtered = filterNodes(graphData.nodes); | ||
| const nodeIds = new Set(filtered.map(n => n.id)); | ||
| const filteredLinks = graphData.links.filter(l => { | ||
| const sourceId = typeof l.source === 'object' ? l.source.id : l.source; | ||
| const targetId = typeof l.target === 'object' ? l.target.id : l.target; | ||
| return nodeIds.has(sourceId) && nodeIds.has(targetId); | ||
| }); | ||
| const exportData = { | ||
| version: '3.1.6', | ||
| layout: currentLayout, | ||
| filter: currentFilter, | ||
| depth: currentDepth, | ||
| clusterMode: currentClusterMode, | ||
| nodes: filtered, | ||
| links: filteredLinks, | ||
| clusters: clusters, | ||
| metadata: graphData.metadata | ||
| }; | ||
| const dataStr = JSON.stringify(exportData, null, 2); | ||
| const dataBlob = new Blob([dataStr], { type: 'application/json' }); | ||
| const url = URL.createObjectURL(dataBlob); | ||
| const a = document.createElement('a'); | ||
| a.href = url; | ||
| a.download = 'devcompass-graph-' + currentLayout + '-' + currentClusterMode + '.json'; | ||
| a.click(); | ||
| URL.revokeObjectURL(url); | ||
| } | ||
| function toggleFullscreen() { | ||
| if (!document.fullscreenElement) { | ||
| document.documentElement.requestFullscreen().catch(err => { | ||
| alert('Fullscreen not supported on this browser'); | ||
| }); | ||
| } else { | ||
| document.exitFullscreen(); | ||
| } | ||
| } | ||
| function updateStats() { | ||
| const filtered = filterNodes(graphData.nodes); | ||
| const stats = { | ||
| total: filtered.length, | ||
| clusters: clusters.length, | ||
| vulnerable: filtered.filter(n => n.isVulnerable).length, | ||
| deprecated: filtered.filter(n => n.isDeprecated).length, | ||
| outdated: filtered.filter(n => n.isOutdated).length, | ||
| unused: filtered.filter(n => n.isUnused).length, | ||
| healthy: filtered.filter(n => !n.isVulnerable && !n.isDeprecated && !n.isOutdated && !n.isUnused).length | ||
| }; | ||
| document.getElementById('stats').innerHTML = ` | ||
| <div class="stat-item"> | ||
| <span class="stat-label">Total</span> | ||
| <span class="stat-value">${stats.total}</span> | ||
| </div> | ||
| <div class="stat-item"> | ||
| <span class="stat-label">Clusters</span> | ||
| <span class="stat-value cluster">${stats.clusters}</span> | ||
| </div> | ||
| <div class="stat-item"> | ||
| <span class="stat-label">Vulnerable</span> | ||
| <span class="stat-value danger">${stats.vulnerable}</span> | ||
| </div> | ||
| <div class="stat-item"> | ||
| <span class="stat-label">Deprecated</span> | ||
| <span class="stat-value warning">${stats.deprecated}</span> | ||
| </div> | ||
| <div class="stat-item"> | ||
| <span class="stat-label">Outdated</span> | ||
| <span class="stat-value warning">${stats.outdated}</span> | ||
| </div> | ||
| <div class="stat-item"> | ||
| <span class="stat-label">Healthy</span> | ||
| <span class="stat-value success">${stats.healthy}</span> | ||
| </div> | ||
| `; | ||
| } | ||
| </script> | ||
| </body> | ||
| </html> |
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
5
-16.67%73
10.61%817
36.85%36
-25%455016
-9.26%12580
-4.05%- Removed
- Removed