diff --git a/frontend/src/components/site-finder/CpLayerControlPanel.tsx b/frontend/src/components/site-finder/CpLayerControlPanel.tsx index df16d14b..49f12064 100644 --- a/frontend/src/components/site-finder/CpLayerControlPanel.tsx +++ b/frontend/src/components/site-finder/CpLayerControlPanel.tsx @@ -11,13 +11,28 @@ import { CP_CATEGORY_STYLES, type CpCategory, } from "@/components/site-finder/ConnectionPointsLayer"; +import { MARKET_COLORS } from "@/components/site-finder/MarketLayers"; +import type { MarketLayerKey } from "@/components/site-finder/SiteMap"; + +// #999 (958-B4) — конфиг рыночных слоёв для тумблеров. count = число объектов +// с координатами (без них точка не рисуется), поэтому показываем именно его. +export interface MarketLayerToggle { + key: MarketLayerKey; + label: string; + color: string; + count: number; +} interface Props { - data: ConnectionPointsResponse; + data: ConnectionPointsResponse | null; grouped: Map; visibleCategories: Set; onToggleCategory: (cat: CpCategory) => void; onToggleAll: () => void; + // #999 — рыночные слои (опциональны: панель работает и без них). + marketLayers?: MarketLayerToggle[]; + visibleMarketLayers?: Set; + onToggleMarketLayer?: (key: MarketLayerKey) => void; } export function CpLayerControlPanel({ @@ -26,11 +41,15 @@ export function CpLayerControlPanel({ visibleCategories, onToggleCategory, onToggleAll, + marketLayers, + visibleMarketLayers, + onToggleMarketLayer, }: Props) { const [collapsed, setCollapsed] = useState(false); - const totalCount = data.engineering_structures.length; + const totalCount = data?.engineering_structures.length ?? 0; const allVisible = visibleCategories.size === CP_ALL_CATEGORIES.length; + const hasMarketLayers = (marketLayers?.length ?? 0) > 0; return (
setCollapsed((v) => !v)} > - Точки подключения + {data ? "Слои карты" : "Слои рынка"} - {totalCount} шт {collapsed ? "▲" : "▼"} + {data ? `${totalCount} тчк ` : ""} + {collapsed ? "▲" : "▼"}
{!collapsed && (
+ {/* ── Точки подключения (CP) ─────────────────────────────── */} + {data && ( + <> {/* No dump */} {!data.dump_available && (
)} + + )} + + {/* ── Рыночные слои (#999, 958-B4) ───────────────────────── */} + {hasMarketLayers && visibleMarketLayers && onToggleMarketLayer && ( +
+
+ Рынок +
+
+ {marketLayers!.map((ml) => { + const active = visibleMarketLayers.has(ml.key); + return ( + + ); + })} +
+
+ )}
)}
diff --git a/frontend/src/components/site-finder/MarketLayers.tsx b/frontend/src/components/site-finder/MarketLayers.tsx new file mode 100644 index 00000000..502129b7 --- /dev/null +++ b/frontend/src/components/site-finder/MarketLayers.tsx @@ -0,0 +1,399 @@ +"use client"; + +/** + * MarketLayers — #999 (958-B4): рыночные слои поверх карты Site Finder. + * + * Три независимо-переключаемых слоя (видимость управляется из SiteMap, как у + * ConnectionPointsLayer): + * 1. Конкуренты — CircleMarker на каждый competitor с координатами. + * 2. Будущие проекты — CircleMarker на каждый pipeline_24mo.top_objects + * (визуально отличны: dashed-кольцо, другой цвет). + * 3. Зоны риска — GeoJSON-полигоны из RiskZone.geom_wkt (WKT). + * + * Цвета — token-hex (документированное исключение карты/чартов, ui-tokens.md): + * Конкуренты → --viz-2 (#0ea5e9) + * Будущие проекты → --viz-5 (#8b5cf6) + * Зоны риска → --danger (#b3261e) + * + * future-ЖК (newbuilding_listings) — ВНЕ scope: эти объекты пока не отдаются в + * /analyze payload. Слой добавится отдельно после exposure newbuilding API. + * + * Graceful: объекты без координат пропускаются; пустые массивы → пустой слой + * (без падения); невалидный/неподдержанный WKT risk-зоны → зона не рисуется. + */ + +import { CircleMarker, GeoJSON, LayerGroup, Popup } from "react-leaflet"; +import type { Feature, Geometry, Position } from "geojson"; + +import type { + ParcelAnalysisCompetitor, + PipelineObject, +} from "@/types/site-finder"; +import type { 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 +} as const; + +// --------------------------------------------------------------------------- +// 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) + ); +} + +// --------------------------------------------------------------------------- +// Minimal WKT → GeoJSON parser (no new deps — see #999 conventions). +// Поддерживает типы, которые реально отдаёт NSPD risk-слой (ST_AsText, EPSG:4326, +// порядок «lon lat»): POINT, POLYGON, MULTIPOLYGON. Прочее / мусор → null +// (зона тогда просто не рисуется). НЕ general-purpose: только для risk-зон. +// --------------------------------------------------------------------------- + +function parseRing(body: string): Position[] { + return body + .split(",") + .map((pair) => pair.trim().split(/\s+/).map(Number)) + .filter( + (nums): nums is [number, number] => + nums.length >= 2 && + Number.isFinite(nums[0]) && + Number.isFinite(nums[1]), + ) + .map(([lon, lat]) => [lon, lat] as Position); +} + +// Делит верхнеуровневые группы внутри MULTIPOLYGON по скобочной вложенности, +// чтобы запятые ВНУТРИ координат не рвали полигоны. +function splitTopLevel(body: string): string[] { + const parts: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + if (ch === "(") depth++; + else if (ch === ")") depth--; + else if (ch === "," && depth === 0) { + parts.push(body.slice(start, i)); + start = i + 1; + } + } + parts.push(body.slice(start)); + return parts; +} + +function wktToGeometry(wkt: string): Geometry | null { + const trimmed = wkt.trim(); + const upper = trimmed.toUpperCase(); + + try { + if (upper.startsWith("POINT")) { + const body = trimmed.slice(trimmed.indexOf("(") + 1, trimmed.lastIndexOf(")")); + const nums = body.trim().split(/\s+/).map(Number); + if (nums.length < 2 || !Number.isFinite(nums[0]) || !Number.isFinite(nums[1])) { + return null; + } + return { type: "Point", coordinates: [nums[0], nums[1]] }; + } + + if (upper.startsWith("MULTIPOLYGON")) { + // MULTIPOLYGON(((ring),(hole)),((ring))) + const inner = trimmed.slice(trimmed.indexOf("(") + 1, trimmed.lastIndexOf(")")); + const polygons = splitTopLevel(inner) + .map((polyChunk) => { + const polyBody = polyChunk.trim().replace(/^\(/, "").replace(/\)$/, ""); + const rings = splitTopLevel(polyBody) + .map((ringChunk) => parseRing(ringChunk.replace(/[()]/g, ""))) + .filter((ring) => ring.length >= 3); + return rings; + }) + .filter((rings) => rings.length > 0); + if (polygons.length === 0) return null; + return { type: "MultiPolygon", coordinates: polygons }; + } + + if (upper.startsWith("POLYGON")) { + // POLYGON((ring),(hole)) + const inner = trimmed.slice(trimmed.indexOf("(") + 1, trimmed.lastIndexOf(")")); + const rings = splitTopLevel(inner) + .map((ringChunk) => parseRing(ringChunk.replace(/[()]/g, ""))) + .filter((ring) => ring.length >= 3); + if (rings.length === 0) return null; + return { type: "Polygon", coordinates: rings }; + } + } catch { + // Любой неожиданный WKT — graceful skip (зона не рисуется). + return null; + } + + return null; +} + +// --------------------------------------------------------------------------- +// Layer styles +// --------------------------------------------------------------------------- + +const COMPETITOR_RADIUS = 7; +const PIPELINE_RADIUS = 7; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +export interface MarketLayersProps { + competitors: ParcelAnalysisCompetitor[]; + pipelineObjects: PipelineObject[]; + riskZones: RiskZone[]; + showCompetitors: boolean; + showPipeline: boolean; + showRiskZones: boolean; +} + +export function MarketLayers({ + competitors, + pipelineObjects, + riskZones, + showCompetitors, + showPipeline, + showRiskZones, +}: 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} +
+ )} +
+
+
+ ); + })} +
+ )} + + {/* Конкуренты — сплошная заливка 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}
+ )} +
+
+
+ ); + })} +
+ )} + + ); +} diff --git a/frontend/src/components/site-finder/SiteMap.tsx b/frontend/src/components/site-finder/SiteMap.tsx index b60266fd..66e4b7a3 100644 --- a/frontend/src/components/site-finder/SiteMap.tsx +++ b/frontend/src/components/site-finder/SiteMap.tsx @@ -12,12 +12,21 @@ import { import type { Feature, FeatureCollection, Geometry, Position } from "geojson"; import "leaflet/dist/leaflet.css"; -import type { ParcelAnalysis } from "@/types/site-finder"; +import type { + ParcelAnalysis, + ParcelAnalysisCompetitor, + PipelineObject, +} from "@/types/site-finder"; import type { ConnectionPointsResponse, EngineeringStructure, + RiskZone, } from "@/types/nspd"; import type { CustomPoi } from "@/types/customPoi"; +import { + MarketLayers, + MARKET_COLORS, +} from "@/components/site-finder/MarketLayers"; import { ConnectionPointsLayer, CP_ALL_CATEGORIES, @@ -108,8 +117,15 @@ interface Props { connectionPoints?: ConnectionPointsResponse; customPois?: CustomPoi[]; parcelCad?: string; + // #999 (958-B4) — рыночные слои (опциональны: SiteMap используется и без них). + competitors?: ParcelAnalysisCompetitor[]; + pipelineObjects?: PipelineObject[]; + riskZones?: RiskZone[]; } +// Ключи переключаемых рыночных слоёв (#999). +export type MarketLayerKey = "competitors" | "pipeline" | "risk"; + // --------------------------------------------------------------------------- // Map click handler sub-component (must be inside MapContainer) // --------------------------------------------------------------------------- @@ -136,6 +152,9 @@ export function SiteMap({ connectionPoints, customPois, parcelCad, + competitors, + pipelineObjects, + riskZones, }: Props) { // Fix Leaflet default icon paths broken by webpack bundler useEffect(() => { @@ -157,6 +176,25 @@ export function SiteMap({ new Set(CP_ALL_CATEGORIES), ); + // #999 (958-B4) — рыночные слои. Карта — MiniMap (компактная), поэтому по + // умолчанию включены только Конкуренты; Будущие проекты + Зоны риска OFF + // (пользователь включает тумблером в CpLayerControlPanel). + const [visibleMarketLayers, setVisibleMarketLayers] = useState< + Set + >(new Set(["competitors"])); + + function toggleMarketLayer(key: MarketLayerKey) { + setVisibleMarketLayers((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + } + // Custom POI add mode state const [addMode, setAddMode] = useState(false); const [pendingCoords, setPendingCoords] = useState<{ @@ -221,6 +259,23 @@ export function SiteMap({ ? groupStructuresByCategory(connectionPoints.engineering_structures) : new Map(); + // #999 — нормализуем рыночные данные (тонкий ответ → пустые массивы). + const competitorList = competitors ?? data.competitors ?? []; + const pipelineList = pipelineObjects ?? data.pipeline_24mo?.top_objects ?? []; + const riskZoneList = riskZones ?? data.nspd_risk_zones ?? []; + const hasMarketData = + competitorList.length > 0 || + pipelineList.length > 0 || + riskZoneList.length > 0; + // Кол-во объектов с координатами — для подписи в панели (без координат не + // рисуются, поэтому счётчик панели должен это отражать). + const competitorMappable = competitorList.filter( + (c) => typeof c.lat === "number" && typeof c.lon === "number", + ).length; + const pipelineMappable = pipelineList.filter( + (p) => typeof p.lat === "number" && typeof p.lon === "number", + ).length; + return (
{/* Map */} @@ -299,6 +354,20 @@ export function SiteMap({ ); })} + {/* #999 (958-B4) — рыночные слои (конкуренты / pipeline / зоны риска). + Зоны риска рисуются как фон-заливка внутри MarketLayers, точки — + поверх. Видимость каждого слоя — из visibleMarketLayers. */} + {hasMarketData && ( + + )} + {/* POI markers */} {Object.entries(data.score_breakdown).flatMap(([cat, pois]) => { const style = CATEGORY_STYLES[cat]; @@ -470,14 +539,41 @@ export function SiteMap({

)} - {/* Connection points layer control panel — below the map */} - {connectionPoints && ( + {/* Layer control panel — CP-слои + рыночные слои (#999). Рендерится, + если есть данные точек подключения ИЛИ рыночные данные. */} + {(connectionPoints || hasMarketData) && ( )}
diff --git a/frontend/src/components/site-finder/analysis/MiniMap.tsx b/frontend/src/components/site-finder/analysis/MiniMap.tsx index 93738953..a64c4989 100644 --- a/frontend/src/components/site-finder/analysis/MiniMap.tsx +++ b/frontend/src/components/site-finder/analysis/MiniMap.tsx @@ -45,8 +45,9 @@ interface Props { /** * Adapts ParcelAnalyzeResponse → ParcelAnalysis shape expected by SiteMap. - * SiteMap only reads: cad_num, geom_geojson, score_breakdown. - * Other required fields are filled with safe defaults. + * SiteMap reads: cad_num, geom_geojson, score_breakdown + + * #999 (958-B4): competitors, pipeline_24mo, nspd_risk_zones (рыночные слои). + * Остальные required-поля заполняются безопасными дефолтами. */ function toSiteMapData(data: ParcelAnalyzeResponse): ParcelAnalysis { return { @@ -56,7 +57,10 @@ function toSiteMapData(data: ParcelAnalyzeResponse): ParcelAnalysis { score_breakdown: data.score_breakdown, score: data.score, poi_count: data.poi_count, - competitors: [], + // #999 — пробрасываем рыночные данные в SiteMap-слои. + competitors: data.competitors ?? [], + pipeline_24mo: data.pipeline_24mo ?? undefined, + nspd_risk_zones: data.nspd_risk_zones ?? undefined, noise: null, air_quality: null, wind: null, @@ -80,7 +84,12 @@ export function MiniMap({ data }: Props) { }} >
- +
); diff --git a/frontend/src/lib/site-finder-api.ts b/frontend/src/lib/site-finder-api.ts index 495b564d..f5894019 100644 --- a/frontend/src/lib/site-finder-api.ts +++ b/frontend/src/lib/site-finder-api.ts @@ -25,6 +25,11 @@ import fixtureAnalyze from "@/lib/mocks/parcel-analyze.json"; import fixturePoiScore from "@/lib/mocks/poi-score.json"; import fixtureForecast from "@/lib/mocks/parcel-forecast.json"; import type { ForecastEnvelope } from "@/types/forecast"; +import type { + ParcelAnalysisCompetitor, + Pipeline24mo, +} from "@/types/site-finder"; +import type { RiskZone } from "@/types/nspd"; // ── Types ───────────────────────────────────────────────────────────────────── @@ -310,6 +315,15 @@ export interface ParcelAnalyzeResponse { egrn?: ParcelEgrn | null; /** Engineering / utilities nearby — present when NSPD data available */ utilities?: UtilitiesData | null; + // #999 (958-B4) — рыночные слои для карты Site Finder. Все optional: + // отражают graceful 202-стаб (ещё нет данных) и тонкие ответы (нет + // конкурентов / пустой pipeline / нет risk-зон). + /** Конкуренты в радиусе 3 км (DOM.РФ + objective_lots), несут lat/lon. */ + competitors?: ParcelAnalysisCompetitor[]; + /** Будущие проекты 24 мес (D4 #36); top_objects несут lat/lon. */ + pipeline_24mo?: Pipeline24mo | null; + /** Риск-зоны НСПД (TIER 3 #94) — geom_wkt; часто пустой массив. */ + nspd_risk_zones?: RiskZone[] | null; } // ── Types: B6 poi-score response ───────────────────────────────────────────── diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 4bec110a..40ea5c4f 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -43,6 +43,16 @@ export interface ParcelAnalysisCompetitor { // Codegen will overwrite after backend deploy (additive, backward compat). site_status?: string | null; // 'Строящиеся' | 'Сданные' | null ready_dt?: string | null; // ISO date + // #999 (958-B4) — координаты для Leaflet-слоя «Конкуренты» (EPSG:4326). + // Backend `_competitor_with_coords` (parcels.py) кладёт lat/lon|null. Optional + // т.к. codegen / тонкий 202-стаб могут их не содержать; слой пропускает null. + lat?: number | null; + lon?: number | null; + // Рыночные метрики из objective_lots (LEFT JOIN → null без маппинга). + // Используются в popup-слое (цена/распроданность как velocity-прокси). + avg_price_per_m2_rub?: number | null; + units_sold?: number | null; + units_available?: number | null; } export interface ParcelAnalysisDistrict { @@ -193,6 +203,11 @@ export interface PipelineObject { // building даёт 0.0 которое falsy, raw Decimal иначе упал бы в JSON), так // что nullable отражает defensive Python path. distance_m: number | null; + // #999 (958-B4) — координаты для Leaflet-слоя «Будущие проекты» (EPSG:4326). + // backend `_aggregate_pipeline.top_objects` кладёт lat/lon|null; слой + // пропускает объекты без координат. + lat?: number | null; + lon?: number | null; } export interface Pipeline24mo {