Example data and reusable visualization recipes

A reusable ivue example starts with coordinates, observation IDs, annotations, and the meaning of any connections or frames. This guide constructs three small illustrative inputs in base R; it does not fit an embedding or propose a dataset-generation API. Use the function guide to choose an entry point and the introduction for extended plotting controls.

Choose a recipe

What you need Functions to combine What to retain or share
Finite n-by-3 positions and one numerical value per row color.scale.cont(), plot3D.cont() Coordinates, IDs, scale, initial camera, HTML widget.
The same positions and category labels color.scale.groups(), plot3D.groups() Named palette, level order, annotations, HTML widget.
A vertex set, weighted edges, and supplied positions prepare.graph(), plot3D.graph(), optional layers Vertex IDs, normalized edge order, weight meaning, coordinates.
Triangle indices or an independent height grid layer3D.mesh() or layer3D.surface() with a point plot Connectivity or grid coordinates; these have different reuse semantics.
Stable observation rows over two or more frames map.colors(), animate.frames() Original frames and labels, retained indices, fixed colors, player.
A finished static or animated view htmlwidgets::saveWidget(); for animation, write.animation.gif() Interactive HTML plus dependencies, or a noninteractive GIF.

Data, scales, cameras, and layer specifications can be kept together in an R list and saved with base R’s saveRDS(). Keep the original scientific inputs and preparation choices as well as the widget.

Construct a point cloud

This helix samples 24 equally spaced parameter values from one complete turn. Height is a numerical annotation; the sign of height defines two illustrative categories. These categories are assigned by construction, not discovered clusters. The first and last points share horizontal position but differ in height, so they are distinct observations.

n <- 24L
t <- seq(0, 2 * pi, length.out = n)
X <- cbind(x = cos(t), y = sin(t), z = seq(-1, 1, length.out = n))
ids <- sprintf("point-%02d", seq_len(n))
rownames(X) <- ids
annotations <- data.frame(
  id = ids, height = X[, "z"],
  half = factor(ifelse(X[, "z"] < 0, "Lower", "Upper"),
                levels = c("Lower", "Upper"))
)
# Simulate an annotation table arriving in another order, then align it.
annotations <- annotations[rev(seq_len(n)), ]
stopifnot(!anyNA(annotations$id), !anyDuplicated(annotations$id),
          setequal(annotations$id, rownames(X)))
annotations <- annotations[match(rownames(X), annotations$id), ]
stopifnot(identical(annotations$id, rownames(X)),
          identical(dim(X), c(n, 3L)), all(is.finite(X)))
head(annotations, 3)
#>                id     height  half
#> point-01 point-01 -1.0000000 Lower
#> point-02 point-02 -0.9130435 Lower
#> point-03 point-03 -0.8260870 Lower

Explicit table matching is useful when keeping several annotation columns together. Alternatively, pass a named annotation vector: point and graph plots both match its names to observation IDs, regardless of vector order. For example, the following reversed vector still associates each height with its own point:

named.height <- setNames(annotations$height, annotations$id)
named.height <- named.height[rev(seq_along(named.height))]

Named vectors must cover the coordinate IDs exactly. Duplicate, missing, empty, partial, or extra names are errors, as are named point annotations without explicit coordinate row names. Automatic data-frame row numbers are not IDs. Unnamed vectors, including table columns extracted with $, follow row position; unname() explicitly requests that behavior for a named vector. If you subset or reorder coordinates, rebuild any row-indexed connectivity. Missing positions are errors in static plots. Missing annotation values can remain and receive the scale’s missing color.

Share a scale and camera

Compare height before and after an illustrative change to the annotation, while keeping positions and row identity fixed. The second value is half the first; it is not another embedding. A single numerical scale makes that reduction visible. Separate automatic scales would color both panels almost identically despite their different values.

original <- annotations$height
reduced <- original / 2
scale <- color.scale.cont(c(original, reduced), limits = c(-1, 1), center = 0,
                          palette = c("#2166AC", "#F7F7F7", "#B2182B"))
camera <- camera.zup(elevation = 20, turn = -135, fov = 0, zoom = 0.65)
original.mapping <- map.colors(original, scale)
reduced.mapping <- map.colors(reduced, scale)
stopifnot(identical(original.mapping$legend, reduced.mapping$legend))
original.mapping$legend
#>   label     color count
#> 1  -1.0 #2166ACFF    NA
#> 2  -0.5 #8AADD1FF    NA
#> 3   0.0 #F6F6F6FF    NA
#> 4   0.5 #D48791FF    NA
#> 5   1.0 #B2182BFF    NA
original.view <- plot3D.cont(X, named.height, scale = scale, camera = camera,
  aspect = "equal", point.size = 7, height = 360,
  legend.title = "Original height", legend.width = 120)
reduced.view <- plot3D.cont(X, reduced, scale = scale, camera = camera,
  aspect = "equal", point.size = 7, height = 360,
  legend.title = "Half height", legend.width = 120)
stopifnot(identical(attr(original.view, "ivue")$mapping$colors,
                    original.mapping$colors),
          identical(attr(original.view, "ivue")$observation.ids, ids))
original.view

Interactive 3D view of 24 observations.

reduced.view

Interactive 3D view of 24 observations.

Both widgets start with identical coordinates, IDs, point sizes, aspect, projection, camera, and scale limits. Colors alone encode the changed values; no coordinate scaling or alignment occurs. At the negative endpoint the original view is dark blue, while half height has a lighter blue. Each widget rotates independently: this workflow does not synchronize interaction. For a noninteractive reader, the following small table records the same change.

rows <- c(1L, 12L, 24L)
data.frame(id = ids[rows], original = original[rows], reduced = reduced[rows],
           original.color = original.mapping$colors[rows],
           reduced.color = reduced.mapping$colors[rows])
#>         id    original     reduced original.color reduced.color
#> 1 point-01 -1.00000000 -0.50000000      #2166ACFF     #8AADD1FF
#> 2 point-12 -0.04347826 -0.02173913      #ECEFF3FF     #F1F3F4FF
#> 3 point-24  1.00000000  0.50000000      #B2182BFF     #D48791FF

A categorical alternative uses the same observation order. This constructs and checks another view without adding a third embedded widget to the guide. Print group.view interactively to display it.

group.scale <- color.scale.groups(annotations$half,
                                  colors = c(Lower = "#2166AC", Upper = "#B2182B"))
map.colors(annotations$half, group.scale)$legend
#>   label   color count
#> 1 Lower #2166AC    12
#> 2 Upper #B2182B    12
group.view <- plot3D.groups(X, annotations$half, scale = group.scale,
                            camera = camera, height = 300,
                            legend.width = 120, legend.title = "Helix half")
stopifnot(inherits(group.view, "htmlwidget"))

Equal colors across views establish a shared annotation mapping. Equal cameras establish an initial orientation. Neither establishes equivalent geometries, clustering quality, a developmental trajectory, or biological causation.

Prepare a small embedded weighted graph

These four vertices and two connections are chosen for illustration. The weights represent supplied target distances; they are not inferred from the drawn segment lengths. The fourth vertex is isolated and is still retained.

vertices <- c("start", "bend", "end", "isolate")
edges <- data.frame(from = c("start", "bend"), to = c("bend", "end"),
                    weight = c(2, 4))
coords <- rbind(start = c(0, 0, 0), bend = c(1, 1, 0),
                end = c(2, 0, 1), isolate = c(0, 2, 1))
graph <- prepare.graph(edges, vertices = vertices, weight.type = "distance")
stopifnot(identical(graph$vertices$id, vertices),
          nrow(graph$edges) == 2L, all(is.finite(coords)),
          identical(rownames(coords), graph$vertices$id))
graph$edges
#>   from to weight
#> 1    1  2      2
#> 2    2  3      4

The prepared edge endpoints are integer indices into graph$vertices, not external IDs. We deliberately shuffle coordinate and annotation input order below. As with point annotations, plot3D.graph() aligns named inputs by exact ID. Graph plotting additionally puts coordinates into prepared vertex order; its layers refer to that order. Point plotting keeps the supplied coordinate order.

coords <- coords[c("end", "start", "isolate", "bend"), , drop = FALSE]
values <- c(isolate = 40, bend = 20, start = 10, end = 30)
graph.view <- plot3D.graph(graph, X = coords, values = values,
  edge.width = c(1, 3), edge.col = "gray55",
  point.size = 8, legend.width = 100, height = 340,
  camera = camera.zup(zoom = 0.55),
  layers = list(layer3D.path(c(1, 3), col = "#D55E00", width = 2),
                layer3D.labels(1:4, vertices, offset = c(0, 0, 0.15))))
info <- attr(graph.view, "ivue")
stopifnot(identical(rownames(info$X), vertices),
          identical(info$mapping$colors,
                    map.colors(unname(values[vertices]), info$mapping$scale)$colors))
graph.view

Interactive 3D view of 4 observations.

The orange geometric path joins start directly to end without changing the graph topology; the gray graph edges go through bend. Labels sit 0.15 coordinate units above their vertices. Edge widths are explicit visual choices in prepared edge order, independent of weights. No layout is computed. See the introduction for reciprocal adjacency lists, matrices, and optional igraph layouts.

Record a short coordinate sequence

This three-frame triangle first gains a vertex and then expands by a factor of 1.4 about the coordinate origin. The change is explicitly constructed; these frames are neither solver iterations nor physical time measurements.

triangle <- rbind(a = c(0, 0), b = c(1, 0), c = c(0.5, 0.9))
first <- triangle
first[3, ] <- NA_real_
frames <- list(first, triangle, triangle * 1.4)
frame.labels <- c("Two vertices", "Complete triangle", "Expanded by 1.4")
frame.edges <- rbind(c(1, 2), c(2, 3), c(3, 1))
stopifnot(length(frames) == length(frame.labels),
  all(vapply(frames, function(f) identical(dim(f), c(3L, 2L)) &&
    identical(rownames(f), rownames(triangle)) &&
    all(rowSums(is.finite(f)) == 2L | rowSums(is.na(f)) == 2L), logical(1))),
  all(frame.edges >= 1L & frame.edges <= nrow(triangle)))
player <- animate.frames(frames, edges = frame.edges, labels = frame.labels,
  fps = 1, loop = FALSE, max.frames = NULL,
  col = c("#2166AC", "#B2182B", "#009F87"), point.size = 9, height = 300)
player

Coordinate animation of 3 observations across 3 retained frames.

Press Play, step with the slider, or use Reset. Blue and red are present at the start; green and its two incident edges appear in the second frame. Two-dimensional input is drawn at z = 0, initially face-on. The three vertex colors are fixed across frames. No labels or categories are inferred from them. The player’s text labels describe frames, not observations.

An all-missing row means inactive, preserving its identity for later frames. A partially missing row is invalid. Bounds stay fixed across all original frames, and every retained frame has equal duration. No interpolation or alignment occurs. Record meaningful original frame labels for a real solver trace or measured sequence; uniform playback does not recover irregular time intervals. The animation guide gives larger examples and explicit frame-selection recipes.

Save and clean up

The example below checks a small HTML export and, when magick is installed, a three-frame GIF, then removes all outputs. It does not launch a viewer. Choose your own persistent directory when keeping an export.

local({
  directory <- tempfile("ivue-recipe-")
  dir.create(directory)
  on.exit(unlink(directory, recursive = TRUE))
  htmlwidgets::saveWidget(player, file.path(directory, "triangle.html"),
                          selfcontained = FALSE)
  stopifnot(file.exists(file.path(directory, "triangle.html")))
  if (requireNamespace("magick", quietly = TRUE)) {
    gif <- write.animation.gif(player, file.path(directory, "triangle.gif"),
                               width = 240, height = 240, final.hold = 0)
    stopifnot(file.exists(gif))
  }
})

HTML retains interaction and must accompany its dependency folder unless saved with selfcontained = TRUE (which needs Pandoc). GIF is a fixed orthographic raster rendering using the R widget’s initial camera; it does not capture browser rotations. See the export comparison for controls, portability, and limitations.

Inspect the retinal bundle without refitting

The bundled example adds a real identity and provenance check. Rendering the full case study is left to the retinal-development vignette, which includes static posters and complete drawing recipes.

retina <- readRDS(system.file("extdata", "retinal-development.rds", package = "ivue"))
ids <- retina$annotations$id
stopifnot(identical(names(retina$coordinates), c("umap", "sknn")),
          identical(dim(retina$annotations), c(12000L, 3L)),
          !anyNA(ids), !anyDuplicated(ids),
          identical(retina$graph$vertices, ids),
          nrow(retina$graph$edges) == 4108L,
          all(retina$graph$edges$from %in% ids),
          all(retina$graph$edges$to %in% ids))
for (coords in retina$coordinates) {
  stopifnot(identical(dim(coords), c(12000L, 3L)),
            identical(rownames(coords), ids), all(is.finite(coords)))
}
data.frame(representation = names(retina$coordinates),
           observations = 12000L, dimensions = 3L)
#>   representation observations dimensions
#> 1           umap        12000          3
#> 2           sknn        12000          3
c(age.levels = nlevels(retina$annotations$age),
  cell.type.levels = nlevels(retina$annotations$cell.type),
  displayed.edges = nrow(retina$graph$edges))
#>       age.levels cell.type.levels  displayed.edges 
#>               10               11             4108

There are two 12,000-by-3 matrices (umap, sknn), an undirected distance weighted graph with 4,108 edges, and an annotation table with synthetic id, 10-level age, and 11-level cell.type. provenance records the source, fitting cohort, methods, sample, checksums, and upstream package versions.

The upstream fitting cohort had 120,804 cells. The published analysis retained 107,052 retinal cells; the bundle displays a stratified sample of 12,000 retained cells. Both embeddings were fitted on the full cohort before extracting these display rows. The displayed graph is an induced subgraph of the full fitting graph: it keeps only edges whose endpoints both survive display selection and can be disconnected. No embedding is refitted on this smaller graph.

Published UMAP used Canberra distance on the upstream representation. The symmetric 4-nearest-neighbor graph used Euclidean distance, followed by weighted-GRIP and edge-KK layout. Distance metrics define comparisons of upstream observations; embedding/layout methods produce display coordinates. ivue renders those results. UMAP coordinates were centered for bundling; the README capture additionally applies independent rigid rotations to face each P14 centroid toward the viewer. The recipe camera alone does not perform that alignment. The README’s PHATE view uses Euclidean distance and was also fitted on the full cohort, but its coordinates occur only in external assets, not in this RDS. No raw expression, principal-component matrix, original barcodes, or sample identifiers are bundled.

The values come from Clark et al. (2019), GEO GSE118614, and the Goff lab’s published coordinates and annotations. Their source use agreement contains a prepublication consent restriction tied to January 1, 2019. The documented source review found no separate dataset license. Public availability and the elapsed date are not a public-domain claim or a new permission grant; the package’s GPL code license does not establish rights in third-party values. The installed extdata/README.md and stored provenance$source.terms preserve these limitations.

Shared colors and IDs help compare the same cells, but apparent proximity, separation, or a smooth age gradient is not by itself evidence of equivalent geometries, clustering fidelity, lineage, or biological causation. Interpret the plots together with the upstream analyses and the case study’s limitations.

Compare spatial extents

Reusing only a camera allows each scene to fit its own coordinate extent. For a spatial comparison, also fix the framing range, equal aspect, and widget size. This illustrative triangle is contracted by one half without changing its observation IDs. Both views use the same range and orthographic camera, so the contracted triangle occupies half the linear screen extent.

reference <- rbind(a = c(-1, -1, 0), b = c(1, -1, 0), c = c(0, 1, 1))
common.bounds <- rbind(x = c(-2, 2), y = c(-2, 2), z = c(-2, 2))
orientation <- camera.zup(elevation = 35, turn = 0)
full <- plot3D.plain(reference, limits = common.bounds, camera = orientation,
                     width = 300, height = 300, point.size = 8,
                     description = "Reference triangle at its original scale.")
contracted <- plot3D.plain(reference / 2, limits = common.bounds,
                     camera = orientation, width = 300, height = 300, point.size = 8,
                     description = "The same triangle contracted by half.")
htmltools::tagList(full, contracted)

Reference triangle at its original scale.

The same triangle contracted by half.

The ranges must contain every observation. They frame the view without adding points or clipping planes. Spheres, meshes, surfaces, and labels cannot expand an explicit range; leave enough room for them. With aspect = "normalized", axis stretching is calculated from the supplied ranges, so equal data-unit lengths across axes no longer have equal screen scales.