I've been to trying add more flexible control over the axis lines,
ticks, tick labels, etc., but I think I'm in over my head on this
project. Again and again, I've settled on one approach only to
completely rewrite it the next time I look at the code. I'm looking
for some major design advice/ideas that will make the code simpler
(AND maintain compatibility with the current implementation of Axis
and Tick objects).
For simplicity, I've only made changes to the x-axis. Since this
involves some major changes, I wrote this as a module that subclasses
XTicks and XAxis as opposed to a patch on axis.py (which would be the
ultimate, but at this point unlikely, goal). The attached module is
quite long at the moment, but most of it is comments and methods
copied (but slightly modified) from `axis.py`. I tried to document how/
why the method has changed if I copied the method.
Sorry for the code dump.
-Tony
#!/usr/bin/env python
"""
Add more flexibility in positioning of the x and y axes (only xaxis is implemented, currently).
In this module, I use the word 'frame' to denote the collection of axis elements, i.e. the axis label, ticks, tick-labels, and the axis-line. Is there a better name for this container???
To allow an arbitrary number of frames to be placed on the axes, XAxis stores a list of frames in `XAxis.frames`. Each frame contains parameters that control the look of the axis label, ticks, tick-labels, and axis-line. An x-frame can be placed at any height, `y` (in axes or data coordinates), and has a range `xmin` to `xmax` (in axes or data coordinates).
Unfortunately, XTicks have lists `ticklines`, `labels`, `tick_on`, and `label_on` whose lengths must match the length of `XAxis.frames`. This design seems rather volatile. If each tick were a child of a single XFrame, this complication could be avoided, but this is too much change for now. Is there another way?
To maintain compatibility with the current interface, XAxis.frames[0] and XAxis.frames[1] are reserved for the bottom and top frames, respectively.
I think there maybe some performance issues with this implementation, but I'm not sure where it's coming from.
"""
import numpy as np
from numpy.random import randn
from matplotlib import rcParams
import matplotlib.axis as maxis
import matplotlib.axes as maxes
import matplotlib.text as mtext
import matplotlib.pyplot as plt
import matplotlib.lines as mlines
import matplotlib.artist as martist
import matplotlib.patches as mpatches
import matplotlib.collections as mcoll
import matplotlib.transforms as mtrans
import matplotlib.projections as projections
import matplotlib.font_manager as font_manager
class XTick(maxis.XTick):
"""Customize XTick to place ticks based on XFrame properties.
Add `labels` list to replace `label#`s, `ticklines` list to replace
`tick#line`s, `tick_on` list to replace `label#On`s, and `label_on` to
replace `label#On`. Also, add methods to these lists.
"""
_type_map = {True: 'major', False: 'minor'}
def __init__(self, axes, loc, label, major=True, **kwargs):
"""Override to add `ticklines`, `labels`, `tick_on`, and `label_on`"""
super(XTick, self).__init__(axes, loc, label, major=major, **kwargs)
self._type = self._type_map[major]
# An XTick is at a specific x-position, but draws the tick, tick
# label, etc for every frame at that x-position (just like the
# current implentation has top and bottom ticks drawn by a single
# XTick instance)
# `ticklines` replaces `tick1line` and `tick2line`
self.ticklines = []
# `labels` replaces `label1` and `label2`
self.labels = []
# `tick_on` replaces `tick1On` and `tick2On`
self.tick_on = []
# 'label_on' replaces `label1On` and `label2On`
self.label_on = []
self.num_frames = 0
def _init_frames(self, frames):
"""Initialize frames for XTick."""
# This method is separated from __init__ so the call signature of
# XTick doesn't need to be changed.
self._update_frames(frames)
return self
def _update_frames(self, frames):
"""Add tickline and label for each frame in `frames` list
This method calls `_get_tickline` and `_get_text` which replicate
the _get_tick#line, _get_text# (# = 1 or 2) methods. Note that
_get_tick#line, _get_text# are called only on XTick init in `axis.py`.
In contrast, `_update_frames` should be called on both XTick init and
XAxis.add_frames so that frames that are added after init are
recognized.
"""
preset = len(self.ticklines)
# redefine `ticklines` and `labels` for preset frames
for n, frame in enumerate(frames[:preset]):
self.ticklines[n] = self._get_tickline(frame)
self.labels[n] = self._get_text(frame)
self.tick_on[n] = frame._tick_on
self.label_on[n] = frame._ticklabel_on
# add `ticklines` and `labels` for new frames
# maybe put this loop in separate method _add_frames
for frame in frames[preset:]:
self.ticklines.append(self._get_tickline(frame))
self.labels.append(self._get_text(frame))
self.tick_on.append(frame._tick_on)
self.label_on.append(frame._ticklabel_on)
self.num_frames = len(frames)
def set_labels(self, s):
"""Set the text of ticklabel for each frame in `frames` list
Generalized from set_label1 and set_label2
"""
for n in range(self.num_frames):
self.labels[n].set_text(s)
def _get_tickline(self, frame):
"""Get the default line2D instance. `x` coordinate can be set later.
Generalized from XTick._get_tick1line and XTick._get_tick2line
"""
# x in data coords, y in axes coords
l = mlines.Line2D(xdata=(0,), ydata=(frame.y,),
color=frame.linecolor, # rcParams['xtick.color']?
linestyle='None',
marker=frame.tickmarker,
markersize=frame.ticksize[self._type])
l.set_transform(frame.tick_transform)
self._set_artist_props(l)
return l
def _get_text(self, frame):
"""Get the default Text instance
Generalized from XTick._get_text1 and XTick._get_text2
"""
# x in data coords, y in axes coords
trans, vert, horiz = frame.text_transform
size = rcParams['xtick.labelsize']
t = mtext.TextWithDash(
x=0, y=frame.y,
fontproperties=font_manager.FontProperties(size=size),
color=rcParams['xtick.color'],
verticalalignment=vert,
horizontalalignment=horiz,
dashdirection=0,
xaxis=True)
t.set_transform(trans)
self._set_artist_props(t)
return t
def is_nonlinear(self):
"""Test if xaxis or yaxis is nonlinear
Stolen from XTick.update_position.
"""
x_nonlinear = (hasattr(self.axes, 'xaxis') and
self.axes.xaxis.get_scale() != 'linear')
y_nonlinear = (hasattr(self.axes, 'yaxis') and
self.axes.yaxis.get_scale() != 'linear')
return x_nonlinear or y_nonlinear
def _update_position(self, loc):
"""Set the location of tick in data coords with scalar `loc`
Modified XTick.update_position to work with frames
"""
x = loc
nonlinear = self.is_nonlinear()
if self.gridOn:
self.gridline.set_xdata((x,))
if nonlinear:
self.gridline._invalid = True
for n, (tline, label) in enumerate(zip(self.ticklines, self.labels)):
if self.tick_on[n]:
tline.set_xdata((x,))
if self.label_on[n]:
label.set_x(x)
if nonlinear:
tline._invalid = True
self._loc = loc
def draw(self, renderer):
"""Draw ticks for all frames `self.axes.xaxis.frames` list
"""
if not self.get_visible(): return
renderer.open_group(self.__name__)
in_view = mtrans.interval_contains(self.get_view_interval(),
self.get_loc())
if self.gridOn:
self.gridline.draw(renderer)
# Calling `self.axes.xaxis.frames` is really ugly, but I want to be
# able to turn the frame on/off without setting each tick's tick_on
# and label_on for that frame.
for n, frame in enumerate(self.axes.xaxis.frames):
if not frame.frame_on: continue
if in_view and self.tick_on[n]:
self.ticklines[n].draw(renderer)
if self.label_on[n]:
self.labels[n].draw(renderer)
renderer.close_group(self.__name__)
class XFrame(martist.Artist):
"""XFrame is a container for XTick properties and draws the xaxis line.
This frame container replaces the bottom and top properties (e.g. tick1On,
tick2On) from the current Tick implementation. The top and bottom ticks
are now implemented as two separate XFrames.
Maybe XFrame should be a container for XTicks (not just its properties),
but this change is too drastic at the moment.
`y` is the height on which everything (axis-line, ticks, etc) is centered.
`yscale` controls whether `y` is in 'axes' or 'data' coords
`xmin`, `xmax` is the extent of the axis-line
`xscale` controls whether `xmin`/`xmax` is in 'axes' or 'data' coords
"""
# I'm not sure if it's actually desirable to customize the length of the
# axis line using xmin, xmax, and xscale.
# `xscale` and `yscale` are already used to set data scale, i.e. linear
# vs. log scale. Maybe use: `xbbox`, `xcoord`, `xtrans`, or `xref`?
tickdirection_map = dict(down=mlines.TICKDOWN, up=mlines.TICKUP)
# TODO: create tick line that is a combination of TICKUP and TICKDOWN
def __init__(self, axes, y=0, yscale='axes', xmin=0, xmax=1,
xscale='axes', tick_on=True, ticklabel_on=True, **kwargs):
# There are a lot of optional arguments. I'm not sure how best to
# handle them. Specify keywords in init, or store as kwargs and pop?
# Right now I'm doing both and it looks really bad.
martist.Artist.__init__(self)
self.axes = axes
self.frame_on = True
self.y = y
self.xmin = xmin
self.xmax = xmax
self.yscale = yscale
self.xscale = xscale
self.xlabel_on = kwargs.pop('label_on', True)
self.xlabel_pos = 'below'
self.linecolor = kwargs.pop('color', rcParams['axes.edgecolor'])
self.linewidth = kwargs.pop('linewidth', rcParams['axes.linewidth'])
self.linestyle = kwargs.pop('linestyle', 'solid')
# _tick_on and _ticklabel_on control the *default* tick_on and
# label_on for new XTicks, but XTick.tick_on and XTick.label_on
# can be changed from these defaults after initialization.
# I'd like to add setters for `_tick_on` and `_ticklabel_on` that
# iterate through the ticks for this frame and resets their `tick_on`
# and `label_on` properties.
self._tick_on = tick_on
self._ticklabel_on = ticklabel_on
# `ticksize`, `tickpad`, `tickdirection`, and `tickmarker` are public
# because they are currently the *only* way to set these values, i.e.
# all ticks in this frame have the same values for these parameters.
# Is it desirable to have finer control over these values?
self.ticksize = {'major': rcParams['xtick.major.size'],
'minor': rcParams['xtick.minor.size']}
self.tickpad = {'major': rcParams['xtick.major.pad'],
'minor': rcParams['xtick.minor.pad']}
self.tickdirection = kwargs.pop('tickdirection', 'up')
self.tickmarker = self.tickdirection_map[self.tickdirection]
if self.tickdirection == 'down' and self.xlabel_pos == 'below':
self.tickpad['major'] += self.ticksize['major']
self.tickpad['minor'] += self.ticksize['minor']
@property
def transform(self):
"""Generalization of Axis._xaxis_transform
Used for placing frame's axis-line.
"""
return self.axes.trans_map[(self.xscale, self.yscale)]
@property
def tick_transform(self):
"""Generalization of Axis._xaxis_transform when used for ticks.
Using yscale allows ticks to be placed based on 'axes' or 'data'
coordinates.
"""
# XTicks should be placed based on data coordinates in x-direction
return self.axes.trans_map[('data', self.yscale)]
_scale_pad = dict(below=-1/72.0, above=1/72.0)
_valign = dict(below='top', above='bottom')
@property
def text_transform(self):
"""Generalization of Axes.get_xaxis_text1_transform and
Axes.get_xaxis_text1_transform.
Text is placed above or below the axis when `position` is set to
'above' and 'below', respectively.
"""
yt = self._scale_pad[self.xlabel_pos] * self.tickpad['major']
scale_trans = self.axes.figure.dpi_scale_trans
t = self.tick_transform + mtrans.ScaledTranslation(0, yt, scale_trans)
return (t, self._valign[self.xlabel_pos], "center")
def draw(self, renderer):
"""Draw an axis-line at self.y"""
if not self.get_visible() or not self.frame_on: return
renderer.open_group(__name__)
verts = [(self.xmin, self.y), (self.xmax, self.y)]
frame_lines = mcoll.LineCollection(segments=[verts],
linewidths=[self.linewidth],
colors=[self.linecolor])
frame_lines.set_transform(self.transform)
frame_lines.draw(renderer)
renderer.close_group(__name__)
class XAxis(maxis.XAxis):
__name__ = 'xaxis'
axis_name = 'x'
def __init__(self, *args, **kwargs):
"""Override to add `_frames` list and call `_add_default_frames`"""
self._frames = []
super(XAxis, self).__init__(*args, **kwargs)
self._add_default_frames()
# only specify getter for _frames. add_frame should control adding.
# TODO: add a remove_frame method.
frames = property(lambda self: self._frames)
_defaultdirection_map = {'out': ('down', 'up'), 'in': ('up', 'down')}
def _add_default_frames(self):
"""Add default (bottom and top) frames."""
td = self._defaultdirection_map[plt.rcParams['xtick.direction']]
self.add_frame(0, yscale='axes', tickdirection=td[0])
self.add_frame(1, yscale='axes', tickdirection=td[1],
ticklabel_on=False, label_on=False)
def add_frame(self, y, xmin=0, xmax=1, yscale='data', xscale='axes',
tick_on=True, ticklabel_on=True, **kwargs):
"""Add x-axis frame at position `y` extending from `xmin` to `xmax`.
By default, `y` is in data coordinates, while `x` is in axes
coordinates (where `x` goes from 0 to 1 in axes coordinates). You can
change these scales by setting the `yscale` and `xscale` keyword args
to 'data' (for data coordinates) or 'axes' (for unit bounding box
coordinates).
"""
# I could just use **kwargs as the function input and pass the
# resulting dictionary to `_frames` [that's less clear, maybe?]
frame = XFrame(self.axes,
y=y, xmin=xmin, xmax=xmax,
yscale=yscale, xscale=xscale,
tick_on=tick_on, ticklabel_on=ticklabel_on,
**kwargs)
self._frames.append(frame)
# XTick needs to 'know' how many frames there are.
for tick, loc, label in self.iter_ticks():
tick._update_frames(self.frames)
def _get_tick(self, major):
"""Override _get_tick so that custom XTick is called and initialize
initialize frames.
"""
tick = XTick(self.axes, 0, '', major=major)
return tick._init_frames(self.frames)
def _update_labels(self, renderer, frame, n):
"""Calculate tick label boxes and update label and offset text.
`n` is the frame identifier
Modified from XAxis.draw to iterate through tick.labels.
"""
tick_label_boxes = []
for tick, loc, label in self.iter_ticks():
if tick.label_on[n] and tick.labels[n].get_visible():
# FIXME: get_window_extent raises a numpy warning for loglog
# call. Ticks that aren't in view interval don't get updated
# positions and thus, have masked values. When do these ticks
# get removed in `axis.py`?
extent = tick.labels[n].get_window_extent(renderer)
tick_label_boxes.append(extent)
if frame.frame_on and frame.xlabel_on:
# Need to override _update_label_position so that label can be
# placed above or below (and maybe left-of/right-of) axis line.
# Second argument doesn't apply since we override tick.label2.
self._update_label_position(tick_label_boxes, tick_label_boxes)
self.label.draw(renderer)
# _update_offset_text_position doesn't even use second argument. Bug?
self._update_offset_text_position(tick_label_boxes, None)
self.offsetText.set_text(self.major.formatter.get_offset())
self.offsetText.draw(renderer)
if False: # draw the bounding boxes around the text for debug
for tick in self.get_major_ticks():
label = tick.labels[n]
mpatches.bbox_artist(label, renderer)
mpatches.bbox_artist(self.label, renderer)
def draw(self, renderer, *args, **kwargs):
"""Override to call XTicks new `set_labels` method and draw frames.
Also, moved tickLabelBoxes calculation to `_update_labels`.
"""
if not self.get_visible(): return
renderer.open_group(__name__)
interval = self.get_view_interval()
for tick, loc, label in self.iter_ticks():
if tick is None: continue
if not mtrans.interval_contains(interval, loc): continue
tick._update_position(loc)
tick.set_labels(label)
tick.draw(renderer)
for n, frame in enumerate(self.frames):
if frame.frame_on:
frame.draw(renderer)
self._update_labels(renderer, frame, n)
renderer.close_group(__name__)
class FrameAxes(maxes.Axes):
"""Override Axes to add custom XAxis and clear default frame lines
"""
name = 'frameaxes'
def cla(self):
"""Override default Axes.frame and clear frame/patch edgecolor"""
super(FrameAxes, self).cla()
self.patch.set_edgecolor('none')
self.frame.set_edgecolor('none')
def _init_axis(self):
"""Override to use custom XAxis class and add trans_map"""
# maybe `trans_map` should go in `__init__`
f = mtrans.blended_transform_factory
self.trans_map = {('axes', 'data'): f(self.transAxes, self.transData),
('data', 'axes'): f(self.transData, self.transAxes),
('axes', 'axes'): self.transAxes,
('data', 'data'): self.transData}
self.xaxis = XAxis(self)
self.yaxis = maxis.YAxis(self)
self._update_transScale()
projections.register_projection(FrameAxes)
def plot_test():
"""Plot comparison of some different frames."""
# Only the last plot actually uses the features added in this module, but
# I want to make sure the normal plots worked (loglog doesn't quite work)
x = randn(10)
y = randn(10)
# I'd tried using argument axes_class='frameaxes', but got an error.
ax = plt.subplot(221, projection='frameaxes')
ax.plot(x, y, 'o')
ax.set_xlabel('x label')
ax.set_title('test normal')
ax = plt.subplot(222, projection='frameaxes')
ax.plot(x, y, 'o')
ax.set_title('test grid')
ax.set_xlabel('x label')
ax.grid()
# loglog raises a warning that doesn't occur if 'frameaxes' is not used
ax = plt.subplot(223, projection='frameaxes')
ax.loglog(x, y, 'o')
ax.set_title('test loglog')
ax.set_xlabel('x label')
ax = plt.subplot(224, projection='frameaxes')
ax.plot(x, y, 'o')
ax.xaxis.frames[0].frame_on = False
ax.xaxis.frames[1].frame_on = False
ax.xaxis.add_frame(0, yscale='data', tickdirection='down')
ax.set_xlabel('x label')
ax.set_title('frame at y=0 in data coords')
fig = plt.gcf()
fig.subplots_adjust(hspace=0.5, wspace=0.5)
plt.show()
if __name__ == '__main__':
plot_test()
-------------------------------------------------------------------------
This SF.Net email is sponsored by the Moblin Your Move Developer's challenge
Build the coolest Linux based applications with Moblin SDK & win great prizes
Grand prize is a trip for two to an Open Source event anywhere in the world
http://moblin-contest.org/redirect.php?banner_id=100&url=/
_______________________________________________
Matplotlib-devel mailing list
Matplotlib-devel@lists.sourceforge.net
https://lists.sourceforge.net/lists/listinfo/matplotlib-devel