gendesign/frontend/src/components/site-finder/MarketLayers.tsx
Light1YT 9d75ca4874
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m5s
CI / openapi-codegen-check (pull_request) Successful in 2m3s
fix(site-finder): попап конкурента — NUMERIC-поля приходят строкой, рендерим цену и площадь
Прод-verify #2113 вскрыл: avg_price_per_m2_rub и avg_area_pd приходят с бэкенда
СТРОКОЙ («160345», «47.5») — Pydantic v2 сериализует Decimal (ROUND(::numeric))
в строку. Гварды `typeof === "number"` / `Number.isFinite(str)` молча роняли поля:
цена конкурента вообще не показывалась, а добавленная в #2113 «Ср. площадь кв.»
не рендерилась.

- toFiniteNumber(v) — приводит number|string→number|null.
- formatPrice использует его (цена снова показывается).
- avg_area_pd рендерится через toFiniteNumber.
- Типы avg_price_per_m2_rub/avg_area_pd расширены до number|string|null (честно
  отражает рантайм).

Frontend-only, контракт не меняется.
2026-06-30 18:57:48 +05:00

514 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/**
* MarketLayers — #999 (958-B4): рыночные слои поверх карты Site Finder.
*
* Независимо-переключаемые слои (видимость управляется из SiteMap, как у
* ConnectionPointsLayer):
* 1. Конкуренты — CircleMarker на каждый competitor с координатами.
* 2. Будущие проекты — CircleMarker на каждый pipeline_24mo.top_objects
* (§12.1 future supply; dashed-кольцо, viz-5).
* 3. Зоны риска — GeoJSON-полигоны из RiskZone.geom_wkt (§13).
* 4. Перспективные ЗУ — GeoJSON-полигоны из OpportunityParcel.geom_wkt
* (§12.1: auction/scheme/free/future ЗУ + ООПТ).
* 5. Красные линии — GeoJSON-линии из RedLine.geom_wkt (§13 граддок).
*
* Цвета — token-hex (документированное исключение карты/чартов, ui-tokens.md):
* Конкуренты → --viz-2 (#0ea5e9)
* Будущие проекты → --viz-5 (#8b5cf6)
* Зоны риска → --danger (#b3261e)
* Перспективные ЗУ → --viz-3 (#14b8a6) — «возможность», отлично от риска
* Красные линии → --warn (#9a6700) — граддок-ограничение, не риск-зона
*
* future-ЖК (newbuilding_listings) и полноценные граддок-полигоны
* (planning_projects / ППТ-ПМТ) — ВНЕ scope: эти объекты пока НЕ отдаются в
* /analyze payload. Слои добавятся после exposure соответствующих API.
*
* Graceful: объекты без координат пропускаются; пустые массивы → пустой слой
* (без падения); невалидный/неподдержанный WKT → фича не рисуется.
*/
import { CircleMarker, GeoJSON, LayerGroup, Popup } from "react-leaflet";
import type { Feature } from "geojson";
import { wktToGeometry } from "@/lib/wkt";
import type {
ParcelAnalysisCompetitor,
PipelineObject,
} from "@/types/site-finder";
import type { OpportunityParcel, RedLine, RiskZone } from "@/types/nspd";
// ---------------------------------------------------------------------------
// Market colors — token-hex (map/chart exception per ui-tokens.md)
// ---------------------------------------------------------------------------
export const MARKET_COLORS = {
competitor: "#0ea5e9", // --viz-2
pipeline: "#8b5cf6", // --viz-5
risk: "#b3261e", // --danger
opportunity: "#14b8a6", // --viz-3 (перспективные ЗУ — «возможность»)
redline: "#9a6700", // --warn (красные линии застройки — граддок)
} as const;
// ---------------------------------------------------------------------------
// RU labels for opportunity-parcel layers (NSPD `layer` codes → человекочитаемо)
// ---------------------------------------------------------------------------
const OPPORTUNITY_LAYER_LABELS: Record<string, string> = {
auction_parcels: "ЗУ на торгах",
scheme_parcels: "ЗУ по схеме размещения",
free_parcels: "Свободный ЗУ",
future_parcels: "Перспективный ЗУ",
oopt: "ООПТ",
};
function opportunityLabel(layer: string): string {
return OPPORTUNITY_LAYER_LABELS[layer] ?? "Перспективный ЗУ";
}
// ---------------------------------------------------------------------------
// Formatting helpers (RU)
// ---------------------------------------------------------------------------
function formatDistance(m: number | null | undefined): string | null {
if (m === null || m === undefined || !Number.isFinite(m)) return null;
return `${Math.round(m).toLocaleString("ru-RU")} м`;
}
// Бэкенд отдаёт NUMERIC-поля (avg_price_per_m2_rub, avg_area_pd) как СТРОКИ
// («160345», «47.5») — Pydantic v2 сериализует Decimal в строку. Приводим к числу,
// иначе typeof/Number.isFinite-гварды молча роняют поле (попап не показывал цену).
function toFiniteNumber(v: number | string | null | undefined): number | null {
if (v === null || v === undefined) return null;
const n = typeof v === "string" ? Number(v) : v;
return Number.isFinite(n) ? n : null;
}
function formatPrice(rub: number | string | null | undefined): string | null {
const n = toFiniteNumber(rub);
if (n === null) return null;
return `${Math.round(n).toLocaleString("ru-RU")} ₽/м²`;
}
function formatReadyDt(iso: string | null | undefined): string | null {
if (!iso) return null;
// Бэкенд отдаёт ISO date (YYYY-MM-DD) — показываем как есть, без таймзонных
// сюрпризов от Date(). Если формат иной — fallback на сырое значение.
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
if (!m) return iso;
return `${m[3]}.${m[2]}.${m[1]}`;
}
// Generic guard — сохраняет исходный тип объекта, сужая lat/lon к number
// (иначе .filter() «съел» бы comm_name/obj_class и т.п.).
function hasCoords<T extends { lat?: number | null; lon?: number | null }>(
o: T,
): o is T & { lat: number; lon: number } {
return (
typeof o.lat === "number" &&
typeof o.lon === "number" &&
Number.isFinite(o.lat) &&
Number.isFinite(o.lon) &&
// (0,0) — Атлантика: на наших данных = «нет координат», пропускаем.
!(o.lat === 0 && o.lon === 0)
);
}
// ---------------------------------------------------------------------------
// Layer styles
// ---------------------------------------------------------------------------
const COMPETITOR_RADIUS = 7;
const PIPELINE_RADIUS = 7;
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
export interface MarketLayersProps {
competitors: ParcelAnalysisCompetitor[];
pipelineObjects: PipelineObject[];
riskZones: RiskZone[];
// §12.1-13 (#958) — opportunity-ЗУ + красные линии (geom_wkt в /analyze).
opportunityParcels: OpportunityParcel[];
redLines: RedLine[];
showCompetitors: boolean;
showPipeline: boolean;
showRiskZones: boolean;
showOpportunity: boolean;
showRedLines: boolean;
}
export function MarketLayers({
competitors,
pipelineObjects,
riskZones,
opportunityParcels,
redLines,
showCompetitors,
showPipeline,
showRiskZones,
showOpportunity,
showRedLines,
}: MarketLayersProps) {
return (
<>
{/* Зоны риска — рисуем ПЕРВЫМИ (под точками), как заливку-фон. */}
{showRiskZones && (
<LayerGroup>
{riskZones.map((zone, idx) => {
if (!zone.geom_wkt) return null;
const geometry = wktToGeometry(zone.geom_wkt);
if (!geometry) return null;
const feature: Feature = {
type: "Feature",
geometry,
properties: {},
};
const areaStr =
zone.intersection_area_sqm != null
? `${Math.round(zone.intersection_area_sqm).toLocaleString("ru-RU")} м²`
: null;
return (
<GeoJSON
// geom_wkt в key — react-leaflet GeoJSON кэширует слой и не
// реагирует на смену data prop; хеш форсирует remount при смене
// участка (тот же приём, что у изохрон в SiteMap).
key={`risk-${idx}-${zone.geom_wkt.slice(0, 24)}`}
data={feature}
style={{
color: MARKET_COLORS.risk,
weight: 1.5,
fillColor: MARKET_COLORS.risk,
fillOpacity: 0.18,
dashArray: "4 3",
}}
>
<Popup>
<div
style={{ fontSize: 12, lineHeight: 1.55, minWidth: 170 }}
>
<div style={{ fontWeight: 700, marginBottom: 4 }}>
Зона риска
</div>
<div style={{ marginBottom: 2 }}>
{zone.subtype ?? zone.layer}
</div>
{areaStr && (
<div style={{ color: "#374151" }}>
Пересечение с участком: <strong>{areaStr}</strong>
</div>
)}
</div>
</Popup>
</GeoJSON>
);
})}
</LayerGroup>
)}
{/* §12.1 — Перспективные ЗУ (opportunity parcels): GeoJSON-полигоны под
точками, заливка viz-3. Сюда входят future_parcels (перспективное
предложение земли), auction/scheme/free ЗУ и ООПТ. */}
{showOpportunity && (
<LayerGroup>
{opportunityParcels.map((parcel, idx) => {
if (!parcel.geom_wkt) return null;
const geometry = wktToGeometry(parcel.geom_wkt);
if (!geometry) return null;
const feature: Feature = {
type: "Feature",
geometry,
properties: {},
};
const distanceStr = formatDistance(parcel.distance_m);
return (
<GeoJSON
key={`opp-${idx}-${parcel.geom_wkt.slice(0, 24)}`}
data={feature}
style={{
color: MARKET_COLORS.opportunity,
weight: 1.5,
fillColor: MARKET_COLORS.opportunity,
fillOpacity: 0.16,
}}
>
<Popup>
<div
style={{ fontSize: 12, lineHeight: 1.55, minWidth: 170 }}
>
<div
style={{
fontWeight: 700,
marginBottom: 4,
display: "flex",
alignItems: "center",
gap: 5,
}}
>
<span
style={{
display: "inline-block",
width: 10,
height: 10,
borderRadius: 2,
background: MARKET_COLORS.opportunity,
flexShrink: 0,
}}
/>
{opportunityLabel(parcel.layer)}
</div>
{parcel.cad_num && (
<div style={{ marginBottom: 2 }}>
<strong>{parcel.cad_num}</strong>
</div>
)}
{distanceStr && (
<div style={{ color: "#374151" }}>{distanceStr}</div>
)}
</div>
</Popup>
</GeoJSON>
);
})}
</LayerGroup>
)}
{/* §13 — Красные линии застройки (граддок): GeoJSON-линии warn-цвета.
geom_wkt — LINESTRING/MULTILINESTRING (wkt.ts парсит оба). */}
{showRedLines && (
<LayerGroup>
{redLines.map((line, idx) => {
if (!line.geom_wkt) return null;
const geometry = wktToGeometry(line.geom_wkt);
if (!geometry) return null;
const feature: Feature = {
type: "Feature",
geometry,
properties: {},
};
// Пересекает участок → длина пересечения; иначе → дистанция nearby.
const intersectStr =
line.intersection_length_m != null
? `${Math.round(line.intersection_length_m).toLocaleString("ru-RU")} м`
: null;
const distanceStr = formatDistance(line.distance_m);
return (
<GeoJSON
key={`redline-${idx}-${line.geom_wkt.slice(0, 24)}`}
data={feature}
style={{
color: MARKET_COLORS.redline,
weight: 3,
// Линии — без заливки; пунктир отличает от границ объектов.
fillOpacity: 0,
dashArray: "6 4",
}}
>
<Popup>
<div
style={{ fontSize: 12, lineHeight: 1.55, minWidth: 170 }}
>
<div
style={{
fontWeight: 700,
marginBottom: 4,
display: "flex",
alignItems: "center",
gap: 5,
}}
>
<span
style={{
display: "inline-block",
width: 14,
height: 0,
borderTop: `3px dashed ${MARKET_COLORS.redline}`,
flexShrink: 0,
}}
/>
Красная линия застройки
</div>
{intersectStr ? (
<div style={{ color: "#374151" }}>
Пересекает участок: <strong>{intersectStr}</strong>
</div>
) : (
distanceStr && (
<div style={{ color: "#374151" }}>
До участка: <strong>{distanceStr}</strong>
</div>
)
)}
</div>
</Popup>
</GeoJSON>
);
})}
</LayerGroup>
)}
{/* Конкуренты — сплошная заливка viz-2. */}
{showCompetitors && (
<LayerGroup>
{competitors.filter(hasCoords).map((c, idx) => {
const distanceStr = formatDistance(c.distance_m);
const priceStr = formatPrice(c.avg_price_per_m2_rub);
const showSales =
typeof c.units_sold === "number" ||
typeof c.units_available === "number";
return (
<CircleMarker
key={`comp-${c.obj_id}-${idx}`}
center={[c.lat, c.lon]}
radius={COMPETITOR_RADIUS}
pathOptions={{
color: MARKET_COLORS.competitor,
fillColor: MARKET_COLORS.competitor,
fillOpacity: 0.85,
weight: 1.5,
}}
>
<Popup>
<div
style={{ fontSize: 12, lineHeight: 1.55, minWidth: 180 }}
>
<div
style={{
fontWeight: 700,
marginBottom: 4,
display: "flex",
alignItems: "center",
gap: 5,
}}
>
<span
style={{
display: "inline-block",
width: 10,
height: 10,
borderRadius: "50%",
background: MARKET_COLORS.competitor,
flexShrink: 0,
}}
/>
Конкурент
</div>
<div style={{ marginBottom: 2 }}>
<strong>{c.comm_name ?? "Без названия"}</strong>
</div>
{c.dev_name && (
<div style={{ color: "#6b7280", marginBottom: 2 }}>
Застройщик: {c.dev_name}
</div>
)}
{c.obj_class && (
<div style={{ color: "#6b7280", marginBottom: 2 }}>
Класс: {c.obj_class}
</div>
)}
{distanceStr && (
<div style={{ color: "#374151" }}>{distanceStr}</div>
)}
{c.site_status && (
<div style={{ color: "#374151" }}>{c.site_status}</div>
)}
{priceStr && (
<div style={{ color: "#374151" }}>{priceStr}</div>
)}
{toFiniteNumber(c.avg_area_pd) !== null && (
<div style={{ color: "#374151" }}>
Ср. площадь кв.: {toFiniteNumber(c.avg_area_pd)} м²
</div>
)}
{showSales && (
<div style={{ color: "#374151" }}>
Продано: {c.units_sold ?? 0}
{typeof c.units_available === "number"
? ` · в продаже: ${c.units_available}`
: ""}
</div>
)}
</div>
</Popup>
</CircleMarker>
);
})}
</LayerGroup>
)}
{/* Будущие проекты (pipeline) — визуально ОТЛИЧНЫ: полое кольцо viz-5
с пунктирной обводкой (не путать с конкурентами). */}
{showPipeline && (
<LayerGroup>
{pipelineObjects.filter(hasCoords).map((p, idx) => {
const distanceStr = formatDistance(p.distance_m);
const readyStr = formatReadyDt(p.ready_dt);
return (
<CircleMarker
key={`pipe-${p.obj_id}-${idx}`}
center={[p.lat, p.lon]}
radius={PIPELINE_RADIUS}
pathOptions={{
color: MARKET_COLORS.pipeline,
fillColor: MARKET_COLORS.pipeline,
fillOpacity: 0.12,
weight: 2.5,
dashArray: "3 2",
}}
>
<Popup>
<div
style={{ fontSize: 12, lineHeight: 1.55, minWidth: 180 }}
>
<div
style={{
fontWeight: 700,
marginBottom: 4,
display: "flex",
alignItems: "center",
gap: 5,
}}
>
<span
style={{
display: "inline-block",
width: 10,
height: 10,
borderRadius: "50%",
border: `2px dashed ${MARKET_COLORS.pipeline}`,
boxSizing: "border-box",
flexShrink: 0,
}}
/>
Будущий проект
</div>
<div style={{ marginBottom: 2 }}>
<strong>{p.comm_name ?? "Без названия"}</strong>
</div>
{p.obj_class && (
<div style={{ color: "#6b7280", marginBottom: 2 }}>
Класс: {p.obj_class}
</div>
)}
{typeof p.flat_count === "number" && (
<div style={{ color: "#374151" }}>
Квартир: {p.flat_count.toLocaleString("ru-RU")}
</div>
)}
{readyStr && (
<div style={{ color: "#374151" }}>Сдача: {readyStr}</div>
)}
{distanceStr && (
<div style={{ color: "#374151" }}>{distanceStr}</div>
)}
</div>
</Popup>
</CircleMarker>
);
})}
</LayerGroup>
)}
</>
);
}