acadplot 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.
- acadplot/__init__.py +70 -0
- acadplot/bar.py +428 -0
- acadplot/charts.py +376 -0
- acadplot/draw.py +126 -0
- acadplot/line.py +184 -0
- acadplot/plot.py +70 -0
- acadplot/styles.py +911 -0
- acadplot/utils.py +440 -0
- acadplot-0.1.0.dist-info/METADATA +840 -0
- acadplot-0.1.0.dist-info/RECORD +12 -0
- acadplot-0.1.0.dist-info/WHEEL +4 -0
- acadplot-0.1.0.dist-info/licenses/LICENSE +21 -0
acadplot/utils.py
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
import json
|
|
4
|
+
from typing import Iterable, Sequence, Tuple
|
|
5
|
+
|
|
6
|
+
import matplotlib.pyplot as plt
|
|
7
|
+
from matplotlib.patches import Rectangle
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_OUTPUT_DIR: Path | None = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def blend_color(
|
|
14
|
+
rgba1: Tuple[float, float, float, float], rgba2: Tuple[float, float, float, float]
|
|
15
|
+
) -> Tuple[float, float, float, float]:
|
|
16
|
+
return (
|
|
17
|
+
(rgba1[0] + rgba2[0]) / 2.0,
|
|
18
|
+
(rgba1[1] + rgba2[1]) / 2.0,
|
|
19
|
+
(rgba1[2] + rgba2[2]) / 2.0,
|
|
20
|
+
(rgba1[3] + rgba2[3]) / 2.0,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def new_alpha(
|
|
25
|
+
c: tuple[float, float, float] | tuple[float, float, float, float],
|
|
26
|
+
alpha: float,
|
|
27
|
+
) -> tuple[float, float, float, float]:
|
|
28
|
+
return (c[0], c[1], c[2], alpha)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _normalize_formats(formats: Iterable[str]) -> tuple[str, ...]:
|
|
32
|
+
normalized = []
|
|
33
|
+
for fmt in formats:
|
|
34
|
+
clean = fmt.lower().lstrip(".")
|
|
35
|
+
if clean and clean not in normalized:
|
|
36
|
+
normalized.append(clean)
|
|
37
|
+
return tuple(normalized)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def set_output_dir(directory: str | Path | None) -> Path | None:
|
|
41
|
+
"""Set the default directory used by ``save`` for relative output names."""
|
|
42
|
+
global _OUTPUT_DIR
|
|
43
|
+
|
|
44
|
+
_OUTPUT_DIR = None if directory is None else Path(directory)
|
|
45
|
+
return _OUTPUT_DIR
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_output_dir() -> Path | None:
|
|
49
|
+
"""Return the default output directory used by ``save``."""
|
|
50
|
+
return _OUTPUT_DIR
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _resolve_output_path(name: str | Path, directory: str | Path | None) -> Path:
|
|
54
|
+
path = Path(name)
|
|
55
|
+
output_dir = Path(directory) if directory is not None else _OUTPUT_DIR
|
|
56
|
+
if output_dir is not None and not path.is_absolute():
|
|
57
|
+
path = output_dir / path
|
|
58
|
+
return path
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _write_save_metadata(
|
|
62
|
+
targets: tuple[Path, ...],
|
|
63
|
+
metadata: bool | str | Path,
|
|
64
|
+
) -> Path:
|
|
65
|
+
from matplotlib import __version__ as matplotlib_version
|
|
66
|
+
|
|
67
|
+
from .styles import get_current_style
|
|
68
|
+
|
|
69
|
+
metadata_path = (
|
|
70
|
+
targets[0].with_suffix(".acadplot.json") if metadata is True else Path(metadata)
|
|
71
|
+
)
|
|
72
|
+
metadata_path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
payload = {
|
|
74
|
+
"saved_at": datetime.now(timezone.utc).isoformat(),
|
|
75
|
+
"outputs": [str(target) for target in targets],
|
|
76
|
+
"matplotlib_version": matplotlib_version,
|
|
77
|
+
"style": get_current_style(),
|
|
78
|
+
}
|
|
79
|
+
metadata_path.write_text(
|
|
80
|
+
json.dumps(payload, indent=2, default=str), encoding="utf-8"
|
|
81
|
+
)
|
|
82
|
+
return metadata_path
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def save(
|
|
86
|
+
fig,
|
|
87
|
+
name: str | Path,
|
|
88
|
+
*,
|
|
89
|
+
directory: str | Path | None = None,
|
|
90
|
+
formats: Iterable[str] | None = None,
|
|
91
|
+
png: bool = True,
|
|
92
|
+
pdf: bool = False,
|
|
93
|
+
svg: bool = False,
|
|
94
|
+
dpi: int | float = 300,
|
|
95
|
+
bbox_inches: str | None = "tight",
|
|
96
|
+
pad_inches: float = 0,
|
|
97
|
+
tight_layout: bool = True,
|
|
98
|
+
tight_pad: float = 0.2,
|
|
99
|
+
close: bool = True,
|
|
100
|
+
transparent: bool = False,
|
|
101
|
+
metadata: bool | str | Path = False,
|
|
102
|
+
**savefig_kwargs,
|
|
103
|
+
) -> tuple[Path, ...]:
|
|
104
|
+
"""Save a figure with publication-oriented defaults.
|
|
105
|
+
|
|
106
|
+
If ``name`` has a file extension, that exact file is written. Otherwise the
|
|
107
|
+
requested formats are appended to ``name``. By default AcadPlot writes PNG
|
|
108
|
+
output and uses a tight bounding box with no padding.
|
|
109
|
+
"""
|
|
110
|
+
path = _resolve_output_path(name, directory)
|
|
111
|
+
|
|
112
|
+
if path.suffix:
|
|
113
|
+
targets = (path,)
|
|
114
|
+
else:
|
|
115
|
+
selected_formats = []
|
|
116
|
+
if formats is None:
|
|
117
|
+
if png:
|
|
118
|
+
selected_formats.append("png")
|
|
119
|
+
if pdf:
|
|
120
|
+
selected_formats.append("pdf")
|
|
121
|
+
if svg:
|
|
122
|
+
selected_formats.append("svg")
|
|
123
|
+
else:
|
|
124
|
+
selected_formats.extend(formats)
|
|
125
|
+
if pdf:
|
|
126
|
+
selected_formats.append("pdf")
|
|
127
|
+
if svg:
|
|
128
|
+
selected_formats.append("svg")
|
|
129
|
+
|
|
130
|
+
normalized_formats = _normalize_formats(selected_formats)
|
|
131
|
+
if not normalized_formats:
|
|
132
|
+
raise ValueError("At least one output format must be requested.")
|
|
133
|
+
targets = tuple(path.with_suffix(f".{fmt}") for fmt in normalized_formats)
|
|
134
|
+
|
|
135
|
+
if tight_layout:
|
|
136
|
+
fig.tight_layout(pad=tight_pad)
|
|
137
|
+
|
|
138
|
+
for target in targets:
|
|
139
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
fig.savefig(
|
|
141
|
+
target,
|
|
142
|
+
dpi=dpi,
|
|
143
|
+
bbox_inches=bbox_inches,
|
|
144
|
+
pad_inches=pad_inches,
|
|
145
|
+
transparent=transparent,
|
|
146
|
+
**savefig_kwargs,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if close:
|
|
150
|
+
plt.close(fig)
|
|
151
|
+
|
|
152
|
+
if metadata:
|
|
153
|
+
_write_save_metadata(targets, metadata)
|
|
154
|
+
|
|
155
|
+
return targets
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def save_all(
|
|
159
|
+
fig,
|
|
160
|
+
name: str | Path,
|
|
161
|
+
*,
|
|
162
|
+
formats: Iterable[str] = ("png", "pdf", "svg"),
|
|
163
|
+
**kwargs,
|
|
164
|
+
) -> tuple[Path, ...]:
|
|
165
|
+
"""Save PNG, PDF, and SVG outputs unless a different format set is given."""
|
|
166
|
+
return save(fig, name, formats=formats, **kwargs)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _legend_location(location: str, legend_outside: bool | str) -> dict[str, object]:
|
|
170
|
+
if not legend_outside:
|
|
171
|
+
return {"loc": location}
|
|
172
|
+
|
|
173
|
+
side = "right" if legend_outside is True else str(legend_outside).lower()
|
|
174
|
+
outside_locations = {
|
|
175
|
+
"right": {"loc": "center left", "bbox_to_anchor": (1.02, 0.5)},
|
|
176
|
+
"left": {"loc": "center right", "bbox_to_anchor": (-0.02, 0.5)},
|
|
177
|
+
"top": {"loc": "lower center", "bbox_to_anchor": (0.5, 1.02)},
|
|
178
|
+
"bottom": {"loc": "upper center", "bbox_to_anchor": (0.5, -0.18)},
|
|
179
|
+
}
|
|
180
|
+
if side not in outside_locations:
|
|
181
|
+
raise ValueError(
|
|
182
|
+
"legend_outside must be True, False, or one of: right, left, top, bottom."
|
|
183
|
+
)
|
|
184
|
+
return outside_locations[side]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def styled_legend(
|
|
188
|
+
ax,
|
|
189
|
+
location: str = "best",
|
|
190
|
+
*,
|
|
191
|
+
legend_size: float | None = None,
|
|
192
|
+
ncols: int = 1,
|
|
193
|
+
columnspacing: float = 0.5,
|
|
194
|
+
legend_outside: bool | str = False,
|
|
195
|
+
handles=None,
|
|
196
|
+
labels=None,
|
|
197
|
+
):
|
|
198
|
+
"""Create a legend using the active AcadPlot style."""
|
|
199
|
+
from .styles import apply_legend_style, get_current_style
|
|
200
|
+
|
|
201
|
+
style = get_current_style()
|
|
202
|
+
kwargs = _legend_location(location, legend_outside)
|
|
203
|
+
legend = ax.legend(
|
|
204
|
+
handles=handles,
|
|
205
|
+
labels=labels,
|
|
206
|
+
prop=dict(
|
|
207
|
+
size=float(style["legend_size"]) if legend_size is None else legend_size,
|
|
208
|
+
family=str(style["font_family"]),
|
|
209
|
+
),
|
|
210
|
+
frameon=bool(style["legend_frameon"]),
|
|
211
|
+
framealpha=float(style["legend_framealpha"]),
|
|
212
|
+
facecolor=str(style["legend_face_color"]),
|
|
213
|
+
edgecolor=str(style["legend_edge_color"]),
|
|
214
|
+
ncols=ncols,
|
|
215
|
+
columnspacing=columnspacing,
|
|
216
|
+
**kwargs,
|
|
217
|
+
)
|
|
218
|
+
apply_legend_style(legend)
|
|
219
|
+
return legend
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def format_legend(legend=None):
|
|
223
|
+
"""Apply AcadPlot styling to an existing Matplotlib legend."""
|
|
224
|
+
from .styles import apply_legend_style
|
|
225
|
+
|
|
226
|
+
if legend is None:
|
|
227
|
+
legend = plt.gca().get_legend()
|
|
228
|
+
if legend is None:
|
|
229
|
+
return None
|
|
230
|
+
apply_legend_style(legend)
|
|
231
|
+
return legend
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def panel_labels(
|
|
235
|
+
axes,
|
|
236
|
+
labels: Sequence[str] | None = None,
|
|
237
|
+
*,
|
|
238
|
+
x: float = -0.08,
|
|
239
|
+
y: float = 1.04,
|
|
240
|
+
font_size: float | None = None,
|
|
241
|
+
weight: str = "bold",
|
|
242
|
+
prefix: str = "(",
|
|
243
|
+
suffix: str = ")",
|
|
244
|
+
):
|
|
245
|
+
"""Add panel labels such as ``(a)``, ``(b)``, and ``(c)`` to axes."""
|
|
246
|
+
from .styles import get_current_style
|
|
247
|
+
|
|
248
|
+
flat_axes = _flatten_axes(axes)
|
|
249
|
+
style = get_current_style()
|
|
250
|
+
if labels is None:
|
|
251
|
+
labels = [chr(ord("a") + idx) for idx in range(len(flat_axes))]
|
|
252
|
+
if len(labels) != len(flat_axes):
|
|
253
|
+
raise ValueError("labels must match the number of axes.")
|
|
254
|
+
|
|
255
|
+
text_objects = []
|
|
256
|
+
for ax, label in zip(flat_axes, labels):
|
|
257
|
+
text_objects.append(
|
|
258
|
+
ax.text(
|
|
259
|
+
x,
|
|
260
|
+
y,
|
|
261
|
+
f"{prefix}{label}{suffix}",
|
|
262
|
+
transform=ax.transAxes,
|
|
263
|
+
ha="left",
|
|
264
|
+
va="bottom",
|
|
265
|
+
fontsize=float(style["label_size"]) if font_size is None else font_size,
|
|
266
|
+
fontweight=weight,
|
|
267
|
+
color=str(style["text_color"]),
|
|
268
|
+
family=str(style["font_family"]),
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
return text_objects
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def annotate_points(
|
|
275
|
+
ax,
|
|
276
|
+
points: Sequence[tuple[float, float, str]],
|
|
277
|
+
*,
|
|
278
|
+
xytext: tuple[float, float] = (4, 4),
|
|
279
|
+
font_size: float | None = None,
|
|
280
|
+
arrow: bool = False,
|
|
281
|
+
**kwargs,
|
|
282
|
+
):
|
|
283
|
+
"""Annotate selected points using active AcadPlot text styling."""
|
|
284
|
+
from .styles import get_current_style
|
|
285
|
+
|
|
286
|
+
style = get_current_style()
|
|
287
|
+
annotations = []
|
|
288
|
+
arrowprops = (
|
|
289
|
+
{"arrowstyle": "-", "color": str(style["axis_color"]), "linewidth": 0.5}
|
|
290
|
+
if arrow
|
|
291
|
+
else None
|
|
292
|
+
)
|
|
293
|
+
for x, y, text in points:
|
|
294
|
+
annotations.append(
|
|
295
|
+
ax.annotate(
|
|
296
|
+
text,
|
|
297
|
+
xy=(x, y),
|
|
298
|
+
xytext=xytext,
|
|
299
|
+
textcoords="offset points",
|
|
300
|
+
fontsize=float(style["tick_size"]) if font_size is None else font_size,
|
|
301
|
+
color=str(style["text_color"]),
|
|
302
|
+
family=str(style["font_family"]),
|
|
303
|
+
arrowprops=arrowprops,
|
|
304
|
+
**kwargs,
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
return annotations
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def theme_preview(
|
|
311
|
+
*,
|
|
312
|
+
layout: str | None = None,
|
|
313
|
+
themes: Sequence[str] | None = None,
|
|
314
|
+
fig_size: tuple[float, float] | None = None,
|
|
315
|
+
):
|
|
316
|
+
"""Create a compact preview figure for AcadPlot theme palettes."""
|
|
317
|
+
from .styles import THEMES, figure_size, format_axes, get_current_style
|
|
318
|
+
|
|
319
|
+
selected_themes = list(THEMES if themes is None else themes)
|
|
320
|
+
for theme in selected_themes:
|
|
321
|
+
if theme not in THEMES:
|
|
322
|
+
raise ValueError(f"Unknown theme {theme!r}.")
|
|
323
|
+
|
|
324
|
+
if fig_size is None:
|
|
325
|
+
base_width, _ = figure_size(layout)
|
|
326
|
+
fig_size = (base_width, max(1.0, 0.36 * len(selected_themes)))
|
|
327
|
+
fig, ax = plt.subplots(figsize=fig_size)
|
|
328
|
+
|
|
329
|
+
for row_idx, theme_name in enumerate(selected_themes):
|
|
330
|
+
palette = THEMES[theme_name].palette
|
|
331
|
+
ax.text(
|
|
332
|
+
-0.25,
|
|
333
|
+
row_idx + 0.5,
|
|
334
|
+
theme_name,
|
|
335
|
+
ha="right",
|
|
336
|
+
va="center",
|
|
337
|
+
fontsize=float(get_current_style()["tick_size"]),
|
|
338
|
+
color=str(get_current_style()["text_color"]),
|
|
339
|
+
)
|
|
340
|
+
for col_idx, color in enumerate(palette):
|
|
341
|
+
ax.add_patch(
|
|
342
|
+
Rectangle(
|
|
343
|
+
(col_idx, row_idx), 0.9, 0.72, facecolor=color, edgecolor="none"
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
ax.set_xlim(-1.2, max(len(THEMES[name].palette) for name in selected_themes))
|
|
348
|
+
ax.set_ylim(0, len(selected_themes))
|
|
349
|
+
ax.invert_yaxis()
|
|
350
|
+
ax.set_xticks([])
|
|
351
|
+
ax.set_yticks([])
|
|
352
|
+
format_axes(ax, grid="none", despine=True)
|
|
353
|
+
return fig, ax
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _flatten_axes(axes) -> list:
|
|
357
|
+
if hasattr(axes, "ravel"):
|
|
358
|
+
return list(axes.ravel())
|
|
359
|
+
if isinstance(axes, (list, tuple)):
|
|
360
|
+
flat = []
|
|
361
|
+
for item in axes:
|
|
362
|
+
flat.extend(_flatten_axes(item))
|
|
363
|
+
return flat
|
|
364
|
+
return [axes]
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
markers = {
|
|
368
|
+
"square": ("s", 3.5),
|
|
369
|
+
"triangle_up": ("^", 4),
|
|
370
|
+
"pentagon": ("p", 5),
|
|
371
|
+
"circle": ("o", 4),
|
|
372
|
+
"star": ("*", 4.5),
|
|
373
|
+
"plus_filled": ("P", 3.5),
|
|
374
|
+
"triangle_down": ("v", 4),
|
|
375
|
+
"diamond": ("D", 3),
|
|
376
|
+
"x_filled": ("X", 3.5),
|
|
377
|
+
"triangle_left": ("<", 4),
|
|
378
|
+
"triangle_right": (">", 4),
|
|
379
|
+
"thin_diamond": ("d", 3),
|
|
380
|
+
"hexagon1": ("h", 4),
|
|
381
|
+
"hexagon2": ("H", 4),
|
|
382
|
+
"plus": ("+", 4),
|
|
383
|
+
"x": ("x", 4),
|
|
384
|
+
"vline": ("|", 4),
|
|
385
|
+
"hline": ("_", 4),
|
|
386
|
+
"point": (".", 2),
|
|
387
|
+
"pixel": (",", 1),
|
|
388
|
+
"tri_down": ("1", 4),
|
|
389
|
+
"tri_up": ("2", 4),
|
|
390
|
+
"tri_left": ("3", 4),
|
|
391
|
+
"tri_right": ("4", 4),
|
|
392
|
+
"octagon": ("8", 4),
|
|
393
|
+
"heart": (r"$\heartsuit$", 4),
|
|
394
|
+
"club": (r"$\clubsuit$", 4),
|
|
395
|
+
"spade": (r"$\spadesuit$", 4),
|
|
396
|
+
"diamond_suit": (r"$\diamondsuit$", 4),
|
|
397
|
+
"none": ("", 0),
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
patterns = {
|
|
401
|
+
"none": "",
|
|
402
|
+
"diagonal": "///",
|
|
403
|
+
"back_diagonal": "\\\\\\",
|
|
404
|
+
"cross": "xxx",
|
|
405
|
+
"plus": "+++",
|
|
406
|
+
"dots": "...",
|
|
407
|
+
"circles": "ooo",
|
|
408
|
+
"stars": "***",
|
|
409
|
+
"horizontal": "---",
|
|
410
|
+
"vertical": "|||",
|
|
411
|
+
"grid": "++++",
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def available_patterns() -> tuple[str, ...]:
|
|
416
|
+
"""Return built-in bar pattern preset names."""
|
|
417
|
+
return tuple(patterns)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
colors = {
|
|
421
|
+
"blue": "#0173B2",
|
|
422
|
+
"orange": "#DE8F05",
|
|
423
|
+
"green": "#029E73",
|
|
424
|
+
"purple": "#CC78BC",
|
|
425
|
+
"brown": "#CA9161",
|
|
426
|
+
"yellow": "#ECE133",
|
|
427
|
+
"sky_blue": "#56B4E9",
|
|
428
|
+
"gray": "#949494",
|
|
429
|
+
"red": "#D55E00",
|
|
430
|
+
"pink": "#CC79A7",
|
|
431
|
+
"teal": "#009E73",
|
|
432
|
+
"olive": "#808000",
|
|
433
|
+
"navy": "#0072B2",
|
|
434
|
+
"maroon": "#800000",
|
|
435
|
+
"lime": "#00FF00",
|
|
436
|
+
"cyan": "#00FFFF",
|
|
437
|
+
"magenta": "#FF00FF",
|
|
438
|
+
"dark_gray": "#404040",
|
|
439
|
+
"light_gray": "#D3D3D3",
|
|
440
|
+
}
|