Playing and Exporting Coordinate Animations

For a task index, see Finding your way around ivue; for small reproducible inputs, see Example data and recipes.

animate.frames() turns recorded coordinates into a browser player. write.animation.gif() exports the same retained frames to a GIF for a README, presentation, or website. Playback requires rgl; GIF export additionally requires magick:

install.packages(c("rgl", "magick"))

On this page: Watch a Sierpinski triangle unfold · Supply your own frames · Animate a changing 3D surface · Export interactive HTML · Export a GIF · Control size and preserve diagnostic meaning

Watch a Sierpinski triangle unfold

Generate a level-4 Sierpinski triangle graph, then record its two-dimensional layout with grip::trace.grip(). The optional grip package supplies both the graph constructor and the layout algorithm; install it with install.packages("grip") to run this example.

edges <- grip::edges.sierpinski.triangle(level = 4)
tr <- grip::trace.grip(
  edges, n = max(edges), dim = 2, preset = "carpet", seed = 1,
  trace = "round", trace.every = 1
)
length(tr$frames)
#> [1] 643
dim(tr$frames[[1]])
#> [1] 123   2
utils::packageVersion("grip")
#> [1] '0.1.2'

The trace contains coordinates after successive layout rounds, including rows for vertices that have not yet been introduced. Select up to 24 evenly spaced frames, including the first and last, to keep the player compact. Retain their metadata and original frame numbers for the labels:

frame.index <- unique(as.integer(round(seq(
  1, length(tr$frames), length.out = min(24L, length(tr$frames))
))))
triangle.frames <- tr$frames[frame.index]
triangle.meta <- tr$meta[frame.index, , drop = FALSE]
head(triangle.meta[, c("phase", "active_vertices")])
#>     phase active_vertices
#> 1    init              28
#> 29  round              28
#> 57  round              28
#> 85  round              28
#> 113 round              28
#> 141 round              28
triangle.labels <- paste0("Frame ", frame.index,
                           " | ", triangle.meta$phase,
                           " | ", triangle.meta$active_vertices, " vertices")

No coordinates are aligned to a target shape, rotated, or normalized. The seed fixes the random input to the installed GRIP implementation; the resulting trace can change between package versions, so its version is printed above.

triangle.player <- animate.frames(
  triangle.frames, edges = edges, labels = triangle.labels,
  fps = 5, col = "#C24E25", point.size = 4,
  edge.col = "#314E6ECC", edge.width = 1,
  background.color = "#FAF7F0", height = 450
)
triangle.player

Coordinate animation of 123 observations across 24 retained frames.

Press Play to start; the button becomes Pause. Drag the slider to inspect a particular frame. With the slider focused, use the arrow keys to move one frame at a time. Slower and Faster change playback speed, Reverse changes direction, and Reset returns to the beginning. Drag inside the scene to rotate it, including during playback. The default 2D camera looks straight down onto the xy plane.

The recorded frames all have the same number of rows. A vertex that has not yet been introduced has an entirely missing (NA or NaN) row. Its point and all incident edges stay hidden until its coordinates are finite. Rows remain associated with the same vertices throughout playback; they must not be reordered.

Supply your own frames

Frames are a list of numeric matrices with two or three columns. Every matrix must have identical dimensions. If row names are supplied, they must be unique and identical across frames. A row must be completely finite or completely missing (NA or NaN); partial missing coordinates and infinity are errors. GRIP records its inactive vertices using NaN rows.

Here a triangle gains its third vertex and then expands. The second edge appears only when its missing endpoint is introduced.

X <- rbind(a = c(0, 0), b = c(1, 0), c = c(0.5, 0.9))
first <- X
first[3, ] <- NA
small <- animate.frames(
  list(first, X, X * 1.4),
  edges = rbind(c(1, 2), c(2, 3), c(3, 1)),
  labels = c("Two vertices", "Triangle", "Expanded triangle"),
  fps = 1, loop = FALSE, point.size = 9, height = 280
)
small

Coordinate animation of 3 observations across 3 retained frames.

Vertices may disappear as well as appear. An entirely empty frame is allowed, provided at least one frame contains a finite point. The stricter finite-coordinate requirement of ordinary plot3D functions is unchanged.

Animate a changing 3D surface

Three static views show a plane, halfway deformation, and a saddle, using the same framing. Blue and red colors stay fixed and describe final saddle height, even in the flat frame.
Three static views show a plane, halfway deformation, and a saddle, using the same framing. Blue and red colors stay fixed and describe final saddle height, even in the flat frame.

The poster shows three of the recorded states below with a common spatial scale. All players start paused, including for readers who prefer reduced motion. Use the labeled frame slider with the arrow keys to inspect one frame at a time without starting playback.

Any sequence satisfying the frame contract can be played. This example explicitly constructs a sequence from a plane to a saddle; it is an illustrative deformation, not the output of a layout optimizer. The xy positions and vertex identities are fixed while the height changes. A 7-by-7 grid and 17 recorded amplitudes keep this installed example compact; the later paraboloid-to-saddle example uses 33 stages.

side <- 7L
grid <- expand.grid(x = seq(-1, 1, length.out = side),
                    y = seq(-1, 1, length.out = side))
amplitudes <- seq(0, 1.2, length.out = 17)
saddle.frames <- lapply(amplitudes, function(a) {
  cbind(x = grid$x, y = grid$y, z = a * (grid$x^2 - grid$y^2))
})
ids <- matrix(seq_len(nrow(grid)), side, side)
saddle.edges <- rbind(
  cbind(as.vector(ids[-side, ]), as.vector(ids[-1, ])),
  cbind(as.vector(ids[, -side]), as.vector(ids[, -1]))
)
final.heights <- saddle.frames[[length(saddle.frames)]][, "z"]
height.scale <- color.scale.cont(final.heights, center = 0,
                                 palette = c("#2455A4", "#ECE6C2", "#B83232"))
height.mapping <- map.colors(final.heights, height.scale)
point.colors <- height.mapping$colors
saddle.player <- animate.frames(
  saddle.frames, edges = saddle.edges,
  labels = sprintf("Saddle amplitude = %.2f", amplitudes),
  mapping = height.mapping, legend.title = "Final saddle height",
  caption = "Color: final saddle height; positions: current frame.",
  description = "Plane to saddle, with fixed colors for final saddle height.",
  point.size = 6, edge.col = "#314E6E99",
  camera = camera.zup(elevation = 25, turn = -130),
  fps = 8, height = 450
)
saddle.player

Plane to saddle, with fixed colors for final saddle height.

Color: final saddle height; positions: current frame.

Colors describe each vertex’s final saddle height and remain fixed across frames. This makes identity easy to follow; they do not encode instantaneous height. Frame-dependent colors and animated mesh faces are outside this initial API. edges draws a wire grid without filling its faces.

From a paraboloid through a flat grid to a saddle

Reuse the same grid, edges, and colors, but change the height formula to pass through three shapes. Let t run from -1 to 1 and set \[ z(t) = 1.2\bigl(|t|x^2 - t y^2\bigr). \] At t = -1 this is the upward-opening paraboloid z = 1.2 * (x^2 + y^2). At t = 0 all heights are zero, and at t = 1 the surface is the saddle z = 1.2 * (x^2 - y^2). An odd number of equally spaced frames places the flat grid exactly at the slider’s midpoint.

stages <- seq(-1, 1, length.out = 33)
surface.frames <- lapply(stages, function(t) {
  cbind(x = grid$x, y = grid$y,
        z = 1.2 * (abs(t) * grid$x^2 - t * grid$y^2))
})
surface.labels <- sprintf("%s | t = %.2f",
  ifelse(stages < 0, "Paraboloid", ifelse(stages == 0, "Flat grid", "Saddle")),
  stages)
surface.player <- animate.frames(
  surface.frames, edges = saddle.edges, labels = surface.labels,
  mapping = height.mapping, legend.title = "Final saddle height",
  caption = "Color: final saddle height; positions: current frame.",
  description = "Paraboloid through a plane to a saddle, with fixed colors for final saddle height.",
  point.size = 6, edge.col = "#314E6E99",
  camera = camera.zup(elevation = 25, turn = -130),
  fps = 8, height = 450
)
surface.player

Paraboloid through a plane to a saddle, with fixed colors for final saddle height.

Color: final saddle height; positions: current frame.

Move the slider to the center to inspect the flat grid (frame 17 of 33). Use Pause to hold a view and Reverse to change the playback direction. Colors still represent final saddle height, not the height in the current frame.

Export interactive HTML

The returned object is an ordinary htmlwidget. Saving it preserves the player, caption, readable color legend, and camera controls and does not require Shiny or a running R session for playback. No file is saved merely by constructing a player.

htmlwidgets::saveWidget(triangle.player, "triangle-playback.html", selfcontained = TRUE)
htmlwidgets::saveWidget(saddle.player, "saddle-playback.html", selfcontained = TRUE)

Self-contained HTML requires Pandoc and bundles widget assets into one file. Use selfcontained = FALSE to save the HTML alongside a dependency folder; keep that folder with the HTML when sharing it.

Export a GIF

Use annotations = TRUE to carry the fixed color legend and plain-text caption into the GIF. The raster renderer reserves space beside and below the scene; it does not capture arbitrary HTML. Text wraps at a readable size, and an export that cannot fit its annotations asks for larger dimensions.

GIF export uses the frames retained in the player, including inactive vertices and edges. This build checks the small three-frame triangle; the larger trace can be exported with the same function in your own session. The example writes only temporary output and removes it after checking the file. No GIF asset is embedded in the installed guide.

local({
  gif.path <- tempfile(fileext = ".gif")
  on.exit(unlink(gif.path))
  write.animation.gif(small, gif.path, fps = 1,
                       width = 240, height = 240, final.hold = 0)
  stopifnot(file.exists(gif.path))
})
write.animation.gif(saddle.player, "saddle.gif", fps = 8,
                     width = 720, height = 560, final.hold = 2,
                     annotations = TRUE,
                     loop = TRUE, overwrite = FALSE)

The GIF has the widget’s initial camera orientation, not a rotation made later in the browser. Set camera explicitly when constructing the player to choose the exported view. Export requires an orthographic camera (fov = 0), which is the animation default. It renders a diagram from the recorded coordinates using R graphics and magick; it is not a screenshot of the WebGL scene. Smaller camera$zoom values enlarge the scene in both outputs. Use matching dimensions and annotation layouts when comparing GIF spatial scale; legend and caption space reduces the scene area. Point appearance can differ slightly. Edges are painted before points, so complex 3D intersections do not have WebGL depth-buffer semantics.

The last frame has final.hold additional seconds per loop. GIF frame delays are rounded to centiseconds. Existing files are protected unless you set overwrite = TRUE.

Control size and preserve diagnostic meaning

Playback changes only which recorded frame is displayed. It does not interpolate missing frames, align to a target, or recenter each frame. All original frames determine one fixed viewing box, so translation, contraction, and expansion remain visible. Rotating or zooming the camera changes the view, not the stored coordinates.

By default, at most 100 evenly spaced frames are retained, including the first and last. If this limit is exceeded, a message reports the subsampling. For a diagnostic inspection, choose frames explicitly or retain all of them:

inspect.index <- unique(as.integer(round(seq(
  1, length(triangle.frames), length.out = min(5L, length(triangle.frames))
))))
selected <- animate.frames(triangle.frames, edges,
                            frame.index = inspect.index,
                            labels = triangle.labels, fps = 2)
attr(selected, "ivue.animation")$frame.index
#> [1]  1  7 12 18 24
all.frames <- animate.frames(my.frames, edges = my.edges, max.frames = NULL)

Every retained frame has equal duration. A subsampled animation therefore shows the order of the solve, not elapsed computation time. Widget size grows with both frame count and graph size; large traces benefit from an explicit selection. The exported GIF uses that same selection.

The same interface accepts three-dimensional traces and frames recorded by other solvers. Neither animate.frames() nor GIF export calls grip or computes a layout.