Files
NextEdu/src/modules/grades/components/grade-distribution-chart.tsx
SpecialX 95145cd03b feat(grades): add ranking trend, school-wide summary, score cell, and scope filter
- Add ranking-trend-card and school-wide-summary-card for broader analytics

- Add score-cell and grade-filters components for table rendering

- Add scope-filter and type-guards lib utilities for grade data filtering

- Update actions, data-access (analytics, ranking, main), stats-service, export

- Update schema, types, and grade-utils lib

- Update all grade chart and report components (distribution, trend, comparison, query)
2026-06-23 17:37:32 +08:00

175 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { PieChart as PieChartIcon } from "lucide-react"
import { useTranslations } from "next-intl"
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
import { SimpleBarChart } from "@/shared/components/charts/simple-bar-chart"
import type { GradeDistributionResult } from "@/modules/grades/types"
/**
* v4-P3-4: 色盲友好的双重编码。
* 每个分数段使用不同的 SVG pattern条纹/点状/交叉线等)+ 颜色,
* 确保色觉障碍用户能通过纹理区分各分数段。
*/
const BUCKET_FILLS: Record<string, string> = {
"90-100": "url(#grade-pattern-90-100)",
"80-89": "url(#grade-pattern-80-89)",
"70-79": "url(#grade-pattern-70-79)",
"60-69": "url(#grade-pattern-60-69)",
"<60": "url(#grade-pattern-lt60)",
}
/** v4-P3-4: SVG 图案定义,为每个分数段提供独特的纹理 */
const PATTERN_DEFS = (
<defs>
{/* 90-100: 正向斜条纹 */}
<pattern
id="grade-pattern-90-100"
patternUnits="userSpaceOnUse"
width="8"
height="8"
patternTransform="rotate(45)"
>
<rect width="8" height="8" fill="hsl(142, 71%, 45%)" />
<line x1="0" y1="0" x2="0" y2="8" stroke="hsl(142, 71%, 25%)" strokeWidth="3" />
</pattern>
{/* 80-89: 圆点 */}
<pattern
id="grade-pattern-80-89"
patternUnits="userSpaceOnUse"
width="10"
height="10"
>
<rect width="10" height="10" fill="hsl(217, 91%, 60%)" />
<circle cx="5" cy="5" r="2.5" fill="hsl(217, 91%, 35%)" />
</pattern>
{/* 70-79: 交叉线 */}
<pattern
id="grade-pattern-70-79"
patternUnits="userSpaceOnUse"
width="8"
height="8"
>
<rect width="8" height="8" fill="hsl(43, 96%, 56%)" />
<path d="M0,0 L8,8 M8,0 L0,8" stroke="hsl(43, 96%, 31%)" strokeWidth="1.5" />
</pattern>
{/* 60-69: 反向斜条纹 */}
<pattern
id="grade-pattern-60-69"
patternUnits="userSpaceOnUse"
width="8"
height="8"
patternTransform="rotate(-45)"
>
<rect width="8" height="8" fill="hsl(25, 95%, 53%)" />
<line x1="0" y1="0" x2="0" y2="8" stroke="hsl(25, 95%, 33%)" strokeWidth="3" />
</pattern>
{/* <60: 网格 */}
<pattern
id="grade-pattern-lt60"
patternUnits="userSpaceOnUse"
width="8"
height="8"
>
<rect width="8" height="8" fill="hsl(0, 84%, 60%)" />
<path d="M0,0 L8,0 M0,0 L0,8" stroke="hsl(0, 84%, 40%)" strokeWidth="1.5" />
</pattern>
</defs>
)
interface GradeDistributionChartProps {
data: GradeDistributionResult | null
}
interface DistributionTooltipItem {
label: string
count: number
percentage: number
}
interface DistributionTooltipPayload {
payload?: DistributionTooltipItem
}
function isDistributionTooltipPayload(v: unknown): v is DistributionTooltipPayload {
if (typeof v !== "object" || v === null) return false
const obj = v as Record<string, unknown>
const inner = obj.payload
if (inner === undefined || inner === null) return true
if (typeof inner !== "object") return false
const item = inner as Record<string, unknown>
return (
typeof item.label === "string" &&
typeof item.count === "number" &&
typeof item.percentage === "number"
)
}
export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
const t = useTranslations("grades")
const isEmpty = !data || data.totalCount === 0
const chartData = isEmpty
? []
: data.buckets.map((b) => ({
label: b.label,
count: b.count,
percentage:
data.totalCount > 0
? Math.round((b.count / data.totalCount) * 1000) / 10
: 0,
}))
return (
<ChartCardShell
title={t("distribution.title")}
description={
isEmpty
? t("distribution.descriptionEmpty")
: t("distribution.descriptionNonEmpty", { count: data.totalCount })
}
icon={PieChartIcon}
isEmpty={isEmpty}
emptyTitle={t("distribution.emptyTitle")}
emptyDescription={t("distribution.emptyDescription")}
emptyClassName="h-60"
>
<div role="img" aria-label={isEmpty ? t("distribution.ariaLabelEmpty") : t("distribution.ariaLabelNonEmpty", { count: data.totalCount })}>
<SimpleBarChart
data={chartData}
bars={[
{
dataKey: "count",
name: t("chart.students"),
color: "hsl(var(--primary))",
},
]}
xKey="label"
xTickFormatter={null}
yAllowDecimals={false}
yWidth={32}
heightClassName="h-[280px]"
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
tooltipClassName="w-[200px]"
defs={PATTERN_DEFS}
cellColors={BUCKET_FILLS}
tooltipFormatter={(payload: unknown) => {
if (!isDistributionTooltipPayload(payload)) return null
const item = payload.payload
if (!item) return null
return (
<div className="flex w-full flex-col gap-0.5">
<span className="text-sm font-medium">
{item.label}: {t("distribution.tooltipStudents", { count: item.count })}
</span>
<span className="text-xs text-muted-foreground">{t("distribution.tooltipOfTotal", { percentage: item.percentage })}</span>
</div>
)
}}
/>
</div>
</ChartCardShell>
)
}