gendesign/frontend/src/components/site-finder/ScoreCard.tsx

587 lines
16 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 { 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>
);
}