diff --git a/packages/ui/src/app.tsx b/packages/ui/src/app.tsx index ab099d0..c26b418 100644 --- a/packages/ui/src/app.tsx +++ b/packages/ui/src/app.tsx @@ -3,6 +3,7 @@ import { RouterProvider } from 'react-router-dom' import { GlobalsProvider } from './globals.js' import { VehiclesProvider } from './contexts/vehicles.js' +import { SettingsProvider } from './contexts/settings/index.js' import { router } from './router.js' import type { FC } from 'react' @@ -19,9 +20,11 @@ const BusMap: FC = () => { return ( - - - + + + + + ) diff --git a/packages/ui/src/components/busSelector.tsx b/packages/ui/src/components/busSelector.tsx index c513427..3393f6c 100644 --- a/packages/ui/src/components/busSelector.tsx +++ b/packages/ui/src/components/busSelector.tsx @@ -34,7 +34,7 @@ const BusSelector = memo(function BusSelector({ agencies }: BusSelectorProps) { const navigate = useNavigate() const bookmark = useBusSelectorBookmark() const [routeName, setRouteName] = useState() - const { dispatch, markPredictedVehicles, agency, route, direction, stop } = useGlobals() + const { dispatch, agency, route, direction, stop } = useGlobals() const vehiclesDispatch = useVehiclesDispatch() const stops = useMemo(() => { if (direction && route) { @@ -177,9 +177,6 @@ const BusSelector = memo(function BusSelector({ agencies }: BusSelectorProps) { ) } }, [dispatch, vehiclesDispatch, navigate, agency, route, direction]) - const onTogglePredictedVehicles = useCallback(() => { - dispatch({ type: 'markPredictedVehicles', value: !markPredictedVehicles }) - }, [dispatch, markPredictedVehicles]) const error = getFirstDataError([routesError, routeError]) const isLoading = isRoutesLoading || isRouteLoading @@ -232,8 +229,6 @@ const BusSelector = memo(function BusSelector({ agencies }: BusSelectorProps) { selected={stop} onClear={onClearStop} onSelect={onSelectStop} - markPredictedVehicles={markPredictedVehicles} - onTogglePredictedVehicles={onTogglePredictedVehicles} isDisabled={isLoading || !agency || !route || !direction} /> diff --git a/packages/ui/src/components/predictions.tsx b/packages/ui/src/components/predictions.tsx index 28f004c..0c53c3c 100644 --- a/packages/ui/src/components/predictions.tsx +++ b/packages/ui/src/components/predictions.tsx @@ -2,6 +2,7 @@ import styled, { keyframes } from 'styled-components' import { PB50T, PB80T } from '@busmap/components/colors' import { PredictedVehiclesColors } from '../utils.js' +import { useVehicleSettings } from '../contexts/settings/vehicle.js' import type { FC } from 'react' import type { Prediction, Stop } from '../types.js' @@ -12,7 +13,6 @@ interface PredictionsProps { isFetching: boolean timestamp: number messages: Prediction['messages'] - markPredictedVehicles: boolean } const blink = keyframes` @@ -115,9 +115,11 @@ const List = styled.ul<{ markPredictedVehicles: boolean }>` } ` const AffectedByLayover = styled.details` + display: inline-block; margin: 0 0 12px 0; summary:first-of-type { font-size: 12px; + cursor: pointer; } p { line-height: 1.25; @@ -130,10 +132,12 @@ const AffectedByLayover = styled.details` } ` const Messages = styled.details` + display: inline-block; margin: 12px 0 0 0; summary:first-of-type { font-size: 12px; color: blue; + cursor: pointer; } ul { @@ -158,13 +162,9 @@ const Messages = styled.details` } } ` -const Predictions: FC = ({ - preds, - stop, - messages, - timestamp, - markPredictedVehicles = true -}) => { +const Predictions: FC = ({ preds, stop, messages, timestamp }) => { + const { markPredictedVehicles } = useVehicleSettings() + if (Array.isArray(preds) && stop) { if (preds.length) { const values = preds[0].values.slice(0, 3) diff --git a/packages/ui/src/components/selectors/stops.tsx b/packages/ui/src/components/selectors/stops.tsx index 01fe876..fa3c111 100644 --- a/packages/ui/src/components/selectors/stops.tsx +++ b/packages/ui/src/components/selectors/stops.tsx @@ -2,57 +2,38 @@ import { AutoSuggest } from '@busmap/components/autoSuggest' import { FormItem } from '../formItem.js' -import type { FC, ChangeEvent } from 'react' +import type { FC } from 'react' import type { Stop } from '../../types.js' interface Props { stops: Stop[] selected?: Stop isDisabled?: boolean - markPredictedVehicles: boolean onClear?: (clearItem: () => void) => void onSelect: (selected: Stop) => void - onTogglePredictedVehicles: (evt: ChangeEvent) => void } const Stops: FC = ({ stops, selected, onSelect, onClear, - onTogglePredictedVehicles, - markPredictedVehicles = true, isDisabled = Boolean(stops) }) => { return ( - <> - - - - - - - + + + ) } diff --git a/packages/ui/src/components/settings.tsx b/packages/ui/src/components/settings.tsx new file mode 100644 index 0000000..6ad4ca8 --- /dev/null +++ b/packages/ui/src/components/settings.tsx @@ -0,0 +1,91 @@ +import styled from 'styled-components' +import { useCallback } from 'react' + +import { FormItem } from './formItem.js' + +import { useSettings } from '../contexts/settings/index.js' + +import type { FC, ChangeEvent } from 'react' +import type { SpeedUnit } from '../contexts/settings/index.js' + +const Form = styled.form` + fieldset { + display: flex; + flex-direction: column; + gap: 20px; + padding: 10px; + } + + fieldset.row { + flex-direction: row; + gap: 10px; + } + legend { + font-size: 14px; + line-height: 1; + } +` +const Settings: FC = () => { + const settings = useSettings() + const onTogglePredictedVehicles = useCallback(() => { + settings.vehicle.dispatch({ + type: 'markPredictedVehicles', + value: !settings.vehicle.markPredictedVehicles + }) + }, [settings.vehicle]) + const onChangeSpeedUnit = useCallback( + (evt: ChangeEvent) => { + settings.vehicle.dispatch({ + type: 'speedUnit', + value: evt.currentTarget.value as SpeedUnit + }) + }, + [settings.vehicle] + ) + + return ( +
{ + evt.preventDefault() + }}> +
+ Vehicles + + + +
+ Speed units + + + + + + +
+
+
+ ) +} + +export { Settings } diff --git a/packages/ui/src/contexts/settings/index.tsx b/packages/ui/src/contexts/settings/index.tsx new file mode 100644 index 0000000..e408a07 --- /dev/null +++ b/packages/ui/src/contexts/settings/index.tsx @@ -0,0 +1,14 @@ +import { VehicleSettingsProvider, useVehicleSettings } from './vehicle.js' + +import type { FC, ReactNode } from 'react' + +const SettingsProvider: FC<{ children: ReactNode }> = ({ children }) => { + return {children} +} +const useSettings = () => { + return { + vehicle: useVehicleSettings() + } +} +export { SettingsProvider, useSettings } +export type { SpeedUnit } from './vehicle.js' diff --git a/packages/ui/src/contexts/settings/vehicle.tsx b/packages/ui/src/contexts/settings/vehicle.tsx new file mode 100644 index 0000000..cf690c6 --- /dev/null +++ b/packages/ui/src/contexts/settings/vehicle.tsx @@ -0,0 +1,50 @@ +import { createContext, useContext, useReducer, useMemo } from 'react' + +import type { FC, ReactNode, Dispatch } from 'react' + +type SpeedUnit = 'kph' | 'mph' +interface VehicleSettingsState { + markPredictedVehicles: boolean + speedUnit: SpeedUnit + dispatch: Dispatch +} +interface MarkPredictedVehicles { + type: 'markPredictedVehicles' + value: boolean +} +interface SpeedUnitChanged { + type: 'speedUnit' + value: SpeedUnit +} +type VehicleSettingsAction = MarkPredictedVehicles | SpeedUnitChanged +const defaultState: VehicleSettingsState = { + dispatch: () => {}, + speedUnit: 'kph', + markPredictedVehicles: true +} +const VehicleSettings = createContext(defaultState) +const reducer = (state: VehicleSettingsState, action: VehicleSettingsAction) => { + switch (action.type) { + case 'speedUnit': + return { ...state, speedUnit: action.value } + case 'markPredictedVehicles': + return { ...state, markPredictedVehicles: action.value } + default: + return { ...defaultState, ...state } + } +} +const VehicleSettingsProvider: FC<{ children: ReactNode }> = ({ children }) => { + const [vehicleSettings, dispatch] = useReducer(reducer, defaultState) + const context = useMemo( + () => ({ ...vehicleSettings, dispatch }), + [vehicleSettings, dispatch] + ) + + return {children} +} +const useVehicleSettings = () => { + return useContext(VehicleSettings) +} + +export { VehicleSettingsProvider, useVehicleSettings } +export type { SpeedUnit } diff --git a/packages/ui/src/globals.tsx b/packages/ui/src/globals.tsx index 902910a..7916af9 100644 --- a/packages/ui/src/globals.tsx +++ b/packages/ui/src/globals.tsx @@ -8,7 +8,6 @@ type BusmapState = Omit const defaultGlobals = { dispatch: () => {}, locationSettled: false, - markPredictedVehicles: true, center: { lat: 37.7775, lon: -122.416389 }, bounds: { sw: { @@ -55,8 +54,6 @@ const reducer = (state: BusmapState, action: BusmapAction): BusmapState => { return { ...state, stop: action.value, predictions: undefined } case 'locationSettled': return { ...state, locationSettled: action.value } - case 'markPredictedVehicles': - return { ...state, markPredictedVehicles: action.value } case 'predictions': return { ...state, predictions: action.value } case 'selected': diff --git a/packages/ui/src/home.tsx b/packages/ui/src/home.tsx index 1209cd8..38986b8 100644 --- a/packages/ui/src/home.tsx +++ b/packages/ui/src/home.tsx @@ -8,6 +8,7 @@ import { useGlobals } from './globals.js' import { useVehiclesDispatch } from './contexts/vehicles.js' import { BusSelector } from './components/busSelector.js' import { Loading } from './components/loading.js' +import { Settings } from './components/settings.js' import { Predictions } from './components/predictions.js' import { Anchor } from './components/anchor.js' import { getAll as getAllAgencies } from './api/rb/agency.js' @@ -55,14 +56,7 @@ interface HomeProps { } const Home: FC = () => { const vehiclesDispatch = useVehiclesDispatch() - const { - dispatch: update, - markPredictedVehicles, - locationSettled, - agency, - route, - stop - } = useGlobals() + const { dispatch: update, locationSettled, agency, route, stop } = useGlobals() const [state, dispatch] = useReducer(reducer, initialState) const { data: agencies, error: agenciesError } = useQuery('agencies', getAllAgencies) const { data: preds, isFetching: isPredsFetching } = useQuery( @@ -128,7 +122,7 @@ const Home: FC = () => { borderRadius="5px 5px 0 0"> - + @@ -136,11 +130,11 @@ const Home: FC = () => { - -

Other

+ +

Info

-

Settings

+

Profile

@@ -156,7 +150,6 @@ const Home: FC = () => { stop={stop} preds={preds} messages={messages} - markPredictedVehicles={markPredictedVehicles} timestamp={state.timestamp} /> diff --git a/packages/ui/src/hooks/common.ts b/packages/ui/src/hooks/common.ts index 5935d5c..a2206fd 100644 --- a/packages/ui/src/hooks/common.ts +++ b/packages/ui/src/hooks/common.ts @@ -2,14 +2,22 @@ import { Marker } from 'leaflet' import type { MarkerOptions, LatLng } from 'leaflet' import type { Vehicle } from '../types.js' +import type { SpeedUnit } from '../contexts/settings/vehicle.js' class VehicleMarker extends Marker { #vehicle: Vehicle + #speedUnit: SpeedUnit - constructor(latlng: LatLng, vehicle: Vehicle, options?: MarkerOptions) { + constructor( + latlng: LatLng, + vehicle: Vehicle, + speedUnit: SpeedUnit, + options?: MarkerOptions + ) { super(latlng, options) this.#vehicle = vehicle + this.#speedUnit = speedUnit } get vehicle() { @@ -19,6 +27,14 @@ class VehicleMarker extends Marker { set vehicle(value: Vehicle) { this.#vehicle = value } + + get speedUnit() { + return this.#speedUnit + } + + set speedUnit(value: SpeedUnit) { + this.#speedUnit = value + } } export { VehicleMarker } diff --git a/packages/ui/src/hooks/useVehiclesLayer.ts b/packages/ui/src/hooks/useVehiclesLayer.ts index 23aa58d..72bff81 100644 --- a/packages/ui/src/hooks/useVehiclesLayer.ts +++ b/packages/ui/src/hooks/useVehiclesLayer.ts @@ -5,6 +5,7 @@ import { VehicleMarker } from './common.js' import { useGlobals } from '../globals.js' import { useVehicles } from '../contexts/vehicles.js' +import { useVehicleSettings } from '../contexts/settings/vehicle.js' import { PredictedVehiclesColors } from '../utils.js' import type { LayerGroup } from 'leaflet' @@ -143,8 +144,10 @@ const assignDynamicStyles = ({ * The outcome is a 'N/A' in the vehicle poupup, despite the selector form * showing the correct direction. Basically a config error from the API source. */ -const getVehiclePopupContent = (vehicle: Vehicle, route: Route) => { +const getVehiclePopupContent = (marker: VehicleMarker, route: Route) => { + const { vehicle, speedUnit } = marker const direction = route.directions.find(dir => dir.id === vehicle.directionId) + const speed = speedUnit === 'mph' ? Math.round(vehicle.kph / 1.609) : vehicle.kph return `
@@ -155,7 +158,7 @@ const getVehiclePopupContent = (vehicle: Vehicle, route: Route) => {
ID
${vehicle.id}
Speed
-
${vehicle.kph} (kph)
+
${speed} (${speedUnit})
Heading
${vehicle.heading} (deg)
Predictable
@@ -167,7 +170,9 @@ const getVehiclePopupContent = (vehicle: Vehicle, route: Route) => { } const useVehiclesLayer = ({ vehiclesLayer }: UseVehiclesLayer) => { const vehicles = useVehicles() - const { route, predictions, markPredictedVehicles } = useGlobals() + const { route, predictions } = useGlobals() + const { markPredictedVehicles, speedUnit } = useVehicleSettings() + const iconDimensions = useRef(null) const preds = useRef(predictions?.length ? predictions[0].values.slice(0, 3) : []) @@ -198,7 +203,8 @@ const useVehiclesLayer = ({ vehiclesLayer }: UseVehiclesLayer) => { dimensions: iconDimensions.current }) marker.vehicle = vehicle - marker.getPopup()?.setContent(getVehiclePopupContent(vehicle, route)) + marker.speedUnit = speedUnit + marker.getPopup()?.setContent(getVehiclePopupContent(marker, route)) marker.setLatLng(L.latLng(vehicle.lat, vehicle.lon)) } else { const div = document.createElement('div') @@ -209,12 +215,17 @@ const useVehiclesLayer = ({ vehiclesLayer }: UseVehiclesLayer) => { className: 'busmap-vehicle', html: div }) - const marker = new VehicleMarker(L.latLng(vehicle.lat, vehicle.lon), vehicle, { - icon - }) + const marker = new VehicleMarker( + L.latLng(vehicle.lat, vehicle.lon), + vehicle, + speedUnit, + { + icon + } + ) const popup = L.popup({ className: 'busmap-vehicle-popup', - content: getVehiclePopupContent(vehicle, route) + content: getVehiclePopupContent(marker, route) }) marker.bindPopup(popup) @@ -223,7 +234,7 @@ const useVehiclesLayer = ({ vehiclesLayer }: UseVehiclesLayer) => { }) marker.on('click', () => { popup.getElement()?.classList.add('selected') - popup.setContent(getVehiclePopupContent(marker.vehicle, route)) + popup.setContent(getVehiclePopupContent(marker, route)) }) title.appendChild(document.createTextNode(route.title)) @@ -260,7 +271,7 @@ const useVehiclesLayer = ({ vehiclesLayer }: UseVehiclesLayer) => { } else { vehiclesLayer.clearLayers() } - }, [vehicles, vehiclesLayer, route, markPredictedVehicles]) + }, [vehicles, vehiclesLayer, route, markPredictedVehicles, speedUnit]) } export { useVehiclesLayer } diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index b269198..e8000fe 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -130,10 +130,6 @@ interface LocationSettled { type: 'locationSettled' value: boolean } -interface MarkPredictedVehiclesChanged { - type: 'markPredictedVehicles' - value: boolean -} type BusmapAction = | BoundsChanged | CenterChanged @@ -144,7 +140,6 @@ type BusmapAction = | PredictionsChanged | SelectedChanged | LocationSettled - | MarkPredictedVehiclesChanged interface BusmapGlobals { dispatch: Dispatch center: Point @@ -156,7 +151,6 @@ interface BusmapGlobals { predictions?: Prediction[] selected?: Selection locationSettled: boolean - markPredictedVehicles: boolean } export type {