fix(tradein/v2): UI/UX-аудит P1 quick-wins (#2062)
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

Честная полировка живого /trade-in/v2 без правок архитектуры (FE-only).

- Провенанс из data, без хардкодов: footer «ПОСТРОЕНО ПО N АНАЛОГАМ И M
  СДЕЛКАМ» из meta.builtOn; единый счётчик источников (activeSourceCount =
  залитые плитки) в шапке 02, СВОДКЕ и оверлее; убраны «6/10», «2/7»,
  «Avito·10», «из 2 источников».
- Единый ru-форматтер: запятая везде (CV/площадь/%), NBSP число+единица,
  один en-dash и одна точность для денежных диапазонов.
- CTA «ОЦЕНИТЬ КВАРТИРУ» → solid brand-blue + белый текст; :focus-visible
  HUD-glow на инпутах и кнопке.
- Лендмарки <nav>/<main>/<footer> (display:contents, 0 визуальных изменений);
  scale-fit артборда — на 1366×768 / 1440×900 ужимается, не режется.
- Мини-карта: честный зум (min=1, без пустых краёв при отдалении), убран
  мёртвый crosshair-двойник, расклеены налезающие метки цен.
- CRM-дропдаун явно задизейблен («скоро», без ▼); убран «ШАГ 1/1».
- Карточка 02 → «ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ · с учётом торга» + строка-примирение
  с медианой ДКП; КОЭФ.ЛОКАЦИИ → muted-пилюля «скоро».
- Бейдж СВОДКИ «В РАСЧЁТЕ» → «АКТУАЛЬНО»; скрыты целиком-прочерк колонки
  (ИСТОЧНИКОВ в 07, ОБЪЯВЛЕНИЕ ДО СДЕЛКИ в 04) data-driven.
This commit is contained in:
bot-backend 2026-06-28 19:33:12 +03:00
parent 83ae6f8a47
commit 30e158ad2a
10 changed files with 554 additions and 379 deletions

View file

@ -24,7 +24,11 @@ import { ObjectSummary } from "@/components/trade-in/v2/ObjectSummary";
import { Footer } from "@/components/trade-in/v2/Footer"; import { Footer } from "@/components/trade-in/v2/Footer";
import SectionOverlay from "@/components/trade-in/v2/SectionOverlay"; import SectionOverlay from "@/components/trade-in/v2/SectionOverlay";
import { LocationDrawer } from "@/components/trade-in/v2/LocationDrawer"; import { LocationDrawer } from "@/components/trade-in/v2/LocationDrawer";
import type { Analytics, ObjectInfo, Report } from "@/components/trade-in/v2/types"; import type {
Analytics,
ObjectInfo,
Report,
} from "@/components/trade-in/v2/types";
import type { import type {
CacheData, CacheData,
HistoryData, HistoryData,
@ -156,13 +160,7 @@ const retryBtnStyle: CSSProperties = {
// ── Result/summary area state panels (kept inside the page — these are layout // ── Result/summary area state panels (kept inside the page — these are layout
// states, not reusable design components, so they do not belong in v2/). ─── // states, not reusable design components, so they do not belong in v2/). ───
function SkeletonBlock({ function SkeletonBlock({ height, flex }: { height?: number; flex?: number }) {
height,
flex,
}: {
height?: number;
flex?: number;
}) {
return ( return (
<div <div
className="v2-skel" className="v2-skel"
@ -399,11 +397,25 @@ export default function TradeInV2Page() {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []); useEffect(() => setMounted(true), []);
// Scale-fit: shrink the fixed 1536×1024 artboard to fit smaller viewports
// instead of clipping. Never upscale (capped at 1); recomputed on resize.
const [artboardScale, setArtboardScale] = useState(1);
useEffect(() => {
const compute = () =>
setArtboardScale(
Math.min(1, window.innerWidth / 1536, window.innerHeight / 1024),
);
compute();
window.addEventListener("resize", compute);
return () => window.removeEventListener("resize", compute);
}, []);
const mutation = useEstimateMutation(); const mutation = useEstimateMutation();
// Skip the restore fetch once we already hold a fresh result. // Skip the restore fetch once we already hold a fresh result.
const restored = useEstimate(freshResult === null ? urlId : null); const restored = useEstimate(freshResult === null ? urlId : null);
const estimate: AggregatedEstimate | null = freshResult ?? restored.data ?? null; const estimate: AggregatedEstimate | null =
freshResult ?? restored.data ?? null;
const currentEstimateId = freshResult?.estimate_id ?? urlId; const currentEstimateId = freshResult?.estimate_id ?? urlId;
// Per-user side data, independent of the current estimate. Both fail-soft // Per-user side data, independent of the current estimate. Both fail-soft
@ -454,7 +466,8 @@ export default function TradeInV2Page() {
const loading = mutation.isPending || restoreLoading; const loading = mutation.isPending || restoreLoading;
const insufficient = const insufficient =
estimate != null && (estimate.insufficient_data || estimate.n_analogs === 0); estimate != null &&
(estimate.insufficient_data || estimate.n_analogs === 0);
const apiError = mutation.error?.message ?? null; const apiError = mutation.error?.message ?? null;
// ── Mapped presentation data (memoised so nav/drawer toggles don't recompute // ── Mapped presentation data (memoised so nav/drawer toggles don't recompute
@ -472,7 +485,8 @@ export default function TradeInV2Page() {
[estimate, streetDealsData], [estimate, streetDealsData],
); );
const summaryData = useMemo( const summaryData = useMemo(
() => (estimate ? mapSummary(estimate, analyticsData, streetDealsData) : null), () =>
estimate ? mapSummary(estimate, analyticsData, streetDealsData) : null,
[estimate, analyticsData, streetDealsData], [estimate, analyticsData, streetDealsData],
); );
@ -604,103 +618,116 @@ export default function TradeInV2Page() {
} }
return ( return (
<div <div style={{ width: 1536 * artboardScale, height: 1024 * artboardScale }}>
style={{
position: "relative",
width: 1536,
height: 1024,
overflow: "hidden",
background: tokens.gradientBg,
fontFamily: tokens.font.sans,
color: tokens.ink,
}}
>
<style>{HELPER_STYLES}</style>
{/* OUTER HUD FRAME */}
<div <div
style={{ style={{
position: "absolute", position: "relative",
inset: FRAME_INSET, width: 1536,
border: `1px solid ${tokens.line}`, height: 1024,
borderRadius: 4, overflow: "hidden",
clipPath: FRAME_CLIP, background: tokens.gradientBg,
pointerEvents: "none", fontFamily: tokens.font.sans,
}} color: tokens.ink,
/> transform: `scale(${artboardScale})`,
{/* 4 corner brackets */} transformOrigin: "top left",
{brackets.map((b) => (
<div
key={b.key}
style={{
position: "absolute",
width: BRACKET_ARM,
height: BRACKET_ARM,
pointerEvents: "none",
...b.style,
}}
/>
))}
{/* CONTENT WRAP */}
<div
style={{
position: "absolute",
inset: 14,
padding: "16px 28px",
display: "flex",
flexDirection: "column",
}} }}
> >
<TopNav <style>{HELPER_STYLES}</style>
active={nav}
onNavigate={setNav}
reports={reportsCount ?? 0}
user={topNavUser}
onLogout={logout}
/>
<HeroBar
data={{ report, object: objectInfo }}
estimateId={mounted ? currentEstimateId : null}
onOpenInfo={() => setDrawerOpen(true)}
/>
{/* OUTER HUD FRAME */}
<div <div
style={{ style={{
display: "grid", position: "absolute",
gridTemplateColumns: "474px 1fr 250px", inset: FRAME_INSET,
gap: 18, border: `1px solid ${tokens.line}`,
flex: 1, borderRadius: 4,
minHeight: 0, clipPath: FRAME_CLIP,
pointerEvents: "none",
}}
/>
{/* 4 corner brackets */}
{brackets.map((b) => (
<div
key={b.key}
style={{
position: "absolute",
width: BRACKET_ARM,
height: BRACKET_ARM,
pointerEvents: "none",
...b.style,
}}
/>
))}
{/* CONTENT WRAP */}
<div
style={{
position: "absolute",
inset: 14,
padding: "16px 28px",
display: "flex",
flexDirection: "column",
}} }}
> >
<ParamsPanel <nav aria-label="Разделы" style={{ display: "contents" }}>
key={estimate?.estimate_id ?? "new"} <TopNav
onSubmit={handleSubmit} active={nav}
isPending={mutation.isPending} onNavigate={setNav}
error={apiError} reports={reportsCount ?? 0}
initialValues={initialValues} user={topNavUser}
markers={markers} onLogout={logout}
/> />
{middleContent} </nav>
{rightContent} <main style={{ display: "contents" }}>
<HeroBar
data={{ report, object: objectInfo }}
estimateId={mounted ? currentEstimateId : null}
onOpenInfo={() => setDrawerOpen(true)}
/>
<div
style={{
display: "grid",
gridTemplateColumns: "474px 1fr 250px",
gap: 18,
flex: 1,
minHeight: 0,
}}
>
<ParamsPanel
key={estimate?.estimate_id ?? "new"}
onSubmit={handleSubmit}
isPending={mutation.isPending}
error={apiError}
initialValues={initialValues}
markers={markers}
/>
{middleContent}
{rightContent}
</div>
</main>
<footer style={{ display: "contents" }}>
<Footer data={report} />
</footer>
</div> </div>
<Footer data={report} /> {nav !== 0 && (
</div> <SectionOverlay
active={nav}
{nav !== 0 && ( onClose={() => setNav(0)}
<SectionOverlay onNavigate={setNav}
active={nav} history={historyData}
onClose={() => setNav(0)} analytics={analyticsViewData}
onNavigate={setNav} sources={sourcesData}
history={historyData} cache={cacheData}
analytics={analyticsViewData} />
sources={sourcesData} )}
cache={cacheData} <LocationDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
/> />
)} </div>
<LocationDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
</div> </div>
); );
} }

View file

@ -6,11 +6,11 @@ import { tokens } from "./tokens";
import { cacheKpi, cacheRows } from "./fixtures"; import { cacheKpi, cacheRows } from "./fixtures";
import type { CacheData } from "./mappers"; import type { CacheData } from "./mappers";
const GRID_COLUMNS = "2.4fr 1fr 1fr 1fr";
const FIXTURE_CACHE: CacheData = { kpis: cacheKpi, rows: cacheRows }; const FIXTURE_CACHE: CacheData = { kpis: cacheKpi, rows: cacheRows };
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 gridCols = showSrc ? "2.4fr 1fr 1fr 1fr" : "2.4fr 1fr 1fr";
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}> <div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
{/* 3 KPI cards */} {/* 3 KPI cards */}
@ -87,7 +87,7 @@ export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
<div <div
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: GRID_COLUMNS, gridTemplateColumns: gridCols,
gap: 10, gap: 10,
padding: "9px 18px", padding: "9px 18px",
fontSize: 9, fontSize: 9,
@ -98,7 +98,7 @@ export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
> >
<span>АДРЕС</span> <span>АДРЕС</span>
<span>ВРЕМЯ</span> <span>ВРЕМЯ</span>
<span>ИСТОЧНИКОВ</span> {showSrc && <span>ИСТОЧНИКОВ</span>}
<span>СТАТУС</span> <span>СТАТУС</span>
</div> </div>
{data.rows.map((r, i) => ( {data.rows.map((r, i) => (
@ -106,7 +106,7 @@ export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
key={`${r.addr}-${i}`} key={`${r.addr}-${i}`}
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: GRID_COLUMNS, gridTemplateColumns: gridCols,
gap: 10, gap: 10,
padding: "12px 18px", padding: "12px 18px",
fontSize: 11, fontSize: 11,
@ -120,9 +120,13 @@ export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
> >
{r.time} {r.time}
</span> </span>
<span style={{ fontFamily: tokens.font.mono, color: tokens.muted }}> {showSrc && (
{r.src} <span
</span> style={{ fontFamily: tokens.font.mono, color: tokens.muted }}
>
{r.src}
</span>
)}
<span <span
style={{ style={{
display: "flex", display: "flex",

View file

@ -32,7 +32,9 @@ function pdfDownloadHref(estimateId: string | null | undefined): string | null {
if (!estimateId || !PDF_UUID_RE.test(estimateId)) return null; if (!estimateId || !PDF_UUID_RE.test(estimateId)) return null;
const href = `${API_BASE_URL}/api/v1/trade-in/estimate/${estimateId}/pdf`; const href = `${API_BASE_URL}/api/v1/trade-in/estimate/${estimateId}/pdf`;
if (typeof window === "undefined") return href; if (typeof window === "undefined") return href;
return safeUrl(new URL(href, window.location.origin).toString()) ? href : null; return safeUrl(new URL(href, window.location.origin).toString())
? href
: null;
} }
interface HeroBarProps { interface HeroBarProps {
@ -366,16 +368,32 @@ export default function HeroBar({
КОЭФ. ЛОКАЦИИ КОЭФ. ЛОКАЦИИ
</span> </span>
<span style={{ display: "flex", alignItems: "center", gap: 6 }}> <span style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span {data.object.locationCoef === "—" ? (
style={{ <span
fontFamily: tokens.font.mono, style={{
fontSize: 15, fontSize: "9px",
fontWeight: 500, letterSpacing: ".5px",
color: tokens.accent, color: tokens.muted2,
}} background: tokens.badgeTint,
> border: `1px solid ${tokens.line2}`,
{data.object.locationCoef} borderRadius: 10,
</span> padding: "1px 8px",
}}
>
скоро
</span>
) : (
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 15,
fontWeight: 500,
color: tokens.accent,
}}
>
{data.object.locationCoef}
</span>
)}
<span <span
style={{ style={{
display: "flex", display: "flex",

View file

@ -12,7 +12,6 @@ import type { HistoryData } from "./mappers";
// Shared grid templates (kept verbatim from the design column tracks). // Shared grid templates (kept verbatim from the design column tracks).
const houseGrid = "2fr 1.4fr 1fr 1fr"; const houseGrid = "2fr 1.4fr 1fr 1fr";
const dkpGrid = "2.2fr 1fr 1.3fr 1fr .9fr 2fr";
// Default presentation data (unwired usage): the existing design fixtures. // Default presentation data (unwired usage): the existing design fixtures.
const HISTORY_FIXTURE: HistoryData = { ...history, dkpRows }; const HISTORY_FIXTURE: HistoryData = { ...history, dkpRows };
@ -21,7 +20,13 @@ interface HistoryViewProps {
data?: HistoryData; data?: HistoryData;
} }
export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps) { export default function HistoryView({
data = HISTORY_FIXTURE,
}: HistoryViewProps) {
const showAsk = data.dkpRows.some((r) => r.ask && r.ask !== "—");
const dkpGridCols = showAsk
? "2.2fr 1fr 1.3fr 1fr .9fr 2fr"
: "2.2fr 1fr 1.3fr 1fr .9fr";
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: "22px" }}> <div style={{ display: "flex", flexDirection: "column", gap: "22px" }}>
{/* ── История продаж в этом доме ───────────────────────────── */} {/* ── История продаж в этом доме ───────────────────────────── */}
@ -278,7 +283,7 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
<div <div
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: dkpGrid, gridTemplateColumns: dkpGridCols,
gap: "10px", gap: "10px",
padding: "9px 18px", padding: "9px 18px",
fontSize: "9px", fontSize: "9px",
@ -292,7 +297,7 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
<span>ЦЕНА СДЕЛКИ</span> <span>ЦЕНА СДЕЛКИ</span>
<span>/М²</span> <span>/М²</span>
<span>ДАТА</span> <span>ДАТА</span>
<span>ОБЪЯВЛЕНИЕ ДО СДЕЛКИ</span> {showAsk && <span>ОБЪЯВЛЕНИЕ ДО СДЕЛКИ</span>}
</div> </div>
{data.dkpRows.map((r, i) => ( {data.dkpRows.map((r, i) => (
@ -300,7 +305,7 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
key={i} key={i}
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: dkpGrid, gridTemplateColumns: dkpGridCols,
gap: "10px", gap: "10px",
padding: "11px 18px", padding: "11px 18px",
fontSize: "11px", fontSize: "11px",
@ -323,32 +328,34 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
> >
{r.date ?? "—"} {r.date ?? "—"}
</span> </span>
<span {showAsk && (
style={{
display: "flex",
alignItems: "center",
gap: "7px",
fontFamily: tokens.font.mono,
fontSize: "10px",
color: tokens.muted,
}}
>
<span <span
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,
color: tokens.accent,
}} }}
> >
{r.source ?? "—"} <span
style={{
fontSize: "8px",
letterSpacing: ".5px",
background: tokens.badgeTint,
border: `1px solid ${tokens.line2}`,
borderRadius: "3px",
padding: "1px 5px",
color: tokens.accent,
}}
>
{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>
</span>
</div> </div>
))} ))}

View file

@ -101,7 +101,7 @@ export function ObjectSummary({
background: tokens.success, background: tokens.success,
}} }}
/> />
В РАСЧЁТЕ АКТУАЛЬНО
</span> </span>
</div> </div>
@ -140,7 +140,9 @@ export function ObjectSummary({
<circle cx="7.5" cy="6.5" r="2.3" fill={tokens.accent} /> <circle cx="7.5" cy="6.5" r="2.3" fill={tokens.accent} />
</svg> </svg>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>{data.object.address}</div> <div style={{ fontSize: 13, fontWeight: 600 }}>
{data.object.address}
</div>
<div style={{ fontSize: 11, color: tokens.muted, marginTop: 2 }}> <div style={{ fontSize: 11, color: tokens.muted, marginTop: 2 }}>
{data.object.city} {data.object.city}
</div> </div>

View file

@ -46,6 +46,7 @@ interface DdProps {
triggerBg?: string; triggerBg?: string;
triggerColor?: string; triggerColor?: string;
caretColor?: string; caretColor?: string;
disabled?: boolean;
} }
function Dd({ function Dd({
@ -63,6 +64,7 @@ function Dd({
triggerBg = tokens.surface.w62, triggerBg = tokens.surface.w62,
triggerColor = tokens.ink, triggerColor = tokens.ink,
caretColor = tokens.muted2, caretColor = tokens.muted2,
disabled = false,
}: DdProps) { }: DdProps) {
const panelStyle: CSSProperties = { const panelStyle: CSSProperties = {
position: "absolute", position: "absolute",
@ -88,7 +90,11 @@ function Dd({
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
<div <div
className={ className={
variant === "dashed" ? "pp-dd-trigger-dashed" : "pp-dd-trigger" disabled
? "pp-dd-trigger-disabled"
: variant === "dashed"
? "pp-dd-trigger-dashed"
: "pp-dd-trigger"
} }
onClick={onToggle} onClick={onToggle}
style={{ style={{
@ -102,19 +108,21 @@ function Dd({
fontFamily: mono ? tokens.font.mono : tokens.font.sans, fontFamily: mono ? tokens.font.mono : tokens.font.sans,
fontSize: triggerFontSize, fontSize: triggerFontSize,
color: triggerColor, color: triggerColor,
cursor: "pointer", cursor: disabled ? "default" : "pointer",
}} }}
> >
{value} {value}
<span {!disabled && (
style={{ <span
color: caretColor, style={{
fontSize: 9, color: caretColor,
fontFamily: tokens.font.sans, fontSize: 9,
}} fontFamily: tokens.font.sans,
> }}
>
</span>
</span>
)}
</div> </div>
{open && ( {open && (
<div style={panelStyle}> <div style={panelStyle}>
@ -149,11 +157,14 @@ const styles = `
.pp-dd-trigger:hover{border-color:${tokens.accent}} .pp-dd-trigger:hover{border-color:${tokens.accent}}
.pp-dd-trigger-dashed{border:1px dashed ${tokens.line};transition:border-color .15s} .pp-dd-trigger-dashed{border:1px dashed ${tokens.line};transition:border-color .15s}
.pp-dd-trigger-dashed:hover{border-color:${tokens.accent}} .pp-dd-trigger-dashed:hover{border-color:${tokens.accent}}
.pp-dd-trigger-disabled{border:1px dashed ${tokens.line}}
.pp-dd-opt{transition:background .15s} .pp-dd-opt{transition:background .15s}
.pp-dd-opt:hover{background:rgba(46,139,255,.1)} .pp-dd-opt:hover{background:rgba(46,139,255,.1)}
.pp-eval-btn{background:linear-gradient(90deg,rgba(255,255,255,.7),rgba(233,242,252,.7));box-shadow:0 0 0 0 rgba(46,139,255,0);transition:all .18s} .pp-input:focus-visible{outline:none;border-color:${tokens.accent};box-shadow:0 0 0 3px rgba(46,139,255,.18)}
.pp-eval-btn:hover{background:linear-gradient(90deg,rgba(46,139,255,.12),rgba(46,139,255,.18));box-shadow:0 6px 22px rgba(46,139,255,.28)} .pp-eval-btn{background:linear-gradient(90deg,${tokens.accentDeep},${tokens.accent});box-shadow:0 2px 10px rgba(46,139,255,.25);transition:all .18s}
.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:active{transform:translateY(1px)}
.pp-eval-btn:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(46,139,255,.35)}
`; `;
const hintLabel: CSSProperties = { const hintLabel: CSSProperties = {
@ -343,7 +354,9 @@ export default function ParamsPanel({
}: ParamsPanelProps) { }: ParamsPanelProps) {
const [openDd, setOpenDd] = useState<DdKey>(null); const [openDd, setOpenDd] = useState<DdKey>(null);
// Map zoom — applied as transform: scale() on the map content layer (not the // Map zoom — applied as transform: scale() on the map content layer (not the
// whole panel). Default 1 (identity → pixel-identical), step .25, clamp .62.5. // whole panel). Default 1 (identity → pixel-identical), step .25, clamp 12.5
// (no zoom-out below 1: there is no real basemap behind the blueprint SVG, so
// shrinking would only expose empty corners — zoom-in only).
const [zoom, setZoom] = useState(1); const [zoom, setZoom] = useState(1);
const [address, setAddress] = useState(initialValues?.address ?? ""); const [address, setAddress] = useState(initialValues?.address ?? "");
const [area, setArea] = useState( const [area, setArea] = useState(
@ -378,7 +391,9 @@ export default function ParamsPanel({
// handler (a plain timer, not a useEffect-for-HTTP); `useGeocodeSuggest` is // handler (a plain timer, not a useEffect-for-HTTP); `useGeocodeSuggest` is
// gated at >=3 chars and fires on the debounced value. Picking a suggestion // gated at >=3 chars and fires on the debounced value. Picking a suggestion
// fills the full address and captures lat/lon so the backend can skip geocoding. // fills the full address and captures lat/lon so the backend can skip geocoding.
const [addressQuery, setAddressQuery] = useState(initialValues?.address ?? ""); const [addressQuery, setAddressQuery] = useState(
initialValues?.address ?? "",
);
const [suggestOpen, setSuggestOpen] = useState(false); const [suggestOpen, setSuggestOpen] = useState(false);
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
@ -479,7 +494,9 @@ export default function ParamsPanel({
// ring never blows past the map box. The inner two rings stay fixed. // ring never blows past the map box. The inner two rings stay fixed.
const radiusM = parseInt(radius, 10); const radiusM = parseInt(radius, 10);
const outerRingR = Number.isFinite(radiusM) const outerRingR = Number.isFinite(radiusM)
? Math.round(Math.max(70, Math.min(155, 112 * Math.pow(radiusM / 500, 0.35)))) ? Math.round(
Math.max(70, Math.min(155, 112 * Math.pow(radiusM / 500, 0.35))),
)
: 112; : 112;
return ( return (
@ -544,16 +561,6 @@ export default function ParamsPanel({
ПАРАМЕТРЫ КВАРТИРЫ ПАРАМЕТРЫ КВАРТИРЫ
</span> </span>
</div> </div>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 10,
letterSpacing: 1,
color: tokens.muted2,
}}
>
ШАГ 1/1
</span>
</div> </div>
{/* MAP */} {/* MAP */}
@ -580,97 +587,97 @@ export default function ParamsPanel({
transition: "transform .15s ease-out", transition: "transform .15s ease-out",
}} }}
> >
<svg <svg
viewBox="0 0 440 210" viewBox="0 0 440 210"
preserveAspectRatio="xMidYMid slice" preserveAspectRatio="xMidYMid slice"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
}}
>
<rect width="440" height="210" fill="#e6edf4" />
<g stroke="#dbe5ef" strokeWidth={1}>
<path d="M0 38H440M0 78H440M0 118H440M0 158H440M0 198H440" />
<path d="M40 0V210M110 0V210M180 0V210M250 0V210M320 0V210M390 0V210" />
</g>
<g stroke="#f5f8fc" strokeWidth={8} strokeLinecap="round">
<path d="M-10 92 H450" />
<path d="M150 -10 V220" />
<path d="M-10 30 L260 0" />
</g>
<g stroke="#eef3f9" strokeWidth={5}>
<path d="M300 -10 L470 150" />
<path d="M-10 170 H450" />
</g>
<path d="M150 92 L470 200" stroke="#cfe0f3" strokeWidth={6} />
<g fill="none" stroke="#2e8bff" strokeDasharray="3 4">
<circle cx="208" cy="100" r="44" opacity={0.55} />
<circle cx="208" cy="100" r="80" opacity={0.4} />
<circle cx="208" cy="100" r={outerRingR} opacity={0.28} />
</g>
<circle cx="208" cy="100" r="80" fill="#2e8bff" opacity={0.04} />
</svg>
{/* center pin */}
<div
style={{
position: "absolute",
left: "47%",
top: "48%",
transform: "translate(-50%,-100%)",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<div
style={{ style={{
display: "flex", position: "absolute",
alignItems: "center", inset: 0,
gap: 6, width: "100%",
background: tokens.surface.w85, height: "100%",
border: `1px solid ${tokens.accent}`,
borderRadius: 4,
padding: "4px 8px",
whiteSpace: "nowrap",
boxShadow: "0 3px 10px rgba(46,139,255,.25)",
}} }}
> >
<span <rect width="440" height="210" fill="#e6edf4" />
style={{ <g stroke="#dbe5ef" strokeWidth={1}>
width: 7, <path d="M0 38H440M0 78H440M0 118H440M0 158H440M0 198H440" />
height: 7, <path d="M40 0V210M110 0V210M180 0V210M250 0V210M320 0V210M390 0V210" />
borderRadius: "50%", </g>
background: tokens.accent, <g stroke="#f5f8fc" strokeWidth={8} strokeLinecap="round">
}} <path d="M-10 92 H450" />
/> <path d="M150 -10 V220" />
<span style={{ fontSize: 11, fontWeight: 600 }}> <path d="M-10 30 L260 0" />
{address.trim() || "Адрес квартиры"} </g>
</span> <g stroke="#eef3f9" strokeWidth={5}>
</div> <path d="M300 -10 L470 150" />
<path d="M-10 170 H450" />
</g>
<path d="M150 92 L470 200" stroke="#cfe0f3" strokeWidth={6} />
<g fill="none" stroke="#2e8bff" strokeDasharray="3 4">
<circle cx="208" cy="100" r="44" opacity={0.55} />
<circle cx="208" cy="100" r="80" opacity={0.4} />
<circle cx="208" cy="100" r={outerRingR} opacity={0.28} />
</g>
<circle cx="208" cy="100" r="80" fill="#2e8bff" opacity={0.04} />
</svg>
{/* center pin */}
<div <div
style={{ style={{
width: 0, position: "absolute",
height: 0, left: "47%",
borderLeft: "5px solid transparent", top: "48%",
borderRight: "5px solid transparent", transform: "translate(-50%,-100%)",
borderTop: `7px solid ${tokens.accent}`, display: "flex",
flexDirection: "column",
alignItems: "center",
}} }}
/> >
</div> <div
style={{
display: "flex",
alignItems: "center",
gap: 6,
background: tokens.surface.w85,
border: `1px solid ${tokens.accent}`,
borderRadius: 4,
padding: "4px 8px",
whiteSpace: "nowrap",
boxShadow: "0 3px 10px rgba(46,139,255,.25)",
}}
>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: tokens.accent,
}}
/>
<span style={{ fontSize: 11, fontWeight: 600 }}>
{address.trim() || "Адрес квартиры"}
</span>
</div>
<div
style={{
width: 0,
height: 0,
borderLeft: "5px solid transparent",
borderRight: "5px solid transparent",
borderTop: `7px solid ${tokens.accent}`,
}}
/>
</div>
{/* analog price pins projected from the real estimate via mapMarkers(). {/* analog price pins projected from the real estimate via mapMarkers().
Empty markers (no estimate yet) no price pins, just the subject pin + Empty markers (no estimate yet) no price pins, just the subject pin +
radius rings (Finding #2: never the fabricated fixture prices/dots). */} radius rings (Finding #2: never the fabricated fixture prices/dots). */}
{markers.map((m, i) => ( {markers.map((m, i) => (
<div key={i} style={{ ...analogPin, left: m.left, top: m.top }}> <div key={i} style={{ ...analogPin, left: m.left, top: m.top }}>
{m.label} {m.label}
<br /> <br />
<span style={{ color: tokens.muted }}>{m.sub}</span> <span style={{ color: tokens.muted }}>{m.sub}</span>
</div> </div>
))} ))}
</div> </div>
{/* map controls */} {/* map controls */}
@ -713,38 +720,18 @@ export default function ParamsPanel({
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
cursor: "pointer", cursor: zoom <= 1 ? "default" : "pointer",
transition: "all .15s", transition: "all .15s",
fontSize: 15, fontSize: 15,
color: tokens.muted, color: tokens.muted,
opacity: zoom <= 1 ? 0.4 : 1,
}}
onClick={() => {
if (zoom > 1) setZoom((z) => Math.max(1, z - 0.25));
}} }}
onClick={() => setZoom((z) => Math.max(0.6, z - 0.25))}
> >
</div> </div>
<div
style={{
width: 24,
height: 24,
background: tokens.surface.w80,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all .15s",
color: tokens.accent,
}}
>
<svg width="13" height="13" viewBox="0 0 13 13" fill="none">
<circle cx="6.5" cy="6.5" r="4" stroke="#2e8bff" />
<line x1="6.5" y1="0" x2="6.5" y2="2.5" stroke="#2e8bff" />
<line x1="6.5" y1="10.5" x2="6.5" y2="13" stroke="#2e8bff" />
<line x1="0" y1="6.5" x2="2.5" y2="6.5" stroke="#2e8bff" />
<line x1="10.5" y1="6.5" x2="13" y2="6.5" stroke="#2e8bff" />
</svg>
</div>
</div> </div>
{/* map corner bracket */} {/* map corner bracket */}
@ -873,6 +860,7 @@ export default function ParamsPanel({
HUD <Dd> panel styling. Picking an item fills the full address HUD <Dd> panel styling. Picking an item fills the full address
and captures lat/lon for the estimate payload. */} and captures lat/lon for the estimate payload. */}
<input <input
className="pp-input"
type="text" type="text"
value={address} value={address}
onChange={(e) => handleAddressChange(e.target.value)} onChange={(e) => handleAddressChange(e.target.value)}
@ -941,12 +929,11 @@ export default function ParamsPanel({
<div> <div>
<div style={hintLabel}>ПЛОЩАДЬ, М²</div> <div style={hintLabel}>ПЛОЩАДЬ, М²</div>
<input <input
className="pp-input"
type="text" type="text"
inputMode="decimal" inputMode="decimal"
value={area} value={area}
onChange={(e) => onChange={(e) => setArea(e.target.value.replace(/[^\d.,]/g, ""))}
setArea(e.target.value.replace(/[^\d.,]/g, ""))
}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") handleSubmit(); if (e.key === "Enter") handleSubmit();
}} }}
@ -976,6 +963,7 @@ export default function ParamsPanel({
<span style={optHint}>если знаешь</span> <span style={optHint}>если знаешь</span>
</div> </div>
<input <input
className="pp-input"
type="text" type="text"
inputMode="numeric" inputMode="numeric"
value={floor} value={floor}
@ -993,6 +981,7 @@ export default function ParamsPanel({
<span style={optHint}>если знаешь</span> <span style={optHint}>если знаешь</span>
</div> </div>
<input <input
className="pp-input"
type="text" type="text"
inputMode="numeric" inputMode="numeric"
value={totalFloors} value={totalFloors}
@ -1026,6 +1015,7 @@ export default function ParamsPanel({
<span style={optHint}>опц.</span> <span style={optHint}>опц.</span>
</div> </div>
<input <input
className="pp-input"
type="text" type="text"
inputMode="numeric" inputMode="numeric"
value={year} value={year}
@ -1094,7 +1084,7 @@ export default function ParamsPanel({
<div> <div>
<div style={{ display: "flex", justifyContent: "space-between" }}> <div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>CRM ДЛЯ МЕНЕДЖЕРА</span> <span style={hintLabel}>CRM ДЛЯ МЕНЕДЖЕРА</span>
<span style={optHint}>опц.</span> <span style={optHint}>скоро</span>
</div> </div>
{/* CRM non-functional (no backend, #395). Markup kept; open is {/* CRM non-functional (no backend, #395). Markup kept; open is
forced false + no-op handlers so it never expands. TODO(wire). */} forced false + no-op handlers so it never expands. TODO(wire). */}
@ -1113,6 +1103,7 @@ export default function ParamsPanel({
triggerBg={tokens.surface.w50} triggerBg={tokens.surface.w50}
triggerColor={tokens.hint} triggerColor={tokens.hint}
caretColor={tokens.muted4} caretColor={tokens.muted4}
disabled
/> />
</div> </div>
</div> </div>
@ -1136,7 +1127,7 @@ export default function ParamsPanel({
cursor: isPending ? "wait" : "pointer", cursor: isPending ? "wait" : "pointer",
opacity: isPending ? 0.6 : 1, opacity: isPending ? 0.6 : 1,
fontFamily: tokens.font.sans, fontFamily: tokens.font.sans,
color: tokens.ink, color: tokens.onAccent,
flex: "0 0 auto", flex: "0 0 auto",
}} }}
> >
@ -1152,9 +1143,9 @@ export default function ParamsPanel({
justifyContent: "center", justifyContent: "center",
width: 26, width: 26,
height: 26, height: 26,
border: `1px solid ${tokens.accent}`, border: "1px solid rgba(255,255,255,.7)",
borderRadius: "50%", borderRadius: "50%",
color: tokens.accent, color: tokens.onAccent,
}} }}
> >

View file

@ -231,6 +231,19 @@ export default function ResultPanel({
> >
{card.ppm} {card.ppm}
</div> </div>
{card.note && (
<div
style={{
fontSize: 9,
color: muted2,
marginTop: 4,
lineHeight: 1.4,
maxWidth: "82%",
}}
>
{card.note}
</div>
)}
{card.bars ? ( {card.bars ? (
<div <div
@ -885,7 +898,7 @@ export default function ResultPanel({
При недоступности источника частичный результат не блокируется При недоступности источника частичный результат не блокируется
</span> </span>
<span style={{ fontSize: 9.5, letterSpacing: 1.5, color: muted2 }}> <span style={{ fontSize: 9.5, letterSpacing: 1.5, color: muted2 }}>
ПОСТРОЕНО ПО 6 АНАЛОГАМ И 10 СДЕЛКАМ ЗА 6 МЕСЯЦЕВ {data.meta.builtOn ?? "—"}
</span> </span>
</div> </div>
</div> </div>

View file

@ -15,7 +15,6 @@ import type { SourcesData } from "./mappers";
// 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 AVITO_DOT = "#e8743b";
const AD_GRID = "2.4fr 1fr 1fr 1.2fr .7fr 1fr"; const AD_GRID = "2.4fr 1fr 1fr 1.2fr .7fr 1fr";
const DEAL_GRID = "2.6fr 1.2fr 1fr 1.2fr 1fr"; const DEAL_GRID = "2.6fr 1.2fr 1fr 1.2fr 1fr";
@ -187,7 +186,9 @@ interface SourcesViewProps {
data?: SourcesData; data?: SourcesData;
} }
export default function SourcesView({ data = SOURCES_FIXTURE }: SourcesViewProps) { export default function SourcesView({
data = SOURCES_FIXTURE,
}: SourcesViewProps) {
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}> <div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
<style>{` <style>{`
@ -208,7 +209,8 @@ export default function SourcesView({ data = SOURCES_FIXTURE }: SourcesViewProps
</div> </div>
<div style={headerRight}> <div style={headerRight}>
Обновлено: <br /> Обновлено: <br />
Источников: <b style={{ color: tokens.muted }}>2 / 7</b> Источников:{" "}
<b style={{ color: tokens.muted }}>{data.sourceCount ?? "—"}</b>
</div> </div>
</div> </div>
@ -216,7 +218,7 @@ export default function SourcesView({ data = SOURCES_FIXTURE }: SourcesViewProps
<KpiCell <KpiCell
label="ОБЪЯВЛЕНИЙ ПО АНАЛОГАМ" label="ОБЪЯВЛЕНИЙ ПО АНАЛОГАМ"
value={data.marketAds.kpi.count} value={data.marketAds.kpi.count}
unit="шт · из 2 источников" unit="шт"
/> />
<KpiCell <KpiCell
label="МЕДИАНА" label="МЕДИАНА"
@ -255,26 +257,6 @@ export default function SourcesView({ data = SOURCES_FIXTURE }: SourcesViewProps
{f} {f}
</span> </span>
))} ))}
<span
style={{
marginLeft: "auto",
fontSize: "10px",
color: tokens.muted,
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: 2,
background: AVITO_DOT,
}}
/>
Avito · 10
</span>
</div> </div>
<div <div

View file

@ -140,6 +140,10 @@ export const REPAIR_FROM_RU: Record<string, RepairState> = {
// ── Format helpers (ru) ───────────────────────────────────────────────────── // ── Format helpers (ru) ─────────────────────────────────────────────────────
// Non-breaking space (U+00A0) — glues a number to its unit / pads range dashes so
// "6,56 млн ₽" / "1,2 3,4" never wrap mid-token.
const NBSP = " ";
/** rub -> млн with ru grouping, 2dp. 6557500 -> "6,56". null/NaN -> "—". */ /** rub -> млн with ru grouping, 2dp. 6557500 -> "6,56". null/NaN -> "—". */
export function fmtMln(rub: number | null | undefined): string { export function fmtMln(rub: number | null | undefined): string {
if (rub == null || !Number.isFinite(rub)) return "—"; if (rub == null || !Number.isFinite(rub)) return "—";
@ -155,6 +159,12 @@ export function fmtPpm(ppm: number | null | undefined): string {
return `${Math.round(ppm).toLocaleString("ru-RU")} ₽/м²`; return `${Math.round(ppm).toLocaleString("ru-RU")} ₽/м²`;
} }
/** area -> "54,3 м²" (ru comma, ≤1dp, NBSP before unit). null/NaN -> "—". */
function fmtArea(area: number | null | undefined): string {
if (area == null || !Number.isFinite(area)) return "—";
return `${area.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}${NBSP}м²`;
}
/** ISO datetime -> "DD.MM.YYYY" (ru). Invalid/empty -> "—". */ /** ISO datetime -> "DD.MM.YYYY" (ru). Invalid/empty -> "—". */
export function fmtDate(iso: string | null | undefined): string { export function fmtDate(iso: string | null | undefined): string {
if (!iso) return "—"; if (!iso) return "—";
@ -228,7 +238,12 @@ function coeffVar(values: number[]): number | null {
function cvStr(values: number[]): string { function cvStr(values: number[]): string {
const cv = coeffVar(values); const cv = coeffVar(values);
return cv != null ? `${cv.toFixed(1)}%` : "—"; return cv != null
? `${cv.toLocaleString("ru-RU", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})}%`
: "—";
} }
/** /**
@ -339,10 +354,15 @@ function rangeLine(
lo: number | null | undefined, lo: number | null | undefined,
hi: number | null | undefined, hi: number | null | undefined,
): string { ): string {
if (lo == null || hi == null || !Number.isFinite(lo) || !Number.isFinite(hi)) { if (
lo == null ||
hi == null ||
!Number.isFinite(lo) ||
!Number.isFinite(hi)
) {
return "—"; return "—";
} }
return `${fmtMln(lo)}${fmtMln(hi)} млн ₽`; return `${fmtMln(lo)}${NBSP}${NBSP}${fmtMln(hi)}${NBSP}млн${NBSP}`;
} }
// ── Address parsing (TODO BE-2) ───────────────────────────────────────────── // ── Address parsing (TODO BE-2) ─────────────────────────────────────────────
@ -384,7 +404,8 @@ function parseAddress(full: string | null): { address: string; city: string } {
city = tokenMatch[1].trim(); city = tokenMatch[1].trim();
} else { } else {
const anchorIdx = parts.findIndex((p) => ADMIN_ANCHOR_RE.test(p)); const anchorIdx = parts.findIndex((p) => ADMIN_ANCHOR_RE.test(p));
if (anchorIdx > 0) city = parts[anchorIdx - 1].replace(CITY_PREFIX_RE, "").trim(); if (anchorIdx > 0)
city = parts[anchorIdx - 1].replace(CITY_PREFIX_RE, "").trim();
} }
// Address: street token + adjacent house number (house precedes the street in // Address: street token + adjacent house number (house precedes the street in
@ -444,17 +465,32 @@ function buildSources(e: AggregatedEstimate): SourceCard[] {
const count = slot.keys.reduce((sum, k) => sum + (counts.get(k) ?? 0), 0); const count = slot.keys.reduce((sum, k) => sum + (counts.get(k) ?? 0), 0);
const active = slot.keys.some((k) => used.has(k)) || count > 0; const active = slot.keys.some((k) => used.has(k)) || count > 0;
if (!active) { if (!active) {
return { name: slot.name, count: "—", label: "нет данных", active: false }; return {
name: slot.name,
count: "—",
label: "нет данных",
active: false,
};
} }
return { return {
name: slot.name, name: slot.name,
count: count > 0 ? String(count) : "—", count: count > 0 ? String(count) : "—",
label: count > 0 ? pluralRu(count, ["лот", "лота", "лотов"]) : "нет данных", label:
count > 0 ? pluralRu(count, ["лот", "лота", "лотов"]) : "нет данных",
active: true, active: true,
}; };
}); });
} }
/**
* Canonical "active sources" count = number of filled source TILES (one per
* design slot), so meta/quality/overlay all agree with what the user sees. NOT
* e.sources_used.length (which can double-count alias keys / anchor-only sources).
*/
function activeSourceCount(e: AggregatedEstimate): number {
return buildSources(e).filter((s) => s.active).length;
}
// ── Deal tier resolution (DKP / Росреестр) ────────────────────────────────── // ── Deal tier resolution (DKP / Росреестр) ──────────────────────────────────
interface DealTier { interface DealTier {
@ -559,7 +595,10 @@ function buildScatterMini(
p.x != null && Number.isFinite(p.y), p.x != null && Number.isFinite(p.y),
); );
const dealsRaw = toPts(sd && sd.deals.length > 0 ? sd.deals : e.actual_deals, realDom); const dealsRaw = toPts(
sd && sd.deals.length > 0 ? sd.deals : e.actual_deals,
realDom,
);
const analogsRaw = toPts(e.analogs, (l) => realDom(l) ?? daysListed(l)); const analogsRaw = toPts(e.analogs, (l) => realDom(l) ?? daysListed(l));
const subjectPrice = e.expected_sold_price_rub ?? e.median_price_rub; const subjectPrice = e.expected_sold_price_rub ?? e.median_price_rub;
const subjectDays = e.est_days_on_market; const subjectDays = e.est_days_on_market;
@ -657,9 +696,10 @@ export function mapObject(e: AggregatedEstimate): ObjectInfo {
// {lat,lon} relative to the subject (target_lat/lon) into the map's 0100% box. // {lat,lon} relative to the subject (target_lat/lon) into the map's 0100% box.
// There is no real basemap/zoom behind the SVG, so a fixed geographic half-span // There is no real basemap/zoom behind the SVG, so a fixed geographic half-span
// (~1.2 km) is used purely to spread nearby analogs; out-of-box analogs clamp to // (~1.2 km) is used purely to spread nearby analogs; out-of-box analogs clamp to
// the 496% margin. Closest ~6 by distance are shown (the pin chrome only fits a // the 496% margin. Finding #12: the closest pins overlapped on the small map, so
// handful). null / empty / un-geocoded estimate → [] (the map shows just the // a greedy non-overlap pass keeps at most KEEP_CAP pins that are ≥ MIN_PIN_DIST
// subject pin, never the fake fixture prices). // apart (% units). null / empty / un-geocoded estimate → [] (the map shows just
// the subject pin, never the fake fixture prices).
/** One analog price pin positioned by CSS % offsets over the ParamsPanel map. */ /** One analog price pin positioned by CSS % offsets over the ParamsPanel map. */
export interface MapMarker { export interface MapMarker {
@ -674,9 +714,13 @@ export interface MapMarker {
// analogs toward the centre, smaller pushes them to the clamped margins. // analogs toward the centre, smaller pushes them to the clamped margins.
const MAP_LAT_HALF_SPAN = 0.011; const MAP_LAT_HALF_SPAN = 0.011;
const MAP_LON_HALF_SPAN = 0.018; const MAP_LON_HALF_SPAN = 0.018;
const MAP_MARKER_CAP = 6;
const MAP_CLAMP_LO = 4; const MAP_CLAMP_LO = 4;
const MAP_CLAMP_HI = 96; const MAP_CLAMP_HI = 96;
// Greedy de-clutter (Finding #12): keep at most KEEP_CAP pins, each ≥ MIN_PIN_DIST
// from every already-kept pin (Euclidean in the 0100% map box) so close analogs
// don't stack into an unreadable blob on the small map.
const MIN_PIN_DIST = 22;
const KEEP_CAP = 3;
/** 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[] {
@ -705,10 +749,15 @@ export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] {
: fallbackM; : fallbackM;
return { a, dist }; return { a, dist };
}) })
.sort((x, y) => x.dist - y.dist) .sort((x, y) => x.dist - y.dist);
.slice(0, MAP_MARKER_CAP);
return ranked.map(({ a }) => { // Greedy non-overlap pass over the closest-first list: project each candidate
// to numeric left/top %, keep it only when it sits ≥ MIN_PIN_DIST from every
// pin already kept. Stop at KEEP_CAP. Positions stay numeric until the final
// map, where they become the MapMarker "NN%" strings.
const kept: { left: number; top: number; a: AnalogLot }[] = [];
for (const { a } of ranked) {
if (kept.length >= KEEP_CAP) break;
const left = clamp( const left = clamp(
50 + ((a.lon - tLon) / MAP_LON_HALF_SPAN) * 50, 50 + ((a.lon - tLon) / MAP_LON_HALF_SPAN) * 50,
MAP_CLAMP_LO, MAP_CLAMP_LO,
@ -719,13 +768,19 @@ export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] {
MAP_CLAMP_LO, MAP_CLAMP_LO,
MAP_CLAMP_HI, MAP_CLAMP_HI,
); );
return { const overlaps = kept.some(
left: `${round1(left)}%`, (k) => Math.hypot(k.left - left, k.top - top) < MIN_PIN_DIST,
top: `${round1(top)}%`, );
label: `${fmtMln(a.price_rub)} млн ₽`, if (overlaps) continue;
sub: Number.isFinite(a.area_m2) ? `${Math.round(a.area_m2)} м²` : "—", kept.push({ left, top, a });
}; }
});
return kept.map(({ left, top, a }) => ({
left: `${round1(left)}%`,
top: `${round1(top)}%`,
label: `${fmtMln(a.price_rub)}${NBSP}млн${NBSP}`,
sub: Number.isFinite(a.area_m2) ? `${Math.round(a.area_m2)}${NBSP}м²` : "—",
}));
} }
/** Full 02 РЕЗУЛЬТАТ block: 3 cards + meta + ranges + scatter + sources. */ /** Full 02 РЕЗУЛЬТАТ block: 3 cards + meta + ranges + scatter + sources. */
@ -746,23 +801,35 @@ export function mapResultPanel(
nav: 2, nav: 2,
}, },
{ {
title: ["ОЖИДАЕМАЯ СДЕЛКА", "(ПОЛУЧИТЕ)"], title: ["ОЖИДАЕМАЯ ЦЕНА", "СДЕЛКИ"],
value: fmtMln(e.expected_sold_price_rub), value: fmtMln(e.expected_sold_price_rub),
unit: "млн ₽", unit: "млн ₽",
range: rangeLine(e.expected_sold_range_low_rub, e.expected_sold_range_high_rub), range: rangeLine(
ppm: fmtPpm(e.expected_sold_per_m2), e.expected_sold_range_low_rub,
e.expected_sold_range_high_rub,
),
ppm: `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга`,
delta: delta:
e.asking_to_sold_ratio != null e.asking_to_sold_ratio != null
? fmtPct((e.asking_to_sold_ratio - 1) * 100) ? fmtPct((e.asking_to_sold_ratio - 1) * 100)
: "—", : "—",
deltaLabel: "К РЫНКУ", deltaLabel: "К РЫНКУ",
note:
dealTier != null &&
e.expected_sold_price_rub != null &&
Number.isFinite(e.expected_sold_price_rub) &&
e.expected_sold_price_rub < dealTier.medianRub
? "Обычно ниже медианы ДКП — другая выборка и площади"
: undefined,
nav: 2, nav: 2,
}, },
{ {
title: ["ДКП · РОСРЕЕСТР", "(ФАКТИЧЕСКИЕ СДЕЛКИ)"], title: ["ДКП · РОСРЕЕСТР", "(ФАКТИЧЕСКИЕ СДЕЛКИ)"],
value: dealTier ? fmtMln(dealTier.medianRub) : "—", value: dealTier ? fmtMln(dealTier.medianRub) : "—",
unit: "млн ₽", unit: "млн ₽",
range: dealTier ? rangeLine(dealTier.loRub, dealTier.hiRub) : "нет данных", range: dealTier
? rangeLine(dealTier.loRub, dealTier.hiRub)
: "нет данных",
ppm: dealTier ppm: dealTier
? `${fmtPpm(dealTier.medianPpm)} · ${dealTier.count} ${pluralRu( ? `${fmtPpm(dealTier.medianPpm)} · ${dealTier.count} ${pluralRu(
dealTier.count, dealTier.count,
@ -774,10 +841,24 @@ export function mapResultPanel(
}, },
]; ];
// Data-driven footer provenance (replaces the view's hardcoded "ПО 6 АНАЛОГАМ
// И 10 СДЕЛКАМ"). No "ЗА N МЕСЯЦЕВ" — we have no honest period to show.
const builtOnDeals = dealTier?.count ?? 0;
const builtOn = `ПОСТРОЕНО ПО ${e.n_analogs} ${pluralRu(e.n_analogs, [
"АНАЛОГУ",
"АНАЛОГАМ",
"АНАЛОГАМ",
])} И ${builtOnDeals} ${pluralRu(builtOnDeals, [
"СДЕЛКЕ",
"СДЕЛКАМ",
"СДЕЛКАМ",
])}`;
const meta: ResultMeta = { const meta: ResultMeta = {
sources: `${e.sources_used.length} / ${TOTAL_SOURCES}`, sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`,
confidence: CONFIDENCE_RU[e.confidence].toUpperCase(), confidence: CONFIDENCE_RU[e.confidence].toUpperCase(),
cv: cvStr(e.analogs.map((a) => a.price_per_m2)), cv: cvStr(e.analogs.map((a) => a.price_per_m2)),
builtOn,
}; };
const adsBar: RangeBar = { const adsBar: RangeBar = {
@ -832,7 +913,8 @@ export function mapSummary(
const dealCount = streetDeals?.count ?? e.actual_deals.length; const dealCount = streetDeals?.count ?? e.actual_deals.length;
const dealMedian = const dealMedian =
streetDeals?.median_price_rub ?? median(e.actual_deals.map((d) => d.price_rub)); streetDeals?.median_price_rub ??
median(e.actual_deals.map((d) => d.price_rub));
const row3Value = const row3Value =
dealCount > 0 dealCount > 0
? `${dealCount}${dealMedian != null ? ` · ${fmtMln(dealMedian)} млн` : ""}` ? `${dealCount}${dealMedian != null ? ` · ${fmtMln(dealMedian)} млн` : ""}`
@ -876,7 +958,7 @@ export function mapSummary(
return { return {
rows, rows,
quality: { quality: {
sources: `${e.sources_used.length} / ${TOTAL_SOURCES}`, sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`,
confidence: CONFIDENCE_RU[e.confidence], confidence: CONFIDENCE_RU[e.confidence],
cv: cvStr(e.analogs.map((a) => a.price_per_m2)), cv: cvStr(e.analogs.map((a) => a.price_per_m2)),
}, },
@ -911,42 +993,47 @@ function numRu(v: number | null | undefined): string {
return Math.round(v).toLocaleString("ru-RU"); return Math.round(v).toLocaleString("ru-RU");
} }
/** rub -> млн, ru, exactly 1dp. 4700000 -> "4,7". (compact KPI range parts) */ /** "{lo} {hi}" млн range, ru 2dp, NBSP-padded en-dash (dkpKpi / marketDeals). Missing -> "—". */
function mln1(rub: number): string {
return (rub / 1e6).toLocaleString("ru-RU", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
}
/** "{lo} {hi}" млн range with spaces (dkpKpi / marketDeals). Missing -> "—". */
function mlnRangeSp( function mlnRangeSp(
lo: number | null | undefined, lo: number | null | undefined,
hi: number | null | undefined, hi: number | null | undefined,
): string { ): string {
if (lo == null || hi == null || !Number.isFinite(lo) || !Number.isFinite(hi)) { if (
lo == null ||
hi == null ||
!Number.isFinite(lo) ||
!Number.isFinite(hi)
) {
return "—"; return "—";
} }
return `${mln1(lo)} ${mln1(hi)}`; return `${fmtMln(lo)}${NBSP}${NBSP}${fmtMln(hi)}`;
} }
/** "{lo}{hi}" млн range, no spaces (marketAds P25P75). Missing -> "—". */ /** Same style as mlnRangeSp (marketAds P25P75), ru 2dp, NBSP en-dash. Missing -> "—". */
function mlnRangeTight( function mlnRangeTight(
lo: number | null | undefined, lo: number | null | undefined,
hi: number | null | undefined, hi: number | null | undefined,
): string { ): string {
if (lo == null || hi == null || !Number.isFinite(lo) || !Number.isFinite(hi)) { if (
lo == null ||
hi == null ||
!Number.isFinite(lo) ||
!Number.isFinite(hi)
) {
return "—"; return "—";
} }
return `${mln1(lo)}${mln1(hi)}`; return `${fmtMln(lo)}${NBSP}${NBSP}${fmtMln(hi)}`;
} }
/** Signed percent, always 1dp, "." separator + ru minus. -36 -> "36.0%". */ /** Signed percent, always 1dp, ru comma separator + ru minus. -36 -> "36,0%". */
function pct1(v: number | null | undefined): string { function pct1(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return "—"; if (v == null || !Number.isFinite(v)) return "—";
const r = Number(v.toFixed(1)); const r = Number(v.toFixed(1));
const sign = r < 0 ? "" : r > 0 ? "+" : ""; const sign = r < 0 ? "" : r > 0 ? "+" : "";
return `${sign}${Math.abs(r).toFixed(1)}%`; return `${sign}${Math.abs(r).toLocaleString("ru-RU", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})}%`;
} }
/** /**
@ -968,11 +1055,11 @@ function bargainSigned(pct: number | null | undefined): string {
/** Distance label: <1km -> "850 м", else "1.2 км". null/NaN -> "—". */ /** Distance label: <1km -> "850 м", else "1.2 км". null/NaN -> "—". */
function fmtDist(m: number | null | undefined): string { function fmtDist(m: number | null | undefined): string {
if (m == null || !Number.isFinite(m)) return "—"; if (m == null || !Number.isFinite(m)) return "—";
if (m < 1000) return `${Math.round(m)} м`; if (m < 1000) return `${Math.round(m)}${NBSP}м`;
return `${(m / 1000).toLocaleString("ru-RU", { return `${(m / 1000).toLocaleString("ru-RU", {
minimumFractionDigits: 1, minimumFractionDigits: 1,
maximumFractionDigits: 1, maximumFractionDigits: 1,
})} км`; })}${NBSP}км`;
} }
// Source key -> brand/RU display label (badges + link source). // Source key -> brand/RU display label (badges + link source).
@ -1013,9 +1100,11 @@ function composeLot(
if (rooms == null) parts.push("Квартира"); if (rooms == null) parts.push("Квартира");
else if (rooms <= 0) parts.push("Студия"); else if (rooms <= 0) parts.push("Студия");
else parts.push(`${rooms}-к. квартира`); else parts.push(`${rooms}-к. квартира`);
if (area != null && Number.isFinite(area)) parts.push(`${area} м²`); if (area != null && Number.isFinite(area)) parts.push(fmtArea(area));
if (floor != null) { if (floor != null) {
parts.push(totalFloors != null ? `${floor}/${totalFloors} эт.` : `${floor} эт.`); parts.push(
totalFloors != null ? `${floor}/${totalFloors} эт.` : `${floor} эт.`,
);
} }
return parts.join(", "); return parts.join(", ");
} }
@ -1023,10 +1112,14 @@ function composeLot(
/** "32.5 м² · 1-к · этаж 12/15" for an analog ad row. */ /** "32.5 м² · 1-к · этаж 12/15" for an analog ad row. */
function adMeta(l: AnalogLot): string { function adMeta(l: AnalogLot): string {
const parts: string[] = []; const parts: string[] = [];
if (Number.isFinite(l.area_m2)) parts.push(`${l.area_m2.toFixed(1)} м²`); if (Number.isFinite(l.area_m2)) parts.push(fmtArea(l.area_m2));
parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}`); parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}`);
if (l.floor != null) { if (l.floor != null) {
parts.push(l.total_floors != null ? `этаж ${l.floor}/${l.total_floors}` : `этаж ${l.floor}`); parts.push(
l.total_floors != null
? `этаж ${l.floor}/${l.total_floors}`
: `этаж ${l.floor}`,
);
} }
return parts.join(" · "); return parts.join(" · ");
} }
@ -1034,7 +1127,7 @@ function adMeta(l: AnalogLot): string {
/** "38.1 м² · 1-к" for a deal row (no floor in the design). */ /** "38.1 м² · 1-к" for a deal row (no floor in the design). */
function dealMeta(l: AnalogLot): string { function dealMeta(l: AnalogLot): string {
const parts: string[] = []; const parts: string[] = [];
if (Number.isFinite(l.area_m2)) parts.push(`${l.area_m2.toFixed(1)} м²`); if (Number.isFinite(l.area_m2)) parts.push(fmtArea(l.area_m2));
parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}`); parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}`);
return parts.join(" · "); return parts.join(" · ");
} }
@ -1103,7 +1196,9 @@ export function mapHistory(
); );
} }
if (salesVsListings?.median_discount_pct != null) { if (salesVsListings?.median_discount_pct != null) {
noteParts.push(`медианный торг ${pct1(salesVsListings.median_discount_pct)}`); noteParts.push(
`медианный торг ${pct1(salesVsListings.median_discount_pct)}`,
);
} }
const note = const note =
(noteParts.length > 0 ? `${noteParts.join(" · ")}. ` : "") + (noteParts.length > 0 ? `${noteParts.join(" · ")}. ` : "") +
@ -1137,7 +1232,7 @@ export function mapHistory(
? tokens.success ? tokens.success
: tokens.danger; : tokens.danger;
return { return {
area: `${pr.deal_area_m2.toFixed(1)} м²`, area: fmtArea(pr.deal_area_m2),
price: `${numRu(pr.deal_price_rub)}`, price: `${numRu(pr.deal_price_rub)}`,
ppm: numRu(pr.deal_price_per_m2), ppm: numRu(pr.deal_price_per_m2),
ask, ask,
@ -1163,7 +1258,8 @@ function buildAnalyticsKpis(k: HouseAnalyticsKpi | null): AnalyticsKpi[] {
return [ return [
{ {
label: "СРЕДНЯЯ ЭКСПОЗИЦИЯ", label: "СРЕДНЯЯ ЭКСПОЗИЦИЯ",
value: k?.median_exposure_days != null ? String(k.median_exposure_days) : "—", value:
k?.median_exposure_days != null ? String(k.median_exposure_days) : "—",
unit: "дн.", unit: "дн.",
sub: "по архивным объявлениям", sub: "по архивным объявлениям",
}, },
@ -1195,7 +1291,13 @@ const SELLTIME_META: Record<
// 4-column shell kept (with "—") when sell-time data is absent. // 4-column shell kept (with "—") when sell-time data is absent.
const SELLTIME_FALLBACK: SellTimeTier[] = [ const SELLTIME_FALLBACK: SellTimeTier[] = [
{ tier: "5% от рынка", days: "—", range: "—", count: "—", variant: "success" }, {
tier: "5% от рынка",
days: "—",
range: "—",
count: "—",
variant: "success",
},
{ tier: "По медиане", days: "—", range: "—", count: "—", variant: "accent" }, { tier: "По медиане", days: "—", range: "—", count: "—", variant: "accent" },
{ tier: "+5%", days: "—", range: "—", count: "—", variant: "gold" }, { tier: "+5%", days: "—", range: "—", count: "—", variant: "gold" },
{ tier: "+10%", days: "—", range: "—", count: "—", variant: "danger" }, { tier: "+10%", days: "—", range: "—", count: "—", variant: "danger" },
@ -1207,12 +1309,16 @@ function buildSellTime(s: SellTimeSensitivityResponse | null): SellTimeTier[] {
const meta = const meta =
SELLTIME_META[b.price_premium_label] ?? SELLTIME_META[b.price_premium_label] ??
({ ({
tier: b.price_premium_pct === 0 ? "По медиане" : fmtPct(b.price_premium_pct), tier:
b.price_premium_pct === 0
? "По медиане"
: fmtPct(b.price_premium_pct),
variant: "accent", variant: "accent",
} as { tier: string; variant: SellTimeTier["variant"] }); } as { tier: string; variant: SellTimeTier["variant"] });
return { return {
tier: meta.tier, tier: meta.tier,
days: b.median_exposure_days != null ? `~${b.median_exposure_days} дн.` : "—", days:
b.median_exposure_days != null ? `~${b.median_exposure_days} дн.` : "—",
range: range:
b.p25_days != null && b.p75_days != null b.p25_days != null && b.p75_days != null
? `обычно ${b.p25_days}${b.p75_days} дн.` ? `обычно ${b.p25_days}${b.p75_days} дн.`
@ -1254,9 +1360,7 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
const series = (src: string): string => const series = (src: string): string =>
ph ph
.filter( .filter((p) => p.source === src && Number.isFinite(p.median_price_per_m2))
(p) => p.source === src && Number.isFinite(p.median_price_per_m2),
)
.sort((x, y) => x.year - y.year) .sort((x, y) => x.year - y.year)
.map((p) => { .map((p) => {
const x = Math.round(phYearX(yearIndex.get(p.year) ?? 0, n)); const x = Math.round(phYearX(yearIndex.get(p.year) ?? 0, n));
@ -1313,8 +1417,7 @@ function detailExposurePts(
return lots return lots
.map((l) => ({ x: dom(l), y: l.price_rub })) .map((l) => ({ x: dom(l), y: l.price_rub }))
.filter( .filter(
(p): p is { x: number; y: number } => (p): p is { x: number; y: number } => p.x != null && Number.isFinite(p.y),
p.x != null && Number.isFinite(p.y),
); );
} }
@ -1474,6 +1577,8 @@ export interface SourcesData {
dealRows: DealRowData[]; dealRows: DealRowData[];
marketAds: MarketAds; marketAds: MarketAds;
marketDeals: MarketDeals; marketDeals: MarketDeals;
/** Canonical active-source counter for the overlay header, e.g. "4 / 7". */
sourceCount?: string;
} }
// Filter chips: distance corridor + rooms + analog area span (design order). // Filter chips: distance corridor + rooms + analog area span (design order).
@ -1485,17 +1590,28 @@ function buildAdFilters(e: AggregatedEstimate | null): string[] {
.filter((d): d is number => d != null && Number.isFinite(d)); .filter((d): d is number => d != null && Number.isFinite(d));
if (dists.length > 0) { if (dists.length > 0) {
const maxKm = Math.max(...dists) / 1000; const maxKm = Math.max(...dists) / 1000;
const km = maxKm < 1 ? maxKm.toFixed(1) : String(Math.ceil(maxKm)); const km =
filters.push(`Расстояние ≤ ${km} км`); maxKm < 1
? maxKm.toLocaleString("ru-RU", { maximumFractionDigits: 1 })
: String(Math.ceil(maxKm));
filters.push(`Расстояние ≤ ${km}${NBSP}км`);
} }
if (e.rooms != null) { if (e.rooms != null) {
filters.push(`Планировка ${e.rooms <= 0 ? "студия" : `${e.rooms}`}`); filters.push(`Планировка ${e.rooms <= 0 ? "студия" : `${e.rooms}`}`);
} }
const areas = e.analogs.map((a) => a.area_m2).filter((x) => Number.isFinite(x)); const areas = e.analogs
.map((a) => a.area_m2)
.filter((x) => Number.isFinite(x));
if (areas.length > 0) { if (areas.length > 0) {
filters.push( const lo = Math.min(...areas).toLocaleString("ru-RU", {
`Площадь ${Math.min(...areas).toFixed(1)} ${Math.max(...areas).toFixed(1)} м²`, minimumFractionDigits: 1,
); maximumFractionDigits: 1,
});
const hi = Math.max(...areas).toLocaleString("ru-RU", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
filters.push(`Площадь ${lo}${NBSP}${NBSP}${hi}${NBSP}м²`);
} }
return filters; return filters;
} }
@ -1570,7 +1686,15 @@ export function mapSources(
}, },
}; };
return { adRows, dealRows, marketAds, marketDeals }; return {
adRows,
dealRows,
marketAds,
marketDeals,
sourceCount: estimate
? `${activeSourceCount(estimate)} / ${TOTAL_SOURCES}`
: "—",
};
} }
// ── 07 ПРЕДЫДУЩИЕ ОЦЕНКИ (CacheView) ───────────────────────────────────────── // ── 07 ПРЕДЫДУЩИЕ ОЦЕНКИ (CacheView) ─────────────────────────────────────────
@ -1626,7 +1750,8 @@ function cacheStatus(iso: string | null | undefined): {
} { } {
if (!iso) return { status: "—", statusColor: tokens.muted }; if (!iso) return { status: "—", statusColor: tokens.muted };
const d = new Date(iso); const d = new Date(iso);
if (Number.isNaN(d.getTime())) return { status: "—", statusColor: tokens.muted }; if (Number.isNaN(d.getTime()))
return { status: "—", statusColor: tokens.muted };
const ageH = (Date.now() - d.getTime()) / 3_600_000; const ageH = (Date.now() - d.getTime()) / 3_600_000;
if (ageH <= CACHE_TTL_HOURS * 0.75) { if (ageH <= CACHE_TTL_HOURS * 0.75) {
return { status: "свежий", statusColor: tokens.success }; return { status: "свежий", statusColor: tokens.success };
@ -1666,7 +1791,9 @@ export function mapCache(history: EstimateHistoryItem[] | null): CacheData {
.map((h) => h.median_price) .map((h) => h.median_price)
.filter((p): p is number => p != null && Number.isFinite(p) && p > 0); .filter((p): p is number => p != null && Number.isFinite(p) && p > 0);
const avg = const avg =
prices.length > 0 ? prices.reduce((a, b) => a + b, 0) / prices.length : null; prices.length > 0
? prices.reduce((a, b) => a + b, 0) / prices.length
: null;
// KPI 3 — доля переоценок: (всего адресов уникальных) / всего адресов. // KPI 3 — доля переоценок: (всего адресов уникальных) / всего адресов.
const seen = new Map<string, number>(); const seen = new Map<string, number>();

View file

@ -42,6 +42,8 @@ export interface ResultCard {
delta?: string; delta?: string;
/** Donut caption, e.g. "К РЫНКУ" (card 2). */ /** Donut caption, e.g. "К РЫНКУ" (card 2). */
deltaLabel?: string; deltaLabel?: string;
/** Reconciliation note under the value (card 2: expected price vs ДКП median). */
note?: string;
/** Nav index opened by the card's "Подробнее →" link. */ /** Nav index opened by the card's "Подробнее →" link. */
nav: number; nav: number;
} }
@ -50,6 +52,8 @@ export interface ResultMeta {
sources: string; sources: string;
confidence: string; confidence: string;
cv: string; cv: string;
/** Data-driven footer provenance, e.g. "ПОСТРОЕНО ПО 6 АНАЛОГАМ И 10 СДЕЛКАМ". */
builtOn?: string;
} }
export interface RangeMarker { export interface RangeMarker {