Spaces:
Running
Running
| from shiny import App, reactive, render, ui | |
| import pandas as pd | |
| import numpy as np | |
| import matplotlib.pyplot as plt | |
| import matplotlib | |
| matplotlib.use('Agg') # Use non-interactive backend | |
| import io | |
| import base64 | |
| from PIL import Image, ImageDraw, ImageFont | |
| import requests | |
| import polars as pl | |
| import numpy as np | |
| import pandas as pd | |
| import matplotlib.pyplot as plt | |
| from matplotlib import gridspec | |
| import seaborn as sns | |
| import csv | |
| df_rankings = pd.read_csv("hf://datasets/TJStatsApps/scouting_reports/top_100_rankings_app.csv", quoting=csv.QUOTE_MINIMAL,encoding='latin1').replace(0, None) | |
| df_rankings = df_rankings[df_rankings['rank']<=100] | |
| df_scout_batters = pd.read_csv("hf://datasets/TJStatsApps/scouting_reports/scouting_reports_batters.csv", quoting=csv.QUOTE_MINIMAL).replace(0, None) | |
| df_scout_batters['last_updated'] = pd.Timestamp.today().strftime('%Y-%m-%d %H:%M:%S') | |
| df_scout_pitchers = pd.read_csv("hf://datasets/TJStatsApps/scouting_reports/scouting_reports.csv", quoting=csv.QUOTE_MINIMAL).replace(0, None) | |
| df_scout_pitchers['last_updated'] = pd.Timestamp.today().strftime('%Y-%m-%d %H:%M:%S') | |
| df_scout = pd.concat([df_scout_batters, df_scout_pitchers], ignore_index=True) | |
| df_scout = df_rankings.merge(df_scout, on='player_id') | |
| player_url = f"http://statsapi.mlb.com/api/v1/people?personIds={str([int(x) for x in df_rankings['player_id']]).strip('[]').replace(' ', '')}&hydrate=currentTeam&appContext=minorLeague" | |
| player_data = requests.get(url=player_url).json()['people'] | |
| # Extract relevant data | |
| fullName_list = [x['fullName'] for x in player_data] | |
| firstName_list = [x['firstName'] for x in player_data] | |
| useName_list = [x['useName'] for x in player_data] | |
| lastName_list = [x['lastName'] for x in player_data] | |
| id_list = [x['id'] for x in player_data] | |
| position_list = [x['primaryPosition']['abbreviation'] if 'primaryPosition' in x else None for x in player_data] | |
| team_list = [x['currentTeam']['id'] if 'currentTeam' in x else None for x in player_data] | |
| parent_org_list = [x['currentTeam']['parentOrgId'] if 'parentOrgId' in x['currentTeam'] else None for x in player_data] | |
| weight_list = [x['weight'] if 'weight' in x else None for x in player_data] | |
| height_list = [x['height'] if 'height' in x else None for x in player_data] | |
| age_list = [x['currentAge'] if 'currentAge' in x else None for x in player_data] | |
| birthDate_list = [x['birthDate'] if 'birthDate' in x else None for x in player_data] | |
| bat_list = [x['batSide']['code'] if 'batSide' in x else None for x in player_data] | |
| throw_list = [x['pitchHand']['code'] if 'pitchHand' in x else None for x in player_data] | |
| df_players = pd.DataFrame(data={ | |
| 'player_id': id_list, | |
| 'first_name': firstName_list, | |
| 'use_name': useName_list, | |
| 'last_name': lastName_list, | |
| 'name': fullName_list, | |
| 'position': position_list, | |
| 'team': team_list, | |
| 'parent_org_id': parent_org_list, | |
| 'weight': weight_list, | |
| 'height': height_list, | |
| 'age': age_list, | |
| 'birthDate': birthDate_list, | |
| 'bat_side': bat_list, | |
| 'throw_side': throw_list | |
| }) | |
| df_players['name'] = df_players['use_name'] + ' ' + df_players['last_name'] | |
| df_players['logo'] = df_players['parent_org_id'].apply(lambda team_id: f'https://www.mlbstatic.com/team-logos/{team_id}.svg') | |
| df_players['headshot'] = df_players['player_id'].apply(lambda player_id: f'https://img.mlbstatic.com/mlb-photos/image/upload/c_fill,g_auto/w_640/v1/people/{player_id}/headshot/milb/current.png') | |
| df_scout = df_scout.merge(df_players, on='player_id', how='left', suffixes=('', '_y')) | |
| # df_scout = pd.concat([df_scout_batters, df_scout_pitchers], ignore_index=True) | |
| df_scout['player_id'] = df_scout['player_id'].astype(int) | |
| import pandas as pd | |
| import matplotlib.pyplot as plt | |
| import requests | |
| from PIL import Image | |
| from io import BytesIO | |
| import cairosvg | |
| # --- Utilities --- | |
| def get_index_of_latest_draft(drafts): | |
| """Return index of latest draft year.""" | |
| if not isinstance(drafts, list) or not drafts: | |
| return -1 | |
| try: | |
| return max(range(len(drafts)), key=lambda i: int(drafts[i].get('year', -1))) | |
| except (ValueError, TypeError): | |
| return -1 | |
| def get_value(data, key): | |
| return data.get(key) | |
| # --- Season Index Helpers --- | |
| DEFAULT_YEARS = ['2026','2025','2024','2023','2022'] | |
| def get_available_seasons(stats): | |
| """Return all seasons available in stats[0]['splits'], sorted descending.""" | |
| try: | |
| return sorted({split.get('season') for split in stats[0].get('splits', []) if split.get('season')}, reverse=True) | |
| except Exception: | |
| return [] | |
| def find_season_index(stats, target_seasons=None): | |
| """Find index of first occurrence of any target season, preferring newest available season first.""" | |
| try: | |
| available_seasons = get_available_seasons(stats) | |
| if not available_seasons: | |
| return -1 | |
| # Combine available seasons with default target seasons | |
| search_order = available_seasons + (target_seasons or DEFAULT_YEARS) | |
| splits = stats[0].get('splits', []) | |
| for season in search_order: | |
| for i, split in enumerate(splits): | |
| if split.get('season') == season: | |
| return i | |
| return -1 | |
| except Exception: | |
| return -1 | |
| def find_all_season_indices(stats, target_seasons=None): | |
| """Return all indices of splits for the first matching season in search_order.""" | |
| try: | |
| available_seasons = get_available_seasons(stats) | |
| search_order = available_seasons + (target_seasons or DEFAULT_YEARS) | |
| splits = stats[0].get('splits', []) | |
| for season in search_order: | |
| indices = [i for i, s in enumerate(splits) if s.get('season') == season] | |
| if indices: | |
| return indices | |
| return [] | |
| except Exception: | |
| return [] | |
| # --- Stats Extraction --- | |
| def get_latest_stats(stats, target_seasons=DEFAULT_YEARS): | |
| """Return a DataFrame with the latest season stats.""" | |
| if not stats or not isinstance(stats, list): | |
| return pd.DataFrame() | |
| latest_index = find_season_index(stats, target_seasons) | |
| idx_yby = next((i for i, s in enumerate(stats) if s.get('type', {}).get('displayName') == 'yearByYear'), None) | |
| idx_ybya = next((i for i, s in enumerate(stats) if s.get('type', {}).get('displayName') == 'yearByYearAdvanced'), None) | |
| if latest_index == -1 or idx_yby is None or idx_ybya is None: | |
| # Return empty stats if no season found | |
| empty_stats = { | |
| 'season': None, | |
| 'plateAppearances': None, | |
| 'totalBases': None, | |
| 'leftOnBase': None, | |
| 'sacBunts': None, | |
| 'sacFlies': None, | |
| 'babip': None, | |
| 'extraBaseHits': None, | |
| 'hitByPitch': None, | |
| 'gidp': None, | |
| 'gidpOpp': None, | |
| 'numberOfPitches': None, | |
| 'pitchesPerPlateAppearance': None, | |
| 'walksPerPlateAppearance': None, | |
| 'strikeoutsPerPlateAppearance': None, | |
| 'homeRunsPerPlateAppearance': None, | |
| 'walksPerStrikeout': None, | |
| 'iso': None, | |
| 'reachedOnError': None, | |
| 'walkOffs': None, | |
| 'flyOuts': None, | |
| 'totalSwings': None, | |
| 'swingAndMisses': None, | |
| 'ballsInPlay': None, | |
| 'popOuts': None, | |
| 'lineOuts': None, | |
| 'groundOuts': None, | |
| 'flyHits': None, | |
| 'popHits': None, | |
| 'lineHits': None, | |
| 'groundHits': None, | |
| 'whiffRate': None, | |
| 'contactRate': None, | |
| # Add new stats with None as default | |
| 'gamesPlayed': None, | |
| 'groundOuts': None, | |
| 'airOuts': None, | |
| 'runs': None, | |
| 'doubles': None, | |
| 'triples': None, | |
| 'homeRuns': None, | |
| 'strikeOuts': None, | |
| 'baseOnBalls': None, | |
| 'intentionalWalks': None, | |
| 'hits': None, | |
| 'hitByPitch': None, | |
| 'avg': None, | |
| 'atBats': None, | |
| 'obp': None, | |
| 'slg': None, | |
| 'ops': None, | |
| 'caughtStealing': None, | |
| 'stolenBases': None, | |
| 'stolenBasePercentage': None, | |
| 'groundIntoDoublePlay': None, | |
| 'numberOfPitches': None, | |
| 'plateAppearances': None, | |
| 'totalBases': None, | |
| 'rbi': None, | |
| 'leftOnBase': None, | |
| 'sacBunts': None, | |
| 'sacFlies': None, | |
| 'babip': None, | |
| 'groundOutsToAirouts': None, | |
| 'catchersInterference': None, | |
| 'atBatsPerHomeRun': None, | |
| } | |
| # df = pd.DataFrame(stats_latest, index=[0]) | |
| # return df | |
| # empty_stats = {k: None for k in [ | |
| # 'season','gamesPlayed','plateAppearances','totalSwings','swingAndMisses','ballsInPlay','whiffRate', | |
| # 'contactRate','swingRate','numberOfPitches','lineHits', | |
| # 'lineOuts','flyHits','flyOuts','popHits','popOuts','airPercent', | |
| # 'strikeoutPercentage','walkPercentage','strikeoutMinusWalkPercentage','levels','iso','ops' | |
| # ]} | |
| return pd.DataFrame([empty_stats]) | |
| latest_season = stats[0]['splits'][latest_index]['season'] | |
| split_yby = next((s for s in stats[idx_yby]['splits'] if s.get('season') == latest_season), {}) | |
| split_ybya = next((s for s in stats[idx_ybya]['splits'] if s.get('season') == latest_season), {}) | |
| stat_yby = split_yby.get('stat', {}) | |
| stat_ybya = split_ybya.get('stat', {}) | |
| # Merge stats, advanced overrides base | |
| merged_stats = {k: stat_ybya.get(k, stat_yby.get(k)) for k in set(stat_yby) | set(stat_ybya)} | |
| merged_stats['season'] = latest_season | |
| # Compute derived stats | |
| pa = merged_stats.get('plateAppearances') | |
| swings = merged_stats.get('totalSwings') | |
| misses = merged_stats.get('swingAndMisses') | |
| balls_in_play = merged_stats.get('ballsInPlay') | |
| merged_stats['whiffRate'] = misses / swings if swings else None | |
| merged_stats['contactRate'] = 1 - merged_stats['whiffRate'] if swings else None | |
| merged_stats['swingRate'] = swings / merged_stats.get('numberOfPitches') if swings and merged_stats.get('numberOfPitches') else None | |
| try: | |
| air_cols = ['lineHits', 'lineOuts', 'flyHits', 'flyOuts','popHits','popOuts'] | |
| merged_stats['airPercent'] = sum(merged_stats.get(c, 0) for c in air_cols) / balls_in_play if balls_in_play else None | |
| except Exception: | |
| merged_stats['airPercent'] = None | |
| # Compute levels | |
| indices = find_all_season_indices(stats, target_seasons) | |
| levels = set(stats[0]['splits'][i]['sport']['abbreviation'] for i in indices) | |
| merged_stats['levels'] = ", ".join(levels).replace(', MLB', '').replace(', Minors', '') | |
| return pd.DataFrame([merged_stats]) | |
| def get_pitcher_stats(stats): | |
| """Extract pitcher stats for the first available target season.""" | |
| idx = find_season_index(stats) | |
| if idx == -1: | |
| # Return empty stats if no season found | |
| empty_stats = {k: None for k in [ | |
| 'season','gamesPlayed','gamesStarted','strikeOuts','baseOnBalls','homeRuns','era', | |
| 'inningsPitched','whip','battersFaced','strikes','numberOfPitches','babip', | |
| 'totalSwings','swingAndMisses','ballsInPlay','whiffRate','strikePercentage', | |
| 'strikeoutPercentage','walkPercentage','strikeoutMinusWalkPercentage','levels' | |
| ]} | |
| return pd.DataFrame([empty_stats]) | |
| try: | |
| split_basic = stats[0]['splits'][idx]['stat'] | |
| split_adv = stats[1]['splits'][idx]['stat'] | |
| df = pd.DataFrame([{ | |
| 'season': stats[0]['splits'][idx]['season'], | |
| 'gamesPlayed': split_basic.get('gamesPlayed'), | |
| 'gamesStarted': split_basic.get('gamesStarted'), | |
| 'strikeOuts': split_basic.get('strikeOuts'), | |
| 'baseOnBalls': split_basic.get('baseOnBalls'), | |
| 'homeRuns': split_basic.get('homeRuns'), | |
| 'era': split_basic.get('era'), | |
| 'inningsPitched': split_basic.get('inningsPitched'), | |
| 'whip': split_basic.get('whip'), | |
| 'battersFaced': split_basic.get('battersFaced'), | |
| 'strikes': split_basic.get('strikes'), | |
| 'numberOfPitches': split_basic.get('numberOfPitches'), | |
| 'babip': split_adv.get('babip'), | |
| 'totalSwings': split_adv.get('totalSwings'), | |
| 'swingAndMisses': split_adv.get('swingAndMisses'), | |
| 'ballsInPlay': split_adv.get('ballsInPlay'), | |
| }]) | |
| df['whiffRate'] = df['swingAndMisses'] / df['totalSwings'] | |
| df['strikePercentage'] = df['strikes'] / df['numberOfPitches'] | |
| df['strikeoutPercentage'] = df['strikeOuts'] / df['battersFaced'] | |
| df['walkPercentage'] = df['baseOnBalls'] / df['battersFaced'] | |
| df['strikeoutMinusWalkPercentage'] = (df['strikeOuts'] - df['baseOnBalls']) / df['battersFaced'] | |
| indices = find_all_season_indices(stats) | |
| df['levels'] = ", ".join(sorted({stats[0]['splits'][i]['sport']['abbreviation'] for i in indices})) | |
| df['levels'] = df['levels'].str.replace(', MLB','').str.replace(', Minors','') | |
| return df | |
| except (KeyError, IndexError, TypeError): | |
| return pd.DataFrame([]) | |
| # --- Stats Table --- | |
| def stats_table(df: pd.DataFrame, ax: plt.Axes): | |
| stat_cols = ['gamesPlayed','plateAppearances','ops','iso','homeRuns','stolenBases', | |
| 'strikeoutsPerPlateAppearance','walksPerPlateAppearance','whiffRate','swingRate','airPercent'] | |
| col_labels = ["GP", "PA", "OPS",'ISO', "HR", "SB", "K%", "BB%", "Whiff%", "Swing%",'Air%'] | |
| formats = { | |
| 'gamesPlayed': "d", | |
| 'plateAppearances': "d", | |
| 'ops': ".3f", | |
| 'iso': ".3f", | |
| 'homeRuns': "d", | |
| 'stolenBases': "d", | |
| 'strikeoutsPerPlateAppearance': ".1%", | |
| 'walksPerPlateAppearance': ".1%", | |
| 'whiffRate': ".1%", | |
| 'swingRate': ".1%", | |
| 'airPercent': ".1%" | |
| } | |
| # Fill missing columns with None if not present | |
| for col in stat_cols: | |
| if col not in df.columns: | |
| df[col] = None | |
| def format_value(value, col_name): | |
| fmt = formats[col_name] | |
| if value is not None: | |
| try: | |
| if fmt.endswith("f"): | |
| return f"{float(value):{fmt}}" | |
| elif fmt.endswith("%"): | |
| return f"{float(value):{fmt}}" | |
| else: | |
| return f"{int(value):{fmt}}" | |
| except Exception: | |
| return "—" | |
| else: | |
| return "—" | |
| df_table = df[stat_cols] | |
| formatted_values = df_table.apply(lambda row: [format_value(row[col], col) for col in stat_cols], axis=1).tolist() | |
| table = ax.table(cellText=formatted_values, colLabels=col_labels, cellLoc='center', bbox=[0,0,1,0.6]) | |
| table.auto_set_font_size(False) | |
| table.set_fontsize(10) | |
| for i in range(len(col_labels)): | |
| table.get_celld()[(0,i)].get_text().set_fontweight('bold') | |
| season_text = f"{df['season'][0]} Season Stats\nLevels: {df['levels'][0]}" if df['season'][0] else "\nNo MiLB Data" | |
| ax.text(0.5, 1, season_text, ha='center', va='top', fontsize=12, fontstyle='italic') | |
| def stats_table_pitcher(df, ax): | |
| """Display pitcher stats as a formatted table.""" | |
| formats = { | |
| 'gamesPlayed': "d", | |
| 'inningsPitched': ".1f", | |
| 'era': ".2f", | |
| 'whip': ".2f", | |
| 'strikeoutPercentage': ".1%", | |
| 'walkPercentage': ".1%", | |
| 'strikeoutMinusWalkPercentage': ".1%", | |
| 'whiffRate': ".1%", | |
| 'strikePercentage': ".1%", | |
| } | |
| column_names = { | |
| 'gamesPlayed': "GP", | |
| 'inningsPitched': "IP", | |
| 'era': "ERA", | |
| 'whip': "WHIP", | |
| 'strikeoutPercentage': "K%", | |
| 'walkPercentage': "BB%", | |
| 'strikeoutMinusWalkPercentage': "K-BB%", | |
| 'whiffRate': "Whiff%", | |
| 'strikePercentage': "Strike%", | |
| } | |
| df_table = df[list(column_names.keys())] | |
| def format_value(value, col): | |
| fmt = formats[col] | |
| if value is None: | |
| return "—" | |
| if fmt.endswith("%"): | |
| return f"{float(value):{fmt}}" | |
| if fmt.endswith("f"): | |
| return f"{float(value):{fmt}}" | |
| return f"{int(value):{fmt}}" | |
| table_data = df_table.apply(lambda row: [format_value(row[col], col) for col in df_table.columns], axis=1).tolist() | |
| table = ax.table( | |
| colLabels=list(column_names.values()), | |
| cellText=table_data, | |
| cellLoc='center', | |
| bbox=[0, 0, 1, 0.6] | |
| ) | |
| table.auto_set_font_size(False) | |
| table.set_fontsize(10) | |
| table.scale(1, 0.5) | |
| title = f"{df['season'][0]} Season Stats\nLevels: {df['levels'][0]}" if df['season'][0] else "\nNo MiLB Data" | |
| ax.text(0.5, 1, title, ha='center', va='top', fontsize=12, fontstyle='italic') | |
| ax.axis('off') | |
| # --- Player Bio --- | |
| def bio_plot(df: pd.DataFrame, ax: plt.Axes): | |
| ax.text(0.5,1, df.get('fullName',[None])[0], ha='center', va='top', fontsize=20) | |
| ax.text(0.5,0.7, f"{df.get('position',[''])[0]}, Age: {df.get('currentAge',[''])[0]}, B/T: {df.get('batSide',[''])[0]}/{df.get('pitchHand',[''])[0]}, {df.get('height',[''])[0]}/{df.get('weight',[''])[0]}", ha='center', va='top', fontsize=10) | |
| ax.text(0.5,0.52, f"DOB: {df.get('birthDate',[''])[0]}, {df.get('birthCity',[''])[0]}, {df.get('birthCountry',[''])[0]}", ha='center', va='top', fontsize=10) | |
| if df.get('draftsYear',[None])[0]: | |
| ax.text(0.5,0.34, f"Drafted: {df.get('draftsYear',[None])[0]}, Rd. {df.get('draftsRound',[None])[0]}, Pick: {df.get('draftsRoundPickNumber',[None])[0]}", ha='center', va='top', fontsize=10) | |
| ax.text(0.5,0.16, f"School: {df.get('draftsSchool',[''])[0]}", ha='center', va='top', fontsize=10) | |
| # --- Team Logo --- | |
| def plot_logo(df_bio: pd.DataFrame, ax: plt.Axes): | |
| try: | |
| logo_url = f'https://www.mlbstatic.com/team-logos/{df_bio["currentTeamId"][0]}.svg' | |
| response = requests.get(logo_url) | |
| png_data = cairosvg.svg2png(bytestring=response.content, output_width=300, output_height=300) | |
| img = Image.open(BytesIO(png_data)) | |
| ax.imshow(img, extent=[0,1,0,1], origin='upper') | |
| except Exception: | |
| ax.axis('off') | |
| ax.axis('off') | |
| # --- Player Headshot --- | |
| def player_headshot(player_id, ax: plt.Axes): | |
| url = f'https://img.mlbstatic.com/mlb-photos/image/upload/c_fill,g_auto/w_640/v1/people/{player_id}/headshot/milb/current.png' | |
| try: | |
| img = Image.open(BytesIO(requests.get(url).content)) | |
| ax.imshow(img, extent=[1/6, 5/6, 0, 1], origin='upper') | |
| except Exception: | |
| ax.axis('off') | |
| ax.axis('off') | |
| import requests | |
| import pandas as pd | |
| import matplotlib.pyplot as plt | |
| from matplotlib import gridspec | |
| # import datetime | |
| def fetch_player_data(player_id, season="2025"): | |
| """Fetch player data from MLB API.""" | |
| url = ( | |
| f"https://statsapi.mlb.com/api/v1/people/{player_id}" | |
| f"?hydrate=draft,currentTeam,team,stats(type=[yearByYear,yearByYearAdvanced," | |
| f"careerRegularSeason,careerAdvanced,availableStats](team(league)),season={season},leagueListId=mlb_milb)&site=en&appContext=minorLeague" | |
| ) | |
| r = requests.get(url) | |
| data = r.json() | |
| person = data.get("people", [None])[0] | |
| return person | |
| def parse_draft(drafts): | |
| """Return the latest draft info or None.""" | |
| if not drafts: | |
| return None, None, None, None | |
| if isinstance(drafts, list): | |
| draft = drafts[get_index_of_latest_draft(drafts)] | |
| else: | |
| draft = drafts | |
| return ( | |
| get_value(draft, "pickRound"), | |
| get_value(draft, "roundPickNumber"), | |
| get_value(draft, "year"), | |
| get_value(draft.get("school", {}), "name") | |
| ) | |
| def parse_player_bio(person): | |
| """Extract key bio fields into a DataFrame.""" | |
| if not person: | |
| return pd.DataFrame() | |
| current_team = person.get("currentTeam") | |
| if current_team: | |
| currentTeamName = current_team.get("parentOrgName") or current_team.get("name") | |
| currentTeamId = current_team.get("parentOrgId") or current_team.get("id") | |
| else: | |
| currentTeamName, currentTeamId = None, None | |
| draftsRound, draftsRoundPickNumber, draftsYear, draftsSchool = parse_draft(person.get("drafts")) | |
| df_bio = pd.DataFrame([{ | |
| "id": person.get("id"), | |
| "fullName": person.get("fullName"), | |
| "firstName": person.get("firstName"), | |
| "lastName": person.get("lastName"), | |
| "birthDate": person.get("birthDate"), | |
| "currentAge": person.get("currentAge"), | |
| "birthCity": person.get("birthCity"), | |
| "birthProvince": person.get("birthProvince"), | |
| "birthCountry": person.get("birthCountry"), | |
| "height": person.get("height"), | |
| "weight": person.get("weight"), | |
| "currentTeam": currentTeamName, | |
| "currentTeamId": currentTeamId, | |
| "batSide": get_value(person.get('batSide', {}), 'code'), | |
| "pitchHand": get_value(person.get('pitchHand', {}), 'code'), | |
| "draftsRound": draftsRound, | |
| "draftsRoundPickNumber": draftsRoundPickNumber, | |
| "draftsYear": draftsYear, | |
| "draftsSchool": draftsSchool, | |
| "position": get_value(person.get('primaryPosition', {}), 'abbreviation') | |
| }]) | |
| return df_bio | |
| def format_scout_df(df_scout_player): | |
| """Prepare scouting DataFrame for plotting.""" | |
| df = df_scout_player.dropna(axis=1, how="all").copy() | |
| grade_columns = [col.split("_")[0] for col in df.columns if "_fv" in col] | |
| # Combine PV/FV grades | |
| for col in grade_columns: | |
| df[col.capitalize()] = df[f"{col}_pv"].astype(int).astype(str) + "/" + df[f"{col}_fv"].astype(int).astype(str) | |
| # Format FV | |
| df["fv"] = pd.to_numeric(df["fv"], errors="coerce") | |
| df["FV"] = df["fv"].apply(lambda x: f"{int(x-1)}+" if x % 5 == 1 else f"{int(x)}") | |
| df["ETA"] = df["eta"].astype(int) | |
| grade_columns = [col.split("_")[0].capitalize() for col in df.columns if "_fv" in col] | |
| df_plot = df[["ETA","FV"] + grade_columns] | |
| return df_plot | |
| def generate_player_card(df_bio, df_stats, df_scout_plot, save_path): | |
| """Generate a player card using matplotlib.""" | |
| fig = plt.figure(figsize=(10,6), dpi=600) | |
| plt.rcParams.update({'figure.autolayout': True}) | |
| fig.set_facecolor('white') | |
| gs = gridspec.GridSpec(5, 5, | |
| height_ratios=[1,10,10,10,4], | |
| width_ratios=[1,6,20,6,1]) | |
| gs.update(hspace=0.2, wspace=0.2) | |
| ax_header = fig.add_subplot(gs[0,:]) | |
| ax_headshot = fig.add_subplot(gs[1,1]) | |
| ax_bio = fig.add_subplot(gs[1,2]) | |
| ax_logo = fig.add_subplot(gs[1,3]) | |
| ax_stats = fig.add_subplot(gs[2,1:4]) | |
| ax_scout = fig.add_subplot(gs[3,1:4]) | |
| ax_foot = fig.add_subplot(gs[-1,:]) | |
| # Draw elements | |
| player_headshot(df_bio['id'][0], ax_headshot) | |
| plot_logo(df_bio=df_bio, ax=ax_logo) | |
| bio_plot(df=df_bio, ax=ax_bio) | |
| stats_table(df=df_stats, ax=ax_stats) | |
| # Scouting table | |
| scout_table = ax_scout.table( | |
| colLabels=df_scout_plot.columns, | |
| cellText=df_scout_plot.values, | |
| cellLoc='center', | |
| bbox=[0,0,1,0.6] | |
| ) | |
| scout_table.auto_set_font_size(False) | |
| scout_table.set_fontsize(10) | |
| scout_table.scale(1, 0.5) | |
| new_column_names = [x.title() for x in df_scout_plot.columns[2:]] | |
| new_column_names = ['ETA', 'FV'] + new_column_names | |
| for i, col_name in enumerate(new_column_names): | |
| if col_name == "Decisions": | |
| col_name = "Swing Decisions" | |
| elif col_name == "Power": | |
| col_name = "Game Power" | |
| # Replace spaces with newlines for display, but keep LaTeX bold | |
| if " " in col_name: | |
| base = col_name.replace(" ", "\n") | |
| # Ensure the bold formatting is preserved | |
| scout_table.get_celld()[(0, i)].get_text().set_text(base) | |
| else: | |
| scout_table.get_celld()[(0, i)].get_text().set_text(col_name) | |
| # Set fontweight to bold for header | |
| scout_table.get_celld()[(0, i)].get_text().set_fontweight('bold') | |
| ax_scout.text(s="\nScouting Grades", x=0.5, y=1, ha='center', va='top', fontsize=12, fontstyle='italic') | |
| # Footer | |
| import datetime | |
| last_updated = datetime.datetime.now().strftime("%Y-%m-%d") | |
| ax_foot.text( | |
| s='By: Thomas Nestico\n @TJStats', | |
| x=0.06, | |
| y=0.25, | |
| ha='left', | |
| va='bottom', | |
| fontsize=10 | |
| ) | |
| ax_foot.text( | |
| s='Data: MLB\nImages: MLB', | |
| x=0.94, | |
| y=0.25, | |
| ha='right', | |
| va='bottom', | |
| fontsize=10 | |
| ) | |
| ax_foot.text( | |
| s=f'Updated: {last_updated}', | |
| x=0.5, | |
| y=0.25, | |
| ha='center', | |
| va='bottom', | |
| fontsize=8 | |
| ) | |
| # Turn off axes | |
| for ax in [ax_header, ax_headshot, ax_bio, ax_logo, ax_stats, ax_scout, ax_foot]: | |
| ax.axis('off') | |
| if save_path: | |
| plt.savefig(save_path, bbox_inches='tight') | |
| plt.close(fig) | |
| return fig | |
| def generate_player_card_pitcher(df_bio, df_stats, df_scout_plot, save_path): | |
| """Generate a player card using matplotlib.""" | |
| fig = plt.figure(figsize=(10,6), dpi=600) | |
| plt.rcParams.update({'figure.autolayout': True}) | |
| fig.set_facecolor('white') | |
| gs = gridspec.GridSpec(5, 5, | |
| height_ratios=[1,10,10,10,4], | |
| width_ratios=[1,6,20,6,1]) | |
| gs.update(hspace=0.2, wspace=0.2) | |
| ax_header = fig.add_subplot(gs[0,:]) | |
| ax_headshot = fig.add_subplot(gs[1,1]) | |
| ax_bio = fig.add_subplot(gs[1,2]) | |
| ax_logo = fig.add_subplot(gs[1,3]) | |
| ax_stats = fig.add_subplot(gs[2,1:4]) | |
| ax_scout = fig.add_subplot(gs[3,1:4]) | |
| ax_foot = fig.add_subplot(gs[-1,:]) | |
| # Draw elements | |
| player_headshot(df_bio['id'][0], ax_headshot) | |
| plot_logo(df_bio=df_bio, ax=ax_logo) | |
| bio_plot(df=df_bio, ax=ax_bio) | |
| stats_table_pitcher(df=df_stats, ax=ax_stats) | |
| # Scouting table | |
| scout_table = ax_scout.table( | |
| colLabels=df_scout_plot.columns, | |
| cellText=df_scout_plot.values, | |
| cellLoc='center', | |
| bbox=[0,0,1,0.6] | |
| ) | |
| scout_table.auto_set_font_size(False) | |
| scout_table.set_fontsize(10) | |
| scout_table.scale(1, 0.5) | |
| new_column_names = [x.title() for x in df_scout_plot.columns[2:]] | |
| new_column_names = ['ETA', 'FV'] + new_column_names | |
| for i, col_name in enumerate(new_column_names): | |
| # Replace spaces with newlines for display, but keep LaTeX bold | |
| if " " in col_name: | |
| base = col_name.replace(" ", "\n") | |
| # Ensure the bold formatting is preserved | |
| scout_table.get_celld()[(0, i)].get_text().set_text(base) | |
| else: | |
| scout_table.get_celld()[(0, i)].get_text().set_text(col_name) | |
| # Set fontweight to bold for header | |
| scout_table.get_celld()[(0, i)].get_text().set_fontweight('bold') | |
| ax_scout.text(s="\nScouting Grades", x=0.5, y=1, ha='center', va='top', fontsize=12, fontstyle='italic') | |
| # Footer | |
| import datetime | |
| last_updated = datetime.datetime.now().strftime("%Y-%m-%d") | |
| ax_foot.text( | |
| s='By: Thomas Nestico\n @TJStats', | |
| x=0.06, | |
| y=0.25, | |
| ha='left', | |
| va='bottom', | |
| fontsize=10 | |
| ) | |
| ax_foot.text( | |
| s='Data: MLB\nImages: MLB', | |
| x=0.94, | |
| y=0.25, | |
| ha='right', | |
| va='bottom', | |
| fontsize=10 | |
| ) | |
| ax_foot.text( | |
| s=f'Updated: {last_updated}', | |
| x=0.5, | |
| y=0.25, | |
| ha='center', | |
| va='bottom', | |
| fontsize=8 | |
| ) | |
| # Turn off axes | |
| for ax in [ax_header, ax_headshot, ax_bio, ax_logo, ax_stats, ax_scout, ax_foot]: | |
| ax.axis('off') | |
| if save_path: | |
| plt.savefig(save_path, bbox_inches='tight') | |
| plt.close(fig) | |
| return fig | |
| # Example usage for multiple players | |
| # Generate sample prospect data | |
| def generate_prospect_data(): | |
| df_scout[['player_id','name','position','team','age','height','weight','bat_side','throw_side']] | |
| prospects = [] | |
| for i in range(len(df_scout)): | |
| name = df_scout['name'].iloc[i] | |
| position = df_scout['position'].iloc[i] | |
| team = df_scout['team'].iloc[i] | |
| age = df_scout['age'].iloc[i] | |
| height = df_scout['height'].iloc[i] | |
| weight = df_scout['weight'].iloc[i] | |
| bat_side = df_scout['bat_side'].iloc[i] | |
| throw_side = df_scout['throw_side'].iloc[i] | |
| prospects.append({ | |
| "Rank": i + 1, | |
| "Name": name, | |
| "Team": team, | |
| "Age": age, | |
| "Position": position, | |
| "Height": height, | |
| "Weight": weight, | |
| "Bat Side": bat_side, | |
| "Throw Side": throw_side, | |
| }) | |
| return pd.DataFrame(prospects) | |
| default_headshot = "https://img.mlbstatic.com/mlb-photos/image/upload/d_people:generic:headshot:67:current.png/w_213,q_auto:best/v1/people/000000/headshot/67/current" | |
| def get_valid_headshot(url: str) -> str: | |
| """Return url if it exists (status 200), else return default_logo.""" | |
| try: | |
| r = requests.head(url, allow_redirects=True, timeout=3) | |
| if r.status_code == 200: | |
| return url | |
| except requests.RequestException: | |
| pass | |
| return default_headshot | |
| app_ui = ui.page_fluid( | |
| ui.card( | |
| ui.h1("TJStats Top 100 Prospects"), | |
| ui.row( | |
| ui.column(3, | |
| ui.h6( | |
| "By: Thomas Nestico (", | |
| ui.a( | |
| "@TJStats", | |
| href="https://twitter.com/TJStats", # change to your actual URL | |
| target="_blank", # open in new tab | |
| style="text-decoration: none; color: #007bff;" # optional styling | |
| ), | |
| ")")), | |
| ui.column(2, | |
| ui.h6("Data: MLB")), | |
| ui.column(4, | |
| ui.h6( | |
| ui.tags.a( | |
| "Support me on Patreon for more baseball content", | |
| href="https://www.patreon.com/TJ_Stats", | |
| target="_blank" | |
| ), | |
| ), | |
| ) | |
| ), | |
| # ui.card_header("TJStats Top 100 Prospects"), | |
| ui.output_ui("prospects_table_with_images"), | |
| ), | |
| ) | |
| def server(input, output, session): | |
| # Load prospect data | |
| prospects_df = df_scout.copy() | |
| # Reactive value to store selected prospect | |
| selected_player_id = reactive.value(None) | |
| def prospects_table_with_images(): | |
| df = prospects_df.copy() | |
| # Create HTML table with images | |
| table_rows = [] | |
| # Header row with explicit column widths | |
| header = """ | |
| <tr style="background-color: #f8f9fa; font-weight: bold; position: sticky; top: 0; z-index: 10;"> | |
| <th style="width: 60px; padding: 12px; text-align: center; border: 1px solid #dee2e6;">Rank</th> | |
| <th style="width: 50px; padding: 12px; text-align: center; border: 1px solid #dee2e6;"></th> | |
| <th style="width: 200px; padding: 12px; text-align: left; border: 1px solid #dee2e6;">Name</th> | |
| <th style="width: 70px; padding: 12px; text-align: center; border: 1px solid #dee2e6;">Team</th> | |
| <th style="width: 100px; padding: 12px; text-align: center; border: 1px solid #dee2e6;">Position</th> | |
| <th style="width: 80px; padding: 12px; text-align: center; border: 1px solid #dee2e6;">FV</th> | |
| <th style="width: 70px; padding: 12px; text-align: center; border: 1px solid #dee2e6;">ETA</th> | |
| <th style="width: 50px; padding: 12px; text-align: center; border: 1px solid #dee2e6;">Age</th> | |
| <th style="width: 70px; padding: 12px; text-align: center; border: 1px solid #dee2e6;">Height</th> | |
| <th style="width: 70px; padding: 12px; text-align: center; border: 1px solid #dee2e6;">Weight</th> | |
| <th style="width: 60px; padding: 12px; text-align: center; border: 1px solid #dee2e6;">B/T</th> | |
| </tr> | |
| """ | |
| table_rows.append(header) | |
| # Data rows | |
| for idx, row in df.iterrows(): | |
| # Headshot | |
| headshot_src = get_valid_headshot(row.get("headshot")) | |
| headshot_html = f""" | |
| <a href="{headshot_src}" target="_blank"> | |
| <img src="{headshot_src}" class="headshot" alt="{row['name']} headshot" style="border: 2px solid #ddd;"> | |
| </a> | |
| """ | |
| # Logo | |
| logo_src = row.get("logo") | |
| logo_html = "" | |
| if logo_src: | |
| logo_html = f""" | |
| <a href="{logo_src}" target="_blank"> | |
| <img src="{logo_src}" class="logo" alt="{row.get('team', 'Team')} logo"> | |
| </a> | |
| """ | |
| # FV background | |
| fv = int(row['fv']) if pd.notna(row['fv']) else 0 | |
| if fv >= 65: | |
| fv_bg_color = "#DC267F" | |
| elif fv >= 60: | |
| fv_bg_color = "#E76BA8" | |
| elif fv >= 55: | |
| fv_bg_color = "#EF9BC4" | |
| elif fv >= 50: | |
| fv_bg_color = "#F7CEE2" | |
| else: | |
| fv_bg_color = "#ffffff" | |
| # Position background | |
| position_colors = { | |
| "C": "#785EF0", "1B": "#DC267F", "2B": "#FE6100", "3B": "#41AC99", | |
| "SS": "#FFB000", "LF": "#D5E472", "CF": "#222DA1", "RF": "#39540F", | |
| "OF": "#222DA1", "DH": "#3F518A", "P": "#648FFF","IF":"#a12222" | |
| } | |
| pos_bg_color = position_colors.get(row['position'], "#e9ecef") | |
| display_position = row['position'] | |
| if display_position == "P" and pd.notna(row.get('throw_side')): | |
| display_position = f"{row['throw_side']}H{display_position}" | |
| # Row HTML | |
| row_html = f""" | |
| <tr onclick="Shiny.setInputValue('table_row_click', '{row['player_id']}', {{priority: 'event'}});" | |
| style="cursor: pointer; border: 1px solid #dee2e6;" | |
| onmouseover="this.style.backgroundColor='#f5f5f5';" | |
| onmouseout="this.style.backgroundColor='white';"> | |
| <td style="width: 60px; padding: 8px; text-align: center; border: 1px solid #dee2e6; font-weight: 500;">{row['rank']}</td> | |
| <td style="width: 60px; padding: 8px; text-align: center; border: 1px solid #dee2e6;">{headshot_html}</td> | |
| <td style="width: 200px; padding: 8px; border: 1px solid #dee2e6; font-weight: 500;">{row['name']}</td> | |
| <td style="width: 80px; padding: 8px; text-align: center; border: 1px solid #dee2e6;">{logo_html}</td> | |
| <td style="width: 100px; padding: 8px; text-align: center; border: 1px solid #dee2e6;"> | |
| <span style="display: inline-block; min-width: 50px; background-color: {pos_bg_color}; | |
| color: white; padding: clamp(2px, 0.5vw, 6px) clamp(4px, 1vw, 10px); | |
| border-radius: 12px; font-size: clamp(12px, 2vw, 24px); font-weight: bold;"> | |
| {display_position} | |
| </span> | |
| </td> | |
| <td style="width: 80px; padding: 8px; text-align: center; border: 1px solid #dee2e6;"> | |
| <span style="background-color: {fv_bg_color}; min-width: 40px; | |
| color: black; padding: clamp(2px, 0.5vw, 6px) clamp(4px, 1vw, 10px); | |
| border-radius: 12px; font-size: clamp(12px, 2vw, 24px); font-weight: bold;"> | |
| {int(row['fv'])} | |
| </span> | |
| </td> | |
| <td style="width: 80px; padding: 8px; text-align: center; border: 1px solid #dee2e6;">{int(row['eta'])}</td> | |
| <td style="width: 60px; padding: 8px; text-align: center; border: 1px solid #dee2e6;">{row['age']}</td> | |
| <td style="width: 80px; padding: 8px; text-align: center; border: 1px solid #dee2e6;">{row['height']}</td> | |
| <td style="width: 80px; padding: 8px; text-align: center; border: 1px solid #dee2e6;">{int(row['weight'])}</td> | |
| <td style="width: 70px; padding: 8px; text-align: center; border: 1px solid #dee2e6;">{row['bat_side']}/{row['throw_side']}</td> | |
| </tr> | |
| """ | |
| table_rows.append(row_html) | |
| # Combine table and CSS | |
| table_html = f""" | |
| <div style="max-height: 75vh; overflow-y: auto; overflow-x: hidden; border: 1px solid #dee2e6; border-radius: 8px;"> | |
| <table style="width: 100%; border-collapse: collapse; table-layout: auto; font-size: clamp(12px, 1.8vw, 28px);"> | |
| {''.join(table_rows)} | |
| </table> | |
| </div> | |
| <style> | |
| /* Headshots circular */ | |
| table img.headshot {{ | |
| border-radius: 50% !important; | |
| object-fit: cover !important; | |
| width: 50px; | |
| height: 50px; | |
| }} | |
| /* Logos rectangular */ | |
| table img.logo {{ | |
| border-radius: 0 !important; | |
| object-fit: contain; | |
| width: 50px; | |
| height: 50px; | |
| }} | |
| /* Tablets / small laptops */ | |
| @media (max-width: 768px) {{ | |
| table {{ | |
| font-size: clamp(10px, 2.5vw, 20px); | |
| }} | |
| table img.headshot {{ | |
| width: 40px; | |
| height: 40px; | |
| }} | |
| table img.logo {{ | |
| width: 40px; | |
| height: 40px; | |
| }} | |
| th, td {{ | |
| padding: 6px; | |
| }} | |
| td span {{ | |
| font-size: clamp(10px, 2.2vw, 18px) !important; | |
| padding: 2px 6px !important; | |
| min-width: 35px !important; | |
| border-radius: 10px !important; | |
| }} | |
| td:nth-child(9), th:nth-child(9), /* Height */ | |
| td:nth-child(10), th:nth-child(10) /* Weight */ {{ | |
| display: none; | |
| }} | |
| }} | |
| /* Phones */ | |
| @media (max-width: 480px) {{ | |
| table {{ | |
| font-size: clamp(9px, 3vw, 16px); | |
| table-layout: fixed; /* Prevent horizontal scrolling */ | |
| width: 100% !important; | |
| }} | |
| th, td {{ | |
| padding: 4px; | |
| word-break: break-word; /* Wrap long text */ | |
| }} | |
| th:first-child, td:first-child, /* Rank */ | |
| th:nth-child(4), td:nth-child(4), | |
| th:nth-child(5), td:nth-child(5), | |
| th:nth-child(6), td:nth-child(6){{ | |
| white-space: nowrap; | |
| }} | |
| /* Force column 3 (Name) to wrap only at spaces, not mid-word */ | |
| td:nth-child(3), th:nth-child(3) {{ | |
| white-space: normal !important; | |
| word-break: normal !important; | |
| overflow-wrap: break-word !important; | |
| }} | |
| th:nth-child(1), td:nth-child(1) {{ | |
| width: 50px !important; | |
| min-width: 50px !important; | |
| max-width: 50px !important; | |
| }} | |
| th:nth-child(5), td:nth-child(5) {{ | |
| width: 80px !important; | |
| min-width: 80px !important; | |
| max-width: 80px !important; | |
| }} | |
| table img.headshot {{ | |
| width: 30px; | |
| height: 30px; | |
| }} | |
| table img.logo {{ | |
| width: 30px; | |
| height: 30px; | |
| }} | |
| td span {{ | |
| font-size: clamp(9px, 2.5vw, 14px) !important; | |
| padding: 2px 4px !important; | |
| border-radius: 8px !important; | |
| }} | |
| /* Hide less critical columns */ | |
| td:nth-child(2), th:nth-child(2), /* Age */ | |
| td:nth-child(7), th:nth-child(7), /* Age */ | |
| td:nth-child(8), th:nth-child(8), /* Age */ | |
| td:nth-child(9), th:nth-child(9), /* Height */ | |
| td:nth-child(10), th:nth-child(10), /* Weight */ | |
| td:nth-child(11), th:nth-child(11) /* B/T */ {{ | |
| display: none; | |
| }} | |
| }} | |
| </style> | |
| """ | |
| return ui.HTML(table_html) | |
| # Handle table row clicks - now storing player_id | |
| def _(): | |
| if input.table_row_click(): | |
| selected_player_id.set(int(input.table_row_click())) | |
| # Handle table row clicks and show modal - this is the key modal functionality | |
| def handle_selection(): | |
| if input.table_row_click(): | |
| player_id = int(input.table_row_click()) | |
| selected_player_id.set(player_id) | |
| # Get player name for modal title | |
| player_data = prospects_df[prospects_df['player_id'] == player_id].iloc[0] | |
| player_name = player_data['name'] | |
| static_text = player_data['notes'] | |
| good_text = player_data['the_good'] | |
| bad_text = player_data['the_bad'] | |
| # Show modal with chart | |
| ui.modal_show( | |
| ui.modal( | |
| ui.h3(f"{player_data['rank']:.0f}) {player_name}", style="font-weight: bold;"), | |
| ui.output_ui("modal_prospect_chart"), | |
| ui.h4('The Good'), | |
| ui.p(good_text.strip() if 'good_text' in locals() else "No notes available."), | |
| ui.h4('The Bad'), | |
| ui.p(bad_text.strip() if 'bad_text' in locals() else "No notes available."), | |
| # title=f"{player_data['rank']:.0f}) {player_name}", | |
| size="l", | |
| easy_close=True, | |
| footer=ui.modal_button("Close", class_="btn-secondary") | |
| ) | |
| ) | |
| # Create a reactive calculation that handles the loading state | |
| def chart_content(): | |
| if not selected_player_id(): | |
| return None | |
| try: | |
| print("Generating modal chart for player ID:", selected_player_id()) | |
| player_id = int(selected_player_id()) | |
| person = fetch_player_data(player_id) | |
| df_bio = parse_player_bio(person) | |
| stats = get_value(person, "stats") | |
| df_stats = get_latest_stats(stats) | |
| df_scout_player = df_scout[df_scout['player_id'] == df_bio['id'][0]].reset_index(drop=True) | |
| print("Scouting data found:", df_scout_player) | |
| df_scout_plot = format_scout_df(df_scout_player) | |
| if df_bio['position'][0] in ['P','SP','RP']: | |
| df_stats = get_pitcher_stats(stats) | |
| card = generate_player_card_pitcher(df_bio, df_stats, df_scout_plot, save_path=False) | |
| else: | |
| df_stats = get_latest_stats(stats) | |
| card = generate_player_card(df_bio, df_stats, df_scout_plot, save_path=False) | |
| # Convert plot to base64 string | |
| img_buffer = io.BytesIO() | |
| card.savefig(img_buffer, format='png', dpi=150, bbox_inches='tight', | |
| facecolor='white', edgecolor='none') | |
| img_buffer.seek(0) | |
| img_str = base64.b64encode(img_buffer.getvalue()).decode() | |
| plt.close(card) # Important: close the figure to free memory | |
| return img_str | |
| except Exception as e: | |
| print(f"Error generating chart: {e}") | |
| return "error" | |
| def modal_prospect_chart(): | |
| if not selected_player_id(): | |
| return ui.div("No prospect selected") | |
| # Trigger dependency on selected player | |
| player_id = selected_player_id() | |
| try: | |
| # Get the chart content (this will be None initially while calculating) | |
| chart_data = chart_content() | |
| if chart_data is None: | |
| # Show white placeholder with spinner while loading | |
| return ui.div( | |
| ui.div( | |
| # White background placeholder | |
| ui.div( | |
| style="width: 100%; max-width: 800px; height: 600px; background-color: white; border-radius: 8px; border: 1px solid #e0e0e0; display: flex; align-items: center; justify-content: center;" | |
| ), | |
| # Loading spinner overlay | |
| ui.div( | |
| ui.HTML('<div class="spinner-border text-primary" role="status" style="width: 3rem; height: 3rem;"></div>'), | |
| ui.p("Generating player card...", style="margin-top: 1rem; color: #666;"), | |
| style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center;" | |
| ), | |
| style="position: relative; text-align: center; padding: 10px;" | |
| ), | |
| style="text-align: center;" | |
| ) | |
| elif chart_data == "error": | |
| return ui.div( | |
| ui.p("Error generating modal chart"), | |
| style="color: red; text-align: center; padding: 20px;" | |
| ) | |
| else: | |
| # Show actual chart | |
| return ui.div( | |
| ui.img(src=f"data:image/png;base64,{chart_data}", | |
| style="width: 100%; max-width: 800px; height: auto; border-radius: 8px;"), | |
| style="text-align: center; padding: 10px;" | |
| ) | |
| except Exception: | |
| # Show loading state if there's any issue | |
| return ui.div( | |
| ui.div( | |
| ui.div( | |
| style="width: 100%; max-width: 800px; height: 600px; background-color: white; border-radius: 8px; border: 1px solid #e0e0e0;" | |
| ), | |
| ui.div( | |
| ui.HTML('<div class="spinner-border text-primary" role="status" style="width: 3rem; height: 3rem;"></div>'), | |
| ui.p("Loading...", style="margin-top: 1rem; color: #666;"), | |
| style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center;" | |
| ), | |
| style="position: relative; text-align: center; padding: 10px;" | |
| ) | |
| ) | |
| app = App(app_ui, server) | |