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:
6
stats-react/.dockerignore
Normal file
6
stats-react/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
*.local
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
5
stats-react/.gitignore
vendored
Normal file
5
stats-react/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
*.local
|
||||
.vite
|
||||
*.tsbuildinfo
|
||||
14
stats-react/Dockerfile
Normal file
14
stats-react/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
# --- Стадия 1: сборка React-приложения ---
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# --- Стадия 2: раздача статики через nginx + прокси /api ---
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
13
stats-react/index.html
Normal file
13
stats-react/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ЦОПП — Статистика</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
36
stats-react/nginx.conf
Normal file
36
stats-react/nginx.conf
Normal file
@@ -0,0 +1,36 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Статика SPA: всё неизвестное отдаём index.html (клиентский роутинг)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Прокси на внутренний API-контейнер (браузер к api:8000 напрямую не ходит).
|
||||
# Префикс /api срезается: /api/stats/summary -> http://api:8000/stats/summary
|
||||
#
|
||||
# Используем встроенный DNS Docker (127.0.0.11) и переменную в proxy_pass,
|
||||
# чтобы nginx переразрешал имя `api` на каждый запрос. Иначе nginx кэширует
|
||||
# IP при старте и ломается (502), если контейнер api перезапустился и сменил IP.
|
||||
location /api/ {
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $api_backend api;
|
||||
rewrite ^/api/(.*)$ /$1 break;
|
||||
proxy_pass http://$api_backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Кэш статических ассетов с хэшем в имени
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
1732
stats-react/package-lock.json
generated
Normal file
1732
stats-react/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
stats-react/package.json
Normal file
22
stats-react/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "copp-stats-react",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.6"
|
||||
}
|
||||
}
|
||||
BIN
stats-react/public/favicon.png
Normal file
BIN
stats-react/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 851 B |
342
stats-react/src/App.tsx
Normal file
342
stats-react/src/App.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import * as api from "./api/client";
|
||||
import type { AvgDatum, ChartDatum, Filters, TopUserRow } from "./api/client";
|
||||
import { SearchableDropdown, type Option } from "./components/SearchableDropdown";
|
||||
import { ChartCard } from "./components/ChartCard";
|
||||
import { SummaryTile } from "./components/SummaryTile";
|
||||
import { TopUsersTable } from "./components/TopUsersTable";
|
||||
import { BarChart } from "./components/charts/BarChart";
|
||||
import { LineChart } from "./components/charts/LineChart";
|
||||
import { PartToWholeBar } from "./components/charts/PartToWholeBar";
|
||||
import { useIsDark } from "./theme/useIsDark";
|
||||
import { categoricalColor } from "./theme/palette";
|
||||
import {
|
||||
IconAssignment,
|
||||
IconBarChart,
|
||||
IconBusiness,
|
||||
IconFilterOff,
|
||||
IconInsights,
|
||||
IconPlayCircle,
|
||||
IconQuiz,
|
||||
IconRefresh,
|
||||
} from "./components/icons";
|
||||
|
||||
function toValueData(data: ChartDatum[]): { label: string; value: number }[] {
|
||||
return data.map((d) => ({ label: d.label, value: d.count }));
|
||||
}
|
||||
|
||||
const COMPARE_OPTIONS: Option[] = [
|
||||
{ key: "organization", text: "Организациям" },
|
||||
{ key: "group", text: "Группам" },
|
||||
];
|
||||
|
||||
export default function App() {
|
||||
const dark = useIsDark();
|
||||
|
||||
// ── справочники для фильтров ──────────────────────────────────────────
|
||||
const [pollOptions, setPollOptions] = useState<Option[]>([]);
|
||||
const [orgOptions, setOrgOptions] = useState<Option[]>([]);
|
||||
const [groupOptions, setGroupOptions] = useState<Option[]>([]);
|
||||
const [yearOptions, setYearOptions] = useState<Option[]>([]);
|
||||
const [dimensionOptions, setDimensionOptions] = useState<Option[]>([]);
|
||||
|
||||
// ── общие фильтры ────────────────────────────────────────────────────
|
||||
const [pollId, setPollId] = useState<string | null>(null);
|
||||
const [orgId, setOrgId] = useState<string | null>(null);
|
||||
const [groupId, setGroupId] = useState<string | null>(null);
|
||||
const [year, setYear] = useState<string | null>(null);
|
||||
|
||||
// ── фильтры таба 2 ───────────────────────────────────────────────────
|
||||
const [dimension, setDimension] = useState<string | null>(null);
|
||||
const [compareBy, setCompareBy] = useState<string>("organization");
|
||||
|
||||
const [tab, setTab] = useState<0 | 1>(0);
|
||||
|
||||
// ── summary ──────────────────────────────────────────────────────────
|
||||
const [summary, setSummary] = useState<api.Summary>({
|
||||
total_responses: 0,
|
||||
total_polls: 0,
|
||||
active_polls: 0,
|
||||
orgs_active: 0,
|
||||
});
|
||||
|
||||
// ── tab1 данные ──────────────────────────────────────────────────────
|
||||
const [tab1Loading, setTab1Loading] = useState(true);
|
||||
const [pollData, setPollData] = useState<ChartDatum[]>([]);
|
||||
const [orgData, setOrgData] = useState<ChartDatum[]>([]);
|
||||
const [groupData, setGroupData] = useState<ChartDatum[]>([]);
|
||||
const [monthData, setMonthData] = useState<ChartDatum[]>([]);
|
||||
|
||||
// ── tab2 данные ──────────────────────────────────────────────────────
|
||||
const [tab2Loading, setTab2Loading] = useState(true);
|
||||
const [avgDimData, setAvgDimData] = useState<AvgDatum[]>([]);
|
||||
const [leadingData, setLeadingData] = useState<ChartDatum[]>([]);
|
||||
const [compareData, setCompareData] = useState<AvgDatum[]>([]);
|
||||
const [trendData, setTrendData] = useState<AvgDatum[]>([]);
|
||||
const [topUsers, setTopUsers] = useState<TopUserRow[]>([]);
|
||||
|
||||
const filters = (): Filters => ({
|
||||
poll_id: pollId,
|
||||
org_id: orgId,
|
||||
group_id: groupId,
|
||||
year: year ? parseInt(year, 10) : null,
|
||||
});
|
||||
|
||||
async function refreshTab1() {
|
||||
setTab1Loading(true);
|
||||
const f = filters();
|
||||
const [p, o, g, m] = await Promise.all([
|
||||
api.getResponsesGrouped("poll", f),
|
||||
api.getResponsesGrouped("organization", f),
|
||||
api.getResponsesGrouped("group", f),
|
||||
api.getResponsesGrouped("month", f),
|
||||
]);
|
||||
setPollData(p);
|
||||
setOrgData(o);
|
||||
setGroupData(g);
|
||||
setMonthData(m);
|
||||
setTab1Loading(false);
|
||||
}
|
||||
|
||||
async function refreshTab2() {
|
||||
setTab2Loading(true);
|
||||
const f = filters();
|
||||
const dim = dimension;
|
||||
const [avg, lead] = await Promise.all([api.getAvgByDimension(f), api.getLeadingType(f)]);
|
||||
let top: TopUserRow[] = [];
|
||||
let compare: AvgDatum[] = [];
|
||||
let trend: AvgDatum[] = [];
|
||||
if (dim) {
|
||||
[top, compare, trend] = await Promise.all([
|
||||
api.getTopUsers(dim, f),
|
||||
api.getCompareGroups(dim, compareBy as api.CompareBy, f.poll_id, f.year),
|
||||
api.getDimensionTrend(dim, f),
|
||||
]);
|
||||
}
|
||||
setAvgDimData(avg);
|
||||
setLeadingData(lead);
|
||||
setTopUsers(top);
|
||||
setCompareData(compare);
|
||||
setTrendData(trend);
|
||||
setTab2Loading(false);
|
||||
}
|
||||
|
||||
function refreshCurrentTab() {
|
||||
if (tab === 0) void refreshTab1();
|
||||
else void refreshTab2();
|
||||
}
|
||||
|
||||
// ── первичная загрузка ───────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const [polls, orgs, groups, years, summaryData, dims] = await Promise.all([
|
||||
api.getPolls(),
|
||||
api.getOrganizations(),
|
||||
api.getGroups(),
|
||||
api.getYears(),
|
||||
api.getSummary(),
|
||||
api.getDimensions(),
|
||||
]);
|
||||
setPollOptions(polls.map((p) => ({ key: p.id, text: p.title })));
|
||||
setOrgOptions(orgs.map((o) => ({ key: o.id, text: o.name_organization })));
|
||||
setGroupOptions(groups.map((g) => ({ key: g.id, text: g.name_group })));
|
||||
setYearOptions(years.map((y) => ({ key: String(y), text: String(y) })));
|
||||
setDimensionOptions(dims.map((d) => ({ key: d, text: d })));
|
||||
setSummary(summaryData);
|
||||
if (dims.length > 0) setDimension(dims[0]);
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Первый прогон обоих табов, как только известна выбранная ось (dimension).
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
useEffect(() => {
|
||||
if (initialized || dimensionOptions.length === 0) return;
|
||||
setInitialized(true);
|
||||
void refreshTab1();
|
||||
void refreshTab2();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dimensionOptions]);
|
||||
|
||||
const onGeneralFilterChange = () => {
|
||||
if (!initialized) return;
|
||||
refreshCurrentTab();
|
||||
};
|
||||
|
||||
const onResultsFilterChange = () => {
|
||||
if (!initialized) return;
|
||||
void refreshTab2();
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setPollId(null);
|
||||
setOrgId(null);
|
||||
setGroupId(null);
|
||||
setYear(null);
|
||||
setTimeout(refreshCurrentTab, 0);
|
||||
};
|
||||
|
||||
const dimColor = (i: number) => categoricalColor(i, dark);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="app-header">
|
||||
<span className="brand">ЦОПП</span>
|
||||
<span className="divider" />
|
||||
<span className="subtitle">Статистика прохождений</span>
|
||||
</header>
|
||||
|
||||
<div className="container">
|
||||
<div className="summary-row">
|
||||
<SummaryTile title="Всего ответов" value={summary.total_responses} icon={<IconAssignment />} color="var(--primary)" />
|
||||
<SummaryTile title="Тестов всего" value={summary.total_polls} icon={<IconQuiz />} color="var(--info)" />
|
||||
<SummaryTile title="Активных тестов" value={summary.active_polls} icon={<IconPlayCircle />} color="var(--success)" />
|
||||
<SummaryTile title="Организаций с ответами" value={summary.orgs_active} icon={<IconBusiness />} color="var(--accent)" />
|
||||
</div>
|
||||
|
||||
<div className="filters-card">
|
||||
<span className="filters-label">Фильтры</span>
|
||||
<div className="filters-row">
|
||||
<SearchableDropdown
|
||||
label="Тест"
|
||||
hint="Все тесты"
|
||||
options={pollOptions}
|
||||
value={pollId}
|
||||
onChange={(v) => {
|
||||
setPollId(v);
|
||||
setTimeout(onGeneralFilterChange, 0);
|
||||
}}
|
||||
/>
|
||||
<SearchableDropdown
|
||||
label="Организация"
|
||||
hint="Все организации"
|
||||
options={orgOptions}
|
||||
value={orgId}
|
||||
onChange={(v) => {
|
||||
setOrgId(v);
|
||||
setTimeout(onGeneralFilterChange, 0);
|
||||
}}
|
||||
/>
|
||||
<SearchableDropdown
|
||||
label="Группа"
|
||||
hint="Все группы"
|
||||
options={groupOptions}
|
||||
value={groupId}
|
||||
onChange={(v) => {
|
||||
setGroupId(v);
|
||||
setTimeout(onGeneralFilterChange, 0);
|
||||
}}
|
||||
/>
|
||||
<SearchableDropdown
|
||||
label="Год"
|
||||
hint="Все годы"
|
||||
options={yearOptions}
|
||||
value={year}
|
||||
onChange={(v) => {
|
||||
setYear(v);
|
||||
setTimeout(onGeneralFilterChange, 0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="filters-actions">
|
||||
<button className="btn btn-ghost" onClick={resetFilters}>
|
||||
<IconFilterOff /> Сбросить фильтры
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={refreshCurrentTab}>
|
||||
<IconRefresh /> Обновить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tabs-bar">
|
||||
<button className={"tab-btn" + (tab === 0 ? " active" : "")} onClick={() => setTab(0)}>
|
||||
<IconBarChart /> Прохождения
|
||||
</button>
|
||||
<button className={"tab-btn" + (tab === 1 ? " active" : "")} onClick={() => setTab(1)}>
|
||||
<IconInsights /> Результаты тестов
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 0 ? (
|
||||
<div className="chart-grid">
|
||||
<ChartCard title="По тестам" loading={tab1Loading}>
|
||||
<BarChart data={toValueData(pollData)} colorFor={() => "var(--primary)"} />
|
||||
</ChartCard>
|
||||
<ChartCard title="По организациям" loading={tab1Loading}>
|
||||
<BarChart data={toValueData(orgData)} colorFor={() => "var(--info)"} />
|
||||
</ChartCard>
|
||||
<ChartCard title="По группам" loading={tab1Loading}>
|
||||
<BarChart data={toValueData(groupData)} colorFor={() => "var(--success)"} />
|
||||
</ChartCard>
|
||||
<ChartCard title="Динамика по месяцам" loading={tab1Loading}>
|
||||
<LineChart data={toValueData(monthData)} formatValue={(v) => String(v)} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
) : (
|
||||
<div className="chart-grid">
|
||||
<div className="filters-card">
|
||||
<span className="filters-label">Фильтр по оси/типу</span>
|
||||
<div className="filters-row">
|
||||
<SearchableDropdown
|
||||
label="Ось/Тип"
|
||||
hint="Выберите ось"
|
||||
options={dimensionOptions}
|
||||
value={dimension}
|
||||
onChange={(v) => {
|
||||
setDimension(v);
|
||||
setTimeout(onResultsFilterChange, 0);
|
||||
}}
|
||||
/>
|
||||
<SearchableDropdown
|
||||
label="Сравнение по"
|
||||
hint="Организациям"
|
||||
options={COMPARE_OPTIONS}
|
||||
value={compareBy}
|
||||
onChange={(v) => {
|
||||
setCompareBy(v ?? "organization");
|
||||
setTimeout(onResultsFilterChange, 0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChartCard title="Средний балл по осям" loading={tab2Loading}>
|
||||
<BarChart
|
||||
data={avgDimData.map((d) => ({ label: d.label, value: d.avg }))}
|
||||
colorFor={dimColor}
|
||||
formatValue={(v) => v.toFixed(1)}
|
||||
/>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="Распределение ведущего типа" loading={tab2Loading}>
|
||||
<PartToWholeBar data={leadingData.map((d) => ({ label: d.label, count: d.count }))} dark={dark} />
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title={`Сравнение по ${compareBy === "organization" ? "организациям" : "группам"}`} loading={tab2Loading}>
|
||||
<BarChart
|
||||
data={compareData.map((d) => ({ label: d.label, value: d.avg }))}
|
||||
colorFor={dimColor}
|
||||
formatValue={(v) => v.toFixed(1)}
|
||||
/>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="Динамика среднего балла" loading={tab2Loading}>
|
||||
<LineChart data={trendData.map((d) => ({ label: d.label, value: d.avg }))} formatValue={(v) => v.toFixed(1)} />
|
||||
</ChartCard>
|
||||
|
||||
<div className="chart-card">
|
||||
<div className="chart-card-title">Топ-10 участников по выбранной оси</div>
|
||||
{tab2Loading ? (
|
||||
<div className="chart-loading">
|
||||
<div className="spinner" style={{ margin: "0 auto" }} />
|
||||
</div>
|
||||
) : (
|
||||
<TopUsersTable rows={topUsers} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
stats-react/src/api/client.ts
Normal file
156
stats-react/src/api/client.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
// Единое место для всех HTTP-запросов к бэкенду.
|
||||
// Порт stats/api_client.py. Базовый путь /api проксируется nginx (прод) или Vite (dev) на api:8000.
|
||||
// Все эндпоинты публичные — дашборд не требует авторизации (как и раньше).
|
||||
|
||||
const BASE = "/api";
|
||||
|
||||
async function getJson<T>(path: string): Promise<T | null> {
|
||||
try {
|
||||
const resp = await fetch(BASE + path);
|
||||
if (resp.status !== 200) return null;
|
||||
return (await resp.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function qs(params: Record<string, string | number | null | undefined>): string {
|
||||
const usp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== null && v !== undefined && v !== "") usp.set(k, String(v));
|
||||
}
|
||||
const s = usp.toString();
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
|
||||
export interface Summary {
|
||||
total_responses: number;
|
||||
total_polls: number;
|
||||
active_polls: number;
|
||||
orgs_active: number;
|
||||
}
|
||||
|
||||
export interface Poll {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface Organization {
|
||||
id: string;
|
||||
name_organization: string;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
name_group: string;
|
||||
}
|
||||
|
||||
export interface ChartDatum {
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface AvgDatum {
|
||||
label: string;
|
||||
avg: number;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface TopUserRow {
|
||||
name: string;
|
||||
poll: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export type GroupBy = "poll" | "organization" | "group" | "month";
|
||||
export type CompareBy = "organization" | "group";
|
||||
|
||||
export interface Filters {
|
||||
poll_id?: string | null;
|
||||
org_id?: string | null;
|
||||
group_id?: string | null;
|
||||
year?: number | null;
|
||||
}
|
||||
|
||||
export async function getSummary(): Promise<Summary> {
|
||||
return (
|
||||
(await getJson<Summary>("/stats/summary")) ?? {
|
||||
total_responses: 0,
|
||||
total_polls: 0,
|
||||
active_polls: 0,
|
||||
orgs_active: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function getYears(): Promise<number[]> {
|
||||
return (await getJson<number[]>("/stats/years")) ?? [];
|
||||
}
|
||||
|
||||
export async function getPolls(): Promise<Poll[]> {
|
||||
return (await getJson<Poll[]>("/polls/")) ?? [];
|
||||
}
|
||||
|
||||
export async function getOrganizations(): Promise<Organization[]> {
|
||||
return (await getJson<Organization[]>("/organizations/")) ?? [];
|
||||
}
|
||||
|
||||
export async function getGroups(): Promise<Group[]> {
|
||||
return (await getJson<Group[]>("/groups/")) ?? [];
|
||||
}
|
||||
|
||||
export async function getResponsesGrouped(groupBy: GroupBy, f: Filters): Promise<ChartDatum[]> {
|
||||
const query = qs({ group_by: groupBy, poll_id: f.poll_id, org_id: f.org_id, group_id: f.group_id, year: f.year });
|
||||
return (await getJson<ChartDatum[]>(`/stats/responses${query}`)) ?? [];
|
||||
}
|
||||
|
||||
export async function getDimensions(pollId?: string | null): Promise<string[]> {
|
||||
const query = qs({ poll_id: pollId });
|
||||
return (await getJson<string[]>(`/stats/results/dimensions${query}`)) ?? [];
|
||||
}
|
||||
|
||||
export async function getAvgByDimension(f: Filters): Promise<AvgDatum[]> {
|
||||
const query = qs({ poll_id: f.poll_id, org_id: f.org_id, group_id: f.group_id, year: f.year });
|
||||
return (await getJson<AvgDatum[]>(`/stats/results/avg-by-dimension${query}`)) ?? [];
|
||||
}
|
||||
|
||||
export async function getLeadingType(f: Filters): Promise<ChartDatum[]> {
|
||||
const query = qs({ poll_id: f.poll_id, org_id: f.org_id, group_id: f.group_id, year: f.year });
|
||||
return (await getJson<ChartDatum[]>(`/stats/results/leading-type${query}`)) ?? [];
|
||||
}
|
||||
|
||||
export async function getCompareGroups(
|
||||
dimensionName: string,
|
||||
by: CompareBy,
|
||||
pollId?: string | null,
|
||||
year?: number | null,
|
||||
): Promise<AvgDatum[]> {
|
||||
const query = qs({ dimension_name: dimensionName, by, poll_id: pollId, year });
|
||||
return (await getJson<AvgDatum[]>(`/stats/results/compare-groups${query}`)) ?? [];
|
||||
}
|
||||
|
||||
export async function getTopUsers(
|
||||
dimensionName: string,
|
||||
f: Filters,
|
||||
limit = 10,
|
||||
): Promise<TopUserRow[]> {
|
||||
const query = qs({
|
||||
dimension_name: dimensionName,
|
||||
poll_id: f.poll_id,
|
||||
org_id: f.org_id,
|
||||
group_id: f.group_id,
|
||||
year: f.year,
|
||||
limit,
|
||||
});
|
||||
return (await getJson<TopUserRow[]>(`/stats/results/top-users${query}`)) ?? [];
|
||||
}
|
||||
|
||||
export async function getDimensionTrend(dimensionName: string, f: Filters): Promise<AvgDatum[]> {
|
||||
const query = qs({
|
||||
dimension_name: dimensionName,
|
||||
poll_id: f.poll_id,
|
||||
org_id: f.org_id,
|
||||
group_id: f.group_id,
|
||||
});
|
||||
return (await getJson<AvgDatum[]>(`/stats/results/dimension-trend${query}`)) ?? [];
|
||||
}
|
||||
24
stats-react/src/components/ChartCard.tsx
Normal file
24
stats-react/src/components/ChartCard.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function ChartCard({
|
||||
title,
|
||||
loading,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
loading?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="chart-card">
|
||||
<div className="chart-card-title">{title}</div>
|
||||
{loading ? (
|
||||
<div className="chart-loading">
|
||||
<div className="spinner" style={{ margin: "0 auto" }} />
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
stats-react/src/components/SearchableDropdown.tsx
Normal file
83
stats-react/src/components/SearchableDropdown.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { IconCaretDown } from "./icons";
|
||||
|
||||
export interface Option {
|
||||
key: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
// Выпадающий список с поиском — порт web-react/src/components/SearchableDropdown.tsx
|
||||
// (изначально web/designer.py SearchableDropdown из Flet-версии).
|
||||
export function SearchableDropdown({
|
||||
label,
|
||||
hint,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label?: string;
|
||||
hint?: string;
|
||||
options: Option[];
|
||||
value: string | null;
|
||||
onChange: (key: string | null) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const selected = useMemo(() => options.find((o) => o.key === value) ?? null, [options, value]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return options;
|
||||
return options.filter((o) => o.text.toLowerCase().includes(q));
|
||||
}, [options, query]);
|
||||
|
||||
return (
|
||||
<div className="field">
|
||||
{label && <span className="field-label">{label}</span>}
|
||||
<div
|
||||
className="dropdown-control"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className={"dropdown-value" + (selected ? "" : " placeholder")}>
|
||||
{selected ? selected.text : hint ?? "Выберите..."}
|
||||
</span>
|
||||
<IconCaretDown style={{ color: "var(--text-secondary)", fontSize: "1.2rem", flexShrink: 0 }} />
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="modal-backdrop" onClick={() => setOpen(false)}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-title">{label}</div>
|
||||
<input
|
||||
className="dropdown-search"
|
||||
placeholder="Введите для поиска..."
|
||||
value={query}
|
||||
autoFocus
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<hr className="divider" />
|
||||
<div className="sd-list">
|
||||
{filtered.length === 0 && <div className="dropdown-empty">Ничего не найдено</div>}
|
||||
{filtered.map((o) => (
|
||||
<div
|
||||
key={o.key}
|
||||
className={"dropdown-option" + (o.key === value ? " active" : "")}
|
||||
onClick={() => {
|
||||
onChange(o.key);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{o.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
stats-react/src/components/SummaryTile.tsx
Normal file
23
stats-react/src/components/SummaryTile.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function SummaryTile({
|
||||
title,
|
||||
value,
|
||||
icon,
|
||||
color,
|
||||
}: {
|
||||
title: string;
|
||||
value: number | string;
|
||||
icon: ReactNode;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="summary-tile">
|
||||
<div className="tile-head">
|
||||
<span style={{ color, fontSize: "1.1rem", display: "flex" }}>{icon}</span>
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
<div className="tile-value">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
stats-react/src/components/TopUsersTable.tsx
Normal file
33
stats-react/src/components/TopUsersTable.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { TopUserRow } from "../api/client";
|
||||
|
||||
export function TopUsersTable({ rows }: { rows: TopUserRow[] }) {
|
||||
if (rows.length === 0) {
|
||||
return <div className="chart-empty">Нет данных</div>;
|
||||
}
|
||||
return (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table className="stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Участник</th>
|
||||
<th>Тест</th>
|
||||
<th style={{ textAlign: "right" }}>Балл</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td>{r.name}</td>
|
||||
<td className="poll-cell" title={r.poll}>
|
||||
{r.poll}
|
||||
</td>
|
||||
<td className="value-cell" style={{ textAlign: "right" }}>
|
||||
{r.value}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
stats-react/src/components/charts/BarChart.tsx
Normal file
115
stats-react/src/components/charts/BarChart.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useState } from "react";
|
||||
import { ChartTooltip, type TooltipState } from "./ChartTooltip";
|
||||
|
||||
export interface BarDatum {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
// Горизонтальный bar chart: ≤24px толщина бара, 4px скруглённый конец,
|
||||
// подпись значения у края, hover-тултип на каждом баре (сам бар — hit target).
|
||||
export function BarChart({
|
||||
data,
|
||||
colorFor,
|
||||
formatValue = (v) => String(v),
|
||||
xLabel,
|
||||
}: {
|
||||
data: BarDatum[];
|
||||
colorFor: (index: number) => string;
|
||||
formatValue?: (v: number) => string;
|
||||
xLabel?: string;
|
||||
}) {
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
|
||||
if (data.length === 0) {
|
||||
return <div className="chart-empty">Нет данных</div>;
|
||||
}
|
||||
|
||||
const max = Math.max(...data.map((d) => d.value), 1);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{data.map((d, i) => {
|
||||
const pct = Math.max((d.value / max) * 100, 2);
|
||||
const color = colorFor(i);
|
||||
return (
|
||||
<div
|
||||
key={d.label + i}
|
||||
style={{ display: "flex", alignItems: "center", gap: 10 }}
|
||||
onMouseMove={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const parent = e.currentTarget.parentElement!.parentElement!.getBoundingClientRect();
|
||||
setTooltip({
|
||||
x: rect.left - parent.left + rect.width * 0.3,
|
||||
y: rect.top - parent.top,
|
||||
label: d.label,
|
||||
value: formatValue(d.value),
|
||||
swatch: color,
|
||||
});
|
||||
}}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
onFocus={() =>
|
||||
setTooltip({ x: 0, y: 0, label: d.label, value: formatValue(d.value), swatch: color })
|
||||
}
|
||||
onBlur={() => setTooltip(null)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<span
|
||||
title={d.label}
|
||||
style={{
|
||||
width: 170,
|
||||
flexShrink: 0,
|
||||
fontSize: "0.78rem",
|
||||
color: "var(--text-secondary)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{d.label}
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
background: "var(--surface-alt)",
|
||||
borderRadius: 4,
|
||||
height: 20,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
height: "100%",
|
||||
background: color,
|
||||
borderRadius: 4,
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
minWidth: 44,
|
||||
textAlign: "right",
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{formatValue(d.value)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{xLabel && (
|
||||
<div style={{ fontSize: "0.72rem", color: "var(--text-secondary)", marginTop: 8, marginLeft: 180 }}>
|
||||
{xLabel}
|
||||
</div>
|
||||
)}
|
||||
<ChartTooltip tooltip={tooltip} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
stats-react/src/components/charts/ChartTooltip.tsx
Normal file
50
stats-react/src/components/charts/ChartTooltip.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
// Плавающий тултип, привязанный к позиции курсора внутри контейнера графика.
|
||||
// Общий для всех типов графиков — см. dataviz: "значение — сильный элемент, подпись — вторичная".
|
||||
export interface TooltipState {
|
||||
x: number;
|
||||
y: number;
|
||||
label: string;
|
||||
value: string;
|
||||
swatch?: string;
|
||||
}
|
||||
|
||||
export function ChartTooltip({ tooltip }: { tooltip: TooltipState | null }) {
|
||||
if (!tooltip) return null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: tooltip.x,
|
||||
top: tooltip.y,
|
||||
transform: "translate(-50%, -100%)",
|
||||
marginTop: -10,
|
||||
background: "var(--chart-tooltip-bg)",
|
||||
color: "var(--chart-tooltip-text)",
|
||||
borderRadius: 8,
|
||||
padding: "0.4rem 0.6rem",
|
||||
fontSize: "0.78rem",
|
||||
pointerEvents: "none",
|
||||
whiteSpace: "nowrap",
|
||||
zIndex: 30,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.18)",
|
||||
}}
|
||||
>
|
||||
{tooltip.swatch && (
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 2,
|
||||
background: tooltip.swatch,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span style={{ opacity: 0.85 }}>{tooltip.label}</span>
|
||||
<strong>{tooltip.value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
stats-react/src/components/charts/LineChart.tsx
Normal file
152
stats-react/src/components/charts/LineChart.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { ChartTooltip, type TooltipState } from "./ChartTooltip";
|
||||
|
||||
export interface LineDatum {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const W = 1000;
|
||||
const H = 300;
|
||||
const PAD_L = 44;
|
||||
const PAD_R = 16;
|
||||
const PAD_T = 16;
|
||||
const PAD_B = 48;
|
||||
|
||||
// Линейный график для одного ряда: 2px линия, area fill ~10%, маркеры ≥8px
|
||||
// с 2px кольцом в цвет поверхности, вертикальный crosshair с тултипом,
|
||||
// прямая подпись значения на последней точке.
|
||||
export function LineChart({
|
||||
data,
|
||||
color = "var(--primary)",
|
||||
formatValue = (v) => String(v),
|
||||
}: {
|
||||
data: LineDatum[];
|
||||
color?: string;
|
||||
formatValue?: (v: number) => string;
|
||||
}) {
|
||||
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
if (data.length === 0) {
|
||||
return <div className="chart-empty">Нет данных</div>;
|
||||
}
|
||||
|
||||
const max = Math.max(...data.map((d) => d.value), 1);
|
||||
const plotW = W - PAD_L - PAD_R;
|
||||
const plotH = H - PAD_T - PAD_B;
|
||||
const n = data.length;
|
||||
|
||||
const xAt = (i: number) => PAD_L + (n === 1 ? plotW / 2 : (i / (n - 1)) * plotW);
|
||||
const yAt = (v: number) => PAD_T + plotH - (v / max) * plotH;
|
||||
|
||||
const points = data.map((d, i) => ({ x: xAt(i), y: yAt(d.value), ...d }));
|
||||
const linePath = points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ");
|
||||
const areaPath = `${linePath} L ${points[points.length - 1].x} ${PAD_T + plotH} L ${points[0].x} ${PAD_T + plotH} Z`;
|
||||
|
||||
const gridLines = [0, 0.25, 0.5, 0.75, 1];
|
||||
|
||||
const handleMove = (e: React.PointerEvent<SVGSVGElement>) => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const relX = ((e.clientX - rect.left) / rect.width) * W;
|
||||
let nearest = 0;
|
||||
let best = Infinity;
|
||||
points.forEach((p, i) => {
|
||||
const d = Math.abs(p.x - relX);
|
||||
if (d < best) {
|
||||
best = d;
|
||||
nearest = i;
|
||||
}
|
||||
});
|
||||
setHoverIdx(nearest);
|
||||
const p = points[nearest];
|
||||
setTooltip({
|
||||
x: (p.x / W) * rect.width,
|
||||
y: (p.y / H) * rect.height,
|
||||
label: p.label,
|
||||
value: formatValue(p.value),
|
||||
swatch: color,
|
||||
});
|
||||
};
|
||||
|
||||
const last = points[points.length - 1];
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
style={{ width: "100%", height: "auto", display: "block" }}
|
||||
onPointerMove={handleMove}
|
||||
onPointerLeave={() => {
|
||||
setHoverIdx(null);
|
||||
setTooltip(null);
|
||||
}}
|
||||
>
|
||||
{/* gridlines */}
|
||||
{gridLines.map((g) => {
|
||||
const y = PAD_T + plotH * (1 - g);
|
||||
return (
|
||||
<g key={g}>
|
||||
<line x1={PAD_L} y1={y} x2={W - PAD_R} y2={y} stroke="var(--chart-grid)" strokeWidth={1} />
|
||||
<text x={PAD_L - 8} y={y + 4} fontSize={11} fill="var(--text-secondary)" textAnchor="end">
|
||||
{Math.round(max * g)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* area fill */}
|
||||
<path d={areaPath} fill={color} opacity={0.1} stroke="none" />
|
||||
|
||||
{/* line */}
|
||||
<path d={linePath} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
|
||||
|
||||
{/* crosshair */}
|
||||
{hoverIdx !== null && (
|
||||
<line
|
||||
x1={points[hoverIdx].x}
|
||||
y1={PAD_T}
|
||||
x2={points[hoverIdx].x}
|
||||
y2={PAD_T + plotH}
|
||||
stroke="var(--text-secondary)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* markers */}
|
||||
{points.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={hoverIdx === i ? 6 : 4.5}
|
||||
fill={color}
|
||||
stroke="var(--surface)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* end label */}
|
||||
<text x={last.x} y={last.y - 12} fontSize={12} fontWeight={700} fill="var(--text-primary)" textAnchor="end">
|
||||
{formatValue(last.value)}
|
||||
</text>
|
||||
|
||||
{/* x labels */}
|
||||
{points.map((p, i) => {
|
||||
const anchor = i === 0 ? "start" : i === points.length - 1 ? "end" : "middle";
|
||||
return (
|
||||
<text key={i} x={p.x} y={H - PAD_B + 20} fontSize={11} fill="var(--text-secondary)" textAnchor={anchor}>
|
||||
{p.label}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<ChartTooltip tooltip={tooltip} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
67
stats-react/src/components/icons.tsx
Normal file
67
stats-react/src/components/icons.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
// Небольшой набор inline-SVG иконок (замена Material Icons из Flet-версии).
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
type P = SVGProps<SVGSVGElement>;
|
||||
const base = (p: P) => ({
|
||||
width: "1em",
|
||||
height: "1em",
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "currentColor",
|
||||
...p,
|
||||
});
|
||||
|
||||
export const IconAssignment = (p: P) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M19 3h-4.18a3 3 0 0 0-5.64 0H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2Zm-7 0a1 1 0 1 1-1 1 1 1 0 0 1 1-1Zm-1.5 14.5-3.5-3.5 1.4-1.4 2.1 2.1 5.1-5.1L17 11l-6.5 6.5Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconQuiz = (p: P) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2Zm-6.5 15h-1v-1h1Zm1-4.4c-.6.5-.7.7-.7 1.4h-1c0-1.1.3-1.6 1-2.2.5-.4.8-.7.8-1.3 0-.7-.5-1.1-1.3-1.1s-1.3.5-1.4 1.2l-1.1-.2c.2-1.2 1.1-2 2.5-2 1.5 0 2.4.9 2.4 2.1 0 .8-.4 1.4-1.2 2.1Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconPlayCircle = (p: P) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2Zm-2 14.5v-9l7 4.5Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconBusiness = (p: P) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M6 3h8v4h6v14H4V3Zm2 2v14h4v-4h4v4h4V9h-4V5H8Zm2 3h2v2h-2Zm4 0h2v2h-2ZM10 12h2v2h-2Zm4 0h2v2h-2Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconFilterOff = (p: P) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M2.4 3.8 3.8 2.4l18 18-1.4 1.4-5.9-5.9-1.5 1.9v3.8h-2v-3.8L4.5 8.9l1.5-1.5H4V5h2.4Zm5.9 0H20v2h-9.7Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconRefresh = (p: P) => (
|
||||
<svg {...base(p)} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M20 11A8 8 0 1 0 18.5 16" />
|
||||
<path d="M20 4v7h-7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconBarChart = (p: P) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M5 10h3v10H5Zm11-6h3v16h-3ZM10.5 15h3v5h-3Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconInsights = (p: P) => (
|
||||
<svg {...base(p)} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 17l6-6 4 4 8-8" />
|
||||
<path d="M15 6h6v6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconCaretDown = (p: P) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M7 10l5 5 5-5Z" />
|
||||
</svg>
|
||||
);
|
||||
10
stats-react/src/main.tsx
Normal file
10
stats-react/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles/global.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
478
stats-react/src/styles/global.css
Normal file
478
stats-react/src/styles/global.css
Normal file
@@ -0,0 +1,478 @@
|
||||
/* Сброс + переменные. Цвета — web-react/src/theme/themes.ts (designer.py),
|
||||
категориальная палитра графиков — theme/palette.ts (провалидирована dataviz-скиллом). */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--primary: #f85a40;
|
||||
--primary-dark: #d94530;
|
||||
--primary-light: #ffeab7;
|
||||
--accent: #ffc845;
|
||||
--info: #007fbd;
|
||||
--success: #85c446;
|
||||
|
||||
--background: #f4f6f8;
|
||||
--surface: #ffffff;
|
||||
--surface-alt: #f0f2f5;
|
||||
--text-primary: #1e2a3a;
|
||||
--text-secondary: #5a6779;
|
||||
--text-on-primary: #ffffff;
|
||||
--border: #dde3ea;
|
||||
|
||||
--chart-grid: #dde3ea;
|
||||
--chart-tooltip-bg: #1e2a3a;
|
||||
--chart-tooltip-text: #ffffff;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--primary: #f85a40;
|
||||
--primary-dark: #d94530;
|
||||
--primary-light: #ffeab7;
|
||||
--accent: #ffc845;
|
||||
--info: #4db3e8;
|
||||
--success: #a2d97a;
|
||||
|
||||
--background: #141a22;
|
||||
--surface: #1e2733;
|
||||
--surface-alt: #263140;
|
||||
--text-primary: #f2f4f7;
|
||||
--text-secondary: #a7b2c0;
|
||||
--text-on-primary: #ffffff;
|
||||
--border: #33404f;
|
||||
|
||||
--chart-grid: #33404f;
|
||||
--chart-tooltip-bg: #f2f4f7;
|
||||
--chart-tooltip-text: #1e2a3a;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── Layout ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: var(--surface);
|
||||
padding: 0.9rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.app-header .brand {
|
||||
color: var(--primary);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.app-header .divider {
|
||||
width: 1px;
|
||||
height: 1.4rem;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.app-header .subtitle {
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1300px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* ── Summary tiles ───────────────────────────────────────────────────── */
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.summary-tile {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.9rem 1rem;
|
||||
min-width: 190px;
|
||||
flex: 1 1 190px;
|
||||
}
|
||||
|
||||
.summary-tile .tile-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.72rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.summary-tile .tile-value {
|
||||
font-size: 1.7rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Filters ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.filters-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.filters-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.filters-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.filters-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.btn {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: var(--text-on-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
/* ── Dropdown ────────────────────────────────────────────────────────── */
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 190px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dropdown-control {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
min-height: 2.3rem;
|
||||
}
|
||||
|
||||
.dropdown-value {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dropdown-value.placeholder {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.dropdown-search {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.7rem;
|
||||
font-size: 0.85rem;
|
||||
background: var(--background);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 0.6rem 0;
|
||||
}
|
||||
|
||||
.sd-list {
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dropdown-option {
|
||||
padding: 0.55rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dropdown-option:hover {
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
.dropdown-option.active {
|
||||
background: var(--primary);
|
||||
color: var(--text-on-primary);
|
||||
}
|
||||
|
||||
.dropdown-empty {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Tabs ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.tabs-bar {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.7rem 1rem;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Chart cards ─────────────────────────────────────────────────────── */
|
||||
|
||||
.chart-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.chart-card-title {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
.chart-empty {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
font-size: 0.85rem;
|
||||
padding: 2rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chart-loading {
|
||||
padding: 2rem 0;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ── Legend ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem 1rem;
|
||||
margin-top: 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.legend-swatch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Table ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.stats-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.stats-table th {
|
||||
text-align: left;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.stats-table td {
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.stats-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.stats-table .value-cell {
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stats-table .poll-cell {
|
||||
color: var(--text-secondary);
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Spinner ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.spinner {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.spinner-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
43
stats-react/src/theme/palette.ts
Normal file
43
stats-react/src/theme/palette.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// Палитра дашборда. Бренд-цвета — из web-react/src/theme/themes.ts (designer.py).
|
||||
// Категориальная 8-цветная палитра для графиков (pie/multi-bar) провалидирована
|
||||
// dataviz-скиллом (scripts/validate_palette.js) на CVD-безопасность в light и
|
||||
// dark режимах — порядок слотов и hex не менять без повторной валидации.
|
||||
// Единственная замена относительно референсного набора: слот "orange" — бренд-оранжевый.
|
||||
|
||||
export const BRAND = {
|
||||
primary: "#F85A40",
|
||||
primaryDark: "#D94530",
|
||||
primaryLight: "#FFEAB7",
|
||||
accent: "#FFC845",
|
||||
info: "#007FBD",
|
||||
success: "#85C446",
|
||||
};
|
||||
|
||||
// [PASS] все проверки, light: surface #FFFFFF (WARN contrast на magenta/yellow/aqua — компенсируется прямыми подписями значений)
|
||||
export const CATEGORICAL_LIGHT = [
|
||||
"#2a78d6", // blue
|
||||
"#008300", // green
|
||||
"#e87ba4", // magenta
|
||||
"#eda100", // yellow
|
||||
"#1baf7a", // aqua
|
||||
"#F85A40", // orange (бренд)
|
||||
"#4a3aa7", // violet
|
||||
"#e34948", // red
|
||||
];
|
||||
|
||||
// [PASS] все проверки, dark: surface #1a1a19
|
||||
export const CATEGORICAL_DARK = [
|
||||
"#3987e5",
|
||||
"#008300",
|
||||
"#d55181",
|
||||
"#c98500",
|
||||
"#199e70",
|
||||
"#D94530", // orange (бренд, тёмный шаг)
|
||||
"#9085e9",
|
||||
"#e66767",
|
||||
];
|
||||
|
||||
export function categoricalColor(index: number, dark: boolean): string {
|
||||
const arr = dark ? CATEGORICAL_DARK : CATEGORICAL_LIGHT;
|
||||
return arr[index % arr.length];
|
||||
}
|
||||
18
stats-react/src/theme/useIsDark.ts
Normal file
18
stats-react/src/theme/useIsDark.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Категориальная палитра графиков зависит от режима (см. palette.ts) —
|
||||
// нужно знать активный prefers-color-scheme, чтобы выбрать нужный набор.
|
||||
export function useIsDark(): boolean {
|
||||
const [dark, setDark] = useState(
|
||||
() => typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const onChange = () => setDark(mq.matches);
|
||||
mq.addEventListener("change", onChange);
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return dark;
|
||||
}
|
||||
21
stats-react/tsconfig.json
Normal file
21
stats-react/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
18
stats-react/vite.config.ts
Normal file
18
stats-react/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// В dev-режиме проксируем /api на запущенный локально API (docker compose up -d api).
|
||||
// В проде эту роль выполняет nginx (stats-react/nginx.conf).
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: process.env.API_URL || "http://localhost:8000",
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user