From a5ca2a326a8ce19e347aa8052a68abe70a403ac8 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 22:41:07 +0500 Subject: [PATCH] =?UTF-8?q?feat(mera/b2c):=20=D0=B2=20=D0=BA=D0=B0=D1=80?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BA=D0=B5=20=D0=B8=D0=B3=D1=80=D1=8B=20?= =?UTF-8?q?=D1=81=D1=85=D0=B5=D0=BC=D0=B0=20=D1=83=D0=BB=D0=B8=D1=86=D1=8B?= =?UTF-8?q?=20=D1=81=D0=B4=D0=B5=D0=BB=D0=BA=D0=B8,=20=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=BF=D1=8F=D1=82=D0=BD=D0=BE=20=D1=80=D0=B0=D0=B9?= =?UTF-8?q?=D0=BE=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Карта показывала ПОЛИГОН РАЙОНА — десятки квадратных километров, на которых одинаково выглядят сделка у парка и сделка у ТЭЦ. Схема улиц уже приезжает строкой витрины (`street_scheme`), рисовать её было нечем. `StreetMapV3` — серверный компонент, инлайновый , НИ ОДНОГО внешнего запроса: тайлы отдали бы провайдеру IP посетителя и то, что он смотрит, на странице, которая обещает «без звонков и регистрации» (правило шапки layout.tsx, по нему же в дереве self-hosted шрифты). Слои снизу вверх: плашка и приглушённая сетка (уже были), вода, фоновые дороги толщиной по классу OSM, целевая улица акцентом поверх мягкого ореола, подписи соседних улиц моноширинным. DealMapV3 ОСТАЁТСЯ и не тронут. Улица матчится у 92.1% строк витрины, у остальных схемы нет — там по-прежнему район. Ветвление стоит в GuessGameV3, чтобы обе карты остались тупыми: каждая рисует то, что ей дали, и ни одна не подставляет вместо отсутствующих данных правдоподобное. ЧТО КАРТА ГОВОРИТ О СЕБЕ. Подпись — «<УЛИЦА> · ДОМ НЕ ИЗВЕСТЕН». Адрес сделки уровня улицы, номер дома известен у 2.7% сделок, координата окна — центроид улицы. Слов «объект» и «адрес» в карточке нет; тест держит и то и другое. Точку дома поставить нечем и по построению: в `street_scheme` нет ни констант проекции, ни координат окна. ЧЕГО НЕ РИСУЕМ. Зданий: `cad_buildings` — 18 307 контуров на город, в плотном центре 51 здание на радиус 450 м, где их в разы больше; нарисованная застройка заявляла бы полноту, которой нет. Улицы — фильтрованная выгрузка «источников шума», дворовых и служебных проездов в ней нет, поэтому фоновые дороги приглушены: это контекст, а не план квартала. КАДР — `slice`, а не `meet`: окно квадратное, карточка нет, и `meet` оставил бы поля по бокам. Обрезка безопасна ровно потому, что целевая улица лежит в ЦЕНТРЕ окна по построению; обрезаются края с частью подписей соседей. ДОСТУПНОСТЬ — как у DealMapV3, `aria-hidden`: всё, что схема сообщает, стоит рядом текстом. `role="img"` заставил бы прочитать то же самое дважды, а названия соседних улиц без их взаимного расположения ничего не значат. ЦВЕТА — токенами: `--b2c-water` (вода отдельным оттенком, иначе склеивается с дорогами) и `--b2c-muted-dark` (подписи на тёмной плашке: `muted` даёт там 3.2:1). Литералов в CSS не добавлено. Подпись объекта лежит ПОВЕРХ карты, и карта теперь доходит до краёв — добавлено затемнение под ней и плашка под подписью карты; без них белый текст пересекался с подсвеченной улицей. Псевдоэлемент позиционирован, поэтому детям `.gameMapBottom` задан `position: relative` — иначе затемнение красится поверх текста и стирает его (поймано скриншотом, а не рассуждением). Тесты (каждый сломан вручную и покраснел): кадр из данных и `slice`; ореол шире линии и по одному на путь; иерархия дорог и незнакомый класс не дают нулевой толщины; вода отделена; подпись называет улицу и говорит про дом; `aria-hidden`; ветвление карточки в обе стороны. Проверено глазами на dev-сервере, стаб — реальные строки прода плюс схемы, собранные той же проекцией по геометрии gendesign: улица сматчилась; не сматчилась (виден район); полей схемы нет вовсе (тот же район, без падения). --- .../mera-public/__tests__/deal-map.test.tsx | 109 ++++++++++++++++- .../__tests__/landing-v3-render.test.tsx | 3 + .../_components/v3/GuessGameV3.tsx | 26 ++-- .../_components/v3/StreetMapV3.tsx | 112 ++++++++++++++++++ .../mera-public/_components/v3/deal-view.ts | 9 +- .../src/app/mera-public/b2c-tokens.ts | 15 +++ .../src/app/mera-public/landing-v3.module.css | 58 +++++++++ .../src/app/mera-public/public-api.ts | 33 ++++++ .../frontend/src/app/mera-public/theme.ts | 2 + 9 files changed, 356 insertions(+), 11 deletions(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/v3/StreetMapV3.tsx 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[] }) {