diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx
index df710146..987bb353 100644
--- a/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx
@@ -13,9 +13,11 @@ import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DealMapV3 } from "../_components/v3/DealMapV3";
-import { dealTitle } from "../_components/v3/deal-view";
+import { GuessGameV3 } from "../_components/v3/GuessGameV3";
+import { StreetMapV3 } from "../_components/v3/StreetMapV3";
import { EKB_MAP } from "../_components/v3/ekb-districts";
-import type { ShowcaseDeal } from "../public-api";
+import { dealTitle, toGameRound } from "../_components/v3/deal-view";
+import type { ShowcaseDeal, StreetScheme } from "../public-api";
/** Ширина кадра из атрибута viewBox отрисованной карты. */
function frameWidth(container: HTMLElement): number {
@@ -108,6 +110,8 @@ describe("подпись сделки различает студию и ком
note: "",
lat: null,
lon: null,
+ street_name: null,
+ street_scheme: null,
});
it("rooms = 0 подписывается студией, а не «0-к»", () => {
@@ -121,3 +125,104 @@ describe("подпись сделки различает студию и ком
expect(dealTitle(deal(2, 52))).not.toContain("Студия");
});
});
+
+// ── Схема улиц: что она обязана сказать и чего не должна заявлять ───────────
+//
+// Схема приходит готовыми путями из ручки — рисовать тут нечего, и «рисуется
+// ли svg» проверять бессмысленно. Ломаются другие вещи, и каждая ломается
+// тихо: пропадает иерархия дорог (схема превращается в решётку), теряется
+// подсветка целевой улицы (карта перестаёт быть про эту сделку), подпись
+// начинает выдавать улицу за адрес объекта, а выбор между двумя картами
+// съезжает — и 8% строк без схемы получают пустоту вместо района.
+
+const SCHEME: StreetScheme = {
+ street: "улица Краснолесья",
+ w: 1000,
+ h: 1000,
+ target: ["M431.2 88L500.1 210.4", "M500.1 210.4L512 640"],
+ roads: [
+ { c: "trunk", d: "M0 0L100 100" },
+ { c: "residential", d: "M0 900L100 950" },
+ { c: "unknown_class", d: "M10 10L20 20" },
+ ],
+ water: ["M0 500L1000 520"],
+ labels: [{ t: "улица Чкалова", x: 431.2, y: 88 }],
+};
+
+const widths = (container: HTMLElement, cls: string): number[] =>
+ [...container.querySelectorAll(`path[class*="${cls}"]`)].map((n) =>
+ Number(n.getAttribute("stroke-width")),
+ );
+
+describe("схема улиц в карточке сделки", () => {
+ it("кадр берётся из данных, а не из константы фронта", () => {
+ const { container } = render();
+ expect(container.querySelector("svg")!.getAttribute("viewBox")).toBe("0 0 800 800");
+ // slice, а не meet: окно квадратное, карточка — нет. Целевая улица лежит в
+ // центре окна по построению, поэтому обрезка краёв её не трогает.
+ expect(container.querySelector("svg")!.getAttribute("preserveAspectRatio")).toContain("slice");
+ });
+
+ it("целевая улица подсвечена и несёт ореол — по одному на каждый путь", () => {
+ const { container } = render();
+ expect(widths(container, "streetTarget")).toHaveLength(SCHEME.target.length);
+ const halo = widths(container, "streetHalo");
+ expect(halo).toHaveLength(SCHEME.target.length);
+ // Ореол обязан быть ШИРЕ линии — иначе он не ореол, а вторая линия.
+ expect(Math.min(...halo)).toBeGreaterThan(Math.max(...widths(container, "streetTarget")));
+ });
+
+ it("иерархия дорог сохраняется, а незнакомый класс не даёт нулевой толщины", () => {
+ const { container } = render();
+ const [trunk, residential, unknown] = widths(container, "streetRoad");
+ expect(trunk).toBeGreaterThan(residential);
+ expect(unknown).toBeGreaterThan(0);
+ });
+
+ it("вода нарисована и отделена от дорог", () => {
+ const { container } = render();
+ expect(widths(container, "streetWater")).toHaveLength(SCHEME.water.length);
+ });
+
+ it("подпись называет улицу и сразу говорит, что дом не известен", () => {
+ const { container, getByText } = render();
+ expect(getByText(/УЛИЦА КРАСНОЛЕСЬЯ · ДОМ НЕ ИЗВЕСТЕН/u)).toBeTruthy();
+ expect(container.textContent).not.toMatch(/АДРЕС|ОБЪЕКТ/u);
+ });
+
+ it("схема ничего не сообщает скринридеру — всё, что она знает, стоит рядом текстом", () => {
+ const { container } = render();
+ expect(container.querySelector("svg")!.getAttribute("aria-hidden")).toBe("true");
+ });
+});
+
+describe("карточка игры выбирает карту по наличию схемы", () => {
+ const deal = (scheme: StreetScheme | null): ShowcaseDeal => ({
+ district: "Ленинский",
+ rooms: 2,
+ area_m2: 52,
+ floor: 3,
+ total_floors: 9,
+ deal_quarter: "II кв. 2026",
+ predicted_rub: 5_210_000,
+ fact_rub: 5_180_000,
+ err_pct: 0.58,
+ n_analogs: 12,
+ note: "",
+ lat: null,
+ lon: null,
+ street_name: scheme ? scheme.street : null,
+ street_scheme: scheme,
+ });
+
+ it("схема есть — показана улица", () => {
+ const { container } = render();
+ expect(container.textContent).toContain("ДОМ НЕ ИЗВЕСТЕН");
+ });
+
+ it("схемы нет — остаётся район, а не пустое место", () => {
+ const { container } = render();
+ expect(container.textContent).toContain("ЛЕНИНСКИЙ");
+ expect(container.textContent).not.toContain("ДОМ НЕ ИЗВЕСТЕН");
+ });
+});
diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx
index ae83d8a5..2fdbbac7 100644
--- a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx
@@ -56,6 +56,9 @@ const SHOWCASE: ShowcaseResponse = {
note: "Прогноз посчитан по активным объявлениям на дату пересчёта",
lat: null,
lon: null,
+ // Схемы у этой строки нет — рендер идёт запасным путём, картой района.
+ street_name: null,
+ street_scheme: null,
},
],
stats: {
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx
index 562f05d3..e59f0ffc 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx
@@ -10,13 +10,18 @@
* реальных сделках» была неправдой. Сделок мало — игру не рендерит родитель:
* играть на одной строке под заголовком «три квартиры» нельзя.
*
- * КАРТА (`DealMapV3`) — настоящая: границы районов ЕКБ статикой, кадр по
- * району сделки, точка по координате, когда она есть. Раньше здесь стоял
- * декоративный прямоугольник с подписью «ОБЪЕКТ · РАЙОН», а координат в
- * витрине не было вовсе. Подпись объекта на карте сменилась: координата — это
- * ЦЕНТРОИД УЛИЦЫ, а не дом, и карта произносит это сама (разбор — в шапке
- * DealMapV3). Ниже карты объект по-прежнему подписан только тем, что известно:
- * комнаты, площадь, этаж, район, квартал сделки. Адреса среди них нет.
+ * КАРТА — настоящая, и их две. Есть схема улиц (`street_scheme`, 92.1% строк
+ * витрины) — рисуется `StreetMapV3`: окрестности улицы сделки. Нет — остаётся
+ * `DealMapV3` с полигоном района, как было. Выбор делается ЗДЕСЬ, чтобы обе
+ * карты оставались тупыми: каждая рисует то, что ей дали, и ни одна не
+ * подставляет вместо отсутствующих данных правдоподобное.
+ *
+ * Обе подписывают себя сами и обе говорят про масштаб: схема улиц — «улица ·
+ * ДОМ НЕ ИЗВЕСТЕН», район — «СЕРЕДИНА УЛИЦЫ, НЕ ДОМ». Раньше тут стоял
+ * декоративный прямоугольник с подписью «ОБЪЕКТ · РАЙОН», заявлявший
+ * местоположение объекта — то, чего в данных нет. Ниже карты объект
+ * по-прежнему подписан только тем, что известно: комнаты, площадь, этаж,
+ * район, квартал сделки. Адреса среди них нет.
*
* Ошибка игрока считается медианой |ответ − факт| / факт по сыгранным раундам
* — той же формулой, что и ошибка МЕРЫ на этих же сделках (`err_pct` витрины).
@@ -29,6 +34,7 @@ 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";
@@ -99,7 +105,11 @@ export function GuessGameV3({ rounds }: { rounds: readonly GameRound[] }) {
-
+ {r.streetScheme ? (
+
+ ) : (
+
+ )}
{r.title}
{r.meta}
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/StreetMapV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/StreetMapV3.tsx
new file mode 100644
index 00000000..625e2eb8
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/StreetMapV3.tsx
@@ -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 = {
+ 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 (
+ <>
+
+
{`${scheme.street.toUpperCase()} · ДОМ НЕ ИЗВЕСТЕН`}
+ >
+ );
+}
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts
index d8bd62ac..13e9c86a 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts
@@ -12,7 +12,7 @@
* координата уезжает только в карту, а в текстовые склейки — нет.
*/
-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", {
@@ -74,6 +74,12 @@ export interface GameRound {
/** Центроид УЛИЦЫ сделки, не дом (см. `note` строки витрины). */
readonly lat: number | null;
readonly lon: number | null;
+ /**
+ * Готовая схема улиц вокруг центра улицы сделки — либо `null` у тех строк,
+ * чей адрес не сматчился с OSM. Раунду хватает одного поля: район в нём уже
+ * есть и остаётся запасным вариантом карты, подставлять вместо схемы нечего.
+ */
+ readonly streetScheme: StreetScheme | null;
readonly quarter: string;
/** Границы слайдера, млн ₽. */
readonly min: number;
@@ -112,6 +118,7 @@ export function toGameRound(deal: ShowcaseDeal, index: number): GameRound {
district: deal.district,
lat: deal.lat,
lon: deal.lon,
+ streetScheme: deal.street_scheme,
quarter: deal.deal_quarter,
min,
max,
diff --git a/tradein-mvp/frontend/src/app/mera-public/b2c-tokens.ts b/tradein-mvp/frontend/src/app/mera-public/b2c-tokens.ts
index dfcbe336..cb905db5 100644
--- a/tradein-mvp/frontend/src/app/mera-public/b2c-tokens.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/b2c-tokens.ts
@@ -55,6 +55,21 @@ export const b2c = {
*/
lineDark: "#2C3A3F",
+ /**
+ * Приглушённый текст НА ТЁМНОЙ плашке. `muted` (#667579) на `surfaceDark`
+ * даёт 3.2:1 и для подписей не годится; этот — 6.5:1. Заведён под подписи
+ * соседних улиц на схеме карты сделки.
+ */
+ mutedDark: "#8C9CA1",
+
+ /**
+ * Вода на схеме улиц (реки, каналы, ручьи, пруды). Отдельный оттенок, а не
+ * `line-dark`: вода и дорога — разные вещи, и одинаковая линия склеила бы
+ * их в одну сетку. Синий увод от акцента, чтобы вода не читалась как
+ * подсвеченная улица.
+ */
+ water: "#2A5A78",
+
/** Акцент — заливки/обводки/декор. НЕ для текста, см. шапку. */
accent: "#16A9BC",
/** Акцент для заливки кнопок (под белым текстом). */
diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css
index 15829739..a9f09a78 100644
--- a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css
+++ b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css
@@ -1674,8 +1674,52 @@
fill: var(--b2c-accent);
stroke: var(--b2c-surface-dark);
}
+/* --- Схема улиц (StreetMapV3). Порядок в разметке = порядок слоёв. --- */
+/* Вода: под дорогами, своим оттенком — иначе сливается с сеткой улиц. */
+.streetWater {
+ fill: none;
+ stroke: var(--b2c-water);
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+/* Фоновые дороги — контекст. Выгрузка неполна (нет дворовых проездов),
+ поэтому линия приглушена: это не план квартала. `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;
@@ -1684,6 +1728,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);
diff --git a/tradein-mvp/frontend/src/app/mera-public/public-api.ts b/tradein-mvp/frontend/src/app/mera-public/public-api.ts
index 959f116e..5933b8b4 100644
--- a/tradein-mvp/frontend/src/app/mera-public/public-api.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/public-api.ts
@@ -194,6 +194,31 @@ export interface LandingStat {
*/
export type LandingStats = Readonly>;
+/**
+ * Схема улиц вокруг центра улицы сделки — уже спроецированные 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;
@@ -213,6 +238,14 @@ export interface ShowcaseDeal {
*/
lat: number | null;
lon: number | null;
+ /**
+ * Улица сделки, как она названа в OSM. `null` примерно у 8% строк — адрес
+ * не сматчился с OSM; это штатный случай, а не сбой, и подставлять на его
+ * место район нельзя: район не улица.
+ */
+ street_name: string | null;
+ /** Схема улиц. `null` ровно тогда же, когда `street_name`. */
+ street_scheme: StreetScheme | null;
}
/** Подпись под витриной: из чего отобраны показанные строки. */
diff --git a/tradein-mvp/frontend/src/app/mera-public/theme.ts b/tradein-mvp/frontend/src/app/mera-public/theme.ts
index 2a4803af..bc290476 100644
--- a/tradein-mvp/frontend/src/app/mera-public/theme.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/theme.ts
@@ -95,6 +95,8 @@ 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,