auto-sync: 2026-05-03 07:50:01

This commit is contained in:
Stream
2026-05-03 07:50:01 +03:00
parent 14844be1e6
commit 6fc5c8cf36
2 changed files with 248 additions and 0 deletions

View File

@@ -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 79 — умеренное
z >= 10 — лёгкое
"""
if z >= 10:
return 0.0005 # ~50 m — почти без изменений
elif z >= 7:
return 0.002 + (9 - z) * 0.001 # 0.0020.005 (~200500 m)
elif z >= 5:
return 0.01 + (7 - z) * 0.015 # 0.010.04 (~14 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"

View File

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