gendesign/frontend/src/components/site-finder/EnvironmentTab.tsx
Light1YT 86e9ea2937 fix(week-review): автофиксы код-ревью — 169 issue (label «week ревью 1»)
Многоагентный аудит + имплементация: один воркер на файл, точечные правки.
Верификация: py_compile (47/47 .py) + tsc --noEmit (0 ошибок). Unit-тесты
не прогонялись (окружение не поднято: rollup native dep / нет pytest-venv).

Полностью исправлено (169): #1336, #1337, #1339, #1340, #1341, #1342, #1343, #1345, #1346, #1348, #1349, #1350, #1351, #1354, #1356, #1358, #1359, #1360, #1362, #1364, #1365, #1366, #1367, #1368, #1369, #1370, #1371, #1372, #1373, #1374, #1375, #1376, #1377, #1378, #1379, #1380, #1381, #1382, #1384, #1385, #1386, #1387, #1388, #1389, #1390, #1391, #1392, #1394, #1395, #1396, #1397, #1399, #1400, #1401, #1402, #1403, #1404, #1408, #1409, #1410, #1411, #1412, #1413, #1414, #1415, #1416, #1417, #1418, #1420, #1423, #1425, #1426, #1427, #1428, #1429, #1430, #1431, #1432, #1433, #1434, #1435, #1437, #1438, #1439, #1440, #1441, #1442, #1443, #1444, #1445, #1446, #1447, #1448, #1449, #1450, #1451, #1452, #1453, #1454, #1455, #1456, #1457, #1458, #1459, #1460, #1461, #1462, #1463, #1464, #1465, #1466, #1467, #1468, #1469, #1471, #1472, #1473, #1474, #1476, #1478, #1479, #1481, #1482, #1483, #1484, #1485, #1487, #1488, #1489, #1490, #1491, #1492, #1493, #1494, #1495, #1496, #1497, #1499, #1500, #1501, #1502, #1504, #1505, #1506, #1507, #1510, #1514, #1515, #1516, #1517, #1518, #1519, #1521, #1522, #1523, #1524, #1525, #1526, #1527, #1528, #1529, #1531, #1532, #1533, #1534, #1535, #1536, #1537, #1538

Частично (9, in-file часть, остаток cross-file): #1361, #1419, #1422, #1424, #1470, #1475, #1477, #1480, #1498
Требуют cross-file (3, не тронуты): #1338, #1363, #1421
Пропущено (1): #1539

Не входило в партию: 22 needs-Leha issue (нужны решения владельца).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:21:11 +05:00

498 lines
14 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";
import type {
ParcelAnalysis,
ParcelAnalysisNoise,
ParcelAnalysisAirQuality,
ParcelAnalysisWind,
ParcelAnalysisWeather,
} from "@/types/site-finder";
import { SectionLabel } from "@/components/ui/SectionLabel";
import { EmptyState } from "@/components/ui/EmptyState";
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} · {weather.forecast_days} дн.
</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>
<SectionLabel style={{ marginBottom: 12 }}>
Внешние факторы
</SectionLabel>
<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>
<SectionLabel style={{ marginBottom: 12 }}>
Климат (нормали 30 лет)
</SectionLabel>
<SeasonalWeatherBlock seasonal={data.seasonal_weather} />
</div>
)}
{/* Hydrology */}
{data.hydrology !== undefined && (
<div>
<SectionLabel style={{ marginBottom: 12 }}>Гидрология</SectionLabel>
<HydrologyBlock hydrology={data.hydrology} />
</div>
)}
{!hasEnv &&
!("seasonal_weather" in data) &&
data.hydrology === undefined && (
<EmptyState message="Данные об окружающей среде недоступны" />
)}
</div>
);
}