I am currently using Medical Dashboard from LightningchartJS which has 4 channels: ECG, Pulse Rate, Respiratory rate, Blood pressure and am using websocket for live feed data into the charts.
But I have observed a delay in receiving the data from socket connection. Typical time difference between two data set received in 1 second and goes up to 5 seconds. This actually displays the charts with lag since both X and Y axis values are pushed only when handleIncomingData()
gets executed, which is done on socket.onMessage()
.
So every second data is received and its plotted one-by-one or step-by-step which gives a sense of lag. If no data is received for 5 seconds (say), chart stops.
Expected behaviour: I want continuous flow of X axis irrespective of Y values, so that its appealing to eyes and when data is received from socket, plot it accordingly as per sampling rate. For the delay in receiving the data, no chart should be plotted, i.e, gaps will be there in chart which I am okay with (for now).
I tried to implement the same but have found unexpected behaviour. I have faced a dead end, please help.
Below is my current implementation:
import {
emptyFill,
emptyLine,
UIOrigins,
UILayoutBuilders,
UIElementBuilders,
AxisTickStrategies,
AxisScrollStrategies,
synchronizeAxisIntervals,
} from "@lightningchart/lcjs";
import { useEffect, useRef } from "react";
import { useSelector } from "react-redux";
import { iChannel } from "../../interfaces";
import { WEBSOCKET_URL } from "../../utils/constants";
import { generateRandomID, lc } from "../../utils/helperFunctions";
import "./PatientVitals.css";
const PatientVitals = () => {
let ecgInput: number[] = [];
let pulseRateInput: number[] = [];
let respRateInput: number[] = [];
const channels: iChannel[] = [
{
shortName: "ECG/EKG",
name: "Electrocardiogram",
type: "ecg",
dataSet: [],
yStart: -50,
yEnd: 160,
rate: 256,
},
{
shortName: "Pulse Rate",
name: "Pleth",
type: "pulse",
dataSet: [],
yStart: -200,
yEnd: 200,
rate: 256,
},
{
shortName: "Respiratory rate",
name: "Resp",
type: "resp",
dataSet: [],
yStart: -150,
yEnd: 150,
rate: 128,
},
{
shortName: "NIBP",
name: "Blood pressure",
type: "bloodPressure",
dataSet: [],
yStart: 50,
yEnd: 200,
rate: 256,
},
];
const TIME_DOMAIN = 10 * 1000;
const patientDetails = useSelector((state: any) => ({
patient_uhid: state.patient_uhid,
}));
const socketRef = useRef<WebSocket | null>(null);
const closeWebSocket = () => {
if (socketRef.current) {
socketRef.current.close();
console.log("WebSocket connection closed");
}
};
const createCharts = () => {
const layoutCharts = document.createElement("div");
layoutCharts.style.display = "flex";
layoutCharts.style.flexDirection = "column";
const chartList = channels?.map((_, i) => {
const container = document.createElement("div");
layoutCharts.append(container);
container.style.height = i === channels?.length - 1 ? "150px" : "220px";
const chart = lc
.ChartXY({ container })
.setPadding({ bottom: 4, top: 4, right: 140, left: 10 })
.setMouseInteractions(false)
.setCursorMode(undefined);
const axisX = chart.getDefaultAxisX().setMouseInteractions(false);
axisX
.setTickStrategy(AxisTickStrategies.Time)
.setInterval({ start: -TIME_DOMAIN, end: 0, stopAxisAfter: false })
.setScrollStrategy(AxisScrollStrategies.progressive);
if (i > 0) {
chart.setTitleFillStyle(emptyFill);
} else {
let tFpsStart = window.performance.now();
let frames = 0;
let fps = 0;
const recordFrame = () => {
frames++;
const tNow = window.performance.now();
fps = 1000 / ((tNow - tFpsStart) / frames);
requestAnimationFrame(recordFrame);
chart.setTitle(`Medical Dashboard (FPS: ${fps.toFixed(1)})`);
};
requestAnimationFrame(recordFrame);
setInterval(() => {
tFpsStart = window.performance.now();
frames = 0;
}, 5000);
}
return chart;
});
const uiList = chartList?.map((chart, i) => {
let labelEcgHeartRate;
let labelBpmValue;
let labelBloodPIValue;
let labelMinMaxBPValue;
let labelMeanBPValue;
let labelRespiratoryValue;
const axisX = chart.getDefaultAxisX();
const axisY = chart
.getDefaultAxisY()
.setMouseInteractions(false)
.setTickStrategy(AxisTickStrategies.Empty)
.setStrokeStyle(emptyLine);
const channel = channels[i];
const ui = chart
.addUIElement(UILayoutBuilders.Column, chart.coordsRelative)
.setBackground((background: any) =>
background.setFillStyle(emptyFill).setStrokeStyle(emptyLine)
)
.setMouseInteractions(false)
.setVisible(false);
ui.addElement(UIElementBuilders.TextBox).setText(channel.shortName);
ui.addElement(UIElementBuilders.TextBox)
.setText(channel.name)
.setTextFont((font) => font.setSize(10));
if (i !== channels.length - 1) {
ui.addElement(UIElementBuilders.TextBox)
.setText(`${channel.rate} samples/second`)
.setTextFont((font) => font.setSize(10));
}
if (channel.name === "Electrocardiogram") {
labelEcgHeartRate = ui
.addElement(UIElementBuilders.TextBox)
.setText("")
.setTextFont((font: any) => font.setSize(36))
.setMargin({ top: 10 });
}
if (channel.name === "Pleth") {
ui.addElement(UIElementBuilders.TextBox)
.setMargin({ top: 10 })
.setText("SPO2");
labelBpmValue = ui
.addElement(UIElementBuilders.TextBox)
.setText("")
.setTextFont((font: any) => font.setSize(36));
labelBloodPIValue = ui
.addElement(UIElementBuilders.TextBox)
.setText("")
.setTextFont((font: any) => font.setSize(12));
}
if (channel.name === "Blood pressure") {
labelMinMaxBPValue = ui
.addElement(UIElementBuilders.TextBox)
.setText("")
.setTextFont((font: any) => font.setSize(36));
labelMeanBPValue = ui
.addElement(UIElementBuilders.TextBox)
.setText("")
.setTextFont((font: any) => font.setSize(36));
}
if (channel.name === "Resp") {
labelRespiratoryValue = ui
.addElement(UIElementBuilders.TextBox)
.setText("")
.setTextFont((font: any) => font.setSize(36))
.setMargin({ top: 10 });
}
const positionUI = () => {
ui.setVisible(true)
.setPosition(
chart.translateCoordinate(
{ x: axisX.getInterval().end, y: axisY.getInterval().end },
{ x: axisX, y: axisY },
chart.coordsRelative
)
)
.setOrigin(UIOrigins.LeftTop);
requestAnimationFrame(positionUI);
};
requestAnimationFrame(positionUI);
return {
labelEcgHeartRate,
labelBpmValue,
labelBloodPIValue,
labelMinMaxBPValue,
labelMeanBPValue,
labelRespiratoryValue,
};
});
synchronizeAxisIntervals(
...chartList.map((chart) => chart.getDefaultAxisX())
);
const seriesList = chartList.map((chart, i) => {
const series = chart
.addPointLineAreaSeries({
dataPattern: "ProgressiveX",
automaticColorIndex: Math.max(i - 1, 0),
yAxis: chart.getDefaultAxisY(),
})
.setAreaFillStyle(emptyFill)
.setMaxSampleCount(100_000);
return series;
});
const handleIncomingData = (data: number[][]) => {
data?.forEach((dataCh, index) => {
const ch = seriesList[index];
ch.appendSamples({
yValues: dataCh,
step: 1000 / channels[index].rate,
});
});
};
createSocketConnection(handleIncomingData, channels, uiList);
const vitalGraphsContainer = document.getElementById("vitalGraphs");
vitalGraphsContainer?.replaceChildren(layoutCharts);
};
function createSocketConnection(handleIncomingData, channels, uiList) {
const randomID = generateRandomID(4);
const socket = new WebSocket(
`${WEBSOCKET_URL}`
);
socketRef.current = socket;
socket.onopen = function (event) {
console.log("WebSocket connection opened", event);
};
socket.onmessage = function (event) {
const message = JSON.parse(event.data);
console.log(message, new Date());
ecgInput = message?.ecg
?.split("^")
?.filter((item) => item < 1000)
?.map(Number);
pulseRateInput = message?.pulseRate
?.split("^")
?.filter((item) => item < 1000)
?.map(Number);
respRateInput = message?.respiratoryGraph
?.split("^")
?.filter((item) => item < 1000)
?.map(Number);
let ecgHeartRate: string = message?.ecgHeartRate;
let pusleRateValue: string = message?.pulseRateValue;
let systolicBpValue: string = message?.systolicBpValue;
let diastolicBpValue: string = message?.diastolicBpValue;
let meanBpValue: string = message?.meanBpValue;
let spo2: string = message?.spo2;
let respiratoryValue: string = message?.respiratoryValue;
let bloodPerforationIndex: string = message?.bloodPerforationIndex;
uiList?.forEach((ui) => {
if (ui.labelEcgHeartRate) {
const ecgOrPulseRate = ecgHeartRate || pusleRateValue;
if (ecgOrPulseRate) {
ui.labelEcgHeartRate.setText(ecgOrPulseRate.toString());
}
}
if (ui.labelBpmValue) {
if (spo2) {
ui.labelBpmValue.setText(spo2?.toString());
}
if (bloodPerforationIndex) {
ui.labelBloodPIValue.setText(
" PI: " + bloodPerforationIndex?.toString()
);
}
}
if (ui.labelMinMaxBPValue && systolicBpValue && diastolicBpValue) {
ui.labelMinMaxBPValue.setText(
systolicBpValue?.toString() + "/" + diastolicBpValue?.toString()
);
}
if (ui.labelMeanBPValue && meanBpValue) {
ui.labelMeanBPValue.setText(" (" + meanBpValue?.toString() + ")");
}
if (ui.labelRespiratoryValue && respiratoryValue) {
ui.labelRespiratoryValue.setText(respiratoryValue?.toString());
}
});
const chart_Inputs = [ecgInput, pulseRateInput, respRateInput];
handleIncomingData(channels?.map((_, index) => chart_Inputs[index]));
};
socket.onerror = function (event) {
console.log("WebSocket error observed:", event);
};
socket.onclose = function (event) {
console.log("Websocket closure code:", event.code);
if (event.code !== 1000 && event.code !== 1001) {
console.log(
"Websocket closed abnormally. Reconnecting to WebSocket server..."
);
createSocketConnection(handleIncomingData, channels, uiList);
}
};
}
useEffect(() => {
createCharts();
return () => {
closeWebSocket();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [patientDetails.patient_uhid]);
return <div id="vitalGraphs"></div>;
};
export default PatientVitals;
Version used: "@lightningchart/lcjs": "^6.0.3"
What I tried:
I tried bumping up X axis by default using setInterval, and plot y values when received data from socket. And it worked, but when stopped receiving data from socket, chart stops and X axis continues to flow. Now lets say 5 seconds later, again I started receiving the data, the chart starts plotting from the point it stopped. This is actually an issue. Lets say I start receiving the data after 2 mins or 10 mins, so it would start plotting from the point it ended, but that particular timestamp has already passed and is out of the view since X axis keeps on moving. So essentially no chart from an end user point of view.
Continuation to above:
I also tried to stop the X axis when stopped receiving the data (actually incrementing with at a very slow rate, so it looks that it stopped), but in this case chart synchronisation between multiple devices is hampered i.e, charts for a particular patient is not same in multiple devices.