diff --git a/data/sql/195_tradein_osm_roads_view.sql b/data/sql/195_tradein_osm_roads_view.sql new file mode 100644 index 00000000..c7ba85d3 --- /dev/null +++ b/data/sql/195_tradein_osm_roads_view.sql @@ -0,0 +1,57 @@ +-- 195_tradein_osm_roads_view.sql +-- Улицы ЕКБ на витрину сделок МЕРЫ: сторона gendesign (PR 1 из 2). +-- +-- Карточка игры на лэндинге показывала ПОЛИГОН РАЙОНА — единственную геометрию, +-- которая у tradein была. Улицы живут здесь, в `osm_noise_sources_ekb`, а у +-- tradein нет ни foreign table, ни гранта. Эта миграция открывает вьюху ровно +-- по образцу соседей (`v_tradein_cad_buildings`, `v_tradein_osm_poi_ekb`, +-- 185_tradein_osm_poi_view.sql): плоский срез нужных колонок + GRANT SELECT +-- уже существующей роли `tradein_fdw_reader` (100_tradein_fdw_role.sql). +-- Новых ролей и user mapping здесь нет. +-- +-- ГРАНТ СТОИТ РЯДОМ С CREATE, В ЭТОМ ЖЕ ФАЙЛЕ — И ЭТО НЕ СТИЛЬ. 2026-08-29 +-- (#3227) объект пересоздали, а GRANT жил отдельной миграцией и потерялся: +-- пересоздание снимает права молча, и чтение с той стороны падает на +-- `permission denied`. То же было с `ekb_districts_geom` (194) и +-- `mv_quarter_price_index` (188). Кто будет менять эту вьюху — меняйте файл +-- целиком, вместе с грантом. +-- +-- ЧТО В СРЕЗЕ И ЧЕГО В НЁМ НЕТ (замер на проде 2026-08-29): +-- * 10 025 линий, 1377 различных названий улиц. Выгрузка собиралась как +-- «источники шума», поэтому именованные улицы покрыты хорошо, а дворовые +-- проезды и служебные ОТСУТСТВУЮТ. Потребитель обязан это знать: схема из +-- этих данных — улицы района, а не полная уличная сеть. +-- * `source_type IN ('highway','water')` — только то, что рисуется на схеме. +-- Железные дороги, ЛЭП, трубопроводы, подстанции и промзона (ещё ~4 400 +-- строк) сюда НЕ едут: на карточке 320 px они дают шум, а не смысл. +-- * ЗДАНИЙ ЗДЕСЬ НЕТ И НЕ БУДЕТ. `cad_buildings` — 18 307 контуров на весь +-- город; в плотном центре это 51 здание на радиус 450 м, где их в разы +-- больше. Нарисованная застройка заявляла бы полноту, которой в данных +-- нет, и показывала бы пустые кварталы там, где стоят дома. +-- +-- Идемпотентна: CREATE OR REPLACE VIEW + повторяемый GRANT. + +BEGIN; +-- Конвенция проекта (#2752). CREATE OR REPLACE VIEW и GRANT берут лок на объект; +-- ждать его выдачи за чужой сессией — значит собрать очередь перед приложением. +SET LOCAL lock_timeout = '5s'; + +CREATE OR REPLACE VIEW v_tradein_osm_roads_ekb AS +SELECT + name, + road_class, + geom +FROM osm_noise_sources_ekb +WHERE source_type IN ('highway', 'water'); + +GRANT SELECT ON v_tradein_osm_roads_ekb TO tradein_fdw_reader; + +COMMENT ON VIEW v_tradein_osm_roads_ekb IS + 'FDW-источник для tradein-mvp: улицы и вода ЕКБ для схемы улицы на витрине ' + 'сделок лэндинга МЕРЫ. Срез osm_noise_sources_ekb по source_type ' + 'IN (highway, water) — 10 025 линий, 1377 названий. Выгрузка фильтрованная ' + '(«источники шума»): дворовых и служебных проездов в ней нет. Зданий нет ' + 'намеренно, см. шапку 195_tradein_osm_roads_view.sql. GRANT живёт в том же ' + 'файле — пересоздание вьюхи снимает права молча (#3227).'; + +COMMIT; diff --git a/tradein-mvp/backend/app/api/public/mera.py b/tradein-mvp/backend/app/api/public/mera.py index 4b8288c0..7b8c4d85 100644 --- a/tradein-mvp/backend/app/api/public/mera.py +++ b/tradein-mvp/backend/app/api/public/mera.py @@ -393,8 +393,36 @@ class ShowcaseDeal(BaseModel): `district` / `floor` / `total_floors` НУЛЛАБЕЛЬНЫ намеренно: этих величин в ДКП-данных может не быть, и фронт обязан пережить null, а не получить + правдоподобную подстановку. + + `street_name` / `street_scheme` — УЛИЦА, А НЕ ДОМ. Номер дома есть у 2.7% + сделок (разбор в миграции 276), поэтому дома в витрине нет и не будет. + Оба поля НУЛЛАБЕЛЬНЫ, и null — штатный случай: название сматчилось с OSM у + 550 названий из 654 (92.3% сделок, замер 2026-08-29), остальным схемы нет и + фронт показывает район. + + `street_scheme` — уже спроецированные SVG-пути окна 840×840 м вокруг центра + улицы:: + + {"street": "улица Краснолесья", "w": 1000, "h": 1000, + "target": ["M…L…"], # подсвеченная улица + "roads": [{"c": "primary", "d": "M…L…"}], # фон, c = класс дороги + "water": ["M…L…"], + "labels": [{"t": "Чкалова", "x": 431.2, "y": 88.0}]} + + Координат окна и констант проекции в схеме НЕТ намеренно: по ней нельзя + положить (lon, lat) в её систему координат, то есть нарисовать точку дома + невозможно даже случайно. Схема готовая, а не геометрия, потому что GeoJSON + того же окна — 7-8 КБ на строку против 2.8-2.9 КБ схемы (замер 2026-08-29). правдоподобную подстановку. Улицы и дома в модели нет вовсе — номер дома есть у 2.7% сделок (разбор в миграции 276). + + `lat` / `lon` — ЦЕНТРОИД УЛИЦЫ, не дом: 991 различная координата на 34 017 + сделок выборки (≈34 сделки в одной точке) при тех же 2.7% известных домов + (замер 2026-08-29, миграция 280). Точка честна на масштабе района и улицы + и НЕ честна на масштабе дома — то же самое написано в `note` строки, + которая едет рядом. Тоже нуллабельны: у части сделок координаты нет, и + такая строка остаётся на витрине без точки, а не выбрасывается. """ district: str | None @@ -408,6 +436,10 @@ class ShowcaseDeal(BaseModel): err_pct: float n_analogs: int note: str + street_name: str | None + street_scheme: dict | None + lat: float | None = None + lon: float | None = None class ShowcaseStats(BaseModel): @@ -458,7 +490,8 @@ _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, street_name, street_scheme FROM landing_showcase_deals WHERE computed_at = CAST(:computed_at AS timestamptz) ORDER BY id @@ -515,6 +548,13 @@ def public_showcase( err_pct=float(r["err_pct"]), n_analogs=int(r["n_analogs"]), note=r["note"], + street_name=r["street_name"], + street_scheme=r["street_scheme"], + # Порядок не менять: 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/services/street_scheme.py b/tradein-mvp/backend/app/services/street_scheme.py new file mode 100644 index 00000000..a493e46c --- /dev/null +++ b/tradein-mvp/backend/app/services/street_scheme.py @@ -0,0 +1,328 @@ +"""Схема улицы сделки: готовые SVG-пути для карточки лэндинга. + +ЗАЧЕМ ГОТОВАЯ СХЕМА, А НЕ ГЕОМЕТРИЯ. Отдать фронту GeoJSON окна 840×840 м — +это десятки килобайт на КАЖДУЮ строку витрины плюс проекция в рантайме. Здесь +всё считается один раз при пересчёте витрины и хранится строкой SVG-путей; +порядок величины на прототипе — 4 КБ на сделку. + +ЧТО ЭТА СХЕМА ЗАЯВЛЯЕТ, А ЧТО НЕТ +--------------------------------- + * ДОМА В НЕЙ НЕТ И БЫТЬ НЕ МОЖЕТ. `deals.address` — уровня улицы + («Екатеринбург, Краснолесья»), номер дома известен у 2.7% сделок. Точка + дома была бы выдумана, поэтому её не рисует даже фронт: в схеме нет ни + координат окна, ни констант проекции — по ней физически нельзя положить + (lon, lat) в её систему координат. Это не забывчивость, а замок. + * ЗДАНИЙ В НЕЙ НЕТ. Замер 2026-08-29: `cad_buildings` содержит 18 307 + контуров на весь ЕКБ, в плотном центре это 51 здание на радиус 450 м, где + их в разы больше. Нарисованная застройка заявляла бы полноту, которой в + данных нет, и показывала бы пустые кварталы там, где стоят дома. + * УЛИЦЫ — ФИЛЬТРОВАННАЯ ВЫГРУЗКА. `osm_noise_sources_ekb` собиралась как + «источники шума»: 10 025 линий, 1377 различных названий. Именованные улицы + покрыты хорошо, дворовые проезды и служебные отсутствуют. Схема — это + именно улицы района, а не полная уличная сеть. + * УЛИЦА СМАТЧИЛАСЬ НЕ ВСЕГДА. Нормализация даёт совпадение у 550 названий из + 654 — это 31 410 сделок из 34 021 (92.3%, замер на проде 2026-08-29 по той + же выборке, из которой набирается витрина). Оставшиеся 8% — штатный + случай: схемы нет, фронт показывает район. + +ПРОЕКЦИЯ — та же равнопромежуточная с поправкой cos(широты), что в +`scripts/export_ekb_districts_svg.py`. Второй проекции в проекте нет намеренно. +Окно квадратное в метрах, поэтому viewBox тоже квадратный: 1000×1000. +""" + +from __future__ import annotations + +import json +import logging +import math +import re +from dataclasses import dataclass +from typing import Any + +from sqlalchemy import text +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + +# Полуразмер окна в метрах. 420 м на 1000 единиц viewBox — это ~1.2 м на +# единицу: улица читается как улица, а соседние кварталы попадают в кадр. +RADIUS_M = 420.0 +VIEWBOX = 1000.0 +# Метров в градусе широты (сфера). Долгота — то же, делённое на cos(широты). +M_PER_DEG_LAT = 111_320.0 +# Допуск упрощения в градусах ≈ 3 м: меньше трёх единиц viewBox, то есть тоньше +# линии. Смена значения обязана сопровождаться замером размера схемы. +SIMPLIFY_DEG = 0.00003 +# Сколько подписей соседних улиц кладём в схему. Больше — каша на карточке +# 320 px и лишние байты; отбираем самые длинные в кадре. +MAX_LABELS = 7 + +# Родовые слова, которые в `deals.address` и в OSM пишутся по-разному +# («Краснолесья» ↔ «улица Краснолесья»). Снимаем с обеих сторон. +_STREET_TYPES = frozenset( + { + "улица", + "ул", + "проспект", + "пр", + "пр-кт", + "пркт", + "проспкт", + "переулок", + "пер", + "бульвар", + "бул", + "б-р", + "шоссе", + "ш", + "проезд", + "набережная", + "наб", + "тракт", + "аллея", + "площадь", + "пл", + "тупик", + } +) + +_KEEP = re.compile(r"[^0-9a-zа-я\s-]") + + +def normalize_street(address: str | None) -> str | None: + """`'Екатеринбург, ул. Краснолесья'` → ``'краснолесья'``. Нет улицы — None. + + Что делает и почему именно так: + * берёт часть ПОСЛЕ последней запятой — в `deals.address` город стоит + первым («Екатеринбург, 8 Марта»); + * ПЕРЕКРЁСТОК берёт первой улицей. «Екатеринбург, Челюскинцев/Шейнкмана» + → «челюскинцев». Таких адресов в выборке ТРИ на 34 021 сделку (по + одной сделке на каждый, замер 2026-08-29), и обе улицы одинаково + верны: адрес и так уровня улицы, а не дома. Рисовать перекрёсток + точкой значило бы заявить точность, которой в остальных 34 018 строках + нет, ради трёх; + * складывает ё→е: «Королёва» в адресе и «Королева» в OSM — одна улица; + * снимает родовое слово (улица/проспект/…), потому что в адресе его + обычно нет, а в OSM обычно есть; + * цифры оставляет: «8 Марта» и «22 Партсъезда» — названия, а не номера. + """ + if not address: + return None + tail = address.rsplit(",", 1)[-1] + tail = tail.split("/")[0] + tail = tail.replace("ё", "е").replace("Ё", "Е").lower() + tail = _KEEP.sub(" ", tail) + words = [w for w in tail.split() if w.strip("-") not in _STREET_TYPES] + return " ".join(words) or None + + +# ── Запросы ────────────────────────────────────────────────────────────────── +# +# Обе идут в foreign table `gendesign_osm_roads_ekb` (миграция 281) над вьюхой +# `v_tradein_osm_roads_ekb` чужой базы. Сервер `gendesign_remote` объявлен с +# OPTIONS (extensions 'postgis'), поэтому PostGIS-операторы shippable: `&&` с +# готовым конвертом уходит на ту сторону и попадает в GiST-индекс, а обратно +# едет уже обрезанный GeoJSON, а не десять тысяч линий. + +_NAMES_SQL = text("SELECT DISTINCT name FROM gendesign_osm_roads_ekb WHERE name IS NOT NULL") + +_CENTER_SQL = text( + """ + SELECT ST_X(p) AS lon, ST_Y(p) AS lat + FROM ( + SELECT ST_ClosestPoint(g, ST_Centroid(g)) AS p + FROM ( + SELECT ST_Collect(geom) AS g + FROM gendesign_osm_roads_ekb + WHERE name = ANY(CAST(:names AS text[])) + ) t + WHERE g IS NOT NULL + ) q + """ +) + +_WINDOW_SQL = text( + """ + SELECT name, road_class, + ST_AsGeoJSON( + ST_SimplifyPreserveTopology( + ST_Intersection( + geom, + ST_MakeEnvelope(:min_lon, :min_lat, :max_lon, :max_lat, 4326) + ), + CAST(:tol AS double precision) + ), 6 + ) AS gj + FROM gendesign_osm_roads_ekb + WHERE geom && ST_MakeEnvelope(:min_lon, :min_lat, :max_lon, :max_lat, 4326) + """ +) + +# Классы из `osm_noise_sources_ekb`, которые рисуются водой. Остальные (trunk / +# primary / secondary / tertiary / residential) — дороги. Разделение +# перечислением, а не «всё, что не дорога»: во вьюху могут доехать новые классы, +# и тогда лучше не нарисовать, чем нарисовать реку поверх улицы. +_WATER_CLASSES = frozenset({"river", "canal", "stream", "lake_or_pond"}) + + +@dataclass(frozen=True) +class StreetIndex: + """Нормализованное название → как эта улица называется в OSM. + + Строится ОДИН раз на прогон: 1377 названий на всю базу, тянуть их на каждую + из двадцати сделок незачем. + """ + + by_norm: dict[str, list[str]] + + def lookup(self, address: str | None) -> tuple[str, list[str]] | None: + key = normalize_street(address) + if key is None: + return None + raw = self.by_norm.get(key) + return (key, raw) if raw else None + + +def load_street_index(db: Session) -> StreetIndex: + """Список названий улиц из FDW-вьюхи. Недоступна — пустой индекс. + + SAVEPOINT ИМЕННО ЗДЕСЬ, на месте глушения: провалившийся SELECT переводит + транзакцию в aborted, и следующий запрос упал бы уже не по своей вине. + Гранты на той стороне уже терялись при пересоздании объекта (#3227). + """ + try: + with db.begin_nested(): + rows = db.execute(_NAMES_SQL).all() + except Exception as exc: + logger.warning("улицы не резолвятся (витрина будет без схем): %s", exc) + return StreetIndex(by_norm={}) + by_norm: dict[str, list[str]] = {} + for (name,) in rows: + key = normalize_street(name) + if key: + by_norm.setdefault(key, []).append(name) + return StreetIndex(by_norm=by_norm) + + +def _parts(gj: dict[str, Any]) -> list[list[list[float]]]: + """GeoJSON любой из встречающихся форм → список цепочек точек.""" + t = gj.get("type") + c = gj.get("coordinates") or [] + if t == "LineString": + return [c] + if t == "MultiLineString": + return list(c) + if t == "Polygon": + return list(c) + if t == "MultiPolygon": + return [ring for poly in c for ring in poly] + if t == "GeometryCollection": + return [p for g in gj.get("geometries", []) for p in _parts(g)] + return [] + + +def _length(chain: list[list[float]]) -> float: + return sum(math.dist(chain[i], chain[i + 1]) for i in range(len(chain) - 1)) + + +def build_street_scheme(db: Session, index: StreetIndex, address: str | None) -> dict | None: + """Схема улицы для одной сделки, либо None — и None это ШТАТНО. + + None означает ровно одно: «улицы сделки в геометрии нет» (нет адреса, не + сматчилось название, вьюха недоступна, у улицы нет геометрии). Витрина в + этом случае отдаёт строку без схемы, а фронт показывает район — запасной + вариант, который для этого и оставлен. + """ + found = index.lookup(address) + if found is None: + return None + key, raw_names = found + try: + with db.begin_nested(): + center = db.execute(_CENTER_SQL, {"names": raw_names}).mappings().first() + if center is None: + return None + lat, lon = float(center["lat"]), float(center["lon"]) + dlat = RADIUS_M / M_PER_DEG_LAT + k = math.cos(math.radians(lat)) + dlon = dlat / k + bounds = { + "min_lon": lon - dlon, + "max_lon": lon + dlon, + "min_lat": lat - dlat, + "max_lat": lat + dlat, + "tol": SIMPLIFY_DEG, + } + rows = db.execute(_WINDOW_SQL, bounds).mappings().all() + except Exception as exc: + logger.warning("схема улицы %r не собралась: %s", key, exc) + return None + + min_lon, max_lat = bounds["min_lon"], bounds["max_lat"] + # Окно квадратное в метрах, значит после поправки cos(широты) его ширина и + # высота в единицах viewBox равны: масштаб один на обе оси. + scale = VIEWBOX / (2 * dlat) + + def path(chain: list[list[float]]) -> str: + out = [] + for i, (plon, plat) in enumerate(chain): + x = round((plon - min_lon) * k * scale, 1) + y = round((max_lat - plat) * scale, 1) + out.append(f"{'M' if i == 0 else 'L'}{x} {y}") + return "".join(out) + + target: list[str] = [] + roads: list[dict[str, str]] = [] + water: list[str] = [] + label_pool: list[tuple[float, str, list[float]]] = [] + targets = set(raw_names) + + for row in rows: + if not row["gj"]: + continue + chains = [c for c in _parts(json.loads(row["gj"])) if len(c) > 1] + if not chains: + continue + cls = row["road_class"] + is_target = row["name"] in targets + for chain in chains: + d = path(chain) + if is_target: + target.append(d) + elif cls in _WATER_CLASSES: + water.append(d) + else: + roads.append({"c": cls or "residential", "d": d}) + if row["name"] and not is_target and cls not in _WATER_CLASSES: + longest = max(chains, key=_length) + label_pool.append((_length(longest), row["name"], longest[len(longest) // 2])) + + if not target: + return None + + # Подписи: самые длинные в кадре, по одной на название. + labels: list[dict[str, Any]] = [] + seen: set[str] = set() + for _, name, point in sorted(label_pool, key=lambda t: -t[0]): + if name in seen: + continue + seen.add(name) + labels.append( + { + "t": name, + "x": round((point[0] - min_lon) * k * scale, 1), + "y": round((max_lat - point[1]) * scale, 1), + } + ) + if len(labels) >= MAX_LABELS: + break + + return { + "street": raw_names[0], + "w": VIEWBOX, + "h": VIEWBOX, + "target": target, + "roads": roads, + "water": water, + "labels": labels, + } diff --git a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py index 5f8a064a..40aaed0d 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. Спайн считает прогноз по СЕГОДНЯШНИМ активным @@ -56,6 +63,13 @@ `MAX_FACT_PPM2 = 1.2M` был недостижим при потолке выборки 600k — из трёх отбраковок в проде срабатывала РОВНО ОДНА, та самая, что льстила витрине. Неработающая проверка читается как работающая, поэтому её нет. + * КАРТА ПОКАЗЫВАЕТ УЛИЦУ, А НЕ ДОМ. `deals.address` — уровня улицы + («Екатеринбург, Краснолесья»), поэтому в строку кладётся схема окна вокруг + ЦЕНТРА улицы (`app/services/street_scheme.py`), и в этой схеме намеренно + нет ни координат окна, ни констант проекции: точку дома по ней нельзя + поставить даже случайно. Название сматчилось с OSM у 550 из 654 названий — + 92.3% сделок; остальным `street_scheme` = NULL, и это штатно: фронт + показывает район. Наличие схемы НА ОТБОР НЕ ВЛИЯЕТ (см. `_schemes_for`). * СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных строк» неотличимо от «столько и было», пока рядом не написано, сколько сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог @@ -74,6 +88,7 @@ from __future__ import annotations import argparse +import json import logging from dataclasses import dataclass from datetime import date @@ -82,6 +97,8 @@ from typing import Any from sqlalchemy import text from sqlalchemy.orm import Session +from app.services.street_scheme import build_street_scheme, load_street_index + logger = logging.getLogger(__name__) # ── Правило отбраковки: одна формулировка, она же едет на фронт ────────────── @@ -102,7 +119,12 @@ NOTE = ( "Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: " "это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. " "Факт — цена ДКП, заявленная в Росреестр: она бывает занижена сторонами, и тогда " - "строка выглядит как промах оценщика, хотя врёт документ." + "строка выглядит как промах оценщика, хотя врёт документ. " + "Схема на карточке — улица сделки, а не её дом: в адресе Росреестра номер дома " + "есть у 2.7% строк, поэтому дом не показан и показан быть не может." + "Точка на карте — центроид улицы, а не дом: в выборке витрины 991 различная " + "координата на 34 017 сделок (≈34 сделки в одной точке), номер дома известен " + "у 2.7% сделок. Точка честна на масштабе района и улицы и не честна на масштабе дома." ) _ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV"} @@ -131,6 +153,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 +194,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 +203,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 +233,8 @@ def build_row( fact_rub=round(fact_rub), err_pct=round(err_pct, 2), n_analogs=n_analogs, + lat=lat, + lon=lon, ) @@ -255,17 +290,41 @@ _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, street_name, street_scheme) 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), + CAST(:street_name AS text), CAST(:street_scheme AS jsonb)) """ ) +def _schemes_for(db: Session, chosen: list[ShowcaseRow], addresses: dict[int, str | None]) -> dict: + """Схемы улиц ТОЛЬКО для показанных строк: id сделки → схема. + + Считаем после отбора, а не до: схема — это два пространственных запроса на + сделку, и на двухстах кандидатах ради двадцати показанных это четыреста + лишних запросов в чужую базу. + + НА ОТБОР НАЛИЧИЕ СХЕМЫ НЕ ВЛИЯЕТ — и это то же правило, что запрещает отбор + по величине ошибки. Ранжируй мы строки со сматчившейся улицей выше, витрина + показывала бы не работу оценщика, а те 92% адресов, которые удобно легли на + OSM. Схема — украшение строки, а не причина её показать. + """ + index = load_street_index(db) + out = {} + for row in chosen: + scheme = build_street_scheme(db, index, addresses.get(row.deal_id)) + if scheme is not None: + out[row.deal_id] = scheme + return out + + def refresh_landing_showcase_deals( db: Session, *, @@ -328,6 +387,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 @@ -335,13 +399,17 @@ def refresh_landing_showcase_deals( candidates.append(row) chosen = select_rows(candidates, limit) + schemes = _schemes_for(db, chosen, {d.id: d.address for d in deals}) db.execute(_DELETE_SQL) db.execute(_DELETE_RUNS_SQL) for row in chosen: + scheme = schemes.get(row.deal_id) db.execute( _INSERT_SQL, { + "street_name": scheme["street"] if scheme else None, + "street_scheme": json.dumps(scheme, ensure_ascii=False) if scheme else None, "district": row.district, "rooms": row.rooms, "area_m2": row.area_m2, @@ -353,6 +421,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 = { @@ -364,6 +434,10 @@ def refresh_landing_showcase_deals( "written": len(chosen), "with_district": sum(1 for r in chosen if r.district is not None), } + # Схем — не счётчик в `landing_showcase_runs` намеренно: у КАЖДОЙ строки + # витрины street_scheme либо есть, либо null, и это едет на фронт вместе со + # строкой. Отдельное число повторяло бы то, что посетитель и так видит. + logger.info("схем улиц собрано: %d из %d показанных", len(schemes), len(chosen)) db.execute(_INSERT_RUN_SQL, {**counters, "rejection_rule": REJECTION_RULE}) db.commit() 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/data/sql/281_street_scheme.sql b/tradein-mvp/backend/data/sql/281_street_scheme.sql new file mode 100644 index 00000000..1ff97dff --- /dev/null +++ b/tradein-mvp/backend/data/sql/281_street_scheme.sql @@ -0,0 +1,86 @@ +-- 281_street_scheme.sql +-- Улицы ЕКБ на витрине сделок: сторона tradein (PR 2 из 2). +-- +-- ДВА ДЕЙСТВИЯ В ОДНОМ ФАЙЛЕ, И ЭТО ОСОЗНАННО: +-- 1) foreign table над `v_tradein_osm_roads_ekb` (миграция gendesign 195); +-- 2) колонки `street_name` / `street_scheme` в `landing_showcase_deals`. +-- Порознь у них разные зависимости, но смысл один: без геометрии колонки некому +-- заполнить, без колонок геометрию некуда положить. Один файл = один +-- транзакционный исход: если 195 на той стороне ещё не задеплоена, падает +-- ВСЁ и не остаётся полусостояния «колонка есть, данных не будет никогда». +-- Файл идемпотентен, поэтому повтор после деплоя 195 доводит дело до конца. +-- +-- ПОЧЕМУ КОЛОНКА В `landing_showcase_deals`, А НЕ ОТДЕЛЬНАЯ ТАБЛИЦА. Схема +-- живёт ровно столько же, сколько строка витрины: пересчёт делает +-- DELETE+INSERT всей витрины, и схема обязана умереть вместе со своей строкой. +-- Отдельная таблица дала бы собственный жизненный цикл (то есть возможность +-- пережить свой батч и уехать на фронт рядом с ЧУЖИМИ числами), join в +-- публичной ручке и вторую точку отказа — в обмен ни на что: связь строго 1:1. +-- +-- ЧТО ЛЕЖИТ В `street_scheme` — ГОТОВЫЕ SVG-ПУТИ, А НЕ ГЕОМЕТРИЯ. Окно +-- 840×840 м в GeoJSON — это 7-8 КБ на сделку плюс проекция в рантайме у +-- посетителя. Уже спроецированная схема того же окна — 2.8-2.9 КБ (замер +-- 2026-08-29 на «улица Краснолесья» и «улица 8 Марта»). +-- +-- ГРАНИЦА ЧЕСТНОСТИ, ЗАПИСАННАЯ ЗДЕСЬ, А НЕ ТОЛЬКО В ГОЛОВЕ АВТОРА: +-- * ЭТО УЛИЦА, А НЕ ДОМ. `deals.address` — уровня улицы («Екатеринбург, +-- Краснолесья»), номер дома известен у 2.7% сделок. В схеме намеренно НЕТ +-- ни координат окна, ни констант проекции: по ней физически нельзя +-- положить (lon, lat) в её систему координат, то есть нарисовать точку +-- дома нельзя даже случайно. +-- * `street_name` NULLABLE, и NULL — ШТАТНЫЙ СЛУЧАЙ. Нормализация названия +-- даёт совпадение с OSM у 550 названий из 654, это 31 410 сделок из +-- 34 021 — 92.3% (замер 2026-08-29 по той же выборке, из которой +-- набирается витрина). Оставшиеся 8% схемы не получают, и фронт +-- показывает район — запасной вариант, который для этого и оставлен. +-- +-- Зависимости: +-- - 060_postgres_fdw_extension.sql (SERVER gendesign_remote + USER MAPPING, +-- пароль подставляется на старте бэкенда). Сервер объявлен с +-- OPTIONS (extensions 'postgis') — именно поэтому `&&` с конвертом уходит +-- на ту сторону в GiST-индекс, а не тянет сюда десять тысяч линий. +-- - gendesign: вьюха public.v_tradein_osm_roads_ekb + GRANT SELECT роли +-- tradein_fdw_reader (миграция 195, деплоится ПЕРВОЙ). +-- - 276_landing_showcase_deals.sql (сама витрина). +-- +-- Идемпотентна: DROP FOREIGN TABLE IF EXISTS + ADD COLUMN IF NOT EXISTS. + +BEGIN; +-- Конвенция проекта (#2752): ALTER TABLE берёт ACCESS EXCLUSIVE и, ожидая его +-- за чужой сессией, встаёт в очередь ПЕРЕД запросами приложения. +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE landing_showcase_deals + ADD COLUMN IF NOT EXISTS street_name text, + ADD COLUMN IF NOT EXISTS street_scheme jsonb; + +COMMENT ON COLUMN landing_showcase_deals.street_name IS + 'Улица сделки как она называется в OSM. NULL — штатно: адрес не сматчился ' + '(8% сделок), схемы нет, фронт показывает район. Это УЛИЦА, а не дом: ' + 'номер дома известен у 2.7% сделок.'; + +COMMENT ON COLUMN landing_showcase_deals.street_scheme IS + 'Готовые SVG-пути окна 840x840 м вокруг центра улицы: {street, w, h, ' + 'target[], roads[{c,d}], water[], labels[{t,x,y}]}. Координат окна и ' + 'констант проекции в ней НЕТ намеренно — по схеме нельзя положить точку ' + 'дома, которого в данных нет. Зданий нет тоже (cad_buildings покрывает ' + 'город на 18 307 контуров и в центре показывает пустые кварталы).'; + +DROP FOREIGN TABLE IF EXISTS gendesign_osm_roads_ekb; + +CREATE FOREIGN TABLE gendesign_osm_roads_ekb ( + name text, + road_class text, + geom geometry(Geometry, 4326) +) +SERVER gendesign_remote +OPTIONS (schema_name 'public', table_name 'v_tradein_osm_roads_ekb'); + +COMMENT ON FOREIGN TABLE gendesign_osm_roads_ekb IS + 'Живой срез gendesign.osm_noise_sources_ekb (улицы и вода ЕКБ). Выгрузка ' + 'фильтрованная («источники шума»): именованные улицы покрыты хорошо, ' + 'дворовые и служебные проезды отсутствуют. Читает ' + 'app/services/street_scheme.py при пересчёте витрины лэндинга; в публичной ' + 'ручке НЕ используется — там читаются готовые схемы из landing_showcase_deals.'; + +COMMIT; 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/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..33a8ead9 100644 --- a/tradein-mvp/backend/tests/test_public_mera_api.py +++ b/tradein-mvp/backend/tests/test_public_mera_api.py @@ -179,6 +179,20 @@ _SHOWCASE_ROW = { "err_pct": 3.39, "n_analogs": 41, "note": "не point-in-time", + "street_name": "улица Краснолесья", + "street_scheme": { + "street": "улица Краснолесья", + "w": 1000, + "h": 1000, + "target": ["M10 20L30 40"], + "roads": [{"c": "primary", "d": "M0 0L100 100"}], + "water": [], + "labels": [{"t": "улица Чкалова", "x": 431.2, "y": 88.0}], + }, + # Центроид улицы, не дом (миграция 280). ЕКБ: широта ~56.8, долгота ~60.6 — + # величины намеренно непохожие, чтобы перестановка ловилась по значению. + "lat": 56.8386, + "lon": 60.6055, } _SHOWCASE_RUN = { "computed_at": datetime(2026, 8, 29, 10, 0, tzinfo=UTC), @@ -217,6 +231,62 @@ def test_anonymous_gets_showcase(client: TestClient) -> None: assert body["deals"][0]["fact_rub"] == 5_900_000 # Адреса в контракте ручки нет вовсе — в `deals` дом известен у 2.7% строк. assert "address" not in body["deals"][0] + # Схема улицы едет готовыми SVG-путями, а не геометрией. + assert body["deals"][0]["street_name"] == "улица Краснолесья" + assert body["deals"][0]["street_scheme"]["target"] == ["M10 20L30 40"] + + +def test_showcase_row_without_street_stays_null(client: TestClient) -> None: + """Несматчившаяся улица — 8% сделок — проходит насквозь как null. + + Ни подстановки соседней улицы, ни падения: фронт по этому null показывает + район. Ломать так: сделать street_name/street_scheme обязательными в + `ShowcaseDeal` — тест покраснеет на 500. + """ + row = {**_SHOWCASE_ROW, "street_name": None, "street_scheme": None} + client.app.dependency_overrides[get_db] = lambda: _showcase_db(rows=[row]) + + body = client.get(f"{PREFIX}/showcase").json() + assert body["deals"][0]["street_name"] is None + assert body["deals"][0]["street_scheme"] is None + + +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( diff --git a/tradein-mvp/backend/tests/test_street_scheme.py b/tradein-mvp/backend/tests/test_street_scheme.py new file mode 100644 index 00000000..2576d5f2 --- /dev/null +++ b/tradein-mvp/backend/tests/test_street_scheme.py @@ -0,0 +1,214 @@ +"""Схема улицы для витрины лэндинга: нормализация, отказ и размер. + +Каждый тест здесь сломан вручную перед коммитом и покраснел — иначе он не +проверка, а декорация. Что именно ловится: + * нормализация названий на РЕАЛЬНЫХ строках `deals.address` и OSM; + * несматчившаяся улица не роняет пересчёт (штатные 8%); + * схема не раздувается: без округления координат она вырастает в разы. +""" + +from __future__ import annotations + +import contextlib +import json +import re + +import pytest + +from app.services import street_scheme as ss + + +# ── Нормализация ──────────────────────────────────────────────────────────── +@pytest.mark.parametrize( + ("raw", "expected"), + [ + # Реальные значения deals.address (прод, ЕКБ). + ("Екатеринбург, Краснолесья", "краснолесья"), + ("Екатеринбург, 8 Марта", "8 марта"), + ("Екатеринбург, 22 Партсъезда", "22 партсъезда"), + ("Екатеринбург, Академика Сахарова", "академика сахарова"), + # Перекрёсток: берём ПЕРВУЮ улицу (обоснование — в докстринге функции). + ("Екатеринбург, Челюскинцев/Шейнкмана", "челюскинцев"), + ("Екатеринбург, Шаумяна/Чкалова", "шаумяна"), + ("Екатеринбург, пр-кт Космонавтов/ Ильича", "космонавтов"), + # Реальные значения name в osm_noise_sources_ekb. + ("улица Красных Командиров", "красных командиров"), + ("Амбулаторная улица", "амбулаторная"), + ("проспект Космонавтов", "космонавтов"), + ("Сибирский тракт", "сибирский"), + # ё → е: адрес и OSM пишут по-разному, улица одна. + ("Екатеринбург, Королёва", "королева"), + ("улица Королева", "королева"), + # Пусто — значит пусто, а не выдуманная улица. + (None, None), + ("", None), + ("Екатеринбург, улица", None), + ], +) +def test_normalize_street(raw: str | None, expected: str | None) -> None: + assert ss.normalize_street(raw) == expected + + +def test_normalize_matches_address_to_osm_name() -> None: + """Смысл нормализации: адрес сделки и название OSM сходятся в один ключ.""" + assert ss.normalize_street("Екатеринбург, Краснолесья") == ss.normalize_street( + "улица Краснолесья" + ) + assert ss.normalize_street("Екатеринбург, Королёва") == ss.normalize_street("улица Королева") + + +# ── Тестовый двойник сессии ───────────────────────────────────────────────── +class _Result: + def __init__(self, rows: list) -> None: + self._rows = rows + + def all(self) -> list: + return self._rows + + def mappings(self) -> _Result: + return self + + def first(self): + return self._rows[0] if self._rows else None + + +class _FakeDB: + """Отвечает на три запроса модуля. `fail` — имитация недоступной вьюхи.""" + + def __init__(self, *, names: list[str], center=None, window=None, fail: bool = False) -> None: + self.names = names + self.center = center + self.window = window or [] + self.fail = fail + self.calls = 0 + + @contextlib.contextmanager + def begin_nested(self): + yield + + def execute(self, stmt, params=None): + self.calls += 1 + if self.fail: + raise RuntimeError("permission denied for view v_tradein_osm_roads_ekb") + sql = str(stmt) + if "DISTINCT name" in sql: + return _Result([(n,) for n in self.names]) + if "ST_ClosestPoint" in sql: + return _Result([self.center] if self.center else []) + return _Result(self.window) + + +def _line(n: int, lon0: float = 60.53, lat0: float = 56.78) -> str: + pts = [[round(lon0 + i * 0.0002, 6), round(lat0 + i * 0.0001, 6)] for i in range(n)] + return json.dumps({"type": "LineString", "coordinates": pts}) + + +# ── Несматчившаяся улица — штатный случай, а не авария ────────────────────── +def test_unmatched_street_returns_none_without_touching_db() -> None: + db = _FakeDB(names=["улица Краснолесья"]) + index = ss.load_street_index(db) + before = db.calls + assert ss.build_street_scheme(db, index, "Екатеринбург, Такой Улицы Нет") is None + # Ни одного лишнего запроса: не сматчилось — не ходим в чужую базу. + assert db.calls == before + + +def test_missing_address_returns_none() -> None: + db = _FakeDB(names=["улица Краснолесья"]) + index = ss.load_street_index(db) + assert ss.build_street_scheme(db, index, None) is None + + +def test_unavailable_view_gives_empty_index_not_exception() -> None: + """Грант на той стороне терялся уже трижды. Пересчёт обязан это пережить.""" + db = _FakeDB(names=[], fail=True) + index = ss.load_street_index(db) + assert index.by_norm == {} + assert ss.build_street_scheme(db, index, "Екатеринбург, Краснолесья") is None + + +def test_query_failure_gives_none_not_exception() -> None: + class _FailOnSecond(_FakeDB): + def execute(self, stmt, params=None): + if "DISTINCT name" not in str(stmt): + raise RuntimeError("FDW connection refused") + return super().execute(stmt, params) + + db = _FailOnSecond(names=["улица Краснолесья"]) + index = ss.load_street_index(db) + assert ss.build_street_scheme(db, index, "Екатеринбург, Краснолесья") is None + + +# ── Форма схемы и её размер ───────────────────────────────────────────────── +def _scheme_with(rows: list[dict]) -> dict: + db = _FakeDB( + names=["улица Краснолесья"], + center={"lon": 60.537283, "lat": 56.784694}, + window=rows, + ) + index = ss.load_street_index(db) + scheme = ss.build_street_scheme(db, index, "Екатеринбург, Краснолесья") + assert scheme is not None + return scheme + + +def test_scheme_shape() -> None: + scheme = _scheme_with( + [ + {"name": "улица Краснолесья", "road_class": "tertiary", "gj": _line(5)}, + {"name": "улица Чкалова", "road_class": "primary", "gj": _line(4, 60.534)}, + {"name": "Патрушиха", "road_class": "river", "gj": _line(3, 60.535)}, + ] + ) + assert scheme["street"] == "улица Краснолесья" + assert scheme["w"] == scheme["h"] == ss.VIEWBOX + assert len(scheme["target"]) == 1 and scheme["target"][0].startswith("M") + assert [r["c"] for r in scheme["roads"]] == ["primary"] + assert len(scheme["water"]) == 1 + assert [label["t"] for label in scheme["labels"]] == ["улица Чкалова"] + # Ни координат окна, ни констант проекции: по схеме нельзя поставить дом. + assert not {"minLon", "maxLat", "k", "scale", "lat", "lon"} & set(scheme) + + +def _dense_window() -> list[dict]: + """Плотность как в реальном окне (замер 2026-08-29 по «улица 8 Марта» и + «улица Краснолесья»): 56-58 отрезков в кадре, 114-118 точек после упрощения + на 3 м, то есть 2-2.7 точки на отрезок. Берём 3 — чуть плотнее замера.""" + rows = [ + {"name": "улица Краснолесья", "road_class": "tertiary", "gj": _line(3, 60.537 + i * 0.0002)} + for i in range(16) + ] + rows += [ + { + "name": f"улица №{i}", + "road_class": "residential", + "gj": _line(3, 60.5340 + i * 0.0004, 56.7820 + i * 0.0002), + } + for i in range(40) + ] + return rows + + +def test_scheme_stays_small() -> None: + """Порядок величины — 4 КБ. Замер на проде 2026-08-29: 2.8-2.9 КБ. + + Сломать: убрать `round(..., 1)` в `path()` — координаты станут длинными + float'ами, и схема на том же окне перевалит за потолок. + """ + scheme = _scheme_with(_dense_window()) + size = len(json.dumps(scheme, ensure_ascii=False, separators=(",", ":")).encode()) + assert size < 4400, f"схема раздулась до {size} байт" + assert len(scheme["labels"]) == ss.MAX_LABELS + + +def test_coordinates_carry_one_decimal() -> None: + """Прямая проверка того, чем держится размер: одна цифра после точки. + + Потолок в байтах ловит раздутие только на той плотности, что в тесте; этот + ассерт ловит саму потерю округления на любой. + """ + scheme = _scheme_with(_dense_window()) + blob = " ".join(scheme["target"] + [r["d"] for r in scheme["roads"]]) + assert not re.search(r"\.\d\d", blob), "координаты перестали округляться до 0.1" + for label in scheme["labels"]: + assert not re.search(r"\.\d\d", f"{label['x']} {label['y']}") 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..987bb353 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx @@ -0,0 +1,228 @@ +/** + * Карта сделки: что она обязана показать и о чём обязана промолчать. + * + * Проверяется не «рисуется ли svg», а три решения, каждое из которых при поломке + * даёт правдоподобную картинку с неверным смыслом: + * · кадр берётся по району сделки, а не по городу (иначе район — пятно); + * · точка ставится ТОЛЬКО когда координата попала в кадр (иначе далёкая + * координата прижмётся к краю и будет прочитана как место сделки); + * · подпись говорит, что точка — середина улицы, а не дом, и не называет её + * адресом объекта. + */ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { DealMapV3 } from "../_components/v3/DealMapV3"; +import { GuessGameV3 } from "../_components/v3/GuessGameV3"; +import { StreetMapV3 } from "../_components/v3/StreetMapV3"; +import { EKB_MAP } from "../_components/v3/ekb-districts"; +import { dealTitle, toGameRound } from "../_components/v3/deal-view"; +import type { ShowcaseDeal, StreetScheme } from "../public-api"; + +/** Ширина кадра из атрибута 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); + }); +}); + +// ── Студия — это тип квартиры, а не ноль комнат ───────────────────────────── +// +// В витрине на проде (29.08.2026) 3 строки из 20 имеют rooms = 0. Пока +// dealTitle клеил «${rooms}-к», карточка писала «0-к, 25,9 м²» — это читается +// как ошибка выгрузки, а не как студия. Тест держит обе ветки: сломай любую — +// покраснеет по значению. +describe("подпись сделки различает студию и комнатные", () => { + const deal = (rooms: number, area: number): ShowcaseDeal => ({ + district: "Кировский", + rooms, + area_m2: area, + floor: 9, + total_floors: null, + deal_quarter: "II квартал 2026", + predicted_rub: 5_000_000, + fact_rub: 4_800_000, + err_pct: 4.2, + n_analogs: 24, + note: "", + lat: null, + lon: null, + street_name: null, + street_scheme: null, + }); + + it("rooms = 0 подписывается студией, а не «0-к»", () => { + const title = dealTitle(deal(0, 25.9)); + expect(title).toContain("Студия"); + expect(title).not.toContain("0-к"); + }); + + it("комнатные подписываются числом комнат", () => { + expect(dealTitle(deal(2, 52))).toContain("2-к"); + expect(dealTitle(deal(2, 52))).not.toContain("Студия"); + }); +}); + +// ── Схема улиц: что она обязана сказать и чего не должна заявлять ─────────── +// +// Схема приходит готовыми путями из ручки — рисовать тут нечего, и «рисуется +// ли svg» проверять бессмысленно. Ломаются другие вещи, и каждая ломается +// тихо: пропадает иерархия дорог (схема превращается в решётку), теряется +// подсветка целевой улицы (карта перестаёт быть про эту сделку), подпись +// начинает выдавать улицу за адрес объекта, а выбор между двумя картами +// съезжает — и 8% строк без схемы получают пустоту вместо района. + +const SCHEME: StreetScheme = { + street: "улица Краснолесья", + w: 1000, + h: 1000, + target: ["M431.2 88L500.1 210.4", "M500.1 210.4L512 640"], + roads: [ + { c: "trunk", d: "M0 0L100 100" }, + { c: "residential", d: "M0 900L100 950" }, + { c: "unknown_class", d: "M10 10L20 20" }, + ], + water: ["M0 500L1000 520"], + labels: [{ t: "улица Чкалова", x: 431.2, y: 88 }], +}; + +const widths = (container: HTMLElement, cls: string): number[] => + [...container.querySelectorAll(`path[class*="${cls}"]`)].map((n) => + Number(n.getAttribute("stroke-width")), + ); + +describe("схема улиц в карточке сделки", () => { + it("кадр берётся из данных, а не из константы фронта", () => { + const { container } = render(); + expect(container.querySelector("svg")!.getAttribute("viewBox")).toBe("0 0 800 800"); + // slice, а не meet: окно квадратное, карточка — нет. Целевая улица лежит в + // центре окна по построению, поэтому обрезка краёв её не трогает. + expect(container.querySelector("svg")!.getAttribute("preserveAspectRatio")).toContain("slice"); + }); + + it("целевая улица подсвечена и несёт ореол — по одному на каждый путь", () => { + const { container } = render(); + expect(widths(container, "streetTarget")).toHaveLength(SCHEME.target.length); + const halo = widths(container, "streetHalo"); + expect(halo).toHaveLength(SCHEME.target.length); + // Ореол обязан быть ШИРЕ линии — иначе он не ореол, а вторая линия. + expect(Math.min(...halo)).toBeGreaterThan(Math.max(...widths(container, "streetTarget"))); + }); + + it("иерархия дорог сохраняется, а незнакомый класс не даёт нулевой толщины", () => { + const { container } = render(); + const [trunk, residential, unknown] = widths(container, "streetRoad"); + expect(trunk).toBeGreaterThan(residential); + expect(unknown).toBeGreaterThan(0); + }); + + it("вода нарисована и отделена от дорог", () => { + const { container } = render(); + expect(widths(container, "streetWater")).toHaveLength(SCHEME.water.length); + }); + + it("подпись называет улицу и сразу говорит, что дом не известен", () => { + const { container, getByText } = render(); + expect(getByText(/УЛИЦА КРАСНОЛЕСЬЯ · ДОМ НЕ ИЗВЕСТЕН/u)).toBeTruthy(); + expect(container.textContent).not.toMatch(/АДРЕС|ОБЪЕКТ/u); + }); + + it("схема ничего не сообщает скринридеру — всё, что она знает, стоит рядом текстом", () => { + const { container } = render(); + expect(container.querySelector("svg")!.getAttribute("aria-hidden")).toBe("true"); + }); +}); + +describe("карточка игры выбирает карту по наличию схемы", () => { + const deal = (scheme: StreetScheme | null): ShowcaseDeal => ({ + district: "Ленинский", + rooms: 2, + area_m2: 52, + floor: 3, + total_floors: 9, + deal_quarter: "II кв. 2026", + predicted_rub: 5_210_000, + fact_rub: 5_180_000, + err_pct: 0.58, + n_analogs: 12, + note: "", + lat: null, + lon: null, + street_name: scheme ? scheme.street : null, + street_scheme: scheme, + }); + + it("схема есть — показана улица", () => { + const { container } = render(); + expect(container.textContent).toContain("ДОМ НЕ ИЗВЕСТЕН"); + }); + + it("схемы нет — остаётся район, а не пустое место", () => { + const { container } = render(); + expect(container.textContent).toContain("ЛЕНИНСКИЙ"); + expect(container.textContent).not.toContain("ДОМ НЕ ИЗВЕСТЕН"); + }); +}); 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..2fdbbac7 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,11 @@ const SHOWCASE: ShowcaseResponse = { err_pct: 0.58, n_analogs: 12, note: "Прогноз посчитан по активным объявлениям на дату пересчёта", + lat: null, + lon: null, + // Схемы у этой строки нет — рендер идёт запасным путём, картой района. + street_name: null, + street_scheme: null, }, ], stats: { diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/CityPicker.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CityPicker.tsx deleted file mode 100644 index 7ef9ee8d..00000000 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/CityPicker.tsx +++ /dev/null @@ -1,140 +0,0 @@ -"use client"; - -/** - * CityPicker — выбор города в шапке лэндинга v3 (макет ~ строки 47-73). - * - * Презентационный клиентский остров: сегодня публичный расчёт выключен - * (`PUBLIC_ESTIMATE_ENABLED = false` в content.ts), поэтому выбранный здесь - * город никуда не отправляется — состояние живёт только в этом компоненте и - * показывает посетителю, что сервис знает больше одного города области. - * Когда форма расчёта откроется, сюда нужно будет добавить проброс выбора - * наружу (проп/контекст) — в этом заходе scope ограничен шапкой/подвалом/ - * sticky-баром, самой формы здесь нет. - * - * Список городов и деление на «полное покрытие» / «данных меньше» берутся из - * `OBLAST_CITIES` / `PRIMARY_CITY` (content.ts, тот же реестр, что честно - * объясняет разницу в покрытии в Hero.tsx). Макет показывал числовые «веса» - * по городам (тысячи объявлений) — мы их не повторяем: таких цифр в коде нет, - * а гадать нельзя (правило честности в content.ts). - */ - -import { useEffect, useId, useRef, useState } from "react"; - -import { DEFAULT_CITY, OBLAST_CITIES } from "@/lib/city-registry"; - -import { PRIMARY_CITY, REGION_NAME } from "../../content"; -import styles from "../../landing-v3.module.css"; - -export function CityPicker() { - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(""); - const [city, setCity] = useState(DEFAULT_CITY.label); - const wrapRef = useRef(null); - const buttonRef = useRef(null); - const panelId = useId(); - - // Клик вне панели — закрыть. Тот же паттерн, что components/auth/UserMenu.tsx. - useEffect(() => { - if (!open) return; - function handleClick(e: MouseEvent) { - if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) { - setOpen(false); - } - } - document.addEventListener("mousedown", handleClick); - return () => document.removeEventListener("mousedown", handleClick); - }, [open]); - - // Escape — закрыть и вернуть фокус на кнопку. - useEffect(() => { - if (!open) return; - function handleKey(e: KeyboardEvent) { - if (e.key === "Escape") { - setOpen(false); - buttonRef.current?.focus(); - } - } - document.addEventListener("keydown", handleKey); - return () => document.removeEventListener("keydown", handleKey); - }, [open]); - - const normalizedQuery = query.trim().toLowerCase(); - const filtered = normalizedQuery - ? OBLAST_CITIES.filter((c) => - c.label.toLowerCase().includes(normalizedQuery), - ) - : OBLAST_CITIES; - - return ( -
- - - {open ? ( -
-
- setQuery(e.target.value)} - placeholder="Поиск города" - aria-label="Поиск города" - /> -
-
- {filtered.length > 0 ? ( - filtered.map((c) => ( - - )) - ) : ( -

- Города нет в списке. Мы работаем там, где хватает данных по - сделкам — напишите нам, и добавим. -

- )} -
-
- ДАННЫЕ ПО {OBLAST_CITIES.length} ГОРОДАМ:{" "} - {REGION_NAME.toUpperCase()} -
-
- ) : null} -
- ); -} 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/FreeCheckCard.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx index bd045ed1..5b8a4dee 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx @@ -28,10 +28,11 @@ * Правило из шапки `lib/city-registry.ts`: город считается известным, только * если человек выбрал его сам ИЛИ `detectCityInText` нашёл его в тексте * адреса; молча подставлять Екатеринбург нельзя. Ответ про город вне покрытия - * — тот же и дословно тот же, что в v1. Выпадашка города в шапке - * (`CityPicker`) сюда НЕ пробрасывается: она презентационная и своим выбором - * не владеет (см. её JSDoc), а гейт обязан читать то, что человек указал - * рядом с адресом. + * — тот же и дословно тот же, что в v1. Выпадашки города в шапке больше нет + * вовсе (`CityPicker` удалён 29.08.2026, см. JSDoc HeaderV3): она была + * презентационной и своим выбором не владела, то есть значение задавалось в + * двух местах, а на расчёт влияло только это. Гейт обязан читать то, что + * человек указал рядом с адресом, — и теперь это единственное такое место. * * СОСТОЯНИЕ ТЕПЕРЬ ДОЕЗЖАЕТ ДО ЧЕРНОВИКА. Поле было обязательным, а его * значение никуда не клалось — человек выбирал состояние квартиры, и мы его 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..e59f0ffc 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,18 @@ * реальных сделках» была неправдой. Сделок мало — игру не рендерит родитель: * играть на одной строке под заголовком «три квартиры» нельзя. * - * ЧЕГО В КАРТОЧКЕ БОЛЬШЕ НЕТ: точки «ОБЪЕКТ НА КАРТЕ». Координат в данных - * витрины нет вовсе (улица известна у 2.7% сделок), а точка на карте — это - * утверждение о конкретном месте. Фон-сетка осталась декорацией, объект - * подписан тем, что известно: район (или прямое «район не указан»), комнаты, - * площадь, этаж, квартал сделки. + * КАРТА — настоящая, и их две. Есть схема улиц (`street_scheme`, 92.1% строк + * витрины) — рисуется `StreetMapV3`: окрестности улицы сделки. Нет — остаётся + * `DealMapV3` с полигоном района, как было. Выбор делается ЗДЕСЬ, чтобы обе + * карты оставались тупыми: каждая рисует то, что ей дали, и ни одна не + * подставляет вместо отсутствующих данных правдоподобное. + * + * Обе подписывают себя сами и обе говорят про масштаб: схема улиц — «улица · + * ДОМ НЕ ИЗВЕСТЕН», район — «СЕРЕДИНА УЛИЦЫ, НЕ ДОМ». Раньше тут стоял + * декоративный прямоугольник с подписью «ОБЪЕКТ · РАЙОН», заявлявший + * местоположение объекта — то, чего в данных нет. Ниже карты объект + * по-прежнему подписан только тем, что известно: комнаты, площадь, этаж, + * район, квартал сделки. Адреса среди них нет. * * Ошибка игрока считается медианой |ответ − факт| / факт по сыгранным раундам * — той же формулой, что и ошибка МЕРЫ на этих же сделках (`err_pct` витрины). @@ -26,6 +33,8 @@ import { useState } from "react"; import styles from "../../landing-v3.module.css"; import type { GameRound } from "./deal-view"; +import { DealMapV3 } from "./DealMapV3"; +import { StreetMapV3 } from "./StreetMapV3"; import { PublicLink } from "../PublicLink"; @@ -96,11 +105,11 @@ export function GuessGameV3({ rounds }: { rounds: readonly GameRound[] }) {