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, plabel
Classes
class cfg (*args, **kwargs)
-
tikz.extended_wilkinson
configuration variablesExpand source code
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 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 code
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
Methods
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.Expand source code
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 (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
Ticks
objects are obtained viaTicksGenerator.ticks()
.Expand source code
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, plabel
Instance 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.Decimal
s var labels
-
list of tick labels strings
var plabel
-
power label string
If
plabel
is notNone
, it represents a decadic power factored from the tick values. It is intended to be displayed in the form 10plabel
either 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)