587 lines
16 KiB
TypeScript
587 lines
16 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import type {
|
||
ParcelAnalysis,
|
||
ParcelAnalysisNoise,
|
||
ParcelAnalysisAirQuality,
|
||
ParcelAnalysisWind,
|
||
} from "@/types/site-finder";
|
||
import { MarketTrendBlock } from "./MarketTrendBlock";
|
||
|
||
interface Props {
|
||
data: ParcelAnalysis;
|
||
}
|
||
|
||
const CATEGORY_LABELS: Record<string, string> = {
|
||
school: "Школы",
|
||
kindergarten: "Детские сады",
|
||
pharmacy: "Аптеки",
|
||
hospital: "Больницы / клиники",
|
||
shop_mall: "ТЦ / ТРЦ",
|
||
shop_supermarket: "Супермаркеты",
|
||
shop_small: "Магазины",
|
||
park: "Парки",
|
||
tram_stop: "Трамвайные остановки",
|
||
bus_stop: "Автобусные остановки",
|
||
metro_stop: "Метро",
|
||
};
|
||
|
||
const SOURCE_TYPE_LABELS: Record<string, string> = {
|
||
highway: "Магистраль",
|
||
railway: "Ж/д",
|
||
industrial: "Производство",
|
||
aerodrome: "Аэродром",
|
||
};
|
||
|
||
function scoreColor(score: number): string {
|
||
if (score > 5) return "#16a34a";
|
||
if (score >= 2) return "#d97706";
|
||
return "#dc2626";
|
||
}
|
||
|
||
type ScoreLabel = "плохо" | "средне" | "хорошо" | "отлично";
|
||
|
||
const SCORE_LABEL_BG: Record<ScoreLabel, string> = {
|
||
отлично: "#dcfce7",
|
||
хорошо: "#dbeafe",
|
||
средне: "#fef3c7",
|
||
плохо: "#fecaca",
|
||
};
|
||
|
||
const SCORE_LABEL_COLOR: Record<ScoreLabel, string> = {
|
||
отлично: "#15803d",
|
||
хорошо: "#1d4ed8",
|
||
средне: "#b45309",
|
||
плохо: "#b91c1c",
|
||
};
|
||
|
||
function avgDist(items: Array<{ distance_m: number }>): number {
|
||
if (!items.length) return 0;
|
||
return Math.round(items.reduce((s, i) => s + i.distance_m, 0) / items.length);
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
// ── Noise block ───────────────────────────────────────────────────────────────
|
||
|
||
function NoiseBlock({ noise }: { noise: ParcelAnalysisNoise }) {
|
||
const [open, setOpen] = useState(false);
|
||
const top3 = noise.sources.slice(0, 3);
|
||
|
||
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: 22,
|
||
fontWeight: 800,
|
||
color: "#111827",
|
||
lineHeight: 1,
|
||
}}
|
||
>
|
||
~{Math.round(noise.estimated_db)} dB
|
||
</div>
|
||
<div style={{ fontSize: 13, color: "#374151" }}>{noise.level}</div>
|
||
|
||
{top3.length > 0 && (
|
||
<div>
|
||
<button
|
||
onClick={() => setOpen((p) => !p)}
|
||
style={{
|
||
background: "none",
|
||
border: "none",
|
||
padding: 0,
|
||
fontSize: 12,
|
||
color: "#6b7280",
|
||
cursor: "pointer",
|
||
textDecoration: "underline",
|
||
}}
|
||
>
|
||
{open ? "Скрыть источники" : `Источники (${noise.sources.length})`}
|
||
</button>
|
||
{open && (
|
||
<div
|
||
style={{
|
||
marginTop: 8,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 4,
|
||
}}
|
||
>
|
||
{top3.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>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Air Quality block ─────────────────────────────────────────────────────────
|
||
|
||
function AirQualityBlock({ aq }: { aq: ParcelAnalysisAirQuality | null }) {
|
||
if (!aq) {
|
||
return (
|
||
<div
|
||
style={{
|
||
borderRadius: 10,
|
||
padding: "14px 16px",
|
||
background: "#f3f4f6",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
||
Воздух
|
||
</div>
|
||
<div style={{ fontSize: 13, color: "#9ca3af" }}>Данные недоступны</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>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
// ── Wind block ────────────────────────────────────────────────────────────────
|
||
|
||
function WindArrow({ deg }: { deg: number }) {
|
||
// Arrow points "up" by default (north). Rotate to show wind coming FROM that direction.
|
||
return (
|
||
<svg
|
||
width="52"
|
||
height="52"
|
||
viewBox="0 0 52 52"
|
||
style={{ display: "block" }}
|
||
aria-label={`Направление ветра ${deg}°`}
|
||
>
|
||
{/* compass ring */}
|
||
<circle cx="26" cy="26" r="24" fill="#e5e7eb" />
|
||
{/* arrow group rotated to dominant_direction_deg */}
|
||
<g transform={`rotate(${deg}, 26, 26)`}>
|
||
{/* shaft */}
|
||
<rect x="24.5" y="10" width="3" height="24" rx="1.5" fill="#374151" />
|
||
{/* arrowhead */}
|
||
<polygon points="26,6 21,16 31,16" fill="#374151" />
|
||
{/* tail notch */}
|
||
<polygon points="26,34 22,44 30,44" fill="#9ca3af" />
|
||
</g>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function WindBlock({ wind }: { wind: ParcelAnalysisWind | null }) {
|
||
if (!wind) {
|
||
return (
|
||
<div
|
||
style={{
|
||
borderRadius: 10,
|
||
padding: "14px 16px",
|
||
background: "#f3f4f6",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
||
Ветер
|
||
</div>
|
||
<div style={{ fontSize: 13, color: "#9ca3af" }}>Данные недоступны</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>
|
||
);
|
||
}
|
||
|
||
// ── ScoreCard ─────────────────────────────────────────────────────────────────
|
||
|
||
export function ScoreCard({ data }: Props) {
|
||
const tramPois = data.score_breakdown["tram_stop"] ?? [];
|
||
const nearestTram =
|
||
tramPois.length > 0
|
||
? Math.round(Math.min(...tramPois.map((p) => p.distance_m)))
|
||
: null;
|
||
|
||
const hasEnvironmental =
|
||
data.noise !== undefined ||
|
||
data.air_quality !== undefined ||
|
||
data.wind !== undefined;
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
border: "1px solid #e5e7eb",
|
||
borderRadius: 12,
|
||
overflow: "hidden",
|
||
background: "#fff",
|
||
}}
|
||
>
|
||
{/* Score header */}
|
||
<div
|
||
style={{
|
||
padding: "20px 24px",
|
||
background: "#f9fafb",
|
||
borderBottom: "1px solid #e5e7eb",
|
||
display: "flex",
|
||
alignItems: "flex-start",
|
||
gap: 16,
|
||
}}
|
||
>
|
||
<div
|
||
title={data.score_explanation ?? undefined}
|
||
style={{
|
||
fontSize: 48,
|
||
fontWeight: 800,
|
||
lineHeight: 1,
|
||
color: scoreColor(data.score),
|
||
cursor: data.score_explanation ? "help" : undefined,
|
||
}}
|
||
>
|
||
{data.score.toFixed(2)}
|
||
</div>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||
{/* score_max_reference + label badge */}
|
||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||
{data.score_max_reference !== undefined && (
|
||
<span style={{ fontSize: 14, color: "#6b7280" }}>
|
||
/ {data.score_max_reference.toFixed(0)}
|
||
</span>
|
||
)}
|
||
{data.score_label && (
|
||
<span
|
||
style={{
|
||
borderRadius: 6,
|
||
padding: "2px 10px",
|
||
background: SCORE_LABEL_BG[data.score_label],
|
||
color: SCORE_LABEL_COLOR[data.score_label],
|
||
fontSize: 13,
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
{data.score_label}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div style={{ fontSize: 13, color: "#6b7280" }}>
|
||
Социальный балл участка
|
||
</div>
|
||
<div style={{ fontSize: 12, color: "#9ca3af" }}>
|
||
{data.poi_count} POI ·{" "}
|
||
{data.source === "cad_quarter" ? "квартал" : "участок"}
|
||
</div>
|
||
{data.score_explanation && (
|
||
<div
|
||
style={{
|
||
fontSize: 12,
|
||
color: "#6b7280",
|
||
fontStyle: "italic",
|
||
maxWidth: 280,
|
||
}}
|
||
>
|
||
{data.score_explanation}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* District */}
|
||
{data.district && (
|
||
<div
|
||
style={{
|
||
padding: "12px 24px",
|
||
borderBottom: "1px solid #e5e7eb",
|
||
fontSize: 13,
|
||
}}
|
||
>
|
||
<span style={{ fontWeight: 600 }}>{data.district.district_name}</span>
|
||
<span style={{ color: "#6b7280", marginLeft: 8 }}>
|
||
· медиана {(data.district.median_price_per_m2 / 1000).toFixed(0)}{" "}
|
||
тыс ₽/м²
|
||
</span>
|
||
<span style={{ color: "#9ca3af", marginLeft: 8 }}>
|
||
· {(data.district.dist_to_center / 1000).toFixed(1)} км до центра
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Tram warning */}
|
||
{nearestTram !== null && (
|
||
<div
|
||
style={{
|
||
padding: "10px 24px",
|
||
borderBottom: "1px solid #e5e7eb",
|
||
background: "#fff7ed",
|
||
fontSize: 13,
|
||
color: "#c2410c",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
}}
|
||
>
|
||
<span>⚠</span>
|
||
<span>Трамвай в {nearestTram} м (возможный источник шума)</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* POI breakdown */}
|
||
<div style={{ padding: "12px 24px" }}>
|
||
<div
|
||
style={{
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
color: "#6b7280",
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.05em",
|
||
marginBottom: 10,
|
||
}}
|
||
>
|
||
POI по категориям
|
||
</div>
|
||
{Object.entries(data.score_breakdown).length === 0 ? (
|
||
<p style={{ fontSize: 13, color: "#9ca3af" }}>Нет данных</p>
|
||
) : (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||
{Object.entries(data.score_breakdown).map(([cat, items]) => (
|
||
<div
|
||
key={cat}
|
||
style={{
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
alignItems: "center",
|
||
fontSize: 13,
|
||
}}
|
||
>
|
||
<span
|
||
style={{ color: cat === "tram_stop" ? "#dc2626" : "#374151" }}
|
||
>
|
||
{CATEGORY_LABELS[cat] ?? cat}
|
||
</span>
|
||
<span style={{ color: "#6b7280", fontSize: 12 }}>
|
||
{items.length} шт · ср. {avgDist(items)} м
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Environmental factors */}
|
||
{hasEnvironmental && (
|
||
<div
|
||
style={{
|
||
padding: "12px 24px 16px",
|
||
borderTop: "1px solid #e5e7eb",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
color: "#6b7280",
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.05em",
|
||
marginBottom: 12,
|
||
}}
|
||
>
|
||
Внешние факторы
|
||
</div>
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "repeat(3, 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} />
|
||
|
||
<WindBlock wind={data.wind ?? null} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Market trend */}
|
||
{"market_trend" in data && (
|
||
<div
|
||
style={{
|
||
padding: "12px 24px 16px",
|
||
borderTop: "1px solid #e5e7eb",
|
||
}}
|
||
>
|
||
<MarketTrendBlock trend={data.market_trend} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|