# Dev Task: Enduro Trails — Фаза 3 «Умный маршрут» **Приоритет:** HIGH **Проект:** enduro-trails **Сервер:** `slin@82.22.50.71`, пароль: `motoZ@yaz2010` **Контейнер приложения:** `prototype-enduro-trails-1`, порт `5558` **Контейнер OSRM:** `osrm-osrm-routed-1`, порт `5559` **URL:** `https://openclaw.mva154.duckdns.org/enduro/` **Workspace:** `/home/node/.openclaw/workspace/tasks/enduro-trails/prototype/` **Код на сервере:** `/home/slin/enduro-trails/prototype/` **БД:** `/home/slin/enduro-trails/data/centralfederal.sqlite` --- ## Контекст Прототип работает. Фаза 2 завершена: роутинг A→B, поиск, линейка. Фаза 3 — расширение роутинга: альтернативные маршруты, статистика покрытия, человекочитаемое время, промежуточные точки, GPX-экспорт, флажки на карте. Текущий стек: - **Бэкенд:** FastAPI + uvicorn (4 workers), `app.py` - **Фронт:** MapLibre GL JS 4.1.3, vanilla JS, `app.js` + `app.css` + `index.html` - **OSRM:** контейнер `osrm-osrm-routed-1`, порт 5559, `OSRM_URL=http://172.22.0.1:5559` - **БД:** SQLite (Spatialite), таблицы `trails` и `poi` --- ## Задачи --- ### Задача 1: Человекочитаемое время (F-03) **Файл:** `static/app.js` Добавить утилитарную функцию `formatDuration(seconds)` и применить везде где отображается время. ```js function formatDuration(seconds) { const totalMin = Math.round(seconds / 60); if (totalMin < 60) return totalMin + ' мин'; const days = Math.floor(totalMin / 1440); const hours = Math.floor((totalMin % 1440) / 60); const mins = totalMin % 60; if (days > 0) { if (mins === 0) return `${days} дн ${hours} ч`; return `${days} дн ${hours} ч ${mins} мин`; } if (mins === 0) return `${hours} ч`; return `${hours} ч ${mins} мин`; } ``` Применить: заменить `~${p.duration_min} мин` на `formatDuration(p.duration_s)` (бэкенд должен вернуть `duration_s`). --- ### Задача 2: Альтернативные маршруты + статистика (F-01 + F-02) #### 2.1 Бэкенд — новый endpoint `/api/route` (POST, заменяет GET) Текущий GET `/api/route` заменить на POST. Принимает JSON: ```json { "waypoints": [ {"lon": 37.6, "lat": 55.7}, {"lon": 39.5, "lat": 56.2} ], "alternatives": 5 } ``` Логика: 1. Собрать строку координат для OSRM: `lon1,lat1;lon2,lat2;...` 2. Запросить OSRM с `alternatives=5&overview=full&geometries=geojson` 3. Для каждого маршрута из ответа — вызвать `calc_route_stats(geometry, conn)` 4. Вернуть массив маршрутов Функция `calc_route_stats(geometry, conn)`: - Принять GeoJSON LineString (список координат `[lon, lat]`) - Разбить на сегменты по ~100м (каждые N точек, где N ≈ 100м / средний шаг) - Для каждого сегмента взять среднюю точку, найти ближайший трек в БД: ```sql SELECT highway_type, track_type, length_m FROM trails WHERE min_lon <= ? AND max_lon >= ? AND min_lat <= ? AND max_lat >= ? ORDER BY ABS(min_lon - ?) + ABS(min_lat - ?) LIMIT 1 ``` - Суммировать длины по категориям: - `track_lev12`: highway_type='track' AND track_type IN ('grade1','grade2') - `track_lev345`: highway_type='track' AND track_type IN ('grade3','grade4','grade5') или track_type IS NULL - `path`: highway_type IN ('path','bridleway','footway') - `asphalt`: всё остальное (primary, secondary, tertiary, residential, unclassified и т.д.) - Вернуть словарь с метрами и процентами Формат ответа: ```json { "routes": [ { "index": 0, "geometry": {"type": "LineString", "coordinates": [...]}, "distance_m": 142000, "duration_s": 16500, "stats": { "track_lev12_m": 68000, "track_lev345_m": 42000, "path_m": 12000, "asphalt_m": 20000, "track_lev12_pct": 48, "track_lev345_pct": 30, "path_pct": 8, "asphalt_pct": 14, "dirt_total_pct": 86 } } ] } ``` Если stats не удалось посчитать — вернуть `"stats": null`, не падать. #### 2.2 Фронт — панель альтернативных маршрутов **Цвета маршрутов** (по индексу): ```js const ROUTE_COLORS = ['#0066ff', '#00aa44', '#9933cc', '#ff8800', '#888888']; ``` **Состояние:** ```js let routeWaypoints = []; // [{lon, lat}, ...] let routeResults = []; // массив маршрутов из API let activeRouteIdx = 0; // выбранный маршрут let waypointMarkers = []; // MapLibre маркеры точек let addingWaypoint = false; // режим добавления промежуточной точки ``` **HTML панели маршрутов** (добавить в `index.html`, заменить текущий `#route-panel`): ```html ``` **Функция рендера карточек** `renderRouteCards(routes)`: Для каждого маршрута генерировать HTML карточки: ```html
Вариант N XX км X ч Y мин
XX% грунт · XX% асфальт
``` CSS для карточек (добавить в `app.css`): ```css .route-card { border: 2px solid #eee; border-radius: 6px; padding: 8px 10px; margin-bottom: 6px; cursor: pointer; transition: border-color 0.15s, background 0.15s; } .route-card:hover { background: #fff8f0; } .route-card.active { border-color: #ff6600; background: #fff8f0; } .route-card-header { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; } .route-color-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } .route-card-title { font-weight: 600; flex: 1; } .route-card-dist { color: #333; font-weight: 600; } .route-card-time { color: #666; font-size: 12px; } .route-coverage-bar { display: flex; height: 6px; border-radius: 3px; overflow: hidden; margin-bottom: 4px; background: #eee; } .route-coverage-bar div { height: 100%; min-width: 3px; } .route-card-summary { font-size: 11px; color: #666; margin-bottom: 4px; } .route-stat-row { font-size: 12px; padding: 2px 0; color: #444; } .route-details-toggle { font-size: 11px; color: #888; background: none; border: none; cursor: pointer; padding: 2px 0; width: 100%; text-align: right; } ``` **Функция `selectRoute(idx)`:** - Установить `activeRouteIdx = idx` - Обновить стиль всех слоёв маршрутов на карте (активный — жирнее, остальные — тоньше и прозрачнее) - Обновить CSS класс `active` на карточках **Hover на карточке:** - `onmouseenter` → подсветить маршрут на карте (увеличить line-width) - `onmouseleave` → вернуть к состоянию active/inactive **Клик на линию маршрута на карте:** - Добавить обработчик `map.on('click', 'route-line-N', ...)` → `selectRoute(N)` --- ### Задача 3: Промежуточные точки (F-04) **Логика добавления точки:** - Кнопка «+ Точка» → `startAddWaypoint()` → устанавливает `addingWaypoint = true`, меняет курсор - Следующий клик на карту (в `initRouteClicks`) → добавляет точку в `routeWaypoints` как промежуточную (между A и B) - После добавления — `addingWaypoint = false`, курсор сбрасывается - Перестройка маршрута если A и B уже установлены **Маркеры точек:** - A: зелёный кружок с буквой «A» - B: красный кружок с буквой «B» - Промежуточные: белый кружок с цветной обводкой (#0066ff) и номером **Перетаскивание:** - Все маркеры создавать с `draggable: true` - На событие `dragend` → обновить координаты в `routeWaypoints`, вызвать `buildRoute()` с debounce 300ms **Панель точек `renderWaypointsList()`:** ```html
A 55.7512, 37.6184
``` - Drag-and-drop для изменения порядка (HTML5 draggable API) - После изменения порядка — перестройка маршрута **Лимит:** максимум 10 точек (A + 8 промежуточных + B). При достижении — скрыть кнопку «+ Точка». --- ### Задача 4: Экспорт GPX (F-05) **Функция `downloadGPX()`** (фронт, без бэкенда): ```js function downloadGPX() { const route = routeResults[activeRouteIdx]; if (!route) return; const now = new Date(); const dateStr = now.toISOString().slice(0, 10); const timeStr = now.toISOString().replace(/[-:]/g, '').slice(0, 15); const filename = `enduro-${timeStr}.gpx`; const distKm = (route.distance_m / 1000).toFixed(1); const dirtPct = route.stats ? route.stats.dirt_total_pct : '?'; // Waypoints: точки маршрута + флажки из localStorage const wpts = routeWaypoints.map((wp, i) => { const name = i === 0 ? 'Старт' : i === routeWaypoints.length - 1 ? 'Финиш' : `Точка ${i}`; return ` ${name}`; }); // Добавить флажки из localStorage const markers = loadMarkers(); markers.forEach(m => { wpts.push(` ${escapeXml(m.name)}${m.icon}`); }); // Трек const trkpts = route.geometry.coordinates.map(([lon, lat]) => ` ` ).join('\n'); const gpx = ` Enduro route ${dateStr} ${distKm} км · ${dirtPct}% грунт ${wpts.join('\n')} Enduro route ${dateStr} ${trkpts} `; const blob = new Blob([gpx], { type: 'application/gpx+xml' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } function escapeXml(str) { return (str || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } ``` --- ### Задача 5: Флажки / именованные метки (F-06) **localStorage ключ:** `enduro_markers` **Структура метки:** ```js { id: Date.now(), name: 'Заправка', icon: '⛽', lat: 55.71, lon: 37.62 } ``` **Иконки:** `['🚩', '⛺', '🔧', '⛽', '💧', '📍']` **Функции:** - `loadMarkers()` → читает из localStorage, возвращает массив - `saveMarkers(markers)` → пишет в localStorage - `addMarker(lngLat)` → показывает диалог, сохраняет, рисует маркер - `renderMarkers()` → при загрузке страницы рисует все сохранённые метки - `removeMarker(id)` → удаляет из localStorage и с карты **Диалог добавления метки** (простой `prompt` или inline div над картой): ```js function addMarker(lngLat) { const name = prompt('Название метки (Enter = автоимя):') ?? ''; const markers = loadMarkers(); const autoName = name.trim() || `Метка ${markers.length + 1}`; // Выбор иконки — упрощённо: первый вызов = 🚩, можно расширить позже const icon = '🚩'; const marker = { id: Date.now(), name: autoName, icon, lat: lngLat.lat, lon: lngLat.lng }; markers.push(marker); saveMarkers(markers); drawMarker(marker); } ``` **Попап метки** при клике: ```html НАЗВАНИЕ
LAT, LON
``` **Кнопка в панели управления** (добавить в `index.html`): ```html ``` **Режим добавления:** `markerMode = true/false`, при клике на карту → `addMarker(lngLat)` **Лимит:** 50 меток. При достижении — `alert('Достигнут лимит 50 меток')`. **Кнопка «Очистить все»** — добавить в панель меток с `confirm()`. --- ## Деплой После реализации всех задач задеплоить на сервер: ```bash # Синхронизировать файлы sshpass -p 'motoZ@yaz2010' scp -r /home/node/.openclaw/workspace/tasks/enduro-trails/prototype/app.py slin@82.22.50.71:/home/slin/enduro-trails/prototype/app.py sshpass -p 'motoZ@yaz2010' scp /home/node/.openclaw/workspace/tasks/enduro-trails/prototype/static/app.js slin@82.22.50.71:/home/slin/enduro-trails/prototype/static/app.js sshpass -p 'motoZ@yaz2010' scp /home/node/.openclaw/workspace/tasks/enduro-trails/prototype/static/app.css slin@82.22.50.71:/home/slin/enduro-trails/prototype/static/app.css sshpass -p 'motoZ@yaz2010' scp /home/node/.openclaw/workspace/tasks/enduro-trails/prototype/static/index.html slin@82.22.50.71:/home/slin/enduro-trails/prototype/static/index.html # Перезапустить контейнер sshpass -p 'motoZ@yaz2010' ssh slin@82.22.50.71 'cd /home/slin/enduro-trails && docker compose restart prototype' # Проверить health sleep 5 && curl -s https://openclaw.mva154.duckdns.org/enduro/api/health ``` --- ## Definition of Done - [ ] `formatDuration(seconds)` реализована и применена везде - [ ] POST `/api/route` принимает waypoints + alternatives=5 - [ ] Ответ содержит массив `routes` с `geometry`, `distance_m`, `duration_s`, `stats` - [ ] На карте отображаются до 5 маршрутов разными цветами - [ ] Панель карточек с компактной и развёрнутой статистикой - [ ] Hover на карточке подсвечивает маршрут - [ ] Клик на карточку / линию карты выбирает маршрут - [ ] Промежуточные точки добавляются, удаляются, перетаскиваются - [ ] Маршрут перестраивается при изменении точек (debounce 300ms) - [ ] GPX скачивается с треком + waypoints + флажками - [ ] Флажки добавляются, сохраняются в localStorage, переживают перезагрузку - [ ] Флажки попадают в GPX - [ ] Деплой на сервер выполнен - [ ] `curl https://openclaw.mva154.duckdns.org/enduro/api/health` возвращает 200 ## Что НЕ делать - Не трогать tile math и MVT-логику - Не менять стиль карты (цвета треков, подложку) - Не пересоздавать БД - Не менять порт (5558) - Не добавлять новые зависимости в requirements.txt без крайней необходимости