MediaWiki:Gadget-DiagnosticTree.js

From Painwiki
Revision as of 17:54, 11 April 2026 by Yatreyu (talk | contribs) (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...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
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
 * 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-tree-page="Data:DiagnosticTree/EarTMJ"></div>
 *      where data-tree-page is a wiki page containing the JSON tree definition.
 *
 * TESTING (standalone, without MediaWiki):
 *   Open DiagnosticTree_test.html in a browser.
 */

( function () {
	'use strict';

	/* ─── Colour / icon map by node type ──────────────────────────────────── */
	var NODE_META = {
		emergency:       { color: '#c0392b', bg: '#fdf2f2', icon: '🚨', label: 'Emergency' },
		urgent:          { color: '#e67e22', bg: '#fef9f0', icon: '⚠️',  label: 'Urgent Referral' },
		rom:             { color: '#2980b9', bg: '#f0f7fd', icon: '🔄', label: 'Movement Screen' },
		nerve_entrapment:{ color: '#8e44ad', bg: '#f9f4fd', icon: '⚡', label: 'Nerve Screen' },
		symptom:         { color: '#16a085', bg: '#f0faf7', icon: '💬', label: 'Patient Symptom' },
		examination:     { color: '#27ae60', bg: '#f2faf4', icon: '🩺', label: 'Clinical Examination' },
		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 ) {
		this.tree    = tree;
		this.history = [];
		this.current = tree.start;
	}

	TreeState.prototype.go = function ( nodeId ) {
		this.history.push( this.current );
		this.current = nodeId;
	};

	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 ];
	};

	/* ─── Renderer ─────────────────────────────────────────────────────────── */
	function DiagnosticTree( hostEl, treeData ) {
		this.host  = hostEl;
		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;

		/* Progress breadcrumb */
		var progressHtml = this._renderProgress();

		/* Node card */
		var cardHtml = '';

		if ( node.type === 'result' ) {
			cardHtml = self._renderResult( node );
		} else if ( node.type === 'referral' ) {
			cardHtml = self._renderReferral( node );
		} else if ( node.type === 'overlap' ) {
			cardHtml = self._renderOverlap( node );
		} else {
			cardHtml = self._renderQuestion( node, meta );
		}

		/* Shell */
		this.host.innerHTML =
			'<div class="dt-shell">' +
				'<div class="dt-header">' +
					'<span class="dt-region">' + ( this.state.tree.region || 'Diagnostic Tree' ) + '</span>' +
					'<div class="dt-controls">' +
						( 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 */
		var backBtn  = this.host.querySelector( '#dt-back' );
		var resetBtn = this.host.querySelector( '#dt-reset' );

		if ( backBtn ) {
			backBtn.addEventListener( 'click', function () {
				self.state.back();
				self.render();
			} );
		}
		if ( resetBtn ) {
			resetBtn.addEventListener( 'click', function () {
				self.state.reset();
				self.render();
			} );
		}

		/* Yes / No answer buttons */
		var yesBtn = this.host.querySelector( '#dt-yes' );
		var noBtn  = this.host.querySelector( '#dt-no' );

		if ( yesBtn ) {
			yesBtn.addEventListener( 'click', function () {
				var node = self.state.node();
				if ( node.yes ) { self.state.go( node.yes ); self.render(); }
			} );
		}
		if ( noBtn ) {
			noBtn.addEventListener( 'click', function () {
				var node = self.state.node();
				if ( node.no ) { self.state.go( node.no ); self.render(); }
			} );
		}

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

	DiagnosticTree.prototype._renderProgress = function () {
		var steps   = this.state.history.length + 1;
		var tree    = this.state.tree;
		var total   = Object.keys( tree.nodes ).length;
		var pct     = Math.min( Math.round( ( steps / total ) * 100 ), 95 );

		return '<div class="dt-progress-wrap">' +
			'<div class="dt-progress-bar" style="width:' + pct + '%"></div>' +
			'</div>' +
			'<div class="dt-step-label">Step ' + steps + '</div>';
	};

	DiagnosticTree.prototype._renderQuestion = function ( node, meta ) {
		var typeLabel    = meta.label;
		var rationale    = node.clinical_rationale || node.distinguishing_feature || '';
		var implicated   = node.muscles_implicated  ? node.muscles_implicated.join( ', ' )  : '';
		var excluded     = node.muscles_excluded    ? node.muscles_excluded.join( ', ' )    : '';
		var landmark     = node.landmark            ? '<div class="dt-landmark">📍 ' + node.landmark + '</div>' : '';
		var movement     = node.movement            ? '<div class="dt-tag dt-tag-blue">Movement: ' + 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 type: ' + node.exam_type.replace(/_/g,' ') + '</div>' : '';
		var positiveFind = node.positive_finding    ? '<div class="dt-positive">Positive finding: ' + node.positive_finding + '</div>' : '';

		return '<div class="dt-card" style="border-color:' + meta.color + '; background:' + meta.bg + '">' +
			'<div class="dt-type-badge" style="background:' + meta.color + '">' + meta.icon + ' ' + typeLabel + '</div>' +
			'<div class="dt-question">' + node.question + '</div>' +
			( movement || nerve || examType ? '<div class="dt-tags">' + movement + nerve + examType + '</div>' : '' ) +
			landmark +
			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 ) {
		var confidence = node.confidence || 'moderate';
		var confColor  = { high: '#27ae60', moderate: '#e67e22', low: '#e74c3c' }[ confidence ] || '#7f8c8d';
		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:#27ae60">✅ Likely Diagnosis</div>' +
			'<div class="dt-result-name">' + node.diagnosis + '</div>' +
			( node.confidence ? '<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._renderReferral = function ( node ) {
		var isEmergency = node.urgency === 'emergency';
		var color       = isEmergency ? '#c0392b' : '#e67e22';
		var bg          = isEmergency ? '#fdf2f2' : '#fef9f0';
		var icon        = isEmergency ? '🚨' : '⚠️';
		var label       = isEmergency ? 'EMERGENCY — Act Now' : 'Urgent Referral Required';

		return '<div class="dt-card dt-card-referral" style="border-color:' + color + ';background:' + bg + '">' +
			'<div class="dt-type-badge" style="background:' + color + '">' + icon + ' ' + label + '</div>' +
			'<div class="dt-referral-text">' + ( node.text || '' ) + '</div>' +
			( node.action ? '<div class="dt-referral-action">' + node.action + '</div>' : '' ) +
			( node.flag_label ? '<div class="dt-flag-label">' + node.flag_label + '</div>' : '' ) +
		'</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:#7f8c8d">🔍 Inconclusive</div>' +
			'<div class="dt-overlap-text">' + ( node.text || 'Findings inconclusive — multi-muscle involvement likely.' ) + '</div>' +
			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 ───────────────────── */
	function loadTreeFromWikiPage( pageName, hostEl ) {
		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' ) {
				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() {
		var hosts = document.querySelectorAll( '.diagnostic-tree-host' );
		if ( !hosts.length ) { return; }

		hosts.forEach( function ( el ) {
			var treePage = el.getAttribute( 'data-tree-page' );
			if ( treePage ) {
				loadTreeFromWikiPage( treePage, el );
			} else {
				/* Inline JSON support: <div data-tree-inline='{"tree_id":...}'> */
				var inlineData = el.getAttribute( 'data-tree-inline' );
				if ( inlineData ) {
					try {
						new DiagnosticTree( el, JSON.parse( inlineData ) );
					} catch ( e ) {
						el.innerHTML = '<div class="dt-error">Invalid inline tree JSON: ' + e.message + '</div>';
					}
				}
			}
		} );
	}

	if ( typeof mw !== 'undefined' ) {
		mw.hook( 'wikipage.content' ).add( init );
	} else {
		/* Standalone test mode */
		window.DiagnosticTree = DiagnosticTree;
	}

}() );