import plotly.graph_objects as go import numpy as np import pandas as pd import json import os from datetime import datetime from leaderboard_utils import ( get_combined_leaderboard, GAME_ORDER ) # Load model colors with open('assets/model_color.json', 'r', encoding='utf-8') as f: MODEL_COLORS = json.load(f) GAME_SCORE_COLUMNS = { "Super Mario Bros": "Score", "Sokoban": "Levels Cracked", "2048": "Score", "Candy Crush": "Average Score", "Tetris (complete)": "Score", "Tetris (planning only)": "Score", "Ace Attorney": "Score" } def get_model_prefix(name): return name.split('-')[0] def normalize_values(values, mean, std): """ Normalize values using z-score and scale to 0-100 range Args: values (list): List of values to normalize mean (float): Mean value for normalization std (float): Standard deviation for normalization Returns: list: Normalized values scaled to 0-100 range """ if std == 0: return [50 if v > 0 else 0 for v in values] # Handle zero std case z_scores = [(v - mean) / std for v in values] # Scale z-scores to 0-100 range, with mean at 50 scaled_values = [max(0, min(100, (z * 30) + 35)) for z in z_scores] return scaled_values def simplify_model_name(name): if name == "claude-3-7-sonnet-20250219(thinking)": name ="claude-3-7-thinking" parts = name.split('-') return '-'.join(parts[:4]) + '-...' if len(parts) > 4 else name def create_horizontal_bar_chart(df, game_name): """Creates a horizontal bar chart for a given game's leaderboard data.""" if df is None or df.empty: # Return a placeholder or an empty figure if there's no data fig = go.Figure() fig.update_layout( title=f"No data available for {game_name}", xaxis_title="Score", yaxis_title="Player", plot_bgcolor='rgba(0,0,0,0)', paper_bgcolor='rgba(0,0,0,0)', font=dict(color='#2c3e50') ) return fig score_col = "Score" # Standardized score column name if score_col not in df.columns: fig = go.Figure() fig.update_layout(title=f"'{score_col}' column not found for {game_name}") return fig # Ensure the score column is numeric for sorting and plotting df[score_col] = pd.to_numeric(df[score_col], errors='coerce') df_cleaned = df.dropna(subset=[score_col]) # Remove rows where score is NaN after conversion if df_cleaned.empty: fig = go.Figure() fig.update_layout(title=f"No valid score data to plot for {game_name}") return fig # Sort values for chart display (lowest score at the top of the chart) # The input df is already sorted descending by score from leaderboard_utils # Re-sorting ascending=True here means player with lowest score is at the top of the y-axis categories df_sorted = df_cleaned.sort_values(by=score_col, ascending=True) fig = go.Figure( go.Bar( y=df_sorted['Player'], x=df_sorted[score_col], orientation='h', marker=dict( color=df_sorted[score_col], colorscale='Viridis', # Example colorscale, can be changed line=dict(color='#2c3e50', width=1) ), hovertext=df_sorted[score_col].round(2).astype(str) + ' points', hoverinfo='y+text' ) ) fig.update_layout( title=dict( text=f'{game_name} Scores', x=0.5, font=dict(size=20, color='#2c3e50') ), xaxis_title="Score", yaxis_title="Player", plot_bgcolor='rgba(0,0,0,0)', # Transparent plot background paper_bgcolor='rgba(0,0,0,0)', # Transparent paper background font=dict(color='#2c3e50'), # Dark text for better readability on light backgrounds margin=dict(l=150, r=20, t=50, b=50), # Adjust margins for player names yaxis=dict( automargin=True, tickfont=dict(size=10) ), xaxis=dict(gridcolor='#e0e0e0') # Light gridlines for x-axis ) return fig def create_radar_charts(df): game_cols = [c for c in df.columns if c.endswith(" Score")] categories = [c.replace(" Score", "") for c in game_cols] for col in game_cols: vals = df[col].replace("n/a", 0).infer_objects(copy=False).astype(float) mean, std = vals.mean(), vals.std() df[f"norm_{col}"] = normalize_values(vals, mean, std) fig = go.Figure() for _, row in df.iterrows(): player = row["Player"] r = [row[f"norm_{c}"] for c in game_cols] color = MODEL_COLORS.get(player, '#808080') # fallback to gray fig.add_trace(go.Scatterpolar( r=r + [r[0]], theta=categories + [categories[0]], mode='lines+markers', fill='toself', name=player, line=dict(color=color, width=2), marker=dict(color=color), fillcolor=color + '33', # add transparency to fill (33 = ~20% opacity) opacity=0.8 )) fig.update_layout( autosize=False, width=800, height=600, margin=dict(l=80, r=150, t=20, b=20), title=dict( text="Radar Chart of AI Performance (Normalized)", pad=dict(t=10) ), polar=dict(radialaxis=dict(visible=True, range=[0, 100])), legend=dict( font=dict(size=9), itemsizing='trace', x=1.4, y=1, xanchor='left', yanchor='top', bgcolor='rgba(255,255,255,0.6)', bordercolor='gray', borderwidth=1 ) ) return fig def get_combined_leaderboard_with_radar(rank_data, selected_games): df = get_combined_leaderboard(rank_data, selected_games) # Create a copy for visualization to avoid modifying the original df_viz = df.copy() return df, create_radar_charts(df_viz) def create_group_bar_chart(df, top_n=5): game_cols = {} for game in GAME_ORDER: col = f"{game} Score" if col in df.columns: # Replace "n/a" with np.nan and handle downcasting properly df[col] = df[col].replace("n/a", np.nan).infer_objects(copy=False).astype(float) if df[col].notna().any(): game_cols[game] = col if not game_cols: return go.Figure().update_layout(title="No data available") # Drop players with no data df = df.dropna(subset=game_cols.values(), how='all') # Normalize scores per game for game, col in game_cols.items(): valid = df[col].dropna() norm_col = f"norm_{col}" if valid.empty: df[norm_col] = np.nan else: mean, std = valid.mean(), valid.std() normalized = normalize_values(valid, mean, std) df[norm_col] = np.nan df.loc[valid.index, norm_col] = normalized # Build consistent game order (X-axis) sorted_games = [game for game in GAME_ORDER if f"norm_{game} Score" in df.columns] # Format game names with line breaks formatted_games = [] for game in sorted_games: if len(game) > 10 and ' ' in game: parts = game.split(' ') midpoint = len(parts) // 2 formatted_name = ' '.join(parts[:midpoint]) + '
' + ' '.join(parts[midpoint:]) formatted_games.append(formatted_name) else: formatted_games.append(game) # Create mapping from original to formatted names game_display_map = dict(zip(sorted_games, formatted_games)) # For each game, get top performers and create combined x-axis categories fig = go.Figure() all_x_categories = [] all_players = set() unique_x_labels = [] # First pass: collect all players and create x-axis categories game_rankings = {} for game in sorted_games: col = f"norm_{game} Score" # Get valid scores for this game and sort by score (highest first) game_data = df[df[col].notna()].copy() game_data = game_data.sort_values(by=col, ascending=False) # Store rankings for this game (limit to top_n) game_rankings[game] = [] for i, (_, row) in enumerate(game_data.iterrows()): if i >= top_n: # Limit to top_n performers break player = row["Player"] score = row[col] rank = i + 1 x_category = f"{game_display_map[game]}
#{rank}" game_rankings[game].append({ 'player': player, 'score': score, 'x_category': x_category, 'rank': rank }) all_x_categories.append(x_category) all_players.add(player) # Show label at the middle position based on number of models middle_position = (top_n + 1) // 2 if rank == middle_position: # Special case for Super Mario Bros (planning only) if game == "Super Mario Bros": unique_x_labels.append("SMB") else: unique_x_labels.append(game_display_map[game]) # Show just game name without rank else: unique_x_labels.append("") # Empty string for other ranks # Second pass: create traces for each player for player in sorted(all_players): x_vals = [] y_vals = [] for game in sorted_games: # Find this player's data for this game player_data = None for data in game_rankings[game]: if data['player'] == player: player_data = data break if player_data: x_vals.append(player_data['x_category']) y_vals.append(player_data['score']) if x_vals: # Only add trace if player has data fig.add_trace(go.Bar( name=player, x=x_vals, y=y_vals, marker_color=MODEL_COLORS.get(player, '#808080'), hovertemplate="%{fullData.name}
Score: %{y:.1f}" )) fig.update_layout( autosize=True, height=550, margin=dict(l=50, r=50, t=20, b=20), title=dict(text=f"Grouped Bar Chart - Top {top_n} Performers by Game", pad=dict(t=10)), xaxis_title="Games (Ranked by Performance)", yaxis_title="Normalized Score", xaxis=dict( categoryorder='array', categoryarray=all_x_categories, tickangle=0, # Keep text horizontal since we're using line breaks ticktext=unique_x_labels, # Show labels only for first occurrence tickvals=all_x_categories ), barmode='group', bargap=0.2, # Gap between game categories bargroupgap=0.05, # Gap between bars in a group uniformtext=dict(mode='hide', minsize=8), # Hide text that doesn't fit legend=dict( font=dict(size=12), title="Choose your model 💡 (click / double-click)", itemsizing='trace', x=1.1, y=1, xanchor='left', yanchor='top', bgcolor='rgba(255,255,255,0.6)', bordercolor='gray', borderwidth=1 ) ) return fig def get_combined_leaderboard_with_group_bar(rank_data, selected_games, top_n=5, limit_to_top_n=None): df = get_combined_leaderboard(rank_data, selected_games, limit_to_top_n) # Create a copy for visualization to avoid modifying the original df_viz = df.copy() return df, create_group_bar_chart(df_viz, top_n) def hex_to_rgba(hex_color, alpha=0.2): hex_color = hex_color.lstrip('#') r = int(hex_color[0:2], 16) g = int(hex_color[2:4], 16) b = int(hex_color[4:6], 16) return f'rgba({r}, {g}, {b}, {alpha})' def create_single_radar_chart(df, selected_games=None, highlight_models=None, chart_title=None, top_n=None, full_df=None): if selected_games is None: selected_games = ['Super Mario Bros', '2048', 'Candy Crush', 'Sokoban', 'Ace Attorney'] # Format game names formatted_games = [] for game in selected_games: if game == 'Super Mario Bros': formatted_games.append('SMB') # Clean name without planning only else: formatted_games.append(game) # Keep other names as is game_cols = [f"{game} Score" for game in selected_games] categories = formatted_games # Use full dataset for normalization to keep consistent scale # If full_df is not provided, use the current df (fallback for backward compatibility) normalization_df = full_df if full_df is not None else df # Normalize using the full dataset but apply to the limited df for col in game_cols: # Get normalization parameters from full dataset # Use where() to avoid FutureWarning about downcasting in replace() full_series = normalization_df[col].copy() full_series = full_series.where(full_series != "n/a", 0) full_vals = full_series.astype(float) mean, std = full_vals.mean(), full_vals.std() # Apply normalization to the limited df # Use where() to avoid FutureWarning about downcasting in replace() limited_series = df[col].copy() limited_series = limited_series.where(limited_series != "n/a", 0) limited_vals = limited_series.astype(float) df[f"norm_{col}"] = normalize_values(limited_vals, mean, std) # Group players by prefix and sort alphabetically model_groups = {} for player in df["Player"]: prefix = get_model_prefix(player) model_groups.setdefault(prefix, []).append(player) # Sort each group alphabetically for prefix in model_groups: model_groups[prefix] = sorted(model_groups[prefix], key=str.lower) # Get sorted prefixes and create ordered player list sorted_prefixes = sorted(model_groups.keys(), key=str.lower) grouped_players = [] for prefix in sorted_prefixes: grouped_players.extend(model_groups[prefix]) fig = go.Figure() for player in grouped_players: row = df[df["Player"] == player] if row.empty: continue row = row.iloc[0] is_highlighted = highlight_models and player in highlight_models color = 'red' if is_highlighted else MODEL_COLORS.get(player, '#808080') fillcolor = 'rgba(255, 0, 0, 0.4)' if is_highlighted else hex_to_rgba(color, 0.2) r = [row[f"norm_{col}"] for col in game_cols] # Convert player name to lowercase for the legend display_name = player.lower() fig.add_trace(go.Scatterpolar( r=r + [r[0]], theta=categories + [categories[0]], mode='lines+markers', fill='toself', name=display_name, # Use lowercase name in legend line=dict(color=color, width=6 if is_highlighted else 2), marker=dict(color=color, size=10 if is_highlighted else 6), fillcolor=fillcolor, opacity=1.0 if is_highlighted else 0.7, hovertemplate='%{fullData.name}
Game: %{theta}
Score: %{r:.1f}' )) # Dynamic title based on the data source and top_n if chart_title is None: if top_n is not None: chart_title = f"Radar Chart - Top {top_n} Performers by Game" else: # Fallback title if len(df) <= 10: chart_title = "🎮 Agent Performance Across Games" else: chart_title = "🤖 Model Performance Across Games" fig.update_layout( autosize=True, height=550, # Reduced height for better proportion with legend margin=dict(l=400, r=100, t=20, b=20), title=dict( text=chart_title, x=0.5, xanchor='center', yanchor='top', y=0.95, font=dict(size=20), pad=dict(b=20) ), polar=dict( radialaxis=dict( visible=True, range=[0, 100], tickangle=45, tickfont=dict(size=12), gridcolor='lightgray', gridwidth=1, angle=45 ), angularaxis=dict( tickfont=dict(size=14, weight='bold'), tickangle=0 ) ), legend=dict( font=dict(size=12), title="Choose your model 💡 (click / double-click)", itemsizing='trace', x=-1.4, # Moved further left y=0.8, # Moved to top yanchor='top', xanchor='left', bgcolor='rgba(255,255,255,0.6)', bordercolor='gray', borderwidth=1 ) ) fig.update_layout( legend=dict( itemclick="toggleothers", # This will make clicked item the only visible one itemdoubleclick="toggle" # Double click toggles visibility ) ) return fig def get_combined_leaderboard_with_single_radar(rank_data, selected_games, highlight_models=None, limit_to_top_n=None, chart_title=None, top_n=None): # Get full dataset for normalization full_df = get_combined_leaderboard(rank_data, selected_games, limit_to_top_n=None) # Get limited dataset for display df = get_combined_leaderboard(rank_data, selected_games, limit_to_top_n) selected_game_names = [g for g, sel in selected_games.items() if sel] # Create copies for visualization to avoid modifying the original df_viz = df.copy() full_df_viz = full_df.copy() return df, create_single_radar_chart(df_viz, selected_game_names, highlight_models, chart_title, top_n, full_df_viz) def create_organization_radar_chart(rank_data): df = get_combined_leaderboard(rank_data, {g: True for g in GAME_ORDER}) orgs = df["Organization"].unique() game_cols = [f"{g} Score" for g in GAME_ORDER if f"{g} Score" in df.columns] categories = [g.replace(" Score", "") for g in game_cols] avg_df = pd.DataFrame([ { **{col: df[df["Organization"] == org][col].where(df[df["Organization"] == org][col] != "n/a", 0).astype(float).mean() for col in game_cols}, "Organization": org } for org in orgs ]) for col in game_cols: vals = avg_df[col] mean, std = vals.mean(), vals.std() avg_df[f"norm_{col}"] = normalize_values(vals, mean, std) fig = go.Figure() for _, row in avg_df.iterrows(): r = [row[f"norm_{col}"] for col in game_cols] fig.add_trace(go.Scatterpolar( r=r + [r[0]], theta=categories + [categories[0]], mode='lines+markers', fill='toself', name=row["Organization"] )) fig.update_layout( autosize=False, width=800, height=600, margin=dict(l=80, r=150, t=20, b=20), title=dict( text="Radar Chart: Organization Performance (Normalized)", pad=dict(t=10) ), polar=dict(radialaxis=dict(visible=True, range=[0, 100])), legend=dict( font=dict(size=9), itemsizing='trace', x=1.4, y=1, xanchor='left', yanchor='top', bgcolor='rgba(255,255,255,0.6)', bordercolor='gray', borderwidth=1 ) ) return fig def create_top_players_radar_chart(rank_data, n=5): df = get_combined_leaderboard(rank_data, {g: True for g in GAME_ORDER}) top_players = df.head(n)["Player"].tolist() top_df = df[df["Player"].isin(top_players)] game_cols = [f"{g} Score" for g in GAME_ORDER if f"{g} Score" in df.columns] categories = [g.replace(" Score", "") for g in game_cols] for col in game_cols: # Replace "n/a" with 0 and handle downcasting properly # Use where() to avoid FutureWarning about downcasting in replace() series = top_df[col].copy() series = series.where(series != "n/a", 0) vals = series.astype(float) mean, std = vals.mean(), vals.std() top_df[f"norm_{col}"] = normalize_values(vals, mean, std) fig = go.Figure() for _, row in top_df.iterrows(): r = [row[f"norm_{col}"] for col in game_cols] fig.add_trace(go.Scatterpolar( r=r + [r[0]], theta=categories + [categories[0]], mode='lines+markers', fill='toself', name=row["Player"] )) fig.update_layout( autosize=False, width=800, height=600, margin=dict(l=80, r=150, t=20, b=20), title=dict( text=f"Top {n} Players Radar Chart (Normalized)", pad=dict(t=10) ), polar=dict(radialaxis=dict(visible=True, range=[0, 100])), legend=dict( font=dict(size=9), itemsizing='trace', x=1.4, y=1, xanchor='left', yanchor='top', bgcolor='rgba(255,255,255,0.6)', bordercolor='gray', borderwidth=1 ) ) return fig def create_player_radar_chart(rank_data, player_name): df = get_combined_leaderboard(rank_data, {g: True for g in GAME_ORDER}) player_df = df[df["Player"] == player_name] if player_df.empty: return go.Figure().update_layout( title=dict(text="Player not found", pad=dict(t=10)), autosize=False, width=800, height=400 ) game_cols = [f"{g} Score" for g in GAME_ORDER if f"{g} Score" in df.columns] categories = [g.replace(" Score", "") for g in game_cols] for col in game_cols: # Replace "n/a" with 0 and handle downcasting properly # Use where() to avoid FutureWarning about downcasting in replace() player_series = player_df[col].copy() player_series = player_series.where(player_series != "n/a", 0) vals = player_series.astype(float) df_series = df[col].copy() df_series = df_series.where(df_series != "n/a", 0) df_vals = df_series.astype(float) mean, std = df_vals.mean(), df_vals.std() player_df[f"norm_{col}"] = normalize_values(vals, mean, std) fig = go.Figure() for _, row in player_df.iterrows(): r = [row[f"norm_{col}"] for col in game_cols] fig.add_trace(go.Scatterpolar( r=r + [r[0]], theta=categories + [categories[0]], mode='lines+markers', fill='toself', name=row["Player"] )) fig.update_layout( autosize=False, width=800, height=600, margin=dict(l=80, r=150, t=20, b=20), title=dict( text=f"{row['Player']} Radar Chart (Normalized)", pad=dict(t=10) ), polar=dict(radialaxis=dict(visible=True, range=[0, 100])), legend=dict( font=dict(size=9), itemsizing='trace', x=1.4, y=1, xanchor='left', yanchor='top', bgcolor='rgba(255,255,255,0.6)', bordercolor='gray', borderwidth=1 ) ) return fig def save_normalized_data(df, selected_games, filename="normalized_data.json"): """ Save normalized data to a JSON file for caching Args: df (pd.DataFrame): DataFrame with raw scores selected_games (dict): Dictionary of selected games filename (str): Output filename """ game_cols = [f"{game} Score" for game in GAME_ORDER if f"{game} Score" in df.columns] # Calculate normalization parameters and normalized values normalization_data = { "timestamp": datetime.now().isoformat(), "selected_games": selected_games, "games": {}, "players": {} } # Store normalization parameters per game for col in game_cols: game_name = col.replace(" Score", "") vals = df[col].replace("n/a", 0).infer_objects(copy=False).astype(float) mean, std = vals.mean(), vals.std() normalization_data["games"][game_name] = { "mean": mean, "std": std, "raw_scores": vals.to_dict() } # Store normalized scores per player for _, row in df.iterrows(): player = row["Player"] player_data = {"organization": row.get("Organization", "unknown")} for col in game_cols: game_name = col.replace(" Score", "") raw_score = row[col] if raw_score != "n/a": raw_score = float(raw_score) mean = normalization_data["games"][game_name]["mean"] std = normalization_data["games"][game_name]["std"] normalized = normalize_values([raw_score], mean, std)[0] else: raw_score = "n/a" normalized = 0 player_data[f"{game_name}_raw"] = raw_score player_data[f"{game_name}_normalized"] = normalized normalization_data["players"][player] = player_data # Save to file os.makedirs("cache", exist_ok=True) filepath = os.path.join("cache", filename) with open(filepath, 'w') as f: json.dump(normalization_data, f, indent=2) print(f"Normalized data saved to {filepath}") return filepath def load_normalized_data(filename="normalized_data.json"): """ Load normalized data from a JSON file Args: filename (str): Input filename Returns: dict: Normalized data or None if file doesn't exist """ filepath = os.path.join("cache", filename) if not os.path.exists(filepath): return None try: with open(filepath, 'r') as f: data = json.load(f) print(f"Normalized data loaded from {filepath}") return data except Exception as e: print(f"Error loading normalized data: {e}") return None def get_normalized_scores_from_cache(players, games, cache_data): """ Extract normalized scores from cached data Args: players (list): List of player names games (list): List of game names cache_data (dict): Cached normalization data Returns: pd.DataFrame: DataFrame with normalized scores """ data = [] for player in players: if player in cache_data["players"]: player_data = {"Player": player} player_cache = cache_data["players"][player] for game in games: raw_key = f"{game}_raw" norm_key = f"{game}_normalized" if raw_key in player_cache: player_data[f"{game} Score"] = player_cache[raw_key] player_data[f"norm_{game} Score"] = player_cache[norm_key] else: player_data[f"{game} Score"] = "n/a" player_data[f"norm_{game} Score"] = 0 data.append(player_data) return pd.DataFrame(data) def save_visualization(fig, filename): fig.write_image(filename) def generate_and_save_normalized_data(rank_data, filename="normalized_data.json"): """ Generate normalized data for all games and save to file Args: rank_data (dict): Raw rank data filename (str): Output filename Returns: str: Path to saved file """ # Select all games all_games = {game: True for game in GAME_ORDER} # Get combined leaderboard df = get_combined_leaderboard(rank_data, all_games) # Save normalized data return save_normalized_data(df, all_games, filename) def create_single_radar_chart_with_cache(df, selected_games=None, highlight_models=None, use_cache=True, cache_filename="normalized_data.json"): """ Create radar chart with optional caching support """ if selected_games is None: selected_games = ['Super Mario Bros', '2048', 'Candy Crush', 'Sokoban', 'Ace Attorney'] # Try to load from cache first cached_data = None if use_cache: cached_data = load_normalized_data(cache_filename) if cached_data: # Use cached normalized data players = df["Player"].tolist() df_normalized = get_normalized_scores_from_cache(players, selected_games, cached_data) # Merge with original df to get Organization info df_normalized = df_normalized.merge(df[["Player", "Organization"]], on="Player", how="left") else: # Fall back to on-the-fly normalization df_normalized = df.copy() game_cols = [f"{game} Score" for game in selected_games] # Normalize for col in game_cols: vals = df_normalized[col].replace("n/a", 0).infer_objects(copy=False).astype(float) mean, std = vals.mean(), vals.std() df_normalized[f"norm_{col}"] = normalize_values(vals, mean, std) # Format game names formatted_games = [] for game in selected_games: if game == 'Super Mario Bros': formatted_games.append('SMB') else: formatted_games.append(game) categories = formatted_games # Group players by prefix and sort alphabetically model_groups = {} for player in df_normalized["Player"]: prefix = get_model_prefix(player) model_groups.setdefault(prefix, []).append(player) # Sort each group alphabetically for prefix in model_groups: model_groups[prefix] = sorted(model_groups[prefix], key=str.lower) # Get sorted prefixes and create ordered player list sorted_prefixes = sorted(model_groups.keys(), key=str.lower) grouped_players = [] for prefix in sorted_prefixes: grouped_players.extend(model_groups[prefix]) fig = go.Figure() for player in grouped_players: row = df_normalized[df_normalized["Player"] == player] if row.empty: continue row = row.iloc[0] is_highlighted = highlight_models and player in highlight_models color = 'red' if is_highlighted else MODEL_COLORS.get(player, '#808080') fillcolor = 'rgba(255, 0, 0, 0.4)' if is_highlighted else hex_to_rgba(color, 0.2) # Get normalized values if cached_data: r = [row[f"norm_{game} Score"] for game in selected_games] else: r = [row[f"norm_{game} Score"] for game in selected_games] display_name = player.lower() fig.add_trace(go.Scatterpolar( r=r + [r[0]], theta=categories + [categories[0]], mode='lines+markers', fill='toself', name=display_name, line=dict(color=color, width=6 if is_highlighted else 2), marker=dict(color=color, size=10 if is_highlighted else 6), fillcolor=fillcolor, opacity=1.0 if is_highlighted else 0.7, hovertemplate='%{fullData.name}
Game: %{theta}
Score: %{r:.1f}' )) fig.update_layout( autosize=True, height=550, margin=dict(l=400, r=100, t=20, b=20), title=dict( text="AI Normalized Performance Across Games", x=0.5, xanchor='center', yanchor='top', y=0.95, font=dict(size=20), pad=dict(b=20) ), polar=dict( radialaxis=dict( visible=True, range=[0, 100], tickangle=45, tickfont=dict(size=12), gridcolor='lightgray', gridwidth=1, angle=45 ), angularaxis=dict( tickfont=dict(size=14, weight='bold'), tickangle=0 ) ), legend=dict( font=dict(size=12), title="Choose your model 💡 (click / double-click)", itemsizing='trace', x=-1.4, y=0.8, yanchor='top', xanchor='left', bgcolor='rgba(255,255,255,0.6)', bordercolor='gray', borderwidth=1, itemclick="toggleothers", itemdoubleclick="toggle" ) ) return fig