Showcase

Can Campos build this?

This is the main showcase for Campos. Each entry starts with a real football data visualisation published by an analyst or data team, breaks it into reusable Campos pieces, then composes and styles those pieces to match the original.

If a reference surface exposes a layout, styling, or composition gap, the gap is the work. That makes this route a better product test than isolated component demos.

Composition Pressure

The library has to hold together as one football surface, not a gallery of unrelated widgets.

Data Pressure

Real reference work reveals which adapter and chart seams are already strong enough for production-looking output.

Styling Pressure

Recreating published analyst work forces the callback styling model to prove it can handle editorial demands without bespoke one-off APIs.

1. Target Visualization

@R_by_Ryo Liverpool 3-1 Arsenal match summary

Original by @R_by_Ryo · Data: Opta

2. Component Breakdown

The recreation uses two Campos components: XGTimeline for the cumulative xG step chart, and two ShotMap instances (one per team) for the split pitch view. Here they are individually:

XGTimeline
ETHTFT0.39J. Matip 0.09Mohamed Salah 0.790.41Mohamed Salah 0.29L. Torreira 0.10015304560759093Minute00.511.522.53Cumulative xG2.511.02
ShotMap — Arsenal
ShotMap — Liverpool

3. Styled Recreation

The same components composed and styled to match the reference. Custom layout code handles the score header, team stats summary, side-by-side shot maps, and shared outcome legend.

Liverpool: 3(2.51 xPTs)
Arsenal: 1(1.02 xPTs)
Aug 24 2019 (Matchday 3)
ETHTFT0.39J. Matip 0.09Mohamed Salah 0.790.41Mohamed Salah 0.29L. Torreira 0.10015304560759093Minute00.511.522.53Cumulative xG2.511.02
Liverpool | Shots: 25 | On Target: 5 | xG per Shot: 0.10
Arsenal | Shots: 9 | On Target: 3 | xG per Shot: 0.11
Arsenal1.02 xG
Liverpool2.51 xG
ResultGoalSaved ShotBlocked ShotMissed Shot
@R_by_RyoData: Opta
How this was composed
import { ShotMap, XGTimeline } from "@withqwerty/campos-react";
import type { Shot } from "@withqwerty/campos-schema";

// Outcome color palette
const OUTCOME_MARKERS = {
  fill: ({ shot }) => {
    const colors = {
      goal: "#22883e", saved: "#e8943a",
      blocked: "#999", "off-target": "#1a1a1a",
    };
    return colors[shot.outcome] ?? "#1a1a1a";
  },
  fillOpacity: () => 1,
  shape: () => "circle",
};

// Shared half-pitch config
const halfPitchProps = {
  crop: "half",
  preset: "opta",
  markers: OUTCOME_MARKERS,
  showShotTrajectory: false,
  showLegend: false,
  showSizeScale: false,
  showHeaderStats: false,
  framePadding: 0,
  pitchColors: { fill: "#fff", lines: "#ccc" },
};

export function MatchSummary({ homeShots, awayShots, allShots, meta }) {
  return (
    <div>
      {/* xG Timeline */}
      <XGTimeline
        shots={allShots}
        homeTeam={meta.homeTeamId}
        awayTeam={meta.awayTeamId}
        teamColors={["#c8102e", "#08285e"]}
        showAreaFill={false}
      />

      {/* Split shot map: two half-pitches joined */}
      <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr" }}>
        <ShotMap
          shots={awayShots}
          side="attack"
          attackingDirection="left"  // goal faces left
          {...halfPitchProps}
        />
        <ShotMap
          shots={homeShots}
          side="attack"
          attackingDirection="right"  // goal faces right
          {...halfPitchProps}
        />
      </div>
    </div>
  );
}