Files
admin/src/components/charts/SimpleBarChart.tsx
2026-06-01 16:02:27 +08:00

176 lines
4.7 KiB
TypeScript

"use client"
import { useMemo, useState } from "react"
interface SimpleBarChartProps {
data: Record<string, string | number>[]
dataKey: string
xAxisKey: string
fill?: string
height?: number
}
export function SimpleBarChart({
data,
dataKey,
xAxisKey,
fill = "#f59e0b",
height = 250,
}: SimpleBarChartProps) {
const [tooltip, setTooltip] = useState<{
x: number
y: number
label: string
value: number
} | null>(null)
const padding = { top: 20, right: 20, bottom: 40, left: 50 }
const chartWidth = 600
const chartHeight = height
const innerWidth = chartWidth - padding.left - padding.right
const innerHeight = chartHeight - padding.top - padding.bottom
const { bars, xLabels, yLabels } = useMemo(() => {
if (data.length === 0) return { bars: [], xLabels: [], yLabels: [] }
const values = data.map(d => Number(d[dataKey]) || 0)
const maxVal = Math.max(...values, 1)
const yMax = Math.ceil(maxVal * 1.2)
const barCount = data.length
const barGap = barCount > 1 ? (innerWidth * 0.1) / (barCount - 1) : 0
const totalGap = barGap * (barCount - 1)
const barWidth = Math.min((innerWidth - totalGap) / barCount, 50)
const startX =
padding.left + (innerWidth - (barWidth * barCount + totalGap)) / 2
const brs = data.map((d, i) => {
const val = Number(d[dataKey]) || 0
const barHeight = (val / yMax) * innerHeight
return {
x: startX + i * (barWidth + barGap),
y: padding.top + innerHeight - barHeight,
width: barWidth,
height: barHeight,
label: String(d[xAxisKey] ?? ""),
value: val,
}
})
const xLbls = brs.map(b => ({
x: b.x + b.width / 2,
label: b.label,
}))
const yTickCount = 5
const yLbls = Array.from({ length: yTickCount + 1 }, (_, i) => {
const val = Math.round((yMax / yTickCount) * i)
return {
y: padding.top + innerHeight - (val / yMax) * innerHeight,
label: String(val),
}
})
return { bars: brs, xLabels: xLbls, yLabels: yLbls }
}, [data, dataKey, xAxisKey, innerWidth, innerHeight])
if (data.length === 0) {
return (
<div
className="flex items-center justify-center text-muted-foreground text-sm"
style={{ height }}
>
</div>
)
}
return (
<div className="relative" style={{ height }}>
<svg
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
className="w-full h-full"
preserveAspectRatio="xMidYMid meet"
>
{yLabels.map(yl => (
<g key={yl.label}>
<line
x1={padding.left}
y1={yl.y}
x2={padding.left + innerWidth}
y2={yl.y}
stroke="hsl(var(--border))"
strokeDasharray="4 4"
strokeWidth={0.5}
/>
<text
x={padding.left - 8}
y={yl.y + 4}
textAnchor="end"
className="fill-muted-foreground"
fontSize={11}
>
{yl.label}
</text>
</g>
))}
{bars.map((bar, i) => (
<rect
key={i}
x={bar.x}
y={bar.y}
width={bar.width}
height={bar.height}
rx={3}
fill={fill}
role="button"
aria-label={`${bar.label}: ${bar.value}`}
className="cursor-pointer opacity-90 hover:opacity-100 transition-opacity"
onMouseEnter={() =>
setTooltip({
x: bar.x + bar.width / 2,
y: bar.y,
label: bar.label,
value: bar.value,
})
}
onMouseLeave={() => setTooltip(null)}
/>
))}
{xLabels.map((xl, i) =>
i % Math.ceil(xLabels.length / 10) === 0 ||
i === xLabels.length - 1 ? (
<text
key={i}
x={xl.x}
y={padding.top + innerHeight + 20}
textAnchor="middle"
className="fill-muted-foreground"
fontSize={11}
>
{xl.label}
</text>
) : null,
)}
</svg>
{tooltip && (
<div
className="absolute pointer-events-none bg-popover border rounded-md px-2 py-1 text-xs shadow-sm z-10"
style={{
left: `${((tooltip.x / chartWidth) * 100).toFixed(1)}%`,
top: tooltip.y - 30,
transform: "translate(-50%, -100%)",
}}
>
<div className="text-muted-foreground">{tooltip.label}</div>
<div className="font-semibold">{tooltip.value}</div>
</div>
)}
</div>
)
}