---
title: "Messi vs Ronaldo | Career Analytics"
output:
flexdashboard::flex_dashboard:
orientation: columns
vertical_layout: fill
source_code: embed
---
```{r setup, include=FALSE}
# ============================================================================
# SETUP, DATA LOADING, CLEANING AND DERIVED METRICS
# ============================================================================
library(flexdashboard)
library(tidyverse) # dplyr, readr, ggplot2, tidyr, purrr, stringr
library(plotly)
library(DT)
library(scales)
library(htmltools)
knitr::opts_chunk$set(echo = FALSE, message = FALSE, warning = FALSE)
# ---- Football theme palette (dark pitch green, chalk white, trophy gold) ----
col_bg <- "#0B1A16"
col_panel <- "#10261F"
col_gold <- "#F2C14E"
col_text <- "#F3F7F4"
col_muted <- "#9DB8AC"
col_club <- "#3F8F6B" # grass green for "club" splits
player_cols <- c(Messi = "#6CC3F5", Ronaldo = "#FF4D5A")
players <- c("Messi", "Ronaldo")
font_stack <- "Barlow Condensed, Inter, Segoe UI, Helvetica, Arial, sans-serif"
# ---- 1. Locate and load the CSV --------------------------------------------
# Looks in the working directory for e.g. messi_vs_ronaldo_comparison_dataset.csv
# or messi_vs_ronaldo_comparison_dataset(1).csv
candidate_files <- list.files(pattern = "messi.*ronaldo.*\\.csv$", ignore.case = TRUE)
if (length(candidate_files) == 0) {
stop("Dataset not found. Save the CSV in the same folder as this .Rmd file ",
"(current working directory: ", getwd(), ").")
}
data_file <- candidate_files[1]
raw <- readr::read_csv(data_file, show_col_types = FALSE, trim_ws = TRUE)
# ---- 2. Detect the structure (wide format: one row per statistic) ----------
nm <- tolower(names(raw))
messi_col <- names(raw)[str_detect(nm, "messi")][1]
ronaldo_col <- names(raw)[str_detect(nm, "ronaldo")][1]
metric_col <- names(raw)[str_detect(nm, "categor|metric|measure")][1]
unit_col <- names(raw)[str_detect(nm, "^unit")][1]
if (is.na(metric_col)) metric_col <- names(raw)[1]
if (is.na(messi_col) || is.na(ronaldo_col)) {
stop("Could not find one column for Messi and one for Ronaldo. Columns found: ",
paste(names(raw), collapse = ", "))
}
# ---- 3. Clean: types, blanks, duplicates -----------------------------------
dat <- tibble(
metric = str_squish(as.character(raw[[metric_col]])),
unit = if (is.na(unit_col)) "" else as.character(raw[[unit_col]]),
Messi = parse_number(as.character(raw[[messi_col]])),
Ronaldo = parse_number(as.character(raw[[ronaldo_col]]))
) %>%
mutate(unit = replace_na(unit, "")) %>%
filter(!is.na(metric), metric != "") %>%
distinct(metric, .keep_all = TRUE) # one row per statistic: no double counting
# ---- 4. Helpers --------------------------------------------------------------
# Number formatting (scalar)
fmt <- function(x, digits = 0) {
if (length(x) == 0 || is.na(x)) "n/a" else scales::comma(x, accuracy = 10^(-digits))
}
# Decimals to show for a statistic: whole numbers -> 0, "minutes per" -> 1, else 2
row_digits <- function(metric, a, b) {
vals <- c(a, b)
is_int <- all(abs(vals - round(vals)) < 1e-9, na.rm = TRUE)
if (is_int) 0 else if (str_detect(tolower(metric), "minutes per")) 1 else 2
}
# For these statistics a LOWER value is better
lower_better <- function(metric) str_detect(tolower(metric), "minutes per")
dat <- dat %>% mutate(digits = pmap_dbl(list(metric, Messi, Ronaldo), row_digits))
# Look up one raw value from the CSV (NA if that statistic is not in the file)
val <- function(metric, player) {
x <- dat[[player]][tolower(dat$metric) == tolower(metric)]
if (length(x) == 0) NA_real_ else x[1]
}
# ---- 5. Derived metrics, recalculated from the raw totals ------------------
build_stats <- function(p) {
g <- val("Goals", p)
a <- val("Assists", p)
apps <- val("Appearances", p)
mins <- val("Minutes played", p)
ig <- val("International goals", p)
ia <- val("International assists", p)
iapps <- val("International appearances", p)
tibble(
player = p,
goals = g, assists = a, apps = apps, minutes = mins, ga = g + a,
g_per_app = g / apps, a_per_app = a / apps, ga_per_app = (g + a) / apps,
g_per90 = g / mins * 90, a_per90 = a / mins * 90, ga_per90 = (g + a) / mins * 90,
min_per_goal = mins / g, min_per_assist = mins / a, min_per_ga = mins / (g + a),
intl_goals = ig, intl_assists = ia, intl_apps = iapps,
club_goals = g - ig, club_assists = a - ia, club_apps = apps - iapps,
club_g_per_app = (g - ig) / (apps - iapps), intl_g_per_app = ig / iapps,
club_a_per_app = (a - ia) / (apps - iapps), intl_a_per_app = ia / iapps,
cl_goals = val("Champions League goals", p),
wc_goals = val("World Cup goals", p),
ballon = val("Ballon d'Or awards", p)
)
}
S <- bind_rows(build_stats("Messi"), build_stats("Ronaldo"))
sv <- function(col, p) S[[col]][S$player == p] # one derived value
has <- function(...) all(!is.na(unlist(S[, c(...)]))) # are all inputs available?
note <- function(txt) tags$div(style = paste0("color:", col_muted, ";padding:20px;"), txt)
# The club/international split assumes career totals INCLUDE international games.
# Only use it if that is plausible (international <= total for every measure).
split_ok <- has("club_goals", "club_assists", "club_apps") &&
all(unlist(S[, c("club_goals", "club_assists", "club_apps")]) >= 0)
# ---- 6. Index table: every metric scaled so the leader = 100 ---------------
# Higher-is-better metrics: value / best * 100.
# Lower-is-better metrics (minutes per goal): best / value * 100 (inverted).
index_tbl <- dat %>%
filter(!is.na(Messi), !is.na(Ronaldo), Messi > 0, Ronaldo > 0) %>%
mutate(
lower = lower_better(metric),
best = if_else(lower, pmin(Messi, Ronaldo), pmax(Messi, Ronaldo)),
Messi_idx = if_else(lower, best / Messi, Messi / best) * 100,
Ronaldo_idx = if_else(lower, best / Ronaldo, Ronaldo / best) * 100
)
# ---- 7. Validate the pre-computed statistics in the CSV --------------------
chk_def <- tribble(
~metric, ~col, ~tol, ~d,
"Goals + assists", "ga", 0.5, 0,
"Goals per appearance", "g_per_app", 0.0051, 3,
"Goals per 90 minutes", "g_per90", 0.0051, 3,
"Goals + assists per 90 minutes", "ga_per90", 0.0051, 3,
"Minutes per goal", "min_per_goal", 0.051, 2
)
validation <- chk_def %>%
crossing(player = players) %>%
mutate(
reported = map2_dbl(metric, player, val),
recomputed = map2_dbl(col, player, sv),
status = case_when(
is.na(reported) | is.na(recomputed) ~ "Not checked",
abs(reported - recomputed) <= tol ~ "Match",
TRUE ~ "Mismatch"
),
Reported = map2_chr(reported, d, fmt),
Recomputed = map2_chr(recomputed, d, fmt)
) %>%
arrange(player, match(metric, chk_def$metric))
n_checked <- sum(validation$status != "Not checked")
n_ok <- sum(validation$status == "Match")
validation_msg <- if (n_checked == 0) {
"None of the pre-computed statistics in the CSV could be validated."
} else {
paste0(n_ok, " of ", n_checked, " pre-computed statistics in the CSV match values ",
"recalculated from the raw totals (within rounding).")
}
# ---- 8. Plot styling helpers -------------------------------------------------
# Common dark, minimal plotly layout
style_plot <- function(p, ytitle = NULL, xtitle = NULL, legend = TRUE,
xaxis_extra = list(), yaxis_extra = list()) {
p %>%
layout(
paper_bgcolor = "rgba(0,0,0,0)",
plot_bgcolor = "rgba(0,0,0,0)",
font = list(color = col_text, family = font_stack, size = 14),
xaxis = modifyList(list(title = xtitle, showgrid = FALSE, zeroline = FALSE,
color = col_muted, automargin = TRUE), xaxis_extra),
yaxis = modifyList(list(title = ytitle, showgrid = FALSE, zeroline = FALSE,
color = col_muted, automargin = TRUE), yaxis_extra),
showlegend = legend,
legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.12),
hoverlabel = list(bgcolor = col_panel, bordercolor = col_gold,
font = list(color = col_text, family = font_stack, size = 14)),
margin = list(l = 10, r = 10, t = 25, b = 10)
) %>%
config(displaylogo = FALSE,
modeBarButtonsToRemove = c("lasso2d", "select2d", "autoScale2d"))
}
# ggplot2 theme (used where ggplot2 + ggplotly is the better fit)
theme_dash <- function() {
theme_minimal(base_size = 13) +
theme(
plot.background = element_rect(fill = "transparent", color = NA),
panel.background = element_rect(fill = "transparent", color = NA),
text = element_text(color = col_text),
axis.text = element_text(color = col_muted),
axis.title = element_blank(),
panel.grid = element_blank(),
strip.text = element_text(color = col_gold, face = "bold"),
legend.position = "none"
)
}
# Two-bar chart (Messi vs Ronaldo) with data labels and custom tooltips
bar_two <- function(values, tooltips, ytitle) {
d <- tibble(player = factor(players, levels = players),
value = values, label = map_chr(values, fmt), tip = tooltips)
plot_ly(d, x = ~player, y = ~value, color = ~player, colors = player_cols,
type = "bar", text = ~label, textposition = "outside", cliponaxis = FALSE,
textfont = list(color = col_text, size = 18),
hovertext = ~tip, hoverinfo = "text") %>%
style_plot(ytitle = ytitle, legend = FALSE,
yaxis_extra = list(range = c(0, max(values, na.rm = TRUE) * 1.18)))
}
# ---- 9. HTML building blocks (scoreboard, KPI cards, match-stat bars) ------
# A number that animates from 0 up to its value (see count_js below).
# The final value is already in the text, so it still shows if JavaScript is off.
cnt <- function(x, digits = 0, class = "") {
if (is.na(x)) {
tags$span(class = class, "n/a")
} else {
tags$span(class = paste(class, "countup"),
`data-target` = x, `data-digits` = digits, fmt(x, digits))
}
}
count_js <- "(function(){ function run(){ var els=document.querySelectorAll('.countup'); for(var i=0;i<els.length;i++){ (function(el){ var target=parseFloat(el.getAttribute('data-target')); var d=parseInt(el.getAttribute('data-digits')||'0',10); if(isNaN(target)){return;} var start=null; var dur=1500; function step(ts){ if(start===null){start=ts;} var p=Math.min((ts-start)/dur,1); var e=1-Math.pow(1-p,3); el.textContent=(target*e).toLocaleString('en-GB',{minimumFractionDigits:d,maximumFractionDigits:d}); if(p<1){requestAnimationFrame(step);} } requestAnimationFrame(step); })(els[i]); } } document.addEventListener('DOMContentLoaded',run); if(window.jQuery){ window.jQuery(document).on('shown.bs.tab',run); } })();"
# KPI card: both players side by side, the higher value is underlined in gold
kpi_card <- function(title, m, r, digits = 0) {
lead_m <- !is.na(m) && !is.na(r) && m > r
lead_r <- !is.na(m) && !is.na(r) && r > m
tags$div(
class = "kpi-card",
tags$div(class = "kpi-title", title),
tags$div(
class = "kpi-row",
cnt(m, digits, paste(c("kpi-val", "messi", if (lead_m) "lead"), collapse = " ")),
tags$div(class = "kpi-vs", "vs"),
cnt(r, digits, paste(c("kpi-val", "ronaldo", if (lead_r) "lead"), collapse = " "))
)
)
}
# One "match stats" row: values on each side and a split bar showing each player's share.
# invert = TRUE for statistics where lower is better (share is based on 1 / value).
stat_row <- function(label, m, r, digits = 0, invert = FALSE) {
if (is.na(m) || is.na(r) || m <= 0 || r <= 0) return(NULL)
pm <- if (invert) (1 / m) / (1 / m + 1 / r) * 100 else m / (m + r) * 100
tags$div(
class = "ms-row",
tags$div(class = "ms-head",
tags$span(class = "ms-val messi", fmt(m, digits)),
tags$span(class = "ms-label", label),
tags$span(class = "ms-val ronaldo", fmt(r, digits))),
tags$div(class = "ms-bar",
tags$div(class = "ms-fill messi", style = sprintf("flex:%.2f 1 0;", pm)),
tags$div(class = "ms-fill ronaldo", style = sprintf("flex:%.2f 1 0;", 100 - pm)))
)
}
# ---- 10. Player photos, avatars, icons and profile blocks -------------------
# Full display names come from the CSV column headers (e.g. "Lionel Messi")
full_name <- c(Messi = messi_col, Ronaldo = ronaldo_col)
# Optional player photos: save them as images/messi.jpg and images/ronaldo.jpg
# (jpg, jpeg, png or webp) in a folder called "images" next to this .Rmd.
# They are embedded into the HTML file. Keep each photo under about 500 KB.
# If no photo is found, a styled silhouette avatar with the player's initials is shown.
photo_uri <- function(p) {
if (!dir.exists("images")) return(NA_character_)
f <- list.files("images", pattern = paste0("^", tolower(p), ".*\\.(jpg|jpeg|png|webp)$"),
ignore.case = TRUE, full.names = TRUE)
if (length(f) == 0) NA_character_ else knitr::image_uri(f[1])
}
photo_src <- c(Messi = photo_uri("Messi"), Ronaldo = photo_uri("Ronaldo"))
initials <- function(x) toupper(paste(substr(strsplit(x, " ")[[1]], 1, 1), collapse = ""))
rgba <- function(col, a) { v <- col2rgb(col); sprintf("rgba(%d,%d,%d,%.2f)", v[1], v[2], v[3], a) }
# Fallback avatar: original silhouette in the player's colour, with initials
avatar_svg <- function(p) {
col <- player_cols[[p]]
HTML(paste0(
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 120' width='100%' height='100%'>",
"<defs><clipPath id='clip-", p, "'><circle cx='60' cy='60' r='58'/></clipPath>",
"<linearGradient id='bg-", p, "' x1='0' y1='0' x2='0' y2='1'>",
"<stop offset='0' stop-color='", col, "' stop-opacity='.45'/>",
"<stop offset='1' stop-color='#0B1A16' stop-opacity='.95'/></linearGradient></defs>",
"<circle cx='60' cy='60' r='58' fill='url(#bg-", p, ")' stroke='", col, "' stroke-width='3'/>",
"<g clip-path='url(#clip-", p, ")'>",
"<circle cx='60' cy='45' r='19' fill='", col, "' fill-opacity='.9'/>",
"<path d='M18 120C22 84 40 72 60 72s38 12 42 48Z' fill='", col, "' fill-opacity='.9'/>",
"<text x='60' y='104' text-anchor='middle' font-family='Oswald, Arial, sans-serif' ",
"font-size='22' font-weight='700' fill='#0B1A16'>", initials(full_name[[p]]), "</text></g></svg>"))
}
# Player picture: the photo if one was supplied, otherwise the avatar
player_avatar <- function(p, size = 70) {
src <- photo_src[[p]]
px <- as.integer(size)
if (!is.na(src)) {
tags$img(class = "avatar-img", src = src, alt = full_name[[p]],
style = sprintf("width:%dpx;height:%dpx;border-color:%s;", px, px, player_cols[[p]]))
} else {
tags$div(class = "avatar-svg", style = sprintf("width:%dpx;height:%dpx;", px, px), avatar_svg(p))
}
}
# ---- Original, generic SVG icons (cup, golden ball, assist, globe, clock) ----
.icon_counter <- 0
next_id <- function() { .icon_counter <<- .icon_counter + 1; paste0("ic", .icon_counter) }
svg_open <- function(size) {
paste0("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' width='", size, "' height='", size, "'>")
}
gold_grad <- function(id) {
paste0("<defs><linearGradient id='", id, "' x1='0' y1='0' x2='1' y2='1'>",
"<stop offset='0' stop-color='#FFE08A'/><stop offset='.55' stop-color='#F2C14E'/>",
"<stop offset='1' stop-color='#B8860B'/></linearGradient></defs>")
}
icon_cup <- function(size = 40) {
id <- next_id()
HTML(paste0(svg_open(size), gold_grad(id),
"<path d='M16 6h32v16c0 11-7 19-16 19S16 33 16 22Z' fill='url(#", id, ")'/>",
"<path d='M16 11H7c0 11 5 17 12 19M48 11h9c0 11-5 17-12 19' fill='none' stroke='#F2C14E' stroke-width='4' stroke-linecap='round'/>",
"<rect x='29' y='41' width='6' height='9' fill='#D9A93A'/>",
"<path d='M19 58h26l-3-8H22Z' fill='url(#", id, ")'/>",
"<path d='M23 11v11c0 6 3 11 7 13' fill='none' stroke='#FFF6D6' stroke-opacity='.6' stroke-width='2.5' stroke-linecap='round'/></svg>"))
}
icon_ball <- function(size = 40) {
id <- next_id()
HTML(paste0(svg_open(size), gold_grad(id),
"<circle cx='32' cy='32' r='27' fill='url(#", id, ")' stroke='#8A6A0A' stroke-width='1.5'/>",
"<polygon points='32,23 40.6,29.2 37.3,39.3 26.7,39.3 23.4,29.2' fill='#8A6A0A' fill-opacity='.85'/>",
"<path d='M32 23V6M40.6 29.2L56.7 24M37.3 39.3L47.3 53M26.7 39.3L16.7 53M23.4 29.2L7.3 24' stroke='#8A6A0A' stroke-width='1.6' fill='none' stroke-opacity='.8'/>",
"<path d='M15 22C19 14 27 9 36 9' fill='none' stroke='#FFF6D6' stroke-opacity='.6' stroke-width='3' stroke-linecap='round'/></svg>"))
}
icon_assist <- function(size = 40) {
HTML(paste0(svg_open(size),
"<circle cx='12' cy='50' r='5' fill='#F2C14E'/>",
"<path d='M12 50C16 26 36 16 52 24' fill='none' stroke='#F2C14E' stroke-width='4' stroke-linecap='round' stroke-dasharray='1 8'/>",
"<polygon points='58,27 46,21 47,34' fill='#F2C14E'/></svg>"))
}
icon_globe <- function(size = 40) {
HTML(paste0(svg_open(size),
"<g fill='none' stroke='#F2C14E' stroke-width='3' stroke-linecap='round'>",
"<circle cx='32' cy='32' r='26'/><ellipse cx='32' cy='32' rx='11' ry='26' stroke-width='2.5'/>",
"<path d='M6 32h52M11 19h42M11 45h42' stroke-width='2.5'/></g></svg>"))
}
icon_clock <- function(size = 40) {
HTML(paste0(svg_open(size),
"<g fill='none' stroke='#F2C14E' stroke-width='3.5' stroke-linecap='round'>",
"<circle cx='32' cy='35' r='23'/><path d='M32 35V21M32 35l10 6M26 6h12'/></g></svg>"))
}
# Small stat tile: icon, animated number, label
stat_tile <- function(icon, value, label, digits = 0) {
tags$div(class = "tile", icon, cnt(value, digits, "tile-val"), tags$div(class = "tile-label", label))
}
# Profile hero card: photo/avatar, name and three headline numbers
profile_hero <- function(p) {
tags$div(
class = paste("profile-hero", tolower(p)),
tags$div(class = "ph-bigflag", flag_tag(p, 170)),
tags$div(class = "ph-photo", player_avatar(p, 150)),
tags$div(
class = "ph-info",
tags$div(class = "ph-tag", "PLAYER PROFILE"),
tags$div(class = "ph-name", toupper(full_name[[p]])),
tags$div(class = "ph-country", flag_tag(p, 40), tags$span(country[[p]])),
tags$div(class = "ph-sub", "Career totals from the dataset"),
tags$div(
class = "ph-chips",
tags$div(class = "ph-chip", cnt(sv("goals", p), 0, "ph-num"), tags$span("GOALS")),
tags$div(class = "ph-chip", cnt(sv("assists", p), 0, "ph-num"), tags$span("ASSISTS")),
tags$div(class = "ph-chip", cnt(sv("apps", p), 0, "ph-num"), tags$span("APPEARANCES"))
)
)
)
}
# Trophy cabinet: one golden ball per Ballon d'Or award in the data, plus stat tiles
profile_cabinet <- function(p) {
n <- sv("ballon", p)
unit_txt <- dat$unit[tolower(dat$metric) == "ballon d'or awards"][1]
if (length(unit_txt) == 0 || is.na(unit_txt)) unit_txt <- ""
balls <- if (!is.na(n) && n > 0) {
lapply(seq_len(min(n, 20)), function(i)
tags$span(class = "cab-ball", style = sprintf("animation-delay:%.2fs;", i * 0.12), icon_ball(34)))
} else NULL
tagList(
if (!is.na(n)) tags$div(
class = "cab-row",
tags$div(class = "cab-text",
tags$div(class = "cab-label", "Ballon d'Or awards"),
tags$div(class = "cab-unit", unit_txt)),
tags$div(class = "cab-icons", balls),
cnt(n, 0, "cab-count")),
tags$div(
class = "tile-grid",
stat_tile(icon_cup(38), sv("cl_goals", p), "Champions League goals"),
stat_tile(icon_cup(38), sv("wc_goals", p), "World Cup goals"),
stat_tile(icon_globe(38), sv("intl_goals", p), "International goals"),
stat_tile(icon_ball(38), sv("g_per90", p), "Goals per 90", 2),
stat_tile(icon_assist(38), sv("a_per90", p), "Assists per 90", 2),
stat_tile(icon_clock(38), sv("min_per_goal", p), "Minutes per goal", 1)
)
)
}
# Two donuts: where the player's goals and assists came from (club vs international)
profile_donuts <- function(p) {
if (!split_ok) return(note("Club/international split is not available (missing or inconsistent data)."))
pie_cols <- c(col_club, col_gold)
plot_ly() %>%
add_pie(labels = c("Club", "International"),
values = c(sv("club_goals", p), sv("intl_goals", p)),
hole = 0.62, sort = FALSE, name = "Goals",
domain = list(x = c(0, 0.47), y = c(0.05, 0.95)),
marker = list(colors = pie_cols, line = list(color = col_bg, width = 3)),
textinfo = "value+percent", textfont = list(color = col_text, size = 13),
hoverinfo = "label+value+percent") %>%
add_pie(labels = c("Club", "International"),
values = c(sv("club_assists", p), sv("intl_assists", p)),
hole = 0.62, sort = FALSE, name = "Assists", showlegend = FALSE,
domain = list(x = c(0.53, 1), y = c(0.05, 0.95)),
marker = list(colors = pie_cols, line = list(color = col_bg, width = 3)),
textinfo = "value+percent", textfont = list(color = col_text, size = 13),
hoverinfo = "label+value+percent") %>%
layout(
paper_bgcolor = "rgba(0,0,0,0)",
font = list(color = col_text, family = font_stack, size = 14),
showlegend = TRUE,
legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.02),
hoverlabel = list(bgcolor = col_panel, bordercolor = col_gold, font = list(color = col_text)),
annotations = list(
list(text = "<b>GOALS</b>", x = 0.235, y = 0.5, xref = "paper", yref = "paper",
showarrow = FALSE, font = list(size = 16, color = col_gold)),
list(text = "<b>ASSISTS</b>", x = 0.765, y = 0.5, xref = "paper", yref = "paper",
showarrow = FALSE, font = list(size = 16, color = col_gold))),
margin = list(l = 10, r = 10, t = 10, b = 30)
) %>%
config(displaylogo = FALSE)
}
# One player's metrics against the rival (leader of each metric = 100)
profile_index <- function(p) {
if (nrow(index_tbl) == 0) return(note("No comparable metrics in the dataset."))
rival <- setdiff(players, p)
pc <- player_cols[[p]]
d <- index_tbl %>%
mutate(
idx = .data[[paste0(p, "_idx")]],
own_txt = map2_chr(.data[[p]], digits, fmt),
rival_txt = map2_chr(.data[[rival]], digits, fmt),
bar_col = if_else(idx >= 99.999, pc, rgba(pc, 0.45)),
metric = factor(metric, levels = rev(unique(dat$metric))),
tip = paste0("<b>", full_name[[p]], "</b><br>", metric, ": ", own_txt, " ", unit,
"<br>", rival, ": ", rival_txt, " ", unit,
"<br>Index: ", round(idx, 1),
if_else(idx >= 99.999, "<br><i>Leads this metric</i>", ""))
)
plot_ly(d, y = ~metric, x = ~idx, type = "bar", orientation = "h",
marker = list(color = ~bar_col),
text = ~round(idx), textposition = "outside", cliponaxis = FALSE,
textfont = list(color = col_text, size = 12),
hovertext = ~tip, hoverinfo = "text") %>%
style_plot(xtitle = "Index (leader of each metric = 100)", legend = FALSE,
xaxis_extra = list(range = c(0, 122))) %>%
layout(shapes = list(list(type = "line", x0 = 100, x1 = 100, y0 = 0, y1 = 1, yref = "paper",
line = list(color = col_gold, dash = "dot", width = 1))))
}
# ---- 11. National flags (original SVG drawings, waved with CSS) --------------
country <- c(Messi = "Argentina", Ronaldo = "Portugal")
flag_svg <- function(ctry) {
if (ctry == "Argentina") {
ang <- seq(0, 330, by = 30) * pi / 180
rays <- paste0(sprintf("<line x1='%.2f' y1='%.2f' x2='%.2f' y2='%.2f'/>",
30 + 5.2 * cos(ang), 20 + 5.2 * sin(ang),
30 + 7.4 * cos(ang), 20 + 7.4 * sin(ang)), collapse = "")
paste0("<rect width='60' height='40' fill='#74ACDF'/>",
"<rect y='13.33' width='60' height='13.34' fill='#FFFFFF'/>",
"<g stroke='#F6B40E' stroke-width='.9' stroke-linecap='round'>", rays, "</g>",
"<circle cx='30' cy='20' r='4.3' fill='#F6B40E' stroke='#C98A00' stroke-width='.5'/>")
} else {
paste0("<rect width='60' height='40' fill='#FF0000'/>",
"<rect width='24' height='40' fill='#046A38'/>",
"<circle cx='24' cy='20' r='7.2' fill='none' stroke='#FFE000' stroke-width='1.6'/>",
"<circle cx='24' cy='20' r='4.2' fill='none' stroke='#FFE000' stroke-width='.8'/>",
"<path d='M20.4 15.6h7.2v6c0 2.6-1.7 4.2-3.6 5-1.9-.8-3.6-2.4-3.6-5z' fill='#FFFFFF' stroke='#FFE000' stroke-width='.6'/>",
"<path d='M21.8 17h4.4v4.2c0 1.6-1 2.6-2.2 3.2-1.2-.6-2.2-1.6-2.2-3.2z' fill='#FF0000'/>")
}
}
# A waving flag (width in px, 3:2 ratio). Animated by the .flag CSS rules.
flag_tag <- function(p, w = 44) {
h <- round(w * 2 / 3)
tags$span(
class = paste("flag", tolower(p)),
style = sprintf("width:%dpx;height:%dpx;", as.integer(w), as.integer(h)),
title = country[[p]],
HTML(paste0("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 60 40' width='100%' height='100%' ",
"preserveAspectRatio='none'>", flag_svg(country[[p]]), "</svg>")),
tags$span(class = "flag-shine"))
}
# ---- 12. Club-by-club career data (typed in; NOT part of the CSV) -----------
# Senior-team figures, all competitions. Past clubs are final; the current clubs
# (Inter Miami, Al Nassr) change every match - figures are approximate, taken from
# public reports in Sep-Oct 2026. Assists for Sporting CP, Manchester United,
# Inter Miami and Al Nassr are estimates. Edit this table to update anything.
club_tbl <- tribble(
~player, ~club, ~abbr, ~nation, ~league, ~years, ~apps, ~goals, ~assists, ~color, ~ink, ~trophies,
"Messi", "Grandoli", "GRA", "Argentina","Youth academy", "1992\u20131995", NA, NA, NA, "#7A8F86", "#FFFFFF", "",
"Messi", "Newell's Old Boys","NOB", "Argentina","Youth academy", "1995\u20132000", NA, NA, NA, "#B3121B", "#FFFFFF", "Youth league titles",
"Messi", "FC Barcelona Youth","BAY","Spain", "Youth academy", "2000\u20132003", NA, NA, NA, "#5B1A33", "#FFFFFF", "Youth league titles",
"Messi", "FC Barcelona C", "BAC", "Spain", "Tercera Divisi\u00f3n", "2003\u20132004", 10, 5, NA, "#7A1F3D", "#FFFFFF", "",
"Messi", "FC Barcelona B", "BAB", "Spain", "Segunda Divisi\u00f3n", "2004\u20132005", 22, 6, NA, "#8C2A4D", "#FFFFFF", "",
"Messi", "FC Barcelona", "FCB", "Spain", "La Liga", "2004\u20132021", 778, 672, 305, "#A50044", "#FFFFFF", "10 La Liga;7 Copa del Rey;4 Champions League;3 Club World Cup;3 UEFA Super Cup;8 Supercopa de Espa\u00f1a",
"Messi", "Paris Saint-Germain", "PSG", "France", "Ligue 1", "2021\u20132023", 75, 32, 35, "#1F5FB4", "#FFFFFF", "2 Ligue 1;1 Troph\u00e9e des Champions",
"Messi", "Inter Miami", "MIA", "USA", "MLS", "2023\u2013present", 111, 98, 66, "#F7B5CD", "#0B1A16", "Leagues Cup 2023;Supporters' Shield 2024;MLS Cup 2025",
"Ronaldo", "Andorinha", "AND", "Portugal","Youth academy", "1992\u20131995", NA, NA, NA, "#7A8F86", "#FFFFFF", "",
"Ronaldo", "CD Nacional", "NAC", "Portugal","Youth academy", "1995\u20131997", NA, NA, NA, "#1B3A8C", "#FFFFFF", "",
"Ronaldo", "Sporting CP Youth","SPY", "Portugal","Youth academy", "1997\u20132002", NA, NA, NA, "#4FA06E", "#FFFFFF", "",
"Ronaldo", "Sporting CP B", "SPB", "Portugal","Reserve team", "2001\u20132002", NA, NA, NA, "#3E8E5E", "#FFFFFF", "",
"Ronaldo", "Sporting CP", "SCP", "Portugal","Primeira Liga", "2002\u20132003", 31, 5, 6, "#00843D", "#FFFFFF", "Superta\u00e7a 2002",
"Ronaldo", "Manchester United (1st spell)", "MUN", "England", "Premier League", "2003\u20132009", 292, 118, 64, "#DA291C", "#FFFFFF", "3 Premier League;1 Champions League;1 FA Cup;2 League Cup;1 Club World Cup",
"Ronaldo", "Real Madrid", "RMA", "Spain", "La Liga", "2009\u20132018", 438, 450, 131, "#EDEDED", "#0B1A16", "2 La Liga;4 Champions League;2 Copa del Rey;3 Club World Cup;2 UEFA Super Cup;2 Supercopa de Espa\u00f1a",
"Ronaldo", "Juventus", "JUV", "Italy", "Serie A", "2018\u20132021", 134, 101, 22, "#C9C9C9", "#0B1A16", "2 Serie A;1 Coppa Italia;2 Supercoppa Italiana",
"Ronaldo", "Manchester United (2nd spell)", "MUN", "England", "Premier League", "2021\u20132022", 54, 27, 6, "#B71C1C", "#FFFFFF", "",
"Ronaldo", "Al Nassr", "NAS", "Saudi Arabia", "Saudi Pro League", "2023\u2013present", 160, 135, 28, "#F9D71C", "#0B1A16", "Arab Club Champions Cup 2023;Saudi Pro League 2025/26"
) %>%
group_by(player) %>%
mutate(ga = goals + assists, g_app = goals / apps, a_app = assists / apps,
ga_app = (goals + assists) / apps, share = goals / sum(goals, na.rm = TRUE)) %>%
ungroup() %>%
mutate(stage_senior = !is.na(goals),
tip = paste0("<b>", club, "</b> (", years, ")<br>", league, ", ", nation,
"<br>Appearances: ", comma(apps), "<br>Goals: ", comma(goals),
"<br>Assists: ", comma(assists), "<br>Goals per match: ", sprintf("%.2f", g_app),
"<br>G+A per match: ", sprintf("%.2f", ga_app),
"<br>Share of club goals: ", sprintf("%.1f%%", share * 100)))
# One club card: badge (initials, no logos), animated numbers, goal-share bar, honours
club_card <- function(r, i, max_goals) {
pc <- player_cols[[r$player]]
f2 <- function(x) if (is.na(x)) "n/a" else sprintf("%.2f", x)
bar_w <- if (is.na(r$goals)) 0 else r$goals / max_goals * 100
share_txt <- if (is.na(r$goals)) "Youth / reserve spell - stats not included in this table" else
if (is.na(r$assists)) sprintf("%.1f%% of the player's club goals (assists not recorded)", r$share * 100) else
sprintf("%.1f%% of the player's club goals", r$share * 100)
trophies <- trimws(strsplit(r$trophies, ";", fixed = TRUE)[[1]])
tags$div(
class = "club-card",
style = sprintf("--cc:%s;--ink:%s;--pc:%s;animation-delay:%.2fs;", r$color, r$ink, pc, i * 0.14),
tags$div(class = "cc-badge", r$abbr),
tags$div(
class = "cc-main",
tags$div(class = "cc-top",
tags$span(class = "cc-name", r$club),
tags$span(class = "cc-years", r$years)),
tags$div(class = "cc-sub", paste0(r$nation, " | ", r$league)),
tags$div(
class = "cc-stats",
tags$div(class = "cc-stat", cnt(r$goals, 0, "cc-num"), tags$span("GOALS")),
tags$div(class = "cc-stat", cnt(r$assists, 0, "cc-num"), tags$span("ASSISTS")),
tags$div(class = "cc-stat", cnt(r$apps, 0, "cc-num"), tags$span("APPS")),
tags$div(class = "cc-stat", tags$span(class = "cc-num", f2(r$g_app)), tags$span("GOALS / MATCH")),
tags$div(class = "cc-stat", tags$span(class = "cc-num", f2(r$ga_app)), tags$span("G+A / MATCH"))
),
tags$div(class = "cc-bar", tags$div(class = "cc-fill", style = sprintf("width:%.1f%%;", bar_w))),
tags$div(class = "cc-share", share_txt),
tags$div(class = "cc-trophies",
lapply(trophies, function(t) tags$span(class = "cc-trophy", paste0("\U0001F3C6 ", t))))
)
)
}
# A player's whole club career: flag header + one card per club
club_column <- function(p) {
d <- club_tbl %>% filter(player == p)
tagList(
tags$div(class = "club-head",
flag_tag(p, 52),
tags$div(tags$div(class = "ch-name", toupper(full_name[[p]])),
tags$div(class = "ch-sub",
paste0(country[[p]], " | ", n_distinct(sub(" \\(.*$", "", d$club)), " clubs | ",
comma(sum(d$goals, na.rm = TRUE)), " club goals | ",
comma(sum(d$apps, na.rm = TRUE)), " club appearances")))),
tags$div(class = "club-list club-scroll",
lapply(seq_len(nrow(d)), function(i) club_card(d[i, ], i, max(d$goals, na.rm = TRUE)))),
tags$script(HTML(count_js))
)
}
# Goals and assists by club for one player
club_chart <- function(p) {
d <- club_tbl %>% filter(player == p, !is.na(goals)) %>% mutate(club = factor(club, levels = club))
plot_ly(d, x = ~club) %>%
add_bars(y = ~goals, name = "Goals", marker = list(color = player_cols[[p]]),
text = ~goals, textposition = "outside", cliponaxis = FALSE,
textfont = list(color = col_text, size = 14), hovertext = ~tip, hoverinfo = "text") %>%
add_bars(y = ~assists, name = "Assists", marker = list(color = col_gold),
text = ~assists, textposition = "outside", cliponaxis = FALSE,
textfont = list(color = col_text, size = 14), hovertext = ~tip, hoverinfo = "text") %>%
style_plot(ytitle = "Count", yaxis_extra = list(range = c(0, max(d$goals) * 1.2))) %>%
layout(barmode = "group", bargap = 0.25)
}
# ---- 13. Pitch heatmaps with a running player (ILLUSTRATIVE) -----------------
# The dataset has no positional / tracking data, so these heat zones are hand-drawn
# from each player's commonly described role at each club. They show the idea of a
# heatmap, not measured data. Pitch is 105 x 68; the team attacks left to right.
# zone = c(x, y, intensity 0-1, radius). Edit the numbers to change the picture.
pitch_zones <- list(
Messi = list(
"Barcelona" = list(
role = "Starts as a right winger cutting inside, later a roaming false nine between the lines.",
zones = list(c(74,46,1,13), c(62,36,.9,14), c(86,36,.9,10), c(50,40,.55,13), c(92,30,.5,8), c(38,36,.3,12)),
path = "M38 46 C52 52 62 44 70 41 S84 36 92 34 S80 22 64 28 S44 40 38 46"),
"Paris Saint-Germain" = list(
role = "Right-sided forward who drops deep to link play and create for team-mates.",
zones = list(c(70,44,.9,13), c(58,38,.8,14), c(80,34,.6,11), c(46,42,.5,13), c(88,40,.4,9)),
path = "M44 44 C54 50 64 46 72 42 S86 40 90 36 S76 28 62 32 S46 38 44 44"),
"Inter Miami" = list(
role = "Free-roaming playmaker in the centre and right channel, finishing late in attacks.",
zones = list(c(62,36,1,15), c(72,42,.8,13), c(52,38,.7,14), c(82,36,.6,11), c(40,40,.4,12), c(90,32,.35,8)),
path = "M40 40 C50 34 60 40 70 38 S84 34 90 32 S74 46 60 42 S44 44 40 40")
),
Ronaldo = list(
"Manchester United" = list(
role = "Wide attacker who beats defenders on the flank and cuts in to shoot.",
zones = list(c(80,22,.9,13), c(88,34,.8,10), c(70,46,.6,12), c(62,30,.5,13), c(94,34,.7,8), c(78,48,.45,10)),
path = "M60 20 C72 16 82 20 88 28 S96 36 90 40 S72 48 66 38 S56 24 60 20"),
"Real Madrid" = list(
role = "Left winger who became the main central goalscorer, hunting around the penalty area.",
zones = list(c(82,24,1,13), c(92,34,.9,10), c(72,30,.7,13), c(62,22,.45,12), c(86,46,.35,9)),
path = "M62 20 C72 18 84 20 90 28 S96 34 92 38 S78 34 70 30 S58 22 62 20"),
"Juventus" = list(
role = "Left-sided striker who attacks the box and arrives late for headers and finishes.",
zones = list(c(88,28,.8,11), c(94,36,.9,9), c(78,24,.6,12), c(68,34,.45,13), c(84,44,.4,10)),
path = "M66 30 C76 24 86 24 92 30 S96 38 88 42 S72 40 66 30"),
"Al Nassr" = list(
role = "Central striker and penalty-area poacher; most of his time is spent inside the box.",
zones = list(c(94,34,1,9), c(86,34,.8,11), c(90,24,.5,9), c(90,44,.5,9), c(74,34,.35,12)),
path = "M72 34 C80 28 90 26 95 32 S92 42 86 42 S76 38 72 34")
)
)
pitch_svg <- function(uid, zones, path, pc) {
hid <- paste0("heat", uid)
stripes <- paste(vapply(0:9, function(i) sprintf(
"<rect x='%.1f' y='0' width='10.5' height='68' fill='%s'/>", i * 10.5, if (i %% 2 == 0) "#1D7A4F" else "#1A6E48"),
character(1)), collapse = "")
blobs <- paste(vapply(zones, function(z) sprintf(
"<circle cx='%.1f' cy='%.1f' r='%.1f' fill='url(#%s)' opacity='%.2f'><animate attributeName='opacity' values='%.2f;%.2f;%.2f' dur='3.5s' repeatCount='indefinite'/></circle>",
z[1], z[2], z[4], hid, z[3], z[3] * .75, z[3], z[3] * .75), character(1)), collapse = "")
lines <- paste0(
"<g fill='none' stroke='#FFFFFF' stroke-width='.45' stroke-opacity='.85'>",
"<rect x='0' y='0' width='105' height='68'/><line x1='52.5' y1='0' x2='52.5' y2='68'/>",
"<circle cx='52.5' cy='34' r='9.15'/>",
"<rect x='0' y='13.85' width='16.5' height='40.3'/><rect x='88.5' y='13.85' width='16.5' height='40.3'/>",
"<rect x='0' y='24.85' width='5.5' height='18.3'/><rect x='99.5' y='24.85' width='5.5' height='18.3'/>",
"<path d='M16.5 26.69 A9.15 9.15 0 0 1 16.5 41.31'/><path d='M88.5 26.69 A9.15 9.15 0 0 0 88.5 41.31'/>",
"<rect x='-1.6' y='30.3' width='1.6' height='7.4'/><rect x='105' y='30.3' width='1.6' height='7.4'/></g>",
"<circle cx='11' cy='34' r='.5' fill='#fff'/><circle cx='94' cy='34' r='.5' fill='#fff'/><circle cx='52.5' cy='34' r='.5' fill='#fff'/>")
paste0(
"<svg class='pitch-svg' xmlns='http://www.w3.org/2000/svg' viewBox='-2.5 -2 110 72' width='100%'>",
"<defs><radialGradient id='", hid, "'>",
"<stop offset='0%' stop-color='#FF2A2A' stop-opacity='.95'/><stop offset='35%' stop-color='#FF9800' stop-opacity='.7'/>",
"<stop offset='68%' stop-color='#FFEB3B' stop-opacity='.35'/><stop offset='100%' stop-color='#FFEB3B' stop-opacity='0'/>",
"</radialGradient></defs>",
stripes, blobs, lines,
"<path d='", path, "' fill='none' stroke='", pc, "' stroke-width='.7' stroke-dasharray='2 2' opacity='.9'>",
"<animate attributeName='stroke-dashoffset' from='0' to='-8' dur='1s' repeatCount='indefinite'/></path>",
"<g><animateMotion dur='11s' repeatCount='indefinite' begin='-0.7s' path='", path, "'/>",
"<circle r='.9' fill='#FFFFFF' stroke='#222' stroke-width='.25'/></g>",
"<g><animateMotion dur='11s' repeatCount='indefinite' path='", path, "'/>",
"<circle r='2' fill='", pc, "' opacity='.3'><animate attributeName='r' values='2;5;2' dur='1.2s' repeatCount='indefinite'/></circle>",
"<circle r='1.9' fill='", pc, "' stroke='#FFFFFF' stroke-width='.5'/></g>",
"<text x='52.5' y='67' text-anchor='middle' font-size='2.6' fill='#FFFFFF' fill-opacity='.55' ",
"font-family='Oswald, Arial, sans-serif' letter-spacing='.4'>ATTACKING DIRECTION →</text>",
"</svg>")
}
pitch_js <- "(function(){ if(window.__pitchBound){return;} window.__pitchBound=true; document.addEventListener('click',function(e){ var b=e.target.closest ? e.target.closest('.pitch-btn') : null; if(!b){return;} var w=b.closest('.pitch-wrap'); var bs=w.querySelectorAll('.pitch-btn'); for(var i=0;i<bs.length;i++){bs[i].classList.remove('active');} b.classList.add('active'); var vs=w.querySelectorAll('.pitch-view'); for(var j=0;j<vs.length;j++){ vs[j].style.display = (vs[j].getAttribute('data-id')===b.getAttribute('data-target')) ? 'block' : 'none'; } }); })();"
pitch_panel <- function(p) {
zs <- pitch_zones[[p]]
nm <- names(zs)
btns <- lapply(seq_along(nm), function(i)
tags$button(type = "button", class = paste("pitch-btn", if (i == 1) "active" else ""),
`data-target` = paste0(p, "-", i), nm[i]))
views <- lapply(seq_along(nm), function(i)
tags$div(class = "pitch-view", `data-id` = paste0(p, "-", i),
style = if (i == 1) "display:block;" else "display:none;",
HTML(pitch_svg(paste0(p, i), zs[[i]]$zones, zs[[i]]$path, player_cols[[p]])),
tags$div(class = "pitch-role", zs[[i]]$role)))
tagList(
tags$div(
class = "pitch-wrap",
tags$div(class = "pitch-head", flag_tag(p, 34), tags$span(class = "pitch-title", toupper(full_name[[p]]))),
tags$div(class = "pitch-btns", btns),
views,
tags$div(class = "pitch-legend", tags$span("Less time"), tags$span(class = "pitch-scale"), tags$span("More time")),
tags$div(class = "pitch-note",
"Illustrative heatmap: the dataset has no tracking data, so the zones are drawn from the player's usual role at each club. Pick a club above.")),
tags$script(HTML(pitch_js)))
}
```
```{css, echo=FALSE}
/* =====================================================================
FOOTBALL THEME - dark pitch green, chalk lines, trophy gold
Fonts load from Google Fonts (needs internet); fallbacks are provided.
===================================================================== */
@import url('https://fonts.googleapis.com/css2?family=Oswald:wght@500;600;700&family=Barlow+Condensed:wght@400;500;600&family=Inter:wght@400;500;600&display=swap');
:root {
--bg: #0B1A16; --panel: rgba(16,38,31,.80); --line: rgba(255,255,255,.14);
--gold: #F2C14E; --text: #F3F7F4; --muted: #9DB8AC;
--messi: #6CC3F5; --ronaldo: #FF4D5A; --grass: #3F8F6B;
}
/* ---------- Keyframes ---------- */
@keyframes slideUp { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: none; } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes wipe { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0 0 0 0); } }
@keyframes fillbar { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(242,193,78,.55); } 50% { box-shadow: 0 0 0 14px rgba(242,193,78,0); } }
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }
@keyframes shine { 0%, 60% { left: -60%; } 100% { left: 130%; } }
/* ---------- Page: mown-grass stripes + soft vignette ---------- */
body {
background: repeating-linear-gradient(90deg, #0B1A16 0, #0B1A16 90px, #0E2019 90px, #0E2019 180px);
background-attachment: fixed;
color: var(--text);
font-family: 'Inter', 'Segoe UI', Helvetica, Arial, sans-serif;
}
body::before {
content: ''; position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: -1; pointer-events: none;
background: radial-gradient(ellipse at center, rgba(0,0,0,0) 45%, rgba(0,0,0,.5) 100%);
}
.tab-content, .section, .dashboard-column { background: transparent !important; color: var(--text); }
.tab-pane.active { animation: fadeIn .5s ease; }
/* ---------- Navbar ---------- */
.navbar-inverse {
background: linear-gradient(90deg, #06110D, #0F2B21 55%, #06110D);
border: 0; border-bottom: 3px solid var(--gold);
box-shadow: 0 4px 18px rgba(0,0,0,.55);
}
.navbar-inverse .navbar-brand {
font-family: 'Oswald', 'Segoe UI', sans-serif; font-weight: 700; letter-spacing: .14em;
text-transform: uppercase; color: #fff;
}
.navbar-inverse .navbar-brand::before { content: '\26BD'; display: inline-block; margin-right: 10px; animation: spin 6s linear infinite; }
.navbar-inverse .navbar-nav > li > a {
font-family: 'Oswald', 'Segoe UI', sans-serif; text-transform: uppercase;
letter-spacing: .1em; font-size: 13px; color: #CFE3D9; transition: color .2s, background .2s;
}
.navbar-inverse .navbar-nav > li > a:hover { color: var(--gold); background: rgba(242,193,78,.08); }
.navbar-inverse .navbar-nav > .active > a, .navbar-inverse .navbar-nav > .active > a:hover, .navbar-inverse .navbar-nav > .active > a:focus {
background: rgba(242,193,78,.12); color: var(--gold); box-shadow: inset 0 -3px 0 var(--gold);
}
/* ---------- Glass panels with chalk-line borders ---------- */
.chart-wrapper {
background: var(--panel); border: 1px solid var(--line); border-radius: 12px; overflow: hidden;
box-shadow: 0 8px 24px rgba(0,0,0,.35);
transition: box-shadow .3s ease, border-color .3s ease;
animation: slideUp .7s ease backwards;
}
.chart-wrapper:hover { box-shadow: 0 12px 30px rgba(0,0,0,.5), 0 0 0 1px rgba(242,193,78,.4); border-color: rgba(242,193,78,.5); }
.dashboard-column > .chart-wrapper:nth-child(2) { animation-delay: .12s; }
.dashboard-column > .chart-wrapper:nth-child(3) { animation-delay: .24s; }
.chart-stage { background: transparent; color: var(--text); }
.chart-stage a { color: var(--gold); }
.chart-title {
position: relative; padding-left: 34px;
font-family: 'Oswald', 'Segoe UI', sans-serif; text-transform: uppercase; letter-spacing: .14em; font-size: 13px; font-weight: 600;
color: var(--gold); border-bottom: 1px solid var(--line);
background: linear-gradient(90deg, rgba(242,193,78,.2), rgba(242,193,78,0) 70%);
}
.chart-title::before { content: '\26BD'; position: absolute; left: 12px; font-size: 13px; }
/* ---------- Plotly bars: wipe-in animation, gold glow on hover ---------- */
.js-plotly-plot .barlayer .point path { animation: wipe 1.1s cubic-bezier(.2,.8,.2,1) both; transition: filter .2s; }
.js-plotly-plot .barlayer .point text { animation: fadeIn .6s ease .9s both; }
.js-plotly-plot .barlayer .point path:hover { filter: brightness(1.25) drop-shadow(0 0 8px rgba(242,193,78,.65)); }
.js-plotly-plot .scatterlayer .trace, .js-plotly-plot .pielayer .slice { animation: fadeIn 1s ease both; }
/* ---------- Scoreboard ---------- */
.scoreboard {
position: relative; margin: 12px 14px; padding: 14px 10px 12px; border-radius: 12px; overflow: hidden;
border: 1px solid var(--line); text-align: center;
background:
url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 220' preserveAspectRatio='none'%3E%3Cg fill='none' stroke='white' stroke-opacity='.16' stroke-width='2'%3E%3Crect x='4' y='4' width='592' height='212'/%3E%3Cline x1='300' y1='4' x2='300' y2='216'/%3E%3Ccircle cx='300' cy='110' r='42'/%3E%3Crect x='4' y='62' width='66' height='96'/%3E%3Crect x='530' y='62' width='66' height='96'/%3E%3Crect x='4' y='88' width='24' height='44'/%3E%3Crect x='572' y='88' width='24' height='44'/%3E%3C/g%3E%3C/svg%3E") center / 100% 100% no-repeat,
linear-gradient(180deg, rgba(17,52,40,.95), rgba(8,26,19,.95));
}
.sb-top { font-family: 'Oswald', sans-serif; letter-spacing: .22em; font-size: 11px; color: var(--muted); text-transform: uppercase; }
.live-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #FF4D5A; margin-right: 8px; animation: blink 1.4s ease-in-out infinite; }
.sb-main { display: flex; align-items: center; justify-content: space-between; margin-top: 8px; }
.sb-side { flex: 1; }
.sb-name { font-family: 'Oswald', sans-serif; letter-spacing: .18em; font-size: 15px; font-weight: 600; }
.sb-side.messi .sb-name, .sb-side.messi .sb-score { color: var(--messi); }
.sb-side.ronaldo .sb-name, .sb-side.ronaldo .sb-score { color: var(--ronaldo); }
.sb-score { display: block; font-family: 'Oswald', sans-serif; font-size: 58px; font-weight: 700; line-height: 1.05; text-shadow: 0 0 22px currentColor; }
.sb-sub { font-family: 'Barlow Condensed', sans-serif; letter-spacing: .2em; color: var(--muted); font-size: 12px; }
.sb-vs {
width: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
font-family: 'Oswald', sans-serif; font-weight: 700; color: #0B1A16; background: var(--gold); animation: pulse 2.2s infinite;
}
.sb-foot { display: flex; justify-content: center; gap: 10px; flex-wrap: wrap; margin-top: 12px; }
.sb-chip {
display: flex; align-items: center; gap: 10px; padding: 4px 12px; border-radius: 20px;
background: rgba(0,0,0,.35); border: 1px solid var(--line); font-family: 'Oswald', sans-serif; font-size: 15px;
}
.sb-chip .messi { color: var(--messi); } .sb-chip .ronaldo { color: var(--ronaldo); }
.sb-chip-label { font-family: 'Barlow Condensed', sans-serif; letter-spacing: .14em; font-size: 12px; color: var(--muted); }
/* ---------- KPI cards ---------- */
.kpi-legend { display: flex; gap: 18px; align-items: center; padding: 10px 14px 0 14px; font-family: 'Barlow Condensed', sans-serif; letter-spacing: .08em; font-size: 14px; color: var(--muted); }
.kpi-legend .dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; }
.dot.messi { background: var(--messi); } .dot.ronaldo { background: var(--ronaldo); }
.kpi-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; padding: 12px 14px; }
.kpi-card {
position: relative; overflow: hidden; padding: 12px 14px; border-radius: 10px;
background: linear-gradient(160deg, rgba(23,58,45,.9), rgba(12,32,25,.9));
border: 1px solid var(--line); border-top: 3px solid var(--gold);
transition: transform .25s ease, box-shadow .25s ease; animation: slideUp .6s ease backwards;
}
.kpi-card:nth-child(1) { animation-delay: .05s; } .kpi-card:nth-child(2) { animation-delay: .12s; }
.kpi-card:nth-child(3) { animation-delay: .19s; } .kpi-card:nth-child(4) { animation-delay: .26s; }
.kpi-card:nth-child(5) { animation-delay: .33s; } .kpi-card:nth-child(6) { animation-delay: .40s; }
.kpi-card:hover { transform: translateY(-4px); box-shadow: 0 10px 24px rgba(0,0,0,.5), 0 0 0 1px var(--gold); }
.kpi-card::after {
content: ''; position: absolute; top: 0; left: -60%; width: 40%; height: 100%;
background: linear-gradient(100deg, transparent, rgba(255,255,255,.09), transparent);
transform: skewX(-20deg); animation: shine 5s ease-in-out infinite;
}
.kpi-title { font-family: 'Barlow Condensed', sans-serif; color: var(--muted); font-size: 13px; text-transform: uppercase; letter-spacing: .14em; }
.kpi-row { display: flex; align-items: baseline; justify-content: space-between; margin-top: 6px; }
.kpi-val { font-family: 'Oswald', sans-serif; font-size: 30px; font-weight: 600; line-height: 1.1; }
.kpi-val.messi { color: var(--messi); } .kpi-val.ronaldo { color: var(--ronaldo); }
.kpi-val.lead { text-decoration: underline; text-decoration-color: var(--gold); text-decoration-thickness: 3px; text-underline-offset: 6px; }
.kpi-vs { font-family: 'Barlow Condensed', sans-serif; color: var(--muted); font-size: 13px; letter-spacing: .1em; }
.kpi-note { color: var(--muted); font-size: 12px; padding: 0 14px 12px 14px; }
/* ---------- Match stats (split bars, like a TV match-centre) ---------- */
.ms-wrap { padding: 8px 18px 14px 18px; }
.ms-legend { display: flex; justify-content: space-between; font-family: 'Oswald', sans-serif; letter-spacing: .16em; font-size: 13px; text-transform: uppercase; margin-bottom: 4px; }
.ms-legend .messi { color: var(--messi); } .ms-legend .ronaldo { color: var(--ronaldo); }
.ms-row { margin: 9px 0 13px 0; }
.ms-head { display: flex; justify-content: space-between; align-items: baseline; }
.ms-val { font-family: 'Oswald', sans-serif; font-size: 19px; font-weight: 600; min-width: 70px; }
.ms-val.messi { color: var(--messi); text-align: left; } .ms-val.ronaldo { color: var(--ronaldo); text-align: right; }
.ms-label { font-family: 'Barlow Condensed', sans-serif; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); font-size: 13px; text-align: center; }
.ms-bar { display: flex; gap: 3px; height: 10px; margin-top: 4px; border-radius: 6px; overflow: hidden; background: rgba(255,255,255,.06); }
.ms-fill { height: 100%; }
.ms-fill.messi { background: linear-gradient(90deg, #3C9BD6, var(--messi)); transform-origin: left; animation: fillbar 1.3s cubic-bezier(.2,.8,.2,1) both; }
.ms-fill.ronaldo { background: linear-gradient(90deg, #C62E3A, var(--ronaldo)); transform-origin: right; animation: fillbar 1.3s cubic-bezier(.2,.8,.2,1) both; }
/* ---------- Avatars and player photos ---------- */
.avatar-img { display: block; margin: 0 auto 4px; border-radius: 50%; object-fit: cover; object-position: center top; border: 3px solid var(--gold); box-shadow: 0 0 18px rgba(0,0,0,.5); }
.avatar-svg { display: block; margin: 0 auto 4px; }
/* ---------- Profile hero ---------- */
@keyframes popIn { from { opacity: 0; transform: scale(.2) rotate(-90deg); } to { opacity: 1; transform: none; } }
@keyframes floaty { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-5px); } }
.profile-hero { display: flex; align-items: center; gap: 22px; margin: 12px 14px; padding: 18px 20px; border-radius: 14px; border: 1px solid var(--line); overflow: hidden; position: relative; }
.profile-hero.messi { --pc: #6CC3F5; background: linear-gradient(135deg, rgba(108,195,245,.28), rgba(8,24,18,.96) 62%); }
.profile-hero.ronaldo { --pc: #FF4D5A; background: linear-gradient(135deg, rgba(255,77,90,.26), rgba(8,24,18,.96) 62%); }
.ph-photo { position: relative; width: 150px; height: 150px; flex: none; animation: floaty 5s ease-in-out infinite; }
.ph-photo::before { content: ''; position: absolute; top: -6px; left: -6px; right: -6px; bottom: -6px; border-radius: 50%; background: conic-gradient(var(--gold), var(--pc), var(--gold)); animation: spin 8s linear infinite; }
.ph-photo .avatar-img, .ph-photo .avatar-svg { position: relative; margin: 0; border-radius: 50%; border: 4px solid #0B1A16; box-sizing: border-box; background: #0B1A16; }
.ph-info { flex: 1; min-width: 0; }
.ph-tag { font-family: 'Oswald', sans-serif; letter-spacing: .24em; font-size: 11px; color: var(--gold); }
.ph-name { font-family: 'Oswald', sans-serif; font-size: 30px; font-weight: 700; line-height: 1.1; color: var(--pc); text-shadow: 0 0 20px rgba(0,0,0,.6); }
.ph-sub { font-family: 'Barlow Condensed', sans-serif; letter-spacing: .1em; color: var(--muted); font-size: 14px; margin-top: 2px; }
.ph-chips { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
.ph-chip { padding: 6px 12px; border-radius: 10px; background: rgba(0,0,0,.35); border: 1px solid var(--line); border-top: 2px solid var(--pc); text-align: center; }
.ph-num { display: block; font-family: 'Oswald', sans-serif; font-size: 24px; font-weight: 600; line-height: 1.1; }
.ph-chip span:last-child { font-family: 'Barlow Condensed', sans-serif; letter-spacing: .14em; font-size: 11px; color: var(--muted); }
/* ---------- Trophy cabinet and stat tiles ---------- */
.cab-row { display: flex; align-items: center; gap: 14px; margin: 12px 14px 4px; padding: 12px 14px; border-radius: 12px; border: 1px solid rgba(242,193,78,.35); background: linear-gradient(135deg, rgba(242,193,78,.14), rgba(8,24,18,.6)); }
.cab-label { font-family: 'Oswald', sans-serif; letter-spacing: .12em; text-transform: uppercase; font-size: 13px; color: var(--gold); }
.cab-unit { font-family: 'Barlow Condensed', sans-serif; color: var(--muted); font-size: 12px; letter-spacing: .08em; }
.cab-icons { flex: 1; display: flex; flex-wrap: wrap; gap: 4px; }
.cab-ball { display: inline-block; line-height: 0; transition: transform .4s ease; animation: popIn .55s cubic-bezier(.2,1.4,.4,1) backwards; filter: drop-shadow(0 2px 4px rgba(0,0,0,.5)); }
.cab-ball:hover { transform: rotate(360deg) scale(1.25); }
.cab-count { font-family: 'Oswald', sans-serif; font-size: 40px; font-weight: 700; color: var(--gold); text-shadow: 0 0 16px rgba(242,193,78,.5); }
.tile-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; padding: 10px 14px 14px; }
.tile { text-align: center; padding: 10px 6px; border-radius: 10px; border: 1px solid var(--line); border-top: 3px solid var(--gold); background: linear-gradient(160deg, rgba(23,58,45,.9), rgba(12,32,25,.9)); transition: transform .25s ease, box-shadow .25s ease; animation: slideUp .6s ease backwards; }
.tile:hover { transform: translateY(-4px); box-shadow: 0 10px 22px rgba(0,0,0,.5), 0 0 0 1px var(--gold); }
.tile svg { display: block; margin: 0 auto 4px; }
.tile-val { display: block; font-family: 'Oswald', sans-serif; font-size: 25px; font-weight: 600; color: var(--text); line-height: 1.1; }
.tile-label { font-family: 'Barlow Condensed', sans-serif; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); font-size: 12px; margin-top: 2px; }
/* ---------- DT tables ---------- */
.dataTables_wrapper, table.dataTable, table.dataTable td, .dataTables_info, .dataTables_length label, .dataTables_filter label { color: var(--text) !important; font-family: 'Barlow Condensed', 'Inter', sans-serif; font-size: 15px; }
table.dataTable thead th { background: rgba(242,193,78,.12); color: var(--gold) !important; font-family: 'Oswald', sans-serif; letter-spacing: .08em; text-transform: uppercase; border-bottom: 1px solid var(--gold) !important; }
table.dataTable tbody tr { background-color: rgba(10,26,20,.55) !important; }
table.dataTable.stripe tbody tr.odd { background-color: rgba(18,45,35,.65) !important; }
table.dataTable.hover tbody tr:hover { background-color: rgba(242,193,78,.14) !important; }
.dataTables_filter input, .dataTables_length select { background: #10261F; color: var(--text); border: 1px solid var(--gold); border-radius: 4px; }
.dataTables_wrapper .dataTables_paginate .paginate_button { color: var(--text) !important; }
.dataTables_wrapper .dataTables_paginate .paginate_button.current { background: var(--gold) !important; color: #0B1A16 !important; border: 1px solid var(--gold); }
/* ---------- Scrollbars ---------- */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: #2C5A48; border-radius: 4px; }
::-webkit-scrollbar-track { background: transparent; }
/* =====================================================================
NATIONAL FLAGS, CLUB CARDS AND EXTRA ANIMATION
===================================================================== */
@keyframes wave {
0%, 100% { transform: perspective(260px) rotateY(0deg) skewY(0deg); }
25% { transform: perspective(260px) rotateY(-16deg) skewY(2.5deg); }
75% { transform: perspective(260px) rotateY(14deg) skewY(-2.5deg); }
}
@keyframes flagshine { 0% { left: -70%; } 55%, 100% { left: 130%; } }
@keyframes slideInLeft { from { opacity: 0; transform: translateX(-40px); } to { opacity: 1; transform: none; } }
@keyframes growbar { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes pulseBadge { 0% { box-shadow: 0 0 0 0 var(--cc); } 70%, 100% { box-shadow: 0 0 0 12px rgba(0,0,0,0); } }
@keyframes roll { from { left: -40px; transform: rotate(0deg); } to { left: 100%; transform: rotate(1800deg); } }
@keyframes shimmer { 0% { background-position: 0% 50%; } 100% { background-position: 200% 50%; } }
@keyframes bounceIn { 0% { transform: scale(.3); opacity: 0; } 60% { transform: scale(1.12); opacity: 1; } 100% { transform: scale(1); } }
@keyframes wiggle { 0%, 100% { transform: rotate(0); } 25% { transform: rotate(-12deg); } 75% { transform: rotate(12deg); } }
/* ---------- Waving flags ---------- */
.flag { display: inline-block; position: relative; overflow: hidden; vertical-align: middle; border-radius: 3px;
box-shadow: 0 3px 10px rgba(0,0,0,.55); transform-origin: left center; animation: wave 3.4s ease-in-out infinite; }
.flag.ronaldo { animation-delay: -1.1s; }
.flag svg { display: block; }
.flag-shine { position: absolute; top: 0; bottom: 0; left: -70%; width: 45%;
background: linear-gradient(100deg, transparent, rgba(255,255,255,.5), transparent);
transform: skewX(-20deg); animation: flagshine 3.4s ease-in-out infinite; }
.sb-side .flag { display: block; margin: 2px auto 4px; }
.kpi-legend .flag, .ms-legend .flag { margin: 0 4px 0 2px; }
.ms-legend span { display: inline-flex; align-items: center; }
.ph-country { display: flex; align-items: center; gap: 10px; margin-top: 6px; font-family: 'Oswald', sans-serif;
letter-spacing: .2em; text-transform: uppercase; font-size: 13px; color: var(--text); }
.ph-bigflag { position: absolute; right: 18px; top: 50%; margin-top: -57px; opacity: .2; pointer-events: none; }
.ph-bigflag .flag { box-shadow: none; }
.ph-name { background: linear-gradient(90deg, var(--pc), #FFFFFF, var(--pc)); background-size: 200% 100%;
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; text-shadow: none; animation: shimmer 5s linear infinite; }
/* ---------- Rolling ball along the navbar, bouncing score and icons ---------- */
.navbar-inverse { position: relative; }
.navbar-inverse::after { content: '\26BD'; position: absolute; bottom: -22px; left: -40px; font-size: 17px; z-index: 5;
pointer-events: none; animation: roll 16s linear infinite; }
.sb-score { animation: bounceIn 1s cubic-bezier(.2,1.4,.4,1) both; }
.sb-side.ronaldo .sb-score { animation-delay: .15s; }
.tile:hover svg { animation: wiggle .5s ease; }
.kpi-card:hover .kpi-val { transform: scale(1.08); }
.kpi-val { display: inline-block; transition: transform .25s ease; }
/* ---------- Club-by-club cards ---------- */
.chart-stage { overflow-y: auto; }
.club-head { display: flex; align-items: center; gap: 16px; margin: 12px 14px 4px; padding: 12px 16px; border-radius: 12px;
border: 1px solid rgba(242,193,78,.35); background: linear-gradient(135deg, rgba(242,193,78,.16), rgba(8,24,18,.7));
animation: slideUp .6s ease backwards; }
.ch-name { font-family: 'Oswald', sans-serif; font-size: 22px; font-weight: 700; letter-spacing: .08em; color: var(--gold); }
.ch-sub { font-family: 'Barlow Condensed', sans-serif; letter-spacing: .1em; color: var(--muted); font-size: 14px; }
.club-list { padding: 4px 14px 14px; }
.club-card { position: relative; display: flex; gap: 14px; margin: 10px 0; padding: 12px 14px; overflow: hidden; border-radius: 12px;
border: 1px solid var(--line); border-left: 5px solid var(--cc);
background: linear-gradient(160deg, rgba(23,58,45,.92), rgba(12,32,25,.92));
animation: slideInLeft .75s cubic-bezier(.2,.8,.2,1) backwards; transition: transform .25s ease, box-shadow .25s ease; }
.club-card:hover { transform: translateX(6px) scale(1.01); box-shadow: 0 10px 24px rgba(0,0,0,.5), 0 0 0 1px var(--cc); }
.club-card::after { content: ''; position: absolute; top: 0; left: -60%; width: 35%; height: 100%;
background: linear-gradient(100deg, transparent, rgba(255,255,255,.08), transparent);
transform: skewX(-20deg); animation: shine 6s ease-in-out infinite; }
.cc-badge { flex: none; width: 54px; height: 54px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
font-family: 'Oswald', sans-serif; font-weight: 700; font-size: 15px; letter-spacing: .04em;
background: var(--cc); color: var(--ink); border: 3px solid rgba(255,255,255,.85); animation: pulseBadge 2.6s infinite; }
.cc-main { flex: 1; min-width: 0; }
.cc-top { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.cc-name { font-family: 'Oswald', sans-serif; font-size: 19px; font-weight: 600; color: var(--text); letter-spacing: .04em; }
.cc-years { font-family: 'Barlow Condensed', sans-serif; letter-spacing: .1em; color: var(--gold); font-size: 14px; }
.cc-sub { font-family: 'Barlow Condensed', sans-serif; letter-spacing: .1em; color: var(--muted); font-size: 13px; text-transform: uppercase; }
.cc-stats { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.cc-stat { padding: 4px 10px; border-radius: 8px; background: rgba(0,0,0,.32); border: 1px solid var(--line); border-top: 2px solid var(--pc); text-align: center; min-width: 62px; }
.cc-num { display: block; font-family: 'Oswald', sans-serif; font-size: 20px; font-weight: 600; line-height: 1.15; color: var(--text); }
.cc-stat span:last-child { font-family: 'Barlow Condensed', sans-serif; letter-spacing: .1em; font-size: 10.5px; color: var(--muted); }
.cc-bar { height: 8px; margin-top: 10px; border-radius: 5px; overflow: hidden; background: rgba(255,255,255,.08); }
.cc-fill { height: 100%; transform-origin: left; border-radius: 5px; background: linear-gradient(90deg, var(--cc), var(--pc));
animation: growbar 1.4s cubic-bezier(.2,.8,.2,1) .35s backwards; }
.cc-share { font-family: 'Barlow Condensed', sans-serif; font-size: 12px; letter-spacing: .08em; color: var(--muted); margin-top: 3px; }
.cc-trophies { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.cc-trophy { padding: 2px 9px; border-radius: 12px; font-family: 'Barlow Condensed', sans-serif; font-size: 12.5px; letter-spacing: .04em;
color: var(--gold); border: 1px solid rgba(242,193,78,.4); background: rgba(242,193,78,.08); animation: bounceIn .6s ease .6s backwards; }
/* ---------- Pitch heatmap + scrolling club lists ---------- */
.club-scroll { max-height: calc(100vh - 235px); overflow-y: auto; overflow-x: hidden; padding-right: 6px; }
.pitch-wrap { padding: 10px 16px 14px; }
.pitch-head { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.pitch-title { font-family: 'Oswald', sans-serif; font-size: 20px; font-weight: 700; letter-spacing: .1em; color: var(--gold); }
.pitch-btns { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; }
.pitch-btn { cursor: pointer; padding: 5px 14px; border-radius: 18px; border: 1px solid var(--line); background: rgba(0,0,0,.35); color: var(--text);
font-family: 'Oswald', sans-serif; font-size: 12px; letter-spacing: .1em; text-transform: uppercase; transition: all .2s ease; }
.pitch-btn:hover { border-color: var(--gold); color: var(--gold); transform: translateY(-2px); }
.pitch-btn.active { background: var(--gold); color: #0B1A16; border-color: var(--gold); }
.pitch-svg { display: block; border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,.5); animation: fadeIn .8s ease; }
.pitch-role { margin-top: 8px; font-family: 'Barlow Condensed', sans-serif; font-size: 15px; letter-spacing: .05em; color: var(--text); }
.pitch-legend { display: flex; align-items: center; gap: 10px; margin-top: 8px; font-family: 'Barlow Condensed', sans-serif; font-size: 12px; letter-spacing: .1em; color: var(--muted); }
.pitch-scale { flex: 0 0 160px; height: 10px; border-radius: 5px; background: linear-gradient(90deg, rgba(255,235,59,.1), #FFEB3B, #FF9800, #FF2A2A); }
.pitch-note { margin-top: 6px; font-size: 12px; color: var(--muted); }
```
Overview
=====================================
Column {data-width=420}
-------------------------------------
### Career scoreboard {data-height=360}
```{r scoreboard}
# Animated "final score" board: career goals, with assists and G+A underneath
tagList(
tags$div(
class = "scoreboard",
tags$div(class = "sb-top", tags$span(class = "live-dot"), "Career final score"),
tags$div(
class = "sb-main",
tags$div(class = "sb-side messi",
player_avatar("Messi", 64),
flag_tag("Messi", 36),
tags$div(class = "sb-name", "MESSI"),
cnt(sv("goals", "Messi"), 0, "sb-score"),
tags$div(class = "sb-sub", "GOALS")),
tags$div(class = "sb-vs", "VS"),
tags$div(class = "sb-side ronaldo",
player_avatar("Ronaldo", 64),
flag_tag("Ronaldo", 36),
tags$div(class = "sb-name", "RONALDO"),
cnt(sv("goals", "Ronaldo"), 0, "sb-score"),
tags$div(class = "sb-sub", "GOALS"))
),
tags$div(
class = "sb-foot",
tags$div(class = "sb-chip", cnt(sv("assists", "Messi"), 0, "messi"),
tags$span(class = "sb-chip-label", "ASSISTS"),
cnt(sv("assists", "Ronaldo"), 0, "ronaldo")),
tags$div(class = "sb-chip", cnt(sv("ga", "Messi"), 0, "messi"),
tags$span(class = "sb-chip-label", "G+A"),
cnt(sv("ga", "Ronaldo"), 0, "ronaldo"))
)
),
tags$script(HTML(count_js))
)
```
### Key performance indicators
```{r kpis}
# Section A: KPI cards (all values come from, or are recalculated from, the CSV)
tagList(
tags$div(class = "kpi-legend",
tags$span(class = "dot messi"), flag_tag("Messi", 24), "Lionel Messi",
tags$span(class = "dot ronaldo"), flag_tag("Ronaldo", 24), "Cristiano Ronaldo"),
tags$div(
class = "kpi-grid",
kpi_card("Total goals", sv("goals", "Messi"), sv("goals", "Ronaldo"), 0),
kpi_card("Total assists", sv("assists", "Messi"), sv("assists", "Ronaldo"), 0),
kpi_card("Appearances", sv("apps", "Messi"), sv("apps", "Ronaldo"), 0),
kpi_card("Goal contributions (G+A)", sv("ga", "Messi"), sv("ga", "Ronaldo"), 0),
kpi_card("Goals per match", sv("g_per_app", "Messi"), sv("g_per_app", "Ronaldo"), 2),
kpi_card("Assists per match", sv("a_per_app", "Messi"), sv("a_per_app", "Ronaldo"), 2)
),
tags$div(class = "kpi-note",
"Gold underline = higher value. Per-match figures are recalculated from the raw career totals.")
)
```
Column {data-width=580}
-------------------------------------
### Goals comparison
```{r goals}
# Section B: total goals
if (has("goals")) {
tip_goals <- function(p) paste0(
"<b>", p, "</b><br>Total goals: ", fmt(sv("goals", p)),
"<br>Appearances: ", fmt(sv("apps", p)),
"<br>Goals per match: ", fmt(sv("g_per_app", p), 2),
"<br>Goals per 90 min: ", fmt(sv("g_per90", p), 2))
bar_two(S$goals, unname(vapply(players, tip_goals, character(1))), "Career goals")
} else note("Goals are not available in the dataset.")
```
### Assists comparison
```{r assists}
# Section C: total assists
if (has("assists")) {
tip_assists <- function(p) paste0(
"<b>", p, "</b><br>Total assists: ", fmt(sv("assists", p)),
"<br>Assists per match: ", fmt(sv("a_per_app", p), 2),
"<br>Assists per 90 min: ", fmt(sv("a_per90", p), 2),
"<br>International assists: ", fmt(sv("intl_assists", p)))
bar_two(S$assists, unname(vapply(players, tip_assists, character(1))), "Career assists")
} else note("Assists are not available in the dataset.")
```
Messi
=====================================
Column {data-width=400}
-------------------------------------
### Player profile {data-height=270}
```{r messi_hero}
profile_hero("Messi")
```
### Trophy cabinet
```{r messi_cabinet}
profile_cabinet("Messi")
```
Column {data-width=600}
-------------------------------------
### Goals and assists: club vs international
```{r messi_donuts}
profile_donuts("Messi")
```
### How Messi compares with Ronaldo (leader = 100)
```{r messi_index}
profile_index("Messi")
```
Ronaldo
=====================================
Column {data-width=400}
-------------------------------------
### Player profile {data-height=270}
```{r ronaldo_hero}
profile_hero("Ronaldo")
```
### Trophy cabinet
```{r ronaldo_cabinet}
profile_cabinet("Ronaldo")
```
Column {data-width=600}
-------------------------------------
### Goals and assists: club vs international
```{r ronaldo_donuts}
profile_donuts("Ronaldo")
```
### How Ronaldo compares with Messi (leader = 100)
```{r ronaldo_index}
profile_index("Ronaldo")
```
Match Centre
=====================================
Column {data-width=450}
-------------------------------------
### Match stats (who wins each category?)
```{r match_stats}
# TV-style split bars. Each bar shows the share of the two players' combined total.
# For "minutes per goal" lower is better, so the share is based on 1 / value.
tags$div(
class = "ms-wrap",
tags$div(class = "ms-legend", tags$span(class = "messi", flag_tag("Messi", 26), "Messi"), tags$span(class = "ronaldo", "Ronaldo", flag_tag("Ronaldo", 26))),
stat_row("Goals", sv("goals", "Messi"), sv("goals", "Ronaldo")),
stat_row("Assists", sv("assists", "Messi"), sv("assists", "Ronaldo")),
stat_row("Goals + assists", sv("ga", "Messi"), sv("ga", "Ronaldo")),
stat_row("Appearances", sv("apps", "Messi"), sv("apps", "Ronaldo")),
stat_row("Goals per match", sv("g_per_app", "Messi"), sv("g_per_app", "Ronaldo"), 2),
stat_row("G+A per 90 minutes", sv("ga_per90", "Messi"), sv("ga_per90", "Ronaldo"), 2),
stat_row("Minutes per goal (lower is better)", sv("min_per_goal", "Messi"), sv("min_per_goal", "Ronaldo"), 1, invert = TRUE),
stat_row("International goals", sv("intl_goals", "Messi"), sv("intl_goals", "Ronaldo")),
stat_row("Champions League goals", sv("cl_goals", "Messi"), sv("cl_goals", "Ronaldo")),
stat_row("World Cup goals", sv("wc_goals", "Messi"), sv("wc_goals", "Ronaldo")),
stat_row("Ballon d'Or awards", sv("ballon", "Messi"), sv("ballon", "Ronaldo"))
)
```
Column {data-width=550}
-------------------------------------
### Player profile radar (leader of each metric = 100)
```{r radar}
# Scouting-style radar built from the indexed metrics (only metrics present in the CSV)
radar_pick <- c("Goals", "Assists", "Goals per appearance", "Goals + assists per 90 minutes",
"Champions League goals", "World Cup goals", "Ballon d'Or awards", "International goals")
radar_short <- c("Goals" = "Goals", "Assists" = "Assists", "Goals per appearance" = "Goals / match",
"Goals + assists per 90 minutes" = "G+A / 90", "Champions League goals" = "UCL goals",
"World Cup goals" = "WC goals", "Ballon d'Or awards" = "Ballon d'Or",
"International goals" = "Intl goals")
radar_tbl <- index_tbl %>%
filter(metric %in% radar_pick) %>%
arrange(match(metric, radar_pick)) %>%
mutate(label = coalesce(unname(radar_short[metric]), metric),
Messi_txt = map2_chr(Messi, digits, fmt),
Ronaldo_txt = map2_chr(Ronaldo, digits, fmt))
if (nrow(radar_tbl) >= 3) {
close_poly <- function(x) c(x, x[1]) # repeat the first point to close the shape
theta <- close_poly(radar_tbl$label)
plot_ly() %>%
add_trace(type = "scatterpolar", mode = "lines+markers", fill = "toself", name = "Messi",
r = close_poly(radar_tbl$Messi_idx), theta = theta,
line = list(color = player_cols[["Messi"]], width = 3),
marker = list(color = player_cols[["Messi"]], size = 8),
fillcolor = "rgba(108,195,245,0.25)", hoverinfo = "text",
text = paste0("<b>Messi</b><br>", theta, ": ", close_poly(radar_tbl$Messi_txt),
"<br>Index: ", round(close_poly(radar_tbl$Messi_idx), 1))) %>%
add_trace(type = "scatterpolar", mode = "lines+markers", fill = "toself", name = "Ronaldo",
r = close_poly(radar_tbl$Ronaldo_idx), theta = theta,
line = list(color = player_cols[["Ronaldo"]], width = 3),
marker = list(color = player_cols[["Ronaldo"]], size = 8),
fillcolor = "rgba(255,77,90,0.22)", hoverinfo = "text",
text = paste0("<b>Ronaldo</b><br>", theta, ": ", close_poly(radar_tbl$Ronaldo_txt),
"<br>Index: ", round(close_poly(radar_tbl$Ronaldo_idx), 1))) %>%
layout(
paper_bgcolor = "rgba(0,0,0,0)",
font = list(color = col_text, family = font_stack, size = 14),
polar = list(
bgcolor = "rgba(255,255,255,0.03)",
radialaxis = list(range = c(0, 100), tickvals = c(25, 50, 75, 100),
gridcolor = "rgba(255,255,255,0.15)", linecolor = "rgba(255,255,255,0.2)",
tickfont = list(color = col_muted, size = 10)),
angularaxis = list(gridcolor = "rgba(255,255,255,0.15)", linecolor = "rgba(255,255,255,0.2)",
tickfont = list(color = col_text, size = 14))
),
legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.05),
hoverlabel = list(bgcolor = col_panel, bordercolor = col_gold, font = list(color = col_text)),
margin = list(l = 50, r = 50, t = 30, b = 30)
) %>%
config(displaylogo = FALSE, modeBarButtonsToRemove = c("lasso2d", "select2d"))
} else note("Not enough comparable metrics in the dataset for a radar chart.")
```
Performance
=====================================
Column {data-width=600}
-------------------------------------
### Overall performance (each metric indexed: the leader = 100)
```{r index_chart}
# Section D: every metric in the CSV, normalised so scale differences do not distort
# (index_tbl is built in the setup chunk)
if (nrow(index_tbl) > 0) {
idx_long <- index_tbl %>%
pivot_longer(c(Messi_idx, Ronaldo_idx), names_to = "player", values_to = "index") %>%
mutate(
player = factor(str_remove(player, "_idx"), levels = players),
raw = if_else(player == "Messi", Messi, Ronaldo),
raw_txt = map2_chr(raw, digits, fmt),
metric = factor(metric, levels = rev(unique(dat$metric))),
tip = paste0("<b>", player, "</b><br>", metric, ": ", raw_txt, " ", unit,
"<br>Index: ", round(index, 1),
if_else(lower, "<br><i>Lower is better - index inverted</i>", ""))
)
plot_ly(idx_long, y = ~metric, x = ~index, color = ~player, colors = player_cols,
type = "bar", orientation = "h",
text = ~round(index), textposition = "outside", cliponaxis = FALSE,
textfont = list(color = col_text, size = 12),
hovertext = ~tip, hoverinfo = "text") %>%
style_plot(xtitle = "Index (leader of each metric = 100)",
xaxis_extra = list(range = c(0, 125)),
yaxis_extra = list(automargin = TRUE)) %>%
layout(barmode = "group",
shapes = list(list(type = "line", x0 = 100, x1 = 100, y0 = 0, y1 = 1, yref = "paper",
line = list(color = col_gold, dash = "dot", width = 1))))
} else note("No metrics with values for both players.")
```
Column {data-width=400}
-------------------------------------
### Big-stage numbers
```{r big_stage}
# International and trophy statistics (different scales, so each has its own axis)
big_defs <- tribble(
~label, ~col,
"Champions League goals", "cl_goals",
"World Cup goals", "wc_goals",
"International goals", "intl_goals",
"Ballon d'Or awards", "ballon"
)
big_long <- big_defs %>%
crossing(player = players) %>%
mutate(value = map2_dbl(col, player, sv)) %>%
filter(!is.na(value)) %>%
mutate(
label = factor(label, levels = big_defs$label),
player = factor(player, levels = players),
tip = paste0("<b>", player, "</b><br>", label, ": ", map_chr(value, fmt))
)
if (nrow(big_long) > 0) {
g <- ggplot(big_long, aes(x = player, y = value, fill = player)) +
geom_col(aes(text = tip), width = 0.65) +
geom_text(aes(label = comma(value)), vjust = -0.5, color = col_text, size = 4) +
facet_wrap(~label, scales = "free_y", ncol = 2) +
scale_fill_manual(values = player_cols) +
scale_y_continuous(expand = expansion(mult = c(0, 0.22))) +
theme_dash()
ggplotly(g, tooltip = "text") %>%
layout(paper_bgcolor = "rgba(0,0,0,0)", plot_bgcolor = "rgba(0,0,0,0)") %>%
config(displaylogo = FALSE, modeBarButtonsToRemove = c("lasso2d", "select2d", "autoScale2d"))
} else note("No international or award statistics in the dataset.")
```
Club vs International
=====================================
Column {data-width=500}
-------------------------------------
### Goals: club vs international
```{r split_goals}
# The dataset has career totals only (no seasons or years), so a time trend is not
# possible. This page uses the club/international split instead.
# Club = career total - international (assumes career totals include national-team games).
split_chart <- function(club, intl, ytitle) {
total <- club + intl
plot_ly(x = players) %>%
add_bars(y = club, name = "Club", marker = list(color = col_club),
text = map_chr(club, fmt), textposition = "inside", insidetextanchor = "middle",
textfont = list(color = "#FFFFFF", size = 16), hoverinfo = "text",
hovertext = paste0("<b>", players, "</b><br>Club: ", map_chr(club, fmt),
" (", percent(club / total, accuracy = 0.1), " of total)")) %>%
add_bars(y = intl, name = "International", marker = list(color = col_gold),
text = map_chr(intl, fmt), textposition = "inside", insidetextanchor = "middle",
textfont = list(color = col_bg, size = 16), hoverinfo = "text",
hovertext = paste0("<b>", players, "</b><br>International: ", map_chr(intl, fmt),
" (", percent(intl / total, accuracy = 0.1), " of total)")) %>%
style_plot(ytitle = ytitle) %>%
layout(barmode = "stack")
}
if (split_ok) {
split_chart(S$club_goals, S$intl_goals, "Career goals")
} else note("Club/international split is not available (missing or inconsistent data).")
```
### Assists: club vs international
```{r split_assists}
if (split_ok) {
split_chart(S$club_assists, S$intl_assists, "Career assists")
} else note("Club/international split is not available (missing or inconsistent data).")
```
Column {data-width=500}
-------------------------------------
### Output per match: club vs international
```{r split_rates}
if (split_ok && has("club_g_per_app", "intl_g_per_app", "club_a_per_app", "intl_a_per_app")) {
rate_labels <- c("Goals per match (club)", "Goals per match (international)",
"Assists per match (club)", "Assists per match (international)")
rate_cols <- c("club_g_per_app", "intl_g_per_app", "club_a_per_app", "intl_a_per_app")
rate_split <- tibble(label = rep(rate_labels, each = 2),
player = rep(players, times = 4),
value = unlist(lapply(rate_cols, function(cn) c(sv(cn, "Messi"), sv(cn, "Ronaldo"))))) %>%
mutate(label = factor(label, levels = rate_labels),
player = factor(player, levels = players),
tip = paste0("<b>", player, "</b><br>", label, ": ", map_chr(value, fmt, digits = 2)))
plot_ly(rate_split, x = ~label, y = ~value, color = ~player, colors = player_cols,
type = "bar", text = ~map_chr(value, fmt, digits = 2), textposition = "outside",
cliponaxis = FALSE, textfont = list(color = col_text, size = 14),
hovertext = ~tip, hoverinfo = "text") %>%
style_plot(ytitle = "Per match",
yaxis_extra = list(range = c(0, max(rate_split$value, na.rm = TRUE) * 1.2))) %>%
layout(barmode = "group")
} else note("Per-match club/international rates are not available.")
```
### Reading this page
* **No time series in the data.** The CSV holds career totals only (no seasons or years), so a line chart of goals and assists over time would require data that does not exist. This page shows the club/international split instead.
* **Club = career total minus international.** This assumes the career totals include national-team matches; the code checks that international figures never exceed the totals.
* **Per match** = goals (or assists) divided by appearances in that setting.
Clubs
=====================================
Column {data-width=500}
-------------------------------------
### Messi: club by club
```{r messi_clubs}
club_column("Messi")
```
Column {data-width=500}
-------------------------------------
### Ronaldo: club by club
```{r ronaldo_clubs}
club_column("Ronaldo")
```
Club Stats
=====================================
Column {data-width=500}
-------------------------------------
### Messi: goals and assists by club
```{r messi_club_chart}
club_chart("Messi")
```
### Ronaldo: goals and assists by club
```{r ronaldo_club_chart}
club_chart("Ronaldo")
```
Column {data-width=500}
-------------------------------------
### Scoring rate by club (goals per match)
```{r club_rate}
rate_d <- club_tbl %>%
filter(!is.na(goals)) %>%
mutate(club_f = factor(club, levels = unique(club)),
player = factor(player, levels = players))
plot_ly(rate_d, x = ~club_f, y = ~g_app, color = ~player, colors = player_cols,
type = "bar", text = ~sprintf("%.2f", g_app), textposition = "outside",
cliponaxis = FALSE, textfont = list(color = col_text, size = 14),
hovertext = ~tip, hoverinfo = "text") %>%
style_plot(ytitle = "Goals per match",
yaxis_extra = list(range = c(0, max(rate_d$g_app) * 1.2))) %>%
layout(bargap = 0.25)
```
### Club-by-club details
```{r club_table}
club_dt <- club_tbl %>%
transmute(Player = player, Club = club, Years = years, Apps = apps, Goals = goals,
Assists = assists, `G+A` = ga, `Goals/match` = round(g_app, 2),
`Assists/match` = round(a_app, 2),
`Share of club goals` = ifelse(is.na(share), "n/a", paste0(round(share * 100, 1), "%")),
Honours = gsub(";", " | ", trophies, fixed = TRUE))
datatable(club_dt, rownames = FALSE, class = "compact stripe hover",
options = list(pageLength = 5, scrollX = TRUE, dom = "tp")) %>%
formatStyle("Player", color = styleEqual(c("Messi", "Ronaldo"), unname(player_cols)),
fontWeight = "bold")
```
Pitch Heatmap
=====================================
Column {data-width=500}
-------------------------------------
### Messi: where he operates on the pitch
```{r messi_pitch}
pitch_panel("Messi")
```
Column {data-width=500}
-------------------------------------
### Ronaldo: where he operates on the pitch
```{r ronaldo_pitch}
pitch_panel("Ronaldo")
```
Efficiency
=====================================
Column {data-width=600}
-------------------------------------
### Scoring and creative efficiency (per match and per 90 minutes)
```{r efficiency_rates}
# Section F: rate statistics, recalculated from goals, assists, appearances and minutes
rate_defs <- tribble(
~label, ~col, ~how,
"Goals / match", "g_per_app", "goals / appearances",
"Goals / 90", "g_per90", "goals / minutes x 90",
"Assists / match", "a_per_app", "assists / appearances",
"Assists / 90", "a_per90", "assists / minutes x 90",
"G+A / match", "ga_per_app", "(goals + assists) / appearances",
"G+A / 90", "ga_per90", "(goals + assists) / minutes x 90"
)
rate_long <- rate_defs %>%
crossing(player = players) %>%
mutate(value = map2_dbl(col, player, sv)) %>%
filter(!is.na(value)) %>%
mutate(label = factor(label, levels = rate_defs$label),
player = factor(player, levels = players),
tip = paste0("<b>", player, "</b><br>", label, ": ", map_chr(value, fmt, digits = 2),
"<br><i>", how, "</i>"))
if (nrow(rate_long) > 0) {
plot_ly(rate_long, x = ~label, y = ~value, color = ~player, colors = player_cols,
type = "bar", text = ~map_chr(value, fmt, digits = 2), textposition = "outside",
cliponaxis = FALSE, textfont = list(color = col_text, size = 15),
hovertext = ~tip, hoverinfo = "text") %>%
style_plot(ytitle = "Rate",
yaxis_extra = list(range = c(0, max(rate_long$value) * 1.18))) %>%
layout(barmode = "group")
} else note("Not enough data (goals, assists, appearances, minutes) to calculate efficiency.")
```
Column {data-width=400}
-------------------------------------
### Minutes needed per goal contribution (lower is better)
```{r efficiency_minutes}
min_defs <- tribble(
~label, ~col, ~how,
"Minutes per goal", "min_per_goal", "minutes played / goals",
"Minutes per assist", "min_per_assist", "minutes played / assists",
"Minutes per G+A", "min_per_ga", "minutes played / (goals + assists)"
)
min_long <- min_defs %>%
crossing(player = players) %>%
mutate(value = map2_dbl(col, player, sv)) %>%
filter(!is.na(value)) %>%
mutate(label = factor(label, levels = min_defs$label),
player = factor(player, levels = players),
tip = paste0("<b>", player, "</b><br>", label, ": ", map_chr(value, fmt, digits = 1),
"<br><i>", how, "</i>"))
if (nrow(min_long) > 0) {
plot_ly(min_long, x = ~label, y = ~value, color = ~player, colors = player_cols,
type = "bar", text = ~map_chr(value, fmt, digits = 1), textposition = "outside",
cliponaxis = FALSE, textfont = list(color = col_text, size = 14),
hovertext = ~tip, hoverinfo = "text") %>%
style_plot(ytitle = "Minutes",
yaxis_extra = list(range = c(0, max(min_long$value) * 1.18))) %>%
layout(barmode = "group")
} else note("Minutes played are not available.")
```
### How to read these metrics
* **Per match** divides output by appearances. It rewards consistency but treats a 10-minute cameo like a full game.
* **Per 90** divides by minutes played and multiplies by 90, so it adjusts for different amounts of playing time.
* **Minutes per goal / assist / G+A** is the time needed for one contribution. **Lower is better.**
* **Data check:** `r validation_msg`
Data
=====================================
Column {data-width=620}
-------------------------------------
### Detailed statistics
```{r detail_table}
# Section G: interactive table. Display columns are formatted text; hidden numeric
# columns keep sorting numeric (via orderData).
stats_table <- dat %>%
mutate(
diff = Messi - Ronaldo,
leader = case_when(
is.na(Messi) | is.na(Ronaldo) ~ "n/a",
Messi == Ronaldo ~ "Tie",
lower_better(metric) ~ if_else(Messi < Ronaldo, "Messi", "Ronaldo"),
TRUE ~ if_else(Messi > Ronaldo, "Messi", "Ronaldo")
),
Messi_txt = map2_chr(Messi, digits, fmt),
Ronaldo_txt = map2_chr(Ronaldo, digits, fmt),
diff_txt = map2_chr(diff, digits, function(x, d) {
if (is.na(x)) "n/a" else paste0(if (x > 0) "+" else "", fmt(x, d))
})
)
dt_display <- stats_table %>%
transmute(
Metric = metric, Unit = unit,
Messi = Messi_txt, Ronaldo = Ronaldo_txt,
`Difference (Messi - Ronaldo)` = diff_txt,
Leader = leader,
Messi_num = Messi, Ronaldo_num = Ronaldo, Diff_num = diff
)
datatable(
dt_display,
rownames = FALSE,
class = "compact stripe hover",
options = list(
pageLength = 10,
lengthMenu = c(5, 10, 15),
order = list(),
columnDefs = list(
list(orderData = 6, targets = 2),
list(orderData = 7, targets = 3),
list(orderData = 8, targets = 4),
list(visible = FALSE, targets = 6:8),
list(className = "dt-right", targets = 2:4),
list(className = "dt-center", targets = 5)
)
)
) %>%
formatStyle("Messi", color = player_cols[["Messi"]], fontWeight = "bold") %>%
formatStyle("Ronaldo", color = player_cols[["Ronaldo"]], fontWeight = "bold") %>%
formatStyle("Leader",
color = styleEqual(c("Messi", "Ronaldo", "Tie", "n/a"),
c(player_cols[["Messi"]], player_cols[["Ronaldo"]], col_gold, col_muted)),
fontWeight = "bold")
```
Column {data-width=380}
-------------------------------------
### Validation of pre-computed statistics
```{r validation_table}
# Reported value in the CSV vs value recalculated from raw totals
val_display <- validation %>%
transmute(Player = player, Metric = metric, Reported, Recomputed, Status = status)
datatable(
val_display,
rownames = FALSE,
class = "compact stripe",
options = list(dom = "t", paging = FALSE, ordering = FALSE)
) %>%
formatStyle("Status",
color = styleEqual(c("Match", "Mismatch", "Not checked"),
c("#4CAF50", "#FF4D5A", col_muted)),
fontWeight = "bold")
```
### About the data
```{r about_data, results='asis'}
photo_msg <- paste(vapply(players, function(p) paste0(
full_name[[p]], ": ", if (is.na(photo_src[[p]])) "no photo found (avatar shown)" else "photo found"),
character(1)), collapse = "; ")
cat(
"* **File:** `", data_file, "`\n",
"* **Format:** wide - one row per statistic, one column per player (", nrow(dat), " statistics).\n",
"* **Scope:** career totals only; there are no seasons, years or competitions by season.\n",
"* **Clubs pages:** club-by-club figures are typed into `club_tbl` in the setup chunk (not in the CSV); they are senior-team, all-competition numbers and approximate for current clubs (Inter Miami, Al Nassr). Edit that table to update them.\n",
"* **Recalculated:** per-match, per-90 and minutes-per metrics are derived from the raw totals.\n",
"* **Validation:** ", validation_msg, "\n",
"* **Photos:** ", photo_msg, ". To add photos, save `images/messi.jpg` and `images/ronaldo.jpg` next to this file and knit again.\n",
sep = "")
```