gendesign/tradein-mvp/backend/scripts/export_ekb_districts_svg.py
bot-backend 7b12b07935 feat(mera/b2c): границы районов ЕКБ статикой — основа настоящей карты в игре
Карточка игры «Сыграйте против МЕРЫ» показывает декоративный прямоугольник:
сетка и две «дороги», нарисованные CSS. Заменяем настоящей геометрией.

ПОЧЕМУ СТАТИКА, А НЕ ТАЙЛЫ. Публичное дерево mera-public держится без единого
внешнего запроса — шрифты self-hosted, Leaflet с unpkg сюда намеренно не тянули
(шапка layout.tsx). Тайловый провайдер получал бы IP посетителя и то, что он
смотрит, на странице, которая обещает «без звонков и регистрации». Границы
районов не меняются годами, раздавать их в рантайме незачем.

Что в коммите:
· backend/scripts/export_ekb_districts_svg.py — генератор. У данных обязано быть
  проверяемое происхождение: источник — боевая gendesign_ekb_districts_geom
  (OSM через FDW, читается с 29.08 — грант #3227), допуск упрощения и проекция
  названы в докстринге вместе с причиной выбора.
· _components/v3/ekb-districts.ts — сгенерированное, руками не править.
  8 районов, 7826 символов путей, плюс project() для точки (lon,lat).

Упрощение 0.0012° (~70-130 м) выбрано под масштаб карточки — она показывает
район целиком, где такая ошибка меньше толщины линии.
2026-08-29 23:25:44 +05:00

108 lines
5.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Экспорт границ районов Екатеринбурга в 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())