Skip to content

Commit

Permalink
Localize bike infra annotations
Browse files Browse the repository at this point in the history
...and render as pills in itinerary.

Fixes #333
Fixes #334
Addresses the first two tasks in #177
  • Loading branch information
graue committed May 1, 2024
1 parent f4d1421 commit 91dbef9
Show file tree
Hide file tree
Showing 4 changed files with 159 additions and 46 deletions.
9 changes: 6 additions & 3 deletions src/components/BikehopperMap.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import maplibregl from 'maplibre-gl';
import { forwardRef, useEffect, useRef, useState } from 'react';
import { useCallback, useLayoutEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { useCallback, useLayoutEffect, useMemo } from 'react';
import { useIntl, FormattedMessage } from 'react-intl';
import { useDispatch, useSelector, shallowEqual } from 'react-redux';
import MapGL, {
Layer,
Expand Down Expand Up @@ -45,6 +45,7 @@ const _isTouch = 'ontouchstart' in window;

const BikehopperMap = forwardRef(function _BikehopperMap(props, mapRef) {
const dispatch = useDispatch();
const intl = useIntl();
const {
routeStatus,
startCoords,
Expand Down Expand Up @@ -386,7 +387,9 @@ const BikehopperMap = forwardRef(function _BikehopperMap(props, mapRef) {
});
}, [routes, activePath, viewingDetails, viewingStep, mapRef]);

const features = routes ? routesToGeoJSON(routes) : EMPTY_GEOJSON;
const features = useMemo(() => {
return routes ? routesToGeoJSON(routes, intl) : EMPTY_GEOJSON;
}, [routes, intl]);

const navigationControlStyle = {
visibility: mapRef.current?.getBearing() !== 0 ? 'visible' : 'hidden',
Expand Down
25 changes: 23 additions & 2 deletions src/components/ItineraryBikeStep.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { useCallback } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import classnames from 'classnames';

import BorderlessButton from './BorderlessButton';
import { STEP_ANNOTATIONS, describeStepAnnotation } from '../lib/geometry';
import InstructionSigns from '../lib/InstructionSigns';
import ItineraryStep from './ItineraryStep';
import classnames from 'classnames';

import './ItineraryBikeStep.css';

Expand Down Expand Up @@ -333,7 +335,26 @@ export default function ItineraryBikeStep({
ItineraryBikeStep_infra: true,
})}
>
{infra}
{infra.map((anno, idx) => (
<span
key={idx}
className={classnames({
'mt-1 mr-1 rounded-md px-1 py-0.5 inline-block': true,
'font-medium text-white text-xs': true,
'bg-red-700': anno === STEP_ANNOTATIONS.mainRoad,
'bg-gray-500':
anno === STEP_ANNOTATIONS.steepHill ||
anno === STEP_ANNOTATIONS.verySteepHill,
'bg-bikeinfragreen': !(
anno === STEP_ANNOTATIONS.mainRoad ||
anno === STEP_ANNOTATIONS.steepHill ||
anno === STEP_ANNOTATIONS.verySteepHill
),
})}
>
{describeStepAnnotation(anno, intl)}
</span>
))}
</div>
</BorderlessButton>
</div>
Expand Down
170 changes: 129 additions & 41 deletions src/lib/geometry.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const MAIN_ROADS = [
'trunk_link',
];

export function routesToGeoJSON(paths) {
export function routesToGeoJSON(paths, intl) {
const features = [];

// For-each path
Expand Down Expand Up @@ -80,6 +80,7 @@ export function routesToGeoJSON(paths) {
leg.geometry.coordinates,
leg.type,
pathIdx,
intl,
);
if (detailFeatures) features.push(...detailFeatures);
}
Expand All @@ -93,7 +94,7 @@ export function routesToGeoJSON(paths) {
*
* @param {object} details
*/
function detailsToLines(details, coordinates, type, pathIdx) {
function detailsToLines(details, coordinates, type, pathIdx, intl) {
if (!details || !Object.keys(details).length) return;
const lines = [];
let currentStart = 0;
Expand All @@ -116,9 +117,12 @@ function detailsToLines(details, coordinates, type, pathIdx) {
path_index: pathIdx,
type,
...lineDetails,
bike_infra: _describeBikeInfraFromCyclewayAndRoadClass(
lineDetails['cycleway'],
lineDetails['road_class'],
bike_infra: describeStepAnnotation(
_describeBikeInfraFromCyclewayAndRoadClass(
lineDetails['cycleway'],
lineDetails['road_class'],
),
intl,
),
}),
);
Expand Down Expand Up @@ -159,20 +163,111 @@ export function curveBetween(start, end, options, angle = 30) {
);
}

// TODO: i18n by instead returning constants which are converted into
// user-visible strings within a react component
export const STEP_ANNOTATIONS = {
path: 1,
bikePath: 2,
footPath: 3,
promenade: 4,
steps: 5,
protectedBikeLane: 6,
bikeLane: 7,
sharedRoad: 8,
shoulder: 9,
mainRoad: 10,
steepHill: 11,
verySteepHill: 12,
};

export function describeStepAnnotation(sa, intl) {
switch (sa) {
case STEP_ANNOTATIONS.path:
return intl.formatMessage({
defaultMessage: 'path',
description:
'annotation for a step in a series of biking directions.' +
' A path, such as foot path or bike path.',
});
case STEP_ANNOTATIONS.bikePath:
return intl.formatMessage({
defaultMessage: 'bike path',
description: 'annotation for a step in a series of biking directions.',
});
case STEP_ANNOTATIONS.footPath:
return intl.formatMessage({
defaultMessage: 'foot path',
description: 'annotation for a step in a series of biking directions.',
});
case STEP_ANNOTATIONS.promenade:
return intl.formatMessage({
defaultMessage: 'promenade',
description: 'annotation for a step in a series of biking directions.',
});
case STEP_ANNOTATIONS.steps:
return intl.formatMessage({
defaultMessage: 'steps',
description:
'annotation for a step in a series of biking directions.' +
' Steps or a staircase.',
});
case STEP_ANNOTATIONS.protectedBikeLane:
return intl.formatMessage({
defaultMessage: 'protected bike lane',
description: 'annotation for a step in a series of biking directions.',
});
case STEP_ANNOTATIONS.bikeLane:
return intl.formatMessage({
defaultMessage: 'bike lane',
description: 'annotation for a step in a series of biking directions.',
});
case STEP_ANNOTATIONS.sharedRoad:
return intl.formatMessage({
defaultMessage: 'shared road',
description:
'annotation for a step in a series of biking directions.' +
' A road intended for cycling but that cyclists must share with cars,' +
' without a bike lane.',
});
case STEP_ANNOTATIONS.shoulder:
return intl.formatMessage({
defaultMessage: 'shoulder',
description:
'annotation for a step in a series of biking directions.' +
' A road where bikes are recommended to ride on the shoulder.',
});
case STEP_ANNOTATIONS.mainRoad:
return intl.formatMessage({
defaultMessage: 'main road',
description:
'annotation for a step in a series of biking directions.' +
' A main road which might have lots of fast traffic.',
});
case STEP_ANNOTATIONS.steepHill:
return intl.formatMessage({
defaultMessage: 'steep hill',
description: 'annotation for a step in a series of biking directions.',
});
case STEP_ANNOTATIONS.verySteepHill:
return intl.formatMessage({
defaultMessage: 'very steep hill',
description: 'annotation for a step in a series of biking directions.',
});
default:
return '';
}
}

function _describeBikeInfraFromCyclewayAndRoadClass(cycleway, roadClass) {
if (roadClass === 'path') return 'path';
if (roadClass === 'cycleway') return 'bike path';
if (roadClass === 'footway') return 'foot path';
if (roadClass === 'pedestrian') return 'promenade';
if (roadClass === 'steps') return 'steps';
if (cycleway === 'track') return 'protected bike lane';
if (cycleway === 'lane') return 'bike lane';
if (cycleway === 'shared_lane') return 'shared road';
if (cycleway === 'sidepath') return 'sidepath';
if (cycleway === 'shoulder') return 'shoulder';
if (MAIN_ROADS.includes(roadClass)) return 'main road';
if (roadClass === 'path') return STEP_ANNOTATIONS.path;
if (roadClass === 'cycleway') return STEP_ANNOTATIONS.bikePath;
if (roadClass === 'footway') return STEP_ANNOTATIONS.footPath;
if (roadClass === 'pedestrian') return STEP_ANNOTATIONS.promenade;
if (roadClass === 'steps') return STEP_ANNOTATIONS.steps;
if (cycleway === 'track') return STEP_ANNOTATIONS.protectedBikeLane;
if (cycleway === 'lane') return STEP_ANNOTATIONS.bikeLane;
if (cycleway === 'shared_lane') return STEP_ANNOTATIONS.sharedRoad;
if (cycleway === 'sidepath') return STEP_ANNOTATIONS.path;
if (cycleway === 'shoulder') return STEP_ANNOTATIONS.shoulder;
if (MAIN_ROADS.includes(roadClass)) return STEP_ANNOTATIONS.mainRoad;
return null;
}

Expand All @@ -182,15 +277,15 @@ function _describeBikeInfraFromCyclewayAndRoadClass(cycleway, roadClass) {
// 'cyclewayValues' and 'roadClasses' are each arrays of triples [start, end, value]
// in which the start and end refer to coordinate indexes in the lineString.
//
// TODO: localize this (how since it's outside of react? intl parameter?)
// Return: array of STEP_ANNOTATIONS values.
export function describeBikeInfra(
lineString,
cyclewayValues,
roadClasses,
start,
end,
) {
if (end <= start) return ''; // Ignore instruction steps that travel zero distance.
if (end <= start) return []; // Ignore instruction steps that travel zero distance.

const stepLineString = turf.lineString(
lineString.coordinates.slice(start, end + 1),
Expand All @@ -206,7 +301,7 @@ export function describeBikeInfra(

let cyclewayIndex = 0,
roadClassIndex = 0;
const distanceByInfraType = {};
const distanceByInfraType = new Map();
let gradesScratchpad = []; // array of [percent grade, length in km] tuples
let maxGrade = 0;

Expand All @@ -227,9 +322,11 @@ export function describeBikeInfra(
);

if (infraType) {
distanceByInfraType[infraType] =
(distanceByInfraType[infraType] || 0) +
(segmentLength * 100) / stepTotalDistance;
distanceByInfraType.set(
infraType,
(distanceByInfraType.get(infraType) || 0) +
(segmentLength * 100) / stepTotalDistance,
);
}

// Compute windowed grade
Expand Down Expand Up @@ -261,35 +358,26 @@ export function describeBikeInfra(
'kilometers',
);
if (stepTotalDistance < MIN_DISTANCE_TO_DESCRIBE) {
if (distanceByInfraType.steps) return 'steps';
return '';
if (distanceByInfraType.has(STEP_ANNOTATIONS.steps))
return [STEP_ANNOTATIONS.steps];
return [];
}

let infraTypes = Object.entries(distanceByInfraType);
let infraTypes = Array.from(distanceByInfraType.entries());
// Sort the infra types by most common first
infraTypes.sort((a, b) => b[1] - a[1]);

let descriptors = infraTypes
.filter(([infraType, percent]) => percent > 25)
.map(([infraType, percent]) => `${_describePercent(percent)} ${infraType}`);
.map(([infraType, percent]) => infraType);

if (maxGrade > 14) {
descriptors = [
'very steep hill (max grade ' + maxGrade.toFixed(1) + '%)',
].concat(descriptors);
descriptors.unshift(STEP_ANNOTATIONS.verySteepHill);
} else if (maxGrade > 8) {
descriptors = [
'steep hill (max grade ' + maxGrade.toFixed(1) + '%)',
].concat(descriptors);
descriptors.unshift(STEP_ANNOTATIONS.steepHill);
}

return descriptors.join(', ');
}

function _describePercent(percent) {
if (percent > 75) return '';
if (percent > 50) return 'mostly ';
return 'partial ';
return descriptors;
}

function _elevationChangeInKm(lineSegment) {
Expand Down
1 change: 1 addition & 0 deletions tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default {
bikehoppergreen: '#5aaa0a',
bikehoppergreenlight: '#def0cc',
bikehopperyellow: '#ffd18e',
bikeinfragreen: '#438601',
},
scale: {
'-100': '-1', // allow flipping
Expand Down

0 comments on commit 91dbef9

Please sign in to comment.