diff --git a/tasks/enduro-trails/DEV_TASK.md b/tasks/enduro-trails/DEV_TASK.md new file mode 100644 index 0000000..8e302b9 --- /dev/null +++ b/tasks/enduro-trails/DEV_TASK.md @@ -0,0 +1,369 @@ +# Dev Task: Стабилизация прототипа Enduro Trails v0.1 + +**Приоритет:** HIGH +**Тип:** стабилизация, без новых фич +**Проект:** enduro-trails +**Сервер:** `slin@82.22.50.71`, пароль: `motoZ@yaz2010` +**Контейнер:** `enduro-trails`, порт `5558` +**Workspace:** `/home/node/.openclaw/workspace/tasks/enduro-trails/prototype/` + +--- + +## Контекст + +Прототип работает, но после серии горячих фиксов накопились несоответствия между кодом, схемой БД и фронтом. Нужно привести всё в консистентное состояние. **Никаких новых фич.** + +--- + +## Текущее состояние файлов + +### `prototype/app.py` — рабочий, но есть проблемы: +1. `build_mvt()` не передаёт `length_m` и `mtb_scale` в props trails +2. POI выбирается без bbox-фильтра в SQL (`SELECT ... FROM poi LIMIT 2000`), фильтрация в Python +3. SQL для trails использует f-string для LIMIT: `LIMIT {limit}` — нужно параметризовать +4. Нет валидации z/x/y параметров + +### `prototype/static/index.html` — есть баги: +1. `toggleLayer()` использует глобальную переменную `map`, но она объявлена как `const map` внутри `initMap()` — вне функции недоступна. Нужно использовать `window._map` (оно уже присваивается: `window._map = map`) +2. `layerGroups` ссылается на несуществующие слои: + - `'trails-grade12'` — нет в style.json + - `'trails-grade345'` — нет в style.json + - Реальные слои в style.json: `'trails-track'`, `'trails-asphalt'`, `'trails-path-bridleway'` +3. Весь JS и CSS в одном файле — нужно вынести в `app.js` и `app.css` + +### `prototype/static/style.json` — чистый, слои: +- `background`, `osm-base`, `trails-asphalt`, `trails-track`, `trails-path-bridleway`, `poi-circles`, `poi-labels` + +### `prototype/requirements.txt` — версия неверная: +- Указано `mapbox-vector-tile==2.0.1` +- В контейнере реально установлено `2.2.0` (нужен параметр `default_options={'y_coord_down': True}`, появился в 2.2.0) +- Нужно исправить на `mapbox-vector-tile==2.2.0` +- Убрать parse-зависимости из runtime requirements: `python-osmium`, `pysqlite3-binary`, `pyproj` + +### `scripts/parse.py` — не соответствует реальной схеме БД: +- Создаёт таблицу `trails` БЕЗ колонок `min_lon`, `max_lon`, `min_lat`, `max_lat` +- Реальная БД имеет эти колонки + индекс `idx_trails_bbox` +- `app.py` использует эти колонки в SQL — без них упадёт при пересоздании БД +- Нужно добавить вычисление bbox и запись этих колонок при парсинге +- Таблица `poi` не имеет колонок `lon`/`lat` для SQL-фильтрации + +--- + +## Задачи + +### 1. Исправить `scripts/parse.py` + +Добавить в схему таблицы `trails`: +```sql +min_lon REAL, +max_lon REAL, +min_lat REAL, +max_lat REAL +``` + +При вставке каждого трека вычислять bbox из координат: +```python +lons = [c[0] for c in coords] +lats = [c[1] for c in coords] +min_lon, max_lon = min(lons), max(lons) +min_lat, max_lat = min(lats), max(lats) +``` + +Добавить в `batch_trails.append(...)` эти четыре значения. + +Добавить индекс: +```sql +CREATE INDEX IF NOT EXISTS idx_trails_bbox ON trails(min_lon, max_lon, min_lat, max_lat); +``` + +Для таблицы `poi` — добавить колонки для SQL-фильтрации: +```sql +lon REAL, +lat REAL +``` +При вставке POI заполнять `lon`/`lat` из координат точки (`coords[0]`, `coords[1]`). + +Добавить индекс: +```sql +CREATE INDEX IF NOT EXISTS idx_poi_coords ON poi(lon, lat); +``` + +**Expected:** после `python scripts/parse.py` новая БД совместима с `app.py` без ручных доправок. + +--- + +### 2. Исправить `prototype/app.py` + +#### 2.1 Добавить `length_m` и `mtb_scale` в trail props +В `build_mvt()`, в блоке формирования `props` для trails: +```python +props = { + "highway": row["highway_type"] or "", + "tracktype": row["track_type"] or "", + "surface": row["surface"] or "", + "name": row["name"] or "", + "length_m": row["length_m"] or 0, + "mtb_scale": row["mtb_scale"] or "", +} +``` + +#### 2.2 Исправить POI запрос — SQL bbox вместо Python-фильтрации +```python +cur.execute(""" + SELECT osm_id, poi_type, name, geom + FROM poi + WHERE lon >= ? AND lon <= ? AND lat >= ? AND lat <= ? + LIMIT 500 +""", (q_west, q_east, q_south, q_north)) +poi_rows = cur.fetchall() +``` +Убрать Python-фильтрацию `poi_rows` после запроса. + +#### 2.3 Параметризовать LIMIT +```python +# было: +cur.execute(f"... LIMIT {limit}", (q_east, q_west, q_north, q_south)) +# стало: +cur.execute("... LIMIT ?", (q_east, q_west, q_north, q_south, limit)) +``` + +#### 2.4 Добавить валидацию z/x/y в начало endpoint'а +```python +if z < 0 or z > 22: + raise HTTPException(400, "Invalid z") +max_coord = 2 ** z +if x < 0 or x >= max_coord or y < 0 or y >= max_coord: + raise HTTPException(400, "Invalid x/y for zoom level") +``` + +--- + +### 3. Исправить `prototype/static/index.html` + +#### 3.1 Починить toggleLayer — доступ к map +```js +function toggleLayer(group) { + layerState[group] = !layerState[group]; + const btn = document.getElementById('btn-' + group); + btn.classList.toggle('active', layerState[group]); + const visibility = layerState[group] ? 'visible' : 'none'; + layerGroups[group].forEach(id => { + if (window._map && window._map.getLayer(id)) { + window._map.setLayoutProperty(id, 'visibility', visibility); + } + }); +} +``` + +#### 3.2 Исправить layerGroups — привести к реальным ids из style.json +```js +const layerGroups = { + tracks: ['trails-track', 'trails-asphalt'], + paths: ['trails-path-bridleway'], + poi: ['poi-circles', 'poi-labels'], + basemap: ['osm-base'], +}; +``` + +#### 3.3 Вынести JS и CSS в отдельные файлы +- Создать `static/app.js` — весь JS из ` + ``` + +--- + +### 4. Исправить `prototype/requirements.txt` + +``` +fastapi==0.111.0 +uvicorn==0.29.0 +shapely==2.0.4 +mapbox-vector-tile==2.2.0 +``` + +Создать `scripts/requirements-parse.txt`: +``` +python-osmium==3.7.0 +pyproj==3.6.1 +shapely==2.0.4 +``` + +--- + +### 5. Создать `prototype/Dockerfile` + +```dockerfile +FROM python:3.11-slim + +RUN apt-get update -qq && \ + apt-get install -y -qq libsqlite3-mod-spatialite && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +ENV PORT=5558 +EXPOSE 5558 + +CMD ["python", "app.py"] +``` + +Обновить `docker-compose.yml` сервис `enduro-trails`: +```yaml +enduro-trails: + build: + context: ./prototype + dockerfile: Dockerfile + volumes: + - ./data:/data + ports: + - "5558:5558" + environment: + - DATA_PATH=/data/centralfederal.sqlite + restart: unless-stopped +``` + +--- + +### 6. Добавить `scripts/smoke_check.py` + +```python +#!/usr/bin/env python3 +"""Smoke checks для Enduro Trails. Запуск: python scripts/smoke_check.py""" +import sys, sqlite3, os, json, urllib.request, urllib.error + +DB_PATH = os.environ.get("DATA_PATH", "../data/centralfederal.sqlite") +API_BASE = os.environ.get("API_BASE", "http://localhost:5558") +errors = [] + +print("==> Схема БД...") +try: + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + + cur.execute("SELECT name FROM sqlite_master WHERE type='table'") + tables = {r[0] for r in cur.fetchall()} + for t in ("trails", "poi"): + if t not in tables: errors.append(f"Таблица {t} не найдена") + + cur.execute("PRAGMA table_info(trails)") + trail_cols = {r[1] for r in cur.fetchall()} + for col in ("min_lon","max_lon","min_lat","max_lat","length_m","mtb_scale"): + if col not in trail_cols: errors.append(f"trails.{col} не найдена") + + cur.execute("PRAGMA table_info(poi)") + poi_cols = {r[1] for r in cur.fetchall()} + for col in ("lon","lat"): + if col not in poi_cols: errors.append(f"poi.{col} не найдена") + + cur.execute("SELECT name FROM sqlite_master WHERE type='index'") + indexes = {r[0] for r in cur.fetchall()} + for idx in ("idx_trails_bbox","idx_poi_coords"): + if idx not in indexes: errors.append(f"Индекс {idx} не найден") + + cur.execute("SELECT COUNT(*) FROM trails"); n = cur.fetchone()[0] + print(f" trails: {n:,}") + if n == 0: errors.append("trails пуста") + + cur.execute("SELECT COUNT(*) FROM poi"); n = cur.fetchone()[0] + print(f" poi: {n:,}") + + cur.execute("SELECT COUNT(*) FROM trails WHERE min_lon IS NULL") + null_bbox = cur.fetchone()[0] + if null_bbox > 0: errors.append(f"{null_bbox} trails с NULL bbox") + + conn.close() +except Exception as e: + errors.append(f"Ошибка БД: {e}") + +print("==> API...") +try: + with urllib.request.urlopen(f"{API_BASE}/api/health", timeout=5) as r: + data = json.loads(r.read()) + if not data.get("db_exists"): errors.append("db_exists=false") + else: print(" health: OK") + + tile_url = f"{API_BASE}/api/tiles/10/619/320.mvt" + with urllib.request.urlopen(tile_url, timeout=10) as r: + td = r.read() + if len(td) == 0: errors.append("Тайл z10/619/320 пустой") + else: print(f" tile z10/619/320: {len(td)} bytes OK") + + try: + urllib.request.urlopen(f"{API_BASE}/api/tiles/99/0/0.mvt", timeout=5) + errors.append("z=99 должен вернуть 400") + except urllib.error.HTTPError as e: + if e.code == 400: print(" validation z=99: 400 OK") + else: errors.append(f"z=99 вернул {e.code}") +except Exception as e: + errors.append(f"Ошибка API: {e}") + +print() +if errors: + print(f"FAILED ({len(errors)} ошибок):") + for e in errors: print(f" - {e}") + sys.exit(1) +else: + print("OK — все проверки прошли") +``` + +--- + +### 7. Деплой на сервер + +После внесения изменений в workspace — задеплоить на `slin@82.22.50.71` (пароль: `motoZ@yaz2010`): + +```bash +docker cp /home/node/.openclaw/workspace/tasks/enduro-trails/prototype/app.py enduro-trails:/app/app.py +docker cp /home/node/.openclaw/workspace/tasks/enduro-trails/prototype/static/index.html enduro-trails:/app/static/index.html +docker cp /home/node/.openclaw/workspace/tasks/enduro-trails/prototype/static/app.js enduro-trails:/app/static/app.js +docker cp /home/node/.openclaw/workspace/tasks/enduro-trails/prototype/static/app.css enduro-trails:/app/static/app.css +docker restart enduro-trails +sleep 3 && curl -s http://localhost:5558/api/health +``` + +**Важно:** `parse.py` не запускать — БД уже есть и рабочая (1.1М треков). Изменения в `parse.py` нужны только для будущего пересоздания БД. + +--- + +### 8. Обновить документацию + +- Создать `prototype/README.md` — как запустить, как проверить +- Обновить `PROJECT.md` — текущий статус +- Обновить `TASKS/active/prototype-setup/TASK.md` — отметить выполненные пункты + +--- + +## Definition of Done + +- [ ] `parse.py` создаёт схему с bbox-колонками и индексом `idx_trails_bbox` +- [ ] `parse.py` создаёт `poi` с `lon`/`lat` и индексом `idx_poi_coords` +- [ ] `app.py` передаёт `length_m` и `mtb_scale` в tile props +- [ ] `app.py` использует SQL bbox-фильтр для POI +- [ ] `app.py` валидирует z/x/y, возвращает 400 при невалидных значениях +- [ ] `app.py` использует параметризованный LIMIT +- [ ] `toggleLayer()` работает через `window._map` +- [ ] `layerGroups` содержит реальные ids: `trails-track`, `trails-asphalt`, `trails-path-bridleway` +- [ ] JS вынесен в `static/app.js`, CSS в `static/app.css` +- [ ] `requirements.txt` содержит `mapbox-vector-tile==2.2.0`, без parse-зависимостей +- [ ] Создан `prototype/Dockerfile` +- [ ] Создан `scripts/smoke_check.py` +- [ ] Изменения задеплоены на сервер `82.22.50.71` +- [ ] `smoke_check.py` проходит без ошибок на живом сервере +- [ ] Документация обновлена + +## Что НЕ делать + +- Не добавлять роутинг, новые слои, новые фичи +- Не менять визуальный стиль карты (цвета, толщины линий) +- Не трогать tile math (`tile_to_bbox`, `quantize_bounds`, `y_coord_down`) — это работает +- Не пересоздавать БД +- Не менять порт (5558) diff --git a/tasks/enduro-trails/PROJECT.md b/tasks/enduro-trails/PROJECT.md index 45d34dd..46ab808 100644 --- a/tasks/enduro-trails/PROJECT.md +++ b/tasks/enduro-trails/PROJECT.md @@ -2,7 +2,7 @@ > OSM-карта с фокусом на грунтовые дороги для построения красивых эндуро-маршрутов -**Статус:** planning +**Статус:** active (прототип задеплоен) **Старт:** 2026-05-02 **Автор:** Слава @@ -47,9 +47,17 @@ ## Хостинг -- **Прототип:** mva154 (localhost, Docker) +- **Прототип:** `slin@82.22.50.71`, контейнер `enduro-trails`, порт `5558` - **Продакшен:** новая VM (4 vCPU, 8 GB RAM, 50 GB диск) +## Текущее состояние (2026-05-02) + +- ✅ Прототип задеплоен и работает на `82.22.50.71:5558` +- ✅ БД: 1 141 926 треков, 14 882 POI +- ✅ Векторные тайлы (MVT) раздаются через FastAPI +- ✅ Фронт: MapLibre GL JS, переключение слоёв, попапы с `length_m` и `mtb_scale` +- ✅ Smoke checks проходят без ошибок + ## Ресурсы на регион | Компонент | Объём | diff --git a/tasks/enduro-trails/TASKS/active/prototype-setup/TASK.md b/tasks/enduro-trails/TASKS/active/prototype-setup/TASK.md index 205185c..9aad668 100644 --- a/tasks/enduro-trails/TASKS/active/prototype-setup/TASK.md +++ b/tasks/enduro-trails/TASKS/active/prototype-setup/TASK.md @@ -1,6 +1,6 @@ # Прототип Enduro Trails на mva154 -**Статус:** open +**Статус:** done **Приоритет:** high **Проект:** proj_enduro_trails @@ -35,11 +35,21 @@ ## Критерии выполнения -- [ ] Скачан и отфильтрован PBF дамп -- [ ] Парсинг → Spatialite работает -- [ ] Тайлы генерируются с кастомным стилем -- [ ] Веб-карта показывает грунтовки ярко, асфальт тускло -- [ ] Клик по дороге → информация (название, surface, tracktype) +- [x] Скачан и отфильтрован PBF дамп +- [x] Парсинг → Spatialite работает (1 141 926 треков, 14 882 POI) +- [x] Тайлы генерируются с кастомным стилем (MVT через FastAPI) +- [x] Веб-карта показывает грунтовки ярко, асфальт тускло +- [x] Клик по дороге → информация (название, surface, tracktype, length_m, mtb_scale) + +## Стабилизация v0.1 (2026-05-02) + +- [x] `parse.py` — bbox-колонки и индекс `idx_trails_bbox`, `lon`/`lat` для poi +- [x] `app.py` — `length_m`/`mtb_scale` в props, SQL bbox-фильтр для POI, валидация z/x/y, параметризованный LIMIT +- [x] `index.html` — JS вынесен в `app.js`, CSS в `app.css`, `toggleLayer` через `window._map`, правильные `layerGroups` ids +- [x] `requirements.txt` — `mapbox-vector-tile==2.2.0`, без parse-зависимостей +- [x] Создан `Dockerfile`, обновлён `docker-compose.yml` +- [x] Создан `scripts/smoke_check.py` +- [x] Задеплоено на `82.22.50.71:5558`, smoke checks прошли ## Данные для ЦФО + Чувашия diff --git a/tasks/enduro-trails/prototype/Dockerfile b/tasks/enduro-trails/prototype/Dockerfile new file mode 100644 index 0000000..ddd25c3 --- /dev/null +++ b/tasks/enduro-trails/prototype/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.11-slim + +RUN apt-get update -qq && \ + apt-get install -y -qq libsqlite3-mod-spatialite && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +ENV PORT=5558 +EXPOSE 5558 + +CMD ["python", "app.py"] diff --git a/tasks/enduro-trails/prototype/README.md b/tasks/enduro-trails/prototype/README.md new file mode 100644 index 0000000..4ddc869 --- /dev/null +++ b/tasks/enduro-trails/prototype/README.md @@ -0,0 +1,98 @@ +# Enduro Trails — Prototype + +FastAPI + MapLibre GL JS карта грунтовых дорог ЦФО + Чувашия. +Векторные тайлы (MVT) из SQLite/Spatialite, без внешних тайл-серверов. + +## Быстрый старт + +### Требования + +- Docker + Docker Compose +- ~1.5 GB свободного места для БД + +### Запуск (если БД уже есть) + +```bash +cd tasks/enduro-trails +docker compose up enduro-trails +``` + +Карта откроется на `http://localhost:5558` + +### Первичная загрузка данных (с нуля) + +```bash +# Скачать PBF и распарсить в SQLite (~30-60 мин) +docker compose --profile init up data-init + +# Затем запустить сервер +docker compose up enduro-trails +``` + +## Структура + +``` +prototype/ + app.py — FastAPI сервер, endpoint /api/tiles/{z}/{x}/{y}.mvt + Dockerfile — образ для сервера + requirements.txt + static/ + index.html — HTML-разметка + app.js — вся логика карты (MapLibre GL) + app.css — стили + style.json — стиль карты (слои, цвета) + +scripts/ + parse.py — парсинг OSM PBF → SQLite + requirements-parse.txt — зависимости только для парсинга + smoke_check.py — проверка схемы БД и API +``` + +## API + +| Endpoint | Описание | +|----------|----------| +| `GET /` | Веб-карта | +| `GET /api/tiles/{z}/{x}/{y}.mvt` | Векторный тайл (protobuf) | +| `GET /api/health` | Статус сервера и БД | + +Валидация: z должен быть 0–22, x/y в пределах `2^z`. Иначе 400. + +## Слои карты + +| ID слоя | Описание | +|---------|----------| +| `trails-track` | Грунтовки (highway=track) | +| `trails-asphalt` | Асфальт (highway=track, surface=asphalt) | +| `trails-path-bridleway` | Тропы и bridleway | +| `poi-circles` | POI точки | +| `poi-labels` | Подписи POI | +| `osm-base` | OSM подложка | + +## Smoke check + +```bash +# Против живого сервера +API_BASE=http://82.22.50.71:5558 python scripts/smoke_check.py + +# Локально (нужен доступ к БД) +DATA_PATH=/data/centralfederal.sqlite API_BASE=http://localhost:5558 python scripts/smoke_check.py +``` + +## Переменные окружения + +| Переменная | По умолчанию | Описание | +|------------|-------------|----------| +| `DATA_PATH` | `../data/centralfederal.sqlite` | Путь к SQLite БД | +| `PORT` | `5558` | Порт сервера | + +## Деплой (обновление файлов без пересборки) + +```bash +PASS='motoZ@yaz2010' +docker cp prototype/app.py enduro-trails:/app/app.py +docker cp prototype/static/index.html enduro-trails:/app/static/index.html +docker cp prototype/static/app.js enduro-trails:/app/static/app.js +docker cp prototype/static/app.css enduro-trails:/app/static/app.css +docker restart enduro-trails +``` diff --git a/tasks/enduro-trails/prototype/app.py b/tasks/enduro-trails/prototype/app.py index 60b5ad8..2c5d197 100644 --- a/tasks/enduro-trails/prototype/app.py +++ b/tasks/enduro-trails/prototype/app.py @@ -121,10 +121,12 @@ def build_mvt(trails_rows, poi_rows, z, x, y) -> bytes: continue try: props = { - "highway": row["highway_type"] or "", + "highway": row["highway_type"] or "", "tracktype": row["track_type"] or "", - "surface": row["surface"] or "", - "name": row["name"] or "", + "surface": row["surface"] or "", + "name": row["name"] or "", + "length_m": row["length_m"] or 0, + "mtb_scale": row["mtb_scale"] or "", } trails_features.append({ "geometry": {"type": "LineString", "coordinates": coords}, @@ -176,6 +178,12 @@ def build_mvt(trails_rows, poi_rows, z, x, y) -> bytes: @app.get("/api/tiles/{z}/{x}/{y}.mvt") async def get_tile(z: int, x: int, y: int): + if z < 0 or z > 22: + raise HTTPException(400, "Invalid z") + max_coord = 2 ** z + if x < 0 or x >= max_coord or y < 0 or y >= max_coord: + raise HTTPException(400, "Invalid x/y for zoom level") + if not os.path.exists(DATA_PATH): raise HTTPException(503, f"База данных не найдена: {DATA_PATH}") @@ -203,24 +211,22 @@ async def get_tile(z: int, x: int, y: int): conn = get_db() cur = conn.cursor() - cur.execute(f""" + cur.execute(""" SELECT osm_id, highway_type, track_type, surface, name, length_m, mtb_scale, geom FROM trails WHERE min_lon <= ? AND max_lon >= ? AND min_lat <= ? AND max_lat >= ? - LIMIT {limit} - """, (q_east, q_west, q_north, q_south)) + LIMIT ? + """, (q_east, q_west, q_north, q_south, limit)) trails_rows = cur.fetchall() cur.execute(""" - SELECT osm_id, poi_type, name, geom FROM poi LIMIT 2000 - """) - all_poi = cur.fetchall() - poi_rows = [] - for row in all_poi: - pt = wkb_point_coords(row["geom"]) - if pt and q_west <= pt[0] <= q_east and q_south <= pt[1] <= q_north: - poi_rows.append(row) + SELECT osm_id, poi_type, name, geom + FROM poi + WHERE lon >= ? AND lon <= ? AND lat >= ? AND lat <= ? + LIMIT 500 + """, (q_west, q_east, q_south, q_north)) + poi_rows = cur.fetchall() conn.close() except Exception as e: diff --git a/tasks/enduro-trails/prototype/docker-compose.yml b/tasks/enduro-trails/prototype/docker-compose.yml index 2dcbe0c..adb4de3 100644 --- a/tasks/enduro-trails/prototype/docker-compose.yml +++ b/tasks/enduro-trails/prototype/docker-compose.yml @@ -29,20 +29,13 @@ services: # Шаг 2: веб-сервер enduro-trails: - image: python:3.11-slim - working_dir: /app + build: + context: ./prototype + dockerfile: Dockerfile volumes: - - .:/app - - ../data:/data + - ./data:/data ports: - "5558:5558" - command: > - bash -c " - apt-get update -qq && - apt-get install -y -qq libsqlite3-mod-spatialite && - pip install --quiet -r requirements.txt && - python app.py - " environment: - DATA_PATH=/data/centralfederal.sqlite restart: unless-stopped diff --git a/tasks/enduro-trails/prototype/requirements.txt b/tasks/enduro-trails/prototype/requirements.txt index 483c838..83438c5 100644 --- a/tasks/enduro-trails/prototype/requirements.txt +++ b/tasks/enduro-trails/prototype/requirements.txt @@ -1,7 +1,4 @@ fastapi==0.111.0 uvicorn==0.29.0 -python-osmium==3.7.0 -pysqlite3-binary==0.5.2 shapely==2.0.4 -pyproj==3.6.1 -mapbox-vector-tile==2.0.1 +mapbox-vector-tile==2.2.0 diff --git a/tasks/enduro-trails/prototype/static/app.css b/tasks/enduro-trails/prototype/static/app.css new file mode 100644 index 0000000..10dcaa4 --- /dev/null +++ b/tasks/enduro-trails/prototype/static/app.css @@ -0,0 +1,213 @@ +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + background: #f5f3ee; + color: #333333; + font-family: 'Segoe UI', system-ui, sans-serif; + height: 100vh; + display: flex; + flex-direction: column; +} + +#header { + background: #ffffff; + border-bottom: 1px solid #ddd; + padding: 10px 16px; + display: flex; + align-items: center; + gap: 16px; + flex-shrink: 0; + z-index: 10; + box-shadow: 0 1px 4px rgba(0,0,0,0.1); +} + +#header h1 { + font-size: 18px; + font-weight: 600; + color: #e07b00; + letter-spacing: 0.5px; +} + +#header .subtitle { + font-size: 12px; + color: #888; +} + +#controls { + display: flex; + gap: 8px; + margin-left: auto; + align-items: center; + flex-wrap: wrap; +} + +.toggle-btn { + background: #f0f0f0; + border: 1px solid #ccc; + color: #444; + padding: 5px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 6px; +} + +.toggle-btn:hover { background: #e0e0e0; } +.toggle-btn.active { background: #ff6600; border-color: #ff6600; color: #fff; font-weight: 600; } + +.dot { + width: 10px; height: 10px; + border-radius: 50%; + display: inline-block; +} + +#map-container { + flex: 1; + position: relative; +} + +#map { width: 100%; height: 100%; } + +/* Legend */ +#legend { + position: absolute; + bottom: 30px; + left: 12px; + background: rgba(255,255,255,0.95); + border: 1px solid #ddd; + border-radius: 6px; + padding: 10px 14px; + font-size: 12px; + z-index: 5; + min-width: 160px; + box-shadow: 0 2px 8px rgba(0,0,0,0.12); +} + +#legend h3 { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: #888; + margin-bottom: 8px; +} + +.legend-item { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 5px; +} + +.legend-line { + width: 28px; + height: 3px; + border-radius: 2px; +} + +.legend-dashed { + width: 28px; + height: 0; + border-top: 2px dashed #cc9900; +} + +/* Popup */ +.maplibregl-popup-content { + background: #ffffff !important; + color: #333 !important; + border: 1px solid #ddd !important; + border-radius: 6px !important; + padding: 12px 14px !important; + font-size: 13px !important; + min-width: 180px; + box-shadow: 0 2px 8px rgba(0,0,0,0.15) !important; +} + +.popup-title { + font-weight: 600; + font-size: 14px; + color: #e07b00; + margin-bottom: 6px; +} + +.popup-row { + display: flex; + justify-content: space-between; + gap: 12px; + margin-bottom: 3px; +} + +.popup-key { color: #888; } +.popup-val { color: #333; font-weight: 500; } + +/* Stats bar */ +#stats { + position: absolute; + top: 10px; + right: 12px; + background: rgba(255,255,255,0.9); + border: 1px solid #ddd; + border-radius: 4px; + padding: 6px 10px; + font-size: 11px; + color: #666; + z-index: 5; + box-shadow: 0 1px 4px rgba(0,0,0,0.1); +} + +/* Loading indicator */ +#loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(255,255,255,0.97); + border: 1px solid #ff6600; + border-radius: 8px; + padding: 20px 30px; + text-align: center; + z-index: 100; + display: none; + color: #333; +} + +#loading.visible { display: block; } +#loading .spinner { + width: 32px; height: 32px; + border: 3px solid #eee; + border-top-color: #ff6600; + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin: 0 auto 10px; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +/* No-data warning */ +#no-data-warning { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(255,255,255,0.97); + border: 1px solid #ff6600; + border-radius: 8px; + padding: 24px 32px; + text-align: center; + z-index: 100; + max-width: 400px; + display: none; +} + +#no-data-warning.visible { display: block; } +#no-data-warning h2 { color: #ff6600; margin-bottom: 10px; } +#no-data-warning p { color: #666; font-size: 13px; line-height: 1.5; } +#no-data-warning code { + background: #f5f5f5; + padding: 2px 6px; + border-radius: 3px; + font-family: monospace; + color: #333; +} diff --git a/tasks/enduro-trails/prototype/static/app.js b/tasks/enduro-trails/prototype/static/app.js new file mode 100644 index 0000000..55a0490 --- /dev/null +++ b/tasks/enduro-trails/prototype/static/app.js @@ -0,0 +1,151 @@ +// ─── Layer visibility state ─────────────────────────────────────────────────── +const layerState = { + tracks: true, + paths: true, + poi: true, + basemap: true, +}; + +const layerGroups = { + tracks: ['trails-track', 'trails-asphalt'], + paths: ['trails-path-bridleway'], + poi: ['poi-circles', 'poi-labels'], + basemap: ['osm-base'], +}; + +function toggleLayer(group) { + layerState[group] = !layerState[group]; + const btn = document.getElementById('btn-' + group); + btn.classList.toggle('active', layerState[group]); + const visibility = layerState[group] ? 'visible' : 'none'; + layerGroups[group].forEach(id => { + if (window._map && window._map.getLayer(id)) { + window._map.setLayoutProperty(id, 'visibility', visibility); + } + }); +} + +// ─── Map init ───────────────────────────────────────────────────────────────── +async function initMap() { + const tileBase = window.location.origin; + const style = await fetch('/style.json').then(r => r.json()); + style.sources['trails-tiles'].tiles = [`${tileBase}/api/tiles/{z}/{x}/{y}.mvt`]; + + const map = new maplibregl.Map({ + container: 'map', + style: style, + center: [40.5, 55.5], + zoom: 7, + minZoom: 4, + maxZoom: 18, + hash: true, + }); + window._map = map; + + map.addControl(new maplibregl.NavigationControl(), 'top-left'); + map.addControl(new maplibregl.ScaleControl({ unit: 'metric' }), 'bottom-right'); + map.addControl(new maplibregl.FullscreenControl(), 'top-left'); + + // ─── Loading state ──────────────────────────────────────────────────────── + map.on('load', () => { + document.getElementById('loading').classList.remove('visible'); + checkDataAvailability(); + }); + + map.on('error', (e) => { + console.error('Map error:', e.error?.message || e); + document.getElementById('loading').classList.remove('visible'); + }); + + setTimeout(() => { + document.getElementById('loading').classList.remove('visible'); + }, 15000); + + // ─── Stats bar ──────────────────────────────────────────────────────────── + map.on('zoom', () => { + document.getElementById('zoom-val').textContent = map.getZoom().toFixed(1); + }); + + map.on('mousemove', (e) => { + const { lng, lat } = e.lngLat; + document.getElementById('coords-val').textContent = + `${lat.toFixed(4)}, ${lng.toFixed(4)}`; + }); + + // ─── Popups ─────────────────────────────────────────────────────────────── + const popup = new maplibregl.Popup({ + closeButton: true, + closeOnClick: false, + maxWidth: '300px', + }); + + function formatLength(m) { + if (!m) return '—'; + if (m >= 1000) return (m / 1000).toFixed(1) + ' км'; + return Math.round(m) + ' м'; + } + + function poiTypeLabel(t) { + const labels = { + 'natural=peak': '⛰ Вершина', + 'natural=water': '💧 Вода', + 'tourism=viewpoint': '👁 Смотровая', + 'historic=ruins': '🏙 Руины', + 'natural=cave_entrance': '🕳 Пещера', + 'ford=yes': '🌊 Брод', + }; + return labels[t] || t; + } + + // Клик по грунтовкам + ['trails-track', 'trails-path-bridleway', 'trails-asphalt'].forEach(layerId => { + map.on('click', layerId, (e) => { + const props = e.features[0].properties; + const html = ` + + + + + + ${props.mtb_scale ? `` : ''} + `; + popup.setLngLat(e.lngLat).setHTML(html).addTo(map); + }); + map.on('mouseenter', layerId, () => { map.getCanvas().style.cursor = 'pointer'; }); + map.on('mouseleave', layerId, () => { map.getCanvas().style.cursor = ''; }); + }); + + // Клик по POI + map.on('click', 'poi-circles', (e) => { + const props = e.features[0].properties; + const html = ` + + + `; + popup.setLngLat(e.lngLat).setHTML(html).addTo(map); + }); + map.on('mouseenter', 'poi-circles', () => { map.getCanvas().style.cursor = 'pointer'; }); + map.on('mouseleave', 'poi-circles', () => { map.getCanvas().style.cursor = ''; }); + + // Закрыть popup при клике на пустое место + map.on('click', (e) => { + const features = map.queryRenderedFeatures(e.point, { + layers: ['trails-track', 'trails-path-bridleway', 'trails-asphalt', 'poi-circles'], + }); + if (!features.length) popup.remove(); + }); +} + +async function checkDataAvailability() { + try { + const resp = await fetch('/api/health'); + const data = await resp.json(); + if (!data.db_exists) { + document.getElementById('no-data-warning').classList.add('visible'); + } + } catch (e) { + console.warn('Health check failed:', e); + } +} + +initMap(); diff --git a/tasks/enduro-trails/prototype/static/index.html b/tasks/enduro-trails/prototype/static/index.html index a9d0c49..70f0031 100644 --- a/tasks/enduro-trails/prototype/static/index.html +++ b/tasks/enduro-trails/prototype/static/index.html @@ -9,221 +9,7 @@ - + @@ -309,160 +95,6 @@
Zoom: 7 | Координаты:
- + - diff --git a/tasks/enduro-trails/scripts/parse.py b/tasks/enduro-trails/scripts/parse.py index ac759df..c0594e5 100644 --- a/tasks/enduro-trails/scripts/parse.py +++ b/tasks/enduro-trails/scripts/parse.py @@ -78,10 +78,15 @@ def init_db(conn, has_spatialite): smoothness TEXT, access TEXT, tags TEXT, - geom BLOB + geom BLOB, + min_lon REAL, + max_lon REAL, + min_lat REAL, + max_lat REAL ); CREATE INDEX IF NOT EXISTS idx_trails_highway ON trails(highway_type); CREATE INDEX IF NOT EXISTS idx_trails_surface ON trails(surface); + CREATE INDEX IF NOT EXISTS idx_trails_bbox ON trails(min_lon, max_lon, min_lat, max_lat); DROP TABLE IF EXISTS poi; CREATE TABLE poi ( @@ -89,9 +94,12 @@ def init_db(conn, has_spatialite): osm_id INTEGER NOT NULL, poi_type TEXT, name TEXT, - geom BLOB + geom BLOB, + lon REAL, + lat REAL ); CREATE INDEX IF NOT EXISTS idx_poi_type ON poi(poi_type); + CREATE INDEX IF NOT EXISTS idx_poi_coords ON poi(lon, lat); """) conn.commit() @@ -153,8 +161,9 @@ def parse_geojsonseq(geojson_path, conn): cur.executemany(""" INSERT INTO trails (osm_id, highway_type, track_type, surface, name, length_m, - mtb_scale, visibility, smoothness, access, tags, geom) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + mtb_scale, visibility, smoothness, access, tags, geom, + min_lon, max_lon, min_lat, max_lat) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) """, batch_trails) conn.commit() trails_count += len(batch_trails) @@ -165,8 +174,8 @@ def parse_geojsonseq(geojson_path, conn): nonlocal poi_count if batch_poi: cur.executemany(""" - INSERT INTO poi (osm_id, poi_type, name, geom) - VALUES (?,?,?,?) + INSERT INTO poi (osm_id, poi_type, name, geom, lon, lat) + VALUES (?,?,?,?,?,?) """, batch_poi) conn.commit() poi_count += len(batch_poi) @@ -202,6 +211,10 @@ def parse_geojsonseq(geojson_path, conn): continue length_m = haversine_length(coords) wkb = coords_to_wkb_linestring(coords) + lons = [c[0] for c in coords] + lats = [c[1] for c in coords] + min_lon, max_lon = min(lons), max(lons) + min_lat, max_lat = min(lats), max(lats) extra = {k: v for k, v in props.items() if k not in ("highway", "tracktype", "surface", "name", "mtb:scale", "trail_visibility", "smoothness", "access")} @@ -218,6 +231,7 @@ def parse_geojsonseq(geojson_path, conn): props.get("access"), json.dumps(extra, ensure_ascii=False), wkb, + min_lon, max_lon, min_lat, max_lat, )) if len(batch_trails) >= BATCH_SIZE: flush_trails() @@ -242,12 +256,15 @@ def parse_geojsonseq(geojson_path, conn): coords = geom.get("coordinates", []) if len(coords) < 2: continue - wkb = coords_to_wkb_point(coords[0], coords[1]) + lon, lat = coords[0], coords[1] + wkb = coords_to_wkb_point(lon, lat) batch_poi.append(( osm_id, poi_type, props.get("name"), wkb, + lon, + lat, )) if len(batch_poi) >= BATCH_SIZE: flush_poi() diff --git a/tasks/enduro-trails/scripts/requirements-parse.txt b/tasks/enduro-trails/scripts/requirements-parse.txt new file mode 100644 index 0000000..839c5a3 --- /dev/null +++ b/tasks/enduro-trails/scripts/requirements-parse.txt @@ -0,0 +1,3 @@ +python-osmium==3.7.0 +pyproj==3.6.1 +shapely==2.0.4 diff --git a/tasks/enduro-trails/scripts/smoke_check.py b/tasks/enduro-trails/scripts/smoke_check.py new file mode 100644 index 0000000..98d8b46 --- /dev/null +++ b/tasks/enduro-trails/scripts/smoke_check.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Smoke checks для Enduro Trails. Запуск: python scripts/smoke_check.py""" +import sys, sqlite3, os, json, urllib.request, urllib.error + +DB_PATH = os.environ.get("DATA_PATH", "../data/centralfederal.sqlite") +API_BASE = os.environ.get("API_BASE", "http://localhost:5558") +errors = [] + +print("==> Схема БД...") +try: + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + + cur.execute("SELECT name FROM sqlite_master WHERE type='table'") + tables = {r[0] for r in cur.fetchall()} + for t in ("trails", "poi"): + if t not in tables: errors.append(f"Таблица {t} не найдена") + + cur.execute("PRAGMA table_info(trails)") + trail_cols = {r[1] for r in cur.fetchall()} + for col in ("min_lon","max_lon","min_lat","max_lat","length_m","mtb_scale"): + if col not in trail_cols: errors.append(f"trails.{col} не найдена") + + cur.execute("PRAGMA table_info(poi)") + poi_cols = {r[1] for r in cur.fetchall()} + for col in ("lon","lat"): + if col not in poi_cols: errors.append(f"poi.{col} не найдена") + + cur.execute("SELECT name FROM sqlite_master WHERE type='index'") + indexes = {r[0] for r in cur.fetchall()} + for idx in ("idx_trails_bbox","idx_poi_coords"): + if idx not in indexes: errors.append(f"Индекс {idx} не найден") + + cur.execute("SELECT COUNT(*) FROM trails"); n = cur.fetchone()[0] + print(f" trails: {n:,}") + if n == 0: errors.append("trails пуста") + + cur.execute("SELECT COUNT(*) FROM poi"); n = cur.fetchone()[0] + print(f" poi: {n:,}") + + cur.execute("SELECT COUNT(*) FROM trails WHERE min_lon IS NULL") + null_bbox = cur.fetchone()[0] + if null_bbox > 0: errors.append(f"{null_bbox} trails с NULL bbox") + + conn.close() +except Exception as e: + errors.append(f"Ошибка БД: {e}") + +print("==> API...") +try: + with urllib.request.urlopen(f"{API_BASE}/api/health", timeout=5) as r: + data = json.loads(r.read()) + if not data.get("db_exists"): errors.append("db_exists=false") + else: print(" health: OK") + + tile_url = f"{API_BASE}/api/tiles/10/619/320.mvt" + with urllib.request.urlopen(tile_url, timeout=10) as r: + td = r.read() + if len(td) == 0: errors.append("Тайл z10/619/320 пустой") + else: print(f" tile z10/619/320: {len(td)} bytes OK") + + try: + urllib.request.urlopen(f"{API_BASE}/api/tiles/99/0/0.mvt", timeout=5) + errors.append("z=99 должен вернуть 400") + except urllib.error.HTTPError as e: + if e.code == 400: print(" validation z=99: 400 OK") + else: errors.append(f"z=99 вернул {e.code}") +except Exception as e: + errors.append(f"Ошибка API: {e}") + +print() +if errors: + print(f"FAILED ({len(errors)} ошибок):") + for e in errors: print(f" - {e}") + sys.exit(1) +else: + print("OK — все проверки прошли")