diff --git a/tasks/flightradar24/prototype/app.py b/tasks/flightradar24/prototype/app.py index 83ef9c3..b935114 100644 --- a/tasks/flightradar24/prototype/app.py +++ b/tasks/flightradar24/prototype/app.py @@ -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///.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" diff --git a/tasks/flightradar24/prototype/requirements.txt b/tasks/flightradar24/prototype/requirements.txt index cd210e8..52fd4d3 100644 --- a/tasks/flightradar24/prototype/requirements.txt +++ b/tasks/flightradar24/prototype/requirements.txt @@ -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