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 6 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
146 changes: 127 additions & 19 deletions src/components/RouteCard.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
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.segment_start_times[0])
const endTime = () => dayjs(props.route.segment_end_times.at(-1))
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?.segment_start_times ? dayjs(props.route.segment_start_times[0]) : null
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's best not to add new usages of segment_start_times: #53

const endTime = () => props?.route?.segment_end_times ? dayjs(props.route.segment_end_times.at(-1)) : null
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here with segment_end_times


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

return (
<CardHeader
Expand All @@ -29,27 +33,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,
}
}

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,
})
}
}

const RouteCard: VoidComponent<RouteCardProps> = (props) => {
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;
}

<div class="mx-2 h-48 overflow-hidden rounded-lg">
const RouteCard: Component<RouteCardProps> = (props) => {
const route = () => props.route

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

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 @@ -33,7 +33,7 @@ const DeviceActivity: VoidComponent<DeviceActivityProps> = (props) => {
</div>
</Suspense>
</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
32 changes: 22 additions & 10 deletions src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,12 @@ export interface Route {
create_time: number
devicetype: number
dongle_id: string
start_lng?: number
start_lat?: number
end_lat?: number
end_lng?: number
end_time?: string
fullname: string
end_time: string
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this might not exist: #60

fullname?: string
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is guaranteed to exist

git_branch?: string
git_commit?: string
git_dirty?: boolean
Expand Down Expand Up @@ -88,14 +90,24 @@ export interface RouteShareSignature extends Record<string, string> {
}

export interface RouteSegments extends Route {
end_time_utc_millis: number
is_preserved: boolean
segment_end_times: number[]
segment_numbers: number[]
segment_start_times: number[]
share_exp: RouteShareSignature['exp']
share_sig: RouteShareSignature['sig']
start_time_utc_millis: number
end_time_utc_millis?: number
is_preserved?: boolean
segment_end_times?: number[]
segment_numbers?: number[]
segment_start_times?: number[]
share_exp?: RouteShareSignature['exp']
share_sig?: RouteShareSignature['sig']
start_time_utc_millis?: number
}

export interface GeocodeResult {
formatted_address: string;
geometry: {
location: {
lat: number;
lng: number;
};
};
}

export interface Clip {
Expand Down