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