Files
EOJHL-LED-Scoreboard/src/leaderboard_manager.py
2026-02-13 12:22:06 -05:00

214 lines
9.9 KiB
Python

import logging
import os
import time
from typing import Dict, Any, List, Optional
from PIL import Image, ImageDraw, ImageFont
logger = logging.getLogger(__name__)
class LeaderboardManager:
"""
Generic leaderboard renderer: scrolls a league strip of:
[league logo] [conference label] [#rank] [team logo] [abbr] [record (optional, spacing preserved)]
League-specific data is injected via league_data from the league manager.
"""
def __init__(self, config: Dict[str, Any], display_manager, league_data: Dict[str, Any]):
self.leaderboard_config = config.get("leaderboard", {})
self.is_enabled = self.leaderboard_config.get("enabled", False)
self.update_interval = self.leaderboard_config.get("update_interval", 3600)
self.scroll_speed = max(1, self.leaderboard_config.get("scroll_speed", 1))
self.scroll_delay = self.leaderboard_config.get("scroll_delay", 0.01)
self.loop = self.leaderboard_config.get("loop", False)
self.show_record = self.leaderboard_config.get("show_record", True)
self.display_manager = display_manager
self.league_data = league_data
self.fonts = self._load_fonts()
self._cached_standings: Optional[Dict[str, List[Dict[str, Any]]]] = None
self._last_standings_reload: float = 0.0
def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]:
try:
return {
'small': ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 6),
'medium': ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8),
'large': ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10),
'xlarge': ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12),
}
except IOError:
logger.warning("Custom fonts not found, falling back to default PIL font.")
return {k: ImageFont.load_default() for k in ['small','medium','large','xlarge']}
def _get_team_logo(self, logo_path: str) -> Optional[Image.Image]:
try:
if logo_path and os.path.exists(logo_path):
logo = Image.open(logo_path).convert("RGBA")
bbox = logo.getbbox()
if bbox:
logo = logo.crop(bbox)
max_h = self.display_manager.height
max_w = int(max_h * 1.5)
scale = min(max_w / logo.width, max_h / logo.height)
return logo.resize((int(logo.width * scale), int(logo.height * scale)), Image.Resampling.LANCZOS)
except Exception as e:
logger.error(f"Error loading logo {logo_path}: {e}")
return None
def _fetch_standings(self) -> Dict[str, List[Dict[str, Any]]]:
now = time.time()
if self._cached_standings and (now - self._last_standings_reload < self.update_interval):
return self._cached_standings
provider = self.league_data.get("standings_provider")
try:
standings = provider() if callable(provider) else self.league_data.get("standings", {})
if isinstance(standings, dict):
self._cached_standings = standings
self._last_standings_reload = now
logger.info(f"[Leaderboard] Standings reloaded: {list(standings.keys())}")
return standings
except Exception as e:
logger.error(f"[Leaderboard] standings_provider failed: {e}")
return self._cached_standings or {}
def display(self):
if not self.is_enabled:
logger.info("[Leaderboard] Disabled in config")
return
standings = self._fetch_standings()
conferences: List[str] = self.league_data.get("conferences", [])
if not conferences or not any(standings.get(conf) for conf in conferences):
logger.info("[Leaderboard] No conferences or empty standings")
return
strip_height = self.display_manager.height
logo_spacing = self.leaderboard_config.get("logo_spacing", 20)
conference_spacing = self.leaderboard_config.get("conference_spacing", 60)
# --- Measure total width ---
dummy_img = Image.new("RGB", (1, 1))
draw = ImageDraw.Draw(dummy_img)
total_width = 0
league_logo_path = self.league_data.get("league_logo", "")
league_logo_probe = self._get_team_logo(league_logo_path)
if league_logo_probe:
total_width += league_logo_probe.width + logo_spacing
for conf in conferences:
teams = standings.get(conf, [])
if not teams:
continue
conf_text = f"{conf.capitalize()} Div."
total_width += int(draw.textlength(conf_text, font=self.fonts['medium'])) + 40
for rank, team in enumerate(teams, start=1):
total_width += int(draw.textlength(f"#{rank}.", font=self.fonts['xlarge'])) + 5
total_width += int(strip_height * 1.5) + 5 # logo box space
abbr = team.get("abbreviation", team.get("name", "")[:3])
total_width += int(draw.textlength(abbr, font=self.fonts['large'])) + 10
record = team.get("record", "")
total_width += (int(draw.textlength(record, font=self.fonts['medium'])) + 30) if record else 30
total_width += conference_spacing
# --- Build strip ---
strip = Image.new("RGB", (total_width, strip_height), (0, 0, 0))
draw = ImageDraw.Draw(strip)
x_offset = 0
league_logo = self._get_team_logo(league_logo_path)
if league_logo:
strip.paste(league_logo, (x_offset, (strip_height - league_logo.height) // 2), league_logo)
x_offset += league_logo.width + logo_spacing
for conf in conferences:
teams = standings.get(conf, [])
if not teams:
continue
conf_text = f"{conf.capitalize()} Div."
conf_y = (strip_height - (self.fonts['medium'].getbbox(conf_text)[3])) // 2
draw.text((x_offset, conf_y), conf_text, font=self.fonts['medium'], fill=(0, 200, 255))
x_offset += int(draw.textlength(conf_text, font=self.fonts['medium'])) + 40
for rank, team in enumerate(teams, start=1):
rank_text = f"#{rank}."
rank_y = (strip_height - (self.fonts['xlarge'].getbbox(rank_text)[3])) // 2
draw.text((x_offset, rank_y), rank_text, font=self.fonts['xlarge'], fill=(255, 255, 0))
x_offset += int(draw.textlength(rank_text, font=self.fonts['xlarge'])) + 5
logo = self._get_team_logo(team.get("logo", ""))
if logo:
strip.paste(logo, (x_offset, (strip_height - logo.height) // 2), logo)
x_offset += logo.width + 5
else:
# Reserve logo space even if missing, for consistent layout
x_offset += int(strip_height * 1.5) + 5
abbr = team.get("abbreviation", team.get("name", "")[:3])
abbr_y = (strip_height - (self.fonts['large'].getbbox(abbr)[3])) // 2
draw.text((x_offset, abbr_y), abbr, font=self.fonts['large'], fill=(255, 255, 255))
x_offset += int(draw.textlength(abbr, font=self.fonts['large'])) + 10
record = team.get("record", "")
if self.show_record and record:
rec_y = (strip_height - (self.fonts['medium'].getbbox(record)[3])) // 2
draw.text((x_offset, rec_y), record, font=self.fonts['medium'], fill=(255, 255, 0))
x_offset += int(draw.textlength(record, font=self.fonts['medium'])) + 30
else:
x_offset += 30
x_offset += conference_spacing
# --- Scroll ---
visible_w = self.display_manager.width
intro_hold = self.leaderboard_config.get("logo_intro_hold", 0)
if self.leaderboard_config.get("dynamic_duration", False):
est_duration = (total_width / self.scroll_speed) * self.scroll_delay
duration = max(
self.leaderboard_config.get("min_duration", 30),
min(est_duration, self.leaderboard_config.get("max_display_time", 600)),
)
else:
duration = self.leaderboard_config.get("max_display_time", 600)
end_time = time.time() + duration
def scroll_once():
for start_x in range(-visible_w, x_offset, self.scroll_speed):
# Crop the appropriate segment from the strip
frame = strip.crop((max(0, start_x), 0, start_x + visible_w, strip_height))
# Create a visible frame canvas and paste the cropped segment at the right offset
frame_canvas = Image.new("RGB", (visible_w, strip_height), (0, 0, 0))
paste_x = 0 if start_x >= 0 else -start_x
frame_canvas.paste(frame, (paste_x, 0))
# Paint to the matrix
self.display_manager.image.paste(frame_canvas, (0, 0))
self.display_manager.update_display()
# Optional intro hold when the strip first fully aligns
if start_x == 0 and intro_hold > 0:
time.sleep(intro_hold)
time.sleep(self.scroll_delay)
while True:
scroll_once()
if not self.loop or time.time() >= end_time:
break
# --- Static league logo hold at the end ---
if league_logo:
canvas = Image.new("RGB", (visible_w, strip_height), (0, 0, 0))
offset_x = (visible_w - league_logo.width) // 2
offset_y = (strip_height - league_logo.height) // 2
canvas.paste(league_logo, (offset_x, offset_y), league_logo)
self.display_manager.image.paste(canvas, (0, 0))
self.display_manager.update_display()
time.sleep(self.leaderboard_config.get("logo_hold", 3))