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

20 KiB
Raw Permalink Blame History

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) и применить везде где отображается время.

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:

{
  "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м / средний шаг)
  • Для каждого сегмента взять среднюю точку, найти ближайший трек в БД:
    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 и т.д.)
  • Вернуть словарь с метрами и процентами

Формат ответа:

{
  "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 Фронт — панель альтернативных маршрутов

Цвета маршрутов (по индексу):

const ROUTE_COLORS = ['#0066ff', '#00aa44', '#9933cc', '#ff8800', '#888888'];

Состояние:

let routeWaypoints = []; // [{lon, lat}, ...]
let routeResults = [];   // массив маршрутов из API
let activeRouteIdx = 0;  // выбранный маршрут
let waypointMarkers = []; // MapLibre маркеры точек
let addingWaypoint = false; // режим добавления промежуточной точки

HTML панели маршрутов (добавить в index.html, заменить текущий #route-panel):

<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 карточки:

<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):

.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():

<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() (фронт, без бэкенда):

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

Структура метки:

{ 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 над картой):

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);
}

Попап метки при клике:

<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):

<button id="btn-markers" class="map-ctrl-btn" title="Добавить метку" onclick="toggleMarkerMode()">🚩</button>

Режим добавления: markerMode = true/false, при клике на карту → addMarker(lngLat)

Лимит: 50 меток. При достижении — alert('Достигнут лимит 50 меток').

Кнопка «Очистить все» — добавить в панель меток с confirm().


Деплой

После реализации всех задач задеплоить на сервер:

# Синхронизировать файлы
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 без крайней необходимости