aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
1,438 lines • 60.1 kB
JavaScript
'use strict';
// Design tokens aligned with dashboard globals.css (Design Tokens v2.0)
// Source of truth: pro-design-migration/apps/dashboard/src/app/globals.css
const THEME = {
bg: {
base: '#000000', // --bg-base
surface: '#0A0A0A', // --bg-surface
overlay: 'rgba(10,10,10,0.9)', // --bg-surface + opacity
},
text: {
primary: '#E8E8DF', // --text-primary
secondary: '#B8B8AC', // --text-secondary
tertiary: '#8A8A7F', // --text-tertiary
muted: '#6B6B63', // --text-muted
},
status: {
success: '#4ADE80', // --status-success
warning: '#FBBF24', // --status-warning
error: '#F87171', // --status-error
info: '#60A5FA', // --status-info
},
border: {
default: 'rgba(255,255,255,0.06)', // --border
subtle: 'rgba(255,255,255,0.04)', // --border-subtle (card-refined)
highlight: 'rgba(201,178,152,0.25)', // --border-gold
gold: 'rgba(201,178,152,0.25)', // --border-gold (alias for highlight)
goldStrong: 'rgba(201,178,152,0.5)', // --border-gold-strong (selection)
},
accent: {
gold: '#C9B298', // --accent-gold
},
agent: {
dev: '#22c55e', // --agent-dev
sm: '#f472b6', // --agent-sm
po: '#f97316', // --agent-po
qa: '#eab308', // --agent-qa
architect: '#8b5cf6', // --agent-architect
devops: '#ec4899', // --agent-devops
analyst: '#06b6d4', // --agent-analyst
},
tooltip: {
bg: '#0A0A0A', // = bg.surface (card-refined)
border: 'rgba(255,255,255,0.04)', // = border.subtle
shadow: '0 4px 12px rgba(0,0,0,0.5)', // --tooltip-shadow
},
radius: {
md: '4px', // --radius-md
},
controls: {
sliderThumb: '#C9B298', // = accent.gold
sliderTrack: 'rgba(255,255,255,0.1)', // slider track background
},
};
const CATEGORY_COLORS = {
agents: { color: THEME.agent.dev, shape: 'dot' },
tasks: { color: THEME.status.info, shape: 'box' },
templates: { color: THEME.status.warning, shape: 'diamond' },
checklists: { color: THEME.agent.po, shape: 'triangle' },
workflows: { color: THEME.agent.sm, shape: 'star' },
'scripts/task': { color: THEME.status.success, shape: 'box' },
'scripts/engine': { color: THEME.agent.devops, shape: 'box' },
'scripts/infra': { color: THEME.agent.analyst, shape: 'box' },
utils: { color: THEME.agent.analyst, shape: 'ellipse' },
data: { color: THEME.agent.qa, shape: 'database' },
tools: { color: THEME.agent.architect, shape: 'hexagon' },
};
const DEFAULT_COLOR = { color: THEME.text.tertiary, shape: 'box' };
const LIFECYCLE_STYLES = {
production: { opacity: 1.0, borderDashes: false, colorOverride: null },
experimental: { opacity: 0.8, borderDashes: [5, 5], colorOverride: null },
deprecated: { opacity: 0.5, borderDashes: false, colorOverride: THEME.text.tertiary },
orphan: { opacity: 0.3, borderDashes: [2, 4], colorOverride: THEME.text.muted },
};
/**
* Sanitize a string for safe embedding inside HTML/JS.
* Prevents XSS by escaping HTML entities and JS-breaking chars.
* @param {string} str - Raw string
* @returns {string} Sanitized string
*/
function _sanitize(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
/**
* Build vis-network nodes array with category-based and lifecycle styling.
* @param {Array} nodes - Raw graph nodes
* @returns {Array} Styled nodes for vis-network
*/
function _buildVisNodes(nodes) {
const seen = new Set();
return (nodes || []).reduce((acc, node) => {
if (seen.has(node.id)) return acc;
seen.add(node.id);
const category = (node.group || node.category || '').toLowerCase();
const style = CATEGORY_COLORS[category]
|| (category === 'scripts' ? CATEGORY_COLORS['scripts/task'] : null)
|| DEFAULT_COLOR;
const lifecycle = node.lifecycle || 'production';
const lcStyle = LIFECYCLE_STYLES[lifecycle] || LIFECYCLE_STYLES.production;
const nodeColor = lcStyle.colorOverride || style.color;
acc.push({
id: node.id,
label: _sanitize(node.label || node.id),
group: category,
lifecycle: lifecycle,
path: node.path || '',
color: {
background: nodeColor,
border: THEME.border.subtle,
highlight: { background: nodeColor, border: THEME.border.goldStrong },
hover: { background: nodeColor, border: THEME.border.gold },
},
opacity: lcStyle.opacity,
shapeProperties: { borderDashes: lcStyle.borderDashes },
shape: style.shape,
});
return acc;
}, []);
}
/**
* Build vis-network edges array.
* @param {Array} edges - Raw graph edges
* @returns {Array} Edges for vis-network
*/
function _buildVisEdges(edges) {
return (edges || []).map((edge) => ({
from: edge.from,
to: edge.to,
arrows: 'to',
}));
}
/**
* Build the sidebar HTML for filters.
* @returns {string} Sidebar HTML
*/
function _buildSidebar(nodes) {
// Compute node counts per category
const categoryCounts = {};
(nodes || []).forEach((n) => {
const cat = (n.group || n.category || '').toLowerCase();
categoryCounts[cat] = (categoryCounts[cat] || 0) + 1;
});
const categoryItems = Object.entries(CATEGORY_COLORS).map(([name, style]) => {
const count = categoryCounts[name] || 0;
return `<label class="filter-item">
<input type="checkbox" data-filter="category" value="${name}" checked>
<span class="status-dot" style="color:${style.color}"></span>
<span style="color:${THEME.text.secondary};font-size:11px">${name}</span>
<span style="color:${THEME.text.tertiary};font-size:11px;margin-left:auto">${count}</span>
</label>`;
}).join('\n');
const lifecycleItems = Object.entries(LIFECYCLE_STYLES).map(([name, style]) => {
const opacity = style.opacity;
return `<label class="filter-item">
<input type="checkbox" data-filter="lifecycle" value="${name}" checked>
<span style="opacity:${opacity}">●</span> ${name}
</label>`;
}).join('\n');
return `<div id="sidebar">
<div class="sidebar-header">
<span class="sidebar-title">Filters</span>
<button id="btn-toggle-sidebar" title="Toggle sidebar">☰</button>
</div>
<div id="sidebar-content">
<div class="filter-section">
<input type="text" id="search-input" placeholder="Search entities..." autocomplete="off">
</div>
<div class="filter-section">
<div class="section-title">ENTITY TYPES</div>
<div class="gold-line"></div>
${categoryItems}
</div>
<div class="filter-section">
<div class="section-title">Lifecycle</div>
${lifecycleItems}
<label class="filter-item hide-orphans">
<input type="checkbox" id="hide-orphans"> Hide Orphans
</label>
</div>
<div class="filter-section physics-section">
<div class="section-title physics-toggle" style="cursor:pointer">PHYSICS</div>
<div class="gold-line"></div>
<div class="physics-content" style="display:none">
<div class="slider-row">
<label class="slider-label">Center Force <span id="val-center" style="color:${THEME.text.tertiary}">0.3</span></label>
<input type="range" id="slider-center" min="0" max="1" step="0.05" value="0.3" aria-label="Center Force">
</div>
<div class="slider-row">
<label class="slider-label">Repel Force <span id="val-repel" style="color:${THEME.text.tertiary}">-2000</span></label>
<input type="range" id="slider-repel" min="-30000" max="0" step="500" value="-2000" aria-label="Repel Force">
</div>
<div class="slider-row">
<label class="slider-label">Link Force <span id="val-link" style="color:${THEME.text.tertiary}">0.04</span></label>
<input type="range" id="slider-link" min="0" max="1" step="0.01" value="0.04" aria-label="Link Force">
</div>
<div class="slider-row">
<label class="slider-label">Link Distance <span id="val-distance" style="color:${THEME.text.tertiary}">95</span></label>
<input type="range" id="slider-distance" min="10" max="500" step="5" value="95" aria-label="Link Distance">
</div>
<div class="physics-buttons">
<button id="btn-physics-reset" class="action-btn">Reset</button>
<button id="btn-physics-pause" class="action-btn">Pause</button>
</div>
</div>
</div>
<div id="depth-selector" class="filter-section" style="display:none">
<div class="section-title">DEPTH</div>
<div class="gold-line"></div>
<div class="depth-buttons">
<button class="depth-btn active" data-depth="1">1</button>
<button class="depth-btn" data-depth="2">2</button>
<button class="depth-btn" data-depth="3">3</button>
<button class="depth-btn" data-depth="all">All</button>
</div>
<div id="depth-node-count" style="color:${THEME.text.tertiary};font-size:11px;margin-top:6px"></div>
</div>
<div class="filter-section">
<div class="section-title">NODE SIZE</div>
<div class="gold-line"></div>
<div class="size-buttons">
<button class="size-btn active" data-sizing="uniform">Uniform</button>
<button class="size-btn" data-sizing="degree">By Degree</button>
<button class="size-btn" data-sizing="in-degree">By In-Degree</button>
<button class="size-btn" data-sizing="out-degree">By Out-Degree</button>
</div>
</div>
<div class="filter-section">
<div class="section-title">LAYOUT</div>
<div class="gold-line"></div>
<div class="layout-buttons">
<button class="layout-btn active" data-layout="force">Force</button>
<button class="layout-btn" data-layout="hierarchical">Hierarchical</button>
<button class="layout-btn" data-layout="circular">Circular</button>
</div>
</div>
<div class="filter-section">
<div class="section-title">EXPORT</div>
<div class="gold-line"></div>
<div class="export-buttons">
<button class="export-btn" id="btn-export-png" aria-label="Export graph as PNG image">PNG</button>
<button class="export-btn" id="btn-export-json" aria-label="Export graph data as JSON file">JSON</button>
</div>
</div>
<div class="filter-section">
<div class="section-title">CLUSTERING</div>
<div class="gold-line"></div>
<button id="btn-cluster-category" class="action-btn">Cluster by Category</button>
</div>
<div class="filter-section">
<div class="section-title">STATISTICS</div>
<div class="gold-line"></div>
<div id="stats-panel">
<div class="stat-row"><span class="stat-label" style="color:${THEME.text.secondary}">Total Nodes</span> <span class="stat-value" id="stat-nodes" style="color:${THEME.text.primary}">0</span></div>
<div class="stat-row"><span class="stat-label" style="color:${THEME.text.secondary}">Total Edges</span> <span class="stat-value" id="stat-edges" style="color:${THEME.text.primary}">0</span></div>
<div class="stat-row"><span class="stat-label" style="color:${THEME.text.secondary}">Graph Density</span> <span class="stat-value" id="stat-density" style="color:${THEME.text.primary}">0</span></div>
<div class="stat-row"><span class="stat-label" style="color:${THEME.text.secondary}">Avg Degree</span> <span class="stat-value" id="stat-avg-degree" style="color:${THEME.text.primary}">0</span></div>
<div class="gold-line"></div>
<div class="stat-label" style="color:${THEME.text.secondary};margin-bottom:4px">Top 5 Connected</div>
<div id="stat-top5"></div>
</div>
</div>
<div class="filter-section actions">
<button id="btn-reset" class="action-btn">Reset / Show All</button>
<button id="btn-exit-focus" class="action-btn" style="display:none">Exit Focus Mode</button>
</div>
<div id="metrics" class="filter-section metrics"></div>
</div>
</div>`;
}
/**
* Build the legend HTML for the graph (kept for backward compat, now in sidebar).
* @returns {string} Legend HTML (empty — legend is now part of sidebar)
*/
function _buildLegend() {
return '';
}
/**
* Format graph data as a self-contained HTML page with vis-network,
* DataView filtering, lifecycle styling, focus mode, search, and metrics.
* @param {Object} graphData - Normalized graph data { nodes, edges, source, isFallback }
* @param {Object} [options] - Formatting options
* @param {boolean} [options.autoRefresh] - Add meta-refresh for watch mode
* @param {number} [options.refreshInterval] - Refresh interval in seconds (default: 5)
* @returns {string} Complete HTML string
*/
function formatAsHtml(graphData, options = {}) {
const visNodes = _buildVisNodes(graphData.nodes);
const visEdges = _buildVisEdges(graphData.edges);
const sidebar = _buildSidebar(graphData.nodes);
const nodesJson = JSON.stringify(visNodes);
const edgesJson = JSON.stringify(visEdges);
const metaRefresh = options.autoRefresh
? `<meta http-equiv="refresh" content="${options.refreshInterval || 5}">`
: '';
const nodeCount = visNodes.length;
const isLargeGraph = nodeCount > 200;
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>AIOS Graph Dashboard</title>
${metaRefresh}
<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
<style>
* { box-sizing: border-box; }
body { margin: 0; background: ${THEME.bg.base}; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', monospace; }
#graph { position: absolute; top: 0; left: 260px; right: 0; bottom: 0; }
#status { position: fixed; top: 10px; left: 270px; color: ${THEME.accent.gold}; font-family: monospace; font-size: 13px; z-index: 100; background: ${THEME.bg.overlay}; padding: 6px 12px; border-radius: ${THEME.radius.md}; }
#sidebar {
position: fixed; top: 0; left: 0; width: 260px; height: 100vh;
background: ${THEME.bg.surface}; border-right: 1px solid ${THEME.border.default};
overflow-y: auto; z-index: 200; color: ${THEME.text.secondary}; font-size: 12px;
}
#sidebar.collapsed { width: 40px; }
#sidebar.collapsed #sidebar-content { display: none; }
#sidebar.collapsed .sidebar-title { display: none; }
#sidebar.collapsed ~ #graph { left: 40px; }
#sidebar.collapsed ~ #status { left: 50px; }
.sidebar-header {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 12px; border-bottom: 1px solid ${THEME.border.default};
}
.sidebar-title { font-size: 14px; font-weight: 600; color: ${THEME.text.primary}; }
#btn-toggle-sidebar {
background: none; border: none; color: ${THEME.text.secondary}; font-size: 16px;
cursor: pointer; padding: 2px 6px;
}
#btn-toggle-sidebar:hover { color: ${THEME.text.primary}; }
.filter-section { padding: 8px 12px; border-bottom: 1px solid ${THEME.border.default}; }
.section-title { font-size: 10px; text-transform: uppercase; color: ${THEME.accent.gold}; margin-bottom: 6px; letter-spacing: 0.2em; }
.filter-item { display: flex; align-items: center; gap: 6px; padding: 2px 0; cursor: pointer; }
.filter-item input[type="checkbox"] { margin: 0; cursor: pointer; }
.filter-item.hide-orphans { margin-top: 6px; padding-top: 6px; border-top: 1px solid ${THEME.border.default}; }
#search-input {
width: 100%; padding: 6px 8px; background: ${THEME.bg.base}; border: 1px solid ${THEME.border.default};
color: ${THEME.text.secondary}; border-radius: ${THEME.radius.md}; font-size: 12px; outline: none;
}
#search-input:focus { border-color: ${THEME.accent.gold}; }
.action-btn {
width: 100%; padding: 6px; margin-top: 4px; background: ${THEME.border.default}; border: none;
color: ${THEME.text.secondary}; border-radius: ${THEME.radius.md}; cursor: pointer; font-size: 12px;
}
.action-btn:hover { background: ${THEME.accent.gold}; color: ${THEME.bg.base}; }
.metrics { color: ${THEME.text.tertiary}; font-size: 11px; line-height: 1.6; }
.metrics b { color: ${THEME.text.secondary}; }
.status-dot {
display: inline-block; width: 6px; height: 6px; border-radius: 50%;
background: currentColor; box-shadow: 0 0 8px currentColor; flex-shrink: 0;
}
.gold-line {
height: 1px; margin: 6px 0;
background: linear-gradient(90deg, ${THEME.accent.gold}, transparent);
}
.slider-row { margin-bottom: 8px; }
.slider-label { display: flex; justify-content: space-between; font-size: 11px; color: ${THEME.text.secondary}; margin-bottom: 2px; }
.physics-buttons { display: flex; gap: 6px; margin-top: 6px; }
.physics-buttons .action-btn { flex: 1; }
input[type="range"] {
-webkit-appearance: none; width: 100%; height: 4px; border-radius: 2px;
background: ${THEME.controls.sliderTrack}; outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%;
background: ${THEME.controls.sliderThumb}; cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
width: 12px; height: 12px; border-radius: 50%; border: none;
background: ${THEME.controls.sliderThumb}; cursor: pointer;
}
input[type="range"]::-moz-range-track {
height: 4px; border-radius: 2px; background: ${THEME.controls.sliderTrack};
}
.depth-buttons { display: flex; gap: 6px; }
.depth-btn {
flex: 1; padding: 4px 0; background: ${THEME.border.default}; border: 1px solid ${THEME.border.subtle};
color: ${THEME.text.secondary}; border-radius: ${THEME.radius.md}; cursor: pointer;
font-size: 12px; font-family: inherit; text-align: center;
}
.depth-btn:hover { border-color: ${THEME.border.gold}; }
.depth-btn.active { background: ${THEME.accent.gold}; color: ${THEME.bg.base}; border-color: ${THEME.accent.gold}; }
.size-buttons, .layout-buttons { display: flex; flex-wrap: wrap; gap: 6px; }
.size-btn, .layout-btn {
flex: 1; min-width: 45%; padding: 4px 0; background: ${THEME.border.default}; border: 1px solid ${THEME.border.subtle};
color: ${THEME.text.secondary}; border-radius: ${THEME.radius.md}; cursor: pointer;
font-size: 11px; font-family: inherit; text-align: center;
}
.size-btn:hover, .layout-btn:hover { border-color: ${THEME.border.gold}; }
.size-btn.active, .layout-btn.active { background: ${THEME.accent.gold}; color: ${THEME.bg.base}; border-color: ${THEME.accent.gold}; }
.export-buttons { display: flex; gap: 6px; }
.export-btn {
flex: 1; padding: 6px 0; background: ${THEME.bg.surface}; border: 1px solid ${THEME.border.subtle};
color: ${THEME.text.secondary}; border-radius: ${THEME.radius.md}; cursor: pointer;
font-size: 11px; font-family: inherit; text-align: center;
}
.export-btn:hover { border-color: ${THEME.border.gold}; color: ${THEME.text.primary}; }
.stat-row { display: flex; justify-content: space-between; align-items: center; padding: 2px 0; font-size: 11px; }
.stat-label { font-size: 11px; }
.stat-value { font-size: 11px; font-weight: 600; }
.top5-item { display: flex; justify-content: space-between; font-size: 11px; padding: 1px 0; }
.top5-name { color: ${THEME.text.secondary}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 160px; }
.top5-degree { color: ${THEME.text.primary}; font-weight: 600; margin-left: 8px; flex-shrink: 0; }
#minimap-container {
position: fixed; bottom: 16px; right: 16px; width: 200px; z-index: 400;
background: ${THEME.bg.surface}; border: 1px solid ${THEME.border.subtle};
border-radius: ${THEME.radius.md}; overflow: hidden;
}
#minimap-header {
display: flex; justify-content: space-between; align-items: center;
padding: 4px 8px; font-size: 10px; color: ${THEME.text.tertiary};
}
#minimap-toggle {
background: none; border: none; color: ${THEME.text.tertiary}; cursor: pointer;
font-size: 12px; padding: 0 2px;
}
#minimap-toggle:hover { color: ${THEME.text.primary}; }
#minimap-canvas { display: block; width: 200px; height: 150px; }
#minimap-container.collapsed #minimap-canvas { display: none; }
#minimap-container.collapsed { width: auto; }
#node-tooltip {
display: none; position: fixed; z-index: 500;
background: ${THEME.tooltip.bg}; border: 1px solid ${THEME.tooltip.border};
border-radius: ${THEME.radius.md}; padding: 12px;
box-shadow: ${THEME.tooltip.shadow}; max-width: 320px; pointer-events: auto;
}
#node-tooltip .tt-name { color: ${THEME.text.primary}; font-size: 13px; font-weight: 600; margin-bottom: 4px; }
#node-tooltip .tt-type { display: flex; align-items: center; gap: 6px; color: ${THEME.text.secondary}; font-size: 11px; margin-bottom: 4px; }
#node-tooltip .tt-path { color: ${THEME.text.tertiary}; font-size: 10px; font-family: monospace; margin-bottom: 4px; word-break: break-all; }
#node-tooltip .tt-deps { color: ${THEME.text.secondary}; font-size: 11px; }
</style>
</head>
<body>
<div id="status">Loading vis-network...</div>
${sidebar}
<div id="node-tooltip" role="tooltip"></div>
<div id="graph"></div>
<div id="minimap-container">
<div id="minimap-header">
<span>Map</span>
<button id="minimap-toggle" aria-label="Toggle minimap">✕</button>
</div>
<canvas id="minimap-canvas" width="200" height="150"></canvas>
</div>
<script>
(function() {
var statusEl = document.getElementById('status');
if (typeof vis === 'undefined') {
statusEl.textContent = 'ERROR: vis-network failed to load from CDN';
statusEl.style.color = '${THEME.status.error}';
return;
}
statusEl.textContent = 'Creating graph (${nodeCount} nodes)...';
// --- Data Setup ---
var allNodesData = ${nodesJson};
var allEdgesData = ${edgesJson};
var nodesDataset = new vis.DataSet(allNodesData);
var edgesDataset = new vis.DataSet(allEdgesData);
var totalEntities = allNodesData.length;
// --- Filter State ---
var activeCategories = new Set(${JSON.stringify(Object.keys(CATEGORY_COLORS))});
var activeLifecycles = new Set(['production', 'experimental', 'deprecated', 'orphan']);
var hideOrphans = false;
var searchTerm = '';
var focusNodeId = null;
var focusNeighbors = null;
// --- DataView Filtering ---
var nodesView = new vis.DataView(nodesDataset, {
filter: function(node) {
if (focusNodeId) {
return focusNeighbors && focusNeighbors.has(node.id);
}
if (!activeCategories.has(node.group)) return false;
if (!activeLifecycles.has(node.lifecycle)) return false;
if (hideOrphans && node.lifecycle === 'orphan') return false;
if (searchTerm) {
var term = searchTerm.toLowerCase();
return node.label.toLowerCase().indexOf(term) !== -1 || node.id.toLowerCase().indexOf(term) !== -1;
}
return true;
}
});
var visibleNodeIds = new Set(nodesView.getIds());
var edgesView = new vis.DataView(edgesDataset, {
filter: function(edge) {
return visibleNodeIds.has(edge.from) && visibleNodeIds.has(edge.to);
}
});
function refreshFilters() {
nodesView.refresh();
visibleNodeIds = new Set(nodesView.getIds());
edgesView.refresh();
updateMetrics();
if (typeof updateStatistics === 'function') updateStatistics();
if (typeof scheduleMinimapUpdate === 'function') scheduleMinimapUpdate();
}
// --- Network ---
var container = document.getElementById('graph');
var network = new vis.Network(container, { nodes: nodesView, edges: edgesView }, {
physics: {
stabilization: { iterations: ${isLargeGraph ? 200 : 100}, updateInterval: 25 },
barnesHut: {
gravitationalConstant: ${isLargeGraph ? -2000 : -3000},
springLength: ${isLargeGraph ? 200 : 150},
springConstant: 0.01,
damping: 0.3
}
},
nodes: {
font: { color: '${THEME.text.secondary}', size: ${isLargeGraph ? 10 : 12} },
borderWidth: 2,
scaling: { min: 5, max: 20 }
},
edges: {
color: { color: '${THEME.border.default}', highlight: '${THEME.border.highlight}' },
smooth: ${isLargeGraph ? 'false' : '{ type: "cubicBezier" }'}
},
interaction: {
hover: true,
tooltipDelay: 200,
hideEdgesOnDrag: true,
hideEdgesOnZoom: ${isLargeGraph ? 'true' : 'false'}
}
});
// --- Tooltip ---
var tooltipEl = document.getElementById('node-tooltip');
function showTooltip(nodeId, domPos) {
var nodeData = nodesDataset.get(nodeId);
if (!nodeData) return;
// Compute dependency count from edges
var depCount = 0;
allEdgesData.forEach(function(e) {
if (e.from === nodeId || e.to === nodeId) depCount++;
});
var catColor = nodeData.color && nodeData.color.background ? nodeData.color.background : '${THEME.text.tertiary}';
tooltipEl.innerHTML =
'<div class="tt-name">' + nodeData.label + '</div>' +
'<div class="tt-type"><span class="status-dot" style="color:' + catColor + '"></span> ' + (nodeData.group || 'unknown') + '</div>' +
(nodeData.path ? '<div class="tt-path">' + nodeData.path + '</div>' : '') +
'<div class="tt-deps">' + depCount + ' dependenc' + (depCount === 1 ? 'y' : 'ies') + '</div>';
// Position tooltip clamped to viewport
var x = domPos.x + 15;
var y = domPos.y + 15;
tooltipEl.style.display = 'block';
var rect = tooltipEl.getBoundingClientRect();
if (x + rect.width > window.innerWidth) x = domPos.x - rect.width - 15;
if (y + rect.height > window.innerHeight) y = domPos.y - rect.height - 15;
if (x < 0) x = 10;
if (y < 0) y = 10;
tooltipEl.style.left = x + 'px';
tooltipEl.style.top = y + 'px';
// Accessibility: link node to tooltip
container.setAttribute('aria-describedby', 'node-tooltip');
}
function hideTooltip() {
tooltipEl.style.display = 'none';
container.removeAttribute('aria-describedby');
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') hideTooltip();
});
function bindNetworkEvents() {
network.on('stabilizationProgress', function(params) {
var pct = Math.round(params.iterations / params.total * 100);
statusEl.textContent = 'Stabilizing... ' + pct + '%';
});
network.on('stabilizationIterationsDone', function() {
statusEl.textContent = 'Graph ready — ${nodeCount} nodes';
statusEl.style.color = '${THEME.status.success}';
network.fit({ animation: { duration: 500 } });
setTimeout(function() { statusEl.style.display = 'none'; }, 4000);
});
network.on('click', function(params) {
if (params.nodes.length === 1) {
var nodeId = params.nodes[0];
var canvasPos = network.getPosition(nodeId);
var domPos = network.canvasToDOM(canvasPos);
showTooltip(nodeId, domPos);
} else {
hideTooltip();
}
});
// --- Focus Mode ---
network.on('doubleClick', function(params) {
if (params.nodes.length === 1) {
enterFocusMode(params.nodes[0]);
}
});
// --- Minimap sync on network events ---
network.on('zoom', function() { if (typeof scheduleMinimapUpdate === 'function') scheduleMinimapUpdate(); });
network.on('dragEnd', function() { if (typeof scheduleMinimapUpdate === 'function') scheduleMinimapUpdate(); });
}
bindNetworkEvents();
// --- BFS Algorithm (GD-12) ---
function getNeighborsAtDepth(net, nodeId, maxDepth) {
var visited = new Set([nodeId]);
var levels = new Map();
levels.set(nodeId, 0);
var currentLevel = [nodeId];
for (var depth = 1; depth <= maxDepth; depth++) {
var nextLevel = [];
for (var i = 0; i < currentLevel.length; i++) {
var neighbors = net.getConnectedNodes(currentLevel[i]);
for (var j = 0; j < neighbors.length; j++) {
if (!visited.has(neighbors[j])) {
visited.add(neighbors[j]);
levels.set(neighbors[j], depth);
nextLevel.push(neighbors[j]);
}
}
}
currentLevel = nextLevel;
}
return { visited: visited, levels: levels };
}
var currentDepth = 1;
var depthLevels = null;
function applyDepthVisuals(levels) {
var updates = [];
var baseSizes = {};
allNodesData.forEach(function(n) {
baseSizes[n.id] = 25; // vis-network default node size
});
nodesDataset.forEach(function(node) {
var depth = levels ? levels.get(node.id) : undefined;
var update = { id: node.id };
if (depth === undefined) {
update.hidden = true;
} else if (depth === 0) {
update.hidden = false;
update.opacity = 1.0;
update.size = baseSizes[node.id] * 1.2;
update.color = {
background: node.color.background,
border: '${THEME.border.goldStrong}',
highlight: { background: node.color.background, border: '${THEME.border.goldStrong}' },
hover: { background: node.color.background, border: '${THEME.border.goldStrong}' }
};
} else if (depth === 1) {
update.hidden = false;
update.opacity = 1.0;
update.size = baseSizes[node.id];
update.color = {
background: node.color.background,
border: '${THEME.border.gold}',
highlight: { background: node.color.background, border: '${THEME.border.goldStrong}' },
hover: { background: node.color.background, border: '${THEME.border.gold}' }
};
} else if (depth === 2) {
update.hidden = false;
update.opacity = 0.7;
update.size = baseSizes[node.id] * 0.9;
update.color = {
background: node.color.background,
border: '${THEME.border.subtle}',
highlight: { background: node.color.background, border: '${THEME.border.goldStrong}' },
hover: { background: node.color.background, border: '${THEME.border.gold}' }
};
} else {
update.hidden = false;
update.opacity = 0.4;
update.size = baseSizes[node.id] * 0.8;
update.color = {
background: node.color.background,
border: '${THEME.border.subtle}',
highlight: { background: node.color.background, border: '${THEME.border.goldStrong}' },
hover: { background: node.color.background, border: '${THEME.border.gold}' }
};
}
updates.push(update);
});
nodesDataset.update(updates);
}
function updateDepthCount() {
var countEl = document.getElementById('depth-node-count');
if (!countEl) return;
var visible = 0;
nodesDataset.forEach(function(n) { if (!n.hidden) visible++; });
countEl.textContent = 'Nodes: ' + visible + ' / ' + totalEntities;
}
function setDepth(depth) {
currentDepth = depth;
var depthSelector = document.getElementById('depth-selector');
if (!depthSelector) return;
var btns = depthSelector.querySelectorAll('.depth-btn');
for (var i = 0; i < btns.length; i++) {
btns[i].classList.toggle('active', btns[i].getAttribute('data-depth') === String(depth));
}
if (depth === 'all') {
// Reset all nodes to visible, remove depth styling
var resetUpdates = [];
allNodesData.forEach(function(n) {
resetUpdates.push({ id: n.id, hidden: false, opacity: n.opacity || 1.0, size: undefined });
});
nodesDataset.update(resetUpdates);
focusNeighbors = null;
depthLevels = null;
} else {
var result = getNeighborsAtDepth(network, focusNodeId, depth);
depthLevels = result.levels;
focusNeighbors = result.visited;
applyDepthVisuals(result.levels);
}
refreshFilters();
updateDepthCount();
network.fit({ animation: { duration: 300 } });
}
function enterFocusMode(nodeId) {
focusNodeId = nodeId;
currentDepth = 1;
var result = getNeighborsAtDepth(network, nodeId, 1);
depthLevels = result.levels;
focusNeighbors = result.visited;
applyDepthVisuals(result.levels);
refreshFilters();
document.getElementById('btn-exit-focus').style.display = 'block';
// Show depth selector
var depthSelector = document.getElementById('depth-selector');
if (depthSelector) {
depthSelector.style.display = 'block';
var btns = depthSelector.querySelectorAll('.depth-btn');
for (var i = 0; i < btns.length; i++) {
btns[i].classList.toggle('active', btns[i].getAttribute('data-depth') === '1');
}
}
updateDepthCount();
network.fit({ animation: { duration: 300 } });
}
function exitFocusMode() {
focusNodeId = null;
focusNeighbors = null;
depthLevels = null;
// Restore original node properties
var resetUpdates = [];
allNodesData.forEach(function(n) {
resetUpdates.push({ id: n.id, hidden: false, opacity: n.opacity || 1.0, size: undefined });
});
nodesDataset.update(resetUpdates);
refreshFilters();
document.getElementById('btn-exit-focus').style.display = 'none';
// Hide depth selector
var depthSelector = document.getElementById('depth-selector');
if (depthSelector) depthSelector.style.display = 'none';
}
// Depth button click handlers
var depthBtns = document.querySelectorAll('.depth-btn');
for (var di = 0; di < depthBtns.length; di++) {
depthBtns[di].addEventListener('click', function() {
if (!focusNodeId) return;
var d = this.getAttribute('data-depth');
setDepth(d === 'all' ? 'all' : parseInt(d, 10));
});
}
// Keyboard shortcuts for depth (GD-12 AC 19)
document.addEventListener('keydown', function(e) {
if (!focusNodeId) return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === '1') setDepth(1);
else if (e.key === '2') setDepth(2);
else if (e.key === '3') setDepth(3);
else if (e.key === 'a' || e.key === 'A') setDepth('all');
});
// --- Sidebar Events ---
document.getElementById('btn-toggle-sidebar').addEventListener('click', function() {
var sb = document.getElementById('sidebar');
sb.classList.toggle('collapsed');
var graphEl = document.getElementById('graph');
graphEl.style.left = sb.classList.contains('collapsed') ? '40px' : '260px';
statusEl.style.left = sb.classList.contains('collapsed') ? '50px' : '270px';
});
// Category checkboxes
var catBoxes = document.querySelectorAll('input[data-filter="category"]');
for (var i = 0; i < catBoxes.length; i++) {
catBoxes[i].addEventListener('change', function() {
if (this.checked) {
activeCategories.add(this.value);
} else {
activeCategories.delete(this.value);
}
refreshFilters();
});
}
// Lifecycle checkboxes
var lcBoxes = document.querySelectorAll('input[data-filter="lifecycle"]');
for (var i = 0; i < lcBoxes.length; i++) {
lcBoxes[i].addEventListener('change', function() {
if (this.checked) {
activeLifecycles.add(this.value);
} else {
activeLifecycles.delete(this.value);
}
refreshFilters();
});
}
// Hide orphans toggle
document.getElementById('hide-orphans').addEventListener('change', function() {
hideOrphans = this.checked;
refreshFilters();
});
// Search input
var searchTimer = null;
document.getElementById('search-input').addEventListener('input', function() {
var val = this.value;
clearTimeout(searchTimer);
searchTimer = setTimeout(function() {
searchTerm = val;
refreshFilters();
}, 200);
});
// Reset button
document.getElementById('btn-reset').addEventListener('click', function() {
searchTerm = '';
focusNodeId = null;
focusNeighbors = null;
hideOrphans = false;
activeCategories = new Set(${JSON.stringify(Object.keys(CATEGORY_COLORS))});
activeLifecycles = new Set(['production', 'experimental', 'deprecated', 'orphan']);
document.getElementById('search-input').value = '';
document.getElementById('hide-orphans').checked = false;
document.getElementById('btn-exit-focus').style.display = 'none';
// Reset depth state (GD-12 O-3 fix)
depthLevels = null;
currentDepth = 1;
var depthSel = document.getElementById('depth-selector');
if (depthSel) depthSel.style.display = 'none';
// Restore original node properties (clear depth visuals)
var resetNodeUpdates = [];
allNodesData.forEach(function(n) {
resetNodeUpdates.push({ id: n.id, hidden: false, opacity: n.opacity || 1.0, size: undefined });
});
nodesDataset.update(resetNodeUpdates);
var allBoxes = document.querySelectorAll('#sidebar input[type="checkbox"][data-filter]');
for (var i = 0; i < allBoxes.length; i++) { allBoxes[i].checked = true; }
refreshFilters();
network.fit({ animation: { duration: 300 } });
});
// Exit focus button
document.getElementById('btn-exit-focus').addEventListener('click', exitFocusMode);
// --- Physics Controls ---
function _debounce(fn, ms) {
var timer;
return function() {
var args = arguments;
clearTimeout(timer);
timer = setTimeout(function() { fn.apply(null, args); }, ms);
};
}
var physicsDefaults = { center: 0.3, repel: -2000, link: 0.04, distance: 95 };
var physicsPaused = false;
var sliderCenter = document.getElementById('slider-center');
var sliderRepel = document.getElementById('slider-repel');
var sliderLink = document.getElementById('slider-link');
var sliderDistance = document.getElementById('slider-distance');
var valCenter = document.getElementById('val-center');
var valRepel = document.getElementById('val-repel');
var valLink = document.getElementById('val-link');
var valDistance = document.getElementById('val-distance');
function applyPhysics() {
network.setOptions({
physics: {
barnesHut: {
centralGravity: parseFloat(sliderCenter.value),
gravitationalConstant: parseFloat(sliderRepel.value),
springConstant: parseFloat(sliderLink.value),
springLength: parseFloat(sliderDistance.value)
}
}
});
}
var debouncedApply = _debounce(applyPhysics, 50);
function setupSlider(slider, valEl) {
slider.addEventListener('input', function() {
valEl.textContent = this.value;
debouncedApply();
});
}
setupSlider(sliderCenter, valCenter);
setupSlider(sliderRepel, valRepel);
setupSlider(sliderLink, valLink);
setupSlider(sliderDistance, valDistance);
// Physics toggle (collapse/expand)
var physicsToggle = document.querySelector('.physics-toggle');
var physicsContent = document.querySelector('.physics-content');
if (physicsToggle) {
physicsToggle.addEventListener('click', function() {
physicsContent.style.display = physicsContent.style.display === 'none' ? 'block' : 'none';
});
}
// Reset physics
document.getElementById('btn-physics-reset').addEventListener('click', function() {
sliderCenter.value = physicsDefaults.center;
sliderRepel.value = physicsDefaults.repel;
sliderLink.value = physicsDefaults.link;
sliderDistance.value = physicsDefaults.distance;
valCenter.textContent = physicsDefaults.center;
valRepel.textContent = physicsDefaults.repel;
valLink.textContent = physicsDefaults.link;
valDistance.textContent = physicsDefaults.distance;
applyPhysics();
});
// Pause/Resume physics
document.getElementById('btn-physics-pause').addEventListener('click', function() {
physicsPaused = !physicsPaused;
network.setOptions({ physics: { enabled: !physicsPaused } });
this.textContent = physicsPaused ? 'Resume' : 'Pause';
this.style.color = physicsPaused ? '${THEME.text.secondary}' : '';
});
// --- Degree Computation (GD-13) ---
function computeDegrees(edgesArr) {
var degrees = {};
for (var i = 0; i < edgesArr.length; i++) {
var e = edgesArr[i];
if (!degrees[e.from]) degrees[e.from] = { total: 0, in: 0, out: 0 };
if (!degrees[e.to]) degrees[e.to] = { total: 0, in: 0, out: 0 };
degrees[e.from].out++;
degrees[e.from].total++;
degrees[e.to].in++;
degrees[e.to].total++;
}
return degrees;
}
var nodeDegrees = computeDegrees(allEdgesData);
var currentSizingMode = 'uniform';
function applySizing(mode) {
currentSizingMode = mode;
var sizeBtns = document.querySelectorAll('.size-btn');
for (var i = 0; i < sizeBtns.length; i++) {
sizeBtns[i].classList.toggle('active', sizeBtns[i].getAttribute('data-sizing') === mode);
}
if (mode === 'uniform') {
var resetUpdates = [];
allNodesData.forEach(function(n) { resetUpdates.push({ id: n.id, size: undefined }); });
nodesDataset.update(resetUpdates);
return;
}
var field = mode === 'degree' ? 'total' : (mode === 'in-degree' ? 'in' : 'out');
var maxDeg = 0;
allNodesData.forEach(function(n) {
var d = nodeDegrees[n.id] ? nodeDegrees[n.id][field] : 0;
if (d > maxDeg) maxDeg = d;
});
var minSize = 10, maxSize = 40;
var updates = [];
allNodesData.forEach(function(n) {
var d = nodeDegrees[n.id] ? nodeDegrees[n.id][field] : 0;
var size = maxDeg > 0 ? minSize + (d / maxDeg) * (maxSize - minSize) : minSize;
updates.push({ id: n.id, size: size });
});
nodesDataset.update(updates);
}
var sizeBtns = document.querySelectorAll('.size-btn');
for (var si = 0; si < sizeBtns.length; si++) {
sizeBtns[si].addEventListener('click', function() {
applySizing(this.getAttribute('data-sizing'));
});
}
// --- Layout Switching (GD-13) ---
var currentLayout = 'force';
var baseNetworkOptions = {
physics: {
stabilization: { iterations: ${isLargeGraph ? 200 : 100}, updateInterval: 25 },
barnesHut: {
gravitationalConstant: ${isLargeGraph ? -2000 : -3000},
springLength: ${isLargeGraph ? 200 : 150},
springConstant: 0.01,
damping: 0.3
}
},
nodes: {
font: { color: '${THEME.text.secondary}', size: ${isLargeGraph ? 10 : 12} },
borderWidth: 2,
scaling: { min: 5, max: 20 }
},
edges: {
color: { color: '${THEME.border.default}', highlight: '${THEME.border.highlight}' },
smooth: ${isLargeGraph ? 'false' : '{ type: "cubicBezier" }'}
},
interaction: {
hover: true,
tooltipDelay: 200,
hideEdgesOnDrag: true,
hideEdgesOnZoom: ${isLargeGraph ? 'true' : 'false'}
}
};
function rebuildNetwork(opts) {
network.destroy();
network = new vis.Network(container, { nodes: nodesView, edges: edgesView }, opts);
network.on('stabilizationIterationsDone', function() {
network.fit({ animation: { duration: 500 } });
});
bindNetworkEvents();
}
function switchLayout(layout) {
currentLayout = layout;
var layoutBtns = document.querySelectorAll('.layout-btn');
for (var i = 0; i < layoutBtns.length; i++) {
layoutBtns[i].classList.toggle('active', layoutBtns[i].getAttribute('data-layout') === layout);
}
// Dim/enable physics controls based on layout
var physicsSection = document.querySelector('.physics-section');
if (physicsSection) {
physicsSection.style.opacity = layout === 'force' ? '1' : '0.4';
physicsSection.style.pointerEvents = layout === 'force' ? 'auto' : 'none';
}
if (layout === 'force') {
var forceOpts = JSON.parse(JSON.stringify(baseNetworkOptions));
forceOpts.layout = { hierarchical: { enabled: false } };
rebuildNetwork(forceOpts);
} else if (layout === 'hierarchical') {
var hierOpts = JSON.parse(JSON.stringify(baseNetworkOptions));
hierOpts.physics = { enabled: false };
hierOpts.layout = { hierarchical: { enabled: true, direction: 'UD', sortMethod: 'directed', levelSeparation: 150, nodeSpacing: 100, treeSpacing: 200 } };
rebuildNetwork(hierOpts);
} else if (layout === 'circular') {
network.setOptions({
physics: { enabled: false },
layout: { hierarchical: { enabled: false } }
});
var ids = nodesView.getIds();
var count = ids.length;
var radius = Math.max(200, count * 3);
var posUpdates = [];
for (var ci = 0; ci < count; ci++) {
var angle = (2 * Math.PI * ci) / count;
posUpdates.push({ id: ids[ci], x: radius * Math.cos(angle), y: radius * Math.sin(angle) });
}
nodesDataset.update(posUpdates);
network.fit({ animation: { duration: 500 } });
}
}
var layoutBtns = document.querySelectorAll('.layout-btn');
for (var li = 0; li < layoutBtns.length; li++) {
layoutBtns[li].addEventListener('click', function() {
switchLayout(this.getAttribute('data-layout'));
});
}
// --- Metrics ---
function updateMetrics() {
var visible = nodesView.getIds().length;
var visibleEdges = edgesView.getIds().length;
var metricsEl = document.getElementById('metrics');
metricsEl.innerHTML =
'<b>Visible:</b> ' + visible + ' / ' + totalEntities + ' entities<br>' +
'<b>Edges:</b> ' + visibleEdges + '<br>' +
(focusNodeId ? '<b>Focus:</b> ' + focusNodeId + '<br>' : '');
}
// --- Export (GD-14) ---
function getTimestampFilename(ext) {
var d = new Date();
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
return 'aios-graph-' + d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + '-' + pad(d.getHours()) + pad(d.getMinutes()) + '.' + ext;
}
document.getElementById('btn-export-png').addEventListener('click', function() {
try {
var dataURL = network.canvas.canvas.toDataURL('image/png');
var a = document.createElement('a');
a.href = dataURL;
a.download = getTimestampFilename('png');
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} catch (e) {
alert('PNG export failed: ' + e.message);
}
});
document.getElementById('btn-export-json').addEventListener('click', function() {
var exportNodes = [];
nodesDataset.forEach(function(n) {
exportNodes.push({ id: n.id, label: n.label, category: n.group, lifecycle: n.lifecycle, path: n.path });
});
var exportEdges = [];
edgesDataset.forEach(function(e) {
exportEdges.push({ from: e.from, to: e.to });
});
var exportData = {
nodes: exportNodes,
edges: exportEdges,
metadata: { total: exportNodes.length, timestamp: new Date().toISOString() }
};
var blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = getTimestampFilename('json');
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
});
// --- Minimap (GD-14) ---
var minimapCanvas = document.getElementById('minimap-canvas');
var minimapCtx = minimapCanvas.getContext('2d');
var minimapContainer = document.getElementById('minimap-container');
var minimapVisible = true;
document.getElementById('minimap-toggle').addEventListener('click', function() {
minimapVisible = !minimapVisible;
minimapContainer.classList.toggle('collapsed', !minimapVisible);
this.innerHTML = minimapVisible ? '✕' : '□';
if (minimapVisible) drawMinimap();
});
function drawMinimap() {
if (!minimapVisible) return;
var cw = minimapCanvas.width;
var ch = minimapCanvas.height;
minimapCtx.clearRect(0, 0, cw, ch);
minimapCtx.fillStyle = '${THEME.bg.surface}';
minimapCtx.fillRect(0, 0, cw, ch);
var positions = network.getPositions();
var ids = Object.keys(positions);
if (ids.length === 0) return;
// Compute bounding box
var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (var i = 0; i < ids.length; i++) {
var p = positions[ids[i]];
if (p.x < minX) minX = p.x;
if (p.x > maxX) maxX = p.x;
if (p.y < minY) minY = p.y;
if (p.y > maxY) maxY = p.y;
}
var pad = 50;
minX -= pad; maxX += pad; minY -= pad; maxY += pad;
var rangeX = maxX - minX || 1;
var rangeY = maxY - minY || 1;
var scaleX = cw / rangeX;
var scaleY = ch / rangeY;
// Draw edges
minimapCtx.strokeStyle = '${THEME.border.subtle}';
minimapCtx.lineWidth = 0.5;
var visibleIds = new Set(nodesView.getIds());
allEdgesData.forEach(function(e) {
if (!visibleIds.has(e.from) || !visibleIds.has(e.to)) return;
var pf = positions[e.from];
var pt = positions[e.to];
if (!pf || !pt) return;
minimapCtx.beginPath();
minimapCtx.moveTo((pf.x - minX) * scaleX, (pf.y - minY) * scaleY);
minimapCtx.lineTo((pt.x - minX) * scaleX, (pt.y - minY) * scaleY);
minimapCtx.stroke();
});
// Draw nodes as 2px dots
minimapCtx.fillStyle = '${THEME.text.secondary}';
for (var ni = 0; ni < ids.length; ni++) {
if (!visibleIds.has(ids[ni])) continue;
var np = positions[ids[ni]];
minimapCtx.beginPath();
minimapCtx.arc((np.x - minX) * scaleX, (np.y - minY) * scaleY, 2, 0, 2 * Math.PI);
minimapCtx.fill();
}
// Draw viewport rectangle
var viewPos = network.getViewPosition();
var scale = network.getScale();
var graphEl = document.getElementById('graph');
var vw = graphEl.clientWidth / scale;
var vh = graphEl.clientHeight / scale;
var vx = (viewPos.x - vw / 2 - minX) * scaleX;
var vy = (viewPos.y - vh / 2 - minY) * scaleY;
var vrw = vw * scaleX;
var vrh = vh * scaleY;
minimapCtx.strokeStyle = '${THEME.accent.gold}';
minimapCtx.lineWidth = 1.5;
minimapCtx.globalAlpha = 0.6;
minimapCtx.strokeRect(vx, vy, vrw, vrh);
minimapCtx.fillStyle = '${THEME.accent.gold}';
minimapCtx.globalAlpha = 0.08;
minimapCtx.fillRect(vx, vy, vrw, vrh);
minimapCtx.globalAlpha = 1.0;
// Store transform for click handler
minimapCanvas._transform = { minX: minX, minY: minY, scaleX: scaleX, scaleY: scaleY };
}
minimapCanvas.addEventListener('click', function(evt) {
var rect = minimapCanvas.getBoundingClientRect();
var cx = evt.clientX - rect.left;
var cy = evt.clientY - rect.top;
var t = minimapCanvas._transform;
if (!t) return;
var gx = cx / t.scaleX + t.minX;
var gy = cy / t.scaleY + t.minY;
network.moveTo({ position: { x: gx, y: gy }, animation: { duration: 300, easingFunction: 'easeInOutQuad' } });
});
var minimapUpdatePending = false;
function scheduleMinimapUpdate() {
if (minimapUpdatePending) return;
minimapUpdatePending = true;
requestAnimationFrame(function() {
minimapUpdatePending = false;
drawMinimap();
});
}
network.on('zoom', scheduleMinimapUpdate);
network.on('dragEnd', scheduleMinimapUpdate);
network.on('stabilizationIterationsDone', function() { setTimeout(drawMinimap, 200); });
// --- Clustering (GD-15) ---
var isClustered = false;
var categoryColors = ${JSON.stringify(Object.fromEntries(Object.entries(CATEGORY_COLORS).map(([k, v]) => [k, v.color])))};
function clusterByCategory() {
var categories = new Set();
nodesView.forEach(function(node) { categories.add(node.group); });
categories.forEach(function(cat) {
var catNodes = [];
nodesView.forEach(function(node) {
if (node.group === cat) catNodes.push(node.id);
});
if (catNodes.length === 0) return;
var clusterSize = Math.max(20, Math.min(50, 20 + (catNodes.length / allNodesData.length) * 30));
network.cluster({
joinCondition: function(nodeOptions) {
return nodeOptions.group === cat && visibleNodeIds.has(nodeOptions.id);
},
clusterNodeProperties: {
label: cat + ' (' + catNodes.length + ')',
shape: 'dot',
size: clusterSize,
color: { background: categoryColors[cat] || '${THEME.text.tertiary}', border: '${THEME.border.gold}' },
font: { color: '${THEME.text.primary}', size: 12 }
}
});
});
isClustered = true;
network.setOptions({ physics: { stabilization: { iterations: 50 } } });
}
function unclusterAll() {
var clusterIds = [];
nodesDataset.forEach(function(node) {
if (network.isCluster(node.id)) clusterIds.push(node.id);
});
// Also check generated cluster node IDs
network.body.data.nodes.forEach(function(node) {
if (network.isCluster(node.id) && clusterIds.indexOf(node.id) === -1) clusterIds.push(node.id);
});
for (var i = 0; i < clusterIds.length; i++) {
try { network.openCluster(clusterIds[i]); } catch(e) { /* already opened */ }
}
isClustered = false;
}
document.getElementById('btn-cluster-category').addEventListener('click', function() {
if (isClustered) {
unclusterAll();
this.textContent = 'Cluster by Category';
} else {
clusterByCategory();
this.textContent = 'Uncluster All';
}
updateStatistics();
});
// Double-click: cluster check before focus mode (Risk #3 mitigation)
// Override the doubleClick handler from bindNetworkEvents
network.off('doubleClick');
network.on('doubleClick', function(params) {
if (params.nodes.length === 1) {
var nodeId = params.nodes[0];
if (network.isCluster(nodeId)) {
network.openCluster(nodeId);
updateStatistics();
} else {
enterFocusMode(nodeId);
}
}
});
// --- Statistics (GD-15) ---
function computeGraphStats(nodeIds, edgesArr) {
var V = nodeIds.length;
var visibleSet = new Set(nodeIds);
var visibleEdges = [];
for (var i = 0; i < edgesArr.length; i++) {
if (visibleSet.has(edgesArr[i].from) && visibleSet.has(edgesArr[i].to)) {
visibleEdges.push(edgesArr[i]);
}
}
var E = visibleEdges.length;
var density = V > 1 ? (2 * E / (V * (V - 1))) : 0;
var avgDegree = V > 0 ? (2 * E / V) : 0;
// Top 5 by degree
var degMap = {};
for (var j = 0; j < visibleEdges.length; j++) {
degMap[visibleEdges[j].from] = (degMap[visibleEdges[j].from] || 0) + 1;
degMap[visibleEdges[j].to] = (degMap[visibleEdges[j].to] || 0) + 1;
}
var sorted = Object.keys(degMap).map(function(id) { return { id: id, degree: degMap[id] }; });
sorted.sort(function(a, b) { return b.degree - a.degree; });
var top5 = sorted.slice(0, 5);
// Resolve labels
for (var k = 0; k < top5.length; k++) {
var nd = nodesDataset.get(top5[k].id);
top5[k].label = nd ? nd.label : top5[k].id;
}
return { nodeCount: V, edgeCount: E, density: density, avgDegree: avgDegree, top5: top5 };
}
function updateStatistics() {
var visIds = nodesView.getIds();
var stats = computeGraphStats(visIds, allEdgesData);
var elNodes = document.getElementById('stat-nodes');
var elEdges = document.getElementById('stat-edges');
var elDensity = document.getElementById('stat-density');
var elAvgDeg = document.getElementById('stat-avg-degree');
var elTop5 = document.getElementById('stat-top5');
if (elNodes) elNodes.textContent = stats.nodeCount;
if (elEdges) elEdges.textContent = stats.edgeCount;
if (elDensity) elDensity.textContent = stats.density.toFixed(2);
if (elAvgDeg) elAvgDeg.textContent = stats.avgDegree.toFixed(1);
if (elTop5) {
var html = '';
for (var i = 0; i < stats.top5.length; i++) {
html += '<div class="top5-item"><span class="top5-name">' + stats.top5[i].label + '</span><span class="top5-degree">' + stats.top5[i].degree + '</span></div>';
}
elTop5.innerHTML = html;
}
}
updateMetrics();
updateStatistics();
setTimeout(drawMinimap, 1000);
})();
</script>
</body>
</html>`;
}
module.exports = {
formatAsHtml,
_sanitize,
_buildVisNodes,
_buildVisEdges,
_buildLegend,
_buildSidebar,
THEME,
CATEGORY_COLORS,
DEFAULT_COLOR,
LIFECYCLE_STYLES,
};