Why Campos is the way it is
Every concrete trade-off — an API shape, a default value, a breaking change, a policy — with the context, the decision, and the consequences. Grouped by component so you can read the full story behind any chart; filterable by status, component, and tag; superseded entries stay linked so reasoning is never lost.
ShotMap 5
#- ✓ ShotMap analytical default uses magma instead of turbo
The built-in analytical ShotMap default now uses `colorScale="magma"` instead of `"turbo"`. Consumers who need the earlier output must pass `colorScale="turbo"` explicitly.
default-behaviourbreaking-changechart-api - ✓ ShotMap renders goals after non-goals so they never get occluded in dense clusters
Markers are sorted by outcome before rendering: non-goals first, goals last. In dense penalty-area clusters, goal markers always sit on top of shot markers. Deterministic and unconditional — no API to override.
visual-encodingfootball-semantics - ✓ ShotMap marker size scales by area, not linear radius
Marker radius is computed from xG via `r = √(minArea + t × (maxArea − minArea))`, not linearly. A 0.5 xG shot reads as ~71% the radius of a 1.0 xG shot, not 50% — which preserves perceptual equivalence because human vision reads circle area, not radius.
visual-encodingdefault-behaviourdata-integrity - ✓ ShotMap shape + fill grammar is preset-owned; Opta is outcome-first, StatsBomb is xG-first
`preset="opta"` locks markers to circles with fill-vs-hollow encoding outcome (goal vs non-goal). `preset="statsbomb"` uses shapes keyed to context/body-part (hexagon foot, circle header, square set-piece) with fill colour driven by the xG colour ramp. The two presets intentionally produce different charts from the same data model.
default-behaviourvisual-encodingpreset-semantics - ✓ Canonical pitch coordinates: attacker-perspective, x 0→100 own→opposition goal
Every event produced by an adapter is in canonical pitch coordinates before reaching a compute or render layer. x = 0 is the own goal, x = 100 is the opposition goal, y = 0 is attacker's right, y = 100 is attacker's left. No chart re-maps axes; all orientation is a render-time concern.
PassMap PassFlow PassNetwork Heatmap Territory KDE Formation GoalMouthShotChart coordinate-systemcanonicalinvariants
Heatmap 4
#- ✓ `autoPitchLines` overrides user `pitchColors.lines` on dark ramps — the only prop that breaks user-wins
When `autoPitchLines: true` (default) and the colour ramp is dark (magma, inferno, viridis, or a custom dark gradient), pitch lines are forced to white — even if the user passed `pitchColors.lines="#333"`. Dark ramp + dark lines makes the pitch disappear; legibility wins over API consistency.
KDE Territory default-behaviourvisual-encodingaccessibility - ✓ Heatmap defaults to fixed 12×8 bins; no adaptive grid
Zero-config Heatmap bins events into a fixed 12×8 grid. Adaptive schemes that vary bin count with data density were rejected because they break cross-match comparability — two heatmaps must be readable side by side without grid surprises.
default-behaviourapivisual-encoding - ✓ Heatmap colour ramp is invariant across `valueMode`; only labels change
`valueMode: "count" | "intensity" | "share"` only changes the scale-bar copy and tooltip phrasing. Cell fills are always driven by intensity (`count / maxCount`). Switching mode never repaints the surface — so two heatmaps in different modes remain visually comparable.
apivisual-encodingdata-integrity - ✓ Canonical pitch coordinates: attacker-perspective, x 0→100 own→opposition goal
Every event produced by an adapter is in canonical pitch coordinates before reaching a compute or render layer. x = 0 is the own goal, x = 100 is the opposition goal, y = 0 is attacker's right, y = 100 is attacker's left. No chart re-maps axes; all orientation is a render-time concern.
ShotMap PassMap PassFlow PassNetwork Territory KDE Formation GoalMouthShotChart coordinate-systemcanonicalinvariants
KDE 4
#- ✓ `autoPitchLines` overrides user `pitchColors.lines` on dark ramps — the only prop that breaks user-wins
When `autoPitchLines: true` (default) and the colour ramp is dark (magma, inferno, viridis, or a custom dark gradient), pitch lines are forced to white — even if the user passed `pitchColors.lines="#333"`. Dark ramp + dark lines makes the pitch disappear; legibility wins over API consistency.
Heatmap Territory default-behaviourvisual-encodingaccessibility - ✓ KDE defaults bandwidth to Silverman per axis; football multi-clusters over-smooth as a known trade
Zero-config KDE uses Silverman's rule (`h = σ × n^(-1/6)`) independently on x and y. Silverman is designed for unimodal distributions, and multi-cluster football patterns (wing + centre + striker zones) over-smooth as a result. That's a known limitation, surfaced as a `[kde.low-confidence]` warning on sparse inputs.
algorithmdefault-behaviour - ✓ KDE renders as a browser-side raster; static export support is deferred
KDE renders via a canvas-to-data-URL raster wrapped in an SVG `<image>`. Static export skips KDE entirely — it is not a member of the stable `ExportFrameSpec` union. Vector output is deferred until raster-vs-static parity rules are deliberately defined.
breaking-changechart-apiexport - ✓ Canonical pitch coordinates: attacker-perspective, x 0→100 own→opposition goal
Every event produced by an adapter is in canonical pitch coordinates before reaching a compute or render layer. x = 0 is the own goal, x = 100 is the opposition goal, y = 0 is attacker's right, y = 100 is attacker's left. No chart re-maps axes; all orientation is a render-time concern.
ShotMap PassMap PassFlow PassNetwork Heatmap Territory Formation GoalMouthShotChart coordinate-systemcanonicalinvariants
LineChart 4
#- ✓ Default axis-padding to a 6px pixel gutter on every cartesian chart
Markers at data extremes no longer clip against axis frames. Scale range insets inward by 6px; axis lines stay at the frame edge. Opt out with `axisPadding: 0` or `false`.
ScatterPlot CometChart BumpChart DistributionChart XGTimeline default-behaviourchart-apivisual-polish - ✓ Hidden series participate in x/y-domain inference and always emit an `[extends-*]` warning
`series[].hidden: true` suppresses rendering but keeps the series in domain inference, palette skipping, and envelope targetability. When a hidden series widens the domain, `[hidden.extends-x-domain]` / `[hidden.extends-y-domain]` always fires — consumers filter by warning code if the behaviour is intentional.
chart-apiwarningsdata-integrity - ✓ Bands / references / envelopes as LineChart reference layers
LineChart gains `bands` (shaded rectangles), `references` (horizontal / vertical / diagonal lines), and `envelopes` (signed-area fills between two bounds). Unlocks five editorial ideas from the cross-sport lab in one packet.
chart-apinew-primitiveseditorial - ✓ `meta.warnings` stays `string[]`; public contract is the `[code]` bracket prefix
Warnings remain plain strings, but every warning starts with a bracket-prefixed code like `[envelope.no-overlap]`. Codes are public contract; prose may evolve. Consumers can grep codes reliably without a breaking type change.
warningsapipublic-contract
PassSonar 4
#- ✓ PassSonar is always drawn in the attack-adjusted frame; heading-frame is deferred
Pass angles on `PassSonar` are always computed from canonical-frame start/end points (`atan2(endY - y, endX - x)`), so 0 rad always points toward the opposition goal. This is the "adjusted" convention some references call out. Heading-frame ("regular") sonars require tracking data and are deferred until a tracking adapter lands.
coordinate-systemcanonicalinvariantschart-api - ✓ PassSonar colour encoding is configurable; default is completion
`PassSonar` exposes `colorBy: "completion" | "distance" | "none"` with `"completion"` as the default. `"completion"` keeps the current attempted/completed annular stack. `"distance"` matches Eliot McKinley's canonical reference — bar colour encodes mean pass distance on a sequential ramp.
default-behaviourchart-apivisual-encoding - ✓ PassSonar defaults to 24 bins (15° wedges) instead of 8
`PassSonar` now defaults to `binCount: 24` (15° wedges), matching Eliot McKinley's canonical reference (`ref_code/PassSonar/`) and the convention adopted by mplsoccer / d3-soccer. Consumers that want the earlier 45° wedges must pass `binCount={8}` explicitly.
default-behaviourbreaking-changechart-api - ✓ PassSonar wedge radius scales √-proportional to count, not linearly
Each wedge's outer radius is `√(count / maxCount)`, not `count / maxCount`. A bin with four times the passes has twice the radius — matching how human vision reads wedge area. Aligns with `mplsoccer` and Eliot McKinley's canonical reference.
visual-encodingalgorithmfootball-semantics
Territory 3
#- ✓ `autoPitchLines` overrides user `pitchColors.lines` on dark ramps — the only prop that breaks user-wins
When `autoPitchLines: true` (default) and the colour ramp is dark (magma, inferno, viridis, or a custom dark gradient), pitch lines are forced to white — even if the user passed `pitchColors.lines="#333"`. Dark ramp + dark lines makes the pitch disappear; legibility wins over API consistency.
Heatmap KDE default-behaviourvisual-encodingaccessibility - ✓ Territory offers only 3×3 or 5×3 grids; no arbitrary resolution
`Territory` accepts `grid="3x3"` or `"5x3"` (plus named tactical presets), never an arbitrary `gridX`. A deliberate break from Heatmap's flexibility: Territory is a broadcast-editorial view where thirds-by-thirds or fifths-by-thirds are semantically meaningful and any other resolution is noise.
apifootball-semanticsdefault-behaviour - ✓ Canonical pitch coordinates: attacker-perspective, x 0→100 own→opposition goal
Every event produced by an adapter is in canonical pitch coordinates before reaching a compute or render layer. x = 0 is the own goal, x = 100 is the opposition goal, y = 0 is attacker's right, y = 100 is attacker's left. No chart re-maps axes; all orientation is a render-time concern.
ShotMap PassMap PassFlow PassNetwork Heatmap KDE Formation GoalMouthShotChart coordinate-systemcanonicalinvariants
XGTimeline 3
#- ✓ Default axis-padding to a 6px pixel gutter on every cartesian chart
Markers at data extremes no longer clip against axis frames. Scale range insets inward by 6px; axis lines stay at the frame edge. Opt out with `axisPadding: 0` or `false`.
LineChart ScatterPlot CometChart BumpChart DistributionChart default-behaviourchart-apivisual-polish - ✓ XGTimeline annotation tiers: goals always, big-xG non-goals up to 3 per 15 min, rest tooltip-only
Shot annotations follow a three-tier policy. Tier 1 — goals — always annotate. Tier 2 — non-goals with xG ≥ 0.3, capped at 3 per 15-minute window. Tier 3 — everything else — surfaces only in the tooltip. Stops dense matches (30+ shots) becoming unreadable without losing the story.
default-behaviourchart-apivisual-encoding - ✓ XGTimeline cumulative lines are step-after, never smoothed
Cumulative xG paths are SVG `M/H/V` step-after segments — no cubic Béziers, no linear interpolation between shots. Smooth curves imply continuous chance generation, which is a lie. A step function says "this xG value held until the next shot", which is what actually happened.
visual-encodingfootball-semanticsalgorithm
BumpChart 2
#- ✓ Default axis-padding to a 6px pixel gutter on every cartesian chart
Markers at data extremes no longer clip against axis frames. Scale range insets inward by 6px; axis lines stay at the frame edge. Opt out with `axisPadding: 0` or `false`.
LineChart ScatterPlot CometChart DistributionChart XGTimeline default-behaviourchart-apivisual-polish - ✓ BumpChart input ranks must be 1..N ordinal; no auto-derivation from raw metrics
`BumpChart` expects a `rank` field that is already a discrete 1..N ordinal. It never derives rank from `points` / `goals` / `xG` internally. Tie-breaking (dense vs min vs max) is a narrative choice and lives upstream; the chart refuses to own it.
data-integrityapifootball-semantics
CometChart 2
#- ✓ Default axis-padding to a 6px pixel gutter on every cartesian chart
Markers at data extremes no longer clip against axis frames. Scale range insets inward by 6px; axis lines stay at the frame edge. Opt out with `axisPadding: 0` or `false`.
LineChart ScatterPlot BumpChart DistributionChart XGTimeline default-behaviourchart-apivisual-polish - ✓ CometChart trail: thin tail is the earliest timepoint, thick head is the latest
The trail narrows as it recedes into the past. The marker and label live at the most recent timepoint. Consumers reading left-to-right intuit the story as "how we got to here" — not "where we started".
visual-encodingfootball-semantics
Formation 2
#- ✓ Formation player names render in a below-marker pill, never inside the glyph
The marker glyph holds only the jersey number, initials, or position code. Full names render in a rounded pill below the marker when `showNames: true`. This keeps markers legible across long or multilingual names without clipping, and preserves a readable glyph even when the name pill is off.
visual-encodingaccessibilitydefault-behaviour - ✓ Canonical pitch coordinates: attacker-perspective, x 0→100 own→opposition goal
Every event produced by an adapter is in canonical pitch coordinates before reaching a compute or render layer. x = 0 is the own goal, x = 100 is the opposition goal, y = 0 is attacker's right, y = 100 is attacker's left. No chart re-maps axes; all orientation is a render-time concern.
ShotMap PassMap PassFlow PassNetwork Heatmap Territory KDE GoalMouthShotChart coordinate-systemcanonicalinvariants
PassFlow 2
#- ✓ PassFlow suppresses arrows when circular mean resultant is below 0.3
Bins whose mean resultant length R (circular concentration) < 0.3 render a neutral glyph (hollow circle by default) instead of an arrow. A zone where passes go in every direction has no honest mean — drawing an arrow would invent a direction the data doesn't support.
default-behaviourvisual-encodingdata-integrity - ✓ Canonical pitch coordinates: attacker-perspective, x 0→100 own→opposition goal
Every event produced by an adapter is in canonical pitch coordinates before reaching a compute or render layer. x = 0 is the own goal, x = 100 is the opposition goal, y = 0 is attacker's right, y = 100 is attacker's left. No chart re-maps axes; all orientation is a render-time concern.
ShotMap PassMap PassNetwork Heatmap Territory KDE Formation GoalMouthShotChart coordinate-systemcanonicalinvariants
PassMap 2
#- ✓ PassMap renders dots when destination coordinates are missing, never invents arrows
Passes with null / missing `endX` or `endY` render as a dot at the origin, not as an omitted pass and not as an inferred trajectory. Header counts include both arrows and dots so the completion denominator stays honest.
data-integrityvisual-encodingaccessibility - ✓ Canonical pitch coordinates: attacker-perspective, x 0→100 own→opposition goal
Every event produced by an adapter is in canonical pitch coordinates before reaching a compute or render layer. x = 0 is the own goal, x = 100 is the opposition goal, y = 0 is attacker's right, y = 100 is attacker's left. No chart re-maps axes; all orientation is a render-time concern.
ShotMap PassFlow PassNetwork Heatmap Territory KDE Formation GoalMouthShotChart coordinate-systemcanonicalinvariants
PassNetwork 2
#- ✓ PassNetwork merges A↔B edge pairs into undirected edges by default
When the input contains `A→B` (10 passes) and `B→A` (8 passes), the default render collapses them into a single undirected edge with weight 18. `directed: true` keeps the pair distinct with arrowheads, at the cost of dense-network readability.
default-behaviourvisual-encodingfootball-semantics - ✓ Canonical pitch coordinates: attacker-perspective, x 0→100 own→opposition goal
Every event produced by an adapter is in canonical pitch coordinates before reaching a compute or render layer. x = 0 is the own goal, x = 100 is the opposition goal, y = 0 is attacker's right, y = 100 is attacker's left. No chart re-maps axes; all orientation is a render-time concern.
ShotMap PassMap PassFlow Heatmap Territory KDE Formation GoalMouthShotChart coordinate-systemcanonicalinvariants
DistributionChart 1
#- ✓ Default axis-padding to a 6px pixel gutter on every cartesian chart
Markers at data extremes no longer clip against axis frames. Scale range insets inward by 6px; axis lines stay at the frame edge. Opt out with `axisPadding: 0` or `false`.
LineChart ScatterPlot CometChart BumpChart XGTimeline default-behaviourchart-apivisual-polish
GoalMouthShotChart 1
#- ✓ Canonical pitch coordinates: attacker-perspective, x 0→100 own→opposition goal
Every event produced by an adapter is in canonical pitch coordinates before reaching a compute or render layer. x = 0 is the own goal, x = 100 is the opposition goal, y = 0 is attacker's right, y = 100 is attacker's left. No chart re-maps axes; all orientation is a render-time concern.
ShotMap PassMap PassFlow PassNetwork Heatmap Territory KDE Formation coordinate-systemcanonicalinvariants
PercentileSurfaces 1
#- ✓ PercentileBar always renders percentiles on the upward scale; inversion happens upstream
The bar geometry never inverts. A metric with `percentile: 15, originalDirection: "lower"` renders a 15% bar plus a "lower is better" badge — not an 85% bar. The chart owns rendering; the caller owns statistical correctness.
chart-apidata-integrityfootball-semantics
PizzaChart 1
#- ✓ PizzaChart slice length is always percentile; raw values never drive geometry
Slice radius reads as percentile against the declared cohort. Raw metric values (xG, progressive passes, tackles) pass through to tooltips and labels but never set the wedge geometry. Consumers who need raw-scale polar charts pick a different primitive.
chart-apifootball-semanticsdata-integrity
ScatterPlot 1
#- ✓ Default axis-padding to a 6px pixel gutter on every cartesian chart
Markers at data extremes no longer clip against axis frames. Scale range insets inward by 6px; axis lines stay at the frame edge. Opt out with `axisPadding: 0` or `false`.
LineChart CometChart BumpChart DistributionChart XGTimeline default-behaviourchart-apivisual-polish
SmallMultiples 1
#- ✓ SmallMultiples passes shared-view hints through `renderCell`, not React context
`renderCell(datum, index, view)` receives a third `SmallMultiplesView` argument carrying `pitchOrientation`, `pitchCrop`, and `sharedScale`. Consumers opt-in per chart by forwarding `view` into children. Implicit React context was rejected — it breaks SSR, static export, and agent-readability.
architectureapicore-contract
Library-wide 5
#Architecture, policy, and cross-cutting conventions that don't belong to a single component.
- ✓ v0.2 is pre-release: break contracts outright, no deprecation shims
Until v1, Campos removes deprecated APIs in the same commit that introduces their replacement. No `@deprecated` markers, no opt-in migration windows, no shim layers. Consumer migration is part of the same packet.
policybreaking-changeversioning - ✓ Three-way theme split: UI theme (context), pitch theme (store), chart preset (prop)
Theming is split into three separate systems. **UI theme** (light/dark chrome) flows through React context. **Pitch theme** (pitch colours, line style) lives in a nanostore shared across the site. **Chart preset** (editorial/analytical/minimal encoding grammar) is a component prop. Each owns one concern; they never reach into each other.
architecturethemecore-contract - ✓ Style props are callback-first; enum shorthands are sugar over callbacks
Every style surface (`markers`, `lines`, `trajectories`, `labels`) takes a callback `(ctx) => StyleValue` as its canonical signature. Object literals and enum shorthands (`colorBy="xg"`) are implemented as sugar that internally resolves to a callback. Agents can always fall back to callbacks without learning chart-local magic.
apiarchitectureagent-usability - ✓ Empty states keep the component shell; only the plot region goes blank
`<ShotMap shots={[]} />` renders the header stats (with zeroed values), a muted pitch, and a centred "No shots" message. Frame and chrome stay visible. The shell preserves layout during async loads and clearly distinguishes "no data" from "component not rendered".
component-defaultsaccessibilityapi - ✓ Core compute returns semantic regions, not raw drawing primitives
Every `compute*` function returns a model with named semantic regions — `headerStats`, `scaleBar`, `plot`, `legend`, `emptyState` — not a flat list of shapes. Layout policy lives in compute; renderers paint regions by name. New renderers (canvas, Figma, static export) plug in without re-deriving layout.
architecturecore-contractapi
- ✓ PassSonar is always drawn in the attack-adjusted frame; heading-frame is deferred
Pass angles on `PassSonar` are always computed from canonical-frame start/end points (`atan2(endY - y, endX - x)`), so 0 rad always points toward the opposition goal. This is the "adjusted" convention some references call out. Heading-frame ("regular") sonars require tracking data and are deferred until a tracking adapter lands.
PassSonar coordinate-systemcanonicalinvariantschart-api - ✓ PassSonar colour encoding is configurable; default is completion
`PassSonar` exposes `colorBy: "completion" | "distance" | "none"` with `"completion"` as the default. `"completion"` keeps the current attempted/completed annular stack. `"distance"` matches Eliot McKinley's canonical reference — bar colour encodes mean pass distance on a sequential ramp.
PassSonar default-behaviourchart-apivisual-encoding - ✓ PassSonar defaults to 24 bins (15° wedges) instead of 8
`PassSonar` now defaults to `binCount: 24` (15° wedges), matching Eliot McKinley's canonical reference (`ref_code/PassSonar/`) and the convention adopted by mplsoccer / d3-soccer. Consumers that want the earlier 45° wedges must pass `binCount={8}` explicitly.
PassSonar default-behaviourbreaking-changechart-api - ✓ PassSonar wedge radius scales √-proportional to count, not linearly
Each wedge's outer radius is `√(count / maxCount)`, not `count / maxCount`. A bin with four times the passes has twice the radius — matching how human vision reads wedge area. Aligns with `mplsoccer` and Eliot McKinley's canonical reference.
PassSonar visual-encodingalgorithmfootball-semantics - ✓ PercentileBar always renders percentiles on the upward scale; inversion happens upstream
The bar geometry never inverts. A metric with `percentile: 15, originalDirection: "lower"` renders a 15% bar plus a "lower is better" badge — not an 85% bar. The chart owns rendering; the caller owns statistical correctness.
PercentileSurfaces chart-apidata-integrityfootball-semantics - ✓ ShotMap analytical default uses magma instead of turbo
The built-in analytical ShotMap default now uses `colorScale="magma"` instead of `"turbo"`. Consumers who need the earlier output must pass `colorScale="turbo"` explicitly.
ShotMap default-behaviourbreaking-changechart-api - ✓ Default axis-padding to a 6px pixel gutter on every cartesian chart
Markers at data extremes no longer clip against axis frames. Scale range insets inward by 6px; axis lines stay at the frame edge. Opt out with `axisPadding: 0` or `false`.
LineChartScatterPlotCometChartBumpChartDistributionChartXGTimeline default-behaviourchart-apivisual-polish - ✓ Hidden series participate in x/y-domain inference and always emit an `[extends-*]` warning
`series[].hidden: true` suppresses rendering but keeps the series in domain inference, palette skipping, and envelope targetability. When a hidden series widens the domain, `[hidden.extends-x-domain]` / `[hidden.extends-y-domain]` always fires — consumers filter by warning code if the behaviour is intentional.
LineChart chart-apiwarningsdata-integrity - ✓ Bands / references / envelopes as LineChart reference layers
LineChart gains `bands` (shaded rectangles), `references` (horizontal / vertical / diagonal lines), and `envelopes` (signed-area fills between two bounds). Unlocks five editorial ideas from the cross-sport lab in one packet.
LineChart chart-apinew-primitiveseditorial - ✓ `meta.warnings` stays `string[]`; public contract is the `[code]` bracket prefix
Warnings remain plain strings, but every warning starts with a bracket-prefixed code like `[envelope.no-overlap]`. Codes are public contract; prose may evolve. Consumers can grep codes reliably without a breaking type change.
LineChart warningsapipublic-contract - ✓ ShotMap renders goals after non-goals so they never get occluded in dense clusters
Markers are sorted by outcome before rendering: non-goals first, goals last. In dense penalty-area clusters, goal markers always sit on top of shot markers. Deterministic and unconditional — no API to override.
ShotMap visual-encodingfootball-semantics - ✓ ShotMap marker size scales by area, not linear radius
Marker radius is computed from xG via `r = √(minArea + t × (maxArea − minArea))`, not linearly. A 0.5 xG shot reads as ~71% the radius of a 1.0 xG shot, not 50% — which preserves perceptual equivalence because human vision reads circle area, not radius.
ShotMap visual-encodingdefault-behaviourdata-integrity - ✓ ShotMap shape + fill grammar is preset-owned; Opta is outcome-first, StatsBomb is xG-first
`preset="opta"` locks markers to circles with fill-vs-hollow encoding outcome (goal vs non-goal). `preset="statsbomb"` uses shapes keyed to context/body-part (hexagon foot, circle header, square set-piece) with fill colour driven by the xG colour ramp. The two presets intentionally produce different charts from the same data model.
ShotMap default-behaviourvisual-encodingpreset-semantics - ✓ XGTimeline annotation tiers: goals always, big-xG non-goals up to 3 per 15 min, rest tooltip-only
Shot annotations follow a three-tier policy. Tier 1 — goals — always annotate. Tier 2 — non-goals with xG ≥ 0.3, capped at 3 per 15-minute window. Tier 3 — everything else — surfaces only in the tooltip. Stops dense matches (30+ shots) becoming unreadable without losing the story.
XGTimeline default-behaviourchart-apivisual-encoding - ✓ XGTimeline cumulative lines are step-after, never smoothed
Cumulative xG paths are SVG `M/H/V` step-after segments — no cubic Béziers, no linear interpolation between shots. Smooth curves imply continuous chance generation, which is a lie. A step function says "this xG value held until the next shot", which is what actually happened.
XGTimeline visual-encodingfootball-semanticsalgorithm - ✓ `autoPitchLines` overrides user `pitchColors.lines` on dark ramps — the only prop that breaks user-wins
When `autoPitchLines: true` (default) and the colour ramp is dark (magma, inferno, viridis, or a custom dark gradient), pitch lines are forced to white — even if the user passed `pitchColors.lines="#333"`. Dark ramp + dark lines makes the pitch disappear; legibility wins over API consistency.
HeatmapKDETerritory default-behaviourvisual-encodingaccessibility - ✓ Heatmap defaults to fixed 12×8 bins; no adaptive grid
Zero-config Heatmap bins events into a fixed 12×8 grid. Adaptive schemes that vary bin count with data density were rejected because they break cross-match comparability — two heatmaps must be readable side by side without grid surprises.
Heatmap default-behaviourapivisual-encoding - ✓ Heatmap colour ramp is invariant across `valueMode`; only labels change
`valueMode: "count" | "intensity" | "share"` only changes the scale-bar copy and tooltip phrasing. Cell fills are always driven by intensity (`count / maxCount`). Switching mode never repaints the surface — so two heatmaps in different modes remain visually comparable.
Heatmap apivisual-encodingdata-integrity - ✓ Territory offers only 3×3 or 5×3 grids; no arbitrary resolution
`Territory` accepts `grid="3x3"` or `"5x3"` (plus named tactical presets), never an arbitrary `gridX`. A deliberate break from Heatmap's flexibility: Territory is a broadcast-editorial view where thirds-by-thirds or fifths-by-thirds are semantically meaningful and any other resolution is noise.
Territory apifootball-semanticsdefault-behaviour - ✓ KDE defaults bandwidth to Silverman per axis; football multi-clusters over-smooth as a known trade
Zero-config KDE uses Silverman's rule (`h = σ × n^(-1/6)`) independently on x and y. Silverman is designed for unimodal distributions, and multi-cluster football patterns (wing + centre + striker zones) over-smooth as a result. That's a known limitation, surfaced as a `[kde.low-confidence]` warning on sparse inputs.
KDE algorithmdefault-behaviour - ✓ KDE renders as a browser-side raster; static export support is deferred
KDE renders via a canvas-to-data-URL raster wrapped in an SVG `<image>`. Static export skips KDE entirely — it is not a member of the stable `ExportFrameSpec` union. Vector output is deferred until raster-vs-static parity rules are deliberately defined.
KDE breaking-changechart-apiexport - ✓ v0.2 is pre-release: break contracts outright, no deprecation shims
Until v1, Campos removes deprecated APIs in the same commit that introduces their replacement. No `@deprecated` markers, no opt-in migration windows, no shim layers. Consumer migration is part of the same packet.
policybreaking-changeversioning - ✓ SmallMultiples passes shared-view hints through `renderCell`, not React context
`renderCell(datum, index, view)` receives a third `SmallMultiplesView` argument carrying `pitchOrientation`, `pitchCrop`, and `sharedScale`. Consumers opt-in per chart by forwarding `view` into children. Implicit React context was rejected — it breaks SSR, static export, and agent-readability.
SmallMultiples architectureapicore-contract - ✓ BumpChart input ranks must be 1..N ordinal; no auto-derivation from raw metrics
`BumpChart` expects a `rank` field that is already a discrete 1..N ordinal. It never derives rank from `points` / `goals` / `xG` internally. Tie-breaking (dense vs min vs max) is a narrative choice and lives upstream; the chart refuses to own it.
BumpChart data-integrityapifootball-semantics - ✓ CometChart trail: thin tail is the earliest timepoint, thick head is the latest
The trail narrows as it recedes into the past. The marker and label live at the most recent timepoint. Consumers reading left-to-right intuit the story as "how we got to here" — not "where we started".
CometChart visual-encodingfootball-semantics - ✓ PassNetwork merges A↔B edge pairs into undirected edges by default
When the input contains `A→B` (10 passes) and `B→A` (8 passes), the default render collapses them into a single undirected edge with weight 18. `directed: true` keeps the pair distinct with arrowheads, at the cost of dense-network readability.
PassNetwork default-behaviourvisual-encodingfootball-semantics - ✓ PassFlow suppresses arrows when circular mean resultant is below 0.3
Bins whose mean resultant length R (circular concentration) < 0.3 render a neutral glyph (hollow circle by default) instead of an arrow. A zone where passes go in every direction has no honest mean — drawing an arrow would invent a direction the data doesn't support.
PassFlow default-behaviourvisual-encodingdata-integrity - ✓ PassMap renders dots when destination coordinates are missing, never invents arrows
Passes with null / missing `endX` or `endY` render as a dot at the origin, not as an omitted pass and not as an inferred trajectory. Header counts include both arrows and dots so the completion denominator stays honest.
PassMap data-integrityvisual-encodingaccessibility - ✓ PizzaChart slice length is always percentile; raw values never drive geometry
Slice radius reads as percentile against the declared cohort. Raw metric values (xG, progressive passes, tackles) pass through to tooltips and labels but never set the wedge geometry. Consumers who need raw-scale polar charts pick a different primitive.
PizzaChart chart-apifootball-semanticsdata-integrity - ✓ Three-way theme split: UI theme (context), pitch theme (store), chart preset (prop)
Theming is split into three separate systems. **UI theme** (light/dark chrome) flows through React context. **Pitch theme** (pitch colours, line style) lives in a nanostore shared across the site. **Chart preset** (editorial/analytical/minimal encoding grammar) is a component prop. Each owns one concern; they never reach into each other.
architecturethemecore-contract - ✓ Formation player names render in a below-marker pill, never inside the glyph
The marker glyph holds only the jersey number, initials, or position code. Full names render in a rounded pill below the marker when `showNames: true`. This keeps markers legible across long or multilingual names without clipping, and preserves a readable glyph even when the name pill is off.
Formation visual-encodingaccessibilitydefault-behaviour - ✓ Style props are callback-first; enum shorthands are sugar over callbacks
Every style surface (`markers`, `lines`, `trajectories`, `labels`) takes a callback `(ctx) => StyleValue` as its canonical signature. Object literals and enum shorthands (`colorBy="xg"`) are implemented as sugar that internally resolves to a callback. Agents can always fall back to callbacks without learning chart-local magic.
apiarchitectureagent-usability - ✓ Empty states keep the component shell; only the plot region goes blank
`<ShotMap shots={[]} />` renders the header stats (with zeroed values), a muted pitch, and a centred "No shots" message. Frame and chrome stay visible. The shell preserves layout during async loads and clearly distinguishes "no data" from "component not rendered".
component-defaultsaccessibilityapi - ✓ Core compute returns semantic regions, not raw drawing primitives
Every `compute*` function returns a model with named semantic regions — `headerStats`, `scaleBar`, `plot`, `legend`, `emptyState` — not a flat list of shapes. Layout policy lives in compute; renderers paint regions by name. New renderers (canvas, Figma, static export) plug in without re-deriving layout.
architecturecore-contractapi - ✓ Canonical pitch coordinates: attacker-perspective, x 0→100 own→opposition goal
Every event produced by an adapter is in canonical pitch coordinates before reaching a compute or render layer. x = 0 is the own goal, x = 100 is the opposition goal, y = 0 is attacker's right, y = 100 is attacker's left. No chart re-maps axes; all orientation is a render-time concern.
ShotMapPassMapPassFlowPassNetworkHeatmapTerritoryKDEFormationGoalMouthShotChart coordinate-systemcanonicalinvariants
No decisions match the current filters.