Files
ContecMonitor/static/dashboard.html
T

666 lines
24 KiB
HTML
Raw Normal View History

<!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>