From b3c8a05fac613081f19272c1849a6ddd3aa8da25 Mon Sep 17 00:00:00 2001 From: Stream Date: Sat, 2 May 2026 16:20:01 +0300 Subject: [PATCH] auto-sync: 2026-05-02 16:20:01 --- memory/2026-05-02.md | 5 + tasks/enduro-trails/prototype/app.py | 279 +++++++++++++++------------ 2 files changed, 161 insertions(+), 123 deletions(-) diff --git a/memory/2026-05-02.md b/memory/2026-05-02.md index 449fc7f..1d50710 100644 --- a/memory/2026-05-02.md +++ b/memory/2026-05-02.md @@ -105,3 +105,8 @@ - [ ] Синхронизировать app.py с workspace: `tasks/enduro-trails/prototype/app.py` - [ ] Обновить PROJECT.md и TASK.md для enduro-trails - [ ] Обновить онтологию для проекта enduro-trails + + +## Enduro Trails — ревью прототипа (12:53 UTC) +- Слава попросил ревью — после серии горячих фиксов (clip_by_rect, y_coord_down, swap lat/lon) код запутан +- TODO: провести полное ревью app.py и index.html diff --git a/tasks/enduro-trails/prototype/app.py b/tasks/enduro-trails/prototype/app.py index 633b432..60b5ad8 100644 --- a/tasks/enduro-trails/prototype/app.py +++ b/tasks/enduro-trails/prototype/app.py @@ -1,13 +1,16 @@ #!/usr/bin/env python3 """ app.py — FastAPI сервер для Enduro Trails -MVT тайлы через mapbox-vector-tile + shapely (без клиппинга — MapLibre клипит сам) +- Раздаёт статику из static/ +- /api/tiles/{z}/{x}/{y}.mvt — векторные тайлы из SQLite +- /api/health — статус БД """ import os import math import struct import sqlite3 +import json from pathlib import Path from fastapi import FastAPI, HTTPException, Response @@ -15,22 +18,26 @@ from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware import uvicorn -DATA_PATH = os.path.abspath( - os.environ.get("DATA_PATH", os.path.join(os.path.dirname(__file__), "../data/centralfederal.sqlite")) +# ─── Конфиг ─────────────────────────────────────────────────────────────────── + +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(CORMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) -def get_db(): - conn = sqlite3.connect(DATA_PATH) - conn.row_factory = sqlite3.Row - conn.enable_load_extension(True) - for p in ["/usr/lib/x86_64-linux-gnu/mod_spatialite.so", "/usr/lib/mod_spatialite.so", - "/usr/local +# ─── DB ─────────────────────────────────────────────────────────────────────── def get_db(): conn = sqlite3.connect(DATA_PATH) @@ -38,188 +45,214 @@ def get_db(): return conn -def tile_to_bbox(z, x, y): +# ─── Tile math ──────────────────────────────────────────────────────────────── + +def tile_to_bbox(z: int, x: int, y: int): n = 2 ** z - 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 + 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 wkb_to_coords(blob): +# ─── WKB parser ─────────────────────────────────────────────────────────────── + +def wkb_to_coords(blob: bytes): + """Парсит WKB LineString с опциональным SRID, возвращает [(lon, lat), ...].""" try: b = bytes(blob) if len(b) < 9: return None endian = "<" if b[0] == 1 else ">" gtype = struct.unpack_from(endian + "I", b, 1)[0] - offset = 5 + (4 if gtype & 0x20000000 else 0) + base_type = gtype & 0xFF + if base_type != 2: + return None + offset = 5 + if gtype & 0x20000000: + offset += 4 npts = struct.unpack_from(endian + "I", b, offset)[0] offset += 4 - coords = [struct.unpack_from(endian + "dd", b, offset + i * 16) for i range(npts)] + coords = [] + for _ in range(npts): + lon, lat = struct.unpack_from(endian + "dd", b, offset) + offset += 16 + coords.append((lon, lat)) return coords if len(coords) >= 2 else None - except Exception: - try: - from shapely import wkb as swkb - return list(swkb.loads(bytes(blob)).coords) - except Exception: - return None - - -def wkb_point_coords(blob): - try: - 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 +def wkb_point_coords(blob: bytes): + """Парсит WKB Point, возвращает (lon, lat).""" + try: + b = bytes(blob) + if len(b) < 21: + return None + endian = "<" if b[0] == 1 else ">" + gtype = struct.unpack_from(endian + "I", b, 1)[0] + base_type = gtype & 0xFF + if base_type != 1: + return None + offset = 5 + if gtype & 0x20000000: + offset += 4 + lon, lat = struct.unpack_from(endian + "dd", b, offset) + return lon, lat + except Exception: + return None + + +# ─── MVT builder ────────────────────────────────────────────────────────────── + def build_mvt(trails_rows, poi_rows, z, x, y) -> bytes: + """Собирает MVT тайл. Передаёт полные геометрии без серверного клиппинга. + MapLibre GL сам клипит на клиенте. quantize_bounds расширяем на 10% чтобы + точки за границей тайла правильно квантизировались.""" import mapbox_vector_tile - w, s, e, n = tile_to_bbox(z, x, y) + west, south, east, north = tile_to_bbox(z, x, y) - trails_feats = [] + trails_features = [] 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) - - # --- 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}) + trails_features.append({ + "geometry": {"type": "LineString", "coordinates": coords}, + "properties": props, + }) except Exception: continue - # --- POI --- - poi_feats = [] + poi_features = [] for row in poi_rows: - lon, lat = wkb_point_coords(row["geom"]) - if lon is None: + pt = wkb_point_coords(row["geom"]) + if not pt: + continue + try: + props = { + "poi_type": row["poi_type"] or "", + "name": row["name"] or "", + } + poi_features.append({ + "geometry": {"type": "Point", "coordinates": list(pt)}, + "properties": props, + }) + except Exception: 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 trails_features: + layers.append({"name": "trails", "features": trails_features}) + if poi_features: + layers.append({"name": "poi", "features": poi_features}) + if not layers: return b"" - # MapLibre требует Y=0 сверху (=north) → y_coord_down=True НЕ флипает (оставляет как есть) - # По умолчанию библиотека флипает Y, нам нужно y_coord_down=True чтобы НЕ флипать + # Расширяем quantize_bounds на 10% за каждую сторону + # чтобы точки за пределами тайла не получали пиксели >4096 + dx = (east - west) * 0.1 + dy = (north - south) * 0.1 + return mapbox_vector_tile.encode( layers, - quantize_bounds=(west, south, east, north), + quantize_bounds=(west - dx, south - dy, east + dx, north + dy), extents=4096, - default_options={'y_coord_down': False}, + default_options={'y_coord_down': True}, ) +# ─── API endpoints ──────────────────────────────────────────────────────────── + @app.get("/api/tiles/{z}/{x}/{y}.mvt") -async def get_tile(z: int, x: int, y: int +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 + west, south, east, north = tile_to_bbox(z, x, y) - limit = 2000 if z <= 7 else (5000 if z <= 9 else (10000 if z <= 11 else 20000)) + # Расширенный bbox для SQL-запроса (на 15% за каждую сторону) + buf_x = (east - west) * 0.15 + buf_y = (north - south) * 0.15 + q_west = west - buf_x + q_east = east + buf_x + q_south = south - buf_y + q_north = north + buf_y + + # Лимиты по зуму + if z <= 6: + limit = 500 + elif z <= 8: + limit = 3000 + elif z <= 10: + limit = 8000 + else: + limit = 15000 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), - ) + + cur.execute(f""" + 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)) trails_rows = cur.fetchall() - 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() + 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) + conn.close() - except Exception as exc: - raise HTTPException(500, f"Ошибка БД: {exc}") + except Exception as e: + raise HTTPException(500, f"Ошибка БД: {e}") 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 exc: - raise HTTPException(500, f"Ошибка БД: {exc}") - features = [] - for row in rows: - pt = wkb_point_coords(row["geom]) - if not pt: - continue - 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 + return Response( + content=mvt, + media_type="application/x-protobuf", + headers={ + "Content-Encoding": "identity", + "Access-Control-Allow-Origin": "*", + }, + ) + @app.get("/api/health") async def health(): - return {"status": "ok", "db_path": DATA_PATH, "db_exists": os.path.exists(DATA_PATH)} + return { + "status": "ok", + "db_path": DATA_PATH, + "db_exists": os.path.exists(DATA_PATH), + } + + +# ─── Static files ───────────────────────────────────────────────────────────── if os.path.exists(STATIC_DIR): app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static") + if __name__ == "__main__": print(f"==> Enduro Trails API DB={DATA_PATH} Port={PORT}") uvicorn.run(app, host="0.0.0.0", port=PORT)