feat(mera/b2c): в карточке игры настоящая карта вместо декорации — и подпись, которую данные выдерживают

Блок `gameMap` рисовал CSS-сетку и два прямоугольника-«дороги», а подпись
говорила «ОБЪЕКТ · РАЙОН» — то есть заявляла местоположение объекта. Географии
там не было вовсе.

Что сделано: `DealMapV3` — инлайновый <svg> по статике `ekb-districts.ts`, без
единого внешнего запроса (то же правило, по которому в этом дереве self-hosted
шрифты). Район сделки — заливка и обводка акцентом, соседние — контур; кадр по
bbox района с запасом, не по городу: в городском кадре район занимает несколько
процентов площади. Точка — по `project(lon, lat)` координаты витрины.

ПОДПИСЬ. Координата в данных — ЦЕНТРОИД УЛИЦЫ, не дом: 34 021 сделка, 34 017 с
координатой, различных точек 991, номер дома известен у 2.7% (прод, 29.08.2026).
Поэтому карта подписана «СЕРЕДИНА УЛИЦЫ, НЕ ДОМ · РАЙОН» — она произносит
границу вслух, а не оставляет читателю догадываться по размеру маркера. Слова
«объект» и «адрес» в карточке не появляются.

ТОЧКИ НЕТ — ДВА СЛУЧАЯ, оба дают карту с подсвеченным районом и без прочерка:
координаты нет; координата есть, но лежит вне кадра. Второе не теоретическое —
2 132 строки из 34 021 (6.3%) в той же выборке имеют координату за пределами
города, встречаются точки за сотни километров. Прижать такую к краю значило бы
показать место, которого в данных нет; подпись в обоих случаях — «КООРДИНАТЫ
НЕТ».

Доступность: <svg> aria-hidden, всё его содержание (район) стоит рядом текстом
дважды — в подписи карты и в мете сделки.

Гейт витринных чисел: из скана исключён сгенерированный `ekb-districts.ts` —
координаты полигонов не утверждают ничего о рынке. Исключение не бланковое:
добавлена проверка, что исключённый файл несёт маркер генератора.

Проверено глазами на dev-сервере с заглушкой бэкенда: три состояния (Кировский с
точкой, Чкаловский — кадр едет, сделка без координаты).
This commit is contained in:
bot-backend 2026-08-29 21:32:32 +05:00
parent cd2a767178
commit 08ee1dcc24
10 changed files with 334 additions and 59 deletions

View file

@ -0,0 +1,86 @@
/**
* Карта сделки: что она обязана показать и о чём обязана промолчать.
*
* Проверяется не «рисуется ли svg», а три решения, каждое из которых при поломке
* даёт правдоподобную картинку с неверным смыслом:
* · кадр берётся по району сделки, а не по городу (иначе район пятно);
* · точка ставится ТОЛЬКО когда координата попала в кадр (иначе далёкая
* координата прижмётся к краю и будет прочитана как место сделки);
* · подпись говорит, что точка середина улицы, а не дом, и не называет её
* адресом объекта.
*/
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DealMapV3 } from "../_components/v3/DealMapV3";
import { EKB_MAP } from "../_components/v3/ekb-districts";
/** Ширина кадра из атрибута viewBox отрисованной карты. */
function frameWidth(container: HTMLElement): number {
const box = container.querySelector("svg")!.getAttribute("viewBox")!;
return Number(box.split(" ")[2]);
}
const dots = (container: HTMLElement) => container.querySelectorAll("circle").length;
describe("карта сделки", () => {
it("кадрируется по району сделки, а не по городу", () => {
const home = render(
<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);
});
});

View file

@ -64,10 +64,25 @@ const V3_DIR = "_components/v3";
* Компонент, положенный ВНЕ каталога, каталогом не ловится за это отвечает
* отдельная проверка «всё, что импортирует корень, попало в список».
*/
/**
* Единственное исключение таблица координат, СГЕНЕРИРОВАННАЯ из боевой
* геометрии (`backend/scripts/export_ekb_districts_svg.py`). Её числа не
* величины о рынке, а границы районов: «56.8» в пути полигона ничего не
* утверждает и источник у неё проверяемый, записанный в шапке файла. Правило
* (1) на ней даёт шум, за которым правило перестают читать.
*
* Исключение НЕ бланковое: ниже отдельная проверка требует, чтобы каждый
* исключённый файл нёс маркер генератора. Иначе строчка в этом списке стала бы
* способом занести в компонент любое число, сославшись на генератор, которого
* нет.
*/
const GENERATED = ["ekb-districts.ts"];
const GENERATED_MARK = "СГЕНЕРИРОВАН";
const V3_SOURCES = [
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), справа — код, который в

View file

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

View file

@ -0,0 +1,159 @@
/**
* DealMapV3 карта сделки в карточке игры «Сыграйте против МЕРЫ».
*
* Заменяет декорацию, которая стояла тут раньше: CSS-сетку и две «дороги»,
* нарисованные прямоугольниками. Географии в них не было вовсе, а подпись
* «ОБЪЕКТ · РАЙОН» заявляла местоположение объекта то, чего в данных нет.
*
* БЕЗ ЕДИНОГО ВНЕШНЕГО ЗАПРОСА. Геометрия районов приезжает статикой из
* `ekb-districts.ts` (сгенерирована из боевой OSM-таблицы), рисуется инлайновым
* <svg>. Тайлы сюда не тянули сознательно: провайдер получал бы IP посетителя и
* то, что он смотрит, на странице, которая обещает «без звонков и регистрации»
* (то же правило, по которому в дереве self-hosted шрифты см. шапку
* layout.tsx).
*
* КАДР ПО РАЙОНУ, А НЕ ПО ГОРОДУ. Административные границы Екатеринбурга
* включают огромные незастроенные территории: в кадре «весь город» район сделки
* занимает несколько процентов площади и читается как пятно. Кадрируем по bbox
* района с запасом PAD, соседние районы остаются контуром они и дают понять,
* какая это часть города. Кадр раздвигается до пропорции карточки, а
* `preserveAspectRatio="…meet"` показывает его ЦЕЛИКОМ: `slice` обрезал бы
* края, и точка у границы района могла бы уехать за поле зрения то есть
* проверка «точка в кадре» перестала бы совпадать с тем, что видно.
*
* ТОЧКА И ЧТО ОНА ЗНАЧИТ. Координата в витрине ЦЕНТРОИД УЛИЦЫ, не дом
* (замер на проде 29.08.2026 по выборке витрины: 34 021 сделка, 34 017 с
* координатой, но различных точек 991 около тридцати сделок в одной точке
* при 2.7% известных номеров дома; та же граница записана в `note` каждой
* строки). Поэтому подпись говорит «СЕРЕДИНА УЛИЦЫ, НЕ ДОМ», а не «объект» и
* не «адрес»: точка верна на масштабе улицы и неверна на масштабе дома, и
* карточка обязана произносить это вслух, а не оставлять читателю догадываться
* по размеру маркера.
*
* КОГДА ТОЧКИ НЕТ. Два случая, оба дают карту без точки и с подсвеченным
* районом ни прочерка, ни подстановки центра района:
* · координаты нет (`lat`/`lon` = null);
* · координата есть, но лежит ВНЕ кадра. Это не теоретический случай: в той
* же прод-выборке 2 132 строки из 34 021 (6.3%) имеют координату за
* пределами города встречаются точки за сотни километров. Рисовать их
* прижатыми к краю значило бы показать место, которого в данных нет.
* Подпись в обоих случаях меняется на «КООРДИНАТЫ НЕТ», чтобы пустая карта не
* читалась как «объект где-то тут, просто не отметили».
*
* ДОСТУПНОСТЬ. <svg> помечен `aria-hidden`: всё, что он сообщает район сделки
* и характер точки, стоит рядом текстом (подпись под картой плюс район в
* мете сделки). Дублировать это ещё и в `role="img"`+`aria-label` значило бы
* прочитать одно и то же дважды подряд; ничего, кроме района, карта не знает.
*
* Компонент чистый и без состояния директивы "use client" в нём нет. В
* клиентский бандл он всё равно попадает: карточка игры листает раунды
* состоянием, и карта живёт внутри её дерева.
*/
import { EKB_DISTRICTS, EKB_MAP, project } from "./ekb-districts";
import styles from "../../landing-v3.module.css";
/** Запас вокруг района: без него граница упирается в край кадра. */
const PAD = 0.45;
/**
* Полоса пустого кадра СНИЗУ. Нижнюю треть плашки занимает подпись объекта
* (комнаты, площадь, этаж, квартал) она лежит поверх карты. Без этой полосы
* район приходится на середину плашки, и точка примерно в трети случаев
* оказывается ЗА текстом: видно её или нет, решал бы район сделки.
*/
const BAND = 0.3;
/** Пропорция кадра под карточку — шире, чем выше. */
const RATIO = 1.35;
interface Box {
readonly x: number;
readonly y: number;
readonly w: number;
readonly h: number;
}
const CITY: Box = { x: 0, y: 0, w: EKB_MAP.width, h: EKB_MAP.height };
/**
* bbox готового атрибута `d`. Пути генератора только абсолютные `M`/`L` с
* парами координат, поэтому чисел всегда чётное число и разбор пар точен; для
* дуг и относительных команд он бы не годился, но их там нет.
*/
function pathBox(d: string): Box {
const nums = (d.match(/-?\d+(?:\.\d+)?/gu) ?? []).map(Number);
const xs = nums.filter((_, i) => i % 2 === 0);
const ys = nums.filter((_, i) => i % 2 === 1);
const x = Math.min(...xs);
const y = Math.min(...ys);
return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
}
/** Расширяет кадр на PAD, добавляет полосу под подпись и доводит до RATIO. */
function frameFor(box: Box): Box {
const padW = box.w * (1 + PAD * 2);
const padH = box.h * (1 + PAD * 2);
const x = box.x + box.w / 2 - padW / 2;
const y = box.y + box.h / 2 - padH / 2;
const bandH = padH * (1 + BAND);
let w = padW;
let h = bandH;
if (w / h < RATIO) w = h * RATIO;
else h = w / RATIO;
// Центр по горизонтали сохраняется, по вертикали — нет: район должен сидеть
// ВЫШЕ середины, освобождая низ под подпись.
return { x: x + padW / 2 - w / 2, y, w, h };
}
const inside = (f: Box, x: number, y: number): boolean =>
x >= f.x && x <= f.x + f.w && y >= f.y && y <= f.y + f.h;
export interface DealMapProps {
/** Район сделки как он записан в данных, либо `null`. */
readonly district: string | null;
/** Готовая подпись района — «район не указан», когда его нет. */
readonly districtLabel: string;
readonly lat: number | null;
readonly lon: number | null;
}
export function DealMapV3({ district, districtLabel, lat, lon }: DealMapProps) {
const home = EKB_DISTRICTS.find((d) => d.name === district);
const frame = frameFor(home ? pathBox(home.d) : CITY);
const p = lat !== null && lon !== null ? project(lon, lat) : null;
const dot = p && inside(frame, p.x, p.y) ? p : null;
// Толщина линий и радиусы задаются в единицах кадра: кадр района вчетверо
// мельче городского, и постоянная в пикселях выглядела бы в нём вчетверо
// толще.
const u = frame.w / CITY.w;
return (
<>
<svg
className={styles.gameMapSvg}
viewBox={`${frame.x} ${frame.y} ${frame.w} ${frame.h}`}
preserveAspectRatio="xMidYMid meet"
aria-hidden="true"
focusable="false"
>
{EKB_DISTRICTS.map((d) => (
<path
key={d.name}
d={d.d}
className={d === home ? styles.gameMapHome : styles.gameMapAround}
strokeWidth={(d === home ? 4 : 2.5) * u}
/>
))}
{dot && (
<g>
<circle cx={dot.x} cy={dot.y} r={42 * u} className={styles.gameMapHalo} />
<circle cx={dot.x} cy={dot.y} r={16 * u} strokeWidth={5 * u} className={styles.gameMapCore} />
</g>
)}
</svg>
<div className={styles.gameMapLabel}>
{`${dot ? "СЕРЕДИНА УЛИЦЫ, НЕ ДОМ" : "КООРДИНАТЫ НЕТ"} · ${districtLabel.toUpperCase()}`}
</div>
</>
);
}

View file

@ -10,11 +10,13 @@
* реальных сделках» была неправдой. Сделок мало игру не рендерит родитель:
* играть на одной строке под заголовком «три квартиры» нельзя.
*
* ЧЕГО В КАРТОЧКЕ БОЛЬШЕ НЕТ: точки «ОБЪЕКТ НА КАРТЕ». Координат в данных
* витрины нет вовсе (улица известна у 2.7% сделок), а точка на карте это
* утверждение о конкретном месте. Фон-сетка осталась декорацией, объект
* подписан тем, что известно: район (или прямое «район не указан»), комнаты,
* площадь, этаж, квартал сделки.
* КАРТА (`DealMapV3`) настоящая: границы районов ЕКБ статикой, кадр по
* району сделки, точка по координате, когда она есть. Раньше здесь стоял
* декоративный прямоугольник с подписью «ОБЪЕКТ · РАЙОН», а координат в
* витрине не было вовсе. Подпись объекта на карте сменилась: координата это
* ЦЕНТРОИД УЛИЦЫ, а не дом, и карта произносит это сама (разбор в шапке
* DealMapV3). Ниже карты объект по-прежнему подписан только тем, что известно:
* комнаты, площадь, этаж, район, квартал сделки. Адреса среди них нет.
*
* Ошибка игрока считается медианой |ответ факт| / факт по сыгранным раундам
* той же формулой, что и ошибка МЕРЫ на этих же сделках (`err_pct` витрины).
@ -26,6 +28,7 @@ import { useState } from "react";
import styles from "../../landing-v3.module.css";
import type { GameRound } from "./deal-view";
import { DealMapV3 } from "./DealMapV3";
import { PublicLink } from "../PublicLink";
@ -96,11 +99,7 @@ export function GuessGameV3({ rounds }: { rounds: readonly GameRound[] }) {
<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>
<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>

View file

@ -1,10 +1,15 @@
/**
* Форматирование строк витрины `/showcase` общее для ленты, таблицы сверок и
* игры. Держится в одном месте, потому что «объект» в этих данных описывается
* ТОЛЬКО так: комнаты, площадь, этаж, район (может отсутствовать). Улицы и
* дома в данных нет вовсе номер дома известен у 2.7% сделок, и три
* компонента, каждый со своей склейкой, рано или поздно разъехались бы в том,
* что именно они выдают за адрес.
* ТОЛЬКО так: комнаты, площадь, этаж, район (может отсутствовать). НАЗВАНИЯ
* улицы и номера дома в данных нет номер дома известен у 2.7% сделок, и
* три компонента, каждый со своей склейкой, рано или поздно разъехались бы в
* том, что именно они выдают за адрес.
*
* С 29.08.2026 у строки есть координата, но адресом она не становится: это
* центроид УЛИЦЫ (около тридцати сделок в одной точке), он годится карте
* масштаба района и не годится подписи «объект находится здесь». Поэтому
* координата уезжает только в карту, а в текстовые склейки нет.
*/
import type { ShowcaseDeal } from "../../public-api";
@ -51,6 +56,15 @@ export interface GameRound {
readonly meta: string;
/** Район или прямое «район не указан» — подстановки нет. */
readonly districtLabel: string;
/**
* Район как он записан в данных им карта ищет полигон, и подставлять
* вместо `null` подпись «район не указан» нельзя: совпадений с именами
* районов у неё не будет, но выглядеть это будет как поиск.
*/
readonly district: string | null;
/** Центроид УЛИЦЫ сделки, не дом (см. `note` строки витрины). */
readonly lat: number | null;
readonly lon: number | null;
readonly quarter: string;
/** Границы слайдера, млн ₽. */
readonly min: number;
@ -86,6 +100,9 @@ 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,
quarter: deal.deal_quarter,
min,
max,

View file

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

View file

@ -1643,58 +1643,36 @@
.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);
}
@keyframes gamePing {
0% { transform: scale(1); opacity: 0.55; }
70%, 100% { transform: scale(2.6); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.gameMapPing { animation: none; }
fill: var(--b2c-accent);
stroke: var(--b2c-surface-dark);
}
.gameMapLabel {
position: relative;
@ -1727,7 +1705,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;
}

View file

@ -206,6 +206,13 @@ export interface ShowcaseDeal {
err_pct: number;
n_analogs: number;
note: string;
/**
* Координата сделки ЦЕНТРОИД УЛИЦЫ, не дом (граница честности записана в
* `note` каждой строки и в COMMENT колонок). `null` координаты нет;
* подставлять на её место соседнюю строку или центр района нельзя.
*/
lat: number | null;
lon: number | null;
}
/** Подпись под витриной: из чего отобраны показанные строки. */

View file

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