diff --git a/.eslintrc.cjs b/.eslintrc.cjs index eb4c6e12..9853191d 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -31,6 +31,7 @@ module.exports = { 'react/jsx-key': 'warn', 'no-empty': ['warn', { allowEmptyCatch: true }], 'no-constant-condition': 'warn', + 'no-unreachable': 'warn', 'require-await': 'warn', }, overrides: [ diff --git a/package-lock.json b/package-lock.json index 4e1dde69..90f6cb29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,7 +47,9 @@ "redux": "^4.1.2", "redux-thunk": "^2.4.1", "reselect": "^5.1.1", - "resolve": "^1.20.0" + "resolve": "^1.20.0", + "zod": "^3.23.8", + "zod-geojson": "^0.0.3" }, "devDependencies": { "@formatjs/cli": "^6.2.9", @@ -10653,6 +10655,21 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-geojson": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/zod-geojson/-/zod-geojson-0.0.3.tgz", + "integrity": "sha512-swL9Tatws3djCG6yux98SuaIdfa8dHh3lEITNeI8HOCxM26XBgqOVrw4KC5YtNwxE1kmfaJnR8rG+fRc5y7Eog==", + "license": "MIT" } } } diff --git a/package.json b/package.json index 81456b2b..24cc3452 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,9 @@ "redux": "^4.1.2", "redux-thunk": "^2.4.1", "reselect": "^5.1.1", - "resolve": "^1.20.0" + "resolve": "^1.20.0", + "zod": "^3.23.8", + "zod-geojson": "^0.0.3" }, "scripts": { "start": "vite", diff --git a/src/components/DirectionsNullState.tsx b/src/components/DirectionsNullState.tsx index d5d4cff3..30df17e7 100644 --- a/src/components/DirectionsNullState.tsx +++ b/src/components/DirectionsNullState.tsx @@ -3,7 +3,7 @@ import type { FocusEvent, ReactNode } from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; import MagnifyingGlass from 'iconoir/icons/search.svg?react'; import Icon from './primitives/Icon'; -import { SupportedRegionText } from '../lib/region'; +import { getSupportedRegionText } from '../lib/region'; import './DirectionsNullState.css'; @@ -13,6 +13,7 @@ type Props = { export default function DirectionsNullState(props: Props) { const intl = useIntl(); + const supportedRegion = getSupportedRegionText(); // The rendered here is fake: its only function is to get focused and then // switch to a different UI that has the real input box. @@ -48,7 +49,7 @@ export default function DirectionsNullState(props: Props) {

Welcome to BikeHopper! This is a new bike navigation' + + 'Welcome to BikeHopper! This is a bike navigation' + ' app that suggests ways to combine biking and transit, expanding your' + ' options for getting around without a car.' } @@ -56,13 +57,21 @@ export default function DirectionsNullState(props: Props) { values={{ strong }} />

-

- {SupportedRegionText && [, ' ']} - -

+ {supportedRegion && ( +

+ {region}. Get started' + + ' by entering a destination above.' + } + description={ + 'paragraph in welcome screen. region is a city or region name.' + + ' Appears below an input box for destination' + } + values={{ strong, region: supportedRegion }} + /> +

+ )}

{ dispatch(shareRoutes(intl)); @@ -214,13 +215,13 @@ export default function RoutesOverview({ {os === 'iOS' ? : } - {containsTransitLeg && TRANSIT_DATA_ACKNOWLEDGEMENT?.text && ( + {containsTransitLeg && transitDataAck?.text && ( - {TRANSIT_DATA_ACKNOWLEDGEMENT.text} + {transitDataAck.text} )} diff --git a/src/features/viewport.ts b/src/features/viewport.ts index 12f968a1..13c4eb84 100644 --- a/src/features/viewport.ts +++ b/src/features/viewport.ts @@ -12,7 +12,7 @@ import type { Action } from 'redux'; import type { ViewState } from 'react-map-gl/maplibre'; import * as geoViewport from '@placemarkio/geo-viewport'; -import { DEFAULT_VIEWPORT_BOUNDS } from '../lib/region'; +import { getDefaultViewportBounds } from '../lib/region'; import type { BikeHopperAction } from '../store'; const MAPBOX_VT_SIZE = 512; @@ -26,11 +26,15 @@ type ViewportInfo = { }; function viewportForScreen(screenDims: [number, number]): ViewportInfo { - const viewport = geoViewport.viewport(DEFAULT_VIEWPORT_BOUNDS, screenDims, { - minzoom: 0, - maxzoom: 14, - tileSize: MAPBOX_VT_SIZE, - }); + const viewport = geoViewport.viewport( + getDefaultViewportBounds(), + screenDims, + { + minzoom: 0, + maxzoom: 14, + tileSize: MAPBOX_VT_SIZE, + }, + ); return { latitude: viewport.center[1], longitude: viewport.center[0], @@ -40,17 +44,19 @@ function viewportForScreen(screenDims: [number, number]): ViewportInfo { }; } -export const DEFAULT_VIEWPORT = viewportForScreen([ - window.innerWidth, - window.innerHeight, -]); - -const DEFAULT_STATE: ViewportInfo = { ...DEFAULT_VIEWPORT }; +function genDefaultState(): ViewportInfo { + return viewportForScreen([window.innerWidth, window.innerHeight]); +} export function viewportReducer( - state = DEFAULT_STATE, + state: ViewportInfo | undefined, action: BikeHopperAction, ): ViewportInfo { + if (!state) { + // Must be generated after geoconfig has loaded, so not at import time. + state = genDefaultState(); + } + switch (action.type) { case 'map_moved': return { diff --git a/src/index.tsx b/src/index.tsx index 12e74aaa..c1df7803 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,8 +3,11 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { Provider } from 'react-redux'; import { shouldPolyfill as listFormatShouldPolyfill } from '@formatjs/intl-listformat/should-polyfill'; + import App from './components/App'; -import store from './store'; +import { init as initStore } from './store'; +import { fetchRegionConfig } from './lib/BikeHopperClient'; +import { init as initRegion } from './lib/region'; import './index.css'; @@ -67,7 +70,12 @@ async function bootstrapApp(): Promise { const root = createRoot(document.getElementById('root')!); const locale = selectLocale(); - const messages = await loadMessages(locale); + const [regionConfig, messages] = await Promise.all([ + fetchRegionConfig(), + loadMessages(locale), + ]); + initRegion(regionConfig); + const store = initStore(); root.render( diff --git a/src/lib/BikeHopperClient.ts b/src/lib/BikeHopperClient.ts index 7c643d19..839c245e 100644 --- a/src/lib/BikeHopperClient.ts +++ b/src/lib/BikeHopperClient.ts @@ -1,6 +1,10 @@ import { DateTime } from 'luxon'; import delay from './delay'; -import { DEFAULT_VIEWPORT_BOUNDS } from './region'; +import { + getDefaultViewportBounds, + RegionConfig, + RegionConfigSchema, +} from './region'; import { InstructionSign } from './InstructionSigns'; import { Mode } from './TransitModes'; import { POINT_PRECISION } from './geometry'; @@ -29,6 +33,12 @@ export class BikeHopperClientError extends Error { } } +export async function fetchRegionConfig(): Promise { + const result = await fetch(`${getApiPath()}/api/v1/config`); + if (!result.ok) throw new BikeHopperClientError(result); + return RegionConfigSchema.parse(await result.json()); +} + type GtfsRouteType = number; export async function fetchRoute({ @@ -309,7 +319,7 @@ export async function geocode( if (now < dontHitBefore) await delay(dontHitBefore - now); url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent( placeString, - )}&limit=1&viewbox=${DEFAULT_VIEWPORT_BOUNDS.join(',')}&format=geojson`; + )}&limit=1&viewbox=${getDefaultViewportBounds().join(',')}&format=geojson`; // TODO figure out why bounded=1 messes up results // Things come up without it but disappear with it...? _lastNominatimReqTime = Date.now(); diff --git a/src/lib/region.ts b/src/lib/region.ts new file mode 100644 index 00000000..c64152b9 --- /dev/null +++ b/src/lib/region.ts @@ -0,0 +1,87 @@ +import { z } from 'zod'; +import { GeoJSONPolygonSchema } from 'zod-geojson'; + +export function getAgencyDisplayName(gtfsAgencyName: string): string { + const { agencyAliases } = _getConfig(); + return (agencyAliases && agencyAliases[gtfsAgencyName]) || gtfsAgencyName; +} + +export function getSupportedRegionText(): string | undefined { + return _getConfig().supportedRegionDescription; +} + +export function getDefaultViewportBounds(): [number, number, number, number] { + const { defaultViewport, transitServiceArea } = _getConfig(); + if (defaultViewport) { + return defaultViewport; + } else if (transitServiceArea) { + // generate bounding box + let bbox: [number, number, number, number] = [180, 90, -180, -90]; + for (const coord of transitServiceArea.geometry.coordinates[0]) { + bbox[0] = Math.min(bbox[0], coord[0]); + bbox[1] = Math.min(bbox[1], coord[1]); + bbox[2] = Math.max(bbox[2], coord[0]); + bbox[3] = Math.max(bbox[3], coord[1]); + } + return bbox; + } else { + throw new Error( + 'one of default viewport or transit service area must be defined', + ); + } +} + +export function getTransitServiceArea(): + | GeoJSON.Feature + | undefined { + return _getConfig().transitServiceArea; +} + +export function getTransitDataAcknowledgement(): + | { + text: string; + url: string; + } + | undefined { + return _getConfig().transitDataAcknowledgement; +} + +const Latitude = z.number().lte(90).gte(-90); +const Longitude = z.number().lte(180).gte(-180); + +const FeatureOfSchema = (geometryType: z.ZodSchema) => + z.object({ + properties: z.record(z.string(), z.any()), + type: z.literal('Feature'), + geometry: geometryType, + }); + +export const RegionConfigSchema = z.object({ + agencyAliases: z.record(z.string(), z.string()).optional(), + transitDataAcknowledgement: z + .object({ + text: z.string(), + url: z.string(), + }) + .optional(), + transitServiceArea: FeatureOfSchema(GeoJSONPolygonSchema).optional(), + defaultViewport: z + .tuple([Longitude, Latitude, Longitude, Latitude]) + .optional(), + supportedRegionDescription: z.string().optional(), +}); +export type RegionConfig = z.infer; + +let _regionConfig: RegionConfig | null = null; + +function _getConfig(): RegionConfig { + if (!_regionConfig) throw new Error('region config not initialized'); + return _regionConfig; +} + +export function init(config: RegionConfig) { + if (_regionConfig) { + throw new Error('region config already initialized'); + } + _regionConfig = config; +} diff --git a/src/lib/region/BayArea.tsx b/src/lib/region/BayArea.tsx deleted file mode 100644 index 34671984..00000000 --- a/src/lib/region/BayArea.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { FormattedMessage } from 'react-intl'; - -/* eslint-disable react-refresh/only-export-components */ - -export function SupportedRegionText() { - return ( - - San Francisco Bay Area,' + - ' California.' - } - description="describes supported region for Bay Area instance of BikeHopper" - values={{ strong: (chunks) => {chunks} }} - /> - - ); -} - -export const DEFAULT_VIEWPORT_BOUNDS: [number, number, number, number] = [ - -122.597652, 37.330751, -121.669687, 37.858476, -]; - -export const AGENCY_COMMON_NAMES: Record = { - 'AC TRANSIT': 'AC Transit', - 'Bay Area Rapid Transit': 'BART', - 'San Francisco Municipal Transportation Agency': 'Muni', - 'Capitol Corridor Joint Powers Authority': 'Capitol Corridor', -}; - -// Generated; see scripts/gtfs/ -// Last generated based on 2022/11/29 regional 511 GTFS dump -export const TRANSIT_SERVICE_AREA: GeoJSON.Feature = { - geometry: { - coordinates: [ - [ - [-122.934717602052, 38.0657217846716], - [-122.592855104275, 37.4890856152745], - [-122.582918059716, 37.475993234839], - [-122.498879245645, 37.3877341046361], - [-122.488577670219, 37.3786176705377], - [-122.47649072562, 37.3710116923302], - [-121.621337024145, 36.9149702922179], - [-121.605803740923, 36.9082417864834], - [-121.588933924057, 36.9040519024665], - [-121.571368789088, 36.902559845504], - [-121.553775751097, 36.9038222992483], - [-121.536823273122, 36.9077912909354], - [-121.521155676931, 36.9143159998438], - [-121.507368833934, 36.9231484429335], - [-121.49598762641, 36.9339528301239], - [-121.474369675758, 36.9590830570304], - [-121.466620340069, 36.9700096260051], - [-121.461350395401, 36.9818420485311], - [-121.458719619684, 36.994224249018], - [-121.458808522461, 37.0067834295131], - [-121.596706602266, 38.1757603706033], - [-121.600399426388, 38.1903268650075], - [-121.607800491188, 38.2039935119599], - [-121.618599623542, 38.2161839106203], - [-121.867293628393, 38.4421152496585], - [-121.878007683742, 38.4503472287548], - [-121.881027386736, 38.4523167243367], - [-121.898770699, 38.4614069186767], - [-122.97453187976, 38.8759586904302], - [-122.992251602416, 38.8810080796265], - [-122.999700854747, 38.8824831303937], - [-123.018281553072, 38.8846355957731], - [-123.037038533754, 38.8838332682929], - [-123.055203575891, 38.8801090156815], - [-123.072032917718, 38.8736153615898], - [-123.086837950314, 38.8646181853924], - [-123.099013559795, 38.8534857499229], - [-123.108062924574, 38.8406735239722], - [-123.109682304411, 38.8377037328306], - [-123.114567999982, 38.8261099926876], - [-123.116882501855, 38.8140413911205], - [-123.145991625815, 38.4579191800476], - [-123.144708945542, 38.4404619231194], - [-123.138119300921, 38.4237549048193], - [-122.935541936189, 38.0671395317679], - [-122.934717602052, 38.0657217846716], - ], - ], - type: 'Polygon', - }, - properties: {}, - type: 'Feature', -}; - -export const TRANSIT_DATA_ACKNOWLEDGEMENT = { - text: 'Data provided by 511.org', - url: 'http://www.511.org', -}; diff --git a/src/lib/region/Chicagoland.jsx b/src/lib/region/Chicagoland.jsx deleted file mode 100644 index 2519e78e..00000000 --- a/src/lib/region/Chicagoland.jsx +++ /dev/null @@ -1,20 +0,0 @@ -/* eslint-disable react-refresh/only-export-components */ -/* eslint-disable formatjs/no-literal-string-in-jsx */ - -export function SupportedRegionText(props) { - // TODO: localize - return ( - - the Chicago area - - ); -} - -export const DEFAULT_VIEWPORT_BOUNDS = [-89.057, 40.937, -86.255, 42.932]; - -export const AGENCY_COMMON_NAMES = { - 'Chicago Transit Authority': 'CTA', -}; - -export const TRANSIT_SERVICE_AREA = null; // TODO -export const TRANSIT_DATA_ACKNOWLEDGEMENT = null; // TODO: is one required? diff --git a/src/lib/region/LosAngeles.jsx b/src/lib/region/LosAngeles.jsx deleted file mode 100644 index 0eb21aaa..00000000 --- a/src/lib/region/LosAngeles.jsx +++ /dev/null @@ -1,27 +0,0 @@ -/* eslint-disable react-refresh/only-export-components */ -/* eslint-disable formatjs/no-literal-string-in-jsx */ - -export function SupportedRegionText(props) { - // TODO: localize - return ( - - the Los Angeles metro - - ); -} - -// latitude 33.930742 -// longitude -118.232976 -// zoom 9.2 - -// top left: lat 34.19, long -118.61 -// lower right: lat 33.77, long -117.98 - -export const DEFAULT_VIEWPORT_BOUNDS = [-118.61, 33.77, -117.98, 34.19]; - -export const AGENCY_COMMON_NAMES = { - //'Chicago Transit Authority': 'CTA', -}; - -export const TRANSIT_SERVICE_AREA = null; // TODO -export const TRANSIT_DATA_ACKNOWLEDGEMENT = null; // TODO: is one required? diff --git a/src/lib/region/index.ts b/src/lib/region/index.ts deleted file mode 100644 index 9cd2f0e2..00000000 --- a/src/lib/region/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -// As I write this comment, we only actively support the Bay Area. The point of -// this file is to centralize all region-specific assumptions made on the -// frontend in one place, so we can more easily modularize and support more -// regions in the future. - -import { - SupportedRegionText, - DEFAULT_VIEWPORT_BOUNDS, - AGENCY_COMMON_NAMES, - TRANSIT_SERVICE_AREA, - TRANSIT_DATA_ACKNOWLEDGEMENT, -} from './BayArea'; - -export { - SupportedRegionText, - DEFAULT_VIEWPORT_BOUNDS, - TRANSIT_SERVICE_AREA, - TRANSIT_DATA_ACKNOWLEDGEMENT, -}; - -export function getAgencyDisplayName(gtfsAgencyName: string): string { - return AGENCY_COMMON_NAMES[gtfsAgencyName] || gtfsAgencyName; -} diff --git a/src/store.ts b/src/store.ts index a7218aaf..578cd445 100644 --- a/src/store.ts +++ b/src/store.ts @@ -19,32 +19,42 @@ import { viewportReducer } from './features/viewport'; import type { ViewportAction } from './features/viewport'; import urlMiddleware from './lib/urlMiddleware'; -const rootReducer = combineReducers({ - alerts: alertsReducer, - geocoding: geocodingReducer, - geolocation: geolocationReducer, - routeParams: routeParamsReducer, - routes: routesReducer, - viewport: viewportReducer, -}); - declare global { interface Window { __REDUX_DEVTOOLS_EXTENSION_COMPOSE__?: typeof compose; } } -const enhancedCompose = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; +function createRootReducer() { + return combineReducers({ + alerts: alertsReducer, + geocoding: geocodingReducer, + geolocation: geolocationReducer, + routeParams: routeParamsReducer, + routes: routesReducer, + viewport: viewportReducer, + }); +} + +export function init() { + const rootReducer = createRootReducer(); + + const enhancedCompose = + window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; -const store = createStore( - rootReducer, - enhancedCompose( - applyMiddleware(thunkMiddleware, urlMiddleware, storageMiddleware), - ), -); + const store = createStore( + rootReducer, + enhancedCompose( + applyMiddleware(thunkMiddleware, urlMiddleware, storageMiddleware), + ), + ); -export type GetState = typeof store.getState; -export type RootState = ReturnType; + store.dispatch(initFromStorage()); + return store; +} + +export type GetState = ReturnType['getState']; +export type RootState = ReturnType>; export type Dispatch = ThunkDispatch; export type BikeHopperThunkAction = ThunkAction< void, @@ -53,8 +63,6 @@ export type BikeHopperThunkAction = ThunkAction< AnyAction >; -store.dispatch(initFromStorage()); - export type BikeHopperAction = ( | AlertAction | GeocodingAction @@ -66,5 +74,3 @@ export type BikeHopperAction = ( | ViewportAction ) & ActionAlertMixin; - -export default store;