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 pressCtrl+Spaceto see every parameter with its description. Inside a sub-map, likexScale: [, pressCtrl+Spaceagain for the keys of that sub-map.
Table of Contents
- The quick version
- Ticks
- Automatic ticks
- Secondary ticks
- Individual ticks
- Ticks with no label
- Styling
- The axes themselves
- Marks and labels
- One tick at a time
- Label size
- Number format
- Bounded axes, arrow heads and names
- Number lines: one axis only
- Scales: separating values from coordinates
- Shifted origin and a different unit
- Logarithmic axes
- Placing objects by axis value
- Working with the ticks after creating them
- How it behaves with the camera
- Parameter reference
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 withxStep: 1replaces the automatic1witha. 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) |