diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index e3d7ba06..28fed31f 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -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 diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx index 8b0ea3d5..8a995065 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx @@ -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 && } - {active === 2 && } + {active === 2 && } {active === 3 && ( )} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx new file mode 100644 index 00000000..34e54e66 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx @@ -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 { + 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(`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 = { + 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 === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : c === '"' ? """ : "'", + ); +} + +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(); + 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 `
+
${esc(deglueAddr(l.address || "—"))}
+
${esc(sourceLabel(l.source))} · ${esc(adMeta(l))}
+
${numRu(l.price_per_m2)} ₽/м² · ${numRu(l.price_rub)} ₽
+
${esc(fmtDate(l.listing_date) + exposure + dist)}
+ ${ + href + ? `Источник ↗` + : "" + } +
`; +} + +function dealPopupHtml(l: AnalogLot, stackCount: number): string { + const tier = l.tier ? tierLabel(l.tier) : ""; + const stackNote = + stackCount > 1 + ? `
Ещё ${stackCount - 1} ${esc( + pluralRu(stackCount - 1, ["сделка", "сделки", "сделок"]), + )} на этой точке — геопривязка по улице/кварталу, не по дому
` + : ""; + return `
+
${esc(deglueAddr(l.address || "—"))}
+
Сделка · ${esc(sourceLabel(l.source))}${ + tier ? ` · ${esc(tier)}` : "" + }
+
${numRu(l.price_per_m2)} ₽/м² · ${numRu(l.price_rub)} ₽
+
${esc(dealMeta(l))}
+
Период сделки: ${esc( + fmtMonthYear(l.listing_date), + )} (квартал, Росреестр)
+ ${stackNote} +
`; +} + +function targetPopupHtml(estimate: AggregatedEstimate): string { + return `
+
Ваша квартира
+
${esc(estimate.target_address ?? "—")}
+
`; +} + +/** Ромбовидная divIcon для сделки + бейдж «×N» при стеке в одной точке. */ +function dealDivIcon(L: any, count: number): any { + const size = Math.min(28, 14 + count * 2); + const badge = + count > 1 + ? `
${count}
` + : ""; + const html = `
+
+ ${badge} +
`; + 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(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(); + 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 ( +
+
+
+
РЫНОК · КАРТА
+
Аналоги и сделки на карте
+
+
+ Объявлений: {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")