Skip to content

Commit

Permalink
Add DistanceUnitFormatter and DurationUnitFormatter, two react compon…
Browse files Browse the repository at this point in the history
…ents to automatically choose the best unit for a value

They also allow clicking on the values to cycle between the different possible units. Example usage is shown in transit routing output for the walking mode.

Fixes #775
  • Loading branch information
davidmurray committed Nov 22, 2023
1 parent 726c117 commit 90d99ee
Show file tree
Hide file tree
Showing 7 changed files with 191 additions and 6 deletions.
9 changes: 7 additions & 2 deletions locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,15 @@
"DataSource": "Data source",
"DataSources": "Data sources",
"Highlights": "Highlights",
"minuteAbbr": "min.",
"hourAbbr": "hr",
"minuteAbbr": "min",
"secondAbbr": "sec",
"meterAbbr": "m",
"kilometerAbbr": "km",
"mileAbbr": "mi",
"feetAbbr": "ft",
"copy": "copy",
"passengerAbbr": "pass.",
"secondAbbr": "sec",
"Max": "Max",
"Min": "Min",
"Left": "Left",
Expand Down
7 changes: 6 additions & 1 deletion locales/fr/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,15 @@
"DataSource": "Source de données",
"DataSources": "Sources de données",
"Highlights": "Faits saillants",
"hourAbbr": "h",
"secondAbbr": "s",
"minuteAbbr": "min",
"meterAbbr": "m",
"kilometerAbbr": "km",
"mileAbbr": "milles",
"feetAbbr": "pi",
"copy": "copie",
"passengerAbbr": "pass.",
"secondAbbr": "sec",
"Max": "Max",
"Min": "Min",
"Left": "Gauche",
Expand Down
33 changes: 33 additions & 0 deletions packages/chaire-lib-common/src/utils/DateTimeUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,38 @@ const hoursToSeconds = function (hours) {
return _isFinite(hours) ? hours * 3600 : null;
};

/**
* Convert a duration in seconds to a formatted time string.
* The function takes a duration in seconds and returns a string
* in the format "XX hr YY min ZZ sec", where each non-zero
* component is included with its corresponding unit.
*
* @param {number} seconds - The duration in seconds to be converted.
* @param {string} hourUnit - The unit for hours (e.g., "hr").
* @param {string} minuteUnit - The unit for minutes (e.g., "min").
* @param {string} secondsUnit - The unit for seconds (e.g., "sec").
* @returns {string} - The formatted time string or an empty string if the input is invalid.
*/
const toXXhrYYminZZsec = function (seconds: number, hourUnit: string, minuteUnit: string, secondsUnit: string) {
if (_isBlank(seconds)) {
return null;
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);

const timeComponents = [
{ value: hours, unit: hourUnit },
{ value: minutes, unit: minuteUnit },
{ value: secs, unit: secondsUnit }
];

return timeComponents
.map((component) => (component.value > 0 ? component.value + ' ' + component.unit : ''))
.filter(Boolean) // Discard empty components (ex: no seconds)
.join(' ');
};

/**
* Convert a time string (HH:MM[:ss]) to the number of seconds since midnight.
* Time strings can be > 24:00, for example for schedules for a day that end
Expand Down Expand Up @@ -168,6 +200,7 @@ export {
minutesToHoursDecimal,
minutesToSeconds,
hoursToSeconds,
toXXhrYYminZZsec,
timeStrToSecondsSinceMidnight,
intTimeToSecondsSinceMidnight,
roundSecondsToNearestMinute
Expand Down
10 changes: 10 additions & 0 deletions packages/chaire-lib-common/src/utils/PhysicsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ const milesToMeters = function (miles: number): number {
return miles * 1609.34;
};

const metersToFeet = function (meters: number): number {
return meters * 3.281;
};

const feetToMeters = function (feet: number): number {
return feet / 3.281;
};

const kmToMiles = function (km: number): number {
return km / 1.60934;
};
Expand Down Expand Up @@ -95,6 +103,8 @@ export {
mphToMps,
metersToMiles,
milesToMeters,
metersToFeet,
feetToMeters,
kmToMiles,
milesToKm,
sqFeetToSqMeters,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright 2023, Polytechnique Montreal and contributors
*
* This file is licensed under the MIT License.
* License text available at https://opensource.org/licenses/MIT
*/
import React, { useState, useEffect } from 'react';
import { withTranslation, WithTranslation } from 'react-i18next';
import { roundToDecimals } from 'chaire-lib-common/lib/utils/MathUtils';
import { metersToMiles, metersToFeet } from 'chaire-lib-common/lib/utils/PhysicsUtils';

const destinationUnitOptions = ['kilometers', 'meters', 'miles', 'feet'] as const;
type destinationUnitOptionsType = typeof destinationUnitOptions[number];

export interface DistanceUnitFormatterProps extends WithTranslation {
value: number;
sourceUnit: 'kilometers' | 'meters';
destinationUnit?: destinationUnitOptionsType;
}

const DistanceUnitFormatter: React.FunctionComponent<DistanceUnitFormatterProps> = (
props: DistanceUnitFormatterProps
) => {
const [destinationUnit, setDestinationUnit] = useState<string | undefined>(props.destinationUnit);

const valueInMeters = props.sourceUnit === 'meters' ? props.value : props.value / 1000;

useEffect(() => {
// This effect runs only after the initial render
// If the destination unit was not specified, we choose the best one based on the magnitude of the value.
if (destinationUnit === undefined) {
if (valueInMeters < 1000) {
setDestinationUnit('meters');
} else {
setDestinationUnit('kilometers');
}
}
}, [valueInMeters]);

const unitFormatters: Record<destinationUnitOptionsType, (value: number) => string> = {
meters: (value) => `${roundToDecimals(value, 0)} ${props.t('main:meterAbbr')}`,
kilometers: (value) => `${roundToDecimals(value / 1000, 2)} ${props.t('main:kilometerAbbr')}`,
miles: (value) => `${roundToDecimals(metersToMiles(value), 2)} ${props.t('main:mileAbbr')}`,
feet: (value) => `${roundToDecimals(metersToFeet(value), 0)} ${props.t('main:feetAbbr')}`
};

const formattedValue = destinationUnit ? unitFormatters[destinationUnit](valueInMeters) : '';

const cycleThroughDestinationUnits = () => {
// Infer the next unit based on the currently displayed unit.
setDestinationUnit((prevUnit) => {
return destinationUnitOptions[
(destinationUnitOptions.indexOf(prevUnit as destinationUnitOptionsType) + 1) %
destinationUnitOptions.length
];
});
};

return <span onClick={cycleThroughDestinationUnits}>{formattedValue}</span>;
};

export default withTranslation([])(DistanceUnitFormatter);
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* Copyright 2023, Polytechnique Montreal and contributors
*
* This file is licensed under the MIT License.
* License text available at https://opensource.org/licenses/MIT
*/
import React, { useState, useEffect } from 'react';
import { withTranslation, WithTranslation } from 'react-i18next';
import { toXXhrYYminZZsec } from 'chaire-lib-common/lib/utils/DateTimeUtils';
import { roundToDecimals } from 'chaire-lib-common/lib/utils/MathUtils';

const destinationUnitOptions = ['hrMinSec', 'seconds', 'minutes', 'hours'] as const;
type destinationUnitOptionsType = typeof destinationUnitOptions[number];

export interface DurationUnitFormatterProps extends WithTranslation {
value: number;
sourceUnit: 'seconds' | 'minutes' | 'hours';
destinationUnit?: destinationUnitOptionsType;
}

const DurationUnitFormatter: React.FunctionComponent<DurationUnitFormatterProps> = (
props: DurationUnitFormatterProps
) => {
const [destinationUnit, setDestinationUnit] = useState<string | undefined>(props.destinationUnit);

const valueInSeconds =
props.sourceUnit === 'seconds'
? props.value
: props.sourceUnit === 'minutes'
? props.value * 60
: props.sourceUnit === 'hours'
? props.value * 60 * 60
: props.value;

useEffect(() => {
// This effect runs only after the initial render
// If the destination unit was not specified, we choose a default.
if (destinationUnit === undefined) {
setDestinationUnit('hrMinSec');
}
}, [valueInSeconds]);

const unitFormatters: Record<destinationUnitOptionsType, (value: number) => string> = {
hrMinSec: (value) =>
toXXhrYYminZZsec(value, props.t('main:hourAbbr'), props.t('main:minuteAbbr'), props.t('main:secondAbbr')) ||
`${value.toString()} ${props.t('main:secondAbbr')}`,
seconds: (value) => `${roundToDecimals(value, 2)} ${props.t('main:secondAbbr')}`,
minutes: (value) => `${roundToDecimals(value / 60, 2)} ${props.t('main:minuteAbbr')}`,
hours: (value) => `${roundToDecimals(value / 3600, 2)} ${props.t('main:hourAbbr')}`
};

const formattedValue = destinationUnit ? unitFormatters[destinationUnit](valueInSeconds) : '';

const cycleThroughDestinationUnits = () => {
// Infer the next unit based on the currently displayed unit.
setDestinationUnit((prevUnit) => {
return destinationUnitOptions[
(destinationUnitOptions.indexOf(prevUnit as destinationUnitOptionsType) + 1) %
destinationUnitOptions.length
];
});
};

return <span onClick={cycleThroughDestinationUnits}>{formattedValue}</span>;
};

export default withTranslation([])(DurationUnitFormatter);
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { withTranslation, WithTranslation } from 'react-i18next';
import TransitRoutingStepWalkButton from './TransitRoutingStepWalkButton';
import TransitRoutingStepRideButton from './TransitRoutingStepRideButton';
import RouteButton from './RouteButton';
import DistanceUnitFormatter from 'chaire-lib-frontend/lib/components/pageParts/DistanceUnitFormatter';
import DurationUnitFormatter from 'chaire-lib-frontend/lib/components/pageParts/DurationUnitFormatter';
import { secondsToMinutes, secondsSinceMidnightToTimeStr } from 'chaire-lib-common/lib/utils/DateTimeUtils';
import { _isBlank } from 'chaire-lib-common/lib/utils/LodashExtensions';
import { TrRoutingV2 } from 'chaire-lib-common/lib/api/TrRouting';
Expand Down Expand Up @@ -57,13 +59,14 @@ const TransitRoutingResults: React.FunctionComponent<TransitRoutingResultsProps>
<tr>
<th>{props.t('transit:transitRouting:results:TravelTime')}</th>
<td title={`${pathToDisplay.duration} ${props.t('main:secondAbbr')}.`}>
{secondsToMinutes(pathToDisplay.duration, Math.round)}{' '}
{props.t('main:minuteAbbr')}.
<DurationUnitFormatter value={pathToDisplay.duration} sourceUnit="seconds" />
</td>
</tr>
<tr>
<th>{props.t('transit:transitRouting:results:Distance')}</th>
<td>{Math.round(pathToDisplay.distance)} m</td>
<td>
<DistanceUnitFormatter value={pathToDisplay.distance} sourceUnit="meters" />
</td>
</tr>
</tbody>
</table>
Expand Down

0 comments on commit 90d99ee

Please sign in to comment.