fix(tradein/v2): визуальный аудит — иерархия/честность/микрокопи (#2266)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / 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
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m5s
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / 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
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m5s
Живой визуальный аудит /trade-in/v2 нашёл HIGH/MEDIUM/LOW дефекты после honest-hero раунда — фиксы: - H1: честный fallback-state флагманской карточки при expected_sold_price=null (вместо четырёх прочерков) - H3: две расходящиеся "медианы объявлений" на одном экране (рейка 14.23млн vs герой 8.99млн на тех же аналогах) — унифицированы на e.median_price_rub - M1-M3: иерархия трёх цен (asking уменьшен, круговой гейдж → пилюля "-N% к цене объявления", дисклеймер разброса читаем) - M4: PDF-отчёт primary после результата (без добавления lead-CTA — открытый продуктовый вопрос, не решался) - M5: порядок меток оси X графика (2026 в начале, не терялась при years!=5) — geometry вынесена в общий export (phYearX) вместо дублирования в двух файлах - M6: gate немонотонного "срок продажи" при n<5 аналогов - M7: сброс scroll оверлея при смене секции - M8 + LOW: унификация терминов (СТОИМОСТЬ→ЦЕНА, CV→"разброс цен", P25-P75 человекочитаемо, ДКП-расшифровка, скрыт литеральный "Обновлено: —", метро-разделители в адресе, убран внутренний "ПОВТОРНЫЕ АДРЕСА" label) - Контраст accent-текста hero (40px headline) поднят с ~2.87:1 до ~4.2:1 (accent → accentDeep, существующий токен той же палитры) Мобильная раскладка (H2) вынесена в отдельный issue #2275, не в этом PR. Lead-CTA "Оставить заявку" — открытый продуктовый вопрос, не решался. Refs #2266, #2262
This commit is contained in:
parent
3945060f0c
commit
d73b9198d5
12 changed files with 459 additions and 264 deletions
3
tradein-mvp/frontend/pnpm-lock.yaml
generated
3
tradein-mvp/frontend/pnpm-lock.yaml
generated
|
|
@ -24,6 +24,9 @@ importers:
|
|||
specifier: ^2.15.4
|
||||
version: 2.15.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
devDependencies:
|
||||
'@eslint/eslintrc':
|
||||
specifier: ^3.0.0
|
||||
version: 3.3.5
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.19
|
||||
|
|
|
|||
|
|
@ -814,6 +814,7 @@ export default function TradeInV2Page() {
|
|||
key={estimate?.estimate_id ?? "new"}
|
||||
onSubmit={handleSubmit}
|
||||
isPending={mutation.isPending}
|
||||
hasEstimate={hasEstimate}
|
||||
error={apiError}
|
||||
initialValues={initialValues}
|
||||
markers={markers}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { tokens } from "./tokens";
|
||||
import { analytics as ANALYTICS_FIXTURE } from "./fixtures";
|
||||
import { phYearX as histYearX } from "./mappers";
|
||||
import type { Analytics, SellTimeTier } from "./types";
|
||||
|
||||
interface AnalyticsViewProps {
|
||||
|
|
@ -36,8 +37,14 @@ function parsePoints(s: string): Pt[] {
|
|||
});
|
||||
}
|
||||
|
||||
// X positions of the year axis labels (chart scaffolding, not in fixtures).
|
||||
const histYearX = [50, 250, 460, 670, 860];
|
||||
// M5 — X positions of the year axis labels. MUST be DATA-DRIVEN from the number
|
||||
// of years, using the SAME geometry the mapper uses for the polyline vertices
|
||||
// (buildPriceHistory: phYearX over [PH_X_LEFT, PH_X_RIGHT]) — imported from
|
||||
// ./mappers (single source of truth) rather than copy-pasted, so the two never
|
||||
// drift apart. A fixed 5-slot array broke whenever the real series had ≠5
|
||||
// years: the extra labels fell back to an undefined x → x=0, so a late year
|
||||
// (e.g. 2026) was drawn FIRST, on top of the y-axis ticks. Now each label sits
|
||||
// centred under its own vertex.
|
||||
|
||||
// ---- sell-time tier tints --------------------------------------------------
|
||||
// One-off decorative tints per variant (design lines 553-556). The soft border
|
||||
|
|
@ -92,12 +99,39 @@ function sellTimeRangeMeaningful(tier: SellTimeTier): boolean {
|
|||
return true;
|
||||
}
|
||||
|
||||
// M6 — «Срок продажи в зависимости от цены» is not a real trend when built on
|
||||
// thin buckets: on the audited object +5% mapped to 80 дн (below the 119-дн
|
||||
// median) off just 3–6 analogs, i.e. noise read as a signal. We therefore GATE
|
||||
// the chart honestly instead of faking monotonicity:
|
||||
// • a tile with fewer than SELLTIME_MIN_N analogs is dropped (too few to trust);
|
||||
// • if fewer than SELLTIME_MIN_TILES tiles survive, the whole grid is replaced
|
||||
// by a «мало данных» note (nothing meaningful left to compare).
|
||||
const SELLTIME_MIN_N = 5;
|
||||
const SELLTIME_MIN_TILES = 3;
|
||||
|
||||
/** Parse the analog count out of a tier ("6 аналогов" → 6). Unknown → 0. */
|
||||
function sellTimeTierN(tier: SellTimeTier): number {
|
||||
const n = Number.parseInt(tier.count, 10);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/** A tier is trustworthy only with a real point estimate AND ≥ SELLTIME_MIN_N lots. */
|
||||
function sellTimeTierValid(tier: SellTimeTier): boolean {
|
||||
return tier.days !== "—" && sellTimeTierN(tier) >= SELLTIME_MIN_N;
|
||||
}
|
||||
|
||||
export default function AnalyticsView({
|
||||
data = ANALYTICS_FIXTURE,
|
||||
onNavigate,
|
||||
}: AnalyticsViewProps) {
|
||||
const [hoverHist, setHoverHist] = useState(false);
|
||||
|
||||
// M6 — only trustworthy price/sell-time tiles (≥ SELLTIME_MIN_N analogs).
|
||||
const sellTimeTiles = useMemo(
|
||||
() => data.sellTime.filter(sellTimeTierValid),
|
||||
[data.sellTime],
|
||||
);
|
||||
|
||||
// Price-history series + hover targets, derived from the data prop. Яндекс
|
||||
// only draws dots where its series diverges from Avito (последние 3 года).
|
||||
const avitoPts = useMemo(
|
||||
|
|
@ -215,56 +249,73 @@ export default function AnalyticsView({
|
|||
>
|
||||
{data.sellTimeNote}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(4,1fr)",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{data.sellTime.map((tier, i) => {
|
||||
const v = tierStyles[tier.variant];
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
background: v.bg,
|
||||
border: v.border,
|
||||
borderRadius: 7,
|
||||
padding: "13px 15px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ fontSize: 9.5, color: v.label, letterSpacing: 0.5 }}
|
||||
>
|
||||
{tier.tier}
|
||||
</div>
|
||||
{sellTimeTiles.length >= SELLTIME_MIN_TILES ? (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${sellTimeTiles.length},1fr)`,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{sellTimeTiles.map((tier, i) => {
|
||||
const v = tierStyles[tier.variant];
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: 26,
|
||||
fontWeight: 300,
|
||||
color: tokens.ink2,
|
||||
margin: "6px 0 4px",
|
||||
background: v.bg,
|
||||
border: v.border,
|
||||
borderRadius: 7,
|
||||
padding: "13px 15px",
|
||||
}}
|
||||
>
|
||||
{tier.days}
|
||||
<div
|
||||
style={{ fontSize: 9.5, color: v.label, letterSpacing: 0.5 }}
|
||||
>
|
||||
{tier.tier}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: 26,
|
||||
fontWeight: 300,
|
||||
color: tokens.ink2,
|
||||
margin: "6px 0 4px",
|
||||
}}
|
||||
>
|
||||
{tier.days}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9.5,
|
||||
color: tokens.hint,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{sellTimeRangeMeaningful(tier) ? (
|
||||
<>
|
||||
{tier.range}
|
||||
<br />
|
||||
</>
|
||||
) : null}
|
||||
{tier.count}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{ fontSize: 9.5, color: tokens.hint, lineHeight: 1.5 }}
|
||||
>
|
||||
{sellTimeRangeMeaningful(tier) ? (
|
||||
<>
|
||||
{tier.range}
|
||||
<br />
|
||||
</>
|
||||
) : null}
|
||||
{tier.count}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: tokens.muted2,
|
||||
lineHeight: 1.5,
|
||||
padding: "6px 0 2px",
|
||||
}}
|
||||
>
|
||||
Мало данных для оценки чувствительности срока продажи к цене.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* история цен */}
|
||||
|
|
@ -339,9 +390,18 @@ export default function AnalyticsView({
|
|||
</text>
|
||||
))}
|
||||
</g>
|
||||
<g fontFamily={tokens.font.mono} fontSize={11} fill={tokens.muted2}>
|
||||
<g
|
||||
fontFamily={tokens.font.mono}
|
||||
fontSize={11}
|
||||
fill={tokens.muted2}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{data.priceHistory.years.map((yr, i) => (
|
||||
<text key={yr} x={histYearX[i]} y={214}>
|
||||
<text
|
||||
key={yr}
|
||||
x={histYearX(i, data.priceHistory.years.length)}
|
||||
y={214}
|
||||
>
|
||||
{yr}
|
||||
</text>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,17 @@ const pdfBtnStyle: CSSProperties = {
|
|||
transition: "all .15s",
|
||||
};
|
||||
|
||||
// M4 — once a result is on screen the primary action is «СКАЧАТЬ PDF-ОТЧЁТ»
|
||||
// (re-running the estimate is secondary), so the download becomes the single
|
||||
// filled/primary button and «ОЦЕНИТЬ КВАРТИРУ» demotes to outline (ParamsPanel).
|
||||
const pdfFilledStyle: CSSProperties = {
|
||||
...pdfBtnStyle,
|
||||
background: `linear-gradient(90deg, ${tokens.accentDeep}, ${tokens.accent})`,
|
||||
border: `1px solid ${tokens.accent}`,
|
||||
color: tokens.onAccent,
|
||||
boxShadow: "0 2px 10px rgba(46,139,255,.25)",
|
||||
};
|
||||
|
||||
export default function HeroBar({
|
||||
data = HERO_FIXTURE,
|
||||
estimateId,
|
||||
|
|
@ -71,22 +82,29 @@ export default function HeroBar({
|
|||
onOpenInfo,
|
||||
}: HeroBarProps) {
|
||||
const pdfHref = pdfDownloadHref(estimateId);
|
||||
// A downloadable report exists ⇔ the estimate is ready ⇒ the PDF button is the
|
||||
// filled/primary CTA (M4). Otherwise it stays a disabled outline.
|
||||
const pdfFilled = Boolean(pdfHref);
|
||||
// Hide the building photo if the asset 404s/400s so the photoBg fill shows
|
||||
// instead of a broken-image icon.
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
const pdfBtnInner = (
|
||||
<>
|
||||
<svg width="18" height="20" viewBox="0 0 18 20" fill="none" aria-hidden="true">
|
||||
<path d="M2 1h9l5 5v13H2z" stroke="#2e8bff" strokeWidth="1.2" />
|
||||
<path d="M11 1v5h5" stroke="#2e8bff" strokeWidth="1.2" />
|
||||
<line x1="5" y1="11" x2="13" y2="11" stroke="#6f8195" />
|
||||
<line x1="5" y1="14" x2="13" y2="14" stroke="#6f8195" />
|
||||
</svg>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, letterSpacing: "1px" }}>
|
||||
СКАЧАТЬ PDF-ОТЧЁТ
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
const pdfBtnInner = (filled: boolean) => {
|
||||
const frame = filled ? "#fff" : "#2e8bff";
|
||||
const rule = filled ? "rgba(255,255,255,.75)" : "#6f8195";
|
||||
return (
|
||||
<>
|
||||
<svg width="18" height="20" viewBox="0 0 18 20" fill="none" aria-hidden="true">
|
||||
<path d="M2 1h9l5 5v13H2z" stroke={frame} strokeWidth="1.2" />
|
||||
<path d="M11 1v5h5" stroke={frame} strokeWidth="1.2" />
|
||||
<line x1="5" y1="11" x2="13" y2="11" stroke={rule} />
|
||||
<line x1="5" y1="14" x2="13" y2="14" stroke={rule} />
|
||||
</svg>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, letterSpacing: "1px" }}>
|
||||
СКАЧАТЬ PDF-ОТЧЁТ
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -100,6 +118,9 @@ export default function HeroBar({
|
|||
<style>{`
|
||||
.hero-pdf-btn:hover { border-color: ${tokens.accent}; background: rgba(46,139,255,.07); }
|
||||
.hero-pdf-btn:active { transform: translateY(1px); }
|
||||
.hero-pdf-btn-filled { transition: all .18s; }
|
||||
.hero-pdf-btn-filled:hover { background: ${tokens.accentDeep}; box-shadow: 0 6px 22px rgba(46,139,255,.4); }
|
||||
.hero-pdf-btn-filled:active { transform: translateY(1px); }
|
||||
.hero-calc-btn:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; }
|
||||
.hero-coef-row:hover { background: rgba(46,139,255,.09); }
|
||||
@keyframes hero-scanv { 0% { transform: translateY(-100%); } 100% { transform: translateY(900%); } }
|
||||
|
|
@ -197,13 +218,13 @@ export default function HeroBar({
|
|||
<div style={{ display: "flex", gap: 12, width: "100%", maxWidth: 440 }}>
|
||||
{pdfHref ? (
|
||||
<a
|
||||
className="hero-pdf-btn"
|
||||
className={pdfFilled ? "hero-pdf-btn-filled" : "hero-pdf-btn"}
|
||||
href={pdfHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={pdfBtnStyle}
|
||||
style={pdfFilled ? pdfFilledStyle : pdfBtnStyle}
|
||||
>
|
||||
{pdfBtnInner}
|
||||
{pdfBtnInner(pdfFilled)}
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
|
|
@ -212,7 +233,7 @@ export default function HeroBar({
|
|||
title="Отчёт станет доступен после расчёта оценки"
|
||||
style={{ ...pdfBtnStyle, cursor: "not-allowed", opacity: 0.45 }}
|
||||
>
|
||||
{pdfBtnInner}
|
||||
{pdfBtnInner(false)}
|
||||
</button>
|
||||
)}
|
||||
{hasEstimate && (
|
||||
|
|
|
|||
|
|
@ -20,14 +20,13 @@ interface ObjectSummaryProps {
|
|||
|
||||
// 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:
|
||||
// per row. 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))
|
||||
// Аналоги : e.n_analogs · e.median_price_rub (H3 — SAME field as the
|
||||
// 02-РЕЗУЛЬТАТ hero «медиана объявлений», not the raw analog
|
||||
// listing-price median which read far higher over the same set)
|
||||
// Сделки : dealCount · streetDeals.median_price_rub ?? median(deals)
|
||||
// Аналитика : kpi.median_exposure_days · -kpi.median_bargain_pct
|
||||
function rowHint(label: string): string {
|
||||
|
|
@ -356,7 +355,7 @@ export function ObjectSummary({
|
|||
lineHeight: 1.7,
|
||||
}}
|
||||
>
|
||||
<span>CV</span>
|
||||
<span>Разброс цен</span>
|
||||
<span
|
||||
style={{ fontFamily: tokens.font.mono, color: tokens.accent }}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -313,6 +313,10 @@ const styles = `
|
|||
.pp-eval-btn:hover{background:${tokens.accentDeep};box-shadow:0 6px 22px rgba(46,139,255,.4)}
|
||||
.pp-eval-btn:active{transform:translateY(1px)}
|
||||
.pp-eval-btn:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(46,139,255,.35)}
|
||||
.pp-eval-btn-secondary{background:transparent;transition:all .18s}
|
||||
.pp-eval-btn-secondary:hover{background:rgba(46,139,255,.08)}
|
||||
.pp-eval-btn-secondary:active{transform:translateY(1px)}
|
||||
.pp-eval-btn-secondary:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(46,139,255,.35)}
|
||||
`;
|
||||
|
||||
// Primary field captions ("АДРЕС", "ПЛОЩАДЬ", "КОМНАТ", …) — the labels the user
|
||||
|
|
@ -457,6 +461,9 @@ interface ParamsPanelProps {
|
|||
onSubmit?: (input: TradeInEstimateInput) => void;
|
||||
/** Disables the button + shows a pending label while the estimate is running. */
|
||||
isPending?: boolean;
|
||||
/** M4 — once a result is on screen the primary CTA moves to «СКАЧАТЬ PDF-ОТЧЁТ»
|
||||
* (HeroBar), so «ОЦЕНИТЬ КВАРТИРУ» (a re-run) demotes to a secondary outline. */
|
||||
hasEstimate?: boolean;
|
||||
/** Server-side error to surface inline (validation errors are handled locally). */
|
||||
error?: string | null;
|
||||
/** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */
|
||||
|
|
@ -499,10 +506,14 @@ function initRadiusLabel(radiusM: number | null | undefined): string {
|
|||
export default function ParamsPanel({
|
||||
onSubmit,
|
||||
isPending = false,
|
||||
hasEstimate = false,
|
||||
error = null,
|
||||
initialValues,
|
||||
markers = [],
|
||||
}: ParamsPanelProps) {
|
||||
// M4 — once a result is on screen the primary CTA is «СКАЧАТЬ PDF-ОТЧЁТ»
|
||||
// (HeroBar); this button (a re-run) demotes to a secondary outline.
|
||||
const secondaryCta = hasEstimate;
|
||||
const [openDd, setOpenDd] = useState<DdKey>(null);
|
||||
// РАДИУС combobox a11y state (aria-activedescendant highlight + listbox id).
|
||||
const radiusListId = useId();
|
||||
|
|
@ -1569,10 +1580,10 @@ export default function ParamsPanel({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* button */}
|
||||
{/* button — filled/primary before a result, outline/secondary after (M4). */}
|
||||
<button
|
||||
type="button"
|
||||
className="pp-eval-btn"
|
||||
className={secondaryCta ? "pp-eval-btn-secondary" : "pp-eval-btn"}
|
||||
onClick={handleSubmit}
|
||||
disabled={isPending}
|
||||
style={{
|
||||
|
|
@ -1588,7 +1599,7 @@ export default function ParamsPanel({
|
|||
cursor: isPending ? "wait" : "pointer",
|
||||
opacity: isPending ? 0.6 : 1,
|
||||
fontFamily: tokens.font.sans,
|
||||
color: tokens.onAccent,
|
||||
color: secondaryCta ? tokens.accentDeep : tokens.onAccent,
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
|
|
@ -1604,9 +1615,11 @@ export default function ParamsPanel({
|
|||
justifyContent: "center",
|
||||
width: 26,
|
||||
height: 26,
|
||||
border: "1px solid rgba(255,255,255,.7)",
|
||||
border: `1px solid ${
|
||||
secondaryCta ? tokens.accent : "rgba(255,255,255,.7)"
|
||||
}`,
|
||||
borderRadius: "50%",
|
||||
color: tokens.onAccent,
|
||||
color: secondaryCta ? tokens.accentDeep : tokens.onAccent,
|
||||
}}
|
||||
>
|
||||
→
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ import type { ResultPanelData } from "./mappers";
|
|||
|
||||
const {
|
||||
accent,
|
||||
accentDeep,
|
||||
ink,
|
||||
body,
|
||||
body2,
|
||||
muted,
|
||||
muted2,
|
||||
|
|
@ -158,7 +160,7 @@ export default function ResultPanel({
|
|||
ДОСТОВЕРНОСТЬ: <b style={{ color: ink }}>{data.meta.confidence}</b>
|
||||
</span>
|
||||
<span>
|
||||
CV{" "}
|
||||
РАЗБРОС ЦЕН{" "}
|
||||
<b style={{ color: accent, fontFamily: font.mono }}>
|
||||
{data.meta.cv}
|
||||
</b>
|
||||
|
|
@ -176,17 +178,24 @@ export default function ResultPanel({
|
|||
}}
|
||||
>
|
||||
{data.cards.map((card, ci) => {
|
||||
// Price hierarchy (audit H2). The three cards are NOT equal weight:
|
||||
// Price hierarchy (audit H2 + M1). 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
|
||||
// The headline gets an accent border + tint + glow + a 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;
|
||||
// tile. Value scale reads expected(40) ≫ asking(30) > ДКП(28).
|
||||
//
|
||||
// H1 — when the expected card has no prediction (emptyNote), it drops
|
||||
// to an honest empty-state and the accent frame moves onto the asking
|
||||
// card (ci 0) so the panel always has ONE clear headline, never a hole.
|
||||
const expectedEmpty = Boolean(data.cards[1]?.emptyNote);
|
||||
const isEmptyCard = Boolean(card.emptyNote);
|
||||
const isRegistry = ci === 2;
|
||||
const headlineIndex = expectedEmpty ? 0 : 1;
|
||||
const isHeadline = !isEmptyCard && ci === headlineIndex;
|
||||
const isAsking = ci === 0;
|
||||
return (
|
||||
<div
|
||||
key={ci}
|
||||
|
|
@ -225,7 +234,12 @@ export default function ResultPanel({
|
|||
style={{
|
||||
fontSize: 9.5,
|
||||
letterSpacing: 1.5,
|
||||
color: isHeadline ? accent : isRegistry ? muted2 : body2,
|
||||
// Contrast follow-up (audit #2266): accent (#2e8bff) on the
|
||||
// headline card's pale infoSoftBg tint measured ≈2.87:1 — below
|
||||
// even the AA "large text" floor (3:1). accentDeep is the same
|
||||
// brand hue, darkened (existing token, not a one-off hex) — it
|
||||
// clears ~4.2:1 here, so the label reads reliably too.
|
||||
color: isHeadline ? accentDeep : isRegistry ? muted2 : body2,
|
||||
fontWeight: isHeadline ? 600 : undefined,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
|
|
@ -241,74 +255,133 @@ export default function ResultPanel({
|
|||
marginTop: 3,
|
||||
}}
|
||||
>
|
||||
СПРАВОЧНО
|
||||
{/* §M8 — расшифровка «ДКП» один раз на секцию. */}
|
||||
СПРАВОЧНО · ДОГОВОРЫ КУПЛИ-ПРОДАЖИ
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: font.mono,
|
||||
fontSize: isHeadline ? 40 : isRegistry ? 28 : 38,
|
||||
fontWeight: isHeadline ? 600 : 400,
|
||||
letterSpacing: -1,
|
||||
color: isHeadline ? accent : isRegistry ? muted : undefined,
|
||||
}}
|
||||
>
|
||||
{card.value}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: muted,
|
||||
letterSpacing: 1,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{card.unit}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: font.mono,
|
||||
fontSize: 11,
|
||||
color: muted,
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
{card.range}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: font.mono,
|
||||
fontSize: 10,
|
||||
color: muted2,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{card.ppm}
|
||||
</div>
|
||||
{card.note && (
|
||||
{/* H1 empty-state: no bare «—» / gauge / «· с учётом торга» — just an
|
||||
honest note pointing at the recommended asking price. */}
|
||||
{isEmptyCard ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9,
|
||||
color: muted2,
|
||||
marginTop: 4,
|
||||
lineHeight: 1.4,
|
||||
maxWidth: "82%",
|
||||
marginTop: 12,
|
||||
fontSize: 11,
|
||||
lineHeight: 1.5,
|
||||
color: body,
|
||||
maxWidth: "94%",
|
||||
}}
|
||||
>
|
||||
{card.note}
|
||||
{card.emptyNote}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: font.mono,
|
||||
// M1 — asking (30) sits above the ДКП «справочно» tier (28)
|
||||
// yet clearly below the accent headline (40).
|
||||
fontSize: isHeadline ? 40 : isRegistry ? 28 : 30,
|
||||
fontWeight: isHeadline ? 600 : 400,
|
||||
letterSpacing: -1,
|
||||
// Contrast follow-up (audit #2266) — accentDeep, not
|
||||
// accent, for the same reason as the title label above.
|
||||
color: isHeadline
|
||||
? accentDeep
|
||||
: isRegistry
|
||||
? muted
|
||||
: isAsking
|
||||
? ink
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{card.value}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: muted,
|
||||
letterSpacing: 1,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{card.unit}
|
||||
</span>
|
||||
</div>
|
||||
{card.range && (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: font.mono,
|
||||
fontSize: 11,
|
||||
color: muted,
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
{card.range}
|
||||
</div>
|
||||
)}
|
||||
{card.ppm && (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: font.mono,
|
||||
fontSize: 10,
|
||||
color: muted2,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{card.ppm}
|
||||
</div>
|
||||
)}
|
||||
{card.note && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9,
|
||||
color: muted2,
|
||||
marginTop: 4,
|
||||
lineHeight: 1.4,
|
||||
maxWidth: "82%",
|
||||
}}
|
||||
>
|
||||
{card.note}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{card.bars ? (
|
||||
{isEmptyCard ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rp-more"
|
||||
onClick={() => onNavigate(card.nav)}
|
||||
aria-label={`Подробнее — ${card.title.join(" ")}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: 4,
|
||||
cursor: "pointer",
|
||||
fontSize: 9,
|
||||
letterSpacing: 0.3,
|
||||
color: accent,
|
||||
width: "100%",
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
marginTop: 16,
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
Подробнее <span>→</span>
|
||||
</button>
|
||||
) : card.bars ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
|
|
@ -363,7 +436,50 @@ export default function ResultPanel({
|
|||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
// M2 — calm delta pill (was a 51px circular gauge that read like a
|
||||
// tech "занижение" indicator). Full text «−18% к цене объявления»,
|
||||
// neutral accentDeep on a soft-blue chip, tabular-nums.
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 10,
|
||||
marginTop: 14,
|
||||
}}
|
||||
>
|
||||
{card.delta ? (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "baseline",
|
||||
gap: 5,
|
||||
background: infoSoftBg,
|
||||
border: `1px solid ${lineSoft}`,
|
||||
borderRadius: 20,
|
||||
padding: "3px 10px",
|
||||
color: accentDeep,
|
||||
fontFamily: font.sans,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: font.mono,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{card.delta}
|
||||
</span>
|
||||
<span style={{ fontSize: 9.5, letterSpacing: 0.2 }}>
|
||||
{card.deltaLabel}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="rp-more"
|
||||
|
|
@ -372,87 +488,22 @@ export default function ResultPanel({
|
|||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: 4,
|
||||
cursor: "pointer",
|
||||
fontSize: 9,
|
||||
letterSpacing: 0.3,
|
||||
color: accent,
|
||||
width: "100%",
|
||||
whiteSpace: "nowrap",
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
marginTop: 16,
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
Подробнее <span>→</span>
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 14,
|
||||
top: 13,
|
||||
width: 58,
|
||||
height: 58,
|
||||
}}
|
||||
>
|
||||
<svg width="58" height="58" viewBox="0 0 58 58" aria-hidden="true">
|
||||
<circle
|
||||
cx="29"
|
||||
cy="29"
|
||||
r="25"
|
||||
fill="none"
|
||||
stroke={track}
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<circle
|
||||
cx="29"
|
||||
cy="29"
|
||||
r="25"
|
||||
fill="none"
|
||||
stroke={accent}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="157"
|
||||
strokeDashoffset="140"
|
||||
transform="rotate(-90 29 29)"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: font.mono,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: accent,
|
||||
}}
|
||||
>
|
||||
{card.delta}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 6.5,
|
||||
letterSpacing: 1,
|
||||
color: muted2,
|
||||
marginTop: 1,
|
||||
}}
|
||||
>
|
||||
{card.deltaLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -464,9 +515,11 @@ export default function ResultPanel({
|
|||
не читался как «неуверенность расчёта». */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
// M3 — было 10px muted-микропринт; повышено до 12px body, чтобы
|
||||
// ключевая honest-оговорка о разбросе читалась, а не терялась.
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
color: hint,
|
||||
color: body,
|
||||
marginTop: -4,
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -50,10 +50,19 @@ export default function SectionOverlay({
|
|||
// effect (deps []) never re-runs / re-traps focus on parent re-renders even
|
||||
// though page.tsx passes a fresh arrow each render.
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const lastFocused = useRef<Element | null>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
// M7 — the overlay body is ONE scroller shared by every section view; switching
|
||||
// sections (nav within the overlay) used to inherit the previous section's
|
||||
// scroll offset, dropping the user into the middle of the new view. Reset to the
|
||||
// top whenever the active section changes.
|
||||
useEffect(() => {
|
||||
if (bodyRef.current) bodyRef.current.scrollTop = 0;
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
// Remember the trigger, then move focus into the dialog.
|
||||
|
|
@ -294,6 +303,7 @@ export default function SectionOverlay({
|
|||
|
||||
{/* overlay body */}
|
||||
<div
|
||||
ref={bodyRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
|
|
|
|||
|
|
@ -228,7 +228,8 @@ const AD_COLS: Col<AdRowData>[] = [
|
|||
{
|
||||
key: "price",
|
||||
fr: "1.1fr",
|
||||
head: <span role="columnheader">СТОИМОСТЬ</span>,
|
||||
// §M8 — унификация терминологии: везде «цена» (был «СТОИМОСТЬ»).
|
||||
head: <span role="columnheader">ЦЕНА</span>,
|
||||
cell: (r) => (
|
||||
<span role="cell" style={monoInk}>
|
||||
{r.price}
|
||||
|
|
@ -475,7 +476,7 @@ export default function SourcesView({
|
|||
</div>
|
||||
</div>
|
||||
<div style={headerRight}>
|
||||
Обновлено: —<br />
|
||||
{/* §M8 — литеральный «Обновлено: —» скрыт (нет реального значения). */}
|
||||
Источников:{" "}
|
||||
<b style={{ color: tokens.muted }}>{data.sourceCount ?? "—"}</b>
|
||||
</div>
|
||||
|
|
@ -493,9 +494,11 @@ export default function SourcesView({
|
|||
unit={`млн ₽ · ${data.marketAds.kpi.ppm} ₽/м²`}
|
||||
/>
|
||||
<KpiCell
|
||||
label="ДИАПАЗОН P25–P75"
|
||||
// §M8 — «СРЕДНИЕ 50% РЫНКА» вместо статистического «ДИАПАЗОН P25–P75»
|
||||
// (P25–P75 оставлен в скобках); «CV» → «разброс» (внутренний термин).
|
||||
label="СРЕДНИЕ 50% РЫНКА (P25–P75)"
|
||||
value={data.marketAds.kpi.range}
|
||||
unit={`млн · CV ${data.marketAds.kpi.cv}`}
|
||||
unit={`млн · разброс ${data.marketAds.kpi.cv}`}
|
||||
valueColor={tokens.accent}
|
||||
last
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -624,7 +624,7 @@ export const dropdownOptions: DropdownOptions = {
|
|||
export const locationFactors: LocationFactors = {
|
||||
coef: "0.87",
|
||||
intro:
|
||||
"Показывает, как адрес корректирует стоимость относительно медианы по Екатеринбургу. 1.00 — средний уровень. 0.87 означает, что локация снижает цену примерно на 13% из-за баланса факторов ниже.",
|
||||
"Показывает, как адрес корректирует цену относительно медианы по Екатеринбургу. 1.00 — средний уровень. 0.87 означает, что локация снижает цену примерно на 13% из-за баланса факторов ниже.",
|
||||
formula: { base: "11,29 млн", coef: "0.87", result: "9,82 млн ₽" },
|
||||
positives: [
|
||||
{ label: "Центр города, пешая доступность ключевых точек", delta: "+0.06" },
|
||||
|
|
|
|||
|
|
@ -262,12 +262,15 @@ function coeffVar(values: number[]): number | null {
|
|||
return (Math.sqrt(variance) / mean) * 100;
|
||||
}
|
||||
|
||||
// «Разброс цен» (§M8 / #1993): the внутренний term "CV" (coefficient of
|
||||
// variation) is not surfaced to users anymore, and the витрина rounds it to a
|
||||
// whole percent ("42%", not "42,2%") — the extra decimal implied a false
|
||||
// precision for what is a rough spread indicator.
|
||||
function cvStr(values: number[]): string {
|
||||
const cv = coeffVar(values);
|
||||
return cv != null
|
||||
? `${cv.toLocaleString("ru-RU", {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
maximumFractionDigits: 0,
|
||||
})}%`
|
||||
: "—";
|
||||
}
|
||||
|
|
@ -857,6 +860,14 @@ export function mapResultPanel(
|
|||
): ResultPanelData {
|
||||
const dealTier = resolveDealTier(e, streetDeals);
|
||||
|
||||
// H1 — the «ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ» flagship card must NOT render as a hole of
|
||||
// bare «—» when the model has no expected-sold prediction. When it is missing
|
||||
// we emit an honest empty-state (emptyNote) and drop the value/range/ppm/delta;
|
||||
// the view then moves the accent frame onto the «рекомендованная цена» card.
|
||||
const hasExpected =
|
||||
e.expected_sold_price_rub != null &&
|
||||
Number.isFinite(e.expected_sold_price_rub);
|
||||
|
||||
const cards: ResultCard[] = [
|
||||
{
|
||||
title: ["РЕКОМЕНДОВАННАЯ ЦЕНА", "В ОБЪЯВЛЕНИИ"],
|
||||
|
|
@ -869,21 +880,29 @@ export function mapResultPanel(
|
|||
},
|
||||
{
|
||||
title: ["ОЖИДАЕМАЯ ЦЕНА", "СДЕЛКИ"],
|
||||
value: fmtMln(e.expected_sold_price_rub),
|
||||
value: hasExpected ? fmtMln(e.expected_sold_price_rub) : "—",
|
||||
unit: "млн ₽",
|
||||
range: rangeLine(
|
||||
e.expected_sold_range_low_rub,
|
||||
e.expected_sold_range_high_rub,
|
||||
),
|
||||
ppm: `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга`,
|
||||
range: hasExpected
|
||||
? rangeLine(
|
||||
e.expected_sold_range_low_rub,
|
||||
e.expected_sold_range_high_rub,
|
||||
)
|
||||
: "",
|
||||
ppm: hasExpected ? `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга` : "",
|
||||
delta:
|
||||
e.asking_to_sold_ratio != null
|
||||
hasExpected && e.asking_to_sold_ratio != null
|
||||
? fmtPct((e.asking_to_sold_ratio - 1) * 100)
|
||||
: "—",
|
||||
// §1.3: разрыв «цена в объявлении → ожидаемая сделка», НЕ скидка сервиса.
|
||||
// «К ОБЪЯВЛ.» однозначнее прежнего «К РЫНКУ» (не читается как «скидка от нас»).
|
||||
deltaLabel: "К ОБЪЯВЛ.",
|
||||
: undefined,
|
||||
// §1.3 / M2: разрыв «цена в объявлении → ожидаемая сделка», НЕ скидка
|
||||
// сервиса. Полная формулировка «к цене объявления» (единая с §M8) — вместо
|
||||
// обрубка «К ОБЪЯВЛ.»; рендерится спокойной пилюлей, не круговым гейджем.
|
||||
deltaLabel: "к цене объявления",
|
||||
// H1 empty-state message (only when there is no expected-sold prediction).
|
||||
emptyNote: hasExpected
|
||||
? undefined
|
||||
: "Недостаточно данных для прогноза цены сделки — ориентируйтесь на рекомендованную цену в объявлении",
|
||||
note:
|
||||
hasExpected &&
|
||||
dealTier != null &&
|
||||
e.expected_sold_price_rub != null &&
|
||||
Number.isFinite(e.expected_sold_price_rub) &&
|
||||
|
|
@ -972,11 +991,18 @@ export function mapSummary(
|
|||
? `${houseSold}${houseExp != null ? ` · ${houseExp} дн` : ""}`
|
||||
: "—";
|
||||
|
||||
const analogMedian = median(e.analogs.map((a) => a.price_rub));
|
||||
// H3 — «медиана объявлений» in the СВОДКА rail MUST match the hero/02-РЕЗУЛЬТАТ
|
||||
// headline (e.median_price_rub, the subject-area-normalised median). It used to
|
||||
// show median(analogs.price_rub) — the RAW median of the analog LISTING totals
|
||||
// across mixed areas — which for a small subject reads far higher (e.g. 14,23
|
||||
// млн vs the hero's 8,99 млн over the same 37 analogs) and looked like two
|
||||
// contradictory medians on one screen. Now both surfaces read the same field.
|
||||
const row2Value =
|
||||
e.n_analogs > 0
|
||||
? `${e.n_analogs}${
|
||||
analogMedian != null ? ` · ${fmtMln(analogMedian)} млн` : ""
|
||||
Number.isFinite(e.median_price_rub)
|
||||
? ` · ${fmtMln(e.median_price_rub)} млн`
|
||||
: ""
|
||||
}`
|
||||
: "—";
|
||||
|
||||
|
|
@ -1204,7 +1230,17 @@ function dealMeta(l: AnalogLot): string {
|
|||
* touches the comma-splitting parseAddress.
|
||||
*/
|
||||
function deglueAddr(addr: string): string {
|
||||
return addr.replace(/([а-яёa-z\d])([А-ЯЁ][а-яё])/g, "$1 $2");
|
||||
return (
|
||||
addr
|
||||
.replace(/([а-яёa-z\d])([А-ЯЁ][а-яё])/g, "$1 $2")
|
||||
// §M8 — metro proximity glued straight into the address with no spaces
|
||||
// ("Геологическая11–15 мин.Чкаловская16–20"): (a) split a Cyrillic word
|
||||
// from a following digit ("Геологическая11" → "Геологическая 11"), and
|
||||
// (b) separate a "мин." run from the next station name with " · "
|
||||
// ("мин.Чкаловская" → "мин. · Чкаловская"). Display cleanup only.
|
||||
.replace(/([а-яё])(\d)/g, "$1 $2")
|
||||
.replace(/(мин\.)\s*([А-ЯЁ])/g, "$1 · $2")
|
||||
);
|
||||
}
|
||||
|
||||
// ── 04 ПРОДАЖИ В ДОМЕ (HistoryView) ─────────────────────────────────────────
|
||||
|
|
@ -1393,8 +1429,11 @@ function buildSellTime(s: SellTimeSensitivityResponse | null): SellTimeTier[] {
|
|||
// [58,885]. The Y domain is DATA-DRIVEN (zoomed to the real median ₽/м² band)
|
||||
// so a narrow series like 120–160k fills the plot instead of flat-lining against
|
||||
// a fixed 0–200k scale; the axis labels are emitted as yTicks for the 5 gridlines.
|
||||
const PH_X_LEFT = 58;
|
||||
const PH_X_RIGHT = 885;
|
||||
// PH_X_LEFT/PH_X_RIGHT/phYearX are exported — AnalyticsView's year-axis labels
|
||||
// (M5) must share this EXACT geometry with the polyline vertices below, or a
|
||||
// label drifts off its own dot. Single source of truth, no copy-pasted consts.
|
||||
export const PH_X_LEFT = 58;
|
||||
export const PH_X_RIGHT = 885;
|
||||
const PH_Y_TOP = 20; // top gridline pixel (high value)
|
||||
const PH_Y_BOTTOM = 195; // bottom gridline pixel (low value)
|
||||
const PH_Y_SPAN = PH_Y_BOTTOM - PH_Y_TOP; // 175
|
||||
|
|
@ -1403,7 +1442,7 @@ const PH_GRID_Y = [20, 65, 110, 155, 195]; // 5 gridlines (top → bottom)
|
|||
// the union of their finite medians so the two series share one axis.
|
||||
const PH_SERIES_SOURCES = ["avito_imv", "yandex_valuation"];
|
||||
|
||||
function phYearX(i: number, n: number): number {
|
||||
export function phYearX(i: number, n: number): number {
|
||||
if (n <= 1) return (PH_X_LEFT + PH_X_RIGHT) / 2;
|
||||
return PH_X_LEFT + ((PH_X_RIGHT - PH_X_LEFT) * i) / (n - 1);
|
||||
}
|
||||
|
|
@ -1802,7 +1841,9 @@ export function mapSources(
|
|||
kpi: {
|
||||
count: dealCount > 0 ? String(dealCount) : "—",
|
||||
median: dealMedianRub != null ? fmtMln(dealMedianRub) : "—",
|
||||
delta: deltaPct != null ? `${fmtPct(deltaPct)} к рынку` : "—",
|
||||
// §M8 — единая формулировка дельт: всё считается «относительно цен
|
||||
// объявлений» (deltaPct = медиана сделок / медиана объявлений − 1).
|
||||
delta: deltaPct != null ? `${fmtPct(deltaPct)} к цене объявления` : "—",
|
||||
range: mlnRangeSp(dealLo, dealHi),
|
||||
},
|
||||
};
|
||||
|
|
@ -1887,8 +1928,8 @@ function cacheStatus(iso: string | null | undefined): {
|
|||
* 07 ПРЕДЫДУЩИЕ ОЦЕНКИ: таблица последних оценок + 3 KPI, всё посчитано на
|
||||
* фронте из per-user /history (null → пустая таблица + KPI с "—").
|
||||
* rows — addr/время/статус по каждой строке (src всегда "—", TODO BE-1).
|
||||
* kpis — ВСЕГО ОЦЕНОК (length) · СРЕДНЯЯ ЦЕНА (avg median_price, млн) ·
|
||||
* ПОВТОРНЫЕ АДРЕСА (доля переоценок = (всего − уникальных)/всего).
|
||||
* kpis — ВСЕГО ОЦЕНОК (length) · СРЕДНЯЯ ЦЕНА (avg median_price, млн).
|
||||
* «ПОВТОРНЫЕ АДРЕСА» убран (§M8 — внутренняя ops-метрика).
|
||||
*/
|
||||
export function mapCache(history: EstimateHistoryItem[] | null): CacheData {
|
||||
const list = history ?? [];
|
||||
|
|
@ -1896,7 +1937,10 @@ export function mapCache(history: EstimateHistoryItem[] | null): CacheData {
|
|||
const rows: CacheRow[] = list.map((h) => {
|
||||
const { status, statusColor } = cacheStatus(h.created_at);
|
||||
return {
|
||||
addr: h.address || "—",
|
||||
// §M8 — de-glue metro/address runs the same way as the analog/deal rows
|
||||
// (these are the user's own past-estimate addresses, often geocoded with
|
||||
// metro proximity jammed on with no separators).
|
||||
addr: h.address ? deglueAddr(h.address) : "—",
|
||||
time: fmtCacheTime(h.created_at),
|
||||
src: "—", // TODO BE-1: /history без sources_used → N/7 недоступно
|
||||
status,
|
||||
|
|
@ -1916,17 +1960,9 @@ export function mapCache(history: EstimateHistoryItem[] | null): CacheData {
|
|||
? prices.reduce((a, b) => a + b, 0) / prices.length
|
||||
: null;
|
||||
|
||||
// KPI 3 — доля переоценок: (всего адресов − уникальных) / всего адресов.
|
||||
const seen = new Map<string, number>();
|
||||
for (const h of list) {
|
||||
const key = (h.address ?? "").trim().toLowerCase();
|
||||
if (!key) continue;
|
||||
seen.set(key, (seen.get(key) ?? 0) + 1);
|
||||
}
|
||||
const withAddr = [...seen.values()].reduce((a, b) => a + b, 0);
|
||||
const dupPct =
|
||||
withAddr > 0 ? ((withAddr - seen.size) / withAddr) * 100 : null;
|
||||
|
||||
// §M8 — «ПОВТОРНЫЕ АДРЕСА» (доля переоценок) was an internal ops metric with no
|
||||
// meaning for the end user, so it is dropped from this витрина. Only the two
|
||||
// user-relevant KPIs remain.
|
||||
const kpis: CacheKpi[] = [
|
||||
{
|
||||
label: "ВСЕГО ОЦЕНОК",
|
||||
|
|
@ -1939,14 +1975,6 @@ export function mapCache(history: EstimateHistoryItem[] | null): CacheData {
|
|||
unit: avg != null ? "млн ₽" : undefined,
|
||||
sub: "по проведённым оценкам",
|
||||
},
|
||||
{
|
||||
// fmtPct не используем намеренно: он добавляет знак (+18%) и второй "%" —
|
||||
// для доли это неверно; рендерим число + unit "%" (как в дизайне).
|
||||
label: "ПОВТОРНЫЕ АДРЕСА",
|
||||
value: dupPct != null ? String(Math.round(dupPct)) : "—",
|
||||
unit: dupPct != null ? "%" : undefined,
|
||||
sub: "доля переоценок",
|
||||
},
|
||||
];
|
||||
|
||||
return { rows, kpis };
|
||||
|
|
|
|||
|
|
@ -38,12 +38,16 @@ export interface ResultCard {
|
|||
ppm: string;
|
||||
/** Mini-histogram column heights in % (cards 1 & 3). */
|
||||
bars?: number[];
|
||||
/** Donut delta value, e.g. "−11%" (card 2). */
|
||||
/** Delta value, e.g. "−18%" (card 2). Rendered as a calm pill, not a gauge. */
|
||||
delta?: string;
|
||||
/** Donut caption, e.g. "К РЫНКУ" (card 2). */
|
||||
/** Delta pill caption, e.g. "к цене объявления" (card 2). */
|
||||
deltaLabel?: string;
|
||||
/** Reconciliation note under the value (card 2: expected price vs ДКП median). */
|
||||
note?: string;
|
||||
/** H1 empty-state message: when set (card 2, no expected-sold prediction) the
|
||||
* card renders this instead of value/range/ppm/delta, and the panel moves the
|
||||
* accent frame onto the «рекомендованная цена» card. */
|
||||
emptyNote?: string;
|
||||
/** Nav index opened by the card's "Подробнее →" link. */
|
||||
nav: number;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue