63 lines
3.0 KiB
TypeScript
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>
|
||
|
|
);
|
||
|
|
}
|