diff --git a/frontend/src/app/site-finder/page.tsx b/frontend/src/app/site-finder/page.tsx index 58bc394f..04a74bc5 100644 --- a/frontend/src/app/site-finder/page.tsx +++ b/frontend/src/app/site-finder/page.tsx @@ -6,8 +6,11 @@ import Link from "next/link"; import type { FeatureCollection } from "geojson"; import { CadInput } from "@/components/site-finder/CadInput"; -import { ScoreCard } from "@/components/site-finder/ScoreCard"; -import { CompetitorTable } from "@/components/site-finder/CompetitorTable"; +import { KpiCard } from "@/components/site-finder/KpiCard"; +import { OverviewTab } from "@/components/site-finder/OverviewTab"; +import { EnvironmentTab } from "@/components/site-finder/EnvironmentTab"; +import { LandTab } from "@/components/site-finder/LandTab"; +import { MarketTab } from "@/components/site-finder/MarketTab"; import { useSiteAnalysis } from "@/hooks/useSiteAnalysis"; // SiteMap imports Leaflet which requires browser APIs — load without SSR @@ -35,21 +38,93 @@ const SiteMap = dynamic( }, ); +type TabId = "overview" | "env" | "land" | "market"; + +const TABS: Array<{ id: TabId; label: string }> = [ + { id: "overview", label: "Обзор" }, + { id: "env", label: "Окружение" }, + { id: "land", label: "Земля" }, + { id: "market", label: "Рынок" }, +]; + +// ── KPI helpers ─────────────────────────────────────────────────────────────── + +type KpiColor = "neutral" | "amber" | "green" | "red" | "blue"; + +function trendColor(pct: number): KpiColor { + if (pct > 5) return "green"; + if (pct > 0) return "blue"; + if (pct > -5) return "amber"; + return "red"; +} + +function noiseColor(db: number): KpiColor { + if (db < 45) return "green"; + if (db < 60) return "amber"; + return "red"; +} + +function scoreKpiColor(score: number, max: number): KpiColor { + const ratio = score / max; + if (ratio >= 0.65) return "green"; + if (ratio >= 0.35) return "amber"; + return "red"; +} + +// ── Page ────────────────────────────────────────────────────────────────────── + export default function SiteFinderPage() { const { mutate, data, isPending, error, isIdle } = useSiteAnalysis(); const [isochrones, setIsochrones] = useState( undefined, ); + const [tab, setTab] = useState("overview"); function handleAnalyze(cadNum: string) { setIsochrones(undefined); + setTab("overview"); mutate(cadNum); } + // Derive KPI values from data + const scoreMax = data?.score_max_reference ?? 40; + const scoreValue = data + ? `${data.score.toFixed(2)} / ${scoreMax.toFixed(0)}` + : "—"; + const scoreLabel = data?.score_label + ? `Score · ${data.score_label}` + : "Score"; + const scoreCardColor: KpiColor = data + ? scoreKpiColor(data.score, scoreMax) + : "neutral"; + + const districtValue = data?.district ? data.district.district_name : "—"; + const districtLabel = data?.district + ? `${(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс ₽/м²` + + ` · ${(data.district.dist_to_center / 1000).toFixed(1)} км от центра` + : "Район"; + + const trendPct = data?.market_trend?.delta_6m_pct; + const trendValue = + trendPct != null + ? `${trendPct > 0 ? "+" : ""}${trendPct.toFixed(1)}%` + : "—"; + const trendLabel = data?.market_trend + ? `Тренд за 6 мес · ${data.market_trend.label}` + : "Тренд рынка"; + const trendKpiColor: KpiColor = + trendPct != null ? trendColor(trendPct) : "neutral"; + + const noiseDb = data?.noise?.estimated_db; + const noiseValue = noiseDb != null ? `${Math.round(noiseDb)} dB` : "—"; + const noiseLabel = data?.noise ? `Шум · ${data.noise.level}` : "Шум"; + const noiseKpiColor: KpiColor = + noiseDb != null ? noiseColor(noiseDb) : "neutral"; + return ( -
- {/* Breadcrumb */} -
+
+ {/* Breadcrumb + header */} +
-

- Site Finder -

-

- Введите кадастровый номер участка или квартала для анализа локации -

- - {/* Input row */}
- +
+

+ Site Finder +

+

+ Введите кадастровый номер участка или квартала для анализа локации +

+
+
+ +
{/* Error state */} @@ -89,7 +174,7 @@ export default function SiteFinderPage() { borderRadius: 10, color: "#dc2626", fontSize: 13, - marginBottom: 24, + marginBottom: 20, }} > {error instanceof Error ? error.message : "Ошибка анализа участка"} @@ -100,7 +185,7 @@ export default function SiteFinderPage() { {isPending && (
- {/* Left column — score */} -
-
- {data.cad_num} -
- + <> + {/* Cad label */} +
+ {data.cad_num}
- {/* Right column — map + competitors (sticky) */} + {/* Hero band — sticky KPI cards */}
- - + + +
-
+ + {/* Tab buttons */} +
+ {TABS.map((t) => ( + + ))} +
+ + {/* 2-column grid: tab content + sticky map */} +
+ {/* Tab content */} +
+ {tab === "overview" && ( + + )} + {tab === "env" && } + {tab === "land" && } + {tab === "market" && } +
+ + {/* Sticky right sidebar — map always visible */} + +
+ )}
); diff --git a/frontend/src/components/site-finder/EnvironmentTab.tsx b/frontend/src/components/site-finder/EnvironmentTab.tsx new file mode 100644 index 00000000..55ed592d --- /dev/null +++ b/frontend/src/components/site-finder/EnvironmentTab.tsx @@ -0,0 +1,534 @@ +"use client"; + +import type { + ParcelAnalysis, + ParcelAnalysisNoise, + ParcelAnalysisAirQuality, + ParcelAnalysisWind, + ParcelAnalysisWeather, +} from "@/types/site-finder"; +import { SeasonalWeatherBlock } from "./SeasonalWeatherBlock"; +import { HydrologyBlock } from "./HydrologyBlock"; + +interface Props { + data: ParcelAnalysis; +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +const SOURCE_TYPE_LABELS: Record = { + highway: "Магистраль", + railway: "Ж/д", + industrial: "Производство", + aerodrome: "Аэродром", +}; + +function noiseBg(score: number): string { + if (score >= 0.7) return "#dcfce7"; + if (score >= 0.4) return "#fef3c7"; + return "#fecaca"; +} + +function pm25Color(v: number): string { + if (v < 10) return "#16a34a"; + if (v < 25) return "#d97706"; + if (v < 50) return "#ea580c"; + return "#dc2626"; +} + +function pm10Color(v: number): string { + if (v < 20) return "#16a34a"; + if (v < 50) return "#d97706"; + if (v < 100) return "#ea580c"; + return "#dc2626"; +} + +function no2Color(v: number): string { + if (v < 40) return "#16a34a"; + if (v < 100) return "#d97706"; + return "#dc2626"; +} + +function formatTs(iso: string): string { + try { + return new Date(iso).toLocaleDateString("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return iso; + } +} + +function uvColor(uv: number): string { + if (uv < 3) return "#16a34a"; + if (uv < 6) return "#d97706"; + if (uv < 8) return "#ea580c"; + return "#dc2626"; +} + +// ── WindArrow SVG ───────────────────────────────────────────────────────────── + +function WindArrow({ deg }: { deg: number }) { + return ( + + + + + + + + + ); +} + +// ── Noise ───────────────────────────────────────────────────────────────────── + +function NoiseBlock({ noise }: { noise: ParcelAnalysisNoise }) { + return ( +
+
Шум
+
+ ~{Math.round(noise.estimated_db)} dB +
+
{noise.level}
+ + {noise.sources.length > 0 && ( +
+
+ Источники ({noise.sources.length}) +
+ {noise.sources.slice(0, 5).map((s, i) => ( +
+ {SOURCE_TYPE_LABELS[s.source_type] ?? s.source_type} + {s.name ? ` «${s.name}»` : ""} — {Math.round(s.distance_m)} м ( + {Math.round(s.estimated_db)} dB) +
+ ))} +
+ )} +
+ ); +} + +// ── Air quality ─────────────────────────────────────────────────────────────── + +function AqBadge({ + label, + value, + unit, + color, +}: { + label: string; + value: number; + unit: string; + color: string; +}) { + return ( +
+
{label}
+
+ {value.toFixed(1)} +
+
{unit}
+
+ ); +} + +function AirQualityBlock({ aq }: { aq: ParcelAnalysisAirQuality | null }) { + if (!aq) { + return ( +
+
+ Воздух +
+
+ Данные недоступны +
+
+ ); + } + + return ( +
+
+ Воздух +
+
+ + + +
+
+ {aq.source} · {formatTs(aq.ts)} +
+
+ ); +} + +// ── Wind ────────────────────────────────────────────────────────────────────── + +function WindBlock({ wind }: { wind: ParcelAnalysisWind | null }) { + if (!wind) { + return ( +
+
+ Ветер +
+
+ Данные недоступны +
+
+ ); + } + + return ( +
+
+ Ветер +
+ +
+ {wind.dominant_direction_label} + {wind.max_speed_m_s != null ? ` · до ${wind.max_speed_m_s} м/с` : ""} +
+
+ за {wind.forecast_days} дн. +
+
+ ); +} + +// ── Weather (7-day forecast) ────────────────────────────────────────────────── + +function WeatherBlock({ + weather, +}: { + weather: ParcelAnalysisWeather | null | undefined; +}) { + if (!weather) { + return ( +
+
+ Погода / Климат +
+
+ Прогноз недоступен +
+
+ ); + } + + const { temperature: t, wind } = weather; + + return ( +
+
+ Погода / Климат +
+ +
+
Температура
+
+ {t.min_c != null && t.max_c != null + ? `${t.min_c}…${t.max_c}°C` + : t.min_c != null + ? `от ${t.min_c}°C` + : t.max_c != null + ? `до ${t.max_c}°C` + : "—"} +
+ {(t.avg_min_c != null || t.avg_max_c != null) && ( +
+ ср. {t.avg_min_c != null ? `${t.avg_min_c}` : "?"} + {"/"} + {t.avg_max_c != null ? `${t.avg_max_c}` : "?"}°C +
+ )} +
+ +
+
Осадки
+
+ {weather.precipitation_total_mm} мм + + {" "} + (за {weather.forecast_days} дн.) + +
+
+ {weather.precipitation_days} дн. с осадками +
+
+ + {weather.uv_index_max != null && ( +
+
UV макс:
+
+ {weather.uv_index_max} +
+
+ )} + +
+ +
+ {wind.dominant_direction_label} + {wind.max_speed_m_s != null ? ` · до ${wind.max_speed_m_s} м/с` : ""} +
+
+ +
+ {weather.source} · 7 дн. +
+
+ ); +} + +// ── EnvironmentTab ──────────────────────────────────────────────────────────── + +export function EnvironmentTab({ data }: Props) { + const hasEnv = + data.noise !== undefined || + data.air_quality !== undefined || + data.wind !== undefined || + data.weather !== undefined; + + return ( +
+ {/* Primary env grid */} + {hasEnv && ( +
+
+ Внешние факторы +
+
+ {data.noise !== undefined ? ( + data.noise !== null ? ( + + ) : ( +
+
+ Шум +
+
+ Данные недоступны +
+
+ ) + ) : null} + + + + {data.weather !== undefined ? ( + + ) : ( + + )} +
+
+ )} + + {/* Seasonal weather */} + {"seasonal_weather" in data && ( +
+
+ Климат (нормали 30 лет) +
+ +
+ )} + + {/* Hydrology */} + {data.hydrology !== undefined && ( +
+
+ Гидрология +
+ +
+ )} + + {!hasEnv && + !("seasonal_weather" in data) && + data.hydrology === undefined && ( +
+ Данные об окружающей среде недоступны +
+ )} +
+ ); +} diff --git a/frontend/src/components/site-finder/KpiCard.tsx b/frontend/src/components/site-finder/KpiCard.tsx new file mode 100644 index 00000000..1bbc3eb2 --- /dev/null +++ b/frontend/src/components/site-finder/KpiCard.tsx @@ -0,0 +1,62 @@ +"use client"; + +type KpiColor = "neutral" | "amber" | "green" | "red" | "blue"; + +const BG: Record = { + neutral: "#f9fafb", + amber: "#fef3c7", + green: "#dcfce7", + red: "#fee2e2", + blue: "#dbeafe", +}; + +const FG: Record = { + neutral: "#374151", + amber: "#b45309", + green: "#15803d", + red: "#b91c1c", + blue: "#1d4ed8", +}; + +interface Props { + value: string; + label: string; + color?: KpiColor; +} + +export function KpiCard({ value, label, color = "neutral" }: Props) { + return ( +
+
+ {value} +
+
+ {label} +
+
+ ); +} diff --git a/frontend/src/components/site-finder/LandTab.tsx b/frontend/src/components/site-finder/LandTab.tsx new file mode 100644 index 00000000..20273656 --- /dev/null +++ b/frontend/src/components/site-finder/LandTab.tsx @@ -0,0 +1,122 @@ +"use client"; + +import type { ParcelAnalysis } from "@/types/site-finder"; +import { GeologyBlock } from "./GeologyBlock"; +import { GeotechRiskBlock } from "./GeotechRiskBlock"; + +interface Props { + data: ParcelAnalysis; +} + +export function LandTab({ data }: Props) { + const hasAny = data.geotech_risk !== undefined || data.geology !== undefined; + + return ( +
+ {/* Zoning note */} +
+
+ Зонирование (ПЗЗ) +
+
+ Данные ПЗЗ доступны через{" "} + + Публичную кадастровую карту + + . API Росреестра закрыт в 2024 — используйте deep-link для кад. номера{" "} + {data.cad_num}. +
+ + Открыть в ПКК + +
+ + {/* Geotech risk */} + {data.geotech_risk !== undefined && ( +
+
+ Геотехнический риск +
+ +
+ )} + + {/* Geology */} + {data.geology !== undefined && ( +
+
+ Геология +
+ +
+ )} + + {!hasAny && ( +
+ Данные о земле и геологии недоступны +
+ )} +
+ ); +} diff --git a/frontend/src/components/site-finder/MarketTab.tsx b/frontend/src/components/site-finder/MarketTab.tsx new file mode 100644 index 00000000..727bfcec --- /dev/null +++ b/frontend/src/components/site-finder/MarketTab.tsx @@ -0,0 +1,90 @@ +"use client"; + +import type { ParcelAnalysis } from "@/types/site-finder"; +import { MarketTrendBlock } from "./MarketTrendBlock"; +import { CompetitorTable } from "./CompetitorTable"; +import { SuccessRecommendationBlock } from "./SuccessRecommendationBlock"; + +interface Props { + data: ParcelAnalysis; +} + +export function MarketTab({ data }: Props) { + const hasTrend = "market_trend" in data; + const hasRecommendation = "success_recommendation" in data; + const hasAny = hasTrend || hasRecommendation || data.competitors.length > 0; + + return ( +
+ {/* Market trend */} + {hasTrend && ( +
+ +
+ )} + + {/* Competitors */} + {data.competitors.length > 0 && ( +
+
+ Конкуренты +
+ +
+ )} + + {/* Success recommendation */} + {hasRecommendation && ( +
+
+ Что хорошо продаётся +
+ +
+ )} + + {!hasAny && ( +
+ Рыночные данные недоступны +
+ )} +
+ ); +} diff --git a/frontend/src/components/site-finder/OverviewTab.tsx b/frontend/src/components/site-finder/OverviewTab.tsx new file mode 100644 index 00000000..d5631f7b --- /dev/null +++ b/frontend/src/components/site-finder/OverviewTab.tsx @@ -0,0 +1,252 @@ +"use client"; + +import type { FeatureCollection } from "geojson"; +import type { ParcelAnalysis } from "@/types/site-finder"; +import { IsochronesPanel } from "./IsochronesPanel"; + +interface Props { + data: ParcelAnalysis; + onIsochronesResult?: (fc: FeatureCollection) => void; +} + +const CATEGORY_LABELS: Record = { + school: "Школы", + kindergarten: "Детские сады", + pharmacy: "Аптеки", + hospital: "Больницы / клиники", + shop_mall: "ТЦ / ТРЦ", + shop_supermarket: "Супермаркеты", + shop_small: "Магазины", + park: "Парки", + tram_stop: "Трамвайные остановки", + bus_stop: "Автобусные остановки", + metro_stop: "Метро", +}; + +function avgDist(items: Array<{ distance_m: number }>): number { + if (!items.length) return 0; + return Math.round(items.reduce((s, i) => s + i.distance_m, 0) / items.length); +} + +export function OverviewTab({ data, onIsochronesResult }: Props) { + const tramPois = data.score_breakdown["tram_stop"] ?? []; + const nearestTram = + tramPois.length > 0 + ? Math.round(Math.min(...tramPois.map((p) => p.distance_m))) + : null; + + return ( +
+ {/* District info */} + {data.district && ( +
+
+ Район +
+
+ {data.district.district_name} +
+
+ Медиана:{" "} + + {(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс ₽/м² + + + {(data.district.dist_to_center / 1000).toFixed(1)} км до центра + +
+ {data.location && ( +
+ До центра ЕКБ:{" "} + + {data.location.distance_to_center_km.toFixed(1)} км + + {data.location.center_bonus > 0 && ( + + (+{data.location.center_bonus.toFixed(1)} к score) + + )} +
+ )} +
+ )} + + {/* Tram warning */} + {nearestTram !== null && ( +
+ + Трамвай в {nearestTram} м — возможный источник шума +
+ )} + + {/* Score details */} +
+
+ Балл +
+
+ {data.poi_count} POI ·{" "} + {data.source === "cad_quarter" ? "квартал" : "участок"} +
+ {data.score_explanation && ( +
+ {data.score_explanation} +
+ )} + {data.score_without_center !== undefined && ( +
+ Без бонуса центра: {data.score_without_center.toFixed(2)} +
+ )} +
+ + {/* POI breakdown */} +
+
+ POI по категориям +
+ {Object.entries(data.score_breakdown).length === 0 ? ( +

Нет данных

+ ) : ( +
+ {Object.entries(data.score_breakdown).map(([cat, items]) => ( +
+ + {CATEGORY_LABELS[cat] ?? cat} + + + {items.length} шт · ср. {avgDist(items)} м + +
+ ))} +
+ )} +
+ + {/* Isochrones */} + {data.isochrones_available === false ? ( +
+
+ Изохроны доступности +
+
+ Недоступны — нужен OpenRouteService API key (см.{" "} + + backend/.env.runtime + + ) +
+
+ ) : data.isochrones_available === true && onIsochronesResult ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/frontend/src/components/site-finder/ScoreCard.tsx b/frontend/src/components/site-finder/ScoreCard.tsx index c56c4306..2229b1f1 100644 --- a/frontend/src/components/site-finder/ScoreCard.tsx +++ b/frontend/src/components/site-finder/ScoreCard.tsx @@ -1,915 +1,5 @@ -"use client"; +// ScoreCard is superseded by the tabbed dashboard in page.tsx. +// Logic is now split into OverviewTab, EnvironmentTab, LandTab, MarketTab. +// File kept to avoid breaking any external imports; exports an empty stub. -import { useState } from "react"; -import type { - ParcelAnalysis, - ParcelAnalysisNoise, - ParcelAnalysisAirQuality, - ParcelAnalysisWind, - ParcelAnalysisWeather, - ParcelLocation, -} from "@/types/site-finder"; -import type { FeatureCollection } from "geojson"; - -import { MarketTrendBlock } from "./MarketTrendBlock"; -import { GeologyBlock } from "./GeologyBlock"; -import { SeasonalWeatherBlock } from "./SeasonalWeatherBlock"; -import { HydrologyBlock } from "./HydrologyBlock"; -import { GeotechRiskBlock } from "./GeotechRiskBlock"; -import { IsochronesPanel } from "./IsochronesPanel"; -import { SuccessRecommendationBlock } from "./SuccessRecommendationBlock"; - -interface Props { - data: ParcelAnalysis; - onIsochronesResult?: (fc: FeatureCollection) => void; -} - -const CATEGORY_LABELS: Record = { - school: "Школы", - kindergarten: "Детские сады", - pharmacy: "Аптеки", - hospital: "Больницы / клиники", - shop_mall: "ТЦ / ТРЦ", - shop_supermarket: "Супермаркеты", - shop_small: "Магазины", - park: "Парки", - tram_stop: "Трамвайные остановки", - bus_stop: "Автобусные остановки", - metro_stop: "Метро", -}; - -const SOURCE_TYPE_LABELS: Record = { - highway: "Магистраль", - railway: "Ж/д", - industrial: "Производство", - aerodrome: "Аэродром", -}; - -function scoreColor(score: number): string { - if (score > 5) return "#16a34a"; - if (score >= 2) return "#d97706"; - return "#dc2626"; -} - -type ScoreLabel = "плохо" | "средне" | "хорошо" | "отлично"; - -const SCORE_LABEL_BG: Record = { - отлично: "#dcfce7", - хорошо: "#dbeafe", - средне: "#fef3c7", - плохо: "#fecaca", -}; - -const SCORE_LABEL_COLOR: Record = { - отлично: "#15803d", - хорошо: "#1d4ed8", - средне: "#b45309", - плохо: "#b91c1c", -}; - -function avgDist(items: Array<{ distance_m: number }>): number { - if (!items.length) return 0; - return Math.round(items.reduce((s, i) => s + i.distance_m, 0) / items.length); -} - -function noiseBg(score: number): string { - if (score >= 0.7) return "#dcfce7"; - if (score >= 0.4) return "#fef3c7"; - return "#fecaca"; -} - -function pm25Color(v: number): string { - if (v < 10) return "#16a34a"; - if (v < 25) return "#d97706"; - if (v < 50) return "#ea580c"; - return "#dc2626"; -} - -function pm10Color(v: number): string { - if (v < 20) return "#16a34a"; - if (v < 50) return "#d97706"; - if (v < 100) return "#ea580c"; - return "#dc2626"; -} - -function no2Color(v: number): string { - if (v < 40) return "#16a34a"; - if (v < 100) return "#d97706"; - return "#dc2626"; -} - -function formatTs(iso: string): string { - try { - return new Date(iso).toLocaleDateString("ru-RU", { - day: "2-digit", - month: "2-digit", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - }); - } catch { - return iso; - } -} - -// ── Collapsible section wrapper ─────────────────────────────────────────────── - -function CollapsibleSection({ - title, - defaultOpen = false, - children, -}: { - title: string; - defaultOpen?: boolean; - children: React.ReactNode; -}) { - const [open, setOpen] = useState(defaultOpen); - return ( -
- - {open &&
{children}
} -
- ); -} - -// ── Noise block ─────────────────────────────────────────────────────────────── - -function NoiseBlock({ noise }: { noise: ParcelAnalysisNoise }) { - const [open, setOpen] = useState(false); - const top3 = noise.sources.slice(0, 3); - - return ( -
-
Шум
-
- ~{Math.round(noise.estimated_db)} dB -
-
{noise.level}
- - {top3.length > 0 && ( -
- - {open && ( -
- {top3.map((s, i) => ( -
- {SOURCE_TYPE_LABELS[s.source_type] ?? s.source_type} - {s.name ? ` «${s.name}»` : ""} — {Math.round(s.distance_m)} м - ({Math.round(s.estimated_db)} dB) -
- ))} -
- )} -
- )} -
- ); -} - -// ── Air Quality block ───────────────────────────────────────────────────────── - -function AirQualityBlock({ aq }: { aq: ParcelAnalysisAirQuality | null }) { - if (!aq) { - return ( -
-
- Воздух -
-
Данные недоступны
-
- ); - } - - return ( -
-
- Воздух -
-
- - - -
-
- {aq.source} · {formatTs(aq.ts)} -
-
- ); -} - -function AqBadge({ - label, - value, - unit, - color, -}: { - label: string; - value: number; - unit: string; - color: string; -}) { - return ( -
-
{label}
-
- {value.toFixed(1)} -
-
{unit}
-
- ); -} - -// ── Wind block ──────────────────────────────────────────────────────────────── - -function WindArrow({ deg }: { deg: number }) { - // Arrow points "up" by default (north). Rotate to show wind coming FROM that direction. - return ( - - {/* compass ring */} - - {/* arrow group rotated to dominant_direction_deg */} - - {/* shaft */} - - {/* arrowhead */} - - {/* tail notch */} - - - - ); -} - -function WindBlock({ wind }: { wind: ParcelAnalysisWind | null }) { - if (!wind) { - return ( -
-
- Ветер -
-
Данные недоступны
-
- ); - } - - return ( -
-
- Ветер -
- -
- {wind.dominant_direction_label} - {wind.max_speed_m_s != null ? ` · до ${wind.max_speed_m_s} м/с` : ""} -
-
- за {wind.forecast_days} дн. -
-
- ); -} - -// ── Weather block ───────────────────────────────────────────────────────────── - -function uvColor(uv: number): string { - if (uv < 3) return "#16a34a"; - if (uv < 6) return "#d97706"; - if (uv < 8) return "#ea580c"; - return "#dc2626"; -} - -function WeatherBlock({ - weather, -}: { - weather: ParcelAnalysisWeather | null | undefined; -}) { - if (!weather) { - return ( -
-
- Погода / Климат -
-
Прогноз недоступен
-
- ); - } - - const { temperature: t, wind } = weather; - - return ( -
-
- Погода / Климат -
- - {/* Temperature */} -
-
Температура
-
- {t.min_c != null && t.max_c != null - ? `${t.min_c}…${t.max_c}°C` - : t.min_c != null - ? `от ${t.min_c}°C` - : t.max_c != null - ? `до ${t.max_c}°C` - : "—"} -
- {(t.avg_min_c != null || t.avg_max_c != null) && ( -
- ср. {t.avg_min_c != null ? `${t.avg_min_c}` : "?"} - {"/"} - {t.avg_max_c != null ? `${t.avg_max_c}` : "?"} - °C -
- )} -
- - {/* Precipitation */} -
-
Осадки
-
- {weather.precipitation_total_mm} мм - - {" "} - (за {weather.forecast_days} дн.) - -
-
- {weather.precipitation_days} дн. с осадками -
-
- - {/* UV */} - {weather.uv_index_max != null && ( -
-
UV макс:
-
- {weather.uv_index_max} -
-
- )} - - {/* Wind */} -
- -
- {wind.dominant_direction_label} - {wind.max_speed_m_s != null ? ` · до ${wind.max_speed_m_s} м/с` : ""} -
-
- - {/* Source — note shown only on hover */} -
- {weather.source} · 7 дн. -
-
- ); -} - -// ── Center distance row ─────────────────────────────────────────────────────── - -function centerBonusColor(bonus: number): string { - if (bonus >= 3.0) return "#16a34a"; - if (bonus >= 1.5) return "#1d4ed8"; - return "#6b7280"; -} - -function CenterDistanceRow({ location }: { location: ParcelLocation }) { - const color = centerBonusColor(location.center_bonus); - return ( -
- 📍 - - До центра ЕКБ:{" "} - - {location.distance_to_center_km.toFixed(1)} км - - - {location.center_bonus > 0 && ( - - (+{location.center_bonus.toFixed(1)} к score) - - )} -
- ); -} - -// ── ScoreCard ───────────────────────────────────────────────────────────────── - -export function ScoreCard({ data, onIsochronesResult }: Props) { - const tramPois = data.score_breakdown["tram_stop"] ?? []; - const nearestTram = - tramPois.length > 0 - ? Math.round(Math.min(...tramPois.map((p) => p.distance_m))) - : null; - - const hasEnvironmental = - data.noise !== undefined || - data.air_quality !== undefined || - data.wind !== undefined || - data.weather !== undefined; - - const scoreLabelTyped = data.score_label as ScoreLabel | undefined; - const badgeBg = scoreLabelTyped ? SCORE_LABEL_BG[scoreLabelTyped] : "#f3f4f6"; - const badgeFg = scoreLabelTyped - ? SCORE_LABEL_COLOR[scoreLabelTyped] - : "#6b7280"; - - return ( -
- {/* Sticky score header */} -
-
- - {data.score.toFixed(2)} - - {data.score_max_reference !== undefined && ( - - / {data.score_max_reference.toFixed(0)} - - )} -
- {scoreLabelTyped && ( - - {scoreLabelTyped} - - )} -
- - {/* Score detail (expanded) */} -
-
- Социальный балл участка -
-
- {data.poi_count} POI ·{" "} - {data.source === "cad_quarter" ? "квартал" : "участок"} -
- {data.score_explanation && ( -
- {data.score_explanation} -
- )} - {data.location && } -
- - {/* District */} - {data.district && ( -
- {data.district.district_name} - - · медиана {(data.district.median_price_per_m2 / 1000).toFixed(0)}{" "} - тыс ₽/м² - - - · {(data.district.dist_to_center / 1000).toFixed(1)} км до центра - -
- )} - - {/* Tram warning */} - {nearestTram !== null && ( -
- - Трамвай в {nearestTram} м (возможный источник шума) -
- )} - - {/* POI breakdown */} -
-
- POI по категориям -
- {Object.entries(data.score_breakdown).length === 0 ? ( -

Нет данных

- ) : ( -
- {Object.entries(data.score_breakdown).map(([cat, items]) => ( -
- - {CATEGORY_LABELS[cat] ?? cat} - - - {items.length} шт · ср. {avgDist(items)} м - -
- ))} -
- )} -
- - {/* Environmental factors (primary — open) */} - {hasEnvironmental && ( -
-
- Внешние факторы -
-
- {data.noise !== undefined ? ( - data.noise !== null ? ( - - ) : ( -
-
- Шум -
-
- Данные недоступны -
-
- ) - ) : null} - - - - {data.weather !== undefined ? ( - - ) : ( - - )} -
-
- )} - - {/* Market Trend (primary — open) */} - {"market_trend" in data && ( -
- -
- )} - - {/* Isochrones (primary — open) */} - {data.isochrones_available === false ? ( -
-
- Изохроны доступности -
-
- Недоступны — нужен OpenRouteService API key (см.{" "} - - backend/.env.runtime - - ) -
-
- ) : data.isochrones_available === true && onIsochronesResult ? ( -
- -
- ) : null} - - {/* Success recommendation (secondary — collapsed) */} - {"success_recommendation" in data && ( - - - - )} - - {/* Seasonal weather (secondary — collapsed) */} - {"seasonal_weather" in data && ( - - - - )} - - {/* Hydrology (secondary — collapsed) */} - {data.hydrology !== undefined && ( - - - - )} - - {/* Geotech risk (secondary — collapsed) */} - {data.geotech_risk !== undefined && ( - - - - )} - - {/* Geology (secondary — collapsed) */} - {data.geology !== undefined && ( - -
- Геология -
- -
- )} -
- ); -} +export {};