---
type: adr
work_item_id: ET-005
adr_id: adr-0001
title: "ADR-0001: Переключение единиц измерения (км/мили) — клиентское решение с централизованным форматтером"
status: accepted
created_at: 2026-05-21
authors:
  - "agent:architect"
supersedes: []
superseded_by: []
labels: []
---

# ADR-0001 — Переключение единиц измерения (км/мили): клиентское решение с централизованным форматтером

## Статус

Accepted

## Контекст

ET-005 добавляет переключатель единиц измерения расстояний (км/мили) в
панель настроек карты. Выбор сохраняется в `localStorage`, по умолчанию —
километры, при переключении все видимые расстояния пересчитываются за
< 100 мс (см. `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`).

Существующее состояние кодовой базы (`src/web/`):

- Фронтенд **плоский, без сборщика и без модульной системы**:
  `index.html`, `app.js`, `app.css`, `style.json`, `style-dark.json`.
  `app.js` (≈ 3036 строк) подключается как **классический скрипт**
  (`<script src="app.js"></script>`, `index.html:405`), а не ES-модуль.
  Каталога `src/web/static/js/` в репозитории **нет**.
- Форматирование расстояний **не централизовано** — захардкоженный
  паттерн `(m / 1000).toFixed(N) + ' км'` встречается минимум в **13
  местах** `app.js`: `formatDist()` (стр. 187–189), карточки сегментов
  (638), карточки маршрута (1157, 1191, 2202, 2212, 2357, 2370, 2605),
  scale-bar (1416–1440), всплывающие подсказки (1478), линейка/ruler
  (1837, 1875, 1885, 1931). Единого форматтера нет.
- Внутренняя каноническая единица расстояния — **метры**
  (`route.distance_m`), для линейки — километры (`rulerTotal`).
- Сложился устойчивый паттерн персистентности UI-настроек в
  `localStorage`: ключи `enduro-theme-mode`, `terrain-hillshade`,
  `terrain-tri`, `trails-track`, `trails-path`, `poi-visible` (ET-002),
  `MARKERS_KEY`.
- «Панель настроек карты» из BRD/ТЗ де-факто реализована как попап
  `#terrain-popup` (заголовок «Эндуро»), открываемый кнопкой «Рельеф».
  В ET-002 в этот же попап добавлен чекбокс POI. В `app.css` уже есть
  готовый компонент сегментированного переключателя `.seg-control` /
  `.seg-btn` (стр. 360–363).

Backend (FastAPI), БД (SQLite/Spatialite), тайл-сервер и OSRM к выбору
единиц измерения отношения **не имеют**: расстояния приходят с backend
в метрах, перевод — исключительно вопрос представления на клиенте.

## Рассматриваемые варианты

### Вариант A — Централизованный модуль-форматтер + единый оркестратор (выбран)

Новый модуль `src/web/units.js`, подключаемый как **классический скрипт
до `app.js`**, экспортирует глобальный неймспейс `window.Units` с
функциями состояния (`getUnit()` / `setUnit()`) и форматирования
(`formatDistance(meters, {precision})`). Все 13 мест форматирования в
`app.js` переводятся на вызов `Units.formatDistance(...)`. При смене
единицы `setUnit()` пишет в `localStorage` и диспатчит `unitchange` на
`document`; в `app.js` регистрируется **один** обработчик-оркестратор
`onUnitChange()`, который пере-вызывает существующие функции отрисовки
видимых поверхностей с расстояниями.

- **Плюсы:** единственный источник истины по форматированию убирает
  риск рассинхрона; нулевые изменения backend/БД/инфраструктуры;
  пересчёт мгновенный (нет ни сети, ни перезагрузки тайлов) — НФТ
  «< 100 мс» выполняется тривиально; согласуется с принципом «минимум
  зависимостей»; модуль покрывается unit-тестами изолированно.
- **Минусы:** требует рефакторинга 13 мест в `app.js` (риск пропустить
  вызов — вынесен в `10-tech-risks.md`, R1); глобальный неймспейс вместо
  ES-import — но это сознательное соответствие текущему стилю `app.js`.

### Вариант B — Рассыпанные подписчики `unitchange` по компонентам (буквальный тех-дизайн ТЗ)

Каждый компонент с расстоянием самостоятельно подписывается на
`unitchange` и сам себя перерисовывает.

- **Плюсы:** формально совпадает с разделом «Технический дизайн» ТЗ.
- **Минусы:** N подписок вместо одной — высокая связность, легко
  забыть подписку у нового компонента; дублирование логики перерисовки;
  не решает корневую проблему — захардкоженное `+ ' км'` остаётся
  размазанным. Отклонён: оркестрация в одном месте надёжнее.

### Вариант C — Вынести `units.js` в ES-модуль `src/web/static/js/units.js`

Буквально следовать пути из ТЗ: создать `src/web/static/js/units.js` как
ES-модуль.

- **Плюсы:** «правильная» модульность.
- **Минусы:** в проекте нет каталога `static/js/`, нет сборщика, а
  `app.js` подключён как классический скрипт. Полноценный `import`
  потребовал бы перевода `app.js` в `type="module"` и реструктуризации
  ≈ 3000 строк — несопоставимо со scope ET-005 и противоречит принципу
  «минимум зависимостей». Отклонён.

## Решение

Принимается **Вариант A**.

1. **Только клиент.** Backend, БД, API, тайл-сервер, OSRM, инфраструктура
   — без изменений. Расстояния продолжают приходить с backend в метрах.

2. **Новый модуль `src/web/units.js`** (НЕ `src/web/static/js/units.js`).
   Раздел «Технический дизайн» ТЗ носит рекомендательный характер;
   финальное размещение файлов — мандат архитектуры. Путь скорректирован
   под фактическую плоскую структуру `src/web/`. Модуль подключается в
   `index.html` как классический скрипт **строго перед `app.js`**:
   `<script src="units.js"></script>` затем `<script src="app.js">`.

3. **Глобальный API модуля** — неймспейс `window.Units` (соответствует
   стилю `app.js`, где функции глобальны):
   - `Units.getUnit()` → `'km' | 'mi'`;
   - `Units.setUnit(unit)` — валидирует, пишет `localStorage`, диспатчит
     `unitchange`;
   - `Units.formatDistance(meters, { precision })` — возвращает строку с
     корректной единицей в текущем режиме;
   - `Units.KM_TO_MI = 0.621371` — единственная константа коэффициента.

4. **Каноническая единица — метры.** Конвертация выполняется **только в
   момент форматирования**. Запрещено хранить или пересчитывать значения
   расстояний в милях во внутреннем состоянии (`route.distance_m`,
   `rulerTotal` и т.п. остаются метрическими) — это исключает накопление
   ошибок округления при многократном переключении (ФТ-3, AC-2/TP-04).

5. **Персистентность** — `localStorage`, ключ `distance_unit` (как в ТЗ),
   значения `'km'` / `'mi'`. При отсутствии или некорректном значении —
   дефолт `'km'` (AC-3, TP-01). Чтение — при инициализации модуля.

6. **Пересчёт — единый оркестратор.** `setUnit()` диспатчит `unitchange`
   на `document`. В `app.js` регистрируется ровно **один** обработчик
   `onUnitChange()`, пере-вызывающий существующие функции отрисовки
   видимых поверхностей с расстояниями (лист маршрута, список точек,
   карточки маршрутов, линейка, scale-bar). Рассыпать подписки по
   компонентам запрещено.

7. **Все 13 мест форматирования** `(m/1000).toFixed(N)+' км'` в `app.js`
   переводятся на `Units.formatDistance()`. Список call-sites и риск
   пропуска зафиксированы в `10-tech-risks.md` (R1). Форматтер принимает
   `precision`, чтобы сохранить текущую точность карточек (`toFixed(0)`)
   и при этом по умолчанию давать 1 знак (AC-2, R5).

8. **UI-элемент** — сегментированный переключатель `km | mi` на готовом
   компоненте `.seg-control` / `.seg-btn`, размещаемый в попапе
   `#terrain-popup` (та же «панель настроек», что и чекбоксы слоёв и
   POI-чекбокс из ET-002). Новый CSS-компонент не вводится.

9. **C4 без изменений.** Состав компонентов системы не меняется,
   обновление `docs/architecture/c4-*.mmd` не требуется (диаграммы C4 в
   репозитории отсутствуют — только `docs/architecture/README.md`).

## Последствия

### Положительные

- Изменение полностью клиентское: деплой без миграций и без изменения
  состава контейнеров (штатный релиз фронтенда).
- Пересчёт < 100 мс (НФТ ТЗ) — нет ни сетевых вызовов, ни перезагрузки
  тайлов.
- Появляется единственная точка форматирования расстояний — снижается
  будущий риск рассинхрона и упрощается поддержка (положительный
  побочный эффект рефакторинга 13 call-sites).
- Модуль `units.js` изолирован и тестируется юнит-тестами отдельно от
  `app.js`.

### Отрицательные / ограничения

- Настройка локальна для браузера: при очистке `localStorage` или смене
  устройства сбрасывается на дефолт `km`. Принято осознанно — вне scope
  BRD (синхронизация между устройствами не требуется).
- Требуется рефакторинг 13 мест в `app.js` — операционный риск пропуска
  call-site (митигация — `10-tech-risks.md`, R1; grep-аудит + e2e).
- Имперский эквивалент суб-километровых значений («м») и единиц
  scale-bar требует политики — см. `10-tech-risks.md` R2, R3.

### Технический долг

- `units.js` подключается как глобальный классический скрипт. Если в
  будущих фазах появится сборщик/модульность фронтенда, модуль легко
  переводится в ES-модуль без изменения публичного контракта. На ET-005
  перевод `app.js` в модули — вне scope.

## Классификация изменения

**Minor change.** Новые сервисы, БД, API-эндпоинты и контейнеры не
вводятся; состав компонентов C4 не меняется; backend и инфраструктура не
затрагиваются. Лейбл `arch:major-change` **не требуется**. Обязательного
архитектурного approve не требуется.

## Связанные документы

- `docs/work-items/ET-005/01-brd.md`
- `docs/work-items/ET-005/02-trz.md`
- `docs/work-items/ET-005/03-acceptance-criteria.md`
- `docs/work-items/ET-005/04-test-plan.yaml`
- `docs/work-items/ET-005/07-infra-requirements.md`
- `docs/work-items/ET-005/08-data-requirements.md`
- `docs/work-items/ET-005/10-tech-risks.md`
- `docs/architecture/README.md`
