Files
ContecMonitor/static/dashboard.html
T

4849 lines
199 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 CMS7000PLUS</title>
<meta name="description" content="Real-time patient vital signs monitoring dashboard for Contec CMS7000PLUS patient monitor">
<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=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🩺</text></svg>">
<script>
// Suppress browser extension message channel port errors inside telemetry embed
window.addEventListener('unhandledrejection', function(event) {
var msg = (event && event.reason && (event.reason.message || event.reason)) || '';
if (typeof msg === 'string' && msg.indexOf('A listener indicated an asynchronous response') !== -1) {
event.preventDefault();
}
});
</script>
<style>
/* === CSS Reset & Base === */
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
/* Core palette */
--bg-primary: #0a0e1a;
--bg-secondary: #111827;
--bg-card: rgba(17, 24, 39, 0.7);
--bg-card-hover: rgba(25, 34, 56, 0.8);
--border-color: rgba(255, 255, 255, 0.06);
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-dim: #64748b;
/* Vital sign accent colors */
--hr-color: #ef4444;
--hr-glow: rgba(239, 68, 68, 0.4);
--spo2-color: #06b6d4;
--spo2-glow: rgba(6, 182, 212, 0.4);
--bp-color: #a855f7;
--bp-glow: rgba(168, 85, 247, 0.4);
--rr-color: #22c55e;
--rr-glow: rgba(34, 197, 94, 0.4);
--temp-color: #f97316;
--temp-glow: rgba(249, 115, 22, 0.4);
--map-color: #ec4899;
--map-glow: rgba(236, 72, 153, 0.4);
/* Status */
--status-ok: #22c55e;
--status-warn: #eab308;
--status-critical: #ef4444;
--status-offline: #64748b;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
}
/* === Animated Background === */
.bg-grid {
position: fixed;
inset: 0;
z-index: 0;
background-image:
linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
}
.bg-glow {
position: fixed;
top: -200px;
left: 50%;
transform: translateX(-50%);
width: 800px;
height: 600px;
background: radial-gradient(ellipse, rgba(6, 182, 212, 0.08), transparent 70%);
z-index: 0;
pointer-events: none;
animation: glowPulse 8s ease-in-out infinite;
}
@keyframes glowPulse {
0%, 100% { opacity: 0.5; transform: translateX(-50%) scale(1); }
50% { opacity: 1; transform: translateX(-50%) scale(1.1); }
}
/* === Layout === */
.app-container {
position: relative;
z-index: 1;
max-width: 1400px;
margin: 0 auto;
padding: 24px;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* === Minimal / Embedded Iframe Mode === */
body.minimal-mode {
background: #03060c !important;
min-height: 100vh;
overflow-x: hidden;
margin: 0 !important;
padding: 0 !important;
}
body.minimal-mode .bg-glow {
display: none !important;
}
body.minimal-mode .app-container {
padding: 8px !important;
max-width: 100% !important;
min-height: 100vh !important;
margin: 0 !important;
}
body.minimal-mode .header,
body.minimal-mode #setupGuide,
body.minimal-mode #patientFilterToolbar,
body.minimal-mode #deviceNavBar,
body.minimal-mode .info-bar,
body.minimal-mode #singleTargetWaitingState,
body.minimal-mode #singlePatientBanner,
body.minimal-mode #singleVitalsGrid {
display: none !important;
}
/* === Header === */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 28px;
background: var(--bg-card);
backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
border-radius: 16px;
margin-bottom: 24px;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.header-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, var(--spo2-color), var(--hr-color));
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
box-shadow: 0 4px 16px rgba(6, 182, 212, 0.3);
}
.header-title {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.5px;
}
.header-subtitle {
font-size: 13px;
color: var(--text-secondary);
margin-top: 2px;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
/* Audio Alert Toggle */
.audio-toggle {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
background: rgba(100, 116, 139, 0.1);
border: 1px solid rgba(100, 116, 139, 0.2);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.3s;
user-select: none;
}
.audio-toggle:hover {
background: rgba(100, 116, 139, 0.2);
}
.audio-toggle.active {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
color: var(--status-critical);
}
.connection-badge {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.2);
color: var(--status-ok);
transition: all 0.3s;
}
.connection-badge.offline {
background: rgba(100, 116, 139, 0.1);
border-color: rgba(100, 116, 139, 0.2);
color: var(--status-offline);
}
.connection-badge.warning {
background: rgba(234, 179, 8, 0.1);
border-color: rgba(234, 179, 8, 0.2);
color: var(--status-warn);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--status-ok);
animation: statusPulse 2s ease-in-out infinite;
}
.connection-badge.offline .status-dot {
background: var(--status-offline);
animation: none;
}
@keyframes statusPulse {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); }
50% { opacity: 0.7; box-shadow: 0 0 0 6px rgba(34, 197, 94, 0); }
}
.time-display {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
color: var(--text-secondary);
}
/* === Setup Guide Panel === */
.setup-guide {
background: linear-gradient(135deg, rgba(6, 182, 212, 0.08), rgba(168, 85, 247, 0.06));
backdrop-filter: blur(20px);
border: 1px solid rgba(6, 182, 212, 0.15);
border-radius: 16px;
margin-bottom: 24px;
overflow: hidden;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.setup-guide.hidden {
max-height: 0;
margin-bottom: 0;
border-color: transparent;
opacity: 0;
}
.setup-guide-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 24px;
cursor: pointer;
user-select: none;
}
.setup-guide-header:hover {
background: rgba(255, 255, 255, 0.02);
}
.setup-guide-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 15px;
font-weight: 600;
color: var(--spo2-color);
}
.setup-guide-toggle {
font-size: 12px;
color: var(--text-dim);
padding: 4px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
transition: all 0.2s;
}
.setup-guide-body {
padding: 0 24px 20px 24px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.setup-step {
display: flex;
gap: 14px;
align-items: flex-start;
}
.step-number {
width: 32px;
height: 32px;
border-radius: 10px;
background: rgba(6, 182, 212, 0.15);
color: var(--spo2-color);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14px;
flex-shrink: 0;
}
.step-content h4 {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.step-content p {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
}
.step-content code {
background: rgba(255, 255, 255, 0.08);
padding: 2px 6px;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--spo2-color);
}
.setup-ip-display {
grid-column: 1 / -1;
background: rgba(0, 0, 0, 0.2);
border-radius: 12px;
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.setup-ip-label {
font-size: 13px;
color: var(--text-secondary);
}
.setup-ip-value {
font-family: 'JetBrains Mono', monospace;
font-size: 18px;
font-weight: 700;
color: var(--spo2-color);
text-shadow: 0 0 20px var(--spo2-glow);
}
/* === Vitals Grid === */
.vitals-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: auto auto;
gap: 20px;
flex: 1;
}
.vital-card {
background: var(--bg-card);
backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
border-radius: 20px;
padding: 28px;
position: relative;
overflow: hidden;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
display: flex;
flex-direction: column;
}
.vital-card:hover {
background: var(--bg-card-hover);
transform: translateY(-2px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
}
.vital-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
border-radius: 20px 20px 0 0;
}
.vital-card.hr::before { background: linear-gradient(90deg, var(--hr-color), transparent); }
.vital-card.spo2::before { background: linear-gradient(90deg, var(--spo2-color), transparent); }
.vital-card.bp::before { background: linear-gradient(90deg, var(--bp-color), transparent); }
.vital-card.rr::before { background: linear-gradient(90deg, var(--rr-color), transparent); }
.vital-card.temp::before { background: linear-gradient(90deg, var(--temp-color), transparent); }
.vital-card.map-card::before { background: linear-gradient(90deg, var(--map-color), transparent); }
/* Glow effect behind cards */
.vital-card::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 200px;
height: 200px;
border-radius: 50%;
transform: translate(-50%, -50%);
opacity: 0;
transition: opacity 0.6s;
pointer-events: none;
z-index: 0;
}
.vital-card.hr::after { background: radial-gradient(circle, var(--hr-glow), transparent 70%); }
.vital-card.spo2::after { background: radial-gradient(circle, var(--spo2-glow), transparent 70%); }
.vital-card.bp::after { background: radial-gradient(circle, var(--bp-glow), transparent 70%); }
.vital-card.rr::after { background: radial-gradient(circle, var(--rr-glow), transparent 70%); }
.vital-card.temp::after { background: radial-gradient(circle, var(--temp-glow), transparent 70%); }
.vital-card.map-card::after { background: radial-gradient(circle, var(--map-glow), transparent 70%); }
.vital-card.has-value::after {
opacity: 0.15;
}
.vital-card.has-value:hover::after {
opacity: 0.3;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
position: relative;
z-index: 1;
}
.card-label {
display: flex;
align-items: center;
gap: 10px;
}
.card-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.vital-card.hr .card-icon { background: rgba(239, 68, 68, 0.15); }
.vital-card.spo2 .card-icon { background: rgba(6, 182, 212, 0.15); }
.vital-card.bp .card-icon { background: rgba(168, 85, 247, 0.15); }
.vital-card.rr .card-icon { background: rgba(34, 197, 94, 0.15); }
.vital-card.temp .card-icon { background: rgba(249, 115, 22, 0.15); }
.vital-card.map-card .card-icon { background: rgba(236, 72, 153, 0.15); }
.card-name {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-secondary);
}
.vital-card.hr .card-name { color: var(--hr-color); }
.vital-card.spo2 .card-name { color: var(--spo2-color); }
.vital-card.bp .card-name { color: var(--bp-color); }
.vital-card.rr .card-name { color: var(--rr-color); }
.vital-card.temp .card-name { color: var(--temp-color); }
.vital-card.map-card .card-name { color: var(--map-color); }
.status-badge {
padding: 4px 10px;
border-radius: 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-badge.normal {
background: rgba(34, 197, 94, 0.15);
color: var(--status-ok);
}
.status-badge.warning {
background: rgba(234, 179, 8, 0.15);
color: var(--status-warn);
animation: warningFlash 2s ease-in-out infinite;
}
.status-badge.critical {
background: rgba(239, 68, 68, 0.15);
color: var(--status-critical);
animation: criticalFlash 1s ease-in-out infinite;
}
.status-badge.offline {
background: rgba(100, 116, 139, 0.1);
color: var(--status-offline);
}
@keyframes warningFlash {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
@keyframes criticalFlash {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* === Vital Value Display === */
.vital-value-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
min-height: 100px;
}
.vital-value {
font-family: 'JetBrains Mono', monospace;
font-size: 64px;
font-weight: 700;
line-height: 1;
letter-spacing: -2px;
transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.vital-card.hr .vital-value { color: var(--hr-color); text-shadow: 0 0 30px var(--hr-glow); }
.vital-card.spo2 .vital-value { color: var(--spo2-color); text-shadow: 0 0 30px var(--spo2-glow); }
.vital-card.bp .vital-value { color: var(--bp-color); text-shadow: 0 0 30px var(--bp-glow); }
.vital-card.rr .vital-value { color: var(--rr-color); text-shadow: 0 0 30px var(--rr-glow); }
.vital-card.temp .vital-value { color: var(--temp-color); text-shadow: 0 0 30px var(--temp-glow); }
.vital-card.map-card .vital-value { color: var(--map-color); text-shadow: 0 0 30px var(--map-glow); }
.vital-value.no-data {
font-size: 48px;
color: var(--text-dim) !important;
text-shadow: none !important;
}
.vital-value .unit {
font-size: 20px;
font-weight: 400;
letter-spacing: 0;
margin-left: 4px;
opacity: 0.6;
}
.vital-value .slash {
font-size: 48px;
opacity: 0.4;
margin: 0 2px;
}
.vital-value-update {
animation: valueFlash 0.6s ease-out;
}
@keyframes valueFlash {
0% { transform: scale(1.08); filter: brightness(1.4); }
100% { transform: scale(1); filter: brightness(1); }
}
/* === Waveform Canvas === */
.waveform-container {
height: 50px;
margin-top: 12px;
position: relative;
z-index: 1;
overflow: hidden;
border-radius: 8px;
}
.waveform-container canvas {
width: 100%;
height: 100%;
display: block;
}
/* === Normal Range Footer === */
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border-color);
position: relative;
z-index: 1;
}
.normal-range {
font-size: 12px;
color: var(--text-dim);
}
.last-update {
font-size: 11px;
color: var(--text-dim);
font-family: 'JetBrains Mono', monospace;
}
/* === Info Bar === */
.info-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: var(--bg-card);
backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
border-radius: 14px;
margin-top: 20px;
flex-wrap: wrap;
gap: 12px;
}
.info-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-secondary);
}
.info-item strong {
color: var(--text-primary);
font-weight: 600;
}
.info-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-dim);
}
.model-badge {
padding: 4px 12px;
border-radius: 8px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
background: linear-gradient(135deg, rgba(6, 182, 212, 0.15), rgba(168, 85, 247, 0.15));
color: var(--spo2-color);
border: 1px solid rgba(6, 182, 212, 0.2);
}
/* === Responsive === */
@media (max-width: 1024px) {
.vitals-grid {
grid-template-columns: repeat(2, 1fr);
}
.setup-guide-body {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.app-container { padding: 12px; }
.vitals-grid {
grid-template-columns: 1fr;
}
.header {
flex-direction: column;
gap: 12px;
text-align: center;
}
.header-right {
flex-wrap: wrap;
justify-content: center;
}
.vital-value { font-size: 48px; }
.setup-guide-body {
grid-template-columns: 1fr;
}
}
/* === Device Navigation & Multi-Bed CSS === */
.device-nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--bg-card);
backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
border-radius: 14px;
padding: 12px 18px;
margin-bottom: 20px;
gap: 12px;
flex-wrap: wrap;
}
.device-tabs-wrap {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.device-nav-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-dim);
display: flex;
align-items: center;
gap: 6px;
}
.device-tabs {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.device-tab-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
}
.device-tab-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
border-color: rgba(255, 255, 255, 0.2);
}
.device-tab-btn.active {
background: linear-gradient(135deg, rgba(6, 182, 212, 0.25), rgba(168, 85, 247, 0.25));
border-color: var(--spo2-color);
color: #fff;
box-shadow: 0 0 14px rgba(6, 182, 212, 0.3);
}
.device-tab-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--status-ok);
box-shadow: 0 0 6px var(--status-ok);
}
.view-mode-toggles {
display: flex;
background: rgba(0, 0, 0, 0.35);
padding: 4px;
border-radius: 8px;
border: 1px solid var(--border-color);
gap: 4px;
}
.view-mode-btn {
padding: 6px 14px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.view-mode-btn.active {
background: rgba(255, 255, 255, 0.15);
color: #fff;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
}
/* === Multi-Bed Central Grid === */
.multi-bed-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
margin-bottom: 24px;
}
.bed-card {
background: var(--bg-card);
backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
border-radius: 14px;
padding: 20px;
transition: all 0.25s ease;
cursor: pointer;
position: relative;
overflow: hidden;
}
.bed-card:hover {
border-color: rgba(6, 182, 212, 0.5);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
transform: translateY(-2px);
}
.bed-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
padding-bottom: 12px;
}
.bed-card-title {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
}
.bed-card-sub {
font-size: 11px;
color: var(--text-dim);
font-family: 'JetBrains Mono', monospace;
margin-top: 2px;
}
.bed-metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 12px;
}
.bed-metric {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px;
padding: 10px 8px;
text-align: center;
}
.bed-metric-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 4px;
}
.bed-metric-val {
font-size: 22px;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
}
.bed-card-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
color: var(--text-dim);
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.04);
}
/* === Patient Demographics & Filter Toolbar === */
.patient-filter-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--bg-card);
backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
border-radius: 14px;
padding: 12px 18px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.search-box-wrap {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 280px;
}
.search-input {
width: 100%;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
padding: 9px 14px;
font-size: 13px;
color: var(--text-primary);
font-family: inherit;
outline: none;
transition: all 0.2s;
}
.search-input:focus {
border-color: var(--spo2-color);
box-shadow: 0 0 10px rgba(6, 182, 212, 0.25);
}
.filter-select {
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
padding: 9px 12px;
font-size: 13px;
color: var(--text-primary);
outline: none;
cursor: pointer;
}
.filter-actions {
display: flex;
align-items: center;
gap: 10px;
}
.btn-primary {
background: linear-gradient(135deg, #06b6d4, #3b82f6);
color: #fff;
border: none;
padding: 9px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
}
.btn-primary:hover {
filter: brightness(1.15);
box-shadow: 0 4px 14px rgba(6, 182, 212, 0.4);
}
.btn-secondary {
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary);
border: 1px solid rgba(255, 255, 255, 0.15);
padding: 9px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.15);
}
/* Patient Demographics Badges */
.patient-mrn-badge {
background: rgba(6, 182, 212, 0.15);
color: var(--spo2-color);
border: 1px solid rgba(6, 182, 212, 0.3);
border-radius: 6px;
padding: 2px 8px;
font-size: 11px;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
display: inline-flex;
align-items: center;
gap: 4px;
}
.patient-gender-badge {
background: rgba(168, 85, 247, 0.15);
color: #c084fc;
border: 1px solid rgba(168, 85, 247, 0.3);
border-radius: 6px;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
}
.patient-dob-badge {
background: rgba(249, 115, 22, 0.15);
color: #fb923c;
border: 1px solid rgba(249, 115, 22, 0.3);
border-radius: 6px;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
font-family: 'JetBrains Mono', monospace;
}
.patient-name-display {
font-size: 17px;
font-weight: 700;
color: #fff;
letter-spacing: -0.3px;
}
.card-actions-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
gap: 8px;
}
.card-action-btn {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--text-secondary);
padding: 5px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 5px;
}
.card-action-btn:hover {
background: rgba(6, 182, 212, 0.15);
color: var(--spo2-color);
border-color: rgba(6, 182, 212, 0.3);
}
/* Modal Overlay & Dialog */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(8px);
z-index: 1000;
display: none;
align-items: center;
justify-content: center;
padding: 20px;
animation: fadeIn 0.2s ease-out;
}
.modal-overlay.active {
display: flex;
}
.modal-dialog {
background: #111827;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 16px;
width: 100%;
max-width: 580px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.7);
overflow: hidden;
display: flex;
flex-direction: column;
max-height: 90vh;
}
.modal-dialog.large {
max-width: 900px;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 24px;
border-bottom: 1px solid var(--border-color);
background: rgba(255, 255, 255, 0.02);
}
.modal-title {
font-size: 17px;
font-weight: 700;
display: flex;
align-items: center;
gap: 10px;
}
.modal-close-btn {
background: transparent;
border: none;
color: var(--text-dim);
font-size: 22px;
cursor: pointer;
padding: 4px;
line-height: 1;
transition: color 0.2s;
}
.modal-close-btn:hover {
color: #fff;
}
.modal-body {
padding: 24px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 16px 24px;
border-top: 1px solid var(--border-color);
background: rgba(255, 255, 255, 0.02);
gap: 10px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-label {
font-size: 11px;
font-weight: 700;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-input, .form-select {
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
padding: 10px 12px;
color: var(--text-primary);
font-size: 14px;
font-family: inherit;
outline: none;
transition: all 0.2s;
}
.form-input:focus, .form-select:focus {
border-color: var(--spo2-color);
box-shadow: 0 0 10px rgba(6, 182, 212, 0.25);
}
/* History Table */
.history-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.history-table th {
text-align: left;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.04);
color: var(--text-dim);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.history-table td {
padding: 10px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
}
.history-table tr:hover td {
background: rgba(255, 255, 255, 0.03);
}
/* Single view patient info card */
.patient-banner {
background: linear-gradient(135deg, rgba(6, 182, 212, 0.1), rgba(168, 85, 247, 0.08));
border: 1px solid rgba(6, 182, 212, 0.2);
border-radius: 14px;
padding: 16px 20px;
margin-bottom: 20px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 14px;
}
.patient-banner-left {
display: flex;
align-items: center;
gap: 14px;
}
.patient-banner-avatar {
width: 44px;
height: 44px;
border-radius: 12px;
background: rgba(6, 182, 212, 0.2);
color: var(--spo2-color);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
}
.patient-banner-details {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-top: 4px;
}
/* === Scrollbar === */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; }
/* === ICU Live Patient Monitor Console Styles === */
.icu-monitor {
display: flex;
flex-direction: column;
background: #050811;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.85), inset 0 1px 0 rgba(255, 255, 255, 0.1);
margin-bottom: 20px;
position: relative;
}
body.minimal-mode .icu-monitor {
border-radius: 12px;
margin-bottom: 0;
border: 1px solid rgba(255, 255, 255, 0.15);
min-height: calc(100vh - 16px);
}
.icu-monitor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: linear-gradient(180deg, #111a2e 0%, #0c1220 100%);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
flex-wrap: wrap;
gap: 12px;
}
.icu-patient-info {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.icu-patient-avatar {
width: 44px;
height: 44px;
border-radius: 10px;
background: linear-gradient(135deg, #0ea5e9, #6366f1);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: white;
font-weight: bold;
box-shadow: 0 0 16px rgba(14, 165, 233, 0.4);
}
.icu-patient-name {
font-size: 19px;
font-weight: 800;
color: #ffffff;
letter-spacing: 0.3px;
display: flex;
align-items: center;
gap: 10px;
}
.icu-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
}
.icu-tag-mrn {
background: rgba(6, 182, 212, 0.18);
color: #38bdf8;
border: 1px solid rgba(6, 182, 212, 0.35);
}
.icu-tag-bed {
background: rgba(168, 85, 247, 0.18);
color: #c084fc;
border: 1px solid rgba(168, 85, 247, 0.35);
}
.icu-tag-live {
background: rgba(16, 185, 129, 0.2);
color: #34d399;
border: 1px solid rgba(16, 185, 129, 0.4);
animation: liveBeacon 2s ease-in-out infinite;
}
@keyframes liveBeacon {
0%, 100% { box-shadow: 0 0 4px rgba(16, 185, 129, 0.3); }
50% { box-shadow: 0 0 14px rgba(16, 185, 129, 0.8); }
}
.icu-header-controls {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.heart-beat-indicator {
width: 34px;
height: 34px;
border-radius: 50%;
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: transform 0.08s, box-shadow 0.08s, background 0.08s;
cursor: pointer;
}
.heart-beat-indicator.beat {
transform: scale(1.35);
background: rgba(239, 68, 68, 0.45);
box-shadow: 0 0 18px #ef4444;
}
.icu-btn {
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #e2e8f0;
padding: 6px 12px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
outline: none;
}
.icu-btn:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
color: #fff;
}
.icu-btn.active {
background: rgba(6, 182, 212, 0.25);
border-color: #06b6d4;
color: #38bdf8;
box-shadow: 0 0 12px rgba(6, 182, 212, 0.35);
}
.icu-time-display {
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
font-weight: 700;
color: #94a3b8;
background: rgba(0, 0, 0, 0.4);
padding: 6px 10px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
/* Main Waveform Channels */
.icu-waveform-stage {
display: flex;
flex-direction: column;
background: #02050b;
flex: 1;
}
.icu-channel {
display: grid;
grid-template-columns: 1fr 220px;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
background: #02050c;
min-height: 140px;
position: relative;
}
@media (max-width: 768px) {
.icu-channel {
grid-template-columns: 1fr 140px;
min-height: 110px;
}
}
.icu-channel-canvas-wrap {
position: relative;
width: 100%;
height: 100%;
min-height: 130px;
overflow: hidden;
background: #02050b;
}
.icu-channel-canvas-wrap canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: block;
}
.icu-channel-lead-info {
position: absolute;
top: 8px;
left: 12px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
pointer-events: none;
z-index: 2;
display: flex;
align-items: center;
gap: 8px;
}
.lead-tag {
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 800;
}
.icu-channel-param {
display: flex;
flex-direction: column;
justify-content: center;
padding: 12px 18px;
border-left: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(9, 14, 26, 0.8);
backdrop-filter: blur(8px);
position: relative;
}
.param-title {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
display: flex;
align-items: center;
justify-content: space-between;
}
.param-value-wrap {
display: flex;
align-items: baseline;
gap: 6px;
margin: 4px 0;
}
.param-value-num {
font-family: 'JetBrains Mono', monospace;
font-size: 52px;
font-weight: 800;
line-height: 1;
letter-spacing: -2px;
}
.param-unit {
font-size: 16px;
font-weight: 600;
opacity: 0.75;
}
.param-sub {
font-size: 11px;
font-family: 'JetBrains Mono', monospace;
color: #94a3b8;
display: flex;
justify-content: space-between;
align-items: center;
}
/* Channel 1: ECG (Lead II) */
.icu-channel-ecg .icu-channel-lead-info { color: #00ff75; }
.icu-channel-ecg .lead-tag { background: rgba(0, 255, 117, 0.2); color: #00ff75; }
.icu-channel-ecg .param-title { color: #00ff75; }
.icu-channel-ecg .param-value-num { color: #00ff75; text-shadow: 0 0 20px rgba(0, 255, 117, 0.5); }
/* Channel 2: SpO2 PLETH */
.icu-channel-spo2 .icu-channel-lead-info { color: #00e5ff; }
.icu-channel-spo2 .lead-tag { background: rgba(0, 229, 255, 0.2); color: #00e5ff; }
.icu-channel-spo2 .param-title { color: #00e5ff; }
.icu-channel-spo2 .param-value-num { color: #00e5ff; text-shadow: 0 0 20px rgba(0, 229, 255, 0.5); }
/* Channel 3: RESP */
.icu-channel-resp .icu-channel-lead-info { color: #ffbe0b; }
.icu-channel-resp .lead-tag { background: rgba(255, 190, 11, 0.2); color: #ffbe0b; }
.icu-channel-resp .param-title { color: #ffbe0b; }
.icu-channel-resp .param-value-num { color: #ffbe0b; text-shadow: 0 0 20px rgba(255, 190, 11, 0.5); }
/* Perfusion index bar meter */
.pi-meter {
display: flex;
gap: 3px;
margin-top: 4px;
}
.pi-segment {
height: 4px;
flex: 1;
border-radius: 1px;
background: rgba(0, 229, 255, 0.15);
transition: background 0.15s;
}
.pi-segment.active {
background: #00e5ff;
box-shadow: 0 0 6px #00e5ff;
}
/* Bottom Parameters Row (NIBP, TEMP, MAP) */
.icu-footer-params {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
background: #060b16;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding: 12px 18px;
gap: 16px;
}
@media (max-width: 768px) {
.icu-footer-params {
grid-template-columns: 1fr;
gap: 10px;
}
}
.icu-footer-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
padding: 10px 14px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.icu-footer-card.nibp { border-left: 3px solid #c084fc; }
.icu-footer-card.temp { border-left: 3px solid #fb923c; }
.icu-footer-card.map { border-left: 3px solid #f43f5e; }
.icu-footer-card .card-head {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.8px;
color: #94a3b8;
display: flex;
align-items: center;
justify-content: space-between;
}
.icu-footer-card .card-val {
font-family: 'JetBrains Mono', monospace;
font-size: 32px;
font-weight: 800;
line-height: 1.1;
margin: 4px 0;
}
.icu-footer-card.nibp .card-val { color: #c084fc; text-shadow: 0 0 15px rgba(192, 132, 252, 0.4); }
.icu-footer-card.temp .card-val { color: #fb923c; text-shadow: 0 0 15px rgba(251, 146, 60, 0.4); }
.icu-footer-card.map .card-val { color: #f43f5e; text-shadow: 0 0 15px rgba(244, 63, 94, 0.4); }
.icu-footer-card .card-sub {
font-size: 11px;
font-family: 'JetBrains Mono', monospace;
color: #64748b;
}
/* Trends Container */
.icu-trends-stage {
display: none;
flex-direction: column;
gap: 16px;
padding: 20px;
background: #02050b;
flex: 1;
}
.icu-trend-chart-wrap {
background: #070d18;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 14px;
}
.icu-trend-title {
font-size: 13px;
font-weight: 700;
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: space-between;
}
.icu-trend-canvas {
width: 100%;
height: 130px;
display: block;
}
/* =========================================================================
CONTEC CMS7000PLUS / CMS8500 EXACT PATIENT MONITOR MACHINE STYLING
========================================================================= */
.contec-machine-screen {
background: #000000;
border: 2px solid #1e293b;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 0 35px rgba(0,0,0,0.95), inset 0 0 10px rgba(0,0,0,0.8);
display: flex;
flex-direction: column;
width: 100%;
min-height: 560px;
font-family: 'JetBrains Mono', -apple-system, sans-serif;
color: #ffffff;
user-select: none;
position: relative;
}
body.minimal-mode .contec-machine-screen {
border-radius: 0;
border: none;
min-height: 100vh;
}
/* Top Machine Bar */
.machine-top-bar {
display: flex;
align-items: center;
justify-content: space-between;
background: #090e18;
border-bottom: 1px solid #1e293b;
padding: 8px 14px;
font-size: 12px;
font-weight: 700;
gap: 10px;
flex-wrap: wrap;
}
.machine-brand {
color: #facc15;
font-weight: 900;
letter-spacing: 1.5px;
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
}
.machine-pill {
background: #1e293b;
color: #38bdf8;
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
border: 1px solid #334155;
}
.machine-alarm-normal {
background: rgba(16, 185, 129, 0.2);
color: #34d399;
border: 1px solid rgba(16, 185, 129, 0.4);
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
animation: beaconGlow 2s infinite ease-in-out;
}
@keyframes beaconGlow {
0%, 100% { box-shadow: 0 0 4px rgba(16, 185, 129, 0.3); }
50% { box-shadow: 0 0 12px rgba(16, 185, 129, 0.8); }
}
.machine-controls {
display: flex;
align-items: center;
gap: 8px;
}
.machine-btn {
background: #1e293b;
border: 1px solid #334155;
color: #cbd5e1;
padding: 4px 10px;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 5px;
transition: all 0.15s;
}
.machine-btn:hover {
background: #334155;
color: #fff;
}
.machine-btn.active {
background: #0284c7;
border-color: #38bdf8;
color: #fff;
}
/* Machine Screen Body: Left = Waveforms, Right = Parameters */
.machine-body {
display: grid;
grid-template-columns: 1fr 270px;
background: #000000;
flex: 1;
position: relative;
}
@media (max-width: 900px) {
.machine-body {
grid-template-columns: 1fr 210px;
}
}
/* Left: Waveforms Column (3 stacked rows) */
.machine-waveforms-col {
display: flex;
flex-direction: column;
border-right: 1px solid #1e293b;
background: #000000;
}
.wave-row {
flex: 1;
min-height: 140px;
position: relative;
border-bottom: 1px solid #111827;
overflow: hidden;
background: #000000;
}
.wave-row canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: block;
}
.wave-header {
position: absolute;
top: 6px;
left: 10px;
font-size: 12px;
font-weight: 800;
z-index: 2;
pointer-events: none;
display: flex;
align-items: center;
gap: 8px;
text-shadow: 0 0 8px rgba(0,0,0,0.9);
}
/* Calibration pulse bracket on left of ECG: [|--- 1mV ---|] */
.ecg-cal-pulse {
position: absolute;
left: 6px;
top: 50%;
transform: translateY(-50%);
width: 10px;
height: 38px;
border: 1.5px solid #00ff00;
border-right: none;
z-index: 2;
pointer-events: none;
opacity: 0.85;
}
/* Right: Parameter Blocks (Exact Contec Layout) */
.machine-params-col {
display: flex;
flex-direction: column;
background: #000000;
}
.param-box {
flex: 1;
border-bottom: 1px solid #1e293b;
padding: 8px 12px;
display: flex;
flex-direction: column;
justify-content: space-between;
position: relative;
background: #000000;
}
.param-box-top {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.5px;
}
.param-limits {
font-size: 11px;
color: #64748b;
font-weight: 600;
}
.param-box-main {
display: flex;
align-items: center;
justify-content: space-between;
margin: 2px 0;
}
.param-giant-num {
font-size: 58px;
font-weight: 900;
line-height: 1;
letter-spacing: -2px;
font-family: 'JetBrains Mono', monospace;
}
.param-unit-label {
font-size: 13px;
font-weight: 700;
margin-left: 4px;
}
.param-box-bottom {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 11px;
font-weight: 600;
color: #94a3b8;
}
/* Exact Contec Colors */
.param-hr { color: #00ff00; }
.param-hr .param-giant-num { color: #00ff00; text-shadow: 0 0 14px rgba(0,255,0,0.65); }
.param-spo2 { color: #00ffff; }
.param-spo2 .param-giant-num { color: #00ffff; text-shadow: 0 0 14px rgba(0,255,255,0.65); }
.param-resp { color: #ffff00; }
.param-resp .param-giant-num { color: #ffff00; text-shadow: 0 0 14px rgba(255,255,0,0.65); }
/* Vertical SpO2 LED Pulse Bar (Exact Contec Machine) */
.vertical-pulse-bar {
display: flex;
flex-direction: column-reverse;
gap: 2px;
width: 14px;
height: 52px;
padding: 2px;
background: #080d16;
border: 1px solid rgba(0, 255, 255, 0.3);
border-radius: 2px;
}
.v-segment {
flex: 1;
background: rgba(0, 255, 255, 0.12);
border-radius: 1px;
transition: background 0.08s;
}
.v-segment.lit {
background: #00ffff;
box-shadow: 0 0 6px #00ffff;
}
/* Bottom Row: NIBP & TEMP (Exact Contec Layout) */
.machine-bottom-row {
display: grid;
grid-template-columns: 1fr 1fr;
background: #070c18;
border-top: 1px solid #1e293b;
padding: 8px 14px;
gap: 12px;
}
.nibp-block {
color: #ffffff;
border-right: 1px solid #1e293b;
padding-right: 12px;
}
.nibp-title {
font-size: 11px;
font-weight: 800;
color: #d8b4fe;
display: flex;
justify-content: space-between;
}
.nibp-values {
font-size: 34px;
font-weight: 900;
font-family: 'JetBrains Mono', monospace;
color: #ffffff;
margin: 2px 0;
display: flex;
align-items: baseline;
gap: 6px;
text-shadow: 0 0 10px rgba(255,255,255,0.4);
}
.temp-block {
color: #ff9900;
padding-left: 6px;
}
.temp-title {
font-size: 11px;
font-weight: 800;
color: #ff9900;
display: flex;
justify-content: space-between;
}
.temp-values {
font-size: 34px;
font-weight: 900;
font-family: 'JetBrains Mono', monospace;
color: #ff9900;
margin: 2px 0;
display: flex;
align-items: baseline;
gap: 6px;
text-shadow: 0 0 10px rgba(255,153,0,0.4);
}
.alarm-banner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(239, 68, 68, 0.25);
border: 1px solid #ef4444;
color: #fca5a5;
padding: 6px 14px;
border-radius: 4px;
font-weight: 800;
font-size: 13px;
letter-spacing: 1px;
display: none;
z-index: 5;
animation: alarmFlash 1s infinite alternate;
}
@keyframes alarmFlash {
0% { opacity: 0.6; }
100% { opacity: 1; }
}
</style>
</head>
<body>
<div class="bg-grid"></div>
<div class="bg-glow"></div>
<div class="app-container">
<!-- Header -->
<header class="header" id="header">
<div class="header-left">
<div class="header-icon">🏥</div>
<div>
<div class="header-title">Patient Vital Signs Monitor</div>
<div class="header-subtitle">Contec CMS7000PLUS — Real-time Monitoring</div>
</div>
</div>
<div class="header-right">
<div class="audio-toggle" id="audioToggle" onclick="toggleAudioAlert()" title="Toggle audio alerts for critical vitals">
<span id="audioIcon">🔇</span>
<span id="audioText">Alerts Off</span>
</div>
<div class="connection-badge offline" id="connectionBadge">
<div class="status-dot"></div>
<span id="connectionText">Connecting...</span>
</div>
<div class="time-display" id="timeDisplay">--:--:--</div>
</div>
</header>
<!-- Setup Guide -->
<div class="setup-guide" id="setupGuide">
<div class="setup-guide-header" onclick="toggleSetupGuide()">
<div class="setup-guide-title">
<span>📡</span>
<span>CMS7000PLUS Setup Guide — Configure Your Monitor</span>
</div>
<div class="setup-guide-toggle" id="setupToggleText">▾ Collapse</div>
</div>
<div class="setup-guide-body" id="setupGuideBody">
<div class="setup-step">
<div class="step-number">1</div>
<div class="step-content">
<h4>Connect via Ethernet</h4>
<p>Connect your CMS7000PLUS to this PC using an Ethernet cable (direct or through a network switch).</p>
</div>
</div>
<div class="setup-step">
<div class="step-number">2</div>
<div class="step-content">
<h4>Set PC IP Address</h4>
<p>Set your PC's Ethernet adapter to a static IP, e.g. <code>192.168.1.50</code>, subnet <code>255.255.255.0</code>.</p>
</div>
</div>
<div class="setup-step">
<div class="step-number">3</div>
<div class="step-content">
<h4>Configure Monitor CMS Settings</h4>
<p>On your CMS7000PLUS: <code>System Setup → Network → CMS Settings</code>. Set the Server IP to your PC's IP.</p>
</div>
</div>
<div class="setup-step">
<div class="step-number">4</div>
<div class="step-content">
<h4>Set Server Port</h4>
<p>Set Server Port to <code>511</code> (default). If running without admin, use a port &gt;1024 like <code>6060</code>.</p>
</div>
</div>
<div class="setup-step">
<div class="step-number">5</div>
<div class="step-content">
<h4>Enable CMS Connection</h4>
<p>Enable the CMS/Central Monitor connection on your CMS7000PLUS. Set the sending interval (e.g., 5 seconds).</p>
</div>
</div>
<div class="setup-step">
<div class="step-number">6</div>
<div class="step-content">
<h4>Data Will Appear Automatically</h4>
<p>Once connected, vital signs will appear on this dashboard in real-time via WebSocket or polling.</p>
</div>
</div>
<div class="setup-ip-display">
<div>
<div class="setup-ip-label">🖥️ Your PC's Detected IP Address (set this on the monitor)</div>
</div>
<div class="setup-ip-value" id="detectedIp">Detecting...</div>
</div>
</div>
</div>
<!-- Patient Search & Demographics Filter Bar -->
<div class="patient-filter-toolbar" id="patientFilterToolbar">
<div class="search-box-wrap">
<span style="font-size: 16px;">🔍</span>
<input type="text" id="patientSearchInput" class="search-input"
placeholder="Search by Patient MRN, Name, DOB, Gender, or Bed..."
oninput="onPatientSearchChange()">
</div>
<div class="filter-actions">
<select id="genderFilterSelect" class="filter-select" onchange="onPatientSearchChange()">
<option value="">All Genders</option>
<option value="Male">Male (♂)</option>
<option value="Female">Female (♀)</option>
<option value="Other">Other</option>
</select>
<button class="btn-primary" onclick="openPatientHistoryModal()" title="Search and fetch data for any patient">
<span>📋</span>
<span>Fetch Patient Data</span>
</button>
<button class="btn-secondary" onclick="openAssignPatientModal()" title="Assign or register a patient to a monitor">
<span>➕</span>
<span>Assign Patient</span>
</button>
</div>
</div>
<!-- Device Selector Bar (Switch Between Monitors) -->
<div class="device-nav-bar" id="deviceNavBar">
<div class="device-tabs-wrap">
<span class="device-nav-label">🖥️ Monitors / Beds:</span>
<div class="device-tabs" id="deviceTabs">
<button class="device-tab-btn active" id="tab-all-beds" onclick="setViewMode('grid')">
<span>🏥 All Beds Overview</span>
</button>
</div>
</div>
<div class="view-mode-toggles">
<button class="view-mode-btn" id="btnSingleView" onclick="setViewMode('single')">Detailed View</button>
<button class="view-mode-btn active" id="btnGridView" onclick="setViewMode('grid')">Multi-Bed Grid</button>
</div>
</div>
<!-- Multi-Bed Central Grid View -->
<div class="multi-bed-grid" id="multiBedGrid">
<div class="bed-card" style="grid-column: 1 / -1; text-align: center; padding: 40px;">
<div style="font-size: 32px; margin-bottom: 12px;">⏳</div>
<div style="font-size: 16px; font-weight: 600; color: var(--text-primary);">Waiting for patient monitors to connect...</div>
<div style="font-size: 13px; color: var(--text-secondary); margin-top: 6px;">
Local Wi-Fi monitors connect to <code>192.168.100.112:511</code> | Remote hotspot monitors connect to <code>159.223.110.159:61501</code>
</div>
</div>
</div>
<!-- Single Machine Detailed View Wrapper -->
<!-- Single Machine Detailed View Wrapper (EXACT CONTEC CMS7000PLUS PATIENT MONITOR) -->
<div id="singleViewWrapper" style="display: none; flex-direction: column; flex: 1;">
<div class="contec-machine-screen" id="contecMachineScreen">
<!-- Top Machine Bar -->
<div class="machine-top-bar">
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<span class="machine-brand">🏥 CONTEC CMS7000PLUS</span>
<span class="machine-pill" id="machBedPill">BED --</span>
<span class="machine-pill" id="machCategoryPill">ADULT</span>
<span style="font-weight: 800; color: #ffffff;" id="machPatientName">--</span>
<span class="machine-pill" id="machMrnPill">MRN: --</span>
<span style="color: #64748b; font-size: 11px;">PACE: NO</span>
<span class="machine-alarm-normal">● TELEMETRY NORMAL</span>
</div>
<div class="machine-controls">
<span style="font-size: 15px; cursor: pointer;" id="heartBeatPulse" onclick="triggerHeartBeatPulse()" title="Cardiac Heartbeat">❤️</span>
<button type="button" class="machine-btn" id="btnMachAudio" onclick="toggleQrsAudio()">
<span id="machAudioIcon">🔇</span>
<span id="machAudioText">BEEP OFF</span>
</button>
<button type="button" class="machine-btn" id="btnMachFreeze" onclick="toggleFreezeWaveforms()">
<span id="machFreezeIcon">⏸</span>
<span id="machFreezeText">FREEZE</span>
</button>
<span class="machine-pill" style="color: #94a3b8;">25.0 mm/s</span>
<span class="machine-pill" id="machClock">10:48:00</span>
</div>
</div>
<!-- Machine Screen Body: Left = Waveforms, Right = Parameters -->
<div class="machine-body">
<!-- Left: 3 Stacked Oscilloscope Waveform Sweeps -->
<div class="machine-waveforms-col">
<!-- Waveform 1: ECG Lead II -->
<div class="wave-row" id="rowEcg">
<canvas id="canvasMachEcg"></canvas>
<div class="ecg-cal-pulse"></div>
<div class="wave-header" style="color: #00ff00;">
<span>II</span>
<span>ECG</span>
<span style="color: #64748b;">x1.0</span>
<span style="color: #64748b;">ST: +0.00mV</span>
<span style="color: #64748b;">PVC: 0</span>
<span style="color: #475569;">MONITOR</span>
</div>
<div class="alarm-banner" id="alarmEcgOff" style="display: block;">ECG LEAD OFF</div>
</div>
<!-- Waveform 2: SpO2 Plethysmogram -->
<div class="wave-row" id="rowSpo2">
<canvas id="canvasMachSpo2"></canvas>
<div class="wave-header" style="color: #00ffff;">
<span>SpO2</span>
<span style="color: #64748b;">x1.0</span>
<span style="color: #00ffff;" id="machPiVal">PI: 2.4%</span>
<span style="color: #475569;">PULSE SYNC</span>
</div>
<div class="alarm-banner" id="alarmSpo2Off" style="display: block;">SpO2 SENSOR OFF</div>
</div>
<!-- Waveform 3: Respiration (RESP) -->
<div class="wave-row" id="rowResp">
<canvas id="canvasMachResp"></canvas>
<div class="wave-header" style="color: #ffff00;">
<span>RESP</span>
<span style="color: #64748b;">x1.0</span>
<span style="color: #64748b;">APNEA: 20s</span>
</div>
<div class="alarm-banner" id="alarmRespOff" style="display: block;">RESP LEAD OFF</div>
</div>
</div>
<!-- Right: Parameter Readouts (Exact Contec Layout) -->
<div class="machine-params-col">
<!-- HR Box (Green) -->
<div class="param-box param-hr">
<div class="param-box-top">
<span>ECG HR</span>
<span class="param-limits">&lt; 120 / 50 &gt;</span>
</div>
<div class="param-box-main">
<span class="param-giant-num" id="valMachHr">--</span>
<span class="param-unit-label">bpm</span>
</div>
<div class="param-box-bottom">
<span>ARRHY: MONITOR</span>
<span id="machHrStatus" style="color: #ef4444;">LEAD OFF</span>
</div>
</div>
<!-- SpO2 Box (Cyan) -->
<div class="param-box param-spo2">
<div class="param-box-top">
<span>SpO2</span>
<span class="param-limits">&lt; 100 / 90 &gt;</span>
</div>
<div class="param-box-main">
<div>
<span class="param-giant-num" id="valMachSpo2">--</span>
<span class="param-unit-label">%</span>
</div>
<!-- Vertical Pulse LED Column -->
<div class="vertical-pulse-bar" id="pulseBarVertical" title="Pulse Strength">
<div class="v-segment"></div>
<div class="v-segment"></div>
<div class="v-segment"></div>
<div class="v-segment"></div>
<div class="v-segment"></div>
<div class="v-segment"></div>
<div class="v-segment"></div>
<div class="v-segment"></div>
</div>
</div>
<div class="param-box-bottom">
<span>PR: <strong id="valMachPr" style="color: #00ffff;">--</strong> bpm</span>
<span style="color: #64748b;" id="machSpo2Status">SENSOR OFF</span>
</div>
</div>
<!-- RESP Box (Yellow) -->
<div class="param-box param-resp">
<div class="param-box-top">
<span>RESP</span>
<span class="param-limits">&lt; 30 / 8 &gt;</span>
</div>
<div class="param-box-main">
<span class="param-giant-num" id="valMachRr">--</span>
<span class="param-unit-label">/min</span>
</div>
<div class="param-box-bottom">
<span>APNEA ALM: 20s</span>
<span style="color: #64748b;" id="machRespStatus">LEAD OFF</span>
</div>
</div>
</div>
</div>
<!-- Bottom Row: NIBP & Temperature (Exact Contec Layout) -->
<div class="machine-bottom-row">
<!-- NIBP Block -->
<div class="nibp-block">
<div class="nibp-title">
<span>NIBP mmHg</span>
<span style="color: #94a3b8; font-weight: normal;">&lt; 160 / 90 &gt; • Auto 15m</span>
</div>
<div class="nibp-values">
<span id="valMachSys">--</span> / <span id="valMachDia">--</span>
<span style="font-size: 18px; color: #d8b4fe; font-weight: 700; margin-left: 8px;">(<span id="valMachMap">--</span>)</span>
</div>
<div style="font-size: 11px; color: #64748b; display: flex; justify-content: space-between;">
<span>SYS / DIA (MAP)</span>
<span id="machNibpTime">Mode: Manual</span>
</div>
</div>
<!-- Temperature Block (Dual T1 & T2 + TD) -->
<div class="temp-block">
<div class="temp-title">
<span>TEMP °C</span>
<span style="color: #94a3b8; font-weight: normal;">&lt; 37.5 / 35.0 &gt;</span>
</div>
<div class="temp-values-dual" style="display: flex; align-items: baseline; gap: 14px; margin: 2px 0;">
<div>
<span style="font-size: 13px; font-weight: 700; color: #fbbf24;">T1: </span>
<span style="font-size: 32px; font-weight: 900; font-family: 'JetBrains Mono', monospace; color: #ff9900;" id="valMachTemp">--</span>
<span style="font-size: 12px; color: #f97316; font-weight: 600; margin-left: 3px;" id="valMachTempF">--</span>
</div>
<div>
<span style="font-size: 13px; font-weight: 700; color: #fbbf24;">T2: </span>
<span style="font-size: 32px; font-weight: 900; font-family: 'JetBrains Mono', monospace; color: #ff9900;" id="valMachTemp2">--</span>
<span style="font-size: 12px; color: #f97316; font-weight: 600; margin-left: 3px;" id="valMachTemp2F">--</span>
</div>
</div>
<div style="font-size: 11px; color: #64748b; display: flex; justify-content: space-between; align-items: center;">
<span>TD: <strong id="valMachTempDiff" style="color: #fbbf24;">--</strong> °C</span>
<span style="color: #ff9900;">Dual Probe</span>
</div>
</div>
</div>
</div>
</div> <!-- /singleViewWrapper -->
<!-- Info Bar -->
<div class="info-bar">
<div class="info-item">
<span>Model:</span>
<span class="model-badge" id="modelBadge">CMS7000PLUS</span>
</div>
<div class="info-dot"></div>
<div class="info-item">
<span>Device:</span>
<strong id="deviceId">Waiting...</strong>
</div>
<div class="info-dot"></div>
<div class="info-item">
<span>Patient:</span>
<strong id="patientId">Waiting...</strong>
</div>
<div class="info-dot"></div>
<div class="info-item">
<span>Readings:</span>
<strong id="totalReadings">0</strong>
</div>
<div class="info-dot"></div>
<div class="info-item">
<span>Last Update:</span>
<strong id="lastUpdateTime">Never</strong>
</div>
<div class="info-dot"></div>
<div class="info-item">
<span>API:</span>
<strong id="apiStatus">Checking...</strong>
</div>
</div>
</div>
<!-- Modal 1: Assign / Edit Patient -->
<div class="modal-overlay" id="modalAssignPatient" onclick="if(event.target===this) closeAssignPatientModal()">
<div class="modal-dialog">
<div class="modal-header">
<div class="modal-title">
<span>✏️</span>
<span id="assignModalTitle">Assign / Edit Patient Demographics</span>
</div>
<button class="modal-close-btn" onclick="closeAssignPatientModal()">×</button>
</div>
<form onsubmit="submitAssignPatient(event)">
<div class="modal-body">
<div class="form-group">
<label class="form-label">Connected Monitor / Machine</label>
<select id="assignDeviceSelect" class="form-select" required>
<!-- Dynamically populated with active/known devices -->
</select>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Patient MRN / ID *</label>
<input type="text" id="assignMrn" class="form-input" placeholder="Enter MRN / Patient ID" required>
</div>
<div class="form-group">
<label class="form-label">Full Name *</label>
<input type="text" id="assignName" class="form-input" placeholder="Enter Patient Full Name" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Date of Birth (DOB)</label>
<input type="date" id="assignDob" class="form-input">
</div>
<div class="form-group">
<label class="form-label">Gender</label>
<select id="assignGender" class="form-select">
<option value="Male">Male (♂)</option>
<option value="Female">Female (♀)</option>
<option value="Other">Other</option>
<option value="Unknown">Unknown</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Bed / Ward Location</label>
<input type="text" id="assignBed" class="form-input" placeholder="e.g. ICU Bed 01">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick="closeAssignPatientModal()">Cancel</button>
<button type="submit" class="btn-primary" id="btnSavePatient">
<span>💾</span>
<span>Save & Link to Machine</span>
</button>
</div>
</form>
</div>
</div>
<!-- Modal 2: Fetch Patient Data & History Viewer -->
<div class="modal-overlay" id="modalPatientHistory" onclick="if(event.target===this) closePatientHistoryModal()">
<div class="modal-dialog large">
<div class="modal-header">
<div class="modal-title">
<span>📋</span>
<span>Fetch Patient Data & Vitals History</span>
</div>
<button class="modal-close-btn" onclick="closePatientHistoryModal()">×</button>
</div>
<div class="modal-body">
<!-- Search inputs in history modal -->
<div style="display: flex; gap: 10px; align-items: center; background: rgba(0,0,0,0.3); padding: 12px; border-radius: 10px;">
<input type="text" id="historySearchQuery" class="form-input" style="flex: 1;"
placeholder="Enter Patient MRN, Name, or DOB..."
onkeydown="if(event.key==='Enter') executePatientHistorySearch()">
<button type="button" class="btn-primary" onclick="executePatientHistorySearch()">
<span>🔍 Search Patient</span>
</button>
</div>
<!-- Patient Profile Summary Banner -->
<div class="patient-banner" id="historyPatientProfile" style="display: none; margin-bottom: 0;">
<div class="patient-banner-left">
<div class="patient-banner-avatar" id="historyAvatar">👤</div>
<div>
<div class="patient-name-display" id="historyName">---</div>
<div class="patient-banner-details">
<span class="patient-mrn-badge" id="historyMrn">MRN: ---</span>
<span class="patient-gender-badge" id="historyGender">Gender: ---</span>
<span class="patient-dob-badge" id="historyDob">DOB: ---</span>
<span style="font-size: 12px; color: var(--text-dim);" id="historyBed">Bed: ---</span>
<span style="font-size: 12px; color: var(--text-dim);" id="historyDevice">Machine: ---</span>
</div>
</div>
</div>
<div>
<button class="btn-secondary" onclick="exportHistoryToCsv()">
<span>📥 Export CSV</span>
</button>
</div>
</div>
<!-- Historical Table -->
<div style="max-height: 380px; overflow-y: auto; border: 1px solid rgba(255,255,255,0.08); border-radius: 10px;">
<table class="history-table">
<thead>
<tr>
<th>Timestamp</th>
<th>HR (bpm)</th>
<th>SpO₂ (%)</th>
<th>NIBP (mmHg)</th>
<th>RR (/min)</th>
<th>Temp (°C)</th>
<th>MAP</th>
</tr>
</thead>
<tbody id="historyTableBody">
<tr>
<td colspan="7" style="text-align: center; color: var(--text-dim); padding: 30px;">
Search for a patient by MRN or Name to view past readings.
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick="closePatientHistoryModal()">Close</button>
</div>
</div>
</div>
<script>
// === URL Parameters (Minimal / Teleconsult Embed & Demographics Filtering: MRN, Name, DOB, Gender) ===
const urlParams = new URLSearchParams(window.location.search);
const isMinimal = urlParams.get('minimal') === 'true' || urlParams.get('embed') === 'true';
const targetMrn = (urlParams.get('mrn') || urlParams.get('patient_id') || '').trim();
const targetName = (urlParams.get('name') || urlParams.get('patient_name') || '').trim();
const targetDob = (urlParams.get('dob') || urlParams.get('patient_dob') || '').trim();
const targetGender = (urlParams.get('gender') || urlParams.get('patient_gender') || '').trim();
const hasTargetFilter = !!(targetMrn || targetName || targetDob || targetGender);
if (isMinimal) {
document.body.classList.add('minimal-mode');
}
// Helper: Calculate age in milliseconds robustly against UTC and local timezones
function getTimestampAgeMs(ts) {
if (!ts) return 999999999;
try {
let str = String(ts).trim();
if (!str.endsWith('Z') && !str.includes('+') && !/[-+]\d{2}:\d{2}$/.test(str)) {
str = str + 'Z';
}
const time = new Date(str).getTime();
if (isNaN(time)) return 999999999;
return Math.abs(Date.now() - time);
} catch (e) {
return 999999999;
}
}
function getTargetPatientBadge() {
const items = [];
if (targetMrn) items.push(`<span class="patient-mrn-badge" style="padding: 4px 10px; font-size: 12px;">📋 MRN: <strong>${targetMrn}</strong></span>`);
if (targetName) items.push(`<span class="patient-name-badge" style="padding: 4px 10px; background: rgba(14, 165, 233, 0.15); border: 1px solid rgba(14, 165, 233, 0.3); border-radius: 6px; font-size: 12px; color: #38bdf8;">👤 Name: <strong>${targetName}</strong></span>`);
if (targetDob) items.push(`<span class="patient-dob-badge" style="padding: 4px 10px; font-size: 12px;">🎂 DOB: <strong>${targetDob}</strong></span>`);
if (targetGender) items.push(`<span class="patient-gender-badge" style="padding: 4px 10px; font-size: 12px;">⚧ Gender: <strong>${targetGender}</strong></span>`);
return items.length > 0 ? items.join('') : '<span>Target Patient</span>';
}
// Helper: Extract 4-digit year and digits for robust DOB matching
function normalizeDob(str) {
if (!str) return { raw: '', digits: '', year: null };
const clean = String(str).trim();
const yearMatch = clean.match(/\b(19\d\d|20[0-2]\d)\b/);
const digits = clean.replace(/\D/g, '');
return {
raw: clean.toLowerCase(),
digits: digits,
year: yearMatch ? yearMatch[1] : null
};
}
// Helper: Extract individual words for flexible name matching
function normalizeNameTokens(str) {
if (!str) return [];
return String(str)
.toLowerCase()
.replace(/[^a-z0-9\s]/g, '')
.trim()
.split(/\s+/)
.filter(w => w.length >= 2);
}
// Check verification for each requested parameter (MRN, Name, DOB, Gender)
function checkPatientVerification(dev) {
const res = {
mrnMatched: !targetMrn,
nameMatched: !targetName,
dobMatched: !targetDob,
genderMatched: !targetGender,
allMatched: false
};
if (!dev) return res;
let pMrn = String(dev?.mrn || dev?.patient_id || '').toLowerCase().trim();
if ((!pMrn || pMrn === 'unknown') && dev?.device_id) {
const pat = Object.values(patientRegistry).find(p => p.device_id === dev.device_id);
if (pat) pMrn = String(pat.mrn || pat.patient_id || '').toLowerCase().trim();
}
let pName = String(dev?.patient_name || dev?.name || '').toLowerCase().trim();
if ((!pName || pName === 'unknown') && dev?.device_id) {
const pat = Object.values(patientRegistry).find(p => p.device_id === dev.device_id);
if (pat) pName = String(pat.name || '').toLowerCase().trim();
}
const pDob = String(dev?.patient_dob || dev?.dob || '').toLowerCase().trim();
const pGender = String(dev?.patient_gender || dev?.gender || '').toLowerCase().trim();
// 1. MRN verification
if (targetMrn) {
const tm = targetMrn.toLowerCase().trim();
const cleanTm = tm.replace(/^0+/, '');
const cleanMrn = pMrn.replace(/^0+/, '');
if (pMrn && (pMrn === tm || (cleanTm.length >= 2 && cleanTm === cleanMrn))) {
res.mrnMatched = true;
}
}
// 2. Name verification (supports exact, partial or token-based matching)
if (targetName) {
const tn = targetName.toLowerCase().trim();
const targetTokens = normalizeNameTokens(targetName);
const patientTokens = normalizeNameTokens(pName);
if (pName && (pName.includes(tn) || tn.includes(pName))) {
res.nameMatched = true;
} else if (targetTokens.length > 0 && targetTokens.some(t => patientTokens.includes(t))) {
res.nameMatched = true;
}
}
// 3. DOB verification (supports 1960, 1960-06-22, 22/06/1960, 19600622)
if (targetDob) {
const td = targetDob.toLowerCase().trim();
const tNorm = normalizeDob(targetDob);
const pNorm = normalizeDob(pDob);
if (pDob && (pDob.includes(td) || td.includes(pDob))) {
res.dobMatched = true;
} else if (tNorm.year && pNorm.year && tNorm.year === pNorm.year) {
res.dobMatched = true;
} else if (tNorm.digits && pNorm.digits && (tNorm.digits.includes(pNorm.digits) || pNorm.digits.includes(tNorm.digits))) {
res.dobMatched = true;
}
}
// 4. Gender verification
if (targetGender) {
const tg = targetGender.toLowerCase().trim();
if (pGender && (pGender.startsWith(tg[0]) || pGender.includes(tg))) {
res.genderMatched = true;
}
}
// Match positively on MRN, or on Name (with DOB if supplied)
if (targetMrn && res.mrnMatched) {
res.allMatched = true;
} else if (targetName && res.nameMatched && (!targetDob || res.dobMatched)) {
res.allMatched = true;
} else if (!targetMrn && !targetName) {
res.allMatched = res.dobMatched && res.genderMatched;
} else {
res.allMatched = false;
}
return res;
}
// Helper function: Check if a device or patient matches the target demographics (MRN, Name, DOB, Gender)
function matchesTargetPatient(dev, devId) {
if (!hasTargetFilter) return true;
if (!dev && !devId) return false;
return checkPatientVerification(dev).allMatched;
}
// Backward-compatible alias
const matchesTargetMrn = (dev, devId) => matchesTargetPatient(dev, devId);
// =========================================================================
// =========================================================================
// === CONTEC CMS7000PLUS EXACT MACHINE OSCILLOSCOPE SWEEP ENGINE ===
// =========================================================================
// STRICT RULE: ZERO FAKE / SIMULATED DATA.
// If sensors/leads are disconnected or monitor sends null, show '--', 'LEAD OFF', and flat lines.
const machVitals = {
heart_rate: null,
spo2: null,
systolic_bp: null,
diastolic_bp: null,
map_bp: null,
respiratory_rate: null,
temperature: null,
temperature2: null,
temperature_diff: null,
device_id: null,
patient_name: null,
patient_id: null,
bed_id: null
};
function clearMachineVitals() {
machVitals.heart_rate = null;
machVitals.spo2 = null;
machVitals.systolic_bp = null;
machVitals.diastolic_bp = null;
machVitals.map_bp = null;
machVitals.respiratory_rate = null;
machVitals.temperature = null;
machVitals.temperature2 = null;
machVitals.temperature_diff = null;
machVitals.device_id = null;
machVitals.patient_name = targetName || null;
machVitals.patient_id = targetMrn || null;
machVitals.bed_id = null;
machVitals.patient_type = null;
const elName = document.getElementById('machPatientName');
if (elName) elName.textContent = machVitals.patient_name ? machVitals.patient_name.toUpperCase() : '--';
const elMrn = document.getElementById('machMrnPill');
if (elMrn) elMrn.textContent = machVitals.patient_id ? `MRN: ${machVitals.patient_id}` : 'MRN: --';
const elBed = document.getElementById('machBedPill');
if (elBed) elBed.textContent = machVitals.bed_id ? `BED ${machVitals.bed_id}` : 'BED --';
updateMachineDisplay();
}
let machSweepX = 0;
let machPrevY_ecg = null;
let machPrevY_spo2 = null;
let machPrevY_resp = null;
let machLastFrameTime = performance.now();
let machLastRWavePeakTime = 0;
let machIsFrozen = false;
let machAudioEnabled = false;
let machAudioCtx = null;
let canvasMachEcg = null, ctxMachEcg = null;
let canvasMachSpo2 = null, ctxMachSpo2 = null;
let canvasMachResp = null, ctxMachResp = null;
function initIcuMonitor() {
canvasMachEcg = document.getElementById('canvasMachEcg');
canvasMachSpo2 = document.getElementById('canvasMachSpo2');
canvasMachResp = document.getElementById('canvasMachResp');
if (!canvasMachEcg || !canvasMachSpo2 || !canvasMachResp) return;
ctxMachEcg = canvasMachEcg.getContext('2d');
ctxMachSpo2 = canvasMachSpo2.getContext('2d');
ctxMachResp = canvasMachResp.getContext('2d');
resizeMachineCanvases();
window.addEventListener('resize', resizeMachineCanvases);
if (targetMrn) {
machVitals.patient_id = targetMrn;
const elMrn = document.getElementById('machMrnPill');
if (elMrn) elMrn.textContent = `MRN: ${targetMrn}`;
}
if (targetName) {
machVitals.patient_name = targetName;
const elName = document.getElementById('machPatientName');
if (elName) elName.textContent = targetName.toUpperCase();
}
updateMachineDisplay();
// Load real patient telemetry from DB
fetchMachineTelemetry();
setInterval(fetchMachineTelemetry, 2000);
// Start 60fps oscilloscope sweep
requestAnimationFrame(stepContecMachineSweep);
}
function resizeMachineCanvases() {
const rows = [
{ c: canvasMachEcg, row: document.getElementById('rowEcg') },
{ c: canvasMachSpo2, row: document.getElementById('rowSpo2') },
{ c: canvasMachResp, row: document.getElementById('rowResp') }
];
const dpr = window.devicePixelRatio || 1;
rows.forEach(({ c, row }) => {
if (!c || !row) return;
const rect = row.getBoundingClientRect();
const w = Math.floor(rect.width || 600);
const h = Math.floor(rect.height || 140);
c.width = w * dpr;
c.height = h * dpr;
const ctx = c.getContext('2d');
ctx.resetTransform ? ctx.resetTransform() : ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
c._logicalWidth = w;
c._logicalHeight = h;
// Black background
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, w, h);
});
machSweepX = 0;
machPrevY_ecg = null;
machPrevY_spo2 = null;
machPrevY_resp = null;
}
function stepContecMachineSweep(timestamp) {
if (document.hidden) {
machLastFrameTime = timestamp;
requestAnimationFrame(stepContecMachineSweep);
return;
}
if (!ctxMachEcg || !canvasMachEcg) {
requestAnimationFrame(stepContecMachineSweep);
return;
}
const dt = Math.min((timestamp - machLastFrameTime) / 1000, 0.1);
machLastFrameTime = timestamp;
if (machIsFrozen) {
requestAnimationFrame(stepContecMachineSweep);
return;
}
const width = canvasMachEcg._logicalWidth || 600;
const hEcg = canvasMachEcg._logicalHeight || 140;
const hSpo2 = canvasMachSpo2._logicalHeight || 140;
const hResp = canvasMachResp._logicalHeight || 140;
// Speed: 25.0 mm/s (approx 120 pixels per second on typical screens)
const sweepSpeedPx = 120;
const dx = Math.max(1.2, sweepSpeedPx * dt);
const nextX = machSweepX + dx;
// Clear the 18px moving blanking gap ahead of the beam
const clearWidth = 18;
[
{ ctx: ctxMachEcg, h: hEcg },
{ ctx: ctxMachSpo2, h: hSpo2 },
{ ctx: ctxMachResp, h: hResp }
].forEach(({ ctx, h }) => {
ctx.fillStyle = '#000000';
ctx.fillRect(machSweepX, 0, clearWidth, h);
if (machSweepX + clearWidth > width) {
ctx.fillRect(0, 0, (machSweepX + clearWidth) - width, h);
}
});
const t = timestamp / 1000;
// 1. ECG Lead II Waveform
// ONLY draw cardiac QRS complexes if a real heart rate is being received from the monitor
const hasHr = machVitals.heart_rate != null && machVitals.heart_rate > 0;
let yEcg = hEcg * 0.54;
if (hasHr) {
const hr = Math.max(40, Math.min(180, machVitals.heart_rate));
const ecgPeriod = 60 / hr;
const ecgPhase = (t % ecgPeriod) / ecgPeriod;
let ecgNorm = 0;
if (ecgPhase >= 0.12 && ecgPhase < 0.22) {
ecgNorm = 0.16 * Math.sin(((ecgPhase - 0.12) / 0.10) * Math.PI); // P wave
} else if (ecgPhase >= 0.27 && ecgPhase < 0.30) {
ecgNorm = -0.16 * Math.sin(((ecgPhase - 0.27) / 0.03) * Math.PI); // Q wave
} else if (ecgPhase >= 0.30 && ecgPhase < 0.35) {
const rFrac = (ecgPhase - 0.30) / 0.05;
ecgNorm = 0.98 * Math.sin(rFrac * Math.PI); // R wave peak
if (rFrac >= 0.45 && rFrac <= 0.55 && (timestamp - machLastRWavePeakTime > (ecgPeriod * 750))) {
machLastRWavePeakTime = timestamp;
triggerHeartBeatPulse();
}
} else if (ecgPhase >= 0.35 && ecgPhase < 0.39) {
ecgNorm = -0.26 * Math.sin(((ecgPhase - 0.35) / 0.04) * Math.PI); // S wave
} else if (ecgPhase >= 0.48 && ecgPhase < 0.66) {
ecgNorm = 0.26 * Math.sin(((ecgPhase - 0.48) / 0.18) * Math.PI); // T wave
}
ecgNorm += (Math.sin(t * 80) * 0.012);
yEcg = (hEcg * 0.54) - (ecgNorm * (hEcg * 0.42));
const elAlarm = document.getElementById('alarmEcgOff');
if (elAlarm) elAlarm.style.display = 'none';
} else {
// Flat isoelectric line when leads disconnected
yEcg = (hEcg * 0.54) + (Math.sin(t * 8) * 0.8);
const elAlarm = document.getElementById('alarmEcgOff');
if (elAlarm) elAlarm.style.display = 'block';
}
// 2. SpO2 Plethysmogram Waveform
// ONLY draw pleth waves if real SpO2 is being received from the monitor
const hasSpo2 = machVitals.spo2 != null && machVitals.spo2 > 0;
let ySpo2 = hSpo2 * 0.84;
if (hasSpo2 && hasHr) {
const hr = Math.max(40, Math.min(180, machVitals.heart_rate));
const ecgPeriod = 60 / hr;
const plethPhase = ((t - 0.14) % ecgPeriod) / ecgPeriod;
let plethNorm = 0;
if (plethPhase >= 0.0 && plethPhase < 0.24) {
plethNorm = Math.sin((plethPhase / 0.24) * (Math.PI / 2)); // Systolic upstroke
} else if (plethPhase >= 0.24 && plethPhase < 0.45) {
plethNorm = 1.0 - 0.45 * Math.sin(((plethPhase - 0.24) / 0.21) * (Math.PI / 2));
} else if (plethPhase >= 0.45 && plethPhase < 0.58) {
const notchPhase = (plethPhase - 0.45) / 0.13;
plethNorm = 0.55 + 0.16 * Math.sin(notchPhase * Math.PI); // Dicrotic notch
} else if (plethPhase >= 0.58 && plethPhase < 1.0) {
const decayPhase = (plethPhase - 0.58) / 0.42;
plethNorm = 0.55 * Math.cos(decayPhase * (Math.PI / 2)); // Diastolic decay
}
plethNorm = Math.max(0, plethNorm) + (Math.sin(t * 40) * 0.006);
ySpo2 = (hSpo2 * 0.84) - (plethNorm * (hSpo2 * 0.70));
const elAlarm = document.getElementById('alarmSpo2Off');
if (elAlarm) elAlarm.style.display = 'none';
} else {
// Flat line when sensor disconnected
ySpo2 = (hSpo2 * 0.84) + (Math.sin(t * 6) * 0.6);
const elAlarm = document.getElementById('alarmSpo2Off');
if (elAlarm) elAlarm.style.display = 'block';
}
// 3. Respiration Waveform
const hasRr = machVitals.respiratory_rate != null && machVitals.respiratory_rate > 0;
let yResp = hResp * 0.54;
if (hasRr) {
const rr = Math.max(8, Math.min(36, machVitals.respiratory_rate));
const respPeriod = 60 / rr;
const respPhase = (t % respPeriod) / respPeriod;
let respNorm = (respPhase < 0.38)
? Math.sin((respPhase / 0.38) * (Math.PI / 2))
: Math.cos(((respPhase - 0.38) / 0.62) * (Math.PI / 2));
respNorm += (Math.sin(t * 12) * 0.015);
yResp = (hResp * 0.54) - (respNorm * (hResp * 0.38));
const elAlarm = document.getElementById('alarmRespOff');
if (elAlarm) elAlarm.style.display = 'none';
} else {
// Flat line when respiration lead disconnected
yResp = (hResp * 0.54) + (Math.sin(t * 5) * 0.6);
const elAlarm = document.getElementById('alarmRespOff');
if (elAlarm) elAlarm.style.display = 'block';
}
// Draw sweeps on each canvas
drawMachineBeam(ctxMachEcg, machSweepX, nextX, machPrevY_ecg, yEcg, '#00ff00', 2.2);
drawMachineBeam(ctxMachSpo2, machSweepX, nextX, machPrevY_spo2, ySpo2, '#00ffff', 2.2);
drawMachineBeam(ctxMachResp, machSweepX, nextX, machPrevY_resp, yResp, '#ffff00', 2.0);
machPrevY_ecg = yEcg;
machPrevY_spo2 = ySpo2;
machPrevY_resp = yResp;
if (nextX >= width) {
machSweepX = 0;
machPrevY_ecg = null;
machPrevY_spo2 = null;
machPrevY_resp = null;
} else {
machSweepX = nextX;
}
requestAnimationFrame(stepContecMachineSweep);
}
function drawMachineBeam(ctx, x1, x2, y1, y2, color, lineWidth) {
if (y1 === null || y1 === undefined) y1 = y2;
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.shadowColor = color;
ctx.shadowBlur = 4;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.shadowBlur = 0;
}
function triggerHeartBeatPulse() {
if (machVitals.heart_rate == null || machVitals.heart_rate <= 0) return;
const heart = document.getElementById('machHeartIcon');
if (heart) {
heart.style.transform = 'scale(1.4)';
heart.style.filter = 'drop-shadow(0 0 8px #ef4444)';
setTimeout(() => {
heart.style.transform = 'scale(1.0)';
heart.style.filter = 'none';
}, 140);
}
// Vertical LED pleth bar bounce ONLY if SpO2 is connected
if (machVitals.spo2 != null && machVitals.spo2 > 0) {
const segments = document.querySelectorAll('#pulseBarVertical .v-segment');
const bounceHeight = Math.floor(Math.random() * 3) + 5;
segments.forEach((seg, idx) => {
if (idx < bounceHeight) seg.classList.add('lit');
else seg.classList.remove('lit');
});
setTimeout(() => {
segments.forEach((seg, idx) => {
if (idx < 2) seg.classList.add('lit');
else seg.classList.remove('lit');
});
}, 200);
}
// QRS Tone
if (machAudioEnabled) {
playMachineBeep();
}
}
function toggleQrsAudio() {
machAudioEnabled = !machAudioEnabled;
const btn = document.getElementById('btnMachAudio');
const icon = document.getElementById('machAudioIcon');
const text = document.getElementById('machAudioText');
if (machAudioEnabled) {
btn.classList.add('active');
icon.textContent = '🔊';
text.textContent = 'BEEP ON';
if (!machAudioCtx) {
machAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
} else {
btn.classList.remove('active');
icon.textContent = '🔇';
text.textContent = 'BEEP OFF';
}
}
function toggleMachineFreeze() {
machIsFrozen = !machIsFrozen;
const btn = document.getElementById('btnMachFreeze');
const text = document.getElementById('machFreezeText');
if (machIsFrozen) {
btn.classList.add('active');
text.textContent = 'UNFREEZE';
} else {
btn.classList.remove('active');
text.textContent = 'FREEZE';
}
}
function playMachineBeep() {
try {
if (!machAudioCtx) {
machAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (machAudioCtx.state === 'suspended') {
machAudioCtx.resume();
}
const osc = machAudioCtx.createOscillator();
const gain = machAudioCtx.createGain();
const baseFreq = 880;
const spo2 = machVitals.spo2 || 98;
osc.frequency.value = baseFreq * Math.pow(2, (spo2 - 90) / 30);
gain.gain.setValueAtTime(0.06, machAudioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, machAudioCtx.currentTime + 0.08);
osc.connect(gain);
gain.connect(machAudioCtx.destination);
osc.start();
osc.stop(machAudioCtx.currentTime + 0.08);
} catch(e) {}
}
// === Load Real Machine Telemetry from Database & API ===
async function fetchMachineTelemetry() {
try {
if (hasTargetFilter) {
// Strict Target Patient Mode: NEVER fallback to another patient or transmitting device!
let p = null;
const patResp = await fetch(`${API_BASE}/patients`);
if (patResp.ok) {
const patients = await patResp.json();
if (targetMrn) {
const cleanTm = targetMrn.toLowerCase().trim().replace(/^0+/, '');
p = patients.find(pt => {
const m = String(pt.mrn || pt.patient_id || '').toLowerCase().trim();
return m === targetMrn.toLowerCase().trim() || (cleanTm.length >= 2 && m.replace(/^0+/, '') === cleanTm);
});
} else if (targetName) {
p = patients.find(pt => pt.name && pt.name.toLowerCase().includes(targetName.toLowerCase()));
}
}
if (p) {
machVitals.patient_name = p.name || targetName || null;
machVitals.patient_id = p.mrn || p.patient_id || targetMrn || null;
machVitals.bed_id = p.bed_id || null;
machVitals.device_id = p.device_id || '';
const elName = document.getElementById('machPatientName');
if (elName) elName.textContent = machVitals.patient_name ? machVitals.patient_name.toUpperCase() : '--';
const elMrn = document.getElementById('machMrnPill');
if (elMrn) elMrn.textContent = machVitals.patient_id ? `MRN: ${machVitals.patient_id}` : 'MRN: --';
const elBed = document.getElementById('machBedPill');
if (elBed) elBed.textContent = machVitals.bed_id ? `BED ${machVitals.bed_id}` : 'BED --';
if (p.latest_vitals) {
applyVitalsToMachine(p.latest_vitals);
return;
}
}
// Query latest readings directly for target patient
const targetId = targetMrn || targetName;
if (targetId) {
const readResp = await fetch(`${API_BASE}/latest-readings?mrn=${encodeURIComponent(targetId)}&limit=10`);
if (readResp.ok) {
const readings = await readResp.json();
if (readings && readings.length > 0) {
const merged = {};
const now = Date.now();
for (let i = readings.length - 1; i >= 0; i--) {
const r = readings[i];
const rAge = getTimestampAgeMs(r.timestamp);
if (rAge <= 25000) {
if (r.heart_rate != null) merged.heart_rate = r.heart_rate;
if (r.spo2 != null) merged.spo2 = r.spo2;
if (r.respiratory_rate != null) merged.respiratory_rate = r.respiratory_rate;
if (r.temperature != null) merged.temperature = r.temperature;
if (r.temperature2 != null) merged.temperature2 = r.temperature2;
if (r.temperature_diff != null) merged.temperature_diff = r.temperature_diff;
}
if (rAge <= 900000) {
if (r.systolic_bp != null) merged.systolic_bp = r.systolic_bp;
if (r.diastolic_bp != null) merged.diastolic_bp = r.diastolic_bp;
if (r.map_bp != null) merged.map_bp = r.map_bp;
}
if (r.patient_name) merged.patient_name = r.patient_name;
if (r.mrn || r.patient_id) merged.mrn = r.mrn || r.patient_id;
if (r.bed_id) merged.bed_id = r.bed_id;
}
applyVitalsToMachine(merged);
return;
}
}
}
// If no readings or machine data exists for target patient, strictly clear all vitals to '--' / LEAD OFF / flat line
clearMachineVitals();
return;
}
// General mode (no target filter):
// Determine target patient: active device patient
let targetId = null;
if (activeDeviceId && connectedDevices[activeDeviceId]) {
const activeDev = connectedDevices[activeDeviceId];
targetId = activeDev.patient_id || activeDev.mrn;
}
// 1. Demographics from registered patient list
const patResp = await fetch(`${API_BASE}/patients`);
if (patResp.ok) {
const patients = await patResp.json();
let p = null;
if (activeDeviceId) {
p = patients.find(pt => pt.device_id === activeDeviceId);
}
if (!p && patients.length > 0) {
// Find patient corresponding to a live transmitting device
p = patients.find(pt => pt.device_id && connectedDevices[pt.device_id]?._liveData) ||
patients.find(pt => pt.device_id && pt.device_id === activeDeviceId);
}
if (p) {
machVitals.patient_name = p.name || null;
machVitals.patient_id = p.mrn || p.patient_id || null;
machVitals.bed_id = p.bed_id || null;
machVitals.device_id = p.device_id || activeDeviceId || '';
const elName = document.getElementById('machPatientName');
if (elName) elName.textContent = machVitals.patient_name ? machVitals.patient_name.toUpperCase() : '--';
const elMrn = document.getElementById('machMrnPill');
if (elMrn) elMrn.textContent = machVitals.patient_id ? `MRN: ${machVitals.patient_id}` : 'MRN: --';
const elBed = document.getElementById('machBedPill');
if (elBed) elBed.textContent = machVitals.bed_id ? `BED ${machVitals.bed_id}` : 'BED --';
if (p.latest_vitals) {
applyVitalsToMachine(p.latest_vitals);
return;
}
} else if (activeDeviceId && connectedDevices[activeDeviceId]) {
const dev = connectedDevices[activeDeviceId];
if (dev.patient_name || dev.name) machVitals.patient_name = dev.patient_name || dev.name;
if (dev.mrn || dev.patient_id) machVitals.patient_id = dev.mrn || dev.patient_id;
if (dev.bed_id || dev.bedLabel) machVitals.bed_id = dev.bed_id || dev.bedLabel;
const elName = document.getElementById('machPatientName');
if (elName) elName.textContent = machVitals.patient_name ? machVitals.patient_name.toUpperCase() : '--';
const elMrn = document.getElementById('machMrnPill');
if (elMrn) elMrn.textContent = machVitals.patient_id ? `MRN: ${machVitals.patient_id}` : 'MRN: --';
const elBed = document.getElementById('machBedPill');
if (elBed) elBed.textContent = machVitals.bed_id ? `BED ${machVitals.bed_id}` : 'BED --';
}
}
// 2. Fallback: Fetch latest readings directly from DB (only accept recent readings)
if (targetId) {
const readResp = await fetch(`${API_BASE}/latest-readings?mrn=${encodeURIComponent(targetId)}&limit=10`);
if (readResp.ok) {
const readings = await readResp.json();
if (readings && readings.length > 0) {
const merged = {};
const now = Date.now();
for (let i = readings.length - 1; i >= 0; i--) {
const r = readings[i];
const rAge = getTimestampAgeMs(r.timestamp);
if (rAge <= 25000) {
if (r.heart_rate != null) merged.heart_rate = r.heart_rate;
if (r.spo2 != null) merged.spo2 = r.spo2;
if (r.respiratory_rate != null) merged.respiratory_rate = r.respiratory_rate;
if (r.temperature != null) merged.temperature = r.temperature;
if (r.temperature2 != null) merged.temperature2 = r.temperature2;
if (r.temperature_diff != null) merged.temperature_diff = r.temperature_diff;
}
if (rAge <= 900000) {
if (r.systolic_bp != null) merged.systolic_bp = r.systolic_bp;
if (r.diastolic_bp != null) merged.diastolic_bp = r.diastolic_bp;
if (r.map_bp != null) merged.map_bp = r.map_bp;
}
if (r.patient_name) merged.patient_name = r.patient_name;
if (r.mrn || r.patient_id) merged.mrn = r.mrn || r.patient_id;
if (r.bed_id) merged.bed_id = r.bed_id;
}
applyVitalsToMachine(merged);
}
}
}
} catch(e) {
console.log('Telemetry fetch error:', e);
}
}
function applyVitalsToMachine(data) {
if (!data) return;
// Heart Rate
if ('heart_rate' in data) {
machVitals.heart_rate = (data.heart_rate !== null && data.heart_rate !== undefined && data.heart_rate > 0) ? Math.round(data.heart_rate) : null;
}
// SpO2
if ('spo2' in data) {
machVitals.spo2 = (data.spo2 !== null && data.spo2 !== undefined && data.spo2 > 0) ? Math.round(data.spo2) : null;
}
// Respiration Rate
if ('respiratory_rate' in data) {
machVitals.respiratory_rate = (data.respiratory_rate !== null && data.respiratory_rate !== undefined && data.respiratory_rate > 0) ? Math.round(data.respiratory_rate) : null;
}
// NIBP
if ('systolic_bp' in data) {
machVitals.systolic_bp = (data.systolic_bp !== null && data.systolic_bp !== undefined && data.systolic_bp > 0) ? Math.round(data.systolic_bp) : null;
}
if ('diastolic_bp' in data) {
machVitals.diastolic_bp = (data.diastolic_bp !== null && data.diastolic_bp !== undefined && data.diastolic_bp > 0) ? Math.round(data.diastolic_bp) : null;
}
if ('map_bp' in data) {
machVitals.map_bp = (data.map_bp !== null && data.map_bp !== undefined && data.map_bp > 0) ? Math.round(data.map_bp) : null;
}
// Temperature 1 (T1)
if ('temperature' in data) {
machVitals.temperature = (data.temperature !== null && data.temperature !== undefined && data.temperature > 0) ? Number(data.temperature) : null;
}
// Temperature 2 (T2)
if ('temperature2' in data) {
machVitals.temperature2 = (data.temperature2 !== null && data.temperature2 !== undefined && data.temperature2 > 0) ? Number(data.temperature2) : null;
}
// Temperature Difference (TD)
if ('temperature_diff' in data) {
machVitals.temperature_diff = (data.temperature_diff !== null && data.temperature_diff !== undefined) ? Number(data.temperature_diff) : null;
} else if (machVitals.temperature != null && machVitals.temperature2 != null) {
machVitals.temperature_diff = Math.round(Math.abs(machVitals.temperature - machVitals.temperature2) * 10) / 10;
} else {
machVitals.temperature_diff = null;
}
if (data.patient_name && data.patient_name !== 'Unknown' && data.patient_name !== 'UNKNOWN') machVitals.patient_name = data.patient_name;
if (data.name && data.name !== 'Unknown' && data.name !== 'UNKNOWN') machVitals.patient_name = data.name;
if (data.mrn && data.mrn !== 'UNKNOWN' && data.mrn !== 'UNASSIGNED') machVitals.patient_id = data.mrn;
if (data.patient_id && data.patient_id !== 'UNKNOWN' && data.patient_id !== 'UNASSIGNED') machVitals.patient_id = data.patient_id;
if (data.bed_id) machVitals.bed_id = data.bed_id;
if (data.device_id) machVitals.device_id = data.device_id;
if (data.patient_type) machVitals.patient_type = data.patient_type;
// Ensure top machine pills reflect current patient directly from machine
const elName = document.getElementById('machPatientName');
if (elName) elName.textContent = machVitals.patient_name ? machVitals.patient_name.toUpperCase() : '--';
const elMrn = document.getElementById('machMrnPill');
if (elMrn) elMrn.textContent = machVitals.patient_id ? `MRN: ${machVitals.patient_id}` : 'MRN: --';
const elBed = document.getElementById('machBedPill');
if (elBed) elBed.textContent = machVitals.bed_id ? `BED ${machVitals.bed_id}` : 'BED --';
const elCat = document.getElementById('machCategoryPill');
if (elCat && machVitals.patient_type) elCat.textContent = machVitals.patient_type.toUpperCase();
updateMachineDisplay();
}
function updateMachineDisplay() {
const hasHr = machVitals.heart_rate != null && machVitals.heart_rate > 0;
const hr = hasHr ? Math.round(machVitals.heart_rate) : '--';
const hasSpo2 = machVitals.spo2 != null && machVitals.spo2 > 0;
const spo2 = hasSpo2 ? Math.round(machVitals.spo2) : '--';
const hasRr = machVitals.respiratory_rate != null && machVitals.respiratory_rate > 0;
const rr = hasRr ? Math.round(machVitals.respiratory_rate) : '--';
const hasSys = machVitals.systolic_bp != null && machVitals.systolic_bp > 0;
const sys = hasSys ? Math.round(machVitals.systolic_bp) : '--';
const dia = (hasSys && machVitals.diastolic_bp != null) ? Math.round(machVitals.diastolic_bp) : '--';
const map = machVitals.map_bp != null ? Math.round(machVitals.map_bp) : '--';
// T1
const hasTemp = machVitals.temperature != null && machVitals.temperature > 0;
const temp = hasTemp ? machVitals.temperature.toFixed(1) : '--';
const tempF = hasTemp ? ((machVitals.temperature * 9/5) + 32).toFixed(1) + ' °F' : '--';
// T2
const hasTemp2 = machVitals.temperature2 != null && machVitals.temperature2 > 0;
const temp2 = hasTemp2 ? machVitals.temperature2.toFixed(1) : '--';
const temp2F = hasTemp2 ? ((machVitals.temperature2 * 9/5) + 32).toFixed(1) + ' °F' : '--';
// TD (Difference)
const hasTd = machVitals.temperature_diff != null;
const td = hasTd ? machVitals.temperature_diff.toFixed(1) : '--';
const elHr = document.getElementById('valMachHr');
if (elHr) elHr.textContent = hr;
const elHrStatus = document.getElementById('machHrStatus');
if (elHrStatus) {
elHrStatus.textContent = hasHr ? 'SINUS' : 'LEAD OFF';
elHrStatus.style.color = hasHr ? '#00ff00' : '#ef4444';
}
const elSpo2 = document.getElementById('valMachSpo2');
if (elSpo2) elSpo2.textContent = spo2;
const elPr = document.getElementById('valMachPr');
if (elPr) elPr.textContent = (hasSpo2 && hasHr) ? hr : '--';
const elSpo2Status = document.getElementById('machSpo2Status');
if (elSpo2Status) {
elSpo2Status.textContent = hasSpo2 ? 'STABLE' : 'SENSOR OFF';
elSpo2Status.style.color = hasSpo2 ? '#00ffff' : '#64748b';
}
const elPi = document.getElementById('machPiVal');
if (elPi) elPi.textContent = hasSpo2 ? 'PI: 2.4%' : 'PI: --';
// Vertical pulse bar: unlit if SpO2 sensor off
if (!hasSpo2) {
document.querySelectorAll('#pulseBarVertical .v-segment').forEach(seg => seg.classList.remove('lit'));
}
const elRr = document.getElementById('valMachRr');
if (elRr) elRr.textContent = rr;
const elRespStatus = document.getElementById('machRespStatus');
if (elRespStatus) {
elRespStatus.textContent = hasRr ? 'NORMAL' : 'LEAD OFF';
elRespStatus.style.color = hasRr ? '#ffff00' : '#64748b';
}
const elSys = document.getElementById('valMachSys');
if (elSys) elSys.textContent = sys;
const elDia = document.getElementById('valMachDia');
if (elDia) elDia.textContent = dia;
const elMap = document.getElementById('valMachMap');
if (elMap) elMap.textContent = map;
const elTemp = document.getElementById('valMachTemp');
if (elTemp) elTemp.textContent = temp;
const elTempF = document.getElementById('valMachTempF');
if (elTempF) elTempF.textContent = tempF;
const elTemp2 = document.getElementById('valMachTemp2');
if (elTemp2) elTemp2.textContent = temp2;
const elTemp2F = document.getElementById('valMachTemp2F');
if (elTemp2F) elTemp2F.textContent = temp2F;
const elTd = document.getElementById('valMachTempDiff');
if (elTd) elTd.textContent = td;
// Alarm banners
const elAlarmEcg = document.getElementById('alarmEcgOff');
if (elAlarmEcg) elAlarmEcg.style.display = hasHr ? 'none' : 'block';
const elAlarmSpo2 = document.getElementById('alarmSpo2Off');
if (elAlarmSpo2) elAlarmSpo2.style.display = hasSpo2 ? 'none' : 'block';
const elAlarmResp = document.getElementById('alarmRespOff');
if (elAlarmResp) elAlarmResp.style.display = hasRr ? 'none' : 'block';
// Update clock
const now = new Date();
const timeStr = now.toLocaleTimeString('en-US', { hour12: false });
const elClock = document.getElementById('machClock');
if (elClock) elClock.textContent = timeStr;
}
// === Configuration ===
const API_BASE = window.location.origin + '/api';
const POLL_INTERVAL = 2000; // 2 seconds
const WS_URL = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws/vitals`;
// === Vital ranges for status calculation ===
const VITAL_RANGES = {
heart_rate: { low: 60, high: 100, critLow: 40, critHigh: 150 },
spo2: { low: 95, high: 100, critLow: 90, critHigh: 101 },
systolic_bp: { low: 90, high: 140, critLow: 70, critHigh: 180 },
diastolic_bp: { low: 60, high: 90, critLow: 40, critHigh: 120 },
map_bp: { low: 70, high: 105, critLow: 60, critHigh: 130 },
respiratory_rate: { low: 12, high: 20, critLow: 8, critHigh: 30 },
temperature: { low: 36.1, high: 37.2, critLow: 35.0, critHigh: 39.0 },
};
// === State ===
let lastReadingId = null;
let readingCount = 0;
let waveformData = {
hr: [], spo2: [], bp: [], rr: [], temp: [], map: []
};
let waveformCanvases = {};
let useWebSocket = false;
let ws = null;
let audioAlertEnabled = false;
let audioContext = null;
let setupGuideCollapsed = false;
let hasReceivedData = false;
// === Initialize ===
document.addEventListener('DOMContentLoaded', () => {
initIcuMonitor();
if (hasTargetFilter || isMinimal) {
setViewMode('single');
renderSingleDevice(null);
}
initWaveforms();
updateClock();
setInterval(updateClock, 1000);
setInterval(checkDeviceLiveliness, 2000);
tryWebSocket();
startPolling();
animateWaveforms();
fetchNetworkInfo();
fetchInitialDevicesAndPatients();
});
function checkDeviceLiveliness() {
const now = Date.now();
let changed = false;
Object.keys(connectedDevices).forEach(id => {
const dev = connectedDevices[id];
const wasLive = dev._wasLive;
const isLive = dev.lastSeen && (now - dev.lastSeen.getTime()) < 15000;
if (wasLive !== isLive) {
dev._wasLive = isLive;
changed = true;
}
});
if (changed) {
if (viewMode === 'grid') renderMultiBedGrid();
else if (activeDeviceId && connectedDevices[activeDeviceId]) {
renderSingleDevice(connectedDevices[activeDeviceId]);
}
}
}
// === Fetch Network Info ===
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.join(' / ');
} else {
ipEl.textContent = 'Run ipconfig to find your IP';
}
// Update model badge
if (info.monitor_model) {
document.getElementById('modelBadge').textContent = info.monitor_model;
}
}
} catch (e) {
document.getElementById('detectedIp').textContent = 'Check ipconfig';
}
}
// === Setup Guide Toggle ===
function toggleSetupGuide() {
const body = document.getElementById('setupGuideBody');
const toggleText = document.getElementById('setupToggleText');
setupGuideCollapsed = !setupGuideCollapsed;
if (setupGuideCollapsed) {
body.style.display = 'none';
toggleText.textContent = '▸ Expand';
} else {
body.style.display = 'grid';
toggleText.textContent = '▾ Collapse';
}
}
// === Audio Alert Toggle ===
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';
// Initialize audio context on user gesture
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
} else {
toggle.classList.remove('active');
icon.textContent = '🔇';
text.textContent = 'Alerts Off';
}
}
function playAlertBeep() {
if (!audioAlertEnabled || !audioContext) return;
try {
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(880, audioContext.currentTime);
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.5);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.5);
// Second beep
const osc2 = audioContext.createOscillator();
const gain2 = audioContext.createGain();
osc2.connect(gain2);
gain2.connect(audioContext.destination);
osc2.type = 'sine';
osc2.frequency.setValueAtTime(1100, audioContext.currentTime + 0.6);
gain2.gain.setValueAtTime(0.3, audioContext.currentTime + 0.6);
gain2.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 1.1);
osc2.start(audioContext.currentTime + 0.6);
osc2.stop(audioContext.currentTime + 1.1);
} catch (e) {
// Audio not supported
}
}
// === Clock ===
function updateClock() {
const now = new Date();
document.getElementById('timeDisplay').textContent =
now.toLocaleTimeString('en-US', { hour12: false });
}
// === WebSocket Connection ===
function tryWebSocket() {
try {
ws = new WebSocket(WS_URL);
ws.onopen = () => {
useWebSocket = true;
updateConnectionStatus('connected', 'Live — WebSocket');
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
updateVitals(data);
};
ws.onclose = () => {
useWebSocket = false;
updateConnectionStatus('polling', 'Polling Mode');
setTimeout(tryWebSocket, 5000);
};
ws.onerror = () => {
useWebSocket = false;
};
} catch (e) {
useWebSocket = false;
}
}
// === Polling Fallback ===
function startPolling() {
fetchLatestReadings();
setInterval(fetchLatestReadings, POLL_INTERVAL);
}
async function fetchLatestReadings() {
try {
// Always keep system status updated
const statusResp = await fetch(`${API_BASE}/status`);
if (statusResp.ok) {
const status = await statusResp.json();
const elTotal = document.getElementById('totalReadings');
if (elTotal) elTotal.textContent = status.total_readings_stored;
const elApi = document.getElementById('apiStatus');
if (elApi) elApi.textContent = status.pending_transmissions > 0
? `${status.pending_transmissions} pending`
: 'All sent ✓';
}
// If WebSocket is connected, it delivers sub-second live machine telemetry directly.
// Do NOT overwrite live WebSocket data with older throttled DB snapshots.
if (useWebSocket) {
return;
}
const params = new URLSearchParams();
params.set('limit', '10');
if (targetMrn) params.set('mrn', targetMrn);
if (targetName) params.set('name', targetName);
if (targetDob) params.set('dob', targetDob);
const endpoint = `${API_BASE}/latest-readings?${params.toString()}`;
const response = await fetch(endpoint);
if (!response.ok) throw new Error('API error');
const readings = await response.json();
if (readings.length > 0) {
const now = Date.now();
readings.forEach(r => {
const rAge = getTimestampAgeMs(r.timestamp);
if (rAge > 25000) {
r.heart_rate = null;
r.spo2 = null;
r.respiratory_rate = null;
r.temperature = null;
r.temperature2 = null;
r.temperature_diff = null;
}
if (rAge > 900000) {
r.systolic_bp = null;
r.diastolic_bp = null;
r.map_bp = null;
}
if (r.id !== lastReadingId) {
lastReadingId = r.id;
readingCount++;
}
updateVitals(r);
});
updateConnectionStatus('connected', 'Connected — Polling');
} else {
updateConnectionStatus('waiting', hasTargetFilter ? `Waiting for Patient Data...` : 'Waiting for data...');
}
} catch (e) {
if (!useWebSocket) {
updateConnectionStatus('offline', 'Server offline');
}
}
}
// === Multi-Device, Multi-Bed & Patient Registry State ===
const connectedDevices = {}; // { deviceId: { ...vitals, lastSeen: Date, bedLabel: String, patient_id, patient_name, patient_gender, patient_dob, bed_id } }
const patientRegistry = {}; // { mrn: { patient_id, mrn, name, gender, dob, bed_id, device_id, latest_vitals } }
let activeDeviceId = null;
let viewMode = 'grid'; // 'grid' (All Beds) or 'single' (Detailed)
function getBedLabel(deviceId, ip) {
if (deviceId && deviceId.includes('192.168.100.106')) {
return 'Bed 1 (Local Wi-Fi)';
}
if (deviceId && (deviceId.includes('127.0.0.1') || deviceId.includes('152.57.') || deviceId.includes('remote') || (ip && !ip.startsWith('192.168.100.')))) {
return 'Bed 2 (Remote Hotspot)';
}
if (deviceId && deviceId.includes('192.168.100.105')) {
return 'Bed 3 (Local Wi-Fi 2)';
}
const keys = Object.keys(connectedDevices);
const index = keys.indexOf(deviceId);
return `Bed ${index >= 0 ? index + 1 : keys.length + 1}`;
}
async function fetchInitialDevicesAndPatients() {
try {
// 1. Load all registered devices from database
const devResp = await fetch(`${API_BASE}/devices`);
if (devResp.ok) {
const devices = await devResp.json();
devices.forEach(d => {
if (d.device_id) {
connectedDevices[d.device_id] = {
...(connectedDevices[d.device_id] || {}),
device_id: d.device_id,
ip_address: d.ip_address,
bedLabel: getBedLabel(d.device_id, d.ip_address),
lastSeen: d.last_seen ? new Date(d.last_seen) : null,
status: d.status || 'active',
_fromDb: true, // loaded from DB, not yet seen via live WebSocket
_liveData: false // becomes true only when WebSocket packet arrives
};
}
});
}
// 2. Load latest readings to backfill vitals for each device
const backfillParams = new URLSearchParams();
backfillParams.set('limit', '50');
if (targetMrn) backfillParams.set('mrn', targetMrn);
if (targetName) backfillParams.set('name', targetName);
if (targetDob) backfillParams.set('dob', targetDob);
const backfillUrl = hasTargetFilter
? `${API_BASE}/latest-readings?${backfillParams.toString()}`
: `${API_BASE}/latest-readings?limit=100`;
const readResp = await fetch(backfillUrl);
if (readResp.ok) {
const readings = await readResp.json();
const now = Date.now();
readings.forEach(r => {
const devKey = r.device_id;
if (devKey && connectedDevices[devKey]) {
const dev = connectedDevices[devKey];
const rAge = getTimestampAgeMs(r.timestamp);
if (rAge <= 25000) {
if ((dev.heart_rate === undefined || dev.heart_rate === null) && r.heart_rate != null) dev.heart_rate = r.heart_rate;
if ((dev.spo2 === undefined || dev.spo2 === null) && r.spo2 != null) dev.spo2 = r.spo2;
if ((dev.respiratory_rate === undefined || dev.respiratory_rate === null) && r.respiratory_rate != null) dev.respiratory_rate = r.respiratory_rate;
if ((dev.temperature === undefined || dev.temperature === null) && r.temperature != null) dev.temperature = r.temperature;
}
if (rAge <= 900000) {
if ((dev.systolic_bp === undefined || dev.systolic_bp === null) && r.systolic_bp != null) dev.systolic_bp = r.systolic_bp;
if ((dev.diastolic_bp === undefined || dev.diastolic_bp === null) && r.diastolic_bp != null) dev.diastolic_bp = r.diastolic_bp;
if ((dev.map_bp === undefined || dev.map_bp === null) && r.map_bp != null) dev.map_bp = r.map_bp;
}
if (!dev.lastSeen && r.timestamp) dev.lastSeen = new Date(r.timestamp);
}
});
}
// 3. Load assigned patients
const patResp = await fetch(`${API_BASE}/patients`);
if (patResp.ok) {
const patients = await patResp.json();
patients.forEach(p => {
const mrn = p.mrn || p.patient_id;
if (mrn && mrn !== 'UNKNOWN') {
patientRegistry[mrn] = p;
}
if (p.device_id && connectedDevices[p.device_id]) {
Object.assign(connectedDevices[p.device_id], {
patient_id: mrn,
mrn: mrn,
patient_name: p.name,
name: p.name,
patient_gender: p.gender,
gender: p.gender,
patient_dob: p.dob,
dob: p.dob,
bed_id: p.bed_id,
bedLabel: p.bed_id || connectedDevices[p.device_id].bedLabel
});
}
});
}
updateDeviceTabs();
if (hasTargetFilter) {
viewMode = 'single';
const matchingDevId = Object.keys(connectedDevices).find(id => matchesTargetPatient(connectedDevices[id], id));
if (matchingDevId) {
activeDeviceId = matchingDevId;
setViewMode('single');
renderSingleDevice(connectedDevices[matchingDevId]);
} else {
activeDeviceId = null;
setViewMode('single');
renderSingleDevice(null);
}
} else if (isMinimal) {
viewMode = 'single';
const devIds = Object.keys(connectedDevices);
if (devIds.length > 0) activeDeviceId = devIds[0];
setViewMode('single');
if (activeDeviceId && connectedDevices[activeDeviceId]) {
renderSingleDevice(connectedDevices[activeDeviceId]);
}
} else {
if (viewMode === 'grid') renderMultiBedGrid();
else if (activeDeviceId && connectedDevices[activeDeviceId]) renderSingleDevice(connectedDevices[activeDeviceId]);
}
} catch (e) {
console.error('Failed to load initial devices/patients:', e);
}
}
function onPatientSearchChange() {
if (viewMode === 'grid') {
renderMultiBedGrid();
}
}
function setViewMode(mode) {
viewMode = mode;
const singleWrap = document.getElementById('singleViewWrapper');
const multiGrid = document.getElementById('multiBedGrid');
const btnSingle = document.getElementById('btnSingleView');
const btnGrid = document.getElementById('btnGridView');
if (mode === 'grid') {
singleWrap.style.display = 'none';
multiGrid.style.display = 'grid';
btnSingle.classList.remove('active');
btnGrid.classList.add('active');
renderMultiBedGrid();
} else {
singleWrap.style.display = 'flex';
multiGrid.style.display = 'none';
btnSingle.classList.add('active');
btnGrid.classList.remove('active');
if (activeDeviceId && connectedDevices[activeDeviceId]) {
renderSingleDevice(connectedDevices[activeDeviceId]);
}
}
updateDeviceTabs();
}
function selectDevice(devId) {
activeDeviceId = devId;
setViewMode('single');
if (devId && connectedDevices[devId]) {
const dev = connectedDevices[devId];
machVitals.patient_name = (dev.patient_name && dev.patient_name !== 'Unknown' && dev.patient_name !== 'UNKNOWN') ? dev.patient_name : (dev.name && dev.name !== 'Unknown' && dev.name !== 'UNKNOWN' ? dev.name : null);
machVitals.patient_id = (dev.patient_id && dev.patient_id !== 'UNKNOWN' && dev.patient_id !== 'UNASSIGNED') ? dev.patient_id : ((dev.mrn && dev.mrn !== 'UNKNOWN' && dev.mrn !== 'UNASSIGNED') ? dev.mrn : null);
machVitals.bed_id = dev.bed_id || dev.bedLabel || null;
machVitals.device_id = devId;
const elName = document.getElementById('machPatientName');
if (elName) elName.textContent = machVitals.patient_name ? machVitals.patient_name.toUpperCase() : '--';
const elMrn = document.getElementById('machMrnPill');
if (elMrn) elMrn.textContent = machVitals.patient_id ? `MRN: ${machVitals.patient_id}` : 'MRN: --';
const elBed = document.getElementById('machBedPill');
if (elBed) elBed.textContent = machVitals.bed_id ? `BED ${machVitals.bed_id}` : 'BED --';
applyVitalsToMachine(dev);
}
}
function updateDeviceTabs() {
const tabsContainer = document.getElementById('deviceTabs');
if (!tabsContainer) return;
// Only show devices that have received a live WebSocket packet
let devIds = Object.keys(connectedDevices).filter(id => connectedDevices[id]._liveData === true);
if (hasTargetFilter) {
devIds = devIds.filter(id => matchesTargetPatient(connectedDevices[id], id));
}
let html = `
<button class="device-tab-btn ${viewMode === 'grid' ? 'active' : ''}" onclick="setViewMode('grid')">
<span>🏥 ${hasTargetFilter ? 'Target Monitor' : 'All Beds Overview'} (${devIds.length})</span>
</button>
`;
devIds.forEach((id) => {
const dev = connectedDevices[id];
const label = dev.bedLabel || dev.bed_id || getBedLabel(id, dev.ip_address);
const hasAssigned = dev.patient_name && dev.patient_name !== 'Unknown' && dev.patient_name !== 'UNKNOWN' && dev.patient_name !== 'Unassigned Patient';
const pName = hasAssigned ? dev.patient_name : '';
const isActive = (viewMode === 'single' && activeDeviceId === id);
html += `
<button class="device-tab-btn ${isActive ? 'active' : ''}" onclick="selectDevice('${id}')" title="${label} (${id})">
<div class="device-tab-dot"></div>
<span>${label}${pName ? ': ' + pName : ''}</span>
</button>
`;
});
tabsContainer.innerHTML = html;
}
function renderMultiBedGrid() {
const multiGrid = document.getElementById('multiBedGrid');
// Only show devices confirmed live via WebSocket — exclude DB-only ghost devices
let devIds = Object.keys(connectedDevices).filter(id => connectedDevices[id]._liveData === true);
if (hasTargetFilter) {
devIds = devIds.filter(id => matchesTargetPatient(connectedDevices[id], id));
}
const searchInput = document.getElementById('patientSearchInput');
const query = searchInput ? searchInput.value.trim().toLowerCase() : '';
const genderSelect = document.getElementById('genderFilterSelect');
const genderFilter = genderSelect ? genderSelect.value : '';
if (devIds.length === 0) {
const msg = hasTargetFilter
? `Waiting for Contec Monitor data for: <span style="color: var(--spo2-color);">${getTargetPatientBadge()}</span>`
: `Waiting for patient monitors to connect...`;
const sub = hasTargetFilter
? `Showing only monitors matching target demographics. Ensure monitor is connected.`
: `Local Wi-Fi monitors connect to <code>192.168.100.112:511</code> | Remote hotspot monitors connect to <code>159.223.110.159:61501</code>`;
multiGrid.innerHTML = `
<div class="bed-card" style="grid-column: 1 / -1; text-align: center; padding: 40px;">
<div style="font-size: 32px; margin-bottom: 12px;">⏳</div>
<div style="font-size: 16px; font-weight: 600; color: var(--text-primary);">${msg}</div>
<div style="font-size: 13px; color: var(--text-secondary); margin-top: 6px;">${sub}</div>
</div>
`;
return;
}
// Filter devices by demographics query & gender
const filteredIds = devIds.filter(id => {
const dev = connectedDevices[id];
if (hasTargetFilter && !matchesTargetPatient(dev, id)) return false;
const pName = (dev.patient_name || dev.name || '').toLowerCase();
const pMrn = (dev.patient_id || dev.mrn || '').toLowerCase();
const pDob = (dev.patient_dob || dev.dob || '').toLowerCase();
const pGender = (dev.patient_gender || dev.gender || '').toLowerCase();
const pBed = (dev.bed_id || dev.bedLabel || '').toLowerCase();
const devIdStr = id.toLowerCase();
if (genderFilter) {
if (!pGender.includes(genderFilter.toLowerCase())) return false;
}
if (query) {
const match = pName.includes(query) || pMrn.includes(query) ||
pDob.includes(query) || pGender.includes(query) ||
pBed.includes(query) || devIdStr.includes(query);
if (!match) return false;
}
return true;
});
if (filteredIds.length === 0) {
const msg = hasTargetFilter
? `No monitors match Patient: <span style="color: var(--spo2-color);">${getTargetPatientBadge()}</span>`
: `No monitors match your search criteria`;
const sub = hasTargetFilter
? `Filtered for: ${getTargetPatientBadge()}. Waiting for transmission.`
: `Filter: "${query || genderFilter}" — Try clearing the search bar or changing filters.`;
multiGrid.innerHTML = `
<div class="bed-card" style="grid-column: 1 / -1; text-align: center; padding: 40px;">
<div style="font-size: 32px; margin-bottom: 12px;">🔍</div>
<div style="font-size: 16px; font-weight: 600; color: var(--text-primary);">${msg}</div>
<div style="font-size: 13px; color: var(--text-secondary); margin-top: 6px;">${sub}</div>
${!hasTargetFilter ? `
<div style="margin-top: 14px;">
<button class="btn-secondary" onclick="document.getElementById('patientSearchInput').value=''; document.getElementById('genderFilterSelect').value=''; renderMultiBedGrid();">
Clear Filter
</button>
</div>` : ''}
</div>
`;
return;
}
let html = '';
filteredIds.forEach(id => {
const dev = connectedDevices[id];
const label = dev.bedLabel || dev.bed_id || getBedLabel(id, dev.ip_address);
const hasAssigned = dev.patient_name && dev.patient_name !== 'Unknown' && dev.patient_name !== 'UNKNOWN' && dev.patient_name !== 'Unassigned Patient';
const pName = hasAssigned ? dev.patient_name : label;
const pMrn = (dev.patient_id && dev.patient_id !== 'UNKNOWN' && dev.patient_id !== 'UNASSIGNED') ? dev.patient_id : null;
const pGender = (dev.patient_gender && dev.patient_gender !== 'Unknown') ? dev.patient_gender : null;
const pDob = (dev.patient_dob && dev.patient_dob !== 'Unknown') ? dev.patient_dob : null;
// A device is "live" only if it has received a live WebSocket packet within 8 seconds
// Devices loaded only from DB (never seen via WebSocket) are shown as DISCONNECTED
const isLive = dev._liveData === true && dev.lastSeen && (Date.now() - dev.lastSeen.getTime()) < 8000;
// Has active vitals (probe connected and sending data)
const hasVitals = isLive && (dev.heart_rate != null || dev.spo2 != null || dev.temperature != null);
const hr = hasVitals && dev.heart_rate != null ? Math.round(dev.heart_rate) : '---';
const spo2 = hasVitals && dev.spo2 != null ? Math.round(dev.spo2) : '---';
const sys = hasVitals && dev.systolic_bp != null ? Math.round(dev.systolic_bp) : '--';
const dia = hasVitals && dev.diastolic_bp != null ? Math.round(dev.diastolic_bp) : '--';
const bp = (sys !== '--' || dia !== '--') ? `${sys}/${dia}` : '---';
const temp = hasVitals && dev.temperature != null ? dev.temperature.toFixed(1) + '°C' : '---';
const rr = hasVitals && dev.respiratory_rate != null ? Math.round(dev.respiratory_rate) : '---';
const timeStr = dev.lastSeen ? dev.lastSeen.toLocaleTimeString('en-US', { hour12: false }) : '--';
const genderIcon = pGender ? (pGender.toLowerCase().startsWith('m') ? '♂ Male' : pGender.toLowerCase().startsWith('f') ? '♀ Female' : pGender) : '';
// Badge: LIVE (data flowing) | CONNECTED (monitor on, probe off) | DISCONNECTED
const statusBadge = isLive
? (hasVitals
? `<span class="status-badge normal">Live ↗</span>`
: `<span class="status-badge warning" style="background:rgba(234,179,8,.15);color:#fbbf24;border:1px solid rgba(234,179,8,.3);">Connected</span>`)
: `<span class="status-badge offline" style="background:rgba(239,68,68,.15);color:#f87171;border:1px solid rgba(239,68,68,.3);">Disconnected</span>`;
html += `
<div class="bed-card" onclick="selectDevice('${id}')" title="Click to view detailed waveforms for ${label}">
<div class="bed-card-header">
<div>
<div class="patient-name-display">${pName}</div>
<div class="bed-card-sub" style="display: flex; gap: 6px; align-items: center; margin-top: 5px; flex-wrap: wrap;">
${pMrn ? `<span class="patient-mrn-badge">MRN: ${pMrn}</span>` : `<span style="color: var(--text-dim); font-size: 11px;">No MRN assigned</span>`}
${pGender ? `<span class="patient-gender-badge">${genderIcon}</span>` : ''}
${pDob ? `<span class="patient-dob-badge">🎂 ${pDob}</span>` : ''}
</div>
<div style="font-size: 11px; color: var(--text-dim); margin-top: 4px;">
<span>🏥 ${label}</span> • <span style="font-family: monospace;">${id}</span>
</div>
</div>
${statusBadge}
</div>
<div class="bed-metrics">
<div class="bed-metric">
<div class="bed-metric-label" style="color: var(--hr-color);">❤️ HR</div>
<div class="bed-metric-val" style="color: var(--hr-color);">${hr}</div>
</div>
<div class="bed-metric">
<div class="bed-metric-label" style="color: var(--spo2-color);">🫁 SpO₂</div>
<div class="bed-metric-val" style="color: var(--spo2-color);">${spo2}${spo2 !== '---' ? '%' : ''}</div>
</div>
<div class="bed-metric">
<div class="bed-metric-label" style="color: var(--bp-color);">🩺 NIBP</div>
<div class="bed-metric-val" style="color: var(--bp-color); font-size: 18px;">${bp}</div>
</div>
<div class="bed-metric">
<div class="bed-metric-label" style="color: var(--temp-color);">🌡️ Temp</div>
<div class="bed-metric-val" style="color: var(--temp-color); font-size: 18px;">${temp}</div>
</div>
<div class="bed-metric">
<div class="bed-metric-label" style="color: var(--rr-color);">🫁 RR</div>
<div class="bed-metric-val" style="color: var(--rr-color);">${rr}</div>
</div>
<div class="bed-metric">
<div class="bed-metric-label" style="color: var(--map-color);">🩸 MAP</div>
<div class="bed-metric-val" style="color: var(--map-color);">${dev.map_bp ? Math.round(dev.map_bp) : '---'}</div>
</div>
</div>
<div class="bed-card-footer">
<span>Updated: <strong>${timeStr}</strong></span>
<div style="display: flex; gap: 6px;">
<button class="card-action-btn" onclick="event.stopPropagation(); openAssignPatientModal('${id}')">
<span>✏️ ${hasAssigned ? 'Edit' : 'Assign'}</span>
</button>
<button class="card-action-btn" onclick="event.stopPropagation(); openPatientHistoryModal('${pMrn || id}')">
<span>📋 History</span>
</button>
</div>
</div>
</div>
`;
});
multiGrid.innerHTML = html;
}
// === Update Vitals Display ===
function updateVitals(data) {
if (!data) return;
// Handle special event broadcasts like patient assignment
if (data.event === 'patient_assigned' && data.device_id) {
const devId = data.device_id;
connectedDevices[devId] = {
...(connectedDevices[devId] || {}),
device_id: devId,
patient_id: data.mrn || data.patient_id,
mrn: data.mrn || data.patient_id,
patient_name: data.patient_name || data.name,
name: data.patient_name || data.name,
patient_gender: data.patient_gender || data.gender,
gender: data.patient_gender || data.gender,
patient_dob: data.patient_dob || data.dob,
dob: data.patient_dob || data.dob,
bed_id: data.bed_id,
bedLabel: data.bed_id || connectedDevices[devId]?.bedLabel
};
if (activeDeviceId === devId || !activeDeviceId) {
machVitals.patient_name = (data.patient_name && data.patient_name !== 'Unknown' && data.patient_name !== 'UNKNOWN') ? data.patient_name : (data.name && data.name !== 'Unknown' && data.name !== 'UNKNOWN' ? data.name : null);
machVitals.patient_id = (data.mrn && data.mrn !== 'UNKNOWN' && data.mrn !== 'UNASSIGNED') ? data.mrn : ((data.patient_id && data.patient_id !== 'UNKNOWN' && data.patient_id !== 'UNASSIGNED') ? data.patient_id : null);
machVitals.bed_id = data.bed_id || null;
machVitals.device_id = devId;
if (data.patient_type) machVitals.patient_type = data.patient_type;
const elName = document.getElementById('machPatientName');
if (elName) elName.textContent = machVitals.patient_name ? machVitals.patient_name.toUpperCase() : '--';
const elMrn = document.getElementById('machMrnPill');
if (elMrn) elMrn.textContent = machVitals.patient_id ? `MRN: ${machVitals.patient_id}` : 'MRN: --';
const elBed = document.getElementById('machBedPill');
if (elBed) elBed.textContent = machVitals.bed_id ? `BED ${machVitals.bed_id}` : 'BED --';
const elCat = document.getElementById('machCategoryPill');
if (elCat && machVitals.patient_type) elCat.textContent = machVitals.patient_type.toUpperCase();
}
updateDeviceTabs();
if (hasTargetFilter) {
if (matchesTargetPatient(connectedDevices[devId], devId)) {
activeDeviceId = devId;
renderSingleDevice(connectedDevices[devId]);
}
} else {
if (viewMode === 'grid') renderMultiBedGrid();
else if (viewMode === 'single' && activeDeviceId === devId) {
renderSingleDevice(connectedDevices[devId]);
}
}
return;
}
if (!data.device_id) return;
// Hide setup guide once data starts flowing
if (!hasReceivedData) {
hasReceivedData = true;
const guide = document.getElementById('setupGuide');
if (guide) guide.classList.add('hidden');
}
const devId = data.device_id;
const existing = connectedDevices[devId] || {};
if (!existing._vitalTimestamps) existing._vitalTimestamps = {};
const retainVital = (field, maxAgeMs) => {
const newVal = data[field];
if (newVal !== null && newVal !== undefined) {
existing._vitalTimestamps[field] = Date.now();
return newVal;
}
const lastTime = existing._vitalTimestamps[field];
if (existing[field] !== null && existing[field] !== undefined && lastTime && (Date.now() - lastTime < maxAgeMs)) {
return existing[field];
}
return null;
};
connectedDevices[devId] = {
...existing,
...data,
heart_rate: retainVital('heart_rate', 15000),
spo2: retainVital('spo2', 15000),
temperature: retainVital('temperature', 20000),
respiratory_rate: retainVital('respiratory_rate', 15000),
systolic_bp: retainVital('systolic_bp', 900000),
diastolic_bp: retainVital('diastolic_bp', 900000),
map_bp: retainVital('map_bp', 900000),
patient_id: (data.mrn && data.mrn !== 'UNKNOWN') ? data.mrn : (existing.patient_id || 'UNKNOWN'),
mrn: (data.mrn && data.mrn !== 'UNKNOWN') ? data.mrn : (existing.mrn || 'UNKNOWN'),
patient_name: (data.patient_name && data.patient_name !== 'Unknown') ? data.patient_name : (existing.patient_name || null),
patient_gender: data.patient_gender || existing.patient_gender || null,
patient_dob: data.patient_dob || existing.patient_dob || null,
bed_id: data.bed_id || existing.bed_id || null,
lastSeen: new Date(),
bedLabel: data.bed_id || existing.bed_id || existing.bedLabel || getBedLabel(devId, data.ip_address),
_fromDb: false, // confirmed live via WebSocket
_liveData: true // this device is actively transmitting
};
// Real-time sync with Contec Machine Display (Strictly live telemetry, zero fake values)
const isTargetMatch = !hasTargetFilter || matchesTargetPatient(connectedDevices[devId], devId);
if (devId && isTargetMatch) {
if (!activeDeviceId) activeDeviceId = devId;
applyVitalsToMachine(connectedDevices[devId]);
}
if (isTargetMatch) {
if (!activeDeviceId || hasTargetFilter) {
activeDeviceId = devId;
}
}
updateDeviceTabs();
if (isTargetMatch && hasTargetFilter) {
// Target patient matched — force single view and render immediately
if (viewMode !== 'single') setViewMode('single');
renderSingleDevice(connectedDevices[devId]);
} else if (hasTargetFilter && !isTargetMatch) {
// Incoming data is for ANOTHER patient/device - do NOT show it on target patient view!
if (viewMode === 'single') {
renderSingleDevice(null);
}
} else if (viewMode === 'grid') {
renderMultiBedGrid();
} else if (viewMode === 'single') {
if (activeDeviceId === devId) {
renderSingleDevice(connectedDevices[devId]);
}
}
}
function renderSingleDevice(dev) {
const waitingState = document.getElementById('singleTargetWaitingState');
const patientBanner = document.getElementById('singlePatientBanner');
const vitalsGrid = document.getElementById('singleVitalsGrid');
const icuMon = document.getElementById('icuPatientMonitor');
// Always ensure the ICU Live Monitor is visible in single view
if (icuMon) icuMon.style.display = 'flex';
let data = dev;
if (hasTargetFilter) {
if (data && !matchesTargetPatient(data, data.device_id)) {
data = null;
}
if (!data) {
const matchDev = Object.values(connectedDevices).find(d => matchesTargetPatient(d, d.device_id));
if (matchDev) {
data = matchDev;
}
}
}
if (hasTargetFilter && !data) {
// Target patient has NO connected machine or matching readings
clearMachineVitals();
// Clear standard vital cards
updateVitalCard('hr', null, 'heart_rate');
updateVitalCard('spo2', null, 'spo2');
updateVitalCard('bp', null, 'systolic_bp');
updateVitalCard('rr', null, 'respiratory_rate');
updateVitalCard('temp', null, 'temperature');
updateVitalCard('map', null, 'map_bp');
if (!isMinimal && waitingState) {
waitingState.style.display = 'flex';
const badge = document.getElementById('targetWaitingMrnBadge');
if (badge) badge.innerHTML = getTargetPatientBadge();
}
if (!isMinimal) {
if (patientBanner) patientBanner.style.display = 'none';
if (vitalsGrid) vitalsGrid.style.display = 'none';
}
return;
}
if (waitingState) waitingState.style.display = 'none';
if (!isMinimal) {
if (patientBanner) patientBanner.style.display = 'flex';
if (vitalsGrid) vitalsGrid.style.display = 'grid';
}
if (!data) return;
// Sync with Contec Machine Screen
applyVitalsToMachine(data);
// 1. Update Patient Banner
const hasAssigned = data.patient_name && data.patient_name !== 'Unknown' && data.patient_name !== 'UNKNOWN' && data.patient_name !== 'Unassigned Patient';
const label = data.bedLabel || data.bed_id || getBedLabel(data.device_id, data.ip_address);
const pName = hasAssigned ? data.patient_name : `${label} (No Patient Assigned)`;
const pMrn = (data.patient_id && data.patient_id !== 'UNKNOWN' && data.patient_id !== 'UNASSIGNED') ? data.patient_id : '---';
const pGender = (data.patient_gender && data.patient_gender !== 'Unknown') ? data.patient_gender : '---';
const pDob = (data.patient_dob && data.patient_dob !== 'Unknown') ? data.patient_dob : '---';
const pBed = data.bed_id || label;
const bannerEl = document.getElementById('singlePatientBanner');
if (bannerEl) {
const verifiedTag = '<span style="display:inline-flex;align-items:center;gap:3px;padding:2px 6px;background:rgba(16,185,129,0.15);border:1px solid rgba(16,185,129,0.35);border-radius:4px;color:#10b981;font-size:10px;font-weight:700;margin-left:6px;">✓ Verified</span>';
document.getElementById('bannerPatientName').innerHTML = `${pName} ${targetName ? verifiedTag : ''}`;
document.getElementById('bannerMrn').innerHTML = `MRN: ${pMrn} ${targetMrn ? verifiedTag : ''}`;
document.getElementById('bannerGender').innerHTML = `Gender: ${pGender} ${targetGender ? verifiedTag : ''}`;
document.getElementById('bannerDob').innerHTML = `DOB: ${pDob} ${targetDob ? verifiedTag : ''}`;
document.getElementById('bannerBed').textContent = `Location: ${pBed}`;
}
// 2. Update Vital Cards
updateVitalCard('hr', data.heart_rate, 'heart_rate', v => `${Math.round(v)}<span class="unit">bpm</span>`);
updateVitalCard('spo2', data.spo2, 'spo2', v => `${Math.round(v)}<span class="unit">%</span>`);
// Blood pressure - combined display
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) : '--';
updateVitalCard('bp', data.systolic_bp, 'systolic_bp',
() => `${sys}<span class="slash">/</span>${dia}<span class="unit">mmHg</span>`);
} else {
updateVitalCard('bp', null, 'systolic_bp');
}
updateVitalCard('rr', data.respiratory_rate, 'respiratory_rate', v => `${Math.round(v)}<span class="unit">/min</span>`);
updateVitalCard('temp', data.temperature, 'temperature', v => `${v.toFixed(1)}<span class="unit">°C</span>`);
updateVitalCard('map', data.map_bp, 'map_bp', v => `${Math.round(v)}<span class="unit">mmHg</span>`);
// Update info bar
if (data.device_id) document.getElementById('deviceId').textContent = `${label} (${data.device_id})`;
document.getElementById('patientId').textContent = hasAssigned ? `${pName} (${pMrn})` : 'Not Assigned';
const now = new Date();
document.getElementById('lastUpdateTime').textContent =
now.toLocaleTimeString('en-US', { hour12: false });
// Push waveform data
pushWaveformPoint('hr', data.heart_rate);
pushWaveformPoint('spo2', data.spo2);
pushWaveformPoint('bp', data.systolic_bp);
pushWaveformPoint('rr', data.respiratory_rate);
pushWaveformPoint('temp', data.temperature);
pushWaveformPoint('map', data.map_bp);
}
// === Modal 1: Assign / Edit Patient Logic ===
async function openAssignPatientModal(deviceId) {
const modal = document.getElementById('modalAssignPatient');
const select = document.getElementById('assignDeviceSelect');
select.innerHTML = '';
// Fetch live devices from API or connected state
let devIds = Object.keys(connectedDevices);
try {
const resp = await fetch(`${API_BASE}/devices`);
if (resp.ok) {
const dbDevs = await resp.json();
dbDevs.forEach(d => {
if (!devIds.includes(d.device_id)) devIds.push(d.device_id);
});
}
} catch (e) {}
devIds.forEach(id => {
const dev = connectedDevices[id] || {};
const opt = document.createElement('option');
opt.value = id;
opt.textContent = `${dev.bedLabel || id} — ${id}`;
select.appendChild(opt);
});
const targetId = deviceId || (devIds.length > 0 ? devIds[0] : select.value);
if (targetId) select.value = targetId;
// Pre-fill existing demographics if known
const existing = connectedDevices[targetId];
if (existing && existing.patient_name && existing.patient_name !== 'Unknown') {
document.getElementById('assignMrn').value = (existing.patient_id && existing.patient_id !== 'UNKNOWN') ? existing.patient_id : '';
document.getElementById('assignName').value = existing.patient_name || '';
document.getElementById('assignDob').value = existing.patient_dob || '';
document.getElementById('assignGender').value = existing.patient_gender || 'Male';
document.getElementById('assignBed').value = existing.bed_id || existing.bedLabel || '';
} else {
document.getElementById('assignMrn').value = '';
document.getElementById('assignName').value = '';
document.getElementById('assignDob').value = '';
document.getElementById('assignGender').value = 'Male';
document.getElementById('assignBed').value = '';
}
modal.classList.add('active');
}
function closeAssignPatientModal() {
document.getElementById('modalAssignPatient').classList.remove('active');
}
async function submitAssignPatient(event) {
event.preventDefault();
const btn = document.getElementById('btnSavePatient');
btn.disabled = true;
btn.innerHTML = '<span>⏳ Saving...</span>';
const device_id = document.getElementById('assignDeviceSelect').value;
const mrn = document.getElementById('assignMrn').value.trim();
const name = document.getElementById('assignName').value.trim();
const dob = document.getElementById('assignDob').value;
const gender = document.getElementById('assignGender').value;
const bed_id = document.getElementById('assignBed').value.trim();
try {
const response = await fetch(`${API_BASE}/patient/assign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_id, mrn, name, dob, gender, bed_id })
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Assignment failed');
}
// Update local device state immediately
connectedDevices[device_id] = {
...(connectedDevices[device_id] || {}),
device_id,
patient_id: mrn,
mrn: mrn,
patient_name: name,
name: name,
patient_gender: gender,
gender: gender,
patient_dob: dob,
dob: dob,
bed_id: bed_id,
bedLabel: bed_id || connectedDevices[device_id]?.bedLabel
};
closeAssignPatientModal();
if (activeDeviceId === device_id || !activeDeviceId) {
machVitals.patient_name = name;
machVitals.patient_id = mrn;
machVitals.bed_id = bed_id;
const elName = document.getElementById('machPatientName');
if (elName) elName.textContent = name ? name.toUpperCase() : '--';
const elMrn = document.getElementById('machMrnPill');
if (elMrn) elMrn.textContent = mrn ? `MRN: ${mrn}` : 'MRN: --';
const elBed = document.getElementById('machBedPill');
if (elBed) elBed.textContent = bed_id ? `BED ${bed_id}` : 'BED --';
}
updateDeviceTabs();
if (viewMode === 'grid') renderMultiBedGrid();
else if (viewMode === 'single' && activeDeviceId === device_id) {
renderSingleDevice(connectedDevices[device_id]);
}
} catch (err) {
alert(`Could not save patient: ${err.message}`);
} finally {
btn.disabled = false;
btn.innerHTML = '<span>💾 Save & Link to Machine</span>';
}
}
// === Modal 2: Fetch Patient Data & History Logic ===
let currentFetchedReadings = [];
let currentFetchedPatient = null;
function openPatientHistoryModal(identifier) {
const modal = document.getElementById('modalPatientHistory');
const queryInput = document.getElementById('historySearchQuery');
modal.classList.add('active');
if (identifier && identifier !== 'UNKNOWN') {
queryInput.value = identifier;
executePatientHistorySearch();
} else {
queryInput.value = '';
document.getElementById('historyPatientProfile').style.display = 'none';
document.getElementById('historyTableBody').innerHTML = `
<tr>
<td colspan="7" style="text-align: center; color: var(--text-dim); padding: 30px;">
Enter an MRN, Name, or DOB above and click "Search Patient" to fetch all recorded vitals.
</td>
</tr>
`;
}
}
function closePatientHistoryModal() {
document.getElementById('modalPatientHistory').classList.remove('active');
}
async function executePatientHistorySearch() {
const query = document.getElementById('historySearchQuery').value.trim();
if (!query) return;
const tbody = document.getElementById('historyTableBody');
tbody.innerHTML = `<tr><td colspan="7" style="text-align: center; color: var(--spo2-color); padding: 30px;">⏳ Searching patient database...</td></tr>`;
try {
let patientInfo = null;
let readings = [];
const histResp = await fetch(`${API_BASE}/patient/${encodeURIComponent(query)}/history?limit=100`);
if (histResp.ok) {
const data = await histResp.json();
patientInfo = data.patient;
readings = data.readings || [];
} else {
const readResp = await fetch(`${API_BASE}/readings?mrn=${encodeURIComponent(query)}&name=${encodeURIComponent(query)}&limit=100`);
if (readResp.ok) {
readings = await readResp.json();
}
}
currentFetchedReadings = readings;
currentFetchedPatient = patientInfo || { mrn: query, name: query };
const profileBanner = document.getElementById('historyPatientProfile');
if (patientInfo) {
profileBanner.style.display = 'flex';
document.getElementById('historyName').textContent = patientInfo.name || 'Unknown';
document.getElementById('historyMrn').textContent = `MRN: ${patientInfo.mrn || '---'}`;
document.getElementById('historyGender').textContent = `Gender: ${patientInfo.gender || '---'}`;
document.getElementById('historyDob').textContent = `DOB: ${patientInfo.dob || '---'}`;
document.getElementById('historyBed').textContent = `Bed: ${patientInfo.bed_id || '---'}`;
document.getElementById('historyDevice').textContent = `Machine: ${patientInfo.device_id || '---'}`;
} else {
profileBanner.style.display = 'none';
}
if (readings.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="7" style="text-align: center; color: var(--text-dim); padding: 30px;">
No vitals records found for "${query}".
</td>
</tr>
`;
return;
}
let rowsHtml = '';
readings.forEach(r => {
const timeStr = r.timestamp ? new Date(r.timestamp).toLocaleString() : '--';
const hr = r.heart_rate !== null ? Math.round(r.heart_rate) : '--';
const spo2 = r.spo2 !== null ? `${Math.round(r.spo2)}%` : '--';
const sys = r.systolic_bp !== null ? Math.round(r.systolic_bp) : '--';
const dia = r.diastolic_bp !== null ? Math.round(r.diastolic_bp) : '--';
const bp = (sys !== '--' || dia !== '--') ? `${sys}/${dia}` : '--';
const rr = r.respiratory_rate !== null ? Math.round(r.respiratory_rate) : '--';
const temp = r.temperature !== null ? `${r.temperature.toFixed(1)}°C` : '--';
const map = r.map_bp !== null ? Math.round(r.map_bp) : '--';
rowsHtml += `
<tr>
<td style="color: var(--text-secondary);">${timeStr}</td>
<td style="color: var(--hr-color); font-weight: 700;">${hr}</td>
<td style="color: var(--spo2-color); font-weight: 700;">${spo2}</td>
<td style="color: var(--bp-color); font-weight: 700;">${bp}</td>
<td style="color: var(--rr-color); font-weight: 700;">${rr}</td>
<td style="color: var(--temp-color); font-weight: 700;">${temp}</td>
<td style="color: var(--map-color);">${map}</td>
</tr>
`;
});
tbody.innerHTML = rowsHtml;
} catch (err) {
tbody.innerHTML = `
<tr>
<td colspan="7" style="text-align: center; color: var(--status-critical); padding: 30px;">
Error searching: ${err.message}
</td>
</tr>
`;
}
}
function exportHistoryToCsv() {
if (!currentFetchedReadings || currentFetchedReadings.length === 0) {
alert('No vitals data available to export.');
return;
}
const p = currentFetchedPatient || {};
let csv = `Patient Vital Signs History\n`;
csv += `MRN,${p.mrn || ''}\n`;
csv += `Name,${p.name || ''}\n`;
csv += `DOB,${p.dob || ''}\n`;
csv += `Gender,${p.gender || ''}\n`;
csv += `Bed,${p.bed_id || ''}\n\n`;
csv += `Timestamp,Heart Rate (bpm),SpO2 (%),Systolic BP (mmHg),Diastolic BP (mmHg),RR (/min),Temp (C),MAP\n`;
currentFetchedReadings.forEach(r => {
const t = r.timestamp || '';
csv += `"${t}",${r.heart_rate || ''},${r.spo2 || ''},${r.systolic_bp || ''},${r.diastolic_bp || ''},${r.respiratory_rate || ''},${r.temperature || ''},${r.map_bp || ''}\n`;
});
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `vitals_${p.mrn || 'patient'}_${new Date().toISOString().slice(0,10)}.csv`;
link.click();
}
function updateVitalCard(cardKey, value, rangeKey, formatter) {
const valueEl = document.getElementById(`value-${cardKey}`);
const statusEl = document.getElementById(`status-${cardKey}`);
const updateEl = document.getElementById(`update-${cardKey}`);
const cardEl = document.getElementById(`card-${cardKey}`);
if (!valueEl) return;
if (value === null || value === undefined) {
valueEl.innerHTML = '---';
valueEl.className = 'vital-value no-data';
if (statusEl) {
statusEl.textContent = 'No Data';
statusEl.className = 'status-badge offline';
}
if (cardEl) {
cardEl.classList.remove('has-value');
}
return;
}
// Format value
const formatted = formatter ? formatter(value) : Math.round(value);
valueEl.innerHTML = formatted;
valueEl.className = 'vital-value vital-value-update';
cardEl.classList.add('has-value');
// Remove animation class after it plays
setTimeout(() => valueEl.classList.remove('vital-value-update'), 600);
// Calculate status
const ranges = VITAL_RANGES[rangeKey];
let isCritical = false;
if (ranges) {
if (value <= ranges.critLow || value >= ranges.critHigh) {
statusEl.textContent = '🚨 Critical';
statusEl.className = 'status-badge critical';
isCritical = true;
} else if (value < ranges.low || value > ranges.high) {
statusEl.textContent = '⚠ Warning';
statusEl.className = 'status-badge warning';
} else {
statusEl.textContent = '✓ Normal';
statusEl.className = 'status-badge normal';
}
}
// Play alert for critical values
if (isCritical) {
playAlertBeep();
}
// Update timestamp
const now = new Date();
updateEl.textContent = now.toLocaleTimeString('en-US', { hour12: false });
}
// === Connection Status ===
function updateConnectionStatus(status, text) {
const badge = document.getElementById('connectionBadge');
const textEl = document.getElementById('connectionText');
textEl.textContent = text;
badge.className = 'connection-badge';
if (status === 'offline') badge.classList.add('offline');
else if (status === 'waiting') badge.classList.add('warning');
// 'connected' uses default (green)
}
// === Waveform Drawing ===
function initWaveforms() {
['hr', 'spo2', 'bp', 'rr', 'temp', 'map'].forEach(key => {
const canvas = document.getElementById(`waveform-${key}`);
if (canvas) {
const ctx = canvas.getContext('2d');
// Set canvas resolution
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = rect.width * 2;
canvas.height = rect.height * 2;
ctx.scale(2, 2);
waveformCanvases[key] = { canvas, ctx, width: rect.width, height: rect.height };
waveformData[key] = [];
}
});
}
function pushWaveformPoint(key, value) {
if (!waveformData[key]) waveformData[key] = [];
waveformData[key].push(value !== null && value !== undefined ? value : null);
if (waveformData[key].length > 100) waveformData[key].shift();
}
const WAVEFORM_COLORS = {
hr: '#ef4444', spo2: '#06b6d4', bp: '#a855f7',
rr: '#22c55e', temp: '#f97316', map: '#ec4899'
};
function drawWaveform(key) {
const wf = waveformCanvases[key];
if (!wf) return;
const { ctx, width, height } = wf;
const data = waveformData[key] || [];
const color = WAVEFORM_COLORS[key];
ctx.clearRect(0, 0, width, height);
if (data.length < 2) {
// Draw flat line
ctx.beginPath();
ctx.strokeStyle = `${color}33`;
ctx.lineWidth = 1;
ctx.moveTo(0, height / 2);
ctx.lineTo(width, height / 2);
ctx.stroke();
return;
}
// Filter valid values for min/max
const validValues = data.filter(v => v !== null);
if (validValues.length === 0) return;
const min = Math.min(...validValues) * 0.9;
const max = Math.max(...validValues) * 1.1;
const range = max - min || 1;
// Draw gradient fill
const gradient = ctx.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, `${color}20`);
gradient.addColorStop(1, `${color}00`);
ctx.beginPath();
ctx.moveTo(0, height);
let firstPoint = true;
const step = width / (data.length - 1);
data.forEach((v, i) => {
if (v === null) return;
const x = i * step;
const y = height - ((v - min) / range) * (height * 0.8) - height * 0.1;
if (firstPoint) {
ctx.moveTo(x, height);
ctx.lineTo(x, y);
firstPoint = false;
} else {
ctx.lineTo(x, y);
}
});
// Close fill path
const lastValidIdx = data.length - 1 - [...data].reverse().findIndex(v => v !== null);
ctx.lineTo(lastValidIdx * step, height);
ctx.closePath();
ctx.fillStyle = gradient;
ctx.fill();
// Draw line
ctx.beginPath();
firstPoint = true;
data.forEach((v, i) => {
if (v === null) return;
const x = i * step;
const y = height - ((v - min) / range) * (height * 0.8) - height * 0.1;
if (firstPoint) {
ctx.moveTo(x, y);
firstPoint = false;
} else {
ctx.lineTo(x, y);
}
});
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.stroke();
// Glow effect
ctx.strokeStyle = `${color}40`;
ctx.lineWidth = 6;
ctx.stroke();
// Draw latest point dot
if (validValues.length > 0) {
const lastVal = validValues[validValues.length - 1];
const lastIdx = data.lastIndexOf(lastVal);
const x = lastIdx * step;
const y = height - ((lastVal - min) / range) * (height * 0.8) - height * 0.1;
ctx.beginPath();
ctx.arc(x, y, 4, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.beginPath();
ctx.arc(x, y, 8, 0, Math.PI * 2);
ctx.fillStyle = `${color}30`;
ctx.fill();
}
}
let waveformPhase = 0;
function generateIdleWaveform(key, type) {
const data = waveformData[key] || [];
if (data.some(v => v !== null)) return; // Only show idle for no-data
// Generate synthetic waveform for visual appeal
const points = 100;
waveformData[key] = [];
for (let i = 0; i < points; i++) {
const t = (i / points) * Math.PI * 4 + waveformPhase;
let val;
if (type === 'ecg') {
// ECG-like pattern
const phase = (t % (Math.PI * 2)) / (Math.PI * 2);
if (phase > 0.35 && phase < 0.40) val = 80 + 40 * Math.sin((phase - 0.35) * Math.PI / 0.05);
else if (phase > 0.40 && phase < 0.45) val = 80 - 15 * Math.sin((phase - 0.40) * Math.PI / 0.05);
else val = 72 + Math.sin(t * 0.5) * 2;
} else if (type === 'sine') {
val = 50 + Math.sin(t) * 20;
} else {
val = 50 + Math.sin(t * 0.8) * 15 + Math.sin(t * 2.1) * 5;
}
waveformData[key].push(val);
}
}
function animateWaveforms() {
if (isMinimal || document.hidden) {
requestAnimationFrame(animateWaveforms);
return;
}
waveformPhase += 0.05;
// Generate idle waveforms for cards with no data
generateIdleWaveform('hr', 'ecg');
generateIdleWaveform('spo2', 'sine');
generateIdleWaveform('bp', 'sine');
generateIdleWaveform('rr', 'sine');
generateIdleWaveform('temp', 'sine');
generateIdleWaveform('map', 'sine');
Object.keys(waveformCanvases).forEach(drawWaveform);
requestAnimationFrame(animateWaveforms);
}
// === Handle window resize ===
window.addEventListener('resize', () => {
initWaveforms();
});
</script>
</body>
</html>