'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));