670 lines
47 KiB
JavaScript
670 lines
47 KiB
JavaScript
/* ============================================================
|
||
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<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>(헤드라인 효과-편향비는 가정無, <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} < 미녹단독 +${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> (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> 단 병용>단독(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 };
|
||
})();
|