feat(mera/b2c): настоящая карта улицы в карточке игры + мобильный лэндинг перестал съедать 45% экрана #3245

Merged
bot-backend merged 8 commits from feat/b2c-game-real-map into main 2026-08-29 18:45:23 +00:00
26 changed files with 1948 additions and 369 deletions

View file

@ -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;

View file

@ -393,8 +393,36 @@ class ShowcaseDeal(BaseModel):
`district` / `floor` / `total_floors` НУЛЛАБЕЛЬНЫ намеренно: этих величин в `district` / `floor` / `total_floors` НУЛЛАБЕЛЬНЫ намеренно: этих величин в
ДКП-данных может не быть, и фронт обязан пережить null, а не получить ДКП-данных может не быть, и фронт обязан пережить 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). есть у 2.7% сделок (разбор в миграции 276).
`lat` / `lon` ЦЕНТРОИД УЛИЦЫ, не дом: 991 различная координата на 34 017
сделок выборки (34 сделки в одной точке) при тех же 2.7% известных домов
(замер 2026-08-29, миграция 280). Точка честна на масштабе района и улицы
и НЕ честна на масштабе дома то же самое написано в `note` строки,
которая едет рядом. Тоже нуллабельны: у части сделок координаты нет, и
такая строка остаётся на витрине без точки, а не выбрасывается.
""" """
district: str | None district: str | None
@ -408,6 +436,10 @@ class ShowcaseDeal(BaseModel):
err_pct: float err_pct: float
n_analogs: int n_analogs: int
note: str note: str
street_name: str | None
street_scheme: dict | None
lat: float | None = None
lon: float | None = None
class ShowcaseStats(BaseModel): class ShowcaseStats(BaseModel):
@ -458,7 +490,8 @@ _SHOWCASE_RUN_SQL = text(
_SHOWCASE_SQL = text( _SHOWCASE_SQL = text(
""" """
SELECT district, rooms, area_m2, floor, total_floors, deal_quarter, 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 FROM landing_showcase_deals
WHERE computed_at = CAST(:computed_at AS timestamptz) WHERE computed_at = CAST(:computed_at AS timestamptz)
ORDER BY id ORDER BY id
@ -515,6 +548,13 @@ def public_showcase(
err_pct=float(r["err_pct"]), err_pct=float(r["err_pct"]),
n_analogs=int(r["n_analogs"]), n_analogs=int(r["n_analogs"]),
note=r["note"], 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 for r in rows
], ],

View file

@ -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,
}

View file

@ -37,6 +37,13 @@
---------------------------------------------------------- ----------------------------------------------------------
* АДРЕСА НЕТ. Номер дома есть у 2.7% сделок, поэтому строка это «район + * АДРЕСА НЕТ. Номер дома есть у 2.7% сделок, поэтому строка это «район +
2-к, 54 м², 5 эт.», и никогда не улица с домом. 2-к, 54 м², 5 эт.», и никогда не улица с домом.
* ТОЧКА НА КАРТЕ ЦЕНТРОИД УЛИЦЫ, НЕ ДОМ. В выборке витрины (ЕКБ,
с 2025-01-01; замер на проде 2026-08-29) 991 различная координата на
34 017 сделок с координатой 34 сделки в одной точке, при 2.7% известных
номеров дома. Точка честна на масштабе района и улицы и НЕ честна на
масштабе дома. Это записано в `note` каждой строки и в COMMENT колонок
(миграция 280), потому что докстринг на фронт не едет, а зумить карту
будет тот, кто его не читал.
* ДНЯ НЕТ. `deals.deal_date` первое число квартала (10 различных значений * ДНЯ НЕТ. `deals.deal_date` первое число квартала (10 различных значений
на всю таблицу), поэтому в витрине только «II квартал 2026». на всю таблицу), поэтому в витрине только «II квартал 2026».
* ЗАМЕР НЕ POINT-IN-TIME. Спайн считает прогноз по СЕГОДНЯШНИМ активным * ЗАМЕР НЕ POINT-IN-TIME. Спайн считает прогноз по СЕГОДНЯШНИМ активным
@ -56,6 +63,13 @@
`MAX_FACT_PPM2 = 1.2M` был недостижим при потолке выборки 600k из трёх `MAX_FACT_PPM2 = 1.2M` был недостижим при потолке выборки 600k из трёх
отбраковок в проде срабатывала РОВНО ОДНА, та самая, что льстила витрине. отбраковок в проде срабатывала РОВНО ОДНА, та самая, что льстила витрине.
Неработающая проверка читается как работающая, поэтому её нет. Неработающая проверка читается как работающая, поэтому её нет.
* КАРТА ПОКАЗЫВАЕТ УЛИЦУ, А НЕ ДОМ. `deals.address` уровня улицы
(«Екатеринбург, Краснолесья»), поэтому в строку кладётся схема окна вокруг
ЦЕНТРА улицы (`app/services/street_scheme.py`), и в этой схеме намеренно
нет ни координат окна, ни констант проекции: точку дома по ней нельзя
поставить даже случайно. Название сматчилось с OSM у 550 из 654 названий
92.3% сделок; остальным `street_scheme` = NULL, и это штатно: фронт
показывает район. Наличие схемы НА ОТБОР НЕ ВЛИЯЕТ (см. `_schemes_for`).
* СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных * СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных
строк» неотличимо от «столько и было», пока рядом не написано, сколько строк» неотличимо от «столько и было», пока рядом не написано, сколько
сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог
@ -74,6 +88,7 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date
@ -82,6 +97,8 @@ from typing import Any
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.services.street_scheme import build_street_scheme, load_street_index
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── Правило отбраковки: одна формулировка, она же едет на фронт ────────────── # ── Правило отбраковки: одна формулировка, она же едет на фронт ──────────────
@ -102,7 +119,12 @@ NOTE = (
"Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: " "Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: "
"это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. " "это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. "
"Факт — цена ДКП, заявленная в Росреестр: она бывает занижена сторонами, и тогда " "Факт — цена ДКП, заявленная в Росреестр: она бывает занижена сторонами, и тогда "
"строка выглядит как промах оценщика, хотя врёт документ." "строка выглядит как промах оценщика, хотя врёт документ. "
"Схема на карточке — улица сделки, а не её дом: в адресе Росреестра номер дома "
"есть у 2.7% строк, поэтому дом не показан и показан быть не может."
"Точка на карте — центроид улицы, а не дом: в выборке витрины 991 различная "
"координата на 34 017 сделок (≈34 сделки в одной точке), номер дома известен "
"у 2.7% сделок. Точка честна на масштабе района и улицы и не честна на масштабе дома."
) )
_ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV"} _ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV"}
@ -131,6 +153,10 @@ class ShowcaseRow:
fact_rub: int fact_rub: int
err_pct: float err_pct: float
n_analogs: int n_analogs: int
# Координата сделки — ЦЕНТРОИД УЛИЦЫ (замер и разбор в миграции 280 и в NOTE).
# None штатно: у части сделок координаты нет, подставлять туда нечего.
lat: float | None = None
lon: float | None = None
def completeness(row: ShowcaseRow) -> int: def completeness(row: ShowcaseRow) -> int:
@ -168,6 +194,8 @@ def build_row(
predicted_rub: float | None, predicted_rub: float | None,
fact_ppm2: float, fact_ppm2: float,
n_analogs: int, n_analogs: int,
lat: float | None = None,
lon: float | None = None,
) -> ShowcaseRow | None: ) -> ShowcaseRow | None:
"""Кандидат → строка витрины, либо 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: if predicted_rub is None or predicted_rub <= 0 or area_m2 <= 0 or fact_ppm2 <= 0:
return None return None
@ -200,6 +233,8 @@ def build_row(
fact_rub=round(fact_rub), fact_rub=round(fact_rub),
err_pct=round(err_pct, 2), err_pct=round(err_pct, 2),
n_analogs=n_analogs, n_analogs=n_analogs,
lat=lat,
lon=lon,
) )
@ -255,17 +290,41 @@ _INSERT_SQL = text(
""" """
INSERT INTO landing_showcase_deals INSERT INTO landing_showcase_deals
(district, rooms, area_m2, floor, total_floors, deal_quarter, (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 VALUES
(CAST(:district AS text), CAST(:rooms AS integer), CAST(:area_m2 AS numeric), (CAST(:district AS text), CAST(:rooms AS integer), CAST(:area_m2 AS numeric),
CAST(:floor AS integer), CAST(:total_floors AS integer), CAST(:floor AS integer), CAST(:total_floors AS integer),
CAST(:deal_quarter AS text), CAST(:predicted_rub AS bigint), CAST(:deal_quarter AS text), CAST(:predicted_rub AS bigint),
CAST(:fact_rub AS bigint), CAST(:err_pct AS numeric), 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( def refresh_landing_showcase_deals(
db: Session, db: Session,
*, *,
@ -328,6 +387,11 @@ def refresh_landing_showcase_deals(
predicted_rub=pr.expected_sold_price, predicted_rub=pr.expected_sold_price,
fact_ppm2=deal.sold_ppm2, fact_ppm2=deal.sold_ppm2,
n_analogs=len(capture[0]["kwargs"]["listings"]) if capture else 0, 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: if row is None:
n_incomplete += 1 n_incomplete += 1
@ -335,13 +399,17 @@ def refresh_landing_showcase_deals(
candidates.append(row) candidates.append(row)
chosen = select_rows(candidates, limit) 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_SQL)
db.execute(_DELETE_RUNS_SQL) db.execute(_DELETE_RUNS_SQL)
for row in chosen: for row in chosen:
scheme = schemes.get(row.deal_id)
db.execute( db.execute(
_INSERT_SQL, _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, "district": row.district,
"rooms": row.rooms, "rooms": row.rooms,
"area_m2": row.area_m2, "area_m2": row.area_m2,
@ -353,6 +421,8 @@ def refresh_landing_showcase_deals(
"err_pct": row.err_pct, "err_pct": row.err_pct,
"n_analogs": row.n_analogs, "n_analogs": row.n_analogs,
"note": NOTE, "note": NOTE,
"lat": row.lat,
"lon": row.lon,
}, },
) )
counters = { counters = {
@ -364,6 +434,10 @@ def refresh_landing_showcase_deals(
"written": len(chosen), "written": len(chosen),
"with_district": sum(1 for r in chosen if r.district is not None), "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.execute(_INSERT_RUN_SQL, {**counters, "rejection_rule": REJECTION_RULE})
db.commit() db.commit()

View file

@ -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;

View file

@ -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;

View file

@ -0,0 +1,108 @@
"""Экспорт границ районов Екатеринбурга в SVG-пути для лэндинга МЕРЫ.
ЗАЧЕМ ГЕНЕРАТОР, А НЕ РУЧНОЙ БЛОБ. Файл
`frontend/src/app/mera-public/_components/v3/ekb-districts.ts` данные, а у
данных обязано быть проверяемое происхождение. Этот скрипт единственный
источник того файла: запусти его и получишь тот же результат.
ПОЧЕМУ ГЕОМЕТРИЯ ЕДЕТ СТАТИКОЙ, А НЕ ТАЙЛАМИ.
1. Публичное дерево `app/mera-public/**` держится без единого внешнего запроса
(см. шапку layout.tsx: шрифты self-hosted, Leaflet с unpkg сюда намеренно не
тянули). Тайловый провайдер это внешний CDN на каждой загрузке.
2. Тайлы утекают провайдеру IP посетителя и то, ЧТО он смотрит, на странице,
которая обещает «без звонков и регистрации». Своя геометрия такого не делает.
3. Границы районов не меняются годами: раздавать их запросом в рантайме незачем.
ТОЧНОСТЬ. Упрощение 0.0012° (~70-130 м на широте Екатеринбурга) выбрано под
масштаб карточки: она показывает район целиком, где такая ошибка меньше
толщины линии. Для более крупного масштаба этот файл не годится перегенерируй
с меньшим допуском и проверь размер.
ПРОЕКЦИЯ. Равнопромежуточная с поправкой cos(широты): на масштабе одного города
искажение незаметно, а взамен фронту не нужна ни библиотека, ни рантайм-математика
сложнее двух умножений. Константы проекции едут вместе с путями, чтобы точку по
lon, lat) можно было положить в ту же систему координат.
Запуск (только чтение):
docker exec tradein-backend python scripts/export_ekb_districts_svg.py
"""
from __future__ import annotations
import json
import math
import os
from typing import Any
import psycopg
# Допуск упрощения в градусах. Смена значения обязана сопровождаться замером
# длины путей: файл едет в бандл каждому посетителю.
SIMPLIFY_DEG = 0.0012
# Ширина системы координат. Высота считается из формы города.
VIEWBOX_WIDTH = 1000.0
SQL = """
SELECT district_name,
ST_AsGeoJSON(ST_SimplifyPreserveTopology(geom, %(tol)s)) AS gj
FROM gendesign_ekb_districts_geom
ORDER BY district_name
"""
def _rings(geojson: dict[str, Any]) -> list[list[list[float]]]:
"""Полигон и мультиполигон — к одному виду: список колец."""
if geojson["type"] == "Polygon":
return geojson["coordinates"]
return [ring for poly in geojson["coordinates"] for ring in poly]
def main() -> int:
dsn = os.environ["DATABASE_URL"].replace("postgresql+psycopg://", "postgresql://")
with psycopg.connect(dsn) as conn, conn.cursor() as cur:
cur.execute(SQL, {"tol": SIMPLIFY_DEG})
rows = [(name, _rings(json.loads(gj))) for name, gj in cur.fetchall()]
points = [p for _, rings in rows for ring in rings for p in ring]
min_lon = min(p[0] for p in points)
max_lat = max(p[1] for p in points)
lat_mid = (min(p[1] for p in points) + max_lat) / 2
k = math.cos(math.radians(lat_mid))
width_deg = max((p[0] - min_lon) * k for p in points)
scale = VIEWBOX_WIDTH / width_deg
height = round(max((max_lat - p[1]) for p in points) * scale, 1)
def path(rings: list[list[list[float]]]) -> str:
out: list[str] = []
for ring in rings:
for i, (lon, lat) in enumerate(ring):
x = round((lon - min_lon) * k * scale, 1)
y = round((max_lat - lat) * scale, 1)
out.append(f"{'M' if i == 0 else 'L'}{x} {y}")
out.append("Z")
return "".join(out)
districts = [{"name": name, "d": path(rings)} for name, rings in rows]
print(
json.dumps(
{
"meta": {
"minLon": min_lon,
"maxLat": max_lat,
"k": round(k, 10),
"scale": round(scale, 6),
"width": VIEWBOX_WIDTH,
"height": height,
"simplifyDeg": SIMPLIFY_DEG,
},
"districts": districts,
},
ensure_ascii=False,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -93,6 +93,8 @@ def _build(**over: object) -> ShowcaseRow | None:
"predicted_rub": 5_000_000.0, "predicted_rub": 5_000_000.0,
"fact_ppm2": 100_000.0, # → факт 5 000 000 ₽, ошибка 0% "fact_ppm2": 100_000.0, # → факт 5 000 000 ₽, ошибка 0%
"n_analogs": 30, "n_analogs": 30,
"lat": 56.8386, # ЕКБ: широта ~56.8, долгота ~60.6 — величины НЕ похожи,
"lon": 60.6055, # поэтому перестановка ловится по значению.
} }
kwargs.update(over) kwargs.update(over)
return build_row(**kwargs) # type: ignore[arg-type] 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 _build(deal_date=None) is None
assert quarter_label(None) is None assert quarter_label(None) is None
assert quarter_label(date(2026, 7, 1)) == "III квартал 2026" 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, "остальные величины строки не пострадали"

View file

@ -179,6 +179,20 @@ _SHOWCASE_ROW = {
"err_pct": 3.39, "err_pct": 3.39,
"n_analogs": 41, "n_analogs": 41,
"note": "не point-in-time", "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 = { _SHOWCASE_RUN = {
"computed_at": datetime(2026, 8, 29, 10, 0, tzinfo=UTC), "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 assert body["deals"][0]["fact_rub"] == 5_900_000
# Адреса в контракте ручки нет вовсе — в `deals` дом известен у 2.7% строк. # Адреса в контракте ручки нет вовсе — в `deals` дом известен у 2.7% строк.
assert "address" not in body["deals"][0] 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( def test_showcase_carries_counters_so_20_rows_cannot_read_as_all_there_was(

View file

@ -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']}")

View file

@ -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(
<DealMapV3 district="Кировский" districtLabel="Кировский" lat={null} lon={null} />,
);
const nowhere = render(
<DealMapV3 district={null} districtLabel="район не указан" lat={null} lon={null} />,
);
expect(frameWidth(home.container)).toBeLessThan(frameWidth(nowhere.container));
// Без района показывается город целиком — это и есть «кадра нет».
expect(frameWidth(nowhere.container)).toBeGreaterThanOrEqual(EKB_MAP.width);
});
it("кадр разный у разных районов — иначе карта не про эту сделку", () => {
const frames = ["Кировский", "Чкаловский", "Академический"].map((name) => {
const { container } = render(
<DealMapV3 district={name} districtLabel={name} lat={null} lon={null} />,
);
return container.querySelector("svg")!.getAttribute("viewBox")!;
});
expect(new Set(frames).size).toBe(frames.length);
});
it("точка внутри кадра рисуется, а подпись говорит, что это улица, а не дом", () => {
// Точка внутри полигона Чкаловского — проверена тем же `project()`, что
// рисует карту.
const { container, getByText } = render(
<DealMapV3 district="Чкаловский" districtLabel="Чкаловский" lat={56.79} lon={60.62} />,
);
expect(dots(container)).toBeGreaterThan(0);
expect(getByText(/СЕРЕДИНА УЛИЦЫ, НЕ ДОМ · ЧКАЛОВСКИЙ/u)).toBeTruthy();
});
it("координата вне кадра не рисуется и не выдаётся за место сделки", () => {
// Такие строки в данных есть: город записан «Екатеринбург», координата — за
// сотни километров. Прижать её к краю кадра значило бы показать место,
// которого в данных нет.
const { container, getByText } = render(
<DealMapV3 district="Чкаловский" districtLabel="Чкаловский" lat={54.524601} lon={40.92939} />,
);
expect(dots(container)).toBe(0);
expect(getByText(/КООРДИНАТЫ НЕТ · ЧКАЛОВСКИЙ/u)).toBeTruthy();
});
it("без координаты карта остаётся, район подсвечен, прочерка нет", () => {
const { container, getByText } = render(
<DealMapV3 district="Ленинский" districtLabel="Ленинский" lat={null} lon={null} />,
);
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(
<DealMapV3 district="Кировский" districtLabel="Кировский" lat={56.85} lon={60.63} />,
);
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(<StreetMapV3 scheme={{ ...SCHEME, w: 800, h: 800 }} />);
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(<StreetMapV3 scheme={SCHEME} />);
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(<StreetMapV3 scheme={SCHEME} />);
const [trunk, residential, unknown] = widths(container, "streetRoad");
expect(trunk).toBeGreaterThan(residential);
expect(unknown).toBeGreaterThan(0);
});
it("вода нарисована и отделена от дорог", () => {
const { container } = render(<StreetMapV3 scheme={SCHEME} />);
expect(widths(container, "streetWater")).toHaveLength(SCHEME.water.length);
});
it("подпись называет улицу и сразу говорит, что дом не известен", () => {
const { container, getByText } = render(<StreetMapV3 scheme={SCHEME} />);
expect(getByText(/УЛИЦА КРАСНОЛЕСЬЯ · ДОМ НЕ ИЗВЕСТЕН/u)).toBeTruthy();
expect(container.textContent).not.toMatch(/АДРЕС|ОБЪЕКТ/u);
});
it("схема ничего не сообщает скринридеру — всё, что она знает, стоит рядом текстом", () => {
const { container } = render(<StreetMapV3 scheme={SCHEME} />);
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(<GuessGameV3 rounds={[toGameRound(deal(SCHEME), 0)]} />);
expect(container.textContent).toContain("ДОМ НЕ ИЗВЕСТЕН");
});
it("схемы нет — остаётся район, а не пустое место", () => {
const { container } = render(<GuessGameV3 rounds={[toGameRound(deal(null), 0)]} />);
expect(container.textContent).toContain("ЛЕНИНСКИЙ");
expect(container.textContent).not.toContain("ДОМ НЕ ИЗВЕСТЕН");
});
});

View file

@ -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 = [ const V3_SOURCES = [
LANDING_ROOT, LANDING_ROOT,
...readdirSync(join(ROOT, V3_DIR)) ...readdirSync(join(ROOT, V3_DIR))
.filter((name) => /\.tsx?$/u.test(name)) .filter((name) => /\.tsx?$/u.test(name) && !GENERATED.includes(name))
.sort() .sort()
.map((name) => `${V3_DIR}/${name}`), .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("сканер видит вписанное число и молчит на честном коде — иначе он проходил бы вхолостую", () => { it("сканер видит вписанное число и молчит на честном коде — иначе он проходил бы вхолостую", () => {
// Контроль на инструмент. Слева — ровно то, что гейт обязан ловить // Контроль на инструмент. Слева — ровно то, что гейт обязан ловить
// (величины из прежнего marketing-v3.ts), справа — код, который в // (величины из прежнего marketing-v3.ts), справа — код, который в

View file

@ -54,6 +54,11 @@ const SHOWCASE: ShowcaseResponse = {
err_pct: 0.58, err_pct: 0.58,
n_analogs: 12, n_analogs: 12,
note: "Прогноз посчитан по активным объявлениям на дату пересчёта", note: "Прогноз посчитан по активным объявлениям на дату пересчёта",
lat: null,
lon: null,
// Схемы у этой строки нет — рендер идёт запасным путём, картой района.
street_name: null,
street_scheme: null,
}, },
], ],
stats: { stats: {

View file

@ -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<string>(DEFAULT_CITY.label);
const wrapRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(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 (
<div className={styles.hdrCityWrap} ref={wrapRef}>
<button
ref={buttonRef}
type="button"
className={styles.hdrCityBtn}
onClick={() => setOpen((v) => !v)}
aria-haspopup="true"
aria-expanded={open}
aria-controls={panelId}
>
<span className={styles.hdrCityDot} aria-hidden="true" />
<span>{city}</span>
<span className={styles.hdrCityCaret} aria-hidden="true">
{open ? "▲" : "▼"}
</span>
</button>
{open ? (
<div
id={panelId}
className={styles.hdrCityPanel}
aria-label="Выбор города"
>
<div className={styles.hdrCitySearchRow}>
<input
type="text"
className={styles.hdrCityInput}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Поиск города"
aria-label="Поиск города"
/>
</div>
<div className={styles.hdrCityList}>
{filtered.length > 0 ? (
filtered.map((c) => (
<button
key={c.id}
type="button"
className={styles.hdrCityItem}
data-active={c.label === city ? "true" : undefined}
onClick={() => {
setCity(c.label);
setOpen(false);
setQuery("");
buttonRef.current?.focus();
}}
>
<span className={styles.hdrCityItemName}>{c.label}</span>
<span className={styles.hdrCityItemNote}>
{c.label === PRIMARY_CITY
? "Полное покрытие"
: "Данных меньше"}
</span>
</button>
))
) : (
<p className={styles.hdrCityEmpty}>
Города нет в списке. Мы работаем там, где хватает данных по
сделкам напишите нам, и добавим.
</p>
)}
</div>
<div className={styles.hdrCityFooter}>
ДАННЫЕ ПО {OBLAST_CITIES.length} ГОРОДАМ:{" "}
{REGION_NAME.toUpperCase()}
</div>
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,159 @@
/**
* DealMapV3 карта сделки в карточке игры «Сыграйте против МЕРЫ».
*
* Заменяет декорацию, которая стояла тут раньше: CSS-сетку и две «дороги»,
* нарисованные прямоугольниками. Географии в них не было вовсе, а подпись
* «ОБЪЕКТ · РАЙОН» заявляла местоположение объекта то, чего в данных нет.
*
* БЕЗ ЕДИНОГО ВНЕШНЕГО ЗАПРОСА. Геометрия районов приезжает статикой из
* `ekb-districts.ts` (сгенерирована из боевой OSM-таблицы), рисуется инлайновым
* <svg>. Тайлы сюда не тянули сознательно: провайдер получал бы 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%) имеют координату за
* пределами города встречаются точки за сотни километров. Рисовать их
* прижатыми к краю значило бы показать место, которого в данных нет.
* Подпись в обоих случаях меняется на «КООРДИНАТЫ НЕТ», чтобы пустая карта не
* читалась как «объект где-то тут, просто не отметили».
*
* ДОСТУПНОСТЬ. <svg> помечен `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 (
<>
<svg
className={styles.gameMapSvg}
viewBox={`${frame.x} ${frame.y} ${frame.w} ${frame.h}`}
preserveAspectRatio="xMidYMid meet"
aria-hidden="true"
focusable="false"
>
{EKB_DISTRICTS.map((d) => (
<path
key={d.name}
d={d.d}
className={d === home ? styles.gameMapHome : styles.gameMapAround}
strokeWidth={(d === home ? 4 : 2.5) * u}
/>
))}
{dot && (
<g>
<circle cx={dot.x} cy={dot.y} r={42 * u} className={styles.gameMapHalo} />
<circle cx={dot.x} cy={dot.y} r={16 * u} strokeWidth={5 * u} className={styles.gameMapCore} />
</g>
)}
</svg>
<div className={styles.gameMapLabel}>
{`${dot ? "СЕРЕДИНА УЛИЦЫ, НЕ ДОМ" : "КООРДИНАТЫ НЕТ"} · ${districtLabel.toUpperCase()}`}
</div>
</>
);
}

View file

@ -28,10 +28,11 @@
* Правило из шапки `lib/city-registry.ts`: город считается известным, только * Правило из шапки `lib/city-registry.ts`: город считается известным, только
* если человек выбрал его сам ИЛИ `detectCityInText` нашёл его в тексте * если человек выбрал его сам ИЛИ `detectCityInText` нашёл его в тексте
* адреса; молча подставлять Екатеринбург нельзя. Ответ про город вне покрытия * адреса; молча подставлять Екатеринбург нельзя. Ответ про город вне покрытия
* тот же и дословно тот же, что в v1. Выпадашка города в шапке * тот же и дословно тот же, что в v1. Выпадашки города в шапке больше нет
* (`CityPicker`) сюда НЕ пробрасывается: она презентационная и своим выбором * вовсе (`CityPicker` удалён 29.08.2026, см. JSDoc HeaderV3): она была
* не владеет (см. её JSDoc), а гейт обязан читать то, что человек указал * презентационной и своим выбором не владела, то есть значение задавалось в
* рядом с адресом. * двух местах, а на расчёт влияло только это. Гейт обязан читать то, что
* человек указал рядом с адресом, и теперь это единственное такое место.
* *
* СОСТОЯНИЕ ТЕПЕРЬ ДОЕЗЖАЕТ ДО ЧЕРНОВИКА. Поле было обязательным, а его * СОСТОЯНИЕ ТЕПЕРЬ ДОЕЗЖАЕТ ДО ЧЕРНОВИКА. Поле было обязательным, а его
* значение никуда не клалось человек выбирал состояние квартиры, и мы его * значение никуда не клалось человек выбирал состояние квартиры, и мы его

View file

@ -10,11 +10,18 @@
* реальных сделках» была неправдой. Сделок мало игру не рендерит родитель: * реальных сделках» была неправдой. Сделок мало игру не рендерит родитель:
* играть на одной строке под заголовком «три квартиры» нельзя. * играть на одной строке под заголовком «три квартиры» нельзя.
* *
* ЧЕГО В КАРТОЧКЕ БОЛЬШЕ НЕТ: точки «ОБЪЕКТ НА КАРТЕ». Координат в данных * КАРТА настоящая, и их две. Есть схема улиц (`street_scheme`, 92.1% строк
* витрины нет вовсе (улица известна у 2.7% сделок), а точка на карте это * витрины) рисуется `StreetMapV3`: окрестности улицы сделки. Нет остаётся
* утверждение о конкретном месте. Фон-сетка осталась декорацией, объект * `DealMapV3` с полигоном района, как было. Выбор делается ЗДЕСЬ, чтобы обе
* подписан тем, что известно: район (или прямое «район не указан»), комнаты, * карты оставались тупыми: каждая рисует то, что ей дали, и ни одна не
* площадь, этаж, квартал сделки. * подставляет вместо отсутствующих данных правдоподобное.
*
* Обе подписывают себя сами и обе говорят про масштаб: схема улиц «улица ·
* ДОМ НЕ ИЗВЕСТЕН», район «СЕРЕДИНА УЛИЦЫ, НЕ ДОМ». Раньше тут стоял
* декоративный прямоугольник с подписью «ОБЪЕКТ · РАЙОН», заявлявший
* местоположение объекта то, чего в данных нет. Ниже карты объект
* по-прежнему подписан только тем, что известно: комнаты, площадь, этаж,
* район, квартал сделки. Адреса среди них нет.
* *
* Ошибка игрока считается медианой |ответ факт| / факт по сыгранным раундам * Ошибка игрока считается медианой |ответ факт| / факт по сыгранным раундам
* той же формулой, что и ошибка МЕРЫ на этих же сделках (`err_pct` витрины). * той же формулой, что и ошибка МЕРЫ на этих же сделках (`err_pct` витрины).
@ -26,6 +33,8 @@ import { useState } from "react";
import styles from "../../landing-v3.module.css"; import styles from "../../landing-v3.module.css";
import type { GameRound } from "./deal-view"; import type { GameRound } from "./deal-view";
import { DealMapV3 } from "./DealMapV3";
import { StreetMapV3 } from "./StreetMapV3";
import { PublicLink } from "../PublicLink"; import { PublicLink } from "../PublicLink";
@ -96,11 +105,11 @@ export function GuessGameV3({ rounds }: { rounds: readonly GameRound[] }) {
<div className={styles.gameGrid}> <div className={styles.gameGrid}>
<div className={styles.gameMap}> <div className={styles.gameMap}>
<div className={styles.gameMapGrid} aria-hidden="true" /> <div className={styles.gameMapGrid} aria-hidden="true" />
<div className={styles.gameMapRoadH} aria-hidden="true" /> {r.streetScheme ? (
<div className={styles.gameMapRoadV} aria-hidden="true" /> <StreetMapV3 scheme={r.streetScheme} />
<div className={styles.gameMapLabel}> ) : (
{`ОБЪЕКТ · ${r.districtLabel.toUpperCase()}`} <DealMapV3 district={r.district} districtLabel={r.districtLabel} lat={r.lat} lon={r.lon} />
</div> )}
<div className={styles.gameMapBottom}> <div className={styles.gameMapBottom}>
<div className={styles.gameAddr}>{r.title}</div> <div className={styles.gameAddr}>{r.title}</div>
<div className={styles.gameMeta}>{r.meta}</div> <div className={styles.gameMeta}>{r.meta}</div>

View file

@ -1,10 +1,34 @@
/** /**
* HeaderV3 шапка лэндинга v3 (макет ~ строки 32-78): sticky, blur, лого + * HeaderV3 шапка лэндинга v3 (макет ~ строки 32-78): sticky, blur, лого +
* навигация + селектор города + CTA. * навигация + CTA.
* *
* Логотип НЕ ссылка: на публичном домене корень это сам лэндинг, то есть * Логотип НЕ ссылка: на публичном домене корень это сам лэндинг, то есть
* ссылка вела бы на текущую же страницу. * ссылка вела бы на текущую же страницу.
* *
* СЕЛЕКТОРА ГОРОДА ЗДЕСЬ БОЛЬШЕ НЕТ (29.08.2026). `CityPicker` жил в этой
* шапке с тех пор, когда карточка проверки города не спрашивала вовсе, и
* своим выбором он НЕ владел: состояние оставалось внутри компонента и никуда
* не отправлялось (см. его прежний JSDoc). Город у формы появился
* `FreeCheckCard` спрашивает его сама и на нём стоит гео-гейт расчёта (#2576),
* и с этого момента шапка стала вторым местом, задающим одно значение, но
* ни на что не влияющим: человек выбирал в шапке Нижний Тагил и всё равно
* заполнял город в форме. Правило то же, по которому селектора нет во
* внутренней шапке (`_components/InnerHeader.tsx`). Граница покрытия, ради
* которой выпадашку когда-то читали, теперь стоит текстом в герое (плашка
* «полное покрытие / данных меньше», HeroV3), то есть видна без клика.
*
* НА УЗКОМ ЭКРАНЕ ШАПКА ОДНА СТРОКА: лого и «Для бизнеса». Навигация по
* секциям и CTA скрыты в CSS (`landing-v3.module.css`, блок шапки) и
* возвращаются с той ширины, на которой шапка макета замерена как
* однострочная, а не с общего для файла шага в 720px, где она всё ещё
* переносится. На 375px переносилось всё, и липкая шапка занимала 29%
* экрана эти проценты отъедались при любой прокрутке. Куда делись пункты: «Точность»
* и «Проверьте себя» секции этой же страницы, до них доезжают прокруткой
* (ради чего на мобильном и скроллят); «Статьи» и «МЕРА для бизнеса» стоят
* ссылками в подвале (`FooterV3`); «Продажа под ключ» и на десктопе не
* ссылка, а заглушка. CTA не потерян: снизу висит `StickyCtaV3` на экране
* остаётся ОДИН призыв вместо двух одинаковых.
*
* НЕАКТИВНЫЕ ПУНКТЫ (29.08.2026). Осталась одна «Продажа под ключ» * НЕАКТИВНЫЕ ПУНКТЫ (29.08.2026). Осталась одна «Продажа под ключ»
* партнёрской формы нет ни в коде, ни у владельца, и пункт остаётся видимым, * партнёрской формы нет ни в коде, ни у владельца, и пункт остаётся видимым,
* но некликабельным. «Проверьте себя» и «Статьи» раньше стояли рядом с ней по * но некликабельным. «Проверьте себя» и «Статьи» раньше стояли рядом с ней по
@ -21,16 +45,14 @@
* «Для бизнеса» ведёт на АБСОЛЮТНЫЙ адрес B2B-контура: относительный «/» на * «Для бизнеса» ведёт на АБСОЛЮТНЫЙ адрес B2B-контура: относительный «/» на
* публичном домене вёл на сам лэндинг, то есть пункт не работал вовсе. * публичном домене вёл на сам лэндинг, то есть пункт не работал вовсе.
* *
* Единственный клиентский остров `CityPicker` (открытие/закрытие, поиск). * Шапка целиком серверная: клиентских островов в ней не осталось (последним
* Сама шапка серверная. * был `CityPicker`).
*/ */
import { B2B_URL } from "../../content"; import { B2B_URL } from "../../content";
import styles from "../../landing-v3.module.css"; import styles from "../../landing-v3.module.css";
import { PublicLink } from "../PublicLink"; import { PublicLink } from "../PublicLink";
import { CityPicker } from "./CityPicker";
export function HeaderV3() { export function HeaderV3() {
return ( return (
<header className={styles.hdrRoot}> <header className={styles.hdrRoot}>
@ -65,7 +87,6 @@ export function HeaderV3() {
</nav> </nav>
<div className={styles.hdrRight}> <div className={styles.hdrRight}>
<CityPicker />
<a href={B2B_URL} className={styles.hdrBizLink}> <a href={B2B_URL} className={styles.hdrBizLink}>
Для бизнеса Для бизнеса
</a> </a>

View file

@ -15,6 +15,16 @@
* того, куда именно в дерево страницы его вставят (ожидаемое место сразу * того, куда именно в дерево страницы его вставят (ожидаемое место сразу
* после подвала, как Header/Footer в текущем `layout.tsx`). * после подвала, как Header/Footer в текущем `layout.tsx`).
* *
* НА УЗКОМ ЭКРАНЕ ПАНЕЛЬ ТОЛЬКО КНОПКА (29.08.2026). Строка «Узнайте цену
* своей квартиры» на 375px переносилась и панель занимала 13% экрана, а
* вместе с шапкой хром съедал 42% при любой прокрутке. Текст скрыт в CSS
* (`.barText`), а не удалён: с той же ширины, что возвращает шапку макета
* (1000px там строка и кнопка встают рядом), панель снова целиком. То же
* обещание на самой странице остаётся словами героя («Бесплатно: сколько
* похожих квартир продаётся рядом и как давно они висят»), то есть уходит
* повтор, а не мысль. Призыв на мобильном теперь ОДИН: `HeaderV3` свою
* кнопку на этой ширине не показывает.
*
* НА КНОПКЕ НЕТ СРОКА. Здесь стояло «Проверить за 30 секунд» замера времени * НА КНОПКЕ НЕТ СРОКА. Здесь стояло «Проверить за 30 секунд» замера времени
* заполнения формы нет и не было (та же причина, по которой «30 СЕКУНД» ушло * заполнения формы нет и не было (та же причина, по которой «30 СЕКУНД» ушло
* из бейджа первого шага, см. `landing-facts.ts`). Панель висит на экране * из бейджа первого шага, см. `landing-facts.ts`). Панель висит на экране

View file

@ -0,0 +1,112 @@
/**
* StreetMapV3 схема улицы сделки в карточке игры.
*
* ЧТО ЭТО ЗАМЕНЯЕТ. `DealMapV3` показывал полигон РАЙОНА единственную
* геометрию, до которой у tradein был доступ. Район это десятки квадратных
* километров; на нём одинаково выглядят сделка у парка и сделка у ТЭЦ. С
* 29.08.2026 строка витрины несёт готовую схему улиц (`street_scheme`), и
* карточка показывает окрестности улицы, а не пятно в четверть города.
*
* DealMapV3 ОСТАЁТСЯ. Адрес сделки нормализуется и матчится с названиями OSM у
* 550 названий из 654, что покрывает 92.1% строк витрины (замер на проде
* 29.08.2026). У остальных схемы нет там по-прежнему район. Выбор делает
* родитель: схема есть эта карта, нет та.
*
* КАРТА ГОВОРИТ, ЧТО ОНА ПОКАЗЫВАЕТ. Подпись «<улица> · ДОМ НЕ ИЗВЕСТЕН».
* Адрес сделки в ДКП-данных уровня улицы, номер дома известен у 2.7% сделок, и
* координата окна центроид улицы. Точки дома тут нет и быть не может: в
* `street_scheme` НАМЕРЕННО нет ни констант проекции, ни координат окна
* поставить точку по этой схеме нельзя даже по ошибке. Поэтому карточка не
* произносит слов «объект» и «адрес»: она показывает улицу и так и говорит.
*
* ЗДАНИЙ НЕТ. `cad_buildings` 18 307 контуров на город; в плотном центре это
* 51 здание на радиус 450 м, где их в разы больше. Нарисованная застройка
* заявляла бы полноту, которой в данных нет, и пустые кварталы читались бы как
* пустыри. Улицы тоже фильтрованная выгрузка («источники шума»): именованные
* покрыты хорошо, дворовые и служебные проезды отсутствуют, поэтому фоновые
* дороги нарисованы приглушённо как контекст, а не как план квартала.
*
* НИ ОДНОГО ВНЕШНЕГО ЗАПРОСА, как и у соседа: тайлы отдали бы провайдеру IP
* посетителя и то, что он смотрит, на странице, которая обещает «без звонков и
* регистрации» (то же правило, по которому в дереве self-hosted шрифты см.
* шапку layout.tsx). Всё, что нужно, уже приехало строкой витрины.
*
* КАДР. `slice`, а не `meet`: окно квадратное, карточка нет, и `meet`
* оставил бы поля по бокам, а карта на плашке читается как карта, только когда
* доходит до краёв. Обрезать безопасно ровно потому, что целевая улица лежит в
* ЦЕНТРЕ окна по построению (окно строится вокруг её центроида) при любой
* пропорции карточки центр остаётся в кадре. Обрезаются края: часть подписей
* соседних улиц. Они контекст, а не содержание.
*
* ДОСТУПНОСТЬ как у DealMapV3, `aria-hidden`. Всё, что схема сообщает
* какая это улица и что дом не известен, стоит рядом текстом, подписью под
* картой. `role="img"` с `aria-label` заставил бы скринридер прочитать то же
* самое дважды подряд; названий соседних улиц он не сообщит всё равно
* геометрию словами не передать, а список из семи улиц без их взаимного
* расположения ничего не значит.
*
* Компонент чистый и без состояния "use client" в нём нет. В клиентский
* бандл он попадает вместе с карточкой игры, которая листает раунды.
*/
import type { StreetScheme } from "../../public-api";
import styles from "../../landing-v3.module.css";
/**
* Толщина фоновых дорог по классу, в единицах кадра (окно 840 м = 1000
* единиц). Иерархия магистраль проезд, а не одна линия на всё: без неё
* схема превращается в решётку, на которой не видно, куда улица ведёт.
*/
const ROAD_W: Record<string, number> = {
trunk: 7,
primary: 6,
secondary: 4.6,
tertiary: 3.4,
residential: 2.2,
};
const ROAD_W_FALLBACK = 2.2;
/** Целевая улица: акцент поверх мягкого ореола той же формы. */
const TARGET_W = 6;
const HALO_W = 18;
/** Вода одной толщиной: ручей и река различаются шириной, которой у нас нет. */
const WATER_W = 5;
const LABEL_SIZE = 21;
export function StreetMapV3({ scheme }: { readonly scheme: StreetScheme }) {
return (
<>
<svg
className={styles.gameMapSvg}
viewBox={`0 0 ${scheme.w} ${scheme.h}`}
preserveAspectRatio="xMidYMid slice"
aria-hidden="true"
focusable="false"
>
{scheme.water.map((d, i) => (
<path key={`w${i}`} d={d} className={styles.streetWater} strokeWidth={WATER_W} />
))}
{scheme.roads.map((r, i) => (
<path
key={`r${i}`}
d={r.d}
className={styles.streetRoad}
strokeWidth={ROAD_W[r.c] ?? ROAD_W_FALLBACK}
/>
))}
{scheme.target.map((d, i) => (
<path key={`h${i}`} d={d} className={styles.streetHalo} strokeWidth={HALO_W} />
))}
{scheme.target.map((d, i) => (
<path key={`t${i}`} d={d} className={styles.streetTarget} strokeWidth={TARGET_W} />
))}
{scheme.labels.map((l) => (
<text key={l.t} x={l.x} y={l.y} className={styles.streetLabel} fontSize={LABEL_SIZE}>
{l.t}
</text>
))}
</svg>
<div className={styles.gameMapLabel}>{`${scheme.street.toUpperCase()} · ДОМ НЕ ИЗВЕСТЕН`}</div>
</>
);
}

View file

@ -1,13 +1,18 @@
/** /**
* Форматирование строк витрины `/showcase` общее для ленты, таблицы сверок и * Форматирование строк витрины `/showcase` общее для ленты, таблицы сверок и
* игры. Держится в одном месте, потому что «объект» в этих данных описывается * игры. Держится в одном месте, потому что «объект» в этих данных описывается
* ТОЛЬКО так: комнаты, площадь, этаж, район (может отсутствовать). Улицы и * ТОЛЬКО так: комнаты, площадь, этаж, район (может отсутствовать). НАЗВАНИЯ
* дома в данных нет вовсе номер дома известен у 2.7% сделок, и три * улицы и номера дома в данных нет номер дома известен у 2.7% сделок, и
* компонента, каждый со своей склейкой, рано или поздно разъехались бы в том, * три компонента, каждый со своей склейкой, рано или поздно разъехались бы в
* что именно они выдают за адрес. * том, что именно они выдают за адрес.
*
* С 29.08.2026 у строки есть координата, но адресом она не становится: это
* центроид УЛИЦЫ (около тридцати сделок в одной точке), он годится карте
* масштаба района и не годится подписи «объект находится здесь». Поэтому
* координата уезжает только в карту, а в текстовые склейки нет.
*/ */
import type { ShowcaseDeal } from "../../public-api"; import type { ShowcaseDeal, StreetScheme } from "../../public-api";
const RUB = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 }); const RUB = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 });
const PCT = new Intl.NumberFormat("ru-RU", { const PCT = new Intl.NumberFormat("ru-RU", {
@ -25,8 +30,17 @@ export const count = (value: number): string => RUB.format(value);
export const errPct = (value: number): string => `${PCT.format(value)} %`; export const errPct = (value: number): string => `${PCT.format(value)} %`;
/** «2-к, 52 м²» — всё, что про объект известно наверняка. */ /** «2-к, 52 м²» — всё, что про объект известно наверняка. */
export const dealTitle = (deal: ShowcaseDeal): string => /**
`${deal.rooms}-к, ${AREA.format(deal.area_m2)} м²`; * Заголовок сделки. `rooms = 0` это СТУДИЯ, а не «ноль комнат»: так её
* называет и наш собственный бэктест (`per_rooms.label` в
* scripts/backtest_estimator.py), и рынок. На витрине таких 3 из 20 (замер на
* проде 29.08.2026), и «0-к, 25,9 м²» читалось бы как ошибка выгрузки, а не
* как тип квартиры.
*/
export const dealTitle = (deal: ShowcaseDeal): string => {
const area = `${AREA.format(deal.area_m2)} м²`;
return deal.rooms === 0 ? `Студия, ${area}` : `${deal.rooms}-к, ${area}`;
};
/** /**
* Район / этаж / число аналогов через точку. Отсутствующие части ВЫПАДАЮТ, а * Район / этаж / число аналогов через точку. Отсутствующие части ВЫПАДАЮТ, а
@ -51,6 +65,21 @@ export interface GameRound {
readonly meta: string; readonly meta: string;
/** Район или прямое «район не указан» — подстановки нет. */ /** Район или прямое «район не указан» — подстановки нет. */
readonly districtLabel: string; readonly districtLabel: string;
/**
* Район как он записан в данных им карта ищет полигон, и подставлять
* вместо `null` подпись «район не указан» нельзя: совпадений с именами
* районов у неё не будет, но выглядеть это будет как поиск.
*/
readonly district: string | null;
/** Центроид УЛИЦЫ сделки, не дом (см. `note` строки витрины). */
readonly lat: number | null;
readonly lon: number | null;
/**
* Готовая схема улиц вокруг центра улицы сделки либо `null` у тех строк,
* чей адрес не сматчился с OSM. Раунду хватает одного поля: район в нём уже
* есть и остаётся запасным вариантом карты, подставлять вместо схемы нечего.
*/
readonly streetScheme: StreetScheme | null;
readonly quarter: string; readonly quarter: string;
/** Границы слайдера, млн ₽. */ /** Границы слайдера, млн ₽. */
readonly min: number; readonly min: number;
@ -86,6 +115,10 @@ export function toGameRound(deal: ShowcaseDeal, index: number): GameRound {
title: dealTitle(deal), title: dealTitle(deal),
meta: dealMeta(deal), meta: dealMeta(deal),
districtLabel: deal.district ?? "район не указан", districtLabel: deal.district ?? "район не указан",
district: deal.district,
lat: deal.lat,
lon: deal.lon,
streetScheme: deal.street_scheme,
quarter: deal.deal_quarter, quarter: deal.deal_quarter,
min, min,
max, max,

View file

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

View file

@ -47,6 +47,28 @@ export const b2c = {
line: "#CBD7DA", line: "#CBD7DA",
/** Тонкие внутренние разделители. */ /** Тонкие внутренние разделители. */
lineSoft: "#EAF5F7", lineSoft: "#EAF5F7",
/**
* Линия НА ТЁМНОЙ плашке (`surfaceDark`): рамки тегов, контуры соседних
* районов на карте сделки. Тот же оттенок, что уже стоял литералом в
* `landing-v3.module.css`; заведён токеном, когда карта потребовала его
* второй раз правило «hex только здесь» иначе разъезжается.
*/
lineDark: "#2C3A3F",
/**
* Приглушённый текст НА ТЁМНОЙ плашке. `muted` (#667579) на `surfaceDark`
* даёт 3.2:1 и для подписей не годится; этот 6.5:1. Заведён под подписи
* соседних улиц на схеме карты сделки.
*/
mutedDark: "#8C9CA1",
/**
* Вода на схеме улиц (реки, каналы, ручьи, пруды). Отдельный оттенок, а не
* `line-dark`: вода и дорога разные вещи, и одинаковая линия склеила бы
* их в одну сетку. Синий увод от акцента, чтобы вода не читалась как
* подсвеченная улица.
*/
water: "#2A5A78",
/** Акцент — заливки/обводки/декор. НЕ для текста, см. шапку. */ /** Акцент — заливки/обводки/декор. НЕ для текста, см. шапку. */
accent: "#16A9BC", accent: "#16A9BC",

View file

@ -17,14 +17,15 @@
* *
* ------------------------------------------------------------------------ * ------------------------------------------------------------------------
* ЭТОТ БЛОК (шапка / подвал / sticky-CTA) префиксы hdr / ftr / bar. * ЭТОТ БЛОК (шапка / подвал / sticky-CTA) префиксы hdr / ftr / bar.
* Секция «Шапка» = HeaderV3.tsx + CityPicker.tsx. * Секция «Шапка» = HeaderV3.tsx (селектор города из неё убран 29.08.2026:
* город спрашивает сама форма проверки, FreeCheckCard).
* Секция «Подвал» = FooterV3.tsx. * Секция «Подвал» = FooterV3.tsx.
* Секция «Sticky CTA» = StickyCtaV3.tsx. * Секция «Sticky CTA» = StickyCtaV3.tsx.
* ------------------------------------------------------------------------ * ------------------------------------------------------------------------
*/ */
/* ========================================================================== /* ==========================================================================
* Шапка (HeaderV3 + CityPicker) * Шапка (HeaderV3)
* ========================================================================== */ * ========================================================================== */
.hdrRoot { .hdrRoot {
@ -40,11 +41,15 @@
border-bottom: 1px solid var(--b2c-line); border-bottom: 1px solid var(--b2c-line);
} }
/* База узкий экран: в шапке остаются только лого и «Для бизнеса», они
помещаются в одну строку, поэтому и высота узкая (`flex-wrap` тут страховка,
а не режим работы). Навигация и CTA возвращаются в @media-блоке в конце
секции. */
.hdrInner { .hdrInner {
max-width: 1280px; max-width: 1280px;
margin-inline: auto; margin-inline: auto;
padding: 10px clamp(20px, 5vw, 56px); padding: 8px clamp(20px, 5vw, 56px);
min-height: 64px; min-height: 52px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px 26px; gap: 14px 26px;
@ -101,7 +106,10 @@
} }
.hdrNav { .hdrNav {
display: flex; /* База узкий экран, где навигации по секциям НЕТ: четыре пункта
переносились в две строки и вместе с остальным делали липкую шапку в 29%
экрана. Куда делись пункты в шапке HeaderV3.tsx. */
display: none;
gap: 10px 22px; gap: 10px 22px;
flex-wrap: wrap; flex-wrap: wrap;
font-size: 14.5px; font-size: 14.5px;
@ -119,8 +127,9 @@
color: var(--b2c-accent-text); color: var(--b2c-accent-text);
} }
/* «Статьи» раздела нет в коде (ни маршрута, ни контента): пункт нарочно /* «Продажа под ключ» раздела нет в коде (ни маршрута, ни контента): пункт
неинтерактивен (aria-disabled, без href), а не ссылка в 404. */ нарочно неинтерактивен (aria-disabled, без href), а не ссылка в 404.
«Статьи» стояли здесь же, пока не появился `articles/`. */
.hdrNavDisabled { .hdrNavDisabled {
color: var(--b2c-muted); color: var(--b2c-muted);
opacity: 0.65; opacity: 0.65;
@ -164,7 +173,11 @@
color: var(--b2c-on-accent); color: var(--b2c-on-accent);
} }
/* На узком экране призыв в шапке скрыт: он вёл туда же, куда липкая нижняя
панель (`.barRoot`), и два одинаковых призыва на одном экране это ровно то
«много всплывашек», на которое жалуются. Панель остаётся, шапка отдаёт. */
.hdrCta { .hdrCta {
display: none;
padding: 11px 22px; padding: 11px 22px;
font-size: 14px; font-size: 14px;
} }
@ -174,146 +187,24 @@
filter: brightness(0.92); filter: brightness(0.92);
} }
/* -- Селектор города (CityPicker) ---------------------------------------- */ /* Шапка макета целиком с той ширины, на которой она РЕАЛЬНО влезает в одну
строку (замерено: 1000px лого, четыре пункта, «Для бизнеса» и кнопка;
на 720px то же самое переносится в две строки и даёт 109px липкого хрома,
то есть ровно тот дефект, от которого узкий экран и лечили). Отсюда шаг
1000, а не общий для файла 720. */
@media (min-width: 1000px) {
.hdrInner {
padding-block: 10px;
min-height: 64px;
}
.hdrCityWrap { .hdrNav {
position: relative; display: flex;
} }
.hdrCityBtn { .hdrCta {
display: flex; display: inline-flex;
align-items: center; }
gap: 8px;
background: none;
border: 1px solid var(--b2c-line);
border-radius: 6px;
padding: 9px 14px;
cursor: pointer;
font-family: var(--b2c-font-sans);
font-size: 14px;
font-weight: 700;
color: var(--b2c-ink);
}
.hdrCityBtn:hover {
border-color: var(--b2c-accent);
color: var(--b2c-accent-text);
}
.hdrCityDot {
width: 6px;
height: 6px;
flex: 0 0 auto;
display: block;
border-radius: 50%;
background: var(--b2c-accent);
}
.hdrCityCaret {
font-family: var(--b2c-font-mono);
font-size: 11px;
color: var(--b2c-muted);
}
.hdrCityPanel {
position: absolute;
top: calc(100% + 10px);
left: 0;
z-index: 80;
width: min(84vw, 340px);
max-width: calc(100vw - 40px);
overflow: hidden;
background: var(--b2c-surface);
border: 1px solid var(--b2c-line);
border-radius: 10px;
box-shadow: 0 24px 64px color-mix(in srgb, var(--b2c-ink) 18%, transparent);
}
.hdrCitySearchRow {
padding: 14px 16px;
border-bottom: 1px solid var(--b2c-line-soft);
}
.hdrCityInput {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--b2c-line);
border-radius: 6px;
padding: 11px 14px;
font-family: var(--b2c-font-sans);
font-size: 14.5px;
color: var(--b2c-ink);
background: var(--b2c-page-bg);
outline: none;
}
.hdrCityInput:focus {
border-color: var(--b2c-accent);
background: var(--b2c-surface);
}
.hdrCityList {
max-height: 300px;
overflow-y: auto;
padding: 6px 0;
}
.hdrCityItem {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: none;
border: none;
padding: 11px 18px;
text-align: left;
cursor: pointer;
font-family: var(--b2c-font-sans);
}
.hdrCityItem:hover {
background: var(--b2c-page-bg);
}
/* Текущий выбранный город (проставляется из CityPicker.tsx как data-атрибут,
не класс состояние живёт в JS, а не в CSS-модуле). */
.hdrCityItem[data-active="true"] {
background: var(--b2c-surface-soft);
}
.hdrCityItem[data-active="true"] .hdrCityItemName {
font-weight: 800;
}
.hdrCityItemName {
font-size: 14.5px;
font-weight: 600;
color: var(--b2c-ink);
}
.hdrCityItemNote {
font-family: var(--b2c-font-mono);
font-size: 10.5px;
color: var(--b2c-muted);
white-space: nowrap;
}
.hdrCityEmpty {
margin: 0;
padding: 22px 18px;
font-size: 14px;
line-height: 1.55;
color: var(--b2c-muted);
}
.hdrCityFooter {
padding: 12px 18px;
border-top: 1px solid var(--b2c-line-soft);
font-family: var(--b2c-font-mono);
font-size: 10px;
letter-spacing: 0.08em;
color: var(--b2c-muted);
} }
/* ========================================================================== /* ==========================================================================
@ -472,16 +363,10 @@
/* Компенсация fixed-позиционирования `.barRoot` (тот вырезан из потока /* Компенсация fixed-позиционирования `.barRoot` (тот вырезан из потока
документа): без спейсера панель легла бы поверх последних пикселей документа): без спейсера панель легла бы поверх последних пикселей
страницы при докрутке до конца (реквизиты в подвале, последний вопрос страницы при докрутке до конца (реквизиты в подвале, последний вопрос
FAQ). Высота на мобильном тексте+кнопка чаще переносятся на две строки FAQ). Высота одна на все ширины: панель везде в одну строку на узком
(flex-wrap), поэтому база выше; на 720px+ строка одна. */ экране за счёт того, что текст скрыт (см. `.barText`), а не переносится. */
.barSpacer { .barSpacer {
height: 96px; height: 68px;
}
@media (min-width: 720px) {
.barSpacer {
height: 68px;
}
} }
.barRoot { .barRoot {
@ -495,14 +380,20 @@
justify-content: center; justify-content: center;
gap: 16px 24px; gap: 16px 24px;
flex-wrap: wrap; flex-wrap: wrap;
padding: 12px clamp(16px, 4vw, 32px); padding: 10px clamp(16px, 4vw, 32px);
/* Фоллбэк на непрозрачный surfaceDark для браузеров без color-mix(). */ /* Фоллбэк на непрозрачный surfaceDark для браузеров без color-mix(). */
background: var(--b2c-surface-dark); background: var(--b2c-surface-dark);
background: color-mix(in srgb, var(--b2c-surface-dark) 97%, transparent); background: color-mix(in srgb, var(--b2c-surface-dark) 97%, transparent);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
} }
/* На узком экране панель только кнопка. Строка «Узнайте цену своей
квартиры» переносилась на две-три строки и делала постоянную панель втрое
выше кнопки; то же самое сказано словами в герое (заголовок, лид и перк
«Бесплатно: сколько похожих квартир продаётся рядом»), то есть со страницы
мысль не пропадает пропадает её третий повтор поверх контента. */
.barText { .barText {
display: none;
margin: 0; margin: 0;
text-align: center; text-align: center;
font-weight: 700; font-weight: 700;
@ -518,6 +409,18 @@
font-size: 14.5px; font-size: 14.5px;
} }
/* Панель макета целиком с той же ширины, что и шапка (1000px): ниже неё
текст и кнопка тоже переносились, и панель становилась вдвое выше. */
@media (min-width: 1000px) {
.barRoot {
padding-block: 12px;
}
.barText {
display: block;
}
}
/* ========================================================================== /* ==========================================================================
* HERO (hero*) первый экран: герой-текст + карточка «Бесплатная проверка». * HERO (hero*) первый экран: герой-текст + карточка «Бесплатная проверка».
* Секция «Герой» = HeroV3.tsx + FreeCheckCard.tsx + FreeResultV3.tsx. * Секция «Герой» = HeroV3.tsx + FreeCheckCard.tsx + FreeResultV3.tsx.
@ -1643,61 +1546,83 @@
.gameMapGrid { .gameMapGrid {
position: absolute; position: absolute;
inset: 0; inset: 0;
opacity: 0.16; opacity: 0.08;
background-image: background-image:
linear-gradient(var(--b2c-accent) 1px, transparent 1px), linear-gradient(var(--b2c-accent) 1px, transparent 1px),
linear-gradient(90deg, var(--b2c-accent) 1px, transparent 1px); linear-gradient(90deg, var(--b2c-accent) 1px, transparent 1px);
background-size: 44px 44px; background-size: 44px 44px;
} }
.gameMapRoadH { .gameMapSvg {
position: absolute;
left: 0;
right: 0;
top: 38%;
height: 14px;
background: #2c3a3f;
opacity: 0.7;
}
.gameMapRoadV {
position: absolute;
top: 0;
bottom: 0;
left: 62%;
width: 12px;
background: #2c3a3f;
opacity: 0.7;
}
.gameMapDot {
position: absolute;
width: 16px;
height: 16px;
margin: -8px 0 0 -8px;
}
.gameMapPing {
position: absolute; position: absolute;
inset: 0; inset: 0;
display: block; width: 100%;
border-radius: 50%; height: 100%;
background: var(--b2c-accent); }
animation: gamePing 2.4s ease-out infinite; /* Район сделки: заливка акцентом поверх фон-сетки плюс сплошная обводка. */
.gameMapHome {
fill: var(--b2c-accent);
fill-opacity: 0.16;
stroke: var(--b2c-accent);
}
/* Соседние районы — контекст, а не содержание: только контур на тёмном. */
.gameMapAround {
fill: none;
stroke: var(--b2c-line-dark);
}
.gameMapHalo {
fill: var(--b2c-accent);
fill-opacity: 0.28;
} }
.gameMapCore { .gameMapCore {
position: absolute; fill: var(--b2c-accent);
inset: 0; stroke: var(--b2c-surface-dark);
display: block;
border-radius: 50%;
background: var(--b2c-accent);
border: 3px solid var(--b2c-surface-dark);
} }
@keyframes gamePing { /* --- Схема улиц (StreetMapV3). Порядок в разметке = порядок слоёв. --- */
0% { transform: scale(1); opacity: 0.55; } /* Вода: под дорогами, своим оттенком — иначе сливается с сеткой улиц. */
70%, 100% { transform: scale(2.6); opacity: 0; } .streetWater {
fill: none;
stroke: var(--b2c-water);
stroke-linecap: round;
stroke-linejoin: round;
} }
@media (prefers-reduced-motion: reduce) { /* Фоновые дороги контекст. Выгрузка неполна (нет дворовых проездов),
.gameMapPing { animation: none; } поэтому линия приглушена: это не план квартала. `line-dark` на плашке
почти не виден берём приглушённый текстовый оттенок и гасим прозрачностью,
чтобы сеть читалась, но не спорила с подсвеченной улицей. */
.streetRoad {
fill: none;
stroke: var(--b2c-muted-dark);
stroke-opacity: 0.45;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Ореол целевой улицы — та же форма, шире и почти прозрачно. */
.streetHalo {
fill: none;
stroke: var(--b2c-accent);
stroke-opacity: 0.18;
stroke-linecap: round;
stroke-linejoin: round;
}
.streetTarget {
fill: none;
stroke: var(--b2c-accent);
stroke-linecap: round;
stroke-linejoin: round;
}
.streetLabel {
fill: var(--b2c-muted-dark);
font-family: var(--b2c-font-mono);
letter-spacing: 0.04em;
} }
.gameMapLabel { .gameMapLabel {
position: relative; position: relative;
/* Плашка под подписью. Карта доходит до краёв, и подпись ложится прямо на
улицу на акцентной линии акцентный же текст не читается вовсе. */
align-self: flex-start;
padding: 5px 9px;
border-radius: 4px;
background: var(--b2c-surface-dark);
font-family: var(--b2c-font-mono); font-family: var(--b2c-font-mono);
font-size: 9.5px; font-size: 9.5px;
letter-spacing: 0.14em; letter-spacing: 0.14em;
@ -1706,6 +1631,20 @@
.gameMapBottom { .gameMapBottom {
position: relative; position: relative;
} }
/* Затемнение под подписью объекта: она лежит ПОВЕРХ карты, и без него белый
текст пересекается с подсвеченной улицей. Растягивается за padding плашки,
чтобы не было видно шва. */
.gameMapBottom::before {
content: "";
position: absolute;
inset: -46px calc(-1 * clamp(24px, 3vw, 34px)) calc(-1 * clamp(24px, 3vw, 34px));
background: linear-gradient(to bottom, transparent, var(--b2c-surface-dark) 45%);
}
/* Затемнение ПОЗИЦИОНИРОВАННЫЙ псевдоэлемент, а сам текст нет: без этой
строки он красится ПОВЕРХ подписи и стирает её. */
.gameMapBottom > * {
position: relative;
}
.gameAddr { .gameAddr {
font-weight: 800; font-weight: 800;
font-size: clamp(19px, 2.2vw, 24px); font-size: clamp(19px, 2.2vw, 24px);
@ -1727,7 +1666,7 @@
font-family: var(--b2c-font-mono); font-family: var(--b2c-font-mono);
font-size: 10.5px; font-size: 10.5px;
color: #9fb0b4; color: #9fb0b4;
border: 1px solid #2c3a3f; border: 1px solid var(--b2c-line-dark);
border-radius: 4px; border-radius: 4px;
padding: 5px 10px; padding: 5px 10px;
} }

View file

@ -194,6 +194,31 @@ export interface LandingStat {
*/ */
export type LandingStats = Readonly<Record<string, LandingStat | undefined>>; export type LandingStats = Readonly<Record<string, LandingStat | undefined>>;
/**
* Схема улиц вокруг центра улицы сделки уже спроецированные SVG-пути.
* Приходит готовой из `/showcase`: на фронте геометрии нет, есть картинка.
*
* ПОЛЕЙ ПРОЕКЦИИ И КООРДИНАТ ОКНА ЗДЕСЬ НЕТ НАМЕРЕННО. По этой схеме нельзя
* поставить точку дома а дом известен у 2.7% сделок, и ставить её было бы
* нечем и незачем. Это замок, а не забывчивость: см. шапку
* `backend/app/services/street_scheme.py`.
*/
export interface StreetScheme {
/** Название улицы как в OSM — дубль `street_name`. */
street: string;
/** Размеры квадратного окна viewBox; `h` всегда равно `w`. */
w: number;
h: number;
/** Подсвеченная улица сделки. Непуста, если схема вообще есть. */
target: string[];
/** Фоновые дороги: `c` — класс OSM (trunk…residential). */
roads: { c: string; d: string }[];
/** Реки, каналы, ручьи, пруды. Пруды приходят замкнутыми кольцами. */
water: string[];
/** Подписи соседних улиц, не более семи. */
labels: { t: string; x: number; y: number }[];
}
export interface ShowcaseDeal { export interface ShowcaseDeal {
district: string | null; district: string | null;
rooms: number; rooms: number;
@ -206,6 +231,21 @@ export interface ShowcaseDeal {
err_pct: number; err_pct: number;
n_analogs: number; n_analogs: number;
note: string; note: string;
/**
* Координата сделки ЦЕНТРОИД УЛИЦЫ, не дом (граница честности записана в
* `note` каждой строки и в COMMENT колонок). `null` координаты нет;
* подставлять на её место соседнюю строку или центр района нельзя.
*/
lat: number | null;
lon: number | null;
/**
* Улица сделки, как она названа в OSM. `null` примерно у 8% строк адрес
* не сматчился с OSM; это штатный случай, а не сбой, и подставлять на его
* место район нельзя: район не улица.
*/
street_name: string | null;
/** Схема улиц. `null` ровно тогда же, когда `street_name`. */
street_scheme: StreetScheme | null;
} }
/** Подпись под витриной: из чего отобраны показанные строки. */ /** Подпись под витриной: из чего отобраны показанные строки. */

View file

@ -95,9 +95,12 @@ export const b2cVars = {
"--b2c-ink": b2c.ink, "--b2c-ink": b2c.ink,
"--b2c-muted": b2c.muted, "--b2c-muted": b2c.muted,
"--b2c-muted-dark": b2c.mutedDark,
"--b2c-water": b2c.water,
"--b2c-line": b2c.line, "--b2c-line": b2c.line,
"--b2c-line-soft": b2c.lineSoft, "--b2c-line-soft": b2c.lineSoft,
"--b2c-line-dark": b2c.lineDark,
// accent — только заливки/обводки; текст красится accentText (5.8:1). // accent — только заливки/обводки; текст красится accentText (5.8:1).
// Разбор контраста — в шапке b2c-tokens.ts. // Разбор контраста — в шапке b2c-tokens.ts.