feat(site-finder): toggleable market map layers (#999, 3/4) #1113
6 changed files with 635 additions and 12 deletions
|
|
@ -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<CpCategory, EngineeringStructure[]>;
|
||||
visibleCategories: Set<CpCategory>;
|
||||
onToggleCategory: (cat: CpCategory) => void;
|
||||
onToggleAll: () => void;
|
||||
// #999 — рыночные слои (опциональны: панель работает и без них).
|
||||
marketLayers?: MarketLayerToggle[];
|
||||
visibleMarketLayers?: Set<MarketLayerKey>;
|
||||
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 (
|
||||
<div
|
||||
|
|
@ -56,15 +75,19 @@ export function CpLayerControlPanel({
|
|||
onClick={() => setCollapsed((v) => !v)}
|
||||
>
|
||||
<span style={{ fontWeight: 700, color: "#1f2937" }}>
|
||||
Точки подключения
|
||||
{data ? "Слои карты" : "Слои рынка"}
|
||||
</span>
|
||||
<span style={{ color: "#6b7280", fontSize: 11 }}>
|
||||
{totalCount} шт {collapsed ? "▲" : "▼"}
|
||||
{data ? `${totalCount} тчк ` : ""}
|
||||
{collapsed ? "▲" : "▼"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div style={{ padding: "8px 12px 10px" }}>
|
||||
{/* ── Точки подключения (CP) ─────────────────────────────── */}
|
||||
{data && (
|
||||
<>
|
||||
{/* No dump */}
|
||||
{!data.dump_available && (
|
||||
<div
|
||||
|
|
@ -217,6 +240,73 @@ export function CpLayerControlPanel({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Рыночные слои (#999, 958-B4) ───────────────────────── */}
|
||||
{hasMarketLayers && visibleMarketLayers && onToggleMarketLayer && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: data ? 8 : 0,
|
||||
paddingTop: data ? 8 : 0,
|
||||
borderTop: data ? "1px solid #f3f4f6" : "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: "#374151",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
Рынок
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "4px 14px",
|
||||
}}
|
||||
>
|
||||
{marketLayers!.map((ml) => {
|
||||
const active = visibleMarketLayers.has(ml.key);
|
||||
return (
|
||||
<label
|
||||
key={ml.key}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
cursor: "pointer",
|
||||
color: "#374151",
|
||||
opacity: active ? 1 : 0.45,
|
||||
transition: "opacity 0.15s",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active}
|
||||
onChange={() => onToggleMarketLayer(ml.key)}
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: "50%",
|
||||
background: ml.color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{ml.label}
|
||||
<span style={{ color: "#9ca3af" }}>{ml.count}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
399
frontend/src/components/site-finder/MarketLayers.tsx
Normal file
399
frontend/src/components/site-finder/MarketLayers.tsx
Normal file
|
|
@ -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<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)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Конкуренты — сплошная заливка 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<MarketLayerKey>
|
||||
>(new Set<MarketLayerKey>(["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<CpCategory, EngineeringStructure[]>();
|
||||
|
||||
// #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 (
|
||||
<div>
|
||||
{/* Map */}
|
||||
|
|
@ -299,6 +354,20 @@ export function SiteMap({
|
|||
);
|
||||
})}
|
||||
|
||||
{/* #999 (958-B4) — рыночные слои (конкуренты / pipeline / зоны риска).
|
||||
Зоны риска рисуются как фон-заливка внутри MarketLayers, точки —
|
||||
поверх. Видимость каждого слоя — из visibleMarketLayers. */}
|
||||
{hasMarketData && (
|
||||
<MarketLayers
|
||||
competitors={competitorList}
|
||||
pipelineObjects={pipelineList}
|
||||
riskZones={riskZoneList}
|
||||
showCompetitors={visibleMarketLayers.has("competitors")}
|
||||
showPipeline={visibleMarketLayers.has("pipeline")}
|
||||
showRiskZones={visibleMarketLayers.has("risk")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* POI markers */}
|
||||
{Object.entries(data.score_breakdown).flatMap(([cat, pois]) => {
|
||||
const style = CATEGORY_STYLES[cat];
|
||||
|
|
@ -470,14 +539,41 @@ export function SiteMap({
|
|||
</p>
|
||||
)}
|
||||
|
||||
{/* Connection points layer control panel — below the map */}
|
||||
{connectionPoints && (
|
||||
{/* Layer control panel — CP-слои + рыночные слои (#999). Рендерится,
|
||||
если есть данные точек подключения ИЛИ рыночные данные. */}
|
||||
{(connectionPoints || hasMarketData) && (
|
||||
<CpLayerControlPanel
|
||||
data={connectionPoints}
|
||||
data={connectionPoints ?? null}
|
||||
grouped={cpGrouped}
|
||||
visibleCategories={visibleCategories}
|
||||
onToggleCategory={toggleCategory}
|
||||
onToggleAll={toggleAll}
|
||||
marketLayers={
|
||||
hasMarketData
|
||||
? [
|
||||
{
|
||||
key: "competitors",
|
||||
label: "Конкуренты",
|
||||
color: MARKET_COLORS.competitor,
|
||||
count: competitorMappable,
|
||||
},
|
||||
{
|
||||
key: "pipeline",
|
||||
label: "Будущие проекты",
|
||||
color: MARKET_COLORS.pipeline,
|
||||
count: pipelineMappable,
|
||||
},
|
||||
{
|
||||
key: "risk",
|
||||
label: "Зоны риска",
|
||||
color: MARKET_COLORS.risk,
|
||||
count: riskZoneList.length,
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
visibleMarketLayers={visibleMarketLayers}
|
||||
onToggleMarketLayer={toggleMarketLayer}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
}}
|
||||
>
|
||||
<div style={{ height: 320 }}>
|
||||
<SiteMap data={adapted} />
|
||||
<SiteMap
|
||||
data={adapted}
|
||||
competitors={data.competitors ?? []}
|
||||
pipelineObjects={data.pipeline_24mo?.top_objects ?? []}
|
||||
riskZones={data.nspd_risk_zones ?? []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue