djrhails-graphs 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,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: djrhails-graphs
3
+ Version: 0.1.0
4
+ Summary: Economist-style chart theme for matplotlib/seaborn
5
+ Author-email: Daniel Hails <graphs@hails.info>
6
+ Project-URL: Homepage, https://github.com/DJRHails/graphs
7
+ Project-URL: Bug Tracker, https://github.com/DJRHails/graphs/issues
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: matplotlib>=3.7
12
+ Requires-Dist: numpy>=1.24
13
+ Dynamic: license-file
14
+
15
+ # graphs
16
+
17
+ Economist-style chart theme for matplotlib and seaborn — global theme, title-stack
18
+ finaliser, direct line labels, CI bands, horizontal bars, and dumbbell charts.
19
+ Uses IBM Plex Sans typography and a curated 8-colour palette.
20
+
21
+ Version: 0.1.0
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pip install djrhails-graphs
27
+ ```
28
+
29
+ PyPI distribution is `djrhails-graphs` because the bare name `graphs` is taken.
30
+ The import package is `graphs`.
31
+
32
+ ## Quick start
33
+
34
+ ```python
35
+ import matplotlib.pyplot as plt
36
+ from graphs import ci_fill, finalize, label_lines, set_theme
37
+
38
+ set_theme()
39
+
40
+ fig, ax = plt.subplots()
41
+ fig.subplots_adjust(top=0.68, bottom=0.14, left=0.06, right=0.88)
42
+
43
+ ax.plot(x, y, label="Series A")
44
+ label_lines(ax)
45
+ finalize(
46
+ ax,
47
+ title="Bold chart title",
48
+ descriptor="Country, metric, unit",
49
+ source="Source: Organisation",
50
+ )
51
+ ```
52
+
53
+ See `examples/` for runnable scripts:
54
+
55
+ - `line_chart.py` — multi-series line with CI bands + direct labels
56
+ - `faceted_chart.py` — three-panel faceted layout with panel labels
57
+ - `bar_chart.py` — horizontal bar with max-value highlight
58
+ - `dumbbell_chart.py` — before/after comparison with legend
59
+
60
+ ## Public API
61
+
62
+ | Function | Purpose |
63
+ |----------|---------|
64
+ | `set_theme()` | Apply global rcParams (figure, axes, ticks, fonts, palette) |
65
+ | `finalize(ax, title, descriptor, source, *, y_axis_right, title_x, y_start, autoscale_y)` | Title stack, red rule, source line, y-axis tidy-up |
66
+ | `label_lines(ax, ...)` | Direct end-of-line labels with collision avoidance |
67
+ | `smart_legend(ax, ...)` | Pick the emptiest corner of the axes |
68
+ | `ci_fill(ax, x, y_lo, y_hi, *, color)` | Confidence-interval band |
69
+ | `bar_h(ax, categories, values, *, highlight_max)` | Horizontal bar chart |
70
+ | `dumbbell(ax, categories, start, end, ...)` | Dot-and-line before/after chart |
71
+ | `panel_label(ax, label)` | Bold panel sub-heading for facets |
72
+
73
+ ### Palette
74
+
75
+ ```python
76
+ from graphs import colors, C_BG, C_RED, C_SPINE, C_GRID, C_LABEL, C_TEXT, C_CI
77
+ ```
78
+
79
+ Sequence `colors` (red primary, blue, teal, green, yellow, mauve, slate, coral).
80
+
81
+ ### Typography
82
+
83
+ IBM Plex Sans is loaded automatically. If already registered in matplotlib's
84
+ font manager, no download occurs. Otherwise TTFs are fetched from
85
+ `github.com/IBM/plex` on first use and cached inside the installed package.
86
+
87
+ Fallback chain: IBM Plex Sans → Verdana → Arial → DejaVu Sans.
88
+
89
+ ## License
90
+
91
+ MIT. See [LICENSE](./LICENSE).
@@ -0,0 +1,17 @@
1
+ djrhails_graphs-0.1.0.dist-info/licenses/LICENSE,sha256=RjexM-UPby8fRPlfzBjGcbGkb-awdO-A5pngqiggAAw,1069
2
+ examples/bar_chart.py,sha256=K4aOd7MXlws2rWXqu4cNs8gHR_lv7NyEaNTExxMszo4,889
3
+ examples/dumbbell_chart.py,sha256=yQr_u0txoBiFG19rPiNKxU_WW-L6psVmZBa1nIwU2PM,1542
4
+ examples/faceted_chart.py,sha256=_3oHGWrxf8n89TNx2aGMFRzbyM9Ug-P92bHdkvJymX4,2259
5
+ examples/line_chart.py,sha256=XpOsbF8TKEJ1RrEzgXcMCBTN5jjGXGadLs0b7YQVvH0,1334
6
+ graphs/__init__.py,sha256=oP_Ub6NIKWv0Xs3FC1nWsIy6HxmsDrEuNKlt-0j32X4,1074
7
+ graphs/_charts.py,sha256=oxNaRjqISoE80FcL-arSgmXKXCGZht8QR26PC1W2fps,3415
8
+ graphs/_finalize.py,sha256=RG_y5Duci_nRDlhoycWdA3Cc0GPLpoZ-dNaf69IB8w0,7970
9
+ graphs/_fonts.py,sha256=DdtmZoxd7Cy_pm2X_UAeLYU-lFqs8P3bdoUaxb3qasc,1761
10
+ graphs/_labels.py,sha256=9T1fW68F2OCQc9w-Cw5JURM8s1ZLIKAHDwQ_5rfva8Q,7405
11
+ graphs/_legend.py,sha256=5zk9kktLCCx0AiAJpJZyHERegGWRLqqFJxUYufGMoTw,4424
12
+ graphs/_palette.py,sha256=39uvvaTcwEt1PpFgqq2INxqcVEpQuzsV0tE7oXJtRRg,500
13
+ graphs/_theme.py,sha256=j7M1_kDB_KVKg2Urobf9YJwQKwSyIFLSlqhi1OfdOtc,2148
14
+ djrhails_graphs-0.1.0.dist-info/METADATA,sha256=_QltfRoUeuhsTogW93O9fEUfS-ZAIwJT7niOS9Xc0vc,2852
15
+ djrhails_graphs-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
16
+ djrhails_graphs-0.1.0.dist-info/top_level.txt,sha256=2AnvJf9GNUDwX0mmiuHIfWKk6oF5FH1ZG7Yibe0Slbs,16
17
+ djrhails_graphs-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Hails
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ examples
2
+ graphs
examples/bar_chart.py ADDED
@@ -0,0 +1,38 @@
1
+ # /// script
2
+ # requires-python = ">=3.12"
3
+ # dependencies = ["matplotlib"]
4
+ # ///
5
+ """Horizontal bar chart — The Economist style."""
6
+
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
11
+
12
+ import matplotlib.pyplot as plt
13
+
14
+ from graphs import bar_h, finalize, set_theme
15
+
16
+ set_theme()
17
+
18
+ categories = ["Germany", "France", "Italy", "Spain", "Poland", "Sweden"]
19
+ values = [3.7, 2.4, 1.8, 2.1, 4.2, 3.1]
20
+
21
+ fig, ax = plt.subplots(figsize=(7, 4))
22
+ fig.subplots_adjust(top=0.62, bottom=0.10, left=0.14, right=0.90)
23
+
24
+ bar_h(ax, categories, values)
25
+
26
+ finalize(
27
+ ax,
28
+ title="Eastern promise",
29
+ descriptor="GDP growth rate, %, 2023",
30
+ source="Source: Eurostat",
31
+ y_axis_right=False,
32
+ title_x=0.02,
33
+ )
34
+
35
+ out = Path(__file__).resolve().parent / "economist_bar.png"
36
+ plt.savefig(out, bbox_inches="tight", dpi=150)
37
+ plt.close()
38
+ print("Saved bar chart")
@@ -0,0 +1,72 @@
1
+ # /// script
2
+ # requires-python = ">=3.12"
3
+ # dependencies = ["matplotlib"]
4
+ # ///
5
+ """Dumbbell chart — The Economist style."""
6
+
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
11
+
12
+ import matplotlib.pyplot as plt
13
+ import matplotlib.ticker as ticker
14
+
15
+ from graphs import dumbbell, finalize, set_theme
16
+
17
+ set_theme()
18
+
19
+ countries = [
20
+ "United States",
21
+ "China",
22
+ "Germany",
23
+ "Japan",
24
+ "India",
25
+ "Brazil",
26
+ ]
27
+ gdp_2000 = [10.25, 1.21, 1.95, 4.72, 0.47, 0.65]
28
+ gdp_2020 = [20.93, 14.72, 3.84, 5.06, 2.62, 1.44]
29
+
30
+ fig, ax = plt.subplots(figsize=(8, 4.5))
31
+ fig.subplots_adjust(top=0.62, bottom=0.12, left=0.18, right=0.88)
32
+
33
+ dumbbell(
34
+ ax,
35
+ countries,
36
+ gdp_2000,
37
+ gdp_2020,
38
+ label_start="2000",
39
+ label_end="2020",
40
+ )
41
+ ax.set_xlim(-0.5, 24)
42
+ ax.xaxis.set_major_formatter(
43
+ ticker.FuncFormatter(lambda v, _: f"${v:.0f}tn")
44
+ )
45
+
46
+ finalize(
47
+ ax,
48
+ title="The great divergence",
49
+ descriptor="GDP in current US dollars, selected economies",
50
+ source="Source: World Bank",
51
+ y_axis_right=False,
52
+ title_x=0.02,
53
+ )
54
+
55
+ bbox = ax.get_position()
56
+ fig.legend(
57
+ handles=ax._dumbbell_handles,
58
+ labels=["2000", "2020"],
59
+ loc="upper right",
60
+ bbox_to_anchor=(bbox.x1, bbox.y1 + 0.005),
61
+ bbox_transform=fig.transFigure,
62
+ frameon=False,
63
+ fontsize=8.5,
64
+ ncol=2,
65
+ handletextpad=0.4,
66
+ columnspacing=1.0,
67
+ )
68
+
69
+ out = Path(__file__).resolve().parent / "economist_dumbbell.png"
70
+ plt.savefig(out, bbox_inches="tight", dpi=150)
71
+ plt.close()
72
+ print("Saved dumbbell chart")
@@ -0,0 +1,86 @@
1
+ # /// script
2
+ # requires-python = ">=3.12"
3
+ # dependencies = ["matplotlib", "numpy"]
4
+ # ///
5
+ """Faceted line chart with panel labels — The Economist style."""
6
+
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
11
+
12
+ import matplotlib.pyplot as plt
13
+ import matplotlib.ticker as ticker
14
+ import numpy as np
15
+
16
+ from graphs import (
17
+ C_BG,
18
+ C_LABEL,
19
+ C_RED,
20
+ C_SPINE,
21
+ ci_fill,
22
+ finalize,
23
+ panel_label,
24
+ set_theme,
25
+ )
26
+
27
+ set_theme()
28
+
29
+ np.random.seed(7)
30
+ x = np.linspace(-6, 10, 80)
31
+ panels = {
32
+ "Economic": np.where(
33
+ x < 0, x * 0.01, np.cumsum(np.random.randn(80) * 0.015)
34
+ ),
35
+ "Violent": np.cumsum(np.random.randn(80) * 0.008),
36
+ "Sexual": np.cumsum(np.random.randn(80) * 0.006),
37
+ }
38
+
39
+ fig, axes = plt.subplots(1, 3, figsize=(10, 5.5), sharey=False)
40
+ fig.subplots_adjust(
41
+ top=0.72, bottom=0.16, left=0.04, right=0.97, wspace=0.35
42
+ )
43
+
44
+ for ax, (panel_name, y) in zip(axes, panels.items()):
45
+ ci = np.abs(np.random.randn(len(x))) * 0.025 + 0.01
46
+ ci_fill(ax, x, y - ci, y + ci)
47
+ ax.plot(x, y, color=C_RED, linewidth=2)
48
+ ax.axhline(0, color=C_SPINE, linewidth=0.8, zorder=3)
49
+ ax.scatter([0], [0], color=C_SPINE, s=40, zorder=5)
50
+
51
+ ax.yaxis.set_label_position("right")
52
+ ax.yaxis.tick_right()
53
+ ax.spines["right"].set_visible(True)
54
+ ax.spines["right"].set_color(C_BG)
55
+ ax.spines["bottom"].set_color(C_SPINE)
56
+ ax.spines[["top", "left"]].set_visible(False)
57
+ ax.yaxis.set_tick_params(pad=-2, labelsize=8.5)
58
+ ax.set_ylim(-0.22, 0.22)
59
+ ax.yaxis.set_major_formatter(ticker.FormatStrFormatter("%.1f"))
60
+ ax.set_xlabel(
61
+ "Years since cancer diagnosis (1980-2018)",
62
+ fontsize=8,
63
+ color=C_LABEL,
64
+ )
65
+ panel_label(ax, panel_name)
66
+
67
+ finalize(
68
+ axes[0],
69
+ title=(
70
+ "Denmark, criminal-conviction rate since\n"
71
+ "cancer diagnosis, by type of offense"
72
+ ),
73
+ descriptor="Percentage-point change from baseline",
74
+ source=(
75
+ 'Source: "Breaking bad", '
76
+ "American Economics Journal, 2026"
77
+ ),
78
+ y_axis_right=False,
79
+ title_x=0.04,
80
+ y_start=0.075,
81
+ )
82
+
83
+ out = Path(__file__).resolve().parent / "economist_facet.png"
84
+ plt.savefig(out, bbox_inches="tight", dpi=150)
85
+ plt.close()
86
+ print("Saved facet chart")
examples/line_chart.py ADDED
@@ -0,0 +1,50 @@
1
+ # /// script
2
+ # requires-python = ">=3.12"
3
+ # dependencies = ["matplotlib", "numpy"]
4
+ # ///
5
+ """Line chart with CI bands and direct labels — The Economist style."""
6
+
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
11
+
12
+ import matplotlib.pyplot as plt
13
+ import matplotlib.ticker as ticker
14
+ import numpy as np
15
+
16
+ from graphs import ci_fill, finalize, label_lines, set_theme
17
+
18
+ set_theme()
19
+
20
+ np.random.seed(42)
21
+ years = np.arange(2000, 2024)
22
+ data = {
23
+ "United States": np.cumsum(np.random.randn(24) * 1.5) + 100,
24
+ "China": np.cumsum(np.random.randn(24) * 2.0) + 100,
25
+ "Germany": np.cumsum(np.random.randn(24) * 1.0) + 100,
26
+ }
27
+
28
+ fig, ax = plt.subplots()
29
+ fig.subplots_adjust(top=0.68, bottom=0.14, left=0.06, right=0.88)
30
+
31
+ for col, y in data.items():
32
+ ci = np.abs(np.random.randn(len(years))) * 2.5 + 0.5
33
+ ci_fill(ax, years, y - ci, y + ci)
34
+ ax.plot(years, y, label=col)
35
+
36
+ label_lines(ax, stroke=True, min_sep_pct=6.0)
37
+ ax.yaxis.set_major_formatter(ticker.FormatStrFormatter("%.0f"))
38
+
39
+ finalize(
40
+ ax,
41
+ title="GDP across major economies",
42
+ descriptor="Index, 2000=100",
43
+ source="Sources: IMF; World Bank",
44
+ y_axis_right=True,
45
+ )
46
+
47
+ out = Path(__file__).resolve().parent / "economist_line.png"
48
+ plt.savefig(out, bbox_inches="tight", dpi=150)
49
+ plt.close()
50
+ print("Saved line chart")
graphs/__init__.py ADDED
@@ -0,0 +1,52 @@
1
+ """graphs — Economist-style chart theme for matplotlib/seaborn.
2
+
3
+ Usage::
4
+
5
+ from graphs import set_theme, finalize, colors
6
+ set_theme()
7
+
8
+ fig, ax = plt.subplots()
9
+ fig.subplots_adjust(top=0.68, bottom=0.14, left=0.06, right=0.88)
10
+ ax.plot(...)
11
+ finalize(ax, title="Bold headline", descriptor="Country, metric, unit",
12
+ source="Source: Organisation")
13
+ """
14
+
15
+ from graphs._charts import bar_h, ci_fill, dumbbell
16
+ from graphs._finalize import finalize, panel_label
17
+ from graphs._fonts import _get_font as get_font
18
+ from graphs._labels import label_lines
19
+ from graphs._legend import smart_legend
20
+ from graphs._palette import (
21
+ C_BG,
22
+ C_CI,
23
+ C_GRID,
24
+ C_LABEL,
25
+ C_RED,
26
+ C_SPINE,
27
+ C_TEXT,
28
+ colors,
29
+ )
30
+ from graphs._theme import set_theme
31
+
32
+ __version__ = "0.1.0"
33
+
34
+ __all__ = [
35
+ "bar_h",
36
+ "ci_fill",
37
+ "colors",
38
+ "C_BG",
39
+ "C_CI",
40
+ "C_GRID",
41
+ "C_LABEL",
42
+ "C_RED",
43
+ "C_SPINE",
44
+ "C_TEXT",
45
+ "dumbbell",
46
+ "finalize",
47
+ "get_font",
48
+ "label_lines",
49
+ "panel_label",
50
+ "set_theme",
51
+ "smart_legend",
52
+ ]
graphs/_charts.py ADDED
@@ -0,0 +1,114 @@
1
+ """Reusable Economist-style chart helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Sequence
6
+
7
+ from graphs._palette import C_CI, C_GRID, C_LABEL, C_RED, C_SPINE, colors
8
+
9
+
10
+ def ci_fill(ax, x, y_lower, y_upper, *, color: str | None = None, alpha: float = 0.20):
11
+ """Fill a confidence-interval band.
12
+
13
+ By default uses the Economist salmon (#f5c5b8) at full opacity — the
14
+ legacy behaviour, fine for charts with a single series. When a colour is
15
+ provided, draws a semi-transparent fill in that colour so the band visibly
16
+ matches its line. Pair with `ax.plot(..., color=col)` and pass the same
17
+ `col` here.
18
+
19
+ Args:
20
+ color: Fill colour. Pass the matching line colour to colour-match.
21
+ alpha: Opacity when `color` is given. Ignored when `color` is None.
22
+ """
23
+ if color is None:
24
+ ax.fill_between(x, y_lower, y_upper, color=C_CI, linewidth=0, zorder=1)
25
+ else:
26
+ ax.fill_between(x, y_lower, y_upper, color=color, alpha=alpha, linewidth=0, zorder=1)
27
+
28
+
29
+ def bar_h(
30
+ ax,
31
+ categories: Sequence[str],
32
+ values: Sequence[float],
33
+ *,
34
+ color: str | None = None,
35
+ highlight_max: bool = True,
36
+ ):
37
+ """Horizontal bar chart in Economist style.
38
+
39
+ Category labels sit right-aligned to the left of the bars.
40
+ The highest-value bar is highlighted in red.
41
+ """
42
+ color = color or colors[1]
43
+ bars = ax.barh(categories, values, color=color, height=0.6, zorder=2)
44
+
45
+ if highlight_max and values:
46
+ idx, _ = max(enumerate(values), key=lambda x: x[1])
47
+ bars[idx].set_color(C_RED)
48
+
49
+ ax.spines[["top", "left", "right", "bottom"]].set_visible(False)
50
+ ax.set_yticks(range(len(categories)))
51
+ ax.set_yticklabels(categories, ha="right", fontsize=9)
52
+ ax.yaxis.set_tick_params(length=0, pad=6)
53
+ ax.xaxis.set_tick_params(labelsize=9, length=3.5, direction="out")
54
+ ax.grid(axis="x", color=C_GRID, linewidth=0.6, zorder=0)
55
+ ax.grid(axis="y", visible=False)
56
+ return ax
57
+
58
+
59
+ def dumbbell(
60
+ ax,
61
+ categories: Sequence[str],
62
+ values_start: Sequence[float],
63
+ values_end: Sequence[float],
64
+ *,
65
+ label_start: str = "Start",
66
+ label_end: str = "End",
67
+ color_start: str | None = None,
68
+ color_end: str | None = None,
69
+ ):
70
+ """Dumbbell (dot-and-line) chart for showing change between two periods.
71
+
72
+ Scatter handles are stored on ``ax._dumbbell_handles`` so you
73
+ can pass them to ``fig.legend()`` afterwards.
74
+ """
75
+ color_start = color_start or colors[7] # Coral
76
+ color_end = color_end or colors[1] # Blue
77
+
78
+ ax.hlines(
79
+ y=categories,
80
+ xmin=values_start,
81
+ xmax=values_end,
82
+ color=C_LABEL,
83
+ linewidth=2,
84
+ alpha=0.6,
85
+ zorder=2,
86
+ label="_nolegend_",
87
+ )
88
+
89
+ s1 = ax.scatter(
90
+ values_start,
91
+ categories,
92
+ s=80,
93
+ color=color_start,
94
+ zorder=4,
95
+ label=label_start,
96
+ )
97
+ s2 = ax.scatter(
98
+ values_end,
99
+ categories,
100
+ s=80,
101
+ color=color_end,
102
+ zorder=4,
103
+ label=label_end,
104
+ )
105
+
106
+ ax.spines[["top", "left", "right"]].set_visible(False)
107
+ ax.spines["bottom"].set_color(C_SPINE)
108
+ ax.yaxis.set_tick_params(length=0, pad=6)
109
+ ax.xaxis.set_tick_params(labelsize=9, length=3.5, direction="out")
110
+ ax.grid(axis="x", color=C_GRID, linewidth=0.6, zorder=0)
111
+ ax.grid(axis="y", visible=False)
112
+
113
+ ax._dumbbell_handles = [s1, s2]
114
+ return ax
graphs/_finalize.py ADDED
@@ -0,0 +1,254 @@
1
+ """Chart finalisation — title stack, source line, panel labels."""
2
+
3
+ import warnings
4
+
5
+ import matplotlib.font_manager as fm
6
+ import matplotlib.pyplot as plt
7
+
8
+ from graphs._fonts import _get_font
9
+ from graphs._palette import C_BG, C_LABEL, C_RED, C_SPINE, C_TEXT
10
+
11
+
12
+ def _check_x_monotonic(ax) -> None:
13
+ """Warn if the x-axis runs right-to-left (e.g. accidental invert_xaxis)."""
14
+ x0, x1 = ax.get_xlim()
15
+ if x0 > x1:
16
+ warnings.warn(
17
+ "graphs.finalize: x-axis runs right-to-left "
18
+ f"(xlim={x0:.3g} → {x1:.3g}). If this is unintended, remove the "
19
+ "invert_xaxis() call or fix the data order.",
20
+ stacklevel=3,
21
+ )
22
+
23
+
24
+ def _autoscale_y(ax, *, headroom: float = 0.10, floor_frac: float = 0.40) -> None:
25
+ """Tighten y-limits when data uses less than `floor_frac` of the range.
26
+
27
+ Leaves an explicit user-set y-limit alone (detected by the autoscale flag).
28
+ Skips axes with no real data extent. Adds `headroom` proportional padding
29
+ above the data so the top line/bar doesn't collide with the spine.
30
+ """
31
+ if not ax.get_autoscaley_on():
32
+ return # user pinned ylim explicitly
33
+
34
+ data_max = float("-inf")
35
+ data_min = float("inf")
36
+ for art in list(ax.lines) + list(ax.patches) + list(ax.collections):
37
+ try:
38
+ dp = art.get_datalim(ax.transData) if hasattr(art, "get_datalim") else None
39
+ except Exception:
40
+ dp = None
41
+ if dp is not None and dp.height > 0:
42
+ data_min = min(data_min, dp.y0)
43
+ data_max = max(data_max, dp.y1)
44
+
45
+ # Fallback: scan line ydata.
46
+ if not (data_max > float("-inf") and data_min < float("inf")):
47
+ for line in ax.lines:
48
+ ys = line.get_ydata()
49
+ if len(ys):
50
+ data_min = min(data_min, float(min(ys)))
51
+ data_max = max(data_max, float(max(ys)))
52
+
53
+ if not (data_max > float("-inf") and data_min < float("inf")):
54
+ return
55
+ if data_max == data_min:
56
+ return
57
+
58
+ y_lo, y_hi = ax.get_ylim()
59
+ span_used = (data_max - data_min) / (y_hi - y_lo) if y_hi > y_lo else 1.0
60
+ if span_used >= floor_frac:
61
+ return
62
+
63
+ pad = (data_max - data_min) * headroom
64
+ new_lo = max(y_lo, data_min - pad) if data_min >= 0 else data_min - pad
65
+ # Snap to 0 if data is non-negative and close to 0 — keeps the baseline clean.
66
+ if data_min >= 0 and data_min <= (data_max - data_min) * 0.25:
67
+ new_lo = 0.0
68
+ new_hi = data_max + pad
69
+ ax.set_ylim(new_lo, new_hi)
70
+
71
+
72
+ def finalize(
73
+ ax,
74
+ title: str = "",
75
+ descriptor: str = "",
76
+ source: str = "",
77
+ *,
78
+ y_axis_right: bool = True,
79
+ title_x: float | None = None,
80
+ y_start: float = 0.010,
81
+ autoscale_y: bool = True,
82
+ ):
83
+ """Add Economist finishing touches to an axes object.
84
+
85
+ Title stack (top to bottom)::
86
+
87
+ ──── short red rule
88
+ Title IBM Plex Sans Bold
89
+ Descriptor IBM Plex Sans Regular
90
+
91
+ Args:
92
+ title: Chart headline.
93
+ descriptor: Subtitle line (country, metric, unit).
94
+ source: Attribution line below the chart.
95
+ y_axis_right: Move y-axis labels to the right.
96
+ title_x: Override x anchor in figure coords.
97
+ Defaults to the axes bounding-box x0.
98
+ Set to e.g. 0.02 for charts with wide left margins.
99
+ y_start: Gap above bbox.y1 where title stack begins.
100
+ Increase to ~0.07 for faceted charts.
101
+ autoscale_y: If True (default), auto-tighten y-limits when the data
102
+ fills less than 40% of the current axis range. Disable for charts
103
+ that need a pinned 0–100% canvas regardless of data.
104
+ """
105
+ fig = ax.get_figure()
106
+ fig.patch.set_facecolor(C_BG)
107
+
108
+ _check_x_monotonic(ax)
109
+ if autoscale_y:
110
+ _autoscale_y(ax)
111
+
112
+ if y_axis_right:
113
+ ax.yaxis.set_label_position("right")
114
+ ax.yaxis.tick_right()
115
+ ax.spines["right"].set_visible(True)
116
+ ax.spines["right"].set_color(C_BG)
117
+ ax.spines["right"].set_linewidth(0)
118
+ ax.spines["left"].set_visible(False)
119
+
120
+ ax.yaxis.set_tick_params(pad=4, labelsize=9)
121
+ ax.spines["bottom"].set_color(C_SPINE)
122
+ ax.spines["bottom"].set_linewidth(1.0)
123
+
124
+ fig.canvas.draw()
125
+ bbox = ax.get_position()
126
+ tx = title_x if title_x is not None else bbox.x0
127
+
128
+ # Build title stack upward from just above the axes
129
+ line_gap = 0.013
130
+ rule_gap = 0.026
131
+ y_cursor = bbox.y1 + y_start
132
+
133
+ if descriptor:
134
+ n_desc_lines = descriptor.count("\n") + 1
135
+ fig.text(
136
+ tx,
137
+ y_cursor,
138
+ descriptor,
139
+ transform=fig.transFigure,
140
+ fontsize=9.5,
141
+ fontweight="normal",
142
+ color=C_TEXT,
143
+ va="bottom",
144
+ ha="left",
145
+ linespacing=1.25,
146
+ )
147
+ # 0.032 is the height of one descriptor line in figure coords;
148
+ # add 0.022 per extra line so the title sits clear of the wrap.
149
+ y_cursor += 0.032 + 0.022 * (n_desc_lines - 1) + line_gap
150
+
151
+ if title:
152
+ fp = fm.FontProperties(family=_get_font(), weight=700)
153
+ fig.text(
154
+ tx,
155
+ y_cursor,
156
+ title,
157
+ transform=fig.transFigure,
158
+ fontsize=12,
159
+ fontproperties=fp,
160
+ color=C_TEXT,
161
+ va="bottom",
162
+ ha="left",
163
+ )
164
+ y_cursor += 0.040 + rule_gap
165
+
166
+ # Short red rule at the top (~80 px wide)
167
+ rule_w = 80 / (fig.get_figwidth() * fig.dpi)
168
+ fig.add_artist(
169
+ plt.Line2D(
170
+ [tx, tx + rule_w],
171
+ [y_cursor, y_cursor],
172
+ transform=fig.transFigure,
173
+ color=C_RED,
174
+ linewidth=3.5,
175
+ solid_capstyle="butt",
176
+ clip_on=False,
177
+ )
178
+ )
179
+
180
+ if source:
181
+ # Place source below the lowest of: xlabel, x-tick labels.
182
+ # Wrapped multi-line x-tick labels can extend further down than the
183
+ # xlabel and previously caused the source line to overlap them.
184
+ source_y = bbox.y0 - 0.06
185
+ try:
186
+ renderer = fig.canvas.get_renderer()
187
+ lowest_fig_y = bbox.y0
188
+ xlabel = ax.xaxis.label
189
+ if xlabel.get_text():
190
+ xlbl_bbox = xlabel.get_window_extent(renderer=renderer)
191
+ lowest_fig_y = min(
192
+ lowest_fig_y,
193
+ xlbl_bbox.transformed(fig.transFigure.inverted()).y0,
194
+ )
195
+ for tl in ax.get_xticklabels():
196
+ if not tl.get_text():
197
+ continue
198
+ tl_bb = tl.get_window_extent(renderer=renderer)
199
+ lowest_fig_y = min(
200
+ lowest_fig_y,
201
+ tl_bb.transformed(fig.transFigure.inverted()).y0,
202
+ )
203
+ source_y = min(source_y, lowest_fig_y - 0.015)
204
+ except Exception:
205
+ pass
206
+ fig.text(
207
+ tx,
208
+ source_y,
209
+ source,
210
+ transform=fig.transFigure,
211
+ fontsize=7.5,
212
+ color=C_LABEL,
213
+ va="top",
214
+ ha="left",
215
+ )
216
+
217
+ return fig, ax
218
+
219
+
220
+ def panel_label(ax, label: str, *, fontsize: int = 10) -> None:
221
+ """Bold panel sub-heading with a short dark rule — for faceted charts.
222
+
223
+ Renders above the axes::
224
+
225
+ --------
226
+ Economic
227
+ """
228
+ fig = ax.get_figure()
229
+ fig.canvas.draw()
230
+ bbox = ax.get_position()
231
+
232
+ rule_w = 35 / (fig.get_figwidth() * fig.dpi)
233
+ fig.add_artist(
234
+ plt.Line2D(
235
+ [bbox.x0, bbox.x0 + rule_w],
236
+ [bbox.y1 + 0.052, bbox.y1 + 0.052],
237
+ transform=fig.transFigure,
238
+ color=C_SPINE,
239
+ linewidth=1.2,
240
+ solid_capstyle="butt",
241
+ clip_on=False,
242
+ )
243
+ )
244
+ fig.text(
245
+ bbox.x0,
246
+ bbox.y1 + 0.010,
247
+ label,
248
+ transform=fig.transFigure,
249
+ fontsize=fontsize,
250
+ fontweight="bold",
251
+ color=C_TEXT,
252
+ va="bottom",
253
+ ha="left",
254
+ )
graphs/_fonts.py ADDED
@@ -0,0 +1,66 @@
1
+ """IBM Plex Sans font management for matplotlib.
2
+
3
+ Downloads font files from github.com/IBM/plex only when the font
4
+ is not already registered in matplotlib's font manager.
5
+ """
6
+
7
+ import logging
8
+ import os
9
+ import urllib.request
10
+
11
+ import matplotlib.font_manager as fm
12
+
13
+ _log = logging.getLogger(__name__)
14
+
15
+ _IBM_BASE = (
16
+ "https://github.com/IBM/plex/raw/master/"
17
+ "packages/plex-sans/fonts/complete/ttf/"
18
+ )
19
+ _IBM_FILES = [
20
+ "IBMPlexSans-Regular.ttf",
21
+ "IBMPlexSans-Bold.ttf",
22
+ "IBMPlexSans-SemiBold.ttf",
23
+ "IBMPlexSans-Light.ttf",
24
+ "IBMPlexSans-Italic.ttf",
25
+ ]
26
+ _FONT_NAME = "IBM Plex Sans"
27
+ _FALLBACK = "DejaVu Sans"
28
+
29
+
30
+ def _is_registered() -> bool:
31
+ """Check if IBM Plex Sans is already in matplotlib's font manager."""
32
+ return any(f.name == _FONT_NAME for f in fm.fontManager.ttflist)
33
+
34
+
35
+ def ensure_ibm_plex() -> str:
36
+ """Return the font family name, downloading only if not already available."""
37
+ if _is_registered():
38
+ return _FONT_NAME
39
+
40
+ font_dir = os.path.join(os.path.dirname(__file__), "_fonts_cache")
41
+ os.makedirs(font_dir, exist_ok=True)
42
+
43
+ for fname in _IBM_FILES:
44
+ path = os.path.join(font_dir, fname)
45
+ if not os.path.exists(path):
46
+ try:
47
+ urllib.request.urlretrieve(_IBM_BASE + fname, path)
48
+ except Exception:
49
+ _log.debug("Failed to download %s", fname, exc_info=True)
50
+ continue
51
+ try:
52
+ fm.fontManager.addfont(path)
53
+ except Exception:
54
+ _log.debug("Failed to register font %s", fname, exc_info=True)
55
+
56
+ return _FONT_NAME if _is_registered() else _FALLBACK
57
+
58
+
59
+ FONT: str = ""
60
+
61
+
62
+ def _get_font() -> str:
63
+ global FONT # noqa: PLW0603
64
+ if not FONT:
65
+ FONT = ensure_ibm_plex()
66
+ return FONT
graphs/_labels.py ADDED
@@ -0,0 +1,197 @@
1
+ """Direct line labelling — replaces legends for line charts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from collections import defaultdict
7
+
8
+ import matplotlib.patheffects as pe
9
+
10
+ from graphs._palette import C_BG
11
+
12
+
13
+ def label_lines(
14
+ ax,
15
+ labels: list[str] | None = None,
16
+ *,
17
+ x_offset: int = 6,
18
+ fontsize: int = 9,
19
+ stroke: bool = True,
20
+ min_sep_pct: float = 5.0,
21
+ tick_pad_pct: float = 4.5,
22
+ edge_pull_pct: float = 1.5,
23
+ ):
24
+ """Label each line at its rightmost point instead of using a legend.
25
+
26
+ Visible y-tick rows are treated as fixed dividers. Each label is placed
27
+ in the band between two consecutive ticks (with `tick_pad_pct` padding off
28
+ each tick) so labels can never obscure axis tick text. Within a band,
29
+ labels are spread evenly with at least `min_sep_pct` separation; if the
30
+ band is too narrow for the labels in it, separation shrinks to fit
31
+ without spilling onto the tick rows.
32
+
33
+ Args:
34
+ labels: Override list of strings (defaults to line labels).
35
+ x_offset: Horizontal pixel offset from the last data point.
36
+ fontsize: Label font size.
37
+ stroke: White halo behind text to avoid clashing with gridlines.
38
+ min_sep_pct: Minimum label separation as % of y-axis range.
39
+ tick_pad_pct: Minimum gap between any label and a y-tick row,
40
+ as % of y-axis range. Set to 0 to disable tick-avoidance.
41
+ edge_pull_pct: When a line ends exactly at y_lo or y_hi (so its
42
+ label would otherwise sit on top of the 0% / 100% tick text),
43
+ pull the label this many % of the y-range *into* the chart.
44
+ Set to 0 to disable.
45
+ """
46
+ lines = [line for line in ax.get_lines() if not line.get_label().startswith("_")]
47
+ if not lines:
48
+ return
49
+
50
+ y_lo, y_hi = ax.get_ylim()
51
+ span = y_hi - y_lo
52
+ min_sep = span * min_sep_pct / 100
53
+ tick_pad = span * tick_pad_pct / 100
54
+ edge_pull = span * edge_pull_pct / 100
55
+
56
+ # Visible ticks define band boundaries. The axis limits cap the outer bands.
57
+ yticks = sorted(t for t in ax.get_yticks() if y_lo <= t <= y_hi)
58
+ tick_set = set(yticks)
59
+ edges = [y_lo] + yticks + [y_hi]
60
+ bands: list[tuple[float, float]] = []
61
+ for i in range(len(edges) - 1):
62
+ lo = edges[i] + (tick_pad if edges[i] in tick_set else 0)
63
+ hi = edges[i + 1] - (tick_pad if edges[i + 1] in tick_set else 0)
64
+ if hi > lo:
65
+ bands.append((lo, hi))
66
+ if not bands: # degenerate: no usable band, fall back to full axis
67
+ bands = [(y_lo, y_hi)]
68
+
69
+ chart_center = (y_lo + y_hi) / 2
70
+
71
+ def assign_band(y: float) -> tuple[float, float]:
72
+ """Pick the band for a label at `y`.
73
+
74
+ Strict containment wins. Otherwise pick the band whose nearest edge
75
+ is closest to `y`; on ties (label sits exactly on a tick boundary),
76
+ pick the band whose centre is closer to the chart centre — so labels
77
+ for lines that end at the axis extremes land *interior* to the chart
78
+ rather than spilling outside the spine.
79
+ """
80
+ for lo, hi in bands:
81
+ if lo <= y <= hi:
82
+ return (lo, hi)
83
+
84
+ def edge_dist(b):
85
+ lo, hi = b
86
+ return min(abs(lo - y), abs(hi - y))
87
+
88
+ d_min = min(edge_dist(b) for b in bands)
89
+ candidates = [b for b in bands if edge_dist(b) == d_min]
90
+ if len(candidates) == 1:
91
+ return candidates[0]
92
+ return min(candidates, key=lambda b: abs((b[0] + b[1]) / 2 - chart_center))
93
+
94
+ items: list[list] = []
95
+ for i, line in enumerate(lines):
96
+ lbl = labels[i] if labels and i < len(labels) else line.get_label()
97
+ y = float(line.get_ydata()[-1])
98
+ # Pull labels that hug the axis extremes into the chart so they don't
99
+ # collide with the 0% / 100% tick text.
100
+ if edge_pull > 0:
101
+ if abs(y - y_hi) < tick_pad:
102
+ y = y_hi - edge_pull - tick_pad
103
+ elif abs(y - y_lo) < tick_pad:
104
+ y = y_lo + edge_pull + tick_pad
105
+ items.append([y, lbl, line, assign_band(y)])
106
+
107
+ # Distribute each band's labels evenly inside it, anchored near their mean y.
108
+ by_band: dict[tuple[float, float], list[list]] = defaultdict(list)
109
+ for it in items:
110
+ by_band[it[3]].append(it)
111
+
112
+ for (band_lo, band_hi), group in by_band.items():
113
+ group.sort(key=lambda it: it[0])
114
+ n = len(group)
115
+ if n == 1:
116
+ group[0][0] = max(band_lo, min(band_hi, group[0][0]))
117
+ continue
118
+ avail = band_hi - band_lo
119
+ sep = min(min_sep, avail / (n - 1))
120
+ total = (n - 1) * sep
121
+ mean_y = sum(it[0] for it in group) / n
122
+ center = max(band_lo + total / 2, min(band_hi - total / 2, mean_y))
123
+ start = center - total / 2
124
+ for i, it in enumerate(group):
125
+ it[0] = start + i * sep
126
+
127
+ path_fx = [pe.withStroke(linewidth=3, foreground=C_BG)] if stroke else []
128
+ annotations = []
129
+ for nudged_y, lbl, line, _ in items:
130
+ ann = ax.annotate(
131
+ lbl,
132
+ xy=(line.get_xdata()[-1], nudged_y),
133
+ xytext=(x_offset, 0),
134
+ textcoords="offset points",
135
+ va="center",
136
+ fontsize=fontsize,
137
+ color=line.get_color(),
138
+ path_effects=path_fx,
139
+ clip_on=False,
140
+ annotation_clip=False,
141
+ )
142
+ annotations.append(ann)
143
+
144
+ # Render-pass: detect residual overlaps between labels in pixel space and
145
+ # spread them in y. Necessary because the band-distribution pass works in
146
+ # data coords and can leave labels touching when the band is very thin or
147
+ # when two lines end within a few pixels of each other.
148
+ fig = ax.get_figure()
149
+ try:
150
+ fig.canvas.draw()
151
+ renderer = fig.canvas.get_renderer()
152
+ except Exception:
153
+ return
154
+
155
+ def _bbox(a):
156
+ return a.get_window_extent(renderer=renderer)
157
+
158
+ for _ in range(8):
159
+ bboxes = [(_bbox(a), a) for a in annotations]
160
+ bboxes.sort(key=lambda b: b[0].y0)
161
+ moved = False
162
+ for i in range(1, len(bboxes)):
163
+ prev_bb, _ = bboxes[i - 1]
164
+ cur_bb, cur_ann = bboxes[i]
165
+ overlap = prev_bb.y1 - cur_bb.y0
166
+ if overlap > 0:
167
+ # nudge current annotation up by `overlap + 1px`
168
+ shift_px = overlap + 1
169
+ inv = ax.transData.inverted()
170
+ _, y0 = inv.transform((0, 0))
171
+ _, y1 = inv.transform((0, shift_px))
172
+ dy = y1 - y0
173
+ xy = cur_ann.xy
174
+ cur_ann.xy = (xy[0], xy[1] + dy)
175
+ moved = True
176
+ if not moved:
177
+ break
178
+
179
+ # Final warning if any label still overlaps a y-tick label in pixel space.
180
+ tick_bboxes = []
181
+ for tl in ax.get_yticklabels():
182
+ if tl.get_text():
183
+ try:
184
+ tick_bboxes.append(tl.get_window_extent(renderer=renderer))
185
+ except Exception:
186
+ pass
187
+ for ann in annotations:
188
+ bb = _bbox(ann)
189
+ for tb in tick_bboxes:
190
+ if bb.overlaps(tb):
191
+ warnings.warn(
192
+ f"graphs.label_lines: label {ann.get_text()!r} overlaps "
193
+ "a y-tick label. Consider increasing tick_pad_pct or "
194
+ "tightening y-limits.",
195
+ stacklevel=2,
196
+ )
197
+ break
graphs/_legend.py ADDED
@@ -0,0 +1,145 @@
1
+ """Smart legend placement — pick the emptiest corner of the axes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+
7
+ from graphs._palette import C_BG, C_SPINE
8
+
9
+
10
+ _CORNERS = {
11
+ "upper right": (1, 1),
12
+ "upper left": (0, 1),
13
+ "lower right": (1, 0),
14
+ "lower left": (0, 0),
15
+ }
16
+
17
+
18
+ def smart_legend(
19
+ ax,
20
+ *,
21
+ pad: float = 0.02,
22
+ prefer: tuple[str, ...] = ("upper right", "upper left", "lower right", "lower left"),
23
+ fontsize: int = 9,
24
+ frame: bool = True,
25
+ **legend_kwargs,
26
+ ):
27
+ """Place a legend in the emptiest corner of the axes.
28
+
29
+ Inspects every artist on `ax` (lines, bars, error-bars, scatter, fills),
30
+ samples their occupied pixel bboxes, and picks the corner of the axes that
31
+ has the most empty space for a legend roughly sized to fit the entries.
32
+
33
+ Args:
34
+ pad: Fractional inset from the axes edge (data area).
35
+ prefer: Tie-break order among equally-empty corners.
36
+ fontsize: Legend text size.
37
+ frame: Draw a thin frame matching the spine colour. Set False for
38
+ frameless legends.
39
+ **legend_kwargs: Forwarded to ax.legend (e.g. title, ncol).
40
+
41
+ Returns:
42
+ The matplotlib Legend object.
43
+ """
44
+ fig = ax.get_figure()
45
+ try:
46
+ fig.canvas.draw()
47
+ renderer = fig.canvas.get_renderer()
48
+ except Exception:
49
+ return ax.legend(loc=prefer[0], fontsize=fontsize, **legend_kwargs)
50
+
51
+ handles, labels = ax.get_legend_handles_labels()
52
+ if not handles:
53
+ return None
54
+
55
+ # Draft legend off-axes to measure its size.
56
+ draft = ax.legend(
57
+ handles,
58
+ labels,
59
+ loc="upper right",
60
+ fontsize=fontsize,
61
+ frameon=frame,
62
+ **legend_kwargs,
63
+ )
64
+ fig.canvas.draw()
65
+ leg_bbox_px = draft.get_window_extent(renderer=renderer)
66
+ leg_w_px = leg_bbox_px.width
67
+ leg_h_px = leg_bbox_px.height
68
+ draft.remove()
69
+
70
+ ax_bbox = ax.get_window_extent(renderer=renderer)
71
+
72
+ # Gather artist bboxes that count as "data ink" we should avoid.
73
+ artist_bboxes = []
74
+ for art in (
75
+ list(ax.lines)
76
+ + list(ax.patches)
77
+ + list(ax.collections)
78
+ + list(ax.containers)
79
+ ):
80
+ # ErrorbarContainer / BarContainer aren't artists themselves but iterate.
81
+ if hasattr(art, "get_window_extent"):
82
+ try:
83
+ bb = art.get_window_extent(renderer=renderer)
84
+ if bb.width > 0 and bb.height > 0:
85
+ artist_bboxes.append(bb)
86
+ except Exception:
87
+ pass
88
+ elif hasattr(art, "__iter__"):
89
+ for sub in art:
90
+ try:
91
+ bb = sub.get_window_extent(renderer=renderer)
92
+ if bb.width > 0 and bb.height > 0:
93
+ artist_bboxes.append(bb)
94
+ except Exception:
95
+ pass
96
+
97
+ def overlap_score(corner: str) -> float:
98
+ ax_x, ax_y = _CORNERS[corner]
99
+ # Pixel box for the candidate legend at this corner.
100
+ if ax_x == 1:
101
+ x1 = ax_bbox.x1 - pad * ax_bbox.width
102
+ x0 = x1 - leg_w_px
103
+ else:
104
+ x0 = ax_bbox.x0 + pad * ax_bbox.width
105
+ x1 = x0 + leg_w_px
106
+ if ax_y == 1:
107
+ y1 = ax_bbox.y1 - pad * ax_bbox.height
108
+ y0 = y1 - leg_h_px
109
+ else:
110
+ y0 = ax_bbox.y0 + pad * ax_bbox.height
111
+ y1 = y0 + leg_h_px
112
+
113
+ score = 0.0
114
+ for bb in artist_bboxes:
115
+ ox = max(0, min(x1, bb.x1) - max(x0, bb.x0))
116
+ oy = max(0, min(y1, bb.y1) - max(y0, bb.y0))
117
+ score += ox * oy
118
+ return score
119
+
120
+ scored = [(overlap_score(c), prefer.index(c), c) for c in prefer]
121
+ scored.sort() # lowest overlap wins; tie-break by user preference order
122
+ best = scored[0][2]
123
+ if scored[0][0] > 0:
124
+ warnings.warn(
125
+ f"graphs.smart_legend: best corner {best!r} still overlaps "
126
+ "data. Consider tightening axes limits or moving the legend "
127
+ "outside the axes with bbox_to_anchor.",
128
+ stacklevel=2,
129
+ )
130
+
131
+ leg = ax.legend(
132
+ handles,
133
+ labels,
134
+ loc=best,
135
+ fontsize=fontsize,
136
+ frameon=frame,
137
+ **legend_kwargs,
138
+ )
139
+ if frame:
140
+ frame_obj = leg.get_frame()
141
+ frame_obj.set_edgecolor(C_SPINE)
142
+ frame_obj.set_linewidth(0.5)
143
+ frame_obj.set_facecolor(C_BG)
144
+ frame_obj.set_alpha(0.92)
145
+ return leg
graphs/_palette.py ADDED
@@ -0,0 +1,22 @@
1
+ """Economist-style colour palette and visual constants."""
2
+
3
+ # Background and structural colours
4
+ C_BG = "#FFFFFF"
5
+ C_SPINE = "#1A1A1A"
6
+ C_GRID = "#D9D9D9"
7
+ C_TEXT = "#1A1A1A"
8
+ C_LABEL = "#666666"
9
+ C_RED = "#bf352b"
10
+ C_CI = "#f5c5b8"
11
+
12
+ # Categorical colour cycle — red is the primary accent
13
+ colors = [
14
+ "#bf352b", # Red — primary
15
+ "#006BA2", # Blue
16
+ "#3EBCD2", # Teal
17
+ "#379A8B", # Green
18
+ "#EBB434", # Yellow
19
+ "#9A607F", # Mauve
20
+ "#758D99", # Slate
21
+ "#DB444B", # Coral
22
+ ]
graphs/_theme.py ADDED
@@ -0,0 +1,70 @@
1
+ """Economist-style matplotlib/seaborn global theme."""
2
+
3
+ import matplotlib.pyplot as plt
4
+
5
+ from graphs._fonts import _get_font
6
+ from graphs._palette import (
7
+ C_BG,
8
+ C_GRID,
9
+ C_LABEL,
10
+ C_SPINE,
11
+ C_TEXT,
12
+ colors,
13
+ )
14
+
15
+
16
+ def set_theme() -> None:
17
+ """Apply The Economist's visual style globally to matplotlib/seaborn."""
18
+ plt.rcParams.update(
19
+ {
20
+ # Figure
21
+ "figure.facecolor": C_BG,
22
+ "figure.dpi": 150,
23
+ "figure.figsize": (7, 5),
24
+ # Axes
25
+ "axes.facecolor": C_BG,
26
+ "axes.edgecolor": C_SPINE,
27
+ "axes.linewidth": 1.0,
28
+ "axes.spines.top": False,
29
+ "axes.spines.right": False,
30
+ "axes.spines.left": False,
31
+ "axes.spines.bottom": True,
32
+ "axes.grid": True,
33
+ "axes.grid.axis": "y",
34
+ "axes.axisbelow": True,
35
+ "axes.labelcolor": C_LABEL,
36
+ "axes.labelsize": 9,
37
+ "axes.labelpad": 4,
38
+ "axes.prop_cycle": plt.cycler("color", colors),
39
+ # Grid
40
+ "grid.color": C_GRID,
41
+ "grid.linewidth": 0.6,
42
+ "grid.linestyle": "-",
43
+ # Ticks
44
+ "xtick.color": C_SPINE,
45
+ "ytick.color": C_LABEL,
46
+ "xtick.labelcolor": C_LABEL,
47
+ "ytick.labelcolor": C_LABEL,
48
+ "xtick.labelsize": 9,
49
+ "ytick.labelsize": 9,
50
+ "xtick.major.size": 3.5,
51
+ "xtick.major.width": 0.8,
52
+ "ytick.major.size": 0,
53
+ "xtick.minor.size": 0,
54
+ "ytick.minor.size": 0,
55
+ "xtick.bottom": True,
56
+ "xtick.direction": "out",
57
+ # Typography
58
+ "font.family": "sans-serif",
59
+ "font.sans-serif": [_get_font(), "Verdana", "Arial", "DejaVu Sans"],
60
+ "text.color": C_TEXT,
61
+ # Legend
62
+ "legend.frameon": False,
63
+ "legend.fontsize": 8.5,
64
+ "legend.labelcolor": C_TEXT,
65
+ # Lines & patches
66
+ "lines.linewidth": 2.0,
67
+ "lines.solid_capstyle": "round",
68
+ "patch.edgecolor": "none",
69
+ }
70
+ )