Многоагентный аудит + имплементация: один воркер на файл, точечные правки. Верификация: 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>
498 lines
14 KiB
TypeScript
498 lines
14 KiB
TypeScript
"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>
|
||
);
|
||
}
|