components/tanstack-charts/area/violin-distributions.tsx
import {
type ChartPoint,
binY,
d3AreaXCurve,
defineChart,
dot,
groupBy,
median,
normalize,
tickY,
violinY,
} 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, scalePoint } from "d3-scale";
import { curveBasis } from "d3-shape";
import { SlidersHorizontal } from "lucide-react";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
interface PenguinDatum {
species: "Adelie" | "Chinstrap" | "Gentoo";
body_mass_g: number;
}
const motionRenderer = motion({
initial: "always",
transition: { type: "spring", stiffness: 170, damping: 18, mass: 1 },
});
const speciesList: ("Adelie" | "Chinstrap" | "Gentoo")[] = ["Adelie", "Chinstrap", "Gentoo"];
const massBoundaries = [2500, 3000, 3500, 4000, 4500, 5000, 5500, 6000, 6500];
function generateDemoData(): PenguinDatum[] {
const results: PenguinDatum[] = [];
const means = [3700, 3730, 5080];
speciesList.forEach((sp, idx) => {
for (let i = 0; i < 50; i++) {
const mass = means[idx] + (Math.random() - 0.5) * 800;
results.push({ species: sp, body_mass_g: Math.round(mass) });
}
});
return results;
}
function shadcnTooltipContent<TDatum>(points: readonly ChartPoint<TDatum>[]) {
if (points.length === 0) return { title: "", rows: [] };
const d = points[0].datum as { species: string; median?: number; y?: number };
if (!d) return { title: "", rows: [] };
if (d.median !== undefined) {
return {
title: `${d.species} (Median Marker)`,
rows: [
{ label: "Median Mass", value: `${Math.round(d.median).toLocaleString()} g`, color: "var(--chart-1)" },
],
};
}
return {
title: `${d.species} (Violin Profile)`,
rows: [{ label: "Mass Bin", value: `${d.y?.toLocaleString()} g`, color: "var(--chart-2)" }],
};
}
export function ViolinDistributions() {
const data = useMemo(() => generateDemoData(), []);
const profiles = useMemo(() => {
const bins = binY(data, {
value: "body_mass_g",
by: "species",
thresholds: massBoundaries,
outputs: { count: { reduce: "count" } },
});
return normalize(bins, {
value: "count",
by: "species",
basis: "max",
as: "width",
});
}, [data]);
const summaries = useMemo(() => {
return groupBy(data, {
by: "species",
outputs: {
median: { value: "body_mass_g", reduce: median },
},
});
}, [data]);
const definition = useMemo(
() =>
defineChart(
{
renderer: motionRenderer,
marks: [
violinY(profiles, {
id: "mass-violins",
x: "species",
y: "y",
width: "width",
key: (row: { species: string; y: number }) => `${row.species}:${row.y}`,
span: 0.76,
color: "species",
fillOpacity: 0.55,
curve: d3AreaXCurve(curveBasis),
}),
tickY(summaries, {
id: "median-ticks",
x: "species",
y: "median",
key: "species",
span: 0.36,
stroke: "var(--foreground)",
strokeWidth: 2,
}),
dot(summaries, {
id: "median-dots",
x: "species",
y: "median",
key: "species",
color: "species",
stroke: "var(--background)",
strokeWidth: 1.5,
r: 4,
}),
],
scales: {
x: {
scale: scalePoint<string>().domain(speciesList).padding(0.5),
},
y: { scale: scaleLinear, grid: true, axis: { label: "Body Mass (g)" } },
},
color: {
domain: speciesList,
range: ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)"],
},
margin: { top: 24, right: 24, 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),
},
},
),
[profiles, summaries],
);
return (
<Card className="w-full max-w-xl">
<CardHeader>
<CardTitle>Violin Kernel Density Plot</CardTitle>
<CardDescription>Symmetric KDE violin profiles overlaid with central median markers</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[250px] w-full">
<Chart
definition={definition}
renderer={motionRenderer}
height={250}
ariaLabel="Violin Kernel Density Plot Chart"
/>
</div>
</CardContent>
<CardFooter className="flex-col items-start gap-2 text-sm">
<div className="flex items-center gap-2 leading-none font-medium">
Violin contours reveal multi-modal continuous density shapes{" "}
<SlidersHorizontal className="h-4 w-4 text-emerald-500" />
</div>
<div className="text-muted-foreground leading-none">
Combines boxplot median markers with smooth kernel density estimations
</div>
</CardFooter>
</Card>
);
}
export default ViolinDistributions;