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.
391 lines
12 KiB
TypeScript
391 lines
12 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",
|
||
}}
|
||
>
|
||
{/* 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>
|
||
);
|
||
}
|