-
Notifications
You must be signed in to change notification settings - Fork 96
Expand file tree
/
Copy pathgridspec.py
More file actions
1618 lines (1501 loc) · 69.2 KB
/
gridspec.py
File metadata and controls
1618 lines (1501 loc) · 69.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
The gridspec and subplot grid classes used throughout proplot.
"""
import inspect
import itertools
import re
from collections.abc import MutableSequence
from numbers import Integral
import matplotlib.axes as maxes
import matplotlib.gridspec as mgridspec
import matplotlib.transforms as mtransforms
import numpy as np
from . import axes as paxes
from .config import rc
from .internals import ic # noqa: F401
from .internals import _not_none, docstring, warnings
from .utils import _fontsize_to_pt, units
__all__ = [
'GridSpec',
'SubplotGrid',
'SubplotsContainer' # deprecated
]
# Gridspec vector arguments
# Valid for figure() and GridSpec()
_shared_docstring = """
left, right, top, bottom : unit-spec, default: None
The fixed space between the subplots and the figure edge.
%(units.em)s
If ``None``, the space is determined automatically based on the tick and
label settings. If :rcraw:`subplots.tight` is ``True`` or ``tight=True`` was
passed to the figure, the space is determined by the tight layout algorithm.
"""
_scalar_docstring = """
wspace, hspace, space : unit-spec, default: None
The fixed space between grid columns, rows, or both.
%(units.em)s
If ``None``, the space is determined automatically based on the font size and axis
sharing settings. If :rcraw:`subplots.tight` is ``True`` or ``tight=True`` was
passed to the figure, the space is determined by the tight layout algorithm.
"""
_vector_docstring = """
wspace, hspace, space : unit-spec or sequence, default: None
The fixed space between grid columns, rows, and both, respectively. If
float, string, or ``None``, this value is expanded into lists of length
``ncols - 1`` (for `wspace`) or length ``nrows - 1`` (for `hspace`). If
a sequence, its length must match these lengths.
%(units.em)s
For elements equal to ``None``, the space is determined automatically based
on the tick and label settings. If :rcraw:`subplots.tight` is ``True`` or
``tight=True`` was passed to the figure, the space is determined by the tight
layout algorithm. For example, ``subplots(ncols=3, tight=True, wspace=(2, None))``
fixes the space between columns 1 and 2 but lets the tight layout algorithm
determine the space between columns 2 and 3.
wratios, hratios : float or sequence, optional
Passed to `~proplot.gridspec.GridSpec`, denotes the width and height
ratios for the subplot grid. Length of `wratios` must match the number
of columns, and length of `hratios` must match the number of rows.
width_ratios, height_ratios
Aliases for `wratios`, `hratios`. Included for
consistency with `matplotlib.gridspec.GridSpec`.
wpad, hpad, pad : unit-spec or sequence, optional
The tight layout padding between columns, rows, and both, respectively.
Unlike ``space``, these control the padding between subplot content
(including text, ticks, etc.) rather than subplot edges. As with
``space``, these can be scalars or arrays optionally containing ``None``.
For elements equal to ``None``, the default is `innerpad`.
%(units.em)s
"""
_tight_docstring = """
wequal, hequal, equal : bool, default: :rc:`subplots.equalspace`
Whether to make the tight layout algorithm apply equal spacing
between columns, rows, or both.
wgroup, hgroup, group : bool, default: :rc:`subplots.groupspace`
Whether to make the tight layout algorithm just consider spaces between
adjacent subplots instead of entire columns and rows of subplots.
outerpad : unit-spec, default: :rc:`subplots.outerpad`
The scalar tight layout padding around the left, right, top, bottom figure edges.
%(units.em)s
innerpad : unit-spec, default: :rc:`subplots.innerpad`
The scalar tight layout padding between columns and rows. Synonymous with `pad`.
%(units.em)s
panelpad : unit-spec, default: :rc:`subplots.panelpad`
The scalar tight layout padding between subplots and their panels,
colorbars, and legends and between "stacks" of these objects.
%(units.em)s
"""
docstring._snippet_manager['gridspec.shared'] = _shared_docstring
docstring._snippet_manager['gridspec.scalar'] = _scalar_docstring
docstring._snippet_manager['gridspec.vector'] = _vector_docstring
docstring._snippet_manager['gridspec.tight'] = _tight_docstring
def _disable_method(attr):
"""
Disable the inherited method.
"""
def _dummy_method(*args):
raise RuntimeError(f'Method {attr}() is disabled on proplot gridspecs.')
_dummy_method.__name__ = attr
return _dummy_method
class _SubplotSpec(mgridspec.SubplotSpec):
"""
A thin `~matplotlib.gridspec.SubplotSpec` subclass with a nice string
representation and a few helper methods.
"""
def __repr__(self):
# NOTE: Also include panel obfuscation here to avoid confusion. If this
# is a panel slot generated internally then show zero info.
try:
nrows, ncols, num1, num2 = self._get_geometry()
except (IndexError, ValueError, AttributeError):
return 'SubplotSpec(unknown)'
else:
return f'SubplotSpec(nrows={nrows}, ncols={ncols}, index=({num1}, {num2}))'
def _get_geometry(self):
"""
Return the geometry and scalar indices relative to the "unhidden" non-panel
geometry. May trigger error if this is in a "hidden" panel slot.
"""
gs = self.get_gridspec()
num1, num2 = self.num1, self.num2
if isinstance(gs, GridSpec):
nrows, ncols = gs.get_geometry()
num1, num2 = gs._decode_indices(num1, num2) # may trigger error
return nrows, ncols, num1, num2
def _get_rows_columns(self, ncols=None):
"""
Return the row and column indices. The resulting indices include
"hidden" panel rows and columns. See `GridSpec.get_grid_positions`.
"""
# NOTE: Sort of confusing that this doesn't have 'total' in name but that
# is by analogy with get_grid_positions(). This is used for grid positioning.
gs = self.get_gridspec()
if isinstance(gs, GridSpec):
ncols = _not_none(ncols, gs.ncols_total)
else:
ncols = _not_none(ncols, gs.ncols)
row1, col1 = divmod(self.num1, ncols)
row2, col2 = divmod(self.num2, ncols)
return row1, row2, col1, col2
def get_position(self, figure, return_all=False):
# Silent override. Older matplotlib versions can create subplots
# with negative heights and widths that crash on instantiation.
# Instead better to dynamically adjust the bounding box and hope
# that subsequent adjustments will correct the subplot position.
gs = self.get_gridspec()
if isinstance(gs, GridSpec):
nrows, ncols = gs.get_total_geometry()
else:
nrows, ncols = gs.get_geometry()
rows, cols = np.unravel_index([self.num1, self.num2], (nrows, ncols))
bottoms, tops, lefts, rights = gs.get_grid_positions(figure)
bottom = bottoms[rows].min()
top = max(bottom, tops[rows].max())
left = lefts[cols].min()
right = max(left, rights[cols].max())
bbox = mtransforms.Bbox.from_extents(left, bottom, right, top)
if return_all:
return bbox, rows[0], cols[0], nrows, ncols
else:
return bbox
class GridSpec(mgridspec.GridSpec):
"""
A `~matplotlib.gridspec.GridSpec` subclass that permits variable spacing
between successive rows and columns and hides "panel slots" from indexing.
"""
def __repr__(self):
nrows, ncols = self.get_geometry()
prows, pcols = self.get_panel_geometry()
params = {'nrows': nrows, 'ncols': ncols}
if prows:
params['nrows_panel'] = prows
if pcols:
params['ncols_panel'] = pcols
params = ', '.join(f'{key}={value!r}' for key, value in params.items())
return f'GridSpec({params})'
def __getattr__(self, attr):
# Redirect to private 'layout' attributes that are fragile w.r.t.
# matplotlib version. Cannot set these by calling super().__init__()
# because we make spacing arguments non-settable properties.
if 'layout' in attr:
return None
super().__getattribute__(attr) # native error message
@docstring._snippet_manager
def __init__(self, nrows=1, ncols=1, **kwargs):
"""
Parameters
----------
nrows : int, optional
The number of rows in the subplot grid.
ncols : int, optional
The number of columns in the subplot grid.
Other parameters
----------------
%(gridspec.shared)s
%(gridspec.vector)s
%(gridspec.tight)s
See also
--------
proplot.ui.figure
proplot.figure.Figure
proplot.ui.subplots
proplot.figure.Figure.subplots
proplot.figure.Figure.add_subplots
matplotlib.gridspec.GridSpec
Important
---------
Adding axes panels, axes or figure colorbars, and axes or figure legends
quietly augments the gridspec geometry by inserting "panel slots". However,
subsequently indexing the gridspec with ``gs[num]`` or ``gs[row, col]`` will
ignore the "panel slots". This permits adding new subplots by passing
``gs[num]`` or ``gs[row, col]`` to `~proplot.figure.Figure.add_subplot`
even in the presence of panels (see `~GridSpec.__getitem__` for details).
This also means that each `GridSpec` is `~proplot.figure.Figure`-specific,
i.e. it can only be used once (if you are working with `GridSpec` instances
manually and want the same geometry for multiple figures, you must create
a copy with `GridSpec.copy` before working on the subsequent figure).
"""
# Fundamental GridSpec properties
self._nrows_total = nrows
self._ncols_total = ncols
self._left = None
self._right = None
self._bottom = None
self._top = None
self._hspace_total = [None] * (nrows - 1)
self._wspace_total = [None] * (ncols - 1)
self._hratios_total = [1] * nrows
self._wratios_total = [1] * ncols
self._left_default = None
self._right_default = None
self._bottom_default = None
self._top_default = None
self._hspace_total_default = [None] * (nrows - 1)
self._wspace_total_default = [None] * (ncols - 1)
self._figure = None # initial state
# Capture rc settings used for default spacing
# NOTE: This is consistent with conversion of 'em' units to inches on gridspec
# instantiation. In general it seems strange for future changes to rc settings
# to magically update an existing gridspec layout. This also may improve draw
# time as manual or auto figure resizes repeatedly call get_grid_positions().
scales = {'in': 0, 'inout': 0.5, 'out': 1, None: 1}
self._xtickspace = scales[rc['xtick.direction']] * rc['xtick.major.size']
self._ytickspace = scales[rc['ytick.direction']] * rc['ytick.major.size']
self._xticklabelspace = _fontsize_to_pt(rc['xtick.labelsize']) + rc['xtick.major.pad'] # noqa: E501
self._yticklabelspace = 2 * _fontsize_to_pt(rc['ytick.labelsize']) + rc['ytick.major.pad'] # noqa: E501
self._labelspace = _fontsize_to_pt(rc['axes.labelsize']) + rc['axes.labelpad']
self._titlespace = _fontsize_to_pt(rc['axes.titlesize']) + rc['axes.titlepad']
# Tight layout and panel-related properties
# NOTE: The wpanels and hpanels contain empty strings '' (indicating main axes),
# or one of 'l', 'r', 'b', 't' (indicating axes panels) or 'f' (figure panels)
outerpad = _not_none(kwargs.pop('outerpad', None), rc['subplots.outerpad'])
innerpad = _not_none(kwargs.pop('innerpad', None), rc['subplots.innerpad'])
panelpad = _not_none(kwargs.pop('panelpad', None), rc['subplots.panelpad'])
pad = _not_none(kwargs.pop('pad', None), innerpad) # alias of innerpad
self._outerpad = units(outerpad, 'em', 'in')
self._innerpad = units(innerpad, 'em', 'in')
self._panelpad = units(panelpad, 'em', 'in')
self._hpad_total = [units(pad, 'em', 'in')] * (nrows - 1)
self._wpad_total = [units(pad, 'em', 'in')] * (ncols - 1)
self._hequal = rc['subplots.equalspace']
self._wequal = rc['subplots.equalspace']
self._hgroup = rc['subplots.groupspace']
self._wgroup = rc['subplots.groupspace']
self._hpanels = [''] * nrows # axes and figure panel identification
self._wpanels = [''] * ncols
self._fpanels = { # array representation of figure panel spans
'left': np.empty((0, nrows), dtype=bool),
'right': np.empty((0, nrows), dtype=bool),
'bottom': np.empty((0, ncols), dtype=bool),
'top': np.empty((0, ncols), dtype=bool),
}
self._update_params(pad=pad, **kwargs)
def __getitem__(self, key):
"""
Get a `~matplotlib.gridspec.SubplotSpec`. "Hidden" slots allocated for axes
panels, colorbars, and legends are ignored. For example, given a gridspec with
2 subplot rows, 3 subplot columns, and a "panel" row between the subplot rows,
calling ``gs[1, 1]`` returns a `~matplotlib.gridspec.SubplotSpec` corresponding
to the central subplot on the second row rather than a "panel" slot.
"""
return self._make_subplot_spec(key, includepanels=False)
def _make_subplot_spec(self, key, includepanels=False):
"""
Generate a subplotspec either ignoring panels or including panels.
"""
# Convert the indices into endpoint-inclusive (start, stop)
def _normalize_index(key, size, axis=None): # noqa: E306
if isinstance(key, slice):
start, stop, _ = key.indices(size)
if stop > start:
return start, stop - 1
else:
if key < 0:
key += size
if 0 <= key < size:
return key, key # endpoing inclusive
extra = 'for gridspec' if axis is None else f'along axis {axis}'
raise IndexError(f'Invalid index {key} {extra} with size {size}.')
# Normalize the indices
if includepanels:
nrows, ncols = self.get_total_geometry()
else:
nrows, ncols = self.get_geometry()
if not isinstance(key, tuple): # usage gridspec[1,2]
num1, num2 = _normalize_index(key, nrows * ncols)
elif len(key) == 2:
k1, k2 = key
num1 = _normalize_index(k1, nrows, axis=0)
num2 = _normalize_index(k2, ncols, axis=1)
num1, num2 = np.ravel_multi_index((num1, num2), (nrows, ncols))
else:
raise ValueError(f'Invalid index {key!r}.')
# Return the subplotspec
if not includepanels:
num1, num2 = self._encode_indices(num1, num2)
return _SubplotSpec(self, num1, num2)
def _encode_indices(self, *args, which=None):
"""
Convert indices from the "unhidden" gridspec geometry into indices for the
total geometry. If `which` is not passed these should be flattened indices.
"""
nums = []
idxs = self._get_indices(which)
for arg in args:
try:
nums.append(idxs[arg])
except (IndexError, TypeError):
raise ValueError(f'Invalid gridspec index {arg}.')
return nums[0] if len(nums) == 1 else nums
def _decode_indices(self, *args, which=None):
"""
Convert indices from the total geometry into the "unhidden" gridspec
geometry. If `which` is not passed these should be flattened indices.
"""
nums = []
idxs = self._get_indices(which)
for arg in args:
try:
nums.append(idxs.index(arg))
except ValueError:
raise ValueError(f'Invalid gridspec index {arg}.')
return nums[0] if len(nums) == 1 else nums
def _filter_indices(self, key, panel=False):
"""
Filter the vector attribute for "unhidden" or "hidden" slots.
"""
# NOTE: Currently this is just used for unused internal properties,
# defined for consistency with the properties ending in "total".
# These may be made public in a future version.
which = key[0]
space = 'space' in key or 'pad' in key
idxs = self._get_indices(which=which, space=space, panel=panel)
vector = getattr(self, key + '_total')
return [vector[i] for i in idxs]
def _get_indices(self, which=None, space=False, panel=False):
"""
Get the indices associated with "unhidden" or "hidden" slots.
"""
if which:
panels = getattr(self, f'_{which}panels')
else:
panels = [h + w for h, w in itertools.product(self._hpanels, self._wpanels)]
if not space:
idxs = [
i for i, p in enumerate(panels) if p
]
else:
idxs = [
i for i, (p1, p2) in enumerate(zip(panels[:-1], panels[1:]))
if p1 == p2 == 'f'
or p1 in ('l', 't') and p2 in ('l', 't', '')
or p1 in ('r', 'b', '') and p2 in ('r', 'b')
]
if not panel:
length = len(panels) - 1 if space else len(panels)
idxs = [i for i in range(length) if i not in idxs]
return idxs
def _modify_subplot_geometry(self, newrow=None, newcol=None):
"""
Update the axes subplot specs by inserting rows and columns as specified.
"""
fig = self.figure
ncols = self._ncols_total - int(newcol is not None) # previous columns
inserts = (newrow, newrow, newcol, newcol)
for ax in fig._iter_axes(hidden=True, children=True):
# Get old index
# NOTE: Endpoints are inclusive, not exclusive!
if not isinstance(ax, maxes.SubplotBase):
continue
gs = ax.get_subplotspec().get_gridspec()
ss = ax.get_subplotspec().get_topmost_subplotspec()
# Get a new subplotspec
coords = list(ss._get_rows_columns(ncols=ncols))
for i in range(4):
if inserts[i] is not None and coords[i] >= inserts[i]:
coords[i] += 1
row1, row2, col1, col2 = coords
key1 = slice(row1, row2 + 1)
key2 = slice(col1, col2 + 1)
ss_new = self._make_subplot_spec((key1, key2), includepanels=True)
# Apply new subplotspec
# NOTE: We should only have one possible level of GridSpecFromSubplotSpec
# nesting -- from making side colorbars with length less than 1.
if ss is ax.get_subplotspec():
ax.set_subplotspec(ss_new)
elif ss is getattr(gs, '_subplot_spec', None):
gs._subplot_spec = ss_new
else:
raise RuntimeError('Unexpected GridSpecFromSubplotSpec nesting.')
ax._reposition_subplot()
def _parse_panel_arg(self, side, arg):
"""
Return the indices associated with a new figure panel on the specified side.
Try to find room in the current mosaic of figure panels.
"""
# Add a subplot panel. Index depends on the side
# NOTE: This always "stacks" new panels on old panels
if isinstance(arg, maxes.SubplotBase) and isinstance(arg, paxes.Axes):
slot = side[0]
ss = arg.get_subplotspec().get_topmost_subplotspec()
offset = len(arg._panel_dict[side]) + 1
row1, row2, col1, col2 = ss._get_rows_columns()
if side in ('left', 'right'):
iratio = col1 - offset if side == 'left' else col2 + offset
start, stop = row1, row2
else:
iratio = row1 - offset if side == 'top' else row2 + offset
start, stop = col1, col2
# Add a figure panel. Index depends on the side and the input 'span'
# NOTE: Here the 'span' indices start at '1' by analogy with add_subplot()
# integers and with main subplot numbers. Also *ignores panel slots*.
# NOTE: This only "stacks" panels if requested slots are filled. Slots are
# tracked with figure panel array (a boolean mask where each row corresponds
# to a panel, moving toward the outside, and True indicates a slot is filled).
elif (
arg is None or isinstance(arg, Integral)
or np.iterable(arg) and all(isinstance(_, Integral) for _ in arg)
):
slot = 'f'
array = self._fpanels[side]
nacross = self._ncols_total if side in ('left', 'right') else self._nrows_total # noqa: E501
npanels, nalong = array.shape
arg = np.atleast_1d(_not_none(arg, (1, nalong)))
if arg.size not in (1, 2):
raise ValueError(f'Invalid span={arg!r}. Must be scalar or 2-tuple of coordinates.') # noqa: E501
if any(s < 1 or s > nalong for s in arg):
raise ValueError(f'Invalid span={arg!r}. Coordinates must satisfy 1 <= c <= {nalong}.') # noqa: E501
start, stop = arg[0] - 1, arg[-1] # non-inclusive starting at zero
iratio = -1 if side in ('left', 'top') else nacross # default values
for i in range(npanels): # possibly use existing panel slot
if not any(array[i, start:stop]):
array[i, start:stop] = True
if side in ('left', 'top'): # descending moves us closer to 0
iratio = npanels - 1 - i # index in ratios array
else: # descending array moves us closer to nacross - 1
iratio = nacross - (npanels - i) # index in ratios array
break
if iratio == -1 or iratio == nacross: # no slots so we must add to array
iarray = np.zeros((1, nalong), dtype=bool)
iarray[0, start:stop] = True
array = np.concatenate((array, iarray), axis=0)
self._fpanels[side] = array # replace array
which = 'h' if side in ('left', 'right') else 'w'
start, stop = self._encode_indices(start, stop - 1, which=which)
else:
raise ValueError(f'Invalid panel argument {arg!r}.')
# Return subplotspec indices
# NOTE: Convert using the lengthwise indices
return slot, iratio, slice(start, stop + 1)
def _insert_panel_slot(
self, side, arg, *, share=None, width=None, space=None, pad=None, filled=False,
):
"""
Insert a panel slot into the existing gridspec. The `side` is the panel side
and the `arg` is either an axes instance or the figure row-column span.
"""
# Parse input args and get user-input properties, default properties
fig = self.figure
if fig is None:
raise RuntimeError('Figure must be assigned to gridspec.')
if side not in ('left', 'right', 'bottom', 'top'):
raise ValueError(f'Invalid side {side}.')
slot, idx, span = self._parse_panel_arg(side, arg)
pad = units(pad, 'em', 'in')
space = units(space, 'em', 'in')
width = units(width, 'in')
share = False if filled else share if share is not None else True
which = 'w' if side in ('left', 'right') else 'h'
panels = getattr(self, f'_{which}panels')
pads = getattr(self, f'_{which}pad_total') # no copies!
ratios = getattr(self, f'_{which}ratios_total')
spaces = getattr(self, f'_{which}space_total')
spaces_default = getattr(self, f'_{which}space_total_default')
new_outer_slot = idx in (-1, len(panels))
new_inner_slot = not new_outer_slot and panels[idx] != slot
# Retrieve default spaces
# NOTE: Cannot use 'wspace' and 'hspace' for top and right colorbars because
# that adds an unnecessary tick space. So bypass _get_default_space totally.
pad_default = (
self._panelpad
if slot != 'f'
or side in ('left', 'top') and panels[0] == 'f'
or side in ('right', 'bottom') and panels[-1] == 'f'
else self._innerpad
)
inner_space_default = (
_not_none(pad, pad_default)
if side in ('top', 'right')
else self._get_default_space(
'hspace_total' if side == 'bottom' else 'wspace_total',
title=False, # no title between subplot and panel
share=3 if share else 0, # space for main subplot labels
pad=_not_none(pad, pad_default),
)
)
outer_space_default = self._get_default_space(
'bottom' if not share and side == 'top'
else 'left' if not share and side == 'right'
else side,
title=True, # room for titles deflected above panels
pad=self._outerpad if new_outer_slot else self._innerpad,
)
if new_inner_slot:
outer_space_default += self._get_default_space(
'hspace_total' if side in ('bottom', 'top') else 'wspace_total',
share=None, # use external share setting
pad=0, # use no additional padding
)
width_default = units(
rc['colorbar.width' if filled else 'subplots.panelwidth'], 'in'
)
# Adjust space, ratio, and panel indicator arrays
# If slot exists, overwrite width, pad, space if they were provided by the user
# If slot does not exist, modify gemoetry and add insert new spaces
attr = 'ncols' if side in ('left', 'right') else 'nrows'
idx_offset = int(side in ('top', 'left'))
idx_inner_space = idx - int(side in ('bottom', 'right')) # inner colorbar space
idx_outer_space = idx - int(side in ('top', 'left')) # outer colorbar space
if new_outer_slot or new_inner_slot:
idx += idx_offset
idx_inner_space += idx_offset
idx_outer_space += idx_offset
newcol, newrow = (idx, None) if attr == 'ncols' else (None, idx)
setattr(self, f'_{attr}_total', 1 + getattr(self, f'_{attr}_total'))
panels.insert(idx, slot)
ratios.insert(idx, _not_none(width, width_default))
pads.insert(idx_inner_space, _not_none(pad, pad_default))
spaces.insert(idx_inner_space, space)
spaces_default.insert(idx_inner_space, inner_space_default)
if new_inner_slot:
spaces_default.insert(idx_outer_space, outer_space_default)
else:
setattr(self, f'_{side}_default', outer_space_default)
else:
newrow = newcol = None
spaces_default[idx_inner_space] = inner_space_default
if width is not None:
ratios[idx] = width
if pad is not None:
pads[idx_inner_space] = pad
if space is not None:
spaces[idx_inner_space] = space
# Update the figure and axes and return a SubplotSpec
# NOTE: For figure panels indices are determined by user-input spans.
self._modify_subplot_geometry(newrow, newcol)
figsize = self._update_figsize()
if figsize is not None:
fig.set_size_inches(figsize, internal=True, forward=False)
else:
self.update()
key = (span, idx) if side in ('left', 'right') else (idx, span)
ss = self._make_subplot_spec(key, includepanels=True) # bypass obfuscation
return ss, share
def _get_space(self, key):
"""
Return the currently active vector inner space or scalar outer space
accounting for both default values and explicit user overrides.
"""
# NOTE: Default panel spaces should have been filled by _insert_panel_slot.
# They use 'panelpad' and the panel-local 'share' setting. This function
# instead fills spaces between subplots depending on sharing setting.
fig = self.figure
if not fig:
raise ValueError('Figure must be assigned to get grid positions.')
attr = f'_{key}' # user-specified
attr_default = f'_{key}_default' # default values
value = getattr(self, attr)
value_default = getattr(self, attr_default)
if key in ('left', 'right', 'bottom', 'top'):
if value_default is None:
value_default = self._get_default_space(key)
setattr(self, attr_default, value_default)
return _not_none(value, value_default)
elif key in ('wspace_total', 'hspace_total'):
result = []
for i, (val, val_default) in enumerate(zip(value, value_default)):
if val_default is None:
val_default = self._get_default_space(key)
value_default[i] = val_default
result.append(_not_none(val, val_default))
return result
else:
raise ValueError(f'Unknown space parameter {key!r}.')
def _get_default_space(self, key, pad=None, share=None, title=True):
"""
Return suitable default scalar inner or outer space given a shared axes
setting. This is only relevant when "tight layout" is disabled.
"""
# NOTE: Internal spacing args are stored in inches to simplify the
# get_grid_positions() calculations.
fig = self.figure
if fig is None:
raise RuntimeError('Figure must be assigned.')
if key == 'right':
pad = _not_none(pad, self._outerpad)
space = 0
elif key == 'top':
pad = _not_none(pad, self._outerpad)
space = self._titlespace if title else 0
elif key == 'left':
pad = _not_none(pad, self._outerpad)
space = self._labelspace + self._yticklabelspace + self._ytickspace
elif key == 'bottom':
pad = _not_none(pad, self._outerpad)
space = self._labelspace + self._xticklabelspace + self._xtickspace
elif key == 'wspace_total':
pad = _not_none(pad, self._innerpad)
share = _not_none(share, fig._sharey, 0)
space = self._ytickspace
if share < 3:
space += self._yticklabelspace
if share < 1:
space += self._labelspace
elif key == 'hspace_total':
pad = _not_none(pad, self._innerpad)
share = _not_none(share, fig._sharex, 0)
space = self._xtickspace
if title:
space += self._titlespace
if share < 3:
space += self._xticklabelspace
if share < 1:
space += self._labelspace
else:
raise ValueError(f'Invalid space key {key!r}.')
return pad + space / 72
def _get_tight_space(self, w):
"""
Get tight layout spaces between the input subplot rows or columns.
"""
# Get constants
fig = self.figure
if not fig:
return
if w == 'w':
x, y = 'xy'
group = self._wgroup
nacross = self.nrows_total
space = self.wspace_total
pad = self.wpad_total
else:
x, y = 'yx'
group = self._hgroup
nacross = self.ncols_total
space = self.hspace_total
pad = self.hpad_total
# Iterate along each row or column space
axs = tuple(fig._iter_axes(hidden=True, children=False))
space = list(space) # a copy
ralong = np.array([ax._range_subplotspec(x) for ax in axs])
racross = np.array([ax._range_subplotspec(y) for ax in axs])
for i, (s, p) in enumerate(zip(space, pad)):
# Find axes that abutt aginst this row or column space
groups = []
for j in range(nacross): # e.g. each row
# Get the indices for axes that meet this row or column edge.
# NOTE: Rigorously account for empty and overlapping slots here
filt = (racross[:, 0] <= j) & (j <= racross[:, 1])
if sum(filt) < 2:
continue # no interface
ii = i
idx1 = idx2 = np.array(())
while ii >= 0 and idx1.size == 0:
filt1 = ralong[:, 1] == ii # i.e. r / b edge abutts against this
idx1, = np.where(filt & filt1)
ii -= 1
ii = i + 1
while ii <= len(space) and idx2.size == 0:
filt2 = ralong[:, 0] == ii # i.e. l / t edge abutts against this
idx2, = np.where(filt & filt2)
ii += 1
# Put axes into unique groups and store as (l, r) or (b, t) pairs.
axs1, axs2 = [axs[_] for _ in idx1], [axs[_] for _ in idx2]
if x != 'x': # order bottom-to-top
axs1, axs2 = axs2, axs1
for (group1, group2) in groups:
if any(_ in group1 for _ in axs1) or any(_ in group2 for _ in axs2):
group1.update(axs1)
group2.update(axs2)
break
else:
if axs1 and axs2:
groups.append((set(axs1), set(axs2))) # form new group
# Determing the spaces using cached tight bounding boxes
# NOTE: Set gridspec space to zero if there are no adjacent edges
if not group:
groups = [(
set(ax for (group1, _) in groups for ax in group1),
set(ax for (_, group2) in groups for ax in group2),
)]
margins = []
for (group1, group2) in groups:
x1 = max(ax._range_tightbbox(x)[1] for ax in group1)
x2 = min(ax._range_tightbbox(x)[0] for ax in group2)
margins.append((x2 - x1) / self.figure.dpi)
s = 0 if not margins else max(0, s - min(margins) + p)
space[i] = s
return space
def _auto_layout_aspect(self):
"""
Update the underlying default aspect ratio.
"""
# Get the axes
fig = self.figure
if not fig:
return
ax = fig._subplot_dict.get(fig._refnum, None)
if ax is None:
return
# Get aspect ratio
ratio = ax.get_aspect() # the aspect ratio in *data units*
if ratio == 'auto':
return
elif ratio == 'equal':
ratio = 1
elif isinstance(ratio, str):
raise RuntimeError(f'Unknown aspect ratio mode {ratio!r}.')
else:
ratio = 1 / ratio
# Compare to current aspect after scaling by data ratio
# Noat matplotlib 3.2.0 expanded get_data_ratio to work for all axis scales:
# https://github.com/matplotlib/matplotlib/commit/87c742b99dc6b9a190f8c89bc6256ced72f5ab80 # noqa: E501
aspect = ratio / ax.get_data_ratio()
if fig._refaspect is not None:
return # fixed by user
if np.isclose(aspect, fig._refaspect_default):
return # close enough to the default aspect
fig._refaspect_default = aspect
# Update the layout
figsize = self._update_figsize()
if not fig._is_same_size(figsize):
fig.set_size_inches(figsize, internal=True)
def _auto_layout_tight(self, renderer):
"""
Update the underlying spaces with tight layout values. If `resize` is
``True`` and the auto figure size has changed then update the figure
size. Either way always update the subplot positions.
"""
# Initial stuff
fig = self.figure
if not fig:
return
if not any(fig._iter_axes(hidden=True, children=False)):
return # skip tight layout if there are no subplots in the figure
# Get the tight bounding box around the whole figure.
# NOTE: This triggers proplot.axes.Axes.get_tightbbox which *caches* the
# computed bounding boxes used by _range_tightbbox below.
pad = self._outerpad
obox = fig.bbox_inches # original bbox
bbox = fig.get_tightbbox(renderer)
# Calculate new figure margins
# NOTE: Negative spaces are common where entire rows/columns of gridspec
# are empty but it seems to result in wrong figure size + grid positions. Not
# worth correcting so instead enforce positive margin sizes. Will leave big
# empty slot but that is probably what should happen under this scenario.
left = self.left
bottom = self.bottom
right = self.right
top = self.top
self._left_default = max(0, left - (bbox.xmin - 0) + pad)
self._bottom_default = max(0, bottom - (bbox.ymin - 0) + pad)
self._right_default = max(0, right - (obox.xmax - bbox.xmax) + pad)
self._top_default = max(0, top - (obox.ymax - bbox.ymax) + pad)
# Calculate new subplot row and column spaces. Enforce equal
# default spaces between main subplot edges if requested.
hspace = self._get_tight_space('h')
wspace = self._get_tight_space('w')
if self._hequal:
idxs = self._get_indices('h', space=True)
space = max(hspace[i] for i in idxs)
for i in idxs:
hspace[i] = space
if self._wequal:
idxs = self._get_indices('w', space=True)
space = max(wspace[i] for i in idxs)
for i in idxs:
wspace[i] = space
self._hspace_total_default = hspace
self._wspace_total_default = wspace
# Update the layout
# NOTE: fig.set_size_inches() always updates the gridspec to enforce fixed
# spaces (necessary since native position coordinates are figure-relative)
# and to enforce fixed panel ratios. So only self.update() if we skip resize.
figsize = self._update_figsize()
if not fig._is_same_size(figsize):
fig.set_size_inches(figsize, internal=True)
else:
self.update()
def _update_figsize(self):
"""
Return an updated auto layout figure size accounting for the
gridspec and figure parameters. May or may not need to be applied.
"""
fig = self.figure
if fig is None: # drawing before subplots are added?
return
ax = fig._subplot_dict.get(fig._refnum, None)
if ax is None: # drawing before subplots are added?
return
ss = ax.get_subplotspec().get_topmost_subplotspec()
y1, y2, x1, x2 = ss._get_rows_columns()
refhspace = sum(self.hspace_total[y1:y2])
refwspace = sum(self.wspace_total[x1:x2])
refhpanel = sum(self.hratios_total[i] for i in range(y1, y2 + 1) if self._hpanels[i]) # noqa: E501
refwpanel = sum(self.wratios_total[i] for i in range(x1, x2 + 1) if self._wpanels[i]) # noqa: E501
refhsubplot = sum(self.hratios_total[i] for i in range(y1, y2 + 1) if not self._hpanels[i]) # noqa: E501
refwsubplot = sum(self.wratios_total[i] for i in range(x1, x2 + 1) if not self._wpanels[i]) # noqa: E501
# Get the reference sizes
# NOTE: The sizing arguments should have been normalized already
figwidth, figheight = fig._figwidth, fig._figheight
refwidth, refheight = fig._refwidth, fig._refheight
refaspect = _not_none(fig._refaspect, fig._refaspect_default)
if refheight is None and figheight is None:
if figwidth is not None:
gridwidth = figwidth - self.spacewidth - self.panelwidth
refwidth = gridwidth * refwsubplot / self.gridwidth
if refwidth is not None: # WARNING: do not change to elif!
refheight = refwidth / refaspect
else:
raise RuntimeError('Figure size arguments are all missing.')
if refwidth is None and figwidth is None:
if figheight is not None:
gridheight = figheight - self.spaceheight - self.panelheight
refheight = gridheight * refhsubplot / self.gridheight
if refheight is not None:
refwidth = refheight * refaspect
else:
raise RuntimeError('Figure size arguments are all missing.')
# Get the auto figure size. Might trigger 'not enough room' error later
# NOTE: For e.g. [[1, 1, 2, 2], [0, 3, 3, 0]] we make sure to still scale the
# reference axes like a square even though takes two columns of gridspec.
if refheight is not None:
refheight -= refhspace + refhpanel
gridheight = refheight * self.gridheight / refhsubplot
figheight = gridheight + self.spaceheight + self.panelheight
if refwidth is not None:
refwidth -= refwspace + refwpanel
gridwidth = refwidth * self.gridwidth / refwsubplot
figwidth = gridwidth + self.spacewidth + self.panelwidth
# Return the figure size
figsize = (figwidth, figheight)
if all(np.isfinite(figsize)):
return figsize
else:
warnings._warn_proplot(f'Auto resize failed. Invalid figsize {figsize}.')
def _update_params(
self, *,
left=None, bottom=None, right=None, top=None,
wspace=None, hspace=None, space=None,
wpad=None, hpad=None, pad=None,
wequal=None, hequal=None, equal=None,
wgroup=None, hgroup=None, group=None,
outerpad=None, innerpad=None, panelpad=None,
hratios=None, wratios=None, width_ratios=None, height_ratios=None,
):
"""
Update the user-specified properties.
"""
# Assign scalar args
# WARNING: The key signature here is critical! Used in ui.py to
# separate out figure keywords and gridspec keywords.
def _assign_scalar(key, value, convert=True):
if value is None:
return
if not np.isscalar(value):
raise ValueError(f'Unexpected {key}={value!r}. Must be scalar.')
if convert:
value = units(value, 'em', 'in')
setattr(self, f'_{key}', value)
hequal = _not_none(hequal, equal)
wequal = _not_none(wequal, equal)
hgroup = _not_none(hgroup, group)
wgroup = _not_none(wgroup, group)
_assign_scalar('left', left)
_assign_scalar('right', right)
_assign_scalar('bottom', bottom)
_assign_scalar('top', top)
_assign_scalar('panelpad', panelpad)
_assign_scalar('outerpad', outerpad)
_assign_scalar('innerpad', innerpad)
_assign_scalar('hequal', hequal, convert=False)
_assign_scalar('wequal', wequal, convert=False)
_assign_scalar('hgroup', hgroup, convert=False)
_assign_scalar('wgroup', wgroup, convert=False)
# Assign vector args
# NOTE: Here we employ obfuscation that skips 'panel' indices. So users could
# still call self.update(wspace=[1, 2]) even if there is a right-axes panel
# between each subplot. To control panel spaces users should instead pass
# 'pad' or 'space' to panel_axes(), colorbar(), or legend() on creation.
def _assign_vector(key, values, space):
if values is None:
return
idxs = self._get_indices(key[0], space=space)
nidxs = len(idxs)
values = np.atleast_1d(values)
if values.size == 1:
values = np.repeat(values, nidxs)
if values.size != nidxs:
raise ValueError(f'Expected len({key}) == {nidxs}. Got {values.size}.')
list_ = getattr(self, f'_{key}_total')
for i, value in enumerate(values):
if value is None:
continue
list_[idxs[i]] = value
if pad is not None and not np.isscalar(pad):
raise ValueError(f'Parameter pad={pad!r} must be scalar.')
if space is not None and not np.isscalar(space):
raise ValueError(f'Parameter space={space!r} must be scalar.')
hpad = _not_none(hpad, pad)
wpad = _not_none(wpad, pad)
hpad = units(hpad, 'em', 'in')
wpad = units(wpad, 'em', 'in')
hspace = _not_none(hspace, space)
wspace = _not_none(wspace, space)
hspace = units(hspace, 'em', 'in')
wspace = units(wspace, 'em', 'in')
hratios = _not_none(hratios=hratios, height_ratios=height_ratios)
wratios = _not_none(wratios=wratios, width_ratios=width_ratios)
_assign_vector('hpad', hpad, space=True)
_assign_vector('wpad', wpad, space=True)
_assign_vector('hspace', hspace, space=True)