Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Route card redesign #35

Open
wants to merge 19 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 138 additions & 19 deletions src/components/RouteCard.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,35 @@
import { Suspense, type VoidComponent } from 'solid-js'
import { createSignal, createEffect, Suspense, type Component } from 'solid-js'
import dayjs from 'dayjs'

import Avatar from '~/components/material/Avatar'
import Card, { CardContent, CardHeader } from '~/components/material/Card'
import { CardContent, CardHeader } from '~/components/material/Card'
import Icon from '~/components/material/Icon'
import RouteStaticMap from '~/components/RouteStaticMap'
import RouteStatistics from '~/components/RouteStatistics'
import Timeline from './Timeline'

import type { RouteSegments } from '~/types'
import type { Route, RouteSegments } from '~/types'

const RouteHeader = (props: { route: RouteSegments }) => {
const startTime = () => dayjs(props.route.start_time_utc_millis)
const endTime = () => dayjs(props.route.end_time_utc_millis)
import { reverseGeocode } from '~/map'

const headline = () => startTime().format('ddd, MMM D, YYYY')
const subhead = () => `${startTime().format('h:mm A')} to ${endTime().format('h:mm A')}`
const RouteHeader = (props: { route?: RouteSegments }) => {

const startTime = () => props?.route?.start_time_utc_millis ? dayjs(props.route.start_time_utc_millis) : null
const endTime = () => props?.route?.end_time_utc_millis ? dayjs(props.route.end_time_utc_millis) : null

const headline = () => {
if (!startTime() && !endTime()) {
return 'No time info'
}
return startTime()?.format('ddd, MMM D, YYYY') || 'No Time Info!'
}

const subhead = () => {
if (!startTime() && !endTime()) {
return ''
}
return `${startTime()?.format('h:mm A') || 'No start time'} to ${endTime()?.format('h:mm A') || 'No end time'}`
}

return (
<CardHeader
Expand All @@ -29,27 +44,131 @@ const RouteHeader = (props: { route: RouteSegments }) => {
)
}

interface RouteCardProps {
route: RouteSegments
interface GeoResult {
features?: Array<{
properties?: {
context?: {
neighborhood?: string | null,
region?: string | null,
place?: string | null
}
}
}>
}

interface LocationContext {
neighborhood?: {
name: string | null,
},
region?: {
region_code: string | null,
},
place?: {
name: string | null,
}
}

const RouteCard: VoidComponent<RouteCardProps> = (props) => {
async function fetchGeoData(lng: number, lat: number): Promise<GeoResult | null> {
try {
const revGeoResult = await reverseGeocode(lng, lat) as GeoResult
if (revGeoResult instanceof Error) throw revGeoResult
return revGeoResult
} catch (error) {
console.error(error)
// To allow execution to continue for the next location.
return null
}
}

function processGeoResult(
result: GeoResult | null,
setLocation: (location: { neighborhood?: string | null, region?: string | null }) => void,
) {
if (result) {
const { neighborhood, region, place } =
(result?.features?.[0]?.properties?.context || {}) as LocationContext
setLocation({
neighborhood: neighborhood?.name || place?.name,
region: region?.region_code,
})
}
}

type LocationState = { neighborhood?: string | null, region?: string | null }

const RouteRevGeo = (props: { route?: Route }) => {
const [startLocation, setStartLocation] = createSignal<LocationState>({
neighborhood: null,
region: null,
})
const [endLocation, setEndLocation] = createSignal<LocationState>({
neighborhood: null,
region: null,
})
const [error, setError] = createSignal<Error | null>(null)

createEffect(() => {
if (!props.route) return
const { start_lng, start_lat, end_lng, end_lat } = props.route
if (!start_lng || !start_lat || !end_lng || !end_lat) return

Promise.all([
fetchGeoData(start_lng, start_lat),
fetchGeoData(end_lng, end_lat),
]).then(([startResult, endResult]) => {
processGeoResult(startResult, setStartLocation)
processGeoResult(endResult, setEndLocation)
}).catch((error) => {
setError(error as Error)
console.error('An error occurred while fetching geolocation data:', error)
})
})

return (
<Card href={`/${props.route.dongle_id}/${props.route.fullname.slice(17)}`}>
<RouteHeader route={props.route} />
<div>
{error() && <div>Error: {error()?.message}</div>}
<div class="flex w-fit items-center gap-2 rounded-xl border border-gray-700 bg-black px-4 py-1 text-[13px]">
{startLocation().neighborhood && <div>{startLocation().neighborhood}, {startLocation().region}</div>}
<span class="material-symbols-outlined icon-outline" style={{ 'font-size': '14px' }}>
arrow_right_alt
</span>
{endLocation().neighborhood && <div>{endLocation().neighborhood}, {endLocation().region}</div>}
</div>
</div>
)
}

type RouteCardProps = {
route?: Route;
}

const RouteCard: Component<RouteCardProps> = (props) => {
const route = () => props.route

const navigateToRouteActivity = () => {
location.href = `/${route()?.dongle_id}/${route()?.fullname?.slice(17)}`
}

<div class="mx-2 h-48 overflow-hidden rounded-lg">
return (
<div class="custom-card flex shrink-0 flex-col rounded-lg md:flex-row" onClick={navigateToRouteActivity}>
<div class="h-full lg:w-[410px]">
<Suspense
fallback={<div class="skeleton-loader size-full bg-surface" />}
>
<RouteStaticMap route={props.route} />
<RouteStaticMap route={route()} />
</Suspense>
</div>

<CardContent>
<RouteStatistics route={props.route} />
</CardContent>
</Card>
<div class="flex flex-col">
<RouteHeader route={route()} />

<CardContent class="py-0">
<RouteRevGeo route={route()} />
<Timeline route={route()} rounded="rounded-sm" />
<RouteStatistics route={route()} />
</CardContent>
</div>
</div>
)
}

Expand Down
8 changes: 5 additions & 3 deletions src/components/RouteStaticMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ const getStaticMapUrl = (gpsPoints: GPSPathPoint[]): string | undefined => {
if (gpsPoints.length === 0) {
return undefined
}

const path: Coords = []
gpsPoints.forEach(({ lng, lat }) => {
path.push([lng, lat])
})
const themeId = getThemeId()

return getPathStaticMapUrl(themeId, path, 380, 192, true)
}

Expand All @@ -41,7 +43,7 @@ const State = (props: {
return (
<div
class={clsx(
'absolute flex size-full items-center justify-center gap-2',
'absolute flex h-[192px] w-full items-center justify-center gap-2',
props.opaque && 'bg-surface text-on-surface',
)}
>
Expand All @@ -64,7 +66,7 @@ const RouteStaticMap: VoidComponent<RouteStaticMapProps> = (props) => {
return (
<div
class={clsx(
'relative isolate flex h-full flex-col justify-end self-stretch bg-surface text-on-surface',
'flex size-full flex-col',
props.class,
)}
>
Expand All @@ -81,7 +83,7 @@ const RouteStaticMap: VoidComponent<RouteStaticMapProps> = (props) => {
</Match>
<Match when={url() && loadedUrl()} keyed>
<img
class="pointer-events-none size-full object-cover"
class="pointer-events-none size-full rounded-t-lg object-contain md:rounded-none md:rounded-l-lg"
src={loadedUrl()}
alt=""
/>
Expand Down
2 changes: 1 addition & 1 deletion src/components/RouteStatistics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const RouteStatistics: VoidComponent<RouteStatisticsProps> = (props) => {
const [timeline] = createResource(() => props.route, getTimelineStatistics)

return (
<div class={clsx('flex size-full items-stretch gap-8', props.class)}>
<div class={clsx('mb-[10px] flex h-[45px] w-full items-stretch gap-8 whitespace-nowrap', props.class)}>
<div class="flex flex-col justify-between">
<span class="text-body-sm text-on-surface-variant">Distance</span>
<span class="font-mono text-label-lg uppercase">{formatRouteDistance(props.route)}</span>
Expand Down
16 changes: 8 additions & 8 deletions src/components/Timeline.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { For, createResource, Show, Suspense } from 'solid-js'
import type { VoidComponent } from 'solid-js'
import type { Component } from 'solid-js'
import clsx from 'clsx'

import { TimelineEvent, getTimelineEvents } from '~/api/derived'
import { getRoute } from '~/api/route'
import type { Route } from '~/types'
import { getRouteDuration } from '~/utils/date'

Expand Down Expand Up @@ -112,30 +111,31 @@ function renderMarker(route: Route | undefined, seekTime: number | undefined) {
}

interface TimelineProps {
route: Route | undefined
class?: string
routeName: string
seekTime?: number
rounded?: string
}

const Timeline: VoidComponent<TimelineProps> = (props) => {
const [route] = createResource(() => props.routeName, getRoute)
const Timeline: Component<TimelineProps> = (props) => {
const route = () => props.route
const [events] = createResource(route, getTimelineEvents)

return (
<div
class={clsx(
'relative isolate flex h-6 self-stretch overflow-hidden rounded-sm bg-blue-900',
`relative isolate flex h-3.5 self-stretch overflow-hidden ${props.rounded} bg-blue-900`,
'after:absolute after:inset-0 after:bg-gradient-to-b after:from-[rgba(0,0,0,0)] after:via-[rgba(0,0,0,0.1)] after:to-[rgba(0,0,0,0.2)]',
props.class,
)}
title="Disengaged"
>
<Suspense fallback={<div class="skeleton-loader size-full" />}>
<Show when={route()} keyed>
{(route) => (
{(route: Route | undefined) => (
<>
<Show when={events()} keyed>
{(events) => renderTimelineEvents(route, events)}
{(events: TimelineEvent[]) => renderTimelineEvents(route, events)}
</Show>
{renderMarker(route, props.seekTime)}
</>
Expand Down
9 changes: 9 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -213,4 +213,13 @@
display: none; /* Safari and Chrome */
}
}

.custom-card {
cursor: pointer;
background-color: var(--color-surface-container-low);
}

.custom-card:hover {
background-color: var(--color-surface-container);
}
}
14 changes: 14 additions & 0 deletions src/map/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
MAPBOX_TOKEN,
} from './config'

import type { GeocodeResult } from '~/types'

export type Coords = [number, number][]

const POLYLINE_SAMPLE_SIZE = 50
Expand Down Expand Up @@ -50,3 +52,15 @@ 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 async function reverseGeocode(lng: number, lat: number): Promise<GeocodeResult> {
const url = `https://api.mapbox.com/search/geocode/v6/reverse?longitude=${lng}&latitude=${lat}&types=address&worldview=us&access_token=${MAPBOX_TOKEN}`
try {
const response = await fetch(url)
const data = await (response.json() as Promise<GeocodeResult>)
return data
} catch (error) {
console.error(error)
throw error
}
}
2 changes: 1 addition & 1 deletion src/pages/dashboard/activities/DeviceActivity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ const DeviceActivity: VoidComponent<DeviceActivityProps> = (props) => {
</div>
)}
</div>
<div class="flex flex-col gap-2">
<div class="flex w-fit flex-col gap-2">
<span class="text-label-sm">Routes</span>
<RouteList dongleId={props.dongleId} />
</div>
Expand Down
10 changes: 8 additions & 2 deletions src/pages/dashboard/components/RouteList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,18 @@ const pages: Promise<RouteSegments[]>[] = []

const RouteList: VoidComponent<RouteListProps> = (props) => {
const endpoint = () => `/v1/devices/${props.dongleId}/routes_segments?limit=${PAGE_SIZE}`

const getKey = (previousPageData?: RouteSegments[]): string | undefined => {
if (!previousPageData) return endpoint()
if (previousPageData.length === 0) return undefined
const lastSegmentEndTime = previousPageData.at(-1)!.end_time_utc_millis
return `${endpoint()}&end=${lastSegmentEndTime - 1}`
const lastSegment = previousPageData.at(-1)
if (lastSegment && lastSegment.end_time_utc_millis !== undefined) {
const lastSegmentEndTime = lastSegment.end_time_utc_millis
return `${endpoint()}&end=${lastSegmentEndTime - 1}`
}
return undefined
}

const getPage = (page: number): Promise<RouteSegments[]> => {
if (!pages[page]) {
// eslint-disable-next-line no-async-promise-executor
Expand Down
Loading
Loading