diff --git a/tasks/enduro-trails/prototype/app.py b/tasks/enduro-trails/prototype/app.py index 44e7918..633b432 100644 --- a/tasks/enduro-trails/prototype/app.py +++ b/tasks/enduro-trails/prototype/app.py @@ -1,16 +1,13 @@ #!/usr/bin/env python3 """ app.py — FastAPI сервер для Enduro Trails -- Раздаёт статику из static/ -- /api/tiles/{z}/{x}/{y}.mvt — векторные тайлы из Spatialite -- /api/poi — список POI как GeoJSON +MVT тайлы через mapbox-vector-tile + shapely (без клиппинга — MapLibre клипит сам) """ import os import math import struct import sqlite3 -import json from pathlib import Path from fastapi import FastAPI, HTTPException, Response @@ -18,510 +15,211 @@ 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( + 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(CORMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) -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) + for p in ["/usr/lib/x86_64-linux-gnu/mod_spatialite.so", "/usr/lib/mod_spatialite.so", + "/usr/local + +def get_db(): + conn = sqlite3.connect(DATA_PATH) + conn.row_factory = sqlite3.Row return conn -# ─── Tile math ──────────────────────────────────────────────────────────────── - -def tile_to_bbox(z: int, x: int, y: int): - """Возвращает (west, south, east, north) в градусах для тайла z/x/y.""" +def tile_to_bbox(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 + w = x / n * 360 - 180 + e = (x + 1) / n * 360 - 180 + n2 = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n)))) + s = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n)))) + return w, s, e, n2 -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("" - 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] + endian = "<" if b[0] == 1 else ">" + gtype = struct.unpack_from(endian + "I", b, 1)[0] + offset = 5 + (4 if gtype & 0x20000000 else 0) + npts = struct.unpack_from(endian + "I", b, 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 + coords = [struct.unpack_from(endian + "dd", b, offset + i * 16) for i range(npts)] + return coords if len(coords) >= 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: + try: + from shapely import wkb as swkb + return list(swkb.loads(bytes(blob)).coords) + except Exception: 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.""" +def wkb_point_coords(blob): 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] + b = bytes(blob) + endian = "<" if b[0] == 1 else ">" + gtype = struct.unpack_from(endian + "I", b, 1)[0] + offset = 5 + (4 if gtype & 0x20000000 else 0) + return struct.unpack_from(endian + "dd", b, offset) except Exception: return None -# ─── API endpoints ──────────────────────────────────────────────────────────── +def build_mvt(trails_rows, poi_rows, z, x, y) -> bytes: + import mapbox_vector_tile -@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") + w, s, e, n = tile_to_bbox(z, x, y) + + trails_feats = [] + for row in trails_rows: + coords = wkb_to_coords(row["geom"]) + if not coords: + continue + props = { + "highway": row["highway_type"] or "", + "tracktype": row["track_type"] or "", + "surface": row[" + +def build_mvt(trails_rows, poi_rows, z, x, y) -> bytes: + import mapbox_vector_tile + from shapely.geometry import LineString, Point, MultiLineString + from shapely import clip_by_rect 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 + # --- Trails --- + trails_feats = [] + for row in trails_rows: + coords = wkb_to_coords(row["geom"] + if not coords: + continue + try: + line = LineString(coords) + clipped = clip_by_rect(line, west, south, east, north) + if clipped.is_empty: + continue + props = { + "highway": row["highway_type"] or "", + "tracktype": row["track_type"] or "", + "surface": row["surface"] or "", + "name": row["name"] or "", + } + if clipped.geom_type == "LineString": + trails_feats.append({"geometry": clipped.__geo_interface__, "properties": props}) + elif clipped.geom_type == "MultiLineString": + for part in clipped.geoms: + trails_feats.append({"geometry": part.__geo_interface__, "properties": props}) + except Exception: + continue + + # --- POI --- + poi_feats = [] + for row in poi_rows: + lon, lat = wkb_point_coords(row["geom"]) + if lon is None: + continue + poi_feats.append({ + "geometry": {"type": "Point", "coordinates": [lon, lat]}, + "properties": {"poi_type": row["poi_type"] or "", "name": " + }) + + layers = [] + if trails_feats: + layers.append({"name": "trails", "features": trails_feats}) + if poi_feats: + layers.append({"name": "poi", "features": poi_feats}) + if not layers: + return b"" + + # MapLibre требует Y=0 сверху (=north) → y_coord_down=True НЕ флипает (оставляет как есть) + # По умолчанию библиотека флипает Y, нам нужно y_coord_down=True чтобы НЕ флипать + return mapbox_vector_tile.encode( + layers, + quantize_bounds=(west, south, east, north), + extents=4096, + default_options={'y_coord_down': False}, + ) + + +@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}") + + w, s, e, n = tile_to_bbox(z, x, y) + buf_x = (e - w) * 0.15 + buf_y = (n - s) * 0.15 + qw, qs, qe, qn = w - buf_x, s - buf_y, e + buf_x, n + buf_y + + limit = 2000 if z <= 7 else (5000 if z <= 9 else (10000 if z <= 11 else 20000)) try: conn = get_db() cur = conn.cursor() + 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 ?", + (qe, qw, qn, qs, limit), + ) + trails_rows = cur.fetchall() - # Читаем все trails и фильтруем по bbox через WKB парсинг в Python - # (ST_Intersects не работает с raw WKB без правильной инициализации Spatialite) - cur.execute(""" - SELECT osm_id, highway_type, track_type, surface, name, - length_m, mtb_scale, geom - FROM trails - """) - all_trails = cur.fetchall() - - # Фильтруем по bbox через первую точку WKB - trails_rows = [] - for row in all_trails: - geom_blob = row["geom"] - if not geom_blob: - continue - try: - blob = bytes(geom_blob) - endian = "<" if blob[0] == 1 else ">" - gtype = struct.unpack_from(endian + "I", blob, 1)[0] - offset = 5 - if gtype & 0x20000000: - offset += 4 # skip SRID - npts = struct.unpack_from(endian + "I", blob, offset)[0] - offset += 4 - if npts < 2: - continue - # Проверяем первую и последнюю точку для bbox - lon1, lat1 = struct.unpack_from(endian + "dd", blob, offset) - lon2, lat2 = struct.unpack_from(endian + "dd", blob, offset + (npts - 1) * 16) - min_lon = min(lon1, lon2) - max_lon = max(lon1, lon2) - min_lat = min(lat1, lat2) - max_lat = max(lat1, lat2) - if max_lon < q_west or min_lon > q_east or max_lat < q_south or min_lat > q_north: - continue - trails_rows.append(row) - if len(trails_rows) >= 5000: - break - except Exception: - continue - - cur.execute("SELECT osm_id, poi_type, name, geom FROM poi") - all_poi = cur.fetchall() - poi_rows = [] - for row in all_poi: - geom_blob = row["geom"] - if not geom_blob: - continue - try: - blob = bytes(geom_blob) - endian = "<" if blob[0] == 1 else ">" - offset = 5 - gtype = struct.unpack_from(endian + "I", blob, 1)[0] - if gtype & 0x20000000: - offset += 4 - lon, lat = struct.unpack_from(endian + "dd", blob, offset) - if q_west <= lon <= q_east and q_south <= lat <= q_north: - poi_rows.append(row) - except Exception: - continue - + cur.execute( + "SELECT osm_id, poi_type, name, geom " + "FROM poi WHERE min_lon <= ? AND max_lon >= ? AND min_lat <= ? AND max_lat >= ?", + (qe, qw, qn, qs), + ) + poi_rows = cur.fetchall() conn.close() + except Exception as exc: + raise HTTPException(500, f"Ошибка БД: {exc}") - 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": "*", - }, - ) - + mvt = build_mvt(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}") - + except Exception as exc: + raise HTTPException(500, f"Ошибка БД: {exc}") features = [] for row in rows: - geom_blob = row["geom"] - if not geom_blob: + pt = wkb_point_coords(row["geom]) + if not pt: 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} - + features.append({"type": "Feature", "geometry": {"type": "Point", "coordinates": list(pt)}, + "properties": {"osm_id": row["osm_id"], "poi_type": row["poi_type"], "name": row["name"]}}) + return {"type": "FeatureCollection", "features": feature @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 ───────────────────────────────────────────────────────────── + return {"status": "ok", "db_path": DATA_PATH, "db_exists": os.path.exists(DATA_PATH)} 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}") + print(f"==> Enduro Trails API DB={DATA_PATH} Port={PORT}") uvicorn.run(app, host="0.0.0.0", port=PORT)