315 lines
16 KiB
JavaScript
315 lines
16 KiB
JavaScript
/* ============================================================
|
||
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 후 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 };
|
||
})();
|