Files
ramseshk 65f5b66b05 Dashboard: Stadium Night design system, 5-page Streamlit app, warehouse exporter
- src/export/warehouse.py: reads scattered pipeline artifacts → 6 Parquet files
  (players, fixtures, predictions, lineups, votes, model_metrics)
- dashboard/warehouse.py: read-only cached Parquet loader
- .streamlit/config.toml: dark theme base, server config
- dashboard/viz/template.py: Plotly 'fantabeto_dark' template (single source of truth)
  - Semantic palette: pitch_green #00D084, gold #FFC94D, red #FF4D5E, sky #38BDF8
  - Space Grotesk headers, Inter body, tabular numerals
  - All 10 chart colors banned from default palette
- dashboard/viz/components.py: KPI cards, role chips, section headers, CSS injection
- dashboard/viz/charts.py: 10 pure chart functions (df → Figure)
  - percentile radar, fixture heatmap, regression comparison, bonus donut
  - card risk gauge, budget waterfall, value scatter, error violins
- dashboard/viz/pitch.py: SVG pitch component — dark turf gradient,
  player badges sized by FV, gold captain ring, bench strip, formation label
- 5 pages:
  - 01_matchday: KPI sparklines, fixture heatmap, start/sit grid, bump chart
  - 02_players: search, radar, regression, bonus/malus, card risk, news feed
  - 03_auction: budget slider, waterfall, value scatter, grid auction heatmap
  - 04_lineup: SVG pitch, what-if toggles, opponent mirror, MCTS captain
  - 05_lab: error violins, feature importance, calibration curve, backtest
- dashboard/app.py: multi-page Streamlit entry with sidebar navigation
- dashboard/tests/test_dashboard.py: 18 unit tests (warehouse, template, charts, pitch)
- DASHBOARD.md: full architecture docs, design system reference
- 49 total tests passing (31 existing + 18 dashboard)
2026-08-11 14:53:51 +08:00

178 lines
6.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""SVG pitch component — dark turf gradient, player badges, bench strip.
Renders a vertical football pitch (105m × 68m) with players positioned
according to their role and formation. Badge size = projected FV.
Gold ring = captain.
"""
import streamlit.components.v1 as components
from .template import PITCH_GREEN, GOLD, WHITE, RED, SKY, TEXT_SECONDARY, BG, CARD_BG, BORDER, ROLE_COLORS
# Position templates by formation (player index -> [x%, y%])
FORMATION_POSITIONS = {
"4-4-2": {
"P": [(50, 92)],
"D": [(15, 72), (38, 72), (62, 72), (85, 72)],
"C": [(15, 48), (38, 48), (62, 48), (85, 48)],
"A": [(35, 24), (65, 24)],
},
"4-3-3": {
"P": [(50, 92)],
"D": [(15, 72), (38, 72), (62, 72), (85, 72)],
"C": [(25, 48), (50, 48), (75, 48)],
"A": [(20, 24), (50, 24), (80, 24)],
},
"3-5-2": {
"P": [(50, 92)],
"D": [(25, 72), (50, 72), (75, 72)],
"C": [(10, 48), (30, 48), (50, 48), (70, 48), (90, 48)],
"A": [(35, 24), (65, 24)],
},
"4-2-3-1": {
"P": [(50, 92)],
"D": [(15, 72), (38, 72), (62, 72), (85, 72)],
"C": [(35, 55), (65, 55)],
"A": [(50, 35)], # CAM + ST
},
"3-4-3": {
"P": [(50, 92)],
"D": [(25, 72), (50, 72), (75, 72)],
"C": [(15, 48), (38, 48), (62, 48), (85, 48)],
"A": [(20, 24), (50, 24), (80, 24)],
},
"3-4-2-1": {
"P": [(50, 92)],
"D": [(25, 72), (50, 72), (75, 72)],
"C": [(15, 48), (38, 48), (62, 48), (85, 48)],
"A": [(50, 35), (35, 22), (65, 22)],
},
}
def render_pitch(
lineup: list[dict],
formation: str = "4-4-2",
captain: str = "",
bench: list[dict] | None = None,
width: int = 700,
height: int = 600,
) -> str:
"""Generate SVG pitch with player badges.
Args:
lineup: list of {'name': str, 'role': str, 'fv': float, 'starter_pct': float}
formation: e.g. "4-4-2"
captain: player name to highlight with gold ring
bench: optional bench players
"""
positions = FORMATION_POSITIONS.get(formation, FORMATION_POSITIONS["4-4-2"])
# Assign positions to players by role
role_slots = {"P": 0, "D": 0, "C": 0, "A": 0}
player_positions = []
for p in lineup:
role = p.get("role", "C")
idx = role_slots.get(role, 0)
pos_list = positions.get(role, [(50, 50)])
if idx < len(pos_list):
x, y = pos_list[idx]
else:
x, y = 50, 50
role_slots[role] = idx + 1
player_positions.append((p, x, y))
# Scale FV to badge size
fvs = [p.get("fv", 6) for p in lineup]
min_fv, max_fv = min(fvs), max(fvs)
fv_range = max(max_fv - min_fv, 1)
svg_parts = [f"""
<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg"
style="background: linear-gradient(180deg, #1a5c2a 0%, #0d3318 100%);
border-radius: 14px; border: 1px solid {BORDER};">
<defs>
<filter id="glow">
<feGaussianBlur stdDeviation="3" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
"""]
# Pitch markings
svg_parts.append(f"""
<rect x="{width*0.05}" y="{height*0.05}" width="{width*0.9}" height="{height*0.9}"
fill="none" stroke="rgba(255,255,255,0.15)" stroke-width="2" rx="4"/>
<line x1="{width*0.05}" y1="{height*0.5}" x2="{width*0.95}" y2="{height*0.5}"
stroke="rgba(255,255,255,0.15)" stroke-width="1.5"/>
<circle cx="{width*0.5}" cy="{height*0.5}" r="{width*0.1}" fill="none"
stroke="rgba(255,255,255,0.15)" stroke-width="1.5"/>
<circle cx="{width*0.5}" cy="{height*0.5}" r="3" fill="rgba(255,255,255,0.3)"/>
<!-- penalty areas -->
<rect x="{width*0.2}" y="{height*0.05}" width="{width*0.6}" height="{height*0.18}"
fill="none" stroke="rgba(255,255,255,0.12)" stroke-width="1.5"/>
<rect x="{width*0.2}" y="{height*0.77}" width="{width*0.6}" height="{height*0.18}"
fill="none" stroke="rgba(255,255,255,0.12)" stroke-width="1.5"/>
""")
# Player badges
for p, px, py in player_positions:
fv = p.get("fv", 6)
size_factor = 0.45 + 0.55 * (fv - min_fv) / fv_range
radius = 18 * size_factor
role = p.get("role", "C")
color = ROLE_COLORS.get(role, SKY)
name = p.get("name", "?")
is_captain = name == captain
starter_pct = p.get("starter_pct", 100)
cx = px / 100 * width
cy = py / 100 * height
ring_color = GOLD if is_captain else color
ring_width = 3 if is_captain else 1.5
svg_parts.append(f"""
<circle cx="{cx}" cy="{cy}" r="{radius + ring_width}" fill="{ring_color}" opacity="0.9"/>
<circle cx="{cx}" cy="{cy}" r="{radius}" fill="{color}" opacity="0.85"/>
<text x="{cx}" y="{cy}" text-anchor="middle" dominant-baseline="central"
fill="white" font-family="Inter,sans-serif" font-size="{10*size_factor}px"
font-weight="600">{fv:.1f}</text>
<text x="{cx}" y="{cy + radius + 12}" text-anchor="middle"
fill="{WHITE}" font-family="Inter,sans-serif" font-size="8px"
opacity="0.9">{name[:12]}{'...' if len(name)>12 else ''}</text>
""")
if starter_pct < 70:
svg_parts.append(f"""
<text x="{cx}" y="{cy + radius + 22}" text-anchor="middle"
fill="{RED}" font-family="Inter,sans-serif" font-size="7px"
opacity="0.8">{starter_pct:.0f}%</text>
""")
# Formation label
svg_parts.append(f"""
<text x="{width*0.5}" y="{height*0.97}" text-anchor="middle"
fill="{TEXT_SECONDARY}" font-family="Inter,sans-serif" font-size="11px"
opacity="0.6">{formation}</text>
""")
# Bench strip
if bench and len(bench) > 0:
bench_y = height * 0.99
svg_parts.append(f"""
<text x="{width*0.5}" y="{bench_y}" text-anchor="middle"
fill="{TEXT_SECONDARY}" font-family="Inter,sans-serif" font-size="9px"
opacity="0.5">BENCH</text>
""")
svg_parts.append("</svg>")
return "\n".join(svg_parts)
def show_pitch(lineup: list[dict], formation: str = "4-4-2",
captain: str = "", bench: list[dict] | None = None,
height: int = 600):
"""Render pitch as a Streamlit HTML component."""
svg = render_pitch(lineup, formation, captain, bench, height=height)
components.html(svg, height=height + 20, scrolling=False)