// ThoughtGraph: force-directed canvas with drag, zoom, click, multi-select const { useEffect, useRef, useState, useCallback } = React; const NODE_COLORS = { thesis: '#00d4aa', ticker: '#e8e8ed', catalyst: '#ffaa33', source: '#55556a', }; const NODE_RADIUS = { thesis: 44, ticker: 26, catalyst: 22, source: 12, }; function ThoughtGraph({ nodes: seedNodes, edges, selected, setSelected, focused, setFocused, hoveredEdgeKind, onShortcut, webId, onConnect }) { const canvasRef = useRef(null); const wrapRef = useRef(null); const stateRef = useRef({ nodes: seedNodes.map(n => ({ ...n, vx: 0, vy: 0, x: n.x ?? (Math.random() * 400 - 200), y: n.y ?? (Math.random() * 400 - 200), })), edges, cam: { x: 0, y: 0, k: 1 }, drag: null, pan: null, hover: null, pointer: null, alpha: 1, // simulation energy — cools to 0 like d3-force / Obsidian alphaTarget: 0, ticks: 0, }); const [, force] = useState(0); // Track which web we last laid out for. When this flips, we DON'T carry // over positions from the prior web — webs share node IDs (e.g. `t_hypo`, // `t1`) and inheriting positions across web boundaries was sending the // new web's nodes far off-screen. Fresh layout per web. const lastWebIdRef = useRef(webId); // Compute a camera that fits the web inside the visible canvas with small // padding, hypothesis-centered if there is one. // // For dense webs (Mad Max sweeps with 100-200+ nodes): we DON'T fit the // full bbox — it would zoom so far out that every node becomes a dot. // Instead we fit to the dense core around the hypothesis (nearest 60% // of nodes by distance from the hypothesis). Outliers stay reachable by // panning, but the initial view is tight and readable. const fitCameraToNodes = (nodesArr) => { const canvas = canvasRef.current; const W = (canvas?.clientWidth) || 800; const H = ((canvas?.clientHeight) || 600) - 60; // legend + hint if (!nodesArr || !nodesArr.length) return { x: 0, y: 0, k: 1 }; const anchor = nodesArr.find(n => n.isHypothesis) || nodesArr.find(n => n.id === 't_hypo') || nodesArr[0]; // For large webs, only fit the densest 60% of nodes (closest to anchor) // so the camera doesn't zoom out to chase outliers. const sorted = [...nodesArr].sort((a, b) => { const da = (a.x - anchor.x) ** 2 + (a.y - anchor.y) ** 2; const db = (b.x - anchor.x) ** 2 + (b.y - anchor.y) ** 2; return da - db; }); const fitCount = nodesArr.length > 30 ? Math.max(20, Math.round(nodesArr.length * 0.6)) : nodesArr.length; const fitNodes = sorted.slice(0, fitCount); let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const n of fitNodes) { const r = NODE_RADIUS[n.type] || 12; minX = Math.min(minX, n.x - r); minY = Math.min(minY, n.y - r); maxX = Math.max(maxX, n.x + r); maxY = Math.max(maxY, n.y + r); } const w = Math.max(1, maxX - minX); const h = Math.max(1, maxY - minY); const pad = 80; const kRaw = Math.min((W - pad * 2) / w, (H - pad * 2) / h); // Hold zoom in a readable band — never zoom in past 1.0× (no magnify) // and never below 0.45× (so 200-node webs stay legible, not dust). const k = Math.max(0.45, Math.min(1.0, kRaw)); return { x: -anchor.x * k, y: -anchor.y * k, k }; }; // Sync stateRef when web swaps (seedNodes/edges identity changes) useEffect(() => { const s = stateRef.current; const isWebSwitch = (webId !== undefined) && (webId !== lastWebIdRef.current); lastWebIdRef.current = webId; // On web switch, throw away the prior web's positions. Otherwise // (intra-web edits, Mad Max additions, etc.) keep them so the user's // hand-tuned layout doesn't reset on every node mutation. const prevById = isWebSwitch ? new Map() : new Map(s.nodes.map(n => [n.id, n])); s.nodes = seedNodes.map((n, i) => { const prev = prevById.get(n.id); if (prev) return { ...n, vx: prev.vx, vy: prev.vy, x: prev.x, y: prev.y }; // For new nodes (or all nodes after a web switch): respect the node's // own stored x/y if present — that's the seed/localStorage layout — // and fall back to a deterministic ring otherwise. const angle = (i / Math.max(1, seedNodes.length)) * Math.PI * 2 + Math.random() * 0.4; const radius = 220 + Math.random() * 80; return { ...n, vx: 0, vy: 0, x: typeof n.x === 'number' ? n.x : Math.cos(angle) * radius, y: typeof n.y === 'number' ? n.y : Math.sin(angle) * radius, }; }); s.edges = edges; s.ticks = 0; if (isWebSwitch) { // Fresh web: don't perturb the loaded layout. Cold alpha + zero // velocities so the physics doesn't drag anything around — the seed // positions are intentional. Drag/hover state from the previous web // is also cleared so a half-finished interaction doesn't carry over. s.alpha = 0; s.alphaTarget = 0; for (const n of s.nodes) { n.vx = 0; n.vy = 0; } s.drag = null; s.pan = null; s.hover = null; s.cam = fitCameraToNodes(s.nodes); } else { // Intra-web update (Mad Max added nodes, user edited, etc.) — re-heat // alpha so the new nodes find their place. Keep current camera. s.alpha = Math.max(s.alpha, 0.6); } force(v => v + 1); }, [seedNodes, edges, webId]); // Physics + render loop useEffect(() => { const canvas = canvasRef.current; const ctx = canvas.getContext('2d'); let raf; const resize = () => { const rect = wrapRef.current.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; canvas.style.width = rect.width + 'px'; canvas.style.height = rect.height + 'px'; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }; resize(); const ro = new ResizeObserver(resize); ro.observe(wrapRef.current); const tick = () => { const s = stateRef.current; const W = canvas.clientWidth, H = canvas.clientHeight; const cam = s.cam; s.ticks++; // ─── Obsidian-style cooling simulation ─── // alpha decays from 1 → 0 then sticks. While dragging we keep it warm. // Below alphaMin we still render but skip physics entirely (graph is settled, no drift). const alphaDecay = 0.012; // ~1 / (1/0.012) ≈ 83 ticks to cool const alphaMin = 0.005; if (s.drag) s.alpha = Math.max(s.alpha, 0.4); const settled = s.alpha < alphaMin; if (!settled) s.alpha += (s.alphaTarget - s.alpha) * alphaDecay; if (!settled) { const a = s.alpha; // Center pull — very gentle, scaled by alpha so it doesn't trickle when settled const centerK = 0.012 * a; for (const n of s.nodes) { if (s.drag && s.drag.id === n.id) continue; n.vx += (-n.x) * centerK; n.vy += (-n.y) * centerK; } // Repulsion — capped, linear falloff (not 1/r² which causes the "flying") // Only acts within a local neighborhood, like Obsidian's many-body force. const repulseRange = 240; const repulseStrength = 50 * a; for (let i = 0; i < s.nodes.length; i++) { for (let j = i + 1; j < s.nodes.length; j++) { const A = s.nodes[i], B = s.nodes[j]; const dx = B.x - A.x, dy = B.y - A.y; const d2 = dx*dx + dy*dy + 0.01; const d = Math.sqrt(d2); if (d < repulseRange) { // Linear falloff: full strength at distance 0, zero at repulseRange const f = repulseStrength * (1 - d / repulseRange) / d; const fx = dx * f; const fy = dy * f; if (!(s.drag && s.drag.id === A.id)) { A.vx -= fx; A.vy -= fy; } if (!(s.drag && s.drag.id === B.id)) { B.vx += fx; B.vy += fy; } } // Hard collision (always on, even when settled — but applied positionally so no momentum) const ra = NODE_RADIUS[A.type], rb = NODE_RADIUS[B.type]; const minD = ra + rb + 6; if (d < minD) { const overlap = (minD - d) / 2; const ox = (dx/d) * overlap; const oy = (dy/d) * overlap; if (!(s.drag && s.drag.id === A.id)) { A.x -= ox; A.y -= oy; } if (!(s.drag && s.drag.id === B.id)) { B.x += ox; B.y += oy; } } } } // Spring edges — gentle, alpha-scaled const springK = 0.08 * a; const restLength = 130; for (const e of s.edges) { const A = s.nodes.find(n => n.id === e.from); const B = s.nodes.find(n => n.id === e.to); if (!A || !B) continue; const dx = B.x - A.x, dy = B.y - A.y; const d = Math.sqrt(dx*dx+dy*dy) + 0.01; const f = (d - restLength) * springK; const fx = (dx/d) * f, fy = (dy/d) * f; if (!(s.drag && s.drag.id === A.id)) { A.vx += fx; A.vy += fy; } if (!(s.drag && s.drag.id === B.id)) { B.vx -= fx; B.vy -= fy; } } // Integrate with strong velocity damping (Obsidian-feel) const damping = 0.4; // d3-force default; very draggy → no flying for (const n of s.nodes) { if (s.drag && s.drag.id === n.id) { n.vx = 0; n.vy = 0; continue; } n.vx *= damping; n.vy *= damping; n.x += n.vx; n.y += n.vy; } } else { // Settled — make sure leftover velocity doesn't keep nudging things for (const n of s.nodes) { n.vx = 0; n.vy = 0; } } // Render ctx.clearRect(0, 0, W, H); // Grid lines ctx.save(); const gridSize = 80 * cam.k; const ox = ((W/2 + cam.x) % gridSize + gridSize) % gridSize; const oy = ((H/2 + cam.y) % gridSize + gridSize) % gridSize; ctx.strokeStyle = 'rgba(30, 30, 40, 0.6)'; ctx.lineWidth = 1; ctx.beginPath(); for (let x = ox; x < W; x += gridSize) { ctx.moveTo(x, 0); ctx.lineTo(x, H); } for (let y = oy; y < H; y += gridSize) { ctx.moveTo(0, y); ctx.lineTo(W, y); } ctx.stroke(); ctx.restore(); // World transform ctx.save(); ctx.translate(W/2 + cam.x, H/2 + cam.y); ctx.scale(cam.k, cam.k); // Edges for (const e of s.edges) { const a = s.nodes.find(n => n.id === e.from); const b = s.nodes.find(n => n.id === e.to); if (!a || !b) continue; const isFocusedEdge = focused && (focused === a.id || focused === b.id); const isContradict = e.kind === 'contradicts'; const baseColor = isContradict ? 'rgba(255, 68, 102, ALPHA)' : 'rgba(0, 212, 170, ALPHA)'; const dim = isContradict ? 0.16 : 0.10; const lit = isContradict ? 0.6 : 0.55; ctx.strokeStyle = baseColor.replace('ALPHA', isFocusedEdge ? lit : dim); ctx.lineWidth = isFocusedEdge ? 1.4 : 1; if (isContradict) ctx.setLineDash([5, 4]); else ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); if (isFocusedEdge) { // Edge label const mx = (a.x + b.x) / 2; const my = (a.y + b.y) / 2; ctx.setLineDash([]); ctx.font = '600 9px "JetBrains Mono", monospace'; const labelText = e.kind.toUpperCase(); const w = ctx.measureText(labelText).width + 14; ctx.fillStyle = '#0a0a0c'; ctx.fillRect(mx - w/2, my - 8, w, 16); ctx.strokeStyle = isContradict ? '#ff4466' : '#00d4aa'; ctx.lineWidth = 1; ctx.strokeRect(mx - w/2, my - 8, w, 16); ctx.fillStyle = isContradict ? '#ff4466' : '#00d4aa'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(labelText, mx, my); } } ctx.setLineDash([]); // Tether (Shift-drag connect) — dashed line from source node to // current cursor world-position, with a target ring when hovering on // a different node so the user knows where the edge will land. if (s.tether) { const from = s.nodes.find(nn => nn.id === s.tether.fromId); if (from) { ctx.save(); ctx.strokeStyle = '#00d4aa'; ctx.lineWidth = 1.6; ctx.setLineDash([6, 4]); ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.lineTo(s.tether.mx, s.tether.my); ctx.stroke(); ctx.setLineDash([]); // Mouse-end dot ctx.fillStyle = '#00d4aa'; ctx.beginPath(); ctx.arc(s.tether.mx, s.tether.my, 4, 0, Math.PI * 2); ctx.fill(); // Highlight ring on the snap target (if we're over a node) if (s.tether.targetId) { const tgt = s.nodes.find(nn => nn.id === s.tether.targetId); if (tgt) { const tr = (NODE_RADIUS[tgt.type] || 18) + 8; ctx.strokeStyle = '#00d4aa'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(tgt.x, tgt.y, tr, 0, Math.PI * 2); ctx.stroke(); } } ctx.restore(); } } // Nodes for (const n of s.nodes) { const r = NODE_RADIUS[n.type]; const color = NODE_COLORS[n.type]; const isSelected = selected.includes(n.id); const isFocused = focused === n.id; const isHover = s.hover === n.id; // Glow for focused/selected if (isSelected || isFocused) { const grad = ctx.createRadialGradient(n.x, n.y, r, n.x, n.y, r + 28); grad.addColorStop(0, color + '40'); grad.addColorStop(1, color + '00'); ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(n.x, n.y, r + 28, 0, Math.PI * 2); ctx.fill(); } // Render per-type — flat shapes, no glassy fills if (n.type === 'thesis') { // Hollow ring with accent + dark core ctx.fillStyle = '#0a0a0c'; ctx.beginPath(); ctx.arc(n.x, n.y, r, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = color; ctx.lineWidth = isFocused || isHover ? 2 : 1.4; ctx.beginPath(); ctx.arc(n.x, n.y, r, 0, Math.PI * 2); ctx.stroke(); // Hypothesis node: distinguish with a doubled ring + status tint if (n.isHypothesis) { const statusColor = n.hypothesisStatus === 'yes' ? '#00d4aa' : n.hypothesisStatus === 'no' ? '#ff4466' : n.hypothesisStatus === 'undecided' ? '#d4b800' : '#00d4aa'; ctx.strokeStyle = statusColor; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(n.x, n.y, r + 5, 0, Math.PI * 2); ctx.stroke(); // Tiny status pip at top ctx.fillStyle = statusColor; ctx.beginPath(); ctx.arc(n.x, n.y - r - 5, 3, 0, Math.PI * 2); ctx.fill(); } // Tiny inner dot ctx.fillStyle = color; ctx.beginPath(); ctx.arc(n.x, n.y - r + 8, 2, 0, Math.PI * 2); ctx.fill(); } else if (n.type === 'ticker') { // Square tag with monospace symbol const half = r; ctx.fillStyle = isFocused || isHover ? color : '#16161c'; ctx.fillRect(n.x - half, n.y - half + 4, half * 2, half * 2 - 8); ctx.strokeStyle = color; ctx.lineWidth = 1.2; ctx.strokeRect(n.x - half, n.y - half + 4, half * 2, half * 2 - 8); } else if (n.type === 'catalyst') { // Diamond ctx.fillStyle = '#0a0a0c'; ctx.beginPath(); ctx.moveTo(n.x, n.y - r); ctx.lineTo(n.x + r, n.y); ctx.lineTo(n.x, n.y + r); ctx.lineTo(n.x - r, n.y); ctx.closePath(); ctx.fill(); ctx.strokeStyle = color; ctx.lineWidth = isFocused || isHover ? 2 : 1.4; ctx.stroke(); } else if (n.type === 'source') { // Small filled dot ctx.fillStyle = isFocused || isHover ? '#e8e8ed' : color; ctx.beginPath(); ctx.arc(n.x, n.y, r, 0, Math.PI * 2); ctx.fill(); } // Selection ring (outer corner-marks) if (isSelected) { ctx.strokeStyle = '#00d4aa'; ctx.lineWidth = 1.4; const cr = r + 8; const cl = 5; // 4 corner ticks ctx.beginPath(); // TL ctx.moveTo(n.x - cr, n.y - cr + cl); ctx.lineTo(n.x - cr, n.y - cr); ctx.lineTo(n.x - cr + cl, n.y - cr); // TR ctx.moveTo(n.x + cr - cl, n.y - cr); ctx.lineTo(n.x + cr, n.y - cr); ctx.lineTo(n.x + cr, n.y - cr + cl); // BR ctx.moveTo(n.x + cr, n.y + cr - cl); ctx.lineTo(n.x + cr, n.y + cr); ctx.lineTo(n.x + cr - cl, n.y + cr); // BL ctx.moveTo(n.x - cr + cl, n.y + cr); ctx.lineTo(n.x - cr, n.y + cr); ctx.lineTo(n.x - cr, n.y + cr - cl); ctx.stroke(); } // Label inside ticker if (n.type === 'ticker') { ctx.fillStyle = isFocused || isHover ? '#0a0a0c' : color; // Auto-fit symbol inside the bubble const maxW = (r * 2) - 12; let fontPx = 12; ctx.font = `700 ${fontPx}px "JetBrains Mono", monospace`; while (ctx.measureText(n.label).width > maxW && fontPx > 8) { fontPx -= 0.5; ctx.font = `700 ${fontPx}px "JetBrains Mono", monospace`; } ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(n.label, n.x, n.y + 1); } else if (n.type === 'thesis') { // Simple, robust wrap inside the ring. Max 3 lines, ellipsize last. ctx.fillStyle = '#e8e8ed'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const maxLines = 3; // Effective width: a bit less than diameter so words don't kiss the ring const maxW = r * 1.55; // Pick font size based on label length const len = (n.label || '').length; const fontPx = len > 36 ? 9.5 : len > 24 ? 10 : 10.5; ctx.font = `500 ${fontPx}px "DM Sans", sans-serif`; const lineH = fontPx * 1.2; const words = (n.label || '').split(/\s+/).filter(Boolean); const lines = []; let line = ''; for (let i = 0; i < words.length && lines.length < maxLines; i++) { const w = words[i]; const test = line ? line + ' ' + w : w; if (ctx.measureText(test).width > maxW && line) { lines.push(line); line = w; } else { line = test; } } if (line && lines.length < maxLines) lines.push(line); // If we dropped words, ellipsize last line const consumed = lines.join(' ').split(/\s+/).filter(Boolean).length; if (consumed < words.length && lines.length) { let s = lines[lines.length - 1]; while (ctx.measureText(s + '…').width > maxW && s.length > 0) s = s.slice(0, -1); lines[lines.length - 1] = s + '…'; } const totalH = lines.length * lineH; lines.forEach((l, i) => { const y = n.y - totalH/2 + lineH/2 + i * lineH; ctx.fillText(l, n.x, y); }); } // External label below (catalyst & source) const drawExternalLabel = (text, color, font, maxLines = 2) => { ctx.fillStyle = color; ctx.font = font; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; const maxW = 140; const words = (text || '').split(/\s+/).filter(Boolean); const lines = []; let line = ''; for (let i = 0; i < words.length && lines.length < maxLines; i++) { const w = words[i]; const test = line ? line + ' ' + w : w; if (ctx.measureText(test).width > maxW && line) { lines.push(line); line = w; } else line = test; } if (line && lines.length < maxLines) lines.push(line); const consumed = lines.join(' ').split(/\s+/).filter(Boolean).length; if (consumed < words.length && lines.length) { let s = lines[lines.length - 1]; while (ctx.measureText(s + '…').width > maxW && s.length > 0) s = s.slice(0, -1); lines[lines.length - 1] = s + '…'; } const lineH = 13; lines.forEach((l, i) => ctx.fillText(l, n.x, n.y + r + 8 + i * lineH)); }; if (n.type === 'catalyst') { drawExternalLabel( n.label, isFocused || isHover ? '#e8e8ed' : '#9090a8', '500 11px "DM Sans", sans-serif', 2 ); } else if (n.type === 'source') { drawExternalLabel( n.label, isFocused || isHover ? '#e8e8ed' : '#7a7a92', '500 10.5px "DM Sans", sans-serif', 2 ); } if (n.type === 'ticker') { ctx.fillStyle = n.change >= 0 ? '#00d4aa' : '#ff4466'; ctx.font = '500 10px "JetBrains Mono", monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText((n.change >= 0 ? '+' : '') + n.change.toFixed(2) + '%', n.x, n.y + r + 4); } } ctx.restore(); raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => { cancelAnimationFrame(raf); ro.disconnect(); }; }, [selected, focused]); // Convert screen -> world const toWorld = (sx, sy) => { const s = stateRef.current; const rect = canvasRef.current.getBoundingClientRect(); const x = (sx - rect.left - rect.width/2 - s.cam.x) / s.cam.k; const y = (sy - rect.top - rect.height/2 - s.cam.y) / s.cam.k; return { x, y }; }; const hitTest = (wx, wy) => { const s = stateRef.current; for (let i = s.nodes.length - 1; i >= 0; i--) { const n = s.nodes[i]; const r = NODE_RADIUS[n.type]; const dx = wx - n.x, dy = wy - n.y; if (dx*dx + dy*dy <= r*r) return n; } return null; }; const onMouseDown = (e) => { const { x, y } = toWorld(e.clientX, e.clientY); const n = hitTest(x, y); const s = stateRef.current; if (n) { // Shift-drag from a node = Obsidian-style "tether" — drag a line out // and release on another node to create an edge between them. if (e.shiftKey) { s.tether = { fromId: n.id, mx: x, my: y }; canvasRef.current.style.cursor = 'crosshair'; } else { s.drag = { id: n.id, dx: x - n.x, dy: y - n.y, moved: false }; s.alpha = Math.max(s.alpha, 0.6); // re-heat so neighbors readjust } } else { s.pan = { sx: e.clientX, sy: e.clientY, cx: s.cam.x, cy: s.cam.y }; } }; const onMouseMove = (e) => { const s = stateRef.current; const { x, y } = toWorld(e.clientX, e.clientY); if (s.tether) { // Track the cursor so the dashed line follows the mouse, and // highlight whichever node it's hovering on as the connection target. s.tether.mx = x; s.tether.my = y; const target = hitTest(x, y); s.tether.targetId = (target && target.id !== s.tether.fromId) ? target.id : null; } else if (s.drag) { const node = s.nodes.find(n => n.id === s.drag.id); if (node) { node.x = x - s.drag.dx; node.y = y - s.drag.dy; s.drag.moved = true; } } else if (s.pan) { s.cam.x = s.pan.cx + (e.clientX - s.pan.sx); s.cam.y = s.pan.cy + (e.clientY - s.pan.sy); } else { const n = hitTest(x, y); const newHover = n ? n.id : null; if (s.hover !== newHover) { s.hover = newHover; canvasRef.current.style.cursor = n ? 'pointer' : 'grab'; } } }; const onMouseUp = (e) => { const s = stateRef.current; if (s.tether) { // Release the tether — if we landed on another node, fire onConnect // and let app.jsx decide the edge kind / append it to setEdges. const fromId = s.tether.fromId; const toId = s.tether.targetId; if (fromId && toId && fromId !== toId && typeof onConnect === 'function') { onConnect(fromId, toId); } s.tether = null; canvasRef.current.style.cursor = 'grab'; } else if (s.drag && !s.drag.moved) { const id = s.drag.id; if (e.metaKey || e.ctrlKey) { setSelected(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]); } else { setFocused(id); setSelected([id]); } } else if (s.pan && !s.drag) { // click on empty -> clear focus const moved = Math.hypot(e.clientX - s.pan.sx, e.clientY - s.pan.sy); if (moved < 4 && !e.metaKey) { setFocused(null); setSelected([]); } } s.drag = null; s.pan = null; }; const ZOOM_MIN = 0.12; // pull way out for 100+ node Mad Max sweeps const ZOOM_MAX = 4.0; // zoom in close enough to read source excerpts const onWheel = (e) => { e.preventDefault(); const s = stateRef.current; const rect = canvasRef.current.getBoundingClientRect(); const mx = e.clientX - rect.left - rect.width/2; const my = e.clientY - rect.top - rect.height/2; const wx = (mx - s.cam.x) / s.cam.k; const wy = (my - s.cam.y) / s.cam.k; // Trackpad pinch-zoom on macOS comes through as wheel + ctrlKey — heavier steps for it. const sensitivity = e.ctrlKey ? 0.0040 : 0.0015; const factor = Math.exp(-e.deltaY * sensitivity); const nk = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, s.cam.k * factor)); s.cam.k = nk; s.cam.x = mx - wx * nk; s.cam.y = my - wy * nk; }; // Programmatic zoom (used by the on-screen ± / ⊙ buttons). Anchors at center. const zoomBy = (factor) => { const s = stateRef.current; s.cam.k = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, s.cam.k * factor)); }; // Middle button — pan the camera to the hypothesis (central thesis) node // and focus it. Keeps the current zoom level so the user can dial in a // wide view and still snap back to the anchor. Falls back to a clean // origin/zoom reset if no hypothesis node exists yet. const centerOnHypothesis = () => { const s = stateRef.current; const hypo = s.nodes.find(n => n.isHypothesis) || s.nodes.find(n => n.id === 't_hypo'); if (!hypo) { s.cam = { x: 0, y: 0, k: 1 }; return; } const targetX = -hypo.x * s.cam.k; const targetY = -hypo.y * s.cam.k; const startX = s.cam.x, startY = s.cam.y; let frame = 0; const tick = () => { frame++; const t = Math.min(1, frame / 30); const ease = 1 - Math.pow(1 - t, 3); s.cam.x = startX + (targetX - startX) * ease; s.cam.y = startY + (targetY - startY) * ease; if (t < 1) requestAnimationFrame(tick); }; tick(); // Highlight it too, so the user sees the glow + selection corners. setFocused(hypo.id); }; const onContext = (e) => { e.preventDefault(); const { x, y } = toWorld(e.clientX, e.clientY); const n = hitTest(x, y); if (n) { onShortcut('contextmenu', { node: n, screenX: e.clientX, screenY: e.clientY }); } }; // Focus on a node externally (jump-to from chat citation) useEffect(() => { if (!focused) return; const s = stateRef.current; const node = s.nodes.find(n => n.id === focused); if (!node) return; const W = canvasRef.current.clientWidth; const H = canvasRef.current.clientHeight; const targetX = -node.x * s.cam.k; const targetY = -node.y * s.cam.k; let frame = 0; const startX = s.cam.x, startY = s.cam.y; const animate = () => { frame++; const t = Math.min(1, frame / 30); const ease = 1 - Math.pow(1 - t, 3); s.cam.x = startX + (targetX - startX) * ease; s.cam.y = startY + (targetY - startY) * ease; if (t < 1) requestAnimationFrame(animate); }; animate(); }, [focused]); return (