()
+
+ useEffect(() => {
+ const sortedSensors = [...data.sensors].sort(
+ (a, b) => (a.id as unknown as number) - (b.id as unknown as number),
+ )
+ setSensors(sortedSensors)
+ }, [data.sensors])
+
+ useEffect(() => {
+ let interval: any = null
+ if (refreshOn) {
+ if (refreshSecond == 0) {
+ setRefreshSecond(59)
+ }
+ interval = setInterval(() => {
+ setRefreshSecond((refreshSecond) => refreshSecond - 1)
+ }, 1000)
+ } else if (!refreshOn) {
+ clearInterval(interval)
+ }
+ return () => clearInterval(interval)
+ }, [refreshOn, refreshSecond])
+
+ function handleDrag(_e: any, data: DraggableData) {
+ setOffsetPositionX(data.x)
+ setOffsetPositionY(data.y)
+ }
+
+ const addLineBreaks = (text: string) =>
+ text.split('\\n').map((text, index) => (
+
+ {text}
+
+
+ ))
+
+ if (!data.device) return null
+
+ return (
+ <>
+ {open && (
+
+
+
+ {navigation.state === 'loading' && (
+
+
+
+ )}
+ {/* this is the header */}
+
+
+
+ {data.device.name}
+
+
+
+
+
+
+
+ Share this link
+
+
+
+ Close
+
+
+
+
+
setOpen(false)}
+ />
+ {
+ void navigate({
+ pathname: '/explore',
+ search: searchParams.toString(),
+ })
+ }}
+ />
+
+
+ {/* this is the metadata */}
+
+
+
+
+ {/* here are the tags */}
+ {data.device.tags &&
}
+
+ {/* log entry component */}
+ {data.device.logEntries.length > 0 && (
+ <>
+
+
+ >
+ )}
+ {/* description component */}
+ {data.device.description && (
+
+
+
+ Description
+
+
+ {addLineBreaks(data.device.description)}
+
+
+
+ )}
+ {/* sensors component */}
+
+
+
+ Sensors
+
+
+ {sensors && }
+
+
+
+
+
+
+
+ )}
+ {!open && (
+ {
+ setOpen(true)
+ }}
+ className="absolute bottom-[10px] left-4 flex cursor-pointer rounded-xl border border-gray-100 bg-white shadow-lg transition-colors duration-300 ease-in-out hover:brightness-90 dark:bg-zinc-800 dark:text-zinc-200 dark:opacity-90 sm:bottom-[30px] sm:left-[10px]"
+ >
+
+
+
+
+
+
+
+
+ Open device details
+
+
+
+
+ )}
+ >
+ )
+}
diff --git a/app/components/device-overview/device-image.tsx b/app/components/device-overview/device-image.tsx
new file mode 100644
index 00000000..ca471ac6
--- /dev/null
+++ b/app/components/device-overview/device-image.tsx
@@ -0,0 +1,19 @@
+import { Image as ImageIcon } from 'lucide-react'
+
+export default function DeviceImage({ image }: { image: string | null }) {
+ return (
+
+ {image ? (
+

+ ) : (
+
+
+
+ )}
+
+ )
+}
diff --git a/app/components/device-overview/device-metadata-info.tsx b/app/components/device-overview/device-metadata-info.tsx
new file mode 100644
index 00000000..8a66d0bb
--- /dev/null
+++ b/app/components/device-overview/device-metadata-info.tsx
@@ -0,0 +1,51 @@
+import { Separator } from '../ui/separator'
+import { format } from 'date-fns'
+import { CalendarPlus, Cpu, LandPlot, Rss } from 'lucide-react'
+import InfoItem from './info-item'
+
+export default function DeviceMetadataInfo({
+ exposure,
+ sensorWikiModel,
+ updatedAt,
+ createdAt,
+ expiresAt,
+}: {
+ exposure: string | null
+ sensorWikiModel: string | null
+ updatedAt: Date
+ createdAt: Date
+ expiresAt?: Date | null
+}) {
+ return (
+
+
+
+
+
+
+
+ {expiresAt && (
+ <>
+
+
+ >
+ )}
+
+ )
+}
diff --git a/app/components/device-overview/device-options.tsx b/app/components/device-overview/device-options.tsx
new file mode 100644
index 00000000..10550a1b
--- /dev/null
+++ b/app/components/device-overview/device-options.tsx
@@ -0,0 +1,77 @@
+import { Link } from 'react-router'
+import { Archive, EllipsisVertical, ExternalLink, Scale } from 'lucide-react'
+import { Button } from '../ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '../ui/dropdown-menu'
+import { getArchiveLink } from '~/utils/device'
+
+export default function DeviceOptions({
+ id,
+ name,
+ link,
+}: {
+ id: string
+ name: string
+ link: string | null
+}) {
+ return (
+
+
+
+
+
+ Actions
+
+
+
+
+ Compare
+
+
+
+
+
+
+ Archive
+
+
+
+
+
+
+
+ External Link
+
+
+
+
+
+ )
+}
diff --git a/app/components/device-overview/device-tags.tsx b/app/components/device-overview/device-tags.tsx
new file mode 100644
index 00000000..86b5a6bb
--- /dev/null
+++ b/app/components/device-overview/device-tags.tsx
@@ -0,0 +1,70 @@
+import { useNavigate, useSearchParams } from 'react-router'
+import { Hash } from 'lucide-react'
+import { Badge } from '../ui/badge'
+import clsx from 'clsx'
+
+export default function DeviceTags({ tags }: { tags: string[] }) {
+ const [searchParams] = useSearchParams()
+ const navigate = useNavigate()
+
+ return (
+ <>
+ {tags && tags.length > 0 && (
+
+
+
+ Tags
+
+
+
+
+ {tags.map((tag: string) => (
+ {
+ event.stopPropagation()
+
+ const currentParams = new URLSearchParams(
+ searchParams.toString(),
+ )
+
+ // Safely retrieve and parse the current tags
+ const currentTags =
+ currentParams.get('tags')?.split(',') || []
+
+ // Toggle the tag in the list
+ const updatedTags = currentTags.includes(tag)
+ ? currentTags.filter((t) => t !== tag) // Remove if already present
+ : [...currentTags, tag] // Add if not present
+
+ // Update the tags parameter or remove it if empty
+ if (updatedTags.length > 0) {
+ currentParams.set('tags', updatedTags.join(','))
+ } else {
+ currentParams.delete('tags')
+ }
+
+ // Update the URL with the new search params
+ void navigate({
+ search: currentParams.toString(),
+ })
+ }}
+ >
+ {tag}
+
+ ))}
+
+
+
+
+ )}
+ >
+ )
+}
diff --git a/app/components/device-detail/entry-logs.tsx b/app/components/device-overview/entry-logs.tsx
similarity index 100%
rename from app/components/device-detail/entry-logs.tsx
rename to app/components/device-overview/entry-logs.tsx
diff --git a/app/components/device-detail/graph.tsx b/app/components/device-overview/graph.tsx
similarity index 100%
rename from app/components/device-detail/graph.tsx
rename to app/components/device-overview/graph.tsx
diff --git a/app/components/device-overview/info-item.tsx b/app/components/device-overview/info-item.tsx
new file mode 100644
index 00000000..31368493
--- /dev/null
+++ b/app/components/device-overview/info-item.tsx
@@ -0,0 +1,20 @@
+const InfoItem = ({
+ icon: Icon,
+ title,
+ text,
+}: {
+ icon: React.ElementType
+ title: string
+ text?: string
+}) =>
+ text && (
+
+
{title}
+
+
+ {text}
+
+
+ )
+
+export default InfoItem
diff --git a/app/components/device-detail/profile-box-selection.tsx b/app/components/device-overview/profile-box-selection.tsx
similarity index 100%
rename from app/components/device-detail/profile-box-selection.tsx
rename to app/components/device-overview/profile-box-selection.tsx
diff --git a/app/components/device-overview/sensor-cards.tsx b/app/components/device-overview/sensor-cards.tsx
new file mode 100644
index 00000000..5adfbce0
--- /dev/null
+++ b/app/components/device-overview/sensor-cards.tsx
@@ -0,0 +1,196 @@
+import { formatDistanceToNow } from 'date-fns'
+import {
+ Link,
+ useMatches,
+ useNavigation,
+ useParams,
+ useSearchParams,
+} from 'react-router'
+import {
+ Card,
+ CardContent,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '../ui/card'
+import { toast } from '../ui/use-toast'
+import SensorIcon from '../sensor-icon'
+import { type SensorWithLatestMeasurement } from '~/schema'
+import { Separator } from '../ui/separator'
+
+export default function SensorCards({
+ sensors,
+}: {
+ sensors: SensorWithLatestMeasurement[]
+}) {
+ const navigation = useNavigation()
+ const matches = useMatches()
+ const [searchParams] = useSearchParams()
+ const { deviceId } = useParams()
+
+ const sensorIds = new Set()
+
+ const createSensorLink = (sensorIdToBeSelected: string) => {
+ const lastSegment = matches[matches.length - 1]?.params?.['*']
+ if (lastSegment) {
+ const secondLastSegment = matches[matches.length - 2]?.params?.sensorId
+ sensorIds.add(secondLastSegment)
+ sensorIds.add(lastSegment)
+ } else {
+ const lastSegment = matches[matches.length - 1]?.params?.sensorId
+ if (lastSegment) {
+ sensorIds.add(lastSegment)
+ }
+ }
+
+ // If sensorIdToBeSelected is second selected sensor
+ if (sensorIds.has(sensorIdToBeSelected) && sensorIds.size === 2) {
+ const clonedSet = new Set(sensorIds)
+ clonedSet.delete(sensorIdToBeSelected)
+ return `/explore/${deviceId}/${Array.from(clonedSet).join('/')}?${searchParams.toString()}`
+ } else if (sensorIds.has(sensorIdToBeSelected) && sensorIds.size === 1) {
+ return `/explore/${deviceId}?${searchParams.toString()}`
+ } else if (sensorIds.size === 0) {
+ return `/explore/${deviceId}/${sensorIdToBeSelected}?${searchParams.toString()}`
+ } else if (sensorIds.size === 1) {
+ return `/explore/${deviceId}/${Array.from(sensorIds).join('/')}/${sensorIdToBeSelected}?${searchParams.toString()}`
+ }
+
+ return ''
+ }
+
+ const isSensorActive = (sensorId: string) => {
+ if (sensorIds.has(sensorId)) {
+ return 'bg-green-100 dark:bg-dark-green'
+ }
+ return 'hover:bg-muted'
+ }
+
+ return (
+
+
+ {sensors &&
+ sensors.map((sensor) => {
+ const sensorLink = createSensorLink(sensor.id)
+ if (sensorLink === '') {
+ return (
+
+ toast({
+ title: 'Cant select more than 2 sensors',
+ description: 'Deselect one sensor to select another',
+ variant: 'destructive',
+ })
+ }
+ >
+
+
+ )
+ }
+ return (
+
+
+
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/app/components/device-detail/share-link.tsx b/app/components/device-overview/share-link.tsx
similarity index 100%
rename from app/components/device-detail/share-link.tsx
rename to app/components/device-overview/share-link.tsx
diff --git a/app/components/map/layers/cluster/box-marker.tsx b/app/components/map/layers/cluster/box-marker.tsx
index 0d62048e..7d079856 100644
--- a/app/components/map/layers/cluster/box-marker.tsx
+++ b/app/components/map/layers/cluster/box-marker.tsx
@@ -1,121 +1,126 @@
-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";
-import { useMatches, useNavigate, useSearchParams } from "react-router";
-import { useSharedCompareMode } from "~/components/device-detail/device-detail-box";
-import { cn } from "~/lib/utils";
-import { type Device } from "~/schema";
+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'
+import {
+ useLocation,
+ useMatches,
+ useNavigate,
+ useSearchParams,
+} from 'react-router'
+import { cn } from '~/lib/utils'
+import { type Device } from '~/schema'
interface BoxMarkerProps extends MarkerProps {
- device: Device;
+ device: Device
}
const getStatusColor = (device: Device) => {
- if (device.status === "active") {
- if (device.exposure === "mobile") {
- return "bg-blue-100";
- }
- return "bg-green-300";
- } else if (device.status === "inactive") {
- return "bg-gray-100";
- } else {
- return "bg-gray-100 opacity-50";
- }
-};
+ if (device.status === 'active') {
+ if (device.exposure === 'mobile') {
+ return 'bg-blue-100'
+ }
+ return 'bg-green-300'
+ } else if (device.status === 'inactive') {
+ return 'bg-gray-100'
+ } else {
+ return 'bg-gray-100 opacity-50'
+ }
+}
export default function BoxMarker({ device, ...props }: BoxMarkerProps) {
- const navigate = useNavigate();
- const matches = useMatches();
- const { osem } = useMap();
- const { compareMode, setCompareMode } = useSharedCompareMode();
+ const navigate = useNavigate()
+ const location = useLocation()
+ const matches = useMatches()
+ const { osem } = useMap()
+
+ const compareMode = location.pathname.endsWith('/compare')
- const isFullZoom = osem && osem?.getZoom() >= 14;
+ const isFullZoom = osem && osem?.getZoom() >= 14
- const [isHovered, setIsHovered] = useState(false);
- const [searchParams] = useSearchParams();
+ const [isHovered, setIsHovered] = useState(false)
+ const [searchParams] = useSearchParams()
- // calculate zIndex based on device status and hover
- const getZIndex = () => {
- if (isHovered) {
- return 30;
- }
- // priority to active devices
- if (device.status === "active") {
- return 20;
- }
- if (device.status === "inactive") {
- return 10;
- }
+ // calculate zIndex based on device status and hover
+ const getZIndex = () => {
+ if (isHovered) {
+ return 30
+ }
+ // priority to active devices
+ if (device.status === 'active') {
+ return 20
+ }
+ if (device.status === 'inactive') {
+ return 10
+ }
- return 0;
- };
+ return 0
+ }
- return (
-
-
- {
- if (searchParams.has("sensor")) {
- searchParams.delete("sensor");
- }
- if (compareMode) {
- void navigate(
- `/explore/${matches[2].params.deviceId}/compare/${device.id}`,
- );
- setCompareMode(false);
- return;
- }
- void navigate({
- pathname: `${device.id}`,
- search: searchParams.toString(),
- });
- }}
- onHoverStart={() => setIsHovered(true)}
- onHoverEnd={() => setIsHovered(false)}
- >
-
- {device.exposure === "mobile" ? (
-
- ) : (
-
- )}
- {device.status === "active" ? (
-
- ) : null}
-
- {isFullZoom ? (
-
- {device.name}
-
- ) : null}
-
-
-
- );
+ return (
+
+
+ {
+ if (searchParams.has('sensor')) {
+ searchParams.delete('sensor')
+ }
+ if (compareMode) {
+ void navigate(
+ `/explore/${matches[2].params.deviceId}/compare/${device.id}`,
+ )
+ return
+ }
+ void navigate({
+ pathname: `${device.id}`,
+ search: searchParams.toString(),
+ })
+ }}
+ onHoverStart={() => setIsHovered(true)}
+ onHoverEnd={() => setIsHovered(false)}
+ >
+
+ {device.exposure === 'mobile' ? (
+
+ ) : (
+
+ )}
+ {device.status === 'active' ? (
+
+ ) : null}
+
+ {isFullZoom ? (
+
+ {device.name}
+
+ ) : null}
+
+
+
+ )
}
diff --git a/app/components/search/search-list.tsx b/app/components/search/search-list.tsx
index ab460b6a..fbddd7d6 100644
--- a/app/components/search/search-list.tsx
+++ b/app/components/search/search-list.tsx
@@ -1,170 +1,176 @@
-import { Cpu, Globe, MapPin } from "lucide-react";
-import { useState, useEffect, useCallback, useContext } from "react";
-import { useMap } from "react-map-gl";
-import { useMatches, useNavigate, useSearchParams } from "react-router";
+import { Cpu, Globe, MapPin } from 'lucide-react'
+import { useState, useEffect, useCallback, useContext } from 'react'
+import { useMap } from 'react-map-gl'
+import {
+ useLocation,
+ useMatches,
+ useNavigate,
+ useSearchParams,
+} from 'react-router'
-import { useSharedCompareMode } from "../device-detail/device-detail-box";
-import { NavbarContext } from "../header/nav-bar";
-import useKeyboardNav from "../header/nav-bar/use-keyboard-nav";
-import SearchListItem from "./search-list-item";
-import { goTo } from "~/lib/search-map-helper";
+import { NavbarContext } from '../header/nav-bar'
+import useKeyboardNav from '../header/nav-bar/use-keyboard-nav'
+import SearchListItem from './search-list-item'
+import { goTo } from '~/lib/search-map-helper'
interface SearchListProps {
- searchResultsLocation: any[];
- searchResultsDevice: any[];
+ searchResultsLocation: any[]
+ searchResultsDevice: any[]
}
export default function SearchList(props: SearchListProps) {
- const { osem } = useMap();
- const navigate = useNavigate();
- const { setOpen } = useContext(NavbarContext);
- const { compareMode } = useSharedCompareMode();
- const matches = useMatches();
+ const { osem } = useMap()
+ const location = useLocation()
+ const navigate = useNavigate()
+ const { setOpen } = useContext(NavbarContext)
+ const matches = useMatches()
- const { cursor, setCursor, enterPress, controlPress } = useKeyboardNav(
- 0,
- 0,
- props.searchResultsDevice.length + props.searchResultsLocation.length,
- );
+ const compareMode = location.pathname.endsWith('/compare')
- const length =
- props.searchResultsDevice.length + props.searchResultsLocation.length;
- const searchResultsAll = props.searchResultsDevice.concat(
- props.searchResultsLocation,
- );
- const [selected, setSelected] = useState(searchResultsAll[cursor]);
- const [searchParams] = useSearchParams();
- const [navigateTo, setNavigateTo] = useState(
- compareMode
- ? `/explore/${matches[2].params.deviceId}/compare/${selected.deviceId}`
- : selected.type === "device"
- ? `/explore/${selected.deviceId + "?" + searchParams.toString()}}`
- : `/explore?${searchParams.toString()}`,
- );
+ const { cursor, setCursor, enterPress, controlPress } = useKeyboardNav(
+ 0,
+ 0,
+ props.searchResultsDevice.length + props.searchResultsLocation.length,
+ )
- const handleNavigate = useCallback(
- (result: any) => {
- return compareMode
- ? `/explore/${matches[2].params.deviceId}/compare/${selected.deviceId}`
- : result.type === "device"
- ? `/explore/${result.deviceId + "?" + searchParams.toString()}`
- : `/explore?${searchParams.toString()}`;
- },
- [searchParams, compareMode, matches, selected],
- );
+ const length =
+ props.searchResultsDevice.length + props.searchResultsLocation.length
+ const searchResultsAll = props.searchResultsDevice.concat(
+ props.searchResultsLocation,
+ )
+ const [selected, setSelected] = useState(searchResultsAll[cursor])
+ const [searchParams] = useSearchParams()
+ const [navigateTo, setNavigateTo] = useState(
+ compareMode
+ ? `/explore/${matches[2].params.deviceId}/compare/${selected.deviceId}`
+ : selected.type === 'device'
+ ? `/explore/${selected.deviceId + '?' + searchParams.toString()}}`
+ : `/explore?${searchParams.toString()}`,
+ )
- const setShowSearchCallback = useCallback(
- (state: boolean) => {
- setOpen(state);
- },
- [setOpen],
- );
+ const handleNavigate = useCallback(
+ (result: any) => {
+ return compareMode
+ ? `/explore/${matches[2].params.deviceId}/compare/${selected.deviceId}`
+ : result.type === 'device'
+ ? `/explore/${result.deviceId + '?' + searchParams.toString()}`
+ : `/explore?${searchParams.toString()}`
+ },
+ [searchParams, compareMode, matches, selected],
+ )
- const handleDigitPress = useCallback(
- (event: any) => {
- if (
- typeof Number(event.key) === "number" &&
- Number(event.key) <= length &&
- controlPress
- ) {
- event.preventDefault();
- setCursor(Number(event.key) - 1);
- goTo(osem, searchResultsAll[Number(event.key) - 1]);
- setTimeout(() => {
- setShowSearchCallback(false);
- void navigate(handleNavigate(searchResultsAll[Number(event.key) - 1]));
- }, 500);
- }
- },
- [
- controlPress,
- length,
- navigate,
- osem,
- searchResultsAll,
- setCursor,
- setShowSearchCallback,
- handleNavigate,
- ],
- );
+ const setShowSearchCallback = useCallback(
+ (state: boolean) => {
+ setOpen(state)
+ },
+ [setOpen],
+ )
- useEffect(() => {
- setSelected(searchResultsAll[cursor]);
- }, [cursor, searchResultsAll]);
+ const handleDigitPress = useCallback(
+ (event: any) => {
+ if (
+ typeof Number(event.key) === 'number' &&
+ Number(event.key) <= length &&
+ controlPress
+ ) {
+ event.preventDefault()
+ setCursor(Number(event.key) - 1)
+ goTo(osem, searchResultsAll[Number(event.key) - 1])
+ setTimeout(() => {
+ setShowSearchCallback(false)
+ void navigate(handleNavigate(searchResultsAll[Number(event.key) - 1]))
+ }, 500)
+ }
+ },
+ [
+ controlPress,
+ length,
+ navigate,
+ osem,
+ searchResultsAll,
+ setCursor,
+ setShowSearchCallback,
+ handleNavigate,
+ ],
+ )
- useEffect(() => {
- const navigate = handleNavigate(selected);
- setNavigateTo(navigate);
- }, [selected, handleNavigate]);
+ useEffect(() => {
+ setSelected(searchResultsAll[cursor])
+ }, [cursor, searchResultsAll])
- useEffect(() => {
- if (length !== 0 && enterPress) {
- goTo(osem, selected);
- setShowSearchCallback(false);
- void navigate(navigateTo);
- }
- }, [
- enterPress,
- osem,
- navigate,
- selected,
- setShowSearchCallback,
- navigateTo,
- length,
- ]);
+ useEffect(() => {
+ const navigate = handleNavigate(selected)
+ setNavigateTo(navigate)
+ }, [selected, handleNavigate])
- useEffect(() => {
- // attach the event listener
- window.addEventListener("keydown", handleDigitPress);
+ useEffect(() => {
+ if (length !== 0 && enterPress) {
+ goTo(osem, selected)
+ setShowSearchCallback(false)
+ void navigate(navigateTo)
+ }
+ }, [
+ enterPress,
+ osem,
+ navigate,
+ selected,
+ setShowSearchCallback,
+ navigateTo,
+ length,
+ ])
- // remove the event listener
- return () => {
- window.removeEventListener("keydown", handleDigitPress);
- };
- });
+ useEffect(() => {
+ // attach the event listener
+ window.addEventListener('keydown', handleDigitPress)
- return (
-
- {props.searchResultsDevice.length > 0 && (
-
- )}
- {props.searchResultsDevice.map((device: any, i) => (
- setCursor(i)}
- onClick={() => {
- goTo(osem, device);
- setShowSearchCallback(false);
- void navigate(navigateTo);
- }}
- />
- ))}
- {props.searchResultsLocation.length > 0 && (
-
- )}
- {props.searchResultsLocation.map((location: any, i) => {
- return (
- setCursor(i + props.searchResultsDevice.length)}
- onClick={() => {
- goTo(osem, location);
- setShowSearchCallback(false);
- void navigate(navigateTo);
- }}
- />
- );
- })}
-
- );
+ // remove the event listener
+ return () => {
+ window.removeEventListener('keydown', handleDigitPress)
+ }
+ })
+
+ return (
+
+ {props.searchResultsDevice.length > 0 && (
+
+ )}
+ {props.searchResultsDevice.map((device: any, i) => (
+ setCursor(i)}
+ onClick={() => {
+ goTo(osem, device)
+ setShowSearchCallback(false)
+ void navigate(navigateTo)
+ }}
+ />
+ ))}
+ {props.searchResultsLocation.length > 0 && (
+
+ )}
+ {props.searchResultsLocation.map((location: any, i) => {
+ return (
+ setCursor(i + props.searchResultsDevice.length)}
+ onClick={() => {
+ goTo(osem, location)
+ setShowSearchCallback(false)
+ void navigate(navigateTo)
+ }}
+ />
+ )
+ })}
+
+ )
}
diff --git a/app/models/device.server.ts b/app/models/device.server.ts
index a030a883..0a090d2e 100644
--- a/app/models/device.server.ts
+++ b/app/models/device.server.ts
@@ -64,14 +64,6 @@ export function getDevice({ id }: Pick) {
export function getDeviceWithoutSensors({ id }: Pick) {
return drizzleClient.query.device.findFirst({
where: (device, { eq }) => eq(device.id, id),
- columns: {
- id: true,
- name: true,
- exposure: true,
- updatedAt: true,
- latitude: true,
- longitude: true,
- },
})
}
diff --git a/app/routes/explore.$deviceId.$sensorId.$.tsx b/app/routes/explore.$deviceId.$sensorId.$.tsx
index 768c13d1..fc597a06 100644
--- a/app/routes/explore.$deviceId.$sensorId.$.tsx
+++ b/app/routes/explore.$deviceId.$sensorId.$.tsx
@@ -1,6 +1,6 @@
import { addDays } from "date-fns";
import { redirect, type LoaderFunctionArgs, useLoaderData } from "react-router";
-import Graph from "~/components/device-detail/graph";
+import Graph from "~/components/device-overview/graph";
import MobileBoxView from "~/components/map/layers/mobile/mobile-box-view";
import { getDevice } from "~/models/device.server";
import { getMeasurement } from "~/models/measurement.server";
diff --git a/app/routes/explore.$deviceId.compare_.tsx b/app/routes/explore.$deviceId.compare_.tsx
new file mode 100644
index 00000000..fb98c4b6
--- /dev/null
+++ b/app/routes/explore.$deviceId.compare_.tsx
@@ -0,0 +1,29 @@
+import { XSquare } from 'lucide-react'
+import { useLocation, useNavigate } from 'react-router'
+import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert'
+
+export default function Compare() {
+ const navigate = useNavigate()
+ const location = useLocation()
+
+ const compareMode = location.pathname.endsWith('/compare')
+
+ return (
+ <>
+ {compareMode && (
+
+ {
+ void navigate('/explore')
+ }}
+ />
+ Compare devices
+
+ Choose a device from the map to compare with.
+
+
+ )}
+ >
+ )
+}
diff --git a/app/routes/explore.$deviceId.tsx b/app/routes/explore.$deviceId.tsx
index 31e56343..da2ff3f4 100644
--- a/app/routes/explore.$deviceId.tsx
+++ b/app/routes/explore.$deviceId.tsx
@@ -1,88 +1,87 @@
-import { useState } from "react";
-import { type LoaderFunctionArgs, Outlet, useLoaderData, useMatches } from "react-router";
+import { useState } from 'react'
+import {
+ type LoaderFunctionArgs,
+ Outlet,
+ useLoaderData,
+ useLocation,
+ useMatches,
+} from 'react-router'
-import DeviceDetailBox from "~/components/device-detail/device-detail-box";
-import ErrorMessage from "~/components/error-message";
-import { HoveredPointContext } from "~/components/map/layers/mobile/mobile-box-layer";
-import MobileOverviewLayer from "~/components/map/layers/mobile/mobile-overview-layer";
-import i18next from "~/i18next.server";
-import { type LocationPoint } from "~/lib/mobile-box-helper";
-import { getDevice } from "~/models/device.server";
-import { getSensorsWithLastMeasurement } from "~/models/sensor.server";
+import DeviceDetailBox from '~/components/device-overview/device-detail-box'
+import { HoveredPointContext } from '~/components/map/layers/mobile/mobile-box-layer'
+import MobileOverviewLayer from '~/components/map/layers/mobile/mobile-overview-layer'
+import i18next from '~/i18next.server'
+import { type LocationPoint } from '~/lib/mobile-box-helper'
+import { getDevice } from '~/models/device.server'
+import { getSensorsWithLastMeasurement } from '~/models/sensor.server'
export async function loader({ params, request }: LoaderFunctionArgs) {
- const locale = await i18next.getLocale(request);
- // Extracting the selected sensors from the URL query parameters using the stringToArray function
- const url = new URL(request.url);
+ const locale = await i18next.getLocale(request)
+ // Extracting the selected sensors from the URL query parameters using the stringToArray function
+ const url = new URL(request.url)
- if (!params.deviceId) {
- throw new Response("Device not found", { status: 502 });
- }
+ if (!params.deviceId) {
+ throw new Response('Device not found', { status: 502 })
+ }
- const device = await getDevice({ id: params.deviceId });
- const sensorsWithLastestMeasurement = await getSensorsWithLastMeasurement(
- params.deviceId,
- );
+ const device = await getDevice({ id: params.deviceId })
+ const sensorsWithLastestMeasurement = await getSensorsWithLastMeasurement(
+ params.deviceId,
+ )
- // Find all sensors from the device response that have the same id as one of the sensor array value
- const aggregation = url.searchParams.get("aggregation") || "raw";
- const startDate = url.searchParams.get("date_from") || undefined;
- const endDate = url.searchParams.get("date_to") || undefined;
+ // Find all sensors from the device response that have the same id as one of the sensor array value
+ const aggregation = url.searchParams.get('aggregation') || 'raw'
+ const startDate = url.searchParams.get('date_from') || undefined
+ const endDate = url.searchParams.get('date_to') || undefined
- // Combine the device data with the selected sensors and return the result as JSON + add env variable
- const data = {
- device: device,
- sensors: sensorsWithLastestMeasurement,
- aggregation: aggregation,
- fromDate: startDate,
- toDate: endDate,
- OSEM_API_URL: process.env.OSEM_API_URL,
- locale: locale,
- };
+ // Combine the device data with the selected sensors and return the result as JSON + add env variable
+ const data = {
+ device: device,
+ sensors: sensorsWithLastestMeasurement,
+ aggregation: aggregation,
+ fromDate: startDate,
+ toDate: endDate,
+ OSEM_API_URL: process.env.OSEM_API_URL,
+ locale: locale,
+ }
- return data;
+ return data
}
-// Defining the component that will render the page
export default function DeviceId() {
- // Retrieving the data returned by the loader using the useLoaderData hook
- const data = useLoaderData();
- const matches = useMatches();
- const isSensorView = matches[matches.length - 1].params.sensorId
- ? true
- : false;
- const [hoveredPoint, setHoveredPoint] = useState(null);
+ const data = useLoaderData()
+ const location = useLocation()
+ const matches = useMatches()
- const setHoveredPointDebug = (point: any) => {
- setHoveredPoint(point);
- };
+ const compareMode = location.pathname.endsWith('/compare')
- if (!data?.device && !data.sensors) {
- return null;
- }
+ const isSensorView = matches[matches.length - 1].params.sensorId
+ ? true
+ : false
+ const [hoveredPoint, setHoveredPoint] = useState(null)
- return (
- <>
-
- {/* If the box is mobile, iterate over selected sensors and show trajectory */}
- {data.device?.exposure === "mobile" && !isSensorView && (
-
- )}
-
-
-
- >
- );
-}
+ const setHoveredPointDebug = (point: any) => {
+ setHoveredPoint(point)
+ }
+
+ if (!data?.device && !data.sensors) {
+ return null
+ }
-export function ErrorBoundary() {
- return (
-
-
-
- );
+ return (
+ <>
+
+ {/* If the box is mobile, iterate over selected sensors and show trajectory */}
+ {data.device?.exposure === 'mobile' && !isSensorView && (
+
+ )}
+ {!compareMode && }
+
+
+ >
+ )
}
diff --git a/app/routes/explore.$deviceId_.compare.$deviceId2.$sensorId.$.tsx b/app/routes/explore.$deviceId_.compare.$deviceId2.$sensorId.$.tsx
new file mode 100644
index 00000000..6982c204
--- /dev/null
+++ b/app/routes/explore.$deviceId_.compare.$deviceId2.$sensorId.$.tsx
@@ -0,0 +1,94 @@
+import { addDays } from 'date-fns'
+import { redirect, type LoaderFunctionArgs, useLoaderData } from 'react-router'
+import Graph from '~/components/device-overview/graph'
+import MobileBoxView from '~/components/map/layers/mobile/mobile-box-view'
+import { getDevice } from '~/models/device.server'
+import { getMeasurement } from '~/models/measurement.server'
+import { getSensor } from '~/models/sensor.server'
+import { type SensorWithMeasurementData } from '~/schema'
+
+interface SensorWithColor extends SensorWithMeasurementData {
+ color: string
+}
+
+export async function loader({ params, request }: LoaderFunctionArgs) {
+ const { deviceId, sensorId } = params
+ const { deviceId2 } = params
+ const sensorId2 = params['*']
+
+ if (!deviceId) {
+ return redirect('/explore')
+ }
+
+ const device = await getDevice({ id: deviceId })
+
+ if (!device) {
+ return redirect('/explore')
+ }
+
+ const url = new URL(request.url)
+ const aggregation = url.searchParams.get('aggregation') || 'raw'
+ const startDate = url.searchParams.get('date_from')
+ const endDate = url.searchParams.get('date_to')
+
+ if (!sensorId) {
+ 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,
+ )
+
+ sensor1.data = sensor1Data
+ 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,
+ )
+
+ sensor2.data = sensor2Data
+ sensor2.color = sensor2.color || '#fc8d62'
+ }
+
+ // if the two sensors are from two different devices, we need to add the device name to the sensor title
+ if (sensor2 && deviceId2 && sensor1.deviceId !== sensor2.deviceId) {
+ const device2 = await getDevice({ id: deviceId2 })
+ if (sensor1.deviceId === deviceId) {
+ sensor1.title = `${device.name} - ${sensor1.title}`
+ } else {
+ sensor1.title = `${device2?.name} - ${sensor1.title}`
+ }
+ if (sensor2.deviceId === deviceId) {
+ sensor2.title = `${device.name} - ${sensor2.title}`
+ } else {
+ sensor2.title = `${device2?.name} - ${sensor2.title}`
+ }
+ }
+
+ return {
+ device,
+ sensors: sensor2 ? [sensor1, sensor2] : [sensor1],
+ startDate,
+ endDate,
+ aggregation,
+ }
+}
+
+export default function SensorView() {
+ const loaderData = useLoaderData()
+
+ return (
+
+ )
+}
diff --git a/app/routes/explore.$deviceId_.compare.$deviceId2.tsx b/app/routes/explore.$deviceId_.compare.$deviceId2.tsx
new file mode 100644
index 00000000..acdd0955
--- /dev/null
+++ b/app/routes/explore.$deviceId_.compare.$deviceId2.tsx
@@ -0,0 +1,70 @@
+import {
+ type LoaderFunctionArgs,
+ Outlet,
+ redirect,
+ useLoaderData,
+} from 'react-router'
+import CompareDeviceBox from '~/components/compare-devices/compare-device-box'
+
+import i18next from '~/i18next.server'
+import { getDevice, getDeviceWithoutSensors } from '~/models/device.server'
+import { getSensorsWithLastMeasurement } from '~/models/sensor.server'
+
+export async function loader({ params, request }: LoaderFunctionArgs) {
+ const locale = await i18next.getLocale(request)
+ // Extracting the selected sensors from the URL query parameters using the stringToArray function
+ const url = new URL(request.url)
+
+ if (!params.deviceId) {
+ // TODO: Alert user that device not found
+ return redirect('/explore')
+ }
+
+ if (!params.deviceId2) {
+ return redirect(`/explore/${params.deviceId}/compare`)
+ }
+
+ const device1 = await getDeviceWithoutSensors({ id: params.deviceId })
+ const sensorsDevice1 = await getSensorsWithLastMeasurement(params.deviceId)
+ const device1WithSensors = {
+ ...device1,
+ sensors: sensorsDevice1,
+ }
+
+ const device2 = await getDevice({ id: params.deviceId2 })
+ const sensorsDevice2 = await getSensorsWithLastMeasurement(params.deviceId2)
+ const device2WithSensors = {
+ ...device2,
+ sensors: sensorsDevice2,
+ }
+
+ const devices = [device1WithSensors, device2WithSensors]
+
+ // Find all sensors from the device response that have the same id as one of the sensor array value
+ const aggregation = url.searchParams.get('aggregation') || 'raw'
+ const startDate = url.searchParams.get('date_from') || undefined
+ const endDate = url.searchParams.get('date_to') || undefined
+
+ // Combine the device data with the selected sensors and return the result as JSON + add env variable
+ const data = {
+ devices,
+ aggregation,
+ fromDate: startDate,
+ toDate: endDate,
+ OSEM_API_URL: process.env.OSEM_API_URL,
+ locale,
+ }
+
+ return data
+}
+
+export default function CompareDevices() {
+ const data = useLoaderData()
+
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/app/utils/device.ts b/app/utils/device.ts
index 999815cb..3d87ca0b 100644
--- a/app/utils/device.ts
+++ b/app/utils/device.ts
@@ -8,7 +8,7 @@ const doubleGermanS = function (value: string) {
return value;
};
-export function getArchiveLink(device: any) {
+export function getArchiveLink({ name, id } : { name: string, id: string }) {
const date = new Date();
date.setDate(date.getDate() - 1);
const yesterday = date
@@ -17,8 +17,8 @@ export function getArchiveLink(device: any) {
.reverse()
.join("-");
const normalizedName = doubleGermanS(
- device.name.replace(/[^A-Za-z0-9._-]/g, "_")
+ name.replace(/[^A-Za-z0-9._-]/g, "_")
);
- return `https://archive.opensensemap.org/${yesterday}/${device.id}-${normalizedName}`;
+ return `https://archive.opensensemap.org/${yesterday}/${id}-${normalizedName}`;
}