gendesign/frontend/src/components/site-finder/analysis/Section5Atmosphere.tsx
lekss361 607be225b0
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m50s
Deploy / deploy (push) Successful in 40s
feat(sf-fe-a11): Section 5 «Атмосфера / воздух» (#361)
2026-05-18 04:07:48 +00:00

673 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 макс:&nbsp;
<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>
);
}