mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-02-13 17:35:44 +01:00
- Update tsconfig to ES2024 target and bundler moduleResolution - Add dynamic imports for chart.js and recharts (bundle optimization) - Consolidate 17 useState into useReducer in sessions page - Fix 18 .js extension imports across lib files - Add type declarations for @rapideditor/country-coder - Fix platform user types (PlatformUserRole enum) - Fix Calendar component prop types - Centralize next-auth type augmentation - Add force-dynamic to all API routes (prevent build-time prerender) - Fix Prisma JSON null handling with Prisma.DbNull - Fix various type mismatches (SessionMessage, ImportRecord, etc.) - Export ButtonProps from button component - Update next-themes import path - Replace JSX.Element with React.ReactElement - Remove obsolete debug scripts and pnpm lockfile - Downgrade eslint to v8 for next compatibility
196 lines
5.6 KiB
TypeScript
196 lines
5.6 KiB
TypeScript
"use client";
|
|
|
|
import type { BubbleDataPoint, Chart as ChartType, Point } from "chart.js";
|
|
import dynamic from "next/dynamic";
|
|
import { useEffect, useRef } from "react";
|
|
|
|
interface DonutChartProps {
|
|
data: {
|
|
labels: string[];
|
|
values: number[];
|
|
colors?: string[];
|
|
};
|
|
centerText?: {
|
|
title?: string;
|
|
value?: string | number;
|
|
};
|
|
}
|
|
|
|
function DonutChartSkeleton() {
|
|
return (
|
|
<div className="flex items-center justify-center h-[300px]">
|
|
<div className="animate-pulse flex items-center gap-8">
|
|
<div className="w-40 h-40 rounded-full bg-gray-200 dark:bg-gray-700" />
|
|
<div className="space-y-2">
|
|
{[...Array(4)].map((_, i) => (
|
|
<div key={i} className="flex items-center gap-2">
|
|
<div className="w-3 h-3 rounded-full bg-gray-200 dark:bg-gray-700" />
|
|
<div className="w-20 h-3 rounded bg-gray-200 dark:bg-gray-700" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DonutChartInner({ data, centerText }: DonutChartProps) {
|
|
const ref = useRef<HTMLCanvasElement | null>(null);
|
|
const chartRef = useRef<ChartType<"doughnut"> | null>(null);
|
|
|
|
useEffect(() => {
|
|
let isMounted = true;
|
|
|
|
async function initChart() {
|
|
if (!ref.current || !data.values.length) return;
|
|
|
|
const ctx = ref.current.getContext("2d");
|
|
if (!ctx) return;
|
|
|
|
const { default: Chart } = await import("chart.js/auto");
|
|
|
|
if (!isMounted) return;
|
|
|
|
// Default colors if not provided
|
|
const defaultColors: string[] = [
|
|
"rgba(59, 130, 246, 0.8)", // blue
|
|
"rgba(16, 185, 129, 0.8)", // green
|
|
"rgba(249, 115, 22, 0.8)", // orange
|
|
"rgba(236, 72, 153, 0.8)", // pink
|
|
"rgba(139, 92, 246, 0.8)", // purple
|
|
"rgba(107, 114, 128, 0.8)", // gray
|
|
];
|
|
|
|
const colors: string[] = data.colors || defaultColors;
|
|
|
|
// Helper to create an array of colors based on the data length
|
|
const getColors = () => {
|
|
const result: string[] = [];
|
|
for (let i = 0; i < data.values.length; i++) {
|
|
result.push(colors[i % colors.length]);
|
|
}
|
|
return result;
|
|
};
|
|
|
|
// Destroy existing chart if any
|
|
if (chartRef.current) {
|
|
chartRef.current.destroy();
|
|
}
|
|
|
|
chartRef.current = new Chart(ctx, {
|
|
type: "doughnut",
|
|
data: {
|
|
labels: data.labels,
|
|
datasets: [
|
|
{
|
|
data: data.values,
|
|
backgroundColor: getColors(),
|
|
borderWidth: 1,
|
|
hoverOffset: 5,
|
|
},
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: true,
|
|
cutout: "70%",
|
|
plugins: {
|
|
legend: {
|
|
position: "right",
|
|
labels: {
|
|
boxWidth: 12,
|
|
padding: 20,
|
|
usePointStyle: true,
|
|
},
|
|
},
|
|
tooltip: {
|
|
callbacks: {
|
|
label: (context) => {
|
|
const label = context.label || "";
|
|
const value = context.formattedValue;
|
|
const total = context.chart.data.datasets[0].data.reduce(
|
|
(
|
|
a: number,
|
|
b:
|
|
| number
|
|
| Point
|
|
| [number, number]
|
|
| BubbleDataPoint
|
|
| null
|
|
) => {
|
|
if (typeof b === "number") {
|
|
return a + b;
|
|
}
|
|
return a;
|
|
},
|
|
0
|
|
) as number;
|
|
const percentage = Math.round((context.parsed * 100) / total);
|
|
return `${label}: ${value} (${percentage}%)`;
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
plugins: centerText
|
|
? [
|
|
{
|
|
id: "centerText",
|
|
beforeDraw: (chart: ChartType<"doughnut">) => {
|
|
const height = chart.height;
|
|
const ctx = chart.ctx;
|
|
ctx.restore();
|
|
|
|
const chartArea = chart.chartArea;
|
|
const chartWidth = chartArea.right - chartArea.left;
|
|
const centerX = chartArea.left + chartWidth / 2;
|
|
const centerY = height / 2;
|
|
|
|
if (centerText.title) {
|
|
ctx.font = "1rem sans-serif";
|
|
ctx.fillStyle = "#6B7280";
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillText(centerText.title, centerX, centerY - 10);
|
|
}
|
|
|
|
if (centerText.value !== undefined) {
|
|
ctx.font = "bold 1.5rem sans-serif";
|
|
ctx.fillStyle = "#1F2937";
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillText(
|
|
centerText.value.toString(),
|
|
centerX,
|
|
centerY + 15
|
|
);
|
|
}
|
|
ctx.save();
|
|
},
|
|
},
|
|
]
|
|
: [],
|
|
});
|
|
}
|
|
|
|
initChart();
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
if (chartRef.current) {
|
|
chartRef.current.destroy();
|
|
chartRef.current = null;
|
|
}
|
|
};
|
|
}, [data, centerText]);
|
|
|
|
return <canvas ref={ref} height={300} />;
|
|
}
|
|
|
|
const DonutChart = dynamic(() => Promise.resolve(DonutChartInner), {
|
|
ssr: false,
|
|
loading: () => <DonutChartSkeleton />,
|
|
});
|
|
|
|
export default DonutChart;
|