/* ============================================================ graph.js — Knowledge Graph (캔버스 force 시뮬레이션, 의존성 없음) ============================================================ */ (function () { 'use strict'; const TYPE_COLOR = { protein: '#6699ff', pathway: '#a855f7', disease: '#f87171', axis: '#22d3ee', drug: '#34d399' }; const TYPE_R = { protein: 4, pathway: 7, disease: 9, axis: 8, drug: 3 }; let canvas, ctx, nodes = [], links = [], view = { x: 0, y: 0, k: 1 }; let alpha = 1, running = false, raf = null; let drag = null, hover = null, pan = null, initialized = false, justDragged = false; let showDrug = false, focusDisease = ''; function el(id) { return document.getElementById(id); } function init() { if (initialized) { resize(); reheat(); return; } initialized = true; canvas = el('graph-canvas'); ctx = canvas.getContext('2d'); const disSel = el('g-focus-disease'); [...new Set(Store.catalog.proteins.flatMap(p => p.diseases || []))].sort() .forEach(d => disSel.add(new Option(d, d))); el('g-show-drug').addEventListener('change', e => { showDrug = e.target.checked; build(); reheat(); }); disSel.addEventListener('change', e => { focusDisease = e.target.value; build(); reheat(); }); el('g-reheat').addEventListener('click', reheat); bindInteraction(); window.addEventListener('resize', () => { if (isVisible()) resize(); }); build(); resize(); reheat(); } function isVisible() { return el('tab-graph').classList.contains('active'); } function build() { const G = Store.graph; const keep = new Set(); let activeNodes = G.nodes.filter(n => { if (!showDrug && n.type === 'drug') return false; return true; }); if (focusDisease) { const did = 'D:' + focusDisease; const nbr = new Set([did]); G.links.forEach(l => { const s = idOf(l.source), t = idOf(l.target); if (s === did) nbr.add(t); if (t === did) nbr.add(s); }); // 단백질의 경로/축도 포함 G.links.forEach(l => { const s = idOf(l.source), t = idOf(l.target); if (nbr.has(s) && (t.startsWith('PW:') || t.startsWith('AX:'))) nbr.add(t); if (nbr.has(t) && (s.startsWith('PW:') || s.startsWith('AX:'))) nbr.add(s); }); activeNodes = activeNodes.filter(n => nbr.has(n.id)); } activeNodes.forEach(n => keep.add(n.id)); const prev = {}; nodes.forEach(n => prev[n.id] = n); nodes = activeNodes.map(n => { const p = prev[n.id]; return Object.assign({}, n, { x: p ? p.x : (Math.random() - .5) * 600, y: p ? p.y : (Math.random() - .5) * 600, vx: 0, vy: 0, r: TYPE_R[n.type] + Math.min(6, (n.degree || 1) * 0.5), }); }); const byId = {}; nodes.forEach(n => byId[n.id] = n); links = G.links.filter(l => keep.has(idOf(l.source)) && keep.has(idOf(l.target))) .map(l => ({ s: byId[idOf(l.source)], t: byId[idOf(l.target)], type: l.type })); } function idOf(x) { return typeof x === 'object' ? x.id : x; } function resize() { const r = canvas.getBoundingClientRect(); canvas.width = r.width * devicePixelRatio; canvas.height = r.height * devicePixelRatio; ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); if (!view._init) { view.x = r.width / 2; view.y = r.height / 2; view.k = 0.8; view._init = true; } draw(); } function reheat() { alpha = 1; if (!running) loop(); } function tick() { const k = 0.9; alpha *= 0.985; // 반발 (O(n^2), 노드 수 제한적) for (let i = 0; i < nodes.length; i++) { const a = nodes[i]; for (let j = i + 1; j < nodes.length; j++) { const b = nodes[j]; let dx = a.x - b.x, dy = a.y - b.y, d2 = dx * dx + dy * dy || 1; const f = 900 / d2; const d = Math.sqrt(d2); const fx = dx / d * f, fy = dy / d * f; a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy; } } // 스프링 (엣지) links.forEach(l => { let dx = l.t.x - l.s.x, dy = l.t.y - l.s.y, d = Math.sqrt(dx * dx + dy * dy) || 1; const target = 60, f = (d - target) * 0.02; const fx = dx / d * f, fy = dy / d * f; l.s.vx += fx; l.s.vy += fy; l.t.vx -= fx; l.t.vy -= fy; }); // 중심 인력 + 감쇠 + 적용 nodes.forEach(n => { if (n === (drag && drag.node)) return; n.vx -= n.x * 0.002; n.vy -= n.y * 0.002; n.vx *= k; n.vy *= k; n.x += n.vx * alpha * 2; n.y += n.vy * alpha * 2; }); } function loop() { running = true; tick(); draw(); if (alpha > 0.02) raf = requestAnimationFrame(loop); else running = false; } function toScreen(n) { return [view.x + n.x * view.k, view.y + n.y * view.k]; } function toWorld(px, py) { return [(px - view.x) / view.k, (py - view.y) / view.k]; } function draw() { if (!ctx) return; const w = canvas.width / devicePixelRatio, h = canvas.height / devicePixelRatio; ctx.clearRect(0, 0, w, h); // 엣지 ctx.lineWidth = 0.6; links.forEach(l => { const [sx, sy] = toScreen(l.s), [tx, ty] = toScreen(l.t); ctx.strokeStyle = (hover && (l.s === hover || l.t === hover)) ? 'rgba(102,153,255,.5)' : 'rgba(255,255,255,.06)'; ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(tx, ty); ctx.stroke(); }); // 노드 nodes.forEach(n => { const [x, y] = toScreen(n), r = n.r * Math.sqrt(view.k); ctx.beginPath(); ctx.arc(x, y, r, 0, 7); ctx.fillStyle = TYPE_COLOR[n.type] || '#888'; ctx.globalAlpha = (hover && hover !== n && !isNeighbor(n, hover)) ? 0.25 : 1; ctx.fill(); if (n === hover || n.type === 'disease' || n.type === 'axis' || (n.type === 'protein' && n.in_model && view.k > 0.7)) { ctx.globalAlpha = 1; ctx.fillStyle = '#dfe4ff'; ctx.font = `${Math.max(9, 11 * Math.sqrt(view.k))}px Inter`; ctx.fillText(n.label, x + r + 2, y + 3); } ctx.globalAlpha = 1; }); } function isNeighbor(n, h) { return links.some(l => (l.s === n && l.t === h) || (l.t === n && l.s === h)); } function nodeAt(px, py) { for (let i = nodes.length - 1; i >= 0; i--) { const [x, y] = toScreen(nodes[i]); const r = nodes[i].r * Math.sqrt(view.k) + 3; if ((px - x) ** 2 + (py - y) ** 2 < r * r) return nodes[i]; } return null; } function bindInteraction() { const tip = el('graph-tooltip'); canvas.addEventListener('mousemove', e => { const rect = canvas.getBoundingClientRect(); const px = e.clientX - rect.left, py = e.clientY - rect.top; if (drag) { drag.moved = true; const [wx, wy] = toWorld(px, py); drag.node.x = wx; drag.node.y = wy; drag.node.vx = drag.node.vy = 0; draw(); return; } if (pan) { view.x += e.clientX - pan.x; view.y += e.clientY - pan.y; pan.x = e.clientX; pan.y = e.clientY; draw(); return; } const prev = hover; const n = nodeAt(px, py); hover = n; if (n) { canvas.style.cursor = 'pointer'; tip.classList.remove('hidden'); tip.style.left = (e.clientX + 14) + 'px'; tip.style.top = (e.clientY + 12) + 'px'; tip.innerHTML = `
${n.label}
${ttSub(n)}
`; } else { canvas.style.cursor = 'grab'; tip.classList.add('hidden'); } if (n !== prev) draw(); // hover 변화시에만 재그리기(유휴 redraw 방지) }); canvas.addEventListener('mousedown', e => { const rect = canvas.getBoundingClientRect(); const n = nodeAt(e.clientX - rect.left, e.clientY - rect.top); if (n) { drag = { node: n }; reheat(); } else pan = { x: e.clientX, y: e.clientY }; }); window.addEventListener('mouseup', () => { justDragged = !!(drag && drag.moved); // 이동을 동반한 드래그였으면 click 무시 drag = null; pan = null; }); canvas.addEventListener('click', e => { if (justDragged) { justDragged = false; return; } // 드래그 끝의 click 은 네비게이션 금지 const rect = canvas.getBoundingClientRect(); const n = nodeAt(e.clientX - rect.left, e.clientY - rect.top); if (n && n.type === 'protein') window.openProtein(n.label); }); canvas.addEventListener('wheel', e => { e.preventDefault(); const rect = canvas.getBoundingClientRect(); const px = e.clientX - rect.left, py = e.clientY - rect.top; const [wx, wy] = toWorld(px, py); view.k *= e.deltaY < 0 ? 1.1 : 0.9; view.k = Math.max(0.2, Math.min(4, view.k)); view.x = px - wx * view.k; view.y = py - wy * view.k; draw(); }, { passive: false }); } function ttSub(n) { if (n.type === 'protein') return `${n.pathway || ''} · ${n.role || ''} · 근거 ${n.n_evidence || 0}편${n.in_model ? ' · 트윈 wired' : ''}`; if (n.type === 'axis') return '디지털 트윈 상태축'; return { pathway: '신호 경로', disease: '질환', drug: '약물/물질' }[n.type] || ''; } window.GraphTab = { init }; })();