cpflecht commited on
Commit
ffddebf
·
verified ·
1 Parent(s): 671b63b

Update app.R

Browse files
Files changed (1) hide show
  1. app.R +623 -486
app.R CHANGED
@@ -1,16 +1,3 @@
1
- # ============================================================
2
- # Oldham Athletic Player Scouting App — R Shiny Version
3
- #
4
- # Files needed in the same directory:
5
- # app.R
6
- # OA_sheet_for_app.csv
7
- # all_players_enriched_multiseason.csv (optional)
8
- #
9
- # Required R packages (install once):
10
- # install.packages(c("shiny","shinythemes","DT","dplyr","tidyr",
11
- # "readr","plotly","stringr","scales"))
12
- # ============================================================
13
-
14
  library(shiny)
15
  library(shinythemes)
16
  library(DT)
@@ -21,81 +8,180 @@ library(plotly)
21
  library(stringr)
22
  library(scales)
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  # ============================================================
25
  # HELPERS
26
  # ============================================================
27
 
28
  clean_colnames <- function(df) {
29
- names(df) <- names(df) |>
30
- str_trim() |>
31
- str_to_lower() |>
32
- str_replace_all("[ \\-/]", "_") |>
33
- str_replace_all("\\.", "_")
 
34
  df
35
  }
36
 
37
  clean_player_key <- function(x) {
38
- x |>
39
- str_trim() |>
40
- str_to_lower() |>
41
- str_replace_all("\\.", "") |>
42
- str_replace_all(",", "") |>
43
- str_replace_all("-", " ") |>
44
- str_replace_all(" ", " ")
45
  }
46
 
47
  format_money <- function(x) {
48
  x <- suppressWarnings(as.numeric(x))
49
- ifelse(is.na(x), "Not listed",
50
- ifelse(x >= 1e6, paste0("\u20ac", round(x / 1e6, 1), "M"),
51
- ifelse(x >= 1e3, paste0("\u20ac", round(x / 1e3, 0), "K"),
52
- paste0("\u20ac", round(x, 0)))))
 
 
 
 
 
 
 
 
 
53
  }
54
 
55
  clean_value <- function(x) {
56
- if (is.null(x) || length(x) == 0 || (length(x) == 1 && is.na(x))) return("N/A")
57
- if (is.numeric(x)) return(round(x, 2))
 
58
  as.character(x)
59
  }
60
 
61
  pretty_label <- function(col) {
62
- custom <- c(
63
- player_name = "Player", team_name = "Club", competition_name = "Competition",
64
- season_name = "Season", primary_position = "Primary Position",
65
- secondary_position = "Secondary Position", country_id = "Country",
66
- player_height = "Height", player_weight = "Weight",
67
- player_season_minutes = "Minutes", market_value_eur = "Market Value",
68
- seasons_left_num = "Seasons Left", attainability = "Attainability",
69
- target_score = "Target Score", best_position_archetype_name = "Best Archetype",
 
 
 
70
  best_position_archetype_score = "Best Archetype Score",
71
- cb_score = "CB Score", fb_score = "FB Score", cmd_score = "CMD Score",
72
- cma_score = "CMA Score", wm_score = "WM Score", cf_score = "CF Score",
 
73
  st_score = "ST Score", gk_score = "GK Score",
74
  club_rank = "Club Rank", match_toughness = "Match Toughness",
75
- elo = "Club ELO", competition_rank = "Competition Rank", fit_score = "Fit Score"
 
76
  )
77
- if (col %in% names(custom)) return(unname(custom[col]))
78
- lbl <- col |>
79
- str_replace("player_season_", "") |>
80
- str_replace("attr_", "") |>
81
- str_replace("cat_", "") |>
82
- str_replace("score_", "") |>
83
- str_replace_all("_90$", " Per 90") |>
84
- str_replace_all("_", " ") |>
85
- str_to_title() |>
86
- str_replace("Np Xg", "NP xG") |>
87
- str_replace("Xa ", "xA ") |>
88
- str_replace("Xgchain", "xGChain") |>
89
- str_replace("Xgbuildup", "xGBuildup") |>
90
- str_replace("Obv", "OBV") |>
91
- str_replace("\\bGk\\b", "GK") |>
92
- str_replace("\\bCb\\b", "CB") |>
93
- str_replace("\\bFb\\b", "FB") |>
94
- str_replace("\\bCmd\\b", "CMD") |>
95
- str_replace("\\bCma\\b", "CMA") |>
96
- str_replace("\\bWm\\b", "WM") |>
97
- str_replace("\\bCf\\b", "CF") |>
98
- str_replace("\\bSt\\b", "ST")
99
  lbl
100
  }
101
 
@@ -111,167 +197,96 @@ normalize_0_100 <- function(x) {
111
  (x - mn) / (mx - mn) * 100
112
  }
113
 
114
- pretty_df <- function(data, df_source) {
115
  out <- data
116
- if ("market_value_eur" %in% names(out)) {
117
- out$market_value_eur <- format_money(out$market_value_eur)
118
  }
119
  num_cols <- names(out)[sapply(out, is.numeric)]
120
- out[num_cols] <- lapply(out[num_cols], function(x) round(x, 2))
121
- names(out) <- sapply(names(out), pretty_label)
 
 
 
122
  out
123
  }
124
 
125
- # ============================================================
126
- # COLUMN CONSTANTS
127
- # ============================================================
128
-
129
- PLAYER_COL <- "player_name"
130
- TEAM_COL <- "team_name"
131
- COMP_COL <- "competition_name"
132
- SEASON_COL <- "season_name"
133
- POSITION_COL <- "primary_position"
134
- SECONDARY_POSITION_COL<- "secondary_position"
135
- COUNTRY_COL <- "country_id"
136
- AGE_COL <- "age"
137
- HEIGHT_COL <- "player_height"
138
- WEIGHT_COL <- "player_weight"
139
- MINUTES_COL <- "player_season_minutes"
140
- MARKET_VALUE_COL <- "market_value_eur"
141
- CONTRACT_COL <- "seasons_left_num"
142
- ATTAINABILITY_COL <- "attainability"
143
- TARGET_SCORE_COL <- "target_score"
144
- ARCHETYPE_COL <- "best_position_archetype_name"
145
- ARCHETYPE_SCORE_COL <- "best_position_archetype_score"
146
- CLUB_RANK_COL <- "club_rank"
147
- MATCH_TOUGHNESS_COL <- "match_toughness"
148
- ELO_COL <- "elo"
149
- COMPETITION_RANK_COL <- "competition_rank"
150
-
151
- ATTR_COLS <- c(
152
- "attr_shot_stopping","attr_sweeping","attr_ball_claiming","attr_short_passing",
153
- "attr_long_passing","attr_pressing","attr_duels","attr_aerial",
154
- "attr_possession_retention","attr_blocking","attr_progression","attr_set_pieces",
155
- "attr_impact","attr_discipline","attr_dribbling","attr_chance_creation",
156
- "attr_finishing","attr_crossing","attr_box_presence","attr_holdup"
157
- )
158
-
159
- POSITION_SCORE_COLS <- c(
160
- "cb_score","fb_score","cmd_score","cma_score","wm_score","cf_score","st_score","gk_score"
161
- )
162
-
163
- ARCHETYPE_SCORE_COLS <- c(
164
- "score_defensive_cb","score_pressing_cb","score_ballplaying_cb",
165
- "score_defensive_fb","score_attacking_fb","score_possession_fb",
166
- "score_poacher","score_target_man","score_false_nine","score_complete_forward",
167
- "score_inside_forward","score_traditional_winger","score_playmaking_winger",
168
- "score_pressing_winger","score_complete_winger","score_defensive_midfielder",
169
- "score_deep_lying_playmaker","score_box_to_box_midfielder","score_advanced_playmaker",
170
- "score_wide_midfielder","score_attacking_runner","score_shot_stopper_gk",
171
- "score_sweeper_keeper_gk","score_ball_playing_gk"
172
- )
173
-
174
- KEY_METRICS <- c(
175
- "player_season_minutes","player_season_goals_90","player_season_assists_90",
176
- "player_season_np_xg_90","player_season_xa_90","player_season_key_passes_90",
177
- "player_season_passing_ratio","player_season_tackles_90","player_season_interceptions_90",
178
- "player_season_tackles_and_interceptions_90","player_season_aerial_wins_90",
179
- "player_season_aerial_ratio","player_season_dribbles_90","player_season_crosses_90",
180
- "player_season_long_balls_90","player_season_xgchain_90","player_season_xgbuildup_90",
181
- "player_season_obv_90"
182
- )
183
-
184
- SEARCH_TABLE_COLS <- c(
185
- PLAYER_COL, POSITION_COL, TEAM_COL, COMP_COL, AGE_COL, COUNTRY_COL,
186
- MINUTES_COL, MARKET_VALUE_COL, CONTRACT_COL, ARCHETYPE_COL, ARCHETYPE_SCORE_COL,
187
- TARGET_SCORE_COL, ATTAINABILITY_COL, POSITION_SCORE_COLS, ATTR_COLS
188
- )
189
-
190
- COMPARISON_COLS <- c(
191
- PLAYER_COL, POSITION_COL, TEAM_COL, COMP_COL, AGE_COL,
192
- MARKET_VALUE_COL, CONTRACT_COL, ARCHETYPE_COL, ARCHETYPE_SCORE_COL,
193
- TARGET_SCORE_COL, ATTAINABILITY_COL, POSITION_SCORE_COLS, ATTR_COLS
194
- )
195
-
196
- SHORTLIST_COLS <- c(
197
- PLAYER_COL, POSITION_COL, TEAM_COL, COMP_COL, AGE_COL,
198
- MINUTES_COL, MARKET_VALUE_COL, CONTRACT_COL, ARCHETYPE_COL, ARCHETYPE_SCORE_COL,
199
- TARGET_SCORE_COL, ATTAINABILITY_COL, KEY_METRICS, ATTR_COLS,
200
- POSITION_SCORE_COLS, ARCHETYPE_SCORE_COLS
201
- )
202
-
203
- RADAR_METRICS <- ATTR_COLS
204
- PERCENTILE_METRICS <- c(ATTR_COLS, TARGET_SCORE_COL, ATTAINABILITY_COL, ARCHETYPE_SCORE_COL)
205
- SIMILARITY_METRICS <- c(ATTR_COLS, TARGET_SCORE_COL, ATTAINABILITY_COL, ARCHETYPE_SCORE_COL)
206
- PERFORMANCE_TIME_METRICS <- POSITION_SCORE_COLS
207
-
208
- HISTORICAL_SEASONS <- c("2122" = "2021-22", "2223" = "2022-23",
209
- "2324" = "2023-24", "2425" = "2024-25")
210
- CURRENT_MAIN_SEASON_LABEL <- "2025-26"
211
-
212
  # ============================================================
213
  # LOAD DATA
214
  # ============================================================
215
 
216
  load_data <- function() {
217
- df <- read_csv("OA_sheet_for_app.csv", locale = locale(encoding = "latin1"),
218
- show_col_types = FALSE) |> clean_colnames()
219
-
220
- multi_df <- if (file.exists("all_players_enriched_multiseason.csv")) {
221
- read_csv("all_players_enriched_multiseason.csv",
222
- locale = locale(encoding = "latin1"), show_col_types = FALSE) |>
223
- clean_colnames()
224
- } else {
225
- data.frame()
 
 
 
 
 
 
 
 
226
  }
227
 
228
  if (PLAYER_COL %in% names(df)) {
229
- df$`_player_key` <- clean_player_key(df[[PLAYER_COL]])
230
  }
231
 
232
- multi_player_col <- NULL
233
  if (nrow(multi_df) > 0) {
 
234
  for (pc in c("player_name", "player", "name")) {
235
- if (pc %in% names(multi_df)) { multi_player_col <- pc; break }
 
 
 
236
  }
237
  if (!is.null(multi_player_col)) {
238
- multi_df$`_player_key` <- clean_player_key(multi_df[[multi_player_col]])
239
  } else {
240
- multi_df$`_player_key` <- ""
241
  }
242
 
243
  hist_suffixes <- c("_2122", "_2223", "_2324", "_2425")
244
- hist_cols <- names(multi_df)[sapply(names(multi_df),
245
- function(c) any(str_ends(c, hist_suffixes)))]
 
246
 
247
  if (length(hist_cols) > 0 && "_player_key" %in% names(multi_df)) {
248
- multi_keep <- multi_df |>
249
- select(all_of(c("_player_key", hist_cols))) |>
250
- distinct(`_player_key`, .keep_all = TRUE)
251
-
252
  overlap <- hist_cols[hist_cols %in% names(df)]
253
- if (length(overlap) > 0) df <- df |> select(-all_of(overlap))
254
-
255
- df <- left_join(df, multi_keep, by = "_player_key")
 
256
  }
257
  }
258
 
259
  # Coerce numeric columns
260
- num_cols_to_coerce <- available_cols(c(
261
  KEY_METRICS, ATTR_COLS, POSITION_SCORE_COLS, ARCHETYPE_SCORE_COLS,
262
  AGE_COL, HEIGHT_COL, WEIGHT_COL, MINUTES_COL, MARKET_VALUE_COL,
263
  ATTAINABILITY_COL, TARGET_SCORE_COL, ARCHETYPE_SCORE_COL,
264
- CLUB_RANK_COL, MATCH_TOUGHNESS_COL, ELO_COL, COMPETITION_RANK_COL
265
- ), df)
 
266
 
267
- hist_num_cols <- names(df)[sapply(names(df), function(c)
268
- any(str_ends(c, paste0("_", names(HISTORICAL_SEASONS)))))]
 
269
 
270
- for (col in unique(c(num_cols_to_coerce, hist_num_cols))) {
271
  df[[col]] <- suppressWarnings(as.numeric(df[[col]]))
272
  }
273
 
274
- list(df = df, multi_df = multi_df, multi_player_col = multi_player_col)
275
  }
276
 
277
  # ============================================================
@@ -279,7 +294,8 @@ load_data <- function() {
279
  # ============================================================
280
 
281
  get_player_row <- function(df, player) {
282
- if (is.null(player) || player == "" || !PLAYER_COL %in% names(df)) return(NULL)
 
283
  rows <- df[as.character(df[[PLAYER_COL]]) == as.character(player), ]
284
  if (nrow(rows) == 0) return(NULL)
285
  as.list(rows[1, ])
@@ -290,73 +306,65 @@ get_player_group <- function(df, row) {
290
  pos <- row[[POSITION_COL]]
291
  group <- df
292
  if (COMP_COL %in% names(df) && POSITION_COL %in% names(df) &&
293
- !is.null(comp) && !is.na(comp) && !is.null(pos) && !is.na(pos)) {
294
- group <- df[df[[COMP_COL]] == comp & df[[POSITION_COL]] == pos, ]
 
 
295
  }
296
- if (nrow(group) == 0) group <- df
297
  group
298
  }
299
 
300
  top_attr_cols <- function(df, row = NULL, max_cols = 8) {
301
- cols <- available_cols(RADAR_METRICS, df)
302
  if (!is.null(row)) {
303
- cols <- cols[sapply(cols, function(c) {
304
- v <- row[[c]]
305
- !is.null(v) && !is.na(v)
306
  })]
307
  }
308
  head(cols, max_cols)
309
  }
310
 
311
  # ============================================================
312
- # PERFORMANCE OVER TIME HELPERS
313
  # ============================================================
314
 
315
  historical_candidate_columns <- function(base_metric, season_code) {
316
- short_metric <- str_replace(base_metric, "player_season_", "")
317
  candidates <- c(
318
  paste0(base_metric, "_", season_code),
319
  paste0(short_metric, "_", season_code)
320
  )
321
- if (str_ends(short_metric, "_90")) {
322
- no_90 <- str_replace(short_metric, "_90$", "")
323
  candidates <- c(candidates,
324
  paste0(no_90, "_90_", season_code),
325
  paste0(no_90, "_per_90_", season_code),
326
- paste0(no_90, "_p90_", season_code))
327
- }
328
- plurals <- c(crosses = "cross", goals = "goal", assists = "assist",
329
- dribbles = "dribble", tackles = "tackle", interceptions = "interception",
330
- aerial_wins = "aerial_win", key_passes = "key_pass", long_balls = "long_ball")
331
- for (pl in names(plurals)) {
332
- sg <- plurals[pl]
333
- if (str_detect(short_metric, pl)) {
334
- replaced <- str_replace(short_metric, pl, sg)
335
- candidates <- c(candidates, paste0(replaced, "_", season_code))
336
- if (str_ends(short_metric, "_90")) {
337
- candidates <- c(candidates,
338
- paste0(str_replace(replaced, "_90$", ""), "_per_90_", season_code))
339
- }
340
- }
341
  }
342
  if (base_metric %in% POSITION_SCORE_COLS) {
343
- pos_code <- str_replace(base_metric, "_score", "")
344
  candidates <- c(candidates,
345
  paste0(pos_code, "_score_", season_code),
346
- paste0(pos_code, "_", season_code))
 
347
  }
348
- unique(str_to_lower(candidates))
349
  }
350
 
351
  find_metric_value <- function(row, base_metric, season_code = NULL) {
352
  if (is.null(row)) return(NA_real_)
353
  if (is.null(season_code)) {
354
  v <- row[[base_metric]]
355
- return(if (is.null(v)) NA_real_ else suppressWarnings(as.numeric(v)))
 
356
  }
357
  for (col in historical_candidate_columns(base_metric, season_code)) {
358
  v <- row[[col]]
359
- if (!is.null(v) && !is.na(v)) return(suppressWarnings(as.numeric(v)))
 
 
360
  }
361
  NA_real_
362
  }
@@ -365,7 +373,7 @@ get_multiseason_row <- function(multi_df, player) {
365
  if (is.null(multi_df) || nrow(multi_df) == 0) return(NULL)
366
  pk <- clean_player_key(player)
367
  if (!"_player_key" %in% names(multi_df)) return(NULL)
368
- matches <- multi_df[multi_df$`_player_key` == pk, ]
369
  if (nrow(matches) == 0) return(NULL)
370
  as.list(matches[1, ])
371
  }
@@ -377,13 +385,20 @@ build_performance_metric_options <- function(df, multi_df) {
377
  hist_exists <- FALSE
378
  for (sc in names(HISTORICAL_SEASONS)) {
379
  for (cand in historical_candidate_columns(m, sc)) {
380
- if (cand %in% names(df) || (!is.null(multi_df) && nrow(multi_df) > 0 && cand %in% names(multi_df))) {
381
- hist_exists <- TRUE; break
 
 
 
 
 
382
  }
383
  }
384
  if (hist_exists) break
385
  }
386
- if (current_exists || hist_exists) options[pretty_label(m)] <- m
 
 
387
  }
388
  options
389
  }
@@ -394,22 +409,18 @@ build_performance_metric_options <- function(df, multi_df) {
394
 
395
  ui <- fluidPage(
396
  theme = shinytheme("flatly"),
397
- tags$head(tags$style(HTML("
398
- .container-fluid { max-width: 98%; }
399
- table.dataTable { width: 100% !important; }
400
- .dataTables_wrapper { overflow-x: auto; }
401
- th, td { white-space: nowrap; }
402
- .section-header { border-bottom: 2px solid #2c3e50; margin-bottom: 15px; padding-bottom: 6px; }
403
- "))),
404
-
405
  titlePanel("Oldham Athletic Player Scouting"),
406
-
407
  tabsetPanel(id = "main_tabs",
408
 
409
- # ---- PLAYER SEARCH ----
410
  tabPanel("Player Search",
411
  br(),
412
- h4("Search and Filter Players", class = "section-header"),
413
  fluidRow(
414
  column(4, textInput("search_box", "Search Player Name", "")),
415
  column(4, selectizeInput("competition_filter", "Competition",
@@ -431,25 +442,23 @@ ui <- fluidPage(
431
  actionButton("search_btn", "Search Players", class = "btn-primary"),
432
  br(), br(),
433
  DTOutput("search_results"),
434
- textOutput("search_status")
435
  ),
436
 
437
- # ---- PLAYER PROFILE ----
438
  tabPanel("Player Profile",
439
  br(),
440
- h4("Full Player Profile", class = "section-header"),
441
- selectizeInput("selected_player", "Select Player", choices = NULL, width = "100%"),
 
442
  fluidRow(
443
  column(8, uiOutput("profile_output")),
444
- column(4,
445
- h5("Key Performance Summary"),
446
- DTOutput("key_summary")
447
- )
448
  ),
449
  br(),
450
- h4("Player Metrics", class = "section-header"),
451
  selectInput("metric_group", "Metric Group",
452
- choices = c("Attributes", "Position Scores", "Archetype Scores", "Key Season Stats"),
 
453
  selected = "Attributes"),
454
  DTOutput("metric_table"),
455
  br(),
@@ -461,24 +470,25 @@ ui <- fluidPage(
461
  fluidRow(
462
  column(6, selectInput("profile_metric", "Performance Metric Over Time",
463
  choices = NULL)),
464
- column(6, actionButton("trend_btn", "Show Performance Chart", class = "btn-info"))
 
465
  ),
466
  plotlyOutput("trend_plot"),
467
  br(),
468
  textAreaInput("scout_notes", "Scout Notes", rows = 4,
469
  placeholder = "Enter notes to include in the scouting report."),
470
  fluidRow(
471
- column(3, downloadButton("report_btn", "Download Scouting Report (CSV)")),
472
- column(3, actionButton("shortlist_btn", "Add Player to Shortlist", class = "btn-success"))
 
473
  ),
474
  br(),
475
  DTOutput("shortlist_from_profile")
476
  ),
477
 
478
- # ---- PLAYER COMPARISON ----
479
  tabPanel("Player Comparison Tool",
480
  br(),
481
- h4("Compare Up To Three Players", class = "section-header"),
482
  fluidRow(
483
  column(4, selectizeInput("compare_1", "Player 1", choices = NULL)),
484
  column(4, selectizeInput("compare_2", "Player 2", choices = NULL)),
@@ -491,16 +501,14 @@ ui <- fluidPage(
491
  plotlyOutput("comparison_radar", height = "550px")
492
  ),
493
 
494
- # ---- FIT SCORE ----
495
  tabPanel("Fit Score Calculator",
496
  br(),
497
- h4("Fit Score Calculator", class = "section-header"),
498
- p("Select competitions and positions, then adjust trait weights to generate ranked recommendations."),
499
  fluidRow(
500
- column(6, selectizeInput("fit_competition_filter", "Competitions to Search",
501
- choices = NULL, multiple = TRUE)),
502
- column(6, selectizeInput("fit_position_filter", "Positions to Search",
503
- choices = NULL, multiple = TRUE))
504
  ),
505
  fluidRow(
506
  column(4, sliderInput("pressing_w", "Pressing", 0, 10, 5, step = 1)),
@@ -508,17 +516,21 @@ ui <- fluidPage(
508
  column(4, sliderInput("aerial_w", "Aerial", 0, 10, 4, step = 1))
509
  ),
510
  fluidRow(
511
- column(4, sliderInput("possession_w", "Possession Retention", 0, 10, 5, step = 1)),
 
512
  column(4, sliderInput("blocking_w", "Blocking", 0, 10, 4, step = 1)),
513
- column(4, sliderInput("progression_w", "Progression", 0, 10, 6, step = 1))
 
514
  ),
515
  fluidRow(
516
  column(4, sliderInput("impact_w", "Impact", 0, 10, 6, step = 1)),
517
- column(4, sliderInput("discipline_w", "Discipline", 0, 10, 3, step = 1)),
 
518
  column(4, sliderInput("dribbling_w", "Dribbling", 0, 10, 4, step = 1))
519
  ),
520
  fluidRow(
521
- column(4, sliderInput("chance_w", "Chance Creation", 0, 10, 5, step = 1)),
 
522
  column(4, sliderInput("finishing_w", "Finishing", 0, 10, 3, step = 1)),
523
  column(4, sliderInput("crossing_w", "Crossing", 0, 10, 3, step = 1))
524
  ),
@@ -530,29 +542,33 @@ ui <- fluidPage(
530
  fluidRow(
531
  column(4, sliderInput("attain_w", "Attainability", 0, 10, 6, step = 1))
532
  ),
533
- actionButton("fit_btn", "Generate Ranked Recommendations", class = "btn-primary"),
 
534
  br(), br(),
535
  DTOutput("fit_table")
536
  ),
537
 
538
- # ---- SIMILAR PLAYERS ----
539
  tabPanel("Similar Player Finder",
540
  br(),
541
- h4("Find Similar Players", class = "section-header"),
542
- selectizeInput("similar_player_select", "Select Player", choices = NULL, width = "60%"),
543
- actionButton("similar_btn", "Find Similar Players", class = "btn-primary"),
 
 
544
  br(), br(),
545
  DTOutput("similar_table")
546
  ),
547
 
548
- # ---- SHORTLIST ----
549
  tabPanel("Shortlist Manager",
550
  br(),
551
- h4("Shortlist Manager", class = "section-header"),
552
  fluidRow(
553
- column(4, selectizeInput("shortlist_player", "Add Player", choices = NULL)),
554
- column(2, br(), actionButton("add_shortlist_btn", "Add to Shortlist", class = "btn-success")),
555
- column(2, br(), actionButton("clear_shortlist_btn", "Clear Shortlist", class = "btn-danger")),
 
 
 
556
  column(2, br(), downloadButton("export_shortlist_btn", "Export CSV"))
557
  ),
558
  br(),
@@ -567,126 +583,150 @@ ui <- fluidPage(
567
 
568
  server <- function(input, output, session) {
569
 
570
- # --- Load data once ---
571
  app_data <- tryCatch(load_data(), error = function(e) {
572
- showNotification(paste("Error loading data:", e$message), type = "error", duration = NULL)
573
- list(df = data.frame(), multi_df = data.frame(), multi_player_col = NULL)
 
574
  })
575
 
576
  df <- app_data$df
577
  multi_df <- app_data$multi_df
578
-
579
- # Session shortlist
580
  shortlist <- reactiveVal(character(0))
581
 
582
- # --- Populate dropdowns on startup ---
583
  observe({
584
  req(nrow(df) > 0)
585
 
586
- comp_opts <- if (COMP_COL %in% names(df)) sort(unique(na.omit(as.character(df[[COMP_COL]])))) else character(0)
587
- team_opts <- if (TEAM_COL %in% names(df)) sort(unique(na.omit(as.character(df[[TEAM_COL]])))) else character(0)
588
- pos_opts <- if (POSITION_COL %in% names(df)) sort(unique(na.omit(as.character(df[[POSITION_COL]])))) else character(0)
589
- country_opts <- if (COUNTRY_COL %in% names(df)) sort(unique(na.omit(as.character(df[[COUNTRY_COL]])))) else character(0)
590
-
591
- # Build named player choices: "Name | Position | Team" -> player_name
592
- player_rows <- df[, available_cols(c(PLAYER_COL, POSITION_COL, TEAM_COL), df), drop = FALSE] |> distinct()
593
- player_choices <- setNames(
594
- as.character(player_rows[[PLAYER_COL]]),
595
- paste0(player_rows[[PLAYER_COL]], " | ",
596
- if (POSITION_COL %in% names(player_rows)) player_rows[[POSITION_COL]] else "",
597
- " | ",
598
- if (TEAM_COL %in% names(player_rows)) player_rows[[TEAM_COL]] else "")
599
- )
 
 
 
 
600
  player_choices <- player_choices[order(names(player_choices))]
601
 
602
  perf_opts <- build_performance_metric_options(df, multi_df)
603
 
604
- updateSelectizeInput(session, "competition_filter", choices = comp_opts, server = TRUE)
605
- updateSelectizeInput(session, "team_filter", choices = team_opts, server = TRUE)
606
- updateSelectizeInput(session, "position_filter", choices = pos_opts, server = TRUE)
607
- updateSelectizeInput(session, "country_filter", choices = country_opts, server = TRUE)
608
- updateSelectizeInput(session, "selected_player", choices = player_choices, server = TRUE)
609
- updateSelectizeInput(session, "compare_1", choices = c("" = "", player_choices), server = TRUE)
610
- updateSelectizeInput(session, "compare_2", choices = c("" = "", player_choices), server = TRUE)
611
- updateSelectizeInput(session, "compare_3", choices = c("" = "", player_choices), server = TRUE)
612
- updateSelectizeInput(session, "fit_competition_filter", choices = comp_opts, server = TRUE)
613
- updateSelectizeInput(session, "fit_position_filter", choices = pos_opts, server = TRUE)
614
- updateSelectizeInput(session, "similar_player_select", choices = player_choices, server = TRUE)
615
- updateSelectizeInput(session, "shortlist_player", choices = player_choices, server = TRUE)
 
 
 
 
 
 
 
 
 
 
 
 
616
  updateSelectInput(session, "profile_metric", choices = perf_opts)
617
  })
618
 
619
- # --- Dynamic sliders ---
620
  output$age_slider_ui <- renderUI({
621
- age_min <- if (AGE_COL %in% names(df)) floor(min(df[[AGE_COL]], na.rm = TRUE)) else 15
622
- age_max <- if (AGE_COL %in% names(df)) ceiling(max(df[[AGE_COL]], na.rm = TRUE)) else 45
 
 
623
  tagList(
624
- sliderInput("min_age_filter", "Minimum Age", age_min, age_max, age_min, step = 1),
625
- sliderInput("max_age_filter", "Maximum Age", age_min, age_max, age_max, step = 1)
 
 
626
  )
627
  })
628
 
629
  output$minutes_slider_ui <- renderUI({
630
- min_max <- if (MINUTES_COL %in% names(df)) ceiling(max(df[[MINUTES_COL]], na.rm = TRUE)) else 5000
631
- sliderInput("minutes_filter", "Minimum Minutes", 0, min_max, 0, step = 100)
 
632
  })
633
 
634
  # ---- SEARCH ----
635
  search_result_df <- eventReactive(input$search_btn, {
636
  data <- df
637
-
638
- if (!is.null(input$search_box) && nchar(input$search_box) > 0 && PLAYER_COL %in% names(data)) {
639
- data <- data[str_detect(as.character(data[[PLAYER_COL]]),
640
- regex(input$search_box, ignore_case = TRUE)), ]
 
641
  }
642
- if (length(input$competition_filter) > 0 && COMP_COL %in% names(data))
643
  data <- data[data[[COMP_COL]] %in% input$competition_filter, ]
644
- if (length(input$team_filter) > 0 && TEAM_COL %in% names(data))
 
645
  data <- data[data[[TEAM_COL]] %in% input$team_filter, ]
646
- if (length(input$position_filter) > 0 && POSITION_COL %in% names(data))
 
647
  data <- data[data[[POSITION_COL]] %in% input$position_filter, ]
648
- if (length(input$country_filter) > 0 && COUNTRY_COL %in% names(data))
 
649
  data <- data[data[[COUNTRY_COL]] %in% input$country_filter, ]
650
-
651
  min_age <- if (!is.null(input$min_age_filter)) input$min_age_filter else -Inf
652
- max_age <- if (!is.null(input$max_age_filter)) input$max_age_filter else Inf
653
- if (AGE_COL %in% names(data))
654
  data <- data[!is.na(data[[AGE_COL]]) &
655
- data[[AGE_COL]] >= min_age & data[[AGE_COL]] <= max_age, ]
656
-
657
  min_min <- if (!is.null(input$minutes_filter)) input$minutes_filter else 0
658
- if (MINUTES_COL %in% names(data))
659
- data <- data[!is.na(data[[MINUTES_COL]]) & data[[MINUTES_COL]] >= min_min, ]
660
-
 
661
  cols <- available_cols(SEARCH_TABLE_COLS, data)
662
  out <- data[, cols, drop = FALSE]
663
  if (nrow(out) == 0) return(data.frame(Message = "No players found."))
664
-
665
- sort_col <- if (TARGET_SCORE_COL %in% names(out)) TARGET_SCORE_COL else ATTAINABILITY_COL
666
- if (sort_col %in% names(out))
667
  out <- out[order(-out[[sort_col]], na.last = TRUE), ]
668
-
669
- pretty_df(out, df)
670
  })
671
 
672
  output$search_results <- renderDT({
673
  req(search_result_df())
674
  datatable(search_result_df(), selection = "single", rownames = FALSE,
675
- options = list(scrollX = TRUE, pageLength = 25))
676
  })
677
 
678
  output$search_status <- renderText({
679
  sel <- input$search_results_rows_selected
680
  if (!is.null(sel) && length(sel) > 0) {
681
- player <- search_result_df()[sel, "Player"]
682
- updateSelectizeInput(session, "selected_player", selected = player)
683
- paste("Loaded", player, "into the Player Profile tab.")
684
- } else {
685
- "Click a player row to load them into the Player Profile tab."
 
686
  }
 
687
  })
688
 
689
- # ---- PLAYER PROFILE ----
690
  current_player_row <- reactive({
691
  get_player_row(df, input$selected_player)
692
  })
@@ -699,13 +739,18 @@ server <- function(input, output, session) {
699
  h4(paste0(row[[TEAM_COL]], " | ", row[[COMP_COL]])),
700
  h4("Player Details"),
701
  tags$ul(
702
- tags$li(strong("Primary Position: "), clean_value(row[[POSITION_COL]])),
703
- tags$li(strong("Secondary Position: "), clean_value(row[[SECONDARY_POSITION_COL]])),
 
 
704
  tags$li(strong("Age: "), clean_value(row[[AGE_COL]])),
705
  tags$li(strong("Country: "), clean_value(row[[COUNTRY_COL]])),
706
- tags$li(strong("Height: "), paste0(clean_value(row[[HEIGHT_COL]]), " cm")),
707
- tags$li(strong("Weight: "), paste0(clean_value(row[[WEIGHT_COL]]), " kg")),
708
- tags$li(strong("Market Value: "), format_money(row[[MARKET_VALUE_COL]])),
 
 
 
709
  tags$li(strong("Contract: "), clean_value(row[[CONTRACT_COL]])),
710
  tags$li(strong("Minutes: "), clean_value(row[[MINUTES_COL]]))
711
  )
@@ -714,24 +759,32 @@ server <- function(input, output, session) {
714
 
715
  output$key_summary <- renderDT({
716
  row <- current_player_row()
717
- if (is.null(row)) return(datatable(data.frame(Metric = "Select a player", Value = "")))
 
 
718
  out <- data.frame(
719
- Metric = c("Best Archetype","Best Archetype Score","Target Score",
720
- "Attainability","Club Rank","Match Toughness","Club ELO"),
721
- Value = c(clean_value(row[[ARCHETYPE_COL]]),
722
- clean_value(row[[ARCHETYPE_SCORE_COL]]),
723
- clean_value(row[[TARGET_SCORE_COL]]),
724
- clean_value(row[[ATTAINABILITY_COL]]),
725
- clean_value(row[[CLUB_RANK_COL]]),
726
- clean_value(row[[MATCH_TOUGHNESS_COL]]),
727
- clean_value(row[[ELO_COL]]))
 
 
 
728
  )
729
- datatable(out, rownames = FALSE, options = list(dom = "t", paging = FALSE))
 
730
  })
731
 
732
  output$metric_table <- renderDT({
733
  row <- current_player_row()
734
- if (is.null(row)) return(datatable(data.frame(Metric = "Select a player", Score = "")))
 
 
735
  cols <- switch(input$metric_group,
736
  "Attributes" = ATTR_COLS,
737
  "Position Scores" = POSITION_SCORE_COLS,
@@ -740,210 +793,285 @@ server <- function(input, output, session) {
740
  ATTR_COLS
741
  )
742
  cols <- available_cols(cols, df)
743
- rows_data <- lapply(cols, function(c) {
744
- v <- row[[c]]
745
- if (!is.null(v) && !is.na(v))
746
- data.frame(Metric = pretty_label(c),
747
- Score = round(as.numeric(v), 2))
748
- })
749
- out <- do.call(rbind, Filter(Negate(is.null), rows_data))
750
- if (is.null(out)) out <- data.frame(Metric = "No metrics available", Score = NA)
 
 
 
 
 
 
 
751
  out <- out[order(-out$Score, na.last = TRUE), ]
752
- datatable(out, rownames = FALSE, options = list(scrollX = TRUE, pageLength = 25))
 
753
  })
754
 
755
  output$radar_plot <- renderPlotly({
756
  row <- current_player_row()
757
- if (is.null(row)) return(plot_ly() |> layout(title = "Select a player"))
 
 
758
  metrics <- top_attr_cols(df, row, max_cols = 8)
759
- if (length(metrics) < 3) return(plot_ly() |> layout(title = "Not enough attributes"))
 
 
760
  group <- get_player_group(df, row)
761
  labels <- sapply(metrics, pretty_label)
762
  player_vals <- sapply(metrics, function(m) {
763
- v <- row[[m]]; if (is.null(v) || is.na(v)) 0 else as.numeric(v)
 
764
  })
765
  avg_vals <- sapply(metrics, function(m) {
766
  if (m %in% names(group)) mean(group[[m]], na.rm = TRUE) else 0
767
  })
768
- max_val <- max(100, max(player_vals, avg_vals, na.rm = TRUE) * 1.1, na.rm = TRUE)
769
- plot_ly(type = "scatterpolar", fill = "toself") |>
770
  add_trace(r = c(player_vals, player_vals[1]),
771
- theta = c(labels, labels[1]),
772
- name = as.character(input$selected_player)) |>
773
  add_trace(r = c(avg_vals, avg_vals[1]),
774
- theta = c(labels, labels[1]),
775
- name = "Position/Competition Avg") |>
776
- layout(title = paste(input$selected_player, "Attribute Radar"),
777
- polar = list(radialaxis = list(range = c(0, max_val))),
778
- legend = list(orientation = "h"))
 
 
779
  })
780
 
781
  output$percentile_plot <- renderPlotly({
782
  row <- current_player_row()
783
- if (is.null(row)) return(plot_ly() |> layout(title = "Select a player"))
 
 
784
  group <- get_player_group(df, row)
785
- rows_data <- lapply(available_cols(PERCENTILE_METRICS, df), function(m) {
786
- v <- suppressWarnings(as.numeric(row[[m]]))
787
- vals <- suppressWarnings(as.numeric(group[[m]]))
788
- vals <- vals[!is.na(vals)]
789
- if (!is.null(v) && !is.na(v) && length(vals) > 1) {
 
 
790
  pct <- mean(vals < v, na.rm = TRUE) * 100
791
- data.frame(Metric = pretty_label(m), Percentile = round(pct, 1))
 
 
 
 
792
  }
793
- })
794
- plot_df <- do.call(rbind, Filter(Negate(is.null), rows_data))
795
- if (is.null(plot_df) || nrow(plot_df) == 0)
796
- return(plot_ly() |> layout(title = "No percentile data"))
 
797
  plot_df <- plot_df[order(plot_df$Percentile), ]
798
- plot_ly(plot_df, x = ~Percentile, y = ~Metric, type = "bar", orientation = "h",
799
- text = ~paste0(Percentile, "%"), textposition = "outside") |>
800
- layout(title = paste(input$selected_player, "Percentiles vs Same Position and Competition"),
801
- xaxis = list(range = c(0, 110)), yaxis = list(title = ""),
802
- height = max(450, 32 * nrow(plot_df)))
 
 
 
 
803
  })
804
 
805
  observeEvent(input$trend_btn, {
806
- row <- current_player_row()
807
- multi_row <- get_multiseason_row(multi_df, input$selected_player)
808
- metric <- input$profile_metric
809
-
810
  output$trend_plot <- renderPlotly({
811
- if (is.null(row) || is.null(metric) || metric == "")
812
- return(plot_ly() |> layout(title = "Select a player and metric."))
813
- rows_data <- lapply(names(HISTORICAL_SEASONS), function(sc) {
 
 
 
 
 
814
  v <- NA_real_
815
  if (!is.null(multi_row)) v <- find_metric_value(multi_row, metric, sc)
816
  if (is.na(v)) v <- find_metric_value(row, metric, sc)
817
- if (!is.na(v)) data.frame(Season = HISTORICAL_SEASONS[sc], Score = v)
818
- })
819
- plot_df <- do.call(rbind, Filter(Negate(is.null), rows_data))
 
 
 
 
 
820
  curr_val <- find_metric_value(row, metric, NULL)
 
 
821
  if (!is.na(curr_val)) {
822
  plot_df <- plot_df[plot_df$Season != CURRENT_MAIN_SEASON_LABEL, ]
823
- plot_df <- rbind(plot_df, data.frame(Season = CURRENT_MAIN_SEASON_LABEL, Score = curr_val))
 
 
 
 
 
 
 
824
  }
825
- if (is.null(plot_df) || nrow(plot_df) == 0)
826
- return(plot_ly() |> layout(title = "No performance data found."))
827
- season_order <- c("2021-22","2022-23","2023-24","2024-25","2025-26")
828
  plot_df$Season <- factor(plot_df$Season, levels = season_order)
829
  plot_df <- plot_df[order(plot_df$Season), ]
830
- plot_ly(plot_df, x = ~Season, y = ~Score, type = "scatter", mode = "lines+markers+text",
831
- text = ~round(Score, 2), textposition = "top center") |>
832
- layout(title = paste0(input$selected_player, ": ", pretty_label(metric), " Over Time"))
 
 
833
  })
834
  })
835
 
836
- # --- PDF/CSV Report download ---
837
  output$report_btn <- downloadHandler(
838
  filename = function() {
839
- safe <- str_replace_all(input$selected_player, "[^A-Za-z0-9_]", "_")
840
  paste0(safe, "_scouting_report.csv")
841
  },
842
  content = function(file) {
843
  row <- current_player_row()
844
  if (is.null(row)) {
845
- write.csv(data.frame(Message = "No player selected"), file, row.names = FALSE)
 
846
  return()
847
  }
848
- all_cols <- available_cols(c(PLAYER_COL, TEAM_COL, COMP_COL, POSITION_COL,
849
- AGE_COL, COUNTRY_COL, HEIGHT_COL, WEIGHT_COL,
850
- MARKET_VALUE_COL, CONTRACT_COL, MINUTES_COL,
851
- ARCHETYPE_COL, ARCHETYPE_SCORE_COL, TARGET_SCORE_COL,
852
- ATTAINABILITY_COL, CLUB_RANK_COL, MATCH_TOUGHNESS_COL,
853
- ELO_COL, ATTR_COLS, KEY_METRICS, POSITION_SCORE_COLS,
854
- ARCHETYPE_SCORE_COLS), df)
855
- out <- df[as.character(df[[PLAYER_COL]]) == as.character(input$selected_player),
856
- all_cols, drop = FALSE]
857
- notes_row <- data.frame(Section = "Scout Notes", Notes = input$scout_notes)
 
858
  write.csv(out, file, row.names = FALSE)
859
  }
860
  )
861
 
862
- # --- Shortlist ---
863
  observeEvent(input$shortlist_btn, {
864
  p <- input$selected_player
865
- if (!is.null(p) && p != "" && !p %in% shortlist()) {
866
  shortlist(c(shortlist(), p))
867
  }
868
  })
869
 
870
  view_shortlist <- reactive({
871
  sl <- shortlist()
872
- if (length(sl) == 0) return(data.frame(Message = "No players added yet."))
 
 
 
873
  data <- df[as.character(df[[PLAYER_COL]]) %in% sl, ]
874
  cols <- available_cols(SHORTLIST_COLS, data)
875
  out <- data[, cols, drop = FALSE]
876
- if (nrow(out) == 0) return(data.frame(Message = "Shortlist is empty."))
877
- pretty_df(out, df)
 
 
 
878
  })
879
 
880
  output$shortlist_from_profile <- renderDT({
881
  datatable(view_shortlist(), rownames = FALSE,
882
- options = list(scrollX = TRUE, pageLength = 15))
883
  })
884
 
885
  # ---- COMPARISON ----
886
  comparison_df <- eventReactive(input$compare_btn, {
887
  players <- c(input$compare_1, input$compare_2, input$compare_3)
888
- players <- players[!is.null(players) & players != ""]
889
- if (length(players) == 0) return(data.frame(Message = "Select at least one player."))
 
 
 
890
  data <- df[as.character(df[[PLAYER_COL]]) %in% players, ]
891
  cols <- available_cols(COMPARISON_COLS, data)
892
- pretty_df(data[, cols, drop = FALSE], df)
893
  })
894
 
895
  output$comparison_table <- renderDT({
896
  datatable(comparison_df(), selection = "single", rownames = FALSE,
897
- options = list(scrollX = TRUE, pageLength = 25))
898
  })
899
 
900
  output$comparison_radar <- renderPlotly({
901
  players <- c(input$compare_1, input$compare_2, input$compare_3)
902
- players <- players[!is.null(players) & players != ""]
903
- if (length(players) == 0) return(plot_ly() |> layout(title = "Select players to compare."))
 
 
904
  first_row <- get_player_row(df, players[1])
905
  if (is.null(first_row)) return(plot_ly())
906
  metrics <- top_attr_cols(df, first_row, max_cols = 8)
907
- if (length(metrics) < 3) return(plot_ly() |> layout(title = "Not enough attributes."))
 
 
908
  labels <- sapply(metrics, pretty_label)
909
  fig <- plot_ly(type = "scatterpolar", fill = "toself")
910
  for (p in players) {
911
  row <- get_player_row(df, p)
912
  if (!is.null(row)) {
913
  vals <- sapply(metrics, function(m) {
914
- v <- row[[m]]; if (is.null(v) || is.na(v)) 0 else as.numeric(v)
 
915
  })
916
- fig <- fig |> add_trace(r = c(vals, vals[1]), theta = c(labels, labels[1]), name = p)
 
 
 
 
917
  }
918
  }
919
- fig |> layout(title = "Player Attribute Radar Comparison",
920
- polar = list(radialaxis = list(range = c(0, 110))),
921
- legend = list(orientation = "h"))
 
 
922
  })
923
 
924
  # ---- FIT SCORE ----
925
  fit_result_df <- eventReactive(input$fit_btn, {
926
  data <- df
927
- if (length(input$fit_competition_filter) > 0 && COMP_COL %in% names(data))
928
  data <- data[data[[COMP_COL]] %in% input$fit_competition_filter, ]
929
- if (length(input$fit_position_filter) > 0 && POSITION_COL %in% names(data))
 
930
  data <- data[data[[POSITION_COL]] %in% input$fit_position_filter, ]
931
- if (nrow(data) == 0) return(data.frame(Message = "No players found for selected filters."))
932
-
933
- weights <- c(
934
- attr_pressing = input$pressing_w, attr_duels = input$duels_w,
935
- attr_aerial = input$aerial_w, attr_possession_retention = input$possession_w,
936
- attr_blocking = input$blocking_w, attr_progression = input$progression_w,
937
- attr_impact = input$impact_w, attr_discipline = input$discipline_w,
938
- attr_dribbling = input$dribbling_w, attr_chance_creation = input$chance_w,
939
- attr_finishing = input$finishing_w, attr_crossing = input$crossing_w,
940
- attr_box_presence = input$box_w, attr_holdup = input$holdup_w
 
 
941
  )
942
- weights <- c(weights, setNames(c(input$target_w, input$attain_w),
943
- c(TARGET_SCORE_COL, ATTAINABILITY_COL)))
 
 
 
 
 
 
 
944
  total_weight <- sum(weights)
945
- if (total_weight == 0) return(data.frame(Message = "At least one weight must be > 0."))
946
-
 
 
947
  fit_vals <- rep(0, nrow(data))
948
  for (col in names(weights)) {
949
  w <- weights[col]
@@ -951,66 +1079,78 @@ server <- function(input, output, session) {
951
  fit_vals <- fit_vals + normalize_0_100(data[[col]]) * w
952
  }
953
  }
954
- data$fit_score <- fit_vals / total_weight
955
- cols <- available_cols(c(PLAYER_COL, POSITION_COL, TEAM_COL, COMP_COL,
956
- AGE_COL, MINUTES_COL, MARKET_VALUE_COL, CONTRACT_COL,
957
- ARCHETYPE_COL, ARCHETYPE_SCORE_COL,
958
- TARGET_SCORE_COL, ATTAINABILITY_COL,
959
- names(weights)), data)
960
- cols <- c(cols, "fit_score")
961
- out <- data[order(-data$fit_score, na.last = TRUE), cols, drop = FALSE]
962
- pretty_df(head(out, 50), df)
963
  })
964
 
965
  output$fit_table <- renderDT({
966
  datatable(fit_result_df(), selection = "single", rownames = FALSE,
967
- options = list(scrollX = TRUE, pageLength = 25))
968
  })
969
 
970
  # ---- SIMILAR PLAYERS ----
971
  similar_result_df <- eventReactive(input$similar_btn, {
972
  row <- get_player_row(df, input$similar_player_select)
973
- if (is.null(row)) return(data.frame(Message = "Select a player."))
974
- metrics <- available_cols(SIMILARITY_METRICS, df)
 
 
 
975
  metrics <- metrics[sapply(metrics, function(m) {
976
- v <- row[[m]]; !is.null(v) && !is.na(v)
 
977
  })]
978
  metrics <- head(metrics, 24)
979
- if (length(metrics) == 0) return(data.frame(Message = "No similarity metrics available."))
 
 
 
980
  pos <- row[[POSITION_COL]]
981
- candidates <- df[as.character(df[[PLAYER_COL]]) != as.character(input$similar_player_select), ]
 
982
  if (POSITION_COL %in% names(df) && !is.null(pos) && !is.na(pos)) {
983
  sub <- candidates[candidates[[POSITION_COL]] == pos, ]
984
  if (nrow(sub) > 0) candidates <- sub
985
  }
986
  dist_vals <- rep(0, nrow(candidates))
987
  for (m in metrics) {
988
- all_vals <- suppressWarnings(as.numeric(df[[m]]))
989
- sd_val <- sd(all_vals, na.rm = TRUE)
990
  cand_vals <- suppressWarnings(as.numeric(candidates[[m]]))
991
  ref_val <- suppressWarnings(as.numeric(row[[m]]))
992
  if (!is.na(sd_val) && sd_val > 0) {
993
- dist_vals <- dist_vals + ((cand_vals - ref_val) / sd_val)^2
 
 
994
  }
995
  }
996
- candidates$similarity_score <- 100 / (1 + dist_vals)
997
- cols <- c(available_cols(c(PLAYER_COL, TEAM_COL, COMP_COL, POSITION_COL,
998
- AGE_COL, MARKET_VALUE_COL, ARCHETYPE_COL,
999
- ARCHETYPE_SCORE_COL, TARGET_SCORE_COL,
1000
- ATTAINABILITY_COL), candidates), "similarity_score")
1001
- out <- candidates[order(-candidates$similarity_score), cols, drop = FALSE]
1002
- pretty_df(head(out, 10), df)
 
 
1003
  })
1004
 
1005
  output$similar_table <- renderDT({
1006
  datatable(similar_result_df(), selection = "single", rownames = FALSE,
1007
- options = list(scrollX = TRUE, pageLength = 15))
1008
  })
1009
 
1010
  # ---- SHORTLIST MANAGER ----
1011
  observeEvent(input$add_shortlist_btn, {
1012
  p <- input$shortlist_player
1013
- if (!is.null(p) && p != "" && !p %in% shortlist()) {
1014
  shortlist(c(shortlist(), p))
1015
  }
1016
  })
@@ -1021,15 +1161,12 @@ server <- function(input, output, session) {
1021
 
1022
  output$shortlist_table <- renderDT({
1023
  datatable(view_shortlist(), rownames = FALSE,
1024
- options = list(scrollX = TRUE, pageLength = 25))
1025
  })
1026
 
1027
  output$export_shortlist_btn <- downloadHandler(
1028
  filename = function() "shortlist_export.csv",
1029
- content = function(file) {
1030
- sl <- view_shortlist()
1031
- write.csv(sl, file, row.names = FALSE)
1032
- }
1033
  )
1034
  }
1035
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  library(shiny)
2
  library(shinythemes)
3
  library(DT)
 
8
  library(stringr)
9
  library(scales)
10
 
11
+ # ============================================================
12
+ # COLUMN CONSTANTS
13
+ # ============================================================
14
+
15
+ PLAYER_COL <- "player_name"
16
+ TEAM_COL <- "team_name"
17
+ COMP_COL <- "competition_name"
18
+ POSITION_COL <- "primary_position"
19
+ SECONDARY_POSITION_COL <- "secondary_position"
20
+ COUNTRY_COL <- "country_id"
21
+ AGE_COL <- "age"
22
+ HEIGHT_COL <- "player_height"
23
+ WEIGHT_COL <- "player_weight"
24
+ MINUTES_COL <- "player_season_minutes"
25
+ MARKET_VALUE_COL <- "market_value_eur"
26
+ CONTRACT_COL <- "seasons_left_num"
27
+ ATTAINABILITY_COL <- "attainability"
28
+ TARGET_SCORE_COL <- "target_score"
29
+ ARCHETYPE_COL <- "best_position_archetype_name"
30
+ ARCHETYPE_SCORE_COL <- "best_position_archetype_score"
31
+ CLUB_RANK_COL <- "club_rank"
32
+ MATCH_TOUGHNESS_COL <- "match_toughness"
33
+ ELO_COL <- "elo"
34
+
35
+ ATTR_COLS <- c(
36
+ "attr_shot_stopping", "attr_sweeping", "attr_ball_claiming",
37
+ "attr_short_passing", "attr_long_passing", "attr_pressing",
38
+ "attr_duels", "attr_aerial", "attr_possession_retention",
39
+ "attr_blocking", "attr_progression", "attr_set_pieces",
40
+ "attr_impact", "attr_discipline", "attr_dribbling",
41
+ "attr_chance_creation", "attr_finishing", "attr_crossing",
42
+ "attr_box_presence", "attr_holdup"
43
+ )
44
+
45
+ POSITION_SCORE_COLS <- c(
46
+ "cb_score", "fb_score", "cmd_score", "cma_score",
47
+ "wm_score", "cf_score", "st_score", "gk_score"
48
+ )
49
+
50
+ ARCHETYPE_SCORE_COLS <- c(
51
+ "score_defensive_cb", "score_pressing_cb", "score_ballplaying_cb",
52
+ "score_defensive_fb", "score_attacking_fb", "score_possession_fb",
53
+ "score_poacher", "score_target_man", "score_false_nine",
54
+ "score_complete_forward", "score_inside_forward",
55
+ "score_traditional_winger", "score_playmaking_winger",
56
+ "score_pressing_winger", "score_complete_winger",
57
+ "score_defensive_midfielder", "score_deep_lying_playmaker",
58
+ "score_box_to_box_midfielder", "score_advanced_playmaker",
59
+ "score_wide_midfielder", "score_attacking_runner",
60
+ "score_shot_stopper_gk", "score_sweeper_keeper_gk",
61
+ "score_ball_playing_gk"
62
+ )
63
+
64
+ KEY_METRICS <- c(
65
+ "player_season_minutes", "player_season_goals_90",
66
+ "player_season_assists_90", "player_season_np_xg_90",
67
+ "player_season_xa_90", "player_season_key_passes_90",
68
+ "player_season_passing_ratio", "player_season_tackles_90",
69
+ "player_season_interceptions_90",
70
+ "player_season_tackles_and_interceptions_90",
71
+ "player_season_aerial_wins_90", "player_season_aerial_ratio",
72
+ "player_season_dribbles_90", "player_season_crosses_90",
73
+ "player_season_long_balls_90", "player_season_xgchain_90",
74
+ "player_season_xgbuildup_90", "player_season_obv_90"
75
+ )
76
+
77
+ SEARCH_TABLE_COLS <- c(
78
+ PLAYER_COL, POSITION_COL, TEAM_COL, COMP_COL, AGE_COL,
79
+ COUNTRY_COL, MINUTES_COL, MARKET_VALUE_COL, CONTRACT_COL,
80
+ ARCHETYPE_COL, ARCHETYPE_SCORE_COL, TARGET_SCORE_COL,
81
+ ATTAINABILITY_COL, POSITION_SCORE_COLS, ATTR_COLS
82
+ )
83
+
84
+ COMPARISON_COLS <- c(
85
+ PLAYER_COL, POSITION_COL, TEAM_COL, COMP_COL, AGE_COL,
86
+ MARKET_VALUE_COL, CONTRACT_COL, ARCHETYPE_COL,
87
+ ARCHETYPE_SCORE_COL, TARGET_SCORE_COL, ATTAINABILITY_COL,
88
+ POSITION_SCORE_COLS, ATTR_COLS
89
+ )
90
+
91
+ SHORTLIST_COLS <- c(
92
+ PLAYER_COL, POSITION_COL, TEAM_COL, COMP_COL, AGE_COL,
93
+ MINUTES_COL, MARKET_VALUE_COL, CONTRACT_COL, ARCHETYPE_COL,
94
+ ARCHETYPE_SCORE_COL, TARGET_SCORE_COL, ATTAINABILITY_COL,
95
+ KEY_METRICS, ATTR_COLS, POSITION_SCORE_COLS, ARCHETYPE_SCORE_COLS
96
+ )
97
+
98
+ PERFORMANCE_TIME_METRICS <- POSITION_SCORE_COLS
99
+
100
+ HISTORICAL_SEASONS <- c(
101
+ "2122" = "2021-22", "2223" = "2022-23",
102
+ "2324" = "2023-24", "2425" = "2024-25"
103
+ )
104
+
105
+ CURRENT_MAIN_SEASON_LABEL <- "2025-26"
106
+
107
  # ============================================================
108
  # HELPERS
109
  # ============================================================
110
 
111
  clean_colnames <- function(df) {
112
+ n <- names(df)
113
+ n <- trimws(n)
114
+ n <- tolower(n)
115
+ n <- gsub("[ \\-/]", "_", n)
116
+ n <- gsub("\\.", "_", n)
117
+ names(df) <- n
118
  df
119
  }
120
 
121
  clean_player_key <- function(x) {
122
+ x <- trimws(x)
123
+ x <- tolower(x)
124
+ x <- gsub("\\.", "", x)
125
+ x <- gsub(",", "", x)
126
+ x <- gsub("-", " ", x)
127
+ x <- gsub(" ", " ", x)
128
+ x
129
  }
130
 
131
  format_money <- function(x) {
132
  x <- suppressWarnings(as.numeric(x))
133
+ result <- character(length(x))
134
+ for (i in seq_along(x)) {
135
+ if (is.na(x[i])) {
136
+ result[i] <- "Not listed"
137
+ } else if (x[i] >= 1e6) {
138
+ result[i] <- paste0("EUR ", round(x[i] / 1e6, 1), "M")
139
+ } else if (x[i] >= 1e3) {
140
+ result[i] <- paste0("EUR ", round(x[i] / 1e3, 0), "K")
141
+ } else {
142
+ result[i] <- paste0("EUR ", round(x[i], 0))
143
+ }
144
+ }
145
+ result
146
  }
147
 
148
  clean_value <- function(x) {
149
+ if (is.null(x) || length(x) == 0) return("N/A")
150
+ if (length(x) == 1 && is.na(x)) return("N/A")
151
+ if (is.numeric(x)) return(as.character(round(x, 2)))
152
  as.character(x)
153
  }
154
 
155
  pretty_label <- function(col) {
156
+ custom <- list(
157
+ player_name = "Player", team_name = "Club",
158
+ competition_name = "Competition", season_name = "Season",
159
+ primary_position = "Primary Position",
160
+ secondary_position = "Secondary Position",
161
+ country_id = "Country", player_height = "Height",
162
+ player_weight = "Weight", player_season_minutes = "Minutes",
163
+ market_value_eur = "Market Value",
164
+ seasons_left_num = "Seasons Left",
165
+ attainability = "Attainability", target_score = "Target Score",
166
+ best_position_archetype_name = "Best Archetype",
167
  best_position_archetype_score = "Best Archetype Score",
168
+ cb_score = "CB Score", fb_score = "FB Score",
169
+ cmd_score = "CMD Score", cma_score = "CMA Score",
170
+ wm_score = "WM Score", cf_score = "CF Score",
171
  st_score = "ST Score", gk_score = "GK Score",
172
  club_rank = "Club Rank", match_toughness = "Match Toughness",
173
+ elo = "Club ELO", competition_rank = "Competition Rank",
174
+ fit_score = "Fit Score", similarity_score = "Similarity Score"
175
  )
176
+ if (col %in% names(custom)) return(custom[[col]])
177
+ lbl <- col
178
+ lbl <- gsub("player_season_", "", lbl)
179
+ lbl <- gsub("attr_", "", lbl)
180
+ lbl <- gsub("cat_", "", lbl)
181
+ lbl <- gsub("score_", "", lbl)
182
+ lbl <- gsub("_90$", " Per 90", lbl)
183
+ lbl <- gsub("_", " ", lbl)
184
+ lbl <- tools::toTitleCase(lbl)
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  lbl
186
  }
187
 
 
197
  (x - mn) / (mx - mn) * 100
198
  }
199
 
200
+ pretty_df <- function(data) {
201
  out <- data
202
+ if (MARKET_VALUE_COL %in% names(out)) {
203
+ out[[MARKET_VALUE_COL]] <- format_money(out[[MARKET_VALUE_COL]])
204
  }
205
  num_cols <- names(out)[sapply(out, is.numeric)]
206
+ for (col in num_cols) {
207
+ out[[col]] <- round(out[[col]], 2)
208
+ }
209
+ new_names <- sapply(names(out), pretty_label)
210
+ names(out) <- new_names
211
  out
212
  }
213
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  # ============================================================
215
  # LOAD DATA
216
  # ============================================================
217
 
218
  load_data <- function() {
219
+ df <- tryCatch(
220
+ read_csv("OA_sheet_for_app.csv",
221
+ locale = locale(encoding = "latin1"),
222
+ show_col_types = FALSE),
223
+ error = function(e) data.frame()
224
+ )
225
+ df <- clean_colnames(df)
226
+
227
+ multi_df <- data.frame()
228
+ if (file.exists("all_players_enriched_multiseason.csv")) {
229
+ multi_df <- tryCatch(
230
+ read_csv("all_players_enriched_multiseason.csv",
231
+ locale = locale(encoding = "latin1"),
232
+ show_col_types = FALSE),
233
+ error = function(e) data.frame()
234
+ )
235
+ multi_df <- clean_colnames(multi_df)
236
  }
237
 
238
  if (PLAYER_COL %in% names(df)) {
239
+ df[["_player_key"]] <- clean_player_key(df[[PLAYER_COL]])
240
  }
241
 
 
242
  if (nrow(multi_df) > 0) {
243
+ multi_player_col <- NULL
244
  for (pc in c("player_name", "player", "name")) {
245
+ if (pc %in% names(multi_df)) {
246
+ multi_player_col <- pc
247
+ break
248
+ }
249
  }
250
  if (!is.null(multi_player_col)) {
251
+ multi_df[["_player_key"]] <- clean_player_key(multi_df[[multi_player_col]])
252
  } else {
253
+ multi_df[["_player_key"]] <- ""
254
  }
255
 
256
  hist_suffixes <- c("_2122", "_2223", "_2324", "_2425")
257
+ hist_cols <- names(multi_df)[sapply(names(multi_df), function(cn) {
258
+ any(endsWith(cn, hist_suffixes))
259
+ })]
260
 
261
  if (length(hist_cols) > 0 && "_player_key" %in% names(multi_df)) {
262
+ multi_keep <- multi_df[, c("_player_key", hist_cols), drop = FALSE]
263
+ multi_keep <- multi_keep[!duplicated(multi_keep[["_player_key"]]), ]
 
 
264
  overlap <- hist_cols[hist_cols %in% names(df)]
265
+ if (length(overlap) > 0) {
266
+ df <- df[, !names(df) %in% overlap, drop = FALSE]
267
+ }
268
+ df <- merge(df, multi_keep, by = "_player_key", all.x = TRUE)
269
  }
270
  }
271
 
272
  # Coerce numeric columns
273
+ all_num_cols <- unique(c(
274
  KEY_METRICS, ATTR_COLS, POSITION_SCORE_COLS, ARCHETYPE_SCORE_COLS,
275
  AGE_COL, HEIGHT_COL, WEIGHT_COL, MINUTES_COL, MARKET_VALUE_COL,
276
  ATTAINABILITY_COL, TARGET_SCORE_COL, ARCHETYPE_SCORE_COL,
277
+ CLUB_RANK_COL, MATCH_TOUGHNESS_COL, ELO_COL
278
+ ))
279
+ all_num_cols <- available_cols(all_num_cols, df)
280
 
281
+ hist_num_cols <- names(df)[sapply(names(df), function(cn) {
282
+ any(endsWith(cn, paste0("_", names(HISTORICAL_SEASONS))))
283
+ })]
284
 
285
+ for (col in unique(c(all_num_cols, hist_num_cols))) {
286
  df[[col]] <- suppressWarnings(as.numeric(df[[col]]))
287
  }
288
 
289
+ list(df = df, multi_df = multi_df)
290
  }
291
 
292
  # ============================================================
 
294
  # ============================================================
295
 
296
  get_player_row <- function(df, player) {
297
+ if (is.null(player) || nchar(trimws(player)) == 0) return(NULL)
298
+ if (!PLAYER_COL %in% names(df)) return(NULL)
299
  rows <- df[as.character(df[[PLAYER_COL]]) == as.character(player), ]
300
  if (nrow(rows) == 0) return(NULL)
301
  as.list(rows[1, ])
 
306
  pos <- row[[POSITION_COL]]
307
  group <- df
308
  if (COMP_COL %in% names(df) && POSITION_COL %in% names(df) &&
309
+ !is.null(comp) && !is.na(comp) &&
310
+ !is.null(pos) && !is.na(pos)) {
311
+ sub <- df[df[[COMP_COL]] == comp & df[[POSITION_COL]] == pos, ]
312
+ if (nrow(sub) > 0) group <- sub
313
  }
 
314
  group
315
  }
316
 
317
  top_attr_cols <- function(df, row = NULL, max_cols = 8) {
318
+ cols <- available_cols(ATTR_COLS, df)
319
  if (!is.null(row)) {
320
+ cols <- cols[sapply(cols, function(cn) {
321
+ v <- row[[cn]]
322
+ !is.null(v) && length(v) > 0 && !is.na(v)
323
  })]
324
  }
325
  head(cols, max_cols)
326
  }
327
 
328
  # ============================================================
329
+ # PERFORMANCE OVER TIME
330
  # ============================================================
331
 
332
  historical_candidate_columns <- function(base_metric, season_code) {
333
+ short_metric <- gsub("player_season_", "", base_metric)
334
  candidates <- c(
335
  paste0(base_metric, "_", season_code),
336
  paste0(short_metric, "_", season_code)
337
  )
338
+ if (endsWith(short_metric, "_90")) {
339
+ no_90 <- sub("_90$", "", short_metric)
340
  candidates <- c(candidates,
341
  paste0(no_90, "_90_", season_code),
342
  paste0(no_90, "_per_90_", season_code),
343
+ paste0(no_90, "_p90_", season_code)
344
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  }
346
  if (base_metric %in% POSITION_SCORE_COLS) {
347
+ pos_code <- sub("_score", "", base_metric)
348
  candidates <- c(candidates,
349
  paste0(pos_code, "_score_", season_code),
350
+ paste0(pos_code, "_", season_code)
351
+ )
352
  }
353
+ unique(tolower(candidates))
354
  }
355
 
356
  find_metric_value <- function(row, base_metric, season_code = NULL) {
357
  if (is.null(row)) return(NA_real_)
358
  if (is.null(season_code)) {
359
  v <- row[[base_metric]]
360
+ if (is.null(v)) return(NA_real_)
361
+ return(suppressWarnings(as.numeric(v)))
362
  }
363
  for (col in historical_candidate_columns(base_metric, season_code)) {
364
  v <- row[[col]]
365
+ if (!is.null(v) && length(v) > 0 && !is.na(v)) {
366
+ return(suppressWarnings(as.numeric(v)))
367
+ }
368
  }
369
  NA_real_
370
  }
 
373
  if (is.null(multi_df) || nrow(multi_df) == 0) return(NULL)
374
  pk <- clean_player_key(player)
375
  if (!"_player_key" %in% names(multi_df)) return(NULL)
376
+ matches <- multi_df[multi_df[["_player_key"]] == pk, ]
377
  if (nrow(matches) == 0) return(NULL)
378
  as.list(matches[1, ])
379
  }
 
385
  hist_exists <- FALSE
386
  for (sc in names(HISTORICAL_SEASONS)) {
387
  for (cand in historical_candidate_columns(m, sc)) {
388
+ if (cand %in% names(df)) {
389
+ hist_exists <- TRUE
390
+ break
391
+ }
392
+ if (!is.null(multi_df) && nrow(multi_df) > 0 && cand %in% names(multi_df)) {
393
+ hist_exists <- TRUE
394
+ break
395
  }
396
  }
397
  if (hist_exists) break
398
  }
399
+ if (current_exists || hist_exists) {
400
+ options[pretty_label(m)] <- m
401
+ }
402
  }
403
  options
404
  }
 
409
 
410
  ui <- fluidPage(
411
  theme = shinytheme("flatly"),
412
+ tags$head(tags$style(HTML(
413
+ ".container-fluid { max-width: 98%; }
414
+ table.dataTable { width: 100% !important; }
415
+ .dataTables_wrapper { overflow-x: auto; }
416
+ th, td { white-space: nowrap; }"
417
+ ))),
 
 
418
  titlePanel("Oldham Athletic Player Scouting"),
 
419
  tabsetPanel(id = "main_tabs",
420
 
 
421
  tabPanel("Player Search",
422
  br(),
423
+ h4("Search and Filter Players"),
424
  fluidRow(
425
  column(4, textInput("search_box", "Search Player Name", "")),
426
  column(4, selectizeInput("competition_filter", "Competition",
 
442
  actionButton("search_btn", "Search Players", class = "btn-primary"),
443
  br(), br(),
444
  DTOutput("search_results"),
445
+ verbatimTextOutput("search_status")
446
  ),
447
 
 
448
  tabPanel("Player Profile",
449
  br(),
450
+ h4("Full Player Profile"),
451
+ selectizeInput("selected_player", "Select Player",
452
+ choices = NULL, width = "100%"),
453
  fluidRow(
454
  column(8, uiOutput("profile_output")),
455
+ column(4, h5("Key Performance Summary"), DTOutput("key_summary"))
 
 
 
456
  ),
457
  br(),
458
+ h4("Player Metrics"),
459
  selectInput("metric_group", "Metric Group",
460
+ choices = c("Attributes", "Position Scores",
461
+ "Archetype Scores", "Key Season Stats"),
462
  selected = "Attributes"),
463
  DTOutput("metric_table"),
464
  br(),
 
470
  fluidRow(
471
  column(6, selectInput("profile_metric", "Performance Metric Over Time",
472
  choices = NULL)),
473
+ column(6, br(), actionButton("trend_btn", "Show Performance Chart",
474
+ class = "btn-info"))
475
  ),
476
  plotlyOutput("trend_plot"),
477
  br(),
478
  textAreaInput("scout_notes", "Scout Notes", rows = 4,
479
  placeholder = "Enter notes to include in the scouting report."),
480
  fluidRow(
481
+ column(4, downloadButton("report_btn", "Download Scouting Report (CSV)")),
482
+ column(4, actionButton("shortlist_btn", "Add to Shortlist",
483
+ class = "btn-success"))
484
  ),
485
  br(),
486
  DTOutput("shortlist_from_profile")
487
  ),
488
 
 
489
  tabPanel("Player Comparison Tool",
490
  br(),
491
+ h4("Compare Up To Three Players"),
492
  fluidRow(
493
  column(4, selectizeInput("compare_1", "Player 1", choices = NULL)),
494
  column(4, selectizeInput("compare_2", "Player 2", choices = NULL)),
 
501
  plotlyOutput("comparison_radar", height = "550px")
502
  ),
503
 
 
504
  tabPanel("Fit Score Calculator",
505
  br(),
506
+ h4("Fit Score Calculator"),
 
507
  fluidRow(
508
+ column(6, selectizeInput("fit_competition_filter",
509
+ "Competitions to Search", choices = NULL, multiple = TRUE)),
510
+ column(6, selectizeInput("fit_position_filter",
511
+ "Positions to Search", choices = NULL, multiple = TRUE))
512
  ),
513
  fluidRow(
514
  column(4, sliderInput("pressing_w", "Pressing", 0, 10, 5, step = 1)),
 
516
  column(4, sliderInput("aerial_w", "Aerial", 0, 10, 4, step = 1))
517
  ),
518
  fluidRow(
519
+ column(4, sliderInput("possession_w", "Possession Retention",
520
+ 0, 10, 5, step = 1)),
521
  column(4, sliderInput("blocking_w", "Blocking", 0, 10, 4, step = 1)),
522
+ column(4, sliderInput("progression_w", "Progression",
523
+ 0, 10, 6, step = 1))
524
  ),
525
  fluidRow(
526
  column(4, sliderInput("impact_w", "Impact", 0, 10, 6, step = 1)),
527
+ column(4, sliderInput("discipline_w", "Discipline",
528
+ 0, 10, 3, step = 1)),
529
  column(4, sliderInput("dribbling_w", "Dribbling", 0, 10, 4, step = 1))
530
  ),
531
  fluidRow(
532
+ column(4, sliderInput("chance_w", "Chance Creation",
533
+ 0, 10, 5, step = 1)),
534
  column(4, sliderInput("finishing_w", "Finishing", 0, 10, 3, step = 1)),
535
  column(4, sliderInput("crossing_w", "Crossing", 0, 10, 3, step = 1))
536
  ),
 
542
  fluidRow(
543
  column(4, sliderInput("attain_w", "Attainability", 0, 10, 6, step = 1))
544
  ),
545
+ actionButton("fit_btn", "Generate Ranked Recommendations",
546
+ class = "btn-primary"),
547
  br(), br(),
548
  DTOutput("fit_table")
549
  ),
550
 
 
551
  tabPanel("Similar Player Finder",
552
  br(),
553
+ h4("Find Similar Players"),
554
+ selectizeInput("similar_player_select", "Select Player",
555
+ choices = NULL, width = "60%"),
556
+ actionButton("similar_btn", "Find Similar Players",
557
+ class = "btn-primary"),
558
  br(), br(),
559
  DTOutput("similar_table")
560
  ),
561
 
 
562
  tabPanel("Shortlist Manager",
563
  br(),
564
+ h4("Shortlist Manager"),
565
  fluidRow(
566
+ column(4, selectizeInput("shortlist_player", "Add Player",
567
+ choices = NULL)),
568
+ column(2, br(), actionButton("add_shortlist_btn", "Add to Shortlist",
569
+ class = "btn-success")),
570
+ column(2, br(), actionButton("clear_shortlist_btn", "Clear Shortlist",
571
+ class = "btn-danger")),
572
  column(2, br(), downloadButton("export_shortlist_btn", "Export CSV"))
573
  ),
574
  br(),
 
583
 
584
  server <- function(input, output, session) {
585
 
 
586
  app_data <- tryCatch(load_data(), error = function(e) {
587
+ showNotification(paste("Error loading data:", e$message),
588
+ type = "error", duration = NULL)
589
+ list(df = data.frame(), multi_df = data.frame())
590
  })
591
 
592
  df <- app_data$df
593
  multi_df <- app_data$multi_df
 
 
594
  shortlist <- reactiveVal(character(0))
595
 
 
596
  observe({
597
  req(nrow(df) > 0)
598
 
599
+ comp_opts <- if (COMP_COL %in% names(df))
600
+ sort(unique(na.omit(as.character(df[[COMP_COL]])))) else character(0)
601
+ team_opts <- if (TEAM_COL %in% names(df))
602
+ sort(unique(na.omit(as.character(df[[TEAM_COL]])))) else character(0)
603
+ pos_opts <- if (POSITION_COL %in% names(df))
604
+ sort(unique(na.omit(as.character(df[[POSITION_COL]])))) else character(0)
605
+ country_opts <- if (COUNTRY_COL %in% names(df))
606
+ sort(unique(na.omit(as.character(df[[COUNTRY_COL]])))) else character(0)
607
+
608
+ base_cols <- available_cols(c(PLAYER_COL, POSITION_COL, TEAM_COL), df)
609
+ player_rows <- unique(df[, base_cols, drop = FALSE])
610
+ pnames <- as.character(player_rows[[PLAYER_COL]])
611
+ ppos <- if (POSITION_COL %in% names(player_rows))
612
+ as.character(player_rows[[POSITION_COL]]) else rep("", nrow(player_rows))
613
+ pteam <- if (TEAM_COL %in% names(player_rows))
614
+ as.character(player_rows[[TEAM_COL]]) else rep("", nrow(player_rows))
615
+ labels <- paste0(pnames, " | ", ppos, " | ", pteam)
616
+ player_choices <- setNames(pnames, labels)
617
  player_choices <- player_choices[order(names(player_choices))]
618
 
619
  perf_opts <- build_performance_metric_options(df, multi_df)
620
 
621
+ updateSelectizeInput(session, "competition_filter",
622
+ choices = comp_opts, server = TRUE)
623
+ updateSelectizeInput(session, "team_filter",
624
+ choices = team_opts, server = TRUE)
625
+ updateSelectizeInput(session, "position_filter",
626
+ choices = pos_opts, server = TRUE)
627
+ updateSelectizeInput(session, "country_filter",
628
+ choices = country_opts, server = TRUE)
629
+ updateSelectizeInput(session, "selected_player",
630
+ choices = player_choices, server = TRUE)
631
+ updateSelectizeInput(session, "compare_1",
632
+ choices = c("" = "", player_choices), server = TRUE)
633
+ updateSelectizeInput(session, "compare_2",
634
+ choices = c("" = "", player_choices), server = TRUE)
635
+ updateSelectizeInput(session, "compare_3",
636
+ choices = c("" = "", player_choices), server = TRUE)
637
+ updateSelectizeInput(session, "fit_competition_filter",
638
+ choices = comp_opts, server = TRUE)
639
+ updateSelectizeInput(session, "fit_position_filter",
640
+ choices = pos_opts, server = TRUE)
641
+ updateSelectizeInput(session, "similar_player_select",
642
+ choices = player_choices, server = TRUE)
643
+ updateSelectizeInput(session, "shortlist_player",
644
+ choices = player_choices, server = TRUE)
645
  updateSelectInput(session, "profile_metric", choices = perf_opts)
646
  })
647
 
 
648
  output$age_slider_ui <- renderUI({
649
+ age_min <- if (AGE_COL %in% names(df) && any(!is.na(df[[AGE_COL]])))
650
+ floor(min(df[[AGE_COL]], na.rm = TRUE)) else 15
651
+ age_max <- if (AGE_COL %in% names(df) && any(!is.na(df[[AGE_COL]])))
652
+ ceiling(max(df[[AGE_COL]], na.rm = TRUE)) else 45
653
  tagList(
654
+ sliderInput("min_age_filter", "Minimum Age",
655
+ age_min, age_max, age_min, step = 1),
656
+ sliderInput("max_age_filter", "Maximum Age",
657
+ age_min, age_max, age_max, step = 1)
658
  )
659
  })
660
 
661
  output$minutes_slider_ui <- renderUI({
662
+ mx <- if (MINUTES_COL %in% names(df) && any(!is.na(df[[MINUTES_COL]])))
663
+ ceiling(max(df[[MINUTES_COL]], na.rm = TRUE)) else 5000
664
+ sliderInput("minutes_filter", "Minimum Minutes", 0, mx, 0, step = 100)
665
  })
666
 
667
  # ---- SEARCH ----
668
  search_result_df <- eventReactive(input$search_btn, {
669
  data <- df
670
+ search_term <- input$search_box
671
+ if (!is.null(search_term) && nchar(trimws(search_term)) > 0 &&
672
+ PLAYER_COL %in% names(data)) {
673
+ data <- data[grepl(search_term, as.character(data[[PLAYER_COL]]),
674
+ ignore.case = TRUE), ]
675
  }
676
+ if (length(input$competition_filter) > 0 && COMP_COL %in% names(data)) {
677
  data <- data[data[[COMP_COL]] %in% input$competition_filter, ]
678
+ }
679
+ if (length(input$team_filter) > 0 && TEAM_COL %in% names(data)) {
680
  data <- data[data[[TEAM_COL]] %in% input$team_filter, ]
681
+ }
682
+ if (length(input$position_filter) > 0 && POSITION_COL %in% names(data)) {
683
  data <- data[data[[POSITION_COL]] %in% input$position_filter, ]
684
+ }
685
+ if (length(input$country_filter) > 0 && COUNTRY_COL %in% names(data)) {
686
  data <- data[data[[COUNTRY_COL]] %in% input$country_filter, ]
687
+ }
688
  min_age <- if (!is.null(input$min_age_filter)) input$min_age_filter else -Inf
689
+ max_age <- if (!is.null(input$max_age_filter)) input$max_age_filter else Inf
690
+ if (AGE_COL %in% names(data)) {
691
  data <- data[!is.na(data[[AGE_COL]]) &
692
+ data[[AGE_COL]] >= min_age & data[[AGE_COL]] <= max_age, ]
693
+ }
694
  min_min <- if (!is.null(input$minutes_filter)) input$minutes_filter else 0
695
+ if (MINUTES_COL %in% names(data)) {
696
+ data <- data[!is.na(data[[MINUTES_COL]]) &
697
+ data[[MINUTES_COL]] >= min_min, ]
698
+ }
699
  cols <- available_cols(SEARCH_TABLE_COLS, data)
700
  out <- data[, cols, drop = FALSE]
701
  if (nrow(out) == 0) return(data.frame(Message = "No players found."))
702
+ sort_col <- if (TARGET_SCORE_COL %in% names(out)) TARGET_SCORE_COL
703
+ else ATTAINABILITY_COL
704
+ if (sort_col %in% names(out)) {
705
  out <- out[order(-out[[sort_col]], na.last = TRUE), ]
706
+ }
707
+ pretty_df(out)
708
  })
709
 
710
  output$search_results <- renderDT({
711
  req(search_result_df())
712
  datatable(search_result_df(), selection = "single", rownames = FALSE,
713
+ options = list(scrollX = TRUE, pageLength = 25))
714
  })
715
 
716
  output$search_status <- renderText({
717
  sel <- input$search_results_rows_selected
718
  if (!is.null(sel) && length(sel) > 0) {
719
+ d <- search_result_df()
720
+ if ("Player" %in% names(d)) {
721
+ player <- d[sel, "Player"]
722
+ updateSelectizeInput(session, "selected_player", selected = player)
723
+ return(paste("Loaded", player, "into Player Profile tab."))
724
+ }
725
  }
726
+ "Click a player row to load them into the Player Profile tab."
727
  })
728
 
729
+ # ---- PROFILE ----
730
  current_player_row <- reactive({
731
  get_player_row(df, input$selected_player)
732
  })
 
739
  h4(paste0(row[[TEAM_COL]], " | ", row[[COMP_COL]])),
740
  h4("Player Details"),
741
  tags$ul(
742
+ tags$li(strong("Primary Position: "),
743
+ clean_value(row[[POSITION_COL]])),
744
+ tags$li(strong("Secondary Position: "),
745
+ clean_value(row[[SECONDARY_POSITION_COL]])),
746
  tags$li(strong("Age: "), clean_value(row[[AGE_COL]])),
747
  tags$li(strong("Country: "), clean_value(row[[COUNTRY_COL]])),
748
+ tags$li(strong("Height: "),
749
+ paste0(clean_value(row[[HEIGHT_COL]]), " cm")),
750
+ tags$li(strong("Weight: "),
751
+ paste0(clean_value(row[[WEIGHT_COL]]), " kg")),
752
+ tags$li(strong("Market Value: "),
753
+ format_money(row[[MARKET_VALUE_COL]])),
754
  tags$li(strong("Contract: "), clean_value(row[[CONTRACT_COL]])),
755
  tags$li(strong("Minutes: "), clean_value(row[[MINUTES_COL]]))
756
  )
 
759
 
760
  output$key_summary <- renderDT({
761
  row <- current_player_row()
762
+ if (is.null(row)) {
763
+ return(datatable(data.frame(Metric = "Select a player", Value = "")))
764
+ }
765
  out <- data.frame(
766
+ Metric = c("Best Archetype", "Best Archetype Score", "Target Score",
767
+ "Attainability", "Club Rank", "Match Toughness", "Club ELO"),
768
+ Value = c(
769
+ clean_value(row[[ARCHETYPE_COL]]),
770
+ clean_value(row[[ARCHETYPE_SCORE_COL]]),
771
+ clean_value(row[[TARGET_SCORE_COL]]),
772
+ clean_value(row[[ATTAINABILITY_COL]]),
773
+ clean_value(row[[CLUB_RANK_COL]]),
774
+ clean_value(row[[MATCH_TOUGHNESS_COL]]),
775
+ clean_value(row[[ELO_COL]])
776
+ ),
777
+ stringsAsFactors = FALSE
778
  )
779
+ datatable(out, rownames = FALSE,
780
+ options = list(dom = "t", paging = FALSE))
781
  })
782
 
783
  output$metric_table <- renderDT({
784
  row <- current_player_row()
785
+ if (is.null(row)) {
786
+ return(datatable(data.frame(Metric = "Select a player", Score = "")))
787
+ }
788
  cols <- switch(input$metric_group,
789
  "Attributes" = ATTR_COLS,
790
  "Position Scores" = POSITION_SCORE_COLS,
 
793
  ATTR_COLS
794
  )
795
  cols <- available_cols(cols, df)
796
+ rows_list <- list()
797
+ for (cn in cols) {
798
+ v <- row[[cn]]
799
+ if (!is.null(v) && length(v) > 0 && !is.na(v)) {
800
+ rows_list[[length(rows_list) + 1]] <- data.frame(
801
+ Metric = pretty_label(cn),
802
+ Score = round(as.numeric(v), 2),
803
+ stringsAsFactors = FALSE
804
+ )
805
+ }
806
+ }
807
+ if (length(rows_list) == 0) {
808
+ return(datatable(data.frame(Metric = "No metrics available", Score = NA)))
809
+ }
810
+ out <- do.call(rbind, rows_list)
811
  out <- out[order(-out$Score, na.last = TRUE), ]
812
+ datatable(out, rownames = FALSE,
813
+ options = list(scrollX = TRUE, pageLength = 25))
814
  })
815
 
816
  output$radar_plot <- renderPlotly({
817
  row <- current_player_row()
818
+ if (is.null(row)) {
819
+ return(plot_ly() %>% layout(title = "Select a player"))
820
+ }
821
  metrics <- top_attr_cols(df, row, max_cols = 8)
822
+ if (length(metrics) < 3) {
823
+ return(plot_ly() %>% layout(title = "Not enough attributes"))
824
+ }
825
  group <- get_player_group(df, row)
826
  labels <- sapply(metrics, pretty_label)
827
  player_vals <- sapply(metrics, function(m) {
828
+ v <- row[[m]]
829
+ if (is.null(v) || is.na(v)) 0 else as.numeric(v)
830
  })
831
  avg_vals <- sapply(metrics, function(m) {
832
  if (m %in% names(group)) mean(group[[m]], na.rm = TRUE) else 0
833
  })
834
+ max_val <- max(100, max(c(player_vals, avg_vals), na.rm = TRUE) * 1.1)
835
+ plot_ly(type = "scatterpolar", fill = "toself") %>%
836
  add_trace(r = c(player_vals, player_vals[1]),
837
+ theta = c(labels, labels[1]),
838
+ name = as.character(input$selected_player)) %>%
839
  add_trace(r = c(avg_vals, avg_vals[1]),
840
+ theta = c(labels, labels[1]),
841
+ name = "Position/Competition Avg") %>%
842
+ layout(
843
+ title = paste(input$selected_player, "Attribute Radar"),
844
+ polar = list(radialaxis = list(range = c(0, max_val))),
845
+ legend = list(orientation = "h")
846
+ )
847
  })
848
 
849
  output$percentile_plot <- renderPlotly({
850
  row <- current_player_row()
851
+ if (is.null(row)) {
852
+ return(plot_ly() %>% layout(title = "Select a player"))
853
+ }
854
  group <- get_player_group(df, row)
855
+ rows_list <- list()
856
+ for (m in available_cols(c(ATTR_COLS, TARGET_SCORE_COL,
857
+ ATTAINABILITY_COL, ARCHETYPE_SCORE_COL), df)) {
858
+ v <- suppressWarnings(as.numeric(row[[m]]))
859
+ vals <- suppressWarnings(as.numeric(group[[m]]))
860
+ vals <- vals[!is.na(vals)]
861
+ if (!is.na(v) && length(vals) > 1) {
862
  pct <- mean(vals < v, na.rm = TRUE) * 100
863
+ rows_list[[length(rows_list) + 1]] <- data.frame(
864
+ Metric = pretty_label(m),
865
+ Percentile = round(pct, 1),
866
+ stringsAsFactors = FALSE
867
+ )
868
  }
869
+ }
870
+ if (length(rows_list) == 0) {
871
+ return(plot_ly() %>% layout(title = "No percentile data"))
872
+ }
873
+ plot_df <- do.call(rbind, rows_list)
874
  plot_df <- plot_df[order(plot_df$Percentile), ]
875
+ plot_ly(plot_df,
876
+ x = ~Percentile, y = ~Metric, type = "bar", orientation = "h",
877
+ text = ~paste0(Percentile, "%"), textposition = "outside") %>%
878
+ layout(
879
+ title = paste(input$selected_player, "Percentiles"),
880
+ xaxis = list(range = c(0, 110)),
881
+ yaxis = list(title = ""),
882
+ height = max(450, 32 * nrow(plot_df))
883
+ )
884
  })
885
 
886
  observeEvent(input$trend_btn, {
 
 
 
 
887
  output$trend_plot <- renderPlotly({
888
+ row <- current_player_row()
889
+ multi_row <- get_multiseason_row(multi_df, input$selected_player)
890
+ metric <- input$profile_metric
891
+ if (is.null(row) || is.null(metric) || metric == "") {
892
+ return(plot_ly() %>% layout(title = "Select a player and metric."))
893
+ }
894
+ rows_list <- list()
895
+ for (sc in names(HISTORICAL_SEASONS)) {
896
  v <- NA_real_
897
  if (!is.null(multi_row)) v <- find_metric_value(multi_row, metric, sc)
898
  if (is.na(v)) v <- find_metric_value(row, metric, sc)
899
+ if (!is.na(v)) {
900
+ rows_list[[length(rows_list) + 1]] <- data.frame(
901
+ Season = HISTORICAL_SEASONS[sc],
902
+ Score = v,
903
+ stringsAsFactors = FALSE
904
+ )
905
+ }
906
+ }
907
  curr_val <- find_metric_value(row, metric, NULL)
908
+ plot_df <- if (length(rows_list) > 0) do.call(rbind, rows_list)
909
+ else data.frame(Season = character(0), Score = numeric(0))
910
  if (!is.na(curr_val)) {
911
  plot_df <- plot_df[plot_df$Season != CURRENT_MAIN_SEASON_LABEL, ]
912
+ plot_df <- rbind(plot_df, data.frame(
913
+ Season = CURRENT_MAIN_SEASON_LABEL,
914
+ Score = curr_val,
915
+ stringsAsFactors = FALSE
916
+ ))
917
+ }
918
+ if (nrow(plot_df) == 0) {
919
+ return(plot_ly() %>% layout(title = "No performance data found."))
920
  }
921
+ season_order <- c("2021-22", "2022-23", "2023-24", "2024-25", "2025-26")
 
 
922
  plot_df$Season <- factor(plot_df$Season, levels = season_order)
923
  plot_df <- plot_df[order(plot_df$Season), ]
924
+ plot_ly(plot_df, x = ~Season, y = ~Score,
925
+ type = "scatter", mode = "lines+markers+text",
926
+ text = ~round(Score, 2), textposition = "top center") %>%
927
+ layout(title = paste0(input$selected_player, ": ",
928
+ pretty_label(metric), " Over Time"))
929
  })
930
  })
931
 
 
932
  output$report_btn <- downloadHandler(
933
  filename = function() {
934
+ safe <- gsub("[^A-Za-z0-9_]", "_", input$selected_player)
935
  paste0(safe, "_scouting_report.csv")
936
  },
937
  content = function(file) {
938
  row <- current_player_row()
939
  if (is.null(row)) {
940
+ write.csv(data.frame(Message = "No player selected"),
941
+ file, row.names = FALSE)
942
  return()
943
  }
944
+ all_cols <- available_cols(c(
945
+ PLAYER_COL, TEAM_COL, COMP_COL, POSITION_COL,
946
+ AGE_COL, COUNTRY_COL, HEIGHT_COL, WEIGHT_COL,
947
+ MARKET_VALUE_COL, CONTRACT_COL, MINUTES_COL,
948
+ ARCHETYPE_COL, ARCHETYPE_SCORE_COL, TARGET_SCORE_COL,
949
+ ATTAINABILITY_COL, CLUB_RANK_COL, MATCH_TOUGHNESS_COL,
950
+ ELO_COL, ATTR_COLS, KEY_METRICS,
951
+ POSITION_SCORE_COLS, ARCHETYPE_SCORE_COLS
952
+ ), df)
953
+ out <- df[as.character(df[[PLAYER_COL]]) ==
954
+ as.character(input$selected_player), all_cols, drop = FALSE]
955
  write.csv(out, file, row.names = FALSE)
956
  }
957
  )
958
 
 
959
  observeEvent(input$shortlist_btn, {
960
  p <- input$selected_player
961
+ if (!is.null(p) && nchar(trimws(p)) > 0 && !p %in% shortlist()) {
962
  shortlist(c(shortlist(), p))
963
  }
964
  })
965
 
966
  view_shortlist <- reactive({
967
  sl <- shortlist()
968
+ if (length(sl) == 0) {
969
+ return(data.frame(Message = "No players added yet.",
970
+ stringsAsFactors = FALSE))
971
+ }
972
  data <- df[as.character(df[[PLAYER_COL]]) %in% sl, ]
973
  cols <- available_cols(SHORTLIST_COLS, data)
974
  out <- data[, cols, drop = FALSE]
975
+ if (nrow(out) == 0) {
976
+ return(data.frame(Message = "Shortlist is empty.",
977
+ stringsAsFactors = FALSE))
978
+ }
979
+ pretty_df(out)
980
  })
981
 
982
  output$shortlist_from_profile <- renderDT({
983
  datatable(view_shortlist(), rownames = FALSE,
984
+ options = list(scrollX = TRUE, pageLength = 15))
985
  })
986
 
987
  # ---- COMPARISON ----
988
  comparison_df <- eventReactive(input$compare_btn, {
989
  players <- c(input$compare_1, input$compare_2, input$compare_3)
990
+ players <- players[!is.null(players) & nchar(trimws(players)) > 0]
991
+ if (length(players) == 0) {
992
+ return(data.frame(Message = "Select at least one player.",
993
+ stringsAsFactors = FALSE))
994
+ }
995
  data <- df[as.character(df[[PLAYER_COL]]) %in% players, ]
996
  cols <- available_cols(COMPARISON_COLS, data)
997
+ pretty_df(data[, cols, drop = FALSE])
998
  })
999
 
1000
  output$comparison_table <- renderDT({
1001
  datatable(comparison_df(), selection = "single", rownames = FALSE,
1002
+ options = list(scrollX = TRUE, pageLength = 25))
1003
  })
1004
 
1005
  output$comparison_radar <- renderPlotly({
1006
  players <- c(input$compare_1, input$compare_2, input$compare_3)
1007
+ players <- players[!is.null(players) & nchar(trimws(players)) > 0]
1008
+ if (length(players) == 0) {
1009
+ return(plot_ly() %>% layout(title = "Select players to compare."))
1010
+ }
1011
  first_row <- get_player_row(df, players[1])
1012
  if (is.null(first_row)) return(plot_ly())
1013
  metrics <- top_attr_cols(df, first_row, max_cols = 8)
1014
+ if (length(metrics) < 3) {
1015
+ return(plot_ly() %>% layout(title = "Not enough attributes."))
1016
+ }
1017
  labels <- sapply(metrics, pretty_label)
1018
  fig <- plot_ly(type = "scatterpolar", fill = "toself")
1019
  for (p in players) {
1020
  row <- get_player_row(df, p)
1021
  if (!is.null(row)) {
1022
  vals <- sapply(metrics, function(m) {
1023
+ v <- row[[m]]
1024
+ if (is.null(v) || is.na(v)) 0 else as.numeric(v)
1025
  })
1026
+ fig <- fig %>% add_trace(
1027
+ r = c(vals, vals[1]),
1028
+ theta = c(labels, labels[1]),
1029
+ name = p
1030
+ )
1031
  }
1032
  }
1033
+ fig %>% layout(
1034
+ title = "Player Attribute Radar Comparison",
1035
+ polar = list(radialaxis = list(range = c(0, 110))),
1036
+ legend = list(orientation = "h")
1037
+ )
1038
  })
1039
 
1040
  # ---- FIT SCORE ----
1041
  fit_result_df <- eventReactive(input$fit_btn, {
1042
  data <- df
1043
+ if (length(input$fit_competition_filter) > 0 && COMP_COL %in% names(data)) {
1044
  data <- data[data[[COMP_COL]] %in% input$fit_competition_filter, ]
1045
+ }
1046
+ if (length(input$fit_position_filter) > 0 && POSITION_COL %in% names(data)) {
1047
  data <- data[data[[POSITION_COL]] %in% input$fit_position_filter, ]
1048
+ }
1049
+ if (nrow(data) == 0) {
1050
+ return(data.frame(Message = "No players found for selected filters.",
1051
+ stringsAsFactors = FALSE))
1052
+ }
1053
+ weight_cols <- c(
1054
+ "attr_pressing", "attr_duels", "attr_aerial",
1055
+ "attr_possession_retention", "attr_blocking", "attr_progression",
1056
+ "attr_impact", "attr_discipline", "attr_dribbling",
1057
+ "attr_chance_creation", "attr_finishing", "attr_crossing",
1058
+ "attr_box_presence", "attr_holdup",
1059
+ TARGET_SCORE_COL, ATTAINABILITY_COL
1060
  )
1061
+ weight_vals <- c(
1062
+ input$pressing_w, input$duels_w, input$aerial_w,
1063
+ input$possession_w, input$blocking_w, input$progression_w,
1064
+ input$impact_w, input$discipline_w, input$dribbling_w,
1065
+ input$chance_w, input$finishing_w, input$crossing_w,
1066
+ input$box_w, input$holdup_w,
1067
+ input$target_w, input$attain_w
1068
+ )
1069
+ weights <- setNames(weight_vals, weight_cols)
1070
  total_weight <- sum(weights)
1071
+ if (total_weight == 0) {
1072
+ return(data.frame(Message = "At least one weight must be above 0.",
1073
+ stringsAsFactors = FALSE))
1074
+ }
1075
  fit_vals <- rep(0, nrow(data))
1076
  for (col in names(weights)) {
1077
  w <- weights[col]
 
1079
  fit_vals <- fit_vals + normalize_0_100(data[[col]]) * w
1080
  }
1081
  }
1082
+ data[["fit_score"]] <- fit_vals / total_weight
1083
+ cols <- c(available_cols(c(
1084
+ PLAYER_COL, POSITION_COL, TEAM_COL, COMP_COL,
1085
+ AGE_COL, MINUTES_COL, MARKET_VALUE_COL, CONTRACT_COL,
1086
+ ARCHETYPE_COL, ARCHETYPE_SCORE_COL,
1087
+ TARGET_SCORE_COL, ATTAINABILITY_COL
1088
+ ), data), "fit_score")
1089
+ out <- data[order(-data[["fit_score"]], na.last = TRUE), cols, drop = FALSE]
1090
+ pretty_df(head(out, 50))
1091
  })
1092
 
1093
  output$fit_table <- renderDT({
1094
  datatable(fit_result_df(), selection = "single", rownames = FALSE,
1095
+ options = list(scrollX = TRUE, pageLength = 25))
1096
  })
1097
 
1098
  # ---- SIMILAR PLAYERS ----
1099
  similar_result_df <- eventReactive(input$similar_btn, {
1100
  row <- get_player_row(df, input$similar_player_select)
1101
+ if (is.null(row)) {
1102
+ return(data.frame(Message = "Select a player.", stringsAsFactors = FALSE))
1103
+ }
1104
+ metrics <- available_cols(
1105
+ c(ATTR_COLS, TARGET_SCORE_COL, ATTAINABILITY_COL, ARCHETYPE_SCORE_COL), df)
1106
  metrics <- metrics[sapply(metrics, function(m) {
1107
+ v <- row[[m]]
1108
+ !is.null(v) && length(v) > 0 && !is.na(v)
1109
  })]
1110
  metrics <- head(metrics, 24)
1111
+ if (length(metrics) == 0) {
1112
+ return(data.frame(Message = "No similarity metrics available.",
1113
+ stringsAsFactors = FALSE))
1114
+ }
1115
  pos <- row[[POSITION_COL]]
1116
+ candidates <- df[as.character(df[[PLAYER_COL]]) !=
1117
+ as.character(input$similar_player_select), ]
1118
  if (POSITION_COL %in% names(df) && !is.null(pos) && !is.na(pos)) {
1119
  sub <- candidates[candidates[[POSITION_COL]] == pos, ]
1120
  if (nrow(sub) > 0) candidates <- sub
1121
  }
1122
  dist_vals <- rep(0, nrow(candidates))
1123
  for (m in metrics) {
1124
+ all_vals <- suppressWarnings(as.numeric(df[[m]]))
1125
+ sd_val <- sd(all_vals, na.rm = TRUE)
1126
  cand_vals <- suppressWarnings(as.numeric(candidates[[m]]))
1127
  ref_val <- suppressWarnings(as.numeric(row[[m]]))
1128
  if (!is.na(sd_val) && sd_val > 0) {
1129
+ diff <- cand_vals - ref_val
1130
+ diff[is.na(diff)] <- 0
1131
+ dist_vals <- dist_vals + (diff / sd_val)^2
1132
  }
1133
  }
1134
+ candidates[["similarity_score"]] <- 100 / (1 + dist_vals)
1135
+ cols <- c(available_cols(c(
1136
+ PLAYER_COL, TEAM_COL, COMP_COL, POSITION_COL, AGE_COL,
1137
+ MARKET_VALUE_COL, ARCHETYPE_COL, ARCHETYPE_SCORE_COL,
1138
+ TARGET_SCORE_COL, ATTAINABILITY_COL
1139
+ ), candidates), "similarity_score")
1140
+ out <- candidates[order(-candidates[["similarity_score"]]),
1141
+ cols, drop = FALSE]
1142
+ pretty_df(head(out, 10))
1143
  })
1144
 
1145
  output$similar_table <- renderDT({
1146
  datatable(similar_result_df(), selection = "single", rownames = FALSE,
1147
+ options = list(scrollX = TRUE, pageLength = 15))
1148
  })
1149
 
1150
  # ---- SHORTLIST MANAGER ----
1151
  observeEvent(input$add_shortlist_btn, {
1152
  p <- input$shortlist_player
1153
+ if (!is.null(p) && nchar(trimws(p)) > 0 && !p %in% shortlist()) {
1154
  shortlist(c(shortlist(), p))
1155
  }
1156
  })
 
1161
 
1162
  output$shortlist_table <- renderDT({
1163
  datatable(view_shortlist(), rownames = FALSE,
1164
+ options = list(scrollX = TRUE, pageLength = 25))
1165
  })
1166
 
1167
  output$export_shortlist_btn <- downloadHandler(
1168
  filename = function() "shortlist_export.csv",
1169
+ content = function(file) write.csv(view_shortlist(), file, row.names = FALSE)
 
 
 
1170
  )
1171
  }
1172