22"""
33Various tools that may be useful while making plots.
44"""
5+ # WARNING: Cannot import 'rc' anywhere in this file or we get circular import
6+ # issues. The rc param validators need functions in this file.
57import re
6- from numbers import Integral , Number
8+ from numbers import Integral , Real
79
810import matplotlib .colors as mcolors
911import matplotlib .font_manager as mfonts
1012import numpy as np
11- from matplotlib import rcParams
13+ from matplotlib import rcParams as rc_matplotlib
1214
1315from .externals import hsluv
1416from .internals import ic # noqa: F401
15- from .internals import docstring , warnings
17+ from .internals import _not_none , docstring , warnings
1618
1719__all__ = [
1820 'arange' ,
3133 'to_xyz' ,
3234 'to_rgba' ,
3335 'to_xyza' ,
36+ 'units' ,
3437 'shade' , # deprecated
3538 'saturate' , # deprecated
3639]
3740
38- NUMBER = re .compile (r'\A([-+]?[0-9._]+(?:[eE][-+]?[0-9_]+)?)(.*)\Z' )
41+ UNIT_REGEX = re .compile (
42+ r'\A([-+]?[0-9._]+(?:[eE][-+]?[0-9_]+)?)(.*)\Z' # float with trailing units
43+ )
3944UNIT_DICT = {
4045 'in' : 1.0 ,
4146 'ft' : 12.0 ,
4449 'dm' : 3.937 ,
4550 'cm' : 0.3937 ,
4651 'mm' : 0.03937 ,
47- 'pt' : 1 / 72.0 ,
4852 'pc' : 1 / 6.0 ,
53+ 'pt' : 1 / 72.0 ,
4954 'ly' : 3.725e+17 ,
5055}
5156
52- # Shared parameters
57+
58+ # Unit docstrings
59+ # NOTE: Try to fit this into a single line. Cannot break up with newline as that will
60+ # mess up docstring indentation since this is placed in indented param lines.
61+ _units_docstring = (
62+ 'If float, units are {units}. If string, interpreted by `~proplot.utils.units`.'
63+ )
64+ docstring .snippets ['units.pt' ] = _units_docstring .format (units = 'points' )
65+ docstring .snippets ['units.in' ] = _units_docstring .format (units = 'inches' )
66+ docstring .snippets ['units.em' ] = _units_docstring .format (units = 'em-widths' )
67+
68+
69+ # Color docstrings
5370docstring .snippets ['param.rgba' ] = """
5471color : color-spec
5572 The color. Sanitized with `to_rgba`.
7592 The hue-saturation-luminance-like colorspace used to transform the color.
7693 Default is the perceptually uniform colorspace ``'hcl'``.
7794"""
78-
79- # Shared return values
8095docstring .snippets ['return.hex' ] = """
8196color : str
8297 A HEX string.
@@ -472,6 +487,35 @@ def set_alpha(color, alpha):
472487 return to_hex (color )
473488
474489
490+ def _translate_cycle_color (color , cycle = None ):
491+ """
492+ Parse the input cycle color.
493+ """
494+ if isinstance (cycle , str ):
495+ from .colors import _cmap_database
496+ try :
497+ cycle = _cmap_database [cycle ].colors
498+ except (KeyError , AttributeError ):
499+ cycles = sorted (
500+ name for name , cmap in _cmap_database .items ()
501+ if isinstance (cmap , mcolors .ListedColormap )
502+ )
503+ raise ValueError (
504+ f'Invalid color cycle { cycle !r} . Options are: '
505+ + ', ' .join (map (repr , cycles )) + '.'
506+ )
507+ elif cycle is None :
508+ cycle = rc_matplotlib ['axes.prop_cycle' ].by_key ()
509+ if 'color' not in cycle :
510+ cycle = ['k' ]
511+ else :
512+ cycle = cycle ['color' ]
513+ else :
514+ raise ValueError (f'Invalid cycle { cycle !r} .' )
515+
516+ return cycle [int (color [- 1 ]) % len (cycle )]
517+
518+
475519@docstring .add_snippets
476520def to_hex (color , space = 'rgb' , cycle = None , keep_alpha = True ):
477521 """
@@ -549,37 +593,16 @@ def to_rgba(color, space='rgb', cycle=None):
549593 to_xyz
550594 to_xyza
551595 """
552- # Convert color cycle strings
596+ # Translate color cycle strings
553597 if isinstance (color , str ) and re .match (r'\AC[0-9]\Z' , color ):
554- if isinstance (cycle , str ):
555- from .colors import _cmap_database
556- try :
557- cycle = _cmap_database [cycle ].colors
558- except (KeyError , AttributeError ):
559- cycles = sorted (
560- name for name , cmap in _cmap_database .items ()
561- if isinstance (cmap , mcolors .ListedColormap )
562- )
563- raise ValueError (
564- f'Invalid cycle { cycle !r} . Options are: '
565- + ', ' .join (map (repr , cycles )) + '.'
566- )
567- elif cycle is None :
568- cycle = rcParams ['axes.prop_cycle' ].by_key ()
569- if 'color' not in cycle :
570- cycle = ['k' ]
571- else :
572- cycle = cycle ['color' ]
573- else :
574- raise ValueError (f'Invalid cycle { cycle !r} .' )
575- color = cycle [int (color [- 1 ]) % len (cycle )]
598+ color = _translate_cycle_color (color , cycle = cycle )
576599
577600 # Translate RGB strings and (colormap, index) tuples
578601 opacity = 1
579602 if isinstance (color , str ) or np .iterable (color ) and len (color ) == 2 :
580603 try :
581604 * color , opacity = mcolors .to_rgba (color ) # ensure is valid color
582- except (ValueError , TypeError ):
605+ except (TypeError , ValueError ):
583606 raise ValueError (f'Invalid RGB argument { color !r} .' )
584607
585608 # Pull out alpha channel
@@ -594,7 +617,7 @@ def to_rgba(color, space='rgb', cycle=None):
594617 if any (c > 2 for c in color ):
595618 color = [c / 255 for c in color ] # scale to within 0-1
596619 color = tuple (color )
597- except (ValueError , TypeError ):
620+ except (TypeError , ValueError ):
598621 raise ValueError (f'Invalid RGB argument { color !r} .' )
599622 elif space == 'hsv' :
600623 color = hsluv .hsl_to_rgb (* color )
@@ -681,8 +704,28 @@ def to_xyza(color, space='hcl'):
681704 return (* color , opacity )
682705
683706
707+ def _fontsize_to_pt (size ):
708+ """
709+ Translate font preset size or unit string to points.
710+ """
711+ scalings = mfonts .font_scalings
712+ if not isinstance (size , str ):
713+ return size
714+ if size in mfonts .font_scalings :
715+ return rc_matplotlib ['font.size' ] * scalings [size ]
716+ try :
717+ return units (size , 'pt' )
718+ except ValueError :
719+ raise KeyError (
720+ f'Invalid font size { size !r} . Can be points or one of the preset scalings: '
721+ + ', ' .join (f'{ key !r} ({ value } )' for key , value in scalings .items ()) + '.'
722+ )
723+
724+
684725@warnings ._rename_kwargs ('0.6' , units = 'dest' )
685- def units (value , dest = 'in' , axes = None , figure = None , width = True ):
726+ def units (
727+ value , numeric = None , dest = None , * , axes = None , figure = None , width = True , fontsize = None
728+ ):
686729 """
687730 Convert values and lists of values between arbitrary physical units. This
688731 is used internally all over ProPlot, permitting flexible units for various
@@ -691,10 +734,11 @@ def units(value, dest='in', axes=None, figure=None, width=True):
691734 Parameters
692735 ----------
693736 value : float or str or list thereof
694- A size specifier or *list thereof*. If numeric, nothing is done.
695- If string, it is converted to the units `dest`. The string should look
696- like ``'123.456unit'``, where the number is the magnitude and
697- ``'unit'`` is one of the following.
737+ A size specifier or *list thereof*. If numeric, units are converted from
738+ `numeric` to `dest`. If string, units are converted to `dest` according
739+ to the string specifier. The string should look like ``'123.456unit'``,
740+ where the number is the magnitude and ``'unit'`` matches a key in
741+ the below table.
698742
699743 .. _units_table ::
700744
@@ -708,8 +752,8 @@ def units(value, dest='in', axes=None, figure=None, width=True):
708752 ``'yd'`` Yards
709753 ``'ft'`` Feet
710754 ``'in'`` Inches
711- ``'pt'`` `Points <pt_>`_ (1/72 inches)
712755 ``'pc'`` `Pica <pc_>`_ (1/6 inches)
756+ ``'pt'`` `Points <pt_>`_ (1/72 inches)
713757 ``'px'`` Pixels on screen, using dpi of :rcraw:`figure.dpi`
714758 ``'pp'`` Pixels once printed, using dpi of :rcraw:`savefig.dpi`
715759 ``'em'`` `Em square <em_>`_ for :rcraw:`font.size`
@@ -726,28 +770,28 @@ def units(value, dest='in', axes=None, figure=None, width=True):
726770 .. _em: https://en.wikipedia.org/wiki/Em_(typography)
727771 .. _en: https://en.wikipedia.org/wiki/En_(typography)
728772
773+ numeric : str, optional
774+ The units associated with numeric input. Default is inches.
729775 dest : str, optional
730- The destination units. Default is inches, i.e. ``'in'` `.
776+ The destination units. Default is the same as `numeric `.
731777 axes : `~matplotlib.axes.Axes`, optional
732778 The axes to use for scaling units that look like ``'0.1ax'``.
733779 figure : `~matplotlib.figure.Figure`, optional
734- The figure to use for scaling units that look like ``'0.1fig'``. If
735- ``None`` we try to get the figure from ``axes.figure``.
780+ The figure to use for scaling units that look like ``'0.1fig'``.
781+ If ``None`` we try to get the figure from ``axes.figure``.
736782 width : bool, optional
737- Whether to use the width or height for the axes and figure relative
738- coordinates.
739- """
740- # Font unit scales
741- # NOTE: Delay font_manager import, because want to avoid rebuilding font
742- # cache, which means import must come after TTFPATH added to environ
743- # by register_fonts()!
744- fontsize_small = rcParams ['font.size' ] # must be absolute
745- fontsize_large = rcParams ['axes.titlesize' ]
746- if isinstance (fontsize_large , str ):
747- scale = mfonts .font_scalings .get (fontsize_large , 1 )
748- fontsize_large = fontsize_small * scale
749-
783+ Whether to use the width or height for the axes and figure
784+ relative coordinates.
785+ fontsize : size-spec, optional
786+ The font size in points used for scaling. Default is
787+ :rcraw:`font.size` for ``em`` and ``en`` units and
788+ :rcraw:`axes.titlesize` for ``Em`` and ``En`` units.
789+ """
750790 # Scales for converting physical units to inches
791+ fontsize_small = _not_none (fontsize , rc_matplotlib ['font.size' ]) # always absolute
792+ fontsize_small = _fontsize_to_pt (fontsize_small )
793+ fontsize_large = _not_none (fontsize , rc_matplotlib ['axes.titlesize' ])
794+ fontsize_large = _fontsize_to_pt (fontsize_large )
751795 unit_dict = UNIT_DICT .copy ()
752796 unit_dict .update ({
753797 'em' : fontsize_small / 72.0 ,
@@ -758,62 +802,63 @@ def units(value, dest='in', axes=None, figure=None, width=True):
758802
759803 # Scales for converting display units to inches
760804 # WARNING: In ipython shell these take the value 'figure'
761- if not isinstance (rcParams ['figure.dpi' ], str ):
762- # once generated by backend
763- unit_dict ['px' ] = 1 / rcParams ['figure.dpi' ]
764- if not isinstance (rcParams ['savefig.dpi' ], str ):
765- # once 'printed' i.e. saved
766- unit_dict ['pp' ] = 1 / rcParams ['savefig.dpi' ]
805+ if not isinstance (rc_matplotlib ['figure.dpi' ], str ):
806+ unit_dict ['px' ] = 1 / rc_matplotlib ['figure.dpi' ] # once generated by backend
807+ if not isinstance (rc_matplotlib ['savefig.dpi' ], str ):
808+ unit_dict ['pp' ] = 1 / rc_matplotlib ['savefig.dpi' ] # once 'printed' i.e. saved
767809
768810 # Scales relative to axes and figure objects
769- if axes is not None and hasattr (axes , 'get_size_inches ' ): # proplot axes
770- unit_dict ['ax' ] = axes .get_size_inches ()[1 - int (width )]
811+ if axes is not None and hasattr (axes , '_get_size_inches ' ): # proplot axes
812+ unit_dict ['ax' ] = axes ._get_size_inches ()[1 - int (width )]
771813 if figure is None :
772814 figure = getattr (axes , 'figure' , None )
773- if figure is not None and hasattr (
774- figure , 'get_size_inches' ): # proplot axes
815+ if figure is not None and hasattr (figure , 'get_size_inches' ):
775816 unit_dict ['fig' ] = figure .get_size_inches ()[1 - int (width )]
776817
777818 # Scale for converting inches to arbitrary other unit
819+ if numeric is None and dest is None :
820+ numeric = dest = 'in'
821+ elif numeric is None :
822+ numeric = dest
823+ elif dest is None :
824+ dest = numeric
825+ options = 'Valid units are ' + ', ' .join (map (repr , unit_dict )) + '.'
778826 try :
779- scale = unit_dict [dest ]
827+ nscale = unit_dict [numeric ]
780828 except KeyError :
781- raise ValueError (
782- f'Invalid destination units { dest !r} . Valid units are '
783- + ', ' .join (map (repr , unit_dict .keys ())) + '.'
784- )
829+ raise ValueError (f'Invalid numeric units { numeric !r} . ' + options )
830+ try :
831+ dscale = unit_dict [dest ]
832+ except KeyError :
833+ raise ValueError (f'Invalid destination units { dest !r} . ' + options )
785834
786835 # Convert units for each value in list
787836 result = []
788837 singleton = not np .iterable (value ) or isinstance (value , str )
789838 for val in ((value ,) if singleton else value ):
790- if val is None or isinstance (val , Number ):
839+ # Silently pass None
840+ if val is None :
791841 result .append (val )
792842 continue
793- elif not isinstance (val , str ):
794- raise ValueError (
795- f'Size spec must be string or number or list thereof. '
796- f'Got { value !r} .'
797- )
798- regex = NUMBER .match (val )
799- if not regex :
800- raise ValueError (
801- f'Invalid size spec { val !r} . Valid units are '
802- + ', ' .join (map (repr , unit_dict .keys ())) + '.'
803- )
804- number , units = regex .groups () # second group is exponential
805- try :
806- result .append (
807- float (number ) * (unit_dict [units ] / scale if units else 1 )
808- )
809- except (KeyError , ValueError ):
810- raise ValueError (
811- f'Invalid size spec { val !r} . Valid units are '
812- + ', ' .join (map (repr , unit_dict .keys ())) + '.'
813- )
814- if singleton :
815- result = result [0 ]
816- return result
843+ # Get unit string
844+ if isinstance (val , Real ):
845+ number , units = val , None
846+ elif isinstance (val , str ):
847+ regex = UNIT_REGEX .match (val )
848+ if regex :
849+ number , units = regex .groups () # second group is exponential
850+ else :
851+ raise ValueError (f'Invalid unit size spec { val !r} .' )
852+ else :
853+ raise ValueError (f'Invalid unit size spec { val !r} .' )
854+ # Convert with units
855+ if not units :
856+ result .append (float (number ) * nscale / dscale )
857+ elif units in unit_dict :
858+ result .append (float (number ) * unit_dict [units ] / dscale )
859+ else :
860+ raise ValueError (f'Invalid input units { units !r} . ' + options )
861+ return result [0 ] if singleton else result
817862
818863
819864# Deprecations
0 commit comments