MediaWiki:Gadget-DiagnosticTreeScoring.js: Difference between revisions

From Painwiki
Jump to navigation Jump to search
No edit summary
No edit summary
Line 491: Line 491:


     var CITE = {
     var CITE = {
       jama: 'Fanaroff et al. JAMA 2015;314(18):1955-1965 (Rational Clinical Examination)',
       jama: 'Fanaroff et al. JAMA 2015;314(18):1955-1965 (Rational Clinical Examination)',
       t1:   'JAMA 2015 \u2014 Table 1 (cardiac risk factors)',
      heart:'Six-Backus / HEART score (Backus 2008; HEART Pathway). Strata LRs per JAMA 2015 Table 5',
       t2:   'JAMA 2015 \u2014 Table 2 (chest-pain characteristics)',
      timi: 'Antman TIMI risk score UA/NSTEMI (JAMA 2000). Strata LRs per JAMA 2015 Table 5',
       t3:   'JAMA 2015 \u2014 Table 3 (physical examination)',
      hear: 'HEAR score (HEART minus troponin). Smith 2020 EMJ; O\u2019Reilly 2021 CJEM; JACC Adv 2025 \u2014 \u22641 rule-out',
       t4:   'JAMA 2015 \u2014 Table 4 (ECG)',
       t1:'JAMA 2015 \u2014 Table 1 (cardiac risk factors)',
      t5:    'JAMA 2015 \u2014 Table 5 (clinical decision tools)',
       t2:'JAMA 2015 \u2014 Table 2 (chest-pain characteristics)',
       acs25: 'NHFA/CSANZ Australian ACS Guideline 2025 (Brieger, Cullen, Briffa et al.)',
       t3:'JAMA 2015 \u2014 Table 3 (physical examination)',
      fram:  'Framingham general cardiovascular risk (D\u2019Agostino et al. Circulation 2008)'
       t4:'JAMA 2015 \u2014 Table 4 (ECG)',
       acs25:'NHFA/CSANZ Australian ACS Guideline 2025 (Brieger, Cullen, Briffa et al.)'
     };
     };


     /* Scores (Table 5 strata LRs applied to the 13% baseline). One only. */
     /* ── JAMA single-feature tables (LR+/LR-). Table 1 order MI->CABG->CAD. ── */
    var SCORES = {
     var TABLE1 = { id:'t1', table:1, title:'Cardiac risk factors', cite:CITE.t1, rows:[
      heart: { id:'heart', label:'HEART score', requires_troponin:true, cite:CITE.t5,
       { id:'t1_stress', text:'Abnormal prior stress test',               lrPos:3.1,  lrNeg:0.92 },
        note:'History, ECG, Age, Risk factors, Troponin. Offered only when a troponin result is available (the T of HEART). Each element 0\u20132; total 0\u201310.',
       { id:'t1_pad',    text:'Peripheral arterial disease',               lrPos:2.7,  lrNeg:0.96 },
        strata:[ {id:'h_low',label:'HEART 0\u20133 \u2014 low',lr:0.20},
       { id:'t1_mi',    text:'Prior myocardial infarction',               lrPos:1.6,  lrNeg:0.88 },
                {id:'h_ind',label:'HEART 4 \u2014 indeterminate',lr:0.79},
       { id:'t1_cabg',  text:'Prior CABG',                               lrPos:1.4,  lrNeg:0.90 },
                {id:'h_int',label:'HEART 5\u20136 \u2014 intermediate',lr:2.4},
       { id:'t1_cad',    text:'Prior CAD',                                 lrPos:2.0,  lrNeg:0.75 },
                {id:'h_high',label:'HEART 7\u201310 \u2014 high',lr:13.0} ] },
       { id:'t1_dm',    text:'Diabetes mellitus',                         lrPos:1.4,  lrNeg:0.97 },
      timi: { id:'timi', label:'TIMI risk score', requires_troponin:false, cite:CITE.t5,
       { id:'t1_cvd',    text:'Cerebrovascular disease',                   lrPos:1.3,  lrNeg:0.70 },
        note:'Thrombolysis In Myocardial Infarction. 7 variables, each present/absent; total 0\u20137.',
       { id:'t1_male',  text:'Male sex',                                 lrPos:1.3,  lrNeg:0.85 },
        strata:[ {id:'t_low',label:'TIMI 0\u20131 \u2014 low',lr:0.31},
       { id:'t1_lipid',  text:'Hyperlipidaemia',                         lrPos:1.2,  lrNeg:0.78 },
                {id:'t_ind',label:'TIMI 2 \u2014 indeterminate',lr:0.94},
       { id:'t1_htn',    text:'Hypertension',                             lrPos:1.1,  lrNeg:0.96 },
                {id:'t_int',label:'TIMI 3\u20134 \u2014 intermediate',lr:2.4},
       { id:'t1_tob',    text:'Any tobacco use',                           lrPos:1.0,  lrNeg:0.99 },
                {id:'t_high',label:'TIMI 5\u20137 \u2014 high',lr:6.8} ] },
       { id:'t1_fhx',    text:'Family history of CAD',                     lrPos:1.0,  lrNeg:0.99 },
      hfacsanz: { id:'hfacsanz', label:'HFA/CSANZ rule', requires_troponin:false, cite:CITE.t5,
       { id:'t1_obes',  text:'Obesity',                                   lrPos:0.97, lrNeg:1.0  }
        note:'Heart Foundation of Australia / Cardiac Society of Australia & New Zealand risk algorithm. A low-to-intermediate result may fall through to the Framingham pathway.',
        strata:[ {id:'c_low',label:'Low-to-intermediate risk',lr:0.24,framingham_fallback:true},
                {id:'c_high',label:'High risk',lr:2.8} ] },
      ahcpr: { id:'ahcpr', label:'AHCPR rule', requires_troponin:false, requires_angiography:true, cite:CITE.t5,
        note:'Agency for Health Care Policy & Research rule. Offered only when coronary angiography is on record. Pre-troponin-era single-study data \u2014 weakest tool.',
        strata:[ {id:'a_low',label:'Low risk',lr:0.36},
                {id:'a_high',label:'High risk',lr:1.8} ] }
    };
 
    var FRAMINGHAM = {
      cite:CITE.fram,
      note:'Entered only when HFA/CSANZ is low-to-intermediate and no superior score is on record. Use only if total/HDL cholesterol are from within the last 10 years and a resting BP is on record. The 10-year cardiovascular risk category seeds the pre-test probability.',
      recency:'Lipids within 10 years; resting BP on record.',
      strata:[ {id:'f_low',label:'Low 10-yr CVD risk (<10%)',lr:0.24},
              {id:'f_int',label:'Intermediate 10-yr CVD risk (10\u201320%)',lr:1.0},
              {id:'f_high',label:'High 10-yr CVD risk (>20%)',lr:2.0} ] };
 
    /* Tables 1-4 — single-feature LR+/LR- (user CSV, JAMA 2015).            */
    /* Only ONE feature applied per table (findings not independent).         */
    /* Table 1 display order keeps Prior MI -> Prior CABG -> Prior CAD.       */
     var TABLE1 = { table:1, title:'Cardiac risk factors', 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 (coronary artery bypass graft)',     lrPos:1.4,  lrNeg:0.90 },
       { id:'t1_cad',    text:'Prior CAD (coronary artery disease)',           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:'Chest-pain characteristics', cite:CITE.t2, rows:[
     var TABLE2 = { table:2, title:'Chest-pain characteristics', cite:CITE.t2, rows:[
       { id:'t2_botharms', text:'Pain radiates to BOTH arms',             lrPos:2.6,  lrNeg:0.93 },
       { id:'t2_botharms', text:'Pain radiates to BOTH arms',                 lrPos:2.6,  lrNeg:0.93 },
       { id:'t2_prior',    text:'Similar to prior ischaemia',            lrPos:2.2,  lrNeg:0.67 },
       { id:'t2_prior',    text:'Pain similar to prior ischaemia',            lrPos:2.2,  lrNeg:0.67 },
       { id:'t2_change',  text:'Change in pattern over prior 24 h',      lrPos:2.0,  lrNeg:0.84 },
       { id:'t2_change',  text:'Change in pain 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_typical',  text:'\u201CTypical\u201D (pressure-like) chest pain', lrPos:1.9, lrNeg:0.52 },
       { id:'t2_exertion', text:'Worse with exertion',                   lrPos:1.65, lrNeg:0.83 },
       { id:'t2_exertion', text:'Worse with exertion',                         lrPos:1.65, lrNeg:0.83 },
       { id:'t2_neckjaw',  text:'Radiates to neck or jaw',               lrPos:1.5,  lrNeg:0.91 },
       { id:'t2_neckjaw',  text:'Radiates to neck or jaw',                     lrPos:1.5,  lrNeg:0.91 },
       { id:'t2_diaph',    text:'Associated diaphoresis',                 lrPos:1.4,  lrNeg:0.93 },
       { id:'t2_diaph',    text:'Associated diaphoresis',                     lrPos:1.4,  lrNeg:0.93 },
       { id:'t2_recent',  text:'Recent episode of similar pain',         lrPos:1.3,  lrNeg:0.80 },
       { id:'t2_recent',  text:'Recent episode of similar pain',             lrPos:1.3,  lrNeg:0.80 },
       { id:'t2_leftarm',  text:'Radiates to left arm',                   lrPos:1.3,  lrNeg:0.88 },
       { 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_rightarm', text:'Radiates to right arm',                       lrPos:1.3,  lrNeg:0.99 },
       { id:'t2_palp',    text:'Associated palpitations',               lrPos:1.3,  lrNeg:1.0  },
       { id:'t2_palp',    text:'Associated palpitations',                     lrPos:1.3,  lrNeg:1.0  },
       { id:'t2_dyspnea',  text:'Associated dyspnoea',                   lrPos:1.2,  lrNeg:0.89 },
       { id:'t2_dyspnea',  text:'Associated dyspnoea',                         lrPos:1.2,  lrNeg:0.89 },
       { id:'t2_burning',  text:'Burning pain',                           lrPos:1.2,  lrNeg:1.0  },
       { id:'t2_burning',  text:'Burning pain',                               lrPos:1.2,  lrNeg:1.0  },
       { id:'t2_abrupt',  text:'Abrupt onset',                           lrPos:1.1,  lrNeg:0.75 },
       { id:'t2_abrupt',  text:'Abrupt onset',                               lrPos:1.1,  lrNeg:0.75 },
       { id:'t2_ntg',      text:'Any improvement with nitroglycerin',     lrPos:1.1,  lrNeg:0.90,
       { id:'t2_ntg',      text:'Any improvement with nitroglycerin',         lrPos:1.1,  lrNeg:0.90,
         nb:'Response to nitroglycerin is unhelpful for diagnosing ACS.' },
         nb:'Response to nitroglycerin is unhelpful for diagnosing ACS (LR ~1).' },
       { id:'t2_nausea',  text:'Associated nausea / vomiting',           lrPos:1.1,  lrNeg:1.0  },
       { id:'t2_nausea',  text:'Associated nausea / vomiting',               lrPos:1.1,  lrNeg:1.0  },
       { id:'t2_typrad',  text:'\u201CTypical\u201D radiation',         lrPos:1.0,  lrNeg:0.98 },
       { id:'t2_typrad',  text:'\u201CTypical\u201D radiation',               lrPos:1.0,  lrNeg:0.98 },
       { id:'t2_syncope',  text:'Associated syncope',                     lrPos:0.76, lrNeg:1.1, nb:'Lowers likelihood.' },
       { id:'t2_syncope',  text:'Associated syncope',                         lrPos:0.76, lrNeg:1.1,
       { id:'t2_pleur',    text:'Pleuritic pain',                         lrPos:0.61, lrNeg:1.2, nb:'Lowers likelihood.' }
        nb:'Lowers the likelihood (LR+ <1).' },
       { id:'t2_pleur',    text:'Pleuritic pain',                             lrPos:0.61, lrNeg:1.2,
        nb:'Pleuritic quality lowers the likelihood (LR+ <1).' }
     ] };
     ] };
 
     var TABLE3 = { id:'t3', table:3, title:'Physical examination', cite:CITE.t3, rows:[
     var TABLE3 = { table:3, title:'Physical examination', cite:CITE.t3, rows:[
       { id:'t3_hypo',  text:'Hypotension (SBP < 100)',                  lrPos:3.9,  lrNeg:0.98 },
       { id:'t3_hypo',  text:'Hypotension (SBP < 100 mmHg)',                  lrPos:3.9,  lrNeg:0.98 },
       { id:'t3_rales', text:'Lung rales / crackles',                     lrPos:2.0,  lrNeg:0.95 },
       { 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_tachyp',text:'Tachypnoea',                                     lrPos:1.9,  lrNeg:0.95 },
       { id:'t3_tachyc',text:'Tachycardia (HR > 120)',                   lrPos:1.3,  lrNeg:0.99 },
       { 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.' }
       { id:'t3_palp',  text:'Pain reproduced on palpation',                 lrPos:0.28, lrNeg:1.2,
        nb:'Reproducible-on-palpation pain lowers the likelihood most of any sign (LR+ 0.28).' }
     ] };
     ] };
 
     var TABLE4 = { id:'t4', table:4, title:'Electrocardiogram (ECG)', cite:CITE.t4,
     var TABLE4 = { table:4, title:'Electrocardiogram (ECG)', cite:CITE.t4,
       note:'Ischemic ECG = any T-wave inversion, Q-waves, or ST depression.', rows:[
       note:'Ischemic ECG = ANY T-wave inversion, Q-waves, or ST depression.',
       { id:'t4_stdep', text:'ST-segment depression',                                         lrPos:5.3, lrNeg:0.79 },
      rows:[
       { id:'t4_isch',  text:'Ischemic ECG (any T-wave inversion, Q-waves, or ST depression)', lrPos:3.6, lrNeg:0.74 },
       { id:'t4_stdep', text:'ST-segment depression',                                                 lrPos:5.3, lrNeg:0.79 },
       { id:'t4_twave', text:'T-wave inversion',                                               lrPos:1.8, lrNeg:0.89,
       { id:'t4_isch',  text:'Ischemic ECG (any T-wave inversion, Q-waves, or ST depression)',         lrPos:3.6, lrNeg:0.74 },
       { id:'t4_twave', text:'T-wave inversion',                                                       lrPos:1.8, lrNeg:0.89,
         nb:'T-wave positive in aVR, negative in I, II, V3\u2013V6.' }
         nb:'T-wave positive in aVR, negative in I, II, V3\u2013V6.' }
     ] };
     ] };
    var TABLES = [ TABLE1, TABLE2, TABLE3, TABLE4 ];


     var TABLES = [ TABLE1, TABLE2, TABLE3, TABLE4 ];
    /* Risk factors (HEART/HEAR/TIMI). Each carries a representative JAMA +LR    */
    /* for the highest-single-+LR fallback. timi=true counts toward TIMI \u22653.  */
    var RISK_FACTORS = [
      { id:'rf_htn',  label:'Hypertension',                          lr:1.1, timi:true },
      { id:'rf_lipid', label:'Hypercholesterolaemia',                lr:1.2, timi:true },
      { id:'rf_dm',    label:'Diabetes mellitus',                    lr:1.4, timi:true },
      { id:'rf_obes',  label:'Obesity (BMI > 30)',                    lr:0.97,timi:false },
      { id:'rf_smoke', label:'Smoking (current or \u22643 mo)',        lr:1.0, timi:true },
      { id:'rf_fhx',  label:'Family history CVD < 65',              lr:1.0, timi:true }
    ];
     var ATHERO = [
      { id:'ath_mi', label:'Prior MI',               lr:1.6 },
      { id:'ath_pci', label:'Prior PCI / CABG',      lr:1.4 },
      { id:'ath_cva', label:'Prior CVA / TIA',        lr:1.3 },
      { id:'ath_pad', label:'Peripheral arterial disease', lr:2.7 }
    ];
 
    /* Strata: cumulative LR applied only when lr > 1 (see rule). */
    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 pToOdds(p){return p/(1-p);}
Line 601: Line 585:
     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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];});}
     function esc(s){return String(s).replace(/[&<>"']/g,function(c){
      return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];});}
     function citeSpan(t){return '<span class="cpg-cite">'+esc(t)+'</span>';}
     function citeSpan(t){return '<span class="cpg-cite">'+esc(t)+'</span>';}


     /* Risk bands: <2% very low | 2-5% low | 5-20% intermediate | >20% high */
     /* Risk bands: <2% very low | 2-5% low | 5-20% intermediate | >20% high */
     function band(p){
     function band(p){
       if (p >= 0.20) return {cls:'cpg-band-high',   word:'High'};
       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.05) return {cls:'cpg-band-int',word:'Intermediate'};
       if (p >= 0.02) return {cls:'cpg-band-lowmid', word:'Low'};
       if (p>=0.02) return {cls:'cpg-band-lowmid',word:'Low'};
       return {cls:'cpg-band-low', word:'Very low'};
       return {cls:'cpg-band-low',word:'Very low'};
     }
     }


     var S; // state (memory only)
     /* ── state (memory only) ────────────────────────────────────────── */
    var S;
     function fresh(){ return {
     function fresh(){ return {
       phase:'gate', hasTroponin:null, hasAngiography:null,
       host:null, nextPage:'Pain:Chest', phase:'gate',
       scoreId:null, framingham:false, prob:BASELINE, steps:[],
      hasTroponin:null,
       tableIdx:0, host:null, nextPage:'Pain:Chest'
      openBuilder:null,                 // 'heart' | 'hear' | 'timi' | null
       builder:{},                       // component selections for the open builder
      evidence:[],                     // [{kind,label,lr,scoreId?,tableId?}]
       consumed:{}                      // tableId -> true (Option 2)
     }; }
     }; }


     function masthead(){return [
     function prob(){ var p=BASELINE; S.evidence.forEach(function(e){ p=applyLR(p,e.lr); }); return p; }
       '<header class="cpg-masthead">',
    function scoreApplied(){ return S.evidence.some(function(e){ return e.kind==='score'; }); }
       '  <div class="cpg-logo">Pain<span>Wiki</span></div>',
 
       ' <div class="cpg-region">Cardiac Pre-Test &middot; Chest Pain \u2014 ACS probability</div>',
    /* ══ RULE ENGINE ══ Build a score from builder selections, return result. */
       '</header>' ].join('');}
    function riskTier(b){
      var rfCount=0, athero=false, poolMax=0;
      RISK_FACTORS.forEach(function(rf){ if(b['rf_'+rf.id]){ rfCount++; if(rf.lr>poolMax)poolMax=rf.lr; } });
       ATHERO.forEach(function(a){ if(b['ath_'+a.id]){ athero=true; if(a.lr>poolMax)poolMax=a.lr; } });
      var tier = (athero || rfCount>=3) ? 2 : (rfCount>=1 ? 1 : 0);
      return { tier:tier, rfCount:rfCount, athero:athero, poolMax:poolMax };
    }
    function timiRfCount(b){ var n=0; RISK_FACTORS.forEach(function(rf){ if(rf.timi && b['rf_'+rf.id]) n++; }); return n; }
 
    // returns {complete, total, label, applyLR, applyLabel}
    function evalScore(id,b){
       var poolMax=0; // highest single +LR among positive components (>1 only)
      function pool(lr){ if(lr>1 && lr>poolMax) poolMax=lr; }
      var total=0, complete=true, rt;
 
       if (id==='heart' || id==='hear'){
        // history 0/1/2
        if (b.history==null) complete=false; else { total+=b.history; if(b.history>=1) pool(1.9); }
        // ecg 0/1/2
        if (b.ecg==null) complete=false; else { total+=b.ecg; if(b.ecg===2) pool(5.3); else if(b.ecg===1) pool(1.8); }
        // age 0/1/2 (no JAMA +LR)
        if (b.age==null) complete=false; else { total+=b.age; }
        // risk factors (checklist always answerable; treat as answered)
        rt=riskTier(b); total+=rt.tier; pool(rt.poolMax);
        if (id==='heart'){ if (b.trop==null) complete=false; else { total+=b.trop; } } // troponin no +LR
       } else if (id==='timi'){
        var items=['age65','knownCAD','asa','angina2','stdev','marker'];
        items.forEach(function(k){ if(b['timi_'+k]==null) complete=false; });
        var n=0;
        if (b.timi_age65) n++;                         // no +LR
        if (b.timi_knownCAD){ n++; pool(2.0); }        // prior CAD 2.0
        if (b.timi_asa) n++;                            // no +LR
        if (b.timi_angina2){ n++; pool(2.0); }          // change-24h 2.0 (loose)
        if (b.timi_stdev){ n++; pool(5.3); }            // ST dep 5.3
        if (b.timi_marker) n++;                        // no +LR
        // >=3 CAD risk factors
        var trf=timiRfCount(b); var rfPos = trf>=3;
        if (rfPos){ n++; rt=riskTier(b); pool(rt.poolMax); }
        total=n;
        // risk-factor checklist is optional input; score still 'complete' from the 6 toggles
      }


     function disclaimer(){return [
      var strata = (id==='timi') ? TIMI_STRATA : (id==='hear' ? null : HEART_STRATA);
       '<div class="cpg-foot">',
      var res = { complete:complete, total:total, poolMax:poolMax };
       ' <div class="cpg-disclaimer"><strong>Not validated for clinical use.</strong> Research prototype. Pre-test probability tool after '+citeSpan(CITE.jama)+'. Does not exclude ACS.</div>',
 
       ' <button class="cpg-bypass" id="cpg-bypass">Bypass alert &amp; jump to algorithm</button>',
      // HEAR: <=1 rule-out (non-ischemic ECG + no known CAD), else highest single +LR
      '</div>' ].join('');}
      if (id==='hear'){
        var noKnownCAD = !ATHERO.some(function(a){return b['ath_'+a.id];});
        if (complete && total<=1 && b.ecg===0 && noKnownCAD){
          res.lr=0.01; res.label='HEAR \u22641 \u2014 very low risk (rule-out)'; res.kind='cumulative'; return res;
        }
        res.lr = poolMax>1 ? poolMax : 1.0;
        res.label = 'HEAR \u22652 \u2014 highest single +LR '+ (poolMax>1?poolMax:'(none \u2192 no change)');
        res.kind = poolMax>1 ? 'fallback' : 'none'; return res;
      }
 
      var st = stratumFor(strata,total);
      res.stratumLabel = st.label;
      if (st.lr>1){
        res.lr=st.lr; res.label=(id==='heart'?'HEART ':'TIMI ')+st.label+' \u2014 cumulative LR '+st.lr; res.kind='cumulative';
      } else {
        res.lr = poolMax>1 ? poolMax : 1.0;
        res.label=(id==='heart'?'HEART ':'TIMI ')+st.label+' \u2014 highest single +LR '+(poolMax>1?poolMax:'(none \u2192 no change)');
        res.kind = poolMax>1 ? 'fallback' : 'none';
      }
      return res;
    }
 
    /* ══ RENDER ══ everything rebuilt from state each interaction ══ */
     function masthead(){ return ''
      +'<header class="cpg-masthead"><div class="cpg-logo">Pain<span>Wiki</span></div>'
       +'<div class="cpg-region">Cardiac Pre-Test &middot; Chest Pain \u2014 ACS probability</div></header>'; }
 
    function disclaimer(){ return ''
       +'<div class="cpg-foot"><div class="cpg-disclaimer"><strong>Not validated for clinical use.</strong> '
      +'Pre-test probability tool after '+citeSpan(CITE.jama)+'. Does not exclude ACS.</div>'
       +'<button class="cpg-bypass" id="cpg-bypass">Bypass &amp; jump to result</button></div>'; }


     function probHeader(){
     function probHeader(){
       var b=band(S.prob), sub;
       var p=prob(), bd=band(p);
       if (S.phase==='score')      sub='Baseline pre-test probability';
       var sub = S.phase==='result' ? 'Post-test probability of ACS' : 'Running probability';
      else if (S.phase==='tables') sub='Running probability \u2014 JAMA Table '+(S.tableIdx+1)+' of 4';
       return '<div class="cpg-prob-header '+bd.cls+'"><div class="cpg-prob-left">'
      else                        sub='Post-test probability of ACS';
         +'<div class="cpg-prob-sub">'+sub+'</div><div class="cpg-prob-band">'+bd.word+' likelihood</div></div>'
       return [
         +'<div class="cpg-prob-value">'+pct(p)+'</div></div>';
        '<div class="cpg-prob-header '+b.cls+'">',
        '  <div class="cpg-prob-left">',
         '   <div class="cpg-prob-sub">'+sub+'</div>',
        '    <div class="cpg-prob-band">'+b.word+' likelihood</div>',
        '  </div>',
         ' <div class="cpg-prob-value">'+pct(S.prob)+'</div>',
        '</div>' ].join('');
     }
     }


     function trailPanel(){
     function trailPanel(){
       if (!S.steps.length) return '';
       if (!S.evidence.length) return '';
       var rows = S.steps.map(function(s){
       var rows=S.evidence.map(function(e,i){
         return '<div class="cpg-trail-item"><div class="cpg-trail-q">'+esc(s.label)+'</div>'+
         return '<div class="cpg-trail-item"><div class="cpg-trail-q">'+esc(e.label)+'</div>'
           '<div class="cpg-trail-a">&#8627; LR '+s.lr+' \u00b7 '+pct(s.before)+' \u2192 '+pct(s.after)+'</div></div>';
           +'<div class="cpg-trail-a">&#8627; LR '+e.lr+'</div>'
          +'<button class="cpg-undo" data-undo="'+i+'">undo</button></div>';
       }).join('');
       }).join('');
       return '<div class="cpg-trail"><div class="cpg-trail-head">Calculation record</div>'+rows+'</div>';
       return '<div class="cpg-trail"><div class="cpg-trail-head">Applied evidence</div>'+rows+'</div>';
     }
     }


     function renderGate(){
     function renderGate(){
       S.host.innerHTML = [
       S.host.innerHTML = masthead()
        masthead(),
         +'<div class="cpg-card"><div class="cpg-card-label">Before you begin</div>'
         '<div class="cpg-card">',
         +'<div class="cpg-q">Is a cardiac troponin result available?</div>'
        '  <div class="cpg-card-label">Before selecting a score</div>',
         +'<div class="cpg-precond-note">Troponin enables the HEART score (the T of HEART). Without it, use HEAR (no troponin) or TIMI.</div>'
         ' <div class="cpg-q">Which data are on record for this patient?</div>',
         +'<div class="cpg-yn" style="margin-top:.8rem">'
        '  <div class="cpg-precond">',
         +'<button class="cpg-btn-yes" data-trop="1">Yes</button>'
        '    <div class="cpg-precond-row">',
        +'<button class="cpg-btn-no" data-trop="0">No</button></div></div>'
         '     <div class="cpg-precond-label">Cardiac troponin result available?<div class="cpg-precond-note">Enables the HEART score (the T of HEART).</div></div>',
         + disclaimer();
        '      <div class="cpg-yn"><button class="cpg-btn-yes" data-pre="trop" data-val="1">Yes</button><button class="cpg-btn-no" data-pre="trop" data-val="0">No</button></div>',
         '   </div>',
        '    <div class="cpg-precond-row">',
        '      <div class="cpg-precond-label">Coronary angiography on record?<div class="cpg-precond-note">Required before the AHCPR rule is offered.</div></div>',
         '     <div class="cpg-yn"><button class="cpg-btn-yes" data-pre="angio" data-val="1">Yes</button><button class="cpg-btn-no" data-pre="angio" data-val="0">No</button></div>',
        '    </div>',
        '  </div>',
        '  <div class="cpg-precond-status" id="cpg-precond-status"></div>',
        '  <button class="cpg-proceed" id="cpg-to-score" disabled>Select a risk score &rarr;</button>',
        '</div>',
         disclaimer()
      ].join('\n');
      updatePrecond();
     }
     }


     function updatePrecond(){
     function seg(name,opts,cur){ // segmented control: opts=[{v,label}]
       var el=S.host.querySelector('#cpg-precond-status'); if(!el) return;
       return '<div class="cpg-seg">'+opts.map(function(o){
       var bits=[];
        return '<button class="cpg-seg-btn'+(cur===o.v?' sel':'')+'" data-set="'+name+'" data-val="'+o.v+'">'+esc(o.label)+'</button>';
      if (S.hasTroponin!==null) bits.push('Troponin: '+(S.hasTroponin?'available':'not available'));
      }).join('')+'</div>';
       if (S.hasAngiography!==null) bits.push('Angiography: '+(S.hasAngiography?'on record':'not on record'));
    }
       el.textContent = bits.join(' \u00b7  ');
    function rfChecklist(b){
       var btn=S.host.querySelector('#cpg-to-score');
       var rfs=RISK_FACTORS.map(function(rf){
      if (btn) btn.disabled = !(S.hasTroponin!==null && S.hasAngiography!==null);
        return '<label class="cpg-chk'+(b['rf_'+rf.id]?' on':'')+'"><input type="checkbox" data-chk="rf_'+rf.id+'"'+(b['rf_'+rf.id]?' checked':'')+'>'+esc(rf.label)+' <span class="cpg-chk-lr">+LR '+rf.lr+'</span></label>';
       }).join('');
      var ath=ATHERO.map(function(a){
        return '<label class="cpg-chk'+(b['ath_'+a.id]?' on':'')+'"><input type="checkbox" data-chk="ath_'+a.id+'"'+(b['ath_'+a.id]?' checked':'')+'>'+esc(a.label)+' <span class="cpg-chk-lr">+LR '+a.lr+'</span></label>';
       }).join('');
       var rt=riskTier(b);
      return '<div class="cpg-rf-group"><div class="cpg-rf-head">Risk factors '
        +'<span class="cpg-rf-tier">tier '+rt.tier+' &middot; '+rt.rfCount+' factor'+(rt.rfCount===1?'':'s')+(rt.athero?' + atherosclerotic disease':'')+'</span></div>'
        +'<div class="cpg-chk-grid">'+rfs+'</div>'
        +'<div class="cpg-rf-sub">Established atherosclerotic disease (\u2192 tier 2)</div>'
        +'<div class="cpg-chk-grid">'+ath+'</div></div>';
     }
     }


     function renderScores(){
     function builderHEART(isHeart){
       S.phase='score'; S.prob=BASELINE;
       var b=S.builder, id=isHeart?'heart':'hear';
       var avail=[SCORES.heart,SCORES.timi,SCORES.hfacsanz,SCORES.ahcpr].filter(function(sc){
      var parts=[];
        if (sc.requires_troponin && !S.hasTroponin) return false;
       parts.push('<div class="cpg-build-row"><div class="cpg-build-label">History</div>'
         if (sc.requires_angiography && !S.hasAngiography) return false;
        + seg('history',[{v:0,label:'Slightly susp. (0)'},{v:1,label:'Moderately (1)'},{v:2,label:'Highly (2)'}], b.history)+'</div>');
         return true;
      parts.push('<div class="cpg-build-row"><div class="cpg-build-label">ECG</div>'
       });
        + seg('ecg',[{v:0,label:'Normal (0)'},{v:1,label:'Non-specific (1)'},{v:2,label:'Significant ST dev. (2)'}], b.ecg)+'</div>');
       var boxes=avail.map(function(sc){
      parts.push('<div class="cpg-build-row"><div class="cpg-build-label">Age</div>'
        var strata=sc.strata.map(function(st){
         + seg('age',[{v:0,label:'< 45 (0)'},{v:1,label:'45\u201364 (1)'},{v:2,label:'\u2265 65 (2)'}], b.age)+'</div>');
          return '<button class="cpg-stratum" data-score="'+sc.id+'" data-stratum="'+st.id+'">'+
      parts.push('<div class="cpg-build-row">'+rfChecklist(b)+'</div>');
            '<span class="cpg-stratum-label">'+esc(st.label)+'</span><span class="cpg-stratum-lr">LR '+st.lr+'</span></button>';
      if (isHeart) parts.push('<div class="cpg-build-row"><div class="cpg-build-label">Troponin</div>'
        }).join('');
         + seg('trop',[{v:0,label:'\u2264 ULN (0)'},{v:1,label:'1\u20133\u00d7 (1)'},{v:2,label:'> 3\u00d7 (2)'}], b.trop)+'</div>');
        return '<div class="cpg-score-box"><div class="cpg-score-head">'+esc(sc.label)+'</div>'+
       var ev=evalScore(id,b);
          '<div class="cpg-score-note">'+esc(sc.note)+'</div><div class="cpg-strata">'+strata+'</div>'+citeSpan(sc.cite)+'</div>';
       var totalTxt = (isHeart? 'HEART':'HEAR')+' total: '+ev.total+(ev.complete?'':' (incomplete)');
       }).join('');
      var willApply = 'Will apply: LR '+ev.lr+' \u2014 '+ev.label;
       S.host.innerHTML=[
      return '<div class="cpg-builder"><div class="cpg-builder-head">'+(isHeart?'Build HEART score':'Build HEAR score (no troponin)')
        masthead(), probHeader(),
        +'<button class="cpg-builder-close" data-closebuild="1">close</button></div>'
         '<div class="cpg-card">',
        + parts.join('')
         ' <div class="cpg-card-label">Step 1 \u2014 select ONE validated score</div>',
        +'<div class="cpg-build-total">'+esc(totalTxt)+'<div class="cpg-build-apply-note">'+esc(willApply)+'</div></div>'
         ' <div class="cpg-q">Choose the single score you will use, then click its risk stratum.</div>',
        +'<button class="cpg-apply" data-applyscore="'+id+'">Apply '+(isHeart?'HEART':'HEAR')+' &rarr;</button>'
         ' <div class="cpg-rationale">Each score already integrates a <em>group</em> of features into one stratum likelihood ratio, applied to the 13% baseline. Only one score is used. '+citeSpan(CITE.t5)+'</div>',
        +'<div class="cpg-cite">'+esc(isHeart?CITE.heart:CITE.hear)+'</div></div>';
        ' <div class="cpg-score-grid">'+boxes+'</div>',
    }
        '</div>',
 
        disclaimer()
    function builderTIMI(){
       ].join('\n');
      var b=S.builder;
      function yn(k,label){ var v=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="1">Yes</button>'
        +'<button class="cpg-btn-no'+(v===false?' sel':'')+'" data-timi="'+k+'" data-val="0">No</button></div></div>'; }
      var parts=[];
      parts.push(yn('age65','Age \u2265 65'));
      parts.push('<div class="cpg-build-row">'+rfChecklist(b)+'<div class="cpg-rf-sub">TIMI counts \u22653 of these (obesity excluded): '+timiRfCount(b)+' so far</div></div>');
      parts.push(yn('knownCAD','Known CAD (stenosis \u2265 50%)'));
       parts.push(yn('asa','Aspirin use in past 7 days'));
      parts.push(yn('angina2','\u2265 2 anginal episodes in 24 h'));
       parts.push(yn('stdev','ST deviation \u2265 0.5 mm'));
      parts.push(yn('marker','Positive cardiac marker'));
      var ev=evalScore('timi',b);
      return '<div class="cpg-builder"><div class="cpg-builder-head">Build TIMI score'
        +'<button class="cpg-builder-close" data-closebuild="1">close</button></div>'
        + parts.join('')
         +'<div class="cpg-build-total">TIMI total: '+ev.total+(ev.complete?'':' (incomplete \u2014 answer all 7)')
         +'<div class="cpg-build-apply-note">Will apply: LR '+ev.lr+' \u2014 '+esc(ev.label)+'</div></div>'
         +'<button class="cpg-apply" data-applyscore="timi">Apply TIMI &rarr;</button>'
         +'<div class="cpg-cite">'+esc(CITE.timi)+'</div></div>';
    }
 
    function scoreCards(){
      if (scoreApplied()) return '<div class="cpg-note">A risk score has been applied. Undo it in the evidence list to choose a different one.</div>';
      var cards=[];
      if (S.hasTroponin) cards.push('<button class="cpg-score-card" data-openbuild="heart"><div class="cpg-score-name">HEART</div><div class="cpg-score-desc">History, ECG, Age, Risk factors, Troponin</div></button>');
      cards.push('<button class="cpg-score-card" data-openbuild="hear"><div class="cpg-score-name">HEAR</div><div class="cpg-score-desc">HEART without troponin \u2014 \u22641 rules out</div></button>');
      cards.push('<button class="cpg-score-card" data-openbuild="timi"><div class="cpg-score-name">TIMI</div><div class="cpg-score-desc">7 yes/no items (UA/NSTEMI)</div></button>');
       return '<div class="cpg-score-grid">'+cards.join('')+'</div>';
     }
     }


     function renderFramingham(){
     function featureTables(){
       S.framingham=true;
       return TABLES.map(function(t){
      var strata=FRAMINGHAM.strata.map(function(st){
        var locked = !!S.consumed[t.id];
        return '<button class="cpg-stratum" data-fram="'+st.id+'"><span class="cpg-stratum-label">'+esc(st.label)+'</span><span class="cpg-stratum-lr">LR '+st.lr+'</span></button>';
        var rows=t.rows.map(function(r){
          var ans=null; S.evidence.forEach(function(e){ if(e.tableId===t.id && e.rowId===r.id) ans=e.dir; });
          var nb=r.nb?'<div class="cpg-row-nb">'+esc(r.nb)+'</div>':'';
          var lrs='<span class="cpg-row-lrs"><span class="cpg-lrpos">+LR '+r.lrPos+'</span><span class="cpg-lrneg">\u2212LR '+r.lrNeg+'</span></span>';
          return '<div class="cpg-row'+(locked?' locked':'')+'"><div class="cpg-row-text">'+esc(r.text)+nb+lrs+'</div>'
            +'<button class="cpg-row-yes'+(ans==='pos'?' sel':'')+'" data-row="'+t.id+':'+r.id+'" data-val="pos"'+(locked?' disabled':'')+'>Yes</button>'
            +'<button class="cpg-row-no'+(ans==='neg'?' sel':'')+'" data-row="'+t.id+':'+r.id+'" data-val="neg"'+(locked?' disabled':'')+'>No</button></div>';
        }).join('');
        var note=t.note?'<div class="cpg-table-note">'+esc(t.note)+'</div>':'';
        var lockmsg=locked?'<div class="cpg-table-locked">Captured by the applied score \u2014 not counted again.</div>':'';
        return '<div class="cpg-table-block"><div class="cpg-card-label">JAMA Table '+t.table+' \u2014 '+esc(t.title)
          +' <span class="cpg-oneper">(one feature per table)</span></div>'+note+lockmsg
          +'<div class="cpg-table-headrow"><span>Feature</span><span>Present</span><span>Absent</span></div>'
          +'<div class="cpg-table">'+rows+'</div><div class="cpg-cite">'+esc(t.cite)+'</div></div>';
       }).join('');
       }).join('');
      S.host.innerHTML=[
        masthead(), probHeader(),
        '<div class="cpg-card">',
        '  <div class="cpg-card-label">Framingham fallback</div>',
        '  <div class="cpg-q">HFA/CSANZ was low-to-intermediate \u2014 categorise 10-year cardiovascular risk.</div>',
        '  <div class="cpg-rationale">'+esc(FRAMINGHAM.note)+'</div>',
        '  <div class="cpg-recency">&#9888; '+esc(FRAMINGHAM.recency)+'</div>',
        '  <div class="cpg-strata cpg-strata-wide">'+strata+'</div>',
        '  '+citeSpan(FRAMINGHAM.cite),
        '</div>',
        disclaimer()
      ].join('\n');
     }
     }


     function renderTable(){
     function renderMain(){
       S.phase='tables';
       var body;
       var tbl=TABLES[S.tableIdx];
      if (S.openBuilder==='heart') body=builderHEART(true);
       var rows=tbl.rows.map(function(r){
       else if (S.openBuilder==='hear') body=builderHEART(false);
        var nb=r.nb?'<div class="cpg-row-nb">'+esc(r.nb)+'</div>':'';
       else if (S.openBuilder==='timi') body=builderTIMI();
        return '<div class="cpg-row"><div class="cpg-row-text">'+esc(r.text)+nb+'</div>'+
      else body='<div class="cpg-card"><div class="cpg-card-label">Step 1 \u2014 risk score (optional, recommended)</div>'
          '<button class="cpg-row-yes" data-row="'+r.id+'" data-val="pos">Yes</button>'+
        +'<div class="cpg-q">Build one validated score. Every component is listed \u2014 nothing to memorise.</div>'
          '<button class="cpg-row-no" data-row="'+r.id+'" data-val="neg">No</button></div>';
        +'<div class="cpg-rationale">A score applies its cumulative LR only when that lands above 1 (escalating). A low/indeterminate total instead contributes the single highest +LR among its positive components. HEAR \u22641 is the one rule-out. '+citeSpan(CITE.jama)+'</div>'
      }).join('');
        + scoreCards()+'</div>'
      var tnote=tbl.note?'<div class="cpg-table-note">'+esc(tbl.note)+'</div>':'';
         +'<div class="cpg-card"><div class="cpg-card-label">Step 2 \u2014 single features</div>'
      S.host.innerHTML=[
         +'<div class="cpg-q">Add individual findings the score didn\u2019t already capture. +LR and \u2212LR are shown so you can pick the highest-yield discriminator.</div>'
        masthead(), probHeader(),
         + featureTables()
         '<div class="cpg-card">',
         +'<button class="cpg-result-btn" data-result="1">View pre-test result &rarr;</button></div>';
        '  <div class="cpg-card-label">JAMA Table '+tbl.table+' of 4 \u2014 '+esc(tbl.title)+'</div>',
      S.host.innerHTML = masthead()+probHeader()+body+trailPanel()+disclaimer();
         ' <div class="cpg-q">Answer ONE feature. Independence between findings is not assured, so a single likelihood ratio is applied per table; remaining rows are skipped.</div>',
         tnote,
        '  <div class="cpg-table-headrow"><span>Feature</span><span>Present</span><span>Absent</span></div>',
        '  <div class="cpg-table">'+rows+'</div>',
        '  '+citeSpan(tbl.cite),
         ' <button class="cpg-skip" data-skip="1">Skip this table (no informative feature) &rarr;</button>',
        '</div>',
        trailPanel(),
        disclaimer()
      ].join('\n');
     }
     }


     function renderResult(){
     function renderResult(){
       S.phase='result';
       S.phase='result';
       var b=band(S.prob);
       var p=prob(), bd=band(p);
       var href=(mw && mw.util) ? mw.util.getUrl(S.nextPage) : ('/wiki/'+S.nextPage);
       var href=(window.mw&&mw.util)?mw.util.getUrl(S.nextPage):('/wiki/'+S.nextPage);
       var esc_txt;
       var msg;
       if (S.prob>=0.20)     esc_txt='HIGH post-test probability. Arrange ECG and troponin and escalate / refer 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 (S.prob>=0.05) esc_txt='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.';
       else if (S.prob>=0.02) esc_txt='LOW post-test probability. Consider ECG / troponin per local pathway; this does not exclude ACS.';
       else if (p>=0.02) msg='LOW post-test probability. Consider ECG / troponin per local pathway; this does not exclude ACS.';
       else                   esc_txt='VERY LOW post-test probability. ACS is less likely, but this gate does NOT exclude it \u2014 troponin, ECG, and clinical judgement govern disposition.';
       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=[
       S.host.innerHTML = masthead()+probHeader()
        masthead(), probHeader(),
         +'<div class="cpg-card cpg-result-card"><div class="cpg-card-label">Pre-test gate complete</div>'
         '<div class="cpg-card cpg-result-card">',
         +'<div class="cpg-result-band '+bd.cls+'">'+bd.word+' likelihood of ACS \u00b7 '+pct(p)+'</div>'
        '  <div class="cpg-card-label">Pre-test gate complete</div>',
         +'<div class="cpg-escalate">'+esc(msg)+'</div>'
         ' <div class="cpg-result-band '+b.cls+'">'+b.word+' likelihood of ACS \u00b7 '+pct(S.prob)+'</div>',
         +'<div class="cpg-bottomline"><strong>This is a 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-escalate">'+esc(esc_txt)+'</div>',
         + trailPanel()
         ' <div class="cpg-bottomline"><strong>This is a probability estimate, not a rule-out.</strong> The cardiac troponin level and ECG in the context of the clinical picture govern disposition. '+citeSpan(CITE.jama)+' \u00b7 '+citeSpan(CITE.acs25)+'</div>',
         +'<div class="cpg-handoff"><div class="cpg-handoff-note">Cardiac pre-test screening is complete.</div>'
        '</div>',
         +'<a class="cpg-proceed-link" href="'+href+'">Proceed to the chest-pain muscle algorithm &rarr;</a>'
         trailPanel(),
         +'<button class="cpg-restart" id="cpg-restart">&#8635; Start the pre-test over</button></div>'
         '<div class="cpg-handoff">',
         + disclaimer();
        '  <div class="cpg-handoff-note">Cardiac pre-test screening is complete. You may now run the chest-pain muscle algorithm.</div>',
         ' <a class="cpg-proceed-link" href="'+href+'">Proceed to the chest-pain muscle algorithm &rarr;</a>',
         ' <button class="cpg-restart" id="cpg-restart">&#8635; Start the pre-test over</button>',
        '</div>',
         disclaimer()
      ].join('\n');
     }
     }


     function advanceTable(){
     function render(){ if(S.phase==='gate') renderGate(); else if(S.phase==='result') renderResult(); else renderMain(); }
      if (S.tableIdx < TABLES.length-1){ S.tableIdx++; renderTable(); }
 
      else { renderResult(); }
    /* ══ EVENTS ══ */
     }
     function consumeFor(scoreId){ S.consumed.t1=true; S.consumed.t4=true; /* HEART/HEAR/TIMI all incorporate risk factors + ECG */ }


     function onClick(e){
     function onClick(e){
       var t;
       var t;
       t=e.target.closest('[data-pre]');
      // preconditions
       t=e.target.closest('[data-trop]');
      if (t){ S.hasTroponin = t.getAttribute('data-trop')==='1'; S.phase='main'; render(); return; }
      // open / close builder
      t=e.target.closest('[data-openbuild]');
      if (t){ S.openBuilder=t.getAttribute('data-openbuild'); S.builder={}; render(); return; }
      if (e.target.closest('[data-closebuild]')){ S.openBuilder=null; S.builder={}; render(); return; }
      // segmented set (history/ecg/age/trop)
      t=e.target.closest('[data-set]');
      if (t){ S.builder[t.getAttribute('data-set')] = parseInt(t.getAttribute('data-val'),10); render(); return; }
      // checklist
      t=e.target.closest('[data-chk]');
      if (t){ var k=t.getAttribute('data-chk'); S.builder[k]=!S.builder[k]; render(); return; }
      // timi yes/no
      t=e.target.closest('[data-timi]');
      if (t){ S.builder['timi_'+t.getAttribute('data-timi')] = (t.getAttribute('data-val')==='1'); render(); return; }
      // apply score
      t=e.target.closest('[data-applyscore]');
       if (t){
       if (t){
         var v=t.getAttribute('data-val')==='1';
         var id=t.getAttribute('data-applyscore'); var ev=evalScore(id,S.builder);
        if (t.getAttribute('data-pre')==='trop') S.hasTroponin=v;
         S.evidence.push({kind:'score', scoreId:id, label:ev.label, lr:ev.lr});
         if (t.getAttribute('data-pre')==='angio') S.hasAngiography=v;
         consumeFor(id); S.openBuilder=null; S.builder={}; render(); return;
        var row=t.closest('.cpg-precond-row');
         if (row) row.querySelectorAll('button').forEach(function(x){x.classList.remove('sel');});
        t.classList.add('sel'); updatePrecond(); return;
       }
       }
       var go=e.target.closest('#cpg-to-score');
       // single feature
      if (go && !go.disabled){ renderScores(); return; }
 
      t=e.target.closest('[data-stratum]');
      if (t){
        var sc=SCORES[t.getAttribute('data-score')];
        var st=sc.strata.filter(function(x){return x.id===t.getAttribute('data-stratum');})[0];
        var b0=S.prob; S.prob=applyLR(S.prob,st.lr); S.scoreId=sc.id;
        S.steps.push({label:sc.label+' \u2014 '+st.label, lr:st.lr, before:b0, after:S.prob});
        if (st.framingham_fallback){ renderFramingham(); return; }
        S.tableIdx=0; renderTable(); return;
      }
 
      t=e.target.closest('[data-fram]');
      if (t){
        var fst=FRAMINGHAM.strata.filter(function(x){return x.id===t.getAttribute('data-fram');})[0];
        var fb=S.prob; S.prob=applyLR(S.prob,fst.lr);
        S.steps.push({label:'Framingham \u2014 '+fst.label, lr:fst.lr, before:fb, after:S.prob});
        S.tableIdx=0; renderTable(); return;
      }
 
       t=e.target.closest('[data-row]');
       t=e.target.closest('[data-row]');
       if (t){
       if (t && !t.disabled){
         var tbl=TABLES[S.tableIdx];
         var parts=t.getAttribute('data-row').split(':'); var tid=parts[0], rid=parts[1], dir=t.getAttribute('data-val');
         var r=tbl.rows.filter(function(x){return x.id===t.getAttribute('data-row');})[0];
        if (S.consumed[tid]) return;
         var dir=t.getAttribute('data-val');
         // remove existing answer for this table (one per table)
         var lr=(dir==='pos')?r.lrPos:r.lrNeg;
        S.evidence = S.evidence.filter(function(x){ return x.tableId!==tid; });
         var bb=S.prob; S.prob=applyLR(S.prob,lr);
        var tbl=TABLES.filter(function(x){return x.id===tid;})[0];
        S.steps.push({label:'T'+tbl.table+': '+r.text+' \u2014 '+(dir==='pos'?'present':'absent'), lr:lr, before:bb, after:S.prob});
         var row=tbl.rows.filter(function(x){return x.id===rid;})[0];
        advanceTable(); return;
         var lr = dir==='pos'?row.lrPos:row.lrNeg;
      }
         S.evidence.push({kind:'feature', tableId:tid, rowId:rid, dir:dir, lr:lr,
 
          label:'T'+tbl.table+': '+row.text+' \u2014 '+(dir==='pos'?'present':'absent')});
      if (e.target.closest('[data-skip]')){ advanceTable(); return; }
         render(); return;
 
      if (e.target.closest('#cpg-restart')){
        var h=S.host, n=S.nextPage; S=fresh(); S.host=h; S.nextPage=n; renderGate(); return;
      }
      if (e.target.closest('#cpg-bypass')){
        S.steps.push({label:'Clinician bypassed the alert', lr:1.0, before:S.prob, after:S.prob});
         renderResult(); return;
       }
       }
      // undo
      t=e.target.closest('[data-undo]');
      if (t){ var idx=parseInt(t.getAttribute('data-undo'),10); var removed=S.evidence.splice(idx,1)[0];
        if (removed && removed.kind==='score'){ S.consumed={}; } render(); return; }
      // result / restart / bypass
      if (e.target.closest('[data-result]')){ renderResult(); return; }
      if (e.target.closest('#cpg-restart')){ var h=S.host,n=S.nextPage; S=fresh(); S.host=h; S.nextPage=n; render(); return; }
      if (e.target.closest('#cpg-bypass')){ S.evidence.push({kind:'bypass',label:'Clinician bypassed the alert',lr:1.0}); renderResult(); return; }
     }
     }


     function boot(hostEl){
     function boot(hostEl){
       S=fresh(); S.host=hostEl;
       S=fresh(); S.host=hostEl;
       var np=hostEl.getAttribute('data-next-page'); if (np) S.nextPage=np;
       var np=hostEl.getAttribute('data-next-page'); if(np) S.nextPage=np;
       hostEl.addEventListener('click', onClick);
       hostEl.addEventListener('click', onClick);
       renderGate();
       render();
     }
     }
     function init(){
     function init(){
       document.querySelectorAll('.chest-pretest-gate').forEach(function(el){
       document.querySelectorAll('.chest-pretest-gate').forEach(function(el){
         if (!el.dataset.cpgBooted){ el.dataset.cpgBooted='1'; boot(el); }
         if(!el.dataset.cpgBooted){ el.dataset.cpgBooted='1'; boot(el); }
       });
       });
     }
     }
     return { init: init };
     return { init: init };
   }() );
   }() );

Revision as of 07:23, 18 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">&#8627; '+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]+'&times;</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)+' &mdash; 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">&#10003; 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 &mdash; 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 &middot; ' + 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 &#9663;</button>',
      '  </div>',
      '  <div class="rf-expandable" id="rf-expandable">',
      '    <div class="rf-panel-wrap" id="rf-emergency-panel">',
      '      <div class="rf-col-header">&#9888; Emergency &mdash; 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">&#9888; Urgent &mdash; 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 &mdash; or appropriate action taken.</div>',
      '        <button class="btn-affirm" id="btn-affirm">Proceed to clinical interview &rarr;</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 &mdash; 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">&#9632; Broad Differential Diagnosis</div>',
      '      <button class="dt-broad-toggle" id="broad-toggle-btn">Hide</button>',
      '    </div>',
      '    <div class="dt-epigraph">',
      '      &#8220;If he does not expect the unexpected, he will not discover it &mdash;',
      '      for it is difficult to discover and intractable.&#8221;',
      '      <cite>&mdash; 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">&#8635; 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;  // 13% pooled pre-test ACS prevalence (JAMA 2015)

    var CITE = {
      jama: 'Fanaroff et al. JAMA 2015;314(18):1955-1965 (Rational Clinical Examination)',
      heart:'Six-Backus / HEART score (Backus 2008; HEART Pathway). Strata LRs per JAMA 2015 Table 5',
      timi: 'Antman TIMI risk score UA/NSTEMI (JAMA 2000). Strata LRs per JAMA 2015 Table 5',
      hear: 'HEAR score (HEART minus troponin). Smith 2020 EMJ; O\u2019Reilly 2021 CJEM; JACC Adv 2025 \u2014 \u22641 rule-out',
      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)',
      t4:'JAMA 2015 \u2014 Table 4 (ECG)',
      acs25:'NHFA/CSANZ Australian ACS Guideline 2025 (Brieger, Cullen, Briffa et al.)'
    };

    /* ── JAMA single-feature tables (LR+/LR-). Table 1 order MI->CABG->CAD. ── */
    var TABLE1 = { id:'t1', table:1, title:'Cardiac risk factors', 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:'Chest-pain characteristics', cite:CITE.t2, rows:[
      { id:'t2_botharms', text:'Pain radiates to BOTH arms',             lrPos:2.6,  lrNeg:0.93 },
      { id:'t2_prior',    text:'Similar to prior ischaemia',             lrPos:2.2,  lrNeg:0.67 },
      { 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_neckjaw',  text:'Radiates to neck or jaw',                lrPos:1.5,  lrNeg:0.91 },
      { id:'t2_diaph',    text:'Associated diaphoresis',                 lrPos:1.4,  lrNeg:0.93 },
      { id:'t2_recent',   text:'Recent episode of similar pain',         lrPos:1.3,  lrNeg:0.80 },
      { 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_palp',     text:'Associated palpitations',               lrPos:1.3,  lrNeg:1.0  },
      { id:'t2_dyspnea',  text:'Associated dyspnoea',                    lrPos:1.2,  lrNeg:0.89 },
      { id:'t2_burning',  text:'Burning pain',                           lrPos:1.2,  lrNeg:1.0  },
      { id:'t2_abrupt',   text:'Abrupt onset',                           lrPos:1.1,  lrNeg:0.75 },
      { id:'t2_ntg',      text:'Any improvement with nitroglycerin',     lrPos:1.1,  lrNeg:0.90,
        nb:'Response to nitroglycerin is unhelpful for diagnosing ACS.' },
      { id:'t2_nausea',   text:'Associated nausea / vomiting',           lrPos:1.1,  lrNeg:1.0  },
      { id:'t2_typrad',   text:'\u201CTypical\u201D radiation',          lrPos:1.0,  lrNeg:0.98 },
      { id:'t2_syncope',  text:'Associated syncope',                     lrPos:0.76, lrNeg:1.1, nb:'Lowers likelihood.' },
      { id:'t2_pleur',    text:'Pleuritic pain',                         lrPos:0.61, lrNeg:1.2, nb:'Lowers likelihood.' }
    ] };
    var TABLE3 = { id:'t3', table:3, title:'Physical examination', 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 TABLE4 = { id:'t4', table:4, title:'Electrocardiogram (ECG)', cite:CITE.t4,
      note:'Ischemic ECG = any T-wave inversion, Q-waves, or ST depression.', rows:[
      { id:'t4_stdep', text:'ST-segment depression',                                          lrPos:5.3, lrNeg:0.79 },
      { id:'t4_isch',  text:'Ischemic ECG (any T-wave inversion, Q-waves, or ST depression)', lrPos:3.6, lrNeg:0.74 },
      { id:'t4_twave', text:'T-wave inversion',                                               lrPos:1.8, lrNeg:0.89,
        nb:'T-wave positive in aVR, negative in I, II, V3\u2013V6.' }
    ] };
    var TABLES = [ TABLE1, TABLE2, TABLE3, TABLE4 ];

    /* Risk factors (HEART/HEAR/TIMI). Each carries a representative JAMA +LR    */
    /* for the highest-single-+LR fallback. timi=true counts toward TIMI \u22653.  */
    var RISK_FACTORS = [
      { id:'rf_htn',   label:'Hypertension',                          lr:1.1, timi:true },
      { id:'rf_lipid', label:'Hypercholesterolaemia',                lr:1.2, timi:true },
      { id:'rf_dm',    label:'Diabetes mellitus',                     lr:1.4, timi:true },
      { id:'rf_obes',  label:'Obesity (BMI > 30)',                    lr:0.97,timi:false },
      { id:'rf_smoke', label:'Smoking (current or \u22643 mo)',        lr:1.0, timi:true },
      { id:'rf_fhx',   label:'Family history CVD < 65',               lr:1.0, timi:true }
    ];
    var ATHERO = [
      { id:'ath_mi',  label:'Prior MI',               lr:1.6 },
      { id:'ath_pci', label:'Prior PCI / CABG',       lr:1.4 },
      { id:'ath_cva', label:'Prior CVA / TIA',        lr:1.3 },
      { id:'ath_pad', label:'Peripheral arterial disease', lr:2.7 }
    ];

    /* Strata: cumulative LR applied only when lr > 1 (see rule). */
    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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];});}
    function citeSpan(t){return '<span class="cpg-cite">'+esc(t)+'</span>';}

    /* Risk bands: <2% very low | 2-5% low | 5-20% intermediate | >20% high */
    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 (memory only) ────────────────────────────────────────── */
    var S;
    function fresh(){ return {
      host:null, nextPage:'Pain:Chest', phase:'gate',
      hasTroponin:null,
      openBuilder:null,                 // 'heart' | 'hear' | 'timi' | null
      builder:{},                       // component selections for the open builder
      evidence:[],                      // [{kind,label,lr,scoreId?,tableId?}]
      consumed:{}                       // tableId -> true (Option 2)
    }; }

    function prob(){ var p=BASELINE; S.evidence.forEach(function(e){ p=applyLR(p,e.lr); }); return p; }
    function scoreApplied(){ return S.evidence.some(function(e){ return e.kind==='score'; }); }

    /* ══ RULE ENGINE ══ Build a score from builder selections, return result. */
    function riskTier(b){
      var rfCount=0, athero=false, poolMax=0;
      RISK_FACTORS.forEach(function(rf){ if(b['rf_'+rf.id]){ rfCount++; if(rf.lr>poolMax)poolMax=rf.lr; } });
      ATHERO.forEach(function(a){ if(b['ath_'+a.id]){ athero=true; if(a.lr>poolMax)poolMax=a.lr; } });
      var tier = (athero || rfCount>=3) ? 2 : (rfCount>=1 ? 1 : 0);
      return { tier:tier, rfCount:rfCount, athero:athero, poolMax:poolMax };
    }
    function timiRfCount(b){ var n=0; RISK_FACTORS.forEach(function(rf){ if(rf.timi && b['rf_'+rf.id]) n++; }); return n; }

    // returns {complete, total, label, applyLR, applyLabel}
    function evalScore(id,b){
      var poolMax=0; // highest single +LR among positive components (>1 only)
      function pool(lr){ if(lr>1 && lr>poolMax) poolMax=lr; }
      var total=0, complete=true, rt;

      if (id==='heart' || id==='hear'){
        // history 0/1/2
        if (b.history==null) complete=false; else { total+=b.history; if(b.history>=1) pool(1.9); }
        // ecg 0/1/2
        if (b.ecg==null) complete=false; else { total+=b.ecg; if(b.ecg===2) pool(5.3); else if(b.ecg===1) pool(1.8); }
        // age 0/1/2 (no JAMA +LR)
        if (b.age==null) complete=false; else { total+=b.age; }
        // risk factors (checklist always answerable; treat as answered)
        rt=riskTier(b); total+=rt.tier; pool(rt.poolMax);
        if (id==='heart'){ if (b.trop==null) complete=false; else { total+=b.trop; } } // troponin no +LR
      } else if (id==='timi'){
        var items=['age65','knownCAD','asa','angina2','stdev','marker'];
        items.forEach(function(k){ if(b['timi_'+k]==null) complete=false; });
        var n=0;
        if (b.timi_age65) n++;                          // no +LR
        if (b.timi_knownCAD){ n++; pool(2.0); }         // prior CAD 2.0
        if (b.timi_asa) n++;                            // no +LR
        if (b.timi_angina2){ n++; pool(2.0); }          // change-24h 2.0 (loose)
        if (b.timi_stdev){ n++; pool(5.3); }            // ST dep 5.3
        if (b.timi_marker) n++;                         // no +LR
        // >=3 CAD risk factors
        var trf=timiRfCount(b); var rfPos = trf>=3;
        if (rfPos){ n++; rt=riskTier(b); pool(rt.poolMax); }
        total=n;
        // risk-factor checklist is optional input; score still 'complete' from the 6 toggles
      }

      var strata = (id==='timi') ? TIMI_STRATA : (id==='hear' ? null : HEART_STRATA);
      var res = { complete:complete, total:total, poolMax:poolMax };

      // HEAR: <=1 rule-out (non-ischemic ECG + no known CAD), else highest single +LR
      if (id==='hear'){
        var noKnownCAD = !ATHERO.some(function(a){return b['ath_'+a.id];});
        if (complete && total<=1 && b.ecg===0 && noKnownCAD){
          res.lr=0.01; res.label='HEAR \u22641 \u2014 very low risk (rule-out)'; res.kind='cumulative'; return res;
        }
        res.lr = poolMax>1 ? poolMax : 1.0;
        res.label = 'HEAR \u22652 \u2014 highest single +LR '+ (poolMax>1?poolMax:'(none \u2192 no change)');
        res.kind = poolMax>1 ? 'fallback' : 'none'; return res;
      }

      var st = stratumFor(strata,total);
      res.stratumLabel = st.label;
      if (st.lr>1){
        res.lr=st.lr; res.label=(id==='heart'?'HEART ':'TIMI ')+st.label+' \u2014 cumulative LR '+st.lr; res.kind='cumulative';
      } else {
        res.lr = poolMax>1 ? poolMax : 1.0;
        res.label=(id==='heart'?'HEART ':'TIMI ')+st.label+' \u2014 highest single +LR '+(poolMax>1?poolMax:'(none \u2192 no change)');
        res.kind = poolMax>1 ? 'fallback' : 'none';
      }
      return res;
    }

    /* ══ RENDER ══ everything rebuilt from state each interaction ══ */
    function masthead(){ return ''
      +'<header class="cpg-masthead"><div class="cpg-logo">Pain<span>Wiki</span></div>'
      +'<div class="cpg-region">Cardiac Pre-Test &middot; Chest Pain \u2014 ACS probability</div></header>'; }

    function disclaimer(){ return ''
      +'<div class="cpg-foot"><div class="cpg-disclaimer"><strong>Not validated for clinical use.</strong> '
      +'Pre-test probability tool after '+citeSpan(CITE.jama)+'. Does not exclude ACS.</div>'
      +'<button class="cpg-bypass" id="cpg-bypass">Bypass &amp; 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';
      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 trailPanel(){
      if (!S.evidence.length) return '';
      var rows=S.evidence.map(function(e,i){
        return '<div class="cpg-trail-item"><div class="cpg-trail-q">'+esc(e.label)+'</div>'
          +'<div class="cpg-trail-a">&#8627; LR '+e.lr+'</div>'
          +'<button class="cpg-undo" data-undo="'+i+'">undo</button></div>';
      }).join('');
      return '<div class="cpg-trail"><div class="cpg-trail-head">Applied evidence</div>'+rows+'</div>';
    }

    function renderGate(){
      S.host.innerHTML = masthead()
        +'<div class="cpg-card"><div class="cpg-card-label">Before you begin</div>'
        +'<div class="cpg-q">Is a cardiac troponin result available?</div>'
        +'<div class="cpg-precond-note">Troponin enables the HEART score (the T of HEART). Without it, use HEAR (no troponin) or TIMI.</div>'
        +'<div class="cpg-yn" style="margin-top:.8rem">'
        +'<button class="cpg-btn-yes" data-trop="1">Yes</button>'
        +'<button class="cpg-btn-no" data-trop="0">No</button></div></div>'
        + disclaimer();
    }

    function seg(name,opts,cur){ // segmented control: opts=[{v,label}]
      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 rfChecklist(b){
      var rfs=RISK_FACTORS.map(function(rf){
        return '<label class="cpg-chk'+(b['rf_'+rf.id]?' on':'')+'"><input type="checkbox" data-chk="rf_'+rf.id+'"'+(b['rf_'+rf.id]?' checked':'')+'>'+esc(rf.label)+' <span class="cpg-chk-lr">+LR '+rf.lr+'</span></label>';
      }).join('');
      var ath=ATHERO.map(function(a){
        return '<label class="cpg-chk'+(b['ath_'+a.id]?' on':'')+'"><input type="checkbox" data-chk="ath_'+a.id+'"'+(b['ath_'+a.id]?' checked':'')+'>'+esc(a.label)+' <span class="cpg-chk-lr">+LR '+a.lr+'</span></label>';
      }).join('');
      var rt=riskTier(b);
      return '<div class="cpg-rf-group"><div class="cpg-rf-head">Risk factors '
        +'<span class="cpg-rf-tier">tier '+rt.tier+' &middot; '+rt.rfCount+' factor'+(rt.rfCount===1?'':'s')+(rt.athero?' + atherosclerotic disease':'')+'</span></div>'
        +'<div class="cpg-chk-grid">'+rfs+'</div>'
        +'<div class="cpg-rf-sub">Established atherosclerotic disease (\u2192 tier 2)</div>'
        +'<div class="cpg-chk-grid">'+ath+'</div></div>';
    }

    function builderHEART(isHeart){
      var b=S.builder, id=isHeart?'heart':'hear';
      var parts=[];
      parts.push('<div class="cpg-build-row"><div class="cpg-build-label">History</div>'
        + seg('history',[{v:0,label:'Slightly susp. (0)'},{v:1,label:'Moderately (1)'},{v:2,label:'Highly (2)'}], b.history)+'</div>');
      parts.push('<div class="cpg-build-row"><div class="cpg-build-label">ECG</div>'
        + seg('ecg',[{v:0,label:'Normal (0)'},{v:1,label:'Non-specific (1)'},{v:2,label:'Significant ST dev. (2)'}], b.ecg)+'</div>');
      parts.push('<div class="cpg-build-row"><div class="cpg-build-label">Age</div>'
        + seg('age',[{v:0,label:'< 45 (0)'},{v:1,label:'45\u201364 (1)'},{v:2,label:'\u2265 65 (2)'}], b.age)+'</div>');
      parts.push('<div class="cpg-build-row">'+rfChecklist(b)+'</div>');
      if (isHeart) parts.push('<div class="cpg-build-row"><div class="cpg-build-label">Troponin</div>'
        + seg('trop',[{v:0,label:'\u2264 ULN (0)'},{v:1,label:'1\u20133\u00d7 (1)'},{v:2,label:'> 3\u00d7 (2)'}], b.trop)+'</div>');
      var ev=evalScore(id,b);
      var totalTxt = (isHeart? 'HEART':'HEAR')+' total: '+ev.total+(ev.complete?'':' (incomplete)');
      var willApply = 'Will apply: LR '+ev.lr+' \u2014 '+ev.label;
      return '<div class="cpg-builder"><div class="cpg-builder-head">'+(isHeart?'Build HEART score':'Build HEAR score (no troponin)')
        +'<button class="cpg-builder-close" data-closebuild="1">close</button></div>'
        + parts.join('')
        +'<div class="cpg-build-total">'+esc(totalTxt)+'<div class="cpg-build-apply-note">'+esc(willApply)+'</div></div>'
        +'<button class="cpg-apply" data-applyscore="'+id+'">Apply '+(isHeart?'HEART':'HEAR')+' &rarr;</button>'
        +'<div class="cpg-cite">'+esc(isHeart?CITE.heart:CITE.hear)+'</div></div>';
    }

    function builderTIMI(){
      var b=S.builder;
      function yn(k,label){ var v=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="1">Yes</button>'
        +'<button class="cpg-btn-no'+(v===false?' sel':'')+'" data-timi="'+k+'" data-val="0">No</button></div></div>'; }
      var parts=[];
      parts.push(yn('age65','Age \u2265 65'));
      parts.push('<div class="cpg-build-row">'+rfChecklist(b)+'<div class="cpg-rf-sub">TIMI counts \u22653 of these (obesity excluded): '+timiRfCount(b)+' so far</div></div>');
      parts.push(yn('knownCAD','Known CAD (stenosis \u2265 50%)'));
      parts.push(yn('asa','Aspirin use in past 7 days'));
      parts.push(yn('angina2','\u2265 2 anginal episodes in 24 h'));
      parts.push(yn('stdev','ST deviation \u2265 0.5 mm'));
      parts.push(yn('marker','Positive cardiac marker'));
      var ev=evalScore('timi',b);
      return '<div class="cpg-builder"><div class="cpg-builder-head">Build TIMI score'
        +'<button class="cpg-builder-close" data-closebuild="1">close</button></div>'
        + parts.join('')
        +'<div class="cpg-build-total">TIMI total: '+ev.total+(ev.complete?'':' (incomplete \u2014 answer all 7)')
        +'<div class="cpg-build-apply-note">Will apply: LR '+ev.lr+' \u2014 '+esc(ev.label)+'</div></div>'
        +'<button class="cpg-apply" data-applyscore="timi">Apply TIMI &rarr;</button>'
        +'<div class="cpg-cite">'+esc(CITE.timi)+'</div></div>';
    }

    function scoreCards(){
      if (scoreApplied()) return '<div class="cpg-note">A risk score has been applied. Undo it in the evidence list to choose a different one.</div>';
      var cards=[];
      if (S.hasTroponin) cards.push('<button class="cpg-score-card" data-openbuild="heart"><div class="cpg-score-name">HEART</div><div class="cpg-score-desc">History, ECG, Age, Risk factors, Troponin</div></button>');
      cards.push('<button class="cpg-score-card" data-openbuild="hear"><div class="cpg-score-name">HEAR</div><div class="cpg-score-desc">HEART without troponin \u2014 \u22641 rules out</div></button>');
      cards.push('<button class="cpg-score-card" data-openbuild="timi"><div class="cpg-score-name">TIMI</div><div class="cpg-score-desc">7 yes/no items (UA/NSTEMI)</div></button>');
      return '<div class="cpg-score-grid">'+cards.join('')+'</div>';
    }

    function featureTables(){
      return TABLES.map(function(t){
        var locked = !!S.consumed[t.id];
        var rows=t.rows.map(function(r){
          var ans=null; S.evidence.forEach(function(e){ if(e.tableId===t.id && e.rowId===r.id) ans=e.dir; });
          var nb=r.nb?'<div class="cpg-row-nb">'+esc(r.nb)+'</div>':'';
          var lrs='<span class="cpg-row-lrs"><span class="cpg-lrpos">+LR '+r.lrPos+'</span><span class="cpg-lrneg">\u2212LR '+r.lrNeg+'</span></span>';
          return '<div class="cpg-row'+(locked?' locked':'')+'"><div class="cpg-row-text">'+esc(r.text)+nb+lrs+'</div>'
            +'<button class="cpg-row-yes'+(ans==='pos'?' sel':'')+'" data-row="'+t.id+':'+r.id+'" data-val="pos"'+(locked?' disabled':'')+'>Yes</button>'
            +'<button class="cpg-row-no'+(ans==='neg'?' sel':'')+'" data-row="'+t.id+':'+r.id+'" data-val="neg"'+(locked?' disabled':'')+'>No</button></div>';
        }).join('');
        var note=t.note?'<div class="cpg-table-note">'+esc(t.note)+'</div>':'';
        var lockmsg=locked?'<div class="cpg-table-locked">Captured by the applied score \u2014 not counted again.</div>':'';
        return '<div class="cpg-table-block"><div class="cpg-card-label">JAMA Table '+t.table+' \u2014 '+esc(t.title)
          +' <span class="cpg-oneper">(one feature per table)</span></div>'+note+lockmsg
          +'<div class="cpg-table-headrow"><span>Feature</span><span>Present</span><span>Absent</span></div>'
          +'<div class="cpg-table">'+rows+'</div><div class="cpg-cite">'+esc(t.cite)+'</div></div>';
      }).join('');
    }

    function renderMain(){
      var body;
      if (S.openBuilder==='heart') body=builderHEART(true);
      else if (S.openBuilder==='hear') body=builderHEART(false);
      else if (S.openBuilder==='timi') body=builderTIMI();
      else body='<div class="cpg-card"><div class="cpg-card-label">Step 1 \u2014 risk score (optional, recommended)</div>'
        +'<div class="cpg-q">Build one validated score. Every component is listed \u2014 nothing to memorise.</div>'
        +'<div class="cpg-rationale">A score applies its cumulative LR only when that lands above 1 (escalating). A low/indeterminate total instead contributes the single highest +LR among its positive components. HEAR \u22641 is the one rule-out. '+citeSpan(CITE.jama)+'</div>'
        + scoreCards()+'</div>'
        +'<div class="cpg-card"><div class="cpg-card-label">Step 2 \u2014 single features</div>'
        +'<div class="cpg-q">Add individual findings the score didn\u2019t already capture. +LR and \u2212LR are shown so you can pick the highest-yield discriminator.</div>'
        + featureTables()
        +'<button class="cpg-result-btn" data-result="1">View pre-test result &rarr;</button></div>';
      S.host.innerHTML = masthead()+probHeader()+body+trailPanel()+disclaimer();
    }

    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>This is a probability estimate, not a rule-out.</strong> Troponin and ECG in clinical context govern disposition. '+citeSpan(CITE.jama)+' \u00b7 '+citeSpan(CITE.acs25)+'</div></div>'
        + trailPanel()
        +'<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 &rarr;</a>'
        +'<button class="cpg-restart" id="cpg-restart">&#8635; Start the pre-test over</button></div>'
        + disclaimer();
    }

    function render(){ if(S.phase==='gate') renderGate(); else if(S.phase==='result') renderResult(); else renderMain(); }

    /* ══ EVENTS ══ */
    function consumeFor(scoreId){ S.consumed.t1=true; S.consumed.t4=true; /* HEART/HEAR/TIMI all incorporate risk factors + ECG */ }

    function onClick(e){
      var t;
      // preconditions
      t=e.target.closest('[data-trop]');
      if (t){ S.hasTroponin = t.getAttribute('data-trop')==='1'; S.phase='main'; render(); return; }
      // open / close builder
      t=e.target.closest('[data-openbuild]');
      if (t){ S.openBuilder=t.getAttribute('data-openbuild'); S.builder={}; render(); return; }
      if (e.target.closest('[data-closebuild]')){ S.openBuilder=null; S.builder={}; render(); return; }
      // segmented set (history/ecg/age/trop)
      t=e.target.closest('[data-set]');
      if (t){ S.builder[t.getAttribute('data-set')] = parseInt(t.getAttribute('data-val'),10); render(); return; }
      // checklist
      t=e.target.closest('[data-chk]');
      if (t){ var k=t.getAttribute('data-chk'); S.builder[k]=!S.builder[k]; render(); return; }
      // timi yes/no
      t=e.target.closest('[data-timi]');
      if (t){ S.builder['timi_'+t.getAttribute('data-timi')] = (t.getAttribute('data-val')==='1'); render(); return; }
      // apply score
      t=e.target.closest('[data-applyscore]');
      if (t){
        var id=t.getAttribute('data-applyscore'); var ev=evalScore(id,S.builder);
        S.evidence.push({kind:'score', scoreId:id, label:ev.label, lr:ev.lr});
        consumeFor(id); S.openBuilder=null; S.builder={}; render(); return;
      }
      // single feature
      t=e.target.closest('[data-row]');
      if (t && !t.disabled){
        var parts=t.getAttribute('data-row').split(':'); var tid=parts[0], rid=parts[1], dir=t.getAttribute('data-val');
        if (S.consumed[tid]) return;
        // remove existing answer for this table (one per table)
        S.evidence = S.evidence.filter(function(x){ return x.tableId!==tid; });
        var tbl=TABLES.filter(function(x){return x.id===tid;})[0];
        var row=tbl.rows.filter(function(x){return x.id===rid;})[0];
        var lr = dir==='pos'?row.lrPos:row.lrNeg;
        S.evidence.push({kind:'feature', tableId:tid, rowId:rid, dir:dir, lr:lr,
          label:'T'+tbl.table+': '+row.text+' \u2014 '+(dir==='pos'?'present':'absent')});
        render(); return;
      }
      // undo
      t=e.target.closest('[data-undo]');
      if (t){ var idx=parseInt(t.getAttribute('data-undo'),10); var removed=S.evidence.splice(idx,1)[0];
        if (removed && removed.kind==='score'){ S.consumed={}; } render(); return; }
      // result / restart / bypass
      if (e.target.closest('[data-result]')){ renderResult(); return; }
      if (e.target.closest('#cpg-restart')){ var h=S.host,n=S.nextPage; S=fresh(); S.host=h; S.nextPage=n; render(); return; }
      if (e.target.closest('#cpg-bypass')){ S.evidence.push({kind:'bypass',label:'Clinician bypassed the alert',lr:1.0}); 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();
      }
    } );
  }

}() );