65f5b66b05
- 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)
178 lines
6.5 KiB
Python
178 lines
6.5 KiB
Python
"""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)
|