Format every metre/kilometre distance through one shared helper

Directions, the Contacts marker and panel, placed annotation labels, the
drawn-line measure, the Cockpit and formatRouteDistance each chose the unit
and precision on their own, and most chose before rounding: #1450, #1433 and
formatDistanceText or kilometreDigits from src/data/distanceFormat.js, which
cut after rounding. Each keeps its own metre step and rendering.

That also fixes the 10 km cut the Contacts marker and panel, the drawn-line
measure and the Cockpit still had (9,960 m read 10.0 km). The helper returns
kilometres already rounded, so the plain marker and the localized panel no
longer disagree on half-tenths such as 1,450 m.

A negative annotation distance now leaves the label without a distance, and
a drawn line with a coordinate that is not a number shows no measure, rather
than printing 0 m or NaN km. The HUD altitude tag and the spoken view height
keep their own unit choice.
This commit is contained in:
Sameh Khamis committed 2026-11-01 22:40:28 -08:00
1 parent 2124f56f92
commit d14205160a
18 files changed
+169 -46

No files matched your search

+2 -6
View File
@@ -12,6 +12,7 @@ import {
sampleGroundHeight,
} from './annotationResolver.js';
import { ringCentroid } from './drawMode.js';
import { formatDistanceText } from '../data/distanceFormat.js';
// Dev convenience: expose the app's Cesium instance for console/preview probing
// (single shared module instance — avoids dual-Cesium state bugs when testing).
@@ -1572,12 +1573,7 @@ function greatCircleM(a, b) {
}
function formatDistance(m) {
if (!Number.isFinite(m)) return null;
// Round before each unit cut: 996 m was printed as "1000 m" and
// 9,960 m as "10.0 km".
const tens = Math.round(m / 10) * 10;
if (tens < 1000) return `${tens} m`;
return `${(m / 1000).toFixed(Math.round(m / 100) >= 100 ? 0 : 1)} km`;
return formatDistanceText(m, { metreStep: 10 });
}
function composeRouteLabel(baseLabel, distM, durS, mode, fallback) {
+2 -5
View File
@@ -21,6 +21,7 @@ import { t, tn } from '../i18n/index.js';
*/
import { greatCircleMeters } from '../geoDistance.js';
import { formatDistanceText } from '../data/distanceFormat.js';
export const DRAW_SHAPES = Object.freeze(['area', 'line', 'pin']);
export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 });
@@ -207,11 +208,7 @@ export function ringCentroid(vertices) {
export function formatMeasure(session) {
if (!session) return '';
if (session.shape === 'line') {
const m = pathLengthM(session.vertices);
// Round before the kilometre cut. 999.6 m was printed as "1000 m".
const whole = Math.round(m);
if (whole < 1000) return `${whole} m`;
return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`;
return formatDistanceText(pathLengthM(session.vertices)) ?? '';
}
if (session.shape === 'area') {
const m2 = ringAreaM2(session.vertices);
+8
View File
@@ -346,6 +346,14 @@ test('a line that rounds up to a kilometre reads 1.0 km, not 1000 m', () => {
assert.match(drawHint(line), /^1\.0 km · /);
});
test('a line that rounds to 10.0 km reads in whole kilometres', () => {
const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180;
const line = createDrawSession('line');
addVertex(line, { lon: 0, lat: 0 });
addVertex(line, { lon: 0, lat: 9960 / metresPerDegree });
assert.equal(formatMeasure(line), '10 km');
});
test('an area that rounds up to the next unit reads in that unit', () => {
// A square on the equator whose shoelace area is about `m2`.
const square = (m2) => {
+43
View File
@@ -0,0 +1,43 @@
/**
* Kilometre decimals for a distance: one below 10 km, none from there on.
* The cut is taken on the tenth that prints, so 9.96 km reads 10 km, not 10.0.
* @param {number} meters
* @returns {0|1}
*/
export function kilometreDigits(meters) {
return Math.round(meters / 100) < 100 ? 1 : 0;
}
/**
* The unit and precision a distance prints with: metres below 1 km, then
* kilometres with `kilometreDigits`. Metres round to `metreStep` (10 for tens),
* except below `exactBelowM`, where they stay whole. Each cut is taken after
* rounding, so 999.6 m reads 1.0 km rather than 1000 m.
* @param {number} meters
* @param {{metreStep?: number, exactBelowM?: number}} [options]
* @returns {{value: number, unit: 'm'|'km', digits: 0|1}|null} null for a
* distance that is negative or not finite. The value is already rounded to
* `digits`, so toFixed and Intl render it the same way.
*/
export function distanceParts(meters, { metreStep = 1, exactBelowM = 0 } = {}) {
if (!Number.isFinite(meters) || meters < 0) return null;
const step = meters < exactBelowM ? 1 : metreStep;
const shownMeters = Math.round(meters / step) * step;
if (shownMeters < 1000) return { value: shownMeters, unit: 'm', digits: 0 };
const digits = kilometreDigits(meters);
const value =
digits === 1 ? Math.round(meters / 100) / 10 : Math.round(meters / 1000);
return { value, unit: 'km', digits };
}
/**
* Plain `850 m` / `4.6 km` / `12 km` text for labels that are not localized.
* @param {number} meters
* @param {{metreStep?: number, exactBelowM?: number}} [options]
* @returns {string|null}
*/
export function formatDistanceText(meters, options) {
const parts = distanceParts(meters, options);
if (!parts) return null;
return `${parts.value.toFixed(parts.digits)} ${parts.unit}`;
}
+50
View File
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
distanceParts,
formatDistanceText,
kilometreDigits,
} from './distanceFormat.js';
test('a distance switches to kilometres where its metres round to 1,000', () => {
assert.equal(formatDistanceText(999.4), '999 m');
assert.equal(formatDistanceText(999.5), '1.0 km');
assert.equal(formatDistanceText(999.6), '1.0 km');
assert.equal(formatDistanceText(994, { metreStep: 10 }), '990 m');
assert.equal(formatDistanceText(995, { metreStep: 10 }), '1.0 km');
});
test('kilometres drop their decimal where the tenth rounds to 10.0', () => {
assert.equal(kilometreDigits(9949), 1);
assert.equal(kilometreDigits(9950), 0);
assert.equal(formatDistanceText(9949), '9.9 km');
assert.equal(formatDistanceText(9950), '10 km');
assert.equal(formatDistanceText(9960), '10 km');
assert.equal(formatDistanceText(99950), '100 km');
});
test('metres below exactBelowM stay whole while the rest round to the step', () => {
const options = { metreStep: 10, exactBelowM: 100 };
assert.equal(formatDistanceText(8, options), '8 m');
assert.equal(formatDistanceText(99.4, options), '99 m');
assert.equal(formatDistanceText(846, options), '850 m');
assert.deepEqual(distanceParts(846, options), {
value: 850,
unit: 'm',
digits: 0,
});
});
test('a negative or non-finite distance has no parts', () => {
for (const value of [-1, Number.NaN, Infinity, -Infinity, null, undefined]) {
assert.equal(distanceParts(value), null);
assert.equal(formatDistanceText(value), null);
}
assert.equal(formatDistanceText(0), '0 m');
});
test('kilometres come back rounded, so toFixed and Intl agree', () => {
assert.deepEqual(distanceParts(1450), { value: 1.5, unit: 'km', digits: 1 });
assert.equal(formatDistanceText(1450), '1.5 km');
assert.equal(formatDistanceText(1150), '1.2 km');
});
+3
View File
@@ -79,6 +79,9 @@ test('Contacts moves a distance that rounds to 1,000 m into kilometres', () => {
assert.equal(presentAwarenessDistance(999.4), '999\u00a0m');
assert.equal(presentAwarenessDistance(999.6), '1.0\u00a0km');
assert.equal(presentAwarenessDistance(4600), '4.6\u00a0km');
assert.equal(presentAwarenessDistance(9949), '9.9\u00a0km');
assert.equal(presentAwarenessDistance(9950), '10\u00a0km');
assert.equal(presentAwarenessDistance(9960), '10\u00a0km');
});
function installAwarenessRuntime({
+2 -6
View File
@@ -2,6 +2,7 @@
import { msg, t } from '../i18n/index.js';
import { UNNAMED_PLACE_NAMES } from './installationNames.js';
import { formatDistanceText } from './distanceFormat.js';
const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES);
@@ -46,12 +47,7 @@ export function findByDoublingRadius(
/** @param {number} meters @returns {string} */
export function formatAwarenessDistance(meters) {
if (!Number.isFinite(meters) || meters < 0) return '—';
// Round before the kilometre cut. 999.6 m was printed as "1000 m".
const wholeMeters = Math.round(meters);
return wholeMeters < 1000
? `${wholeMeters} m`
: `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`;
return formatDistanceText(meters) ?? '—';
}
/**
@@ -29,6 +29,9 @@ test('a contact marker moves a distance that rounds to 1,000 m into kilometres',
assert.equal(formatAwarenessDistance(999.4), '999 m');
assert.equal(formatAwarenessDistance(999.6), '1.0 km');
assert.equal(formatAwarenessDistance(4600), '4.6 km');
assert.equal(formatAwarenessDistance(9949), '9.9 km');
assert.equal(formatAwarenessDistance(9950), '10 km');
assert.equal(formatAwarenessDistance(9960), '10 km');
});
test('awareness cohorts sort nearest-first and do not turn empty data into out of range', () => {
+3 -5
View File
@@ -13,6 +13,8 @@
* roundabout turn, exit roundabout, exit rotary, notification, use lane.
*/
import { formatDistanceText } from './distanceFormat.js';
/** Cap on steps returned for one route — a 2,500 km drive rarely needs more. */
export const ROUTE_STEPS_MAX = 200;
@@ -216,11 +218,7 @@ export function normalizeOsrmSteps(route) {
* @returns {string}
*/
export function formatRouteDistance(meters) {
if (!Number.isFinite(meters) || meters < 0) return '';
if (meters < 100) return `${Math.round(meters)} m`;
if (meters < 1000) return `${Math.round(meters / 10) * 10} m`;
const km = meters / 1000;
return `${km < 10 ? km.toFixed(1) : Math.round(km)} km`;
return formatDistanceText(meters, { metreStep: 10, exactBelowM: 100 }) ?? '';
}
/**
+2
View File
@@ -236,6 +236,8 @@ test('distance and duration formatting', () => {
assert.equal(formatRouteDistance(8), '8 m');
assert.equal(formatRouteDistance(846), '850 m');
assert.equal(formatRouteDistance(1234), '1.2 km');
assert.equal(formatRouteDistance(996), '1.0 km');
assert.equal(formatRouteDistance(9960), '10 km');
assert.equal(formatRouteDistance(21479), '21 km');
assert.equal(formatRouteDistance(-1), '');
assert.equal(formatRouteDuration(40), '40 s');
+6 -7
View File
@@ -10,6 +10,7 @@ import {
} from '../../i18n/index.js';
import { formatQuantity } from '../../ui/unitLabels.js';
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
import { distanceParts } from '../../data/distanceFormat.js';
/** Format a cohort total without presenting a retained cap as exact. */
export function formatAwarenessCount(summary = {}) {
@@ -31,13 +32,11 @@ function presentAwarenessCount(summary = {}) {
/** A distance as the panel shows it: 850 m, 4.6 km, 120 km, in the UI language. */
export function presentAwarenessDistance(meters) {
if (!Number.isFinite(meters) || meters < 0) return '—';
const wholeMeters = Math.round(meters);
if (wholeMeters < 1000) return formatQuantity(wholeMeters, 'm');
const digits = meters < 10000 ? 1 : 0;
return formatQuantity(meters / 1000, 'km', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
const parts = distanceParts(meters);
if (!parts) return '—';
return formatQuantity(parts.value, parts.unit, {
minimumFractionDigits: parts.digits,
maximumFractionDigits: parts.digits,
});
}
+7 -12
View File
@@ -31,6 +31,7 @@ import {
quantitySeparator,
unitLabel,
} from '../../ui/unitLabels.js';
import { distanceParts } from '../../data/distanceFormat.js';
export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step';
export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
@@ -108,18 +109,12 @@ export const FLIGHT_PROGRESS_MS = 250;
* @returns {string}
*/
export function routeDistanceText(meters) {
if (!Number.isFinite(meters) || meters < 0) return '';
if (meters < 100) return formatQuantity(Math.round(meters), 'm');
const tens = Math.round(meters / 10) * 10;
if (tens < 1000) return formatQuantity(tens, 'm');
const km = meters / 1000;
// Pick the unit from the tenth it prints: 9.96 km read "10.0 km".
return Math.round(meters / 100) < 100
? formatQuantity(km, 'km', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})
: formatQuantity(Math.round(km), 'km');
const parts = distanceParts(meters, { metreStep: 10, exactBelowM: 100 });
if (!parts) return '';
return formatQuantity(parts.value, parts.unit, {
minimumFractionDigits: parts.digits,
maximumFractionDigits: parts.digits,
});
}
/**
+2 -1
View File
@@ -5,6 +5,7 @@ import {
normalizeHeading,
roundHeadingDegrees,
} from '../data/headingDegrees.js';
import { kilometreDigits } from '../data/distanceFormat.js';
export const COCKPIT_HEADING_SLEW_DPS = 28;
@@ -120,7 +121,7 @@ export function formatCockpitCoordinate(value, positive, negative) {
* (`4,2 КМ`): one decimal under 10 km, whole kilometres beyond.
*/
export function formatCockpitDistance(distanceM) {
const digits = distanceM < 10000 ? 1 : 0;
const digits = kilometreDigits(distanceM);
const distance = formatQuantity(distanceM / 1000, 'km', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
+9 -1
View File
@@ -1,6 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { formatCockpitWindDirection } from './cockpitPresentation.js';
import {
formatCockpitDistance,
formatCockpitWindDirection,
} from './cockpitPresentation.js';
test('wind just west of north reads 0°, not 360°', () => {
assert.equal(formatCockpitWindDirection(90), 'E · 90°');
@@ -8,3 +11,8 @@ test('wind just west of north reads 0°, not 360°', () => {
assert.equal(formatCockpitWindDirection(359.4), 'N · 359°');
assert.equal(formatCockpitWindDirection(359.6), 'N · 0°');
});
test('a cockpit distance that rounds to 10.0 km drops the decimal', () => {
assert.match(formatCockpitDistance(9949), /^9\.9\s?KM$/);
assert.match(formatCockpitDistance(9960), /^10\s?KM$/);
});