diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx new file mode 100644 index 00000000..d49010cb --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx @@ -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( + , + ); + const nowhere = render( + , + ); + expect(frameWidth(home.container)).toBeLessThan(frameWidth(nowhere.container)); + // Без района показывается город целиком — это и есть «кадра нет». + expect(frameWidth(nowhere.container)).toBeGreaterThanOrEqual(EKB_MAP.width); + }); + + it("кадр разный у разных районов — иначе карта не про эту сделку", () => { + const frames = ["Кировский", "Чкаловский", "Академический"].map((name) => { + const { container } = render( + , + ); + return container.querySelector("svg")!.getAttribute("viewBox")!; + }); + expect(new Set(frames).size).toBe(frames.length); + }); + + it("точка внутри кадра рисуется, а подпись говорит, что это улица, а не дом", () => { + // Точка внутри полигона Чкаловского — проверена тем же `project()`, что + // рисует карту. + const { container, getByText } = render( + , + ); + expect(dots(container)).toBeGreaterThan(0); + expect(getByText(/СЕРЕДИНА УЛИЦЫ, НЕ ДОМ · ЧКАЛОВСКИЙ/u)).toBeTruthy(); + }); + + it("координата вне кадра не рисуется и не выдаётся за место сделки", () => { + // Такие строки в данных есть: город записан «Екатеринбург», координата — за + // сотни километров. Прижать её к краю кадра значило бы показать место, + // которого в данных нет. + const { container, getByText } = render( + , + ); + expect(dots(container)).toBe(0); + expect(getByText(/КООРДИНАТЫ НЕТ · ЧКАЛОВСКИЙ/u)).toBeTruthy(); + }); + + it("без координаты карта остаётся, район подсвечен, прочерка нет", () => { + const { container, getByText } = render( + , + ); + expect(dots(container)).toBe(0); + expect(container.querySelectorAll("path").length).toBeGreaterThan(0); + expect(getByText(/КООРДИНАТЫ НЕТ · ЛЕНИНСКИЙ/u)).toBeTruthy(); + expect(container.textContent).not.toMatch(/—\s*$/u); + }); + + it("карта не называет себя адресом объекта", () => { + const { container } = render( + , + ); + expect(container.textContent).not.toMatch(/АДРЕС|ОБЪЕКТ/u); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts index 6e34f1ab..369ba1a0 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts @@ -64,10 +64,25 @@ const V3_DIR = "_components/v3"; * Компонент, положенный ВНЕ каталога, каталогом не ловится — за это отвечает * отдельная проверка «всё, что импортирует корень, попало в список». */ +/** + * Единственное исключение — таблица координат, СГЕНЕРИРОВАННАЯ из боевой + * геометрии (`backend/scripts/export_ekb_districts_svg.py`). Её числа — не + * величины о рынке, а границы районов: «56.8» в пути полигона ничего не + * утверждает и источник у неё проверяемый, записанный в шапке файла. Правило + * (1) на ней даёт шум, за которым правило перестают читать. + * + * Исключение НЕ бланковое: ниже отдельная проверка требует, чтобы каждый + * исключённый файл нёс маркер генератора. Иначе строчка в этом списке стала бы + * способом занести в компонент любое число, сославшись на генератор, которого + * нет. + */ +const GENERATED = ["ekb-districts.ts"]; +const GENERATED_MARK = "СГЕНЕРИРОВАН"; + const V3_SOURCES = [ LANDING_ROOT, ...readdirSync(join(ROOT, V3_DIR)) - .filter((name) => /\.tsx?$/u.test(name)) + .filter((name) => /\.tsx?$/u.test(name) && !GENERATED.includes(name)) .sort() .map((name) => `${V3_DIR}/${name}`), ]; @@ -140,6 +155,10 @@ describe("гейт витринных чисел (#2904)", () => { } }); + it.each(GENERATED)("исключённый из скана %s действительно сгенерирован, а не написан руками", (name) => { + expect(read(`${V3_DIR}/${name}`)).toContain(GENERATED_MARK); + }); + it("сканер видит вписанное число и молчит на честном коде — иначе он проходил бы вхолостую", () => { // Контроль на инструмент. Слева — ровно то, что гейт обязан ловить // (величины из прежнего marketing-v3.ts), справа — код, который в diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx index ed59741d..ae83d8a5 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx @@ -54,6 +54,8 @@ const SHOWCASE: ShowcaseResponse = { err_pct: 0.58, n_analogs: 12, note: "Прогноз посчитан по активным объявлениям на дату пересчёта", + lat: null, + lon: null, }, ], stats: { diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealMapV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealMapV3.tsx new file mode 100644 index 00000000..d56f964c --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealMapV3.tsx @@ -0,0 +1,159 @@ +/** + * DealMapV3 — карта сделки в карточке игры «Сыграйте против МЕРЫ». + * + * Заменяет декорацию, которая стояла тут раньше: CSS-сетку и две «дороги», + * нарисованные прямоугольниками. Географии в них не было вовсе, а подпись + * «ОБЪЕКТ · РАЙОН» заявляла местоположение объекта — то, чего в данных нет. + * + * БЕЗ ЕДИНОГО ВНЕШНЕГО ЗАПРОСА. Геометрия районов приезжает статикой из + * `ekb-districts.ts` (сгенерирована из боевой OSM-таблицы), рисуется инлайновым + * . Тайлы сюда не тянули сознательно: провайдер получал бы IP посетителя и + * то, что он смотрит, на странице, которая обещает «без звонков и регистрации» + * (то же правило, по которому в дереве self-hosted шрифты — см. шапку + * layout.tsx). + * + * КАДР — ПО РАЙОНУ, А НЕ ПО ГОРОДУ. Административные границы Екатеринбурга + * включают огромные незастроенные территории: в кадре «весь город» район сделки + * занимает несколько процентов площади и читается как пятно. Кадрируем по bbox + * района с запасом PAD, соседние районы остаются контуром — они и дают понять, + * какая это часть города. Кадр раздвигается до пропорции карточки, а + * `preserveAspectRatio="…meet"` показывает его ЦЕЛИКОМ: `slice` обрезал бы + * края, и точка у границы района могла бы уехать за поле зрения — то есть + * проверка «точка в кадре» перестала бы совпадать с тем, что видно. + * + * ТОЧКА — И ЧТО ОНА ЗНАЧИТ. Координата в витрине — ЦЕНТРОИД УЛИЦЫ, не дом + * (замер на проде 29.08.2026 по выборке витрины: 34 021 сделка, 34 017 с + * координатой, но различных точек 991 — около тридцати сделок в одной точке + * при 2.7% известных номеров дома; та же граница записана в `note` каждой + * строки). Поэтому подпись говорит «СЕРЕДИНА УЛИЦЫ, НЕ ДОМ», а не «объект» и + * не «адрес»: точка верна на масштабе улицы и неверна на масштабе дома, и + * карточка обязана произносить это вслух, а не оставлять читателю догадываться + * по размеру маркера. + * + * КОГДА ТОЧКИ НЕТ. Два случая, оба дают карту без точки и с подсвеченным + * районом — ни прочерка, ни подстановки центра района: + * · координаты нет (`lat`/`lon` = null); + * · координата есть, но лежит ВНЕ кадра. Это не теоретический случай: в той + * же прод-выборке 2 132 строки из 34 021 (6.3%) имеют координату за + * пределами города — встречаются точки за сотни километров. Рисовать их + * прижатыми к краю значило бы показать место, которого в данных нет. + * Подпись в обоих случаях меняется на «КООРДИНАТЫ НЕТ», чтобы пустая карта не + * читалась как «объект где-то тут, просто не отметили». + * + * ДОСТУПНОСТЬ. помечен `aria-hidden`: всё, что он сообщает — район сделки + * и характер точки, — стоит рядом текстом (подпись под картой плюс район в + * мете сделки). Дублировать это ещё и в `role="img"`+`aria-label` значило бы + * прочитать одно и то же дважды подряд; ничего, кроме района, карта не знает. + * + * Компонент чистый и без состояния — директивы "use client" в нём нет. В + * клиентский бандл он всё равно попадает: карточка игры листает раунды + * состоянием, и карта живёт внутри её дерева. + */ + +import { EKB_DISTRICTS, EKB_MAP, project } from "./ekb-districts"; +import styles from "../../landing-v3.module.css"; + +/** Запас вокруг района: без него граница упирается в край кадра. */ +const PAD = 0.45; +/** + * Полоса пустого кадра СНИЗУ. Нижнюю треть плашки занимает подпись объекта + * (комнаты, площадь, этаж, квартал) — она лежит поверх карты. Без этой полосы + * район приходится на середину плашки, и точка примерно в трети случаев + * оказывается ЗА текстом: видно её или нет, решал бы район сделки. + */ +const BAND = 0.3; +/** Пропорция кадра под карточку — шире, чем выше. */ +const RATIO = 1.35; + +interface Box { + readonly x: number; + readonly y: number; + readonly w: number; + readonly h: number; +} + +const CITY: Box = { x: 0, y: 0, w: EKB_MAP.width, h: EKB_MAP.height }; + +/** + * bbox готового атрибута `d`. Пути генератора — только абсолютные `M`/`L` с + * парами координат, поэтому чисел всегда чётное число и разбор пар точен; для + * дуг и относительных команд он бы не годился, но их там нет. + */ +function pathBox(d: string): Box { + const nums = (d.match(/-?\d+(?:\.\d+)?/gu) ?? []).map(Number); + const xs = nums.filter((_, i) => i % 2 === 0); + const ys = nums.filter((_, i) => i % 2 === 1); + const x = Math.min(...xs); + const y = Math.min(...ys); + return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y }; +} + +/** Расширяет кадр на PAD, добавляет полосу под подпись и доводит до RATIO. */ +function frameFor(box: Box): Box { + const padW = box.w * (1 + PAD * 2); + const padH = box.h * (1 + PAD * 2); + const x = box.x + box.w / 2 - padW / 2; + const y = box.y + box.h / 2 - padH / 2; + const bandH = padH * (1 + BAND); + let w = padW; + let h = bandH; + if (w / h < RATIO) w = h * RATIO; + else h = w / RATIO; + // Центр по горизонтали сохраняется, по вертикали — нет: район должен сидеть + // ВЫШЕ середины, освобождая низ под подпись. + return { x: x + padW / 2 - w / 2, y, w, h }; +} + +const inside = (f: Box, x: number, y: number): boolean => + x >= f.x && x <= f.x + f.w && y >= f.y && y <= f.y + f.h; + +export interface DealMapProps { + /** Район сделки как он записан в данных, либо `null`. */ + readonly district: string | null; + /** Готовая подпись района — «район не указан», когда его нет. */ + readonly districtLabel: string; + readonly lat: number | null; + readonly lon: number | null; +} + +export function DealMapV3({ district, districtLabel, lat, lon }: DealMapProps) { + const home = EKB_DISTRICTS.find((d) => d.name === district); + const frame = frameFor(home ? pathBox(home.d) : CITY); + + const p = lat !== null && lon !== null ? project(lon, lat) : null; + const dot = p && inside(frame, p.x, p.y) ? p : null; + // Толщина линий и радиусы задаются в единицах кадра: кадр района вчетверо + // мельче городского, и постоянная в пикселях выглядела бы в нём вчетверо + // толще. + const u = frame.w / CITY.w; + + return ( + <> + +
+ {`${dot ? "СЕРЕДИНА УЛИЦЫ, НЕ ДОМ" : "КООРДИНАТЫ НЕТ"} · ${districtLabel.toUpperCase()}`} +
+ + ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx index fb1367f4..562f05d3 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx @@ -10,11 +10,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[] }) {