/** * 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 = `
OrbitSkyGroundInside
`; 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(); }, }; }