Component

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.

01020304050Shot distance (m)00.020.040.060.080.1Density
Series
Central striker
Inside forward
Deep-lying midfielder
Stories

Canonical behaviors.

Player vs Population

A single population curve with an explicit markerValue pins one observation — e.g. a scouted player's npxG/90 — inside the league distribution.

00.20.40.60.81Non-penalty xG per 9000.511.52Density
Event-Level Distribution

One dense series for a single team's shot angles over a season. No comparison — the shape itself is the answer.

020406080100Shot angle (°)00.0050.010.0150.020.025Density
Median Stems

Styling variant: smooth fills plus dashed median stems, closer to the naive blog-chart grammar.

01020304050Shot distance (m)00.020.040.060.080.1Density
Series
Central striker
Inside forward
Deep-lying midfielder
Sparse Data Warning

Low-N samples still render, but the component warns rather than implying false certainty.

91011121314151617Shot distance (m)00.020.040.060.080.10.12Density
Series
Sparse A
Sparse B
Sparse A: 2 value(s) excluded due to missing or invalid numbers.
Sparse A: only 2 valid value(s) — KDE smoothing may not be meaningful.
Sparse B: 1 value(s) excluded due to missing or invalid numbers.
Sparse B: only 2 valid value(s) — KDE smoothing may not be meaningful.
Empty State

No valid values means no fake shape.

00.20.40.60.81Shot distance (m)00.20.40.60.81DensityNo plottable distribution data
Empty A: 3 value(s) excluded due to missing or invalid numbers.
Empty A: Fewer than 2 valid values — using fallback bandwidth.
Empty B: Fewer than 2 valid values — using fallback bandwidth.
Long And Multilingual Labels

Legend and tooltip copy should survive non-ASCII labels and longer team names.

00.511.522.5Non-penalty xG per match00.10.20.30.40.50.60.70.8Density
Series
Borussia Mönchengladbach
横浜F・マリノス
Dark Theme

The chart keeps contrast and hierarchy under the shared dark UI theme.

01020304050Shot distance (m)00.020.040.060.080.1Density
Series
Central striker
Inside forward
Deep-lying midfielder
Stacked Comparison Companion

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.

0.000.200.400.600.80Non-penaltyxG / 900.02.04.06.08.010.012.0Progressivepasses / 900510152025Successfulpressures /90
Series
Player A
Player B
Cross-cutting

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.

Usage

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" }}
    />
  );
}
                  
API

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.
Companion API

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.
Use with AI
LLM Prompt
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.