1599 lines
56 KiB
HTML
1599 lines
56 KiB
HTML
<!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 X1 Monitor 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 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 X2 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 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>
|