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,
|
CP_CATEGORY_STYLES,
|
||||||
type CpCategory,
|
type CpCategory,
|
||||||
} from "@/components/site-finder/ConnectionPointsLayer";
|
} 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 {
|
interface Props {
|
||||||
data: ConnectionPointsResponse;
|
data: ConnectionPointsResponse | null;
|
||||||
grouped: Map<CpCategory, EngineeringStructure[]>;
|
grouped: Map<CpCategory, EngineeringStructure[]>;
|
||||||
visibleCategories: Set<CpCategory>;
|
visibleCategories: Set<CpCategory>;
|
||||||
onToggleCategory: (cat: CpCategory) => void;
|
onToggleCategory: (cat: CpCategory) => void;
|
||||||
onToggleAll: () => void;
|
onToggleAll: () => void;
|
||||||
|
// #999 — рыночные слои (опциональны: панель работает и без них).
|
||||||
|
marketLayers?: MarketLayerToggle[];
|
||||||
|
visibleMarketLayers?: Set<MarketLayerKey>;
|
||||||
|
onToggleMarketLayer?: (key: MarketLayerKey) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CpLayerControlPanel({
|
export function CpLayerControlPanel({
|
||||||
|
|
@ -26,11 +41,15 @@ export function CpLayerControlPanel({
|
||||||
visibleCategories,
|
visibleCategories,
|
||||||
onToggleCategory,
|
onToggleCategory,
|
||||||
onToggleAll,
|
onToggleAll,
|
||||||
|
marketLayers,
|
||||||
|
visibleMarketLayers,
|
||||||
|
onToggleMarketLayer,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
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 allVisible = visibleCategories.size === CP_ALL_CATEGORIES.length;
|
||||||
|
const hasMarketLayers = (marketLayers?.length ?? 0) > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -56,15 +75,19 @@ export function CpLayerControlPanel({
|
||||||
onClick={() => setCollapsed((v) => !v)}
|
onClick={() => setCollapsed((v) => !v)}
|
||||||
>
|
>
|
||||||
<span style={{ fontWeight: 700, color: "#1f2937" }}>
|
<span style={{ fontWeight: 700, color: "#1f2937" }}>
|
||||||
Точки подключения
|
{data ? "Слои карты" : "Слои рынка"}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ color: "#6b7280", fontSize: 11 }}>
|
<span style={{ color: "#6b7280", fontSize: 11 }}>
|
||||||
{totalCount} шт {collapsed ? "▲" : "▼"}
|
{data ? `${totalCount} тчк ` : ""}
|
||||||
|
{collapsed ? "▲" : "▼"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div style={{ padding: "8px 12px 10px" }}>
|
<div style={{ padding: "8px 12px 10px" }}>
|
||||||
|
{/* ── Точки подключения (CP) ─────────────────────────────── */}
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
{/* No dump */}
|
{/* No dump */}
|
||||||
{!data.dump_available && (
|
{!data.dump_available && (
|
||||||
<div
|
<div
|
||||||
|
|
@ -217,6 +240,73 @@ export function CpLayerControlPanel({
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</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 type { Feature, FeatureCollection, Geometry, Position } from "geojson";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
import type {
|
||||||
|
ParcelAnalysis,
|
||||||
|
ParcelAnalysisCompetitor,
|
||||||
|
PipelineObject,
|
||||||
|
} from "@/types/site-finder";
|
||||||
import type {
|
import type {
|
||||||
ConnectionPointsResponse,
|
ConnectionPointsResponse,
|
||||||
EngineeringStructure,
|
EngineeringStructure,
|
||||||
|
RiskZone,
|
||||||
} from "@/types/nspd";
|
} from "@/types/nspd";
|
||||||
import type { CustomPoi } from "@/types/customPoi";
|
import type { CustomPoi } from "@/types/customPoi";
|
||||||
|
import {
|
||||||
|
MarketLayers,
|
||||||
|
MARKET_COLORS,
|
||||||
|
} from "@/components/site-finder/MarketLayers";
|
||||||
import {
|
import {
|
||||||
ConnectionPointsLayer,
|
ConnectionPointsLayer,
|
||||||
CP_ALL_CATEGORIES,
|
CP_ALL_CATEGORIES,
|
||||||
|
|
@ -108,8 +117,15 @@ interface Props {
|
||||||
connectionPoints?: ConnectionPointsResponse;
|
connectionPoints?: ConnectionPointsResponse;
|
||||||
customPois?: CustomPoi[];
|
customPois?: CustomPoi[];
|
||||||
parcelCad?: string;
|
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)
|
// Map click handler sub-component (must be inside MapContainer)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -136,6 +152,9 @@ export function SiteMap({
|
||||||
connectionPoints,
|
connectionPoints,
|
||||||
customPois,
|
customPois,
|
||||||
parcelCad,
|
parcelCad,
|
||||||
|
competitors,
|
||||||
|
pipelineObjects,
|
||||||
|
riskZones,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// Fix Leaflet default icon paths broken by webpack bundler
|
// Fix Leaflet default icon paths broken by webpack bundler
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -157,6 +176,25 @@ export function SiteMap({
|
||||||
new Set(CP_ALL_CATEGORIES),
|
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
|
// Custom POI add mode state
|
||||||
const [addMode, setAddMode] = useState(false);
|
const [addMode, setAddMode] = useState(false);
|
||||||
const [pendingCoords, setPendingCoords] = useState<{
|
const [pendingCoords, setPendingCoords] = useState<{
|
||||||
|
|
@ -221,6 +259,23 @@ export function SiteMap({
|
||||||
? groupStructuresByCategory(connectionPoints.engineering_structures)
|
? groupStructuresByCategory(connectionPoints.engineering_structures)
|
||||||
: new Map<CpCategory, EngineeringStructure[]>();
|
: 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Map */}
|
{/* 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 */}
|
{/* POI markers */}
|
||||||
{Object.entries(data.score_breakdown).flatMap(([cat, pois]) => {
|
{Object.entries(data.score_breakdown).flatMap(([cat, pois]) => {
|
||||||
const style = CATEGORY_STYLES[cat];
|
const style = CATEGORY_STYLES[cat];
|
||||||
|
|
@ -470,14 +539,41 @@ export function SiteMap({
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Connection points layer control panel — below the map */}
|
{/* Layer control panel — CP-слои + рыночные слои (#999). Рендерится,
|
||||||
{connectionPoints && (
|
если есть данные точек подключения ИЛИ рыночные данные. */}
|
||||||
|
{(connectionPoints || hasMarketData) && (
|
||||||
<CpLayerControlPanel
|
<CpLayerControlPanel
|
||||||
data={connectionPoints}
|
data={connectionPoints ?? null}
|
||||||
grouped={cpGrouped}
|
grouped={cpGrouped}
|
||||||
visibleCategories={visibleCategories}
|
visibleCategories={visibleCategories}
|
||||||
onToggleCategory={toggleCategory}
|
onToggleCategory={toggleCategory}
|
||||||
onToggleAll={toggleAll}
|
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>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,9 @@ interface Props {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adapts ParcelAnalyzeResponse → ParcelAnalysis shape expected by SiteMap.
|
* Adapts ParcelAnalyzeResponse → ParcelAnalysis shape expected by SiteMap.
|
||||||
* SiteMap only reads: cad_num, geom_geojson, score_breakdown.
|
* SiteMap reads: cad_num, geom_geojson, score_breakdown +
|
||||||
* Other required fields are filled with safe defaults.
|
* #999 (958-B4): competitors, pipeline_24mo, nspd_risk_zones (рыночные слои).
|
||||||
|
* Остальные required-поля заполняются безопасными дефолтами.
|
||||||
*/
|
*/
|
||||||
function toSiteMapData(data: ParcelAnalyzeResponse): ParcelAnalysis {
|
function toSiteMapData(data: ParcelAnalyzeResponse): ParcelAnalysis {
|
||||||
return {
|
return {
|
||||||
|
|
@ -56,7 +57,10 @@ function toSiteMapData(data: ParcelAnalyzeResponse): ParcelAnalysis {
|
||||||
score_breakdown: data.score_breakdown,
|
score_breakdown: data.score_breakdown,
|
||||||
score: data.score,
|
score: data.score,
|
||||||
poi_count: data.poi_count,
|
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,
|
noise: null,
|
||||||
air_quality: null,
|
air_quality: null,
|
||||||
wind: null,
|
wind: null,
|
||||||
|
|
@ -80,7 +84,12 @@ export function MiniMap({ data }: Props) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ height: 320 }}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,11 @@ import fixtureAnalyze from "@/lib/mocks/parcel-analyze.json";
|
||||||
import fixturePoiScore from "@/lib/mocks/poi-score.json";
|
import fixturePoiScore from "@/lib/mocks/poi-score.json";
|
||||||
import fixtureForecast from "@/lib/mocks/parcel-forecast.json";
|
import fixtureForecast from "@/lib/mocks/parcel-forecast.json";
|
||||||
import type { ForecastEnvelope } from "@/types/forecast";
|
import type { ForecastEnvelope } from "@/types/forecast";
|
||||||
|
import type {
|
||||||
|
ParcelAnalysisCompetitor,
|
||||||
|
Pipeline24mo,
|
||||||
|
} from "@/types/site-finder";
|
||||||
|
import type { RiskZone } from "@/types/nspd";
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -310,6 +315,15 @@ export interface ParcelAnalyzeResponse {
|
||||||
egrn?: ParcelEgrn | null;
|
egrn?: ParcelEgrn | null;
|
||||||
/** Engineering / utilities nearby — present when NSPD data available */
|
/** Engineering / utilities nearby — present when NSPD data available */
|
||||||
utilities?: UtilitiesData | null;
|
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 ─────────────────────────────────────────────
|
// ── Types: B6 poi-score response ─────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,16 @@ export interface ParcelAnalysisCompetitor {
|
||||||
// Codegen will overwrite after backend deploy (additive, backward compat).
|
// Codegen will overwrite after backend deploy (additive, backward compat).
|
||||||
site_status?: string | null; // 'Строящиеся' | 'Сданные' | null
|
site_status?: string | null; // 'Строящиеся' | 'Сданные' | null
|
||||||
ready_dt?: string | null; // ISO date
|
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 {
|
export interface ParcelAnalysisDistrict {
|
||||||
|
|
@ -193,6 +203,11 @@ export interface PipelineObject {
|
||||||
// building даёт 0.0 которое falsy, raw Decimal иначе упал бы в JSON), так
|
// building даёт 0.0 которое falsy, raw Decimal иначе упал бы в JSON), так
|
||||||
// что nullable отражает defensive Python path.
|
// что nullable отражает defensive Python path.
|
||||||
distance_m: number | null;
|
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 {
|
export interface Pipeline24mo {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue