MediaWiki:Gadget-DiagnosticTree.js

From Painwiki
Revision as of 22:04, 11 April 2026 by Yatreyu (talk | contribs)
Jump to navigation Jump to search

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/**
 * DiagnosticTree.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 );
  }

}() );