auto-sync: 2026-05-04 10:40:01
This commit is contained in:
475
tasks/enduro-trails/DEV_TASK_PHASE3.md
Normal file
475
tasks/enduro-trails/DEV_TASK_PHASE3.md
Normal 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 XX км XX%</div>
|
||||||
|
<div class="route-stat-row">🔴 Lev3-5 XX км XX%</div>
|
||||||
|
<div class="route-stat-row">🔴 Тропы XX км XX%</div>
|
||||||
|
<div class="route-stat-row">⬜ Асфальт XX км 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,'&').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
|
||||||
|
<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 без крайней необходимости
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
app.py — FastAPI сервер для Enduro Trails
|
app.py — FastAPI сервер для Enduro Trails
|
||||||
- Раздаёт статику из static/
|
- Раздаёт статику из static/
|
||||||
- /api/tiles/{z}/{x}/{y}.mvt — векторные тайлы из SQLite
|
- /api/tiles/{z}/{x}/{y}.mvt — векторные тайлы из SQLite
|
||||||
|
- /api/route (POST) — роутинг через OSRM с альтернативами и статистикой покрытия
|
||||||
- /api/health — статус БД
|
- /api/health — статус БД
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -13,11 +14,13 @@ import sqlite3
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from shapely.geometry import LineString
|
from shapely.geometry import LineString
|
||||||
|
from typing import List
|
||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from fastapi import FastAPI, HTTPException, Response
|
from fastapi import FastAPI, HTTPException, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel
|
||||||
import httpx
|
import httpx
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
@@ -148,9 +151,7 @@ def simplify_coords(coords, z):
|
|||||||
|
|
||||||
|
|
||||||
def build_mvt(trails_rows, poi_rows, z, x, y) -> bytes:
|
def build_mvt(trails_rows, poi_rows, z, x, y) -> bytes:
|
||||||
"""Собирает MVT тайл. Передаёт полные геометрии без серверного клиппинга.
|
"""Собирает MVT тайл."""
|
||||||
MapLibre GL сам клипит на клиенте. quantize_bounds расширяем на 10% чтобы
|
|
||||||
точки за границей тайла правильно квантизировались."""
|
|
||||||
import mapbox_vector_tile
|
import mapbox_vector_tile
|
||||||
|
|
||||||
west, south, east, north = tile_to_bbox(z, x, y)
|
west, south, east, north = tile_to_bbox(z, x, y)
|
||||||
@@ -203,10 +204,6 @@ def build_mvt(trails_rows, poi_rows, z, x, y) -> bytes:
|
|||||||
if not layers:
|
if not layers:
|
||||||
return b""
|
return b""
|
||||||
|
|
||||||
# quantize_bounds ДОЛЖЕН быть точно равен bbox тайла — без буфера.
|
|
||||||
# Буфер в SQL нужен чтобы захватить треки за границей тайла,
|
|
||||||
# но quantize_bounds определяет систему координат для MVT-пикселей.
|
|
||||||
# Любое расширение сдвигает треки относительно подложки.
|
|
||||||
return mapbox_vector_tile.encode(
|
return mapbox_vector_tile.encode(
|
||||||
layers,
|
layers,
|
||||||
quantize_bounds=(west, south, east, north),
|
quantize_bounds=(west, south, east, north),
|
||||||
@@ -215,6 +212,145 @@ def build_mvt(trails_rows, poi_rows, z, x, y) -> bytes:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Route stats ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def haversine_m(lon1, lat1, lon2, lat2) -> float:
|
||||||
|
"""Расстояние между двумя точками в метрах."""
|
||||||
|
R = 6371000
|
||||||
|
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
||||||
|
dphi = math.radians(lat2 - lat1)
|
||||||
|
dlam = math.radians(lon2 - lon1)
|
||||||
|
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
|
||||||
|
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||||
|
|
||||||
|
|
||||||
|
def calc_route_stats(geometry: dict, conn) -> dict | None:
|
||||||
|
"""
|
||||||
|
Считает статистику покрытия маршрута по типам дорог.
|
||||||
|
geometry — GeoJSON LineString {"type":"LineString","coordinates":[[lon,lat],...]}
|
||||||
|
Возвращает словарь с метрами и процентами по категориям.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
coords = geometry.get("coordinates", [])
|
||||||
|
if len(coords) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Считаем общую длину маршрута и шаг между точками
|
||||||
|
total_len = 0.0
|
||||||
|
seg_lengths = []
|
||||||
|
for i in range(len(coords) - 1):
|
||||||
|
d = haversine_m(coords[i][0], coords[i][1], coords[i+1][0], coords[i+1][1])
|
||||||
|
seg_lengths.append(d)
|
||||||
|
total_len += d
|
||||||
|
|
||||||
|
if total_len < 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Средний шаг между точками
|
||||||
|
avg_step = total_len / len(seg_lengths)
|
||||||
|
|
||||||
|
# Сколько точек пропускать чтобы получить ~100м сегменты
|
||||||
|
# Минимум 1 точка, максимум 50
|
||||||
|
step = max(1, min(50, int(round(100.0 / avg_step)))) if avg_step > 0 else 5
|
||||||
|
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"track_lev12_m": 0.0,
|
||||||
|
"track_lev345_m": 0.0,
|
||||||
|
"path_m": 0.0,
|
||||||
|
"asphalt_m": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Проходим по маршруту с шагом step, берём среднюю точку сегмента
|
||||||
|
i = 0
|
||||||
|
while i < len(coords) - 1:
|
||||||
|
end_i = min(i + step, len(coords) - 1)
|
||||||
|
# Средняя точка сегмента
|
||||||
|
mid_lon = (coords[i][0] + coords[end_i][0]) / 2
|
||||||
|
mid_lat = (coords[i][1] + coords[end_i][1]) / 2
|
||||||
|
|
||||||
|
# Длина этого сегмента
|
||||||
|
seg_len = sum(seg_lengths[i:end_i])
|
||||||
|
|
||||||
|
# Bbox для поиска ближайшего трека (~500м вокруг точки)
|
||||||
|
delta = 0.005 # ~500м
|
||||||
|
try:
|
||||||
|
cur.execute("""
|
||||||
|
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
|
||||||
|
""", (
|
||||||
|
mid_lon + delta, mid_lon - delta,
|
||||||
|
mid_lat + delta, mid_lat - delta,
|
||||||
|
mid_lon, mid_lat
|
||||||
|
))
|
||||||
|
row = cur.fetchone()
|
||||||
|
except Exception:
|
||||||
|
row = None
|
||||||
|
|
||||||
|
if row:
|
||||||
|
hw = (row["highway_type"] or "").lower()
|
||||||
|
tt = (row["track_type"] or "").lower()
|
||||||
|
if hw == "track":
|
||||||
|
if tt in ("grade1", "grade2"):
|
||||||
|
stats["track_lev12_m"] += seg_len
|
||||||
|
else:
|
||||||
|
# grade3/4/5 или NULL
|
||||||
|
stats["track_lev345_m"] += seg_len
|
||||||
|
elif hw in ("path", "bridleway", "footway"):
|
||||||
|
stats["path_m"] += seg_len
|
||||||
|
else:
|
||||||
|
stats["asphalt_m"] += seg_len
|
||||||
|
else:
|
||||||
|
# Нет данных — считаем асфальтом
|
||||||
|
stats["asphalt_m"] += seg_len
|
||||||
|
|
||||||
|
i = end_i
|
||||||
|
|
||||||
|
# Считаем итоговую длину из статистики
|
||||||
|
computed_total = (
|
||||||
|
stats["track_lev12_m"] + stats["track_lev345_m"] +
|
||||||
|
stats["path_m"] + stats["asphalt_m"]
|
||||||
|
)
|
||||||
|
if computed_total < 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def pct(v):
|
||||||
|
return round(v / computed_total * 100)
|
||||||
|
|
||||||
|
dirt_total = stats["track_lev12_m"] + stats["track_lev345_m"] + stats["path_m"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"track_lev12_m": round(stats["track_lev12_m"]),
|
||||||
|
"track_lev345_m": round(stats["track_lev345_m"]),
|
||||||
|
"path_m": round(stats["path_m"]),
|
||||||
|
"asphalt_m": round(stats["asphalt_m"]),
|
||||||
|
"track_lev12_pct": pct(stats["track_lev12_m"]),
|
||||||
|
"track_lev345_pct": pct(stats["track_lev345_m"]),
|
||||||
|
"path_pct": pct(stats["path_m"]),
|
||||||
|
"asphalt_pct": pct(stats["asphalt_m"]),
|
||||||
|
"dirt_total_pct": pct(dirt_total),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Pydantic models ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class Waypoint(BaseModel):
|
||||||
|
lon: float
|
||||||
|
lat: float
|
||||||
|
|
||||||
|
|
||||||
|
class RouteRequest(BaseModel):
|
||||||
|
waypoints: List[Waypoint]
|
||||||
|
alternatives: int = 5
|
||||||
|
|
||||||
|
|
||||||
# ─── API endpoints ────────────────────────────────────────────────────────────
|
# ─── API endpoints ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/api/cache/clear")
|
@app.get("/api/cache/clear")
|
||||||
@@ -231,7 +367,6 @@ async def get_tile(z: int, x: int, y: int):
|
|||||||
if x < 0 or x >= max_coord or y < 0 or y >= max_coord:
|
if x < 0 or x >= max_coord or y < 0 or y >= max_coord:
|
||||||
raise HTTPException(400, "Invalid x/y for zoom level")
|
raise HTTPException(400, "Invalid x/y for zoom level")
|
||||||
|
|
||||||
# Проверяем кэш до обращения к БД
|
|
||||||
cached = get_cached_tile(z, x, y)
|
cached = get_cached_tile(z, x, y)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return Response(
|
return Response(
|
||||||
@@ -245,7 +380,6 @@ async def get_tile(z: int, x: int, y: int):
|
|||||||
|
|
||||||
west, south, east, north = tile_to_bbox(z, x, y)
|
west, south, east, north = tile_to_bbox(z, x, y)
|
||||||
|
|
||||||
# Расширенный bbox для SQL-запроса (на 15% за каждую сторону)
|
|
||||||
buf_x = (east - west) * 0.15
|
buf_x = (east - west) * 0.15
|
||||||
buf_y = (north - south) * 0.15
|
buf_y = (north - south) * 0.15
|
||||||
q_west = west - buf_x
|
q_west = west - buf_x
|
||||||
@@ -262,7 +396,6 @@ async def get_tile(z: int, x: int, y: int):
|
|||||||
else:
|
else:
|
||||||
limit = 25000
|
limit = 25000
|
||||||
|
|
||||||
# Минимальная длина трека по зуму — фильтруем мусор на низких зумах
|
|
||||||
if z <= 7:
|
if z <= 7:
|
||||||
min_length = 2000
|
min_length = 2000
|
||||||
elif z == 8:
|
elif z == 8:
|
||||||
@@ -302,7 +435,6 @@ async def get_tile(z: int, x: int, y: int):
|
|||||||
|
|
||||||
mvt = build_mvt(trails_rows, poi_rows, z, x, y)
|
mvt = build_mvt(trails_rows, poi_rows, z, x, y)
|
||||||
|
|
||||||
# Кэшируем только непустые тайлы
|
|
||||||
if mvt:
|
if mvt:
|
||||||
set_cached_tile(z, x, y, mvt)
|
set_cached_tile(z, x, y, mvt)
|
||||||
|
|
||||||
@@ -317,12 +449,78 @@ async def get_tile(z: int, x: int, y: int):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/route")
|
||||||
|
async def post_route(req: RouteRequest):
|
||||||
|
"""
|
||||||
|
Роутинг через OSRM с альтернативными маршрутами и статистикой покрытия.
|
||||||
|
Принимает JSON: {"waypoints": [{"lon":..,"lat":..}, ...], "alternatives": 5}
|
||||||
|
"""
|
||||||
|
if len(req.waypoints) < 2:
|
||||||
|
raise HTTPException(400, "Нужно минимум 2 точки")
|
||||||
|
|
||||||
|
# Строим строку координат для OSRM
|
||||||
|
coords_str = ";".join(f"{wp.lon},{wp.lat}" for wp in req.waypoints)
|
||||||
|
alternatives = max(1, min(5, req.alternatives))
|
||||||
|
|
||||||
|
url = (
|
||||||
|
f"{OSRM_URL}/route/v1/driving/{coords_str}"
|
||||||
|
f"?alternatives={alternatives}&overview=full&geometries=geojson&annotations=false"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
data = resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(503, f"OSRM недоступен: {e}")
|
||||||
|
|
||||||
|
if data.get("code") != "Ok" or not data.get("routes"):
|
||||||
|
raise HTTPException(404, "Маршрут не найден")
|
||||||
|
|
||||||
|
# Открываем БД один раз для всех маршрутов
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
except Exception as e:
|
||||||
|
conn = None
|
||||||
|
|
||||||
|
routes_out = []
|
||||||
|
for idx, route in enumerate(data["routes"]):
|
||||||
|
geometry = route["geometry"]
|
||||||
|
distance_m = route["distance"]
|
||||||
|
duration_s = route["duration"]
|
||||||
|
|
||||||
|
# Считаем статистику покрытия
|
||||||
|
stats = None
|
||||||
|
if conn is not None:
|
||||||
|
try:
|
||||||
|
stats = calc_route_stats(geometry, conn)
|
||||||
|
except Exception:
|
||||||
|
stats = None
|
||||||
|
|
||||||
|
routes_out.append({
|
||||||
|
"index": idx,
|
||||||
|
"geometry": geometry,
|
||||||
|
"distance_m": round(distance_m),
|
||||||
|
"duration_s": round(duration_s),
|
||||||
|
"stats": stats,
|
||||||
|
})
|
||||||
|
|
||||||
|
if conn is not None:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {"routes": routes_out}
|
||||||
|
|
||||||
|
|
||||||
|
# Обратная совместимость — старый GET endpoint (для линейки и прочего)
|
||||||
@app.get("/api/route")
|
@app.get("/api/route")
|
||||||
async def get_route(
|
async def get_route(
|
||||||
from_lon: float, from_lat: float,
|
from_lon: float, from_lat: float,
|
||||||
to_lon: float, to_lat: float
|
to_lon: float, to_lat: float
|
||||||
):
|
):
|
||||||
"""Роутинг через OSRM. Параметры: from_lon, from_lat, to_lon, to_lat"""
|
"""Роутинг через OSRM (legacy GET). Параметры: from_lon, from_lat, to_lon, to_lat"""
|
||||||
url = (
|
url = (
|
||||||
f"{OSRM_URL}/route/v1/driving/"
|
f"{OSRM_URL}/route/v1/driving/"
|
||||||
f"{from_lon},{from_lat};{to_lon},{to_lat}"
|
f"{from_lon},{from_lat};{to_lon},{to_lat}"
|
||||||
@@ -350,6 +548,7 @@ async def get_route(
|
|||||||
"distance_m": round(distance_m),
|
"distance_m": round(distance_m),
|
||||||
"distance_km": round(distance_m / 1000, 1),
|
"distance_km": round(distance_m / 1000, 1),
|
||||||
"duration_min": round(duration_s / 60),
|
"duration_min": round(duration_s / 60),
|
||||||
|
"duration_s": round(duration_s),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -341,3 +341,111 @@ body {
|
|||||||
0% { transform: translate(-50%, -50%) scale(0.5); opacity: 1; }
|
0% { transform: translate(-50%, -50%) scale(0.5); opacity: 1; }
|
||||||
100% { transform: translate(-50%, -50%) scale(1.5); opacity: 0; }
|
100% { transform: translate(-50%, -50%) scale(1.5); opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Фаза 3: Карточки маршрутов ─────────────────────────────────────────── */
|
||||||
|
.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; font-size: 13px; }
|
||||||
|
.route-card-dist { color: #333; font-weight: 600; font-size: 13px; }
|
||||||
|
.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-card-details { margin-top: 6px; }
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.route-details-toggle:hover { color: #ff6600; }
|
||||||
|
|
||||||
|
/* ─── Фаза 3: Панель точек маршрута ──────────────────────────────────────── */
|
||||||
|
.waypoint-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.waypoint-row:last-child { border-bottom: none; }
|
||||||
|
.waypoint-row.drag-over { background: #fff3e0; border-radius: 4px; }
|
||||||
|
|
||||||
|
.waypoint-label {
|
||||||
|
width: 20px; height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 10px; font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.waypoint-label.start { background: #00aa44; }
|
||||||
|
.waypoint-label.end { background: #cc0000; }
|
||||||
|
.waypoint-label.mid { background: #fff; color: #0066ff; border: 2px solid #0066ff; }
|
||||||
|
|
||||||
|
.waypoint-coords {
|
||||||
|
flex: 1;
|
||||||
|
color: #555;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waypoint-remove {
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
color: #aaa; font-size: 14px; padding: 0 2px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.waypoint-remove:hover { color: #cc0000; }
|
||||||
|
|
||||||
|
/* ─── Фаза 3: Маркеры на карте ───────────────────────────────────────────── */
|
||||||
|
.route-waypoint-marker {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 10px; font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.named-marker-el {
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
filter: drop-shadow(0 1px 3px rgba(0,0,0,0.4));
|
||||||
|
user-select: none;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,14 +94,40 @@
|
|||||||
|
|
||||||
<div id="stats">Zoom: <span id="zoom-val">7</span> | Координаты: <span id="coords-val">—</span></div>
|
<div id="stats">Zoom: <span id="zoom-val">7</span> | Координаты: <span id="coords-val">—</span></div>
|
||||||
|
|
||||||
<div id="route-panel" style="display:none; position:absolute; bottom:40px; left:12px; background:rgba(255,255,255,0.97); border:1px solid #ddd; border-radius:6px; padding:12px 14px; font-size:13px; z-index:5; min-width:180px; box-shadow:0 2px 8px rgba(0,0,0,0.12);">
|
<!-- ─── Панель маршрута (Фаза 3) ─────────────────────────────────────── -->
|
||||||
<div style="font-weight:600; color:#e07b00; margin-bottom:8px;">🗺️ Маршрут</div>
|
<div id="route-panel" style="display:none; position:absolute; bottom:40px; right:10px;
|
||||||
<div id="route-status" style="color:#888; font-size:12px;">Кликни точку старта</div>
|
background:rgba(255,255,255,0.97); border:1px solid #ddd; border-radius:8px;
|
||||||
<div id="route-info" style="display:none;">
|
padding:12px; font-size:13px; z-index:5; width:280px;
|
||||||
<div class="popup-row"><span class="popup-key">Дистанция</span><span class="popup-val" id="route-distance">—</span></div>
|
box-shadow:0 2px 12px rgba(0,0,0,0.15); max-height:70vh; overflow-y:auto;">
|
||||||
<div class="popup-row"><span class="popup-key">Время</span><span class="popup-val" id="route-duration">—</span></div>
|
|
||||||
<button onclick="clearRoute()" style="margin-top:8px; width:100%; padding:4px; background:#f0f0f0; border:1px solid #ccc; border-radius:4px; cursor:pointer; font-size:12px;">✕ Сбросить</button>
|
<!-- Панель точек -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<div id="map-controls-br" class="custom-map-ctrl">
|
<div id="map-controls-br" class="custom-map-ctrl">
|
||||||
@@ -109,6 +135,7 @@
|
|||||||
<button id="btn-route" class="map-ctrl-btn" title="Построить маршрут" onclick="toggleRouteMode()">🗺️</button>
|
<button id="btn-route" class="map-ctrl-btn" title="Построить маршрут" onclick="toggleRouteMode()">🗺️</button>
|
||||||
<button id="btn-locate" class="map-ctrl-btn" title="Моё местоположение" onclick="locateMe()">📍</button>
|
<button id="btn-locate" class="map-ctrl-btn" title="Моё местоположение" onclick="locateMe()">📍</button>
|
||||||
<button id="btn-ruler" class="map-ctrl-btn" title="Измерить расстояние" onclick="toggleRuler()">📏</button>
|
<button id="btn-ruler" class="map-ctrl-btn" title="Измерить расстояние" onclick="toggleRuler()">📏</button>
|
||||||
|
<button id="btn-markers" class="map-ctrl-btn" title="Добавить метку" onclick="toggleMarkerMode()">🚩</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user