380 lines
18 KiB
JavaScript
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, '"').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);
|
|
});
|