SmallMultiples
Responsive comparison grid for repeated football visuals, especially pitch-chart grids and bespoke analyst cells. It is an HTML layout primitive in @withqwerty/campos-react: stable keying, per-cell labels, and per-cell error isolation without pretending every repeated chart is its own bespoke layout.
Repeated pitch analysis first.
In practice, SmallMultiples is mostly used to repeat pitch charts or bespoke pitch cells across teams, matches, or players. Generic card dashboards are possible, but they are not the main football workflow this page should teach.
Live repeated charts
ShotMap, PassMap, Heatmap,
Territory, and PassFlow are the current official repeated-chart
participants.
Raw Pitch still matters
Use raw Pitch for bespoke analyst cells until a real use case proves
PitchCell belongs.
Canonical behaviors.
Show the repeated pitch work before the utility mechanics.
This page should teach the real library pattern: repeated football visuals first, grid mechanics second. The official chart set is now broader, but still chart specific rather than magical.
This is the dense editorial pattern: external header copy, compact labels, and many tiny pitch cells in one grid.
Team Goal Kick Locations
English Premier League 2024-25 | Ending inside & outside the box
ShotMap is the fully symmetric repeated-chart path today: it consumes pitchOrientation, pitchCrop, and sharedScale cleanly.
PassMap is the second honest repeated-chart participant. It uses shared crop and scale, but chart-specific view forwarding is still selective rather than universal.
Heatmap is now an honest repeated-grid participant when the bins stay coarse or tactical and the scale bar is suppressed per cell.
Territory is the editorial repeated-zone path: fast-to-read cards, horizontal orientation, and no need to invent a shared-domain story.
PassFlow works in repeated tactical grids when the bins stay coarse and the header and legend chrome are removed.
Keep the utility stories, but make them football-shaped.
Responsive layout, error isolation, and empty states still matter. They just should not dominate the page over actual pitch usage.
Empty and null inputs are a first-class contract, not a consumer-side conditional.
Use auto-fill with a minimum cell width when the entity count and viewport vary widely. This should still feel like repeated pitch analysis, not a generic card wall.
Shared concerns.
SmallMultiples forwards a shared view object, but repeated-chart adoption is chart-specific. Do not assume every pitch chart consumes the full hint set.
| Surface | Status | Reads from view today | Guidance |
|---|---|---|---|
ShotMap | Live | pitchOrientation, pitchCrop, sharedScale | Use for the fully aligned repeated-chart path. |
PassMap | Live | pitchCrop, sharedScale | Use when repeated raw trajectories matter more than aggregation. |
Heatmap | Live | pitchOrientation → attackingDirection, pitchCrop | Use for repeated discrete zone-density reads when each cell should keep inspectable bins. |
Territory | Live | pitchOrientation → attackingDirection, pitchCrop | Use for repeated editorial zone cards when readability matters more than exact bin counts. |
PassFlow | Live | pitchOrientation → attackingDirection, pitchCrop | Use for repeated tactical flow tiles with coarse bins and standalone-page chrome suppressed. |
KDE | Deferred | pitchCrop only | Keep standalone unless a real repeated-density use case proves it belongs. |
PassNetwork | Not part of the immediate promise | Chart-owned attackingDirection only | Do not market as a dense-grid default unless a battle-test proves it belongs. |
Pitch / queued PitchCell | Raw path live / wrapper queued | Whatever you forward manually | Use raw Pitch for bespoke analyst cells; PitchCell stays queued until repeated charts or raw Pitch prove too awkward. |
Use repeated charts
Choose this when the same football question repeats cleanly across entities: shots, raw pass paths, discrete zones, editorial territory, or coarse pass flow.
Use raw Pitch
Choose raw Pitch when you need mixed marks, custom bins, or editorial analyst cells that do not honestly map to one shipped chart.
Do not wait for magic
PitchCell is still queued. Do not block a real analyst grid on a wrapper that does not yet have proven reuse.
Best-practice examples.
Smallest good football usage
Start with a repeated pitch chart, not a generic card grid. This is the smallest honest ShotMap-based use.
import { CellLabel, SmallMultiples, ShotMap } from "@withqwerty/campos-react";
export function ShotComparison({ teams }) {
return (
<SmallMultiples
items={teams}
getItemKey={(team) => team.id}
pitchOrientation="horizontal"
pitchCrop="full"
columns={{ minCellWidth: 220 }}
renderLabel={(team) => (
<CellLabel title={team.name} eyebrow={team.phase} caption={team.summary} />
)}
renderCell={(team, _index, view) => (
<ShotMap
shots={team.shots}
orientation={view.pitchOrientation}
crop={view.pitchCrop}
showHeaderStats={false}
showLegend={false}
showSizeScale={false}
/>
)}
/>
);
}
Shared ShotMap scale
When cross-cell comparison matters, compute a shared size domain once and forward it into each chart.
import {
CellLabel,
SmallMultiples,
ShotMap,
computeSharedPitchScale,
} from "@withqwerty/campos-react";
export function ShotComparison({ teams }) {
const sharedScale = computeSharedPitchScale(teams, {
size: (team) => team.shots.map((shot) => shot.xg ?? 0),
});
return (
<SmallMultiples
items={teams}
getItemKey={(team) => team.id}
pitchOrientation="horizontal"
pitchCrop="full"
columns={{ minCellWidth: 180 }}
sharedScale={sharedScale}
renderLabel={(team) => (
<CellLabel title={team.name} eyebrow={team.phase} caption={team.summary} />
)}
renderCell={(team, _index, view) => (
<ShotMap
shots={team.shots}
orientation={view.pitchOrientation}
crop={view.pitchCrop}
sharedScale={view.sharedScale}
showHeaderStats={false}
showLegend={false}
showSizeScale={false}
/>
)}
/>
);
}
PassMap repeated grid
PassMap is a real repeated-chart participant, but not a symmetric one. It consumes shared crop and scale today; it does not read SmallMultiples' pitchOrientation hint.
import {
CellLabel,
PassMap,
SmallMultiples,
computeSharedPitchScale,
} from "@withqwerty/campos-react";
function scalePassWidth(
value: number,
domain: readonly [number, number] | undefined,
) {
if (domain == null || domain[0] === domain[1]) return 0.7;
const ratio = Math.max(0, Math.min(1, (value - domain[0]) / (domain[1] - domain[0])));
return 0.35 + ratio * 0.7;
}
export function SharedPassGrid({ teams }) {
const sharedScale = computeSharedPitchScale(teams, {
width: (team) => team.passes.map((pass) => pass.length ?? 0),
});
return (
<SmallMultiples
items={teams}
getItemKey={(team) => team.id}
pitchCrop="full"
sharedScale={sharedScale}
renderLabel={(team) => (
<CellLabel title={team.name} caption={team.summary} />
)}
renderCell={(team, _index, view) => (
<PassMap
passes={team.passes}
crop={view.pitchCrop}
sharedScale={view.sharedScale}
showHeaderStats={false}
showLegend={false}
lines={{
strokeWidth: ({ pass, sharedScale }) =>
scalePassWidth(pass.length ?? 0, sharedScale?.widthDomain),
}}
/>
)}
/>
);
}
Charts that use attackingDirection
Heatmap, Territory, and PassFlow still fit the SmallMultiples story. They just need an explicit mapping from pitchOrientation to the chart's attackingDirection prop.
import { Heatmap, SmallMultiples } from "@withqwerty/campos-react";
function attackingDirectionFromView(view) {
return view.pitchOrientation === "vertical" ? "up" : "right";
}
export function ZoneComparison({ teams }) {
return (
<SmallMultiples
items={teams}
getItemKey={(team) => team.id}
pitchOrientation="horizontal"
pitchCrop="full"
renderCell={(team, _index, view) => {
const attackingDirection = attackingDirectionFromView(view);
return (
<Heatmap
events={team.passOrigins}
zonePreset="5x3"
crop={view.pitchCrop}
attackingDirection={attackingDirection}
showScaleBar={false}
/>
);
}}
/>
);
}
// The same mapping pattern works for Territory and PassFlow too.
Analyst-style pitch grid
Use raw Pitch when the analyst view is bespoke and should not be forced through a chart contract.
import { SmallMultiples } from "@withqwerty/campos-react";
import { Pitch } from "@withqwerty/campos-stadia";
export function GoalKickLocations({ teams }) {
return (
<section>
<header>
<h2>Team Goal Kick Locations</h2>
<p>Ending inside & outside the box</p>
</header>
<SmallMultiples
items={teams}
columns={5}
gap={12}
pitchOrientation="horizontal"
pitchCrop="full"
getItemKey={(team) => team.id}
renderLabel={(team) => (
<div>
<strong>{team.name}</strong>
<span>{team.summary}</span>
</div>
)}
renderCell={(team) => (
<Pitch crop="full" attackingDirection="right" interactive={false}>
{({ project }) => (
<g>
{team.points.map((point, index) => {
const p = project(point.x, point.y);
return (
<circle
key={index}
cx={p.x}
cy={p.y}
r={1.1}
fill={point.zone === "inside" ? "#8b5cf6" : "#fb7185"}
opacity={0.64}
/>
);
})}
</g>
)}
</Pitch>
)}
/>
</section>
);
}
Public surface.
| Prop | Type | Default | Description |
|---|---|---|---|
items | ReadonlyArray<T> | null | undefined | required | Source rows for the grid. Null and empty both render the empty-state contract. |
getItemKey | (item: T, index: number) => string | number | required | Stable keying for per-cell recovery. Duplicate keys are warned in development. |
renderCell | (item: T, index: number, view: SmallMultiplesView) => ReactNode | required | Render the cell body. The third arg is the forward-compatible shared-view contract. |
renderLabel | (item: T, index: number) => ReactNode | — | Optional label renderer for figcaption content above or below each cell. |
columns | number | { minCellWidth: number } | { minCellWidth: 240 } | Fixed count or responsive auto-fill layout. |
labelPlacement | "above" | "below" | "above" | Controls whether figcaptions sit before or after the cell body. |
emptyState | ReactNode | "No items to compare." | Override the generic empty state when no items are present. |
pitchOrientation | "horizontal" | "vertical" | — | Optional shared pitch-view hint forwarded into renderCell via the third view argument. |
pitchCrop | "full" | "half" | — | Optional shared crop hint forwarded into renderCell for participating pitch charts. |
sharedScale | SharedPitchScale | — | Optional shared numeric domains produced by computeSharedPitchScale() and forwarded into renderCell. |
Label primitive.
| Prop | Type | Default | Description |
|---|---|---|---|
title | ReactNode | required | Primary label content. Usually the compared entity name. |
eyebrow | ReactNode | — | Optional small uppercase line above the title. |
caption | ReactNode | — | Optional supporting line under the title. |
Create a React component using Campos SmallMultiples. Import SmallMultiples and CellLabel from @withqwerty/campos-react, keep data shaping outside the component, use the final renderCell(item, index, view) signature, and show one repeated pitch-chart grid plus one bespoke raw Pitch analyst-grid example.