mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
361 lines
10 KiB
JavaScript
361 lines
10 KiB
JavaScript
/**
|
|
* Link graph (DESIGN.md §12.1): a hand-written force-directed graph of
|
|
* globe.graph(ref), ≤ 40 nodes, on one canvas. Nodes are coloured by
|
|
* altitude band, the root is larger, edges carry their relation on hover.
|
|
* Drag a node to pull the graph; tap a node to open its dossier. Selection
|
|
* is shared with the globe through `select(id)`.
|
|
*/
|
|
|
|
import { BAND_COLOR } from './bands.js';
|
|
import { bandOfKind } from './shim3.js';
|
|
import { reducedMotion } from './dom.js';
|
|
|
|
const KIND_GLYPH = {
|
|
aircraft: '✈',
|
|
helicopter: 'H',
|
|
military: '✈',
|
|
satellite: '◆',
|
|
vessel: '⛴',
|
|
quake: '◎',
|
|
camera: '◉',
|
|
situation: '!',
|
|
place: '⌖',
|
|
moment: '▶',
|
|
track: '·',
|
|
capture: '◐',
|
|
};
|
|
|
|
export function createLinkGraph(host, { onOpen, onSelect } = {}) {
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'lg';
|
|
wrap.innerHTML = `<canvas class="lg-canvas" aria-label="Link graph" role="img"></canvas><div class="lg-tip tele" hidden></div><div class="lg-legend tele"><span data-band="orbit"><i></i>Orbit</span><span data-band="sky"><i></i>Sky</span><span data-band="ground"><i></i>Ground</span><span data-band="inside"><i></i>Inside</span></div>`;
|
|
host.appendChild(wrap);
|
|
const canvas = wrap.querySelector('canvas');
|
|
const tip = wrap.querySelector('.lg-tip');
|
|
const ctx = canvas.getContext('2d');
|
|
let W = 0;
|
|
let H = 0;
|
|
let dpr = 1;
|
|
let nodes = [];
|
|
let edges = [];
|
|
let rootId = null;
|
|
let selected = null;
|
|
let hover = null;
|
|
let alpha = 1;
|
|
let raf = 0;
|
|
let dragN = null;
|
|
let destroyed = false;
|
|
|
|
function resize() {
|
|
const r = wrap.getBoundingClientRect();
|
|
dpr = Math.min(2, window.devicePixelRatio || 1);
|
|
W = r.width;
|
|
H = r.height;
|
|
canvas.width = Math.round(W * dpr);
|
|
canvas.height = Math.round(H * dpr);
|
|
kick(0.3);
|
|
}
|
|
const ro = new ResizeObserver(resize);
|
|
ro.observe(wrap);
|
|
|
|
function set(graph, root) {
|
|
rootId = root;
|
|
const prev = new Map(nodes.map((n) => [n.id, n]));
|
|
const deg = new Map();
|
|
for (const e of graph.edges) {
|
|
deg.set(e.from, (deg.get(e.from) || 0) + 1);
|
|
deg.set(e.to, (deg.get(e.to) || 0) + 1);
|
|
}
|
|
nodes = graph.nodes.slice(0, 40).map((n, i) => {
|
|
const p = prev.get(n.id);
|
|
const a = (i / Math.max(1, graph.nodes.length)) * Math.PI * 2;
|
|
const isRoot = n.id === root;
|
|
return {
|
|
...n,
|
|
band: bandOfKind(n.kind),
|
|
r: isRoot ? 15 : 7 + Math.min(5, (deg.get(n.id) || 1) * 0.9),
|
|
x: p?.x ?? (isRoot ? 0 : Math.cos(a) * 90),
|
|
y: p?.y ?? (isRoot ? 0 : Math.sin(a) * 90),
|
|
vx: 0,
|
|
vy: 0,
|
|
root: isRoot,
|
|
};
|
|
});
|
|
const idx = new Map(nodes.map((n, i) => [n.id, i]));
|
|
edges = graph.edges
|
|
.filter((e) => idx.has(e.from) && idx.has(e.to))
|
|
.map((e) => ({
|
|
...e,
|
|
a: nodes[idx.get(e.from)],
|
|
b: nodes[idx.get(e.to)],
|
|
}));
|
|
kick(1);
|
|
}
|
|
|
|
function kick(a = 0.6) {
|
|
alpha = Math.max(alpha, a);
|
|
if (!raf && !destroyed) raf = requestAnimationFrame(tick);
|
|
}
|
|
|
|
function step() {
|
|
const k = 1;
|
|
// Repulsion
|
|
for (let i = 0; i < nodes.length; i += 1) {
|
|
const a = nodes[i];
|
|
for (let j = i + 1; j < nodes.length; j += 1) {
|
|
const b = nodes[j];
|
|
let dx = b.x - a.x;
|
|
let dy = b.y - a.y;
|
|
let d2 = dx * dx + dy * dy;
|
|
if (d2 < 0.01) {
|
|
dx = Math.random() - 0.5;
|
|
dy = Math.random() - 0.5;
|
|
d2 = 0.5;
|
|
}
|
|
const f = (4200 * k) / d2;
|
|
const d = Math.sqrt(d2);
|
|
a.vx -= (dx / d) * f;
|
|
a.vy -= (dy / d) * f;
|
|
b.vx += (dx / d) * f;
|
|
b.vy += (dy / d) * f;
|
|
}
|
|
}
|
|
// Springs
|
|
for (const e of edges) {
|
|
const dx = e.b.x - e.a.x;
|
|
const dy = e.b.y - e.a.y;
|
|
const d = Math.max(0.01, Math.hypot(dx, dy));
|
|
const rest = e.a.root || e.b.root ? 120 : 80;
|
|
const f = (d - rest) * 0.035;
|
|
e.a.vx += (dx / d) * f;
|
|
e.a.vy += (dy / d) * f;
|
|
e.b.vx -= (dx / d) * f;
|
|
e.b.vy -= (dy / d) * f;
|
|
}
|
|
for (const n of nodes) {
|
|
// Gravity to the centre; the root is pinned there.
|
|
n.vx -= n.x * 0.012;
|
|
n.vy -= n.y * 0.012;
|
|
if (n.root && n !== dragN) {
|
|
n.x *= 0.8;
|
|
n.y *= 0.8;
|
|
n.vx = 0;
|
|
n.vy = 0;
|
|
continue;
|
|
}
|
|
if (n === dragN) continue;
|
|
n.vx *= 0.62;
|
|
n.vy *= 0.62;
|
|
n.x += n.vx * alpha;
|
|
n.y += n.vy * alpha;
|
|
}
|
|
}
|
|
|
|
function view() {
|
|
// Fit the graph into the canvas with padding.
|
|
let minX = -60;
|
|
let maxX = 60;
|
|
let minY = -60;
|
|
let maxY = 60;
|
|
for (const n of nodes) {
|
|
minX = Math.min(minX, n.x - n.r);
|
|
maxX = Math.max(maxX, n.x + n.r);
|
|
minY = Math.min(minY, n.y - n.r);
|
|
maxY = Math.max(maxY, n.y + n.r);
|
|
}
|
|
const pad = 46;
|
|
const s = Math.min(
|
|
2.2,
|
|
(W - pad * 2) / (maxX - minX),
|
|
(H - pad * 2) / (maxY - minY),
|
|
);
|
|
return {
|
|
s,
|
|
cx: W / 2 - ((minX + maxX) / 2) * s,
|
|
cy: H / 2 - ((minY + maxY) / 2) * s,
|
|
};
|
|
}
|
|
let V = { s: 1, cx: 0, cy: 0 };
|
|
const sx = (n) => V.cx + n.x * V.s;
|
|
const sy = (n) => V.cy + n.y * V.s;
|
|
|
|
function draw() {
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
ctx.clearRect(0, 0, W, H);
|
|
V = view();
|
|
const focus = hover || selected || rootId;
|
|
const relFocus = hover || selected;
|
|
const near = new Set();
|
|
if (focus)
|
|
for (const e of edges) {
|
|
if (e.a.id === focus) near.add(e.b.id);
|
|
if (e.b.id === focus) near.add(e.a.id);
|
|
}
|
|
// Edges
|
|
for (const e of edges) {
|
|
const hot = focus && (e.a.id === focus || e.b.id === focus);
|
|
ctx.strokeStyle = hot
|
|
? 'rgba(255,255,255,0.7)'
|
|
: 'rgba(255,255,255,0.16)';
|
|
ctx.lineWidth = hot ? 1.4 : 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(sx(e.a), sy(e.a));
|
|
ctx.lineTo(sx(e.b), sy(e.b));
|
|
ctx.stroke();
|
|
if (hot && e.relation && relFocus) {
|
|
const mx = (sx(e.a) + sx(e.b)) / 2;
|
|
const my = (sy(e.a) + sy(e.b)) / 2;
|
|
ctx.font = '600 9px "JetBrains Mono", ui-monospace, monospace';
|
|
const w = ctx.measureText(e.relation.toUpperCase()).width;
|
|
ctx.fillStyle = 'rgba(8,10,14,0.85)';
|
|
ctx.fillRect(mx - w / 2 - 4, my - 7, w + 8, 14);
|
|
ctx.fillStyle = 'rgba(255,255,255,0.75)';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(e.relation.toUpperCase(), mx, my + 3);
|
|
}
|
|
}
|
|
// Nodes
|
|
for (const n of nodes) {
|
|
const x = sx(n);
|
|
const y = sy(n);
|
|
const col = BAND_COLOR[n.band] || '#fff';
|
|
const dim = focus && focus !== n.id && !near.has(n.id) && !n.root;
|
|
ctx.globalAlpha = dim ? 0.35 : 1;
|
|
if (n.id === selected || n.root) {
|
|
ctx.fillStyle = `${col}33`;
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, n.r + 7, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
ctx.fillStyle = n.root ? col : '#0b0d11';
|
|
ctx.strokeStyle = col;
|
|
ctx.lineWidth = n.root ? 0 : 2;
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, n.r, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
if (!n.root) ctx.stroke();
|
|
ctx.fillStyle = n.root ? '#0b0d11' : col;
|
|
ctx.font = `700 ${n.root ? 13 : 9}px system-ui, sans-serif`;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(KIND_GLYPH[n.kind] || '•', x, y + 0.5);
|
|
ctx.textBaseline = 'alphabetic';
|
|
const showLabel =
|
|
n.root || n.id === focus || near.has(n.id) || nodes.length <= 14;
|
|
if (showLabel) {
|
|
const short = n.root ? n.title : String(n.title).split(' · ')[0];
|
|
const label = short.length > 22 ? `${short.slice(0, 21)}…` : short;
|
|
ctx.font = `${n.root ? 650 : 550} ${n.root ? 12 : 10.5}px system-ui, sans-serif`;
|
|
ctx.fillStyle = n.root ? '#fff' : 'rgba(235,241,250,0.85)';
|
|
ctx.fillText(label, x, y + n.r + 13);
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
}
|
|
|
|
function tick() {
|
|
raf = 0;
|
|
if (destroyed) return;
|
|
const steps = reducedMotion() ? 60 : alpha > 0.4 ? 6 : 2;
|
|
for (let i = 0; i < steps && alpha > 0.01; i += 1) {
|
|
step();
|
|
alpha *= reducedMotion() ? 0.92 : 0.985;
|
|
}
|
|
draw();
|
|
if (alpha > 0.01 || dragN) raf = requestAnimationFrame(tick);
|
|
}
|
|
|
|
function hit(e) {
|
|
const r = canvas.getBoundingClientRect();
|
|
const px = e.clientX - r.left;
|
|
const py = e.clientY - r.top;
|
|
let best = null;
|
|
let bd = 22;
|
|
for (const n of nodes) {
|
|
const d = Math.hypot(sx(n) - px, sy(n) - py) - n.r;
|
|
if (d < bd) {
|
|
bd = d;
|
|
best = n;
|
|
}
|
|
}
|
|
return { n: best, px, py };
|
|
}
|
|
|
|
let down = null;
|
|
canvas.addEventListener('pointerdown', (e) => {
|
|
const { n, px, py } = hit(e);
|
|
down = { n, px, py, moved: false };
|
|
if (n) {
|
|
dragN = n;
|
|
canvas.setPointerCapture(e.pointerId);
|
|
}
|
|
});
|
|
canvas.addEventListener('pointermove', (e) => {
|
|
if (down?.n) {
|
|
const r = canvas.getBoundingClientRect();
|
|
const px = e.clientX - r.left;
|
|
const py = e.clientY - r.top;
|
|
if (Math.hypot(px - down.px, py - down.py) > 4) down.moved = true;
|
|
if (down.moved) {
|
|
down.n.x = (px - V.cx) / V.s;
|
|
down.n.y = (py - V.cy) / V.s;
|
|
kick(0.4);
|
|
}
|
|
return;
|
|
}
|
|
if (e.pointerType === 'mouse') {
|
|
const { n } = hit(e);
|
|
const id = n?.id || null;
|
|
if (id !== hover) {
|
|
hover = id;
|
|
canvas.style.cursor = id ? 'pointer' : 'default';
|
|
if (n) {
|
|
tip.hidden = false;
|
|
tip.textContent = `${n.kind.toUpperCase()} · ${n.title}`;
|
|
tip.style.transform = `translate(${Math.round(sx(n))}px, ${Math.round(sy(n) - n.r - 30)}px) translateX(-50%)`;
|
|
} else tip.hidden = true;
|
|
draw();
|
|
}
|
|
}
|
|
});
|
|
const up = () => {
|
|
if (!down) return;
|
|
const d = down;
|
|
down = null;
|
|
dragN = null;
|
|
if (d.n && !d.moved) {
|
|
selected = d.n.id;
|
|
draw();
|
|
onSelect?.(d.n);
|
|
if (!d.n.root) onOpen?.(d.n);
|
|
}
|
|
};
|
|
canvas.addEventListener('pointerup', up);
|
|
canvas.addEventListener('pointercancel', up);
|
|
canvas.addEventListener('pointerleave', () => {
|
|
if (hover) {
|
|
hover = null;
|
|
tip.hidden = true;
|
|
draw();
|
|
}
|
|
});
|
|
|
|
return {
|
|
set,
|
|
select(id) {
|
|
selected = id;
|
|
draw();
|
|
},
|
|
has: (id) => nodes.some((n) => n.id === id),
|
|
get size() {
|
|
return nodes.length;
|
|
},
|
|
destroy() {
|
|
destroyed = true;
|
|
cancelAnimationFrame(raf);
|
|
ro.disconnect();
|
|
wrap.remove();
|
|
},
|
|
};
|
|
}
|