From 42d7561c3d9ea357c6e99b4acf2a516ed59e9a6e Mon Sep 17 00:00:00 2001 From: jona159 Date: Sat, 13 Jun 2026 12:27:44 +0200 Subject: [PATCH] feat(wip): graph sensor comparison across devices --- .../device-detail/device-detail-box.tsx | 38 +-- app/components/device-detail/graph.tsx | 248 +++++++++------ .../device-detail/useGlobalCompareMode.tsx | 30 -- .../map/layers/cluster/box-marker.tsx | 21 +- app/components/search/search-list.tsx | 17 +- app/db/models/sensor.server.ts | 40 ++- app/routes/explore.$deviceId.$sensorId.$.tsx | 299 ++++++++---------- app/routes/explore.tsx | 7 +- public/locales/de/device-detail-box.json | 2 - public/locales/de/graph.json | 3 + public/locales/en/device-detail-box.json | 2 - public/locales/en/graph.json | 3 + 12 files changed, 362 insertions(+), 348 deletions(-) delete mode 100644 app/components/device-detail/useGlobalCompareMode.tsx diff --git a/app/components/device-detail/device-detail-box.tsx b/app/components/device-detail/device-detail-box.tsx index 3561d6630..257843101 100644 --- a/app/components/device-detail/device-detail-box.tsx +++ b/app/components/device-detail/device-detail-box.tsx @@ -4,11 +4,9 @@ import { ChevronUp, Minus, Share2, - XSquare, EllipsisVertical, X, ExternalLink, - Scale, Archive, Cpu, Rss, @@ -39,7 +37,6 @@ import { AccordionItem, AccordionTrigger, } from '../ui/accordion' -import { Alert, AlertDescription, AlertTitle } from '../ui/alert' import { AlertDialog, AlertDialogCancel, @@ -76,7 +73,6 @@ import { import { useToast } from '../ui/use-toast' import EntryLogs from './entry-logs' import ShareLink from './share-link' -import { useGlobalCompareMode } from './useGlobalCompareMode' import { type SensorWithLatestMeasurement } from '~/db/schema' import { getArchiveLink } from '~/lib/archive-link' import { type loader } from '~/routes/explore.$deviceId' @@ -104,7 +100,6 @@ export default function DeviceDetailBox() { const [open, setOpen] = useState(true) const [offsetPositionX, setOffsetPositionX] = useState(0) const [offsetPositionY, setOffsetPositionY] = useState(0) - const [compareMode, setCompareMode] = useGlobalCompareMode() const [refreshOn] = useState(false) const [refreshSecond, setRefreshSecond] = useState(59) @@ -121,6 +116,9 @@ export default function DeviceDetailBox() { const { deviceId } = useParams() // Get the deviceId from the URL params const createSensorLink = (sensorIdToBeSelected: string) => { + const sensorSearchParams = new URLSearchParams(searchParams.toString()) + const query = sensorSearchParams.toString() + const search = query ? `?${query}` : '' const lastSegment = matches[matches.length - 1]?.params?.['*'] if (lastSegment) { const secondLastSegment = matches[matches.length - 2]?.params?.sensorId @@ -137,13 +135,13 @@ export default function DeviceDetailBox() { if (sensorIds.has(sensorIdToBeSelected) && sensorIds.size === 2) { const clonedSet = new Set(sensorIds) clonedSet.delete(sensorIdToBeSelected) - return `/explore/${deviceId}/${Array.from(clonedSet).join('/')}?${searchParams.toString()}` + return `/explore/${deviceId}/${Array.from(clonedSet).join('/')}${search}` } else if (sensorIds.has(sensorIdToBeSelected) && sensorIds.size === 1) { - return `/explore/${deviceId}?${searchParams.toString()}` + return `/explore/${deviceId}${search}` } else if (sensorIds.size === 0) { - return `/explore/${deviceId}/${sensorIdToBeSelected}?${searchParams.toString()}` + return `/explore/${deviceId}/${sensorIdToBeSelected}${search}` } else if (sensorIds.size === 1) { - return `/explore/${deviceId}/${Array.from(sensorIds).join('/')}/${sensorIdToBeSelected}?${searchParams.toString()}` + return `/explore/${deviceId}/${Array.from(sensorIds).join('/')}/${sensorIdToBeSelected}${search}` } return '' @@ -246,13 +244,6 @@ export default function DeviceDetailBox() { > {t('actions')} - - - {t('compare')} - @@ -627,21 +618,6 @@ export default function DeviceDetailBox() { )} - {compareMode && ( - - { - setCompareMode(!compareMode) - setOpen(true) - }} - /> - {t('compare_devices')} - - {t('choose_device_for_comparison')} - - - )} {!open && (
{ diff --git a/app/components/device-detail/graph.tsx b/app/components/device-detail/graph.tsx index 2186a98f6..130d6c97b 100644 --- a/app/components/device-detail/graph.tsx +++ b/app/components/device-detail/graph.tsx @@ -15,6 +15,7 @@ import 'chartjs-adapter-date-fns' import { Download, RefreshCcw, X } from 'lucide-react' import { useMemo, + useCallback, useRef, useState, useEffect, @@ -31,6 +32,15 @@ import { ColorPicker } from '../color-picker' import { DateRangeFilter } from '../daterange-filter' import { HoveredPointContext } from '../map/layers/mobile/mobile-box-layer' import Spinner from '../spinner' +import { + Combobox, + ComboboxCollection, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from '../ui/combobox' import { DropdownMenu, DropdownMenuContent, @@ -57,6 +67,8 @@ ChartJS.register( Filler, ) +const maxComparableDevices = 5 + // ClientOnly component to handle the plugin that needs window const GraphWithZoom = (props: any) => { useMemo(() => { @@ -77,14 +89,39 @@ const GraphWithZoom = (props: any) => { interface GraphProps { aggregation: string - sensors: any[] + sensors: GraphSensor[] + compareCandidates?: { + id: string + title: string + unit: string | null + deviceId: string + deviceName: string + }[] startDate?: string endDate?: string } +type GraphSensor = { + id: string + title: string + unit?: string | null + deviceId: string + deviceName?: string + device_name?: string + data: any[] + color: string +} + +type CompareCandidate = NonNullable[number] + +function getSensorDeviceName(sensor: GraphSensor) { + return sensor.deviceName ?? sensor.device_name ?? sensor.deviceId +} + export default function Graph({ aggregation, sensors, + compareCandidates = [], startDate, endDate, }: GraphProps) { @@ -136,105 +173,60 @@ export default function Graph({ // 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 + const includeDeviceName = useMemo(() => { + return new Set(sensors.map(getSensorDeviceName)).size > 1 + }, [sensors]) - 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, - } + const useSharedYAxis = useMemo(() => { + const firstSensor = sensors[0] - 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, - } + if (!firstSensor) return true - 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 sensors.every( + (sensor) => + sensor.title === firstSensor.title && sensor.unit === firstSensor.unit, + ) + }, [sensors]) - return [maxDataset, baseDataset, minDataset] - } + const selectedSensorIds = useMemo(() => { + return new Set(sensors.map((sensor) => sensor.id)) + }, [sensors]) - return [baseDataset] - }, - ) - .flat(), - } - }) + const availableCompareCandidates = useMemo(() => { + if (sensors.length >= maxComparableDevices) return [] - useEffect(() => { - const includeDeviceName = - sensors.length === 2 && sensors[0].device_name !== sensors[1].device_name + return compareCandidates.filter( + (candidate) => !selectedSensorIds.has(candidate.id), + ) + }, [compareCandidates, selectedSensorIds]) + + const createDatasetLabel = useCallback( + (sensor: GraphSensor) => { + if (!includeDeviceName) return sensor.title - setChartData({ + return `${sensor.title} (${getSensorDeviceName(sensor)})` + }, + [includeDeviceName], + ) + + const createChartData = useCallback( + (pointRadius: number) => ({ datasets: sensors - .map( - ( - sensor: { - title: any - device_name: any - data: any[] - color: string - }, - index: number, - ) => { + .map((sensor, index: number) => { const baseDataset = { - label: includeDeviceName - ? `${sensor.title} (${sensor.device_name})` - : sensor.title, + label: createDatasetLabel(sensor), + unit: sensor.unit ?? '', + sensorId: sensor.id, + deviceName: getSensorDeviceName(sensor), data: sensor.data.map((measurement) => ({ x: measurement.time, y: measurement.value, locationId: measurement.locationId, })), - pointRadius: 1, + pointRadius, borderColor: sensor.color, backgroundColor: sensor.color, - yAxisID: index === 0 ? 'y' : 'y1', + yAxisID: useSharedYAxis || index === 0 ? 'y' : 'y1', fill: false, tension: 0.4, } @@ -243,6 +235,7 @@ export default function Graph({ const minDataset = { ...baseDataset, label: `${baseDataset.label} (Min)`, + unit: sensor.unit ?? '', data: sensor.data.map((measurement) => ({ x: measurement.time, y: measurement.min_value, @@ -256,6 +249,7 @@ export default function Graph({ const maxDataset = { ...baseDataset, label: `${baseDataset.label} (Max)`, + unit: sensor.unit ?? '', data: sensor.data.map((measurement) => ({ x: measurement.time, y: measurement.max_value, @@ -270,11 +264,17 @@ export default function Graph({ } return [baseDataset] - }, - ) + }) .flat(), - }) - }, [sensors, isAggregated]) + }), + [sensors, createDatasetLabel, isAggregated, useSharedYAxis], + ) + + const [chartData, setChartData] = useState(() => createChartData(3)) + + useEffect(() => { + setChartData(createChartData(1)) + }, [createChartData]) const options: ChartOptions<'scatter'> = useMemo(() => { return { @@ -353,10 +353,15 @@ export default function Graph({ y1: { title: { display: true, - text: sensors[1] ? sensors[1].title + ' in ' + sensors[1].unit : '', //data.sensors[1].unit + text: useSharedYAxis + ? '' + : sensors + .slice(1) + .map((sensor) => `${sensor.title} in ${sensor.unit}`) + .join(' / '), }, // type: 'linear', - display: 'auto', + display: !useSharedYAxis && sensors.length > 1, position: 'right', grid: { drawOnChartArea: false, @@ -387,7 +392,11 @@ export default function Graph({ setHoveredPoint(locationId) - return `${context.dataset.label}: ${context.raw.y}` + const unit = context.dataset.unit + ? ` ${context.dataset.unit}` + : '' + + return `${context.dataset.label}: ${context.raw.y}${unit}` }, }, }, @@ -449,6 +458,7 @@ export default function Graph({ currentZoom?.xMax, theme, sensors, + useSharedYAxis, chartData.datasets, setHoveredPoint, colorPickerState.open, @@ -539,6 +549,23 @@ export default function Graph({ } } + function createCompareLink(sensorId: string) { + const graphSearchParams = new URLSearchParams(searchParams.toString()) + const query = graphSearchParams.toString() + const sensorIds = sensors + .map((sensor) => sensor.id) + .slice(0, maxComparableDevices - 1) + + return `/explore/${sensors[0].deviceId}/${[...sensorIds, sensorId].join('/')}${query ? `?${query}` : ''}` + } + + function handleCompareDeviceSelect(candidate: CompareCandidate | null) { + if (!candidate) return + if (sensors.length >= maxComparableDevices) return + + void navigate(createCompareLink(candidate.id)) + } + function handleDrag(_e: any, data: DraggableData) { setOffsetPositionX(data.x) setOffsetPositionY(data.y) @@ -572,6 +599,42 @@ export default function Graph({
+ {sensors.length > 0 && ( + + items={availableCompareCandidates} + value={null} + itemToStringLabel={(candidate) => + candidate?.deviceName ?? '' + } + itemToStringValue={(candidate) => candidate?.id ?? ''} + onValueChange={handleCompareDeviceSelect} + > + + + {t('no_matching_devices')} + + + {(candidate: CompareCandidate, index: number) => ( + + + {candidate.deviceName} + + + )} + + + + + )} {currentZoom !== null && currentZoom.xMax !== 0 && currentZoom.xMin !== 0 && ( @@ -620,8 +683,7 @@ export default function Graph({
- {(sensors[0].data.length === 0 && sensors[1] === undefined) || - (sensors[0].data.length === 0 && sensors[1].data.length === 0) ? ( + {sensors.every((sensor) => sensor.data.length === 0) ? (
{t('no_data_in_range')}
) : ( }> diff --git a/app/components/device-detail/useGlobalCompareMode.tsx b/app/components/device-detail/useGlobalCompareMode.tsx deleted file mode 100644 index 30cf04701..000000000 --- a/app/components/device-detail/useGlobalCompareMode.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useEffect, useState } from 'react' - -let globalValue = false // Shared global state -let listeners: ((value: boolean) => void)[] = [] // Listeners for updates - -export const useGlobalCompareMode = () => { - const [compareMode, setCompareMode] = useState(globalValue) - - // Function to update the global value and notify listeners - const updateValue = (newValue: boolean) => { - globalValue = newValue - listeners.forEach((listener) => listener(newValue)) - } - - // Subscribe to global state updates - const subscribe = (listener: (value: boolean) => void) => { - listeners.push(listener) - return () => { - listeners = listeners.filter((l) => l !== listener) - } - } - - // Effect to keep the local state synced with the global state - useEffect(() => { - const unsubscribe = subscribe(setCompareMode) - return unsubscribe // Clean up on unmount - }, []) - - return [compareMode, updateValue] as const -} diff --git a/app/components/map/layers/cluster/box-marker.tsx b/app/components/map/layers/cluster/box-marker.tsx index b2d59aa6b..9811ad061 100644 --- a/app/components/map/layers/cluster/box-marker.tsx +++ b/app/components/map/layers/cluster/box-marker.tsx @@ -2,8 +2,7 @@ import { AnimatePresence, motion } from 'framer-motion' import { Box, Rocket } from 'lucide-react' import { useState } from 'react' import { type MarkerProps, Marker, useMap } from 'react-map-gl/maplibre' -import { useMatches, useNavigate, useSearchParams } from 'react-router' -import { useGlobalCompareMode } from '~/components/device-detail/useGlobalCompareMode' +import { useNavigate, useSearchParams } from 'react-router' import { type Device } from '~/db/schema' import { validLngLat } from '~/lib/location' import { cn } from '~/lib/utils' @@ -27,9 +26,7 @@ const getStatusColor = (device: Device) => { export default function BoxMarker({ device, ...props }: BoxMarkerProps) { const navigate = useNavigate() - const matches = useMatches() const { osem } = useMap() - const [compareMode, setCompareMode] = useGlobalCompareMode() const isFullZoom = osem && osem?.getZoom() >= 14 @@ -68,19 +65,13 @@ export default function BoxMarker({ device, ...props }: BoxMarkerProps) { isFullZoom ? '-top-5 -left-5' : '-top-3.75 -left-3.75', )} onClick={() => { - if (searchParams.has('sensor')) { - searchParams.delete('sensor') - } - if (compareMode) { - void navigate( - `/explore/${matches[2].params.deviceId}/compare/${device.id}`, - ) - setCompareMode(false) - return - } + const nextSearchParams = new URLSearchParams( + searchParams.toString(), + ) + nextSearchParams.delete('sensor') void navigate({ pathname: `${device.id}`, - search: searchParams.toString(), + search: nextSearchParams.toString(), }) }} onHoverStart={() => setIsHovered(true)} diff --git a/app/components/search/search-list.tsx b/app/components/search/search-list.tsx index 5ba9911eb..51f5662d9 100644 --- a/app/components/search/search-list.tsx +++ b/app/components/search/search-list.tsx @@ -1,8 +1,7 @@ import { Cpu, Globe, MapPin } from 'lucide-react' import { useCallback, useContext, useEffect, useMemo } from 'react' import { useMap } from 'react-map-gl/maplibre' -import { useMatches, useNavigate, useSearchParams } from 'react-router' -import { useGlobalCompareMode } from '../device-detail/useGlobalCompareMode' +import { useNavigate, useSearchParams } from 'react-router' import { NavbarContext } from '../header/nav-bar' import useKeyboardNav from '../header/nav-bar/use-keyboard-nav' import SearchListItem from './search-list-item' @@ -21,10 +20,8 @@ interface SearchListProps { export default function SearchList(props: SearchListProps) { const { osem } = useMap() const navigate = useNavigate() - const matches = useMatches() const [searchParams] = useSearchParams() const { setOpen } = useContext(NavbarContext) - const [compareMode] = useGlobalCompareMode() const searchResultsAll = useMemo( () => [...props.searchResultsDevice, ...props.searchResultsLocation], @@ -47,22 +44,16 @@ export default function SearchList(props: SearchListProps) { const handleNavigate = useCallback( (result: SearchResult) => { - const params = searchParams.toString() - const suffix = params ? `?${params}` : '' + const search = searchParams.toString() + const suffix = search ? `?${search}` : '' if (result.type === 'device') { - const baseDeviceId = matches[2]?.params?.deviceId - - if (compareMode && baseDeviceId) { - return `/explore/${baseDeviceId}/compare/${result.deviceId}` - } - return `/explore/${result.deviceId}${suffix}` } return `/explore${suffix}` }, - [searchParams, compareMode, matches], + [searchParams], ) const selectResult = useCallback( diff --git a/app/db/models/sensor.server.ts b/app/db/models/sensor.server.ts index cdb1c7e72..22b839692 100644 --- a/app/db/models/sensor.server.ts +++ b/app/db/models/sensor.server.ts @@ -1,4 +1,4 @@ -import { eq, sql, inArray, and } from 'drizzle-orm' +import { eq, sql, inArray, and, ne, isNull } from 'drizzle-orm' import { type Measurement, sensor, @@ -204,6 +204,44 @@ export function getSensor(id: Sensor['id']) { }) } +export type ComparableSensor = { + id: string + title: string + unit: string | null + deviceId: string + deviceName: string +} + +export async function getComparableSensors( + baseSensor: Pick, +): Promise { + const conditions = [ + eq(sensor.title, baseSensor.title), + ne(sensor.id, baseSensor.id), + ne(sensor.deviceId, baseSensor.deviceId), + isNull(device.archivedAt), + ] + + if (baseSensor.unit === null) { + conditions.push(isNull(sensor.unit)) + } else { + conditions.push(eq(sensor.unit, baseSensor.unit)) + } + + return drizzleClient + .select({ + id: sensor.id, + title: sensor.title, + unit: sensor.unit, + deviceId: sensor.deviceId, + deviceName: device.name, + }) + .from(sensor) + .innerJoin(device, eq(sensor.deviceId, device.id)) + .where(and(...conditions)) + .orderBy(device.name) +} + export function deleteSensor(id: Sensor['id']) { return drizzleClient.delete(sensor).where(eq(sensor.id, id)) } diff --git a/app/routes/explore.$deviceId.$sensorId.$.tsx b/app/routes/explore.$deviceId.$sensorId.$.tsx index 40f6ed038..70f9a9de6 100644 --- a/app/routes/explore.$deviceId.$sensorId.$.tsx +++ b/app/routes/explore.$deviceId.$sensorId.$.tsx @@ -5,7 +5,7 @@ import Graph from '~/components/device-detail/graph' import MobileBoxView from '~/components/map/layers/mobile/mobile-box-view' import { getDevice } from '~/db/models/device.server' import { getMeasurement } from '~/db/models/measurement.query.server' -import { getSensor } from '~/db/models/sensor.server' +import { getComparableSensors, getSensor } from '~/db/models/sensor.server' import { type SensorWithMeasurementData } from '~/db/schema' import { categorizeIntoTrips, @@ -14,11 +14,119 @@ import { interface SensorWithColor extends SensorWithMeasurementData { color: string + deviceName: string +} + +const sensorColors = [ + '#8da0cb', + '#fc8d62', + '#66c2a5', + '#e78ac3', + '#a6d854', + '#ffd92f', + '#e5c494', + '#b3b3b3', +] + +const maxGraphSensors = 5 + +function normalizeMeasurementData( + measurementData: { + sensorId: string + locationId: bigint | null + time: Date + value: number | null + location: { + id: bigint + x: number + y: number + } | null + }[], +) { + return measurementData.map((d) => ({ + ...d, + locationId: d.locationId === null ? null : Number(d.locationId), + location: d.location + ? { + ...d.location, + id: Number(d.location.id), + } + : null, + })) +} + +function filterLatestTripData( + measurementData: ReturnType, +) { + const dataPoints: LocationPoint[] = measurementData + .filter((d) => d.location !== null) + .map((d) => ({ + geometry: { x: d.location!.x, y: d.location!.y }, + time: d.time.toISOString(), + })) + + const trips = categorizeIntoTrips(dataPoints, 600) + const latestTrip = trips[0] + + if (!latestTrip) { + return measurementData + } + + const tripStartTime = new Date(latestTrip.startTime).getTime() + const tripEndTime = new Date(latestTrip.endTime).getTime() + + return measurementData.filter((point) => { + const pointTime = point.time.getTime() + return pointTime >= tripStartTime && pointTime <= tripEndTime + }) +} + +async function loadGraphSensor({ + sensorId, + aggregation, + startDate, + endDate, + color, +}: { + sensorId: string + aggregation: string + startDate: string | null + endDate: string | null + color: string +}): Promise { + const sensor = (await getSensor(sensorId)) as SensorWithColor | null + + if (!sensor) return null + + const sensorDevice = await getDevice({ id: sensor.deviceId }) + const sensorData = await getMeasurement( + sensorId, + aggregation, + startDate ? new Date(startDate) : undefined, + endDate ? addDays(new Date(endDate), 1) : undefined, + ) + const normalizedData = normalizeMeasurementData(sensorData as any) + const data = + sensorDevice?.exposure === 'mobile' && !startDate + ? filterLatestTripData(normalizedData) + : normalizedData + + sensor.data = data.map((d) => ({ + ...d, + sensorId, + locationId: d.locationId ?? null, + location: d.location, + time: d.time, + value: d.value ?? 0, + })) + sensor.color = color + sensor.deviceName = sensorDevice?.name ?? sensor.deviceId + + return sensor } export async function loader({ params, request }: Route.LoaderArgs) { const { deviceId, sensorId } = params - const sensorId2 = params['*'] if (!deviceId) { return redirect('/explore') @@ -39,171 +147,41 @@ export async function loader({ params, request }: Route.LoaderArgs) { throw new Response('Sensor 1 not found', { status: 404 }) } - const sensor1 = (await getSensor(sensorId)) as SensorWithColor - const sensor1Data = await getMeasurement( - sensorId, - aggregation, - startDate ? new Date(startDate) : undefined, - endDate ? addDays(new Date(endDate), 1) : undefined, - ) - - const normalizedSensor1Data = ( - sensor1Data as { - sensorId: string - locationId: bigint | null - time: Date - value: number | null - location: { - id: bigint - x: number - y: number - } - }[] - ).map((d) => ({ - ...d, - locationId: Number(d.locationId), - location: d.location - ? { - ...d.location, - id: Number(d.location.id), - } - : null, - })) - - // If device exposure is 'mobile', process trips - if (device.exposure === 'mobile' && !startDate) { - // Categorize data into trips - const dataPoints: LocationPoint[] = normalizedSensor1Data - .filter((d) => d.location !== null) - .map((d) => ({ - // null locations cannot be shown on the map and have been filtered above - // hence the ! operator is fine here - geometry: { x: d.location!.x, y: d.location!.y }, - time: d.time.toISOString(), // Ensure the time is in ISO format - })) - - const trips = categorizeIntoTrips(dataPoints, 600) // 600 seconds (10 minutes) as the time threshold - - // Get the latest 5 trips - const latestTrips = trips.slice(0, 1) - - // Calculate the time range of the latest 5 trips - const latestTripTimeRange = { - startTime: latestTrips[0].startTime, - endTime: latestTrips[latestTrips.length - 1].endTime, - } - - // Filter sensor data to include only the points within the time range of the latest 5 trips - const filteredData = normalizedSensor1Data.filter((point) => { - const pointTime = point.time.getTime() - const tripStartTime = new Date(latestTripTimeRange.startTime).getTime() - const tripEndTime = new Date(latestTripTimeRange.endTime).getTime() - - // Keep only the points within the time range of the latest trips - return pointTime >= tripStartTime && pointTime <= tripEndTime - }) - - // Update sensor1 data with the filtered data - sensor1.data = filteredData.map((d) => ({ - ...d, - sensorId: sensorId, // Set the sensorId to match - locationId: d.locationId ?? null, // Retain the locationId if available - location: d.location, - time: d.time, // Keep the timestamp - value: d.value ?? 0, // Set value to the actual value or default it to 0 - })) - - sensor1.color = sensor1.color || '#8da0cb' - } else { - sensor1.data = normalizedSensor1Data - sensor1.color = sensor1.color || '#8da0cb' - } - - let sensor2: SensorWithColor | null = null - - if (sensorId2) { - sensor2 = (await getSensor(sensorId2)) as SensorWithColor - const sensor2Data = await getMeasurement( - sensorId2, - aggregation, - startDate ? new Date(startDate) : undefined, - endDate ? addDays(new Date(endDate), 1) : undefined, + const comparisonSensorIds = Array.from( + new Set( + (params['*'] ?? '') + .split('/') + .map((id) => id.trim()) + .filter(Boolean), + ), + ).slice(0, maxGraphSensors - 1) + + const sensors = ( + await Promise.all( + [sensorId, ...comparisonSensorIds].map((id, index) => + loadGraphSensor({ + sensorId: id, + aggregation, + startDate, + endDate, + color: sensorColors[index % sensorColors.length], + }), + ), ) + ).filter((sensor): sensor is SensorWithColor => sensor !== null) - const normalizedSensor2Data = ( - sensor2Data as { - sensorId: string - locationId: bigint | null - time: Date - value: number | null - location: { - id: bigint - x: number - y: number - } - }[] - ).map((d) => ({ - ...d, - locationId: Number(d.locationId), - location: d.location - ? { - ...d.location, - id: Number(d.location.id), - } - : null, - })) + const sensor1 = sensors[0] - if (device.exposure === 'mobile') { - // Categorize data into trips - const dataPoints: LocationPoint[] = normalizedSensor2Data - .filter((d) => d.location !== null) - .map((d) => ({ - // null locations cannot be shown on the map and have been filtered above - // hence the ! operator is fine here - geometry: { x: d.location!.x, y: d.location!.y }, - time: d.time.toISOString(), // Ensure the time is in ISO format - })) - - const trips = categorizeIntoTrips(dataPoints, 600) // 600 seconds (10 minutes) as the time threshold - - // Get the latest trip --- slice to get more trips if needed - const latestTrips = trips.slice(0, 1) - - // Calculate the time range of the latest 5 trips - const latestTripTimeRange = { - startTime: latestTrips[0].startTime, - endTime: latestTrips[latestTrips.length - 1].endTime, - } - - // Filter sensor data to include only the points within the time range of the latest 5 trips - const filteredData = normalizedSensor2Data.filter((point) => { - const pointTime = point.time.getTime() - const tripStartTime = new Date(latestTripTimeRange.startTime).getTime() - const tripEndTime = new Date(latestTripTimeRange.endTime).getTime() - - // Keep only the points within the time range of the latest trips - return pointTime >= tripStartTime && pointTime <= tripEndTime - }) - - // Update sensor2 data with the filtered data - sensor2.data = filteredData.map((d) => ({ - ...d, - sensorId: sensorId2, // Set the sensorId to match - locationId: d.locationId ?? null, // Retain the locationId if available - location: d.location, - time: d.time, // Keep the timestamp - value: d.value ?? 0, // Set value to the actual value or default it to 0 - })) - sensor2.color = sensor2.color || '#fc8d62' - } else { - sensor2.data = normalizedSensor2Data - sensor2.color = sensor2.color || '#fc8d62' - } + if (!sensor1) { + throw new Response('Sensor 1 not found', { status: 404 }) } + const compareCandidates = await getComparableSensors(sensor1) + return { device, - sensors: sensor2 ? [sensor1, sensor2] : [sensor1], + sensors, + compareCandidates, startDate, endDate, aggregation, @@ -218,6 +196,7 @@ export default function SensorView() { {loaderData.device?.exposure === 'mobile' && ( diff --git a/app/routes/explore.tsx b/app/routes/explore.tsx index d829b80c5..ff3a032e8 100644 --- a/app/routes/explore.tsx +++ b/app/routes/explore.tsx @@ -404,6 +404,11 @@ export default function Explore() { feature.layer?.id === 'phenomenon-layer' || feature.layer?.id === 'devices-symbol-layer' ) { + const featureDeviceId = feature.properties?.id + + if (!featureDeviceId) return + const query = searchParams.toString() + map.flyTo({ center: coordinates, zoom: Math.max(map.getZoom(), 14), @@ -412,7 +417,7 @@ export default function Explore() { essential: true, }) void navigate( - `/explore/${feature.properties?.id}?${searchParams.toString()}`, + `/explore/${String(featureDeviceId)}${query ? `?${query}` : ''}`, ) } diff --git a/public/locales/de/device-detail-box.json b/public/locales/de/device-detail-box.json index 2547185e7..257997556 100644 --- a/public/locales/de/device-detail-box.json +++ b/public/locales/de/device-detail-box.json @@ -8,7 +8,5 @@ "open_external_link": "Externen Link öffnen", "description": "Beschreibung", "sensors": "Sensoren", - "compare_devices": "Geräte vergleichen", - "choose_device_for_comparison": "Wähle ein Gerät auf der Karte für den Vergleich.", "open_device_details": "Öffne Gerätedetails" } diff --git a/public/locales/de/graph.json b/public/locales/de/graph.json index f70522a00..5288b7dae 100644 --- a/public/locales/de/graph.json +++ b/public/locales/de/graph.json @@ -10,6 +10,9 @@ "apply": "Anwenden", "no_data_in_range": "Es liegen keine Daten in der ausgewählten Zeitspanne vor.", "reset_zoom": "Zoom zurücksetzen", + "add_device_to_graph": "Weiteres Gerät zu diesem Diagramm hinzufügen", + "search_device": "Gerät hinzufügen...", + "no_matching_devices": "Keine weiteren Geräte mit diesem Sensor gefunden", "dateRanges": { "last-30-minutes": "Letzte 30 Minuten", "last-1-hour": "Letzte Stunde", diff --git a/public/locales/en/device-detail-box.json b/public/locales/en/device-detail-box.json index b758a9c0d..4673c486d 100644 --- a/public/locales/en/device-detail-box.json +++ b/public/locales/en/device-detail-box.json @@ -8,7 +8,5 @@ "open_external_link": "Open external link", "description": "Description", "sensors": "Sensors", - "compare_devices": "Compare devices", - "choose_device_for_comparison": "Choose a device on the map to compare with.", "open_device_details": "Open device details" } diff --git a/public/locales/en/graph.json b/public/locales/en/graph.json index ba493f53d..801e67530 100644 --- a/public/locales/en/graph.json +++ b/public/locales/en/graph.json @@ -10,6 +10,9 @@ "apply": "Apply", "no_data_in_range": "There is no data for the selected time period.", "reset_zoom": "Reset zoom", + "add_device_to_graph": "Add another device to this graph", + "search_device": "Add device...", + "no_matching_devices": "No other devices with this sensor found", "dateRanges": { "last-30-minutes": "Last 30 minutes", "last-1-hour": "Last 1 hour",