From 90d99eed86151f071df16b6f2efeef545c8260de Mon Sep 17 00:00:00 2001 From: davidmurray Date: Wed, 22 Nov 2023 11:17:46 -0500 Subject: [PATCH] Add DistanceUnitFormatter and DurationUnitFormatter, two react components 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 --- locales/en/main.json | 9 ++- locales/fr/main.json | 7 +- .../src/utils/DateTimeUtils.ts | 33 +++++++++ .../src/utils/PhysicsUtils.ts | 10 +++ .../pageParts/DistanceUnitFormatter.tsx | 62 +++++++++++++++++ .../pageParts/DurationUnitFormatter.tsx | 67 +++++++++++++++++++ .../TransitRoutingResultComponent.tsx | 9 ++- 7 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 packages/chaire-lib-frontend/src/components/pageParts/DistanceUnitFormatter.tsx create mode 100644 packages/chaire-lib-frontend/src/components/pageParts/DurationUnitFormatter.tsx diff --git a/locales/en/main.json b/locales/en/main.json index e64a276a0..a372f5339 100644 --- a/locales/en/main.json +++ b/locales/en/main.json @@ -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", diff --git a/locales/fr/main.json b/locales/fr/main.json index 636d3ff74..293c84459 100644 --- a/locales/fr/main.json +++ b/locales/fr/main.json @@ -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", diff --git a/packages/chaire-lib-common/src/utils/DateTimeUtils.ts b/packages/chaire-lib-common/src/utils/DateTimeUtils.ts index fe318330d..5507cc8f8 100644 --- a/packages/chaire-lib-common/src/utils/DateTimeUtils.ts +++ b/packages/chaire-lib-common/src/utils/DateTimeUtils.ts @@ -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 @@ -168,6 +200,7 @@ export { minutesToHoursDecimal, minutesToSeconds, hoursToSeconds, + toXXhrYYminZZsec, timeStrToSecondsSinceMidnight, intTimeToSecondsSinceMidnight, roundSecondsToNearestMinute diff --git a/packages/chaire-lib-common/src/utils/PhysicsUtils.ts b/packages/chaire-lib-common/src/utils/PhysicsUtils.ts index 095255eeb..5174af350 100644 --- a/packages/chaire-lib-common/src/utils/PhysicsUtils.ts +++ b/packages/chaire-lib-common/src/utils/PhysicsUtils.ts @@ -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; }; @@ -95,6 +103,8 @@ export { mphToMps, metersToMiles, milesToMeters, + metersToFeet, + feetToMeters, kmToMiles, milesToKm, sqFeetToSqMeters, diff --git a/packages/chaire-lib-frontend/src/components/pageParts/DistanceUnitFormatter.tsx b/packages/chaire-lib-frontend/src/components/pageParts/DistanceUnitFormatter.tsx new file mode 100644 index 000000000..ccd1cd6b8 --- /dev/null +++ b/packages/chaire-lib-frontend/src/components/pageParts/DistanceUnitFormatter.tsx @@ -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 = ( + props: DistanceUnitFormatterProps +) => { + const [destinationUnit, setDestinationUnit] = useState(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 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 {formattedValue}; +}; + +export default withTranslation([])(DistanceUnitFormatter); diff --git a/packages/chaire-lib-frontend/src/components/pageParts/DurationUnitFormatter.tsx b/packages/chaire-lib-frontend/src/components/pageParts/DurationUnitFormatter.tsx new file mode 100644 index 000000000..a6699e5d3 --- /dev/null +++ b/packages/chaire-lib-frontend/src/components/pageParts/DurationUnitFormatter.tsx @@ -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 = ( + props: DurationUnitFormatterProps +) => { + const [destinationUnit, setDestinationUnit] = useState(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 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 {formattedValue}; +}; + +export default withTranslation([])(DurationUnitFormatter); diff --git a/packages/transition-frontend/src/components/forms/transitRouting/TransitRoutingResultComponent.tsx b/packages/transition-frontend/src/components/forms/transitRouting/TransitRoutingResultComponent.tsx index 85e43d3d3..78ec818ce 100644 --- a/packages/transition-frontend/src/components/forms/transitRouting/TransitRoutingResultComponent.tsx +++ b/packages/transition-frontend/src/components/forms/transitRouting/TransitRoutingResultComponent.tsx @@ -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'; @@ -57,13 +59,14 @@ const TransitRoutingResults: React.FunctionComponent {props.t('transit:transitRouting:results:TravelTime')} - {secondsToMinutes(pathToDisplay.duration, Math.round)}{' '} - {props.t('main:minuteAbbr')}. + {props.t('transit:transitRouting:results:Distance')} - {Math.round(pathToDisplay.distance)} m + + +