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:
142
src/web/app.js
142
src/web/app.js
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user