209 lines
8.8 KiB
JavaScript
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 };
|
|
})();
|