Skip to contents

Past a few hundred thousand points, drawing one marker each stops working. Markers pile on top of markers, the file balloons, and a solid blob hides whatever structure the data had. Drawing the markers faster does not fix this, because the picture itself is wrong: overplotting throws away density.

datashade() takes the approach datashader made popular: do not draw markers at all. Bin the points into a canvas-sized grid in one pass, count how many land in each cell, and colour each cell by that count. The cost is decoupled from both the number of points and the amount of overplotting, and the result is honest about density. datashade() returns a single raster_grob() you draw like anything else.

The examples here are strange attractors, the same gallery shipped in system.file("examples/attractors.R", package = "vellum"). They are a good stress test: a chaotic map iterated for millions of points, with density spanning many orders of magnitude between the sparse outer wisps and the bright folded core.

Generating an orbit

An attractor orbit is sequential: each point is a function of the one before it, so it cannot be vectorised in R. vellum ships a small Rust kernel for it, rs_attractor(), which returns 10 million points in a fraction of a second. It is internal (hence the :::), exposed here only to feed the example; the subject of this article is what happens after the cloud exists.

n_points <- 2e6

attractor <- function(kind, p, n = n_points, x0 = 0.1, y0 = 0.1) {
  n <- as.integer(n)
  # jarl-ignore internal_function: the Rust kernel is internal; the vignette drives it directly
  v <- vellum:::rs_attractor(kind, n, p[1], p[2], p[3], p[4], x0, y0)
  list(x = v[seq_len(n)], y = v[n + seq_len(n)])
}

pts <- attractor("clifford", c(-1.4, 1.6, 1.0, 0.7))
length(pts$x)
#> [1] 2000000

Shading in one call

Hand the cloud to datashade(). width and height are the aggregation grid in cells, which is also the output raster in pixels, and colors is the low-to-high density ramp. A white first stop lets the sparse regions fade into the page.

blues <- c("#ffffff", "#c6dbef", "#6baed6", "#2171b5", "#08306b")

vl_scene(5, 5, bg = "white") |>
  draw(datashade(
    pts$x, pts$y,
    width = 500, height = 500,
    colors = blues
  ))

Two million points, and the work scaled with the half-million grid cells, not the points. The same call handles twenty million with no change to the picture’s size on disk: still one 500x500 raster.

Why aggregation wins

The whole cost is one linear pass over the points to bin them, then a colour lookup per grid cell. Concretely, datashade() calls an aggregation kernel that is the O(N) heart of the method:

# jarl-ignore internal_function: the Rust kernel is internal; the vignette drives it directly
counts <- vellum:::rs_aggregate_2d(
  pts$x, pts$y,
  NULL,          # optional per-point weights
  120L, 120L,    # grid size
  min(pts$x), max(pts$x), min(pts$y), max(pts$y)
)
dim(counts)
#> NULL
range(counts)     # the densest cell holds this many points
#> [1]    0 4985
mean(counts > 0)  # fraction of the grid the orbit actually touches
#> [1] 0.5379167

That count grid is all the downstream cost depends on. Whether it came from two million points or two hundred million, the shading step sees the same 120x120 matrix. Overplotting, the thing that ruins a scatter of markers, is exactly what the count measures here rather than hides.

Mapping density to colour

The densest cell of an attractor can hold thousands of times more points than a faint outer cell. Map that range linearly and the bright core saturates while everything faint collapses to nearly the background. The how argument controls the density-to-colour mapping; datashader’s default, "eq_hist" (histogram equalisation), allocates colour by rank so structure stays visible across the whole range.

ramp <- c("#ffffff", "#08306b")

panel <- function(how) {
  datashade(pts$x, pts$y, width = 300, height = 300, colors = ramp, how = how)
}

vl_scene(6, 6, bg = "white") |>
  push(vl_viewport(layout = grid_layout(
    widths = vl_unit(c(1, 1), "null"),
    heights = vl_unit(c(1, 1), "null")
  ))) |>
  push(vl_viewport(row = 1, col = 1)) |> draw(panel("linear"))  |> pop() |>
  push(vl_viewport(row = 1, col = 2)) |> draw(panel("log"))     |> pop() |>
  push(vl_viewport(row = 2, col = 1)) |> draw(panel("cbrt"))    |> pop() |>
  push(vl_viewport(row = 2, col = 2)) |> draw(panel("eq_hist")) |> pop()

Clockwise from top-left: "linear" (only the core survives), "log", "cbrt", and "eq_hist" (the fullest picture). "log" and "cbrt" are useful middle grounds when you want a mapping with a fixed analytic meaning rather than one that depends on the data’s rank distribution.

Lining up with data axes

datashade() bins over xlim by ylim and returns a raster that fills its viewport (npc 0..1). To place it against data axes, draw it inside a vl_viewport() whose xscale / yscale match the same limits, then draw axes, labels, or reference lines in "native" units in that viewport. For crisp bins, match width / height to the viewport’s pixel size and keep the default interpolate = FALSE.

For the attractors we centre a square window on the orbit so a square cell maps without distortion:

window <- function(x, y, pad = 1.05) {
  xr <- range(x)
  yr <- range(y)
  half <- max(diff(xr), diff(yr)) / 2 * pad
  list(
    xlim = mean(xr) + c(-half, half),
    ylim = mean(yr) + c(-half, half)
  )
}

w <- window(pts$x, pts$y)
str(w)
#> List of 2
#>  $ xlim: num [1:2] -1.5 2.06
#>  $ ylim: num [1:2] -1.67 1.9

Passing that window as xlim / ylim keeps the aspect honest regardless of how the raw orbit range differs between the two axes.

Because each shaded attractor is just a grob, a gallery is a layout of them. Push a grid_layout() of "null" (flexible) tracks and drop one datashade() raster into each cell. Square cells (from the square window above) keep every map undistorted.

gallery <- list(
  list(kind = "clifford", p = c(-1.4,  1.6,  1.0,  0.7),
       pal = c("#ffffff", "#c6dbef", "#6baed6", "#2171b5", "#08306b")),
  list(kind = "dejong",   p = c( 1.4, -2.3,  2.4, -2.1),
       pal = c("#ffffff", "#fdd0a2", "#fd8d3c", "#d94801", "#7f2704")),
  list(kind = "svensson", p = c( 1.5, -1.8,  1.6,  0.9),
       pal = c("#ffffff", "#c7e9c0", "#74c476", "#238b45", "#00441b")),
  list(kind = "clifford", p = c(-1.8, -2.0, -0.5, -0.9),
       pal = c("#ffffff", "#dadaeb", "#9e9ac8", "#6a51a3", "#3f007d"))
)

ncol <- 2
nrow <- 2
cell_px <- 300

s <- vl_scene(6, 6, dpi = 100, bg = "white") |>
  push(vl_viewport(layout = grid_layout(
    widths  = vl_unit(rep(1, ncol), "null"),
    heights = vl_unit(rep(1, nrow), "null")
  )))

for (i in seq_along(gallery)) {
  a <- gallery[[i]]
  orbit <- attractor(a$kind, a$p)
  w <- window(orbit$x, orbit$y)
  img <- datashade(
    orbit$x, orbit$y,
    width = cell_px, height = cell_px,
    xlim = w$xlim, ylim = w$ylim,
    colors = a$pal, how = "eq_hist"
  )
  row <- (i - 1) %/% ncol + 1
  col <- (i - 1) %% ncol + 1
  s <- s |> push(vl_viewport(row = row, col = col)) |> draw(img) |> pop()
}

s

The shipped example (inst/examples/attractors.R) goes further: it draws twelve panels with random parameters, keeping only orbits that fill a fair fraction of the canvas (rejection sampling, since most random parameters collapse to a point or diverge), each under a random colormap. Run it for a fresh gallery every time:

Rscript inst/examples/attractors.R gallery.png 1e7

Lines: dense timeseries

Points are not the only thing that overplots. A few hundred stacked timeseries, each with a few thousand samples, is already a million line vertices — draw them as vector paths and you get a solid band that hides where the traces actually concentrate. The same aggregate-then-shade idea applies: rasterise the lines into a grid where each cell accumulates the coverage of the lines crossing it, then colour by that density. datashade_lines() does this.

It takes a connected polyline. Pass group to pack many series into one call — the line breaks wherever the group id changes (an NA coordinate breaks it too), so a whole panel of traces is a single call and a single raster.

set.seed(1)
k <- 600       # series
m <- 800       # samples each
steps <- matrix(rnorm(k * m, sd = 0.4), m, k)
walks <- apply(steps, 2, cumsum)          # k random walks, m samples

t <- rep(seq_len(m), times = k)           # x: shared time axis
y <- as.vector(walks)                     # y: all traces concatenated
grp <- rep(seq_len(k), each = m)          # which series each vertex belongs to

vl_scene(6, 4, bg = "white") |>
  push(vl_viewport(xscale = range(t), yscale = range(y))) |>
  draw(datashade_lines(
    t, y, group = grp,
    width = 600, height = 400,
    colors = c("#ffffff", "#fdd0a2", "#fd8d3c", "#d94801", "#7f2704"),
    how = "eq_hist"
  ))

The bright core is where the walks pile up (near the start, before they diffuse apart); the faint outer envelope is the handful of traces that wandered far. That structure is exactly what an opaque band of 480,000 vertices would have hidden. Coverage is anti-aliased and summed, so overlapping traces brighten honestly rather than saturating to a single flat colour.

Segments: network edges

datashade_segments() is the same engine for independent segments — each element is one (x0, y0) -> (x1, y1) line, not a connected path. The motivating case is the edges of a large graph: a hairball of tens of thousands of edges drawn as vectors is an unreadable blob, but aggregated it becomes an edge-density field showing which regions of the layout the edges actually run through.

set.seed(2)
# A toy "layout": two loose clusters of nodes with many edges between them.
n_nodes <- 400
nx <- c(rnorm(n_nodes / 2, -1), rnorm(n_nodes / 2, 1))
ny <- rnorm(n_nodes)
n_edges <- 8000
a <- sample(n_nodes, n_edges, replace = TRUE)
b <- sample(n_nodes, n_edges, replace = TRUE)

vl_scene(6, 4, bg = "white") |>
  push(vl_viewport(xscale = range(nx), yscale = range(ny))) |>
  draw(datashade_segments(
    nx[a], ny[a], nx[b], ny[b],
    width = 600, height = 400,
    colors = c("#ffffff", "#c6dbef", "#6baed6", "#2171b5", "#08306b"),
    how = "eq_hist"
  ))

Per-segment weight works like the point path’s per-point weight: pass edge weights and cells accumulate summed weight instead of a plain crossing count.

Keeping thin marks visible: spread

Aggregated lines are often a single pixel wide, and a lone pixel disappears when the image is shown small or over a busy background. spread() dilates each non-empty pixel over a small neighbourhood — the aggregation is untouched, the marks just get a little bolder. dynspread() chooses the radius automatically from how dense the image already is (dense regions spread less), which is what the spread = "auto" shortcut on the datashade_* functions calls.

sparse <- datashade_segments(
  nx[a][1:1500], ny[a][1:1500], nx[b][1:1500], ny[b][1:1500],
  width = 300, height = 300, colors = c("#ffffff", "#08306b")
)

vl_scene(6, 3, bg = "white") |>
  push(vl_viewport(layout = grid_layout(
    widths = vl_unit(c(1, 1), "null"),
    heights = vl_unit(1, "null")
  ))) |>
  push(vl_viewport(row = 1, col = 1)) |> draw(sparse)            |> pop() |>
  push(vl_viewport(row = 1, col = 2)) |> draw(dynspread(sparse)) |> pop()

Left, the raw one-pixel edges; right, the same raster after dynspread(). Pass spread = 2 (a fixed radius) or spread = "auto" (dynspread) straight to datashade_lines() / datashade_segments() to fold this into the shading call.

Recap

  • datashade() bins a point cloud into a grid and colours cells by density, so cost tracks the grid size, not the point count, and overplotting becomes signal rather than noise.
  • datashade_lines() and datashade_segments() do the same for dense timeseries and network edges: an anti-aliased line rasteriser accumulates coverage per cell, so overlapping lines add rather than saturate.
  • how = "eq_hist" keeps structure visible across orders of magnitude; "log", "cbrt", and "linear" are simpler alternatives.
  • spread() / dynspread() (or spread = on the datashade_* calls) dilate thin rasterised marks so single-pixel lines stay visible.
  • The result is one raster_grob(). Draw it inside a viewport whose scales match xlim / ylim to align it with axes, or tile many of them in a grid_layout() for a gallery.

In a grammar

datashade() is the low-level engine: you supply the coordinates, the limits, and the ramp. A grammar layer on top can wire all of that up from a plot spec. vellumplot exposes it as mark_datashade(), which bins straight from a data frame and fits into a normal plot with scales, guides, and facets. Its datashading article pushes the same engine to its limit, shading the full US Census (about 306 million points) two ways. ```