From 7b12b07935f191bd0f725dc90c488d3b3dabac31 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 21:02:27 +0500 Subject: [PATCH 1/8] =?UTF-8?q?feat(mera/b2c):=20=D0=B3=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=86=D1=8B=20=D1=80=D0=B0=D0=B9=D0=BE=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=95=D0=9A=D0=91=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D0=BA?= =?UTF-8?q?=D0=BE=D0=B9=20=E2=80=94=20=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=20=D0=BD=D0=B0=D1=81=D1=82=D0=BE=D1=8F=D1=89=D0=B5=D0=B9=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D1=8B=20=D0=B2=20=D0=B8=D0=B3=D1=80?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Карточка игры «Сыграйте против МЕРЫ» показывает декоративный прямоугольник: сетка и две «дороги», нарисованные 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 м) выбрано под масштаб карточки — она показывает район целиком, где такая ошибка меньше толщины линии. --- .../scripts/export_ekb_districts_svg.py | 108 ++++++++++++++++++ .../_components/v3/ekb-districts.ts | 60 ++++++++++ 2 files changed, 168 insertions(+) create mode 100644 tradein-mvp/backend/scripts/export_ekb_districts_svg.py create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/v3/ekb-districts.ts diff --git a/tradein-mvp/backend/scripts/export_ekb_districts_svg.py b/tradein-mvp/backend/scripts/export_ekb_districts_svg.py new file mode 100644 index 00000000..ae9d2697 --- /dev/null +++ b/tradein-mvp/backend/scripts/export_ekb_districts_svg.py @@ -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()) diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/ekb-districts.ts b/tradein-mvp/frontend/src/app/mera-public/_components/v3/ekb-districts.ts new file mode 100644 index 00000000..f454c7c3 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/ekb-districts.ts @@ -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` для . */ + 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" }, +]; -- 2.45.3 From f3401badd007677e1fbb609529c706fc6ef5b921 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 21:10:02 +0500 Subject: [PATCH 2/8] =?UTF-8?q?feat(mera/b2c):=20=D0=BA=D0=BE=D0=BE=D1=80?= =?UTF-8?q?=D0=B4=D0=B8=D0=BD=D0=B0=D1=82=D0=B0=20=D1=81=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B4=D0=BE=D0=B5=D0=B7=D0=B6=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=20=D0=B4=D0=BE=20=D0=B2=D0=B8=D1=82=D1=80=D0=B8=D0=BD=D1=8B=20?= =?UTF-8?q?=E2=80=94=20=D1=81=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D0=BE=D0=B9=20=D0=B3=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B5?= =?UTF-8?q?=D0=B9=20=D1=87=D0=B5=D1=81=D1=82=D0=BD=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Карта лэндинга не может показать точку, пока её нет в витрине: район, комнаты, площадь и квартал в `landing_showcase_deals` есть, координаты не было. Что сделано: миграция 280 добавляет lat/lon (double precision, NULLABLE), задача пересчёта переносит их из `deals`, ручка /showcase отдаёт их как Optional[float]. ГРАНИЦА ЧЕСТНОСТИ, записанная в трёх местах (COMMENT колонок, `note` каждой строки, докстринг задачи), а не только в голове автора: это ЦЕНТРОИД УЛИЦЫ, а не дом. Замер на проде 2026-08-29 по той самой выборке, из которой набирается витрина (deals, city='Екатеринбург', deal_date >= '2025-01-01'): 34 021 сделка, 34 017 с координатой, но РАЗЛИЧНЫХ точек всего 991 — ≈34 сделки в одной точке, при 2.7% известных номеров дома. Точка верна на масштабе района и улицы и неверна на масштабе дома; `note` едет на фронт вместе с числами, поэтому следующий, кто возьмётся зумить карту, об этом споткнётся. NULLABLE и без отбраковки: строка без координаты остаётся на витрине с lat=lon=None. Выбрасывать сделку за отсутствие точки — отбор по признаку, не связанному с качеством оценки, то есть та же порча витрины, которую здесь уже чинили (порог по величине ошибки). Тесты двусторонние, каждый проверен фальсификацией — краснеет по ЗНАЧЕНИЮ: * перестановка lat/lon в build_row → 60.6055 == 56.8386 * `if lat is None: return None` → None is not None * перестановка lat/lon в ручке → 60.6055 == 56.8386 * фильтр строк без координат в ручке → len([]) == 1 Перестановку широты и долготы не ловит ни схема, ни тип (обе float), поэтому в фикстурах намеренно непохожие величины: 56.8386 против 60.6055. Фронтенд не тронут — его делает следующий шаг. Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/api/public/mera.py | 16 ++++++- .../app/tasks/landing_showcase_deals.py | 37 ++++++++++++-- .../sql/280_landing_showcase_deals_coords.sql | 48 +++++++++++++++++++ .../tests/test_landing_showcase_deals.py | 33 +++++++++++++ .../backend/tests/test_public_mera_api.py | 42 ++++++++++++++++ 5 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/280_landing_showcase_deals_coords.sql diff --git a/tradein-mvp/backend/app/api/public/mera.py b/tradein-mvp/backend/app/api/public/mera.py index 4b8288c0..2c8badf5 100644 --- a/tradein-mvp/backend/app/api/public/mera.py +++ b/tradein-mvp/backend/app/api/public/mera.py @@ -395,6 +395,13 @@ class ShowcaseDeal(BaseModel): ДКП-данных может не быть, и фронт обязан пережить null, а не получить правдоподобную подстановку. Улицы и дома в модели нет вовсе — номер дома есть у 2.7% сделок (разбор в миграции 276). + + `lat` / `lon` — ЦЕНТРОИД УЛИЦЫ, не дом: 991 различная координата на 34 017 + сделок выборки (≈34 сделки в одной точке) при тех же 2.7% известных домов + (замер 2026-08-29, миграция 280). Точка честна на масштабе района и улицы + и НЕ честна на масштабе дома — то же самое написано в `note` строки, + которая едет рядом. Тоже нуллабельны: у части сделок координаты нет, и + такая строка остаётся на витрине без точки, а не выбрасывается. """ district: str | None @@ -408,6 +415,8 @@ class ShowcaseDeal(BaseModel): err_pct: float n_analogs: int note: str + lat: float | None = None + lon: float | None = None class ShowcaseStats(BaseModel): @@ -458,7 +467,7 @@ _SHOWCASE_RUN_SQL = text( _SHOWCASE_SQL = text( """ SELECT district, rooms, area_m2, floor, total_floors, deal_quarter, - predicted_rub, fact_rub, err_pct, n_analogs, note + predicted_rub, fact_rub, err_pct, n_analogs, note, lat, lon FROM landing_showcase_deals WHERE computed_at = CAST(:computed_at AS timestamptz) ORDER BY id @@ -515,6 +524,11 @@ def public_showcase( err_pct=float(r["err_pct"]), n_analogs=int(r["n_analogs"]), note=r["note"], + # Порядок не менять: lat — широта (~56.8 в ЕКБ), lon — долгота + # (~60.6). Перепутанные местами координаты остаются валидными + # float и уедут на карту точкой в другой стране. + lat=(float(r["lat"]) if r["lat"] is not None else None), + lon=(float(r["lon"]) if r["lon"] is not None else None), ) for r in rows ], diff --git a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py index 5f8a064a..9de881c4 100644 --- a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py +++ b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py @@ -37,6 +37,13 @@ ---------------------------------------------------------- * АДРЕСА НЕТ. Номер дома есть у 2.7% сделок, поэтому строка — это «район + 2-к, 54 м², 5 эт.», и никогда не улица с домом. + * ТОЧКА НА КАРТЕ — ЦЕНТРОИД УЛИЦЫ, НЕ ДОМ. В выборке витрины (ЕКБ, + с 2025-01-01; замер на проде 2026-08-29) 991 различная координата на + 34 017 сделок с координатой — ≈34 сделки в одной точке, при 2.7% известных + номеров дома. Точка честна на масштабе района и улицы и НЕ честна на + масштабе дома. Это записано в `note` каждой строки и в COMMENT колонок + (миграция 280), потому что докстринг на фронт не едет, а зумить карту + будет тот, кто его не читал. * ДНЯ НЕТ. `deals.deal_date` — первое число квартала (10 различных значений на всю таблицу), поэтому в витрине только «II квартал 2026». * ЗАМЕР НЕ POINT-IN-TIME. Спайн считает прогноз по СЕГОДНЯШНИМ активным @@ -102,7 +109,10 @@ NOTE = ( "Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: " "это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. " "Факт — цена ДКП, заявленная в Росреестр: она бывает занижена сторонами, и тогда " - "строка выглядит как промах оценщика, хотя врёт документ." + "строка выглядит как промах оценщика, хотя врёт документ. " + "Точка на карте — центроид улицы, а не дом: в выборке витрины 991 различная " + "координата на 34 017 сделок (≈34 сделки в одной точке), номер дома известен " + "у 2.7% сделок. Точка честна на масштабе района и улицы и не честна на масштабе дома." ) _ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV"} @@ -131,6 +141,10 @@ class ShowcaseRow: fact_rub: int err_pct: float n_analogs: int + # Координата сделки — ЦЕНТРОИД УЛИЦЫ (замер и разбор в миграции 280 и в NOTE). + # None штатно: у части сделок координаты нет, подставлять туда нечего. + lat: float | None = None + lon: float | None = None def completeness(row: ShowcaseRow) -> int: @@ -168,6 +182,8 @@ def build_row( predicted_rub: float | None, fact_ppm2: float, n_analogs: int, + lat: float | None = None, + lon: float | None = None, ) -> ShowcaseRow | None: """Кандидат → строка витрины, либо None если считать не из чего. @@ -175,6 +191,11 @@ def build_row( цены продажи; квартал сделки неизвестен; нет площади или цены сделки (делить не на что). Величина отклонения причиной НЕ является ни при каких значениях — см. «ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ» в докстринге модуля. + + ОТСУТСТВИЕ КООРДИНАТЫ ПРИЧИНОЙ ТОЖЕ НЕ ЯВЛЯЕТСЯ. Строка без точки едет на + витрину с lat=lon=None: карта переживёт сделку без точки, а выбрасывание + сделки из-за отсутствия координаты — отбор по признаку, не связанному с + качеством оценки, то есть та же порча витрины, что и отбор по ошибке. """ if predicted_rub is None or predicted_rub <= 0 or area_m2 <= 0 or fact_ppm2 <= 0: return None @@ -200,6 +221,8 @@ def build_row( fact_rub=round(fact_rub), err_pct=round(err_pct, 2), n_analogs=n_analogs, + lat=lat, + lon=lon, ) @@ -255,13 +278,14 @@ _INSERT_SQL = text( """ INSERT INTO landing_showcase_deals (district, rooms, area_m2, floor, total_floors, deal_quarter, - predicted_rub, fact_rub, err_pct, n_analogs, note) + predicted_rub, fact_rub, err_pct, n_analogs, note, lat, lon) VALUES (CAST(:district AS text), CAST(:rooms AS integer), CAST(:area_m2 AS numeric), CAST(:floor AS integer), CAST(:total_floors AS integer), CAST(:deal_quarter AS text), CAST(:predicted_rub AS bigint), CAST(:fact_rub AS bigint), CAST(:err_pct AS numeric), - CAST(:n_analogs AS integer), CAST(:note AS text)) + CAST(:n_analogs AS integer), CAST(:note AS text), + CAST(:lat AS double precision), CAST(:lon AS double precision)) """ ) @@ -328,6 +352,11 @@ def refresh_landing_showcase_deals( predicted_rub=pr.expected_sold_price, fact_ppm2=deal.sold_ppm2, n_analogs=len(capture[0]["kwargs"]["listings"]) if capture else 0, + # Порядок ровно такой: lat — широта (~56.8 для ЕКБ), lon — долгота + # (~60.6). Перепутать местами — это точка в другой стране, и никакой + # тип этого не поймает: обе величины float. + lat=deal.lat, + lon=deal.lon, ) if row is None: n_incomplete += 1 @@ -353,6 +382,8 @@ def refresh_landing_showcase_deals( "err_pct": row.err_pct, "n_analogs": row.n_analogs, "note": NOTE, + "lat": row.lat, + "lon": row.lon, }, ) counters = { diff --git a/tradein-mvp/backend/data/sql/280_landing_showcase_deals_coords.sql b/tradein-mvp/backend/data/sql/280_landing_showcase_deals_coords.sql new file mode 100644 index 00000000..c78803fd --- /dev/null +++ b/tradein-mvp/backend/data/sql/280_landing_showcase_deals_coords.sql @@ -0,0 +1,48 @@ +-- 280: координата сделки в витрине лэндинга — чтобы карта могла показать точку. +-- +-- ЧТО ЭТО ЗА КООРДИНАТА. Не координата ДОМА. Замер по той самой выборке, +-- из которой набирается витрина (deals, city='Екатеринбург', deal_date >= +-- '2025-01-01'; прод, 2026-08-29): +-- +-- 34 021 сделка, из них 34 017 с координатой (без неё — 4), +-- но РАЗЛИЧНЫХ точек (lat, lon) всего 991 → ≈34 сделки на одну точку, +-- номер дома в адресе известен у 2.7% сделок. +-- +-- Тридцать четыре сделки в одной точке при 2.7% известных домов — это не дома, +-- это центроиды улиц (тот же разбор, из-за которого в миграции 276 нет колонки +-- адреса). Отсюда граница честности: точка верна на масштабе района и улицы и +-- НЕВЕРНА на масштабе дома. Карта, зумящаяся до дома, покажет 34 разные сделки +-- в одном подъезде — это будет враньё интерфейса, а не данных. +-- +-- Поэтому: то же самое написано в `note` каждой строки витрины (note едет на +-- фронт вместе с числами, а комментарий к колонке — нет), и это единственное +-- место, которое увидит следующий, кто возьмётся зумить карту. +-- +-- NULLABLE — потому что у части сделок координаты нет (4 из 34 021 в замере +-- выше). Подставлять туда центр города или район значило бы поставить точку +-- там, где её в данных нет. Строка без координаты остаётся на витрине: +-- выбрасывать сделку из-за отсутствия точки — это отбор по признаку, не +-- связанному с качеством оценки, ровно то, что в этой витрине уже чинили. + +BEGIN; +-- Конвенция проекта (#2752): ADD COLUMN на существующей таблице берёт +-- ACCESS EXCLUSIVE и без lock_timeout встанет в очередь за чужой сессией, +-- утащив за собой запросы приложения. +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE landing_showcase_deals + ADD COLUMN IF NOT EXISTS lat double precision, + ADD COLUMN IF NOT EXISTS lon double precision; + +COMMENT ON COLUMN landing_showcase_deals.lat IS + 'Широта сделки — ЦЕНТРОИД УЛИЦЫ, не дом. Замер 2026-08-29 по выборке ' + 'витрины (ЕКБ, с 2025-01-01): 991 различная точка на 34 017 сделок с ' + 'координатой (≈34 сделки на точку), номер дома известен у 2.7%. Честно ' + 'на масштабе района/улицы, НЕ честно на масштабе дома. NULL = координаты ' + 'нет (4 сделки из 34 021); строка при этом остаётся на витрине.'; + +COMMENT ON COLUMN landing_showcase_deals.lon IS + 'Долгота сделки — ЦЕНТРОИД УЛИЦЫ, не дом; см. COMMENT на lat: 991 точка ' + 'на 34 017 сделок, дом известен у 2.7%. NULL = координаты нет.'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_landing_showcase_deals.py b/tradein-mvp/backend/tests/test_landing_showcase_deals.py index 7b5a51ba..42bde9bd 100644 --- a/tradein-mvp/backend/tests/test_landing_showcase_deals.py +++ b/tradein-mvp/backend/tests/test_landing_showcase_deals.py @@ -93,6 +93,8 @@ def _build(**over: object) -> ShowcaseRow | None: "predicted_rub": 5_000_000.0, "fact_ppm2": 100_000.0, # → факт 5 000 000 ₽, ошибка 0% "n_analogs": 30, + "lat": 56.8386, # ЕКБ: широта ~56.8, долгота ~60.6 — величины НЕ похожи, + "lon": 60.6055, # поэтому перестановка ловится по значению. } kwargs.update(over) return build_row(**kwargs) # type: ignore[arg-type] @@ -169,3 +171,34 @@ def test_unknown_quarter_is_not_invented() -> None: assert _build(deal_date=None) is None assert quarter_label(None) is None assert quarter_label(date(2026, 7, 1)) == "III квартал 2026" + + +# ── Координата: центроид улицы, но обязана доехать и обязана не перепутаться ── + + +def test_coords_are_carried_through_and_not_swapped() -> None: + """Широта остаётся широтой. Обе величины float, тип перестановку не поймает. + + Ломать так: поменять в `build_row` местами `lat=lat` и `lon=lon` — тест + покраснеет по ЗНАЧЕНИЮ (56.8386 против 60.6055), а не по форме. + """ + row = _build() + assert row is not None + assert row.lat == 56.8386, "широта уехала не в ту колонку" + assert row.lon == 60.6055, "долгота уехала не в ту колонку" + + +def test_row_without_coords_stays_on_showcase() -> None: + """Нет точки — строка всё равно на витрине, с lat=lon=None. + + Выбрасывать сделку из-за отсутствия координаты — отбор по признаку, не + связанному с качеством оценки: та же порча витрины, что и отбор по + величине ошибки, просто по другому полю. Карта переживёт строку без точки. + + Ломать так: добавить в `build_row` `if lat is None or lon is None: return + None` — тест покраснеет на None вместо строки. + """ + row = _build(lat=None, lon=None) + assert row is not None, "сделка выброшена из витрины за отсутствие координаты" + assert row.lat is None and row.lon is None, "координата придумана там, где её нет" + assert row.fact_rub == 5_000_000, "остальные величины строки не пострадали" diff --git a/tradein-mvp/backend/tests/test_public_mera_api.py b/tradein-mvp/backend/tests/test_public_mera_api.py index b0033ac7..a7e1c556 100644 --- a/tradein-mvp/backend/tests/test_public_mera_api.py +++ b/tradein-mvp/backend/tests/test_public_mera_api.py @@ -179,6 +179,10 @@ _SHOWCASE_ROW = { "err_pct": 3.39, "n_analogs": 41, "note": "не point-in-time", + # Центроид улицы, не дом (миграция 280). ЕКБ: широта ~56.8, долгота ~60.6 — + # величины намеренно непохожие, чтобы перестановка ловилась по значению. + "lat": 56.8386, + "lon": 60.6055, } _SHOWCASE_RUN = { "computed_at": datetime(2026, 8, 29, 10, 0, tzinfo=UTC), @@ -219,6 +223,44 @@ def test_anonymous_gets_showcase(client: TestClient) -> None: assert "address" not in body["deals"][0] +def test_showcase_carries_coords_without_swapping_them(client: TestClient) -> None: + """Координата доезжает до JSON и не переставлена местами. + + Обе величины float, поэтому перестановку не поймает ни схема, ни тип — + только значение: 56.8 (широта ЕКБ) против 60.6 (долгота). Ломать так: + поменять местами `lat=` и `lon=` в сборке `ShowcaseDeal` — покраснеет здесь. + + Точка при этом — ЦЕНТРОИД УЛИЦЫ, а не дом (991 различная координата на + 34 017 сделок выборки); ровно это написано в `note`, который едет рядом. + """ + client.app.dependency_overrides[get_db] = lambda: _showcase_db() + + deal = client.get(f"{PREFIX}/showcase").json()["deals"][0] + assert deal["lat"] == 56.8386, "широта приехала не в то поле" + assert deal["lon"] == 60.6055, "долгота приехала не в то поле" + + +def test_showcase_row_without_coords_reaches_the_client_as_null( + client: TestClient, +) -> None: + """Сделка без координаты доезжает до ответа целиком, с lat=lon=null. + + Карта переживёт строку без точки; выбрасывание такой сделки было бы + отбором по признаку, не связанному с качеством оценки. Подстановка центра + города вместо отсутствующей точки — тем более враньё. + + Ломать так: заменить null на любую подстановку или отфильтровать строку в + ручке — покраснеет по значению, а не по форме. + """ + row = {**_SHOWCASE_ROW, "lat": None, "lon": None} + client.app.dependency_overrides[get_db] = lambda: _showcase_db(rows=[row]) + + deals = client.get(f"{PREFIX}/showcase").json()["deals"] + assert len(deals) == 1, "строка без координаты выброшена из витрины" + assert deals[0]["lat"] is None and deals[0]["lon"] is None + assert deals[0]["fact_rub"] == 5_900_000, "остальные величины строки не пострадали" + + def test_showcase_carries_counters_so_20_rows_cannot_read_as_all_there_was( client: TestClient, ) -> None: -- 2.45.3 From 272d807f558b1f1ed50f7c9e26a4a50641b0249d Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 21:32:32 +0500 Subject: [PATCH 3/8] =?UTF-8?q?feat(mera/b2c):=20=D0=B2=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B5=20=D0=B8=D0=B3=D1=80=D1=8B?= =?UTF-8?q?=20=D0=BD=D0=B0=D1=81=D1=82=D0=BE=D1=8F=D1=89=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=B0=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BE=20=D0=B4=D0=B5=D0=BA=D0=BE=D1=80=D0=B0=D1=86=D0=B8=D0=B8?= =?UTF-8?q?=20=E2=80=94=20=D0=B8=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D1=8C,=20=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D1=83=D1=8E=20=D0=B4?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=B2=D1=8B=D0=B4=D0=B5=D1=80?= =?UTF-8?q?=D0=B6=D0=B8=D0=B2=D0=B0=D1=8E=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Блок `gameMap` рисовал CSS-сетку и два прямоугольника-«дороги», а подпись говорила «ОБЪЕКТ · РАЙОН» — то есть заявляла местоположение объекта. Географии там не было вовсе. Что сделано: `DealMapV3` — инлайновый по статике `ekb-districts.ts`, без единого внешнего запроса (то же правило, по которому в этом дереве self-hosted шрифты). Район сделки — заливка и обводка акцентом, соседние — контур; кадр по bbox района с запасом, не по городу: в городском кадре район занимает несколько процентов площади. Точка — по `project(lon, lat)` координаты витрины. ПОДПИСЬ. Координата в данных — ЦЕНТРОИД УЛИЦЫ, не дом: 34 021 сделка, 34 017 с координатой, различных точек 991, номер дома известен у 2.7% (прод, 29.08.2026). Поэтому карта подписана «СЕРЕДИНА УЛИЦЫ, НЕ ДОМ · РАЙОН» — она произносит границу вслух, а не оставляет читателю догадываться по размеру маркера. Слова «объект» и «адрес» в карточке не появляются. ТОЧКИ НЕТ — ДВА СЛУЧАЯ, оба дают карту с подсвеченным районом и без прочерка: координаты нет; координата есть, но лежит вне кадра. Второе не теоретическое — 2 132 строки из 34 021 (6.3%) в той же выборке имеют координату за пределами города, встречаются точки за сотни километров. Прижать такую к краю значило бы показать место, которого в данных нет; подпись в обоих случаях — «КООРДИНАТЫ НЕТ». Доступность: aria-hidden, всё его содержание (район) стоит рядом текстом дважды — в подписи карты и в мете сделки. Гейт витринных чисел: из скана исключён сгенерированный `ekb-districts.ts` — координаты полигонов не утверждают ничего о рынке. Исключение не бланковое: добавлена проверка, что исключённый файл несёт маркер генератора. Проверено глазами на dev-сервере с заглушкой бэкенда: три состояния (Кировский с точкой, Чкаловский — кадр едет, сделка без координаты). --- .../mera-public/__tests__/deal-map.test.tsx | 86 ++++++++++ .../__tests__/landing-numbers-gate.test.ts | 21 ++- .../__tests__/landing-v3-render.test.tsx | 2 + .../mera-public/_components/v3/DealMapV3.tsx | 159 ++++++++++++++++++ .../_components/v3/GuessGameV3.tsx | 19 +-- .../mera-public/_components/v3/deal-view.ts | 25 ++- .../src/app/mera-public/b2c-tokens.ts | 7 + .../src/app/mera-public/landing-v3.module.css | 66 +++----- .../src/app/mera-public/public-api.ts | 7 + .../frontend/src/app/mera-public/theme.ts | 1 + 10 files changed, 334 insertions(+), 59 deletions(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/v3/DealMapV3.tsx diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx new file mode 100644 index 00000000..d49010cb --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx @@ -0,0 +1,86 @@ +/** + * Карта сделки: что она обязана показать и о чём обязана промолчать. + * + * Проверяется не «рисуется ли svg», а три решения, каждое из которых при поломке + * даёт правдоподобную картинку с неверным смыслом: + * · кадр берётся по району сделки, а не по городу (иначе район — пятно); + * · точка ставится ТОЛЬКО когда координата попала в кадр (иначе далёкая + * координата прижмётся к краю и будет прочитана как место сделки); + * · подпись говорит, что точка — середина улицы, а не дом, и не называет её + * адресом объекта. + */ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { DealMapV3 } from "../_components/v3/DealMapV3"; +import { EKB_MAP } from "../_components/v3/ekb-districts"; + +/** Ширина кадра из атрибута viewBox отрисованной карты. */ +function frameWidth(container: HTMLElement): number { + const box = container.querySelector("svg")!.getAttribute("viewBox")!; + return Number(box.split(" ")[2]); +} + +const dots = (container: HTMLElement) => container.querySelectorAll("circle").length; + +describe("карта сделки", () => { + it("кадрируется по району сделки, а не по городу", () => { + const home = render( + , + ); + const nowhere = render( + , + ); + expect(frameWidth(home.container)).toBeLessThan(frameWidth(nowhere.container)); + // Без района показывается город целиком — это и есть «кадра нет». + expect(frameWidth(nowhere.container)).toBeGreaterThanOrEqual(EKB_MAP.width); + }); + + it("кадр разный у разных районов — иначе карта не про эту сделку", () => { + const frames = ["Кировский", "Чкаловский", "Академический"].map((name) => { + const { container } = render( + , + ); + return container.querySelector("svg")!.getAttribute("viewBox")!; + }); + expect(new Set(frames).size).toBe(frames.length); + }); + + it("точка внутри кадра рисуется, а подпись говорит, что это улица, а не дом", () => { + // Точка внутри полигона Чкаловского — проверена тем же `project()`, что + // рисует карту. + const { container, getByText } = render( + , + ); + expect(dots(container)).toBeGreaterThan(0); + expect(getByText(/СЕРЕДИНА УЛИЦЫ, НЕ ДОМ · ЧКАЛОВСКИЙ/u)).toBeTruthy(); + }); + + it("координата вне кадра не рисуется и не выдаётся за место сделки", () => { + // Такие строки в данных есть: город записан «Екатеринбург», координата — за + // сотни километров. Прижать её к краю кадра значило бы показать место, + // которого в данных нет. + const { container, getByText } = render( + , + ); + expect(dots(container)).toBe(0); + expect(getByText(/КООРДИНАТЫ НЕТ · ЧКАЛОВСКИЙ/u)).toBeTruthy(); + }); + + it("без координаты карта остаётся, район подсвечен, прочерка нет", () => { + const { container, getByText } = render( + , + ); + expect(dots(container)).toBe(0); + expect(container.querySelectorAll("path").length).toBeGreaterThan(0); + expect(getByText(/КООРДИНАТЫ НЕТ · ЛЕНИНСКИЙ/u)).toBeTruthy(); + expect(container.textContent).not.toMatch(/—\s*$/u); + }); + + it("карта не называет себя адресом объекта", () => { + const { container } = render( + , + ); + expect(container.textContent).not.toMatch(/АДРЕС|ОБЪЕКТ/u); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts index 6e34f1ab..369ba1a0 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts @@ -64,10 +64,25 @@ const V3_DIR = "_components/v3"; * Компонент, положенный ВНЕ каталога, каталогом не ловится — за это отвечает * отдельная проверка «всё, что импортирует корень, попало в список». */ +/** + * Единственное исключение — таблица координат, СГЕНЕРИРОВАННАЯ из боевой + * геометрии (`backend/scripts/export_ekb_districts_svg.py`). Её числа — не + * величины о рынке, а границы районов: «56.8» в пути полигона ничего не + * утверждает и источник у неё проверяемый, записанный в шапке файла. Правило + * (1) на ней даёт шум, за которым правило перестают читать. + * + * Исключение НЕ бланковое: ниже отдельная проверка требует, чтобы каждый + * исключённый файл нёс маркер генератора. Иначе строчка в этом списке стала бы + * способом занести в компонент любое число, сославшись на генератор, которого + * нет. + */ +const GENERATED = ["ekb-districts.ts"]; +const GENERATED_MARK = "СГЕНЕРИРОВАН"; + const V3_SOURCES = [ LANDING_ROOT, ...readdirSync(join(ROOT, V3_DIR)) - .filter((name) => /\.tsx?$/u.test(name)) + .filter((name) => /\.tsx?$/u.test(name) && !GENERATED.includes(name)) .sort() .map((name) => `${V3_DIR}/${name}`), ]; @@ -140,6 +155,10 @@ describe("гейт витринных чисел (#2904)", () => { } }); + it.each(GENERATED)("исключённый из скана %s действительно сгенерирован, а не написан руками", (name) => { + expect(read(`${V3_DIR}/${name}`)).toContain(GENERATED_MARK); + }); + it("сканер видит вписанное число и молчит на честном коде — иначе он проходил бы вхолостую", () => { // Контроль на инструмент. Слева — ровно то, что гейт обязан ловить // (величины из прежнего marketing-v3.ts), справа — код, который в diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx index ed59741d..ae83d8a5 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx @@ -54,6 +54,8 @@ const SHOWCASE: ShowcaseResponse = { err_pct: 0.58, n_analogs: 12, note: "Прогноз посчитан по активным объявлениям на дату пересчёта", + lat: null, + lon: null, }, ], stats: { diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealMapV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealMapV3.tsx new file mode 100644 index 00000000..d56f964c --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealMapV3.tsx @@ -0,0 +1,159 @@ +/** + * DealMapV3 — карта сделки в карточке игры «Сыграйте против МЕРЫ». + * + * Заменяет декорацию, которая стояла тут раньше: CSS-сетку и две «дороги», + * нарисованные прямоугольниками. Географии в них не было вовсе, а подпись + * «ОБЪЕКТ · РАЙОН» заявляла местоположение объекта — то, чего в данных нет. + * + * БЕЗ ЕДИНОГО ВНЕШНЕГО ЗАПРОСА. Геометрия районов приезжает статикой из + * `ekb-districts.ts` (сгенерирована из боевой OSM-таблицы), рисуется инлайновым + * . Тайлы сюда не тянули сознательно: провайдер получал бы IP посетителя и + * то, что он смотрит, на странице, которая обещает «без звонков и регистрации» + * (то же правило, по которому в дереве self-hosted шрифты — см. шапку + * layout.tsx). + * + * КАДР — ПО РАЙОНУ, А НЕ ПО ГОРОДУ. Административные границы Екатеринбурга + * включают огромные незастроенные территории: в кадре «весь город» район сделки + * занимает несколько процентов площади и читается как пятно. Кадрируем по bbox + * района с запасом PAD, соседние районы остаются контуром — они и дают понять, + * какая это часть города. Кадр раздвигается до пропорции карточки, а + * `preserveAspectRatio="…meet"` показывает его ЦЕЛИКОМ: `slice` обрезал бы + * края, и точка у границы района могла бы уехать за поле зрения — то есть + * проверка «точка в кадре» перестала бы совпадать с тем, что видно. + * + * ТОЧКА — И ЧТО ОНА ЗНАЧИТ. Координата в витрине — ЦЕНТРОИД УЛИЦЫ, не дом + * (замер на проде 29.08.2026 по выборке витрины: 34 021 сделка, 34 017 с + * координатой, но различных точек 991 — около тридцати сделок в одной точке + * при 2.7% известных номеров дома; та же граница записана в `note` каждой + * строки). Поэтому подпись говорит «СЕРЕДИНА УЛИЦЫ, НЕ ДОМ», а не «объект» и + * не «адрес»: точка верна на масштабе улицы и неверна на масштабе дома, и + * карточка обязана произносить это вслух, а не оставлять читателю догадываться + * по размеру маркера. + * + * КОГДА ТОЧКИ НЕТ. Два случая, оба дают карту без точки и с подсвеченным + * районом — ни прочерка, ни подстановки центра района: + * · координаты нет (`lat`/`lon` = null); + * · координата есть, но лежит ВНЕ кадра. Это не теоретический случай: в той + * же прод-выборке 2 132 строки из 34 021 (6.3%) имеют координату за + * пределами города — встречаются точки за сотни километров. Рисовать их + * прижатыми к краю значило бы показать место, которого в данных нет. + * Подпись в обоих случаях меняется на «КООРДИНАТЫ НЕТ», чтобы пустая карта не + * читалась как «объект где-то тут, просто не отметили». + * + * ДОСТУПНОСТЬ. помечен `aria-hidden`: всё, что он сообщает — район сделки + * и характер точки, — стоит рядом текстом (подпись под картой плюс район в + * мете сделки). Дублировать это ещё и в `role="img"`+`aria-label` значило бы + * прочитать одно и то же дважды подряд; ничего, кроме района, карта не знает. + * + * Компонент чистый и без состояния — директивы "use client" в нём нет. В + * клиентский бандл он всё равно попадает: карточка игры листает раунды + * состоянием, и карта живёт внутри её дерева. + */ + +import { EKB_DISTRICTS, EKB_MAP, project } from "./ekb-districts"; +import styles from "../../landing-v3.module.css"; + +/** Запас вокруг района: без него граница упирается в край кадра. */ +const PAD = 0.45; +/** + * Полоса пустого кадра СНИЗУ. Нижнюю треть плашки занимает подпись объекта + * (комнаты, площадь, этаж, квартал) — она лежит поверх карты. Без этой полосы + * район приходится на середину плашки, и точка примерно в трети случаев + * оказывается ЗА текстом: видно её или нет, решал бы район сделки. + */ +const BAND = 0.3; +/** Пропорция кадра под карточку — шире, чем выше. */ +const RATIO = 1.35; + +interface Box { + readonly x: number; + readonly y: number; + readonly w: number; + readonly h: number; +} + +const CITY: Box = { x: 0, y: 0, w: EKB_MAP.width, h: EKB_MAP.height }; + +/** + * bbox готового атрибута `d`. Пути генератора — только абсолютные `M`/`L` с + * парами координат, поэтому чисел всегда чётное число и разбор пар точен; для + * дуг и относительных команд он бы не годился, но их там нет. + */ +function pathBox(d: string): Box { + const nums = (d.match(/-?\d+(?:\.\d+)?/gu) ?? []).map(Number); + const xs = nums.filter((_, i) => i % 2 === 0); + const ys = nums.filter((_, i) => i % 2 === 1); + const x = Math.min(...xs); + const y = Math.min(...ys); + return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y }; +} + +/** Расширяет кадр на PAD, добавляет полосу под подпись и доводит до RATIO. */ +function frameFor(box: Box): Box { + const padW = box.w * (1 + PAD * 2); + const padH = box.h * (1 + PAD * 2); + const x = box.x + box.w / 2 - padW / 2; + const y = box.y + box.h / 2 - padH / 2; + const bandH = padH * (1 + BAND); + let w = padW; + let h = bandH; + if (w / h < RATIO) w = h * RATIO; + else h = w / RATIO; + // Центр по горизонтали сохраняется, по вертикали — нет: район должен сидеть + // ВЫШЕ середины, освобождая низ под подпись. + return { x: x + padW / 2 - w / 2, y, w, h }; +} + +const inside = (f: Box, x: number, y: number): boolean => + x >= f.x && x <= f.x + f.w && y >= f.y && y <= f.y + f.h; + +export interface DealMapProps { + /** Район сделки как он записан в данных, либо `null`. */ + readonly district: string | null; + /** Готовая подпись района — «район не указан», когда его нет. */ + readonly districtLabel: string; + readonly lat: number | null; + readonly lon: number | null; +} + +export function DealMapV3({ district, districtLabel, lat, lon }: DealMapProps) { + const home = EKB_DISTRICTS.find((d) => d.name === district); + const frame = frameFor(home ? pathBox(home.d) : CITY); + + const p = lat !== null && lon !== null ? project(lon, lat) : null; + const dot = p && inside(frame, p.x, p.y) ? p : null; + // Толщина линий и радиусы задаются в единицах кадра: кадр района вчетверо + // мельче городского, и постоянная в пикселях выглядела бы в нём вчетверо + // толще. + const u = frame.w / CITY.w; + + return ( + <> + +
+ {`${dot ? "СЕРЕДИНА УЛИЦЫ, НЕ ДОМ" : "КООРДИНАТЫ НЕТ"} · ${districtLabel.toUpperCase()}`} +
+ + ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx index fb1367f4..562f05d3 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx @@ -10,11 +10,13 @@ * реальных сделках» была неправдой. Сделок мало — игру не рендерит родитель: * играть на одной строке под заголовком «три квартиры» нельзя. * - * ЧЕГО В КАРТОЧКЕ БОЛЬШЕ НЕТ: точки «ОБЪЕКТ НА КАРТЕ». Координат в данных - * витрины нет вовсе (улица известна у 2.7% сделок), а точка на карте — это - * утверждение о конкретном месте. Фон-сетка осталась декорацией, объект - * подписан тем, что известно: район (или прямое «район не указан»), комнаты, - * площадь, этаж, квартал сделки. + * КАРТА (`DealMapV3`) — настоящая: границы районов ЕКБ статикой, кадр по + * району сделки, точка по координате, когда она есть. Раньше здесь стоял + * декоративный прямоугольник с подписью «ОБЪЕКТ · РАЙОН», а координат в + * витрине не было вовсе. Подпись объекта на карте сменилась: координата — это + * ЦЕНТРОИД УЛИЦЫ, а не дом, и карта произносит это сама (разбор — в шапке + * DealMapV3). Ниже карты объект по-прежнему подписан только тем, что известно: + * комнаты, площадь, этаж, район, квартал сделки. Адреса среди них нет. * * Ошибка игрока считается медианой |ответ − факт| / факт по сыгранным раундам * — той же формулой, что и ошибка МЕРЫ на этих же сделках (`err_pct` витрины). @@ -26,6 +28,7 @@ import { useState } from "react"; import styles from "../../landing-v3.module.css"; import type { GameRound } from "./deal-view"; +import { DealMapV3 } from "./DealMapV3"; import { PublicLink } from "../PublicLink"; @@ -96,11 +99,7 @@ export function GuessGameV3({ rounds }: { rounds: readonly GameRound[] }) {