diff --git a/app/components/header/download.tsx b/app/components/header/download.tsx index 75113397..624222ab 100644 --- a/app/components/header/download.tsx +++ b/app/components/header/download.tsx @@ -1,9 +1,14 @@ import { type BBox } from 'geojson' -import { Download as DownloadIcon } from 'lucide-react' -import { useEffect, useState } from 'react' +import { + AlertTriangle, + CheckCircle2, + Download as DownloadIcon, +} from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useMap } from 'react-map-gl/maplibre' -import { Form, useNavigation, useActionData } from 'react-router' +import { useFetcher } from 'react-router' + import { Button } from '../ui/button' import { Checkbox } from '../ui/checkbox' import { @@ -13,7 +18,6 @@ import { DialogFooter, DialogHeader, DialogTitle, - DialogTrigger, } from '../ui/dialog' import { Input } from '../ui/input' import { Label } from '../ui/label' @@ -26,52 +30,87 @@ import { } from '../ui/select' import { toast } from '../ui/use-toast' -// Custom Loading Animation Component -const PulsingDownloadAnimation = () => { +type DownloadActionData = + | { + href: string + download?: string + error?: never + link?: never + } + | { + error: string + link?: string + href?: never + download?: never + } + +type DeviceFeature = { + geometry?: { + coordinates?: [number, number] + } + properties?: { + id?: string | number + [key: string]: unknown + } +} + +type DevicesGeoJson = { + features?: DeviceFeature[] +} + +type DownloadProps = { + devices: DevicesGeoJson + open: boolean + onOpenChange: (open: boolean) => void +} + +type DownloadFields = { + title: boolean + unit: boolean + value: boolean + timestamp: boolean +} + +const DEFAULT_FIELDS: DownloadFields = { + title: true, + unit: true, + value: true, + timestamp: true, +} + +function PulsingDownloadAnimation() { const { t } = useTranslation('download') + return (
- {/* Main download icon */}
- - - - - +
- {/* Animated ripples */} -
+
+
+ /> - {/* Small data points moving toward the download icon */}
+ /> +
+ /> +
+ />
+ {t('processingData')} @@ -79,349 +118,392 @@ const PulsingDownloadAnimation = () => { ) } -// Data Ready Animation -const DataReadyAnimation = () => { +function DataReadyAnimation() { const { t } = useTranslation('download') + return ( -
- - - - +
+ {t('readyToDownload')}
) } -export default function Download(props: any) { +export default function Download({ + devices, + open, + onOpenChange, +}: DownloadProps) { const { t } = useTranslation('download') - const actionData = useActionData() - const navigation = useNavigation() - const isLoading = navigation.state === 'submitting' - const devices = props.devices.features || [] + const fetcher = useFetcher() + const actionData = fetcher.data + const { osem: mapRef } = useMap() + const closeTimerRef = useRef | null>(null) + + const [format, setFormat] = useState('csv') + const [aggregate, setAggregate] = useState('10m') + const [fields, setFields] = useState(DEFAULT_FIELDS) + const [isDownloadReady, setIsDownloadReady] = useState(false) const [showReadyAnimation, setShowReadyAnimation] = useState(false) + const [downloadStarted, setDownloadStarted] = useState(false) const [errorMessage, setErrorMessage] = useState(null) - // Update download ready state when actionData changes - useEffect(() => { - if (actionData && actionData.error) { - setErrorMessage(actionData.error) - } else { - setErrorMessage(null) - // Only set download ready if there's no error - if (actionData) { - setIsDownloadReady(true) - setShowReadyAnimation(true) - } + const isBusy = fetcher.state !== 'idle' + const hasSelectedFields = Object.values(fields).some(Boolean) + + const deviceIds = useMemo(() => { + const features = devices?.features ?? [] + + const bounds = mapRef?.getMap().getBounds()?.toArray().flat() as + | BBox + | undefined + + if (!bounds || bounds.length !== 4) { + return [] } - }, [actionData]) - // Reset download ready state when format changes - const [format, setFormat] = useState('csv') - const handleFormatChange = (value: string) => { - setFormat(value) - setShowReadyAnimation(false) + const [minLon, minLat, maxLon, maxLat] = bounds + + const ids = features + .filter((device) => { + const coordinates = device.geometry?.coordinates + + if (!coordinates) { + return false + } + + const [longitude, latitude] = coordinates + + return ( + longitude >= minLon && + longitude <= maxLon && + latitude >= minLat && + latitude <= maxLat + ) + }) + .map((device) => device.properties?.id) + .filter((id): id is string | number => id !== undefined && id !== null) + .map(String) + + return Array.from(new Set(ids)) + }, [devices, mapRef, open]) + + const resetResultState = () => { setIsDownloadReady(false) + setShowReadyAnimation(false) setErrorMessage(null) + setDownloadStarted(false) } - const [downloadStarted, setDownloadStarted] = useState(false) + const handleDialogOpenChange = (nextOpen: boolean) => { + onOpenChange(nextOpen) + + if (!nextOpen) { + resetResultState() + } + } + + const handleFormatChange = (value: string) => { + setFormat(value) + resetResultState() + } + + const handleAggregateChange = (value: string) => { + setAggregate(value) + resetResultState() + } + + const handleFieldChange = ( + field: keyof DownloadFields, + checked: boolean | 'indeterminate', + ) => { + setFields((previousFields) => ({ + ...previousFields, + [field]: checked === true, + })) + + resetResultState() + } - // Add this function to handle download start const handleDownloadStart = () => { - const Delay = 3500 + const delay = 3500 + + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current) + } + setDownloadStarted(true) setShowReadyAnimation(false) + toast({ description: t('toast'), - duration: Delay, + duration: delay, variant: 'success', }) - // Reset the download started state after a delay - setTimeout(() => { + closeTimerRef.current = setTimeout(() => { setDownloadStarted(false) - setOpen(false) - }, Delay) + onOpenChange(false) + }, delay) } - // Filter devices inside the current bounds - const bounds = mapRef?.getMap().getBounds()?.toArray().flat() as - | BBox - | undefined - const devicesInBounds = - bounds && bounds.length === 4 - ? devices.filter((device: any) => { - // Ensure the device has coordinates - - if (!device.geometry || !device.geometry.coordinates) return false - - const [longitude, latitude] = device.geometry.coordinates - - // Check if bounds are defined properly - const [minLon, minLat] = bounds.slice(0, 2) // [minLongitude, minLatitude] - const [maxLon, maxLat] = bounds.slice(2, 4) // [maxLongitude, maxLatitude] - - return ( - longitude >= minLon && - longitude <= maxLon && - latitude >= minLat && - latitude <= maxLat - ) - }) - : [] - - let deviceIDs: Array = [] - devicesInBounds.map((device: any) => { - deviceIDs.push(device.properties.id) - }) - - const [aggregate, setAggregate] = useState('10m') - const [fields, setFields] = useState({ - title: true, - unit: true, - value: true, - timestamp: true, - }) - const [open, setOpen] = useState(false) - const handleFieldChange = (field: keyof typeof fields) => { - setFields((prev) => ({ ...prev, [field]: !prev[field] })) - setIsDownloadReady(false) - setErrorMessage(null) - setShowReadyAnimation(false) - } + useEffect(() => { + if (!actionData) { + return + } + + if ('error' in actionData && actionData.error) { + setErrorMessage(actionData.error) + setIsDownloadReady(false) + setShowReadyAnimation(false) + return + } + + if ('href' in actionData && actionData.href) { + setErrorMessage(null) + setIsDownloadReady(true) + setShowReadyAnimation(true) + } + }, [actionData]) + + useEffect(() => { + return () => { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current) + } + } + }, []) return ( - { - setOpen(!open) - setIsDownloadReady(false) - setErrorMessage(null) - setShowReadyAnimation(false) - }} - > - setOpen(true)} - > -
- -
-
- + + {t('downloadOptions')} {t('downloadDescription')} -
-
-
-
- - - {deviceIDs.length} 📡 {t('selected')} - -
- { + resetResultState() + }} + > +
+
+ + + + {deviceIds.length} 📡 {t('selected')} + +
+ + + + {deviceIds.length === 0 && ( +

+ {t( + 'noDevicesInBounds', + 'No devices are currently selected in the visible map area.', + )} +

+ )} + + + + + + + + +
+ +
+ + {t('fieldsToInclude')} + + +
+ + handleFieldChange('title', checked) + } /> - - - - + +
-
-
- {t('fieldsToInclude')} -
- handleFieldChange('title')} - name="title" - /> - -
-
- handleFieldChange('unit')} - name="unit" - /> - -
-
- handleFieldChange('value')} - name="value" - /> - -
-
- handleFieldChange('timestamp')} - name="timestamp" - /> - -
-
+ +
+ + handleFieldChange('unit', checked) + } + /> + +
-
- {isLoading ? ( - - ) : showReadyAnimation ? ( - - ) : null} +
+ + handleFieldChange('value', checked) + } + /> + +
- {errorMessage && ( -
- - - - - -

- {t('error')}{' '} - - {t('clickHere')} - {' '} - {t('toGoToArchive')} -

-
- )} - -
- - {actionData && isDownloadReady ? ( - - + + handleFieldChange('timestamp', checked) + } + /> + + +
+
+ +
+ {isBusy ? ( + + ) : showReadyAnimation ? ( + + ) : null} +
+ + {errorMessage && ( +
- - -
+

+
+ )} + + {!hasSelectedFields && ( +

+ {t( + 'selectAtLeastOneField', + 'Please select at least one field to include.', + )} +

+ )} + + +
+ + + {actionData && + 'href' in actionData && + actionData.href && + isDownloadReady ? ( + + + + {downloadStarted + ? t('downloading') + : `${format.toUpperCase()} ${t('data')} ${t('download')}`} + + ) : null} +
+
+
) diff --git a/app/components/header/home/index.tsx b/app/components/header/home/index.tsx index 901d2dcc..0079dded 100644 --- a/app/components/header/home/index.tsx +++ b/app/components/header/home/index.tsx @@ -1,22 +1,75 @@ -import { Link } from 'react-router' +import { useTranslation } from 'react-i18next' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '~/components/ui/tooltip' +import { topbarSurface } from '~/components/map/topbar-styles' +import { cn } from '~/lib/utils' + +interface HomeProps { + deviceCount?: number + measurementCount?: number + onHomeClick?: () => void +} + +export default function Home({ + deviceCount = 0, + measurementCount = 0, + onHomeClick, +}: HomeProps) { + const { t } = useTranslation('menu') -export default function Home() { return ( -
-
- - - -
-
+ + + + + + +

{t('returnToGlobeView')}

+
+
) -} +} \ No newline at end of file diff --git a/app/components/header/index.tsx b/app/components/header/index.tsx index d3ad676b..ee7ed511 100644 --- a/app/components/header/index.tsx +++ b/app/components/header/index.tsx @@ -1,29 +1,18 @@ -import LanguageSelector from '../landing/header/language-selector' -import Download from './download' import Home from './home' import Menu from './menu' import NavBar from './nav-bar' -// import { useLoaderData } from "@remix-run/react"; -// import Notification from "./notification"; -// import type { loader } from "~/routes/explore.$deviceId._index"; interface HeaderProps { devices: any } export default function Header(props: HeaderProps) { - // const data = useLoaderData(); return (
-
- -
- - {/* {data?.user?.email ? : null} */} - +
) diff --git a/app/components/header/info/index.tsx b/app/components/header/info/index.tsx new file mode 100644 index 00000000..052b8df2 --- /dev/null +++ b/app/components/header/info/index.tsx @@ -0,0 +1,109 @@ +import { + Globe, + FileLock2, + Coins, + ExternalLink, + ScrollText, + MessagesSquare, + InfoIcon, +} from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Link, useNavigation } from 'react-router' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { useRootRouteLoaderData } from '~/root' +import { Button } from '~/components/ui/button' + +export default function Info() { + const { ENV } = useRootRouteLoaderData() + const [open, setOpen] = useState(false) + const navigation = useNavigation() + + const { t } = useTranslation('menu') + + return ( + + +
+ +
+
+ +
+ + + + + {t('community_label')} + + + + + + + {t('api_docs_label')} + + + + + + + + + + {t('data_protection_label')} + + + + + + e.preventDefault()} + className="cursor-pointer" + > + + {t('tos')} + + + + + + + + e.preventDefault()} + className="cursor-pointer" + > + + {t('donate_label')} + + + + +
+
+
+ ) +} diff --git a/app/components/header/menu/index.tsx b/app/components/header/menu/index.tsx index 2fbd91ac..13d4c65c 100644 --- a/app/components/header/menu/index.tsx +++ b/app/components/header/menu/index.tsx @@ -1,17 +1,11 @@ import { - Globe, LogIn, LogOut, - Puzzle, - Menu as MenuIcon, - FileLock2, - Coins, User2, - ExternalLink, Settings, Compass, - ScrollText, - MessagesSquare, + PlusIcon, + DownloadIcon, Info, } from 'lucide-react' import { useState } from 'react' @@ -35,216 +29,204 @@ import { import Spinner from '~/components/spinner' import { toast } from '~/components/ui/use-toast' import { useOptionalUser } from '~/utils' -import { useRootRouteLoaderData } from '~/root' +import { Button } from '~/components/ui/button' +import Download from '../download' -export default function Menu() { - const { ENV } = useRootRouteLoaderData() +interface MenuProps { + devices?: any +} + +export default function Menu({ devices }: MenuProps) { const [searchParams] = useSearchParams() const redirectTo = searchParams.size > 0 ? '/explore?' + searchParams.toString() : '/explore' + const [open, setOpen] = useState(false) + const [downloadOpen, setDownloadOpen] = useState(false) + const navigation = useNavigation() const isLoggingOut = Boolean(navigation.state === 'submitting') const user = useOptionalUser() const matches = useMatches() - const { t } = useTranslation('menu') + const isExplore = matches.some((match) => match.pathname === '/explore') + return ( - - -
- -
-
- -
+ + +
+ +
+
+ + - - {!user ? ( -
-

{t('title')}

-

- {t('subtitle')} -

-
- ) : ( -
-

- {/* Max Mustermann */} - {user?.name} -

-

- {user?.email} -

-
- )} -
- - {user && ( - - {navigation.state === 'loading' && ( -
- +
+ + {!user ? ( +
+

+ {t('title')} +

+

+ {t('subtitle')} +

+
+ ) : ( +
+

+ {user?.name} +

+

+ {user?.email} +

)} - +
+ + + + {user && ( + + {navigation.state === 'loading' && ( +
+ +
+ )} + + {t('about_label')} - {!(matches[1].pathname === '/explore') && ( - - - - {t('explore_label')} - - - )} - {!(matches[1].pathname === '/profile') && ( - - - - {t('profile_label')} - - - )} + {!isExplore && ( + + + + {t('explore_label')} + + + )} - {!(matches[1].pathname === '/settings') && ( - - - - {t('settings_label')} - - - + {matches[1]?.pathname !== '/settings' && ( + + + + {t('settings_label')} + + + )} + + {matches[1]?.pathname !== '/profile' && ( + + + + {t('my_devices_label')} + + + )} + + {matches[1]?.pathname !== '/profile' && ( + + + + {t('add_device_label')} + + + )} +
)} - - )} - - - - - {t('community_label')} - - - - - - - {t('api_docs_label')} - - - - - - - - - - {t('data_protection_label')} - - - - - - e.preventDefault()} - className="cursor-pointer" - > - - {t('tos')} - - - - - - - - + + + {isExplore && ( + + { + event.preventDefault() + setOpen(false) + setDownloadOpen(true) + }} + > + + {t('download_label', 'Download data')} + + + + + )} + + + e.preventDefault()} - className="cursor-pointer" + onSelect={(e) => { + e.preventDefault() + }} > - - {t('donate_label')} - - - - - - - - - { - // Prevent dropdown from closing - e.preventDefault() - }} - > - {!user ? ( - setOpen(false)} - className="w-full cursor-pointer" - > - - - ) : ( -
{ - setOpen(false) - toast({ - description: 'Successfully logged out.', - }) - }} - className="w-full cursor-pointer" - > - - -
- )} -
-
-
- - + + + ) : ( +
{ + setOpen(false) + toast({ + description: 'Successfully logged out.', + }) + }} + className="w-full cursor-pointer" + > + + +
+ )} + + +
+
+
+ {devices && ( + + )} + ) } diff --git a/app/components/header/nav-bar/index.tsx b/app/components/header/nav-bar/index.tsx index 8cb2c255..db0d280d 100644 --- a/app/components/header/nav-bar/index.tsx +++ b/app/components/header/nav-bar/index.tsx @@ -7,6 +7,8 @@ import { useMap } from 'react-map-gl/maplibre' import NavbarHandler from './nav-bar-handler' import FilterVisualization from '~/components/map/filter-visualization' import { type Device } from '~/db/schema' +import { cn } from '~/lib/utils' +import { topbarSurface } from '~/components/map/topbar-styles' interface NavBarProps { devices: Device[] @@ -48,7 +50,6 @@ export default function NavBar(props: NavBarProps) { return () => document.removeEventListener('keydown', down) }, []) - // focus input when opening useEffect(() => { if (open) { inputRef.current?.focus() @@ -61,59 +62,103 @@ export default function NavBar(props: NavBarProps) { const isDesktop = useMediaQuery('(min-width: 768px)') return ( -
-
-
-
- - setOpen(true)} - onChange={(e) => setSearchString(e.target.value)} - className="h-fit w-full flex-1 border-none bg-white focus:border-none focus:ring-0 focus:outline-hidden dark:bg-zinc-800 dark:text-zinc-200" - value={searchString} - /> - {!open && ( - - ctrl + K - - )} +
+ +
+ + + setOpen(true)} + onChange={(e) => setSearchString(e.target.value)} + className="h-full w-full flex-1 border-none bg-transparent focus:border-none focus:ring-0 focus:outline-hidden dark:text-zinc-200" + value={searchString} + /> + + {!open && ( + + ctrl + K + + )} + + {open && ( + + )} +
+ + + {open && ( - { - setSearchString('') - setOpen(false) - inputRef.current?.blur() + - )} -
- - - {open && ( - + animate={{ + opacity: 1, + height: 'auto', + y: 0, + }} + exit={{ + opacity: 0, + height: 0, + y: -4, + }} + transition={{ + duration: 0.22, + ease: [0.22, 1, 0.36, 1], + }} + > +
- - )} - - +
+
+ )} +
+
+ + {!open && isDesktop && ( +
+
- {!open && isDesktop && ( -
- -
- )} -
+ )}
) } diff --git a/app/components/landing/header/language-selector.tsx b/app/components/landing/header/language-selector.tsx index d5345292..8620f208 100644 --- a/app/components/landing/header/language-selector.tsx +++ b/app/components/landing/header/language-selector.tsx @@ -1,30 +1,36 @@ -import { Globe } from 'lucide-react' +import { Languages } from 'lucide-react' import { useFetcher } from 'react-router' import { Button } from '~/components/ui/button' import { useRootRouteLoaderData } from '~/root' export default function LanguageSelector() { - const { locale } = useRootRouteLoaderData() - const fetcher = useFetcher() + const { locale } = useRootRouteLoaderData() + const fetcher = useFetcher() - const toggleLanguage = () => { - const newLocale = locale === 'en' ? 'de' : 'en' - void fetcher.submit( - { 'set-language': newLocale }, - { method: 'post', action: '/' }, - ) - } + const toggleLanguage = () => { + const newLocale = locale === 'en' ? 'de' : 'en' - return ( - - ) -} + void fetcher.submit( + { 'set-language': newLocale }, + { method: 'post', action: '/' }, + ) + } + + return ( +
+ + +
+ {locale.toUpperCase()} +
+
+ ) +} \ No newline at end of file diff --git a/app/components/map/topbar-styles.ts b/app/components/map/topbar-styles.ts new file mode 100644 index 00000000..5a58914f --- /dev/null +++ b/app/components/map/topbar-styles.ts @@ -0,0 +1,17 @@ +import { cva } from 'class-variance-authority' + +export const topbarSurface = cva( + `border border-gray-100 bg-white text-black shadow-xl backdrop-blur-md transition hover:bg-gray-100 dark:border-zinc-700 dark:bg-zinc-800/90 dark:text-zinc-200 dark:hover:bg-zinc-700/90`, + { + variants: { + shape: { + circle: 'h-11 w-11 rounded-full', + pill: 'h-11 rounded-full', + panel: 'rounded-2xl', + }, + }, + defaultVariants: { + shape: 'pill', + }, + }, +) diff --git a/app/components/map/topbar.tsx b/app/components/map/topbar.tsx new file mode 100644 index 00000000..dc29d1ab --- /dev/null +++ b/app/components/map/topbar.tsx @@ -0,0 +1,51 @@ +import Home from '../header/home' +import Menu from '../header/menu' +import NavBar from '../header/nav-bar' +import Info from '../header/info' +import LanguageSelector from '../landing/header/language-selector' +import { TooltipProvider } from '../ui/tooltip' + +interface MapHeaderProps { + user: any + devices: any + measurementCount: number | undefined + onHomeClick?: () => void +} + +export default function MapHeader({ + devices, + measurementCount, + onHomeClick +}: MapHeaderProps) { + return ( + +
+
+
+ + +
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+
+ ) +} diff --git a/app/components/nav-bar.tsx b/app/components/nav-bar.tsx index d23a7580..5092649d 100644 --- a/app/components/nav-bar.tsx +++ b/app/components/nav-bar.tsx @@ -1,4 +1,4 @@ -import { LogIn, Mailbox, Plus } from 'lucide-react' +import { LogIn, Plus } from 'lucide-react' import { useTranslation } from 'react-i18next' import { Link, useLocation } from 'react-router' import Menu from './header/menu' @@ -77,9 +77,6 @@ export function NavBar() { -
diff --git a/app/components/ui/button.tsx b/app/components/ui/button.tsx index ee9aca39..2fa10767 100644 --- a/app/components/ui/button.tsx +++ b/app/components/ui/button.tsx @@ -20,12 +20,16 @@ const buttonVariants = cva( ghost: 'hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-slate-50', link: 'text-slate-900 underline-offset-4 hover:underline dark:text-slate-50', + topbar: + 'border border-gray-100 bg-white text-black shadow-sm hover:bg-gray-100', }, size: { default: 'h-10 px-4 py-2', sm: 'h-9 rounded-md px-3', lg: 'h-11 rounded-md px-8', icon: 'h-10 w-10', + topbarIcon: 'h-10 w-10 rounded-full p-0 [&_svg]:size-6', + topbarPill: 'h-10 rounded-full px-3 py-0 [&_svg]:size-5', }, }, defaultVariants: { diff --git a/app/db/models/measurement.server.ts b/app/db/models/measurement.server.ts index 5d5b8c65..fe23aa5a 100644 --- a/app/db/models/measurement.server.ts +++ b/app/db/models/measurement.server.ts @@ -303,3 +303,7 @@ export async function deleteMeasurementsForTime(date: Date) { .delete(measurement) .where(eq(measurement.time, date)) } + +export async function getMeasurementsCount() { + return await drizzleClient.$count(measurement) +} diff --git a/app/routes/explore.tsx b/app/routes/explore.tsx index c6970e94..fa28a51a 100644 --- a/app/routes/explore.tsx +++ b/app/routes/explore.tsx @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { type FeatureCollection, type Point } from 'geojson' -import { useState, useRef, useCallback, useMemo } from 'react' +import { useState, useRef, useCallback, useMemo, useEffect } from 'react' import { type MapRef, MapProvider, @@ -14,9 +14,9 @@ import { useSearchParams, useLoaderData, useParams, + useLocation, } from 'react-router' import { type Route } from './+types/explore' -import Header from '~/components/header' import Map from '~/components/map' import { phenomenonLayers, defaultLayer } from '~/components/map/layers' import Legend, { type LegendValue } from '~/components/map/legend' @@ -36,6 +36,28 @@ import maplibregl, { type FilterSpecification, } from 'maplibre-gl' import BoxMarker from '~/components/map/layers/cluster/box-marker' +import MapHeader from '~/components/map/topbar' +import { getMeasurementsCount } from '~/db/models/measurement.server' + +const INITIAL_VIEW_STATE = { + zoom: 2, + latitude: 7, + longitude: 52, +} as const + +function parseMapHash(hash: string) { + const match = hash.match(/^#?(-?\d+(?:\.\d+)?)\/(-?\d+(?:\.\d+)?)\/(-?\d+(?:\.\d+)?)$/) + + if (!match) return null + + const [, zoom, latitude, longitude] = match + + return { + zoom: Number(zoom), + latitude: Number(latitude), + longitude: Number(longitude), + } +} export async function action({ request }: { request: Request }) { const deviceLimit = 50 @@ -112,6 +134,8 @@ export async function loader({ context, request }: Route.LoaderArgs) { ? await getDevices('geojson') : await getDevicesWithSensors() + const measurementCount = await getMeasurementsCount() + const session = await getUserSession(request) const message = session.get('global_message') || null @@ -127,6 +151,7 @@ export async function loader({ context, request }: Route.LoaderArgs) { : 'en' return { devices, + measurementCount, user, profile, filteredDevices, @@ -137,6 +162,7 @@ export async function loader({ context, request }: Route.LoaderArgs) { } return { devices, + measurementCount, user, profile: null, filterParams, @@ -155,9 +181,10 @@ if (process.env.NODE_ENV === 'production') { export default function Explore() { // data from our loader - const { devices, filteredDevices } = useLoaderData() + const { devices, filteredDevices, measurementCount, user } = useLoaderData() const mapRef = useRef(null) const navigate = useNavigate() + const location = useLocation() // const [showSearch, setShowSearch] = useState(false); const [selectedPheno, setSelectedPheno] = useState(undefined) const [searchParams] = useSearchParams() @@ -331,6 +358,37 @@ export default function Explore() { } } + const flyToView = useCallback( + (view: { zoom: number; latitude: number; longitude: number }) => { + mapRef.current?.flyTo({ + center: [view.longitude, view.latitude], + zoom: view.zoom, + duration: 900, + essential: true, + }) + }, + [], + ) + + const flyToHash = useCallback( + (hash: string) => { + const view = parseMapHash(hash) + + if (!view) return + + flyToView(view) + }, + [flyToView], + ) + + useEffect(() => { + flyToHash(location.hash) + }, [location.hash, flyToHash]) + + const handleHomeClick = useCallback(() => { + flyToView(INITIAL_VIEW_STATE) + }, [flyToView]) + const handleMouseMove = useCallback( (e: MapLayerMouseEvent) => { if (e.features && e.features.length > 0) { @@ -462,8 +520,13 @@ export default function Explore() { return (
-
- + + {/*
*/} {selectedPheno && ( {!selectedPheno && ( diff --git a/public/locales/de/menu.json b/public/locales/de/menu.json index fd9f6b54..c3f058d1 100644 --- a/public/locales/de/menu.json +++ b/public/locales/de/menu.json @@ -6,6 +6,7 @@ "settings_label": "Einstellungen", "about_label": "Über OpenSenseMap", "my_devices_label": "Meine Geräte", + "download_label": "Datendownload", "add_device_label": "Gerät hinzufügen", "tutorials_label": "Tutorials", "api_docs_label": "API Dokumentation", @@ -21,5 +22,8 @@ "toast_login_success": "Erfolgreich eingeloggt", "toast_logout_success": "Erfolgreich ausgeloggt", "toast_user_creation_success": "Benutzer erfolgreich erstellt", - "community_label": "Community Forum" + "community_label": "Community Forum", + "devices": "Geräte", + "measurements": "Messungen", + "returnToGlobeView": "Zur Globusansicht zurückkehren" } diff --git a/public/locales/en/menu.json b/public/locales/en/menu.json index bd3c42b6..a6db7dc0 100644 --- a/public/locales/en/menu.json +++ b/public/locales/en/menu.json @@ -5,6 +5,7 @@ "profile_label": "Profile", "about_label": "About", "settings_label": "Settings", + "download_label": "Download data", "my_devices_label": "My Devices", "add_device_label": "Add Device", "tutorials_label": "Tutorials", @@ -21,5 +22,8 @@ "toast_login_success": "Successfully logged in", "toast_logout_success": "Successfully logged out", "toast_user_creation_success": "Successfully created account", - "community_label": "Community Forum" + "community_label": "Community Forum", + "devices": "Devices", + "measurements": "Measurements", + "returnToGlobeView": "Return to globe view" }