Module tikz.extended_wilkinson
Extended-Wilkinson algorithm for tick values and labels
Implementation of J. Talbot, S. Lin, & P. Hanrahan, An Extension of Wilkinson’s Algorithm for Positioning Tick Labels on Axes, IEEE Trans. Vis. Comput. Graph., 16(6), 1036-1043, 2010. doi:10.1109/TVCG.2010.130
Based on the R implementation and additional information by Justin Talbot.
Other than the R code, this implementation includes the legibility score,
and the result provides detailed information about the optimal presentation of
tick values as labels. In line with that, the parameter target number of ticks
m has been replaced by a target physical tick density (ρt in the
paper).
Compared to the paper, there are two limitations: Of the eight label formats, only 'Decimal' and 'Factored scientific' are implemented; and tick labels are always '0-extended', i.e. if stripping of trailing zeros is desired, it must be performed by the user.
Expand source code
"""
Extended-Wilkinson algorithm for tick values and labels
Implementation of J. Talbot, S. Lin, & P. Hanrahan, An Extension of
Wilkinson’s Algorithm for Positioning Tick Labels on Axes, *IEEE Trans.
Vis. Comput. Graph.*, 16(6), 1036-1043, 2010.
[doi:10.1109/TVCG.2010.130](https://doi.org/10.1109/tvcg.2010.130)
Based on the
[R implementation](https://rdrr.io/rforge/labeling/src/R/labeling.R)
and [additional information](https://github.com/jtalbot/Labeling/issues/1)
by Justin Talbot.
Other than the R code, this implementation includes the *legibility* score,
and the result provides detailed information about the optimal presentation of
tick values as labels. In line with that, the parameter target number of ticks
`m` has been replaced by a target physical tick density (ρ<sub>t</sub> in the
paper).
Compared to the paper, there are two limitations: Of the eight label formats,
only 'Decimal' and 'Factored scientific' are implemented; and tick labels are
always '0-extended', i.e. if stripping of trailing zeros is desired, it must be
performed by the user.
"""
# Copyright (C) 2020 Carsten Allefeld
from math import log10, ceil, floor
from itertools import count
from decimal import Decimal as D
class cfg:
    "`tikz.extended_wilkinson` configuration variables"
    Q = [D(1), D(5), D(2), D('2.5'), D(4), D(3)]
    """
    preference-ordered list of nice step sizes
    Values must be of type `decimal.Decimal`. The default step sizes are 1, 5,
    2, 2.5, 4, and 3.
    """
    w = [0.25, 0.2, 0.5, 0.05]
    """
    list of weights of score components
    The default weights are 0.25 for *simplicity*, 0.2 for *coverage*, 0.5 for
    *density*, and 0.05 for *legibility*.
    """
    font_metrics = {'offset': 0.1, '-': 0.678, '1': 0.5, '2': 0.5, '3': 0.5,
                    '4': 0.5, '5': 0.5, '6': 0.5, '7': 0.5, '8': 0.5, '9': 0.5,
                    '0': 0.5, '.': 0.278, 'height': 0.728}
    """
    default font metrics
    Font metrics are used to calculate the width and height of tick labels.
    They are specified as a `dict`, where each character that can occur in a
    tick label (`'-'`, `'0'`–`'9'`, `'.'`) is a key associated with the
    character's width; it also contains an `'offset'` to be added to the total
    width, and a `'height'`. All numbers are in units of the font size.
    The default values are for La/TeX's standard math font (Computer Modern
    Roman).
    """
class TicksGenerator:
    """
    generator of tick values and labels
    This class stores parameters of tick generation that are likely to stay the
    same across several axes:
    `font_sizes`
    :   admissible font sizes, in TeX pt (2.54 cm / 72.27)
    `density`
    :   target density of ticks, in cm<sup>–1</sup>
    `font_metrics`
    :   used to calculate the width and height of tick labels,
        see `cfg.font_metrics` (default)
    `only_loose`
    :   whether the range of tick values is forced to encompass the
      range of data values
    Ticks for a specific axis are generated by calling the
    [`ticks()`](#tikz.extended_wilkinson.TicksGenerator.ticks) method on an
    instance.
    """
    def __init__(self, font_sizes, density,
                 font_metrics=None, only_loose=True):
        if font_metrics is None:
            font_metrics = cfg.font_metrics
        self.font_sizes = sorted(font_sizes)
        self.rt = density
        self.font_metrics = font_metrics
        self.only_loose = only_loose
    # scoring functions, including the approximations for limiting the search
    def _simplicity(self, i, start, j, k):
        # v: is zero included in the ticks?
        # modifications
        # - (lmin % lstep < eps or lstep - (lmin % lstep) < eps),
        #   means lmin / lstep = start / j is an integer
        # - lmin <= 0 means start <=0
        # - lmax >= 0 means start + j * (k - 1) >= 0
        v = (start % j == 0 and start <= 0 and start + j * (k - 1) >= 0) * 1
        return 1 - (i - 1) / (len(cfg.Q) - 1) - j + v
    def _simplicity_max(self, i, j):
        # upper bound on _simplicity w.r.t. k, z, start
        # = w.r.t. v
        return 1 - (i - 1) / (len(cfg.Q) - 1) - j + 1
    def _coverage(self, dmin, dmax, lmin, lmax):
        return (1 - 0.5 * ((dmax - lmax)**2 + (dmin - lmin)**2)
                / (0.1 * (dmax - dmin))**2)
    def _coverage_max(self, dmin, dmax, span):
        # upper bound on _coverage w.r.t. start
        range = dmax - dmin
        # The original code has a branching which I don't think is necessary.
        # if span > range:
        #     half = (span - range) / 2
        #     return 1 - 0.5 * (2 * half ** 2) / (0.1 * range)**2
        # else:
        #     return 1
        half = (span - range) / 2
        return 1 - 0.5 * (2 * half ** 2) / (0.1 * range)**2
    def _density(self, k, m, dmin, dmax, lmin, lmax):
        r = (k - 1) / (lmax - lmin)
        rt = (m - 1) / (max(lmax, dmax) - min(dmin, lmin))
        return 2 - max((r / rt, rt / r))
    def _density_max(self, k, m):
        # From original code, which I don't understand.
        if k >= m:
            return 2 - (k - 1) / (m - 1)
        else:
            # Probably just the trivial upper bound.
            return 1
    def _score(self, s, c, d, l):
        # combined score
        return cfg.w[0] * s + cfg.w[1] * c + cfg.w[2] * d + cfg.w[3] * l
    # optimization algorithm
    def ticks(self, dmin, dmax, axis_length, axis_horizontal):
        """
        generate tick values and labels for a specific axis
        `dmin`
        :   data values lower bound
        `dmax`
        :   data values upper bound
        `axis_length`
        :   physical length of the axis, in cm
        `axis_horizontal`
        :   whether the axis is oriented horizontally (rather than vertically)
        Returns a `Ticks` object.
        """
        # The implementation here is based on the R code, which is defined
        # in terms of `m`, the target number of ticks. It optimizes w.r.t.
        # the ratio between the two quantities
        #   r = (k - 1) / (lmax - lmin)
        #   rt = (m - 1) / (max(lmax, dmax) - min(dmin, lmin))
        # We want to instead specify the physical density (e.g. in 1/cm),
        # stored as a class attribute `self.rt`, and the parameter `length`
        # (e.g. in cm). Assuming that the axis spans `min(dmin, lmin)` to
        # `max(lmax, dmax)`, while the ticks span lmin to lmax, the
        # optimization should use the ratio of
        #   r = (k - 1) / (length * (lmax - lmin))
        #       * (max(lmax, dmax) - min(dmin, lmin))
        # to `self.rt`.
        # It turns out that the two ratios are equivalent if one sets
        m = self.rt * axis_length + 1
        if dmin > dmax:
            dmin, dmax = dmax, dmin
        # threshold for optimization
        ticks = None
        best_score = -2
        # We combine the j and q loops into one to enable breaking out of both
        # simultaneously, by iterating over a generator; and we create an
        # index i corresponding to q at the same time. i is `match(q, Q)[1]`
        # and replaces `q, Q` in function calls.
        JIQ = ((j, i, q)
               for j in count(start=1)
               for i, q in enumerate(cfg.Q, start=1))
        for j, i, q in JIQ:
            sm = self._simplicity_max(i, j)
            if self._score(sm, 1, 1, 1) < best_score:
                break
            for k in count(start=2):      # loop over tick counts
                dm = self._density_max(k, m)
                if self._score(sm, 1, dm, 1) < best_score:
                    break
                delta = (dmax - dmin) / (k + 1) / (j * float(q))
                for z in count(start=ceil(log10(delta))):
                    step = float(q) * j * 10**z
                    cm = self._coverage_max(dmin, dmax, step * (k - 1))
                    if self._score(sm, cm, dm, 1) < best_score:
                        break
                    min_start = floor(dmax / step) * j - (k - 1) * j
                    max_start = ceil(dmin / step) * j
                    if min_start > max_start:
                        continue
                    for start in range(min_start, max_start + 1):
                        lmin = start * step / j
                        lmax = lmin + step * (k - 1)
                        # lstep = step
                        if self.only_loose:
                            if lmin > dmin or lmax < dmax:
                                continue
                        s = self._simplicity(i, start, j, k)
                        c = self._coverage(dmin, dmax, lmin, lmax)
                        d = self._density(k, m, dmin, dmax, lmin, lmax)
                        score = self._score(s, c, d, 1)
                        if score < best_score:
                            continue
                        # Exact tick values in terms of loop variables:
                        #   lmin = q * start * 10**z
                        #   lmax = q * (start + j * (k - 1)) * 10 ** z
                        #   lstep = float(q) * j * 10**z
                        decimal_values = [q * (start + j * ind)
                                          * D('1E1') ** z
                                          for ind in range(k)]
                        # Create `Ticks` object
                        ticks = Ticks(
                            amin=min(lmin, dmin),
                            amax=max(lmax, dmax),
                            decimal_values=decimal_values)
                        # and initiate internal optimization for label
                        # legibility.
                        ticks._optimize(
                            self.font_sizes,
                            self.font_metrics,
                            axis_length,
                            axis_horizontal)
                        l = ticks.opt_legibility                                    # noqa E741
                        score = self._score(s, c, d, l)
                        if score > best_score:
                            best_score = score
        if ticks is None:
            # no solution found: no ticks
            print('Warning: Could not determine ticks.')
            ticks = Ticks(
                amin=dmin,
                amax=dmax,
                decimal_values=[],
                labels=[])
        return ticks
class Ticks:
    """
    represents tick values and labels
    This class is not intended to be instantiated by the user, but
    `Ticks` objects are obtained via `TicksGenerator.ticks`.
    """
    def __init__(self, amin, amax, decimal_values,
                 labels=None, plabel=None, font_size=None, horizontal=None):
        self.amin = amin
        "axis lower bound"
        self.amax = amax
        "axis upper bound"
        self.values = [float(dv) for dv in decimal_values]
        "list of tick values"
        self.decimal_values = decimal_values
        "list of exact tick values, as `decimal.Decimal`s"
        self.labels = labels
        "list of tick labels strings"
        self.plabel = plabel
        """
        power label string
        If `plabel` is not `None`, it represents a decadic power factored from
        the tick values. It is intended to be displayed in the form
        10<sup>`plabel`</sup> either at the side of the axis or as/with a unit
        in the axis label.
        """
        self.font_size = font_size
        "tick label font size, in TeX pt (2.54 cm / 72.27)"
        self.horizontal = horizontal
        """
        whether the tick label is to be displayed in horizontal orientation
        (rather than vertical)
        """
    def _optimize(self, font_sizes, font_metrics,
                  axis_length, axis_horizontal):
        """
        optimize label legibility in terms of format, font size, and
        orientation
        """
        # tick values
        values = self.values
        # minimum font size
        fs_min = min(font_sizes)
        # target font size
        fs_t = max(font_sizes)
        # optimization
        self.opt_legibility = float('-inf')
        # format
        for f in range(2):
            # legibility score for format
            if f == 0:
                # format 'Decimal'
                vls = [(1e-4 < abs(v) < 1e6) * 1 for v in values]
                leg_f = sum(vls) / len(vls)
            else:
                # format 'Factored scientific'
                leg_f = 0.3
            # tick labels
            if f == 0:
                # format 'Decimal'
                labels = self._labels_Decimal()
                plabel = None
            else:
                # format 'Factored scientific'
                labels, plabel = self._labels_Scientific()
            # widths and heights of tick labels, in units of font size
            widths = [self._label_width(l, font_metrics) for l in labels]
            heights = [self._label_height(l, font_metrics) for l in labels]
            # font size
            for fs in font_sizes:
                # legibility score for font size
                if fs == fs_t:
                    leg_fs = 1
                else:
                    leg_fs = 0.2 * (fs - fs_min + 1) / (fs_t - fs_min)
                # distance between ticks, in units of font size
                step = (
                    (values[1] - values[0])         # numerical
                    / (self.amax - self.amin)       # relative to axis
                    * axis_length                   # physical, in cm
                    /
                    (fs / 72.27 * 2.54)             # font size, in cm
                    )
                # orientation
                for o in range(2):
                    # legibility score for orientation
                    if o == 0:              # horizontal orientation
                        leg_or = 1
                    else:                   # vertical orientation
                        leg_or = -0.5
                    # legibility score for overlap
                    # extents of labels along the axis, in units of font size
                    if (o == 0) == axis_horizontal:
                        # label and axis have the same orientation
                        extents = widths
                    else:
                        # label and axis have different orientations
                        extents = heights
                    # minimum distance between neighboring labels
                    # We can apply the minimum here, since overlap legibility
                    # is an increasing function of distance.
                    dist = min(step - (extents[i] + extents[i + 1]) / 2
                               for i in range(len(extents) - 1))
                    # score; we interpret em as font size
                    if dist >= 1.5:
                        leg_ov = 1
                    elif dist > 0:
                        leg_ov = 2 - 1.5 / dist
                    else:
                        leg_ov = float('-inf')
                    # total legibility score
                    leg = (leg_f + leg_fs + leg_or + leg_ov) / 4
                    # aggregate
                    if leg > self.opt_legibility:
                        self.opt_legibility = leg
                        self.labels = labels
                        self.plabel = plabel
                        self.font_size = fs
                        self.horizontal = (o == 0)
    def _label_width(self, label, font_metrics):
        "get width of tick label"
        w = sum(map(font_metrics.get, label)) + font_metrics['offset']
        return w
    def _label_height(self, label, font_metrics):
        "get height of tick label"
        h = font_metrics['height']
        return h
    def _labels_Decimal(self):
        "get tick labels in 'Decimal' format"
        # get values
        dvs = self.decimal_values
        # create labels
        labels = ['{:f}'.format(dv) for dv in dvs]
        return labels
    def _labels_Scientific(self):
        "get tick labels in 'Scientific format'"
        # get values
        dvs = self.decimal_values
        # get largest power of 10 than can be factored out
        z0 = min([floor(log10(abs(dv))) for dv in dvs if dv != 0])
        # get values adjusted to that power
        dvs = [dv * D('1E1') ** (-z0) for dv in dvs]
        # create labels
        labels = ['{:f}'.format(dv) for dv in dvs]
        plabel = '{:d}'.format(z0)
        return labels, plabelClasses
- class cfg (*args, **kwargs)
- 
tikz.extended_wilkinsonconfiguration variablesExpand source codeclass cfg: "`tikz.extended_wilkinson` configuration variables" Q = [D(1), D(5), D(2), D('2.5'), D(4), D(3)] """ preference-ordered list of nice step sizes Values must be of type `decimal.Decimal`. The default step sizes are 1, 5, 2, 2.5, 4, and 3. """ w = [0.25, 0.2, 0.5, 0.05] """ list of weights of score components The default weights are 0.25 for *simplicity*, 0.2 for *coverage*, 0.5 for *density*, and 0.05 for *legibility*. """ font_metrics = {'offset': 0.1, '-': 0.678, '1': 0.5, '2': 0.5, '3': 0.5, '4': 0.5, '5': 0.5, '6': 0.5, '7': 0.5, '8': 0.5, '9': 0.5, '0': 0.5, '.': 0.278, 'height': 0.728} """ default font metrics Font metrics are used to calculate the width and height of tick labels. They are specified as a `dict`, where each character that can occur in a tick label (`'-'`, `'0'`–`'9'`, `'.'`) is a key associated with the character's width; it also contains an `'offset'` to be added to the total width, and a `'height'`. All numbers are in units of the font size. The default values are for La/TeX's standard math font (Computer Modern Roman). """Class variables- var Q
- 
preference-ordered list of nice step sizes Values must be of type decimal.Decimal. The default step sizes are 1, 5, 2, 2.5, 4, and 3.
- var w
- 
list of weights of score components The default weights are 0.25 for simplicity, 0.2 for coverage, 0.5 for density, and 0.05 for legibility. 
- var font_metrics
- 
default font metrics Font metrics are used to calculate the width and height of tick labels. They are specified as a dict, where each character that can occur in a tick label ('-','0'–'9','.') is a key associated with the character's width; it also contains an'offset'to be added to the total width, and a'height'. All numbers are in units of the font size.The default values are for La/TeX's standard math font (Computer Modern Roman). 
 
- class TicksGenerator (font_sizes, density, font_metrics=None, only_loose=True)
- 
generator of tick values and labels This class stores parameters of tick generation that are likely to stay the same across several axes: - font_sizes
- admissible font sizes, in TeX pt (2.54 cm / 72.27)
- density
- target density of ticks, in cm–1
- font_metrics
- used to calculate the width and height of tick labels,
see cfg.font_metrics(default)
- only_loose
- whether the range of tick values is forced to encompass the range of data values
 Ticks for a specific axis are generated by calling the ticks()method on an instance.Expand source codeclass TicksGenerator: """ generator of tick values and labels This class stores parameters of tick generation that are likely to stay the same across several axes: `font_sizes` : admissible font sizes, in TeX pt (2.54 cm / 72.27) `density` : target density of ticks, in cm<sup>–1</sup> `font_metrics` : used to calculate the width and height of tick labels, see `cfg.font_metrics` (default) `only_loose` : whether the range of tick values is forced to encompass the range of data values Ticks for a specific axis are generated by calling the [`ticks()`](#tikz.extended_wilkinson.TicksGenerator.ticks) method on an instance. """ def __init__(self, font_sizes, density, font_metrics=None, only_loose=True): if font_metrics is None: font_metrics = cfg.font_metrics self.font_sizes = sorted(font_sizes) self.rt = density self.font_metrics = font_metrics self.only_loose = only_loose # scoring functions, including the approximations for limiting the search def _simplicity(self, i, start, j, k): # v: is zero included in the ticks? # modifications # - (lmin % lstep < eps or lstep - (lmin % lstep) < eps), # means lmin / lstep = start / j is an integer # - lmin <= 0 means start <=0 # - lmax >= 0 means start + j * (k - 1) >= 0 v = (start % j == 0 and start <= 0 and start + j * (k - 1) >= 0) * 1 return 1 - (i - 1) / (len(cfg.Q) - 1) - j + v def _simplicity_max(self, i, j): # upper bound on _simplicity w.r.t. k, z, start # = w.r.t. v return 1 - (i - 1) / (len(cfg.Q) - 1) - j + 1 def _coverage(self, dmin, dmax, lmin, lmax): return (1 - 0.5 * ((dmax - lmax)**2 + (dmin - lmin)**2) / (0.1 * (dmax - dmin))**2) def _coverage_max(self, dmin, dmax, span): # upper bound on _coverage w.r.t. start range = dmax - dmin # The original code has a branching which I don't think is necessary. # if span > range: # half = (span - range) / 2 # return 1 - 0.5 * (2 * half ** 2) / (0.1 * range)**2 # else: # return 1 half = (span - range) / 2 return 1 - 0.5 * (2 * half ** 2) / (0.1 * range)**2 def _density(self, k, m, dmin, dmax, lmin, lmax): r = (k - 1) / (lmax - lmin) rt = (m - 1) / (max(lmax, dmax) - min(dmin, lmin)) return 2 - max((r / rt, rt / r)) def _density_max(self, k, m): # From original code, which I don't understand. if k >= m: return 2 - (k - 1) / (m - 1) else: # Probably just the trivial upper bound. return 1 def _score(self, s, c, d, l): # combined score return cfg.w[0] * s + cfg.w[1] * c + cfg.w[2] * d + cfg.w[3] * l # optimization algorithm def ticks(self, dmin, dmax, axis_length, axis_horizontal): """ generate tick values and labels for a specific axis `dmin` : data values lower bound `dmax` : data values upper bound `axis_length` : physical length of the axis, in cm `axis_horizontal` : whether the axis is oriented horizontally (rather than vertically) Returns a `Ticks` object. """ # The implementation here is based on the R code, which is defined # in terms of `m`, the target number of ticks. It optimizes w.r.t. # the ratio between the two quantities # r = (k - 1) / (lmax - lmin) # rt = (m - 1) / (max(lmax, dmax) - min(dmin, lmin)) # We want to instead specify the physical density (e.g. in 1/cm), # stored as a class attribute `self.rt`, and the parameter `length` # (e.g. in cm). Assuming that the axis spans `min(dmin, lmin)` to # `max(lmax, dmax)`, while the ticks span lmin to lmax, the # optimization should use the ratio of # r = (k - 1) / (length * (lmax - lmin)) # * (max(lmax, dmax) - min(dmin, lmin)) # to `self.rt`. # It turns out that the two ratios are equivalent if one sets m = self.rt * axis_length + 1 if dmin > dmax: dmin, dmax = dmax, dmin # threshold for optimization ticks = None best_score = -2 # We combine the j and q loops into one to enable breaking out of both # simultaneously, by iterating over a generator; and we create an # index i corresponding to q at the same time. i is `match(q, Q)[1]` # and replaces `q, Q` in function calls. JIQ = ((j, i, q) for j in count(start=1) for i, q in enumerate(cfg.Q, start=1)) for j, i, q in JIQ: sm = self._simplicity_max(i, j) if self._score(sm, 1, 1, 1) < best_score: break for k in count(start=2): # loop over tick counts dm = self._density_max(k, m) if self._score(sm, 1, dm, 1) < best_score: break delta = (dmax - dmin) / (k + 1) / (j * float(q)) for z in count(start=ceil(log10(delta))): step = float(q) * j * 10**z cm = self._coverage_max(dmin, dmax, step * (k - 1)) if self._score(sm, cm, dm, 1) < best_score: break min_start = floor(dmax / step) * j - (k - 1) * j max_start = ceil(dmin / step) * j if min_start > max_start: continue for start in range(min_start, max_start + 1): lmin = start * step / j lmax = lmin + step * (k - 1) # lstep = step if self.only_loose: if lmin > dmin or lmax < dmax: continue s = self._simplicity(i, start, j, k) c = self._coverage(dmin, dmax, lmin, lmax) d = self._density(k, m, dmin, dmax, lmin, lmax) score = self._score(s, c, d, 1) if score < best_score: continue # Exact tick values in terms of loop variables: # lmin = q * start * 10**z # lmax = q * (start + j * (k - 1)) * 10 ** z # lstep = float(q) * j * 10**z decimal_values = [q * (start + j * ind) * D('1E1') ** z for ind in range(k)] # Create `Ticks` object ticks = Ticks( amin=min(lmin, dmin), amax=max(lmax, dmax), decimal_values=decimal_values) # and initiate internal optimization for label # legibility. ticks._optimize( self.font_sizes, self.font_metrics, axis_length, axis_horizontal) l = ticks.opt_legibility # noqa E741 score = self._score(s, c, d, l) if score > best_score: best_score = score if ticks is None: # no solution found: no ticks print('Warning: Could not determine ticks.') ticks = Ticks( amin=dmin, amax=dmax, decimal_values=[], labels=[]) return ticksMethods- def ticks(self, dmin, dmax, axis_length, axis_horizontal)
- 
generate tick values and labels for a specific axis - dmin
- data values lower bound
- dmax
- data values upper bound
- axis_length
- physical length of the axis, in cm
- axis_horizontal
- whether the axis is oriented horizontally (rather than vertically)
 Returns a Ticksobject.Expand source codedef ticks(self, dmin, dmax, axis_length, axis_horizontal): """ generate tick values and labels for a specific axis `dmin` : data values lower bound `dmax` : data values upper bound `axis_length` : physical length of the axis, in cm `axis_horizontal` : whether the axis is oriented horizontally (rather than vertically) Returns a `Ticks` object. """ # The implementation here is based on the R code, which is defined # in terms of `m`, the target number of ticks. It optimizes w.r.t. # the ratio between the two quantities # r = (k - 1) / (lmax - lmin) # rt = (m - 1) / (max(lmax, dmax) - min(dmin, lmin)) # We want to instead specify the physical density (e.g. in 1/cm), # stored as a class attribute `self.rt`, and the parameter `length` # (e.g. in cm). Assuming that the axis spans `min(dmin, lmin)` to # `max(lmax, dmax)`, while the ticks span lmin to lmax, the # optimization should use the ratio of # r = (k - 1) / (length * (lmax - lmin)) # * (max(lmax, dmax) - min(dmin, lmin)) # to `self.rt`. # It turns out that the two ratios are equivalent if one sets m = self.rt * axis_length + 1 if dmin > dmax: dmin, dmax = dmax, dmin # threshold for optimization ticks = None best_score = -2 # We combine the j and q loops into one to enable breaking out of both # simultaneously, by iterating over a generator; and we create an # index i corresponding to q at the same time. i is `match(q, Q)[1]` # and replaces `q, Q` in function calls. JIQ = ((j, i, q) for j in count(start=1) for i, q in enumerate(cfg.Q, start=1)) for j, i, q in JIQ: sm = self._simplicity_max(i, j) if self._score(sm, 1, 1, 1) < best_score: break for k in count(start=2): # loop over tick counts dm = self._density_max(k, m) if self._score(sm, 1, dm, 1) < best_score: break delta = (dmax - dmin) / (k + 1) / (j * float(q)) for z in count(start=ceil(log10(delta))): step = float(q) * j * 10**z cm = self._coverage_max(dmin, dmax, step * (k - 1)) if self._score(sm, cm, dm, 1) < best_score: break min_start = floor(dmax / step) * j - (k - 1) * j max_start = ceil(dmin / step) * j if min_start > max_start: continue for start in range(min_start, max_start + 1): lmin = start * step / j lmax = lmin + step * (k - 1) # lstep = step if self.only_loose: if lmin > dmin or lmax < dmax: continue s = self._simplicity(i, start, j, k) c = self._coverage(dmin, dmax, lmin, lmax) d = self._density(k, m, dmin, dmax, lmin, lmax) score = self._score(s, c, d, 1) if score < best_score: continue # Exact tick values in terms of loop variables: # lmin = q * start * 10**z # lmax = q * (start + j * (k - 1)) * 10 ** z # lstep = float(q) * j * 10**z decimal_values = [q * (start + j * ind) * D('1E1') ** z for ind in range(k)] # Create `Ticks` object ticks = Ticks( amin=min(lmin, dmin), amax=max(lmax, dmax), decimal_values=decimal_values) # and initiate internal optimization for label # legibility. ticks._optimize( self.font_sizes, self.font_metrics, axis_length, axis_horizontal) l = ticks.opt_legibility # noqa E741 score = self._score(s, c, d, l) if score > best_score: best_score = score if ticks is None: # no solution found: no ticks print('Warning: Could not determine ticks.') ticks = Ticks( amin=dmin, amax=dmax, decimal_values=[], labels=[]) return ticks
 
- class Ticks (amin, amax, decimal_values, labels=None, plabel=None, font_size=None, horizontal=None)
- 
represents tick values and labels This class is not intended to be instantiated by the user, but Ticksobjects are obtained viaTicksGenerator.ticks().Expand source codeclass Ticks: """ represents tick values and labels This class is not intended to be instantiated by the user, but `Ticks` objects are obtained via `TicksGenerator.ticks`. """ def __init__(self, amin, amax, decimal_values, labels=None, plabel=None, font_size=None, horizontal=None): self.amin = amin "axis lower bound" self.amax = amax "axis upper bound" self.values = [float(dv) for dv in decimal_values] "list of tick values" self.decimal_values = decimal_values "list of exact tick values, as `decimal.Decimal`s" self.labels = labels "list of tick labels strings" self.plabel = plabel """ power label string If `plabel` is not `None`, it represents a decadic power factored from the tick values. It is intended to be displayed in the form 10<sup>`plabel`</sup> either at the side of the axis or as/with a unit in the axis label. """ self.font_size = font_size "tick label font size, in TeX pt (2.54 cm / 72.27)" self.horizontal = horizontal """ whether the tick label is to be displayed in horizontal orientation (rather than vertical) """ def _optimize(self, font_sizes, font_metrics, axis_length, axis_horizontal): """ optimize label legibility in terms of format, font size, and orientation """ # tick values values = self.values # minimum font size fs_min = min(font_sizes) # target font size fs_t = max(font_sizes) # optimization self.opt_legibility = float('-inf') # format for f in range(2): # legibility score for format if f == 0: # format 'Decimal' vls = [(1e-4 < abs(v) < 1e6) * 1 for v in values] leg_f = sum(vls) / len(vls) else: # format 'Factored scientific' leg_f = 0.3 # tick labels if f == 0: # format 'Decimal' labels = self._labels_Decimal() plabel = None else: # format 'Factored scientific' labels, plabel = self._labels_Scientific() # widths and heights of tick labels, in units of font size widths = [self._label_width(l, font_metrics) for l in labels] heights = [self._label_height(l, font_metrics) for l in labels] # font size for fs in font_sizes: # legibility score for font size if fs == fs_t: leg_fs = 1 else: leg_fs = 0.2 * (fs - fs_min + 1) / (fs_t - fs_min) # distance between ticks, in units of font size step = ( (values[1] - values[0]) # numerical / (self.amax - self.amin) # relative to axis * axis_length # physical, in cm / (fs / 72.27 * 2.54) # font size, in cm ) # orientation for o in range(2): # legibility score for orientation if o == 0: # horizontal orientation leg_or = 1 else: # vertical orientation leg_or = -0.5 # legibility score for overlap # extents of labels along the axis, in units of font size if (o == 0) == axis_horizontal: # label and axis have the same orientation extents = widths else: # label and axis have different orientations extents = heights # minimum distance between neighboring labels # We can apply the minimum here, since overlap legibility # is an increasing function of distance. dist = min(step - (extents[i] + extents[i + 1]) / 2 for i in range(len(extents) - 1)) # score; we interpret em as font size if dist >= 1.5: leg_ov = 1 elif dist > 0: leg_ov = 2 - 1.5 / dist else: leg_ov = float('-inf') # total legibility score leg = (leg_f + leg_fs + leg_or + leg_ov) / 4 # aggregate if leg > self.opt_legibility: self.opt_legibility = leg self.labels = labels self.plabel = plabel self.font_size = fs self.horizontal = (o == 0) def _label_width(self, label, font_metrics): "get width of tick label" w = sum(map(font_metrics.get, label)) + font_metrics['offset'] return w def _label_height(self, label, font_metrics): "get height of tick label" h = font_metrics['height'] return h def _labels_Decimal(self): "get tick labels in 'Decimal' format" # get values dvs = self.decimal_values # create labels labels = ['{:f}'.format(dv) for dv in dvs] return labels def _labels_Scientific(self): "get tick labels in 'Scientific format'" # get values dvs = self.decimal_values # get largest power of 10 than can be factored out z0 = min([floor(log10(abs(dv))) for dv in dvs if dv != 0]) # get values adjusted to that power dvs = [dv * D('1E1') ** (-z0) for dv in dvs] # create labels labels = ['{:f}'.format(dv) for dv in dvs] plabel = '{:d}'.format(z0) return labels, plabelInstance variables- var amin
- 
axis lower bound 
- var amax
- 
axis upper bound 
- var values
- 
list of tick values 
- var decimal_values
- 
list of exact tick values, as decimal.Decimals
- var labels
- 
list of tick labels strings 
- var plabel
- 
power label string If plabelis notNone, it represents a decadic power factored from the tick values. It is intended to be displayed in the form 10plabeleither at the side of the axis or as/with a unit in the axis label.
- var font_size
- 
tick label font size, in TeX pt (2.54 cm / 72.27) 
- var horizontal
- 
whether the tick label is to be displayed in horizontal orientation (rather than vertical)