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,773 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
import numpy as np
|
|
6
|
+
import os
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
from matplotlib.axes import Axes
|
|
10
|
+
from matplotlib.collections import PolyCollection, LineCollection
|
|
11
|
+
from matplotlib.path import Path
|
|
12
|
+
from matplotlib.patches import PathPatch
|
|
13
|
+
#
|
|
14
|
+
from .helper import _auto_clip, _mask_by_extend, voronoi_finite_polygons_2d, _clip_poly_to_rect
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# —— Basic Adapter: Forward to the underlying Axes, merge default parameters, perform automatic clipping ——
|
|
18
|
+
class StdAxesAdapter:
|
|
19
|
+
def __init__(self, ax: Axes, defaults: Optional[Dict[str, Dict[str, Any]]] = None,
|
|
20
|
+
clip_path=None):
|
|
21
|
+
"""
|
|
22
|
+
ax: 原始 matplotlib Axes
|
|
23
|
+
defaults: 分方法的默认参数,如 {"scatter": {"s": 8, "alpha": 0.8}}
|
|
24
|
+
clip_path: 可选 Path/PathPatch,用于 set_clip_path
|
|
25
|
+
"""
|
|
26
|
+
self.ax = ax
|
|
27
|
+
self._defaults = defaults or {}
|
|
28
|
+
self._clip_path = clip_path # None means no cropping
|
|
29
|
+
self.config = self._load_internal_config()
|
|
30
|
+
self._legend = False
|
|
31
|
+
self.status = "init" # lifecycle: init -> configured -> drawn -> finalized
|
|
32
|
+
self.needs_finalize = True # allow some axes (e.g., logo) to opt out
|
|
33
|
+
|
|
34
|
+
def finalize(self):
|
|
35
|
+
"""Finalize axes after all layers/legends/colorbars applied.
|
|
36
|
+
Override in specialized adapters if needed. Here we just mark status.
|
|
37
|
+
"""
|
|
38
|
+
self.status = "finalized"
|
|
39
|
+
|
|
40
|
+
def _load_internal_config(self):
|
|
41
|
+
default_path = os.path.join(os.path.dirname(__file__), "cards", "std_axes_adapter_config.json")
|
|
42
|
+
try:
|
|
43
|
+
with open(default_path, "r") as f:
|
|
44
|
+
return json.load(f)
|
|
45
|
+
except FileNotFoundError:
|
|
46
|
+
# Optional: return default empty configuration or raise
|
|
47
|
+
return {}
|
|
48
|
+
|
|
49
|
+
# Parameter merging: user preference takes priority, default as fallback
|
|
50
|
+
def _merge(self, method: str, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
51
|
+
base = dict(self._defaults.get(method, {}))
|
|
52
|
+
base.update(kwargs or {})
|
|
53
|
+
return base
|
|
54
|
+
|
|
55
|
+
# —— Common method forwarding (add as needed) ——
|
|
56
|
+
def scatter(self, **kwargs):
|
|
57
|
+
x, y = kwargs.pop("x"), kwargs.pop("y")
|
|
58
|
+
kw = self._merge("scatter", kwargs)
|
|
59
|
+
artists = self.ax.scatter(x, y, **kw)
|
|
60
|
+
return _auto_clip(artists, self.ax, self._clip_path)
|
|
61
|
+
|
|
62
|
+
def plot(self, *args, **kwargs):
|
|
63
|
+
x, y = kwargs.pop("x"), kwargs.pop("y")
|
|
64
|
+
# print("x:", x, "y:", y)
|
|
65
|
+
kw = self._merge("plot", kwargs)
|
|
66
|
+
fmt = kw.pop("fmt", None)
|
|
67
|
+
if fmt is not None:
|
|
68
|
+
artists = self.ax.plot(x, y, fmt, **kw)
|
|
69
|
+
else:
|
|
70
|
+
artists = self.ax.plot(x, y, **kw)
|
|
71
|
+
return _auto_clip(artists, self.ax, self._clip_path)
|
|
72
|
+
|
|
73
|
+
def fill(self, **kwargs):
|
|
74
|
+
x, y = kwargs.pop("x"), kwargs.pop("y")
|
|
75
|
+
kw = self._merge("fill", kwargs)
|
|
76
|
+
artists = self.ax.fill(x, y, **kw)
|
|
77
|
+
return _auto_clip(artists, self.ax, self._clip_path)
|
|
78
|
+
|
|
79
|
+
def contour(self, *args, **kwargs):
|
|
80
|
+
kw = self._merge("contour", kwargs)
|
|
81
|
+
artists = self.ax.contour(*args, **kw)
|
|
82
|
+
return _auto_clip(artists, self.ax, self._clip_path)
|
|
83
|
+
|
|
84
|
+
def contourf(self, *args, **kwargs):
|
|
85
|
+
kw = self._merge("contourf", kwargs)
|
|
86
|
+
artists = self.ax.contourf(*args, **kw)
|
|
87
|
+
return _auto_clip(artists, self.ax, self._clip_path)
|
|
88
|
+
|
|
89
|
+
def imshow(self, *args, **kwargs):
|
|
90
|
+
kw = self._merge("imshow", kwargs)
|
|
91
|
+
artists = self.ax.imshow(*args, **kw)
|
|
92
|
+
return _auto_clip(artists, self.ax, self._clip_path)
|
|
93
|
+
|
|
94
|
+
def tricontour(self, **kwargs):
|
|
95
|
+
x, y, z = kwargs.pop("x"), kwargs.pop("y"), kwargs.pop("z")
|
|
96
|
+
import matplotlib.tri as tri
|
|
97
|
+
triang = tri.Triangulation(x, y)
|
|
98
|
+
refiner = tri.UniformTriRefiner(triang)
|
|
99
|
+
tri_refi, z_test_refi = refiner.refine_field(z, subdiv=3)
|
|
100
|
+
kw = self._merge("tricontour", kwargs)
|
|
101
|
+
artists = self.ax.tricontour(tri_refi, z_test_refi, **kw)
|
|
102
|
+
return _auto_clip(artists, self.ax, self._clip_path)
|
|
103
|
+
|
|
104
|
+
def tricontourf(self, **kwargs):
|
|
105
|
+
x, y, z = kwargs.pop("x"), kwargs.pop("y"), kwargs.pop("z")
|
|
106
|
+
import matplotlib.tri as tri
|
|
107
|
+
|
|
108
|
+
triang = tri.Triangulation(x, y)
|
|
109
|
+
refiner = tri.UniformTriRefiner(triang)
|
|
110
|
+
tri_refi, z_refi = refiner.refine_field(z, subdiv=3)
|
|
111
|
+
|
|
112
|
+
kw = self._merge("tricontourf", kwargs)
|
|
113
|
+
|
|
114
|
+
z_masked, vmin_eff, vmax_eff = _mask_by_extend(
|
|
115
|
+
z_refi,
|
|
116
|
+
extend=kw.get("extend", "neither"),
|
|
117
|
+
vmin=kw.get("vmin"),
|
|
118
|
+
vmax=kw.get("vmax"),
|
|
119
|
+
levels=kw.get("levels"),
|
|
120
|
+
norm=kw.get("norm"),
|
|
121
|
+
)
|
|
122
|
+
try:
|
|
123
|
+
print("Adapter 184 -> ", z_refi.max(), z_refi.min())
|
|
124
|
+
if kw.get("levels", False) and isinstance(kw.get("levels", False), int):
|
|
125
|
+
kw["levels"] = np.linspace(kw.get("vmin"), kw.get("vmax"), kw.get("levels"))
|
|
126
|
+
except TypeError:
|
|
127
|
+
pass
|
|
128
|
+
if kw.get("norm") is not None:
|
|
129
|
+
kw.pop("vmin", None)
|
|
130
|
+
kw.pop("vmax", None)
|
|
131
|
+
else:
|
|
132
|
+
kw.setdefault("vmin", vmin_eff)
|
|
133
|
+
kw.setdefault("vmax", vmax_eff)
|
|
134
|
+
|
|
135
|
+
z_mask_arr = np.ma.getmaskarray(z_masked)
|
|
136
|
+
if z_mask_arr is not False and z_mask_arr is not None:
|
|
137
|
+
tri_mask = np.any(z_mask_arr[tri_refi.triangles], axis=1)
|
|
138
|
+
tri_refi.set_mask(tri_mask)
|
|
139
|
+
z_for_plot = np.asarray(np.ma.filled(z_masked, 0.0))
|
|
140
|
+
else:
|
|
141
|
+
z_for_plot = np.asarray(z_masked)
|
|
142
|
+
|
|
143
|
+
artists = self.ax.tricontourf(tri_refi, z_for_plot, **kw)
|
|
144
|
+
return _auto_clip(artists, self.ax, self._clip_path)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def voronoi(self, **kwargs):
|
|
149
|
+
if {"x", "y", "z"}.issubset(kwargs.keys()):
|
|
150
|
+
return self.voronoi_cmapfill(**kwargs)
|
|
151
|
+
elif {"x", "y"}.issubset(kwargs.keys()):
|
|
152
|
+
return self.voronoi_colorfill(**kwargs)
|
|
153
|
+
|
|
154
|
+
def voronoi_colorfill(self, **kwargs):
|
|
155
|
+
"""Fill selected Voronoi cells with a single facecolor (no z / no colorbar).
|
|
156
|
+
|
|
157
|
+
Required:
|
|
158
|
+
- x, y
|
|
159
|
+
Optional:
|
|
160
|
+
- where: boolean mask (same shape as x/y). If provided, only True cells are filled.
|
|
161
|
+
- facecolor, edgecolor, linewidth/linewidths, draw_edges, antialiased, extent, radius, zorder
|
|
162
|
+
"""
|
|
163
|
+
import numpy as _np
|
|
164
|
+
try:
|
|
165
|
+
from scipy.spatial import Voronoi
|
|
166
|
+
except Exception as e:
|
|
167
|
+
raise ImportError("voronoi requires scipy.spatial.Voronoi. Please install scipy.") from e
|
|
168
|
+
|
|
169
|
+
x = kwargs.pop("x")
|
|
170
|
+
y = kwargs.pop("y")
|
|
171
|
+
where = kwargs.pop("where", None)
|
|
172
|
+
|
|
173
|
+
# Keep matplotlib fill kwargs intact (facecolor/edgecolor/linewidth/alpha/etc.).
|
|
174
|
+
# Only consume voronoi-specific options here.
|
|
175
|
+
extent = kwargs.pop("extent", None) # data-space
|
|
176
|
+
radius = kwargs.pop("radius", None)
|
|
177
|
+
|
|
178
|
+
if x.size == 0:
|
|
179
|
+
return []
|
|
180
|
+
|
|
181
|
+
# ---- derive view box & transforms from axes ----
|
|
182
|
+
xlim = self.ax.get_xlim()
|
|
183
|
+
ylim = self.ax.get_ylim()
|
|
184
|
+
if extent is None:
|
|
185
|
+
extent = (min(xlim), max(xlim), min(ylim), max(ylim))
|
|
186
|
+
xmin, xmax, ymin, ymax = extent
|
|
187
|
+
|
|
188
|
+
t_data_to_disp = self.ax.transData
|
|
189
|
+
t_disp_to_data = t_data_to_disp.inverted()
|
|
190
|
+
|
|
191
|
+
disp_ll = t_data_to_disp.transform((xmin, ymin))
|
|
192
|
+
disp_ur = t_data_to_disp.transform((xmax, ymax))
|
|
193
|
+
disp_x0, disp_y0 = disp_ll
|
|
194
|
+
disp_x1, disp_y1 = disp_ur
|
|
195
|
+
|
|
196
|
+
# robust ordering / non-zero spans
|
|
197
|
+
if disp_x1 == disp_x0:
|
|
198
|
+
disp_x1 = disp_x0 + 1.0
|
|
199
|
+
if disp_y1 == disp_y0:
|
|
200
|
+
disp_y1 = disp_y0 + 1.0
|
|
201
|
+
if disp_x1 < disp_x0:
|
|
202
|
+
disp_x0, disp_x1 = disp_x1, disp_x0
|
|
203
|
+
if disp_y1 < disp_y0:
|
|
204
|
+
disp_y0, disp_y1 = disp_y1, disp_y0
|
|
205
|
+
|
|
206
|
+
disp_w = (disp_x1 - disp_x0)
|
|
207
|
+
disp_h = (disp_y1 - disp_y0)
|
|
208
|
+
|
|
209
|
+
pts_disp = t_data_to_disp.transform(_np.c_[x, y])
|
|
210
|
+
pts_norm = _np.c_[
|
|
211
|
+
(pts_disp[:, 0] - disp_x0) / disp_w,
|
|
212
|
+
(pts_disp[:, 1] - disp_y0) / disp_h,
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
if not _np.all(_np.isfinite(pts_norm)):
|
|
216
|
+
pts_norm = _np.nan_to_num(pts_norm, nan=0.5, posinf=1.0, neginf=0.0)
|
|
217
|
+
|
|
218
|
+
vor = Voronoi(pts_norm)
|
|
219
|
+
|
|
220
|
+
regions, vertices = voronoi_finite_polygons_2d(vor, radius=radius)
|
|
221
|
+
unit_rect = (0.0, 1.0, 0.0, 1.0)
|
|
222
|
+
|
|
223
|
+
polys_fill = []
|
|
224
|
+
for i_pt, region in enumerate(regions):
|
|
225
|
+
if not region:
|
|
226
|
+
continue
|
|
227
|
+
if where is not None and (not bool(where[i_pt])):
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
poly = vertices[region]
|
|
231
|
+
poly = [(float(px), float(py)) for px, py in poly]
|
|
232
|
+
poly = _clip_poly_to_rect(poly, unit_rect)
|
|
233
|
+
if len(poly) < 3:
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
# norm -> display -> data
|
|
237
|
+
poly_disp = _np.c_[
|
|
238
|
+
_np.array([p[0] for p in poly]) * disp_w + disp_x0,
|
|
239
|
+
_np.array([p[1] for p in poly]) * disp_h + disp_y0,
|
|
240
|
+
]
|
|
241
|
+
poly_data = [tuple(p) for p in t_disp_to_data.transform(poly_disp)]
|
|
242
|
+
polys_fill.append(poly_data)
|
|
243
|
+
|
|
244
|
+
if len(polys_fill) == 0:
|
|
245
|
+
return []
|
|
246
|
+
|
|
247
|
+
# Merge selected Voronoi cells into one (possibly multi-) polygon, then fill using ax.fill
|
|
248
|
+
try:
|
|
249
|
+
from shapely.geometry import Polygon as _SHPPolygon
|
|
250
|
+
from shapely.ops import unary_union as _shp_unary_union
|
|
251
|
+
except Exception as e:
|
|
252
|
+
raise ImportError("voronoi_colorfill merge requires shapely. Please install shapely.") from e
|
|
253
|
+
|
|
254
|
+
shp_polys = []
|
|
255
|
+
for poly in polys_fill:
|
|
256
|
+
try:
|
|
257
|
+
g = _SHPPolygon(poly)
|
|
258
|
+
if not g.is_valid:
|
|
259
|
+
g = g.buffer(0)
|
|
260
|
+
if (not g.is_empty) and g.area > 0:
|
|
261
|
+
shp_polys.append(g)
|
|
262
|
+
except Exception:
|
|
263
|
+
continue
|
|
264
|
+
|
|
265
|
+
if not shp_polys:
|
|
266
|
+
return []
|
|
267
|
+
|
|
268
|
+
merged = _shp_unary_union(shp_polys)
|
|
269
|
+
if merged.is_empty:
|
|
270
|
+
return []
|
|
271
|
+
|
|
272
|
+
# Inherit matplotlib fill kwargs (plus any defaults for 'fill')
|
|
273
|
+
kw = self._merge("fill", kwargs)
|
|
274
|
+
|
|
275
|
+
artists = []
|
|
276
|
+
if merged.geom_type == "Polygon":
|
|
277
|
+
parts = [merged]
|
|
278
|
+
else:
|
|
279
|
+
parts = list(getattr(merged, "geoms", []))
|
|
280
|
+
|
|
281
|
+
for g in parts:
|
|
282
|
+
if g.is_empty:
|
|
283
|
+
continue
|
|
284
|
+
xs, ys = g.exterior.coords.xy
|
|
285
|
+
artists.extend(self.ax.fill(list(xs), list(ys), **kw))
|
|
286
|
+
|
|
287
|
+
return _auto_clip(artists, self.ax, self._clip_path)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def voronoi_cmapfill(self, **kwargs):
|
|
291
|
+
import matplotlib as mpl
|
|
292
|
+
x = kwargs.pop("x")
|
|
293
|
+
y = kwargs.pop("y")
|
|
294
|
+
z = kwargs.pop("z")
|
|
295
|
+
where = kwargs.pop("where", None)
|
|
296
|
+
cmap = kwargs.pop("cmap", None)
|
|
297
|
+
if isinstance(cmap, str):
|
|
298
|
+
try:
|
|
299
|
+
cmap = mpl.colormaps.get(cmap)
|
|
300
|
+
except Exception:
|
|
301
|
+
cmap = None
|
|
302
|
+
# Now proceed as before
|
|
303
|
+
import numpy as _np
|
|
304
|
+
try:
|
|
305
|
+
from scipy.spatial import Voronoi
|
|
306
|
+
except Exception as e:
|
|
307
|
+
raise ImportError("voronoi requires scipy.spatial.Voronoi. Please install scipy.") from e
|
|
308
|
+
|
|
309
|
+
# ---- inputs ----
|
|
310
|
+
x = _np.asarray(x)
|
|
311
|
+
y = _np.asarray(y)
|
|
312
|
+
z = _np.asarray(z)
|
|
313
|
+
if where is not None:
|
|
314
|
+
where = _np.asarray(where, dtype=bool)
|
|
315
|
+
if where.shape != x.shape:
|
|
316
|
+
raise ValueError("voronoi: 'where' must have the same shape as x/y")
|
|
317
|
+
# print(x, y, z)
|
|
318
|
+
# self.ax.scatter(x, y, s=0.3, marker='.', c="#FF42A1", zorder=10, edgecolors="none")
|
|
319
|
+
vmin = kwargs.pop("vmin", None)
|
|
320
|
+
vmax = kwargs.pop("vmax", None)
|
|
321
|
+
edgecolor = kwargs.pop("edgecolor", 'none')
|
|
322
|
+
draw_edges = kwargs.pop("draw_edges", True)
|
|
323
|
+
antialiased = kwargs.pop("antialiased", False)
|
|
324
|
+
orig_lw = kwargs.pop("linewidth", kwargs.pop("linewidths", 0.0))
|
|
325
|
+
extent = kwargs.pop("extent", None) # data-space
|
|
326
|
+
radius = kwargs.pop("radius", None)
|
|
327
|
+
nan_color = kwargs.pop("nan_color", None)
|
|
328
|
+
zorder = kwargs.pop("zorder", None)
|
|
329
|
+
|
|
330
|
+
# ---- derive view box & transforms from axes ----
|
|
331
|
+
xlim = self.ax.get_xlim()
|
|
332
|
+
ylim = self.ax.get_ylim()
|
|
333
|
+
# print(xlim, ylim)
|
|
334
|
+
if extent is None:
|
|
335
|
+
extent = (min(xlim), max(xlim), min(ylim), max(ylim))
|
|
336
|
+
xmin, xmax, ymin, ymax = extent
|
|
337
|
+
|
|
338
|
+
t_data_to_disp = self.ax.transData
|
|
339
|
+
t_disp_to_data = t_data_to_disp.inverted()
|
|
340
|
+
|
|
341
|
+
disp_ll = t_data_to_disp.transform((xmin, ymin))
|
|
342
|
+
disp_ur = t_data_to_disp.transform((xmax, ymax))
|
|
343
|
+
disp_x0, disp_y0 = disp_ll
|
|
344
|
+
disp_x1, disp_y1 = disp_ur
|
|
345
|
+
if disp_x1 == disp_x0:
|
|
346
|
+
disp_x1 = disp_x0 + 1.0
|
|
347
|
+
if disp_y1 == disp_y0:
|
|
348
|
+
disp_y1 = disp_y0 + 1.0
|
|
349
|
+
if disp_x1 < disp_x0:
|
|
350
|
+
disp_x0, disp_x1 = disp_x1, disp_x0
|
|
351
|
+
if disp_y1 < disp_y0:
|
|
352
|
+
disp_y0, disp_y1 = disp_y1, disp_y0
|
|
353
|
+
disp_w = (disp_x1 - disp_x0)
|
|
354
|
+
disp_h = (disp_y1 - disp_y0)
|
|
355
|
+
|
|
356
|
+
pts_disp = t_data_to_disp.transform(_np.c_[x, y])
|
|
357
|
+
pts_norm = _np.c_[ (pts_disp[:,0] - disp_x0)/disp_w, (pts_disp[:,1] - disp_y0)/disp_h ]
|
|
358
|
+
|
|
359
|
+
if not _np.all(_np.isfinite(pts_norm)):
|
|
360
|
+
pts_norm = _np.nan_to_num(pts_norm, nan=0.5, posinf=1.0, neginf=0.0)
|
|
361
|
+
|
|
362
|
+
vor = Voronoi(pts_norm)
|
|
363
|
+
|
|
364
|
+
from .helper import voronoi_finite_polygons_2d, _clip_poly_to_rect
|
|
365
|
+
regions, vertices = voronoi_finite_polygons_2d(vor, radius=radius)
|
|
366
|
+
unit_rect = (0.0, 1.0, 0.0, 1.0)
|
|
367
|
+
|
|
368
|
+
polys_valid, zvals_valid = [], []
|
|
369
|
+
polys_bg = []
|
|
370
|
+
def _is_invalid(val):
|
|
371
|
+
try:
|
|
372
|
+
return (val is None) or (not _np.isfinite(float(val)))
|
|
373
|
+
except Exception:
|
|
374
|
+
return True
|
|
375
|
+
|
|
376
|
+
for i_pt, region in enumerate(regions):
|
|
377
|
+
if not region:
|
|
378
|
+
continue
|
|
379
|
+
if where is not None and (not bool(where[i_pt])):
|
|
380
|
+
continue
|
|
381
|
+
poly = vertices[region]
|
|
382
|
+
poly = [(float(px), float(py)) for px, py in poly]
|
|
383
|
+
poly = _clip_poly_to_rect(poly, unit_rect)
|
|
384
|
+
if len(poly) < 3:
|
|
385
|
+
continue
|
|
386
|
+
poly_disp = _np.c_[_np.array([p[0] for p in poly])*disp_w + disp_x0,
|
|
387
|
+
_np.array([p[1] for p in poly])*disp_h + disp_y0]
|
|
388
|
+
poly_data = [tuple(p) for p in t_disp_to_data.transform(poly_disp)]
|
|
389
|
+
val = z[i_pt]
|
|
390
|
+
if _is_invalid(val):
|
|
391
|
+
polys_bg.append(poly_data)
|
|
392
|
+
else:
|
|
393
|
+
polys_valid.append(poly_data)
|
|
394
|
+
zvals_valid.append(float(val))
|
|
395
|
+
|
|
396
|
+
from matplotlib.collections import PolyCollection
|
|
397
|
+
artists = []
|
|
398
|
+
from matplotlib import colors as mcolors
|
|
399
|
+
norm = kwargs.pop("norm", None)
|
|
400
|
+
if norm is None and (vmin is not None or vmax is not None):
|
|
401
|
+
norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
|
|
402
|
+
|
|
403
|
+
pc = PolyCollection(
|
|
404
|
+
polys_valid,
|
|
405
|
+
array=_np.asarray(zvals_valid),
|
|
406
|
+
cmap=cmap,
|
|
407
|
+
edgecolor='none',
|
|
408
|
+
linewidth=0.0,
|
|
409
|
+
norm=norm,
|
|
410
|
+
antialiased=antialiased,
|
|
411
|
+
)
|
|
412
|
+
if zorder is not None:
|
|
413
|
+
pc.set_zorder(zorder)
|
|
414
|
+
|
|
415
|
+
artists.append(self.ax.add_collection(pc))
|
|
416
|
+
|
|
417
|
+
return _auto_clip(artists, self.ax, self._clip_path)
|
|
418
|
+
|
|
419
|
+
def voronoif(self, **kwargs):
|
|
420
|
+
"""Hatched fill for a boundary layer of a where-selected Voronoi region.
|
|
421
|
+
|
|
422
|
+
Algorithm:
|
|
423
|
+
1) Use `where` to select Voronoi cells and union them into region A.
|
|
424
|
+
2) For each selected cell, keep it only if the site point (core) is within
|
|
425
|
+
`core_dist` (in axes-intrinsic unit-square coords) of the *boundary of A*.
|
|
426
|
+
The kept cells form region B.
|
|
427
|
+
3) Hatch-fill the union of B.
|
|
428
|
+
|
|
429
|
+
Inputs:
|
|
430
|
+
- x, y: 1D arrays of site positions (data coordinates)
|
|
431
|
+
- where: optional 1D boolean array; True selects the corresponding Voronoi cell for region A
|
|
432
|
+
|
|
433
|
+
Keyword options:
|
|
434
|
+
- core_dist: float, core-to-boundary(A) distance threshold in unit-square coords (default 0.05)
|
|
435
|
+
- hatch: str (default '///')
|
|
436
|
+
- extent: (xmin, xmax, ymin, ymax) in data coords (default: current view)
|
|
437
|
+
- radius: passed to voronoi_finite_polygons_2d
|
|
438
|
+
- frame_strip: float, exclude hatch within this strip near axes frame (unit-square, default 0.0)
|
|
439
|
+
- All standard `fill` kwargs are inherited (facecolor/edgecolor/linewidth/alpha/...) via adapter defaults.
|
|
440
|
+
"""
|
|
441
|
+
import numpy as _np
|
|
442
|
+
|
|
443
|
+
x = kwargs.pop("x")
|
|
444
|
+
y = kwargs.pop("y")
|
|
445
|
+
where = kwargs.pop("where", None)
|
|
446
|
+
|
|
447
|
+
core_dist = 0.025
|
|
448
|
+
frame_strip = 0.0
|
|
449
|
+
|
|
450
|
+
kw_all = self._merge("fill", kwargs)
|
|
451
|
+
from .helper import split_fill_kwargs
|
|
452
|
+
kw_edge, kw_face, kw_rest = split_fill_kwargs(kw_all)
|
|
453
|
+
kw_edge.update(kw_rest)
|
|
454
|
+
kw_face.update(kw_rest)
|
|
455
|
+
# ---- inputs ----
|
|
456
|
+
x = _np.asarray(x)
|
|
457
|
+
y = _np.asarray(y)
|
|
458
|
+
if x.shape != y.shape:
|
|
459
|
+
raise ValueError("voronoif: x and y must have the same shape")
|
|
460
|
+
if x.size == 0:
|
|
461
|
+
return []
|
|
462
|
+
|
|
463
|
+
if where is None:
|
|
464
|
+
where = _np.ones_like(x, dtype=bool)
|
|
465
|
+
|
|
466
|
+
try:
|
|
467
|
+
from scipy.spatial import Voronoi
|
|
468
|
+
except Exception as e:
|
|
469
|
+
raise ImportError("voronoif requires scipy.spatial.Voronoi. Please install scipy.") from e
|
|
470
|
+
|
|
471
|
+
try:
|
|
472
|
+
from shapely.geometry import Polygon as _SHPPolygon, Point as _SHPPoint, box as _SHPBox
|
|
473
|
+
from shapely.ops import unary_union as _shp_unary_union
|
|
474
|
+
except Exception as e:
|
|
475
|
+
raise ImportError("voronoif requires shapely. Please install shapely.") from e
|
|
476
|
+
|
|
477
|
+
# ---- derive view box & transforms from axes ----
|
|
478
|
+
xlim = self.ax.get_xlim()
|
|
479
|
+
ylim = self.ax.get_ylim()
|
|
480
|
+
extent = (min(xlim), max(xlim), min(ylim), max(ylim))
|
|
481
|
+
xmin, xmax, ymin, ymax = extent
|
|
482
|
+
|
|
483
|
+
t_data_to_disp = self.ax.transData
|
|
484
|
+
|
|
485
|
+
disp_ll = t_data_to_disp.transform((xmin, ymin))
|
|
486
|
+
disp_ur = t_data_to_disp.transform((xmax, ymax))
|
|
487
|
+
disp_x0, disp_y0 = disp_ll
|
|
488
|
+
disp_x1, disp_y1 = disp_ur
|
|
489
|
+
|
|
490
|
+
# robust ordering / non-zero spans
|
|
491
|
+
if disp_x1 == disp_x0:
|
|
492
|
+
disp_x1 = disp_x0 + 1.0
|
|
493
|
+
if disp_y1 == disp_y0:
|
|
494
|
+
disp_y1 = disp_y0 + 1.0
|
|
495
|
+
if disp_x1 < disp_x0:
|
|
496
|
+
disp_x0, disp_x1 = disp_x1, disp_x0
|
|
497
|
+
if disp_y1 < disp_y0:
|
|
498
|
+
disp_y0, disp_y1 = disp_y1, disp_y0
|
|
499
|
+
|
|
500
|
+
disp_w = (disp_x1 - disp_x0)
|
|
501
|
+
disp_h = (disp_y1 - disp_y0)
|
|
502
|
+
|
|
503
|
+
# ---- sites in axes-intrinsic unit-square coords ----
|
|
504
|
+
pts_disp = t_data_to_disp.transform(_np.c_[x, y])
|
|
505
|
+
pts_norm = _np.c_[
|
|
506
|
+
(pts_disp[:, 0] - disp_x0) / disp_w,
|
|
507
|
+
(pts_disp[:, 1] - disp_y0) / disp_h,
|
|
508
|
+
]
|
|
509
|
+
if not _np.all(_np.isfinite(pts_norm)):
|
|
510
|
+
pts_norm = _np.nan_to_num(pts_norm, nan=0.5, posinf=1.0, neginf=0.0)
|
|
511
|
+
|
|
512
|
+
vor = Voronoi(pts_norm)
|
|
513
|
+
regions, vertices = voronoi_finite_polygons_2d(vor)
|
|
514
|
+
unit_rect = (0.0, 1.0, 0.0, 1.0)
|
|
515
|
+
|
|
516
|
+
# ---- collect A: where-selected cell polygons (unit-square) ----
|
|
517
|
+
polys_A_unit = []
|
|
518
|
+
idx_A = []
|
|
519
|
+
poly_by_idx = {}
|
|
520
|
+
for i_pt, region in enumerate(regions):
|
|
521
|
+
if (not where[i_pt]) or (not region):
|
|
522
|
+
continue
|
|
523
|
+
poly = vertices[region]
|
|
524
|
+
poly = [(float(px), float(py)) for px, py in poly]
|
|
525
|
+
poly = _clip_poly_to_rect(poly, unit_rect)
|
|
526
|
+
if len(poly) < 3:
|
|
527
|
+
continue
|
|
528
|
+
try:
|
|
529
|
+
g = _SHPPolygon(poly)
|
|
530
|
+
if not g.is_valid:
|
|
531
|
+
g = g.buffer(0)
|
|
532
|
+
if g.is_empty or g.area <= 0:
|
|
533
|
+
continue
|
|
534
|
+
polys_A_unit.append(g)
|
|
535
|
+
idx_A.append(i_pt)
|
|
536
|
+
poly_by_idx[i_pt] = g
|
|
537
|
+
except Exception:
|
|
538
|
+
continue
|
|
539
|
+
|
|
540
|
+
if not polys_A_unit:
|
|
541
|
+
return []
|
|
542
|
+
|
|
543
|
+
A = _shp_unary_union(polys_A_unit)
|
|
544
|
+
if A.is_empty:
|
|
545
|
+
return []
|
|
546
|
+
|
|
547
|
+
# ---- filter cells in A by core distance to boundary(A) ----
|
|
548
|
+
B_polys = []
|
|
549
|
+
bnd = A.boundary
|
|
550
|
+
|
|
551
|
+
# Exclude boundary segments that coincide with the axes frame (unit-square edges).
|
|
552
|
+
# We do this by intersecting with a slightly shrunken unit box, removing edges at u/v=0/1.
|
|
553
|
+
eps = 1.e-9
|
|
554
|
+
inner = _SHPBox(eps, eps, 1.0 - eps, 1.0 - eps)
|
|
555
|
+
bnd = bnd.intersection(inner)
|
|
556
|
+
# bnd: shapely LineString/MultiLineString in unit-square coords
|
|
557
|
+
if not (bnd is None or bnd.is_empty):
|
|
558
|
+
lines = []
|
|
559
|
+
gt = bnd.geom_type
|
|
560
|
+
if gt == "LineString":
|
|
561
|
+
lines = [bnd]
|
|
562
|
+
elif gt == "MultiLineString":
|
|
563
|
+
lines = list(bnd.geoms)
|
|
564
|
+
elif gt == "GeometryCollection":
|
|
565
|
+
for g in bnd.geoms:
|
|
566
|
+
if g.geom_type == "LineString":
|
|
567
|
+
lines.append(g)
|
|
568
|
+
elif g.geom_type == "MultiLineString":
|
|
569
|
+
lines.extend(list(g.geoms))
|
|
570
|
+
for line in lines:
|
|
571
|
+
lind = line.buffer(0.001, cap_style=2, join_style=2)
|
|
572
|
+
xs, ys = lind.exterior.coords.xy
|
|
573
|
+
self.ax.fill(list(xs), list(ys), **kw_edge, transform=self.ax.transAxes)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
for i_pt in idx_A:
|
|
577
|
+
u, v = float(pts_norm[i_pt, 0]), float(pts_norm[i_pt, 1])
|
|
578
|
+
try:
|
|
579
|
+
d = _SHPPoint(u, v).distance(bnd)
|
|
580
|
+
except Exception:
|
|
581
|
+
continue
|
|
582
|
+
if d < core_dist:
|
|
583
|
+
B_polys.append(poly_by_idx[i_pt])
|
|
584
|
+
|
|
585
|
+
if not B_polys:
|
|
586
|
+
return []
|
|
587
|
+
|
|
588
|
+
B = _shp_unary_union(B_polys)
|
|
589
|
+
if B.is_empty:
|
|
590
|
+
return []
|
|
591
|
+
|
|
592
|
+
# Inherit matplotlib fill kwargs (plus any defaults for 'fill')
|
|
593
|
+
kw_face = self._merge("fill", kw_face)
|
|
594
|
+
kw_face['linewidth'] = 0.
|
|
595
|
+
|
|
596
|
+
artists = []
|
|
597
|
+
if B.geom_type == "Polygon":
|
|
598
|
+
parts = [B]
|
|
599
|
+
else:
|
|
600
|
+
parts = list(getattr(B, "geoms", []))
|
|
601
|
+
|
|
602
|
+
for g in parts:
|
|
603
|
+
if g.is_empty:
|
|
604
|
+
continue
|
|
605
|
+
xs, ys = g.exterior.coords.xy
|
|
606
|
+
artists.extend(self.ax.fill(list(xs), list(ys), **kw_face, transform=self.ax.transAxes))
|
|
607
|
+
|
|
608
|
+
return _auto_clip(artists, self.ax, self._clip_path)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
# ---- optionally exclude hatch near the axes frame ----
|
|
614
|
+
if frame_strip > 0:
|
|
615
|
+
F = _SHPBox(0.0, 0.0, 1.0, 1.0)
|
|
616
|
+
Fin = F.buffer(-frame_strip)
|
|
617
|
+
strip = F.difference(Fin) if not Fin.is_empty else F
|
|
618
|
+
B = B.difference(strip.intersection(B))
|
|
619
|
+
if B.is_empty:
|
|
620
|
+
return []
|
|
621
|
+
|
|
622
|
+
# ---- draw hatch fill (HOLE-AWARE) ----
|
|
623
|
+
# NOTE: ax.fill cannot represent holes; use a PathPatch so removed interior cells stay unhatched.
|
|
624
|
+
kw = self._merge("fill", kw_face)
|
|
625
|
+
# kw.setdefault("facecolor", "none")
|
|
626
|
+
kw["linewidth"]= 0.
|
|
627
|
+
print(kw)
|
|
628
|
+
|
|
629
|
+
# kw.setdefault("hatch", hatch)
|
|
630
|
+
|
|
631
|
+
# Build a compound Path from shapely geometry (Polygon/MultiPolygon) with holes.
|
|
632
|
+
def _geom_to_path(geom):
|
|
633
|
+
if geom.is_empty:
|
|
634
|
+
return None
|
|
635
|
+
polys = [geom] if geom.geom_type == "Polygon" else list(getattr(geom, "geoms", []))
|
|
636
|
+
verts = []
|
|
637
|
+
codes = []
|
|
638
|
+
|
|
639
|
+
def _add_ring(coords):
|
|
640
|
+
ring = list(coords)
|
|
641
|
+
if len(ring) < 3:
|
|
642
|
+
return
|
|
643
|
+
# Matplotlib expects the last vertex for CLOSEPOLY; shapely rings already repeat the first.
|
|
644
|
+
# Ensure we have at least 4 points including the closing point.
|
|
645
|
+
if ring[0] != ring[-1]:
|
|
646
|
+
ring.append(ring[0])
|
|
647
|
+
verts.extend(ring)
|
|
648
|
+
codes.extend([Path.MOVETO] + [Path.LINETO] * (len(ring) - 2) + [Path.CLOSEPOLY])
|
|
649
|
+
|
|
650
|
+
for p in polys:
|
|
651
|
+
if p.is_empty:
|
|
652
|
+
continue
|
|
653
|
+
_add_ring(p.exterior.coords)
|
|
654
|
+
for hole in p.interiors:
|
|
655
|
+
_add_ring(hole.coords)
|
|
656
|
+
|
|
657
|
+
if not verts:
|
|
658
|
+
return None
|
|
659
|
+
return Path(verts, codes)
|
|
660
|
+
|
|
661
|
+
path = _geom_to_path(B)
|
|
662
|
+
if path is None:
|
|
663
|
+
return []
|
|
664
|
+
|
|
665
|
+
patch = PathPatch(path, transform=self.ax.transAxes, **kw)
|
|
666
|
+
if zorder is not None:
|
|
667
|
+
patch.set_zorder(zorder)
|
|
668
|
+
|
|
669
|
+
artist = self.ax.add_patch(patch)
|
|
670
|
+
return _auto_clip([artist], self.ax, self._clip_path)
|
|
671
|
+
|
|
672
|
+
# 为了兼容现有框架,暴露底层的方法/属性
|
|
673
|
+
def __getattr__(self, name: str):
|
|
674
|
+
# 未覆写的方法透传给原始 Axes
|
|
675
|
+
return getattr(self.ax, name)
|
|
676
|
+
|
|
677
|
+
def hist(self, *args, **kwargs):
|
|
678
|
+
import matplotlib.pyplot as plt
|
|
679
|
+
stacked = kwargs.get('stacked', False)
|
|
680
|
+
colors = kwargs.get('color', None) or kwargs.get('colors', None)
|
|
681
|
+
|
|
682
|
+
# Get the number of data groups: supports x as args[0] or kwargs['x']
|
|
683
|
+
x = kwargs.get('x', args[0] if args else None)
|
|
684
|
+
n_groups = None
|
|
685
|
+
if stacked and colors is None and x is not None:
|
|
686
|
+
# Only when x is two-dimensional data, len(x) is the number of groups (in the case of multiple arrays stacked)
|
|
687
|
+
try:
|
|
688
|
+
n_groups = len(x) if hasattr(x, '__len__') and not isinstance(x, (str, bytes)) else 1
|
|
689
|
+
except Exception:
|
|
690
|
+
n_groups = 1
|
|
691
|
+
default_colors = self._get_default_colors(n_groups)
|
|
692
|
+
kwargs['color'] = default_colors
|
|
693
|
+
|
|
694
|
+
kw = self._merge("hist", kwargs)
|
|
695
|
+
artists = self.ax.hist(*args, **kw)
|
|
696
|
+
return _auto_clip(artists, self.ax, self._clip_path)
|
|
697
|
+
|
|
698
|
+
def _get_default_colors(self, n):
|
|
699
|
+
import matplotlib.pyplot as plt
|
|
700
|
+
# print(getattr(self, "color_palette"))
|
|
701
|
+
palette = self.config['hist']['color']
|
|
702
|
+
print(len(palette), n)
|
|
703
|
+
|
|
704
|
+
# palette = getattr(self, 'color_palette', plt.cm.tab10.colors)
|
|
705
|
+
# print(palette)
|
|
706
|
+
return [palette[i % len(palette)] for i in range(n)]
|
|
707
|
+
|
|
708
|
+
# —— Ternary 适配器:在 Std 基础上增加 (a,b,c)->(x,y) 投影 ——
|
|
709
|
+
class TernaryAxesAdapter(StdAxesAdapter):
|
|
710
|
+
def __init__(self, ax: Axes, defaults: Optional[Dict[str, Any]] = None,
|
|
711
|
+
clip_path=None):
|
|
712
|
+
# Allow a flat defaults dict like {"facecolor": "..."} and keep it internal.
|
|
713
|
+
d = dict(defaults) if isinstance(defaults, dict) else {}
|
|
714
|
+
facecolor = d.pop("facecolor", None)
|
|
715
|
+
|
|
716
|
+
super().__init__(ax, defaults=d or None, clip_path=clip_path)
|
|
717
|
+
|
|
718
|
+
if facecolor is not None:
|
|
719
|
+
self.set_facecolor(facecolor)
|
|
720
|
+
|
|
721
|
+
self.status = "init"
|
|
722
|
+
|
|
723
|
+
def set_facecolor(self, color, zorder=-100):
|
|
724
|
+
self.ax.fill(
|
|
725
|
+
[0.0, 1.0, 0.5],
|
|
726
|
+
[0.0, 0.0, 1.0],
|
|
727
|
+
facecolor=color,
|
|
728
|
+
edgecolor="none",
|
|
729
|
+
zorder=zorder
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
@staticmethod
|
|
733
|
+
def _lbr_to_xy(a, b, c):
|
|
734
|
+
s = (a + b + c)
|
|
735
|
+
s = np.where(s == 0.0, 1.0, s) # 避免除零
|
|
736
|
+
aa, bb, cc = a/s, b/s, c/s
|
|
737
|
+
x = bb + 0.5 * cc
|
|
738
|
+
y = cc
|
|
739
|
+
return x, y
|
|
740
|
+
|
|
741
|
+
def scatter(self, **kwargs):
|
|
742
|
+
if {"left", "right", "bottom"}.issubset(kwargs.keys()):
|
|
743
|
+
x, y = self._lbr_to_xy(kwargs.pop('left'), kwargs.pop('right'), kwargs.pop('bottom'))
|
|
744
|
+
kwargs['x'] = x
|
|
745
|
+
kwargs['y'] = y
|
|
746
|
+
return super().scatter(**kwargs)
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def plot(self, **kwargs):
|
|
750
|
+
if {"left", "right", "bottom"}.issubset(kwargs.keys()):
|
|
751
|
+
x, y = self._lbr_to_xy(kwargs.pop('left'), kwargs.pop('right'), kwargs.pop('bottom'))
|
|
752
|
+
kwargs['x'] = x
|
|
753
|
+
kwargs['y'] = y
|
|
754
|
+
return super().plot(**kwargs)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def tricontour(self, **kwargs):
|
|
758
|
+
if {"left", "right", "bottom"}.issubset(kwargs.keys()):
|
|
759
|
+
x, y = self._lbr_to_xy(kwargs.pop('left'), kwargs.pop('right'), kwargs.pop('bottom'))
|
|
760
|
+
kwargs['x'] = x
|
|
761
|
+
kwargs['y'] = y
|
|
762
|
+
return super().tricontour( **kwargs)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def tricontourf(self, **kwargs):
|
|
766
|
+
if {"left", "right", "bottom"}.issubset(kwargs.keys()):
|
|
767
|
+
x, y = self._lbr_to_xy(kwargs.pop('left'), kwargs.pop('right'), kwargs.pop('bottom'))
|
|
768
|
+
kwargs['x'] = x
|
|
769
|
+
kwargs['y'] = y
|
|
770
|
+
|
|
771
|
+
return super().tricontourf(**kwargs)
|
|
772
|
+
# else:
|
|
773
|
+
# raise ValueError("scatter() needs either (a,b,c) or (x,y) inputs")
|