Source code for storylines.plot

# Copyright (C) 2016-2024 Jan Berges
# This program is free software under the terms of the BSD Zero Clause License.

"""Figure object."""

from __future__ import division

import math
import re

from .calc import power_of_ten, xround_mantissa, multiples
from .color import Color, colormap, colorize
from .convert import inch, pt, csv
from .cut import relevant, shortcut, cut2d, jump
from .fatband import fatband, miter_butt
from .files import goto, typeset, rasterize
from .group import islands, groups
from .png import save

[docs] class Plot(): """Plot object. Parameters ---------- width : float, default None Figure width in cm. A negative value is interpreted as the inner width (without left and right margins). If zero, the x-axis scale is set equal to the y-axis scale and the width is inferred from the height. By default, the single-column width for the chosen `style` is used. height : float, default None Figure height in cm. A negative value is interpreted as the inner height (without bottom and top margins). If zero, the y-axis scale is set equal to the x-axis scale and the height is inferred from the width. By default, it is inferred from `width` for a 4:3 aspect ratio. margin : float, default None Default margin in cm. If ``None``, margins are set automatically. This is not always the best option. xyaxes : bool, default True Draw x and y axes? style : str, default None Predefined style. Possible values are ``'APS'``, ``'NanoLett'``, ``'NatCommun'``, and ``'Nature'``. This changes some of the below default values. rounded : bool, default True Use ``round`` as default value for ``line cap`` and ``line join``? Otherwise the TikZ initial values ``miter`` and ``butt`` are used. **more Initial values of attributes (see below) or global TikZ options. Attributes ---------- left : float, default `margin` Left margin in cm. right : float, default `margin` Right margin in cm. bottom : float, default `margin` Bottom margin in cm. top : float, default `margin` Top margin in cm. margmin : float, default 0.15 Minimum automatic margin in cm. ratio : float, default None Figure width divided by figure height. The desired aspect ratio is obtained by adding extra margins as needed. align : float, default 0.5 If `ratio` is used, this value aligns the original plot relative to the new viewport. A value of ``0.0``, ``0.5``, and ``1.0`` moves it to the lower side, to the center, and to the upper side, respectively. xlabel, ylabel, zlabel : str, default None Axis labels. xticks, yticks, zticks : list, default None List of ticks, e.g., ``[0, (0.5, '$\\\\frac12$'), 1]``. If the label is ``None``, the tick mark is not drawn (but possibe grid lines are). If it otherwise evaluates to ``False``, the tick mark but no label is drawn. xmarks, ymarks, zmarks : bool, default True Show tick marks and labels? xlabels, ylabels, zlabels : bool, default True Show tick labels? xspacing, yspacing, zspacing : float, default 1.0 Approximate tick spacing in cm. xstep, ystep, zstep : float, default None Exact tick increment. xminorticks, yminorticks : list, default None List of minor-tick positions. There is currently no intelligent way to set the minor ticks depending on the major ticks. However, minor ticks that fall on major ticks are omitted. xminormarks, yminormarks : bool, default False Show minor tick marks? xminorspacing, yminorspacing : float, default None Approximate minor-tick spacing in cm. xminorstep, yminorstep : float, default None Exact minor-tick increment. xmin, ymin, zmin : float, default None Lower axis limit. xmax, ymax, zmax : float, default None Upper axis limit. dleft, dright, dbottom, dtop : float, default 0.0 Protrusion of x- and y-axis limits into margins in cm. xpadding, ypadding, zpadding : float, default 0.0 Padding between data and axes in data units. xclose, yclose, zclose : bool, default False Place axis labels in space reserved for tick labels. xformat, yformat, zformat : function Tick formatter. Takes tick position as argument. lower : str, default 'blue' Lower color of colorbar. Can also be of type ``Color`` as long as `upper` has the same type. upper : str, default 'red' Upper color of colorbar. Can also be of type ``Color`` as long as `lower` has the same type. cmap : function, default None Colormap for colorbar used instead of `lower` and `upper`. title : str, default None Plot title. label : str, default None Subfigure label, e.g., ``'a'``. labelsize : int, default None Different font size for subfigure label in pt. labelformat : function, default None Formatter for subfigure label. Takes `label` as argument. labelopt : str, default 'inner sep=0pt, below right' Label options, e.g., for orientation. labelpos : str, default 'LT' Label position, a combination of ``lcrbmtLCRBMT`` or a tuple of data coordinates. lali : str, default 'center' Alignment of legend entries. lbls : str, default '\\\\\\\\baselineskip' Line height of legend entries. lbox : bool, default False Draw box around legend? lcol : int, default 1 Number of columns in legend. llen : str, default '4mm' Length of example lines next to labels. lopt : str, default None Legend options, e.g., for orientation. lpos : str, default 'cm' Legend position, a combination of ``lcrbmtLCRBMT`` or a tuple of data coordinates. lput : bool, default True Draw legend? lrow : int, default 0 Number of rows in legend. lsep : str, default None Space between legend title and entries, e.g., ``'6pt'``. ltop : str, default None Legend title. If `lbox` is used, you might want to prevent that TikZ pictures in the legend inherit the ``rounded corners`` of the box by prepending ``\\tikzset{sharp corners}`` to this value. lwid : float, default 4.0 Width of legend columns in units of `llen`. tick : float, default 0.07 Length of tick marks in cm. minortick : float, default 0.04 Length of minor tick marks in cm. gap : float, default 0.15 Gap between plot area and colorbar in cm. bar : float, default 0.15 Width of color bar in cm. tip : float, default 0.1 Overlap of axis tips in cm. xaxis : bool, default `xyaxes` Draw x axis? yaxis : bool, default `xyaxes` Draw y axis? xorigin : float, default None Horizontal position of y axis in data coordinates. A reasonable value could be zero. By default, the y axis is shown on the left. yorigin : float, default None Vertical position of x axis in data coordinates. A reasonable value could be zero. By default, the x axis is shown on the bottom. frame : bool, default `xyaxes` Draw frame around plot area? grid : bool, default False Add grid lines at tick positions? minorgrid : bool, default False Add grid lines at minor-tick positions? colorbar : bool or str, default None Draw colorbar? If ``None``, the colorbar is drawn if any line is given a z value or if both `zmin` and `zmax` are given. Alternatively, the path to an image with a color gradient can be specified. Here, an image width of one pixel is sufficient. outline : bool, default False Draw dashed figure outline? canvas : str, default None Background color of whole document. background : str, default None Path to background image. preamble : str, default '' Definitions for standalone figures. inputenc : str, default None Text encoding, e.g., ``'utf8'``. fontenc : str, default None Font encoding. The default is ``'T1'`` if `font` is specified, none otherwise. font : str, default None Predefined font selection. Imitates well-known fonts. Possible values are ``'Gill Sans'``, ``'Helvetica'``, ``'Iwona'``, ``'Latin Modern'``, ``'Times'``, and ``'Utopia'``. fontsize : int, default 10 Font size for standalone figures in pt. single : float, default 8.0 Single-column width for the chosen `style`. double : float, default 17.0 Full textwidth for the chosen `style`. resolution : float, default 1e-3 Smallest distance in cm expected to be discernible when looking at the plot. The default is acceptable when the plot is viewed or printed in its original size. For zooming, smaller values may be necessary. This parameter determines the number of vertices used to render a line and thus affects the file size. eps : float, default 1e-4 Distance from plot boundary in cm beyond which a mark or grid line is considered to lie outside of the plot area (and is potentially cut off). This tolerance is meant to make up for the limited numerical precision. For example, using double preicision, ``3 * 0.1 > 0.3``. lines : list List of all line objects. options : dict Global TikZ options. Notes ----- In all textual attributes and parameters, numbers in angle brackets are interpreted as values in y data units, e.g., ``line_width='<0.1>'``. For the parameters `line_width` and `mark_size` this is also the case if an integer or float is passed instead of a string. """ def __init__(self, width=None, height=None, margin=None, xyaxes=True, style=None, rounded=True, **more): self.width = width self.height = height self.left = margin self.right = margin self.bottom = margin self.top = margin self.dleft = 0.0 self.dright = 0.0 self.dbottom = 0.0 self.dtop = 0.0 self.margmin = 0.15 self.ratio = None self.align = 0.5 for x in 'xyz': setattr(self, x + 'label', None) setattr(self, x + 'ticks', None) setattr(self, x + 'marks', True) setattr(self, x + 'labels', True) setattr(self, x + 'spacing', 1.0) setattr(self, x + 'step', None) setattr(self, x + 'min', None) setattr(self, x + 'max', None) setattr(self, x + 'padding', 0.0) setattr(self, x + 'close', False) setattr(self, x + 'format', lambda x: ('%g' % x).replace('-', '\\smash{\\llap\\textminus}')) for x in 'xy': setattr(self, x + 'minorticks', None) setattr(self, x + 'minormarks', False) setattr(self, x + 'minorspacing', None) setattr(self, x + 'minorstep', None) self.lower = 'blue' self.upper = 'red' self.cmap = None self.title = None self.label = None self.labelsize = None self.labelformat = None self.labelopt = 'inner sep=0pt, below right' self.labelpos = 'LT' self.lali = 'center' self.lbls = '\\baselineskip' self.lbox = False self.lcol = 1 self.llen = '4mm' self.lopt = None self.lpos = 'cm' self.lput = True self.lrow = 0 self.lsep = None self.ltop = None self.lwid = 4.0 self.tick = 0.07 self.minortick = 0.04 self.gap = 0.15 self.bar = 0.15 self.tip = 0.1 self.xaxis = xyaxes self.yaxis = xyaxes self.xorigin = None self.yorigin = None self.frame = xyaxes self.grid = False self.minorgrid = False self.colorbar = None self.outline = False self.canvas = None self.background = None self.preamble = '' self.inputenc = None self.fontenc = None self.font = None self.fontsize = 10 self.single = 8.0 self.double = 17.0 self.resolution = 1e-3 self.eps = 0.1 * self.resolution self.lines = [] self.options = dict(mark_size='0.05cm') if rounded: self.options.update(line_cap='round', line_join='round') if style is not None: if style == 'APS': self.font = 'Times' self.fontsize = 9 self.labelformat = lambda x: '(%s)' % x self.single = 8.6 self.double = 17.8 elif style == 'NanoLett': self.font = 'Helvetica' self.fontsize = 9 self.labelformat = lambda x: '(%s)' % x self.single = 3.33 * inch self.double = 7.0 * inch elif style == 'NatCommun': self.font = 'Helvetica' self.fontsize = 8 self.labelsize = 9 self.labelformat = lambda x: '\\textbf{%s}' % x self.single = 8.8 self.double = 18.0 elif style == 'Nature': self.font = 'Helvetica' self.fontsize = 7 self.labelsize = 8 self.labelformat = lambda x: '\\textbf{%s}' % x self.single = 8.9 self.double = 18.3 if self.width is None: self.width = self.single if self.height is None: self.height = 3 * self.width / 4 for name, value in more.items(): if hasattr(self, name): setattr(self, name, value) else: self.options[name] = value
[docs] def line(self, x=[], y=[], z=None, axes=False, code=None, cut=False, frame=False, grid=False, join=None, jump=0, label=None, miter=False, nib=None, omit=None, protrusion=0, sgn=+1, shifts=None, shortcut=0, shortcut_rel=0.5, thickness=0.05, weights=None, xref=None, yref=None, zindex=None, **options): """Add line/curve. Parameters ---------- x, y : list or float Coordinates of data points. z : float, default None z value for entire line, represented by color. axes : bool, default False Draw axes at current z index? By default, the axes are drawn first, i.e., below all data. code : str, default None Literal TikZ code to be inserted at current position. cut : bool or tuple, default False Cut off line segments beyond plotting range? It is also possible to pass the clipping window as a tuple ``(xmin, xmax, ymin, ymax)`` in data coordinates, where ``None`` is replaced by the plot bounds. frame : bool, default False Draw frame at current z index? By default, the frame is drawn just below the axes. grid : bool, default False Add grid lines (at positions of major and possibly minor ticks) at current z index? By default, the grid is drawn just below the frame. join : bool, default None Join cut-up line segments along edge of plotting range? By default, this is ``True`` if any ``fill`` is specified, ``False`` otherwise. jump : float, default 0 Shortest distance between consecutive data points that is considered as a discontinuity. label : str, default None Label for legend entry. The special label ``*next*`` adds a second example line with the current line style to the next legend entry. miter : bool, default False Draw fatbands using `miter_butt` function? If ``False``, the `fatband` function is used. nib : float, default None Angle of broad pen nib. If ``None``, the nib is held perpendicular to the direction of the current line segment. omit : bool, default None Remove irrelevant vertices of linear spline? The default is ``False`` if the TikZ option ``mark`` is set, ``True`` otherwise. protrusion : float, default 0 Extend curve linearly at both ends? This may improve the appearance of fatbands ending at the edge of the plotting range. sgn : integer, default +1 Direction of fatband outline. Coinciding outlines should be drawn in the same direction if `omit` is ``True``. shifts : list of float Displacements in weight direction of fatband. shortcut : float, default 0 Maximum length of loop to be cut off. shortcut_rel : float, default 0.5 Maximum length of loop to be cut off relative to the total length of the curve. This is only used if `shortcut` is nonzero. thickness : float, default 0.05 Fatband linewidth in cm. weights : list of float Fatband weights. xref, yref : float, default None Reference values for filled curves. This is useful to visualize integrands such as a density of states. zindex : int, default None Index of list of lines where new line is inserted. By default, the new line is appended to the list, i.e., has the highest `zindex`. **options Local TikZ options. """ if not hasattr(x, '__len__'): x = [x] if not hasattr(y, '__len__'): y = [y] new_line = dict( x=x, y=y, z=z, axes=axes, code=code, cut=cut, frame=frame, grid=grid, join=join, jump=jump, label=label, miter=miter, nib=nib, omit=omit, protrusion=protrusion, sgn=sgn, shifts=shifts, shortcut=shortcut, shortcut_rel=shortcut_rel, thickness=thickness, weights=weights, xref=xref, yref=yref, options=options, ) if zindex is None: self.lines.append(new_line) else: self.lines.insert(zindex, new_line)
[docs] def fatband(self, x, y, weights=1.0, shifts=0.0, fill=True, draw='none', **options): """Draw fatband. Parameters ---------- x, y : list Vertices of linear spline. weights : list of float or float, default 1.0 Weights of `x` and `y`. shifts : list of float or float, default 0.0 Displacements in weight direction. fill, draw : str or Color TikZ line options (filled without outline by default). **options Options passed to `line` function. """ try: iter(weights) except TypeError: weights = [weights] * len(x) try: iter(shifts) except TypeError: shifts = [shifts] * len(x) for island in islands(len(weights), lambda n: any(weights[max(n - 1, 0):n + 2])): if len(island) > 1: n = slice(island[0], island[-1] + 1) self.line(x[n], y[n], weights=weights[n], shifts=shifts[n], fill=fill, draw=draw, **options)
[docs] def compline(self, x, y, weights=1.0, colors=True, threshold=0.0, **options): """Represent points of multiple weights as composite fatband. Parameters ---------- x, y : list of float Coordinates of line vertices. weights : list of tuple of float, list of float, or float, default 1.0 Weights of vertices. The corresponding linewidth is always measured perpendicular to the direction of the line; This ensures that lines of the same weight have the same thickness regardless of direction. colors : list of str or str, default True Colors of different components. Any objects whose representations as a string are valid LaTeX colors can be used. If ``True``, the fill color is the same as the stroke color. threshold : float, default 0.0 Minimum displayed weight. **options Further line options. """ try: weights = [[0 if part < threshold else part for part in parts] for parts in weights] except TypeError: try: weights = [[0 if part < threshold else part] for part in weights] except TypeError: weights = [[0 if weights < threshold else weights]] * len(x) try: iter(colors) except TypeError: colors = [colors] * len(x) shifts = [] for parts in weights: shifts.append([0]) shift = shifts[-1] for part in parts: shift.append(shift[-1] + part) for m, part in enumerate(parts): shift[m] -= (shift[-1] - part) / 2 sgn = +1 for weights, shifts, color in zip(zip(*weights), zip(*shifts), colors): self.fatband(x, y, weights, shifts, fill=color, sgn=sgn, **options) sgn *= -1
[docs] def node(self, x, y, content, name=None, **options): """Draw (text) node at given position. Parameters ---------- x, y : float Node position in data coordinates. content : str Node content. name : str Name/label to refer back to the node. **options TikZ options of the node, e.g., ``above left=True``. """ self.code('\\node%s%s at (<x=%.14g>, <y=%.14g>) {%s};' % (' (%s)' % name if name else '', csv(options), x, y, content))
[docs] def cut(self, x=0.0, y=0.0): """Indicate broken axis. Parameters ---------- x, y : float Position of the break symbol. """ self.axes() for cmd, color, to in ('fill', 'white', '--'), ('draw', 'black', ' '): self.code('\\%s [%s, xshift=<x=%.14g>cm, yshift=<y=%.14g>cm] ' '(-0.1, -0.15) -- (0.1, 0.05) %s (0.1, 0.15) -- (-0.1, -0.05);' % (cmd, color, x, y, to))
[docs] def point(self, x, y, name): """Define point. Parameters ---------- x, y : float Point position in data coordinates. name : str Name/label to refer back to the point. """ self.code('\\coordinate (%s) at (<x=%.14g>, <y=%.14g>);' % (name, x, y))
[docs] def image(self, filename, x1, y1, x2, y2, **options): """Insert image between given data coordinates. Parameters ---------- filename : str File name of image. x1, y1 : float Position of bottom-left corner in data coordinates. x2, y2 : float Position of top-right corner in data coordinates. **options Options passed to `line`. """ if x1 < x2: xscale = 1 else: xscale = -1 x1, x2 = x2, x1 if y1 < y2: yscale = 1 else: yscale = -1 y1, y2 = y2, y1 graphics = ('\\includegraphics[width=<dx=%g>cm, height=<dy=%g>cm]{%s}' % (x2 - x1, y2 - y1, filename)) if not xscale == yscale == 1: graphics = '\\scalebox{%s}[%s]{%s}' % (xscale, yscale, graphics) self.code('\\node at (<x=%g>, <y=%g>) ' '[anchor=south west, inner sep=0, outer sep=0] {%s};' % (x1, y1, graphics), **options)
[docs] def code(self, data, **options): """Insert literal TikZ code. Parameters ---------- data : str TikZ code. Positions and distances in data coordinates and units can be specified using angle brackets, e.g., ``(<x=1>, <y=2>)`` or ``+(<dx=1>, <dy=2>)``. **options Options passed to `line`. """ self.line(code=data, **options)
[docs] def axes(self, **options): """Draw axes at current z index.""" self.line(axes=True, **options)
[docs] def nolabel(self): """Pass empty entry to legend (as spacer between entries).""" self.line(draw='none', label='')
[docs] def clear(self): """Remove all lines from plot.""" self.lines = []
[docs] def save(self, filename, external=False, standalone=False, pdf=False, png=False, dpi=300.0, width=0, height=0, rewrite=False, engine='pdflatex'): """Save plot to file. Parameters ---------- filename : str File name. If no period is contained, the ``.tex`` extension may be omitted. external : bool, default False Provide file name to TikZ library ``external``. standalone : bool, default False Create file that can be typeset with ``pdflatex``, i.e., include document header etc.? pdf : bool, default False Typeset TeX file via ``pdflatex``? This implies `standalone`. Automatically set to ``True`` if `filename` ends with ``.pdf``. png : bool, default False Rasterize PDF file via ``pdftoppm``? This implies `pdf`. Automatically set to ``True`` if `filename` ends with ``.png``. dpi : float, default 300.0 Image resolution in dots per inch. width, height : int Image dimensions in pixels. If either `width` or `height` is zero, it will be determined by the aspect ratio of the image. If both are zero, they will also be determined by `dpi`. rewrite : bool, default False Rewrite resulting PNG file using StoryLines? This will remove possible metadata and may reduce the file size but currently is quite slow. engine : str, default 'pdflatex' TeX typesetting engine. """ # determine data limits: lower = {} upper = {} for x in 'xyz': xmin = getattr(self, x + 'min') xmax = getattr(self, x + 'max') if xmin is None or xmax is None: if x == 'z': X = [line[x] for line in self.lines if line[x] is not None] else: X = [value for line in self.lines for value in line[x]] if x == 'z' and self.colorbar is None: self.colorbar = xmin is not None and xmax is not None or bool(X) lower[x] = xmin if xmin is not None else min(X) if X else 0.0 upper[x] = xmax if xmax is not None else max(X) if X else 0.0 lower[x] -= getattr(self, x + 'padding') upper[x] += getattr(self, x + 'padding') # embed zero- and one-dimensional data: if lower[x] == upper[x]: padding = power_of_ten(lower[x]) lower[x] -= padding upper[x] += padding # choose automatic margins: baselineskip = 1.2 * self.fontsize * pt if self.bottom is None: self.bottom = self.margmin if self.xaxis: xticks = ((self.xticks is None or bool(self.xticks)) and self.xmarks and self.xlabels) if self.xlabel or xticks: self.bottom += self.tick + baselineskip if self.xlabel and xticks and not self.xclose: self.bottom += baselineskip if self.left is None: self.left = self.margmin if self.yaxis: yticks = ((self.yticks is None or bool(self.yticks)) and self.ymarks and self.ylabels) if self.ylabel or yticks: self.left += self.tick + baselineskip if self.ylabel and yticks and not self.yclose: self.left += baselineskip if self.right is None: self.right = self.margmin if self.colorbar: self.right += self.gap + self.bar zticks = ((self.zticks is None or bool(self.zticks)) and self.zmarks and self.zlabels) if self.zlabel or zticks: self.right += baselineskip if self.zlabel and zticks and not self.zclose: self.right += baselineskip if self.top is None: self.top = self.margmin if self.title: self.top += baselineskip * (self.title.count('\\\\') + 1) # interpret negative as inner dimensions: if self.width < 0: self.width = -self.width + self.left + self.right if self.height < 0: self.height = -self.height + self.bottom + self.top # temporarily subtract protrusions from margins: self.left -= self.dleft self.right -= self.dright self.bottom -= self.dbottom self.top -= self.dtop # determine extent of the plotting area: extent = {} extent['x'] = self.width - self.left - self.right extent['y'] = self.height - self.bottom - self.top # determine width or height for proportional plot: if not self.height: extent['y'] = (upper['y'] - lower['y']) * extent['x'] \ / (upper['x'] - lower['x']) self.height = extent['y'] + self.bottom + self.top elif not self.width: extent['x'] = (upper['x'] - lower['x']) * extent['y'] \ / (upper['y'] - lower['y']) self.width = extent['x'] + self.left + self.right # determine scale: scale = {} for x in extent.keys(): # how many centimeters correspond to one unit of the axis? scale[x] = extent[x] / (upper[x] - lower[x]) # add protrusions back to margins: self.left += self.dleft self.right += self.dright self.bottom += self.dbottom self.top += self.dtop lower['x'] += self.dleft / scale['x'] upper['x'] -= self.dright / scale['x'] lower['y'] += self.dbottom / scale['y'] upper['y'] -= self.dtop / scale['y'] extent['x'] -= self.dleft + self.dright extent['y'] -= self.dbottom + self.dtop # take care of z-axis: extent['z'] = extent['y'] scale['z'] = extent['z'] / (upper['z'] - lower['z']) # set aspect ratio by adjusting margins: if self.ratio is not None: ratio = self.width / self.height if ratio < self.ratio: diff = self.height * self.ratio - self.width self.width += diff self.left += self.align * diff self.right += (1 - self.align) * diff elif self.ratio < ratio: diff = self.width / self.ratio - self.height self.height += diff self.bottom += self.align * diff self.top += (1 - self.align) * diff # determine tick positions: ticks = {} for x in extent.keys(): # use ticks or choose ticks with a spacing close to the given one: xformat = getattr(self, x + 'format') if getattr(self, x + 'ticks') is not None: ticks[x] = [(scale[x] * (n - lower[x]), label) for n, label in [tick if hasattr(tick, '__len__') else (tick, xformat(tick)) for tick in getattr(self, x + 'ticks')]] ticks[x] = [(position, label) for position, label in ticks[x] if -self.eps <= position <= extent[x] + self.eps] else: ticks[x] = [(scale[x] * (n - lower[x]), xformat(n)) for n in multiples(lower[x], upper[x], getattr(self, x + 'step') or xround_mantissa( getattr(self, x + 'spacing') / scale[x]))] if not getattr(self, x + 'labels'): ticks[x] = [(position, False) for position, label in ticks[x]] # determine minor-tick positions: minorticks = {} for x in 'xy': if getattr(self, x + 'minormarks') or self.minorgrid: positions = getattr(self, x + 'minorticks') if positions is None: if (getattr(self, x + 'minorstep') is None and getattr(self, x + 'minorspacing') is None): positions = [] else: positions = multiples(lower[x], upper[x], getattr(self, x + 'minorstep') or xround_mantissa( getattr(self, x + 'minorspacing') / scale[x])) minorticks[x] = [scale[x] * (n - lower[x]) for n in positions] minorticks[x] = [minor for minor in minorticks[x] if not any(abs(major - minor) < self.resolution for major, label in ticks[x])] minorticks[x] = [position for position in minorticks[x] if -self.eps <= position <= extent[x] + self.eps] # handle horizontal and vertical lines: for x, y in 'xy', 'yx': for line in self.lines: if not len(line[x]) and len(line[y]) == 1: line[x] = [lower[x], upper[x]] line[y] = [line[y][0]] * 2 line['options'].setdefault('line_cap', 'butt') # create simple colormap from special lower and upper colors: if self.cmap is None: if isinstance(self.upper, Color) and isinstance(self.lower, Color): self.cmap = colormap((0, self.lower), (1, self.upper)) # build LaTeX file: labels = [] stem, typ, home = goto(filename) png = png or typ == 'png' pdf = pdf or typ == 'pdf' or png if pdf: standalone = True with open('%s.tex' % stem, 'w') as file: # print premable and open document: if standalone: file.write('\\documentclass[class=%s, %dpt]{standalone}\n' % ('article' if 10 <= self.fontsize <= 12 else 'scrartcl', self.fontsize)) file.write('\\usepackage{tikz}\n') if self.inputenc and 'inputenc' not in self.preamble: file.write('\\usepackage[%s]{inputenc}\n' % self.inputenc) if self.font is not None: texfonts = { 'Gill Sans': '\\usepackage[math]{iwona}\n' '\\usepackage[sfdefault]{cabin}\n' '\\usepackage[italic, noplusnominus]{mathastext}\n', 'Helvetica': '\\usepackage{sansmathfonts}\n' '\\usepackage[scaled]{helvet}\n' '\\let\\familydefault\\sfdefault\n' '\\usepackage[italic]{mathastext}\n', 'Iwona': '\\usepackage[math]{iwona}\n', 'Latin Modern': '\\usepackage{lmodern}\n', 'Times': '\\usepackage{newtxtext, newtxmath}\n', 'Utopia': '\\usepackage{fourier}', } if self.font in texfonts: file.write(texfonts[self.font]) if self.fontenc is None: self.fontenc = 'T1' else: file.write('\\usepackage{mathspec}\n') file.write('\\setallmainfonts{%s}\n' % self.font) engine = 'xelatex' if self.fontenc and 'fontenc' not in self.preamble: file.write('\\usepackage[%s]{fontenc}\n' % self.fontenc) if self.preamble: file.write('%s\n' % self.preamble.strip()) for line in self.lines: if ('mark' in line['options'] and line['options']['mark'] not in ['*', '+', 'x', 'ball']): file.write('\\usetikzlibrary{plotmarks}\n') break file.write('\\begin{document}\n\\noindent\n') # set filename for externalization: elif external: file.write('\\tikzsetnextfilename{%s}\n%%\n' % stem) # open TikZ environment: file.write('\\begin{tikzpicture}%s' % csv(self.options, '[%s]')) # set bounding box: bbox = ('\n (%.3f, %.3f) rectangle +(%.3f, %.3f);' % (-self.left, -self.bottom, self.width, self.height)) file.write('\n\\useasboundingbox') file.write(bbox) if self.canvas is not None: file.write('\n\\draw%s' % csv(dict(color=self.canvas, line_width='1mm', fill=True))) file.write(bbox) if self.outline: file.write('\n\\draw%s' % csv(dict(color='gray', very_thin=True, dashed=True))) file.write(bbox) # add background image: if self.background is not None: file.write('\n\\node ' '[anchor=south west, inner sep=0, outer sep=0] ' '{\\includegraphics[width=%.3fcm, height=%.3fcm]{%s}};' % (extent['x'], extent['y'], self.background)) # draw coordinate system: origin = {} for x in 'xy': xorigin = getattr(self, x + 'origin') if xorigin is None: origin[x] = 0 else: origin[x] = scale[x] * (xorigin - lower[x]) for x, y in 'xy', 'yx': if not origin[y]: continue ticks[x] = [(position, label) for position, label in ticks[x] if not abs(position - origin[x]) < self.resolution] if getattr(self, x + 'minorticks'): minorticks[x] = [position for position in minorticks[x] if not abs(position - origin[x]) < self.resolution] def draw_grid(): if draw_grid.done: return lines = {} for x in 'xy': lines[x] = set() if getattr(self, x + 'axis'): lines[x].add(origin[x]) if self.frame: lines[x].add(0.0) lines[x].add(extent[x]) if self.minorgrid: file.write('\n\\draw [lightgray!50, line cap=rect]') for x in minorticks['x']: if not any(abs(x - line) < self.resolution for line in lines['x']): file.write('\n (%.3f, 0) -- +(0, %.3f)' % (x, extent['y'])) for y in minorticks['y']: if not any(abs(y - line) < self.resolution for line in lines['y']): file.write('\n (0, %.3f) -- +(%.3f, 0)' % (y, extent['x'])) file.write(';') file.write('\n\\draw [lightgray, line cap=rect]') for x, label in ticks['x']: if not any(abs(x - line) < self.resolution for line in lines['x']): file.write('\n (%.3f, 0) -- +(0, %.3f)' % (x, extent['y'])) for y, label in ticks['y']: if not any(abs(y - line) < self.resolution for line in lines['y']): file.write('\n (0, %.3f) -- +(%.3f, 0)' % (y, extent['x'])) file.write(';') draw_grid.done = True draw_grid.done = False def draw_frame(): if draw_frame.done: return file.write('\n\\draw [gray, line cap=rect]\n ') if not self.xaxis or origin['y']: file.write('(0, 0) -- ') file.write('(%.3f, 0) -- (%.3f, %.3f) -- (0, %.3f)' % tuple(extent[x] for x in 'xxyy')) if not self.yaxis or origin['x']: file.write(' -- (0, 0)') file.write(';') draw_frame.done = True draw_frame.done = False def draw_axes(): if draw_axes.done: return # paint colorbar: if self.colorbar: if self.cmap is not None: dots = max(2, int(round(extent['z'] / inch * dpi))) colorbar = colorize([[n / (dots - 1.0)] for n in reversed(range(dots))], self.cmap) self.colorbar = '%s.bar.png' % stem save(self.colorbar, colorbar) if isinstance(self.colorbar, str): file.write('\n\\node at (%.3f, 0) ' '[anchor=south west, inner sep=0, outer sep=0] ' '{\\includegraphics[width=%.3fcm, height=%.3fcm]' '{%s}};' % (extent['x'] + self.gap, self.bar, extent['y'], self.colorbar)) else: file.write('\n\\shade [bottom color=%s, top color=%s]' % (self.lower, self.upper)) file.write('\n (%.3f, 0) rectangle (%.3f, %.3f);' % (extent['x'] + self.gap, extent['x'] + self.gap + self.bar, extent['z'])) if self.zmarks: for z, label in ticks['z']: if label is None: continue file.write('\n\\node ' '[rotate=90, below] at (%.3f, %.3f) {%s};' % (extent['x'] + self.gap + self.bar, z, label)) if self.grid: draw_grid() if self.frame: draw_frame() if self.xaxis or self.yaxis: # draw tick marks and labels: if (self.xaxis and (self.xmarks and ticks['x'] or self.xminormarks and minorticks['x']) or self.yaxis and (self.ymarks and ticks['y'] or self.yminormarks and minorticks['y'])): file.write('\n\\draw [line cap=butt]') if self.xaxis and self.xmarks: for x, label in ticks['x']: if label is None: continue file.write('\n (%.3f, %.3f) -- +(0, %.3f)' % (x, origin['y'], -self.tick)) if label: file.write(' node [below] {%s}' % label) if self.xaxis and self.xminormarks: for x in minorticks['x']: file.write('\n (%.3f, %.3f) -- +(0, %.3f)' % (x, origin['y'], -self.minortick)) if self.yaxis and self.ymarks: for y, label in ticks['y']: if label is None: continue file.write('\n (%.3f, %.3f) -- +(%.3f, 0)' % (origin['x'], y, -self.tick)) if label: file.write(' node [%s] {%s}' % ('left' if origin['x'] else 'rotate=90, above', label)) if self.yaxis and self.yminormarks: for y in minorticks['y']: file.write('\n (%.3f, %.3f) -- +(%.3f, 0)' % (origin['x'], y, -self.minortick)) file.write(';') # draw coordinate axes: if origin['x'] or origin['y']: file.write('\n\\draw [->, line cap=rect]\n ' '(0, %.3f) -- +(%.3f, 0);' % (origin['y'], extent['x'] + self.tip)) file.write('\n\\draw [->, line cap=rect]\n ' '(%.3f, 0) -- +(0, %.3f);' % (origin['x'], extent['y'] + self.tip)) else: file.write('\n\\draw [%s-%s, line cap=butt]\n ' % ('<' * self.xaxis, '>' * self.yaxis)) if self.xaxis: file.write('(%.3f, 0) -- ' % (extent['x'] + self.tip)) file.write('(0, 0)') if self.yaxis: file.write(' -- (0, %.3f)' % (extent['y'] + self.tip)) file.write(';') # label coordinate axes: if self.xaxis and self.xlabel: if origin['y']: file.write('\n\\node [right] at (%.3f, %.3f)' % (extent['x'] + self.tip, origin['y'])) else: file.write('\n\\node [below') if ticks['x'] and not self.xclose: file.write('=\\baselineskip') file.write('] at (%.3f, %.3f)' % (extent['x'] / 2, -self.tick)) file.write('\n {%s};' % self.xlabel) if self.yaxis and self.ylabel: if origin['x']: file.write('\n\\node [above] at (%.3f, %.3f)' % (origin['x'], extent['y'] + self.tip)) else: file.write('\n\\node [rotate=90, above') if ticks['y'] and not self.yclose: file.write('=\\baselineskip') file.write('] at (%.3f, %.3f)' % (-self.tick, extent['y'] / 2)) file.write('\n {%s};' % self.ylabel) if self.colorbar and self.zlabel: file.write('\n\\node [rotate=90, below') if ticks['z'] and not self.zclose: file.write('=\\baselineskip') file.write('] at (%.3f, %.3f)' % (extent['x'] + self.gap + self.bar, extent['y'] / 2)) file.write('\n {%s};' % self.zlabel) draw_axes.done = True draw_axes.done = False if not any(line['axes'] for line in self.lines): draw_axes() # plot lines: form = '(%%%d.3f, %%%d.3f)' % ( 5 if extent['x'] < 10 else 6, 5 if extent['y'] < 10 else 6) for line in self.lines: if line['z'] is not None: ratio = (line['z'] - lower['z']) / (upper['z'] - lower['z']) line['options'].setdefault('color', '%s!%.1f!%s' % (self.upper, 100 * ratio, self.lower) if self.cmap is None else self.cmap(ratio)) if line['options'].get('mark') == 'ball': line['options'].setdefault('ball_color', line['options']['color']) for option in 'line_width', 'mark_size': if isinstance(line['options'].get(option), (float, int)): line['options'][option] = ('%.3fcm' % (line['options'][option] * scale['y'])) for option in line['options']: if isinstance(line['options'][option], str): line['options'][option] = re.sub('<([\\d.]+)>', lambda match: '%.3f' % (float(match.group(1)) * scale['y']), line['options'][option]) if line['label'] is not None: label = [line['options'], line['label']] for previous in labels: if label[1] and previous == label: break else: labels.append(label) if len(line['x']) and len(line['y']): for x, y in 'xy', 'yx': xref = line[x + 'ref'] if xref is not None: line[x] = list(line[x]) line[y] = list(line[y]) line[x] = [xref] + line[x] + [xref] line[y] = line[y][:1] + line[y] + line[y][-1:] points = list(zip(*[[scale[x] * (n - lower[x]) for n in line[x]] for x in 'xy'])) if line['protrusion']: for i, j in (1, 0), (-2, -1): if line['weights'] is not None: if not line['weights'][j]: continue dx = points[j][0] - points[i][0] dy = points[j][1] - points[i][1] dr = math.sqrt(dx * dx + dy * dy) rescale = 1 + line['protrusion'] / dr points[j] = ( points[i][0] + dx * rescale, points[i][1] + dy * rescale, ) if line['jump']: segments = jump(points, distance=line['jump']) else: segments = [points] if line['weights'] is not None: segments = [(miter_butt if line['miter'] else fatband)( segment, line['thickness'], line['weights'], line['shifts'], line['nib']) for segment in segments] if line['cut']: try: xmin, xmax, ymin, ymax = line['cut'] except (TypeError, ValueError): xmin = xmax = ymin = ymax = None eps = self.eps if line['options'].get('mark') else 0 xmin = (scale['x'] * (xmin - lower['x']) if xmin is not None else 0) - eps xmax = (scale['x'] * (xmax - lower['x']) if xmax is not None else extent['x']) + eps ymin = (scale['y'] * (ymin - lower['y']) if ymin is not None else 0) - eps ymax = (scale['y'] * (ymax - lower['y']) if ymax is not None else extent['y']) + eps if line['join'] is None: line['join'] = line['options'].get('fill', 'none') != 'none' if line['options'].get('only_marks'): segments = [[(x, y) for segment in segments for x, y in segment if xmin <= x <= xmax and ymin <= y <= ymax]] else: segments = [segment for segment in segments for segment in cut2d(segment, xmin, xmax, ymin, ymax, line['join'])] if line['omit'] is None: line['omit'] = 'mark' not in line['options'] for segment in segments: options = line['options'].copy() if line['omit']: segment = relevant(segment[::line['sgn']], self.resolution) elif line['cut'] and line['options'].get('mark') \ and not line['options'].get('only_marks'): options['mark_indices'] = '{%s}' % ','.join(str(n) for n, point in enumerate(segment, 1) if point in points) if line['shortcut']: segment = shortcut(segment, line['shortcut'], line['shortcut_rel']) file.write('\n\\draw%s plot coordinates {' % csv(options)) for group in groups(segment): file.write('\n ') file.write(' '.join(form % point for point in group)) file.write(' };') # insert TikZ code with special coordinates: if line['code']: code = line['code'].strip() for x in 'xy': code = re.sub('<%s=(.*?)>' % x, lambda match: '%.3f' % (scale[x] * (float(match.group(1)) - lower[x])), code) code = re.sub('<d%s=(.*?)>' % x, lambda match: '%.3f' % (scale[x] * float(match.group(1))), code) file.write('\n%s' % code) if line['grid']: draw_grid() if line['frame']: draw_frame() if line['axes']: draw_axes() draw_axes() def position(pos): if isinstance(pos, str): x = [] y = [] positions = dict( L=(x, -self.left), B=(y, -self.bottom), l=(x, 0.0), b=(y, 0.0), r=(x, extent['x']), t=(y, extent['y']), R=(x, extent['x'] + self.right), T=(y, extent['y'] + self.top), ) abbreviations = dict(c='lr', C='LR', m='bt', M='BT') for abbreviation in abbreviations.items(): pos = pos.replace(*abbreviation) for char in pos: positions[char][0].append(positions[char][1]) x = sum(x) / len(x) y = sum(y) / len(y) else: x, y = pos x = scale['x'] * (x - lower['x']) y = scale['y'] * (y - lower['y']) return x, y # add label: if self.label is not None: if self.labelformat is not None: self.label = self.labelformat(self.label) if self.labelsize is not None: self.label = ('\\fontsize{%d}{%d}\\selectfont %s' % (self.labelsize, self.labelsize, self.label)) file.write('\n\\node at (%.3f, %.3f)' % position(self.labelpos)) file.write(' [%s] {%s};' % (self.labelopt, self.label)) # add legend: if self.lput and (self.ltop is not None or labels): file.write('\n\\node [align=%s' % self.lali) if self.lopt is not None: file.write(', %s' % self.lopt) if self.lbox: file.write(', draw=gray, fill=white, rounded corners=1pt') file.write('] at (%.3f, %.3f) {' % position(self.lpos)) if self.ltop: file.write('\n %s' % self.ltop) if labels: file.write(' \\\\') if self.lsep is not None: file.write('[%s]' % self.lsep) if labels: file.write('\n \\begin{tikzpicture}[x=%s, y=-%s]' % (self.llen, self.lbls)) lrow = self.lrow or 1 + (len(labels) - 1) // self.lcol spacer = True n = 0 for options, label in labels: col = n // lrow row = n % lrow if label == '*next*': label = None row -= 0.2 else: n += 1 if label: file.write('\n \\node ' '[right] at (%.3f, %d) {%s};' % (col * self.lwid + 1, row, label)) fill = options.get('fill', 'none') != 'none' draw = not options.get('only_marks') draw &= not options.get('draw') == 'none' mark = 'mark' in options if fill or draw or mark: if (fill or draw) and mark: options['mark_indices'] = '{2}' file.write('\n \\draw%s' % csv(options)) file.write('\n plot coordinates ') if fill: top = row - 0.1 bot = row + 0.1 x = [0.0] + [0.5] * mark + [1.0, 1.0, 0.0, 0.0] y = [bot] + [bot] * mark + [bot, top, top, bot] elif draw: x = [0.0] + [0.5] * mark + [1.0] y = [row] + [row] * mark + [row] elif mark: x = [0.5] y = [row] file.write('{') for m in range(len(x)): file.write(' (%.3f, %g)' % (col * self.lwid + x[m], y[m])) file.write(' };') if draw or fill and not col: spacer = False if spacer: file.write('\n \\useasboundingbox (0, 0);') file.write('\n \\end{tikzpicture}%') file.write('\n };') # add title: if self.title is not None: options = dict(above=True) if '\\\\' in self.title: options['align'] = 'center' file.write('\n\\node%s at (%.3f, %.3f) {%s};' % (csv(options), extent['x'] / 2, extent['y'], self.title)) # close TikZ environment: file.write('\n\\end{tikzpicture}%') # close document: if standalone: file.write('\n\\end{document}') file.write('\n') # typeset document and clean up: if pdf: typeset(stem, engine) if png: rasterize(stem, dpi, width, height, rewrite) home()