eofigs 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.
- eofigs/__init__.py +52 -0
- eofigs/_colour.py +258 -0
- eofigs/_countries.py +157 -0
- eofigs/_presets.py +147 -0
- eofigs/accuracy.py +716 -0
- eofigs/check.py +766 -0
- eofigs/data/journals.json +126 -0
- eofigs/data/ne_110m_countries.geojson +1 -0
- eofigs/data/variables.json +270 -0
- eofigs/events.py +1090 -0
- eofigs/export.py +193 -0
- eofigs/figures.py +313 -0
- eofigs/maps.py +1510 -0
- eofigs/palettes.py +90 -0
- eofigs/py.typed +0 -0
- eofigs/raster.py +383 -0
- eofigs/stats.py +251 -0
- eofigs/styling.py +521 -0
- eofigs/trends.py +873 -0
- eofigs/variables.py +329 -0
- eofigs-0.1.0.dist-info/METADATA +202 -0
- eofigs-0.1.0.dist-info/RECORD +24 -0
- eofigs-0.1.0.dist-info/WHEEL +4 -0
- eofigs-0.1.0.dist-info/licenses/LICENSE +21 -0
eofigs/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Analysis-aware, publication-ready figures for Earth observation research.
|
|
2
|
+
|
|
3
|
+
eofigs is under development towards its first release (v0.1). Available now: journal
|
|
4
|
+
styles, figure sizes in millimetres, panel labels, export and figure checks, the variable
|
|
5
|
+
library, print-size raster handling, trend statistics, manuscript figure sets, built-in
|
|
6
|
+
palettes, colour-blind previews, study area maps, trend maps, event composites and accuracy
|
|
7
|
+
scatters.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from eofigs import palettes
|
|
11
|
+
from eofigs.accuracy import accuracy_scatter
|
|
12
|
+
from eofigs.check import CheckReport, CheckResult, check_figure, cvd_preview
|
|
13
|
+
from eofigs.events import event_composite
|
|
14
|
+
from eofigs.export import save_figure
|
|
15
|
+
from eofigs.figures import FigureSet, figure_set
|
|
16
|
+
from eofigs.maps import add_inset, add_north_arrow, add_scale_bar, study_area_map
|
|
17
|
+
from eofigs.raster import fit_raster
|
|
18
|
+
from eofigs.stats import trend
|
|
19
|
+
from eofigs.styling import label_panels, new_figure, set_style, style, tidy_axes
|
|
20
|
+
from eofigs.trends import trend_map
|
|
21
|
+
from eofigs.variables import Variable, register_variable, variable
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"CheckReport",
|
|
27
|
+
"CheckResult",
|
|
28
|
+
"FigureSet",
|
|
29
|
+
"Variable",
|
|
30
|
+
"__version__",
|
|
31
|
+
"accuracy_scatter",
|
|
32
|
+
"add_inset",
|
|
33
|
+
"add_north_arrow",
|
|
34
|
+
"add_scale_bar",
|
|
35
|
+
"check_figure",
|
|
36
|
+
"cvd_preview",
|
|
37
|
+
"event_composite",
|
|
38
|
+
"figure_set",
|
|
39
|
+
"fit_raster",
|
|
40
|
+
"label_panels",
|
|
41
|
+
"new_figure",
|
|
42
|
+
"palettes",
|
|
43
|
+
"register_variable",
|
|
44
|
+
"save_figure",
|
|
45
|
+
"set_style",
|
|
46
|
+
"study_area_map",
|
|
47
|
+
"style",
|
|
48
|
+
"tidy_axes",
|
|
49
|
+
"trend",
|
|
50
|
+
"trend_map",
|
|
51
|
+
"variable",
|
|
52
|
+
]
|
eofigs/_colour.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Colour science helpers: CIELAB, CIEDE2000, colour-vision simulation, colour-map classes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from matplotlib.colors import Colormap
|
|
7
|
+
|
|
8
|
+
# Okabe & Ito (2008) categorical colours, black last.
|
|
9
|
+
OKABE_ITO = (
|
|
10
|
+
"#E69F00",
|
|
11
|
+
"#56B4E9",
|
|
12
|
+
"#009E73",
|
|
13
|
+
"#F0E442",
|
|
14
|
+
"#0072B2",
|
|
15
|
+
"#D55E00",
|
|
16
|
+
"#CC79A7",
|
|
17
|
+
"#000000",
|
|
18
|
+
)
|
|
19
|
+
TEXT_COLOUR = "#1A1A1A"
|
|
20
|
+
SECONDARY_COLOUR = "#4D4D4D"
|
|
21
|
+
GRID_COLOUR = "#E0E0E0"
|
|
22
|
+
NO_DATA_COLOUR = "#BDBDBD"
|
|
23
|
+
|
|
24
|
+
# Machado, Oliveira & Fernandes (2009), severity 1.0, applied to linear sRGB.
|
|
25
|
+
CVD_MATRICES = {
|
|
26
|
+
"protan": np.array(
|
|
27
|
+
[
|
|
28
|
+
[0.152286, 1.052583, -0.204868],
|
|
29
|
+
[0.114503, 0.786281, 0.099216],
|
|
30
|
+
[-0.003882, -0.048116, 1.051998],
|
|
31
|
+
]
|
|
32
|
+
),
|
|
33
|
+
"deutan": np.array(
|
|
34
|
+
[
|
|
35
|
+
[0.367322, 0.860646, -0.227968],
|
|
36
|
+
[0.280085, 0.672501, 0.047413],
|
|
37
|
+
[-0.011820, 0.042940, 0.968881],
|
|
38
|
+
]
|
|
39
|
+
),
|
|
40
|
+
"tritan": np.array(
|
|
41
|
+
[
|
|
42
|
+
[1.255528, -0.076749, -0.178779],
|
|
43
|
+
[-0.078411, 0.930809, 0.147602],
|
|
44
|
+
[0.004733, 0.691367, 0.303900],
|
|
45
|
+
]
|
|
46
|
+
),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_SRGB_TO_XYZ = np.array(
|
|
50
|
+
[
|
|
51
|
+
[0.4124564, 0.3575761, 0.1804375],
|
|
52
|
+
[0.2126729, 0.7151522, 0.0721750],
|
|
53
|
+
[0.0193339, 0.1191920, 0.9503041],
|
|
54
|
+
]
|
|
55
|
+
)
|
|
56
|
+
_D65 = np.array([0.95047, 1.0, 1.08883])
|
|
57
|
+
|
|
58
|
+
# Colour-map classes. Names are compared without a "cmc." or "eofigs." prefix and without "_r".
|
|
59
|
+
CMAP_BANNED = frozenset(
|
|
60
|
+
{
|
|
61
|
+
"jet",
|
|
62
|
+
"rainbow",
|
|
63
|
+
"hsv",
|
|
64
|
+
"nipy_spectral",
|
|
65
|
+
"turbo",
|
|
66
|
+
"gist_rainbow",
|
|
67
|
+
"gist_ncar",
|
|
68
|
+
"flag",
|
|
69
|
+
"prism",
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
CMAP_SEQUENTIAL = frozenset(
|
|
73
|
+
{
|
|
74
|
+
# cmcrameri
|
|
75
|
+
"acton", "bamako", "batlow", "batlowK", "batlowW", "bilbao", "buda", "davos", "devon",
|
|
76
|
+
"glasgow", "grayC", "hawaii", "imola", "lajolla", "lapaz", "lipari", "navia", "nuuk",
|
|
77
|
+
"oslo", "tokyo", "turku",
|
|
78
|
+
# matplotlib perceptually uniform and single-hue sequential
|
|
79
|
+
"viridis", "plasma", "inferno", "magma", "cividis", "Greys", "Purples", "Blues",
|
|
80
|
+
"Greens", "Oranges", "Reds", "YlOrBr", "YlOrRd", "OrRd", "PuRd", "RdPu", "BuPu", "GnBu",
|
|
81
|
+
"PuBu", "YlGnBu", "PuBuGn", "BuGn", "YlGn", "gray", "binary", "bone",
|
|
82
|
+
# eofigs
|
|
83
|
+
"slate",
|
|
84
|
+
}
|
|
85
|
+
) # fmt: skip
|
|
86
|
+
CMAP_DIVERGING = frozenset(
|
|
87
|
+
{
|
|
88
|
+
# cmcrameri
|
|
89
|
+
"bam", "berlin", "broc", "cork", "lisbon", "managua", "roma", "tofino", "vanimo", "vik",
|
|
90
|
+
# matplotlib
|
|
91
|
+
"PiYG", "PRGn", "BrBG", "PuOr", "RdGy", "RdBu", "RdYlBu", "RdYlGn", "Spectral",
|
|
92
|
+
"coolwarm", "bwr", "seismic",
|
|
93
|
+
# eofigs
|
|
94
|
+
"terra_slate",
|
|
95
|
+
}
|
|
96
|
+
) # fmt: skip
|
|
97
|
+
CMAP_MULTI_SEQUENTIAL = frozenset({"bukavu", "fes", "oleron"})
|
|
98
|
+
CMAP_CYCLIC = frozenset({"bamO", "brocO", "corkO", "romaO", "vikO", "twilight", "twilight_shifted"})
|
|
99
|
+
CMAP_CATEGORICAL = frozenset(
|
|
100
|
+
{
|
|
101
|
+
"tab10", "tab20", "tab20b", "tab20c", "Set1", "Set2", "Set3", "Paired", "Accent",
|
|
102
|
+
"Dark2", "Pastel1", "Pastel2",
|
|
103
|
+
}
|
|
104
|
+
) # fmt: skip
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def to_linear(rgb: np.ndarray) -> np.ndarray:
|
|
108
|
+
"""Convert sRGB values in [0, 1] to linear RGB."""
|
|
109
|
+
rgb = np.asarray(rgb, dtype=float)
|
|
110
|
+
return np.where(rgb <= 0.04045, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def to_srgb(lin: np.ndarray) -> np.ndarray:
|
|
114
|
+
"""Convert linear RGB to sRGB values in [0, 1]."""
|
|
115
|
+
lin = np.clip(lin, 0.0, 1.0)
|
|
116
|
+
return np.where(lin <= 0.0031308, lin * 12.92, 1.055 * lin ** (1 / 2.4) - 0.055)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def simulate_cvd(rgb: np.ndarray, kind: str) -> np.ndarray:
|
|
120
|
+
"""Simulate protan, deutan or tritan vision (Machado et al. 2009, full severity)."""
|
|
121
|
+
return to_srgb(to_linear(rgb) @ CVD_MATRICES[kind].T)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def to_lab(rgb: np.ndarray) -> np.ndarray:
|
|
125
|
+
"""Convert sRGB values in [0, 1] (last axis = channels) to CIELAB (D65)."""
|
|
126
|
+
xyz = to_linear(rgb) @ _SRGB_TO_XYZ.T / _D65
|
|
127
|
+
f = np.where(xyz > 216 / 24389, np.cbrt(xyz), (24389 / 27 * xyz + 16) / 116)
|
|
128
|
+
lightness = 116 * f[..., 1] - 16
|
|
129
|
+
a = 500 * (f[..., 0] - f[..., 1])
|
|
130
|
+
b = 200 * (f[..., 1] - f[..., 2])
|
|
131
|
+
return np.stack([lightness, a, b], axis=-1)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def delta_e2000(lab1: np.ndarray, lab2: np.ndarray) -> float:
|
|
135
|
+
"""CIEDE2000 colour difference between two CIELAB colours."""
|
|
136
|
+
l1, a1, b1 = (float(v) for v in lab1)
|
|
137
|
+
l2, a2, b2 = (float(v) for v in lab2)
|
|
138
|
+
c1, c2 = np.hypot(a1, b1), np.hypot(a2, b2)
|
|
139
|
+
c_mean = (c1 + c2) / 2
|
|
140
|
+
g = 0.5 * (1 - np.sqrt(c_mean**7 / (c_mean**7 + 25**7)))
|
|
141
|
+
a1p, a2p = (1 + g) * a1, (1 + g) * a2
|
|
142
|
+
c1p, c2p = np.hypot(a1p, b1), np.hypot(a2p, b2)
|
|
143
|
+
h1p = np.degrees(np.arctan2(b1, a1p)) % 360
|
|
144
|
+
h2p = np.degrees(np.arctan2(b2, a2p)) % 360
|
|
145
|
+
dlp, dcp = l2 - l1, c2p - c1p
|
|
146
|
+
if c1p * c2p == 0:
|
|
147
|
+
dh = 0.0
|
|
148
|
+
else:
|
|
149
|
+
dh = h2p - h1p
|
|
150
|
+
if dh > 180:
|
|
151
|
+
dh -= 360
|
|
152
|
+
elif dh < -180:
|
|
153
|
+
dh += 360
|
|
154
|
+
dhp = 2 * np.sqrt(c1p * c2p) * np.sin(np.radians(dh / 2))
|
|
155
|
+
lp_mean, cp_mean = (l1 + l2) / 2, (c1p + c2p) / 2
|
|
156
|
+
if c1p * c2p == 0:
|
|
157
|
+
hp_mean = h1p + h2p
|
|
158
|
+
elif abs(h1p - h2p) <= 180:
|
|
159
|
+
hp_mean = (h1p + h2p) / 2
|
|
160
|
+
elif h1p + h2p < 360:
|
|
161
|
+
hp_mean = (h1p + h2p + 360) / 2
|
|
162
|
+
else:
|
|
163
|
+
hp_mean = (h1p + h2p - 360) / 2
|
|
164
|
+
t = (
|
|
165
|
+
1
|
|
166
|
+
- 0.17 * np.cos(np.radians(hp_mean - 30))
|
|
167
|
+
+ 0.24 * np.cos(np.radians(2 * hp_mean))
|
|
168
|
+
+ 0.32 * np.cos(np.radians(3 * hp_mean + 6))
|
|
169
|
+
- 0.20 * np.cos(np.radians(4 * hp_mean - 63))
|
|
170
|
+
)
|
|
171
|
+
d_theta = 30 * np.exp(-(((hp_mean - 275) / 25) ** 2))
|
|
172
|
+
r_c = 2 * np.sqrt(cp_mean**7 / (cp_mean**7 + 25**7))
|
|
173
|
+
s_l = 1 + 0.015 * (lp_mean - 50) ** 2 / np.sqrt(20 + (lp_mean - 50) ** 2)
|
|
174
|
+
s_c = 1 + 0.045 * cp_mean
|
|
175
|
+
s_h = 1 + 0.015 * cp_mean * t
|
|
176
|
+
r_t = -np.sin(np.radians(2 * d_theta)) * r_c
|
|
177
|
+
return float(
|
|
178
|
+
np.sqrt(
|
|
179
|
+
(dlp / s_l) ** 2 + (dcp / s_c) ** 2 + (dhp / s_h) ** 2 + r_t * (dcp / s_c) * (dhp / s_h)
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def base_cmap_name(cmap: Colormap) -> str:
|
|
185
|
+
"""Return a colour map's name without a ``cmc.``/``eofigs.`` prefix or ``_r`` suffix."""
|
|
186
|
+
name = cmap.name
|
|
187
|
+
for prefix in ("cmc.", "eofigs."):
|
|
188
|
+
if name.startswith(prefix):
|
|
189
|
+
name = name[len(prefix) :]
|
|
190
|
+
if name.endswith("_r"):
|
|
191
|
+
name = name[:-2]
|
|
192
|
+
return name
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def cmap_lightness(cmap: Colormap, n: int = 256) -> np.ndarray:
|
|
196
|
+
"""CIELAB lightness L* sampled evenly along a colour map."""
|
|
197
|
+
rgb = cmap(np.linspace(0, 1, n))[:, :3]
|
|
198
|
+
return to_lab(rgb)[:, 0]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def is_monotonic(values: np.ndarray, tol: float = 1.0) -> bool:
|
|
202
|
+
"""Test whether values only increase or only decrease.
|
|
203
|
+
|
|
204
|
+
Small reversals are allowed: the values may fall back by at most ``tol`` below their
|
|
205
|
+
running maximum (increasing) or rise by at most ``tol`` above their running minimum
|
|
206
|
+
(decreasing), which absorbs rounding in sampled colour maps.
|
|
207
|
+
"""
|
|
208
|
+
values = np.asarray(values, dtype=float)
|
|
209
|
+
increasing = np.max(np.maximum.accumulate(values) - values) <= tol
|
|
210
|
+
decreasing = np.max(values - np.minimum.accumulate(values)) <= tol
|
|
211
|
+
return bool(increasing or decreasing)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def classify_cmap(cmap: Colormap) -> str:
|
|
215
|
+
"""Classify a colour map by how its lightness should behave.
|
|
216
|
+
|
|
217
|
+
Returns ``"banned"``, ``"sequential"``, ``"diverging"``, ``"multi-sequential"``,
|
|
218
|
+
``"cyclic"``, ``"categorical"`` or ``"unknown"``. Known cmcrameri and matplotlib
|
|
219
|
+
names are classified from fixed lists. Other maps are
|
|
220
|
+
classified from their lightness profile; maps that fit no class are returned as
|
|
221
|
+
``"unknown"``.
|
|
222
|
+
"""
|
|
223
|
+
name = base_cmap_name(cmap)
|
|
224
|
+
if name in CMAP_BANNED:
|
|
225
|
+
return "banned"
|
|
226
|
+
for kind, names in (
|
|
227
|
+
("sequential", CMAP_SEQUENTIAL),
|
|
228
|
+
("diverging", CMAP_DIVERGING),
|
|
229
|
+
("multi-sequential", CMAP_MULTI_SEQUENTIAL),
|
|
230
|
+
("cyclic", CMAP_CYCLIC),
|
|
231
|
+
("categorical", CMAP_CATEGORICAL),
|
|
232
|
+
):
|
|
233
|
+
if name in names:
|
|
234
|
+
return kind
|
|
235
|
+
if name.endswith("S") and name[:-1] in CMAP_SEQUENTIAL | {"batlowK", "batlowW"}:
|
|
236
|
+
return "categorical"
|
|
237
|
+
lightness = cmap_lightness(cmap)
|
|
238
|
+
if is_monotonic(lightness):
|
|
239
|
+
return "sequential"
|
|
240
|
+
if diverging_ok(lightness):
|
|
241
|
+
return "diverging"
|
|
242
|
+
return "unknown"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def diverging_ok(lightness: np.ndarray, tol: float = 1.0) -> bool:
|
|
246
|
+
"""Test whether lightness is monotonic on each side of its extreme value near the centre."""
|
|
247
|
+
n = lightness.size
|
|
248
|
+
mid = slice(n // 4, 3 * n // 4)
|
|
249
|
+
centre = lightness[mid]
|
|
250
|
+
ends = (lightness[0] + lightness[-1]) / 2
|
|
251
|
+
k = n // 4 + int(np.argmax(centre) if centre.mean() >= ends else np.argmin(centre))
|
|
252
|
+
return is_monotonic(lightness[: k + 1], tol) and is_monotonic(lightness[k:], tol)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def multi_sequential_ok(lightness: np.ndarray, tol: float = 1.0) -> bool:
|
|
256
|
+
"""Test whether each half of a two-part colour map has monotonic lightness."""
|
|
257
|
+
half = lightness.size // 2
|
|
258
|
+
return is_monotonic(lightness[:half], tol) and is_monotonic(lightness[half:], tol)
|
eofigs/_countries.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Country outlines for locator insets: bundled default and optional Natural Earth worldviews."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from importlib import resources
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
NE_VERSION = "5.1.2"
|
|
15
|
+
|
|
16
|
+
#: Natural Earth worldviews (point-of-view country layers), by lower-case code. ``iso`` and
|
|
17
|
+
#: ``tlc`` are Natural Earth's ISO-based and "Top Level Country" variants.
|
|
18
|
+
WORLDVIEWS = (
|
|
19
|
+
"arg", "bdg", "bra", "chn", "deu", "egy", "esp", "fra", "gbr", "grc", "idn", "ind",
|
|
20
|
+
"iso", "isr", "ita", "jpn", "kor", "mar", "nep", "nld", "pak", "pol", "prt", "pse",
|
|
21
|
+
"rus", "sau", "swe", "tlc", "tur", "twn", "ukr", "usa", "vnm",
|
|
22
|
+
) # fmt: skip
|
|
23
|
+
|
|
24
|
+
#: sha256 of each worldview file at NE_VERSION (docs/scripts/worldview_checksums.py).
|
|
25
|
+
WORLDVIEW_SHA256: dict[str, str] = {
|
|
26
|
+
"arg": "357fbb518e4288d32734087c91ac9f27d630068f7f60552d225ff64a6c04ed12",
|
|
27
|
+
"bdg": "38dc28eea054c58cd828ca846cd08bd31007451b5c3591df0d73483d24380ed7",
|
|
28
|
+
"bra": "131c5102ecf8f87f8462bb2bb50d114e3d80c3076ad61bc29122ace3fb97186d",
|
|
29
|
+
"chn": "16e7589083527d01208b9f645fc8643c767170258e9d13b59d37bc5a1f6a8758",
|
|
30
|
+
"deu": "125fc4b1f425926b8bd22dce23af6f9f873543c2fe330915ccbe0aa7fa21ff82",
|
|
31
|
+
"egy": "11a59f8d01367bfe49e1e65a04349dc670a4341ba9465683b10e70f34576f5ef",
|
|
32
|
+
"esp": "cc953fbb5ee827ca39d49940491fe48b3979a7ca4b10649c36d66948ad3ea7a0",
|
|
33
|
+
"fra": "e5537c00e42ef027db1be55f3cadaf97441a8edea7435c3731c742175267d48c",
|
|
34
|
+
"gbr": "23e586f7225a4a83b20178b78b8b8209b14bc6c385b09add44e3a2e50ac6dac3",
|
|
35
|
+
"grc": "dfb2253a2ee995a4f2a4ea9ae90c6b915080d85144be7a37971be33ca7096592",
|
|
36
|
+
"idn": "358d61ae12a92f9d42bb2193877030b4bff1193ff967ce0f0d9edb51b60acfea",
|
|
37
|
+
"ind": "36a54ee97a509325e98eb44fd91bc7e53b0c5139cf10039918042bbd64102192",
|
|
38
|
+
"iso": "9a097f5c5fe0cd2e0f1f120a507f9f8e421c6ff7e20a2e4c8621311081b55453",
|
|
39
|
+
"isr": "36c2af239f9a04d2d104e893ce82ca52fea7dafbceac91a09be5b9c0569ecf68",
|
|
40
|
+
"ita": "76f28332474ffdd7717fc67dcd8f0b2086eb8405c71fa47bf857ff42642a9615",
|
|
41
|
+
"jpn": "d9dde4daa1b51fef2ee76b7e7ab86eb0f5deffc6bacb64dd9cacdd6cec6e880c",
|
|
42
|
+
"kor": "4d7d99017cc81e5825295e781c21361c3f3b86b702d515302770924cf0c4cbf8",
|
|
43
|
+
"mar": "9956aa888e35c4ca8d8d10a918469b3695316cfc6528d41d48bbfbeff19f3d4b",
|
|
44
|
+
"nep": "4755295da115a74668886f37db1b8a6b631a28e5c30a5cf7e47d799565a6d365",
|
|
45
|
+
"nld": "19f257724022f2c694e80f52c7e3263da75ec0f0f0c7a39ae22bd5672a492375",
|
|
46
|
+
"pak": "cb6730734f7ffd64a087ec8cd801291ddf4507611c9902bd800362f2813a653b",
|
|
47
|
+
"pol": "eef38fb79a91f9d26e1645f761dfafe9550ec217c73ac443f824581d42f5f197",
|
|
48
|
+
"prt": "b1ca5db9522ba7d249b62fec5bab48afed74b42fbbcc79743a9722b7d8dcc35b",
|
|
49
|
+
"pse": "8a69cbfe9402ac5bbd6e24e81cce6950137e0d7524023df9f5cfbd467bfdc976",
|
|
50
|
+
"rus": "8309218a2d9b0b2a7f218a03fa05825a7757390ccec4d793e6e8e6a6015dd04d",
|
|
51
|
+
"sau": "36191159ec58c7beaca1e2508cef84155b9dccba143d621ee336b60b81e786c0",
|
|
52
|
+
"swe": "6ff518e3d0079108fc12996a72352d30c86b251431ec506b6573a1eebd9d6bc6",
|
|
53
|
+
"tlc": "07b9ea4ffe9daa6b5280dc3c3343d176db5c05c77e4975918501356ca2080ce4",
|
|
54
|
+
"tur": "2019fd24f5ac8dc68b90a2f26e236e5e1f5bd97b75e7e88520c3f20ac22e7fde",
|
|
55
|
+
"twn": "5a3a14498c8fed258452de9e207d5e226b6fd279a43ff191897674708c219e9b",
|
|
56
|
+
"ukr": "6fcdaed26c8d551018fbd3b97185df863a4f15f489e602fceae1dee90d6a5ae8",
|
|
57
|
+
"usa": "4dd67d07246421372a3ea069d0b58b8ae28fe42c6a787a18411025b68f614d03",
|
|
58
|
+
"vnm": "09e8e8d4fa210d8d8b5d8d0c988bf3e2ff37beda138ac97af2b044b7859ef7a2",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#: Simplification tolerance in degrees for downloaded worldviews (inset scale).
|
|
62
|
+
SIMPLIFY_DEG = 0.02
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def worldview_url(code: str) -> str:
|
|
66
|
+
"""URL of one worldview file (1:10m admin-0 countries) at the pinned version."""
|
|
67
|
+
return (
|
|
68
|
+
f"https://naciscdn.org/naturalearth/{NE_VERSION}/10m/cultural/"
|
|
69
|
+
f"ne_10m_admin_0_countries_{code}.zip"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def cache_dir() -> Path:
|
|
74
|
+
"""Folder for downloaded data (``EOFIGS_CACHE`` overrides the platform default)."""
|
|
75
|
+
if os.environ.get("EOFIGS_CACHE"):
|
|
76
|
+
return Path(os.environ["EOFIGS_CACHE"])
|
|
77
|
+
if sys.platform == "win32":
|
|
78
|
+
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
|
|
79
|
+
return base / "eofigs" / "Cache"
|
|
80
|
+
if sys.platform == "darwin":
|
|
81
|
+
return Path.home() / "Library" / "Caches" / "eofigs"
|
|
82
|
+
return Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "eofigs"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _normalise(worldview: str) -> str:
|
|
86
|
+
code = str(worldview).strip().lower()
|
|
87
|
+
if code not in WORLDVIEWS:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f"Unknown worldview {worldview!r}. Natural Earth provides: "
|
|
90
|
+
f"{', '.join(c.upper() for c in WORLDVIEWS)}; or pass worldview=None for the "
|
|
91
|
+
"default (de facto) boundaries."
|
|
92
|
+
)
|
|
93
|
+
return code
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def countries(worldview: str | None = None):
|
|
97
|
+
"""Country outlines (EPSG:4326) with ``code`` and ``name`` columns.
|
|
98
|
+
|
|
99
|
+
``None`` returns the bundled Natural Earth 1:110m layer (de facto boundaries). A
|
|
100
|
+
worldview code such as ``"PAK"`` or ``"IND"`` returns that country's point of view,
|
|
101
|
+
downloaded once from Natural Earth (version pinned, checksum verified), simplified
|
|
102
|
+
to inset scale and cached.
|
|
103
|
+
"""
|
|
104
|
+
import geopandas as gpd
|
|
105
|
+
|
|
106
|
+
if worldview is None:
|
|
107
|
+
path = resources.files("eofigs").joinpath("data/ne_110m_countries.geojson")
|
|
108
|
+
with resources.as_file(path) as local:
|
|
109
|
+
return gpd.read_file(local)
|
|
110
|
+
code = _normalise(worldview)
|
|
111
|
+
cached = cache_dir() / f"ne_{NE_VERSION}_countries_{code}.geojson"
|
|
112
|
+
if not cached.exists():
|
|
113
|
+
_build_worldview(code, cached)
|
|
114
|
+
return gpd.read_file(cached)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _download(url: str, target: Path) -> None:
|
|
118
|
+
try:
|
|
119
|
+
with urllib.request.urlopen(url, timeout=60) as response, target.open("wb") as fh: # noqa: S310
|
|
120
|
+
while chunk := response.read(1 << 20):
|
|
121
|
+
fh.write(chunk)
|
|
122
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
123
|
+
raise RuntimeError(
|
|
124
|
+
f"Could not download the Natural Earth worldview file {url} ({exc}). eofigs "
|
|
125
|
+
"needs the internet once for each worldview; after that it is cached in "
|
|
126
|
+
f"{cache_dir()}. Offline, use worldview=None (bundled default boundaries) or "
|
|
127
|
+
"pass your own boundaries as a GeoDataFrame (countries=...)."
|
|
128
|
+
) from exc
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _build_worldview(code: str, cached: Path) -> None:
|
|
132
|
+
import geopandas as gpd
|
|
133
|
+
|
|
134
|
+
expected = WORLDVIEW_SHA256.get(code)
|
|
135
|
+
if expected is None:
|
|
136
|
+
raise RuntimeError(f"No checksum is recorded for worldview {code.upper()!r}.")
|
|
137
|
+
cached.parent.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
139
|
+
archive = Path(tmp) / f"{code}.zip"
|
|
140
|
+
_download(worldview_url(code), archive)
|
|
141
|
+
digest = hashlib.sha256(archive.read_bytes()).hexdigest()
|
|
142
|
+
if digest != expected:
|
|
143
|
+
raise RuntimeError(
|
|
144
|
+
f"The downloaded worldview file for {code.upper()} does not match its "
|
|
145
|
+
f"checksum (got {digest[:12]}..., expected {expected[:12]}...); it was not "
|
|
146
|
+
"used. Try again later."
|
|
147
|
+
)
|
|
148
|
+
data = gpd.read_file(f"zip://{archive.as_posix()}")
|
|
149
|
+
data = data.to_crs("EPSG:4326")
|
|
150
|
+
out = gpd.GeoDataFrame(
|
|
151
|
+
{"code": data["ADM0_A3"], "name": data["NAME_EN"]},
|
|
152
|
+
geometry=data.geometry.simplify(SIMPLIFY_DEG, preserve_topology=True),
|
|
153
|
+
crs="EPSG:4326",
|
|
154
|
+
)
|
|
155
|
+
partial = cached.with_suffix(".part")
|
|
156
|
+
out.to_file(partial, driver="GeoJSON")
|
|
157
|
+
partial.replace(cached)
|
eofigs/_presets.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Journal presets loaded from the bundled ``data/journals.json``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
from importlib import resources
|
|
9
|
+
|
|
10
|
+
MM_PER_INCH = 25.4
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Journal:
|
|
15
|
+
"""Figure requirements of one publisher, read from ``journals.json``."""
|
|
16
|
+
|
|
17
|
+
key: str
|
|
18
|
+
name: str
|
|
19
|
+
widths_mm: dict[str, float]
|
|
20
|
+
max_height_mm: float
|
|
21
|
+
min_font_pt: float
|
|
22
|
+
base_font_pt: float
|
|
23
|
+
font_family: str
|
|
24
|
+
fonts: tuple[str, ...]
|
|
25
|
+
dpi: dict[str, int]
|
|
26
|
+
export_dpi: int
|
|
27
|
+
formats: tuple[str, ...]
|
|
28
|
+
source_url: str
|
|
29
|
+
checked: str
|
|
30
|
+
eofigs_defaults: tuple[str, ...]
|
|
31
|
+
notes: str
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def max_width_mm(self) -> float:
|
|
35
|
+
"""Largest named width (full page or text width)."""
|
|
36
|
+
return max(self.widths_mm.values())
|
|
37
|
+
|
|
38
|
+
def required_dpi(self, art: str = "combination") -> int:
|
|
39
|
+
"""Resolution the publisher asks for one artwork type.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
art : {"combination", "line", "halftone"}
|
|
44
|
+
``"line"`` for line art (plots and drawings), ``"halftone"`` for photographs
|
|
45
|
+
and continuous-tone images, ``"combination"`` for figures that mix both.
|
|
46
|
+
"""
|
|
47
|
+
return int(self.dpi[check_art(art)])
|
|
48
|
+
|
|
49
|
+
def export_dpi_for(self, art: str = "combination") -> int:
|
|
50
|
+
"""Default raster resolution: the export resolution or the requirement, if higher."""
|
|
51
|
+
return max(self.export_dpi, self.required_dpi(art))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@lru_cache(maxsize=1)
|
|
55
|
+
def _load() -> dict:
|
|
56
|
+
text = resources.files("eofigs").joinpath("data/journals.json").read_text(encoding="utf-8")
|
|
57
|
+
return json.loads(text)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
ART_TYPES = ("combination", "line", "halftone")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def check_art(art: str) -> str:
|
|
64
|
+
"""Validate an artwork type name.
|
|
65
|
+
|
|
66
|
+
Raises
|
|
67
|
+
------
|
|
68
|
+
ValueError
|
|
69
|
+
If ``art`` is not ``"combination"``, ``"line"`` or ``"halftone"``.
|
|
70
|
+
"""
|
|
71
|
+
if art not in ART_TYPES:
|
|
72
|
+
raise ValueError(f"art must be one of {ART_TYPES} (got {art!r}).")
|
|
73
|
+
return art
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def journal_keys() -> list[str]:
|
|
77
|
+
"""Names of all bundled journal presets."""
|
|
78
|
+
return sorted(_load()["journals"])
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_journal(name: str | Journal) -> Journal:
|
|
82
|
+
"""Return the preset for ``name`` (case-insensitive; aliases such as ``"tandf"`` accepted).
|
|
83
|
+
|
|
84
|
+
Raises
|
|
85
|
+
------
|
|
86
|
+
ValueError
|
|
87
|
+
If no preset has that name. The message lists the available presets.
|
|
88
|
+
"""
|
|
89
|
+
if isinstance(name, Journal):
|
|
90
|
+
return name
|
|
91
|
+
data = _load()
|
|
92
|
+
key = str(name).strip().lower()
|
|
93
|
+
key = data["aliases"].get(key, key)
|
|
94
|
+
if key not in data["journals"]:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"Unknown journal {name!r}. Available presets: {', '.join(journal_keys())}. "
|
|
97
|
+
"Use 'custom' for a generic preset."
|
|
98
|
+
)
|
|
99
|
+
entry = data["journals"][key]
|
|
100
|
+
return Journal(
|
|
101
|
+
key=key,
|
|
102
|
+
name=entry["name"],
|
|
103
|
+
widths_mm={k: float(v) for k, v in entry["widths_mm"].items()},
|
|
104
|
+
max_height_mm=float(entry["max_height_mm"]),
|
|
105
|
+
min_font_pt=float(entry["min_font_pt"]),
|
|
106
|
+
base_font_pt=float(entry["base_font_pt"]),
|
|
107
|
+
font_family=entry["font_family"],
|
|
108
|
+
fonts=tuple(entry["fonts"]),
|
|
109
|
+
dpi={k: int(v) for k, v in entry["dpi"].items()},
|
|
110
|
+
export_dpi=int(entry["export_dpi"]),
|
|
111
|
+
formats=tuple(entry["formats"]),
|
|
112
|
+
source_url=entry["source_url"],
|
|
113
|
+
checked=entry["checked"],
|
|
114
|
+
eofigs_defaults=tuple(entry["eofigs_defaults"]),
|
|
115
|
+
notes=entry["notes"],
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def resolve_width(width: str | float, journal: Journal) -> float:
|
|
120
|
+
"""Convert a named width (``"single"``, ``"double"``, ...) or a number in mm to mm.
|
|
121
|
+
|
|
122
|
+
Raises
|
|
123
|
+
------
|
|
124
|
+
ValueError
|
|
125
|
+
For an unknown width name, a non-positive width, or a width larger than the
|
|
126
|
+
journal's full text width.
|
|
127
|
+
"""
|
|
128
|
+
if isinstance(width, str):
|
|
129
|
+
if width not in journal.widths_mm:
|
|
130
|
+
raise ValueError(
|
|
131
|
+
f"Unknown width {width!r} for {journal.name}. "
|
|
132
|
+
f"Use one of {sorted(journal.widths_mm)} or a number in mm."
|
|
133
|
+
)
|
|
134
|
+
return journal.widths_mm[width]
|
|
135
|
+
width_mm = float(width)
|
|
136
|
+
if width_mm <= 0:
|
|
137
|
+
raise ValueError(f"width must be positive (got {width_mm} mm).")
|
|
138
|
+
if width_mm > journal.max_width_mm + 0.5:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"width {width_mm:g} mm is wider than {journal.name}'s full width "
|
|
141
|
+
f"({journal.max_width_mm:g} mm). Use width='{_widest(journal)}' or a smaller number."
|
|
142
|
+
)
|
|
143
|
+
return width_mm
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _widest(journal: Journal) -> str:
|
|
147
|
+
return max(journal.widths_mm, key=journal.widths_mm.get)
|