Files
wiki/tasks/enduro-trails/DEV_TASK_PHASE3.md
2026-05-04 10:40:01 +03:00

476 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
<div id="route-panel" style="display:none; position:absolute; bottom:40px; right:10px;
background:rgba(255,255,255,0.97); border:1px solid #ddd; border-radius:8px;
padding:12px; font-size:13px; z-index:5; width:280px;
box-shadow:0 2px 12px rgba(0,0,0,0.15); max-height:70vh; overflow-y:auto;">
<!-- Панель точек -->
<div id="waypoints-panel">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<span style="font-weight:600; color:#e07b00;">📍 Точки маршрута</span>
<button id="btn-add-waypoint" onclick="startAddWaypoint()"
style="font-size:11px; padding:3px 8px; background:#f0f0f0; border:1px solid #ccc; border-radius:4px; cursor:pointer;">
+ Точка
</button>
</div>
<div id="waypoints-list"></div>
</div>
<div id="route-status" style="color:#888; font-size:12px; margin:8px 0;">Кликни точку старта</div>
<!-- Кнопки действий -->
<div id="route-actions" style="display:none; margin-top:8px;">
<button onclick="buildRoute()" id="btn-build-route"
style="width:100%; padding:6px; background:#ff6600; color:#fff; border:none; border-radius:4px; cursor:pointer; font-size:13px; font-weight:600;">
🗺️ Построить маршрут
</button>
<button onclick="clearRoute()"
style="width:100%; margin-top:4px; padding:4px; background:#f0f0f0; border:1px solid #ccc; border-radius:4px; cursor:pointer; font-size:12px;">
✕ Сбросить всё
</button>
</div>
<!-- Карточки маршрутов -->
<div id="route-cards" style="margin-top:10px;"></div>
</div>
```
**Функция рендера карточек** `renderRouteCards(routes)`:
Для каждого маршрута генерировать HTML карточки:
```html
<div class="route-card" id="route-card-N" onclick="selectRoute(N)">
<div class="route-card-header">
<span class="route-color-dot" style="background: COLOR"></span>
<span class="route-card-title">Вариант N</span>
<span class="route-card-dist">XX км</span>
<span class="route-card-time">X ч Y мин</span>
</div>
<div class="route-coverage-bar">
<!-- пропорциональные сегменты -->
<div style="width:XX%; background:#FFD700" title="Lev1-2: XX км"></div>
<div style="width:XX%; background:#FF4400" title="Lev3-5: XX км"></div>
<div style="width:XX%; background:#cc0000" title="Тропы: XX км"></div>
<div style="width:XX%; background:#aaaaaa" title="Асфальт: XX км"></div>
</div>
<div class="route-card-summary">XX% грунт · XX% асфальт</div>
<div class="route-card-details" id="route-details-N" style="display:none;">
<!-- развёрнутая статистика -->
<div class="route-stat-row">🟡 Lev1-2 &nbsp; XX км &nbsp; XX%</div>
<div class="route-stat-row">🔴 Lev3-5 &nbsp; XX км &nbsp; XX%</div>
<div class="route-stat-row">🔴 Тропы &nbsp; XX км &nbsp; XX%</div>
<div class="route-stat-row">⬜ Асфальт &nbsp; XX км &nbsp; XX%</div>
<div style="margin-top:6px; display:flex; gap:6px;">
<button onclick="event.stopPropagation(); downloadGPX()"
style="flex:1; padding:4px; background:#f0f0f0; border:1px solid #ccc; border-radius:4px; cursor:pointer; font-size:11px;">
📥 GPX
</button>
<button onclick="event.stopPropagation(); selectRoute(N)"
style="flex:1; padding:4px; background:#ff6600; color:#fff; border:none; border-radius:4px; cursor:pointer; font-size:11px;">
Выбрать
</button>
</div>
</div>
<button class="route-details-toggle" onclick="event.stopPropagation(); toggleRouteDetails(N)">
Подробнее ▼
</button>
</div>
```
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
<div class="waypoint-row" draggable="true">
<span class="waypoint-label">A</span>
<span class="waypoint-coords">55.7512, 37.6184</span>
<button onclick="removeWaypoint(0)">✕</button>
</div>
```
- 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 ` <wpt lat="${wp.lat}" lon="${wp.lon}"><name>${name}</name></wpt>`;
});
// Добавить флажки из localStorage
const markers = loadMarkers();
markers.forEach(m => {
wpts.push(` <wpt lat="${m.lat}" lon="${m.lon}"><name>${escapeXml(m.name)}</name><sym>${m.icon}</sym></wpt>`);
});
// Трек
const trkpts = route.geometry.coordinates.map(([lon, lat]) =>
` <trkpt lat="${lat}" lon="${lon}"/>`
).join('\n');
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Enduro Trails" xmlns="http://www.topografix.com/GPX/1/1">
<metadata>
<name>Enduro route ${dateStr}</name>
<desc>${distKm} км · ${dirtPct}% грунт</desc>
<time>${now.toISOString()}</time>
</metadata>
${wpts.join('\n')}
<trk>
<name>Enduro route ${dateStr}</name>
<trkseg>
${trkpts}
</trkseg>
</trk>
</gpx>`;
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
```
---
### Задача 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
<b>НАЗВАНИЕ</b><br>
LAT, LON<br>
<button onclick="useMarkerAsA(id)">→ Точка A</button>
<button onclick="useMarkerAsB(id)">→ Точка B</button>
<button onclick="removeMarker(id)">🗑 Удалить</button>
```
**Кнопка в панели управления** (добавить в `index.html`):
```html
<button id="btn-markers" class="map-ctrl-btn" title="Добавить метку" onclick="toggleMarkerMode()">🚩</button>
```
**Режим добавления:** `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 без крайней необходимости