{
diff --git a/app/components/device-detail/graph.tsx b/app/components/device-detail/graph.tsx
index 2186a98f..130d6c97 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[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 30cf0470..00000000
--- 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 b2d59aa6..9811ad06 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 5ba9911e..51f5662d 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 cdb1c7e7..22b83969 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 40f6ed03..70f9a9de 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 d829b80c..ff3a032e 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 2547185e..25799755 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 f70522a0..5288b7da 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 b758a9c0..4673c486 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 ba493f53..801e6753 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",