Point Clouds and Weighted Graphs with ivue

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

On this page: A sample from a saddle · Reusable numerical colors · Textbook-style axes and a z-up camera · A triangular mesh beneath the spheres · Groups and highlighting · A gridded reference surface · Weighted graphs and geometric layers · Cameras, metadata, and export

A sample from a saddle

Static orthographic view of 250 green points on a saddle, whose opposing sides curve upward and downward.
Static orthographic view of 250 green points on a saddle, whose opposing sides curve upward and downward.

The poster uses the same observations as the first interactive example below. It shows the saddle shape without requiring scripts or WebGL; it is a static projection, with no interactive rotation.

The input is an n x 3 numeric matrix, or an all-numeric data frame. Each row is one observation. Coordinates must be finite; ivue does not silently drop rows with missing coordinates.

Explicit coordinate row names are observation IDs. Named values, groups, point colors, logical highlight masks, and style colors match those IDs exactly, as in graph plots; missing, duplicate, or extra names cause errors. Unnamed vectors follow row position. Use unname() deliberately if annotation names are not IDs. Point coordinates and indexed layers keep their supplied row order.

set.seed(1)
xs <- runif(250, -1, 1)
ys <- runif(250, -1, 1)
zs <- 1.2 * (xs^2 - ys^2)
X <- cbind(x = xs, y = ys, z = zs)

plot3D.plain() uses rgl to create an interactive browser widget. Assigning the result to plain defers viewing; the final plain expression displays it in this vignette, in RStudio’s Viewer, or in a browser from an interactive R console. Scene creation uses an off-screen device, so no native graphics window is needed.

All plot3D functions start with z pointing upward and x and y in the horizontal data plane. The default is camera.zup(elevation = 20, turn = -135, fov = 0, zoom = 0.8): an orthographic view with positive x down-left and positive y down-right on screen. Dragging the widget can change this orientation.

plain <- plot3D.plain(X, col = "#197A68", point.size = 5,
                     axes = TRUE, xlab = "x", ylab = "y", zlab = "z",
                     description = "250 green observations on a saddle; opposite sides curve upward and downward.")
plain

250 green observations on a saddle; opposite sides curve upward and downward.

point.size is a screen-pixel size. Spheres are a separate primitive, with point.type = "sphere" and sphere.radius measured in coordinate units. For example, plot3D.plain(X, point.type = "sphere", sphere.radius = 0.02) draws spheres of radius 0.02. A missing radius uses one percent of the largest coordinate span. Dense sphere scenes can be much more expensive than points.

The default aspect = "equal" preserves equal units across axes. Choose aspect = "normalized" only when stretching axes independently is intended; it changes relative geometric distances.

Reusable numerical colors

Continuous interpolation is the default. Here zero has a scientific meaning, so we specify both a center and a diverging palette. Automatic limits become symmetric around that center. Creating a scale and inspecting its color and legend mappings do not require rgl or open a plot.

height.scale <- color.scale.cont(zs, center = 0,
  palette = c("#2166AC", "#F7F7F7", "#B2182B"))
mapping <- map.colors(zs, height.scale)
head(mapping$colors)
#> [1] "#EAEEF2FF" "#6091C2FF" "#F6F4F4FF" "#CB6A76FF" "#DEA7AEFF" "#F5F2F3FF"
mapping$legend
#>    label     color count
#> 1 -1.180 #2166ACFF    NA
#> 2 -0.588 #8AADD1FF    NA
#> 3  0.000 #F6F6F6FF    NA
#> 4  0.588 #D48791FF    NA
#> 5  1.180 #B2182BFF    NA
colored <- plot3D.cont(X, values = zs, scale = height.scale,
                       point.size = 5, legend.title = "Saddle height")
colored

Interactive 3D view of 250 observations.

Construct one scale from a reference sample and reuse it to compare plots. limits fixes the numerical range. Values outside it are squished by default; oob = "censor" maps them to the missing color, while oob = "error" rejects them. Missing values use na.color without removing coordinate rows.

For binned colors, request them explicitly. Break calculations retain full precision. The default digits = NULL increases legend precision until nearby boundaries are distinguishable; an explicit digits fixes precision and warns if labels are ambiguous. There is no automatic winsorization. center is a continuous-scale control; for binned diverging colors, supply the intended breaks and bin colors directly.

binned <- color.scale.cont(zs, mode = "binned",
  breaks = c(-1.2, -0.4, 0.4, 1.2),
  palette = c("#2166AC", "#EEEEEE", "#B2182B"))
map.colors(zs, binned)$legend
#>          label   color count
#> 1 [-1.2, -0.4] #2166AC    71
#> 2  (-0.4, 0.4] #EEEEEE   136
#> 3   (0.4, 1.2] #B2182B    43

palette can be a vector of colors or a function of the requested number of colors. color.map instead receives numerical values and must return one color per value. Do not supply both. Numeric palette indices, including missing colors, are fixed when a scale is fitted, so later changes to R’s palette() do not change its colors. A color.map callback is run at mapping time; its dependence on mutable external state remains the caller’s responsibility.

Textbook-style axes and a z-up camera

The earlier saddle sample is uniform in its horizontal parameter square, not in surface area. For z = C * (x^2 - y^2), the surface-area factor is sqrt(1 + 4*C^2*(x^2 + y^2)). Accepting uniform-square proposals with probability proportional to this factor gives a surface-area-uniform sample. Batches accumulate until there are at least n accepted points; the final line retains exactly n of them.

set.seed(1)
n <- 500
C <- 0.8
half.width <- 1
xy <- matrix(numeric(), ncol = 2)
while (nrow(xy) < n) {
  proposal <- matrix(runif(4000, -half.width, half.width), ncol = 2)
  area <- sqrt(1 + 4*C^2 * rowSums(proposal^2))
  accept <- runif(nrow(proposal)) < area / sqrt(1 + 8*C^2*half.width^2)
  xy <- rbind(xy, proposal[accept, , drop = FALSE])
}
xy <- xy[seq_len(n), , drop = FALSE]
saddle <- cbind(x = xy[, 1], y = xy[, 2],
                z = C * (xy[, 1]^2 - xy[, 2]^2))
byr.scale <- color.scale.cont(saddle[, "z"], center = 0,
                               palette = c("blue", "yellow", "red"))

layer3D.axes() adds three axes through the origin, with arrowheads at their positive ends. It does not rotate the data or choose a camera. The example below keeps the default z-up orientation and uses zoom = 0.4 to leave room for the origin-crossing axes and legend:

saddle.view <- plot3D.cont(
  saddle, values = saddle[, "z"], scale = byr.scale,
  point.type = "sphere", sphere.radius = 0.02,
  legend.title = "Saddle height", legend.width = 160,
  axes = FALSE, aspect = "equal",
  layers = list(layer3D.axes(head.length = 0.04, head.angle = pi/8)),
  camera = camera.zup(elevation = 20, turn = -135, fov = 0, zoom = 0.4),
  height = 500L
)

Interactive 3D view of 500 observations.

Zero is yellow; negative and positive heights move toward blue and red, respectively. scale takes a color-scale object, not a raw color vector. Reusing byr.scale and the original height values preserves observation colors if these points are subsequently displayed in another coordinate configuration.

The axes extend beyond the data automatically. Set origin to move their intersection, or use explicit limits, for example rbind(x = c(-1.2, 1.2), y = c(-1.2, 1.2), z = c(-1, 1)), to fix endpoints across views. These limits do not clip data. head.length is a fraction of the full axis span; head.angle is the cone half-angle in radians. width, col, cex, and label.offset control the shafts, colors, label size, and label gaps. The heads are solid cones in data coordinates, so they keep their 3D geometry while the widget rotates. Use equal aspect to preserve their proportions.

With turn = -135, positive x points down-left and positive y down-right. With turn = 0, positive x is horizontal to the right and positive y recedes. elevation changes the angle above the xy plane; fov = 0 removes perspective. The helper only specifies the initial view: dragging can still tilt z, and at elevations of plus or minus 90 degrees z points along the viewing direction. Neither the axes constructor nor the camera helper needs rgl until a plot is actually drawn. The same layer and camera also work with plot3D.graph().

A triangular mesh beneath the spheres

Static view of triangular faces connecting the 500 saddle observations. Point colors encode height: blue below zero, yellow at zero, and red above zero.
Static view of triangular faces connecting the 500 saddle observations. Point colors encode height: blue below zero, yellow at zero, and red above zero.

This poster projects the same sample and triangulation used below. It is a flat diagram of the geometry; it does not reproduce WebGL lighting or spheres.

Connecting neighboring observations with triangular faces makes the saddle’s shape easier to see. Construct a Delaunay triangulation in the original two-dimensional parameter plane, then draw its faces at the three-dimensional saddle coordinates. Triangulating the 3D coordinates instead would construct a different object, not this surface mesh.

The geometry package is an add-on, not part of base R. Install it with install.packages("geometry") to run this example. It is suggested for the vignette, but layer3D.mesh() does not require it when triangle indices are already available. The example below is evaluated when both geometry and rgl are installed.

triangles <- geometry::delaunayn(saddle[, c("x", "y")])
surface <- layer3D.mesh(
  triangles, col = "gray75", alpha = 0.2,
  edge.col = "gray45", edge.alpha = 0.35, edge.width = 1
)

Each row of triangles contains three indices into saddle. The mesh joins those observations with planar faces, approximating the surface over the sample’s parameter-space convex hull. It does not extend to unsampled corners of the square. The points, colors, axes, and initial camera below are the same as in the preceding plot.

saddle.mesh <- plot3D.cont(
  saddle, description = "500 saddle observations joined by triangular faces; blue is below zero, yellow is zero, red is above zero.", values = saddle[, "z"], scale = byr.scale,
  point.type = "sphere", sphere.radius = 0.02,
  legend.title = "Saddle height", legend.width = 160,
  axes = FALSE, aspect = "equal",
  layers = list(surface, layer3D.axes()),
  camera = camera.zup(elevation = 20, turn = -135, fov = 0, zoom = 0.4),
  height = 500L
)

500 saddle observations joined by triangular faces; blue is below zero, yellow is zero, red is above zero.

alpha controls face opacity; edge.alpha independently controls the mesh lines. Shared edges are drawn once. Set alpha = 0 for a wireframe or edges = FALSE to show faces without their outlines.

To inspect another embedding of these same observations, retain surface and replace only the plotting coordinates. For example, if Z contains the metric-MDS or metric-MDS-followed-by-edge-KK coordinates in the same row order:

plot3D.cont(
  Z, values = saddle[, "z"], scale = byr.scale,
  point.type = "sphere", sphere.radius = 0.02, axes = FALSE,
  layers = list(surface, layer3D.axes()), camera = camera.zup()
)

Do not retriangulate Z: fixed triangle connectivity reveals how the original mesh stretches, folds, or collapses. Original heights continue to determine point colors. The mesh is a visualization overlay, separate from any symmetric-kNN graph used for fitting or fidelity scoring; adding it does not change that graph or its objectives.

Groups and highlighting

Groups need not be clusters. Factors retain their level order; other vectors use first-occurrence order. Named colors make the assignment explicit and stable across datasets. Unknown groups raise an error unless the scale uses unknown = "missing". Missing factor levels are treated as missing, not as the literal group "NA". Empty strings remain valid groups. Legends quote group labels when needed to distinguish an empty label or a group called "Missing" from the missing-value entry.

First, color and highlight the original 250-point sample. Then reuse the same group scale on the mesh-backed saddle below.

groups <- factor(ifelse(zs < 0, "Negative", "Nonnegative"),
                 levels = c("Negative", "Nonnegative"))
group.scale <- color.scale.groups(groups,
  colors = c(Negative = "#D95479", Nonnegative = "#009F87"))
map.colors(groups, group.scale)$legend
#>         label   color count
#> 1    Negative #D95479   135
#> 2 Nonnegative #009F87   115
plot3D.groups(X, groups, scale = group.scale, point.size = 5,
  highlight = abs(zs) > 0.4,
  highlight.style = list(point.size = 7),
  non.highlight.style = list(col = "gray75", alpha = 0.25))

Interactive 3D view of 250 observations.

highlight changes styling, not membership: all 250 rows keep their original IDs and the color scale is not refitted. Legends describe the base color scale and global alpha, not the per-highlight overrides. Scale colors can include alpha, and global alpha multiplies it. A highlight-style alpha overrides the global multiplier for that subset, while preserving its color’s alpha.

Groups on the mesh-backed saddle

plot3D.groups() accepts the same sphere, mesh, axis, and camera controls as plot3D.cont(). To color the preceding mesh-backed saddle by group, replace the continuous values and byr.scale with group labels and group.scale. Derive the labels from saddle, which has 500 rows, rather than reusing the 250 labels associated with X:

saddle.groups <- factor(ifelse(saddle[, "z"] < 0, "Negative", "Nonnegative"),
                        levels = c("Negative", "Nonnegative"))
saddle.mesh.groups <- plot3D.groups(
  saddle, groups = saddle.groups, scale = group.scale,
  point.type = "sphere", sphere.radius = 0.02,
  legend.title = "Height group", legend.width = 160,
  axes = FALSE, aspect = "equal",
  layers = list(surface, layer3D.axes()),
  camera = camera.zup(elevation = 20, turn = -135, fov = 0, zoom = 0.4),
  height = 500L
)

Interactive 3D view of 500 observations.

Negative-height spheres are pink and nonnegative-height spheres are green, using the same named colors as the simpler example. The gray surface, mesh connectivity, sphere radii, axes, and camera are unchanged from saddle.mesh; only the point coloring and legend differ. Here all points retain their group colors, without highlighting. Reusing surface does not require another triangulation.

A gridded reference surface

The triangular mesh above follows the plotted observations. When a surface is known analytically, layer3D.surface() can instead draw a reference with its own coordinates. Here the original 250-point sample X lies on z = 1.2 * (x^2 - y^2); evaluate that formula on a regular grid covering its parameter square:

grid.x <- grid.y <- seq(-1, 1, length.out = 31)
grid.z <- outer(grid.x, grid.y, function(x, y) 1.2 * (x^2 - y^2))
reference <- layer3D.surface(grid.x, grid.y, grid.z,
                              col = "lightblue", alpha = 0.3)

Entry grid.z[i, j] is the height at (grid.x[i], grid.y[j]). No triangulation call or geometry installation is needed. Overlay the reference beneath the sample, reusing the earlier height scale:

reference.view <- plot3D.cont(
  X, values = X[, "z"], scale = height.scale, point.size = 5,
  legend.title = "Saddle height", legend.width = 160, axes = FALSE,
  layers = list(reference, layer3D.axes()),
  camera = camera.zup(zoom = 0.6), height = 450
)

Interactive 3D view of 250 observations.

Set edges = TRUE in layer3D.surface() for grid lines and lit = TRUE for lighting. Faces are planar triangles; increasing grid resolution approximates the smooth surface more closely. Unlike the earlier mesh, reference stays fixed when reused with another set of plotting coordinates. To compare an MDS or refined embedding against it, first align the embedding to the reference coordinate system. The layer performs no alignment or rescaling and does not modify the graph used to compute the embedding.

Weighted graphs and geometric layers

A graph supplies topology and weights. Coordinates supply geometry; one does not substitute for the other. The following graph contains an isolated vertex.

vertices <- data.frame(id = c("A", "B", "C", "D"),
                       label = c("Start", "Middle", "End", "Isolate"))
edges <- data.frame(from = c("A", "B"), to = c("B", "C"), weight = c(2, 4))
coords <- rbind(A = c(0, 0, 0), B = c(1, 1, 0),
                C = c(2, 0, 1), D = c(0, 2, 1))
graph <- prepare.graph(edges, vertices = vertices, weight.type = "distance")
graph$edges
#>   from to weight
#> 1    1  2      2
#> 2    2  3      4
coords <- coords[c("C", "A", "D", "B"), ]
values <- c(D = 40, B = 20, A = 10, C = 30)
plot3D.graph(graph, X = coords, values = values, edge.col = "gray50",
  edge.width = 1 + graph$edges$weight,
  point.type = "sphere", sphere.radius = 0.07,
  layers = list(layer3D.path(c(1, 2, 3), col = "#D55E00", width = 4),
                layer3D.labels(1:4, vertices$label, offset = c(0, 0, 0.15))))

Interactive 3D view of 4 observations.

The other accepted formats are paired adj.list/weight.list lists, dense or sparse adjacency matrices, and igraph objects. Sparse matrix inputs require Matrix; igraph inputs require igraph. Preparing a graph with prepare.graph() does not require rgl. For the same graph:

adjacency <- list(
  adj.list = list(A = 2L, B = c(1L, 3L), C = 2L, D = integer()),
  weight.list = list(A = 2, B = c(2, 4), C = 4, D = numeric()))

Undirected adjacency entries must be reciprocal, with matching weights. Matrices use zero to mean no edge; use tables or lists for actual zero-weight edges. Coordinate row names are aligned to vertex IDs. Without row names, coordinates follow vertex order. Named values and groups are independently aligned by exact vertex ID, as in the shuffled example above. Unnamed annotation vectors follow vertex order. Duplicate, missing, partial, and extra names are rejected. Named point colors, highlight-style color vectors, and logical highlight masks use the same rule. Layer row indices and numeric highlight indices refer to graph vertex order after alignment.

Weights do not automatically control edge width or color. The example chooses 1 + graph$edges$weight explicitly. Edge colors and widths follow the edge order exposed by prepare.graph() before rendering; matrix/list inputs use one copy of each undirected edge in vertex-row order. Table inputs retain their supplied order.

Prepared graphs can be reused across plots. Vertex identities, isolates, weights, and vertex and edge table attributes are retained. Integer edge endpoints index the prepared vertex table: to change vertex order, prepare a new graph from vertex IDs rather than reorder that table in place.

ivue can display coordinates produced by dgraphs, grip, igraph, graphlayouts, or other embedding tools. Choose a method that returns three-dimensional coordinates, then supply them through X; ivue renders the result without recomputing the layout. Examples include spectral embeddings from dgraphs, multiscale layouts from grip, and three-dimensional stress layouts from graphlayouts::layout_with_stress3D(). Not every layout in these packages supports three dimensions. The coordinate matrix must have three columns, with rows matched to graph vertices as described above. For table, list, or matrix graph inputs, supplying X also avoids constructing an igraph object.

Alternatively, let ivue compute coordinates by supplying layout instead of X. The built-in choices "kk" and "fr" require igraph. A custom layout function can use another package: it receives ivue’s normalized graph and must return an n x 3 coordinate matrix in vertex order. The examples below use the built-in adapters:

layout.widget <- plot3D.graph(adjacency, layout = "kk", weight.type = "distance",
                              seed = 1)
stopifnot(inherits(layout.widget, "htmlwidget"))
unweighted <- igraph::make_ring(4)
unweighted.widget <- plot3D.graph(unweighted, layout = "fr", weight.type = "unweighted")
stopifnot(inherits(unweighted.widget, "htmlwidget"))

kk treats positive weights as distances; fr requires positive strengths. No weight inversion occurs. weight.type = "unweighted" allows missing or unit weights only. Zero and negative weights may be drawn with supplied coordinates, but cannot be used by these layout adapters. Directed rendering, self-loops, and parallel edges are explicitly unsupported in this release.

Use layer3D.edges() when you already have geometric endpoint pairs, without requiring a graph object. layer3D.callback() supports advanced rgl drawing; callbacks receive coordinates and row/draw IDs but must not open, close, or switch devices. Layers are applied before serialization, not appended to a live browser widget later.

Cameras, metadata, and export

The widget’s metadata describes the captured R scene. Reusing its initial camera keeps related plots comparable. Rotating a widget in the browser does not update this R-side camera metadata.

camera <- attr(colored, "ivue")$camera
comparison <- plot3D.plain(X, camera = camera)
stopifnot(identical(attr(comparison, "ivue")$row.ids, seq_len(nrow(X))))
comparison

Interactive 3D view of 250 observations.

Creating or displaying a widget does not save a file. To share the colored saddle plot, export colored explicitly with htmlwidgets::saveWidget(). This example writes a temporary HTML file and removes it and its dependencies after checking that the export succeeded:

out <- tempfile(fileext = ".html")
htmlwidgets::saveWidget(colored, out, selfcontained = FALSE)
stopifnot(file.exists(out))
unlink(c(out, sub("\\.html$", "_files", out)), recursive = TRUE)

For a single portable HTML file, use selfcontained = TRUE; Pandoc must be installed. Otherwise distribute the HTML file together with its dependency directory. Widgets can also be used in Shiny through rgl::rglwidgetOutput() and rgl::renderRglwidget(); Shiny is not an ivue dependency.

Default widget width follows its container, with a height of 600 pixels. Explicit width and height set dimensions. Long legends scroll within their allocated area, and legend.show = FALSE permits a separate application legend built from the same map.colors() result.