Compare commits

..

6 commits

Author SHA1 Message Date
bot-backend
3d36861fb2 Merge remote-tracking branch 'forgejo/main' into fix/tradein-location-index-calibration
All checks were successful
CI Trade-In / frontend-checks (pull_request) Successful in 1m54s
CI / backend-tests (pull_request) Successful in 16m1s
CI / openapi-codegen-check (pull_request) Successful in 3m25s
CI Trade-In / backend-tests (pull_request) Successful in 5m23s
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI / frontend-tests (pull_request) Has been skipped
2026-07-26 23:32:15 +03:00
bot-backend
ec7fd9c02c fix(tradein/ui): фронт под location-index — убрать обещание влияния на цену
Старый интерфейс рисовал в панели «КАК РАССЧИТАНО» пару «база -> результат»,
из чего клиент делал вывод, что локация подвинула его цену. Она её не двигала:
estimator.py про location_coef не знает вовсе. Пара удалена.

Подпись «КОЭФ. ЛОКАЦИИ» заменена на «ЛОКАЦИЯ» — слово «коэффициент»
подразумевает множитель. В тултипе прямо сказано: «Сравнение медианы руб/м2
района и города. На итоговую оценку не влияет.»

Три состояния недоступности теперь различимы вместо одного прочерка:
«вне ЕКБ» (индекс считаем только по Екатеринбургу), «мало данных»
(сопоставимых объявлений меньше порога) и загрузка. В панели каждое состояние
объяснено человеческим текстом, а при status=ok показано, по скольким
объявлениям и в каком радиусе посчитано.

Список «что рядом» сохранён как качественная справка, weight из ответа убран
(был внутренней ранжирующей величиной, клиенту не значил ничего).
2026-07-26 23:32:03 +03:00
c9ba1b15ba fix(tradein/v2): вернуть адресную карточку поверх карты и координаты при restore-by-id (#2530)
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 2m12s
Deploy Trade-In / deploy (push) Successful in 48s
2026-07-26 20:26:08 +00:00
bot-backend
e767661503 Merge remote-tracking branch 'forgejo/main' into fix/tradein-location-index-calibration 2026-07-26 23:16:09 +03:00
06bf8dfada fix(tradein/ui): мини-карта адреса вместо статичного фото чужого дома в шапке (#2529)
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 2m18s
Deploy Trade-In / deploy (push) Successful in 51s
2026-07-26 20:15:54 +00:00
e0b63cc637 fix(tradein/v2): настоящая карта Leaflet+OSM вместо декоративного псевдо-SVG (#2528)
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 2m21s
Deploy Trade-In / deploy (push) Successful in 52s
2026-07-26 20:02:28 +00:00
9 changed files with 974 additions and 666 deletions

View file

@ -60,7 +60,7 @@ import {
useEstimateMutation, useEstimateMutation,
useEstimatePlacementHistory, useEstimatePlacementHistory,
useEstimateSellTimeSensitivity, useEstimateSellTimeSensitivity,
useLocationCoef, useLocationIndex,
useSalesVsListings, useSalesVsListings,
useStreetDeals, useStreetDeals,
} from "@/lib/trade-in-api"; } from "@/lib/trade-in-api";
@ -134,9 +134,10 @@ const EMPTY_OBJECT: ObjectInfo = {
houseType: "—", houseType: "—",
repair: "—", repair: "—",
balcony: false, balcony: false,
locationCoef: "—", locationIndexLabel: "—",
streetView: "", locationIndexOk: false,
compass: "", lat: null,
lon: null,
}; };
// Skeleton pulse (opacity only — NO shimmer sweep, per .claude/rules/ui-*) + // Skeleton pulse (opacity only — NO shimmer sweep, per .claude/rules/ui-*) +
@ -471,7 +472,7 @@ export default function TradeInV2Page() {
// L3 — once the restore-by-id fetch has confirmed the estimate does not // L3 — once the restore-by-id fetch has confirmed the estimate does not
// exist (404), `currentEstimateId` below drops to null so the sibling // exist (404), `currentEstimateId` below drops to null so the sibling
// dashboard hooks (analytics/location-coef/placement-history/sell-time, // dashboard hooks (analytics/location-index/placement-history/sell-time,
// all `enabled: estimate_id !== null`) don't each fire their own doomed // all `enabled: estimate_id !== null`) don't each fire their own doomed
// request against the same dead id. Hoisted above the sub-hooks (was // request against the same dead id. Hoisted above the sub-hooks (was
// computed further down, after they'd already fired on the stale id). // computed further down, after they'd already fired on the stale id).
@ -532,10 +533,10 @@ export default function TradeInV2Page() {
estimate?.rooms ?? null, estimate?.rooms ?? null,
); );
const analytics = useEstimateHouseAnalytics(currentEstimateId); const analytics = useEstimateHouseAnalytics(currentEstimateId);
// LocationDrawer + HeroBar «КОЭФ. ЛОКАЦИИ» (#2317). Same independent-resolve // LocationDrawer + HeroBar «ЛОКАЦИЯ». Same independent-resolve contract: a
// contract: a pending/errored/unavailable response degrades to the mapper's // pending/errored/degraded response resolves to the mapper's own honest,
// honest "—" (mapObject/mapLocation), never a fabricated coefficient. // distinct reason (mapObject/mapLocation) — never a fabricated percent.
const locationCoef = useLocationCoef(currentEstimateId); const locationIndex = useLocationIndex(currentEstimateId);
// Overlay-only sub-hooks (04 ПРОДАЖИ В ДОМЕ / 05 РЫНОК / 06 АНАЛИТИКА). Same // Overlay-only sub-hooks (04 ПРОДАЖИ В ДОМЕ / 05 РЫНОК / 06 АНАЛИТИКА). Same
// contract: each resolves independently; a pending/errored one degrades its // contract: each resolves independently; a pending/errored one degrades its
// overlay section to an honest empty via the mapper (null input). // overlay section to an honest empty via the mapper (null input).
@ -549,7 +550,7 @@ export default function TradeInV2Page() {
const streetDealsData = streetDeals.data ?? null; const streetDealsData = streetDeals.data ?? null;
const analyticsData = analytics.data ?? null; const analyticsData = analytics.data ?? null;
const locationCoefData = locationCoef.data ?? null; const locationIndexData = locationIndex.data ?? null;
const placementHistoryData = placementHistory.data ?? null; const placementHistoryData = placementHistory.data ?? null;
const salesVsListingsData = salesVsListings.data ?? null; const salesVsListingsData = salesVsListings.data ?? null;
const sellTimeData = sellTime.data ?? null; const sellTimeData = sellTime.data ?? null;
@ -575,12 +576,12 @@ export default function TradeInV2Page() {
[estimate], [estimate],
); );
const objectInfo = useMemo( const objectInfo = useMemo(
() => (estimate ? mapObject(estimate, locationCoefData) : EMPTY_OBJECT), () => (estimate ? mapObject(estimate, locationIndexData) : EMPTY_OBJECT),
[estimate, locationCoefData], [estimate, locationIndexData],
); );
const locationData = useMemo( const locationData = useMemo(
() => mapLocation(locationCoefData), () => mapLocation(locationIndexData),
[locationCoefData], [locationIndexData],
); );
const resultPanelData = useMemo( const resultPanelData = useMemo(
() => (estimate ? mapResultPanel(estimate, streetDealsData) : null), () => (estimate ? mapResultPanel(estimate, streetDealsData) : null),
@ -681,6 +682,13 @@ export default function TradeInV2Page() {
house_type: estimate.house_type ?? undefined, house_type: estimate.house_type ?? undefined,
repair_state: estimate.repair_state ?? undefined, repair_state: estimate.repair_state ?? undefined,
has_balcony: estimate.has_balcony ?? undefined, has_balcony: estimate.has_balcony ?? undefined,
// Without these the 01 map falls back to its "pick an address"
// placeholder on every restored estimate (shared link, history,
// PDF flow) even though the address field is populated — the map
// keys off coords, not the address string, and only the geocode
// suggestion path used to supply them.
lat: estimate.target_lat ?? undefined,
lon: estimate.target_lon ?? undefined,
} }
: undefined, : undefined,
[estimate], [estimate],

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, type CSSProperties } from "react"; import { useEffect, useRef, useState, type CSSProperties } from "react";
import { API_BASE_URL } from "@/lib/api"; import { API_BASE_URL } from "@/lib/api";
import { safeUrl } from "@/lib/safeUrl"; import { safeUrl } from "@/lib/safeUrl";
@ -12,11 +12,6 @@ import type { HeroBarData } from "./mappers";
// Default presentation data (unwired usage): the existing design fixtures. // Default presentation data (unwired usage): the existing design fixtures.
const HERO_FIXTURE: HeroBarData = { report, object }; const HERO_FIXTURE: HeroBarData = { report, object };
// next/image does NOT prepend the configured basePath ("/trade-in") to a
// literal src, so an <Image src="/trade-in-v2/…"> 404s behind Caddy. A plain
// <img> with the basePath baked in resolves to /trade-in/trade-in-v2/… → 200.
const BP = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
// estimate ids are server-issued UUIDs — reject anything else before it lands // estimate ids are server-issued UUIDs — reject anything else before it lands
// in the PDF request path so a tampered id cannot be injected. // in the PDF request path so a tampered id cannot be injected.
const PDF_UUID_RE = const PDF_UUID_RE =
@ -37,6 +32,157 @@ function pdfDownloadHref(estimateId: string | null | undefined): string | null {
: null; : null;
} }
// ── Locator mini-map (Leaflet + OSM) ────────────────────────────────────────
// Replaces the old static building.png stock photo — user-reported bug: that
// single asset was shown for EVERY estimate regardless of the real address,
// misleading users into thinking they were looking at their own building.
// PORTS the Leaflet-CDN loader pattern from ./SourcesMap.tsx (itself ported
// from the dead v1 tree's MapCard.tsx) — copied rather than imported so this
// file stays a self-contained port with no shared runtime module and no npm
// Leaflet dep, same rationale as SourcesMap.
/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet (см. SourcesMap.tsx) */
const LEAFLET_VER = "1.9.4";
const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.css`;
const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.js`;
const LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=";
const LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=";
/** Подгружает Leaflet с CDN один раз, резолвит window.L. (mirror SourcesMap.tsx) */
function loadLeaflet(): Promise<any> {
return new Promise((resolve, reject) => {
const w = window as any;
if (w.L) {
resolve(w.L);
return;
}
if (!document.querySelector(`link[data-leaflet]`)) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = LEAFLET_CSS;
link.integrity = LEAFLET_CSS_SRI;
link.crossOrigin = "anonymous";
link.setAttribute("data-leaflet", "1");
document.head.appendChild(link);
}
const existing = document.querySelector<HTMLScriptElement>(`script[data-leaflet]`);
if (existing) {
existing.addEventListener("load", () => resolve(w.L));
existing.addEventListener("error", () => reject(new Error("leaflet load failed")));
return;
}
const script = document.createElement("script");
script.src = LEAFLET_JS;
script.integrity = LEAFLET_JS_SRI;
script.crossOrigin = "anonymous";
script.setAttribute("data-leaflet", "1");
script.onload = () => resolve(w.L);
script.onerror = () => reject(new Error("leaflet load failed"));
document.body.appendChild(script);
});
}
// Overview zoom: close enough to recognise the actual building on a 560×152
// box without feeling zoomed-in on bare rooftops (SourcesMap's multi-pin
// overlay map fits bounds instead — this is a single-point locator, not an
// exploration map).
const HERO_MAP_ZOOM = 16;
interface HeroMiniMapProps {
lat: number | null;
lon: number | null;
}
/**
* Small OSM/Leaflet locator map centred on the subject address, replacing the
* old static building.png photo. Deliberately near-non-interactive this is
* a 560×152 "you are here" badge, not an explorable map (ParamsPanel/
* SourcesMap already cover that): dragging/zoomControl/scroll-zoom/dbl-click
* zoom are all off so the box reads as a locator, not a broken-feeling mini
* map, and never steals the page's scroll or click focus.
*/
function HeroMiniMap({ lat, lon }: HeroMiniMapProps) {
const mapRef = useRef<HTMLDivElement>(null);
const [mapError, setMapError] = useState(false);
useEffect(() => {
if (lat == null || lon == null) return;
let map: any = null;
let cancelled = false;
// Reset a stale failure from a previous address/CDN hiccup before this
// attempt — otherwise one bad load latches mapError forever (caught in
// review on a sibling PR) and every later estimate shows the placeholder
// even once the CDN is reachable again.
setMapError(false);
loadLeaflet()
.then((L) => {
if (cancelled || !mapRef.current) return;
map = L.map(mapRef.current, {
scrollWheelZoom: false, // embedded in the page — must not steal page scroll
dragging: false, // locator badge, not an explorable map
touchZoom: false,
doubleClickZoom: false,
zoomControl: false, // no room for +/- controls at this size
keyboard: false,
}).setView([lat, lon], HERO_MAP_ZOOM);
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "© OpenStreetMap",
maxZoom: 19,
}).addTo(map);
L.circleMarker([lat, lon], {
radius: 8,
color: "#fff",
weight: 3,
fillColor: tokens.accent,
fillOpacity: 1,
}).addTo(map);
setTimeout(() => map && map.invalidateSize(), 120);
})
.catch(() => {
if (!cancelled) setMapError(true);
});
return () => {
cancelled = true;
if (map) map.remove();
};
}, [lat, lon]);
// Honest empty states — no coordinates yet (fresh/un-geocoded estimate) or
// the CDN failed — never a blank rectangle or a broken-image icon.
if (lat == null || lon == null || mapError) {
return (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: tokens.mapBg,
color: tokens.muted,
fontSize: 11,
textAlign: "center",
padding: "0 20px",
}}
>
{lat == null || lon == null
? "Карта появится после расчёта адреса"
: "Не удалось загрузить карту"}
</div>
);
}
return (
<div
ref={mapRef}
style={{ position: "absolute", inset: 0, background: tokens.mapBg }}
aria-label="Расположение объекта на карте"
/>
);
}
/* eslint-enable @typescript-eslint/no-explicit-any */
interface HeroBarProps { interface HeroBarProps {
data?: HeroBarData; data?: HeroBarData;
estimateId?: string | null; estimateId?: string | null;
@ -46,10 +192,10 @@ interface HeroBarProps {
hasEstimate: boolean; hasEstimate: boolean;
onOpenInfo: () => void; onOpenInfo: () => void;
// #2275 mobile quick-view: a real fluid layout instead of the fixed-width // #2275 mobile quick-view: a real fluid layout instead of the fixed-width
// desktop one — meta/buttons stack, the decorative building photo (and the // desktop one — meta/buttons stack, the locator mini-map (and the
// address/coef card baked into it) is dropped since it assumes a 560×152 box // address/coef card baked into it) is dropped since it assumes a 560×152 box
// that cannot reflow. «КАК РАССЧИТАНО» still opens the same location-coef // that cannot reflow. «КАК РАССЧИТАНО» still opens the same location-index
// drawer, so no functionality is lost, only the redundant photo-card copy. // drawer, so no functionality is lost, only the redundant map-card copy.
compact?: boolean; compact?: boolean;
} }
@ -92,9 +238,6 @@ export default function HeroBar({
// A downloadable report exists ⇔ the estimate is ready ⇒ the PDF button is the // A downloadable report exists ⇔ the estimate is ready ⇒ the PDF button is the
// filled/primary CTA (M4). Otherwise it stays a disabled outline. // filled/primary CTA (M4). Otherwise it stays a disabled outline.
const pdfFilled = Boolean(pdfHref); const pdfFilled = Boolean(pdfHref);
// Hide the building photo if the asset 404s/400s so the photoBg fill shows
// instead of a broken-image icon.
const [imgFailed, setImgFailed] = useState(false);
const pdfBtnInner = (filled: boolean) => { const pdfBtnInner = (filled: boolean) => {
const frame = filled ? "#fff" : "#2e8bff"; const frame = filled ? "#fff" : "#2e8bff";
const rule = filled ? "rgba(255,255,255,.75)" : "#6f8195"; const rule = filled ? "rgba(255,255,255,.75)" : "#6f8195";
@ -137,7 +280,6 @@ export default function HeroBar({
.hero-pdf-btn-filled:active { transform: translateY(1px); } .hero-pdf-btn-filled:active { transform: translateY(1px); }
.hero-calc-btn:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; } .hero-calc-btn:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; }
.hero-coef-row:hover { background: rgba(46,139,255,.09); } .hero-coef-row:hover { background: rgba(46,139,255,.09); }
@keyframes hero-scanv { 0% { transform: translateY(-100%); } 100% { transform: translateY(900%); } }
`}</style> `}</style>
{/* LEFT: meta + buttons */} {/* LEFT: meta + buttons */}
@ -295,11 +437,15 @@ export default function HeroBar({
</div> </div>
</div> </div>
{/* RIGHT: photo + overlays dropped in compact mode (#2275): the box is {/* RIGHT: locator mini-map + overlays dropped in compact mode (#2275):
a fixed 560×152 with several absolutely-positioned children (address the box is a fixed 560×152 with several absolutely-positioned
card, compass, distance scale) pinned to that size, so it cannot children (address card) pinned to that size, so it cannot reflow to
reflow to a phone width. «КАК РАССЧИТАНО» above still opens the same a phone width. «КАК РАССЧИТАНО» above still opens the same
location-coef drawer, so no functionality is lost. */} location-index drawer, so no functionality is lost.
User-reported bug: this used to be a single static building.png
photo shown for EVERY estimate regardless of the real address (a
user could be looking at someone else's building) replaced with a
real Leaflet/OSM map centred on the subject's own coordinates. */}
{!compact && ( {!compact && (
<div <div
style={{ style={{
@ -310,65 +456,29 @@ export default function HeroBar({
border: `1px solid ${tokens.line3}`, border: `1px solid ${tokens.line3}`,
borderRadius: 6, borderRadius: 6,
overflow: "hidden", overflow: "hidden",
background: tokens.photoBg, background: tokens.mapBg,
boxShadow: "0 6px 26px rgba(40,80,130,.10)", boxShadow: "0 6px 26px rgba(40,80,130,.10)",
}} }}
> >
{!imgFailed && ( <HeroMiniMap lat={data.object.lat} lon={data.object.lon} />
<img
src={`${BP}/trade-in-v2/building.png`} {/* Left fade so the address card (below) stays legible over busy
alt="" map tiles instead of a floating card with no visual anchor kept
onError={() => setImgFailed(true)} from the old photo styling, where it served the same purpose.
style={{ pointerEvents:none so it never blocks map interaction/attribution
position: "absolute", underneath. The old bottom vignette + scanning HUD sweep line are
inset: 0, dropped: both existed purely to stylise/recede the stock photo and
width: "100%", have no equivalent purpose over a live basemap. */}
height: "100%",
objectFit: "contain",
objectPosition: "right center",
filter: "saturate(.25) brightness(1.06) contrast(.95)",
}}
/>
)}
{/* blue duotone tint toward HUD accent recedes the photo (only over
the real image; skip when it 404s so the placeholder stays clean) */}
{!imgFailed && (
<div
style={{
position: "absolute",
inset: 0,
background: "rgba(46,139,255,.14)",
mixBlendMode: "multiply",
pointerEvents: "none",
}}
/>
)}
<div <div
style={{ style={{
position: "absolute", position: "absolute",
inset: 0, inset: 0,
background: background:
"linear-gradient(90deg,rgba(238,244,250,.96) 0%,rgba(238,244,250,.5) 22%,transparent 40%)", "linear-gradient(90deg,rgba(238,244,250,.96) 0%,rgba(238,244,250,.5) 22%,transparent 40%)",
}} pointerEvents: "none",
/> // Above Leaflet's tile pane (200) / overlay pane (400); below the
<div // marker (600) so the subject pin still reads through the fade.
style={{ zIndex: 500,
position: "absolute",
inset: 0,
background:
"linear-gradient(0deg,rgba(230,240,250,.45),transparent 40%)",
}}
/>
<div
style={{
position: "absolute",
left: 0,
right: 0,
top: "34%",
height: 1,
background:
"linear-gradient(90deg,transparent,rgba(46,139,255,.5),transparent)",
animation: "hero-scanv 6s linear infinite",
}} }}
/> />
@ -380,6 +490,15 @@ export default function HeroBar({
top: "50%", top: "50%",
transform: "translateY(-50%)", transform: "translateY(-50%)",
width: 182, width: 182,
// MUST be set explicitly. .leaflet-container is position:relative
// with z-index:auto, so it does NOT open a stacking context — its
// internal panes (tiles 200 … popup 700) compete directly with
// these siblings, and an auto/0 card is painted UNDER the map.
// Shipped without this in #2529 and the address + location figure
// vanished from the hero on prod. 750 clears every pane; the
// attribution control (800) sits bottom-right and never overlaps
// this left-anchored card, so its link stays clickable.
zIndex: 750,
background: tokens.surface.w72, background: tokens.surface.w72,
backdropFilter: "blur(5px)", backdropFilter: "blur(5px)",
border: `1px solid ${tokens.line}`, border: `1px solid ${tokens.line}`,
@ -406,31 +525,6 @@ export default function HeroBar({
{data.object.city} {data.object.city}
</span> </span>
</div> </div>
{/* streetView Fix #4 (audit): always "" (TODO BE-3, mappers.ts
mapObject), so this slot + its divider are hidden together
rather than showing an empty caption row. Leaves the single
divider below to separate the address block from the coef row. */}
{data.object.streetView && (
<>
<div
style={{
height: 1,
background: tokens.lineSoft,
margin: "8px 0",
}}
/>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: 9,
color: tokens.muted,
lineHeight: 1.55,
}}
>
{data.object.streetView}
</div>
</>
)}
<div <div
style={{ style={{
height: 1, height: 1,
@ -453,7 +547,14 @@ export default function HeroBar({
onOpenInfo(); onOpenInfo();
} }
}} }}
aria-label="Пояснение к расчёту коэффициента локации" // Honest framing (post location-coef rewrite, see
// backend/app/services/location_index.py): this is a comparison
// vs. the city median, not a price multiplier, and it never
// affects the quoted estimate. title= is a plain hover tooltip
// (zero layout cost) carrying that caveat since the compact pill
// has no room to spell it out inline.
aria-label="Локация относительно города — справочно, не влияет на оценку"
title="Сравнение медианы ₽/м² района и города. На итоговую оценку не влияет."
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
@ -472,10 +573,21 @@ export default function HeroBar({
color: tokens.muted2, color: tokens.muted2,
}} }}
> >
КОЭФ. ЛОКАЦИИ ЛОКАЦИЯ
</span> </span>
<span style={{ display: "flex", alignItems: "center", gap: 6 }}> <span style={{ display: "flex", alignItems: "center", gap: 6 }}>
{data.object.locationCoef === "—" ? ( {data.object.locationIndexOk ? (
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 15,
fontWeight: 500,
color: tokens.accent,
}}
>
{data.object.locationIndexLabel}
</span>
) : (
<span <span
style={{ style={{
fontSize: "9px", fontSize: "9px",
@ -489,22 +601,13 @@ export default function HeroBar({
padding: "1px 8px", padding: "1px 8px",
}} }}
> >
{/* #2317: coef is a live feature now (GET /location-coef) a {/* Distinct honest reasons instead of one blank dash see
dash here means unavailable/loading for THIS estimate mapObject/locationIndexBadge (./mappers.ts): "вне ЕКБ"
(unavailable geo_source, no lat/lon, or query pending), (out_of_coverage index only covers Yekaterinburg),
never "not built yet", so «скоро» would be stale/false. */} "мало данных" (insufficient_data too few comparable
нет данных listings), "нет данных" (not fetched yet for this
</span> estimate). Full explanation lives in the drawer below. */}
) : ( {data.object.locationIndexLabel}
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 15,
fontWeight: 500,
color: tokens.accent,
}}
>
{data.object.locationCoef}
</span> </span>
)} )}
<span <span
@ -528,54 +631,14 @@ export default function HeroBar({
</div> </div>
</div> </div>
{/* compass Fix #4 (audit): compass bearing is always "" (TODO BE-3,
mappers.ts mapObject), so the icon+label are hidden rather than
showing a compass that never actually points anywhere. Mirrors the
locationCoef "нет данных" graceful-fallback pattern already used
in this file. */}
{data.object.compass && (
<div
style={{
position: "absolute",
right: 16,
top: 14,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 2,
color: tokens.accent,
}}
>
<svg
width="22"
height="22"
viewBox="0 0 22 22"
fill="none"
aria-hidden="true"
>
<circle cx="11" cy="11" r="10" stroke="#2e8bff" strokeWidth="1" />
<path d="M11 3 L13 11 L11 9 L9 11 Z" fill="#2e8bff" />
</svg>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 8,
letterSpacing: ".5px",
color: tokens.muted,
whiteSpace: "nowrap",
}}
>
{data.object.compass}
</span>
</div>
)}
{/* Fix #3 (audit) the "0/25/50/75/100м" distance ruler here was a {/* Fix #3 (audit) the "0/25/50/75/100м" distance ruler here was a
hardcoded tick scale over a generic stock photo with no real hardcoded tick scale over a generic stock photo with no real
measurement behind it (not tied to any actual distance/scale measurement behind it (not tied to any actual distance/scale
value). Removed rather than fabricating a scale. */} value). Removed rather than fabricating a scale. */}
{/* corner brackets */} {/* corner brackets decorative HUD framing only; pointerEvents:none
so they never sit on top of the map's own bottom-right OSM
attribution link. */}
<div <div
style={{ style={{
position: "absolute", position: "absolute",
@ -585,6 +648,7 @@ export default function HeroBar({
height: 14, height: 14,
borderLeft: `1.5px solid ${tokens.accent}`, borderLeft: `1.5px solid ${tokens.accent}`,
borderTop: `1.5px solid ${tokens.accent}`, borderTop: `1.5px solid ${tokens.accent}`,
pointerEvents: "none",
}} }}
/> />
<div <div
@ -596,6 +660,7 @@ export default function HeroBar({
height: 14, height: 14,
borderRight: `1.5px solid ${tokens.accent}`, borderRight: `1.5px solid ${tokens.accent}`,
borderTop: `1.5px solid ${tokens.accent}`, borderTop: `1.5px solid ${tokens.accent}`,
pointerEvents: "none",
}} }}
/> />
<div <div
@ -607,6 +672,7 @@ export default function HeroBar({
height: 14, height: 14,
borderLeft: `1.5px solid ${tokens.accent}`, borderLeft: `1.5px solid ${tokens.accent}`,
borderBottom: `1.5px solid ${tokens.accent}`, borderBottom: `1.5px solid ${tokens.accent}`,
pointerEvents: "none",
}} }}
/> />
<div <div
@ -618,6 +684,7 @@ export default function HeroBar({
height: 14, height: 14,
borderRight: `1.5px solid ${tokens.accent}`, borderRight: `1.5px solid ${tokens.accent}`,
borderBottom: `1.5px solid ${tokens.accent}`, borderBottom: `1.5px solid ${tokens.accent}`,
pointerEvents: "none",
}} }}
/> />
</div> </div>

View file

@ -1,33 +1,128 @@
"use client"; "use client";
// "ПОЯСНЕНИЕ К РАСЧЁТУ" right-side drawer for the /trade-in/v2 "МЕРА Оценка" // "ПОЯСНЕНИЕ К РАСЧЁТУ" right-side drawer for the /trade-in/v2 "МЕРА Оценка"
// design port. Opened from HeroBar (the "?" near "КОЭФ. ЛОКАЦИИ"). It used to // design port. Opened from HeroBar (the "?" near "ЛОКАЦИЯ"). It used to render
// render a FABRICATED location coefficient (0.87), a fake "base × coef = result" // a FABRICATED location coefficient (0.87), a fake "base × coef = result"
// formula and invented POI factor lists with a false "Источник: OpenStreetMap" // formula and invented POI factor lists with a false "Источник: OpenStreetMap"
// footer — none of which the backend produced at the time (location-coef was // footer. That location-coef metric was replaced outright (see
// deferred, backend #2045). #2317 wires the now-real GET /trade-in/location-coef // backend/app/services/location_index.py for the full audit): ±5% range,
// response (mapLocation, ./mappers.ts): a real coefficient + real nearest-POI // uncorrelated with real prices, never actually fed the estimate. This drawer
// factor list when available, and an HONEST "недоступно" state (never a fake // now renders GET /trade-in/location-index (mapLocation, ./mappers.ts): a real
// zero/coefficient) when geo_source="unavailable" (local POI mirror empty/stale // % deviation of the local median ₽/м² from the citywide median — framed as a
// for this environment, or the estimate has no lat/lon). Keeps the same drawer // comparison metric, explicitly NOT a price adjustment — plus the real
// shell / slide animation / close button. Open/close is driven entirely by // nearest-POI list. The three degraded states (loading / out_of_coverage /
// props; while open it is a real modal dialog (role=dialog/aria-modal, // insufficient_data) each get their own honest explanation instead of one
// focus-trap, Esc) with semantics mirrored from SectionOverlay. // blank "недоступно". Keeps the same drawer shell / slide animation / close
// button. Open/close is driven entirely by props; while open it is a real
// modal dialog (role=dialog/aria-modal, focus-trap, Esc) with semantics
// mirrored from SectionOverlay.
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { tokens } from "./tokens"; import { tokens } from "./tokens";
import { pluralRu } from "./mappers";
import type { LocationData } from "./mappers"; import type { LocationData } from "./mappers";
// Default presentation data (unwired usage / no coef fetched yet): the honest // Default presentation data (unwired usage / not fetched yet): the honest
// unavailable state, never a fabricated coefficient. // loading state, never a fabricated coefficient.
const LOCATION_FIXTURE: LocationData = { const LOCATION_FIXTURE: LocationData = {
available: false, status: "loading",
coefDelta: "—", indexLabel: "—",
baseLabel: "—", localMedianLabel: "—",
resultLabel: "—", cityMedianLabel: "—",
sampleSize: 0,
radiusLabel: "—",
poiAvailable: false,
factors: [], factors: [],
}; };
// data.indexLabel is already the signed, rounded fmtPct string produced by
// mapLocation ("+12%" / "8%" / "0%" / "—") — reusing it here (rather than a
// second raw-number field) keeps the sign/rounding logic in one place
// (./mappers.ts). Turns it into a plain-language comparison sentence instead
// of a bare percent, so it reads as "vs. the city", never as a price change.
function locationDirectionSentence(indexLabel: string): string {
if (indexLabel.startsWith("")) {
return `Район дешевле города на ${indexLabel.slice(1)}`;
}
if (indexLabel.startsWith("+")) {
return `Район дороже города на ${indexLabel.slice(1)}`;
}
if (indexLabel === "0%") return "Район на уровне медианы по городу";
return "—";
}
// «Что рядом» — qualitative POI list, independent of the numeric index
// (poi_status degrades separately from status, see mapLocation/./mappers.ts).
function PoiSection({ data }: { data: LocationData }) {
if (!data.poiAvailable) {
return (
<div style={{ marginTop: 12, fontSize: 11.5, color: tokens.muted3 }}>
Данные о ближайшей инфраструктуре сейчас недоступны.
</div>
);
}
return (
<>
<div
style={{
marginTop: 12,
marginBottom: 6,
fontSize: 10,
letterSpacing: "1px",
color: tokens.muted2,
}}
>
ЧТО РЯДОМ
</div>
{data.factors.length > 0 ? (
<ul
style={{
listStyle: "none",
margin: 0,
padding: 0,
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
{data.factors.map((f, i) => (
<li
key={i}
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 10,
fontSize: 11.5,
}}
>
<span style={{ color: tokens.ink2 }}>
{f.label}
{f.category !== f.label && (
<span style={{ color: tokens.muted3 }}> · {f.category}</span>
)}
</span>
<span
style={{
flex: "0 0 auto",
color: tokens.muted,
fontFamily: tokens.font.mono,
}}
>
{f.distance}
</span>
</li>
))}
</ul>
) : (
<div style={{ fontSize: 11.5, color: tokens.muted3 }}>
Объектов инфраструктуры в радиусе поиска не найдено.
</div>
)}
</>
);
}
interface LocationDrawerProps { interface LocationDrawerProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
@ -313,7 +408,6 @@ export function LocationDrawer({
> >
ЛОКАЦИЯ ЛОКАЦИЯ
</div> </div>
{data.available ? (
<div <div
style={{ style={{
fontSize: 12.5, fontSize: 12.5,
@ -325,16 +419,26 @@ export function LocationDrawer({
padding: "12px 14px", padding: "12px 14px",
}} }}
> >
{data.status === "ok" && (
<>
<div
style={{ fontSize: 13, fontWeight: 500, color: tokens.ink2 }}
>
{locationDirectionSentence(data.indexLabel)}
</div>
<div <div
style={{ style={{
display: "flex", display: "flex",
alignItems: "baseline", alignItems: "baseline",
justifyContent: "space-between", justifyContent: "space-between",
marginTop: 10,
}} }}
> >
<span>Без поправки на локацию</span> <span>Медиана /м² рядом (радиус {data.radiusLabel})</span>
<span style={{ fontFamily: tokens.font.mono, color: tokens.ink2 }}> <span
{data.baseLabel} style={{ fontFamily: tokens.font.mono, color: tokens.ink2 }}
>
{data.localMedianLabel}
</span> </span>
</div> </div>
<div <div
@ -343,24 +447,18 @@ export function LocationDrawer({
alignItems: "baseline", alignItems: "baseline",
justifyContent: "space-between", justifyContent: "space-between",
marginTop: 4, marginTop: 4,
marginBottom: data.factors.length > 0 ? 10 : 0,
}} }}
> >
<span>С поправкой на локацию ({data.coefDelta})</span> <span>Медиана /м² по Екатеринбургу</span>
<span <span
style={{ style={{ fontFamily: tokens.font.mono, color: tokens.ink2 }}
fontFamily: tokens.font.mono,
fontSize: 14,
fontWeight: 600,
color: tokens.accent,
}}
> >
{data.resultLabel} {data.cityMedianLabel}
</span> </span>
</div> </div>
{/* Fix #7c (audit): the estimator never reads this coefficient it's {/* Honest, not illustrative: this metric никогда не идёт в цену
illustrative/reference only. Without this line the -vs- layout (аналоги уже берутся из этого же района повторный учёт
above reads as if it changes the quoted price. */} локации был бы задвоением, см. app/services/location_index.py). */}
<div <div
style={{ style={{
marginTop: 8, marginTop: 8,
@ -369,86 +467,36 @@ export function LocationDrawer({
color: tokens.muted3, color: tokens.muted3,
}} }}
> >
Справочно: поправка на локацию не влияет на итоговую оценку. Посчитано по {data.sampleSize}{" "}
{pluralRu(data.sampleSize, [
"объявлению",
"объявлениям",
"объявлениям",
])}{" "}
в радиусе {data.radiusLabel}. Справочно на итоговую оценку не
влияет: аналоги для расчёта уже берутся из этого района.
</div> </div>
{data.factors.length > 0 ? ( <PoiSection data={data} />
<ul </>
style={{
listStyle: "none",
margin: 0,
padding: 0,
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
{data.factors.map((f, i) => (
<li
key={i}
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 10,
fontSize: 11.5,
}}
>
<span style={{ color: tokens.ink2 }}>
{f.label}
{f.category !== f.label && (
<span style={{ color: tokens.muted3 }}>
{" "}
· {f.category}
</span>
)} )}
</span> {data.status === "out_of_coverage" && (
<span <>
style={{ Локационный индекс считаем только по{" "}
flex: "0 0 auto", <b style={{ color: tokens.ink2 }}>Екатеринбургу</b> этот адрес
color: tokens.muted, вне зоны покрытия, сравнение с городом недоступно.
fontFamily: tokens.font.mono, </>
}}
>
{f.distance}
</span>
</li>
))}
</ul>
) : (
<div style={{ fontSize: 11.5, color: tokens.muted3 }}>
Объектов инфраструктуры в радиусе поиска не найдено.
</div>
)} )}
<div {data.status === "insufficient_data" && (
style={{ <>
marginTop: 12, Рядом нашлось только{" "}
fontSize: 10.5, <b style={{ color: tokens.ink2 }}>{data.sampleSize}</b>{" "}
lineHeight: 1.5, сопоставимых объявлений (радиус {data.radiusLabel}) этого
color: tokens.muted3, недостаточно для надёжного сравнения с городом.
}} <PoiSection data={data} />
> </>
Ориентировочная поправка по близости инфраструктуры
(OpenStreetMap) MVP-эвристика, диапазон ±5%, не откалибрована
на реальных ценовых сделках.
</div>
</div>
) : (
<div
style={{
fontSize: 12.5,
lineHeight: 1.7,
color: tokens.body2,
background: tokens.infoSoftBg,
border: `1px solid ${tokens.infoSoftBorder}`,
borderRadius: 7,
padding: "12px 14px",
}}
>
Данные о ближайшей инфраструктуре для этого адреса сейчас{" "}
<b style={{ color: tokens.ink2 }}>недоступны</b> коэффициент
локации не корректирует текущую оценку.
</div>
)} )}
{data.status === "loading" && "Считаем локационный индекс…"}
</div>
</div> </div>
</> </>
); );

View file

@ -7,9 +7,16 @@
// styles are UNCHANGED — only the data plumbing differs (display <div>s became // styles are UNCHANGED — only the data plumbing differs (display <div>s became
// <input>s styled identically, dropdowns now feed real enum values). RU dropdown // <input>s styled identically, dropdowns now feed real enum values). RU dropdown
// labels <-> API enum values go through HOUSE_TYPE_*/REPAIR_* maps in ./mappers. // labels <-> API enum values go through HOUSE_TYPE_*/REPAIR_* maps in ./mappers.
// РАДИУС is now wired (radius_m on submit + the outer map ring scales with it); // РАДИУС is now wired (radius_m on submit + the real map circle below scales
// the CRM dropdown still has no backend → kept visually but disabled. Hover/active // with it); the CRM dropdown still has no backend → kept visually but disabled.
// + @keyframes live in a pp-prefixed local <style>. // Hover/active + @keyframes live in a pp-prefixed local <style>.
//
// Fix (audit): the 01 map was a decorative SVG (grid + fake streets + radius
// rings) with no real coordinates behind it. It is now a real Leaflet + OSM
// basemap, ported from the same CDN-loader pattern as ./SourcesMap.tsx /
// ../MapPicker.tsx (copied rather than shared — same self-contained-port
// convention as SourcesMap.tsx, no npm Leaflet dep).
/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet (см. SourcesMap.tsx) */
import { import {
useEffect, useEffect,
@ -143,6 +150,97 @@ function comboKeyDown(
} }
} }
// ── 01 map — Leaflet + OSM (real coordinates) ───────────────────────────────
// Same CDN loader pattern/version/SRI as ./SourcesMap.tsx and ../MapPicker.tsx
// (duplicated on purpose — each v2 file is a self-contained port, no shared
// runtime module, no npm Leaflet dep).
const LEAFLET_VER = "1.9.4";
const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.css`;
const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.js`;
const LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=";
const LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=";
const MAP_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const MAP_ATTRIBUTION = "© OpenStreetMap";
const DEFAULT_MAP_ZOOM = 16;
const MIN_MAP_ZOOM = 11;
const MAX_MAP_ZOOM = 19;
/** Подгружает Leaflet с CDN один раз, резолвит window.L. (mirror SourcesMap.tsx) */
function loadLeaflet(): Promise<any> {
return new Promise((resolve, reject) => {
const w = window as any;
if (w.L) {
resolve(w.L);
return;
}
if (!document.querySelector(`link[data-leaflet]`)) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = LEAFLET_CSS;
link.integrity = LEAFLET_CSS_SRI;
link.crossOrigin = "anonymous";
link.setAttribute("data-leaflet", "1");
document.head.appendChild(link);
}
const existing = document.querySelector<HTMLScriptElement>(`script[data-leaflet]`);
if (existing) {
existing.addEventListener("load", () => resolve(w.L));
existing.addEventListener("error", () => reject(new Error("leaflet load failed")));
return;
}
const script = document.createElement("script");
script.src = LEAFLET_JS;
script.integrity = LEAFLET_JS_SRI;
script.crossOrigin = "anonymous";
script.setAttribute("data-leaflet", "1");
script.onload = () => resolve(w.L);
script.onerror = () => reject(new Error("leaflet load failed"));
document.body.appendChild(script);
});
}
/** Экранирование перед вставкой в raw-HTML Leaflet divIcon (адрес ввод
* пользователя). Тот же паттерн, что и esc() в SourcesMap.tsx. */
function escapeMapHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) =>
c === "&"
? "&amp;"
: c === "<"
? "&lt;"
: c === ">"
? "&gt;"
: c === '"'
? "&quot;"
: "&#39;",
);
}
/** Пин квартиры-предмета оценки divIcon 1:1 повторяет прежний SVG-бабл
* (акцентная рамка, точка, адрес + площадь, треугольник-указатель), только
* теперь висит на реальных координатах реальной карты. */
function buildSubjectIcon(
L: any,
addressLabel: string,
areaLabel: string | null,
): any {
const html = `
<div style="position:relative;width:220px;height:56px;pointer-events:none">
<div style="position:absolute;left:50%;bottom:9px;transform:translateX(-50%);display:flex;align-items:center;gap:6px;background:${tokens.surface.w85};border:1px solid ${tokens.accent};border-radius:4px;padding:4px 8px;white-space:nowrap;box-shadow:0 3px 10px rgba(46,139,255,.25);font-family:${tokens.font.sans}">
<span style="width:7px;height:7px;border-radius:50%;background:${tokens.accent};flex:0 0 auto"></span>
<span style="display:flex;flex-direction:column;gap:1px">
<span style="font-size:11px;font-weight:600;color:${tokens.ink}">${escapeMapHtml(addressLabel)}</span>
${
areaLabel
? `<span style="font-size:9px;color:${tokens.muted}">${escapeMapHtml(areaLabel)}</span>`
: ""
}
</span>
</div>
<div style="position:absolute;left:50%;bottom:0;transform:translateX(-50%);width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;border-top:7px solid ${tokens.accent}"></div>
</div>`;
return L.divIcon({ html, className: "", iconSize: [220, 56], iconAnchor: [110, 56] });
}
function Dd({ function Dd({
open, open,
onToggle, onToggle,
@ -425,21 +523,6 @@ const errorText: CSSProperties = {
color: tokens.danger, 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,
};
// РАДИУС dropdown panel — mirrors the <Dd> HUD panel (surface.w98 + soft blue // РАДИУС dropdown panel — mirrors the <Dd> HUD panel (surface.w98 + soft blue
// shadow), sized to the narrow radius trigger and dropped just beneath it. // shadow), sized to the narrow radius trigger and dropped just beneath it.
const radiusPanel: CSSProperties = { const radiusPanel: CSSProperties = {
@ -468,8 +551,13 @@ interface ParamsPanelProps {
error?: string | null; error?: string | null;
/** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */ /** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */
initialValues?: Partial<TradeInEstimateInput>; initialValues?: Partial<TradeInEstimateInput>;
/** Analog price pins for the 01 map, projected from the real estimate via /** Analog price pins, projected from the real estimate via mapMarkers() onto
* mapMarkers(). Default [] no price pins (never the fabricated fixtures). */ * the OLD decorative SVG's fixed 0-100% grid (never a real geo scale see
* mapMarkers() comment in ./mappers.ts). Kept in the prop contract for
* backward-compat with the caller (app/v2/page.tsx); intentionally NOT
* plotted on the real Leaflet map below, because their %-positions do not
* correspond to real lat/lon at the map's actual zoom projecting them
* would be a new, subtler version of the honesty bug this map replaces. */
markers?: MapMarker[]; markers?: MapMarker[];
} }
@ -495,6 +583,12 @@ function initRepairLabel(rs: RepairState | undefined): string {
// both ("ищем строго в пределах X м"). Design dropdown was values-only. // both ("ищем строго в пределах X м"). Design dropdown was values-only.
const RADIUS_OPTIONS = ["Авто", "300 м", "500 м", "1000 м", "2000 м"]; const RADIUS_OPTIONS = ["Авто", "300 м", "500 м", "1000 м", "2000 м"];
// "Авто" sends no radius_m → the backend applies its two-tier default (1000 m
// primary / 2000 m fallback, see RADIUS_OPTIONS comment above). The map circle
// previews the PRIMARY tier so it is never wildly off from what the backend
// will actually use.
const AUTO_RADIUS_PREVIEW_M = 1000;
// radius_m (metres) -> dropdown label. null/absent → "Авто" (legacy two-tier // radius_m (metres) -> dropdown label. null/absent → "Авто" (legacy two-tier
// default), so a re-submit never silently narrows the search. // default), so a re-submit never silently narrows the search.
function initRadiusLabel(radiusM: number | null | undefined): string { function initRadiusLabel(radiusM: number | null | undefined): string {
@ -509,7 +603,9 @@ export default function ParamsPanel({
hasEstimate = false, hasEstimate = false,
error = null, error = null,
initialValues, initialValues,
markers = [], // markers intentionally not destructured — see the ParamsPanelProps.markers
// doc comment: its %-positions belong to the retired decorative SVG grid and
// do not correspond to real lat/lon on the Leaflet map below.
}: ParamsPanelProps) { }: ParamsPanelProps) {
// M4 — once a result is on screen the primary CTA is «СКАЧАТЬ PDF-ОТЧЁТ» // M4 — once a result is on screen the primary CTA is «СКАЧАТЬ PDF-ОТЧЁТ»
// (HeroBar); this button (a re-run) demotes to a secondary outline. // (HeroBar); this button (a re-run) demotes to a secondary outline.
@ -518,11 +614,6 @@ export default function ParamsPanel({
// РАДИУС combobox a11y state (aria-activedescendant highlight + listbox id). // РАДИУС combobox a11y state (aria-activedescendant highlight + listbox id).
const radiusListId = useId(); const radiusListId = useId();
const [radiusActive, setRadiusActive] = useState(-1); const [radiusActive, setRadiusActive] = useState(-1);
// Map zoom — applied as transform: scale() on the map content layer (not the
// 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 [address, setAddress] = useState(initialValues?.address ?? "");
const [area, setArea] = useState( const [area, setArea] = useState(
initialValues?.area_m2 != null ? String(initialValues.area_m2) : "", initialValues?.area_m2 != null ? String(initialValues.area_m2) : "",
@ -776,21 +867,138 @@ export default function ParamsPanel({
transition: "all .15s", transition: "all .15s",
}; };
// Outer radius ring tracks the selected РАДИУС (subtle, best-effort): 500 м // Real analysis-radius circle (metres), synced with the selected РАДИУС —
// keeps the design's r=112 exactly; other values scale gently (and clamp) so the // L.circle takes a radius in metres, so this is a true geographic scale
// ring never blows past the map box. The inner two rings stay fixed. // (unlike the old SVG ring, which was a clamped pixel best-effort).
const radiusM = parseInt(radius, 10); const parsedRadiusM = parseInt(radius, 10);
const outerRingR = Number.isFinite(radiusM) const circleRadiusM = Number.isFinite(parsedRadiusM)
? Math.round( ? parsedRadiusM
Math.max(70, Math.min(155, 112 * Math.pow(radiusM / 500, 0.35))), : AUTO_RADIUS_PREVIEW_M;
)
: 112;
// Subject area caption for the map pin (M10) — the SUBJECT's own m², from the // Subject area caption for the map pin (M10) — the SUBJECT's own m², from the
// form, so the pin never borrows an analog's area. Empty area → no caption. // form, so the pin never borrows an analog's area. Empty area → no caption.
const areaTrimmed = area.trim(); const areaTrimmed = area.trim();
const subjectAreaLabel = areaTrimmed ? `${areaTrimmed} м²` : null; const subjectAreaLabel = areaTrimmed ? `${areaTrimmed} м²` : null;
// ── 01 map — real Leaflet + OSM (see loadLeaflet()/buildSubjectIcon() above) ──
const hasCoords = coords != null;
const mapContainerRef = useRef<HTMLDivElement>(null);
const leafletMapRef = useRef<any>(null);
const markerRef = useRef<any>(null);
const circleRef = useRef<any>(null);
const [mapZoom, setMapZoom] = useState(DEFAULT_MAP_ZOOM);
const [mapLoadError, setMapLoadError] = useState(false);
// "Latest value" refs, read inside the async loadLeaflet().then() callback
// below, which may resolve several renders after the effect fired (slow CDN
// load). Plain closure vars would go stale; refs assigned every render don't.
const addressRef = useRef(address);
addressRef.current = address;
const subjectAreaLabelRef = useRef(subjectAreaLabel);
subjectAreaLabelRef.current = subjectAreaLabel;
const circleRadiusMRef = useRef(circleRadiusM);
circleRadiusMRef.current = circleRadiusM;
const coordsRef = useRef(coords);
coordsRef.current = coords;
// Create/destroy the map when coordinates appear/disappear (not on every
// lat/lon value change — see the position-sync effect below for that). This
// is the only effect that mounts/tears down the Leaflet instance, so it is
// also the only place map.remove() needs to run, fixing the "Map container
// is already initialized" crash class.
useEffect(() => {
if (!hasCoords) return; // no coords yet -> the placeholder renders instead
let map: any = null;
let cancelled = false;
// Reset a previous CDN failure before retrying. Without this the error
// branch is a dead end: it replaces the map container in the render tree,
// so mapContainerRef stays null and no later attempt can ever succeed —
// one transient unpkg blip would kill the map for the rest of the session
// even after the user picks a different address.
setMapLoadError(false);
loadLeaflet()
.then((L) => {
if (cancelled || !mapContainerRef.current || !coordsRef.current) return;
const center: [number, number] = [
coordsRef.current.lat,
coordsRef.current.lon,
];
map = L.map(mapContainerRef.current, {
scrollWheelZoom: false,
zoomControl: false,
minZoom: MIN_MAP_ZOOM,
maxZoom: MAX_MAP_ZOOM,
}).setView(center, DEFAULT_MAP_ZOOM);
L.tileLayer(MAP_TILE_URL, {
attribution: MAP_ATTRIBUTION,
maxZoom: MAX_MAP_ZOOM,
}).addTo(map);
setTimeout(() => map && map.invalidateSize(), 120);
circleRef.current = L.circle(center, {
radius: circleRadiusMRef.current,
color: tokens.accent,
weight: 1,
fillColor: tokens.accent,
fillOpacity: 0.05,
interactive: false,
}).addTo(map);
markerRef.current = L.marker(center, {
icon: buildSubjectIcon(
L,
addressRef.current.trim() || "Адрес квартиры",
subjectAreaLabelRef.current,
),
interactive: false,
}).addTo(map);
leafletMapRef.current = map;
setMapZoom(DEFAULT_MAP_ZOOM);
map.on("zoomend", () => setMapZoom(map.getZoom()));
})
.catch(() => setMapLoadError(true));
return () => {
cancelled = true;
if (map) map.remove();
leafletMapRef.current = null;
markerRef.current = null;
circleRef.current = null;
};
// hasCoords is the only plain dependency this effect reads directly — the
// live lat/lon/radius/address values come from refs (assigned every
// render above), which exhaustive-deps correctly treats as stable.
}, [hasCoords]);
// Move the existing map/marker/circle to a newly picked address without
// tearing the map down (avoids a tile-reload flash on every pick).
useEffect(() => {
const map = leafletMapRef.current;
if (!map || !coords) return;
const center: [number, number] = [coords.lat, coords.lon];
map.setView(center, map.getZoom());
markerRef.current?.setLatLng(center);
circleRef.current?.setLatLng(center);
}, [coords]);
// Sync the radius circle in place when the РАДИУС АНАЛИЗА selection changes.
useEffect(() => {
circleRef.current?.setRadius(circleRadiusM);
}, [circleRadiusM]);
// Refresh the pin's address/area caption in place as the user edits the form.
useEffect(() => {
if (!markerRef.current) return;
const w = window as any;
if (!w.L) return;
markerRef.current.setIcon(
buildSubjectIcon(w.L, address.trim() || "Адрес квартиры", subjectAreaLabel),
);
}, [address, subjectAreaLabel]);
// Address combobox render state (M6). `popupOpen` = the dropdown is shown at // Address combobox render state (M6). `popupOpen` = the dropdown is shown at
// all (incl. loading / empty notes); `listboxOpen` = it holds real selectable // all (incl. loading / empty notes); `listboxOpen` = it holds real selectable
// options, which is when the input advertises aria-expanded + activedescendant. // options, which is when the input advertises aria-expanded + activedescendant.
@ -870,7 +1078,12 @@ export default function ParamsPanel({
</div> </div>
</div> </div>
{/* MAP */} {/* MAP real Leaflet + OSM basemap, centred on the geocoded address
(Fix, audit #2264 C7 follow-up): was a decorative SVG (grid + fake
streets + a clamped best-effort radius ring), no real coordinates
behind it at all. © OpenStreetMap attribution comes from Leaflet's
own attribution control on the tile layer below (OSM tile licence
requirement) no separate caption needed. */}
<div <div
style={{ style={{
position: "relative", position: "relative",
@ -882,126 +1095,34 @@ export default function ParamsPanel({
flex: "0 0 auto", flex: "0 0 auto",
}} }}
> >
{/* zoomable map content — scaled by the +/ controls. The map controls {hasCoords ? (
and corner bracket below are SIBLINGS (outside this layer) so they mapLoadError ? (
never scale. zoom=1 scale() identity pixel-identical to design. */}
<div <div
style={{ style={{
position: "absolute", position: "absolute",
inset: 0, inset: 0,
transform: `scale(${zoom})`,
transformOrigin: "center",
transition: "transform .15s ease-out",
}}
>
<svg
viewBox="0 0 440 210"
preserveAspectRatio="xMidYMid slice"
aria-hidden="true"
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={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 6, justifyContent: "center",
background: tokens.surface.w85, padding: "0 16px",
border: `1px solid ${tokens.accent}`, textAlign: "center",
borderRadius: 4, fontSize: 10.5,
padding: "4px 8px", lineHeight: 1.4,
whiteSpace: "nowrap", color: tokens.hint,
boxShadow: "0 3px 10px rgba(46,139,255,.25)", fontFamily: tokens.font.sans,
}} }}
> >
<span Не удалось загрузить карту. Проверьте интернет-соединение.
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: tokens.accent,
}}
/>
{/* M10: the SUBJECT pin must show the subject's OWN address + area,
never an analog's. The form area (subjectAreaLabel) is rendered
here so an analog pin that lands near the centre can no longer be
mistaken for the subject's caption. */}
<span
style={{ display: "flex", flexDirection: "column", gap: 1 }}
>
<span style={{ fontSize: 11, fontWeight: 600 }}>
{address.trim() || "Адрес квартиры"}
</span>
{subjectAreaLabel && (
<span style={{ fontSize: 9, color: tokens.muted }}>
{subjectAreaLabel} · оцениваемая квартира
</span>
)}
</span>
</div> </div>
) : (
<>
<div <div
style={{ ref={mapContainerRef}
width: 0, style={{ position: "absolute", inset: 0 }}
height: 0, aria-label="Карта расположения квартиры"
borderLeft: "5px solid transparent",
borderRight: "5px solid transparent",
borderTop: `7px solid ${tokens.accent}`,
}}
/> />
</div> {/* map controls real Leaflet zoom (Leaflet's own zoomControl
is disabled above; these are the HUD-styled buttons). */}
{/* 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>
))}
</div>
{/* map controls */}
<div <div
style={{ style={{
position: "absolute", position: "absolute",
@ -1015,6 +1136,7 @@ export default function ParamsPanel({
<button <button
type="button" type="button"
aria-label="Приблизить карту" aria-label="Приблизить карту"
disabled={mapZoom >= MAX_MAP_ZOOM}
style={{ style={{
width: 24, width: 24,
height: 24, height: 24,
@ -1024,21 +1146,22 @@ export default function ParamsPanel({
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
cursor: "pointer", cursor: mapZoom >= MAX_MAP_ZOOM ? "default" : "pointer",
transition: "all .15s", transition: "all .15s",
fontSize: 15, fontSize: 15,
color: tokens.muted, color: tokens.muted,
opacity: mapZoom >= MAX_MAP_ZOOM ? 0.4 : 1,
padding: 0, padding: 0,
fontFamily: "inherit", fontFamily: "inherit",
}} }}
onClick={() => setZoom((z) => Math.min(2.5, z + 0.25))} onClick={() => leafletMapRef.current?.zoomIn()}
> >
+ +
</button> </button>
<button <button
type="button" type="button"
aria-label="Отдалить карту" aria-label="Отдалить карту"
disabled={zoom <= 1} disabled={mapZoom <= MIN_MAP_ZOOM}
style={{ style={{
width: 24, width: 24,
height: 24, height: 24,
@ -1048,23 +1171,66 @@ export default function ParamsPanel({
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
cursor: zoom <= 1 ? "default" : "pointer", cursor: mapZoom <= MIN_MAP_ZOOM ? "default" : "pointer",
transition: "all .15s", transition: "all .15s",
fontSize: 15, fontSize: 15,
color: tokens.muted, color: tokens.muted,
opacity: zoom <= 1 ? 0.4 : 1, opacity: mapZoom <= MIN_MAP_ZOOM ? 0.4 : 1,
padding: 0, padding: 0,
fontFamily: "inherit", fontFamily: "inherit",
}} }}
onClick={() => { onClick={() => leafletMapRef.current?.zoomOut()}
if (zoom > 1) setZoom((z) => Math.max(1, z - 0.25));
}}
> >
</button> </button>
</div> </div>
</>
)
) : (
/* No coordinates yet (address not typed/picked from suggest) a
tidy placeholder, never an empty grey box or a broken map. */
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 6,
padding: "0 16px",
textAlign: "center",
}}
>
<svg
aria-hidden="true"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
>
<path
d="M12 21s-7-7.2-7-12a7 7 0 1 1 14 0c0 4.8-7 12-7 12Z"
stroke={tokens.muted2}
strokeWidth={1.5}
/>
<circle cx="12" cy="9" r="2.5" stroke={tokens.muted2} strokeWidth={1.5} />
</svg>
<span
style={{
fontSize: 10.5,
lineHeight: 1.4,
color: tokens.hint,
fontFamily: tokens.font.sans,
}}
>
Выберите адрес из подсказок, чтобы увидеть карту
</span>
</div>
)}
{/* map corner bracket */} {/* map corner bracket decorative HUD chrome, shown regardless of
map/placeholder state (unchanged from the prior design). */}
<div <div
style={{ style={{
position: "absolute", position: "absolute",
@ -1078,23 +1244,6 @@ export default function ParamsPanel({
/> />
</div> </div>
{/* Fix #7 (audit) the SVG above is decorative (grid + fake streets +
radius rings), not a real map, and the rings themselves aren't drawn
to scale (outerRingR is a clamped best-effort, not a true geometric
projection of РАДИУС). Same honesty-caption tone as SourcesMap's
footer disclosure. */}
<div
style={{
fontSize: 9,
color: tokens.hint,
lineHeight: 1.4,
marginTop: 6,
flex: "0 0 auto",
}}
>
Схематично · не географическая карта · радиус не в масштабе
</div>
{/* radius row */} {/* radius row */}
<div <div
style={{ style={{

View file

@ -13,7 +13,6 @@ import type {
DealRow, DealRow,
DropdownOptions, DropdownOptions,
History, History,
LocationFactors,
MarketAds, MarketAds,
MarketDeals, MarketDeals,
ObjectInfo, ObjectInfo,
@ -44,9 +43,12 @@ export const object: ObjectInfo = {
houseType: "Панельный", houseType: "Панельный",
repair: "Хороший", repair: "Хороший",
balcony: true, balcony: true,
locationCoef: "0.87", locationIndexLabel: "+8%",
streetView: "Street View · май 2024", locationIndexOk: true,
compass: "СЕВЕРО-ЗАПАД", // Approximate центр Екатеринбурга near ул. Малышева, 30 — illustrative
// fixture coordinate for the HeroBar locator mini-map (unwired usage only).
lat: 56.8384,
lon: 60.6057,
}; };
// ---- 02 RESULT ------------------------------------------------------------ // ---- 02 RESULT ------------------------------------------------------------
@ -631,28 +633,12 @@ export const dropdownOptions: DropdownOptions = {
], ],
}; };
// ---- КОЭФ. ЛОКАЦИИ DRAWER ------------------------------------------------- // Note: the pre-wiring `locationFactors` fixture (fabricated 0.87 coefficient
// + base/coef/result formula + positives/negatives) lived here — removed
export const locationFactors: LocationFactors = { // together with the location-index rewrite. LocationDrawer now reads
coef: "0.87", // `LocationData` produced by `mapLocation` (./mappers.ts) from the real
intro: // GET /trade-in/location-index response; its own default/unwired fixture is
"Показывает, как адрес корректирует цену относительно медианы по Екатеринбургу. 1.00 — средний уровень. 0.87 означает, что локация снижает цену примерно на 13% из-за баланса факторов ниже.", // LOCATION_FIXTURE in ./LocationDrawer.tsx.
formula: { base: "11,29 млн", coef: "0.87", result: "9,82 млн ₽" },
positives: [
{ label: "Центр города, пешая доступность ключевых точек", delta: "+0.06" },
{ label: "Транспортные узлы и остановки рядом", delta: "+0.05" },
{ label: "Набережная и парк в 10 минутах", delta: "+0.04" },
{ label: "Развитая торговая инфраструктура", delta: "+0.03" },
],
negatives: [
{ label: "Оживлённая магистраль, шумовая нагрузка", delta: "0.07" },
{ label: "Дефицит парковочных мест", delta: "0.05" },
{ label: "Возраст жилого фонда района (1985)", delta: "0.04" },
{ label: "Износ инженерных сетей квартала", delta: "0.02" },
],
footer:
"Источник геоданных: OpenStreetMap POI, транспортная доступность, шумовые и экологические слои. Коэффициент пересчитывается при смене адреса.",
};
// ---- NAV / CHROME --------------------------------------------------------- // ---- NAV / CHROME ---------------------------------------------------------

View file

@ -18,10 +18,9 @@
// source groupBy) — out of scope for #2040/#2041, left as-is. // source groupBy) — out of scope for #2040/#2041, left as-is.
// BE-2 target_address is a single string; street/city are split heuristically // BE-2 target_address is a single string; street/city are split heuristically
// (parseAddress). Backend should return structured address components. // (parseAddress). Backend should return structured address components.
// BE-3 location coefficient shipped 2026-07-03 (#2045) and is wired here // BE-3 location index (replacing the broken location-coef, see
// (mapObject/mapLocation consume GET /trade-in/location-coef, #2317). // backend/app/services/location_index.py) is wired here
// street-view caption / compass bearing are still not in the API → // (mapObject/mapLocation consume GET /trade-in/location-index).
// remain placeholders.
// //
// Enum <-> RU reconciliation (design dropdowns have options with no enum value): // Enum <-> RU reconciliation (design dropdowns have options with no enum value):
// house type: 'Блочный' ⇄ enum 'other' (enum has no dedicated block type) // house type: 'Блочный' ⇄ enum 'other' (enum has no dedicated block type)
@ -36,7 +35,7 @@ import type {
HouseAnalyticsKpi, HouseAnalyticsKpi,
HouseAnalyticsResponse, HouseAnalyticsResponse,
HouseType, HouseType,
LocationCoefResponse, LocationIndexResponse,
PlacementHistoryItem, PlacementHistoryItem,
RepairState, RepairState,
SalesVsListingsResponse, SalesVsListingsResponse,
@ -785,29 +784,39 @@ export function mapReport(e: AggregatedEstimate): Report {
} }
/** /**
* Location-coefficient delta label shared by mapObject (HeroBar tile) and * Compact HeroBar badge for the location index {label, ok} shared by
* mapLocation (LocationDrawer). coef is an MVP heuristic in [0.95, 1.05] * mapObject (ObjectInfo.locationIndexLabel/locationIndexOk) and available for
* (backend location_coef.py::_score_to_coef, NOT calibrated on real price * reuse. location_index_pct is a real % deviation of the local median /м²
* deltas) surfaced as a whole-percent delta via fmtPct (same rounding as the * from the citywide median (see backend/app/services/location_index.py) a
* other honest deltas on this page), never a raw multiplier that would read as * comparison metric, NOT a price multiplier, and it does NOT feed the
* more precise than it is. "—" while absent/loading AND when * estimate. `ok: true` only for status="ok" (a trustworthy percent); every
* geo_source="unavailable" (legitimate graceful fallback, not an error). * other case gets a short, honest, DISTINCT reason instead of a blank dash
* the drawer (mapLocation below) expands on each:
* - li == null "нет данных" (not fetched yet / this estimate
* has no location index request enabled)
* - "out_of_coverage" "вне ЕКБ" (the index only covers Yekaterinburg)
* - "insufficient_data" "мало данных" (too few comparable listings)
*/ */
function coefDeltaLabel(lc: LocationCoefResponse | null | undefined): string { function locationIndexBadge(
if (lc == null || lc.geo_source === "unavailable") return "—"; li: LocationIndexResponse | null | undefined,
return fmtPct((lc.coef - 1) * 100); ): { label: string; ok: boolean } {
if (li == null) return { label: "нет данных", ok: false };
if (li.status === "ok") return { label: fmtPct(li.location_index_pct), ok: true };
if (li.status === "out_of_coverage") return { label: "вне ЕКБ", ok: false };
return { label: "мало данных", ok: false }; // status === "insufficient_data"
} }
/** /**
* Object snapshot (ParamsPanel inputs + HeroBar/ObjectSummary address block). * Object snapshot (ParamsPanel inputs + HeroBar/ObjectSummary address block).
* `coef` is the GET /trade-in/location-coef response (#2317) optional/null * `locationIndex` is the GET /trade-in/location-index response optional/null
* while it is still loading or unavailable for this address. * while it is still loading or not requested for this address.
*/ */
export function mapObject( export function mapObject(
e: AggregatedEstimate, e: AggregatedEstimate,
coef?: LocationCoefResponse | null, locationIndex?: LocationIndexResponse | null,
): ObjectInfo { ): ObjectInfo {
const { address, city } = parseAddress(e.target_address); const { address, city } = parseAddress(e.target_address);
const badge = locationIndexBadge(locationIndex);
return { return {
address, address,
city, city,
@ -819,16 +828,20 @@ export function mapObject(
houseType: e.house_type ? HOUSE_TYPE_RU[e.house_type] : "—", houseType: e.house_type ? HOUSE_TYPE_RU[e.house_type] : "—",
repair: e.repair_state ? REPAIR_RU[e.repair_state] : "—", repair: e.repair_state ? REPAIR_RU[e.repair_state] : "—",
balcony: e.has_balcony ?? false, balcony: e.has_balcony ?? false,
locationCoef: coefDeltaLabel(coef), locationIndexLabel: badge.label,
streetView: "", // TODO BE-3 (backend does not surface a street-view caption) locationIndexOk: badge.ok,
compass: "", // TODO BE-3 (backend does not surface a compass bearing) // Same target_lat/target_lon the ParamsPanel/SourcesMap map pins use —
// null while the estimate has no geocode yet.
lat: e.target_lat,
lon: e.target_lon,
}; };
} }
// ── Location factors (LocationDrawer «ЛОКАЦИЯ») ───────────────────────────── // ── Location index (LocationDrawer «ЛОКАЦИЯ») ───────────────────────────────
// RU category label per OSM POI type, mirroring the CATEGORY_WEIGHTS keys in // RU category label per OSM POI type, mirroring the CATEGORY_WEIGHTS keys in
// backend/app/services/location_coef.py (top7 straight-line POI score). An // backend/app/services/location_index.py (top-N straight-line POI ranking —
// unrecognised category (raw OSM tag outside that dict) falls back to a // a qualitative "what's nearby" list only, no longer feeding any score/coef).
// An unrecognised category (raw OSM tag outside that dict) falls back to a
// generic label rather than surfacing a raw enum-ish string to the user. // generic label rather than surfacing a raw enum-ish string to the user.
const POI_CATEGORY_RU: Record<string, string> = { const POI_CATEGORY_RU: Record<string, string> = {
metro_stop: "Метро", metro_stop: "Метро",
@ -849,51 +862,71 @@ function poiCategoryLabel(poiType: string): string {
return POI_CATEGORY_RU[poiType] ?? POI_CATEGORY_FALLBACK; return POI_CATEGORY_RU[poiType] ?? POI_CATEGORY_FALLBACK;
} }
/** One POI row in the LocationDrawer factor list. */ /** One POI row in the LocationDrawer «что рядом» list. */
export interface LocationFactorRow { export interface LocationFactorRow {
label: string; // POI name if known, else its RU category label: string; // POI name if known, else its RU category
category: string; // RU category label (always present, for the badge) category: string; // RU category label (always present, for the badge)
distance: string; // "150 м" / "1.2 км" distance: string; // "150 м" / "1.2 км"
} }
/**
* LocationDrawer «ЛОКАЦИЯ» section data. `status` mirrors the backend's own
* three-way honest-degradation contract ("loading" is an FE-only 4th state
* for li == null, e.g. query still pending) so the drawer can explain EACH
* case differently instead of collapsing them into one dash:
* - "ok" index/medians reliable, show the real numbers.
* - "out_of_coverage" address outside Yekaterinburg the index simply
* doesn't cover it (not an error, not "no data").
* - "insufficient_data" too few comparable listings even at the widest
* radius sampleSize/radiusLabel still say what was actually found.
* - "loading" not fetched yet.
* indexLabel/localMedianLabel/cityMedianLabel are "—" whenever the
* corresponding backend field is null (never a fabricated number).
*/
export interface LocationData { export interface LocationData {
// true when the backend actually computed a coefficient for this address status: "ok" | "out_of_coverage" | "insufficient_data" | "loading";
// (geo_source="osm_poi_ekb"), even if no POI were found within radius indexLabel: string; // fmtPct(location_index_pct), "—" if not status="ok"
// (factors=[] is then a legitimate empty result, not "no data"). localMedianLabel: string; // fmtPpm(local_median_price_per_m2), "—" if null
available: boolean; cityMedianLabel: string; // fmtPpm(city_median_price_per_m2), "—" if null
coefDelta: string; // same formatting as ObjectInfo.locationCoef, "—" if unavailable sampleSize: number; // comparable active listings actually found (0 if n/a)
baseLabel: string; // base_price_rub before the location adjustment, "X млн ₽" radiusLabel: string; // fmtDist(radius_m), "—" while loading
resultLabel: string; // result_price_rub = round(base_price_rub * coef), "X млн ₽" poiAvailable: boolean; // poi_status === "ok" (independent of status above)
factors: LocationFactorRow[]; factors: LocationFactorRow[];
} }
/** /**
* LocationDrawer «ЛОКАЦИЯ» section data from GET /trade-in/location-coef * LocationDrawer «ЛОКАЦИЯ» section data from GET /trade-in/location-index.
* (#2317). null/undefined/geo_source="unavailable" all degrade to an honest * null/undefined (not fetched yet) degrades to status="loading" never a
* unavailable state never a fabricated coefficient, price or factor list * fabricated index, price or factor list (mirrors the backend's own
* (mirrors the backend's own graceful-fallback contract). * graceful-fallback contract).
*/ */
export function mapLocation( export function mapLocation(
lc: LocationCoefResponse | null | undefined, li: LocationIndexResponse | null | undefined,
): LocationData { ): LocationData {
if (lc == null || lc.geo_source === "unavailable") { if (li == null) {
return { return {
available: false, status: "loading",
coefDelta: "—", indexLabel: "—",
baseLabel: "—", localMedianLabel: "—",
resultLabel: "—", cityMedianLabel: "—",
sampleSize: 0,
radiusLabel: "—",
poiAvailable: false,
factors: [], factors: [],
}; };
} }
return { return {
available: true, status: li.status,
coefDelta: coefDeltaLabel(lc), indexLabel: li.status === "ok" ? fmtPct(li.location_index_pct) : "—",
baseLabel: `${fmtMln(lc.base_price_rub)} млн ₽`, localMedianLabel: fmtPpm(li.local_median_price_per_m2),
resultLabel: `${fmtMln(lc.result_price_rub)} млн ₽`, cityMedianLabel: fmtPpm(li.city_median_price_per_m2),
factors: lc.factors.map((f) => ({ sampleSize: li.sample_size,
label: f.name?.trim() || poiCategoryLabel(f.poi_type), radiusLabel: fmtDist(li.radius_m),
category: poiCategoryLabel(f.poi_type), poiAvailable: li.poi_status === "ok",
distance: fmtDist(f.distance_m), factors: li.nearby_poi.map((p) => ({
label: p.name?.trim() || poiCategoryLabel(p.poi_type),
category: poiCategoryLabel(p.poi_type),
distance: fmtDist(p.distance_m),
})), })),
}; };
} }

View file

@ -18,9 +18,20 @@ export interface ObjectInfo {
houseType: string; houseType: string;
repair: string; repair: string;
balcony: boolean; balcony: boolean;
locationCoef: string; // Compact HeroBar badge for the location index (see mapObject / mappers.ts
streetView: string; // locationIndexBadge): "+12%"/"8%" when the backend has a reliable value
compass: string; // (status="ok"), else a short honest reason ("вне ЕКБ" / "мало данных" /
// "нет данных" while loading) — never a fabricated percent.
locationIndexLabel: string;
// true only when locationIndexLabel is a real percent (status="ok") — tells
// HeroBar whether to render it as the accent numeric value or as the muted
// unavailable-reason pill.
locationIndexOk: boolean;
// Subject coordinates for the HeroBar locator mini-map (Leaflet/OSM). null
// when the estimate has no geocode yet — the map then renders an honest
// "нет координат" placeholder instead of an empty/broken box.
lat: number | null;
lon: number | null;
} }
// ---- 02 RESULT ------------------------------------------------------------ // ---- 02 RESULT ------------------------------------------------------------
@ -284,21 +295,11 @@ export interface DropdownOptions {
crm: string[]; crm: string[];
} }
// ---- КОЭФ. ЛОКАЦИИ DRAWER ------------------------------------------------- // Note: the pre-wiring `LocationFactor`/`LocationFactors` types (base/coef/
// result formula + positives/negatives) lived here — removed together with
export interface LocationFactor { // the location-index rewrite: LocationDrawer now consumes `LocationData`
label: string; // (see ./mappers.ts), which reflects the real backend contract, not the old
delta: string; // fabricated formula.
}
export interface LocationFactors {
coef: string;
intro: string;
formula: { base: string; coef: string; result: string };
positives: LocationFactor[];
negatives: LocationFactor[];
footer: string;
}
// ---- USER / CHROME -------------------------------------------------------- // ---- USER / CHROME --------------------------------------------------------

View file

@ -12,7 +12,7 @@ import type {
HouseAnalyticsResponse, HouseAnalyticsResponse,
HouseInfoForEstimate, HouseInfoForEstimate,
IMVBenchmarkResponse, IMVBenchmarkResponse,
LocationCoefResponse, LocationIndexResponse,
PlacementHistoryItem, PlacementHistoryItem,
SalesVsListingsResponse, SalesVsListingsResponse,
SellTimeSensitivityResponse, SellTimeSensitivityResponse,
@ -147,22 +147,24 @@ export function useEstimateHouseAnalytics(estimate_id: string | null) {
} }
/** /**
* GET /api/v1/trade-in/location-coef?estimate_id=&radius_m= * GET /api/v1/trade-in/location-index?estimate_id=&radius_m=
* POI-based location coefficient for the estimate's target address (#2045 BE-3 * Location index for the estimate's target address replaces the broken
* backend, #2317 FE wiring LocationDrawer + HeroBar «КОЭФ. ЛОКАЦИИ»). coef is * location-coef (backend rewrite, see app/services/location_index.py):
* an MVP heuristic in [0.95, 1.05], NOT calibrated on real price deltas. * % deviation of the local median /м² (comparable active listings near the
* geo_source="unavailable" is a legitimate graceful-fallback response (local POI * address) from the citywide median /м², NOT a price multiplier and NOT fed
* mirror empty/stale on this environment, or the estimate has no lat/lon) * into the estimate. status="out_of_coverage"/"insufficient_data" are honest
* ./v2/mappers.ts renders it as an honest "—", never a fabricated coefficient. * graceful-fallback responses (address outside Yekaterinburg / too few
* comparables) ./v2/mappers.ts renders each distinctly, never a fabricated
* number.
*/ */
export function useLocationCoef(estimate_id: string | null, radius_m?: number) { export function useLocationIndex(estimate_id: string | null, radius_m?: number) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (estimate_id) params.set("estimate_id", estimate_id); if (estimate_id) params.set("estimate_id", estimate_id);
if (radius_m != null) params.set("radius_m", String(radius_m)); if (radius_m != null) params.set("radius_m", String(radius_m));
return useQuery<LocationCoefResponse>({ return useQuery<LocationIndexResponse>({
queryKey: ["trade-in", "location-coef", estimate_id, radius_m ?? null], queryKey: ["trade-in", "location-index", estimate_id, radius_m ?? null],
queryFn: () => queryFn: () =>
apiFetch<LocationCoefResponse>(`${BASE}/location-coef?${params}`), apiFetch<LocationIndexResponse>(`${BASE}/location-index?${params}`),
enabled: estimate_id !== null && estimate_id.length > 0, enabled: estimate_id !== null && estimate_id.length > 0,
staleTime: 10 * 60_000, staleTime: 10 * 60_000,
}); });

View file

@ -470,27 +470,41 @@ export interface TradeInLeadResponse {
status: string; // 'received' status: string; // 'received'
} }
// ── Location coefficient (endpoint: GET /trade-in/location-coef?estimate_id=&radius_m=) ── // ── Location index (endpoint: GET /trade-in/location-index?estimate_id=&radius_m=) ──
// #2045 BE-3 (backend) / #2317 (this FE wiring) — LocationDrawer + HeroBar «КОЭФ. // Replaces the broken location-coef (#2045 audit — see
// ЛОКАЦИИ». coef is an MVP heuristic (see backend app/services/location_coef.py // backend/app/services/location_index.py for the full history: the old
// ::_score_to_coef) — NOT calibrated on real price deltas, range [0.95, 1.05]. // `coef = 0.95 + score/100*0.10` was clamped to ±5%, uncorrelated with real
// result_price_rub = round(base_price_rub * coef). // prices, and never actually fed the estimate). location_index_pct is the %
// deviation of the local median ₽/м² (comparable active listings near the
// address) from the citywide median ₽/м² — a real, uncalibrated-range
// comparison metric, NOT a price multiplier. It does NOT feed the estimate
// (analogs already carry location in the base price; folding this in again
// would double-count the same effect).
// //
// geo_source="unavailable" is a legitimate graceful-fallback response (the local // status:
// osm_poi_ekb_local mirror is empty/stale on this environment, OR the estimate // "ok" — location_index_pct/local_median_price_per_m2 reliable.
// has no lat/lon) — coef=1.0 and factors=[] in that case, never fabricated. // "out_of_coverage" — address outside the product's geo coverage
// "osm_poi_ekb" is the normal/real-data source. // (Yekaterinburg only). All numeric fields null — an honest dash, not 0%.
export interface LocationCoefFactor { // "insufficient_data" — even at the widest search radius there are too few
// comparable active listings. Numeric fields null; sample_size/radius_m
// still report what was actually found.
//
// poi_status is independent of status above ("что рядом" and the numeric
// index degrade separately): "ok" | "unavailable" (local OSM POI mirror
// empty/not yet refreshed on this environment — never fabricated points).
export interface NearbyPoi {
poi_type: string; // OSM POI category, e.g. "school" / "metro_stop" / "kindergarten" poi_type: string; // OSM POI category, e.g. "school" / "metro_stop" / "kindergarten"
name: string | null; // POI name if known name: string | null; // POI name if known
distance_m: number; distance_m: number;
weight: number; // internal score contribution — NOT a per-POI price delta
} }
export interface LocationCoefResponse { export interface LocationIndexResponse {
coef: number; status: "ok" | "out_of_coverage" | "insufficient_data";
factors: LocationCoefFactor[]; location_index_pct: number | null;
geo_source: string; // "osm_poi_ekb" | "unavailable" local_median_price_per_m2: number | null;
base_price_rub: number; city_median_price_per_m2: number | null;
result_price_rub: number; sample_size: number;
radius_m: number;
nearby_poi: NearbyPoi[];
poi_status: "ok" | "unavailable";
} }