alopecia/js/app.js

380 lines
18 KiB
JavaScript

/* ============================================================
Alopecia Digital Twin — app.js
============================================================ */
document.addEventListener('DOMContentLoaded', () => {
// ── Data store
let allPapers = [];
let filteredPapers = [];
let charts = {};
let currentPage = 1;
const PAGE_SIZE = 20;
// ── Tab Nav
const tabBtns = document.querySelectorAll('.tab-btn');
const tabContents = document.querySelectorAll('.tab-content');
tabBtns.forEach(btn => {
btn.addEventListener('click', () => {
tabBtns.forEach(b => b.classList.remove('active'));
tabContents.forEach(c => c.classList.remove('active'));
btn.classList.add('active');
document.getElementById(`tab-${btn.dataset.tab}`).classList.add('active');
// Lazy-render charts when Stats tab opens
if (btn.dataset.tab === 'stats' && allPapers.length) renderCharts();
});
});
// ── Element refs (Digital Twin)
const diseaseSelect = document.getElementById('disease-select');
const modalitySelect = document.getElementById('modality-select');
const deliverySelect = document.getElementById('delivery-select');
const synthesizeBtn = document.getElementById('btn-synthesize');
const visPlaceholder = document.getElementById('vis-placeholder');
const visActive = document.getElementById('vis-active');
const mechanismImage = document.getElementById('mechanism-image');
const activePathway = document.getElementById('active-pathway');
const mechanismDesc = document.getElementById('mechanism-description');
const efficacyBar = document.getElementById('efficacy-bar');
const bioBar = document.getElementById('bio-bar');
const evidenceBar = document.getElementById('evidence-bar');
const insightsList = document.getElementById('insights-list');
const paperCountEl = document.getElementById('paper-count');
const lastUpdatedEl = document.getElementById('last-updated');
const relatedCount = document.getElementById('related-count');
const relatedNum = document.getElementById('related-num');
const insightFilter = document.getElementById('insight-disease-filter');
// ── Digital Twin control logic
diseaseSelect.addEventListener('change', e => {
const val = e.target.value;
modalitySelect.disabled = !val;
if (!val) { modalitySelect.value = ''; deliverySelect.disabled = true; deliverySelect.value = ''; synthesizeBtn.disabled = true; relatedCount.classList.add('hidden'); }
});
modalitySelect.addEventListener('change', e => {
deliverySelect.disabled = !e.target.value;
if (!e.target.value) { deliverySelect.value = ''; synthesizeBtn.disabled = true; }
});
deliverySelect.addEventListener('change', e => { synthesizeBtn.disabled = !e.target.value; });
// ── Synthesize
synthesizeBtn.addEventListener('click', () => {
const disease = diseaseSelect.value;
const modality = modalitySelect.value;
const delivery = deliverySelect.value;
visPlaceholder.classList.add('hidden');
visActive.classList.remove('hidden');
const config = getTwinConfig(disease, modality, delivery);
activePathway.textContent = config.pathway;
mechanismDesc.innerHTML = `<span class="desc-en">${config.desc}</span><br><span class="desc-ko">${config.descKo}</span>`;
mechanismImage.style.opacity = '0';
setTimeout(() => {
mechanismImage.src = config.image;
mechanismImage.onload = () => { mechanismImage.style.opacity = '0.85'; };
mechanismImage.onerror = () => { mechanismImage.style.opacity = '0'; };
}, 400);
// Animate bars
setTimeout(() => {
efficacyBar.style.width = config.efficacy + '%';
efficacyBar.textContent = config.efficacy + '%';
bioBar.style.width = config.bio + '%';
bioBar.textContent = config.bio + '%';
}, 600);
// Count related papers
const related = allPapers.filter(p =>
p.disease === disease &&
(modality === 'Other / Unknown' || p.modality === modality)
);
const pct = allPapers.length ? Math.round((related.length / allPapers.length) * 100) : 0;
evidenceBar.style.width = Math.min(pct * 4, 100) + '%';
evidenceBar.textContent = related.length + ' papers';
relatedNum.textContent = related.length;
relatedCount.classList.remove('hidden');
});
function getTwinConfig(disease, modality, delivery) {
const modalityMap = {
'Microneedles': { efficacy: 91, bio: 95 },
'Stem Cells & Exosomes': { efficacy: 88, bio: 82 },
'Natural Extracts': { efficacy: 72, bio: 68 },
'Nanoparticles/Nanogels': { efficacy: 85, bio: 88 },
'Other / Unknown': { efficacy: 78, bio: 75 },
};
const stats = modalityMap[modality] || { efficacy: 78, bio: 75 };
if (disease === 'Androgenetic Alopecia') return {
pathway: 'Wnt/β-catenin Upregulation (Wnt/β-카테닌 신호 활성화)',
desc: `Utilizing ${modality} via ${delivery}, the treatment bypasses DHT binding barriers, stimulating Dermal Papilla Cells and driving hair follicles back into the Anagen growth phase.`,
descKo: `${modality}을(를) ${delivery} 경로로 전달하여 DHT 결합 차단막을 우회합니다. 진피유두세포(Dermal Papilla Cell)를 자극해 모낭을 다시 성장기(Anagen Phase)로 전환시킵니다. Wnt 경로 활성화는 β-카테닌의 핵 내 이동을 촉진하여 모발 생성 관련 유전자 전사를 유도합니다.`,
image: 'images/wnt_pathway.png',
...stats
};
if (disease === 'Alopecia Areata') return {
pathway: 'JAK-STAT Pathway Inhibition (JAK-STAT 경로 억제)',
desc: `Application of ${modality} (${delivery}) locally suppresses aberrant CD8+ T-cell immune responses, disabling the inflammatory signal loop that attacks the hair bulb.`,
descKo: `${modality}(${delivery})을(를) 국소 적용하여 비정상적인 CD8+ T세포 면역 반응을 억제합니다. 모낭의 면역 특권(Immune Privilege)이 무너지면서 발생하는 자가면역 공격 신호 루프를 JAK1/2 억제를 통해 차단하고, 모낭 주변 염증을 완화합니다.`,
image: 'images/jak_stat_pathway.png',
...stats
};
return {
pathway: 'Anti-Apoptosis / ROS Scavenging (세포자멸사 억제 / 활성산소 제거)',
desc: `${modality} delivered through ${delivery} mitigates severe oxidative stress and prevents p53-mediated apoptosis in rapidly dividing matrix cells during toxic exposure.`,
descKo: `${modality}을(를) ${delivery}로 전달하여 항암 독성으로 인한 극심한 산화 스트레스를 완화합니다. 급속 분열 중인 모기질세포(Matrix Cell)에서 p53 매개 세포자멸사(Apoptosis)를 억제하고, 활성산소종(ROS)을 제거하여 모낭 세포 생존율을 높입니다.`,
image: 'images/apoptosis_pathway.png',
...stats
};
}
// ── Insight filter
insightFilter.addEventListener('change', () => { updateInsights(currentData); });
let currentData = null;
function updateInsights(data) {
if (!data) return;
currentData = data;
const filterVal = insightFilter.value;
const papers = filterVal ? data.papers.filter(p => p.disease === filterVal) : data.papers;
const recent = papers.slice(0, 12);
insightsList.innerHTML = '';
if (!recent.length) {
insightsList.innerHTML = '<div class="insight-card empty-state">No papers found.</div>';
return;
}
recent.forEach(paper => {
const card = document.createElement('div');
card.className = 'insight-card';
const abbr = paper.disease.includes('Androgenetic') ? 'AGA Target'
: paper.disease.includes('Areata') ? 'AA Target' : 'CIA Target';
card.innerHTML = `
<div class="insight-disease">${abbr}</div>
<div class="insight-title" title="${paper.title}">${paper.title}</div>
<div class="insight-meta">
<span class="tag">${paper.modality}</span>
<span>${paper.pubYear || 'Recent'}</span>
</div>`;
insightsList.appendChild(card);
});
}
// ── Chart rendering
const CHART_COLORS = ['#6699ff', '#a855f7', '#22d3ee', '#34d399', '#fbbf24', '#f87171', '#818cf8'];
const chartDefaults = {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { labels: { color: '#7b82b0', font: { size: 11 }, boxWidth: 12 } } }
};
function renderCharts() {
if (!currentData?.stats) return;
const s = currentData.stats;
renderDoughnut('chart-disease', s.by_disease);
renderDoughnut('chart-modality', s.by_modality);
renderBar('chart-trend', s.trend_by_year, 'Papers Published');
renderDoughnut('chart-aga-modality', s.disease_modality?.['Androgenetic Alopecia'] || {});
renderDoughnut('chart-aa-modality', s.disease_modality?.['Alopecia Areata'] || {});
}
function renderDoughnut(canvasId, dataObj) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
if (charts[canvasId]) charts[canvasId].destroy();
const labels = Object.keys(dataObj);
const values = Object.values(dataObj);
charts[canvasId] = new Chart(canvas, {
type: 'doughnut',
data: {
labels,
datasets: [{ data: values, backgroundColor: CHART_COLORS, borderColor: 'rgba(255,255,255,0.05)', borderWidth: 1 }]
},
options: { ...chartDefaults, cutout: '60%' }
});
}
function renderBar(canvasId, dataObj, label) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
if (charts[canvasId]) charts[canvasId].destroy();
const labels = Object.keys(dataObj);
const values = Object.values(dataObj);
charts[canvasId] = new Chart(canvas, {
type: 'bar',
data: {
labels,
datasets: [{
label,
data: values,
backgroundColor: 'rgba(102,153,255,0.4)',
borderColor: '#6699ff',
borderWidth: 1,
borderRadius: 6
}]
},
options: {
...chartDefaults,
scales: {
x: { ticks: { color: '#7b82b0', font: { size: 11 } }, grid: { color: 'rgba(255,255,255,0.04)' } },
y: { ticks: { color: '#7b82b0', font: { size: 11 } }, grid: { color: 'rgba(255,255,255,0.06)' }, beginAtZero: true }
}
}
});
}
// ── Paper Library
const searchInput = document.getElementById('search-input');
const filterDisease = document.getElementById('filter-disease');
const filterModality = document.getElementById('filter-modality');
const filterYear = document.getElementById('filter-year');
const papersTbody = document.getElementById('papers-tbody');
const tableCountEl = document.getElementById('table-count');
const btnPrev = document.getElementById('btn-prev');
const btnNext = document.getElementById('btn-next');
const pageInfo = document.getElementById('page-info');
let sortCol = 'pubYear'; let sortAsc = false;
function populateYearFilter(papers) {
const years = [...new Set(papers.map(p => p.pubYear).filter(y => y))].sort((a, b) => b - a);
years.forEach(y => {
const opt = document.createElement('option');
opt.value = y; opt.textContent = y;
filterYear.appendChild(opt);
});
}
function applyFilters() {
const q = searchInput.value.toLowerCase();
const dis = filterDisease.value;
const mod = filterModality.value;
const yr = filterYear.value;
filteredPapers = allPapers.filter(p => {
if (dis && p.disease !== dis) return false;
if (mod && p.modality !== mod) return false;
if (yr && p.pubYear !== yr) return false;
if (q && !(p.title.toLowerCase().includes(q) || (p.abstract || '').toLowerCase().includes(q))) return false;
return true;
});
filteredPapers.sort((a, b) => {
const va = a[sortCol] || ''; const vb = b[sortCol] || '';
return sortAsc ? va.localeCompare(vb) : vb.localeCompare(va);
});
currentPage = 1;
renderTable();
}
function makePdfFilename(title) {
// Windows에서 금지된 문자만 제거. 나머지는 agent1이 저장한 실제 파일명과 동일하게 유지.
// agent1_fetcher.py: 특수문자 제거 없이 {title}.pdf 그대로 저장
const illegal = /[\\/:*?"<>|]/g;
return title.replace(illegal, '').trim() + '.pdf';
}
function downloadPdf(url, filename) {
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
function renderTable() {
const total = filteredPapers.length;
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const start = (currentPage - 1) * PAGE_SIZE;
const slice = filteredPapers.slice(start, start + PAGE_SIZE);
tableCountEl.textContent = total;
pageInfo.textContent = `Page ${currentPage} of ${pages}`;
btnPrev.disabled = currentPage <= 1;
btnNext.disabled = currentPage >= pages;
papersTbody.innerHTML = '';
if (!slice.length) {
papersTbody.innerHTML = '<tr><td colspan="5" class="loading-row">No papers match your filter.</td></tr>';
return;
}
slice.forEach(p => {
const tr = document.createElement('tr');
const diseaseClass = p.disease.includes('Androgenetic') ? 'disease-aga'
: p.disease.includes('Areata') ? 'disease-aa' : 'disease-cia';
const diseaseLabel = p.disease.includes('Androgenetic') ? 'AGA (남성형 탈모)'
: p.disease.includes('Areata') ? 'AA (원형 탈모)' : 'CIA (항암 탈모)';
const pdfFilename = makePdfFilename(p.title);
const pdfUrl = `../papers/${encodeURIComponent(pdfFilename)}`;
tr.style.cursor = 'pointer';
tr.title = 'PDF 다운로드 클릭 (Click to download PDF)';
tr.innerHTML = `
<td>${p.pubYear || '—'}</td>
<td>
<span class="paper-title" title="${(p.abstract || '').replace(/"/g, '&quot;').substring(0, 300)}">${p.title}</span>
<span class="pdf-icon" title="PDF 다운로드">📄</span>
</td>
<td><span class="disease-badge ${diseaseClass}">${diseaseLabel}</span></td>
<td><span class="tag">${p.modality}</span></td>
<td>${p.mechanism || '—'}</td>`;
tr.addEventListener('click', () => downloadPdf(pdfUrl, pdfFilename));
papersTbody.appendChild(tr);
});
}
// Sorting
document.querySelectorAll('.sortable').forEach(th => {
th.addEventListener('click', () => {
const col = th.dataset.col;
if (sortCol === col) sortAsc = !sortAsc; else { sortCol = col; sortAsc = true; }
applyFilters();
});
});
// Filters
[searchInput, filterDisease, filterModality, filterYear].forEach(el => el.addEventListener('input', applyFilters));
btnPrev.addEventListener('click', () => { if (currentPage > 1) { currentPage--; renderTable(); } });
btnNext.addEventListener('click', () => {
const pages = Math.ceil(filteredPapers.length / PAGE_SIZE);
if (currentPage < pages) { currentPage++; renderTable(); }
});
// ── Data Polling
async function fetchData() {
try {
const res = await fetch(`../analysis_results.json?t=${Date.now()}`);
if (!res.ok) return;
const data = await res.json();
if (!data || !data.papers) return;
allPapers = data.papers;
filteredPapers = [...allPapers];
// Header
paperCountEl.textContent = data.total_papers;
const updated = data.last_updated ? new Date(data.last_updated).toLocaleString('ko-KR', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—';
lastUpdatedEl.textContent = updated;
// Year filter
filterYear.innerHTML = '<option value="">All Years</option>';
populateYearFilter(allPapers);
// Insights
updateInsights(data);
// Paper table
applyFilters();
// Charts (only if Stats tab is active)
if (document.getElementById('tab-stats').classList.contains('active')) renderCharts();
} catch (err) {
console.warn('Waiting for analysis_results.json...', err);
}
}
fetchData();
setInterval(fetchData, 10000);
});