"use client"; import { useState } from "react"; import type { FactorContribution } from "@/types/site-finder"; // --------------------------------------------------------------------------- // Color mapping per category // --------------------------------------------------------------------------- const CATEGORY_COLORS: Record = { 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, ): CategoryAgg[] { const map = new Map(); 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(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(); 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 (
{/* Header + toggle */}
Вклад по категориям
{/* Stacked horizontal bar — positive contributions */} {groupBy === "category" && positives.length > 0 && (
Положительный вклад
{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 (
setHoveredCat(agg.category)} onMouseLeave={() => setHoveredCat(null)} > {widthPct >= 12 ? `${Math.round(widthPct)}%` : ""}
); })}
{/* Negative bar */} {negatives.length > 0 && ( <>
Снижают балл
{negatives.map((agg) => { const widthPct = (Math.abs(agg.contribution) / totalNegative) * 100; const color = categoryColor(agg.category); return (
{widthPct >= 15 ? `${Math.round(widthPct)}%` : ""}
); })}
)} {/* Legend */}
{aggregated .filter((a) => a.contribution !== 0) .map((agg) => { const color = agg.isCustom ? CATEGORY_COLORS.custom : categoryColor(agg.category); return (
setHoveredCat(agg.category)} onMouseLeave={() => setHoveredCat(null)} > {agg.category_ru} {agg.isCustom && ( (custom) )} :{" "} = 0 ? "#16a34a" : "#dc2626", }} > {fmtV(agg.contribution)}
); })}
)} {/* Source (factor) view — top 10 table */} {groupBy === "source" && sourceAgg && (
{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 (
{isCustom && ( [custom] )} {factor}
{fmtV(contribution)}
); })} {breakdown.length > 10 && (
Показаны топ-10 из {breakdown.length} факторов
)}
)}
); }