"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 = { 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")} м`; } function formatPrice(rub: number | null | undefined): string | null { if (rub === null || rub === undefined || !Number.isFinite(rub)) return null; return `${Math.round(rub).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( 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 && ( {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 (
Зона риска
{zone.subtype ?? zone.layer}
{areaStr && (
Пересечение с участком: {areaStr}
)}
); })}
)} {/* §12.1 — Перспективные ЗУ (opportunity parcels): GeoJSON-полигоны под точками, заливка viz-3. Сюда входят future_parcels (перспективное предложение земли), auction/scheme/free ЗУ и ООПТ. */} {showOpportunity && ( {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 (
{opportunityLabel(parcel.layer)}
{parcel.cad_num && (
{parcel.cad_num}
)} {distanceStr && (
{distanceStr}
)}
); })}
)} {/* §13 — Красные линии застройки (граддок): GeoJSON-линии warn-цвета. geom_wkt — LINESTRING/MULTILINESTRING (wkt.ts парсит оба). */} {showRedLines && ( {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 (
Красная линия застройки
{intersectStr ? (
Пересекает участок: {intersectStr}
) : ( distanceStr && (
До участка: {distanceStr}
) )}
); })}
)} {/* Конкуренты — сплошная заливка viz-2. */} {showCompetitors && ( {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 (
Конкурент
{c.comm_name ?? "Без названия"}
{c.obj_class && (
Класс: {c.obj_class}
)} {distanceStr && (
{distanceStr}
)} {c.site_status && (
{c.site_status}
)} {priceStr && (
{priceStr}
)} {showSales && (
Продано: {c.units_sold ?? 0} {typeof c.units_available === "number" ? ` · в продаже: ${c.units_available}` : ""}
)}
); })}
)} {/* Будущие проекты (pipeline) — визуально ОТЛИЧНЫ: полое кольцо viz-5 с пунктирной обводкой (не путать с конкурентами). */} {showPipeline && ( {pipelineObjects.filter(hasCoords).map((p, idx) => { const distanceStr = formatDistance(p.distance_m); const readyStr = formatReadyDt(p.ready_dt); return (
Будущий проект
{p.comm_name ?? "Без названия"}
{p.obj_class && (
Класс: {p.obj_class}
)} {typeof p.flat_count === "number" && (
Квартир: {p.flat_count.toLocaleString("ru-RU")}
)} {readyStr && (
Сдача: {readyStr}
)} {distanceStr && (
{distanceStr}
)}
); })}
)} ); }