auto-sync: 2026-05-04 10:40:01

This commit is contained in:
Stream
2026-05-04 10:40:01 +03:00
parent 32c6939ef9
commit 6477326eda
4 changed files with 828 additions and 19 deletions

View File

@@ -0,0 +1,475 @@
# 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 без крайней необходимости