/* ============================================================
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 ? '●seed' : `${p.n_evidence}편`;
return `
${seed}
${p.gene}
${p.name || ''}
${ax}
${p.role || ''}
`;
}).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 ? '트윈 wired' : '',
`${AXIS_LABELS[p.twin_node] || '축 미배정'}`,
`${p.role || ''}`,
].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]) => ``).join('');
el('d-mech').textContent = p.mechanism || p.function || '기전 정보 없음';
el('d-drugs').innerHTML = (p.drugs || []).length
? p.drugs.map(d => `${d}`).join('')
: '등록된 표적 약물 없음';
// 근거 논문
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 `
${info.year || ''}${info.title || ('PMID ' + id)}`;
}).join('') : '초록 스캔/추출 근거 없음 (캐논 표적)';
// 실제 논문 전문 근거 (검증 인용) — 요소 없을 때(구버전 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 =>
`📄 ${h.paper} ${h.n_hits}회 언급
"…${(h.quote || '').replace(/"/g, '"')}…"
`).join('')
: (p.grounded_in_corpus === false
? '분석 코퍼스(papers/) 전문에 근거 없음 — 웹 발굴(2024–26) 또는 canonical 경로 멤버'
: '—');
// 외부 링크
el('d-links').innerHTML = [
st.uniprot_url ? `UniProt ↗` : '',
st.af_entry_url ? `AlphaFold DB ↗` : '',
].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 = ``;
};
viewer.innerHTML = 'AlphaFold 구조 로딩 중…
';
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);
};
})();