+
+
+
РЫНОК · КАРТА
+
Аналоги и сделки на карте
+
+
+ Объявлений: {analogPoints.length}
+ {dealPoints.length > 0 && (
+ <>
+ {" · "}сделок: {dealPoints.length}
+ >
+ )}
+
+
+
+ {mapError ? (
+
+ Не удалось загрузить карту. Проверьте интернет-соединение.
+
+ ) : (
+
+ )}
+
+
+ {presentSources.map((s) => (
+
+
+ {sourceLabel(s)}
+
+ ))}
+ {dealPoints.length > 0 && (
+
+
+ Сделки (Росреестр)
+
+ )}
+ {hasTarget && (
+
+
+ Ваша квартира
+
+ )}
+
+
+
+ Сделки Росреестра геокодированы по улице/кадастральному кварталу, а не по
+ конкретному дому — совпадающие точки визуально разнесены и подписаны
+ «×N». Клик по пину — детали.
+
+
+ );
+}
+
+export default SourcesMap;
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx
index ca99103e..25193150 100644
--- a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx
@@ -2,9 +2,11 @@ import { Fragment } from "react";
import type { CSSProperties, ReactNode } from "react";
import { safeUrl } from "@/lib/safeUrl";
+import type { AggregatedEstimate } from "@/types/trade-in";
import { tokens } from "./tokens";
import { adRows, dealRows, marketAds, marketDeals } from "./fixtures";
import type { AdRowData, DealRowData, SourcesData } from "./mappers";
+import { SourcesMap } from "./SourcesMap";
// Overlay 05 — РЫНОК · АНАЛОГИ И СДЕЛКИ.
// Faithful markup port from "МЕРА Оценка.dc.html" (lines 492-537).
@@ -447,10 +449,16 @@ const SOURCES_FIXTURE: SourcesData = {
interface SourcesViewProps {
data?: SourcesData;
+ // Raw estimate (real lat/lon per lot) — the table rows above are pre-mapped
+ // display strings with no geometry, so SourcesMap needs the source object
+ // directly. Optional: unwired/storybook usage (no estimate) renders the
+ // tables only, same honest-empty contract as the rest of the mappers.
+ estimate?: AggregatedEstimate | null;
}
export default function SourcesView({
data = SOURCES_FIXTURE,
+ estimate = null,
}: SourcesViewProps) {
const adCols = visibleCols(AD_COLS, data.adRows);
const adGrid = adCols.map((c) => c.fr).join(" ");
@@ -466,6 +474,8 @@ export default function SourcesView({
.sv-rr:hover { background: ${BADGE_BG}; border-color: ${tokens.accent}; }
`}
+
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts
index b2afe279..ec4141fa 100644
--- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts
@@ -199,7 +199,7 @@ const RU_MONTHS = [
"дек",
];
/** ISO -> "янв 2026" (Росреестр deal dates are month-granular; the day is fake). null/invalid -> "—". */
-function fmtMonthYear(iso: string | null | undefined): string {
+export function fmtMonthYear(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
@@ -1180,7 +1180,7 @@ export function mapSummary(
// ── Overlay format helpers ──────────────────────────────────────────────────
/** Grouped integer, ru, no unit. 161351 -> "161 351". null/NaN -> "—". */
-function numRu(v: number | null | undefined): string {
+export function numRu(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return "—";
return Math.round(v).toLocaleString("ru-RU");
}
@@ -1245,7 +1245,7 @@ function bargainSigned(pct: number | null | undefined): string {
}
/** Distance label: <1km -> "850 м", else "1.2 км". null/NaN -> "—". */
-function fmtDist(m: number | null | undefined): string {
+export function fmtDist(m: number | null | undefined): string {
if (m == null || !Number.isFinite(m)) return "—";
if (m < 1000) return `${Math.round(m)}${NBSP}м`;
return `${(m / 1000).toLocaleString("ru-RU", {
@@ -1258,13 +1258,13 @@ function fmtDist(m: number | null | undefined): string {
// single source-registry roster (#2211) instead of a local hardcoded map — an
// unknown/inactive id (e.g. historical 'n1' rows, #2204) echoes back verbatim
// via the registry's own fallback rather than inventing a label.
-function sourceLabel(s: string | null | undefined): string {
+export function sourceLabel(s: string | null | undefined): string {
if (!s) return "—";
return registrySourceLabel(s.toLowerCase());
}
/** Rosreestr deal tier -> RU caption next to the badge. */
-function tierLabel(t: string | null | undefined): string {
+export function tierLabel(t: string | null | undefined): string {
if (!t) return "";
if (t.includes("house")) return "по дому";
if (t.includes("street")) return "по улице";
@@ -1292,7 +1292,7 @@ function composeLot(
}
/** "32.5 м² · 1-к · этаж 12/15" for an analog ad row. */
-function adMeta(l: AnalogLot): string {
+export function adMeta(l: AnalogLot): string {
const parts: string[] = [];
if (Number.isFinite(l.area_m2)) parts.push(fmtArea(l.area_m2));
parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}-к`);
@@ -1307,7 +1307,7 @@ function adMeta(l: AnalogLot): string {
}
/** "38.1 м² · 1-к" for a deal row (no floor in the design). */
-function dealMeta(l: AnalogLot): string {
+export function dealMeta(l: AnalogLot): string {
const parts: string[] = [];
if (Number.isFinite(l.area_m2)) parts.push(fmtArea(l.area_m2));
parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}-к`);
@@ -1326,7 +1326,7 @@ function dealMeta(l: AnalogLot): string {
* capital is preceded by ".", not a glue char). Pure display cleanup; never
* touches the comma-splitting parseAddress.
*/
-function deglueAddr(addr: string): string {
+export function deglueAddr(addr: string): string {
return (
addr
.replace(/([а-яёa-z\d])([А-ЯЁ][а-яё])/g, "$1 $2")