"""Экспорт границ районов Екатеринбурга в SVG-пути для лэндинга МЕРЫ. ЗАЧЕМ ГЕНЕРАТОР, А НЕ РУЧНОЙ БЛОБ. Файл `frontend/src/app/mera-public/_components/v3/ekb-districts.ts` — данные, а у данных обязано быть проверяемое происхождение. Этот скрипт единственный источник того файла: запусти его и получишь тот же результат. ПОЧЕМУ ГЕОМЕТРИЯ ЕДЕТ СТАТИКОЙ, А НЕ ТАЙЛАМИ. 1. Публичное дерево `app/mera-public/**` держится без единого внешнего запроса (см. шапку layout.tsx: шрифты self-hosted, Leaflet с unpkg сюда намеренно не тянули). Тайловый провайдер — это внешний CDN на каждой загрузке. 2. Тайлы утекают провайдеру IP посетителя и то, ЧТО он смотрит, — на странице, которая обещает «без звонков и регистрации». Своя геометрия такого не делает. 3. Границы районов не меняются годами: раздавать их запросом в рантайме незачем. ТОЧНОСТЬ. Упрощение 0.0012° (~70-130 м на широте Екатеринбурга) выбрано под масштаб карточки: она показывает район целиком, где такая ошибка меньше толщины линии. Для более крупного масштаба этот файл не годится — перегенерируй с меньшим допуском и проверь размер. ПРОЕКЦИЯ. Равнопромежуточная с поправкой cos(широты): на масштабе одного города искажение незаметно, а взамен фронту не нужна ни библиотека, ни рантайм-математика сложнее двух умножений. Константы проекции едут вместе с путями, чтобы точку по (lon, lat) можно было положить в ту же систему координат. Запуск (только чтение): docker exec tradein-backend python scripts/export_ekb_districts_svg.py """ from __future__ import annotations import json import math import os from typing import Any import psycopg # Допуск упрощения в градусах. Смена значения обязана сопровождаться замером # длины путей: файл едет в бандл каждому посетителю. SIMPLIFY_DEG = 0.0012 # Ширина системы координат. Высота считается из формы города. VIEWBOX_WIDTH = 1000.0 SQL = """ SELECT district_name, ST_AsGeoJSON(ST_SimplifyPreserveTopology(geom, %(tol)s)) AS gj FROM gendesign_ekb_districts_geom ORDER BY district_name """ def _rings(geojson: dict[str, Any]) -> list[list[list[float]]]: """Полигон и мультиполигон — к одному виду: список колец.""" if geojson["type"] == "Polygon": return geojson["coordinates"] return [ring for poly in geojson["coordinates"] for ring in poly] def main() -> int: dsn = os.environ["DATABASE_URL"].replace("postgresql+psycopg://", "postgresql://") with psycopg.connect(dsn) as conn, conn.cursor() as cur: cur.execute(SQL, {"tol": SIMPLIFY_DEG}) rows = [(name, _rings(json.loads(gj))) for name, gj in cur.fetchall()] points = [p for _, rings in rows for ring in rings for p in ring] min_lon = min(p[0] for p in points) max_lat = max(p[1] for p in points) lat_mid = (min(p[1] for p in points) + max_lat) / 2 k = math.cos(math.radians(lat_mid)) width_deg = max((p[0] - min_lon) * k for p in points) scale = VIEWBOX_WIDTH / width_deg height = round(max((max_lat - p[1]) for p in points) * scale, 1) def path(rings: list[list[list[float]]]) -> str: out: list[str] = [] for ring in rings: for i, (lon, lat) in enumerate(ring): x = round((lon - min_lon) * k * scale, 1) y = round((max_lat - lat) * scale, 1) out.append(f"{'M' if i == 0 else 'L'}{x} {y}") out.append("Z") return "".join(out) districts = [{"name": name, "d": path(rings)} for name, rings in rows] print( json.dumps( { "meta": { "minLon": min_lon, "maxLat": max_lat, "k": round(k, 10), "scale": round(scale, 6), "width": VIEWBOX_WIDTH, "height": height, "simplifyDeg": SIMPLIFY_DEG, }, "districts": districts, }, ensure_ascii=False, ) ) return 0 if __name__ == "__main__": raise SystemExit(main())