Files
enduro-trails/src/web/units.js
claude-bot 2fe5cfe453
All checks were successful
CI / lint (push) Successful in 4s
CI / test (push) Successful in 5s
CI / build (push) Successful in 2s
CI / lint (pull_request) Successful in 4s
CI / test (pull_request) Successful in 4s
CI / build (pull_request) Successful in 1s
feat(web): переключатель единиц измерения расстояний (км/мили)
Добавляет сегментированный toggle км/мили в попап рельефа. Новый модуль
src/web/units.js — единственный источник истины по выбору единицы, её
персистентности (localStorage: distance_unit, дефолт km) и форматированию
отображаемых расстояний (Units.formatDistance).

Все места форматирования в app.js переведены на централизованный
форматтер; пересчёт всех видимых расстояний выполняет единый оркестратор
onUnitChange по событию unitchange (карточки маршрутов, лист точек,
линейка, масштабная линейка, связка, «красивый» маршрут).

Экспорт GPX и параметры построения маршрута остаются метрическими
(риск R6). units.js подключается строго перед app.js (риск R7).

Refs: ET-005
2026-05-21 19:36:13 +00:00

191 lines
8.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* units.js — ET-005: централизованный модуль единиц измерения расстояний.
*
* Единственный источник истины по выбору единицы (км/мили), её
* персистентности и форматированию ОТОБРАЖАЕМЫХ расстояний.
*
* Подключается в index.html как классический скрипт СТРОГО перед app.js
* (ADR-0001 п.2, риск R7 в 10-tech-risks.md) и публикует глобальный
* неймспейс `window.Units`. Для unit-тестов модуль дополнительно
* экспортируется через `module.exports` (среда Node).
*
* Каноническая единица — МЕТРЫ: `formatDistance()` всегда принимает
* расстояние в метрах, конвертация в мили выполняется только в момент
* форматирования строки (ADR-0001 п.4, 08-data-requirements.md §3).
* Хранить или пересчитывать значения в милях во внутреннем состоянии
* запрещено — это исключает накопление ошибок округления при
* многократном переключении (ФТ-3 ТЗ, AC-2, тест TP-04).
*
* См. docs/work-items/ET-005/06-adr/adr-0001-unit-toggle-client-side.md
*/
(function (global) {
'use strict';
/** Ключ localStorage для сохранённой единицы (02-trz.md, 08-data-requirements.md §4). */
var STORAGE_KEY = 'distance_unit';
/** Коэффициент перевода: 1 км = 0.621371 мили (AC-2). Единственное место объявления. */
var KM_TO_MI = 0.621371;
/** Единица по умолчанию при отсутствии/некорректности сохранённого значения (AC-3). */
var DEFAULT_UNIT = 'km';
/** Допустимые значения единицы измерения. */
var VALID_UNITS = ['km', 'mi'];
/** Разделитель дробной части — запятая (R4: единый разделитель, ru-локаль интерфейса). */
var DECIMAL_SEP = ',';
/** Подписи единиц в UI. */
var UNIT_LABEL = { km: 'км', mi: 'ми' };
/** Подпись суб-километровых значений в режиме «км». */
var SUBKM_LABEL = 'м';
/** Рантайм-кэш текущей единицы; null — ещё не прочитана из localStorage. */
var current = null;
/**
* Безопасное чтение localStorage: возвращает null, если хранилище
* недоступно (private mode, отключённые cookies и т.п.).
* @returns {?string}
*/
function readStored() {
try {
return window.localStorage.getItem(STORAGE_KEY);
} catch (e) {
return null;
}
}
/**
* Безопасная запись в localStorage. Недоступность хранилища не
* считается ошибкой — выбор просто не персистится между сессиями.
* @param {string} value
*/
function writeStored(value) {
try {
window.localStorage.setItem(STORAGE_KEY, value);
} catch (e) {
/* localStorage недоступен — намеренно проглатываем */
}
}
/**
* Проверяет, что переданное значение — допустимая единица измерения.
* @param {*} unit
* @returns {boolean}
*/
function isValidUnit(unit) {
return VALID_UNITS.indexOf(unit) !== -1;
}
/**
* Возвращает текущую выбранную единицу измерения.
*
* При первом обращении значение читается из localStorage; отсутствующее
* или некорректное значение даёт дефолт `'km'` (AC-3, TP-01).
* @returns {('km'|'mi')}
*/
function getUnit() {
if (current === null) {
var stored = readStored();
current = isValidUnit(stored) ? stored : DEFAULT_UNIT;
}
return current;
}
/**
* Устанавливает единицу измерения.
*
* Валидирует значение, сохраняет его в localStorage и диспатчит
* событие `unitchange` на `document` (ADR-0001 п.3, п.6). Некорректное
* значение игнорируется. Если единица фактически не изменилась — ни
* записи, ни события не происходит.
* @param {('km'|'mi')} unit
* @returns {('km'|'mi')} актуальная единица после вызова
*/
function setUnit(unit) {
if (!isValidUnit(unit)) return getUnit();
if (getUnit() === unit) return current;
current = unit;
writeStored(unit);
try {
document.dispatchEvent(new CustomEvent('unitchange', { detail: { unit: unit } }));
} catch (e) {
/* document/CustomEvent недоступны (headless-окружение) */
}
return current;
}
/**
* Переключает единицу измерения km ⇄ mi.
* @returns {('km'|'mi')} единица после переключения
*/
function toggleUnit() {
return setUnit(getUnit() === 'km' ? 'mi' : 'km');
}
/**
* Форматирует число с фиксированной точностью и единым разделителем
* дробной части (R4).
* @param {number} value
* @param {number} precision - знаков после запятой
* @returns {string}
*/
function formatNumber(value, precision) {
var s = value.toFixed(precision);
return DECIMAL_SEP === '.' ? s : s.replace('.', DECIMAL_SEP);
}
/**
* Форматирует расстояние, заданное в МЕТРАХ, строкой в текущей единице.
*
* Политика суб-километровых расстояний (R2 в 10-tech-risks.md):
* - режим «km»: значения < 1000 м показываются в метрах ('850 м');
* - режим «mi»: всё показывается в милях; для значений < 1000 м
* точность повышается минимум до 2 знаков, чтобы короткие отрезки
* не схлопывались в «0 ми». Футы намеренно не вводятся.
*
* @param {number} meters - расстояние в метрах (каноническая единица).
* @param {object} [opts]
* @param {number} [opts.precision=1] - знаков после запятой для значения
* в км/милях. По умолчанию 1 (AC-2). Карточки маршрутов
* запрашивают `precision: 0` ради целочисленного вида (R5).
* @returns {string} например '12,3 км', '7,6 ми', '850 м', '0,53 ми'.
*/
function formatDistance(meters, opts) {
if (meters === null || meters === undefined || isNaN(meters)) return '-';
var precision = (opts && typeof opts.precision === 'number') ? opts.precision : 1;
if (getUnit() === 'mi') {
var miles = (meters / 1000) * KM_TO_MI;
// R2: суб-километровые расстояния в милях — с повышенной точностью.
var miPrecision = (meters < 1000) ? Math.max(precision, 2) : precision;
return formatNumber(miles, miPrecision) + ' ' + UNIT_LABEL.mi;
}
// Режим «км».
if (meters < 1000) {
return Math.round(meters) + ' ' + SUBKM_LABEL;
}
return formatNumber(meters / 1000, precision) + ' ' + UNIT_LABEL.km;
}
/** Публичный контракт модуля (ADR-0001 п.3). */
var Units = {
STORAGE_KEY: STORAGE_KEY,
KM_TO_MI: KM_TO_MI,
getUnit: getUnit,
setUnit: setUnit,
toggleUnit: toggleUnit,
formatDistance: formatDistance,
};
// Браузер — глобальный неймспейс window.Units (соответствует стилю app.js).
if (global) {
global.Units = Units;
}
// Node — экспорт для изолированных unit-тестов.
if (typeof module === 'object' && module.exports) {
module.exports = Units;
}
})(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : null));