185 lines
9.1 KiB
JavaScript
185 lines
9.1 KiB
JavaScript
/* ============================================================
|
||
atlas.js — Protein Atlas 탭 (단백질 그리드 + NGL 3D 구조)
|
||
============================================================ */
|
||
(function () {
|
||
'use strict';
|
||
|
||
const AXIS_LABELS = {
|
||
AND: "안드로겐(AR/DHT)", Wnt: "Wnt/β-catenin", BMP: "BMP/TGFβ", SHH: "Hedgehog",
|
||
INF: "염증/JAK-STAT", APO: "세포사멸/산화", DP: "진피유두/성장인자", HFSC: "모낭 줄기세포",
|
||
};
|
||
const AXIS_COLOR = {
|
||
AND: "#f87171", Wnt: "#6699ff", BMP: "#a78bfa", SHH: "#22d3ee",
|
||
INF: "#fbbf24", APO: "#fb923c", DP: "#34d399", HFSC: "#f472b6", "—": "#64748b",
|
||
};
|
||
|
||
let nglStage = null, selectedGene = null;
|
||
function el(id) { return document.getElementById(id); }
|
||
|
||
function init() {
|
||
const axisSel = el('atlas-axis'), disSel = el('atlas-disease');
|
||
const axes = [...new Set(Store.catalog.proteins.map(p => p.twin_node))].filter(a => a && a !== '—');
|
||
axes.forEach(a => axisSel.add(new Option(AXIS_LABELS[a] || a, a)));
|
||
const diseases = [...new Set(Store.catalog.proteins.flatMap(p => p.diseases || []))].sort();
|
||
diseases.forEach(d => disSel.add(new Option(d, d)));
|
||
|
||
['atlas-search', 'atlas-axis', 'atlas-disease', 'atlas-sort'].forEach(id =>
|
||
el(id).addEventListener('input', renderGrid));
|
||
// resize 핸들러는 단 한 번만 등록 (이전: loadStructure 마다 추가 → 누수)
|
||
window.addEventListener('resize', () => nglStage && nglStage.handleResize());
|
||
renderGrid();
|
||
}
|
||
|
||
function filtered() {
|
||
const q = el('atlas-search').value.trim().toLowerCase();
|
||
const ax = el('atlas-axis').value, dis = el('atlas-disease').value, sort = el('atlas-sort').value;
|
||
let list = Store.catalog.proteins.filter(p => {
|
||
if (ax && p.twin_node !== ax) return false;
|
||
if (dis && !(p.diseases || []).includes(dis)) return false;
|
||
if (q && !(p.gene.toLowerCase().includes(q) || (p.name || '').toLowerCase().includes(q))) return false;
|
||
return true;
|
||
});
|
||
const plddt = p => (p.structure && p.structure.mean_plddt) || 0;
|
||
const sorters = {
|
||
evidence: (a, b) => b.n_evidence - a.n_evidence || b.mention_count - a.mention_count,
|
||
mention: (a, b) => b.mention_count - a.mention_count,
|
||
plddt: (a, b) => plddt(b) - plddt(a),
|
||
gene: (a, b) => a.gene.localeCompare(b.gene),
|
||
};
|
||
return list.sort(sorters[sort] || sorters.evidence);
|
||
}
|
||
|
||
function renderGrid() {
|
||
const list = filtered();
|
||
el('atlas-count').textContent = list.length;
|
||
const grid = el('atlas-grid');
|
||
grid.innerHTML = list.map(p => {
|
||
const ax = p.twin_node || '—';
|
||
const seed = p.in_model ? '<span class="pc-seed">●seed</span>' : `<span class="pc-ev">${p.n_evidence}편</span>`;
|
||
return `<div class="prot-card${p.gene === selectedGene ? ' selected' : ''}" data-gene="${p.gene}">
|
||
${seed}
|
||
<div class="pc-gene">${p.gene}</div>
|
||
<div class="pc-name">${p.name || ''}</div>
|
||
<div class="pc-tags">
|
||
<span class="axis-tag" style="background:${hex(AXIS_COLOR[ax], .16)};color:${AXIS_COLOR[ax]}">${ax}</span>
|
||
<span class="axis-tag role-${p.role}">${p.role || ''}</span>
|
||
</div></div>`;
|
||
}).join('');
|
||
grid.querySelectorAll('.prot-card').forEach(c =>
|
||
c.onclick = () => showDetail(c.dataset.gene));
|
||
}
|
||
|
||
function hex(h, a) {
|
||
const n = parseInt((h || '#64748b').slice(1), 16);
|
||
return `rgba(${n >> 16 & 255},${n >> 8 & 255},${n & 255},${a})`;
|
||
}
|
||
|
||
function showDetail(gene) {
|
||
const p = Store.proteinByGene[gene];
|
||
if (!p) return;
|
||
if (gene === selectedGene && !el('atlas-detail').classList.contains('hidden')) return; // 동일 단백질 재로딩 방지
|
||
selectedGene = gene;
|
||
document.querySelectorAll('.prot-card').forEach(c => c.classList.toggle('selected', c.dataset.gene === gene));
|
||
el('atlas-detail-empty').classList.add('hidden');
|
||
el('atlas-detail').classList.remove('hidden');
|
||
|
||
el('d-gene').textContent = p.gene;
|
||
el('d-name').textContent = p.uniprot_name || p.name || '';
|
||
const st = p.structure || {};
|
||
el('d-badges').innerHTML = [
|
||
p.in_model ? '<span class="dbadge" style="color:#a855f7">트윈 wired</span>' : '',
|
||
`<span class="dbadge">${AXIS_LABELS[p.twin_node] || '축 미배정'}</span>`,
|
||
`<span class="dbadge role-${p.role}">${p.role || ''}</span>`,
|
||
].filter(Boolean).join('');
|
||
|
||
// 메타 그리드
|
||
el('d-meta').innerHTML = [
|
||
['경로 (Pathway)', p.pathway || '—'],
|
||
['UniProt', st.accession || '—'],
|
||
['질환', (p.diseases || []).join(', ') || '—'],
|
||
['서열 길이', p.length ? p.length + ' aa' : '—'],
|
||
['PDB 실험구조', (p.pdb_count || 0) + '개'],
|
||
['논문 언급', p.mention_count + '회'],
|
||
].map(([k, v]) => `<div class="dg"><div class="k">${k}</div><div class="v">${v}</div></div>`).join('');
|
||
|
||
el('d-mech').textContent = p.mechanism || p.function || '기전 정보 없음';
|
||
el('d-drugs').innerHTML = (p.drugs || []).length
|
||
? p.drugs.map(d => `<span class="gene-pill">${d}</span>`).join('')
|
||
: '<span class="panel-subtitle">등록된 표적 약물 없음</span>';
|
||
|
||
// 근거 논문
|
||
const ev = (p.evidence_paper_ids || []);
|
||
el('d-evcount').textContent = ev.length;
|
||
el('d-evidence').innerHTML = ev.length ? ev.slice(0, 30).map(id => {
|
||
const info = Store.papersIdx[id] || {};
|
||
return `<a class="evidence-item" href="https://pubmed.ncbi.nlm.nih.gov/${id}/" target="_blank" style="text-decoration:none;color:inherit;display:block">
|
||
<span class="ey">${info.year || ''}</span>${info.title || ('PMID ' + id)}</a>`;
|
||
}).join('') : '<span class="panel-subtitle">초록 스캔/추출 근거 없음 (캐논 표적)</span>';
|
||
|
||
// 실제 논문 전문 근거 (검증 인용) — 요소 없을 때(구버전 HTML 캐시) 안전 가드
|
||
const gr = p.grounding || [];
|
||
const gcEl = el('d-groundcount'); if (gcEl) gcEl.textContent = gr.length;
|
||
const grEl = el('d-grounding');
|
||
if (grEl) grEl.innerHTML = gr.length ? gr.map(h =>
|
||
`<div class="grounding-item"><div class="gr-paper">📄 ${h.paper} <span class="gr-hits">${h.n_hits}회 언급</span></div>
|
||
<div class="gr-quote">"…${(h.quote || '').replace(/"/g, '"')}…"</div></div>`).join('')
|
||
: (p.grounded_in_corpus === false
|
||
? '<span class="panel-subtitle">분석 코퍼스(papers/) 전문에 근거 없음 — 웹 발굴(2024–26) 또는 canonical 경로 멤버</span>'
|
||
: '<span class="panel-subtitle">—</span>');
|
||
|
||
// 외부 링크
|
||
el('d-links').innerHTML = [
|
||
st.uniprot_url ? `<a href="${st.uniprot_url}" target="_blank">UniProt ↗</a>` : '',
|
||
st.af_entry_url ? `<a href="${st.af_entry_url}" target="_blank">AlphaFold DB ↗</a>` : '',
|
||
].filter(Boolean).join('');
|
||
|
||
el('d-plddt').textContent = st.mean_plddt ? `평균 pLDDT ${st.mean_plddt} (${st.plddt_band || ''})` : '구조 신뢰도 미계산';
|
||
loadStructure(p);
|
||
}
|
||
|
||
function loadStructure(p) {
|
||
const viewer = el('ngl-viewer');
|
||
const st = p.structure || {};
|
||
const afLink = st.af_entry_url || (st.accession ? `https://alphafold.ebi.ac.uk/entry/${st.accession}` : null);
|
||
const fallback = (msg) => {
|
||
viewer.innerHTML = `<div class="ngl-loading" style="flex-direction:column;gap:8px;text-align:center">${msg}` +
|
||
(afLink ? `<a href="${afLink}" target="_blank" style="color:var(--primary);text-decoration:underline">AlphaFold에서 3D 구조 보기 ↗</a>` : '') + `</div>`;
|
||
};
|
||
viewer.innerHTML = '<div class="ngl-loading">AlphaFold 구조 로딩 중…</div>';
|
||
if (typeof NGL === 'undefined') { fallback('NGL 라이브러리 미로딩'); return; }
|
||
if (!st.accession) { fallback('구조 정보 없음'); return; }
|
||
if (nglStage) { try { nglStage.dispose(); } catch (e) {} nglStage = null; }
|
||
try {
|
||
nglStage = new NGL.Stage('ngl-viewer', { backgroundColor: '#0e0d0b' });
|
||
} catch (e) { fallback('3D 뷰어(WebGL) 사용 불가'); return; }
|
||
|
||
// 서빙 디렉토리/오프라인 무관 — 로컬(프로젝트루트) → 원격 AlphaFold 순 폴백
|
||
const candidates = [];
|
||
if (st.local_pdb) candidates.push(['../' + st.local_pdb.replace(/\\/g, '/'), 'pdb']);
|
||
if (st.af_cif_url) candidates.push([st.af_cif_url, 'cif']);
|
||
if (st.af_pdb_url) candidates.push([st.af_pdb_url, 'pdb']);
|
||
|
||
(function tryLoad(i) {
|
||
if (i >= candidates.length) { fallback('구조 로드 실패 (네트워크/경로)'); return; }
|
||
const [url, ext] = candidates[i];
|
||
let comp_done = false;
|
||
nglStage.loadFile(url, { ext }).then(comp => {
|
||
const ld = viewer.querySelector('.ngl-loading'); if (ld) ld.remove();
|
||
comp.addRepresentation('cartoon', {
|
||
colorScheme: 'bfactor', colorScale: ['#FF7D45', '#FFDB13', '#65CBF3', '#0053D6'],
|
||
colorDomain: [40, 100], smoothSheet: true,
|
||
});
|
||
comp.autoView();
|
||
try { nglStage.setSpin(true); } catch (e) {}
|
||
comp_done = true;
|
||
}).catch(() => tryLoad(i + 1));
|
||
})(0);
|
||
}
|
||
|
||
window.AtlasTab = { init, resize: () => nglStage && nglStage.handleResize() };
|
||
window.openProtein = function (gene) {
|
||
document.querySelector('.tab-btn[data-tab="atlas"]').click();
|
||
setTimeout(() => showDetail(gene), 60);
|
||
};
|
||
})();
|