mirror of
https://github.com/RichieCahill/dotfiles.git
synced 2026-04-17 13:08:19 -04:00
199 lines
7.8 KiB
HTML
199 lines
7.8 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Relationship Graph{% endblock %}
|
|
{% block content %}
|
|
<div class="graph-container">
|
|
<div class="header">
|
|
<h1>Relationship Graph</h1>
|
|
</div>
|
|
<p class="graph-hint">Drag nodes to reposition. Closer relationships have shorter, darker edges.</p>
|
|
<canvas id="graph-canvas" width="900" height="600"
|
|
style="border: 1px solid var(--color-border); border-radius: 8px; background: var(--color-bg); cursor: grab;">
|
|
</canvas>
|
|
<div id="selected-info"></div>
|
|
<div class="legend">
|
|
<h4>Relationship Closeness (1-10)</h4>
|
|
<div class="legend-items">
|
|
<div class="legend-item">
|
|
<span class="legend-line" style="background: hsl(220, 70%, 40%); height: 4px; display: inline-block;"></span>
|
|
<span>10 - Very Close (Spouse, Partner)</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<span class="legend-line" style="background: hsl(220, 70%, 52%); height: 3px; display: inline-block;"></span>
|
|
<span>7 - Close (Family, Best Friend)</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<span class="legend-line" style="background: hsl(220, 70%, 64%); height: 2px; display: inline-block;"></span>
|
|
<span>4 - Moderate (Friend, Colleague)</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<span class="legend-line" style="background: hsl(220, 70%, 72%); height: 1px; display: inline-block;"></span>
|
|
<span>2 - Distant (Acquaintance)</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function() {
|
|
const RELATIONSHIP_DISPLAY = {{ relationship_type_display|tojson }};
|
|
const graphData = {{ graph_data|tojson }};
|
|
|
|
const canvas = document.getElementById('graph-canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
const width = canvas.width;
|
|
const height = canvas.height;
|
|
const centerX = width / 2;
|
|
const centerY = height / 2;
|
|
|
|
const nodes = graphData.nodes.map(function(node) {
|
|
return Object.assign({}, node, {
|
|
x: centerX + (Math.random() - 0.5) * 300,
|
|
y: centerY + (Math.random() - 0.5) * 300,
|
|
vx: 0,
|
|
vy: 0
|
|
});
|
|
});
|
|
|
|
const nodeMap = new Map(nodes.map(function(node) { return [node.id, node]; }));
|
|
|
|
const edges = graphData.edges.map(function(edge) {
|
|
const sourceNode = nodeMap.get(edge.source);
|
|
const targetNode = nodeMap.get(edge.target);
|
|
if (!sourceNode || !targetNode) return null;
|
|
return Object.assign({}, edge, { sourceNode: sourceNode, targetNode: targetNode });
|
|
}).filter(function(edge) { return edge !== null; });
|
|
|
|
let dragNode = null;
|
|
let selectedNode = null;
|
|
|
|
const repulsion = 5000;
|
|
const springStrength = 0.05;
|
|
const baseSpringLength = 150;
|
|
const damping = 0.9;
|
|
const centerPull = 0.01;
|
|
|
|
function simulate() {
|
|
for (const node of nodes) { node.vx = 0; node.vy = 0; }
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
for (let j = i + 1; j < nodes.length; j++) {
|
|
const dx = nodes[j].x - nodes[i].x;
|
|
const dy = nodes[j].y - nodes[i].y;
|
|
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
const force = repulsion / (dist * dist);
|
|
const fx = (dx / dist) * force;
|
|
const fy = (dy / dist) * force;
|
|
nodes[i].vx -= fx; nodes[i].vy -= fy;
|
|
nodes[j].vx += fx; nodes[j].vy += fy;
|
|
}
|
|
}
|
|
for (const edge of edges) {
|
|
const dx = edge.targetNode.x - edge.sourceNode.x;
|
|
const dy = edge.targetNode.y - edge.sourceNode.y;
|
|
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
const normalizedWeight = edge.closeness_weight / 10;
|
|
const idealLength = baseSpringLength * (1.5 - normalizedWeight);
|
|
const displacement = dist - idealLength;
|
|
const force = springStrength * displacement;
|
|
const fx = (dx / dist) * force;
|
|
const fy = (dy / dist) * force;
|
|
edge.sourceNode.vx += fx; edge.sourceNode.vy += fy;
|
|
edge.targetNode.vx -= fx; edge.targetNode.vy -= fy;
|
|
}
|
|
for (const node of nodes) {
|
|
node.vx += (centerX - node.x) * centerPull;
|
|
node.vy += (centerY - node.y) * centerPull;
|
|
}
|
|
for (const node of nodes) {
|
|
if (node === dragNode) continue;
|
|
node.x += node.vx * damping;
|
|
node.y += node.vy * damping;
|
|
node.x = Math.max(30, Math.min(width - 30, node.x));
|
|
node.y = Math.max(30, Math.min(height - 30, node.y));
|
|
}
|
|
}
|
|
|
|
function getEdgeColor(weight) {
|
|
const normalized = weight / 10;
|
|
return 'hsl(220, 70%, ' + (80 - normalized * 40) + '%)';
|
|
}
|
|
|
|
function draw() {
|
|
ctx.clearRect(0, 0, width, height);
|
|
for (const edge of edges) {
|
|
const lineWidth = 1 + (edge.closeness_weight / 10) * 3;
|
|
ctx.strokeStyle = getEdgeColor(edge.closeness_weight);
|
|
ctx.lineWidth = lineWidth;
|
|
ctx.beginPath();
|
|
ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y);
|
|
ctx.lineTo(edge.targetNode.x, edge.targetNode.y);
|
|
ctx.stroke();
|
|
const midX = (edge.sourceNode.x + edge.targetNode.x) / 2;
|
|
const midY = (edge.sourceNode.y + edge.targetNode.y) / 2;
|
|
ctx.fillStyle = '#666';
|
|
ctx.font = '10px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
const label = RELATIONSHIP_DISPLAY[edge.relationship_type] || edge.relationship_type;
|
|
ctx.fillText(label, midX, midY - 5);
|
|
}
|
|
for (const node of nodes) {
|
|
const isSelected = node === selectedNode;
|
|
const radius = isSelected ? 25 : 20;
|
|
ctx.beginPath();
|
|
ctx.arc(node.x, node.y, radius, 0, Math.PI * 2);
|
|
ctx.fillStyle = isSelected ? '#0066cc' : '#fff';
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#0066cc';
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
ctx.fillStyle = isSelected ? '#fff' : '#333';
|
|
ctx.font = '12px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
const name = node.name.length > 10 ? node.name.slice(0, 9) + '\u2026' : node.name;
|
|
ctx.fillText(name, node.x, node.y);
|
|
}
|
|
}
|
|
|
|
function animate() {
|
|
simulate();
|
|
draw();
|
|
requestAnimationFrame(animate);
|
|
}
|
|
animate();
|
|
|
|
function getNodeAt(x, y) {
|
|
for (const node of nodes) {
|
|
const dx = x - node.x;
|
|
const dy = y - node.y;
|
|
if (dx * dx + dy * dy < 400) return node;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
canvas.addEventListener('mousedown', function(event) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const node = getNodeAt(event.clientX - rect.left, event.clientY - rect.top);
|
|
if (node) {
|
|
dragNode = node;
|
|
selectedNode = node;
|
|
const infoDiv = document.getElementById('selected-info');
|
|
let html = '<div class="selected-info"><h3>' + node.name + '</h3>';
|
|
if (node.current_job) html += '<p>Job: ' + node.current_job + '</p>';
|
|
html += '<a href="/contacts/' + node.id + '">View details</a></div>';
|
|
infoDiv.innerHTML = html;
|
|
}
|
|
});
|
|
|
|
canvas.addEventListener('mousemove', function(event) {
|
|
if (!dragNode) return;
|
|
const rect = canvas.getBoundingClientRect();
|
|
dragNode.x = event.clientX - rect.left;
|
|
dragNode.y = event.clientY - rect.top;
|
|
});
|
|
|
|
canvas.addEventListener('mouseup', function() { dragNode = null; });
|
|
canvas.addEventListener('mouseleave', function() { dragNode = null; });
|
|
})();
|
|
</script>
|
|
{% endblock %}
|