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:
parent
cd2a767178
commit
08ee1dcc24
10 changed files with 334 additions and 59 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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), справа — код, который в
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ const SHOWCASE: ShowcaseResponse = {
|
|||
err_pct: 0.58,
|
||||
n_analogs: 12,
|
||||
note: "Прогноз посчитан по активным объявлениям на дату пересчёта",
|
||||
lat: null,
|
||||
lon: null,
|
||||
},
|
||||
],
|
||||
stats: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -47,6 +47,13 @@ export const b2c = {
|
|||
line: "#CBD7DA",
|
||||
/** Тонкие внутренние разделители. */
|
||||
lineSoft: "#EAF5F7",
|
||||
/**
|
||||
* Линия НА ТЁМНОЙ плашке (`surfaceDark`): рамки тегов, контуры соседних
|
||||
* районов на карте сделки. Тот же оттенок, что уже стоял литералом в
|
||||
* `landing-v3.module.css`; заведён токеном, когда карта потребовала его
|
||||
* второй раз — правило «hex только здесь» иначе разъезжается.
|
||||
*/
|
||||
lineDark: "#2C3A3F",
|
||||
|
||||
/** Акцент — заливки/обводки/декор. НЕ для текста, см. шапку. */
|
||||
accent: "#16A9BC",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -206,6 +206,13 @@ export interface ShowcaseDeal {
|
|||
err_pct: number;
|
||||
n_analogs: number;
|
||||
note: string;
|
||||
/**
|
||||
* Координата сделки — ЦЕНТРОИД УЛИЦЫ, не дом (граница честности записана в
|
||||
* `note` каждой строки и в COMMENT колонок). `null` — координаты нет;
|
||||
* подставлять на её место соседнюю строку или центр района нельзя.
|
||||
*/
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
}
|
||||
|
||||
/** Подпись под витриной: из чего отобраны показанные строки. */
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue