gsplot 0.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.
@@ -0,0 +1,952 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ from typing import Any, Callable, TypeVar, cast
5
+
6
+ import matplotlib.pyplot as plt
7
+ import numpy as np
8
+ from matplotlib.axes import Axes
9
+ from matplotlib.transforms import Bbox
10
+ from numpy.typing import NDArray
11
+
12
+ from .figure_tools import FigureLayout
13
+
14
+ F = TypeVar("F", bound=Callable[..., Any])
15
+
16
+ __all__: list[str] = []
17
+
18
+
19
+ class AxesResolver:
20
+ """
21
+ Resolves an axis target to a Matplotlib `Axes` object or its index.
22
+
23
+ This class provides a mechanism to convert an axis target, which can be either
24
+ an integer (index of the axis) or an `Axes` object, into a consistent representation
25
+ including the corresponding `Axes` object and its index within the current figure.
26
+
27
+ Parameters
28
+ --------------------
29
+ axis_target : int or matplotlib.axes.Axes
30
+ The target axis to resolve. Can be an integer representing the index of the
31
+ axis in the current figure or a specific `Axes` object.
32
+
33
+ Attributes
34
+ --------------------
35
+ axis_target : int or matplotlib.axes.Axes
36
+ The input target axis (as provided by the user).
37
+ _axis_index : int or None
38
+ The resolved index of the target axis in the current figure.
39
+ _axis : matplotlib.axes.Axes or None
40
+ The resolved `Axes` object corresponding to the target.
41
+
42
+ Methods
43
+ --------------------
44
+ _resolve_type()
45
+ Resolves the type of the axis target and retrieves the corresponding
46
+ `Axes` object and its index.
47
+ axis_index
48
+ Returns the resolved index of the axis.
49
+ axis
50
+ Returns the resolved `Axes` object.
51
+
52
+ Raises
53
+ --------------------
54
+ IndexError
55
+ If the provided axis index is out of range for the current figure.
56
+ ValueError
57
+ If the axis target is neither an integer nor an `Axes` object.
58
+
59
+ Examples
60
+ --------------------
61
+ >>> import matplotlib.pyplot as plt
62
+ >>> fig, axs = plt.subplots(2, 2)
63
+ >>> resolver = AxesResolver(1) # Resolves the second axis (index 1)
64
+ >>> print(resolver.axis)
65
+ AxesSubplot(0.5,0.5;0.352273x0.352273)
66
+
67
+ >>> resolver = AxesResolver(axs[0, 0]) # Resolves an Axes object directly
68
+ >>> print(resolver.axis_index)
69
+ 0
70
+ """
71
+
72
+ def __init__(self, axis_target: int | Axes) -> None:
73
+ self.axis_target: int | Axes = axis_target
74
+
75
+ self._axis_index: int | None = None
76
+ self._axis: Axes | None = None
77
+
78
+ self._resolve_type()
79
+
80
+ def _resolve_type(self) -> None:
81
+ """
82
+ Resolves the type of the axis target and retrieves the corresponding
83
+ `Axes` object and its index.
84
+
85
+ Raises
86
+ --------------------
87
+ IndexError
88
+ If the provided axis index is out of range for the current figure.
89
+ ValueError
90
+ If the axis target is neither an integer nor an `Axes` object.
91
+ """
92
+
93
+ def ordinal_suffix(n: int) -> str:
94
+ if 11 <= n % 100 <= 13:
95
+ suffix = "th"
96
+ else:
97
+ suffix = {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
98
+ return f"{n}{suffix}"
99
+
100
+ if isinstance(self.axis_target, int):
101
+ self._axis_index = self.axis_target
102
+ axes = plt.gcf().axes
103
+ try:
104
+ self._axis = axes[self._axis_index]
105
+ except IndexError:
106
+ error_message = f"Axes out of range: {self._axis_index} => Number of axes: {len(axes)}, but requested {ordinal_suffix(self._axis_index + 1)} axis."
107
+ raise IndexError(error_message)
108
+
109
+ elif isinstance(self.axis_target, Axes):
110
+ self._axis = self.axis_target
111
+ self._axis_index = plt.gcf().axes.index(self._axis)
112
+ else:
113
+ raise ValueError(
114
+ "Invalid axis target. Please provide an integer or Axes object."
115
+ )
116
+
117
+ @property
118
+ def axis_index(self) -> int:
119
+ """
120
+ Returns the resolved index of the target axis.
121
+
122
+ Returns
123
+ --------------------
124
+ int
125
+ The index of the resolved axis.
126
+
127
+ Raises
128
+ --------------------
129
+ ValueError
130
+ If the axis index is not resolved.
131
+ """
132
+ if isinstance(self._axis_index, int):
133
+ return self._axis_index
134
+ else:
135
+ raise ValueError("Axis index not resolved. Please check the AxisResolver")
136
+
137
+ @property
138
+ def axis(self) -> Axes:
139
+ """
140
+ Returns the resolved `Axes` object.
141
+
142
+ Returns
143
+ --------------------
144
+ matplotlib.axes.Axes
145
+ The resolved `Axes` object.
146
+
147
+ Raises
148
+ --------------------
149
+ ValueError
150
+ If the axis is not resolved.
151
+ """
152
+ if isinstance(self._axis, Axes):
153
+ return self._axis
154
+ else:
155
+ raise ValueError("Axis not resolced. Please check the AxisResolver")
156
+
157
+
158
+ class AxesRangeSingleton:
159
+ """
160
+ A thread-safe singleton class for managing axis ranges in Matplotlib figures.
161
+
162
+ This class provides functionality to store and update axis ranges for all axes
163
+ in a figure, ensuring consistency across different parts of the application.
164
+ It maintains axis ranges for each axis and can dynamically extend or reset the stored ranges.
165
+
166
+ Attributes
167
+ --------------------
168
+ _instance : AxesRangeSingleton or None
169
+ The singleton instance of the `AxesRangeSingleton` class.
170
+ _lock : threading.Lock
171
+ A lock to ensure thread-safe access to the singleton instance.
172
+ _axes_ranges : list of list[Any]
173
+ A list storing ranges for each axis, where each range is represented as `[xrange, yrange]`.
174
+
175
+ Methods
176
+ --------------------
177
+ axes_ranges
178
+ Retrieves the list of axis ranges, ensuring its size matches the number of axes in the current figure.
179
+ add_range(axis_index, xrange, yrange)
180
+ Adds or updates the range for a specific axis.
181
+ get_max_wo_inf(array)
182
+ Returns the maximum value in an array, ignoring infinities.
183
+ get_min_wo_inf(array)
184
+ Returns the minimum value in an array, ignoring infinities.
185
+ reset(axes)
186
+ Resets the stored ranges to match the provided list of axes.
187
+ update(func)
188
+ A decorator to update axis ranges based on data and ensure consistency.
189
+
190
+ Examples
191
+ --------------------
192
+ >>> axes_ranges = AxesRangeSingleton()
193
+ >>> print(axes_ranges.axes_ranges)
194
+ [[None, None]]
195
+
196
+ >>> axes_ranges.add_range(0, np.array([0, 10]), np.array([0, 20]))
197
+ >>> print(axes_ranges.axes_ranges)
198
+ [array([0, 10]), array([0, 20])]
199
+
200
+ >>> axes_ranges.reset(plt.gcf().axes)
201
+ >>> print(axes_ranges.axes_ranges)
202
+ [[None, None], [None, None]] # Resets to the number of current figure axes
203
+ """
204
+
205
+ _instance: AxesRangeSingleton | None = None
206
+ _lock: threading.Lock = threading.Lock() # Lock to ensure thread safety
207
+
208
+ def __new__(cls) -> "AxesRangeSingleton":
209
+ with cls._lock: # Ensure thread safety
210
+ if cls._instance is None:
211
+ cls._instance = super(AxesRangeSingleton, cls).__new__(cls)
212
+ cls._instance._initialize_axes_ranges()
213
+ return cls._instance
214
+
215
+ def _initialize_axes_ranges(self) -> None:
216
+ """
217
+ Initializes the axis ranges storage with a default value.
218
+ """
219
+
220
+ # Explicitly initialize the instance variable with a type hint
221
+ self._axes_ranges: list[list[Any]] = [[None, None]]
222
+
223
+ def ensure_size_of_axes_ranges(self) -> None:
224
+ """
225
+ Ensures the `_axes_ranges` list matches the number of axes in the current figure.
226
+ """
227
+
228
+ axes = plt.gcf().axes
229
+ axes_length = len(axes)
230
+ num_elements_to_append = max(0, axes_length - len(self._axes_ranges))
231
+ self._axes_ranges.extend([[None, None]] * num_elements_to_append)
232
+
233
+ @property
234
+ def axes_ranges(self) -> list[list[Any]]:
235
+ """
236
+ Retrieves the list of axis ranges, ensuring its size matches the number of axes.
237
+
238
+ Returns
239
+ --------------------
240
+ list of list[Any]
241
+ The list of axis ranges.
242
+ """
243
+
244
+ self.ensure_size_of_axes_ranges()
245
+ return self._axes_ranges
246
+
247
+ @axes_ranges.setter
248
+ def axes_ranges(
249
+ self,
250
+ axes_ranges: list[list[Any]],
251
+ ) -> None:
252
+ """
253
+ Sets the axis ranges to a new list.
254
+
255
+ Parameters
256
+ --------------------
257
+ axes_ranges : list of list[Any]
258
+ The new axis ranges to set.
259
+
260
+ Raises
261
+ --------------------
262
+ TypeError
263
+ If the provided value is not iterable.
264
+ """
265
+
266
+ try:
267
+ iter(axes_ranges)
268
+ except TypeError:
269
+ raise TypeError(f"Expected an iterable, got {type(axes_ranges).__name__}")
270
+ self._axes_ranges = axes_ranges
271
+
272
+ def add_range(
273
+ self, axis_index: int, xrange: NDArray[Any], yrange: NDArray[Any]
274
+ ) -> None:
275
+ """
276
+ Adds or updates the range for a specific axis.
277
+
278
+ Parameters
279
+ --------------------
280
+ axis_index : int
281
+ The index of the axis to update.
282
+ xrange : numpy.ndarray
283
+ The range for the x-axis.
284
+ yrange : numpy.ndarray
285
+ The range for the y-axis.
286
+
287
+ Examples
288
+ --------------------
289
+ >>> axes_ranges = AxesRangeSingleton()
290
+ >>> axes_ranges.add_range(0, np.array([0, 10]), np.array([0, 20]))
291
+ >>> print(axes_ranges.axes_ranges)
292
+ [[array([ 0, 10]), array([ 0, 20])]]
293
+ """
294
+ while len(self._axes_ranges) <= axis_index:
295
+ self._axes_ranges.append([None, None])
296
+ self._axes_ranges[axis_index] = [xrange, yrange]
297
+
298
+ def _get_wider_range(
299
+ self, range1: NDArray[Any], range2: NDArray[Any]
300
+ ) -> NDArray[Any]:
301
+ """
302
+ Computes the wider range encompassing two given ranges.
303
+
304
+ Parameters
305
+ --------------------
306
+ range1 : numpy.ndarray
307
+ The first range.
308
+ range2 : numpy.ndarray
309
+ The second range.
310
+
311
+ Returns
312
+ --------------------
313
+ numpy.ndarray
314
+ The wider range encompassing both inputs.
315
+
316
+ Examples
317
+ --------------------
318
+ >>> wider_range = AxesRangeSingleton()._get_wider_range(
319
+ ... np.array([0, 5]),
320
+ ... np.array([3, 10])
321
+ ... )
322
+ >>> print(wider_range)
323
+ array([0, 10])
324
+ """
325
+ new_range = np.array([min(range1[0], range2[0]), max(range1[1], range2[1])])
326
+ return new_range
327
+
328
+ def get_max_wo_inf(self, array: NDArray[Any]) -> float:
329
+ """
330
+ Returns the maximum value in an array, ignoring infinities.
331
+
332
+ Parameters
333
+ --------------------
334
+ array : numpy.ndarray
335
+ The input array.
336
+
337
+ Returns
338
+ --------------------
339
+ float
340
+ The maximum value excluding infinities.
341
+
342
+ Examples
343
+ --------------------
344
+ >>> max_value = AxesRangeSingleton().get_max_wo_inf(
345
+ ... np.array([1, 2, np.inf, 3])
346
+ ... )
347
+ >>> print(max_value)
348
+ 3.0
349
+ """
350
+ array = np.array(array)
351
+ array = array[array != np.inf]
352
+ return float(np.nanmax(array))
353
+
354
+ def get_min_wo_inf(self, array: NDArray[Any]) -> float:
355
+ """
356
+ Returns the minimum value in an array, ignoring negative infinities.
357
+
358
+ Parameters
359
+ --------------------
360
+ array : numpy.ndarray
361
+ The input array.
362
+
363
+ Returns
364
+ --------------------
365
+ float
366
+ The minimum value excluding negative infinities.
367
+
368
+ Examples
369
+ --------------------
370
+ >>> min_value = AxesRangeSingleton().get_min_wo_inf(
371
+ ... np.array([1, 2, -np.inf, 3])
372
+ ... )
373
+ >>> print(min_value)
374
+ 1.0
375
+ """
376
+ array = np.array(array)
377
+ array = array[array != -np.inf]
378
+ return float(np.nanmin(array))
379
+
380
+ @classmethod
381
+ def update(cls, func: F) -> F:
382
+ """
383
+ A decorator to update axis ranges based on data and ensure consistency.
384
+
385
+ The decorator dynamically adjusts axis ranges by considering the current axis
386
+ data and adding it to the stored ranges.
387
+
388
+ Parameters
389
+ --------------------
390
+ func : callable
391
+ The function to wrap.
392
+
393
+ Returns
394
+ --------------------
395
+ callable
396
+ The wrapped function.
397
+
398
+ Examples
399
+ --------------------
400
+ >>> @AxesRangeSingleton.update
401
+ ... def draw_plot(self, *args, **kwargs):
402
+ ... pass
403
+ """
404
+
405
+ def wrapper(self, *args: Any, **kwargs: Any) -> Any:
406
+ axis_index: int = self.axis_index
407
+ x: NDArray[Any] = self.x
408
+ y: NDArray[Any] = self.y
409
+
410
+ num_elements_to_append = max(0, axis_index + 1 - len(cls().axes_ranges))
411
+ cls().axes_ranges.extend([[None, None]] * num_elements_to_append)
412
+
413
+ xrange, yrange = AxisRangeHandler(axis_index, x, y).get_new_axis_range()
414
+ xrange = np.array([cls().get_min_wo_inf(x), cls().get_max_wo_inf(x)])
415
+ yrange = np.array([cls().get_min_wo_inf(y), cls().get_max_wo_inf(y)])
416
+
417
+ xrange_singleton = cls().axes_ranges[axis_index][0]
418
+ yrange_singleton = cls().axes_ranges[axis_index][1]
419
+
420
+ if xrange_singleton is not None:
421
+ new_xrange = cls()._get_wider_range(xrange, xrange_singleton)
422
+ else:
423
+ new_xrange = xrange
424
+
425
+ if yrange_singleton is not None:
426
+ new_yrange = cls()._get_wider_range(yrange, yrange_singleton)
427
+ else:
428
+ new_yrange = yrange
429
+
430
+ cls().add_range(axis_index, new_xrange, new_yrange)
431
+
432
+ result = func(self, *args, **kwargs)
433
+ return result
434
+
435
+ return cast(F, wrapper)
436
+
437
+ def reset(self, axes: list[Axes]):
438
+ axes_length = len(axes)
439
+ self._axes_ranges = [[None, None]] * axes_length
440
+
441
+
442
+ class AxisLayout:
443
+ """
444
+ A utility class for managing axis layout properties in a Matplotlib figure.
445
+
446
+ This class provides methods to retrieve an axis's position and size, both in
447
+ normalized figure coordinates and in physical units (inches). It integrates
448
+ with the `AxesResolver` and `FigureLayout` classes to ensure consistent layout
449
+ calculations.
450
+
451
+ Parameters
452
+ --------------------
453
+ axis_index : int
454
+ The index of the target axis in the current figure.
455
+
456
+ Attributes
457
+ --------------------
458
+ axis_index : int
459
+ The index of the target axis.
460
+ axis : matplotlib.axes.Axes
461
+ The resolved `Axes` object corresponding to the target index.
462
+ fig_size : numpy.ndarray
463
+ The size of the figure in inches as a NumPy array.
464
+
465
+ Methods
466
+ --------------------
467
+ get_axis_position()
468
+ Returns the position of the axis in normalized figure coordinates.
469
+ get_axis_size()
470
+ Returns the size of the axis in normalized figure coordinates.
471
+ get_axis_position_inches()
472
+ Returns the position of the axis in physical units (inches).
473
+ get_axis_size_inches()
474
+ Returns the size of the axis in physical units (inches).
475
+
476
+ Examples
477
+ --------------------
478
+ >>> layout = AxisLayout(axis_index=0)
479
+ >>> axis_position = layout.get_axis_position()
480
+ >>> print(axis_position)
481
+ Bbox(x0=0.1, y0=0.1, x1=0.9, y1=0.9)
482
+
483
+ >>> axis_size = layout.get_axis_size()
484
+ >>> print(axis_size)
485
+ array([0.8, 0.8])
486
+
487
+ >>> axis_position_inches = layout.get_axis_position_inches()
488
+ >>> print(axis_position_inches)
489
+ Bbox(x0=1.6, y0=1.6, x1=14.4, y1=14.4)
490
+
491
+ >>> axis_size_inches = layout.get_axis_size_inches()
492
+ >>> print(axis_size_inches)
493
+ array([12.8, 12.8])
494
+ """
495
+
496
+ def __init__(self, axis_index: int) -> None:
497
+ self.axis_index = axis_index
498
+ self.axis: Axes = AxesResolver(self.axis_index).axis
499
+
500
+ self.fig_size: NDArray[Any] = FigureLayout().get_figure_size()
501
+
502
+ def get_axis_position(self) -> Bbox:
503
+ """
504
+ Retrieves the position of the axis in normalized figure coordinates.
505
+
506
+ Returns
507
+ --------------------
508
+ matplotlib.transforms.Bbox
509
+ The position of the axis as a bounding box in normalized coordinates.
510
+
511
+ Examples
512
+ --------------------
513
+ >>> layout = AxisLayout(axis_index=0)
514
+ >>> position = layout.get_axis_position()
515
+ >>> print(position)
516
+ Bbox(x0=0.1, y0=0.1, x1=0.9, y1=0.9)
517
+ """
518
+ axis_position = self.axis.get_position()
519
+ return axis_position
520
+
521
+ def get_axis_size(self) -> NDArray[Any]:
522
+ """
523
+ Retrieves the size of the axis in normalized figure coordinates.
524
+
525
+ Returns
526
+ --------------------
527
+ numpy.ndarray
528
+ The width and height of the axis as a NumPy array.
529
+
530
+ Examples
531
+ --------------------
532
+ >>> layout = AxisLayout(axis_index=0)
533
+ >>> size = layout.get_axis_size()
534
+ >>> print(size)
535
+ array([0.8, 0.8])
536
+ """
537
+ axis_position_size = np.array(self.get_axis_position().size)
538
+ return axis_position_size
539
+
540
+ def get_axis_position_inches(self) -> Bbox:
541
+ """
542
+ Retrieves the position of the axis in physical units (inches).
543
+
544
+ Returns
545
+ --------------------
546
+ matplotlib.transforms.Bbox
547
+ The position of the axis as a bounding box in inches.
548
+
549
+ Examples
550
+ --------------------
551
+ >>> layout = AxisLayout(axis_index=0)
552
+ >>> position_inches = layout.get_axis_position_inches()
553
+ >>> print(position_inches)
554
+ Bbox(x0=1.6, y0=1.6, x1=14.4, y1=14.4)
555
+ """
556
+
557
+ axis_position = self.get_axis_position()
558
+
559
+ axis_position_inches = Bbox.from_bounds(
560
+ axis_position.x0 * self.fig_size[0],
561
+ axis_position.y0 * self.fig_size[1],
562
+ axis_position.width * self.fig_size[0],
563
+ axis_position.height * self.fig_size[1],
564
+ )
565
+ return axis_position_inches
566
+
567
+ def get_axis_size_inches(self) -> NDArray[Any]:
568
+ """
569
+ Retrieves the size of the axis in physical units (inches).
570
+
571
+ Returns
572
+ --------------------
573
+ numpy.ndarray
574
+ The width and height of the axis in inches as a NumPy array.
575
+
576
+ Examples
577
+ --------------------
578
+ >>> layout = AxisLayout(axis_index=0)
579
+ >>> size_inches = layout.get_axis_size_inches()
580
+ >>> print(size_inches)
581
+ array([12.8, 12.8])
582
+ """
583
+ axis_position_size_inches = np.array(self.get_axis_position_inches().size)
584
+ return axis_position_size_inches
585
+
586
+
587
+ class AxisRangeController:
588
+ """
589
+ A controller for managing the x and y ranges of a specific Matplotlib axis.
590
+
591
+ This class provides methods to get and set the x-axis and y-axis ranges for
592
+ a given axis in a Matplotlib figure.
593
+
594
+ Parameters
595
+ --------------------
596
+ axis_index : int
597
+ The index of the target axis in the current figure.
598
+
599
+ Attributes
600
+ --------------------
601
+ axis_index : int
602
+ The index of the target axis.
603
+ axis : matplotlib.axes.Axes
604
+ The resolved `Axes` object corresponding to the target index.
605
+
606
+ Methods
607
+ --------------------
608
+ get_axis_xrange()
609
+ Retrieves the x-axis range of the target axis.
610
+ get_axis_yrange()
611
+ Retrieves the y-axis range of the target axis.
612
+ set_axis_xrange(xrange)
613
+ Sets the x-axis range of the target axis.
614
+ set_axis_yrange(yrange)
615
+ Sets the y-axis range of the target axis.
616
+
617
+ Examples
618
+ --------------------
619
+ >>> controller = AxisRangeController(axis_index=0)
620
+ >>> x_range = controller.get_axis_xrange()
621
+ >>> print(x_range)
622
+ array([0.0, 1.0])
623
+
624
+ >>> controller.set_axis_xrange(np.array([0.5, 1.5]))
625
+ >>> print(controller.get_axis_xrange())
626
+ array([0.5, 1.5])
627
+
628
+ >>> y_range = controller.get_axis_yrange()
629
+ >>> print(y_range)
630
+ array([0.0, 1.0])
631
+
632
+ >>> controller.set_axis_yrange(np.array([0.2, 0.8]))
633
+ >>> print(controller.get_axis_yrange())
634
+ array([0.2, 0.8])
635
+ """
636
+
637
+ def __init__(self, axis_index: int):
638
+ self.axis_index = axis_index
639
+ self.axis: Axes = AxesResolver(self.axis_index).axis
640
+
641
+ def get_axis_xrange(self) -> NDArray[Any]:
642
+ """
643
+ Retrieves the x-axis range of the target axis.
644
+
645
+ Returns
646
+ --------------------
647
+ numpy.ndarray
648
+ The x-axis range as a NumPy array.
649
+
650
+ Examples
651
+ --------------------
652
+ >>> controller = AxisRangeController(axis_index=0)
653
+ >>> x_range = controller.get_axis_xrange()
654
+ >>> print(x_range)
655
+ array([0.0, 1.0])
656
+ """
657
+ axis_xrange: NDArray[Any] = np.array(self.axis.get_xlim())
658
+ return axis_xrange
659
+
660
+ def get_axis_yrange(self) -> NDArray[Any]:
661
+ """
662
+ Retrieves the y-axis range of the target axis.
663
+
664
+ Returns
665
+ --------------------
666
+ numpy.ndarray
667
+ The y-axis range as a NumPy array.
668
+
669
+ Examples
670
+ --------------------
671
+ >>> controller = AxisRangeController(axis_index=0)
672
+ >>> y_range = controller.get_axis_yrange()
673
+ >>> print(y_range)
674
+ array([0.0, 1.0])
675
+ """
676
+ axis_yrange: NDArray[Any] = np.array(self.axis.get_ylim())
677
+ return axis_yrange
678
+
679
+ def set_axis_xrange(self, xrange: NDArray[Any]) -> None:
680
+ """
681
+ Sets the x-axis range of the target axis.
682
+
683
+ Parameters
684
+ --------------------
685
+ xrange : numpy.ndarray
686
+ The new x-axis range as a NumPy array.
687
+
688
+ Examples
689
+ --------------------
690
+ >>> controller = AxisRangeController(axis_index=0)
691
+ >>> controller.set_axis_xrange(np.array([0.5, 1.5]))
692
+ >>> print(controller.get_axis_xrange())
693
+ array([0.5, 1.5])
694
+ """
695
+ xrange_tuple = tuple(xrange)
696
+ self.axis.set_xlim(xrange_tuple)
697
+
698
+ def set_axis_yrange(self, yrange: NDArray[Any]) -> None:
699
+ """
700
+ Sets the y-axis range of the target axis.
701
+
702
+ Parameters
703
+ --------------------
704
+ yrange : numpy.ndarray
705
+ The new y-axis range as a NumPy array.
706
+
707
+ Examples
708
+ --------------------
709
+ >>> controller = AxisRangeController(axis_index=0)
710
+ >>> controller.set_axis_yrange(np.array([0.2, 0.8]))
711
+ >>> print(controller.get_axis_yrange())
712
+ array([0.2, 0.8])
713
+ """
714
+ yrange_tuple = tuple(yrange)
715
+ self.axis.set_ylim(yrange_tuple)
716
+
717
+
718
+ class AxisRangeManager:
719
+ """
720
+ A manager for handling axis range-related operations in Matplotlib.
721
+
722
+ This class provides functionality to determine whether a given axis is initialized
723
+ or has any existing plots (lines) drawn on it.
724
+
725
+ Parameters
726
+ --------------------
727
+ axis_index : int
728
+ The index of the target axis in the current figure.
729
+
730
+ Attributes
731
+ --------------------
732
+ axis_index : int
733
+ The index of the target axis.
734
+ axis : matplotlib.axes.Axes
735
+ The resolved `Axes` object corresponding to the target index.
736
+
737
+ Methods
738
+ --------------------
739
+ is_init_axis()
740
+ Checks whether the target axis is initialized (has no plots).
741
+
742
+ Examples
743
+ --------------------
744
+ >>> manager = AxisRangeManager(axis_index=0)
745
+ >>> is_initialized = manager.is_init_axis()
746
+ >>> print(is_initialized)
747
+ True # No lines plotted yet
748
+
749
+ >>> plt.plot([1, 2, 3], [4, 5, 6])
750
+ >>> is_initialized = manager.is_init_axis()
751
+ >>> print(is_initialized)
752
+ False # A line plot exists on the axis
753
+ """
754
+
755
+ def __init__(self, axis_index: int):
756
+ self.axis_index = axis_index
757
+
758
+ self.axis: Axes = AxesResolver(self.axis_index).axis
759
+
760
+ def is_init_axis(self) -> bool:
761
+ """
762
+ Checks whether the target axis is initialized (has no plots).
763
+
764
+ This method determines if the axis has no lines plotted, indicating that it is in
765
+ its initial state.
766
+
767
+ Returns
768
+ --------------------
769
+ bool
770
+ `True` if the axis has no plots (lines), `False` otherwise.
771
+
772
+ Examples
773
+ --------------------
774
+ >>> manager = AxisRangeManager(axis_index=0)
775
+ >>> is_initialized = manager.is_init_axis()
776
+ >>> print(is_initialized)
777
+ True # No lines plotted yet
778
+
779
+ >>> plt.plot([1, 2, 3], [4, 5, 6])
780
+ >>> is_initialized = manager.is_init_axis()
781
+ >>> print(is_initialized)
782
+ False # A line plot exists on the axis
783
+ """
784
+ num_lines = len(self.axis.lines)
785
+
786
+ if num_lines:
787
+ return False
788
+ else:
789
+ return True
790
+
791
+
792
+ class AxisRangeHandler:
793
+ """
794
+ Handles the computation and updating of axis ranges for a specific Matplotlib axis.
795
+
796
+ This class calculates new axis ranges by considering existing ranges and new data,
797
+ ensuring that the axis ranges encompass all relevant data. It also determines whether
798
+ an axis is in its initial state or has been previously modified.
799
+
800
+ Parameters
801
+ --------------------
802
+ axis_index : int
803
+ The index of the target axis in the current figure.
804
+ xdata : numpy.ndarray
805
+ The x-axis data to consider for range calculation.
806
+ ydata : numpy.ndarray
807
+ The y-axis data to consider for range calculation.
808
+
809
+ Attributes
810
+ --------------------
811
+ axis_index : int
812
+ The index of the target axis.
813
+ xdata : numpy.ndarray
814
+ The x-axis data to consider for range calculation.
815
+ ydata : numpy.ndarray
816
+ The y-axis data to consider for range calculation.
817
+ axis : matplotlib.axes.Axes
818
+ The resolved `Axes` object corresponding to the target index.
819
+ _is_init_axis : bool
820
+ Indicates whether the axis is in its initial state (no plots or data).
821
+
822
+ Methods
823
+ --------------------
824
+ get_new_axis_range()
825
+ Calculates the new axis range, combining existing ranges and new data ranges.
826
+
827
+ Examples
828
+ --------------------
829
+ >>> xdata = np.array([0, 1, 2, 3])
830
+ >>> ydata = np.array([4, 5, 6, 7])
831
+ >>> handler = AxisRangeHandler(axis_index=0, xdata=xdata, ydata=ydata)
832
+ >>> new_xrange, new_yrange = handler.get_new_axis_range()
833
+ >>> print(new_xrange)
834
+ array([0, 3])
835
+ >>> print(new_yrange)
836
+ array([4, 7])
837
+ """
838
+
839
+ def __init__(self, axis_index: int, xdata: NDArray[Any], ydata: NDArray[Any]):
840
+ self.axis_index = axis_index
841
+ self.xdata = xdata
842
+ self.ydata = ydata
843
+
844
+ self.axis: Axes = AxesResolver(self.axis_index).axis
845
+
846
+ self._is_init_axis: bool = AxisRangeManager(self.axis_index).is_init_axis()
847
+
848
+ def _get_axis_range(
849
+ self,
850
+ ) -> tuple[NDArray | None, NDArray | None] | None:
851
+ """
852
+ Retrieves the current axis ranges (x and y) if the axis is not in its initial state.
853
+
854
+ Returns
855
+ --------------------
856
+ tuple of (numpy.ndarray or None, numpy.ndarray or None)
857
+ The x-axis and y-axis ranges. Returns `(None, None)` if the axis is in its initial state.
858
+
859
+ Examples
860
+ --------------------
861
+ >>> handler = AxisRangeHandler(axis_index=0, xdata=np.array([]), ydata=np.array([]))
862
+ >>> axis_range = handler._get_axis_range()
863
+ >>> print(axis_range)
864
+ (array([0.0, 1.0]), array([0.0, 1.0]))
865
+ """
866
+ if self._is_init_axis:
867
+ return None, None
868
+ else:
869
+ axis_xrange = AxisRangeController(self.axis_index).get_axis_xrange()
870
+ axis_yrange = AxisRangeController(self.axis_index).get_axis_yrange()
871
+ return axis_xrange, axis_yrange
872
+
873
+ def _calculate_data_range(self, data: NDArray[Any]) -> NDArray[Any]:
874
+ """
875
+ Calculates the minimum and maximum range for the given data.
876
+
877
+ Parameters
878
+ --------------------
879
+ data : numpy.ndarray
880
+ The data for which to calculate the range.
881
+
882
+ Returns
883
+ --------------------
884
+ numpy.ndarray
885
+ The range of the data as a NumPy array `[min, max]`.
886
+
887
+ Examples
888
+ --------------------
889
+ >>> handler = AxisRangeHandler(axis_index=0, xdata=np.array([0, 1, 2]), ydata=np.array([]))
890
+ >>> data_range = handler._calculate_data_range(np.array([1, 2, 3]))
891
+ >>> print(data_range)
892
+ array([1, 3])
893
+ """
894
+ min_data = np.min(data)
895
+ max_data = np.max(data)
896
+ return np.array([min_data, max_data])
897
+
898
+ def get_new_axis_range(
899
+ self,
900
+ ) -> tuple[NDArray | None, NDArray | None]:
901
+ """
902
+ Calculates the new axis ranges based on existing ranges and new data.
903
+
904
+ If the axis is in its initial state, it returns the range of the new data.
905
+ Otherwise, it computes the wider range encompassing both the existing range
906
+ and the new data range.
907
+
908
+ Returns
909
+ --------------------
910
+ tuple of (numpy.ndarray or None, numpy.ndarray or None)
911
+ The new x-axis and y-axis ranges.
912
+
913
+ Examples
914
+ --------------------
915
+ >>> xdata = np.array([0, 1, 2, 3])
916
+ >>> ydata = np.array([4, 5, 6, 7])
917
+ >>> handler = AxisRangeHandler(axis_index=0, xdata=xdata, ydata=ydata)
918
+ >>> new_xrange, new_yrange = handler.get_new_axis_range()
919
+ >>> print(new_xrange)
920
+ array([0, 3])
921
+ >>> print(new_yrange)
922
+ array([4, 7])
923
+ """
924
+ axis_range = self._get_axis_range()
925
+ if axis_range is None:
926
+ return None, None
927
+
928
+ xrange, yrange = axis_range
929
+ xrange_data, yrange_data = (
930
+ self._calculate_data_range(self.xdata),
931
+ self._calculate_data_range(self.ydata),
932
+ )
933
+
934
+ if xrange is None:
935
+ new_xrange = xrange_data
936
+ else:
937
+ new_xrange = np.array([xrange[0], xrange[1]])
938
+
939
+ if yrange is None:
940
+ new_yrange = yrange_data
941
+ else:
942
+ new_yrange = np.array([yrange[0], yrange[1]])
943
+
944
+ if xrange is not None and yrange is not None:
945
+ new_xrange = np.array(
946
+ [min(xrange[0], xrange_data[0]), max(xrange[1], xrange_data[1])]
947
+ )
948
+ new_yrange = np.array(
949
+ [min(yrange[0], yrange_data[0]), max(yrange[1], yrange_data[1])]
950
+ )
951
+
952
+ return new_xrange, new_yrange