feat(mera/b2c): в карточке игры схема улицы сделки, а не пятно района
Карта показывала ПОЛИГОН РАЙОНА — десятки квадратных километров, на которых одинаково выглядят сделка у парка и сделка у ТЭЦ. Схема улиц уже приезжает строкой витрины (`street_scheme`), рисовать её было нечем. `StreetMapV3` — серверный компонент, инлайновый <svg>, НИ ОДНОГО внешнего запроса: тайлы отдали бы провайдеру 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: улица сматчилась; не сматчилась (виден район); полей схемы нет вовсе (тот же район, без падения).
This commit is contained in:
parent
ca3f073b0e
commit
a5ca2a326a
9 changed files with 356 additions and 11 deletions
|
|
@ -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(<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("ДОМ НЕ ИЗВЕСТЕН");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -56,6 +56,9 @@ const SHOWCASE: ShowcaseResponse = {
|
|||
note: "Прогноз посчитан по активным объявлениям на дату пересчёта",
|
||||
lat: null,
|
||||
lon: null,
|
||||
// Схемы у этой строки нет — рендер идёт запасным путём, картой района.
|
||||
street_name: null,
|
||||
street_scheme: null,
|
||||
},
|
||||
],
|
||||
stats: {
|
||||
|
|
|
|||
|
|
@ -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[] }) {
|
|||
<div className={styles.gameGrid}>
|
||||
<div className={styles.gameMap}>
|
||||
<div className={styles.gameMapGrid} aria-hidden="true" />
|
||||
<DealMapV3 district={r.district} districtLabel={r.districtLabel} lat={r.lat} lon={r.lon} />
|
||||
{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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
/** Акцент для заливки кнопок (под белым текстом). */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/** Подпись под витриной: из чего отобраны показанные строки. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue