home back

Axes

The axes object draws a pair of coordinate axes with their ticks and labels. It looks simple, and for the common case it is: one line gives you a usable pair of axes. But underneath it can do rather more, so this chapter goes through it option by option.

Every example is shown twice:

  • DSL: the named-parameter form, axes(...). Shorter, autocompleted by the editor, and the way you will normally write it.
  • Plain Groovy: the same thing built by calling methods on the object. Longer, but it is what you need when you want to change the axes after creating them, or when you are doing something the DSL does not cover.

Both produce the same object. The DSL is a thin layer that calls exactly the methods shown on the right.

Let the editor do it: type axes( and press Ctrl+Space to see every parameter with its description. Inside a sub-map, like xScale: [, press Ctrl+Space again for the keys of that sub-map.

Table of Contents

The quick version

// DSL
def ax = axes(xRange: [-3, 3], yRange: [-2, 2], xStep: 1, yStep: 1)
// Plain Groovy
def ax = Axes.make()
ax.generatePrimaryXTicks(-3, 3, 1)
ax.generatePrimaryYTicks(-2, 2, 1)
add(ax)

Two differences worth noting from the start. The DSL adds the object to the scene for you, unless you say addToScene: false; in plain Groovy you call scene.add(ax) yourself. And the axes are infinite lines: xRange says where the ticks go, not where the axis stops. To stop it, see bounded axes.

Ticks

Automatic ticks

xRange/yRange with xStep/yStep generate one labelled tick per step. The label is the number itself. There is never a tick at the origin.

// DSL
def ax = axes(range: [-4, 4], xStep: 0.5, yStep: 1)
// Plain Groovy
def ax = Axes.make()
ax.generatePrimaryXTicks(-4, 4, 0.5)
ax.generatePrimaryYTicks(-4, 4, 1)
add(ax)

range: is a shorthand that sets both xRange and yRange.

Secondary ticks

Secondary ticks are shorter marks, meant to subdivide the primary ones. They take a maxWidth: when the math view gets wider than that, they disappear, so they do not clutter a zoomed-out shot.

// DSL
def ax = axes(
    range: [-4, 4], xStep: 1, yStep: 1,
    secondaryX: [step: 0.25, maxWidth: 10],
    secondaryY: [from: -2, to: 2, step: 0.5]
)
// Plain Groovy
def ax = Axes.make()
ax.generatePrimaryXTicks(-4, 4, 1)
ax.generatePrimaryYTicks(-4, 4, 1)
ax.generateSecondaryXTicks(-4, 4, 0.25, 10)   // from, to, step, maxWidth
ax.generateSecondaryYTicks(-2, 2, 0.5, 30)
add(ax)

secondary: on its own sets both axes. If you omit from/to they default to the ends of xRange/yRange.

Individual ticks

For a tick at a value the automatic grid would never hit, or with a label that is not a number:

// DSL
def ax = axes(
    xRange: [-1, 4], xStep: 1,
    ticksX: [
        [at: PI/2, label: r"$\frac{\pi}{2}$"],
        [at: PI,   label: r"$\pi$"],
        [at: 2.5,  type: "secondary"],
        3.5                                    // just a number: automatic label
    ]
)
// Plain Groovy
def ax = Axes.make()
ax.generatePrimaryXTicks(-1, 4, 1)
ax.addXTicksLegend(r"$\frac{\pi}{2}$", PI/2, TickType.PRIMARY)
ax.addXTicksLegend(r"$\pi$", PI, TickType.PRIMARY)
ax.addXTicksLegend(2.5, TickType.SECONDARY)
ax.addXTicksLegend(3.5, TickType.PRIMARY)
add(ax)

There is a rule about what happens when a tick already exists at that value:

  • An explicit label wins. ticksX: [[at: 1, label: r"$a$"]] together with xStep: 1 replaces the automatic 1 with a. In Groovy, that is the overload that takes the latex string first.
  • An automatic label does not overwrite. That is what keeps secondaryX: [step: 0.5] from turning every integer tick into a secondary one.

Ticks with no label

A mark on the axis without any text:

// DSL
def ax = axes(
    xRange: [-3, 3],
    ticksX: [
        [at: 2.5, type: "secondary", showLabel: false],
        [at: 3.5, showLabel: false]
    ]
)
// Plain Groovy
def ax = Axes.make()
ax.generatePrimaryXTicks(-3, 3, 1)
ax.addXTickMark(2.5, TickType.SECONDARY, Double.POSITIVE_INFINITY)
ax.addXTickMark(3.5, TickType.PRIMARY, Double.POSITIVE_INFINITY)
add(ax)

The label is still built, just not drawn, so it can be brought back later without rebuilding anything:

ax.xticksBase.find { it.dataValue == 2.5 }?.setLegendVisible(true)
ax.refreshDisplayTicks()

This is what the minor ticks of a logarithmic axis use.

Styling

The axes themselves

// DSL
def ax = axes(
    range: [-3, 3],
    axisStyle: [color: "gray", thickness: 3],   // both axes
    xStyle: [color: "red"],                     // only the x-axis, wins over axisStyle
    yStyle: [color: "blue"]
)
// Plain Groovy
def ax = Axes.make()
ax.generatePrimaryXTicks(-3, 3, 1)
ax.generatePrimaryYTicks(-3, 3, 1)
ax.getxAxis().drawColor("red").thickness(3)
ax.getyAxis().drawColor("blue").thickness(3)
add(ax)

getxAxis() returns the Line object of the axis. It stays the style handle even when the axis is drawn bounded: the segment copies its style.

Marks and labels

// DSL
def ax = axes(
    range: [-3, 3],
    xTickStyle: [color: "darkgray", thickness: 2],   // the marks
    xLegendStyle: [color: "black"],                  // the labels
    yTickStyle: [color: "darkgray"],
    yLegendStyle: [color: "black"]
)
// Plain Groovy
def ax = Axes.make()
ax.generatePrimaryXTicks(-3, 3, 1)
ax.generatePrimaryYTicks(-3, 3, 1)
ax.xticksBase.each { it.tick.drawColor("darkgray").thickness(2); it.legend.drawColor("black") }
ax.yticksBase.each { it.tick.drawColor("darkgray"); it.legend.drawColor("black") }
add(ax)

xticksBase is the live list of every x-tick. Each TickAxes has a tick (the mark, a Shape) and a legend (the label, a LatexMathObject).

One tick at a time

// DSL
def ax = axes(
    xRange: [-3, 3],
    ticksX: [
        [at: 2, label: r"$a$",
         style:     [color: "orange"],   // the label
         markStyle: [color: "green", thickness: 4]]   // the mark
    ]
)
// Plain Groovy
def ax = Axes.make()
ax.generatePrimaryXTicks(-3, 3, 1)
def t = ax.addXTicksLegend(r"$a$", 2, TickType.PRIMARY)
t.legend.drawColor("orange")
t.tick.drawColor("green").thickness(4)
add(ax)

Every add... method returns the tick it created, so you do not have to look it up afterwards. When the value already had a tick and the call is one of those that do not replace it, you get the tick that was already there, which is what you want anyway. You get null only when the value cannot be drawn on that axis, as happens with a negative value on a logarithmic scale.

You can also pick ticks by a condition, which the DSL cannot express:

ax.xticksBase.findAll { it.dataValue < 0 }.each { it.legend.drawColor("red") }

Label size

Three levels, and they multiply with each other:

// DSL
def ax = axes(
    xRange: [-3, 3], yRange: [-2, 2],
    labelScale: 0.7,          // every label, both axes
    xLabelScale: 1.2,         // only x, overrides labelScale for that axis
    ticksX: [[at: 2, label: r"$a$", scale: 2]]   // only this label
)
// Plain Groovy
def ax = Axes.make()
ax.generatePrimaryXTicks(-3, 3, 1)
ax.generatePrimaryYTicks(-2, 2, 1)
ax.addXTicksLegend(r"$a$", 2, TickType.PRIMARY)
ax.xticksBase.each { it.legend.scale(1.2) }
ax.yticksBase.each { it.legend.scale(0.7) }
ax.xticksBase.find { it.dataValue == 2 }.legend.scale(2)
ax.refreshDisplayTicks()      // needed after changing a label size by hand
add(ax)

The factor is relative to the default label size, so 1.0 leaves it alone. The call to refreshDisplayTicks() is only needed when you change a size by hand: the gap between a label and its mark is proportional to the height of the label, so it has to be recomputed. Changing a color needs no refresh.

Number format

Automatic labels are formatted with a DecimalFormat pattern.

// DSL
def ax = axes(
    xRange: [0, 1], xStep: 0.125,
    yRange: [-2, 2],
    format: "#.####",     // both axes
    xFormat: "0.000",     // only x, overrides format
    yFormat: "#"
)
// Plain Groovy
def symbols = new DecimalFormatSymbols(new Locale("en", "UK"))
def ax = Axes.make()
ax.setXFormat(new DecimalFormat("0.000", symbols))
ax.setYFormat(new DecimalFormat("#", symbols))
ax.generatePrimaryXTicks(0, 1, 0.125)
ax.generatePrimaryYTicks(-2, 2, 1)
add(ax)

Set the format before generating the ticks: the label of a tick is built when the tick is created.

Use english symbols, as the DSL does, so that the decimal separator does not depend on the machine the animation runs on.

Bounded axes, arrow heads and names

// DSL
def ax = axes(
    xRange: [-3, 3], yRange: [-2, 2],
    axisRange: [-3.5, 3.5],         // or xAxisRange / yAxisRange separately
    arrows: true,
    xAxisLabel: r"$t$",
    yAxisLabel: r"$v(t)$"
)
// Plain Groovy
def ax = Axes.make()
ax.generatePrimaryXTicks(-3, 3, 1)
ax.generatePrimaryYTicks(-2, 2, 1)
ax.setXAxisRange(-3.5, 3.5)
ax.setYAxisRange(-3.5, 3.5)
ax.setArrows(true)
ax.setXAxisLabel(r"$t$")
ax.setYAxisLabel(r"$v(t)$")
add(ax)

Notes:

  • Without a range the axes stay infinite lines. Arrow heads still work: they sit at the border of the math view and follow it as the camera moves.
  • Arrow heads and axis names keep a constant size on screen, like the tick labels.
  • clearAxesRanges() goes back to infinite axes.

Number lines: one axis only

Inequalities, intervals, integer sets and the whole of primary school arithmetic happen on a single line. The type parameter drops the other axis:

def nl = axes(type: "numberLine", range: [-5, 5], xStep: 1, arrows: true)

numberLine keeps the x-axis, numberLineY keeps the y-axis, and cartesian (the default) keeps both. The hidden axis is hidden completely: its line, its arrow head, its name and its ticks. Its range is ignored as well, which is why the shorthand range: above is enough and does not fill the vertical axis with ticks nobody asked for.

Everything else in this chapter works the same on a number line: individual ticks with their own labels, secondary ticks, scales, formats and styles.

def nl = axes(type: "numberLine", xRange: [0, 4], xStep: 1,
              secondaryX: [step: 0.25],
              ticksX: [[at: Math.PI, label: r"\pi", scale: 1.2]],
              xAxisLabel: r"$x$", arrows: true)

If you would rather say it key by key, xAxisVisible and yAxisVisible do the same thing and override whatever type decided:

def nl = axes(range: [-5, 5], yAxisVisible: false)

In plain Groovy these are the Axes methods setXAxisVisible(boolean) and setYAxisVisible(boolean).

Scales: separating values from coordinates

By default, the value 3 on the x-axis is drawn at the scene coordinate 3. A scale breaks that tie, which is what lets an axis have a shifted origin, a unit other than 1, or a logarithm.

Shifted origin and a different unit

Suppose the x-axis has to show 0 to 100, but you want it to occupy from -5 to 5 on screen.

// DSL
def ax = axes(
    xScale: [origin: -5, unit: 0.1],
    xRange: [0, 100], xStep: 20,
    yRange: [-2, 2],
    xFormat: "#"
)
// Plain Groovy
def ax = Axes.make()
ax.setXScale(AxisScale.linear(-5, 0.1))    // origin, unit
ax.setXFormat(new DecimalFormat("#", new DecimalFormatSymbols(new Locale("en", "UK"))))
ax.generatePrimaryXTicks(0, 100, 20)
ax.generatePrimaryYTicks(-2, 2, 1)
add(ax)

The value v is drawn at origin + unit * v, so the value 0 lands at -5 and the value 100 at 5. Every number you write elsewhere, in xRange, in ticksX: [at: ...], is a value of the axis, not a scene coordinate.

You can set the scale after the ticks exist; they are placed again rather than rebuilt.

Logarithmic axes

// DSL
def ax = axes(
    xScale: [log: 10, unit: 1.2],
    logTicksX: [from: 0, to: 3, minor: true],   // 10^0 .. 10^3
    yRange: [-2, 2],
    xAxisLabel: r"$f$"
)
// Plain Groovy
def ax = Axes.make()
ax.setXScale(AxisScale.logarithmic(10, 0, 1.2))   // base, origin, unit
ax.generateLogXTicks(0, 3, true)                  // fromExponent, toExponent, minor
ax.generatePrimaryYTicks(-2, 2, 1)
ax.setXAxisLabel(r"$f$")
add(ax)

The value v is drawn at origin + unit * log_base(v), so consecutive powers of the base are one unit apart. Primary ticks land on the powers and are labelled 10^n; the minor ones are the intermediate multiples (2, 3, ..., 9 of each decade) and are drawn as marks with no label, because their labels would pile up at the top of each decade.

Only strictly positive values exist on a logarithmic axis. Asking for a tick at 0 or at a negative value logs a warning and adds nothing.

Since origin is where the value 1 sits on a logarithmic scale, that is also where the perpendicular axis crosses.

Placing objects by axis value

Once an axis has a scale, writing scene coordinates by hand is a losing game. Ask the axes instead:

def p  = dot(ax.toWorld(40, 1))          // the point at (40, 1) in axis values
def v  = ax.toData(0, 0)                 // which values are at the scene origin

Both work whatever scales the axes carry, and are the identity when they carry none.

Working with the ticks after creating them

ax.xticksBase          // live list of every x-tick, visible or not
ax.yticksBase
ax.getXticks()         // snapshot of the ones the current camera shows

Each element is a TickAxes:

Member What it is
dataValue the value of the axis it stands for
location the scene coordinate where it is drawn (differs from dataValue if the axis has a scale)
tick the mark, a Shape
legend the label, a LatexMathObject
tickType PRIMARY or SECONDARY
setLegendVisible(b) draw the label or not
// Everything from 1 on in red, and the label of 3 twice as big
ax.xticksBase.findAll { it.dataValue >= 1 }.each { it.legend.drawColor("red") }
ax.xticksBase.find { it.dataValue == 3 }?.legend?.scale(2)
ax.refreshDisplayTicks()

Call refreshDisplayTicks() after changing sizes. Colors and visibility take effect on their own.

How it behaves with the camera

Worth knowing, because it explains a few things that look odd otherwise:

  • Ticks, arrow heads and axis names keep a constant size on screen. When you zoom out they grow in math units so that they look the same. That is why legend.getHeight() gives a different number depending on the zoom.
  • Ticks outside the visible area are not drawn, and secondary ticks vanish when the view gets wider than their maxWidth.
  • The axes are updated only when the camera actually moves. A static scene does no work at all, so leaving axes on screen costs nothing per frame.
  • The bounding box of the axes is the whole visible area, and it is deliberately left out of the camera fitting computations. If you use a command that adjusts the camera to fit the objects, the axes will not fight it.

Parameter reference

All DSL keys of axes(...). Every one is optional.

Key Type What it does
range [from, to] shorthand for xRange and yRange
xRange / yRange [from, to] range of the automatic ticks
xStep / yStep double step of the automatic ticks (default 1)
secondary Map shorthand for secondaryX and secondaryY
secondaryX / secondaryY Map {from, to, step, maxWidth} secondary ticks
ticksX / ticksY List individual ticks: a number, or a map {at, label, type, maxWidth, scale, style, markStyle, showLabel}
style Map style of the whole object
axisStyle Map style of both axis lines
xStyle / yStyle Map style of one axis line
xTickStyle / yTickStyle Map style of the marks
xLegendStyle / yLegendStyle Map style of the labels
labelScale double size of every label
xLabelScale / yLabelScale double size of the labels of one axis
format String number pattern for automatic labels
xFormat / yFormat String number pattern for one axis
xScale / yScale Map {origin, unit} or {log, origin, unit} mapping from values to scene coordinates
logTicksX / logTicksY Map {from, to, minor} ticks at powers of the base
axisRange [from, to] shorthand for xAxisRange and yAxisRange
xAxisRange / yAxisRange [from, to] draw that axis as a segment instead of an infinite line
arrows boolean arrow head at the positive end of each axis
xAxisLabel / yAxisLabel String latex name at the end of the axis
addToScene boolean add to the scene (default true for axes)
layer, visible common to every object

And the methods used above, for the plain Groovy side:

Method What it does
Axes.make() empty axes, no ticks
Axes.make(min, max) axes with integer ticks from min to max on both axes
generatePrimaryXTicks(from, to, step) labelled ticks (also ...YTicks)
generateSecondaryXTicks(from, to, step, maxWidth) short ticks (also ...YTicks)
generateLogXTicks(fromExp, toExp, minor) ticks at powers of the base (also ...YTicks)
addXTicksLegend(latex, x, type[, maxWidth]) tick with an explicit label, replaces any tick there; returns it
addXTicksLegend(x, type[, maxWidth]) tick with an automatic label, does not replace; returns it
addXTickMark(x, type, maxWidth) mark with no visible label; returns it
setXScale(scale) / setYScale(scale) mapping from values to scene coordinates
getXScale() / getYScale() the current mapping
toWorld(x, y) / toData(x, y) convert between axis values and scene coordinates
setXFormat(f) / setYFormat(f) number format of one axis
setFormat(f) / getFormat() number format shared by both
setXAxisRange(from, to) / setYAxisRange(from, to) draw the axis as a segment
clearAxesRanges() back to infinite axes
setArrows(b) / hasArrows() arrow heads
setXAxisLabel(latex) / setYAxisLabel(latex) name at the end of the axis
getxAxis() / getyAxis() the Line of each axis, the style handle
getXticksBase() / getYticksBase() live list of every tick
getXticks() / getYticks() the ticks the camera currently shows
refreshDisplayTicks() place the ticks again after changing a label size

And the scale factories:

Method What it does
AxisScale.identity() values are scene coordinates, the default
AxisScale.linear(origin, unit) value v at origin + unit * v
AxisScale.logarithmic(base, origin, unit) value v at origin + unit * log_base(v)

home back