Update Point Progression Chart.

This commit is contained in:
Chris Parsons 2026-04-15 23:37:33 -07:00
parent b27adfbca0
commit fb3bec9a2a
2 changed files with 245 additions and 42 deletions

View file

@ -0,0 +1,152 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PointProgressionChart } from "./PointProgressionChart";
import type { ChartDataPoint } from "./PointProgressionChart";
const meta: Meta<typeof PointProgressionChart> = {
title: "Standings/PointProgressionChart",
component: PointProgressionChart,
parameters: {
layout: "padded",
docs: {
description: {
component: "Multi-line chart showing team point progression over time. Features monotone curve rendering and interactive legend hover highlighting to isolate individual team lines.",
},
},
},
};
export default meta;
type Story = StoryObj<typeof PointProgressionChart>;
export const Empty: Story = {
args: {
chartData: [],
teams: [],
},
};
const twoTeams = [
{ id: "team1", name: "Thunderhawks" },
{ id: "team2", name: "Storm Chasers" },
];
const twoTeamData = [
{ date: "2024-01-01", Thunderhawks: 25, "Storm Chasers": 20 },
{ date: "2024-01-02", Thunderhawks: 50, "Storm Chasers": 40 },
{ date: "2024-01-03", Thunderhawks: 75, "Storm Chasers": 60 },
{ date: "2024-01-04", Thunderhawks: 100, "Storm Chasers": 80 },
{ date: "2024-01-05", Thunderhawks: 125, "Storm Chasers": 105 },
];
export const TwoTeams: Story = {
args: {
chartData: twoTeamData,
teams: twoTeams,
},
};
const fiveTeams = [
{ id: "team1", name: "Thunderhawks" },
{ id: "team2", name: "Storm Chasers" },
{ id: "team3", name: "Golden Eagles" },
{ id: "team4", name: "Lightning Bolts" },
{ id: "team5", name: "Iron Wolves" },
];
const fiveTeamData = [
{ date: "2024-01-01", Thunderhawks: 25, "Storm Chasers": 20, "Golden Eagles": 30, "Lightning Bolts": 15, "Iron Wolves": 10 },
{ date: "2024-01-02", Thunderhawks: 50, "Storm Chasers": 45, "Golden Eagles": 40, "Lightning Bolts": 35, "Iron Wolves": 25 },
{ date: "2024-01-03", Thunderhawks: 75, "Storm Chasers": 70, "Golden Eagles": 55, "Lightning Bolts": 65, "Iron Wolves": 50 },
{ date: "2024-01-04", Thunderhawks: 100, "Storm Chasers": 95, "Golden Eagles": 85, "Lightning Bolts": 90, "Iron Wolves": 80 },
{ date: "2024-01-05", Thunderhawks: 125, "Storm Chasers": 115, "Golden Eagles": 105, "Lightning Bolts": 110, "Iron Wolves": 100 },
];
export const FiveTeams: Story = {
args: {
chartData: fiveTeamData,
teams: fiveTeams,
},
};
const overlappingTeams = [
{ id: "team1", name: "Alpha" },
{ id: "team2", name: "Beta" },
{ id: "team3", name: "Gamma" },
];
const overlappingData: ChartDataPoint[] = [
{ date: "2024-01-01", Alpha: 100, Beta: 100, Gamma: 100 },
{ date: "2024-01-02", Alpha: 110, Beta: 110, Gamma: 105 },
{ date: "2024-01-03", Alpha: 120, Beta: 115, Gamma: 120 },
{ date: "2024-01-04", Alpha: 125, Beta: 125, Gamma: 125 },
];
export const OverlappingPoints: Story = {
name: "Overlapping Points (Hover Legend)",
args: {
chartData: overlappingData,
teams: overlappingTeams,
},
parameters: {
docs: {
description: {
story: "Demonstrates interactive highlighting via the legend. Hover over a team name in the legend to isolate that team's line and dim the others.",
},
},
},
};
const soloTeam = [{ id: "team1", name: "Solo Team" }];
const soloTeamData = [
{ date: "2024-01-01", "Solo Team": 10 },
{ date: "2024-01-02", "Solo Team": 20 },
{ date: "2024-01-03", "Solo Team": 30 },
{ date: "2024-01-04", "Solo Team": 40 },
{ date: "2024-01-05", "Solo Team": 50 },
];
export const SingleTeam: Story = {
args: {
chartData: soloTeamData,
teams: soloTeam,
},
};
const manyTeams = Array.from({ length: 12 }, (_, i) => ({
id: `team${i}`,
name: `Team ${i + 1}`,
}));
const manyTeamData = [
{ date: "2024-01-01", ...Object.fromEntries(manyTeams.map((t, i) => [t.name, (i + 1) * 10])) },
{ date: "2024-01-02", ...Object.fromEntries(manyTeams.map((t, i) => [t.name, (i + 1) * 10 + 15])) },
{ date: "2024-01-03", ...Object.fromEntries(manyTeams.map((t, i) => [t.name, (i + 1) * 10 + 30])) },
];
export const ManyTeams: Story = {
args: {
chartData: manyTeamData,
teams: manyTeams,
},
};
const lateStartTeams = [
{ id: "team1", name: "Early Birds" },
{ id: "team2", name: "Late Bloomer" },
];
const lateStartData: ChartDataPoint[] = [
{ date: "2024-01-01", "Early Birds": 25, "Late Bloomer": 0 },
{ date: "2024-01-02", "Early Birds": 50, "Late Bloomer": 5 },
{ date: "2024-01-03", "Early Birds": 75, "Late Bloomer": 25 },
{ date: "2024-01-04", "Early Birds": 100, "Late Bloomer": 60 },
{ date: "2024-01-05", "Early Birds": 125, "Late Bloomer": 110 },
];
export const LateStart: Story = {
args: {
chartData: lateStartData,
teams: lateStartTeams,
},
};

View file

@ -1,12 +1,62 @@
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, ResponsiveContainer } from 'recharts';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "~/components/ui/card";
import { useState } from "react";
interface LegendPayload {
value: string;
color: string;
}
interface CustomLegendProps {
payload: LegendPayload[];
onHover: (dataKey: string | null) => void;
onLeave: () => void;
}
function CustomLegend({ payload, onHover, onLeave }: CustomLegendProps) {
return (
<div className="flex flex-wrap md:flex-col gap-3 px-4" role="group" aria-label="Chart legend">
{payload.map((entry) => (
<div
key={entry.value}
className="flex items-center gap-2 cursor-pointer transition-opacity hover:opacity-80"
style={{ color: entry.color }}
onMouseEnter={() => onHover(entry.value)}
onMouseLeave={onLeave}
role="button"
aria-label={`Toggle ${entry.value} line`}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onHover(entry.value);
}
}}
onKeyUp={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onLeave();
}
}}
>
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: entry.color }}
aria-hidden="true"
/>
<span className="text-sm font-medium">{entry.value}</span>
</div>
))}
</div>
);
}
interface Team { interface Team {
id: string; id: string;
name: string; name: string;
} }
interface ChartDataPoint { export interface ChartDataPoint {
date: string; date: string;
[teamName: string]: string | number; [teamName: string]: string | number;
} }
@ -36,6 +86,8 @@ function formatDate(dateStr: string) {
} }
export function PointProgressionChart({ chartData, teams }: PointProgressionChartProps) { export function PointProgressionChart({ chartData, teams }: PointProgressionChartProps) {
const [hoveredLine, setHoveredLine] = useState<string | null>(null);
if (chartData.length === 0) { if (chartData.length === 0) {
return ( return (
<Card> <Card>
@ -62,8 +114,10 @@ export function PointProgressionChart({ chartData, teams }: PointProgressionChar
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-col md:flex-row md:items-center gap-6">
<div className="flex-1 min-w-0">
<ResponsiveContainer width="100%" height={400}> <ResponsiveContainer width="100%" height={400}>
<LineChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}> <LineChart data={chartData} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis <XAxis
dataKey="date" dataKey="date"
@ -72,22 +126,10 @@ export function PointProgressionChart({ chartData, teams }: PointProgressionChar
tick={{ fill: 'currentColor' }} tick={{ fill: 'currentColor' }}
/> />
<YAxis <YAxis
label={{ value: 'Points', angle: -90, position: 'insideLeft' }} width={40}
className="text-xs" className="text-xs"
tick={{ fill: 'currentColor' }} tick={{ fill: 'currentColor' }}
/> />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--background))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
labelFormatter={(label) => formatDate(label as string)}
formatter={(value: number) => [value.toFixed(2), '']}
/>
<Legend
wrapperStyle={{ paddingTop: '20px' }}
/>
{teams.map((team, index) => ( {teams.map((team, index) => (
<Line <Line
key={team.id} key={team.id}
@ -95,13 +137,22 @@ export function PointProgressionChart({ chartData, teams }: PointProgressionChar
dataKey={team.name} dataKey={team.name}
stroke={COLORS[index % COLORS.length]} stroke={COLORS[index % COLORS.length]}
strokeWidth={2} strokeWidth={2}
dot={{ r: 3 }} dot={false}
activeDot={{ r: 5 }} activeDot={false}
strokeOpacity={hoveredLine === null || hoveredLine === team.name ? 1 : 0.1}
isAnimationActive={false}
connectNulls connectNulls
/> />
))} ))}
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div>
<CustomLegend
payload={teams.map((team, index) => ({ value: team.name, color: COLORS[index % COLORS.length] }))}
onHover={setHoveredLine}
onLeave={() => setHoveredLine(null)}
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
); );