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
494 lines
20 KiB
TypeScript
494 lines
20 KiB
TypeScript
"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<string, string> = {
|
||
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<T extends { lat?: number | null; lon?: number | null }>(
|
||
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 && (
|
||
<LayerGroup>
|
||
{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 (
|
||
<GeoJSON
|
||
// geom_wkt в key — react-leaflet GeoJSON кэширует слой и не
|
||
// реагирует на смену data prop; хеш форсирует remount при смене
|
||
// участка (тот же приём, что у изохрон в SiteMap).
|
||
key={`risk-${idx}-${zone.geom_wkt.slice(0, 24)}`}
|
||
data={feature}
|
||
style={{
|
||
color: MARKET_COLORS.risk,
|
||
weight: 1.5,
|
||
fillColor: MARKET_COLORS.risk,
|
||
fillOpacity: 0.18,
|
||
dashArray: "4 3",
|
||
}}
|
||
>
|
||
<Popup>
|
||
<div
|
||
style={{ fontSize: 12, lineHeight: 1.55, minWidth: 170 }}
|
||
>
|
||
<div style={{ fontWeight: 700, marginBottom: 4 }}>
|
||
Зона риска
|
||
</div>
|
||
<div style={{ marginBottom: 2 }}>
|
||
{zone.subtype ?? zone.layer}
|
||
</div>
|
||
{areaStr && (
|
||
<div style={{ color: "#374151" }}>
|
||
Пересечение с участком: <strong>{areaStr}</strong>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Popup>
|
||
</GeoJSON>
|
||
);
|
||
})}
|
||
</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>
|
||
{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 (
|
||
<CircleMarker
|
||
key={`comp-${c.obj_id}-${idx}`}
|
||
center={[c.lat, c.lon]}
|
||
radius={COMPETITOR_RADIUS}
|
||
pathOptions={{
|
||
color: MARKET_COLORS.competitor,
|
||
fillColor: MARKET_COLORS.competitor,
|
||
fillOpacity: 0.85,
|
||
weight: 1.5,
|
||
}}
|
||
>
|
||
<Popup>
|
||
<div
|
||
style={{ fontSize: 12, lineHeight: 1.55, minWidth: 180 }}
|
||
>
|
||
<div
|
||
style={{
|
||
fontWeight: 700,
|
||
marginBottom: 4,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 5,
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
display: "inline-block",
|
||
width: 10,
|
||
height: 10,
|
||
borderRadius: "50%",
|
||
background: MARKET_COLORS.competitor,
|
||
flexShrink: 0,
|
||
}}
|
||
/>
|
||
Конкурент
|
||
</div>
|
||
<div style={{ marginBottom: 2 }}>
|
||
<strong>{c.comm_name ?? "Без названия"}</strong>
|
||
</div>
|
||
{c.obj_class && (
|
||
<div style={{ color: "#6b7280", marginBottom: 2 }}>
|
||
Класс: {c.obj_class}
|
||
</div>
|
||
)}
|
||
{distanceStr && (
|
||
<div style={{ color: "#374151" }}>{distanceStr}</div>
|
||
)}
|
||
{c.site_status && (
|
||
<div style={{ color: "#374151" }}>{c.site_status}</div>
|
||
)}
|
||
{priceStr && (
|
||
<div style={{ color: "#374151" }}>{priceStr}</div>
|
||
)}
|
||
{showSales && (
|
||
<div style={{ color: "#374151" }}>
|
||
Продано: {c.units_sold ?? 0}
|
||
{typeof c.units_available === "number"
|
||
? ` · в продаже: ${c.units_available}`
|
||
: ""}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Popup>
|
||
</CircleMarker>
|
||
);
|
||
})}
|
||
</LayerGroup>
|
||
)}
|
||
|
||
{/* Будущие проекты (pipeline) — визуально ОТЛИЧНЫ: полое кольцо viz-5
|
||
с пунктирной обводкой (не путать с конкурентами). */}
|
||
{showPipeline && (
|
||
<LayerGroup>
|
||
{pipelineObjects.filter(hasCoords).map((p, idx) => {
|
||
const distanceStr = formatDistance(p.distance_m);
|
||
const readyStr = formatReadyDt(p.ready_dt);
|
||
return (
|
||
<CircleMarker
|
||
key={`pipe-${p.obj_id}-${idx}`}
|
||
center={[p.lat, p.lon]}
|
||
radius={PIPELINE_RADIUS}
|
||
pathOptions={{
|
||
color: MARKET_COLORS.pipeline,
|
||
fillColor: MARKET_COLORS.pipeline,
|
||
fillOpacity: 0.12,
|
||
weight: 2.5,
|
||
dashArray: "3 2",
|
||
}}
|
||
>
|
||
<Popup>
|
||
<div
|
||
style={{ fontSize: 12, lineHeight: 1.55, minWidth: 180 }}
|
||
>
|
||
<div
|
||
style={{
|
||
fontWeight: 700,
|
||
marginBottom: 4,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 5,
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
display: "inline-block",
|
||
width: 10,
|
||
height: 10,
|
||
borderRadius: "50%",
|
||
border: `2px dashed ${MARKET_COLORS.pipeline}`,
|
||
boxSizing: "border-box",
|
||
flexShrink: 0,
|
||
}}
|
||
/>
|
||
Будущий проект
|
||
</div>
|
||
<div style={{ marginBottom: 2 }}>
|
||
<strong>{p.comm_name ?? "Без названия"}</strong>
|
||
</div>
|
||
{p.obj_class && (
|
||
<div style={{ color: "#6b7280", marginBottom: 2 }}>
|
||
Класс: {p.obj_class}
|
||
</div>
|
||
)}
|
||
{typeof p.flat_count === "number" && (
|
||
<div style={{ color: "#374151" }}>
|
||
Квартир: {p.flat_count.toLocaleString("ru-RU")}
|
||
</div>
|
||
)}
|
||
{readyStr && (
|
||
<div style={{ color: "#374151" }}>Сдача: {readyStr}</div>
|
||
)}
|
||
{distanceStr && (
|
||
<div style={{ color: "#374151" }}>{distanceStr}</div>
|
||
)}
|
||
</div>
|
||
</Popup>
|
||
</CircleMarker>
|
||
);
|
||
})}
|
||
</LayerGroup>
|
||
)}
|
||
</>
|
||
);
|
||
}
|