MediaWiki:Gadget-DiagnosticTree.js: Difference between revisions
Jump to navigation
Jump to search
No edit summary |
No edit summary |
||
| Line 1: | Line 1: | ||
/** | /** | ||
* DiagnosticTree.js | * DiagnosticTree.js — v6 | ||
* Copy to: MediaWiki:Gadget-DiagnosticTree.js | * Copy entire contents to: MediaWiki:Gadget-DiagnosticTree.js | ||
* | * | ||
* | * EMBED ON ANY WIKI PAGE: | ||
* <div class="diagnostic-tree-host" data-tree-page="DiagnosticTree/EarTMJ"></div> | * <div class="diagnostic-tree-host" | ||
* data-tree-page="DiagnosticTree/EarTMJ" | |||
* data-nerve-tree-page="DiagnosticTree/EarTMJ_nerve"> | |||
* </div> | |||
* | * | ||
* JSON page | * data-nerve-tree-page is optional — omit it and the nerve tab | ||
* shows as "coming soon" automatically. | |||
* | |||
* JSON PAGE STRUCTURE (data-tree-page): | |||
* { | * { | ||
* "tree_id": "...", | * "tree_id": "...", | ||
* "region": | * "region": "...", | ||
* "start": | * "start": "first-node-id", | ||
* "redflags": { | * "redflags": { "emergency": [...], "urgent": [...] }, | ||
* "broad_differential": [...], | |||
* | * "nodes": { ... } | ||
* "nodes": { ... } | |||
* } | * } | ||
*/ | */ | ||
| Line 22: | Line 26: | ||
'use strict'; | 'use strict'; | ||
/* | /* ══════════════════════════════════════════════════════ | ||
NODE META | |||
══════════════════════════════════════════════════════ */ | |||
var NODE_META = { | var NODE_META = { | ||
rom: { color: '#1d4ed8', icon: '🔄', label: 'Movement Screen' }, | rom: { color:'#1d4ed8', icon:'🔄', label:'Movement Screen' }, | ||
nerve_entrapment: { color: '#6d28d9', icon: '⚡', label: 'Nerve Screen' }, | nerve_entrapment: { color:'#6d28d9', icon:'⚡', label:'Nerve Screen' }, | ||
symptom: { color: '#0f766e', icon: '💬', label: 'Patient Symptom' }, | symptom: { color:'#0f766e', icon:'💬', label:'Patient Symptom' }, | ||
examination: { color: '#15803d', icon: '🩺', label: 'Examination' }, | examination: { color:'#15803d', icon:'🩺', label:'Examination' }, | ||
result: { color: '#15803d', icon: '✅', label: 'Diagnosis' }, | result: { color:'#15803d', icon:'✅', label:'Diagnosis' }, | ||
overlap: { color: '#78716c', icon: '🔍', label: 'Inconclusive' } | overlap: { color:'#78716c', icon:'🔍', label:'Inconclusive' }, | ||
neuro_referral: { color:'#b91c1c', icon:'🧠', label:'Neurological Referral' } | |||
}; | }; | ||
/* | /* ══════════════════════════════════════════════════════ | ||
RED FLAG PANEL | |||
══════════════════════════════════════════════════════ */ | |||
function RedFlagPanel( | function RedFlagPanel( el, data, onCleared ) { | ||
this.el | this.el = el; this.data = data; this.onCleared = onCleared; | ||
this.state = {}; this.expanded = null; | |||
data.emergency.forEach( function ( f ) { this.state[ f.id ] = 'pending'; }, this ); | |||
this.state | data.urgent.forEach( function ( f ) { this.state[ f.id ] = 'pending'; }, this ); | ||
this.render(); | this.render(); | ||
} | } | ||
| Line 54: | Line 57: | ||
RedFlagPanel.prototype.render = function () { | RedFlagPanel.prototype.render = function () { | ||
var | var s = this; | ||
var | var eC = this.data.emergency.every( function ( f ) { return s.state[ f.id ] === 'cleared'; } ); | ||
var | var uC = this.data.urgent.every( function ( f ) { return s.state[ f.id ] === 'cleared'; } ); | ||
this.el.innerHTML = | this.el.innerHTML = | ||
'<div class="dt-redflag-panel">' + | '<div class="dt-redflag-panel">' + | ||
this. | this._col( 'emergency', this.data.emergency, eC ) + | ||
this. | this._col( 'urgent', this.data.urgent, uC ) + | ||
'</div>'; | '</div>'; | ||
this.el.querySelectorAll( '.dt-flag-item' ).forEach( function ( | this.el.querySelectorAll( '.dt-flag-item' ).forEach( function ( el ) { | ||
var id = | var id = el.getAttribute( 'data-id' ); | ||
el.addEventListener( 'click', function ( e ) { | |||
if ( e.target.classList.contains( 'dt-flag-btn' ) ) return; | if ( e.target.classList.contains( 'dt-flag-btn' ) ) return; | ||
if ( | if ( s.state[ id ] === 'cleared' ) return; | ||
s.expanded = s.expanded === id ? null : id; | |||
s.render(); | |||
} ); | } ); | ||
} ); | } ); | ||
| Line 77: | Line 80: | ||
btn.addEventListener( 'click', function ( e ) { | btn.addEventListener( 'click', function ( e ) { | ||
e.stopPropagation(); | e.stopPropagation(); | ||
var id | var id = btn.getAttribute( 'data-id' ); | ||
var | var act = btn.getAttribute( 'data-action' ); | ||
s.state[ id ] = act === 'present' ? 'present' : 'cleared'; | |||
s.expanded = act === 'present' ? id : null; | |||
s.render(); | |||
if ( | if ( s.allCleared() ) s.onCleared(); | ||
} ); | } ); | ||
} ); | } ); | ||
}; | }; | ||
RedFlagPanel.prototype. | RedFlagPanel.prototype._col = function ( type, flags, allCleared ) { | ||
var | var s = this, isE = type === 'emergency'; | ||
var cc = 'dt-flag-column' + ( isE ? '' : ' urgent' ) + ( allCleared ? ' all-cleared' : '' ); | |||
var | var items = flags.map( function ( f ) { return s._item( f ); } ).join( '' ); | ||
var | var refs = ''; | ||
var | |||
flags.forEach( function ( f ) { | flags.forEach( function ( f ) { | ||
if ( | if ( s.state[ f.id ] === 'present' ) { | ||
refs += '<div class="dt-flag-referral ' + ( isE ? '' : 'urgent-ref' ) + ' visible">' + | |||
'<strong>⛔ ' + ( isE ? 'EMERGENCY ACTION' : 'URGENT REFERRAL' ) + '</strong>' + | |||
f.action + '</div>'; | |||
} | } | ||
} ); | } ); | ||
return '<div class="' + cc + '">' + | |||
return '<div class="' + | '<div class="dt-flag-col-header"><span class="header-icon">' + ( isE ? '🚨' : '⚠️' ) + '</span>' + | ||
'<div class="dt-flag-col-header"><span class="header-icon">' + | ( isE ? 'Emergency' : 'Urgent Referral' ) + '</div>' + | ||
'<div class="dt-flag-items">' + | '<div class="dt-flag-items">' + items + '</div>' + refs + | ||
'<div class="dt-panel-cleared-msg">✓ All ' + ( isE ? 'emergency' : 'urgent' ) + ' flags cleared</div>' + | |||
'<div class="dt-panel-cleared-msg">✓ All ' + ( | |||
'</div>'; | '</div>'; | ||
}; | }; | ||
RedFlagPanel.prototype. | RedFlagPanel.prototype._item = function ( f ) { | ||
var | var isCl = this.state[ f.id ] === 'cleared'; | ||
var | var isEx = this.expanded === f.id; | ||
var | var cls = 'dt-flag-item' + ( isCl ? ' cleared' : '' ) + ( isEx ? ' expanded' : '' ); | ||
var acts = isCl ? '' : | |||
var | |||
'<div class="dt-flag-actions">' + | '<div class="dt-flag-actions">' + | ||
'<button class="dt-flag-btn present" data-id="' + | '<button class="dt-flag-btn present" data-id="' + f.id + '" data-action="present">Present</button>' + | ||
'<button class="dt-flag-btn absent" data-id="' + | '<button class="dt-flag-btn absent" data-id="' + f.id + '" data-action="absent">Absent — cleared</button>' + | ||
'</div>'; | '</div>'; | ||
return '<div class="' + cls + '" data-id="' + f.id + '">' + | |||
return '<div class="' + | |||
'<div class="dt-flag-checkbox"></div>' + | '<div class="dt-flag-checkbox"></div>' + | ||
'<div class="dt-flag-item-body">' + | '<div class="dt-flag-item-body">' + | ||
'<div class="dt-flag-label">' + | '<div class="dt-flag-label">' + f.label + '</div>' + | ||
'<div class="dt-flag-question">' + | '<div class="dt-flag-question">' + f.question + | ||
'<div class="dt-flag-rationale">' + | '<div class="dt-flag-rationale">' + f.rationale + '</div>' + | ||
'</div>' + acts + | |||
'</div></div>'; | |||
}; | |||
/* ══════════════════════════════════════════════════════ | |||
BROAD DIFFERENTIAL PANEL | |||
══════════════════════════════════════════════════════ */ | |||
function BroadDiffPanel( el, data ) { | |||
this.el = el; this.data = data; | |||
this.open = null; this.collapsed = false; this.locked = true; | |||
this.render(); | |||
} | |||
BroadDiffPanel.prototype.unlock = function () { this.locked = false; this.render(); }; | |||
BroadDiffPanel.prototype.render = function () { | |||
var s = this; | |||
var lockedSection = this.locked | |||
? '<div class="dt-broad-locked-msg">⟳ Complete the emergency & urgent checklist above to unlock</div>' | |||
: '<div class="dt-broad-grid' + ( this.collapsed ? ' hidden' : '' ) + '" id="bd-grid">' + | |||
this.data.map( function ( d ) { return s._card( d ); } ).join( '' ) + | |||
'</div>'; | |||
this.el.innerHTML = | |||
'<div class="dt-broad-panel">' + | |||
'<div class="dt-broad-header">' + | |||
'<div class="dt-broad-header-top">' + | |||
'<div class="dt-broad-title"><span>🔭</span> Broad Differential — Also Consider</div>' + | |||
( !this.locked | |||
? '<button class="dt-broad-toggle" id="bd-toggle">' + ( this.collapsed ? 'Show ▾' : '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>' + | ||
lockedSection + | |||
'</div>' + | '</div>'; | ||
'</div>'; | |||
var tog = this.el.querySelector( '#bd-toggle' ); | |||
if ( tog ) tog.addEventListener( 'click', function () { s.collapsed = !s.collapsed; s.render(); } ); | |||
this.el.querySelectorAll( '.dt-diff-item' ).forEach( function ( el ) { | |||
var id = el.getAttribute( 'data-id' ); | |||
el.addEventListener( 'click', function ( e ) { | |||
if ( e.target.classList.contains( 'dt-wiki-link' ) ) return; | |||
s.open = s.open === id ? null : id; | |||
s.render(); | |||
} ); | |||
} ); | |||
}; | |||
BroadDiffPanel.prototype._card = function ( d ) { | |||
var isOpen = this.open === d.id; | |||
var detail = isOpen | |||
? '<div class="dt-diff-detail">' + | |||
'<div class="dt-diff-distinguisher"><strong>Distinguishing feature</strong>' + d.distinguishing_feature + '</div>' + | |||
'<div class="dt-diff-action"><strong>If suspected</strong>' + d.action + '</div>' + | |||
'</div>' : ''; | |||
return '<div class="dt-diff-item' + ( isOpen ? ' open' : '' ) + '" data-id="' + d.id + '">' + | |||
'<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>' + | |||
detail + '</div>'; | |||
}; | }; | ||
/* | /* ══════════════════════════════════════════════════════ | ||
TREE STATE | |||
══════════════════════════════════════════════════════ */ | |||
function TreeState( tree ) { | function TreeState( tree ) { | ||
this.tree | this.tree = tree; this.history = []; this.current = tree.start; | ||
} | } | ||
TreeState.prototype.go = function ( id ) { this.history.push( this.current ); this.current = id; }; | TreeState.prototype.go = function ( id ) { this.history.push( this.current ); this.current = id; }; | ||
| Line 151: | Line 203: | ||
TreeState.prototype.node = function () { return this.tree.nodes[ this.current ]; }; | TreeState.prototype.node = function () { return this.tree.nodes[ this.current ]; }; | ||
/* | /* ══════════════════════════════════════════════════════ | ||
DIAGNOSTIC TREE ENGINE | |||
══════════════════════════════════════════════════════ */ | |||
function DiagnosticTree( | function DiagnosticTree( el, data ) { | ||
this.el | this.el = el; this.state = new TreeState( data ); this.render(); | ||
} | } | ||
DiagnosticTree.prototype.render = function () { | DiagnosticTree.prototype.render = function () { | ||
var | var s = this, node = this.state.node(), meta = NODE_META[ node.type ] || NODE_META.symptom; | ||
var canBack = this.state.history.length > 0; | var canBack = this.state.history.length > 0; | ||
var steps | var steps = this.state.history.length + 1; | ||
var total | var total = Object.keys( this.state.tree.nodes ).length; | ||
var pct | var pct = Math.min( Math.round( ( steps / total ) * 100 ), 95 ); | ||
var cardHtml; | var cardHtml; | ||
if ( node.type === 'result' | if ( node.type === 'result' ) cardHtml = this._result( node ); | ||
else if ( node.type === 'overlap' ) cardHtml = this. | else if ( node.type === 'overlap' ) cardHtml = this._overlap( node ); | ||
else | else if ( node.type === 'neuro_referral') cardHtml = this._neuroRef( node ); | ||
else cardHtml = this._question( node, meta ); | |||
this.el.innerHTML = | this.el.innerHTML = | ||
| Line 186: | Line 235: | ||
cardHtml; | cardHtml; | ||
var | var b = function ( sel, fn ) { | ||
var | var el = s.el.querySelector( sel ); | ||
var | if ( el ) el.addEventListener( 'click', fn ); | ||
}; | |||
b( '#dt-back', function () { s.state.back(); s.render(); } ); | |||
b( '#dt-reset', function () { s.state.reset(); s.render(); } ); | |||
b( '#dt-yes', function () { var n = s.state.node(); if ( n.yes ) { s.state.go( n.yes ); s.render(); } } ); | |||
b( '#dt-no', function () { var n = s.state.node(); if ( n.no ) { s.state.go( n.no ); s.render(); } } ); | |||
this.el.querySelectorAll( '.dt-division-tab' ).forEach( function ( tab ) { | |||
tab.addEventListener( 'click', function () { | |||
var panel = tab.getAttribute( 'data-panel' ); | |||
s.el.querySelectorAll( '.dt-division-tab' ).forEach( function ( t ) { t.classList.remove( 'active' ); } ); | |||
s.el.querySelectorAll( '.dt-division-panel' ).forEach( function ( p ) { p.classList.remove( 'active' ); } ); | |||
tab.classList.add( 'active' ); | |||
var target = s.el.querySelector( '#' + panel ); | |||
if ( target ) target.classList.add( 'active' ); | |||
} ); | |||
} ); | |||
this.el.querySelectorAll( '.dt-wiki-link' ).forEach( function ( btn ) { | |||
btn.addEventListener( 'click', function ( e ) { | |||
e.stopPropagation(); | |||
var page = btn.getAttribute( 'data-page' ); | |||
if ( page ) window.location.href = mw.util.getUrl( page ); | |||
} ); | |||
} | |||
} ); | } ); | ||
}; | }; | ||
DiagnosticTree.prototype. | DiagnosticTree.prototype._question = function ( node, meta ) { | ||
var | var mv = node.movement ? '<div class="dt-tag dt-tag-blue">' + node.movement + ' (' + ( node.direction || '' ) + ')</div>' : ''; | ||
var | var nv = node.nerve ? '<div class="dt-tag dt-tag-purple">Nerve: ' + node.nerve + '</div>' : ''; | ||
var | var et = node.exam_type ? '<div class="dt-tag dt-tag-green">' + node.exam_type.replace( /_/g, ' ' ) + '</div>' : ''; | ||
var | var rat = node.clinical_rationale || node.distinguishing_feature || ''; | ||
var | var lm = node.landmark ? '<div class="dt-landmark">📍 ' + node.landmark + '</div>' : ''; | ||
var | var pf = node.positive_finding? '<div class="dt-positive">✓ Positive: ' + node.positive_finding + '</div>' : ''; | ||
var | var nf = node.negative_finding? '<div class="dt-negative">✗ Negative: ' + node.negative_finding + '</div>' : ''; | ||
var | var imp = node.muscles_implicated ? '<div class="dt-muscle-hint dt-implicated">Suggests: ' + node.muscles_implicated.join( ', ' ) + '</div>' : ''; | ||
var exc = node.muscles_excluded ? '<div class="dt-muscle-hint dt-excluded">Argues against: ' + node.muscles_excluded.join( ', ' ) + '</div>' : ''; | |||
return '<div class="dt-card" style="border-color:' + meta.color + '22">' + | return '<div class="dt-card" style="border-color:' + meta.color + '22">' + | ||
'<div class="dt-type-badge" style="background:' + meta.color + '">' + meta.icon + ' ' + meta.label + '</div>' + | '<div class="dt-type-badge" style="background:' + meta.color + '">' + meta.icon + ' ' + meta.label + '</div>' + | ||
'<div class="dt-question">' + node.question + '</div>' + | '<div class="dt-question">' + node.question + '</div>' + | ||
( ( | ( ( mv || nv || et ) ? '<div class="dt-tags">' + mv + nv + et + '</div>' : '' ) + | ||
lm + pf + nf + | |||
( | ( rat ? '<div class="dt-rationale">' + rat + '</div>' : '' ) + | ||
imp + exc + | |||
'<div class="dt-answers">' + | '<div class="dt-answers">' + | ||
'<button class="dt-answer dt-answer-yes" id="dt-yes">Yes</button>' + | '<button class="dt-answer dt-answer-yes" id="dt-yes">Yes</button>' + | ||
'<button class="dt-answer dt-answer-no" id="dt-no">No</button>' | '<button class="dt-answer dt-answer-no" id="dt-no">No</button>' + | ||
'</div>' + | '</div>' + | ||
'</div>'; | '</div>'; | ||
}; | }; | ||
DiagnosticTree.prototype. | DiagnosticTree.prototype._result = function ( node ) { | ||
var | var conf = node.confidence || 'moderate'; | ||
var | var cc = { high: '#15803d', moderate: '#b45309', low: '#b91c1c' }[ conf ] || '#78716c'; | ||
var | var also = node.also_consider && node.also_consider.length | ||
? '<div class="dt-also">Also consider: ' + node.also_consider.join( ', ' ) + '</div>' : ''; | ? '<div class="dt-also">Also consider: ' + node.also_consider.join( ', ' ) + '</div>' : ''; | ||
/* Confirmatory checklist */ | |||
var confirmatoryHtml = ''; | |||
if ( node.confirmatory && node.confirmatory.length ) { | |||
confirmatoryHtml = | |||
'<div class="dt-confirmatory">' + | |||
'<div class="dt-confirmatory-header">✓ Confirm at point of diagnosis</div>' + | |||
'<div class="dt-confirmatory-items">' + | |||
node.confirmatory.map( function ( c ) { | |||
return '<div class="dt-confirmatory-item"><div class="dt-confirmatory-dot"></div>' + c + '</div>'; | |||
} ).join( '' ) + | |||
'</div>' + | |||
'</div>'; | |||
} | |||
/* Division tabs — SCM */ | |||
var divisionHtml = ''; | |||
if ( node.division ) { | |||
divisionHtml = | |||
'<div class="dt-division-tabs">' + | |||
'<button class="dt-division-tab active" data-panel="div-sternal">Sternal Division</button>' + | |||
'<button class="dt-division-tab" data-panel="div-clavicular">Clavicular Division</button>' + | |||
'</div>' + | |||
'<div class="dt-division-panel active" id="div-sternal">' + | |||
'<div class="dt-division-content">' + | |||
'<strong>Primary symptoms:</strong> Cheek, temple, orbit, and supraorbital pain. Sore neck sometimes misattributed to lymphadenopathy.' + | |||
'<ul>' + | |||
'<li>Profuse tearing — often more alarming to patient than pain</li>' + | |||
'<li>Conjunctival redness, rhinitis</li>' + | |||
'<li>Apparent ptosis via palpebral fissure narrowing on TrP side</li>' + | |||
'<li>Visual disturbance with strongly contrasted vertical lines (e.g. venetian blinds)</li>' + | |||
'<li>Head tilts to side of TrPs — pain on holding head upright</li>' + | |||
'<li>Patient prefers to lie on sore side with pillow so face does not bear weight</li>' + | |||
'</ul>' + | |||
'</div>' + | |||
'</div>' + | |||
'<div class="dt-division-panel" id="div-clavicular">' + | |||
'<div class="dt-division-content">' + | |||
'<strong>Any one of three may dominate:</strong> Frontal headache, postural dizziness/imbalance, or dysmetria.' + | |||
'<ul>' + | |||
'<li>Dizziness worsens on changing head load, lying without pillow, or quick rotation</li>' + | |||
'<li>Turning over in bed — patient should roll head on pillow, not lift it</li>' + | |||
'<li>Adds to car sickness or sea sickness; may report nausea or anorexia</li>' + | |||
'<li>Transient loss of equilibrium after vigorous or quick head/neck rotation</li>' + | |||
'<li>Rarely: hearing impaired on same side as clavicular TrPs</li>' + | |||
'</ul>' + | |||
'</div>' + | |||
'</div>'; | |||
} | |||
/* Horner confirmation */ | |||
var hornerHtml = node.division | |||
? '<div class="dt-confirm-panel">' + | |||
'<div class="dt-confirm-header">🔍 Confirmatory — Horner Syndrome Exclusion</div>' + | |||
'<div class="dt-confirm-items">' + | |||
'<div class="dt-confirm-item"><div class="dt-confirm-dot"></div>Pupils equal and reactive — no miosis</div>' + | |||
'<div class="dt-confirm-item"><div class="dt-confirm-dot"></div>No enophthalmos — apparent ptosis is from palpebral fissure narrowing only</div>' + | |||
'<div class="dt-confirm-item"><div class="dt-confirm-dot"></div>Ciliospinal reflex present — pinch skin on back of neck → ipsilateral pupil dilates</div>' + | |||
'<div class="dt-confirm-item"><div class="dt-confirm-dot"></div>Extraocular movements full — no paralysis; symptoms not conversion hysteria</div>' + | |||
'</div>' + | |||
'</div>' : ''; | |||
/* Satellite TrPs */ | |||
var satelliteHtml = ''; | |||
if ( node.satellite_trps && node.satellite_trps.length ) { | |||
satelliteHtml = | |||
'<div class="dt-satellite-section">' + | |||
'<div class="dt-satellite-header">Satellite TrPs — may activate from or with this muscle</div>' + | |||
'<div class="dt-satellite-grid">' + | |||
node.satellite_trps.map( function ( t ) { | |||
return '<span class="dt-satellite-tag">' + t + '</span>'; | |||
} ).join( '' ) + | |||
'</div>' + | |||
'</div>'; | |||
} | |||
/* Landing page signposting */ | |||
var landingHtml = ''; | |||
if ( node.landing_page_topics && node.landing_page_topics.length ) { | |||
landingHtml = | |||
'<div class="dt-landing-panel">' + | |||
'<div class="dt-landing-header">📋 Further assessment — muscle landing page</div>' + | |||
'<div class="dt-landing-body">' + | |||
'The following clinical detail goes beyond the scope of this algorithm and is covered on the muscle page:' + | |||
'<ul class="dt-landing-list">' + | |||
node.landing_page_topics.map( function ( t ) { return '<li>' + t + '</li>'; } ).join( '' ) + | |||
'</ul>' + | |||
'</div>' + | |||
'</div>'; | |||
} | |||
/* Related page links */ | |||
var relatedHtml = node.related_pages && node.related_pages.length | |||
? node.related_pages.map( function ( p ) { | |||
return '<button class="dt-wiki-link satellite" data-page="' + p.page + '">' + p.label + '</button>'; | |||
} ).join( '' ) : ''; | |||
return '<div class="dt-card dt-card-result">' + | return '<div class="dt-card dt-card-result">' + | ||
'<div class="dt-type-badge" style="background:#15803d">✅ Likely Diagnosis</div>' + | '<div class="dt-type-badge" style="background:#15803d">✅ Likely Diagnosis</div>' + | ||
'<div class="dt-result-name">' + node.diagnosis + '</div>' + | '<div class="dt-result-name">' + node.diagnosis + '</div>' + | ||
'<div class="dt-confidence" style="color:' + | '<div class="dt-confidence" style="color:' + cc + '">Confidence: ' + conf + '</div>' + | ||
divisionHtml + | |||
( node.notes ? '<div class="dt-notes">' + node.notes + '</div>' : '' ) + | ( node.notes ? '<div class="dt-notes">' + node.notes + '</div>' : '' ) + | ||
( node.treatment_hint ? '<div class="dt-treatment">Treatment: ' + node.treatment_hint + '</div>' : '' ) + | ( node.treatment_hint ? '<div class="dt-treatment">Treatment: ' + node.treatment_hint + '</div>' : '' ) + | ||
( node.chapter_ref ? '<div class="dt-chapter">📖 ' + node.chapter_ref + '</div>' : '' ) + | ( node.chapter_ref ? '<div class="dt-chapter">📖 ' + node.chapter_ref + '</div>' : '' ) + | ||
also + confirmatoryHtml + hornerHtml + satelliteHtml + landingHtml + | |||
( node.wiki_page ? '<button class="dt-wiki-link" data-page="' + node.wiki_page + '">Open Wiki Page →</button>' : '' ) + | ( node.wiki_page ? '<button class="dt-wiki-link" data-page="' + node.wiki_page + '">Open Wiki Page →</button>' : '' ) + | ||
relatedHtml + | |||
'<div class="dt-broad-reminder">' + | |||
'<span class="dt-broad-reminder-icon">🔭</span>' + | |||
'<div><strong>Before concluding</strong>' + | |||
'Review the broad differential panel below — several conditions in this region closely mimic this presentation.</div>' + | |||
'</div>' + | |||
'</div>'; | '</div>'; | ||
}; | }; | ||
DiagnosticTree.prototype. | DiagnosticTree.prototype._neuroRef = function ( node ) { | ||
var | var isE = node.urgency === 'emergency'; | ||
? '< | var color = isE ? 'var(--emergency)' : 'var(--urgent)'; | ||
var bdrCl = isE ? 'var(--emergency-border)' : 'var(--urgent-border)'; | |||
var bgCl = isE ? 'var(--emergency-bg)' : 'var(--urgent-bg)'; | |||
return '<div class="dt-card dt-card-neuro-ref" style="border-color:' + bdrCl + ';background:' + bgCl + '">' + | |||
'<div class="dt-type-badge" style="background:' + color + '">' + ( isE ? '🚨 Emergency' : '⚠️ Urgent Referral' ) + '</div>' + | |||
'<div class="dt-neuro-title">' + node.title + '</div>' + | |||
'<div class="dt-neuro-body">' + node.body + '</div>' + | |||
'<div class="dt-treatment">' + node.action + '</div>' + | |||
'</div>'; | |||
}; | |||
DiagnosticTree.prototype._overlap = function ( node ) { | |||
var sl = node.screen_these && node.screen_these.length | |||
? '<ul class="dt-screen-list">' + | |||
node.screen_these.map( function ( m ) { return '<li>' + m + '</li>'; } ).join( '' ) + | |||
'</ul>' : ''; | |||
return '<div class="dt-card dt-card-overlap">' + | return '<div class="dt-card dt-card-overlap">' + | ||
'<div class="dt-type-badge" style="background:#78716c">🔍 Inconclusive</div>' + | '<div class="dt-type-badge" style="background:#78716c">🔍 Inconclusive</div>' + | ||
'<div class="dt-overlap-text">' + ( node.text || '' ) + '</div>' + | '<div class="dt-overlap-text">' + ( node.text || '' ) + '</div>' + sl + | ||
( node.wiki_page ? '<button class="dt-wiki-link" data-page="' + node.wiki_page + '">Open Differential Page →</button>' : '' ) + | ( node.wiki_page ? '<button class="dt-wiki-link" data-page="' + node.wiki_page + '">Open Differential Page →</button>' : '' ) + | ||
'<div class="dt-broad-reminder">' + | |||
'<span class="dt-broad-reminder-icon">🔭</span>' + | |||
'<div><strong>Before concluding</strong>' + | |||
'Review the broad differential panel below — several conditions in this region closely mimic these findings.</div>' + | |||
'</div>' + | |||
'</div>'; | '</div>'; | ||
}; | }; | ||
/* | /* ══════════════════════════════════════════════════════ | ||
BOOT SHELL — builds key toggle, red flags, algo, broad diff | |||
══════════════════════════════════════════════════════ */ | |||
function | function bootShell( hostEl, treeData ) { | ||
var | var shell = document.createElement( 'div' ); | ||
if ( ! | shell.className = 'dt-shell'; | ||
hostEl.appendChild( shell ); | |||
var hasNerveTree = !!hostEl.getAttribute( 'data-nerve-tree-page' ); | |||
/* 0 — Key toggle tabs */ | |||
var keyToggle = document.createElement( 'div' ); | |||
keyToggle.className = 'dt-key-toggle'; | |||
keyToggle.innerHTML = | |||
'<button class="dt-key-tab pain-key active" id="dt-tab-pain">' + | |||
'<span class="tab-icon">🩺</span> Pain Pattern Key' + | |||
'</button>' + | |||
'<button class="dt-key-tab nerve-key' + ( hasNerveTree ? '' : ' coming-soon' ) + '" id="dt-tab-nerve"' + | |||
( hasNerveTree ? '' : ' title="Nerve entrapment key coming soon"' ) + '>' + | |||
'<span class="tab-icon">⚡</span> Nerve Entrapment Key' + | |||
( hasNerveTree ? '' : '<span class="tab-soon">coming soon</span>' ) + | |||
'</button>'; | |||
shell.appendChild( keyToggle ); | |||
/* 1 — Red flag panel (shared) */ | |||
var panelEl = document.createElement( 'div' ); | |||
shell.appendChild( panelEl ); | |||
/* 2 — Pain algorithm section */ | |||
var algoSection = document.createElement( 'div' ); | |||
algoSection.className = 'dt-algo-section locked'; | |||
algoSection.innerHTML = | |||
'<div class="dt-algo-header">' + | |||
'<div class="dt-algo-header-left">🩺 Pain Pattern Algorithm</div>' + | |||
'<div class="dt-algo-header-right">Complete checklist above to unlock</div>' + | |||
'</div>' + | |||
'<div class="dt-algo-body" id="dt-algo-body">' + | |||
'<div class="dt-tree-locked-msg">Work through all emergency and urgent flags above before proceeding.</div>' + | |||
'</div>'; | |||
shell.appendChild( algoSection ); | |||
/* 2b — Nerve section (placeholder or loaded) */ | |||
var nerveSection = document.createElement( 'div' ); | |||
nerveSection.style.display = 'none'; | |||
if ( !hasNerveTree ) { | |||
nerveSection.innerHTML = | |||
'<div class="dt-algo-section" style="border-color:var(--nerve-header-border)">' + | |||
'<div class="dt-algo-header" style="background:var(--nerve-header)">' + | |||
'<div class="dt-algo-header-left">⚡ Nerve Entrapment Key</div>' + | |||
'</div>' + | |||
'<div class="dt-algo-body">' + | |||
'<div class="dt-tree-locked-msg" style="color:#6d28d9">' + | |||
'The nerve entrapment key for this region is under development.<br>' + | |||
'It will differentiate muscles by their nerve entrapment signatures.<br><br>' + | |||
'<small>Refer to individual muscle pages for entrapment detail in the meantime.</small>' + | |||
'</div>' + | |||
'</div>' + | |||
'</div>'; | |||
} | |||
shell.appendChild( nerveSection ); | |||
/* Tab switching */ | |||
var tabPain = shell.querySelector( '#dt-tab-pain' ); | |||
var tabNerve = shell.querySelector( '#dt-tab-nerve' ); | |||
tabPain.addEventListener( 'click', function () { | |||
tabPain.classList.add( 'active' ); | |||
tabNerve.classList.remove( 'active' ); | |||
algoSection.style.display = ''; | |||
nerveSection.style.display = 'none'; | |||
} ); | |||
tabNerve.addEventListener( 'click', function () { | |||
tabNerve.classList.add( 'active' ); | |||
tabPain.classList.remove( 'active' ); | |||
algoSection.style.display = 'none'; | |||
nerveSection.style.display = ''; | |||
} ); | |||
/* 3 — Broad differential (shared) */ | |||
var div2 = document.createElement( 'div' ); | |||
div2.className = 'dt-divider'; | |||
div2.textContent = 'Low Probability — Broad Differential'; | |||
shell.appendChild( div2 ); | |||
var broadEl = document.createElement( 'div' ); | |||
shell.appendChild( broadEl ); | |||
var broadPanel = new BroadDiffPanel( broadEl, treeData.broad_differential || [] ); | |||
/* 4 — Unlock on red flags cleared */ | |||
var tree = null; | |||
new RedFlagPanel( panelEl, treeData.redflags, function () { | |||
algoSection.classList.remove( 'locked' ); | |||
algoSection.classList.add( 'unlocked' ); | |||
var body = document.getElementById( 'dt-algo-body' ); | |||
body.innerHTML = ''; | |||
if ( !tree ) tree = new DiagnosticTree( body, treeData ); | |||
broadPanel.unlock(); | |||
} ); | |||
} | |||
/* ══════════════════════════════════════════════════════ | |||
API LOADER — fetch JSON from wiki page | |||
══════════════════════════════════════════════════════ */ | |||
function loadPage( pageName, callback ) { | |||
var api = new mw.Api(); | var api = new mw.Api(); | ||
api.get( { | api.get( { | ||
action: 'query', | action: 'query', | ||
titles: | titles: pageName, | ||
prop: 'revisions', | prop: 'revisions', | ||
rvprop: 'content', | rvprop: 'content', | ||
| Line 276: | Line 557: | ||
var pages = data.query.pages; | var pages = data.query.pages; | ||
var pageId = Object.keys( pages )[ 0 ]; | var pageId = Object.keys( pages )[ 0 ]; | ||
if ( pageId === '-1' ) { | if ( pageId === '-1' ) { callback( null, 'Page not found: ' + pageName ); return; } | ||
var content = pages[ pageId ].revisions[ 0 ].slots.main[ '*' ]; | var content = pages[ pageId ].revisions[ 0 ].slots.main[ '*' ]; | ||
/* Strip any wiki markup after the closing } (e.g. category tags) */ | |||
var jsonEnd = content.lastIndexOf( '}' ); | |||
try { | try { | ||
callback( JSON.parse( content.substring( 0, jsonEnd + 1 ) ), null ); | |||
} catch ( e ) { | } catch ( e ) { | ||
callback( null, 'Invalid JSON in ' + pageName + ': ' + e.message ); | |||
} | } | ||
} ).fail( function () { | } ).fail( function () { | ||
callback( null, 'API request failed for: ' + pageName ); | |||
} ); | } ); | ||
} | } | ||
function | function bootHost( hostEl ) { | ||
var treePage = hostEl.getAttribute( 'data-tree-page' ); | |||
var | if ( !treePage ) return; | ||
hostEl.innerHTML = '<div style="padding:1em;font-family:monospace;font-size:0.8em;color:#999">Loading...</div>'; | |||
loadPage( treePage, function ( treeData, err ) { | |||
hostEl.innerHTML = ''; | |||
if ( err ) { | |||
hostEl.innerHTML = '<div class="dt-error">' + err + '</div>'; | |||
return; | |||
} | |||
bootShell( hostEl, treeData ); | |||
} ); | |||
} | |||
} | } | ||
/* | /* ══════════════════════════════════════════════════════ | ||
MEDIAWIKI ENTRY POINT | |||
══════════════════════════════════════════════════════ */ | |||
function init() { | function init() { | ||
document.querySelectorAll( '.diagnostic-tree-host' ).forEach( function ( el ) { | document.querySelectorAll( '.diagnostic-tree-host' ).forEach( function ( el ) { | ||
Revision as of 01:01, 12 April 2026
/**
* DiagnosticTree.js — v6
* Copy entire contents to: MediaWiki:Gadget-DiagnosticTree.js
*
* EMBED ON ANY WIKI PAGE:
* <div class="diagnostic-tree-host"
* data-tree-page="DiagnosticTree/EarTMJ"
* data-nerve-tree-page="DiagnosticTree/EarTMJ_nerve">
* </div>
*
* data-nerve-tree-page is optional — omit it and the nerve tab
* shows as "coming soon" automatically.
*
* JSON PAGE STRUCTURE (data-tree-page):
* {
* "tree_id": "...",
* "region": "...",
* "start": "first-node-id",
* "redflags": { "emergency": [...], "urgent": [...] },
* "broad_differential": [...],
* "nodes": { ... }
* }
*/
( function () {
'use strict';
/* ══════════════════════════════════════════════════════
NODE META
══════════════════════════════════════════════════════ */
var NODE_META = {
rom: { color:'#1d4ed8', icon:'🔄', label:'Movement Screen' },
nerve_entrapment: { color:'#6d28d9', icon:'⚡', label:'Nerve Screen' },
symptom: { color:'#0f766e', icon:'💬', label:'Patient Symptom' },
examination: { color:'#15803d', icon:'🩺', label:'Examination' },
result: { color:'#15803d', icon:'✅', label:'Diagnosis' },
overlap: { color:'#78716c', icon:'🔍', label:'Inconclusive' },
neuro_referral: { color:'#b91c1c', icon:'🧠', label:'Neurological Referral' }
};
/* ══════════════════════════════════════════════════════
RED FLAG PANEL
══════════════════════════════════════════════════════ */
function RedFlagPanel( el, data, onCleared ) {
this.el = el; this.data = data; this.onCleared = onCleared;
this.state = {}; this.expanded = null;
data.emergency.forEach( function ( f ) { this.state[ f.id ] = 'pending'; }, this );
data.urgent.forEach( function ( f ) { this.state[ f.id ] = 'pending'; }, this );
this.render();
}
RedFlagPanel.prototype.allCleared = function () {
return Object.keys( this.state ).every( function ( k ) {
return this.state[ k ] === 'cleared';
}, this );
};
RedFlagPanel.prototype.render = function () {
var s = this;
var eC = this.data.emergency.every( function ( f ) { return s.state[ f.id ] === 'cleared'; } );
var uC = this.data.urgent.every( function ( f ) { return s.state[ f.id ] === 'cleared'; } );
this.el.innerHTML =
'<div class="dt-redflag-panel">' +
this._col( 'emergency', this.data.emergency, eC ) +
this._col( 'urgent', this.data.urgent, uC ) +
'</div>';
this.el.querySelectorAll( '.dt-flag-item' ).forEach( function ( el ) {
var id = el.getAttribute( 'data-id' );
el.addEventListener( 'click', function ( e ) {
if ( e.target.classList.contains( 'dt-flag-btn' ) ) return;
if ( s.state[ id ] === 'cleared' ) return;
s.expanded = s.expanded === id ? null : id;
s.render();
} );
} );
this.el.querySelectorAll( '.dt-flag-btn' ).forEach( function ( btn ) {
btn.addEventListener( 'click', function ( e ) {
e.stopPropagation();
var id = btn.getAttribute( 'data-id' );
var act = btn.getAttribute( 'data-action' );
s.state[ id ] = act === 'present' ? 'present' : 'cleared';
s.expanded = act === 'present' ? id : null;
s.render();
if ( s.allCleared() ) s.onCleared();
} );
} );
};
RedFlagPanel.prototype._col = function ( type, flags, allCleared ) {
var s = this, isE = type === 'emergency';
var cc = 'dt-flag-column' + ( isE ? '' : ' urgent' ) + ( allCleared ? ' all-cleared' : '' );
var items = flags.map( function ( f ) { return s._item( f ); } ).join( '' );
var refs = '';
flags.forEach( function ( f ) {
if ( s.state[ f.id ] === 'present' ) {
refs += '<div class="dt-flag-referral ' + ( isE ? '' : 'urgent-ref' ) + ' visible">' +
'<strong>⛔ ' + ( isE ? 'EMERGENCY ACTION' : 'URGENT REFERRAL' ) + '</strong>' +
f.action + '</div>';
}
} );
return '<div class="' + cc + '">' +
'<div class="dt-flag-col-header"><span class="header-icon">' + ( isE ? '🚨' : '⚠️' ) + '</span>' +
( isE ? 'Emergency' : 'Urgent Referral' ) + '</div>' +
'<div class="dt-flag-items">' + items + '</div>' + refs +
'<div class="dt-panel-cleared-msg">✓ All ' + ( isE ? 'emergency' : 'urgent' ) + ' flags cleared</div>' +
'</div>';
};
RedFlagPanel.prototype._item = function ( f ) {
var isCl = this.state[ f.id ] === 'cleared';
var isEx = this.expanded === f.id;
var cls = 'dt-flag-item' + ( isCl ? ' cleared' : '' ) + ( isEx ? ' expanded' : '' );
var acts = isCl ? '' :
'<div class="dt-flag-actions">' +
'<button class="dt-flag-btn present" data-id="' + f.id + '" data-action="present">Present</button>' +
'<button class="dt-flag-btn absent" data-id="' + f.id + '" data-action="absent">Absent — cleared</button>' +
'</div>';
return '<div class="' + cls + '" data-id="' + f.id + '">' +
'<div class="dt-flag-checkbox"></div>' +
'<div class="dt-flag-item-body">' +
'<div class="dt-flag-label">' + f.label + '</div>' +
'<div class="dt-flag-question">' + f.question +
'<div class="dt-flag-rationale">' + f.rationale + '</div>' +
'</div>' + acts +
'</div></div>';
};
/* ══════════════════════════════════════════════════════
BROAD DIFFERENTIAL PANEL
══════════════════════════════════════════════════════ */
function BroadDiffPanel( el, data ) {
this.el = el; this.data = data;
this.open = null; this.collapsed = false; this.locked = true;
this.render();
}
BroadDiffPanel.prototype.unlock = function () { this.locked = false; this.render(); };
BroadDiffPanel.prototype.render = function () {
var s = this;
var lockedSection = this.locked
? '<div class="dt-broad-locked-msg">⟳ Complete the emergency & urgent checklist above to unlock</div>'
: '<div class="dt-broad-grid' + ( this.collapsed ? ' hidden' : '' ) + '" id="bd-grid">' +
this.data.map( function ( d ) { return s._card( d ); } ).join( '' ) +
'</div>';
this.el.innerHTML =
'<div class="dt-broad-panel">' +
'<div class="dt-broad-header">' +
'<div class="dt-broad-header-top">' +
'<div class="dt-broad-title"><span>🔭</span> Broad Differential — Also Consider</div>' +
( !this.locked
? '<button class="dt-broad-toggle" id="bd-toggle">' + ( this.collapsed ? 'Show ▾' : '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>' +
lockedSection +
'</div>';
var tog = this.el.querySelector( '#bd-toggle' );
if ( tog ) tog.addEventListener( 'click', function () { s.collapsed = !s.collapsed; s.render(); } );
this.el.querySelectorAll( '.dt-diff-item' ).forEach( function ( el ) {
var id = el.getAttribute( 'data-id' );
el.addEventListener( 'click', function ( e ) {
if ( e.target.classList.contains( 'dt-wiki-link' ) ) return;
s.open = s.open === id ? null : id;
s.render();
} );
} );
};
BroadDiffPanel.prototype._card = function ( d ) {
var isOpen = this.open === d.id;
var detail = isOpen
? '<div class="dt-diff-detail">' +
'<div class="dt-diff-distinguisher"><strong>Distinguishing feature</strong>' + d.distinguishing_feature + '</div>' +
'<div class="dt-diff-action"><strong>If suspected</strong>' + d.action + '</div>' +
'</div>' : '';
return '<div class="dt-diff-item' + ( isOpen ? ' open' : '' ) + '" data-id="' + d.id + '">' +
'<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>' +
detail + '</div>';
};
/* ══════════════════════════════════════════════════════
TREE STATE
══════════════════════════════════════════════════════ */
function TreeState( tree ) {
this.tree = tree; this.history = []; this.current = tree.start;
}
TreeState.prototype.go = function ( id ) { this.history.push( this.current ); this.current = id; };
TreeState.prototype.back = function () { if ( this.history.length ) this.current = this.history.pop(); };
TreeState.prototype.reset = function () { this.history = []; this.current = this.tree.start; };
TreeState.prototype.node = function () { return this.tree.nodes[ this.current ]; };
/* ══════════════════════════════════════════════════════
DIAGNOSTIC TREE ENGINE
══════════════════════════════════════════════════════ */
function DiagnosticTree( el, data ) {
this.el = el; this.state = new TreeState( data ); this.render();
}
DiagnosticTree.prototype.render = function () {
var s = this, node = this.state.node(), meta = NODE_META[ node.type ] || NODE_META.symptom;
var canBack = this.state.history.length > 0;
var steps = this.state.history.length + 1;
var total = Object.keys( this.state.tree.nodes ).length;
var pct = Math.min( Math.round( ( steps / total ) * 100 ), 95 );
var cardHtml;
if ( node.type === 'result' ) cardHtml = this._result( node );
else if ( node.type === 'overlap' ) cardHtml = this._overlap( node );
else if ( node.type === 'neuro_referral') cardHtml = this._neuroRef( node );
else cardHtml = this._question( node, meta );
this.el.innerHTML =
'<div class="dt-tree-header">' +
'<span class="dt-region-label">Diagnostic algorithm</span>' +
'<div class="dt-controls">' +
( canBack ? '<button class="dt-btn dt-btn-back" id="dt-back">← Back</button>' : '' ) +
'<button class="dt-btn" id="dt-reset">↺ Restart</button>' +
'</div>' +
'</div>' +
'<div class="dt-progress-wrap"><div class="dt-progress-bar" style="width:' + pct + '%"></div></div>' +
'<div class="dt-step-label">Step ' + steps + '</div>' +
cardHtml;
var b = function ( sel, fn ) {
var el = s.el.querySelector( sel );
if ( el ) el.addEventListener( 'click', fn );
};
b( '#dt-back', function () { s.state.back(); s.render(); } );
b( '#dt-reset', function () { s.state.reset(); s.render(); } );
b( '#dt-yes', function () { var n = s.state.node(); if ( n.yes ) { s.state.go( n.yes ); s.render(); } } );
b( '#dt-no', function () { var n = s.state.node(); if ( n.no ) { s.state.go( n.no ); s.render(); } } );
this.el.querySelectorAll( '.dt-division-tab' ).forEach( function ( tab ) {
tab.addEventListener( 'click', function () {
var panel = tab.getAttribute( 'data-panel' );
s.el.querySelectorAll( '.dt-division-tab' ).forEach( function ( t ) { t.classList.remove( 'active' ); } );
s.el.querySelectorAll( '.dt-division-panel' ).forEach( function ( p ) { p.classList.remove( 'active' ); } );
tab.classList.add( 'active' );
var target = s.el.querySelector( '#' + panel );
if ( target ) target.classList.add( 'active' );
} );
} );
this.el.querySelectorAll( '.dt-wiki-link' ).forEach( function ( btn ) {
btn.addEventListener( 'click', function ( e ) {
e.stopPropagation();
var page = btn.getAttribute( 'data-page' );
if ( page ) window.location.href = mw.util.getUrl( page );
} );
} );
};
DiagnosticTree.prototype._question = function ( node, meta ) {
var mv = node.movement ? '<div class="dt-tag dt-tag-blue">' + node.movement + ' (' + ( node.direction || '' ) + ')</div>' : '';
var nv = node.nerve ? '<div class="dt-tag dt-tag-purple">Nerve: ' + node.nerve + '</div>' : '';
var et = node.exam_type ? '<div class="dt-tag dt-tag-green">' + node.exam_type.replace( /_/g, ' ' ) + '</div>' : '';
var rat = node.clinical_rationale || node.distinguishing_feature || '';
var lm = node.landmark ? '<div class="dt-landmark">📍 ' + node.landmark + '</div>' : '';
var pf = node.positive_finding? '<div class="dt-positive">✓ Positive: ' + node.positive_finding + '</div>' : '';
var nf = node.negative_finding? '<div class="dt-negative">✗ Negative: ' + node.negative_finding + '</div>' : '';
var imp = node.muscles_implicated ? '<div class="dt-muscle-hint dt-implicated">Suggests: ' + node.muscles_implicated.join( ', ' ) + '</div>' : '';
var exc = node.muscles_excluded ? '<div class="dt-muscle-hint dt-excluded">Argues against: ' + node.muscles_excluded.join( ', ' ) + '</div>' : '';
return '<div class="dt-card" style="border-color:' + meta.color + '22">' +
'<div class="dt-type-badge" style="background:' + meta.color + '">' + meta.icon + ' ' + meta.label + '</div>' +
'<div class="dt-question">' + node.question + '</div>' +
( ( mv || nv || et ) ? '<div class="dt-tags">' + mv + nv + et + '</div>' : '' ) +
lm + pf + nf +
( rat ? '<div class="dt-rationale">' + rat + '</div>' : '' ) +
imp + exc +
'<div class="dt-answers">' +
'<button class="dt-answer dt-answer-yes" id="dt-yes">Yes</button>' +
'<button class="dt-answer dt-answer-no" id="dt-no">No</button>' +
'</div>' +
'</div>';
};
DiagnosticTree.prototype._result = function ( node ) {
var conf = node.confidence || 'moderate';
var cc = { high: '#15803d', moderate: '#b45309', low: '#b91c1c' }[ conf ] || '#78716c';
var also = node.also_consider && node.also_consider.length
? '<div class="dt-also">Also consider: ' + node.also_consider.join( ', ' ) + '</div>' : '';
/* Confirmatory checklist */
var confirmatoryHtml = '';
if ( node.confirmatory && node.confirmatory.length ) {
confirmatoryHtml =
'<div class="dt-confirmatory">' +
'<div class="dt-confirmatory-header">✓ Confirm at point of diagnosis</div>' +
'<div class="dt-confirmatory-items">' +
node.confirmatory.map( function ( c ) {
return '<div class="dt-confirmatory-item"><div class="dt-confirmatory-dot"></div>' + c + '</div>';
} ).join( '' ) +
'</div>' +
'</div>';
}
/* Division tabs — SCM */
var divisionHtml = '';
if ( node.division ) {
divisionHtml =
'<div class="dt-division-tabs">' +
'<button class="dt-division-tab active" data-panel="div-sternal">Sternal Division</button>' +
'<button class="dt-division-tab" data-panel="div-clavicular">Clavicular Division</button>' +
'</div>' +
'<div class="dt-division-panel active" id="div-sternal">' +
'<div class="dt-division-content">' +
'<strong>Primary symptoms:</strong> Cheek, temple, orbit, and supraorbital pain. Sore neck sometimes misattributed to lymphadenopathy.' +
'<ul>' +
'<li>Profuse tearing — often more alarming to patient than pain</li>' +
'<li>Conjunctival redness, rhinitis</li>' +
'<li>Apparent ptosis via palpebral fissure narrowing on TrP side</li>' +
'<li>Visual disturbance with strongly contrasted vertical lines (e.g. venetian blinds)</li>' +
'<li>Head tilts to side of TrPs — pain on holding head upright</li>' +
'<li>Patient prefers to lie on sore side with pillow so face does not bear weight</li>' +
'</ul>' +
'</div>' +
'</div>' +
'<div class="dt-division-panel" id="div-clavicular">' +
'<div class="dt-division-content">' +
'<strong>Any one of three may dominate:</strong> Frontal headache, postural dizziness/imbalance, or dysmetria.' +
'<ul>' +
'<li>Dizziness worsens on changing head load, lying without pillow, or quick rotation</li>' +
'<li>Turning over in bed — patient should roll head on pillow, not lift it</li>' +
'<li>Adds to car sickness or sea sickness; may report nausea or anorexia</li>' +
'<li>Transient loss of equilibrium after vigorous or quick head/neck rotation</li>' +
'<li>Rarely: hearing impaired on same side as clavicular TrPs</li>' +
'</ul>' +
'</div>' +
'</div>';
}
/* Horner confirmation */
var hornerHtml = node.division
? '<div class="dt-confirm-panel">' +
'<div class="dt-confirm-header">🔍 Confirmatory — Horner Syndrome Exclusion</div>' +
'<div class="dt-confirm-items">' +
'<div class="dt-confirm-item"><div class="dt-confirm-dot"></div>Pupils equal and reactive — no miosis</div>' +
'<div class="dt-confirm-item"><div class="dt-confirm-dot"></div>No enophthalmos — apparent ptosis is from palpebral fissure narrowing only</div>' +
'<div class="dt-confirm-item"><div class="dt-confirm-dot"></div>Ciliospinal reflex present — pinch skin on back of neck → ipsilateral pupil dilates</div>' +
'<div class="dt-confirm-item"><div class="dt-confirm-dot"></div>Extraocular movements full — no paralysis; symptoms not conversion hysteria</div>' +
'</div>' +
'</div>' : '';
/* Satellite TrPs */
var satelliteHtml = '';
if ( node.satellite_trps && node.satellite_trps.length ) {
satelliteHtml =
'<div class="dt-satellite-section">' +
'<div class="dt-satellite-header">Satellite TrPs — may activate from or with this muscle</div>' +
'<div class="dt-satellite-grid">' +
node.satellite_trps.map( function ( t ) {
return '<span class="dt-satellite-tag">' + t + '</span>';
} ).join( '' ) +
'</div>' +
'</div>';
}
/* Landing page signposting */
var landingHtml = '';
if ( node.landing_page_topics && node.landing_page_topics.length ) {
landingHtml =
'<div class="dt-landing-panel">' +
'<div class="dt-landing-header">📋 Further assessment — muscle landing page</div>' +
'<div class="dt-landing-body">' +
'The following clinical detail goes beyond the scope of this algorithm and is covered on the muscle page:' +
'<ul class="dt-landing-list">' +
node.landing_page_topics.map( function ( t ) { return '<li>' + t + '</li>'; } ).join( '' ) +
'</ul>' +
'</div>' +
'</div>';
}
/* Related page links */
var relatedHtml = node.related_pages && node.related_pages.length
? node.related_pages.map( function ( p ) {
return '<button class="dt-wiki-link satellite" data-page="' + p.page + '">' + p.label + '</button>';
} ).join( '' ) : '';
return '<div class="dt-card dt-card-result">' +
'<div class="dt-type-badge" style="background:#15803d">✅ Likely Diagnosis</div>' +
'<div class="dt-result-name">' + node.diagnosis + '</div>' +
'<div class="dt-confidence" style="color:' + cc + '">Confidence: ' + conf + '</div>' +
divisionHtml +
( node.notes ? '<div class="dt-notes">' + node.notes + '</div>' : '' ) +
( node.treatment_hint ? '<div class="dt-treatment">Treatment: ' + node.treatment_hint + '</div>' : '' ) +
( node.chapter_ref ? '<div class="dt-chapter">📖 ' + node.chapter_ref + '</div>' : '' ) +
also + confirmatoryHtml + hornerHtml + satelliteHtml + landingHtml +
( node.wiki_page ? '<button class="dt-wiki-link" data-page="' + node.wiki_page + '">Open Wiki Page →</button>' : '' ) +
relatedHtml +
'<div class="dt-broad-reminder">' +
'<span class="dt-broad-reminder-icon">🔭</span>' +
'<div><strong>Before concluding</strong>' +
'Review the broad differential panel below — several conditions in this region closely mimic this presentation.</div>' +
'</div>' +
'</div>';
};
DiagnosticTree.prototype._neuroRef = function ( node ) {
var isE = node.urgency === 'emergency';
var color = isE ? 'var(--emergency)' : 'var(--urgent)';
var bdrCl = isE ? 'var(--emergency-border)' : 'var(--urgent-border)';
var bgCl = isE ? 'var(--emergency-bg)' : 'var(--urgent-bg)';
return '<div class="dt-card dt-card-neuro-ref" style="border-color:' + bdrCl + ';background:' + bgCl + '">' +
'<div class="dt-type-badge" style="background:' + color + '">' + ( isE ? '🚨 Emergency' : '⚠️ Urgent Referral' ) + '</div>' +
'<div class="dt-neuro-title">' + node.title + '</div>' +
'<div class="dt-neuro-body">' + node.body + '</div>' +
'<div class="dt-treatment">' + node.action + '</div>' +
'</div>';
};
DiagnosticTree.prototype._overlap = function ( node ) {
var sl = node.screen_these && node.screen_these.length
? '<ul class="dt-screen-list">' +
node.screen_these.map( function ( m ) { return '<li>' + m + '</li>'; } ).join( '' ) +
'</ul>' : '';
return '<div class="dt-card dt-card-overlap">' +
'<div class="dt-type-badge" style="background:#78716c">🔍 Inconclusive</div>' +
'<div class="dt-overlap-text">' + ( node.text || '' ) + '</div>' + sl +
( node.wiki_page ? '<button class="dt-wiki-link" data-page="' + node.wiki_page + '">Open Differential Page →</button>' : '' ) +
'<div class="dt-broad-reminder">' +
'<span class="dt-broad-reminder-icon">🔭</span>' +
'<div><strong>Before concluding</strong>' +
'Review the broad differential panel below — several conditions in this region closely mimic these findings.</div>' +
'</div>' +
'</div>';
};
/* ══════════════════════════════════════════════════════
BOOT SHELL — builds key toggle, red flags, algo, broad diff
══════════════════════════════════════════════════════ */
function bootShell( hostEl, treeData ) {
var shell = document.createElement( 'div' );
shell.className = 'dt-shell';
hostEl.appendChild( shell );
var hasNerveTree = !!hostEl.getAttribute( 'data-nerve-tree-page' );
/* 0 — Key toggle tabs */
var keyToggle = document.createElement( 'div' );
keyToggle.className = 'dt-key-toggle';
keyToggle.innerHTML =
'<button class="dt-key-tab pain-key active" id="dt-tab-pain">' +
'<span class="tab-icon">🩺</span> Pain Pattern Key' +
'</button>' +
'<button class="dt-key-tab nerve-key' + ( hasNerveTree ? '' : ' coming-soon' ) + '" id="dt-tab-nerve"' +
( hasNerveTree ? '' : ' title="Nerve entrapment key coming soon"' ) + '>' +
'<span class="tab-icon">⚡</span> Nerve Entrapment Key' +
( hasNerveTree ? '' : '<span class="tab-soon">coming soon</span>' ) +
'</button>';
shell.appendChild( keyToggle );
/* 1 — Red flag panel (shared) */
var panelEl = document.createElement( 'div' );
shell.appendChild( panelEl );
/* 2 — Pain algorithm section */
var algoSection = document.createElement( 'div' );
algoSection.className = 'dt-algo-section locked';
algoSection.innerHTML =
'<div class="dt-algo-header">' +
'<div class="dt-algo-header-left">🩺 Pain Pattern Algorithm</div>' +
'<div class="dt-algo-header-right">Complete checklist above to unlock</div>' +
'</div>' +
'<div class="dt-algo-body" id="dt-algo-body">' +
'<div class="dt-tree-locked-msg">Work through all emergency and urgent flags above before proceeding.</div>' +
'</div>';
shell.appendChild( algoSection );
/* 2b — Nerve section (placeholder or loaded) */
var nerveSection = document.createElement( 'div' );
nerveSection.style.display = 'none';
if ( !hasNerveTree ) {
nerveSection.innerHTML =
'<div class="dt-algo-section" style="border-color:var(--nerve-header-border)">' +
'<div class="dt-algo-header" style="background:var(--nerve-header)">' +
'<div class="dt-algo-header-left">⚡ Nerve Entrapment Key</div>' +
'</div>' +
'<div class="dt-algo-body">' +
'<div class="dt-tree-locked-msg" style="color:#6d28d9">' +
'The nerve entrapment key for this region is under development.<br>' +
'It will differentiate muscles by their nerve entrapment signatures.<br><br>' +
'<small>Refer to individual muscle pages for entrapment detail in the meantime.</small>' +
'</div>' +
'</div>' +
'</div>';
}
shell.appendChild( nerveSection );
/* Tab switching */
var tabPain = shell.querySelector( '#dt-tab-pain' );
var tabNerve = shell.querySelector( '#dt-tab-nerve' );
tabPain.addEventListener( 'click', function () {
tabPain.classList.add( 'active' );
tabNerve.classList.remove( 'active' );
algoSection.style.display = '';
nerveSection.style.display = 'none';
} );
tabNerve.addEventListener( 'click', function () {
tabNerve.classList.add( 'active' );
tabPain.classList.remove( 'active' );
algoSection.style.display = 'none';
nerveSection.style.display = '';
} );
/* 3 — Broad differential (shared) */
var div2 = document.createElement( 'div' );
div2.className = 'dt-divider';
div2.textContent = 'Low Probability — Broad Differential';
shell.appendChild( div2 );
var broadEl = document.createElement( 'div' );
shell.appendChild( broadEl );
var broadPanel = new BroadDiffPanel( broadEl, treeData.broad_differential || [] );
/* 4 — Unlock on red flags cleared */
var tree = null;
new RedFlagPanel( panelEl, treeData.redflags, function () {
algoSection.classList.remove( 'locked' );
algoSection.classList.add( 'unlocked' );
var body = document.getElementById( 'dt-algo-body' );
body.innerHTML = '';
if ( !tree ) tree = new DiagnosticTree( body, treeData );
broadPanel.unlock();
} );
}
/* ══════════════════════════════════════════════════════
API LOADER — fetch JSON from wiki page
══════════════════════════════════════════════════════ */
function loadPage( pageName, callback ) {
var api = new mw.Api();
api.get( {
action: 'query',
titles: pageName,
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' ) { callback( null, 'Page not found: ' + pageName ); return; }
var content = pages[ pageId ].revisions[ 0 ].slots.main[ '*' ];
/* Strip any wiki markup after the closing } (e.g. category tags) */
var jsonEnd = content.lastIndexOf( '}' );
try {
callback( JSON.parse( content.substring( 0, jsonEnd + 1 ) ), null );
} catch ( e ) {
callback( null, 'Invalid JSON in ' + pageName + ': ' + e.message );
}
} ).fail( function () {
callback( null, 'API request failed for: ' + pageName );
} );
}
function bootHost( hostEl ) {
var treePage = hostEl.getAttribute( 'data-tree-page' );
if ( !treePage ) return;
hostEl.innerHTML = '<div style="padding:1em;font-family:monospace;font-size:0.8em;color:#999">Loading...</div>';
loadPage( treePage, function ( treeData, err ) {
hostEl.innerHTML = '';
if ( err ) {
hostEl.innerHTML = '<div class="dt-error">' + err + '</div>';
return;
}
bootShell( hostEl, treeData );
} );
}
/* ══════════════════════════════════════════════════════
MEDIAWIKI ENTRY POINT
══════════════════════════════════════════════════════ */
function init() {
document.querySelectorAll( '.diagnostic-tree-host' ).forEach( function ( el ) {
bootHost( el );
} );
}
if ( typeof mw !== 'undefined' ) {
mw.hook( 'wikipage.content' ).add( init );
}
}() );