fix(tradein/v2): UI/UX-аудит P1 quick-wins (#2062) #2066

Merged
lekss361 merged 1 commit from fix/tradein-v2-uiux-p1 into main 2026-06-28 16:34:42 +00:00
10 changed files with 554 additions and 379 deletions
Showing only changes of commit 30e158ad2a - Show all commits

View file

@ -24,7 +24,11 @@ import { ObjectSummary } from "@/components/trade-in/v2/ObjectSummary";
import { Footer } from "@/components/trade-in/v2/Footer";
import SectionOverlay from "@/components/trade-in/v2/SectionOverlay";
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 {
CacheData,
HistoryData,
@ -156,13 +160,7 @@ const retryBtnStyle: CSSProperties = {
// ── Result/summary area state panels (kept inside the page — these are layout
// states, not reusable design components, so they do not belong in v2/). ───
function SkeletonBlock({
height,
flex,
}: {
height?: number;
flex?: number;
}) {
function SkeletonBlock({ height, flex }: { height?: number; flex?: number }) {
return (
<div
className="v2-skel"
@ -399,11 +397,25 @@ export default function TradeInV2Page() {
const [mounted, setMounted] = useState(false);
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();
// Skip the restore fetch once we already hold a fresh result.
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;
// 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 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;
// ── Mapped presentation data (memoised so nav/drawer toggles don't recompute
@ -472,7 +485,8 @@ export default function TradeInV2Page() {
[estimate, streetDealsData],
);
const summaryData = useMemo(
() => (estimate ? mapSummary(estimate, analyticsData, streetDealsData) : null),
() =>
estimate ? mapSummary(estimate, analyticsData, streetDealsData) : null,
[estimate, analyticsData, streetDealsData],
);
@ -604,103 +618,116 @@ export default function TradeInV2Page() {
}
return (
<div
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 style={{ width: 1536 * artboardScale, height: 1024 * artboardScale }}>
<div
style={{
position: "absolute",
inset: FRAME_INSET,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
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",
position: "relative",
width: 1536,
height: 1024,
overflow: "hidden",
background: tokens.gradientBg,
fontFamily: tokens.font.sans,
color: tokens.ink,
transform: `scale(${artboardScale})`,
transformOrigin: "top left",
}}
>
<TopNav
active={nav}
onNavigate={setNav}
reports={reportsCount ?? 0}
user={topNavUser}
onLogout={logout}
/>
<HeroBar
data={{ report, object: objectInfo }}
estimateId={mounted ? currentEstimateId : null}
onOpenInfo={() => setDrawerOpen(true)}
/>
<style>{HELPER_STYLES}</style>
{/* OUTER HUD FRAME */}
<div
style={{
display: "grid",
gridTemplateColumns: "474px 1fr 250px",
gap: 18,
flex: 1,
minHeight: 0,
position: "absolute",
inset: FRAME_INSET,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
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
key={estimate?.estimate_id ?? "new"}
onSubmit={handleSubmit}
isPending={mutation.isPending}
error={apiError}
initialValues={initialValues}
markers={markers}
/>
{middleContent}
{rightContent}
<nav aria-label="Разделы" style={{ display: "contents" }}>
<TopNav
active={nav}
onNavigate={setNav}
reports={reportsCount ?? 0}
user={topNavUser}
onLogout={logout}
/>
</nav>
<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>
<Footer data={report} />
</div>
{nav !== 0 && (
<SectionOverlay
active={nav}
onClose={() => setNav(0)}
onNavigate={setNav}
history={historyData}
analytics={analyticsViewData}
sources={sourcesData}
cache={cacheData}
{nav !== 0 && (
<SectionOverlay
active={nav}
onClose={() => setNav(0)}
onNavigate={setNav}
history={historyData}
analytics={analyticsViewData}
sources={sourcesData}
cache={cacheData}
/>
)}
<LocationDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
/>
)}
<LocationDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
</div>
</div>
);
}

View file

@ -6,11 +6,11 @@ import { tokens } from "./tokens";
import { cacheKpi, cacheRows } from "./fixtures";
import type { CacheData } from "./mappers";
const GRID_COLUMNS = "2.4fr 1fr 1fr 1fr";
const FIXTURE_CACHE: CacheData = { kpis: cacheKpi, rows: cacheRows };
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 (
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
{/* 3 KPI cards */}
@ -87,7 +87,7 @@ export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
<div
style={{
display: "grid",
gridTemplateColumns: GRID_COLUMNS,
gridTemplateColumns: gridCols,
gap: 10,
padding: "9px 18px",
fontSize: 9,
@ -98,7 +98,7 @@ export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
>
<span>АДРЕС</span>
<span>ВРЕМЯ</span>
<span>ИСТОЧНИКОВ</span>
{showSrc && <span>ИСТОЧНИКОВ</span>}
<span>СТАТУС</span>
</div>
{data.rows.map((r, i) => (
@ -106,7 +106,7 @@ export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
key={`${r.addr}-${i}`}
style={{
display: "grid",
gridTemplateColumns: GRID_COLUMNS,
gridTemplateColumns: gridCols,
gap: 10,
padding: "12px 18px",
fontSize: 11,
@ -120,9 +120,13 @@ export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
>
{r.time}
</span>
<span style={{ fontFamily: tokens.font.mono, color: tokens.muted }}>
{r.src}
</span>
{showSrc && (
<span
style={{ fontFamily: tokens.font.mono, color: tokens.muted }}
>
{r.src}
</span>
)}
<span
style={{
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;
const href = `${API_BASE_URL}/api/v1/trade-in/estimate/${estimateId}/pdf`;
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 {
@ -366,16 +368,32 @@ export default function HeroBar({
КОЭФ. ЛОКАЦИИ
</span>
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 15,
fontWeight: 500,
color: tokens.accent,
}}
>
{data.object.locationCoef}
</span>
{data.object.locationCoef === "—" ? (
<span
style={{
fontSize: "9px",
letterSpacing: ".5px",
color: tokens.muted2,
background: tokens.badgeTint,
border: `1px solid ${tokens.line2}`,
borderRadius: 10,
padding: "1px 8px",
}}
>
скоро
</span>
) : (
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 15,
fontWeight: 500,
color: tokens.accent,
}}
>
{data.object.locationCoef}
</span>
)}
<span
style={{
display: "flex",

View file

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

View file

@ -101,7 +101,7 @@ export function ObjectSummary({
background: tokens.success,
}}
/>
В РАСЧЁТЕ
АКТУАЛЬНО
</span>
</div>
@ -140,7 +140,9 @@ export function ObjectSummary({
<circle cx="7.5" cy="6.5" r="2.3" fill={tokens.accent} />
</svg>
<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 }}>
{data.object.city}
</div>

View file

@ -46,6 +46,7 @@ interface DdProps {
triggerBg?: string;
triggerColor?: string;
caretColor?: string;
disabled?: boolean;
}
function Dd({
@ -63,6 +64,7 @@ function Dd({
triggerBg = tokens.surface.w62,
triggerColor = tokens.ink,
caretColor = tokens.muted2,
disabled = false,
}: DdProps) {
const panelStyle: CSSProperties = {
position: "absolute",
@ -88,7 +90,11 @@ function Dd({
<div style={{ position: "relative" }}>
<div
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}
style={{
@ -102,19 +108,21 @@ function Dd({
fontFamily: mono ? tokens.font.mono : tokens.font.sans,
fontSize: triggerFontSize,
color: triggerColor,
cursor: "pointer",
cursor: disabled ? "default" : "pointer",
}}
>
{value}
<span
style={{
color: caretColor,
fontSize: 9,
fontFamily: tokens.font.sans,
}}
>
</span>
{!disabled && (
<span
style={{
color: caretColor,
fontSize: 9,
fontFamily: tokens.font.sans,
}}
>
</span>
)}
</div>
{open && (
<div style={panelStyle}>
@ -149,11 +157,14 @@ const styles = `
.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:hover{border-color:${tokens.accent}}
.pp-dd-trigger-disabled{border:1px dashed ${tokens.line}}
.pp-dd-opt{transition:background .15s}
.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-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-input:focus-visible{outline:none;border-color:${tokens.accent};box-shadow:0 0 0 3px rgba(46,139,255,.18)}
.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:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(46,139,255,.35)}
`;
const hintLabel: CSSProperties = {
@ -343,7 +354,9 @@ export default function ParamsPanel({
}: ParamsPanelProps) {
const [openDd, setOpenDd] = useState<DdKey>(null);
// 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 [address, setAddress] = useState(initialValues?.address ?? "");
const [area, setArea] = useState(
@ -378,7 +391,9 @@ export default function ParamsPanel({
// handler (a plain timer, not a useEffect-for-HTTP); `useGeocodeSuggest` is
// 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.
const [addressQuery, setAddressQuery] = useState(initialValues?.address ?? "");
const [addressQuery, setAddressQuery] = useState(
initialValues?.address ?? "",
);
const [suggestOpen, setSuggestOpen] = useState(false);
const [coords, setCoords] = useState<{ lat: number; lon: number } | 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.
const radiusM = parseInt(radius, 10);
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;
return (
@ -544,16 +561,6 @@ export default function ParamsPanel({
ПАРАМЕТРЫ КВАРТИРЫ
</span>
</div>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 10,
letterSpacing: 1,
color: tokens.muted2,
}}
>
ШАГ 1/1
</span>
</div>
{/* MAP */}
@ -580,97 +587,97 @@ export default function ParamsPanel({
transition: "transform .15s ease-out",
}}
>
<svg
viewBox="0 0 440 210"
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
<svg
viewBox="0 0 440 210"
preserveAspectRatio="xMidYMid slice"
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)",
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
}}
>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: tokens.accent,
}}
/>
<span style={{ fontSize: 11, fontWeight: 600 }}>
{address.trim() || "Адрес квартиры"}
</span>
</div>
<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={{
width: 0,
height: 0,
borderLeft: "5px solid transparent",
borderRight: "5px solid transparent",
borderTop: `7px solid ${tokens.accent}`,
position: "absolute",
left: "47%",
top: "48%",
transform: "translate(-50%,-100%)",
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 +
radius rings (Finding #2: never the fabricated fixture prices/dots). */}
{markers.map((m, i) => (
<div key={i} style={{ ...analogPin, left: m.left, top: m.top }}>
{m.label}
<br />
<span style={{ color: tokens.muted }}>{m.sub}</span>
</div>
))}
{markers.map((m, i) => (
<div key={i} style={{ ...analogPin, left: m.left, top: m.top }}>
{m.label}
<br />
<span style={{ color: tokens.muted }}>{m.sub}</span>
</div>
))}
</div>
{/* map controls */}
@ -713,38 +720,18 @@ export default function ParamsPanel({
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
cursor: zoom <= 1 ? "default" : "pointer",
transition: "all .15s",
fontSize: 15,
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
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>
{/* map corner bracket */}
@ -873,6 +860,7 @@ export default function ParamsPanel({
HUD <Dd> panel styling. Picking an item fills the full address
and captures lat/lon for the estimate payload. */}
<input
className="pp-input"
type="text"
value={address}
onChange={(e) => handleAddressChange(e.target.value)}
@ -941,12 +929,11 @@ export default function ParamsPanel({
<div>
<div style={hintLabel}>ПЛОЩАДЬ, М²</div>
<input
className="pp-input"
type="text"
inputMode="decimal"
value={area}
onChange={(e) =>
setArea(e.target.value.replace(/[^\d.,]/g, ""))
}
onChange={(e) => setArea(e.target.value.replace(/[^\d.,]/g, ""))}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmit();
}}
@ -976,6 +963,7 @@ export default function ParamsPanel({
<span style={optHint}>если знаешь</span>
</div>
<input
className="pp-input"
type="text"
inputMode="numeric"
value={floor}
@ -993,6 +981,7 @@ export default function ParamsPanel({
<span style={optHint}>если знаешь</span>
</div>
<input
className="pp-input"
type="text"
inputMode="numeric"
value={totalFloors}
@ -1026,6 +1015,7 @@ export default function ParamsPanel({
<span style={optHint}>опц.</span>
</div>
<input
className="pp-input"
type="text"
inputMode="numeric"
value={year}
@ -1094,7 +1084,7 @@ export default function ParamsPanel({
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>CRM ДЛЯ МЕНЕДЖЕРА</span>
<span style={optHint}>опц.</span>
<span style={optHint}>скоро</span>
</div>
{/* CRM non-functional (no backend, #395). Markup kept; open is
forced false + no-op handlers so it never expands. TODO(wire). */}
@ -1113,6 +1103,7 @@ export default function ParamsPanel({
triggerBg={tokens.surface.w50}
triggerColor={tokens.hint}
caretColor={tokens.muted4}
disabled
/>
</div>
</div>
@ -1136,7 +1127,7 @@ export default function ParamsPanel({
cursor: isPending ? "wait" : "pointer",
opacity: isPending ? 0.6 : 1,
fontFamily: tokens.font.sans,
color: tokens.ink,
color: tokens.onAccent,
flex: "0 0 auto",
}}
>
@ -1152,9 +1143,9 @@ export default function ParamsPanel({
justifyContent: "center",
width: 26,
height: 26,
border: `1px solid ${tokens.accent}`,
border: "1px solid rgba(255,255,255,.7)",
borderRadius: "50%",
color: tokens.accent,
color: tokens.onAccent,
}}
>

View file

@ -231,6 +231,19 @@ export default function ResultPanel({
>
{card.ppm}
</div>
{card.note && (
<div
style={{
fontSize: 9,
color: muted2,
marginTop: 4,
lineHeight: 1.4,
maxWidth: "82%",
}}
>
{card.note}
</div>
)}
{card.bars ? (
<div
@ -885,7 +898,7 @@ export default function ResultPanel({
При недоступности источника частичный результат не блокируется
</span>
<span style={{ fontSize: 9.5, letterSpacing: 1.5, color: muted2 }}>
ПОСТРОЕНО ПО 6 АНАЛОГАМ И 10 СДЕЛКАМ ЗА 6 МЕСЯЦЕВ
{data.meta.builtOn ?? "—"}
</span>
</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).
const KPI_BG = "rgba(238,244,250,.5)";
const BADGE_BG = "#eaf1f8";
const AVITO_DOT = "#e8743b";
const AD_GRID = "2.4fr 1fr 1fr 1.2fr .7fr 1fr";
const DEAL_GRID = "2.6fr 1.2fr 1fr 1.2fr 1fr";
@ -187,7 +186,9 @@ interface SourcesViewProps {
data?: SourcesData;
}
export default function SourcesView({ data = SOURCES_FIXTURE }: SourcesViewProps) {
export default function SourcesView({
data = SOURCES_FIXTURE,
}: SourcesViewProps) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
<style>{`
@ -208,7 +209,8 @@ export default function SourcesView({ data = SOURCES_FIXTURE }: SourcesViewProps
</div>
<div style={headerRight}>
Обновлено: <br />
Источников: <b style={{ color: tokens.muted }}>2 / 7</b>
Источников:{" "}
<b style={{ color: tokens.muted }}>{data.sourceCount ?? "—"}</b>
</div>
</div>
@ -216,7 +218,7 @@ export default function SourcesView({ data = SOURCES_FIXTURE }: SourcesViewProps
<KpiCell
label="ОБЪЯВЛЕНИЙ ПО АНАЛОГАМ"
value={data.marketAds.kpi.count}
unit="шт · из 2 источников"
unit="шт"
/>
<KpiCell
label="МЕДИАНА"
@ -255,26 +257,6 @@ export default function SourcesView({ data = SOURCES_FIXTURE }: SourcesViewProps
{f}
</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

View file

@ -140,6 +140,10 @@ export const REPAIR_FROM_RU: Record<string, RepairState> = {
// ── 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 -> "—". */
export function fmtMln(rub: number | null | undefined): string {
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")} ₽/м²`;
}
/** 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 -> "—". */
export function fmtDate(iso: string | null | undefined): string {
if (!iso) return "—";
@ -228,7 +238,12 @@ function coeffVar(values: number[]): number | null {
function cvStr(values: number[]): string {
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,
hi: number | null | undefined,
): 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 `${fmtMln(lo)}${fmtMln(hi)} млн ₽`;
return `${fmtMln(lo)}${NBSP}${NBSP}${fmtMln(hi)}${NBSP}млн${NBSP}`;
}
// ── Address parsing (TODO BE-2) ─────────────────────────────────────────────
@ -384,7 +404,8 @@ function parseAddress(full: string | null): { address: string; city: string } {
city = tokenMatch[1].trim();
} else {
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
@ -444,17 +465,32 @@ function buildSources(e: AggregatedEstimate): SourceCard[] {
const count = slot.keys.reduce((sum, k) => sum + (counts.get(k) ?? 0), 0);
const active = slot.keys.some((k) => used.has(k)) || count > 0;
if (!active) {
return { name: slot.name, count: "—", label: "нет данных", active: false };
return {
name: slot.name,
count: "—",
label: "нет данных",
active: false,
};
}
return {
name: slot.name,
count: count > 0 ? String(count) : "—",
label: count > 0 ? pluralRu(count, ["лот", "лота", "лотов"]) : "нет данных",
label:
count > 0 ? pluralRu(count, ["лот", "лота", "лотов"]) : "нет данных",
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 / Росреестр) ──────────────────────────────────
interface DealTier {
@ -559,7 +595,10 @@ function buildScatterMini(
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 subjectPrice = e.expected_sold_price_rub ?? e.median_price_rub;
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.
// 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
// the 496% margin. Closest ~6 by distance are shown (the pin chrome only fits a
// handful). null / empty / un-geocoded estimate → [] (the map shows just the
// subject pin, never the fake fixture prices).
// the 496% margin. Finding #12: the closest pins overlapped on the small map, so
// a greedy non-overlap pass keeps at most KEEP_CAP pins that are ≥ MIN_PIN_DIST
// 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. */
export interface MapMarker {
@ -674,9 +714,13 @@ export interface MapMarker {
// analogs toward the centre, smaller pushes them to the clamped margins.
const MAP_LAT_HALF_SPAN = 0.011;
const MAP_LON_HALF_SPAN = 0.018;
const MAP_MARKER_CAP = 6;
const MAP_CLAMP_LO = 4;
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 → []. */
export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] {
@ -705,10 +749,15 @@ export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] {
: fallbackM;
return { a, dist };
})
.sort((x, y) => x.dist - y.dist)
.slice(0, MAP_MARKER_CAP);
.sort((x, y) => x.dist - y.dist);
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(
50 + ((a.lon - tLon) / MAP_LON_HALF_SPAN) * 50,
MAP_CLAMP_LO,
@ -719,13 +768,19 @@ export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] {
MAP_CLAMP_LO,
MAP_CLAMP_HI,
);
return {
left: `${round1(left)}%`,
top: `${round1(top)}%`,
label: `${fmtMln(a.price_rub)} млн ₽`,
sub: Number.isFinite(a.area_m2) ? `${Math.round(a.area_m2)} м²` : "—",
};
});
const overlaps = kept.some(
(k) => Math.hypot(k.left - left, k.top - top) < MIN_PIN_DIST,
);
if (overlaps) continue;
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. */
@ -746,23 +801,35 @@ export function mapResultPanel(
nav: 2,
},
{
title: ["ОЖИДАЕМАЯ СДЕЛКА", "(ПОЛУЧИТЕ)"],
title: ["ОЖИДАЕМАЯ ЦЕНА", "СДЕЛКИ"],
value: 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: rangeLine(
e.expected_sold_range_low_rub,
e.expected_sold_range_high_rub,
),
ppm: `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга`,
delta:
e.asking_to_sold_ratio != null
? fmtPct((e.asking_to_sold_ratio - 1) * 100)
: "—",
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,
},
{
title: ["ДКП · РОСРЕЕСТР", "(ФАКТИЧЕСКИЕ СДЕЛКИ)"],
value: dealTier ? fmtMln(dealTier.medianRub) : "—",
unit: "млн ₽",
range: dealTier ? rangeLine(dealTier.loRub, dealTier.hiRub) : "нет данных",
range: dealTier
? rangeLine(dealTier.loRub, dealTier.hiRub)
: "нет данных",
ppm: dealTier
? `${fmtPpm(dealTier.medianPpm)} · ${dealTier.count} ${pluralRu(
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 = {
sources: `${e.sources_used.length} / ${TOTAL_SOURCES}`,
sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`,
confidence: CONFIDENCE_RU[e.confidence].toUpperCase(),
cv: cvStr(e.analogs.map((a) => a.price_per_m2)),
builtOn,
};
const adsBar: RangeBar = {
@ -832,7 +913,8 @@ export function mapSummary(
const dealCount = streetDeals?.count ?? e.actual_deals.length;
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 =
dealCount > 0
? `${dealCount}${dealMedian != null ? ` · ${fmtMln(dealMedian)} млн` : ""}`
@ -876,7 +958,7 @@ export function mapSummary(
return {
rows,
quality: {
sources: `${e.sources_used.length} / ${TOTAL_SOURCES}`,
sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`,
confidence: CONFIDENCE_RU[e.confidence],
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");
}
/** rub -> млн, ru, exactly 1dp. 4700000 -> "4,7". (compact KPI range parts) */
function mln1(rub: number): string {
return (rub / 1e6).toLocaleString("ru-RU", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
}
/** "{lo} {hi}" млн range with spaces (dkpKpi / marketDeals). Missing -> "—". */
/** "{lo} {hi}" млн range, ru 2dp, NBSP-padded en-dash (dkpKpi / marketDeals). Missing -> "—". */
function mlnRangeSp(
lo: number | null | undefined,
hi: number | null | undefined,
): 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 `${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(
lo: number | null | undefined,
hi: number | null | undefined,
): 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 `${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 {
if (v == null || !Number.isFinite(v)) return "—";
const r = Number(v.toFixed(1));
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 -> "—". */
function fmtDist(m: number | null | undefined): string {
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", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})} км`;
})}${NBSP}км`;
}
// Source key -> brand/RU display label (badges + link source).
@ -1013,9 +1100,11 @@ function composeLot(
if (rooms == null) parts.push("Квартира");
else if (rooms <= 0) parts.push("Студия");
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) {
parts.push(totalFloors != null ? `${floor}/${totalFloors} эт.` : `${floor} эт.`);
parts.push(
totalFloors != null ? `${floor}/${totalFloors} эт.` : `${floor} эт.`,
);
}
return parts.join(", ");
}
@ -1023,10 +1112,14 @@ function composeLot(
/** "32.5 м² · 1-к · этаж 12/15" for an analog ad row. */
function adMeta(l: AnalogLot): 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}`);
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(" · ");
}
@ -1034,7 +1127,7 @@ function adMeta(l: AnalogLot): string {
/** "38.1 м² · 1-к" for a deal row (no floor in the design). */
function dealMeta(l: AnalogLot): 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}`);
return parts.join(" · ");
}
@ -1103,7 +1196,9 @@ export function mapHistory(
);
}
if (salesVsListings?.median_discount_pct != null) {
noteParts.push(`медианный торг ${pct1(salesVsListings.median_discount_pct)}`);
noteParts.push(
`медианный торг ${pct1(salesVsListings.median_discount_pct)}`,
);
}
const note =
(noteParts.length > 0 ? `${noteParts.join(" · ")}. ` : "") +
@ -1137,7 +1232,7 @@ export function mapHistory(
? tokens.success
: tokens.danger;
return {
area: `${pr.deal_area_m2.toFixed(1)} м²`,
area: fmtArea(pr.deal_area_m2),
price: `${numRu(pr.deal_price_rub)}`,
ppm: numRu(pr.deal_price_per_m2),
ask,
@ -1163,7 +1258,8 @@ function buildAnalyticsKpis(k: HouseAnalyticsKpi | null): AnalyticsKpi[] {
return [
{
label: "СРЕДНЯЯ ЭКСПОЗИЦИЯ",
value: k?.median_exposure_days != null ? String(k.median_exposure_days) : "—",
value:
k?.median_exposure_days != null ? String(k.median_exposure_days) : "—",
unit: "дн.",
sub: "по архивным объявлениям",
},
@ -1195,7 +1291,13 @@ const SELLTIME_META: Record<
// 4-column shell kept (with "—") when sell-time data is absent.
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: "+5%", days: "—", range: "—", count: "—", variant: "gold" },
{ tier: "+10%", days: "—", range: "—", count: "—", variant: "danger" },
@ -1207,12 +1309,16 @@ function buildSellTime(s: SellTimeSensitivityResponse | null): SellTimeTier[] {
const meta =
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",
} as { tier: string; variant: SellTimeTier["variant"] });
return {
tier: meta.tier,
days: b.median_exposure_days != null ? `~${b.median_exposure_days} дн.` : "—",
days:
b.median_exposure_days != null ? `~${b.median_exposure_days} дн.` : "—",
range:
b.p25_days != null && b.p75_days != null
? `обычно ${b.p25_days}${b.p75_days} дн.`
@ -1254,9 +1360,7 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
const series = (src: string): string =>
ph
.filter(
(p) => p.source === src && Number.isFinite(p.median_price_per_m2),
)
.filter((p) => p.source === src && Number.isFinite(p.median_price_per_m2))
.sort((x, y) => x.year - y.year)
.map((p) => {
const x = Math.round(phYearX(yearIndex.get(p.year) ?? 0, n));
@ -1313,8 +1417,7 @@ function detailExposurePts(
return lots
.map((l) => ({ x: dom(l), y: l.price_rub }))
.filter(
(p): p is { x: number; y: number } =>
p.x != null && Number.isFinite(p.y),
(p): p is { x: number; y: number } => p.x != null && Number.isFinite(p.y),
);
}
@ -1474,6 +1577,8 @@ export interface SourcesData {
dealRows: DealRowData[];
marketAds: MarketAds;
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).
@ -1485,17 +1590,28 @@ function buildAdFilters(e: AggregatedEstimate | null): string[] {
.filter((d): d is number => d != null && Number.isFinite(d));
if (dists.length > 0) {
const maxKm = Math.max(...dists) / 1000;
const km = maxKm < 1 ? maxKm.toFixed(1) : String(Math.ceil(maxKm));
filters.push(`Расстояние ≤ ${km} км`);
const km =
maxKm < 1
? maxKm.toLocaleString("ru-RU", { maximumFractionDigits: 1 })
: String(Math.ceil(maxKm));
filters.push(`Расстояние ≤ ${km}${NBSP}км`);
}
if (e.rooms != null) {
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) {
filters.push(
`Площадь ${Math.min(...areas).toFixed(1)} ${Math.max(...areas).toFixed(1)} м²`,
);
const lo = Math.min(...areas).toLocaleString("ru-RU", {
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;
}
@ -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) ─────────────────────────────────────────
@ -1626,7 +1750,8 @@ function cacheStatus(iso: string | null | undefined): {
} {
if (!iso) return { status: "—", statusColor: tokens.muted };
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;
if (ageH <= CACHE_TTL_HOURS * 0.75) {
return { status: "свежий", statusColor: tokens.success };
@ -1666,7 +1791,9 @@ export function mapCache(history: EstimateHistoryItem[] | null): CacheData {
.map((h) => h.median_price)
.filter((p): p is number => p != null && Number.isFinite(p) && p > 0);
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 — доля переоценок: (всего адресов уникальных) / всего адресов.
const seen = new Map<string, number>();

View file

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