(function() {
const container = document.getElementById('graph-container');
if (!container) return;
if (window.d3) {
initGraph();
} else {
const script = document.createElement('script');
script.src = 'https://d3js.org/d3.v7.min.js';
script.onload = () => initGraph();
script.onerror = () => {
container.innerHTML = '<p style="color:var(--color-text);opacity:0.5;text-align:center;padding-top:40vh">Could not load D3.js β check network connection</p>';
};
document.head.appendChild(script);
}
function initGraph() {
if (window.__GRAPH_DATA) {
try { renderGraph(window.__GRAPH_DATA); }
catch (err) {
container.innerHTML = '<p style="color:var(--color-text);opacity:0.5;text-align:center;padding-top:40vh">Graph error: ' + err.message + '</p>';
console.error(err);
}
} else {
fetch('/graph-data.json')
.then(r => { if (!r.ok) throw new Error(r.status + ' ' + r.statusText); return r.json(); })
.then(data => renderGraph(data))
.catch(err => {
container.innerHTML = '<p style="color:var(--color-text);opacity:0.5;text-align:center;padding-top:40vh">Failed to load graph data: ' + err.message + '</p>';
console.error(err);
});
}
}
function renderGraph(data) {
const dpr = window.devicePixelRatio || 1;
const w = container.clientWidth || window.innerWidth;
const h = container.clientHeight || window.innerHeight;
const canvas = document.createElement('canvas');
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
container.appendChild(canvas);
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const style = getComputedStyle(document.documentElement);
const colorPrimary = style.getPropertyValue('--color-primary').trim() || '#22c55e';
const colorText = style.getPropertyValue('--color-text').trim() || '#e5e5e5';
const colorBg = style.getPropertyValue('--color-bg').trim() || '#0a0a0a';
const adj = {};
data.nodes.forEach(n => { adj[n.id] = new Set(); });
data.edges.forEach(e => {
adj[e.source] = adj[e.source] || new Set();
adj[e.target] = adj[e.target] || new Set();
adj[e.source].add(e.target);
adj[e.target].add(e.source);
});
const maxFocus = d3.max(data.nodes, d => d.focus) || 0.001;
const radiusScale = d3.scalePow().exponent(0.4).domain([0, maxFocus]).range([1.5, 20]);
const opacityScale = d3.scalePow().exponent(0.4).domain([0, maxFocus]).range([0.08, 0.85]);
const sorted = [...data.nodes].sort((a, b) => (b.focus || 0) - (a.focus || 0));
const labelCount = Math.min(12, Math.max(3, Math.floor(data.nodes.length * 0.002)));
const labelSet = new Set(sorted.slice(0, labelCount).map(n => n.id));
const nodeById = {};
data.nodes.forEach(n => { nodeById[n.id] = n; });
let transform = d3.zoomIdentity;
let hoveredNode = null;
let hoveredCentroid = null; let centroidMembers = null; let edgeRefs = [];
let zoomBehavior = null; const groups = { domains: {}, subgraphs: {}, tags: {} };
const stats = document.createElement('div');
stats.className = 'graph-stats';
const activeFilters = {
domains: new Set(),
subgraphs: new Set(),
tags: new Set(),
};
let activeDim = 'domains';
let tabHighlight = false; let visibleNodes = null;
const allDomains = (data.domains || []).slice();
const allSubgraphs = (data.subgraphs || []).slice();
const tagCounts = {};
data.nodes.forEach(n => {
(n.tags || []).forEach(t => { tagCounts[t] = (tagCounts[t] || 0) + 1; });
});
const topTags = Object.entries(tagCounts)
.filter(([, count]) => count >= 3)
.sort((a, b) => b[1] - a[1])
.slice(0, 30)
.map(([tag]) => tag);
const dimensionMeta = {
domains: { label: 'Domains', values: allDomains, validator: (v) => allDomains.includes(v) },
subgraphs: { label: 'Subgraphs', values: allSubgraphs, validator: (v) => allSubgraphs.includes(v) },
tags: { label: 'Tags', values: topTags, validator: (v) => tagCounts[v] != null },
};
const urlParams = new URLSearchParams(window.location.search);
const urlDim = urlParams.get('dim');
if (urlDim && dimensionMeta[urlDim]) activeDim = urlDim;
['domains','subgraphs','tags'].forEach(d => {
const key = d === 'subgraphs' ? 'sub' : d;
const raw = urlParams.get(key);
if (!raw) return;
raw.split(',').forEach(v => {
const t = v.trim();
if (t && dimensionMeta[d].validator(t)) activeFilters[d].add(t);
});
});
if (Object.values(activeFilters).some(s => s.size > 0)) updateVisibleNodes();
function syncFilterToURL() {
const url = new URL(window.location);
if (activeDim !== 'domains') url.searchParams.set('dim', activeDim);
else url.searchParams.delete('dim');
[['domains','domains'],['subgraphs','sub'],['tags','tags']].forEach(([d, key]) => {
if (activeFilters[d].size > 0) {
url.searchParams.set(key, Array.from(activeFilters[d]).join(','));
} else {
url.searchParams.delete(key);
}
});
history.replaceState(null, '', url);
}
const VIEWPORT_KEY = 'graph-viewport';
function saveViewport() {
try {
sessionStorage.setItem(VIEWPORT_KEY, JSON.stringify({
x: transform.x, y: transform.y, k: transform.k
}));
} catch (e) {}
}
function loadViewport() {
try {
const raw = sessionStorage.getItem(VIEWPORT_KEY);
if (raw) return JSON.parse(raw);
} catch (e) {}
return null;
}
function nodeHasValueInDim(n, dim) {
if (dim === 'domains') return n.domain != null && n.domain !== '';
if (dim === 'subgraphs') return n.subgraph != null && n.subgraph !== '';
if (dim === 'tags') return (n.tags || []).length > 0;
return false;
}
function updateVisibleNodes() {
const anyActive = Object.values(activeFilters).some(s => s.size > 0);
if (!anyActive) { visibleNodes = null; return; }
visibleNodes = new Set();
data.nodes.forEach(n => {
if (activeFilters.domains.size > 0 && !activeFilters.domains.has(n.domain)) return;
if (activeFilters.subgraphs.size > 0 && !activeFilters.subgraphs.has(n.subgraph)) return;
if (activeFilters.tags.size > 0) {
const tags = n.tags || [];
let hit = false;
for (const t of tags) { if (activeFilters.tags.has(t)) { hit = true; break; } }
if (!hit) return;
}
visibleNodes.add(n.id);
});
}
function fitToVisible() {
if (!zoomBehavior) return;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity, count = 0;
const points = (tabHighlight && groups[activeDim])
? Object.values(groups[activeDim])
: data.nodes.filter(n => !visibleNodes || visibleNodes.has(n.id));
for (const p of points) {
if (typeof p.x !== 'number' || typeof p.y !== 'number') continue;
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;
count++;
}
if (count === 0 || !isFinite(minX)) return;
const pad = 80;
const bw = Math.max(1, maxX - minX);
const bh = Math.max(1, maxY - minY);
const k = Math.min((w - pad * 2) / bw, (h - pad * 2) / bh, 6);
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
const tx = w / 2 - cx * k;
const ty = h / 2 - cy * k;
const next = d3.zoomIdentity.translate(tx, ty).scale(k);
d3.select(canvas)
.transition()
.duration(450)
.call(zoomBehavior.transform, next);
}
let pillsRowEl = null;
let tabsEl = null;
function buildFilterUI() {
const wrap = document.createElement('div');
wrap.className = 'graph-filter';
tabsEl = document.createElement('div');
tabsEl.className = 'graph-filter-tabs';
['domains', 'subgraphs', 'tags'].forEach(d => {
const meta = dimensionMeta[d];
if (!meta.values.length) return;
const btn = document.createElement('button');
btn.className = 'graph-filter-tab';
btn.dataset.dim = d;
btn.addEventListener('click', () => {
if (activeDim === d) {
tabHighlight = !tabHighlight;
} else {
activeDim = d;
tabHighlight = activeFilters[d].size === 0;
}
renderPillsRow();
updateTabStates();
syncFilterToURL();
updateVisibleNodes();
updateStats();
draw();
fitToVisible();
});
tabsEl.appendChild(btn);
});
wrap.appendChild(tabsEl);
pillsRowEl = document.createElement('div');
pillsRowEl.className = 'graph-filter-pills';
wrap.appendChild(pillsRowEl);
document.body.appendChild(wrap);
renderPillsRow();
updateTabStates();
}
function renderPillsRow() {
if (!pillsRowEl) return;
pillsRowEl.innerHTML = '';
const meta = dimensionMeta[activeDim];
if (!meta || !meta.values.length) return;
const set = activeFilters[activeDim];
const allPill = document.createElement('button');
allPill.className = 'graph-filter-pill all-pill' + (set.size === 0 ? ' active' : '');
allPill.textContent = 'all';
allPill.addEventListener('click', () => {
set.clear();
tabHighlight = false;
applyFilterChange();
});
pillsRowEl.appendChild(allPill);
meta.values.forEach(v => {
const pill = document.createElement('button');
pill.className = 'graph-filter-pill' + (set.has(v) ? ' active' : '');
pill.textContent = v;
pill.dataset.value = v;
pill.addEventListener('click', () => {
if (set.has(v)) set.delete(v); else set.add(v);
tabHighlight = false;
applyFilterChange();
});
pill.addEventListener('mouseenter', () => {
if (!tabHighlight) return;
const members = new Set();
for (const n of data.nodes) {
let match = false;
if (activeDim === 'domains') match = n.domain === v;
else if (activeDim === 'subgraphs') match = n.subgraph === v;
else if (activeDim === 'tags') match = (n.tags || []).includes(v);
if (match) members.add(n.id);
}
centroidMembers = members.size ? members : null;
hoveredCentroid = v;
draw();
});
pill.addEventListener('mouseleave', () => {
if (!tabHighlight) return;
if (hoveredCentroid === v) {
centroidMembers = null;
hoveredCentroid = null;
draw();
}
});
pillsRowEl.appendChild(pill);
});
}
function applyFilterChange() {
updateVisibleNodes();
updatePillStates();
updateTabStates();
updateStats();
syncFilterToURL();
draw();
fitToVisible();
}
function updatePillStates() {
if (!pillsRowEl) return;
const set = activeFilters[activeDim];
pillsRowEl.querySelectorAll('.graph-filter-pill').forEach(pill => {
const v = pill.dataset.value;
if (!v) {
pill.classList.toggle('active', set.size === 0);
} else {
pill.classList.toggle('active', set.has(v));
}
});
}
function updateTabStates() {
if (!tabsEl) return;
tabsEl.querySelectorAll('.graph-filter-tab').forEach(btn => {
const d = btn.dataset.dim;
const count = activeFilters[d].size;
const engaged = (d === activeDim && tabHighlight) || count > 0;
btn.classList.toggle('active', engaged);
btn.classList.toggle('current', d === activeDim);
btn.classList.toggle('has-selection', count > 0);
btn.textContent = count > 0
? dimensionMeta[d].label + ' Β· ' + count
: dimensionMeta[d].label;
});
}
function formatBytes(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
function formatLines(lines) {
if (lines < 1000) return lines + ' loc';
if (lines < 1_000_000) return (lines / 1000).toFixed(1) + 'k loc';
return (lines / 1_000_000).toFixed(2) + 'M loc';
}
function updateStats() {
if (!stats) return;
const sizeStr = data.totalBytes != null ? ' · ' + formatBytes(data.totalBytes) : '';
const locStr = data.totalLines != null ? ' · ' + formatLines(data.totalLines) : '';
if (visibleNodes) {
const visEdges = edgeRefs.filter(e => {
const sid = e.source.id || e.source;
const tid = e.target.id || e.target;
return visibleNodes.has(sid) || visibleNodes.has(tid);
}).length;
stats.innerHTML = visibleNodes.size + ' / ' + data.nodes.length + ' files · ' + visEdges + ' connections' + sizeStr + locStr;
} else {
stats.innerHTML = data.nodes.length + ' files · ' + data.edges.length + ' connections' + sizeStr + locStr;
}
}
const hasLayout = data.nodes.length > 0 && data.nodes.some(n => n.x !== 0 || n.y !== 0);
if (hasLayout) {
function robustBounds(values) {
const sorted = values.slice().sort((a, b) => a - b);
const median = sorted[Math.floor(sorted.length / 2)];
const devs = sorted.map(v => Math.abs(v - median)).sort((a, b) => a - b);
const mad = devs[Math.floor(devs.length / 2)] || 1;
const spread = Math.max(1, mad * 5);
return { min: median - spread, max: median + spread, center: median };
}
const xs = data.nodes.map(d => d.x);
const ys = data.nodes.map(d => d.y);
const xb = robustBounds(xs);
const yb = robustBounds(ys);
const graphW = xb.max - xb.min || 1;
const graphH = yb.max - yb.min || 1;
const scale = Math.min(w / graphW, h / graphH) * 0.85;
const cx = xb.center;
const cy = yb.center;
data.nodes.forEach(d => {
d.x = w / 2 + (d.x - cx) * scale;
d.y = h / 2 + (d.y - cy) * scale;
});
finishSetup();
} else {
data.nodes.forEach(d => {
const angle = Math.random() * Math.PI * 2;
const r = Math.random() * Math.min(w, h) * 0.35;
d.x = w / 2 + Math.cos(angle) * r;
d.y = h / 2 + Math.sin(angle) * r;
});
const overlay = document.createElement('div');
overlay.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:20;pointer-events:none';
const progText = document.createElement('div');
progText.style.cssText = 'color:var(--color-primary);font-size:14px;font-weight:500;letter-spacing:0.05em';
progText.textContent = 'Computing layout\u2026';
const progBar = document.createElement('div');
progBar.style.cssText = 'width:200px;height:2px;background:color-mix(in srgb,var(--color-primary) 15%,transparent);border-radius:1px;margin-top:12px;overflow:hidden';
const progFill = document.createElement('div');
progFill.style.cssText = 'width:0%;height:100%;background:var(--color-primary);border-radius:1px;transition:width 0.1s';
progBar.appendChild(progFill);
overlay.appendChild(progText);
overlay.appendChild(progBar);
container.appendChild(overlay);
const simulation = d3.forceSimulation(data.nodes)
.force('link', d3.forceLink(data.edges).id(d => d.id).distance(20).strength(0.3))
.force('charge', d3.forceManyBody().strength(-15).distanceMax(250).theta(0.9))
.force('center', d3.forceCenter(w / 2, h / 2))
.force('collision', d3.forceCollide().radius(d => radiusScale(d.focus) + 2).strength(0.8).iterations(1))
.alphaDecay(0.04)
.stop();
const maxTicks = 300;
const chunkSize = 15;
let ticksDone = 0;
const t0 = performance.now();
function tickChunk() {
for (let i = 0; i < chunkSize && ticksDone < maxTicks; i++, ticksDone++) {
simulation.tick();
if (simulation.alpha() < 0.001) { ticksDone = maxTicks; break; }
}
const pct = Math.min(100, Math.round(ticksDone / maxTicks * 100));
progFill.style.width = pct + '%';
progText.textContent = 'Computing layout\u2026 ' + pct + '%';
if (ticksDone < maxTicks) {
setTimeout(tickChunk, 0);
} else {
overlay.remove();
finishSetup();
}
}
tickChunk();
}
function finishSetup() {
edgeRefs = data.edges.map(e => ({
source: typeof e.source === 'object' ? e.source : nodeById[e.source],
target: typeof e.target === 'object' ? e.target : nodeById[e.target]
}));
function bumpGroup(map, key, n) {
if (!map[key]) map[key] = { sumX: 0, sumY: 0, count: 0, value: key };
map[key].sumX += n.x;
map[key].sumY += n.y;
map[key].count += 1;
}
for (const n of data.nodes) {
if (typeof n.x !== 'number' || typeof n.y !== 'number') continue;
if (n.domain) bumpGroup(groups.domains, n.domain, n);
if (n.subgraph) bumpGroup(groups.subgraphs, n.subgraph, n);
for (const t of (n.tags || [])) {
if (tagCounts[t] >= 3) bumpGroup(groups.tags, t, n);
}
}
for (const dim of ['domains', 'subgraphs', 'tags']) {
for (const k in groups[dim]) {
const g = groups[dim][k];
g.x = g.sumX / g.count;
g.y = g.sumY / g.count;
}
}
buildFilterUI();
updatePillStates();
updateStats();
draw();
zoomBehavior = d3.zoom()
.scaleExtent([0.1, 20])
.on('zoom', (event) => {
transform = event.transform;
saveViewport();
draw();
});
d3.select(canvas).call(zoomBehavior);
const saved = loadViewport();
if (saved) {
transform = d3.zoomIdentity.translate(saved.x, saved.y).scale(saved.k);
d3.select(canvas).call(zoomBehavior.transform, transform);
}
setupInteractions();
}
function draw() {
ctx.save();
ctx.clearRect(0, 0, w, h);
ctx.translate(transform.x, transform.y);
ctx.scale(transform.k, transform.k);
const k = transform.k;
const connectedSet = hoveredNode ? (adj[hoveredNode.id] || new Set()) : null;
const isVisible = visibleNodes ? (id) => visibleNodes.has(id) : () => true;
if (hoveredNode || visibleNodes || centroidMembers) {
ctx.strokeStyle = colorPrimary;
ctx.lineWidth = 0.8 / k;
ctx.beginPath();
for (const e of edgeRefs) {
const sid = e.source.id || e.source;
const tid = e.target.id || e.target;
let show = false;
if (hoveredNode && (sid === hoveredNode.id || tid === hoveredNode.id)) {
show = true;
} else if (centroidMembers && centroidMembers.has(sid) && centroidMembers.has(tid)) {
show = true;
} else if (visibleNodes && isVisible(sid) && isVisible(tid)) {
show = true;
}
if (show) {
ctx.moveTo(e.source.x, e.source.y);
ctx.lineTo(e.target.x, e.target.y);
}
}
ctx.globalAlpha = centroidMembers ? 0.4 : (visibleNodes ? 0.15 : 0.3);
ctx.stroke();
ctx.globalAlpha = 1;
}
for (const n of data.nodes) {
const r = radiusScale(n.focus);
const nodeVisible = isVisible(n.id);
let alpha = opacityScale(n.focus);
if (visibleNodes && !nodeVisible) {
alpha = 0.02;
}
if (tabHighlight) {
if (centroidMembers && centroidMembers.has(n.id)) {
alpha = Math.max(alpha, 0.85);
} else {
alpha = Math.min(alpha, 0.05);
}
}
if (hoveredNode) {
if (n.id === hoveredNode.id) {
alpha = 1;
} else if (connectedSet.has(n.id)) {
alpha = visibleNodes && !nodeVisible ? 0.15 : 0.7;
} else {
alpha = 0.03;
}
}
ctx.beginPath();
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
ctx.fillStyle = colorPrimary;
ctx.globalAlpha = alpha;
ctx.fill();
if (hoveredNode && n.id === hoveredNode.id) {
ctx.shadowColor = colorPrimary;
ctx.shadowBlur = 12;
ctx.strokeStyle = colorPrimary;
ctx.lineWidth = 1.5 / k;
ctx.stroke();
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1;
}
const showLabelsAtZoom = k >= 0.5;
if (showLabelsAtZoom) {
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
for (const n of data.nodes) {
const r = radiusScale(n.focus);
let show = false;
let alpha = 0.7;
const nodeVisible = isVisible(n.id);
if (hoveredNode) {
if (n.id === hoveredNode.id || connectedSet.has(n.id)) {
show = true;
alpha = n.id === hoveredNode.id ? 1 : 0.75;
} else if (labelSet.has(n.id)) {
show = true;
alpha = 0.15;
}
} else if (centroidMembers) {
if (centroidMembers.has(n.id)) {
show = true;
alpha = 0.85;
}
} else if (tabHighlight) {
show = false;
} else if (visibleNodes) {
if (nodeVisible) {
show = true;
alpha = 0.8;
}
} else {
if (labelSet.has(n.id)) {
show = true;
alpha = 0.7;
} else if (k >= 2 && n.focus > maxFocus * 0.08) {
show = true;
alpha = 0.5;
} else if (k >= 4 && n.focus > maxFocus * 0.02) {
show = true;
alpha = 0.4;
} else if (k >= 6) {
show = true;
alpha = 0.35;
}
}
if (!show) continue;
const fontSize = Math.max(9, Math.min(14, 10 + r * 0.2)) / k;
ctx.font = '500 ' + fontSize + 'px system-ui, sans-serif';
ctx.globalAlpha = alpha;
ctx.strokeStyle = colorBg;
ctx.lineWidth = 3 / k;
ctx.lineJoin = 'round';
ctx.strokeText(n.title, n.x, n.y - r - 3 / k);
ctx.fillStyle = colorText;
ctx.fillText(n.title, n.x, n.y - r - 3 / k);
}
}
if (tabHighlight) {
const dimGroups = groups[activeDim] || {};
const groupArr = Object.values(dimGroups);
if (groupArr.length) {
const maxCount = groupArr.reduce((m, g) => Math.max(m, g.count), 1);
for (const g of groupArr) {
const r = (10 + Math.sqrt(g.count / maxCount) * 30) / k;
ctx.beginPath();
ctx.arc(g.x, g.y, r, 0, Math.PI * 2);
ctx.fillStyle = colorPrimary;
ctx.globalAlpha = 0.85;
ctx.fill();
ctx.shadowColor = colorPrimary;
ctx.shadowBlur = 14 / k;
ctx.lineWidth = 1.5 / k;
ctx.strokeStyle = colorPrimary;
ctx.stroke();
ctx.shadowBlur = 0;
}
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
for (const g of groupArr) {
const r = (10 + Math.sqrt(g.count / maxCount) * 30) / k;
const fontSize = Math.max(11, Math.min(18, 12 + r * 0.3 * k)) / k;
ctx.font = '600 ' + fontSize + 'px system-ui, sans-serif';
ctx.lineWidth = 4 / k;
ctx.lineJoin = 'round';
ctx.strokeStyle = colorBg;
ctx.globalAlpha = 1;
ctx.strokeText(g.value, g.x, g.y - r - 4 / k);
ctx.fillStyle = colorText;
ctx.fillText(g.value, g.x, g.y - r - 4 / k);
}
}
}
ctx.globalAlpha = 1;
ctx.restore();
}
function findNode(mx, my) {
const gx = (mx - transform.x) / transform.k;
const gy = (my - transform.y) / transform.k;
let closest = null;
let closestDist = Infinity;
for (const n of data.nodes) {
if (visibleNodes && !visibleNodes.has(n.id)) continue;
const r = radiusScale(n.focus);
const hitR = Math.max(r, 5 / transform.k);
const dx = gx - n.x;
const dy = gy - n.y;
const dist = dx * dx + dy * dy;
if (dist < hitR * hitR && dist < closestDist) {
closest = n;
closestDist = dist;
}
}
return closest;
}
function findCentroid(mx, my) {
if (!tabHighlight) return null;
const dimGroups = groups[activeDim] || {};
const groupArr = Object.values(dimGroups);
if (!groupArr.length) return null;
const maxCount = groupArr.reduce((m, g) => Math.max(m, g.count), 1);
const gx = (mx - transform.x) / transform.k;
const gy = (my - transform.y) / transform.k;
let closest = null;
let closestDist = Infinity;
for (const g of groupArr) {
const r = (10 + Math.sqrt(g.count / maxCount) * 30) / transform.k;
const hitR = Math.max(r, 6 / transform.k);
const dx = gx - g.x;
const dy = gy - g.y;
const dist = dx * dx + dy * dy;
if (dist < hitR * hitR && dist < closestDist) {
closest = g;
closestDist = dist;
}
}
return closest;
}
const tooltip = document.createElement('div');
tooltip.style.cssText = 'position:absolute;display:none;background:color-mix(in srgb,var(--color-bg) 85%,transparent);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);border:1px solid var(--color-primary);padding:8px 14px;border-radius:8px;font-size:13px;pointer-events:none;box-shadow:0 0 20px rgba(34,197,94,0.15);z-index:10';
container.appendChild(tooltip);
let dragNode = null;
let isDragging = false;
function setupInteractions() {
canvas.addEventListener('mousemove', (event) => {
if (dragNode) {
isDragging = true;
const rect = canvas.getBoundingClientRect();
dragNode.x = (event.clientX - rect.left - transform.x) / transform.k;
dragNode.y = (event.clientY - rect.top - transform.y) / transform.k;
draw();
return;
}
const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
const centroid = tabHighlight ? findCentroid(mx, my) : null;
const node = centroid ? null : findNode(mx, my);
if (centroid !== hoveredCentroid) {
hoveredCentroid = centroid;
centroidMembers = null;
if (centroid) {
centroidMembers = new Set();
for (const n of data.nodes) {
let match = false;
if (activeDim === 'domains') match = n.domain === centroid.value;
else if (activeDim === 'subgraphs') match = n.subgraph === centroid.value;
else if (activeDim === 'tags') match = (n.tags || []).indexOf(centroid.value) !== -1;
if (match) centroidMembers.add(n.id);
}
}
draw();
}
if (node !== hoveredNode) {
hoveredNode = node;
draw();
}
if (centroid) {
canvas.style.cursor = 'pointer';
tooltip.style.display = 'block';
tooltip.innerHTML = '<strong style="font-size:15px">' + centroid.value + '</strong><br><span style="font-size:12px;opacity:0.6">' + centroid.count + ' pages</span>';
tooltip.style.left = (event.clientX - rect.left + 15) + 'px';
tooltip.style.top = (event.clientY - rect.top - 10) + 'px';
} else if (node) {
canvas.style.cursor = 'pointer';
tooltip.style.display = 'block';
tooltip.innerHTML = '<strong style="font-size:15px">' + node.title + '</strong><br><span style="font-size:12px;opacity:0.6">Ο ' + ((node.focus || 0) * 100).toFixed(2) + '% Β· ' + node.linkCount + ' links</span>';
tooltip.style.left = (event.clientX - rect.left + 15) + 'px';
tooltip.style.top = (event.clientY - rect.top - 10) + 'px';
} else {
canvas.style.cursor = 'grab';
tooltip.style.display = 'none';
}
});
canvas.addEventListener('mouseleave', () => {
hoveredNode = null;
hoveredCentroid = null;
centroidMembers = null;
tooltip.style.display = 'none';
draw();
});
canvas.addEventListener('click', (event) => {
if (isDragging) return;
const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
if (tabHighlight) {
const c = findCentroid(mx, my);
if (c) {
activeFilters[activeDim].add(c.value);
tabHighlight = false;
applyFilterChange();
renderPillsRow();
return;
}
}
const node = findNode(mx, my);
if (node) {
window.location.href = node.url;
return;
}
if (tabHighlight) {
tabHighlight = false;
updateVisibleNodes();
updateTabStates();
updateStats();
draw();
}
});
canvas.addEventListener('mousedown', (event) => {
const rect = canvas.getBoundingClientRect();
const node = findNode(event.clientX - rect.left, event.clientY - rect.top);
if (node) {
dragNode = node;
isDragging = false;
}
});
canvas.addEventListener('mouseup', () => {
dragNode = null;
isDragging = false;
});
window.addEventListener('resize', () => {
const nw = container.clientWidth || window.innerWidth;
const nh = container.clientHeight || window.innerHeight;
canvas.width = nw * dpr;
canvas.height = nh * dpr;
canvas.style.width = nw + 'px';
canvas.style.height = nh + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
draw();
});
}
updateStats();
container.appendChild(stats);
}
})();
(function() {
const container = document.getElementById('minimap-container');
if (!container) return;
const pageId = container.dataset.pageId;
const depth = parseInt(container.dataset.depth) || 2;
const maxNodes = parseInt(container.dataset.maxNodes) || 30;
if (!pageId) return;
function loadAndRender() {
fetch('/graph-data.json')
.then(r => r.json())
.then(fullGraph => {
const data = extractNeighborhood(fullGraph, pageId, depth, maxNodes);
if (data.nodes.length > 1) {
renderMinimap(data);
} else {
container.innerHTML = '<p style="color:#888;font-size:12px">No connections</p>';
}
})
.catch(() => {});
}
function extractNeighborhood(graph, centerId, maxDepth, maxNodes) {
const adj = {};
const nodeMap = {};
graph.nodes.forEach(n => { nodeMap[n.id] = n; adj[n.id] = []; });
graph.edges.forEach(e => {
const s = typeof e.source === 'object' ? e.source.id : e.source;
const t = typeof e.target === 'object' ? e.target.id : e.target;
if (adj[s]) adj[s].push(t);
if (adj[t]) adj[t].push(s);
});
const hop = { [centerId]: 0 };
let frontier = [centerId];
for (let d = 0; d < maxDepth; d++) {
const next = [];
for (const nid of frontier) {
for (const neighbor of (adj[nid] || [])) {
if (!(neighbor in hop)) {
hop[neighbor] = d + 1;
next.push(neighbor);
}
}
}
frontier = next;
}
const score = id => {
const n = nodeMap[id] || {};
const focus = typeof n.focus === 'number' ? n.focus : 0;
const rank = typeof n.pageRank === 'number' ? n.pageRank : 0;
return (focus + rank) / (1 + (hop[id] || 0));
};
const ranked = Object.keys(hop)
.filter(id => nodeMap[id] && id !== centerId)
.sort((a, b) => score(b) - score(a))
.slice(0, Math.max(0, maxNodes - 1));
const keep = new Set([centerId, ...ranked]);
const nodes = Array.from(keep).map(id => {
const n = nodeMap[id];
return {
id,
title: n.title,
url: n.url,
current: id === centerId,
focus: typeof n.focus === 'number' ? n.focus : 0,
pageRank: typeof n.pageRank === 'number' ? n.pageRank : 0,
hop: hop[id] || 0,
};
});
const edges = graph.edges.filter(e => {
const s = typeof e.source === 'object' ? e.source.id : e.source;
const t = typeof e.target === 'object' ? e.target.id : e.target;
return keep.has(s) && keep.has(t);
}).map(e => ({
source: typeof e.source === 'object' ? e.source.id : e.source,
target: typeof e.target === 'object' ? e.target.id : e.target
}));
return { nodes, edges };
}
let booted = false;
function boot() {
if (booted) return;
booted = true;
if (window.d3) {
loadAndRender();
} else {
const script = document.createElement('script');
script.src = 'https://d3js.org/d3.v7.min.js';
script.onload = loadAndRender;
document.head.appendChild(script);
}
}
if ('IntersectionObserver' in window) {
const io = new IntersectionObserver(function (entries) {
for (const e of entries) {
if (e.isIntersecting) { io.disconnect(); boot(); break; }
}
}, { rootMargin: '200px' });
io.observe(container);
} else {
(window.requestIdleCallback || setTimeout)(boot, 1500);
}
function renderMinimap(data) {
const w = container.clientWidth || 720;
const h = 460;
const pad = 36;
const maxFocus = Math.max(0.0001, ...data.nodes.map(n => n.focus || 0));
const radius = d => {
if (d.current) return 11;
const f = (d.focus || d.pageRank || 0) / maxFocus;
return 4 + Math.sqrt(f) * 6; };
const svg = d3.select(container)
.append('svg')
.attr('width', w)
.attr('height', h)
.attr('viewBox', [0, 0, w, h])
.style('display', 'block')
.style('max-width', '100%');
const neighbors = {};
data.nodes.forEach(n => { neighbors[n.id] = new Set([n.id]); });
data.edges.forEach(e => {
const s = typeof e.source === 'object' ? e.source.id : e.source;
const t = typeof e.target === 'object' ? e.target.id : e.target;
neighbors[s] && neighbors[s].add(t);
neighbors[t] && neighbors[t].add(s);
});
const g = svg.append('g');
const simulation = d3.forceSimulation(data.nodes)
.force('link', d3.forceLink(data.edges).id(d => d.id).distance(90).strength(0.55))
.force('charge', d3.forceManyBody().strength(-320))
.force('x', d3.forceX(w / 2).strength(0.04))
.force('y', d3.forceY(h / 2).strength(0.04))
.force('collision', d3.forceCollide(d => radius(d) + 6));
function fit() {
if (!data.nodes.length) return { scale: 1, tx: 0, ty: 0 };
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const n of data.nodes) {
if (typeof n.x !== 'number' || typeof n.y !== 'number') continue;
if (n.x < minX) minX = n.x;
if (n.x > maxX) maxX = n.x;
if (n.y < minY) minY = n.y;
if (n.y > maxY) maxY = n.y;
}
if (!isFinite(minX)) return { scale: 1, tx: 0, ty: 0 };
const bw = Math.max(1, maxX - minX);
const bh = Math.max(1, maxY - minY);
const scale = Math.min((w - pad * 2) / bw, (h - pad * 2) / bh, 3.5);
return {
scale,
tx: (w - bw * scale) / 2 - minX * scale,
ty: (h - bh * scale) / 2 - minY * scale,
};
}
let current = fit();
const link = g.append('g')
.attr('stroke', 'var(--color-text)')
.attr('stroke-opacity', 0.18)
.attr('stroke-width', 1)
.selectAll('line')
.data(data.edges)
.join('line');
const node = g.append('g')
.selectAll('circle')
.data(data.nodes)
.join('circle')
.attr('r', radius)
.attr('fill', d => d.current ? 'var(--color-primary)' : 'var(--color-secondary)')
.attr('fill-opacity', d => d.current ? 1 : 0.8)
.attr('stroke', d => d.current ? 'var(--color-primary)' : 'transparent')
.attr('stroke-width', d => d.current ? 2 : 0)
.attr('stroke-opacity', 0.4)
.style('cursor', 'pointer')
.on('click', (event, d) => { window.location.href = d.url; })
.call(d3.drag()
.on('start', (event, d) => {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x; d.fy = d.y;
})
.on('drag', (event, d) => {
d.fx = (event.x - current.tx) / current.scale;
d.fy = (event.y - current.ty) / current.scale;
})
.on('end', (event, d) => {
if (!event.active) simulation.alphaTarget(0);
d.fx = null; d.fy = null;
}));
const label = g.append('g')
.attr('font-family', 'inherit')
.attr('font-size', '11px')
.attr('fill', 'var(--color-text)')
.attr('text-anchor', 'middle')
.style('paint-order', 'stroke')
.attr('stroke', 'var(--color-bg)')
.attr('stroke-width', 3)
.attr('stroke-linejoin', 'round')
.selectAll('text')
.data(data.nodes)
.join('text')
.text(d => d.title.length > 22 ? d.title.slice(0, 20) + '\u2026' : d.title)
.attr('font-weight', d => d.current ? 700 : 400)
.style('pointer-events', 'none');
function onEnter(event, d) {
const near = neighbors[d.id];
node.attr('fill-opacity', n => near.has(n.id) ? 1 : 0.15)
.attr('stroke', n => n.id === d.id ? 'var(--color-primary)' : (n.current ? 'var(--color-primary)' : 'transparent'))
.attr('stroke-width', n => n.id === d.id ? 2 : (n.current ? 2 : 0))
.attr('stroke-opacity', n => n.id === d.id ? 0.9 : 0.4);
link.attr('stroke-opacity', e => {
const s = typeof e.source === 'object' ? e.source.id : e.source;
const t = typeof e.target === 'object' ? e.target.id : e.target;
return (s === d.id || t === d.id) ? 0.6 : 0.05;
});
label.attr('fill-opacity', n => near.has(n.id) ? 1 : 0.25);
}
function onLeave() {
node.attr('fill-opacity', d => d.current ? 1 : 0.8)
.attr('stroke', d => d.current ? 'var(--color-primary)' : 'transparent')
.attr('stroke-width', d => d.current ? 2 : 0);
link.attr('stroke-opacity', 0.18);
label.attr('fill-opacity', 1);
}
node.on('mouseenter', onEnter).on('mouseleave', onLeave);
simulation.on('tick', () => {
current = fit();
const s = current.scale, tx = current.tx, ty = current.ty;
link
.attr('x1', d => d.source.x * s + tx)
.attr('y1', d => d.source.y * s + ty)
.attr('x2', d => d.target.x * s + tx)
.attr('y2', d => d.target.y * s + ty);
node
.attr('cx', d => d.x * s + tx)
.attr('cy', d => d.y * s + ty);
label
.attr('x', d => d.x * s + tx)
.attr('y', d => d.y * s + ty - radius(d) - 4);
});
}
})();