msds-comms-plotter 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- msds_comms_plotter/__init__.py +10 -0
- msds_comms_plotter/altair_charts.py +224 -0
- msds_comms_plotter/chartkit.py +248 -0
- msds_comms_plotter/worldcup.py +313 -0
- msds_comms_plotter-0.1.0.dist-info/METADATA +685 -0
- msds_comms_plotter-0.1.0.dist-info/RECORD +9 -0
- msds_comms_plotter-0.1.0.dist-info/WHEEL +5 -0
- msds_comms_plotter-0.1.0.dist-info/licenses/LICENSE +339 -0
- msds_comms_plotter-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""msds_comms_plotter: a small pandas-based library.
|
|
2
|
+
|
|
3
|
+
The distribution name is ``msds-comms-plotter`` (hyphens); the import name
|
|
4
|
+
uses underscores, so use ``import msds_comms_plotter``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from msds_comms_plotter import chartkit
|
|
8
|
+
|
|
9
|
+
__all__ = ["chartkit"]
|
|
10
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Altair passing chart for the World Cup 2022 data, themed with :mod:`chartkit`.
|
|
2
|
+
|
|
3
|
+
**Passing: volume vs accuracy** — a linked-brush scatter of passes attempted
|
|
4
|
+
(volume) vs pass completion % (accuracy), colored by position, with a
|
|
5
|
+
count-by-position bar chart below, a player-name search box, and a live
|
|
6
|
+
color-scheme dropdown. Drag a box on the scatter and the bars recount only the
|
|
7
|
+
selected players; type a name to filter both views.
|
|
8
|
+
|
|
9
|
+
Run as a script::
|
|
10
|
+
|
|
11
|
+
python -m msds_comms_plotter.altair_charts
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import altair as alt
|
|
17
|
+
import pandas as pd
|
|
18
|
+
|
|
19
|
+
from msds_comms_plotter import chartkit, worldcup
|
|
20
|
+
|
|
21
|
+
# Reuse the project's figures directory.
|
|
22
|
+
FIG_DIR = worldcup.PROJECT_ROOT / "reports" / "figures"
|
|
23
|
+
|
|
24
|
+
# Enable the shared look once, at import time.
|
|
25
|
+
chartkit.enable_altair_theme()
|
|
26
|
+
|
|
27
|
+
# Outfield position groups (goalkeepers are dropped). Three well-separated
|
|
28
|
+
# categories used as the color-scale domain.
|
|
29
|
+
POSITION_ORDER = ["Defender", "Midfielder", "Forward"]
|
|
30
|
+
|
|
31
|
+
# Categorical color schemes for the "Category colors" dropdown. Values are Vega
|
|
32
|
+
# scheme names bound live into the color scale; labels are the dropdown text.
|
|
33
|
+
CAT_SCHEME_OPTIONS = ["category10", "dark2", "tableau10", "set2",
|
|
34
|
+
"set1", "tableau20", "paired", "accent",
|
|
35
|
+
"observable10", "set3", "category20", "category20b",
|
|
36
|
+
"category20c", "pastel1", "pastel2"]
|
|
37
|
+
CAT_SCHEME_LABELS = ["Category 10", "Dark 2", "Tableau 10", "Set 2 (soft)",
|
|
38
|
+
"Set 1 (bold)", "Tableau 20", "Paired", "Accent",
|
|
39
|
+
"Observable 10", "Set 3", "Category 20", "Category 20b",
|
|
40
|
+
"Category 20c", "Pastel 1", "Pastel 2"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def categorical_scheme_param():
|
|
44
|
+
"""A 'Category colors' dropdown, bound to the categorical color scheme.
|
|
45
|
+
|
|
46
|
+
Reference it in a color scale with ``scheme=alt.ExprRef(expr="cat_scheme")``.
|
|
47
|
+
Pass one instance to the chart (and add it once at the enclosing level) to
|
|
48
|
+
drive its colors from an external control instead of the built-in dropdown.
|
|
49
|
+
"""
|
|
50
|
+
return alt.param(
|
|
51
|
+
name="cat_scheme", value=CAT_SCHEME_OPTIONS[0],
|
|
52
|
+
bind=alt.binding_select(options=CAT_SCHEME_OPTIONS,
|
|
53
|
+
labels=CAT_SCHEME_LABELS,
|
|
54
|
+
name="Category colors "))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _register_png_fonts() -> None:
|
|
58
|
+
"""Point vl-convert (the PNG backend) at any locally installed fonts.
|
|
59
|
+
|
|
60
|
+
vl-convert doesn't read the system font config, so a named font like
|
|
61
|
+
JetBrains Mono is invisible to it unless its directory is registered —
|
|
62
|
+
without this, PNG export silently drops *all* text. Best-effort: scans the
|
|
63
|
+
common user/system font locations and registers those that exist. HTML
|
|
64
|
+
output is unaffected (the browser handles font fallback itself).
|
|
65
|
+
"""
|
|
66
|
+
from pathlib import Path
|
|
67
|
+
try:
|
|
68
|
+
import vl_convert as vlc
|
|
69
|
+
except ImportError: # pragma: no cover - optional dependency
|
|
70
|
+
return
|
|
71
|
+
for d in ("~/.fonts", "~/.local/share/fonts", "/usr/share/fonts",
|
|
72
|
+
"/usr/local/share/fonts", "/Library/Fonts",
|
|
73
|
+
"~/Library/Fonts", "C:/Windows/Fonts"):
|
|
74
|
+
p = Path(d).expanduser()
|
|
75
|
+
if p.is_dir():
|
|
76
|
+
try:
|
|
77
|
+
vlc.register_font_directory(str(p))
|
|
78
|
+
except Exception: # pragma: no cover - non-fatal
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _save(chart: alt.Chart, name: str) -> alt.Chart:
|
|
83
|
+
"""Write ``<name>.html`` (always) and ``<name>.png`` (if vl-convert present)."""
|
|
84
|
+
FIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
html = FIG_DIR / f"{name}.html"
|
|
86
|
+
chart.save(html)
|
|
87
|
+
print(f"Wrote {html}")
|
|
88
|
+
try:
|
|
89
|
+
_register_png_fonts()
|
|
90
|
+
png = FIG_DIR / f"{name}.png"
|
|
91
|
+
chart.save(png, ppi=200)
|
|
92
|
+
print(f"Wrote {png}")
|
|
93
|
+
except Exception as exc: # pragma: no cover - optional dependency
|
|
94
|
+
print(f"(skipped {name}.png — install vl-convert-python: {exc})")
|
|
95
|
+
return chart
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _position_group(pos):
|
|
99
|
+
"""Collapse a detailed StatsBomb position into a broad outfield group.
|
|
100
|
+
|
|
101
|
+
Order matters: "Wing Back" contains "Back", so defenders are matched before
|
|
102
|
+
wingers. Goalkeepers and unknown positions return ``None`` (dropped).
|
|
103
|
+
"""
|
|
104
|
+
if not isinstance(pos, str):
|
|
105
|
+
return None
|
|
106
|
+
if "Goalkeeper" in pos:
|
|
107
|
+
return None
|
|
108
|
+
if "Back" in pos:
|
|
109
|
+
return "Defender"
|
|
110
|
+
if "Midfield" in pos:
|
|
111
|
+
return "Midfielder"
|
|
112
|
+
if "Wing" in pos or "Forward" in pos:
|
|
113
|
+
return "Forward"
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _players_passing(stats: pd.DataFrame | None, min_minutes: int = 90,
|
|
118
|
+
min_passes: int = 20) -> pd.DataFrame:
|
|
119
|
+
"""Per-player passing totals (volume + completion %) plus position group.
|
|
120
|
+
|
|
121
|
+
A player's group is the most common position they played. Restricted to
|
|
122
|
+
players with real involvement (``>= min_minutes`` minutes and
|
|
123
|
+
``>= min_passes`` passes) so passing rates are meaningful.
|
|
124
|
+
"""
|
|
125
|
+
if stats is None:
|
|
126
|
+
stats = worldcup.build_all()
|
|
127
|
+
s = stats.copy()
|
|
128
|
+
s["_grp"] = s["position"].map(_position_group)
|
|
129
|
+
modal = (s.dropna(subset=["_grp"]).groupby(["player", "team"])["_grp"]
|
|
130
|
+
.agg(lambda x: x.mode().iloc[0]).rename("position_group")
|
|
131
|
+
.reset_index())
|
|
132
|
+
tot = (stats.groupby(["player", "team"], as_index=False)
|
|
133
|
+
.agg(passes=("passes", "sum"),
|
|
134
|
+
passes_completed=("passes_completed", "sum"),
|
|
135
|
+
minutes=("minutes_played", "sum")))
|
|
136
|
+
tot = tot.merge(modal, on=["player", "team"], how="left")
|
|
137
|
+
tot["completion_pct"] = (tot["passes_completed"] / tot["passes"]
|
|
138
|
+
* 100).round(1)
|
|
139
|
+
return tot[(tot["minutes"] >= min_minutes)
|
|
140
|
+
& (tot["passes"] >= min_passes)
|
|
141
|
+
& tot["position_group"].notna()].copy()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def linked_scatter_passing(stats=None, scheme_param=None, save=False):
|
|
145
|
+
"""Brush passing volume vs accuracy; count-by-position bars recount selection.
|
|
146
|
+
|
|
147
|
+
A linked-brush chart on two continuous passing measures — passes attempted
|
|
148
|
+
(volume) vs pass completion % (accuracy) — colored by position. **Drag a
|
|
149
|
+
box** on the scatter and the bars below recount only the selected players
|
|
150
|
+
while the rest fade to grey. Defenders and midfielders cluster
|
|
151
|
+
high-volume / high-accuracy; forwards spread lower and looser.
|
|
152
|
+
|
|
153
|
+
A **player search box** filters both views to names containing the typed
|
|
154
|
+
text (any part of the full name, case-insensitive); an empty box shows
|
|
155
|
+
everyone. Search and brush compose — search narrows the pool, brush selects
|
|
156
|
+
within it. A **"Category colors" dropdown** switches the position palette
|
|
157
|
+
live; pass a shared ``scheme_param`` to drive it from an external control.
|
|
158
|
+
"""
|
|
159
|
+
df = _players_passing(stats)
|
|
160
|
+
|
|
161
|
+
# The selected Vega scheme name is bound straight into the color scale, so
|
|
162
|
+
# points, bars, and legend recolor together. Shared when scheme_param is
|
|
163
|
+
# passed; otherwise this chart supplies its own dropdown.
|
|
164
|
+
owns_param = scheme_param is None
|
|
165
|
+
scheme = scheme_param or categorical_scheme_param()
|
|
166
|
+
color = alt.Color(
|
|
167
|
+
"position_group:N", title="Position",
|
|
168
|
+
scale=alt.Scale(domain=POSITION_ORDER,
|
|
169
|
+
scheme=alt.ExprRef(expr="cat_scheme")),
|
|
170
|
+
sort=POSITION_ORDER)
|
|
171
|
+
brush = alt.selection_interval(name="passing_brush")
|
|
172
|
+
|
|
173
|
+
# Free-text search over the full player name. Declared at the top level (so
|
|
174
|
+
# both sub-views can filter on it); an empty string's regexp matches every
|
|
175
|
+
# name, so the default view shows all players.
|
|
176
|
+
name_search = alt.param(
|
|
177
|
+
name="player_search", value="",
|
|
178
|
+
bind=alt.binding(input="search", placeholder="e.g. Messi",
|
|
179
|
+
name="Player "))
|
|
180
|
+
name_matches = alt.expr.test(
|
|
181
|
+
alt.expr.regexp(name_search, "i"), alt.datum.player)
|
|
182
|
+
|
|
183
|
+
points = alt.Chart(df).mark_point(filled=True, size=60).encode(
|
|
184
|
+
x=alt.X("passes:Q", title="Passes attempted"),
|
|
185
|
+
y=alt.Y("completion_pct:Q", scale=alt.Scale(zero=False),
|
|
186
|
+
title="Pass completion (%)"),
|
|
187
|
+
color=alt.when(brush).then(color).otherwise(alt.value("#cbcac4")),
|
|
188
|
+
tooltip=[alt.Tooltip("player:N", title="Player"),
|
|
189
|
+
alt.Tooltip("team:N", title="Team"),
|
|
190
|
+
alt.Tooltip("position_group:N", title="Position"),
|
|
191
|
+
alt.Tooltip("passes:Q", title="Passes"),
|
|
192
|
+
alt.Tooltip("completion_pct:Q", title="Completion %"),
|
|
193
|
+
alt.Tooltip("minutes:Q", title="Minutes")],
|
|
194
|
+
).transform_filter(name_matches).add_params(brush).properties(
|
|
195
|
+
width=460, height=360, title="Drag a box to select players →")
|
|
196
|
+
|
|
197
|
+
bars = alt.Chart(df).mark_bar().encode(
|
|
198
|
+
y=alt.Y("position_group:N", sort=POSITION_ORDER, title=None),
|
|
199
|
+
x=alt.X("count():Q", title="Players selected",
|
|
200
|
+
axis=alt.Axis(tickMinStep=1)),
|
|
201
|
+
color=color,
|
|
202
|
+
tooltip=[alt.Tooltip("position_group:N", title="Position"),
|
|
203
|
+
alt.Tooltip("count():Q", title="Players")],
|
|
204
|
+
).transform_filter(brush).transform_filter(name_matches).properties(
|
|
205
|
+
width=460, height=150, title="Selected players by position")
|
|
206
|
+
|
|
207
|
+
chart = alt.vconcat(points, bars).add_params(name_search).properties(
|
|
208
|
+
title="Passing: volume vs accuracy — search a name or brush the cloud")
|
|
209
|
+
if owns_param:
|
|
210
|
+
chart = chart.add_params(scheme)
|
|
211
|
+
return _save(chart, "alt_brush_passing") if save else chart
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
ALL_CHARTS = [linked_scatter_passing]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def main():
|
|
218
|
+
"""Build the passing chart and save it to reports/figures/."""
|
|
219
|
+
stats = worldcup.build_all()
|
|
220
|
+
linked_scatter_passing(stats=stats, save=True)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
if __name__ == "__main__":
|
|
224
|
+
main()
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""
|
|
2
|
+
chartkit — one consistent look for matplotlib *and* Altair.
|
|
3
|
+
|
|
4
|
+
matplotlib and Altair are independent renderers: matplotlib draws static
|
|
5
|
+
figures in Python, while Altair emits Vega-Lite JSON that renders in a
|
|
6
|
+
browser. You can't stack one on the other. What this module does instead
|
|
7
|
+
is give both the *same* visual identity — a single monospaced typeface
|
|
8
|
+
(JetBrains Mono), one shared palette, and matching minimal chrome — so a
|
|
9
|
+
static matplotlib figure and an interactive Altair chart look like siblings.
|
|
10
|
+
|
|
11
|
+
Usage
|
|
12
|
+
-----
|
|
13
|
+
from msds_comms_plotter import chartkit
|
|
14
|
+
|
|
15
|
+
# matplotlib
|
|
16
|
+
chartkit.ensure_font() # warns if JetBrains Mono is missing
|
|
17
|
+
chartkit.apply_matplotlib_theme() # sets rcParams globally
|
|
18
|
+
fig, ax = plt.subplots()
|
|
19
|
+
ax.plot(x, y)
|
|
20
|
+
chartkit.style_axes(ax, title="Revenue", ylabel="USD")
|
|
21
|
+
|
|
22
|
+
# Altair
|
|
23
|
+
chartkit.enable_altair_theme() # registers + enables the theme
|
|
24
|
+
chartkit.alt_line(df, x="date:T", y="price:Q", color="symbol:N")
|
|
25
|
+
|
|
26
|
+
Note: JetBrains Mono must be installed for matplotlib to use it, and
|
|
27
|
+
available to the browser (e.g. via a webfont) for Altair. Both fall back
|
|
28
|
+
to a generic monospace stack otherwise.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
# --------------------------------------------------------------------------- #
|
|
34
|
+
# Shared design tokens #
|
|
35
|
+
# --------------------------------------------------------------------------- #
|
|
36
|
+
|
|
37
|
+
FONT = "JetBrains Mono"
|
|
38
|
+
FONT_STACK = [FONT, "DejaVu Sans Mono", "Menlo", "Consolas", "monospace"]
|
|
39
|
+
|
|
40
|
+
# Calm, colorblind-aware categorical palette, used by both libraries.
|
|
41
|
+
PALETTE = [
|
|
42
|
+
"#2a78d6", # blue
|
|
43
|
+
"#eb6834", # orange
|
|
44
|
+
"#1baf7a", # teal
|
|
45
|
+
"#eda100", # amber
|
|
46
|
+
"#e87ba4", # pink
|
|
47
|
+
"#4a3aa7", # violet
|
|
48
|
+
"#008300", # green
|
|
49
|
+
"#e34948", # red
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
INK = "#2c2c2a" # primary text / axis domain
|
|
53
|
+
MUTED = "#6b6a66" # secondary text / tick labels
|
|
54
|
+
GRID = "#e1e0d9" # gridlines
|
|
55
|
+
SURFACE = "#ffffff" # chart background
|
|
56
|
+
|
|
57
|
+
SEQUENTIAL = "viridis" # magnitude scales (heatmaps, choropleths)
|
|
58
|
+
DIVERGING = "redblue" # polarity scales (deltas vs. a baseline)
|
|
59
|
+
|
|
60
|
+
SIZE_TITLE = 15
|
|
61
|
+
SIZE_LABEL = 12
|
|
62
|
+
SIZE_TICK = 10
|
|
63
|
+
SIZE_LEGEND = 11
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# --------------------------------------------------------------------------- #
|
|
67
|
+
# matplotlib #
|
|
68
|
+
# --------------------------------------------------------------------------- #
|
|
69
|
+
|
|
70
|
+
def apply_matplotlib_theme() -> dict:
|
|
71
|
+
"""Apply the chartkit look to matplotlib globally via rcParams."""
|
|
72
|
+
import matplotlib as mpl
|
|
73
|
+
from cycler import cycler
|
|
74
|
+
|
|
75
|
+
rc = {
|
|
76
|
+
# font
|
|
77
|
+
"font.family": "monospace",
|
|
78
|
+
"font.monospace": FONT_STACK,
|
|
79
|
+
"font.size": SIZE_TICK,
|
|
80
|
+
"text.color": INK,
|
|
81
|
+
# figure & saving
|
|
82
|
+
"figure.figsize": (7, 4.5),
|
|
83
|
+
"figure.dpi": 120,
|
|
84
|
+
"figure.facecolor": SURFACE,
|
|
85
|
+
"savefig.dpi": 200,
|
|
86
|
+
"savefig.bbox": "tight",
|
|
87
|
+
"savefig.facecolor": SURFACE,
|
|
88
|
+
# axes
|
|
89
|
+
"axes.facecolor": SURFACE,
|
|
90
|
+
"axes.edgecolor": GRID,
|
|
91
|
+
"axes.linewidth": 1.0,
|
|
92
|
+
"axes.titlesize": SIZE_TITLE,
|
|
93
|
+
"axes.titleweight": "bold",
|
|
94
|
+
"axes.titlepad": 12,
|
|
95
|
+
"axes.titlecolor": INK,
|
|
96
|
+
"axes.labelsize": SIZE_LABEL,
|
|
97
|
+
"axes.labelcolor": INK,
|
|
98
|
+
"axes.labelpad": 8,
|
|
99
|
+
"axes.spines.top": False,
|
|
100
|
+
"axes.spines.right": False,
|
|
101
|
+
"axes.prop_cycle": cycler(color=PALETTE),
|
|
102
|
+
# grid (horizontal only — quieter)
|
|
103
|
+
"axes.grid": True,
|
|
104
|
+
"axes.axisbelow": True,
|
|
105
|
+
"grid.color": GRID,
|
|
106
|
+
"grid.linewidth": 0.8,
|
|
107
|
+
"grid.alpha": 1.0,
|
|
108
|
+
# ticks
|
|
109
|
+
"xtick.color": MUTED,
|
|
110
|
+
"ytick.color": MUTED,
|
|
111
|
+
"xtick.labelsize": SIZE_TICK,
|
|
112
|
+
"ytick.labelsize": SIZE_TICK,
|
|
113
|
+
"xtick.direction": "out",
|
|
114
|
+
"ytick.direction": "out",
|
|
115
|
+
# lines & markers
|
|
116
|
+
"lines.linewidth": 2.0,
|
|
117
|
+
"lines.markersize": 6,
|
|
118
|
+
"lines.solid_capstyle": "round",
|
|
119
|
+
# legend
|
|
120
|
+
"legend.frameon": False,
|
|
121
|
+
"legend.fontsize": SIZE_LEGEND,
|
|
122
|
+
}
|
|
123
|
+
mpl.rcParams.update(rc)
|
|
124
|
+
return dict(rc)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def style_axes(ax, title=None, xlabel=None, ylabel=None, ygrid_only=True):
|
|
128
|
+
"""Per-axes finishing touches the global rcParams can't express."""
|
|
129
|
+
if title:
|
|
130
|
+
ax.set_title(title, loc="left")
|
|
131
|
+
if xlabel:
|
|
132
|
+
ax.set_xlabel(xlabel)
|
|
133
|
+
if ylabel:
|
|
134
|
+
ax.set_ylabel(ylabel)
|
|
135
|
+
ax.tick_params(length=0) # hide tick marks, keep labels
|
|
136
|
+
if ygrid_only:
|
|
137
|
+
ax.grid(axis="y")
|
|
138
|
+
ax.grid(axis="x", visible=False)
|
|
139
|
+
for spine in ("left", "bottom"):
|
|
140
|
+
ax.spines[spine].set_color(GRID)
|
|
141
|
+
return ax
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def ensure_font(name: str = FONT) -> bool:
|
|
145
|
+
"""Return True if `name` is available to matplotlib; warn otherwise."""
|
|
146
|
+
from matplotlib import font_manager
|
|
147
|
+
available = {f.name for f in font_manager.fontManager.ttflist}
|
|
148
|
+
if name in available:
|
|
149
|
+
return True
|
|
150
|
+
import warnings
|
|
151
|
+
warnings.warn(
|
|
152
|
+
f"Font {name!r} not found; matplotlib will fall back to "
|
|
153
|
+
f"{FONT_STACK[1]!r}. Install JetBrains Mono system-wide, or call "
|
|
154
|
+
f"register_font('/path/to/JetBrainsMono-Regular.ttf').",
|
|
155
|
+
stacklevel=2,
|
|
156
|
+
)
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def register_font(path: str) -> str:
|
|
161
|
+
"""Register a .ttf/.otf with matplotlib at runtime; return its family name."""
|
|
162
|
+
from matplotlib import font_manager
|
|
163
|
+
font_manager.fontManager.addfont(path)
|
|
164
|
+
return font_manager.FontProperties(fname=path).get_name()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# --------------------------------------------------------------------------- #
|
|
168
|
+
# Altair #
|
|
169
|
+
# --------------------------------------------------------------------------- #
|
|
170
|
+
|
|
171
|
+
def altair_theme() -> dict:
|
|
172
|
+
"""Return the chartkit Vega-Lite config as a plain dict."""
|
|
173
|
+
return {
|
|
174
|
+
"config": {
|
|
175
|
+
"background": SURFACE,
|
|
176
|
+
"font": FONT,
|
|
177
|
+
"view": {"stroke": "transparent", "continuousWidth": 480,
|
|
178
|
+
"continuousHeight": 300},
|
|
179
|
+
"title": {"font": FONT, "fontSize": SIZE_TITLE, "fontWeight": "bold",
|
|
180
|
+
"color": INK, "anchor": "start", "offset": 12},
|
|
181
|
+
"axis": {"labelFont": FONT, "titleFont": FONT,
|
|
182
|
+
"labelFontSize": SIZE_TICK, "titleFontSize": SIZE_LABEL,
|
|
183
|
+
"labelColor": MUTED, "titleColor": INK,
|
|
184
|
+
"gridColor": GRID, "domainColor": GRID, "tickColor": GRID,
|
|
185
|
+
"tickSize": 0, "labelPadding": 6, "titlePadding": 8},
|
|
186
|
+
"legend": {"labelFont": FONT, "titleFont": FONT,
|
|
187
|
+
"labelFontSize": SIZE_TICK, "titleFontSize": SIZE_LEGEND,
|
|
188
|
+
"labelColor": INK, "titleColor": INK},
|
|
189
|
+
"header": {"labelFont": FONT, "titleFont": FONT,
|
|
190
|
+
"labelColor": INK, "titleColor": INK},
|
|
191
|
+
"range": {"category": PALETTE,
|
|
192
|
+
"heatmap": {"scheme": SEQUENTIAL},
|
|
193
|
+
"ramp": {"scheme": SEQUENTIAL},
|
|
194
|
+
"diverging": {"scheme": DIVERGING}},
|
|
195
|
+
"line": {"strokeWidth": 2},
|
|
196
|
+
"point": {"filled": True, "size": 60},
|
|
197
|
+
"bar": {"cornerRadiusEnd": 3},
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def enable_altair_theme(name: str = "chartkit") -> str:
|
|
203
|
+
"""Register + enable the theme, handling both new and legacy Altair APIs."""
|
|
204
|
+
import altair as alt
|
|
205
|
+
cfg = altair_theme()
|
|
206
|
+
try: # Altair >= 5.5 (decorator-based theme API)
|
|
207
|
+
@alt.theme.register(name, enable=True)
|
|
208
|
+
def _theme():
|
|
209
|
+
return cfg
|
|
210
|
+
except AttributeError: # Altair < 5.5 (legacy registry API)
|
|
211
|
+
alt.themes.register(name, lambda: cfg)
|
|
212
|
+
alt.themes.enable(name)
|
|
213
|
+
return name
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def alt_line(data, x, y, color=None, title=""):
|
|
217
|
+
"""Thin styled line-chart builder. Channels use Altair shorthand, e.g. 'date:T'."""
|
|
218
|
+
import altair as alt
|
|
219
|
+
enc = {"x": x, "y": y}
|
|
220
|
+
if color:
|
|
221
|
+
enc["color"] = color
|
|
222
|
+
return alt.Chart(data, title=title).mark_line().encode(**enc)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def alt_bar(data, x, y, color=None, title=""):
|
|
226
|
+
"""Thin styled bar-chart builder."""
|
|
227
|
+
import altair as alt
|
|
228
|
+
enc = {"x": x, "y": y}
|
|
229
|
+
if color:
|
|
230
|
+
enc["color"] = color
|
|
231
|
+
return alt.Chart(data, title=title).mark_bar().encode(**enc)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def alt_scatter(data, x, y, color=None, title=""):
|
|
235
|
+
"""Thin styled scatter builder."""
|
|
236
|
+
import altair as alt
|
|
237
|
+
enc = {"x": x, "y": y}
|
|
238
|
+
if color:
|
|
239
|
+
enc["color"] = color
|
|
240
|
+
return alt.Chart(data, title=title).mark_point().encode(**enc)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
__all__ = [
|
|
244
|
+
"FONT", "FONT_STACK", "PALETTE", "INK", "MUTED", "GRID", "SURFACE",
|
|
245
|
+
"SEQUENTIAL", "DIVERGING",
|
|
246
|
+
"apply_matplotlib_theme", "style_axes", "ensure_font", "register_font",
|
|
247
|
+
"altair_theme", "enable_altair_theme", "alt_line", "alt_bar", "alt_scatter",
|
|
248
|
+
]
|