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;