Merge pull request 'fix(tradein/v2): audit findings — de-fabricate location drawer, real map markers, validation, hero image, real user' (#2057) from feat/tradein-v2-audit-fixes into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m24s
Deploy Trade-In / deploy (push) Successful in 1m2s

This commit is contained in:
lekss361 2026-06-28 14:27:05 +00:00
commit fb25180929
7 changed files with 323 additions and 321 deletions

View file

@ -34,6 +34,7 @@ import {
mapAnalytics,
mapCache,
mapHistory,
mapMarkers,
mapObject,
mapReport,
mapResultPanel,
@ -57,6 +58,8 @@ import {
useStreetDeals,
} from "@/lib/trade-in-api";
import { useQuota } from "@/lib/useQuota";
import { useMe } from "@/lib/useMe";
import { logout } from "@/lib/logout";
// OUTER HUD FRAME + 4 corner brackets (design lines 31-37). Decorative,
// non-interactive overlay drawn over the artboard gradient. The frame has
@ -409,6 +412,11 @@ export default function TradeInV2Page() {
// via invalidateQueries in handleSubmit.
const history = useEstimateHistory();
const quota = useQuota();
// Real logged-in identity for the TopNav user menu (replaces the design
// fixture). /me returns {username, role, brand} — no display name / email, so
// name falls back to username, org to brand ?? role, email to "" (renders
// blank, never invented). undefined while loading → TopNav «Гость» fallback.
const me = useMe();
// Dashboard sub-hooks — each resolves independently; failure degrades its
// section via the mappers (null input) rather than blanking the page.
@ -497,6 +505,24 @@ export default function TradeInV2Page() {
// to the fixture default (undefined) for unwired/pre-load states.
const reportsCount = quota.data?.used ?? history.data?.length;
// Real user for the TopNav menu. undefined until /me resolves → TopNav shows
// its neutral «Гость» fallback (never the old "Андрей Петров / Брусника").
const topNavUser = useMemo(() => {
const scope = me.data;
if (!scope) return undefined;
return {
name: scope.username,
org: scope.brand ?? scope.role,
email: "",
initials: scope.username.slice(0, 2).toUpperCase(),
};
}, [me.data]);
// Analog price pins for the 01 map, projected from the real estimate. No
// estimate → mapMarkers(null) → [] → ParamsPanel renders only the subject pin
// (Finding #2: never the static fixture price markers).
const markers = useMemo(() => mapMarkers(estimate), [estimate]);
// Prefill for restore-by-id / re-estimate. Keyed remount applies it when the
// estimate arrives async (useState reads initialValues only on mount).
const initialValues = useMemo<Partial<TradeInEstimateInput> | undefined>(
@ -626,7 +652,13 @@ export default function TradeInV2Page() {
flexDirection: "column",
}}
>
<TopNav active={nav} onNavigate={setNav} reports={reportsCount ?? 0} />
<TopNav
active={nav}
onNavigate={setNav}
reports={reportsCount ?? 0}
user={topNavUser}
onLogout={logout}
/>
<HeroBar
data={{ report, object: objectInfo }}
estimateId={mounted ? currentEstimateId : null}
@ -648,6 +680,7 @@ export default function TradeInV2Page() {
isPending={mutation.isPending}
error={apiError}
initialValues={initialValues}
markers={markers}
/>
{middleContent}
{rightContent}

View file

@ -1,6 +1,6 @@
"use client";
import type { CSSProperties } from "react";
import { useState, type CSSProperties } from "react";
import Image from "next/image";
import { API_BASE_URL } from "@/lib/api";
@ -60,6 +60,9 @@ export default function HeroBar({
onOpenInfo,
}: HeroBarProps) {
const pdfHref = pdfDownloadHref(estimateId);
// Hide the building photo if the asset 404s/400s so the photoBg fill shows
// instead of a broken-image icon.
const [imgFailed, setImgFailed] = useState(false);
const pdfBtnInner = (
<>
<svg width="18" height="20" viewBox="0 0 18 20" fill="none">
@ -202,6 +205,7 @@ export default function HeroBar({
<button
type="button"
className="hero-calc-btn"
onClick={onOpenInfo}
style={{
flex: 1,
height: 50,
@ -239,17 +243,21 @@ export default function HeroBar({
boxShadow: "0 6px 26px rgba(40,80,130,.10)",
}}
>
<Image
src="/trade-in-v2/building.png"
alt=""
fill
sizes="560px"
style={{
objectFit: "contain",
objectPosition: "right center",
filter: "saturate(.9) brightness(1.04)",
}}
/>
{!imgFailed && (
<Image
src="/trade-in-v2/building.png"
alt=""
fill
sizes="560px"
unoptimized
onError={() => setImgFailed(true)}
style={{
objectFit: "contain",
objectPosition: "right center",
filter: "saturate(.9) brightness(1.04)",
}}
/>
)}
<div
style={{
position: "absolute",

View file

@ -7,6 +7,7 @@
import { tokens } from "./tokens";
import { history, dkpRows } from "./fixtures";
import { pluralRu } from "./mappers";
import type { HistoryData } from "./mappers";
// Shared grid templates (kept verbatim from the design column tracks).
@ -58,7 +59,8 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
color: tokens.muted2,
}}
>
{data.houseSales.length} ЛОТА
{data.houseSales.length}{" "}
{pluralRu(data.houseSales.length, ["ЛОТ", "ЛОТА", "ЛОТОВ"])}
</span>
</div>

View file

@ -1,73 +1,35 @@
"use client";
// "КОЭФФИЦИЕНТ ЛОКАЦИИ" right-side drawer for the /trade-in/v2 "МЕРА Оценка"
// design port. Faithful markup port of the design drawer (МЕРА Оценка.dc.html,
// lines 618-660): scrim + 452px right slider that slides in via translateX, a
// big location coefficient, intro, base×coef formula, the factors that raise /
// lower price, and a geo-source footer. Static markup, data from fixtures.
// Open/close is driven entirely by props (no internal state, no fetch).
// "ПОЯСНЕНИЕ К РАСЧЁТУ" right-side drawer for the /trade-in/v2 "МЕРА Оценка"
// design port. Opened from HeroBar (the "?" near "КОЭФ. ЛОКАЦИИ"). This used to
// render a FABRICATED location coefficient (0.87), a fake "base × coef = result"
// formula and invented POI factor lists with a false "Источник: OpenStreetMap"
// footer — none of which the backend produces (location-coef is deferred,
// backend #2045). It is now an HONEST methodology panel with STATIC, truthful
// documentation text only: how the estimate is built, and an explicit note that
// the location coefficient does not exist yet. Keeps the same drawer shell /
// slide animation / close button. Open/close is driven entirely by props; the
// only effect is an Escape-to-close keydown listener while open.
import { useEffect } from "react";
import { tokens } from "./tokens";
import type { LocationFactor } from "./types";
import { locationFactors } from "./fixtures";
interface LocationDrawerProps {
open: boolean;
onClose: () => void;
}
function FactorRow({
factor,
variant,
}: {
factor: LocationFactor;
variant: "pos" | "neg";
}) {
const isPos = variant === "pos";
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
fontSize: 12,
color: tokens.body,
}}
>
<span style={{ display: "flex", alignItems: "center", gap: 9 }}>
<span
style={{
width: 16,
height: 16,
borderRadius: 4,
background: isPos
? "rgba(27,170,107,.12)"
: "rgba(111,129,149,.14)",
color: isPos ? tokens.success : tokens.muted,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: isPos ? 11 : 12,
}}
>
{isPos ? "+" : ""}
</span>
{factor.label}
</span>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 11,
color: isPos ? tokens.success : tokens.muted,
}}
>
{factor.delta}
</span>
</div>
);
}
export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
// Escape-to-close (UI effect only — no HTTP). Active while the drawer is open.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
return (
<>
<style>{`
@ -182,7 +144,7 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
color: tokens.muted2,
}}
>
ПОЯСНЕНИЕ К РАСЧЁТУ
МЕТОДИКА
</div>
<div
style={{
@ -193,7 +155,7 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
marginTop: 8,
}}
>
КОЭФФИЦИЕНТ ЛОКАЦИИ
ПОЯСНЕНИЕ К РАСЧЁТУ
</div>
</div>
<div className="ld-close" onClick={onClose}>
@ -201,81 +163,47 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
</div>
</div>
{/* big coefficient */}
{/* divider under header */}
<div
style={{
display: "flex",
alignItems: "baseline",
gap: 14,
marginTop: 24,
paddingBottom: 20,
marginTop: 22,
borderBottom: `1px solid ${tokens.lineSoft}`,
}}
>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 52,
fontWeight: 300,
color: tokens.accent,
lineHeight: 1,
}}
>
{locationFactors.coef}
</span>
<div style={{ fontSize: 11, color: tokens.muted, lineHeight: 1.5 }}>
множитель к базовой
<br />
цене по городу
</div>
</div>
/>
{/* intro */}
{/* methodology section */}
<div
style={{
fontSize: 9,
letterSpacing: "2px",
color: tokens.muted2,
marginTop: 22,
marginBottom: 12,
}}
>
КАК СЧИТАЕМ
</div>
<div
style={{
fontSize: 12.5,
lineHeight: 1.7,
color: tokens.body2,
marginTop: 20,
}}
>
Показывает, как адрес корректирует стоимость относительно медианы по
Екатеринбургу. <b style={{ color: tokens.ink2 }}>1.00</b> средний
уровень. <b style={{ color: tokens.accent }}>0.87</b> означает, что
локация снижает цену примерно на{" "}
<b style={{ color: tokens.ink2 }}>13%</b> из-за баланса факторов ниже.
Агрегируем объявления (Авито, Циан, Яндекс, Домклик) и сделки
Росреестра по сопоставимым квартирам. Медиана{" "}
<b style={{ color: tokens.ink2 }}>/м²</b> 3 оценки:{" "}
<b style={{ color: tokens.ink2 }}>
рекомендованная цена в объявлении
</b>{" "}
(asking),{" "}
<b style={{ color: tokens.ink2 }}>ожидаемая цена сделки</b> (asking
торг по ДКП),{" "}
<b style={{ color: tokens.ink2 }}>ДКП·Росреестр</b> (фактические
сделки). CV коэффициент вариации выборки (разброс цен).
</div>
{/* formula */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
marginTop: 18,
background: tokens.surface.w55,
border: `1px solid ${tokens.lineSoft}`,
borderRadius: 7,
padding: "12px 14px",
fontFamily: tokens.font.mono,
fontSize: 12,
color: tokens.ink2,
}}
>
<span style={{ color: tokens.muted }}>
{locationFactors.formula.base}
</span>
<span style={{ color: tokens.muted2 }}>×</span>
<span style={{ color: tokens.accent }}>
{locationFactors.formula.coef}
</span>
<span style={{ color: tokens.muted2 }}>=</span>
<span style={{ fontWeight: 500 }}>
{locationFactors.formula.result}
</span>
</div>
{/* positives */}
{/* location note section */}
<div
style={{
fontSize: 9,
@ -285,44 +213,22 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
marginBottom: 12,
}}
>
ЧТО ПОВЫШАЕТ ЦЕНУ
ЛОКАЦИЯ
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 9 }}>
{locationFactors.positives.map((f, i) => (
<FactorRow key={i} factor={f} variant="pos" />
))}
</div>
{/* negatives */}
<div
style={{
fontSize: 9,
letterSpacing: "2px",
color: tokens.muted2,
marginTop: 24,
marginBottom: 12,
fontSize: 12.5,
lineHeight: 1.7,
color: tokens.body2,
background: tokens.infoSoftBg,
border: `1px solid ${tokens.infoSoftBorder}`,
borderRadius: 7,
padding: "12px 14px",
}}
>
ЧТО СНИЖАЕТ ЦЕНУ
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 9 }}>
{locationFactors.negatives.map((f, i) => (
<FactorRow key={i} factor={f} variant="neg" />
))}
</div>
{/* footer source */}
<div
style={{
marginTop: 24,
paddingTop: 16,
borderTop: `1px solid ${tokens.lineSoft}`,
fontSize: 10.5,
lineHeight: 1.6,
color: tokens.hint,
}}
>
{locationFactors.footer}
Коэффициент локации (POI, транспорт, шум) {" "}
<b style={{ color: tokens.ink2 }}>в разработке</b>; текущая оценка по
локации не корректируется.
</div>
</div>
</>

View file

@ -18,6 +18,7 @@ import {
HOUSE_TYPE_RU,
REPAIR_FROM_RU,
REPAIR_RU,
type MapMarker,
} from "./mappers";
import { useGeocodeSuggest } from "@/lib/trade-in-api";
import type {
@ -257,6 +258,21 @@ const errorText: CSSProperties = {
color: tokens.danger,
};
// Analog price pin on the 01 map. left/top come from mapMarkers() per-analog
// (projected from the real estimate); the chrome matches the former fixture
// pins 1:1 — only the data source changed (Finding #2).
const analogPin: CSSProperties = {
position: "absolute",
background: tokens.surface.w70,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
padding: "3px 7px",
fontFamily: tokens.font.mono,
fontSize: 9,
lineHeight: 1.4,
color: tokens.ink,
};
interface ParamsPanelProps {
/** Called with the validated form payload on «ОЦЕНИТЬ КВАРТИРУ». Optional so
* the still-unwired v2 page renders without props (tsc-safe). */
@ -267,6 +283,9 @@ interface ParamsPanelProps {
error?: string | null;
/** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */
initialValues?: Partial<TradeInEstimateInput>;
/** Analog price pins for the 01 map, projected from the real estimate via
* mapMarkers(). Default [] no price pins (never the fabricated fixtures). */
markers?: MapMarker[];
}
// rooms number -> dropdown label. The design has no «Студия» option, so studio
@ -291,6 +310,7 @@ export default function ParamsPanel({
isPending = false,
error = null,
initialValues,
markers = [],
}: ParamsPanelProps) {
const [openDd, setOpenDd] = useState<DdKey>(null);
const [address, setAddress] = useState(initialValues?.address ?? "");
@ -366,7 +386,11 @@ export default function ParamsPanel({
const trimmedAddress = address.trim();
const areaNum = Number(area.replace(",", "."));
if (trimmedAddress.length < 3) {
setValidationError("Укажите адрес — минимум 3 символа");
setValidationError("Укажите адрес квартиры — минимум 3 символа");
return;
}
if (!area.trim()) {
setValidationError("Укажите площадь квартиры");
return;
}
if (!Number.isFinite(areaNum) || areaNum <= 10) {
@ -581,135 +605,16 @@ export default function ParamsPanel({
/>
</div>
{/* analog price pins */}
<div
style={{
position: "absolute",
left: "5%",
top: "13%",
background: tokens.surface.w70,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
padding: "3px 7px",
fontFamily: tokens.font.mono,
fontSize: 9,
lineHeight: 1.4,
color: tokens.ink,
}}
>
12,7 млн <br />
<span style={{ color: tokens.muted }}>54 м²</span>
</div>
<div
style={{
position: "absolute",
right: "32%",
top: "8%",
background: tokens.surface.w70,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
padding: "3px 7px",
fontFamily: tokens.font.mono,
fontSize: 9,
lineHeight: 1.4,
color: tokens.ink,
}}
>
7,6 млн <br />
<span style={{ color: tokens.muted }}>44 м²</span>
</div>
<div
style={{
position: "absolute",
right: "3%",
top: "36%",
background: tokens.surface.w70,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
padding: "3px 7px",
fontFamily: tokens.font.mono,
fontSize: 9,
lineHeight: 1.4,
color: tokens.ink,
}}
>
22,4 млн <br />
<span style={{ color: tokens.muted }}>55 м²</span>
</div>
<div
style={{
position: "absolute",
left: "13%",
bottom: "9%",
background: tokens.surface.w70,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
padding: "3px 7px",
fontFamily: tokens.font.mono,
fontSize: 9,
lineHeight: 1.4,
color: tokens.ink,
}}
>
15,9 млн <br />
<span style={{ color: tokens.muted }}>55 м²</span>
</div>
<div
style={{
position: "absolute",
right: "14%",
bottom: "11%",
background: tokens.surface.w70,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
padding: "3px 7px",
fontFamily: tokens.font.mono,
fontSize: 9,
lineHeight: 1.4,
color: tokens.ink,
}}
>
14,9 млн <br />
<span style={{ color: tokens.muted }}>56 м²</span>
</div>
{/* analog dots */}
<span
style={{
position: "absolute",
left: "18%",
top: "30%",
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.accent,
border: `1.5px solid ${tokens.dotStroke}`,
}}
/>
<span
style={{
position: "absolute",
right: "34%",
top: "28%",
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.dealDot,
border: `1.5px solid ${tokens.dotStroke}`,
}}
/>
<span
style={{
position: "absolute",
left: "30%",
bottom: "24%",
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.accent,
border: `1.5px solid ${tokens.dotStroke}`,
}}
/>
{/* 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>
))}
{/* map controls */}
<div
@ -962,7 +867,7 @@ export default function ParamsPanel({
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmit();
}}
placeholder="54"
placeholder="напр. 54"
style={inputField}
/>
</div>

View file

@ -4,23 +4,46 @@
// Faithful markup port of the design header (МЕРА Оценка.dc.html, lines 42-90):
// inline SVG logo + version + 5 nav tabs (active underline/triangle) + user menu.
// Tabs change only local UI state via onNavigate; the user dropdown owns its
// own useState. No data fetching — labels/user come from fixtures, colours from
// tokens.
// own useState. No data fetching — labels/version come from fixtures, the user
// identity is fed in from the page (real useMe), colours from tokens.
import { useState } from "react";
import type { CSSProperties } from "react";
import { tokens } from "./tokens";
import { navLabels, user, version } from "./fixtures";
import { navLabels, version } from "./fixtures";
// Real logged-in user identity, derived by the page from useMe()
// ({username, role, brand}). Deliberately excludes `reports` — the «Мои
// отчёты» badge count is a separate prop fed from useQuota().used.
interface TopNavUser {
name: string;
org: string;
email: string;
initials: string;
}
interface TopNavProps {
active: number;
onNavigate: (i: number) => void;
// «Мои отчёты» badge count — page feeds it from useQuota().used (per-user
// estimate count). Defaults to the design fixture for unwired usage.
// estimate count). Defaults to 0 for unwired usage.
reports?: number;
// Real logged-in user. undefined → neutral «Гость» placeholder, NEVER the
// old design fixture ("Андрей Петров / Брусника").
user?: TopNavUser;
// Sign out — page wires this to logout(); closes the menu first.
onLogout?: () => void;
}
// Neutral fallback when the user prop is absent (loading / unauthenticated).
const GUEST_USER: TopNavUser = {
name: "Гость",
org: "—",
email: "—",
initials: "—",
};
const menuItemStyle: CSSProperties = {
display: "flex",
alignItems: "center",
@ -33,12 +56,23 @@ const menuItemStyle: CSSProperties = {
transition: "background .12s",
};
// Профиль / Настройки / Помощь have no pages yet — render them dimmed and
// non-interactive (no hover class, default cursor) so they read as disabled.
const menuItemDisabledStyle: CSSProperties = {
...menuItemStyle,
cursor: "default",
color: tokens.muted2,
};
export default function TopNav({
active,
onNavigate,
reports = user.reports,
reports = 0,
user,
onLogout,
}: TopNavProps) {
const [userOpen, setUserOpen] = useState(false);
const u = user ?? GUEST_USER;
return (
<>
@ -222,7 +256,7 @@ export default function TopNav({
boxShadow: "0 3px 10px rgba(46,139,255,.3)",
}}
>
{user.initials}
{u.initials}
</div>
<div style={{ lineHeight: 1.25 }}>
<div
@ -233,7 +267,7 @@ export default function TopNav({
fontFamily: tokens.font.sans,
}}
>
{user.name}
{u.name}
</div>
<div
style={{
@ -243,7 +277,7 @@ export default function TopNav({
letterSpacing: ".5px",
}}
>
{user.org}
{u.org}
</div>
</div>
<span style={{ color: tokens.muted2, fontSize: "9px" }}></span>
@ -289,7 +323,7 @@ export default function TopNav({
fontWeight: 600,
}}
>
{user.initials}
{u.initials}
</div>
<div>
<div
@ -299,15 +333,15 @@ export default function TopNav({
color: tokens.ink2,
}}
>
{user.name}
{u.name}
</div>
<div style={{ fontSize: "10px", color: tokens.muted2 }}>
{user.email}
{u.email}
</div>
</div>
</div>
<div className="tnav-menuitem" style={menuItemStyle}>
<div style={menuItemDisabledStyle} aria-disabled="true">
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<circle
cx="7.5"
@ -347,7 +381,7 @@ export default function TopNav({
</span>
</div>
<div className="tnav-menuitem" style={menuItemStyle}>
<div style={menuItemDisabledStyle} aria-disabled="true">
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<circle
cx="7.5"
@ -365,7 +399,7 @@ export default function TopNav({
Настройки
</div>
<div className="tnav-menuitem" style={menuItemStyle}>
<div style={menuItemDisabledStyle} aria-disabled="true">
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<circle
cx="7.5"
@ -392,7 +426,10 @@ export default function TopNav({
/>
<div
onClick={() => setUserOpen(false)}
onClick={() => {
setUserOpen(false);
onLogout?.();
}}
className="tnav-logout"
style={{ ...menuItemStyle, color: tokens.danger }}
>

View file

@ -651,6 +651,83 @@ export function mapObject(e: AggregatedEstimate): ObjectInfo {
};
}
// ── ParamsPanel map markers (analog price pins) ─────────────────────────────
// Finding #2: the 01 map showed STATIC fixture price pins (12,7 / 7,6 / 22,4 млн)
// that never reflected the real estimate. We project each analog with a known
// {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).
/** One analog price pin positioned by CSS % offsets over the ParamsPanel map. */
export interface MapMarker {
left: string; // e.g. "62%"
top: string; // e.g. "31%"
label: string; // "12,70 млн ₽"
sub: string; // "54 м²"
}
// Half-span from the subject to a box edge (the 50% multiplier). ≈ 0.011° lat /
// 0.018° lon ≈ ~1.2 km at ЕКБ latitude (cos 56.8° ≈ 0.55). Tuneable: larger pulls
// 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;
/** Analog price pins for the 01 ParamsPanel map. null/empty/un-geocoded → []. */
export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] {
if (e == null || e.target_lat == null || e.target_lon == null) return [];
const tLat = e.target_lat;
const tLon = e.target_lon;
const geo = e.analogs.filter(
(a): a is AnalogLot & { lat: number; lon: number } =>
a.lat != null &&
a.lon != null &&
Number.isFinite(a.lat) &&
Number.isFinite(a.lon),
);
if (geo.length === 0) return [];
// Closest first: real distance_m when known, else a degree-based estimate
// (lon scaled by cos≈0.55) so analogs without distance_m still order sensibly.
const ranked = geo
.map((a) => {
const fallbackM =
Math.hypot(a.lat - tLat, (a.lon - tLon) * 0.55) * 111_320;
const dist =
a.distance_m != null && Number.isFinite(a.distance_m)
? a.distance_m
: fallbackM;
return { a, dist };
})
.sort((x, y) => x.dist - y.dist)
.slice(0, MAP_MARKER_CAP);
return ranked.map(({ a }) => {
const left = clamp(
50 + ((a.lon - tLon) / MAP_LON_HALF_SPAN) * 50,
MAP_CLAMP_LO,
MAP_CLAMP_HI,
);
const top = clamp(
50 - ((a.lat - tLat) / MAP_LAT_HALF_SPAN) * 50,
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)} м²` : "—",
};
});
}
/** Full 02 РЕЗУЛЬТАТ block: 3 cards + meta + ranges + scatter + sources. */
export function mapResultPanel(
e: AggregatedEstimate,
@ -765,13 +842,17 @@ export function mapSummary(
const anaBargain = kpi?.median_bargain_pct ?? null;
const row4Parts = [
anaExp != null ? `${anaExp} дн` : null,
anaBargain != null ? fmtPct(anaBargain) : null,
// Finding #10: negate the bargain so a price reduction reads "3%" (compact
// whole-percent here), not a misleading "+3%". See bargainSigned() rationale.
anaBargain != null ? fmtPct(-anaBargain) : null,
].filter((p): p is string => p != null);
const row4Value = row4Parts.length > 0 ? row4Parts.join(" · ") : "—";
const rows: SummaryRow[] = [
{
label: "Продажи в доме",
// Finding #9: fed by house-analytics radius data (район, radius_m up to
// 300 m), NOT strictly the exact house → honest label "Продажи рядом".
label: "Продажи рядом",
value: row1Value,
dot: houseSold && houseSold > 0 ? "accent" : "muted",
nav: 1,
@ -868,6 +949,22 @@ function pct1(v: number | null | undefined): string {
return `${sign}${Math.abs(r).toFixed(1)}%`;
}
/**
* Finding #10 "СРЕДНИЙ ТОРГ" rendering. The house-analytics KPI
* median_bargain_pct is the median over house_placement_history of
* (start_price last_price) / start_price × 100
* (see backend trade_in.py), so a POSITIVE value means the asking price was
* REDUCED from its initial to its final value (the haggle / discount room).
* Feeding that straight through pct1 renders a misleading "+3.3%" the "+"
* reads as a price INCREASE. We instead show the SIGNED change from initial
* final price, so a reduction reads "3.3%", matching the tile sub-label
* "от начальной до итоговой цены" (final price is 3.3% below initial). null "—".
*/
function bargainSigned(pct: number | null | undefined): string {
if (pct == null || !Number.isFinite(pct)) return "—";
return pct1(-pct);
}
/** Distance label: <1km -> "850 м", else "1.2 км". null/NaN -> "—". */
function fmtDist(m: number | null | undefined): string {
if (m == null || !Number.isFinite(m)) return "—";
@ -942,6 +1039,19 @@ function dealMeta(l: AnalogLot): string {
return parts.join(" · ");
}
/**
* Finding #7 de-glue addresses. Some geocoded analog/deal addresses run a
* metro/word straight onto the house number ("…, 69/2Геологическая"). Insert a
* space where a digit is immediately followed by the start of a Cyrillic WORD
* (capital + lowercase). NB: deliberately NOT the looser /(\d)([А-ЯЁ])/ that
* would also split legitimate single-letter house litereras (14А, 5Б, д.5К),
* which are valid ЕКБ house numbers. Pure display cleanup; never touches the
* comma-splitting parseAddress.
*/
function deglueAddr(addr: string): string {
return addr.replace(/(\d)([А-ЯЁ][а-яё])/g, "$1 $2");
}
// ── 04 ПРОДАЖИ В ДОМЕ (HistoryView) ─────────────────────────────────────────
/** ДКП deal row + real per-row address/date/listing-source (DkpRow has none). */
@ -1030,7 +1140,7 @@ export function mapHistory(
ask,
delta: pct1(pr.discount_pct),
deltaColor,
addr: pr.deal_address || undefined,
addr: pr.deal_address ? deglueAddr(pr.deal_address) : undefined,
date: fmtDate(pr.deal_date),
source: pr.listing_source,
};
@ -1056,7 +1166,8 @@ function buildAnalyticsKpis(k: HouseAnalyticsKpi | null): AnalyticsKpi[] {
},
{
label: "СРЕДНИЙ ТОРГ",
value: pct1(k?.median_bargain_pct),
// Finding #10: negate so a price reduction reads "3.3%" not "+3.3%".
value: bargainSigned(k?.median_bargain_pct),
color: bargainColor,
sub: "от начальной до итоговой цены",
},
@ -1398,7 +1509,7 @@ export function mapSources(
const e = estimate;
const adRows: AdRowData[] = (e?.analogs ?? []).map((l) => ({
addr: l.address || "—",
addr: deglueAddr(l.address || "—"),
meta: adMeta(l),
ppm: numRu(l.price_per_m2),
price: numRu(l.price_rub),
@ -1408,7 +1519,7 @@ export function mapSources(
}));
const dealRows: DealRowData[] = (e?.actual_deals ?? []).map((l) => ({
addr: l.address || "—",
addr: deglueAddr(l.address || "—"),
meta: dealMeta(l),
ppm: numRu(l.price_per_m2),
price: numRu(l.price_rub),