accepted

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

Context

Two sonar frame-of-reference conventions exist in public work:

  • Attack-adjusted / pitch-frame — 0 rad (the top of the sonar) always points toward the opposition goal. A “forward” bar means “toward the goal the player is attacking”, regardless of which way the team is shooting that half. This is what StatsBomb’s pass.angle encodes, what mplsoccer’s Pitch.sonar produces, and what McKinley’s reference (/Volumes/WQ/ref_code/PassSonar/) renders via coord_polar(start=pi, direction=1) on attacker-perspective data.
  • Heading-frame / body-frame — 0 rad points in the direction the passer’s body is facing at the moment of the pass. A “forward” bar means “the ball went where the player was facing”. This requires per-event heading data, which is only available in tracking-data products (or hybrid tracking+event feeds).

Some consumers use “adjusted” to mean the first form and “regular” to mean the second. The terminology isn’t standardised, so we pin down the frame in this decision doc.

Decision

Campos PassSonar is always drawn in the attack-adjusted frame. Specifically:

  • Compute layer derives the angle from canonical start/end points: atan2(pass.endY - pass.y, pass.endX - pass.x) (packages/react/src/compute/pass-sonar.ts:246).
  • Campos canonical coordinates are attacker-perspective with x = 0 own goal and x = 100 opposition goal (see canonical-pitch-coordinates.md). atan2 on canonical-frame deltas therefore always yields “0 rad = toward opposition goal”.
  • Adapters never read a provider’s pass.angle field. Provider conventions vary (StatsBomb pitch-frame, Opta raw absolute, tracking-derived body-frame) and passing any of them through unchanged leaks that ambiguity into the rendered chart. We always recompute.

No frame prop ships in v0.2. Adding a single-valued prop for forward-compatibility is code smell; the invariant is documented here and in JSDoc on ComputePassSonarInput and PassSonarProps instead.

Heading-frame is deferred

Heading-frame (“regular”) sonars are a distinct analytical product and will land with the tracking-adapter work, not as a retrofit onto event data. When that ships:

  • Add frame: "attacking" | "heading" to PassSonarProps and ComputePassSonarInput, defaulting to "attacking" (no behaviour change for existing consumers).
  • Under frame: "heading", compute angle from atan2(endY - y, endX - x) - headingAtPass. Requires headingAtPass on the PassEvent schema, which only tracking adapters will supply.
  • If frame: "heading" is requested but headingAtPass is missing, emit a missing-heading warning and fall back to attack-adjusted.

Why no runtime detection of “adjusted vs regular”

The initial instinct was to have the renderer recognise which frame the input is in and adjust. That’s the wrong seam:

  • Ingest-layer canonicalisation (coordinate frame) handles it already. Any PassEvent reaching compute has canonical coordinates by contract — the adjusted-frame angle drops out of the maths.
  • Runtime detection would have to infer intent from statistical regularities (e.g. “most players pass forward → the mode bin must be forward”), which breaks on goalkeepers, centre-backs under pressure, and low-sample cases.
  • It also duplicates responsibility that the coordinate-invariants doc already places on adapters.

Consequences

  • JSDoc on PassSonarProps and ComputePassSonarInput gains an explicit “angles are computed in the attack-adjusted frame” note with a pointer to this decision doc.
  • PassSonar spec (docs/specs/pass-sonar-spec.md) gains an “Frame of reference” section documenting the invariant and the deferred heading-frame extension.
  • No code change today. The existing implementation is already correct; this doc just nails down the contract so future tracking-adapter work knows the seam.
  • A future frame: "heading" option will be additive and default-safe; no migration will be required for consumers who don’t opt in.
← All decisions