feat(sf-fe-a11): Section 5 «Атмосфера / воздух» (#361)
This commit is contained in:
parent
1bbd4e835d
commit
607be225b0
2 changed files with 676 additions and 8 deletions
|
|
@ -6,6 +6,7 @@ import { Section1ParcelInfo } from "@/components/site-finder/analysis/Section1Pa
|
||||||
import { Section2NetworksUtilities } from "@/components/site-finder/analysis/Section2NetworksUtilities";
|
import { Section2NetworksUtilities } from "@/components/site-finder/analysis/Section2NetworksUtilities";
|
||||||
import { Section3SettingsAndCompetitors } from "@/components/site-finder/analysis/Section3SettingsAndCompetitors";
|
import { Section3SettingsAndCompetitors } from "@/components/site-finder/analysis/Section3SettingsAndCompetitors";
|
||||||
import { Section4Estimate } from "@/components/site-finder/analysis/Section4Estimate";
|
import { Section4Estimate } from "@/components/site-finder/analysis/Section4Estimate";
|
||||||
|
import { Section5Atmosphere } from "@/components/site-finder/analysis/Section5Atmosphere";
|
||||||
import { useParcelAnalyzeQuery } from "@/lib/site-finder-api";
|
import { useParcelAnalyzeQuery } from "@/lib/site-finder-api";
|
||||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||||
|
|
||||||
|
|
@ -131,14 +132,8 @@ export function AnalysisPageContent({ cad }: Props) {
|
||||||
{/* Section 4 — IMPLEMENTED in A10 */}
|
{/* Section 4 — IMPLEMENTED in A10 */}
|
||||||
<Section4Estimate cad={cad} />
|
<Section4Estimate cad={cad} />
|
||||||
|
|
||||||
{/* Section 5 placeholder — filled by A11 */}
|
{/* Section 5 — IMPLEMENTED in A11 */}
|
||||||
<section id="section-5" style={{ scrollMarginTop: 72 }}>
|
<Section5Atmosphere cad={cad} />
|
||||||
<SectionPlaceholder
|
|
||||||
number="5"
|
|
||||||
title="Атмосфера / воздух"
|
|
||||||
note="Заполняется в A11: SeasonalWeather, air quality"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<svg
|
||||||
|
width="40"
|
||||||
|
height="40"
|
||||||
|
viewBox="0 0 52 52"
|
||||||
|
style={{ display: "block", flexShrink: 0 }}
|
||||||
|
aria-label={`Направление ветра ${deg}°`}
|
||||||
|
>
|
||||||
|
<circle cx="26" cy="26" r="24" fill="var(--border-soft, #EEF0F3)" />
|
||||||
|
<g transform={`rotate(${deg}, 26, 26)`}>
|
||||||
|
<rect
|
||||||
|
x="24.5"
|
||||||
|
y="10"
|
||||||
|
width="3"
|
||||||
|
height="24"
|
||||||
|
rx="1.5"
|
||||||
|
fill="var(--fg-primary, #111111)"
|
||||||
|
/>
|
||||||
|
<polygon points="26,6 21,16 31,16" fill="var(--fg-primary, #111111)" />
|
||||||
|
<polygon
|
||||||
|
points="26,34 22,44 30,44"
|
||||||
|
fill="var(--fg-tertiary, #73767E)"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NoiseBlock ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function NoiseBlock({ noise }: { noise: ParcelAnalysisNoise }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: noiseBg(noise.score),
|
||||||
|
border: "1px solid var(--border-soft, #EEF0F3)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--fg-secondary, #5B6066)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Шум
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--fg-primary, #111111)",
|
||||||
|
lineHeight: 1,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
~{Math.round(noise.estimated_db)} dB
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "var(--fg-primary, #111111)" }}>
|
||||||
|
{noise.level}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{noise.sources.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 4,
|
||||||
|
marginTop: 4,
|
||||||
|
paddingTop: 8,
|
||||||
|
borderTop: "1px solid var(--border-soft, #EEF0F3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--fg-secondary, #5B6066)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Источники ({noise.sources.length})
|
||||||
|
</div>
|
||||||
|
{noise.sources.slice(0, 5).map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{ fontSize: 12, color: "var(--fg-primary, #111111)" }}
|
||||||
|
>
|
||||||
|
{NOISE_SOURCE_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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AirQualityBlock ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AqBadge({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
unit,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
unit: string;
|
||||||
|
color: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "8px 12px",
|
||||||
|
background: "var(--bg-app, #F6F7F9)",
|
||||||
|
border: "1px solid var(--border-card, #E6E8EC)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
minWidth: 68,
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--fg-secondary, #5B6066)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 700,
|
||||||
|
color,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: "var(--fg-tertiary, #73767E)" }}>
|
||||||
|
{unit}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AirQualityBlock({ aq }: { aq: ParcelAnalysisAirQuality }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: "var(--bg-card, #FFFFFF)",
|
||||||
|
border: "1px solid var(--border-card, #E6E8EC)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--fg-secondary, #5B6066)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Воздух
|
||||||
|
</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: "var(--fg-tertiary, #73767E)" }}>
|
||||||
|
{aq.source} · {formatTs(aq.ts)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WindBlock ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function WindBlock({ wind }: { wind: ParcelAnalysisWind }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: "var(--bg-card, #FFFFFF)",
|
||||||
|
border: "1px solid var(--border-card, #E6E8EC)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--fg-secondary, #5B6066)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ветер
|
||||||
|
</div>
|
||||||
|
<WindArrow deg={wind.dominant_direction_deg} />
|
||||||
|
<div style={{ fontSize: 13, color: "var(--fg-primary, #111111)" }}>
|
||||||
|
{wind.dominant_direction_label}
|
||||||
|
{wind.max_speed_m_s != null ? ` · до ${wind.max_speed_m_s} м/с` : ""}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--fg-tertiary, #73767E)" }}>
|
||||||
|
за {wind.forecast_days} дн.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: "var(--bg-card, #FFFFFF)",
|
||||||
|
border: "1px solid var(--border-card, #E6E8EC)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--fg-secondary, #5B6066)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Погода / Ветер
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
<WindArrow deg={wind.dominant_direction_deg} />
|
||||||
|
<div style={{ fontSize: 13, color: "var(--fg-primary, #111111)" }}>
|
||||||
|
{wind.dominant_direction_label}
|
||||||
|
{wind.max_speed_m_s != null ? ` · до ${wind.max_speed_m_s} м/с` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--fg-secondary, #5B6066)" }}>
|
||||||
|
<Droplets
|
||||||
|
size={12}
|
||||||
|
style={{ display: "inline", marginRight: 4, verticalAlign: "middle" }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{precipMm} мм · {precipDays} дн. с осадками за {forecastDays} дн.
|
||||||
|
</div>
|
||||||
|
{uvMax != null && (
|
||||||
|
<div style={{ fontSize: 12, color: "var(--fg-secondary, #5B6066)" }}>
|
||||||
|
UV макс:
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontWeight: 700,
|
||||||
|
color: uvColor(uvMax),
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{uvMax}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: 11, color: "var(--fg-tertiary, #73767E)" }}>
|
||||||
|
{source} · {forecastDays} дн.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty-state card ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function EmptyCard({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: "var(--bg-card-alt, #FAFBFC)",
|
||||||
|
border: "1px solid var(--border-soft, #EEF0F3)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--fg-secondary, #5B6066)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "var(--fg-tertiary, #73767E)" }}>
|
||||||
|
Данные недоступны
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<section id="section-5" style={{ scrollMarginTop: 72 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-card-alt, #FAFBFC)",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "14px 18px",
|
||||||
|
marginBottom: 12,
|
||||||
|
height: 48,
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
background: "var(--bg-card-alt, #FAFBFC)",
|
||||||
|
border: "1px solid var(--border-soft, #EEF0F3)",
|
||||||
|
height: 120,
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Error state ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<section id="section-5" style={{ scrollMarginTop: 72 }}>
|
||||||
|
<HeadlineBar title="5. Атмосфера" />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
padding: "16px 20px",
|
||||||
|
background: "var(--danger-soft, #FEE2E2)",
|
||||||
|
border: "1px solid var(--danger, #B3261E)",
|
||||||
|
borderRadius: 10,
|
||||||
|
color: "var(--danger, #B3261E)",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error instanceof Error ? error.message : "Ошибка загрузки данных"}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<section id="section-5" style={{ scrollMarginTop: 72 }}>
|
||||||
|
{/* HeadlineBar */}
|
||||||
|
<HeadlineBar title={headlineTitle} subtitle={headlineSubtitle} />
|
||||||
|
|
||||||
|
{hasAny ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Primary blocks grid */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Noise */}
|
||||||
|
{hasNoise ? (
|
||||||
|
<NoiseBlock noise={analysis.noise!} />
|
||||||
|
) : (
|
||||||
|
<EmptyCard label="Шум" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Air quality */}
|
||||||
|
{hasAirQuality ? (
|
||||||
|
<AirQualityBlock aq={analysis.air_quality!} />
|
||||||
|
) : (
|
||||||
|
<EmptyCard label="Воздух" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Wind — prefer standalone wind, fall back to weather.wind */}
|
||||||
|
{hasWind ? (
|
||||||
|
<WindBlock wind={analysis.wind!} />
|
||||||
|
) : hasWeather && analysis.weather!.wind != null ? (
|
||||||
|
<WeatherWindBlock
|
||||||
|
wind={analysis.weather!.wind}
|
||||||
|
uvMax={analysis.weather!.uv_index_max}
|
||||||
|
precipMm={analysis.weather!.precipitation_total_mm}
|
||||||
|
precipDays={analysis.weather!.precipitation_days}
|
||||||
|
forecastDays={analysis.weather!.forecast_days}
|
||||||
|
source={analysis.weather!.source}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyCard label="Ветер" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Seasonal weather (climate normals) */}
|
||||||
|
{hasSeasonal && (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--fg-secondary, #5B6066)",
|
||||||
|
marginBottom: 8,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Thermometer size={12} aria-hidden />
|
||||||
|
Климат (нормали 30 лет)
|
||||||
|
</div>
|
||||||
|
<SeasonalWeatherBlock seasonal={analysis.seasonal_weather} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Full empty state — all fields null */
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "32px 24px",
|
||||||
|
background: "var(--bg-card-alt, #FAFBFC)",
|
||||||
|
border: "1px dashed var(--border-strong, #D1D5DB)",
|
||||||
|
textAlign: "center",
|
||||||
|
color: "var(--fg-tertiary, #73767E)",
|
||||||
|
fontSize: 13,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Wind size={20} aria-hidden />
|
||||||
|
Данные об атмосфере недоступны для этого участка
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue