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)
This commit is contained in:
ramseshk
2026-08-11 14:53:51 +08:00
parent 68670889c7
commit 65f5b66b05
19 changed files with 2217 additions and 0 deletions
+177
View File
@@ -0,0 +1,177 @@
"""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)