citegraph 0.2.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.
- citegraph/__init__.py +44 -0
- citegraph/_progress.py +74 -0
- citegraph/annotations.py +183 -0
- citegraph/author_export.py +294 -0
- citegraph/author_overrides.py +143 -0
- citegraph/authors.py +1997 -0
- citegraph/cli.py +926 -0
- citegraph/cocitation_stats.py +370 -0
- citegraph/config.py +73 -0
- citegraph/cost_estimation.py +259 -0
- citegraph/dedup.py +498 -0
- citegraph/enrich.py +972 -0
- citegraph/extract_metadata.py +81 -0
- citegraph/extract_references.py +376 -0
- citegraph/graph.py +660 -0
- citegraph/html_report.py +1787 -0
- citegraph/identity.py +199 -0
- citegraph/ids.py +106 -0
- citegraph/io.py +566 -0
- citegraph/jel.py +167 -0
- citegraph/journals.py +182 -0
- citegraph/llm.py +186 -0
- citegraph/pdf_to_markdown.py +394 -0
- citegraph/pipeline.py +997 -0
- citegraph/plotting.py +1214 -0
- citegraph/reports.py +214 -0
- citegraph/schemas.py +89 -0
- citegraph/webui.py +624 -0
- citegraph-0.2.0.dist-info/METADATA +870 -0
- citegraph-0.2.0.dist-info/RECORD +33 -0
- citegraph-0.2.0.dist-info/WHEEL +4 -0
- citegraph-0.2.0.dist-info/entry_points.txt +2 -0
- citegraph-0.2.0.dist-info/licenses/LICENSE +21 -0
citegraph/plotting.py
ADDED
|
@@ -0,0 +1,1214 @@
|
|
|
1
|
+
"""Publication-quality matplotlib styling for economics manuscripts.
|
|
2
|
+
|
|
3
|
+
The conventions encoded here follow what top economics journals (AER, QJE,
|
|
4
|
+
JPE, Econometrica, REStud) actually print:
|
|
5
|
+
|
|
6
|
+
* **Grayscale-first.** Black and gray marks carry the data; colour, when
|
|
7
|
+
used, is the colour-blind-safe Okabe–Ito set and is always doubled by a
|
|
8
|
+
line style so the figure survives black-and-white printing.
|
|
9
|
+
* **No title inside the figure.** The title lives in the caption
|
|
10
|
+
("Figure 1. …"), followed by *Notes:* then *Source:*. Panels are labelled
|
|
11
|
+
"Panel A. …" above each axes.
|
|
12
|
+
* **Serif type matching the manuscript**, 8–10 pt at final size, one
|
|
13
|
+
weight. Figures are designed at the final printed width so nothing is
|
|
14
|
+
rescaled.
|
|
15
|
+
* **Minimal chrome.** Left and bottom spines only, thin black axes,
|
|
16
|
+
outward ticks, no gridlines, bars start at zero, integer/percent tick
|
|
17
|
+
formats.
|
|
18
|
+
* **Vector output** (PDF with TrueType fonts embedded) plus a 300 dpi PNG.
|
|
19
|
+
|
|
20
|
+
``matplotlib`` is an optional dependency (``pip install "citegraph[plots]"``);
|
|
21
|
+
it is imported lazily inside each function so ``import citegraph`` stays
|
|
22
|
+
cheap.
|
|
23
|
+
|
|
24
|
+
Typical use inside a notebook::
|
|
25
|
+
|
|
26
|
+
from citegraph.plotting import bar, econ_style, figure_size, panel_title, save_figure
|
|
27
|
+
|
|
28
|
+
with econ_style():
|
|
29
|
+
fig, (ax_a, ax_b) = plt.subplots(1, 2, figsize=figure_size("text", ratio=0.45))
|
|
30
|
+
bar(ax_a, years, counts)
|
|
31
|
+
panel_title(ax_a, "A", "Annual counts")
|
|
32
|
+
...
|
|
33
|
+
save_figure(fig, out_dir / "figures" / "trend",
|
|
34
|
+
caption="Publication trends", notes="…", source="…")
|
|
35
|
+
|
|
36
|
+
Draw bars with :func:`bar` / :func:`barh` rather than ``ax.bar`` / ``ax.barh``:
|
|
37
|
+
matplotlib takes an unspecified bar colour from the property cycle, whose first
|
|
38
|
+
entry is ink, so a bare ``ax.bar`` comes out solid black under this style
|
|
39
|
+
however ``patch.facecolor`` is set. Network figures have their own vocabulary —
|
|
40
|
+
:func:`network_axes`, :func:`draw_node`, :func:`hairline` — so a node-link
|
|
41
|
+
diagram and a bar chart in the same manuscript read as one family.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import contextlib
|
|
47
|
+
import math
|
|
48
|
+
from collections.abc import Iterable, Iterator, Mapping, Sequence
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
from typing import TYPE_CHECKING, Any
|
|
51
|
+
|
|
52
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
53
|
+
from matplotlib.axes import Axes
|
|
54
|
+
from matplotlib.container import BarContainer
|
|
55
|
+
from matplotlib.figure import Figure
|
|
56
|
+
from matplotlib.lines import Line2D
|
|
57
|
+
from matplotlib.text import Annotation
|
|
58
|
+
from matplotlib.transforms import Bbox
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
"INK",
|
|
62
|
+
"GRAYS",
|
|
63
|
+
"OKABE_ITO",
|
|
64
|
+
"LINESTYLES",
|
|
65
|
+
"MARKERS",
|
|
66
|
+
"HATCHES",
|
|
67
|
+
"WIDTHS",
|
|
68
|
+
"BAR_FILL",
|
|
69
|
+
"BAR_WIDTH",
|
|
70
|
+
"NODE_FILL",
|
|
71
|
+
"NODE_OPEN",
|
|
72
|
+
"BOX_FILL",
|
|
73
|
+
"NODE_EDGE",
|
|
74
|
+
"NODE_RADIUS",
|
|
75
|
+
"LABEL_OFFSET",
|
|
76
|
+
"JUNCTION_RADIUS",
|
|
77
|
+
"EDGE_COLOR",
|
|
78
|
+
"RULE_COLOR",
|
|
79
|
+
"econ_rc",
|
|
80
|
+
"econ_style",
|
|
81
|
+
"use_econ_style",
|
|
82
|
+
"figure_size",
|
|
83
|
+
"bar",
|
|
84
|
+
"barh",
|
|
85
|
+
"year_ticks",
|
|
86
|
+
"panel_title",
|
|
87
|
+
"integer_ticks",
|
|
88
|
+
"percent_ticks",
|
|
89
|
+
"reference_line",
|
|
90
|
+
"value_labels",
|
|
91
|
+
"caption_markdown",
|
|
92
|
+
"save_figure",
|
|
93
|
+
"show_caption",
|
|
94
|
+
"grayscale_preview",
|
|
95
|
+
"draw_node",
|
|
96
|
+
"box_node",
|
|
97
|
+
"flow_band",
|
|
98
|
+
"node_legend",
|
|
99
|
+
"hairline",
|
|
100
|
+
"arrow_props",
|
|
101
|
+
"junction_dot",
|
|
102
|
+
"network_axes",
|
|
103
|
+
"separator_rule",
|
|
104
|
+
"place_node_labels",
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
# ----------------------------------------------------------------------
|
|
108
|
+
# Palette constants
|
|
109
|
+
# ----------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
#: Text, axes, and the first series.
|
|
112
|
+
INK = "#000000"
|
|
113
|
+
|
|
114
|
+
#: Ordinal gray ramp, dark → light, for bars/stacked segments. Fills always
|
|
115
|
+
#: carry a black edge so the lightest step still reads on white.
|
|
116
|
+
GRAYS: tuple[str, ...] = ("#252525", "#636363", "#969696", "#BDBDBD", "#E6E6E6")
|
|
117
|
+
|
|
118
|
+
#: Okabe–Ito colour-blind-safe hues in fixed order (blue, vermillion,
|
|
119
|
+
#: bluish green, reddish purple, orange, sky blue). Validated: adjacent-pair
|
|
120
|
+
#: CVD ΔE ≥ 7.6, normal-vision ΔE ≥ 21. The last three are light against white
|
|
121
|
+
#: and must always be paired with a line style, marker, or direct label.
|
|
122
|
+
OKABE_ITO: tuple[str, ...] = ("#0072B2", "#D55E00", "#009E73", "#CC79A7", "#E69F00", "#56B4E9")
|
|
123
|
+
|
|
124
|
+
#: Dash patterns in the order they are assigned to series.
|
|
125
|
+
LINESTYLES: tuple[str, ...] = ("-", "--", ":", "-.")
|
|
126
|
+
|
|
127
|
+
#: Marker shapes in the order they are assigned to series.
|
|
128
|
+
MARKERS: tuple[str, ...] = ("o", "s", "^", "D")
|
|
129
|
+
|
|
130
|
+
#: Hatch patterns for distinguishing bar series without colour. The empty
|
|
131
|
+
#: string is the first (solid) series.
|
|
132
|
+
HATCHES: tuple[str, ...] = ("", "////", "....", "xxxx", "\\\\\\\\")
|
|
133
|
+
|
|
134
|
+
#: Default bar fill. ``ax.bar`` takes its colour from the property cycle, whose
|
|
135
|
+
#: first entry is :data:`INK`, so an un-styled bar comes out solid black however
|
|
136
|
+
#: ``patch.facecolor`` is set — no rcParam can express "black lines, gray bars".
|
|
137
|
+
#: :func:`bar` and :func:`barh` apply this fill instead; call them rather than
|
|
138
|
+
#: ``ax.bar``/``ax.barh`` so every bar in the corpus reads the same.
|
|
139
|
+
BAR_FILL = GRAYS[1]
|
|
140
|
+
|
|
141
|
+
#: Default bar thickness (fraction of the category step).
|
|
142
|
+
BAR_WIDTH = 0.72
|
|
143
|
+
|
|
144
|
+
#: Network node fill for a node inside the population being described.
|
|
145
|
+
NODE_FILL = GRAYS[1]
|
|
146
|
+
|
|
147
|
+
#: Network node fill for a node outside it (an advisor who is not a listed
|
|
148
|
+
#: author, a co-cited author below the inclusion threshold).
|
|
149
|
+
NODE_OPEN = "white"
|
|
150
|
+
|
|
151
|
+
#: Fill for a :func:`box_node` inside the population described. A box is orders
|
|
152
|
+
#: of magnitude more area than a marker, and area at :data:`NODE_FILL` leaves no
|
|
153
|
+
#: room for the text printed on it — at that weight the subtitle gray and the
|
|
154
|
+
#: fill are the same value and every second line inside a filled box vanishes.
|
|
155
|
+
#: An area mark takes the light end of the ramp and keeps its text black; the
|
|
156
|
+
#: black edge every node carries is what makes the tint read as a fill at all.
|
|
157
|
+
BOX_FILL = GRAYS[4]
|
|
158
|
+
|
|
159
|
+
#: Every network node carries this edge so the open fill still reads on white.
|
|
160
|
+
NODE_EDGE = INK
|
|
161
|
+
|
|
162
|
+
#: Network edges and connectors: a hairline dark enough to follow across the
|
|
163
|
+
#: page but light enough not to compete with the node markers.
|
|
164
|
+
EDGE_COLOR = "#555555"
|
|
165
|
+
|
|
166
|
+
#: Hairline separating bands of a grouped figure (lineage blocks, tie rows).
|
|
167
|
+
RULE_COLOR = "#DBDBDB"
|
|
168
|
+
|
|
169
|
+
#: Network node radius in points. Markers are sized ``2 * NODE_RADIUS``.
|
|
170
|
+
NODE_RADIUS = 2.5
|
|
171
|
+
|
|
172
|
+
#: Gap in points between a node centre and its label.
|
|
173
|
+
LABEL_OFFSET = 6.0
|
|
174
|
+
|
|
175
|
+
#: Radius in points of the mark at a connector junction. Deliberately far below
|
|
176
|
+
#: :data:`NODE_RADIUS`: a junction is punctuation on a connector, and one drawn
|
|
177
|
+
#: anywhere near node size reads as an unlabelled person in the network.
|
|
178
|
+
JUNCTION_RADIUS = 1.15
|
|
179
|
+
|
|
180
|
+
#: Final printed widths in inches. ``text`` is a US-letter manuscript with
|
|
181
|
+
#: 1-inch margins; ``journal`` approximates an AER/QJE text block; ``column``
|
|
182
|
+
#: is one column of a two-column layout or a half-width panel.
|
|
183
|
+
WIDTHS: dict[str, float] = {"column": 3.25, "journal": 4.75, "text": 6.5}
|
|
184
|
+
|
|
185
|
+
_SERIF_STACK = (
|
|
186
|
+
"Times New Roman",
|
|
187
|
+
"Times",
|
|
188
|
+
"STIX Two Text",
|
|
189
|
+
"STIXGeneral",
|
|
190
|
+
"Nimbus Roman",
|
|
191
|
+
"DejaVu Serif",
|
|
192
|
+
)
|
|
193
|
+
_SANS_STACK = ("Helvetica", "Arial", "Liberation Sans", "DejaVu Sans")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ----------------------------------------------------------------------
|
|
197
|
+
# Lazy import helpers
|
|
198
|
+
# ----------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _mpl():
|
|
202
|
+
try:
|
|
203
|
+
import matplotlib
|
|
204
|
+
except ImportError as exc: # pragma: no cover - import guard
|
|
205
|
+
raise ImportError(
|
|
206
|
+
"citegraph.plotting requires matplotlib. Install with: "
|
|
207
|
+
'pip install "citegraph[plots]"'
|
|
208
|
+
) from exc
|
|
209
|
+
return matplotlib
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _pyplot():
|
|
213
|
+
_mpl()
|
|
214
|
+
import matplotlib.pyplot as plt
|
|
215
|
+
|
|
216
|
+
return plt
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ----------------------------------------------------------------------
|
|
220
|
+
# rcParams
|
|
221
|
+
# ----------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _prop_cycle(palette: str):
|
|
225
|
+
from cycler import cycler
|
|
226
|
+
|
|
227
|
+
if palette == "mono":
|
|
228
|
+
colors = (INK, "#636363", "#A6A6A6", "#404040")
|
|
229
|
+
styles = LINESTYLES
|
|
230
|
+
elif palette == "color":
|
|
231
|
+
colors = (INK, *OKABE_ITO)
|
|
232
|
+
# Repeat the dash cycle so every colour still differs in line style.
|
|
233
|
+
styles = tuple(LINESTYLES[i % len(LINESTYLES)] for i in range(len(colors)))
|
|
234
|
+
else:
|
|
235
|
+
raise ValueError(f"Unknown palette {palette!r}; expected 'mono' or 'color'.")
|
|
236
|
+
return cycler(color=list(colors)) + cycler(linestyle=list(styles))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def econ_rc(
|
|
240
|
+
*,
|
|
241
|
+
palette: str = "mono",
|
|
242
|
+
font: str = "serif",
|
|
243
|
+
base_size: float = 9.0,
|
|
244
|
+
) -> dict[str, Any]:
|
|
245
|
+
"""Return the rcParams dict for the economics-journal style.
|
|
246
|
+
|
|
247
|
+
Parameters
|
|
248
|
+
----------
|
|
249
|
+
palette:
|
|
250
|
+
``"mono"`` (default) cycles black and grays with distinct dashes;
|
|
251
|
+
``"color"`` cycles black then the Okabe–Ito hues, still with dashes.
|
|
252
|
+
font:
|
|
253
|
+
``"serif"`` (Times/STIX, matching a LaTeX or Word manuscript) or
|
|
254
|
+
``"sans"`` (Helvetica/Arial, the Stata default many journals print).
|
|
255
|
+
base_size:
|
|
256
|
+
Body font size in points at the final printed width. Tick labels,
|
|
257
|
+
legend entries and value labels are one step smaller.
|
|
258
|
+
"""
|
|
259
|
+
_mpl()
|
|
260
|
+
if font == "serif":
|
|
261
|
+
family, stack, mathtext = "serif", _SERIF_STACK, "stix"
|
|
262
|
+
elif font == "sans":
|
|
263
|
+
family, stack, mathtext = "sans-serif", _SANS_STACK, "stixsans"
|
|
264
|
+
else:
|
|
265
|
+
raise ValueError(f"Unknown font {font!r}; expected 'serif' or 'sans'.")
|
|
266
|
+
|
|
267
|
+
small = round(base_size - 1.0, 2)
|
|
268
|
+
return {
|
|
269
|
+
# --- type ---
|
|
270
|
+
"font.family": family,
|
|
271
|
+
f"font.{family}": list(stack),
|
|
272
|
+
"mathtext.fontset": mathtext,
|
|
273
|
+
"font.size": base_size,
|
|
274
|
+
"font.weight": "normal",
|
|
275
|
+
"axes.titlesize": base_size,
|
|
276
|
+
"axes.titleweight": "normal",
|
|
277
|
+
"axes.titlelocation": "center",
|
|
278
|
+
"axes.titlepad": 6.0,
|
|
279
|
+
"axes.labelsize": base_size,
|
|
280
|
+
"axes.labelweight": "normal",
|
|
281
|
+
"axes.labelpad": 4.0,
|
|
282
|
+
"xtick.labelsize": small,
|
|
283
|
+
"ytick.labelsize": small,
|
|
284
|
+
"legend.fontsize": small,
|
|
285
|
+
"legend.title_fontsize": small,
|
|
286
|
+
"figure.titlesize": base_size,
|
|
287
|
+
"figure.titleweight": "normal",
|
|
288
|
+
"axes.unicode_minus": True,
|
|
289
|
+
# --- axes & ticks ---
|
|
290
|
+
"axes.edgecolor": INK,
|
|
291
|
+
"axes.labelcolor": INK,
|
|
292
|
+
"axes.linewidth": 0.6,
|
|
293
|
+
"axes.spines.top": False,
|
|
294
|
+
"axes.spines.right": False,
|
|
295
|
+
"axes.spines.left": True,
|
|
296
|
+
"axes.spines.bottom": True,
|
|
297
|
+
"axes.axisbelow": True,
|
|
298
|
+
"axes.grid": False,
|
|
299
|
+
"grid.color": "#BFBFBF",
|
|
300
|
+
"grid.linewidth": 0.5,
|
|
301
|
+
"grid.linestyle": "-",
|
|
302
|
+
"xtick.color": INK,
|
|
303
|
+
"ytick.color": INK,
|
|
304
|
+
"xtick.direction": "out",
|
|
305
|
+
"ytick.direction": "out",
|
|
306
|
+
"xtick.major.size": 3.0,
|
|
307
|
+
"ytick.major.size": 3.0,
|
|
308
|
+
"xtick.major.width": 0.6,
|
|
309
|
+
"ytick.major.width": 0.6,
|
|
310
|
+
"xtick.minor.visible": False,
|
|
311
|
+
"ytick.minor.visible": False,
|
|
312
|
+
"axes.prop_cycle": _prop_cycle(palette),
|
|
313
|
+
# --- marks ---
|
|
314
|
+
"lines.linewidth": 1.2,
|
|
315
|
+
"lines.markersize": 4.0,
|
|
316
|
+
"lines.markeredgewidth": 0.6,
|
|
317
|
+
"patch.linewidth": 0.6,
|
|
318
|
+
"patch.edgecolor": INK,
|
|
319
|
+
"patch.force_edgecolor": True,
|
|
320
|
+
"hatch.linewidth": 0.5,
|
|
321
|
+
"hatch.color": INK,
|
|
322
|
+
"errorbar.capsize": 2.0,
|
|
323
|
+
# --- legend ---
|
|
324
|
+
"legend.frameon": False,
|
|
325
|
+
"legend.handlelength": 2.2,
|
|
326
|
+
"legend.handleheight": 0.7,
|
|
327
|
+
"legend.borderaxespad": 0.3,
|
|
328
|
+
"legend.labelspacing": 0.4,
|
|
329
|
+
"legend.columnspacing": 1.2,
|
|
330
|
+
# --- figure & output ---
|
|
331
|
+
"figure.facecolor": "white",
|
|
332
|
+
"figure.edgecolor": "white",
|
|
333
|
+
"figure.dpi": 130,
|
|
334
|
+
"figure.constrained_layout.use": True,
|
|
335
|
+
"savefig.facecolor": "white",
|
|
336
|
+
"savefig.edgecolor": "white",
|
|
337
|
+
"savefig.dpi": 300,
|
|
338
|
+
"savefig.bbox": "tight",
|
|
339
|
+
"savefig.pad_inches": 0.03,
|
|
340
|
+
"pdf.fonttype": 42,
|
|
341
|
+
"ps.fonttype": 42,
|
|
342
|
+
"svg.fonttype": "none",
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@contextlib.contextmanager
|
|
347
|
+
def econ_style(
|
|
348
|
+
*,
|
|
349
|
+
palette: str = "mono",
|
|
350
|
+
font: str = "serif",
|
|
351
|
+
base_size: float = 9.0,
|
|
352
|
+
overrides: dict[str, Any] | None = None,
|
|
353
|
+
) -> Iterator[dict[str, Any]]:
|
|
354
|
+
"""Context manager applying :func:`econ_rc` for the duration of a block.
|
|
355
|
+
|
|
356
|
+
``overrides`` are merged last, so callers can switch on gridlines or
|
|
357
|
+
change a single size without redefining the whole style.
|
|
358
|
+
"""
|
|
359
|
+
plt = _pyplot()
|
|
360
|
+
rc = econ_rc(palette=palette, font=font, base_size=base_size)
|
|
361
|
+
if overrides:
|
|
362
|
+
rc.update(overrides)
|
|
363
|
+
with plt.rc_context(rc):
|
|
364
|
+
yield rc
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def use_econ_style(
|
|
368
|
+
*,
|
|
369
|
+
palette: str = "mono",
|
|
370
|
+
font: str = "serif",
|
|
371
|
+
base_size: float = 9.0,
|
|
372
|
+
overrides: dict[str, Any] | None = None,
|
|
373
|
+
) -> dict[str, Any]:
|
|
374
|
+
"""Apply :func:`econ_rc` globally (for the rest of a notebook session)."""
|
|
375
|
+
plt = _pyplot()
|
|
376
|
+
rc = econ_rc(palette=palette, font=font, base_size=base_size)
|
|
377
|
+
if overrides:
|
|
378
|
+
rc.update(overrides)
|
|
379
|
+
plt.rcParams.update(rc)
|
|
380
|
+
return rc
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# ----------------------------------------------------------------------
|
|
384
|
+
# Sizes
|
|
385
|
+
# ----------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def figure_size(
|
|
389
|
+
width: str | float = "text",
|
|
390
|
+
*,
|
|
391
|
+
ratio: float = 0.6,
|
|
392
|
+
height: float | None = None,
|
|
393
|
+
) -> tuple[float, float]:
|
|
394
|
+
"""Return ``(width, height)`` in inches at the final printed size.
|
|
395
|
+
|
|
396
|
+
``width`` is a preset name from :data:`WIDTHS` or a number of inches.
|
|
397
|
+
``height`` overrides ``ratio`` (height / width) when given.
|
|
398
|
+
"""
|
|
399
|
+
if isinstance(width, str):
|
|
400
|
+
try:
|
|
401
|
+
w = WIDTHS[width]
|
|
402
|
+
except KeyError as exc:
|
|
403
|
+
raise ValueError(
|
|
404
|
+
f"Unknown width preset {width!r}; expected one of {sorted(WIDTHS)} or inches."
|
|
405
|
+
) from exc
|
|
406
|
+
else:
|
|
407
|
+
w = float(width)
|
|
408
|
+
h = float(height) if height is not None else w * ratio
|
|
409
|
+
return (w, h)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# ----------------------------------------------------------------------
|
|
413
|
+
# Axis helpers
|
|
414
|
+
# ----------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _axis(ax: Axes, axis: str):
|
|
418
|
+
if axis == "x":
|
|
419
|
+
return ax.xaxis
|
|
420
|
+
if axis == "y":
|
|
421
|
+
return ax.yaxis
|
|
422
|
+
raise ValueError(f"axis must be 'x' or 'y', got {axis!r}")
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def bar(ax: Axes, x, height, *, width: float = BAR_WIDTH, **kwargs: Any) -> BarContainer:
|
|
426
|
+
"""Vertical bars in the house fill: :data:`BAR_FILL` with a black edge.
|
|
427
|
+
|
|
428
|
+
Use instead of ``ax.bar``. Matplotlib takes an un-specified bar colour from
|
|
429
|
+
the property cycle, whose first entry is black, so ``ax.bar`` produces solid
|
|
430
|
+
black bars under :func:`econ_style` no matter what ``patch.facecolor`` says.
|
|
431
|
+
Passing ``color=`` here still wins, for stacked or multi-series bars.
|
|
432
|
+
"""
|
|
433
|
+
kwargs.setdefault("color", BAR_FILL)
|
|
434
|
+
kwargs.setdefault("edgecolor", INK)
|
|
435
|
+
return ax.bar(x, height, width=width, **kwargs)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def barh(ax: Axes, y, width, *, height: float = BAR_WIDTH, **kwargs: Any) -> BarContainer:
|
|
439
|
+
"""Horizontal bars in the house fill. See :func:`bar`."""
|
|
440
|
+
kwargs.setdefault("color", BAR_FILL)
|
|
441
|
+
kwargs.setdefault("edgecolor", INK)
|
|
442
|
+
return ax.barh(y, width, height=height, **kwargs)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def year_ticks(ax: Axes, first_year: int, last_year: int, *, pad: float = 0.7) -> None:
|
|
446
|
+
"""Horizontal year ticks at a readable spacing, with the x-limits padded.
|
|
447
|
+
|
|
448
|
+
Every five years over a long span, every two over a short one, so a dense
|
|
449
|
+
annual series never needs rotated labels. Ticks land on round years.
|
|
450
|
+
"""
|
|
451
|
+
step = 5 if last_year - first_year > 15 else 2
|
|
452
|
+
first_tick = first_year + (-first_year) % step
|
|
453
|
+
ax.set_xticks(range(first_tick, last_year + 1, step))
|
|
454
|
+
ax.set_xlim(first_year - pad, last_year + pad)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def panel_title(ax: Axes, letter: str, title: str | None = None, **kwargs: Any):
|
|
458
|
+
"""Label a panel the AER way: ``Panel A. Title`` centred above the axes."""
|
|
459
|
+
text = f"Panel {letter}. {title}" if title else f"Panel {letter}"
|
|
460
|
+
return ax.set_title(text, **kwargs)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def integer_ticks(ax: Axes, axis: str = "y") -> None:
|
|
464
|
+
"""Force whole-number ticks (counts) on one axis."""
|
|
465
|
+
from matplotlib.ticker import MaxNLocator
|
|
466
|
+
|
|
467
|
+
_axis(ax, axis).set_major_locator(MaxNLocator(integer=True))
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def percent_ticks(ax: Axes, axis: str = "y", *, xmax: float = 100, decimals: int = 0) -> None:
|
|
471
|
+
"""Format ticks as percentages. ``xmax=100`` for data already in percent,
|
|
472
|
+
``xmax=1`` for proportions."""
|
|
473
|
+
from matplotlib.ticker import PercentFormatter
|
|
474
|
+
|
|
475
|
+
_axis(ax, axis).set_major_formatter(PercentFormatter(xmax=xmax, decimals=decimals))
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def reference_line(
|
|
479
|
+
ax: Axes,
|
|
480
|
+
value: float,
|
|
481
|
+
*,
|
|
482
|
+
label: str | None = None,
|
|
483
|
+
orientation: str = "h",
|
|
484
|
+
color: str = INK,
|
|
485
|
+
linestyle: str = "--",
|
|
486
|
+
linewidth: float = 0.8,
|
|
487
|
+
in_legend: bool = False,
|
|
488
|
+
label_kwargs: dict[str, Any] | None = None,
|
|
489
|
+
**kwargs: Any,
|
|
490
|
+
) -> Line2D:
|
|
491
|
+
"""Draw a dashed hairline at ``value`` (a mean, a target, an overall share).
|
|
492
|
+
|
|
493
|
+
By default the optional ``label`` sits just above the line at its right
|
|
494
|
+
end (or beside a vertical line at its top) in small ink text — the journal
|
|
495
|
+
way to annotate a benchmark without a legend entry. When bars or lines
|
|
496
|
+
would collide with that text, pass ``in_legend=True``: the label is then
|
|
497
|
+
attached to the line artist so a later ``ax.legend()`` lists it instead.
|
|
498
|
+
"""
|
|
499
|
+
if orientation == "h":
|
|
500
|
+
line = ax.axhline(value, color=color, linestyle=linestyle, linewidth=linewidth, **kwargs)
|
|
501
|
+
elif orientation == "v":
|
|
502
|
+
line = ax.axvline(value, color=color, linestyle=linestyle, linewidth=linewidth, **kwargs)
|
|
503
|
+
else:
|
|
504
|
+
raise ValueError(f"orientation must be 'h' or 'v', got {orientation!r}")
|
|
505
|
+
|
|
506
|
+
if label and in_legend:
|
|
507
|
+
line.set_label(label)
|
|
508
|
+
elif label:
|
|
509
|
+
opts: dict[str, Any] = {"color": INK, "fontsize": "small"}
|
|
510
|
+
if orientation == "h":
|
|
511
|
+
opts.update(
|
|
512
|
+
x=0.995,
|
|
513
|
+
y=value,
|
|
514
|
+
ha="right",
|
|
515
|
+
va="bottom",
|
|
516
|
+
transform=ax.get_yaxis_transform(),
|
|
517
|
+
)
|
|
518
|
+
else:
|
|
519
|
+
opts.update(
|
|
520
|
+
x=value,
|
|
521
|
+
y=0.98,
|
|
522
|
+
ha="left",
|
|
523
|
+
va="top",
|
|
524
|
+
rotation=90,
|
|
525
|
+
transform=ax.get_xaxis_transform(),
|
|
526
|
+
)
|
|
527
|
+
if label_kwargs:
|
|
528
|
+
opts.update(label_kwargs)
|
|
529
|
+
ax.text(s=label, **opts)
|
|
530
|
+
return line
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def value_labels(
|
|
534
|
+
ax: Axes,
|
|
535
|
+
bars: BarContainer,
|
|
536
|
+
*,
|
|
537
|
+
fmt: str = "{:,.0f}",
|
|
538
|
+
labels: Iterable[str] | None = None,
|
|
539
|
+
padding: float = 2.0,
|
|
540
|
+
**kwargs: Any,
|
|
541
|
+
) -> list[Annotation]:
|
|
542
|
+
"""Annotate bars with their values in small, regular-weight ink text.
|
|
543
|
+
|
|
544
|
+
Use sparingly (a handful of bars, or a horizontal ranking where the exact
|
|
545
|
+
count matters). Never bold, never coloured.
|
|
546
|
+
"""
|
|
547
|
+
if labels is None:
|
|
548
|
+
labels = [fmt.format(value) for value in bars.datavalues]
|
|
549
|
+
opts: dict[str, Any] = {
|
|
550
|
+
"color": INK,
|
|
551
|
+
"fontsize": "small",
|
|
552
|
+
"fontweight": "normal",
|
|
553
|
+
"padding": padding,
|
|
554
|
+
}
|
|
555
|
+
opts.update(kwargs)
|
|
556
|
+
return ax.bar_label(bars, labels=list(labels), **opts)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
# ----------------------------------------------------------------------
|
|
560
|
+
# Captions and saving
|
|
561
|
+
# ----------------------------------------------------------------------
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def caption_markdown(
|
|
565
|
+
caption: str,
|
|
566
|
+
*,
|
|
567
|
+
notes: str | None = None,
|
|
568
|
+
source: str | None = None,
|
|
569
|
+
number: int | str | None = None,
|
|
570
|
+
) -> str:
|
|
571
|
+
"""Render a journal-style caption block as Markdown.
|
|
572
|
+
|
|
573
|
+
``**Figure N. Caption**`` then ``*Notes:*`` then ``*Source:*`` — notes
|
|
574
|
+
precede the source note, following the AER style guide. Without ``number``
|
|
575
|
+
the title stands alone (``**Caption**``) so figures can be numbered later,
|
|
576
|
+
when they are arranged in the manuscript.
|
|
577
|
+
"""
|
|
578
|
+
title = caption.strip()
|
|
579
|
+
heading = f"Figure {number}. {title}" if number is not None else title
|
|
580
|
+
parts = [f"**{heading}**"]
|
|
581
|
+
if notes:
|
|
582
|
+
parts.append(f"*Notes:* {notes.strip()}")
|
|
583
|
+
if source:
|
|
584
|
+
parts.append(f"*Source:* {source.strip()}")
|
|
585
|
+
return "\n\n".join(parts)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def save_figure(
|
|
589
|
+
fig: Figure,
|
|
590
|
+
stem: str | Path,
|
|
591
|
+
*,
|
|
592
|
+
formats: Sequence[str] = ("pdf", "png"),
|
|
593
|
+
dpi: int = 300,
|
|
594
|
+
caption: str | None = None,
|
|
595
|
+
notes: str | None = None,
|
|
596
|
+
source: str | None = None,
|
|
597
|
+
number: int | str | None = None,
|
|
598
|
+
grayscale: bool = True,
|
|
599
|
+
) -> list[Path]:
|
|
600
|
+
"""Save ``fig`` as ``<stem>.<fmt>`` for each format, plus ``<stem>.caption.md``.
|
|
601
|
+
|
|
602
|
+
PDF is the vector file journals ask for (TrueType fonts embedded via
|
|
603
|
+
``pdf.fonttype = 42``); PNG at ``dpi`` is for Word drafts and slides.
|
|
604
|
+
The caption file keeps title, notes and source next to the artwork so the
|
|
605
|
+
figure itself can stay free of in-plot titles.
|
|
606
|
+
|
|
607
|
+
``grayscale`` (default) also writes a black-and-white proof to a ``gray/``
|
|
608
|
+
subdirectory, so the "legible without colour" check leaves evidence on disk
|
|
609
|
+
without doubling the file count in the directory handed to a coauthor. It is
|
|
610
|
+
skipped silently when Pillow is absent or no PNG was requested.
|
|
611
|
+
"""
|
|
612
|
+
stem = Path(stem)
|
|
613
|
+
stem.parent.mkdir(parents=True, exist_ok=True)
|
|
614
|
+
written: list[Path] = []
|
|
615
|
+
for fmt in formats:
|
|
616
|
+
path = stem.with_name(f"{stem.name}.{fmt}")
|
|
617
|
+
fig.savefig(path, dpi=dpi, bbox_inches="tight", facecolor="white")
|
|
618
|
+
written.append(path)
|
|
619
|
+
if caption:
|
|
620
|
+
caption_path = stem.with_name(f"{stem.name}.caption.md")
|
|
621
|
+
caption_path.write_text(
|
|
622
|
+
caption_markdown(caption, notes=notes, source=source, number=number) + "\n",
|
|
623
|
+
encoding="utf-8",
|
|
624
|
+
)
|
|
625
|
+
written.append(caption_path)
|
|
626
|
+
if grayscale:
|
|
627
|
+
png = next((p for p in written if p.suffix == ".png"), None)
|
|
628
|
+
if png is not None:
|
|
629
|
+
gray_dir = stem.parent / "gray"
|
|
630
|
+
try:
|
|
631
|
+
gray_dir.mkdir(parents=True, exist_ok=True)
|
|
632
|
+
written.append(
|
|
633
|
+
grayscale_preview(png, gray_dir / f"{stem.name}.gray.png")
|
|
634
|
+
)
|
|
635
|
+
except ImportError: # Pillow not installed; the proof is optional
|
|
636
|
+
pass
|
|
637
|
+
return written
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def network_axes(ax: Axes, *, equal: bool = True) -> Axes:
|
|
641
|
+
"""Strip a network drawing down to its marks: no spines, ticks, or frame.
|
|
642
|
+
|
|
643
|
+
A node-link diagram has no meaningful axes, so the chrome
|
|
644
|
+
:func:`econ_style` draws for a plot is noise here. ``equal`` keeps circles
|
|
645
|
+
circular; pass ``equal=False`` when one axis carries real units (a year).
|
|
646
|
+
"""
|
|
647
|
+
ax.set_axis_off()
|
|
648
|
+
if equal:
|
|
649
|
+
ax.set_aspect("equal")
|
|
650
|
+
return ax
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def hairline(
|
|
654
|
+
ax: Axes,
|
|
655
|
+
xs: Sequence[float],
|
|
656
|
+
ys: Sequence[float],
|
|
657
|
+
*,
|
|
658
|
+
color: str = EDGE_COLOR,
|
|
659
|
+
linewidth: float = 0.7,
|
|
660
|
+
zorder: float = 2.0,
|
|
661
|
+
**kwargs: Any,
|
|
662
|
+
) -> Line2D:
|
|
663
|
+
"""Draw one thin connector segment.
|
|
664
|
+
|
|
665
|
+
``econ_style`` installs a property cycle that carries *line styles* as well
|
|
666
|
+
as colours, so a bare ``ax.plot`` silently comes out dashed or dotted on the
|
|
667
|
+
second call. Connectors are structure, not data, so this pins a solid line
|
|
668
|
+
and butt caps — the caps matter where segments meet at a right angle.
|
|
669
|
+
"""
|
|
670
|
+
return ax.plot(
|
|
671
|
+
xs,
|
|
672
|
+
ys,
|
|
673
|
+
color=color,
|
|
674
|
+
linewidth=linewidth,
|
|
675
|
+
linestyle="-",
|
|
676
|
+
solid_capstyle="butt",
|
|
677
|
+
zorder=zorder,
|
|
678
|
+
**kwargs,
|
|
679
|
+
)[0]
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def arrow_props(
|
|
683
|
+
*,
|
|
684
|
+
color: str = EDGE_COLOR,
|
|
685
|
+
linewidth: float = 0.7,
|
|
686
|
+
mutation_scale: float = 5.0,
|
|
687
|
+
arrowstyle: str = "-|>",
|
|
688
|
+
**kwargs: Any,
|
|
689
|
+
) -> dict[str, Any]:
|
|
690
|
+
"""``arrowprops`` for a directed connector, matching :func:`hairline`.
|
|
691
|
+
|
|
692
|
+
``shrinkA``/``shrinkB`` are zero because callers anchor arrows at a measured
|
|
693
|
+
label or node edge themselves; letting matplotlib shrink from the centre is
|
|
694
|
+
what runs an arrow back through its own label.
|
|
695
|
+
"""
|
|
696
|
+
props: dict[str, Any] = {
|
|
697
|
+
"arrowstyle": arrowstyle,
|
|
698
|
+
"mutation_scale": mutation_scale,
|
|
699
|
+
"color": color,
|
|
700
|
+
"linewidth": linewidth,
|
|
701
|
+
"shrinkA": 0,
|
|
702
|
+
"shrinkB": 0,
|
|
703
|
+
}
|
|
704
|
+
props.update(kwargs)
|
|
705
|
+
return props
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def junction_dot(
|
|
709
|
+
ax: Axes,
|
|
710
|
+
x: float,
|
|
711
|
+
y: float,
|
|
712
|
+
*,
|
|
713
|
+
color: str = EDGE_COLOR,
|
|
714
|
+
radius: float = JUNCTION_RADIUS,
|
|
715
|
+
zorder: float = 2.6,
|
|
716
|
+
**kwargs: Any,
|
|
717
|
+
) -> Line2D:
|
|
718
|
+
"""Mark a point where connectors *meet*, as opposed to merely cross.
|
|
719
|
+
|
|
720
|
+
This is the schematic convention, and a bracket router needs it as soon as
|
|
721
|
+
the graph it draws stops being a forest. With one parent per node, every
|
|
722
|
+
bracket owns its own rows and no two ever touch; with several — a doctorate
|
|
723
|
+
supervised by three people — one bracket's vertical spine has to run past
|
|
724
|
+
rows that other brackets are using, and a reader has no way to tell the
|
|
725
|
+
resulting T-junction from an X-crossing. Left unmarked, that is a figure
|
|
726
|
+
asserting ties nobody recorded: in one supervision tree it joined two
|
|
727
|
+
people who share no advisor, no institution and no edge.
|
|
728
|
+
|
|
729
|
+
So a filled dot means connected and bare crossing lines mean not connected,
|
|
730
|
+
and the two become distinguishable. Callers decide which points qualify —
|
|
731
|
+
the rule is three or more connector directions leaving the same point, since
|
|
732
|
+
a plain corner or a straight pass-through needs no mark and a figure peppered
|
|
733
|
+
with dots teaches the reader to ignore them.
|
|
734
|
+
|
|
735
|
+
``radius`` is in points, independent of the axis scale, so the mark stays put
|
|
736
|
+
on a year axis as readily as in the point space the lineage trees use.
|
|
737
|
+
"""
|
|
738
|
+
return ax.plot(
|
|
739
|
+
[x],
|
|
740
|
+
[y],
|
|
741
|
+
marker="o",
|
|
742
|
+
markersize=radius * 2,
|
|
743
|
+
linestyle="none",
|
|
744
|
+
markerfacecolor=color,
|
|
745
|
+
markeredgecolor=color,
|
|
746
|
+
markeredgewidth=0.0,
|
|
747
|
+
zorder=zorder,
|
|
748
|
+
**kwargs,
|
|
749
|
+
)[0]
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def draw_node(
|
|
753
|
+
ax: Axes,
|
|
754
|
+
x: float,
|
|
755
|
+
y: float,
|
|
756
|
+
label: str | None = None,
|
|
757
|
+
*,
|
|
758
|
+
inside: bool = True,
|
|
759
|
+
focus: bool = False,
|
|
760
|
+
size: float = NODE_RADIUS * 2,
|
|
761
|
+
fontsize: float | str | None = None,
|
|
762
|
+
label_offset: float = LABEL_OFFSET,
|
|
763
|
+
label_side: str = "right",
|
|
764
|
+
unit: float = 1.0,
|
|
765
|
+
zorder: float = 4.0,
|
|
766
|
+
**kwargs: Any,
|
|
767
|
+
) -> Line2D:
|
|
768
|
+
"""Draw one network node, plus its label to the right, in the house style.
|
|
769
|
+
|
|
770
|
+
The fill carries one bit of membership and nothing else: ``inside`` (the
|
|
771
|
+
default) fills with :data:`NODE_FILL` for a node in the population being
|
|
772
|
+
described, and ``inside=False`` leaves it open (:data:`NODE_OPEN`) for one
|
|
773
|
+
outside it. ``focus=True`` fills with ink, for the single node a figure is
|
|
774
|
+
built around. Every node keeps a black edge so the open fill still reads.
|
|
775
|
+
|
|
776
|
+
``size`` is the marker diameter in points. ``label_offset`` is the gap
|
|
777
|
+
between node centre and label, also in points; ``unit`` converts points to
|
|
778
|
+
data units when the axis carries real units (a year axis), so the label sits
|
|
779
|
+
the same distance from the marker whatever the axis scale. ``label_side``
|
|
780
|
+
flips the label to the left of the marker, for the left end of a paired row.
|
|
781
|
+
|
|
782
|
+
Labels are never bold — the focus node is already distinguished by its fill.
|
|
783
|
+
"""
|
|
784
|
+
facecolor = INK if focus else (NODE_FILL if inside else NODE_OPEN)
|
|
785
|
+
marker = ax.plot(
|
|
786
|
+
[x],
|
|
787
|
+
[y],
|
|
788
|
+
marker="o",
|
|
789
|
+
markersize=size,
|
|
790
|
+
linestyle="none",
|
|
791
|
+
markerfacecolor=facecolor,
|
|
792
|
+
markeredgecolor=NODE_EDGE,
|
|
793
|
+
markeredgewidth=0.7,
|
|
794
|
+
zorder=zorder,
|
|
795
|
+
**kwargs,
|
|
796
|
+
)[0]
|
|
797
|
+
if label:
|
|
798
|
+
if label_side == "right":
|
|
799
|
+
offset, align = label_offset, "left"
|
|
800
|
+
elif label_side == "left":
|
|
801
|
+
offset, align = -label_offset, "right"
|
|
802
|
+
else:
|
|
803
|
+
raise ValueError(f"label_side must be 'left' or 'right', got {label_side!r}")
|
|
804
|
+
opts: dict[str, Any] = {"ha": align, "va": "center", "color": INK}
|
|
805
|
+
if fontsize is not None:
|
|
806
|
+
opts["fontsize"] = fontsize
|
|
807
|
+
ax.text(x + offset * unit, y, label, zorder=zorder + 1, **opts)
|
|
808
|
+
return marker
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def box_node(
|
|
812
|
+
ax: Axes,
|
|
813
|
+
x: float,
|
|
814
|
+
y: float,
|
|
815
|
+
lines: Sequence[str],
|
|
816
|
+
*,
|
|
817
|
+
width: float,
|
|
818
|
+
height: float,
|
|
819
|
+
inside: bool = True,
|
|
820
|
+
focus: bool = False,
|
|
821
|
+
fontsize: float | None = None,
|
|
822
|
+
sub_fontsize: float | None = None,
|
|
823
|
+
zorder: float = 4.0,
|
|
824
|
+
**kwargs: Any,
|
|
825
|
+
) -> Any:
|
|
826
|
+
"""Draw one network node as a box carrying a name and an attribute.
|
|
827
|
+
|
|
828
|
+
The box is :func:`draw_node` with room for a second fact: the first line is
|
|
829
|
+
the node's name, and any line after it is an attribute of that node — an
|
|
830
|
+
institution, a department — set smaller and in :data:`GRAYS` so it reads as
|
|
831
|
+
a subtitle rather than as a second name. The fill still carries membership
|
|
832
|
+
and nothing else, exactly as ``draw_node``: :data:`NODE_FILL` for a node in
|
|
833
|
+
the population being described, :data:`NODE_OPEN` for one outside it, ink
|
|
834
|
+
for the single node a figure is built around. On an ink fill the text flips
|
|
835
|
+
to white, since the alternative is a box with nothing legible in it.
|
|
836
|
+
|
|
837
|
+
``x`` is the **left edge** and ``y`` the **vertical centre**, which is how a
|
|
838
|
+
tree router positions a node: it knows the column a generation starts at and
|
|
839
|
+
the row a person sits on. ``width`` and ``height`` are in data units and
|
|
840
|
+
arrive measured — this function draws a box at the size it is given and
|
|
841
|
+
never asks the renderer how wide the text is, because the layout needs those
|
|
842
|
+
widths before any axes exist.
|
|
843
|
+
"""
|
|
844
|
+
from matplotlib.patches import Rectangle
|
|
845
|
+
|
|
846
|
+
facecolor = INK if focus else (BOX_FILL if inside else NODE_OPEN)
|
|
847
|
+
patch = Rectangle(
|
|
848
|
+
(x, y - height / 2),
|
|
849
|
+
width,
|
|
850
|
+
height,
|
|
851
|
+
facecolor=facecolor,
|
|
852
|
+
edgecolor=NODE_EDGE,
|
|
853
|
+
linewidth=0.7,
|
|
854
|
+
zorder=zorder,
|
|
855
|
+
**kwargs,
|
|
856
|
+
)
|
|
857
|
+
ax.add_patch(patch)
|
|
858
|
+
|
|
859
|
+
base = float(_pyplot().rcParams["font.size"]) if fontsize is None else float(fontsize)
|
|
860
|
+
sub = base - 1.1 if sub_fontsize is None else float(sub_fontsize)
|
|
861
|
+
head_color, sub_color = ("white", GRAYS[3]) if focus else (INK, GRAYS[1])
|
|
862
|
+
step = height / (len(lines) + 1)
|
|
863
|
+
for i, line in enumerate(lines):
|
|
864
|
+
ax.text(
|
|
865
|
+
x + width / 2,
|
|
866
|
+
y + height / 2 - (i + 1) * step,
|
|
867
|
+
line,
|
|
868
|
+
ha="center",
|
|
869
|
+
va="center",
|
|
870
|
+
color=head_color if i == 0 else sub_color,
|
|
871
|
+
fontsize=base if i == 0 else sub,
|
|
872
|
+
zorder=zorder + 1,
|
|
873
|
+
)
|
|
874
|
+
return patch
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def flow_band(
|
|
878
|
+
ax: Axes,
|
|
879
|
+
x0: float,
|
|
880
|
+
x1: float,
|
|
881
|
+
*,
|
|
882
|
+
y_left: float,
|
|
883
|
+
y_right: float,
|
|
884
|
+
thickness: float,
|
|
885
|
+
fill: str = BAR_FILL,
|
|
886
|
+
edgecolor: str = INK,
|
|
887
|
+
linewidth: float = 0.4,
|
|
888
|
+
zorder: float = 2.0,
|
|
889
|
+
**kwargs: Any,
|
|
890
|
+
) -> Any:
|
|
891
|
+
"""Draw one ribbon of a two-column flow diagram.
|
|
892
|
+
|
|
893
|
+
A quadrilateral running from ``(x0, y_left)`` to ``(x1, y_right)``, of the
|
|
894
|
+
same ``thickness`` at both ends because both ends count the same people.
|
|
895
|
+
``y_left`` and ``y_right`` are its **lower** edge, so a caller stacks bands
|
|
896
|
+
by carrying a running offset up each column.
|
|
897
|
+
|
|
898
|
+
The edges are straight rather than curved on purpose: a ribbon's job here is
|
|
899
|
+
to be countable, and a sheaf of Béziers reads as a texture. The hairline
|
|
900
|
+
outline is what keeps the lightest fill separable from its neighbour once
|
|
901
|
+
the figure goes through the grayscale proof.
|
|
902
|
+
"""
|
|
903
|
+
from matplotlib.patches import Polygon
|
|
904
|
+
|
|
905
|
+
band = Polygon(
|
|
906
|
+
[
|
|
907
|
+
(x0, y_left),
|
|
908
|
+
(x1, y_right),
|
|
909
|
+
(x1, y_right + thickness),
|
|
910
|
+
(x0, y_left + thickness),
|
|
911
|
+
],
|
|
912
|
+
closed=True,
|
|
913
|
+
facecolor=fill,
|
|
914
|
+
edgecolor=edgecolor,
|
|
915
|
+
linewidth=linewidth,
|
|
916
|
+
zorder=zorder,
|
|
917
|
+
**kwargs,
|
|
918
|
+
)
|
|
919
|
+
ax.add_patch(band)
|
|
920
|
+
return band
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def node_legend(
|
|
924
|
+
ax: Axes,
|
|
925
|
+
inside_label: str,
|
|
926
|
+
outside_label: str,
|
|
927
|
+
*,
|
|
928
|
+
focus_label: str | None = None,
|
|
929
|
+
loc: str = "lower left",
|
|
930
|
+
size: float = NODE_RADIUS * 2,
|
|
931
|
+
shape: str = "circle",
|
|
932
|
+
**kwargs: Any,
|
|
933
|
+
) -> Any:
|
|
934
|
+
"""Frameless legend naming what a filled node means and what an open one means.
|
|
935
|
+
|
|
936
|
+
Entries follow the same fill vocabulary as :func:`draw_node`, so the legend
|
|
937
|
+
cannot drift from the marks it explains. ``shape="box"`` swaps the circular
|
|
938
|
+
proxies for rectangular ones, for a figure whose nodes are
|
|
939
|
+
:func:`box_node` — a legend of circles under a diagram of boxes invites the
|
|
940
|
+
reader to hunt for a distinction that is not there.
|
|
941
|
+
"""
|
|
942
|
+
plt = _pyplot()
|
|
943
|
+
if shape not in ("circle", "box"):
|
|
944
|
+
raise ValueError(f"shape must be 'circle' or 'box', got {shape!r}")
|
|
945
|
+
inside_fill = BOX_FILL if shape == "box" else NODE_FILL
|
|
946
|
+
entries = [(inside_fill, inside_label), (NODE_OPEN, outside_label)]
|
|
947
|
+
if focus_label:
|
|
948
|
+
entries.insert(0, (INK, focus_label))
|
|
949
|
+
if shape == "box":
|
|
950
|
+
handles: list[Any] = [
|
|
951
|
+
plt.Rectangle(
|
|
952
|
+
(0, 0),
|
|
953
|
+
1,
|
|
954
|
+
1,
|
|
955
|
+
facecolor=fill,
|
|
956
|
+
edgecolor=NODE_EDGE,
|
|
957
|
+
linewidth=0.7,
|
|
958
|
+
label=text,
|
|
959
|
+
)
|
|
960
|
+
for fill, text in entries
|
|
961
|
+
]
|
|
962
|
+
else:
|
|
963
|
+
handles = [
|
|
964
|
+
plt.Line2D(
|
|
965
|
+
[],
|
|
966
|
+
[],
|
|
967
|
+
marker="o",
|
|
968
|
+
linestyle="none",
|
|
969
|
+
markerfacecolor=fill,
|
|
970
|
+
markeredgecolor=NODE_EDGE,
|
|
971
|
+
markeredgewidth=0.7,
|
|
972
|
+
markersize=size,
|
|
973
|
+
label=text,
|
|
974
|
+
)
|
|
975
|
+
for fill, text in entries
|
|
976
|
+
]
|
|
977
|
+
opts: dict[str, Any] = {
|
|
978
|
+
"frameon": False,
|
|
979
|
+
"loc": loc,
|
|
980
|
+
"ncol": len(handles),
|
|
981
|
+
"handlelength": 1.2,
|
|
982
|
+
"borderaxespad": 0.3,
|
|
983
|
+
"columnspacing": 1.5,
|
|
984
|
+
}
|
|
985
|
+
opts.update(kwargs)
|
|
986
|
+
return ax.legend(handles=handles, **opts)
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _label_anchors(reaches: Sequence[float]) -> tuple[tuple[float, float, str, str], ...]:
|
|
990
|
+
"""Offsets to try for a label, nearest ring first.
|
|
991
|
+
|
|
992
|
+
Four sides, then four diagonals, then the same eight further out. One ring
|
|
993
|
+
is not enough: a force-directed layout routinely pulls five people into a
|
|
994
|
+
clump, and the fifth name needs somewhere to go that is not on top of the
|
|
995
|
+
fourth.
|
|
996
|
+
"""
|
|
997
|
+
directions = (
|
|
998
|
+
(0.0, 1.0, "center", "bottom"),
|
|
999
|
+
(0.0, -1.0, "center", "top"),
|
|
1000
|
+
(1.0, 0.0, "left", "center"),
|
|
1001
|
+
(-1.0, 0.0, "right", "center"),
|
|
1002
|
+
(0.75, 0.75, "left", "bottom"),
|
|
1003
|
+
(-0.75, 0.75, "right", "bottom"),
|
|
1004
|
+
(0.75, -0.75, "left", "top"),
|
|
1005
|
+
(-0.75, -0.75, "right", "top"),
|
|
1006
|
+
)
|
|
1007
|
+
return tuple(
|
|
1008
|
+
(dx * reach, dy * reach, ha, va)
|
|
1009
|
+
for reach in reaches
|
|
1010
|
+
for dx, dy, ha, va in directions
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def place_node_labels(
|
|
1015
|
+
ax: Axes,
|
|
1016
|
+
labels: Mapping[str, str],
|
|
1017
|
+
positions: Mapping[str, Any],
|
|
1018
|
+
*,
|
|
1019
|
+
node_sizes: Mapping[str, float] | float = NODE_RADIUS * 2,
|
|
1020
|
+
order: Sequence[str] | None = None,
|
|
1021
|
+
fontsize: float | str | None = None,
|
|
1022
|
+
reaches: Sequence[float] = (6.5, 13.0, 21.0),
|
|
1023
|
+
leader_beyond: float = 13.5,
|
|
1024
|
+
padding: tuple[float, float] = (1.08, 1.25),
|
|
1025
|
+
**kwargs: Any,
|
|
1026
|
+
) -> dict[str, Annotation]:
|
|
1027
|
+
"""Label nodes of a node-link drawing, moving each name off what it would hit.
|
|
1028
|
+
|
|
1029
|
+
A force-directed layout has no idea that its nodes carry names, so the
|
|
1030
|
+
naive "text just above the marker" collides — with another name, or with a
|
|
1031
|
+
marker belonging to somebody else. Each label here is drawn, *measured
|
|
1032
|
+
against the renderer*, and moved to the next anchor around its node when it
|
|
1033
|
+
clashes with a label already placed or with any node's marker. Measuring
|
|
1034
|
+
the rendered text rather than guessing at distances is what makes this hold
|
|
1035
|
+
for a different corpus, or the same corpus one stage later.
|
|
1036
|
+
|
|
1037
|
+
``positions`` covers **every** node in the drawing, in data coordinates;
|
|
1038
|
+
``labels`` names only the subset that gets text. The difference matters:
|
|
1039
|
+
an unlabelled node is still an obstacle, and treating it as absent is how a
|
|
1040
|
+
name ends up sitting on a marker. ``node_sizes`` are marker diameters in
|
|
1041
|
+
points, one per node or one for all, and set how wide a berth each marker
|
|
1042
|
+
gets.
|
|
1043
|
+
|
|
1044
|
+
Labels are placed in ``order`` (default: the sorted keys of ``labels``), so
|
|
1045
|
+
the caller decides who keeps the preferred anchor — pass the most prominent
|
|
1046
|
+
nodes first. A fixed order also makes the drawing reproducible, which a set
|
|
1047
|
+
or dict built from graph iteration would not be. When a label has to travel
|
|
1048
|
+
beyond ``leader_beyond`` points it gets a hairline leader back to its node,
|
|
1049
|
+
because a name far from every marker belongs to none of them.
|
|
1050
|
+
|
|
1051
|
+
Returns the annotations, keyed as ``labels`` was.
|
|
1052
|
+
"""
|
|
1053
|
+
missing = sorted(key for key in labels if key not in positions)
|
|
1054
|
+
if missing:
|
|
1055
|
+
raise KeyError(f"no position for labelled node(s): {', '.join(missing)}")
|
|
1056
|
+
|
|
1057
|
+
figure = ax.figure
|
|
1058
|
+
figure.canvas.draw()
|
|
1059
|
+
renderer = figure.canvas.get_renderer()
|
|
1060
|
+
|
|
1061
|
+
def radius(key: str) -> float:
|
|
1062
|
+
size = node_sizes[key] if isinstance(node_sizes, Mapping) else node_sizes
|
|
1063
|
+
return float(size) / 2.0 + 1.5
|
|
1064
|
+
|
|
1065
|
+
obstacles = [
|
|
1066
|
+
(ax.transData.transform(positions[key]), radius(key)) for key in sorted(positions)
|
|
1067
|
+
]
|
|
1068
|
+
anchors = _label_anchors(reaches)
|
|
1069
|
+
taken: list[Bbox] = []
|
|
1070
|
+
placed: dict[str, Annotation] = {}
|
|
1071
|
+
|
|
1072
|
+
def draw(key: str, anchor: tuple[float, float, str, str], *, leader: bool) -> Annotation:
|
|
1073
|
+
dx, dy, ha, va = anchor
|
|
1074
|
+
opts: dict[str, Any] = {"ha": ha, "va": va, "color": INK, "zorder": 6.0}
|
|
1075
|
+
if fontsize is not None:
|
|
1076
|
+
opts["fontsize"] = fontsize
|
|
1077
|
+
if leader:
|
|
1078
|
+
opts["arrowprops"] = {
|
|
1079
|
+
"arrowstyle": "-",
|
|
1080
|
+
"color": GRAYS[2],
|
|
1081
|
+
"linewidth": 0.4,
|
|
1082
|
+
"shrinkA": 0.0,
|
|
1083
|
+
"shrinkB": 2.0,
|
|
1084
|
+
}
|
|
1085
|
+
opts.update(kwargs)
|
|
1086
|
+
return ax.annotate(
|
|
1087
|
+
labels[key],
|
|
1088
|
+
positions[key],
|
|
1089
|
+
xytext=(dx, dy),
|
|
1090
|
+
textcoords="offset points",
|
|
1091
|
+
**opts,
|
|
1092
|
+
)
|
|
1093
|
+
|
|
1094
|
+
def needs_leader(anchor: tuple[float, float, str, str]) -> bool:
|
|
1095
|
+
return max(abs(anchor[0]), abs(anchor[1])) > leader_beyond
|
|
1096
|
+
|
|
1097
|
+
for key in (sorted(labels) if order is None else order):
|
|
1098
|
+
if key not in labels:
|
|
1099
|
+
continue
|
|
1100
|
+
chosen: Annotation | None = None
|
|
1101
|
+
box: Bbox | None = None
|
|
1102
|
+
least_bad: tuple[float, tuple[float, float, str, str]] | None = None
|
|
1103
|
+
picked: tuple[float, float, str, str] | None = None
|
|
1104
|
+
for anchor in anchors:
|
|
1105
|
+
# Measured without its leader on purpose: an annotation that owns an
|
|
1106
|
+
# arrow reports the arrow inside its extent, so a far anchor would
|
|
1107
|
+
# always measure as a collision and never be chosen at all.
|
|
1108
|
+
text = draw(key, anchor, leader=False)
|
|
1109
|
+
extent = text.get_window_extent(renderer=renderer).expanded(*padding)
|
|
1110
|
+
cost = sum(_overlap_area(extent, other) for other in taken) + sum(
|
|
1111
|
+
_circle_overlap(centre, r, extent) for centre, r in obstacles
|
|
1112
|
+
)
|
|
1113
|
+
if cost == 0.0:
|
|
1114
|
+
chosen, box, picked = text, extent, anchor
|
|
1115
|
+
break
|
|
1116
|
+
text.remove()
|
|
1117
|
+
if least_bad is None or cost < least_bad[0]:
|
|
1118
|
+
least_bad = (cost, anchor)
|
|
1119
|
+
if chosen is None:
|
|
1120
|
+
# Nowhere is clean. Take the least-bad anchor rather than the
|
|
1121
|
+
# preferred one: falling back to "just above the node" every time
|
|
1122
|
+
# stacks a whole crowd's names in one place, which is the exact
|
|
1123
|
+
# collision this function exists to avoid.
|
|
1124
|
+
assert least_bad is not None
|
|
1125
|
+
picked = least_bad[1]
|
|
1126
|
+
chosen = draw(key, picked, leader=False)
|
|
1127
|
+
box = chosen.get_window_extent(renderer=renderer).expanded(*padding)
|
|
1128
|
+
if needs_leader(picked):
|
|
1129
|
+
chosen.remove()
|
|
1130
|
+
chosen = draw(key, picked, leader=True)
|
|
1131
|
+
taken.append(box)
|
|
1132
|
+
placed[key] = chosen
|
|
1133
|
+
return placed
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def _overlap_area(box: Bbox, other: Bbox) -> float:
|
|
1137
|
+
"""Area shared by two display-space boxes, zero when they are disjoint."""
|
|
1138
|
+
width = min(box.x1, other.x1) - max(box.x0, other.x0)
|
|
1139
|
+
height = min(box.y1, other.y1) - max(box.y0, other.y0)
|
|
1140
|
+
return width * height if width > 0 and height > 0 else 0.0
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def _circle_overlap(centre: Any, radius: float, box: Bbox) -> float:
|
|
1144
|
+
"""How deeply a node marker intrudes into a label box, as a penalty area.
|
|
1145
|
+
|
|
1146
|
+
The exact lens area is not worth computing: this only has to order
|
|
1147
|
+
candidate anchors, and squared intrusion depth does that monotonically.
|
|
1148
|
+
"""
|
|
1149
|
+
nearest_x = min(max(float(centre[0]), box.x0), box.x1)
|
|
1150
|
+
nearest_y = min(max(float(centre[1]), box.y0), box.y1)
|
|
1151
|
+
gap = math.hypot(float(centre[0]) - nearest_x, float(centre[1]) - nearest_y)
|
|
1152
|
+
return (radius - gap) ** 2 if gap < radius else 0.0
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
def separator_rule(
|
|
1156
|
+
ax: Axes,
|
|
1157
|
+
y: float,
|
|
1158
|
+
*,
|
|
1159
|
+
xmin: float = 0.0,
|
|
1160
|
+
xmax: float = 1.0,
|
|
1161
|
+
color: str = RULE_COLOR,
|
|
1162
|
+
linewidth: float = 0.6,
|
|
1163
|
+
zorder: float = 0.5,
|
|
1164
|
+
**kwargs: Any,
|
|
1165
|
+
) -> Line2D:
|
|
1166
|
+
"""Hairline separating one band of a grouped figure from the next.
|
|
1167
|
+
|
|
1168
|
+
Lighter than any data mark, drawn under everything, so it groups rows
|
|
1169
|
+
without reading as a gridline.
|
|
1170
|
+
"""
|
|
1171
|
+
return ax.axhline(
|
|
1172
|
+
y,
|
|
1173
|
+
xmin=xmin,
|
|
1174
|
+
xmax=xmax,
|
|
1175
|
+
color=color,
|
|
1176
|
+
linewidth=linewidth,
|
|
1177
|
+
linestyle="-",
|
|
1178
|
+
zorder=zorder,
|
|
1179
|
+
**kwargs,
|
|
1180
|
+
)
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def show_caption(
|
|
1184
|
+
caption: str,
|
|
1185
|
+
*,
|
|
1186
|
+
notes: str | None = None,
|
|
1187
|
+
source: str | None = None,
|
|
1188
|
+
number: int | str | None = None,
|
|
1189
|
+
) -> None:
|
|
1190
|
+
"""Render the caption block as Markdown under a figure in a notebook.
|
|
1191
|
+
|
|
1192
|
+
The display counterpart of :func:`save_figure`'s caption sidecar, so the
|
|
1193
|
+
caption a reader sees in the notebook is built by the same code that writes
|
|
1194
|
+
the file next to the artwork.
|
|
1195
|
+
"""
|
|
1196
|
+
from IPython.display import Markdown, display
|
|
1197
|
+
|
|
1198
|
+
display(Markdown(caption_markdown(caption, notes=notes, source=source, number=number)))
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
def grayscale_preview(png_path: str | Path, out_path: str | Path | None = None) -> Path:
|
|
1202
|
+
"""Write a grayscale copy of a PNG so the "legible in black and white"
|
|
1203
|
+
requirement can be checked by eye. Returns the output path."""
|
|
1204
|
+
from PIL import Image
|
|
1205
|
+
|
|
1206
|
+
png_path = Path(png_path)
|
|
1207
|
+
out = (
|
|
1208
|
+
Path(out_path)
|
|
1209
|
+
if out_path is not None
|
|
1210
|
+
else png_path.with_name(f"{png_path.stem}.gray{png_path.suffix}")
|
|
1211
|
+
)
|
|
1212
|
+
with Image.open(png_path) as img:
|
|
1213
|
+
img.convert("L").save(out)
|
|
1214
|
+
return out
|