diff --git a/app/components/device-detail/graph.tsx b/app/components/device-detail/graph.tsx index 1852dbdb..12a43017 100644 --- a/app/components/device-detail/graph.tsx +++ b/app/components/device-detail/graph.tsx @@ -8,6 +8,7 @@ import { Legend, Tooltip as ChartTooltip, Filler, + Decimation, type ChartOptions, } from 'chart.js' import 'chartjs-adapter-date-fns' @@ -18,9 +19,10 @@ import { useState, useEffect, useContext, + useCallback, type RefObject, } from 'react' -import { Scatter } from 'react-chartjs-2' +import { Line } from 'react-chartjs-2' import { isBrowser, isTablet } from 'react-device-detect' import Draggable, { type DraggableData } from 'react-draggable' import { useNavigate, useNavigation, useSearchParams } from 'react-router' @@ -39,6 +41,11 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' import { datesHave48HourRange } from '~/lib/utils' import { useTranslation } from 'react-i18next' +import { + createMeasurementChartData, + type MeasurementChartData, + type MeasurementChartPoint, +} from '~/lib/measurement-chart' ChartJS.register( LineElement, @@ -49,23 +56,68 @@ ChartJS.register( ChartTooltip, Legend, Filler, + Decimation, ) -// ClientOnly component to handle the plugin that needs window -const GraphWithZoom = (props: any) => { - useMemo(() => { - // Dynamically import the zoom plugin - void import('chartjs-plugin-zoom').then(({ default: zoomPlugin }) => { +let zoomPluginRegistration: Promise | null = null + +function registerZoomPlugin(): Promise { + if (zoomPluginRegistration) return zoomPluginRegistration + + zoomPluginRegistration = import('chartjs-plugin-zoom') + .then(({ default: zoomPlugin }) => { ChartJS.register(zoomPlugin) }) + .catch((error: unknown) => { + zoomPluginRegistration = null + console.error('Failed to register chartjs-plugin-zoom:', error) + throw error + }) + + return zoomPluginRegistration +} + +interface GraphWithZoomProps { + chartData: MeasurementChartData + options: ChartOptions<'line'> + chartRef: RefObject | null> + onMouseLeave: () => void +} + +// ClientOnly component to handle the plugin that needs window +const GraphWithZoom = ({ + chartData, + options, + chartRef, + onMouseLeave, +}: GraphWithZoomProps) => { + const [isZoomPluginSettled, setIsZoomPluginSettled] = useState(false) + + useEffect(() => { + let isMounted = true + const finishLoadingZoomPlugin = () => { + if (isMounted) setIsZoomPluginSettled(true) + } + + void registerZoomPlugin().then( + finishLoadingZoomPlugin, + finishLoadingZoomPlugin, + ) + + return () => { + isMounted = false + } }, []) + if (!isZoomPluginSettled) return + return ( - + ) } @@ -82,7 +134,7 @@ export default function Graph({ startDate, endDate, }: GraphProps) { - const { setHoveredPoint } = useContext(HoveredPointContext) + const { hoveredPoint, setHoveredPoint } = useContext(HoveredPointContext) const navigation = useNavigation() const { t, i18n } = useTranslation('graph') const navigate = useNavigate() @@ -101,7 +153,32 @@ export default function Graph({ const isAggregated = aggregation !== 'raw' const nodeRef = useRef(null) - const chartRef = useRef>(null) + const chartRef = useRef | null>(null) + const isZoomingRef = useRef(false) + const lastHoveredPointRef = useRef(hoveredPoint) + const previousChartInputRef = useRef({ sensors, isAggregated }) + + useEffect(() => { + lastHoveredPointRef.current = hoveredPoint + }, [hoveredPoint]) + + const setHoveredPointIfChanged = useCallback( + (point: number | null) => { + if (lastHoveredPointRef.current === point) return + + lastHoveredPointRef.current = point + setHoveredPoint(point) + }, + [setHoveredPoint], + ) + const handleChartMouseLeave = useCallback(() => { + isZoomingRef.current = false + setHoveredPointIfChanged(null) + }, [setHoveredPointIfChanged]) const dateTimeFormatter = useMemo(() => { return new Intl.DateTimeFormat(i18n.language, { @@ -110,179 +187,37 @@ export default function Graph({ }) }, [i18n.language]) - useEffect(() => { - if (chartRef.current) { - const canvas = chartRef.current.canvas - - const handleMouseLeave = () => { - setHoveredPoint(null) // Clear the hovered point when the mouse leaves the chart area - } - - canvas.addEventListener('mouseleave', handleMouseLeave) - - // Cleanup - return () => { - canvas.removeEventListener('mouseleave', handleMouseLeave) - } - } - }, [chartRef, setHoveredPoint]) - // get theme from tailwind const [theme] = 'light' //useTheme(); - const [chartData, setChartData] = useState(() => { - const includeDeviceName = - sensors.length === 2 && sensors[0].device_name !== sensors[1].device_name - - return { - datasets: sensors - .map( - ( - sensor: { - title: any - device_name: any - data: any[] - color: string - }, - index: number, - ) => { - const baseDataset = { - label: includeDeviceName - ? `${sensor.title} (${sensor.device_name})` - : sensor.title, - data: sensor.data.map((measurement) => ({ - x: measurement.time, - y: measurement.value, - locationId: measurement.locationId, - })), - pointRadius: 3, - borderColor: sensor.color, - backgroundColor: sensor.color, - yAxisID: index === 0 ? 'y' : 'y1', - fill: false, - tension: 0.4, - } - - if (isAggregated && sensors.length === 1) { - const minDataset = { - ...baseDataset, - label: `${baseDataset.label} (Min)`, - data: sensor.data.map((measurement) => ({ - x: measurement.time, - y: measurement.min_value, - locationId: null, - })), - borderColor: sensor.color + '33', - backgroundColor: sensor.color + '33', - fill: 1, - } - - const maxDataset = { - ...baseDataset, - label: `${baseDataset.label} (Max)`, - data: sensor.data.map((measurement) => ({ - x: measurement.time, - y: measurement.max_value, - locationId: null, - })), - borderColor: sensor.color + '33', - backgroundColor: sensor.color + '33', - fill: 1, - } - - return [maxDataset, baseDataset, minDataset] - } - - return [baseDataset] - }, - ) - .flat(), - } - }) + const [chartData, setChartData] = useState(() => + createMeasurementChartData(sensors, isAggregated), + ) useEffect(() => { - const includeDeviceName = - sensors.length === 2 && sensors[0].device_name !== sensors[1].device_name - - setChartData({ - datasets: sensors - .map( - ( - sensor: { - title: any - device_name: any - data: any[] - color: string - }, - index: number, - ) => { - const baseDataset = { - label: includeDeviceName - ? `${sensor.title} (${sensor.device_name})` - : sensor.title, - data: sensor.data.map((measurement) => ({ - x: measurement.time, - y: measurement.value, - locationId: measurement.locationId, - })), - pointRadius: 1, - borderColor: sensor.color, - backgroundColor: sensor.color, - yAxisID: index === 0 ? 'y' : 'y1', - fill: false, - tension: 0.4, - } - - if (isAggregated && sensors.length === 1) { - const minDataset = { - ...baseDataset, - label: `${baseDataset.label} (Min)`, - data: sensor.data.map((measurement) => ({ - x: measurement.time, - y: measurement.min_value, - locationId: null, - })), - borderColor: sensor.color + '33', - backgroundColor: sensor.color + '33', - fill: 1, - } - - const maxDataset = { - ...baseDataset, - label: `${baseDataset.label} (Max)`, - data: sensor.data.map((measurement) => ({ - x: measurement.time, - y: measurement.max_value, - locationId: null, - })), - borderColor: sensor.color + '33', - backgroundColor: sensor.color + '33', - fill: 1, - } - - return [maxDataset, baseDataset, minDataset] - } + if ( + previousChartInputRef.current.sensors === sensors && + previousChartInputRef.current.isAggregated === isAggregated + ) { + return + } - return [baseDataset] - }, - ) - .flat(), - }) + previousChartInputRef.current = { sensors, isAggregated } + setChartData(createMeasurementChartData(sensors, isAggregated)) }, [sensors, isAggregated]) - const options: ChartOptions<'scatter'> = useMemo(() => { + const options: ChartOptions<'line'> = useMemo(() => { return { maintainAspectRatio: false, responsive: true, + animation: false, + normalized: sensors.length === 1 && !isAggregated, spanGaps: false, interaction: { mode: 'index', intersect: false, }, - parsing: { - xAxisKey: 'x', - yAxisKey: 'y', - }, + parsing: false, scales: { x: { type: 'time', @@ -307,8 +242,6 @@ export default function Graph({ // locale: data.locale === "de" ? de : enGB, // }, // }, - min: currentZoom?.xMin, - max: currentZoom?.xMax, ticks: { major: { enabled: true, @@ -358,6 +291,10 @@ export default function Graph({ }, }, plugins: { + decimation: { + enabled: true, + algorithm: 'min-max', + }, tooltip: { enabled: true, mode: 'index', @@ -368,18 +305,17 @@ export default function Graph({ if (!firstItem) return '' - const timestamp = firstItem.raw.x + const { timestamp } = firstItem.raw as MeasurementChartPoint return dateTimeFormatter.format(new Date(timestamp)) }, label: (context: any) => { - const dataIndex = context.dataIndex - const datasetIndex = context.datasetIndex - const point = chartData.datasets[datasetIndex].data[dataIndex] - const locationId = point.locationId + const point = context.raw as MeasurementChartPoint - setHoveredPoint(locationId) + if (!isZoomingRef.current && point.locationId !== null) { + setHoveredPointIfChanged(point.locationId) + } return `${context.dataset.label}: ${context.raw.y}` }, @@ -394,12 +330,16 @@ export default function Graph({ enabled: true, }, mode: 'x', - onZoom: ({ chart }) => { + onZoomStart: () => { + isZoomingRef.current = true + return true + }, + onZoomComplete: ({ chart }) => { + isZoomingRef.current = false const xScale = chart.scales['x'] const xMin = xScale.min const xMax = xScale.max - // Track the zoom level setCurrentZoom({ xMin, xMax }) }, }, @@ -439,25 +379,27 @@ export default function Graph({ }, [ startDate, endDate, - currentZoom?.xMin, - currentZoom?.xMax, theme, sensors, + isAggregated, chartData.datasets, - setHoveredPoint, + setHoveredPointIfChanged, colorPickerState.open, dateTimeFormatter, ]) function handleColorChange(newColor: string) { - const updatedDatasets = [...chartData.datasets] - updatedDatasets[colorPickerState.index].borderColor = newColor - updatedDatasets[colorPickerState.index].backgroundColor = newColor - - // Update the chartData state with the new dataset colors setChartData((prevData) => ({ ...prevData, - datasets: updatedDatasets, + datasets: prevData.datasets.map((dataset, index) => + index === colorPickerState.index + ? { + ...dataset, + borderColor: newColor, + backgroundColor: newColor, + } + : dataset, + ), })) } @@ -487,7 +429,7 @@ export default function Graph({ let csvContent = 'timestamp,deviceId,sensorId,value,unit,phenomena\n' // Loop through each timestamp and sensor data - labels.forEach((timestamp: any, index: string | number) => { + labels.forEach((timestamp: number, index: number) => { sensors.forEach((sensor: any) => { const dataset = chartData.datasets.find( (ds: { label: string | any[] }) => ds.label.includes(sensor.title), @@ -495,7 +437,7 @@ export default function Graph({ if (dataset) { const value = (dataset.data as any)[index]?.y ?? '' - csvContent += `${timestamp},` + csvContent += `${new Date(timestamp).toISOString()},` csvContent += `${sensor.deviceId},` csvContent += `${sensor.id},` csvContent += `${value},` @@ -529,6 +471,7 @@ export default function Graph({ function handleResetZoomClick() { if (chartRef.current) { chartRef.current.resetZoom() // Use the resetZoom function from the zoom plugin + isZoomingRef.current = false setCurrentZoom(null) // Reset the zoom state } } @@ -622,6 +565,7 @@ export default function Graph({ chartData={chartData} options={options} chartRef={chartRef} // Pass chartRef as a prop + onMouseLeave={handleChartMouseLeave} /> )} diff --git a/app/lib/measurement-chart.ts b/app/lib/measurement-chart.ts new file mode 100644 index 00000000..34c73b3c --- /dev/null +++ b/app/lib/measurement-chart.ts @@ -0,0 +1,178 @@ +import type { ChartData, ChartDataset, Point } from 'chart.js' + +export interface MeasurementChartPoint extends Point { + x: number + timestamp: number + y: number + locationId: number | null +} + +interface MeasurementChartMeasurement { + time: Date | string | number + value: unknown + min_value?: unknown + max_value?: unknown + locationId?: number | null +} + +interface NormalizedMeasurement { + timestamp: number + value: number + minValue: number | null + maxValue: number | null + locationId: number | null +} + +interface NormalizedAggregateMeasurement extends NormalizedMeasurement { + minValue: number + maxValue: number +} + +export interface MeasurementChartSensor { + title: string + device_name?: string | null + data: MeasurementChartMeasurement[] + color: string +} + +export type MeasurementChartDataset = ChartDataset< + 'line', + MeasurementChartPoint[] +> & { + label: string + data: MeasurementChartPoint[] + pointRadius: number + showLine: false + borderColor: string + backgroundColor: string + yAxisID: string + fill: boolean | number +} + +export interface MeasurementChartData extends ChartData< + 'line', + MeasurementChartPoint[] +> { + datasets: MeasurementChartDataset[] +} + +function toTimestamp(time: Date | string | number) { + const timestamp = + time instanceof Date ? time.getTime() : new Date(time).getTime() + + return Number.isFinite(timestamp) ? timestamp : null +} + +function toNumericValue(value: unknown) { + if (value === null || value === undefined || value === '') return null + + const numericValue = Number(value) + + return Number.isFinite(numericValue) ? numericValue : null +} + +// Filters once so that value/min/max series stay index-aligned. +function normalizeMeasurements( + measurements: MeasurementChartMeasurement[], +): NormalizedMeasurement[] { + return measurements + .map((measurement): NormalizedMeasurement | null => { + const timestamp = toTimestamp(measurement.time) + const value = toNumericValue(measurement.value) + + if (timestamp === null || value === null) return null + + return { + timestamp, + value, + minValue: toNumericValue(measurement.min_value), + maxValue: toNumericValue(measurement.max_value), + locationId: measurement.locationId ?? null, + } + }) + .filter((row): row is NormalizedMeasurement => row !== null) + .sort((left, right) => left.timestamp - right.timestamp) +} + +function hasAggregateBounds( + measurement: NormalizedMeasurement, +): measurement is NormalizedAggregateMeasurement { + return measurement.minValue !== null && measurement.maxValue !== null +} + +function createPoints( + measurements: T[], + getValue: (measurement: T) => number, + includeLocation: boolean, +): MeasurementChartPoint[] { + return measurements.map((measurement) => ({ + x: measurement.timestamp, + timestamp: measurement.timestamp, + y: getValue(measurement), + locationId: includeLocation ? measurement.locationId : null, + })) +} + +export function createMeasurementChartData( + sensors: MeasurementChartSensor[], + isAggregated: boolean, +): MeasurementChartData { + const includeDeviceName = + sensors.length === 2 && sensors[0].device_name !== sensors[1].device_name + + return { + datasets: sensors.flatMap((sensor, index) => { + const label = includeDeviceName + ? `${sensor.title} (${sensor.device_name})` + : sensor.title + const normalizedMeasurements = normalizeMeasurements(sensor.data) + const createBaseDataset = ( + measurements: NormalizedMeasurement[], + ): MeasurementChartDataset => ({ + label, + data: createPoints(measurements, ({ value }) => value, true), + pointRadius: 1, + showLine: false, + borderColor: sensor.color, + backgroundColor: sensor.color, + yAxisID: index === 0 ? 'y' : 'y1', + fill: false, + }) + + if (!isAggregated || sensors.length !== 1) { + return [createBaseDataset(normalizedMeasurements)] + } + + const aggregateMeasurements = + normalizedMeasurements.filter(hasAggregateBounds) + const baseDataset = createBaseDataset(aggregateMeasurements) + + const minDataset: MeasurementChartDataset = { + ...baseDataset, + label: `${label} (Min)`, + data: createPoints( + aggregateMeasurements, + ({ minValue }) => minValue, + false, + ), + borderColor: sensor.color + '33', + backgroundColor: sensor.color + '33', + fill: 1, + } + const maxDataset: MeasurementChartDataset = { + ...baseDataset, + label: `${label} (Max)`, + data: createPoints( + aggregateMeasurements, + ({ maxValue }) => maxValue, + false, + ), + borderColor: sensor.color + '33', + backgroundColor: sensor.color + '33', + fill: 1, + } + + return [maxDataset, baseDataset, minDataset] + }), + } +} diff --git a/app/routes/explore.$deviceId.$sensorId.$.tsx b/app/routes/explore.$deviceId.$sensorId.$.tsx index 40f6ed03..64d648e6 100644 --- a/app/routes/explore.$deviceId.$sensorId.$.tsx +++ b/app/routes/explore.$deviceId.$sensorId.$.tsx @@ -218,6 +218,8 @@ export default function SensorView() { {loaderData.device?.exposure === 'mobile' && ( diff --git a/app/routes/explore.$deviceId.tsx b/app/routes/explore.$deviceId.tsx index 73016ed3..28e83843 100644 --- a/app/routes/explore.$deviceId.tsx +++ b/app/routes/explore.$deviceId.tsx @@ -85,11 +85,7 @@ export default function DeviceId() { const isSensorView = matches[matches.length - 1].params.sensorId ? true : false - const [hoveredPoint, setHoveredPoint] = useState(null) - - const setHoveredPointDebug = (point: any) => { - setHoveredPoint(point) - } + const [hoveredPoint, setHoveredPoint] = useState(null) if (!data?.device && !data.sensors) { return null @@ -97,9 +93,7 @@ export default function DeviceId() { return ( <> - + {/* If the box is mobile, iterate over selected sensors and show trajectory */} {data.device?.exposure === 'mobile' && !isSensorView &&