Многоагентный аудит + имплементация: один воркер на файл, точечные правки. Верификация: 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>
465 lines
15 KiB
TypeScript
465 lines
15 KiB
TypeScript
"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>
|
||
);
|
||
}
|