mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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:
1 parent
2124f56f92
commit
d14205160a
18 files changed
+169
-46
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 +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', () => {
|
||||
|
||||
@@ -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 }) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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$/);
|
||||
});
|
||||
Reference in new issue
Block a user