Skip to content

Commit

Permalink
feat: show user and device on map
Browse files Browse the repository at this point in the history
  • Loading branch information
jtgi committed Dec 13, 2024
1 parent cbdc157 commit bb317ee
Show file tree
Hide file tree
Showing 8 changed files with 230 additions and 2 deletions.
Binary file modified bun.lockb
Binary file not shown.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@stylistic/eslint-plugin": "^2.1.0",
"@types/eslint__js": "^8.42.3",
"@types/mapbox__polyline": "^1.0.5",
"@types/leaflet": "^1.9.15",
"@typescript-eslint/eslint-plugin": "^7.13.0",
"@typescript-eslint/parser": "^7.13.0",
"autoprefixer": "^10.4.19",
Expand Down Expand Up @@ -84,6 +85,7 @@
"clsx": "^1.2.1",
"dayjs": "^1.11.11",
"hls.js": "^1.5.13",
"leaflet": "^1.9.4",
"qr-scanner": "^1.4.2",
"solid-js": "^1.8.17"
},
Expand Down
1 change: 1 addition & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Suspense, lazy, type VoidComponent } from 'solid-js'
import { Router, Route } from '@solidjs/router'
import 'leaflet/dist/leaflet.css'

const Login = lazy(() => import('./pages/auth/login'))
const Logout = lazy(() => import('./pages/auth/logout'))
Expand Down
214 changes: 214 additions & 0 deletions src/components/DeviceLocation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { createSignal, onMount, onCleanup, Show } from 'solid-js'
import type { VoidComponent } from 'solid-js'
import L from 'leaflet'
import { MAPBOX_USERNAME, MAPBOX_TOKEN } from '~/map/config'
import { getThemeId } from '~/theme'
import { getMapStyleId } from '~/map'
import { Device } from '~/types'
import { render } from 'solid-js/web'
import Icon from './material/Icon'
import clsx from 'clsx'
import Button from './material/Button'
import { createResource } from 'solid-js'

type Location = {
lat: number
lng: number
label: string
address: string | null
}

const THE_GUNDO: [number, number] = [33.9153, 118.4041]

const DeviceLocation: VoidComponent<{ device: Device; deviceName: string }> = (props) => {
let mapContainer!: HTMLDivElement

const [map, setMap] = createSignal<L.Map | null>(null)
const [selectedLocation, setSelectedLocation] = createSignal<Location | null>(null)
const [locationPermission, setLocationPermission] = createSignal<'granted' | 'denied' | 'prompt'>('prompt')

onMount(() => {
navigator.permissions.query({ name: 'geolocation' }).then(permission => {
setLocationPermission(permission.state)
permission.addEventListener('change', () => setLocationPermission(permission.state))
}).catch(() => setLocationPermission('denied'))

const tileLayer = L.tileLayer(
`https://api.mapbox.com/styles/v1/${MAPBOX_USERNAME}/${getMapStyleId(getThemeId())}/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
)
const instance = L.map(
mapContainer,
{
attributionControl: false,
zoomControl: false,
layers: [tileLayer],
},
)
instance.setView(THE_GUNDO, 10)
instance.on('click', () => setSelectedLocation(null))

// fix: leaflet sometimes misses resize events
// and leaves unrendered gray tiles
const observer = new ResizeObserver(() => instance.invalidateSize())
observer.observe(mapContainer)
onCleanup(() => observer.disconnect())

setMap(instance)
})

const [locationData] = createResource(() => ({
map,
device: props.device,
deviceName: props.deviceName,
locationPermission,
}), async (args) => {
const _map = args.map()
if (!_map) {
return []
}

const foundLocations: Location[] = []

if (args.device.last_gps_lat && args.device.last_gps_lng) {
const address = await getPlaceName(args.device.last_gps_lat, args.device.last_gps_lng)
const deviceLoc: Location = {
lat: args.device.last_gps_lat,
lng: args.device.last_gps_lng,
label: args.deviceName,
address,
}

addMarker(_map, deviceLoc, 'directions_car')
foundLocations.push(deviceLoc)
}

if (args.locationPermission() === 'granted') {
const position = await getUserPosition().catch(() => null)

if (position) {
const addr = await getPlaceName(position.coords.latitude, position.coords.longitude)
const userLoc: Location = {
lat: position.coords.latitude,
lng: position.coords.longitude,
label: 'You',
address: addr,
}

addMarker(_map, userLoc, 'person', 'bg-primary')
foundLocations.push(userLoc)
}
}

if (foundLocations.length > 1) {
_map.fitBounds(L.latLngBounds(foundLocations.map(l => [l.lat, l.lng])), { padding: [50, 50] })
} else if (foundLocations.length === 1) {
_map.setView([foundLocations[0].lat, foundLocations[0].lng], 15)
} else {
throw new Error('Location unavailable')
}

return foundLocations
})


const addMarker = (instance: L.Map, loc: Location, iconName: string, iconClass?: string) => {
const el = document.createElement('div')

render(() =>
<div class={clsx('flex size-[40px] items-center justify-center rounded-full bg-primary-container', iconClass)}>
<Icon>{iconName}</Icon>
</div>, el)

const icon = L.divIcon({
className: 'border-none bg-none',
html: el.innerHTML,
iconSize: [40, 40],
iconAnchor: [20, 20],
})

L.marker([loc.lat, loc.lng], { icon })
.addTo(instance)
.on('click', () => setSelectedLocation(loc))
}

const getUserPosition = () => {
return new Promise<GeolocationPosition>((resolve, reject) =>
navigator.geolocation.getCurrentPosition(resolve, reject),
)
}

const requestLocation = async () => {
const position = await getUserPosition()
if (position) {
setLocationPermission('granted')
}
}

return (
<div class="relative">
<div ref={mapContainer} class="h-[200px] w-full !bg-surface-container-low" />

<Show when={locationPermission() !== 'granted'}>
<div class="absolute bottom-2 right-2 z-[9999]">
<Button
title="Show your current location"
color="secondary"
class="bg-surface-container-low text-on-surface-variant"
onClick={() => void requestLocation()}
trailing={<span class="pr-2 text-sm">Show my location</span>}
>
<Icon size="20">my_location</Icon>
</Button>
</div>
</Show>

<Show when={locationData.loading}>
<div class="absolute left-1/2 top-1/2 z-[5000] flex -translate-x-1/2 -translate-y-1/2 items-center rounded-full bg-surface-variant px-4 py-2 shadow">
<div class="mr-2 size-4 animate-spin rounded-full border-2 border-on-surface-variant border-t-transparent" />
<span class="text-sm">Locating...</span>
</div>
</Show>

<Show when={(locationData.error as Error)?.message}>
<div class="absolute left-1/2 top-1/2 z-[5000] flex -translate-x-1/2 -translate-y-1/2 items-center rounded-full bg-surface-variant px-4 py-2 shadow">
<Icon class="mr-2 text-red-500">error</Icon>
<span class="text-sm text-red-500">{(locationData.error as Error).message}</span>
</div>
</Show>

<div class={clsx(
'absolute bottom-0 left-0 z-[9999] w-full p-2 transition-opacity duration-150',
selectedLocation() ? 'opacity-100' : 'pointer-events-none opacity-0',
)}>
<div class="flex w-full gap-4 rounded-lg bg-surface-container-high p-4 shadow-lg">
<div class="flex-auto">
<h3 class="mb-2 font-bold">{selectedLocation()?.label}</h3>
<p class="mb-2 text-sm text-on-surface-variant">{selectedLocation()?.address}</p>
</div>
<div class="shrink-0 self-end">
<Button
color="secondary"
onClick={() => window.open(`https://www.google.com/maps?q=${selectedLocation()!.lat},${selectedLocation()!.lng}`, '_blank')}
trailing={<Icon size="20">open_in_new</Icon>}
class="rounded-lg bg-gray-50 px-3 py-2 text-sm font-medium text-black"
>
Open in Maps
</Button>
</div>
</div>
</div>
</div>
)
}

async function getPlaceName(lat: number, lng: number) {
try {
const r = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${lng},${lat}.json?access_token=${MAPBOX_TOKEN}`)
const data = await r.json() as { features?: { place_name?: string }[] }
return data.features?.[0]?.place_name ?? null
} catch {
return null
}
}

export default DeviceLocation
2 changes: 1 addition & 1 deletion src/map/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
11 changes: 10 additions & 1 deletion src/pages/dashboard/activities/DeviceActivity.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createResource, Suspense, useContext, createSignal, For } from 'solid-js'
import { createResource, Suspense, useContext, createSignal, For, Switch, Match } from 'solid-js'
import type { VoidComponent } from 'solid-js'

import { getDevice } from '~/api/devices'
Expand All @@ -12,6 +12,7 @@ import { getDeviceName } from '~/utils/device'

import RouteList from '../components/RouteList'
import { DashboardContext } from '../Dashboard'
import DeviceLocation from '~/components/DeviceLocation'

type DeviceActivityProps = {
dongleId: string
Expand Down Expand Up @@ -111,6 +112,14 @@ const DeviceActivity: VoidComponent<DeviceActivityProps> = (props) => {
</TopAppBar>
<div class="flex flex-col gap-4 px-4 pb-4">
<div class="h-min overflow-hidden rounded-lg bg-surface-container-low">
<Switch>
<Match when={device() && deviceName()}>
<DeviceLocation device={device()!} deviceName={deviceName()!} />
</Match>
<Match when={true}>
<div class="skeleton-loader size-full" />
</Match>
</Switch>
<div class="flex">
<div class="flex-auto">
<Suspense fallback={<div class="skeleton-loader size-full" />}>
Expand Down
Empty file.
2 changes: 2 additions & 0 deletions src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export interface Device extends ApiResponseBase {
openpilot_version: string
sim_id: string
sim_type: number
last_gps_lat: number | null
last_gps_lng: number | null
eligible_features: {
prime: boolean
prime_data: boolean
Expand Down

0 comments on commit bb317ee

Please sign in to comment.