Files
forge/client/src/components/charts/DonutChart/index.tsx
T
Dmytro Tkachenko 28d817ebe9 Init
2026-08-29 11:59:28 +03:00

63 lines
3.0 KiB
TypeScript

import { useState } from 'react';
import styles from './DonutChart.module.scss';
import type { Bucket } from '../../../types/analytics.types';
const PALETTE = ['#6366f1', '#8b5cf6', '#06b6d4', '#f59e0b', '#10b981', '#ef4444', '#ec4899', '#3b82f6', '#14b8a6', '#f97316', '#a855f7', '#0ea5e9'];
const SIZE = 200, R = 78, STROKE = 20, C = 2 * Math.PI * R;
export default function DonutChart({ title, data, topN = 11 }: { title: string; data: Bucket[]; topN?: number }) {
const [pct, setPct] = useState(false);
const sorted = [...data].sort((a, b) => b.count - a.count);
const head = sorted.slice(0, topN);
const rest = sorted.slice(topN);
const restTotal = rest.reduce((s, b) => s + b.count, 0);
const slices = restTotal > 0 ? [...head, { key: '__others', label: `Others (${rest.length})`, count: restTotal }] : head;
const total = slices.reduce((s, b) => s + b.count, 0) || 1;
const color = (i: number) => (i === slices.length - 1 && restTotal > 0 ? 'var(--text-dim)' : PALETTE[i % PALETTE.length]);
let offset = 0;
const arcs = slices.map((b, i) => {
const frac = b.count / total;
const dash = frac * C;
const arc = { b, i, dash, gap: C - dash, rot: (offset / C) * 360, color: color(i) };
offset += dash;
return arc;
});
return (
<figure className={styles.card}>
<figcaption className={styles.head}>
<span className={styles.title}>{title}</span>
<div className={styles.toggle}>
<button className={!pct ? styles.on : ''} onClick={() => setPct(false)}>#</button>
<button className={pct ? styles.on : ''} onClick={() => setPct(true)}>%</button>
</div>
</figcaption>
<div className={styles.body}>
<svg className={styles.ring} viewBox={`0 0 ${SIZE} ${SIZE}`} role="img" aria-label={title}>
<circle cx={SIZE / 2} cy={SIZE / 2} r={R} fill="none" stroke="var(--surface-alt)" strokeWidth={STROKE} />
{arcs.map(a => (
<circle key={a.b.key} cx={SIZE / 2} cy={SIZE / 2} r={R} fill="none"
stroke={a.color} strokeWidth={STROKE} strokeDasharray={`${a.dash} ${a.gap}`}
transform={`rotate(${a.rot - 90} ${SIZE / 2} ${SIZE / 2})`}>
<title>{`${a.b.label}: ${a.b.count} (${Math.round((a.b.count / total) * 100)}%)`}</title>
</circle>
))}
<text x={SIZE / 2} y={SIZE / 2 - 4} textAnchor="middle" className={styles.centerNum}>{total}</text>
<text x={SIZE / 2} y={SIZE / 2 + 14} textAnchor="middle" className={styles.centerLbl}>total</text>
</svg>
<ul className={styles.legend}>
{arcs.map(a => (
<li key={a.b.key} className={styles.legItem}>
<span className={styles.sw} style={{ background: a.color }} />
<span className={styles.legLabel} title={a.b.label}>{a.b.label}</span>
<span className={styles.legVal}>{pct ? `${Math.round((a.b.count / total) * 100)}%` : a.b.count}</span>
</li>
))}
</ul>
</div>
</figure>
);
}