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');
|
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
|
// CANVAS WAVEFORM ENGINE
|
||||||
// Continuous right-to-left scrolling sweep
|
// Continuous right-to-left scrolling sweep
|
||||||
// ════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════
|
||||||
class WaveformTrack {
|
class WaveformTrack {
|
||||||
constructor(canvasId, color, baseline=0.5, amplitude=0.38) {
|
constructor(canvasId, color, mode='ecg') {
|
||||||
this.canvas = document.getElementById(canvasId);
|
this.canvas = document.getElementById(canvasId);
|
||||||
this.ctx = this.canvas.getContext('2d');
|
this.ctx = this.canvas.getContext('2d');
|
||||||
this.color = color;
|
this.color = color;
|
||||||
this.baseline = baseline; // 0..1 from top
|
this.mode = mode; // 'ecg' | 'pleth' | 'resp'
|
||||||
this.amplitude = amplitude; // fraction of height
|
this.queue = []; // Y-normalised values pending display
|
||||||
this.samples = []; // raw 0-255 bytes from monitor
|
this.drawBuf= []; // Y-normalised values for each pixel column
|
||||||
this.drawBuf = []; // processed display values (0..1 Y position)
|
this.sweepPos = 0;
|
||||||
this.MAX_DRAW = 600; // max points in draw buffer
|
this.lastW = 0; this.lastH = 0;
|
||||||
this.sweepPos = 0; // current write head X pixel
|
|
||||||
this.lastWidth = 0;
|
|
||||||
this.lastHeight= 0;
|
|
||||||
this.animating = false;
|
|
||||||
this.synthPhase = 0;
|
this.synthPhase = 0;
|
||||||
this.synthType = 'flat';
|
|
||||||
this.rrValue = null;
|
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() {
|
resize() {
|
||||||
const w = this.canvas.offsetWidth;
|
const w = this.canvas.offsetWidth;
|
||||||
const h = this.canvas.offsetHeight;
|
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.width = w;
|
||||||
this.canvas.height = h;
|
this.canvas.height = h;
|
||||||
this.lastWidth = w; this.lastHeight = h;
|
this.lastW = w; this.lastH = h;
|
||||||
this.drawBuf = new Array(w).fill(null);
|
this.drawBuf = new Array(w).fill(null);
|
||||||
this.sweepPos = 0;
|
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) {
|
pushSamples(arr) {
|
||||||
if (!arr || arr.length === 0) return;
|
if (!arr || arr.length === 0) return;
|
||||||
// normalise 0-255 → 0..1 Y position (inverted: 0=top)
|
|
||||||
for (const s of arr) {
|
for (const s of arr) {
|
||||||
const norm = (255 - s) / 255; // flip: high byte = up
|
this.queue.push(this._normalise(s));
|
||||||
const y = this.baseline + (norm - 0.5) * this.amplitude * 2;
|
}
|
||||||
this.samples.push(Math.max(0.02, Math.min(0.98, y)));
|
// 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() {
|
tick() {
|
||||||
const w = this.canvas.width;
|
const w = this.canvas.width;
|
||||||
const h = this.canvas.height;
|
if (!w) return;
|
||||||
if (!w || !h) return;
|
|
||||||
|
|
||||||
// blank 6px ahead of sweep cursor
|
// Erase 6px ahead of write head
|
||||||
const eraseW = 8;
|
for (let e = 1; e <= 6; e++) {
|
||||||
for (let e = 0; e < eraseW; e++) {
|
this.drawBuf[(this.sweepPos + e) % w] = null;
|
||||||
const ex = (this.sweepPos + e) % w;
|
|
||||||
if (this.drawBuf[ex] !== undefined) this.drawBuf[ex] = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let yNorm;
|
const yNorm = this.queue.length > 0 ? this.queue.shift() : this._synth();
|
||||||
if (this.samples.length > 0) {
|
|
||||||
yNorm = this.samples.shift();
|
|
||||||
} else {
|
|
||||||
// synthetic fallback
|
|
||||||
yNorm = this._synth();
|
|
||||||
}
|
|
||||||
this.drawBuf[this.sweepPos] = yNorm;
|
this.drawBuf[this.sweepPos] = yNorm;
|
||||||
this.sweepPos = (this.sweepPos + 1) % w;
|
this.sweepPos = (this.sweepPos + 1) % w;
|
||||||
}
|
}
|
||||||
|
|
||||||
_synth() {
|
_synth() {
|
||||||
this.synthPhase += 0.05;
|
const m = this.mode;
|
||||||
|
this.synthPhase += 0.04;
|
||||||
const t = this.synthPhase;
|
const t = this.synthPhase;
|
||||||
if (this.synthType === 'ecg') {
|
if (m === 'ecg') {
|
||||||
const p = (t % (Math.PI * 2)) / (Math.PI * 2);
|
// Realistic P-QRS-T morphology
|
||||||
if (p > 0.35 && p < 0.38) return this.baseline - 0.32;
|
const period = Math.PI * 2;
|
||||||
if (p > 0.38 && p < 0.42) return this.baseline + 0.12;
|
const p = ((t % period) / period);
|
||||||
return this.baseline + Math.sin(t * 0.3) * 0.02;
|
if (p > 0.40 && p < 0.43) return 0.5 - 0.38; // R peak (up)
|
||||||
} else if (this.synthType === 'pleth') {
|
if (p > 0.43 && p < 0.46) return 0.5 + 0.14; // S trough (down)
|
||||||
return this.baseline + Math.sin(t * 0.8) * 0.28;
|
if (p > 0.30 && p < 0.36) return 0.5 - 0.06; // P wave
|
||||||
} else if (this.synthType === 'resp') {
|
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 rr = this.rrValue || 15;
|
||||||
const speed = rr / 60 * 0.025;
|
const speed = (rr / 60) * 0.30;
|
||||||
return this.baseline + Math.sin(this.synthPhase * speed * 20) * 0.22;
|
return 0.5 + Math.sin(t * speed) * 0.28;
|
||||||
}
|
}
|
||||||
return this.baseline; // flat
|
|
||||||
}
|
}
|
||||||
|
|
||||||
draw() {
|
draw() {
|
||||||
@@ -363,44 +380,43 @@ class WaveformTrack {
|
|||||||
const h = this.canvas.height;
|
const h = this.canvas.height;
|
||||||
if (!w || !h) return;
|
if (!w || !h) return;
|
||||||
|
|
||||||
// Clear
|
|
||||||
ctx.clearRect(0, 0, w, h);
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
// Grid lines (subtle)
|
// ECG-paper style grid
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
|
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
|
||||||
ctx.lineWidth = 1;
|
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();
|
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();
|
ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Waveform
|
// Waveform line
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.strokeStyle = this.color;
|
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.shadowColor = this.color;
|
||||||
ctx.shadowBlur = 6;
|
ctx.shadowBlur = (this.mode === 'ecg') ? 5 : 8;
|
||||||
|
|
||||||
let started = false;
|
let started = false;
|
||||||
for (let x = 0; x < w; x++) {
|
for (let x = 0; x < w; x++) {
|
||||||
const idx = (this.sweepPos + x) % w;
|
const idx = (this.sweepPos + x) % w;
|
||||||
const yNorm = this.drawBuf[idx];
|
const yv = this.drawBuf[idx];
|
||||||
if (yNorm === null || yNorm === undefined) {
|
if (yv === null || yv === undefined) { started = false; continue; }
|
||||||
started = false; continue;
|
const py = yv * h;
|
||||||
}
|
if (!started) { ctx.moveTo(x, py); started = true; }
|
||||||
const px = x;
|
else { ctx.lineTo(x, py); }
|
||||||
const py = yNorm * h;
|
|
||||||
if (!started) { ctx.moveTo(px, py); started = true; }
|
|
||||||
else { ctx.lineTo(px, py); }
|
|
||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.shadowBlur = 0;
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
// Sweep cursor (black erase bar)
|
// Sweep erase bar
|
||||||
ctx.fillStyle = 'rgba(10,14,10,0.85)';
|
ctx.fillStyle = 'rgba(10,14,10,0.92)';
|
||||||
ctx.fillRect(((this.sweepPos + w - 4) % w), 0, 6, h);
|
ctx.fillRect(((this.sweepPos - 4 + w) % w), 0, 8, h);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user