auto-sync: 2026-05-02 16:20:01
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user