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.
2
Answers
I see your problem—you’re dealing with choppy, non-continuous chart updates because of gaps in the WebSocket data. Here’s how I think you can solve it:
Keep the X-Axis Moving Smoothly: Use
setInterval
to advance the X-axis at a consistent rate, so the chart keeps flowing naturally, even if data pauses briefly.andle Data Gaps Smartly: When new data arrives after a delay, calculate the gap and adjust the starting point of the Y-axis data to match where the X-axis is now. That way, you won’t see abrupt jumps from old positions.
Leave Blank Spaces Where Data’s Missing: Instead of trying to fill in missing data, just let gaps show on the chart. When data resumes, it’ll plot at the current X position, so the chart looks continuous without forced connections.
Sync Across Devices: To keep everything aligned, log timestamps of data on the server, and each client can use this to calculate any delay and adjust accordingly.
With this setup, your chart should appear smooth and real-time, with visible gaps for any missing data, and it’ll stay in sync on different devices.
You can try to scroll the X-axis every second irrespective of whether or not you receive web socket data using a timer. Then update x position in handleincomingdata by calculating the current X position based on the last time data was received and the idea that X axis is updated every second. Sorry I am unable to provide code as I am not a JavaScript expert.