Skip to content

Commit

Permalink
feat: stop popup predictions. (#7)
Browse files Browse the repository at this point in the history
  • Loading branch information
morganney committed Sep 11, 2023
1 parent f03423c commit bb771d6
Show file tree
Hide file tree
Showing 12 changed files with 360 additions and 14 deletions.
4 changes: 4 additions & 0 deletions packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@
"import": "./dist/dataList/mod.js",
"require": "./dist/cjs/dataList/mod.cjs"
},
"./skeleton": {
"import": "./dist/skeleton/mod.js",
"require": "./dist/cjs/skeleton/mod.cjs"
},
"./icons/*": {
"import": "./dist/icons/*/mod.js",
"require": "./dist/cjs/*/mod.js"
Expand Down
2 changes: 2 additions & 0 deletions packages/components/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
export { DataList } from './dataList/mod.js'
export { Input } from './input/mod.js'
export { AutoSuggest } from './autoSuggest/mod.js'
export { Skeleton } from './skeleton/mod.js'

export type { InputProps } from './input/mod.js'
export type { DataListProps } from './dataList/mod.js'
export type { AutoSuggestProps, AnItem, Item } from './autoSuggest/mod.js'
export type { SkeletonProps } from './skeleton/mod.js'
89 changes: 89 additions & 0 deletions packages/components/src/skeleton/mod.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import styled, { keyframes, css } from 'styled-components'

import { PB97T, PB90T } from '../colors.js'

import type { FC } from 'react'

const baseColor = PB90T
const highlightColor = PB97T
const shimmer = keyframes`
100% {
transform: translateX(100%);
}
`
const getAnimation = ({
isAnimated,
duration
}: {
isAnimated: boolean
duration: number
}) => {
if (!isAnimated) {
return null
}

return css`
animation: ${shimmer} ${duration}s ease-in-out infinite;
background-image: linear-gradient(
90deg,
${baseColor} 0%,
${highlightColor} 50%,
${baseColor} 100%
);
`
}
const Box = styled.span<Required<SkeletonProps>>`
display: ${({ display }) => display};
position: relative;
overflow: hidden;
background-color: ${baseColor};
border-radius: ${props => (props.circle ? '50%' : '4px')};
height: ${props => (props.circle ? `calc(2 * ${props.radius}) ` : props.height)};
width: ${props => (props.circle ? `calc(2 * ${props.radius}) ` : props.width)};
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
transform: translateX(-100%);
${getAnimation};
}
`
interface SkeletonProps {
display?: 'inline-block' | 'block'
width?: string
height?: string
circle?: boolean
radius?: string
duration?: number
isAnimated?: boolean
}
const Skeleton: FC<SkeletonProps> = ({
display = 'inline-block',
width = '100%',
height = '15px',
circle = false,
radius = '25px',
duration = 1.5,
isAnimated = true,
...rest
}) => {
return (
<Box
display={display}
width={width}
height={height}
circle={circle}
radius={radius}
duration={duration}
isAnimated={isAnimated}
{...rest}
/>
)
}

export { Skeleton }
export type { SkeletonProps }
33 changes: 33 additions & 0 deletions packages/components/src/skeleton/story.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Skeleton } from './mod.js'

import type { StoryFn } from '@storybook/react'

type Story = StoryFn<typeof Skeleton>

const Primary: Story = args => {
return <Skeleton {...args} />
}

export default {
title: 'Skeleton',
component: Skeleton,
args: {
isAnimated: true,
width: '125px',
height: '14px',
circle: false,
display: 'block',
duration: 1.2
},
argTypes: {
display: {
control: 'select',
options: ['inline-block', 'block']
},
radius: {
control: 'text'
}
}
}

export { Primary }
14 changes: 14 additions & 0 deletions packages/ui/assets/svg/circled.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions packages/ui/src/api/rb/predictions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { ROOT } from './common.js'

import { transport } from '../transport.js'
import { errors } from '../errors.js'

import type { Prediction } from '../../types.js'

const getForStop = async (agencyId?: string, routeId?: string, stopId?: string) => {
if (!agencyId || !routeId || !stopId) {
throw errors.create('GET', 400, 'Bad Request')
}

const preds = await transport.fetch<Prediction[]>(
`${ROOT}/agencies/${agencyId}/routes/${routeId}/stops/${stopId}/predictions`
)

return preds
}

export { getForStop }
63 changes: 63 additions & 0 deletions packages/ui/src/components/stop.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import styled from 'styled-components'
import { Skeleton } from '@busmap/components/skeleton'

import type { FC } from 'react'
import type { Stop, Route } from '../types.js'

interface StopProps {
stop?: Stop
route?: Route
direction?: string
arrivals?: string[]
isLoading?: boolean
}

const Definition = styled.dl`
display: grid;
grid-template-rows: repeat(3, max-content);
grid-template-columns: max-content 1fr;
align-items: center;
gap: 8px;
margin: 0;
padding: 0;
dt,
dd {
margin: 0;
padding: 0;
padding: 1px 2px;
}
dt {
text-align: right;
font-weight: 700;
}
dt::after {
content: ':';
}
`
const Stop: FC<StopProps> = ({ stop, route, direction, arrivals, isLoading = false }) => {
return (
<Definition>
<dt>Route</dt>
<dd>{route?.title}</dd>
<dt>Direction</dt>
<dd>{direction}</dd>
<dt>Stop</dt>
<dd>{stop?.title}</dd>
<dt>Arrivals</dt>
<dd>
{isLoading ? (
<Skeleton display="block" />
) : arrivals?.length ? (
arrivals?.join(', ')
) : (
'No arrivals'
)}
</dd>
</Definition>
)
}

export { Stop }
1 change: 1 addition & 0 deletions packages/ui/src/globals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { BusmapGlobals } from './types'

const defaultGlobals = {
dispatch: () => {},
agency: undefined,
route: null,
center: { lat: 32.79578, lon: -95.45166 },
bounds: {
Expand Down
24 changes: 17 additions & 7 deletions packages/ui/src/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { AnItem } from '@busmap/components'
interface HomeState {
agency?: string
route?: string
stop?: string
}
interface HomeAction {
type: 'agency' | 'route'
Expand Down Expand Up @@ -65,19 +66,28 @@ const Home: FC<HomeProps> = () => {
['route', state.route],
() => getRoute(state.agency, state.route),
{
enabled: Boolean(state.route),
enabled: Boolean(state.route) && Boolean(state.agency),
onSuccess(data) {
update({ type: 'bounds', value: data.bounds })
update({ type: 'route', value: data })
}
}
)
const onSelectAgency = useCallback((selected: AnItem) => {
dispatch({
type: 'agency',
value: typeof selected === 'string' ? selected : selected.value
})
}, [])
const onSelectAgency = useCallback(
(selected: AnItem) => {
const value = typeof selected === 'string' ? selected : selected.value

dispatch({
type: 'agency',
value
})
update({
type: 'agency',
value
})
},
[update]
)
const onSelectRoute = useCallback((selected: AnItem) => {
dispatch({
type: 'route',
Expand Down
67 changes: 64 additions & 3 deletions packages/ui/src/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,40 @@
import L from 'leaflet'
import { useEffect, useContext } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'

import { Globals } from './globals.js'
import { getForStop } from './api/rb/predictions.js'
import { Stop } from './components/stop.js'

import type { FC, ReactNode } from 'react'
import type { Direction } from './types.js'

type Point = [number, number]

const getDirectionForStop = (id: string, directions: Direction[]) => {
for (const direction of directions) {
const found = direction.stops.find(stop => stop === id)

if (found) {
return direction.title
}
}

return directions[0].title
}

export interface LayoutProps {
children: ReactNode
}
export const Layout: FC<LayoutProps> = ({ children }) => {
const { bounds, route } = useContext(Globals)
const { bounds, route, agency, dispatch } = useContext(Globals)

useEffect(() => {
const main = document.querySelector('main') as HTMLElement
const map = L.map(main)
const bnds = route?.bounds ?? bounds
const polylines: Point[][] = []

L.latLngBounds(L.latLng(bnds.sw.lat, bnds.sw.lon), L.latLng(bnds.ne.lat, bnds.ne.lon))
map.fitBounds(
L.latLngBounds(
L.latLng(bnds.sw.lat, bnds.sw.lon),
Expand All @@ -29,10 +47,53 @@ export const Layout: FC<LayoutProps> = ({ children }) => {
'&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map)

if (route) {
const icon = L.icon({
iconUrl: '../assets/svg/circled.svg',
iconSize: [7, 7]
})
const popup = L.popup()

route.paths.forEach(path => {
polylines.push(path.points.map(({ lat, lon }) => [lat, lon]))
})
L.polyline(polylines, { color: route.color }).addTo(map)
route.stops.forEach(stop => {
const direction = getDirectionForStop(stop.id, route.directions)
const marker = L.marker([stop.lat, stop.lon], { icon })

marker.addTo(map).bindPopup(popup)
marker.on('click', async () => {
marker.setPopupContent(
renderToStaticMarkup(
<Stop isLoading stop={stop} route={route} direction={direction} />
)
)
const preds = await getForStop(agency, route.id, stop.id)
const arrivals = !preds.length
? []
: /**
* Given that the agency, route, and stop
* are defined the first prediction's values
* should suffice.
*/
preds[0].values
.slice(0, 3)
.map(({ minutes }) => `${minutes === 0 ? 'Arriving' : `${minutes} min`}`)

marker.setPopupContent(
renderToStaticMarkup(
<Stop stop={stop} route={route} direction={direction} arrivals={arrivals} />
)
)
})
})
}

return () => {
map.remove()
}
}, [bounds, route])
}, [bounds, agency, route, dispatch])

return children
}
Loading

0 comments on commit bb771d6

Please sign in to comment.