Files
wiki/tasks/enduro-trails/DEV_TASK_PHASE4.md
2026-05-04 23:20:01 +03:00

11 KiB
Raw Permalink Blame History

Dev Task: Enduro Trails — Фаза 4 «Продвинутый роутинг»

Приоритет: HIGH
Проект: enduro-trails
BRD: BRD_PHASE4.md


Контекст

Фаза 3 реализована: роутинг A→B с альтернативами, статистика, GPX, флажки. Фаза 4 — 3 новых режима роутинга: Разведка, Связка, Красивый маршрут.

Деплой

SSH из контейнера НЕ работает. Используй Node.js ssh2:

const { Client } = require('/tmp/node_modules/ssh2');

Шаблон деплоя: /tmp/deploy_app2.js (уже работает). Для статики (app.js, app.css, index.html) — SFTP upload в /home/slin/enduro-trails/prototype/static/, потом docker cp в контейнер + docker restart.


Задача 1: «Разведка» (F-14) — ПЕРВАЯ

Бэкенд: новый endpoint POST /api/recon

Запрос:

{ "lon": 37.6, "lat": 55.7, "radius_km": 20 }

Ответ:

{
  "center": { "lon": 37.6, "lat": 55.7 },
  "radius_km": 20,
  "trails": {
    "total_count": 124,
    "total_km": 380,
    "lev12_count": 45, "lev12_km": 120,
    "lev345_count": 68, "lev345_km": 210,
    "path_count": 11, "path_km": 50
  },
  "poi": {
    "natural=water": 3, "tourism=viewpoint": 2,
    "historic=ruins": 1, "ford=yes": 12,
    "natural=peak": 0, "natural=cave_entrance": 0
  }
}

SQL логика:

import math
lat_rad = math.radians(lat)
delta_lat = radius_km / 111.0
delta_lon = radius_km / (111.0 * math.cos(lat_rad))

# Trails — агрегация по типам
cur.execute("""
    SELECT highway_type, track_type, SUM(length_m) as total_m, COUNT(*) as cnt
    FROM trails
    WHERE min_lon <= ? AND max_lon >= ?
      AND min_lat <= ? AND max_lat >= ?
      AND length_m >= 100
    GROUP BY highway_type, track_type
""", (lon + delta_lon, lon - delta_lon, lat + delta_lat, lat - delta_lat))

# POI — подсчёт по типам
cur.execute("""
    SELECT poi_type, COUNT(*) as cnt
    FROM poi
    WHERE lon >= ? AND lon <= ? AND lat >= ? AND lat <= ?
    GROUP BY poi_type
""", (lon - delta_lon, lon + delta_lon, lat - delta_lat, lat + delta_lat))

Агрегация trail типов:

  • lev12: highway_type='track' AND track_type IN ('grade1','grade2')
  • lev345: highway_type='track' AND (track_type IN ('grade3','grade4','grade5') OR track_type IS NULL)
  • path: highway_type IN ('path','bridleway')

Фронт: кнопка 📍 и UI

Кнопка в #map-controls-br:

<button id="btn-recon" class="map-ctrl-btn" title="Разведка" onclick="toggleReconMode()">📍</button>

⚠️ Конфликт: текущая кнопка геолокации тоже 📍. Заменить геолокацию на иконку 🎯.

Логика:

  1. Нажал 📍reconMode = true, курсор crosshair, деактивировать другие режимы
  2. Клик на карту → POST /api/recon, нарисовать круг + показать попап
  3. Круг: GeoJSON Polygon (64 точки) с полупрозрачной заливкой #ff6600, opacity 0.1
  4. Попап: статистика
  5. Кнопки радиуса [20] [50] [100] — перезапрос API
  6. Выход: повторный клик на 📍 или другой режим

Попап (HTML div):

<div id="recon-panel" style="display:none; position:absolute; bottom:40px; left:12px;
     background:rgba(255,255,255,0.97); border:1px solid #ddd; border-radius:8px;
     padding:12px; font-size:13px; z-index:5; width:240px;
     box-shadow:0 2px 12px rgba(0,0,0,0.15);">
  <div style="font-weight:600; color:#e07b00; margin-bottom:8px;">📍 Разведка</div>
  <div id="recon-stats"></div>
  <div style="margin-top:8px; display:flex; gap:4px;">
    <button onclick="setReconRadius(20)" class="recon-radius-btn active">20 км</button>
    <button onclick="setReconRadius(50)" class="recon-radius-btn">50 км</button>
    <button onclick="setReconRadius(100)" class="recon-radius-btn">100 км</button>
  </div>
</div>

Круг на карте (GeoJSON Polygon):

function makeCircleGeoJSON(lon, lat, radiusKm) {
  const coords = [];
  for (let i = 0; i <= 64; i++) {
    const a = (2 * Math.PI * i) / 64;
    const dlat = (radiusKm / 111) * Math.cos(a);
    const dlon = (radiusKm / (111 * Math.cos(lat * Math.PI / 180))) * Math.sin(a);
    coords.push([lon + dlon, lat + dlat]);
  }
  return { type: 'Feature', geometry: { type: 'Polygon', coordinates: [coords] } };
}

Задача 2: «Связка» (F-13) — ВТОРАЯ

UI

Кнопка:

<button id="btn-link" class="map-ctrl-btn" title="Связка" onclick="toggleLinkMode()">🔗</button>

Логика: переиспользует POST /api/route с alternatives=3.

  1. Нажал 🔗linkMode = true, курсор crosshair
  2. Клик → маркер «1» (оранжевый)
  3. Клик → маркер «2» (оранжевый)
  4. Автоматический POST /api/route с этими двумя точками
  5. Карточки маршрутов (как «Дикий путь»)

Отличия от «Дикого пути»:

  • Заголовок панели: «🔗 Связка»
  • Маркеры: оранжевые с цифрами 1 2 (не зелёный/красный A/B)
  • В карточке акцент: «грунтовая связка N% грунта»
  • Нет промежуточных точек (не нужно для связки)

Задача 3: «Красивый маршрут» (F-11) — ПОСЛЕДНЕЙ

Бэкенд: новый endpoint POST /api/scenic

Запрос:

{ "lon": 37.6, "lat": 55.7, "target_km": 150 }

Ответ:

{
  "routes": [
    {
      "name": "Северный маршрут",
      "waypoints": [
        {"lon": 37.6, "lat": 55.7, "label": "Старт"},
        {"lon": 36.5, "lat": 56.1, "label": "💧 Озеро Сенеж"},
        {"lon": 36.8, "lat": 56.3, "label": "👁 Смотровая"},
        {"lon": 37.6, "lat": 55.7, "label": "Финиш"}
      ],
      "geometry": {"type": "LineString", "coordinates": [...]},
      "distance_m": 142000,
      "duration_s": 17100,
      "stats": { ... },
      "scenic_score": 35,
      "scenic_pois": [
        {"type": "natural=water", "name": "Озеро Сенеж", "lon": 36.5, "lat": 56.1},
        {"type": "tourism=viewpoint", "name": "Смотровая", "lon": 36.8, "lat": 56.3}
      ]
    }
  ]
}

Алгоритм find_scenic_route()

  1. Радиус поиска POI = target_km × 0.6
  2. Запрос POI из БД с score:
    • water=10, viewpoint=15, ruins=10, peak=12, cave=8, ford=5
  3. Жадный выбор POI:
    • Начать со старта
    • На каждом шаге: выбрать POI с max(score / distance)
    • Условие: расстояние до POI < remaining_km × 0.5 и > 3 км
    • Добавить в маршрут, уменьшить remaining_km
    • Максимум 5 POI
  4. Построить маршрут через все выбранные POI + возврат к старту
    • Один OSRM запрос со всеми waypoints
  5. Проверить дистанцию:
    • Если > target_km × 1.3 — убрать последний POI, перестроить
    • Если < target_km × 0.5 — добавить POI, перестроить
  6. Альтернативы (до 3):
    • Разделить POI на кластеры по азимуту от старта (4 сектора по 90°)
    • Построить маршрут для каждого кластера с POI
    • Имена: «Северный», «Восточный», «Южный», «Западный»

Фронт: кнопка 🎨 и UI

Кнопка:

<button id="btn-scenic" class="map-ctrl-btn" title="Красивый маршрут" onclick="toggleScenicMode()">🎨</button>

Панель:

┌─────────────────────────────────┐
│ 🎨 Красивый маршрут             │
│ 📍 Точка старта: кликни на карте│
│ 📏 Дистанция:                   │
│   [50] [100] [150] [200]  [___] км│
│                                 │
│ [Построить маршрут]             │
└─────────────────────────────────┘

Карточка маршрута — стандартная + дополнительно:

  • scenic_pois с иконками (💧👁🏚🔺)
  • Имя маршрута по направлению

Общие требования

Взаимоисключающие режимы

Активация одного режима деактивирует все остальные:

function deactivateAllModes() {
  if (routeMode) toggleRouteMode();
  if (rulerMode) toggleRuler();
  if (markerMode) toggleMarkerMode();
  if (reconMode) toggleReconMode();
  if (linkMode) toggleLinkMode();
  if (scenicMode) toggleScenicMode();
}

Кнопки

Добавить в #map-controls-br (в index.html):

<button id="btn-scenic" class="map-ctrl-btn" title="Красивый маршрут" onclick="toggleScenicMode()">🎨</button>
<button id="btn-link" class="map-ctrl-btn" title="Связка" onclick="toggleLinkMode()">🔗</button>
<button id="btn-recon" class="map-ctrl-btn" title="Разведка" onclick="toggleReconMode()">📍</button>

Заменить иконку геолокации: 📍🎯 (чтобы не конфликтовала с Разведкой).


Порядок реализации

  1. Разведка (F-14) — бэкенд /api/recon + фронт 📍
  2. Связка (F-13) — фронт 🔗 (переиспользует /api/route)
  3. Красивый маршрут (F-11) — бэкенд /api/scenic + фронт 🎨
  4. Деплой + тест

Definition of Done

  • POST /api/recon возвращает статистику грунтовок и POI в радиусе
  • Круг на карте + попап со статистикой
  • Кнопки радиуса 20/50/100 км работают
  • 🔗 «Связка» строит маршрут между двумя точками с оранжевыми маркерами
  • 🎨 «Красивый маршрут» строит кольцевой маршрут через POI
  • Пресеты дистанции 50/100/150/200 км + ручной ввод
  • Режимы взаимоисключающие
  • Иконка геолокации заменена 📍🎯
  • Деплой на сервер, health OK