feat(site-finder): tabbed UX - hero KPI band + 4 tabs (Overview/Env/Land/Market) + sticky map
This commit is contained in:
parent
199adc31e6
commit
a1201aa94e
7 changed files with 1287 additions and 971 deletions
|
|
@ -6,8 +6,11 @@ import Link from "next/link";
|
||||||
import type { FeatureCollection } from "geojson";
|
import type { FeatureCollection } from "geojson";
|
||||||
|
|
||||||
import { CadInput } from "@/components/site-finder/CadInput";
|
import { CadInput } from "@/components/site-finder/CadInput";
|
||||||
import { ScoreCard } from "@/components/site-finder/ScoreCard";
|
import { KpiCard } from "@/components/site-finder/KpiCard";
|
||||||
import { CompetitorTable } from "@/components/site-finder/CompetitorTable";
|
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";
|
import { useSiteAnalysis } from "@/hooks/useSiteAnalysis";
|
||||||
|
|
||||||
// SiteMap imports Leaflet which requires browser APIs — load without SSR
|
// 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() {
|
export default function SiteFinderPage() {
|
||||||
const { mutate, data, isPending, error, isIdle } = useSiteAnalysis();
|
const { mutate, data, isPending, error, isIdle } = useSiteAnalysis();
|
||||||
const [isochrones, setIsochrones] = useState<FeatureCollection | undefined>(
|
const [isochrones, setIsochrones] = useState<FeatureCollection | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
const [tab, setTab] = useState<TabId>("overview");
|
||||||
|
|
||||||
function handleAnalyze(cadNum: string) {
|
function handleAnalyze(cadNum: string) {
|
||||||
setIsochrones(undefined);
|
setIsochrones(undefined);
|
||||||
|
setTab("overview");
|
||||||
mutate(cadNum);
|
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 (
|
return (
|
||||||
<main style={{ padding: "24px 20px", maxWidth: 1280, margin: "0 auto" }}>
|
<main style={{ padding: "24px 20px", maxWidth: 1400, margin: "0 auto" }}>
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb + header */}
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
style={{ fontSize: 13, color: "#6b7280", textDecoration: "none" }}
|
style={{ fontSize: 13, color: "#6b7280", textDecoration: "none" }}
|
||||||
|
|
@ -58,25 +133,35 @@ export default function SiteFinderPage() {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 4 }}>
|
|
||||||
Site Finder
|
|
||||||
</h1>
|
|
||||||
<p style={{ fontSize: 13, color: "#6b7280", marginBottom: 24 }}>
|
|
||||||
Введите кадастровый номер участка или квартала для анализа локации
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Input row */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
maxWidth: 560,
|
display: "flex",
|
||||||
background: "#fff",
|
alignItems: "center",
|
||||||
border: "1px solid #e5e7eb",
|
justifyContent: "space-between",
|
||||||
borderRadius: 12,
|
marginBottom: 20,
|
||||||
padding: "20px 24px",
|
flexWrap: "wrap",
|
||||||
marginBottom: 32,
|
gap: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CadInput onSubmit={handleAnalyze} loading={isPending} />
|
<div>
|
||||||
|
<h1 style={{ fontSize: 24, fontWeight: 700, margin: 0 }}>
|
||||||
|
Site Finder
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: 13, color: "#6b7280", margin: "4px 0 0" }}>
|
||||||
|
Введите кадастровый номер участка или квартала для анализа локации
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "14px 20px",
|
||||||
|
minWidth: 320,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CadInput onSubmit={handleAnalyze} loading={isPending} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error state */}
|
{/* Error state */}
|
||||||
|
|
@ -89,7 +174,7 @@ export default function SiteFinderPage() {
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
color: "#dc2626",
|
color: "#dc2626",
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
marginBottom: 24,
|
marginBottom: 20,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{error instanceof Error ? error.message : "Ошибка анализа участка"}
|
{error instanceof Error ? error.message : "Ошибка анализа участка"}
|
||||||
|
|
@ -100,7 +185,7 @@ export default function SiteFinderPage() {
|
||||||
{isPending && (
|
{isPending && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: 32,
|
padding: 40,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
color: "#9ca3af",
|
color: "#9ca3af",
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
|
@ -128,50 +213,131 @@ export default function SiteFinderPage() {
|
||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
{data && (
|
{data && (
|
||||||
<div
|
<>
|
||||||
style={{
|
{/* Cad label */}
|
||||||
display: "grid",
|
<div
|
||||||
gridTemplateColumns: "320px 1fr",
|
style={{
|
||||||
gap: 24,
|
fontSize: 11,
|
||||||
alignItems: "start",
|
fontWeight: 600,
|
||||||
}}
|
color: "#9ca3af",
|
||||||
>
|
textTransform: "uppercase",
|
||||||
{/* Left column — score */}
|
letterSpacing: "0.05em",
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
marginBottom: 10,
|
||||||
<div
|
}}
|
||||||
style={{
|
>
|
||||||
fontSize: 11,
|
{data.cad_num}
|
||||||
fontWeight: 600,
|
|
||||||
color: "#9ca3af",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: "0.05em",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{data.cad_num}
|
|
||||||
</div>
|
|
||||||
<ScoreCard data={data} onIsochronesResult={setIsochrones} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right column — map + competitors (sticky) */}
|
{/* Hero band — sticky KPI cards */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "sticky",
|
position: "sticky",
|
||||||
top: 16,
|
top: 0,
|
||||||
alignSelf: "start",
|
zIndex: 20,
|
||||||
maxHeight: "calc(100vh - 32px)",
|
background: "#fff",
|
||||||
overflowY: "auto",
|
borderBottom: "1px solid #e5e7eb",
|
||||||
display: "flex",
|
padding: "12px 0 14px",
|
||||||
flexDirection: "column",
|
marginBottom: 0,
|
||||||
gap: 24,
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(4, 1fr)",
|
||||||
|
gap: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SiteMap data={data} isochrones={isochrones} />
|
<KpiCard
|
||||||
<CompetitorTable
|
value={scoreValue}
|
||||||
competitors={data.competitors}
|
label={scoreLabel}
|
||||||
districtName={data.district?.district_name}
|
color={scoreCardColor}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
value={districtValue}
|
||||||
|
label={districtLabel}
|
||||||
|
color="neutral"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
value={trendValue}
|
||||||
|
label={trendLabel}
|
||||||
|
color={trendKpiColor}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
value={noiseValue}
|
||||||
|
label={noiseLabel}
|
||||||
|
color={noiseKpiColor}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{/* Tab buttons */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 0,
|
||||||
|
borderBottom: "1px solid #e5e7eb",
|
||||||
|
marginBottom: 20,
|
||||||
|
background: "#fff",
|
||||||
|
position: "sticky",
|
||||||
|
top: 86,
|
||||||
|
zIndex: 19,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
style={{
|
||||||
|
padding: "10px 20px",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
borderBottom:
|
||||||
|
tab === t.id
|
||||||
|
? "2px solid #1d4ed8"
|
||||||
|
: "2px solid transparent",
|
||||||
|
color: tab === t.id ? "#1d4ed8" : "#6b7280",
|
||||||
|
transition: "color 0.15s, border-color 0.15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2-column grid: tab content + sticky map */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 480px",
|
||||||
|
gap: 24,
|
||||||
|
alignItems: "start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Tab content */}
|
||||||
|
<div>
|
||||||
|
{tab === "overview" && (
|
||||||
|
<OverviewTab data={data} onIsochronesResult={setIsochrones} />
|
||||||
|
)}
|
||||||
|
{tab === "env" && <EnvironmentTab data={data} />}
|
||||||
|
{tab === "land" && <LandTab data={data} />}
|
||||||
|
{tab === "market" && <MarketTab data={data} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sticky right sidebar — map always visible */}
|
||||||
|
<aside
|
||||||
|
style={{
|
||||||
|
position: "sticky",
|
||||||
|
top: 140,
|
||||||
|
alignSelf: "start",
|
||||||
|
maxHeight: "calc(100vh - 156px)",
|
||||||
|
overflowY: "auto",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SiteMap data={data} isochrones={isochrones} />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
534
frontend/src/components/site-finder/EnvironmentTab.tsx
Normal file
534
frontend/src/components/site-finder/EnvironmentTab.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<svg
|
||||||
|
width="44"
|
||||||
|
height="44"
|
||||||
|
viewBox="0 0 52 52"
|
||||||
|
style={{ display: "block", flexShrink: 0 }}
|
||||||
|
aria-label={`Направление ветра ${deg}°`}
|
||||||
|
>
|
||||||
|
<circle cx="26" cy="26" r="24" fill="#e5e7eb" />
|
||||||
|
<g transform={`rotate(${deg}, 26, 26)`}>
|
||||||
|
<rect x="24.5" y="10" width="3" height="24" rx="1.5" fill="#374151" />
|
||||||
|
<polygon points="26,6 21,16 31,16" fill="#374151" />
|
||||||
|
<polygon points="26,34 22,44 30,44" fill="#9ca3af" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Noise ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function NoiseBlock({ noise }: { noise: ParcelAnalysisNoise }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: noiseBg(noise.score),
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>Шум</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "#111827",
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
~{Math.round(noise.estimated_db)} dB
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#374151" }}>{noise.level}</div>
|
||||||
|
|
||||||
|
{noise.sources.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 4,
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#6b7280",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Источники ({noise.sources.length})
|
||||||
|
</div>
|
||||||
|
{noise.sources.slice(0, 5).map((s, i) => (
|
||||||
|
<div key={i} style={{ fontSize: 12, color: "#374151" }}>
|
||||||
|
{SOURCE_TYPE_LABELS[s.source_type] ?? s.source_type}
|
||||||
|
{s.name ? ` «${s.name}»` : ""} — {Math.round(s.distance_m)} м (
|
||||||
|
{Math.round(s.estimated_db)} dB)
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Air quality ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AqBadge({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
unit,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
unit: string;
|
||||||
|
color: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "6px 10px",
|
||||||
|
background: `${color}18`,
|
||||||
|
border: `1px solid ${color}55`,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
minWidth: 62,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 11, color: "#6b7280" }}>{label}</div>
|
||||||
|
<div style={{ fontSize: 17, fontWeight: 700, color, lineHeight: 1.2 }}>
|
||||||
|
{value.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: "#9ca3af" }}>{unit}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AirQualityBlock({ aq }: { aq: ParcelAnalysisAirQuality | null }) {
|
||||||
|
if (!aq) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: "#f3f4f6",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
||||||
|
Воздух
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#9ca3af", marginTop: 8 }}>
|
||||||
|
Данные недоступны
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: "#f0fdf4",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
||||||
|
Воздух
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
<AqBadge
|
||||||
|
label="PM2.5"
|
||||||
|
value={aq.pm2_5}
|
||||||
|
unit="мкг/м³"
|
||||||
|
color={pm25Color(aq.pm2_5)}
|
||||||
|
/>
|
||||||
|
<AqBadge
|
||||||
|
label="PM10"
|
||||||
|
value={aq.pm10}
|
||||||
|
unit="мкг/м³"
|
||||||
|
color={pm10Color(aq.pm10)}
|
||||||
|
/>
|
||||||
|
<AqBadge
|
||||||
|
label="NO₂"
|
||||||
|
value={aq.no2}
|
||||||
|
unit="мкг/м³"
|
||||||
|
color={no2Color(aq.no2)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "#9ca3af" }}>
|
||||||
|
{aq.source} · {formatTs(aq.ts)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wind ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function WindBlock({ wind }: { wind: ParcelAnalysisWind | null }) {
|
||||||
|
if (!wind) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: "#f3f4f6",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
||||||
|
Ветер
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#9ca3af", marginTop: 8 }}>
|
||||||
|
Данные недоступны
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: "#eff6ff",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
||||||
|
Ветер
|
||||||
|
</div>
|
||||||
|
<WindArrow deg={wind.dominant_direction_deg} />
|
||||||
|
<div style={{ fontSize: 13, color: "#374151" }}>
|
||||||
|
{wind.dominant_direction_label}
|
||||||
|
{wind.max_speed_m_s != null ? ` · до ${wind.max_speed_m_s} м/с` : ""}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "#9ca3af" }}>
|
||||||
|
за {wind.forecast_days} дн.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Weather (7-day forecast) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function WeatherBlock({
|
||||||
|
weather,
|
||||||
|
}: {
|
||||||
|
weather: ParcelAnalysisWeather | null | undefined;
|
||||||
|
}) {
|
||||||
|
if (!weather) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: "#f3f4f6",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
||||||
|
Погода / Климат
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#9ca3af", marginTop: 8 }}>
|
||||||
|
Прогноз недоступен
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { temperature: t, wind } = weather;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: "#eff6ff",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
||||||
|
Погода / Климат
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
|
<div style={{ fontSize: 11, color: "#6b7280" }}>Температура</div>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 700, color: "#111827" }}>
|
||||||
|
{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`
|
||||||
|
: "—"}
|
||||||
|
</div>
|
||||||
|
{(t.avg_min_c != null || t.avg_max_c != null) && (
|
||||||
|
<div style={{ fontSize: 11, color: "#6b7280" }}>
|
||||||
|
ср. {t.avg_min_c != null ? `${t.avg_min_c}` : "?"}
|
||||||
|
{"/"}
|
||||||
|
{t.avg_max_c != null ? `${t.avg_max_c}` : "?"}°C
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
|
<div style={{ fontSize: 11, color: "#6b7280" }}>Осадки</div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: "#374151" }}>
|
||||||
|
{weather.precipitation_total_mm} мм
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 400, color: "#6b7280" }}>
|
||||||
|
{" "}
|
||||||
|
(за {weather.forecast_days} дн.)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "#6b7280" }}>
|
||||||
|
{weather.precipitation_days} дн. с осадками
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{weather.uv_index_max != null && (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<div style={{ fontSize: 11, color: "#6b7280" }}>UV макс:</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: uvColor(weather.uv_index_max),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{weather.uv_index_max}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<WindArrow deg={wind.dominant_direction_deg} />
|
||||||
|
<div style={{ fontSize: 13, color: "#374151" }}>
|
||||||
|
{wind.dominant_direction_label}
|
||||||
|
{wind.max_speed_m_s != null ? ` · до ${wind.max_speed_m_s} м/с` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#9ca3af",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
title={weather.note}
|
||||||
|
>
|
||||||
|
{weather.source} · 7 дн.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EnvironmentTab ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function EnvironmentTab({ data }: Props) {
|
||||||
|
const hasEnv =
|
||||||
|
data.noise !== undefined ||
|
||||||
|
data.air_quality !== undefined ||
|
||||||
|
data.wind !== undefined ||
|
||||||
|
data.weather !== undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||||
|
{/* Primary env grid */}
|
||||||
|
{hasEnv && (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#6b7280",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Внешние факторы
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.noise !== undefined ? (
|
||||||
|
data.noise !== null ? (
|
||||||
|
<NoiseBlock noise={data.noise} />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: "#f3f4f6",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}
|
||||||
|
>
|
||||||
|
Шум
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#9ca3af", marginTop: 8 }}>
|
||||||
|
Данные недоступны
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<AirQualityBlock aq={data.air_quality ?? null} />
|
||||||
|
|
||||||
|
{data.weather !== undefined ? (
|
||||||
|
<WeatherBlock weather={data.weather} />
|
||||||
|
) : (
|
||||||
|
<WindBlock wind={data.wind ?? null} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Seasonal weather */}
|
||||||
|
{"seasonal_weather" in data && (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#6b7280",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Климат (нормали 30 лет)
|
||||||
|
</div>
|
||||||
|
<SeasonalWeatherBlock seasonal={data.seasonal_weather} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hydrology */}
|
||||||
|
{data.hydrology !== undefined && (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#6b7280",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Гидрология
|
||||||
|
</div>
|
||||||
|
<HydrologyBlock hydrology={data.hydrology} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasEnv &&
|
||||||
|
!("seasonal_weather" in data) &&
|
||||||
|
data.hydrology === undefined && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#9ca3af",
|
||||||
|
padding: "24px 0",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Данные об окружающей среде недоступны
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
frontend/src/components/site-finder/KpiCard.tsx
Normal file
62
frontend/src/components/site-finder/KpiCard.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
type KpiColor = "neutral" | "amber" | "green" | "red" | "blue";
|
||||||
|
|
||||||
|
const BG: Record<KpiColor, string> = {
|
||||||
|
neutral: "#f9fafb",
|
||||||
|
amber: "#fef3c7",
|
||||||
|
green: "#dcfce7",
|
||||||
|
red: "#fee2e2",
|
||||||
|
blue: "#dbeafe",
|
||||||
|
};
|
||||||
|
|
||||||
|
const FG: Record<KpiColor, string> = {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "12px 16px",
|
||||||
|
background: BG[color],
|
||||||
|
borderRadius: 10,
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: FG[color],
|
||||||
|
lineHeight: 1.2,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#6b7280",
|
||||||
|
marginTop: 4,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
frontend/src/components/site-finder/LandTab.tsx
Normal file
122
frontend/src/components/site-finder/LandTab.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||||
|
{/* Zoning note */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#f9fafb",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 18px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#6b7280",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Зонирование (ПЗЗ)
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#6b7280" }}>
|
||||||
|
Данные ПЗЗ доступны через{" "}
|
||||||
|
<a
|
||||||
|
href="https://pkk.rosreestr.ru/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: "#1d4ed8", textDecoration: "none" }}
|
||||||
|
>
|
||||||
|
Публичную кадастровую карту
|
||||||
|
</a>
|
||||||
|
. API Росреестра закрыт в 2024 — используйте deep-link для кад. номера{" "}
|
||||||
|
<strong style={{ color: "#374151" }}>{data.cad_num}</strong>.
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={`https://pkk.rosreestr.ru/#/search/${encodeURIComponent(data.cad_num)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
marginTop: 10,
|
||||||
|
padding: "6px 14px",
|
||||||
|
background: "#1d4ed8",
|
||||||
|
color: "#fff",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
textDecoration: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Открыть в ПКК
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Geotech risk */}
|
||||||
|
{data.geotech_risk !== undefined && (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#6b7280",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Геотехнический риск
|
||||||
|
</div>
|
||||||
|
<GeotechRiskBlock risk={data.geotech_risk} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Geology */}
|
||||||
|
{data.geology !== undefined && (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#6b7280",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Геология
|
||||||
|
</div>
|
||||||
|
<GeologyBlock geology={data.geology} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasAny && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#9ca3af",
|
||||||
|
padding: "24px 0",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Данные о земле и геологии недоступны
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
frontend/src/components/site-finder/MarketTab.tsx
Normal file
90
frontend/src/components/site-finder/MarketTab.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||||
|
{/* Market trend */}
|
||||||
|
{hasTrend && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 18px",
|
||||||
|
background: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MarketTrendBlock trend={data.market_trend} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Competitors */}
|
||||||
|
{data.competitors.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#6b7280",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Конкуренты
|
||||||
|
</div>
|
||||||
|
<CompetitorTable
|
||||||
|
competitors={data.competitors}
|
||||||
|
districtName={data.district?.district_name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success recommendation */}
|
||||||
|
{hasRecommendation && (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#6b7280",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Что хорошо продаётся
|
||||||
|
</div>
|
||||||
|
<SuccessRecommendationBlock
|
||||||
|
recommendation={data.success_recommendation}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasAny && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#9ca3af",
|
||||||
|
padding: "24px 0",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Рыночные данные недоступны
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
252
frontend/src/components/site-finder/OverviewTab.tsx
Normal file
252
frontend/src/components/site-finder/OverviewTab.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||||
|
{/* District info */}
|
||||||
|
{data.district && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#f9fafb",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 18px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#6b7280",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Район
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 700, color: "#111827" }}>
|
||||||
|
{data.district.district_name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#6b7280" }}>
|
||||||
|
Медиана:{" "}
|
||||||
|
<strong style={{ color: "#374151" }}>
|
||||||
|
{(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс ₽/м²
|
||||||
|
</strong>
|
||||||
|
<span style={{ marginLeft: 12 }}>
|
||||||
|
{(data.district.dist_to_center / 1000).toFixed(1)} км до центра
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{data.location && (
|
||||||
|
<div
|
||||||
|
style={{ fontSize: 12, color: "#9ca3af" }}
|
||||||
|
title={data.location.note}
|
||||||
|
>
|
||||||
|
До центра ЕКБ:{" "}
|
||||||
|
<strong style={{ color: "#374151" }}>
|
||||||
|
{data.location.distance_to_center_km.toFixed(1)} км
|
||||||
|
</strong>
|
||||||
|
{data.location.center_bonus > 0 && (
|
||||||
|
<span
|
||||||
|
style={{ color: "#16a34a", fontWeight: 600, marginLeft: 6 }}
|
||||||
|
>
|
||||||
|
(+{data.location.center_bonus.toFixed(1)} к score)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tram warning */}
|
||||||
|
{nearestTram !== null && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "10px 16px",
|
||||||
|
background: "#fff7ed",
|
||||||
|
border: "1px solid #fed7aa",
|
||||||
|
borderRadius: 10,
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#c2410c",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 16 }}>⚠</span>
|
||||||
|
<span>Трамвай в {nearestTram} м — возможный источник шума</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Score details */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#f9fafb",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 18px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#6b7280",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Балл
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#6b7280" }}>
|
||||||
|
{data.poi_count} POI ·{" "}
|
||||||
|
{data.source === "cad_quarter" ? "квартал" : "участок"}
|
||||||
|
</div>
|
||||||
|
{data.score_explanation && (
|
||||||
|
<div style={{ fontSize: 12, color: "#6b7280", fontStyle: "italic" }}>
|
||||||
|
{data.score_explanation}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.score_without_center !== undefined && (
|
||||||
|
<div style={{ fontSize: 12, color: "#9ca3af" }}>
|
||||||
|
Без бонуса центра: {data.score_without_center.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* POI breakdown */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 18px",
|
||||||
|
background: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#6b7280",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
POI по категориям
|
||||||
|
</div>
|
||||||
|
{Object.entries(data.score_breakdown).length === 0 ? (
|
||||||
|
<p style={{ fontSize: 13, color: "#9ca3af" }}>Нет данных</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 7 }}>
|
||||||
|
{Object.entries(data.score_breakdown).map(([cat, items]) => (
|
||||||
|
<div
|
||||||
|
key={cat}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{ color: cat === "tram_stop" ? "#dc2626" : "#374151" }}
|
||||||
|
>
|
||||||
|
{CATEGORY_LABELS[cat] ?? cat}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "#6b7280", fontSize: 12 }}>
|
||||||
|
{items.length} шт · ср. {avgDist(items)} м
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Isochrones */}
|
||||||
|
{data.isochrones_available === false ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 18px",
|
||||||
|
background: "#f9fafb",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#6b7280",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Изохроны доступности
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#9ca3af" }}>
|
||||||
|
Недоступны — нужен OpenRouteService API key (см.{" "}
|
||||||
|
<code style={{ fontFamily: "monospace" }}>
|
||||||
|
backend/.env.runtime
|
||||||
|
</code>
|
||||||
|
)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : data.isochrones_available === true && onIsochronesResult ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: 10,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IsochronesPanel
|
||||||
|
cadNum={data.cad_num}
|
||||||
|
onResult={onIsochronesResult}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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";
|
export {};
|
||||||
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<string, string> = {
|
|
||||||
school: "Школы",
|
|
||||||
kindergarten: "Детские сады",
|
|
||||||
pharmacy: "Аптеки",
|
|
||||||
hospital: "Больницы / клиники",
|
|
||||||
shop_mall: "ТЦ / ТРЦ",
|
|
||||||
shop_supermarket: "Супермаркеты",
|
|
||||||
shop_small: "Магазины",
|
|
||||||
park: "Парки",
|
|
||||||
tram_stop: "Трамвайные остановки",
|
|
||||||
bus_stop: "Автобусные остановки",
|
|
||||||
metro_stop: "Метро",
|
|
||||||
};
|
|
||||||
|
|
||||||
const SOURCE_TYPE_LABELS: Record<string, string> = {
|
|
||||||
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<ScoreLabel, string> = {
|
|
||||||
отлично: "#dcfce7",
|
|
||||||
хорошо: "#dbeafe",
|
|
||||||
средне: "#fef3c7",
|
|
||||||
плохо: "#fecaca",
|
|
||||||
};
|
|
||||||
|
|
||||||
const SCORE_LABEL_COLOR: Record<ScoreLabel, string> = {
|
|
||||||
отлично: "#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 (
|
|
||||||
<div style={{ borderTop: "1px solid #e5e7eb", padding: "12px 24px" }}>
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen((p) => !p)}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
width: "100%",
|
|
||||||
background: "none",
|
|
||||||
border: "none",
|
|
||||||
padding: 0,
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "#374151",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{title}</span>
|
|
||||||
<span style={{ color: "#9ca3af", fontSize: 16, lineHeight: 1 }}>
|
|
||||||
{open ? "−" : "+"}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{open && <div style={{ marginTop: 12 }}>{children}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Noise block ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function NoiseBlock({ noise }: { noise: ParcelAnalysisNoise }) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const top3 = noise.sources.slice(0, 3);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: 10,
|
|
||||||
padding: "14px 16px",
|
|
||||||
background: noiseBg(noise.score),
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>Шум</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: 800,
|
|
||||||
color: "#111827",
|
|
||||||
lineHeight: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
~{Math.round(noise.estimated_db)} dB
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 13, color: "#374151" }}>{noise.level}</div>
|
|
||||||
|
|
||||||
{top3.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen((p) => !p)}
|
|
||||||
style={{
|
|
||||||
background: "none",
|
|
||||||
border: "none",
|
|
||||||
padding: 0,
|
|
||||||
fontSize: 12,
|
|
||||||
color: "#6b7280",
|
|
||||||
cursor: "pointer",
|
|
||||||
textDecoration: "underline",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{open ? "Скрыть источники" : `Источники (${noise.sources.length})`}
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 8,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{top3.map((s, i) => (
|
|
||||||
<div key={i} style={{ fontSize: 12, color: "#374151" }}>
|
|
||||||
{SOURCE_TYPE_LABELS[s.source_type] ?? s.source_type}
|
|
||||||
{s.name ? ` «${s.name}»` : ""} — {Math.round(s.distance_m)} м
|
|
||||||
({Math.round(s.estimated_db)} dB)
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Air Quality block ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function AirQualityBlock({ aq }: { aq: ParcelAnalysisAirQuality | null }) {
|
|
||||||
if (!aq) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: 10,
|
|
||||||
padding: "14px 16px",
|
|
||||||
background: "#f3f4f6",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
|
||||||
Воздух
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 13, color: "#9ca3af" }}>Данные недоступны</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: 10,
|
|
||||||
padding: "14px 16px",
|
|
||||||
background: "#f0fdf4",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
|
||||||
Воздух
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
|
||||||
<AqBadge
|
|
||||||
label="PM2.5"
|
|
||||||
value={aq.pm2_5}
|
|
||||||
unit="мкг/м³"
|
|
||||||
color={pm25Color(aq.pm2_5)}
|
|
||||||
/>
|
|
||||||
<AqBadge
|
|
||||||
label="PM10"
|
|
||||||
value={aq.pm10}
|
|
||||||
unit="мкг/м³"
|
|
||||||
color={pm10Color(aq.pm10)}
|
|
||||||
/>
|
|
||||||
<AqBadge
|
|
||||||
label="NO₂"
|
|
||||||
value={aq.no2}
|
|
||||||
unit="мкг/м³"
|
|
||||||
color={no2Color(aq.no2)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 11, color: "#9ca3af" }}>
|
|
||||||
{aq.source} · {formatTs(aq.ts)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AqBadge({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
unit,
|
|
||||||
color,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
unit: string;
|
|
||||||
color: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: "6px 10px",
|
|
||||||
background: `${color}18`,
|
|
||||||
border: `1px solid ${color}55`,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
minWidth: 62,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 11, color: "#6b7280" }}>{label}</div>
|
|
||||||
<div style={{ fontSize: 17, fontWeight: 700, color, lineHeight: 1.2 }}>
|
|
||||||
{value.toFixed(1)}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 10, color: "#9ca3af" }}>{unit}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Wind block ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function WindArrow({ deg }: { deg: number }) {
|
|
||||||
// Arrow points "up" by default (north). Rotate to show wind coming FROM that direction.
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width="52"
|
|
||||||
height="52"
|
|
||||||
viewBox="0 0 52 52"
|
|
||||||
style={{ display: "block" }}
|
|
||||||
aria-label={`Направление ветра ${deg}°`}
|
|
||||||
>
|
|
||||||
{/* compass ring */}
|
|
||||||
<circle cx="26" cy="26" r="24" fill="#e5e7eb" />
|
|
||||||
{/* arrow group rotated to dominant_direction_deg */}
|
|
||||||
<g transform={`rotate(${deg}, 26, 26)`}>
|
|
||||||
{/* shaft */}
|
|
||||||
<rect x="24.5" y="10" width="3" height="24" rx="1.5" fill="#374151" />
|
|
||||||
{/* arrowhead */}
|
|
||||||
<polygon points="26,6 21,16 31,16" fill="#374151" />
|
|
||||||
{/* tail notch */}
|
|
||||||
<polygon points="26,34 22,44 30,44" fill="#9ca3af" />
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function WindBlock({ wind }: { wind: ParcelAnalysisWind | null }) {
|
|
||||||
if (!wind) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: 10,
|
|
||||||
padding: "14px 16px",
|
|
||||||
background: "#f3f4f6",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
|
||||||
Ветер
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 13, color: "#9ca3af" }}>Данные недоступны</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: 10,
|
|
||||||
padding: "14px 16px",
|
|
||||||
background: "#eff6ff",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
|
||||||
Ветер
|
|
||||||
</div>
|
|
||||||
<WindArrow deg={wind.dominant_direction_deg} />
|
|
||||||
<div style={{ fontSize: 13, color: "#374151" }}>
|
|
||||||
{wind.dominant_direction_label}
|
|
||||||
{wind.max_speed_m_s != null ? ` · до ${wind.max_speed_m_s} м/с` : ""}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 11, color: "#9ca3af" }}>
|
|
||||||
за {wind.forecast_days} дн.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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 (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: 10,
|
|
||||||
padding: "14px 16px",
|
|
||||||
background: "#f3f4f6",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
|
||||||
Погода / Климат
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 13, color: "#9ca3af" }}>Прогноз недоступен</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { temperature: t, wind } = weather;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: 10,
|
|
||||||
padding: "14px 16px",
|
|
||||||
background: "#eff6ff",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
|
||||||
Погода / Климат
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Temperature */}
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
||||||
<div style={{ fontSize: 11, color: "#6b7280" }}>Температура</div>
|
|
||||||
<div style={{ fontSize: 15, fontWeight: 700, color: "#111827" }}>
|
|
||||||
{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`
|
|
||||||
: "—"}
|
|
||||||
</div>
|
|
||||||
{(t.avg_min_c != null || t.avg_max_c != null) && (
|
|
||||||
<div style={{ fontSize: 11, color: "#6b7280" }}>
|
|
||||||
ср. {t.avg_min_c != null ? `${t.avg_min_c}` : "?"}
|
|
||||||
{"/"}
|
|
||||||
{t.avg_max_c != null ? `${t.avg_max_c}` : "?"}
|
|
||||||
°C
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Precipitation */}
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
||||||
<div style={{ fontSize: 11, color: "#6b7280" }}>Осадки</div>
|
|
||||||
<div style={{ fontSize: 14, fontWeight: 600, color: "#374151" }}>
|
|
||||||
{weather.precipitation_total_mm} мм
|
|
||||||
<span style={{ fontSize: 11, fontWeight: 400, color: "#6b7280" }}>
|
|
||||||
{" "}
|
|
||||||
(за {weather.forecast_days} дн.)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 11, color: "#6b7280" }}>
|
|
||||||
{weather.precipitation_days} дн. с осадками
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* UV */}
|
|
||||||
{weather.uv_index_max != null && (
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<div style={{ fontSize: 11, color: "#6b7280" }}>UV макс:</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: uvColor(weather.uv_index_max),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{weather.uv_index_max}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Wind */}
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<WindArrow deg={wind.dominant_direction_deg} />
|
|
||||||
<div style={{ fontSize: 13, color: "#374151" }}>
|
|
||||||
{wind.dominant_direction_label}
|
|
||||||
{wind.max_speed_m_s != null ? ` · до ${wind.max_speed_m_s} м/с` : ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Source — note shown only on hover */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
color: "#9ca3af",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
title={weather.note}
|
|
||||||
>
|
|
||||||
{weather.source} · 7 дн.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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 (
|
|
||||||
<div
|
|
||||||
title={location.note}
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
color: "#6b7280",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 4,
|
|
||||||
cursor: "help",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>📍</span>
|
|
||||||
<span>
|
|
||||||
До центра ЕКБ:{" "}
|
|
||||||
<strong style={{ color: "#374151" }}>
|
|
||||||
{location.distance_to_center_km.toFixed(1)} км
|
|
||||||
</strong>
|
|
||||||
</span>
|
|
||||||
{location.center_bonus > 0 && (
|
|
||||||
<span style={{ color, fontWeight: 600 }}>
|
|
||||||
(+{location.center_bonus.toFixed(1)} к score)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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 (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
border: "1px solid #e5e7eb",
|
|
||||||
borderRadius: 12,
|
|
||||||
overflow: "hidden",
|
|
||||||
background: "#fff",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Sticky score header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "sticky",
|
|
||||||
top: 0,
|
|
||||||
background: "#f9fafb",
|
|
||||||
borderBottom: "1px solid #e5e7eb",
|
|
||||||
padding: "12px 24px",
|
|
||||||
zIndex: 10,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
|
||||||
<span
|
|
||||||
title={data.score_explanation ?? undefined}
|
|
||||||
style={{
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: 800,
|
|
||||||
lineHeight: 1,
|
|
||||||
color: scoreColor(data.score),
|
|
||||||
cursor: data.score_explanation ? "help" : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{data.score.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
{data.score_max_reference !== undefined && (
|
|
||||||
<span style={{ fontSize: 13, color: "#6b7280" }}>
|
|
||||||
/ {data.score_max_reference.toFixed(0)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{scoreLabelTyped && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
background: badgeBg,
|
|
||||||
color: badgeFg,
|
|
||||||
padding: "2px 10px",
|
|
||||||
borderRadius: 999,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{scoreLabelTyped}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Score detail (expanded) */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "12px 24px",
|
|
||||||
borderBottom: "1px solid #e5e7eb",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 13, color: "#6b7280" }}>
|
|
||||||
Социальный балл участка
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12, color: "#9ca3af" }}>
|
|
||||||
{data.poi_count} POI ·{" "}
|
|
||||||
{data.source === "cad_quarter" ? "квартал" : "участок"}
|
|
||||||
</div>
|
|
||||||
{data.score_explanation && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
color: "#6b7280",
|
|
||||||
fontStyle: "italic",
|
|
||||||
maxWidth: 280,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{data.score_explanation}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{data.location && <CenterDistanceRow location={data.location} />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* District */}
|
|
||||||
{data.district && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "10px 24px",
|
|
||||||
borderBottom: "1px solid #e5e7eb",
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontWeight: 600 }}>{data.district.district_name}</span>
|
|
||||||
<span style={{ color: "#6b7280", marginLeft: 8 }}>
|
|
||||||
· медиана {(data.district.median_price_per_m2 / 1000).toFixed(0)}{" "}
|
|
||||||
тыс ₽/м²
|
|
||||||
</span>
|
|
||||||
<span style={{ color: "#9ca3af", marginLeft: 8 }}>
|
|
||||||
· {(data.district.dist_to_center / 1000).toFixed(1)} км до центра
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tram warning */}
|
|
||||||
{nearestTram !== null && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "8px 24px",
|
|
||||||
borderBottom: "1px solid #e5e7eb",
|
|
||||||
background: "#fff7ed",
|
|
||||||
fontSize: 13,
|
|
||||||
color: "#c2410c",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>⚠</span>
|
|
||||||
<span>Трамвай в {nearestTram} м (возможный источник шума)</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* POI breakdown */}
|
|
||||||
<div style={{ padding: "12px 24px", borderBottom: "1px solid #e5e7eb" }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "#6b7280",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: "0.05em",
|
|
||||||
marginBottom: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
POI по категориям
|
|
||||||
</div>
|
|
||||||
{Object.entries(data.score_breakdown).length === 0 ? (
|
|
||||||
<p style={{ fontSize: 13, color: "#9ca3af" }}>Нет данных</p>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|
||||||
{Object.entries(data.score_breakdown).map(([cat, items]) => (
|
|
||||||
<div
|
|
||||||
key={cat}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{ color: cat === "tram_stop" ? "#dc2626" : "#374151" }}
|
|
||||||
>
|
|
||||||
{CATEGORY_LABELS[cat] ?? cat}
|
|
||||||
</span>
|
|
||||||
<span style={{ color: "#6b7280", fontSize: 12 }}>
|
|
||||||
{items.length} шт · ср. {avgDist(items)} м
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Environmental factors (primary — open) */}
|
|
||||||
{hasEnvironmental && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "12px 24px 16px",
|
|
||||||
borderBottom: "1px solid #e5e7eb",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "#6b7280",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: "0.05em",
|
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Внешние факторы
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))",
|
|
||||||
gap: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{data.noise !== undefined ? (
|
|
||||||
data.noise !== null ? (
|
|
||||||
<NoiseBlock noise={data.noise} />
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: 10,
|
|
||||||
padding: "14px 16px",
|
|
||||||
background: "#f3f4f6",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}
|
|
||||||
>
|
|
||||||
Шум
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 13, color: "#9ca3af", marginTop: 8 }}>
|
|
||||||
Данные недоступны
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<AirQualityBlock aq={data.air_quality ?? null} />
|
|
||||||
|
|
||||||
{data.weather !== undefined ? (
|
|
||||||
<WeatherBlock weather={data.weather} />
|
|
||||||
) : (
|
|
||||||
<WindBlock wind={data.wind ?? null} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Market Trend (primary — open) */}
|
|
||||||
{"market_trend" in data && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "12px 24px 16px",
|
|
||||||
borderBottom: "1px solid #e5e7eb",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MarketTrendBlock trend={data.market_trend} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Isochrones (primary — open) */}
|
|
||||||
{data.isochrones_available === false ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "10px 24px 14px",
|
|
||||||
borderBottom: "1px solid #e5e7eb",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "#6b7280",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: "0.05em",
|
|
||||||
marginBottom: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Изохроны доступности
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12, color: "#9ca3af" }}>
|
|
||||||
Недоступны — нужен OpenRouteService API key (см.{" "}
|
|
||||||
<code style={{ fontFamily: "monospace" }}>
|
|
||||||
backend/.env.runtime
|
|
||||||
</code>
|
|
||||||
)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : data.isochrones_available === true && onIsochronesResult ? (
|
|
||||||
<div style={{ borderBottom: "1px solid #e5e7eb" }}>
|
|
||||||
<IsochronesPanel
|
|
||||||
cadNum={data.cad_num}
|
|
||||||
onResult={onIsochronesResult}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Success recommendation (secondary — collapsed) */}
|
|
||||||
{"success_recommendation" in data && (
|
|
||||||
<CollapsibleSection title="💎 Что хорошо продаётся">
|
|
||||||
<SuccessRecommendationBlock
|
|
||||||
recommendation={data.success_recommendation}
|
|
||||||
/>
|
|
||||||
</CollapsibleSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Seasonal weather (secondary — collapsed) */}
|
|
||||||
{"seasonal_weather" in data && (
|
|
||||||
<CollapsibleSection title="🌡 Климат (нормали 30 лет)">
|
|
||||||
<SeasonalWeatherBlock seasonal={data.seasonal_weather} />
|
|
||||||
</CollapsibleSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Hydrology (secondary — collapsed) */}
|
|
||||||
{data.hydrology !== undefined && (
|
|
||||||
<CollapsibleSection title="💧 Гидрология">
|
|
||||||
<HydrologyBlock hydrology={data.hydrology} />
|
|
||||||
</CollapsibleSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Geotech risk (secondary — collapsed) */}
|
|
||||||
{data.geotech_risk !== undefined && (
|
|
||||||
<CollapsibleSection title="🏔 Геотехнический риск">
|
|
||||||
<GeotechRiskBlock risk={data.geotech_risk} />
|
|
||||||
</CollapsibleSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Geology (secondary — collapsed) */}
|
|
||||||
{data.geology !== undefined && (
|
|
||||||
<CollapsibleSection title="⛰ Геология">
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "#6b7280",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: "0.05em",
|
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Геология
|
|
||||||
</div>
|
|
||||||
<GeologyBlock geology={data.geology} />
|
|
||||||
</CollapsibleSection>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue