diff --git a/frontend/src/components/site-finder/MarketLayers.tsx b/frontend/src/components/site-finder/MarketLayers.tsx index 6b292753..0cd40d33 100644 --- a/frontend/src/components/site-finder/MarketLayers.tsx +++ b/frontend/src/components/site-finder/MarketLayers.tsx @@ -3,23 +3,29 @@ /** * MarketLayers — #999 (958-B4): рыночные слои поверх карты Site Finder. * - * Три независимо-переключаемых слоя (видимость управляется из SiteMap, как у + * Независимо-переключаемые слои (видимость управляется из SiteMap, как у * ConnectionPointsLayer): - * 1. Конкуренты — CircleMarker на каждый competitor с координатами. - * 2. Будущие проекты — CircleMarker на каждый pipeline_24mo.top_objects - * (визуально отличны: dashed-кольцо, другой цвет). - * 3. Зоны риска — GeoJSON-полигоны из RiskZone.geom_wkt (WKT). + * 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) — ВНЕ scope: эти объекты пока не отдаются в - * /analyze payload. Слой добавится отдельно после exposure newbuilding API. + * future-ЖК (newbuilding_listings) и полноценные граддок-полигоны + * (planning_projects / ППТ-ПМТ) — ВНЕ scope: эти объекты пока НЕ отдаются в + * /analyze payload. Слои добавятся после exposure соответствующих API. * * Graceful: объекты без координат пропускаются; пустые массивы → пустой слой - * (без падения); невалидный/неподдержанный WKT risk-зоны → зона не рисуется. + * (без падения); невалидный/неподдержанный WKT → фича не рисуется. */ import { CircleMarker, GeoJSON, LayerGroup, Popup } from "react-leaflet"; @@ -30,7 +36,7 @@ import type { ParcelAnalysisCompetitor, PipelineObject, } from "@/types/site-finder"; -import type { RiskZone } from "@/types/nspd"; +import type { OpportunityParcel, RedLine, RiskZone } from "@/types/nspd"; // --------------------------------------------------------------------------- // Market colors — token-hex (map/chart exception per ui-tokens.md) @@ -40,8 +46,26 @@ 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) // --------------------------------------------------------------------------- @@ -95,18 +119,27 @@ 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 ( <> @@ -142,7 +175,9 @@ export function MarketLayers({ }} > -
+
Зона риска
@@ -162,6 +197,147 @@ export function MarketLayers({ )} + {/* §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 && ( @@ -184,7 +360,9 @@ export function MarketLayers({ }} > -
+
-
+
{ @@ -263,10 +275,24 @@ export function SiteMap({ const competitorList = competitors ?? data.competitors ?? []; const pipelineList = pipelineObjects ?? data.pipeline_24mo?.top_objects ?? []; const riskZoneList = riskZones ?? data.nspd_risk_zones ?? []; + // §12.1-13 (#958) — opportunity-ЗУ + красные линии застройки. + const opportunityList = + opportunityParcels ?? data.nspd_opportunity_parcels ?? []; + const redLineList = redLines ?? data.nspd_red_lines ?? []; + // Слой рисуется только если у фичи есть геометрия (geom_wkt) — счётчик панели + // должен это отражать (пустой WKT не даёт полигон/линию). + const opportunityMappable = opportunityList.filter( + (o) => typeof o.geom_wkt === "string" && o.geom_wkt.length > 0, + ).length; + const redLineMappable = redLineList.filter( + (l) => typeof l.geom_wkt === "string" && l.geom_wkt.length > 0, + ).length; const hasMarketData = competitorList.length > 0 || pipelineList.length > 0 || - riskZoneList.length > 0; + riskZoneList.length > 0 || + opportunityMappable > 0 || + redLineMappable > 0; // Кол-во объектов с координатами — для подписи в панели (без координат не // рисуются, поэтому счётчик панели должен это отражать). const competitorMappable = competitorList.filter( @@ -362,9 +388,13 @@ export function SiteMap({ competitors={competitorList} pipelineObjects={pipelineList} riskZones={riskZoneList} + opportunityParcels={opportunityList} + redLines={redLineList} showCompetitors={visibleMarketLayers.has("competitors")} showPipeline={visibleMarketLayers.has("pipeline")} showRiskZones={visibleMarketLayers.has("risk")} + showOpportunity={visibleMarketLayers.has("opportunity")} + showRedLines={visibleMarketLayers.has("redlines")} /> )} @@ -569,6 +599,18 @@ export function SiteMap({ color: MARKET_COLORS.risk, count: riskZoneList.length, }, + { + key: "opportunity", + label: "Перспективные ЗУ", + color: MARKET_COLORS.opportunity, + count: opportunityMappable, + }, + { + key: "redlines", + label: "Красные линии", + color: MARKET_COLORS.redline, + count: redLineMappable, + }, ] : undefined } diff --git a/frontend/src/components/site-finder/analysis/MiniMap.tsx b/frontend/src/components/site-finder/analysis/MiniMap.tsx index a64c4989..4cf4b4c3 100644 --- a/frontend/src/components/site-finder/analysis/MiniMap.tsx +++ b/frontend/src/components/site-finder/analysis/MiniMap.tsx @@ -46,7 +46,8 @@ interface Props { /** * Adapts ParcelAnalyzeResponse → ParcelAnalysis shape expected by SiteMap. * SiteMap reads: cad_num, geom_geojson, score_breakdown + - * #999 (958-B4): competitors, pipeline_24mo, nspd_risk_zones (рыночные слои). + * #999 (958-B4): competitors, pipeline_24mo, nspd_risk_zones + + * §12.1-13 (#958): nspd_opportunity_parcels, nspd_red_lines (рыночные слои). * Остальные required-поля заполняются безопасными дефолтами. */ function toSiteMapData(data: ParcelAnalyzeResponse): ParcelAnalysis { @@ -61,6 +62,9 @@ function toSiteMapData(data: ParcelAnalyzeResponse): ParcelAnalysis { competitors: data.competitors ?? [], pipeline_24mo: data.pipeline_24mo ?? undefined, nspd_risk_zones: data.nspd_risk_zones ?? undefined, + // §12.1-13 (#958) — opportunity-ЗУ + красные линии застройки. + nspd_opportunity_parcels: data.nspd_opportunity_parcels ?? undefined, + nspd_red_lines: data.nspd_red_lines ?? undefined, noise: null, air_quality: null, wind: null, @@ -89,6 +93,8 @@ export function MiniMap({ data }: Props) { competitors={data.competitors ?? []} pipelineObjects={data.pipeline_24mo?.top_objects ?? []} riskZones={data.nspd_risk_zones ?? []} + opportunityParcels={data.nspd_opportunity_parcels ?? []} + redLines={data.nspd_red_lines ?? []} />
diff --git a/frontend/src/lib/__tests__/wkt.test.ts b/frontend/src/lib/__tests__/wkt.test.ts index 7d0e7dc6..dbb59d5b 100644 --- a/frontend/src/lib/__tests__/wkt.test.ts +++ b/frontend/src/lib/__tests__/wkt.test.ts @@ -7,6 +7,8 @@ * zones/markers in the ocean. */ import type { + LineString, + MultiLineString, MultiPolygon, Point, Polygon, @@ -41,8 +43,7 @@ describe("wktToGeometry — POLYGON", () => { it("parses a POLYGON with a hole (two rings)", () => { const wkt = - "POLYGON((0 0, 10 0, 10 10, 0 10, 0 0)," + - "(2 2, 4 2, 4 4, 2 4, 2 2))"; + "POLYGON((0 0, 10 0, 10 10, 0 10, 0 0)," + "(2 2, 4 2, 4 4, 2 4, 2 2))"; const geom = wktToGeometry(wkt) as Polygon | null; expect(geom?.type).toBe("Polygon"); expect(geom?.coordinates).toHaveLength(2); // outer + hole @@ -115,8 +116,46 @@ describe("wktToGeometry — invalid / unsupported input", () => { expect(wktToGeometry("")).toBeNull(); }); - it("returns null for an unsupported geometry type (LINESTRING)", () => { - expect(wktToGeometry("LINESTRING(0 0, 1 1, 2 2)")).toBeNull(); + it("returns null for an unsupported geometry type (GEOMETRYCOLLECTION)", () => { + expect( + wktToGeometry("GEOMETRYCOLLECTION(POINT(0 0),LINESTRING(0 0,1 1))"), + ).toBeNull(); + }); +}); + +describe("wktToGeometry — LINESTRING (red lines §13)", () => { + it("parses a LINESTRING preserving lon/lat order", () => { + const geom = wktToGeometry( + "LINESTRING(60.5 56.8, 60.6 56.9, 60.7 56.8)", + ) as LineString | null; + expect(geom?.type).toBe("LineString"); + expect(geom?.coordinates).toHaveLength(3); + // The bug class: WKT lon/lat must stay [lon, lat], not swap to [lat, lon]. + expect(geom?.coordinates[0]).toEqual([60.5, 56.8]); + }); + + it("returns null for a LINESTRING with a single point", () => { + expect(wktToGeometry("LINESTRING(60.6 56.8)")).toBeNull(); + }); +}); + +describe("wktToGeometry — MULTILINESTRING (red lines §13)", () => { + it("parses a MULTILINESTRING with two lines", () => { + const geom = wktToGeometry( + "MULTILINESTRING((0 0, 1 1, 2 2),(5 5, 6 6))", + ) as MultiLineString | null; + expect(geom?.type).toBe("MultiLineString"); + expect(geom?.coordinates).toHaveLength(2); + expect(geom?.coordinates[0]).toHaveLength(3); + expect(geom?.coordinates[1][0]).toEqual([5, 5]); + }); + + it("does not let intra-coordinate commas split lines", () => { + const geom = wktToGeometry( + "MULTILINESTRING((0 0, 1 1, 2 2))", + ) as MultiLineString; + expect(geom.coordinates).toHaveLength(1); + expect(geom.coordinates[0]).toHaveLength(3); }); }); diff --git a/frontend/src/lib/site-finder-api.ts b/frontend/src/lib/site-finder-api.ts index 39f957ff..e5dd5cd5 100644 --- a/frontend/src/lib/site-finder-api.ts +++ b/frontend/src/lib/site-finder-api.ts @@ -29,7 +29,7 @@ import type { ParcelAnalysisCompetitor, Pipeline24mo, } from "@/types/site-finder"; -import type { RiskZone } from "@/types/nspd"; +import type { OpportunityParcel, RedLine, RiskZone } from "@/types/nspd"; // ── Types ───────────────────────────────────────────────────────────────────── @@ -324,6 +324,13 @@ export interface ParcelAnalyzeResponse { pipeline_24mo?: Pipeline24mo | null; /** Риск-зоны НСПД (TIER 3 #94) — geom_wkt; часто пустой массив. */ nspd_risk_zones?: RiskZone[] | null; + // §12.1-13 (#958) — карта-слои граддок/перспективного предложения. Все + // optional: backend сериализует их в /analyze (parcels.py), но дамп квартала + // может быть не загружен → пустой массив (graceful), либо тонкий 202-стаб. + /** TIER 4 opportunity-ЗУ (#94): auction/scheme/free/future/oopt — geom_wkt. */ + nspd_opportunity_parcels?: OpportunityParcel[] | null; + /** TIER 4 красные линии застройки (#94, граддок) — geom_wkt (LINESTRING). */ + nspd_red_lines?: RedLine[] | null; } // ── Types: B6 poi-score response ───────────────────────────────────────────── diff --git a/frontend/src/lib/wkt.ts b/frontend/src/lib/wkt.ts index 07a7c216..2824df4d 100644 --- a/frontend/src/lib/wkt.ts +++ b/frontend/src/lib/wkt.ts @@ -5,9 +5,12 @@ * isolation (the #999 risk-zone parser had zero coverage and never ran on the QA * parcel). Behavior is otherwise identical to the inlined version. * - * Supports the types the NSPD risk layer actually emits (ST_AsText, EPSG:4326, - * `lon lat` order): POINT, POLYGON, MULTIPOLYGON. Anything else / garbage → null - * (the zone then simply is not drawn). NOT general-purpose — risk zones only. + * Supports the types the NSPD layers actually emit (ST_AsText, EPSG:4326, + * `lon lat` order): POINT, LINESTRING, MULTILINESTRING, POLYGON, MULTIPOLYGON. + * Anything else / garbage → null (the feature then simply is not drawn). NOT + * general-purpose. LINESTRING / MULTILINESTRING были добавлены для слоя «красные + * линии застройки» (§13, nspd_red_lines — ST_AsText на LINESTRING-геометрии); + * POLYGON/MULTIPOLYGON покрывают risk-зоны (§13) и opportunity-ЗУ (§12.1). * * Coordinate order: WKT is `lon lat`; GeoJSON Position is `[lon, lat]` — we keep * that order verbatim (mismatching it is the bug class that drops markers in the @@ -79,6 +82,32 @@ export function wktToGeometry(wkt: string): Geometry | null { return { type: "Point", coordinates: [nums[0], nums[1]] }; } + // NB: MULTILINESTRING проверяется ДО LINESTRING (startsWith), как + // MULTIPOLYGON до POLYGON ниже. + if (upper.startsWith("MULTILINESTRING")) { + // MULTILINESTRING((lon lat, …),(lon lat, …)) + const inner = trimmed.slice( + trimmed.indexOf("(") + 1, + trimmed.lastIndexOf(")"), + ); + const lines = splitTopLevel(inner) + .map((lineChunk) => parseRing(lineChunk.replace(/[()]/g, ""))) + .filter((line) => line.length >= 2); + if (lines.length === 0) return null; + return { type: "MultiLineString", coordinates: lines }; + } + + if (upper.startsWith("LINESTRING")) { + // LINESTRING(lon lat, lon lat, …) + const inner = trimmed.slice( + trimmed.indexOf("(") + 1, + trimmed.lastIndexOf(")"), + ); + const coords = parseRing(inner); + if (coords.length < 2) return null; + return { type: "LineString", coordinates: coords }; + } + if (upper.startsWith("MULTIPOLYGON")) { // MULTIPOLYGON(((ring),(hole)),((ring))) const inner = trimmed.slice(