Skip to content

Commit

Permalink
Add [Distance, Duration, Speed]UnitFormatter react components to auto…
Browse files Browse the repository at this point in the history
…matically 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, and route statistics for speed.

Fixes #775
  • Loading branch information
davidmurray committed Nov 22, 2023
1 parent a919bd8 commit 252a55c
Show file tree
Hide file tree
Showing 9 changed files with 268 additions and 10 deletions.
13 changes: 11 additions & 2 deletions locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,19 @@
"DataSource": "Data source",
"DataSources": "Data sources",
"Highlights": "Highlights",
"minuteAbbr": "min.",
"hourAbbr": "hr",
"minuteAbbr": "min",
"secondAbbr": "sec",
"meterAbbr": "m",
"kilometerAbbr": "km",
"mileAbbr": "mi",
"feetAbbr": "ft",
"mpsAbbr": "m/sec",
"kphAbbr": "kph",
"mphAbbr": "mph",
"ftpsAbbr": "ft/s",
"copy": "copy",
"passengerAbbr": "pass.",
"secondAbbr": "sec",
"Max": "Max",
"Min": "Min",
"Left": "Left",
Expand Down
11 changes: 10 additions & 1 deletion locales/fr/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,19 @@
"DataSource": "Source de données",
"DataSources": "Sources de données",
"Highlights": "Faits saillants",
"hourAbbr": "h",
"secondAbbr": "s",
"minuteAbbr": "min",
"meterAbbr": "m",
"kilometerAbbr": "km",
"mileAbbr": "mi",
"feetAbbr": "pi",
"mpsAbbr": "m/s",
"kphAbbr": "km/h",
"mphAbbr": "mi/h",
"ftpsAbbr": "pi/s",
"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
21 changes: 21 additions & 0 deletions packages/chaire-lib-common/src/utils/PhysicsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ const mpsToKph = function (mps: number): number {
return mps * 3.6;
};

// M/s to ft/s
const mpsToFtps = function (mps: number): number {
return mps * 3.281;
};

const ftpsToMps = function (ftps: number): number {
return ftps / 3.281;
};

const kphToMph = function (kph: number): number {
return kph / 1.60934;
};
Expand All @@ -36,6 +45,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 @@ -89,12 +106,16 @@ const durationFromAccelerationDecelerationDistanceAndRunningSpeed = function (
export {
kphToMps,
mpsToKph,
mpsToFtps,
ftpsToMps,
kphToMph,
mphToKph,
mpsToMph,
mphToMps,
metersToMiles,
milesToMeters,
metersToFeet,
feetToMeters,
kmToMiles,
milesToKm,
sqFeetToSqMeters,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* 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 = ['km', 'm', 'mi', 'ft'] as const;
type destinationUnitOptionsType = typeof destinationUnitOptions[number];

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

const DistanceUnitFormatter: React.FunctionComponent<DistanceUnitFormatterProps> = (
props: DistanceUnitFormatterProps
) => {

const [destinationUnit, setDestinationUnit] = useState<destinationUnitOptionsType | undefined>(props.destinationUnit);
const valueInMeters = props.sourceUnit === 'm' ? props.value : props.value / 1000;

useEffect(() => {
// 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('m');
} else {
setDestinationUnit('km');
}
}
}, [valueInMeters]);

const unitFormatters: Record<destinationUnitOptionsType, (value: number) => string> = {
m: (value) => `${roundToDecimals(value, 0)} ${props.t('main:meterAbbr')}`,
km: (value) => `${roundToDecimals(value / 1000, 2)} ${props.t('main:kilometerAbbr')}`,
mi: (value) => `${roundToDecimals(metersToMiles(value), 2)} ${props.t('main:mileAbbr')}`,
ft: (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,66 @@
/*
* 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', 's', 'm', 'h'] as const;
type destinationUnitOptionsType = typeof destinationUnitOptions[number];

export interface DurationUnitFormatterProps extends WithTranslation {
value: number;
sourceUnit: 's' | 'm' | 'h';
destinationUnit?: destinationUnitOptionsType;
}

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

const valueInSeconds =
props.sourceUnit === 's'
? props.value
: props.sourceUnit === 'm'
? props.value * 60
: props.sourceUnit === 'h'
? props.value * 60 * 60
: props.value;

useEffect(() => {
// 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')}`,
s: (value) => `${roundToDecimals(value, 2)} ${props.t('main:secondAbbr')}`,
m: (value) => `${roundToDecimals(value / 60, 2)} ${props.t('main:minuteAbbr')}`,
h: (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
@@ -0,0 +1,57 @@
/*
* 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 { mpsToKph, mpsToMph, mpsToFtps } from 'chaire-lib-common/lib/utils/PhysicsUtils';

const destinationUnitOptions = ['km/h', 'm/s', 'mph', 'ft/s'] as const;
type destinationUnitOptionsType = typeof destinationUnitOptions[number];

export interface SpeedUnitFormatterProps extends WithTranslation {
value: number;
sourceUnit: 'm/s' | 'km/h';
destinationUnit?: destinationUnitOptionsType;
}

const SpeedUnitFormatter: React.FunctionComponent<SpeedUnitFormatterProps> = (
props: SpeedUnitFormatterProps
) => {
const [destinationUnit, setDestinationUnit] = useState<destinationUnitOptionsType | undefined>(props.destinationUnit);

const valueInMetersPerSecond = props.sourceUnit === 'm/s' ? props.value : mpsToKph(props.value);

useEffect(() => {
// If the destination unit was not specified, we choose a default one.
if (destinationUnit === undefined) {
setDestinationUnit('km/h');
}
}, [valueInMetersPerSecond]);

const unitFormatters: Record<destinationUnitOptionsType, (value: number) => string> = {
'm/s': (value) => `${roundToDecimals(value, 0)} ${props.t('main:mpsAbbr')}`,
'km/h': (value) => `${roundToDecimals(mpsToKph(value), 1)} ${props.t('main:kphAbbr')}`,
'mph': (value) => `${roundToDecimals(mpsToMph(value), 1)} ${props.t('main:mphAbbr')}`,
'ft/s': (value) => `${roundToDecimals(mpsToFtps(value), 0)} ${props.t('main:ftpsAbbr')}`
};

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

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([])(SpeedUnitFormatter);
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import MathJax from 'react-mathjax';

import { _isBlank } from 'chaire-lib-common/lib/utils/LodashExtensions';
import { roundToDecimals } from 'chaire-lib-common/lib/utils/MathUtils';
import SpeedUnitFormatter from 'chaire-lib-frontend/lib/components/pageParts/SpeedUnitFormatter';
import pathStatsFormula from 'transition-common/lib/config/path/pathStats';
import Path from 'transition-common/lib/services/path/Path';
import { NodeAttributes } from 'transition-common/lib/services/nodes/Node';
Expand Down Expand Up @@ -42,7 +43,7 @@ const StatsRowBase: React.FunctionComponent<StatsRowProps> = (props: StatsRowPro
};
const StatsRow = withTranslation(['transit'])(StatsRowBase);

const SimpleRow: React.FunctionComponent<{ header: string; value?: string | number; isHeader?: boolean }> = ({
const SimpleRow: React.FunctionComponent<{ header: string; value?: React.ReactNode; isHeader?: boolean }> = ({
header,
value = '',
isHeader = false
Expand Down Expand Up @@ -114,9 +115,7 @@ const TransitPathStatistics: React.FunctionComponent<PathStatsProps> = (props: P
<SimpleRow header={props.t('transit:transitPath:Speeds')} isHeader={true} />
<SimpleRow
header={props.t('transit:transitPath:ExcludingDwellTimes')}
value={`${
Math.round((pathData.averageSpeedWithoutDwellTimesMetersPerSecond || 0) * 3.6 * 100) / 100
} km/h`}
value={<SpeedUnitFormatter value={pathData.averageSpeedWithoutDwellTimesMetersPerSecond || 0} sourceUnit='m/s' destinationUnit='km/h'/>}
/>
<SimpleRow
header={props.t('transit:transitPath:OperatingSpeed')}
Expand Down
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="s" />
</td>
</tr>
<tr>
<th>{props.t('transit:transitRouting:results:Distance')}</th>
<td>{Math.round(pathToDisplay.distance)} m</td>
<td>
<DistanceUnitFormatter value={pathToDisplay.distance} sourceUnit="m" />
</td>
</tr>
</tbody>
</table>
Expand Down

0 comments on commit 252a55c

Please sign in to comment.