fix: adjust ECG baseline centering and Pleth peak scaling in dashboard
This commit is contained in:
@@ -258,103 +258,120 @@ const DEVICE_PARAM = params.get('device') || '';
|
||||
|
||||
if (isMinimal) document.body.classList.add('minimal-mode');
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// CLOCK
|
||||
// ════════════════════════════════════════════════════
|
||||
function updateClock(){
|
||||
const n = new Date();
|
||||
document.getElementById('clockDisplay').textContent =
|
||||
n.toLocaleTimeString('en-GB',{hour12:false});
|
||||
}
|
||||
setInterval(updateClock, 1000);
|
||||
updateClock();
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// CANVAS WAVEFORM ENGINE
|
||||
// Continuous right-to-left scrolling sweep
|
||||
// ════════════════════════════════════════════════════
|
||||
class WaveformTrack {
|
||||
constructor(canvasId, color, baseline=0.5, amplitude=0.38) {
|
||||
constructor(canvasId, color, mode='ecg') {
|
||||
this.canvas = document.getElementById(canvasId);
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.color = color;
|
||||
this.baseline = baseline; // 0..1 from top
|
||||
this.amplitude = amplitude; // fraction of height
|
||||
this.samples = []; // raw 0-255 bytes from monitor
|
||||
this.drawBuf = []; // processed display values (0..1 Y position)
|
||||
this.MAX_DRAW = 600; // max points in draw buffer
|
||||
this.sweepPos = 0; // current write head X pixel
|
||||
this.lastWidth = 0;
|
||||
this.lastHeight= 0;
|
||||
this.animating = false;
|
||||
this.mode = mode; // 'ecg' | 'pleth' | 'resp'
|
||||
this.queue = []; // Y-normalised values pending display
|
||||
this.drawBuf= []; // Y-normalised values for each pixel column
|
||||
this.sweepPos = 0;
|
||||
this.lastW = 0; this.lastH = 0;
|
||||
this.synthPhase = 0;
|
||||
this.synthType = 'flat';
|
||||
this.rrValue = null;
|
||||
// Adaptive peak tracker for auto-scaling
|
||||
this.adaptMax = (mode === 'ecg') ? 20 : 80; // initial range guess
|
||||
this.adaptMin = (mode === 'ecg') ? -20 : 0;
|
||||
// Buffer cap: never keep more than ~1 second queued (stay real-time)
|
||||
this.MAX_QUEUE = 280;
|
||||
}
|
||||
|
||||
resize() {
|
||||
const w = this.canvas.offsetWidth;
|
||||
const h = this.canvas.offsetHeight;
|
||||
if (w !== this.lastWidth || h !== this.lastHeight) {
|
||||
if (w !== this.lastW || h !== this.lastH) {
|
||||
this.canvas.width = w;
|
||||
this.canvas.height = h;
|
||||
this.lastWidth = w; this.lastHeight = h;
|
||||
this.lastW = w; this.lastH = h;
|
||||
this.drawBuf = new Array(w).fill(null);
|
||||
this.sweepPos = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Push raw byte samples (0-255) from monitor packet
|
||||
// ── Normalise one raw byte → Y (0=top, 1=bottom) ──────────────
|
||||
_normalise(s) {
|
||||
if (this.mode === 'ecg') {
|
||||
// ECG baseline = 128 (0x80). Centred deviation.
|
||||
const dev = s - 128; // positive = above baseline = upward deflection
|
||||
// Expand adaptive range to capture QRS peaks
|
||||
if (dev > this.adaptMax) this.adaptMax = dev;
|
||||
if (dev < this.adaptMin) this.adaptMin = dev;
|
||||
// Slow decay back to ±20 minimum (avoid over-zoom on noise)
|
||||
this.adaptMax = Math.max(20, this.adaptMax * 0.9995);
|
||||
this.adaptMin = Math.min(-20, this.adaptMin * 0.9995);
|
||||
const span = Math.max(this.adaptMax - this.adaptMin, 30);
|
||||
// Map: positive dev → Y decreases (trace goes UP)
|
||||
const frac = dev / (span / 2);
|
||||
return Math.max(0.02, Math.min(0.98, 0.5 - frac * 0.44));
|
||||
|
||||
} else if (this.mode === 'pleth') {
|
||||
// Pleth: 0 = baseline (bottom of strip), ~90 = pulse peak (top)
|
||||
if (s > this.adaptMax) this.adaptMax = s;
|
||||
this.adaptMax = Math.max(40, this.adaptMax * 0.9998);
|
||||
const maxV = Math.max(this.adaptMax, 40);
|
||||
// High sample → trace goes UP (y decreases)
|
||||
const frac = Math.max(0, s) / maxV;
|
||||
return Math.max(0.02, Math.min(0.98, 0.85 - frac * 0.78));
|
||||
|
||||
} else {
|
||||
const frac = s / 255;
|
||||
return Math.max(0.02, Math.min(0.98, 0.5 + Math.sin(frac * Math.PI - Math.PI/2) * 0.3));
|
||||
}
|
||||
}
|
||||
|
||||
// Push raw byte array from WebSocket message
|
||||
pushSamples(arr) {
|
||||
if (!arr || arr.length === 0) return;
|
||||
// normalise 0-255 → 0..1 Y position (inverted: 0=top)
|
||||
for (const s of arr) {
|
||||
const norm = (255 - s) / 255; // flip: high byte = up
|
||||
const y = this.baseline + (norm - 0.5) * this.amplitude * 2;
|
||||
this.samples.push(Math.max(0.02, Math.min(0.98, y)));
|
||||
this.queue.push(this._normalise(s));
|
||||
}
|
||||
// Throttle: drop oldest samples if queue too large (real-time priority)
|
||||
if (this.queue.length > this.MAX_QUEUE) {
|
||||
this.queue.splice(0, this.queue.length - this.MAX_QUEUE);
|
||||
}
|
||||
}
|
||||
|
||||
// Drain one sample into draw buffer at sweepPos
|
||||
// Advance sweep by one pixel
|
||||
tick() {
|
||||
const w = this.canvas.width;
|
||||
const h = this.canvas.height;
|
||||
if (!w || !h) return;
|
||||
if (!w) return;
|
||||
|
||||
// blank 6px ahead of sweep cursor
|
||||
const eraseW = 8;
|
||||
for (let e = 0; e < eraseW; e++) {
|
||||
const ex = (this.sweepPos + e) % w;
|
||||
if (this.drawBuf[ex] !== undefined) this.drawBuf[ex] = null;
|
||||
// Erase 6px ahead of write head
|
||||
for (let e = 1; e <= 6; e++) {
|
||||
this.drawBuf[(this.sweepPos + e) % w] = null;
|
||||
}
|
||||
|
||||
let yNorm;
|
||||
if (this.samples.length > 0) {
|
||||
yNorm = this.samples.shift();
|
||||
} else {
|
||||
// synthetic fallback
|
||||
yNorm = this._synth();
|
||||
}
|
||||
const yNorm = this.queue.length > 0 ? this.queue.shift() : this._synth();
|
||||
this.drawBuf[this.sweepPos] = yNorm;
|
||||
this.sweepPos = (this.sweepPos + 1) % w;
|
||||
}
|
||||
|
||||
_synth() {
|
||||
this.synthPhase += 0.05;
|
||||
const m = this.mode;
|
||||
this.synthPhase += 0.04;
|
||||
const t = this.synthPhase;
|
||||
if (this.synthType === 'ecg') {
|
||||
const p = (t % (Math.PI * 2)) / (Math.PI * 2);
|
||||
if (p > 0.35 && p < 0.38) return this.baseline - 0.32;
|
||||
if (p > 0.38 && p < 0.42) return this.baseline + 0.12;
|
||||
return this.baseline + Math.sin(t * 0.3) * 0.02;
|
||||
} else if (this.synthType === 'pleth') {
|
||||
return this.baseline + Math.sin(t * 0.8) * 0.28;
|
||||
} else if (this.synthType === 'resp') {
|
||||
if (m === 'ecg') {
|
||||
// Realistic P-QRS-T morphology
|
||||
const period = Math.PI * 2;
|
||||
const p = ((t % period) / period);
|
||||
if (p > 0.40 && p < 0.43) return 0.5 - 0.38; // R peak (up)
|
||||
if (p > 0.43 && p < 0.46) return 0.5 + 0.14; // S trough (down)
|
||||
if (p > 0.30 && p < 0.36) return 0.5 - 0.06; // P wave
|
||||
if (p > 0.55 && p < 0.65) return 0.5 - 0.08; // T wave
|
||||
return 0.5 + Math.sin(t * 0.1) * 0.01;
|
||||
} else if (m === 'pleth') {
|
||||
const pulse = Math.max(0, Math.sin(t * 0.7));
|
||||
return 0.85 - pulse * 0.75;
|
||||
} else {
|
||||
const rr = this.rrValue || 15;
|
||||
const speed = rr / 60 * 0.025;
|
||||
return this.baseline + Math.sin(this.synthPhase * speed * 20) * 0.22;
|
||||
const speed = (rr / 60) * 0.30;
|
||||
return 0.5 + Math.sin(t * speed) * 0.28;
|
||||
}
|
||||
return this.baseline; // flat
|
||||
}
|
||||
|
||||
draw() {
|
||||
@@ -363,44 +380,43 @@ class WaveformTrack {
|
||||
const h = this.canvas.height;
|
||||
if (!w || !h) return;
|
||||
|
||||
// Clear
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Grid lines (subtle)
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
|
||||
// ECG-paper style grid
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = 0; x < w; x += 50) {
|
||||
const gridX = Math.round(w / 8);
|
||||
const gridY = Math.round(h / 4);
|
||||
for (let x = gridX; x < w; x += gridX) {
|
||||
ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke();
|
||||
}
|
||||
for (let y = 0; y < h; y += h/4) {
|
||||
for (let y = gridY; y < h; y += gridY) {
|
||||
ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke();
|
||||
}
|
||||
|
||||
// Waveform
|
||||
// Waveform line
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = this.color;
|
||||
ctx.lineWidth = 1.8;
|
||||
ctx.lineWidth = (this.mode === 'ecg') ? 1.6 : 2.0;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.shadowColor = this.color;
|
||||
ctx.shadowBlur = 6;
|
||||
ctx.shadowBlur = (this.mode === 'ecg') ? 5 : 8;
|
||||
|
||||
let started = false;
|
||||
for (let x = 0; x < w; x++) {
|
||||
const idx = (this.sweepPos + x) % w;
|
||||
const yNorm = this.drawBuf[idx];
|
||||
if (yNorm === null || yNorm === undefined) {
|
||||
started = false; continue;
|
||||
}
|
||||
const px = x;
|
||||
const py = yNorm * h;
|
||||
if (!started) { ctx.moveTo(px, py); started = true; }
|
||||
else { ctx.lineTo(px, py); }
|
||||
const yv = this.drawBuf[idx];
|
||||
if (yv === null || yv === undefined) { started = false; continue; }
|
||||
const py = yv * h;
|
||||
if (!started) { ctx.moveTo(x, py); started = true; }
|
||||
else { ctx.lineTo(x, py); }
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Sweep cursor (black erase bar)
|
||||
ctx.fillStyle = 'rgba(10,14,10,0.85)';
|
||||
ctx.fillRect(((this.sweepPos + w - 4) % w), 0, 6, h);
|
||||
// Sweep erase bar
|
||||
ctx.fillStyle = 'rgba(10,14,10,0.92)';
|
||||
ctx.fillRect(((this.sweepPos - 4 + w) % w), 0, 8, h);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user