1860 lines
70 KiB
HTML
1860 lines
70 KiB
HTML
<!DOCTYPE html>
|
||||
|
|
<html lang="en">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="UTF-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|||
|
|
<title>Patient Vital Signs Monitor — Contec 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: 16px 24px;
|
|||
|
|
background: var(--bg-card);
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
border-radius: 20px;
|
|||
|
|
box-shadow:
|
|||
|
|
0 4px 6px -1px rgba(0, 0, 0, 0.02),
|
|||
|
|
0 2px 4px -1px rgba(0, 0, 0, 0.01),
|
|||
|
|
0 10px 15px -3px rgba(0, 0, 0, 0.03);
|
|||
|
|
margin-bottom: 24px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-left {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-icon {
|
|||
|
|
width: 48px;
|
|||
|
|
height: 48px;
|
|||
|
|
border-radius: 14px;
|
|||
|
|
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 14px rgba(8, 145, 178, 0.2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-title {
|
|||
|
|
font-size: 20px;
|
|||
|
|
font-weight: 700;
|
|||
|
|
letter-spacing: -0.5px;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-subtitle {
|
|||
|
|
font-size: 13px;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
margin-top: 1px;
|
|||
|
|
font-weight: 500;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-right {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Audio Alert Toggle */
|
|||
|
|
.audio-toggle {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
padding: 8px 16px;
|
|||
|
|
border-radius: 20px;
|
|||
|
|
font-size: 13px;
|
|||
|
|
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: 8px;
|
|||
|
|
padding: 8px 16px;
|
|||
|
|
border-radius: 20px;
|
|||
|
|
font-size: 13px;
|
|||
|
|
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: 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;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* === Device Selector Styles === */
|
|||
|
|
.device-selector-container {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
margin-right: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.device-select-dropdown {
|
|||
|
|
background: #ffffff;
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
padding: 8px 16px;
|
|||
|
|
border-radius: 20px;
|
|||
|
|
font-size: 13px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
outline: none;
|
|||
|
|
cursor: pointer;
|
|||
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02);
|
|||
|
|
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 {
|
|||
|
|
background: linear-gradient(135deg, rgba(8, 145, 178, 0.04), rgba(124, 58, 237, 0.02));
|
|||
|
|
border: 1px solid rgba(8, 145, 178, 0.12);
|
|||
|
|
border-radius: 20px;
|
|||
|
|
margin-bottom: 24px;
|
|||
|
|
overflow: hidden;
|
|||
|
|
box-shadow: 0 4px 15px -3px rgba(0, 0, 0, 0.02);
|
|||
|
|
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: 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);
|
|||
|
|
grid-template-rows: auto auto;
|
|||
|
|
gap: 24px;
|
|||
|
|
flex: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.vital-card {
|
|||
|
|
background: var(--bg-card);
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
border-radius: 24px;
|
|||
|
|
padding: 24px;
|
|||
|
|
position: relative;
|
|||
|
|
overflow: hidden;
|
|||
|
|
box-shadow:
|
|||
|
|
0 4px 6px -1px rgba(0, 0, 0, 0.02),
|
|||
|
|
0 2px 4px -1px rgba(0, 0, 0, 0.01),
|
|||
|
|
0 15px 30px -10px rgba(0, 0, 0, 0.04);
|
|||
|
|
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(-4px);
|
|||
|
|
box-shadow:
|
|||
|
|
0 20px 40px -15px rgba(0, 0, 0, 0.08),
|
|||
|
|
0 0 0 1px rgba(0, 0, 0, 0.02);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.vital-card.hr:hover { border-color: rgba(220, 38, 38, 0.3); }
|
|||
|
|
.vital-card.spo2:hover { border-color: rgba(8, 145, 178, 0.3); }
|
|||
|
|
.vital-card.bp:hover { border-color: rgba(124, 58, 237, 0.3); }
|
|||
|
|
.vital-card.rr:hover { border-color: rgba(22, 163, 74, 0.3); }
|
|||
|
|
.vital-card.temp:hover { border-color: rgba(234, 88, 12, 0.3); }
|
|||
|
|
.vital-card.map-card:hover { border-color: rgba(219, 39, 119, 0.3); }
|
|||
|
|
|
|||
|
|
.vital-card::before {
|
|||
|
|
content: '';
|
|||
|
|
position: absolute;
|
|||
|
|
top: 0;
|
|||
|
|
left: 0;
|
|||
|
|
right: 0;
|
|||
|
|
height: 4px;
|
|||
|
|
border-radius: 24px 24px 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.4;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.vital-card.has-value:hover::after {
|
|||
|
|
opacity: 0.7;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.card-header {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
margin-bottom: 12px;
|
|||
|
|
position: relative;
|
|||
|
|
z-index: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.card-label {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 10px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.card-icon {
|
|||
|
|
width: 38px;
|
|||
|
|
height: 38px;
|
|||
|
|
border-radius: 12px;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
font-size: 18px;
|
|||
|
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.02);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.vital-card.hr .card-icon { background: rgba(220, 38, 38, 0.08); }
|
|||
|
|
.vital-card.spo2 .card-icon { background: rgba(8, 145, 178, 0.08); }
|
|||
|
|
.vital-card.bp .card-icon { background: rgba(124, 58, 237, 0.08); }
|
|||
|
|
.vital-card.rr .card-icon { background: rgba(22, 163, 74, 0.08); }
|
|||
|
|
.vital-card.temp .card-icon { background: rgba(234, 88, 12, 0.08); }
|
|||
|
|
.vital-card.map-card .card-icon { background: rgba(219, 39, 119, 0.08); }
|
|||
|
|
|
|||
|
|
.card-name {
|
|||
|
|
font-size: 13px;
|
|||
|
|
font-weight: 700;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
letter-spacing: 0.8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.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: 700;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
letter-spacing: 0.5px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-badge.normal {
|
|||
|
|
background: rgba(22, 163, 74, 0.08);
|
|||
|
|
color: var(--status-ok);
|
|||
|
|
border: 1px solid rgba(22, 163, 74, 0.15);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-badge.warning {
|
|||
|
|
background: rgba(202, 138, 4, 0.08);
|
|||
|
|
color: var(--status-warn);
|
|||
|
|
border: 1px solid rgba(202, 138, 4, 0.15);
|
|||
|
|
animation: warningFlash 2s ease-in-out infinite;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-badge.critical {
|
|||
|
|
background: rgba(220, 38, 38, 0.08);
|
|||
|
|
color: var(--status-critical);
|
|||
|
|
border: 1px solid rgba(220, 38, 38, 0.15);
|
|||
|
|
animation: criticalFlash 1s ease-in-out infinite;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-badge.offline {
|
|||
|
|
background: rgba(100, 116, 139, 0.06);
|
|||
|
|
color: var(--status-offline);
|
|||
|
|
border: 1px solid rgba(100, 116, 139, 0.1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@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: 110px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.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); }
|
|||
|
|
.vital-card.spo2 .vital-value { color: var(--spo2-color); }
|
|||
|
|
.vital-card.bp .vital-value { color: var(--bp-color); }
|
|||
|
|
.vital-card.rr .vital-value { color: var(--rr-color); }
|
|||
|
|
.vital-card.temp .vital-value { color: var(--temp-color); }
|
|||
|
|
.vital-card.map-card .vital-value { color: var(--map-color); }
|
|||
|
|
|
|||
|
|
.vital-value.no-data {
|
|||
|
|
font-size: 48px;
|
|||
|
|
color: var(--text-dim) !important;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.vital-value .unit {
|
|||
|
|
font-size: 20px;
|
|||
|
|
font-weight: 500;
|
|||
|
|
letter-spacing: 0;
|
|||
|
|
margin-left: 4px;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.vital-value .slash {
|
|||
|
|
font-size: 48px;
|
|||
|
|
opacity: 0.3;
|
|||
|
|
margin: 0 2px;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.vital-value-update {
|
|||
|
|
animation: valueFlash 0.6s ease-out;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@keyframes valueFlash {
|
|||
|
|
0% { transform: scale(1.06); filter: brightness(1.2); }
|
|||
|
|
100% { transform: scale(1); filter: brightness(1); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* === Waveform Canvas === */
|
|||
|
|
.waveform-container {
|
|||
|
|
height: 55px;
|
|||
|
|
margin-top: 12px;
|
|||
|
|
position: relative;
|
|||
|
|
z-index: 1;
|
|||
|
|
overflow: hidden;
|
|||
|
|
border-radius: 12px;
|
|||
|
|
background: rgba(0, 0, 0, 0.01);
|
|||
|
|
border: 1px solid rgba(0, 0, 0, 0.02);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.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: 14px;
|
|||
|
|
padding-top: 12px;
|
|||
|
|
border-top: 1px solid var(--border-color);
|
|||
|
|
position: relative;
|
|||
|
|
z-index: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.normal-range {
|
|||
|
|
font-size: 11px;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.last-update {
|
|||
|
|
font-size: 11px;
|
|||
|
|
color: var(--text-dim);
|
|||
|
|
font-family: 'JetBrains Mono', monospace;
|
|||
|
|
font-weight: 500;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* === Info Bar === */
|
|||
|
|
.info-bar {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
padding: 16px 24px;
|
|||
|
|
background: var(--bg-card);
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
border-radius: 20px;
|
|||
|
|
box-shadow:
|
|||
|
|
0 4px 6px -1px rgba(0, 0, 0, 0.02),
|
|||
|
|
0 2px 4px -1px rgba(0, 0, 0, 0.01),
|
|||
|
|
0 10px 15px -3px rgba(0, 0, 0, 0.03);
|
|||
|
|
margin-top: 24px;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
gap: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-item {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
font-size: 13px;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
font-weight: 500;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-item strong {
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
font-weight: 700;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-dot {
|
|||
|
|
width: 6px;
|
|||
|
|
height: 6px;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
background: var(--border-color);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.model-badge {
|
|||
|
|
padding: 4px 12px;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
font-size: 11px;
|
|||
|
|
font-weight: 800;
|
|||
|
|
letter-spacing: 0.5px;
|
|||
|
|
background: linear-gradient(135deg, rgba(8, 145, 178, 0.08), rgba(124, 58, 237, 0.08));
|
|||
|
|
color: var(--spo2-color);
|
|||
|
|
border: 1px solid rgba(8, 145, 178, 0.15);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* === Responsive === */
|
|||
|
|
@media (max-width: 1024px) {
|
|||
|
|
.vitals-grid {
|
|||
|
|
grid-template-columns: repeat(2, 1fr);
|
|||
|
|
gap: 20px;
|
|||
|
|
}
|
|||
|
|
.setup-guide-body {
|
|||
|
|
grid-template-columns: 1fr;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (max-width: 640px) {
|
|||
|
|
.app-container { padding: 12px; }
|
|||
|
|
.vitals-grid {
|
|||
|
|
grid-template-columns: 1fr;
|
|||
|
|
gap: 16px;
|
|||
|
|
}
|
|||
|
|
.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;
|
|||
|
|
}
|
|||
|
|
.vital-value { font-size: 56px; }
|
|||
|
|
.setup-guide-body {
|
|||
|
|
grid-template-columns: 1fr;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* === Patient Clinical Info Banner === */
|
|||
|
|
.patient-banner {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
padding: 16px 24px;
|
|||
|
|
background: var(--bg-card);
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
border-radius: 20px;
|
|||
|
|
box-shadow:
|
|||
|
|
0 4px 6px -1px rgba(0, 0, 0, 0.02),
|
|||
|
|
0 10px 15px -3px rgba(0, 0, 0, 0.03);
|
|||
|
|
margin-bottom: 24px;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
gap: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.patient-banner-group {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.patient-avatar {
|
|||
|
|
width: 36px;
|
|||
|
|
height: 36px;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
background: rgba(8, 145, 178, 0.08);
|
|||
|
|
color: var(--spo2-color);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.patient-avatar svg {
|
|||
|
|
width: 18px;
|
|||
|
|
height: 18px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.patient-label {
|
|||
|
|
font-size: 11px;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
font-weight: 700;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
letter-spacing: 0.5px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.patient-value-text {
|
|||
|
|
font-size: 14px;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
font-weight: 700;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.patient-banner-divider {
|
|||
|
|
height: 24px;
|
|||
|
|
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 class="patient-banner-divider"></div>
|
|||
|
|
<div class="patient-banner-group">
|
|||
|
|
<div>
|
|||
|
|
<div class="patient-label">Monitor Status</div>
|
|||
|
|
<div class="patient-value-text" id="bannerMonitorModel">CMS8500</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="patient-banner-divider"></div>
|
|||
|
|
<div class="patient-banner-group">
|
|||
|
|
<div>
|
|||
|
|
<div class="patient-label">Last Transmission</div>
|
|||
|
|
<div class="patient-value-text" id="bannerLastSeen">Never</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: 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">
|
|||
|
|
<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: 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">
|
|||
|
|
<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/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">
|
|||
|
|
<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: 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">
|
|||
|
|
<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: 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">
|
|||
|
|
<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: 70–105 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: 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;
|
|||
|
|
|
|||
|
|
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>`);
|
|||
|
|
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 = 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', data.temperature);
|
|||
|
|
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>
|