alopecia/js/validation.js

670 lines
47 KiB
JavaScript
Raw Permalink 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.

/* ============================================================
validation.js — 검증(Validation) 탭
다층 독립 검증 결과(data/validation_results.json)를 시각화.
============================================================ */
(function () {
'use strict';
let data = null, synergy = null, uq = null, pers = null, pwr = null, pwrm = null, ode = null, calib = null, ipd = null, exvivo = null, synClin = null, synRev = null, synReT = null, comp = null, scarm = null, qr = null, biph = null, hfoc = null, charts = {}, done = false;
const el = id => document.getElementById(id);
const VERM = '#c8401f', TEAL = '#1f5d52', INK = '#1b1a16', MUTE = '#6b655a',
GOOD = '#1f6d3a', BAD = '#b3361b', WARN = '#9a6a12', GRID = 'rgba(0,0,0,.08)';
async function init() {
if (done) return; done = true;
try { data = await fetch('data/validation_results.json').then(r => r.json()); }
catch (e) { el('val-summary').innerHTML = '<p class="panel-subtitle">검증 데이터 로드 실패: ' + e + '</p>'; return; }
try { synergy = await fetch('data/synergy_prediction.json').then(r => r.json()); } catch (e) {}
try { uq = await fetch('data/bayes_uq_results.json').then(r => r.json()); } catch (e) {}
try { pers = await fetch('data/personalize_results.json').then(r => r.json()); } catch (e) {}
try { pwr = await fetch('data/power_simulation.json').then(r => r.json()); } catch (e) {}
try { pwrm = await fetch('data/power_molecular.json').then(r => r.json()); } catch (e) {}
try { ode = await fetch('data/ode_personalize.json').then(r => r.json()); } catch (e) {}
try { calib = await fetch('data/mapping_calibration.json').then(r => r.json()); } catch (e) {}
try { ipd = await fetch('data/ipd_dryrun.json').then(r => r.json()); } catch (e) {}
try { exvivo = await fetch('data/exvivo_validation.json').then(r => r.json()); } catch (e) {}
try { comp = await fetch('data/comparator_validation.json').then(r => r.json()); } catch (e) {}
try { scarm = await fetch('data/synthetic_control_validation.json').then(r => r.json()); } catch (e) {}
try { synClin = await fetch('data/synergy_clinical_test.json').then(r => r.json()); } catch (e) {}
try { synRev = await fetch('data/synergy_revised.json').then(r => r.json()); } catch (e) {}
try { synReT = await fetch('data/synergy_retest.json').then(r => r.json()); } catch (e) {}
try { qr = await fetch('data/quant_recalibration.json').then(r => r.json()); } catch (e) {}
try { biph = await fetch('data/biphasic_model.json').then(r => r.json()); } catch (e) {}
try { hfoc = await fetch('data/hfoc_calibration_dryrun.json').then(r => r.json()); } catch (e) {}
renderSummary(); renderLandscape(); renderMolecular(); renderAgaDp(); renderJak();
renderAaSc(); renderGwas(); renderCandidates(); renderTiming(); renderBenchmark();
renderExvivo(); renderSynergy(); renderSynergyRev(); renderUQ(); renderPersonalize(); renderPower(); renderPowerMol(); renderOde(); renderCalib(); renderIpd(); renderComparator(); renderNAM();
}
// 대조군 자격 — 보정곡선 + context 점수표
function renderComparator() {
const C = comp; if (!C || !C.calibration) return;
const cv = C.calibration, cu = C.context_of_use || {};
const cov90 = (cv.curve.find(x => x.nominal === 0.9) || {}).empirical;
const t1 = (cu.tier1_calibrated_comparator || []).length, t2 = (cu.tier2_directional_comparator || []).length, t3 = (cu.tier3_not_yet || []).length;
el('val-comp-headline').innerHTML = [
[cv.calibration_error, '보정오차(↓좋음)'], [Math.round((cov90 || 0) * 100) + '%', '90% 커버리지'],
[t1 + '개', '보정된 비교군'], [t2 + '개', '방향 비교군'], [t3 + '개', '미달(전향/IPD)'],
].map(([v, l]) => `<div class="vstat"><div class="vsv">${v}</div><div class="vsl">${l}</div></div>`).join('');
mk('chart-val-comp-cal', 'line', {
labels: cv.curve.map(x => Math.round(x.nominal * 100) + '%'),
datasets: [
{ label: '경험적 커버리지', data: cv.curve.map(x => Math.round(x.empirical * 100)), borderColor: VERM, backgroundColor: 'rgba(200,64,31,.10)', borderWidth: 2.5, pointRadius: 4, tension: .2, fill: false },
{ label: '이상(=명목)', data: cv.curve.map(x => Math.round(x.nominal * 100)), borderColor: INK, borderDash: [5, 4], borderWidth: 1.3, pointRadius: 0 },
]
}, {
plugins: { legend: { labels: { color: INK, boxWidth: 14, font: { size: 10 } } },
title: { display: true, text: '보정곡선: 빨강이 점선(이상)에 붙음 = 구간 정직(보정됨)', color: MUTE, font: { size: 11 } } },
scales: { y: { min: 0, max: 100, title: { display: true, text: '경험적 %', color: MUTE }, ticks: { color: MUTE, callback: v => v + '%' }, grid: { color: GRID } },
x: { title: { display: true, text: '명목 신뢰수준', color: MUTE }, ticks: { color: MUTE }, grid: { display: false } } }
});
const TC = { 1: GOOD, 2: '#2f63c8', 3: BAD };
const rows = (C.scorecard || []).map(r =>
`<tr><td><span class="vb" style="background:${TC[r.tier]}">T${r.tier}</span></td><td><b>${r.context}</b></td><td>${r.verdict}</td><td class="vc-met">${r.metric}</td></tr>`).join('');
el('val-comp-table').innerHTML = `<table class="val-table"><thead><tr><th>등급</th><th>context</th><th>판정</th><th>근거</th></tr></thead><tbody>${rows}</tbody></table>`;
// synthetic control arm — 실제 RCT 위약 대비
if (scarm && scarm.diseases) {
const dim = 'color:#8a8f98;font-size:11px';
const srows = Object.keys(scarm.diseases).map(dz => {
const s = scarm.diseases[dz];
const eq = s.equivalent ? `<span class="vb" style="background:${GOOD}">동등 ✓</span>` : `<span class="vb" style="background:${BAD}">미입증</span>`;
const pstr = s.tost_p_equiv < 0.001 ? 'p&lt;0.001' : 'p=' + s.tost_p_equiv;
const consv = (s.tost_p_equiv_conservative_SE != null && s.tost_p_equiv_conservative_SE >= 0.05)
? ` <span style="${dim}">(보수SE p=${s.tost_p_equiv_conservative_SE})</span>` : '';
return `<tr>`
+ `<td><b>${dz}</b><br><span style="${dim}">트윈평형 ${s.twin_equilibrium_density_pct}%</span></td>`
+ `<td>${s.real_placebo_mean}±${s.real_placebo_se} ${s.unit}<br><span style="${dim}">${s.n_trials}arm · n=${s.n_subjects}</span></td>`
+ `<td>${s.twin_control} <span style="${dim}">(실행)</span></td>`
+ `<td>${eq} ${pstr}${consv}</td>`
+ `<td>raw ${s.effect_twin_raw} <b>(${s.bias_raw_pct}%)</b><br><span style="${dim}">+오버레이 ${s.effect_twin_corrected} (${s.bias_corrected_pct}%)</span></td>`
+ `</tr>`;
}).join('');
const v = scarm.verdict || {};
const aaMae = scarm.diseases.AA && scarm.diseases.AA.nat_overlay_loto_mae;
el('val-comp-scarm').innerHTML =
`<div class="ipd-warn" style="border-left-color:${GOOD};background:rgba(31,109,58,.07);border-color:rgba(31,109,58,.3)">✅ <b>무작위 대조군(RCT) 검증 — mechanistic synthetic control arm</b><br>트윈을 <b>실제 실행</b>해(질환 평형 등록자 모사) 무치료 대조군 readout 도출 = <b>0 변화</b>(자연사 미모델, 위약데이터 미접촉=held-out·공정). 이 기전 대조군이 <b>실제 RCT 위약 arm과 동등(TOST)</b>: ${v.equivalence}. 치료효과 재구성 편향 raw <b>${v.max_effect_bias_raw_pct}%</b> → 경험 오버레이 보정 후 <b>${v.max_effect_bias_corrected_pct}%</b>(in-sample).</div>`
+ `<table class="val-table" style="margin-top:6px"><thead><tr><th>질환</th><th>실제 위약(arm·n)</th><th>트윈 대조(실행)</th><th>동등성(TOST·마진±15)</th><th>효과재구성: raw / +오버레이</th></tr></thead><tbody>${srows}</tbody></table>`
+ `<div style="${dim};margin-top:6px;line-height:1.6">⚠ <b>정직한 경계:</b> ① <b>회고적</b>(기존 RCT 위약). ② 대조군 <b>평균</b> 재현이지 개인변동(위약 SD) 아님. ③ 동등성은 게시 dispersion=<b>SD 가정</b>; 보수적 <b>SE 가정 시 AGA p≈0.10·AA p≈0.26로 미달</b>(헤드라인 효과-편향비는 가정無, &lt;12%). ④ 자연사 갭(AGA 보수/AA 비보수)을 메우는 <b>경험 오버레이는 트윈 기전 아님·in-sample</b>; 교차시험 일반화는 LOTO 예비${aaMae != null ? `(AA 2arm MAE ${aaMae}%)` : ''}. ⑤ 규제 qualification은 전향+공변량 매칭 필요.</div>`;
}
}
// ⑨ 동물실험 대체 경로 — NAM 자격 프로그램 Phase 0
function renderNAM() {
const host = el('val-nam'); if (!host) return;
const dim = 'color:#8a8f98;font-size:11px';
const badge = (txt, bg) => `<span class="vb" style="background:${bg}">${txt}</span>`;
const parts = [];
// 헤드라인: 대체 가능성 정직 프레이밍
parts.push(`<div class="ipd-warn" style="border-left-color:${WARN};background:rgba(154,106,18,.07);border-color:rgba(154,106,18,.3)">`
+ `🐭→🧪 <b>쥐 실험 대체?</b> <b>전체 대체는 불가</b>(신규기전 발견·전신 PK/독성·전임상 안전성은 어떤 모델로도 영구 제외). `
+ `<b>특정 효능 어세이 1개</b>(기전기지 JAK 화합물 AA발모)를 <b>인체 HFOC + 정량 트윈</b> NAM으로 대체하는 경로만 실재. 아래=<b>Phase 0(건식)</b> 결과.</div>`);
// Phase 0-A: 정량 트윈 동역학 전이성 (make-or-break)
if (qr && qr.overall) {
const o = qr.overall, pass = o.meets_threshold_M2;
parts.push(`<div style="margin-top:8px"><b>① 정량 트윈 동역학 전이성</b> (대체급 도달 가능성, LOTO)</div>`
+ `<div class="val-headline" style="margin-top:4px">`
+ `<div class="vstat"><div class="vsv" style="color:${pass ? GOOD : BAD}">${o.M2_r2_monotone_only != null ? o.M2_r2_monotone_only : o.M2_r2}</div><div class="vsl">M2 형태-전이 R²(단조)</div></div>`
+ `<div class="vstat"><div class="vsv">${o.M2_r2}</div><div class="vsl">M2 전체 R²</div></div>`
+ `<div class="vstat"><div class="vsv" style="color:${WARN}">${o.M1_r2}</div><div class="vsl">M1 군-외삽(정보0·약함)</div></div>`
+ `<div class="vstat"><div class="vsv">${pass ? badge('≥0.8 통과', GOOD) : badge('미달', BAD)}</div><div class="vsl">게이트</div></div>`
+ `</div>`
+ `<div style="${dim};margin-top:2px">→ 동역학(lag/τ)은 새 화합물에 전이되어 <b>대체급</b>, 단 진폭은 HFOC가 공급(맨손 외삽 M1은 약함) = <b>분업 검증</b>.</div>`);
}
// Phase 0-B: biphasic 결함 폐쇄
if (biph && biph.summary) {
const s = biph.summary, fin = (biph.trajectories || {})['finasteride_1mg_5yr_DECLINE'] || {};
parts.push(`<div style="margin-top:10px"><b>② biphasic 결함 폐쇄</b> — 단조 1-exp가 못 내던 '상승-후-감소'(후기 자연사 진행)</div>`
+ `<div style="${dim};margin-top:2px;line-height:1.6">내포모델(biphasic⊇단조). 피나 5yr 감소: 단조 R²<b>${fin.mono ? fin.mono.r2 : ''}</b>(종점 ${fin.mono ? fin.mono.last_pt_err : ''} hairs 못 따라감) → biphasic은 wane항으로 표현(종점오차 ${s.biphasic_targets_mean_lastpt_err_mono}${s.biphasic_targets_mean_lastpt_err_biphasic}). 단조 대조 불변(ΔR²~${s.mono_ctrl_mean_abs_r2_diff}). <i>정직: 표적 3점→표현 시연이지 통계검증 아님.</i></div>`);
}
// Phase 0-C: HFOC 보정 하니스 dry-run (합성)
if (hfoc) {
const rep = hfoc.representative_rho090 || {}, sw = hfoc.rho_sweep || [];
const swStr = sw.filter(x => [0.8, 0.9, 0.95, 1.0].includes(x.rho)).map(x => `ρ${x.rho}:${x.G2_r2}`).join(' · ');
parts.push(`<div style="margin-top:10px"><b>③ HFOC 보정 하니스</b> — 사전등록 게이트 (⚠ <b>합성 dry-run</b>, 실 HFOC 아님)</div>`
+ `<div style="${dim};margin-top:2px;line-height:1.6">G1 동역학 적합 R²<b>${rep.G1_kinetic_r2}</b> ${rep.G1_kinetic_r2 >= 0.8 ? badge('통과', GOOD) : ''} · G2 생체외삽(ρ0.9) ${rep.G2_bridge_r2}. `
+ `<b>2병목 발견</b>: G2 R² 천장≈ρ²(HFOC↔생체 번역충실도) + 추정잡음(모낭수). ρ스윕 [${swStr}] → <b>G2≥0.8엔 ρ≳0.92 + 충분 모낭/패널</b>. `
+ `<b>ρ는 wet 종간 브리지로만 측정</b> → 하니스는 '준비됨'이지 '대체 입증' 아님.</div>`);
}
// 4단계 게이트 사다리
const ladder = [
['0 건식 타당성', '동역학 전이성·biphasic·하니스', 'M2 R²≥0.8', badge('통과', GOOD)],
['1 습식 파일럿', 'HFOC + JAK 3~5종 → 진폭·bioCV', 'HFOC 적합 R²≥0.8', badge('미수행(wet)', WARN)],
['2 전향·맹검', '사전등록 held-out 예측', '예측 R²≥0.8', badge('⏳', WARN)],
['3 종간 브리지', '마우스 vs NAM vs 인체', 'NAM≥마우스(인체예측)', badge('⏳ 핵심', WARN)],
['4 공인', 'ring trial + ISTAND/OECD', '재현+독립검증', badge('⏳', WARN)],
].map(r => `<tr><td><b>${r[0]}</b></td><td>${r[1]}</td><td style="${dim}">${r[2]}</td><td>${r[3]}</td></tr>`).join('');
parts.push(`<table class="val-table" style="margin-top:10px"><thead><tr><th>Phase</th><th>내용</th><th>사전 게이트</th><th>상태</th></tr></thead><tbody>${ladder}</tbody></table>`);
// 정직한 경계
parts.push(`<div style="${dim};margin-top:6px;line-height:1.6">⚠ <b>정직:</b> Phase 0(건식)만 완료 — 기술 급소(동역학 전이 R²${qr && qr.overall ? qr.overall.M2_r2_monotone_only : ''})는 통과했으나 <b>Phase 1~4는 다년·고비용 wet-lab+규제</b>(미수행). 성공해도 <b>AA/JAK 효능 스크린 1개</b> 대체일 뿐, 전체 대체 아님.</div>`);
host.innerHTML = parts.join('');
}
// 반증 반영 모델 수정 — 시너지 vs 축겹침(overlap)
function renderSynergyRev() {
const R = synRev; if (!R || !R.overlap_sweep) return;
const sw = R.overlap_sweep;
el('val-synrev-note').innerHTML = `<b>모델 수정(반증 반영)</b>: 미녹시딜의 Wnt 활성 → 피나와 W축 <b>겹침</b> 도입. 시너지는 overlap≈${R.synergy_crosses_zero_near}에서 가법미만 전환 → <b>피나×미녹(겹침 0.55)은 가법</b>(IJT 정합). <b>정정</b>: AR차단×Wnt-agonist도 둘 다 W축이라 <b>중복=무효</b>(이전 제안 오류). <b>정련된 새 예측: 초가법은 *진짜 직교 축 쌍*(한쪽 Wnt무관 D약물)에서만 생존</b> — 직교 약물쌍 전향 검정 필요.`;
mk('chart-val-synrev', 'line', {
labels: sw.map(x => x.overlap),
datasets: [
{ label: '시너지 초과', data: sw.map(x => x.synergy_excess), borderColor: VERM, backgroundColor: 'rgba(200,64,31,.10)', borderWidth: 2.5, pointRadius: 3, tension: .3, fill: true },
{ type: 'line', label: '가법(0)', data: sw.map(() => 0), borderColor: INK, borderDash: [5, 4], borderWidth: 1.2, pointRadius: 0 },
]
}, {
plugins: { legend: { labels: { color: INK, boxWidth: 12, font: { size: 10 } } },
title: { display: true, text: '축 겹침↑ → 시너지 소멸 (직교=초가법, 겹침=가법미만)', color: MUTE, font: { size: 11 } } },
scales: { y: { title: { display: true, text: '시너지 초과(Bliss)', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } },
x: { title: { display: true, text: 'ARM-2의 W축 겹침(overlap)', color: MUTE }, ticks: { color: MUTE }, grid: { display: false } } }
});
// 다중-시험 재검정 — 정직한 verdict (겹침端 가법미만 / 직교축 포함 초가법 시사)
if (synReT && synReT.analysis) {
const ij = synReT.analysis.IJT2023 || {}, fp = synReT.analysis.FPHL2022 || {}, th = synReT.analysis.TH07 || {};
el('val-retest-note').innerHTML = `<b>재검정(다중 시험)</b>: ① <b>IJT 피나×미녹</b>(겹침高, full 2×2) 초가법초과 ${ij.super_additive_excess} = <b>가법미만</b>(모델 겹침端 정합) · ② <b>FPHL 미녹+스피로</b>(W축) 한계이득 +${fp.spt_add_benefit} &lt; 미녹단독 +${fp.mino_mono}(겹침 정합) · ③ <b>TH07 삼중</b>(직교쌍 피나W×라타노프로스트 비-Wnt D 포함): 삼중 ${th.triple} ≫ 단독합 ${th.mono_sum} → 선형시너지 <b>+${th.linear_synergy} = 초가법</b>. <br><b>→ 정련 예측에 시사적 지지</b>: 겹침 쌍=가법미만, 직교축 포함=초가법(대조 정합). <b>단 확정 아님</b>(TH07은 쌍 아닌 삼중·n3-4·반정량·미녹 침투촉진 교란·산업체) — 깨끗한 W축×Wnt무관 D약물 *쌍* 전향 2×2 필요(예: AR차단×PGF2α/아데노신).`;
}
}
// 실제 ex vivo 인체 모낭 검증 (GSE267664 DHT)
function renderExvivo() {
const E = exvivo; if (!E || !E.concordance) return;
const c = E.concordance, k = E.key_DKK1 || {};
el('val-exvivo-headline').innerHTML = [
[c.n_match + '/' + c.n_test, 'Wnt축 방향 일치'],
['p=' + c.sign_test_p, '부호검정'],
[(k.log2fc >= 0 ? '+' : '') + k.log2fc, 'DKK1 log2FC(Wnt길항↑)'],
['n=3', 'ex vivo 모낭'],
].map(([v, l]) => `<div class="vstat"><div class="vsv">${v}</div><div class="vsl">${l}</div></div>`).join('');
const m = (E.markers || []).filter(x => x.in_test);
mk('chart-val-exvivo', 'bar', {
labels: m.map(x => x.gene),
datasets: [{ label: 'log2FC (DHT vs control)', data: m.map(x => x.log2fc),
backgroundColor: m.map(x => x.match ? GOOD : BAD), borderRadius: 3 }]
}, {
plugins: { legend: { display: false },
title: { display: true, text: '실제 모낭 DHT 반응이 트윈 Wnt억제 예측과 협응(초록=일치)', color: MUTE, font: { size: 11 } } },
scales: { y: { title: { display: true, text: 'log2FC (DHT vs ctrl)', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } },
x: { ticks: { color: INK, font: { size: 10 } }, grid: { display: false } } }
});
}
// 트랙 B — IPD 사전등록 분석 하니스 (합성 dry-run)
function renderIpd() {
const I = ipd; if (!I || !I.result) return;
const r = I.result;
el('val-ipd-headline').innerHTML = [
[r.rmse_pop_mean, 'RMSE 모집단'], [r.rmse_personal_mean, 'RMSE 개인화'],
[(r.improve_pct >= 0 ? '+' : '') + r.improve_pct + '%', '개선(불확정)'],
[Math.round(r.coverage_personal * 100) + '%', '구간 커버리지'],
[r.personal_wins + '/' + r.n_patients, '개인화 우세'],
].map(([v, l]) => `<div class="vstat"><div class="vsv" style="font-size:19px">${v}</div><div class="vsl">${l}</div></div>`).join('');
el('val-ipd-verdict').innerHTML = `사전등록 판정: <b>${r.decision}</b> &nbsp;(95% CI ${JSON.stringify(r.diff_ci95)} — 0 포함)`;
mk('chart-val-ipd', 'bar', {
labels: ['모집단', '개인화'],
datasets: [{ label: '후기 forecast RMSE', data: [r.rmse_pop_mean, r.rmse_personal_mean],
backgroundColor: [MUTE, TEAL], borderRadius: 3 }]
}, {
plugins: { legend: { display: false },
title: { display: true, text: '개인 수준: 거의 동일(시험암 +40%와 대조) — 합성', color: MUTE, font: { size: 11 } } },
scales: { y: { title: { display: true, text: 'RMSE (SALT)', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } },
x: { ticks: { color: INK, font: { size: 12 } }, grid: { display: false } } }
});
}
// 트랙 C — 매핑 보정 (GWAS 마커중요도 + 섭동)
function renderCalib() {
const C = calib; if (!C) return;
// GWAS 마커 중요도 (mlogp), AGA·AA 색 구분, GW-sig 7.3 기준선
const items = [];
['AGA', 'AA'].forEach(dz => {
const g = (C.gwas || {})[dz] || {};
Object.keys(g).forEach(k => { if (g[k] > 0) items.push({ gene: k, dz, mlogp: g[k] }); });
});
items.sort((a, b) => b.mlogp - a.mlogp);
const top = items.slice(0, 12);
if (top.length) {
mk('chart-val-calib-gwas', 'bar', {
labels: top.map(x => x.gene + '·' + x.dz),
datasets: [
{ type: 'line', label: 'GW-sig 7.3', data: top.map(() => 7.3), borderColor: INK, borderDash: [5, 4], borderWidth: 1.2, pointRadius: 0 },
{ label: 'log10 p', data: top.map(x => x.mlogp), backgroundColor: top.map(x => x.dz === 'AGA' ? VERM : TEAL), borderRadius: 3 },
]
}, {
indexAxis: 'y',
plugins: { legend: { labels: { color: INK, boxWidth: 12, font: { size: 10 } } },
title: { display: true, text: 'GWAS 마커 중요도 (빨강 AGA·청록 AA; 7.3=유의)', color: MUTE, font: { size: 11 } } },
scales: { x: { title: { display: true, text: 'log10 p (mlogp)', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } },
y: { ticks: { color: INK, font: { size: 10 } }, grid: { display: false } } }
});
}
// 섭동: DHT Wnt 억제(DKK1/LEF1/AXIN2 logΔ) + JAK-i IFN(vehicle vs jaki)
const p = C.perturb || {}; const dht = (p.DHT_Wnt || {}).genes || {}; const jak = p.JAKi_IFN || {};
const dl = ['DKK1', 'LEF1', 'AXIN2'].filter(g => dht[g]);
const labels = dl.map(g => 'DHT→' + g).concat(jak.vehicle_IFN != null ? ['JAK전 IFN', 'JAK후 IFN'] : []);
const vals = dl.map(g => dht[g].log_delta).concat(jak.vehicle_IFN != null ? [jak.vehicle_IFN, jak.jaki_IFN] : []);
const cols = dl.map(g => dht[g].log_delta >= 0 ? BAD : GOOD).concat(jak.vehicle_IFN != null ? [BAD, GOOD] : []);
if (labels.length) {
mk('chart-val-calib-perturb', 'bar', {
labels, datasets: [{ label: '효과(logΔ / IFN시그니처)', data: vals, backgroundColor: cols, borderRadius: 3 }]
}, {
plugins: { legend: { display: false },
title: { display: true, text: '섭동 보정: DHT→Wnt억제 · JAK-i→IFN감소 (실측)', color: MUTE, font: { size: 11 } } },
scales: { y: { title: { display: true, text: '효과 크기', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } },
x: { ticks: { color: INK, font: { size: 9 } }, grid: { display: false } } }
});
}
}
// 분자 readout & 설계 — 무엇이 표본을 줄이는가
function renderPowerMol() {
const M = pwrm; if (!M || !M.strategies) return;
const s = M.strategies;
const shortName = n => n.replace(/\(.*?\)/g, '').replace(/모낭내 |신장기울기/g, '').trim();
const colors = ['#b9b2a6', WARN, TEAL, VERM];
mk('chart-val-powermol', 'bar', {
labels: s.map((r, i) => shortName(r.strategy)),
datasets: [{ label: '총 모낭(80% 검정력)', data: s.map(r => r.total_follicles),
backgroundColor: s.map((_, i) => colors[i % colors.length]), borderRadius: 3 }]
}, {
indexAxis: 'y',
plugins: { legend: { display: false },
title: { display: true, text: '표본 절감은 readout이 아니라 유효 CV↓ 설계에서 (1000→300)', color: MUTE, font: { size: 11 } } },
scales: { x: { title: { display: true, text: '총 모낭 수', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } },
y: { ticks: { color: INK, font: { size: 10 } }, grid: { display: false } } }
});
}
// 실험 검정력 (몬테카를로)
function renderPower() {
const P = pwr; if (!P || !P.scenarios) return;
const rec = P.recommendation || {};
const fmtRec = r => r ? ('공여자 ' + r.donors + '×' + r.follicles_per_group + '모낭') : '비현실';
el('val-pwr-headline').innerHTML = Object.keys(P.scenarios).map(name => {
const r = P.scenarios[name].reco_80pct;
return `<div class="vstat"><div class="vsv" style="font-size:18px">${fmtRec(r)}</div><div class="vsl">${name}</div></div>`;
}).join('');
const NF = [10, 15, 20, 30, 40, 60, 80, 120];
const pal = { '기준(E=0.6)': VERM, '낙관(E=0.6,저변동)': TEAL, '보수(E=0.4)': MUTE };
const ds = Object.keys(P.scenarios).map(name => {
const g = P.scenarios[name].grid;
return { label: name, data: NF.map(nf => Math.round((g['4d_' + nf + 'f'] || 0) * 100)),
borderColor: pal[name] || INK, backgroundColor: 'transparent', borderWidth: 2.5, pointRadius: 2, tension: .3 };
});
ds.push({ label: '80% 목표', data: NF.map(() => 80), borderColor: INK, borderDash: [5, 4], borderWidth: 1.2, pointRadius: 0 });
mk('chart-val-power', 'line', { labels: NF.map(n => n + '개'), datasets: ds }, {
plugins: { legend: { labels: { color: INK, boxWidth: 14, font: { size: 10 } } },
title: { display: true, text: '검정력 vs 군당 모낭수 (공여자 4)', color: MUTE, font: { size: 11 } } },
scales: { y: { min: 0, max: 100, title: { display: true, text: '검정력 %', color: MUTE }, ticks: { color: MUTE, callback: v => v + '%' }, grid: { color: GRID } },
x: { title: { display: true, text: '군당 모낭 수', color: MUTE }, ticks: { color: MUTE }, grid: { display: false } } }
});
}
// ODE-수준 분자 개인화
function renderOde() {
const O = ode; if (!O || !O.profiles) return;
const names = Object.keys(O.profiles);
const get = (n, t) => { const r = O.profiles[n].responses; return r && r[t] != null ? r[t] : 0; };
const series = [
{ key: '피나스테리드', color: '#2f63c8' }, { key: '미녹시딜', color: WARN },
{ key: '병용(피나+미녹)', color: VERM }, { key: 'JAK억제제', color: TEAL },
];
el('val-ode-headline').innerHTML = names.map(n => {
const p = O.profiles[n];
const shift = (p.aa_strength > p.aga_strength)
? 'AA ' + p.aa_strength
: ((p.aga_q != null ? p.aga_q : p.aga_strength) + '→' + p.aga_strength);
return `<div class="vstat"><div class="vsv" style="font-size:16px">${shift}</div><div class="vsl">${n.split('·')[1] || n} · ${p.recommendation}</div></div>`;
}).join('');
mk('chart-val-ode', 'bar', {
labels: names,
datasets: series.map(s => ({ label: s.key, data: names.map(n => get(n, s.key)), backgroundColor: s.color, borderRadius: 3 }))
}, {
plugins: { legend: { labels: { color: INK, boxWidth: 12, font: { size: 10 } } },
title: { display: true, text: 'GWAS-가중 보정 후 — 프로파일별 예측 회복(분자 층화)', color: MUTE, font: { size: 11 } } },
scales: { y: { title: { display: true, text: '예측 회복(∫anagen)', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } },
x: { ticks: { color: INK, font: { size: 10 } }, grid: { display: false } } }
});
}
// 개인화 + 데이터 동화
function renderPersonalize() {
const P = pers; if (!P || !P.forecast_skill) return;
const ov = P.forecast_skill.overall || {};
el('val-pers-headline').innerHTML = [
['+' + (ov.mean_improve_pct || 0) + '%', 'forecast 개선(RMSE↓)'],
[(ov.personal_wins || 0) + '/' + (ov.n || 0), '개인화 우세'],
[Math.round((ov.cover_personal || 0) * 100) + '%', '개인 커버리지'],
[Math.round((ov.cover_pop || 0) * 100) + '%', '모집단 커버리지'],
].map(([v, l]) => `<div class="vstat"><div class="vsv">${v}</div><div class="vsl">${l}</div></div>`).join('');
// 동화 시연: 관측 2점(넓음) vs 전체(좁음) 밴드 + 실측
const ex = P.assimilation_example;
if (ex && ex.steps && ex.steps.length) {
const xy = (xs, ys) => xs.map((x, i) => ({ x, y: ys[i] }));
const first = ex.steps[0], last = ex.steps[ex.steps.length - 1];
mk('chart-val-assim', 'line', {
datasets: [
{ label: '_a', data: xy(first.grid, first.hi), borderColor: 'transparent', backgroundColor: 'rgba(154,106,18,.10)', pointRadius: 0, fill: '+1', order: 4 },
{ label: first.n_obs + '점 관측 90%', data: xy(first.grid, first.lo), borderColor: 'transparent', backgroundColor: 'rgba(154,106,18,.10)', pointRadius: 0, fill: false, order: 4 },
{ label: '_b', data: xy(last.grid, last.hi), borderColor: 'transparent', backgroundColor: 'rgba(31,93,82,.20)', pointRadius: 0, fill: '+1', order: 3 },
{ label: last.n_obs + '점 관측 90%', data: xy(last.grid, last.lo), borderColor: 'transparent', backgroundColor: 'rgba(31,93,82,.20)', pointRadius: 0, fill: false, order: 3 },
{ label: '개인화 예측', data: xy(last.grid, last.median), borderColor: TEAL, borderWidth: 2.5, pointRadius: 0, order: 2 },
{ label: '실측', data: ex.points.map(p => ({ x: p[0], y: p[1] })), type: 'scatter', borderColor: VERM, backgroundColor: VERM, pointRadius: 4, order: 1 },
]
}, {
plugins: { legend: { labels: { color: INK, boxWidth: 12, font: { size: 10 }, filter: it => !it.text.startsWith('_') } },
title: { display: true, text: '관측 늘릴수록 예측띠 수축(데이터 동화)', color: MUTE, font: { size: 11 } } },
scales: { x: { type: 'linear', title: { display: true, text: '개월', color: MUTE }, ticks: { color: MUTE }, grid: { display: false } },
y: { title: { display: true, text: 'SALT %변화', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } } }
});
}
// 궤적별 forecast 개선%
const rows = P.forecast_skill.by_trajectory || [];
const shortId = s => s.replace(/_NCT\d+/, '').replace(/_/g, ' ').slice(0, 22);
mk('chart-val-fskill', 'bar', {
labels: rows.map(r => shortId(r.id)),
datasets: [{ label: 'forecast 개선%', data: rows.map(r => r.improve_pct),
backgroundColor: rows.map(r => r.improve_pct >= 0 ? GOOD : BAD), borderRadius: 3 }]
}, {
indexAxis: 'y',
plugins: { legend: { display: false },
title: { display: true, text: '궤적별 개인화 개선(모집단 대비 RMSE↓)', color: MUTE, font: { size: 11 } } },
scales: { x: { ticks: { color: MUTE, callback: v => v + '%' }, grid: { color: GRID } },
y: { ticks: { color: INK, font: { size: 9 } }, grid: { display: false } } }
});
// 합성환자 정확성: 관측↑ → 종점 오차·구간폭↓
const syn = P.synthetic;
if (syn && syn.steps) {
const st = syn.steps;
mk('chart-val-synth', 'line', {
labels: st.map(s => s.n_obs + '점'),
datasets: [
{ label: '종점 예측오차', data: st.map(s => s.endpoint_err), borderColor: VERM, backgroundColor: 'transparent', borderWidth: 2.5, pointRadius: 3, tension: .3 },
{ label: '90% 구간폭', data: st.map(s => s.endpoint_width), borderColor: TEAL, borderDash: [5, 4], backgroundColor: 'transparent', borderWidth: 2, pointRadius: 3, tension: .3 },
]
}, {
plugins: { legend: { labels: { color: INK, boxWidth: 14, font: { size: 10 } } },
title: { display: true, text: '합성환자(참 종점 ' + syn.true_endpoint + '): 관측↑ → 오차·폭↓ → 참값 수렴', color: MUTE, font: { size: 11 } } },
scales: { y: { title: { display: true, text: 'SALT 단위', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } },
x: { ticks: { color: INK }, grid: { display: false } } }
});
}
}
// 반증가능 예측 — 병용 시너지 (ODE AND-게이트에서 창발)
function renderSynergy() {
const s = synergy; if (!s) return;
const h = s.headline;
el('val-syn-headline').innerHTML = [
['+' + h.synergy_excess_auc.toFixed(3), 'Bliss 초과(시너지)'],
[h.combo_index_CI.toFixed(2), 'Comb.Index (<1=시너지)'],
[h.R_combo_actual.toFixed(2), '실제 병용 회복'],
[h.R_combo_bliss.toFixed(2), '가법 기대 회복'],
].map(([v, l]) => `<div class="vstat"><div class="vsv">${v}</div><div class="vsl">${l}</div></div>`).join('');
// 실제 per-arm 데이터 검정(5-ARI×미녹 병용) — 정직한 반증 결과
if (synClin && synClin.summary) {
const sm = synClin.summary, ds = synClin.data_source || {};
el('val-syn-clinical').innerHTML = `⚠ <b>실제 per-arm 데이터로 반증</b> (${ds.pmc || 'IJT 2023'}, 3-arm RCT n=20/군): 트윈은 <b>초가법(synergy)</b>을 예측했으나 실데이터는 <b>강한 가법미만(sub-additive)</b> — 6/6 부위, 평균 초과 ${sm.mean_super_additive_excess} hairs/cm²(피나 추가이득이 피나 단독효과를 크게 밑돎). <b>핵심 예측 반증.</b> 단 병용&gt;단독(HSA ${sm.hsa_cells})은 충족(병용 임상우월성 방향은 맞음). 해석: 미녹+피나 효과 겹침→AND-게이트 '독립 노드' 전제 미충족. 한계: 단일 소규모·국소 피나·placebo 없음.`;
// 시각적 반증: 평균 단독/병용 vs 가법기대
const cells = Object.values(synClin.cells || {});
if (cells.length) {
const avg = a => cells.reduce((s, c) => s + c[a], 0) / cells.length;
const fns = avg('FNS'), mnx = avg('MNX'), mnf = avg('MNF');
mk('chart-val-synclin', 'bar', {
labels: ['피나 단독', '미녹 단독', '병용(실제)', '가법 기대(피나+미녹)'],
datasets: [{ label: '24주 모발밀도 증가(평균, hairs/cm²)', data: [fns, mnx, mnf, fns + mnx].map(x => +x.toFixed(2)),
backgroundColor: ['#2f63c8', WARN, VERM, INK], borderRadius: 3 }]
}, {
plugins: { legend: { display: false },
title: { display: true, text: '병용(빨강) ≪ 가법기대(검정) = 가법미만 → 초가법 예측 반증', color: BAD, font: { size: 11 } } },
scales: { y: { title: { display: true, text: 'Δ 모발밀도', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } },
x: { ticks: { color: INK, font: { size: 10 } }, grid: { display: false } } }
});
}
}
const dc = s.dose_curve;
mk('chart-val-synergy', 'line', {
labels: dc.map(x => x.E),
datasets: [
{ label: '병용(실제)', data: dc.map(x => x.R_combo), borderColor: VERM, borderWidth: 3, pointRadius: 2, tension: .2, fill: '+1', backgroundColor: 'rgba(200,64,31,.12)' },
{ label: 'Bliss 가법기대', data: dc.map(x => x.bliss_expected), borderColor: INK, borderDash: [6, 4], borderWidth: 2, pointRadius: 0, tension: .2 },
{ label: 'ARM-1 단독', data: dc.map(x => x.R1), borderColor: TEAL, borderWidth: 1.5, pointRadius: 0, tension: .2 },
{ label: 'ARM-2 단독', data: dc.map(x => x.R2), borderColor: '#2f63c8', borderWidth: 1.5, pointRadius: 0, tension: .2 },
]
}, {
plugins: { legend: { labels: { color: INK, boxWidth: 14, font: { size: 10 } } },
title: { display: true, text: '빨강(실제)이 점선(가법기대) 위 = 시너지', color: MUTE, font: { size: 11 } } },
scales: { x: { title: { display: true, text: '단일팔 효능 E', color: MUTE }, ticks: { color: MUTE }, grid: { display: false } },
y: { title: { display: true, text: '결손 회복분율', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } } }
});
const sw = s.threshold_sweep;
mk('chart-val-synergy-kda', 'line', {
labels: sw.map(x => x.KDA),
datasets: [{ label: '시너지 초과', data: sw.map(x => x.synergy_excess), borderColor: VERM, backgroundColor: 'rgba(200,64,31,.12)', borderWidth: 2.5, pointRadius: 3, tension: .35, fill: true }]
}, {
plugins: { legend: { display: false },
title: { display: true, text: 'DP 문턱 KDA — 중간 중증도에서 시너지 최대', color: MUTE, font: { size: 11 } } },
scales: { x: { title: { display: true, text: 'DP 협동 문턱 KDA', color: MUTE }, ticks: { color: MUTE }, grid: { display: false } },
y: { ticks: { color: MUTE }, grid: { color: GRID } } }
});
}
// 불확실성 정량(UQ) — 보정된 신뢰구간 + 커버리지 before/after
function renderUQ() {
const u = uq; if (!u || !u.classes) return;
const oc = u.overall_coverage || {};
const J = u.classes.JAK_inhibitor || {};
el('val-uq-headline').innerHTML = [
[Math.round((oc.mean_only_empirical || 0) * 100) + '%', '단순구간(과신)'],
[Math.round((oc.population_empirical || 0) * 100) + '%', '계층적(보정됨)'],
[Math.round((oc.nominal || .9) * 100) + '%', '명목 목표'],
[(J.lag_mean != null ? J.lag_mean.toFixed(2) : '') + 'm', 'JAK lag 사후평균'],
].map(([v, l]) => `<div class="vstat"><div class="vsv">${v}</div><div class="vsl">${l}</div></div>`).join('');
const b = (J.post_band) || { months: [], median: [], lo: [], hi: [] };
mk('chart-val-uqband', 'line', {
labels: b.months.map(m => m + 'm'),
datasets: [
{ label: '95% 상한', data: b.hi, borderColor: 'transparent', backgroundColor: 'rgba(31,93,82,.16)', pointRadius: 0, fill: '+1', tension: .3 },
{ label: '5% 하한', data: b.lo, borderColor: 'transparent', backgroundColor: 'rgba(31,93,82,.16)', pointRadius: 0, fill: false, tension: .3 },
{ label: '중앙(모집단 평균)', data: b.median, borderColor: TEAL, borderWidth: 3, pointRadius: 0, tension: .3 },
]
}, {
plugins: { legend: { labels: { color: INK, boxWidth: 12, font: { size: 10 }, filter: it => !it.text.includes('하한') } },
title: { display: true, text: 'JAK 회복 타이밍 — 90% 신뢰띠(보정됨)', color: MUTE, font: { size: 11 } } },
scales: { y: { min: 0, max: 1.1, title: { display: true, text: '정규화 회복도', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } },
x: { ticks: { color: MUTE, maxTicksLimit: 8 }, grid: { display: false } } }
});
const cls = ['JAK_inhibitor', 'finasteride'].filter(c => u.classes[c] && u.classes[c].loto_coverage);
const nm = { JAK_inhibitor: 'JAK', finasteride: '피나스테리드' };
mk('chart-val-coverage', 'bar', {
labels: cls.map(c => nm[c] || c),
datasets: [
{ type: 'line', label: '명목 90%', data: cls.map(() => 90), borderColor: INK, borderDash: [5, 4], borderWidth: 1.5, pointRadius: 0 },
{ label: '단순(과신)', data: cls.map(c => Math.round(u.classes[c].loto_coverage.mean_only_empirical * 100)), backgroundColor: BAD, borderRadius: 3 },
{ label: '계층적(보정)', data: cls.map(c => Math.round(u.classes[c].loto_coverage.population_empirical * 100)), backgroundColor: GOOD, borderRadius: 3 },
]
}, {
plugins: { legend: { labels: { color: INK, boxWidth: 12, font: { size: 10 } } },
title: { display: true, text: '커버리지 검정: 과신 → 보정', color: MUTE, font: { size: 11 } } },
scales: { y: { min: 0, max: 108, title: { display: true, text: '경험적 커버리지 %', color: MUTE }, ticks: { color: MUTE, callback: v => v + '%' }, grid: { color: GRID } },
x: { ticks: { color: INK, font: { size: 11 } }, grid: { display: false } } }
});
}
// 데이터 지형
function renderLandscape() {
const L = data.landscape; if (!L) return;
const h = L.headline;
el('val-headline').innerHTML = [
[h.gb + ' GB', '수집 데이터'], [h.files, '파일'], [h.datasets_downloaded + '+', '데이터셋'],
[h.waves, '탐색 웨이브'], [h.agents, '에이전트'],
].map(([v, l]) => `<div class="vstat"><div class="vsv">${v}</div><div class="vsl">${l}</div></div>`).join('');
const m = L.by_modality;
mk('chart-val-modality', 'bar', {
labels: m.map(x => x.mod),
datasets: [{ label: '다운로드', data: m.map(x => x.dl), backgroundColor: TEAL, borderRadius: 3 },
{ label: '기록·게이트', data: m.map(x => x.rec), backgroundColor: '#cdbf9f', borderRadius: 3 }]
}, {
plugins: { legend: { labels: { color: INK, boxWidth: 12, font: { size: 10 } } } },
scales: { x: { stacked: true, ticks: { color: INK, font: { size: 10 } }, grid: { display: false } },
y: { stacked: true, ticks: { color: MUTE }, grid: { color: GRID } } }
});
const d = L.by_disease;
mk('chart-val-disease', 'bar', {
labels: d.map(x => x.d), datasets: [{ data: d.map(x => x.n), backgroundColor: VERM, borderRadius: 3 }]
}, {
indexAxis: 'y', plugins: { legend: { display: false } },
scales: { x: { ticks: { color: MUTE }, grid: { color: GRID } }, y: { ticks: { color: INK, font: { size: 11 } }, grid: { display: false } } }
});
}
// 임상 시간축 회복곡선
function renderTiming() {
const t = data.timing; if (!t) return;
const palette = { '피나스테리드': '#2f63c8', '두타스테리드': VERM, '미녹시딜': WARN, 'JAK억제제': TEAL };
const datasets = Object.keys(t.curves).map(k => ({
label: k, data: t.curves[k], borderColor: palette[k] || INK, backgroundColor: 'transparent',
borderWidth: 2.5, pointRadius: 2, tension: .3,
}));
mk('chart-val-timing', 'line', { labels: t.months.map(m => m < 1 ? m * 4 + '주' : m + 'm'), datasets }, {
plugins: { legend: { labels: { color: INK, boxWidth: 14, font: { size: 11 } } } },
scales: { y: { min: -10, max: 105, title: { display: true, text: '회복도 %', color: MUTE }, ticks: { color: MUTE, callback: v => v + '%' }, grid: { color: GRID } },
x: { ticks: { color: MUTE, maxTicksLimit: 9 }, grid: { display: false } } }
});
}
// Halloy 벤치마크
function renderBenchmark() {
const b = data.benchmark; if (!b) return;
mk('chart-val-bench', 'bar', {
labels: b.labels,
datasets: [{ label: 'Halloy 자동자', data: b.Halloy, backgroundColor: MUTE, borderRadius: 3 },
{ label: '우리 트윈', data: b['트윈'], backgroundColor: VERM, borderRadius: 3 }]
}, {
plugins: { legend: { labels: { color: INK, boxWidth: 12 } } },
scales: { y: { ticks: { color: MUTE, callback: v => v + '%' }, grid: { color: GRID } },
x: { ticks: { color: INK, font: { size: 11 } }, grid: { display: false } } }
});
}
// 1) 다층 요약 카드
function renderSummary() {
el('val-summary').innerHTML = (data.summary || []).map(c => {
const rows = c.rows.map(r => {
const badge = r.strong
? '<span class="vb vb-ok">확증</span>'
: '<span class="vb vb-warn">비재현</span>';
const pv = r.p == null ? '' : `<span class="vp">p=${fmtP(r.p)}</span>`;
return `<div class="vrow"><div class="vrd">${r.design}</div><div class="vrm">${r.metric} ${pv}</div>${badge}</div>`;
}).join('');
return `<div class="vcard"><div class="vch">${c.claim}</div>${rows}</div>`;
}).join('');
}
// 2) 분자 검증 유의도 -log10(p)
function renderMolecular() {
const m = data.molecular || [];
const labels = m.map(x => x.t), vals = m.map(x => Math.min(60, -Math.log10(x.p)));
const cols = m.map(x => x.dz === 'AA' ? VERM : TEAL);
mk('chart-val-mol', 'bar', {
labels, datasets: [{ data: vals, backgroundColor: cols, borderRadius: 4 }]
}, {
indexAxis: 'y', plugins: { legend: { display: false }, tooltip: { callbacks: { label: c => 'log₁₀p = ' + c.parsed.x.toFixed(1) } } },
scales: { x: { title: { display: true, text: 'log₁₀(p) (유의 ≈ 1.3↑)', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } },
y: { ticks: { color: INK, font: { size: 11 } }, grid: { display: false } } }
});
}
// 3) AGA DP Wnt 역전 (grouped bar)
function renderAgaDp() {
const a = data.aga_dp; if (!a) return;
const genes = Object.keys(a.genes), conds = a.conds;
const colByCond = { 'Con': MUTE, 'TP': BAD, 'TP+Ab': TEAL };
const datasets = conds.map(cn => ({
label: cn === 'Con' ? '정상' : cn === 'TP' ? 'AGA(TP)' : '치료(TP+Ab)',
data: genes.map(g => a.genes[g][cn]), backgroundColor: colByCond[cn] || INK, borderRadius: 3,
}));
mk('chart-val-agadp', 'bar', { labels: genes, datasets }, {
plugins: { legend: { labels: { color: INK, boxWidth: 12, font: { size: 11 } } } },
scales: { y: { title: { display: true, text: 'DP세포 발현(log)', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } },
x: { ticks: { color: INK }, grid: { display: false } } }
});
}
// 4) JAK억제제별 염증신호
function renderJak() {
const j = data.jak_drugs; if (!j) return;
const labels = Object.keys(j), vals = labels.map(k => j[k]);
const cols = vals.map(v => v > 0.3 ? BAD : GOOD); // 높으면 염증 잔존(빨강), 낮으면 억제(녹색)
mk('chart-val-jak', 'bar', { labels, datasets: [{ data: vals, backgroundColor: cols, borderRadius: 4 }] }, {
plugins: { legend: { display: false }, tooltip: { callbacks: { label: c => 'IFN sig = ' + c.parsed.y.toFixed(2) } } },
scales: { y: { title: { display: true, text: '염증/IFN 시그니처', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } },
x: { ticks: { color: INK }, grid: { display: false } } }
});
}
// 5) AA 단일세포 비재현 (코호트별 T세포 평균%)
function renderAaSc() {
const s = data.aa_sc; if (!s) return;
const mean = a => a && a.length ? a.reduce((x, y) => x + y, 0) / a.length : 0;
const cohorts = Object.keys(s);
mk('chart-val-aasc', 'bar', {
labels: cohorts,
datasets: [
{ label: 'AA', data: cohorts.map(c => +mean(s[c].AA).toFixed(1)), backgroundColor: VERM, borderRadius: 3 },
{ label: '정상', data: cohorts.map(c => +mean(s[c].control).toFixed(1)), backgroundColor: MUTE, borderRadius: 3 },
]
}, {
plugins: { legend: { labels: { color: INK, boxWidth: 12 } }, title: { display: true, text: '두 코호트 T세포 비율 불일치 (포획 편향)', color: MUTE, font: { size: 11 } } },
scales: { y: { title: { display: true, text: 'T세포 %', color: MUTE }, ticks: { color: MUTE, callback: v => v + '%' }, grid: { color: GRID } },
x: { ticks: { color: INK, font: { size: 10 } }, grid: { display: false } } }
});
}
// 6) GWAS 커버리지 도넛
function renderGwas() {
const g = data.gwas; if (!g) return;
const donut = (id, cov, label) => mk(id, 'doughnut', {
labels: ['보유', '누락'],
datasets: [{ data: [Math.round(cov * 16), 16 - Math.round(cov * 16)], backgroundColor: [TEAL, '#e6ddcb'], borderColor: '#fbf9f4', borderWidth: 2 }]
}, { cutout: '62%', plugins: { legend: { display: false }, tooltip: { callbacks: { label: c => c.label + ': ' + c.parsed } } } });
donut('chart-val-gwasAGA', g.AGA_cov);
donut('chart-val-gwasAA', g.AA_cov);
const aga = el('chart-val-gwasAGA'); if (aga) aga.parentElement.querySelector('.val-donut-lab').innerHTML = 'AGA<br><b>' + Math.round(g.AGA_cov * 100) + '%</b>';
const aa = el('chart-val-gwasAA'); if (aa) aa.parentElement.querySelector('.val-donut-lab').innerHTML = 'AA<br><b>' + Math.round(g.AA_cov * 100) + '%</b>';
}
// 7) 후보 × 구조 × STRING 표
function renderCandidates() {
const c = data.candidates || [];
const order = { COHERES: 0, weak: 1, isolated: 2, 'n/a': 3 };
const rows = c.slice().sort((a, b) => (order[a.cohesion] - order[b.cohesion]) || (b.plddt || 0) - (a.plddt || 0)).map(r => {
const pb = r.plddt == null ? '—' : r.plddt.toFixed(0);
const pcls = r.plddt >= 70 ? 'good' : r.plddt >= 50 ? 'warn' : 'bad';
const coh = { COHERES: '<span class="vb vb-ok">응집</span>', weak: '<span class="vb vb-warn">약</span>',
isolated: '<span class="vb vb-bad">미응집</span>', 'n/a': '—' }[r.cohesion] || '—';
return `<tr><td class="vc-g">${r.gene}</td><td>${r.dz}</td><td>${r.axis}</td>
<td class="vc-p ${pcls}">${pb}</td><td>${r.edges_hi != null ? r.edges_hi : ''}</td><td>${coh}</td></tr>`;
}).join('');
el('val-candidates').innerHTML = `<table class="val-table">
<thead><tr><th>유전자</th><th>질환</th><th>배정 축</th><th>AlphaFold pLDDT</th><th>STRING 고신뢰 엣지</th><th>축 응집</th></tr></thead>
<tbody>${rows}</tbody></table>
<p class="val-cap">구조 19/19 보유 · STRING 응집 ${c.filter(x => x.cohesion === 'COHERES').length}/19. GWAS(유전학)+STRING(네트워크)+AlphaFold(구조)가 한 방향으로 모이는 후보가 신뢰도 높음.</p>`;
}
function fmtP(p) { return p < 1e-4 ? p.toExponential(0) : p.toPrecision(2); }
function mk(id, type, d, opts) {
const ctx = el(id); if (!ctx) return;
if (charts[id]) charts[id].destroy();
charts[id] = new Chart(ctx, { type, data: d, options: Object.assign({ responsive: true, maintainAspectRatio: false }, opts) });
}
window.ValidationTab = { init };
})();