gendesign/frontend/src/components/site-finder/ScoreBreakdownPanel.tsx
lekss361 1002214f12 feat(site-finder): X1 score breakdown + verbal explain (#47)
Backend:
- POST /parcels/{cad}/analyze returns score_breakdown_detailed (per-POI
  FactorContribution with verbal explain), score_top_3_positives /
  score_top_3_negatives, score_by_group (для stacked bar).
- Каждый фактор: factor slug, category, category_ru, group, value, weight,
  contribution, contribution_pct, verbal.
- center_bonus добавлен как synthetic factor группы "Локация".
- Skip факторов с |contribution| < 0.01 (POI > 1км) — UI шуму не нужен.

Frontend:
- Новый ScoreBreakdownPanel.tsx: stacked-bar по группам с легендой,
  топ-3 плюса / топ-3 минуса с verbal, разворачиваемая таблица всех факторов.
- Интегрирован в OverviewTab под секцией "Балл".
- TS типы расширены: FactorContribution, ScoreGroupTotal.

Closes #47.
2026-05-12 07:39:27 +03:00

357 lines
10 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 { FactorContribution, ScoreGroupTotal } from "@/types/site-finder";
interface Props {
topPositives: FactorContribution[];
topNegatives: FactorContribution[];
byGroup: ScoreGroupTotal[];
detailed: FactorContribution[];
}
// Цвета stacked bar — соответствуют тематическим эшелонам
const GROUP_COLORS: Record<string, string> = {
Социалка: "#0ea5e9",
Торговля: "#a855f7",
Парки: "#16a34a",
Транспорт: "#eab308",
"Шум/трамвай": "#dc2626",
Локация: "#1d4ed8",
Прочее: "#94a3b8",
};
function fmtContribution(v: number): string {
const sign = v >= 0 ? "+" : "";
return `${sign}${Math.abs(v).toFixed(2)}`;
}
export function ScoreBreakdownPanel({
topPositives,
topNegatives,
byGroup,
detailed,
}: Props) {
const [expanded, setExpanded] = useState(false);
// Stacked bar — только positive groups (для визуальной шкалы вклада)
const positiveGroups = byGroup.filter((g) => g.contribution > 0);
const totalPositive =
positiveGroups.reduce((s, g) => s + g.contribution, 0) || 1;
return (
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 18px",
background: "#fff",
display: "flex",
flexDirection: "column",
gap: 14,
}}
>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
Почему такой балл
</div>
{/* Stacked bar — % contribution по группам */}
{positiveGroups.length > 0 && (
<div>
<div
style={{
display: "flex",
height: 24,
borderRadius: 4,
overflow: "hidden",
border: "1px solid #e5e7eb",
}}
role="img"
aria-label="Состав положительного вклада по группам"
>
{positiveGroups.map((g) => {
const widthPct = (g.contribution / totalPositive) * 100;
return (
<div
key={g.group}
style={{
width: `${widthPct}%`,
background: GROUP_COLORS[g.group] ?? GROUP_COLORS.Прочее,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
color: "#fff",
fontWeight: 500,
overflow: "hidden",
whiteSpace: "nowrap",
}}
title={`${g.group}: ${fmtContribution(g.contribution)} (${g.contribution_pct}%)`}
>
{widthPct >= 10 ? `${Math.round(widthPct)}%` : ""}
</div>
);
})}
</div>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 10,
marginTop: 8,
fontSize: 12,
color: "#6b7280",
}}
>
{byGroup.map((g) => (
<div
key={g.group}
style={{ display: "flex", alignItems: "center", gap: 4 }}
>
<span
style={{
display: "inline-block",
width: 10,
height: 10,
borderRadius: 2,
background: GROUP_COLORS[g.group] ?? GROUP_COLORS.Прочее,
}}
/>
<span>
{g.group}:{" "}
<strong style={{ color: "#374151" }}>
{fmtContribution(g.contribution)}
</strong>
</span>
</div>
))}
</div>
</div>
)}
{/* Top-3 positive */}
{topPositives.length > 0 && (
<div>
<div
style={{
fontSize: 11,
fontWeight: 600,
color: "#16a34a",
textTransform: "uppercase",
letterSpacing: "0.04em",
marginBottom: 6,
}}
>
Топ-3 плюса
</div>
<ul
style={{
listStyle: "none",
margin: 0,
padding: 0,
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
{topPositives.map((f) => (
<li
key={f.factor}
style={{ fontSize: 13, color: "#374151", lineHeight: 1.4 }}
>
<span
style={{
color: "#16a34a",
fontWeight: 600,
marginRight: 6,
}}
>
</span>
{f.verbal}
</li>
))}
</ul>
</div>
)}
{/* Top-3 negative */}
{topNegatives.length > 0 && (
<div>
<div
style={{
fontSize: 11,
fontWeight: 600,
color: "#dc2626",
textTransform: "uppercase",
letterSpacing: "0.04em",
marginBottom: 6,
}}
>
Топ-3 минуса
</div>
<ul
style={{
listStyle: "none",
margin: 0,
padding: 0,
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
{topNegatives.map((f) => (
<li
key={f.factor}
style={{ fontSize: 13, color: "#374151", lineHeight: 1.4 }}
>
<span
style={{
color: "#dc2626",
fontWeight: 600,
marginRight: 6,
}}
>
</span>
{f.verbal}
</li>
))}
</ul>
</div>
)}
{/* Toggle full breakdown */}
{detailed.length > 0 && (
<div>
<button
type="button"
onClick={() => setExpanded((e) => !e)}
aria-expanded={expanded}
style={{
background: "none",
border: "none",
padding: 0,
color: "#1d4ed8",
fontSize: 13,
cursor: "pointer",
fontWeight: 500,
}}
>
{expanded
? "Скрыть все факторы"
: `Показать все факторы (${detailed.length})`}
</button>
{expanded && (
<div
style={{
marginTop: 10,
maxHeight: 280,
overflowY: "auto",
border: "1px solid #f3f4f6",
borderRadius: 6,
}}
>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: 12,
}}
>
<thead
style={{
background: "#f9fafb",
position: "sticky",
top: 0,
zIndex: 1,
}}
>
<tr>
<th
style={{
padding: "6px 10px",
textAlign: "left",
color: "#6b7280",
fontWeight: 500,
}}
>
Фактор
</th>
<th
style={{
padding: "6px 10px",
textAlign: "right",
color: "#6b7280",
fontWeight: 500,
}}
>
Вклад
</th>
<th
style={{
padding: "6px 10px",
textAlign: "right",
color: "#6b7280",
fontWeight: 500,
}}
>
%
</th>
</tr>
</thead>
<tbody>
{detailed.map((f) => (
<tr
key={f.factor}
style={{ borderTop: "1px solid #f3f4f6" }}
>
<td
style={{
padding: "6px 10px",
color: "#374151",
}}
title={f.verbal}
>
{f.verbal}
</td>
<td
style={{
padding: "6px 10px",
textAlign: "right",
color: f.contribution >= 0 ? "#16a34a" : "#dc2626",
fontVariantNumeric: "tabular-nums",
fontWeight: 600,
}}
>
{fmtContribution(f.contribution)}
</td>
<td
style={{
padding: "6px 10px",
textAlign: "right",
color: "#6b7280",
fontVariantNumeric: "tabular-nums",
}}
>
{f.contribution_pct.toFixed(1)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
}