jarvisplot 1.0.1__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.
- jarvisplot/Figure/adapters.py +773 -0
- jarvisplot/Figure/cards/std_axes_adapter_config.json +23 -0
- jarvisplot/Figure/data_pipelines.py +87 -0
- jarvisplot/Figure/figure.py +1573 -0
- jarvisplot/Figure/helper.py +217 -0
- jarvisplot/Figure/load_data.py +252 -0
- jarvisplot/__init__.py +0 -0
- jarvisplot/cards/a4paper/1x1/ternary.json +6 -0
- jarvisplot/cards/a4paper/2x1/rect.json +106 -0
- jarvisplot/cards/a4paper/2x1/rect5x1.json +344 -0
- jarvisplot/cards/a4paper/2x1/rect_cmap.json +181 -0
- jarvisplot/cards/a4paper/2x1/ternary.json +139 -0
- jarvisplot/cards/a4paper/2x1/ternary_cmap.json +189 -0
- jarvisplot/cards/a4paper/4x1/rect.json +106 -0
- jarvisplot/cards/a4paper/4x1/rect_cmap.json +174 -0
- jarvisplot/cards/a4paper/4x1/ternary.json +139 -0
- jarvisplot/cards/a4paper/4x1/ternary_cmap.json +189 -0
- jarvisplot/cards/args.json +50 -0
- jarvisplot/cards/colors/colormaps.json +140 -0
- jarvisplot/cards/default/output.json +11 -0
- jarvisplot/cards/gambit/1x1/ternary.json +6 -0
- jarvisplot/cards/gambit/2x1/rect_cmap.json +200 -0
- jarvisplot/cards/gambit/2x1/ternary.json +139 -0
- jarvisplot/cards/gambit/2x1/ternary_cmap.json +205 -0
- jarvisplot/cards/icons/JarvisHEP.png +0 -0
- jarvisplot/cards/icons/gambit.png +0 -0
- jarvisplot/cards/icons/gambit_small.png +0 -0
- jarvisplot/cards/style_preference.json +23 -0
- jarvisplot/cli.py +64 -0
- jarvisplot/client.py +6 -0
- jarvisplot/config.py +69 -0
- jarvisplot/core.py +237 -0
- jarvisplot/data_loader.py +441 -0
- jarvisplot/inner_func.py +162 -0
- jarvisplot/utils/__init__.py +0 -0
- jarvisplot/utils/cmaps.py +258 -0
- jarvisplot/utils/interpolator.py +377 -0
- jarvisplot-1.0.1.dist-info/METADATA +80 -0
- jarvisplot-1.0.1.dist-info/RECORD +42 -0
- jarvisplot-1.0.1.dist-info/WHEEL +5 -0
- jarvisplot-1.0.1.dist-info/entry_points.txt +2 -0
- jarvisplot-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1573 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from copy import deepcopy
|
|
3
|
+
from typing import Optional, Mapping
|
|
4
|
+
import numpy as np
|
|
5
|
+
import os, sys
|
|
6
|
+
from ..core import jppwd
|
|
7
|
+
import matplotlib.ticker as mticker
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import matplotlib as mpl
|
|
10
|
+
from types import MethodType
|
|
11
|
+
from .adapters import StdAxesAdapter, TernaryAxesAdapter
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import time
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Figure:
|
|
19
|
+
def _is_numbered_ax(self, name: str) -> bool:
|
|
20
|
+
"""Return True iff name matches ax<NUMBER>, e.g. ax1, ax2, ax10."""
|
|
21
|
+
return isinstance(name, str) and re.fullmatch(r"ax\d+", name) is not None
|
|
22
|
+
|
|
23
|
+
def _ensure_numbered_rect_axes(self, ax_name: str, kwgs: dict):
|
|
24
|
+
"""Create/configure a numbered rectangular axes (ax1, ax2, ...) using ax-style logic."""
|
|
25
|
+
if not self._is_numbered_ax(ax_name):
|
|
26
|
+
raise ValueError(f"Illegal dynamic axes name '{ax_name}'. Only ax<NUMBER> is allowed.")
|
|
27
|
+
|
|
28
|
+
# Reuse ax-style construction, but read frame config from frame[ax_name]
|
|
29
|
+
if ax_name not in self.axes.keys():
|
|
30
|
+
raw_ax = self.fig.add_axes(**kwgs)
|
|
31
|
+
if isinstance(kwgs, dict) and ("facecolor" in kwgs):
|
|
32
|
+
raw_ax.set_facecolor(kwgs['facecolor'])
|
|
33
|
+
adapter = StdAxesAdapter(raw_ax)
|
|
34
|
+
adapter._type = "rect"
|
|
35
|
+
adapter.layers = []
|
|
36
|
+
adapter._legend = self.frame.get(ax_name, {}).get("legend", False)
|
|
37
|
+
self.axes[ax_name] = adapter
|
|
38
|
+
adapter.status = 'configured'
|
|
39
|
+
|
|
40
|
+
ax_obj = self.axes[ax_name]
|
|
41
|
+
|
|
42
|
+
# ---- identical configuration path as ax, but keyed by ax_name ----
|
|
43
|
+
if self.frame.get(ax_name, {}).get("spines"):
|
|
44
|
+
if "color" in self.frame[ax_name]["spines"]:
|
|
45
|
+
for s in ax_obj.spines.values():
|
|
46
|
+
s.set_color(self.frame[ax_name]['spines']['color'])
|
|
47
|
+
|
|
48
|
+
# y scale
|
|
49
|
+
if self.frame.get(ax_name, {}).get("yscale", "").lower() == 'log':
|
|
50
|
+
ax_obj.set_yscale("log")
|
|
51
|
+
from matplotlib.ticker import LogLocator
|
|
52
|
+
ax_obj.yaxis.set_minor_locator(LogLocator(subs='auto'))
|
|
53
|
+
else:
|
|
54
|
+
from matplotlib.ticker import AutoMinorLocator
|
|
55
|
+
ax_obj.yaxis.set_minor_locator(AutoMinorLocator())
|
|
56
|
+
|
|
57
|
+
# x scale
|
|
58
|
+
if self.frame.get(ax_name, {}).get("xscale", "").lower() == 'log':
|
|
59
|
+
ax_obj.set_xscale("log")
|
|
60
|
+
from matplotlib.ticker import LogLocator
|
|
61
|
+
ax_obj.xaxis.set_minor_locator(LogLocator(subs='auto'))
|
|
62
|
+
else:
|
|
63
|
+
from matplotlib.ticker import AutoMinorLocator
|
|
64
|
+
ax_obj.xaxis.set_minor_locator(AutoMinorLocator())
|
|
65
|
+
|
|
66
|
+
def _safe_cast(v):
|
|
67
|
+
try:
|
|
68
|
+
return float(v)
|
|
69
|
+
except Exception:
|
|
70
|
+
return v
|
|
71
|
+
|
|
72
|
+
# text
|
|
73
|
+
if self.frame.get(ax_name, {}).get("text"):
|
|
74
|
+
for txt in self.frame[ax_name]["text"]:
|
|
75
|
+
if txt.get("transform", False):
|
|
76
|
+
txt.pop("transform")
|
|
77
|
+
ax_obj.text(**txt, transform=ax_obj.transAxes)
|
|
78
|
+
else:
|
|
79
|
+
ax_obj.text(**txt)
|
|
80
|
+
|
|
81
|
+
# limits
|
|
82
|
+
xlim = self.frame.get(ax_name, {}).get("xlim")
|
|
83
|
+
if xlim:
|
|
84
|
+
ax_obj.set_xlim(list(map(_safe_cast, xlim)))
|
|
85
|
+
|
|
86
|
+
ylim = self.frame.get(ax_name, {}).get("ylim")
|
|
87
|
+
if ylim:
|
|
88
|
+
ax_obj.set_ylim(list(map(_safe_cast, ylim)))
|
|
89
|
+
|
|
90
|
+
# labels
|
|
91
|
+
if self.frame.get(ax_name, {}).get('labels', {}).get("x"):
|
|
92
|
+
ax_obj.set_xlabel(self.frame[ax_name]['labels']['x'], **self.frame[ax_name]['labels']['xlabel'])
|
|
93
|
+
if self.frame.get(ax_name, {}).get('labels', {}).get("y"):
|
|
94
|
+
ax_obj.set_ylabel(self.frame[ax_name]['labels']['y'], **self.frame[ax_name]['labels']['ylabel'])
|
|
95
|
+
# ax_obj.yaxis.set_label_coords(
|
|
96
|
+
# self.frame[ax_name]['labels']['ylabel_coords']['x'],
|
|
97
|
+
# self.frame[ax_name]['labels']['ylabel_coords']['y']
|
|
98
|
+
# )
|
|
99
|
+
|
|
100
|
+
# ticks
|
|
101
|
+
ax_obj.tick_params(**self.frame.get(ax_name, {}).get('ticks', {}).get("both", {}))
|
|
102
|
+
ax_obj.tick_params(**self.frame.get(ax_name, {}).get('ticks', {}).get("major", {}))
|
|
103
|
+
ax_obj.tick_params(**self.frame.get(ax_name, {}).get('ticks', {}).get("minor", {}))
|
|
104
|
+
|
|
105
|
+
self._apply_axis_endpoints(ax_obj, self.frame.get(ax_name, {}).get('xaxis', {}), "x")
|
|
106
|
+
self._apply_axis_endpoints(ax_obj, self.frame.get(ax_name, {}).get('yaxis', {}), "y")
|
|
107
|
+
|
|
108
|
+
# finalize
|
|
109
|
+
if getattr(ax_obj, 'needs_finalize', True) and hasattr(ax_obj, 'finalize'):
|
|
110
|
+
try:
|
|
111
|
+
ax_obj.finalize()
|
|
112
|
+
except Exception as e:
|
|
113
|
+
if self.logger:
|
|
114
|
+
self.logger.warning(f"Finalize failed on axes '{ax_name}': {e}")
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
self.logger.debug(f"Loaded numbered rectangle axes -> {ax_name}")
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
return ax_obj
|
|
122
|
+
def _has_manual_ticks(self, ax_key: str, which: str) -> bool:
|
|
123
|
+
"""Return True if YAML provides manual tick positions for given axis."""
|
|
124
|
+
try:
|
|
125
|
+
if ax_key == 'ax':
|
|
126
|
+
ticks_cfg = self.frame.get('ax', {}).get('ticks', {})
|
|
127
|
+
elif ax_key == 'axc':
|
|
128
|
+
ticks_cfg = self.frame.get('axc', {}).get('ticks', {})
|
|
129
|
+
else:
|
|
130
|
+
return False
|
|
131
|
+
node = ticks_cfg.get(which, {})
|
|
132
|
+
return isinstance(node, dict) and ((node.get('positions') is not None) or (node.get('pos') is not None))
|
|
133
|
+
except Exception:
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
def _apply_axis_endpoints(self, ax_obj, axis_cfg: dict, which: str):
|
|
137
|
+
"""
|
|
138
|
+
which: 'x' or 'y'
|
|
139
|
+
axis_cfg: self.frame['ax'].get('xaxis', {}) / 'yaxis'
|
|
140
|
+
"""
|
|
141
|
+
if not isinstance(axis_cfg, dict):
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
# Resolve underlying Matplotlib Axes (StdAxesAdapter or plain Axes)
|
|
145
|
+
target = ax_obj.ax if hasattr(ax_obj, "ax") else ax_obj
|
|
146
|
+
|
|
147
|
+
if which == 'x':
|
|
148
|
+
ticks = target.xaxis.get_major_ticks()
|
|
149
|
+
locs = target.xaxis.get_majorticklocs()
|
|
150
|
+
else:
|
|
151
|
+
ticks = target.yaxis.get_major_ticks()
|
|
152
|
+
locs = target.yaxis.get_majorticklocs()
|
|
153
|
+
if not ticks:
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
# Get axis limits for boundary check
|
|
157
|
+
if which == 'x':
|
|
158
|
+
lim0, lim1 = target.get_xlim()
|
|
159
|
+
else:
|
|
160
|
+
lim0, lim1 = target.get_ylim()
|
|
161
|
+
|
|
162
|
+
min_cfg = axis_cfg.get("min_endpoints", {})
|
|
163
|
+
max_cfg = axis_cfg.get("max_endpoints", {})
|
|
164
|
+
width = abs(lim0 - lim1)
|
|
165
|
+
|
|
166
|
+
# 第一个 tick = min 端点
|
|
167
|
+
t0 = ticks[0]
|
|
168
|
+
# Check whether first tick is at the lower boundary
|
|
169
|
+
t0_loc = locs[0]
|
|
170
|
+
|
|
171
|
+
if abs(t0_loc - lim0) < 1e-3 * width:
|
|
172
|
+
if min_cfg.get("tick") is False:
|
|
173
|
+
t0.tick1line.set_visible(False)
|
|
174
|
+
t0.tick2line.set_visible(False)
|
|
175
|
+
if min_cfg.get("label") is False:
|
|
176
|
+
t0.label1.set_visible(False)
|
|
177
|
+
t0.label2.set_visible(False)
|
|
178
|
+
|
|
179
|
+
# 最后一个 tick = max 端点
|
|
180
|
+
t1 = ticks[-1]
|
|
181
|
+
# Check whether last tick is at the upper boundary
|
|
182
|
+
t1_loc = locs[-1]
|
|
183
|
+
|
|
184
|
+
if abs(t1_loc - lim1) < 1e-3 * width:
|
|
185
|
+
if max_cfg.get("tick") is False:
|
|
186
|
+
t1.tick1line.set_visible(False)
|
|
187
|
+
t1.tick2line.set_visible(False)
|
|
188
|
+
if max_cfg.get("label") is False:
|
|
189
|
+
t1.label1.set_visible(False)
|
|
190
|
+
t1.label2.set_visible(False)
|
|
191
|
+
|
|
192
|
+
def _apply_auto_ticks(self, ax_obj, which: str):
|
|
193
|
+
"""Lightweight auto-tick post-processing at finalize stage.
|
|
194
|
+
|
|
195
|
+
Goals:
|
|
196
|
+
- Never print/debug here.
|
|
197
|
+
- X: rotate long labels a bit.
|
|
198
|
+
- Y: if log-like scale -> keep log spacing and use compact decimals for decades in [1e-2, 1e2],
|
|
199
|
+
otherwise defer to Matplotlib's LogFormatter.
|
|
200
|
+
if linear scale -> use ScalarFormatter with sci notation, but never touch log formatters.
|
|
201
|
+
"""
|
|
202
|
+
target = ax_obj.ax if hasattr(ax_obj, "ax") else ax_obj
|
|
203
|
+
axis = target.xaxis if which == 'x' else target.yaxis
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
# Ensure ticks/labels exist
|
|
207
|
+
labels = axis.get_ticklabels()
|
|
208
|
+
|
|
209
|
+
# --- X axis formatting (match Y axis behavior)
|
|
210
|
+
if which == 'x':
|
|
211
|
+
try:
|
|
212
|
+
xscale = target.get_xscale()
|
|
213
|
+
except Exception:
|
|
214
|
+
xscale = None
|
|
215
|
+
|
|
216
|
+
# only touch linear x-axis
|
|
217
|
+
if xscale not in ('log', 'symlog', 'logit'):
|
|
218
|
+
fmt = mticker.ScalarFormatter(useMathText=True)
|
|
219
|
+
# Do not use sci/offset for small exponents like 10^-1; reserve for <=1e-2 or >=1e4
|
|
220
|
+
fmt.set_powerlimits((-2, 4))
|
|
221
|
+
axis.set_major_formatter(fmt)
|
|
222
|
+
try:
|
|
223
|
+
target.ticklabel_format(style='sci', axis='x', scilimits=(-2, 4))
|
|
224
|
+
except Exception:
|
|
225
|
+
pass
|
|
226
|
+
try:
|
|
227
|
+
axis.set_offset_position('bottom')
|
|
228
|
+
except Exception:
|
|
229
|
+
pass
|
|
230
|
+
mpl.rcParams['axes.formatter.useoffset'] = True
|
|
231
|
+
|
|
232
|
+
# Ensure offset text is rendered and sized like tick labels (avoid huge ×10^n)
|
|
233
|
+
try:
|
|
234
|
+
target.figure.canvas.draw_idle()
|
|
235
|
+
tl = axis.get_ticklabels()
|
|
236
|
+
if tl:
|
|
237
|
+
axis.offsetText.set_fontsize(tl[0].get_size() * 0.8)
|
|
238
|
+
# Place the offset near the left of the x-axis (like y-axis), and nudge up/right
|
|
239
|
+
axis.offsetText.set_horizontalalignment('left')
|
|
240
|
+
# axis.offsetText.set_verticalalignment('top')
|
|
241
|
+
axis.offsetText.set_x(1.02) # axes fraction
|
|
242
|
+
# axis.offsetText.set_y(1.08) # small upward nudge (axes fraction)
|
|
243
|
+
|
|
244
|
+
except Exception:
|
|
245
|
+
pass
|
|
246
|
+
|
|
247
|
+
# optional: rotate long labels
|
|
248
|
+
try:
|
|
249
|
+
long = any((t is not None) and (len(t) > 6) for t in (l.get_text() for l in labels))
|
|
250
|
+
except Exception:
|
|
251
|
+
long = False
|
|
252
|
+
# if long:
|
|
253
|
+
# target.tick_params(axis='x', labelrotation=35)
|
|
254
|
+
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
# --- Y axis formatting
|
|
258
|
+
if which != 'y':
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
# Detect scale
|
|
262
|
+
try:
|
|
263
|
+
yscale = target.get_yscale()
|
|
264
|
+
except Exception:
|
|
265
|
+
yscale = None
|
|
266
|
+
|
|
267
|
+
# 1) Log-like y-axis: compact decimals in range, otherwise default log formatter
|
|
268
|
+
if yscale in ('log', 'symlog', 'logit'):
|
|
269
|
+
from matplotlib.ticker import LogFormatterMathtext, FuncFormatter
|
|
270
|
+
|
|
271
|
+
base = LogFormatterMathtext()
|
|
272
|
+
lo, hi = 1e-2, 1e2 # compact decimal range
|
|
273
|
+
|
|
274
|
+
def _fmt(val, pos=None):
|
|
275
|
+
if val is None or val <= 0:
|
|
276
|
+
return ""
|
|
277
|
+
try:
|
|
278
|
+
exp = np.log10(val)
|
|
279
|
+
except Exception:
|
|
280
|
+
return ""
|
|
281
|
+
# Only label exact decades
|
|
282
|
+
if (not np.isfinite(exp)) or (not np.isclose(exp, round(exp))):
|
|
283
|
+
return ""
|
|
284
|
+
|
|
285
|
+
if lo <= val <= hi:
|
|
286
|
+
e = int(round(exp))
|
|
287
|
+
if e >= 0:
|
|
288
|
+
return f"{int(10**e)}"
|
|
289
|
+
# e=-1 -> 0.1 (1 dp), e=-2 -> 0.01 (2 dp)
|
|
290
|
+
return f"{10**e:.{abs(e)}f}"
|
|
291
|
+
|
|
292
|
+
# outside compact range: defer to Matplotlib
|
|
293
|
+
return base(val, pos)
|
|
294
|
+
|
|
295
|
+
axis.set_major_formatter(FuncFormatter(_fmt))
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
# 2) Linear y-axis: ScalarFormatter sci notation
|
|
299
|
+
fmt = mticker.ScalarFormatter(useMathText=True)
|
|
300
|
+
# Do not use sci/offset for small exponents like 10^-1; reserve for <=1e-2 or >=1e4
|
|
301
|
+
fmt.set_powerlimits((-2, 4))
|
|
302
|
+
axis.set_major_formatter(fmt)
|
|
303
|
+
try:
|
|
304
|
+
target.ticklabel_format(style='sci', axis='y', scilimits=(-2, 4))
|
|
305
|
+
except Exception:
|
|
306
|
+
pass
|
|
307
|
+
try:
|
|
308
|
+
axis.set_offset_position('left')
|
|
309
|
+
except Exception:
|
|
310
|
+
pass
|
|
311
|
+
mpl.rcParams['axes.formatter.useoffset'] = True
|
|
312
|
+
try:
|
|
313
|
+
target.figure.canvas.draw_idle()
|
|
314
|
+
# shrink offset text a bit
|
|
315
|
+
try:
|
|
316
|
+
tl = axis.get_ticklabels()
|
|
317
|
+
if tl:
|
|
318
|
+
axis.offsetText.set_fontsize(tl[0].get_size() * 0.8)
|
|
319
|
+
except Exception:
|
|
320
|
+
pass
|
|
321
|
+
except Exception:
|
|
322
|
+
pass
|
|
323
|
+
|
|
324
|
+
except Exception:
|
|
325
|
+
# Always fail silently here; tick post-processing must never crash plotting.
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
def __init__(self, info: Optional[Mapping] = None):
|
|
329
|
+
self.t0 = time.perf_counter()
|
|
330
|
+
|
|
331
|
+
# internal state
|
|
332
|
+
self._name: Optional[str] = None
|
|
333
|
+
self._jpstyles: Optional[dict] = None
|
|
334
|
+
self._style: Optional[dict] = {}
|
|
335
|
+
self.print = False
|
|
336
|
+
self.mode = "Jarvis"
|
|
337
|
+
# self._jpdatas: Optional[list] = []
|
|
338
|
+
self._logger = None
|
|
339
|
+
self._frame = {}
|
|
340
|
+
self._outinfo = {}
|
|
341
|
+
self._yaml_dir = None # directory of the active YAML file (used to resolve relative paths)
|
|
342
|
+
self.axes = {}
|
|
343
|
+
self.debug = False
|
|
344
|
+
# self._axtri = None
|
|
345
|
+
self._layers = {}
|
|
346
|
+
self._ctx = None
|
|
347
|
+
# allow optional initialization from a dict
|
|
348
|
+
if info:
|
|
349
|
+
self.from_dict(info)
|
|
350
|
+
|
|
351
|
+
# --- name property ---
|
|
352
|
+
@property
|
|
353
|
+
def name(self) -> Optional[str]:
|
|
354
|
+
return self._name
|
|
355
|
+
|
|
356
|
+
@name.setter
|
|
357
|
+
def name(self, value: Optional[str]) -> None:
|
|
358
|
+
"""Set figure name as a string (or None)."""
|
|
359
|
+
if value is None:
|
|
360
|
+
self._name = None
|
|
361
|
+
return
|
|
362
|
+
if not isinstance(value, str):
|
|
363
|
+
raise TypeError("Figure.name must be a string or None")
|
|
364
|
+
self._name = value
|
|
365
|
+
|
|
366
|
+
@property
|
|
367
|
+
def config(self):
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
@config.setter
|
|
371
|
+
def config(self, infos):
|
|
372
|
+
self.dir = self.load_path(infos['output'].get("dir", "."), base_dir=self._yaml_dir)
|
|
373
|
+
if not os.path.exists(self.dir):
|
|
374
|
+
os.makedirs(self.dir)
|
|
375
|
+
self.fmts = infos['output'].get("formats", ['png'])
|
|
376
|
+
self.dpi = infos['output'].get('dpi', 600)
|
|
377
|
+
|
|
378
|
+
@property
|
|
379
|
+
def jpstyles(self) -> Optional[dict]:
|
|
380
|
+
return self._jpstyles
|
|
381
|
+
|
|
382
|
+
@jpstyles.setter
|
|
383
|
+
def jpstyles(self, value) -> None:
|
|
384
|
+
self._jpstyles = value
|
|
385
|
+
|
|
386
|
+
@property
|
|
387
|
+
def logger(self):
|
|
388
|
+
return self._logger
|
|
389
|
+
|
|
390
|
+
@logger.setter
|
|
391
|
+
def logger(self, value):
|
|
392
|
+
if value is not None:
|
|
393
|
+
self._logger = value
|
|
394
|
+
|
|
395
|
+
@property
|
|
396
|
+
def frame(self):
|
|
397
|
+
return self._frame
|
|
398
|
+
|
|
399
|
+
@frame.setter
|
|
400
|
+
def frame(self, value) -> None:
|
|
401
|
+
if self._frame is None:
|
|
402
|
+
self._frame = value
|
|
403
|
+
else:
|
|
404
|
+
from deepmerge import always_merger
|
|
405
|
+
self._frame = always_merger.merge(self._frame, value)
|
|
406
|
+
|
|
407
|
+
@property
|
|
408
|
+
def style(self) -> Optional[dict]:
|
|
409
|
+
return self._style
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@style.setter
|
|
413
|
+
def style(self, value) -> None:
|
|
414
|
+
from copy import deepcopy
|
|
415
|
+
if len(value) == 2:
|
|
416
|
+
self._frame = deepcopy(self.jpstyles[value[0]][value[1]]['Frame'])
|
|
417
|
+
self._style = deepcopy(self.jpstyles[value[0]][value[1]]['Style'])
|
|
418
|
+
self.logger.debug("Style: [{} : {}] used for figure -> {}".format(value[0], value[1], self.name))
|
|
419
|
+
elif len(value) == 1:
|
|
420
|
+
self._frame = deepcopy(self.jpstyles[value[0]]["default"]['Frame'])
|
|
421
|
+
self._style = deepcopy(self.jpstyles[value[0]]["default"]['Style'])
|
|
422
|
+
self.logger.debug("Style: [{} : {}] used for figure -> {}".format(value[0], "default", self.name))
|
|
423
|
+
else:
|
|
424
|
+
self.logger.error("Undefined style -> {}".format(value))
|
|
425
|
+
raise TypeError
|
|
426
|
+
|
|
427
|
+
@property
|
|
428
|
+
def context(self):
|
|
429
|
+
return self._ctx
|
|
430
|
+
|
|
431
|
+
@context.setter
|
|
432
|
+
def context(self, value):
|
|
433
|
+
self._ctx = value # 期望是 DataContext
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@property
|
|
439
|
+
def layers(self):
|
|
440
|
+
return self._layers
|
|
441
|
+
|
|
442
|
+
@layers.setter
|
|
443
|
+
def layers(self, infos):
|
|
444
|
+
for layer in infos:
|
|
445
|
+
info = {}
|
|
446
|
+
ax = self.axes[layer['axes']]
|
|
447
|
+
info['name'] = layer.get("name", "")
|
|
448
|
+
info['data'] = self.load_layer_data(layer)
|
|
449
|
+
info['combine'] = layer.get("combine", "concat")
|
|
450
|
+
if layer.get("share_data") and info['data'] is not None:
|
|
451
|
+
from copy import deepcopy
|
|
452
|
+
self.context.update(layer["share_data"], deepcopy(info['data']))
|
|
453
|
+
info['coor'] = layer['coordinates']
|
|
454
|
+
info['method'] = layer.get("method", "scatter")
|
|
455
|
+
info['style'] = layer.get("style", {})
|
|
456
|
+
ax.layers.append(info)
|
|
457
|
+
self.logger.debug("Successfully loaded layer -> {}".format(info["name"]))
|
|
458
|
+
|
|
459
|
+
def load_layer_data(self, layer):
|
|
460
|
+
lyinfo = layer.get("data", False)
|
|
461
|
+
lycomb = layer.get("combine", "concat")
|
|
462
|
+
if lyinfo:
|
|
463
|
+
if lycomb == "concat":
|
|
464
|
+
dts = []
|
|
465
|
+
for ds in lyinfo:
|
|
466
|
+
src = ds.get('source')
|
|
467
|
+
self.logger.debug("Loading layer data source -> {}".format(src))
|
|
468
|
+
if src and self.context:
|
|
469
|
+
from copy import deepcopy
|
|
470
|
+
if isinstance(src, (list, tuple)):
|
|
471
|
+
self.logger.debug("loading datasets in list mode")
|
|
472
|
+
dsrc = []
|
|
473
|
+
for srcitem in src:
|
|
474
|
+
self.logger.debug("loading layer data source item -> {}".format(srcitem))
|
|
475
|
+
dt = deepcopy(self.context.get(srcitem))
|
|
476
|
+
dsrc.append(dt)
|
|
477
|
+
dfsrcs = pd.concat(dsrc, ignore_index=False)
|
|
478
|
+
dt = self.load_bool_df(dfsrcs, ds.get("transform", None))
|
|
479
|
+
dts.append(dt)
|
|
480
|
+
elif self.context.get(src) is not None:
|
|
481
|
+
dt = deepcopy(self.context.get(src))
|
|
482
|
+
dt = self.load_bool_df(dt, ds.get("transform", None))
|
|
483
|
+
dts.append(dt)
|
|
484
|
+
else:
|
|
485
|
+
self.logger.error("DataSet -> {} not specified".format(src))
|
|
486
|
+
if len(dts) == 0:
|
|
487
|
+
return None
|
|
488
|
+
try:
|
|
489
|
+
return pd.concat(dts, ignore_index=False)
|
|
490
|
+
except Exception:
|
|
491
|
+
return dts[0]
|
|
492
|
+
elif lycomb == "seperate":
|
|
493
|
+
dts = {}
|
|
494
|
+
for ds in lyinfo:
|
|
495
|
+
src = ds.get("source")
|
|
496
|
+
label = ds.get("label")
|
|
497
|
+
self.logger.debug("Loading layer data source -> {}".format(src))
|
|
498
|
+
if src and self.context and self.context.get(src) is not None:
|
|
499
|
+
from copy import deepcopy
|
|
500
|
+
dt = deepcopy(self.context.get(src))
|
|
501
|
+
dt = self.load_bool_df(dt, ds.get("transform", None))
|
|
502
|
+
dts[label] = dt
|
|
503
|
+
else:
|
|
504
|
+
self.logger.error("DataSet -> {} not specified".format(src))
|
|
505
|
+
if len(dts) == 0:
|
|
506
|
+
return None
|
|
507
|
+
return dts
|
|
508
|
+
# Unsupported lyinfo shape -> no data
|
|
509
|
+
return None
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def load_bool_df(self, df, transform):
|
|
513
|
+
if transform is None:
|
|
514
|
+
return df
|
|
515
|
+
elif not isinstance(transform, list):
|
|
516
|
+
self.logger.error("illegal transform format, list type needed ->".format(json.dump(transform)))
|
|
517
|
+
return df
|
|
518
|
+
else:
|
|
519
|
+
for trans in transform:
|
|
520
|
+
self.logger.debug("Applying the transform ... ")
|
|
521
|
+
if "filter" in trans.keys():
|
|
522
|
+
self.logger.debug("Before filtering -> {}".format(df.shape))
|
|
523
|
+
from .load_data import filter
|
|
524
|
+
df = filter(df, trans['filter'], self.logger)
|
|
525
|
+
self.logger.debug("After filtering -> {}".format(df.shape))
|
|
526
|
+
elif "profile" in trans.keys():
|
|
527
|
+
from .load_data import profiling
|
|
528
|
+
df = profiling(df, trans['profile'], self.logger)
|
|
529
|
+
self.logger.debug("After profiling -> {}".format(df.shape))
|
|
530
|
+
elif "sortby" in trans.keys():
|
|
531
|
+
from .load_data import sortby
|
|
532
|
+
df = sortby(df, trans['sortby'], self.logger)
|
|
533
|
+
self.logger.debug("After sortby -> {}".format(df.shape))
|
|
534
|
+
elif "add_column" in trans.keys():
|
|
535
|
+
from .load_data import addcolumn
|
|
536
|
+
df = addcolumn(df, trans['add_column'], self.logger)
|
|
537
|
+
self.logger.debug("After Add-column -> {}".format(df.shape))
|
|
538
|
+
|
|
539
|
+
return df
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
@property
|
|
544
|
+
def axlogo(self):
|
|
545
|
+
return self.axes['axlogo']
|
|
546
|
+
|
|
547
|
+
@axlogo.setter
|
|
548
|
+
def axlogo(self, kwgs):
|
|
549
|
+
if "axlogo" not in self.axes.keys():
|
|
550
|
+
axtp = self.fig.add_axes(**kwgs)
|
|
551
|
+
axtp.set_zorder(200)
|
|
552
|
+
axtp.patch.set_alpha(0)
|
|
553
|
+
|
|
554
|
+
self.axes['axlogo'] = axtp
|
|
555
|
+
self.axes['axlogo'].needs_finalize = False
|
|
556
|
+
self.axes['axlogo'].status = 'finalized'
|
|
557
|
+
|
|
558
|
+
self.axlogo.layers = []
|
|
559
|
+
jhlogo = self.load_path(self.frame['axlogo']['file'])
|
|
560
|
+
from PIL import Image
|
|
561
|
+
with Image.open(jhlogo) as image:
|
|
562
|
+
arr = np.asarray(image.convert("RGBA"))
|
|
563
|
+
self.axlogo.imshow(arr)
|
|
564
|
+
if self.frame['axlogo'].get("text"):
|
|
565
|
+
for txt in self.frame['axlogo']['text']:
|
|
566
|
+
self.axlogo.text(**txt, transform=self.axlogo.transAxes)
|
|
567
|
+
# else:
|
|
568
|
+
# self.axlogo.text(1., 0., "Jarvis-HEP", ha="left", va='bottom', color="black", fontfamily="Fira code", fontsize="x-small", fontstyle="normal", fontweight="bold", transform=self.axlogo.transAxes)
|
|
569
|
+
# self.axlogo.text(1., 0.9, " Powered by", ha="left", va='top', color="black", fontfamily="Fira code", fontsize="xx-small", fontstyle="normal", fontweight="normal", transform=self.axlogo.transAxes)
|
|
570
|
+
|
|
571
|
+
@property
|
|
572
|
+
def axtri(self):
|
|
573
|
+
return self.axes['axtri']
|
|
574
|
+
|
|
575
|
+
@axtri.setter
|
|
576
|
+
def axtri(self, kwgs):
|
|
577
|
+
if "axtri" not in self.axes.keys():
|
|
578
|
+
facecolor = kwgs.pop("facecolor", None)
|
|
579
|
+
raw_ax = self.fig.add_axes(**kwgs)
|
|
580
|
+
# Booking Ternary Plot Clip_path
|
|
581
|
+
from matplotlib.path import Path
|
|
582
|
+
vertices = [(0.0, 0.0), (1.0, 0.0), (0.5, 1.0), (0.0, 0.0)]
|
|
583
|
+
codes = [Path.MOVETO, Path.LINETO, Path.LINETO, Path.CLOSEPOLY]
|
|
584
|
+
raw_ax._clip_path = Path(vertices, codes)
|
|
585
|
+
self._install_tri_auto_clip(raw_ax)
|
|
586
|
+
|
|
587
|
+
# Keep rect axes patch transparent; ternary background is handled by adapter.
|
|
588
|
+
raw_ax.patch.set_alpha(0)
|
|
589
|
+
|
|
590
|
+
adapter = TernaryAxesAdapter(
|
|
591
|
+
raw_ax,
|
|
592
|
+
defaults={"facecolor": facecolor} if facecolor is not None else None,
|
|
593
|
+
clip_path=Path(vertices, codes) # 用 path 做 clip,transform 使用 ax.transData 已在适配器里处理
|
|
594
|
+
)
|
|
595
|
+
adapter._type = 'tri'
|
|
596
|
+
adapter._legend = False
|
|
597
|
+
adapter.layers = []
|
|
598
|
+
adapter.status = 'configured'
|
|
599
|
+
self.axes["axtri"] = adapter
|
|
600
|
+
|
|
601
|
+
self.axtri.plot(
|
|
602
|
+
x=[0.5, 1.0, 0.5, 0.0, 0.5],
|
|
603
|
+
y=[0.0, 0.0, 1.0, 0.0, 0.0],
|
|
604
|
+
**self.frame['axtri']['frame'])
|
|
605
|
+
|
|
606
|
+
arr = np.arange(0., 1.0, self.frame['axtri']['grid']['sep'])
|
|
607
|
+
seps = np.empty(len(arr) * 2 - 1)
|
|
608
|
+
seps[0::2] = arr
|
|
609
|
+
seps[1::2] = np.nan
|
|
610
|
+
|
|
611
|
+
x0 = seps
|
|
612
|
+
x1 = 0.5 * seps
|
|
613
|
+
x2 = 0.5 + 0.5 * seps
|
|
614
|
+
x3 = 1.0 - 0.5 * seps
|
|
615
|
+
|
|
616
|
+
y0 = 0.0 * seps
|
|
617
|
+
y1 = seps
|
|
618
|
+
y2 = 1 - seps
|
|
619
|
+
|
|
620
|
+
# Major ticks
|
|
621
|
+
arrt = np.arange(0., 1.0001, self.frame['axtri']['ticks']['majorsep'])
|
|
622
|
+
sept = np.empty(len(arrt) * 2 - 1)
|
|
623
|
+
sept[0::2] = arrt
|
|
624
|
+
sept[1::2] = np.nan
|
|
625
|
+
matl = self.frame['axtri']['ticks']['majorlength']
|
|
626
|
+
|
|
627
|
+
txb0 = sept
|
|
628
|
+
txb1 = sept - 0.5 * matl
|
|
629
|
+
tyb0 = 0.0 * sept
|
|
630
|
+
tyb1 = 0.0 * sept - matl
|
|
631
|
+
|
|
632
|
+
txl0 = 0.5 * sept
|
|
633
|
+
txl1 = 0.5 * sept - 0.5 * matl
|
|
634
|
+
tyl0 = sept
|
|
635
|
+
tyl1 = sept + matl
|
|
636
|
+
|
|
637
|
+
txr0 = 1.0 - 0.5 * sept
|
|
638
|
+
txr1 = 1.0 - 0.5 * sept + matl
|
|
639
|
+
tyr0 = sept
|
|
640
|
+
tyr1 = sept
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
# Minor ticks
|
|
644
|
+
arrm = np.arange(0., 1.0001, self.frame['axtri']['ticks']['minorsep'])
|
|
645
|
+
sepm = np.empty(len(arrm) * 2 - 1)
|
|
646
|
+
sepm[0::2] = arrm
|
|
647
|
+
sepm[1::2] = np.nan
|
|
648
|
+
matm = self.frame['axtri']['ticks']['minorlength']
|
|
649
|
+
|
|
650
|
+
mxb0 = sepm
|
|
651
|
+
mxb1 = sepm - 0.5 * matm
|
|
652
|
+
myb0 = 0.0 * sepm
|
|
653
|
+
myb1 = 0.0 * sepm - matm
|
|
654
|
+
|
|
655
|
+
mxl0 = 0.5 * sepm
|
|
656
|
+
mxl1 = 0.5 * sepm - 0.5 * matm
|
|
657
|
+
myl0 = sepm
|
|
658
|
+
myl1 = sepm + matm
|
|
659
|
+
|
|
660
|
+
mxr0 = 1.0 - 0.5 * sepm
|
|
661
|
+
mxr1 = 1.0 - 0.5 * sepm + matm
|
|
662
|
+
myr0 = sepm
|
|
663
|
+
myr1 = sepm
|
|
664
|
+
|
|
665
|
+
ticklabels = [f"{v*100:.0f}%" for v in arrt]
|
|
666
|
+
# Ticks label positions
|
|
667
|
+
lbx = arrt - 0.7 * matl
|
|
668
|
+
lby = 0.0 * arrt - 1.4 * matl
|
|
669
|
+
|
|
670
|
+
llx = (1.0 - arrt) / 2.0 - 1.5 * matl
|
|
671
|
+
lly = 1 - arrt + 2.0 * matl
|
|
672
|
+
|
|
673
|
+
lrx = 1.0 - arrt / 2.0 + 1.3 * matl
|
|
674
|
+
lry = arrt
|
|
675
|
+
|
|
676
|
+
# Bottom Axis
|
|
677
|
+
gridbx = np.array([val for pair in zip(x0, x1) for val in pair])
|
|
678
|
+
gridby = np.array([val for pair in zip(y0, y1) for val in pair])
|
|
679
|
+
tickbx = np.array([val for pair in zip(txb0, txb1) for val in pair])
|
|
680
|
+
tickby = np.array([val for pair in zip(tyb0, tyb1) for val in pair])
|
|
681
|
+
minorbx = np.array([val for pair in zip(mxb0, mxb1) for val in pair])
|
|
682
|
+
minorby = np.array([val for pair in zip(myb0, myb1) for val in pair])
|
|
683
|
+
|
|
684
|
+
# Left Axis
|
|
685
|
+
gridlx = np.array([val for pair in zip(x0, x2) for val in pair])
|
|
686
|
+
gridly = np.array([val for pair in zip(y0, y2) for val in pair])
|
|
687
|
+
ticklx = np.array([val for pair in zip(txl0, txl1) for val in pair])
|
|
688
|
+
tickly = np.array([val for pair in zip(tyl0, tyl1) for val in pair])
|
|
689
|
+
minorlx = np.array([val for pair in zip(mxl0, mxl1) for val in pair])
|
|
690
|
+
minorly = np.array([val for pair in zip(myl0, myl1) for val in pair])
|
|
691
|
+
|
|
692
|
+
# Right Axis
|
|
693
|
+
gridrx = np.array([val for pair in zip(x1, x3) for val in pair])
|
|
694
|
+
gridry = np.array([val for pair in zip(y1, y1) for val in pair])
|
|
695
|
+
tickrx = np.array([val for pair in zip(txr0, txr1) for val in pair])
|
|
696
|
+
tickry = np.array([val for pair in zip(tyr0, tyr1) for val in pair])
|
|
697
|
+
minorrx = np.array([val for pair in zip(mxr0, mxr1) for val in pair])
|
|
698
|
+
minorry = np.array([val for pair in zip(myr0, myr1) for val in pair])
|
|
699
|
+
|
|
700
|
+
# Grids // Major Ticks // Minor Ticks // Tick Lables
|
|
701
|
+
# Bottom Axis
|
|
702
|
+
|
|
703
|
+
self.axtri.plot(x=gridbx, y=gridby, **self.frame['axtri']['grid']['style'])
|
|
704
|
+
self.axtri.plot(x=tickbx, y=tickby, **self.frame['axtri']['ticks']['majorstyle'])
|
|
705
|
+
self.axtri.plot(x=minorbx, y=minorby, **self.frame['axtri']['ticks']['minorstyle'])
|
|
706
|
+
self.axtri.text(s=self.frame['axtri']['labels']['bottom'], **self.frame['axtri']['labels']['bottomstyle'])
|
|
707
|
+
for x, y, label in zip(lbx, lby, ticklabels):
|
|
708
|
+
self.axtri.text(x, y, label, **self.frame['axtri']['ticks']['bottomticklables'])
|
|
709
|
+
|
|
710
|
+
# Right Axis
|
|
711
|
+
self.axtri.plot(x=gridrx, y=gridry, **self.frame['axtri']['grid']['style'])
|
|
712
|
+
self.axtri.plot(x=tickrx, y=tickry, **self.frame['axtri']['ticks']['majorstyle'])
|
|
713
|
+
self.axtri.plot(x=minorrx, y=minorry, **self.frame['axtri']['ticks']['minorstyle'])
|
|
714
|
+
self.axtri.text(s=self.frame['axtri']['labels']['right'], **self.frame['axtri']['labels']['rightstyle'])
|
|
715
|
+
for x, y, label in zip(lrx, lry, ticklabels):
|
|
716
|
+
self.axtri.text(x, y, label, **self.frame['axtri']['ticks']['rightticklables'])
|
|
717
|
+
|
|
718
|
+
# Left Axis
|
|
719
|
+
self.axtri.plot(x=gridlx, y=gridly, **self.frame['axtri']['grid']['style'])
|
|
720
|
+
self.axtri.plot(x=ticklx, y=tickly, **self.frame['axtri']['ticks']['majorstyle'])
|
|
721
|
+
self.axtri.plot(x=minorlx, y=minorly, **self.frame['axtri']['ticks']['minorstyle'])
|
|
722
|
+
self.axtri.text(s=self.frame['axtri']['labels']['left'], **self.frame['axtri']['labels']['leftstyle'])
|
|
723
|
+
for x, y, label in zip(llx, lly, ticklabels):
|
|
724
|
+
self.axtri.text(x, y, label, **self.frame['axtri']['ticks']['leftticklables'])
|
|
725
|
+
|
|
726
|
+
if self.debug:
|
|
727
|
+
self.axtri.scatter(x=lbx, y=lby, s=1.0, marker='.', c="#FF42A1", clip_on=False)
|
|
728
|
+
self.axtri.scatter(x=lrx, y=lry, s=1.0, marker='.', c="#FF42A1", clip_on=False)
|
|
729
|
+
self.axtri.scatter(x=llx, y=lly, s=1.0, marker='.', c="#FF42A1", clip_on=False)
|
|
730
|
+
self.axtri.plot(x=[0., 0.75, 0.84], y=[0., 0.5, 0.56], marker=".", linestyle="-", lw=0.3, markersize=1, c="#FF42A1", clip_on=False)
|
|
731
|
+
self.axtri.plot(x=[0.5, 0.5, 0.5], y=[1., 0.0, -0.12], marker=".", linestyle="-", lw=0.3, markersize=1, c="#FF42A1", clip_on=False)
|
|
732
|
+
self.axtri.plot(x=[1., 0.25, 0.16], y=[0., 0.5, 0.56], marker=".", linestyle="-", lw=0.3, markersize=1, c="#FF42A1", clip_on=False)
|
|
733
|
+
|
|
734
|
+
@property
|
|
735
|
+
def axc(self):
|
|
736
|
+
return self.axes['axc']
|
|
737
|
+
|
|
738
|
+
@axc.setter
|
|
739
|
+
def axc(self, kwgs):
|
|
740
|
+
if "axc" not in self.axes.keys():
|
|
741
|
+
axc = self.fig.add_axes(**kwgs)
|
|
742
|
+
axc._cb = {
|
|
743
|
+
"mode": "auto", # auto|linear|log|diverging
|
|
744
|
+
"levels": None,
|
|
745
|
+
"label": None,
|
|
746
|
+
"vmin": None, "vmax": None,
|
|
747
|
+
"norm": None,
|
|
748
|
+
"used": False
|
|
749
|
+
}
|
|
750
|
+
self.axes["axc"] = axc
|
|
751
|
+
else:
|
|
752
|
+
if not self.axc._cb.get("used"):
|
|
753
|
+
return
|
|
754
|
+
|
|
755
|
+
# Build mappable for the colorbar
|
|
756
|
+
mappable = mpl.cm.ScalarMappable(
|
|
757
|
+
cmap=self.axc._cb.get("cmap") or mpl.rcParams.get("image.cmap", "rainbow"),
|
|
758
|
+
norm=self.axc._cb.get("norm")
|
|
759
|
+
)
|
|
760
|
+
mappable.set_array([])
|
|
761
|
+
if self.frame['axc'].get('orientation') != "horizontal":
|
|
762
|
+
cbar = self.fig.colorbar(mappable, cax=self.axc)
|
|
763
|
+
cbar.minorticks_on()
|
|
764
|
+
self.axc.set_ylim(self.axc._cb['vmin'], self.axc._cb['vmax'])
|
|
765
|
+
|
|
766
|
+
if str(self.axc._cb.get('mode', 'auto')).lower() == 'log':
|
|
767
|
+
from matplotlib.ticker import LogLocator
|
|
768
|
+
# Use default subs for log scale minor ticks
|
|
769
|
+
self.axc.yaxis.set_minor_locator(LogLocator(subs='auto'))
|
|
770
|
+
else:
|
|
771
|
+
from matplotlib.ticker import AutoMinorLocator
|
|
772
|
+
self.axc.yaxis.set_minor_locator(AutoMinorLocator())
|
|
773
|
+
|
|
774
|
+
self.axc.yaxis.set_ticks_position(self.frame['axc']['ticks']['ticks_position'])
|
|
775
|
+
self.axc.yaxis.set_label_position("right")
|
|
776
|
+
|
|
777
|
+
# Apply tick params (major/minor) as provided in frame config
|
|
778
|
+
self.axc.tick_params(**self.frame['axc']['ticks'].get('both', {}))
|
|
779
|
+
self.axc.tick_params(**self.frame['axc']['ticks'].get('major', {}))
|
|
780
|
+
self.axc.tick_params(**self.frame['axc']['ticks'].get('minor', {}))
|
|
781
|
+
|
|
782
|
+
self.axc.yaxis.set_ticks_position(self.frame['axc']['ticks']['ticks_position'])
|
|
783
|
+
self.axc.yaxis.set_label_position("right")
|
|
784
|
+
|
|
785
|
+
# Apply tick params (major/minor) as provided in frame config
|
|
786
|
+
self.axc.tick_params(**self.frame['axc']['ticks'].get('both', {}))
|
|
787
|
+
self.axc.tick_params(**self.frame['axc']['ticks'].get('major', {}))
|
|
788
|
+
self.axc.tick_params(**self.frame['axc']['ticks'].get('minor', {}))
|
|
789
|
+
self.axc.set_ylabel(**self.frame['axc'].get('label', {}))
|
|
790
|
+
if self.frame['axc'].get('ylabel_coords'):
|
|
791
|
+
self.axc.yaxis.set_label_coords(self.frame['axc']['ylabel_coords']['x'], self.frame['axc']['ylabel_coords']['y'])
|
|
792
|
+
# Apply manual ticks for colorbar (y-axis) at initialization if provided
|
|
793
|
+
cbar_ticks_cfg = self.frame.get('axc', {}).get('ticks', {}).get('y', {})
|
|
794
|
+
# self._apply_manual_ticks(self.axc, 'y', cbar_ticks_cfg)
|
|
795
|
+
self.logger.debug("Loaded colorbar axes -> axc")
|
|
796
|
+
# else:
|
|
797
|
+
# cbar = self.fig.colorbar(mappable, cax=self.axc, orientation="horizontal")
|
|
798
|
+
# cbar.minorticks_on()
|
|
799
|
+
# self.axc.xaxis.set_ticks_position(self.frame['axc']['ticks']['ticks_position'])
|
|
800
|
+
# self.axc.xaxis.set_label_position("top")
|
|
801
|
+
# self.axc.set_xlim(self.axc._cb['vmin'], self.axc._cb['vmax'])
|
|
802
|
+
|
|
803
|
+
# self.axc.set_xlabel(**self.frame['axc'].get("label", {}))
|
|
804
|
+
|
|
805
|
+
else:
|
|
806
|
+
# Horizontal colorbar: drive everything from x-axis
|
|
807
|
+
cbar = self.fig.colorbar(mappable, cax=self.axc, orientation="horizontal")
|
|
808
|
+
cbar.minorticks_on()
|
|
809
|
+
|
|
810
|
+
# Limits use xlim for horizontal bars
|
|
811
|
+
self.axc.set_xlim(self.axc._cb['vmin'], self.axc._cb['vmax'])
|
|
812
|
+
|
|
813
|
+
# Minor locator depends on mode
|
|
814
|
+
if str(self.axc._cb.get('mode', 'auto')).lower() == 'log':
|
|
815
|
+
from matplotlib.ticker import LogLocator
|
|
816
|
+
self.axc.xaxis.set_minor_locator(LogLocator(subs='auto'))
|
|
817
|
+
else:
|
|
818
|
+
from matplotlib.ticker import AutoMinorLocator
|
|
819
|
+
self.axc.xaxis.set_minor_locator(AutoMinorLocator())
|
|
820
|
+
|
|
821
|
+
# Ticks/label positions (top/bottom for horizontal)
|
|
822
|
+
_tp = self.frame.get('axc', {}).get('ticks', {}).get('ticks_position', 'top')
|
|
823
|
+
self.axc.xaxis.set_ticks_position(_tp)
|
|
824
|
+
self.axc.xaxis.set_label_position('top' if _tp == 'top' else 'bottom')
|
|
825
|
+
|
|
826
|
+
# Apply tick params (both/major/minor)
|
|
827
|
+
self.axc.tick_params(**self.frame.get('axc', {}).get('ticks', {}).get('both', {}))
|
|
828
|
+
self.axc.tick_params(**self.frame.get('axc', {}).get('ticks', {}).get('major', {}))
|
|
829
|
+
self.axc.tick_params(**self.frame.get('axc', {}).get('ticks', {}).get('minor', {}))
|
|
830
|
+
# Apply label if configured
|
|
831
|
+
if self.frame.get('axc', {}).get('isxlabel'):
|
|
832
|
+
self.axc.set_xlabel(**self.frame.get('axc', {}).get('label', {}))
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
@property
|
|
840
|
+
def ax(self):
|
|
841
|
+
return self.axes['ax']
|
|
842
|
+
|
|
843
|
+
@ax.setter
|
|
844
|
+
def ax(self, kwgs):
|
|
845
|
+
if "ax" not in self.axes.keys():
|
|
846
|
+
raw_ax = self.fig.add_axes(**kwgs)
|
|
847
|
+
if "facecolor" in kwgs.keys():
|
|
848
|
+
raw_ax.set_facecolor(kwgs['facecolor'])
|
|
849
|
+
adapter = StdAxesAdapter(raw_ax)
|
|
850
|
+
adapter._type = "rect"
|
|
851
|
+
adapter.layers = []
|
|
852
|
+
adapter._legend = self.frame['ax'].get("legend", False)
|
|
853
|
+
self.axes['ax'] = adapter
|
|
854
|
+
adapter.status = 'configured'
|
|
855
|
+
|
|
856
|
+
if self.frame['ax'].get("spines"):
|
|
857
|
+
if "color" in self.frame['ax']['spines']:
|
|
858
|
+
for s in self.axes['ax'].spines.values():
|
|
859
|
+
s.set_color(self.frame['ax']['spines']['color'])
|
|
860
|
+
|
|
861
|
+
if self.frame['ax'].get("yscale", "").lower() == 'log':
|
|
862
|
+
self.ax.set_yscale("log")
|
|
863
|
+
from matplotlib.ticker import LogLocator
|
|
864
|
+
self.ax.yaxis.set_minor_locator(LogLocator(subs='auto'))
|
|
865
|
+
else:
|
|
866
|
+
from matplotlib.ticker import AutoMinorLocator
|
|
867
|
+
self.ax.yaxis.set_minor_locator(AutoMinorLocator())
|
|
868
|
+
|
|
869
|
+
if self.frame['ax'].get("xscale", "").lower() == 'log':
|
|
870
|
+
self.ax.set_xscale("log")
|
|
871
|
+
from matplotlib.ticker import LogLocator
|
|
872
|
+
self.ax.xaxis.set_minor_locator(LogLocator(subs='auto'))
|
|
873
|
+
else:
|
|
874
|
+
from matplotlib.ticker import AutoMinorLocator
|
|
875
|
+
self.ax.xaxis.set_minor_locator(AutoMinorLocator())
|
|
876
|
+
|
|
877
|
+
def _safe_cast(v):
|
|
878
|
+
try:
|
|
879
|
+
return float(v)
|
|
880
|
+
except Exception:
|
|
881
|
+
return v
|
|
882
|
+
|
|
883
|
+
if self.frame["ax"].get("text"):
|
|
884
|
+
for txt in self.frame["ax"]["text"]:
|
|
885
|
+
if txt.get("transform", False):
|
|
886
|
+
txt.pop("transform")
|
|
887
|
+
self.ax.text(**txt, transform=self.ax.transAxes)
|
|
888
|
+
else:
|
|
889
|
+
self.ax.text(**txt)
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
xlim = self.frame["ax"].get("xlim")
|
|
893
|
+
if xlim:
|
|
894
|
+
xlim = list(map(_safe_cast, xlim))
|
|
895
|
+
self.ax.set_xlim(xlim)
|
|
896
|
+
|
|
897
|
+
ylim = self.frame["ax"].get("ylim")
|
|
898
|
+
if ylim:
|
|
899
|
+
ylim = list(map(_safe_cast, ylim))
|
|
900
|
+
self.ax.set_ylim(ylim)
|
|
901
|
+
|
|
902
|
+
if self.frame['ax']['labels'].get("x"):
|
|
903
|
+
self.ax.set_xlabel(self.frame['ax']['labels']['x'], **self.frame['ax']['labels']['xlabel'])
|
|
904
|
+
if self.frame['ax']['labels'].get("y"):
|
|
905
|
+
self.ax.set_ylabel(self.frame['ax']['labels']['y'], **self.frame['ax']['labels']['ylabel'])
|
|
906
|
+
self.ax.yaxis.set_label_coords(self.frame['ax']['labels']['ylabel_coords']['x'], self.frame['ax']['labels']['ylabel_coords']['y'])
|
|
907
|
+
|
|
908
|
+
if self.frame['ax']['labels'].get("zorder"):
|
|
909
|
+
for spine in self.ax.spines.values():
|
|
910
|
+
spine.set_zorder(self.frame['ax']['labels']['zorder'])
|
|
911
|
+
|
|
912
|
+
# Apply manual ticks here at initialization if provided in YAML
|
|
913
|
+
ax_ticks_cfg = self.frame.get('ax', {}).get('ticks', {})
|
|
914
|
+
# self._apply_manual_ticks(self.ax, "x", ax_ticks_cfg.get('x', {}))
|
|
915
|
+
# self._apply_manual_ticks(self.ax, "y", ax_ticks_cfg.get('y', {}))
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
self.ax.tick_params(**self.frame['ax']['ticks'].get("both", {}))
|
|
919
|
+
self.ax.tick_params(**self.frame['ax']['ticks'].get("major", {}))
|
|
920
|
+
self.ax.tick_params(**self.frame['ax']['ticks'].get("minor", {}))
|
|
921
|
+
|
|
922
|
+
self._apply_axis_endpoints(self.axes['ax'], self.frame['ax'].get('xaxis', {}), "x")
|
|
923
|
+
self._apply_axis_endpoints(self.axes['ax'], self.frame['ax'].get('yaxis', {}), "y")
|
|
924
|
+
|
|
925
|
+
# ---- Finalize logic with auto-ticks injection ----
|
|
926
|
+
if getattr(self.ax, 'needs_finalize', True) and hasattr(self.ax, 'finalize'):
|
|
927
|
+
orig_finalize = self.ax.finalize
|
|
928
|
+
def wrapped_finalize():
|
|
929
|
+
# try:
|
|
930
|
+
# if not self._has_manual_ticks('ax', 'x'):
|
|
931
|
+
# self._apply_auto_ticks(self.ax, 'x')
|
|
932
|
+
# if not self._has_manual_ticks('ax', 'y'):
|
|
933
|
+
# self._apply_auto_ticks(self.ax, 'y')
|
|
934
|
+
# except Exception as e:
|
|
935
|
+
# if self.logger:
|
|
936
|
+
# self.logger.warning(f"Auto ticks failed on ax: {e}")
|
|
937
|
+
try:
|
|
938
|
+
orig_finalize()
|
|
939
|
+
except Exception as e:
|
|
940
|
+
if self.logger:
|
|
941
|
+
self.logger.warning(f"Finalize failed on axes 'ax': {e}")
|
|
942
|
+
self.ax.finalize = wrapped_finalize
|
|
943
|
+
self.ax.finalize()
|
|
944
|
+
|
|
945
|
+
self.logger.debug("Loaded main rectangle axes -> ax")
|
|
946
|
+
|
|
947
|
+
def _apply_legend_on_axes(self, ax_name: str, ax_obj, leg_cfg: dict):
|
|
948
|
+
"""Apply a legend on a specific axes using a YAML dict stored under frame['axes'][ax_name]['legend'].
|
|
949
|
+
Supports an optional 'enabled' key (default True). Any 'axes' key will be ignored here."""
|
|
950
|
+
if not isinstance(leg_cfg, dict):
|
|
951
|
+
return
|
|
952
|
+
if leg_cfg.get("enabled", True) is False:
|
|
953
|
+
return
|
|
954
|
+
kw = dict(leg_cfg)
|
|
955
|
+
kw.pop("axes", None) # per-axes legend doesn't need this
|
|
956
|
+
try:
|
|
957
|
+
(ax_obj.ax if hasattr(ax_obj, "ax") else ax_obj).legend(**kw)
|
|
958
|
+
except Exception as e:
|
|
959
|
+
if self.logger:
|
|
960
|
+
self.logger.warning(f"Legend apply failed on '{ax_name}': {e}")
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _install_tri_auto_clip(self, ax):
|
|
964
|
+
"""
|
|
965
|
+
Install auto-clip wrappers on this Axes so that any newly created or
|
|
966
|
+
added artists are clipped to ax._clip_path (data coords) automatically.
|
|
967
|
+
This affects high-level draw calls (plot/scatter/contour/contourf/imshow)
|
|
968
|
+
and low-level add_* entry points (add_line/add_collection/add_patch/add_artist).
|
|
969
|
+
"""
|
|
970
|
+
if not hasattr(ax, "_jp_orig"):
|
|
971
|
+
ax._jp_orig = {}
|
|
972
|
+
|
|
973
|
+
def _wrap_high_level(name):
|
|
974
|
+
if hasattr(ax, name) and name not in ax._jp_orig:
|
|
975
|
+
ax._jp_orig[name] = getattr(ax, name)
|
|
976
|
+
def wrapped(self_ax, *args, **kwargs):
|
|
977
|
+
out = ax._jp_orig[name](*args, **kwargs)
|
|
978
|
+
# Only auto-clip if a triangular clip path is defined
|
|
979
|
+
if getattr(self_ax, "_clip_path", None) is not None:
|
|
980
|
+
auto_clip(out, self_ax)
|
|
981
|
+
return out
|
|
982
|
+
setattr(ax, name, MethodType(wrapped, ax))
|
|
983
|
+
|
|
984
|
+
# Wrap common high-level APIs
|
|
985
|
+
for m in ("plot", "scatter", "contour", "contourf", "imshow"):
|
|
986
|
+
_wrap_high_level(m)
|
|
987
|
+
|
|
988
|
+
# Wrap low-level add_* so indirect additions are also clipped
|
|
989
|
+
def _wrap_add(name):
|
|
990
|
+
if hasattr(ax, name) and name not in ax._jp_orig:
|
|
991
|
+
ax._jp_orig[name] = getattr(ax, name)
|
|
992
|
+
def wrapped_add(self_ax, artist, *args, **kwargs):
|
|
993
|
+
if getattr(self_ax, "_clip_path", None) is not None:
|
|
994
|
+
try:
|
|
995
|
+
artist.set_clip_path(self_ax._clip_path, transform=self_ax.transData)
|
|
996
|
+
except Exception:
|
|
997
|
+
pass
|
|
998
|
+
return ax._jp_orig[name](artist, *args, **kwargs)
|
|
999
|
+
setattr(ax, name, MethodType(wrapped_add, ax))
|
|
1000
|
+
|
|
1001
|
+
for m in ("add_line", "add_collection", "add_patch", "add_artist"):
|
|
1002
|
+
_wrap_add(m)
|
|
1003
|
+
|
|
1004
|
+
def savefig(self):
|
|
1005
|
+
# self.ax.tight_layout()
|
|
1006
|
+
for fmt in self.fmts:
|
|
1007
|
+
spf = os.path.join(self.dir, "{}.{}".format(self.name, fmt))
|
|
1008
|
+
try:
|
|
1009
|
+
self.logger.warning(
|
|
1010
|
+
"JarvisPlot successfully draw {}\t in {:.3f}s sec\n\t-> {}".format(self.name, float(time.perf_counter() - self.t0), spf)
|
|
1011
|
+
)
|
|
1012
|
+
except:
|
|
1013
|
+
pass
|
|
1014
|
+
self.fig.savefig(spf, dpi=self.dpi)
|
|
1015
|
+
|
|
1016
|
+
def load_axes(self):
|
|
1017
|
+
for ax, kws in self.frame['axes'].items():
|
|
1018
|
+
try:
|
|
1019
|
+
self.logger.debug("Loading axes -> {}".format(ax))
|
|
1020
|
+
except Exception:
|
|
1021
|
+
pass
|
|
1022
|
+
|
|
1023
|
+
if ax == "axlogo":
|
|
1024
|
+
self.axlogo = kws
|
|
1025
|
+
elif ax == "axtri":
|
|
1026
|
+
self.axtri = kws
|
|
1027
|
+
elif ax == "axc":
|
|
1028
|
+
self.axc = kws
|
|
1029
|
+
elif ax == "ax":
|
|
1030
|
+
self.ax = kws
|
|
1031
|
+
elif self._is_numbered_ax(ax):
|
|
1032
|
+
self._ensure_numbered_rect_axes(ax, kws)
|
|
1033
|
+
else:
|
|
1034
|
+
try:
|
|
1035
|
+
self.logger.warning(f"Unsupported axes key '{ax}'. Only 'ax' or 'ax<NUMBER>' are allowed.")
|
|
1036
|
+
except Exception:
|
|
1037
|
+
pass
|
|
1038
|
+
|
|
1039
|
+
# import matplotlib.pyplot as plt
|
|
1040
|
+
# plt.show()
|
|
1041
|
+
|
|
1042
|
+
def plot(self):
|
|
1043
|
+
self.render()
|
|
1044
|
+
# for layer in self.layers:
|
|
1045
|
+
if self.debug:
|
|
1046
|
+
if "axtri" in self.axes.keys():
|
|
1047
|
+
# Demo of Scatter Clip
|
|
1048
|
+
x = np.linspace(-1, 2, 121)
|
|
1049
|
+
y = np.linspace(-1, 2, 121)
|
|
1050
|
+
X, Y = np.meshgrid(x, y)
|
|
1051
|
+
self.axtri.scatter(x=X.ravel() + 0.5 * Y.ravel(), y=Y.ravel(), marker='.', s=1, facecolor="#0277BA", edgecolor="None")
|
|
1052
|
+
|
|
1053
|
+
# Demo of Plot Clip
|
|
1054
|
+
self.axtri.plot(x=[-1, 0.5, 0.5, 2], y=[-1.1, 0.6, 0.3, 1.8], linestyle="-", color="#0277BA")
|
|
1055
|
+
self.savefig()
|
|
1056
|
+
import matplotlib.pyplot as plt
|
|
1057
|
+
plt.close(self.fig)
|
|
1058
|
+
|
|
1059
|
+
def render(self):
|
|
1060
|
+
"""
|
|
1061
|
+
Render all layers attached to each axes (we appended them in axtri/axlogo setters).
|
|
1062
|
+
"""
|
|
1063
|
+
for ax_name, ax in self.axes.items():
|
|
1064
|
+
ly_list = getattr(ax, "layers", [])
|
|
1065
|
+
for ly in ly_list:
|
|
1066
|
+
self.render_layer(ax, ly)
|
|
1067
|
+
# mark drawn after all layers on this axes
|
|
1068
|
+
if hasattr(ax, 'status'):
|
|
1069
|
+
ax.status = 'drawn'
|
|
1070
|
+
|
|
1071
|
+
for name, ax in self.axes.items():
|
|
1072
|
+
try:
|
|
1073
|
+
if hasattr(ax, "_legend") and ax._legend:
|
|
1074
|
+
target_ax = ax.ax if hasattr(ax, "ax") else ax
|
|
1075
|
+
target_ax.legend(**ax._legend)
|
|
1076
|
+
except Exception as e:
|
|
1077
|
+
if self.logger:
|
|
1078
|
+
self.logger.warning(f"Legend draw failed on axes '{name}': {e}")
|
|
1079
|
+
|
|
1080
|
+
# finalize colorbar lazily (only if any colored layer appeared)
|
|
1081
|
+
if "axc" in self.axes:
|
|
1082
|
+
self.axc = True
|
|
1083
|
+
|
|
1084
|
+
# ---- Finalize axes that want it ----
|
|
1085
|
+
for name, ax in self.axes.items():
|
|
1086
|
+
# Auto ticks only if user did not provide manual ticks for this axis
|
|
1087
|
+
if name == 'ax':
|
|
1088
|
+
if not self._has_manual_ticks('ax', 'x'):
|
|
1089
|
+
self._apply_auto_ticks(ax, 'x')
|
|
1090
|
+
if not self._has_manual_ticks('ax', 'y'):
|
|
1091
|
+
self._apply_auto_ticks(ax, 'y')
|
|
1092
|
+
elif name == 'axc':
|
|
1093
|
+
if not self._has_manual_ticks('axc', 'y'):
|
|
1094
|
+
self._apply_auto_ticks(ax, 'y')
|
|
1095
|
+
if getattr(ax, 'needs_finalize', True) and hasattr(ax, 'finalize'):
|
|
1096
|
+
try:
|
|
1097
|
+
ax.finalize()
|
|
1098
|
+
except Exception as e:
|
|
1099
|
+
if self.logger:
|
|
1100
|
+
self.logger.warning(f"Finalize failed on axes '{name}': {e}")
|
|
1101
|
+
|
|
1102
|
+
def _apply_manual_ticks(self, ax_obj, which: str, ticks_cfg: dict):
|
|
1103
|
+
"""Apply manual ticks if YAML provides them; otherwise keep auto.
|
|
1104
|
+
YAML:
|
|
1105
|
+
frame.ax.ticks.x: { positions: [...], labels: [...] }
|
|
1106
|
+
frame.ax.ticks.y: { positions: [...], labels: [...] }
|
|
1107
|
+
frame.axc.ticks.y: { positions: [...], labels: [...] }
|
|
1108
|
+
"""
|
|
1109
|
+
if not isinstance(ticks_cfg, dict):
|
|
1110
|
+
return
|
|
1111
|
+
pos = ticks_cfg.get("positions") or ticks_cfg.get("pos")
|
|
1112
|
+
labs = ticks_cfg.get("labels")
|
|
1113
|
+
if pos is None:
|
|
1114
|
+
return
|
|
1115
|
+
target = ax_obj.ax if hasattr(ax_obj, "ax") else ax_obj
|
|
1116
|
+
try:
|
|
1117
|
+
if which == "x":
|
|
1118
|
+
target.set_xticks(pos)
|
|
1119
|
+
if labs is not None:
|
|
1120
|
+
target.set_xticklabels(labs)
|
|
1121
|
+
elif which == "y":
|
|
1122
|
+
target.set_yticks(pos)
|
|
1123
|
+
if labs is not None:
|
|
1124
|
+
target.set_yticklabels(labs)
|
|
1125
|
+
except Exception as e:
|
|
1126
|
+
if self.logger:
|
|
1127
|
+
self.logger.warning(f"Manual ticks apply failed on {which}-axis: {e}")
|
|
1128
|
+
|
|
1129
|
+
# --- config ingestion ---
|
|
1130
|
+
def from_dict(self, info: Mapping) -> bool:
|
|
1131
|
+
"""Apply settings from a dict. Returns True if any field was set.
|
|
1132
|
+
Expected keys (so far): 'name'.
|
|
1133
|
+
"""
|
|
1134
|
+
if not isinstance(info, Mapping):
|
|
1135
|
+
raise TypeError("from_dict expects a mapping/dict")
|
|
1136
|
+
|
|
1137
|
+
try:
|
|
1138
|
+
changed = True
|
|
1139
|
+
if "name" in info:
|
|
1140
|
+
self.name = info["name"] # use the property setter correctly
|
|
1141
|
+
else:
|
|
1142
|
+
changed = False
|
|
1143
|
+
|
|
1144
|
+
# Base directory for resolving relative paths (e.g. output.dir, resources, etc.)
|
|
1145
|
+
# Core should pass one of these keys.
|
|
1146
|
+
if "yaml_dir" in info:
|
|
1147
|
+
self._yaml_dir = info.get("yaml_dir")
|
|
1148
|
+
elif "_yaml_dir" in info:
|
|
1149
|
+
self._yaml_dir = info.get("_yaml_dir")
|
|
1150
|
+
elif "yaml_path" in info:
|
|
1151
|
+
try:
|
|
1152
|
+
from pathlib import Path
|
|
1153
|
+
self._yaml_dir = str(Path(info.get("yaml_path")).expanduser().resolve().parent)
|
|
1154
|
+
except Exception:
|
|
1155
|
+
pass
|
|
1156
|
+
|
|
1157
|
+
if "debug" in info:
|
|
1158
|
+
self.debug = info['debug']
|
|
1159
|
+
try:
|
|
1160
|
+
self.logger.debug("Loading plot -> {} in debug mode".format(self.name))
|
|
1161
|
+
except:
|
|
1162
|
+
pass
|
|
1163
|
+
self._enable = info.get("enable", True)
|
|
1164
|
+
if not self._enable:
|
|
1165
|
+
self.logger.warning("Skip plot -> {}".format(self.name))
|
|
1166
|
+
return False
|
|
1167
|
+
|
|
1168
|
+
if "style" in info:
|
|
1169
|
+
self.style = info['style']
|
|
1170
|
+
else:
|
|
1171
|
+
self.style = ["a4paper_2x1", "default"]
|
|
1172
|
+
self.logger.debug("Figure style loaded")
|
|
1173
|
+
if "gambit" in info['style'][0]:
|
|
1174
|
+
self.mode = "gambit"
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
if "frame" in info:
|
|
1178
|
+
self.frame = info['frame']
|
|
1179
|
+
|
|
1180
|
+
import matplotlib.pyplot as plt
|
|
1181
|
+
plt.rcParams['mathtext.fontset'] = 'stix'
|
|
1182
|
+
# --- Ensure JarvisPLOT colormaps are registered globally before plotting ---
|
|
1183
|
+
try:
|
|
1184
|
+
from ..utils import cmaps as _jp_cmaps
|
|
1185
|
+
_cmaps_summary = _jp_cmaps.setup(force=True)
|
|
1186
|
+
if self.logger:
|
|
1187
|
+
try:
|
|
1188
|
+
self.logger.debug(f"JarvisPLOT: colormaps registered (builtin/external): {_cmaps_summary}")
|
|
1189
|
+
self.logger.debug(f"JarvisPLOT: available cmaps sample: {_jp_cmaps.list_available()[:10]} ...")
|
|
1190
|
+
except:
|
|
1191
|
+
pass
|
|
1192
|
+
except Exception as _e:
|
|
1193
|
+
if self.logger:
|
|
1194
|
+
self.logger.warning(f"JarvisPLOT: failed to register colormaps: {_e}")
|
|
1195
|
+
# plt.rcParams['font.family'] = 'STIXGeneral'
|
|
1196
|
+
self.fig = plt.figure(**self.frame['figure'])
|
|
1197
|
+
# CLI override: disable logo panel
|
|
1198
|
+
if self.print:
|
|
1199
|
+
try:
|
|
1200
|
+
if self.mode == "Jarvis":
|
|
1201
|
+
if isinstance(self.frame.get("axes"), dict):
|
|
1202
|
+
self.frame["axes"].pop("axlogo", None)
|
|
1203
|
+
self.frame.pop("axlogo", None) # optional: drop logo content too
|
|
1204
|
+
except Exception:
|
|
1205
|
+
pass
|
|
1206
|
+
self.load_axes()
|
|
1207
|
+
|
|
1208
|
+
if "layers" in info:
|
|
1209
|
+
self.layers = info['layers']
|
|
1210
|
+
else:
|
|
1211
|
+
changed = False
|
|
1212
|
+
|
|
1213
|
+
return changed
|
|
1214
|
+
except:
|
|
1215
|
+
return False
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
# Backward-compatible alias if other code still calls `set(info)`
|
|
1219
|
+
|
|
1220
|
+
def set(self, info: Mapping) -> bool:
|
|
1221
|
+
return self.from_dict(info)
|
|
1222
|
+
|
|
1223
|
+
def load_path(self, path, base_dir=None):
|
|
1224
|
+
"""Resolve a path string.
|
|
1225
|
+
|
|
1226
|
+
Rules:
|
|
1227
|
+
- "&JP/..." is resolved relative to JarvisPLOT project root (jppwd).
|
|
1228
|
+
- Absolute paths are kept.
|
|
1229
|
+
- Relative paths are resolved relative to `base_dir` if provided, otherwise CWD.
|
|
1230
|
+
"""
|
|
1231
|
+
path = str(path)
|
|
1232
|
+
if path.startswith("&JP/"):
|
|
1233
|
+
return os.path.abspath(os.path.join(jppwd, path[4:]))
|
|
1234
|
+
|
|
1235
|
+
from pathlib import Path
|
|
1236
|
+
p = Path(path).expanduser()
|
|
1237
|
+
if p.is_absolute():
|
|
1238
|
+
return str(p.resolve())
|
|
1239
|
+
|
|
1240
|
+
if base_dir:
|
|
1241
|
+
try:
|
|
1242
|
+
bd = Path(str(base_dir)).expanduser().resolve()
|
|
1243
|
+
return str((bd / p).resolve())
|
|
1244
|
+
except Exception:
|
|
1245
|
+
pass
|
|
1246
|
+
|
|
1247
|
+
return str(p.resolve())
|
|
1248
|
+
|
|
1249
|
+
# --- unified method dispatch ---
|
|
1250
|
+
METHOD_DISPATCH = {
|
|
1251
|
+
"scatter": "scatter",
|
|
1252
|
+
"plot": "plot",
|
|
1253
|
+
"fill": "fill",
|
|
1254
|
+
"contour": "contour",
|
|
1255
|
+
"contourf": "contourf",
|
|
1256
|
+
"imshow": "imshow",
|
|
1257
|
+
"hist": "hist",
|
|
1258
|
+
"hexbin": "hexbin",
|
|
1259
|
+
"tricontour": "tricontour",
|
|
1260
|
+
"tricontourf": "tricontourf",
|
|
1261
|
+
"voronoi": "voronoi",
|
|
1262
|
+
"voronoif": "voronoif"
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
def _eval_series(self, df: pd.DataFrame, set: dict):
|
|
1266
|
+
"""
|
|
1267
|
+
Evaluate an expression/column name against df safely.
|
|
1268
|
+
- If expr is a direct column name, returns that series.
|
|
1269
|
+
- If expr is a python expression, eval with df columns in scope.
|
|
1270
|
+
"""
|
|
1271
|
+
try:
|
|
1272
|
+
self.logger.debug("Loading variable expression -> {}".format(set['expr']))
|
|
1273
|
+
except:
|
|
1274
|
+
pass
|
|
1275
|
+
if not "expr" in set.keys():
|
|
1276
|
+
raise ValueError(f"expr need for axes {set}.")
|
|
1277
|
+
if set["expr"] in df.columns:
|
|
1278
|
+
arr = df[set["expr"]].values
|
|
1279
|
+
if np.isnan(arr).sum() and "fillna" in set.keys():
|
|
1280
|
+
arr = np.where(np.isnan(arr), float(set['fillna']), arr)
|
|
1281
|
+
else:
|
|
1282
|
+
# safe-ish eval with only df columns in locals
|
|
1283
|
+
local_vars = df.to_dict("series")
|
|
1284
|
+
import math
|
|
1285
|
+
from ..inner_func import update_funcs
|
|
1286
|
+
allowed_globals = update_funcs({"np": np, "math": math})
|
|
1287
|
+
arr = eval(set["expr"], allowed_globals, local_vars)
|
|
1288
|
+
if np.isnan(arr).sum() and "fillna" in set.keys():
|
|
1289
|
+
arr = np.where(np.isnan(arr), float(set['fillna']), arr)
|
|
1290
|
+
return np.asarray(arr)
|
|
1291
|
+
|
|
1292
|
+
def _cb_collect_and_attach(self, style: dict, coor: dict, method_key: str, df: pd.DataFrame) -> dict:
|
|
1293
|
+
import matplotlib.colors as mcolors
|
|
1294
|
+
axc = self.axes.get("axc")
|
|
1295
|
+
if axc is None or not hasattr(axc, "_cb"):
|
|
1296
|
+
return style
|
|
1297
|
+
|
|
1298
|
+
s = dict(style)
|
|
1299
|
+
uses_color = bool(style.get("cmap")) or ("c" in coor)
|
|
1300
|
+
if not uses_color:
|
|
1301
|
+
return style
|
|
1302
|
+
self.axc._cb["cmap"] = s.get("cmap")
|
|
1303
|
+
|
|
1304
|
+
# ---- 1) records vmin and vmax ----
|
|
1305
|
+
z = None
|
|
1306
|
+
if self.axc._cb["vmin"] is None:
|
|
1307
|
+
if ("vmin" in s and isinstance(s['vmin'], (int, float))):
|
|
1308
|
+
self.axc._cb['vmin'] = s['vmin']
|
|
1309
|
+
else:
|
|
1310
|
+
if z is None:
|
|
1311
|
+
if "z" in coor and isinstance(coor["z"], dict) and "expr" in coor["z"]:
|
|
1312
|
+
z = self._eval_series(df, {"expr": coor["z"]["expr"]})
|
|
1313
|
+
elif "c" in coor and isinstance(coor["c"], dict) and "expr" in coor["c"]:
|
|
1314
|
+
z = self._eval_series(df, {"expr": coor["c"]["expr"]})
|
|
1315
|
+
if z is not None:
|
|
1316
|
+
z = z[np.isfinite(z)]
|
|
1317
|
+
if z.size:
|
|
1318
|
+
self.axc._cb["vmin"] = float(np.min(z))
|
|
1319
|
+
|
|
1320
|
+
if self.axc._cb["vmax"] is None:
|
|
1321
|
+
if ("vmax" in s and isinstance(s['vmax'], (int, float))):
|
|
1322
|
+
self.axc._cb['vmax'] = s['vmax']
|
|
1323
|
+
else:
|
|
1324
|
+
if z is None:
|
|
1325
|
+
if "z" in coor and isinstance(coor["z"], dict) and "expr" in coor["z"]:
|
|
1326
|
+
z = self._eval_series(df, {"expr": coor["z"]["expr"]})
|
|
1327
|
+
elif "c" in coor and isinstance(coor["c"], dict) and "expr" in coor["c"]:
|
|
1328
|
+
z = self._eval_series(df, {"expr": coor["c"]["expr"]})
|
|
1329
|
+
if z is not None:
|
|
1330
|
+
z = z[np.isfinite(z)]
|
|
1331
|
+
if z.size:
|
|
1332
|
+
self.axc._cb["vmax"] = float(np.max(z))
|
|
1333
|
+
|
|
1334
|
+
# ---- 2) Resolve/attach norm (priority: explicit 'norm' in style) ----
|
|
1335
|
+
if (self.axc._cb["vmin"] is not None) and (self.axc._cb["vmax"] is not None):
|
|
1336
|
+
vmin, vmax = self.axc._cb["vmin"], self.axc._cb["vmax"]
|
|
1337
|
+
|
|
1338
|
+
def _resolve_norm(nv, *, vmin=None, vmax=None):
|
|
1339
|
+
"""Turn user-specified 'norm' into a matplotlib.colors.Normalize instance."""
|
|
1340
|
+
if nv is None:
|
|
1341
|
+
return None
|
|
1342
|
+
# Already a Normalize subclass
|
|
1343
|
+
if isinstance(nv, mcolors.Normalize):
|
|
1344
|
+
return nv
|
|
1345
|
+
# String shorthand, e.g. "LogNorm", "TwoSlopeNorm", "Normalize"
|
|
1346
|
+
if isinstance(nv, str):
|
|
1347
|
+
key = nv.strip().lower()
|
|
1348
|
+
if key in {"log", "lognorm"}:
|
|
1349
|
+
return mcolors.LogNorm(vmin=vmin, vmax=vmax)
|
|
1350
|
+
if key in {"twoslopenorm", "diverging"}:
|
|
1351
|
+
# default vcenter=0 for diverging data; user can override via dict form
|
|
1352
|
+
return mcolors.TwoSlopeNorm(vcenter=0.0, vmin=vmin, vmax=vmax)
|
|
1353
|
+
if key in {"norm", "normalize", "linear"}:
|
|
1354
|
+
return mcolors.Normalize(vmin=vmin, vmax=vmax)
|
|
1355
|
+
# Fallback: unknown string → default linear
|
|
1356
|
+
return mcolors.Normalize(vmin=vmin, vmax=vmax)
|
|
1357
|
+
# Dict form: {"type": "LogNorm", "vmin": ..., "vmax": ..., ...}
|
|
1358
|
+
if isinstance(nv, dict):
|
|
1359
|
+
t = str(nv.get("type", "Normalize")).strip().lower()
|
|
1360
|
+
# Prefer explicit vmin/vmax in dict; otherwise use inferred values
|
|
1361
|
+
_vmin = nv.get("vmin", vmin)
|
|
1362
|
+
_vmax = nv.get("vmax", vmax)
|
|
1363
|
+
if t in {"log", "lognorm"}:
|
|
1364
|
+
return mcolors.LogNorm(vmin=_vmin, vmax=_vmax)
|
|
1365
|
+
if t in {"twoslopenorm", "diverging"}:
|
|
1366
|
+
vcenter = nv.get("vcenter", 0.0)
|
|
1367
|
+
return mcolors.TwoSlopeNorm(vcenter=vcenter, vmin=_vmin, vmax=_vmax)
|
|
1368
|
+
if t in {"symlog", "symlognorm"}:
|
|
1369
|
+
# Optional parameters for SymLogNorm
|
|
1370
|
+
linthresh = nv.get("linthresh", 1.0)
|
|
1371
|
+
linscale = nv.get("linscale", 1.0)
|
|
1372
|
+
base = nv.get("base", 10)
|
|
1373
|
+
return mcolors.SymLogNorm(linthresh=linthresh, linscale=linscale, base=base, vmin=_vmin, vmax=_vmax)
|
|
1374
|
+
# Default linear
|
|
1375
|
+
return mcolors.Normalize(vmin=_vmin, vmax=_vmax)
|
|
1376
|
+
# Anything else → default linear
|
|
1377
|
+
return mcolors.Normalize(vmin=vmin, vmax=vmax)
|
|
1378
|
+
|
|
1379
|
+
# Priority 1: explicit norm in style
|
|
1380
|
+
user_norm = style.get("norm", None)
|
|
1381
|
+
resolved = _resolve_norm(user_norm, vmin=vmin, vmax=vmax)
|
|
1382
|
+
|
|
1383
|
+
# If no explicit norm provided, keep existing (if any) or use linear as default
|
|
1384
|
+
if resolved is None:
|
|
1385
|
+
if self.axc._cb["norm"] is None:
|
|
1386
|
+
self.axc._cb["norm"] = mcolors.Normalize(vmin=vmin, vmax=vmax)
|
|
1387
|
+
else:
|
|
1388
|
+
self.axc._cb["norm"] = resolved
|
|
1389
|
+
|
|
1390
|
+
# Also store the resolved norm back into the outgoing style so that plotting receives a proper Normalize
|
|
1391
|
+
if self.axc._cb["norm"] is not None:
|
|
1392
|
+
s["norm"] = self.axc._cb["norm"]
|
|
1393
|
+
|
|
1394
|
+
# Set a backward-compatible 'mode' tag based on the resolved norm
|
|
1395
|
+
if isinstance(self.axc._cb["norm"], mcolors.LogNorm):
|
|
1396
|
+
self.axc._cb['mode'] = "log"
|
|
1397
|
+
elif isinstance(self.axc._cb["norm"], mcolors.TwoSlopeNorm):
|
|
1398
|
+
self.axc._cb['mode'] = "diverging"
|
|
1399
|
+
elif isinstance(self.axc._cb["norm"], mcolors.SymLogNorm):
|
|
1400
|
+
# Mark as 'log' to trigger log-style minor locators on colorbar if needed
|
|
1401
|
+
self.axc._cb['mode'] = "log"
|
|
1402
|
+
else:
|
|
1403
|
+
self.axc._cb['mode'] = "norm"
|
|
1404
|
+
|
|
1405
|
+
if method_key in ("contour","contourf","tricontour","tricontourf") and self.axc._cb["levels"] is None:
|
|
1406
|
+
lv = s.get("levels", 10)
|
|
1407
|
+
if isinstance(lv, int) and self.axc._cb["vmin"] is not None and self.axc._cb["vmax"] is not None:
|
|
1408
|
+
self.axc._cb["levels"] = np.linspace(self.axc._cb["vmin"], self.axc._cb["vmax"], lv)
|
|
1409
|
+
elif hasattr(lv, "__len__"):
|
|
1410
|
+
self.axc._cb["levels"] = lv
|
|
1411
|
+
if 'norm' in s and s['norm'] is not None:
|
|
1412
|
+
s.pop('vmin', None)
|
|
1413
|
+
s.pop('vmax', None)
|
|
1414
|
+
s.pop('mode', None)
|
|
1415
|
+
self.axc._cb["used"] = uses_color
|
|
1416
|
+
return s
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
|
|
1422
|
+
def render_layer(self, ax, layer_info):
|
|
1423
|
+
"""
|
|
1424
|
+
Render one layer on the given axes using METHOD_DISPATCH and the layer's
|
|
1425
|
+
data/coordinates/style fields assembled earlier in self.layers setter.
|
|
1426
|
+
This function now routes arguments based on the axes type:
|
|
1427
|
+
- ternary axes (ax._type == 'tri'): methods expect (a,b,c, ...)
|
|
1428
|
+
* profile_scatter additionally expects z -> (a,b,c,z, ...)
|
|
1429
|
+
- rectangular axes (ax._type == 'rect'): methods expect (x,y, ...)
|
|
1430
|
+
"""
|
|
1431
|
+
# 1) Resolve method
|
|
1432
|
+
try:
|
|
1433
|
+
self.logger.debug(f"Drawing layer -> {layer_info['name']}")
|
|
1434
|
+
except:
|
|
1435
|
+
pass
|
|
1436
|
+
method_key = str(layer_info.get("method", "scatter")).lower()
|
|
1437
|
+
method_name = self.METHOD_DISPATCH.get(method_key)
|
|
1438
|
+
if not method_name or not hasattr(ax, method_name):
|
|
1439
|
+
raise ValueError(f"Unknown/unsupported method '{method_key}' for axes {ax}.")
|
|
1440
|
+
method = getattr(ax, method_name)
|
|
1441
|
+
|
|
1442
|
+
# 2) Merge style (bundle default -> layer override)
|
|
1443
|
+
style = dict(self.style.get(method_key, {}))
|
|
1444
|
+
if layer_info.get("style", {}) is not None:
|
|
1445
|
+
style.update(layer_info.get("style", {}))
|
|
1446
|
+
|
|
1447
|
+
if getattr(ax, "_type", None) == "tri":
|
|
1448
|
+
df = layer_info["data"]
|
|
1449
|
+
coor = layer_info.get("coor", {})
|
|
1450
|
+
|
|
1451
|
+
# Apply per-figure shared colorbar (lazy) if an axc exists
|
|
1452
|
+
try:
|
|
1453
|
+
style = self._cb_collect_and_attach(style, coor, method_key, df)
|
|
1454
|
+
self.logger.debug("Successful loading colorbar style")
|
|
1455
|
+
except Exception as _e:
|
|
1456
|
+
self._logger.debug(f"colorbar lazy-attach failed: {_e}")
|
|
1457
|
+
# Ternary coordinates required: left/right/bottom
|
|
1458
|
+
requiredlbr = {"left", "right", "bottom"}
|
|
1459
|
+
requiredxy = {"x", "y"}
|
|
1460
|
+
if not ((requiredlbr <= set(coor.keys())) or (requiredxy <= set(coor.keys()))):
|
|
1461
|
+
raise ValueError("Ternary layer must define coordinates: {left, right, bottom} or {x, y} with exprs.")
|
|
1462
|
+
for kk, vv in coor.items():
|
|
1463
|
+
style[kk] = self._eval_series(df, vv)
|
|
1464
|
+
return method(**style)
|
|
1465
|
+
|
|
1466
|
+
elif getattr(ax, "_type", None) == "rect":
|
|
1467
|
+
df = layer_info["data"]
|
|
1468
|
+
coor = layer_info.get("coor", {})
|
|
1469
|
+
try:
|
|
1470
|
+
style = self._cb_collect_and_attach(style, coor, method_key, df)
|
|
1471
|
+
self.logger.debug("Successful loading colorbar style")
|
|
1472
|
+
except Exception as _e:
|
|
1473
|
+
self._logger.debug(f"colorbar lazy-attach failed: {_e}")
|
|
1474
|
+
|
|
1475
|
+
if layer_info['method'] == "hist":
|
|
1476
|
+
if isinstance(layer_info['data'], dict):
|
|
1477
|
+
if "label" not in style.keys():
|
|
1478
|
+
style['label'] = []
|
|
1479
|
+
for kk, vv in coor.items():
|
|
1480
|
+
style[kk] = []
|
|
1481
|
+
for dn, ddf in df.items():
|
|
1482
|
+
style['label'].append(dn)
|
|
1483
|
+
for kk, vv in coor.items():
|
|
1484
|
+
style[kk].append( self._eval_series(ddf, vv) )
|
|
1485
|
+
else:
|
|
1486
|
+
for kk, vv in coor.items():
|
|
1487
|
+
style[kk] = self._eval_series(df, vv)
|
|
1488
|
+
|
|
1489
|
+
return method(**style)
|
|
1490
|
+
# Generic x/y coordinates required
|
|
1491
|
+
else:
|
|
1492
|
+
if not ({"x", "y"} <= set(coor.keys())):
|
|
1493
|
+
raise ValueError("Rectangular layer must define coordinates: {x,y} with exprs.")
|
|
1494
|
+
|
|
1495
|
+
for kk, vv in coor.items():
|
|
1496
|
+
# style[kk] = self._eval_series(df, vv)
|
|
1497
|
+
# Mode 1:expr → DataFrame evaluation
|
|
1498
|
+
if isinstance(vv, dict) and "expr" in vv:
|
|
1499
|
+
if df is None:
|
|
1500
|
+
raise ValueError(
|
|
1501
|
+
f"Layer '{layer_info.get('name', '')}' defines expression-based "
|
|
1502
|
+
f"coordinate for '{kk}' but has no data source."
|
|
1503
|
+
)
|
|
1504
|
+
style[kk] = self._eval_series(df, vv)
|
|
1505
|
+
else:
|
|
1506
|
+
# Mode 2:(list/tuple/ndarray/scalar)
|
|
1507
|
+
style[kk] = vv
|
|
1508
|
+
# if "norm" in style.keys():
|
|
1509
|
+
# style = self.load_norm(style)
|
|
1510
|
+
return method(**style)
|
|
1511
|
+
|
|
1512
|
+
else:
|
|
1513
|
+
# Unknown axes adapter type
|
|
1514
|
+
raise ValueError(f"Axes '{ax}' has unknown _type='{getattr(ax, '_type', None)}'.")
|
|
1515
|
+
|
|
1516
|
+
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
|
|
1520
|
+
# def auto_clip(artists, ax, clip_obj=None, transform=None):
|
|
1521
|
+
def auto_clip(artists, ax):
|
|
1522
|
+
|
|
1523
|
+
from matplotlib.path import Path as MplPath
|
|
1524
|
+
from matplotlib.patches import Patch as MplPatch
|
|
1525
|
+
from matplotlib.container import BarContainer, ErrorbarContainer
|
|
1526
|
+
|
|
1527
|
+
def _apply_to_one(a):
|
|
1528
|
+
try:
|
|
1529
|
+
a.set_clip_path(ax._clip_path, transform=ax.transData)
|
|
1530
|
+
return True
|
|
1531
|
+
except Exception:
|
|
1532
|
+
return False
|
|
1533
|
+
|
|
1534
|
+
def _apply(obj):
|
|
1535
|
+
if _apply_to_one(obj):
|
|
1536
|
+
return True
|
|
1537
|
+
coll = getattr(obj, "collections", None)
|
|
1538
|
+
if coll is not None:
|
|
1539
|
+
for c in coll:
|
|
1540
|
+
_apply_to_one(c)
|
|
1541
|
+
return True
|
|
1542
|
+
if isinstance(obj, BarContainer):
|
|
1543
|
+
for p in obj.patches:
|
|
1544
|
+
_apply_to_one(p)
|
|
1545
|
+
return True
|
|
1546
|
+
if isinstance(obj, ErrorbarContainer):
|
|
1547
|
+
for line in obj.lines:
|
|
1548
|
+
_apply_to_one(line)
|
|
1549
|
+
if hasattr(obj, "has_xerr") and obj.has_xerr and obj.has_yerr:
|
|
1550
|
+
for lc in getattr(obj, "barlinecols", []):
|
|
1551
|
+
_apply_to_one(lc)
|
|
1552
|
+
return True
|
|
1553
|
+
# 4) violinplot
|
|
1554
|
+
if isinstance(obj, dict):
|
|
1555
|
+
for v in obj.values():
|
|
1556
|
+
if isinstance(v, (list, tuple)):
|
|
1557
|
+
for a in v:
|
|
1558
|
+
_apply_to_one(a)
|
|
1559
|
+
else:
|
|
1560
|
+
_apply_to_one(v)
|
|
1561
|
+
return True
|
|
1562
|
+
# 5) iterabile object
|
|
1563
|
+
try:
|
|
1564
|
+
iterator = iter(obj)
|
|
1565
|
+
except TypeError:
|
|
1566
|
+
return False
|
|
1567
|
+
else:
|
|
1568
|
+
for a in iterator:
|
|
1569
|
+
_apply_to_one(a)
|
|
1570
|
+
return True
|
|
1571
|
+
|
|
1572
|
+
_apply(artists)
|
|
1573
|
+
return artists
|