feat(site-finder): toggleable market map layers (#999, 3/4) #1113

Merged
bot-backend merged 1 commit from feat/market-map-layers-999 into main 2026-06-07 09:36:05 +00:00
6 changed files with 635 additions and 12 deletions

View file

@ -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} шт&nbsp;{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>

View 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>
)}
</>
);
}

View file

@ -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>

View file

@ -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>
);

View file

@ -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 ─────────────────────────────────────────────

View file

@ -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 {