alopecia/js/timeline.js

315 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
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) =>
`<button class="seg-btn${i === 0 ? ' active' : ''}" data-i="${i}">
<span>${d.ko}</span><span class="seg-sub">${d.sub}</span></button>`).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 =>
`<button class="chip${S.ivs.includes(iv) ? ' active' : ''}" data-iv="${iv}">${IV_KO[iv] || iv}</button>`).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 후 1exp 상승 + 초기 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 };
})();