feat(stats): переписать дашборд статистики с Flet на React
stats/ (Flet) генерировал графики через matplotlib в PNG и отдавал их через Flutter web с WebSocket — та же архитектура, что подводила основной фронтенд (см. известные ошибки в README). Плавающая версия flet>=0.82.2 недавно подтянула 0.86.0 с несовместимым протоколом фреймирования, и дашборд перестал открываться в браузере. stats-react/ — SPA по тому же паттерну, что и web-react (Vite+React+TS, свой Dockerfile+nginx, same-origin прокси /api вместо WebSocket). Функциональность 1:1: 4 summary-карточки, общие фильтры (тест/ организация/группа/год), два таба (прохождения / результаты тестов), 8 графиков, топ-10 таблица, фильтр по оси + сравнение по организациям/ группам. Круговая диаграмма заменена на 100%-stacked bar (dataviz: "part-to-whole rides on the stacked bar chart; donut stays deprioritized") с фолдингом длинного хвоста категорий в "Другое" — у оригинала было 32 категории на 8 цветов, из-за чего сегменты становились неразличимы. Категориальная палитра графиков провалидирована на CVD-безопасность (scripts/validate_palette.js, light+dark) — 8-цветная палитра из исходного designer.py не проходила проверку (hard fail по normal-vision floor). Бренд-оранжевый сохранён в палитре, остальные 7 слотов — из референсного набора skill'а. stats/ и Dockerfile.stats удалены, docker-compose.yml переключён на stats-react. Проверено сборкой всех трёх образов, полным compose up и визуально (headless Chrome, light+dark, обе вкладки, реальные данные с прод-сервера). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
104
stats-react/src/components/charts/PartToWholeBar.tsx
Normal file
104
stats-react/src/components/charts/PartToWholeBar.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState } from "react";
|
||||
import { ChartTooltip, type TooltipState } from "./ChartTooltip";
|
||||
import { categoricalColor } from "../../theme/palette";
|
||||
|
||||
export interface ShareDatum {
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// Part-to-whole как один горизонтальный 100%-stacked bar вместо pie chart —
|
||||
// dataviz: "part-to-whole rides on the stacked bar chart; donut stays deprioritized".
|
||||
// Легенда обязательна (>=2 сегментов), т.к. на самих сегментах подписи не всегда влезают.
|
||||
// Токен-потолок категориальной палитры — 8 слотов (см. dataviz choosing-a-form:
|
||||
// "series 7-8: token ceiling; past it, fold the tail into Other"). Данные уже
|
||||
// отсортированы бэкендом по убыванию — берём топ-7 и сворачиваем остаток.
|
||||
const MAX_SLOTS = 7;
|
||||
const OTHER_COLOR_LIGHT = "#898781";
|
||||
const OTHER_COLOR_DARK = "#a7b2c0";
|
||||
|
||||
function foldTail(data: ShareDatum[]): ShareDatum[] {
|
||||
if (data.length <= MAX_SLOTS + 1) return data;
|
||||
const head = data.slice(0, MAX_SLOTS);
|
||||
const tail = data.slice(MAX_SLOTS);
|
||||
const otherCount = tail.reduce((s, d) => s + d.count, 0);
|
||||
return [...head, { label: `Другое (${tail.length})`, count: otherCount }];
|
||||
}
|
||||
|
||||
export function PartToWholeBar({ data, dark }: { data: ShareDatum[]; dark: boolean }) {
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
|
||||
if (data.length === 0) {
|
||||
return <div className="chart-empty">Нет данных</div>;
|
||||
}
|
||||
|
||||
const folded = foldTail(data);
|
||||
const wasFolded = folded.length < data.length;
|
||||
const isOther = (i: number) => wasFolded && i === folded.length - 1;
|
||||
const total = folded.reduce((s, d) => s + d.count, 0) || 1;
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
height: 28,
|
||||
borderRadius: 6,
|
||||
overflow: "hidden",
|
||||
gap: 2,
|
||||
background: "var(--surface-alt)",
|
||||
}}
|
||||
>
|
||||
{folded.map((d, i) => {
|
||||
const pct = (d.count / total) * 100;
|
||||
const color = isOther(i) ? (dark ? OTHER_COLOR_DARK : OTHER_COLOR_LIGHT) : categoricalColor(i, dark);
|
||||
return (
|
||||
<div
|
||||
key={d.label + i}
|
||||
style={{
|
||||
flexBasis: `${pct}%`,
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
minWidth: 2,
|
||||
background: color,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
tabIndex={0}
|
||||
onMouseMove={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const parent = e.currentTarget.parentElement!.parentElement!.getBoundingClientRect();
|
||||
setTooltip({
|
||||
x: rect.left - parent.left + rect.width / 2,
|
||||
y: rect.top - parent.top,
|
||||
label: d.label,
|
||||
value: `${d.count} (${pct.toFixed(1)}%)`,
|
||||
swatch: color,
|
||||
});
|
||||
}}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
onFocus={() =>
|
||||
setTooltip({ x: 0, y: 0, label: d.label, value: `${d.count} (${pct.toFixed(1)}%)`, swatch: color })
|
||||
}
|
||||
onBlur={() => setTooltip(null)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="chart-legend">
|
||||
{folded.map((d, i) => {
|
||||
const pct = (d.count / total) * 100;
|
||||
const color = isOther(i) ? (dark ? OTHER_COLOR_DARK : OTHER_COLOR_LIGHT) : categoricalColor(i, dark);
|
||||
return (
|
||||
<span className="legend-item" key={d.label + i}>
|
||||
<span className="legend-swatch" style={{ background: color }} />
|
||||
{d.label} — {d.count} ({pct.toFixed(1)}%)
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ChartTooltip tooltip={tooltip} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user