Files
ContecMonitor/static/dashboard.html
T

1794 lines
68 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 / CMS8500</title>
<meta name="description" content="Real-time patient vital signs monitoring dashboard for Contec CMS7000PLUS / CMS8500 patient monitors">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet">
<style>
/* === CSS Reset & Base === */
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
/* Core palette (Light White Theme) */
--bg-primary: #f8fafc;
--bg-secondary: #ffffff;
--bg-card: #ffffff;
--bg-card-hover: #f1f5f9;
--border-color: rgba(15, 23, 42, 0.08);
--text-primary: #0f172a;
--text-secondary: #475569;
--text-dim: #94a3b8;
/* Vital sign accent colors (optimized for white background contrast) */
--hr-color: #dc2626; /* dark red */
--hr-glow: rgba(220, 38, 38, 0.1);
--spo2-color: #0891b2; /* dark cyan */
--spo2-glow: rgba(8, 145, 178, 0.1);
--bp-color: #7c3aed; /* dark purple */
--bp-glow: rgba(124, 58, 237, 0.1);
--rr-color: #16a34a; /* dark green */
--rr-glow: rgba(22, 163, 74, 0.1);
--temp-color: #ea580c; /* dark orange */
--temp-glow: rgba(234, 88, 12, 0.1);
--map-color: #db2777; /* dark pink */
--map-glow: rgba(219, 39, 119, 0.1);
/* Status colors */
--status-ok: #16a34a;
--status-warn: #ca8a04;
--status-critical: #dc2626;
--status-offline: #64748b;
}
body {
font-family: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
display: flex;
flex-direction: column;
}
/* === Animated Grid Background === */
.bg-grid {
position: fixed;
inset: 0;
z-index: 0;
background-image:
linear-gradient(rgba(15, 23, 42, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(15, 23, 42, 0.03) 1px, transparent 1px);
background-size: 30px 30px;
pointer-events: none;
}
.bg-glow {
position: fixed;
top: -200px;
left: 50%;
transform: translateX(-50%);
width: 800px;
height: 600px;
background: radial-gradient(ellipse, rgba(8, 145, 178, 0.05), 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;
width: 100%;
}
/* === Header === */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow:
0 2px 4px -1px rgba(0, 0, 0, 0.01),
0 4px 6px -3px rgba(0, 0, 0, 0.02);
margin-bottom: 12px;
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.header-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: linear-gradient(135deg, var(--spo2-color), var(--hr-color));
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
box-shadow: 0 2px 6px rgba(8, 145, 178, 0.15);
}
.header-title {
font-size: 15px;
font-weight: 700;
letter-spacing: -0.3px;
color: var(--text-primary);
}
.header-subtitle {
font-size: 11px;
color: var(--text-secondary);
margin-top: 1px;
font-weight: 500;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
/* Audio Alert Toggle */
.audio-toggle {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
background: rgba(71, 85, 105, 0.06);
border: 1px solid rgba(71, 85, 105, 0.1);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
user-select: none;
}
.audio-toggle:hover {
background: rgba(71, 85, 105, 0.12);
color: var(--text-primary);
}
.audio-toggle.active {
background: rgba(239, 68, 68, 0.08);
border-color: rgba(239, 68, 68, 0.2);
color: var(--status-critical);
}
.connection-badge {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
background: rgba(22, 163, 74, 0.08);
border: 1px solid rgba(22, 163, 74, 0.15);
color: var(--status-ok);
transition: all 0.3s;
}
.connection-badge.offline {
background: rgba(100, 116, 139, 0.08);
border-color: rgba(100, 116, 139, 0.15);
color: var(--status-offline);
}
.connection-badge.warning {
background: rgba(202, 138, 4, 0.08);
border-color: rgba(202, 138, 4, 0.15);
color: var(--status-warn);
}
.status-dot {
width: 7px;
height: 7px;
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 5px rgba(34, 197, 94, 0); }
}
.time-display {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
/* === Device Selector Styles === */
.device-selector-container {
display: flex;
align-items: center;
gap: 6px;
margin-right: 4px;
}
.device-select-dropdown {
background: #ffffff;
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 6px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
outline: none;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.01);
transition: all 0.3s;
}
.device-select-dropdown:hover {
border-color: var(--spo2-color);
background: #f8fafc;
}
.device-select-dropdown option {
background-color: #ffffff;
color: var(--text-primary);
}
/* === Setup Guide Panel === */
.setup-guide {
display: none !important;
}
.setup-guide-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
cursor: pointer;
user-select: none;
}
.setup-guide-header:hover {
background: rgba(0, 0, 0, 0.01);
}
.setup-guide-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
font-weight: 700;
color: var(--spo2-color);
}
.setup-guide-toggle {
font-size: 12px;
color: var(--text-secondary);
padding: 4px 12px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.03);
transition: all 0.2s;
font-weight: 600;
}
.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(8, 145, 178, 0.08);
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: 700;
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(0, 0, 0, 0.04);
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.02);
border-radius: 12px;
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
border: 1px solid rgba(0, 0, 0, 0.04);
}
.setup-ip-display div {
font-weight: 600;
}
.setup-ip-label {
font-size: 13px;
color: var(--text-secondary);
font-weight: 600;
}
.setup-ip-value {
font-family: 'JetBrains Mono', monospace;
font-size: 18px;
font-weight: 800;
color: var(--spo2-color);
}
/* === Vitals Grid === */
.vitals-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
flex: 1;
}
.vital-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 12px;
position: relative;
overflow: hidden;
box-shadow:
0 2px 4px -1px rgba(0, 0, 0, 0.02),
0 4px 6px -2px rgba(0, 0, 0, 0.03);
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
display: grid;
grid-template-areas:
"header header"
"value waveform"
"footer footer";
grid-template-columns: 110px 1fr;
grid-template-rows: auto 50px auto;
gap: 6px 12px;
}
.vital-card:hover {
background: var(--bg-card-hover);
transform: translateY(-2px);
box-shadow:
0 8px 16px -6px rgba(0, 0, 0, 0.05),
0 0 0 1px rgba(0, 0, 0, 0.01);
}
.vital-card.hr:hover { border-color: rgba(220, 38, 38, 0.25); }
.vital-card.spo2:hover { border-color: rgba(8, 145, 178, 0.25); }
.vital-card.bp:hover { border-color: rgba(124, 58, 237, 0.25); }
.vital-card.rr:hover { border-color: rgba(22, 163, 74, 0.25); }
.vital-card.temp:hover { border-color: rgba(234, 88, 12, 0.25); }
.vital-card.map-card:hover { border-color: rgba(219, 39, 119, 0.25); }
.vital-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 3.5px;
border-radius: 12px 0 0 12px;
}
.vital-card.hr::before { background: linear-gradient(180deg, var(--hr-color), transparent); }
.vital-card.spo2::before { background: linear-gradient(180deg, var(--spo2-color), transparent); }
.vital-card.bp::before { background: linear-gradient(180deg, var(--bp-color), transparent); }
.vital-card.rr::before { background: linear-gradient(180deg, var(--rr-color), transparent); }
.vital-card.temp::before { background: linear-gradient(180deg, var(--temp-color), transparent); }
.vital-card.map-card::before { background: linear-gradient(180deg, var(--map-color), transparent); }
/* Glow effect behind cards */
.vital-card::after {
content: '';
position: absolute;
top: 50%;
left: 20%;
width: 120px;
height: 120px;
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.25;
}
.vital-card.has-value:hover::after {
opacity: 0.4;
}
.card-header {
grid-area: header;
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
z-index: 1;
padding-bottom: 2px;
}
.card-label {
display: flex;
align-items: center;
gap: 8px;
}
.card-icon {
width: 24px;
height: 24px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.01);
}
.vital-card.hr .card-icon { background: rgba(220, 38, 38, 0.05); }
.vital-card.spo2 .card-icon { background: rgba(8, 145, 178, 0.05); }
.vital-card.bp .card-icon { background: rgba(124, 58, 237, 0.05); }
.vital-card.rr .card-icon { background: rgba(22, 163, 74, 0.05); }
.vital-card.temp .card-icon { background: rgba(234, 88, 12, 0.05); }
.vital-card.map-card .card-icon { background: rgba(219, 39, 119, 0.05); }
.card-name {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.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: 2px 6px;
border-radius: 5px;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.2px;
}
.status-badge.normal {
background: rgba(22, 163, 74, 0.05);
color: var(--status-ok);
border: 1px solid rgba(22, 163, 74, 0.08);
}
.status-badge.warning {
background: rgba(202, 138, 4, 0.05);
color: var(--status-warn);
border: 1px solid rgba(202, 138, 4, 0.08);
animation: warningFlash 2s ease-in-out infinite;
}
.status-badge.critical {
background: rgba(220, 38, 38, 0.05);
color: var(--status-critical);
border: 1px solid rgba(220, 38, 38, 0.08);
animation: criticalFlash 1s ease-in-out infinite;
}
.status-badge.offline {
background: rgba(100, 116, 139, 0.03);
color: var(--status-offline);
border: 1px solid rgba(100, 116, 139, 0.06);
}
@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 {
grid-area: value;
display: flex;
align-items: center;
justify-content: flex-start;
position: relative;
z-index: 1;
height: 100%;
}
.vital-value {
font-family: 'JetBrains Mono', monospace;
font-size: 34px;
font-weight: 700;
line-height: 1;
letter-spacing: -0.5px;
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 6px rgba(220, 38, 38, 0.12); }
.vital-card.spo2 .vital-value { color: var(--spo2-color); text-shadow: 0 0 6px rgba(8, 145, 178, 0.12); }
.vital-card.bp .vital-value { color: var(--bp-color); text-shadow: 0 0 6px rgba(124, 58, 237, 0.12); }
.vital-card.rr .vital-value { color: var(--rr-color); text-shadow: 0 0 6px rgba(22, 163, 74, 0.12); }
.vital-card.temp .vital-value { color: var(--temp-color); text-shadow: 0 0 6px rgba(234, 88, 12, 0.12); }
.vital-card.map-card .vital-value { color: var(--map-color); text-shadow: 0 0 6px rgba(219, 39, 119, 0.12); }
.vital-value.no-data {
font-size: 26px;
color: var(--text-dim) !important;
text-shadow: none !important;
}
.vital-value .unit {
font-size: 12px;
font-weight: 500;
letter-spacing: 0;
margin-left: 2px;
color: var(--text-secondary);
text-shadow: none !important;
}
.vital-value .slash {
font-size: 24px;
opacity: 0.3;
margin: 0;
color: var(--text-secondary);
text-shadow: none !important;
}
.vital-value-update {
animation: valueFlash 0.6s ease-out;
}
@keyframes valueFlash {
0% { transform: scale(1.03); filter: brightness(1.1); }
100% { transform: scale(1); filter: brightness(1); }
}
/* === Waveform Canvas === */
.waveform-container {
grid-area: waveform;
height: 100%;
position: relative;
z-index: 1;
overflow: hidden;
border-radius: 6px;
background: rgba(0, 0, 0, 0.01);
}
.waveform-container canvas {
width: 100%;
height: 100%;
display: block;
}
/* === Normal Range Footer === */
.card-footer {
grid-area: footer;
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 4px;
border-top: 1px solid var(--border-color);
position: relative;
z-index: 1;
}
.normal-range {
font-size: 9px;
color: var(--text-secondary);
font-weight: 600;
}
.last-update {
font-size: 9px;
color: var(--text-dim);
font-family: 'JetBrains Mono', monospace;
font-weight: 500;
}
/* === Info Bar === */
.info-bar {
display: none !important;
}
@media (max-width: 1024px) {
.vitals-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.setup-guide-body {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.app-container { padding: 12px; }
.vitals-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.header {
flex-direction: column;
gap: 16px;
text-align: center;
align-items: center;
padding: 20px;
}
.header-left {
flex-direction: column;
align-items: center;
gap: 10px;
}
.header-right {
flex-direction: column;
width: 100%;
gap: 12px;
}
.device-selector-container {
width: 100%;
justify-content: center;
margin-right: 0;
}
.device-select-dropdown {
width: 100%;
max-width: 250px;
}
.setup-guide-body {
grid-template-columns: 1fr;
}
}
/* === Patient Clinical Info Banner === */
.patient-banner {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 20px;
padding: 8px 16px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow:
0 2px 4px -1px rgba(0, 0, 0, 0.01),
0 4px 6px -3px rgba(0, 0, 0, 0.02);
margin-bottom: 12px;
flex-wrap: wrap;
}
.patient-banner-group {
display: flex;
align-items: center;
gap: 8px;
}
.patient-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(8, 145, 178, 0.06);
color: var(--spo2-color);
display: flex;
align-items: center;
justify-content: center;
}
.patient-avatar svg {
width: 14px;
height: 14px;
}
.patient-label {
font-size: 9px;
color: var(--text-secondary);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.patient-value-text {
font-size: 12px;
color: var(--text-primary);
font-weight: 700;
}
.patient-banner-divider {
height: 16px;
width: 1px;
background: var(--border-color);
}
.vital-svg {
stroke-width: 2.5;
transition: transform 0.3s ease;
}
.vital-card:hover .vital-svg {
transform: scale(1.1);
}
.vital-card.hr .card-icon { color: var(--hr-color); }
.vital-card.spo2 .card-icon { color: var(--spo2-color); }
.vital-card.bp .card-icon { color: var(--bp-color); }
.vital-card.rr .card-icon { color: var(--rr-color); }
.vital-card.temp .card-icon { color: var(--temp-color); }
.vital-card.map-card .card-icon { color: var(--map-color); }
@media (max-width: 768px) {
.patient-banner-divider {
display: none;
}
.patient-banner {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
}
@media (max-width: 480px) {
.patient-banner {
grid-template-columns: 1fr;
}
}
/* === Minimal mode for clean iframe embeds === */
body.minimal-mode .header { display: none !important; }
body.minimal-mode .patient-banner { display: none !important; }
body.minimal-mode .setup-guide { display: none !important; }
body.minimal-mode .info-bar { display: none !important; }
body.minimal-mode .app-container {
padding: 0 !important;
max-width: 100% !important;
margin: 0 !important;
}
body.minimal-mode {
padding: 0 !important;
background: transparent !important;
}
body.minimal-mode .bg-grid, body.minimal-mode .bg-glow {
display: none !important;
}
/* === Scrollbar === */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(0,0,0,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 Monitor — Real-time Monitoring</div>
</div>
</div>
<div class="header-right">
<div class="device-selector-container">
<label for="deviceSelector" style="font-size: 13px; color: var(--text-secondary); font-weight: 500;">Select Monitor:</label>
<select id="deviceSelector" class="device-select-dropdown" onchange="selectDevice(this.value)">
<option value="">Waiting for devices...</option>
</select>
</div>
<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>
<!-- Patient Clinical Info Banner -->
<div class="patient-banner">
<div class="patient-banner-group">
<div class="patient-avatar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
</div>
<div>
<div class="patient-label">Active Patient</div>
<div class="patient-value-text" id="bannerPatientName">John Doe</div>
</div>
</div>
<div class="patient-banner-divider"></div>
<div class="patient-banner-group">
<div>
<div class="patient-label">Patient ID</div>
<div class="patient-value-text" id="bannerPatientId">N/A</div>
</div>
</div>
<div class="patient-banner-divider"></div>
<div class="patient-banner-group">
<div>
<div class="patient-label">Location / Bed</div>
<div class="patient-value-text">ICU - Bed 12</div>
</div>
</div>
</div>
<!-- Setup Guide -->
<div class="setup-guide" id="setupGuide">
<div class="setup-guide-header" onclick="toggleSetupGuide()">
<div class="setup-guide-title">
<span>📡</span>
<span>Contec CMS7000PLUS / CMS8500 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 Wi-Fi or Ethernet</h4>
<p><strong>Wi-Fi:</strong> Connect the monitor (e.g. CMS8500) and your PC to the same Wi-Fi network. <br><strong>Ethernet:</strong> Connect using an Ethernet cable (direct or switch).</p>
</div>
</div>
<div class="setup-step">
<div class="step-number">2</div>
<div class="step-content">
<h4>Get PC IP Address</h4>
<p>Find your PC's IP address on the network (shown in the box below). If using Wi-Fi, use your PC's Wi-Fi IP address.</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 monitor: Go to <code>System Setup → Network → CMS Settings</code>. Set <strong>Server IP</strong> (or CMS IP) to your PC's IP address.</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 on your monitor to one of the listening ports: <code id="activePortsList">Detecting...</code> (usually <code>511</code> or <code>518</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 in the monitor settings. Set the sending interval (e.g. 5 seconds) to start transmitting.</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, the monitor will show up in the "Select Monitor" list, and its vitals will display in real-time.</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>
<!-- Vitals Grid -->
<div class="vitals-grid">
<!-- Heart Rate -->
<div class="vital-card hr" id="card-hr">
<div class="card-header">
<div class="card-label">
<div class="card-icon">
<svg class="vital-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
</svg>
</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: 60100 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">
<svg class="vital-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M7 3a3 3 0 0 0-3 3v6a3 3 0 0 0 3 3h1a2 2 0 0 0 2-2V6a3 3 0 0 0-3-3z"/>
<path d="M17 3a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3h-1a2 2 0 0 1-2-2V6a3 3 0 0 1 3-3z"/>
<path d="M9 20h6"/>
<path d="M12 16v4"/>
</svg>
</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: 95100%</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">
<svg class="vital-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22a7 7 0 0 0 7-7c0-4.3-7-13-7-13S5 10.7 5 15a7 7 0 0 0 7 7z"/>
</svg>
</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/60140/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">
<svg class="vital-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 8h16a4 4 0 0 1 4 4v0a4 4 0 0 1-4 4H2"/>
<path d="M6 12h12a2 2 0 0 0 2-2v0a2 2 0 0 0-2-2H6"/>
<path d="M2 16h8"/>
</svg>
</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: 1220 /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">
<svg class="vital-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z"/>
<line x1="11.5" y1="5" x2="11.5" y2="13"/>
</svg>
</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: 97.099.0 °F</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">
<svg class="vital-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
</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: 70105 mmHg</span>
<span class="last-update" id="update-map">--</span>
</div>
</div>
</div>
<!-- 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>
<script>
// === Configuration ===
const API_BASE = window.location.origin + '/api';
const POLL_INTERVAL = 2000; // 2 seconds
const WS_URL = `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: 97.0, high: 99.0, critLow: 95.0, critHigh: 102.2 },
};
// === 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;
let activeDevices = [];
let selectedDeviceId = localStorage.getItem('selectedDeviceId') || '';
// === Initialize ===
document.addEventListener('DOMContentLoaded', () => {
const urlParams = new URLSearchParams(window.location.search);
const isMinimal = urlParams.get('minimal') === 'true' || urlParams.get('clean') === 'true';
if (isMinimal) {
document.body.classList.add('minimal-mode');
}
initWaveforms();
updateClock();
setInterval(updateClock, 1000);
fetchDevices().then(() => {
tryWebSocket();
startPolling();
});
animateWaveforms();
fetchNetworkInfo();
// Periodically refresh the list of active devices
setInterval(fetchDevices, 4000);
});
// === 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';
}
}
// === Fetch Devices List ===
async function fetchDevices() {
try {
const response = await fetch(`${API_BASE}/devices`);
if (response.ok) {
const devices = await response.json();
activeDevices = devices;
updateDeviceSelector();
}
} catch (e) {
console.error("Error fetching devices:", e);
}
}
// === Update Device Dropdown Selector ===
function updateDeviceSelector() {
const selector = document.getElementById('deviceSelector');
const previousSelection = selector.value || selectedDeviceId;
selector.innerHTML = '';
if (activeDevices.length === 0) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'No monitors connected';
selector.appendChild(opt);
return;
}
// Sort: active devices first, then offline
const sortedDevices = [...activeDevices].sort((a, b) => {
if (a.status === 'active' && b.status !== 'active') return -1;
if (a.status !== 'active' && b.status === 'active') return 1;
return new Date(b.last_seen) - new Date(a.last_seen);
});
sortedDevices.forEach(d => {
const opt = document.createElement('option');
opt.value = d.device_id;
const statusSymbol = d.status === 'active' ? '🟢' : '⚫';
const ipStr = d.ip_address && d.ip_address !== 'unknown' ? ` (${d.ip_address})` : '';
opt.textContent = `${statusSymbol} ${d.device_id}${ipStr}`;
selector.appendChild(opt);
});
if (previousSelection && sortedDevices.some(d => d.device_id === previousSelection)) {
selector.value = previousSelection;
selectedDeviceId = previousSelection;
} else {
const firstActive = sortedDevices.find(d => d.status === 'active');
if (firstActive) {
selector.value = firstActive.device_id;
selectedDeviceId = firstActive.device_id;
} else if (sortedDevices.length > 0) {
selector.value = sortedDevices[0].device_id;
selectedDeviceId = sortedDevices[0].device_id;
}
}
localStorage.setItem('selectedDeviceId', selectedDeviceId);
}
// === Change Selected Device ===
function selectDevice(deviceId) {
selectedDeviceId = deviceId;
localStorage.setItem('selectedDeviceId', deviceId);
clearVitalsDisplay();
fetchLatestReadings();
}
// === Clear Vitals from Screen ===
function clearVitalsDisplay() {
['hr', 'spo2', 'bp', 'rr', 'temp', 'map'].forEach(key => {
const valueEl = document.getElementById(`value-${key}`);
const statusEl = document.getElementById(`status-${key}`);
const cardEl = document.getElementById(`card-${key}`);
valueEl.innerHTML = '---';
valueEl.className = 'vital-value no-data';
statusEl.textContent = 'No Data';
statusEl.className = 'status-badge offline';
cardEl.classList.remove('has-value');
waveformData[key] = [];
});
document.getElementById('deviceId').textContent = 'Waiting...';
document.getElementById('patientId').textContent = 'Waiting...';
document.getElementById('bannerPatientId').textContent = 'N/A';
document.getElementById('bannerPatientName').textContent = 'John Doe';
document.getElementById('bannerMonitorModel').textContent = 'CMS8500';
document.getElementById('bannerLastSeen').textContent = 'Never';
lastReadingId = null;
}
// === 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);
// Filter messages by selected device or auto-select if nothing selected yet
if (!selectedDeviceId && data.device_id) {
selectDevice(data.device_id);
}
if (data.device_id === selectedDeviceId) {
updateVitals(data);
}
// Refresh device list status
fetchDevices();
};
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 {
const url = selectedDeviceId
? `${API_BASE}/latest-readings?device_id=${encodeURIComponent(selectedDeviceId)}&limit=1`
: `${API_BASE}/latest-readings?limit=1`;
const response = await fetch(url);
if (!response.ok) throw new Error('API error');
const readings = await response.json();
if (readings.length > 0) {
const latest = readings[0];
if (latest.id !== lastReadingId) {
lastReadingId = latest.id;
readingCount++;
updateVitals(latest);
}
if (!useWebSocket) {
updateConnectionStatus('connected', 'Connected — Polling');
}
} else {
if (!useWebSocket) {
updateConnectionStatus('waiting', 'Waiting for data...');
}
}
// Fetch system status
const statusResp = await fetch(`${API_BASE}/status`);
if (statusResp.ok) {
const status = await statusResp.json();
document.getElementById('totalReadings').textContent = status.total_readings_stored;
document.getElementById('apiStatus').textContent =
status.pending_transmissions > 0
? `${status.pending_transmissions} pending`
: 'All sent ✓';
// Update active ports list in guide
const portsEl = document.getElementById('activePortsList');
if (portsEl && status.active_ports && status.active_ports.length > 0) {
portsEl.textContent = status.active_ports.join(', ');
}
}
} catch (e) {
if (!useWebSocket) {
updateConnectionStatus('offline', 'Server offline');
}
}
}
// === Update Vitals Display ===
function updateVitals(data) {
// Hide setup guide once data starts flowing
if (!hasReceivedData) {
hasReceivedData = true;
const guide = document.getElementById('setupGuide');
guide.classList.add('hidden');
}
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>`);
const tempF = data.temperature != null ? (data.temperature * 9/5) + 32 : null;
updateVitalCard('temp', tempF, 'temperature', v => `${v.toFixed(1)}<span class="unit">°F</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 = data.device_id;
if (data.patient_id) document.getElementById('patientId').textContent = data.patient_id;
const now = new Date();
document.getElementById('lastUpdateTime').textContent =
now.toLocaleTimeString('en-US', { hour12: false });
// Update patient clinical banner
if (data.patient_id) {
document.getElementById('bannerPatientId').textContent = data.patient_id;
document.getElementById('bannerPatientName').textContent = data.patient_name || `Patient #${data.patient_id}`;
}
if (data.device_id) {
let deviceModel = 'CMS8500';
if (data.device_id.includes('CMS7000')) {
deviceModel = 'CMS7000PLUS';
}
const parts = data.device_id.split('_');
const suffix = parts.length > 1 ? ` (${parts.pop()})` : '';
document.getElementById('bannerMonitorModel').textContent = `${deviceModel}${suffix}`;
}
document.getElementById('bannerLastSeen').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', tempF);
pushWaveformPoint('map', data.map_bp);
}
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>