-
-
-
-
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
-
- )}
+
-
-
- {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 && (