Update Contec monitor service, parser, dashboard and tunneling
This commit is contained in:
@@ -1,249 +0,0 @@
|
||||
/**
|
||||
* ContecVitalsClient - A reusable JavaScript client for the Contec CMS7000PLUS Patient Monitor.
|
||||
* Works in Web, React, React Native, and Node.js environments.
|
||||
*
|
||||
* Features:
|
||||
* - Real-time streaming via WebSockets
|
||||
* - Automatic HTTP polling fallback if WebSockets are blocked or disconnect
|
||||
* - Exponential backoff auto-reconnection
|
||||
* - Lifecycle callbacks for UI updates
|
||||
*/
|
||||
class ContecVitalsClient {
|
||||
/**
|
||||
* @param {Object} config - Client configuration options
|
||||
* @param {string} config.baseUrl - Server URL (e.g. 'https://contectfiveparamonitor.onrender.com')
|
||||
* @param {string} [config.deviceId] - Optional device filter (e.g. 'CMS7000PLUS_192.168.100.120')
|
||||
* @param {number} [config.pollingInterval=3000] - Polling interval in ms if falling back to HTTP
|
||||
* @param {function} [config.onVitals] - Callback when new vitals data arrives
|
||||
* @param {function} [config.onStatusChange] - Callback when connection status changes ('idle', 'connecting', 'connected', 'polling', 'disconnected')
|
||||
* @param {function} [config.onError] - Callback on error
|
||||
*/
|
||||
constructor(config) {
|
||||
this.baseUrl = config.baseUrl.replace(/\/$/, '');
|
||||
this.deviceId = config.deviceId || null;
|
||||
this.pollingInterval = config.pollingInterval || 3000;
|
||||
|
||||
// Callbacks
|
||||
this.onVitals = config.onVitals || (() => {});
|
||||
this.onStatusChange = config.onStatusChange || (() => {});
|
||||
this.onError = config.onError || (() => {});
|
||||
|
||||
// State management
|
||||
this.ws = null;
|
||||
this.pollTimer = null;
|
||||
this.status = 'idle';
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 5;
|
||||
this.reconnectDelay = 1000;
|
||||
this.isStarted = false;
|
||||
this.lastVitalsTime = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start listening for vital signs.
|
||||
*/
|
||||
start() {
|
||||
if (this.isStarted) return;
|
||||
this.isStarted = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this._setStatus('connecting');
|
||||
this._connectWebSocket();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop listening and clean up all connections/timers.
|
||||
*/
|
||||
stop() {
|
||||
this.isStarted = false;
|
||||
this._clearPollTimer();
|
||||
this._closeWebSocket();
|
||||
this._setStatus('idle');
|
||||
}
|
||||
|
||||
/**
|
||||
* Change current status and trigger callback.
|
||||
* @private
|
||||
*/
|
||||
_setStatus(newStatus) {
|
||||
if (this.status !== newStatus) {
|
||||
this.status = newStatus;
|
||||
this.onStatusChange(newStatus);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the base URL to construct the WS URL.
|
||||
* @private
|
||||
*/
|
||||
_getWebSocketUrl() {
|
||||
const wsProto = this.baseUrl.startsWith('https') ? 'wss' : 'ws';
|
||||
const cleanUrl = this.baseUrl.replace(/^(https?:\/\/)/, '');
|
||||
return `${wsProto}://${cleanUrl}/ws/vitals`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open WebSocket connection.
|
||||
* @private
|
||||
*/
|
||||
_connectWebSocket() {
|
||||
this._clearPollTimer();
|
||||
const wsUrl = this._getWebSocketUrl();
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('ContecVitalsClient: WebSocket connected');
|
||||
this._setStatus('connected');
|
||||
this.reconnectAttempts = 0;
|
||||
this.reconnectDelay = 1000;
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
this._processIncomingVitals(data);
|
||||
} catch (err) {
|
||||
this.onError(new Error('Failed to parse incoming WS message: ' + err.message));
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (err) => {
|
||||
this.onError(err);
|
||||
};
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
this.ws = null;
|
||||
if (!this.isStarted) return;
|
||||
|
||||
console.log(`ContecVitalsClient: WebSocket closed (code: ${event.code})`);
|
||||
|
||||
// Attempt reconnection
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts++;
|
||||
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
||||
console.log(`ContecVitalsClient: Reconnecting in ${delay}ms (Attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
|
||||
this._setStatus('connecting');
|
||||
setTimeout(() => {
|
||||
if (this.isStarted) this._connectWebSocket();
|
||||
}, delay);
|
||||
} else {
|
||||
// Exceeded Max WebSocket retries -> Fallback to HTTP Polling
|
||||
console.log('ContecVitalsClient: WebSocket connection limit exceeded. Falling back to HTTP polling.');
|
||||
this._startPolling();
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
this.onError(err);
|
||||
this._startPolling();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close WebSocket connection cleanly.
|
||||
* @private
|
||||
*/
|
||||
_closeWebSocket() {
|
||||
if (this.ws) {
|
||||
// Remove event listeners to prevent onclose reconnect logic
|
||||
this.ws.onopen = null;
|
||||
this.ws.onmessage = null;
|
||||
this.ws.onerror = null;
|
||||
this.ws.onclose = null;
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch (e) {}
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start HTTP Polling.
|
||||
* @private
|
||||
*/
|
||||
_startPolling() {
|
||||
this._closeWebSocket();
|
||||
this._setStatus('polling');
|
||||
this._poll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform single HTTP poll.
|
||||
* @private
|
||||
*/
|
||||
async _poll() {
|
||||
if (!this.isStarted || this.status !== 'polling') return;
|
||||
|
||||
try {
|
||||
const url = `${this.baseUrl}/api/latest-readings?limit=1` + (this.deviceId ? `&device_id=${encodeURIComponent(this.deviceId)}` : '');
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const readings = await response.json();
|
||||
if (Array.isArray(readings) && readings.length > 0) {
|
||||
this._processIncomingVitals(readings[0]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('ContecVitalsClient polling error:', err.message);
|
||||
this.onError(err);
|
||||
}
|
||||
|
||||
// Schedule next poll
|
||||
this.pollTimer = setTimeout(() => this._poll(), this.pollingInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the HTTP polling timer.
|
||||
* @private
|
||||
*/
|
||||
_clearPollTimer() {
|
||||
if (this.pollTimer) {
|
||||
clearTimeout(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes and emits the vitals data payload.
|
||||
* @private
|
||||
*/
|
||||
_processIncomingVitals(data) {
|
||||
if (!data) return;
|
||||
|
||||
// Filter by device if configured
|
||||
if (this.deviceId && data.device_id !== this.deviceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize property names and types
|
||||
const normalized = {
|
||||
deviceId: data.device_id,
|
||||
patientId: data.patient_id,
|
||||
timestamp: data.timestamp,
|
||||
heartRate: typeof data.heart_rate === 'number' ? Math.round(data.heart_rate) : null,
|
||||
spo2: typeof data.spo2 === 'number' ? Math.round(data.spo2) : null,
|
||||
temperature: typeof data.temperature === 'number' ? parseFloat(data.temperature.toFixed(1)) : null,
|
||||
systolicBp: typeof data.systolic_bp === 'number' ? Math.round(data.systolic_bp) : null,
|
||||
diastolicBp: typeof data.diastolic_bp === 'number' ? Math.round(data.diastolic_bp) : null,
|
||||
mapBp: typeof data.map_bp === 'number' ? Math.round(data.map_bp) : null,
|
||||
respiratoryRate: typeof data.respiratory_rate === 'number' ? Math.round(data.respiratory_rate) : null,
|
||||
};
|
||||
|
||||
// Calculate BP display string helper
|
||||
normalized.bloodPressureDisplay = (normalized.systolicBp && normalized.diastolicBp)
|
||||
? `${normalized.systolicBp}/${normalized.diastolicBp}`
|
||||
: null;
|
||||
|
||||
this.onVitals(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
// Export for ES6/Node compatibility
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = ContecVitalsClient;
|
||||
} else if (typeof window !== 'undefined') {
|
||||
window.ContecVitalsClient = ContecVitalsClient;
|
||||
}
|
||||
@@ -1,665 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Contec Vital Signs Monitor</title>
|
||||
<meta name="description" content="Real-time patient vital signs monitoring — Contec CMS8500">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Orbitron:wght@400;700;900&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box;}
|
||||
|
||||
:root{
|
||||
--ecg-color:#00ff41;
|
||||
--pleth-color:#00e5ff;
|
||||
--resp-color:#ffb300;
|
||||
--hr-color:#00ff41;
|
||||
--spo2-color:#00e5ff;
|
||||
--rr-color:#ffb300;
|
||||
--nibp-color:#ff6b6b;
|
||||
--temp-color:#ff9500;
|
||||
--bg:#0a0e0a;
|
||||
--panel:#111611;
|
||||
--border:#1a2e1a;
|
||||
--text:#e0ffe0;
|
||||
--dim:#4a6a4a;
|
||||
}
|
||||
|
||||
body{
|
||||
font-family:'Share Tech Mono',monospace;
|
||||
background:var(--bg);
|
||||
color:var(--text);
|
||||
height:100vh;
|
||||
overflow:hidden;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
}
|
||||
|
||||
/* ── Verification Overlay ── */
|
||||
#verificationOverlay{
|
||||
position:fixed;inset:0;z-index:1000;
|
||||
background:rgba(0,0,0,0.92);
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
}
|
||||
.ver-card{
|
||||
background:#0d1a0d;border:1px solid #1a3a1a;
|
||||
border-radius:12px;padding:40px;text-align:center;max-width:420px;
|
||||
}
|
||||
.ver-card .icon{font-size:56px;margin-bottom:16px;}
|
||||
.ver-card h2{font-family:'Orbitron',sans-serif;font-size:18px;color:#00ff41;margin-bottom:10px;}
|
||||
.ver-card p{color:#4a6a4a;font-size:13px;line-height:1.6;}
|
||||
.spinner{
|
||||
width:48px;height:48px;border:3px solid #1a3a1a;
|
||||
border-top-color:#00ff41;border-radius:50%;
|
||||
margin:0 auto 16px;animation:spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin{to{transform:rotate(360deg);}}
|
||||
|
||||
/* ── Top Status Bar ── */
|
||||
#statusBar{
|
||||
background:#050a05;
|
||||
border-bottom:1px solid var(--border);
|
||||
display:flex;align-items:center;justify-content:space-between;
|
||||
padding:4px 12px;height:32px;flex-shrink:0;
|
||||
}
|
||||
.sb-left{display:flex;align-items:center;gap:16px;}
|
||||
.sb-patient{font-family:'Orbitron',sans-serif;font-size:11px;color:#00ff41;letter-spacing:1px;}
|
||||
.sb-device{font-size:11px;color:var(--dim);}
|
||||
.sb-right{display:flex;align-items:center;gap:16px;}
|
||||
.conn-dot{
|
||||
width:8px;height:8px;border-radius:50%;background:#333;
|
||||
display:inline-block;margin-right:4px;
|
||||
}
|
||||
.conn-dot.online{background:#00ff41;box-shadow:0 0 6px #00ff41;}
|
||||
.conn-dot.offline{background:#ff4444;box-shadow:0 0 6px #ff4444;}
|
||||
#clockDisplay{font-size:11px;color:#00ff41;font-family:'Orbitron',sans-serif;}
|
||||
|
||||
/* ── Main Layout: waveforms left, params right ── */
|
||||
#monitorBody{
|
||||
flex:1;display:flex;overflow:hidden;
|
||||
}
|
||||
|
||||
/* ── Waveform Column ── */
|
||||
#waveformCol{
|
||||
flex:1;display:flex;flex-direction:column;
|
||||
border-right:1px solid var(--border);overflow:hidden;
|
||||
}
|
||||
|
||||
.waveform-strip{
|
||||
flex:1;position:relative;
|
||||
border-bottom:1px solid var(--border);
|
||||
overflow:hidden;
|
||||
}
|
||||
.waveform-strip:last-child{border-bottom:none;}
|
||||
|
||||
.strip-label{
|
||||
position:absolute;top:6px;left:10px;z-index:2;
|
||||
font-family:'Orbitron',sans-serif;font-size:10px;
|
||||
letter-spacing:2px;font-weight:700;
|
||||
}
|
||||
.strip-label.ecg{color:var(--ecg-color);}
|
||||
.strip-label.pleth{color:var(--pleth-color);}
|
||||
.strip-label.resp{color:var(--resp-color);}
|
||||
|
||||
.strip-canvas{
|
||||
position:absolute;inset:0;width:100%;height:100%;
|
||||
}
|
||||
|
||||
/* ── Parameter Column ── */
|
||||
#paramCol{
|
||||
width:220px;flex-shrink:0;
|
||||
display:flex;flex-direction:column;
|
||||
background:var(--panel);
|
||||
}
|
||||
|
||||
.param-box{
|
||||
flex:1;border-bottom:1px solid var(--border);
|
||||
display:flex;flex-direction:column;
|
||||
justify-content:center;padding:8px 14px;
|
||||
position:relative;cursor:default;
|
||||
transition:background 0.2s;
|
||||
}
|
||||
.param-box:last-child{border-bottom:none;}
|
||||
.param-box:hover{background:#151f15;}
|
||||
|
||||
.param-label{
|
||||
font-family:'Orbitron',sans-serif;
|
||||
font-size:9px;letter-spacing:2px;font-weight:700;
|
||||
margin-bottom:2px;
|
||||
}
|
||||
.param-value{
|
||||
font-family:'Orbitron',sans-serif;
|
||||
font-size:36px;font-weight:900;line-height:1;
|
||||
}
|
||||
.param-unit{font-size:11px;font-weight:400;margin-left:4px;opacity:0.7;}
|
||||
.param-sub{font-size:11px;color:var(--dim);margin-top:2px;}
|
||||
.param-badge{
|
||||
position:absolute;top:8px;right:10px;
|
||||
font-size:9px;padding:2px 6px;border-radius:3px;
|
||||
border:1px solid;letter-spacing:1px;
|
||||
}
|
||||
.badge-ok{color:#00ff41;border-color:#00ff41;background:rgba(0,255,65,0.08);}
|
||||
.badge-warn{color:#ffb300;border-color:#ffb300;background:rgba(255,179,0,0.08);}
|
||||
.badge-off{color:#333;border-color:#333;background:transparent;}
|
||||
|
||||
.hr-box .param-label{color:var(--hr-color);}
|
||||
.hr-box .param-value{color:var(--hr-color);text-shadow:0 0 20px rgba(0,255,65,0.5);}
|
||||
.spo2-box .param-label{color:var(--spo2-color);}
|
||||
.spo2-box .param-value{color:var(--spo2-color);text-shadow:0 0 20px rgba(0,229,255,0.5);}
|
||||
.rr-box .param-label{color:var(--rr-color);}
|
||||
.rr-box .param-value{color:var(--rr-color);text-shadow:0 0 20px rgba(255,179,0,0.4);}
|
||||
.nibp-box .param-label{color:var(--nibp-color);}
|
||||
.nibp-box .param-value{color:var(--nibp-color);font-size:26px;}
|
||||
.temp-box .param-label{color:var(--temp-color);}
|
||||
.temp-box .param-value{color:var(--temp-color);font-size:30px;}
|
||||
|
||||
/* ── Scrolling waveform sweep line ── */
|
||||
.sweep-cursor{
|
||||
position:absolute;top:0;bottom:0;width:2px;
|
||||
background:rgba(0,0,0,0.7);pointer-events:none;z-index:3;
|
||||
}
|
||||
|
||||
/* ── No data overlay on strip ── */
|
||||
.strip-nodata{
|
||||
position:absolute;inset:0;z-index:4;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-size:12px;color:#2a4a2a;letter-spacing:1px;
|
||||
pointer-events:none;
|
||||
}
|
||||
|
||||
/* ── Minimal mode ── */
|
||||
body.minimal-mode #statusBar{display:none;}
|
||||
body.minimal-mode #monitorBody{height:100vh;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Verification Overlay -->
|
||||
<div id="verificationOverlay">
|
||||
<div class="ver-card" id="verCard">
|
||||
<div class="spinner" id="verSpinner"></div>
|
||||
<h2 id="verTitle">CONNECTING</h2>
|
||||
<p id="verMsg">Verifying patient identity against monitor records…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Bar -->
|
||||
<div id="statusBar">
|
||||
<div class="sb-left">
|
||||
<span class="sb-patient" id="sbPatient">— PATIENT —</span>
|
||||
<span class="sb-device" id="sbDevice">No device</span>
|
||||
</div>
|
||||
<div class="sb-right">
|
||||
<span><span class="conn-dot offline" id="connDot"></span><span id="connText" style="font-size:11px;color:#4a6a4a;">OFFLINE</span></span>
|
||||
<span id="clockDisplay">--:--:--</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monitor Body -->
|
||||
<div id="monitorBody">
|
||||
|
||||
<!-- Waveform strips -->
|
||||
<div id="waveformCol">
|
||||
<div class="waveform-strip" id="strip-ecg" style="display:none;">
|
||||
<div class="strip-label ecg">I ECG</div>
|
||||
<div class="strip-nodata" id="nodata-ecg">Waiting for ECG lead data…</div>
|
||||
<canvas class="strip-canvas" id="canvas-ecg"></canvas>
|
||||
</div>
|
||||
<div class="waveform-strip" id="strip-pleth" style="display:none;">
|
||||
<div class="strip-label pleth">PLETH</div>
|
||||
<div class="strip-nodata" id="nodata-pleth">Waiting for SpO₂ probe…</div>
|
||||
<canvas class="strip-canvas" id="canvas-pleth"></canvas>
|
||||
</div>
|
||||
<div class="waveform-strip" id="strip-resp">
|
||||
<div class="strip-label resp">RESP</div>
|
||||
<canvas class="strip-canvas" id="canvas-resp"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Parameter panel -->
|
||||
<div id="paramCol">
|
||||
|
||||
<div class="param-box hr-box">
|
||||
<div class="param-label">HEART RATE</div>
|
||||
<div class="param-value" id="val-hr">---<span class="param-unit">bpm</span></div>
|
||||
<div class="param-sub">ECG Lead I</div>
|
||||
<div class="param-badge badge-off" id="badge-hr">WAIT</div>
|
||||
</div>
|
||||
|
||||
<div class="param-box spo2-box">
|
||||
<div class="param-label">SpO₂</div>
|
||||
<div class="param-value" id="val-spo2">---<span class="param-unit">%</span></div>
|
||||
<div class="param-sub" id="sub-spo2">Pleth waveform</div>
|
||||
<div class="param-badge badge-off" id="badge-spo2">WAIT</div>
|
||||
</div>
|
||||
|
||||
<div class="param-box rr-box">
|
||||
<div class="param-label">RESP RATE</div>
|
||||
<div class="param-value" id="val-rr">---<span class="param-unit">/min</span></div>
|
||||
<div class="param-sub">Thoracic impedance</div>
|
||||
<div class="param-badge badge-off" id="badge-rr">WAIT</div>
|
||||
</div>
|
||||
|
||||
<div class="param-box nibp-box">
|
||||
<div class="param-label">NIBP</div>
|
||||
<div class="param-value" id="val-nibp">---<span class="param-unit">mmHg</span></div>
|
||||
<div class="param-sub" id="sub-nibp">Sys / Dia (MAP)</div>
|
||||
<div class="param-badge badge-off" id="badge-nibp">WAIT</div>
|
||||
</div>
|
||||
|
||||
<div class="param-box temp-box">
|
||||
<div class="param-label">TEMP</div>
|
||||
<div class="param-value" id="val-temp">---<span class="param-unit">°C</span></div>
|
||||
<div class="param-sub">Body temperature</div>
|
||||
<div class="param-badge badge-off" id="badge-temp">WAIT</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ════════════════════════════════════════════════════
|
||||
// CONFIG
|
||||
// ════════════════════════════════════════════════════
|
||||
const params = new URLSearchParams(location.search);
|
||||
const MRN = params.get('mrn') || '';
|
||||
const isMinimal = params.get('minimal') === 'true' || params.get('view') === 'minimal';
|
||||
const DEVICE_PARAM = params.get('device') || '';
|
||||
const isDemo = params.get('demo') === 'true' || params.get('synth') === 'true';
|
||||
|
||||
if (isMinimal) document.body.classList.add('minimal-mode');
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// CLOCK
|
||||
// ════════════════════════════════════════════════════
|
||||
function updateClock(){
|
||||
const n = new Date();
|
||||
document.getElementById('clockDisplay').textContent =
|
||||
n.toLocaleTimeString('en-GB',{hour12:false});
|
||||
}
|
||||
setInterval(updateClock, 1000);
|
||||
updateClock();
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// CANVAS WAVEFORM ENGINE
|
||||
// Continuous right-to-left scrolling sweep
|
||||
// ════════════════════════════════════════════════════
|
||||
class WaveformTrack {
|
||||
constructor(canvasId, color, mode='ecg') {
|
||||
this.canvas = document.getElementById(canvasId);
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.color = color;
|
||||
this.mode = mode; // 'ecg' | 'pleth' | 'resp'
|
||||
this.queue = []; // Y-normalised values pending display
|
||||
this.drawBuf= []; // Y-normalised values for each pixel column
|
||||
this.sweepPos = 0;
|
||||
this.lastW = 0; this.lastH = 0;
|
||||
this.synthPhase = 0;
|
||||
this.rrValue = null;
|
||||
// Adaptive peak tracker for auto-scaling
|
||||
this.adaptMax = (mode === 'ecg') ? 20 : 80; // initial range guess
|
||||
this.adaptMin = (mode === 'ecg') ? -20 : 0;
|
||||
// Buffer cap: never keep more than ~1 second queued (stay real-time)
|
||||
this.MAX_QUEUE = 280;
|
||||
}
|
||||
|
||||
resize() {
|
||||
const w = this.canvas.offsetWidth;
|
||||
const h = this.canvas.offsetHeight;
|
||||
if (w !== this.lastW || h !== this.lastH) {
|
||||
this.canvas.width = w;
|
||||
this.canvas.height = h;
|
||||
this.lastW = w; this.lastH = h;
|
||||
this.drawBuf = new Array(w).fill(null);
|
||||
this.sweepPos = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Normalise one raw byte → Y (0=top, 1=bottom) ──────────────
|
||||
_normalise(s) {
|
||||
if (this.mode === 'ecg') {
|
||||
// ECG baseline = 128 (0x80). Centred deviation.
|
||||
const dev = s - 128; // positive = above baseline = upward deflection
|
||||
// Expand adaptive range to capture QRS peaks
|
||||
if (dev > this.adaptMax) this.adaptMax = dev;
|
||||
if (dev < this.adaptMin) this.adaptMin = dev;
|
||||
// Slow decay back to ±20 minimum (avoid over-zoom on noise)
|
||||
this.adaptMax = Math.max(20, this.adaptMax * 0.9995);
|
||||
this.adaptMin = Math.min(-20, this.adaptMin * 0.9995);
|
||||
const span = Math.max(this.adaptMax - this.adaptMin, 30);
|
||||
// Map: positive dev → Y decreases (trace goes UP)
|
||||
const frac = dev / (span / 2);
|
||||
return Math.max(0.02, Math.min(0.98, 0.5 - frac * 0.44));
|
||||
|
||||
} else if (this.mode === 'pleth') {
|
||||
// Pleth: 0 = baseline (bottom of strip), ~90 = pulse peak (top)
|
||||
if (s > this.adaptMax) this.adaptMax = s;
|
||||
this.adaptMax = Math.max(40, this.adaptMax * 0.9998);
|
||||
const maxV = Math.max(this.adaptMax, 40);
|
||||
// High sample → trace goes UP (y decreases)
|
||||
const frac = Math.max(0, s) / maxV;
|
||||
return Math.max(0.02, Math.min(0.98, 0.85 - frac * 0.78));
|
||||
|
||||
} else {
|
||||
const frac = s / 255;
|
||||
return Math.max(0.02, Math.min(0.98, 0.5 + Math.sin(frac * Math.PI - Math.PI/2) * 0.3));
|
||||
}
|
||||
}
|
||||
|
||||
// Push raw byte array from WebSocket message
|
||||
pushSamples(arr) {
|
||||
if (!arr || arr.length === 0) return;
|
||||
for (const s of arr) {
|
||||
this.queue.push(this._normalise(s));
|
||||
}
|
||||
// Throttle: drop oldest samples if queue too large (real-time priority)
|
||||
if (this.queue.length > this.MAX_QUEUE) {
|
||||
this.queue.splice(0, this.queue.length - this.MAX_QUEUE);
|
||||
}
|
||||
}
|
||||
|
||||
// Advance sweep by one pixel
|
||||
tick() {
|
||||
const w = this.canvas.width;
|
||||
if (!w) return;
|
||||
|
||||
// Erase 6px ahead of write head
|
||||
for (let e = 1; e <= 6; e++) {
|
||||
this.drawBuf[(this.sweepPos + e) % w] = null;
|
||||
}
|
||||
|
||||
let yNorm;
|
||||
if (this.queue.length > 0) {
|
||||
yNorm = this.queue.shift();
|
||||
} else if (isDemo) {
|
||||
yNorm = this._synth();
|
||||
} else {
|
||||
// Flatline baseline based on mode
|
||||
if (this.mode === 'pleth') {
|
||||
yNorm = 0.85; // flatline at bottom for pleth
|
||||
} else {
|
||||
yNorm = 0.5; // center flatline for ecg/resp
|
||||
}
|
||||
}
|
||||
this.drawBuf[this.sweepPos] = yNorm;
|
||||
this.sweepPos = (this.sweepPos + 1) % w;
|
||||
}
|
||||
|
||||
_synth() {
|
||||
const m = this.mode;
|
||||
this.synthPhase += 0.04;
|
||||
const t = this.synthPhase;
|
||||
if (m === 'ecg') {
|
||||
// Realistic P-QRS-T morphology
|
||||
const period = Math.PI * 2;
|
||||
const p = ((t % period) / period);
|
||||
if (p > 0.40 && p < 0.43) return 0.5 - 0.38; // R peak (up)
|
||||
if (p > 0.43 && p < 0.46) return 0.5 + 0.14; // S trough (down)
|
||||
if (p > 0.30 && p < 0.36) return 0.5 - 0.06; // P wave
|
||||
if (p > 0.55 && p < 0.65) return 0.5 - 0.08; // T wave
|
||||
return 0.5 + Math.sin(t * 0.1) * 0.01;
|
||||
} else if (m === 'pleth') {
|
||||
const pulse = Math.max(0, Math.sin(t * 0.7));
|
||||
return 0.85 - pulse * 0.75;
|
||||
} else {
|
||||
const rr = this.rrValue || 15;
|
||||
const speed = (rr / 60) * 0.30;
|
||||
return 0.5 + Math.sin(t * speed) * 0.28;
|
||||
}
|
||||
}
|
||||
|
||||
draw() {
|
||||
const ctx = this.ctx;
|
||||
const w = this.canvas.width;
|
||||
const h = this.canvas.height;
|
||||
if (!w || !h) return;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// ECG-paper style grid
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
|
||||
ctx.lineWidth = 1;
|
||||
const gridX = Math.round(w / 8);
|
||||
const gridY = Math.round(h / 4);
|
||||
for (let x = gridX; x < w; x += gridX) {
|
||||
ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke();
|
||||
}
|
||||
for (let y = gridY; y < h; y += gridY) {
|
||||
ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke();
|
||||
}
|
||||
|
||||
// Waveform line
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = this.color;
|
||||
ctx.lineWidth = (this.mode === 'ecg') ? 1.6 : 2.0;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.shadowColor = this.color;
|
||||
ctx.shadowBlur = (this.mode === 'ecg') ? 5 : 8;
|
||||
|
||||
let started = false;
|
||||
for (let x = 0; x < w; x++) {
|
||||
const idx = (this.sweepPos + x) % w;
|
||||
const yv = this.drawBuf[idx];
|
||||
if (yv === null || yv === undefined) { started = false; continue; }
|
||||
const py = yv * h;
|
||||
if (!started) { ctx.moveTo(x, py); started = true; }
|
||||
else { ctx.lineTo(x, py); }
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Sweep erase bar
|
||||
ctx.fillStyle = 'rgba(10,14,10,0.92)';
|
||||
ctx.fillRect(((this.sweepPos - 4 + w) % w), 0, 8, h);
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// TRACKS (mode must match constructor: 'ecg' | 'pleth' | 'resp')
|
||||
// ════════════════════════════════════════════════════
|
||||
const ecgTrack = new WaveformTrack('canvas-ecg', '#00ff41', 'ecg');
|
||||
const plethTrack = new WaveformTrack('canvas-pleth','#00e5ff', 'pleth');
|
||||
const respTrack = new WaveformTrack('canvas-resp', '#ffb300', 'resp');
|
||||
|
||||
// Track whether real data has been received (to show/hide strips)
|
||||
let ecgActive = false;
|
||||
let plethActive = false;
|
||||
|
||||
function showStrip(id, nodataId) {
|
||||
const strip = document.getElementById(id);
|
||||
const nd = document.getElementById(nodataId);
|
||||
if (strip) strip.style.display = '';
|
||||
if (nd) nd.style.display = 'none';
|
||||
}
|
||||
|
||||
// Resize observer
|
||||
const ro = new ResizeObserver(() => {
|
||||
ecgTrack.resize(); plethTrack.resize(); respTrack.resize();
|
||||
});
|
||||
ro.observe(document.getElementById('waveformCol'));
|
||||
|
||||
// Animation loop
|
||||
const TICKS_PER_FRAME = 3; // speed of sweep
|
||||
function loop() {
|
||||
ecgTrack.resize(); plethTrack.resize(); respTrack.resize();
|
||||
for (let i=0;i<TICKS_PER_FRAME;i++){
|
||||
if (ecgActive) ecgTrack.tick();
|
||||
if (plethActive) plethTrack.tick();
|
||||
respTrack.tick();
|
||||
}
|
||||
if (ecgActive) ecgTrack.draw();
|
||||
if (plethActive) plethTrack.draw();
|
||||
respTrack.draw();
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// PARAM UPDATE
|
||||
// ════════════════════════════════════════════════════
|
||||
function setParam(id, value, unit, badgeId, lo, hi) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
if (value === null || value === undefined) {
|
||||
el.innerHTML = `---<span class="param-unit">${unit}</span>`;
|
||||
const b = document.getElementById(badgeId);
|
||||
if (b){ b.textContent='WAIT'; b.className='param-badge badge-off'; }
|
||||
return;
|
||||
}
|
||||
const v = parseFloat(value);
|
||||
el.innerHTML = `${Number.isInteger(v)?v:v.toFixed(1)}<span class="param-unit">${unit}</span>`;
|
||||
const b = document.getElementById(badgeId);
|
||||
if (b) {
|
||||
if (v >= lo && v <= hi){ b.textContent='NML'; b.className='param-badge badge-ok'; }
|
||||
else { b.textContent='WARN'; b.className='param-badge badge-warn'; }
|
||||
}
|
||||
}
|
||||
|
||||
function updateParams(d) {
|
||||
setParam('val-hr', d.heart_rate, 'bpm', 'badge-hr', 60, 100);
|
||||
setParam('val-spo2', d.spo2, '%', 'badge-spo2', 95, 100);
|
||||
setParam('val-rr', d.respiratory_rate, '/min', 'badge-rr', 12, 20);
|
||||
setParam('val-temp', d.temperature, '°C', 'badge-temp', 36, 38.5);
|
||||
|
||||
if (d.systolic_bp != null) {
|
||||
const v = `${Math.round(d.systolic_bp)}/${Math.round(d.diastolic_bp||0)}`;
|
||||
const m = d.map_bp ? ` (${Math.round(d.map_bp)})` : '';
|
||||
document.getElementById('val-nibp').innerHTML =
|
||||
`${v}<span class="param-unit">mmHg</span>`;
|
||||
document.getElementById('sub-nibp').textContent = `MAP${m}`;
|
||||
const b = document.getElementById('badge-nibp');
|
||||
const ok = d.systolic_bp>=90&&d.systolic_bp<=140;
|
||||
b.textContent = ok ? 'NML' : 'WARN';
|
||||
b.className = 'param-badge ' + (ok ? 'badge-ok' : 'badge-warn');
|
||||
}
|
||||
|
||||
// Feed resp synth with RR
|
||||
if (d.respiratory_rate) {
|
||||
respTrack.rrValue = d.respiratory_rate;
|
||||
respTrack.synthType = 'resp';
|
||||
}
|
||||
|
||||
// Status bar
|
||||
if (d.patient_id) {
|
||||
document.getElementById('sbPatient').textContent =
|
||||
d.patient_name ? d.patient_name.toUpperCase() : `MRN ${d.patient_id}`;
|
||||
}
|
||||
if (d.device_id) {
|
||||
document.getElementById('sbDevice').textContent = d.device_id;
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// VERIFICATION
|
||||
// ════════════════════════════════════════════════════
|
||||
let verified = false;
|
||||
let deviceId = DEVICE_PARAM || null;
|
||||
const overlay = document.getElementById('verificationOverlay');
|
||||
const verCard = document.getElementById('verCard');
|
||||
const verSpinner= document.getElementById('verSpinner');
|
||||
const verTitle = document.getElementById('verTitle');
|
||||
const verMsg = document.getElementById('verMsg');
|
||||
|
||||
function showOverlay(title, msg, icon=null) {
|
||||
overlay.style.display = 'flex';
|
||||
verTitle.textContent = title;
|
||||
verMsg.textContent = msg;
|
||||
if (icon) {
|
||||
verSpinner.style.display='none';
|
||||
const ic = verCard.querySelector('.icon') || (() => {
|
||||
const d=document.createElement('div');d.className='icon';
|
||||
verCard.prepend(d);return d;
|
||||
})();
|
||||
ic.textContent = icon;
|
||||
}
|
||||
}
|
||||
|
||||
function hideOverlay() {
|
||||
overlay.style.display='none';
|
||||
verified=true;
|
||||
}
|
||||
|
||||
async function verifyMRN() {
|
||||
if (!MRN) {
|
||||
showOverlay('NO MRN','No MRN provided in URL. Use ?mrn=XXXXXXXX','🔒');
|
||||
return;
|
||||
}
|
||||
showOverlay('VERIFYING',`Matching MRN ${MRN} against monitor records…`);
|
||||
try {
|
||||
const res = await fetch(`/api/match-patient?mrn=${encodeURIComponent(MRN)}`);
|
||||
const data = await res.json();
|
||||
if (data.matched) {
|
||||
deviceId = data.device_id || deviceId;
|
||||
if (deviceId) {
|
||||
document.getElementById('sbDevice').textContent = deviceId;
|
||||
}
|
||||
hideOverlay();
|
||||
connectWS();
|
||||
} else {
|
||||
showOverlay('ACCESS RESTRICTED', data.reason || 'MRN does not match monitor records.','🔐');
|
||||
}
|
||||
} catch(e) {
|
||||
showOverlay('CONNECTION ERROR','Cannot reach verification server. Retrying…','⚠️');
|
||||
setTimeout(verifyMRN, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// WEBSOCKET
|
||||
// ════════════════════════════════════════════════════
|
||||
let ws = null;
|
||||
let wsRetry = 0;
|
||||
const connDot = document.getElementById('connDot');
|
||||
const connText = document.getElementById('connText');
|
||||
|
||||
function setOnline(on) {
|
||||
connDot.className = 'conn-dot ' + (on ? 'online' : 'offline');
|
||||
connText.textContent = on ? 'LIVE' : 'OFFLINE';
|
||||
connText.style.color = on ? '#00ff41' : '#4a6a4a';
|
||||
}
|
||||
|
||||
function connectWS() {
|
||||
const proto = location.protocol==='https:' ? 'wss' : 'ws';
|
||||
const url = `${proto}://${location.host}/ws`;
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => { setOnline(true); wsRetry=0; };
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const d = JSON.parse(ev.data);
|
||||
|
||||
// Filter by device if known
|
||||
if (deviceId && d.device_id && d.device_id !== deviceId) return;
|
||||
|
||||
// Push waveform samples if present — show strip on first real data
|
||||
if (d.ecg_wave && d.ecg_wave.length) {
|
||||
if (!ecgActive) { ecgActive = true; showStrip('strip-ecg', 'nodata-ecg'); }
|
||||
ecgTrack.pushSamples(d.ecg_wave);
|
||||
}
|
||||
if (d.pleth_wave && d.pleth_wave.length) {
|
||||
if (!plethActive) { plethActive = true; showStrip('strip-pleth', 'nodata-pleth'); }
|
||||
plethTrack.pushSamples(d.pleth_wave);
|
||||
}
|
||||
|
||||
// Update numeric params
|
||||
updateParams(d);
|
||||
} catch(e){}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setOnline(false);
|
||||
const delay = Math.min(1000 * 2**wsRetry++, 30000);
|
||||
setTimeout(connectWS, delay);
|
||||
};
|
||||
|
||||
ws.onerror = () => ws.close();
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// BOOT
|
||||
// ════════════════════════════════════════════════════
|
||||
verifyMRN();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user