PHP Classes

File: app/app.bundle.js

Recommend this page to a friend!
  Packages of DeGraciaMathieu   PHP Dep Dashboard   app/app.bundle.js   Download  
File: app/app.bundle.js
Role: Auxiliary data
Content type: text/plain
Description: Auxiliary data
Class: PHP Dep Dashboard
Visualize PHP class dependencies in a Web page
Author: By
Last change:
Date: 10 hours ago
Size: 57,385 bytes
 

Contents

Class file image Download
// ============================================================ // app.bundle.js ? Single-file build, no ES modules required. // Works with file:// protocol (no server needed). // ============================================================ // ?? constants.js ????????????????????????????????????????? const EVENTS = { DATA_LOADED: 'data:loaded', NODE_SELECTED: 'node:selected', EDGE_SELECTED: 'edge:selected', SELECTION_CLEARED: 'selection:cleared', FOCUS_NODE: 'focus:node', FOCUS_RESET: 'focus:reset', LAYOUT_RUN: 'layout:run', FILTERS_APPLIED: 'filters:applied', NAMESPACE_REBUILD: 'namespace:rebuild', GRAPH_READY: 'graph:ready', }; const DOM = { CY: 'cy', FILTERS: 'filters', DETAIL_PANEL: 'detail-panel', NS_BREADCRUMB: 'ns-breadcrumb', NS_PANEL: 'ns-panel', SEARCH_INPUT: 'search-input', SEARCH_RESULTS: 'search-results', WARNINGS_BADGE: 'warnings-badge', WARNINGS_LIST: 'warnings-list', META_STATS: 'meta-stats', LOADING: 'loading', LARGE_DATASET_BANNER: 'large-dataset-banner', GRAPH_STATUS: 'graph-status', BTN_NEW_ANALYSIS: 'btn-new-analysis', DROP_ZONE: 'drop-zone', LAYOUT_SELECT: 'layout-select', NS_SELECT_ALL: 'ns-select-all', NS_DESELECT_ALL: 'ns-deselect-all', NS_SEARCH: 'ns-search', FILTER_EXTERNAL: 'filter-external', BTN_RESET_FOCUS: 'btn-reset-focus', BTN_EXPORT_PNG: 'btn-export-png', }; const NODE_TYPE = { NAMESPACE: 'namespace', CLASS: 'class', }; // ?? state.js ????????????????????????????????????????????? const bus = new EventTarget(); function emit(name, detail) { bus.dispatchEvent(new CustomEvent(name, { detail })); } function on(name, handler) { bus.addEventListener(name, (e) => handler(e.detail)); } // Shared application state const state = { data: null, cy: null, cycles: [], selectedNode: null, focusDepth: 1, filtersActive: false, }; // ?? data-loader.js ??????????????????????????????????????? // Inline worker code ? runs JSON.parse + data processing off the main thread. // Uses String.fromCharCode(92) for backslash to avoid template-literal escaping issues. const WORKER_CODE = ` var SEP = String.fromCharCode(92); function getNamespaceKey(fqcn, isExternal) { var parts = fqcn.split(SEP); if (parts.length < 2) return parts[0]; return isExternal ? parts[0] : parts.slice(0, -1).join(SEP); } function detectCycles(edges, nodeCount) { if (nodeCount > 3000) return []; var adj = new Map(); for (var i = 0; i < edges.length; i++) { var e = edges[i]; if (!adj.has(e.source)) adj.set(e.source, []); adj.get(e.source).push(e.target); } var visited = new Set(); var inStack = new Set(); var cycles = []; var MAX_CYCLES = 50; function dfs(node, path) { if (cycles.length >= MAX_CYCLES) return; if (inStack.has(node)) { var cycleStart = path.indexOf(node); if (cycleStart !== -1) cycles.push(path.slice(cycleStart)); return; } if (visited.has(node)) return; visited.add(node); inStack.add(node); path.push(node); var neighbors = adj.get(node) || []; for (var i = 0; i < neighbors.length; i++) dfs(neighbors[i], path); path.pop(); inStack.delete(node); } var nodes = Array.from(adj.keys()); for (var i = 0; i < nodes.length; i++) { if (!visited.has(nodes[i])) dfs(nodes[i], []); } return cycles; } self.onmessage = function(e) { try { var raw = JSON.parse(e.data); var seen = new Set(raw.classes.map(function(c) { return c.fqcn; })); var classesArray = raw.classes.map(function(cls) { return Object.assign({}, cls, { external: false, namespace: getNamespaceKey(cls.fqcn, false), }); }); for (var i = 0; i < raw.edges.length; i++) { var edge = raw.edges[i]; var fqcns = [edge.source, edge.target]; for (var j = 0; j < fqcns.length; j++) { var fqcn = fqcns[j]; if (!seen.has(fqcn)) { seen.add(fqcn); classesArray.push({ fqcn: fqcn, type: 'class', file: null, line: null, dependencies: [], dependants: [], external: true, namespace: getNamespaceKey(fqcn, true), }); } } } var nodeCount = (raw.meta && raw.meta.node_count) || raw.classes.length; var cycles = detectCycles(raw.edges, nodeCount); self.postMessage({ ok: true, classesArray: classesArray, edges: raw.edges, meta: raw.meta, warnings: raw.warnings || [], cycles: cycles }); } catch(err) { self.postMessage({ ok: false, error: err.message }); } }; `; function parseAndProcessInWorker(text) { return new Promise((resolve, reject) => { const blob = new Blob([WORKER_CODE], { type: 'application/javascript' }); const url = URL.createObjectURL(blob); const worker = new Worker(url); worker.onmessage = (e) => { URL.revokeObjectURL(url); worker.terminate(); if (e.data.ok) { resolve(processWorkerResult(e.data)); } else { reject(new Error(e.data.error)); } }; worker.onerror = (err) => { URL.revokeObjectURL(url); worker.terminate(); reject(new Error(err.message || 'Worker error')); }; worker.postMessage(text); }); } function processWorkerResult({ classesArray, edges, meta, warnings, cycles }) { const classMap = new Map(classesArray.map((cls) => [cls.fqcn, cls])); // Compute instability per class: I = Ce / (Ca + Ce) // Ce = efferent coupling (fan-out): unique classes this one depends on // Ca = afferent coupling (fan-in): unique classes that depend on this one const fanOut = new Map(); const fanIn = new Map(); for (const e of edges) { if (!fanOut.has(e.source)) fanOut.set(e.source, new Set()); fanOut.get(e.source).add(e.target); if (!fanIn.has(e.target)) fanIn.set(e.target, new Set()); fanIn.get(e.target).add(e.source); } for (const cls of classMap.values()) { const ce = (fanOut.get(cls.fqcn) || new Set()).size; const ca = (fanIn.get(cls.fqcn) || new Set()).size; cls.instability = (ce + ca === 0) ? null : parseFloat((ce / (ce + ca)).toFixed(2)); cls.fanOut = ce; cls.fanIn = ca; } const processed = { meta, classes: classMap, edges, warnings, cycles }; state.data = processed; state.cycles = cycles; emit('data:loaded', processed); if (meta && meta.node_count > 200) { const banner = document.getElementById('large-dataset-banner'); banner.textContent = `Large dataset (${meta.node_count} nodes). Use namespace filters for better performance.`; banner.removeAttribute('hidden'); } return processed; } /** * Try fetch, fallback to file picker UI. */ async function loadData() { if (sessionStorage.getItem('forceFilePicker')) { sessionStorage.removeItem('forceFilePicker'); return waitForFilePicker(); } try { const res = await fetch('data.json'); if (!res.ok) throw new Error(res.statusText); const text = await res.text(); return await parseAndProcessInWorker(text); } catch { return waitForFilePicker(); } } function showDropZone() { const zone = document.getElementById('drop-zone'); zone.classList.add('visible'); } function hideDropZone() { const zone = document.getElementById('drop-zone'); zone.classList.remove('visible'); } function waitForFilePicker() { return new Promise((resolve) => { showDropZone(); const zone = document.getElementById('drop-zone'); const input = zone.querySelector('input[type="file"]'); function handleFile(file) { const reader = new FileReader(); reader.onload = async (e) => { try { const processed = await parseAndProcessInWorker(e.target.result); hideDropZone(); resolve(processed); } catch { zone.querySelector('.drop-zone__error').textContent = 'Invalid JSON file.'; } }; reader.readAsText(file); } zone.addEventListener('dragover', (e) => { e.preventDefault(); zone.classList.add('dragover'); }); zone.addEventListener('dragleave', () => zone.classList.remove('dragover')); zone.addEventListener('drop', (e) => { e.preventDefault(); zone.classList.remove('dragover'); if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]); }); zone.addEventListener('click', () => input.click()); input.addEventListener('change', () => { if (input.files.length) handleFile(input.files[0]); }); }); } /** * Get namespace key: first 2 segments for internal, first segment for external. */ function getNamespaceKey(fqcn, isExternal) { const parts = fqcn.split('\\'); if (parts.length < 2) return parts[0]; return isExternal ? parts[0] : parts.slice(0, -1).join('\\'); } // ?? namespace-browser.js ????????????????????????????????? let currentScope = []; // array of namespace segments, e.g. ['App', 'Services'] let viewMode = 'folders'; // 'folders' | 'classes' let nsData = null; function getViewMode() { return viewMode; } function setViewMode(mode) { viewMode = mode; renderBreadcrumb(); emit('selection:cleared'); emit('namespace:rebuild'); } /** * Build Cytoscape elements for the given scope level. * * At scope [] (root): one node per top-level namespace segment. * At scope ['App']: one node per child of App (sub-namespace folders or leaf classes). * Edges are aggregated between nodes at the current scope level. */ function buildNamespaceElementsAtScope(data, scope) { const SEP = '\\'; const nodes = []; const edges = []; // Degree for class node sizing const degree = new Map(); for (const e of data.edges) { degree.set(e.source, (degree.get(e.source) || 0) + 1); degree.set(e.target, (degree.get(e.target) || 0) + 1); } // Only internal classes const allClasses = [...data.classes.values()].filter((c) => !c.external); // Group classes by their child segment at this scope level // Map: segment ? { isFolder: bool, fqcns: Set<string> } const childInfo = new Map(); for (const cls of allClasses) { const parts = cls.fqcn.split(SEP); // Skip if not under current scope if (scope.length > 0) { if (parts.length <= scope.length) continue; let match = true; for (let i = 0; i < scope.length; i++) { if (parts[i] !== scope[i]) { match = false; break; } } if (!match) continue; } const segment = parts[scope.length]; if (segment === undefined) continue; if (!childInfo.has(segment)) { childInfo.set(segment, { isFolder: false, fqcns: new Set() }); } childInfo.get(segment).fqcns.add(cls.fqcn); // If there are more parts, this segment is a namespace folder if (parts.length > scope.length + 1) { childInfo.get(segment).isFolder = true; } } // Cycle detection: which FQCNs are in cycles? const cycleSet = new Set((state.cycles || []).flat()); // Build fqcn ? nodeId mapping for edge aggregation const fqcnToNodeId = new Map(); const prefix = scope.length > 0 ? scope.join(SEP) + SEP : ''; for (const [segment, info] of childInfo) { const nsPath = prefix + segment; if (info.isFolder) { const nodeId = 'ns::' + nsPath; for (const fqcn of info.fqcns) fqcnToNodeId.set(fqcn, nodeId); const hasCycle = [...info.fqcns].some((fqcn) => cycleSet.has(fqcn)); nodes.push({ data: { id: nodeId, label: segment + '\n' + info.fqcns.size + (info.fqcns.size === 1 ? ' class' : ' classes'), nodeType: 'namespace', nsPath, classCount: info.fqcns.size, hasCycle, }, }); } else { const fqcn = [...info.fqcns][0]; const cls = data.classes.get(fqcn); fqcnToNodeId.set(fqcn, fqcn); nodes.push({ data: { id: fqcn, label: fqcn.split(SEP).pop(), fullLabel: fqcn, type: cls.type, nodeType: 'class', external: false, namespace: cls.namespace, file: cls.file, line: cls.line, depCount: cls.dependencies ? cls.dependencies.length : 0, dependantCount: cls.dependants ? cls.dependants.length : 0, degree: degree.get(fqcn) || 0, instability: cls.instability, fanOut: cls.fanOut, fanIn: cls.fanIn, }, }); } } // Build aggregated edges const renderedNodeIds = new Set(nodes.map((n) => n.data.id)); const CONFIDENCE_RANK = { certain: 4, high: 3, medium: 2, low: 1 }; const edgeMap = new Map(); for (const e of data.edges) { const srcId = fqcnToNodeId.get(e.source); const tgtId = fqcnToNodeId.get(e.target); if (!srcId || !tgtId || srcId === tgtId) continue; if (!renderedNodeIds.has(srcId) || !renderedNodeIds.has(tgtId)) continue; const key = srcId + '\u2192' + tgtId; if (!edgeMap.has(key)) edgeMap.set(key, { source: srcId, target: tgtId, entries: [] }); edgeMap.get(key).entries.push(e); } // Compute namespace-level instability from aggregated edges const nsFanOut = new Map(); const nsFanIn = new Map(); for (const { source, target } of edgeMap.values()) { nsFanOut.set(source, (nsFanOut.get(source) || 0) + 1); nsFanIn.set(target, (nsFanIn.get(target) || 0) + 1); } for (const node of nodes) { if (node.data.nodeType !== 'namespace') continue; const id = node.data.id; const ce = nsFanOut.get(id) || 0; const ca = nsFanIn.get(id) || 0; const instability = (ce + ca === 0) ? null : parseFloat((ce / (ce + ca)).toFixed(2)); node.data.instability = instability; node.data.fanOut = ce; node.data.fanIn = ca; } let ei = 0; for (const group of edgeMap.values()) { const { source, target, entries } = group; const best = entries.reduce((a, b) => (CONFIDENCE_RANK[b.confidence] || 0) > (CONFIDENCE_RANK[a.confidence] || 0) ? b : a ); edges.push({ data: { id: 'e' + ei++, source, target, weight: entries.length, confidence: best.confidence, edgeType: entries.map((e) => e.type).join(', '), entries, }, }); } return [...nodes, ...edges]; } /** * Build Cytoscape elements showing all individual classes at the given scope, * grouped into namespace compound-node containers for visual highlighting. */ function buildClassElementsAtScope(data, scope) { const SEP = '\\'; const nodes = []; const edges = []; const degree = new Map(); for (const e of data.edges) { degree.set(e.source, (degree.get(e.source) || 0) + 1); degree.set(e.target, (degree.get(e.target) || 0) + 1); } const allClasses = [...data.classes.values()].filter((c) => !c.external); // Filter to classes under current scope const scopedClasses = []; for (const cls of allClasses) { if (scope.length > 0) { const parts = cls.fqcn.split(SEP); if (parts.length <= scope.length) continue; let match = true; for (let i = 0; i < scope.length; i++) { if (parts[i] !== scope[i]) { match = false; break; } } if (!match) continue; } scopedClasses.push(cls); } // Group by the first sub-namespace segment after the current scope. // Classes sitting directly at scope level get no container. const nsSet = new Set(); for (const cls of scopedClasses) { const parts = cls.fqcn.split(SEP); if (parts.length > scope.length + 1) { nsSet.add(parts.slice(0, scope.length + 1).join(SEP)); } } // Add namespace container nodes first (compound node parents must come before children) for (const nsPath of nsSet) { const parts = nsPath.split(SEP); const label = parts[scope.length]; // one-segment label, e.g. "Commands" nodes.push({ data: { id: 'ns-container::' + nsPath, label, nodeType: 'namespace-container', nsPath, }, }); } // Add class nodes as children of their namespace container for (const cls of scopedClasses) { const parts = cls.fqcn.split(SEP); const nsPath = parts.length > scope.length + 1 ? parts.slice(0, scope.length + 1).join(SEP) : null; const nodeData = { id: cls.fqcn, label: parts[parts.length - 1], fullLabel: cls.fqcn, type: cls.type, nodeType: 'class', external: false, namespace: cls.namespace, file: cls.file, line: cls.line, depCount: cls.dependencies ? cls.dependencies.length : 0, dependantCount: cls.dependants ? cls.dependants.length : 0, degree: degree.get(cls.fqcn) || 0, instability: cls.instability, fanOut: cls.fanOut, fanIn: cls.fanIn, }; if (nsPath) nodeData.parent = 'ns-container::' + nsPath; nodes.push({ data: nodeData }); } const renderedIds = new Set(scopedClasses.map((c) => c.fqcn)); const CONFIDENCE_RANK = { certain: 4, high: 3, medium: 2, low: 1 }; const edgeMap = new Map(); for (const e of data.edges) { if (!renderedIds.has(e.source) || !renderedIds.has(e.target)) continue; const key = e.source + '\u2192' + e.target; if (!edgeMap.has(key)) edgeMap.set(key, { source: e.source, target: e.target, entries: [] }); edgeMap.get(key).entries.push(e); } let ei = 0; for (const group of edgeMap.values()) { const { source, target, entries } = group; const best = entries.reduce((a, b) => (CONFIDENCE_RANK[b.confidence] || 0) > (CONFIDENCE_RANK[a.confidence] || 0) ? b : a ); edges.push({ data: { id: 'e' + ei++, source, target, weight: entries.length, confidence: best.confidence, edgeType: entries.map((e) => e.type).join(', '), entries, }, }); } return [...nodes, ...edges]; } function navigateToScope(nsPath) { currentScope = nsPath ? nsPath.split('\\') : []; renderBreadcrumb(); emit('selection:cleared'); emit('namespace:rebuild'); } function getCurrentScope() { return currentScope; } function renderBreadcrumb() { const el = document.getElementById('ns-breadcrumb'); if (!el) return; const parts = currentScope; const items = []; if (parts.length === 0) { items.push('<span class="breadcrumb-item breadcrumb-item--current">root</span>'); } else { items.push('<span class="breadcrumb-item" data-scope="">root</span>'); } for (let i = 0; i < parts.length; i++) { const sc = parts.slice(0, i + 1).join('\\'); items.push('<span class="breadcrumb-sep">?</span>'); if (i === parts.length - 1) { items.push( '<span class="breadcrumb-item breadcrumb-item--current">' + escHtml(parts[i]) + '</span>' ); } else { items.push( '<span class="breadcrumb-item" data-scope="' + escHtml(sc) + '">' + escHtml(parts[i]) + '</span>' ); } } const isClassMode = viewMode === 'classes'; const toggleLabel = isClassMode ? 'Namespaces' : 'Classes'; const toggleTitle = isClassMode ? 'Afficher par namespaces' : 'Afficher toutes les classes'; el.innerHTML = '<span class="breadcrumb-path">' + items.join('') + '</span>' + '<button class="view-mode-toggle' + (isClassMode ? ' view-mode-toggle--active' : '') + '" id="view-mode-toggle" title="' + toggleTitle + '">' + toggleLabel + '</button>'; el.querySelectorAll('.breadcrumb-item[data-scope]').forEach((item) => { item.addEventListener('click', () => navigateToScope(item.dataset.scope)); }); el.querySelector('#view-mode-toggle').addEventListener('click', () => { setViewMode(viewMode === 'folders' ? 'classes' : 'folders'); }); } function escHtml(str) { return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); } function initNamespaceBrowser(data) { nsData = data; currentScope = []; renderBreadcrumb(); } // ?? graph-renderer.js ???????????????????????????????????? let cy = null; // Above this threshold, only the top N most-connected internal nodes are added // to Cytoscape to prevent the initial render from freezing. const MAX_RENDERED_NODES = 2000; const NODE_COLORS = { class: '#3B82F6', interface: '#8B5CF6', trait: '#F59E0B', enum: '#10B981', }; const NODE_SHAPES = { class: 'ellipse', interface: 'diamond', trait: 'hexagon', enum: 'rectangle', }; const EDGE_STYLES = { certain: { width: 2, style: 'solid' }, high: { width: 1, style: 'solid' }, medium: { width: 1, style: 'dashed' }, low: { width: 1, style: 'dotted' }, }; function initGraph(data) { initNamespaceBrowser(data); const elements = buildNamespaceElementsAtScope(data, []); cy = cytoscape({ container: document.getElementById('cy'), elements, style: buildStylesheet(), layout: { name: 'preset' }, minZoom: 0.1, maxZoom: 5, wheelSensitivity: 0.3, }); state.cy = cy; cy.ready(() => runLayout('fcose')); // Single tap on namespace ? detail panel; double tap ? drill down let nsTapTimer = null; cy.on('tap', 'node[nodeType="namespace"]', (evt) => { const node = evt.target; if (nsTapTimer) return; // a second tap is coming (dbltap) nsTapTimer = setTimeout(() => { nsTapTimer = null; cy.elements().removeClass('ns-selected'); node.addClass('ns-selected'); emit('namespace:selected', node.data()); }, 220); }); cy.on('dbltap', 'node[nodeType="namespace"]', (evt) => { clearTimeout(nsTapTimer); nsTapTimer = null; cy.elements().removeClass('ns-selected'); navigateToScope(evt.target.data('nsPath')); }); cy.on('tap', 'node[nodeType != "namespace"]', (evt) => { cy.elements().removeClass('ns-selected'); emit('node:selected', evt.target.id()); }); cy.on('tap', 'edge', (evt) => { const edge = evt.target; emit('edge:selected', { source: edge.data('source'), target: edge.data('target'), weight: edge.data('weight'), confidence: edge.data('confidence'), entries: edge.data('entries'), }); }); cy.on('tap', (evt) => { if (evt.target === cy) { cy.elements().removeClass('ns-selected'); resetFocus(); emit('selection:cleared'); } }); on('namespace:navigate', (nsPath) => navigateToScope(nsPath)); cy.on('mouseover', 'node', (evt) => { const node = evt.target; const d = node.data(); if (d.nodeType === 'namespace') { const neighborhood = node.closedNeighborhood(); cy.startBatch(); cy.elements().addClass('hover-dimmed'); cy.nodes('[nodeType="namespace-container"]').removeClass('hover-dimmed'); neighborhood.removeClass('hover-dimmed'); node.addClass('hover-highlighted'); cy.endBatch(); } node.connectedEdges().forEach((edge) => { if (edge.source().id() === node.id()) { edge.addClass('edge-out'); } else { edge.addClass('edge-in'); } }); }); cy.on('mouseout', 'node', () => { cy.startBatch(); cy.elements().removeClass('hover-dimmed hover-highlighted'); cy.edges().removeClass('edge-out edge-in'); cy.endBatch(); }); on('focus:node', ({ nodeId, depth }) => focusNode(nodeId, depth)); on('focus:reset', () => resetFocus()); on('layout:run', (name) => runLayout(name)); on('namespace:rebuild', () => { if (!cy || !state.data) return; const scope = getCurrentScope(); const els = getViewMode() === 'classes' ? buildClassElementsAtScope(state.data, scope) : buildNamespaceElementsAtScope(state.data, scope); cy.startBatch(); cy.elements().remove(); cy.add(els); cy.endBatch(); state.selectedNode = null; if (state.cycles && state.cycles.length > 0) markCycleNodes(state.cycles); runLayout('fcose'); }); const btnExport = document.getElementById('btn-export-png'); if (btnExport) { btnExport.removeAttribute('hidden'); btnExport.addEventListener('click', exportPng); } emit('graph:ready', cy); } function exportPng() { if (!cy) return; const dataUrl = cy.png({ output: 'blob', bg: '#0F172A', full: true, scale: 2 }); const url = URL.createObjectURL(dataUrl); const a = document.createElement('a'); a.href = url; a.download = 'php-dep-graph.png'; a.click(); URL.revokeObjectURL(url); } function buildElements(data) { const nodes = []; const edges = []; // Compute degree (in + out) for each node from the raw edge list const degree = new Map(); for (const e of data.edges) { degree.set(e.source, (degree.get(e.source) || 0) + 1); degree.set(e.target, (degree.get(e.target) || 0) + 1); } // Determine which internal nodes to render (cap for large datasets) const internalNodes = [...data.classes.values()].filter((c) => !c.external); let allowedFQCNs = null; // null = all allowed if (internalNodes.length > MAX_RENDERED_NODES) { const sorted = [...internalNodes].sort( (a, b) => (degree.get(b.fqcn) || 0) - (degree.get(a.fqcn) || 0) ); allowedFQCNs = new Set(sorted.slice(0, MAX_RENDERED_NODES).map((c) => c.fqcn)); const banner = document.getElementById('large-dataset-banner'); banner.textContent = `Large dataset: displaying top ${MAX_RENDERED_NODES} of ${internalNodes.length} most-connected nodes. Filter by namespace to explore specific subgraphs.`; banner.removeAttribute('hidden'); } for (const [fqcn, cls] of data.classes) { // Skip external nodes (hidden by default anyway) and capped internal nodes if (cls.external) continue; if (allowedFQCNs && !allowedFQCNs.has(fqcn)) continue; const shortName = fqcn.split('\\').pop(); nodes.push({ data: { id: fqcn, label: shortName, fullLabel: fqcn, type: cls.type, external: cls.external, namespace: cls.namespace, file: cls.file, line: cls.line, depCount: cls.dependencies ? cls.dependencies.length : 0, dependantCount: cls.dependants ? cls.dependants.length : 0, }, }); } const renderedIds = new Set(nodes.map((n) => n.data.id)); // Consolidate parallel edges const CONFIDENCE_RANK = { certain: 4, high: 3, medium: 2, low: 1 }; const edgeMap = new Map(); for (const e of data.edges) { // Only include edges where both endpoints are rendered if (!renderedIds.has(e.source) || !renderedIds.has(e.target)) continue; const key = `${e.source}\u2192${e.target}`; if (!edgeMap.has(key)) { edgeMap.set(key, { source: e.source, target: e.target, entries: [] }); } edgeMap.get(key).entries.push(e); } let i = 0; for (const group of edgeMap.values()) { const { source, target, entries } = group; const best = entries.reduce((a, b) => (CONFIDENCE_RANK[b.confidence] || 0) > (CONFIDENCE_RANK[a.confidence] || 0) ? b : a ); edges.push({ data: { id: `e${i++}`, source, target, weight: entries.length, edgeType: entries.map((e) => e.type).join(', '), confidence: best.confidence, file: best.file, line: best.line, entries, }, }); } return [...nodes, ...edges]; } function buildStylesheet() { return [ { selector: 'node', style: { label: 'data(label)', 'text-valign': 'bottom', 'text-margin-y': 5, 'font-size': 11, 'font-family': 'Inter, system-ui, sans-serif', color: '#E2E8F0', 'text-outline-color': '#0F172A', 'text-outline-width': 2, width: 'mapData(degree, 0, 10, 20, 60)', height: 'mapData(degree, 0, 10, 20, 60)', 'border-width': 2, 'border-color': '#1E293B', 'transition-property': 'opacity, background-color, border-color', 'transition-duration': '200ms', }, }, ...Object.entries(NODE_COLORS).map(([type, color]) => ({ selector: `node[type="${type}"]`, style: { 'background-color': color, shape: NODE_SHAPES[type], }, })), { selector: 'node[?external]', style: { 'background-color': '#9CA3AF', 'border-style': 'dashed', 'border-color': '#6B7280', }, }, { selector: 'node.in-cycle', style: { 'border-color': '#EF4444', 'border-width': 3, }, }, { selector: 'node.dimmed', style: { opacity: 0.15 }, }, { selector: 'edge.dimmed', style: { opacity: 0.15 }, }, { selector: 'node.highlighted', style: { 'border-color': '#FACC15', 'border-width': 4, }, }, { selector: 'edge', style: { width: 'mapData(weight, 1, 20, 1.5, 8)', 'line-color': '#94A3B8', 'target-arrow-color': '#94A3B8', 'target-arrow-shape': 'triangle', 'arrow-scale': 0.8, 'curve-style': 'bezier', 'transition-property': 'opacity, line-color, target-arrow-color', 'transition-duration': '200ms', }, }, ...Object.entries(EDGE_STYLES).map(([confidence, s]) => ({ selector: `edge[confidence="${confidence}"]`, style: { 'line-style': s.style, }, })), { selector: 'edge.edge-out', style: { 'line-color': '#EF4444', 'target-arrow-color': '#EF4444', width: 'mapData(weight, 1, 20, 2, 9)', }, }, { selector: 'edge.edge-in', style: { 'line-color': '#22C55E', 'target-arrow-color': '#22C55E', width: 'mapData(weight, 1, 20, 2, 9)', }, }, { selector: 'node.search-match', style: { 'border-color': '#F97316', 'border-width': 4, }, }, { selector: 'node.hover-dimmed', style: { opacity: 0.12 }, }, { selector: 'edge.hover-dimmed', style: { opacity: 0.08 }, }, { selector: 'node.hover-highlighted', style: { 'border-color': '#60A5FA', 'border-width': 4, }, }, { selector: 'node[nodeType="namespace-container"]', style: { 'background-color': '#1E3A5F', 'background-opacity': 0.35, 'border-color': '#3B82F6', 'border-width': 1, 'border-opacity': 0.6, shape: 'roundrectangle', label: 'data(label)', 'text-valign': 'top', 'text-halign': 'center', 'font-size': 11, 'font-weight': 700, color: '#93C5FD', 'text-outline-width': 0, padding: 20, }, }, { selector: 'node[nodeType="namespace"]', style: { 'background-color': '#0F4C81', 'background-opacity': 0.95, shape: 'roundrectangle', width: 'mapData(classCount, 1, 80, 70, 160)', height: 'mapData(classCount, 1, 80, 70, 160)', label: 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'font-size': 13, 'font-weight': 600, 'text-wrap': 'wrap', 'text-max-width': '140px', 'text-outline-width': 0, color: '#E2E8F0', 'border-color': '#3B82F6', 'border-width': 2, cursor: 'pointer', 'transition-property': 'border-color, border-width, background-color', 'transition-duration': '150ms', }, }, { selector: 'node[nodeType="namespace"]:active', style: { 'background-color': '#1E4D8C', 'border-color': '#60A5FA', 'border-width': 3, }, }, { selector: 'node.ns-selected', style: { 'border-color': '#FACC15', 'border-width': 3, 'background-color': '#1E4D8C', }, }, ]; } function runLayout(name) { if (!cy) return; const visibleEles = cy.elements(':visible'); if (visibleEles.length === 0) return; const visibleNodeCount = visibleEles.nodes().length; // For very large visible graphs, fall back to grid (instant, non-blocking) if (visibleNodeCount > 1500) { visibleEles.layout({ name: 'grid', animate: false, fit: true, padding: 40 }).run(); return; } // For medium graphs, use fcose/cose but without animation and fewer iterations const large = visibleNodeCount > 500; const options = name === 'fcose' ? { name: 'fcose', animate: !large, animationDuration: large ? 0 : 500, fit: true, padding: 40, quality: large ? 'draft' : 'default', nodeDimensionsIncludeLabels: true, idealEdgeLength: 120, nodeRepulsion: 8000, edgeElasticity: 0.45, gravity: 0.25, gravityRange: 3.8, numIter: large ? 500 : 2500, tile: true, packComponents: true, } : { name: 'cose', animate: !large, animationDuration: large ? 0 : 500, fit: true, padding: 40, nodeDimensionsIncludeLabels: true, idealEdgeLength: 120, nodeRepulsion: 8000, }; try { visibleEles.layout(options).run(); } catch (e) { console.warn('Layout "' + name + '" failed, falling back to grid:', e.message); visibleEles.layout({ name: 'grid', animate: false, fit: true, padding: 40 }).run(); } } function focusNode(nodeId, depth = 1) { if (!cy) return; const node = cy.getElementById(nodeId); if (!node || node.empty()) return; const neighborhood = node.closedNeighborhood(); let focus = neighborhood; if (depth === 2) { focus = neighborhood.closedNeighborhood(); } cy.startBatch(); cy.elements().addClass('dimmed'); cy.nodes('[nodeType="namespace-container"]').removeClass('dimmed'); focus.removeClass('dimmed'); node.addClass('highlighted'); cy.endBatch(); state.selectedNode = nodeId; state.focusDepth = depth; } function resetFocus() { if (!cy) return; cy.startBatch(); cy.elements().removeClass('dimmed highlighted'); cy.endBatch(); state.selectedNode = null; } function markCycleNodes(cycles) { if (!cy) return; const nodesInCycles = new Set(); for (const cycle of cycles) { for (const fqcn of cycle) { nodesInCycles.add(fqcn); } } cy.startBatch(); for (const fqcn of nodesInCycles) { cy.getElementById(fqcn).addClass('in-cycle'); } cy.endBatch(); } // ?? filter-manager.js ???????????????????????????????????? let filtersEl; let namespaces = []; function initFilters(data) { filtersEl = document.getElementById('filters'); namespaces = collectNamespaces(data); render(data); bindEvents(); on('data:loaded', (d) => { namespaces = collectNamespaces(d); render(d); }); } function collectNamespaces(data) { const nsSet = new Set(); for (const [, cls] of data.classes) { nsSet.add(cls.namespace); } return [...nsSet].sort(); } function render(data) { const types = ['class', 'interface', 'trait', 'enum']; const confidences = ['certain', 'high', 'medium', 'low']; filtersEl.innerHTML = ` <div class="filter-section"> <h3>Show external</h3> <label class="toggle-label"> <input type="checkbox" id="filter-external"> Show vendor / external classes </label> </div> <div class="filter-section"> <h3>Type</h3> ${types .map( (t) => ` <label class="filter-check"> <input type="checkbox" data-filter="type" value="${t}" checked> <span class="chip chip--${t}">${t}</span> </label>` ) .join('')} </div> <div class="filter-section"> <h3>Confidence</h3> ${confidences .map( (c) => ` <label class="filter-check"> <input type="checkbox" data-filter="confidence" value="${c}" checked> ${c} </label>` ) .join('')} </div> <div class="filter-section"> <h3>Namespace</h3> <div class="namespace-actions"> <button class="btn btn--sm" id="ns-select-all">All</button> <button class="btn btn--sm" id="ns-deselect-all">None</button> </div> <input type="text" id="ns-search" class="ns-search-input" placeholder="Filter namespaces?" autocomplete="off"> <div class="namespace-filters"> ${namespaces .map( (ns) => ` <label class="filter-check"> <input type="checkbox" data-filter="namespace" value="${ns}" checked> ${ns} </label>` ) .join('')} </div> </div> <div class="filter-section"> <h3>Layout</h3> <select id="layout-select"> <option value="fcose" selected>Force-directed (fcose)</option> <option value="cose">CoSE (fallback)</option> </select> </div> `; } function bindEvents() { filtersEl.addEventListener('change', (e) => { const target = e.target; if (target.id === 'filter-external') { toggleExternal(target.checked); return; } if (target.id === 'layout-select') { emit('layout:run', target.value); return; } if (target.dataset.filter) { applyFilters(); } }); filtersEl.addEventListener('click', (e) => { const target = e.target; if (target.id === 'ns-select-all' || target.id === 'ns-deselect-all') { const checked = target.id === 'ns-select-all'; filtersEl .querySelectorAll('input[data-filter="namespace"]') .forEach((cb) => { cb.checked = checked; }); applyFilters(); } }); filtersEl.addEventListener('input', (e) => { if (e.target.id === 'ns-search') { const query = e.target.value.trim().toLowerCase(); filtersEl.querySelectorAll('.namespace-filters .filter-check').forEach((label) => { const ns = label.querySelector('input').value.toLowerCase(); label.style.display = ns.includes(query) ? '' : 'none'; }); } }); } function toggleExternal(show) { const cy = state.cy; if (!cy) return; cy.startBatch(); const externals = cy.nodes('[?external]'); if (show) { externals.show(); } else { externals.hide(); } cy.endBatch(); emit('layout:run', document.getElementById('layout-select').value); } function applyFilters() { const cy = state.cy; if (!cy) return; const activeTypes = getCheckedValues('type'); const activeConfidences = getCheckedValues('confidence'); const activeNamespaces = getCheckedValues('namespace'); const showExternal = document.getElementById('filter-external').checked; cy.startBatch(); // Filter nodes cy.nodes().forEach((node) => { const d = node.data(); // Namespace folder/container nodes are always visible ? they're navigational if (d.nodeType === 'namespace' || d.nodeType === 'namespace-container') { node.show(); return; } const typeMatch = activeTypes.has(d.type); const nsMatch = activeNamespaces.has(d.namespace); const externalMatch = d.external ? showExternal : true; if (typeMatch && nsMatch && externalMatch) { node.show(); } else { node.hide(); } }); // Filter edges by confidence and visibility of endpoints cy.edges().forEach((edge) => { const confMatch = activeConfidences.has(edge.data('confidence')); const srcVisible = edge.source().visible(); const tgtVisible = edge.target().visible(); if (confMatch && srcVisible && tgtVisible) { edge.show(); } else { edge.hide(); } }); cy.endBatch(); state.filtersActive = true; emit('filters:applied'); } function getCheckedValues(filterName) { const checks = filtersEl.querySelectorAll( `input[data-filter="${filterName}"]:checked` ); return new Set([...checks].map((c) => c.value)); } // ?? detail-panel.js ?????????????????????????????????????? let panelEl; function initDetailPanel() { panelEl = document.getElementById('detail-panel'); renderEmpty(); on('node:selected', (nodeId) => renderNode(nodeId)); on('edge:selected', (edgeData) => renderEdge(edgeData)); on('namespace:selected', (nodeData) => renderNamespace(nodeData)); on('selection:cleared', () => renderEmpty()); } function renderEmpty() { panelEl.innerHTML = ` <div class="detail-empty"> <p>Click a node or edge to see details.</p> </div> `; } function renderNode(nodeId) { const data = state.data; if (!data) return; const cls = data.classes.get(nodeId); if (!cls) return; // Compute in/out edges, deduplicated by target/source const outEdges = deduplicateEdges( data.edges.filter((e) => e.source === nodeId), 'target' ); const inEdges = deduplicateEdges( data.edges.filter((e) => e.target === nodeId), 'source' ); // Check if in cycle const inCycle = state.cycles.some((c) => c.includes(nodeId)); // Focus the node on the graph emit('focus:node', { nodeId, depth: state.focusDepth }); panelEl.innerHTML = ` <div class="detail-header"> <span class="chip chip--${cls.type}">${cls.type}</span> ${cls.external ? '<span class="chip chip--external">external</span>' : ''} ${inCycle ? '<span class="chip chip--cycle">circular dep</span>' : ''} </div> <h3 class="detail-title">${cls.fqcn}</h3> ${cls.file ? `<p class="detail-file">${cls.file}:${cls.line}</p>` : ''} <div class="detail-metrics"> <div class="metric"> <span class="metric-value">${outEdges.length}</span> <span class="metric-label">Dependencies (fan-out)</span> </div> <div class="metric"> <span class="metric-value">${inEdges.length}</span> <span class="metric-label">Dependants (fan-in)</span> </div> </div> ${cls.instability !== null && cls.instability !== undefined ? renderInstability(cls.instability) : ''} <div class="detail-section"> <h4>Focus depth</h4> <div class="depth-controls"> <button class="btn btn--sm ${state.focusDepth === 1 ? 'btn--active' : ''}" data-depth="1">1</button> <button class="btn btn--sm ${state.focusDepth === 2 ? 'btn--active' : ''}" data-depth="2">2</button> <button class="btn btn--sm" id="btn-reset-focus">Reset view</button> </div> </div> ${outEdges.length > 0 ? ` <div class="detail-section"> <h4>Dependencies</h4> <ul class="detail-list"> ${outEdges.map((e) => ` <li class="detail-list-item" data-fqcn="${e.target}"> <span class="edge-type">${e.type}</span> <span class="edge-target">${shortName(e.target)}</span> <span class="edge-confidence conf--${e.confidence}">${e.confidence}</span> </li> `).join('')} </ul> </div>` : ''} ${inEdges.length > 0 ? ` <div class="detail-section"> <h4>Dependants</h4> <ul class="detail-list"> ${inEdges.map((e) => ` <li class="detail-list-item" data-fqcn="${e.source}"> <span class="edge-type">${e.type}</span> <span class="edge-target">${shortName(e.source)}</span> <span class="edge-confidence conf--${e.confidence}">${e.confidence}</span> </li> `).join('')} </ul> </div>` : ''} `; // Bind depth buttons panelEl.querySelectorAll('[data-depth]').forEach((btn) => { btn.addEventListener('click', () => { const depth = parseInt(btn.dataset.depth, 10); state.focusDepth = depth; emit('focus:node', { nodeId, depth }); renderNode(nodeId); }); }); // Reset focus button const resetBtn = panelEl.querySelector('#btn-reset-focus'); if (resetBtn) { resetBtn.addEventListener('click', () => { emit('focus:reset'); state.focusDepth = 1; renderNode(nodeId); }); } // Click on dep/dependant to navigate panelEl.querySelectorAll('.detail-list-item').forEach((li) => { li.addEventListener('click', () => { const fqcn = li.dataset.fqcn; emit('node:selected', fqcn); }); }); } function renderNamespace(nodeData) { const cy = state.cy; const node = cy ? cy.getElementById(nodeData.id) : null; // Collect outgoing / incoming namespace edges from the live graph const outEdges = []; const inEdges = []; if (node && !node.empty()) { node.connectedEdges().forEach((edge) => { const d = edge.data(); if (edge.source().id() === nodeData.id) outEdges.push(d); else inEdges.push(d); }); } const instabilityHtml = nodeData.instability !== null && nodeData.instability !== undefined ? renderInstability(nodeData.instability) : ''; const nsPath = nodeData.nsPath || ''; const parentPath = nsPath.includes('\\') ? nsPath.split('\\').slice(0, -1).join('\\') : ''; const makeEdgeList = (edges, dirKey, dirLabel) => { if (!edges.length) return ''; return ` <div class="detail-section"> <h4>${dirLabel}</h4> <ul class="detail-list"> ${edges.map((e) => ` <li class="detail-list-item detail-list-item--static"> <span class="edge-badge">${e.weight || 1}</span> <span class="edge-target">${shortNs(e[dirKey])}</span> <span class="edge-confidence conf--${e.confidence}">${e.confidence}</span> </li> `).join('')} </ul> </div>`; }; panelEl.innerHTML = ` <div class="detail-header"> <span class="chip chip--namespace">namespace</span> ${nodeData.hasCycle ? '<span class="chip chip--cycle">circular dep</span>' : ''} </div> <h3 class="detail-title">${shortNs(nsPath)}</h3> ${parentPath ? `<p class="detail-file">${parentPath}</p>` : ''} <div class="detail-metrics detail-metrics--3"> <div class="metric"> <span class="metric-value">${nodeData.classCount || 0}</span> <span class="metric-label">Classes</span> </div> <div class="metric"> <span class="metric-value">${nodeData.fanOut || 0}</span> <span class="metric-label">Fan-out (Ce)</span> </div> <div class="metric"> <span class="metric-value">${nodeData.fanIn || 0}</span> <span class="metric-label">Fan-in (Ca)</span> </div> </div> ${instabilityHtml} <div class="detail-section"> <button class="btn btn--primary btn--full" id="btn-drilldown"> Drill down into namespace ? </button> </div> ${makeEdgeList(outEdges, 'target', 'Depends on')} ${makeEdgeList(inEdges, 'source', 'Depended by')} `; panelEl.querySelector('#btn-drilldown').addEventListener('click', () => { emit('namespace:navigate', nsPath); }); } function shortNs(nsPath) { if (!nsPath) return ''; const parts = nsPath.replace(/^ns::/, '').split('\\'); return parts[parts.length - 1]; } function renderEdge(edgeData) { const isAggregated = edgeData.weight > 1; const entriesList = isAggregated && edgeData.entries ? ` <div class="detail-section"> <h4>${edgeData.weight} dependencies</h4> <ul class="detail-list"> ${edgeData.entries.map((e) => ` <li class="detail-list-item detail-list-item--static"> <span class="edge-type">${e.type}</span> <span class="edge-target">${shortName(e.source)} ? ${shortName(e.target)}</span> <span class="edge-confidence conf--${e.confidence}">${e.confidence}</span> </li> `).join('')} </ul> </div>` : ''; panelEl.innerHTML = ` <div class="detail-header"> <span class="chip">edge</span> <span class="edge-confidence conf--${edgeData.confidence}">${edgeData.confidence}</span> ${isAggregated ? `<span class="chip chip--weight">${edgeData.weight} deps</span>` : ''} </div> <div class="detail-section"> <p><strong>From:</strong> ${edgeData.source}</p> <p><strong>To:</strong> ${edgeData.target}</p> </div> ${entriesList} `; } function renderInstability(value) { const pct = Math.round(value * 100); const color = value < 0.33 ? '#22C55E' : value < 0.67 ? '#F59E0B' : '#EF4444'; const label = value < 0.33 ? 'Stable' : value < 0.67 ? 'Balanced' : 'Unstable'; return ` <div class="detail-instability"> <div class="instability-header"> <span class="instability-label">Instability (I)</span> <span class="instability-value" style="color:${color}">${value.toFixed(2)} ? ${label}</span> </div> <div class="instability-bar-bg"> <div class="instability-bar-fill" style="width:${pct}%;background:${color}"></div> </div> <p class="instability-hint">I = Ce / (Ca + Ce) &nbsp;·&nbsp; 0 = stable, 1 = instable</p> </div> `; } function shortName(fqcn) { return fqcn.split('\\').pop(); } // Merge edges that share the same key (target or source), combining their types function deduplicateEdges(edges, key) { const map = new Map(); for (const e of edges) { const k = e[key]; if (map.has(k)) { const existing = map.get(k); if (!existing.type.includes(e.type)) { existing.type += `, ${e.type}`; } } else { map.set(k, { ...e }); } } return Array.from(map.values()); } // ?? warnings-panel.js ???????????????????????????????????? let badgeEl; let listEl; function initWarnings(data) { badgeEl = document.getElementById('warnings-badge'); listEl = document.getElementById('warnings-list'); const total = data.warnings.length + data.cycles.length; updateBadge(total); renderList(data); badgeEl.addEventListener('click', () => { listEl.classList.toggle('visible'); }); // Close when clicking outside document.addEventListener('click', (e) => { if (!listEl.contains(e.target) && !badgeEl.contains(e.target)) { listEl.classList.remove('visible'); } }); } function updateBadge(count) { if (count > 0) { badgeEl.textContent = `Warnings (${count})`; badgeEl.classList.add('has-warnings'); } else { badgeEl.textContent = 'No warnings'; } } function renderList(data) { const items = []; for (const w of data.warnings) { items.push(` <li class="warning-item"> <span class="warning-type">${w.type}</span> <span class="warning-message">${w.message}</span> <span class="warning-location">${w.file}:${w.line}</span> </li> `); } for (const cycle of data.cycles) { items.push(` <li class="warning-item warning-item--cycle"> <span class="warning-type">circular dependency</span> <span class="warning-message">${cycle.map(shortName).join(' -> ')} -> ${shortName(cycle[0])}</span> </li> `); } listEl.innerHTML = items.length ? `<ul class="warnings-ul">${items.join('')}</ul>` : '<p class="warnings-empty">No warnings detected.</p>'; } function shortName(fqcn) { return fqcn.split('\\').pop(); } // ?? search.js ???????????????????????????????????????????? let inputEl; let resultsEl; function initSearch() { inputEl = document.getElementById('search-input'); resultsEl = document.getElementById('search-results'); inputEl.addEventListener('input', onInput); inputEl.addEventListener('keydown', (e) => { if (e.key === 'Escape') { clearSearch(); } }); // Close results on outside click document.addEventListener('click', (e) => { if (!resultsEl.contains(e.target) && e.target !== inputEl) { resultsEl.classList.remove('visible'); } }); } function onInput() { const query = inputEl.value.trim().toLowerCase(); if (query.length < 2) { clearSearch(); return; } const data = state.data; if (!data) return; const matches = []; for (const [fqcn, cls] of data.classes) { if (fqcn.toLowerCase().includes(query)) { matches.push({ fqcn, cls, score: scoreMatch(fqcn.toLowerCase(), query) }); } } // Sort by relevance (shorter = more specific match) matches.sort((a, b) => b.score - a.score); // Highlight on graph const cy = state.cy; if (cy) { cy.nodes().removeClass('search-match'); cy.startBatch(); for (const m of matches.slice(0, 20)) { cy.getElementById(m.fqcn).addClass('search-match'); } cy.endBatch(); } renderResults(matches.slice(0, 15)); } function scoreMatch(fqcn, query) { // Exact class name match scores highest const shortName = fqcn.split('\\').pop(); if (shortName === query) return 100; if (shortName.startsWith(query)) return 80; if (shortName.includes(query)) return 60; if (fqcn.startsWith(query)) return 40; return 20; } function renderResults(matches) { if (matches.length === 0) { resultsEl.innerHTML = '<p class="search-empty">No results</p>'; resultsEl.classList.add('visible'); return; } resultsEl.innerHTML = `<ul class="search-list">${matches .map( (m) => ` <li class="search-item" data-fqcn="${m.fqcn}"> <span class="chip chip--${m.cls.type} chip--xs">${m.cls.type}</span> <span>${highlightMatch(m.fqcn, inputEl.value.trim())}</span> </li>` ) .join('')}</ul>`; resultsEl.classList.add('visible'); resultsEl.querySelectorAll('.search-item').forEach((li) => { li.addEventListener('click', () => { const fqcn = li.dataset.fqcn; clearSearch(); const cy = state.cy; if (!cy) return; const node = cy.getElementById(fqcn); if (node && !node.empty()) { // Node is already in the current view if (!node.visible()) node.show(); cy.animate({ center: { eles: node }, duration: 300 }); emit('node:selected', fqcn); } else { // Node is inside a collapsed namespace ? navigate to its scope const parts = fqcn.split('\\'); const scopePath = parts.slice(0, -1).join('\\'); navigateToScope(scopePath); // Wait for rebuild + layout, then center and select setTimeout(() => { const n = cy.getElementById(fqcn); if (n && !n.empty()) { cy.animate({ center: { eles: n }, duration: 400 }); emit('node:selected', fqcn); } }, 600); } }); }); } function highlightMatch(fqcn, query) { const idx = fqcn.toLowerCase().indexOf(query.toLowerCase()); if (idx === -1) return escapeHtml(fqcn); const before = fqcn.slice(0, idx); const match = fqcn.slice(idx, idx + query.length); const after = fqcn.slice(idx + query.length); return `${escapeHtml(before)}<mark>${escapeHtml(match)}</mark>${escapeHtml(after)}`; } function escapeHtml(str) { return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); } function clearSearch() { inputEl.value = ''; resultsEl.classList.remove('visible'); resultsEl.innerHTML = ''; const cy = state.cy; if (cy) { cy.nodes().removeClass('search-match'); } } // ?? app.js ??????????????????????????????????????????????? async function main() { // Show loading state const loading = document.getElementById('loading'); loading.classList.add('visible'); try { const data = await loadData(); loading.classList.remove('visible'); // Initialize all modules initGraph(data); initFilters(data); initDetailPanel(); initWarnings(data); initSearch(); // Mark cycle nodes after graph is ready if (data.cycles.length > 0) { markCycleNodes(data.cycles); } // Warn if dataset is large if (data.meta && data.meta.node_count > 200) { showLargeDatasetWarning(data.meta.node_count); } // Update meta stats renderMeta(data.meta); // Show new analysis button const btnNew = document.getElementById('btn-new-analysis'); btnNew.removeAttribute('hidden'); btnNew.addEventListener('click', () => { sessionStorage.setItem('forceFilePicker', '1'); location.reload(); }); } catch (err) { loading.classList.remove('visible'); console.error('Failed to initialize app:', err); } } function renderMeta(meta) { const el = document.getElementById('meta-stats'); if (!el) return; el.innerHTML = ` <span>${meta.file_count} files</span> <span>${meta.class_count} classes</span> <span>${meta.node_count} nodes</span> <span>${meta.edge_count} edges</span> `; } function showLargeDatasetWarning(nodeCount) { const banner = document.getElementById('large-dataset-banner'); if (!banner) return; banner.textContent = `Large dataset (${nodeCount} nodes). Use namespace filters for better performance.`; banner.removeAttribute('hidden'); } main();