Files
ContecMonitor/fiveparaminte-main/static/dashboard.html
T

1599 lines
56 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>Patient Vital Signs Monitor — Contec CMS8500</title>
<meta name="description" content="Real-time patient vital signs monitoring dashboard for Contec CMS8500 patient monitors">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;600;700;800&display=swap" rel="stylesheet">
<style>
/* === CSS Reset & Base === */
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
/* Monitor Theme Palette (Identical to physical monitor) */
--bg-monitor: #000000;
--bg-card: #080808;
--border-color: #1a1a1a;
--ecg-color: #00ff66; /* Bright medical green */
--spo2-color: #00ffff; /* Cyan */
--resp-color: #ffff00; /* Yellow */
--nibp-color: #ffffff; /* White */
--temp-color: #ffff00; /* Yellow temperature */
--gray-btn: #333333;
--gray-btn-hover: #444444;
--text-dim: #888888;
}
body {
font-family: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-monitor);
color: #ffffff;
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
user-select: none;
}
/* === Retractable Developer Control Panel === */
.dev-controls {
position: fixed;
top: 0;
left: 50%;
transform: translateX(-50%) translateY(-100%);
background: rgba(20, 20, 20, 0.95);
border: 1px solid #333;
border-top: none;
border-radius: 0 0 16px 16px;
padding: 10px 24px;
display: flex;
align-items: center;
gap: 16px;
z-index: 1000;
box-shadow: 0 10px 30px rgba(0,0,0,0.8);
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.dev-controls:hover,
.dev-controls.active {
transform: translateX(-50%) translateY(0);
}
/* Invisible hover trigger zone at the top of the page */
.dev-trigger-zone {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 12px;
z-index: 999;
cursor: pointer;
}
.dev-trigger-zone:hover + .dev-controls {
transform: translateX(-50%) translateY(0);
}
.dev-pull-tab {
position: absolute;
bottom: -20px;
left: 50%;
transform: translateX(-50%);
background: #222;
color: #888;
font-size: 9px;
padding: 2px 10px;
border-radius: 0 0 6px 6px;
border: 1px solid #333;
border-top: none;
font-weight: 700;
pointer-events: none;
}
.device-selector-container {
display: flex;
align-items: center;
gap: 8px;
}
.device-select-dropdown {
background: #111;
border: 1px solid #444;
color: #fff;
padding: 4px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
outline: none;
cursor: pointer;
}
.audio-toggle {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
background: #222;
border: 1px solid #444;
color: #ccc;
cursor: pointer;
}
.audio-toggle.active {
background: rgba(255, 0, 0, 0.2);
border-color: #ff3333;
color: #ff5555;
}
.connection-badge {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
background: rgba(0, 255, 0, 0.1);
border: 1px solid #00aa44;
color: #00ff66;
}
.connection-badge.offline {
background: rgba(255,0,0,0.1);
border-color: #aa0000;
color: #ff3333;
}
.connection-badge.warning {
background: rgba(255,255,0,0.1);
border-color: #aaaa00;
color: #ffff00;
}
/* === Patient Verification Overlay === */
.verification-overlay {
position: fixed;
inset: 0;
background: #000000;
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.error-card {
background: #080808;
border: 1px solid #222;
border-radius: 12px;
padding: 24px;
max-width: 450px;
text-align: center;
box-shadow: 0 10px 40px rgba(0,0,0,0.9);
}
.error-icon {
font-size: 48px;
margin-bottom: 16px;
}
.error-card h2 {
font-size: 18px;
font-weight: 700;
color: #fff;
margin-bottom: 10px;
}
.error-card p {
font-size: 13px;
color: #888;
line-height: 1.5;
}
.verification-overlay.loading .error-icon {
animation: spin 1.5s linear infinite;
}
@keyframes spin {
100% { transform: rotate(360deg); }
}
/* === CMS8500 Main Monitor Layout === */
.monitor-container {
display: flex;
flex-direction: column;
flex: 1;
background: #000;
padding: 4px;
justify-content: space-between;
}
/* Top Banner Info */
.monitor-header {
display: flex;
align-items: center;
justify-content: space-between;
background: #000;
padding: 2px 8px;
font-size: 12px;
font-weight: 600;
border-bottom: 1px solid #111;
height: 24px;
}
.patient-banner-info {
display: flex;
align-items: center;
gap: 12px;
color: #00ff66;
}
.patient-banner-info .gender-badge {
background: #0044bb;
color: #fff;
padding: 0 6px;
border-radius: 50%;
font-size: 10px;
height: 16px;
display: flex;
align-items: center;
font-weight: 800;
}
.alarm-notifications {
display: flex;
align-items: center;
gap: 8px;
}
.alarm-badge {
font-size: 10px;
padding: 1px 8px;
border-radius: 2px;
font-weight: 700;
}
.alarm-badge.blue {
background: #0044aa;
color: #ffffff;
}
.alarm-badge.yellow {
background: #ffff00;
color: #000000;
}
.system-status-icons {
display: flex;
align-items: center;
gap: 10px;
color: #888;
}
.system-status-icons .clock {
font-family: 'JetBrains Mono', monospace;
color: #fff;
}
/* Main Workspace Grid (Left waveforms, Right digits) */
.monitor-body {
display: grid;
grid-template-columns: 1fr 240px;
flex: 1;
gap: 4px;
overflow: hidden;
margin: 4px 0;
}
/* Left Side: Waveform Channels */
.wave-channels {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 4px;
background: #000;
}
.wave-channel {
flex: 1;
position: relative;
background: #000;
border-bottom: 1px solid rgba(255,255,255,0.02);
display: flex;
flex-direction: column;
min-height: 0;
}
.channel-label {
position: absolute;
top: 4px;
left: 8px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 700;
z-index: 10;
}
.channel-label.ecg { color: var(--ecg-color); }
.channel-label.spo2 { color: var(--spo2-color); }
.channel-label.resp { color: var(--resp-color); }
.wave-canvas-wrapper {
flex: 1;
position: relative;
min-height: 0;
}
.wave-canvas-wrapper canvas {
width: 100%;
height: 100%;
display: block;
}
/* Right Side: Numeric Vitals Blocks */
.digit-panels {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 4px;
}
.digit-panel {
flex: 1;
background: #080808;
border: 1px solid #111;
border-radius: 4px;
padding: 6px 12px;
display: flex;
flex-direction: column;
justify-content: space-between;
position: relative;
min-height: 0;
}
.digit-panel-header {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.digit-panel.ecg .digit-panel-header { color: var(--ecg-color); }
.digit-panel.spo2 .digit-panel-header { color: var(--spo2-color); }
.digit-panel.resp .digit-panel-header { color: var(--resp-color); }
.digit-panel-body {
display: flex;
align-items: center;
justify-content: space-between;
flex: 1;
}
.digit-value {
font-family: 'JetBrains Mono', monospace;
font-size: 72px;
font-weight: 800;
line-height: 0.9;
}
.digit-panel.ecg .digit-value { color: var(--ecg-color); }
.digit-panel.spo2 .digit-value { color: var(--spo2-color); }
.digit-panel.resp .digit-value { color: var(--resp-color); }
.digit-value.no-data {
font-size: 54px;
color: #333 !important;
}
.digit-sub-indicators {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 9px;
font-weight: 600;
color: #aaa;
}
.digit-panel.ecg .digit-sub-indicators { color: var(--ecg-color); }
.digit-panel.spo2 .digit-sub-indicators { color: var(--spo2-color); }
.digit-panel.resp .digit-sub-indicators { color: var(--resp-color); }
/* Bottom Section: Measurements Grid */
.monitor-bottom {
display: grid;
grid-template-columns: 1fr 220px 240px;
gap: 4px;
height: 90px;
background: #000;
}
.bottom-card {
background: #080808;
border: 1px solid #111;
border-radius: 4px;
padding: 6px 10px;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 0;
}
/* NIBP List */
.nibp-list-card {
font-family: 'JetBrains Mono', monospace;
}
.nibp-list-title {
font-size: 9px;
color: var(--nibp-color);
font-weight: 700;
margin-bottom: 4px;
}
.nibp-table {
width: 100%;
font-size: 9px;
border-collapse: collapse;
color: #fff;
}
.nibp-table th {
text-align: left;
color: #888;
padding-bottom: 2px;
font-weight: 600;
}
.nibp-table td {
padding: 1px 0;
color: #ffffff;
}
.nibp-table .time {
color: #888;
}
/* Temp Block */
.temp-card {
color: var(--temp-color);
}
.temp-title {
font-size: 9px;
font-weight: 700;
}
.temp-body {
display: flex;
align-items: center;
justify-content: space-between;
flex: 1;
}
.temp-value {
font-family: 'JetBrains Mono', monospace;
font-size: 38px;
font-weight: 800;
color: var(--temp-color);
}
.temp-details {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 8px;
font-weight: 600;
}
.temp-limit-label {
font-size: 7px;
opacity: 0.6;
}
/* NIBP Panel (Right) */
.nibp-card {
color: var(--nibp-color);
}
.nibp-title {
font-size: 9px;
font-weight: 700;
}
.nibp-body {
display: flex;
align-items: baseline;
justify-content: space-between;
flex: 1;
}
.nibp-large-value {
font-family: 'JetBrains Mono', monospace;
font-size: 34px;
font-weight: 800;
color: #fff;
}
.nibp-map-value {
font-family: 'JetBrains Mono', monospace;
font-size: 18px;
font-weight: 700;
color: #aaa;
margin-left: 2px;
}
.nibp-info {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 8px;
text-align: right;
color: #888;
}
/* Bottom Gray Buttons Panel */
.control-buttons-bar {
display: flex;
gap: 2px;
background: #222;
padding: 2px;
height: 38px;
border-top: 1px solid #111;
}
.ctrl-btn {
flex: 1;
background: #333;
border: none;
color: #eee;
font-family: 'Outfit', sans-serif;
font-size: 8px;
font-weight: 700;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
border-radius: 2px;
transition: background 0.2s;
text-transform: uppercase;
}
.ctrl-btn:hover {
background: #444;
}
.ctrl-btn svg {
width: 12px;
height: 12px;
stroke: #aaa;
stroke-width: 2.5;
fill: none;
}
.ctrl-btn.orange {
background: #d97706;
color: #000;
}
.ctrl-btn.orange:hover {
background: #f59e0b;
}
.ctrl-btn.orange svg {
stroke: #000;
}
/* === Minimal/Clean Mode for Iframe (Electron) === */
body.minimal-mode {
padding: 0;
margin: 0;
background: #000;
}
body.minimal-mode .dev-trigger-zone,
body.minimal-mode .dev-controls,
body.minimal-mode .control-buttons-bar,
body.minimal-mode .monitor-header {
display: none !important;
}
body.minimal-mode .monitor-container {
padding: 2px;
height: 100vh;
}
body.minimal-mode .monitor-bottom {
height: 60px;
}
body.minimal-mode .nibp-large-value {
font-size: 26px;
}
body.minimal-mode .temp-value {
font-size: 28px;
}
body.minimal-mode .digit-value {
font-size: 48px;
}
</style>
</head>
<body>
<!-- Retractable Dev Controls for configuring monitors/audio -->
<div class="dev-trigger-zone"></div>
<div class="dev-controls" id="devControls">
<div class="dev-pull-tab">DEV CONTROLS (HOVER)</div>
<div class="device-selector-container">
<label for="deviceSelector" style="font-size: 11px; color: #888; font-weight: 600;">Monitor:</label>
<select id="deviceSelector" class="device-select-dropdown" onchange="selectDevice(this.value)">
<option value="">Waiting...</option>
</select>
</div>
<div class="audio-toggle" id="audioToggle" onclick="toggleAudioAlert()" title="Toggle audio alerts">
<span id="audioIcon">🔇</span>
<span id="audioText">Alerts Off</span>
</div>
<div class="connection-badge offline" id="connectionBadge">
<span id="connectionText">Offline</span>
</div>
<div style="font-size: 10px; color: #888;">
IP: <strong id="detectedIp" style="color: #fff;">...</strong>
</div>
</div>
<!-- Patient Verification Overlay -->
<div id="verificationOverlay" class="verification-overlay" style="display: none;">
<div class="error-card" id="verificationCard">
<div class="error-icon" id="verificationIcon">🔒</div>
<h2 id="verificationTitle">Patient Verification Required</h2>
<p id="verificationMsg">Access restricted. Telemetry is only displayed when patient MRN is fully verified against the monitor configuration.</p>
</div>
</div>
<!-- CMS8500 Monitor Simulation Container -->
<div class="monitor-container">
<!-- Top Status Bar -->
<header class="monitor-header">
<div class="patient-banner-info">
<span class="gender-badge" id="bannerGender">M</span>
<span id="bannerPatientBed">01 Adu 1Bed</span>
<strong id="bannerPatientName" style="color: #fff;">Shahil Kumar</strong>
</div>
<div class="alarm-notifications">
<span class="alarm-badge blue" id="alarmNibp">*NIBP Loose Cuff</span>
<span class="alarm-badge blue" id="alarmT2">*T2 Sensor Off</span>
<span class="alarm-badge yellow" id="alarmT1">**T1 Too Low</span>
</div>
<div class="system-status-icons">
<span style="font-size: 11px;">🔋 📶 🔊</span>
<span class="clock" id="timeDisplay">12:56:17</span>
</div>
</header>
<!-- Main Screen Grid: Waveforms + Digits -->
<div class="monitor-body">
<!-- Left Waveform Traces -->
<div class="wave-channels">
<!-- ECG Lead II -->
<div class="wave-channel">
<div class="channel-label ecg">II &nbsp; X1 &nbsp; Monitor &nbsp; Notch Off</div>
<div class="wave-canvas-wrapper">
<canvas id="waveform-ecg2"></canvas>
</div>
</div>
<!-- ECG Lead I -->
<div class="wave-channel">
<div class="channel-label ecg">I &nbsp; X1</div>
<div class="wave-canvas-wrapper">
<canvas id="waveform-ecg1"></canvas>
</div>
</div>
<!-- SpO2 Pleth -->
<div class="wave-channel">
<div class="channel-label spo2">Pleth</div>
<div class="wave-canvas-wrapper">
<canvas id="waveform-pleth"></canvas>
</div>
</div>
<!-- Respiration -->
<div class="wave-channel">
<div class="channel-label resp">Resp &nbsp; X2 &nbsp; II</div>
<div class="wave-canvas-wrapper">
<canvas id="waveform-resp"></canvas>
</div>
</div>
</div>
<!-- Right Digit Displays -->
<div class="digit-panels">
<!-- ECG panel -->
<div class="digit-panel ecg">
<div class="digit-panel-header">ECG <span style="font-size: 8px;">bpm</span></div>
<div class="digit-panel-body">
<div class="digit-value no-data" id="value-hr">---</div>
<div class="digit-sub-indicators">
<div>ST-II Off</div>
<div>PVCs --</div>
</div>
</div>
</div>
<!-- SpO2 panel -->
<div class="digit-panel spo2">
<div class="digit-panel-header">SpO₂ <span style="font-size: 8px;">%</span></div>
<div class="digit-panel-body">
<div class="digit-value no-data" id="value-spo2">---</div>
<div class="digit-sub-indicators">
<div>PR bpm <span id="value-pr">--</span></div>
<div>PI % <span id="value-pi">3.70</span></div>
</div>
</div>
</div>
<!-- Resp panel -->
<div class="digit-panel resp">
<div class="digit-panel-header">Resp <span style="font-size: 8px;">rpm</span></div>
<div class="digit-panel-body">
<div class="digit-value no-data" id="value-rr">---</div>
<div class="digit-sub-indicators">
<div style="font-size: 12px; margin-top: 10px;">⚡⚡⚡</div>
</div>
</div>
</div>
</div>
</div>
<!-- Bottom Measurement Panel -->
<div class="monitor-bottom">
<!-- NIBP List (Left) -->
<div class="bottom-card nibp-list-card">
<div class="nibp-list-title">NIBP List <span style="font-size: 7px; color: #888;">mmHg</span></div>
<table class="nibp-table" id="nibpTable">
<thead>
<tr>
<th>SYS/DIA</th>
<th>MAP</th>
<th>PR</th>
<th>Time</th>
</tr>
</thead>
<tbody id="nibpTableBody">
<tr>
<td>125/80</td>
<td>99</td>
<td>92</td>
<td class="time">12:54:49</td>
</tr>
<tr>
<td>133/76</td>
<td>89</td>
<td>97</td>
<td class="time">12:30:31</td>
</tr>
<tr>
<td>141/76</td>
<td>91</td>
<td>100</td>
<td class="time">12:21:43</td>
</tr>
</tbody>
</table>
</div>
<!-- Temperature Panel (Center) -->
<div class="bottom-card temp-card">
<div class="temp-title">Temp <span style="font-size: 7px; color: #888;">°C</span></div>
<div class="temp-body">
<div class="temp-value" id="value-temp">30.5</div>
<div class="temp-details">
<div>T1: <span id="value-t1">30.5</span></div>
<div>T2: ---</div>
<div>TD: ---</div>
<div class="temp-limit-label">38.0 / 35.0</div>
</div>
</div>
</div>
<!-- NIBP Panel (Right) -->
<div class="bottom-card nibp-card">
<div class="nibp-title" id="nibpTimeTitle">NIBP mmHg @12:54</div>
<div class="nibp-body">
<div>
<span class="nibp-large-value" id="value-nibp">125/80</span>
<span class="nibp-map-value" id="value-nibp-map">(99)</span>
</div>
<div class="nibp-info">
<div>Auto Measuring</div>
<div style="font-size: 6px; margin-top: 2px;">3min43s &nbsp; 15min</div>
</div>
</div>
</div>
</div>
<!-- Bottom Gray Control Panel Buttons -->
<footer class="control-buttons-bar">
<button class="ctrl-btn">
<svg viewBox="0 0 24 24"><path d="M4 12h16M4 6h16M4 18h16" stroke="currentColor"/></svg>
More
</button>
<button class="ctrl-btn orange" onclick="toggleAudioAlert()">
<svg viewBox="0 0 24 24"><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" stroke="currentColor"/></svg>
Alarm Reset
</button>
<button class="ctrl-btn">
<svg viewBox="0 0 24 24"><path d="M15.75 5.25v13.5m-7.5-13.5v13.5" stroke="currentColor"/></svg>
Alarm Pause
</button>
<button class="ctrl-btn" onclick="simulateNibpMeasurement()">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" stroke="currentColor"/></svg>
NIBP Measure
</button>
<button class="ctrl-btn">
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor"/></svg>
Stop All
</button>
<button class="ctrl-btn">
<svg viewBox="0 0 24 24"><path d="M12 2v20M2 12h20" stroke="currentColor"/></svg>
Zero IBP
</button>
<button class="ctrl-btn">
<svg viewBox="0 0 24 24"><path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" stroke="currentColor"/></svg>
Review
</button>
<button class="ctrl-btn">
<svg viewBox="0 0 24 24"><path d="M18.36 6.64a9 9 0 11-12.73 0M12 2v10" stroke="currentColor"/></svg>
Standby
</button>
<button class="ctrl-btn">
<svg viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2" stroke="currentColor"/><path d="M8 21h8M12 17v4" stroke="currentColor"/></svg>
Display Setup
</button>
<button class="ctrl-btn">
<svg viewBox="0 0 24 24"><path d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" stroke="currentColor"/></svg>
Patient Setup
</button>
<button class="ctrl-btn">
<svg viewBox="0 0 24 24"><path d="M12 8v4l3 3" stroke="currentColor"/></svg>
Manual Event
</button>
<button class="ctrl-btn">
<svg viewBox="0 0 24 24"><path d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4" stroke="currentColor"/></svg>
Print
</button>
<button class="ctrl-btn">
<svg viewBox="0 0 24 24"><path d="M15.536 8.464a5 5 0 010 7.072M18.364 5.636a9 9 0 010 12.728M12 18.75V5.25L7.75 9.5H4.5v5h3.25L12 18.75z" stroke="currentColor"/></svg>
Volume Setup
</button>
<button class="ctrl-btn">
<svg viewBox="0 0 24 24"><path d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" stroke="currentColor"/></svg>
Main Menu
</button>
</footer>
</div>
<script>
// === Configuration ===
const API_BASE = window.location.origin + '/api';
const POLL_INTERVAL = 2000;
const WS_URL = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws/vitals`;
// === State ===
let lastReadingId = null;
let readingCount = 0;
// Circular Waveform Buffers (1000 virtual index points)
const WAVE_BUFFER_SIZE = 1000;
const waveBuffers = {
ecg2: new Float32Array(WAVE_BUFFER_SIZE),
ecg1: new Float32Array(WAVE_BUFFER_SIZE),
pleth: new Float32Array(WAVE_BUFFER_SIZE),
resp: new Float32Array(WAVE_BUFFER_SIZE)
};
let scanIndex = 0;
let ecgPhase = 0;
let plethPhase = 0;
let respPhase = 0;
let activeDevices = [];
let selectedDeviceId = localStorage.getItem('selectedDeviceId') || '';
let isMatchingMode = false;
let matchParams = {};
let useWebSocket = false;
let ws = null;
let audioAlertEnabled = false;
let audioContext = null;
let hasReceivedData = false;
// Numeric values synced from monitor
let currentHR = null;
let currentSpO2 = null;
let currentRR = null;
let currentTemp = 30.5;
// Waveform canvas context cache
const canvases = {};
// === procedural Waveform Functions ===
function getECGValue(phase) {
const p = phase % 1.0;
let v = 0;
if (p < 0.1) {
// P wave
v = 0.08 * Math.sin((p / 0.1) * Math.PI);
} else if (p < 0.15) {
v = 0;
} else if (p < 0.17) {
// Q wave
v = -0.06 * Math.sin(((p - 0.15) / 0.02) * Math.PI);
} else if (p < 0.20) {
// R wave (sharp peak)
v = 0.85 * Math.sin(((p - 0.17) / 0.03) * Math.PI);
} else if (p < 0.23) {
// S wave (deep dip)
v = -0.22 * Math.sin(((p - 0.20) / 0.03) * Math.PI);
} else if (p < 0.32) {
v = 0;
} else if (p < 0.48) {
// T wave
v = 0.18 * Math.sin(((p - 0.32) / 0.16) * Math.PI);
} else {
v = 0;
}
// Add light high-frequency noise
return v + (Math.random() - 0.5) * 0.015;
}
function getECG1Value(phase) {
const p = phase % 1.0;
let v = 0;
if (p < 0.1) {
v = 0.05 * Math.sin((p / 0.1) * Math.PI);
} else if (p < 0.16) {
v = 0;
} else if (p < 0.18) {
v = -0.04 * Math.sin(((p - 0.16) / 0.02) * Math.PI);
} else if (p < 0.21) {
v = 0.65 * Math.sin(((p - 0.18) / 0.03) * Math.PI);
} else if (p < 0.24) {
v = -0.15 * Math.sin(((p - 0.21) / 0.03) * Math.PI);
} else if (p < 0.33) {
v = 0;
} else if (p < 0.50) {
v = 0.22 * Math.sin(((p - 0.33) / 0.17) * Math.PI);
} else {
v = 0;
}
return v + (Math.random() - 0.5) * 0.015;
}
function getPlethValue(phase) {
const p = phase % 1.0;
let v = 0;
if (p < 0.22) {
// Systolic rise
v = 0.8 * Math.sin((p / 0.22) * Math.PI / 2);
} else if (p < 0.42) {
// Diastolic fall
v = 0.8 - 0.45 * Math.sin(((p - 0.22) / 0.20) * Math.PI / 2);
} else if (p < 0.47) {
// Notch
const np = (p - 0.42) / 0.05;
v = 0.35 - 0.05 * Math.sin(np * Math.PI);
} else if (p < 0.62) {
// Diastolic peak
const dp = (p - 0.47) / 0.15;
v = 0.35 + 0.10 * Math.sin(dp * Math.PI);
} else {
// Decay
const dp = (p - 0.62) / 0.38;
v = 0.45 * Math.exp(-dp * 3.0);
}
return v + (Math.random() - 0.5) * 0.008;
}
function getRespValue(phase) {
const p = phase % 1.0;
return 0.5 + 0.4 * Math.sin(p * Math.PI * 2) + (Math.random() - 0.5) * 0.005;
}
// === Canvas Initialization ===
function initCanvases() {
const keys = ['ecg2', 'ecg1', 'pleth', 'resp'];
keys.forEach(k => {
const c = document.getElementById(`waveform-${k}`);
if (c) {
const ctx = c.getContext('2d');
const rect = c.parentElement.getBoundingClientRect();
// High-DPI scaling
c.width = rect.width * 2;
c.height = rect.height * 2;
ctx.scale(2, 2);
canvases[k] = {
canvas: c,
ctx: ctx,
width: rect.width,
height: rect.height
};
}
});
}
// === Patient Credentials Matching ===
async function verifyPatientMatch() {
const overlay = document.getElementById('verificationOverlay');
const icon = document.getElementById('verificationIcon');
const title = document.getElementById('verificationTitle');
const msg = document.getElementById('verificationMsg');
try {
const query = new URLSearchParams(matchParams).toString();
const response = await fetch(`${API_BASE}/match-patient?${query}`);
if (!response.ok) throw new Error('Match verification API error');
const data = await response.json();
if (data.matched) {
if (data.device_id) {
overlay.style.display = 'none';
if (selectedDeviceId !== data.device_id) {
selectedDeviceId = data.device_id;
clearVitalsDisplay();
}
} else {
overlay.style.display = 'flex';
overlay.className = 'verification-overlay loading';
icon.textContent = '⏳';
title.textContent = 'Connecting Monitor...';
msg.textContent = `Patient verified. Waiting for monitor to start transmitting...`;
selectedDeviceId = '';
clearVitalsDisplay();
}
} else {
overlay.style.display = 'flex';
overlay.className = 'verification-overlay';
icon.textContent = '❌';
title.textContent = 'Access Restricted';
msg.textContent = data.reason || 'This patient record does not match the active monitor configuration.';
selectedDeviceId = '';
clearVitalsDisplay();
}
} catch (e) {
console.error("Match verification failed:", e);
overlay.style.display = 'flex';
overlay.className = 'verification-overlay loading';
icon.textContent = '🔄';
title.textContent = 'Verification Server Offline';
msg.textContent = 'Retrying verification match...';
}
}
// === WebSocket Connection ===
function tryWebSocket() {
try {
ws = new WebSocket(WS_URL);
ws.onopen = () => {
useWebSocket = true;
updateConnectionBadge('connected', 'Live');
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (!selectedDeviceId && data.device_id && !isMatchingMode) {
selectDevice(data.device_id);
}
if (data.device_id && data.device_id === selectedDeviceId) {
updateVitals(data);
}
if (!isMatchingMode) {
fetchDevices();
}
};
ws.onclose = () => {
useWebSocket = false;
updateConnectionBadge('polling', 'Polling');
setTimeout(tryWebSocket, 5000);
};
ws.onerror = () => {
useWebSocket = false;
};
} catch (e) {
useWebSocket = false;
}
}
function updateConnectionBadge(status, text) {
const badge = document.getElementById('connectionBadge');
badge.textContent = text;
badge.className = 'connection-badge ' + (status === 'connected' ? '' : 'warning');
}
// === Polling Fallback ===
function startPolling() {
fetchLatestReadings();
setInterval(fetchLatestReadings, POLL_INTERVAL);
}
async function fetchLatestReadings() {
if (isMatchingMode && !selectedDeviceId) return;
try {
const url = selectedDeviceId
? `${API_BASE}/latest-readings?device_id=${encodeURIComponent(selectedDeviceId)}&limit=1`
: `${API_BASE}/latest-readings?limit=1`;
const response = await fetch(url);
if (!response.ok) throw new Error('API error');
const readings = await response.json();
if (readings.length > 0) {
const latest = readings[0];
if (latest.id !== lastReadingId) {
lastReadingId = latest.id;
readingCount++;
updateVitals(latest);
}
}
} catch (e) {
console.error("Polling error:", e);
}
}
// === Update Vital Values ===
function updateVitals(data) {
hasReceivedData = true;
// Heart Rate
currentHR = data.heart_rate;
const hrVal = document.getElementById('value-hr');
if (currentHR !== null && currentHR !== undefined) {
hrVal.textContent = Math.round(currentHR);
hrVal.classList.remove('no-data');
document.getElementById('value-pr').textContent = Math.round(currentHR);
} else {
hrVal.textContent = '---';
hrVal.classList.add('no-data');
document.getElementById('value-pr').textContent = '--';
}
// SpO2
currentSpO2 = data.spo2;
const spo2Val = document.getElementById('value-spo2');
if (currentSpO2 !== null && currentSpO2 !== undefined) {
spo2Val.textContent = Math.round(currentSpO2);
spo2Val.classList.remove('no-data');
} else {
spo2Val.textContent = '---';
spo2Val.classList.add('no-data');
}
// Resp Rate
currentRR = data.respiratory_rate;
const rrVal = document.getElementById('value-rr');
if (currentRR !== null && currentRR !== undefined) {
rrVal.textContent = Math.round(currentRR);
rrVal.classList.remove('no-data');
} else {
rrVal.textContent = '---';
rrVal.classList.add('no-data');
}
// Temperature
if (data.temperature !== null && data.temperature !== undefined) {
currentTemp = data.temperature;
document.getElementById('value-temp').textContent = currentTemp.toFixed(1);
document.getElementById('value-t1').textContent = currentTemp.toFixed(1);
}
// Blood pressure
if (data.systolic_bp !== null && data.systolic_bp !== undefined) {
const sys = Math.round(data.systolic_bp);
const dia = data.diastolic_bp !== null ? Math.round(data.diastolic_bp) : '--';
const mapVal = data.map_bp !== null ? Math.round(data.map_bp) : Math.round(sys*0.33 + dia*0.67);
document.getElementById('value-nibp').textContent = `${sys}/${dia}`;
document.getElementById('value-nibp-map').textContent = `(${mapVal})`;
const now = new Date();
const timeStr = now.toLocaleTimeString('en-US', { hour12: false });
document.getElementById('nibpTimeTitle').textContent = `NIBP mmHg @${timeStr.slice(0, 5)}`;
// Add to NIBP table log
addNibpTableRow(`${sys}/${dia}`, mapVal, currentHR ? Math.round(currentHR) : 92, timeStr);
}
// Update Header Name
if (data.patient_name) {
document.getElementById('bannerPatientName').textContent = data.patient_name;
}
if (data.patient_gender) {
const genderBadge = document.getElementById('bannerGender');
genderBadge.textContent = data.patient_gender.slice(0, 1).toUpperCase();
genderBadge.style.background = genderBadge.textContent === 'F' ? '#db2777' : '#0044bb';
}
}
// Add dynamically a row to NIBP log
function addNibpTableRow(sysDia, map, pr, time) {
const body = document.getElementById('nibpTableBody');
const row = document.createElement('tr');
row.innerHTML = `
<td>${sysDia}</td>
<td>${map}</td>
<td>${pr}</td>
<td class="time">${time}</td>
`;
// Insert at the top
body.insertBefore(row, body.firstChild);
// Keep maximum 3 rows
if (body.children.length > 3) {
body.removeChild(body.lastChild);
}
}
function simulateNibpMeasurement() {
// Replicate NIBP measure action
const sys = Math.round(110 + Math.random() * 25);
const dia = Math.round(70 + Math.random() * 15);
const map = Math.round(sys * 0.33 + dia * 0.67);
const pr = currentHR ? Math.round(currentHR) : Math.round(80 + Math.random() * 20);
const now = new Date();
const timeStr = now.toLocaleTimeString('en-US', { hour12: false });
document.getElementById('value-nibp').textContent = `${sys}/${dia}`;
document.getElementById('value-nibp-map').textContent = `(${map})`;
document.getElementById('nibpTimeTitle').textContent = `NIBP mmHg @${timeStr.slice(0, 5)}`;
addNibpTableRow(`${sys}/${dia}`, map, pr, timeStr);
}
function clearVitalsDisplay() {
currentHR = null;
currentSpO2 = null;
currentRR = null;
hasReceivedData = false;
// Reset wave buffers so sweep starts fresh on next data
scanIndex = 0;
ecgPhase = 0;
plethPhase = 0;
respPhase = 0;
waveBuffers.ecg2.fill(0);
waveBuffers.ecg1.fill(0);
waveBuffers.pleth.fill(0);
waveBuffers.resp.fill(0);
document.getElementById('value-hr').textContent = '---';
document.getElementById('value-hr').classList.add('no-data');
document.getElementById('value-pr').textContent = '--';
document.getElementById('value-spo2').textContent = '---';
document.getElementById('value-spo2').classList.add('no-data');
document.getElementById('value-rr').textContent = '---';
document.getElementById('value-rr').classList.add('no-data');
document.getElementById('bannerPatientName').textContent = 'Shahil Kumar';
lastReadingId = null;
}
// === Animation Loop (Real-time Scanning Sweep Bar) ===
function animateWaveforms() {
const speed = 4;
// Only generate and draw sweep waveforms when live data is flowing
const hasEcgData = hasReceivedData && currentHR !== null;
const hasSpo2Data = hasReceivedData && currentSpO2 !== null;
const hasRespData = hasReceivedData && currentRR !== null;
if (hasEcgData || hasSpo2Data || hasRespData) {
// Advance scan position
const prevScanIndex = scanIndex;
scanIndex = (scanIndex + speed) % WAVE_BUFFER_SIZE;
const hr = currentHR || 75;
const rr = currentRR || 16;
const ecgPhaseInc = (hr / 60) * (speed / WAVE_BUFFER_SIZE) * 4.0;
const respPhaseInc = (rr / 60) * (speed / WAVE_BUFFER_SIZE) * 4.0;
for (let i = 0; i < speed; i++) {
const targetIdx = (prevScanIndex + i) % WAVE_BUFFER_SIZE;
ecgPhase += ecgPhaseInc / speed;
plethPhase += ecgPhaseInc / speed;
respPhase += respPhaseInc / speed;
// Only fill buffers for channels with live data
if (hasEcgData) {
waveBuffers.ecg2[targetIdx] = getECGValue(ecgPhase);
waveBuffers.ecg1[targetIdx] = getECG1Value(ecgPhase + 0.15);
} else {
waveBuffers.ecg2[targetIdx] = 0;
waveBuffers.ecg1[targetIdx] = 0;
}
if (hasSpo2Data) {
waveBuffers.pleth[targetIdx] = getPlethValue(plethPhase - 0.2);
} else {
waveBuffers.pleth[targetIdx] = 0;
}
if (hasRespData) {
waveBuffers.resp[targetIdx] = getRespValue(respPhase);
} else {
waveBuffers.resp[targetIdx] = 0;
}
}
// Draw active channels with sweep, idle channels with flat baseline
drawChannel('ecg2', waveBuffers.ecg2, varColor('--ecg-color'), 1.8, false, hasEcgData);
drawChannel('ecg1', waveBuffers.ecg1, varColor('--ecg-color'), 1.8, false, hasEcgData);
drawChannel('pleth', waveBuffers.pleth, varColor('--spo2-color'), 2.2, false, hasSpo2Data);
drawChannel('resp', waveBuffers.resp, varColor('--resp-color'), 1.8, true, hasRespData);
} else {
// No live data at all — draw dim flat baselines on every channel
drawFlatBaseline('ecg2', varColor('--ecg-color'));
drawFlatBaseline('ecg1', varColor('--ecg-color'));
drawFlatBaseline('pleth', varColor('--spo2-color'));
drawFlatBaseline('resp', varColor('--resp-color'));
}
requestAnimationFrame(animateWaveforms);
}
function drawFlatBaseline(key, color) {
const wf = canvases[key];
if (!wf) return;
const { ctx, width, height } = wf;
ctx.clearRect(0, 0, width, height);
ctx.beginPath();
ctx.strokeStyle = color;
ctx.globalAlpha = 0.15;
ctx.lineWidth = 1;
ctx.setLineDash([]);
ctx.moveTo(0, height / 2);
ctx.lineTo(width, height / 2);
ctx.stroke();
ctx.globalAlpha = 1.0;
}
function varColor(varName) {
// Fallback colors if styling var fails
if (varName.includes('ecg-color')) return '#00ff66';
if (varName.includes('spo2-color')) return '#00ffff';
if (varName.includes('resp-color')) return '#ffff00';
return '#ffffff';
}
function drawChannel(key, buffer, color, lineWidth, isDashed) {
const wf = canvases[key];
if (!wf) return;
const { ctx, width, height } = wf;
ctx.clearRect(0, 0, width, height);
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
if (isDashed) {
ctx.setLineDash([2, 3]);
} else {
ctx.setLineDash([]);
}
// Map the virtual index loop to the screen coordinates.
// We draw two segments:
// 1. From 0 to scanIndex - 5 (new sweep line progress)
// 2. From scanIndex + 25 to end of buffer (previous sweep leftovers)
const gap = 20; // Width of erasing gap in virtual index points
// Segment 1 (New progress)
if (scanIndex > 2) {
ctx.beginPath();
for (let i = 0; i < scanIndex - 2; i++) {
const x = (i / WAVE_BUFFER_SIZE) * width;
const y = height / 2 - (buffer[i] * (height * 0.4));
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.stroke();
}
// Segment 2 (Leftovers)
const leftStart = (scanIndex + gap) % WAVE_BUFFER_SIZE;
if (leftStart > scanIndex) {
ctx.beginPath();
let first = true;
for (let i = leftStart; i < WAVE_BUFFER_SIZE; i++) {
const x = (i / WAVE_BUFFER_SIZE) * width;
const y = height / 2 - (buffer[i] * (height * 0.4));
if (first) {
ctx.moveTo(x, y);
first = false;
} else {
ctx.lineTo(x, y);
}
}
ctx.stroke();
}
}
// === Clock ===
function updateClock() {
const now = new Date();
const dateStr = now.toISOString().slice(0, 10);
const timeStr = now.toLocaleTimeString('en-US', { hour12: false });
document.getElementById('timeDisplay').textContent = `${dateStr} ${timeStr}`;
}
// === Audio Alerts ===
function toggleAudioAlert() {
audioAlertEnabled = !audioAlertEnabled;
const toggle = document.getElementById('audioToggle');
const icon = document.getElementById('audioIcon');
const text = document.getElementById('audioText');
if (audioAlertEnabled) {
toggle.classList.add('active');
icon.textContent = '🔊';
text.textContent = 'Alerts On';
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
} else {
toggle.classList.remove('active');
icon.textContent = '🔇';
text.textContent = 'Alerts Off';
}
}
// === Devices List ===
async function fetchDevices() {
try {
const response = await fetch(`${API_BASE}/devices`);
if (response.ok) {
const devices = await response.json();
activeDevices = devices;
updateDeviceSelector();
}
} catch (e) {
console.error("Error fetching devices:", e);
}
}
function updateDeviceSelector() {
const selector = document.getElementById('deviceSelector');
const previousSelection = selector.value || selectedDeviceId;
selector.innerHTML = '';
if (activeDevices.length === 0) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'No monitors connected';
selector.appendChild(opt);
return;
}
activeDevices.forEach(d => {
const opt = document.createElement('option');
opt.value = d.device_id;
const statusSymbol = d.status === 'active' ? '🟢' : '⚫';
opt.textContent = `${statusSymbol} ${d.device_id}`;
selector.appendChild(opt);
});
if (previousSelection && activeDevices.some(d => d.device_id === previousSelection)) {
selector.value = previousSelection;
selectedDeviceId = previousSelection;
} else {
selector.value = activeDevices[0].device_id;
selectedDeviceId = activeDevices[0].device_id;
}
localStorage.setItem('selectedDeviceId', selectedDeviceId);
}
function selectDevice(deviceId) {
selectedDeviceId = deviceId;
localStorage.setItem('selectedDeviceId', deviceId);
clearVitalsDisplay();
fetchLatestReadings();
}
async function fetchNetworkInfo() {
try {
const response = await fetch(`${API_BASE}/network-info`);
if (response.ok) {
const info = await response.json();
const ipEl = document.getElementById('detectedIp');
if (info.primary_ip) {
ipEl.textContent = info.primary_ip;
} else if (info.ip_addresses && info.ip_addresses.length > 0) {
ipEl.textContent = info.ip_addresses[0];
}
}
} catch (e) {
console.error(e);
}
}
// === Initialize on Load ===
document.addEventListener('DOMContentLoaded', () => {
const urlParams = new URLSearchParams(window.location.search);
const isMinimal = urlParams.get('minimal') === 'true' || urlParams.get('clean') === 'true';
if (isMinimal) {
document.body.classList.add('minimal-mode');
}
const mrn = urlParams.get('mrn');
const gender = urlParams.get('gender');
const dob = urlParams.get('dob');
const age = urlParams.get('age');
if (mrn) {
isMatchingMode = true;
matchParams = { mrn, gender, dob, age };
const overlay = document.getElementById('verificationOverlay');
overlay.style.display = 'flex';
overlay.className = 'verification-overlay loading';
document.getElementById('verificationIcon').textContent = '🔄';
document.getElementById('verificationTitle').textContent = 'Verifying Patient Credentials...';
document.getElementById('verificationMsg').textContent = 'Matching patient identity...';
}
initCanvases();
updateClock();
setInterval(updateClock, 1000);
if (isMatchingMode) {
verifyPatientMatch().then(() => {
tryWebSocket();
startPolling();
});
setInterval(verifyPatientMatch, 5000);
} else {
fetchDevices().then(() => {
tryWebSocket();
startPolling();
});
setInterval(fetchDevices, 4000);
}
animateWaveforms();
fetchNetworkInfo();
});
window.addEventListener('resize', () => {
initCanvases();
});
</script>
</body>
</html>