diff --git a/app/components/standings/PointProgressionChart.stories.tsx b/app/components/standings/PointProgressionChart.stories.tsx new file mode 100644 index 0000000..17c821e --- /dev/null +++ b/app/components/standings/PointProgressionChart.stories.tsx @@ -0,0 +1,152 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PointProgressionChart } from "./PointProgressionChart"; +import type { ChartDataPoint } from "./PointProgressionChart"; + +const meta: Meta = { + 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; + +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, + }, +}; diff --git a/app/components/standings/PointProgressionChart.tsx b/app/components/standings/PointProgressionChart.tsx index ec867bc..3bab221 100644 --- a/app/components/standings/PointProgressionChart.tsx +++ b/app/components/standings/PointProgressionChart.tsx @@ -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 { 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 ( +
+ {payload.map((entry) => ( +
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(); + } + }} + > +
+ ))} +
+ ); +} interface Team { id: string; name: string; } -interface ChartDataPoint { +export interface ChartDataPoint { date: string; [teamName: string]: string | number; } @@ -36,6 +86,8 @@ function formatDate(dateStr: string) { } export function PointProgressionChart({ chartData, teams }: PointProgressionChartProps) { + const [hoveredLine, setHoveredLine] = useState(null); + if (chartData.length === 0) { return ( @@ -62,46 +114,45 @@ export function PointProgressionChart({ chartData, teams }: PointProgressionChar - - - - - - formatDate(label as string)} - formatter={(value: number) => [value.toFixed(2), '']} - /> - - {teams.map((team, index) => ( - - ))} - - +
+
+ + + + + + {teams.map((team, index) => ( + + ))} + + +
+ ({ value: team.name, color: COLORS[index % COLORS.length] }))} + onHover={setHoveredLine} + onLeave={() => setHoveredLine(null)} + /> +
);