/* ============================================================ 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}${meta.desc || ''}`; 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 = '이 상태에는 개입이 없습니다 (기준선).'; 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 => `