/* ============================================================
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 };
})();