feat(web): переключатель единиц измерения расстояний (км/мили)
All checks were successful
All checks were successful
Добавляет сегментированный 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
This commit is contained in:
190
src/web/units.js
Normal file
190
src/web/units.js
Normal file
@@ -0,0 +1,190 @@
|
||||
'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));
|
||||
Reference in New Issue
Block a user