Карточка игры «Сыграйте против МЕРЫ» показывает декоративный прямоугольник: сетка и две «дороги», нарисованные 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 м) выбрано под масштаб карточки — она показывает район целиком, где такая ошибка меньше толщины линии.
108 lines
5.2 KiB
Python
108 lines
5.2 KiB
Python
"""Экспорт границ районов Екатеринбурга в 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())
|