From 607be225b0a68abc74386d9319e1b29ece717555 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Mon, 18 May 2026 04:07:48 +0000 Subject: [PATCH] =?UTF-8?q?feat(sf-fe-a11):=20Section=205=20=C2=AB=D0=90?= =?UTF-8?q?=D1=82=D0=BC=D0=BE=D1=81=D1=84=D0=B5=D1=80=D0=B0=20/=20=D0=B2?= =?UTF-8?q?=D0=BE=D0=B7=D0=B4=D1=83=D1=85=C2=BB=20(#361)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis/[cad]/AnalysisPageContent.tsx | 11 +- .../analysis/Section5Atmosphere.tsx | 673 ++++++++++++++++++ 2 files changed, 676 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/site-finder/analysis/Section5Atmosphere.tsx diff --git a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx index 70430f4d..03b193f3 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx @@ -6,6 +6,7 @@ import { Section1ParcelInfo } from "@/components/site-finder/analysis/Section1Pa import { Section2NetworksUtilities } from "@/components/site-finder/analysis/Section2NetworksUtilities"; import { Section3SettingsAndCompetitors } from "@/components/site-finder/analysis/Section3SettingsAndCompetitors"; import { Section4Estimate } from "@/components/site-finder/analysis/Section4Estimate"; +import { Section5Atmosphere } from "@/components/site-finder/analysis/Section5Atmosphere"; import { useParcelAnalyzeQuery } from "@/lib/site-finder-api"; import type { ParcelAnalysis } from "@/types/site-finder"; @@ -131,14 +132,8 @@ export function AnalysisPageContent({ cad }: Props) { {/* Section 4 — IMPLEMENTED in A10 */} - {/* Section 5 placeholder — filled by A11 */} -
- -
+ {/* Section 5 — IMPLEMENTED in A11 */} + diff --git a/frontend/src/components/site-finder/analysis/Section5Atmosphere.tsx b/frontend/src/components/site-finder/analysis/Section5Atmosphere.tsx new file mode 100644 index 00000000..cd4b5f23 --- /dev/null +++ b/frontend/src/components/site-finder/analysis/Section5Atmosphere.tsx @@ -0,0 +1,673 @@ +"use client"; + +/** + * Section5Atmosphere — "5. Атмосфера / воздух" + * + * Layout: + * HeadlineBar (title "5. Атмосфера" + subtitle summary) + * Responsive grid repeat(auto-fit, minmax(220px, 1fr)): + * NoiseBlock — conditional on analyze.noise + * AirQualityBlock — conditional on analyze.air_quality + * WindBlock — conditional on analyze.wind / analyze.weather.wind + * SeasonalWeatherBlock — conditional on analyze.seasonal_weather + * Graceful empty state if all fields null/absent + * + * Data: useParcelAnalyzeQuery (canonical cache key ["parcel-analyze", cad]). + * No duplicate queries — TanStack shares cache with other sections. + */ + +import { Wind, Thermometer, Droplets } from "lucide-react"; + +import { HeadlineBar } from "@/components/ui/HeadlineBar"; +import { SeasonalWeatherBlock } from "@/components/site-finder/SeasonalWeatherBlock"; +import { useParcelAnalyzeQuery } from "@/lib/site-finder-api"; +import type { ParcelAnalysis } from "@/types/site-finder"; +import type { + ParcelAnalysisNoise, + ParcelAnalysisAirQuality, + ParcelAnalysisWind, +} from "@/types/site-finder"; + +// ── Props ───────────────────────────────────────────────────────────────────── + +interface Props { + cad: string; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const NOISE_SOURCE_LABELS: Record = { + highway: "Магистраль", + railway: "Ж/д", + industrial: "Производство", + aerodrome: "Аэродром", +}; + +function noiseBg(score: number): string { + if (score >= 0.7) return "var(--success-soft, #DCFCE7)"; + if (score >= 0.4) return "var(--warn-soft, #FEF3C7)"; + return "var(--danger-soft, #FEE2E2)"; +} + +function pm25Color(v: number): string { + if (v < 10) return "var(--success, #0A7A3A)"; + if (v < 25) return "var(--warn, #9A6700)"; + if (v < 50) return "#ea580c"; + return "var(--danger, #B3261E)"; +} + +function pm10Color(v: number): string { + if (v < 20) return "var(--success, #0A7A3A)"; + if (v < 50) return "var(--warn, #9A6700)"; + if (v < 100) return "#ea580c"; + return "var(--danger, #B3261E)"; +} + +function no2Color(v: number): string { + if (v < 40) return "var(--success, #0A7A3A)"; + if (v < 100) return "var(--warn, #9A6700)"; + return "var(--danger, #B3261E)"; +} + +function uvColor(uv: number): string { + if (uv < 3) return "var(--success, #0A7A3A)"; + if (uv < 6) return "var(--warn, #9A6700)"; + if (uv < 8) return "#ea580c"; + return "var(--danger, #B3261E)"; +} + +function formatTs(iso: string): string { + try { + return new Date(iso).toLocaleDateString("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + } catch { + return iso; + } +} + +// ── WindArrow SVG ───────────────────────────────────────────────────────────── + +function WindArrow({ deg }: { deg: number }) { + return ( + + + + + + + + + ); +} + +// ── NoiseBlock ──────────────────────────────────────────────────────────────── + +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) => ( +
+ {NOISE_SOURCE_LABELS[s.source_type] ?? s.source_type} + {s.name ? ` «${s.name}»` : ""} — {Math.round(s.distance_m)} м ( + {Math.round(s.estimated_db)} dB) +
+ ))} +
+ )} +
+ ); +} + +// ── AirQualityBlock ─────────────────────────────────────────────────────────── + +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 }) { + return ( +
+
+ Воздух +
+
+ + + +
+
+ {aq.source} · {formatTs(aq.ts)} +
+
+ ); +} + +// ── WindBlock ───────────────────────────────────────────────────────────────── + +function WindBlock({ wind }: { wind: ParcelAnalysisWind }) { + return ( +
+
+ Ветер +
+ +
+ {wind.dominant_direction_label} + {wind.max_speed_m_s != null ? ` · до ${wind.max_speed_m_s} м/с` : ""} +
+
+ за {wind.forecast_days} дн. +
+
+ ); +} + +// ── WeatherWindBlock (from weather.wind when no standalone wind field) ──────── + +function WeatherWindBlock({ + wind, + uvMax, + precipMm, + precipDays, + forecastDays, + source, +}: { + wind: ParcelAnalysisWind; + uvMax: number | null | undefined; + precipMm: number; + precipDays: number; + forecastDays: number; + source: string; +}) { + return ( +
+
+ Погода / Ветер +
+
+ +
+ {wind.dominant_direction_label} + {wind.max_speed_m_s != null ? ` · до ${wind.max_speed_m_s} м/с` : ""} +
+
+
+ + {precipMm} мм · {precipDays} дн. с осадками за {forecastDays} дн. +
+ {uvMax != null && ( +
+ UV макс:  + + {uvMax} + +
+ )} +
+ {source} · {forecastDays} дн. +
+
+ ); +} + +// ── Empty-state card ────────────────────────────────────────────────────────── + +function EmptyCard({ label }: { label: string }) { + return ( +
+
+ {label} +
+
+ Данные недоступны +
+
+ ); +} + +// ── Headline helpers ────────────────────────────────────────────────────────── + +function buildHeadlineTitle(analysis: ParcelAnalysis): string { + const parts: string[] = []; + + if (analysis.noise != null) { + const level = analysis.noise.level; + parts.push(`Шум: ${level}`); + } + + if (analysis.air_quality != null) { + const pm = analysis.air_quality.pm2_5.toFixed(1); + parts.push(`PM2.5: ${pm} мкг/м³`); + } + + if (parts.length === 0) return "5. Атмосфера"; + return `5. Атмосфера · ${parts.join(" · ")}`; +} + +function buildHeadlineSubtitle(analysis: ParcelAnalysis): string | undefined { + const parts: string[] = []; + + if (analysis.wind != null) { + parts.push(`Ветер: ${analysis.wind.dominant_direction_label}`); + } else if (analysis.weather?.wind != null) { + parts.push(`Ветер: ${analysis.weather.wind.dominant_direction_label}`); + } + + if (analysis.seasonal_weather != null) { + parts.push(`Климат: ${analysis.seasonal_weather.period}`); + } + + if (parts.length === 0) return undefined; + return parts.join(" · "); +} + +// ── Section5Atmosphere ──────────────────────────────────────────────────────── + +export function Section5Atmosphere({ cad }: Props) { + const { data, isLoading, error } = useParcelAnalyzeQuery(cad); + + // ── Loading skeleton ─────────────────────────────────────────────────────── + + if (isLoading) { + return ( +
+
+
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+
+ ); + } + + // ── Error state ──────────────────────────────────────────────────────────── + + if (error || !data) { + return ( +
+ +
+ {error instanceof Error ? error.message : "Ошибка загрузки данных"} +
+
+ ); + } + + // Cast to ParcelAnalysis (shared cache with other sections) + const analysis = data as unknown as ParcelAnalysis; + + const hasNoise = analysis.noise != null; + const hasAirQuality = analysis.air_quality != null; + const hasWind = analysis.wind != null; + const hasWeather = analysis.weather != null; + const hasSeasonal = analysis.seasonal_weather != null; + + const hasAny = + hasNoise || hasAirQuality || hasWind || hasWeather || hasSeasonal; + + const headlineTitle = buildHeadlineTitle(analysis); + const headlineSubtitle = buildHeadlineSubtitle(analysis); + + return ( +
+ {/* HeadlineBar */} + + + {hasAny ? ( +
+ {/* Primary blocks grid */} +
+ {/* Noise */} + {hasNoise ? ( + + ) : ( + + )} + + {/* Air quality */} + {hasAirQuality ? ( + + ) : ( + + )} + + {/* Wind — prefer standalone wind, fall back to weather.wind */} + {hasWind ? ( + + ) : hasWeather && analysis.weather!.wind != null ? ( + + ) : ( + + )} +
+ + {/* Seasonal weather (climate normals) */} + {hasSeasonal && ( +
+
+ + Климат (нормали 30 лет) +
+ +
+ )} +
+ ) : ( + /* Full empty state — all fields null */ +
+ + Данные об атмосфере недоступны для этого участка +
+ )} +
+ ); +}