MediaWiki:Gadget-DiagnosticTree.js: Difference between revisions

From Painwiki
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 — Clinical Decision Support Tree
  * Copy to: MediaWiki:Gadget-DiagnosticTree.js
  *
  *
  * INSTALLATION:
  * Embed on any wiki page with:
*  1. Copy this file to MediaWiki:Gadget-DiagnosticTree.js
  *   <div class="diagnostic-tree-host" data-tree-page="DiagnosticTree/EarTMJ"></div>
*  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-tree-page="Data:DiagnosticTree/EarTMJ"></div>
*      where data-tree-page is a wiki page containing the JSON tree definition.
  *
  *
  * TESTING (standalone, without MediaWiki):
  * JSON page structure:
  *  Open DiagnosticTree_test.html in a browser.
*  {
*    "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';
  'use strict';


/* ─── Colour / icon map by node type ──────────────────────────────────── */
  /* ── Node type metadata ─────────────────────────────────────────────────── */
var NODE_META = {
  var NODE_META = {
emergency:      { color: '#c0392b', bg: '#fdf2f2', icon: '🚨', label: 'Emergency' },
    rom:             { color: '#1d4ed8', icon: '🔄', label: 'Movement Screen' },
urgent:          { color: '#e67e22', bg: '#fef9f0', icon: '⚠️',  label: 'Urgent Referral' },
    nerve_entrapment: { color: '#6d28d9', icon: '⚡', label: 'Nerve Screen' },
rom:             { color: '#2980b9', bg: '#f0f7fd', icon: '🔄', label: 'Movement Screen' },
    symptom:         { color: '#0f766e', icon: '💬', label: 'Patient Symptom' },
nerve_entrapment:{ color: '#8e44ad', bg: '#f9f4fd', icon: '⚡', label: 'Nerve Screen' },
    examination:     { color: '#15803d', icon: '🩺', label: 'Examination' },
symptom:         { color: '#16a085', bg: '#f0faf7', icon: '💬', label: 'Patient Symptom' },
    result:           { color: '#15803d', icon: '✅', label: 'Diagnosis' },
examination:     { color: '#27ae60', bg: '#f2faf4', icon: '🩺', label: 'Clinical Examination' },
    overlap:         { color: '#78716c', icon: '🔍', label: 'Inconclusive' }
result:         { color: '#27ae60', bg: '#f2faf4', icon: '✅', label: 'Diagnosis' },
  };
referral:        { color: '#c0392b', bg: '#fdf2f2', icon: '🏥', label: 'Referral' },
overlap:         { color: '#7f8c8d', bg: '#f8f9f9', icon: '🔍', label: 'Inconclusive' }
};


/* ─── History stack for Back button ───────────────────────────────────── */
  /* ══════════════════════════════════════════════════════════════════════════
function TreeState( tree ) {
    Red Flag Panel
this.tree    = tree;
    ══════════════════════════════════════════════════════════════════════════ */
this.history = [];
  function RedFlagPanel( containerEl, flagData, onAllCleared ) {
this.current = tree.start;
    this.el          = containerEl;
}
    this.data        = flagData;
    this.onAllCleared = onAllCleared;
    this.state        = {};
    this.expanded    = null;


TreeState.prototype.go = function ( nodeId ) {
    flagData.emergency.forEach( function ( f ) { this.state[ f.id ] = 'pending'; }, this );
this.history.push( this.current );
    flagData.urgent.forEach(   function ( f ) { this.state[ f.id ] = 'pending'; }, this );
this.current = nodeId;
    this.render();
};
  }


TreeState.prototype.back = function () {
  RedFlagPanel.prototype.allCleared = function () {
if ( this.history.length ) {
    return Object.keys( this.state ).every( function ( k ) {
this.current = this.history.pop();
      return this.state[ k ] === 'cleared';
}
    }, this );
};
  };


TreeState.prototype.reset = function () {
  RedFlagPanel.prototype.render = function () {
this.history = [];
    var self    = this;
this.current = this.tree.start;
    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'; } );


TreeState.prototype.node = function () {
    this.el.innerHTML =
return this.tree.nodes[ this.current ];
      '<div class="dt-redflag-panel">' +
};
        this._renderColumn( 'emergency', this.data.emergency, eCleared ) +
        this._renderColumn( 'urgent',    this.data.urgent,    uCleared ) +
      '</div>';


/* ─── Renderer ─────────────────────────────────────────────────────────── */
    this.el.querySelectorAll( '.dt-flag-item' ).forEach( function ( itemEl ) {
function DiagnosticTree( hostEl, treeData ) {
      var id = itemEl.getAttribute( 'data-id' );
this.host  = hostEl;
      itemEl.addEventListener( 'click', function ( e ) {
this.state = new TreeState( treeData );
        if ( e.target.classList.contains( 'dt-flag-btn' ) ) return;
this.render();
        if ( self.state[ id ] === 'cleared' ) return;
}
        self.expanded = self.expanded === id ? null : id;
        self.render();
      } );
    } );


DiagnosticTree.prototype.render = function () {
    this.el.querySelectorAll( '.dt-flag-btn' ).forEach( function ( btn ) {
var self    = this;
      btn.addEventListener( 'click', function ( e ) {
var node    = this.state.node();
        e.stopPropagation();
var meta    = NODE_META[ node.type ] || NODE_META.symptom;
        var id    = btn.getAttribute( 'data-id' );
var canBack = this.state.history.length > 0;
        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();
      } );
    } );
  };


/* Progress breadcrumb */
  RedFlagPanel.prototype._renderColumn = function ( type, flags, allCleared ) {
var progressHtml = this._renderProgress();
    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';


/* Node card */
    var itemsHtml = flags.map( function ( f ) { return self._renderItem( f, isEmergency ); } ).join( '' );
var cardHtml = '';


if ( node.type === 'result' ) {
    var referralHtml = '';
cardHtml = self._renderResult( node );
    flags.forEach( function ( f ) {
} else if ( node.type === 'referral' ) {
      if ( self.state[ f.id ] === 'present' ) {
cardHtml = self._renderReferral( node );
        referralHtml +=
} else if ( node.type === 'overlap' ) {
          '<div class="dt-flag-referral ' + ( isEmergency ? '' : 'urgent-ref' ) + ' visible">' +
cardHtml = self._renderOverlap( node );
            '<strong>⛔ ' + ( isEmergency ? 'EMERGENCY ACTION' : 'URGENT REFERRAL' ) + '</strong>' +
} else {
            f.action +
cardHtml = self._renderQuestion( node, meta );
          '</div>';
}
      }
    } );


/* Shell */
    return '<div class="' + colClass + '">' +
this.host.innerHTML =
      '<div class="dt-flag-col-header"><span class="header-icon">' + headerIcon + '</span>' + headerText + '</div>' +
'<div class="dt-shell">' +
      '<div class="dt-flag-items">' + itemsHtml + '</div>' +
'<div class="dt-header">' +
      referralHtml +
'<span class="dt-region">' + ( this.state.tree.region || 'Diagnostic Tree' ) + '</span>' +
      '<div class="dt-panel-cleared-msg">✓ All ' + ( isEmergency ? 'emergency' : 'urgent' ) + ' flags cleared</div>' +
'<div class="dt-controls">' +
    '</div>';
( canBack ? '<button class="dt-btn dt-btn-back" id="dt-back">← Back</button>' : '' ) +
  };
'<button class="dt-btn dt-btn-reset" id="dt-reset">↺ Restart</button>' +
'</div>' +
'</div>' +
progressHtml +
cardHtml +
'</div>';


/* Bind buttons */
  RedFlagPanel.prototype._renderItem = function ( flag, isEmergency ) {
var backBtn = this.host.querySelector( '#dt-back' );
    var isCleared = this.state[ flag.id ] === 'cleared';
var resetBtn = this.host.querySelector( '#dt-reset' );
    var isExpanded = this.expanded === flag.id;
    var itemClass  = 'dt-flag-item' + ( isCleared ? ' cleared' : '' ) + ( isExpanded ? ' expanded' : '' );


if ( backBtn ) {
    var actionsHtml = isCleared ? '' :
backBtn.addEventListener( 'click', function () {
      '<div class="dt-flag-actions">' +
self.state.back();
        '<button class="dt-flag-btn present" data-id="' + flag.id + '" data-action="present">Present</button>' +
self.render();
        '<button class="dt-flag-btn absent"  data-id="' + flag.id + '" data-action="absent">Absent — cleared</button>' +
} );
      '</div>';
}
if ( resetBtn ) {
resetBtn.addEventListener( 'click', function () {
self.state.reset();
self.render();
} );
}


/* Yes / No answer buttons */
    return '<div class="' + itemClass + '" data-id="' + flag.id + '">' +
var yesBtn = this.host.querySelector( '#dt-yes' );
      '<div class="dt-flag-checkbox"></div>' +
var noBtn  = this.host.querySelector( '#dt-no' );
      '<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>';
  };


if ( yesBtn ) {
  /* ══════════════════════════════════════════════════════════════════════════
yesBtn.addEventListener( 'click', function () {
    Tree state
var node = self.state.node();
    ══════════════════════════════════════════════════════════════════════════ */
if ( node.yes ) { self.state.go( node.yes ); self.render(); }
  function TreeState( tree ) {
} );
    this.tree    = tree;
}
    this.history = [];
if ( noBtn ) {
    this.current = tree.start;
noBtn.addEventListener( 'click', function () {
  }
var node = self.state.node();
  TreeState.prototype.go    = function ( id ) { this.history.push( this.current ); this.current = id; };
if ( node.no ) { self.state.go( node.no ); self.render(); }
  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 ]; };


/* Result wiki link */
  /* ══════════════════════════════════════════════════════════════════════════
var wikiLink = this.host.querySelector( '.dt-wiki-link' );
    Diagnostic Tree
if ( wikiLink ) {
    ══════════════════════════════════════════════════════════════════════════ */
wikiLink.addEventListener( 'click', function () {
  function DiagnosticTree( containerEl, treeData ) {
var page = wikiLink.getAttribute( 'data-page' );
    this.el    = containerEl;
if ( page ) {
    this.state = new TreeState( treeData );
/* In MediaWiki context use mw.util.getUrl; in test use hash */
    this.render();
if ( typeof mw !== 'undefined' ) {
  }
window.location.href = mw.util.getUrl( page );
} else {
alert( 'Would navigate to wiki page: ' + page );
}
}
} );
}
};


DiagnosticTree.prototype._renderProgress = function () {
  DiagnosticTree.prototype.render = function () {
var steps  = this.state.history.length + 1;
    var self    = this;
var tree    = this.state.tree;
    var node    = this.state.node();
var total  = Object.keys( tree.nodes ).length;
    var meta    = NODE_META[ node.type ] || NODE_META.symptom;
var pct    = Math.min( Math.round( ( steps / total ) * 100 ), 95 );
    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 );


return '<div class="dt-progress-wrap">' +
    var cardHtml;
'<div class="dt-progress-bar" style="width:' + pct + '%"></div>' +
    if      ( node.type === 'result' ) cardHtml = this._renderResult( node );
'</div>' +
    else if ( node.type === 'overlap' ) cardHtml = this._renderOverlap( node );
'<div class="dt-step-label">Step ' + steps + '</div>';
    else                                cardHtml = this._renderQuestion( node, meta );
};


DiagnosticTree.prototype._renderQuestion = function ( node, meta ) {
    this.el.innerHTML =
var typeLabel    = meta.label;
      '<div class="dt-tree-header">' +
var rationale    = node.clinical_rationale || node.distinguishing_feature || '';
        '<span class="dt-region-label">Diagnostic algorithm</span>' +
var implicated  = node.muscles_implicated  ? node.muscles_implicated.join( ', ' )  : '';
        '<div class="dt-controls">' +
var excluded    = node.muscles_excluded    ? node.muscles_excluded.join( ', ' )    : '';
          ( canBack ? '<button class="dt-btn dt-btn-back" id="dt-back">← Back</button>' : '' ) +
var landmark    = node.landmark            ? '<div class="dt-landmark">📍 ' + node.landmark + '</div>' : '';
          '<button class="dt-btn" id="dt-reset">↺ Restart</button>' +
var movement    = node.movement            ? '<div class="dt-tag dt-tag-blue">Movement: ' + node.movement + ' (' + ( node.direction || '' ) + ')</div>' : '';
        '</div>' +
var nerve        = node.nerve              ? '<div class="dt-tag dt-tag-purple">Nerve: ' + node.nerve + '</div>' : '';
      '</div>' +
var examType    = node.exam_type          ? '<div class="dt-tag dt-tag-green">Test type: ' + node.exam_type.replace(/_/g,' ') + '</div>' : '';
      '<div class="dt-progress-wrap"><div class="dt-progress-bar" style="width:' + pct + '%"></div></div>' +
var positiveFind = node.positive_finding    ? '<div class="dt-positive">Positive finding: ' + node.positive_finding + '</div>' : '';
      '<div class="dt-step-label">Step ' + steps + '</div>' +
      cardHtml;


return '<div class="dt-card" style="border-color:' + meta.color + '; background:' + meta.bg + '">' +
    var backBtn  = this.el.querySelector( '#dt-back' );
'<div class="dt-type-badge" style="background:' + meta.color + '">' + meta.icon + ' ' + typeLabel + '</div>' +
    var resetBtn = this.el.querySelector( '#dt-reset' );
'<div class="dt-question">' + node.question + '</div>' +
    var yesBtn   = this.el.querySelector( '#dt-yes' );
( movement || nerve || examType ? '<div class="dt-tags">' + movement + nerve + examType + '</div>' : '' ) +
    var noBtn    = this.el.querySelector( '#dt-no' );
landmark +
    var wikiLink = this.el.querySelector( '.dt-wiki-link' );
positiveFind +
( rationale   ? '<div class="dt-rationale">' + rationale + '</div>' : '' ) +
( implicated  ? '<div class="dt-muscle-hint dt-implicated">Suggests: ' + implicated + '</div>' : '' ) +
( excluded    ? '<div class="dt-muscle-hint dt-excluded">Argues against: ' + excluded + '</div>' : '' ) +
'<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 ) {
    if ( backBtn  ) backBtn.addEventListener(  'click', function () { self.state.back();  self.render(); } );
var confidence = node.confidence || 'moderate';
    if ( resetBtn ) resetBtn.addEventListener( 'click', function () { self.state.reset(); self.render(); } );
var confColor  = { high: '#27ae60', moderate: '#e67e22', low: '#e74c3c' }[ confidence ] || '#7f8c8d';
    if ( yesBtn  ) yesBtn.addEventListener(  'click', function () { var n = self.state.node(); if ( n.yes ) { self.state.go( n.yes ); self.render(); } } );
var alsoHtml  = node.also_consider && node.also_consider.length
    if ( noBtn    ) noBtn.addEventListener(    'click', function () { var n = self.state.node(); if ( n.no  ) { self.state.go( n.no  ); self.render(); } } );
? '<div class="dt-also">Also consider: ' + node.also_consider.join( ', ' ) + '</div>'
    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;
      }
    } );
  };


return '<div class="dt-card dt-card-result">' +
  DiagnosticTree.prototype._renderQuestion = function ( node, meta ) {
'<div class="dt-type-badge" style="background:#27ae60">✅ Likely Diagnosis</div>' +
    var movement  = node.movement        ? '<div class="dt-tag dt-tag-blue">'   + node.movement + ' (' + ( node.direction || '' ) + ')</div>' : '';
'<div class="dt-result-name">' + node.diagnosis + '</div>' +
    var nerve      = node.nerve            ? '<div class="dt-tag dt-tag-purple">Nerve: ' + node.nerve + '</div>' : '';
( node.confidence ? '<div class="dt-confidence" style="color:' + confColor + '">Confidence: ' + confidence + '</div>' : '' ) +
    var examType  = node.exam_type        ? '<div class="dt-tag dt-tag-green">Test: '   + node.exam_type.replace( /_/g, ' ' ) + '</div>' : '';
( node.notes      ? '<div class="dt-notes">' + node.notes + '</div>' : '' ) +
    var rationale  = node.clinical_rationale || node.distinguishing_feature || '';
( node.treatment_hint ? '<div class="dt-treatment">Treatment: ' + node.treatment_hint + '</div>' : '' ) +
    var landmark  = node.landmark        ? '<div class="dt-landmark">📍 ' + node.landmark + '</div>' : '';
( node.chapter_ref    ? '<div class="dt-chapter">📖 ' + node.chapter_ref + '</div>' : '' ) +
    var posFinding = node.positive_finding ? '<div class="dt-positive">Positive finding: ' + node.positive_finding + '</div>' : '';
alsoHtml +
    var implicated = node.muscles_implicated ? '<div class="dt-muscle-hint dt-implicated">Suggests: ' + node.muscles_implicated.join( ', ' ) + '</div>' : '';
( node.wiki_page
    var excluded  = node.muscles_excluded  ? '<div class="dt-muscle-hint dt-excluded">Argues against: ' + node.muscles_excluded.join( ', ' )   + '</div>' : '';
? '<button class="dt-wiki-link" data-page="' + node.wiki_page + '">Open Wiki Page →</button>'
: '' ) +
'</div>';
};


DiagnosticTree.prototype._renderReferral = function ( node ) {
    return '<div class="dt-card" style="border-color:' + meta.color + '22">' +
var isEmergency = node.urgency === 'emergency';
      '<div class="dt-type-badge" style="background:' + meta.color + '">' + meta.icon + ' ' + meta.label + '</div>' +
var color       = isEmergency ? '#c0392b' : '#e67e22';
      '<div class="dt-question">' + node.question + '</div>' +
var bg          = isEmergency ? '#fdf2f2' : '#fef9f0';
       ( ( movement || nerve || examType ) ? '<div class="dt-tags">' + movement + nerve + examType + '</div>' : '' ) +
var icon        = isEmergency ? '🚨' : '⚠️';
      landmark + posFinding +
var label       = isEmergency ? 'EMERGENCY — Act Now' : 'Urgent Referral Required';
      ( 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>';
  };


return '<div class="dt-card dt-card-referral" style="border-color:' + color + ';background:' + bg + '">' +
  DiagnosticTree.prototype._renderResult = function ( node ) {
'<div class="dt-type-badge" style="background:' + color + '">' + icon + ' ' + label + '</div>' +
    var confidence = node.confidence || 'moderate';
'<div class="dt-referral-text">' + ( node.text || '' ) + '</div>' +
    var confColor  = { high: '#15803d', moderate: '#b45309', low: '#b91c1c' }[ confidence ] || '#78716c';
( node.action ? '<div class="dt-referral-action">' + node.action + '</div>' : '' ) +
    var alsoHtml  = node.also_consider && node.also_consider.length
( node.flag_label ? '<div class="dt-flag-label">' + node.flag_label + '</div>' : '' ) +
      ? '<div class="dt-also">Also consider: ' + node.also_consider.join( ', ' ) + '</div>' : '';
'</div>';
};


DiagnosticTree.prototype._renderOverlap = function ( node ) {
    return '<div class="dt-card dt-card-result">' +
var screenList = node.screen_these && node.screen_these.length
      '<div class="dt-type-badge" style="background:#15803d">✅ Likely Diagnosis</div>' +
? '<ul class="dt-screen-list">' + node.screen_these.map( function(m){ return '<li>' + m + '</li>'; } ).join('') + '</ul>'
      '<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>';
  };


return '<div class="dt-card dt-card-overlap">' +
  DiagnosticTree.prototype._renderOverlap = function ( node ) {
'<div class="dt-type-badge" style="background:#7f8c8d">🔍 Inconclusive</div>' +
    var screenList = node.screen_these && node.screen_these.length
'<div class="dt-overlap-text">' + ( node.text || 'Findings inconclusive — multi-muscle involvement likely.' ) + '</div>' +
      ? '<ul class="dt-screen-list">' + node.screen_these.map( function ( m ) { return '<li>' + m + '</li>'; } ).join( '' ) + '</ul>' : '';
screenList +
( node.wiki_page
? '<button class="dt-wiki-link" data-page="' + node.wiki_page + '">Open Differential Page →</button>'
: '' ) +
'</div>';
};


/* ─── Loader: fetch JSON from a wiki page, then boot ───────────────────── */
    return '<div class="dt-card dt-card-overlap">' +
function loadTreeFromWikiPage( pageName, hostEl ) {
      '<div class="dt-type-badge" style="background:#78716c">🔍 Inconclusive</div>' +
var api = new mw.Api();
      '<div class="dt-overlap-text">' + ( node.text || '' ) + '</div>' +
api.get( {
      screenList +
action:  'query',
      ( node.wiki_page ? '<button class="dt-wiki-link" data-page="' + node.wiki_page + '">Open Differential Page →</button>' : '' ) +
titles:  pageName,
    '</div>';
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>' + pageName + '</code></div>';
return;
}
var content = pages[ pageId ].revisions[ 0 ].slots.main[ '*' ];
try {
var treeData = JSON.parse( content );
new DiagnosticTree( hostEl, treeData );
} catch ( e ) {
hostEl.innerHTML = '<div class="dt-error">Invalid JSON in <code>' + pageName + '</code>: ' + e.message + '</div>';
}
} ).fail( function () {
hostEl.innerHTML = '<div class="dt-error">Failed to load tree data.</div>';
} );
}


/* ─── MediaWiki entry point ────────────────────────────────────────────── */
  /* ══════════════════════════════════════════════════════════════════════════
function init() {
    Boot — fetch JSON from wiki page, build panel + tree
var hosts = document.querySelectorAll( '.diagnostic-tree-host' );
    ══════════════════════════════════════════════════════════════════════════ */
if ( !hosts.length ) { return; }
  function bootHost( hostEl ) {
    var treePage = hostEl.getAttribute( 'data-tree-page' );
    if ( !treePage ) return;


hosts.forEach( function ( el ) {
    var api = new mw.Api();
var treePage = el.getAttribute( 'data-tree-page' );
    api.get( {
if ( treePage ) {
      action:  'query',
loadTreeFromWikiPage( treePage, el );
      titles:  treePage,
} else {
      prop:    'revisions',
/* Inline JSON support: <div data-tree-inline='{"tree_id":...}'> */
      rvprop:  'content',
var inlineData = el.getAttribute( 'data-tree-inline' );
      rvslots: 'main',
if ( inlineData ) {
      format:  'json'
try {
    } ).done( function ( data ) {
new DiagnosticTree( el, JSON.parse( inlineData ) );
      var pages  = data.query.pages;
} catch ( e ) {
      var pageId = Object.keys( pages )[ 0 ];
el.innerHTML = '<div class="dt-error">Invalid inline tree JSON: ' + e.message + '</div>';
      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>';
    } );
  }


if ( typeof mw !== 'undefined' ) {
  function bootTree( hostEl, treeData ) {
mw.hook( 'wikipage.content' ).add( init );
    /* Wrap everything in the shell div */
} else {
    var shell = document.createElement( 'div' );
/* Standalone test mode */
    shell.className = 'dt-shell';
window.DiagnosticTree = DiagnosticTree;
    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 );
  }

}() );