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