201 lines
9.0 KiB
JavaScript
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 };
|
|
})();
|