Groovy lists: quick reference for JMathAnim scripts

Recipes for the list operations that show up while writing scenes: slicing, zipping, sliding windows, building maps, and the JMathAnim specific bits (coordinates, groups, formulas, animation lists).

Every snippet was checked against Groovy 5.0.0, the version used by jmathanim-core. Unless a recipe says otherwise, the methods return a new list and leave the original untouched.


1. Creating and reading

def L = [10, 20, 30, 40]      // a list
def empty = []                // an empty one
def r = 0..5                  // a range: 0,1,2,3,4,5   (0..<5 stops at 4)
def nums = (0..<5).toList()   // a range turned into a real list
Recipe Code Result
First element L.first() or L.head() or L[0] 10
Last element L.last() or L[-1] 40
n-th from the end L[-2] 30
Size L.size() 4
Is it empty L.isEmpty() or !L false
Position of a value L.indexOf(30) 2
Position by condition L.findIndexOf { it > 15 } 1
As text L.join(", ") "10, 20, 30, 40"

A range unpacks into separate arguments with the spread operator *:

def all = [*0..3, 99]         // [0, 1, 2, 3, 99]

2. Removing the first or the last element

The whole point: tail/init return a copy, removeAt modifies the list.

Want Code Result on [10,20,30,40]
Without the first L.tail() [20, 30, 40]
Without the last L.init() [10, 20, 30]
Without the first n L.drop(2) [30, 40]
Without the last n L.dropRight(2) [10, 20]
Only the first n L.take(2) [10, 20]
Only the last n L.takeRight(2) [30, 40]
Without the first (slice) L[1..-1] [20, 30, 40]
Without the last (slice) L[0..-2] [10, 20, 30]
Remove in place L.removeAt(0) L becomes [20, 30, 40]
Remove last in place L.removeLast() L becomes [10, 20, 30]
def inner = pts.tail().init()          // drop both ends
def rest  = objs.drop(1)               // everything but the first object

Trap. The - operator removes by value, every match, it is not an index: [1,2,2,3] - 2 gives [1,3]. To remove a position use removeAt or a slice.

Trap. tail(), init() and first() throw on an empty list. Guard with if (L) or use L.take(1) / L.drop(1), which are always safe.


3. Slices and sublists

L[1..2]          // [20, 30]      a range of positions
L[0, 2]          // [10, 30]      pick positions one by one
L[1..-1]         // [20, 30, 40]  negative index counts from the end
L[-2..-1]        // [30, 40]
L.reverse()      // [40, 30, 20, 10]
L.unique(false)  // removes duplicates without touching L

unique() and sort() modify the list; pass false to get a copy instead:

def sorted = L.sort(false)             // L stays as it was
def byName = objs.sort(false) { it.getCenter().x }

4. Combining two lists element by element

transpose() is the workhorse: it pairs the n-th of each list.

def a = [1, 2, 3]
def b = ["x", "y", "z"]

[a, b].transpose()                     // [[1,x], [2,y], [3,z]]
a.zip(b)                               // the same, only for exactly two lists

To do something with each pair, name the two parts in the closure:

def labels = [a, b].transpose().collect { n, s -> "$s$n" }   // [x1, y2, z3]

[a, b].transpose().each { n, s -> println "$s -> $n" }

More than two lists work the same way:

[a, b, [true, false, true]].transpose()   // [[1,x,true], [2,y,false], [3,z,true]]

The index-based version, when you also need the position:

def out = (0..<a.size()).collect { i -> a[i] + b[i] }

Trap. If the lists have different lengths, transpose() truncates to the shortest one, silently. Check a.size() == b.size() when it matters.

All pairs instead of matching pairs (cartesian product, a JMathAnim extension on lists):

def grid = [0, 1] * [0, 1, 2]    // [[0,0],[0,1],[0,2],[1,0],[1,1],[1,2]]

The first list is the outer one. Plain Groovy has [a, b].combinations(), which takes any number of lists but varies the first one fastest.


5. Each element with the next one (sliding window)

collate(size, step, keepPartial) is the general tool. For consecutive pairs:

L.collate(2, 1, false)      // [[10,20], [20,30], [30,40]]
Want Code Result on [10,20,30,40]
Consecutive pairs L.collate(2, 1, false) [[10,20],[20,30],[30,40]]
Consecutive triples L.collate(3, 1, false) [[10,20,30],[20,30,40]]
Disjoint blocks of 2 L.collate(2) [[10,20],[30,40]]
Cyclic pairs (closing the loop) (L + [L.first()]).collate(2, 1, false) [[10,20],[20,30],[30,40],[40,10]]
Same, with transpose [L.init(), L.tail()].transpose() [[10,20],[20,30],[30,40]]

Then work on the pairs:

def steps = L.collate(2, 1, false).collect { x, y -> y - x }   // [10, 10, 10]

Trap. The third argument matters. L.collate(2, 1) defaults to keepPartial = true and leaves a trailing incomplete group [40], which breaks any closure expecting two parameters. Always pass false for windows.

Groovy has no eachCons. collate(n, 1, false) is the equivalent, and it returns [] for a list shorter than the window, which is the safe behaviour.


6. Building a map from two lists

def keys   = ["a", "b", "c"]
def values = [1, 2, 3]

def m = [keys, values].transpose().collectEntries()      // [a:1, b:2, c:3]

collectEntries() with no argument reads a list of [key, value] pairs. With a closure you decide what the key and the value are:

[keys, values].transpose().collectEntries { k, v -> [(v): k] }   // [1:a, 2:b, 3:c]

The parentheses around (v) are required: without them Groovy takes v as the literal key name.

Other map recipes:

keys.collectEntries { [(it): it.toUpperCase()] }   // key list -> computed values
[a:1, b:2].collectEntries { k, v -> [(v): k] }     // swap keys and values
[a:1, b:2, c:3].subMap(["a", "c"])                 // [a:1, c:3]
[a:1, b:2].keySet() as List                        // [a, b]
[a:1, b:2].values() as List                        // [1, 2]
[a:1, b:2].collect { k, v -> "$k=$v" }             // [a=1, b=2]
[a:1, b:2].each { k, v -> println "$k $v" }
[a:1, b:2].find { k, v -> v > 1 }?.key             // b

Maps keep insertion order, so collectEntries over an ordered list gives an ordered map. That is what group(obj: map) relies on for naming elements.


7. Transforming, filtering, folding

Operation Groovy Alias added by JMathAnim
Transform each element collect { } map { }
Keep those matching findAll { } filter { }
Transform and flatten collectMany { } flatMap { }
Fold into one value inject(init) { a, b -> } reduce(init) { a, b -> }
def doubled = [1, 2, 3].collect { it * 2 }          // [2, 4, 6]
def evens   = (1..10).findAll { it % 2 == 0 }       // [2, 4, 6, 8, 10]
def flat    = [[1,2],[3]].collectMany { it }        // [1, 2, 3]
def total   = [1, 2, 3].inject(0) { acc, x -> acc + x }   // 6

Shortcuts worth knowing:

objs*.getCenter()                  // spread-dot: calls the method on each element
[1, 2, 3].sum()                    // 6
[1, 2, 3].sum { it * it }          // 14, sum of a computed value
[1, 2, 3].average()                // 2
[[1,2],[3,[4]]].flatten()          // [1, 2, 3, 4]

With the index. withIndex() pairs each element with its position:

objs.withIndex().collect { obj, i -> animShift(obj: obj, dx: i * 0.5, run: false) }
objs.withIndex(1).each { obj, n -> println "object number $n" }   // start at 1
objs.eachWithIndex { obj, i -> obj.shift(i * 0.5, 0) }            // for side effects

Generating a list instead of consuming one:

def n = 6
def angles = (0..<n).collect { 2 * PI * it / n }        // n evenly spaced angles
def ts     = (0..<n).collect { it / (n - 1d) }          // n values from 0 to 1
def xs     = (0..10).step(2)                            // [0, 2, 4, 6, 8, 10]
def copies = (0..<n).collect { proto.copy() }

Note the 1d in the second line: it / (n - 1) with two integers is still integer division in some contexts, so force a double when in doubt.


8. Order, grouping, searching

L.max()                       // 40
L.min()                       // 10
objs.max { it.getWidth() }    // element with the largest computed value
L.find { it > 15 }            // 20, the first match (null if none)
L.findAll { it > 15 }         // [20, 30, 40], all matches
L.any { it > 35 }             // true
L.every { it > 5 }            // true
L.count { it > 15 }           // 3
L.split { it > 25 }           // [[30,40], [10,20]]  matching first, then the rest
(1..6).groupBy { it % 3 }     // [1:[1,4], 2:[2,5], 0:[3,6]]
[1,2,3].intersect([2,3,4])    // [2, 3]
([1,2] + [2,3]).unique(false) // [1, 2, 3], the union

9. Lists in JMathAnim

Coordinates

Anywhere a Vec is expected, a plain list of 2 or 3 numbers works:

def v = [1, 2] as Vec
def P = [1, 2] as Point
def s = shape(type: "segment", from: [0, 0], to: [1, 1])

Scaling a list scales every element, and the result is still a valid coordinate:

def half = 0.5 * [1, 3]       // [0.5, 1.5]
def back = [1, 3] / 2         // [0.5, 1.5]

Trap, and an important one. The scaling * only works with the number on the left. [1, 3] * 2 is Groovy's own list repetition and gives [1, 3, 1, 3]. Write 2 * [1, 3], or [1, 3] / 0.5 if you prefer the object on the left.

Trap. + between lists concatenates: [1, 2] + [3, 4] is [1, 2, 3, 4], not a vector sum. The DSL depends on this to join lists of objects. To add vectors, convert first: ([1, 3] as Vec) + [1, 0].

Groups and formulas are iterables

MathObjectGroup, LatexMathObject and the other containers implement Iterable, so every method in this document works on them directly:

g.each { it.fillColor("orange") }
g.collect { it.getCenter() }
g.collate(2, 1, false)                 // consecutive pairs of elements
formula.findAll { it.getWidth() > 0.2 }

What you get back is always a plain list, never a group. Rebuild one when you need it:

def sub = [g[0], g[2]] as MathObjectGroup
def all = g as List                    // explicit conversion, rarely needed

Indexing is JMathAnim's own: g[0] is the n-th element, g[-1] the last, g[0..2] a list of the first three, and g["title"] the element stored under that name.

Adding to the scene

<< accepts a single object, a list, or a map of named parts:

scene << circle
scene << [circle, square, dot]
scene << (0..<5).collect { dot + [it * 0.5, 0] }   // + returns a shifted copy
group << [a, b]
group << [head: a, body: b]            // named elements

Recipe: segments joining consecutive points

The classic use of the sliding window.

def pts = [[0, 0], [1, 0.5], [2, -0.3], [3, 0.8]]
def segs = pts.collate(2, 1, false).collect { p, q ->
    shape(type: "segment", from: p, to: q, style: [drawColor: "steelblue"])
}
scene << segs

Closing the polygon is one + away:

def closed = (pts + [pts.first()]).collate(2, 1, false)

Recipe: pairing objects with styles

def colors = ["red", "green", "blue"]
[g, colors].transpose().each { obj, c -> obj.fillColor(c) }

Same idea against a formula, one colour per glyph:

[formula, colorList].transpose().each { glyph, c -> glyph.color(c) }

Recipe: a named group from two lists

A map in obj: names the elements, and the keys keep their writing order, so two parallel lists become a group whose pieces are reachable by name.

def names = ["title", "body", "footer"]
def objs  = [latex(text: r"$E=mc^2$"), shape(type: "circle"), text(text: "bottom")]

def g = group(obj: [names, objs].transpose().collectEntries(),
              layout: "lower", gap: 0.2)

g["body"].fillColor("gold")

Recipe: a list of animations built in a loop

Children of a container must be built with run: false, otherwise each one plays on its own while the argument list is evaluated.

def anims = objs.withIndex().collect { obj, i ->
    animShift(obj: obj, dx: 0.5 * (i + 1), run: false)
}
animGroup(anims: anims, delay: 0.3)      // staggered, all together
animJoin(anims: anims, gap: 0.2)         // one after another

The shortest form, when no extra parameter is needed:

[a1, a2] as AnimationGroup       // played together
[a1, a2] as JoinAnimation        // played one after another

Recipe: a grid without nested loops

def dot = point()
scene << ((0..<5) * (0..<4)).collect { dot + it }    // 20 points, one per [i,j] pair

Each pair is a [i, j] list, so it feeds straight into the shifting +.

Recipe: polygon points computed on the fly

def n = 7
def verts = (0..<n).collect { [Math.cos(2 * PI * it / n), Math.sin(2 * PI * it / n)] }
def poly = shape(type: "polygon", points: verts)

Raw lists of LaTeX strings

The r prefix applies to a whole list, nested lists and maps included, which saves escaping every backslash:

def texts = r["$\alpha$", "$\beta$", "$\gamma$"]

It needs two elements or more; a one element list is written [r"$\pi$"].


10. Trap summary

Looks like Actually does Do instead
[1, 3] * 2 Repeats the list, [1,3,1,3] 2 * [1, 3] to scale
[1, 2] + [3, 4] Concatenates ([1,2] as Vec) + [3,4] to add
L - 2 Removes every element equal to 2 L.removeAt(i) or a slice
L.collate(2, 1) Leaves a trailing partial group L.collate(2, 1, false)
[a, b].transpose() with different sizes Truncates to the shortest, silently Check the sizes first
L.sort(), L.unique() Modify L L.sort(false), L.unique(false)
L.first() on an empty list Throws if (L), or L.take(1)
g.collect { } on a group Returns a plain list as MathObjectGroup to rebuild
collectEntries { [it: v] } Key is the literal string "it" collectEntries { [(it): v] }
it / (n - 1) with ints Integer division it / (n - 1d)

See also

  • A Groovy survival kit, the user facing introduction, with the full list of operator shortcuts.
  • DSL cheatsheet, for the parameters of every DSL block.
  • ListExtensions.groovy in jmathanim-core, where the list extensions (as Vec, as MathObjectGroup, scaling, cartesian product, the map/ filter/reduce aliases) are defined.