components/tanstack-charts/line/indexed-multi-line.tsx
import { type ChartPoint, defineChart, lineY, normalize, ruleY, select, text } from "@tanstack/charts";
import { motion } from "@tanstack/charts/motion";
import { tooltip } from "@tanstack/charts/tooltip";
import { Chart } from "@tanstack/react-charts/core";
import { useMemo } from "react";
import { scaleLinear, scaleUtc } from "d3-scale";
import { TrendingUp } from "lucide-react";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
interface IndustryDatum {
industry: string;
date: Date;
unemployed: number;
indexed?: number;
}
const motionRenderer = motion({
initial: "always",
transition: { type: "spring", stiffness: 170, damping: 18, mass: 1 },
});
const industryList = ["Construction", "Finance", "Government", "Manufacturing"];
function generateDemoData(): IndustryDatum[] {
const results: IndustryDatum[] = [];
const months = 24;
industryList.forEach((ind, i) => {
let baseVal = 100 + i * 20;
for (let m = 0; m < months; m++) {
const date = new Date(Date.UTC(2023, m, 1));
const change = (Math.random() - 0.45) * 8 + m * 1.5 * (i % 2 === 0 ? 1 : -0.5);
baseVal = Math.max(50, baseVal + change);
results.push({
industry: ind,
date,
unemployed: baseVal,
});
}
});
return results;
}
function shadcnTooltipContent<TDatum>(points: readonly ChartPoint<TDatum>[]) {
if (points.length === 0) return { title: "", rows: [] };
const d = points[0].datum as IndustryDatum;
if (!d || !d.date || d.indexed === undefined) return { title: "", rows: [] };
const dateObj = d.date instanceof Date ? d.date : new Date(d.date);
const dateStr = isNaN(dateObj.getTime())
? ""
: dateObj.toLocaleDateString("en-US", { month: "short", year: "numeric", timeZone: "UTC" });
const pctChange = ((d.indexed - 1) * 100).toFixed(1);
const sign = Number(pctChange) >= 0 ? "+" : "";
return {
title: `${d.industry}${dateStr ? ` (${dateStr})` : ""}`,
rows: [{ label: "Change from Baseline", value: `${sign}${pctChange}%`, color: "var(--chart-1)" }],
};
}
export function IndexedMultiLine() {
const rawData = useMemo(() => generateDemoData(), []);
const rows = useMemo(() => {
return normalize(
[...rawData].sort((a, b) => {
const aTime = a.date ? (a.date instanceof Date ? a.date.getTime() : new Date(a.date).getTime()) : 0;
const bTime = b.date ? (b.date instanceof Date ? b.date.getTime() : new Date(b.date).getTime()) : 0;
return aTime - bTime;
}),
{
value: "unemployed",
by: "industry",
basis: "first",
as: "indexed",
},
);
}, [rawData]);
const labels = useMemo(() => {
return select(rows, {
by: "industry",
value: (datum: IndustryDatum) =>
datum?.date ? (datum.date instanceof Date ? datum.date.getTime() : new Date(datum.date).getTime()) : 0,
select: "max",
});
}, [rows]);
const definition = useMemo(
() =>
defineChart(
{
renderer: motionRenderer,
marks: [
ruleY([1], { stroke: "var(--border)", strokeOpacity: 0.8, strokeDasharray: "4 4" }),
lineY(rows, {
id: "indexed-lines",
x: "date",
y: "indexed",
color: "industry",
strokeWidth: 2.25,
}),
text(labels, {
id: "end-labels",
x: "date",
y: "indexed",
text: "industry",
color: "industry",
anchor: "start",
dx: 6,
fontSize: 10,
fontWeight: 600,
}),
],
scales: {
x: { scale: scaleUtc, axis: { label: "Timeline Month" } },
y: {
scale: scaleLinear,
grid: true,
axis: {
ticks: { format: (v: number) => `${Math.round((v - 1) * 100)}%` },
label: "Percentage Change relative to Baseline",
},
},
},
color: {
domain: industryList,
range: ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)"],
},
margin: { top: 24, right: 90, bottom: 32, left: 45 },
theme: {
foreground: "var(--muted-foreground)",
grid: "var(--border)",
background: "transparent",
},
},
{
keyboard: true,
tooltip: {
use: tooltip,
className:
"font-sans shadow-none rounded-md bg-popover text-popover-foreground border p-2 text-xs",
anchor: "pointer",
placement: "auto",
content: (points) => shadcnTooltipContent(points),
},
},
),
[rows, labels],
);
return (
<Card className="w-full max-w-xl">
<CardHeader>
<CardTitle>Indexed Multi-Series Performance Line</CardTitle>
<CardDescription>Normalized multi-line comparison relative to 100% baseline start</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[250px] w-full">
<Chart
definition={definition}
renderer={motionRenderer}
height={250}
ariaLabel="Indexed Multi-Series Performance Line Chart"
/>
</div>
</CardContent>
<CardFooter className="flex-col items-start gap-2 text-sm">
<div className="flex items-center gap-2 leading-none font-medium">
Construction shows highest positive divergence from baseline{" "}
<TrendingUp className="h-4 w-4 text-emerald-500" />
</div>
<div className="text-muted-foreground leading-none">
Dashed horizontal rule indicates 0% net change initial baseline value
</div>
</CardFooter>
</Card>
);
}
export default IndexedMultiLine;