feat(web): переключатель единиц измерения расстояний (км/мили)
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

Добавляет сегментированный 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:
2026-05-21 19:36:13 +00:00
parent bafbea2dab
commit 2fe5cfe453
7 changed files with 774 additions and 24 deletions

View File

@@ -866,6 +866,26 @@ body.has-map-mode #sheet-backdrop.visible { pointer-events: none; }
cursor: not-allowed;
}
/* ── ET-005: переключатель единиц измерения (км/мили) в попапе рельефа ── */
.terrain-unit-row {
padding: 8px 4px 2px;
}
.terrain-unit-label {
display: block;
font-size: 15px;
line-height: 1.3;
color: var(--text, #fff);
margin-bottom: 8px;
}
.theme-light .terrain-unit-label {
color: var(--text, #111);
}
/* Сегментированный переключатель внутри попапа — без нижнего отступа,
он последний элемент (см. .seg-control в блоке Segment Control). */
.terrain-unit-row .seg-control {
margin-bottom: 0;
}
/* ── Scale + Zoom bar (one line, top-right) ───────── */
#scale-zoom-bar {
position: absolute;

View File

@@ -184,10 +184,10 @@ function formatDuration(seconds) {
return `${hours} ч ${mins} мин`;
}
// ET-005: форматирование расстояний централизовано в units.js (ADR-0001).
function formatDist(m) {
if (!m) return '-';
if (m >= 1000) return (m / 1000).toFixed(1) + ' км';
return Math.round(m) + ' м';
return Units.formatDistance(m);
}
// ─── Sheet Management ──────────────────────────────────────────────
@@ -633,9 +633,9 @@ function haversineM(a, b) {
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1-s));
}
// ET-005: единица и разделитель определяются units.js (ADR-0001, риск R4).
function formatSegmentDist(m) {
if (m < 1000) return Math.round(m) + ' м';
return (m / 1000).toFixed(1).replace('.', ',') + ' км';
return Units.formatDistance(m);
}
// Returns array of route-distance segments (meters) for each waypoint.
@@ -1154,7 +1154,6 @@ function renderRouteCards(routes) {
const container = document.getElementById('route-cards');
container.innerHTML = routes.map((route, i) => {
const color = ROUTE_COLORS[i] || '#888888';
const distKm = (route.distance_m / 1000).toFixed(1);
const timeStr = formatDuration(route.duration_s);
const isActive = i === activeRouteIdx;
const s = route.stats || {};
@@ -1165,7 +1164,7 @@ function renderRouteCards(routes) {
<div class="rc-header">
<span class="rc-dot" style="background:${color}"></span>
<span class="rc-title">Вариант ${i + 1}</span>
<span class="rc-meta">${distKm} км · ${timeStr}</span>
<span class="rc-meta">${Units.formatDistance(route.distance_m)} · ${timeStr}</span>
</div>
<div class="rc-bar-wrap">
<div class="rc-bar">
@@ -1188,6 +1187,8 @@ function generateGPX() {
if (!route) return '';
const now = new Date();
const dateStr = now.toISOString().slice(0, 10);
// ET-005: экспорт GPX остаётся метрическим — стандарт GPX и риск R6
// исключают конвертацию выгружаемых данных в мили.
const distKm = (route.distance_m / 1000).toFixed(1);
const dirtPct = route.stats ? route.stats.dirt_total_pct : '?';
@@ -1412,9 +1413,22 @@ async function initMap() {
const targetPx = 80;
const rawMeters = metersPerPixel * targetPx;
// ET-005 (риск R3): масштабная линейка учитывает выбранную единицу.
// niceMeters всегда остаётся в метрах — по нему считается ширина в px.
let distance, unit, niceMeters;
if (rawMeters >= 1000) {
if (window.Units && Units.getUnit() === 'mi') {
const rawMiles = (rawMeters / 1000) * Units.KM_TO_MI;
if (rawMiles >= 1) {
distance = rawMiles >= 100 ? Math.round(rawMiles / 50) * 50 :
rawMiles >= 10 ? Math.round(rawMiles / 5) * 5 :
Math.round(rawMiles);
} else {
distance = Math.max(0.1, Math.round(rawMiles * 10) / 10);
}
unit = 'mi';
niceMeters = (distance / Units.KM_TO_MI) * 1000;
} else if (rawMeters >= 1000) {
const km = rawMeters / 1000;
distance = km >= 100 ? Math.round(km / 50) * 50 :
km >= 10 ? Math.round(km / 5) * 5 :
@@ -1441,6 +1455,8 @@ async function initMap() {
zoomEl.textContent = 'z' + zoom;
}
// ET-005: оркестратор onUnitChange() обновляет линейку при смене единицы.
window._updateScaleZoom = updateScaleZoom;
updateScaleZoom();
map.on('zoom', updateScaleZoom);
map.on('move', updateScaleZoom);
@@ -1473,10 +1489,10 @@ async function initMap() {
maxWidth: '300px',
});
// ET-005: расстояние во всплывающих подсказках — через units.js (ADR-0001).
function formatLength(m) {
if (!m) return '-';
if (m >= 1000) return (m / 1000).toFixed(1) + ' км';
return Math.round(m) + ' м';
return Units.formatDistance(m);
}
function poiTypeLabel(t) {
@@ -1834,7 +1850,8 @@ function updateRulerLine() {
});
}
// Update ruler info display
const dist = rulerTotal >= 1 ? rulerTotal.toFixed(1) + ' км' : Math.round(rulerTotal * 1000) + ' м';
// ET-005: rulerTotal хранится в км — переводим в метры для units.js.
const dist = Units.formatDistance(rulerTotal * 1000);
document.getElementById('ruler-dist').textContent = dist;
}
@@ -1871,9 +1888,8 @@ function updateRulerLabels() {
const segDist = haversineKm(rulerPoints[i - 1], rulerPoints[i]);
rulerTotal += segDist;
if (labelText) {
labelText.textContent = segDist >= 1
? segDist.toFixed(1) + ' км'
: Math.round(segDist * 1000) + ' м';
// ET-005: segDist в км — переводим в метры для units.js.
labelText.textContent = Units.formatDistance(segDist * 1000);
}
}
// Update remove button index
@@ -1882,7 +1898,8 @@ function updateRulerLabels() {
}
}
// Update total display
const dist = rulerTotal >= 1 ? rulerTotal.toFixed(1) + ' км' : Math.round(rulerTotal * 1000) + ' м';
// ET-005: rulerTotal хранится в км — переводим в метры для units.js.
const dist = Units.formatDistance(rulerTotal * 1000);
document.getElementById('ruler-dist').textContent = dist;
}
@@ -1927,9 +1944,8 @@ function addRulerPoint(lngLat) {
if (idx === 0) {
labelText.textContent = 'Старт';
} else {
labelText.textContent = segDist >= 1
? segDist.toFixed(1) + ' км'
: Math.round(segDist * 1000) + ' м';
// ET-005: segDist в км — переводим в метры для units.js.
labelText.textContent = Units.formatDistance(segDist * 1000);
}
// Bug 5: use button element for better tap target and semantics
@@ -2105,6 +2121,9 @@ function clearRecon() {
let linkMode = false;
let linkPoints = [];
let linkMarkers = [];
// ET-005: последние построенные связки кэшируются, чтобы оркестратор
// onUnitChange() мог перерисовать карточки без повторного запроса к API.
let linkRoutes = [];
function toggleLinkMode() {
const btn = document.getElementById('tb-link');
@@ -2178,6 +2197,7 @@ async function buildLinkRoute() {
function renderLinkCards(routes) {
const map = window._map;
linkRoutes = routes; // ET-005: кэш для перерисовки при смене единицы
const colors = ['#0066ff', '#00aa44', '#9933cc'];
const cardsEl = document.getElementById('link-cards');
cardsEl.innerHTML = '';
@@ -2199,7 +2219,6 @@ function renderLinkCards(routes) {
layout: { 'line-cap': 'round', 'line-join': 'round' }
});
const km = (r.distance_m / 1000).toFixed(0);
const time = formatDuration(r.duration_s);
const dirt = r.stats?.dirt_total_pct || '?';
const col = colors[i % colors.length];
@@ -2209,7 +2228,7 @@ function renderLinkCards(routes) {
<div class="rc-header">
<span class="rc-dot" style="background:${col}"></span>
<span class="rc-title">Вариант ${i+1}</span>
<span class="rc-km">${km} км</span>
<span class="rc-km">${Units.formatDistance(r.distance_m, { precision: 0 })}</span>
<span class="rc-time">${time}</span>
</div>
<div style="font-size:11px;color:var(--text2)">${dirt}% грунт</div>
@@ -2354,7 +2373,6 @@ function drawScenicRoutes(routes, activeIdx) {
if (cardsEl) {
cardsEl.innerHTML = routes.map((r, i) => {
const col = colors[i % colors.length];
const km = (r.distance_m / 1000).toFixed(0);
const time = formatDuration(r.duration_s);
const dirt = r.stats?.dirt_total_pct || '?';
const pois = (r.scenic_pois || []).map(p => {
@@ -2367,7 +2385,7 @@ function drawScenicRoutes(routes, activeIdx) {
<div class="rc-header">
<span class="rc-dot" style="background:${col}"></span>
<span class="rc-title">${r.name || 'Вариант '+(i+1)}</span>
<span class="rc-km">${km} км</span>
<span class="rc-km">${Units.formatDistance(r.distance_m, { precision: 0 })}</span>
<span class="rc-time">${time}</span>
</div>
<div style="font-size:11px;color:var(--text2)">${dirt}% грунт · score=${r.scenic_score||0}</div>
@@ -2414,6 +2432,10 @@ document.addEventListener('DOMContentLoaded', () => {
initSheetSwipe();
// Apply saved theme immediately (before map loads)
applyTheme();
// ET-005: восстановить выбор единиц измерения (AC-3) и подключить
// единый оркестратор пересчёта расстояний (ADR-0001 п.6).
syncUnitToggleUI();
document.addEventListener('unitchange', onUnitChange);
});
// ─── Mini Route Bar ──────────────────────────────────────────────────
@@ -2602,11 +2624,10 @@ function hideMiniRouteSheet() {
function updateMiniRouteCard() {
const r = routeResults[activeRouteIdx];
if (!r) return;
const km = (r.distance_m / 1000).toFixed(1);
const dirt = r.stats?.dirt_total_pct ?? '-';
document.getElementById('mini-dot').style.background = ROUTE_COLORS[activeRouteIdx % ROUTE_COLORS.length];
document.getElementById('mini-label').textContent = `Вариант ${activeRouteIdx + 1} из ${routeResults.length}`;
document.getElementById('mini-stats').textContent = `${km} км · ${dirt}% грунт`;
document.getElementById('mini-stats').textContent = `${Units.formatDistance(r.distance_m)} · ${dirt}% грунт`;
document.getElementById('mini-prev').style.opacity = activeRouteIdx > 0 ? '1' : '0.3';
document.getElementById('mini-next').style.opacity = activeRouteIdx < routeResults.length - 1 ? '1' : '0.3';
}
@@ -2853,6 +2874,79 @@ function restorePoiState() {
}
// <<< ET-002 POI visibility block <<<
// >>> ET-005 unit toggle block >>>
// Переключатель единиц измерения расстояний (км/мили) в попапе рельефа.
// Выбор единицы, его персистентность и форматирование вынесены в
// src/web/units.js (ADR-0001). Здесь — UI-обработчик попапа и единый
// оркестратор пересчёта видимых расстояний.
// См. docs/work-items/ET-005/06-adr/adr-0001-unit-toggle-client-side.md
/**
* Обработчик сегментированного переключателя единиц в попапе рельефа
* (атрибут onclick кнопок «км» / «мили»).
*
* Делегирует смену единицы модулю Units. Пересчёт всех видимых
* расстояний выполняет оркестратор onUnitChange() по событию
* 'unitchange'; здесь дополнительно синхронизируется вид кнопок.
* @param {('km'|'mi')} unit - выбранная единица измерения.
*/
function onUnitToggle(unit) {
Units.setUnit(unit);
syncUnitToggleUI();
}
/**
* Синхронизирует визуальное состояние кнопок «км» / «мили» с текущей
* выбранной единицей измерения.
*
* Вызывается при инициализации страницы (восстановление выбора из
* localStorage — AC-3) и после каждого переключения.
*/
function syncUnitToggleUI() {
const unit = Units.getUnit();
const kmBtn = document.getElementById('unit-btn-km');
const miBtn = document.getElementById('unit-btn-mi');
if (kmBtn) kmBtn.classList.toggle('active', unit === 'km');
if (miBtn) miBtn.classList.toggle('active', unit === 'mi');
}
/**
* Единый оркестратор пересчёта расстояний при смене единицы измерения
* (ADR-0001 п.6). Подписан на событие 'unitchange' ровно один раз —
* вместо россыпи подписок по компонентам пере-вызывает функции
* отрисовки всех видимых поверхностей с расстояниями.
*
* Внутреннее состояние остаётся метрическим: конвертация выполняется
* исключительно в Units.formatDistance().
*/
function onUnitChange() {
// Карточки основного маршрута, лист точек и мини-карточка.
if (routeResults.length > 0) {
renderRouteCards(routeResults);
updateMiniRouteCard();
}
if (routeWaypoints.length > 0) {
renderWaypointsList();
}
// Линейка: updateRulerLabels() обновляет и подписи отрезков, и итог.
if (rulerMarkers.length > 0) {
updateRulerLabels();
}
// Карточки связки и «красивого» маршрута — только при активном режиме,
// чтобы перерисовка не возвращала на карту скрытые слои.
if (linkMode && linkRoutes.length > 0) {
renderLinkCards(linkRoutes);
}
if (scenicMode && scenicRoutes.length > 0) {
drawScenicRoutes(scenicRoutes, activeScenicIdx);
}
// Масштабная линейка карты (риск R3).
if (typeof window._updateScaleZoom === 'function') {
window._updateScaleZoom();
}
}
// <<< ET-005 unit toggle block <<<
function applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom) {
const map = window._map;
if (!map) return;

View File

@@ -58,6 +58,15 @@
<input type="checkbox" id="poi-visible-cb" onchange="onPoiCheckbox()" checked>
<span>POI</span>
</label>
<hr style="margin:6px 0;border-color:rgba(128,128,128,0.3)">
<!-- ET-005: переключатель единиц измерения расстояний (км/мили) -->
<div class="terrain-unit-row">
<span class="terrain-unit-label">Единицы</span>
<div class="seg-control unit-seg" id="unit-seg">
<button type="button" class="seg-btn" id="unit-btn-km" data-unit="km" onclick="onUnitToggle('km')">км</button>
<button type="button" class="seg-btn" id="unit-btn-mi" data-unit="mi" onclick="onUnitToggle('mi')">мили</button>
</div>
</div>
</div>
<!-- ── Map Buttons (right) ───────────────── -->
@@ -402,6 +411,8 @@
<!-- Scripts -->
<script src="https://unpkg.com/maplibre-gl@4.7.0/dist/maplibre-gl.js"></script>
<script src="https://unpkg.com/suncalc@1.9.0/suncalc.min.js"></script>
<!-- ET-005: units.js ДОЛЖЕН подключаться строго перед app.js (ADR-0001 п.2, риск R7) -->
<script src="units.js"></script>
<script src="app.js"></script>
</body>
</html>

190
src/web/units.js Normal file
View 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));