{% extends "base.html" %}
{% set active_tab = "graph" %}
{% block title %}Knowledge Graph - Second Brain{% endblock %}
{% block content_class %}browsing{% endblock %}
{% block content %}
<div class="graph-container" id="graphContainer">
<div class="graph-header">
<h2>Knowledge Graph</h2>
<div class="graph-controls">
<button id="resetView" class="btn btn-view">Reset View</button>
</div>
</div>
<div class="graph-legend">
<div class="legend-item">
<span class="legend-node"></span>
<span>Page</span>
</div>
<div class="legend-item">
<span class="legend-edge"></span>
<span>Link</span>
</div>
<div class="legend-item legend-hint">
Scroll to zoom · Drag nodes to arrange · Click nodes to focus
</div>
</div>
<div id="graphSvg" class="graph-svg"></div>
<div class="graph-tooltip" id="tooltip"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
<script>
const tooltip = document.getElementById('tooltip');
// Load graph data
fetch('/api/graph')
.then(r => r.json())
.then(data => {
const width = document.getElementById('graphContainer').clientWidth;
const height = Math.max(window.innerHeight - 200, 500);
const svg = d3.select('#graphSvg')
.append('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', [0, 0, width, height]);
// Zoom behavior
const g = svg.append('g');
const zoom = d3.zoom()
.scaleExtent([0.3, 4])
.on('zoom', (event) => {
g.attr('transform', event.transform);
});
svg.call(zoom);
// Prepare nodes and links
const nodes = data.nodes.map(d => ({...d}));
const links = data.edges.map(d => ({...d}));
// Create force simulation
const simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(150))
.force('charge', d3.forceManyBody().strength(-400))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(30));
// Draw links
const link = g.append('g')
.selectAll('line')
.data(links)
.join('line')
.attr('class', 'graph-link')
.attr('stroke', 'var(--border)')
.attr('stroke-width', 1.5);
// Draw nodes
const node = g.append('g')
.selectAll('g')
.data(nodes)
.join('g')
.attr('class', 'graph-node')
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended));
// Node circles
node.append('circle')
.attr('class', 'graph-node-circle')
.attr('r', 8)
.attr('fill', d => getNodeColor(d))
.attr('stroke', 'var(--bg-secondary)')
.attr('stroke-width', 2);
// Node labels
node.append('text')
.attr('class', 'graph-node-label')
.attr('dx', 14)
.attr('dy', 4)
.text(d => d.title)
.style('fill', 'var(--text-primary)')
.style('font-size', '11px')
.style('pointer-events', 'none');
// Node interactions
node.on('mouseover', function(event, d) {
d3.select(this).select('circle')
.transition().duration(200)
.attr('r', 12)
.attr('stroke', 'var(--accent)');
// Show tooltip
tooltip.innerHTML = `
<strong>${d.title}</strong>
${d.tags.length ? `<br>Tags: ${d.tags.join(', ')}` : ''}
<br><small>${(d.size / 1024).toFixed(1)} KB</small>
`;
tooltip.style.opacity = 1;
})
.on('mousemove', function(event) {
tooltip.style.left = (event.pageX + 15) + 'px';
tooltip.style.top = (event.pageY - 10) + 'px';
})
.on('mouseout', function() {
d3.select(this).select('circle')
.transition().duration(200)
.attr('r', 8)
.attr('stroke', 'var(--bg-secondary)');
tooltip.style.opacity = 0;
})
.on('click', function(event, d) {
window.location.href = `/wiki/${d.id}`;
});
// Link interactions
link.on('mouseover', function(event, d) {
d3.select(this).attr('stroke', 'var(--accent)').attr('stroke-width', 2.5);
})
.on('mouseout', function() {
d3.select(this).attr('stroke', 'var(--border)').attr('stroke-width', 1.5);
});
// Simulation tick
simulation.on('tick', () => {
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
node.attr('transform', d => `translate(${d.x},${d.y})`);
});
// Reset view button
document.getElementById('resetView').addEventListener('click', () => {
svg.transition().duration(750).call(
zoom.transform,
d3.zoomIdentity
);
});
// Drag functions
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
// Color function based on tags
function getNodeColor(d) {
const colorMap = {
'project': '#3b82f6',
'daily': '#22c55e',
'system': '#f59e0b',
'reference': '#8b5cf6',
'default': '#6b7280',
};
for (const tag of d.tags) {
const lower = tag.toLowerCase();
if (lower in colorMap) return colorMap[lower];
}
return colorMap['default'];
}
})
.catch(err => {
document.getElementById('graphSvg').innerHTML = `
<div class="error-state">
<p>Failed to load graph data: ${err.message}</p>
</div>
`;
});
</script>
{% endblock %}