Files
ContecMonitor/static/dashboard.html
T

3173 lines
131 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">
<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: #090d16 !important;
min-height: 100vh;
overflow-x: hidden;
}
body.minimal-mode .bg-glow {
display: none !important;
}
body.minimal-mode .app-container {
padding: 8px 12px !important;
max-width: 100% !important;
min-height: auto !important;
}
body.minimal-mode .header,
body.minimal-mode #setupGuide,
body.minimal-mode #patientFilterToolbar,
body.minimal-mode #deviceNavBar,
body.minimal-mode .info-bar {
display: none !important;
}
body.minimal-mode .patient-banner {
margin-bottom: 12px !important;
padding: 10px 16px !important;
}
body.minimal-mode .patient-banner button {
display: none !important;
}
body.minimal-mode .vitals-grid {
gap: 12px !important;
margin-bottom: 8px !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; }
</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 -->
<div id="singleViewWrapper" style="display: none; flex-direction: column; flex: 1;">
<!-- Targeted Patient Demographics Waiting State (shown when filtered patient hasn't connected yet) -->
<div id="singleTargetWaitingState" style="display: none; flex-direction: column; align-items: center; justify-content: center; padding: 50px 20px; text-align: center; background: var(--bg-card); border: 1px dashed rgba(6, 182, 212, 0.4); border-radius: 16px; margin: 20px 0;">
<div style="font-size: 40px; margin-bottom: 12px;">⏳</div>
<div style="font-size: 19px; font-weight: 700; color: var(--text-primary);">Waiting for Contec Monitor Data</div>
<div style="font-size: 13px; color: var(--text-secondary); margin-top: 6px;">Verifying incoming monitor telemetry against required teleconsult patient:</div>
<div style="margin-top: 12px; display: flex; gap: 8px; justify-content: center; flex-wrap: wrap;" id="targetWaitingMrnBadge">Patient Demographics: ---</div>
<div style="font-size: 13px; color: var(--text-secondary); max-width: 540px; margin-top: 14px; line-height: 1.5;">
Live waveforms and telemetry stream automatically once the Contec CMS7000PLUS monitor connects and transmits telemetry matching all requested demographics (MRN, Name, DOB).
</div>
<div style="margin-top: 18px; display: inline-flex; align-items: center; gap: 8px; padding: 6px 14px; background: rgba(6, 182, 212, 0.1); border: 1px solid rgba(6, 182, 212, 0.25); border-radius: 20px; font-size: 12px; color: var(--spo2-color);">
<span style="width: 8px; height: 8px; border-radius: 50%; background: var(--spo2-color); display: inline-block;"></span> Listening for live connection...
</div>
</div>
<!-- Single View Patient Demographics Banner -->
<div class="patient-banner" id="singlePatientBanner">
<div class="patient-banner-left">
<div class="patient-banner-avatar" id="bannerAvatar">👤</div>
<div>
<div class="patient-name-display" id="bannerPatientName">Unassigned Patient</div>
<div class="patient-banner-details">
<span class="patient-mrn-badge" id="bannerMrn">MRN: ---</span>
<span class="patient-gender-badge" id="bannerGender">Gender: ---</span>
<span class="patient-dob-badge" id="bannerDob">DOB: ---</span>
<span style="font-size: 12px; color: var(--text-dim);" id="bannerBed">Bed: ---</span>
</div>
</div>
</div>
<div style="display: flex; gap: 8px; align-items: center;">
<button class="card-action-btn" onclick="openAssignPatientModal(activeDeviceId)">
<span>✏️ Edit Patient Details</span>
</button>
<button class="card-action-btn" onclick="openPatientHistoryModal(connectedDevices[activeDeviceId]?.mrn || connectedDevices[activeDeviceId]?.patient_id)">
<span>📊 Vitals History</span>
</button>
</div>
</div>
<!-- Vitals Grid (Single Machine Detailed View) -->
<div class="vitals-grid" id="singleVitalsGrid">
<!-- Heart Rate -->
<div class="vital-card hr" id="card-hr">
<div class="card-header">
<div class="card-label">
<div class="card-icon">❤️</div>
<span class="card-name">Heart Rate</span>
</div>
<span class="status-badge offline" id="status-hr">No Data</span>
</div>
<div class="vital-value-container">
<div class="vital-value no-data" id="value-hr">---</div>
</div>
<div class="waveform-container">
<canvas id="waveform-hr"></canvas>
</div>
<div class="card-footer">
<span class="normal-range">Normal: 60–100 bpm</span>
<span class="last-update" id="update-hr">--</span>
</div>
</div>
<!-- SpO2 -->
<div class="vital-card spo2" id="card-spo2">
<div class="card-header">
<div class="card-label">
<div class="card-icon">🫁</div>
<span class="card-name">SpO₂</span>
</div>
<span class="status-badge offline" id="status-spo2">No Data</span>
</div>
<div class="vital-value-container">
<div class="vital-value no-data" id="value-spo2">---</div>
</div>
<div class="waveform-container">
<canvas id="waveform-spo2"></canvas>
</div>
<div class="card-footer">
<span class="normal-range">Normal: 95–100%</span>
<span class="last-update" id="update-spo2">--</span>
</div>
</div>
<!-- Blood Pressure -->
<div class="vital-card bp" id="card-bp">
<div class="card-header">
<div class="card-label">
<div class="card-icon">🩸</div>
<span class="card-name">Blood Pressure</span>
</div>
<span class="status-badge offline" id="status-bp">No Data</span>
</div>
<div class="vital-value-container">
<div class="vital-value no-data" id="value-bp">---</div>
</div>
<div class="waveform-container">
<canvas id="waveform-bp"></canvas>
</div>
<div class="card-footer">
<span class="normal-range">Normal: 90/60–140/90 mmHg</span>
<span class="last-update" id="update-bp">--</span>
</div>
</div>
<!-- Respiratory Rate -->
<div class="vital-card rr" id="card-rr">
<div class="card-header">
<div class="card-label">
<div class="card-icon">🌬️</div>
<span class="card-name">Respiratory Rate</span>
</div>
<span class="status-badge offline" id="status-rr">No Data</span>
</div>
<div class="vital-value-container">
<div class="vital-value no-data" id="value-rr">---</div>
</div>
<div class="waveform-container">
<canvas id="waveform-rr"></canvas>
</div>
<div class="card-footer">
<span class="normal-range">Normal: 12–20 /min</span>
<span class="last-update" id="update-rr">--</span>
</div>
</div>
<!-- Temperature -->
<div class="vital-card temp" id="card-temp">
<div class="card-header">
<div class="card-label">
<div class="card-icon">🌡️</div>
<span class="card-name">Temperature</span>
</div>
<span class="status-badge offline" id="status-temp">No Data</span>
</div>
<div class="vital-value-container">
<div class="vital-value no-data" id="value-temp">---</div>
</div>
<div class="waveform-container">
<canvas id="waveform-temp"></canvas>
</div>
<div class="card-footer">
<span class="normal-range">Normal: 36.1–37.2 °C</span>
<span class="last-update" id="update-temp">--</span>
</div>
</div>
<!-- MAP -->
<div class="vital-card map-card" id="card-map">
<div class="card-header">
<div class="card-label">
<div class="card-icon">💓</div>
<span class="card-name">Mean Arterial Pressure</span>
</div>
<span class="status-badge offline" id="status-map">No Data</span>
</div>
<div class="vital-value-container">
<div class="vital-value no-data" id="value-map">---</div>
</div>
<div class="waveform-container">
<canvas id="waveform-map"></canvas>
</div>
<div class="card-footer">
<span class="normal-range">Normal: 70–105 mmHg</span>
<span class="last-update" id="update-map">--</span>
</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');
}
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;
const pMrn = String(dev?.mrn || dev?.patient_id || '').toLowerCase().trim();
const pName = String(dev?.patient_name || dev?.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();
if (pMrn && (pMrn === tm || pMrn.includes(tm) || tm.includes(pMrn))) {
res.mrnMatched = true;
} else {
const numT = parseInt(tm, 10);
const numMrn = parseInt(pMrn, 10);
if (!isNaN(numT) && !isNaN(numMrn) && numT === numMrn && tm.length >= 3 && pMrn.length >= 3) {
res.mrnMatched = true;
}
}
}
// 2. Name verification (supports "Shahil", "Shahil Kumar", "Kumar Shahil")
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;
}
}
res.allMatched = res.mrnMatched && res.nameMatched && res.dobMatched && res.genderMatched;
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);
// === 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', () => {
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) {
readings.forEach(r => {
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();
readings.forEach(r => {
const devKey = r.device_id;
if (devKey && connectedDevices[devKey]) {
const dev = connectedDevices[devKey];
if ((dev.heart_rate === undefined || dev.heart_rate === null) && r.heart_rate !== null && r.heart_rate !== undefined) dev.heart_rate = r.heart_rate;
if ((dev.spo2 === undefined || dev.spo2 === null) && r.spo2 !== null && r.spo2 !== undefined) dev.spo2 = r.spo2;
if ((dev.systolic_bp === undefined || dev.systolic_bp === null) && r.systolic_bp !== null && r.systolic_bp !== undefined) dev.systolic_bp = r.systolic_bp;
if ((dev.diastolic_bp === undefined || dev.diastolic_bp === null) && r.diastolic_bp !== null && r.diastolic_bp !== undefined) dev.diastolic_bp = r.diastolic_bp;
if ((dev.map_bp === undefined || dev.map_bp === null) && r.map_bp !== null && r.map_bp !== undefined) dev.map_bp = r.map_bp;
if ((dev.respiratory_rate === undefined || dev.respiratory_rate === null) && r.respiratory_rate !== null && r.respiratory_rate !== undefined) dev.respiratory_rate = r.respiratory_rate;
if ((dev.temperature === undefined || dev.temperature === null) && r.temperature !== null && r.temperature !== undefined) dev.temperature = r.temperature;
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');
}
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
};
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', 60000),
spo2: retainVital('spo2', 60000),
temperature: retainVital('temperature', 60000),
respiratory_rate: retainVital('respiratory_rate', 60000),
systolic_bp: retainVital('systolic_bp', 3600000),
diastolic_bp: retainVital('diastolic_bp', 3600000),
map_bp: retainVital('map_bp', 3600000),
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
};
const isTargetMatch = !hasTargetFilter || matchesTargetPatient(connectedDevices[devId], devId);
if (isTargetMatch) {
if (!activeDeviceId || hasTargetFilter) {
activeDeviceId = devId;
}
}
updateDeviceTabs();
if (isTargetMatch && hasTargetFilter) {
// Target patient matched — force single view and render immediately
// (don't wait for fetchInitialDevicesAndPatients async race)
if (viewMode !== 'single') setViewMode('single');
renderSingleDevice(connectedDevices[devId]);
} else if (viewMode === 'grid') {
renderMultiBedGrid();
} else if (viewMode === 'single') {
if (isTargetMatch && activeDeviceId === devId) {
renderSingleDevice(connectedDevices[devId]);
} else if (hasTargetFilter && !activeDeviceId) {
renderSingleDevice(null);
}
}
}
function renderSingleDevice(data) {
const waitingState = document.getElementById('singleTargetWaitingState');
const patientBanner = document.getElementById('singlePatientBanner');
const vitalsGrid = document.getElementById('singleVitalsGrid');
if (hasTargetFilter) {
const isMatch = data && matchesTargetPatient(data, data.device_id);
if (!isMatch) {
if (waitingState) {
waitingState.style.display = 'flex';
const badge = document.getElementById('targetWaitingMrnBadge');
if (badge) badge.innerHTML = getTargetPatientBadge();
}
if (patientBanner) patientBanner.style.display = 'none';
if (vitalsGrid) vitalsGrid.style.display = 'none';
return;
}
}
if (waitingState) waitingState.style.display = 'none';
if (patientBanner) patientBanner.style.display = 'flex';
if (vitalsGrid) vitalsGrid.style.display = 'grid';
if (!data) return;
// 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();
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 (value === null || value === undefined) {
valueEl.innerHTML = '---';
valueEl.className = 'vital-value no-data';
statusEl.textContent = 'No Data';
statusEl.className = 'status-badge offline';
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() {
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>