feat(site-finder): add opportunity-ЗУ + red-line map layers (§12.1-13, #958)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m40s
Deploy / build-worker (push) Has been skipped
Deploy / deploy (push) Successful in 1m4s

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:
Light1YT 2026-06-09 12:15:44 +05:00 committed by bot-backend
parent cd48a095c0
commit df9d54b532
6 changed files with 327 additions and 24 deletions

View file

@ -3,23 +3,29 @@
/** /**
* MarketLayers #999 (958-B4): рыночные слои поверх карты Site Finder. * MarketLayers #999 (958-B4): рыночные слои поверх карты Site Finder.
* *
* Три независимо-переключаемых слоя (видимость управляется из SiteMap, как у * Независимо-переключаемые слои (видимость управляется из SiteMap, как у
* ConnectionPointsLayer): * ConnectionPointsLayer):
* 1. Конкуренты CircleMarker на каждый competitor с координатами. * 1. Конкуренты CircleMarker на каждый competitor с координатами.
* 2. Будущие проекты CircleMarker на каждый pipeline_24mo.top_objects * 2. Будущие проекты CircleMarker на каждый pipeline_24mo.top_objects
* (визуально отличны: dashed-кольцо, другой цвет). * (§12.1 future supply; dashed-кольцо, viz-5).
* 3. Зоны риска GeoJSON-полигоны из RiskZone.geom_wkt (WKT). * 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): * Цвета token-hex (документированное исключение карты/чартов, ui-tokens.md):
* Конкуренты --viz-2 (#0ea5e9) * Конкуренты --viz-2 (#0ea5e9)
* Будущие проекты --viz-5 (#8b5cf6) * Будущие проекты --viz-5 (#8b5cf6)
* Зоны риска --danger (#b3261e) * Зоны риска --danger (#b3261e)
* Перспективные ЗУ --viz-3 (#14b8a6) «возможность», отлично от риска
* Красные линии --warn (#9a6700) граддок-ограничение, не риск-зона
* *
* future-ЖК (newbuilding_listings) ВНЕ scope: эти объекты пока не отдаются в * future-ЖК (newbuilding_listings) и полноценные граддок-полигоны
* /analyze payload. Слой добавится отдельно после exposure newbuilding API. * (planning_projects / ППТ-ПМТ) ВНЕ scope: эти объекты пока НЕ отдаются в
* /analyze payload. Слои добавятся после exposure соответствующих API.
* *
* Graceful: объекты без координат пропускаются; пустые массивы пустой слой * Graceful: объекты без координат пропускаются; пустые массивы пустой слой
* (без падения); невалидный/неподдержанный WKT risk-зоны зона не рисуется. * (без падения); невалидный/неподдержанный WKT фича не рисуется.
*/ */
import { CircleMarker, GeoJSON, LayerGroup, Popup } from "react-leaflet"; import { CircleMarker, GeoJSON, LayerGroup, Popup } from "react-leaflet";
@ -30,7 +36,7 @@ import type {
ParcelAnalysisCompetitor, ParcelAnalysisCompetitor,
PipelineObject, PipelineObject,
} from "@/types/site-finder"; } 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) // Market colors — token-hex (map/chart exception per ui-tokens.md)
@ -40,8 +46,26 @@ export const MARKET_COLORS = {
competitor: "#0ea5e9", // --viz-2 competitor: "#0ea5e9", // --viz-2
pipeline: "#8b5cf6", // --viz-5 pipeline: "#8b5cf6", // --viz-5
risk: "#b3261e", // --danger risk: "#b3261e", // --danger
opportunity: "#14b8a6", // --viz-3 (перспективные ЗУ — «возможность»)
redline: "#9a6700", // --warn (красные линии застройки — граддок)
} as const; } 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) // Formatting helpers (RU)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -95,18 +119,27 @@ export interface MarketLayersProps {
competitors: ParcelAnalysisCompetitor[]; competitors: ParcelAnalysisCompetitor[];
pipelineObjects: PipelineObject[]; pipelineObjects: PipelineObject[];
riskZones: RiskZone[]; riskZones: RiskZone[];
// §12.1-13 (#958) — opportunity-ЗУ + красные линии (geom_wkt в /analyze).
opportunityParcels: OpportunityParcel[];
redLines: RedLine[];
showCompetitors: boolean; showCompetitors: boolean;
showPipeline: boolean; showPipeline: boolean;
showRiskZones: boolean; showRiskZones: boolean;
showOpportunity: boolean;
showRedLines: boolean;
} }
export function MarketLayers({ export function MarketLayers({
competitors, competitors,
pipelineObjects, pipelineObjects,
riskZones, riskZones,
opportunityParcels,
redLines,
showCompetitors, showCompetitors,
showPipeline, showPipeline,
showRiskZones, showRiskZones,
showOpportunity,
showRedLines,
}: MarketLayersProps) { }: MarketLayersProps) {
return ( return (
<> <>
@ -142,7 +175,9 @@ export function MarketLayers({
}} }}
> >
<Popup> <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 style={{ fontWeight: 700, marginBottom: 4 }}>
Зона риска Зона риска
</div> </div>
@ -162,6 +197,147 @@ export function MarketLayers({
</LayerGroup> </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. */} {/* Конкуренты — сплошная заливка viz-2. */}
{showCompetitors && ( {showCompetitors && (
<LayerGroup> <LayerGroup>
@ -184,7 +360,9 @@ export function MarketLayers({
}} }}
> >
<Popup> <Popup>
<div style={{ fontSize: 12, lineHeight: 1.55, minWidth: 180 }}> <div
style={{ fontSize: 12, lineHeight: 1.55, minWidth: 180 }}
>
<div <div
style={{ style={{
fontWeight: 700, fontWeight: 700,
@ -260,7 +438,9 @@ export function MarketLayers({
}} }}
> >
<Popup> <Popup>
<div style={{ fontSize: 12, lineHeight: 1.55, minWidth: 180 }}> <div
style={{ fontSize: 12, lineHeight: 1.55, minWidth: 180 }}
>
<div <div
style={{ style={{
fontWeight: 700, fontWeight: 700,

View file

@ -20,6 +20,8 @@ import type {
import type { import type {
ConnectionPointsResponse, ConnectionPointsResponse,
EngineeringStructure, EngineeringStructure,
OpportunityParcel,
RedLine,
RiskZone, RiskZone,
} from "@/types/nspd"; } from "@/types/nspd";
import type { CustomPoi } from "@/types/customPoi"; import type { CustomPoi } from "@/types/customPoi";
@ -121,10 +123,18 @@ interface Props {
competitors?: ParcelAnalysisCompetitor[]; competitors?: ParcelAnalysisCompetitor[];
pipelineObjects?: PipelineObject[]; pipelineObjects?: PipelineObject[];
riskZones?: RiskZone[]; riskZones?: RiskZone[];
// §12.1-13 (#958) — opportunity-ЗУ + красные линии застройки (geom_wkt).
opportunityParcels?: OpportunityParcel[];
redLines?: RedLine[];
} }
// Ключи переключаемых рыночных слоёв (#999). // Ключи переключаемых рыночных слоёв (#999 + §12.1-13 #958).
export type MarketLayerKey = "competitors" | "pipeline" | "risk"; export type MarketLayerKey =
| "competitors"
| "pipeline"
| "risk"
| "opportunity"
| "redlines";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Map click handler sub-component (must be inside MapContainer) // Map click handler sub-component (must be inside MapContainer)
@ -155,6 +165,8 @@ export function SiteMap({
competitors, competitors,
pipelineObjects, pipelineObjects,
riskZones, riskZones,
opportunityParcels,
redLines,
}: Props) { }: Props) {
// Fix Leaflet default icon paths broken by webpack bundler // Fix Leaflet default icon paths broken by webpack bundler
useEffect(() => { useEffect(() => {
@ -263,10 +275,24 @@ export function SiteMap({
const competitorList = competitors ?? data.competitors ?? []; const competitorList = competitors ?? data.competitors ?? [];
const pipelineList = pipelineObjects ?? data.pipeline_24mo?.top_objects ?? []; const pipelineList = pipelineObjects ?? data.pipeline_24mo?.top_objects ?? [];
const riskZoneList = riskZones ?? data.nspd_risk_zones ?? []; 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 = const hasMarketData =
competitorList.length > 0 || competitorList.length > 0 ||
pipelineList.length > 0 || pipelineList.length > 0 ||
riskZoneList.length > 0; riskZoneList.length > 0 ||
opportunityMappable > 0 ||
redLineMappable > 0;
// Кол-во объектов с координатами — для подписи в панели (без координат не // Кол-во объектов с координатами — для подписи в панели (без координат не
// рисуются, поэтому счётчик панели должен это отражать). // рисуются, поэтому счётчик панели должен это отражать).
const competitorMappable = competitorList.filter( const competitorMappable = competitorList.filter(
@ -362,9 +388,13 @@ export function SiteMap({
competitors={competitorList} competitors={competitorList}
pipelineObjects={pipelineList} pipelineObjects={pipelineList}
riskZones={riskZoneList} riskZones={riskZoneList}
opportunityParcels={opportunityList}
redLines={redLineList}
showCompetitors={visibleMarketLayers.has("competitors")} showCompetitors={visibleMarketLayers.has("competitors")}
showPipeline={visibleMarketLayers.has("pipeline")} showPipeline={visibleMarketLayers.has("pipeline")}
showRiskZones={visibleMarketLayers.has("risk")} showRiskZones={visibleMarketLayers.has("risk")}
showOpportunity={visibleMarketLayers.has("opportunity")}
showRedLines={visibleMarketLayers.has("redlines")}
/> />
)} )}
@ -569,6 +599,18 @@ export function SiteMap({
color: MARKET_COLORS.risk, color: MARKET_COLORS.risk,
count: riskZoneList.length, count: riskZoneList.length,
}, },
{
key: "opportunity",
label: "Перспективные ЗУ",
color: MARKET_COLORS.opportunity,
count: opportunityMappable,
},
{
key: "redlines",
label: "Красные линии",
color: MARKET_COLORS.redline,
count: redLineMappable,
},
] ]
: undefined : undefined
} }

View file

@ -46,7 +46,8 @@ interface Props {
/** /**
* Adapts ParcelAnalyzeResponse ParcelAnalysis shape expected by SiteMap. * Adapts ParcelAnalyzeResponse ParcelAnalysis shape expected by SiteMap.
* SiteMap reads: cad_num, geom_geojson, score_breakdown + * 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-поля заполняются безопасными дефолтами. * Остальные required-поля заполняются безопасными дефолтами.
*/ */
function toSiteMapData(data: ParcelAnalyzeResponse): ParcelAnalysis { function toSiteMapData(data: ParcelAnalyzeResponse): ParcelAnalysis {
@ -61,6 +62,9 @@ function toSiteMapData(data: ParcelAnalyzeResponse): ParcelAnalysis {
competitors: data.competitors ?? [], competitors: data.competitors ?? [],
pipeline_24mo: data.pipeline_24mo ?? undefined, pipeline_24mo: data.pipeline_24mo ?? undefined,
nspd_risk_zones: data.nspd_risk_zones ?? 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, noise: null,
air_quality: null, air_quality: null,
wind: null, wind: null,
@ -89,6 +93,8 @@ export function MiniMap({ data }: Props) {
competitors={data.competitors ?? []} competitors={data.competitors ?? []}
pipelineObjects={data.pipeline_24mo?.top_objects ?? []} pipelineObjects={data.pipeline_24mo?.top_objects ?? []}
riskZones={data.nspd_risk_zones ?? []} riskZones={data.nspd_risk_zones ?? []}
opportunityParcels={data.nspd_opportunity_parcels ?? []}
redLines={data.nspd_red_lines ?? []}
/> />
</div> </div>
</div> </div>

View file

@ -7,6 +7,8 @@
* zones/markers in the ocean. * zones/markers in the ocean.
*/ */
import type { import type {
LineString,
MultiLineString,
MultiPolygon, MultiPolygon,
Point, Point,
Polygon, Polygon,
@ -41,8 +43,7 @@ describe("wktToGeometry — POLYGON", () => {
it("parses a POLYGON with a hole (two rings)", () => { it("parses a POLYGON with a hole (two rings)", () => {
const wkt = const wkt =
"POLYGON((0 0, 10 0, 10 10, 0 10, 0 0)," + "POLYGON((0 0, 10 0, 10 10, 0 10, 0 0)," + "(2 2, 4 2, 4 4, 2 4, 2 2))";
"(2 2, 4 2, 4 4, 2 4, 2 2))";
const geom = wktToGeometry(wkt) as Polygon | null; const geom = wktToGeometry(wkt) as Polygon | null;
expect(geom?.type).toBe("Polygon"); expect(geom?.type).toBe("Polygon");
expect(geom?.coordinates).toHaveLength(2); // outer + hole expect(geom?.coordinates).toHaveLength(2); // outer + hole
@ -115,8 +116,46 @@ describe("wktToGeometry — invalid / unsupported input", () => {
expect(wktToGeometry("")).toBeNull(); expect(wktToGeometry("")).toBeNull();
}); });
it("returns null for an unsupported geometry type (LINESTRING)", () => { it("returns null for an unsupported geometry type (GEOMETRYCOLLECTION)", () => {
expect(wktToGeometry("LINESTRING(0 0, 1 1, 2 2)")).toBeNull(); 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);
}); });
}); });

View file

@ -29,7 +29,7 @@ import type {
ParcelAnalysisCompetitor, ParcelAnalysisCompetitor,
Pipeline24mo, Pipeline24mo,
} from "@/types/site-finder"; } from "@/types/site-finder";
import type { RiskZone } from "@/types/nspd"; import type { OpportunityParcel, RedLine, RiskZone } from "@/types/nspd";
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
@ -324,6 +324,13 @@ export interface ParcelAnalyzeResponse {
pipeline_24mo?: Pipeline24mo | null; pipeline_24mo?: Pipeline24mo | null;
/** Риск-зоны НСПД (TIER 3 #94) — geom_wkt; часто пустой массив. */ /** Риск-зоны НСПД (TIER 3 #94) — geom_wkt; часто пустой массив. */
nspd_risk_zones?: RiskZone[] | null; 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 ───────────────────────────────────────────── // ── Types: B6 poi-score response ─────────────────────────────────────────────

View file

@ -5,9 +5,12 @@
* isolation (the #999 risk-zone parser had zero coverage and never ran on the QA * isolation (the #999 risk-zone parser had zero coverage and never ran on the QA
* parcel). Behavior is otherwise identical to the inlined version. * parcel). Behavior is otherwise identical to the inlined version.
* *
* Supports the types the NSPD risk layer actually emits (ST_AsText, EPSG:4326, * Supports the types the NSPD layers actually emit (ST_AsText, EPSG:4326,
* `lon lat` order): POINT, POLYGON, MULTIPOLYGON. Anything else / garbage null * `lon lat` order): POINT, LINESTRING, MULTILINESTRING, POLYGON, MULTIPOLYGON.
* (the zone then simply is not drawn). NOT general-purpose risk zones only. * 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 * 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 * 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]] }; 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")) { if (upper.startsWith("MULTIPOLYGON")) {
// MULTIPOLYGON(((ring),(hole)),((ring))) // MULTIPOLYGON(((ring),(hole)),((ring)))
const inner = trimmed.slice( const inner = trimmed.slice(