bsplot 0.0.2__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.
Files changed (89) hide show
  1. bsplot/__init__.py +23 -0
  2. bsplot/anat.py +55 -0
  3. bsplot/animate.py +194 -0
  4. bsplot/bioicons.py +777 -0
  5. bsplot/brain.py +188 -0
  6. bsplot/colors.py +1935 -0
  7. bsplot/data/HCP_avg-SC.txt +379 -0
  8. bsplot/data/MNI152.rh.pial +0 -0
  9. bsplot/data/STN_lh.nii.gz +0 -0
  10. bsplot/data/STN_rh.nii.gz +0 -0
  11. bsplot/data/__init__.py +22 -0
  12. bsplot/data/left_electrode.ply +257270 -0
  13. bsplot/data/parcellations/HCP-MMP1.L.label.gii +442 -0
  14. bsplot/data/parcellations/HCP-MMP1.R.label.gii +442 -0
  15. bsplot/data/parcellations/code/split_hcpmmp1.sh +4 -0
  16. bsplot/data/right_electrode.ply +257270 -0
  17. bsplot/data/surface.py +112 -0
  18. bsplot/data/tpl-MNI152NLin2009b_atlas-hcpmmp1_desc-ordered_dseg.nii.gz +0 -0
  19. bsplot/figure.py +177 -0
  20. bsplot/graph/__init__.py +31 -0
  21. bsplot/graph/edges.py +312 -0
  22. bsplot/graph/flowchart.py +762 -0
  23. bsplot/graph/layout.py +201 -0
  24. bsplot/graph/network.py +974 -0
  25. bsplot/graph/nodes.py +452 -0
  26. bsplot/panels.py +110 -0
  27. bsplot/scientific_color_maps/__init__.py +0 -0
  28. bsplot/scientific_color_maps/_sync_from_zenodo_download.sh +1 -0
  29. bsplot/scientific_color_maps/batlowK.txt +256 -0
  30. bsplot/scientific_color_maps/batlowW.txt +256 -0
  31. bsplot/scientific_color_maps/cyclic/bamO.txt +256 -0
  32. bsplot/scientific_color_maps/cyclic/brocO.txt +256 -0
  33. bsplot/scientific_color_maps/cyclic/corkO.txt +256 -0
  34. bsplot/scientific_color_maps/cyclic/romaO.txt +256 -0
  35. bsplot/scientific_color_maps/cyclic/vikO.txt +256 -0
  36. bsplot/scientific_color_maps/diverging/bam.txt +256 -0
  37. bsplot/scientific_color_maps/diverging/berlin.txt +256 -0
  38. bsplot/scientific_color_maps/diverging/broc.txt +256 -0
  39. bsplot/scientific_color_maps/diverging/cork.txt +256 -0
  40. bsplot/scientific_color_maps/diverging/lisbon.txt +256 -0
  41. bsplot/scientific_color_maps/diverging/managua.txt +256 -0
  42. bsplot/scientific_color_maps/diverging/roma.txt +256 -0
  43. bsplot/scientific_color_maps/diverging/tofino.txt +256 -0
  44. bsplot/scientific_color_maps/diverging/vanimo.txt +256 -0
  45. bsplot/scientific_color_maps/diverging/vik.txt +256 -0
  46. bsplot/scientific_color_maps/multisequential/bukavu.txt +256 -0
  47. bsplot/scientific_color_maps/multisequential/fes.txt +256 -0
  48. bsplot/scientific_color_maps/multisequential/oleron.txt +256 -0
  49. bsplot/scientific_color_maps/naviaW.txt +256 -0
  50. bsplot/scientific_color_maps/sequential/acton.txt +256 -0
  51. bsplot/scientific_color_maps/sequential/bamako.txt +256 -0
  52. bsplot/scientific_color_maps/sequential/batlow.txt +256 -0
  53. bsplot/scientific_color_maps/sequential/bilbao.txt +256 -0
  54. bsplot/scientific_color_maps/sequential/buda.txt +256 -0
  55. bsplot/scientific_color_maps/sequential/davos.txt +256 -0
  56. bsplot/scientific_color_maps/sequential/devon.txt +256 -0
  57. bsplot/scientific_color_maps/sequential/glasgow.txt +256 -0
  58. bsplot/scientific_color_maps/sequential/grayC.txt +256 -0
  59. bsplot/scientific_color_maps/sequential/hawaii.txt +256 -0
  60. bsplot/scientific_color_maps/sequential/imola.txt +256 -0
  61. bsplot/scientific_color_maps/sequential/lajolla.txt +256 -0
  62. bsplot/scientific_color_maps/sequential/lapaz.txt +256 -0
  63. bsplot/scientific_color_maps/sequential/lipari.txt +256 -0
  64. bsplot/scientific_color_maps/sequential/navia.txt +256 -0
  65. bsplot/scientific_color_maps/sequential/nuuk.txt +256 -0
  66. bsplot/scientific_color_maps/sequential/oslo.txt +256 -0
  67. bsplot/scientific_color_maps/sequential/tokyo.txt +256 -0
  68. bsplot/scientific_color_maps/sequential/turku.txt +256 -0
  69. bsplot/streamlines.py +314 -0
  70. bsplot/style.py +791 -0
  71. bsplot/styles/black.mplstyle +78 -0
  72. bsplot/styles/bss.mplstyle +50 -0
  73. bsplot/styles/bwcomp.mplstyle +30 -0
  74. bsplot/styles/nature.mplstyle +57 -0
  75. bsplot/styles/transparent.mplstyle +16 -0
  76. bsplot/styles/tvbo.mplstyle +57 -0
  77. bsplot/surface.py +2370 -0
  78. bsplot/templates.py +270 -0
  79. bsplot/text.py +199 -0
  80. bsplot/text2obj.py +59 -0
  81. bsplot/timeseries.py +10 -0
  82. bsplot/utils.py +13 -0
  83. bsplot/volume.py +958 -0
  84. bsplot-0.0.2.dist-info/METADATA +60 -0
  85. bsplot-0.0.2.dist-info/RECORD +89 -0
  86. bsplot-0.0.2.dist-info/WHEEL +5 -0
  87. bsplot-0.0.2.dist-info/entry_points.txt +2 -0
  88. bsplot-0.0.2.dist-info/licenses/LICENSE +193 -0
  89. bsplot-0.0.2.dist-info/top_level.txt +1 -0
bsplot/style.py ADDED
@@ -0,0 +1,791 @@
1
+ import os
2
+
3
+ import matplotlib.pyplot as plt
4
+ import numpy as np
5
+ from matplotlib.ticker import FormatStrFormatter, FuncFormatter
6
+ from bsplot import panels
7
+ import bsplot # Import your package to access its path
8
+ from .colors import make_cycler_from_cmap
9
+
10
+
11
+ def use(style="bss"):
12
+ """Apply one or more bsplot style modules.
13
+
14
+ Styles can be composed by separating modules with ``-``.
15
+ Modules are layered left-to-right, so later modules override earlier ones.
16
+
17
+ Examples
18
+ --------
19
+ >>> bsplot.style.use("black") # single base style
20
+ >>> bsplot.style.use("black-transparent") # base + module
21
+ >>> bsplot.style.use("tvbo-transparent") # works with any base
22
+ """
23
+ styles_dir = os.path.join(os.path.dirname(bsplot.__file__), "styles")
24
+
25
+ def _style_path(name):
26
+ p = os.path.join(styles_dir, f"{name}.mplstyle")
27
+ if not os.path.exists(p):
28
+ available = sorted(
29
+ f.removesuffix(".mplstyle")
30
+ for f in os.listdir(styles_dir)
31
+ if f.endswith(".mplstyle")
32
+ )
33
+ raise ValueError(
34
+ f"Style module '{name}' not found. "
35
+ f"Available modules: {available}"
36
+ )
37
+ return p
38
+
39
+ # Split on "-" to get composable modules: "black-transparent" -> ["black", "transparent"]
40
+ modules = style.split("-")
41
+ paths = [_style_path(m) for m in modules]
42
+ plt.style.use(paths)
43
+
44
+ # Turn off legacy layout flags that can conflict
45
+ plt.rcParams["figure.constrained_layout.use"] = False
46
+ plt.rcParams["figure.autolayout"] = False
47
+
48
+ if modules[0] == "black":
49
+ plt.rcParams["axes.prop_cycle"]._left[0] = {"color": "white"}
50
+
51
+
52
+ def format(obj, **kwargs):
53
+ """Intelligently format matplotlib objects based on their type.
54
+
55
+ This is a convenience function that dispatches to the appropriate formatter:
56
+ - Figure -> format_fig()
57
+ - Axes with label '<colorbar>' -> format_colorbar()
58
+ - Axes -> format_ax()
59
+ - list/tuple of objects -> applies format() to each
60
+
61
+ Parameters
62
+ ----------
63
+ obj : Figure, Axes, or list/tuple of these
64
+ The matplotlib object(s) to format.
65
+ **kwargs
66
+ Additional arguments passed to the appropriate formatting function.
67
+ Common kwargs:
68
+ - tick_decimals: int | None | "smart" for axes tick formatting
69
+ - colorbar_decimals: int | None for colorbar tick formatting
70
+ - add_panel_numbers: "auto" | True | False (for figures only)
71
+ - n_ticks: int, number of ticks per axis
72
+ - shift_left_spine, shift_bottom_spine: float, spine offsets
73
+
74
+ Returns
75
+ -------
76
+ obj
77
+ The formatted object (for chaining).
78
+
79
+ Examples
80
+ --------
81
+ >>> fig, ax = plt.subplots()
82
+ >>> ax.plot([1, 2, 3])
83
+ >>> style.format(fig) # Formats entire figure
84
+
85
+ >>> style.format(ax, n_ticks=5) # Format single axes
86
+
87
+ >>> style.format([ax1, ax2], tick_decimals=2) # Format multiple axes
88
+ """
89
+ import matplotlib.figure
90
+ import matplotlib.axes
91
+
92
+ # Handle lists/tuples of objects
93
+ if isinstance(obj, (list, tuple)):
94
+ for item in obj:
95
+ format(item, **kwargs)
96
+ return obj
97
+
98
+ # Handle Figure
99
+ if isinstance(obj, matplotlib.figure.Figure):
100
+ format_fig(obj, **kwargs)
101
+ return obj
102
+
103
+ # Handle Axes
104
+ if isinstance(obj, matplotlib.axes.Axes):
105
+ # Check if it's a colorbar
106
+ if obj.get_label() == "<colorbar>":
107
+ # Extract colorbar-specific kwargs
108
+ colorbar_kwargs = {}
109
+ if "colorbar_decimals" in kwargs:
110
+ colorbar_kwargs["colorbar_decimals"] = kwargs["colorbar_decimals"]
111
+ format_colorbar(obj, **colorbar_kwargs)
112
+ else:
113
+ # Filter out figure-only kwargs
114
+ ax_kwargs = {k: v for k, v in kwargs.items()
115
+ if k not in ("add_panel_numbers", "colorbar_decimals")}
116
+ format_ax(obj, **ax_kwargs)
117
+ return obj
118
+
119
+ # If we get here, we don't know how to handle this type
120
+ raise TypeError(
121
+ f"Cannot format object of type {type(obj).__name__}. "
122
+ f"Expected Figure, Axes, or list/tuple of these."
123
+ )
124
+
125
+
126
+ def format_fig(
127
+ fig, colorbar_decimals=None, tick_decimals=None, add_panel_numbers="auto", **kwargs
128
+ ):
129
+ """Format all axes in a figure.
130
+
131
+ Parameters (forwarded):
132
+ - tick_decimals: int | None | "smart" -> number of decimals for normal axes ticks.
133
+ If None or "smart", uses automatic detection based on data range.
134
+ - colorbar_decimals: int | None -> number of decimals for colorbar ticks.
135
+ If None, uses automatic detection based on data range.
136
+ - other kwargs are passed to format_ax/format_colorbar as appropriate
137
+ """
138
+ i = 1
139
+ for ax in fig.axes:
140
+ # Skip polar (and other non-rectilinear) axes – they lack standard spines
141
+ if ax.name != "rectilinear" and ax.axes.get_label() != "<colorbar>":
142
+ continue
143
+ if ax.axes.get_label() == "<colorbar>":
144
+ format_colorbar(ax, colorbar_decimals=colorbar_decimals)
145
+ else:
146
+ if "shift_left_spine" not in kwargs:
147
+ kwargs["shift_left_spine"] = -0.02
148
+ if "shift_bottom_spine" not in kwargs:
149
+ kwargs["shift_bottom_spine"] = -0.02
150
+ format_ax(
151
+ ax,
152
+ tick_decimals=tick_decimals,
153
+ **kwargs,
154
+ )
155
+ if add_panel_numbers != False and add_panel_numbers == "auto":
156
+ panels.add_panel_number(
157
+ ax, ax.get_label(), x_shift=kwargs["shift_left_spine"], y_shift=0.02
158
+ )
159
+
160
+ elif add_panel_numbers == True:
161
+ panels.add_panel_number(
162
+ ax, i, x_shift=kwargs["shift_left_spine"], y_shift=0.02
163
+ )
164
+ i += 1
165
+
166
+
167
+ def format_all(**kwargs):
168
+ """Format every open matplotlib figure.
169
+
170
+ Convenience wrapper that calls :func:`format_fig` on each figure
171
+ returned by ``plt.get_fignums()``.
172
+
173
+ Parameters
174
+ ----------
175
+ **kwargs
176
+ Forwarded to :func:`format_fig` (e.g. ``tick_decimals``,
177
+ ``add_panel_numbers``, ``colorbar_decimals``).
178
+ """
179
+ for num in plt.get_fignums():
180
+ format_fig(plt.figure(num), **kwargs)
181
+
182
+
183
+ def _has_string_ticklabels(ax, axis="x"):
184
+ """
185
+ Check if an axis has custom string-based (non-numeric) tick labels.
186
+
187
+ Returns True if any tick label is a non-numeric string that cannot be
188
+ converted to a float. This is used to preserve user-set categorical labels
189
+ like '$Dice_L$', 'Category A', etc.
190
+ """
191
+ try:
192
+ if axis == "x":
193
+ labels = [t.get_text() for t in ax.get_xticklabels()]
194
+ else:
195
+ labels = [t.get_text() for t in ax.get_yticklabels()]
196
+
197
+ # Filter out empty labels (matplotlib default)
198
+ labels = [l for l in labels if l.strip()]
199
+
200
+ if not labels:
201
+ return False
202
+
203
+ # Check if any label is non-numeric
204
+ for label in labels:
205
+ # Strip common LaTeX wrappers for numeric check
206
+ # Also replace Unicode minus (U+2212) with ASCII hyphen
207
+ clean = label.replace('$', '').replace(',', '').replace('\u2212', '-').strip()
208
+ try:
209
+ float(clean)
210
+ except ValueError:
211
+ # Found a non-numeric label
212
+ return True
213
+ return False
214
+ except Exception:
215
+ return False
216
+
217
+
218
+ def _nice_round(x, step, direction="down"):
219
+ """Round *x* to the nearest multiple of *step*.
220
+
221
+ ``"down"`` rounds toward −∞, ``"up"`` toward +∞.
222
+ """
223
+ if step == 0:
224
+ return x
225
+ if direction == "down":
226
+ return np.floor(x / step) * step
227
+ else:
228
+ return np.ceil(x / step) * step
229
+
230
+
231
+ def _auto_round_step(span):
232
+ """Derive a small rounding granularity from *span*.
233
+
234
+ Returns a value from {1, 2, 5} × 10^n that is roughly 1/20th of
235
+ the span, keeping padding minimal. Examples:
236
+
237
+ * span ≈ 250 → 10 (so 125 → 130)
238
+ * span ≈ 1.4 → 0.05
239
+ * span ≈ 30 → 1
240
+ """
241
+ if span <= 0:
242
+ return 1.0
243
+ target = span / 20.0
244
+ mag = 10 ** np.floor(np.log10(target))
245
+ residual = target / mag
246
+ if residual <= 1.5:
247
+ return mag
248
+ elif residual <= 3.5:
249
+ return 2.0 * mag
250
+ elif residual <= 7.5:
251
+ return 5.0 * mag
252
+ else:
253
+ return 10.0 * mag
254
+
255
+
256
+ def format_ax(
257
+ ax,
258
+ x_datatype="continuous",
259
+ n_ticks=4,
260
+ box_aspect=None,
261
+ shift_left_spine=-0.02,
262
+ shift_bottom_spine=-0.02,
263
+ ylim=None,
264
+ xlim=None,
265
+ tick_decimals=None,
266
+ y_datatype=None,
267
+ datatype=None,
268
+ x_tick_decimals=None,
269
+ y_tick_decimals=None,
270
+ n_x_ticks=None,
271
+ n_y_ticks=None,
272
+ scientific=False,
273
+ round_step=None,
274
+ ):
275
+ """
276
+ Format matplotlib Axes:
277
+ - shifts spines
278
+ - enforces aspect ratio
279
+ - continuous: evenly spaced numeric ticks with optional rounding
280
+ - discrete: integer ticks (first, last, and evenly spaced in between)
281
+
282
+ Parameters:
283
+ - x_datatype: "continuous" | "discrete"
284
+ - y_datatype: "continuous" | "discrete" | None (defaults to x_datatype)
285
+ - datatype: "continuous" | "discrete" | None (if set, applies to both axes)
286
+ - n_ticks: maximum number of ticks per axis (endpoints included when possible)
287
+ - tick_decimals: int | "smart" | None -> default formatting for continuous ticks
288
+ - x_tick_decimals: int | "smart" | None -> override for x (falls back to tick_decimals)
289
+ - y_tick_decimals: int | "smart" | None -> override for y (falls back to tick_decimals)
290
+ - n_x_ticks: int | None -> override tick count for x-axis (falls back to n_ticks)
291
+ - n_y_ticks: int | None -> override tick count for y-axis (falls back to n_ticks)
292
+ - scientific: bool -> if True and smart formatting is selected, allow scientific
293
+ notation with a single axis-wide exponent; if False, never use scientific mode.
294
+ - round_step: float | None -> rounding granularity for the axis endpoints.
295
+ E.g. round_step=10 rounds -67 → -70, 87 → 90.
296
+ If None (default), the step is automatically derived from the data range.
297
+
298
+ Note:
299
+ - Tick formatting is skipped for axes with string-based (categorical) tick labels.
300
+ This preserves custom labels like LaTeX strings or category names.
301
+ """
302
+ # If a single datatype is provided, apply to both axes (unless y_datatype explicitly set)
303
+ if datatype is not None:
304
+ x_datatype = datatype
305
+ if y_datatype is None:
306
+ y_datatype = datatype
307
+
308
+ if y_datatype is None:
309
+ y_datatype = x_datatype
310
+
311
+ # Polar / non-rectilinear axes lack standard spines; bail out early.
312
+ if ax.name != "rectilinear":
313
+ return
314
+
315
+ if ylim is not None:
316
+ ax.set_ylim(ylim)
317
+ if xlim is not None:
318
+ ax.set_xlim(xlim)
319
+
320
+ ax.spines["left"].set_position(("axes", shift_left_spine))
321
+ ax.spines["bottom"].set_position(("axes", shift_bottom_spine))
322
+
323
+ if box_aspect:
324
+ ax.set_box_aspect(box_aspect)
325
+
326
+ # Resolve per-axis decimals for continuous axes
327
+ _x_dec = x_tick_decimals if x_tick_decimals is not None else tick_decimals
328
+ _y_dec = y_tick_decimals if y_tick_decimals is not None else tick_decimals
329
+
330
+ # Resolve per-axis tick counts
331
+ _nx = n_x_ticks if n_x_ticks is not None else n_ticks
332
+ _ny = n_y_ticks if n_y_ticks is not None else n_ticks
333
+
334
+ # Helper to trim spines to tick range
335
+ def _update_spine_bounds(axis: str, ticks):
336
+ if ticks is None or len(ticks) < 2:
337
+ return
338
+ lo, hi = float(np.min(ticks)), float(np.max(ticks))
339
+ try:
340
+ if axis == "x":
341
+ ax.spines["bottom"].set_bounds(lo, hi)
342
+ else:
343
+ ax.spines["left"].set_bounds(lo, hi)
344
+ except Exception:
345
+ # set_bounds not available for some artists; ignore
346
+ pass
347
+
348
+ # Helpers
349
+ def _set_continuous(axis: str):
350
+ k = _nx if axis == "x" else _ny
351
+ k = max(2, int(k)) # ensure at least 2 ticks
352
+ if k == 0:
353
+ (ax.set_xticks([]) if axis == "x" else ax.set_yticks([]))
354
+ return
355
+ lo, hi = ax.get_xlim() if axis == "x" else ax.get_ylim()
356
+ a, b = min(lo, hi), max(lo, hi)
357
+ # Guard against degenerate or non-finite limits
358
+ if not np.isfinite(a) or not np.isfinite(b) or a == b:
359
+ center = a if np.isfinite(a) else (b if np.isfinite(b) else 0.0)
360
+ span = (
361
+ 1.0 if not np.isfinite(a) or not np.isfinite(b) or a == b else (b - a)
362
+ )
363
+ a, b = center - span / 2.0, center + span / 2.0
364
+
365
+ # Round endpoints outward by a small amount so the axis starts
366
+ # and ends at a round number. The rounding step is ~1/10th of
367
+ # the span by default (e.g. 125 → 130 for a 250-wide range).
368
+ step = round_step if round_step is not None else _auto_round_step(b - a)
369
+ a_nice = _nice_round(a, step, "down")
370
+ b_nice = _nice_round(b, step, "up")
371
+
372
+ ticks = np.linspace(a_nice, b_nice, k)
373
+ ticks[0], ticks[-1] = a_nice, b_nice # keep endpoints exact
374
+ # Round intermediate ticks to the same step for clean labels
375
+ for i in range(1, len(ticks) - 1):
376
+ ticks[i] = np.round(ticks[i] / step) * step
377
+
378
+ dec = _x_dec if axis == "x" else _y_dec
379
+ if isinstance(dec, int):
380
+ rounded = np.round(ticks, dec)
381
+ if np.unique(rounded).size == rounded.size:
382
+ ticks = rounded
383
+
384
+ t_lo, t_hi = float(ticks[0]), float(ticks[-1])
385
+
386
+ if axis == "x":
387
+ ax.set_xlim(t_lo, t_hi)
388
+ ax.set_xticks(ticks.tolist())
389
+ if isinstance(dec, int):
390
+ if np.unique(np.round(ticks, dec)).size < 2:
391
+ fmt, offset = make_smart_formatter(t_lo, t_hi, k, allow_sci=scientific)
392
+ ax.xaxis.set_major_formatter(FuncFormatter(fmt))
393
+ _apply_axis_offset(ax, "x", offset, pos="top")
394
+ else:
395
+ ax.xaxis.set_major_formatter(FormatStrFormatter(f"%.{dec}f"))
396
+ else:
397
+ fmt, offset = make_smart_formatter(t_lo, t_hi, k, allow_sci=scientific)
398
+ ax.xaxis.set_major_formatter(FuncFormatter(fmt))
399
+ _apply_axis_offset(ax, "x", offset, pos="top")
400
+ _update_spine_bounds("x", ticks)
401
+ else:
402
+ ax.set_ylim(t_lo, t_hi)
403
+ ax.set_yticks(ticks.tolist())
404
+ if isinstance(dec, int):
405
+ if np.unique(np.round(ticks, dec)).size < 2:
406
+ fmt, offset = make_smart_formatter(t_lo, t_hi, k, allow_sci=scientific)
407
+ ax.yaxis.set_major_formatter(FuncFormatter(fmt))
408
+ _apply_axis_offset(ax, "y", offset, pos="right")
409
+ else:
410
+ ax.yaxis.set_major_formatter(FormatStrFormatter(f"%.{dec}f"))
411
+ else:
412
+ fmt, offset = make_smart_formatter(t_lo, t_hi, k, allow_sci=scientific)
413
+ ax.yaxis.set_major_formatter(FuncFormatter(fmt))
414
+ _apply_axis_offset(ax, "y", offset, pos="right")
415
+ _update_spine_bounds("y", ticks)
416
+
417
+ def _set_discrete(axis: str):
418
+ k_in = _nx if axis == "x" else _ny
419
+ if k_in == 0:
420
+ (ax.set_xticks([]) if axis == "x" else ax.set_yticks([]))
421
+ return
422
+ lo, hi = ax.get_xlim() if axis == "x" else ax.get_ylim()
423
+ a, b = min(lo, hi), max(lo, hi)
424
+ # For imshow-like limits [-0.5, N-0.5], integer indices are [0, N-1]
425
+ ia = int(np.round(a + 0.5))
426
+ ib = int(np.round(b - 0.5))
427
+ if ib < ia:
428
+ ib = ia
429
+ count = ib - ia + 1
430
+ k = max(2, min(int(k_in), count))
431
+ ticks = np.round(np.linspace(ia, ib, k)).astype(int)
432
+ ticks = np.unique(ticks)
433
+ if ticks.size < 2:
434
+ ticks = np.array([ia, ib], dtype=int)
435
+ else:
436
+ ticks[0] = ia
437
+ ticks[-1] = ib
438
+ if axis == "x":
439
+ ax.set_xticks(ticks.tolist())
440
+ ax.xaxis.set_major_formatter(FormatStrFormatter("%d"))
441
+ # Trim bottom spine to tick range
442
+ _update_spine_bounds("x", ticks)
443
+ else:
444
+ ax.set_yticks(ticks.tolist())
445
+ ax.yaxis.set_major_formatter(FormatStrFormatter("%d"))
446
+ # Trim left spine to tick range
447
+ _update_spine_bounds("y", ticks)
448
+
449
+ # Check for string-based tick labels before modifying ticks
450
+ x_has_string_labels = _has_string_ticklabels(ax, "x")
451
+ y_has_string_labels = _has_string_ticklabels(ax, "y")
452
+
453
+ # Apply per-axis (skip if string labels are present)
454
+ if not x_has_string_labels:
455
+ if x_datatype == "continuous":
456
+ _set_continuous("x")
457
+ elif x_datatype == "discrete":
458
+ _set_discrete("x")
459
+
460
+ if not y_has_string_labels:
461
+ if y_datatype == "continuous":
462
+ _set_continuous("y")
463
+ elif y_datatype == "discrete":
464
+ _set_discrete("y")
465
+
466
+
467
+ def smart_format(x):
468
+ """Adaptive tick formatter:
469
+ - Uses scientific notation for very small/large values.
470
+ - Otherwise formats with up to 4 decimals and strips trailing zeros.
471
+ - Avoids negative zero.
472
+ """
473
+ # Normalize near-zero to 0
474
+ if abs(x) < 1e-12:
475
+ return "0"
476
+
477
+ m = abs(x)
478
+ # Extreme magnitudes -> scientific with 3 significant digits
479
+ if m >= 1e4 or m < 1e-3:
480
+ s = f"{x:.3g}"
481
+ else:
482
+ # Adaptive fixed-point decimals by magnitude
483
+ if m >= 100:
484
+ dec = 0
485
+ elif m >= 10:
486
+ dec = 1
487
+ elif m >= 1:
488
+ dec = 2
489
+ elif m >= 0.1:
490
+ dec = 3
491
+ else:
492
+ dec = 4
493
+ s = f"{x:.{dec}f}"
494
+ if "." in s:
495
+ s = s.rstrip("0").rstrip(".")
496
+ if s == "-0":
497
+ s = "0"
498
+ return s
499
+
500
+
501
+ def make_smart_formatter(lo, hi, k=5, allow_sci=True):
502
+ """
503
+ Axis-consistent smart formatter:
504
+ - If scientific is chosen (and allowed), show mantissas as tick labels and a single 'e±NN'
505
+ as axis offset text (no mixed styles on the same axis).
506
+ - Else, fixed-point with decimals derived from tick step.
507
+ Returns (formatter_func, offset_text or None).
508
+ """
509
+ a, b = (min(lo, hi), max(lo, hi))
510
+ magnitude = max(abs(a), abs(b))
511
+ step = (b - a) / max(k - 1, 1) if k and k > 1 else (b - a)
512
+
513
+ use_sci = allow_sci and (
514
+ (magnitude >= 1e4)
515
+ or (0 < magnitude < 1e-3)
516
+ or (abs(step) >= 1e4)
517
+ or (0 < abs(step) < 1e-3)
518
+ )
519
+
520
+ if use_sci:
521
+ if magnitude == 0:
522
+ E = 0
523
+ else:
524
+ E = int(np.floor(np.log10(magnitude)))
525
+ scale = 10.0**E
526
+ step_scaled = abs(step) / scale if step != 0 else 0
527
+ # Build tick mantissas for uniqueness test
528
+ ticks = np.linspace(a, b, max(2, int(k))) if k and k > 1 else np.array([a, b])
529
+ mant_ticks = ticks / scale if scale != 0 else ticks
530
+
531
+ # Choose the minimal decimals (<=6) that yield unique rounded mantissas
532
+ dec = 6
533
+ for d in range(0, 7):
534
+ S = 10**d
535
+ ints = np.round(mant_ticks * S).astype(np.int64)
536
+ if np.unique(ints).size == ints.size:
537
+ dec = d
538
+ break
539
+
540
+ def fmt(v, _pos=None):
541
+ mant = v / scale
542
+ val = np.round(mant, dec)
543
+ s = f"{val:.{dec}f}"
544
+ if "." in s:
545
+ s = s.rstrip("0").rstrip(".")
546
+ return "0" if s == "-0" else s
547
+
548
+ sign = "-" if E < 0 else "+"
549
+ offset = f"e{sign}{abs(E):02d}"
550
+ return fmt, offset
551
+
552
+ # Fixed-point mode
553
+ # Fixed-point mode: choose minimal decimals so that all tick labels are unique
554
+ ticks = np.linspace(a, b, max(2, int(k))) if k and k > 1 else np.array([a, b])
555
+ dec = 6
556
+ for d in range(0, 7):
557
+ S = 10**d
558
+ ints = np.round(ticks * S).astype(np.int64)
559
+ if np.unique(ints).size == ints.size:
560
+ dec = d
561
+ break
562
+
563
+ def fmt(v, _pos=None):
564
+ val = np.round(v, dec)
565
+ s = f"{val:.{dec}f}"
566
+ if "." in s:
567
+ s = s.rstrip("0").rstrip(".")
568
+ return "0" if s == "-0" else s
569
+
570
+ return fmt, None
571
+
572
+
573
+ def _apply_axis_offset(ax, axis, offset, pos="top"):
574
+ """
575
+ Apply an offset string to an axis in a version-safe way.
576
+ axis: "x" | "y"
577
+ pos: for x -> "top"/"bottom"; for y -> "left"/"right"
578
+ """
579
+ # Remove any previous manual label if no offset requested
580
+ if not offset:
581
+ tag = "_bss_offset_text_x" if axis == "x" else "_bss_offset_text_y"
582
+ prev = getattr(ax, tag, None)
583
+ if prev is not None:
584
+ try:
585
+ prev.remove()
586
+ except Exception:
587
+ pass
588
+ setattr(ax, tag, None)
589
+ return
590
+
591
+ axis_obj = ax.xaxis if axis == "x" else ax.yaxis
592
+ # Try native API if available
593
+ if hasattr(axis_obj, "set_offset_position"):
594
+ try:
595
+ axis_obj.set_offset_position(pos)
596
+ axis_obj.offsetText.set_visible(True)
597
+ axis_obj.offsetText.set_text(offset)
598
+ return
599
+ except Exception:
600
+ pass
601
+
602
+ # Fallback: draw our own text
603
+ tag = "_bss_offset_text_x" if axis == "x" else "_bss_offset_text_y"
604
+ prev = getattr(ax, tag, None)
605
+ if prev is not None:
606
+ try:
607
+ prev.remove()
608
+ except Exception:
609
+ pass
610
+
611
+ if axis == "x":
612
+ # top or bottom placement in axes coords
613
+ y = 1.02 if pos == "top" else -0.08
614
+ txt = ax.text(1.0, y, offset, transform=ax.transAxes, ha="right", va="bottom")
615
+ else:
616
+ # y-axis: place at top tick, on the requested side of the axis
617
+ if pos == "right":
618
+ # slightly inside the axes so it's to the right of the y-axis line/ticks
619
+ txt = ax.text(
620
+ 0.01, 1.0, offset, transform=ax.transAxes, ha="left", va="top"
621
+ )
622
+ else:
623
+ txt = ax.text(
624
+ -0.02, 1.0, offset, transform=ax.transAxes, ha="right", va="top"
625
+ )
626
+ setattr(ax, tag, txt)
627
+
628
+
629
+ def format_colorbar(ax, colorbar_decimals=None):
630
+ for spine in ax.spines.values():
631
+ spine.set_visible(False)
632
+ orientation = (
633
+ "vertical"
634
+ if ax.get_position().width < ax.get_position().height
635
+ else "horizontal"
636
+ )
637
+
638
+ # Try to get the actual data range from the colorbar's mappable
639
+ # This is more reliable than axis limits which may not be set yet
640
+ mappable = getattr(ax, 'mappable', None) if hasattr(ax, 'mappable') else None
641
+ if mappable is None:
642
+ # Try to find mappable from images or collections
643
+ for child in ax.get_children():
644
+ if hasattr(child, 'get_clim'):
645
+ mappable = child
646
+ break
647
+
648
+ def _safe_limits(val_tuple, fallback_tuple):
649
+ """Safely convert limits to floats, with fallback."""
650
+ try:
651
+ vmin, vmax = val_tuple
652
+ vmin, vmax = float(vmin), float(vmax)
653
+ if np.isfinite(vmin) and np.isfinite(vmax) and vmin != vmax:
654
+ return vmin, vmax
655
+ except (TypeError, ValueError):
656
+ pass
657
+ try:
658
+ vmin, vmax = fallback_tuple
659
+ vmin, vmax = float(vmin), float(vmax)
660
+ if np.isfinite(vmin) and np.isfinite(vmax) and vmin != vmax:
661
+ return vmin, vmax
662
+ except (TypeError, ValueError):
663
+ pass
664
+ return None, None
665
+
666
+ if orientation == "vertical":
667
+ clim = mappable.get_clim() if mappable is not None and hasattr(mappable, 'get_clim') else (None, None)
668
+ vmin, vmax = _safe_limits(clim, ax.get_ylim())
669
+
670
+ # Skip formatting if limits are invalid
671
+ if vmin is None or vmax is None:
672
+ return
673
+
674
+ ticks = [vmin, 0 if vmin < 0 and vmax > 0 else (vmin + vmax) / 2, vmax]
675
+ ax.set_yticks(ticks)
676
+ if colorbar_decimals is not None:
677
+ # Increase decimals if all ticks round to zero (i.e., would be shown as 0,0,0)
678
+ dec = int(colorbar_decimals)
679
+ max_dec = 8
680
+
681
+ def all_round_to_zero(vals, d):
682
+ return all(round(float(v), d) == 0.0 for v in vals)
683
+
684
+ while dec < max_dec and all_round_to_zero(ticks, dec):
685
+ dec += 1
686
+ ax.yaxis.set_major_formatter(FormatStrFormatter(f"%.{dec}f"))
687
+ _apply_axis_offset(ax, "y", None)
688
+ else:
689
+ fmt, offset = make_smart_formatter(vmin, vmax, k=3)
690
+ ax.yaxis.set_major_formatter(FuncFormatter(fmt))
691
+ _apply_axis_offset(ax, "y", offset, pos="left")
692
+ else:
693
+ clim = mappable.get_clim() if mappable is not None and hasattr(mappable, 'get_clim') else (None, None)
694
+ vmin, vmax = _safe_limits(clim, ax.get_xlim())
695
+
696
+ # Skip formatting if limits are invalid
697
+ if vmin is None or vmax is None:
698
+ return
699
+
700
+ ticks = [vmin, 0 if vmin < 0 and vmax > 0 else (vmin + vmax) / 2, vmax]
701
+ ax.set_xticks(ticks)
702
+ if colorbar_decimals is not None:
703
+ # Increase decimals if all ticks round to zero (i.e., would be shown as 0,0,0)
704
+ dec = int(colorbar_decimals)
705
+ max_dec = 8
706
+
707
+ def all_round_to_zero(vals, d):
708
+ return all(round(float(v), d) == 0.0 for v in vals)
709
+
710
+ while dec < max_dec and all_round_to_zero(ticks, dec):
711
+ dec += 1
712
+ ax.xaxis.set_major_formatter(FormatStrFormatter(f"%.{dec}f"))
713
+ _apply_axis_offset(ax, "x", None)
714
+ else:
715
+ fmt, offset = make_smart_formatter(vmin, vmax, k=3)
716
+ ax.xaxis.set_major_formatter(FuncFormatter(fmt))
717
+ _apply_axis_offset(ax, "x", offset, pos="top")
718
+
719
+ # Fully disable clipping on tick marks so extreme ticks are visible
720
+ for tick in ax.yaxis.get_major_ticks() + ax.xaxis.get_major_ticks():
721
+ for line in [tick.tick1line, tick.tick2line]:
722
+ line.set_clip_on(False)
723
+ line.set_clip_box(None)
724
+ line.set_clip_path(None)
725
+
726
+
727
+ def arrowed_spines(ax=None, arrow_length=1, labels=("", ""), arrowprops=None):
728
+ xlabel, ylabel = labels
729
+ if ax is None:
730
+ ax = plt.gca()
731
+ if arrowprops is None:
732
+ arrowprops = dict(arrowstyle="<|-", facecolor="black", lw=1)
733
+
734
+ for i, spine in enumerate(["left", "bottom"]):
735
+ # Set up the annotation parameters
736
+ t = ax.spines[spine].get_transform()
737
+ xy, xycoords = [1, 0], ("axes fraction", t)
738
+ xytext, textcoords = [arrow_length, 0], ("offset points", t)
739
+ ha, va = "left", "bottom"
740
+
741
+ # If axis is reversed, draw the arrow the other way
742
+ top, bottom = ax.spines[spine].axis.get_view_interval()
743
+ if top > bottom:
744
+ xy[0] = 0
745
+ xytext[0] *= -1
746
+ ha, va = "right", "top"
747
+
748
+ if spine == "bottom":
749
+ xarrow = ax.annotate(
750
+ xlabel,
751
+ xy,
752
+ xycoords=xycoords,
753
+ xytext=xytext,
754
+ textcoords=textcoords,
755
+ ha=ha,
756
+ va="center",
757
+ arrowprops=arrowprops,
758
+ )
759
+ else:
760
+ yarrow = ax.annotate(
761
+ ylabel,
762
+ xy[::-1],
763
+ xycoords=xycoords[::-1],
764
+ xytext=xytext[::-1],
765
+ textcoords=textcoords[::-1],
766
+ ha="center",
767
+ va=va,
768
+ arrowprops=arrowprops,
769
+ )
770
+ return xarrow, yarrow
771
+
772
+
773
+ def set_color_cycle_from_cmap(
774
+ cmap="viridis", n=8, start=0.0, stop=1.0, endpoint=True, reverse=False, to_hex=False
775
+ ):
776
+ """
777
+ Update Matplotlib rcParams to use a color cycle sampled from a continuous colormap.
778
+
779
+ Example:
780
+ set_color_cycle_from_cmap("viridis", n=6)
781
+ """
782
+ cyc = make_cycler_from_cmap(
783
+ cmap=cmap,
784
+ n=n,
785
+ start=start,
786
+ stop=stop,
787
+ endpoint=endpoint,
788
+ reverse=reverse,
789
+ to_hex=to_hex,
790
+ )
791
+ plt.rcParams["axes.prop_cycle"] = cyc