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
View File
+328
View File
@@ -0,0 +1,328 @@
"""Pure chart functions. df in → plotly Figure out. Unit-testable."""
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import plotly.express as px
from plotly.subplots import make_subplots
from .template import (
PITCH_GREEN, GOLD, RED, SKY, VIOLET, BG, CARD_BG, BORDER,
TEXT, TEXT_SECONDARY, GRIDLINE, WHITE, ROLE_COLORS,
FANTABETO_TEMPLATE, HEATMAP_COLORS, DISCRETE_10, insight_caption,
)
# ─────────────────────────────────────────────────────────────────
# KPI sparkline row
# ─────────────────────────────────────────────────────────────────
def kpi_sparkline(values: list, label: str, color: str = SKY) -> go.Figure:
fig = go.Figure()
fig.add_trace(go.Scatter(
y=values, mode="lines", line=dict(color=color, width=2),
fill="tozeroy", fillcolor=f"rgba({_hex_to_rgb(color)},0.15)",
showlegend=False, hoverinfo="skip",
))
fig.update_layout(
template=FANTABETO_TEMPLATE,
height=60, width=180,
margin=dict(l=0, r=0, t=0, b=0),
xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
)
return fig
# ─────────────────────────────────────────────────────────────────
# Fixture difficulty heatmap
# ─────────────────────────────────────────────────────────────────
def fixture_heatmap(df: pd.DataFrame) -> go.Figure:
"""Square matrix: teams × matchdays, colored by opponent FV strength.
Args:
df: columns ['team', 'matchday', 'opp_strength']
"""
pivot = df.pivot(index="team", columns="matchday", values="opp_strength")
fig = go.Figure(data=go.Heatmap(
z=pivot.values,
x=[f"GW {c}" for c in pivot.columns],
y=pivot.index,
colorscale=HEATMAP_COLORS,
zmid=np.mean([pivot.min().min(), pivot.max().max()]),
hovertemplate="%{y} vs opponent<br>GW %{x}: difficulty %{z:.1f}<extra></extra>",
))
fig.update_layout(
template=FANTABETO_TEMPLATE,
height=440,
xaxis=dict(side="top", tickangle=-45),
yaxis=dict(autorange="reversed"),
)
return fig
# ─────────────────────────────────────────────────────────────────
# Percentile radar chart
# ─────────────────────────────────────────────────────────────────
def percentile_radar(player_row: pd.Series, metrics: list, labels: list,
role_avg: pd.Series = None) -> go.Figure:
"""Single-player percentile radar across N metrics.
Args:
player_row: Series with metric values (raw).
metrics: column names to plot.
labels: display labels for each metric.
role_avg: optional league-average Series to normalize against.
"""
values = []
for m in metrics:
if role_avg is not None and m in role_avg.index and role_avg[m] > 0:
pct = min(100, max(0, (player_row.get(m, 0) / role_avg[m]) * 50))
else:
pct = 50
values.append(pct)
values.append(values[0])
labels_closed = labels + [labels[0]]
fig = go.Figure()
fig.add_trace(go.Scatterpolar(
r=values, theta=labels_closed,
fill="toself", fillcolor=f"rgba({_hex_to_rgb(SKY)},0.2)",
line=dict(color=SKY, width=2),
name=player_row.get("player", "Player"),
))
fig.update_layout(
template=FANTABETO_TEMPLATE,
polar=dict(
radialaxis=dict(range=[0, 100], showticklabels=False, gridcolor=GRIDLINE),
angularaxis=dict(gridcolor=GRIDLINE, tickfont=dict(size=9, color=TEXT_SECONDARY)),
bgcolor=BG,
),
height=350,
showlegend=False,
margin=dict(l=40, r=40, t=20, b=20),
)
return fig
# ─────────────────────────────────────────────────────────────────
# Regression chart (actual vs xG)
# ─────────────────────────────────────────────────────────────────
def regression_chart(df: pd.DataFrame, player: str,
xg_col="goals_p90", goal_col="goals_season",
games_col="games_season") -> go.Figure:
"""Rolling actual goals vs expected with divergence shading.
Uses season-level totals as static chart.
"""
fig = go.Figure()
fig.add_trace(go.Bar(
x=["Actual Goals", "Expected (xG * 1.2)"],
y=[df[goal_col].iloc[0], df[xg_col].iloc[0] * 1.2 * df[games_col].iloc[0]],
marker_color=[PITCH_GREEN, SKY], texttemplate="%{y:.1f}",
textposition="outside", textfont=dict(color=TEXT, size=13),
showlegend=False,
))
fig.update_layout(
template=FANTABETO_TEMPLATE,
height=200,
margin=dict(l=10, r=10, t=10, b=10),
)
return fig
# ─────────────────────────────────────────────────────────────────
# Bonus/malus donut
# ─────────────────────────────────────────────────────────────────
def bonus_donut(goals: float, assists: float, cards_malus: float) -> go.Figure:
bonus = goals * 3 + assists * 1
malus = abs(cards_malus)
fig = go.Figure(data=[go.Pie(
labels=["Goal Bonus", "Assist Bonus", "Card Malus"],
values=[goals * 3, assists, malus if malus > 0 else 0.01],
hole=0.55,
marker_colors=[PITCH_GREEN, SKY, RED],
textinfo="label+value",
textfont=dict(color=TEXT, size=10),
hovertemplate="%{label}: %{value:.1f}<extra></extra>",
)])
fig.update_layout(
template=FANTABETO_TEMPLATE,
height=200,
showlegend=False,
margin=dict(l=0, r=0, t=10, b=10),
)
return fig
# ─────────────────────────────────────────────────────────────────
# Card risk gauge
# ─────────────────────────────────────────────────────────────────
def card_gauge(yellow_per_game: float, red_per_game: float) -> go.Figure:
ypct = min(100, yellow_per_game / 0.5 * 100)
rpct = min(100, red_per_game / 0.1 * 100)
fig = go.Figure()
fig.add_trace(go.Indicator(
mode="gauge+number",
value=ypct,
title={"text": "Yellow Risk", "font": {"size": 11, "color": TEXT_SECONDARY}},
gauge={
"axis": {"range": [0, 100], "tickcolor": TEXT_SECONDARY},
"bar": {"color": GOLD},
"bgcolor": CARD_BG,
"borderwidth": 0,
"steps": [
{"range": [0, 30], "color": f"rgba({_hex_to_rgb(PITCH_GREEN)},0.15)"},
{"range": [30, 70], "color": f"rgba({_hex_to_rgb(GOLD)},0.15)"},
{"range": [70, 100], "color": f"rgba({_hex_to_rgb(RED)},0.15)"},
],
},
number={"font": {"size": 22, "color": WHITE}},
domain={"row": 0, "column": 0},
))
fig.add_trace(go.Indicator(
mode="gauge+number",
value=rpct,
title={"text": "Red Risk", "font": {"size": 11, "color": TEXT_SECONDARY}},
gauge={
"axis": {"range": [0, 100], "tickcolor": TEXT_SECONDARY},
"bar": {"color": RED},
"bgcolor": CARD_BG,
"borderwidth": 0,
"steps": [
{"range": [0, 30], "color": f"rgba({_hex_to_rgb(PITCH_GREEN)},0.15)"},
{"range": [30, 70], "color": f"rgba({_hex_to_rgb(RED)},0.15)"},
{"range": [70, 100], "color": f"rgba({_hex_to_rgb(RED)},0.3)"},
],
},
number={"font": {"size": 22, "color": WHITE}},
domain={"row": 0, "column": 1},
))
fig.update_layout(
template=FANTABETO_TEMPLATE,
grid={"rows": 1, "columns": 2},
height=180,
margin=dict(l=10, r=10, t=30, b=10),
)
return fig
# ─────────────────────────────────────────────────────────────────
# Budget waterfall
# ─────────────────────────────────────────────────────────────────
def budget_waterfall(allocations: dict) -> go.Figure:
"""allocations: {'GK': amount, 'DEF': amount, 'MID': amount, 'FWD': amount}"""
measures = ["relative", "relative", "relative", "relative", "total"]
labels = list(allocations.keys()) + ["Total"]
values = list(allocations.values()) + [sum(allocations.values())]
fig = go.Figure(go.Waterfall(
measure=measures, x=labels, y=values,
connector=dict(line=dict(color=BORDER, width=1)),
decreasing=dict(marker=dict(color=RED)),
increasing=dict(marker=dict(color=PITCH_GREEN)),
totals=dict(marker=dict(color=SKY)),
text=[f"{v:.0f} cr" for v in values],
textposition="outside",
))
fig.update_layout(
template=FANTABETO_TEMPLATE,
height=280,
showlegend=False,
)
return fig
# ─────────────────────────────────────────────────────────────────
# Value scatter (FV vs price)
# ─────────────────────────────────────────────────────────────────
def value_scatter(df: pd.DataFrame) -> go.Figure:
"""Scatter: fv_avg vs qi, bubble = games_season, labeled steals."""
df = df.copy()
df["value_ratio"] = df["fv_avg"] / df["qi"].clip(lower=1)
fig = go.Figure()
for role, color in ROLE_COLORS.items():
rdf = df[df["role"] == role]
if rdf.empty:
continue
fig.add_trace(go.Scatter(
x=rdf["qi"], y=rdf["fv_avg"],
mode="markers+text",
marker=dict(
size=rdf["games_season"].clip(lower=5) / 2,
color=color, opacity=0.7,
line=dict(width=1, color=BORDER),
),
text=rdf["player"].where(rdf["value_ratio"] > rdf["value_ratio"].quantile(0.9), ""),
textposition="top center",
textfont=dict(size=9, color=TEXT),
name=f"{role} ({len(rdf)})",
hovertemplate=(
"<b>%{text}</b><br>"
"FV: %{y:.2f}<br>Price: %{x:.0f}<br>"
"Games: %{marker.size:.0f}<extra></extra>"
),
))
# Isoline: cost per FV point
x_range = [df["qi"].min(), df["qi"].max()]
for cpp in [3, 5, 8]:
fig.add_trace(go.Scatter(
x=x_range, y=[x / cpp for x in x_range],
mode="lines", line=dict(dash="dash", color=TEXT_SECONDARY, width=0.5),
name=f"{cpp} cr/FV", showlegend=False,
))
fig.update_layout(
template=FANTABETO_TEMPLATE,
height=500,
xaxis_title="Quotazione Iniziale (cr)",
yaxis_title="Fantavoto Avg (25/26)",
hovermode="closest",
)
return fig
# ─────────────────────────────────────────────────────────────────
# Error violin by role
# ─────────────────────────────────────────────────────────────────
def error_violins(df: pd.DataFrame) -> go.Figure:
"""Violins of prediction error by role."""
roles = ["P", "D", "C", "A"]
fig = go.Figure()
for i, role in enumerate(roles):
rdf = df[df["role"] == role]
if rdf.empty or "error" not in rdf.columns:
continue
fig.add_trace(go.Violin(
y=rdf["error"], name=role,
marker=dict(color=ROLE_COLORS.get(role, SKY)),
box_visible=True, meanline_visible=True,
side="positive" if i % 2 == 0 else "negative",
))
fig.update_layout(
template=FANTABETO_TEMPLATE,
height=300,
xaxis=dict(title="Role"),
yaxis=dict(title="Prediction Error (FV)"),
violingap=0, violinmode="overlay",
)
return fig
# ─────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────
def _hex_to_rgb(hex_color: str) -> str:
h = hex_color.lstrip("#")
return ",".join(str(int(h[i:i+2], 16)) for i in (0, 2, 4))
+101
View File
@@ -0,0 +1,101 @@
"""Reusable UI components: KPI cards, role chips, insight row, section headers."""
import streamlit as st
from .template import (
PITCH_GREEN, GOLD, RED, SKY, VIOLET, WHITE, TEXT_SECONDARY,
CARD_BG, BORDER, ROLE_COLORS, ROLE_ICONS,
)
CSS_CLASS = """
<style>
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=Inter:wght@400;500;600&display=swap');
.kpi-card {
background: rgba(17, 24, 39, 0.70);
backdrop-filter: blur(8px);
border: 1px solid #1F2937;
border-radius: 14px;
padding: 18px 20px;
text-align: center;
transition: border-color 0.2s;
}
.kpi-card:hover { border-color: #374151; }
.kpi-value {
font-family: 'Space Grotesk', sans-serif;
font-size: 28px;
font-weight: 700;
letter-spacing: -0.5px;
}
.kpi-label {
font-family: 'Inter', sans-serif;
font-size: 11px;
color: #9CA3AF;
text-transform: uppercase;
letter-spacing: 0.8px;
margin-top: 4px;
}
.kpi-sub {
font-family: 'Inter', sans-serif;
font-size: 12px;
margin-top: 2px;
}
.role-chip {
display: inline-block;
font-family: 'Inter', sans-serif;
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 4px;
margin-right: 4px;
}
.section-header {
font-family: 'Space Grotesk', sans-serif;
font-size: 18px;
font-weight: 600;
color: #E5E7EB;
margin-bottom: 0;
}
.insight {
font-family: 'Inter', sans-serif;
font-size: 12px;
color: #9CA3AF;
font-style: italic;
margin-top: 2px;
}
</style>
"""
def inject_css():
st.markdown(CSS_CLASS, unsafe_allow_html=True)
def kpi_card(label: str, value: str, sub: str = "", color: str = WHITE, delta: float | None = None):
delta_html = ""
if delta is not None:
d_color = PITCH_GREEN if delta >= 0 else RED
d_sign = "+" if delta > 0 else ""
delta_html = f'<span style="color:{d_color};font-size:13px;">{d_sign}{delta:+.1f}</span>'
return f"""
<div class="kpi-card">
<div class="kpi-value" style="color:{color};">{value}{delta_html}</div>
<div class="kpi-label">{label}</div>
{f'<div class="kpi-sub">{sub}</div>' if sub else ''}
</div>
"""
def role_chip(role: str) -> str:
color = ROLE_COLORS.get(role, TEXT_SECONDARY)
return f'<span class="role-chip" style="background:{color}22;color:{color};border:1px solid {color}44;">{ROLE_ICONS.get(role, "")} {role}</span>'
def section(title: str, caption: str = ""):
st.markdown(f'<p class="section-header">{title}</p>', unsafe_allow_html=True)
if caption:
st.markdown(f'<p class="insight">{caption}</p>', unsafe_allow_html=True)
def insight(text: str):
st.markdown(f'<p class="insight">{text}</p>', unsafe_allow_html=True)
+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)
+126
View File
@@ -0,0 +1,126 @@
"""Plotly template "fantabeto_dark" — single source of truth for all chart styling.
Registers once at module import. Every chart uses this template.
"""
import plotly.graph_objects as go
import plotly.io as pio
# ─── Semantic palette ───────────────────────────────────────────────
PITCH_GREEN = "#00D084"
GOLD = "#FFC94D"
RED = "#FF4D5E"
SKY = "#38BDF8"
VIOLET = "#A78BFA"
BG = "#0B0F17"
CARD_BG = "#111827"
BORDER = "#1F2937"
TEXT = "#E5E7EB"
TEXT_SECONDARY = "#9CA3AF"
GRIDLINE = "rgba(31,41,55,0.8)"
WHITE = "#FFFFFF"
# Role colors
ROLE_COLORS = {"P": "#e74c3c", "D": "#3498db", "C": "#2ecc71", "A": "#f39c12"}
ROLE_ICONS = {"P": "🧤", "D": "🛡", "C": "⚙", "A": "⚡"}
# Sequential palette for heatmaps
HEATMAP_COLORS = [
[0.0, "#0B0F17"],
[0.2, "#1F2937"],
[0.4, "#38BDF8"],
[0.6, "#00D084"],
[0.8, "#FFC94D"],
[1.0, "#FF4D5E"],
]
DISCRETE_10 = [
SKY, PITCH_GREEN, GOLD, VIOLET, RED,
"#FB923C", "#34D399", "#60A5FA", "#C084FC", "#F87171",
]
FANTABETO_TEMPLATE = "fantabeto_dark"
def register_template():
"""Register the custom Plotly template. Call once at app startup."""
if FANTABETO_TEMPLATE in pio.templates:
return
t = go.layout.Template()
t.layout.update(
# Canvas
paper_bgcolor=BG,
plot_bgcolor=BG,
font=dict(color=TEXT, family="Inter, sans-serif", size=12),
title=dict(font=dict(family="Space Grotesk, sans-serif", size=18, color=WHITE)),
# Axes
xaxis=dict(
gridcolor=GRIDLINE, zerolinecolor=GRIDLINE,
linecolor=BORDER, tickcolor=BORDER,
title_font=dict(color=TEXT_SECONDARY, size=11),
),
yaxis=dict(
gridcolor=GRIDLINE, zerolinecolor=GRIDLINE,
linecolor=BORDER, tickcolor=BORDER,
title_font=dict(color=TEXT_SECONDARY, size=11),
),
# Legend
legend=dict(
bgcolor="rgba(17,24,39,0.85)", bordercolor=BORDER,
font=dict(color=TEXT_SECONDARY, size=11),
),
# Margins
margin=dict(l=50, r=30, t=60, b=50),
# Hover
hoverlabel=dict(
bgcolor=CARD_BG, bordercolor=BORDER,
font=dict(color=TEXT, family="Inter, sans-serif"),
),
# Annotations default
annotationdefaults=dict(
font=dict(color=TEXT, size=11, family="Inter, sans-serif"),
),
# Colorway
colorway=DISCRETE_10,
)
# Bar trace defaults
t.data.bar = [
go.Bar(marker=dict(line=dict(width=0)), textposition="none"),
]
# Scatter trace defaults
t.data.scatter = [
go.Scatter(
marker=dict(line=dict(width=0)),
line=dict(width=2),
),
]
# Heatmap defaults
t.data.heatmap = [
go.Heatmap(
colorscale=HEATMAP_COLORS,
colorbar=dict(
bgcolor=CARD_BG, bordercolor=BORDER,
tickfont=dict(color=TEXT_SECONDARY),
),
),
]
pio.templates[FANTABETO_TEMPLATE] = t
def insight_caption(text: str) -> str:
"""Generate markdown insight caption for charts."""
return f'<p style="color:{TEXT_SECONDARY};font-size:12px;margin-top:4px;font-style:italic;">{text}</p>'
def stub_render(): # for test import
return True
# Auto-register on import
register_template()