alopecia/js/graph.js

209 lines
8.8 KiB
JavaScript

/* ============================================================
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 = `<div class="tt-title">${n.label}</div><div class="tt-sub">${ttSub(n)}</div>`;
} 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 };
})();