alopecia/js/atlas.js

185 lines
9.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
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, '&quot;')}…"</div></div>`).join('')
: (p.grounded_in_corpus === false
? '<span class="panel-subtitle">분석 코퍼스(papers/) 전문에 근거 없음 — 웹 발굴(202426) 또는 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);
};
})();