gendesign/frontend/src/components/site-finder/MarketLayers.tsx
bot-backend 663f04b777
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m58s
Deploy / deploy (push) Successful in 1m22s
fix(site-finder): попап конкурента — NUMERIC-поля приходят строкой (цена/площадь не рендерились) (#2114)
2026-06-30 14:01:00 +00: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>
)}
</>
);
}