Download app.R from cpflecht/oldham2: direct link, hf CLI and curl.
- Browser
- Download file 41.8 kB
-
https://huggingface.co/spaces/cpflecht/oldham2/resolve/main/app.R
- Command line
-
hf download hf://spaces/cpflecht/oldham2/app.R
-
curl -L -o app.R https://huggingface.co/spaces/cpflecht/oldham2/resolve/main/app.R
41.8 kB
| library(shiny) | |
| library(shinythemes) | |
| library(DT) | |
| library(dplyr) | |
| library(tidyr) | |
| library(readr) | |
| library(plotly) | |
| library(stringr) | |
| library(scales) | |
| # ============================================================ | |
| # COLUMN CONSTANTS | |
| # ============================================================ | |
| PLAYER_COL <- "player_name" | |
| TEAM_COL <- "team_name" | |
| COMP_COL <- "competition_name" | |
| POSITION_COL <- "primary_position" | |
| SECONDARY_POSITION_COL <- "secondary_position" | |
| COUNTRY_COL <- "country_id" | |
| AGE_COL <- "age" | |
| HEIGHT_COL <- "player_height" | |
| WEIGHT_COL <- "player_weight" | |
| MINUTES_COL <- "player_season_minutes" | |
| MARKET_VALUE_COL <- "market_value_eur" | |
| CONTRACT_COL <- "seasons_left_num" | |
| ATTAINABILITY_COL <- "attainability" | |
| TARGET_SCORE_COL <- "target_score" | |
| ARCHETYPE_COL <- "best_position_archetype_name" | |
| ARCHETYPE_SCORE_COL <- "best_position_archetype_score" | |
| CLUB_RANK_COL <- "club_rank" | |
| MATCH_TOUGHNESS_COL <- "match_toughness" | |
| ELO_COL <- "elo" | |
| ATTR_COLS <- c( | |
| "attr_shot_stopping", "attr_sweeping", "attr_ball_claiming", | |
| "attr_short_passing", "attr_long_passing", "attr_pressing", | |
| "attr_duels", "attr_aerial", "attr_possession_retention", | |
| "attr_blocking", "attr_progression", "attr_set_pieces", | |
| "attr_impact", "attr_discipline", "attr_dribbling", | |
| "attr_chance_creation", "attr_finishing", "attr_crossing", | |
| "attr_box_presence", "attr_holdup" | |
| ) | |
| POSITION_SCORE_COLS <- c( | |
| "cb_score", "fb_score", "cmd_score", "cma_score", | |
| "wm_score", "cf_score", "st_score", "gk_score" | |
| ) | |
| ARCHETYPE_SCORE_COLS <- c( | |
| "score_defensive_cb", "score_pressing_cb", "score_ballplaying_cb", | |
| "score_defensive_fb", "score_attacking_fb", "score_possession_fb", | |
| "score_poacher", "score_target_man", "score_false_nine", | |
| "score_complete_forward", "score_inside_forward", | |
| "score_traditional_winger", "score_playmaking_winger", | |
| "score_pressing_winger", "score_complete_winger", | |
| "score_defensive_midfielder", "score_deep_lying_playmaker", | |
| "score_box_to_box_midfielder", "score_advanced_playmaker", | |
| "score_wide_midfielder", "score_attacking_runner", | |
| "score_shot_stopper_gk", "score_sweeper_keeper_gk", | |
| "score_ball_playing_gk" | |
| ) | |
| KEY_METRICS <- c( | |
| "player_season_minutes", "player_season_goals_90", | |
| "player_season_assists_90", "player_season_np_xg_90", | |
| "player_season_xa_90", "player_season_key_passes_90", | |
| "player_season_passing_ratio", "player_season_tackles_90", | |
| "player_season_interceptions_90", | |
| "player_season_tackles_and_interceptions_90", | |
| "player_season_aerial_wins_90", "player_season_aerial_ratio", | |
| "player_season_dribbles_90", "player_season_crosses_90", | |
| "player_season_long_balls_90", "player_season_xgchain_90", | |
| "player_season_xgbuildup_90", "player_season_obv_90" | |
| ) | |
| SEARCH_TABLE_COLS <- c( | |
| PLAYER_COL, POSITION_COL, TEAM_COL, COMP_COL, AGE_COL, | |
| COUNTRY_COL, MINUTES_COL, MARKET_VALUE_COL, CONTRACT_COL, | |
| ARCHETYPE_COL, ARCHETYPE_SCORE_COL, TARGET_SCORE_COL, | |
| ATTAINABILITY_COL, POSITION_SCORE_COLS, ATTR_COLS | |
| ) | |
| COMPARISON_COLS <- c( | |
| PLAYER_COL, POSITION_COL, TEAM_COL, COMP_COL, AGE_COL, | |
| MARKET_VALUE_COL, CONTRACT_COL, ARCHETYPE_COL, | |
| ARCHETYPE_SCORE_COL, TARGET_SCORE_COL, ATTAINABILITY_COL, | |
| POSITION_SCORE_COLS, ATTR_COLS | |
| ) | |
| SHORTLIST_COLS <- c( | |
| PLAYER_COL, POSITION_COL, TEAM_COL, COMP_COL, AGE_COL, | |
| MINUTES_COL, MARKET_VALUE_COL, CONTRACT_COL, ARCHETYPE_COL, | |
| ARCHETYPE_SCORE_COL, TARGET_SCORE_COL, ATTAINABILITY_COL, | |
| KEY_METRICS, ATTR_COLS, POSITION_SCORE_COLS, ARCHETYPE_SCORE_COLS | |
| ) | |
| PERFORMANCE_TIME_METRICS <- POSITION_SCORE_COLS | |
| HISTORICAL_SEASONS <- c( | |
| "2122" = "2021-22", "2223" = "2022-23", | |
| "2324" = "2023-24", "2425" = "2024-25" | |
| ) | |
| CURRENT_MAIN_SEASON_LABEL <- "2025-26" | |
| # ============================================================ | |
| # HELPERS | |
| # ============================================================ | |
| clean_colnames <- function(df) { | |
| n <- names(df) | |
| n <- trimws(n) | |
| n <- tolower(n) | |
| n <- gsub("[ \\-/]", "_", n) | |
| n <- gsub("\\.", "_", n) | |
| names(df) <- n | |
| df | |
| } | |
| clean_player_key <- function(x) { | |
| x <- trimws(x) | |
| x <- tolower(x) | |
| x <- gsub("\\.", "", x) | |
| x <- gsub(",", "", x) | |
| x <- gsub("-", " ", x) | |
| x <- gsub(" ", " ", x) | |
| x | |
| } | |
| format_money <- function(x) { | |
| x <- suppressWarnings(as.numeric(x)) | |
| result <- character(length(x)) | |
| for (i in seq_along(x)) { | |
| if (is.na(x[i])) { | |
| result[i] <- "Not listed" | |
| } else if (x[i] >= 1e6) { | |
| result[i] <- paste0("EUR ", round(x[i] / 1e6, 1), "M") | |
| } else if (x[i] >= 1e3) { | |
| result[i] <- paste0("EUR ", round(x[i] / 1e3, 0), "K") | |
| } else { | |
| result[i] <- paste0("EUR ", round(x[i], 0)) | |
| } | |
| } | |
| result | |
| } | |
| clean_value <- function(x) { | |
| if (is.null(x) || length(x) == 0) return("N/A") | |
| if (length(x) == 1 && is.na(x)) return("N/A") | |
| if (is.numeric(x)) return(as.character(round(x, 2))) | |
| as.character(x) | |
| } | |
| pretty_label <- function(col) { | |
| custom <- list( | |
| player_name = "Player", team_name = "Club", | |
| competition_name = "Competition", season_name = "Season", | |
| primary_position = "Primary Position", | |
| secondary_position = "Secondary Position", | |
| country_id = "Country", player_height = "Height", | |
| player_weight = "Weight", player_season_minutes = "Minutes", | |
| market_value_eur = "Market Value", | |
| seasons_left_num = "Seasons Left", | |
| attainability = "Attainability", target_score = "Target Score", | |
| best_position_archetype_name = "Best Archetype", | |
| best_position_archetype_score = "Best Archetype Score", | |
| cb_score = "CB Score", fb_score = "FB Score", | |
| cmd_score = "CMD Score", cma_score = "CMA Score", | |
| wm_score = "WM Score", cf_score = "CF Score", | |
| st_score = "ST Score", gk_score = "GK Score", | |
| club_rank = "Club Rank", match_toughness = "Match Toughness", | |
| elo = "Club ELO", competition_rank = "Competition Rank", | |
| fit_score = "Fit Score", similarity_score = "Similarity Score" | |
| ) | |
| if (col %in% names(custom)) return(custom[[col]]) | |
| lbl <- col | |
| lbl <- gsub("player_season_", "", lbl) | |
| lbl <- gsub("attr_", "", lbl) | |
| lbl <- gsub("cat_", "", lbl) | |
| lbl <- gsub("score_", "", lbl) | |
| lbl <- gsub("_90$", " Per 90", lbl) | |
| lbl <- gsub("_", " ", lbl) | |
| lbl <- tools::toTitleCase(lbl) | |
| lbl | |
| } | |
| available_cols <- function(cols, df) { | |
| cols[cols %in% names(df)] | |
| } | |
| normalize_0_100 <- function(x) { | |
| x <- suppressWarnings(as.numeric(x)) | |
| mn <- min(x, na.rm = TRUE) | |
| mx <- max(x, na.rm = TRUE) | |
| if (is.na(mn) || is.na(mx) || mn == mx) return(rep(0, length(x))) | |
| (x - mn) / (mx - mn) * 100 | |
| } | |
| pretty_df <- function(data) { | |
| out <- data | |
| if (MARKET_VALUE_COL %in% names(out)) { | |
| out[[MARKET_VALUE_COL]] <- format_money(out[[MARKET_VALUE_COL]]) | |
| } | |
| num_cols <- names(out)[sapply(out, is.numeric)] | |
| for (col in num_cols) { | |
| out[[col]] <- round(out[[col]], 2) | |
| } | |
| new_names <- sapply(names(out), pretty_label) | |
| names(out) <- new_names | |
| out | |
| } | |
| # ============================================================ | |
| # LOAD DATA | |
| # ============================================================ | |
| load_data <- function() { | |
| df <- tryCatch( | |
| read_csv("OA_sheet_for_app.csv", | |
| locale = locale(encoding = "latin1"), | |
| show_col_types = FALSE), | |
| error = function(e) data.frame() | |
| ) | |
| df <- clean_colnames(df) | |
| multi_df <- data.frame() | |
| if (file.exists("all_players_enriched_multiseason.csv")) { | |
| multi_df <- tryCatch( | |
| read_csv("all_players_enriched_multiseason.csv", | |
| locale = locale(encoding = "latin1"), | |
| show_col_types = FALSE), | |
| error = function(e) data.frame() | |
| ) | |
| multi_df <- clean_colnames(multi_df) | |
| } | |
| if (PLAYER_COL %in% names(df)) { | |
| df[["_player_key"]] <- clean_player_key(df[[PLAYER_COL]]) | |
| } | |
| if (nrow(multi_df) > 0) { | |
| multi_player_col <- NULL | |
| for (pc in c("player_name", "player", "name")) { | |
| if (pc %in% names(multi_df)) { | |
| multi_player_col <- pc | |
| break | |
| } | |
| } | |
| if (!is.null(multi_player_col)) { | |
| multi_df[["_player_key"]] <- clean_player_key(multi_df[[multi_player_col]]) | |
| } else { | |
| multi_df[["_player_key"]] <- "" | |
| } | |
| hist_suffixes <- c("_2122", "_2223", "_2324", "_2425") | |
| hist_cols <- names(multi_df)[sapply(names(multi_df), function(cn) { | |
| any(endsWith(cn, hist_suffixes)) | |
| })] | |
| if (length(hist_cols) > 0 && "_player_key" %in% names(multi_df)) { | |
| multi_keep <- multi_df[, c("_player_key", hist_cols), drop = FALSE] | |
| multi_keep <- multi_keep[!duplicated(multi_keep[["_player_key"]]), ] | |
| overlap <- hist_cols[hist_cols %in% names(df)] | |
| if (length(overlap) > 0) { | |
| df <- df[, !names(df) %in% overlap, drop = FALSE] | |
| } | |
| df <- merge(df, multi_keep, by = "_player_key", all.x = TRUE) | |
| } | |
| } | |
| # Coerce numeric columns | |
| all_num_cols <- unique(c( | |
| KEY_METRICS, ATTR_COLS, POSITION_SCORE_COLS, ARCHETYPE_SCORE_COLS, | |
| AGE_COL, HEIGHT_COL, WEIGHT_COL, MINUTES_COL, MARKET_VALUE_COL, | |
| ATTAINABILITY_COL, TARGET_SCORE_COL, ARCHETYPE_SCORE_COL, | |
| CLUB_RANK_COL, MATCH_TOUGHNESS_COL, ELO_COL | |
| )) | |
| all_num_cols <- available_cols(all_num_cols, df) | |
| hist_num_cols <- names(df)[sapply(names(df), function(cn) { | |
| any(endsWith(cn, paste0("_", names(HISTORICAL_SEASONS)))) | |
| })] | |
| for (col in unique(c(all_num_cols, hist_num_cols))) { | |
| df[[col]] <- suppressWarnings(as.numeric(df[[col]])) | |
| } | |
| list(df = df, multi_df = multi_df) | |
| } | |
| # ============================================================ | |
| # PLAYER HELPERS | |
| # ============================================================ | |
| get_player_row <- function(df, player) { | |
| if (is.null(player) || nchar(trimws(player)) == 0) return(NULL) | |
| if (!PLAYER_COL %in% names(df)) return(NULL) | |
| rows <- df[as.character(df[[PLAYER_COL]]) == as.character(player), ] | |
| if (nrow(rows) == 0) return(NULL) | |
| as.list(rows[1, ]) | |
| } | |
| get_player_group <- function(df, row) { | |
| comp <- row[[COMP_COL]] | |
| pos <- row[[POSITION_COL]] | |
| group <- df | |
| if (COMP_COL %in% names(df) && POSITION_COL %in% names(df) && | |
| !is.null(comp) && !is.na(comp) && | |
| !is.null(pos) && !is.na(pos)) { | |
| sub <- df[df[[COMP_COL]] == comp & df[[POSITION_COL]] == pos, ] | |
| if (nrow(sub) > 0) group <- sub | |
| } | |
| group | |
| } | |
| top_attr_cols <- function(df, row = NULL, max_cols = 8) { | |
| cols <- available_cols(ATTR_COLS, df) | |
| if (!is.null(row)) { | |
| cols <- cols[sapply(cols, function(cn) { | |
| v <- row[[cn]] | |
| !is.null(v) && length(v) > 0 && !is.na(v) | |
| })] | |
| } | |
| head(cols, max_cols) | |
| } | |
| # ============================================================ | |
| # PERFORMANCE OVER TIME | |
| # ============================================================ | |
| historical_candidate_columns <- function(base_metric, season_code) { | |
| short_metric <- gsub("player_season_", "", base_metric) | |
| candidates <- c( | |
| paste0(base_metric, "_", season_code), | |
| paste0(short_metric, "_", season_code) | |
| ) | |
| if (endsWith(short_metric, "_90")) { | |
| no_90 <- sub("_90$", "", short_metric) | |
| candidates <- c(candidates, | |
| paste0(no_90, "_90_", season_code), | |
| paste0(no_90, "_per_90_", season_code), | |
| paste0(no_90, "_p90_", season_code) | |
| ) | |
| } | |
| if (base_metric %in% POSITION_SCORE_COLS) { | |
| pos_code <- sub("_score", "", base_metric) | |
| candidates <- c(candidates, | |
| paste0(pos_code, "_score_", season_code), | |
| paste0(pos_code, "_", season_code) | |
| ) | |
| } | |
| unique(tolower(candidates)) | |
| } | |
| find_metric_value <- function(row, base_metric, season_code = NULL) { | |
| if (is.null(row)) return(NA_real_) | |
| if (is.null(season_code)) { | |
| v <- row[[base_metric]] | |
| if (is.null(v)) return(NA_real_) | |
| return(suppressWarnings(as.numeric(v))) | |
| } | |
| for (col in historical_candidate_columns(base_metric, season_code)) { | |
| v <- row[[col]] | |
| if (!is.null(v) && length(v) > 0 && !is.na(v)) { | |
| return(suppressWarnings(as.numeric(v))) | |
| } | |
| } | |
| NA_real_ | |
| } | |
| get_multiseason_row <- function(multi_df, player) { | |
| if (is.null(multi_df) || nrow(multi_df) == 0) return(NULL) | |
| pk <- clean_player_key(player) | |
| if (!"_player_key" %in% names(multi_df)) return(NULL) | |
| matches <- multi_df[multi_df[["_player_key"]] == pk, ] | |
| if (nrow(matches) == 0) return(NULL) | |
| as.list(matches[1, ]) | |
| } | |
| build_performance_metric_options <- function(df, multi_df) { | |
| options <- c() | |
| for (m in PERFORMANCE_TIME_METRICS) { | |
| current_exists <- m %in% names(df) | |
| hist_exists <- FALSE | |
| for (sc in names(HISTORICAL_SEASONS)) { | |
| for (cand in historical_candidate_columns(m, sc)) { | |
| if (cand %in% names(df)) { | |
| hist_exists <- TRUE | |
| break | |
| } | |
| if (!is.null(multi_df) && nrow(multi_df) > 0 && cand %in% names(multi_df)) { | |
| hist_exists <- TRUE | |
| break | |
| } | |
| } | |
| if (hist_exists) break | |
| } | |
| if (current_exists || hist_exists) { | |
| options[pretty_label(m)] <- m | |
| } | |
| } | |
| options | |
| } | |
| # ============================================================ | |
| # UI | |
| # ============================================================ | |
| ui <- fluidPage( | |
| theme = shinytheme("flatly"), | |
| tags$head(tags$style(HTML( | |
| ".container-fluid { max-width: 98%; } | |
| table.dataTable { width: 100% !important; } | |
| .dataTables_wrapper { overflow-x: auto; } | |
| th, td { white-space: nowrap; }" | |
| ))), | |
| titlePanel("Oldham Athletic Player Scouting"), | |
| tabsetPanel(id = "main_tabs", | |
| tabPanel("Player Search", | |
| br(), | |
| h4("Search and Filter Players"), | |
| fluidRow( | |
| column(4, textInput("search_box", "Search Player Name", "")), | |
| column(4, selectizeInput("competition_filter", "Competition", | |
| choices = NULL, multiple = TRUE)), | |
| column(4, selectizeInput("team_filter", "Team", | |
| choices = NULL, multiple = TRUE)) | |
| ), | |
| fluidRow( | |
| column(4, selectizeInput("position_filter", "Position", | |
| choices = NULL, multiple = TRUE)), | |
| column(4, selectizeInput("country_filter", "Country", | |
| choices = NULL, multiple = TRUE)) | |
| ), | |
| fluidRow( | |
| column(4, uiOutput("age_slider_ui")), | |
| column(4, uiOutput("minutes_slider_ui")) | |
| ), | |
| br(), | |
| actionButton("search_btn", "Search Players", class = "btn-primary"), | |
| br(), br(), | |
| DTOutput("search_results"), | |
| verbatimTextOutput("search_status") | |
| ), | |
| tabPanel("Player Profile", | |
| br(), | |
| h4("Full Player Profile"), | |
| selectizeInput("selected_player", "Select Player", | |
| choices = NULL, width = "100%"), | |
| fluidRow( | |
| column(8, uiOutput("profile_output")), | |
| column(4, h5("Key Performance Summary"), DTOutput("key_summary")) | |
| ), | |
| br(), | |
| h4("Player Metrics"), | |
| selectInput("metric_group", "Metric Group", | |
| choices = c("Attributes", "Position Scores", | |
| "Archetype Scores", "Key Season Stats"), | |
| selected = "Attributes"), | |
| DTOutput("metric_table"), | |
| br(), | |
| fluidRow( | |
| column(6, plotlyOutput("radar_plot", height = "500px")), | |
| column(6, plotlyOutput("percentile_plot", height = "500px")) | |
| ), | |
| br(), | |
| fluidRow( | |
| column(6, selectInput("profile_metric", "Performance Metric Over Time", | |
| choices = NULL)), | |
| column(6, br(), actionButton("trend_btn", "Show Performance Chart", | |
| class = "btn-info")) | |
| ), | |
| plotlyOutput("trend_plot"), | |
| br(), | |
| textAreaInput("scout_notes", "Scout Notes", rows = 4, | |
| placeholder = "Enter notes to include in the scouting report."), | |
| fluidRow( | |
| column(4, downloadButton("report_btn", "Download Scouting Report (CSV)")), | |
| column(4, actionButton("shortlist_btn", "Add to Shortlist", | |
| class = "btn-success")) | |
| ), | |
| br(), | |
| DTOutput("shortlist_from_profile") | |
| ), | |
| tabPanel("Player Comparison Tool", | |
| br(), | |
| h4("Compare Up To Three Players"), | |
| fluidRow( | |
| column(4, selectizeInput("compare_1", "Player 1", choices = NULL)), | |
| column(4, selectizeInput("compare_2", "Player 2", choices = NULL)), | |
| column(4, selectizeInput("compare_3", "Player 3", choices = NULL)) | |
| ), | |
| actionButton("compare_btn", "Compare Players", class = "btn-primary"), | |
| br(), br(), | |
| DTOutput("comparison_table"), | |
| br(), | |
| plotlyOutput("comparison_radar", height = "550px") | |
| ), | |
| tabPanel("Fit Score Calculator", | |
| br(), | |
| h4("Fit Score Calculator"), | |
| fluidRow( | |
| column(6, selectizeInput("fit_competition_filter", | |
| "Competitions to Search", choices = NULL, multiple = TRUE)), | |
| column(6, selectizeInput("fit_position_filter", | |
| "Positions to Search", choices = NULL, multiple = TRUE)) | |
| ), | |
| fluidRow( | |
| column(4, sliderInput("pressing_w", "Pressing", 0, 10, 5, step = 1)), | |
| column(4, sliderInput("duels_w", "Duels", 0, 10, 5, step = 1)), | |
| column(4, sliderInput("aerial_w", "Aerial", 0, 10, 4, step = 1)) | |
| ), | |
| fluidRow( | |
| column(4, sliderInput("possession_w", "Possession Retention", | |
| 0, 10, 5, step = 1)), | |
| column(4, sliderInput("blocking_w", "Blocking", 0, 10, 4, step = 1)), | |
| column(4, sliderInput("progression_w", "Progression", | |
| 0, 10, 6, step = 1)) | |
| ), | |
| fluidRow( | |
| column(4, sliderInput("impact_w", "Impact", 0, 10, 6, step = 1)), | |
| column(4, sliderInput("discipline_w", "Discipline", | |
| 0, 10, 3, step = 1)), | |
| column(4, sliderInput("dribbling_w", "Dribbling", 0, 10, 4, step = 1)) | |
| ), | |
| fluidRow( | |
| column(4, sliderInput("chance_w", "Chance Creation", | |
| 0, 10, 5, step = 1)), | |
| column(4, sliderInput("finishing_w", "Finishing", 0, 10, 3, step = 1)), | |
| column(4, sliderInput("crossing_w", "Crossing", 0, 10, 3, step = 1)) | |
| ), | |
| fluidRow( | |
| column(4, sliderInput("box_w", "Box Presence", 0, 10, 3, step = 1)), | |
| column(4, sliderInput("holdup_w", "Holdup", 0, 10, 3, step = 1)), | |
| column(4, sliderInput("target_w", "Target Score", 0, 10, 7, step = 1)) | |
| ), | |
| fluidRow( | |
| column(4, sliderInput("attain_w", "Attainability", 0, 10, 6, step = 1)) | |
| ), | |
| actionButton("fit_btn", "Generate Ranked Recommendations", | |
| class = "btn-primary"), | |
| br(), br(), | |
| DTOutput("fit_table") | |
| ), | |
| tabPanel("Similar Player Finder", | |
| br(), | |
| h4("Find Similar Players"), | |
| selectizeInput("similar_player_select", "Select Player", | |
| choices = NULL, width = "60%"), | |
| actionButton("similar_btn", "Find Similar Players", | |
| class = "btn-primary"), | |
| br(), br(), | |
| DTOutput("similar_table") | |
| ), | |
| tabPanel("Shortlist Manager", | |
| br(), | |
| h4("Shortlist Manager"), | |
| fluidRow( | |
| column(4, selectizeInput("shortlist_player", "Add Player", | |
| choices = NULL)), | |
| column(2, br(), actionButton("add_shortlist_btn", "Add to Shortlist", | |
| class = "btn-success")), | |
| column(2, br(), actionButton("clear_shortlist_btn", "Clear Shortlist", | |
| class = "btn-danger")), | |
| column(2, br(), downloadButton("export_shortlist_btn", "Export CSV")) | |
| ), | |
| br(), | |
| DTOutput("shortlist_table") | |
| ) | |
| ) | |
| ) | |
| # ============================================================ | |
| # SERVER | |
| # ============================================================ | |
| server <- function(input, output, session) { | |
| app_data <- tryCatch(load_data(), error = function(e) { | |
| showNotification(paste("Error loading data:", e$message), | |
| type = "error", duration = NULL) | |
| list(df = data.frame(), multi_df = data.frame()) | |
| }) | |
| df <- app_data$df | |
| multi_df <- app_data$multi_df | |
| shortlist <- reactiveVal(character(0)) | |
| observe({ | |
| req(nrow(df) > 0) | |
| comp_opts <- if (COMP_COL %in% names(df)) | |
| sort(unique(na.omit(as.character(df[[COMP_COL]])))) else character(0) | |
| team_opts <- if (TEAM_COL %in% names(df)) | |
| sort(unique(na.omit(as.character(df[[TEAM_COL]])))) else character(0) | |
| pos_opts <- if (POSITION_COL %in% names(df)) | |
| sort(unique(na.omit(as.character(df[[POSITION_COL]])))) else character(0) | |
| country_opts <- if (COUNTRY_COL %in% names(df)) | |
| sort(unique(na.omit(as.character(df[[COUNTRY_COL]])))) else character(0) | |
| base_cols <- available_cols(c(PLAYER_COL, POSITION_COL, TEAM_COL), df) | |
| player_rows <- unique(df[, base_cols, drop = FALSE]) | |
| pnames <- as.character(player_rows[[PLAYER_COL]]) | |
| ppos <- if (POSITION_COL %in% names(player_rows)) | |
| as.character(player_rows[[POSITION_COL]]) else rep("", nrow(player_rows)) | |
| pteam <- if (TEAM_COL %in% names(player_rows)) | |
| as.character(player_rows[[TEAM_COL]]) else rep("", nrow(player_rows)) | |
| labels <- paste0(pnames, " | ", ppos, " | ", pteam) | |
| player_choices <- setNames(pnames, labels) | |
| player_choices <- player_choices[order(names(player_choices))] | |
| perf_opts <- build_performance_metric_options(df, multi_df) | |
| updateSelectizeInput(session, "competition_filter", | |
| choices = comp_opts, server = TRUE) | |
| updateSelectizeInput(session, "team_filter", | |
| choices = team_opts, server = TRUE) | |
| updateSelectizeInput(session, "position_filter", | |
| choices = pos_opts, server = TRUE) | |
| updateSelectizeInput(session, "country_filter", | |
| choices = country_opts, server = TRUE) | |
| updateSelectizeInput(session, "selected_player", | |
| choices = player_choices, server = TRUE) | |
| updateSelectizeInput(session, "compare_1", | |
| choices = c("" = "", player_choices), server = TRUE) | |
| updateSelectizeInput(session, "compare_2", | |
| choices = c("" = "", player_choices), server = TRUE) | |
| updateSelectizeInput(session, "compare_3", | |
| choices = c("" = "", player_choices), server = TRUE) | |
| updateSelectizeInput(session, "fit_competition_filter", | |
| choices = comp_opts, server = TRUE) | |
| updateSelectizeInput(session, "fit_position_filter", | |
| choices = pos_opts, server = TRUE) | |
| updateSelectizeInput(session, "similar_player_select", | |
| choices = player_choices, server = TRUE) | |
| updateSelectizeInput(session, "shortlist_player", | |
| choices = player_choices, server = TRUE) | |
| updateSelectInput(session, "profile_metric", choices = perf_opts) | |
| }) | |
| output$age_slider_ui <- renderUI({ | |
| age_min <- if (AGE_COL %in% names(df) && any(!is.na(df[[AGE_COL]]))) | |
| floor(min(df[[AGE_COL]], na.rm = TRUE)) else 15 | |
| age_max <- if (AGE_COL %in% names(df) && any(!is.na(df[[AGE_COL]]))) | |
| ceiling(max(df[[AGE_COL]], na.rm = TRUE)) else 45 | |
| tagList( | |
| sliderInput("min_age_filter", "Minimum Age", | |
| age_min, age_max, age_min, step = 1), | |
| sliderInput("max_age_filter", "Maximum Age", | |
| age_min, age_max, age_max, step = 1) | |
| ) | |
| }) | |
| output$minutes_slider_ui <- renderUI({ | |
| mx <- if (MINUTES_COL %in% names(df) && any(!is.na(df[[MINUTES_COL]]))) | |
| ceiling(max(df[[MINUTES_COL]], na.rm = TRUE)) else 5000 | |
| sliderInput("minutes_filter", "Minimum Minutes", 0, mx, 0, step = 100) | |
| }) | |
| # ---- SEARCH ---- | |
| search_result_df <- eventReactive(input$search_btn, { | |
| data <- df | |
| search_term <- input$search_box | |
| if (!is.null(search_term) && nchar(trimws(search_term)) > 0 && | |
| PLAYER_COL %in% names(data)) { | |
| data <- data[grepl(search_term, as.character(data[[PLAYER_COL]]), | |
| ignore.case = TRUE), ] | |
| } | |
| if (length(input$competition_filter) > 0 && COMP_COL %in% names(data)) { | |
| data <- data[data[[COMP_COL]] %in% input$competition_filter, ] | |
| } | |
| if (length(input$team_filter) > 0 && TEAM_COL %in% names(data)) { | |
| data <- data[data[[TEAM_COL]] %in% input$team_filter, ] | |
| } | |
| if (length(input$position_filter) > 0 && POSITION_COL %in% names(data)) { | |
| data <- data[data[[POSITION_COL]] %in% input$position_filter, ] | |
| } | |
| if (length(input$country_filter) > 0 && COUNTRY_COL %in% names(data)) { | |
| data <- data[data[[COUNTRY_COL]] %in% input$country_filter, ] | |
| } | |
| min_age <- if (!is.null(input$min_age_filter)) input$min_age_filter else -Inf | |
| max_age <- if (!is.null(input$max_age_filter)) input$max_age_filter else Inf | |
| if (AGE_COL %in% names(data)) { | |
| data <- data[!is.na(data[[AGE_COL]]) & | |
| data[[AGE_COL]] >= min_age & data[[AGE_COL]] <= max_age, ] | |
| } | |
| min_min <- if (!is.null(input$minutes_filter)) input$minutes_filter else 0 | |
| if (MINUTES_COL %in% names(data)) { | |
| data <- data[!is.na(data[[MINUTES_COL]]) & | |
| data[[MINUTES_COL]] >= min_min, ] | |
| } | |
| cols <- available_cols(SEARCH_TABLE_COLS, data) | |
| out <- data[, cols, drop = FALSE] | |
| if (nrow(out) == 0) return(data.frame(Message = "No players found.")) | |
| sort_col <- if (TARGET_SCORE_COL %in% names(out)) TARGET_SCORE_COL | |
| else ATTAINABILITY_COL | |
| if (sort_col %in% names(out)) { | |
| out <- out[order(-out[[sort_col]], na.last = TRUE), ] | |
| } | |
| pretty_df(out) | |
| }) | |
| output$search_results <- renderDT({ | |
| req(search_result_df()) | |
| datatable(search_result_df(), selection = "single", rownames = FALSE, | |
| options = list(scrollX = TRUE, pageLength = 25)) | |
| }) | |
| output$search_status <- renderText({ | |
| sel <- input$search_results_rows_selected | |
| if (!is.null(sel) && length(sel) > 0) { | |
| d <- search_result_df() | |
| if ("Player" %in% names(d)) { | |
| player <- d[sel, "Player"] | |
| updateSelectizeInput(session, "selected_player", selected = player) | |
| return(paste("Loaded", player, "into Player Profile tab.")) | |
| } | |
| } | |
| "Click a player row to load them into the Player Profile tab." | |
| }) | |
| # ---- PROFILE ---- | |
| current_player_row <- reactive({ | |
| get_player_row(df, input$selected_player) | |
| }) | |
| output$profile_output <- renderUI({ | |
| row <- current_player_row() | |
| if (is.null(row)) return(p("Select a player to view their profile.")) | |
| tagList( | |
| h2(row[[PLAYER_COL]]), | |
| h4(paste0(row[[TEAM_COL]], " | ", row[[COMP_COL]])), | |
| h4("Player Details"), | |
| tags$ul( | |
| tags$li(strong("Primary Position: "), | |
| clean_value(row[[POSITION_COL]])), | |
| tags$li(strong("Secondary Position: "), | |
| clean_value(row[[SECONDARY_POSITION_COL]])), | |
| tags$li(strong("Age: "), clean_value(row[[AGE_COL]])), | |
| tags$li(strong("Country: "), clean_value(row[[COUNTRY_COL]])), | |
| tags$li(strong("Height: "), | |
| paste0(clean_value(row[[HEIGHT_COL]]), " cm")), | |
| tags$li(strong("Weight: "), | |
| paste0(clean_value(row[[WEIGHT_COL]]), " kg")), | |
| tags$li(strong("Market Value: "), | |
| format_money(row[[MARKET_VALUE_COL]])), | |
| tags$li(strong("Contract: "), clean_value(row[[CONTRACT_COL]])), | |
| tags$li(strong("Minutes: "), clean_value(row[[MINUTES_COL]])) | |
| ) | |
| ) | |
| }) | |
| output$key_summary <- renderDT({ | |
| row <- current_player_row() | |
| if (is.null(row)) { | |
| return(datatable(data.frame(Metric = "Select a player", Value = ""))) | |
| } | |
| out <- data.frame( | |
| Metric = c("Best Archetype", "Best Archetype Score", "Target Score", | |
| "Attainability", "Club Rank", "Match Toughness", "Club ELO"), | |
| Value = c( | |
| clean_value(row[[ARCHETYPE_COL]]), | |
| clean_value(row[[ARCHETYPE_SCORE_COL]]), | |
| clean_value(row[[TARGET_SCORE_COL]]), | |
| clean_value(row[[ATTAINABILITY_COL]]), | |
| clean_value(row[[CLUB_RANK_COL]]), | |
| clean_value(row[[MATCH_TOUGHNESS_COL]]), | |
| clean_value(row[[ELO_COL]]) | |
| ), | |
| stringsAsFactors = FALSE | |
| ) | |
| datatable(out, rownames = FALSE, | |
| options = list(dom = "t", paging = FALSE)) | |
| }) | |
| output$metric_table <- renderDT({ | |
| row <- current_player_row() | |
| if (is.null(row)) { | |
| return(datatable(data.frame(Metric = "Select a player", Score = ""))) | |
| } | |
| cols <- switch(input$metric_group, | |
| "Attributes" = ATTR_COLS, | |
| "Position Scores" = POSITION_SCORE_COLS, | |
| "Archetype Scores" = ARCHETYPE_SCORE_COLS, | |
| "Key Season Stats" = KEY_METRICS, | |
| ATTR_COLS | |
| ) | |
| cols <- available_cols(cols, df) | |
| rows_list <- list() | |
| for (cn in cols) { | |
| v <- row[[cn]] | |
| if (!is.null(v) && length(v) > 0 && !is.na(v)) { | |
| rows_list[[length(rows_list) + 1]] <- data.frame( | |
| Metric = pretty_label(cn), | |
| Score = round(as.numeric(v), 2), | |
| stringsAsFactors = FALSE | |
| ) | |
| } | |
| } | |
| if (length(rows_list) == 0) { | |
| return(datatable(data.frame(Metric = "No metrics available", Score = NA))) | |
| } | |
| out <- do.call(rbind, rows_list) | |
| out <- out[order(-out$Score, na.last = TRUE), ] | |
| datatable(out, rownames = FALSE, | |
| options = list(scrollX = TRUE, pageLength = 25)) | |
| }) | |
| output$radar_plot <- renderPlotly({ | |
| row <- current_player_row() | |
| if (is.null(row)) { | |
| return(plot_ly() %>% layout(title = "Select a player")) | |
| } | |
| metrics <- top_attr_cols(df, row, max_cols = 8) | |
| if (length(metrics) < 3) { | |
| return(plot_ly() %>% layout(title = "Not enough attributes")) | |
| } | |
| group <- get_player_group(df, row) | |
| labels <- sapply(metrics, pretty_label) | |
| player_vals <- sapply(metrics, function(m) { | |
| v <- row[[m]] | |
| if (is.null(v) || is.na(v)) 0 else as.numeric(v) | |
| }) | |
| avg_vals <- sapply(metrics, function(m) { | |
| if (m %in% names(group)) mean(group[[m]], na.rm = TRUE) else 0 | |
| }) | |
| max_val <- max(100, max(c(player_vals, avg_vals), na.rm = TRUE) * 1.1) | |
| plot_ly(type = "scatterpolar", fill = "toself") %>% | |
| add_trace(r = c(player_vals, player_vals[1]), | |
| theta = c(labels, labels[1]), | |
| name = as.character(input$selected_player)) %>% | |
| add_trace(r = c(avg_vals, avg_vals[1]), | |
| theta = c(labels, labels[1]), | |
| name = "Position/Competition Avg") %>% | |
| layout( | |
| title = paste(input$selected_player, "Attribute Radar"), | |
| polar = list(radialaxis = list(range = c(0, max_val))), | |
| legend = list(orientation = "h") | |
| ) | |
| }) | |
| output$percentile_plot <- renderPlotly({ | |
| row <- current_player_row() | |
| if (is.null(row)) { | |
| return(plot_ly() %>% layout(title = "Select a player")) | |
| } | |
| group <- get_player_group(df, row) | |
| rows_list <- list() | |
| for (m in available_cols(c(ATTR_COLS, TARGET_SCORE_COL, | |
| ATTAINABILITY_COL, ARCHETYPE_SCORE_COL), df)) { | |
| v <- suppressWarnings(as.numeric(row[[m]])) | |
| vals <- suppressWarnings(as.numeric(group[[m]])) | |
| vals <- vals[!is.na(vals)] | |
| if (!is.na(v) && length(vals) > 1) { | |
| pct <- mean(vals < v, na.rm = TRUE) * 100 | |
| rows_list[[length(rows_list) + 1]] <- data.frame( | |
| Metric = pretty_label(m), | |
| Percentile = round(pct, 1), | |
| stringsAsFactors = FALSE | |
| ) | |
| } | |
| } | |
| if (length(rows_list) == 0) { | |
| return(plot_ly() %>% layout(title = "No percentile data")) | |
| } | |
| plot_df <- do.call(rbind, rows_list) | |
| plot_df <- plot_df[order(plot_df$Percentile), ] | |
| plot_ly(plot_df, | |
| x = ~Percentile, y = ~Metric, type = "bar", orientation = "h", | |
| text = ~paste0(Percentile, "%"), textposition = "outside") %>% | |
| layout( | |
| title = paste(input$selected_player, "Percentiles"), | |
| xaxis = list(range = c(0, 110)), | |
| yaxis = list(title = ""), | |
| height = max(450, 32 * nrow(plot_df)) | |
| ) | |
| }) | |
| observeEvent(input$trend_btn, { | |
| output$trend_plot <- renderPlotly({ | |
| row <- current_player_row() | |
| multi_row <- get_multiseason_row(multi_df, input$selected_player) | |
| metric <- input$profile_metric | |
| if (is.null(row) || is.null(metric) || metric == "") { | |
| return(plot_ly() %>% layout(title = "Select a player and metric.")) | |
| } | |
| rows_list <- list() | |
| for (sc in names(HISTORICAL_SEASONS)) { | |
| v <- NA_real_ | |
| if (!is.null(multi_row)) v <- find_metric_value(multi_row, metric, sc) | |
| if (is.na(v)) v <- find_metric_value(row, metric, sc) | |
| if (!is.na(v)) { | |
| rows_list[[length(rows_list) + 1]] <- data.frame( | |
| Season = HISTORICAL_SEASONS[sc], | |
| Score = v, | |
| stringsAsFactors = FALSE | |
| ) | |
| } | |
| } | |
| curr_val <- find_metric_value(row, metric, NULL) | |
| plot_df <- if (length(rows_list) > 0) do.call(rbind, rows_list) | |
| else data.frame(Season = character(0), Score = numeric(0)) | |
| if (!is.na(curr_val)) { | |
| plot_df <- plot_df[plot_df$Season != CURRENT_MAIN_SEASON_LABEL, ] | |
| plot_df <- rbind(plot_df, data.frame( | |
| Season = CURRENT_MAIN_SEASON_LABEL, | |
| Score = curr_val, | |
| stringsAsFactors = FALSE | |
| )) | |
| } | |
| if (nrow(plot_df) == 0) { | |
| return(plot_ly() %>% layout(title = "No performance data found.")) | |
| } | |
| season_order <- c("2021-22", "2022-23", "2023-24", "2024-25", "2025-26") | |
| plot_df$Season <- factor(plot_df$Season, levels = season_order) | |
| plot_df <- plot_df[order(plot_df$Season), ] | |
| plot_ly(plot_df, x = ~Season, y = ~Score, | |
| type = "scatter", mode = "lines+markers+text", | |
| text = ~round(Score, 2), textposition = "top center") %>% | |
| layout(title = paste0(input$selected_player, ": ", | |
| pretty_label(metric), " Over Time")) | |
| }) | |
| }) | |
| output$report_btn <- downloadHandler( | |
| filename = function() { | |
| safe <- gsub("[^A-Za-z0-9_]", "_", input$selected_player) | |
| paste0(safe, "_scouting_report.csv") | |
| }, | |
| content = function(file) { | |
| row <- current_player_row() | |
| if (is.null(row)) { | |
| write.csv(data.frame(Message = "No player selected"), | |
| file, row.names = FALSE) | |
| return() | |
| } | |
| all_cols <- available_cols(c( | |
| PLAYER_COL, TEAM_COL, COMP_COL, POSITION_COL, | |
| AGE_COL, COUNTRY_COL, HEIGHT_COL, WEIGHT_COL, | |
| MARKET_VALUE_COL, CONTRACT_COL, MINUTES_COL, | |
| ARCHETYPE_COL, ARCHETYPE_SCORE_COL, TARGET_SCORE_COL, | |
| ATTAINABILITY_COL, CLUB_RANK_COL, MATCH_TOUGHNESS_COL, | |
| ELO_COL, ATTR_COLS, KEY_METRICS, | |
| POSITION_SCORE_COLS, ARCHETYPE_SCORE_COLS | |
| ), df) | |
| out <- df[as.character(df[[PLAYER_COL]]) == | |
| as.character(input$selected_player), all_cols, drop = FALSE] | |
| write.csv(out, file, row.names = FALSE) | |
| } | |
| ) | |
| observeEvent(input$shortlist_btn, { | |
| p <- input$selected_player | |
| if (!is.null(p) && nchar(trimws(p)) > 0 && !p %in% shortlist()) { | |
| shortlist(c(shortlist(), p)) | |
| } | |
| }) | |
| view_shortlist <- reactive({ | |
| sl <- shortlist() | |
| if (length(sl) == 0) { | |
| return(data.frame(Message = "No players added yet.", | |
| stringsAsFactors = FALSE)) | |
| } | |
| data <- df[as.character(df[[PLAYER_COL]]) %in% sl, ] | |
| cols <- available_cols(SHORTLIST_COLS, data) | |
| out <- data[, cols, drop = FALSE] | |
| if (nrow(out) == 0) { | |
| return(data.frame(Message = "Shortlist is empty.", | |
| stringsAsFactors = FALSE)) | |
| } | |
| pretty_df(out) | |
| }) | |
| output$shortlist_from_profile <- renderDT({ | |
| datatable(view_shortlist(), rownames = FALSE, | |
| options = list(scrollX = TRUE, pageLength = 15)) | |
| }) | |
| # ---- COMPARISON ---- | |
| comparison_df <- eventReactive(input$compare_btn, { | |
| players <- c(input$compare_1, input$compare_2, input$compare_3) | |
| players <- players[!is.null(players) & nchar(trimws(players)) > 0] | |
| if (length(players) == 0) { | |
| return(data.frame(Message = "Select at least one player.", | |
| stringsAsFactors = FALSE)) | |
| } | |
| data <- df[as.character(df[[PLAYER_COL]]) %in% players, ] | |
| cols <- available_cols(COMPARISON_COLS, data) | |
| pretty_df(data[, cols, drop = FALSE]) | |
| }) | |
| output$comparison_table <- renderDT({ | |
| datatable(comparison_df(), selection = "single", rownames = FALSE, | |
| options = list(scrollX = TRUE, pageLength = 25)) | |
| }) | |
| output$comparison_radar <- renderPlotly({ | |
| players <- c(input$compare_1, input$compare_2, input$compare_3) | |
| players <- players[!is.null(players) & nchar(trimws(players)) > 0] | |
| if (length(players) == 0) { | |
| return(plot_ly() %>% layout(title = "Select players to compare.")) | |
| } | |
| first_row <- get_player_row(df, players[1]) | |
| if (is.null(first_row)) return(plot_ly()) | |
| metrics <- top_attr_cols(df, first_row, max_cols = 8) | |
| if (length(metrics) < 3) { | |
| return(plot_ly() %>% layout(title = "Not enough attributes.")) | |
| } | |
| labels <- sapply(metrics, pretty_label) | |
| fig <- plot_ly(type = "scatterpolar", fill = "toself") | |
| for (p in players) { | |
| row <- get_player_row(df, p) | |
| if (!is.null(row)) { | |
| vals <- sapply(metrics, function(m) { | |
| v <- row[[m]] | |
| if (is.null(v) || is.na(v)) 0 else as.numeric(v) | |
| }) | |
| fig <- fig %>% add_trace( | |
| r = c(vals, vals[1]), | |
| theta = c(labels, labels[1]), | |
| name = p | |
| ) | |
| } | |
| } | |
| fig %>% layout( | |
| title = "Player Attribute Radar Comparison", | |
| polar = list(radialaxis = list(range = c(0, 110))), | |
| legend = list(orientation = "h") | |
| ) | |
| }) | |
| # ---- FIT SCORE ---- | |
| fit_result_df <- eventReactive(input$fit_btn, { | |
| data <- df | |
| if (length(input$fit_competition_filter) > 0 && COMP_COL %in% names(data)) { | |
| data <- data[data[[COMP_COL]] %in% input$fit_competition_filter, ] | |
| } | |
| if (length(input$fit_position_filter) > 0 && POSITION_COL %in% names(data)) { | |
| data <- data[data[[POSITION_COL]] %in% input$fit_position_filter, ] | |
| } | |
| if (nrow(data) == 0) { | |
| return(data.frame(Message = "No players found for selected filters.", | |
| stringsAsFactors = FALSE)) | |
| } | |
| weight_cols <- c( | |
| "attr_pressing", "attr_duels", "attr_aerial", | |
| "attr_possession_retention", "attr_blocking", "attr_progression", | |
| "attr_impact", "attr_discipline", "attr_dribbling", | |
| "attr_chance_creation", "attr_finishing", "attr_crossing", | |
| "attr_box_presence", "attr_holdup", | |
| TARGET_SCORE_COL, ATTAINABILITY_COL | |
| ) | |
| weight_vals <- c( | |
| input$pressing_w, input$duels_w, input$aerial_w, | |
| input$possession_w, input$blocking_w, input$progression_w, | |
| input$impact_w, input$discipline_w, input$dribbling_w, | |
| input$chance_w, input$finishing_w, input$crossing_w, | |
| input$box_w, input$holdup_w, | |
| input$target_w, input$attain_w | |
| ) | |
| weights <- setNames(weight_vals, weight_cols) | |
| total_weight <- sum(weights) | |
| if (total_weight == 0) { | |
| return(data.frame(Message = "At least one weight must be above 0.", | |
| stringsAsFactors = FALSE)) | |
| } | |
| fit_vals <- rep(0, nrow(data)) | |
| for (col in names(weights)) { | |
| w <- weights[col] | |
| if (col %in% names(data) && w > 0) { | |
| fit_vals <- fit_vals + normalize_0_100(data[[col]]) * w | |
| } | |
| } | |
| data[["fit_score"]] <- fit_vals / total_weight | |
| cols <- c(available_cols(c( | |
| PLAYER_COL, POSITION_COL, TEAM_COL, COMP_COL, | |
| AGE_COL, MINUTES_COL, MARKET_VALUE_COL, CONTRACT_COL, | |
| ARCHETYPE_COL, ARCHETYPE_SCORE_COL, | |
| TARGET_SCORE_COL, ATTAINABILITY_COL | |
| ), data), "fit_score") | |
| out <- data[order(-data[["fit_score"]], na.last = TRUE), cols, drop = FALSE] | |
| pretty_df(head(out, 50)) | |
| }) | |
| output$fit_table <- renderDT({ | |
| datatable(fit_result_df(), selection = "single", rownames = FALSE, | |
| options = list(scrollX = TRUE, pageLength = 25)) | |
| }) | |
| # ---- SIMILAR PLAYERS ---- | |
| similar_result_df <- eventReactive(input$similar_btn, { | |
| row <- get_player_row(df, input$similar_player_select) | |
| if (is.null(row)) { | |
| return(data.frame(Message = "Select a player.", stringsAsFactors = FALSE)) | |
| } | |
| metrics <- available_cols( | |
| c(ATTR_COLS, TARGET_SCORE_COL, ATTAINABILITY_COL, ARCHETYPE_SCORE_COL), df) | |
| metrics <- metrics[sapply(metrics, function(m) { | |
| v <- row[[m]] | |
| !is.null(v) && length(v) > 0 && !is.na(v) | |
| })] | |
| metrics <- head(metrics, 24) | |
| if (length(metrics) == 0) { | |
| return(data.frame(Message = "No similarity metrics available.", | |
| stringsAsFactors = FALSE)) | |
| } | |
| pos <- row[[POSITION_COL]] | |
| candidates <- df[as.character(df[[PLAYER_COL]]) != | |
| as.character(input$similar_player_select), ] | |
| if (POSITION_COL %in% names(df) && !is.null(pos) && !is.na(pos)) { | |
| sub <- candidates[candidates[[POSITION_COL]] == pos, ] | |
| if (nrow(sub) > 0) candidates <- sub | |
| } | |
| dist_vals <- rep(0, nrow(candidates)) | |
| for (m in metrics) { | |
| all_vals <- suppressWarnings(as.numeric(df[[m]])) | |
| sd_val <- sd(all_vals, na.rm = TRUE) | |
| cand_vals <- suppressWarnings(as.numeric(candidates[[m]])) | |
| ref_val <- suppressWarnings(as.numeric(row[[m]])) | |
| if (!is.na(sd_val) && sd_val > 0) { | |
| diff <- cand_vals - ref_val | |
| diff[is.na(diff)] <- 0 | |
| dist_vals <- dist_vals + (diff / sd_val)^2 | |
| } | |
| } | |
| candidates[["similarity_score"]] <- 100 / (1 + dist_vals) | |
| cols <- c(available_cols(c( | |
| PLAYER_COL, TEAM_COL, COMP_COL, POSITION_COL, AGE_COL, | |
| MARKET_VALUE_COL, ARCHETYPE_COL, ARCHETYPE_SCORE_COL, | |
| TARGET_SCORE_COL, ATTAINABILITY_COL | |
| ), candidates), "similarity_score") | |
| out <- candidates[order(-candidates[["similarity_score"]]), | |
| cols, drop = FALSE] | |
| pretty_df(head(out, 10)) | |
| }) | |
| output$similar_table <- renderDT({ | |
| datatable(similar_result_df(), selection = "single", rownames = FALSE, | |
| options = list(scrollX = TRUE, pageLength = 15)) | |
| }) | |
| # ---- SHORTLIST MANAGER ---- | |
| observeEvent(input$add_shortlist_btn, { | |
| p <- input$shortlist_player | |
| if (!is.null(p) && nchar(trimws(p)) > 0 && !p %in% shortlist()) { | |
| shortlist(c(shortlist(), p)) | |
| } | |
| }) | |
| observeEvent(input$clear_shortlist_btn, { | |
| shortlist(character(0)) | |
| }) | |
| output$shortlist_table <- renderDT({ | |
| datatable(view_shortlist(), rownames = FALSE, | |
| options = list(scrollX = TRUE, pageLength = 25)) | |
| }) | |
| output$export_shortlist_btn <- downloadHandler( | |
| filename = function() "shortlist_export.csv", | |
| content = function(file) write.csv(view_shortlist(), file, row.names = FALSE) | |
| ) | |
| } | |
| shinyApp(ui, server) |