Compare commits
6 commits
e7272183d4
...
3d36861fb2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d36861fb2 | ||
|
|
ec7fd9c02c | ||
| c9ba1b15ba | |||
|
|
e767661503 | ||
| 06bf8dfada | |||
| e0b63cc637 |
9 changed files with 974 additions and 666 deletions
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 === "&"
|
||||||
|
? "&"
|
||||||
|
: c === "<"
|
||||||
|
? "<"
|
||||||
|
: c === ">"
|
||||||
|
? ">"
|
||||||
|
: c === '"'
|
||||||
|
? """
|
||||||
|
: "'",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пин квартиры-предмета оценки — 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 1–2.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={{
|
||||||
|
|
|
||||||
|
|
@ -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 ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 --------------------------------------------------------
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue