auto-sync: 2026-05-02 17:10:01
This commit is contained in:
369
tasks/enduro-trails/DEV_TASK.md
Normal file
369
tasks/enduro-trails/DEV_TASK.md
Normal file
@@ -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 из `<script>` блока
|
||||
- Создать `static/app.css` — все стили из `<style>` блока
|
||||
- В `index.html` оставить только HTML-разметку + подключение:
|
||||
```html
|
||||
<link rel="stylesheet" href="/app.css">
|
||||
...
|
||||
<script src="/app.js" defer></script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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)
|
||||
@@ -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 проходят без ошибок
|
||||
|
||||
## Ресурсы на регион
|
||||
|
||||
| Компонент | Объём |
|
||||
|
||||
@@ -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 прошли
|
||||
|
||||
## Данные для ЦФО + Чувашия
|
||||
|
||||
|
||||
16
tasks/enduro-trails/prototype/Dockerfile
Normal file
16
tasks/enduro-trails/prototype/Dockerfile
Normal file
@@ -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"]
|
||||
98
tasks/enduro-trails/prototype/README.md
Normal file
98
tasks/enduro-trails/prototype/README.md
Normal file
@@ -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
|
||||
```
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
213
tasks/enduro-trails/prototype/static/app.css
Normal file
213
tasks/enduro-trails/prototype/static/app.css
Normal file
@@ -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;
|
||||
}
|
||||
151
tasks/enduro-trails/prototype/static/app.js
vendored
Normal file
151
tasks/enduro-trails/prototype/static/app.js
vendored
Normal file
@@ -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 = `
|
||||
<div class="popup-title">${props.name || 'Без названия'}</div>
|
||||
<div class="popup-row"><span class="popup-key">Тип</span><span class="popup-val">${props.highway || '—'}</span></div>
|
||||
<div class="popup-row"><span class="popup-key">Покрытие</span><span class="popup-val">${props.surface || '—'}</span></div>
|
||||
<div class="popup-row"><span class="popup-key">Категория</span><span class="popup-val">${props.tracktype || '—'}</span></div>
|
||||
<div class="popup-row"><span class="popup-key">Длина</span><span class="popup-val">${formatLength(props.length_m)}</span></div>
|
||||
${props.mtb_scale ? `<div class="popup-row"><span class="popup-key">MTB scale</span><span class="popup-val">${props.mtb_scale}</span></div>` : ''}
|
||||
`;
|
||||
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 = `
|
||||
<div class="popup-title">${props.name || 'Без названия'}</div>
|
||||
<div class="popup-row"><span class="popup-key">Тип</span><span class="popup-val">${poiTypeLabel(props.poi_type)}</span></div>
|
||||
`;
|
||||
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();
|
||||
@@ -9,221 +9,7 @@
|
||||
<link href="https://unpkg.com/maplibre-gl@4.1.3/dist/maplibre-gl.css" rel="stylesheet" />
|
||||
<script src="https://unpkg.com/maplibre-gl@4.1.3/dist/maplibre-gl.js"></script>
|
||||
|
||||
<style>
|
||||
* { 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;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -309,160 +95,6 @@
|
||||
<div id="stats">Zoom: <span id="zoom-val">7</span> | Координаты: <span id="coords-val">—</span></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ─── Layer visibility state ───────────────────────────────────────────────────
|
||||
const layerState = {
|
||||
tracks: true,
|
||||
paths: true,
|
||||
poi: true,
|
||||
basemap: true,
|
||||
};
|
||||
|
||||
const layerGroups = {
|
||||
tracks: ['trails-grade12', 'trails-grade345', '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 (map.getLayer(id)) {
|
||||
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 = `
|
||||
<div class="popup-title">${props.name || 'Без названия'}</div>
|
||||
<div class="popup-row"><span class="popup-key">Тип</span><span class="popup-val">${props.highway || '—'}</span></div>
|
||||
<div class="popup-row"><span class="popup-key">Покрытие</span><span class="popup-val">${props.surface || '—'}</span></div>
|
||||
<div class="popup-row"><span class="popup-key">Категория</span><span class="popup-val">${props.tracktype || '—'}</span></div>
|
||||
<div class="popup-row"><span class="popup-key">Длина</span><span class="popup-val">${formatLength(props.length_m)}</span></div>
|
||||
${props.mtb_scale ? `<div class="popup-row"><span class="popup-key">MTB scale</span><span class="popup-val">${props.mtb_scale}</span></div>` : ''}
|
||||
`;
|
||||
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 = `
|
||||
<div class="popup-title">${props.name || 'Без названия'}</div>
|
||||
<div class="popup-row"><span class="popup-key">Тип</span><span class="popup-val">${poiTypeLabel(props.poi_type)}</span></div>
|
||||
`;
|
||||
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();
|
||||
</script>
|
||||
<script src="/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
3
tasks/enduro-trails/scripts/requirements-parse.txt
Normal file
3
tasks/enduro-trails/scripts/requirements-parse.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
python-osmium==3.7.0
|
||||
pyproj==3.6.1
|
||||
shapely==2.0.4
|
||||
77
tasks/enduro-trails/scripts/smoke_check.py
Normal file
77
tasks/enduro-trails/scripts/smoke_check.py
Normal file
@@ -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 — все проверки прошли")
|
||||
Reference in New Issue
Block a user