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 м) выбрано под масштаб карточки — она показывает
район целиком, где такая ошибка меньше толщины линии.
This commit is contained in:
bot-backend 2026-08-29 21:02:27 +05:00
parent 2e82b0810f
commit f88e06c090
2 changed files with 168 additions and 0 deletions

View file

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

View file

@ -0,0 +1,60 @@
/**
* Границы восьми районов Екатеринбурга в координатах SVG данные для карты в
* секции «Сыграйте против МЕРЫ».
*
* ЭТОТ ФАЙЛ СГЕНЕРИРОВАН, РУКАМИ НЕ ПРАВИТЬ. Источник боевая геометрия
* (`gendesign_ekb_districts_geom`, OSM через FDW), генератор
* `backend/scripts/export_ekb_districts_svg.py`. Там же записано, почему
* геометрия едет статикой, а не тайлами: публичное дерево держится без единого
* внешнего запроса, а тайловый провайдер получал бы IP посетителя и то, что он
* смотрит, на странице, которая обещает «без звонков и регистрации».
*
* ТОЧНОСТЬ. Упрощение 0.0012° (~70-130 м на этой широте) выбрано под масштаб
* карточки: она показывает район целиком, где такая ошибка меньше толщины
* линии. Для более крупного масштаба файл не годится перегенерируй.
*
* ПРОЕКЦИЯ. Равнопромежуточная с поправкой cos(широты). `project()` кладёт
* произвольную точку (lon, lat) в ту же систему координат, что и пути, без
* гео-библиотеки и без математики сложнее двух умножений.
*/
export interface EkbDistrict {
/** Имя ровно как в БД: по нему витрина сопоставляет сделку с полигоном. */
readonly name: string;
/** Готовый атрибут `d` для <path>. */
readonly d: string;
}
/** Константы проекции: едут вместе с путями, иначе точку не положить рядом. */
export const EKB_MAP = {
width: 1000.0,
height: 758.5,
minLon: 60.0070815,
maxLat: 56.9826914,
/** cos(широты центра) — поправка на сжатие долготы. */
k: 0.5477352775,
scale: 1950.117127,
} as const;
/**
* Точка (lon, lat) координаты внутри `EKB_MAP`. Возвращает точку и за
* пределами города: обрезать решает вызывающий, здесь молча зажимать нельзя
* сделка за границей карты означает ошибку данных, а не край экрана.
*/
export function project(lon: number, lat: number): { x: number; y: number } {
return {
x: (lon - EKB_MAP.minLon) * EKB_MAP.k * EKB_MAP.scale,
y: (EKB_MAP.maxLat - lat) * EKB_MAP.scale,
};
}
export const EKB_DISTRICTS: readonly EkbDistrict[] = [
{ name: "Академический", d: "M559.5 342.4L604.2 372.8L589.1 395.3L572.0 412.3L542.1 435.0L529.7 439.1L475.0 402.6L470.7 396.8L402.4 355.9L416.1 351.7L422.2 341.2L432.5 339.1L436.7 330.4L446.2 323.6L468.4 329.2L484.5 325.7L494.4 318.5L526.5 314.5L559.5 342.4Z" },
{ name: "Верх-Исетский", d: "M637.0 281.5L614.0 285.5L621.2 325.3L603.8 325.8L589.3 345.4L576.1 336.4L573.7 340.1L564.2 335.0L559.5 342.4L526.5 314.5L494.4 318.5L484.5 325.7L468.4 329.2L446.9 323.5L441.9 325.7L432.5 339.1L421.6 341.8L416.1 351.7L402.4 355.9L470.7 396.8L475.0 402.6L507.3 422.8L504.3 434.5L486.7 431.1L484.7 440.6L475.7 439.0L477.7 429.4L448.8 423.2L422.7 446.4L416.3 457.0L406.7 463.3L375.3 455.5L369.0 494.5L313.4 484.4L308.6 509.6L297.8 512.2L291.2 499.1L297.2 494.7L296.4 491.8L300.1 482.1L291.5 480.4L286.4 496.5L271.0 505.7L204.2 484.9L179.9 446.0L175.6 446.6L174.6 459.1L172.5 459.0L170.7 453.9L158.8 447.2L155.2 440.6L143.2 433.2L140.9 428.3L136.8 428.0L118.8 413.2L117.2 408.6L119.9 405.3L116.0 402.1L114.1 395.8L102.0 398.6L99.9 395.3L95.3 398.7L86.3 393.7L81.3 383.4L74.2 376.6L68.6 377.1L67.4 387.0L56.0 389.3L53.5 386.9L52.7 375.1L50.2 374.0L55.3 365.4L61.3 363.7L58.9 357.1L60.3 351.1L70.6 355.9L75.6 349.4L67.9 342.3L69.5 338.6L68.1 335.0L59.3 328.7L57.2 329.4L56.7 334.6L59.3 343.8L52.5 342.9L51.9 346.4L49.3 346.3L40.1 337.7L28.7 346.5L19.2 343.9L14.7 349.8L17.8 358.6L4.8 351.7L0.0 344.8L4.2 341.1L7.5 346.9L15.3 337.0L37.5 331.6L45.8 331.6L49.5 335.4L57.1 315.7L67.3 309.8L85.1 318.1L89.6 316.7L88.4 323.0L93.1 322.5L99.6 315.0L104.9 315.3L109.1 319.6L104.8 327.0L105.2 334.0L111.3 333.9L115.5 325.7L122.4 325.9L128.5 331.6L136.3 329.7L129.3 334.1L129.6 334.2L140.2 328.5L128.4 335.7L145.1 331.3L148.1 335.4L147.3 341.2L149.5 345.3L156.8 346.3L157.4 349.6L152.1 352.4L156.8 352.1L154.9 355.8L160.1 360.7L161.0 368.1L157.2 372.2L163.1 378.7L161.4 387.5L165.3 390.5L162.8 393.7L169.3 393.6L170.1 396.7L167.8 402.8L171.9 408.2L176.9 405.3L176.8 410.4L184.5 410.6L189.6 403.3L190.9 396.1L197.7 389.6L324.3 411.0L346.0 287.6L340.7 287.3L337.1 278.8L328.3 269.9L302.8 269.0L281.7 262.2L297.8 257.5L315.5 245.6L334.4 248.6L390.2 233.4L419.3 213.7L425.4 205.9L428.8 206.2L432.2 213.3L426.7 222.4L440.1 236.1L452.9 222.1L461.6 223.1L459.7 211.9L463.4 205.5L483.5 214.5L485.2 211.4L498.5 211.2L505.7 219.7L514.0 216.5L531.2 234.2L541.9 234.7L552.8 241.8L599.1 243.1L606.9 246.8L601.6 255.1L603.4 258.6L600.9 259.9L603.8 262.2L601.8 264.8L619.4 261.3L635.1 273.2L637.0 281.5Z" },
{ name: "Железнодорожный", d: "M645.0 206.7L646.8 237.5L653.7 239.8L660.6 259.8L642.2 263.3L635.1 273.2L619.4 261.3L601.8 264.8L603.8 262.2L600.9 259.9L603.4 258.6L601.6 255.1L606.9 246.8L599.1 243.1L552.8 241.8L541.9 234.7L531.2 234.2L514.0 216.5L505.7 219.7L498.5 211.2L485.2 211.4L483.5 214.5L463.4 205.5L459.7 211.9L461.6 223.1L452.9 222.1L440.1 236.1L426.7 222.4L432.2 213.3L428.8 206.2L425.4 205.9L419.3 213.7L390.2 233.4L334.4 248.6L315.5 245.6L299.4 256.7L282.6 262.1L276.5 250.1L270.7 251.6L280.2 197.8L282.6 198.0L291.0 141.8L325.6 147.0L341.2 35.1L378.0 41.2L372.4 78.3L391.4 82.1L389.6 99.0L423.0 108.8L425.6 116.9L434.2 124.1L491.5 130.9L497.9 116.8L501.1 114.8L497.6 111.0L510.5 101.4L531.4 93.6L538.4 84.3L534.6 127.6L538.5 147.0L551.5 165.7L581.1 187.1L579.4 196.8L592.4 214.6L600.8 223.1L606.2 223.0L628.3 216.2L645.0 206.7Z" },
{ name: "Кировский", d: "M668.3 275.1L637.0 281.5L635.1 273.2L642.2 263.3L660.6 259.8L657.8 254.8L656.7 245.3L649.7 237.2L658.3 230.8L656.2 227.8L669.5 223.7L669.7 220.3L675.0 222.3L678.9 219.8L680.0 215.0L690.4 215.6L704.2 225.6L711.5 225.4L714.3 206.0L720.8 196.2L717.7 194.7L726.0 182.8L725.3 176.5L720.7 168.0L722.6 156.1L722.1 139.7L740.0 124.6L747.7 107.6L760.3 94.4L763.0 87.7L764.3 93.6L781.5 102.0L780.8 117.7L782.3 120.0L779.0 123.6L782.9 124.5L778.1 150.4L773.7 150.2L777.7 151.9L777.3 154.5L765.3 161.4L764.9 176.8L789.6 181.8L784.8 213.8L797.1 213.4L818.4 221.6L818.5 224.9L826.8 222.5L833.6 228.6L838.2 227.6L835.9 234.9L816.6 239.0L815.9 243.4L1000.0 277.7L998.2 288.1L993.3 288.6L779.6 298.0L789.7 298.8L789.8 312.9L804.4 315.8L800.9 335.0L732.3 308.7L709.2 296.4L692.7 298.7L671.8 288.0L668.3 275.1Z" },
{ name: "Ленинский", d: "M529.7 439.1L542.1 435.0L572.0 412.3L589.1 395.3L604.2 372.8L559.5 342.4L564.2 335.0L573.7 340.1L576.1 336.4L589.3 345.4L603.8 325.8L621.2 325.3L614.0 285.5L645.6 279.8L648.6 300.9L639.6 303.7L645.3 306.5L650.9 316.3L667.1 325.1L673.3 333.9L632.0 341.9L634.6 356.7L627.9 357.7L631.3 386.6L604.1 390.5L601.9 400.7L595.7 405.4L595.6 411.6L601.1 425.2L614.7 432.9L616.9 446.7L609.7 452.9L606.5 460.1L593.9 451.1L551.5 444.4L549.6 449.3L529.7 439.1Z" },
{ name: "Октябрьский", d: "M657.9 337.0L673.3 333.9L667.1 325.1L650.9 316.3L645.3 306.5L639.6 303.7L648.6 300.9L645.6 279.8L668.3 275.1L671.8 288.0L692.7 298.7L709.2 296.4L732.3 308.7L800.9 335.0L804.4 315.8L789.8 312.9L789.7 298.8L779.6 298.0L998.0 288.1L969.6 425.2L973.6 421.0L976.1 424.1L973.8 425.4L974.9 427.4L966.4 430.9L963.2 427.8L953.3 429.9L947.6 434.3L942.4 444.4L936.2 447.1L931.3 476.4L928.6 477.3L917.0 500.9L923.3 504.7L943.2 502.1L958.9 515.4L969.1 518.7L956.7 527.2L925.5 559.6L916.9 545.7L916.7 541.2L913.7 540.7L894.9 510.1L878.8 512.8L869.1 520.3L831.1 493.0L831.3 488.7L824.7 485.2L823.8 474.8L817.5 470.4L818.5 463.0L798.9 449.1L809.0 437.1L807.4 431.5L784.8 422.3L744.2 394.2L683.5 361.1L677.9 368.5L667.0 358.5L657.9 337.0Z" },
{ name: "Орджоникидзевский", d: "M649.7 237.2L646.8 237.5L646.1 233.6L645.0 206.7L626.2 217.0L600.8 223.1L579.4 196.8L581.1 187.1L545.1 159.0L537.2 143.7L534.8 131.6L538.4 84.3L543.8 88.8L559.5 83.0L609.4 76.8L644.6 81.2L648.0 77.3L660.1 75.2L693.9 76.6L704.5 57.0L700.8 54.4L709.9 46.5L707.9 40.4L707.7 13.3L728.0 16.8L730.9 0.0L766.8 6.8L765.1 16.3L779.1 16.5L776.5 24.5L783.1 29.4L784.8 23.8L791.3 24.8L793.6 13.4L787.0 14.3L792.7 11.8L802.7 13.8L798.7 34.2L801.8 34.8L800.5 41.7L809.3 42.8L812.4 39.8L811.8 46.9L809.3 46.8L807.9 53.5L779.3 48.0L771.8 55.2L772.6 59.7L757.9 66.3L763.0 87.7L760.3 94.4L747.7 107.6L740.0 124.6L722.1 139.7L722.6 156.1L720.7 168.0L725.3 176.5L726.0 182.8L717.7 194.7L720.8 196.2L714.3 206.0L711.5 225.4L704.2 225.6L690.4 215.6L680.0 215.0L678.9 219.8L675.0 222.3L669.7 220.3L669.5 223.7L656.2 227.8L658.3 230.8L649.7 237.2Z" },
{ name: "Чкаловский", d: "M616.5 447.2L613.9 431.4L601.1 425.2L595.0 406.9L601.9 400.7L604.1 390.5L631.3 386.6L627.9 357.7L634.6 356.7L632.0 341.9L657.9 337.0L667.0 358.5L677.9 368.5L683.5 361.1L744.2 394.2L784.8 422.3L807.4 431.5L809.0 437.1L798.9 449.1L813.4 460.7L804.3 470.8L794.9 488.1L786.0 487.1L784.5 481.9L776.0 475.8L760.9 491.4L765.8 495.8L779.7 497.8L780.2 507.2L787.8 519.4L780.6 527.8L791.9 544.5L795.7 559.4L809.6 561.4L813.6 549.8L829.8 549.3L829.8 551.7L824.6 552.0L824.3 561.6L804.7 561.9L793.9 560.0L779.0 551.1L779.3 555.6L694.2 601.2L692.5 646.3L645.1 642.3L641.6 655.2L657.8 660.3L655.2 674.0L650.7 678.4L665.2 703.5L689.4 708.5L679.3 733.5L635.6 713.6L626.6 703.8L622.0 707.1L613.6 700.8L608.3 727.6L616.4 731.0L611.8 735.3L604.6 733.4L592.5 740.1L586.2 736.6L589.3 719.6L572.7 716.3L571.3 723.0L563.7 721.5L561.8 730.9L572.9 748.5L570.7 758.5L566.5 746.9L559.9 740.3L535.5 735.7L551.1 732.6L547.8 724.5L526.5 730.1L524.0 740.3L460.6 715.5L471.9 695.3L428.2 661.8L432.8 653.8L428.8 651.2L456.0 624.4L450.6 621.3L451.6 613.0L455.3 607.3L441.4 609.5L427.4 619.6L368.9 636.9L362.1 633.3L360.9 640.1L358.2 641.1L348.3 635.8L338.7 638.0L335.8 634.4L337.4 625.2L334.7 621.0L306.0 611.1L301.8 603.6L303.6 602.8L302.2 593.1L296.7 584.1L279.5 584.8L274.9 580.4L274.3 577.0L277.9 570.8L276.4 570.0L281.9 561.8L279.6 557.2L272.6 558.5L273.2 548.2L263.1 531.5L265.3 522.2L243.9 508.5L211.1 499.9L192.7 487.0L186.2 490.9L170.1 468.6L173.0 468.5L171.7 462.2L174.6 459.1L175.6 446.6L179.9 446.0L204.2 484.9L271.0 505.7L286.4 496.5L291.5 480.4L300.1 482.1L296.4 491.8L297.2 494.7L291.2 499.1L297.8 512.2L308.6 509.6L313.4 484.4L369.0 494.5L375.3 455.5L406.7 463.3L416.3 457.0L422.7 446.4L448.8 423.2L477.7 429.4L475.7 439.0L484.7 440.6L486.7 431.1L504.3 434.5L507.3 422.8L533.9 442.9L549.6 449.3L551.5 444.4L593.9 451.1L606.5 460.1L609.7 452.9L616.5 447.2Z" },
];