DistributionChart
Univariate density chart for any non-pitch metric — per-match rates, per-event values, or player-vs-population benchmarks. Use it when the story is the shape, spread, and overlap of a sample, and where a summary or explicit marker sits inside that shape.
Canonical behaviors.
A single population curve with an explicit markerValue pins one observation — e.g. a scouted player's npxG/90 — inside the league distribution.
One dense series for a single team's shot angles over a season. No comparison — the shape itself is the answer.
Styling variant: smooth fills plus dashed median stems, closer to the naive blog-chart grammar.
Low-N samples still render, but the component warns rather than implying false certainty.
No valid values means no fake shape.
Legend and tooltip copy should survive non-ASCII labels and longer team names.
The chart keeps contrast and hierarchy under the shared dark UI theme.
DistributionComparison handles repeated-metric scouting views where each row needs its own x-domain. Reach for it only when one shared axis would lie about the data.
Shared concerns.
When To Use KDE
Prefer this over a histogram when you want to show shape and overlap without argue-able bin choices. Prefer a box plot or strip chart when the story is comparison of summary statistics across many groups.
Bandwidth Honesty
KDE smoothing is interpretation help, not ground truth. Sparse or highly multimodal samples deserve caution, not decorative confidence.
Marker Semantics
A marker is not "the peak of the curve". It is a chosen statistic (median or mean) or an explicit value on a single series — useful for benchmarking a player, team, or match against a population.
One Axis Or Stacked
Use DistributionChart when the compared series honestly belong on one
x-axis. Use DistributionComparison only when each metric needs its own
domain.
Best-practice examples.
Minimal usage
Pass one or more named numeric series and an x-axis label. Density smoothing, legend, hover, and the default marker are all built in.
import { DistributionChart } from "@withqwerty/campos-react";
export function ShotDistanceProfile() {
return (
<DistributionChart
series={[
{ id: "striker", label: "Central striker", values: strikerShotDistances },
{ id: "inside", label: "Inside forward", values: insideForwardShotDistances },
]}
xLabel="Shot distance (m)"
/>
);
}
Benchmark a player against a population
Use an explicit markerValue on a series to place a single observation inside the distribution — the classic scouting comparison.
import { DistributionChart } from "@withqwerty/campos-react";
export function PlayerBenchmark() {
return (
<DistributionChart
series={[
{
id: "pl-forwards",
label: "Premier League forwards",
values: leagueForwardsNpxgPer90,
markerValue: focusPlayer.npxgPer90,
},
]}
xLabel="Non-penalty xG per 90"
defaultMarker="none"
markers={{ mode: "glyph-and-stem", shape: "circle" }}
/>
);
}
Stacked repeated-metric comparison
Use DistributionComparison when the story is several metrics side-by-side rather than one shared x-domain.
import { DistributionComparison } from "@withqwerty/campos-react";
export function ScoutingProfile() {
return (
<DistributionComparison
rows={[
{
id: "npxg",
label: "Non-penalty xG / 90",
series: [
{ id: "a", label: "Player A", values: playerANpxg },
{ id: "b", label: "Player B", values: playerBNpxg },
],
valueFormatter: (v) => v.toFixed(2),
},
{
id: "prog",
label: "Progressive passes / 90",
series: [
{ id: "a", label: "Player A", values: playerAProgPasses },
{ id: "b", label: "Player B", values: playerBProgPasses },
],
valueFormatter: (v) => v.toFixed(1),
},
]}
defaultMarker="mean"
markers={{ mode: "glyph", shape: "triangle" }}
/>
);
}
Public surface.
| Prop | Type | Default | Description |
|---|---|---|---|
series | readonly DistributionSeriesInput[] | required | Named series of numeric values. Each series may also set markerValue to pin an explicit observation inside its curve. |
xLabel | string | "Value" | X-axis label shown beneath the chart. |
yLabel | string | "Density" | Y-axis label for the single-chart surface. |
bandwidth | "scott" | "silverman" | number | "scott" | Bandwidth rule or explicit bandwidth value for KDE smoothing. |
bandwidthAdjust | number | 1 | Multiplier applied after the selected bandwidth rule. |
domain | [number, number] | auto | Optional explicit x-domain. Auto mode pads the observed extent. |
samplePoints | number | 160 | How many x positions the density curve is sampled across. |
defaultMarker | "none" | "median" | "mean" | "median" | Statistic used when a series does not provide an explicit markerValue. |
areas | DistributionChartAreasStyle | — | First-class fill styling surface for the density areas. |
lines | DistributionChartLinesStyle | — | First-class styling surface for the density outlines. |
markers | DistributionChartMarkersStyle | — | First-class styling surface for summary markers and optional stems. |
valueFormatter | (value: number) => string | — | Formatter used for axis ticks and tooltip values on the x-axis. |
methodologyNotes | ChartMethodologyNotes | — | Shared chart-frame note seam for sample and methodology context. |
DistributionComparison
The stacked companion reuses the same density and marker model, but adds row labels and row-scale control for repeated multi-metric comparisons.
| Prop | Type | Default | Description |
|---|---|---|---|
rows | readonly DistributionComparisonRow[] | required | Stacked row definitions. Each row carries its own label and series list. |
rowScale | "independent" | "shared" | "independent" | Whether density height is normalised per row or shared across every row. |
labels | DistributionComparisonLabelsStyle | — | First-class styling surface for the left-side row labels. |
Create a React component using Campos DistributionChart. Import it from @withqwerty/campos-react, keep metric derivation outside the component, and show the smallest good overlay example first. Use series.markerValue to pin an explicit observation (e.g. a scouted player's value) inside a population curve. Reach for DistributionComparison only when the story is several different metrics stacked, not one shared x-domain.