Merge pull request 'feat(mera/b2c): настоящая карта улицы в карточке игры + мобильный лэндинг перестал съедать 45% экрана' (#3245) from feat/b2c-game-real-map into main
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 42s
Deploy / build-worker (push) Successful in 43s
Deploy / deploy (push) Successful in 1m9s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Successful in 2m10s
Deploy Trade-In / test (push) Successful in 4m11s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy Trade-In / deploy (push) Successful in 1m55s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 42s
Deploy / build-worker (push) Successful in 43s
Deploy / deploy (push) Successful in 1m9s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Successful in 2m10s
Deploy Trade-In / test (push) Successful in 4m11s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy Trade-In / deploy (push) Successful in 1m55s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
This commit is contained in:
commit
ba35c68eb2
26 changed files with 1948 additions and 369 deletions
57
data/sql/195_tradein_osm_roads_view.sql
Normal file
57
data/sql/195_tradein_osm_roads_view.sql
Normal 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;
|
||||
|
|
@ -393,8 +393,36 @@ class ShowcaseDeal(BaseModel):
|
|||
|
||||
`district` / `floor` / `total_floors` НУЛЛАБЕЛЬНЫ намеренно: этих величин в
|
||||
ДКП-данных может не быть, и фронт обязан пережить null, а не получить
|
||||
правдоподобную подстановку.
|
||||
|
||||
`street_name` / `street_scheme` — УЛИЦА, А НЕ ДОМ. Номер дома есть у 2.7%
|
||||
сделок (разбор в миграции 276), поэтому дома в витрине нет и не будет.
|
||||
Оба поля НУЛЛАБЕЛЬНЫ, и null — штатный случай: название сматчилось с OSM у
|
||||
550 названий из 654 (92.3% сделок, замер 2026-08-29), остальным схемы нет и
|
||||
фронт показывает район.
|
||||
|
||||
`street_scheme` — уже спроецированные SVG-пути окна 840×840 м вокруг центра
|
||||
улицы::
|
||||
|
||||
{"street": "улица Краснолесья", "w": 1000, "h": 1000,
|
||||
"target": ["M…L…"], # подсвеченная улица
|
||||
"roads": [{"c": "primary", "d": "M…L…"}], # фон, c = класс дороги
|
||||
"water": ["M…L…"],
|
||||
"labels": [{"t": "Чкалова", "x": 431.2, "y": 88.0}]}
|
||||
|
||||
Координат окна и констант проекции в схеме НЕТ намеренно: по ней нельзя
|
||||
положить (lon, lat) в её систему координат, то есть нарисовать точку дома
|
||||
невозможно даже случайно. Схема готовая, а не геометрия, потому что GeoJSON
|
||||
того же окна — 7-8 КБ на строку против 2.8-2.9 КБ схемы (замер 2026-08-29).
|
||||
правдоподобную подстановку. Улицы и дома в модели нет вовсе — номер дома
|
||||
есть у 2.7% сделок (разбор в миграции 276).
|
||||
|
||||
`lat` / `lon` — ЦЕНТРОИД УЛИЦЫ, не дом: 991 различная координата на 34 017
|
||||
сделок выборки (≈34 сделки в одной точке) при тех же 2.7% известных домов
|
||||
(замер 2026-08-29, миграция 280). Точка честна на масштабе района и улицы
|
||||
и НЕ честна на масштабе дома — то же самое написано в `note` строки,
|
||||
которая едет рядом. Тоже нуллабельны: у части сделок координаты нет, и
|
||||
такая строка остаётся на витрине без точки, а не выбрасывается.
|
||||
"""
|
||||
|
||||
district: str | None
|
||||
|
|
@ -408,6 +436,10 @@ class ShowcaseDeal(BaseModel):
|
|||
err_pct: float
|
||||
n_analogs: int
|
||||
note: str
|
||||
street_name: str | None
|
||||
street_scheme: dict | None
|
||||
lat: float | None = None
|
||||
lon: float | None = None
|
||||
|
||||
|
||||
class ShowcaseStats(BaseModel):
|
||||
|
|
@ -458,7 +490,8 @@ _SHOWCASE_RUN_SQL = text(
|
|||
_SHOWCASE_SQL = text(
|
||||
"""
|
||||
SELECT district, rooms, area_m2, floor, total_floors, deal_quarter,
|
||||
predicted_rub, fact_rub, err_pct, n_analogs, note
|
||||
predicted_rub, fact_rub, err_pct, n_analogs, note,
|
||||
lat, lon, street_name, street_scheme
|
||||
FROM landing_showcase_deals
|
||||
WHERE computed_at = CAST(:computed_at AS timestamptz)
|
||||
ORDER BY id
|
||||
|
|
@ -515,6 +548,13 @@ def public_showcase(
|
|||
err_pct=float(r["err_pct"]),
|
||||
n_analogs=int(r["n_analogs"]),
|
||||
note=r["note"],
|
||||
street_name=r["street_name"],
|
||||
street_scheme=r["street_scheme"],
|
||||
# Порядок не менять: lat — широта (~56.8 в ЕКБ), lon — долгота
|
||||
# (~60.6). Перепутанные местами координаты остаются валидными
|
||||
# float и уедут на карту точкой в другой стране.
|
||||
lat=(float(r["lat"]) if r["lat"] is not None else None),
|
||||
lon=(float(r["lon"]) if r["lon"] is not None else None),
|
||||
)
|
||||
for r in rows
|
||||
],
|
||||
|
|
|
|||
328
tradein-mvp/backend/app/services/street_scheme.py
Normal file
328
tradein-mvp/backend/app/services/street_scheme.py
Normal 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,
|
||||
}
|
||||
|
|
@ -37,6 +37,13 @@
|
|||
----------------------------------------------------------
|
||||
* АДРЕСА НЕТ. Номер дома есть у 2.7% сделок, поэтому строка — это «район +
|
||||
2-к, 54 м², 5 эт.», и никогда не улица с домом.
|
||||
* ТОЧКА НА КАРТЕ — ЦЕНТРОИД УЛИЦЫ, НЕ ДОМ. В выборке витрины (ЕКБ,
|
||||
с 2025-01-01; замер на проде 2026-08-29) 991 различная координата на
|
||||
34 017 сделок с координатой — ≈34 сделки в одной точке, при 2.7% известных
|
||||
номеров дома. Точка честна на масштабе района и улицы и НЕ честна на
|
||||
масштабе дома. Это записано в `note` каждой строки и в COMMENT колонок
|
||||
(миграция 280), потому что докстринг на фронт не едет, а зумить карту
|
||||
будет тот, кто его не читал.
|
||||
* ДНЯ НЕТ. `deals.deal_date` — первое число квартала (10 различных значений
|
||||
на всю таблицу), поэтому в витрине только «II квартал 2026».
|
||||
* ЗАМЕР НЕ POINT-IN-TIME. Спайн считает прогноз по СЕГОДНЯШНИМ активным
|
||||
|
|
@ -56,6 +63,13 @@
|
|||
`MAX_FACT_PPM2 = 1.2M` был недостижим при потолке выборки 600k — из трёх
|
||||
отбраковок в проде срабатывала РОВНО ОДНА, та самая, что льстила витрине.
|
||||
Неработающая проверка читается как работающая, поэтому её нет.
|
||||
* КАРТА ПОКАЗЫВАЕТ УЛИЦУ, А НЕ ДОМ. `deals.address` — уровня улицы
|
||||
(«Екатеринбург, Краснолесья»), поэтому в строку кладётся схема окна вокруг
|
||||
ЦЕНТРА улицы (`app/services/street_scheme.py`), и в этой схеме намеренно
|
||||
нет ни координат окна, ни констант проекции: точку дома по ней нельзя
|
||||
поставить даже случайно. Название сматчилось с OSM у 550 из 654 названий —
|
||||
92.3% сделок; остальным `street_scheme` = NULL, и это штатно: фронт
|
||||
показывает район. Наличие схемы НА ОТБОР НЕ ВЛИЯЕТ (см. `_schemes_for`).
|
||||
* СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных
|
||||
строк» неотличимо от «столько и было», пока рядом не написано, сколько
|
||||
сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог
|
||||
|
|
@ -74,6 +88,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
|
@ -82,6 +97,8 @@ from typing import Any
|
|||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.street_scheme import build_street_scheme, load_street_index
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Правило отбраковки: одна формулировка, она же едет на фронт ──────────────
|
||||
|
|
@ -102,7 +119,12 @@ NOTE = (
|
|||
"Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: "
|
||||
"это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. "
|
||||
"Факт — цена ДКП, заявленная в Росреестр: она бывает занижена сторонами, и тогда "
|
||||
"строка выглядит как промах оценщика, хотя врёт документ."
|
||||
"строка выглядит как промах оценщика, хотя врёт документ. "
|
||||
"Схема на карточке — улица сделки, а не её дом: в адресе Росреестра номер дома "
|
||||
"есть у 2.7% строк, поэтому дом не показан и показан быть не может."
|
||||
"Точка на карте — центроид улицы, а не дом: в выборке витрины 991 различная "
|
||||
"координата на 34 017 сделок (≈34 сделки в одной точке), номер дома известен "
|
||||
"у 2.7% сделок. Точка честна на масштабе района и улицы и не честна на масштабе дома."
|
||||
)
|
||||
|
||||
_ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV"}
|
||||
|
|
@ -131,6 +153,10 @@ class ShowcaseRow:
|
|||
fact_rub: int
|
||||
err_pct: float
|
||||
n_analogs: int
|
||||
# Координата сделки — ЦЕНТРОИД УЛИЦЫ (замер и разбор в миграции 280 и в NOTE).
|
||||
# None штатно: у части сделок координаты нет, подставлять туда нечего.
|
||||
lat: float | None = None
|
||||
lon: float | None = None
|
||||
|
||||
|
||||
def completeness(row: ShowcaseRow) -> int:
|
||||
|
|
@ -168,6 +194,8 @@ def build_row(
|
|||
predicted_rub: float | None,
|
||||
fact_ppm2: float,
|
||||
n_analogs: int,
|
||||
lat: float | None = None,
|
||||
lon: float | None = None,
|
||||
) -> ShowcaseRow | None:
|
||||
"""Кандидат → строка витрины, либо None если считать не из чего.
|
||||
|
||||
|
|
@ -175,6 +203,11 @@ def build_row(
|
|||
цены продажи; квартал сделки неизвестен; нет площади или цены сделки
|
||||
(делить не на что). Величина отклонения причиной НЕ является ни при каких
|
||||
значениях — см. «ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ» в докстринге модуля.
|
||||
|
||||
ОТСУТСТВИЕ КООРДИНАТЫ ПРИЧИНОЙ ТОЖЕ НЕ ЯВЛЯЕТСЯ. Строка без точки едет на
|
||||
витрину с lat=lon=None: карта переживёт сделку без точки, а выбрасывание
|
||||
сделки из-за отсутствия координаты — отбор по признаку, не связанному с
|
||||
качеством оценки, то есть та же порча витрины, что и отбор по ошибке.
|
||||
"""
|
||||
if predicted_rub is None or predicted_rub <= 0 or area_m2 <= 0 or fact_ppm2 <= 0:
|
||||
return None
|
||||
|
|
@ -200,6 +233,8 @@ def build_row(
|
|||
fact_rub=round(fact_rub),
|
||||
err_pct=round(err_pct, 2),
|
||||
n_analogs=n_analogs,
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -255,17 +290,41 @@ _INSERT_SQL = text(
|
|||
"""
|
||||
INSERT INTO landing_showcase_deals
|
||||
(district, rooms, area_m2, floor, total_floors, deal_quarter,
|
||||
predicted_rub, fact_rub, err_pct, n_analogs, note)
|
||||
predicted_rub, fact_rub, err_pct, n_analogs, note,
|
||||
lat, lon, street_name, street_scheme)
|
||||
VALUES
|
||||
(CAST(:district AS text), CAST(:rooms AS integer), CAST(:area_m2 AS numeric),
|
||||
CAST(:floor AS integer), CAST(:total_floors AS integer),
|
||||
CAST(:deal_quarter AS text), CAST(:predicted_rub AS bigint),
|
||||
CAST(:fact_rub AS bigint), CAST(:err_pct AS numeric),
|
||||
CAST(:n_analogs AS integer), CAST(:note AS text))
|
||||
CAST(:n_analogs AS integer), CAST(:note AS text),
|
||||
CAST(:lat AS double precision), CAST(:lon AS double precision),
|
||||
CAST(:street_name AS text), CAST(:street_scheme AS jsonb))
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _schemes_for(db: Session, chosen: list[ShowcaseRow], addresses: dict[int, str | None]) -> dict:
|
||||
"""Схемы улиц ТОЛЬКО для показанных строк: id сделки → схема.
|
||||
|
||||
Считаем после отбора, а не до: схема — это два пространственных запроса на
|
||||
сделку, и на двухстах кандидатах ради двадцати показанных это четыреста
|
||||
лишних запросов в чужую базу.
|
||||
|
||||
НА ОТБОР НАЛИЧИЕ СХЕМЫ НЕ ВЛИЯЕТ — и это то же правило, что запрещает отбор
|
||||
по величине ошибки. Ранжируй мы строки со сматчившейся улицей выше, витрина
|
||||
показывала бы не работу оценщика, а те 92% адресов, которые удобно легли на
|
||||
OSM. Схема — украшение строки, а не причина её показать.
|
||||
"""
|
||||
index = load_street_index(db)
|
||||
out = {}
|
||||
for row in chosen:
|
||||
scheme = build_street_scheme(db, index, addresses.get(row.deal_id))
|
||||
if scheme is not None:
|
||||
out[row.deal_id] = scheme
|
||||
return out
|
||||
|
||||
|
||||
def refresh_landing_showcase_deals(
|
||||
db: Session,
|
||||
*,
|
||||
|
|
@ -328,6 +387,11 @@ def refresh_landing_showcase_deals(
|
|||
predicted_rub=pr.expected_sold_price,
|
||||
fact_ppm2=deal.sold_ppm2,
|
||||
n_analogs=len(capture[0]["kwargs"]["listings"]) if capture else 0,
|
||||
# Порядок ровно такой: lat — широта (~56.8 для ЕКБ), lon — долгота
|
||||
# (~60.6). Перепутать местами — это точка в другой стране, и никакой
|
||||
# тип этого не поймает: обе величины float.
|
||||
lat=deal.lat,
|
||||
lon=deal.lon,
|
||||
)
|
||||
if row is None:
|
||||
n_incomplete += 1
|
||||
|
|
@ -335,13 +399,17 @@ def refresh_landing_showcase_deals(
|
|||
candidates.append(row)
|
||||
|
||||
chosen = select_rows(candidates, limit)
|
||||
schemes = _schemes_for(db, chosen, {d.id: d.address for d in deals})
|
||||
|
||||
db.execute(_DELETE_SQL)
|
||||
db.execute(_DELETE_RUNS_SQL)
|
||||
for row in chosen:
|
||||
scheme = schemes.get(row.deal_id)
|
||||
db.execute(
|
||||
_INSERT_SQL,
|
||||
{
|
||||
"street_name": scheme["street"] if scheme else None,
|
||||
"street_scheme": json.dumps(scheme, ensure_ascii=False) if scheme else None,
|
||||
"district": row.district,
|
||||
"rooms": row.rooms,
|
||||
"area_m2": row.area_m2,
|
||||
|
|
@ -353,6 +421,8 @@ def refresh_landing_showcase_deals(
|
|||
"err_pct": row.err_pct,
|
||||
"n_analogs": row.n_analogs,
|
||||
"note": NOTE,
|
||||
"lat": row.lat,
|
||||
"lon": row.lon,
|
||||
},
|
||||
)
|
||||
counters = {
|
||||
|
|
@ -364,6 +434,10 @@ def refresh_landing_showcase_deals(
|
|||
"written": len(chosen),
|
||||
"with_district": sum(1 for r in chosen if r.district is not None),
|
||||
}
|
||||
# Схем — не счётчик в `landing_showcase_runs` намеренно: у КАЖДОЙ строки
|
||||
# витрины street_scheme либо есть, либо null, и это едет на фронт вместе со
|
||||
# строкой. Отдельное число повторяло бы то, что посетитель и так видит.
|
||||
logger.info("схем улиц собрано: %d из %d показанных", len(schemes), len(chosen))
|
||||
db.execute(_INSERT_RUN_SQL, {**counters, "rejection_rule": REJECTION_RULE})
|
||||
db.commit()
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
86
tradein-mvp/backend/data/sql/281_street_scheme.sql
Normal file
86
tradein-mvp/backend/data/sql/281_street_scheme.sql
Normal 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;
|
||||
108
tradein-mvp/backend/scripts/export_ekb_districts_svg.py
Normal file
108
tradein-mvp/backend/scripts/export_ekb_districts_svg.py
Normal 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())
|
||||
|
|
@ -93,6 +93,8 @@ def _build(**over: object) -> ShowcaseRow | None:
|
|||
"predicted_rub": 5_000_000.0,
|
||||
"fact_ppm2": 100_000.0, # → факт 5 000 000 ₽, ошибка 0%
|
||||
"n_analogs": 30,
|
||||
"lat": 56.8386, # ЕКБ: широта ~56.8, долгота ~60.6 — величины НЕ похожи,
|
||||
"lon": 60.6055, # поэтому перестановка ловится по значению.
|
||||
}
|
||||
kwargs.update(over)
|
||||
return build_row(**kwargs) # type: ignore[arg-type]
|
||||
|
|
@ -169,3 +171,34 @@ def test_unknown_quarter_is_not_invented() -> None:
|
|||
assert _build(deal_date=None) is None
|
||||
assert quarter_label(None) is None
|
||||
assert quarter_label(date(2026, 7, 1)) == "III квартал 2026"
|
||||
|
||||
|
||||
# ── Координата: центроид улицы, но обязана доехать и обязана не перепутаться ──
|
||||
|
||||
|
||||
def test_coords_are_carried_through_and_not_swapped() -> None:
|
||||
"""Широта остаётся широтой. Обе величины float, тип перестановку не поймает.
|
||||
|
||||
Ломать так: поменять в `build_row` местами `lat=lat` и `lon=lon` — тест
|
||||
покраснеет по ЗНАЧЕНИЮ (56.8386 против 60.6055), а не по форме.
|
||||
"""
|
||||
row = _build()
|
||||
assert row is not None
|
||||
assert row.lat == 56.8386, "широта уехала не в ту колонку"
|
||||
assert row.lon == 60.6055, "долгота уехала не в ту колонку"
|
||||
|
||||
|
||||
def test_row_without_coords_stays_on_showcase() -> None:
|
||||
"""Нет точки — строка всё равно на витрине, с lat=lon=None.
|
||||
|
||||
Выбрасывать сделку из-за отсутствия координаты — отбор по признаку, не
|
||||
связанному с качеством оценки: та же порча витрины, что и отбор по
|
||||
величине ошибки, просто по другому полю. Карта переживёт строку без точки.
|
||||
|
||||
Ломать так: добавить в `build_row` `if lat is None or lon is None: return
|
||||
None` — тест покраснеет на None вместо строки.
|
||||
"""
|
||||
row = _build(lat=None, lon=None)
|
||||
assert row is not None, "сделка выброшена из витрины за отсутствие координаты"
|
||||
assert row.lat is None and row.lon is None, "координата придумана там, где её нет"
|
||||
assert row.fact_rub == 5_000_000, "остальные величины строки не пострадали"
|
||||
|
|
|
|||
|
|
@ -179,6 +179,20 @@ _SHOWCASE_ROW = {
|
|||
"err_pct": 3.39,
|
||||
"n_analogs": 41,
|
||||
"note": "не point-in-time",
|
||||
"street_name": "улица Краснолесья",
|
||||
"street_scheme": {
|
||||
"street": "улица Краснолесья",
|
||||
"w": 1000,
|
||||
"h": 1000,
|
||||
"target": ["M10 20L30 40"],
|
||||
"roads": [{"c": "primary", "d": "M0 0L100 100"}],
|
||||
"water": [],
|
||||
"labels": [{"t": "улица Чкалова", "x": 431.2, "y": 88.0}],
|
||||
},
|
||||
# Центроид улицы, не дом (миграция 280). ЕКБ: широта ~56.8, долгота ~60.6 —
|
||||
# величины намеренно непохожие, чтобы перестановка ловилась по значению.
|
||||
"lat": 56.8386,
|
||||
"lon": 60.6055,
|
||||
}
|
||||
_SHOWCASE_RUN = {
|
||||
"computed_at": datetime(2026, 8, 29, 10, 0, tzinfo=UTC),
|
||||
|
|
@ -217,6 +231,62 @@ def test_anonymous_gets_showcase(client: TestClient) -> None:
|
|||
assert body["deals"][0]["fact_rub"] == 5_900_000
|
||||
# Адреса в контракте ручки нет вовсе — в `deals` дом известен у 2.7% строк.
|
||||
assert "address" not in body["deals"][0]
|
||||
# Схема улицы едет готовыми SVG-путями, а не геометрией.
|
||||
assert body["deals"][0]["street_name"] == "улица Краснолесья"
|
||||
assert body["deals"][0]["street_scheme"]["target"] == ["M10 20L30 40"]
|
||||
|
||||
|
||||
def test_showcase_row_without_street_stays_null(client: TestClient) -> None:
|
||||
"""Несматчившаяся улица — 8% сделок — проходит насквозь как null.
|
||||
|
||||
Ни подстановки соседней улицы, ни падения: фронт по этому null показывает
|
||||
район. Ломать так: сделать street_name/street_scheme обязательными в
|
||||
`ShowcaseDeal` — тест покраснеет на 500.
|
||||
"""
|
||||
row = {**_SHOWCASE_ROW, "street_name": None, "street_scheme": None}
|
||||
client.app.dependency_overrides[get_db] = lambda: _showcase_db(rows=[row])
|
||||
|
||||
body = client.get(f"{PREFIX}/showcase").json()
|
||||
assert body["deals"][0]["street_name"] is None
|
||||
assert body["deals"][0]["street_scheme"] is None
|
||||
|
||||
|
||||
def test_showcase_carries_coords_without_swapping_them(client: TestClient) -> None:
|
||||
"""Координата доезжает до JSON и не переставлена местами.
|
||||
|
||||
Обе величины float, поэтому перестановку не поймает ни схема, ни тип —
|
||||
только значение: 56.8 (широта ЕКБ) против 60.6 (долгота). Ломать так:
|
||||
поменять местами `lat=` и `lon=` в сборке `ShowcaseDeal` — покраснеет здесь.
|
||||
|
||||
Точка при этом — ЦЕНТРОИД УЛИЦЫ, а не дом (991 различная координата на
|
||||
34 017 сделок выборки); ровно это написано в `note`, который едет рядом.
|
||||
"""
|
||||
client.app.dependency_overrides[get_db] = lambda: _showcase_db()
|
||||
|
||||
deal = client.get(f"{PREFIX}/showcase").json()["deals"][0]
|
||||
assert deal["lat"] == 56.8386, "широта приехала не в то поле"
|
||||
assert deal["lon"] == 60.6055, "долгота приехала не в то поле"
|
||||
|
||||
|
||||
def test_showcase_row_without_coords_reaches_the_client_as_null(
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
"""Сделка без координаты доезжает до ответа целиком, с lat=lon=null.
|
||||
|
||||
Карта переживёт строку без точки; выбрасывание такой сделки было бы
|
||||
отбором по признаку, не связанному с качеством оценки. Подстановка центра
|
||||
города вместо отсутствующей точки — тем более враньё.
|
||||
|
||||
Ломать так: заменить null на любую подстановку или отфильтровать строку в
|
||||
ручке — покраснеет по значению, а не по форме.
|
||||
"""
|
||||
row = {**_SHOWCASE_ROW, "lat": None, "lon": None}
|
||||
client.app.dependency_overrides[get_db] = lambda: _showcase_db(rows=[row])
|
||||
|
||||
deals = client.get(f"{PREFIX}/showcase").json()["deals"]
|
||||
assert len(deals) == 1, "строка без координаты выброшена из витрины"
|
||||
assert deals[0]["lat"] is None and deals[0]["lon"] is None
|
||||
assert deals[0]["fact_rub"] == 5_900_000, "остальные величины строки не пострадали"
|
||||
|
||||
|
||||
def test_showcase_carries_counters_so_20_rows_cannot_read_as_all_there_was(
|
||||
|
|
|
|||
214
tradein-mvp/backend/tests/test_street_scheme.py
Normal file
214
tradein-mvp/backend/tests/test_street_scheme.py
Normal 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']}")
|
||||
|
|
@ -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("ДОМ НЕ ИЗВЕСТЕН");
|
||||
});
|
||||
});
|
||||
|
|
@ -64,10 +64,25 @@ const V3_DIR = "_components/v3";
|
|||
* Компонент, положенный ВНЕ каталога, каталогом не ловится — за это отвечает
|
||||
* отдельная проверка «всё, что импортирует корень, попало в список».
|
||||
*/
|
||||
/**
|
||||
* Единственное исключение — таблица координат, СГЕНЕРИРОВАННАЯ из боевой
|
||||
* геометрии (`backend/scripts/export_ekb_districts_svg.py`). Её числа — не
|
||||
* величины о рынке, а границы районов: «56.8» в пути полигона ничего не
|
||||
* утверждает и источник у неё проверяемый, записанный в шапке файла. Правило
|
||||
* (1) на ней даёт шум, за которым правило перестают читать.
|
||||
*
|
||||
* Исключение НЕ бланковое: ниже отдельная проверка требует, чтобы каждый
|
||||
* исключённый файл нёс маркер генератора. Иначе строчка в этом списке стала бы
|
||||
* способом занести в компонент любое число, сославшись на генератор, которого
|
||||
* нет.
|
||||
*/
|
||||
const GENERATED = ["ekb-districts.ts"];
|
||||
const GENERATED_MARK = "СГЕНЕРИРОВАН";
|
||||
|
||||
const V3_SOURCES = [
|
||||
LANDING_ROOT,
|
||||
...readdirSync(join(ROOT, V3_DIR))
|
||||
.filter((name) => /\.tsx?$/u.test(name))
|
||||
.filter((name) => /\.tsx?$/u.test(name) && !GENERATED.includes(name))
|
||||
.sort()
|
||||
.map((name) => `${V3_DIR}/${name}`),
|
||||
];
|
||||
|
|
@ -140,6 +155,10 @@ describe("гейт витринных чисел (#2904)", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it.each(GENERATED)("исключённый из скана %s действительно сгенерирован, а не написан руками", (name) => {
|
||||
expect(read(`${V3_DIR}/${name}`)).toContain(GENERATED_MARK);
|
||||
});
|
||||
|
||||
it("сканер видит вписанное число и молчит на честном коде — иначе он проходил бы вхолостую", () => {
|
||||
// Контроль на инструмент. Слева — ровно то, что гейт обязан ловить
|
||||
// (величины из прежнего marketing-v3.ts), справа — код, который в
|
||||
|
|
|
|||
|
|
@ -54,6 +54,11 @@ const SHOWCASE: ShowcaseResponse = {
|
|||
err_pct: 0.58,
|
||||
n_analogs: 12,
|
||||
note: "Прогноз посчитан по активным объявлениям на дату пересчёта",
|
||||
lat: null,
|
||||
lon: null,
|
||||
// Схемы у этой строки нет — рендер идёт запасным путём, картой района.
|
||||
street_name: null,
|
||||
street_scheme: null,
|
||||
},
|
||||
],
|
||||
stats: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -28,10 +28,11 @@
|
|||
* Правило из шапки `lib/city-registry.ts`: город считается известным, только
|
||||
* если человек выбрал его сам ИЛИ `detectCityInText` нашёл его в тексте
|
||||
* адреса; молча подставлять Екатеринбург нельзя. Ответ про город вне покрытия
|
||||
* — тот же и дословно тот же, что в v1. Выпадашка города в шапке
|
||||
* (`CityPicker`) сюда НЕ пробрасывается: она презентационная и своим выбором
|
||||
* не владеет (см. её JSDoc), а гейт обязан читать то, что человек указал
|
||||
* рядом с адресом.
|
||||
* — тот же и дословно тот же, что в v1. Выпадашки города в шапке больше нет
|
||||
* вовсе (`CityPicker` удалён 29.08.2026, см. JSDoc HeaderV3): она была
|
||||
* презентационной и своим выбором не владела, то есть значение задавалось в
|
||||
* двух местах, а на расчёт влияло только это. Гейт обязан читать то, что
|
||||
* человек указал рядом с адресом, — и теперь это единственное такое место.
|
||||
*
|
||||
* СОСТОЯНИЕ ТЕПЕРЬ ДОЕЗЖАЕТ ДО ЧЕРНОВИКА. Поле было обязательным, а его
|
||||
* значение никуда не клалось — человек выбирал состояние квартиры, и мы его
|
||||
|
|
|
|||
|
|
@ -10,11 +10,18 @@
|
|||
* реальных сделках» была неправдой. Сделок мало — игру не рендерит родитель:
|
||||
* играть на одной строке под заголовком «три квартиры» нельзя.
|
||||
*
|
||||
* ЧЕГО В КАРТОЧКЕ БОЛЬШЕ НЕТ: точки «ОБЪЕКТ НА КАРТЕ». Координат в данных
|
||||
* витрины нет вовсе (улица известна у 2.7% сделок), а точка на карте — это
|
||||
* утверждение о конкретном месте. Фон-сетка осталась декорацией, объект
|
||||
* подписан тем, что известно: район (или прямое «район не указан»), комнаты,
|
||||
* площадь, этаж, квартал сделки.
|
||||
* КАРТА — настоящая, и их две. Есть схема улиц (`street_scheme`, 92.1% строк
|
||||
* витрины) — рисуется `StreetMapV3`: окрестности улицы сделки. Нет — остаётся
|
||||
* `DealMapV3` с полигоном района, как было. Выбор делается ЗДЕСЬ, чтобы обе
|
||||
* карты оставались тупыми: каждая рисует то, что ей дали, и ни одна не
|
||||
* подставляет вместо отсутствующих данных правдоподобное.
|
||||
*
|
||||
* Обе подписывают себя сами и обе говорят про масштаб: схема улиц — «улица ·
|
||||
* ДОМ НЕ ИЗВЕСТЕН», район — «СЕРЕДИНА УЛИЦЫ, НЕ ДОМ». Раньше тут стоял
|
||||
* декоративный прямоугольник с подписью «ОБЪЕКТ · РАЙОН», заявлявший
|
||||
* местоположение объекта — то, чего в данных нет. Ниже карты объект
|
||||
* по-прежнему подписан только тем, что известно: комнаты, площадь, этаж,
|
||||
* район, квартал сделки. Адреса среди них нет.
|
||||
*
|
||||
* Ошибка игрока считается медианой |ответ − факт| / факт по сыгранным раундам
|
||||
* — той же формулой, что и ошибка МЕРЫ на этих же сделках (`err_pct` витрины).
|
||||
|
|
@ -26,6 +33,8 @@ import { useState } from "react";
|
|||
|
||||
import styles from "../../landing-v3.module.css";
|
||||
import type { GameRound } from "./deal-view";
|
||||
import { DealMapV3 } from "./DealMapV3";
|
||||
import { StreetMapV3 } from "./StreetMapV3";
|
||||
|
||||
import { PublicLink } from "../PublicLink";
|
||||
|
||||
|
|
@ -96,11 +105,11 @@ export function GuessGameV3({ rounds }: { rounds: readonly GameRound[] }) {
|
|||
<div className={styles.gameGrid}>
|
||||
<div className={styles.gameMap}>
|
||||
<div className={styles.gameMapGrid} aria-hidden="true" />
|
||||
<div className={styles.gameMapRoadH} aria-hidden="true" />
|
||||
<div className={styles.gameMapRoadV} aria-hidden="true" />
|
||||
<div className={styles.gameMapLabel}>
|
||||
{`ОБЪЕКТ · ${r.districtLabel.toUpperCase()}`}
|
||||
</div>
|
||||
{r.streetScheme ? (
|
||||
<StreetMapV3 scheme={r.streetScheme} />
|
||||
) : (
|
||||
<DealMapV3 district={r.district} districtLabel={r.districtLabel} lat={r.lat} lon={r.lon} />
|
||||
)}
|
||||
<div className={styles.gameMapBottom}>
|
||||
<div className={styles.gameAddr}>{r.title}</div>
|
||||
<div className={styles.gameMeta}>{r.meta}</div>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,34 @@
|
|||
/**
|
||||
* 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). Осталась одна «Продажа под ключ» —
|
||||
* партнёрской формы нет ни в коде, ни у владельца, и пункт остаётся видимым,
|
||||
* но некликабельным. «Проверьте себя» и «Статьи» раньше стояли рядом с ней по
|
||||
|
|
@ -21,16 +45,14 @@
|
|||
* «Для бизнеса» ведёт на АБСОЛЮТНЫЙ адрес B2B-контура: относительный «/» на
|
||||
* публичном домене вёл на сам лэндинг, то есть пункт не работал вовсе.
|
||||
*
|
||||
* Единственный клиентский остров — `CityPicker` (открытие/закрытие, поиск).
|
||||
* Сама шапка серверная.
|
||||
* Шапка целиком серверная: клиентских островов в ней не осталось (последним
|
||||
* был `CityPicker`).
|
||||
*/
|
||||
|
||||
import { B2B_URL } from "../../content";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
import { PublicLink } from "../PublicLink";
|
||||
|
||||
import { CityPicker } from "./CityPicker";
|
||||
|
||||
export function HeaderV3() {
|
||||
return (
|
||||
<header className={styles.hdrRoot}>
|
||||
|
|
@ -65,7 +87,6 @@ export function HeaderV3() {
|
|||
</nav>
|
||||
|
||||
<div className={styles.hdrRight}>
|
||||
<CityPicker />
|
||||
<a href={B2B_URL} className={styles.hdrBizLink}>
|
||||
Для бизнеса
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,16 @@
|
|||
* того, куда именно в дерево страницы его вставят (ожидаемое место — сразу
|
||||
* после подвала, как Header/Footer в текущем `layout.tsx`).
|
||||
*
|
||||
* НА УЗКОМ ЭКРАНЕ ПАНЕЛЬ — ТОЛЬКО КНОПКА (29.08.2026). Строка «Узнайте цену
|
||||
* своей квартиры…» на 375px переносилась и панель занимала 13% экрана, а
|
||||
* вместе с шапкой хром съедал 42% при любой прокрутке. Текст скрыт в CSS
|
||||
* (`.barText`), а не удалён: с той же ширины, что возвращает шапку макета
|
||||
* (1000px — там строка и кнопка встают рядом), панель снова целиком. То же
|
||||
* обещание на самой странице остаётся словами героя («Бесплатно: сколько
|
||||
* похожих квартир продаётся рядом и как давно они висят»), то есть уходит
|
||||
* повтор, а не мысль. Призыв на мобильном теперь ОДИН: `HeaderV3` свою
|
||||
* кнопку на этой ширине не показывает.
|
||||
*
|
||||
* НА КНОПКЕ НЕТ СРОКА. Здесь стояло «Проверить за 30 секунд» — замера времени
|
||||
* заполнения формы нет и не было (та же причина, по которой «30 СЕКУНД» ушло
|
||||
* из бейджа первого шага, см. `landing-facts.ts`). Панель висит на экране
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,13 +1,18 @@
|
|||
/**
|
||||
* Форматирование строк витрины `/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 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)} %`;
|
||||
|
||||
/** «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 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 min: number;
|
||||
|
|
@ -86,6 +115,10 @@ export function toGameRound(deal: ShowcaseDeal, index: number): GameRound {
|
|||
title: dealTitle(deal),
|
||||
meta: dealMeta(deal),
|
||||
districtLabel: deal.district ?? "район не указан",
|
||||
district: deal.district,
|
||||
lat: deal.lat,
|
||||
lon: deal.lon,
|
||||
streetScheme: deal.street_scheme,
|
||||
quarter: deal.deal_quarter,
|
||||
min,
|
||||
max,
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
];
|
||||
|
|
@ -47,6 +47,28 @@ export const b2c = {
|
|||
line: "#CBD7DA",
|
||||
/** Тонкие внутренние разделители. */
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -17,14 +17,15 @@
|
|||
*
|
||||
* ------------------------------------------------------------------------
|
||||
* ЭТОТ БЛОК (шапка / подвал / sticky-CTA) — префиксы hdr / ftr / bar.
|
||||
* Секция «Шапка» = HeaderV3.tsx + CityPicker.tsx.
|
||||
* Секция «Шапка» = HeaderV3.tsx (селектор города из неё убран 29.08.2026:
|
||||
* город спрашивает сама форма проверки, FreeCheckCard).
|
||||
* Секция «Подвал» = FooterV3.tsx.
|
||||
* Секция «Sticky CTA» = StickyCtaV3.tsx.
|
||||
* ------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/* ==========================================================================
|
||||
* Шапка (HeaderV3 + CityPicker)
|
||||
* Шапка (HeaderV3)
|
||||
* ========================================================================== */
|
||||
|
||||
.hdrRoot {
|
||||
|
|
@ -40,11 +41,15 @@
|
|||
border-bottom: 1px solid var(--b2c-line);
|
||||
}
|
||||
|
||||
/* База — узкий экран: в шапке остаются только лого и «Для бизнеса», они
|
||||
помещаются в одну строку, поэтому и высота узкая (`flex-wrap` тут страховка,
|
||||
а не режим работы). Навигация и CTA возвращаются в @media-блоке в конце
|
||||
секции. */
|
||||
.hdrInner {
|
||||
max-width: 1280px;
|
||||
margin-inline: auto;
|
||||
padding: 10px clamp(20px, 5vw, 56px);
|
||||
min-height: 64px;
|
||||
padding: 8px clamp(20px, 5vw, 56px);
|
||||
min-height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px 26px;
|
||||
|
|
@ -101,7 +106,10 @@
|
|||
}
|
||||
|
||||
.hdrNav {
|
||||
display: flex;
|
||||
/* База — узкий экран, где навигации по секциям НЕТ: четыре пункта
|
||||
переносились в две строки и вместе с остальным делали липкую шапку в 29%
|
||||
экрана. Куда делись пункты — в шапке HeaderV3.tsx. */
|
||||
display: none;
|
||||
gap: 10px 22px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 14.5px;
|
||||
|
|
@ -119,8 +127,9 @@
|
|||
color: var(--b2c-accent-text);
|
||||
}
|
||||
|
||||
/* «Статьи» — раздела нет в коде (ни маршрута, ни контента): пункт нарочно
|
||||
неинтерактивен (aria-disabled, без href), а не ссылка в 404. */
|
||||
/* «Продажа под ключ» — раздела нет в коде (ни маршрута, ни контента): пункт
|
||||
нарочно неинтерактивен (aria-disabled, без href), а не ссылка в 404.
|
||||
«Статьи» стояли здесь же, пока не появился `articles/`. */
|
||||
.hdrNavDisabled {
|
||||
color: var(--b2c-muted);
|
||||
opacity: 0.65;
|
||||
|
|
@ -164,7 +173,11 @@
|
|||
color: var(--b2c-on-accent);
|
||||
}
|
||||
|
||||
/* На узком экране призыв в шапке скрыт: он вёл туда же, куда липкая нижняя
|
||||
панель (`.barRoot`), и два одинаковых призыва на одном экране — это ровно то
|
||||
«много всплывашек», на которое жалуются. Панель остаётся, шапка отдаёт. */
|
||||
.hdrCta {
|
||||
display: none;
|
||||
padding: 11px 22px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
|
@ -174,146 +187,24 @@
|
|||
filter: brightness(0.92);
|
||||
}
|
||||
|
||||
/* -- Селектор города (CityPicker) ---------------------------------------- */
|
||||
/* Шапка макета целиком — с той ширины, на которой она РЕАЛЬНО влезает в одну
|
||||
строку (замерено: 1000px — лого, четыре пункта, «Для бизнеса» и кнопка;
|
||||
на 720px то же самое переносится в две строки и даёт 109px липкого хрома,
|
||||
то есть ровно тот дефект, от которого узкий экран и лечили). Отсюда шаг
|
||||
1000, а не общий для файла 720. */
|
||||
@media (min-width: 1000px) {
|
||||
.hdrInner {
|
||||
padding-block: 10px;
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.hdrCityWrap {
|
||||
position: relative;
|
||||
}
|
||||
.hdrNav {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.hdrCityBtn {
|
||||
display: 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);
|
||||
.hdrCta {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
|
|
@ -472,16 +363,10 @@
|
|||
/* Компенсация fixed-позиционирования `.barRoot` (тот вырезан из потока
|
||||
документа): без спейсера панель легла бы поверх последних пикселей
|
||||
страницы при докрутке до конца (реквизиты в подвале, последний вопрос
|
||||
FAQ). Высота — на мобильном тексте+кнопка чаще переносятся на две строки
|
||||
(flex-wrap), поэтому база выше; на 720px+ строка одна. */
|
||||
FAQ). Высота одна на все ширины: панель везде в одну строку — на узком
|
||||
экране за счёт того, что текст скрыт (см. `.barText`), а не переносится. */
|
||||
.barSpacer {
|
||||
height: 96px;
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
.barSpacer {
|
||||
height: 68px;
|
||||
}
|
||||
height: 68px;
|
||||
}
|
||||
|
||||
.barRoot {
|
||||
|
|
@ -495,14 +380,20 @@
|
|||
justify-content: center;
|
||||
gap: 16px 24px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px clamp(16px, 4vw, 32px);
|
||||
padding: 10px clamp(16px, 4vw, 32px);
|
||||
/* Фоллбэк на непрозрачный surfaceDark для браузеров без color-mix(). */
|
||||
background: var(--b2c-surface-dark);
|
||||
background: color-mix(in srgb, var(--b2c-surface-dark) 97%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* На узком экране панель — только кнопка. Строка «Узнайте цену своей
|
||||
квартиры…» переносилась на две-три строки и делала постоянную панель втрое
|
||||
выше кнопки; то же самое сказано словами в герое (заголовок, лид и перк
|
||||
«Бесплатно: сколько похожих квартир продаётся рядом»), то есть со страницы
|
||||
мысль не пропадает — пропадает её третий повтор поверх контента. */
|
||||
.barText {
|
||||
display: none;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
|
|
@ -518,6 +409,18 @@
|
|||
font-size: 14.5px;
|
||||
}
|
||||
|
||||
/* Панель макета целиком — с той же ширины, что и шапка (1000px): ниже неё
|
||||
текст и кнопка тоже переносились, и панель становилась вдвое выше. */
|
||||
@media (min-width: 1000px) {
|
||||
.barRoot {
|
||||
padding-block: 12px;
|
||||
}
|
||||
|
||||
.barText {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
* HERO (hero*) — первый экран: герой-текст + карточка «Бесплатная проверка».
|
||||
* Секция «Герой» = HeroV3.tsx + FreeCheckCard.tsx + FreeResultV3.tsx.
|
||||
|
|
@ -1643,61 +1546,83 @@
|
|||
.gameMapGrid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.16;
|
||||
opacity: 0.08;
|
||||
background-image:
|
||||
linear-gradient(var(--b2c-accent) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--b2c-accent) 1px, transparent 1px);
|
||||
background-size: 44px 44px;
|
||||
}
|
||||
.gameMapRoadH {
|
||||
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 {
|
||||
.gameMapSvg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
background: var(--b2c-accent);
|
||||
animation: gamePing 2.4s ease-out infinite;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
/* Район сделки: заливка акцентом поверх фон-сетки плюс сплошная обводка. */
|
||||
.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 {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
background: var(--b2c-accent);
|
||||
border: 3px solid var(--b2c-surface-dark);
|
||||
fill: var(--b2c-accent);
|
||||
stroke: var(--b2c-surface-dark);
|
||||
}
|
||||
@keyframes gamePing {
|
||||
0% { transform: scale(1); opacity: 0.55; }
|
||||
70%, 100% { transform: scale(2.6); opacity: 0; }
|
||||
/* --- Схема улиц (StreetMapV3). Порядок в разметке = порядок слоёв. --- */
|
||||
/* Вода: под дорогами, своим оттенком — иначе сливается с сеткой улиц. */
|
||||
.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 {
|
||||
position: relative;
|
||||
/* Плашка под подписью. Карта доходит до краёв, и подпись ложится прямо на
|
||||
улицу — на акцентной линии акцентный же текст не читается вовсе. */
|
||||
align-self: flex-start;
|
||||
padding: 5px 9px;
|
||||
border-radius: 4px;
|
||||
background: var(--b2c-surface-dark);
|
||||
font-family: var(--b2c-font-mono);
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.14em;
|
||||
|
|
@ -1706,6 +1631,20 @@
|
|||
.gameMapBottom {
|
||||
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 {
|
||||
font-weight: 800;
|
||||
font-size: clamp(19px, 2.2vw, 24px);
|
||||
|
|
@ -1727,7 +1666,7 @@
|
|||
font-family: var(--b2c-font-mono);
|
||||
font-size: 10.5px;
|
||||
color: #9fb0b4;
|
||||
border: 1px solid #2c3a3f;
|
||||
border: 1px solid var(--b2c-line-dark);
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -194,6 +194,31 @@ export interface LandingStat {
|
|||
*/
|
||||
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 {
|
||||
district: string | null;
|
||||
rooms: number;
|
||||
|
|
@ -206,6 +231,21 @@ export interface ShowcaseDeal {
|
|||
err_pct: number;
|
||||
n_analogs: number;
|
||||
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;
|
||||
}
|
||||
|
||||
/** Подпись под витриной: из чего отобраны показанные строки. */
|
||||
|
|
|
|||
|
|
@ -95,9 +95,12 @@ export const b2cVars = {
|
|||
|
||||
"--b2c-ink": b2c.ink,
|
||||
"--b2c-muted": b2c.muted,
|
||||
"--b2c-muted-dark": b2c.mutedDark,
|
||||
"--b2c-water": b2c.water,
|
||||
|
||||
"--b2c-line": b2c.line,
|
||||
"--b2c-line-soft": b2c.lineSoft,
|
||||
"--b2c-line-dark": b2c.lineDark,
|
||||
|
||||
// accent — только заливки/обводки; текст красится accentText (5.8:1).
|
||||
// Разбор контраста — в шапке b2c-tokens.ts.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue