gendesign/frontend/src/components/site-finder/ScoreBreakdownStackedBar.tsx
Light1YT 86e9ea2937 fix(week-review): автофиксы код-ревью — 169 issue (label «week ревью 1»)
Многоагентный аудит + имплементация: один воркер на файл, точечные правки.
Верификация: py_compile (47/47 .py) + tsc --noEmit (0 ошибок). Unit-тесты
не прогонялись (окружение не поднято: rollup native dep / нет pytest-venv).

Полностью исправлено (169): #1336, #1337, #1339, #1340, #1341, #1342, #1343, #1345, #1346, #1348, #1349, #1350, #1351, #1354, #1356, #1358, #1359, #1360, #1362, #1364, #1365, #1366, #1367, #1368, #1369, #1370, #1371, #1372, #1373, #1374, #1375, #1376, #1377, #1378, #1379, #1380, #1381, #1382, #1384, #1385, #1386, #1387, #1388, #1389, #1390, #1391, #1392, #1394, #1395, #1396, #1397, #1399, #1400, #1401, #1402, #1403, #1404, #1408, #1409, #1410, #1411, #1412, #1413, #1414, #1415, #1416, #1417, #1418, #1420, #1423, #1425, #1426, #1427, #1428, #1429, #1430, #1431, #1432, #1433, #1434, #1435, #1437, #1438, #1439, #1440, #1441, #1442, #1443, #1444, #1445, #1446, #1447, #1448, #1449, #1450, #1451, #1452, #1453, #1454, #1455, #1456, #1457, #1458, #1459, #1460, #1461, #1462, #1463, #1464, #1465, #1466, #1467, #1468, #1469, #1471, #1472, #1473, #1474, #1476, #1478, #1479, #1481, #1482, #1483, #1484, #1485, #1487, #1488, #1489, #1490, #1491, #1492, #1493, #1494, #1495, #1496, #1497, #1499, #1500, #1501, #1502, #1504, #1505, #1506, #1507, #1510, #1514, #1515, #1516, #1517, #1518, #1519, #1521, #1522, #1523, #1524, #1525, #1526, #1527, #1528, #1529, #1531, #1532, #1533, #1534, #1535, #1536, #1537, #1538

Частично (9, in-file часть, остаток cross-file): #1361, #1419, #1422, #1424, #1470, #1475, #1477, #1480, #1498
Требуют cross-file (3, не тронуты): #1338, #1363, #1421
Пропущено (1): #1539

Не входило в партию: 22 needs-Leha issue (нужны решения владельца).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:21:11 +05:00

465 lines
15 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 } from "@/types/site-finder";
// ---------------------------------------------------------------------------
// Color mapping per category
// ---------------------------------------------------------------------------
const CATEGORY_COLORS: Record<string, string> = {
school: "#1d4ed8",
kindergarten: "#1d4ed8",
pharmacy: "#10b981",
hospital: "#f59e0b",
shop_mall: "#a855f7",
shop_supermarket: "#a855f7",
shop_small: "#c084fc",
park: "#16a34a",
tram_stop: "#dc2626",
bus_stop: "#6b7280",
metro_stop: "#1e40af",
custom: "#f97316", // custom POIs — orange with border
};
function categoryColor(category: string): string {
return CATEGORY_COLORS[category] ?? "#94a3b8";
}
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface Props {
/** Full factor breakdown from ParcelAnalysis.score_breakdown_detailed */
breakdown: FactorContribution[];
/** Factor names that come from custom POIs (to highlight visually) */
customPoiFactors?: string[];
}
// ---------------------------------------------------------------------------
// Aggregation helpers
// ---------------------------------------------------------------------------
interface CategoryAgg {
category: string;
category_ru: string;
contribution: number;
count: number;
isCustom: boolean;
}
function aggregateByCategory(
items: FactorContribution[],
customFactors: Set<string>,
): CategoryAgg[] {
const map = new Map<string, CategoryAgg>();
for (const item of items) {
const key = item.category;
const existing = map.get(key);
if (existing) {
existing.contribution += item.contribution;
existing.count += 1;
if (customFactors.has(item.factor)) existing.isCustom = true;
} else {
map.set(key, {
category: key,
category_ru: item.category_ru,
contribution: item.contribution,
count: 1,
isCustom: customFactors.has(item.factor),
});
}
}
return [...map.values()].sort((a, b) => b.contribution - a.contribution);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function ScoreBreakdownStackedBar({
breakdown,
customPoiFactors = [],
}: Props) {
const [groupBy, setGroupBy] = useState<"category" | "source">("category");
const [hoveredCat, setHoveredCat] = useState<string | null>(null);
const customSet = new Set(customPoiFactors);
// Aggregate by category (or source as fallback label when groupBy=source)
const aggregated = aggregateByCategory(breakdown, customSet);
// Split positive and negative for stacked bar
const positives = aggregated.filter((a) => a.contribution > 0);
const negatives = aggregated.filter((a) => a.contribution < 0);
const totalPositive = positives.reduce((s, a) => s + a.contribution, 0) || 1;
const totalNegative =
Math.abs(negatives.reduce((s, a) => s + a.contribution, 0)) || 1;
if (aggregated.length === 0) return null;
const fmtV = (v: number) => `${v >= 0 ? "+" : ""}${v.toFixed(2)}`;
// Group-by toggle (source mode uses factor field as key — simpler visual)
const sourceAgg =
groupBy === "source"
? (() => {
const sm = new Map<string, { contribution: number; count: number }>();
for (const item of breakdown) {
const key = item.factor;
const ex = sm.get(key);
if (ex) {
ex.contribution += item.contribution;
ex.count += 1;
} else {
sm.set(key, { contribution: item.contribution, count: 1 });
}
}
return [...sm.entries()]
.map(([k, v]) => ({ factor: k, ...v }))
.sort((a, b) => b.contribution - a.contribution)
.slice(0, 10);
})()
: null;
return (
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 18px",
background: "#fff",
display: "flex",
flexDirection: "column",
gap: 14,
}}
>
{/* Header + toggle */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 8,
}}
>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
Вклад по категориям
</div>
<div style={{ display: "flex", gap: 6, fontSize: 12 }}>
<button
type="button"
onClick={() => setGroupBy("category")}
style={{
padding: "3px 10px",
borderRadius: 5,
border: "1px solid",
borderColor: groupBy === "category" ? "#1d4ed8" : "#d1d5db",
background: groupBy === "category" ? "#dbeafe" : "#f9fafb",
color: groupBy === "category" ? "#1d4ed8" : "#6b7280",
cursor: "pointer",
fontWeight: groupBy === "category" ? 600 : 400,
}}
>
По категории
</button>
<button
type="button"
onClick={() => setGroupBy("source")}
style={{
padding: "3px 10px",
borderRadius: 5,
border: "1px solid",
borderColor: groupBy === "source" ? "#1d4ed8" : "#d1d5db",
background: groupBy === "source" ? "#dbeafe" : "#f9fafb",
color: groupBy === "source" ? "#1d4ed8" : "#6b7280",
cursor: "pointer",
fontWeight: groupBy === "source" ? 600 : 400,
}}
>
По фактору
</button>
</div>
</div>
{/* Stacked horizontal bar — positive contributions */}
{groupBy === "category" && positives.length > 0 && (
<div>
<div style={{ fontSize: 11, color: "#6b7280", marginBottom: 4 }}>
Положительный вклад
</div>
<div
style={{
display: "flex",
height: 28,
borderRadius: 5,
overflow: "hidden",
border: "1px solid #e5e7eb",
}}
role="img"
aria-label="Stacked bar: положительный вклад по категориям"
>
{positives.map((agg) => {
const widthPct = (agg.contribution / totalPositive) * 100;
const color = agg.isCustom
? CATEGORY_COLORS.custom
: categoryColor(agg.category);
const isHovered = hoveredCat === agg.category;
return (
<div
key={agg.category}
style={{
width: `${widthPct}%`,
background: color,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
color: "#fff",
fontWeight: 500,
overflow: "hidden",
whiteSpace: "nowrap",
cursor: "default",
outline: agg.isCustom ? "2px dashed #ea580c" : "none",
outlineOffset: -2,
opacity: hoveredCat && !isHovered ? 0.6 : 1,
transition: "opacity 0.15s",
}}
title={`${agg.category_ru}${agg.isCustom ? " (custom)" : ""}: ${fmtV(agg.contribution)}`}
onMouseEnter={() => setHoveredCat(agg.category)}
onMouseLeave={() => setHoveredCat(null)}
>
{widthPct >= 12 ? `${Math.round(widthPct)}%` : ""}
</div>
);
})}
</div>
{/* Negative bar */}
{negatives.length > 0 && (
<>
<div
style={{
fontSize: 11,
color: "#6b7280",
marginTop: 10,
marginBottom: 4,
}}
>
Снижают балл
</div>
<div
style={{
display: "flex",
height: 20,
borderRadius: 5,
overflow: "hidden",
border: "1px solid #fca5a5",
}}
>
{negatives.map((agg) => {
const widthPct =
(Math.abs(agg.contribution) / totalNegative) * 100;
const color = categoryColor(agg.category);
return (
<div
key={agg.category}
style={{
width: `${widthPct}%`,
background: color,
opacity: 0.6,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 10,
color: "#fff",
overflow: "hidden",
whiteSpace: "nowrap",
cursor: "default",
}}
title={`${agg.category_ru}: ${fmtV(agg.contribution)}`}
>
{widthPct >= 15 ? `${Math.round(widthPct)}%` : ""}
</div>
);
})}
</div>
</>
)}
{/* Legend */}
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 8,
marginTop: 10,
fontSize: 12,
color: "#6b7280",
}}
>
{aggregated
.filter((a) => a.contribution !== 0)
.map((agg) => {
const color = agg.isCustom
? CATEGORY_COLORS.custom
: categoryColor(agg.category);
return (
<div
key={agg.category}
style={{
display: "flex",
alignItems: "center",
gap: 4,
cursor: "default",
}}
onMouseEnter={() => setHoveredCat(agg.category)}
onMouseLeave={() => setHoveredCat(null)}
>
<span
style={{
display: "inline-block",
width: 10,
height: 10,
borderRadius: 2,
background: color,
outline: agg.isCustom ? "1.5px dashed #ea580c" : "none",
outlineOffset: 1,
}}
/>
<span>
{agg.category_ru}
{agg.isCustom && (
<span
style={{
marginLeft: 3,
fontSize: 10,
color: "#ea580c",
fontWeight: 600,
}}
>
(custom)
</span>
)}
:{" "}
<strong
style={{
color: agg.contribution >= 0 ? "#16a34a" : "#dc2626",
}}
>
{fmtV(agg.contribution)}
</strong>
</span>
</div>
);
})}
</div>
</div>
)}
{/* Source (factor) view — top 10 table */}
{groupBy === "source" && sourceAgg && (
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
{sourceAgg.map(({ factor, contribution }) => {
const isCustom = customSet.has(factor);
const maxAbs =
Math.max(...sourceAgg.map((s) => Math.abs(s.contribution))) || 1;
const barWidth = Math.min(
100,
(Math.abs(contribution) / maxAbs) * 100,
);
const color =
contribution >= 0
? isCustom
? CATEGORY_COLORS.custom
: "#16a34a"
: "#dc2626";
return (
<div
key={factor}
style={{ display: "flex", alignItems: "center", gap: 10 }}
>
<div
style={{
fontSize: 12,
color: "#374151",
width: 180,
flexShrink: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={factor}
>
{isCustom && (
<span
style={{
fontSize: 10,
color: "#ea580c",
fontWeight: 600,
marginRight: 4,
}}
>
[custom]
</span>
)}
{factor}
</div>
<div
style={{
flex: 1,
height: 14,
background: "#f3f4f6",
borderRadius: 3,
overflow: "hidden",
}}
>
<div
style={{
width: `${barWidth}%`,
height: "100%",
background: color,
borderRadius: 3,
outline: isCustom ? "1.5px dashed #ea580c" : "none",
}}
/>
</div>
<div
style={{
fontSize: 12,
fontWeight: 600,
color,
width: 50,
textAlign: "right",
flexShrink: 0,
fontVariantNumeric: "tabular-nums",
}}
>
{fmtV(contribution)}
</div>
</div>
);
})}
{breakdown.length > 10 && (
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 4 }}>
Показаны топ-10 из {breakdown.length} факторов
</div>
)}
</div>
)}
</div>
);
}