fix(tradein/v2): UI/UX audit round 2 — AA contrast, honesty, a11y (#2081)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped

C1 (tokens): darken muted scale to WCAG AA (>=4.5:1 vs worst-case canvas
  #e6eef7); reclassify primary labels muted->body2 across components.
H1 (mappers): planning chip derived from rooms actually present in the
  analog set («Все планировки» when heterogeneous) — no number shifted.
H2 (ResultPanel): make «ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ» the headline (accent
  border/tint/glow + larger value); demote ДКП·Росреестр to a muted
  СПРАВОЧНО reference tile. All three values + mapResultPanel wiring intact.
H3 (mappers): parseAddress keeps the house number for prefix-less
  addresses («Белинского, 86»); prefixed path unchanged.
M4 (page/HeroBar/Footer): gate dead controls (КАК РАССЧИТАНО / PDF /
  ДЕЙСТВИТЕЛЕН ДО / footer validity) on hasEstimate (mounted && estimate && !insufficient).
M5 (LocationDrawer): real dialog — role=dialog/aria-modal/aria-labelledby
  + focus-trap + <button> close + Esc preventDefault/stopPropagation.
M6 (ParamsPanel): address autocomplete -> combobox ARIA + Arrow/Home/End/
  Enter/Escape keyboard nav (mirrors Dd listbox).
M7 (Sources/Cache/Analytics/History): ARIA table semantics over the grids;
  Analytics scroll region keyboard-focusable (clears the serious
  scrollable-region-focusable axe violation); chart SVGs get role=img+title+aria-label.
M8 (CacheView): normalize the ВРЕМЯ column (normTime) to consistent minute precision.
M9 (LocationDrawer): honest «как считаем» source list (Циан, Я.Недвижимость,
  Авито, N1.ru + Росреестр) — drop dead Домклик/Restate per #1968.
M10 (ParamsPanel/mapMarkers): subject pin shows the subject's own area; drop
  analog pins overlapping the subject center (was misread as subject data).
M11 (ObjectSummary/SourcesView): label «медиана объявлений»; hide all-«—» columns/rows.
M13 (page): clamp artboard scale >=0.88 so micro-type doesn't drop below ~8px.
L1 (Analytics): suppress fake «X–Y дн» range when n<2.
L2 (Analytics): drop zero-data series from price-history subtitle.
L7 (TopNav): replace hardcoded #6f8195 icon strokes with tokens.muted.

tsc + next build green. No FE test infra in tradein-mvp/frontend — parseAddress
verified via standalone harness (6/6). Backend data-contract items M1/M2/M3/H4
(source counter, ratio vs medians, bargain bases, cross-source dedup) filed
separately — not FE bugs. L6 (blueprint-grid axe-blindness) deferred: base
gradient + glass surfaces keep axe contrast «incomplete» regardless, so the
authoritative check stays the manual computed-color sweep.
This commit is contained in:
bot-backend 2026-06-29 01:01:30 +03:00
parent bd3562b6bd
commit f5a7f9b437
15 changed files with 1046 additions and 395 deletions

View file

@ -403,7 +403,10 @@ export default function TradeInV2Page() {
useEffect(() => { useEffect(() => {
const compute = () => const compute = () =>
setArtboardScale( setArtboardScale(
Math.min(1, window.innerWidth / 1536, window.innerHeight / 1024), Math.max(
0.88,
Math.min(1, window.innerWidth / 1536, window.innerHeight / 1024),
),
); );
compute(); compute();
window.addEventListener("resize", compute); window.addEventListener("resize", compute);
@ -469,6 +472,11 @@ export default function TradeInV2Page() {
estimate != null && estimate != null &&
(estimate.insufficient_data || estimate.n_analogs === 0); (estimate.insufficient_data || estimate.n_analogs === 0);
const apiError = mutation.error?.message ?? null; const apiError = mutation.error?.message ?? null;
// M4: estimate-dependent meta/controls (the «ДЕЙСТВИТЕЛЕН ДО» validity line,
// «КАК РАССЧИТАНО», PDF) only render once there is a real, sufficient estimate.
// Folds in `mounted` so SSR and the first client render agree (false → hidden)
// — no hydration drift, same reason the PDF control is gated behind `mounted`.
const hasEstimate = mounted && estimate != null && !insufficient;
// ── Mapped presentation data (memoised so nav/drawer toggles don't recompute // ── Mapped presentation data (memoised so nav/drawer toggles don't recompute
// geometry). ────────────────────────────────────────────────────────── // geometry). ──────────────────────────────────────────────────────────
@ -716,6 +724,7 @@ export default function TradeInV2Page() {
data={{ report, object: objectInfo }} data={{ report, object: objectInfo }}
estimateId={mounted ? currentEstimateId : null} estimateId={mounted ? currentEstimateId : null}
onOpenInfo={() => setDrawerOpen(true)} onOpenInfo={() => setDrawerOpen(true)}
hasEstimate={hasEstimate}
/> />
<div <div
@ -741,7 +750,7 @@ export default function TradeInV2Page() {
</main> </main>
<footer style={{ display: "contents" }}> <footer style={{ display: "contents" }}>
<Footer data={report} /> <Footer data={report} hasEstimate={hasEstimate} />
</footer> </footer>
</div> </div>

View file

@ -78,6 +78,20 @@ const cardStyle = {
padding: "16px 18px", padding: "16px 18px",
} as const; } as const;
// L1 — a "обычно XY дн" band is only honest with ≥2 lots AND a non-degenerate
// span. From a single analog (n<2) or when min===max ("237237") the band is a
// fake range, so we suppress it and keep just the point estimate (tier.days) +
// the lot count. Gated on BOTH the parsed n (count line "1 аналог" → 1) and the
// parsed range endpoints, so either signal alone drops the band.
function sellTimeRangeMeaningful(tier: SellTimeTier): boolean {
if (!tier.range || tier.range === "—") return false;
const n = Number.parseInt(tier.count, 10); // "1 аналог" → 1
if (Number.isFinite(n) && n < 2) return false;
const nums = tier.range.match(/\d+/g)?.map(Number) ?? []; // "18180" → [18,180]
if (nums.length >= 2 && nums[0] === nums[nums.length - 1]) return false;
return true;
}
export default function AnalyticsView({ export default function AnalyticsView({
data = ANALYTICS_FIXTURE, data = ANALYTICS_FIXTURE,
onNavigate, onNavigate,
@ -107,8 +121,29 @@ export default function AnalyticsView({
[avitoPts, yandexDots], [avitoPts, yandexDots],
); );
// L2 — the subtitle is mapper-built and hardcodes "Avito + Яндекс"; drop any
// series that drew no points so the subtitle matches the rendered legend (the
// swatches below are already gated on *Pts.length). If the note format changes
// the replace is a graceful no-op.
const activeSeriesNames = [
avitoPts.length > 0 ? "Avito" : null,
yandexPts.length > 0 ? "Яндекс" : null,
].filter((s): s is string => s != null);
const historyNote =
activeSeriesNames.length > 0
? data.priceHistory.note.replace(
/Avito \+ Яндекс/,
activeSeriesNames.join(" + "),
)
: data.priceHistory.note;
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}> <div
role="region"
tabIndex={0}
aria-label="Аналитика дома — прокручиваемая область"
style={{ display: "flex", flexDirection: "column", gap: 22 }}
>
{/* header */} {/* header */}
<div <div
style={{ style={{
@ -139,7 +174,9 @@ export default function AnalyticsView({
style={{ style={{
fontSize: 9.5, fontSize: 9.5,
letterSpacing: 1.5, letterSpacing: 1.5,
color: tokens.muted2, // C1: primary KPI label → body2 (matches ParamsPanel field-label
// pattern); the muted value/sub below keep the secondary tier.
color: tokens.body2,
}} }}
> >
{kpi.label} {kpi.label}
@ -216,8 +253,12 @@ export default function AnalyticsView({
<div <div
style={{ fontSize: 9.5, color: tokens.hint, lineHeight: 1.5 }} style={{ fontSize: 9.5, color: tokens.hint, lineHeight: 1.5 }}
> >
{tier.range} {sellTimeRangeMeaningful(tier) ? (
<br /> <>
{tier.range}
<br />
</>
) : null}
{tier.count} {tier.count}
</div> </div>
</div> </div>
@ -240,7 +281,7 @@ export default function AnalyticsView({
История цен в этом доме История цен в этом доме
</div> </div>
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}> <div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
{data.priceHistory.note} {historyNote}
</div> </div>
</div> </div>
<div style={{ display: "flex", gap: 16, fontSize: 10 }}> <div style={{ display: "flex", gap: 16, fontSize: 10 }}>
@ -280,7 +321,10 @@ export default function AnalyticsView({
viewBox="0 0 900 220" viewBox="0 0 900 220"
style={{ width: "100%", height: 200, marginTop: 12 }} style={{ width: "100%", height: 200, marginTop: 12 }}
preserveAspectRatio="none" preserveAspectRatio="none"
role="img"
aria-label="График истории цен в этом доме: медиана ₽/м² по годам, серии Avito и Яндекс"
> >
<title>История цен в этом доме медиана /м² по годам</title>
<g stroke={tokens.lineSoft2} strokeWidth={1}> <g stroke={tokens.lineSoft2} strokeWidth={1}>
<line x1={40} y1={20} x2={900} y2={20} /> <line x1={40} y1={20} x2={900} y2={20} />
<line x1={40} y1={65} x2={900} y2={65} /> <line x1={40} y1={65} x2={900} y2={65} />
@ -454,7 +498,10 @@ export default function AnalyticsView({
<svg <svg
viewBox="0 0 900 300" viewBox="0 0 900 300"
style={{ width: "100%", height: 280, marginTop: 12 }} style={{ width: "100%", height: 280, marginTop: 12 }}
role="img"
aria-label="Точечный график зависимости цены продажи от срока экспозиции: аналоги, сделки и ваш объект"
> >
<title>Цена × срок продажи</title>
<g stroke={tokens.lineSoft2} strokeWidth={1}> <g stroke={tokens.lineSoft2} strokeWidth={1}>
<line x1={60} y1={20} x2={880} y2={20} /> <line x1={60} y1={20} x2={880} y2={20} />
<line x1={60} y1={77} x2={880} y2={77} /> <line x1={60} y1={77} x2={880} y2={77} />

View file

@ -8,9 +8,38 @@ import type { CacheData } from "./mappers";
const FIXTURE_CACHE: CacheData = { kpis: cacheKpi, rows: cacheRows }; const FIXTURE_CACHE: CacheData = { kpis: cacheKpi, rows: cacheRows };
/**
* M8 ВРЕМЯ column normalizer. `r.time` arrives PRE-FORMATTED as a string from
* mapCache (CacheRow.time, produced by fmtCacheTime), one of:
* same-day "HH:MM:SS" (e.g. "23:20:51")
* yesterday "вчера HH:MM"
* older "DD.MM.YYYY"
* The raw column mixed second-precision clock times with bare dates, so a lone
* "23:20:51" was visually indistinguishable from a date. We normalize every bare
* clock time to minute precision (HH:MM) so same-day rows read uniformly with the
* "вчера HH:MM" rows, while date rows pass through unchanged. The ISO timestamp is
* already discarded upstream (mapper not editable here), so a same-day cell stays
* a time by the column's convention: a clock value today, a date older.
* Missing / unparseable «». Pure + idempotent on its own output.
*/
function normTime(raw: string | null | undefined): string {
const s = (raw ?? "").trim();
if (!s || s === "—") return "—";
// Bare clock time "HH:MM:SS" | "HH:MM" → minute precision (drop seconds).
const clock = s.match(/^(\d{1,2}):(\d{2})(?::\d{2})?$/);
if (clock) return `${clock[1].padStart(2, "0")}:${clock[2]}`;
// "вчера HH:MM:SS" → "вчера HH:MM"; dates ("DD.MM.YYYY") have no HH:MM:SS run
// and pass through unchanged.
return s.replace(/(\d{1,2}:\d{2}):\d{2}\b/, "$1");
}
export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) { export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
const showSrc = data.rows.some((r) => r.src && r.src !== "—"); const showSrc = data.rows.some((r) => r.src && r.src !== "—");
const gridCols = showSrc ? "2.4fr 1fr 1fr 1fr" : "2.4fr 1fr 1fr"; const gridCols = showSrc ? "2.4fr 1fr 1fr 1fr" : "2.4fr 1fr 1fr";
// M7 — header cells, in column order, so every data column has a header.
const headers = showSrc
? ["АДРЕС", "ВРЕМЯ", "ИСТОЧНИКОВ", "СТАТУС"]
: ["АДРЕС", "ВРЕМЯ", "СТАТУС"];
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}> <div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
{/* 3 KPI cards */} {/* 3 KPI cards */}
@ -35,7 +64,8 @@ export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
style={{ style={{
fontSize: 9.5, fontSize: 9.5,
letterSpacing: 1.5, letterSpacing: 1.5,
color: tokens.muted2, // C1 — key caption (labels the headline KPI value) → body2.
color: tokens.body2,
}} }}
> >
{kpi.label} {kpi.label}
@ -84,70 +114,82 @@ export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
> >
Последние оценки Последние оценки
</div> </div>
<div {/* M7 — real table semantics via ARIA roles over the existing grid. */}
style={{ <div role="table" aria-label="Последние оценки">
display: "grid",
gridTemplateColumns: gridCols,
gap: 10,
padding: "9px 18px",
fontSize: 9,
letterSpacing: 1,
color: tokens.muted2,
borderBottom: `1px solid ${tokens.lineSoft2}`,
}}
>
<span>АДРЕС</span>
<span>ВРЕМЯ</span>
{showSrc && <span>ИСТОЧНИКОВ</span>}
<span>СТАТУС</span>
</div>
{data.rows.map((r, i) => (
<div <div
key={`${r.addr}-${i}`} role="row"
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: gridCols, gridTemplateColumns: gridCols,
gap: 10, gap: 10,
padding: "12px 18px", padding: "9px 18px",
fontSize: 11, fontSize: 9,
borderBottom: `1px solid ${tokens.lineSoft3}`, letterSpacing: 1,
alignItems: "center", // C1 — column headers are primary labels → body2.
color: tokens.body2,
borderBottom: `1px solid ${tokens.lineSoft2}`,
}} }}
> >
<span style={{ color: tokens.body }}>{r.addr}</span> {headers.map((h) => (
<span <span key={h} role="columnheader">
style={{ fontFamily: tokens.font.mono, color: tokens.muted2 }} {h}
>
{r.time}
</span>
{showSrc && (
<span
style={{ fontFamily: tokens.font.mono, color: tokens.muted }}
>
{r.src}
</span> </span>
)} ))}
<span </div>
{data.rows.map((r, i) => (
<div
key={`${r.addr}-${i}`}
role="row"
style={{ style={{
display: "flex", display: "grid",
gridTemplateColumns: gridCols,
gap: 10,
padding: "12px 18px",
fontSize: 11,
borderBottom: `1px solid ${tokens.lineSoft3}`,
alignItems: "center", alignItems: "center",
gap: 6,
fontSize: 10,
color: r.statusColor,
}} }}
> >
<span role="cell" style={{ color: tokens.body }}>
{r.addr}
</span>
<span <span
role="cell"
style={{ fontFamily: tokens.font.mono, color: tokens.muted2 }}
>
{normTime(r.time)}
</span>
{showSrc && (
<span
role="cell"
style={{ fontFamily: tokens.font.mono, color: tokens.muted }}
>
{r.src}
</span>
)}
<span
role="cell"
style={{ style={{
width: 7, display: "flex",
height: 7, alignItems: "center",
borderRadius: "50%", gap: 6,
background: r.statusColor, fontSize: 10,
color: r.statusColor,
}} }}
/> >
{r.status} <span
</span> style={{
</div> width: 7,
))} height: 7,
borderRadius: "50%",
background: r.statusColor,
}}
/>
{r.status}
</span>
</div>
))}
</div>
</div> </div>
</div> </div>
); );

View file

@ -9,9 +9,12 @@ import type { Report } from "./types";
interface FooterProps { interface FooterProps {
data?: Report; data?: Report;
// M4: hide the «ДЕЙСТВИТЕЛЕН ДО» validity line when there is no real estimate
// (mirrors HeroBar). Optional → unwired/fixture usage keeps showing it.
hasEstimate?: boolean;
} }
export function Footer({ data = report }: FooterProps) { export function Footer({ data = report, hasEstimate }: FooterProps) {
return ( return (
<div <div
style={{ style={{
@ -35,10 +38,12 @@ export function Footer({ data = report }: FooterProps) {
<span> <span>
ДАТА <span style={{ color: tokens.muted }}>{data.date}</span> ДАТА <span style={{ color: tokens.muted }}>{data.date}</span>
</span> </span>
<span> {hasEstimate !== false && (
ДЕЙСТВИТЕЛЕН ДО{" "} <span>
<span style={{ color: tokens.muted }}>{data.validUntil}</span> ДЕЙСТВИТЕЛЕН ДО{" "}
</span> <span style={{ color: tokens.muted }}>{data.validUntil}</span>
</span>
)}
</div> </div>
<div <div
@ -107,7 +112,9 @@ export function Footer({ data = report }: FooterProps) {
fontSize: 12, fontSize: 12,
fontWeight: 600, fontWeight: 600,
letterSpacing: "4px", letterSpacing: "4px",
color: tokens.muted, // C1: primary brand label — body2 (#46586c) reads as the most
// prominent footer text, above the secondary id/date metadata.
color: tokens.body2,
}} }}
> >
МЕРА МЕРА

View file

@ -40,6 +40,10 @@ function pdfDownloadHref(estimateId: string | null | undefined): string | null {
interface HeroBarProps { interface HeroBarProps {
data?: HeroBarData; data?: HeroBarData;
estimateId?: string | null; estimateId?: string | null;
// M4: when false (no real, sufficient estimate yet) the estimate-dependent
// meta/controls — the «ДЕЙСТВИТЕЛЕН ДО» validity line and «КАК РАССЧИТАНО» —
// are hidden, mirroring how the PDF control already gates on estimate presence.
hasEstimate: boolean;
onOpenInfo: () => void; onOpenInfo: () => void;
} }
@ -63,6 +67,7 @@ const pdfBtnStyle: CSSProperties = {
export default function HeroBar({ export default function HeroBar({
data = HERO_FIXTURE, data = HERO_FIXTURE,
estimateId, estimateId,
hasEstimate,
onOpenInfo, onOpenInfo,
}: HeroBarProps) { }: HeroBarProps) {
const pdfHref = pdfDownloadHref(estimateId); const pdfHref = pdfDownloadHref(estimateId);
@ -159,32 +164,34 @@ export default function HeroBar({
{data.report.date} {data.report.date}
</div> </div>
</div> </div>
<div {hasEstimate && (
style={{
borderLeft: `1px solid ${tokens.lineSoft}`,
paddingLeft: 26,
}}
>
<div <div
style={{ style={{
fontSize: 9, borderLeft: `1px solid ${tokens.lineSoft}`,
letterSpacing: "2px", paddingLeft: 26,
color: tokens.muted2,
marginBottom: 6,
}} }}
> >
ДЕЙСТВИТЕЛЕН ДО <div
style={{
fontSize: 9,
letterSpacing: "2px",
color: tokens.muted2,
marginBottom: 6,
}}
>
ДЕЙСТВИТЕЛЕН ДО
</div>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: 14,
color: tokens.ink,
}}
>
{data.report.validUntil}
</div>
</div> </div>
<div )}
style={{
fontFamily: tokens.font.mono,
fontSize: 14,
color: tokens.ink,
}}
>
{data.report.validUntil}
</div>
</div>
</div> </div>
<div style={{ display: "flex", gap: 12, width: "100%", maxWidth: 440 }}> <div style={{ display: "flex", gap: 12, width: "100%", maxWidth: 440 }}>
@ -208,30 +215,32 @@ export default function HeroBar({
{pdfBtnInner} {pdfBtnInner}
</button> </button>
)} )}
<button {hasEstimate && (
type="button" <button
className="hero-calc-btn" type="button"
onClick={onOpenInfo} className="hero-calc-btn"
style={{ onClick={onOpenInfo}
flex: 1, style={{
height: 50, flex: 1,
background: tokens.surface.w40, height: 50,
border: `1px solid ${tokens.line}`, background: tokens.surface.w40,
borderRadius: 7, border: `1px solid ${tokens.line}`,
display: "flex", borderRadius: 7,
alignItems: "center", display: "flex",
justifyContent: "center", alignItems: "center",
cursor: "pointer", justifyContent: "center",
fontFamily: tokens.font.sans, cursor: "pointer",
color: tokens.muted, fontFamily: tokens.font.sans,
fontSize: 11, color: tokens.muted,
fontWeight: 600, fontSize: 11,
letterSpacing: "1.5px", fontWeight: 600,
transition: "all .15s", letterSpacing: "1.5px",
}} transition: "all .15s",
> }}
КАК РАССЧИТАНО >
</button> КАК РАССЧИТАНО
</button>
)}
</div> </div>
</div> </div>

View file

@ -69,52 +69,66 @@ export default function HistoryView({
</span> </span>
</div> </div>
<div <div role="table" aria-label="История продаж в этом доме">
style={{
display: "grid",
gridTemplateColumns: houseGrid,
gap: "12px",
padding: "9px 18px",
fontSize: "9px",
letterSpacing: "1.2px",
color: tokens.muted2,
borderBottom: `1px solid ${tokens.lineSoft2}`,
}}
>
<span>ЛОТ</span>
<span>ЦЕНА НАЧ. ПОСЛ.</span>
<span>ДАТА</span>
<span>ЭКСПОЗИЦИЯ</span>
</div>
{data.houseSales.map((row, i) => (
<div <div
key={i} role="row"
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: houseGrid, gridTemplateColumns: houseGrid,
gap: "12px", gap: "12px",
padding: "13px 18px", padding: "9px 18px",
fontSize: "12px", fontSize: "9px",
borderBottom: letterSpacing: "1.2px",
i < data.houseSales.length - 1 // C1: primary column-header labels → body2 (above the muted rows).
? `1px solid ${tokens.lineSoft4}` color: tokens.body2,
: undefined, borderBottom: `1px solid ${tokens.lineSoft2}`,
}} }}
> >
<span style={{ color: tokens.body }}>{row.lot}</span> <span role="columnheader">ЛОТ</span>
<span style={{ fontFamily: tokens.font.mono, color: tokens.muted }}> <span role="columnheader">ЦЕНА НАЧ. ПОСЛ.</span>
{row.priceFrom} {" "} <span role="columnheader">ДАТА</span>
<b style={{ color: tokens.ink2 }}>{row.priceTo}</b> <span role="columnheader">ЭКСПОЗИЦИЯ</span>
</span>
<span style={{ fontFamily: tokens.font.mono, color: tokens.muted }}>
{row.date}
</span>
<span style={{ fontFamily: tokens.font.mono, color: tokens.muted }}>
{row.exposure}
</span>
</div> </div>
))}
{data.houseSales.map((row, i) => (
<div
key={i}
role="row"
style={{
display: "grid",
gridTemplateColumns: houseGrid,
gap: "12px",
padding: "13px 18px",
fontSize: "12px",
borderBottom:
i < data.houseSales.length - 1
? `1px solid ${tokens.lineSoft4}`
: undefined,
}}
>
<span role="cell" style={{ color: tokens.body }}>{row.lot}</span>
<span
role="cell"
style={{ fontFamily: tokens.font.mono, color: tokens.muted }}
>
{row.priceFrom} {" "}
<b style={{ color: tokens.ink2 }}>{row.priceTo}</b>
</span>
<span
role="cell"
style={{ fontFamily: tokens.font.mono, color: tokens.muted }}
>
{row.date}
</span>
<span
role="cell"
style={{ fontFamily: tokens.font.mono, color: tokens.muted }}
>
{row.exposure}
</span>
</div>
))}
</div>
</div> </div>
{/* ── ДКП-сделки на улице ──────────────────────────────────── */} {/* ── ДКП-сделки на улице ──────────────────────────────────── */}
@ -144,7 +158,8 @@ export default function HistoryView({
style={{ style={{
fontSize: "8.5px", fontSize: "8.5px",
letterSpacing: "2px", letterSpacing: "2px",
color: tokens.muted2, // C1: primary section eyebrow → body2 (ObjectSummary pattern).
color: tokens.body2,
marginBottom: "5px", marginBottom: "5px",
}} }}
> >
@ -205,7 +220,8 @@ export default function HistoryView({
style={{ style={{
fontSize: "9px", fontSize: "9px",
letterSpacing: "1.5px", letterSpacing: "1.5px",
color: tokens.muted2, // C1: primary KPI label → body2 (above the muted value/unit).
color: tokens.body2,
}} }}
> >
СДЕЛОК ЗА 24 МЕС СДЕЛОК ЗА 24 МЕС
@ -232,7 +248,8 @@ export default function HistoryView({
style={{ style={{
fontSize: "9px", fontSize: "9px",
letterSpacing: "1.5px", letterSpacing: "1.5px",
color: tokens.muted2, // C1: primary KPI label → body2 (above the muted value/unit).
color: tokens.body2,
}} }}
> >
МЕДИАНА МЕДИАНА
@ -257,7 +274,8 @@ export default function HistoryView({
style={{ style={{
fontSize: "9px", fontSize: "9px",
letterSpacing: "1.5px", letterSpacing: "1.5px",
color: tokens.muted2, // C1: primary KPI label → body2 (above the muted value/unit).
color: tokens.body2,
}} }}
> >
ДИАПАЗОН ДИАПАЗОН
@ -280,86 +298,104 @@ export default function HistoryView({
</div> </div>
{/* Table header */} {/* Table header */}
<div <div role="table" aria-label="ДКП-сделки по вашей улице">
style={{
display: "grid",
gridTemplateColumns: dkpGridCols,
gap: "10px",
padding: "9px 18px",
fontSize: "9px",
letterSpacing: "1px",
color: tokens.muted2,
borderBottom: `1px solid ${tokens.lineSoft2}`,
}}
>
<span>АДРЕС</span>
<span>ПЛОЩАДЬ</span>
<span>ЦЕНА СДЕЛКИ</span>
<span>/М²</span>
<span>ДАТА</span>
{showAsk && <span>ОБЪЯВЛЕНИЕ ДО СДЕЛКИ</span>}
</div>
{data.dkpRows.map((r, i) => (
<div <div
key={i} role="row"
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: dkpGridCols, gridTemplateColumns: dkpGridCols,
gap: "10px", gap: "10px",
padding: "11px 18px", padding: "9px 18px",
fontSize: "11px", fontSize: "9px",
borderBottom: `1px solid ${tokens.lineSoft3}`, letterSpacing: "1px",
alignItems: "center", // C1: primary column-header labels → body2 (above the muted rows).
color: tokens.body2,
borderBottom: `1px solid ${tokens.lineSoft2}`,
}} }}
> >
<span style={{ color: tokens.body }}>{r.addr ?? "—"}</span> <span role="columnheader">АДРЕС</span>
<span style={{ fontFamily: tokens.font.mono, color: tokens.muted }}> <span role="columnheader">ПЛОЩАДЬ</span>
{r.area} <span role="columnheader">ЦЕНА СДЕЛКИ</span>
</span> <span role="columnheader">/М²</span>
<span style={{ fontFamily: tokens.font.mono, color: tokens.ink2 }}> <span role="columnheader">ДАТА</span>
{r.price} {showAsk && <span role="columnheader">ОБЪЯВЛЕНИЕ ДО СДЕЛКИ</span>}
</span> </div>
<span style={{ fontFamily: tokens.font.mono, color: tokens.muted }}>
{r.ppm} {data.dkpRows.map((r, i) => (
</span> <div
<span key={i}
style={{ fontFamily: tokens.font.mono, color: tokens.muted2 }} role="row"
style={{
display: "grid",
gridTemplateColumns: dkpGridCols,
gap: "10px",
padding: "11px 18px",
fontSize: "11px",
borderBottom: `1px solid ${tokens.lineSoft3}`,
alignItems: "center",
}}
> >
{r.date ?? "—"} <span role="cell" style={{ color: tokens.body }}>
</span> {r.addr ?? "—"}
{showAsk && ( </span>
<span <span
style={{ role="cell"
display: "flex", style={{ fontFamily: tokens.font.mono, color: tokens.muted }}
alignItems: "center",
gap: "7px",
fontFamily: tokens.font.mono,
fontSize: "10px",
color: tokens.muted,
}}
> >
{r.area}
</span>
<span
role="cell"
style={{ fontFamily: tokens.font.mono, color: tokens.ink2 }}
>
{r.price}
</span>
<span
role="cell"
style={{ fontFamily: tokens.font.mono, color: tokens.muted }}
>
{r.ppm}
</span>
<span
role="cell"
style={{ fontFamily: tokens.font.mono, color: tokens.muted2 }}
>
{r.date ?? "—"}
</span>
{showAsk && (
<span <span
role="cell"
style={{ style={{
fontSize: "8px", display: "flex",
letterSpacing: ".5px", alignItems: "center",
background: tokens.badgeTint, gap: "7px",
border: `1px solid ${tokens.line2}`, fontFamily: tokens.font.mono,
borderRadius: "3px", fontSize: "10px",
padding: "1px 5px", color: tokens.muted,
// accentDeep (not accent): 8px accent on badgeTint ~2.8:1 < AA;
// accentDeep keeps the blue brand cue at ≈5:1.
color: tokens.accentDeep,
}} }}
> >
{r.source ?? "—"} <span
style={{
fontSize: "8px",
letterSpacing: ".5px",
background: tokens.badgeTint,
border: `1px solid ${tokens.line2}`,
borderRadius: "3px",
padding: "1px 5px",
// accentDeep (not accent): 8px accent on badgeTint ~2.8:1 < AA;
// accentDeep keeps the blue brand cue at ≈5:1.
color: tokens.accentDeep,
}}
>
{r.source ?? "—"}
</span>
{r.ask}
<span style={{ color: r.deltaColor }}>{r.delta}</span>
</span> </span>
{r.ask} )}
<span style={{ color: r.deltaColor }}>{r.delta}</span> </div>
</span> ))}
)} </div>
</div>
))}
<div <div
style={{ style={{

View file

@ -8,10 +8,11 @@
// backend #2045). It is now an HONEST methodology panel with STATIC, truthful // backend #2045). It is now an HONEST methodology panel with STATIC, truthful
// documentation text only: how the estimate is built, and an explicit note that // documentation text only: how the estimate is built, and an explicit note that
// the location coefficient does not exist yet. Keeps the same drawer shell / // the location coefficient does not exist yet. Keeps the same drawer shell /
// slide animation / close button. Open/close is driven entirely by props; the // slide animation / close button. Open/close is driven entirely by props; while
// only effect is an Escape-to-close keydown listener while open. // open it is a real modal dialog (role=dialog/aria-modal, focus-trap, Esc) with
// semantics mirrored from SectionOverlay.
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { tokens } from "./tokens"; import { tokens } from "./tokens";
interface LocationDrawerProps { interface LocationDrawerProps {
@ -20,15 +21,76 @@ interface LocationDrawerProps {
} }
export function LocationDrawer({ open, onClose }: LocationDrawerProps) { export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
// Escape-to-close (UI effect only — no HTTP). Active while the drawer is open. // Modal-dialog plumbing — mirrors SectionOverlay. `dialogRef` is the dialog
// root, `lastFocused` keeps the trigger so focus can be restored on close, and
// `onCloseRef` holds the latest onClose so the effect (keyed only on `open`)
// never re-runs / re-traps focus when page.tsx passes a fresh arrow each render.
const dialogRef = useRef<HTMLDivElement>(null);
const lastFocused = useRef<Element | null>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
// Focus management + Escape/Tab handling while open. Unlike SectionOverlay
// (mounted only when open) this drawer stays mounted for the slide animation,
// so the effect is keyed on `open`: trap focus on open, restore it on close.
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onKey = (e: KeyboardEvent) => { const dialog = dialogRef.current;
if (e.key === "Escape") onClose(); // Remember the trigger, then move focus into the dialog.
lastFocused.current = document.activeElement;
dialog?.focus();
function getFocusable(): HTMLElement[] {
if (!dialog) return [];
const nodes = dialog.querySelectorAll<HTMLElement>(
'button, [href], input, select, [tabindex]:not([tabindex="-1"])',
);
return Array.from(nodes).filter((el) => !el.hasAttribute("disabled"));
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
// Stop the event so a stacked overlay below can't also consume it and so
// the browser runs no default action; then close this (topmost) drawer.
e.preventDefault();
e.stopPropagation();
onCloseRef.current();
return;
}
if (e.key !== "Tab" || !dialog) return;
const focusable = getFocusable();
if (focusable.length === 0) {
// Nothing tabbable inside — keep focus pinned on the dialog itself.
e.preventDefault();
dialog.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const activeEl = document.activeElement;
const inside = dialog.contains(activeEl);
if (e.shiftKey) {
if (activeEl === first || activeEl === dialog || !inside) {
e.preventDefault();
last.focus();
}
} else if (activeEl === last || !inside) {
e.preventDefault();
first.focus();
}
}
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
// Return focus to whatever opened the drawer.
(lastFocused.current as HTMLElement | null)?.focus?.();
}; };
window.addEventListener("keydown", onKey); // `onClose` is read via onCloseRef; refs are stable → only `open` is reactive.
return () => window.removeEventListener("keydown", onKey); }, [open]);
}, [open, onClose]);
return ( return (
<> <>
@ -37,6 +99,7 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
width: 32px; width: 32px;
height: 32px; height: 32px;
flex: 0 0 auto; flex: 0 0 auto;
padding: 0;
border: 1px solid ${tokens.line}; border: 1px solid ${tokens.line};
border-radius: 6px; border-radius: 6px;
display: flex; display: flex;
@ -44,7 +107,9 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
color: ${tokens.muted}; color: ${tokens.muted};
font-family: inherit;
font-size: 16px; font-size: 16px;
line-height: 1;
background: ${tokens.surface.w60}; background: ${tokens.surface.w60};
transition: all .15s; transition: all .15s;
} }
@ -69,13 +134,18 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
}} }}
/> />
{/* drawer */} {/* drawer — modal dialog (role / focus-trap / inert mirror SectionOverlay) */}
<div <div
// Off-canvas when closed → keep it out of the a11y tree so it isn't ref={dialogRef}
// flagged as content outside a landmark (axe "region"). No native role="dialog"
// focusable elements inside (close is a div), so aria-hidden is safe. aria-modal="true"
// Full dialog semantics (role=dialog/focus-trap/inert) land in P3. aria-labelledby="ld-drawer-title"
tabIndex={-1}
// Off-canvas when closed → keep it out of the a11y tree (axe "region")
// AND inert, so the now-focusable close button is not a focusable node
// inside an aria-hidden subtree (axe "aria-hidden-focus").
aria-hidden={!open} aria-hidden={!open}
inert={!open ? true : undefined}
style={{ style={{
position: "absolute", position: "absolute",
top: 0, top: 0,
@ -146,12 +216,13 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
style={{ style={{
fontSize: 9, fontSize: 9,
letterSpacing: "3px", letterSpacing: "3px",
color: tokens.muted2, color: tokens.body2,
}} }}
> >
МЕТОДИКА МЕТОДИКА
</div> </div>
<div <div
id="ld-drawer-title"
style={{ style={{
fontSize: 22, fontSize: 22,
fontWeight: 300, fontWeight: 300,
@ -163,9 +234,14 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
ПОЯСНЕНИЕ К РАСЧЁТУ ПОЯСНЕНИЕ К РАСЧЁТУ
</div> </div>
</div> </div>
<div className="ld-close" onClick={onClose}> <button
type="button"
className="ld-close"
onClick={onClose}
aria-label="Закрыть"
>
</div> </button>
</div> </div>
{/* divider under header */} {/* divider under header */}
@ -181,7 +257,7 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
style={{ style={{
fontSize: 9, fontSize: 9,
letterSpacing: "2px", letterSpacing: "2px",
color: tokens.muted2, color: tokens.body2,
marginTop: 22, marginTop: 22,
marginBottom: 12, marginBottom: 12,
}} }}
@ -195,7 +271,7 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
color: tokens.body2, color: tokens.body2,
}} }}
> >
Агрегируем объявления (Авито, Циан, Яндекс, Домклик) и сделки Агрегируем объявления (Циан, Я.Недвижимость, Авито, N1.ru) и сделки
Росреестра по сопоставимым квартирам. Медиана{" "} Росреестра по сопоставимым квартирам. Медиана{" "}
<b style={{ color: tokens.ink2 }}>/м²</b> 3 оценки:{" "} <b style={{ color: tokens.ink2 }}>/м²</b> 3 оценки:{" "}
<b style={{ color: tokens.ink2 }}> <b style={{ color: tokens.ink2 }}>

View file

@ -18,6 +18,26 @@ interface ObjectSummaryProps {
onNavigate: (i: number) => void; onNavigate: (i: number) => void;
} }
// Audit M11 — name the unnamed «N · XX,XX млн» numbers. Each summary row value
// is built in mappers.mapSummary as "count · metric"; the metric is different
// per row and the «млн» medians (Аналоги/Сделки) are NOT the headline median
// (e.median_price_rub is area-normalised to the subject; these are raw medians
// of the analog/deal listing prices), so users couldn't tell what they were.
// This caption labels each ·-separated token token-for-token. Keyed by label
// content so it matches both the mapper labels ("Продажи рядом") and the older
// fixture labels ("Продажи в доме"). Sources in mappers.mapSummary:
// Продажи рядом : houseSold · kpi.median_exposure_days
// Аналоги : e.n_analogs · median(e.analogs.map(a => a.price_rub))
// Сделки : dealCount · streetDeals.median_price_rub ?? median(deals)
// Аналитика : kpi.median_exposure_days · -kpi.median_bargain_pct
function rowHint(label: string): string {
if (label.startsWith("Продажи")) return "кол-во · медиана экспозиции";
if (label === "Аналоги") return "кол-во · медиана объявлений";
if (label === "Сделки") return "кол-во · медиана сделок";
if (label === "Аналитика") return "экспозиция · торг";
return "";
}
export function ObjectSummary({ export function ObjectSummary({
data = OBJECT_SUMMARY_FIXTURE, data = OBJECT_SUMMARY_FIXTURE,
onNavigate, onNavigate,
@ -109,7 +129,7 @@ export function ObjectSummary({
style={{ style={{
fontSize: 9, fontSize: 9,
letterSpacing: 1.2, letterSpacing: 1.2,
color: tokens.muted3, color: tokens.body2,
marginBottom: 13, marginBottom: 13,
}} }}
> >
@ -158,7 +178,9 @@ export function ObjectSummary({
{/* Per-section totals (clickable) */} {/* Per-section totals (clickable) */}
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}> <div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
{data.summary.rows.map((row, i) => ( {data.summary.rows
.filter((row) => row.value !== "—")
.map((row, i, visible) => (
<button <button
key={row.label} key={row.label}
type="button" type="button"
@ -176,7 +198,7 @@ export function ObjectSummary({
fontFamily: "inherit", fontFamily: "inherit",
textAlign: "left", textAlign: "left",
borderBottom: borderBottom:
i < data.summary.rows.length - 1 i < visible.length - 1
? `1px dotted ${tokens.lineGradient}` ? `1px dotted ${tokens.lineGradient}`
: undefined, : undefined,
}} }}
@ -206,13 +228,34 @@ export function ObjectSummary({
<span style={{ display: "flex", alignItems: "center", gap: 8 }}> <span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span <span
style={{ style={{
fontFamily: tokens.font.mono, display: "flex",
fontSize: 11, flexDirection: "column",
color: tokens.ink2, alignItems: "flex-end",
whiteSpace: "nowrap", gap: 1,
}} }}
> >
{row.value} <span
style={{
fontFamily: tokens.font.mono,
fontSize: 11,
color: tokens.ink2,
whiteSpace: "nowrap",
}}
>
{row.value}
</span>
{rowHint(row.label) && (
<span
style={{
fontSize: 8,
letterSpacing: 0.2,
color: tokens.muted3,
whiteSpace: "nowrap",
}}
>
{rowHint(row.label)}
</span>
)}
</span> </span>
<span style={{ color: tokens.accent, fontSize: 11 }}></span> <span style={{ color: tokens.accent, fontSize: 11 }}></span>
</span> </span>

View file

@ -293,10 +293,13 @@ const styles = `
.pp-eval-btn:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(46,139,255,.35)} .pp-eval-btn:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(46,139,255,.35)}
`; `;
// Primary field captions ("АДРЕС", "ПЛОЩАДЬ", "КОМНАТ", …) — the labels the user
// reads first. C1 hierarchy: bumped from muted2 to body2 (#46586c, ~6:1) so they
// read as primary against the lighter units/hints which stay on muted (optHint).
const hintLabel: CSSProperties = { const hintLabel: CSSProperties = {
fontSize: 9.5, fontSize: 9.5,
letterSpacing: 1.5, letterSpacing: 1.5,
color: tokens.muted2, color: tokens.body2,
marginBottom: 3, marginBottom: 3,
}; };
@ -529,6 +532,11 @@ export default function ParamsPanel({
initialValues?.address ?? "", initialValues?.address ?? "",
); );
const [suggestOpen, setSuggestOpen] = useState(false); const [suggestOpen, setSuggestOpen] = useState(false);
// Address combobox a11y (M6): stable listbox id + roving highlight index for
// aria-activedescendant / ArrowDown-ArrowUp keyboard nav over the suggestions.
const addressListId = useId();
const addressOptId = (i: number) => `${addressListId}-opt-${i}`;
const [addressActive, setAddressActive] = useState(-1);
const [coords, setCoords] = useState<{ lat: number; lon: number } | null>( const [coords, setCoords] = useState<{ lat: number; lon: number } | null>(
initialValues?.lat != null && initialValues?.lon != null initialValues?.lat != null && initialValues?.lon != null
? { lat: initialValues.lat, lon: initialValues.lon } ? { lat: initialValues.lat, lon: initialValues.lon }
@ -544,6 +552,7 @@ export default function ParamsPanel({
setAddress(v); setAddress(v);
setCoords(null); // a manual edit invalidates a previously picked point setCoords(null); // a manual edit invalidates a previously picked point
setSuggestOpen(v.trim().length >= 3); setSuggestOpen(v.trim().length >= 3);
setAddressActive(-1); // a new query invalidates the previous highlight
if (fieldErrors.address) if (fieldErrors.address)
setFieldErrors((prev) => ({ ...prev, address: undefined })); setFieldErrors((prev) => ({ ...prev, address: undefined }));
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
@ -555,6 +564,65 @@ export default function ParamsPanel({
setAddressQuery(s.full_address); setAddressQuery(s.full_address);
setCoords({ lat: s.lat, lon: s.lon }); setCoords({ lat: s.lat, lon: s.lon });
setSuggestOpen(false); setSuggestOpen(false);
setAddressActive(-1);
};
// Address combobox keyboard (M6). aria-activedescendant pattern: focus STAYS on
// the input; arrows move `addressActive` over the live suggestions, Enter on a
// highlighted row selects it (else submits the form), Escape closes ONLY the
// popup (stopPropagation so it doesn't also dismiss an outer drawer/overlay).
// The geocode/suggest fetch + pickSuggestion side-effects are untouched.
const handleAddressKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
const list = suggest.data ?? [];
const popupOpen = suggestOpen && addressQuery.trim().length >= 3;
const listOpen = popupOpen && list.length > 0;
switch (e.key) {
case "ArrowDown":
if (listOpen) {
e.preventDefault();
setAddressActive((i) => Math.min(list.length - 1, i + 1));
} else if (address.trim().length >= 3) {
e.preventDefault();
setSuggestOpen(true); // reopen after Escape without retyping
}
break;
case "ArrowUp":
if (listOpen) {
e.preventDefault();
setAddressActive((i) => Math.max(0, i - 1));
}
break;
case "Home":
if (listOpen) {
e.preventDefault();
setAddressActive(0);
}
break;
case "End":
if (listOpen) {
e.preventDefault();
setAddressActive(list.length - 1);
}
break;
case "Enter":
if (listOpen && addressActive >= 0 && addressActive < list.length) {
e.preventDefault();
pickSuggestion(list[addressActive]);
} else {
handleSubmit();
}
break;
case "Escape":
if (popupOpen) {
e.preventDefault();
e.stopPropagation();
setSuggestOpen(false);
setAddressActive(-1);
}
break;
default:
break;
}
}; };
// TODO(#395): CRM-интеграция не подключена — дропдаун присутствует визуально, // TODO(#395): CRM-интеграция не подключена — дропдаун присутствует визуально,
@ -675,6 +743,18 @@ export default function ParamsPanel({
) )
: 112; : 112;
// Subject area caption for the map pin (M10) — the SUBJECT's own m², from the
// form, so the pin never borrows an analog's area. Empty area → no caption.
const areaTrimmed = area.trim();
const subjectAreaLabel = areaTrimmed ? `${areaTrimmed} м²` : null;
// Address combobox render state (M6). `popupOpen` = the dropdown is shown at
// all (incl. loading / empty notes); `listboxOpen` = it holds real selectable
// options, which is when the input advertises aria-expanded + activedescendant.
const addressList = suggest.data ?? [];
const addressPopupOpen = suggestOpen && addressQuery.trim().length >= 3;
const addressListboxOpen = addressPopupOpen && addressList.length > 0;
return ( return (
<div <div
style={{ style={{
@ -829,8 +909,21 @@ export default function ParamsPanel({
background: tokens.accent, background: tokens.accent,
}} }}
/> />
<span style={{ fontSize: 11, fontWeight: 600 }}> {/* M10: the SUBJECT pin must show the subject's OWN address + area,
{address.trim() || "Адрес квартиры"} never an analog's. The form area (subjectAreaLabel) is rendered
here so an analog pin that lands near the centre can no longer be
mistaken for the subject's caption. */}
<span
style={{ display: "flex", flexDirection: "column", gap: 1 }}
>
<span style={{ fontSize: 11, fontWeight: 600 }}>
{address.trim() || "Адрес квартиры"}
</span>
{subjectAreaLabel && (
<span style={{ fontSize: 9, color: tokens.muted }}>
{subjectAreaLabel} · оцениваемая квартира
</span>
)}
</span> </span>
</div> </div>
<div <div
@ -943,7 +1036,7 @@ export default function ParamsPanel({
flex: "0 0 auto", flex: "0 0 auto",
}} }}
> >
<span style={{ fontSize: 10, letterSpacing: 1.5, color: tokens.muted }}> <span style={{ fontSize: 10, letterSpacing: 1.5, color: tokens.body2 }}>
РАДИУС АНАЛИЗА РАДИУС АНАЛИЗА
</span> </span>
{/* РАДИУС selectable analysis radius (wired: parsed to radius_m on {/* РАДИУС selectable analysis radius (wired: parsed to radius_m on
@ -1089,6 +1182,15 @@ export default function ParamsPanel({
className="pp-input" className="pp-input"
type="text" type="text"
value={address} value={address}
role="combobox"
aria-autocomplete="list"
aria-expanded={addressListboxOpen}
aria-controls={addressListboxOpen ? addressListId : undefined}
aria-activedescendant={
addressListboxOpen && addressActive >= 0
? addressOptId(addressActive)
: undefined
}
aria-invalid={fieldErrors.address ? true : undefined} aria-invalid={fieldErrors.address ? true : undefined}
aria-describedby={ aria-describedby={
fieldErrors.address ? "pp-address-err" : undefined fieldErrors.address ? "pp-address-err" : undefined
@ -1097,11 +1199,11 @@ export default function ParamsPanel({
onFocus={() => { onFocus={() => {
if (address.trim().length >= 3) setSuggestOpen(true); if (address.trim().length >= 3) setSuggestOpen(true);
}} }}
onBlur={() => setSuggestOpen(false)} onBlur={() => {
onKeyDown={(e) => { setSuggestOpen(false);
if (e.key === "Enter") handleSubmit(); setAddressActive(-1);
if (e.key === "Escape") setSuggestOpen(false);
}} }}
onKeyDown={handleAddressKeyDown}
placeholder="Город, улица, дом" placeholder="Город, улица, дом"
autoComplete="off" autoComplete="off"
style={ style={
@ -1128,34 +1230,51 @@ export default function ParamsPanel({
<line x1="0" y1="7.5" x2="3" y2="7.5" stroke="#2e8bff" /> <line x1="0" y1="7.5" x2="3" y2="7.5" stroke="#2e8bff" />
<line x1="12" y1="7.5" x2="15" y2="7.5" stroke="#2e8bff" /> <line x1="12" y1="7.5" x2="15" y2="7.5" stroke="#2e8bff" />
</svg> </svg>
{suggestOpen && addressQuery.trim().length >= 3 && ( {addressPopupOpen &&
<div style={suggestPanel}> (addressListboxOpen ? (
{suggest.data && suggest.data.length > 0 ? ( <div
suggest.data.map((s, i) => ( style={suggestPanel}
role="listbox"
id={addressListId}
aria-label="Подсказки адреса"
tabIndex={-1}
>
{addressList.map((s, i) => (
<div <div
key={`${s.full_address}-${i}`} key={`${s.full_address}-${i}`}
className="pp-dd-opt" className="pp-dd-opt"
role="option"
id={addressOptId(i)}
aria-selected={i === addressActive}
// onMouseDown (not onClick) + preventDefault fires before // onMouseDown (not onClick) + preventDefault fires before
// the input's onBlur, so the pick lands before the list closes. // the input's onBlur, so the pick lands before the list closes.
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
pickSuggestion(s); pickSuggestion(s);
}} }}
style={suggestOpt} onMouseEnter={() => setAddressActive(i)}
style={
i === addressActive
? { ...suggestOpt, background: "rgba(46,139,255,.1)" }
: suggestOpt
}
> >
<div style={suggestOptMain}>{s.label}</div> <div style={suggestOptMain}>{s.label}</div>
{s.full_address !== s.label && ( {s.full_address !== s.label && (
<div style={suggestOptSub}>{s.full_address}</div> <div style={suggestOptSub}>{s.full_address}</div>
)} )}
</div> </div>
)) ))}
) : suggest.isFetching ? ( </div>
<div style={suggestNote}>Поиск</div> ) : (
) : ( // Loading / empty: a status note, NOT a role="listbox" (it has no
<div style={suggestNote}>Ничего не найдено</div> // selectable options) so aria-required-children stays satisfied.
)} <div style={suggestPanel} role="status">
</div> <div style={suggestNote}>
)} {suggest.isFetching ? "Поиск…" : "Ничего не найдено"}
</div>
</div>
))}
{fieldErrors.address && ( {fieldErrors.address && (
<div <div
id="pp-address-err" id="pp-address-err"

View file

@ -14,12 +14,14 @@ import type { ResultPanelData } from "./mappers";
const { const {
accent, accent,
ink, ink,
body2,
muted, muted,
muted2, muted2,
muted3, muted3,
line, line,
line2, line2,
line3, line3,
lineSoft,
bracket, bracket,
barLo, barLo,
barMid, barMid,
@ -30,6 +32,7 @@ const {
disabledBorder, disabledBorder,
disabledValue, disabledValue,
disabledLabel, disabledLabel,
infoSoftBg,
surface, surface,
font, font,
} = tokens; } = tokens;
@ -147,16 +150,39 @@ export default function ResultPanel({
flex: "0 0 auto", flex: "0 0 auto",
}} }}
> >
{data.cards.map((card, ci) => ( {data.cards.map((card, ci) => {
// Price hierarchy (audit H2). The three cards are NOT equal weight:
// ci 0 — РЕКОМЕНДОВАННАЯ ЦЕНА В ОБЪЯВЛЕНИИ (market asking, baseline)
// ci 1 — ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ (the real answer → headline)
// ci 2 — ДКП · РОСРЕЕСТР (registry reference, ~43% → demoted)
// The headline gets an accent border + tint + glow + larger accent
// value so the user can't confuse it with the registry figure; the
// registry card is demoted to a softer, smaller, muted "СПРАВОЧНО"
// tile. All three values stay (honesty); wiring (mapResultPanel) is
// untouched — emphasis is presentation only.
const isHeadline = ci === 1;
const isRegistry = ci === 2;
return (
<div <div
key={ci} key={ci}
style={{ style={{
position: "relative", position: "relative",
background: surface.w55, background: isHeadline
? infoSoftBg
: isRegistry
? surface.w40
: surface.w55,
backdropFilter: "blur(6px)", backdropFilter: "blur(6px)",
border: `1px solid ${line2}`, border: isHeadline
? `1.5px solid ${accent}`
: isRegistry
? `1px solid ${lineSoft}`
: `1px solid ${line2}`,
borderRadius: 8, borderRadius: 8,
padding: "15px 16px 13px", padding: "15px 16px 13px",
boxShadow: isHeadline
? "0 6px 22px rgba(46,139,255,.16)"
: undefined,
}} }}
> >
<div <div
@ -166,20 +192,33 @@ export default function ResultPanel({
top: -1, top: -1,
width: 12, width: 12,
height: 12, height: 12,
borderLeft: `1.5px solid ${bracket}`, borderLeft: `1.5px solid ${isHeadline ? accent : bracket}`,
borderTop: `1.5px solid ${bracket}`, borderTop: `1.5px solid ${isHeadline ? accent : bracket}`,
}} }}
/> />
<div <div
style={{ style={{
fontSize: 9.5, fontSize: 9.5,
letterSpacing: 1.5, letterSpacing: 1.5,
color: muted2, color: isHeadline ? accent : isRegistry ? muted2 : body2,
fontWeight: isHeadline ? 600 : undefined,
lineHeight: 1.6, lineHeight: 1.6,
}} }}
> >
{lines(card.title)} {lines(card.title)}
</div> </div>
{isRegistry && (
<div
style={{
fontSize: 8,
letterSpacing: 1.2,
color: muted3,
marginTop: 3,
}}
>
СПРАВОЧНО
</div>
)}
<div <div
style={{ style={{
marginTop: 14, marginTop: 14,
@ -191,9 +230,10 @@ export default function ResultPanel({
<span <span
style={{ style={{
fontFamily: font.mono, fontFamily: font.mono,
fontSize: 38, fontSize: isHeadline ? 40 : isRegistry ? 28 : 38,
fontWeight: 400, fontWeight: isHeadline ? 600 : 400,
letterSpacing: -1, letterSpacing: -1,
color: isHeadline ? accent : isRegistry ? muted : undefined,
}} }}
> >
{card.value} {card.value}
@ -390,7 +430,8 @@ export default function ResultPanel({
</> </>
)} )}
</div> </div>
))} );
})}
</div> </div>
{/* ranges + radar */} {/* ranges + radar */}

View file

@ -214,7 +214,7 @@ export default function SectionOverlay({
> >
<span style={{ fontSize: 13 }}></span> <span style={{ fontSize: 13 }}></span>
<span <span
style={{ fontSize: 10, letterSpacing: 1.5, color: tokens.muted }} style={{ fontSize: 10, letterSpacing: 1.5, color: tokens.body2 }}
> >
К ОЦЕНКЕ К ОЦЕНКЕ
</span> </span>

View file

@ -1,23 +1,36 @@
import type { CSSProperties } from "react"; import { Fragment } from "react";
import type { CSSProperties, ReactNode } from "react";
import { safeUrl } from "@/lib/safeUrl"; import { safeUrl } from "@/lib/safeUrl";
import { tokens } from "./tokens"; import { tokens } from "./tokens";
import { adRows, dealRows, marketAds, marketDeals } from "./fixtures"; import { adRows, dealRows, marketAds, marketDeals } from "./fixtures";
import type { SourcesData } from "./mappers"; import type { AdRowData, DealRowData, SourcesData } from "./mappers";
// Overlay 05 — РЫНОК · АНАЛОГИ И СДЕЛКИ. // Overlay 05 — РЫНОК · АНАЛОГИ И СДЕЛКИ.
// Faithful markup port from "МЕРА Оценка.dc.html" (lines 492-537). // Faithful markup port from "МЕРА Оценка.dc.html" (lines 492-537).
// Card A: объявления в продаже (3 KPI + filter chips + adRows table). // Card A: объявления в продаже (3 KPI + filter chips + adRows table).
// Card B: фактические сделки (3 KPI + dealRows table). // Card B: фактические сделки (3 KPI + dealRows table).
// Markup is data-driven via the `data` prop; the design fixtures stay as the // Markup is data-driven via the `data` prop; the design fixtures stay as the
// DEFAULT so unwired/storybook usage still renders pixel-identically. // DEFAULT for unwired/storybook usage. (M11 below may hide an all-«—» column,
// so the storybook render can differ from the design HTML by a dead column.)
//
// M7 — the two "tables" are CSS grids of <span>s. They now carry real table
// semantics via ARIA roles (table / row / columnheader / cell) layered onto the
// existing grid so the visual layout is unchanged (the row <div> keeps display:
// grid; the link cell wraps its <a> in a display:contents role="cell" so the
// anchor stays the grid item AND keeps its link role).
// M11 — columns whose every cell is empty/«—» (e.g. ДИСТ. when no distances are
// known) are dropped from BOTH the header and the body (no dead column).
// One-off tints not present in the v2 palette (kept literal, faithful to design). // One-off tints not present in the v2 palette (kept literal, faithful to design).
const KPI_BG = "rgba(238,244,250,.5)"; const KPI_BG = "rgba(238,244,250,.5)";
const BADGE_BG = "#eaf1f8"; const BADGE_BG = "#eaf1f8";
const AD_GRID = "1.9fr 1.3fr 1fr 1fr 1.1fr .6fr .9fr"; // Per-column grid fractions live on the AD_COLS / DEAL_COLS `fr` fields below;
const DEAL_GRID = "2fr 1.3fr 1.2fr 1fr 1.1fr 1fr"; // the row's gridTemplateColumns is rebuilt from the VISIBLE columns so a hidden
// (all-dash) column also drops its track. Original full templates, for reference:
// AD = "1.9fr 1.3fr 1fr 1fr 1.1fr .6fr .9fr"
// DEAL = "2fr 1.3fr 1.2fr 1fr 1.1fr 1fr"
const card: CSSProperties = { const card: CSSProperties = {
background: tokens.surface.w55, background: tokens.surface.w55,
@ -64,7 +77,8 @@ const kpiRow: CSSProperties = {
const kpiLabel: CSSProperties = { const kpiLabel: CSSProperties = {
fontSize: "9px", fontSize: "9px",
letterSpacing: "1.5px", letterSpacing: "1.5px",
color: tokens.muted2, // C1 — key caption (labels the headline KPI value) → body2.
color: tokens.body2,
}; };
const tableHead: CSSProperties = { const tableHead: CSSProperties = {
@ -72,7 +86,8 @@ const tableHead: CSSProperties = {
padding: "9px 18px", padding: "9px 18px",
fontSize: "9px", fontSize: "9px",
letterSpacing: "1px", letterSpacing: "1px",
color: tokens.muted2, // C1 — column headers are primary labels → body2.
color: tokens.body2,
borderBottom: `1px solid ${tokens.lineSoft2}`, borderBottom: `1px solid ${tokens.lineSoft2}`,
}; };
@ -135,6 +150,214 @@ const linkBtn: CSSProperties = {
textDecoration: "none", textDecoration: "none",
}; };
// ── Table column model (M7 semantics + M11 hide-all-dash) ───────────────────
// Each column owns its header cell, grid fraction, body-cell renderer and an
// `empty` test. A column is hidden when EVERY row's cell is empty/«—».
interface Col<R> {
key: string;
/** Grid fraction in gridTemplateColumns. */
fr: string;
/** Header cell (role="columnheader"); aria-label for a visually-empty header. */
head: ReactNode;
/** Body cell — returns the full grid-item element (carries role="cell"). */
cell: (row: R, index: number) => ReactNode;
/** True when this row contributes no value to the column (for hide-all-dash). */
empty: (row: R) => boolean;
}
/** Empty / placeholder cell value (null, blank, or a bare em-dash). */
function isDash(v: unknown): boolean {
return (
v == null ||
(typeof v === "string" && (v.trim() === "" || v.trim() === "—"))
);
}
/** Drop any column whose every cell is empty/«—»; keep all columns if no rows. */
function visibleCols<R>(cols: Col<R>[], rows: R[]): Col<R>[] {
if (rows.length === 0) return cols;
return cols.filter((c) => rows.some((r) => !c.empty(r)));
}
const AD_COLS: Col<AdRowData>[] = [
{
key: "addr",
fr: "1.9fr",
head: <span role="columnheader">АДРЕС</span>,
cell: (r) => (
<span role="cell" style={{ color: tokens.body }}>
{r.addr}
</span>
),
empty: (r) => isDash(r.addr),
},
{
key: "meta",
fr: "1.3fr",
head: <span role="columnheader">ПАРАМЕТРЫ</span>,
cell: (r) => (
<span role="cell" style={metaSpan}>
{r.meta}
</span>
),
empty: (r) => isDash(r.meta),
},
{
key: "source",
fr: "1fr",
head: <span role="columnheader">ИСТОЧНИК</span>,
cell: (r) => (
<span role="cell">
<span style={badge}>{r.source ?? "Avito"}</span>
</span>
),
empty: () => false,
},
{
key: "ppm",
fr: "1fr",
head: <span role="columnheader">/М²</span>,
cell: (r) => (
<span role="cell" style={monoMuted}>
{r.ppm}
</span>
),
empty: (r) => isDash(r.ppm),
},
{
key: "price",
fr: "1.1fr",
head: <span role="columnheader">СТОИМОСТЬ</span>,
cell: (r) => (
<span role="cell" style={monoInk}>
{r.price}
</span>
),
empty: (r) => isDash(r.price),
},
{
key: "dist",
fr: ".6fr",
head: <span role="columnheader">ДИСТ.</span>,
cell: (r) => (
<span role="cell" style={monoMuted2}>
{r.dist ?? "—"}
</span>
),
empty: (r) => isDash(r.dist),
},
{
key: "link",
fr: ".9fr",
// M11 — visually-empty header in the design, labelled for screen readers.
head: <span role="columnheader" aria-label="Ссылка на оригинал" />,
// role="cell" sits on a display:contents wrapper so the <a> stays the grid
// item (identical layout) AND keeps its link role inside the cell.
cell: (r) => {
// safeUrl gates href against javascript:/data: schemes (XSS). url is
// undefined for the design fixtures (→ "Росреестр ↗" fallback, keeps the
// storybook markup pixel-identical), null for a real row with no link
// (→ "—"), and a string when a real listing URL is available.
const href = typeof r.url === "string" ? safeUrl(r.url) : null;
return (
<span role="cell" style={{ display: "contents" }}>
{href ? (
<a
className="sv-rr"
href={href}
target="_blank"
rel="noopener noreferrer"
style={linkBtn}
>
Источник
</a>
) : r.url === null ? (
<span style={monoMuted2}></span>
) : (
<span className="sv-rr" style={linkBtn}>
Росреестр
</span>
)}
</span>
);
},
empty: () => false,
},
];
const DEAL_COLS: Col<DealRowData>[] = [
{
key: "addr",
fr: "2fr",
head: <span role="columnheader">АДРЕС</span>,
cell: (r) => (
<span role="cell" style={{ color: tokens.body }}>
{r.addr}
</span>
),
empty: (r) => isDash(r.addr),
},
{
key: "meta",
fr: "1.3fr",
head: <span role="columnheader">ПАРАМЕТРЫ</span>,
cell: (r) => (
<span role="cell" style={metaSpan}>
{r.meta}
</span>
),
empty: (r) => isDash(r.meta),
},
{
key: "source",
fr: "1.2fr",
head: <span role="columnheader">ИСТОЧНИК</span>,
cell: (r) => (
<span role="cell">
<span style={badge}>{r.source ?? "Росреестр"}</span>{" "}
<span style={{ fontSize: "9px", color: tokens.muted2 }}>
{r.tier ?? "по улице"}
</span>
</span>
),
empty: () => false,
},
{
key: "ppm",
fr: "1fr",
head: <span role="columnheader">/М²</span>,
cell: (r) => (
<span role="cell" style={monoMuted}>
{r.ppm}
</span>
),
empty: (r) => isDash(r.ppm),
},
{
key: "price",
fr: "1.1fr",
head: <span role="columnheader">ЦЕНА СДЕЛКИ</span>,
cell: (r) => (
<span role="cell" style={monoInk}>
{r.price}
</span>
),
empty: (r) => isDash(r.price),
},
{
key: "date",
fr: "1fr",
head: <span role="columnheader">ДАТА</span>,
cell: (r) => (
<span role="cell" style={monoMuted2}>
{r.date ?? "—"}
</span>
),
empty: (r) => isDash(r.date),
},
];
function KpiCell({ function KpiCell({
label, label,
value, value,
@ -174,6 +397,44 @@ function KpiCell({
); );
} }
/** Header row + body rows wrapped in role="table"; columns are pre-filtered. */
function DataTable<R>({
label,
cols,
rows,
grid,
}: {
label: string;
cols: Col<R>[];
rows: R[];
grid: string;
}) {
return (
<div role="table" aria-label={label}>
<div
role="row"
style={{ ...tableHead, display: "grid", gridTemplateColumns: grid }}
>
{cols.map((c) => (
<Fragment key={c.key}>{c.head}</Fragment>
))}
</div>
{rows.map((r, i) => (
<div
key={i}
role="row"
className="sv-row"
style={{ ...tableRow, display: "grid", gridTemplateColumns: grid }}
>
{cols.map((c) => (
<Fragment key={c.key}>{c.cell(r, i)}</Fragment>
))}
</div>
))}
</div>
);
}
// Default presentation data (unwired/storybook usage): the existing design // Default presentation data (unwired/storybook usage): the existing design
// fixtures. AdRow[]/DealRow[] satisfy AdRowData[]/DealRowData[] (extras optional). // fixtures. AdRow[]/DealRow[] satisfy AdRowData[]/DealRowData[] (extras optional).
const SOURCES_FIXTURE: SourcesData = { const SOURCES_FIXTURE: SourcesData = {
@ -190,6 +451,11 @@ interface SourcesViewProps {
export default function SourcesView({ export default function SourcesView({
data = SOURCES_FIXTURE, data = SOURCES_FIXTURE,
}: SourcesViewProps) { }: SourcesViewProps) {
const adCols = visibleCols(AD_COLS, data.adRows);
const adGrid = adCols.map((c) => c.fr).join(" ");
const dealCols = visibleCols(DEAL_COLS, data.dealRows);
const dealGrid = dealCols.map((c) => c.fr).join(" ");
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}> <div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
<style>{` <style>{`
@ -260,66 +526,12 @@ export default function SourcesView({
))} ))}
</div> </div>
<div <DataTable
style={{ label="Объявления — аналогичные квартиры в продаже"
...tableHead, cols={adCols}
display: "grid", rows={data.adRows}
gridTemplateColumns: AD_GRID, grid={adGrid}
}} />
>
<span>АДРЕС</span>
<span>ПАРАМЕТРЫ</span>
<span>ИСТОЧНИК</span>
<span>/М²</span>
<span>СТОИМОСТЬ</span>
<span>ДИСТ.</span>
<span />
</div>
{data.adRows.map((r, i) => {
// safeUrl gates href against javascript:/data: schemes (XSS). url is
// undefined for the design fixtures (→ "Росреестр ↗" fallback, keeps
// the storybook markup pixel-identical), null for a real row with no
// link (→ "—"), and a string when a real listing URL is available.
const href = typeof r.url === "string" ? safeUrl(r.url) : null;
return (
<div
key={i}
className="sv-row"
style={{
...tableRow,
display: "grid",
gridTemplateColumns: AD_GRID,
}}
>
<span style={{ color: tokens.body }}>{r.addr}</span>
<span style={metaSpan}>{r.meta}</span>
<span>
<span style={badge}>{r.source ?? "Avito"}</span>
</span>
<span style={monoMuted}>{r.ppm}</span>
<span style={monoInk}>{r.price}</span>
<span style={monoMuted2}>{r.dist ?? "—"}</span>
{href ? (
<a
className="sv-rr"
href={href}
target="_blank"
rel="noopener noreferrer"
style={linkBtn}
>
Источник
</a>
) : r.url === null ? (
<span style={monoMuted2}></span>
) : (
<span className="sv-rr" style={linkBtn}>
Росреестр
</span>
)}
</div>
);
})}
</div> </div>
{/* фактические сделки */} {/* фактические сделки */}
@ -359,44 +571,12 @@ export default function SourcesView({
/> />
</div> </div>
<div <DataTable
style={{ label="Фактические сделки по аналогичным квартирам"
...tableHead, cols={dealCols}
display: "grid", rows={data.dealRows}
gridTemplateColumns: DEAL_GRID, grid={dealGrid}
}} />
>
<span>АДРЕС</span>
<span>ПАРАМЕТРЫ</span>
<span>ИСТОЧНИК</span>
<span>/М²</span>
<span>ЦЕНА СДЕЛКИ</span>
<span>ДАТА</span>
</div>
{data.dealRows.map((r, i) => (
<div
key={i}
className="sv-row"
style={{
...tableRow,
display: "grid",
gridTemplateColumns: DEAL_GRID,
}}
>
<span style={{ color: tokens.body }}>{r.addr}</span>
<span style={metaSpan}>{r.meta}</span>
<span>
<span style={badge}>{r.source ?? "Росреестр"}</span>{" "}
<span style={{ fontSize: "9px", color: tokens.muted2 }}>
{r.tier ?? "по улице"}
</span>
</span>
<span style={monoMuted}>{r.ppm}</span>
<span style={monoInk}>{r.price}</span>
<span style={monoMuted2}>{r.date ?? "—"}</span>
</div>
))}
<div <div
style={{ style={{

View file

@ -375,12 +375,12 @@ export default function TopNav({
cx="7.5" cx="7.5"
cy="5" cy="5"
r="3" r="3"
stroke="#6f8195" stroke={tokens.muted}
strokeWidth="1.2" strokeWidth="1.2"
/> />
<path <path
d="M2 13c0-3 2.5-4.5 5.5-4.5S13 10 13 13" d="M2 13c0-3 2.5-4.5 5.5-4.5S13 10 13 13"
stroke="#6f8195" stroke={tokens.muted}
strokeWidth="1.2" strokeWidth="1.2"
/> />
</svg> </svg>
@ -391,10 +391,10 @@ export default function TopNav({
<svg width="15" height="15" viewBox="0 0 15 15" fill="none"> <svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<path <path
d="M3 1.5h6l3 3v9H3z" d="M3 1.5h6l3 3v9H3z"
stroke="#6f8195" stroke={tokens.muted}
strokeWidth="1.2" strokeWidth="1.2"
/> />
<path d="M9 1.5v3h3" stroke="#6f8195" strokeWidth="1.2" /> <path d="M9 1.5v3h3" stroke={tokens.muted} strokeWidth="1.2" />
</svg> </svg>
Мои отчёты{" "} Мои отчёты{" "}
<span <span
@ -415,12 +415,12 @@ export default function TopNav({
cx="7.5" cx="7.5"
cy="7.5" cy="7.5"
r="2.3" r="2.3"
stroke="#6f8195" stroke={tokens.muted}
strokeWidth="1.2" strokeWidth="1.2"
/> />
<path <path
d="M7.5 1v2M7.5 12v2M1 7.5h2M12 7.5h2" d="M7.5 1v2M7.5 12v2M1 7.5h2M12 7.5h2"
stroke="#6f8195" stroke={tokens.muted}
strokeWidth="1.2" strokeWidth="1.2"
/> />
</svg> </svg>
@ -433,12 +433,12 @@ export default function TopNav({
cx="7.5" cx="7.5"
cy="7.5" cy="7.5"
r="6" r="6"
stroke="#6f8195" stroke={tokens.muted}
strokeWidth="1.2" strokeWidth="1.2"
/> />
<path <path
d="M5.8 5.8a1.7 1.7 0 1 1 2.4 2.1c-.5.4-.7.7-.7 1.3M7.5 11.2v.2" d="M5.8 5.8a1.7 1.7 0 1 1 2.4 2.1c-.5.4-.7.7-.7 1.3M7.5 11.2v.2"
stroke="#6f8195" stroke={tokens.muted}
strokeWidth="1.2" strokeWidth="1.2"
/> />
</svg> </svg>

View file

@ -444,7 +444,15 @@ function parseAddress(full: string | null): { address: string; city: string } {
.find((p) => HOUSE_RE.test(p)); .find((p) => HOUSE_RE.test(p));
address = house ? `${street}, ${house}` : street; address = house ? `${street}, ${house}` : street;
} else { } else {
address = parts[0]; // No street keyword (prefix-less address, e.g. "Белинского, 86"). Don't
// silently drop the house number: pair a house token with its preceding
// street name; otherwise keep the full comma-joined string rather than
// only parts[0] (which would lose the house).
const houseIdx = parts.findIndex((p) => HOUSE_RE.test(houseClean(p)));
address =
houseIdx > 0
? `${parts[houseIdx - 1]}, ${houseClean(parts[houseIdx])}`
: parts.join(", ");
} }
return { address, city }; return { address, city };
} }
@ -743,6 +751,11 @@ const MAP_CLAMP_HI = 96;
// don't stack into an unreadable blob on the small map. // don't stack into an unreadable blob on the small map.
const MIN_PIN_DIST = 22; const MIN_PIN_DIST = 22;
const KEEP_CAP = 3; const KEEP_CAP = 3;
// M10: the subject projects to the map centre (lat==tLat, lon==tLon → 50%,50%),
// where ParamsPanel draws the subject pin. Reserve that spot so no analog price
// pin stacks under the subject's address caption (which made the subject look
// labelled with an analog's area/price).
const SUBJECT_POS = { left: 50, top: 50 };
/** Analog price pins for the 01 ParamsPanel map. null/empty/un-geocoded → []. */ /** Analog price pins for the 01 ParamsPanel map. null/empty/un-geocoded → []. */
export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] { export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] {
@ -790,6 +803,11 @@ export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] {
MAP_CLAMP_LO, MAP_CLAMP_LO,
MAP_CLAMP_HI, MAP_CLAMP_HI,
); );
// Drop analogs that land on the subject pin (centre) — otherwise the closest
// analog's "21,2 млн / 80 м²" reads as the subject's caption (M10).
const onSubject =
Math.hypot(SUBJECT_POS.left - left, SUBJECT_POS.top - top) < MIN_PIN_DIST;
if (onSubject) continue;
const overlaps = kept.some( const overlaps = kept.some(
(k) => Math.hypot(k.left - left, k.top - top) < MIN_PIN_DIST, (k) => Math.hypot(k.left - left, k.top - top) < MIN_PIN_DIST,
); );
@ -1663,8 +1681,25 @@ function buildAdFilters(e: AggregatedEstimate | null): string[] {
: String(Math.ceil(maxKm)); : String(Math.ceil(maxKm));
filters.push(`Расстояние ≤ ${km}${NBSP}км`); filters.push(`Расстояние ≤ ${km}${NBSP}км`);
} }
if (e.rooms != null) { // Planning chip — derived from the rooms ACTUALLY present in the rendered
filters.push(`Планировка ${e.rooms <= 0 ? "студия" : `${e.rooms}`}`); // analog set (same source as the distance/area chips), NOT from the subject's
// e.rooms. Analogs are selected by area band + geo proximity, NOT by room
// count, so the set may span several planning types. Only claim a concrete
// "N-к" filter when the analogs are genuinely homogeneous; otherwise stay
// neutral ("все планировки") so the chip never asserts a filter that was not
// applied to the data.
const analogRooms = Array.from(
new Set(
e.analogs
.map((a) => a.rooms)
.filter((r): r is number => r != null && Number.isFinite(r)),
),
);
if (analogRooms.length === 1) {
const r = analogRooms[0];
filters.push(`Планировка ${r <= 0 ? "студия" : `${r}`}`);
} else if (analogRooms.length > 1) {
filters.push("Все планировки");
} }
const areas = e.analogs const areas = e.analogs
.map((a) => a.area_m2) .map((a) => a.area_m2)

View file

@ -23,12 +23,19 @@ export const tokens = {
body: "#39506a", body: "#39506a",
body2: "#46586c", body2: "#46586c",
// Muted text (light → lighter) // Muted text (more prominent → quieter). Darkened to clear WCAG AA (≥4.5:1)
muted: "#6f8195", // against the worst-case (darkest) canvas stop #e6eef7 — the artboard bg is a
muted2: "#91a1b4", // light gradient #f4f8fc→#e6eef7 plus a faint blueprint grid, so micro-labels
muted3: "#9aaabb", // (911px, further shrunk ~0.75× by scale-fit) were failing AA badly
muted4: "#b3c0cf", // (audit #2081 C1: muted2 2.5:1, muted4 1.7:1). Ratios below are vs #e6eef7;
hint: "#8a9aac", // small helper / caption text // they are higher over glass surfaces and the lighter gradient stops. Primary
// labels should use body2/ink2 (6+:1) — these muted stops are for genuinely
// secondary / hint text only. HSV hue+saturation preserved from the originals.
muted: "#586777", // 4.95:1 — most prominent muted (labels, ₽/м², «КАК РАССЧИТАНО»)
muted2: "#5f6a76", // 4.71:1 — card titles / form field captions
muted3: "#626c77", // 4.56:1 — eyebrows («ИТОГИ ПО РАЗДЕЛАМ»), «нет данных»
muted4: "#656c75", // 4.54:1 — true hints only («опц.»/«скоро»/version)
hint: "#5f6b77", // 4.65:1 — small helper / caption text
// Borders & hairlines (strong → soft) // Borders & hairlines (strong → soft)
line: "#c2d3e3", line: "#c2d3e3",