2026-07-08 14:14:03 +05:30
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
2026-07-15 13:37:14 +05:30
|
|
|
<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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Minimal mode ── */
|
|
|
|
|
body.minimal-mode #statusBar{display:none;}
|
|
|
|
|
body.minimal-mode #monitorBody{height:100vh;}
|
|
|
|
|
</style>
|
2026-07-08 14:14:03 +05:30
|
|
|
</head>
|
|
|
|
|
<body>
|
2026-07-15 12:51:46 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
<!-- 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">
|
|
|
|
|
<div class="strip-label ecg">I ECG</div>
|
|
|
|
|
<canvas class="strip-canvas" id="canvas-ecg"></canvas>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="waveform-strip" id="strip-pleth">
|
|
|
|
|
<div class="strip-label pleth">PLETH</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>
|
2026-07-15 12:51:46 +05:30
|
|
|
</div>
|
|
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
<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>
|
2026-07-15 11:33:48 +05:30
|
|
|
</div>
|
|
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
<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>
|
2026-07-08 14:14:03 +05:30
|
|
|
</div>
|
|
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
<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>
|
2026-07-08 14:14:03 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
<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>
|
2026-07-15 12:51:46 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-07-08 14:14:03 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
<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') || '';
|
2026-07-11 14:24:14 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
if (isMinimal) document.body.classList.add('minimal-mode');
|
2026-07-15 11:33:48 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
// ════════════════════════════════════════════════════
|
|
|
|
|
// CANVAS WAVEFORM ENGINE
|
|
|
|
|
// Continuous right-to-left scrolling sweep
|
|
|
|
|
// ════════════════════════════════════════════════════
|
|
|
|
|
class WaveformTrack {
|
2026-07-15 14:46:44 +05:30
|
|
|
constructor(canvasId, color, mode='ecg') {
|
2026-07-15 13:37:14 +05:30
|
|
|
this.canvas = document.getElementById(canvasId);
|
|
|
|
|
this.ctx = this.canvas.getContext('2d');
|
|
|
|
|
this.color = color;
|
2026-07-15 14:46:44 +05:30
|
|
|
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;
|
2026-07-15 13:37:14 +05:30
|
|
|
this.synthPhase = 0;
|
2026-07-15 14:46:44 +05:30
|
|
|
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;
|
2026-07-15 13:37:14 +05:30
|
|
|
}
|
2026-07-15 12:51:46 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
resize() {
|
|
|
|
|
const w = this.canvas.offsetWidth;
|
|
|
|
|
const h = this.canvas.offsetHeight;
|
2026-07-15 14:46:44 +05:30
|
|
|
if (w !== this.lastW || h !== this.lastH) {
|
2026-07-15 13:37:14 +05:30
|
|
|
this.canvas.width = w;
|
|
|
|
|
this.canvas.height = h;
|
2026-07-15 14:46:44 +05:30
|
|
|
this.lastW = w; this.lastH = h;
|
2026-07-15 13:37:14 +05:30
|
|
|
this.drawBuf = new Array(w).fill(null);
|
|
|
|
|
this.sweepPos = 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-15 12:51:46 +05:30
|
|
|
|
2026-07-15 14:46:44 +05:30
|
|
|
// ── 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));
|
2026-07-15 13:37:14 +05:30
|
|
|
}
|
|
|
|
|
}
|
2026-07-15 12:51:46 +05:30
|
|
|
|
2026-07-15 14:46:44 +05:30
|
|
|
// 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
|
2026-07-15 13:37:14 +05:30
|
|
|
tick() {
|
|
|
|
|
const w = this.canvas.width;
|
2026-07-15 14:46:44 +05:30
|
|
|
if (!w) return;
|
2026-07-15 12:51:46 +05:30
|
|
|
|
2026-07-15 14:46:44 +05:30
|
|
|
// Erase 6px ahead of write head
|
|
|
|
|
for (let e = 1; e <= 6; e++) {
|
|
|
|
|
this.drawBuf[(this.sweepPos + e) % w] = null;
|
2026-07-15 13:37:14 +05:30
|
|
|
}
|
2026-07-15 11:33:48 +05:30
|
|
|
|
2026-07-15 14:46:44 +05:30
|
|
|
const yNorm = this.queue.length > 0 ? this.queue.shift() : this._synth();
|
2026-07-15 13:37:14 +05:30
|
|
|
this.drawBuf[this.sweepPos] = yNorm;
|
|
|
|
|
this.sweepPos = (this.sweepPos + 1) % w;
|
|
|
|
|
}
|
2026-07-15 11:33:48 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
_synth() {
|
2026-07-15 14:46:44 +05:30
|
|
|
const m = this.mode;
|
|
|
|
|
this.synthPhase += 0.04;
|
2026-07-15 13:37:14 +05:30
|
|
|
const t = this.synthPhase;
|
2026-07-15 14:46:44 +05:30
|
|
|
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 {
|
2026-07-15 13:37:14 +05:30
|
|
|
const rr = this.rrValue || 15;
|
2026-07-15 14:46:44 +05:30
|
|
|
const speed = (rr / 60) * 0.30;
|
|
|
|
|
return 0.5 + Math.sin(t * speed) * 0.28;
|
2026-07-15 13:37:14 +05:30
|
|
|
}
|
|
|
|
|
}
|
2026-07-15 11:33:48 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
draw() {
|
|
|
|
|
const ctx = this.ctx;
|
|
|
|
|
const w = this.canvas.width;
|
|
|
|
|
const h = this.canvas.height;
|
|
|
|
|
if (!w || !h) return;
|
2026-07-08 14:14:03 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
ctx.clearRect(0, 0, w, h);
|
2026-07-15 12:51:46 +05:30
|
|
|
|
2026-07-15 14:46:44 +05:30
|
|
|
// ECG-paper style grid
|
|
|
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
|
2026-07-15 13:37:14 +05:30
|
|
|
ctx.lineWidth = 1;
|
2026-07-15 14:46:44 +05:30
|
|
|
const gridX = Math.round(w / 8);
|
|
|
|
|
const gridY = Math.round(h / 4);
|
|
|
|
|
for (let x = gridX; x < w; x += gridX) {
|
2026-07-15 13:37:14 +05:30
|
|
|
ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke();
|
|
|
|
|
}
|
2026-07-15 14:46:44 +05:30
|
|
|
for (let y = gridY; y < h; y += gridY) {
|
2026-07-15 13:37:14 +05:30
|
|
|
ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke();
|
|
|
|
|
}
|
2026-07-08 14:14:03 +05:30
|
|
|
|
2026-07-15 14:46:44 +05:30
|
|
|
// Waveform line
|
2026-07-15 13:37:14 +05:30
|
|
|
ctx.beginPath();
|
|
|
|
|
ctx.strokeStyle = this.color;
|
2026-07-15 14:46:44 +05:30
|
|
|
ctx.lineWidth = (this.mode === 'ecg') ? 1.6 : 2.0;
|
|
|
|
|
ctx.lineJoin = 'round';
|
2026-07-15 13:37:14 +05:30
|
|
|
ctx.shadowColor = this.color;
|
2026-07-15 14:46:44 +05:30
|
|
|
ctx.shadowBlur = (this.mode === 'ecg') ? 5 : 8;
|
2026-07-08 14:14:03 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
let started = false;
|
|
|
|
|
for (let x = 0; x < w; x++) {
|
|
|
|
|
const idx = (this.sweepPos + x) % w;
|
2026-07-15 14:46:44 +05:30
|
|
|
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); }
|
2026-07-15 13:37:14 +05:30
|
|
|
}
|
|
|
|
|
ctx.stroke();
|
|
|
|
|
ctx.shadowBlur = 0;
|
2026-07-08 14:14:03 +05:30
|
|
|
|
2026-07-15 14:46:44 +05:30
|
|
|
// Sweep erase bar
|
|
|
|
|
ctx.fillStyle = 'rgba(10,14,10,0.92)';
|
|
|
|
|
ctx.fillRect(((this.sweepPos - 4 + w) % w), 0, 8, h);
|
2026-07-15 13:37:14 +05:30
|
|
|
}
|
|
|
|
|
}
|
2026-07-15 12:51:46 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
// ════════════════════════════════════════════════════
|
|
|
|
|
// TRACKS
|
|
|
|
|
// ════════════════════════════════════════════════════
|
|
|
|
|
const ecgTrack = new WaveformTrack('canvas-ecg', '#00ff41', 0.5, 0.42);
|
|
|
|
|
const plethTrack = new WaveformTrack('canvas-pleth','#00e5ff', 0.5, 0.38);
|
|
|
|
|
const respTrack = new WaveformTrack('canvas-resp', '#ffb300', 0.5, 0.30);
|
2026-07-08 14:14:03 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
ecgTrack.synthType = 'ecg';
|
|
|
|
|
plethTrack.synthType = 'pleth';
|
|
|
|
|
respTrack.synthType = 'resp';
|
2026-07-08 14:14:03 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
// Resize observer
|
|
|
|
|
const ro = new ResizeObserver(() => {
|
|
|
|
|
ecgTrack.resize(); plethTrack.resize(); respTrack.resize();
|
|
|
|
|
});
|
|
|
|
|
ro.observe(document.getElementById('waveformCol'));
|
2026-07-15 12:51:46 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
// 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++){
|
|
|
|
|
ecgTrack.tick(); plethTrack.tick(); respTrack.tick();
|
|
|
|
|
}
|
|
|
|
|
ecgTrack.draw(); plethTrack.draw(); respTrack.draw();
|
|
|
|
|
requestAnimationFrame(loop);
|
|
|
|
|
}
|
|
|
|
|
requestAnimationFrame(loop);
|
2026-07-15 12:51:46 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
// ════════════════════════════════════════════════════
|
|
|
|
|
// 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'; }
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-08 14:14:03 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
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);
|
2026-07-08 14:14:03 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
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');
|
|
|
|
|
}
|
2026-07-08 14:14:03 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
// Feed resp synth with RR
|
|
|
|
|
if (d.respiratory_rate) {
|
|
|
|
|
respTrack.rrValue = d.respiratory_rate;
|
|
|
|
|
respTrack.synthType = 'resp';
|
|
|
|
|
}
|
2026-07-08 14:14:03 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-15 13:00:19 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
// ════════════════════════════════════════════════════
|
|
|
|
|
// 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');
|
2026-07-08 14:14:03 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-08 14:14:03 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
function hideOverlay() {
|
|
|
|
|
overlay.style.display='none';
|
|
|
|
|
verified=true;
|
|
|
|
|
}
|
2026-07-08 14:14:03 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-08 14:14:03 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
// ════════════════════════════════════════════════════
|
|
|
|
|
// WEBSOCKET
|
|
|
|
|
// ════════════════════════════════════════════════════
|
|
|
|
|
let ws = null;
|
|
|
|
|
let wsRetry = 0;
|
|
|
|
|
const connDot = document.getElementById('connDot');
|
|
|
|
|
const connText = document.getElementById('connText');
|
2026-07-08 14:14:03 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
function setOnline(on) {
|
|
|
|
|
connDot.className = 'conn-dot ' + (on ? 'online' : 'offline');
|
|
|
|
|
connText.textContent = on ? 'LIVE' : 'OFFLINE';
|
|
|
|
|
connText.style.color = on ? '#00ff41' : '#4a6a4a';
|
|
|
|
|
}
|
2026-07-15 13:00:19 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
function connectWS() {
|
|
|
|
|
const proto = location.protocol==='https:' ? 'wss' : 'ws';
|
|
|
|
|
const url = `${proto}://${location.host}/ws`;
|
|
|
|
|
ws = new WebSocket(url);
|
2026-07-15 13:00:19 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
ws.onopen = () => { setOnline(true); wsRetry=0; };
|
2026-07-15 13:00:19 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
ws.onmessage = (ev) => {
|
|
|
|
|
try {
|
|
|
|
|
const d = JSON.parse(ev.data);
|
2026-07-15 13:00:19 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
// Filter by device if known
|
|
|
|
|
if (deviceId && d.device_id && d.device_id !== deviceId) return;
|
2026-07-15 13:00:19 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
// Push waveform samples if present
|
|
|
|
|
if (d.ecg_wave && d.ecg_wave.length) ecgTrack.pushSamples(d.ecg_wave);
|
|
|
|
|
if (d.pleth_wave && d.pleth_wave.length) plethTrack.pushSamples(d.pleth_wave);
|
2026-07-15 13:00:19 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
// Update numeric params
|
|
|
|
|
updateParams(d);
|
|
|
|
|
} catch(e){}
|
|
|
|
|
};
|
2026-07-15 13:00:19 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
ws.onclose = () => {
|
|
|
|
|
setOnline(false);
|
|
|
|
|
const delay = Math.min(1000 * 2**wsRetry++, 30000);
|
|
|
|
|
setTimeout(connectWS, delay);
|
|
|
|
|
};
|
2026-07-15 13:00:19 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
ws.onerror = () => ws.close();
|
|
|
|
|
}
|
2026-07-15 13:00:19 +05:30
|
|
|
|
2026-07-15 13:37:14 +05:30
|
|
|
// ════════════════════════════════════════════════════
|
|
|
|
|
// BOOT
|
|
|
|
|
// ════════════════════════════════════════════════════
|
|
|
|
|
verifyMRN();
|
|
|
|
|
</script>
|
2026-07-08 14:14:03 +05:30
|
|
|
</body>
|
|
|
|
|
</html>
|