diff --git a/src/Components/Assets/AssetConfigure.tsx b/src/Components/Assets/AssetConfigure.tsx index 63a27f2671f..24b7acdbf86 100644 --- a/src/Components/Assets/AssetConfigure.tsx +++ b/src/Components/Assets/AssetConfigure.tsx @@ -65,6 +65,22 @@ const AssetConfigure = (props: AssetConfigureProps) => { ); } + if (assetType === "VENTILATOR") { + return ( + + + + ); + } + return ( { - {assetType === "HL7MONITOR" && ( + {["HL7MONITOR", "VENTILATOR"].includes(assetType) && ( )} - + {assetType === "HL7MONITOR" && ( + + )} + {assetType === "VENTILATOR" && ( + + )} ); diff --git a/src/Components/Assets/AssetTypes.tsx b/src/Components/Assets/AssetTypes.tsx index bfd3c61dcd2..26664ad56a8 100644 --- a/src/Components/Assets/AssetTypes.tsx +++ b/src/Components/Assets/AssetTypes.tsx @@ -23,6 +23,7 @@ export enum AssetClass { NONE = "NONE", ONVIF = "ONVIF", HL7MONITOR = "HL7MONITOR", + VENTILATOR = "VENTILATOR", } export const assetClassProps = { diff --git a/src/Components/Facility/AssetCreate.tsx b/src/Components/Facility/AssetCreate.tsx index 07a7421ac42..597a96e0b7e 100644 --- a/src/Components/Facility/AssetCreate.tsx +++ b/src/Components/Facility/AssetCreate.tsx @@ -28,7 +28,6 @@ import useAppHistory from "../../Common/hooks/useAppHistory"; import CareIcon from "../../CAREUI/icons/CareIcon"; import { LocationSelect } from "../Common/LocationSelect"; import { FieldLabel } from "../Form/FormFields/FormField"; -import { useTranslation } from "react-i18next"; const Loading = loadable(() => import("../Common/Loading")); @@ -95,7 +94,6 @@ type AssetFormSection = const AssetCreate = (props: AssetProps) => { const { goBack } = useAppHistory(); - const { t } = useTranslation(); const { facilityId, assetId } = props; let assetTypeInitial: AssetType; @@ -587,8 +585,6 @@ const AssetCreate = (props: AssetProps) => { data-testid="asset-class-input" > { title: "HL7 Vitals Monitor", value: AssetClass.HL7MONITOR, }, + { + title: "Ventilator", + value: AssetClass.VENTILATOR, + }, ]} optionLabel={({ title }) => title} optionValue={({ value }) => value} diff --git a/src/Components/Facility/ConsultationDetails.tsx b/src/Components/Facility/ConsultationDetails.tsx index 8db3b9104cf..578e30dd2ef 100644 --- a/src/Components/Facility/ConsultationDetails.tsx +++ b/src/Components/Facility/ConsultationDetails.tsx @@ -5,10 +5,19 @@ import { OptionsType, SYMPTOM_CHOICES, } from "../../Common/constants"; -import { ConsultationModel, ICD11DiagnosisModel } from "./models"; -import { getConsultation, getPatient } from "../../Redux/actions"; +import { + ConsultationModel, + FacilityModel, + ICD11DiagnosisModel, +} from "./models"; +import { + getConsultation, + getPatient, + getPermittedFacility, + listAssetBeds, +} from "../../Redux/actions"; import { statusType, useAbortableEffect } from "../../Common/utils"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useDispatch } from "react-redux"; import { ABGPlots } from "./Consultations/ABGPlots"; import ButtonV2 from "../Common/components/ButtonV2"; @@ -27,7 +36,6 @@ import { NursingPlot } from "./Consultations/NursingPlot"; import { NutritionPlots } from "./Consultations/NutritionPlots"; import PatientInfoCard from "../Patient/PatientInfoCard"; import { PatientModel } from "../Patient/models"; -import LegacyPatientVitalsCard from "../Patient/LegacyPatientVitalsCard"; import { PressureSoreDiagrams } from "./Consultations/PressureSoreDiagrams"; import { PrimaryParametersPlot } from "./Consultations/PrimaryParametersPlot"; import ReadMore from "../Common/components/Readmore"; @@ -41,6 +49,10 @@ import { NonReadOnlyUsers } from "../../Utils/AuthorizeFor"; import PrescriptionsTable from "../Medicine/PrescriptionsTable"; import MedicineAdministrationsTable from "../Medicine/MedicineAdministrationsTable"; import DischargeSummaryModal from "./DischargeSummaryModal"; +import VentilatorPatientVitalsMonitor from "../VitalsMonitor/VentilatorPatientVitalsMonitor"; + +import { AssetBedModel, AssetClass } from "../Assets/AssetTypes"; +import HL7PatientVitalsMonitor from "../VitalsMonitor/HL7PatientVitalsMonitor"; const Loading = loadable(() => import("../Common/Loading")); const PageTitle = loadable(() => import("../Common/PageTitle")); @@ -431,14 +443,11 @@ export const ConsultationDetails = (props: any) => { {tab === "UPDATES" && (
-
+
{!consultationData.discharge_date && (
- +
)}
@@ -903,7 +912,7 @@ export const ConsultationDetails = (props: any) => {
-
+
@@ -1130,51 +1139,101 @@ export const ConsultationDetails = (props: any) => { ); }; -// TODO: make this thing responsive :/ -// const VitalsCard = ({ consultation }: { consultation: ConsultationModel }) => { -// const dispatch = useDispatch(); -// const [socketUrl, setSocketUrl] = useState(); +const VitalsCard = ({ consultation }: { consultation: ConsultationModel }) => { + const dispatch = useDispatch(); + const [loading, setLoading] = useState(true); + const [hl7SocketUrl, setHL7SocketUrl] = useState(); + const [ventilatorSocketUrl, setVentilatorSocketUrl] = useState(); -// useEffect(() => { -// if (!consultation.facility) return; -// const fetchData = async () => { -// const [facilityRes, assetBedRes] = await Promise.all([ -// dispatch(getPermittedFacility(consultation.facility as any)), -// dispatch( -// listAssetBeds({ -// facility: consultation.facility as any, -// bed: consultation.current_bed?.bed_object.id, -// }) -// ), -// ]); + useEffect(() => { + if (!consultation.facility) return; -// const { middleware_address } = facilityRes.data as FacilityModel; -// const assetbed = assetBedRes.data.results[0] as AssetBedModel; + const fetchData = async () => { + setLoading(true); -// if ( -// !middleware_address || -// !assetbed || -// !assetbed.asset_object.meta?.local_ip_address -// ) -// return; + const [facilityRes, assetBedRes] = await Promise.all([ + dispatch(getPermittedFacility(consultation.facility as any)), + dispatch( + listAssetBeds({ + facility: consultation.facility as any, + bed: consultation.current_bed?.bed_object.id, + }) + ), + ]); + + const { middleware_address } = facilityRes.data as FacilityModel; + const assetBeds = assetBedRes.data.results as AssetBedModel[]; + + const hl7Meta = assetBeds.find( + (i) => i.asset_object.asset_class === AssetClass.HL7MONITOR + )?.asset_object?.meta; + const hl7Middleware = hl7Meta?.middleware_hostname || middleware_address; + if (hl7Middleware && hl7Meta?.local_ip_address) { + setHL7SocketUrl( + `wss://${hl7Middleware}/observations/${hl7Meta.local_ip_address}` + ); + } -// setSocketUrl( -// `wss://${middleware_address}/observations/${assetbed?.asset_object.meta?.local_ip_address}` -// ); -// }; + const ventilatorMeta = assetBeds.find( + (i) => i.asset_object.asset_class === AssetClass.VENTILATOR + )?.asset_object?.meta; + const ventilatorMiddleware = + ventilatorMeta?.middleware_hostname || middleware_address; + if (ventilatorMiddleware && ventilatorMeta?.local_ip_address) { + setVentilatorSocketUrl( + `wss://${ventilatorMiddleware}/observations/${ventilatorMeta?.local_ip_address}` + ); + } + + setLoading(false); + }; + + fetchData(); + }, [consultation]); + + if (loading) { + return ( +
+ +
+ ); + } -// fetchData(); -// }, [consultation]); + if (!hl7SocketUrl && !ventilatorSocketUrl) { + return ( + + No HL7 Monitor or Ventilator configured for this patient + + ); + } -// if (!socketUrl) return null; + return ( +
+
+ {hl7SocketUrl ? ( + + ) : ( + + )} +
+
+ {ventilatorSocketUrl ? ( + + ) : ( + + )} +
+
+ ); +}; -// return ( -// -// ); -// }; +const VitalsDeviceNotConfigured = ({ device }: { device: string }) => { + return ( +
+ + + No {device} configured for this bed + +
+ ); +}; diff --git a/src/Components/VitalsMonitor/HL7PatientVitalsMonitor.tsx b/src/Components/VitalsMonitor/HL7PatientVitalsMonitor.tsx index e9ddacf1f00..d4dde780fb2 100644 --- a/src/Components/VitalsMonitor/HL7PatientVitalsMonitor.tsx +++ b/src/Components/VitalsMonitor/HL7PatientVitalsMonitor.tsx @@ -4,6 +4,8 @@ import { PatientAssetBed } from "../Assets/AssetTypes"; import { Link } from "raviger"; import { GENDER_TYPES } from "../../Common/constants"; import CareIcon from "../../CAREUI/icons/CareIcon"; +import WaveformLabels from "./WaveformLabels"; +import { classNames } from "../../Utils/utils"; interface Props { patientAssetBed?: PatientAssetBed; @@ -16,7 +18,7 @@ export default function HL7PatientVitalsMonitor({ socketUrl, size, }: Props) { - const { connect, waveformCanvas, data } = useHL7VitalsMonitor(); + const { connect, waveformCanvas, data, isOnline } = useHL7VitalsMonitor(); const { patient, bed } = patientAssetBed ?? {}; useEffect(() => { @@ -63,7 +65,28 @@ export default function HL7PatientVitalsMonitor({
)}
-
+
+ + No incoming data from HL7 Monitor +
+
+
-
+
{/* Pulse Rate */}
ECG - {data.pulseRate?.unit ?? "--"} + {(data.pulseRate ?? data.heartRate)?.unit ?? "--"}
- {data.pulseRate?.value ?? "--"} + {(data.pulseRate ?? data.heartRate)?.value ?? "--"} - {data.pulseRate?.value && ( + {(data.pulseRate ?? data.heartRate)?.value && ( ❤️ )}
diff --git a/src/Components/VitalsMonitor/HL7VitalsRenderer.ts b/src/Components/VitalsMonitor/HL7VitalsRenderer.ts index f9d97b21128..1a4df38aabc 100644 --- a/src/Components/VitalsMonitor/HL7VitalsRenderer.ts +++ b/src/Components/VitalsMonitor/HL7VitalsRenderer.ts @@ -1,3 +1,6 @@ +import { ChannelOptions } from "./types"; +import { lerp } from "./utils"; + interface ChannelState { buffer: number[]; cursor: Position; @@ -19,25 +22,6 @@ interface Position { */ const DURATION = 7; -export interface ChannelOptions { - /** - * The baseline value for this channel. - */ - baseline: number; - /** - * The minimum value that can be displayed for this channel. - */ - lowLimit: number; - /** - * The maximum value that can be displayed for this channel. - */ - highLimit: number; - /** - * No. of data points expected to be received per second. - */ - samplingRate: number; -} - interface Options { /** * The size of the canvas rendering context. @@ -112,7 +96,7 @@ class HL7VitalsRenderer { }, spo2: { - color: "#2427ff", + color: "#03a9f4", buffer: [], cursor: { x: 0, y: 0 }, deltaX: w / (DURATION * spo2.samplingRate), @@ -222,43 +206,3 @@ class HL7VitalsRenderer { } export default HL7VitalsRenderer; - -/** - * Maps a value from one range to another. - * Or in mathematical terms, it performs a linear interpolation. - * - * @param x0 The lower bound of the input range. - * @param x1 The upper bound of the input range. - * @param y0 The lower bound of the output range. - * @param y1 The upper bound of the output range. - * @returns A function that maps a value from the input range to the output range. - * @example - * const transform = lerp(0, 100, 0, 1); - * transform(50); // 0.5 - * transform(100); // 1 - * transform(0); // 0 - * transform(200); // 2 - * transform(-100); // -1 - */ -const lerp = (x0: number, x1: number, y0: number, y1: number) => { - // Original formula: - // y = y0 + (x - x0) * (y1 - y0) / (x1 - x0) - // - // Simplified formula: - // - // 1. Take the first order partial derivative out - // m = (y1 - y0) / (x1 - x0) - // ∴ y = y0 + (x - x0) * m - // - // 2. Expanding the (x - x0) term yields: - // ∴ y = y0 + x * m - x0 * m - // - // 3. Simplify the terms by grouping the constants together: - // c = y0 - x0 * m - // ∴ y = m * x + c - - const m = (y1 - y0) / (x1 - x0); - const c = y0 - x0 * m; - - return (x: number) => m * x + c; -}; diff --git a/src/Components/VitalsMonitor/VentilatorDeviceClient.ts b/src/Components/VitalsMonitor/VentilatorDeviceClient.ts new file mode 100644 index 00000000000..0418740dcc7 --- /dev/null +++ b/src/Components/VitalsMonitor/VentilatorDeviceClient.ts @@ -0,0 +1,88 @@ +import { EventEmitter } from "events"; +import { VitalsDataBase, VitalsValueBase, VitalsWaveformBase } from "./types"; + +const WAVEFORM_KEY_MAP: Record = { + P: "pressure-waveform", + F: "flow-waveform", + V: "volume-waveform", +}; + +/** + * Provides the API for connecting to the Vitals Monitor WebSocket and emitting + * events for each observation. + * + * @example + * const device = new VentilatorDeviceClient("wss://vitals-middleware.local/observations/192.168.1.14"); + * + * device.on("FiO2", (observation) => { + * console.log(observation.value); + * }); + */ +class VentilatorDeviceClient extends EventEmitter { + constructor(socketUrl: string) { + super(); + this.ws = new WebSocket(socketUrl); + } + + ws: WebSocket; + + connect() { + this.ws.addEventListener("message", (event) => { + const observations = parseObservations(event.data); + + observations.forEach((observation) => { + if (observation.observation_id === "waveform") { + this.emit(WAVEFORM_KEY_MAP[observation["wave-name"]], observation); + } else { + this.emit(observation.observation_id, observation); + } + }); + }); + } + + disconnect() { + this.ws.close(); + } + + on(event: EventName, listener: (data: VentilatorData) => void): this { + return super.on(event, listener); + } + + emit(event: EventName, data: VentilatorData): boolean { + return super.emit(event, data); + } + + once(event: EventName, listener: (data: VentilatorData) => void): this { + return super.once(event, listener); + } + + off(event: EventName, listener: (data: VentilatorData) => void): this { + return super.off(event, listener); + } +} + +export default VentilatorDeviceClient; + +export interface VentilatorVitalsValueData + extends VitalsDataBase, + VitalsValueBase { + observation_id: "Mode" | "PEEP" | "R.Rate" | "Insp-Time" | "FiO2"; +} + +export interface VentilatorVitalsWaveformData extends VitalsWaveformBase { + "wave-name": "P" | "F" | "V"; +} + +export type VentilatorData = + | VentilatorVitalsValueData + | VentilatorVitalsWaveformData; + +type EventName = + | VentilatorData["observation_id"] + | "pressure-waveform" + | "flow-waveform" + | "volume-waveform"; + +const parseObservations = (data: string) => { + return JSON.parse(data || "[]") as VentilatorData[]; +}; diff --git a/src/Components/VitalsMonitor/VentilatorPatientVitalsMonitor.tsx b/src/Components/VitalsMonitor/VentilatorPatientVitalsMonitor.tsx new file mode 100644 index 00000000000..a964e501f42 --- /dev/null +++ b/src/Components/VitalsMonitor/VentilatorPatientVitalsMonitor.tsx @@ -0,0 +1,151 @@ +import { useEffect } from "react"; +import { PatientAssetBed } from "../Assets/AssetTypes"; +import { Link } from "raviger"; +import { GENDER_TYPES } from "../../Common/constants"; +import CareIcon from "../../CAREUI/icons/CareIcon"; +import useVentilatorVitalsMonitor from "./useVentilatorVitalsMonitor"; +import { VitalsValueBase } from "./types"; +import { classNames } from "../../Utils/utils"; +import WaveformLabels from "./WaveformLabels"; + +interface Props { + patientAssetBed?: PatientAssetBed; + socketUrl: string; + size?: { width: number; height: number }; +} + +export default function VentilatorPatientVitalsMonitor({ + patientAssetBed, + socketUrl, + size, +}: Props) { + const { connect, waveformCanvas, data, isOnline } = + useVentilatorVitalsMonitor(); + const { patient, bed } = patientAssetBed ?? {}; + + useEffect(() => { + connect(socketUrl); + }, [socketUrl]); + + return ( +
+ {patientAssetBed && ( +
+
+ {patient ? ( + + {patient?.name} + + ) : ( + + + No Patient + + )} + {patient && ( + + {patient.age}y;{" "} + {GENDER_TYPES.find((g) => g.id === patient.gender)?.icon} + + )} +
+ {bed && ( +
+ + + {bed.name} + + + + {bed.location_object?.name} + +
+ )} +
+ )} +
+
+ + No incoming data from Ventilator +
+
+ + + +
+
+ + + + +
+
+
+ ); +} + +interface NonWaveformDataProps { + label: string; + attr?: VitalsValueBase; + className?: string; +} + +const NonWaveformData = ({ label, attr, className }: NonWaveformDataProps) => { + return ( +
+
+ {label} + {attr?.unit ?? "--"} +
+ + {attr?.value ?? "--"} + +
+ ); +}; diff --git a/src/Components/VitalsMonitor/VentilatorWaveformsRenderer.ts b/src/Components/VitalsMonitor/VentilatorWaveformsRenderer.ts new file mode 100644 index 00000000000..321c3239d7d --- /dev/null +++ b/src/Components/VitalsMonitor/VentilatorWaveformsRenderer.ts @@ -0,0 +1,212 @@ +import { ChannelOptions } from "./types"; +import { lerp } from "./utils"; + +interface ChannelState { + buffer: number[]; + cursor: Position; + color: string; + deltaX: number; + transform: (value: number) => number; + chunkSize: number; + options: ChannelOptions; + rows: number; +} + +interface Position { + x: number; + y: number; +} + +/** + * Duration of each row on the canvas in seconds. + */ +const DURATION = 7; + +interface Options { + /** + * The size of the canvas rendering context. + * + * Height should preferably be a multiple of 4. + */ + size: { width: number; height: number }; + /** + * The 2D render context of the canvas to draw the vitals waveforms on. + */ + foregroundRenderContext: CanvasRenderingContext2D; + /** + * The 2D render context of the canvas to draw the + */ + backgroundRenderContext: CanvasRenderingContext2D; + /** + * The interval at which the canvas is rendered in milliseconds. + */ + animationInterval: number; + /** + * Options for Pressure channel. + */ + pressure: ChannelOptions; + /** + * Options for Flow channel. + */ + flow: ChannelOptions; + /** + * Options for Volume channel. + */ + volume: ChannelOptions; +} + +/** + * Provides the API for rendering vitals waveforms on a canvas. + * + * Strategy: + * - Render frequency is set manually and is independent of the sampling rate. + * - Manages rendering of all the vitals channels. + */ +class VentilatorVitalsRenderer { + constructor(options: Options) { + const { + pressure, + flow, + volume, + size: { height: h, width: w }, + } = options; + + this.options = options; + this.state = { + pressure: { + color: "#0ffc03", + buffer: [], + cursor: { x: 0, y: 0 }, + deltaX: w / (DURATION * pressure.samplingRate), + transform: lerp(pressure.lowLimit, pressure.highLimit, h * 0.33, 0), + chunkSize: pressure.samplingRate * options.animationInterval * 1e-3, + options: pressure, + rows: 1, + }, + + flow: { + color: "#ffff24", + buffer: [], + cursor: { x: 0, y: 0 }, + deltaX: w / (DURATION * flow.samplingRate), + transform: lerp(flow.lowLimit, flow.highLimit, h * 0.67, h * 0.33), + chunkSize: flow.samplingRate * options.animationInterval * 1e-3, + options: flow, + rows: 1, + }, + + volume: { + color: "#03a9f4", + buffer: [], + cursor: { x: 0, y: 0 }, + deltaX: w / (DURATION * volume.samplingRate), + transform: lerp(volume.lowLimit, volume.highLimit, h, h * 0.67), + chunkSize: volume.samplingRate * options.animationInterval * 1e-3, + options: volume, + rows: 1, + }, + }; + + // Draw baseline for each channel. + this.initialize(this.state.pressure); + this.initialize(this.state.flow); + this.initialize(this.state.volume); + + // Start rendering. + setInterval(() => { + this.render(this.state.pressure); + this.render(this.state.flow); + this.render(this.state.volume); + }, options.animationInterval); + } + + private options: Options; + private state: { + pressure: ChannelState; + flow: ChannelState; + volume: ChannelState; + }; + + /** + * Appends data to the buffer of the specified channel. + */ + append(channel: "pressure" | "flow" | "volume", data: number[]) { + const state = this.state[channel]; + state.buffer.push(...data.map(state.transform)); + } + + private initialize(channel: ChannelState) { + const { foregroundRenderContext: ctx, size } = this.options; + const { transform, rows, options, color } = channel; + + for (let row = 0; row < rows; row++) { + const y = transform(options.baseline) + (row * size.height) / 4; + + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(size.width, y); + ctx.strokeStyle = color; + ctx.stroke(); + } + + channel.cursor = { x: 0, y: transform(options.baseline) }; + } + + private render(channel: ChannelState) { + const { foregroundRenderContext: ctx, size } = this.options; + const { cursor, deltaX, transform } = channel; + + const xMax = size.width * channel.rows; + + ctx.beginPath(); + ctx.moveTo(...this.correctForRow(cursor)); + + ctx.strokeStyle = channel.color; + ctx.lineWidth = 1.5; + + channel.buffer.splice(0, channel.chunkSize).forEach((nextY) => { + cursor.x += deltaX; + cursor.y = nextY; + + if (cursor.x > xMax) { + cursor.x = 0; + } + + const [x, y] = this.correctForRow(cursor); + + if (x < deltaX) { + ctx.beginPath(); + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + }); + + ctx.stroke(); + + const deltaRows = Math.floor(cursor.x / size.width); + ctx.clearRect( + cursor.x - deltaRows * size.width, + 1 + transform(channel.options.highLimit) + (deltaRows * size.height) / 3, + 10, + size.height / 3 + 5 + ); + } + + /** + * Corrects the cursor position for the appropriate row if the cursor is + * outside the bounds of the canvas. + */ + private correctForRow(cursor: Position): [number, number] { + const { size } = this.options; + + const deltaRows = Math.floor(cursor.x / size.width); + + const x = cursor.x % size.width; + const y = cursor.y + (deltaRows * size.height) / 4; + + return [x, y]; + } +} + +export default VentilatorVitalsRenderer; diff --git a/src/Components/VitalsMonitor/WaveformLabels.tsx b/src/Components/VitalsMonitor/WaveformLabels.tsx new file mode 100644 index 00000000000..a83ae2d14f7 --- /dev/null +++ b/src/Components/VitalsMonitor/WaveformLabels.tsx @@ -0,0 +1,17 @@ +import { classNames } from "../../Utils/utils"; + +interface Props { + labels: Record; +} + +export default function WaveformLabels({ labels }: Props) { + return ( +
+ {Object.entries(labels).map(([label, className]) => ( + + {label} + + ))} +
+ ); +} diff --git a/src/Components/VitalsMonitor/types.ts b/src/Components/VitalsMonitor/types.ts index c413181493a..046c3db348b 100644 --- a/src/Components/VitalsMonitor/types.ts +++ b/src/Components/VitalsMonitor/types.ts @@ -22,3 +22,22 @@ export interface VitalsWaveformBase extends VitalsDataBase { "data-high-limit": number; data: string; } + +export interface ChannelOptions { + /** + * The baseline value for this channel. + */ + baseline: number; + /** + * The minimum value that can be displayed for this channel. + */ + lowLimit: number; + /** + * The maximum value that can be displayed for this channel. + */ + highLimit: number; + /** + * No. of data points expected to be received per second. + */ + samplingRate: number; +} diff --git a/src/Components/VitalsMonitor/useHL7VitalsMonitor.ts b/src/Components/VitalsMonitor/useHL7VitalsMonitor.ts index 008ba13105f..8b14dff12e3 100644 --- a/src/Components/VitalsMonitor/useHL7VitalsMonitor.ts +++ b/src/Components/VitalsMonitor/useHL7VitalsMonitor.ts @@ -3,9 +3,10 @@ import HL7DeviceClient, { HL7MonitorData, HL7VitalsWaveformData, } from "./HL7DeviceClient"; -import HL7VitalsRenderer, { ChannelOptions } from "./HL7VitalsRenderer"; +import HL7VitalsRenderer from "./HL7VitalsRenderer"; import useCanvas from "../../Common/hooks/useCanvas"; -import { VitalsValueBase as VitalsValue } from "./types"; +import { ChannelOptions, VitalsValueBase as VitalsValue } from "./types"; +import { getChannel } from "./utils"; export const MONITOR_RATIO = { w: 13, @@ -32,7 +33,9 @@ export default function useHL7VitalsMonitor() { const waveformBackgroundCanvas = useCanvas(); // Non waveform data states. + const [isOnline, setIsOnline] = useState(false); const [pulseRate, setPulseRate] = useState(); + const [heartRate, setHeartRate] = useState(); const [bp, setBp] = useState(); const [spo2, setSpo2] = useState(); const [respiratoryRate, setRespiratoryRate] = useState(); @@ -49,6 +52,7 @@ export default function useHL7VitalsMonitor() { const connect = useCallback( (socketUrl: string) => { + setIsOnline(false); device.current?.disconnect(); device.current = new HL7DeviceClient(socketUrl); @@ -62,6 +66,8 @@ export default function useHL7VitalsMonitor() { ) return; + setIsOnline(true); + renderer.current = new HL7VitalsRenderer({ foregroundRenderContext: waveformForegroundCanvas.contextRef.current!, backgroundRenderContext: waveformBackgroundCanvas.contextRef.current!, @@ -79,7 +85,8 @@ export default function useHL7VitalsMonitor() { const hook = (set: (data: any) => void) => (d: HL7MonitorData) => set(d); - device.current!.on("heart-rate", hook(setPulseRate)); + device.current!.on("pulse-rate", hook(setPulseRate)); + device.current!.on("heart-rate", hook(setHeartRate)); device.current!.on("SpO2", hook(setSpo2)); device.current!.on("respiratory-rate", hook(setRespiratoryRate)); device.current!.on("body-temperature1", hook(setTemperature1)); @@ -120,6 +127,7 @@ export default function useHL7VitalsMonitor() { }, data: { pulseRate, + heartRate, bp, spo2, respiratoryRate, @@ -127,20 +135,10 @@ export default function useHL7VitalsMonitor() { temperature2, }, device, + isOnline, }; } -const getChannel = (observation: HL7VitalsWaveformData): ChannelOptions => { - return { - samplingRate: parseInt( - observation["sampling rate"]?.replace("/sec", "") ?? "-1" - ), - baseline: observation["data-baseline"] ?? 0, - lowLimit: observation["data-low-limit"] ?? 0, - highLimit: observation["data-high-limit"] ?? 0, - }; -}; - const ingestTo = ( vitalsRenderer: HL7VitalsRenderer, channel: "ecg" | "pleth" | "spo2" diff --git a/src/Components/VitalsMonitor/useVentilatorVitalsMonitor.ts b/src/Components/VitalsMonitor/useVentilatorVitalsMonitor.ts new file mode 100644 index 00000000000..ae07a9d0813 --- /dev/null +++ b/src/Components/VitalsMonitor/useVentilatorVitalsMonitor.ts @@ -0,0 +1,142 @@ +import { useCallback, useRef, useState } from "react"; +import useCanvas from "../../Common/hooks/useCanvas"; +import { ChannelOptions, VitalsValueBase as VitalsValue } from "./types"; +import VentilatorDeviceClient, { + VentilatorData, + VentilatorVitalsWaveformData, +} from "./VentilatorDeviceClient"; +import VentilatorVitalsRenderer from "./VentilatorWaveformsRenderer"; +import { getChannel } from "./utils"; + +export const MONITOR_RATIO = { + w: 13, + h: 11, +}; +const MONITOR_SCALE = 38; +const MONITOR_WAVEFORMS_CANVAS_SIZE = { + width: MONITOR_RATIO.h * MONITOR_SCALE, + height: MONITOR_RATIO.h * MONITOR_SCALE, +}; +// const MONITOR_SIZE = { +// width: MONITOR_RATIO.w * MONITOR_SCALE, +// height: MONITOR_RATIO.h * MONITOR_SCALE, +// }; + +export default function useVentilatorVitalsMonitor() { + const waveformForegroundCanvas = useCanvas(); + const waveformBackgroundCanvas = useCanvas(); + + // Non waveform data states. + const [isOnline, setIsOnline] = useState(false); + const [peep, setPeep] = useState(); + const [respRate, setRespRate] = useState(); + const [inspTime, setInspTime] = useState(); + const [fio2, setFio2] = useState(); + + // Waveform data states. + const device = useRef(); + const renderer = useRef(null); + + const pressureOptionsRef = useRef(); + const flowOptionsRef = useRef(); + const volumeOptionsRef = useRef(); + + const connect = useCallback( + (socketUrl: string) => { + setIsOnline(false); + device.current?.disconnect(); + + device.current = new VentilatorDeviceClient(socketUrl); + device.current.connect(); + + function obtainRenderer() { + if ( + !pressureOptionsRef.current || + !flowOptionsRef.current || + !volumeOptionsRef.current + ) + return; + + setIsOnline(true); + + renderer.current = new VentilatorVitalsRenderer({ + foregroundRenderContext: waveformForegroundCanvas.contextRef.current!, + backgroundRenderContext: waveformBackgroundCanvas.contextRef.current!, + size: MONITOR_WAVEFORMS_CANVAS_SIZE, + animationInterval: 50, + pressure: pressureOptionsRef.current, + flow: flowOptionsRef.current, + volume: volumeOptionsRef.current, + }); + + const _renderer = renderer.current; + device.current!.on( + "pressure-waveform", + ingestTo(_renderer, "pressure") + ); + device.current!.on("flow-waveform", ingestTo(_renderer, "flow")); + device.current!.on("volume-waveform", ingestTo(_renderer, "volume")); + + const hook = (set: (data: any) => void) => (d: VentilatorData) => + set(d); + device.current!.on("PEEP", hook(setPeep)); + device.current!.on("R.Rate", hook(setRespRate)); + device.current!.on("Insp-Time", hook(setInspTime)); + device.current!.on("FiO2", hook(setFio2)); + } + + device.current.once("pressure-waveform", (observation) => { + pressureOptionsRef.current = getChannel( + observation as VentilatorVitalsWaveformData + ); + obtainRenderer(); + }); + + device.current.once("flow-waveform", (observation) => { + flowOptionsRef.current = getChannel( + observation as VentilatorVitalsWaveformData + ); + obtainRenderer(); + }); + + device.current.once("volume-waveform", (observation) => { + volumeOptionsRef.current = getChannel( + observation as VentilatorVitalsWaveformData + ); + obtainRenderer(); + }); + }, + [waveformForegroundCanvas.contextRef, waveformBackgroundCanvas] + ); + + return { + connect, + waveformCanvas: { + foreground: waveformForegroundCanvas, + background: waveformBackgroundCanvas, + size: MONITOR_WAVEFORMS_CANVAS_SIZE, + }, + data: { + peep, + respRate, + inspTime, + fio2, + }, + device, + isOnline, + }; +} + +const ingestTo = ( + vitalsRenderer: VentilatorVitalsRenderer, + channel: "pressure" | "flow" | "volume" +) => { + return (observation: VentilatorData) => { + vitalsRenderer.append( + channel, + (observation as VentilatorVitalsWaveformData).data + .split(" ") + .map((x) => parseInt(x)) || [] + ); + }; +}; diff --git a/src/Components/VitalsMonitor/utils.ts b/src/Components/VitalsMonitor/utils.ts new file mode 100644 index 00000000000..bd4b05fad58 --- /dev/null +++ b/src/Components/VitalsMonitor/utils.ts @@ -0,0 +1,52 @@ +import { ChannelOptions, VitalsWaveformBase } from "./types"; + +/** + * Maps a value from one range to another. + * Or in mathematical terms, it performs a linear interpolation. + * + * @param x0 The lower bound of the input range. + * @param x1 The upper bound of the input range. + * @param y0 The lower bound of the output range. + * @param y1 The upper bound of the output range. + * @returns A function that maps a value from the input range to the output range. + * @example + * const transform = lerp(0, 100, 0, 1); + * transform(50); // 0.5 + * transform(100); // 1 + * transform(0); // 0 + * transform(200); // 2 + * transform(-100); // -1 + */ +export const lerp = (x0: number, x1: number, y0: number, y1: number) => { + // Original formula: + // y = y0 + (x - x0) * (y1 - y0) / (x1 - x0) + // + // Simplified formula: + // + // 1. Take the first order partial derivative out + // m = (y1 - y0) / (x1 - x0) + // ∴ y = y0 + (x - x0) * m + // + // 2. Expanding the (x - x0) term yields: + // ∴ y = y0 + x * m - x0 * m + // + // 3. Simplify the terms by grouping the constants together: + // c = y0 - x0 * m + // ∴ y = m * x + c + + const m = (y1 - y0) / (x1 - x0); + const c = y0 - x0 * m; + + return (x: number) => m * x + c; +}; + +export const getChannel = (observation: VitalsWaveformBase): ChannelOptions => { + return { + samplingRate: parseInt( + observation["sampling rate"]?.replace("/sec", "") ?? "-1" + ), + baseline: observation["data-baseline"] ?? 0, + lowLimit: observation["data-low-limit"] ?? 0, + highLimit: observation["data-high-limit"] ?? 0, + }; +};