auto-sync: 2026-05-02 07:50:01
This commit is contained in:
498
tasks/enduro-trails/prototype/app.py
Normal file
498
tasks/enduro-trails/prototype/app.py
Normal file
@@ -0,0 +1,498 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
app.py — FastAPI сервер для Enduro Trails
|
||||
- Раздаёт статику из static/
|
||||
- /api/tiles/{z}/{x}/{y}.mvt — векторные тайлы из Spatialite
|
||||
- /api/poi — список POI как GeoJSON
|
||||
"""
|
||||
|
||||
import os
|
||||
import math
|
||||
import struct
|
||||
import sqlite3
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import uvicorn
|
||||
|
||||
# ─── Конфиг ───────────────────────────────────────────────────────────────────
|
||||
|
||||
DATA_PATH = os.environ.get(
|
||||
"DATA_PATH",
|
||||
os.path.join(os.path.dirname(__file__), "../data/centralfederal.sqlite"),
|
||||
)
|
||||
DATA_PATH = os.path.abspath(DATA_PATH)
|
||||
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
||||
PORT = int(os.environ.get("PORT", 5558))
|
||||
|
||||
app = FastAPI(title="Enduro Trails API")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ─── Spatialite helper ────────────────────────────────────────────────────────
|
||||
|
||||
def get_db():
|
||||
"""Открывает соединение с SQLite (с попыткой загрузить Spatialite)."""
|
||||
conn = sqlite3.connect(DATA_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.enable_load_extension(True)
|
||||
for path in ["mod_spatialite", "/usr/lib/x86_64-linux-gnu/mod_spatialite.so",
|
||||
"/usr/lib/mod_spatialite.so", "/usr/local/lib/mod_spatialite.so"]:
|
||||
try:
|
||||
conn.load_extension(path)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
conn.enable_load_extension(False)
|
||||
return conn
|
||||
|
||||
|
||||
# ─── Tile math ────────────────────────────────────────────────────────────────
|
||||
|
||||
def tile_to_bbox(z: int, x: int, y: int):
|
||||
"""Возвращает (west, south, east, north) в градусах для тайла z/x/y."""
|
||||
n = 2 ** z
|
||||
west = x / n * 360.0 - 180.0
|
||||
east = (x + 1) / n * 360.0 - 180.0
|
||||
north = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n))))
|
||||
south = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n))))
|
||||
return west, south, east, north
|
||||
|
||||
|
||||
def lon_to_tile_x(lon: float, z: int) -> float:
|
||||
return (lon + 180.0) / 360.0 * (2 ** z)
|
||||
|
||||
|
||||
def lat_to_tile_y(lat: float, z: int) -> float:
|
||||
lat_r = math.radians(lat)
|
||||
return (1.0 - math.log(math.tan(lat_r) + 1.0 / math.cos(lat_r)) / math.pi) / 2.0 * (2 ** z)
|
||||
|
||||
|
||||
TILE_EXTENT = 4096
|
||||
|
||||
|
||||
def lonlat_to_tile_coords(lon: float, lat: float, z: int, tx: int, ty: int):
|
||||
"""Конвертирует lon/lat в пиксельные координаты тайла [0, TILE_EXTENT]."""
|
||||
px = (lon_to_tile_x(lon, z) - tx) * TILE_EXTENT
|
||||
py = (lat_to_tile_y(lat, z) - ty) * TILE_EXTENT
|
||||
return int(px), int(py)
|
||||
|
||||
|
||||
# ─── Minimal MVT encoder ──────────────────────────────────────────────────────
|
||||
# Реализует минимальный Mapbox Vector Tile (protobuf) без внешних зависимостей
|
||||
# Spec: https://github.com/mapbox/vector-tile-spec/tree/master/2.1
|
||||
|
||||
def _varint(value: int) -> bytes:
|
||||
"""Encode unsigned varint."""
|
||||
buf = []
|
||||
while True:
|
||||
towrite = value & 0x7F
|
||||
value >>= 7
|
||||
if value:
|
||||
buf.append(towrite | 0x80)
|
||||
else:
|
||||
buf.append(towrite)
|
||||
break
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def _zigzag(n: int) -> int:
|
||||
return (n << 1) ^ (n >> 31)
|
||||
|
||||
|
||||
def _pb_field(field_num: int, wire_type: int, data: bytes) -> bytes:
|
||||
tag = (field_num << 3) | wire_type
|
||||
return _varint(tag) + data
|
||||
|
||||
|
||||
def _pb_string(field_num: int, s: str) -> bytes:
|
||||
encoded = s.encode("utf-8")
|
||||
return _pb_field(field_num, 2, _varint(len(encoded)) + encoded)
|
||||
|
||||
|
||||
def _pb_bytes(field_num: int, b: bytes) -> bytes:
|
||||
return _pb_field(field_num, 2, _varint(len(b)) + b)
|
||||
|
||||
|
||||
def _pb_uint32(field_num: int, v: int) -> bytes:
|
||||
return _pb_field(field_num, 0, _varint(v))
|
||||
|
||||
|
||||
def encode_linestring_geometry(coords_px):
|
||||
"""
|
||||
Кодирует список (x, y) пикселей в MVT geometry (LINESTRING).
|
||||
Возвращает bytes команд.
|
||||
"""
|
||||
if len(coords_px) < 2:
|
||||
return None
|
||||
|
||||
cmds = []
|
||||
# MoveTo первой точки
|
||||
dx = coords_px[0][0]
|
||||
dy = coords_px[0][1]
|
||||
cmds.append((1 << 3) | 1) # MoveTo, count=1
|
||||
cmds.append(_zigzag(dx))
|
||||
cmds.append(_zigzag(dy))
|
||||
|
||||
# LineTo остальных
|
||||
cmds.append(((len(coords_px) - 1) << 3) | 2)
|
||||
prev_x, prev_y = coords_px[0]
|
||||
for x, y in coords_px[1:]:
|
||||
cmds.append(_zigzag(x - prev_x))
|
||||
cmds.append(_zigzag(y - prev_y))
|
||||
prev_x, prev_y = x, y
|
||||
|
||||
return b"".join(_varint(c) for c in cmds)
|
||||
|
||||
|
||||
def encode_point_geometry(x: int, y: int) -> bytes:
|
||||
cmds = []
|
||||
cmds.append((1 << 3) | 1) # MoveTo, count=1
|
||||
cmds.append(_zigzag(x))
|
||||
cmds.append(_zigzag(y))
|
||||
return b"".join(_varint(c) for c in cmds)
|
||||
|
||||
|
||||
def build_mvt_tile(trails_rows, poi_rows, z: int, tx: int, ty: int) -> bytes:
|
||||
"""Строит MVT тайл из строк trails и poi."""
|
||||
|
||||
def build_layer(name: str, features_data: list, geom_type: int) -> bytes:
|
||||
"""
|
||||
features_data: list of {"geom_bytes": bytes, "props": dict}
|
||||
geom_type: 2=LINESTRING, 1=POINT
|
||||
"""
|
||||
if not features_data:
|
||||
return b""
|
||||
|
||||
# Собираем ключи и значения
|
||||
keys = []
|
||||
key_index = {}
|
||||
values = []
|
||||
value_index = {}
|
||||
|
||||
feature_blobs = []
|
||||
for fd in features_data:
|
||||
geom_bytes = fd["geom_bytes"]
|
||||
props = fd["props"]
|
||||
if not geom_bytes:
|
||||
continue
|
||||
|
||||
tags = []
|
||||
for k, v in props.items():
|
||||
if v is None:
|
||||
continue
|
||||
v_str = str(v)
|
||||
|
||||
if k not in key_index:
|
||||
key_index[k] = len(keys)
|
||||
keys.append(k)
|
||||
ki = key_index[k]
|
||||
|
||||
vk = (type(v).__name__, v_str)
|
||||
if vk not in value_index:
|
||||
value_index[vk] = len(values)
|
||||
values.append((type(v).__name__, v_str))
|
||||
vi = value_index[vk]
|
||||
|
||||
tags.extend([ki, vi])
|
||||
|
||||
# Feature proto
|
||||
feat = b""
|
||||
feat += _pb_uint32(3, geom_type) # type
|
||||
# geometry field=4
|
||||
feat += _pb_bytes(4, geom_bytes)
|
||||
# tags field=2
|
||||
for t in tags:
|
||||
feat += _pb_uint32(2, t)
|
||||
|
||||
feature_blobs.append(feat)
|
||||
|
||||
if not feature_blobs:
|
||||
return b""
|
||||
|
||||
layer = b""
|
||||
layer += _pb_uint32(15, 2) # version=2
|
||||
layer += _pb_string(1, name) # name
|
||||
layer += _pb_uint32(5, TILE_EXTENT) # extent
|
||||
|
||||
for k in keys:
|
||||
layer += _pb_string(3, k)
|
||||
|
||||
for (vtype, vstr) in values:
|
||||
# value message: string_value=1, float_value=2, ...
|
||||
val_msg = b""
|
||||
if vtype == "int" or vtype == "float":
|
||||
try:
|
||||
fv = float(vstr)
|
||||
val_msg += _pb_field(3, 1, struct.pack("<d", fv)) # double_value
|
||||
except Exception:
|
||||
val_msg += _pb_string(1, vstr)
|
||||
else:
|
||||
val_msg += _pb_string(1, vstr)
|
||||
layer += _pb_bytes(4, val_msg)
|
||||
|
||||
for fb in feature_blobs:
|
||||
layer += _pb_bytes(2, fb)
|
||||
|
||||
return layer
|
||||
|
||||
# ── Trails layer ──
|
||||
trails_features = []
|
||||
for row in trails_rows:
|
||||
geom_blob = row["geom"]
|
||||
if not geom_blob:
|
||||
continue
|
||||
coords_px = wkb_linestring_to_pixels(geom_blob, z, tx, ty)
|
||||
if not coords_px:
|
||||
continue
|
||||
geom_bytes = encode_linestring_geometry(coords_px)
|
||||
if not geom_bytes:
|
||||
continue
|
||||
trails_features.append({
|
||||
"geom_bytes": geom_bytes,
|
||||
"props": {
|
||||
"name": row["name"],
|
||||
"highway": row["highway_type"],
|
||||
"tracktype": row["track_type"],
|
||||
"surface": row["surface"],
|
||||
"length_m": row["length_m"],
|
||||
"mtb_scale": row["mtb_scale"],
|
||||
},
|
||||
})
|
||||
|
||||
# ── POI layer ──
|
||||
poi_features = []
|
||||
for row in poi_rows:
|
||||
geom_blob = row["geom"]
|
||||
if not geom_blob:
|
||||
continue
|
||||
pt = wkb_point_to_pixels(geom_blob, z, tx, ty)
|
||||
if pt is None:
|
||||
continue
|
||||
geom_bytes = encode_point_geometry(*pt)
|
||||
poi_features.append({
|
||||
"geom_bytes": geom_bytes,
|
||||
"props": {
|
||||
"name": row["name"],
|
||||
"poi_type": row["poi_type"],
|
||||
},
|
||||
})
|
||||
|
||||
tile = b""
|
||||
trails_layer = build_layer("trails", trails_features, 2)
|
||||
poi_layer = build_layer("poi", poi_features, 1)
|
||||
|
||||
if trails_layer:
|
||||
tile += _pb_bytes(3, trails_layer)
|
||||
if poi_layer:
|
||||
tile += _pb_bytes(3, poi_layer)
|
||||
|
||||
return tile
|
||||
|
||||
|
||||
# ─── WKB parsers ──────────────────────────────────────────────────────────────
|
||||
|
||||
def wkb_linestring_to_pixels(blob: bytes, z: int, tx: int, ty: int):
|
||||
"""Парсит WKB LineString и возвращает список пиксельных координат."""
|
||||
try:
|
||||
if len(blob) < 9:
|
||||
return None
|
||||
byte_order = blob[0]
|
||||
endian = "<" if byte_order == 1 else ">"
|
||||
geom_type = struct.unpack_from(endian + "I", blob, 1)[0]
|
||||
# 2 = LineString, 1002 = LineString with SRID
|
||||
if geom_type not in (2, 1000002, 2147483650):
|
||||
# попробуем через shapely
|
||||
return _wkb_via_shapely(blob, z, tx, ty, "line")
|
||||
|
||||
offset = 5
|
||||
# Если есть SRID (geom_type & 0x20000000)
|
||||
if geom_type & 0x20000000:
|
||||
offset += 4
|
||||
|
||||
num_points = struct.unpack_from(endian + "I", blob, offset)[0]
|
||||
offset += 4
|
||||
|
||||
coords_px = []
|
||||
for _ in range(num_points):
|
||||
lon, lat = struct.unpack_from(endian + "dd", blob, offset)
|
||||
offset += 16
|
||||
px, py = lonlat_to_tile_coords(lon, lat, z, tx, ty)
|
||||
coords_px.append((px, py))
|
||||
|
||||
return coords_px if len(coords_px) >= 2 else None
|
||||
except Exception:
|
||||
return _wkb_via_shapely(blob, z, tx, ty, "line")
|
||||
|
||||
|
||||
def wkb_point_to_pixels(blob: bytes, z: int, tx: int, ty: int):
|
||||
"""Парсит WKB Point и возвращает (px, py)."""
|
||||
try:
|
||||
if len(blob) < 21:
|
||||
return None
|
||||
byte_order = blob[0]
|
||||
endian = "<" if byte_order == 1 else ">"
|
||||
geom_type = struct.unpack_from(endian + "I", blob, 1)[0]
|
||||
if geom_type not in (1, 1000001, 2147483649):
|
||||
return _wkb_via_shapely(blob, z, tx, ty, "point")
|
||||
|
||||
offset = 5
|
||||
if geom_type & 0x20000000:
|
||||
offset += 4
|
||||
|
||||
lon, lat = struct.unpack_from(endian + "dd", blob, offset)
|
||||
return lonlat_to_tile_coords(lon, lat, z, tx, ty)
|
||||
except Exception:
|
||||
return _wkb_via_shapely(blob, z, tx, ty, "point")
|
||||
|
||||
|
||||
def _wkb_via_shapely(blob, z, tx, ty, kind):
|
||||
"""Fallback: парсим через shapely."""
|
||||
try:
|
||||
from shapely import wkb as swkb
|
||||
geom = swkb.loads(bytes(blob))
|
||||
if kind == "point":
|
||||
return lonlat_to_tile_coords(geom.x, geom.y, z, tx, ty)
|
||||
else:
|
||||
coords = list(geom.coords)
|
||||
return [lonlat_to_tile_coords(lon, lat, z, tx, ty) for lon, lat in coords]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ─── API endpoints ────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/tiles/{z}/{x}/{y}.mvt")
|
||||
async def get_tile(z: int, x: int, y: int):
|
||||
if not os.path.exists(DATA_PATH):
|
||||
raise HTTPException(503, f"База данных не найдена: {DATA_PATH}. Запустите parse.py")
|
||||
|
||||
west, south, east, north = tile_to_bbox(z, x, y)
|
||||
|
||||
# Небольшой буфер для краевых геометрий
|
||||
buf = (east - west) * 0.1
|
||||
q_west, q_south, q_east, q_north = west - buf, south - buf, east + buf, north + buf
|
||||
|
||||
try:
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Trails — простой bbox по координатам из WKB (без пространственного индекса)
|
||||
# Используем ST_Intersects если Spatialite доступен, иначе fallback
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT osm_id, highway_type, track_type, surface, name,
|
||||
length_m, mtb_scale, geom
|
||||
FROM trails
|
||||
WHERE ST_Intersects(geom, BuildMBR(?,?,?,?,4326))
|
||||
LIMIT 5000
|
||||
""", (q_west, q_south, q_east, q_north))
|
||||
except Exception:
|
||||
# Fallback без пространственного индекса
|
||||
cur.execute("""
|
||||
SELECT osm_id, highway_type, track_type, surface, name,
|
||||
length_m, mtb_scale, geom
|
||||
FROM trails
|
||||
LIMIT 5000
|
||||
""")
|
||||
|
||||
trails_rows = cur.fetchall()
|
||||
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT osm_id, poi_type, name, geom
|
||||
FROM poi
|
||||
WHERE ST_Intersects(geom, BuildMBR(?,?,?,?,4326))
|
||||
LIMIT 1000
|
||||
""", (q_west, q_south, q_east, q_north))
|
||||
except Exception:
|
||||
cur.execute("SELECT osm_id, poi_type, name, geom FROM poi LIMIT 1000")
|
||||
|
||||
poi_rows = cur.fetchall()
|
||||
conn.close()
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Ошибка БД: {e}")
|
||||
|
||||
mvt = build_mvt_tile(trails_rows, poi_rows, z, x, y)
|
||||
|
||||
return Response(
|
||||
content=mvt,
|
||||
media_type="application/x-protobuf",
|
||||
headers={
|
||||
"Content-Encoding": "identity",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/poi")
|
||||
async def get_poi():
|
||||
if not os.path.exists(DATA_PATH):
|
||||
raise HTTPException(503, f"База данных не найдена: {DATA_PATH}")
|
||||
|
||||
try:
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT osm_id, poi_type, name, geom FROM poi LIMIT 10000")
|
||||
rows = cur.fetchall()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Ошибка БД: {e}")
|
||||
|
||||
features = []
|
||||
for row in rows:
|
||||
geom_blob = row["geom"]
|
||||
if not geom_blob:
|
||||
continue
|
||||
try:
|
||||
byte_order = geom_blob[0]
|
||||
endian = "<" if byte_order == 1 else ">"
|
||||
lon, lat = struct.unpack_from(endian + "dd", bytes(geom_blob), 5)
|
||||
features.append({
|
||||
"type": "Feature",
|
||||
"geometry": {"type": "Point", "coordinates": [lon, lat]},
|
||||
"properties": {
|
||||
"osm_id": row["osm_id"],
|
||||
"poi_type": row["poi_type"],
|
||||
"name": row["name"],
|
||||
},
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return {"type": "FeatureCollection", "features": features}
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
db_exists = os.path.exists(DATA_PATH)
|
||||
return {
|
||||
"status": "ok",
|
||||
"db_path": DATA_PATH,
|
||||
"db_exists": db_exists,
|
||||
}
|
||||
|
||||
|
||||
# ─── Static files ─────────────────────────────────────────────────────────────
|
||||
|
||||
if os.path.exists(STATIC_DIR):
|
||||
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
|
||||
|
||||
|
||||
# ─── Entry point ──────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"==> Enduro Trails API")
|
||||
print(f" DB: {DATA_PATH}")
|
||||
print(f" Static: {STATIC_DIR}")
|
||||
print(f" Port: {PORT}")
|
||||
uvicorn.run(app, host="0.0.0.0", port=PORT)
|
||||
49
tasks/enduro-trails/prototype/docker-compose.yml
Normal file
49
tasks/enduro-trails/prototype/docker-compose.yml
Normal file
@@ -0,0 +1,49 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Шаг 1: скачать и распарсить данные
|
||||
data-init:
|
||||
image: python:3.11-slim
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ../data:/data
|
||||
- ../scripts:/scripts
|
||||
command: >
|
||||
bash -c "
|
||||
apt-get update -qq &&
|
||||
apt-get install -y -qq osmium-tool wget libsqlite3-mod-spatialite libspatialite-dev &&
|
||||
pip install --quiet python-osmium shapely pysqlite3-binary &&
|
||||
echo '==> Скачиваем ЦФО...' &&
|
||||
wget -q -c 'https://download.geofabrik.de/russia/centralfederal.ru-latest.osm.pbf' -O /data/centralfederal.ru-latest.osm.pbf &&
|
||||
echo '==> Скачиваем Поволжье...' &&
|
||||
wget -q -c 'https://download.geofabrik.de/russia/volga.osm.pbf' -O /data/volga.osm.pbf &&
|
||||
echo '==> Объединяем...' &&
|
||||
osmium merge /data/centralfederal.ru-latest.osm.pbf /data/volga.osm.pbf -o /data/merged.osm.pbf --overwrite &&
|
||||
echo '==> Фильтруем по BBOX...' &&
|
||||
osmium extract --bbox=30.0,51.0,48.0,59.0 /data/merged.osm.pbf -o /data/region.osm.pbf --overwrite &&
|
||||
echo '==> Парсим в SQLite...' &&
|
||||
python /scripts/parse.py &&
|
||||
echo '==> Данные готовы!'
|
||||
"
|
||||
profiles:
|
||||
- init
|
||||
|
||||
# Шаг 2: веб-сервер
|
||||
enduro-trails:
|
||||
image: python:3.11-slim
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
- ../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
|
||||
7
tasks/enduro-trails/prototype/requirements.txt
Normal file
7
tasks/enduro-trails/prototype/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
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
|
||||
456
tasks/enduro-trails/prototype/static/index.html
Normal file
456
tasks/enduro-trails/prototype/static/index.html
Normal file
@@ -0,0 +1,456 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Enduro Trails — ЦФО + Чувашия</title>
|
||||
|
||||
<!-- MapLibre GL JS -->
|
||||
<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: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#header {
|
||||
background: #16213e;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
#header h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #FF8C00;
|
||||
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: #0f3460;
|
||||
border: 1px solid #1a5276;
|
||||
color: #e0e0e0;
|
||||
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: #1a5276; }
|
||||
.toggle-btn.active { background: #FF8C00; border-color: #FF8C00; color: #1a1a2e; 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(22, 33, 62, 0.92);
|
||||
border: 1px solid #0f3460;
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
z-index: 5;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
#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 #FFD700;
|
||||
}
|
||||
|
||||
/* Popup */
|
||||
.maplibregl-popup-content {
|
||||
background: #16213e !important;
|
||||
color: #e0e0e0 !important;
|
||||
border: 1px solid #0f3460 !important;
|
||||
border-radius: 6px !important;
|
||||
padding: 12px 14px !important;
|
||||
font-size: 13px !important;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.maplibregl-popup-tip {
|
||||
border-top-color: #16213e !important;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #FF8C00;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.popup-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.popup-key { color: #888; }
|
||||
.popup-val { color: #e0e0e0; font-weight: 500; }
|
||||
|
||||
/* Stats bar */
|
||||
#stats {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 12px;
|
||||
background: rgba(22, 33, 62, 0.85);
|
||||
border: 1px solid #0f3460;
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* Loading indicator */
|
||||
#loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(22, 33, 62, 0.95);
|
||||
border: 1px solid #FF8C00;
|
||||
border-radius: 8px;
|
||||
padding: 20px 30px;
|
||||
text-align: center;
|
||||
z-index: 100;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#loading.visible { display: block; }
|
||||
#loading .spinner {
|
||||
width: 32px; height: 32px;
|
||||
border: 3px solid #0f3460;
|
||||
border-top-color: #FF8C00;
|
||||
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(22, 33, 62, 0.95);
|
||||
border: 1px solid #FF8C00;
|
||||
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: #FF8C00; margin-bottom: 10px; }
|
||||
#no-data-warning p { color: #aaa; font-size: 13px; line-height: 1.5; }
|
||||
#no-data-warning code {
|
||||
background: #0f3460;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="header">
|
||||
<div>
|
||||
<h1>🏍 Enduro Trails</h1>
|
||||
<div class="subtitle">ЦФО + Чувашия — грунтовые дороги</div>
|
||||
</div>
|
||||
<div id="controls">
|
||||
<button class="toggle-btn active" id="btn-tracks" onclick="toggleLayer('tracks')">
|
||||
<span class="dot" style="background:#FF8C00"></span> Грунтовки
|
||||
</button>
|
||||
<button class="toggle-btn active" id="btn-paths" onclick="toggleLayer('paths')">
|
||||
<span class="dot" style="background:#FFD700"></span> Тропы
|
||||
</button>
|
||||
<button class="toggle-btn active" id="btn-poi" onclick="toggleLayer('poi')">
|
||||
<span class="dot" style="background:#44ff88"></span> POI
|
||||
</button>
|
||||
<button class="toggle-btn active" id="btn-basemap" onclick="toggleLayer('basemap')">
|
||||
🗺 Подложка
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="map-container">
|
||||
<div id="map"></div>
|
||||
|
||||
<div id="loading" class="visible">
|
||||
<div class="spinner"></div>
|
||||
<div>Загрузка карты...</div>
|
||||
</div>
|
||||
|
||||
<div id="no-data-warning">
|
||||
<h2>⚠️ Данные не загружены</h2>
|
||||
<p>
|
||||
База данных не найдена или пуста.<br><br>
|
||||
Для загрузки данных выполните:<br>
|
||||
<code>bash scripts/download.sh</code><br>
|
||||
<code>python scripts/parse.py</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="legend">
|
||||
<h3>Легенда</h3>
|
||||
<div class="legend-item">
|
||||
<div class="legend-line" style="background:#FF8C00; height:4px"></div>
|
||||
<span>Грунтовка grade3-5</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-line" style="background:#FFA500; height:2px"></div>
|
||||
<span>Грунтовка grade1-2</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-dashed"></div>
|
||||
<span>Тропа / bridleway</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-line" style="background:#555; height:1px"></div>
|
||||
<span>Асфальт</span>
|
||||
</div>
|
||||
<div style="margin-top:8px; border-top:1px solid #0f3460; padding-top:8px;">
|
||||
<div class="legend-item">
|
||||
<span class="dot" style="background:#ff4444"></span><span>Вершина</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="dot" style="background:#4488ff"></span><span>Вода</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="dot" style="background:#44ff88"></span><span>Смотровая</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="dot" style="background:#cc88ff"></span><span>Руины</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="dot" style="background:#ffaa00"></span><span>Пещера</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="dot" style="background:#00ccff"></span><span>Брод</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 ─────────────────────────────────────────────────────────────────
|
||||
const map = new maplibregl.Map({
|
||||
container: 'map',
|
||||
style: '/style.json',
|
||||
center: [40.5, 55.5],
|
||||
zoom: 7,
|
||||
minZoom: 4,
|
||||
maxZoom: 18,
|
||||
hash: true,
|
||||
});
|
||||
|
||||
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.warn('Map error:', e);
|
||||
});
|
||||
|
||||
// ─── Check if data is available ───────────────────────────────────────────────
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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-grade12', 'trails-grade345', '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>
|
||||
<div class="popup-row"><span class="popup-key">OSM ID</span><span class="popup-val">${props.osm_id}</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-grade12', 'trails-grade345', 'trails-path-bridleway',
|
||||
'trails-asphalt', 'poi-circles'],
|
||||
});
|
||||
if (!features.length) popup.remove();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
185
tasks/enduro-trails/prototype/static/style.json
Normal file
185
tasks/enduro-trails/prototype/static/style.json
Normal file
@@ -0,0 +1,185 @@
|
||||
{
|
||||
"version": 8,
|
||||
"name": "Enduro Trails Dark",
|
||||
"metadata": {},
|
||||
"center": [37.6, 55.75],
|
||||
"zoom": 7,
|
||||
"bearing": 0,
|
||||
"pitch": 0,
|
||||
"sources": {
|
||||
"trails-tiles": {
|
||||
"type": "vector",
|
||||
"tiles": ["/api/tiles/{z}/{x}/{y}.mvt"],
|
||||
"minzoom": 5,
|
||||
"maxzoom": 16
|
||||
},
|
||||
"osm-raster": {
|
||||
"type": "raster",
|
||||
"tiles": [
|
||||
"https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
],
|
||||
"tileSize": 256,
|
||||
"attribution": "© OpenStreetMap contributors"
|
||||
}
|
||||
},
|
||||
"glyphs": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
|
||||
"layers": [
|
||||
{
|
||||
"id": "background",
|
||||
"type": "background",
|
||||
"paint": {
|
||||
"background-color": "#1a1a2e"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "osm-base",
|
||||
"type": "raster",
|
||||
"source": "osm-raster",
|
||||
"paint": {
|
||||
"raster-opacity": 0.25,
|
||||
"raster-saturation": -0.8,
|
||||
"raster-brightness-min": 0.0,
|
||||
"raster-brightness-max": 0.3
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "trails-asphalt",
|
||||
"type": "line",
|
||||
"source": "trails-tiles",
|
||||
"source-layer": "trails",
|
||||
"filter": [
|
||||
"in", "highway", "primary", "secondary", "tertiary", "residential", "cycleway"
|
||||
],
|
||||
"paint": {
|
||||
"line-color": "#555555",
|
||||
"line-width": 1,
|
||||
"line-opacity": 0.7
|
||||
},
|
||||
"layout": {
|
||||
"line-cap": "round",
|
||||
"line-join": "round"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "trails-grade12",
|
||||
"type": "line",
|
||||
"source": "trails-tiles",
|
||||
"source-layer": "trails",
|
||||
"filter": [
|
||||
"all",
|
||||
["==", "highway", "track"],
|
||||
["in", "tracktype", "grade1", "grade2"]
|
||||
],
|
||||
"paint": {
|
||||
"line-color": "#FFA500",
|
||||
"line-width": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
8, 1.5,
|
||||
12, 2.5,
|
||||
16, 4
|
||||
],
|
||||
"line-opacity": 0.9
|
||||
},
|
||||
"layout": {
|
||||
"line-cap": "round",
|
||||
"line-join": "round"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "trails-grade345",
|
||||
"type": "line",
|
||||
"source": "trails-tiles",
|
||||
"source-layer": "trails",
|
||||
"filter": [
|
||||
"all",
|
||||
["==", "highway", "track"],
|
||||
["!in", "tracktype", "grade1", "grade2"]
|
||||
],
|
||||
"paint": {
|
||||
"line-color": "#FF8C00",
|
||||
"line-width": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
8, 2,
|
||||
12, 3.5,
|
||||
16, 5
|
||||
],
|
||||
"line-opacity": 0.95
|
||||
},
|
||||
"layout": {
|
||||
"line-cap": "round",
|
||||
"line-join": "round"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "trails-path-bridleway",
|
||||
"type": "line",
|
||||
"source": "trails-tiles",
|
||||
"source-layer": "trails",
|
||||
"filter": [
|
||||
"in", "highway", "path", "bridleway", "footway"
|
||||
],
|
||||
"paint": {
|
||||
"line-color": "#FFD700",
|
||||
"line-width": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
8, 1,
|
||||
12, 1.5,
|
||||
16, 3
|
||||
],
|
||||
"line-opacity": 0.85,
|
||||
"line-dasharray": [3, 2]
|
||||
},
|
||||
"layout": {
|
||||
"line-cap": "butt",
|
||||
"line-join": "round"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "poi-circles",
|
||||
"type": "circle",
|
||||
"source": "trails-tiles",
|
||||
"source-layer": "poi",
|
||||
"paint": {
|
||||
"circle-radius": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
8, 3,
|
||||
12, 6,
|
||||
16, 10
|
||||
],
|
||||
"circle-color": [
|
||||
"match", ["get", "poi_type"],
|
||||
"natural=peak", "#ff4444",
|
||||
"natural=water", "#4488ff",
|
||||
"tourism=viewpoint", "#44ff88",
|
||||
"historic=ruins", "#cc88ff",
|
||||
"natural=cave_entrance", "#ffaa00",
|
||||
"ford=yes", "#00ccff",
|
||||
"#ffffff"
|
||||
],
|
||||
"circle-stroke-color": "#1a1a2e",
|
||||
"circle-stroke-width": 1.5,
|
||||
"circle-opacity": 0.9
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "poi-labels",
|
||||
"type": "symbol",
|
||||
"source": "trails-tiles",
|
||||
"source-layer": "poi",
|
||||
"minzoom": 11,
|
||||
"layout": {
|
||||
"text-field": ["get", "name"],
|
||||
"text-font": ["Open Sans Regular"],
|
||||
"text-size": 11,
|
||||
"text-offset": [0, 1.2],
|
||||
"text-anchor": "top",
|
||||
"text-optional": true
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#ffffff",
|
||||
"text-halo-color": "#1a1a2e",
|
||||
"text-halo-width": 1.5
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
32
tasks/enduro-trails/scripts/download.sh
Executable file
32
tasks/enduro-trails/scripts/download.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# download.sh — скачивает и подготавливает OSM PBF данные для Enduro Trails
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DATA_DIR="$SCRIPT_DIR/../data"
|
||||
|
||||
mkdir -p "$DATA_DIR"
|
||||
cd "$DATA_DIR"
|
||||
|
||||
echo "==> Скачиваем ЦФО..."
|
||||
wget -c "https://download.geofabrik.de/russia/centralfederal.ru-latest.osm.pbf" \
|
||||
-O centralfederal.ru-latest.osm.pbf
|
||||
|
||||
echo "==> Скачиваем Поволжье (Чувашия и др.)..."
|
||||
wget -c "https://download.geofabrik.de/russia/volga.osm.pbf" \
|
||||
-O volga.osm.pbf
|
||||
|
||||
echo "==> Объединяем файлы..."
|
||||
osmium merge centralfederal.ru-latest.osm.pbf volga.osm.pbf \
|
||||
-o merged.osm.pbf --overwrite
|
||||
|
||||
echo "==> Фильтруем по BBOX (ЦФО + Чувашия)..."
|
||||
# bbox: west,south,east,north = 30.0,51.0,48.0,59.0
|
||||
osmium extract \
|
||||
--bbox=30.0,51.0,48.0,59.0 \
|
||||
merged.osm.pbf \
|
||||
-o region.osm.pbf \
|
||||
--overwrite
|
||||
|
||||
echo "==> Готово! Файлы в $DATA_DIR:"
|
||||
ls -lh "$DATA_DIR"/*.pbf
|
||||
375
tasks/enduro-trails/scripts/parse.py
Normal file
375
tasks/enduro-trails/scripts/parse.py
Normal file
@@ -0,0 +1,375 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
parse.py — парсинг OSM PBF → Spatialite для Enduro Trails
|
||||
Читает region.osm.pbf, сохраняет trails и POI в centralfederal.sqlite
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import math
|
||||
import sqlite3
|
||||
import argparse
|
||||
|
||||
try:
|
||||
import osmium
|
||||
except ImportError:
|
||||
print("ERROR: python-osmium не установлен. pip install python-osmium")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
# pysqlite3-binary предоставляет sqlite3 с поддержкой расширений
|
||||
import pysqlite3 as sqlite3_ext
|
||||
HAS_PYSQLITE3 = True
|
||||
except ImportError:
|
||||
HAS_PYSQLITE3 = False
|
||||
sqlite3_ext = sqlite3
|
||||
|
||||
from shapely.geometry import LineString, Point
|
||||
from shapely import wkb as shapely_wkb
|
||||
|
||||
# ─── Константы ────────────────────────────────────────────────────────────────
|
||||
|
||||
HIGHWAY_TYPES = {"track", "path", "bridleway", "cycleway", "footway"}
|
||||
|
||||
POI_FILTERS = {
|
||||
"natural": {"water", "peak", "cave_entrance"},
|
||||
"tourism": {"viewpoint"},
|
||||
"historic": {"ruins"},
|
||||
"ford": {"yes"},
|
||||
}
|
||||
|
||||
EARTH_RADIUS_M = 6_371_000.0
|
||||
|
||||
|
||||
# ─── Утилиты ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def haversine_length(coords):
|
||||
"""Длина ломаной в метрах по списку (lon, lat) пар."""
|
||||
total = 0.0
|
||||
for i in range(len(coords) - 1):
|
||||
lon1, lat1 = math.radians(coords[i][0]), math.radians(coords[i][1])
|
||||
lon2, lat2 = math.radians(coords[i+1][0]), math.radians(coords[i+1][1])
|
||||
dlat = lat2 - lat1
|
||||
dlon = lon2 - lon1
|
||||
a = math.sin(dlat/2)**2 + math.cos(lat1)*math.cos(lat2)*math.sin(dlon/2)**2
|
||||
total += 2 * EARTH_RADIUS_M * math.asin(math.sqrt(a))
|
||||
return total
|
||||
|
||||
|
||||
def geom_to_wkb_hex(geom):
|
||||
"""Shapely geometry → WKB hex string для Spatialite."""
|
||||
return shapely_wkb.dumps(geom, hex=True)
|
||||
|
||||
|
||||
# ─── OSM Handlers ─────────────────────────────────────────────────────────────
|
||||
|
||||
class TrailHandler(osmium.SimpleHandler):
|
||||
"""Собирает highway=track/path/... из OSM."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.trails = []
|
||||
|
||||
def way(self, w):
|
||||
tags = w.tags
|
||||
hw = tags.get("highway", "")
|
||||
if hw not in HIGHWAY_TYPES:
|
||||
return
|
||||
|
||||
try:
|
||||
coords = [(n.lon, n.lat) for n in w.nodes if n.location.valid()]
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if len(coords) < 2:
|
||||
return
|
||||
|
||||
length_m = haversine_length(coords)
|
||||
geom = LineString(coords)
|
||||
|
||||
extra_tags = {}
|
||||
for tag in w.tags:
|
||||
extra_tags[tag.k] = tag.v
|
||||
|
||||
self.trails.append({
|
||||
"osm_id": w.id,
|
||||
"highway_type": hw,
|
||||
"track_type": tags.get("tracktype", None),
|
||||
"surface": tags.get("surface", None),
|
||||
"name": tags.get("name", None),
|
||||
"length_m": length_m,
|
||||
"mtb_scale": tags.get("mtb:scale", None),
|
||||
"visibility": tags.get("trail_visibility", None),
|
||||
"smoothness": tags.get("smoothness", None),
|
||||
"access": tags.get("access", None),
|
||||
"tags": json.dumps(extra_tags, ensure_ascii=False),
|
||||
"geom_wkb": geom_to_wkb_hex(geom),
|
||||
})
|
||||
|
||||
|
||||
class POIHandler(osmium.SimpleHandler):
|
||||
"""Собирает POI: вершины, родники, смотровые и т.д."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.pois = []
|
||||
|
||||
def _check_tags(self, tags):
|
||||
"""Возвращает poi_type если тег совпадает с фильтром."""
|
||||
for key, values in POI_FILTERS.items():
|
||||
val = tags.get(key, "")
|
||||
if val in values:
|
||||
return f"{key}={val}"
|
||||
return None
|
||||
|
||||
def node(self, n):
|
||||
poi_type = self._check_tags(n.tags)
|
||||
if not poi_type:
|
||||
return
|
||||
if not n.location.valid():
|
||||
return
|
||||
|
||||
geom = Point(n.location.lon, n.location.lat)
|
||||
self.pois.append({
|
||||
"osm_id": n.id,
|
||||
"poi_type": poi_type,
|
||||
"name": n.tags.get("name", None),
|
||||
"geom_wkb": geom_to_wkb_hex(geom),
|
||||
})
|
||||
|
||||
def way(self, w):
|
||||
"""Для водоёмов-полигонов берём центроид."""
|
||||
poi_type = self._check_tags(w.tags)
|
||||
if not poi_type:
|
||||
return
|
||||
|
||||
try:
|
||||
coords = [(n.lon, n.lat) for n in w.nodes if n.location.valid()]
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if len(coords) < 2:
|
||||
return
|
||||
|
||||
geom = LineString(coords).centroid
|
||||
self.pois.append({
|
||||
"osm_id": w.id,
|
||||
"poi_type": poi_type,
|
||||
"name": w.tags.get("name", None),
|
||||
"geom_wkb": geom_to_wkb_hex(geom),
|
||||
})
|
||||
|
||||
|
||||
# ─── Spatialite ───────────────────────────────────────────────────────────────
|
||||
|
||||
def open_spatialite(db_path):
|
||||
"""Открывает соединение с Spatialite, загружает расширение."""
|
||||
conn = sqlite3_ext.connect(db_path)
|
||||
conn.enable_load_extension(True)
|
||||
|
||||
# Пробуем разные пути к mod_spatialite
|
||||
spatialite_paths = [
|
||||
"mod_spatialite",
|
||||
"/usr/lib/x86_64-linux-gnu/mod_spatialite.so",
|
||||
"/usr/lib/mod_spatialite.so",
|
||||
"/usr/local/lib/mod_spatialite.so",
|
||||
]
|
||||
loaded = False
|
||||
for path in spatialite_paths:
|
||||
try:
|
||||
conn.load_extension(path)
|
||||
loaded = True
|
||||
print(f" Spatialite загружен: {path}")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not loaded:
|
||||
print("WARNING: mod_spatialite не найден — геометрия будет храниться как WKB blob без пространственных индексов")
|
||||
|
||||
return conn, loaded
|
||||
|
||||
|
||||
def init_db(conn, has_spatialite):
|
||||
"""Создаёт таблицы и индексы."""
|
||||
cur = conn.cursor()
|
||||
|
||||
if has_spatialite:
|
||||
cur.execute("SELECT InitSpatialMetaData(1)")
|
||||
|
||||
cur.executescript("""
|
||||
DROP TABLE IF EXISTS trails;
|
||||
CREATE TABLE trails (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
osm_id INTEGER NOT NULL,
|
||||
highway_type TEXT,
|
||||
track_type TEXT,
|
||||
surface TEXT,
|
||||
name TEXT,
|
||||
length_m REAL,
|
||||
mtb_scale TEXT,
|
||||
visibility TEXT,
|
||||
smoothness TEXT,
|
||||
access TEXT,
|
||||
tags TEXT,
|
||||
geom GEOMETRY
|
||||
);
|
||||
|
||||
DROP TABLE IF EXISTS poi;
|
||||
CREATE TABLE poi (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
osm_id INTEGER NOT NULL,
|
||||
poi_type TEXT,
|
||||
name TEXT,
|
||||
geom GEOMETRY
|
||||
);
|
||||
""")
|
||||
|
||||
if has_spatialite:
|
||||
try:
|
||||
cur.execute("SELECT AddGeometryColumn('trails', 'geom', 4326, 'LINESTRING', 'XY')")
|
||||
except Exception:
|
||||
pass # колонка уже добавлена через CREATE TABLE
|
||||
try:
|
||||
cur.execute("SELECT AddGeometryColumn('poi', 'geom', 4326, 'POINT', 'XY')")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cur.executescript("""
|
||||
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_poi_type ON poi(poi_type);
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
def insert_trails(conn, trails, has_spatialite):
|
||||
cur = conn.cursor()
|
||||
batch = []
|
||||
for t in trails:
|
||||
if has_spatialite:
|
||||
geom_expr = f"GeomFromWKB(x'{t['geom_wkb']}', 4326)"
|
||||
else:
|
||||
geom_expr = f"x'{t['geom_wkb']}'"
|
||||
|
||||
batch.append((
|
||||
t["osm_id"], t["highway_type"], t["track_type"], t["surface"],
|
||||
t["name"], t["length_m"], t["mtb_scale"], t["visibility"],
|
||||
t["smoothness"], t["access"], t["tags"],
|
||||
))
|
||||
|
||||
# Вставляем батчами по 1000
|
||||
BATCH = 1000
|
||||
for i in range(0, len(trails), BATCH):
|
||||
chunk = trails[i:i+BATCH]
|
||||
for t in chunk:
|
||||
cur.execute("""
|
||||
INSERT INTO trails
|
||||
(osm_id, highway_type, track_type, surface, name, length_m,
|
||||
mtb_scale, visibility, smoothness, access, tags, geom)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""", (
|
||||
t["osm_id"], t["highway_type"], t["track_type"], t["surface"],
|
||||
t["name"], t["length_m"], t["mtb_scale"], t["visibility"],
|
||||
t["smoothness"], t["access"], t["tags"],
|
||||
bytes.fromhex(t["geom_wkb"]),
|
||||
))
|
||||
conn.commit()
|
||||
print(f" trails: вставлено {min(i+BATCH, len(trails))}/{len(trails)}")
|
||||
|
||||
|
||||
def insert_pois(conn, pois):
|
||||
cur = conn.cursor()
|
||||
BATCH = 1000
|
||||
for i in range(0, len(pois), BATCH):
|
||||
chunk = pois[i:i+BATCH]
|
||||
for p in chunk:
|
||||
cur.execute("""
|
||||
INSERT INTO poi (osm_id, poi_type, name, geom)
|
||||
VALUES (?,?,?,?)
|
||||
""", (
|
||||
p["osm_id"], p["poi_type"], p["name"],
|
||||
bytes.fromhex(p["geom_wkb"]),
|
||||
))
|
||||
conn.commit()
|
||||
print(f" poi: вставлено {min(i+BATCH, len(pois))}/{len(pois)}")
|
||||
|
||||
|
||||
def create_spatial_indexes(conn, has_spatialite):
|
||||
if not has_spatialite:
|
||||
return
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute("SELECT CreateSpatialIndex('trails', 'geom')")
|
||||
conn.commit()
|
||||
print(" Пространственный индекс trails создан")
|
||||
except Exception as e:
|
||||
print(f" WARNING: индекс trails: {e}")
|
||||
try:
|
||||
cur.execute("SELECT CreateSpatialIndex('poi', 'geom')")
|
||||
conn.commit()
|
||||
print(" Пространственный индекс poi создан")
|
||||
except Exception as e:
|
||||
print(f" WARNING: индекс poi: {e}")
|
||||
|
||||
|
||||
# ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Парсинг OSM PBF → Spatialite")
|
||||
parser.add_argument(
|
||||
"--pbf",
|
||||
default=os.path.join(os.path.dirname(__file__), "../data/region.osm.pbf"),
|
||||
help="Путь к PBF файлу",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--db",
|
||||
default=os.path.join(os.path.dirname(__file__), "../data/centralfederal.sqlite"),
|
||||
help="Путь к выходному SQLite/Spatialite файлу",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
pbf_path = os.path.abspath(args.pbf)
|
||||
db_path = os.path.abspath(args.db)
|
||||
|
||||
if not os.path.exists(pbf_path):
|
||||
print(f"ERROR: PBF файл не найден: {pbf_path}")
|
||||
print("Сначала запустите scripts/download.sh")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"==> Читаем PBF: {pbf_path}")
|
||||
|
||||
print(" Парсим дороги...")
|
||||
trail_handler = TrailHandler()
|
||||
trail_handler.apply_file(pbf_path, locations=True)
|
||||
print(f" Найдено дорог: {len(trail_handler.trails)}")
|
||||
|
||||
print(" Парсим POI...")
|
||||
poi_handler = POIHandler()
|
||||
poi_handler.apply_file(pbf_path, locations=True)
|
||||
print(f" Найдено POI: {len(poi_handler.pois)}")
|
||||
|
||||
print(f"==> Открываем БД: {db_path}")
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
conn, has_spatialite = open_spatialite(db_path)
|
||||
|
||||
print("==> Инициализируем схему...")
|
||||
init_db(conn, has_spatialite)
|
||||
|
||||
print("==> Вставляем дороги...")
|
||||
insert_trails(conn, trail_handler.trails, has_spatialite)
|
||||
|
||||
print("==> Вставляем POI...")
|
||||
insert_pois(conn, poi_handler.pois)
|
||||
|
||||
print("==> Создаём пространственные индексы...")
|
||||
create_spatial_indexes(conn, has_spatialite)
|
||||
|
||||
conn.close()
|
||||
print(f"\n✓ Готово! БД сохранена: {db_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user