gendesign/frontend/src/components/site-finder/ScoreBreakdownPanel.tsx
lekss361 1f8fd77825
feat(site-finder): X1 score breakdown + verbal explain (#47) (#92)
Backend (parcels.py):
- POI scoring loop teper строит score_breakdown_detailed: per-factor list с
  verbal explain (через _verbal_for_poi helper) и группировкой
  (_POI_GROUP: Социалка / Торговля / Парки / Транспорт / Шум/трамвай / Локация).
- center_bonus добавлен как synthetic factor группы "Локация" с weight=1.0
  (decay не применяется — bonus IS the value).
- factor key включает enumerate idx — prevents React key collision когда
  два POI одной категории совпадают по округлённому расстоянию.
- Skip факторов с |contribution| < 0.01 (POI > 1км) — UI шуму не нужен.
- abs_total fallback на 1.0 — защита от division-by-zero для empty factors.
- Top-3 positives/negatives: explicit ascending sort для негативов
  ("most-negative first" очевидно из кода).
- score_by_group: stacked-bar данные с count + contribution_pct.
- group_totals type: dict[str, dict[str, float | int]] (count это int).

Frontend:
- Новый ScoreBreakdownPanel.tsx: stacked-bar по группам с tooltip + legend,
  топ-3 плюса (▲ зелёный) / топ-3 минуса (▼ красный) с verbal, отдельная
  строка "Снижают балл — Шум/трамвай: ..." для negative groups, разворачиваемая
  таблица всех факторов (sticky thead, scrollable).
- Интегрирован в OverviewTab под секцией "Балл".
- TS типы: FactorContribution, ScoreGroupTotal.

Closes #47.

NB: branch создан заново из-за rebase mess (см. PR #87 comments). Логически
эквивалентно но history clean.

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-12 08:17:00 +03:00

391 lines
12 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",
}}
>
{/* Positive groups — visible в баре */}
{positiveGroups.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>
{/* Negative groups — отдельной "drag" линией под баром (legend для bar
использует только positive, чтобы не было orphan swatches без сегмента) */}
{byGroup
.filter((g) => g.contribution < 0)
.map((g) => (
<div
key={`neg-${g.group}`}
style={{
display: "flex",
alignItems: "center",
gap: 4,
marginTop: 6,
fontSize: 12,
color: "#6b7280",
}}
>
<span
style={{
display: "inline-block",
width: 10,
height: 10,
borderRadius: 2,
background: GROUP_COLORS[g.group] ?? GROUP_COLORS.Прочее,
}}
/>
<span>
Снижают балл {g.group}:{" "}
<strong style={{ color: "#dc2626" }}>
{fmtContribution(g.contribution)}
</strong>
</span>
</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>
);
}