Files
ContecMonitor/fiveparaminte-main/static/dashboard.html
T

1435 lines
52 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Patient Vital Signs Monitor — Contec CMS7000PLUS</title>
<meta name="description" content="Real-time patient vital signs monitoring dashboard for Contec CMS7000PLUS patient monitor">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet">
<style>
/* === CSS Reset & Base === */
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
/* Core palette */
--bg-primary: #0a0e1a;
--bg-secondary: #111827;
--bg-card: rgba(17, 24, 39, 0.7);
--bg-card-hover: rgba(25, 34, 56, 0.8);
--border-color: rgba(255, 255, 255, 0.06);
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-dim: #64748b;
/* Vital sign accent colors */
--hr-color: #ef4444;
--hr-glow: rgba(239, 68, 68, 0.4);
--spo2-color: #06b6d4;
--spo2-glow: rgba(6, 182, 212, 0.4);
--bp-color: #a855f7;
--bp-glow: rgba(168, 85, 247, 0.4);
--rr-color: #22c55e;
--rr-glow: rgba(34, 197, 94, 0.4);
--temp-color: #f97316;
--temp-glow: rgba(249, 115, 22, 0.4);
--map-color: #ec4899;
--map-glow: rgba(236, 72, 153, 0.4);
/* Status */
--status-ok: #22c55e;
--status-warn: #eab308;
--status-critical: #ef4444;
--status-offline: #64748b;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
}
/* === Animated Background === */
.bg-grid {
position: fixed;
inset: 0;
z-index: 0;
background-image:
linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
}
.bg-glow {
position: fixed;
top: -200px;
left: 50%;
transform: translateX(-50%);
width: 800px;
height: 600px;
background: radial-gradient(ellipse, rgba(6, 182, 212, 0.08), transparent 70%);
z-index: 0;
pointer-events: none;
animation: glowPulse 8s ease-in-out infinite;
}
@keyframes glowPulse {
0%, 100% { opacity: 0.5; transform: translateX(-50%) scale(1); }
50% { opacity: 1; transform: translateX(-50%) scale(1.1); }
}
/* === Layout === */
.app-container {
position: relative;
z-index: 1;
max-width: 1400px;
margin: 0 auto;
padding: 24px;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* === Header === */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 28px;
background: var(--bg-card);
backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
border-radius: 16px;
margin-bottom: 24px;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.header-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, var(--spo2-color), var(--hr-color));
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
box-shadow: 0 4px 16px rgba(6, 182, 212, 0.3);
}
.header-title {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.5px;
}
.header-subtitle {
font-size: 13px;
color: var(--text-secondary);
margin-top: 2px;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
/* Audio Alert Toggle */
.audio-toggle {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
background: rgba(100, 116, 139, 0.1);
border: 1px solid rgba(100, 116, 139, 0.2);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.3s;
user-select: none;
}
.audio-toggle:hover {
background: rgba(100, 116, 139, 0.2);
}
.audio-toggle.active {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
color: var(--status-critical);
}
.connection-badge {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.2);
color: var(--status-ok);
transition: all 0.3s;
}
.connection-badge.offline {
background: rgba(100, 116, 139, 0.1);
border-color: rgba(100, 116, 139, 0.2);
color: var(--status-offline);
}
.connection-badge.warning {
background: rgba(234, 179, 8, 0.1);
border-color: rgba(234, 179, 8, 0.2);
color: var(--status-warn);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--status-ok);
animation: statusPulse 2s ease-in-out infinite;
}
.connection-badge.offline .status-dot {
background: var(--status-offline);
animation: none;
}
@keyframes statusPulse {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); }
50% { opacity: 0.7; box-shadow: 0 0 0 6px rgba(34, 197, 94, 0); }
}
.time-display {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
color: var(--text-secondary);
}
/* === Setup Guide Panel === */
.setup-guide {
background: linear-gradient(135deg, rgba(6, 182, 212, 0.08), rgba(168, 85, 247, 0.06));
backdrop-filter: blur(20px);
border: 1px solid rgba(6, 182, 212, 0.15);
border-radius: 16px;
margin-bottom: 24px;
overflow: hidden;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.setup-guide.hidden {
max-height: 0;
margin-bottom: 0;
border-color: transparent;
opacity: 0;
}
.setup-guide-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 24px;
cursor: pointer;
user-select: none;
}
.setup-guide-header:hover {
background: rgba(255, 255, 255, 0.02);
}
.setup-guide-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 15px;
font-weight: 600;
color: var(--spo2-color);
}
.setup-guide-toggle {
font-size: 12px;
color: var(--text-dim);
padding: 4px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
transition: all 0.2s;
}
.setup-guide-body {
padding: 0 24px 20px 24px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.setup-step {
display: flex;
gap: 14px;
align-items: flex-start;
}
.step-number {
width: 32px;
height: 32px;
border-radius: 10px;
background: rgba(6, 182, 212, 0.15);
color: var(--spo2-color);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14px;
flex-shrink: 0;
}
.step-content h4 {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.step-content p {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
}
.step-content code {
background: rgba(255, 255, 255, 0.08);
padding: 2px 6px;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--spo2-color);
}
.setup-ip-display {
grid-column: 1 / -1;
background: rgba(0, 0, 0, 0.2);
border-radius: 12px;
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.setup-ip-label {
font-size: 13px;
color: var(--text-secondary);
}
.setup-ip-value {
font-family: 'JetBrains Mono', monospace;
font-size: 18px;
font-weight: 700;
color: var(--spo2-color);
text-shadow: 0 0 20px var(--spo2-glow);
}
/* === Vitals Grid === */
.vitals-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: auto auto;
gap: 20px;
flex: 1;
}
.vital-card {
background: var(--bg-card);
backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
border-radius: 20px;
padding: 28px;
position: relative;
overflow: hidden;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
display: flex;
flex-direction: column;
}
.vital-card:hover {
background: var(--bg-card-hover);
transform: translateY(-2px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
}
.vital-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
border-radius: 20px 20px 0 0;
}
.vital-card.hr::before { background: linear-gradient(90deg, var(--hr-color), transparent); }
.vital-card.spo2::before { background: linear-gradient(90deg, var(--spo2-color), transparent); }
.vital-card.bp::before { background: linear-gradient(90deg, var(--bp-color), transparent); }
.vital-card.rr::before { background: linear-gradient(90deg, var(--rr-color), transparent); }
.vital-card.temp::before { background: linear-gradient(90deg, var(--temp-color), transparent); }
.vital-card.map-card::before { background: linear-gradient(90deg, var(--map-color), transparent); }
/* Glow effect behind cards */
.vital-card::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 200px;
height: 200px;
border-radius: 50%;
transform: translate(-50%, -50%);
opacity: 0;
transition: opacity 0.6s;
pointer-events: none;
z-index: 0;
}
.vital-card.hr::after { background: radial-gradient(circle, var(--hr-glow), transparent 70%); }
.vital-card.spo2::after { background: radial-gradient(circle, var(--spo2-glow), transparent 70%); }
.vital-card.bp::after { background: radial-gradient(circle, var(--bp-glow), transparent 70%); }
.vital-card.rr::after { background: radial-gradient(circle, var(--rr-glow), transparent 70%); }
.vital-card.temp::after { background: radial-gradient(circle, var(--temp-glow), transparent 70%); }
.vital-card.map-card::after { background: radial-gradient(circle, var(--map-glow), transparent 70%); }
.vital-card.has-value::after {
opacity: 0.15;
}
.vital-card.has-value:hover::after {
opacity: 0.3;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
position: relative;
z-index: 1;
}
.card-label {
display: flex;
align-items: center;
gap: 10px;
}
.card-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.vital-card.hr .card-icon { background: rgba(239, 68, 68, 0.15); }
.vital-card.spo2 .card-icon { background: rgba(6, 182, 212, 0.15); }
.vital-card.bp .card-icon { background: rgba(168, 85, 247, 0.15); }
.vital-card.rr .card-icon { background: rgba(34, 197, 94, 0.15); }
.vital-card.temp .card-icon { background: rgba(249, 115, 22, 0.15); }
.vital-card.map-card .card-icon { background: rgba(236, 72, 153, 0.15); }
.card-name {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-secondary);
}
.vital-card.hr .card-name { color: var(--hr-color); }
.vital-card.spo2 .card-name { color: var(--spo2-color); }
.vital-card.bp .card-name { color: var(--bp-color); }
.vital-card.rr .card-name { color: var(--rr-color); }
.vital-card.temp .card-name { color: var(--temp-color); }
.vital-card.map-card .card-name { color: var(--map-color); }
.status-badge {
padding: 4px 10px;
border-radius: 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-badge.normal {
background: rgba(34, 197, 94, 0.15);
color: var(--status-ok);
}
.status-badge.warning {
background: rgba(234, 179, 8, 0.15);
color: var(--status-warn);
animation: warningFlash 2s ease-in-out infinite;
}
.status-badge.critical {
background: rgba(239, 68, 68, 0.15);
color: var(--status-critical);
animation: criticalFlash 1s ease-in-out infinite;
}
.status-badge.offline {
background: rgba(100, 116, 139, 0.1);
color: var(--status-offline);
}
@keyframes warningFlash {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
@keyframes criticalFlash {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* === Vital Value Display === */
.vital-value-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
min-height: 100px;
}
.vital-value {
font-family: 'JetBrains Mono', monospace;
font-size: 64px;
font-weight: 700;
line-height: 1;
letter-spacing: -2px;
transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.vital-card.hr .vital-value { color: var(--hr-color); text-shadow: 0 0 30px var(--hr-glow); }
.vital-card.spo2 .vital-value { color: var(--spo2-color); text-shadow: 0 0 30px var(--spo2-glow); }
.vital-card.bp .vital-value { color: var(--bp-color); text-shadow: 0 0 30px var(--bp-glow); }
.vital-card.rr .vital-value { color: var(--rr-color); text-shadow: 0 0 30px var(--rr-glow); }
.vital-card.temp .vital-value { color: var(--temp-color); text-shadow: 0 0 30px var(--temp-glow); }
.vital-card.map-card .vital-value { color: var(--map-color); text-shadow: 0 0 30px var(--map-glow); }
.vital-value.no-data {
font-size: 48px;
color: var(--text-dim) !important;
text-shadow: none !important;
}
.vital-value .unit {
font-size: 20px;
font-weight: 400;
letter-spacing: 0;
margin-left: 4px;
opacity: 0.6;
}
.vital-value .slash {
font-size: 48px;
opacity: 0.4;
margin: 0 2px;
}
.vital-value-update {
animation: valueFlash 0.6s ease-out;
}
@keyframes valueFlash {
0% { transform: scale(1.08); filter: brightness(1.4); }
100% { transform: scale(1); filter: brightness(1); }
}
/* === Waveform Canvas === */
.waveform-container {
height: 50px;
margin-top: 12px;
position: relative;
z-index: 1;
overflow: hidden;
border-radius: 8px;
}
.waveform-container canvas {
width: 100%;
height: 100%;
display: block;
}
/* === Normal Range Footer === */
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border-color);
position: relative;
z-index: 1;
}
.normal-range {
font-size: 12px;
color: var(--text-dim);
}
.last-update {
font-size: 11px;
color: var(--text-dim);
font-family: 'JetBrains Mono', monospace;
}
/* === Info Bar === */
.info-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: var(--bg-card);
backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
border-radius: 14px;
margin-top: 20px;
flex-wrap: wrap;
gap: 12px;
}
.info-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-secondary);
}
.info-item strong {
color: var(--text-primary);
font-weight: 600;
}
.info-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-dim);
}
.model-badge {
padding: 4px 12px;
border-radius: 8px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
background: linear-gradient(135deg, rgba(6, 182, 212, 0.15), rgba(168, 85, 247, 0.15));
color: var(--spo2-color);
border: 1px solid rgba(6, 182, 212, 0.2);
}
/* === Responsive === */
@media (max-width: 1024px) {
.vitals-grid {
grid-template-columns: repeat(2, 1fr);
}
.setup-guide-body {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.app-container { padding: 12px; }
.vitals-grid {
grid-template-columns: 1fr;
}
.header {
flex-direction: column;
gap: 12px;
text-align: center;
}
.header-right {
flex-wrap: wrap;
justify-content: center;
}
.vital-value { font-size: 48px; }
.setup-guide-body {
grid-template-columns: 1fr;
}
}
/* === Scrollbar === */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; }
</style>
</head>
<body>
<div class="bg-grid"></div>
<div class="bg-glow"></div>
<div class="app-container">
<!-- Header -->
<header class="header" id="header">
<div class="header-left">
<div class="header-icon">🏥</div>
<div>
<div class="header-title">Patient Vital Signs Monitor</div>
<div class="header-subtitle">Contec CMS7000PLUS — Real-time Monitoring</div>
</div>
</div>
<div class="header-right">
<div class="audio-toggle" id="audioToggle" onclick="toggleAudioAlert()" title="Toggle audio alerts for critical vitals">
<span id="audioIcon">🔇</span>
<span id="audioText">Alerts Off</span>
</div>
<div class="connection-badge offline" id="connectionBadge">
<div class="status-dot"></div>
<span id="connectionText">Connecting...</span>
</div>
<div class="time-display" id="timeDisplay">--:--:--</div>
</div>
</header>
<!-- Setup Guide -->
<div class="setup-guide" id="setupGuide">
<div class="setup-guide-header" onclick="toggleSetupGuide()">
<div class="setup-guide-title">
<span>📡</span>
<span>CMS7000PLUS Setup Guide — Configure Your Monitor</span>
</div>
<div class="setup-guide-toggle" id="setupToggleText">▾ Collapse</div>
</div>
<div class="setup-guide-body" id="setupGuideBody">
<div class="setup-step">
<div class="step-number">1</div>
<div class="step-content">
<h4>Connect via Ethernet</h4>
<p>Connect your CMS7000PLUS to this PC using an Ethernet cable (direct or through a network switch).</p>
</div>
</div>
<div class="setup-step">
<div class="step-number">2</div>
<div class="step-content">
<h4>Set PC IP Address</h4>
<p>Set your PC's Ethernet adapter to a static IP, e.g. <code>192.168.1.50</code>, subnet <code>255.255.255.0</code>.</p>
</div>
</div>
<div class="setup-step">
<div class="step-number">3</div>
<div class="step-content">
<h4>Configure Monitor CMS Settings</h4>
<p>On your CMS7000PLUS: <code>System Setup → Network → CMS Settings</code>. Set the Server IP to your PC's IP.</p>
</div>
</div>
<div class="setup-step">
<div class="step-number">4</div>
<div class="step-content">
<h4>Set Server Port</h4>
<p>Set Server Port to <code>511</code> (default). If running without admin, use a port &gt;1024 like <code>6060</code>.</p>
</div>
</div>
<div class="setup-step">
<div class="step-number">5</div>
<div class="step-content">
<h4>Enable CMS Connection</h4>
<p>Enable the CMS/Central Monitor connection on your CMS7000PLUS. Set the sending interval (e.g., 5 seconds).</p>
</div>
</div>
<div class="setup-step">
<div class="step-number">6</div>
<div class="step-content">
<h4>Data Will Appear Automatically</h4>
<p>Once connected, vital signs will appear on this dashboard in real-time via WebSocket or polling.</p>
</div>
</div>
<div class="setup-ip-display">
<div>
<div class="setup-ip-label">🖥️ Your PC's Detected IP Address (set this on the monitor)</div>
</div>
<div class="setup-ip-value" id="detectedIp">Detecting...</div>
</div>
</div>
</div>
<!-- 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">❤️</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">🫁</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">🩸</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">🌬️</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">🌡️</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.137.2 °C</span>
<span class="last-update" id="update-temp">--</span>
</div>
</div>
<!-- MAP -->
<div class="vital-card map-card" id="card-map">
<div class="card-header">
<div class="card-label">
<div class="card-icon">💓</div>
<span class="card-name">Mean Arterial Pressure</span>
</div>
<span class="status-badge offline" id="status-map">No Data</span>
</div>
<div class="vital-value-container">
<div class="vital-value no-data" id="value-map">---</div>
</div>
<div class="waveform-container">
<canvas id="waveform-map"></canvas>
</div>
<div class="card-footer">
<span class="normal-range">Normal: 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: 36.1, high: 37.2, critLow: 35.0, critHigh: 39.0 },
};
// === State ===
let lastReadingId = null;
let readingCount = 0;
let waveformData = {
hr: [], spo2: [], bp: [], rr: [], temp: [], map: []
};
let waveformCanvases = {};
let useWebSocket = false;
let ws = null;
let audioAlertEnabled = false;
let audioContext = null;
let setupGuideCollapsed = false;
let hasReceivedData = false;
// === Initialize ===
document.addEventListener('DOMContentLoaded', () => {
initWaveforms();
updateClock();
setInterval(updateClock, 1000);
tryWebSocket();
startPolling();
animateWaveforms();
fetchNetworkInfo();
});
// === Fetch Network Info ===
async function fetchNetworkInfo() {
try {
const response = await fetch(`${API_BASE}/network-info`);
if (response.ok) {
const info = await response.json();
const ipEl = document.getElementById('detectedIp');
if (info.primary_ip) {
ipEl.textContent = info.primary_ip;
} else if (info.ip_addresses && info.ip_addresses.length > 0) {
ipEl.textContent = info.ip_addresses.join(' / ');
} else {
ipEl.textContent = 'Run ipconfig to find your IP';
}
// Update model badge
if (info.monitor_model) {
document.getElementById('modelBadge').textContent = info.monitor_model;
}
}
} catch (e) {
document.getElementById('detectedIp').textContent = 'Check ipconfig';
}
}
// === Setup Guide Toggle ===
function toggleSetupGuide() {
const body = document.getElementById('setupGuideBody');
const toggleText = document.getElementById('setupToggleText');
setupGuideCollapsed = !setupGuideCollapsed;
if (setupGuideCollapsed) {
body.style.display = 'none';
toggleText.textContent = '▸ Expand';
} else {
body.style.display = 'grid';
toggleText.textContent = '▾ Collapse';
}
}
// === Audio Alert Toggle ===
function toggleAudioAlert() {
audioAlertEnabled = !audioAlertEnabled;
const toggle = document.getElementById('audioToggle');
const icon = document.getElementById('audioIcon');
const text = document.getElementById('audioText');
if (audioAlertEnabled) {
toggle.classList.add('active');
icon.textContent = '🔊';
text.textContent = 'Alerts On';
// Initialize audio context on user gesture
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
} else {
toggle.classList.remove('active');
icon.textContent = '🔇';
text.textContent = 'Alerts Off';
}
}
function playAlertBeep() {
if (!audioAlertEnabled || !audioContext) return;
try {
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(880, audioContext.currentTime);
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.5);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.5);
// Second beep
const osc2 = audioContext.createOscillator();
const gain2 = audioContext.createGain();
osc2.connect(gain2);
gain2.connect(audioContext.destination);
osc2.type = 'sine';
osc2.frequency.setValueAtTime(1100, audioContext.currentTime + 0.6);
gain2.gain.setValueAtTime(0.3, audioContext.currentTime + 0.6);
gain2.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 1.1);
osc2.start(audioContext.currentTime + 0.6);
osc2.stop(audioContext.currentTime + 1.1);
} catch (e) {
// Audio not supported
}
}
// === Clock ===
function updateClock() {
const now = new Date();
document.getElementById('timeDisplay').textContent =
now.toLocaleTimeString('en-US', { hour12: false });
}
// === WebSocket Connection ===
function tryWebSocket() {
try {
ws = new WebSocket(WS_URL);
ws.onopen = () => {
useWebSocket = true;
updateConnectionStatus('connected', 'Live — WebSocket');
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
updateVitals(data);
};
ws.onclose = () => {
useWebSocket = false;
updateConnectionStatus('polling', 'Polling Mode');
setTimeout(tryWebSocket, 5000);
};
ws.onerror = () => {
useWebSocket = false;
};
} catch (e) {
useWebSocket = false;
}
}
// === Polling Fallback ===
function startPolling() {
fetchLatestReadings();
setInterval(fetchLatestReadings, POLL_INTERVAL);
}
async function fetchLatestReadings() {
try {
const response = await fetch(`${API_BASE}/latest-readings?limit=1`);
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 ✓';
}
} 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 });
// 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>