feat(site-finder): add opportunity-ЗУ + red-line map layers (§12.1-13, #958)
Render the §12.1-13 geometry already exposed on /analyze but previously dropped by the MiniMap adapter: - Перспективные ЗУ (nspd_opportunity_parcels.geom_wkt) — viz-3 polygons - Красные линии застройки (nspd_red_lines.geom_wkt) — warn dashed lines Wired as toggles in the existing CpLayerControlPanel (Рынок group), reusing wkt.ts (extended with LINESTRING/MULTILINESTRING for red lines). Empty/invalid geometry renders nothing gracefully; popups RU plain-text. §22 forecast (future_market/special_indices), ППТ-ПМТ planning polygons and future-ЖК points carry no map-able geometry on the frontend yet — left as backend follow-ups, not faked. Refs #958
This commit is contained in:
parent
cd48a095c0
commit
df9d54b532
6 changed files with 327 additions and 24 deletions
|
|
@ -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<string, string> = {
|
||||
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({
|
|||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div style={{ fontSize: 12, lineHeight: 1.55, minWidth: 170 }}>
|
||||
<div
|
||||
style={{ fontSize: 12, lineHeight: 1.55, minWidth: 170 }}
|
||||
>
|
||||
<div style={{ fontWeight: 700, marginBottom: 4 }}>
|
||||
Зона риска
|
||||
</div>
|
||||
|
|
@ -162,6 +197,147 @@ export function MarketLayers({
|
|||
</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>
|
||||
|
|
@ -184,7 +360,9 @@ export function MarketLayers({
|
|||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div style={{ fontSize: 12, lineHeight: 1.55, minWidth: 180 }}>
|
||||
<div
|
||||
style={{ fontSize: 12, lineHeight: 1.55, minWidth: 180 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
|
|
@ -260,7 +438,9 @@ export function MarketLayers({
|
|||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div style={{ fontSize: 12, lineHeight: 1.55, minWidth: 180 }}>
|
||||
<div
|
||||
style={{ fontSize: 12, lineHeight: 1.55, minWidth: 180 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import type {
|
|||
import type {
|
||||
ConnectionPointsResponse,
|
||||
EngineeringStructure,
|
||||
OpportunityParcel,
|
||||
RedLine,
|
||||
RiskZone,
|
||||
} from "@/types/nspd";
|
||||
import type { CustomPoi } from "@/types/customPoi";
|
||||
|
|
@ -121,10 +123,18 @@ interface Props {
|
|||
competitors?: ParcelAnalysisCompetitor[];
|
||||
pipelineObjects?: PipelineObject[];
|
||||
riskZones?: RiskZone[];
|
||||
// §12.1-13 (#958) — opportunity-ЗУ + красные линии застройки (geom_wkt).
|
||||
opportunityParcels?: OpportunityParcel[];
|
||||
redLines?: RedLine[];
|
||||
}
|
||||
|
||||
// Ключи переключаемых рыночных слоёв (#999).
|
||||
export type MarketLayerKey = "competitors" | "pipeline" | "risk";
|
||||
// Ключи переключаемых рыночных слоёв (#999 + §12.1-13 #958).
|
||||
export type MarketLayerKey =
|
||||
| "competitors"
|
||||
| "pipeline"
|
||||
| "risk"
|
||||
| "opportunity"
|
||||
| "redlines";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Map click handler sub-component (must be inside MapContainer)
|
||||
|
|
@ -155,6 +165,8 @@ export function SiteMap({
|
|||
competitors,
|
||||
pipelineObjects,
|
||||
riskZones,
|
||||
opportunityParcels,
|
||||
redLines,
|
||||
}: Props) {
|
||||
// Fix Leaflet default icon paths broken by webpack bundler
|
||||
useEffect(() => {
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ?? []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue