feat(tradein/v2): карта вторички — сделки Росреестра + объявления по источникам (SourcesView) (#2478)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m3s
Deploy Trade-In / deploy (push) Successful in 45s

This commit is contained in:
bot-backend 2026-07-12 13:26:55 +00:00
parent ed504cbbd9
commit 8dd35c01d8
5 changed files with 540 additions and 9 deletions

View file

@ -974,6 +974,7 @@ export default function TradeInV2Page() {
analytics={analyticsViewData}
sources={sourcesData}
cache={cacheData}
estimate={estimate}
onSelectEstimate={(id) => {
// #2420 — same ?id= restore-by-id shape as handleSubmit/handleNew
// above (line ~649); this file uses replace() consistently for

View file

@ -16,6 +16,7 @@ import AnalyticsView from "./AnalyticsView";
import { CacheView } from "./CacheView";
import type { CacheData, HistoryData, SourcesData } from "./mappers";
import type { Analytics } from "./types";
import type { AggregatedEstimate } from "@/types/trade-in";
interface SectionOverlayProps {
active: number;
@ -28,6 +29,11 @@ interface SectionOverlayProps {
sources?: SourcesData;
analytics?: Analytics;
cache?: CacheData;
// Raw current estimate — threaded straight to SourcesView's SourcesMap
// (real per-lot lat/lon; sources/SourcesData above is pre-mapped display
// strings with no geometry). Optional/null: no estimate yet -> map degrades
// to its own honest-empty (renders nothing, tables still show).
estimate?: AggregatedEstimate | null;
// #2420 — «ПРЕДЫДУЩИЕ ОЦЕНКИ» row click: navigate to the full report for
// that historical estimate. Optional (threaded straight to CacheView's own
// optional onSelectRow — unwired usage leaves rows inert).
@ -47,6 +53,7 @@ export default function SectionOverlay({
sources,
analytics,
cache,
estimate = null,
onSelectEstimate,
}: SectionOverlayProps) {
// Non-modal dialog plumbing. `dialogRef` is the dialog root, `lastFocused`
@ -317,7 +324,7 @@ export default function SectionOverlay({
}}
>
{active === 1 && <HistoryView data={history} />}
{active === 2 && <SourcesView data={sources} />}
{active === 2 && <SourcesView data={sources} estimate={estimate} />}
{active === 3 && (
<AnalyticsView data={analytics} onNavigate={onNavigate} />
)}

View file

@ -0,0 +1,513 @@
"use client";
/**
* SourcesMap overlay "05 АНАЛОГИЧНЫЕ ОБЪЯВЛЕНИЯ" (SourcesView): real OSM
* basemap plotting every analog listing (estimate.analogs) + Rosreestr ДКП
* deal (estimate.actual_deals) that carries lat/lon, coloured per listing
* source with a legend, target-property pin + analog-radius circle.
*
* PORTS the Leaflet-CDN loader pattern from ../MapCard.tsx (the v1
* app/page.tsx tree, dead in prod 307-redirected to /v2) copied rather
* than imported so v2 stays a self-contained port with no runtime dependency
* on the legacy tree, and no npm Leaflet dep (same as MapCard).
*
* Deal honesty: deals are geocoded to STREET CENTROIDS (backend
* scripts/geocode_deals_from_houses.py) several deals on the same street
* can collapse onto ONE point. Coincident points are grouped and fanned out
* with a small deterministic jitter + a "×N" badge instead of silently
* overlapping, and a visible caption states the approximation.
*/
/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet (см. MapCard.tsx) */
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in";
import { safeUrl } from "@/lib/safeUrl";
import { tokens } from "./tokens";
import {
adMeta,
dealMeta,
deglueAddr,
fmtDate,
fmtDist,
fmtMonthYear,
numRu,
pluralRu,
sourceLabel,
tierLabel,
} from "./mappers";
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 MapCard.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);
});
}
// ── Per-source categorical palette ──────────────────────────────────────────
// dataviz-skill categorical order (fixed, never cycled), validated against the
// v2 card surface #eaf1f8: worst-adjacent CVD ΔE 24.2 (PASS); the aqua/yellow
// slots (cian/yandex) dip below 3:1 text-contrast, so they are NEVER
// colour-alone — every legend entry and popup keeps its RU label alongside
// the dot. Leaflet needs raw hex strings, not CSS vars (see MapCard.tsx
// comment) — kept literal, same as the rest of this map's Leaflet colours.
const SOURCE_COLORS: Record<string, string> = {
avito: "#2a78d6",
cian: "#1baf7a",
yandex: "#eda100",
domklik: "#008300",
n1: "#4a3aa7",
};
const FALLBACK_SOURCE_COLOR = "#6b7280"; // неизвестный/прочий листинговый источник
const COLOR_TARGET = "#f59e0b"; // та же семантика, что и MapCard.COLOR_TARGET
// Сделки не соревнуются за 6-й категориальный оттенок — они отличаются ФОРМОЙ
// (ромб) и нейтральным тёмным цветом, а не ещё одним hue из палитры источников.
const COLOR_DEAL = tokens.ink;
const DOT_STROKE = "#fff";
function colorForSource(source: string | null | undefined): string {
if (!source) return FALLBACK_SOURCE_COLOR;
return SOURCE_COLORS[source.toLowerCase()] ?? FALLBACK_SOURCE_COLOR;
}
/** Безопасное экранирование для вставки в HTML popup (Leaflet вставляет как raw HTML). */
function esc(s: string): string {
return s.replace(/[&<>"']/g, (c) =>
c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : c === '"' ? "&quot;" : "&#39;",
);
}
interface GeoLot {
lot: AnalogLot;
lat: number;
lon: number;
}
function withCoords(lots: AnalogLot[]): GeoLot[] {
return lots
.filter(
(l): l is AnalogLot & { lat: number; lon: number } =>
typeof l.lat === "number" && typeof l.lon === "number",
)
.map((l) => ({ lot: l, lat: l.lat, lon: l.lon }));
}
/** Группирует точки с ИДЕНТИЧНЫМИ координатами (street-centroid stacking). */
function groupByCoord(points: GeoLot[]): GeoLot[][] {
const groups = new Map<string, GeoLot[]>();
for (const p of points) {
const key = `${p.lat.toFixed(6)},${p.lon.toFixed(6)}`;
const arr = groups.get(key);
if (arr) arr.push(p);
else groups.set(key, [p]);
}
return Array.from(groups.values());
}
// Небольшой детерминированный jitter — чисто визуальный (карта и так подписана
// как street-centroid approximation, честность не меняется). ~0.00018° на
// широте Екатеринбурга (~56.8°) ≈ 12-15 м; поправка на cos(lat) держит разлёт
// круглым, а не сплюснутым по долготе.
const JITTER_DEG = 0.00018;
function jitter(
lat: number,
lon: number,
index: number,
count: number,
): [number, number] {
if (count <= 1) return [lat, lon];
const angle = (index / count) * 2 * Math.PI;
const r = JITTER_DEG * (1 + Math.min(count, 8) * 0.15);
const latRad = (lat * Math.PI) / 180;
return [
lat + r * Math.sin(angle),
lon + (r * Math.cos(angle)) / Math.max(0.2, Math.cos(latRad)),
];
}
function analogPopupHtml(l: AnalogLot): string {
const href = safeUrl(l.source_url);
const exposure =
l.days_on_market != null ? ` · ${l.days_on_market} дн. на рынке` : "";
const dist = l.distance_m != null ? ` · ${fmtDist(l.distance_m)} от квартиры` : "";
return `<div style="font-family:${tokens.font.sans};font-size:12px;line-height:1.55;min-width:190px">
<div style="font-weight:600;color:${tokens.ink2}">${esc(deglueAddr(l.address || "—"))}</div>
<div style="color:${tokens.muted2};margin:2px 0 6px">${esc(sourceLabel(l.source))} · ${esc(adMeta(l))}</div>
<div style="color:${tokens.ink2}">${numRu(l.price_per_m2)} /м² · <b>${numRu(l.price_rub)} </b></div>
<div style="color:${tokens.muted};margin-top:4px">${esc(fmtDate(l.listing_date) + exposure + dist)}</div>
${
href
? `<a href="${esc(href)}" target="_blank" rel="noopener noreferrer" style="display:inline-block;margin-top:8px;font-size:10.5px;color:${tokens.accent};border:1px solid ${tokens.line2};border-radius:4px;padding:3px 8px;text-decoration:none">Источник ↗</a>`
: ""
}
</div>`;
}
function dealPopupHtml(l: AnalogLot, stackCount: number): string {
const tier = l.tier ? tierLabel(l.tier) : "";
const stackNote =
stackCount > 1
? `<div style="margin-top:6px;color:${tokens.warn};font-size:10.5px">Ещё ${stackCount - 1} ${esc(
pluralRu(stackCount - 1, ["сделка", "сделки", "сделок"]),
)} на этой точке геопривязка по улице/кварталу, не по дому</div>`
: "";
return `<div style="font-family:${tokens.font.sans};font-size:12px;line-height:1.55;min-width:190px">
<div style="font-weight:600;color:${tokens.ink2}">${esc(deglueAddr(l.address || "—"))}</div>
<div style="color:${tokens.muted2};margin:2px 0 6px">Сделка · ${esc(sourceLabel(l.source))}${
tier ? ` · ${esc(tier)}` : ""
}</div>
<div style="color:${tokens.ink2}">${numRu(l.price_per_m2)} /м² · <b>${numRu(l.price_rub)} </b></div>
<div style="color:${tokens.muted}">${esc(dealMeta(l))}</div>
<div style="color:${tokens.muted};margin-top:4px">Период сделки: ${esc(
fmtMonthYear(l.listing_date),
)} (квартал, Росреестр)</div>
${stackNote}
</div>`;
}
function targetPopupHtml(estimate: AggregatedEstimate): string {
return `<div style="font-family:${tokens.font.sans};font-size:12px">
<div style="font-weight:600;color:${tokens.ink2}">Ваша квартира</div>
<div style="color:${tokens.muted};margin-top:2px">${esc(estimate.target_address ?? "—")}</div>
</div>`;
}
/** Ромбовидная divIcon для сделки + бейдж «×N» при стеке в одной точке. */
function dealDivIcon(L: any, count: number): any {
const size = Math.min(28, 14 + count * 2);
const badge =
count > 1
? `<div style="position:absolute;top:-7px;right:-7px;background:${tokens.ink2};color:#fff;font-family:${tokens.font.mono};font-size:9px;line-height:14px;min-width:14px;height:14px;border-radius:7px;text-align:center;padding:0 3px;box-shadow:0 0 0 1px #fff">${count}</div>`
: "";
const html = `<div style="position:relative;width:${size}px;height:${size}px">
<div style="position:absolute;inset:3px;background:${COLOR_DEAL};border:2px solid ${DOT_STROKE};border-radius:2px;transform:rotate(45deg);box-shadow:0 1px 2px rgba(0,0,0,.3)"></div>
${badge}
</div>`;
return L.divIcon({ html, className: "", iconSize: [size, size], iconAnchor: [size / 2, size / 2] });
}
// ── Card chrome — mirrors SourcesView's local style consts (v2 has no shared
// card component; each view file owns its own, see AnalyticsView/LeadForm). ──
const card: CSSProperties = {
background: tokens.surface.w55,
border: `1px solid ${tokens.line2}`,
borderRadius: 8,
overflow: "hidden",
};
const cardHeader: CSSProperties = {
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
padding: "14px 18px",
borderBottom: `1px solid ${tokens.lineSoft}`,
};
const eyebrow: CSSProperties = {
fontSize: "8.5px",
letterSpacing: "2px",
color: tokens.muted2,
marginBottom: 5,
};
const cardTitle: CSSProperties = {
fontSize: "12px",
fontWeight: 600,
color: tokens.ink2,
};
const headerRight: CSSProperties = {
textAlign: "right",
fontSize: "9.5px",
color: tokens.muted2,
lineHeight: 1.6,
};
const legendRow: CSSProperties = {
display: "flex",
flexWrap: "wrap",
gap: 14,
alignItems: "center",
padding: "10px 18px",
borderTop: `1px solid ${tokens.lineSoft3}`,
fontSize: "10px",
color: tokens.body2,
};
const legendItem: CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
};
const legendDot: CSSProperties = {
width: 9,
height: 9,
borderRadius: "50%",
display: "inline-block",
boxShadow: "0 0 0 1px #fff, 0 0 0 1.5px rgba(0,0,0,.15)",
};
const legendDiamond: CSSProperties = {
width: 8,
height: 8,
background: COLOR_DEAL,
border: "1.5px solid #fff",
boxShadow: `0 0 0 1px ${tokens.line2}`,
transform: "rotate(45deg)",
display: "inline-block",
};
const honestyFoot: CSSProperties = {
padding: "10px 18px",
fontSize: "9.5px",
color: tokens.hint,
background: "rgba(238,244,250,.5)", // тот же one-off tint, что и SourcesView KPI_BG
borderTop: `1px solid ${tokens.lineSoft3}`,
};
interface Props {
estimate: AggregatedEstimate | null | undefined;
}
export function SourcesMap({ estimate }: Props) {
const mapRef = useRef<HTMLDivElement>(null);
const [mapError, setMapError] = useState(false);
const analogPoints = useMemo(
() => (estimate ? withCoords(estimate.analogs) : []),
[estimate],
);
const dealPoints = useMemo(
() => (estimate ? withCoords(estimate.actual_deals) : []),
[estimate],
);
const hasTarget =
!!estimate &&
typeof estimate.target_lat === "number" &&
typeof estimate.target_lon === "number";
// Радиус круга = тот же максимум distance_m по аналогам, что уже заявлен в
// фильтр-чипе таблицы объявлений выше в этом overlay ("Расстояние ≤ N км",
// mappers.ts buildAdFilters) — карта не должна утверждать другой радиус.
const radiusM = useMemo(() => {
const ds = analogPoints
.map((p) => p.lot.distance_m)
.filter((d): d is number => d != null && Number.isFinite(d));
return ds.length ? Math.max(...ds) : null;
}, [analogPoints]);
const presentSources = useMemo(() => {
const set = new Set<string>();
for (const p of analogPoints) if (p.lot.source) set.add(p.lot.source.toLowerCase());
return Array.from(set);
}, [analogPoints]);
const totalPins = analogPoints.length + dealPoints.length + (hasTarget ? 1 : 0);
useEffect(() => {
if (!estimate || totalPins === 0) return;
let map: any = null;
let cancelled = false;
loadLeaflet()
.then((L) => {
if (cancelled || !mapRef.current) return;
const firstPin = analogPoints[0] ?? dealPoints[0];
let center: [number, number] | null = null;
if (hasTarget) {
center = [estimate.target_lat as number, estimate.target_lon as number];
} else if (firstPin) {
center = [firstPin.lat, firstPin.lon];
}
if (!center) return; // guarded by totalPins>0, но удовлетворяет TS
// scrollWheelZoom:false — карта встроена в скроллящуюся панель overlay
// (SectionOverlay), захват колеса мыши иначе крадёт скролл панели.
map = L.map(mapRef.current, { scrollWheelZoom: false }).setView(center, 14);
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "© OpenStreetMap",
maxZoom: 19,
}).addTo(map);
setTimeout(() => map && map.invalidateSize(), 120);
const bounds: [number, number][] = [];
// Объявления — цвет по источнику, стек в одной точке — jitter по кругу.
for (const group of groupByCoord(analogPoints)) {
group.forEach((p, i) => {
const [lat, lon] = jitter(p.lat, p.lon, i, group.length);
bounds.push([lat, lon]);
L.circleMarker([lat, lon], {
radius: 7,
color: DOT_STROKE,
weight: 2,
fillColor: colorForSource(p.lot.source),
fillOpacity: 0.9,
})
.addTo(map)
.bindPopup(analogPopupHtml(p.lot));
});
}
// ДКП-сделки — ромб, нейтральный цвет, size+badge по числу в стеке.
for (const group of groupByCoord(dealPoints)) {
group.forEach((p, i) => {
const [lat, lon] = jitter(p.lat, p.lon, i, group.length);
bounds.push([lat, lon]);
L.marker([lat, lon], { icon: dealDivIcon(L, group.length) })
.addTo(map)
.bindPopup(dealPopupHtml(p.lot, group.length));
});
}
// Целевая квартира — крупный янтарный маркер + радиус-круг (та же
// семантика/паттерн, что и MapCard.tsx).
if (hasTarget) {
const ll: [number, number] = [
estimate.target_lat as number,
estimate.target_lon as number,
];
bounds.push(ll);
if (radiusM != null && radiusM > 0) {
L.circle(ll, {
radius: radiusM,
color: COLOR_TARGET,
weight: 1,
fillColor: COLOR_TARGET,
fillOpacity: 0.05,
// interactive:false — декоративный круг не должен перехватывать
// клики по пинам под ним (см. MapCard.tsx / issue #786).
interactive: false,
}).addTo(map);
}
L.circleMarker(ll, {
radius: 9,
color: COLOR_TARGET,
weight: 3,
fillColor: "#fff",
fillOpacity: 1,
})
.addTo(map)
.bindPopup(targetPopupHtml(estimate));
}
if (bounds.length > 1) {
map.fitBounds(bounds, { padding: [30, 30], maxZoom: 16 });
}
})
.catch(() => setMapError(true));
return () => {
cancelled = true;
if (map) map.remove();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- пересоздаём карту только при смене оценки/состава пинов, не на каждый ре-рендер
}, [estimate?.estimate_id, totalPins]);
// Честный empty state: нет оценки или нет геоточек вообще — карточку не
// показываем (то же поведение, что и MapCard.tsx), таблицы ниже остаются.
if (!estimate || totalPins === 0) return null;
return (
<article style={card}>
<div style={cardHeader}>
<div>
<div style={eyebrow}>РЫНОК · КАРТА</div>
<div style={cardTitle}>Аналоги и сделки на карте</div>
</div>
<div style={headerRight}>
Объявлений: <b style={{ color: tokens.ink2 }}>{analogPoints.length}</b>
{dealPoints.length > 0 && (
<>
{" · "}сделок: <b style={{ color: tokens.ink2 }}>{dealPoints.length}</b>
</>
)}
</div>
</div>
{mapError ? (
<div
style={{
padding: "24px 18px",
textAlign: "center",
color: tokens.muted,
fontSize: 12,
}}
>
Не удалось загрузить карту. Проверьте интернет-соединение.
</div>
) : (
<div
ref={mapRef}
style={{ height: 400, width: "100%", background: tokens.mapBg }}
aria-label="Карта аналогов и сделок"
/>
)}
<div style={legendRow}>
{presentSources.map((s) => (
<span key={s} style={legendItem}>
<span style={{ ...legendDot, background: colorForSource(s) }} />
{sourceLabel(s)}
</span>
))}
{dealPoints.length > 0 && (
<span style={legendItem}>
<span style={legendDiamond} />
Сделки (Росреестр)
</span>
)}
{hasTarget && (
<span style={legendItem}>
<span style={{ ...legendDot, background: "#fff", boxShadow: `0 0 0 2px ${COLOR_TARGET}` }} />
Ваша квартира
</span>
)}
</div>
<div style={honestyFoot}>
Сделки Росреестра геокодированы по улице/кадастральному кварталу, а не по
конкретному дому совпадающие точки визуально разнесены и подписаны
«×N». Клик по пину детали.
</div>
</article>
);
}
export default SourcesMap;

View file

@ -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}; }
`}</style>
<SourcesMap estimate={estimate} />
{/* объявления */}
<div style={card}>
<div style={cardHeader}>

View file

@ -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")