Single-match team report generator for football. Feed it one SPADL-style event CSV; get back an 18-page report (per-team passing, carries, defensive activity, individual maps, shot map, match-flow momentum, top performers) plus a stitched PDF.
python -m tmviz make <match.csv> --home X --away Y [...flags] # one-shot, scriptable
python -m tmviz wizard <match.csv> # interactive prompts
python -m tmviz render <config.yml> # re-render a saved config
python -m tmviz pages # list available pages
git clone https://github.com/yureed/tmviz.git
cd tmviz
pip install -e .
playwright install chromium # one-timepython -m tmviz make match.csv \
--home "Arsenal" --away "Atletico" \
--matchday 35 --date 2026-04-26 \
--venue "Emirates Stadium" \
--competition "Champions League 25/26" \
--out-dir out/arsenal_atleticoRenders 18 PNGs and one combined PDF to out/arsenal_atletico/. Run with --help to see every flag.
from tmviz import generate_report
generate_report(
csv="match.csv",
home="Arsenal", away="Atletico",
matchday=35, date="2026-04-26",
venue="Emirates Stadium",
competition="Champions League 25/26",
out_dir="out/arsenal_atletico",
)Returns {"html_paths": [...], "png_paths": [...], "pdf_path": Path}.
For interactive use — auto-detects team_ids, lists each detected goal so you can flag own-goals, collects metadata, and saves a YAML config you can re-render later:
python -m tmviz wizard match.csvOne row per event. Required columns:
| column | type | notes |
|---|---|---|
type_name |
str | event verb — see vocabulary below |
team_id |
int | one of two team ids in the file |
period_id |
int | 1, 2 (3, 4 if extra time) |
time_seconds |
float | seconds within the period |
start_x, start_y |
float | Opta 0–100 coordinates |
end_x, end_y |
float | same scale |
type_name vocabulary — tmviz uses the SPADL standard. The names that map to common concepts:
| concept | type_name value(s) |
|---|---|
| pass | pass |
| cross | cross |
| carry (a player moving with the ball) | dribble |
| take-on (1v1 dribble past a defender) | take_on |
| shot | shot |
| direct free-kick shot | shot_freekick |
| penalty shot | shot_penalty |
| corner delivery (in-swinger / out-swinger) | corner_crossed |
| corner played short | corner_short |
| free-kick whipped into the box | freekick_crossed |
| free-kick played short | freekick_short |
| throw-in | throw_in |
| tackle | tackle |
| interception | interception |
| clearance | clearance |
| header (any of above when contested in the air) | bodypart_name = head |
If you have data from a different schema, you'll need a thin transform to remap your event names to these strings before handing the CSV to tmviz. The vocabulary above is the SPADL convention used by socceraction and most public xT/xG models.
Player names — exactly one of:
- a
player_namecolumn inline in the main CSV (preferred), or - a
player_idcolumn + a separate file passed via--players-csv(cols:player_id, player_name). tmviz joins it in at load.
Team names come from the --home / --away flags (or wizard prompts). Optional: pass --teams-csv (cols: team_id, team_name) to give the wizard nicer name suggestions.
| column | what improves |
|---|---|
result_name |
success/fail — feeds pass-completion %, take-on success rate, etc. |
is_goal |
flags goals on the shot map and in the goals timeline |
| column | what it does |
|---|---|
xG (or xg) |
sizes shot circles by chance quality, adds team xG totals + an xG column on the tables page, and switches the match-flow chart to xG-based momentum |
xT_added |
per-event expected-threat. If absent, tmviz auto-computes it on load using the bundled Karun Singh xT grid — you only need raw coordinates. |
tmviz expects each team's events in their own attacking direction (low x = own goal, high x = opp goal — SPADL canon). If your input uses absolute coordinates (one fixed system where home attacks right and away attacks left), tmviz detects it from per-team mean shot start_x and flips the away team automatically. You'll see Note: detected absolute coords — flipped team_id N in the load output when this happens.
| name | what |
|---|---|
overview |
Score, goals timeline, both-team shot dots on a mini pitch, head-to-head stat comparison (possession, shots, xG/xT, pass %, prog passes, prog carries, take-ons, box entries, defensive actions) |
passing_home, passing_away |
Full-pitch open-play pass map per team. Forward = ink, backward = grey, failed = red dotted, progressive = gold. Side panel: top 5 progressive passers + key team rates |
network_home, network_away |
Pass network: nodes at average position sized by passes attempted, edges sized by combination count, top 8 combinations listed |
players_pass_home, players_pass_away |
One mini-pitch per player who featured, showing their passes for the match |
carries_home, carries_away |
All carries (progressive in gold) + take-on attempts (won/lost), top 5 carriers + take-on winners |
players_carry_home, players_carry_away |
One mini-pitch per player, showing their carries + take-ons |
defense_home, defense_away |
Defensive actions heatmap + shape markers (tackle / interception / clearance / aerial duel won-or-lost) |
players_defense_home, players_defense_away |
One mini-pitch per player, showing their defensive actions |
shots |
Both teams' shots on a single full pitch — colored by team, sized by xG (if present), goals haloed in gold |
flow |
Match-flow momentum graph: rolling 5-minute danger signal per team plotted around a zero line (above = home on top, below = away). Uses xG if available, otherwise xT. Goals planted on the zero line. |
tables |
6 (or 7 with xG) side-by-side per-team tables: top 5 in xT, prog passes, prog carries, take-ons, defensive actions, shots |
Use --pages overview,shots,flow,tables (or any subset) to generate fewer pages.
For each match:
out/<dir>/01_overview.html…18_tables.html- One PNG per page (rendered at 2× device scale)
- One combined PDF:
<home>_<score>_<away>_md<n>.pdf
cream (default) — newsprint paper, ink black, red/gold/blue accents.
To add a theme: drop a dataclass into tmviz/themes/, register it in tmviz/themes/__init__.py:THEMES. Pages read all colors via CSS variables so a theme swap restyles every page.
- Subclass
tmviz.pages.base.BasePageand implementbuild(cfg, df, derived) -> strreturning the inner HTML (the shell adds top strip + footer + styles). - Register in
tmviz/pages/__init__.py:REGISTRY. - Add the page name to
cfg.pagesin any config that wants it.
MIT.