Files
ContecMonitor/fiveparaminte-main/static/dashboard.html
T
prathiyuman 14f3d45e53 feat: real ECG/Pleth waveform streaming + clinical monitor UI redesign
- schemas.py: Add ecg_wave and pleth_wave List[int] fields to NormalizedVitals
- contec_parser.py: Extract raw 8-bit waveform samples from binary packets
  - 286/288-byte (Subtype 21): bytes[8..263] = Pleth waveform (256 samples)
  - 989-byte (Subtype 20): bytes[8..903] = ECG waveform, downsampled 4x -> 224 pts
- dashboard.html: Full clinical monitor redesign
  - Scrolling sweep waveform engine (ECG green, Pleth cyan, Resp amber)
  - Real samples fed from WebSocket ecg_wave / pleth_wave arrays
  - Synthetic fallbacks when no live data
  - Right panel: HR, SpO2, RR, NIBP, Temp with range badges
  - MRN-only verification overlay
  - Minimal-mode support for iframe embedding
2026-07-15 13:37:14 +05:30

599 lines
21 KiB
HTML

<!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;
}
/* ── 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">
<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>
</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') || '';
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, baseline=0.5, amplitude=0.38) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
this.color = color;
this.baseline = baseline; // 0..1 from top
this.amplitude = amplitude; // fraction of height
this.samples = []; // raw 0-255 bytes from monitor
this.drawBuf = []; // processed display values (0..1 Y position)
this.MAX_DRAW = 600; // max points in draw buffer
this.sweepPos = 0; // current write head X pixel
this.lastWidth = 0;
this.lastHeight= 0;
this.animating = false;
this.synthPhase = 0;
this.synthType = 'flat';
this.rrValue = null;
}
resize() {
const w = this.canvas.offsetWidth;
const h = this.canvas.offsetHeight;
if (w !== this.lastWidth || h !== this.lastHeight) {
this.canvas.width = w;
this.canvas.height = h;
this.lastWidth = w; this.lastHeight = h;
this.drawBuf = new Array(w).fill(null);
this.sweepPos = 0;
}
}
// Push raw byte samples (0-255) from monitor packet
pushSamples(arr) {
if (!arr || arr.length === 0) return;
// normalise 0-255 → 0..1 Y position (inverted: 0=top)
for (const s of arr) {
const norm = (255 - s) / 255; // flip: high byte = up
const y = this.baseline + (norm - 0.5) * this.amplitude * 2;
this.samples.push(Math.max(0.02, Math.min(0.98, y)));
}
}
// Drain one sample into draw buffer at sweepPos
tick() {
const w = this.canvas.width;
const h = this.canvas.height;
if (!w || !h) return;
// blank 6px ahead of sweep cursor
const eraseW = 8;
for (let e = 0; e < eraseW; e++) {
const ex = (this.sweepPos + e) % w;
if (this.drawBuf[ex] !== undefined) this.drawBuf[ex] = null;
}
let yNorm;
if (this.samples.length > 0) {
yNorm = this.samples.shift();
} else {
// synthetic fallback
yNorm = this._synth();
}
this.drawBuf[this.sweepPos] = yNorm;
this.sweepPos = (this.sweepPos + 1) % w;
}
_synth() {
this.synthPhase += 0.05;
const t = this.synthPhase;
if (this.synthType === 'ecg') {
const p = (t % (Math.PI * 2)) / (Math.PI * 2);
if (p > 0.35 && p < 0.38) return this.baseline - 0.32;
if (p > 0.38 && p < 0.42) return this.baseline + 0.12;
return this.baseline + Math.sin(t * 0.3) * 0.02;
} else if (this.synthType === 'pleth') {
return this.baseline + Math.sin(t * 0.8) * 0.28;
} else if (this.synthType === 'resp') {
const rr = this.rrValue || 15;
const speed = rr / 60 * 0.025;
return this.baseline + Math.sin(this.synthPhase * speed * 20) * 0.22;
}
return this.baseline; // flat
}
draw() {
const ctx = this.ctx;
const w = this.canvas.width;
const h = this.canvas.height;
if (!w || !h) return;
// Clear
ctx.clearRect(0, 0, w, h);
// Grid lines (subtle)
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
ctx.lineWidth = 1;
for (let x = 0; x < w; x += 50) {
ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke();
}
for (let y = 0; y < h; y += h/4) {
ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke();
}
// Waveform
ctx.beginPath();
ctx.strokeStyle = this.color;
ctx.lineWidth = 1.8;
ctx.shadowColor = this.color;
ctx.shadowBlur = 6;
let started = false;
for (let x = 0; x < w; x++) {
const idx = (this.sweepPos + x) % w;
const yNorm = this.drawBuf[idx];
if (yNorm === null || yNorm === undefined) {
started = false; continue;
}
const px = x;
const py = yNorm * h;
if (!started) { ctx.moveTo(px, py); started = true; }
else { ctx.lineTo(px, py); }
}
ctx.stroke();
ctx.shadowBlur = 0;
// Sweep cursor (black erase bar)
ctx.fillStyle = 'rgba(10,14,10,0.85)';
ctx.fillRect(((this.sweepPos + w - 4) % w), 0, 6, h);
}
}
// ════════════════════════════════════════════════════
// 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);
ecgTrack.synthType = 'ecg';
plethTrack.synthType = 'pleth';
respTrack.synthType = 'resp';
// 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++){
ecgTrack.tick(); plethTrack.tick(); respTrack.tick();
}
ecgTrack.draw(); 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
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);
// 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>