alopecia/js/twin.js

201 lines
9.0 KiB
JavaScript

/* ============================================================
twin.js — Protein Twin 탭 (라이브 ODE + Chart.js)
============================================================ */
(function () {
'use strict';
const DISEASE_INTERVENTIONS = {
"Healthy": [],
"Androgenetic Alopecia": ["finasteride", "dutasteride", "AR_antagonist", "minoxidil", "anti_DKK1", "wnt_agonist", "exosome_MSC"],
"Alopecia Areata": ["JAK_inhibitor", "corticosteroid", "exosome_MSC"],
"Chemotherapy-induced Alopecia": ["CDK46_inhibitor", "scalp_cooling", "PTH_CBD"],
};
const KEY_PROTEINS = ["β-catenin (CTNNB1)", "AR/DHT (AR)", "JAK-STAT (STAT1)", "p53/apoptosis (TP53)", "VEGFA (DP)"];
const PCOLORS = ["#2f63c8", "#c8401f", "#b07a12", "#7c3aed", "#1f8a5b", "#0e7490", "#c2367f", "#5b6470", "#c0561f"];
let state = { disease: "Androgenetic Alopecia", interventions: [], live: {} };
let charts = { hair: null, prot: null, compare: null };
function el(id) { return document.getElementById(id); }
function init() {
buildDiseaseSeg();
buildInterventionChips();
bindSliders();
el('protein-trace-mode').addEventListener('change', render);
el('btn-reset-live').addEventListener('click', () => {
state.live = {}; syncSliders(); render();
});
render();
}
function buildDiseaseSeg() {
const seg = el('disease-seg');
const diseases = Store.scenarios.diseases || {};
seg.innerHTML = '';
Object.keys(DISEASE_INTERVENTIONS).forEach(dis => {
const meta = diseases[dis] || {};
const b = document.createElement('button');
b.className = 'seg-btn' + (dis === state.disease ? ' active' : '');
b.innerHTML = `${meta.label || dis}<span class="seg-sub">${meta.desc || ''}</span>`;
b.onclick = () => {
state.disease = dis; state.interventions = []; state.live = {};
document.querySelectorAll('#disease-seg .seg-btn').forEach(x => x.classList.remove('active'));
b.classList.add('active');
buildInterventionChips(); syncSliders(); render();
};
seg.appendChild(b);
});
}
function buildInterventionChips() {
const list = el('intervention-list');
const ivs = DISEASE_INTERVENTIONS[state.disease] || [];
const meta = Store.scenarios.interventions || {};
list.innerHTML = '';
if (!ivs.length) { list.innerHTML = '<span class="panel-subtitle">이 상태에는 개입이 없습니다 (기준선).</span>'; return; }
ivs.forEach(iv => {
const m = meta[iv] || { label: iv };
const c = document.createElement('div');
c.className = 'chip' + (state.interventions.includes(iv) ? ' active' : '');
c.textContent = m.label || iv;
c.onclick = () => {
const i = state.interventions.indexOf(iv);
if (i >= 0) state.interventions.splice(i, 1); else state.interventions.push(iv);
c.classList.toggle('active');
render();
};
list.appendChild(c);
});
}
function bindSliders() {
[['slider-and', 'AND', 'val-and'], ['slider-inf', 'INF', 'val-inf'],
['slider-wnt', 'uWnt', 'val-wnt'], ['slider-dp', 'uDP', 'val-dp']].forEach(([sid, key, vid]) => {
el(sid).addEventListener('input', e => {
state.live[key] = parseFloat(e.target.value);
el(vid).textContent = (+e.target.value).toFixed(2);
render(true);
});
});
}
// 현재 질환+개입의 baseline drive 로 슬라이더 동기화
function syncSliders() {
const d = TwinEngine.buildDrive(state.disease, state.interventions);
const map = { 'slider-and': ['AND', 'val-and'], 'slider-inf': ['INF', 'val-inf'],
'slider-wnt': ['uWnt', 'val-wnt'], 'slider-dp': ['uDP', 'val-dp'] };
Object.entries(map).forEach(([sid, [k, vid]]) => {
const v = state.live[k] !== undefined ? state.live[k] : d[k];
el(sid).value = v; el(vid).textContent = (+v).toFixed(2);
});
}
function render(fromSlider) {
if (!fromSlider) syncSliders();
const overrides = Object.keys(state.live).length ? state.live : null;
const r = TwinEngine.run(state.disease, state.interventions, { overrides });
renderHairChart(r);
renderProteinChart(r);
renderMetrics(r);
renderTracked();
renderCompare();
el('disease-desc').textContent = (Store.scenarios.diseases[state.disease] || {}).desc || '';
}
function renderHairChart(r) {
const ctx = el('chart-hair');
const data = {
labels: r.t,
datasets: [{
label: '모발 밀도 (%)', data: r.states.HairDensity,
borderColor: '#c8401f', backgroundColor: 'rgba(200,64,31,.10)',
fill: true, tension: .25, pointRadius: 0, borderWidth: 2.5,
}],
};
const opts = {
responsive: true, maintainAspectRatio: false,
scales: {
y: { min: 0, max: 105, ticks: { color: '#6b655a', callback: v => v + '%' }, grid: { color: 'rgba(0,0,0,.08)' } },
x: { ticks: { color: '#6b655a', maxTicksLimit: 8, callback: (v, i) => r.t[i] + 'd' }, grid: { display: false } },
},
plugins: { legend: { display: false } },
};
if (charts.hair) { charts.hair.data = data; charts.hair.update('none'); }
else charts.hair = new Chart(ctx, { type: 'line', data, options: opts });
}
function renderProteinChart(r) {
const ctx = el('chart-proteins');
const mode = el('protein-trace-mode').value;
const keys = mode === 'all' ? Object.keys(r.proteins) : KEY_PROTEINS;
const datasets = keys.map((k, i) => ({
label: k, data: r.proteins[k],
borderColor: PCOLORS[i % PCOLORS.length], borderWidth: 2,
pointRadius: 0, tension: .25, fill: false,
}));
const data = { labels: r.t, datasets };
const opts = {
responsive: true, maintainAspectRatio: false,
scales: {
y: { ticks: { color: '#6b655a' }, grid: { color: 'rgba(0,0,0,.08)' }, title: { display: true, text: '상대 활성', color: '#6b655a' } },
x: { ticks: { color: '#6b655a', maxTicksLimit: 8, callback: (v, i) => r.t[i] + 'd' }, grid: { display: false } },
},
plugins: { legend: { labels: { color: '#4a463d', boxWidth: 12, font: { size: 11 } } } },
};
if (charts.prot) { charts.prot.data = data; charts.prot.options = opts; charts.prot.update('none'); }
else charts.prot = new Chart(ctx, { type: 'line', data, options: opts });
}
function renderMetrics(r) {
const m = r.metrics;
const cls = v => v >= 70 ? 'good' : v >= 40 ? 'warn' : 'bad';
const cards = [
{ v: m.final_hair_density_pct + '%', l: '최종 모발 밀도', c: cls(m.final_hair_density_pct) },
{ v: m.min_hair_density_pct + '%', l: '최저 밀도', c: cls(m.min_hair_density_pct) },
{ v: (m.anagen_fraction * 100).toFixed(0) + '%', l: 'Anagen 비율', c: cls(m.anagen_fraction * 100) },
{ v: m.AND_load.toFixed(2) + '/' + m.INF_load.toFixed(2), l: '안드로겐/염증 부하', c: '' },
];
el('twin-metrics').innerHTML = cards.map(c =>
`<div class="metric-card ${c.c}"><div class="mv">${c.v}</div><div class="ml">${c.l}</div></div>`).join('');
}
function renderTracked() {
const meta = Store.scenarios.interventions || {};
const genes = new Set();
state.interventions.forEach(iv => (meta[iv] && meta[iv].genes || []).forEach(g => genes.add(g)));
const box = el('tracked-genes-list');
if (!genes.size) { box.innerHTML = '<span class="panel-subtitle">개입을 선택하면 표적 단백질이 표시됩니다.</span>'; return; }
box.innerHTML = [...genes].map(g => `<span class="gene-pill" data-gene="${g}">${g}</span>`).join('');
box.querySelectorAll('.gene-pill').forEach(p => p.onclick = () => window.openProtein && window.openProtein(p.dataset.gene));
}
function renderCompare() {
const ivsList = [[]].concat((DISEASE_INTERVENTIONS[state.disease] || []).map(iv => [iv]));
if (state.disease === "Androgenetic Alopecia") ivsList.push(["finasteride", "minoxidil"]);
if (state.disease === "Alopecia Areata") ivsList.push(["JAK_inhibitor", "corticosteroid"]);
const meta = Store.scenarios.interventions || {};
const labels = [], vals = [], colors = [];
ivsList.forEach(ivs => {
const r = TwinEngine.run(state.disease, ivs, { days: 240 });
const v = r.metrics.final_hair_density_pct;
labels.push(ivs.length ? ivs.map(i => (meta[i] || {}).label || i).join('+').replace(/\(.*?\)/g, '').slice(0, 16) : '무처치');
vals.push(v);
colors.push(v >= 70 ? '#1f6d3a' : v >= 40 ? '#b07a12' : '#b3361b');
});
const ctx = el('chart-compare');
const data = { labels, datasets: [{ data: vals, backgroundColor: colors, borderRadius: 5 }] };
const opts = {
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
scales: { x: { min: 0, max: 105, ticks: { color: '#6b655a', callback: v => v + '%' }, grid: { color: 'rgba(0,0,0,.08)' } },
y: { ticks: { color: '#4a463d', font: { size: 10 } }, grid: { display: false } } },
plugins: { legend: { display: false } },
};
if (charts.compare) { charts.compare.data = data; charts.compare.update('none'); }
else charts.compare = new Chart(ctx, { type: 'bar', data, options: opts });
}
window.TwinTab = { init };
})();