MediaWiki:Gadget-DiagnosticTreeScoring.js: Difference between revisions
Jump to navigation
Jump to search
No edit summary |
No edit summary |
||
| (3 intermediate revisions by the same user not shown) | |||
| Line 488: | Line 488: | ||
var ChestPretestGate = ( function () { | var ChestPretestGate = ( function () { | ||
var BASELINE = 0.13; | var BASELINE = 0.13; | ||
var JAMA_URL = 'https://doi.org/10.1001/jama.2015.12735'; | |||
var JAMA_CIT = 'Fanaroff AC, Rymer JA, Goldstein SA, Simel DL, Newby LK. Does This Patient With Chest Pain Have Acute Coronary Syndrome? JAMA. 2015;314(18):1955\u20131965.'; | |||
var CITE = { | var CITE = { | ||
jama: 'Fanaroff et al. JAMA 2015;314(18):1955-1965 | jama:'Fanaroff et al. JAMA 2015;314(18):1955-1965', acs25:'NHFA/CSANZ Australian ACS Guideline 2025', | ||
heart:'HEART score (Backus 2008 | heart:'HEART score (Backus 2008). Strata LRs per JAMA 2015 Table 5', | ||
hear:'HEAR score (HEART minus troponin); \u22641 = very-low-risk rule-out', | |||
hear: 'HEAR score (HEART minus troponin) | timi:'TIMI risk score UA/NSTEMI (Antman 2000). Strata LRs per JAMA 2015 Table 5', | ||
t1:'JAMA 2015 \u2014 Table 1 (cardiac risk factors)', | t1:'JAMA 2015 \u2014 Table 1 (cardiac risk factors)', t2:'JAMA 2015 \u2014 Table 2 (chest-pain characteristics)', t3:'JAMA 2015 \u2014 Table 3 (physical examination)' | ||
}; | }; | ||
/* | /* Risk-factor checkboxes (carry JAMA +LR for the fallback pool). timi=true counts toward TIMI \u22653. */ | ||
var | var RISK_FACTORS = [ | ||
{ id:' | {id:'htn', label:'Hypertension', lr:1.1, timi:true}, | ||
{id:'lipid',label:'Hypercholesterolaemia', lr:1.2, timi:true}, | |||
{id:'dm', label:'Diabetes mellitus', lr:1.4, timi:true}, | |||
{ id:' | {id:'obes', label:'Obesity (BMI > 30)', lr:0.97,timi:false}, | ||
{id:'smoke',label:'Smoking (current or \u2264 3 mo)',lr:1.0,timi:true}, | |||
{ id:' | {id:'fhx', label:'Family history CVD < 65', lr:1.0, timi:true} | ||
]; | |||
{ id:' | var ATHERO = [ | ||
{id:'mi', label:'Prior MI', lr:1.6}, | |||
{id:'pci',label:'Prior PCI / CABG', lr:1.4}, | |||
{id:'cva',label:'Prior CVA / TIA', lr:1.3}, | |||
{id:'pad',label:'Peripheral arterial disease', lr:2.7} | |||
]; | |||
{ id:' | |||
{ id:' | |||
] | |||
var | |||
{ id:' | |||
{ id:' | |||
{ id:' | |||
{ id:' | |||
/* | /* JAMA single-feature tables. Table 1 = risk factors (locks when a score is built). */ | ||
var HEART_STRATA = [ {max:3,lr:0.20,label:'0\u20133'}, {max:4,lr:0.79,label:'4'}, | var TABLE1 = { id:'t1', table:1, title:'Personal Medical History', cite:CITE.t1, rows:[ | ||
{id:'t1_stress',text:'Abnormal prior stress test',lrPos:3.1,lrNeg:0.92},{id:'t1_pad',text:'Peripheral arterial disease',lrPos:2.7,lrNeg:0.96}, | |||
var TIMI_STRATA = [ {max:1,lr:0.31,label:'0\u20131'}, {max:2,lr:0.94,label:'2'}, | {id:'t1_mi',text:'Prior myocardial infarction',lrPos:1.6,lrNeg:0.88},{id:'t1_cabg',text:'Prior CABG',lrPos:1.4,lrNeg:0.90}, | ||
{id:'t1_cad',text:'Prior CAD',lrPos:2.0,lrNeg:0.75},{id:'t1_dm',text:'Diabetes mellitus',lrPos:1.4,lrNeg:0.97}, | |||
{id:'t1_cvd',text:'Cerebrovascular disease',lrPos:1.3,lrNeg:0.70},{id:'t1_male',text:'Male sex',lrPos:1.3,lrNeg:0.85}, | |||
{id:'t1_lipid',text:'Hyperlipidaemia',lrPos:1.2,lrNeg:0.78},{id:'t1_htn',text:'Hypertension',lrPos:1.1,lrNeg:0.96}, | |||
{id:'t1_tob',text:'Any tobacco use',lrPos:1.0,lrNeg:0.99},{id:'t1_fhx',text:'Family history of CAD',lrPos:1.0,lrNeg:0.99}, | |||
{id:'t1_obes',text:'Obesity',lrPos:0.97,lrNeg:1.0} | |||
]}; | |||
var TABLE2 = { id:'t2', table:2, title:'Associated Symptoms', cite:CITE.t2, rows:[ | |||
{id:'t2_botharms',text:'Pain radiates to BOTH arms',lrPos:2.6,lrNeg:0.93}, | |||
{id:'t2_leftarm',text:'Radiates to left arm',lrPos:1.3,lrNeg:0.88}, | |||
{id:'t2_rightarm',text:'Radiates to right arm',lrPos:1.3,lrNeg:0.99}, | |||
{id:'t2_neckjaw',text:'Radiates to neck or jaw',lrPos:1.5,lrNeg:0.91}, | |||
{id:'t2_change',text:'Change in pattern over prior 24 h',lrPos:2.0,lrNeg:0.84}, | |||
{id:'t2_typical',text:'\u201CTypical\u201D (pressure-like) pain',lrPos:1.9,lrNeg:0.52}, | |||
{id:'t2_exertion',text:'Worse with exertion',lrPos:1.65,lrNeg:0.83}, | |||
{id:'t2_diaph',text:'Associated diaphoresis',lrPos:1.4,lrNeg:0.93}, | |||
{id:'t2_burning',text:'Burning pain',lrPos:1.2,lrNeg:1.0}, | |||
{id:'t2_ntg',text:'Any improvement with nitroglycerin',lrPos:1.1,lrNeg:0.90,nb:'Unhelpful for diagnosing ACS.'}, | |||
{id:'t2_typrad',text:'\u201CTypical\u201D radiation',lrPos:1.0,lrNeg:0.98}, | |||
{id:'t2_pleur',text:'Pleuritic pain',lrPos:0.61,lrNeg:1.2,nb:'Lowers likelihood.'}, | |||
{id:'t2_prior',text:'Similar to prior ischaemia',lrPos:2.2,lrNeg:0.67}, | |||
{id:'t2_recent',text:'Recent episode of similar pain',lrPos:1.3,lrNeg:0.80}, | |||
{id:'t2_abrupt',text:'Abrupt onset',lrPos:1.1,lrNeg:0.75}, | |||
{id:'t2_nausea',text:'Associated nausea / vomiting',lrPos:1.1,lrNeg:1.0}, | |||
{id:'t2_dyspnea',text:'Associated dyspnoea',lrPos:1.2,lrNeg:0.89}, | |||
{id:'t2_palp',text:'Associated palpitations',lrPos:1.3,lrNeg:1.0}, | |||
{id:'t2_syncope',text:'Associated syncope',lrPos:0.76,lrNeg:1.1,nb:'Lowers likelihood.'} | |||
]}; | |||
var TABLE3 = { id:'t3', table:3, title:'Vitals / Physical Exam', cite:CITE.t3, rows:[ | |||
{id:'t3_hypo',text:'Hypotension (SBP < 100)',lrPos:3.9,lrNeg:0.98},{id:'t3_rales',text:'Lung rales / crackles',lrPos:2.0,lrNeg:0.95}, | |||
{id:'t3_tachyp',text:'Tachypnoea',lrPos:1.9,lrNeg:0.95},{id:'t3_tachyc',text:'Tachycardia (HR > 120)',lrPos:1.3,lrNeg:0.99}, | |||
{id:'t3_palp',text:'Pain reproduced on palpation',lrPos:0.28,lrNeg:1.2,nb:'Lowers likelihood most of any sign.'} | |||
]}; | |||
var TABLES = [ TABLE1, TABLE2, TABLE3 ]; | |||
var HEART_STRATA = [ {max:3,lr:0.20,label:'0\u20133'}, {max:4,lr:0.79,label:'4'}, {max:6,lr:2.4,label:'5\u20136'}, {max:10,lr:13,label:'7\u201310'} ]; | |||
var TIMI_STRATA = [ {max:1,lr:0.31,label:'0\u20131'}, {max:2,lr:0.94,label:'2'}, {max:4,lr:2.4,label:'3\u20134'}, {max:7,lr:6.8,label:'5\u20137'} ]; | |||
function stratumFor(strata,total){ for(var i=0;i<strata.length;i++){ if(total<=strata[i].max) return strata[i]; } return strata[strata.length-1]; } | function stratumFor(strata,total){ for(var i=0;i<strata.length;i++){ if(total<=strata[i].max) return strata[i]; } return strata[strata.length-1]; } | ||
function pToOdds(p){return p/(1-p);} function oddsToP(o){return o/(1+o);} | |||
function pToOdds(p){return p/(1-p);} | |||
function applyLR(p,lr){return oddsToP(pToOdds(p)*lr);} | function applyLR(p,lr){return oddsToP(pToOdds(p)*lr);} | ||
function pct(p){return (p*100).toFixed(1)+'%';} | function pct(p){return (p*100).toFixed(1)+'%';} | ||
function esc(s){return String(s).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];});} | function esc(s){return String(s).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];});} | ||
function citeSpan(t){return '<span class="cpg-cite">'+esc(t)+'</span>';} | function citeSpan(t){return '<span class="cpg-cite">'+esc(t)+'</span>';} | ||
function band(p){ if(p>=0.20)return{cls:'cpg-band-high',word:'High'}; if(p>=0.05)return{cls:'cpg-band-int',word:'Intermediate'}; if(p>=0.02)return{cls:'cpg-band-lowmid',word:'Low'}; return{cls:'cpg-band-low',word:'Very low'}; } | |||
/* ── state ── */ | |||
/* ── state | |||
var S; | var S; | ||
function fresh(){ return { | function fresh(){ return { | ||
host:null, nextPage:'Pain:Chest', phase:'gate', | host:null, nextPage:'Pain:Chest', phase:'gate', hasTroponin:null, ecgAvail:null, | ||
ecg:{a:false,b:false,c:false,d:false,normal:false}, | |||
ecg: | scoreType:null, b:{}, sf:[], | ||
expand:{builder:false} | |||
}; } | }; } | ||
function | function ecgAssessed(){ var e=S.ecg; return e.a||e.b||e.c||e.d||e.normal; } | ||
function | function ecgPointsComplete(){ var e=S.ecg; return e.a?2:(e.d?1:0); } // HEART/HEAR ECG component | ||
function ecgPoolLR(){ var e=S.ecg,m=0; if(e.a)m=Math.max(m,5.3); if(e.b)m=Math.max(m,1.8); if(e.a&&e.b&&e.c)m=Math.max(m,3.6); return m; } | |||
function isAns(v){ return v!=null && v!=='unknown'; } | |||
function isUnk(v){ return v==='unknown'; } | |||
function rfCount(){ var n=0; RISK_FACTORS.forEach(function(r){ if(S.b['rf_'+r.id]) n++; }); return n; } | |||
function timiRfCount(){ var n=0; RISK_FACTORS.forEach(function(r){ if(r.timi && S.b['rf_'+r.id]) n++; }); return n; } | |||
function atheroAny(){ return ATHERO.some(function(a){ return S.b['ath_'+a.id]; }); } | |||
function rfPoolMax(){ var m=0; RISK_FACTORS.forEach(function(r){ if(S.b['rf_'+r.id]&&r.lr>m)m=r.lr; }); ATHERO.forEach(function(a){ if(S.b['ath_'+a.id]&&a.lr>m)m=a.lr; }); return m; } | |||
function heartRfTier(){ return (atheroAny()||rfCount()>=3)?2:(rfCount()>=1?1:0); } | |||
/* | /* ── score evaluation (live) ── */ | ||
function evalScore(id | function evalScore(id){ | ||
var poolMax=0; function pool(lr){ if(lr>1 && lr>poolMax) poolMax=lr; } | var poolMax=0; function pool(lr){ if(lr>1&&lr>poolMax)poolMax=lr; } | ||
var total=0, complete=true | var total=0, complete=true, hasUnknown=false; | ||
if (!ecgAssessed()) complete=false; else { total+=(id==='timi'?(S.ecg.a?1:0):ecgPointsComplete()); pool(ecgPoolLR()); } | |||
if ( | |||
if (id==='heart' || id==='hear'){ | if (id==='heart'||id==='hear'){ | ||
if (b.history= | if (isUnk(S.b.history)) hasUnknown=true; if (!isAns(S.b.history)) complete=false; else { total+=S.b.history; if(S.b.history>=1) pool(1.9); } | ||
if (b.age= | if (isUnk(S.b.age)) hasUnknown=true; if (!isAns(S.b.age)) complete=false; else { total+=S.b.age; } | ||
total+=heartRfTier(); pool(rfPoolMax()); | |||
if (id==='heart'){ if (b.trop= | if (id==='heart'){ if(isUnk(S.b.trop)) hasUnknown=true; if(!isAns(S.b.trop)) complete=false; else total+=S.b.trop; } | ||
} else if (id==='timi'){ | } else if (id==='timi'){ | ||
['age65 | var items=['age65','knownCAD','asa','angina2','marker']; | ||
items.forEach(function(k){ if(isUnk(S.b['timi_'+k])) hasUnknown=true; if(!isAns(S.b['timi_'+k])) complete=false; }); | |||
if (ecg | if (S.ecg.a) total+=1; | ||
if (b.timi_age65) | if (S.b.timi_age65===true) total+=1; | ||
if ( | if (timiRfCount()>=3){ total+=1; pool(rfPoolMax()); } | ||
if (b.timi_knownCAD){ | if (S.b.timi_knownCAD===true){ total+=1; pool(2.0); } | ||
if (b.timi_asa) | if (S.b.timi_asa===true) total+=1; | ||
if (b.timi_angina2){ | if (S.b.timi_angina2===true){ total+=1; pool(2.0); } | ||
if (b.timi_marker) | if (S.b.timi_marker===true) total+=1; | ||
} | } | ||
var res={ complete:complete, total:total, poolMax:poolMax }; | var res={complete:complete,hasUnknown:hasUnknown,total:total,poolMax:poolMax}; | ||
var name = id==='heart'?'HEART':(id==='hear'?'HEAR':'TIMI'); | |||
// fallback used when incomplete OR unknown OR stratum LR <= 1 | |||
function fallback(){ res.lr=poolMax>1?poolMax:1.0; res.label=name+(complete?'':(hasUnknown?' (unknown item)':' (incomplete)'))+' \u2014 highest single +LR '+(poolMax>1?poolMax:'(none \u2192 no change)'); res.kind=poolMax>1?'fallback':'none'; return res; } | |||
if (!complete) return fallback(); | |||
if (id==='hear'){ | if (id==='hear'){ | ||
if ( | if (total<=1 && S.ecg.a===false && S.ecg.b===false && S.ecg.c===false){ res.lr=0.01; res.label='HEAR \u22641 \u2014 very low risk (rule-out)'; res.kind='ruleout'; return res; } | ||
return fallback(); | |||
} | } | ||
var strata = (id==='timi') ? TIMI_STRATA : HEART_STRATA; | var strata=(id==='timi')?TIMI_STRATA:HEART_STRATA; var st=stratumFor(strata,total); res.stratumLabel=st.label; | ||
if (st.lr>1){ res.lr=st.lr; res.label=name+' '+st.label+' \u2014 cumulative LR '+st.lr; res.kind='cumulative'; return res; } | |||
if (st.lr>1){ res.lr=st.lr; res.label= | return fallback(); | ||
return | |||
} | } | ||
/* | /* ── probability (derived) ── */ | ||
function | /* Single-feature tables are NOT statistically independent: only the single | ||
highest LR among all selected answers is applied (the rest are greyed). */ | |||
function sfMax(){ var m=0; S.sf.forEach(function(x){ if(x.lr>m)m=x.lr; }); return m; } | |||
function | function prob(){ | ||
var p=BASELINE; | |||
if (S.scoreType){ p=applyLR(p, evalScore(S.scoreType).lr); } | |||
else if (ecgAssessed() && ecgPoolLR()>0){ p=applyLR(p, ecgPoolLR()); } | |||
if (S.sf.length) p=applyLR(p, sfMax()); | |||
return p; | |||
} | |||
return | /* When are the single-feature tables shown? | ||
- No ECG (no score possible): yes, they are all the clinician has. | |||
- Troponin + ECG (HEART/TIMI): no, the score stands alone. | |||
- HEAR path: only once HEAR total > 1 (i.e. not the <=1 rule-out). */ | |||
function showTables(){ | |||
if (!S.ecgAvail) return true; | |||
if (S.hasTroponin) return false; | |||
if (S.scoreType==='hear') return evalScore('hear').total > 1; | |||
return false; | |||
} | } | ||
function | function scoreState(){ // {none|inprogress|unknown|complete} | ||
if (!S. | if (!S.scoreType) return 'none'; | ||
var | var e=evalScore(S.scoreType); | ||
if (e.complete) return 'complete'; | |||
if (e.hasUnknown) return 'unknown'; | |||
return 'inprogress'; | |||
return ' | |||
} | } | ||
function | /* ── render helpers ── */ | ||
function masthead(){ | |||
return '<header class="cpg-masthead"><div class="cpg-logo">Pain<span>Wiki</span></div>' | |||
+'<div class="cpg-mast-right"><button class="cpg-reset" id="cpg-reset">↻ Reset</button></div></header>' | |||
+'<div class="cpg- | +'<div class="cpg-topcite">Developed from the JAMA Rational Clinical Examination \u2014 <em>Does This Patient With Chest Pain Have Acute Coronary Syndrome?</em> <a href="'+JAMA_URL+'" target="_blank" rel="noopener">doi.org/10.1001/jama.2015.12735</a></div>'; | ||
+'< | |||
} | } | ||
function disclaimer(){ | |||
function | return '<div class="cpg-foot"><div class="cpg-disclaimer"><strong>Not validated for clinical use.</strong> Pre-test probability tool; does not exclude ACS.</div>' | ||
return '<div class="cpg- | +'<button class="cpg-bypass" id="cpg-bypass">Bypass & jump to result</button></div>'; | ||
} | } | ||
function | function probHeader(){ | ||
return '<div class="cpg- | var p=prob(), bd=band(p); var sub=S.phase==='result'?'Post-test probability of ACS':'Running probability for patient with presenting complaint of chest pain'; | ||
+ | return '<div class="cpg-prob-header '+bd.cls+'"><div class="cpg-prob-left"><div class="cpg-prob-sub">'+sub+'</div>' | ||
+'<div class="cpg-prob-band">'+bd.word+' likelihood</div></div><div class="cpg-prob-value">'+pct(p)+'</div></div>'; | |||
} | } | ||
function | function seg(name,opts,cur){ return '<div class="cpg-seg">'+opts.map(function(o){ return '<button class="cpg-seg-btn'+(cur===o.v?' sel':'')+'" data-set="'+name+'" data-val="'+o.v+'">'+esc(o.label)+'</button>'; }).join('')+'</div>'; } | ||
return '<div class="cpg-build-row"><div class="cpg-build-label">'+esc(label)+'</div> | function ynu(k,label){ var v=S.b['timi_'+k]; | ||
return '<div class="cpg-build-row"><div class="cpg-build-label">'+esc(label)+'</div><div class="cpg-yn">' | |||
+'<button class="cpg-btn-no'+( | +'<button class="cpg-btn-yes'+(v===true?' sel':'')+'" data-timi="'+k+'" data-val="y">Yes</button>' | ||
+'<button class="cpg-btn-no'+(v===false?' sel':'')+'" data-timi="'+k+'" data-val="n">No</button>' | |||
+'<button class="cpg-btn-unk'+(v==='unknown'?' sel':'')+'" data-timi="'+k+'" data-val="u">Unknown</button></div></div>'; } | |||
function segU(name,opts,cur){ // graded + Unknown | |||
return seg(name,opts.concat([{v:'unknown',label:'Unknown'}]),cur); } | |||
function chkGrid(){ | |||
var rf=RISK_FACTORS.map(function(r){ var lrTxt=(r.id==='smoke'||r.id==='fhx')?'':' <span class="cpg-chk-lr">+LR '+r.lr+'</span>'; | |||
return '<label class="cpg-chk'+(S.b['rf_'+r.id]?' on':'')+'"><input type="checkbox" data-chk="rf_'+r.id+'"'+(S.b['rf_'+r.id]?' checked':'')+'>'+esc(r.label)+lrTxt+'</label>'; }).join(''); | |||
var ath=ATHERO.map(function(a){ return '<label class="cpg-chk'+(S.b['ath_'+a.id]?' on':'')+'"><input type="checkbox" data-chk="ath_'+a.id+'"'+(S.b['ath_'+a.id]?' checked':'')+'>'+esc(a.label)+' <span class="cpg-chk-lr">+LR '+a.lr+'</span></label>'; }).join(''); | |||
return '<div class="cpg-chk-grid">'+rf+'</div><div class="cpg-chk-grid">'+ath+'</div>'; } | |||
function ecgChecklist(){ | |||
var e=S.ecg; | |||
function box(k,label){ return '<label class="cpg-chk'+(e[k]?' on':'')+'"><input type="checkbox" data-ecg="'+k+'"'+(e[k]?' checked':'')+'>'+esc(label)+'</label>'; } | |||
return '<div class="cpg-build-row"><div class="cpg-build-label">ECG results</div>' | |||
+'<div class="cpg-chk-grid">' | |||
+ box('a','ST depression \u2265 0.5 mm \u00b1 elevation') | |||
+ box('b','T-wave inversion') | |||
+ box('c','Q-waves') | |||
+ box('d','Non-specific repolarisation changes') | |||
+ box('normal','No acute changes (normal)') | |||
+'</div>' | |||
+'<div class="cpg-rf-note">T-wave inversion is positive in aVR, negative in I, II, V3\u2013V6.</div></div>'; | |||
} | } | ||
function builderBody( | function builderBody(){ | ||
var | var id=S.scoreType, parts=[]; | ||
if (id==='heart' || id==='hear'){ | if (id==='heart'||id==='hear'){ | ||
parts.push('<div class="cpg-build-row"><div class="cpg-build-label">History</div>' | parts.push('<div class="cpg-build-row"><div class="cpg-build-label">History</div>'+segU('history',[{v:0,label:'Slightly susp. (0)'},{v:1,label:'Moderately (1)'},{v:2,label:'Highly (2)'}],S.b.history)+'</div>'); | ||
parts.push('<div class="cpg-build-row"><div class="cpg-build-label">Age</div>'+segU('age',[{v:0,label:'< 45 (0)'},{v:1,label:'45\u201364 (1)'},{v:2,label:'\u2265 65 (2)'}],S.b.age)+'</div>'); | |||
parts.push('<div class="cpg-build-row"><div class="cpg-build-label">Age</div>' | parts.push('<div class="cpg-build-row"><div class="cpg-build-label">Risk factors <span class="cpg-rf-tier">tier '+heartRfTier()+'</span></div>'+chkGrid()+'</div>'); | ||
if (id==='heart') parts.push('<div class="cpg-build-row"><div class="cpg-build-label">Troponin</div>'+segU('trop',[{v:0,label:'\u2264 ULN (0)'},{v:1,label:'1\u20133\u00d7 (1)'},{v:2,label:'> 3\u00d7 (2)'}],S.b.trop)+'</div>'); | |||
parts.push( | |||
if (id==='heart') parts.push('<div class="cpg-build-row"><div class="cpg-build-label">Troponin</div>' | |||
} else { | } else { | ||
parts.push( | parts.push(ynu('age65','Age \u2265 65')); | ||
parts.push('<div class="cpg-build-row"><div class="cpg-build-label">\u2265 3 CAD risk factors <span class="cpg-rf-tier">'+timiRfCount()+' counted</span></div>'+chkGrid()+'</div>'); | |||
parts.push('<div class="cpg-build-row"><div class="cpg-build-label">\u2265 3 CAD risk factors< | parts.push(ynu('knownCAD','Known CAD (stenosis \u2265 50%)')); | ||
parts.push(ynu('asa','Aspirin use in past 7 days')); | |||
parts.push(ynu('angina2','\u2265 2 anginal episodes in 24 h')); | |||
parts.push(ynu('marker','Positive cardiac marker')); | |||
parts.push( | |||
parts.push( | |||
parts.push( | |||
parts.push( | |||
} | } | ||
return parts.join(''); | |||
return | |||
} | } | ||
function scoresFor(){ if(!S.ecgAvail) return []; return S.hasTroponin ? ['heart','timi'] : ['hear']; } | |||
function scoreSection(){ | function scoreSection(){ | ||
var name = S.scoreType==='heart'?'HEART':(S.scoreType==='hear'?'HEAR':(S.scoreType==='timi'?'TIMI':'')); | |||
if (!S.ecgAvail){ | |||
return '<div class="cpg-card"><div class="cpg-card-label">Step 1 \u2014 risk score</div>' | |||
+'<div class="cpg-note">No ECG available \u2014 HEART/HEAR/TIMI all require an ECG, so score-based stratification is unavailable. Use the sections below.</div></div>'; | |||
} | |||
+ '<div class="cpg- | var html='<div class="cpg-card"><div class="cpg-card-label">Step 1 \u2014 risk score</div>' | ||
+'<div class="cpg-q">Selecting ECG status opens the '+(S.hasTroponin?'HEART/TIMI':'HEAR')+' scoring questions.</div>' | |||
if ( | + ecgChecklist(); | ||
if (!ecgAssessed()){ html+='</div>'; return html; } | |||
if ( | if (!S.scoreType){ | ||
html+='<div class="cpg- | var avail=scoresFor(), cards=[]; | ||
if (avail.indexOf('heart')>=0) cards.push('<button class="cpg-score-card" data-openscore="heart"><div class="cpg-score-name">HEART</div><div class="cpg-score-desc">History, ECG, Age, Risk factors, Troponin</div></button>'); | |||
if (avail.indexOf('timi')>=0) cards.push('<button class="cpg-score-card" data-openscore="timi"><div class="cpg-score-name">TIMI</div><div class="cpg-score-desc">7 items (UA/NSTEMI)</div></button>'); | |||
if (avail.indexOf('hear')>=0) cards.push('<button class="cpg-score-card" data-openscore="hear"><div class="cpg-score-name">HEAR</div><div class="cpg-score-desc">HEART without troponin \u2014 \u22641 rules out</div></button>'); | |||
html+='<div class="cpg-chooser-label">ECG captured \u2014 now build a score (or skip and use the sections below):</div><div class="cpg-score-grid">'+cards.join('')+'</div></div>'; | |||
return html; | return html; | ||
} | } | ||
var ev=evalScore(S.scoreType), st=scoreState(); | |||
var collapsed = (st==='complete'||st==='unknown') && !S.expand.builder; | |||
var statusTxt = st==='complete' ? (name+' complete \u00b7 total '+ev.total) | |||
: st==='unknown' ? (name+' \u2014 item marked unknown') | |||
: (name+' in progress \u00b7 total '+ev.total); | |||
html+='<div class="cpg-builder"><div class="cpg-builder-head">Build '+name | |||
+'<span class="cpg-builder-tools"><button class="cpg-builder-collapse" data-togglebuild="1">'+(collapsed?'expand':'collapse')+'</button>' | |||
+'<button class="cpg-builder-close" data-closescore="1">change score</button></span></div>'; | |||
if (!collapsed) html+=builderBody(); | |||
html+='<div class="cpg-build-total">'+esc(statusTxt) | |||
+'<div class="cpg-build-apply-note">Applying: LR '+ev.lr+' \u2014 '+esc(ev.label)+'</div></div></div></div>'; | |||
return html; | return html; | ||
} | } | ||
function answerCell(tid,rid,kind,lr,dir,selected, | function answerCell(tid,rid,kind,lr,dir,selected,disabled,superseded){ | ||
var cls = kind==='pos' ? 'cpg-row-yes' : 'cpg-row-no' | var cls=kind==='pos'?'cpg-row-yes':'cpg-row-no', lrcls=kind==='pos'?'cpg-lrpos':'cpg-lrneg'; | ||
var sign=kind==='pos'?'+LR ':'\u2212LR ', lab=kind==='pos'?'Yes':'No'; | |||
var sign = kind==='pos' ? '+LR ' : '\u2212LR ' | var bcls=cls+(selected?' sel':'')+(superseded?' superseded':''); | ||
return '<div class="cpg-ans'+(disabled?' dim':'')+'"><span class="'+lrcls+'">'+sign+lr+'</span>' | |||
return '<div class="cpg-ans"><span class="'+lrcls+'">'+sign+lr+'</span>' | +'<button class="'+bcls+'" data-row="'+tid+':'+rid+'" data-val="'+dir+'"'+(disabled?' disabled':'')+'>'+lab+'</button></div>'; | ||
+'<button class="'+ | |||
} | } | ||
function | function tableBlock(t){ | ||
var gMax=sfMax(); | |||
var rows=t.rows.map(function(r){ | |||
var selDir=null; S.sf.forEach(function(x){ if(x.tableId===t.id&&x.rowId===r.id) selDir=x.dir; }); | |||
var nb=r.nb?'<div class="cpg-row-nb">'+esc(r.nb)+'</div>':''; | |||
var posSel=selDir==='pos', negSel=selDir==='neg'; | |||
var posSup=posSel&&(r.lrPos<gMax), negSup=negSel&&(r.lrNeg<gMax); // selected but a higher LR is active | |||
var posDis=(r.lrPos<gMax)&&!posSel, negDis=(r.lrNeg<gMax)&&!negSel; // lower LR, not selectable | |||
return '<div class="cpg-row"><div class="cpg-row-text">'+esc(r.text)+nb+'</div>' | |||
+answerCell(t.id,r.id,'pos',r.lrPos,'pos',posSel,posDis,posSup) | |||
+answerCell(t.id,r.id,'neg',r.lrNeg,'neg',negSel,negDis,negSup)+'</div>'; | |||
}).join(''); | }).join(''); | ||
return '<div class="cpg-table-block"><div class="cpg-table-head"><div class="cpg-card-label">'+esc(t.title)+'</div></div>' | |||
+'<div class="cpg-table-headrow"><span>Feature</span><span>Present</span><span>Absent</span></div>' | |||
+'<div class="cpg-table">'+rows+'</div></div>'; | |||
} | } | ||
function renderGate(){ | |||
var go = (S.hasTroponin!=null && S.ecgAvail!=null); | |||
S.host.innerHTML = masthead() | |||
+'<div class="cpg-card"><div class="cpg-card-label">Before you begin</div>' | |||
+'<div class="cpg-gate-q"><div class="cpg-q">Is a cardiac troponin result available?</div>' | |||
+'<div class="cpg-yn"><button class="cpg-btn-yes'+(S.hasTroponin===true?' sel':'')+'" data-trop="1">Yes</button>' | |||
+'<button class="cpg-btn-no'+(S.hasTroponin===false?' sel':'')+'" data-trop="0">No</button></div></div>' | |||
+'<div class="cpg-gate-q"><div class="cpg-q">Is an ECG available?</div>' | |||
+'<div class="cpg-yn"><button class="cpg-btn-yes'+(S.ecgAvail===true?' sel':'')+'" data-ecgavail="1">Yes</button>' | |||
+'<button class="cpg-btn-no'+(S.ecgAvail===false?' sel':'')+'" data-ecgavail="0">No</button></div></div>' | |||
+'<button class="cpg-proceed" id="cpg-gate-go"'+(go?'':' disabled')+'>Continue \u2192</button></div>' | |||
+ disclaimer(); | |||
} | |||
function renderMain(){ | function renderMain(){ | ||
var html = masthead()+probHeader()+scoreSection(); | |||
if (showTables()){ | |||
+'<div class="cpg-card"><div class="cpg-card-label">Step 2 \u2014 single features</div>' | html += '<div class="cpg-card"><div class="cpg-card-label">Step 2 \u2014 single features</div>' | ||
+'<div class="cpg-q">Combinations of parameters other than the scores above are not statistically valid \u2014 select the single best response from the tables below. To maintain independence all lower likelihood ratios are greyed out.</div>' | |||
+ TABLES.map(tableBlock).join('') + '</div>'; | |||
} | |||
html += '<div class="cpg-card"><button class="cpg-result-btn" data-result="1">View pre-test result →</button></div>' | |||
+ disclaimer(); | |||
S.host.innerHTML = html; | |||
} | } | ||
function renderResult(){ | function renderResult(){ | ||
S.phase='result'; | S.phase='result'; var p=prob(), bd=band(p); | ||
var href=(window.mw&&mw.util)?mw.util.getUrl(S.nextPage):('/wiki/'+S.nextPage); var msg; | |||
var href=(window.mw&&mw.util)?mw.util.getUrl(S.nextPage):('/wiki/'+S.nextPage); | |||
if (p>=0.20) msg='HIGH post-test probability. Arrange ECG and troponin and escalate for emergency cardiac assessment now. Do not rely on the musculoskeletal algorithm to explain this pain.'; | if (p>=0.20) msg='HIGH post-test probability. Arrange ECG and troponin and escalate for emergency cardiac assessment now. Do not rely on the musculoskeletal algorithm to explain this pain.'; | ||
else if (p>=0.05) msg='INTERMEDIATE post-test probability. ECG and serial troponin are indicated before attributing the pain to a musculoskeletal source.'; | else if (p>=0.05) msg='INTERMEDIATE post-test probability. ECG and serial troponin are indicated before attributing the pain to a musculoskeletal source.'; | ||
| Line 797: | Line 808: | ||
+'<div class="cpg-result-band '+bd.cls+'">'+bd.word+' likelihood of ACS \u00b7 '+pct(p)+'</div>' | +'<div class="cpg-result-band '+bd.cls+'">'+bd.word+' likelihood of ACS \u00b7 '+pct(p)+'</div>' | ||
+'<div class="cpg-escalate">'+esc(msg)+'</div>' | +'<div class="cpg-escalate">'+esc(msg)+'</div>' | ||
+'<div class="cpg-bottomline"><strong> | +'<div class="cpg-bottomline"><strong>Probability estimate, not a rule-out.</strong> Troponin and ECG in clinical context govern disposition. '+citeSpan(CITE.jama)+' \u00b7 '+citeSpan(CITE.acs25)+'</div></div>' | ||
+'<div class="cpg-handoff"><div class="cpg-handoff-note">Cardiac pre-test screening is complete.</div>' | +'<div class="cpg-handoff"><div class="cpg-handoff-note">Cardiac pre-test screening is complete.</div>' | ||
+'<a class="cpg-proceed-link" href="'+href+'">Proceed to the chest-pain muscle algorithm →</a | +'<a class="cpg-proceed-link" href="'+href+'">Proceed to the chest-pain muscle algorithm →</a></div>' | ||
+ disclaimer(); | + disclaimer(); | ||
} | } | ||
function render(){ if(S.phase==='gate')renderGate(); else if(S.phase==='result')renderResult(); else renderMain(); } | |||
/* ── events ── */ | |||
function onClick(e){ | function onClick(e){ | ||
var t; | var t; | ||
t=e.target.closest('[data-trop]'); | if (e.target.closest('#cpg-reset')){ var h=S.host,n=S.nextPage; S=fresh(); S.host=h; S.nextPage=n; render(); return; } | ||
t=e.target.closest('[data-trop]'); if(t){ S.hasTroponin=t.getAttribute('data-trop')==='1'; render(); return; } | |||
t=e.target.closest('[data- | t=e.target.closest('[data-ecgavail]'); if(t){ S.ecgAvail=t.getAttribute('data-ecgavail')==='1'; render(); return; } | ||
if (e.target.closest('#cpg-gate-go')){ if(S.hasTroponin!=null && S.ecgAvail!=null){ S.phase='main'; render(); } return; } | |||
if ( | t=e.target.closest('[data-ecg]'); if(t){ var k=t.getAttribute('data-ecg'); | ||
t=e.target.closest('[data- | if(k==='normal'){ S.ecg={a:false,b:false,c:false,d:false,normal:!S.ecg.normal}; } else { S.ecg[k]=!S.ecg[k]; S.ecg.normal=false; } render(); return; } | ||
t=e.target.closest('[data-openscore]'); if(t){ S.scoreType=t.getAttribute('data-openscore'); S.expand.builder=false; render(); return; } | |||
if (e.target.closest('[data- | if (e.target.closest('[data-closescore]')){ S.scoreType=null; S.b={}; S.expand.builder=false; render(); return; } | ||
t=e.target.closest('[data- | if (e.target.closest('[data-togglebuild]')){ S.expand.builder=!S.expand.builder; render(); return; } | ||
t=e.target.closest('[data-set]'); if(t){ var nm=t.getAttribute('data-set'),raw=t.getAttribute('data-val'); S.b[nm]=(raw==='unknown')?'unknown':parseInt(raw,10); render(); return; } | |||
t=e.target.closest('[data- | t=e.target.closest('[data-timi]'); if(t){ var v=t.getAttribute('data-val'); S.b['timi_'+t.getAttribute('data-timi')]=(v==='y')?true:(v==='n')?false:'unknown'; render(); return; } | ||
t=e.target.closest('[data-chk]'); if(t){ var ck=t.getAttribute('data-chk'); S.b[ck]=!S.b[ck]; render(); return; } | |||
t=e.target.closest('[data-row]'); if(t && !t.disabled){ var parts=t.getAttribute('data-row').split(':'),tid=parts[0],rid=parts[1],dir=t.getAttribute('data-val'); | |||
var cur=null,idx=-1; S.sf.forEach(function(x,i){ if(x.tableId===tid&&x.rowId===rid){cur=x.dir;idx=i;} }); | |||
t=e.target.closest('[data-row]'); | if (cur===dir){ S.sf.splice(idx,1); } // toggle off | ||
else { | |||
if (idx>=0) S.sf.splice(idx,1); // replace other direction on this row | |||
var tbl=TABLES.filter(function(x){return x.id===tid;})[0],row=tbl.rows.filter(function(x){return x.id===rid;})[0]; | |||
S.sf.push({tableId:tid,rowId:rid,dir:dir,lr:dir==='pos'?row.lrPos:row.lrNeg}); | |||
var tbl=TABLES.filter(function(x){return x.id===tid;})[0] | } | ||
render(); return; } | |||
if (e.target.closest('[data-result]')){ renderResult(); return; } | if (e.target.closest('[data-result]')){ renderResult(); return; } | ||
if (e.target.closest('#cpg-bypass')){ renderResult(); return; } | |||
if (e.target.closest('#cpg-bypass')){ | |||
} | } | ||
function boot(hostEl){ S=fresh(); S.host=hostEl; var np=hostEl.getAttribute('data-next-page'); if(np)S.nextPage=np; hostEl.addEventListener('click',onClick); render(); } | |||
function boot(hostEl){ S=fresh(); S.host=hostEl; var np=hostEl.getAttribute('data-next-page'); if(np) S.nextPage=np; hostEl.addEventListener('click',onClick); render(); } | |||
function init(){ document.querySelectorAll('.chest-pretest-gate').forEach(function(el){ if(!el.dataset.cpgBooted){ el.dataset.cpgBooted='1'; boot(el); } }); } | function init(){ document.querySelectorAll('.chest-pretest-gate').forEach(function(el){ if(!el.dataset.cpgBooted){ el.dataset.cpgBooted='1'; boot(el); } }); } | ||
return { init: init }; | return { init: init }; | ||
Latest revision as of 22:02, 21 June 2026
/**
* DiagnosticTree-Scoring.js — Upper Thoracic Probabilistic Scoring Model
* Copy entire contents to: MediaWiki:Gadget-DiagnosticTreeScoring.js
*
* Embed on a wiki page with:
* <div class="scoring-tree-host"
* data-tree-page="DiagnosticTree/UpperThoracicBackPain">
* </div>
*
* Requires: mediawiki.api
*
* PATCH NOTES (2026-06):
* - buildRedFlags() now reads emergency/urgent arrays from CURRENT_SCHEMA
* with fallback to hardcoded RF_EMERGENCY / RF_URGENT constants.
* All existing regions (Upper Thoracic, Anterior Shoulder, etc.) are
* unaffected — they have no emergency/urgent fields in their JSON so
* the fallback fires and behaviour is identical to before.
* - renderInterfaceHTML() now accepts schema param and uses
* schema.region_label for the masthead region string.
* - bootHost() passes schema to renderInterfaceHTML().
*
* PATCH NOTES (2026-06, chest pre-test gate):
* - Added self-contained ChestPretestGate module (JAMA 2015 ACS pre-test
* probability). It boots ONLY on elements with class .chest-pretest-gate
* (i.e. the page Pretest:Chest). Pages using .scoring-tree-host are
* completely unaffected. The gate ends by linking to its data-next-page
* (Pain:Chest), which runs this same scoring gadget unchanged.
*/
( function () {
'use strict';
/* ══════════════════════════════════════════════════════
ALL SCORING ENGINE FUNCTIONS
T, MUSCLE_IDS, DAG are module-level vars set from schema
══════════════════════════════════════════════════════ */
// T, MUSCLE_IDS, DAG, CURRENT_SCHEMA set in bootScoringInterface after schema loads
var T, MUSCLE_IDS, DAG, CURRENT_SCHEMA;
const LS_KEY='painwiki_upper_thoracic_counts_v1';
const RF_EMERGENCY=[{"id": "rf-e1", "label": "Aortic dissection", "question": "Sudden tearing or ripping interscapular pain; hypertension or Marfan features; pulse or BP difference between arms?"}, {"id": "rf-e2", "label": "Pulmonary embolism", "question": "Sudden-onset pleuritic chest or back pain (sharp, worse on inhalation); unexplained breathlessness, tachycardia, or hypoxia; recent immobility, surgery, or long-haul travel?"}, {"id": "rf-e3", "label": "Spinal cord compression / myelopathy", "question": "Bilateral arm or leg weakness, gait disturbance, loss of hand dexterity, or bowel/bladder dysfunction alongside neck or upper back pain?"}, {"id": "rf-e4", "label": "Meningism", "question": "Neck and upper back pain with fever, photophobia, or cerebellar signs (ataxia, dysarthria, nystagmus)?"}];
const RF_URGENT=[{"id": "rf-u1", "label": "Vertebral fracture", "question": "History of significant trauma, or patient is osteoporotic (post-menopausal, long-term corticosteroids, age > 70) with sudden-onset upper thoracic pain?"}, {"id": "rf-u2", "label": "Serious spinal pathology (tumour / infection)", "question": "Constant, progressive upper thoracic pain unrelated to posture or movement, worse at night lying down? Unexplained weight loss, fever, or history of cancer?"}, {"id": "rf-u3", "label": "Cervical instability", "question": "History of head or neck trauma combined with bilateral upper limb symptoms, gait disturbance, or upper cervical pain?"}, {"id": "rf-u4", "label": "Cardiac angina (exertional component)", "question": "Upper thoracic or interscapular pain with a clear exertional component, relieved by rest or GTN? Left-sided with arm radiation?"}, {"id": "rf-u5", "label": "Inflammatory arthropathy", "question": "Bilateral posterior neck or thoracic stiffness WORSE in the morning and improving with movement, with peripheral joint swelling or systemic symptoms?"}];
const BROAD_DIFF=[{"condition": "Cervical disc herniation (C5\u2013C7)", "confidence": "uncommon", "mimics": "Upper thoracic and interscapular referred pain via dorsal rami; arm symptoms overlapping with scaleni and trapezius TrP patterns", "distinguishing_feature": "Dermatomal arm pain; reflex change (biceps C5\u2013C6, triceps C7); true myotomal weakness; Spurling's test positive. TrP pain does not produce reflex changes or dermatomal sensory deficit. TrPs commonly develop secondarily to radiculopathy.", "action": "MRI cervical spine if neurological signs present. Treat TrPs concurrently \u2014 they frequently coexist with disc pathology and may be the dominant pain source."}, {"condition": "Thoracic outlet syndrome \u2014 neurological", "confidence": "rare", "mimics": "Arm and hand symptoms with upper thoracic pain; ulnar symptoms overlap with scaleni and pectoralis minor patterns", "distinguishing_feature": "Roos test (EAST test) positive at 3 minutes; nerve conduction studies confirm. Scaleni TrPs frequently coexist with and drive TOS symptoms \u2014 TrP inactivation often resolves or substantially reduces TOS.", "action": "Inactivate scaleni TrPs first before TOS workup. Refer for nerve conduction studies if symptoms persist after TrP treatment."}, {"condition": "Thoracic outlet syndrome \u2014 vascular", "confidence": "rare", "mimics": "Anterior shoulder and arm pain with upper thoracic aching; overlaps with subclavius and scaleni TrP patterns", "distinguishing_feature": "Radial pulse reduction or loss with arm abduction; hand oedema and finger stiffness. Wright manoeuvre positive. Scaleni and subclavius TrPs contribute to vascular compression via taut band tension.", "action": "Check radial pulse in standard and abducted positions. Scaleni and subclavius TrP inactivation is first-line. Refer for vascular assessment if pulse loss persists after TrP treatment."}, {"condition": "Rotator cuff tendinopathy / subacromial impingement", "confidence": "uncommon", "mimics": "Painful arc on shoulder abduction and lateral arm aching overlapping with supraspinatus TrP referral to the upper thoracic region", "distinguishing_feature": "Tenderness at greater tuberosity insertion; positive Neer's or Hawkins-Kennedy sign; imaging confirms tendon changes. TrP taut bands impose sustained enthesopathic tension \u2014 coexistence is common and causally related.", "action": "Treat supraspinatus TrPs first and reassess tendon findings after inactivation. Both conditions require treatment when identified together."}, {"condition": "Thoracic zygapophyseal (facet) joint pain", "confidence": "uncommon", "mimics": "Deep upper thoracic paraspinal pain in the same zone as multifidi TrP referral", "distinguishing_feature": "Pain reproduced by passive PA intersegmental pressures over the facet joints; hard end-feel on accessory movement testing. Multifidi TrPs and facet dysfunction coexist at the same segment and perpetuate each other \u2014 soft end-feel suggests TrP predominance.", "action": "Segmental accessory movement testing. Treat multifidi TrPs first \u2014 articular dysfunction often resolves with TrP inactivation. Mobilise the segment if hard end-feel persists."}, {"condition": "Herpes zoster \u2014 pre-eruptive and post-herpetic", "confidence": "uncommon", "mimics": "Unilateral burning upper thoracic pain before the rash appears \u2014 indistinguishable from rhomboid, multifidi, or intercostal TrP patterns at onset", "distinguishing_feature": "Dermatomal distribution; allodynia (light touch painful in a band); vesicles appear 1\u20134 days after pain onset. Age > 50 or immunocompromised increases suspicion. Post-herpetic: TrP pain coexists with neurogenic shooting pain.", "action": "Examine the skin carefully at every visit for unilateral burning thoracic pain. If vesicles present, refer urgently \u2014 antiviral window is 72 hours from rash onset."}, {"condition": "First rib dysfunction", "confidence": "rare", "mimics": "Upper thoracic and arm symptoms closely associated with scaleni TrP presentation \u2014 scaleni attach to the first rib and their taut bands directly elevate it", "distinguishing_feature": "First rib elevated and tender on posterior superior palpation; restricted first rib caudal glide on accessory movement testing. Scaleni TrPs are almost universally present concurrently.", "action": "First rib mobilisation in conjunction with scaleni TrP inactivation \u2014 the two conditions are mechanically linked and must be treated together."}, {"condition": "Winged scapula / serratus anterior weakness", "confidence": "rare", "mimics": "Medial scapular border pain and upper thoracic aching overlapping with rhomboid TrP presentation", "distinguishing_feature": "Scapular winging visible on push-up against wall or forward arm elevation; serratus anterior weakness on manual muscle testing. Rhomboid TrPs develop secondarily to serratus anterior inhibition \u2014 the rhomboids are overloaded trying to retract a scapula that serratus cannot stabilise.", "action": "Serratus anterior rehabilitation is the primary treatment. Treat rhomboid TrPs concurrently but address serratus anterior as the underlying driver \u2014 rhomboid TrPs will recur without it."}, {"condition": "Glenohumeral osteoarthritis", "confidence": "uncommon", "mimics": "Diffuse shoulder girdle and upper thoracic pain, particularly in older patients with supraspinatus and biceps TrP patterns", "distinguishing_feature": "Capsular pattern restriction (external rotation > abduction > internal rotation); crepitus on movement; radiological changes. Arthritis does not produce spot-tender taut bands. TrPs coexist and are independently treatable.", "action": "Shoulder radiograph. Inactivate TrPs concurrently with joint management \u2014 TrP treatment can substantially reduce pain even in the presence of established arthritis."}, {"condition": "Fibromyalgia", "confidence": "uncommon", "mimics": "Widespread upper back and shoulder girdle tenderness overlapping with all muscles in this algorithm simultaneously", "distinguishing_feature": "Widespread pain \u2265 3 months across multiple body regions; diffuse tenderness without specific TrP referral patterns; fatigue and non-restorative sleep. TrPs produce specific referred pain patterns and are a treatable component of fibromyalgia.", "action": "Systematic TrP examination alongside fibromyalgia management. Treating active TrPs reduces the overall pain burden independently \u2014 do not withhold TrP treatment because fibromyalgia is also present."}, {"condition": "Residual pain after spinal manipulation or injection", "confidence": "atypical", "mimics": "Persistent upper thoracic pain after a spinal procedure \u2014 may be re-attributed to the joint when myofascial TrPs are the active pain source", "distinguishing_feature": "After a spinal intervention, pain persisting at a lower level or shifting in character indicates TrPs masked by dominant segmental pain are now the primary source. The TrPs were pre-existing \u2014 not created by the procedure.", "action": "Re-examine muscles systematically after any spinal intervention. Most commonly harbouring residual TrPs: trapezius (upper and mid), levator scapulae, rhomboids, multifidi."}];
// ── RED FLAGS ──────────────────────────────────────────────────────
function buildRedFlags(){
// PATCH: read from schema if present, otherwise fall back to hardcoded constants
var emergency = (CURRENT_SCHEMA.emergency && CURRENT_SCHEMA.emergency.length)
? CURRENT_SCHEMA.emergency : RF_EMERGENCY;
var urgent = (CURRENT_SCHEMA.urgent && CURRENT_SCHEMA.urgent.length)
? CURRENT_SCHEMA.urgent : RF_URGENT;
function makeFlags(arr,id,isUrgent){
document.getElementById(id).innerHTML=arr.map(rf=>
'<div class="rf-item">'+
'<label class="rf-check-wrap" for="'+rf.id+'">'+
'<input type="checkbox" class="rf-cb" id="'+rf.id+'">'+
'</label>'+
'<div class="rf-body">'+
'<div class="rf-label">'+rf.label+'</div>'+
'<div class="rf-question">'+rf.question+'</div>'+
'</div></div>'
).join('');
}
makeFlags(emergency,'rf-emergency-items',false);
makeFlags(urgent,'rf-urgent-items',true);
document.getElementById('btn-affirm').addEventListener('click',()=>{
document.getElementById('btn-affirm').style.display='none';
document.getElementById('affirmed-badge').classList.add('show');
const mg=document.getElementById('main-grid');
mg.classList.add('visible');
posteriors=getUpdatedPriors();advance();renderLearnPanel();
// Collapse red flag panel to summary bar
const rfWrap=document.getElementById('rf-outer-wrap');
if(rfWrap) rfWrap.classList.add('rf-collapsed');
setTimeout(()=>mg.scrollIntoView({behavior:'smooth',block:'start'}),80);
});
}
// ── BROAD DIFFERENTIAL ─────────────────────────────────────────────
function buildBroadDiff(){
const broadDiff = (CURRENT_SCHEMA.broad_differential && CURRENT_SCHEMA.broad_differential.length)
? CURRENT_SCHEMA.broad_differential : BROAD_DIFF;
document.getElementById('broad-grid').innerHTML=broadDiff.map(d=>
'<div class="dt-diff-item">'+
'<div class="dt-diff-confidence '+d.confidence+'">'+d.confidence+'</div>'+
'<div class="dt-diff-name">'+d.condition+'</div>'+
'<div class="dt-diff-mimics">'+d.mimics+'</div>'+
'<div class="dt-diff-detail">'+
'<div class="dt-diff-distinguisher"><strong>Distinguishing features</strong>'+d.distinguishing_feature+'</div>'+
'<div class="dt-diff-action"><strong>Action</strong>'+d.action+'</div>'+
'</div></div>'
).join('');
// click to expand
document.getElementById('broad-grid').addEventListener('click',e=>{
const item=e.target.closest('.dt-diff-item');
if(item) item.classList.toggle('open');
});
// toggle visibility
document.getElementById('broad-toggle-btn').addEventListener('click',()=>{
const grid=document.getElementById('broad-grid');
const hidden=grid.classList.toggle('hidden');
document.getElementById('broad-toggle-btn').textContent=hidden?'Show':'Hide';
});
}
// ── LEARNING ───────────────────────────────────────────────────────
function loadCounts(){try{const r=localStorage.getItem(LS_KEY);if(r)return JSON.parse(r);}catch(e){}const c={};MUSCLE_IDS.forEach(m=>c[m]=0);return c;}
function saveCounts(c){try{localStorage.setItem(LS_KEY,JSON.stringify(c));}catch(e){}}
function incrementCount(mid){const c=loadCounts();c[mid]=(c[mid]||0)+1;saveCounts(c);return c;}
function getUpdatedPriors(){const counts=loadCounts(),p={};MUSCLE_IDS.forEach(m=>p[m]=CURRENT_SCHEMA.muscles[m].prior+(counts[m]||0)*0.02);return normalise(p);}
// ── STATE & MATH ───────────────────────────────────────────────────
let answers={},posteriors={},trail=[],pairwiseDone=new Set(),queueIdx=0,earlyDone=false;
function normalise(p){let t=0;MUSCLE_IDS.forEach(m=>t+=p[m]);const o={};MUSCLE_IDS.forEach(m=>o[m]=p[m]/t);return o;}
function applyLR(p,lr){const o={};MUSCLE_IDS.forEach(m=>o[m]=p[m]*(lr[m]||1.0));return normalise(o);}
function ranked(){return MUSCLE_IDS.map(m=>({id:m,p:posteriors[m],label:CURRENT_SCHEMA.muscles[m].label,subtitle:CURRENT_SCHEMA.muscles[m].subtitle||'',page:CURRENT_SCHEMA.muscles[m].page,note:CURRENT_SCHEMA.muscles[m].key_trp_note||null})).sort((a,b)=>b.p-a.p);}
function shouldEarlyExit(r){return r[0].p>=T.early_exit_posterior&&(r[0].p-r[1].p)>=T.early_exit_gap;}
function getPairwise(r){if(Object.keys(answers).length===0)return null;if((r[0].p-r[1].p)>=T.pairwise_trigger)return null;const s=new Set([r[0].id,r[1].id]);for(const pw of CURRENT_SCHEMA.pairwise)if(!pairwiseDone.has(pw.id)&&pw.pair.every(x=>s.has(x)))return pw;return null;}
function condMet(q){if(!q.condition)return true;return Object.entries(q.condition).every(([k,v])=>answers[k]===v);}
function nextQ(){for(let i=queueIdx;i<CURRENT_SCHEMA.questions.length;i++){const q=CURRENT_SCHEMA.questions[i];if(answers[q.id]!==undefined)continue;if(!condMet(q))continue;queueIdx=i;return q;}return null;}
function buildTreatmentOrder(r){const active=r.filter(m=>m.p>=0.05).map(m=>m.id);if(!active.length)return[];const steps=[],placed=new Set(),eL=DAG.edge_type_labels;active.forEach(mid=>{const out=DAG.edges.filter(e=>e.from===mid&&e.type==='key_satellite'&&active.includes(e.to));if(out.length&&!placed.has(mid)){const m=r.find(x=>x.id===mid);steps.push({label:m.label,page:m.page,edgeType:'key_satellite',edgeLabel:eL['key_satellite'],note:out.map(e=>e.label).join('; ')});placed.add(mid);}});active.filter(m=>!placed.has(m)).forEach(mid=>{const m=r.find(x=>x.id===mid);const inE=DAG.edges.find(e=>e.to===mid&&placed.has(e.from));const wE=DAG.edges.find(e=>e.type==='antagonist_risk'&&((e.from===mid&&active.includes(e.to))||(e.to===mid&&active.includes(e.from))));const et=wE?'antagonist_risk':(inE?inE.type:'functional_unit');steps.push({label:m.label,page:m.page,edgeType:et,edgeLabel:eL[et]||'Treat in session',note:wE?wE.label:(inE?inE.label:'')});placed.add(mid);});return steps;}
// ── RENDER ─────────────────────────────────────────────────────────
function renderDiff(){const r=ranked();const html=r.map((m,i)=>{const rank=i+1,w=(m.p*100).toFixed(1),bw=Math.max(0.5,m.p*100).toFixed(1),dim=rank>6?' dimmed':'',rc=rank<=3?' r'+rank:'';return'<div class="dt-diff-bar-row'+rc+dim+'"><div class="dt-diff-bar-rank">'+rank+'</div><div class="dt-diff-bar-name">'+m.label+'</div><div class="dt-diff-bar-track"><div class="dt-diff-bar-fill" style="width:'+bw+'%"></div></div><div class="dt-diff-bar-weight">'+w+'</div></div>';}).join('');const dr=document.getElementById('diff-rows');if(dr)dr.innerHTML=html;const drr=document.getElementById('diff-rows-result');if(drr)drr.innerHTML=html;}
function renderTrail(){const el=document.getElementById('trail-panel');if(!trail.length){el.classList.remove('visible');return;}el.classList.add('visible');document.getElementById('trail-counter').textContent=trail.length+' feature'+(trail.length>1?'s':'')+' recorded';document.getElementById('trail-items').innerHTML=trail.map(t=>'<div class="dt-trail-item"><div class="dt-trail-q">'+t.q+'</div><div class="dt-trail-a">↳ '+t.a+'</div></div>').join('');}
function renderCounter(){const tot=CURRENT_SCHEMA.questions.length,ans=Object.keys(answers).length;const txt=ans===0?'Prior weights':ans+'\u202f/\u202f'+tot+' features';const qc=document.getElementById('q-counter');if(qc)qc.textContent=txt;}
function renderLearnPanel(){const counts=loadCounts(),total=Object.values(counts).reduce((a,b)=>a+b,0);const el=document.getElementById('learn-panel');if(!total){el.classList.remove('visible');return;}el.classList.add('visible');document.getElementById('learn-total').textContent=total+' confirmation'+(total>1?'s':'');document.getElementById('learn-rows').innerHTML=MUSCLE_IDS.filter(m=>counts[m]>0).sort((a,b)=>counts[b]-counts[a]).map(m=>'<div class="learn-row"><div class="learn-muscle">'+CURRENT_SCHEMA.muscles[m].label+'</div><div class="learn-count">'+counts[m]+'×</div></div>').join('');}
function renderQuestion(q,isPW){const qText=q.text||q.question;const label=isPW?'<div class="dt-card-label pairwise">Tiebreaker</div>':'<div class="dt-card-label">Clinical Feature '+(trail.length+1)+'</div>';const sub=q.sublabel?'<div class="dt-rationale">'+q.sublabel+'</div>':'';const btns='<div class="dt-answers"><button class="dt-answer dt-answer-yes" data-qid="'+(isPW?'__pw__'+q.id:q.id)+'" data-aid="yes" data-qlabel="'+encodeURIComponent(qText)+'" data-alabel="'+encodeURIComponent('Yes')+'">Yes</button><button class="dt-answer dt-answer-no" data-qid="'+(isPW?'__pw__'+q.id:q.id)+'" data-aid="no" data-qlabel="'+encodeURIComponent(qText)+'" data-alabel="'+encodeURIComponent('No')+'">No</button></div>';document.getElementById('question-area').innerHTML='<div class="dt-card"><div class="dt-card-head">'+label+'</div><div class="dt-question">'+qText+'</div>'+sub+btns+'</div>';}
// Fix: answer buttons need correct aid from schema
function renderQuestionFull(q,isPW){
const qText=q.text||q.question;
const label=isPW?'<div class="dt-card-label pairwise">Tiebreaker</div>':'<div class="dt-card-label">Clinical Feature '+(trail.length+1)+'</div>';
const sub=q.sublabel?'<div class="dt-rationale" style="font-size:.78rem;color:#57534e;font-style:italic;margin-bottom:.55rem;line-height:1.5">'+q.sublabel+'</div>':'';
const btns=q.answers.map(a=>{
const asub=a.sublabel?'<div style="font-size:.72rem;color:#57534e;font-weight:400;margin-top:2px">'+a.sublabel+'</div>':'';
const cls=q.answers.length===2
? (q.answers.indexOf(a)===0?'dt-answer dt-answer-yes':'dt-answer dt-answer-no')
: 'dt-answer dt-answer-yes';
return'<button class="'+cls+'" style="'+(q.answers.length>2?'flex:none;flex-direction:column;align-items:flex-start;':'')+'" data-qid="'+(isPW?'__pw__'+q.id:q.id)+'" data-aid="'+a.id+'" data-qlabel="'+encodeURIComponent(qText)+'" data-alabel="'+encodeURIComponent(a.label)+'">'+a.label+asub+'</button>';
}).join('');
const ansWrap=q.answers.length>2
?'<div style="display:flex;flex-direction:column;gap:.5rem;margin-top:1rem">'+btns+'</div>'
:'<div class="dt-answers">'+btns+'</div>';
document.getElementById('question-area').innerHTML='<div class="dt-card"><div class="dt-card-head">'+label+'</div><div class="dt-question">'+qText+'</div>'+sub+ansWrap+'</div>';
}
function renderEarlyExit(r){earlyDone=true;const top=r[0];document.getElementById('question-area').innerHTML='<div class="dt-card"><div class="dt-early-badge">Confident result available</div><div class="dt-early-muscle">'+top.label+'</div><div class="dt-early-sub">'+top.subtitle+'<br>Relative weight '+(top.p*100).toFixed(0)+' — accept to view result, or continue refining.</div><div class="dt-early-btns"><button class="dt-btn-primary" id="btn-accept">Accept result</button><button class="dt-btn-secondary" id="btn-continue">Continue refining</button></div></div>';document.getElementById('btn-accept').addEventListener('click',showResult);document.getElementById('btn-continue').addEventListener('click',()=>{earlyDone=true;advance();});}
function buildAddConfirm(r){const rem=r.slice(3);if(!rem.length)return'';const opts=rem.map(m=>'<option value="'+m.id+'">'+m.label+' (rank '+(r.indexOf(m)+1)+', weight '+(m.p*100).toFixed(1)+')</option>').join('');return'<div class="dt-add-confirm"><div class="dt-add-confirm-label">Also confirm a muscle not in the top three</div><div class="dt-add-confirm-row"><select class="dt-add-select" id="add-select"><option value="">— select muscle —</option>'+opts+'</select><button class="dt-btn-add-confirm" id="btn-add-confirm-ok">Confirm</button></div><div class="dt-add-confirm-done" id="add-confirm-done">✓ Confirmed and model updated</div></div>';}
function showResult(){
var mg=document.getElementById('main-grid');
if(mg) mg.classList.add('result-phase');
const r=ranked();
const ranks=['Most likely','2nd','3rd'],rk=['rk1','rk2','rk3'],card_cls=['dt-card-result','dt-card','dt-card'];
const cards=r.slice(0,3).map((m,i)=>{
const w=(m.p*100).toFixed(1);
const note=m.note?'<div class="dt-key-note"><div class="dt-key-note-label">Key TrP relationship</div>'+m.note+'</div>':'';
return'<div class="dt-card '+card_cls[i]+'" style="margin-bottom:10px">'+
'<div class="dt-result-rank '+rk[i]+'">'+ranks[i]+'</div>'+
'<div class="dt-result-name">'+m.label+'</div>'+
'<div class="dt-result-sub">'+m.subtitle+'</div>'+
'<div class="dt-result-weight">Relative weight: '+w+'</div>'+
'<div class="dt-result-actions">'+
'<a class="dt-wiki-link" href="https://painwiki.com/wiki/index.php?title='+m.page+'" target="_blank" rel="noopener">'+m.label+' on PainWiki</a>'+
'<button class="dt-confirm-btn" data-muscle="'+m.id+'" data-label="'+encodeURIComponent(m.label)+'">Confirm this muscle</button>'+
'</div>'+note+'</div>';
}).join('');
const addSec=buildAddConfirm(r);
document.getElementById('question-area').innerHTML=
'<div style="font-family:\'DM Mono\',monospace;font-size:.65rem;letter-spacing:.12em;text-transform:uppercase;color:#a8a29e;margin-bottom:8px">Differential — final weights</div>'+
cards+
(addSec?'<div class="dt-card" style="margin-top:4px"><div style="font-family:\'DM Mono\',monospace;font-size:.65rem;letter-spacing:.1em;text-transform:uppercase;color:#a8a29e;margin-bottom:10px">Additional Confirmation</div>'+addSec+'</div>':'');
const addBtn=document.getElementById('btn-add-confirm-ok');
if(addBtn){addBtn.addEventListener('click',()=>{const sel=document.getElementById('add-select'),mid=sel.value;if(!mid)return;incrementCount(mid);addBtn.disabled=true;sel.disabled=true;document.getElementById('add-confirm-done').style.display='block';renderLearnPanel();});}
const steps=buildTreatmentOrder(r);
if(steps.length){
const tp=document.getElementById('treat-panel');tp.classList.add('visible');
document.getElementById('treat-items').innerHTML=steps.map((s,i)=>{const isW=s.edgeType==='antagonist_risk';return'<div class="dt-treat-item"><div class="dt-treat-step">'+(i+1)+'</div><div><div class="dt-treat-muscle">'+s.label+'</div><div class="dt-treat-edge'+(isW?' warn':'')+'">'+s.edgeLabel+'</div>'+(s.note&&!isW?'<div class="dt-treat-edge" style="margin-top:2px;font-size:10px;color:#a8a29e">'+s.note+'</div>':'')+'</div></div>';}).join('');
}
renderLearnPanel();
}
function advance(){renderDiff();renderTrail();renderCounter();const r=ranked(),pw=getPairwise(r);if(pw){pairwiseDone.add(pw.id);renderQuestionFull(pw,true);return;}if(!earlyDone&&shouldEarlyExit(r)){renderEarlyExit(r);return;}const q=nextQ();if(q){queueIdx++;renderQuestionFull(q,false);return;}showResult();}
/* Click handler, reset, toggle, buildRedFlags and buildBroadDiff
are all wired inside renderInterfaceHTML() and bootScoringInterface()
after the DOM elements exist. Do not call them here. */
/* ══════════════════════════════════════════════════════
BOOT — called after schema is loaded from wiki data page
Injects interface HTML then wires all event listeners
══════════════════════════════════════════════════════ */
function bootScoringInterface( hostEl, SCHEMA ) {
// 1. Set schema-derived globals FIRST — functions depend on these
CURRENT_SCHEMA = SCHEMA;
T = CURRENT_SCHEMA.thresholds;
MUSCLE_IDS = Object.keys( CURRENT_SCHEMA.muscles );
DAG = CURRENT_SCHEMA.treatment_dag;
// 2. Rebuild posteriors from schema priors + any stored confirmations
posteriors = getUpdatedPriors();
// 3. Run the interface build functions — DOM elements now exist
buildRedFlags();
buildBroadDiff();
advance();
renderLearnPanel();
}
/* ══════════════════════════════════════════════════════
SCHEMA LOADER — fetches JSON from wiki data page
══════════════════════════════════════════════════════ */
function bootHost( hostEl ) {
var treePage = hostEl.getAttribute( 'data-tree-page' );
if ( !treePage ) return;
hostEl.innerHTML =
'<div style="padding:1.5em;font-family:DM Mono,monospace;' +
'font-size:0.8em;color:#a8a29e;text-align:center">' +
'Loading scoring model…</div>';
var api = new mw.Api();
api.get( {
action: 'query',
titles: treePage,
prop: 'revisions',
rvprop: 'content',
rvslots: 'main',
format: 'json'
} ).done( function ( data ) {
var pages = data.query.pages;
var pageId = Object.keys( pages )[0];
if ( pageId === '-1' ) {
hostEl.innerHTML =
'<div style="color:#b91c1c;padding:1em;font-family:monospace">' +
'Scoring model not found: ' + treePage + '</div>';
return;
}
var raw = pages[ pageId ].revisions[0].slots.main['*'];
var schema;
try {
schema = JSON.parse( raw );
} catch ( e ) {
hostEl.innerHTML =
'<div style="color:#b91c1c;padding:1em;font-family:monospace">' +
'Invalid JSON in ' + treePage + ': ' + e.message + '</div>';
return;
}
// PATCH: pass schema to renderInterfaceHTML for region label
renderInterfaceHTML( hostEl, schema );
bootScoringInterface( hostEl, schema );
} ).fail( function () {
hostEl.innerHTML =
'<div style="color:#b91c1c;padding:1em;font-family:monospace">' +
'Failed to load: ' + treePage + '</div>';
} );
}
/* ══════════════════════════════════════════════════════
INTERFACE HTML RENDERER
Builds the shell, red flag panels, grid, and broad diff
into the host element — without any JS execution
PATCH: accepts schema param for region_label
══════════════════════════════════════════════════════ */
function renderInterfaceHTML( hostEl, schema ) {
// PATCH: use schema.region_label if present, fall back to original string
var regionLabel = ( schema && schema.region_label )
? schema.region_label
: 'Upper Thoracic Back Pain';
hostEl.innerHTML = [
'<div class="proto-shell">',
// Masthead
'<header class="proto-masthead">',
' <div class="proto-logo">Pain<span>Wiki</span></div>',
' <div class="proto-right">',
' <span class="proto-region">Diagnostic Algorithm · ' + regionLabel + '</span>',
' <button class="learning-toggle" id="learning-toggle-btn">Learning: ON</button>',
' </div>',
'</header>',
// Red flags — collapsible after affirm
'<div class="rf-outer" id="rf-outer-wrap">',
' <div class="rf-collapsed-bar" id="rf-collapsed-bar">',
' <span class="rf-collapsed-label">Red flags cleared</span>',
' <button class="rf-expand-btn" id="rf-expand-toggle">Expand ▿</button>',
' </div>',
' <div class="rf-expandable" id="rf-expandable">',
' <div class="rf-panel-wrap" id="rf-emergency-panel">',
' <div class="rf-col-header">⚠ Emergency — stop and act if any are present</div>',
' <div class="rf-items" id="rf-emergency-items"></div>',
' </div>',
' <div class="rf-panel-wrap urgent" id="rf-urgent-panel">',
' <div class="rf-col-header">⚠ Urgent — refer before myofascial assessment</div>',
' <div class="rf-items" id="rf-urgent-items"></div>',
' <div class="rf-affirm">',
' <div class="rf-affirm-note">All emergency and urgent flags screened and negative — or appropriate action taken.</div>',
' <button class="btn-affirm" id="btn-affirm">Proceed to clinical interview →</button>',
' <div class="affirmed-badge" id="affirmed-badge">Red flags cleared</div>',
' </div>',
' </div>',
' </div>',
'</div>',
// Main content — hidden until affirmed
'<div id="main-grid">',
// Question card — always single column
'<div id="question-area"></div>',
// Running diff — single column during interview
'<div class="dt-running-panel" id="interview-diff-panel">',
' <div class="dt-running-head">',
' <span class="dt-running-title">Running Differential</span>',
' <span class="dt-running-meta" id="q-counter">Prior weights</span>',
' </div>',
' <div id="diff-rows"></div>',
' <div class="dt-diff-caption">Diagnostic weights are relative, not absolute probabilities.</div>',
'</div>',
// Result side grid — 2 columns: running diff | treatment order
// Hidden during interview, shown in result phase
'<div id="result-side-grid">',
' <div class="dt-running-panel">',
' <div class="dt-running-head">',
' <span class="dt-running-title">Running Differential</span>',
' <span class="dt-running-meta" id="q-counter-result">Final weights</span>',
' </div>',
' <div id="diff-rows-result"></div>',
' <div class="dt-diff-caption">Diagnostic weights are relative, not absolute probabilities.</div>',
' </div>',
' <div class="dt-treat-panel" id="treat-panel">',
' <div class="dt-treat-head"><span class="dt-treat-title">Suggested Treatment Order</span></div>',
' <div id="treat-items"></div>',
' </div>',
'</div>',
// Trail — single column
'<div class="dt-trail-panel" id="trail-panel">',
' <div class="dt-trail-head">',
' <span class="dt-trail-title">Clinical Interview Record</span>',
' <span class="dt-trail-meta" id="trail-counter"></span>',
' </div>',
' <div id="trail-items"></div>',
'</div>',
// Learn panel — single column
'<div class="learn-panel" id="learn-panel">',
' <div class="learn-head">',
' <span class="learn-title">Confirmed Cases — This Device</span>',
' <span class="learn-meta" id="learn-total"></span>',
' </div>',
' <div class="learn-intro"><strong>Multiple muscles can be confirmed per case.</strong></div>',
' <div id="learn-rows"></div>',
'</div>',
// Broad differential — single column, 2-col grid inside
'<div class="dt-broad-panel" id="broad-panel">',
' <div class="dt-broad-header">',
' <div class="dt-broad-header-top">',
' <div class="dt-broad-title">■ Broad Differential Diagnosis</div>',
' <button class="dt-broad-toggle" id="broad-toggle-btn">Hide</button>',
' </div>',
' <div class="dt-epigraph">',
' “If he does not expect the unexpected, he will not discover it —',
' for it is difficult to discover and intractable.”',
' <cite>— Heraclitus, Fr. 18</cite>',
' </div>',
' </div>',
' <div class="dt-broad-grid" id="broad-grid"></div>',
'</div>',
'</div>', // end main-grid
// Footer
'<div class="proto-foot">',
' <div class="proto-disclaimer"><strong>Not validated for clinical use.</strong> Research prototype.</div>',
' <button class="btn-reset" id="reset-btn">↻ Start over</button>',
'</div>',
'</div>' // end proto-shell
].join( '\n' );
// Wire all event listeners — DOM elements exist now
// Answer and confirm button clicks (delegated)
document.addEventListener( 'click', function ( e ) {
var ab = e.target.closest( '.dt-answer,.answer-btn' );
if ( ab && ab.dataset.qid ) {
var qid = ab.dataset.qid;
var aid = ab.dataset.aid;
var qLabel = decodeURIComponent( ab.dataset.qlabel );
var aLabel = decodeURIComponent( ab.dataset.alabel );
if ( qid.indexOf( '__pw__' ) === 0 ) {
var pwId = qid.replace( '__pw__', '' );
var pw = CURRENT_SCHEMA.pairwise.find( function(x){ return x.id === pwId; } );
var pa = pw.answers.find( function(x){ return x.id === aid; } );
posteriors = applyLR( posteriors, pa.lr );
trail.push( { q: qLabel, a: aLabel } );
earlyDone = false;
} else {
var q = CURRENT_SCHEMA.questions.find( function(x){ return x.id === qid; } );
var a = q.answers.find( function(x){ return x.id === aid; } );
answers[ qid ] = aid;
posteriors = applyLR( posteriors, a.lr );
trail.push( { q: qLabel, a: aLabel } );
queueIdx++;
}
advance();
return;
}
var cb = e.target.closest( '.dt-confirm-btn' );
if ( cb && !cb.classList.contains( 'confirmed' ) ) {
var mid = cb.dataset.muscle;
var mlabel = decodeURIComponent( cb.dataset.label );
incrementCount( mid );
cb.classList.add( 'confirmed' );
cb.textContent = '\u2713\u2713 ' + mlabel + ' confirmed';
renderLearnPanel();
return;
}
} );
// Reset button
var resetBtn = document.getElementById( 'reset-btn' );
if ( resetBtn ) {
resetBtn.addEventListener( 'click', function () {
answers = {}; trail = [];
pairwiseDone = new Set();
queueIdx = 0; earlyDone = false;
posteriors = getUpdatedPriors();
var tp = document.getElementById( 'trail-panel' );
var tp2 = document.getElementById( 'treat-panel' );
if ( tp ) tp.classList.remove( 'visible' );
if ( tp2 ) tp2.classList.remove( 'visible' );
var mg = document.getElementById( 'main-grid' );
if ( mg ) mg.classList.remove( 'result-phase' );
// Re-collapse red flags (stay collapsed on reset)
var rfWrap = document.getElementById( 'rf-outer-wrap' );
if ( rfWrap ) rfWrap.classList.add( 'rf-collapsed' );
advance();
} );
}
// Learning toggle
var learnBtn = document.getElementById( 'learning-toggle-btn' );
if ( learnBtn ) {
learnBtn.addEventListener( 'click', function () {
var h = document.body.classList.toggle( 'hide-learning' );
learnBtn.textContent = 'Learning: ' + ( h ? 'OFF' : 'ON' );
} );
}
// Red flag expand toggle
var rfExpandBtn = document.getElementById( 'rf-expand-toggle' );
if ( rfExpandBtn ) {
rfExpandBtn.addEventListener( 'click', function () {
var wrap = document.getElementById( 'rf-outer-wrap' );
if ( wrap ) {
var collapsed = wrap.classList.toggle( 'rf-collapsed' );
rfExpandBtn.textContent = collapsed ? 'Expand \u25be' : 'Collapse \u25b2';
}
} );
}
}
/* ══════════════════════════════════════════════════════
CHEST PRE-TEST GATE (JAMA 2015 ACS pre-test probability)
Fully isolated nested module. Boots ONLY on .chest-pretest-gate
elements (page Pretest:Chest). Shares NO state with the scoring
engine above. All clinical reference data embedded; all
calculation held in memory; ends by linking to data-next-page.
Source: Fanaroff et al. JAMA 2015;314(18):1955-1965 (Rational
Clinical Examination); NHFA/CSANZ Australian ACS Guideline 2025.
LR+/LR- values per user-supplied JAMA table CSV.
══════════════════════════════════════════════════════ */
var ChestPretestGate = ( function () {
var BASELINE = 0.13;
var JAMA_URL = 'https://doi.org/10.1001/jama.2015.12735';
var JAMA_CIT = 'Fanaroff AC, Rymer JA, Goldstein SA, Simel DL, Newby LK. Does This Patient With Chest Pain Have Acute Coronary Syndrome? JAMA. 2015;314(18):1955\u20131965.';
var CITE = {
jama:'Fanaroff et al. JAMA 2015;314(18):1955-1965', acs25:'NHFA/CSANZ Australian ACS Guideline 2025',
heart:'HEART score (Backus 2008). Strata LRs per JAMA 2015 Table 5',
hear:'HEAR score (HEART minus troponin); \u22641 = very-low-risk rule-out',
timi:'TIMI risk score UA/NSTEMI (Antman 2000). Strata LRs per JAMA 2015 Table 5',
t1:'JAMA 2015 \u2014 Table 1 (cardiac risk factors)', t2:'JAMA 2015 \u2014 Table 2 (chest-pain characteristics)', t3:'JAMA 2015 \u2014 Table 3 (physical examination)'
};
/* Risk-factor checkboxes (carry JAMA +LR for the fallback pool). timi=true counts toward TIMI \u22653. */
var RISK_FACTORS = [
{id:'htn', label:'Hypertension', lr:1.1, timi:true},
{id:'lipid',label:'Hypercholesterolaemia', lr:1.2, timi:true},
{id:'dm', label:'Diabetes mellitus', lr:1.4, timi:true},
{id:'obes', label:'Obesity (BMI > 30)', lr:0.97,timi:false},
{id:'smoke',label:'Smoking (current or \u2264 3 mo)',lr:1.0,timi:true},
{id:'fhx', label:'Family history CVD < 65', lr:1.0, timi:true}
];
var ATHERO = [
{id:'mi', label:'Prior MI', lr:1.6},
{id:'pci',label:'Prior PCI / CABG', lr:1.4},
{id:'cva',label:'Prior CVA / TIA', lr:1.3},
{id:'pad',label:'Peripheral arterial disease', lr:2.7}
];
/* JAMA single-feature tables. Table 1 = risk factors (locks when a score is built). */
var TABLE1 = { id:'t1', table:1, title:'Personal Medical History', cite:CITE.t1, rows:[
{id:'t1_stress',text:'Abnormal prior stress test',lrPos:3.1,lrNeg:0.92},{id:'t1_pad',text:'Peripheral arterial disease',lrPos:2.7,lrNeg:0.96},
{id:'t1_mi',text:'Prior myocardial infarction',lrPos:1.6,lrNeg:0.88},{id:'t1_cabg',text:'Prior CABG',lrPos:1.4,lrNeg:0.90},
{id:'t1_cad',text:'Prior CAD',lrPos:2.0,lrNeg:0.75},{id:'t1_dm',text:'Diabetes mellitus',lrPos:1.4,lrNeg:0.97},
{id:'t1_cvd',text:'Cerebrovascular disease',lrPos:1.3,lrNeg:0.70},{id:'t1_male',text:'Male sex',lrPos:1.3,lrNeg:0.85},
{id:'t1_lipid',text:'Hyperlipidaemia',lrPos:1.2,lrNeg:0.78},{id:'t1_htn',text:'Hypertension',lrPos:1.1,lrNeg:0.96},
{id:'t1_tob',text:'Any tobacco use',lrPos:1.0,lrNeg:0.99},{id:'t1_fhx',text:'Family history of CAD',lrPos:1.0,lrNeg:0.99},
{id:'t1_obes',text:'Obesity',lrPos:0.97,lrNeg:1.0}
]};
var TABLE2 = { id:'t2', table:2, title:'Associated Symptoms', cite:CITE.t2, rows:[
{id:'t2_botharms',text:'Pain radiates to BOTH arms',lrPos:2.6,lrNeg:0.93},
{id:'t2_leftarm',text:'Radiates to left arm',lrPos:1.3,lrNeg:0.88},
{id:'t2_rightarm',text:'Radiates to right arm',lrPos:1.3,lrNeg:0.99},
{id:'t2_neckjaw',text:'Radiates to neck or jaw',lrPos:1.5,lrNeg:0.91},
{id:'t2_change',text:'Change in pattern over prior 24 h',lrPos:2.0,lrNeg:0.84},
{id:'t2_typical',text:'\u201CTypical\u201D (pressure-like) pain',lrPos:1.9,lrNeg:0.52},
{id:'t2_exertion',text:'Worse with exertion',lrPos:1.65,lrNeg:0.83},
{id:'t2_diaph',text:'Associated diaphoresis',lrPos:1.4,lrNeg:0.93},
{id:'t2_burning',text:'Burning pain',lrPos:1.2,lrNeg:1.0},
{id:'t2_ntg',text:'Any improvement with nitroglycerin',lrPos:1.1,lrNeg:0.90,nb:'Unhelpful for diagnosing ACS.'},
{id:'t2_typrad',text:'\u201CTypical\u201D radiation',lrPos:1.0,lrNeg:0.98},
{id:'t2_pleur',text:'Pleuritic pain',lrPos:0.61,lrNeg:1.2,nb:'Lowers likelihood.'},
{id:'t2_prior',text:'Similar to prior ischaemia',lrPos:2.2,lrNeg:0.67},
{id:'t2_recent',text:'Recent episode of similar pain',lrPos:1.3,lrNeg:0.80},
{id:'t2_abrupt',text:'Abrupt onset',lrPos:1.1,lrNeg:0.75},
{id:'t2_nausea',text:'Associated nausea / vomiting',lrPos:1.1,lrNeg:1.0},
{id:'t2_dyspnea',text:'Associated dyspnoea',lrPos:1.2,lrNeg:0.89},
{id:'t2_palp',text:'Associated palpitations',lrPos:1.3,lrNeg:1.0},
{id:'t2_syncope',text:'Associated syncope',lrPos:0.76,lrNeg:1.1,nb:'Lowers likelihood.'}
]};
var TABLE3 = { id:'t3', table:3, title:'Vitals / Physical Exam', cite:CITE.t3, rows:[
{id:'t3_hypo',text:'Hypotension (SBP < 100)',lrPos:3.9,lrNeg:0.98},{id:'t3_rales',text:'Lung rales / crackles',lrPos:2.0,lrNeg:0.95},
{id:'t3_tachyp',text:'Tachypnoea',lrPos:1.9,lrNeg:0.95},{id:'t3_tachyc',text:'Tachycardia (HR > 120)',lrPos:1.3,lrNeg:0.99},
{id:'t3_palp',text:'Pain reproduced on palpation',lrPos:0.28,lrNeg:1.2,nb:'Lowers likelihood most of any sign.'}
]};
var TABLES = [ TABLE1, TABLE2, TABLE3 ];
var HEART_STRATA = [ {max:3,lr:0.20,label:'0\u20133'}, {max:4,lr:0.79,label:'4'}, {max:6,lr:2.4,label:'5\u20136'}, {max:10,lr:13,label:'7\u201310'} ];
var TIMI_STRATA = [ {max:1,lr:0.31,label:'0\u20131'}, {max:2,lr:0.94,label:'2'}, {max:4,lr:2.4,label:'3\u20134'}, {max:7,lr:6.8,label:'5\u20137'} ];
function stratumFor(strata,total){ for(var i=0;i<strata.length;i++){ if(total<=strata[i].max) return strata[i]; } return strata[strata.length-1]; }
function pToOdds(p){return p/(1-p);} function oddsToP(o){return o/(1+o);}
function applyLR(p,lr){return oddsToP(pToOdds(p)*lr);}
function pct(p){return (p*100).toFixed(1)+'%';}
function esc(s){return String(s).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];});}
function citeSpan(t){return '<span class="cpg-cite">'+esc(t)+'</span>';}
function band(p){ if(p>=0.20)return{cls:'cpg-band-high',word:'High'}; if(p>=0.05)return{cls:'cpg-band-int',word:'Intermediate'}; if(p>=0.02)return{cls:'cpg-band-lowmid',word:'Low'}; return{cls:'cpg-band-low',word:'Very low'}; }
/* ── state ── */
var S;
function fresh(){ return {
host:null, nextPage:'Pain:Chest', phase:'gate', hasTroponin:null, ecgAvail:null,
ecg:{a:false,b:false,c:false,d:false,normal:false},
scoreType:null, b:{}, sf:[],
expand:{builder:false}
}; }
function ecgAssessed(){ var e=S.ecg; return e.a||e.b||e.c||e.d||e.normal; }
function ecgPointsComplete(){ var e=S.ecg; return e.a?2:(e.d?1:0); } // HEART/HEAR ECG component
function ecgPoolLR(){ var e=S.ecg,m=0; if(e.a)m=Math.max(m,5.3); if(e.b)m=Math.max(m,1.8); if(e.a&&e.b&&e.c)m=Math.max(m,3.6); return m; }
function isAns(v){ return v!=null && v!=='unknown'; }
function isUnk(v){ return v==='unknown'; }
function rfCount(){ var n=0; RISK_FACTORS.forEach(function(r){ if(S.b['rf_'+r.id]) n++; }); return n; }
function timiRfCount(){ var n=0; RISK_FACTORS.forEach(function(r){ if(r.timi && S.b['rf_'+r.id]) n++; }); return n; }
function atheroAny(){ return ATHERO.some(function(a){ return S.b['ath_'+a.id]; }); }
function rfPoolMax(){ var m=0; RISK_FACTORS.forEach(function(r){ if(S.b['rf_'+r.id]&&r.lr>m)m=r.lr; }); ATHERO.forEach(function(a){ if(S.b['ath_'+a.id]&&a.lr>m)m=a.lr; }); return m; }
function heartRfTier(){ return (atheroAny()||rfCount()>=3)?2:(rfCount()>=1?1:0); }
/* ── score evaluation (live) ── */
function evalScore(id){
var poolMax=0; function pool(lr){ if(lr>1&&lr>poolMax)poolMax=lr; }
var total=0, complete=true, hasUnknown=false;
if (!ecgAssessed()) complete=false; else { total+=(id==='timi'?(S.ecg.a?1:0):ecgPointsComplete()); pool(ecgPoolLR()); }
if (id==='heart'||id==='hear'){
if (isUnk(S.b.history)) hasUnknown=true; if (!isAns(S.b.history)) complete=false; else { total+=S.b.history; if(S.b.history>=1) pool(1.9); }
if (isUnk(S.b.age)) hasUnknown=true; if (!isAns(S.b.age)) complete=false; else { total+=S.b.age; }
total+=heartRfTier(); pool(rfPoolMax());
if (id==='heart'){ if(isUnk(S.b.trop)) hasUnknown=true; if(!isAns(S.b.trop)) complete=false; else total+=S.b.trop; }
} else if (id==='timi'){
var items=['age65','knownCAD','asa','angina2','marker'];
items.forEach(function(k){ if(isUnk(S.b['timi_'+k])) hasUnknown=true; if(!isAns(S.b['timi_'+k])) complete=false; });
if (S.ecg.a) total+=1;
if (S.b.timi_age65===true) total+=1;
if (timiRfCount()>=3){ total+=1; pool(rfPoolMax()); }
if (S.b.timi_knownCAD===true){ total+=1; pool(2.0); }
if (S.b.timi_asa===true) total+=1;
if (S.b.timi_angina2===true){ total+=1; pool(2.0); }
if (S.b.timi_marker===true) total+=1;
}
var res={complete:complete,hasUnknown:hasUnknown,total:total,poolMax:poolMax};
var name = id==='heart'?'HEART':(id==='hear'?'HEAR':'TIMI');
// fallback used when incomplete OR unknown OR stratum LR <= 1
function fallback(){ res.lr=poolMax>1?poolMax:1.0; res.label=name+(complete?'':(hasUnknown?' (unknown item)':' (incomplete)'))+' \u2014 highest single +LR '+(poolMax>1?poolMax:'(none \u2192 no change)'); res.kind=poolMax>1?'fallback':'none'; return res; }
if (!complete) return fallback();
if (id==='hear'){
if (total<=1 && S.ecg.a===false && S.ecg.b===false && S.ecg.c===false){ res.lr=0.01; res.label='HEAR \u22641 \u2014 very low risk (rule-out)'; res.kind='ruleout'; return res; }
return fallback();
}
var strata=(id==='timi')?TIMI_STRATA:HEART_STRATA; var st=stratumFor(strata,total); res.stratumLabel=st.label;
if (st.lr>1){ res.lr=st.lr; res.label=name+' '+st.label+' \u2014 cumulative LR '+st.lr; res.kind='cumulative'; return res; }
return fallback();
}
/* ── probability (derived) ── */
/* Single-feature tables are NOT statistically independent: only the single
highest LR among all selected answers is applied (the rest are greyed). */
function sfMax(){ var m=0; S.sf.forEach(function(x){ if(x.lr>m)m=x.lr; }); return m; }
function prob(){
var p=BASELINE;
if (S.scoreType){ p=applyLR(p, evalScore(S.scoreType).lr); }
else if (ecgAssessed() && ecgPoolLR()>0){ p=applyLR(p, ecgPoolLR()); }
if (S.sf.length) p=applyLR(p, sfMax());
return p;
}
/* When are the single-feature tables shown?
- No ECG (no score possible): yes, they are all the clinician has.
- Troponin + ECG (HEART/TIMI): no, the score stands alone.
- HEAR path: only once HEAR total > 1 (i.e. not the <=1 rule-out). */
function showTables(){
if (!S.ecgAvail) return true;
if (S.hasTroponin) return false;
if (S.scoreType==='hear') return evalScore('hear').total > 1;
return false;
}
function scoreState(){ // {none|inprogress|unknown|complete}
if (!S.scoreType) return 'none';
var e=evalScore(S.scoreType);
if (e.complete) return 'complete';
if (e.hasUnknown) return 'unknown';
return 'inprogress';
}
/* ── render helpers ── */
function masthead(){
return '<header class="cpg-masthead"><div class="cpg-logo">Pain<span>Wiki</span></div>'
+'<div class="cpg-mast-right"><button class="cpg-reset" id="cpg-reset">↻ Reset</button></div></header>'
+'<div class="cpg-topcite">Developed from the JAMA Rational Clinical Examination \u2014 <em>Does This Patient With Chest Pain Have Acute Coronary Syndrome?</em> <a href="'+JAMA_URL+'" target="_blank" rel="noopener">doi.org/10.1001/jama.2015.12735</a></div>';
}
function disclaimer(){
return '<div class="cpg-foot"><div class="cpg-disclaimer"><strong>Not validated for clinical use.</strong> Pre-test probability tool; does not exclude ACS.</div>'
+'<button class="cpg-bypass" id="cpg-bypass">Bypass & jump to result</button></div>';
}
function probHeader(){
var p=prob(), bd=band(p); var sub=S.phase==='result'?'Post-test probability of ACS':'Running probability for patient with presenting complaint of chest pain';
return '<div class="cpg-prob-header '+bd.cls+'"><div class="cpg-prob-left"><div class="cpg-prob-sub">'+sub+'</div>'
+'<div class="cpg-prob-band">'+bd.word+' likelihood</div></div><div class="cpg-prob-value">'+pct(p)+'</div></div>';
}
function seg(name,opts,cur){ return '<div class="cpg-seg">'+opts.map(function(o){ return '<button class="cpg-seg-btn'+(cur===o.v?' sel':'')+'" data-set="'+name+'" data-val="'+o.v+'">'+esc(o.label)+'</button>'; }).join('')+'</div>'; }
function ynu(k,label){ var v=S.b['timi_'+k];
return '<div class="cpg-build-row"><div class="cpg-build-label">'+esc(label)+'</div><div class="cpg-yn">'
+'<button class="cpg-btn-yes'+(v===true?' sel':'')+'" data-timi="'+k+'" data-val="y">Yes</button>'
+'<button class="cpg-btn-no'+(v===false?' sel':'')+'" data-timi="'+k+'" data-val="n">No</button>'
+'<button class="cpg-btn-unk'+(v==='unknown'?' sel':'')+'" data-timi="'+k+'" data-val="u">Unknown</button></div></div>'; }
function segU(name,opts,cur){ // graded + Unknown
return seg(name,opts.concat([{v:'unknown',label:'Unknown'}]),cur); }
function chkGrid(){
var rf=RISK_FACTORS.map(function(r){ var lrTxt=(r.id==='smoke'||r.id==='fhx')?'':' <span class="cpg-chk-lr">+LR '+r.lr+'</span>';
return '<label class="cpg-chk'+(S.b['rf_'+r.id]?' on':'')+'"><input type="checkbox" data-chk="rf_'+r.id+'"'+(S.b['rf_'+r.id]?' checked':'')+'>'+esc(r.label)+lrTxt+'</label>'; }).join('');
var ath=ATHERO.map(function(a){ return '<label class="cpg-chk'+(S.b['ath_'+a.id]?' on':'')+'"><input type="checkbox" data-chk="ath_'+a.id+'"'+(S.b['ath_'+a.id]?' checked':'')+'>'+esc(a.label)+' <span class="cpg-chk-lr">+LR '+a.lr+'</span></label>'; }).join('');
return '<div class="cpg-chk-grid">'+rf+'</div><div class="cpg-chk-grid">'+ath+'</div>'; }
function ecgChecklist(){
var e=S.ecg;
function box(k,label){ return '<label class="cpg-chk'+(e[k]?' on':'')+'"><input type="checkbox" data-ecg="'+k+'"'+(e[k]?' checked':'')+'>'+esc(label)+'</label>'; }
return '<div class="cpg-build-row"><div class="cpg-build-label">ECG results</div>'
+'<div class="cpg-chk-grid">'
+ box('a','ST depression \u2265 0.5 mm \u00b1 elevation')
+ box('b','T-wave inversion')
+ box('c','Q-waves')
+ box('d','Non-specific repolarisation changes')
+ box('normal','No acute changes (normal)')
+'</div>'
+'<div class="cpg-rf-note">T-wave inversion is positive in aVR, negative in I, II, V3\u2013V6.</div></div>';
}
function builderBody(){
var id=S.scoreType, parts=[];
if (id==='heart'||id==='hear'){
parts.push('<div class="cpg-build-row"><div class="cpg-build-label">History</div>'+segU('history',[{v:0,label:'Slightly susp. (0)'},{v:1,label:'Moderately (1)'},{v:2,label:'Highly (2)'}],S.b.history)+'</div>');
parts.push('<div class="cpg-build-row"><div class="cpg-build-label">Age</div>'+segU('age',[{v:0,label:'< 45 (0)'},{v:1,label:'45\u201364 (1)'},{v:2,label:'\u2265 65 (2)'}],S.b.age)+'</div>');
parts.push('<div class="cpg-build-row"><div class="cpg-build-label">Risk factors <span class="cpg-rf-tier">tier '+heartRfTier()+'</span></div>'+chkGrid()+'</div>');
if (id==='heart') parts.push('<div class="cpg-build-row"><div class="cpg-build-label">Troponin</div>'+segU('trop',[{v:0,label:'\u2264 ULN (0)'},{v:1,label:'1\u20133\u00d7 (1)'},{v:2,label:'> 3\u00d7 (2)'}],S.b.trop)+'</div>');
} else {
parts.push(ynu('age65','Age \u2265 65'));
parts.push('<div class="cpg-build-row"><div class="cpg-build-label">\u2265 3 CAD risk factors <span class="cpg-rf-tier">'+timiRfCount()+' counted</span></div>'+chkGrid()+'</div>');
parts.push(ynu('knownCAD','Known CAD (stenosis \u2265 50%)'));
parts.push(ynu('asa','Aspirin use in past 7 days'));
parts.push(ynu('angina2','\u2265 2 anginal episodes in 24 h'));
parts.push(ynu('marker','Positive cardiac marker'));
}
return parts.join('');
}
function scoresFor(){ if(!S.ecgAvail) return []; return S.hasTroponin ? ['heart','timi'] : ['hear']; }
function scoreSection(){
var name = S.scoreType==='heart'?'HEART':(S.scoreType==='hear'?'HEAR':(S.scoreType==='timi'?'TIMI':''));
if (!S.ecgAvail){
return '<div class="cpg-card"><div class="cpg-card-label">Step 1 \u2014 risk score</div>'
+'<div class="cpg-note">No ECG available \u2014 HEART/HEAR/TIMI all require an ECG, so score-based stratification is unavailable. Use the sections below.</div></div>';
}
var html='<div class="cpg-card"><div class="cpg-card-label">Step 1 \u2014 risk score</div>'
+'<div class="cpg-q">Selecting ECG status opens the '+(S.hasTroponin?'HEART/TIMI':'HEAR')+' scoring questions.</div>'
+ ecgChecklist();
if (!ecgAssessed()){ html+='</div>'; return html; }
if (!S.scoreType){
var avail=scoresFor(), cards=[];
if (avail.indexOf('heart')>=0) cards.push('<button class="cpg-score-card" data-openscore="heart"><div class="cpg-score-name">HEART</div><div class="cpg-score-desc">History, ECG, Age, Risk factors, Troponin</div></button>');
if (avail.indexOf('timi')>=0) cards.push('<button class="cpg-score-card" data-openscore="timi"><div class="cpg-score-name">TIMI</div><div class="cpg-score-desc">7 items (UA/NSTEMI)</div></button>');
if (avail.indexOf('hear')>=0) cards.push('<button class="cpg-score-card" data-openscore="hear"><div class="cpg-score-name">HEAR</div><div class="cpg-score-desc">HEART without troponin \u2014 \u22641 rules out</div></button>');
html+='<div class="cpg-chooser-label">ECG captured \u2014 now build a score (or skip and use the sections below):</div><div class="cpg-score-grid">'+cards.join('')+'</div></div>';
return html;
}
var ev=evalScore(S.scoreType), st=scoreState();
var collapsed = (st==='complete'||st==='unknown') && !S.expand.builder;
var statusTxt = st==='complete' ? (name+' complete \u00b7 total '+ev.total)
: st==='unknown' ? (name+' \u2014 item marked unknown')
: (name+' in progress \u00b7 total '+ev.total);
html+='<div class="cpg-builder"><div class="cpg-builder-head">Build '+name
+'<span class="cpg-builder-tools"><button class="cpg-builder-collapse" data-togglebuild="1">'+(collapsed?'expand':'collapse')+'</button>'
+'<button class="cpg-builder-close" data-closescore="1">change score</button></span></div>';
if (!collapsed) html+=builderBody();
html+='<div class="cpg-build-total">'+esc(statusTxt)
+'<div class="cpg-build-apply-note">Applying: LR '+ev.lr+' \u2014 '+esc(ev.label)+'</div></div></div></div>';
return html;
}
function answerCell(tid,rid,kind,lr,dir,selected,disabled,superseded){
var cls=kind==='pos'?'cpg-row-yes':'cpg-row-no', lrcls=kind==='pos'?'cpg-lrpos':'cpg-lrneg';
var sign=kind==='pos'?'+LR ':'\u2212LR ', lab=kind==='pos'?'Yes':'No';
var bcls=cls+(selected?' sel':'')+(superseded?' superseded':'');
return '<div class="cpg-ans'+(disabled?' dim':'')+'"><span class="'+lrcls+'">'+sign+lr+'</span>'
+'<button class="'+bcls+'" data-row="'+tid+':'+rid+'" data-val="'+dir+'"'+(disabled?' disabled':'')+'>'+lab+'</button></div>';
}
function tableBlock(t){
var gMax=sfMax();
var rows=t.rows.map(function(r){
var selDir=null; S.sf.forEach(function(x){ if(x.tableId===t.id&&x.rowId===r.id) selDir=x.dir; });
var nb=r.nb?'<div class="cpg-row-nb">'+esc(r.nb)+'</div>':'';
var posSel=selDir==='pos', negSel=selDir==='neg';
var posSup=posSel&&(r.lrPos<gMax), negSup=negSel&&(r.lrNeg<gMax); // selected but a higher LR is active
var posDis=(r.lrPos<gMax)&&!posSel, negDis=(r.lrNeg<gMax)&&!negSel; // lower LR, not selectable
return '<div class="cpg-row"><div class="cpg-row-text">'+esc(r.text)+nb+'</div>'
+answerCell(t.id,r.id,'pos',r.lrPos,'pos',posSel,posDis,posSup)
+answerCell(t.id,r.id,'neg',r.lrNeg,'neg',negSel,negDis,negSup)+'</div>';
}).join('');
return '<div class="cpg-table-block"><div class="cpg-table-head"><div class="cpg-card-label">'+esc(t.title)+'</div></div>'
+'<div class="cpg-table-headrow"><span>Feature</span><span>Present</span><span>Absent</span></div>'
+'<div class="cpg-table">'+rows+'</div></div>';
}
function renderGate(){
var go = (S.hasTroponin!=null && S.ecgAvail!=null);
S.host.innerHTML = masthead()
+'<div class="cpg-card"><div class="cpg-card-label">Before you begin</div>'
+'<div class="cpg-gate-q"><div class="cpg-q">Is a cardiac troponin result available?</div>'
+'<div class="cpg-yn"><button class="cpg-btn-yes'+(S.hasTroponin===true?' sel':'')+'" data-trop="1">Yes</button>'
+'<button class="cpg-btn-no'+(S.hasTroponin===false?' sel':'')+'" data-trop="0">No</button></div></div>'
+'<div class="cpg-gate-q"><div class="cpg-q">Is an ECG available?</div>'
+'<div class="cpg-yn"><button class="cpg-btn-yes'+(S.ecgAvail===true?' sel':'')+'" data-ecgavail="1">Yes</button>'
+'<button class="cpg-btn-no'+(S.ecgAvail===false?' sel':'')+'" data-ecgavail="0">No</button></div></div>'
+'<button class="cpg-proceed" id="cpg-gate-go"'+(go?'':' disabled')+'>Continue \u2192</button></div>'
+ disclaimer();
}
function renderMain(){
var html = masthead()+probHeader()+scoreSection();
if (showTables()){
html += '<div class="cpg-card"><div class="cpg-card-label">Step 2 \u2014 single features</div>'
+'<div class="cpg-q">Combinations of parameters other than the scores above are not statistically valid \u2014 select the single best response from the tables below. To maintain independence all lower likelihood ratios are greyed out.</div>'
+ TABLES.map(tableBlock).join('') + '</div>';
}
html += '<div class="cpg-card"><button class="cpg-result-btn" data-result="1">View pre-test result →</button></div>'
+ disclaimer();
S.host.innerHTML = html;
}
function renderResult(){
S.phase='result'; var p=prob(), bd=band(p);
var href=(window.mw&&mw.util)?mw.util.getUrl(S.nextPage):('/wiki/'+S.nextPage); var msg;
if (p>=0.20) msg='HIGH post-test probability. Arrange ECG and troponin and escalate for emergency cardiac assessment now. Do not rely on the musculoskeletal algorithm to explain this pain.';
else if (p>=0.05) msg='INTERMEDIATE post-test probability. ECG and serial troponin are indicated before attributing the pain to a musculoskeletal source.';
else if (p>=0.02) msg='LOW post-test probability. Consider ECG / troponin per local pathway; this does not exclude ACS.';
else msg='VERY LOW post-test probability. ACS is less likely, but this gate does NOT exclude it \u2014 troponin, ECG, and clinical judgement govern disposition.';
S.host.innerHTML = masthead()+probHeader()
+'<div class="cpg-card cpg-result-card"><div class="cpg-card-label">Pre-test gate complete</div>'
+'<div class="cpg-result-band '+bd.cls+'">'+bd.word+' likelihood of ACS \u00b7 '+pct(p)+'</div>'
+'<div class="cpg-escalate">'+esc(msg)+'</div>'
+'<div class="cpg-bottomline"><strong>Probability estimate, not a rule-out.</strong> Troponin and ECG in clinical context govern disposition. '+citeSpan(CITE.jama)+' \u00b7 '+citeSpan(CITE.acs25)+'</div></div>'
+'<div class="cpg-handoff"><div class="cpg-handoff-note">Cardiac pre-test screening is complete.</div>'
+'<a class="cpg-proceed-link" href="'+href+'">Proceed to the chest-pain muscle algorithm →</a></div>'
+ disclaimer();
}
function render(){ if(S.phase==='gate')renderGate(); else if(S.phase==='result')renderResult(); else renderMain(); }
/* ── events ── */
function onClick(e){
var t;
if (e.target.closest('#cpg-reset')){ var h=S.host,n=S.nextPage; S=fresh(); S.host=h; S.nextPage=n; render(); return; }
t=e.target.closest('[data-trop]'); if(t){ S.hasTroponin=t.getAttribute('data-trop')==='1'; render(); return; }
t=e.target.closest('[data-ecgavail]'); if(t){ S.ecgAvail=t.getAttribute('data-ecgavail')==='1'; render(); return; }
if (e.target.closest('#cpg-gate-go')){ if(S.hasTroponin!=null && S.ecgAvail!=null){ S.phase='main'; render(); } return; }
t=e.target.closest('[data-ecg]'); if(t){ var k=t.getAttribute('data-ecg');
if(k==='normal'){ S.ecg={a:false,b:false,c:false,d:false,normal:!S.ecg.normal}; } else { S.ecg[k]=!S.ecg[k]; S.ecg.normal=false; } render(); return; }
t=e.target.closest('[data-openscore]'); if(t){ S.scoreType=t.getAttribute('data-openscore'); S.expand.builder=false; render(); return; }
if (e.target.closest('[data-closescore]')){ S.scoreType=null; S.b={}; S.expand.builder=false; render(); return; }
if (e.target.closest('[data-togglebuild]')){ S.expand.builder=!S.expand.builder; render(); return; }
t=e.target.closest('[data-set]'); if(t){ var nm=t.getAttribute('data-set'),raw=t.getAttribute('data-val'); S.b[nm]=(raw==='unknown')?'unknown':parseInt(raw,10); render(); return; }
t=e.target.closest('[data-timi]'); if(t){ var v=t.getAttribute('data-val'); S.b['timi_'+t.getAttribute('data-timi')]=(v==='y')?true:(v==='n')?false:'unknown'; render(); return; }
t=e.target.closest('[data-chk]'); if(t){ var ck=t.getAttribute('data-chk'); S.b[ck]=!S.b[ck]; render(); return; }
t=e.target.closest('[data-row]'); if(t && !t.disabled){ var parts=t.getAttribute('data-row').split(':'),tid=parts[0],rid=parts[1],dir=t.getAttribute('data-val');
var cur=null,idx=-1; S.sf.forEach(function(x,i){ if(x.tableId===tid&&x.rowId===rid){cur=x.dir;idx=i;} });
if (cur===dir){ S.sf.splice(idx,1); } // toggle off
else {
if (idx>=0) S.sf.splice(idx,1); // replace other direction on this row
var tbl=TABLES.filter(function(x){return x.id===tid;})[0],row=tbl.rows.filter(function(x){return x.id===rid;})[0];
S.sf.push({tableId:tid,rowId:rid,dir:dir,lr:dir==='pos'?row.lrPos:row.lrNeg});
}
render(); return; }
if (e.target.closest('[data-result]')){ renderResult(); return; }
if (e.target.closest('#cpg-bypass')){ renderResult(); return; }
}
function boot(hostEl){ S=fresh(); S.host=hostEl; var np=hostEl.getAttribute('data-next-page'); if(np)S.nextPage=np; hostEl.addEventListener('click',onClick); render(); }
function init(){ document.querySelectorAll('.chest-pretest-gate').forEach(function(el){ if(!el.dataset.cpgBooted){ el.dataset.cpgBooted='1'; boot(el); } }); }
return { init: init };
}() );
/* ══════════════════════════════════════════════════════
MEDIAWIKI ENTRY POINT
══════════════════════════════════════════════════════ */
function init() {
document.querySelectorAll( '.scoring-tree-host' ).forEach( function ( el ) {
// Only boot if not already initialised
if ( !el.dataset.scoringBooted ) {
el.dataset.scoringBooted = '1';
bootHost( el );
}
} );
// PATCH: also boot the chest pre-test gate where present (Pretest:Chest)
ChestPretestGate.init();
}
if ( typeof mw !== 'undefined' ) {
// Register with the hook for future page loads (e.g. after Ajax navigation)
mw.hook( 'wikipage.content' ).add( init );
// Also run immediately in case wikipage.content already fired.
// Using $.ready ensures the DOM is available.
mw.loader.using( [ 'mediawiki.api', 'mediawiki.util' ] ).done( function () {
if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', init );
} else {
init();
}
} );
}
}() );