+
Duration
- {formatRouteDuration(props.route)}
+ {formatRouteDuration(props.route?.ui_derived?.duration)}
-
+
Engaged
-
- {formatEngagement(timeline())}
-
+ {`${props.route?.ui_derived?.engagement}%`}
-
+
User flags
-
- {formatUserFlags(timeline())}
-
+ {props.route?.ui_derived?.flags}
)
}
-export default RouteStatistics
+export const DriveStatistics: VoidComponent
= (props) => {
+
+ type Props = {
+ icon: string
+ data: string | number | undefined
+ label: string
+ }
+ const Statistic: VoidComponent = (statisticProps) => {
+ return
+
+ {statisticProps.icon}
+
{statisticProps?.data}
+
+
+
{statisticProps.label}
+
+
+ }
+
+ return
+
+
+
+
+
+}
diff --git a/src/components/material/Avatar.tsx b/src/components/material/Avatar.tsx
index c5c1734d..a28e909f 100644
--- a/src/components/material/Avatar.tsx
+++ b/src/components/material/Avatar.tsx
@@ -4,6 +4,7 @@ import clsx from 'clsx'
type AvatarProps = {
class?: string
color?: string
+ onClick?: () => void
}
const Avatar: ParentComponent = (props) => {
@@ -24,6 +25,7 @@ const Avatar: ParentComponent = (props) => {
colorClasses(),
props.class,
)}
+ onClick={props.onClick}
>
{props.children}
diff --git a/src/index.css b/src/index.css
index e28fa8a6..14bb9858 100644
--- a/src/index.css
+++ b/src/index.css
@@ -3,6 +3,7 @@
@tailwind utilities;
@layer base {
+
/* https://m3.material.io/styles/color/roles */
:root {
--color-primary: rgb(83 90 146);
@@ -58,15 +59,15 @@
.dark,
[data-theme="dark"] {
- --color-primary: rgb(188 195 255);
+ --color-primary: rgb(145, 151, 201);
--color-surface-tint: rgb(188 195 255);
--color-on-primary: rgb(36 43 97);
--color-primary-container: rgb(59 66 121);
--color-on-primary-container: rgb(223 224 255);
--color-secondary: rgb(196 197 221);
--color-on-secondary: rgb(45 47 66);
- --color-secondary-container: rgb(67 69 89);
- --color-on-secondary-container: rgb(224 225 249);
+ --color-secondary-container: rgb(34, 34, 43);
+ --color-on-secondary-container: rgb(92, 92, 134);
--color-tertiary: rgb(168 210 147);
--color-on-tertiary: rgb(21 56 9);
--color-tertiary-container: rgb(43 79 30);
@@ -114,7 +115,8 @@
@apply select-none selection:bg-transparent;
}
- html, body {
+ html,
+ body {
/*
* fill visible viewport
* https://developers.google.com/web/updates/2016/12/url-bar-resizing
@@ -126,6 +128,23 @@
/* revert user-agent style */
text-align: unset;
}
+
+ ::-webkit-scrollbar {
+ width: 5px;
+ }
+
+ ::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ ::-webkit-scrollbar-thumb {
+ background: var(--color-secondary-container);
+ border-radius: 5px;
+ }
+
+ ::-webkit-scrollbar-thumb:hover {
+ background: var(--color-on-secondary-container);
+ }
}
@layer components {
@@ -206,11 +225,14 @@
}
.hide-scrollbar {
- scrollbar-width: none; /* Firefox */
- -ms-overflow-style: none; /* IE 10+ */
+ scrollbar-width: none;
+ /* Firefox */
+ -ms-overflow-style: none;
+ /* IE 10+ */
&::-webkit-scrollbar {
- display: none; /* Safari and Chrome */
+ display: none;
+ /* Safari and Chrome */
}
}
-}
+}
\ No newline at end of file
diff --git a/src/map/DriveMap.tsx b/src/map/DriveMap.tsx
new file mode 100644
index 00000000..8c54cc26
--- /dev/null
+++ b/src/map/DriveMap.tsx
@@ -0,0 +1,120 @@
+/* eslint-disable solid/style-prop */
+import { VoidComponent, createEffect, createSignal } from 'solid-js'
+import MapGL, { Source, Layer, Viewport } from 'solid-map-gl'
+import { MAPBOX_TOKEN, MAPBOX_USERNAME } from './config'
+import { GPSPathPoint } from '~/api/derived'
+import { getMapStyleId } from '.'
+import { getThemeId } from '~/theme'
+import { calculateAverageBearing } from './bearing'
+
+type Props = {
+ coords: GPSPathPoint[],
+ point: number
+}
+
+const DriveMap: VoidComponent
= (props) => {
+
+ const coords = () => props.coords
+ const point = () => props.point
+
+ const [marker, setMarker] = createSignal([coords()[0].lng, coords()[0].lat])
+ const [viewport, setViewport] = createSignal({
+ // eslint-disable-next-line solid/reactivity
+ center: marker(),
+ zoom: 12,
+ } as Viewport)
+
+ let lastBearing: number = 180
+ const BEARING_THRESHOLD = 15
+
+ const getBearing = (time: number) => {
+ const start = coords().findIndex(coord => coord.t === Math.round(time))
+ const path = coords().slice(start, Math.min(start + 10, coords().length))
+
+ const newBearing = calculateAverageBearing(path)
+ if(Math.abs(newBearing - lastBearing) > BEARING_THRESHOLD) {
+ lastBearing = newBearing
+ return newBearing
+ }
+ return lastBearing
+ }
+
+ createEffect(() => {
+ const coord = coords().find(coord => coord.t === Math.round(point()))
+ if(coord?.lat && coord.lng) {
+ setMarker([coord.lng, coord.lat])
+ setViewport({
+ center: [coord.lng, coord.lat],
+ zoom: 15,
+ pitch: 45,
+ bearing: getBearing(coord.t),
+ })
+ }
+ })
+
+ return (
+
+
+ setViewport(evt)}
+ >
+ [coord.lng, coord.lat]),
+ },
+ },
+ }}
+ >
+
+
+
+
+
+
+
+
+ )
+}
+
+export default DriveMap
diff --git a/src/map/bearing.ts b/src/map/bearing.ts
new file mode 100644
index 00000000..f38deb58
--- /dev/null
+++ b/src/map/bearing.ts
@@ -0,0 +1,27 @@
+import type { GPSPathPoint } from '~/api/derived'
+
+const calculateBearing = (start: GPSPathPoint, end: GPSPathPoint) => {
+
+ const radians = (degree: number): number => degree * (Math.PI / 180.0)
+ const degrees = (radian: number): number => radian * (180.0 / Math.PI)
+
+ const startLat = radians(start.lat)
+ const startLng = radians(start.lng)
+ const endLat = radians(end.lat)
+ const endLng = radians(end.lng)
+
+
+ const dLng = endLng - startLng
+ const dPhi = Math.log(Math.tan(endLat / 2 + Math.PI / 4) / Math.tan(startLat / 2 + Math.PI / 4))
+
+ const bearing = degrees(Math.atan2(
+ Math.abs(dLng) > Math.PI ? (dLng > 0 ? -(2 * Math.PI - dLng) : (2 * Math.PI + dLng)) : dLng, dPhi,
+ ))
+
+ return (bearing + 360) % 360
+}
+
+export const calculateAverageBearing = (points: GPSPathPoint[]): number =>
+ points.length < 2 ? 0 :
+ points.slice(0, -1)
+ .reduce((sum, point, i) => sum + calculateBearing(point, points[i + 1]), 0) / (points.length - 1)
diff --git a/src/map/index.ts b/src/map/index.ts
index cd52e234..c546d2d1 100644
--- a/src/map/index.ts
+++ b/src/map/index.ts
@@ -12,7 +12,7 @@ export type Coords = [number, number][]
const POLYLINE_SAMPLE_SIZE = 50
const POLYLINE_PRECISION = 4
-function getMapStyleId(themeId: string): string {
+export function getMapStyleId(themeId: string): string {
return themeId === 'light' ? MAPBOX_LIGHT_STYLE_ID : MAPBOX_DARK_STYLE_ID
}
@@ -50,3 +50,21 @@ export function getPathStaticMapUrl(
)})`
return `https://api.mapbox.com/styles/v1/${MAPBOX_USERNAME}/${styleId}/static/${path}/auto/${width}x${height}${hidpiStr}?logo=false&attribution=false&padding=30,30,30,30&access_token=${MAPBOX_TOKEN}`
}
+
+export function getPlaceFromCoords(lng: number | undefined, lat:number | undefined): Promise {
+ return new Promise((resolve) => {
+ if(!lat || !lng) resolve('') // keeps the calling code a bit cleaner
+ fetch(`https://api.mapbox.com/search/geocode/v6/reverse?longitude=${lng}&latitude=${lat}.733&types=address&worldview=us&access_token=${MAPBOX_TOKEN}`)
+ .then(res => res.json())
+ .then(res => {
+ // if the object is not found, we can handle the error appropriately in the ui
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @stylistic/max-len, @typescript-eslint/no-unsafe-member-access
+ const neighborhood = res.features[0].properties.context.neighborhood, region = res.features[0].properties.context.region
+ if(neighborhood && region) {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ resolve(`${neighborhood.name}, ${region.region_code}`)
+ } else resolve('')
+ })
+ .catch(() => resolve(''))
+ })
+}
diff --git a/src/pages/auth/login.tsx b/src/pages/auth/login.tsx
index 7eb64f58..8cb51a98 100644
--- a/src/pages/auth/login.tsx
+++ b/src/pages/auth/login.tsx
@@ -1,12 +1,16 @@
+import { useNavigate } from '@solidjs/router'
import { getGoogleAuthUrl, getAppleAuthUrl, getGitHubAuthUrl } from '~/api/auth'
import { setAccessToken } from '~/api/auth/client'
import Button from '~/components/material/Button'
export default function Login() {
+
+ const navigate = useNavigate()
+
const loginAsDemoUser = function () {
setAccessToken('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NDg1ODI0NjUsIm5iZiI6MTcxNzA0NjQ2NSwiaWF0IjoxNzE3MDQ2NDY1LCJpZGVudGl0eSI6IjBkZWNkZGNmZGYyNDFhNjAifQ.g3khyJgOkNvZny6Vh579cuQj1HLLGSDeauZbfZri9jw')
- window.location.href = window.location.origin
+ navigate('/')
}
return (
diff --git a/src/pages/dashboard/Dashboard.tsx b/src/pages/dashboard/Dashboard.tsx
index 4e84bfab..d7b43126 100644
--- a/src/pages/dashboard/Dashboard.tsx
+++ b/src/pages/dashboard/Dashboard.tsx
@@ -1,110 +1,111 @@
import {
- Accessor,
createContext,
createResource,
createSignal,
- Match,
- Setter,
+ onCleanup,
Show,
- Switch,
+ onMount,
+ createEffect,
} from 'solid-js'
-import type { VoidComponent } from 'solid-js'
-import { Navigate, useLocation } from '@solidjs/router'
+import { Navigate, useNavigate, useLocation } from '@solidjs/router'
-import { getDevices } from '~/api/devices'
+import { getDevices, getDevice } from '~/api/devices'
import { getProfile } from '~/api/profile'
-import type { Device } from '~/types'
-import Button from '~/components/material/Button'
-import Drawer from '~/components/material/Drawer'
-import IconButton from '~/components/material/IconButton'
-import TopAppBar from '~/components/material/TopAppBar'
-
-import DeviceList from './components/DeviceList'
-import DeviceActivity from './activities/DeviceActivity'
+import { Controls } from '~/pages/dashboard/components/Controls'
+import Search from './components/Search'
+import RouteList from './components/RouteList'
+import Loader from '~/components/Loader'
+import PlaceHolder from '~/components/PlaceHolder'
import RouteActivity from './activities/RouteActivity'
-type DashboardState = {
- drawer: Accessor
- setDrawer: Setter
- toggleDrawer: () => void
-}
+const MAX_WIDTH = 30
+const MIN_WIDTH = 20
-export const DashboardContext = createContext()
+// adapted from https://www.solidjs.com/guides/typescript#context
+// TODO: find a better way to type annotate context without child components complaining DashboardState | undefined
+export const generateContextType = () => {
+ const pathParts = (): string[] => location.pathname.split('/').slice(1).filter(Boolean)
-const DashboardDrawer = (props: {
- onClose: () => void
- devices: Device[] | undefined
-}) => {
- return (
- <>
- arrow_back}
- >
- comma connect
-
-
- Devices
-
-
- {(devices: Device[]) => }
-
-
-
-
- >
- )
-}
+ const [dongleId] = createSignal(pathParts()[0])
+ const [width] = createSignal(MAX_WIDTH)
+ const [route] = createSignal(pathParts()[1])
+ const [device] = createResource(() => dongleId(), getDevice)
+ const [isDesktop] = createSignal(window.innerWidth > 768)
-const DashboardLayout: VoidComponent = () => {
- const location = useLocation()
+ return {width, isDesktop, device, route} as const
+}
- const pathParts = () => location.pathname.split('/').slice(1).filter(Boolean)
- const dongleId = () => pathParts()[0]
- const dateStr = () => pathParts()[1]
+type DashboardState = ReturnType
+export const DashboardContext = createContext()
- const [drawer, setDrawer] = createSignal(false)
- const onOpen = () => setDrawer(true)
- const onClose = () => setDrawer(false)
- const toggleDrawer = () => setDrawer((prev) => !prev)
+function DashboardLayout() {
+ const navigate = useNavigate()
+ const location = useLocation()
+
+ const pathParts = (): string[] => location.pathname.split('/').slice(1).filter(Boolean)
+ const [dongleId, setDongleId] = createSignal(pathParts()[0])
+ const [route, setRoute] = createSignal(pathParts()[1])
const [devices] = createResource(getDevices)
const [profile] = createResource(getProfile)
+ const [device] = createResource(() => dongleId(), getDevice)
+
+ const [leftContainerWidth, setLeftContainerWidth] = createSignal(MAX_WIDTH)
+ const [isDesktop, setView] = createSignal(window.innerWidth > 768)
+
+ const [searchQuery, setSearchQuery] = createSignal('')
+
+ onMount(() => {
+ window.addEventListener('resize', () => {
+ setView(window.innerWidth > 768)
+ })
+ })
+
+ createEffect(() => {
+ const deviceList = devices.latest
+ if (!dongleId() && deviceList && deviceList.length > 0) {
+ setDongleId(deviceList[0].dongle_id)
+ navigate(`/${deviceList[0].dongle_id}`)
+ }
+ setRoute(pathParts()[1])
+ })
+
+ onCleanup(() => {
+ window.removeEventListener('resize', () => {})
+ })
+
+ const handleResize = (e: { clientX: number, preventDefault: () => void }) => {
+ const offset = (e.clientX / window.innerWidth) * 100
+ setLeftContainerWidth(Math.min(Math.max(offset, MIN_WIDTH), MAX_WIDTH))
+ e.preventDefault()
+ }
return (
-
- }
- >
-
- menu}
- >
- No device
-
- >
- }
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ }>
+
+
+
+
+ }>
+
+
+
+
+
)
}
diff --git a/src/pages/dashboard/activities/DeviceActivity.tsx b/src/pages/dashboard/activities/DeviceActivity.tsx
deleted file mode 100644
index f47638f0..00000000
--- a/src/pages/dashboard/activities/DeviceActivity.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import { createResource, Suspense, useContext } from 'solid-js'
-import type { VoidComponent } from 'solid-js'
-
-import { getDevice } from '~/api/devices'
-
-import IconButton from '~/components/material/IconButton'
-import TopAppBar from '~/components/material/TopAppBar'
-import DeviceStatistics from '~/components/DeviceStatistics'
-import { getDeviceName } from '~/utils/device'
-
-import RouteList from '../components/RouteList'
-import { DashboardContext } from '../Dashboard'
-
-type DeviceActivityProps = {
- dongleId: string
-}
-
-const DeviceActivity: VoidComponent