Skip to content

Commit

Permalink
Fetch region config from API (#388)
Browse files Browse the repository at this point in the history
  • Loading branch information
graue authored Nov 15, 2024
1 parent 487c8e8 commit c84eb9c
Show file tree
Hide file tree
Showing 15 changed files with 208 additions and 223 deletions.
1 change: 1 addition & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
19 changes: 18 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 18 additions & 9 deletions src/components/DirectionsNullState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -13,6 +13,7 @@ type Props = {

export default function DirectionsNullState(props: Props) {
const intl = useIntl();
const supportedRegion = getSupportedRegionText();

// The <input> rendered here is fake: its only function is to get focused and then
// switch to a different UI that has the real input box.
Expand Down Expand Up @@ -48,21 +49,29 @@ export default function DirectionsNullState(props: Props) {
<p>
<FormattedMessage
defaultMessage={
'<strong>Welcome to BikeHopper!</strong> This is a new bike navigation' +
'<strong>Welcome to BikeHopper!</strong> This is a bike navigation' +
' app that suggests ways to combine biking and transit, expanding your' +
' options for getting around without a car.'
}
description="paragraph in welcome screen"
values={{ strong }}
/>
</p>
<p>
{SupportedRegionText && [<SupportedRegionText key="text" />, ' ']}
<FormattedMessage
defaultMessage="Get started by entering a destination above."
description="text in welcome screen. appears below an input box for destination"
/>
</p>
{supportedRegion && (
<p>
<FormattedMessage
defaultMessage={
'Supported region: <strong>{region}</strong>. 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 }}
/>
</p>
)}
<p className="hidden lg:block">
<FormattedMessage
defaultMessage={
Expand Down
12 changes: 7 additions & 5 deletions src/components/Routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useDispatch, useSelector, shallowEqual } from 'react-redux';
import pointInPolygon from '@turf/boolean-point-in-polygon';
import usePrevious from '../hooks/usePrevious';
import { BOTTOM_DRAWER_MIN_HEIGHT } from '../lib/layout';
import { TRANSIT_SERVICE_AREA } from '../lib/region';
import { getTransitServiceArea } from '../lib/region';
import {
routeClicked,
itineraryBackClicked,
Expand Down Expand Up @@ -37,14 +37,16 @@ export default function Routes(props: {}) {
console.error('rendering routes: expected end location');
}

const transitServiceArea = getTransitServiceArea();

const outOfAreaStart =
TRANSIT_SERVICE_AREA && routeParams?.start?.point
? !pointInPolygon(routeParams.start.point, TRANSIT_SERVICE_AREA)
transitServiceArea && routeParams?.start?.point
? !pointInPolygon(routeParams.start.point, transitServiceArea)
: false;

const outOfAreaEnd =
TRANSIT_SERVICE_AREA && routeParams?.end?.point
? !pointInPolygon(routeParams.end.point, TRANSIT_SERVICE_AREA)
transitServiceArea && routeParams?.end?.point
? !pointInPolygon(routeParams.end.point, transitServiceArea)
: false;

return {
Expand Down
9 changes: 5 additions & 4 deletions src/components/RoutesOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type {
TransitLeg,
} from '../lib/BikeHopperClient';
import formatDistance from '../lib/formatDistance';
import { TRANSIT_DATA_ACKNOWLEDGEMENT } from '../lib/region';
import { getTransitDataAcknowledgement } from '../lib/region';
import { formatInterval } from '../lib/time';
import BorderlessButton from './BorderlessButton';
import Icon from './primitives/Icon';
Expand Down Expand Up @@ -57,6 +57,7 @@ export default function RoutesOverview({
let containsTransitLeg = false;

const outOfAreaMsg = _outOfAreaMsg(intl, outOfAreaStart, outOfAreaEnd);
const transitDataAck = getTransitDataAcknowledgement();

const handleShareClick = (evt: React.MouseEvent) => {
dispatch(shareRoutes(intl));
Expand Down Expand Up @@ -214,13 +215,13 @@ export default function RoutesOverview({
{os === 'iOS' ? <ShareIosIcon /> : <ShareAndroidIcon />}
</Icon>
</BorderlessButton>
{containsTransitLeg && TRANSIT_DATA_ACKNOWLEDGEMENT?.text && (
{containsTransitLeg && transitDataAck?.text && (
<a
target="_blank"
href={TRANSIT_DATA_ACKNOWLEDGEMENT.url}
href={transitDataAck.url}
className="RoutesOverview_acknowledgementLink"
>
{TRANSIT_DATA_ACKNOWLEDGEMENT.text}
{transitDataAck.text}
</a>
)}
</footer>
Expand Down
32 changes: 19 additions & 13 deletions src/features/viewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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],
Expand All @@ -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 {
Expand Down
12 changes: 10 additions & 2 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -67,7 +70,12 @@ async function bootstrapApp(): Promise<void> {

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(
<StrictMode>
Expand Down
14 changes: 12 additions & 2 deletions src/lib/BikeHopperClient.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -29,6 +33,12 @@ export class BikeHopperClientError extends Error {
}
}

export async function fetchRegionConfig(): Promise<RegionConfig> {
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({
Expand Down Expand Up @@ -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();
Expand Down
87 changes: 87 additions & 0 deletions src/lib/region.ts
Original file line number Diff line number Diff line change
@@ -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<GeoJSON.Polygon>
| 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 = <GeomType>(geometryType: z.ZodSchema<GeomType>) =>
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<typeof RegionConfigSchema>;

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;
}
Loading

0 comments on commit c84eb9c

Please sign in to comment.