auto-sync: 2026-05-02 17:10:01

This commit is contained in:
Stream
2026-05-02 17:10:02 +03:00
parent a60a6e69a1
commit 08c633f3da
14 changed files with 1004 additions and 414 deletions

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

View File

@@ -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 проходят без ошибок
## Ресурсы на регион
| Компонент | Объём |

View File

@@ -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 прошли
## Данные для ЦФО + Чувашия

View 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"]

View 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 должен быть 022, 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
```

View File

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

View File

@@ -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

View File

@@ -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

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

View 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();

View File

@@ -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>

View File

@@ -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()

View File

@@ -0,0 +1,3 @@
python-osmium==3.7.0
pyproj==3.6.1
shapely==2.0.4

View 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 — все проверки прошли")