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.
@@ -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
+ ]