auto-sync: 2026-05-02 16:20:01

This commit is contained in:
Stream
2026-05-02 16:20:01 +03:00
parent 83d27f1c86
commit b3c8a05fac
2 changed files with 161 additions and 123 deletions

View File

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

View File

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