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.
- djrhails_graphs-0.1.0.dist-info/METADATA +91 -0
- djrhails_graphs-0.1.0.dist-info/RECORD +17 -0
- djrhails_graphs-0.1.0.dist-info/WHEEL +5 -0
- djrhails_graphs-0.1.0.dist-info/licenses/LICENSE +21 -0
- djrhails_graphs-0.1.0.dist-info/top_level.txt +2 -0
- examples/bar_chart.py +38 -0
- examples/dumbbell_chart.py +72 -0
- examples/faceted_chart.py +86 -0
- examples/line_chart.py +50 -0
- graphs/__init__.py +52 -0
- graphs/_charts.py +114 -0
- graphs/_finalize.py +254 -0
- graphs/_fonts.py +66 -0
- graphs/_labels.py +197 -0
- graphs/_legend.py +145 -0
- graphs/_palette.py +22 -0
- graphs/_theme.py +70 -0
|
@@ -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,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.
|
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
|
+
)
|