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.
357 lines
10 KiB
TypeScript
357 lines
10 KiB
TypeScript
"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>
|
||
);
|
||
}
|