/* ============================================================ timeline.js — 치료 타임라인 시뮬레이션 탭 업로드한 두피/쥐/원형탈모 사진 위에, 치료 반응을 0~36개월 프로그레스 바로 표현. ── 정직성 원칙 ────────────────────────────────────────── · 회복의 '양(magnitude)' = TwinEngine 모델(치료 전 질환 평형 → 치료 후 평형 밀도). 모델의 정량 출력. · 회복의 '속도(timing)' = 임상시험 문헌의 반응 동역학(lag/tau/shed). 모델 ODE 의 '일(day)'은 임상시간과 무관(수주 내 평형)하므로 시간축으로 쓰지 않고 폐기 → 문헌 시간상수로 보정. density(t) = baseline + (target − baseline) · f_clinical(t) · 사진 변형 = 위 곡선이 구동하는 절차적 캔버스 일러스트(생성형 AI 아님). 환부 '기하'는 사용자 사진에서, '얼마나·언제'는 모델+문헌. 개별 환자 예측 아님. ============================================================ */ (function () { 'use strict'; const DISEASES = [ { key: "Alopecia Areata", ko: "원형 탈모 (AA)", sub: "두피·패치", style: "edge", ivs: ["JAK_inhibitor", "corticosteroid"] }, { key: "Androgenetic Alopecia", ko: "남성형 탈모 (AGA)", sub: "두피·미만성", style: "diffuse", ivs: ["finasteride", "minoxidil", "dutasteride"] }, { key: "Chemotherapy-induced Alopecia", ko: "항암 탈모 (CIA)", sub: "전두피", style: "diffuse", ivs: ["minoxidil", "CDK46_inhibitor"] }, { key: "__mouse__", ko: "쥐 제모 발모 (마우스)", sub: "등쪽·균일", style: "uniform", ivs: ["minoxidil", "wnt_agonist"] }, ]; const IV_KO = { finasteride: "피나스테리드", dutasteride: "두타스테리드", minoxidil: "미녹시딜", JAK_inhibitor: "JAK 억제제", corticosteroid: "코르티코스테로이드", CDK46_inhibitor: "CDK4/6 억제제", wnt_agonist: "Wnt 작용제", }; // 임상 반응 동역학(개월). lag=가시반응 지연, tau=특성시간(≈63% 도달), shed=초기 telogen 탈락. // ▶ 3가지 증거를 화해(reconcile)시켜 보정 — digital_twin/timeline_calibration.py + 2차 웹스윕: // (1) 데이터적합: CT.gov 실궤적(THRIVE-AA1/2·brepo·ritle SALT 다시점, 5-ARI 24주). JAK 재적합 R²0.94~0.99. // (2) 기계론적 바닥: 가시 발모는 모낭주기(텔로젠 ~2-3개월)에 묶임 — 약물 PK는 빠르나(DHT 수시간) 모발은 느림. // 단기시험의 lag~0 적합은 초기 TAHC '굵어짐' 과적합 → 가시 밀도 lag는 ≥~2개월(5-ARI). // (3) 장기: 피나 Kaufman 2년까지 지속상승(tau~6). JAK는 wk4-8 분리(immune→기존모낭 재진입)로 더 빠름. // ⚠ 단일 지수는 (a)AGA 이상성(빠른 굵어짐+느린 밀도), (b)피나 yr2-5 감소, (c)미녹 재퇴행 재현 못 함(한계). const TIME_MODEL = { finasteride: { lag: 2.0, tau: 6, shed: 0.05 }, // telogen 바닥 + Kaufman 2yr (R²0.999) dutasteride: { lag: 2.0, tau: 5, shed: 0.05 }, // 더 깊은 DHT 억제(scalp 51% vs 41%)·약간 빠름 minoxidil: { lag: 1.5, tau: 4, shed: 0.14 }, // telogen 단축 → 약간 빠름; SULT1A1 응답자 ~40% JAK_inhibitor: { lag: 1.0, tau: 4, shed: 0 }, // THRIVE wk4-8 분리; tau는 baricitinib 느림 포함 절충 corticosteroid: { lag: 1.0, tau: 3, shed: 0 }, CDK46_inhibitor: { lag: 1.5, tau: 4, shed: 0 }, // 데이터 없음(실험적) wnt_agonist: { lag: 1.5, tau: 4, shed: 0 }, // 데이터 없음(실험적) }; const TIME_DEFAULT = { lag: 2, tau: 4.5, shed: 0 }; // 무처치/기타 const MOUSE_KIN = { lag: 0.2, tau: 0.6, shed: 0 }; // 마우스 모주기 ~2-3주(실제로 빠름) // 절대밀도 환산 레퍼런스(정상=100%): 한국인 정수리 ~130 hairs/cm² (Yoo 2002·Han 2004 phototrichogram; 백인 ~226). const NORMAL_DENSITY = 130; const S = { img: null, region: null, strokes: null, hairColor: '#34281d', disease: DISEASES[0], ivs: ["JAK_inhibitor"], baseline: null, target: null, kin: null, ti: 0, marking: false, markStart: null, chart: null, }; function el(id) { return document.getElementById(id); } function gctx() { return el('tl-canvas').getContext('2d', { willReadFrequently: true }); } // 임상 시간축(개월). 초반 telogen-shed 구간은 촘촘, 후반은 분기/연 단위. const TIMEPOINTS = [ { m: 0, l: '0' }, { m: 0.5, l: '2주' }, { m: 1, l: '1개월' }, { m: 2, l: '2개월' }, { m: 3, l: '3개월' }, { m: 4, l: '4개월' }, { m: 6, l: '6개월' }, { m: 9, l: '9개월' }, { m: 12, l: '1년' }, { m: 18, l: '1.5년' }, { m: 24, l: '2년' }, { m: 30, l: '2.5년' }, { m: 36, l: '3년' }, ]; function curMonth() { return TIMEPOINTS[S.ti].m; } function setSliderFill() { const sl = el('tl-slider'); if (!sl) return; const pct = (S.ti / (TIMEPOINTS.length - 1)) * 100; sl.style.background = `linear-gradient(90deg,var(--primary) ${pct}%,var(--line2) ${pct}%)`; } function init() { el('tl-disease').innerHTML = DISEASES.map((d, i) => ``).join(''); el('tl-disease').querySelectorAll('.seg-btn').forEach(b => b.onclick = () => { selectDisease(+b.dataset.i); }); renderIvs(); el('tl-file').addEventListener('change', onFile); bindCanvasMarking(); const sl = el('tl-slider'); sl.max = TIMEPOINTS.length - 1; sl.value = 0; S.ti = 0; sl.addEventListener('input', () => { S.ti = +sl.value; setSliderFill(); renderAt(); }); setSliderFill(); el('tl-remark') && (el('tl-remark').onclick = () => startMark()); el('tl-demo') && (el('tl-demo').onclick = loadDemo); selectDisease(0); } function selectDisease(i) { S.disease = DISEASES[i]; el('tl-disease').querySelectorAll('.seg-btn').forEach((b, j) => b.classList.toggle('active', j === i)); S.ivs = S.disease.ivs.slice(0, 1); renderIvs(); computeCurve(); if (S.img) { rebuildStrokes(); renderAt(); } } function renderIvs() { el('tl-interventions').innerHTML = S.disease.ivs.map(iv => ``).join(''); el('tl-interventions').querySelectorAll('.chip').forEach(c => c.onclick = () => { const iv = c.dataset.iv; if (S.ivs.includes(iv)) S.ivs = S.ivs.filter(x => x !== iv); else S.ivs.push(iv); renderIvs(); computeCurve(); if (S.img) renderAt(); }); } // 회복의 '양' = 모델. baseline(치료 전 질환 평형) / target(치료 후 평형) 밀도를 트윈에서 취득. function computeCurve() { let y0, diseaseForRun; if (S.disease.key === "__mouse__") { y0 = TwinEngine.diseaseEquilibrium("Healthy"); y0[6] = 0.08; // 갓 제모(모발만 바닥) diseaseForRun = "Healthy"; } else { y0 = TwinEngine.diseaseEquilibrium(S.disease.key); diseaseForRun = S.disease.key; } const mc = TwinEngine.run(diseaseForRun, S.ivs, { y0, days: 1095 }).states.HairDensity; S.baseline = mc[0]; // 치료 전 밀도 = 모델 S.target = Math.max.apply(null, mc); // 치료 후 평형 밀도 = 모델 S.kin = combineKinetics(); // 회복 속도 = 임상 문헌 renderChart(); } function combineKinetics() { if (S.disease.key === "__mouse__") return MOUSE_KIN; if (!S.ivs.length) return TIME_DEFAULT; let lag = 99, tau = 99, shed = 0; S.ivs.forEach(iv => { const k = TIME_MODEL[iv] || TIME_DEFAULT; lag = Math.min(lag, k.lag); tau = Math.min(tau, k.tau); shed = Math.max(shed, k.shed); }); return { lag, tau, shed }; } // 임상 회복 분율 f(t개월) ∈ [-shed,1]: lag 후 1−exp 상승 + 초기 telogen 탈락 딥. function recoveryAtMonth(m) { const k = S.kin || TIME_DEFAULT; const grow = m <= k.lag ? 0 : 1 - Math.exp(-(m - k.lag) / k.tau); const dip = -k.shed * Math.exp(-Math.pow(m - 0.7, 2) / (2 * 0.5 * 0.5)); // ~2-8주 탈락 return Math.max(-0.25, Math.min(1, grow + dip)); } function densityAtMonth(m) { return S.baseline + (S.target - S.baseline) * recoveryAtMonth(m); } // ── 파일 업로드 ── function onFile(e) { const f = e.target.files && e.target.files[0]; if (!f) return; const r = new FileReader(); r.onload = () => loadImageSrc(r.result); r.readAsDataURL(f); } function loadImageSrc(src) { const im = new Image(); im.onload = () => { S.img = im; setupCanvas(); defaultRegion(); rebuildStrokes(); startMark(); renderAt(); el('tl-stage').classList.remove('empty'); }; im.src = src; } // 데모: 합성 두피(피부+원형 무모부) — 사진 없을 때 동작 확인용 function loadDemo() { const c = document.createElement('canvas'); c.width = 640; c.height = 480; const x = c.getContext('2d'); x.fillStyle = '#caa987'; x.fillRect(0, 0, 640, 480); for (let i = 0; i < 14000; i++) { const px = Math.random() * 640, py = Math.random() * 480; const d = Math.hypot(px - 320, py - 240); if (d < 95) continue; x.strokeStyle = `rgba(50,35,24,${0.25 + Math.random() * 0.4})`; x.lineWidth = 1; x.beginPath(); x.moveTo(px, py); x.lineTo(px + Math.random() * 6 - 3, py + 6 + Math.random() * 5); x.stroke(); } x.fillStyle = 'rgba(214,180,150,.85)'; x.beginPath(); x.ellipse(320, 240, 92, 80, 0, 0, 7); x.fill(); loadImageSrc(c.toDataURL()); } function setupCanvas() { const cv = el('tl-canvas'), maxW = 760; let w = S.img.naturalWidth, h = S.img.naturalHeight; const sc = Math.min(1, maxW / w); w = Math.round(w * sc); h = Math.round(h * sc); cv.width = w; cv.height = h; } function defaultRegion() { const cv = el('tl-canvas'); if (S.disease.style === 'diffuse') S.region = { cx: cv.width / 2, cy: cv.height * 0.42, rx: cv.width * 0.40, ry: cv.height * 0.34 }; else if (S.disease.style === 'uniform') S.region = { cx: cv.width / 2, cy: cv.height / 2, rx: cv.width * 0.42, ry: cv.height * 0.34 }; else S.region = { cx: cv.width / 2, cy: cv.height / 2, rx: cv.width * 0.18, ry: cv.width * 0.18 }; } // ── 환부 마킹 (드래그로 타원) ── function startMark() { S.marking = 'await'; el('tl-hint').classList.remove('hidden'); renderAt(); } function bindCanvasMarking() { const cv = el('tl-canvas'); const pos = ev => { const r = cv.getBoundingClientRect(); return { x: (ev.clientX - r.left) * cv.width / r.width, y: (ev.clientY - r.top) * cv.height / r.height }; }; cv.addEventListener('mousedown', ev => { if (!S.img) return; S.marking = true; S.markStart = pos(ev); }); cv.addEventListener('mousemove', ev => { if (S.marking !== true || !S.markStart) return; const p = pos(ev); S.region = { cx: S.markStart.x, cy: S.markStart.y, rx: Math.max(8, Math.abs(p.x - S.markStart.x)), ry: Math.max(8, Math.abs(p.y - S.markStart.y)) }; renderAt(true); }); window.addEventListener('mouseup', () => { if (S.marking === true && S.region) { S.marking = false; el('tl-hint').classList.add('hidden'); rebuildStrokes(); renderAt(); } }); } // ── 절차적 모발 스트로크 사전 생성(안정적 progressive 성장) ── function rebuildStrokes() { if (!S.region || !S.img) return; sampleHairColor(); const R = S.region, area = Math.PI * R.rx * R.ry; const N = Math.max(700, Math.min(5200, Math.round(area / 16))); const arr = new Array(N); for (let i = 0; i < N; i++) { const a = Math.random() * Math.PI * 2, rad = Math.sqrt(Math.random()); const t = rad; arr[i] = { x: R.cx + rad * Math.cos(a) * R.rx, y: R.cy + rad * Math.sin(a) * R.ry, t, thrEdge: 1 - t, thrUniform: Math.random(), len: ((R.rx + R.ry) / 2) * (0.035 + Math.random() * 0.05), ang: Math.PI / 2 + (Math.random() - 0.5) * 0.9, shade: 0.55 + Math.random() * 0.45, }; } S.strokes = arr; } function sampleHairColor() { try { const cv = el('tl-canvas'), x = gctx(); x.clearRect(0, 0, cv.width, cv.height); x.drawImage(S.img, 0, 0, cv.width, cv.height); const img = x.getImageData(0, 0, cv.width, cv.height).data; const R = S.region; let rs = 0, gs = 0, bs = 0, n = 0; for (let k = 0; k < 500; k++) { const a = Math.random() * Math.PI * 2, rad = 1.08 + Math.random() * 0.22; const px = Math.round(R.cx + rad * Math.cos(a) * R.rx), py = Math.round(R.cy + rad * Math.sin(a) * R.ry); if (px < 0 || py < 0 || px >= cv.width || py >= cv.height) continue; const i = (py * cv.width + px) * 4; rs += img[i]; gs += img[i + 1]; bs += img[i + 2]; n++; } if (n > 20) { S.hairColor = `rgb(${Math.round(rs / n * 0.75)},${Math.round(gs / n * 0.72)},${Math.round(bs / n * 0.7)})`; } } catch (e) { S.hairColor = '#34281d'; } } // ── 렌더 ── function renderAt(dragging) { const cv = el('tl-canvas'); if (!cv.width) return; const x = gctx(); x.clearRect(0, 0, cv.width, cv.height); if (S.img) x.drawImage(S.img, 0, 0, cv.width, cv.height); const R = S.region; if (R && S.strokes && !dragging) { const r = Math.max(0, recoveryAtMonth(curMonth())); // 탈락(음수)은 빈 두피로 const useEdge = S.disease.style === 'edge'; x.lineCap = 'round'; for (const s of S.strokes) { const thr = useEdge ? s.thrEdge : s.thrUniform; if (thr > r) continue; x.strokeStyle = shade(S.hairColor, s.shade); x.lineWidth = 1.1; x.beginPath(); x.moveTo(s.x, s.y); x.lineTo(s.x + Math.cos(s.ang) * s.len, s.y + Math.sin(s.ang) * s.len); x.stroke(); } } if (R) { x.save(); x.setLineDash([6, 5]); x.strokeStyle = 'rgba(200,64,31,.55)'; x.lineWidth = 1.5; x.beginPath(); x.ellipse(R.cx, R.cy, R.rx, R.ry, 0, 0, Math.PI * 2); x.stroke(); x.restore(); } watermark(x, cv); updateReadout(); } function shade(rgb, f) { const m = rgb.match(/\d+/g) || [52, 40, 29]; return `rgb(${Math.round(m[0] * f)},${Math.round(m[1] * f)},${Math.round(m[2] * f)})`; } function watermark(x, cv) { x.save(); x.font = "12px 'Pretendard Variable', sans-serif"; x.textAlign = 'right'; x.fillStyle = 'rgba(255,255,255,.82)'; x.strokeStyle = 'rgba(0,0,0,.45)'; x.lineWidth = 3; const msg = "모델+문헌 일러스트 · 임상 예측 아님"; x.strokeText(msg, cv.width - 10, cv.height - 10); x.fillText(msg, cv.width - 10, cv.height - 10); x.restore(); } function updateReadout() { const tp = TIMEPOINTS[S.ti]; el('tl-month-label').textContent = tp.l; el('tl-month-sub').textContent = tp.m >= 1 ? '· 치료 ' + tp.m + '개월차' : '· 치료 ' + Math.round(tp.m * 30) + '일차'; if (S.target == null) return; const dens = densityAtMonth(tp.m), rec = Math.max(0, recoveryAtMonth(tp.m)) * 100; el('tl-density').textContent = dens.toFixed(0) + '% (~' + Math.round(dens / 100 * NORMAL_DENSITY) + '/cm²)'; el('tl-recovery').textContent = rec.toFixed(0) + '%'; el('tl-ivs-readout').textContent = S.ivs.length ? S.ivs.map(i => IV_KO[i] || i).join(' + ') : '무처치'; } // 밀도 곡선(임상 개월) — '양=모델, 속도=문헌' 결합 곡선을 보여주는 근거 차트 function renderChart() { const ctx = el('chart-timeline'); if (!ctx || S.target == null) return; const labels = TIMEPOINTS.map(p => p.l); const vals = TIMEPOINTS.map(p => +densityAtMonth(p.m).toFixed(1)); const data = { labels, datasets: [{ label: '모발 밀도 %', data: vals, borderColor: '#c8401f', backgroundColor: 'rgba(200,64,31,.10)', fill: true, tension: .3, pointRadius: 2, borderWidth: 2 }] }; 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: 9, autoSkip: true }, grid: { display: false } } }, plugins: { legend: { display: false } }, }; if (S.chart) { S.chart.data = data; S.chart.options = opts; S.chart.update('none'); } else S.chart = new Chart(ctx, { type: 'line', data, options: opts }); } window.TimelineTab = { init }; })();