MediaWiki:Gadget-DiagnosticTree.js: Difference between revisions
Jump to navigation
Jump to search
Created page with "/** * DiagnosticTree.js * MediaWiki Gadget — Clinical Decision Support Tree * * INSTALLATION: * 1. Copy this file to MediaWiki:Gadget-DiagnosticTree.js * 2. Copy DiagnosticTree.css to MediaWiki:Gadget-DiagnosticTree.css * 3. Add to MediaWiki:Gadgets-definition: * * DiagnosticTree[ResourceLoader|default|type=general]|DiagnosticTree.js|DiagnosticTree.css * 4. On any wiki page, embed a tree with: * <div class="diagnostic-tree-host" data-tre..." |
No edit summary |
||
| Line 1: | Line 1: | ||
/** | /** | ||
* DiagnosticTree.js | * DiagnosticTree.js — v2 | ||
* MediaWiki Gadget | * Copy to: MediaWiki:Gadget-DiagnosticTree.js | ||
* | * | ||
* | * Embed on any wiki page with: | ||
* <div class="diagnostic-tree-host" data-tree-page="DiagnosticTree/EarTMJ"></div> | |||
* | |||
* | * | ||
* | * JSON page structure: | ||
* | * { | ||
* "tree_id": "...", | |||
* "region": "...", | |||
* "start": "first-node-id", | |||
* "redflags": { | |||
* "emergency": [ { "id", "label", "question", "rationale", "action" }, ... ], | |||
* "urgent": [ { "id", "label", "question", "rationale", "action" }, ... ] | |||
* }, | |||
* "nodes": { ... } | |||
* } | |||
*/ | */ | ||
( function () { | ( function () { | ||
'use strict'; | |||
/* ── Node type metadata ─────────────────────────────────────────────────── */ | |||
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' } | |||
}; | |||
/* ══════════════════════════════════════════════════════════════════════════ | |||
Red Flag Panel | |||
══════════════════════════════════════════════════════════════════════════ */ | |||
function RedFlagPanel( containerEl, flagData, onAllCleared ) { | |||
this.el = containerEl; | |||
this.data = flagData; | |||
this.onAllCleared = onAllCleared; | |||
this.state = {}; | |||
this.expanded = null; | |||
flagData.emergency.forEach( function ( f ) { this.state[ f.id ] = 'pending'; }, this ); | |||
flagData.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 self = this; | |||
var eCleared = this.data.emergency.every( function ( f ) { return self.state[ f.id ] === 'cleared'; } ); | |||
var uCleared = this.data.urgent.every( function ( f ) { return self.state[ f.id ] === 'cleared'; } ); | |||
this.el.innerHTML = | |||
'<div class="dt-redflag-panel">' + | |||
this._renderColumn( 'emergency', this.data.emergency, eCleared ) + | |||
this._renderColumn( 'urgent', this.data.urgent, uCleared ) + | |||
'</div>'; | |||
this.el.querySelectorAll( '.dt-flag-item' ).forEach( function ( itemEl ) { | |||
var id = itemEl.getAttribute( 'data-id' ); | |||
itemEl.addEventListener( 'click', function ( e ) { | |||
if ( e.target.classList.contains( 'dt-flag-btn' ) ) return; | |||
if ( self.state[ id ] === 'cleared' ) return; | |||
self.expanded = self.expanded === id ? null : id; | |||
self.render(); | |||
} ); | |||
} ); | |||
this.el.querySelectorAll( '.dt-flag-btn' ).forEach( function ( btn ) { | |||
btn.addEventListener( 'click', function ( e ) { | |||
e.stopPropagation(); | |||
var id = btn.getAttribute( 'data-id' ); | |||
var action = btn.getAttribute( 'data-action' ); | |||
self.state[ id ] = action === 'present' ? 'present' : 'cleared'; | |||
self.expanded = action === 'present' ? id : null; | |||
self.render(); | |||
if ( self.allCleared() ) self.onAllCleared(); | |||
} ); | |||
} ); | |||
}; | |||
RedFlagPanel.prototype._renderColumn = function ( type, flags, allCleared ) { | |||
var self = this; | |||
var isEmergency = type === 'emergency'; | |||
var colClass = 'dt-flag-column' + ( isEmergency ? '' : ' urgent' ) + ( allCleared ? ' all-cleared' : '' ); | |||
var headerIcon = isEmergency ? '🚨' : '⚠️'; | |||
var headerText = isEmergency ? 'Emergency' : 'Urgent Referral'; | |||
var itemsHtml = flags.map( function ( f ) { return self._renderItem( f, isEmergency ); } ).join( '' ); | |||
var referralHtml = ''; | |||
flags.forEach( function ( f ) { | |||
if ( self.state[ f.id ] === 'present' ) { | |||
referralHtml += | |||
'<div class="dt-flag-referral ' + ( isEmergency ? '' : 'urgent-ref' ) + ' visible">' + | |||
'<strong>⛔ ' + ( isEmergency ? 'EMERGENCY ACTION' : 'URGENT REFERRAL' ) + '</strong>' + | |||
f.action + | |||
'</div>'; | |||
} | |||
} ); | |||
return '<div class="' + colClass + '">' + | |||
'<div class="dt-flag-col-header"><span class="header-icon">' + headerIcon + '</span>' + headerText + '</div>' + | |||
'<div class="dt-flag-items">' + itemsHtml + '</div>' + | |||
referralHtml + | |||
'<div class="dt-panel-cleared-msg">✓ All ' + ( isEmergency ? 'emergency' : 'urgent' ) + ' flags cleared</div>' + | |||
'</div>'; | |||
}; | |||
RedFlagPanel.prototype._renderItem = function ( flag, isEmergency ) { | |||
var isCleared = this.state[ flag.id ] === 'cleared'; | |||
var isExpanded = this.expanded === flag.id; | |||
var itemClass = 'dt-flag-item' + ( isCleared ? ' cleared' : '' ) + ( isExpanded ? ' expanded' : '' ); | |||
var actionsHtml = isCleared ? '' : | |||
'<div class="dt-flag-actions">' + | |||
'<button class="dt-flag-btn present" data-id="' + flag.id + '" data-action="present">Present</button>' + | |||
'<button class="dt-flag-btn absent" data-id="' + flag.id + '" data-action="absent">Absent — cleared</button>' + | |||
'</div>'; | |||
return '<div class="' + itemClass + '" data-id="' + flag.id + '">' + | |||
'<div class="dt-flag-checkbox"></div>' + | |||
'<div class="dt-flag-item-body">' + | |||
'<div class="dt-flag-label">' + flag.label + '</div>' + | |||
'<div class="dt-flag-question">' + flag.question + | |||
'<div class="dt-flag-rationale">' + flag.rationale + '</div>' + | |||
'</div>' + | |||
actionsHtml + | |||
'</div>' + | |||
'</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 | |||
══════════════════════════════════════════════════════════════════════════ */ | |||
function DiagnosticTree( containerEl, treeData ) { | |||
this.el = containerEl; | |||
this.state = new TreeState( treeData ); | |||
this.render(); | |||
} | |||
DiagnosticTree.prototype.render = function () { | |||
var self = this; | |||
var node = this.state.node(); | |||
var 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._renderResult( node ); | |||
else if ( node.type === 'overlap' ) cardHtml = this._renderOverlap( node ); | |||
else cardHtml = this._renderQuestion( 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 backBtn = this.el.querySelector( '#dt-back' ); | |||
var resetBtn = this.el.querySelector( '#dt-reset' ); | |||
var yesBtn = this.el.querySelector( '#dt-yes' ); | |||
var noBtn = this.el.querySelector( '#dt-no' ); | |||
var wikiLink = this.el.querySelector( '.dt-wiki-link' ); | |||
if ( backBtn ) backBtn.addEventListener( 'click', function () { self.state.back(); self.render(); } ); | |||
if ( resetBtn ) resetBtn.addEventListener( 'click', function () { self.state.reset(); self.render(); } ); | |||
if ( yesBtn ) yesBtn.addEventListener( 'click', function () { var n = self.state.node(); if ( n.yes ) { self.state.go( n.yes ); self.render(); } } ); | |||
if ( noBtn ) noBtn.addEventListener( 'click', function () { var n = self.state.node(); if ( n.no ) { self.state.go( n.no ); self.render(); } } ); | |||
if ( wikiLink ) wikiLink.addEventListener( 'click', function () { | |||
var page = wikiLink.getAttribute( 'data-page' ); | |||
if ( page ) { | |||
window.location.href = typeof mw !== 'undefined' ? mw.util.getUrl( page ) : page; | |||
} | |||
} ); | |||
}; | |||
DiagnosticTree.prototype._renderQuestion = function ( node, meta ) { | |||
var movement = node.movement ? '<div class="dt-tag dt-tag-blue">' + node.movement + ' (' + ( node.direction || '' ) + ')</div>' : ''; | |||
var nerve = node.nerve ? '<div class="dt-tag dt-tag-purple">Nerve: ' + node.nerve + '</div>' : ''; | |||
var examType = node.exam_type ? '<div class="dt-tag dt-tag-green">Test: ' + node.exam_type.replace( /_/g, ' ' ) + '</div>' : ''; | |||
var rationale = node.clinical_rationale || node.distinguishing_feature || ''; | |||
var landmark = node.landmark ? '<div class="dt-landmark">📍 ' + node.landmark + '</div>' : ''; | |||
var posFinding = node.positive_finding ? '<div class="dt-positive">Positive finding: ' + node.positive_finding + '</div>' : ''; | |||
var implicated = node.muscles_implicated ? '<div class="dt-muscle-hint dt-implicated">Suggests: ' + node.muscles_implicated.join( ', ' ) + '</div>' : ''; | |||
var excluded = 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>' + | |||
( ( movement || nerve || examType ) ? '<div class="dt-tags">' + movement + nerve + examType + '</div>' : '' ) + | |||
landmark + posFinding + | |||
( rationale ? '<div class="dt-rationale">' + rationale + '</div>' : '' ) + | |||
implicated + excluded + | |||
'<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._renderResult = function ( node ) { | |||
var confidence = node.confidence || 'moderate'; | |||
var confColor = { high: '#15803d', moderate: '#b45309', low: '#b91c1c' }[ confidence ] || '#78716c'; | |||
var alsoHtml = node.also_consider && node.also_consider.length | |||
? '<div class="dt-also">Also consider: ' + node.also_consider.join( ', ' ) + '</div>' : ''; | |||
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:' + confColor + '">Confidence: ' + confidence + '</div>' + | |||
( 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>' : '' ) + | |||
alsoHtml + | |||
( node.wiki_page ? '<button class="dt-wiki-link" data-page="' + node.wiki_page + '">Open Wiki Page →</button>' : '' ) + | |||
'</div>'; | |||
}; | |||
DiagnosticTree.prototype._renderOverlap = function ( node ) { | |||
var screenList = 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>' + | |||
screenList + | |||
( node.wiki_page ? '<button class="dt-wiki-link" data-page="' + node.wiki_page + '">Open Differential Page →</button>' : '' ) + | |||
'</div>'; | |||
}; | |||
/* ══════════════════════════════════════════════════════════════════════════ | |||
Boot — fetch JSON from wiki page, build panel + tree | |||
══════════════════════════════════════════════════════════════════════════ */ | |||
function bootHost( hostEl ) { | |||
var treePage = hostEl.getAttribute( 'data-tree-page' ); | |||
if ( !treePage ) return; | |||
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 class="dt-error">Tree data page not found: <code>' + treePage + '</code></div>'; | |||
return; | |||
} | |||
var content = pages[ pageId ].revisions[ 0 ].slots.main[ '*' ]; | |||
try { | |||
bootTree( hostEl, JSON.parse( content ) ); | |||
} catch ( e ) { | |||
hostEl.innerHTML = '<div class="dt-error">Invalid JSON in <code>' + treePage + '</code>: ' + e.message + '</div>'; | |||
} | |||
} ).fail( function () { | |||
hostEl.innerHTML = '<div class="dt-error">Failed to load tree data.</div>'; | |||
} ); | |||
} | |||
function bootTree( hostEl, treeData ) { | |||
/* Wrap everything in the shell div */ | |||
var shell = document.createElement( 'div' ); | |||
shell.className = 'dt-shell'; | |||
hostEl.appendChild( shell ); | |||
/* 1. Red flag panel (if redflags key exists in JSON) */ | |||
if ( treeData.redflags ) { | |||
var panelEl = document.createElement( 'div' ); | |||
shell.appendChild( panelEl ); | |||
var divider = document.createElement( 'div' ); | |||
divider.className = 'dt-divider'; | |||
divider.textContent = 'Diagnostic Algorithm'; | |||
shell.appendChild( divider ); | |||
var treeSection = document.createElement( 'div' ); | |||
treeSection.className = 'dt-tree-section'; | |||
shell.appendChild( treeSection ); | |||
var lockedMsg = document.createElement( 'div' ); | |||
lockedMsg.className = 'dt-tree-locked-msg'; | |||
lockedMsg.textContent = 'Complete the red flag checklist above to unlock the diagnostic algorithm'; | |||
treeSection.appendChild( lockedMsg ); | |||
var treeEl = document.createElement( 'div' ); | |||
treeSection.appendChild( treeEl ); | |||
var tree = null; | |||
new RedFlagPanel( panelEl, treeData.redflags, function () { | |||
treeSection.classList.add( 'unlocked' ); | |||
lockedMsg.style.display = 'none'; | |||
if ( !tree ) tree = new DiagnosticTree( treeEl, treeData ); | |||
} ); | |||
} else { | |||
/* No red flags defined — go straight to tree */ | |||
new DiagnosticTree( shell, 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 ); | |||
} | |||
}() ); | }() ); | ||
Revision as of 22:04, 11 April 2026
/**
* DiagnosticTree.js — v2
* Copy to: MediaWiki:Gadget-DiagnosticTree.js
*
* Embed on any wiki page with:
* <div class="diagnostic-tree-host" data-tree-page="DiagnosticTree/EarTMJ"></div>
*
* JSON page structure:
* {
* "tree_id": "...",
* "region": "...",
* "start": "first-node-id",
* "redflags": {
* "emergency": [ { "id", "label", "question", "rationale", "action" }, ... ],
* "urgent": [ { "id", "label", "question", "rationale", "action" }, ... ]
* },
* "nodes": { ... }
* }
*/
( function () {
'use strict';
/* ── Node type metadata ─────────────────────────────────────────────────── */
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' }
};
/* ══════════════════════════════════════════════════════════════════════════
Red Flag Panel
══════════════════════════════════════════════════════════════════════════ */
function RedFlagPanel( containerEl, flagData, onAllCleared ) {
this.el = containerEl;
this.data = flagData;
this.onAllCleared = onAllCleared;
this.state = {};
this.expanded = null;
flagData.emergency.forEach( function ( f ) { this.state[ f.id ] = 'pending'; }, this );
flagData.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 self = this;
var eCleared = this.data.emergency.every( function ( f ) { return self.state[ f.id ] === 'cleared'; } );
var uCleared = this.data.urgent.every( function ( f ) { return self.state[ f.id ] === 'cleared'; } );
this.el.innerHTML =
'<div class="dt-redflag-panel">' +
this._renderColumn( 'emergency', this.data.emergency, eCleared ) +
this._renderColumn( 'urgent', this.data.urgent, uCleared ) +
'</div>';
this.el.querySelectorAll( '.dt-flag-item' ).forEach( function ( itemEl ) {
var id = itemEl.getAttribute( 'data-id' );
itemEl.addEventListener( 'click', function ( e ) {
if ( e.target.classList.contains( 'dt-flag-btn' ) ) return;
if ( self.state[ id ] === 'cleared' ) return;
self.expanded = self.expanded === id ? null : id;
self.render();
} );
} );
this.el.querySelectorAll( '.dt-flag-btn' ).forEach( function ( btn ) {
btn.addEventListener( 'click', function ( e ) {
e.stopPropagation();
var id = btn.getAttribute( 'data-id' );
var action = btn.getAttribute( 'data-action' );
self.state[ id ] = action === 'present' ? 'present' : 'cleared';
self.expanded = action === 'present' ? id : null;
self.render();
if ( self.allCleared() ) self.onAllCleared();
} );
} );
};
RedFlagPanel.prototype._renderColumn = function ( type, flags, allCleared ) {
var self = this;
var isEmergency = type === 'emergency';
var colClass = 'dt-flag-column' + ( isEmergency ? '' : ' urgent' ) + ( allCleared ? ' all-cleared' : '' );
var headerIcon = isEmergency ? '🚨' : '⚠️';
var headerText = isEmergency ? 'Emergency' : 'Urgent Referral';
var itemsHtml = flags.map( function ( f ) { return self._renderItem( f, isEmergency ); } ).join( '' );
var referralHtml = '';
flags.forEach( function ( f ) {
if ( self.state[ f.id ] === 'present' ) {
referralHtml +=
'<div class="dt-flag-referral ' + ( isEmergency ? '' : 'urgent-ref' ) + ' visible">' +
'<strong>⛔ ' + ( isEmergency ? 'EMERGENCY ACTION' : 'URGENT REFERRAL' ) + '</strong>' +
f.action +
'</div>';
}
} );
return '<div class="' + colClass + '">' +
'<div class="dt-flag-col-header"><span class="header-icon">' + headerIcon + '</span>' + headerText + '</div>' +
'<div class="dt-flag-items">' + itemsHtml + '</div>' +
referralHtml +
'<div class="dt-panel-cleared-msg">✓ All ' + ( isEmergency ? 'emergency' : 'urgent' ) + ' flags cleared</div>' +
'</div>';
};
RedFlagPanel.prototype._renderItem = function ( flag, isEmergency ) {
var isCleared = this.state[ flag.id ] === 'cleared';
var isExpanded = this.expanded === flag.id;
var itemClass = 'dt-flag-item' + ( isCleared ? ' cleared' : '' ) + ( isExpanded ? ' expanded' : '' );
var actionsHtml = isCleared ? '' :
'<div class="dt-flag-actions">' +
'<button class="dt-flag-btn present" data-id="' + flag.id + '" data-action="present">Present</button>' +
'<button class="dt-flag-btn absent" data-id="' + flag.id + '" data-action="absent">Absent — cleared</button>' +
'</div>';
return '<div class="' + itemClass + '" data-id="' + flag.id + '">' +
'<div class="dt-flag-checkbox"></div>' +
'<div class="dt-flag-item-body">' +
'<div class="dt-flag-label">' + flag.label + '</div>' +
'<div class="dt-flag-question">' + flag.question +
'<div class="dt-flag-rationale">' + flag.rationale + '</div>' +
'</div>' +
actionsHtml +
'</div>' +
'</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
══════════════════════════════════════════════════════════════════════════ */
function DiagnosticTree( containerEl, treeData ) {
this.el = containerEl;
this.state = new TreeState( treeData );
this.render();
}
DiagnosticTree.prototype.render = function () {
var self = this;
var node = this.state.node();
var 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._renderResult( node );
else if ( node.type === 'overlap' ) cardHtml = this._renderOverlap( node );
else cardHtml = this._renderQuestion( 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 backBtn = this.el.querySelector( '#dt-back' );
var resetBtn = this.el.querySelector( '#dt-reset' );
var yesBtn = this.el.querySelector( '#dt-yes' );
var noBtn = this.el.querySelector( '#dt-no' );
var wikiLink = this.el.querySelector( '.dt-wiki-link' );
if ( backBtn ) backBtn.addEventListener( 'click', function () { self.state.back(); self.render(); } );
if ( resetBtn ) resetBtn.addEventListener( 'click', function () { self.state.reset(); self.render(); } );
if ( yesBtn ) yesBtn.addEventListener( 'click', function () { var n = self.state.node(); if ( n.yes ) { self.state.go( n.yes ); self.render(); } } );
if ( noBtn ) noBtn.addEventListener( 'click', function () { var n = self.state.node(); if ( n.no ) { self.state.go( n.no ); self.render(); } } );
if ( wikiLink ) wikiLink.addEventListener( 'click', function () {
var page = wikiLink.getAttribute( 'data-page' );
if ( page ) {
window.location.href = typeof mw !== 'undefined' ? mw.util.getUrl( page ) : page;
}
} );
};
DiagnosticTree.prototype._renderQuestion = function ( node, meta ) {
var movement = node.movement ? '<div class="dt-tag dt-tag-blue">' + node.movement + ' (' + ( node.direction || '' ) + ')</div>' : '';
var nerve = node.nerve ? '<div class="dt-tag dt-tag-purple">Nerve: ' + node.nerve + '</div>' : '';
var examType = node.exam_type ? '<div class="dt-tag dt-tag-green">Test: ' + node.exam_type.replace( /_/g, ' ' ) + '</div>' : '';
var rationale = node.clinical_rationale || node.distinguishing_feature || '';
var landmark = node.landmark ? '<div class="dt-landmark">📍 ' + node.landmark + '</div>' : '';
var posFinding = node.positive_finding ? '<div class="dt-positive">Positive finding: ' + node.positive_finding + '</div>' : '';
var implicated = node.muscles_implicated ? '<div class="dt-muscle-hint dt-implicated">Suggests: ' + node.muscles_implicated.join( ', ' ) + '</div>' : '';
var excluded = 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>' +
( ( movement || nerve || examType ) ? '<div class="dt-tags">' + movement + nerve + examType + '</div>' : '' ) +
landmark + posFinding +
( rationale ? '<div class="dt-rationale">' + rationale + '</div>' : '' ) +
implicated + excluded +
'<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._renderResult = function ( node ) {
var confidence = node.confidence || 'moderate';
var confColor = { high: '#15803d', moderate: '#b45309', low: '#b91c1c' }[ confidence ] || '#78716c';
var alsoHtml = node.also_consider && node.also_consider.length
? '<div class="dt-also">Also consider: ' + node.also_consider.join( ', ' ) + '</div>' : '';
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:' + confColor + '">Confidence: ' + confidence + '</div>' +
( 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>' : '' ) +
alsoHtml +
( node.wiki_page ? '<button class="dt-wiki-link" data-page="' + node.wiki_page + '">Open Wiki Page →</button>' : '' ) +
'</div>';
};
DiagnosticTree.prototype._renderOverlap = function ( node ) {
var screenList = 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>' +
screenList +
( node.wiki_page ? '<button class="dt-wiki-link" data-page="' + node.wiki_page + '">Open Differential Page →</button>' : '' ) +
'</div>';
};
/* ══════════════════════════════════════════════════════════════════════════
Boot — fetch JSON from wiki page, build panel + tree
══════════════════════════════════════════════════════════════════════════ */
function bootHost( hostEl ) {
var treePage = hostEl.getAttribute( 'data-tree-page' );
if ( !treePage ) return;
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 class="dt-error">Tree data page not found: <code>' + treePage + '</code></div>';
return;
}
var content = pages[ pageId ].revisions[ 0 ].slots.main[ '*' ];
try {
bootTree( hostEl, JSON.parse( content ) );
} catch ( e ) {
hostEl.innerHTML = '<div class="dt-error">Invalid JSON in <code>' + treePage + '</code>: ' + e.message + '</div>';
}
} ).fail( function () {
hostEl.innerHTML = '<div class="dt-error">Failed to load tree data.</div>';
} );
}
function bootTree( hostEl, treeData ) {
/* Wrap everything in the shell div */
var shell = document.createElement( 'div' );
shell.className = 'dt-shell';
hostEl.appendChild( shell );
/* 1. Red flag panel (if redflags key exists in JSON) */
if ( treeData.redflags ) {
var panelEl = document.createElement( 'div' );
shell.appendChild( panelEl );
var divider = document.createElement( 'div' );
divider.className = 'dt-divider';
divider.textContent = 'Diagnostic Algorithm';
shell.appendChild( divider );
var treeSection = document.createElement( 'div' );
treeSection.className = 'dt-tree-section';
shell.appendChild( treeSection );
var lockedMsg = document.createElement( 'div' );
lockedMsg.className = 'dt-tree-locked-msg';
lockedMsg.textContent = 'Complete the red flag checklist above to unlock the diagnostic algorithm';
treeSection.appendChild( lockedMsg );
var treeEl = document.createElement( 'div' );
treeSection.appendChild( treeEl );
var tree = null;
new RedFlagPanel( panelEl, treeData.redflags, function () {
treeSection.classList.add( 'unlocked' );
lockedMsg.style.display = 'none';
if ( !tree ) tree = new DiagnosticTree( treeEl, treeData );
} );
} else {
/* No red flags defined — go straight to tree */
new DiagnosticTree( shell, 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 );
}
}() );