auto-sync: 2026-05-03 07:50:01
This commit is contained in:
@@ -14,15 +14,21 @@ import os
|
||||
import json
|
||||
import gzip
|
||||
import time
|
||||
import math
|
||||
import logging
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from functools import lru_cache
|
||||
|
||||
import orjson
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
from flask import Flask, jsonify, render_template_string, request, send_from_directory, Response
|
||||
from dotenv import load_dotenv
|
||||
from shapely.geometry import LineString
|
||||
from shapely.ops import transform
|
||||
from shapely import wkt
|
||||
|
||||
from noise_model import process_flight_for_map, get_noise_config, calc_zone_radii_for_point
|
||||
from density_model import compute_density
|
||||
@@ -645,6 +651,247 @@ def get_tracks():
|
||||
return jsonify({"error": str(e), "type": "FeatureCollection", "features": []}), 500
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────
|
||||
# Tile endpoint — GeoJSON с LRU-кэшем и упрощением по зуму
|
||||
# ─────────────────────────────────────────────────────
|
||||
|
||||
# --- LRU tile cache (в памяти, max 512 тайлов) ---
|
||||
_tile_lru_cache: dict[tuple, bytes] = {} # (z, x, y) → gzip-байты
|
||||
def _tile_cache_lru(maxsize: int = 512):
|
||||
return _tile_lru_cache
|
||||
|
||||
def _tile_cache_get(z: int, x: int, y: int):
|
||||
key = (z, x, y)
|
||||
if key in _tile_lru_cache:
|
||||
# LRU: переместить в конец (access order)
|
||||
val = _tile_lru_cache.pop(key)
|
||||
_tile_lru_cache[key] = val
|
||||
return val
|
||||
return None
|
||||
|
||||
def _tile_cache_put(z: int, x: int, y: int, data: bytes, maxsize: int = 512):
|
||||
key = (z, x, y)
|
||||
if key in _tile_lru_cache:
|
||||
del _tile_lru_cache[key]
|
||||
# Удаляем самый старый если достигли лимита
|
||||
while len(_tile_lru_cache) >= maxsize:
|
||||
_tile_lru_cache.pop(next(iter(_tile_lru_cache)))
|
||||
_tile_lru_cache[key] = data
|
||||
|
||||
|
||||
def _get_simplify_tolerance(z: int) -> float:
|
||||
"""Tolerance для simplify (в градусах) по зуму.
|
||||
|
||||
z < 7 — сильное упрощение
|
||||
z 7–9 — умеренное
|
||||
z >= 10 — лёгкое
|
||||
"""
|
||||
if z >= 10:
|
||||
return 0.0005 # ~50 m — почти без изменений
|
||||
elif z >= 7:
|
||||
return 0.002 + (9 - z) * 0.001 # 0.002–0.005 (~200–500 m)
|
||||
elif z >= 5:
|
||||
return 0.01 + (7 - z) * 0.015 # 0.01–0.04 (~1–4 km)
|
||||
else:
|
||||
return 0.08 # очень сильное для z<5
|
||||
|
||||
|
||||
# --- Cache для упрощённых треков: (z, track_id_md5) → simplified points list ---
|
||||
_simplify_cache: dict[tuple, list] = {}
|
||||
_SIMPLIFY_CACHE_MAXSIZE = 4096
|
||||
|
||||
def _simplify_cache_get(z: int, track_id_hash: str):
|
||||
key = (z, track_id_hash)
|
||||
if key in _simplify_cache:
|
||||
val = _simplify_cache.pop(key)
|
||||
_simplify_cache[key] = val
|
||||
return val
|
||||
return None
|
||||
|
||||
def _simplify_cache_put(z: int, track_id_hash: str, simplified_pts: list, maxsize: int = _SIMPLIFY_CACHE_MAXSIZE):
|
||||
key = (z, track_id_hash)
|
||||
if key in _simplify_cache:
|
||||
del _simplify_cache[key]
|
||||
while len(_simplify_cache) >= maxsize:
|
||||
_simplify_cache.pop(next(iter(_simplify_cache)))
|
||||
_simplify_cache[key] = simplified_pts
|
||||
|
||||
|
||||
def _tile_bounds(z: int, x: int, y: int) -> dict:
|
||||
"""Возвращает bounds тайла (lat/lon) + запас (buffer ~10%)."""
|
||||
def _tile2lon(tx, tz):
|
||||
return tx / (2.0**tz) * 360.0 - 180.0
|
||||
def _tile2lat(ty, tz):
|
||||
# Web Mercator inverse: lat = atan(sinh(π - 2π·y/2^z))
|
||||
n = math.pi - 2.0 * math.pi * ty / (2.0**tz)
|
||||
return math.degrees(math.atan(math.sinh(n)))
|
||||
|
||||
west = _tile2lon(x, z)
|
||||
south = _tile2lat(y + 1, z)
|
||||
east = _tile2lon(x + 1, z)
|
||||
north = _tile2lat(y, z)
|
||||
|
||||
# Небольшой буфер чтобы не обрезать треки на границах
|
||||
buf_lat = max(abs(north - south) * 0.05, 0.01)
|
||||
buf_lon = max(abs(east - west) * 0.05, 0.01)
|
||||
|
||||
return {
|
||||
"west": west - buf_lon,
|
||||
"east": east + buf_lon,
|
||||
"south": south - buf_lat,
|
||||
"north": north + buf_lat,
|
||||
}
|
||||
|
||||
|
||||
def _load_track_file(filepath: Path) -> list:
|
||||
"""Загружает трек из JSON файла, возвращает list of point dicts."""
|
||||
with open(filepath, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data
|
||||
|
||||
|
||||
def _simplify_track_points(points: list, tolerance: float) -> list:
|
||||
"""Упрощает трек через Shapely simplify, возвращает подмножество точек."""
|
||||
if len(points) < 3:
|
||||
return points
|
||||
|
||||
coords = [(p["lon"], p["lat"]) for p in points]
|
||||
try:
|
||||
line = LineString(coords)
|
||||
simplified = line.simplify(tolerance, preserve_topology=True)
|
||||
simplified_coords = list(simplified.coords)
|
||||
|
||||
# Находим ближайшие оригинальные точки для каждой упрощённой координаты
|
||||
result = []
|
||||
for sx, sy in simplified_coords:
|
||||
# Берём ближайшую оригинальную точку
|
||||
best = min(points,
|
||||
key=lambda p: (p["lon"] - sx)**2 + (p["lat"] - sy)**2)
|
||||
result.append(best)
|
||||
|
||||
# Убедимся что первая и последняя точки на месте
|
||||
if result and result[0] != points[0]:
|
||||
result.insert(0, points[0])
|
||||
if result and result[-1] != points[-1]:
|
||||
result.append(points[-1])
|
||||
|
||||
return result
|
||||
except Exception:
|
||||
# Если simplify упал — отдаём как есть
|
||||
return points
|
||||
|
||||
|
||||
@app.route("/api/tiles/tracks/<int:z>/<int:x>/<int:y>.geojson", methods=["GET"])
|
||||
def get_tracks_tile(z: int, x: int, y: int):
|
||||
"""
|
||||
GeoJSON tile с треками для MapLibre/Leaflet.
|
||||
|
||||
- LRU-кэш по (z, x, y)
|
||||
- Упрощение геометрии через Shapely по уровню зума
|
||||
- Кэш упрощения по (z, track_id)
|
||||
- Cache-Control для HTTP-кэширования
|
||||
"""
|
||||
# Проверяем кэш целого тайла
|
||||
cached = _tile_cache_get(z, x, y)
|
||||
if cached is not None:
|
||||
accept_enc = request.headers.get("Accept-Encoding", "")
|
||||
if "gzip" in accept_enc:
|
||||
return Response(cached, status=200, headers={
|
||||
"Content-Type": "application/json",
|
||||
"Content-Encoding": "gzip",
|
||||
"Cache-Control": f"public, max-age={3600}",
|
||||
})
|
||||
# Если клиент не поддерживает gzip, разжимаем из кэша
|
||||
try:
|
||||
plain = gzip.decompress(cached)
|
||||
except Exception:
|
||||
plain = cached
|
||||
return Response(plain, status=200, headers={
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": f"public, max-age={3600}",
|
||||
})
|
||||
|
||||
# Определяем tolerance по зуму
|
||||
tolerance = _get_simplify_tolerance(z)
|
||||
bounds = _tile_bounds(z, x, y)
|
||||
|
||||
features = []
|
||||
|
||||
# Сканируем все треки из кэшей
|
||||
for cache_dir_name in ["cache", "cache2"]:
|
||||
cache_dir = DATA_DIR / cache_dir_name
|
||||
if not cache_dir.exists():
|
||||
continue
|
||||
for track_file in sorted(cache_dir.glob("*.json")):
|
||||
track_id = track_file.stem # track_XXXXXXX
|
||||
|
||||
# --- Кэш упрощения по (z, track_id) ---
|
||||
track_id_hash = hashlib.md5(track_file.name.encode()).hexdigest()[:12]
|
||||
simplified_pts = _simplify_cache_get(z, track_id_hash)
|
||||
|
||||
if simplified_pts is None:
|
||||
# Загружаем и упрощаем
|
||||
raw_pts = _load_track_file(track_file)
|
||||
if len(raw_pts) < 2:
|
||||
continue
|
||||
if tolerance < 0.0005:
|
||||
simplified_pts = raw_pts
|
||||
else:
|
||||
simplified_pts = _simplify_track_points(raw_pts, tolerance)
|
||||
_simplify_cache_put(z, track_id_hash, simplified_pts)
|
||||
|
||||
# --- Проверка пересечения с тайлом ---
|
||||
pts_in_tile = []
|
||||
for p in simplified_pts:
|
||||
if (bounds["west"] <= p["lon"] <= bounds["east"] and
|
||||
bounds["south"] <= p["lat"] <= bounds["north"]):
|
||||
pts_in_tile.append(p)
|
||||
|
||||
if len(pts_in_tile) < 2:
|
||||
continue
|
||||
|
||||
# Собираем Feature
|
||||
coords = [[p["lon"], p["lat"]] for p in pts_in_tile]
|
||||
props = {
|
||||
"track_id": track_id,
|
||||
"callsign": pts_in_tile[0].get("callsign", ""),
|
||||
"points_count": len(pts_in_tile),
|
||||
}
|
||||
features.append({
|
||||
"type": "Feature",
|
||||
"properties": props,
|
||||
"geometry": {
|
||||
"type": "LineString",
|
||||
"coordinates": coords,
|
||||
},
|
||||
})
|
||||
|
||||
tile_data = {
|
||||
"type": "FeatureCollection",
|
||||
"features": features,
|
||||
"tile": {"z": z, "x": x, "y": y},
|
||||
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
}
|
||||
|
||||
raw = orjson.dumps(tile_data)
|
||||
gz = gzip.compress(raw, compresslevel=6)
|
||||
|
||||
# Сохраняем в LRU-кэш
|
||||
_tile_cache_put(z, x, y, gz)
|
||||
|
||||
accept_enc = request.headers.get("Accept-Encoding", "")
|
||||
if "gzip" in accept_enc:
|
||||
return Response(gz, status=200, headers={
|
||||
"Content-Type": "application/json",
|
||||
"Content-Encoding": "gzip",
|
||||
"Cache-Control": f"public, max-age={3600}",
|
||||
})
|
||||
return Response(raw, status=200, headers={
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": f"public, max-age={3600}",
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(os.getenv("PORT", 5555))
|
||||
debug = os.getenv("DEBUG", "true").lower() == "true"
|
||||
|
||||
@@ -3,3 +3,4 @@ requests>=2.31.0
|
||||
python-dotenv>=1.0.0
|
||||
urllib3>=2.0.0
|
||||
psycopg2-binary>=2.9.0
|
||||
shapely>=2.0.0
|
||||
|
||||
Reference in New Issue
Block a user