cpflecht commited on
Commit
2f443e2
·
verified ·
1 Parent(s): d80f308

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +1036 -0
app.py ADDED
@@ -0,0 +1,1036 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)
17
+ library(dplyr)
18
+ library(tidyr)
19
+ library(readr)
20
+ 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
+
102
+ available_cols <- function(cols, df) {
103
+ cols[cols %in% names(df)]
104
+ }
105
+
106
+ normalize_0_100 <- function(x) {
107
+ x <- suppressWarnings(as.numeric(x))
108
+ mn <- min(x, na.rm = TRUE)
109
+ mx <- max(x, na.rm = TRUE)
110
+ if (is.na(mn) || is.na(mx) || mn == mx) return(rep(0, length(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
+ # ============================================================
278
+ # PLAYER HELPERS
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, ])
286
+ }
287
+
288
+ get_player_group <- function(df, row) {
289
+ comp <- row[[COMP_COL]]
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
+ }
363
+
364
+ 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
+ }
372
+
373
+ build_performance_metric_options <- function(df, multi_df) {
374
+ options <- c()
375
+ for (m in PERFORMANCE_TIME_METRICS) {
376
+ current_exists <- m %in% names(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
+ }
390
+
391
+ # ============================================================
392
+ # UI
393
+ # ============================================================
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",
416
+ choices = NULL, multiple = TRUE)),
417
+ column(4, selectizeInput("team_filter", "Team",
418
+ choices = NULL, multiple = TRUE))
419
+ ),
420
+ fluidRow(
421
+ column(4, selectizeInput("position_filter", "Position",
422
+ choices = NULL, multiple = TRUE)),
423
+ column(4, selectizeInput("country_filter", "Country",
424
+ choices = NULL, multiple = TRUE))
425
+ ),
426
+ fluidRow(
427
+ column(4, uiOutput("age_slider_ui")),
428
+ column(4, uiOutput("minutes_slider_ui"))
429
+ ),
430
+ br(),
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(),
456
+ fluidRow(
457
+ column(6, plotlyOutput("radar_plot", height = "500px")),
458
+ column(6, plotlyOutput("percentile_plot", height = "500px"))
459
+ ),
460
+ br(),
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)),
485
+ column(4, selectizeInput("compare_3", "Player 3", choices = NULL))
486
+ ),
487
+ actionButton("compare_btn", "Compare Players", class = "btn-primary"),
488
+ br(), br(),
489
+ DTOutput("comparison_table"),
490
+ br(),
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)),
507
+ column(4, sliderInput("duels_w", "Duels", 0, 10, 5, step = 1)),
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
+ ),
525
+ fluidRow(
526
+ column(4, sliderInput("box_w", "Box Presence", 0, 10, 3, step = 1)),
527
+ column(4, sliderInput("holdup_w", "Holdup", 0, 10, 3, step = 1)),
528
+ column(4, sliderInput("target_w", "Target Score", 0, 10, 7, step = 1))
529
+ ),
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(),
559
+ DTOutput("shortlist_table")
560
+ )
561
+ )
562
+ )
563
+
564
+ # ============================================================
565
+ # SERVER
566
+ # ============================================================
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
+ })
693
+
694
+ output$profile_output <- renderUI({
695
+ row <- current_player_row()
696
+ if (is.null(row)) return(p("Select a player to view their profile."))
697
+ tagList(
698
+ h2(row[[PLAYER_COL]]),
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
+ )
712
+ )
713
+ })
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,
738
+ "Archetype Scores" = ARCHETYPE_SCORE_COLS,
739
+ "Key Season Stats" = KEY_METRICS,
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]
950
+ if (col %in% names(data) && w > 0) {
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
+ })
1017
+
1018
+ observeEvent(input$clear_shortlist_btn, {
1019
+ shortlist(character(0))
1020
+ })
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
+
1036
+ shinyApp(ui, server)