gg2d3 renders ggplot2 plots as interactive D3.js SVG widgets in the
browser. Pass any ggplot object to gg2d3() and get an
htmlwidget you can view in RStudio, R Markdown, Shiny, or any web
page.
Basic usage
library(ggplot2)
library(gg2d3)
p <- ggplot(mtcars, aes(wt, mpg, color = factor(cyl))) +
geom_point() +
ggtitle("Motor Trend Cars")
gg2d3(p)The widget size defaults to the viewer/container size. Override with
width and height (in pixels or CSS units):
gg2d3(p, width = 800, height = 500)Supported geoms
gg2d3 supports the core Cartesian geoms below, ordinary
geom_polygon(), polygon-family, point-family, and
line-family geom_sf(), plus projected-anchor
geom_sf_text() and geom_sf_label()
annotations. All aesthetics that ggplot2 maps (color, fill, size, shape,
alpha, linewidth) are carried through to D3. Detailed geometry caveats
live in vignettes/d3-drawing-diagnostics.md.
v1.13 validation and caveat summary
The v1.13 support contract is source-first and intentionally bounded.
Browser visual validation uses the dedicated browser visual smoke
workflow and the same test_output/browser-visual-smoke/
artifact layout documented in diagnostics; local runs may skip cleanly
when optional browser tooling is unavailable, while CI mode treats
browser-level skips as failures. Renderer and interactivity wiring are
guarded by geom-contracts.js, and representative IR
helper-boundary tests cover the selected theme and geom-parameter helper
boundaries without claiming full as_d3_ir()
modularization.
Geometry caveats are also explicit. Ordinary
geom_label() support covers bounded SVG label boxes and
small placement fields. Ordinary geom_polygon() support
remains a grouped closed-path renderer, not topology or hole repair.
geom_rect() and geom_tile() use ggplot2-built
transformed bounds and filter non-finite transformed SVG bounds before
drawing. Collision avoidance, rich text, path-following text, broad
GIS-style topology repair, committed pixel thresholds, and generated
renderer reference docs remain future work. See
vignettes/d3-drawing-diagnostics.md for the detailed v1.13
release validation commands and FUT deferrals.
Points, lines, and paths
# Scatter plot
(ggplot(iris, aes(Sepal.Length, Sepal.Width, color = Species)) +
geom_point(size = 3)) |>
gg2d3()
# Line chart (connects points in x order)
(ggplot(economics, aes(date, unemploy)) +
geom_line()) |>
gg2d3()
# Path (connects points in data order)
(ggplot(data.frame(x = cos(seq(0, 2 * pi, length.out = 60)),
y = sin(seq(0, 2 * pi, length.out = 60))),
aes(x, y)) +
geom_path() +
coord_fixed()) |>
gg2d3()Rectangles, tiles, and text
Ordinary polygons
Ordinary geom_polygon() renders each group as a grouped
closed SVG path while preserving ggplot2’s built row order. Fill,
stroke, alpha, linewidth, linetype, facets, zoom/update behavior, and
the existing tooltip, hover, brush, handler, and linked-view hooks are
supported at the polygon path level.
poly <- data.frame(
id = rep(c("a", "b"), each = 4),
x = c(0, 1, 1.2, 0, 1.5, 2.6, 2.2, 1.3),
y = c(0, 0.2, 1, 0.8, 0.1, 0.4, 1.2, 0.9)
)
(ggplot(poly, aes(x, y, group = id, fill = id)) +
geom_polygon(color = "white", linewidth = 0.4, alpha = 0.8) +
coord_fixed()) |>
gg2d3()This is a grouped-path contract, not a GIS topology engine:
topology/hole repair outside clean ggplot2 built groups is deferred. See
vignettes/d3-drawing-diagnostics.md for detailed
caveats.
Area and ribbon
Segments and reference lines
(ggplot(mtcars, aes(wt, mpg)) +
geom_point() +
geom_hline(yintercept = 20, linetype = "dashed", color = "red") +
geom_vline(xintercept = 3, linetype = "dotted", color = "blue")) |>
gg2d3()geom_segment and geom_abline are also
supported.
Statistical geoms
These geoms are pre-computed in R (via ggplot2’s stat system) and rendered by D3. No JavaScript statistics are needed.
# Boxplot
(ggplot(mpg, aes(class, hwy)) +
geom_boxplot()) |>
gg2d3()
# Violin
(ggplot(mpg, aes(class, hwy, fill = class)) +
geom_violin()) |>
gg2d3()
# Density
(ggplot(diamonds, aes(price, fill = cut)) +
geom_density(alpha = 0.5)) |>
gg2d3()
# Smooth (loess or lm)
(ggplot(mpg, aes(displ, hwy)) +
geom_point() +
geom_smooth(method = "loess")) |>
gg2d3()
#> `geom_smooth()` using formula = 'y ~ x'sf family maps with geom_sf
geom_sf() supports polygon-family (POLYGON,
MULTIPOLYGON), point-family (POINT,
MULTIPOINT), and line-family (LINESTRING,
MULTILINESTRING) geometries. Polygon-family choropleths and
overlays render as D3 path marks; point-family rows render
as .geom-sf-point marks; and line-family rows render as
.geom-sf-line paths. geom_sf_text() and
geom_sf_label() render labels at projected anchors aligned
with those accepted sf families. This example uses the nc
shapefile bundled with sf and renders county boundaries as
D3 path marks.
has_sf <- requireNamespace("sf", quietly = TRUE)
has_geojsonsf <- requireNamespace("geojsonsf", quietly = TRUE)
missing_sf_packages <- c(
if (!has_sf) "sf",
if (!has_geojsonsf) "geojsonsf"
)
if (length(missing_sf_packages) > 0) {
cat(
"PKGDOWN_SF_OPTIONAL_SKIP: sf example not rendered; missing ",
paste(missing_sf_packages, collapse = ", "),
".\n",
sep = ""
)
} else {
sf_pkg <- asNamespace("sf")
nc <- sf_pkg$st_read(system.file("shape/nc.shp", package = "sf"), quiet = TRUE)
(ggplot(nc, aes(fill = AREA)) +
geom_sf(color = "white", linewidth = 0.2) +
scale_fill_gradient(low = "#eff3ff", high = "#08519c") +
labs(fill = "Area")) |>
gg2d3() |>
d3_tooltip(fields = c("NAME", "AREA")) |>
d3_hover(opacity = 0.35, stroke = "#111827", stroke_width = 1.5) |>
d3_brush(fill = "#f59e0b", opacity = 0.2)
}The geom_sf() support contract is intentionally
explicit:
- Accepted families are polygon-family (
POLYGON,MULTIPOLYGON), point-family (POINT,MULTIPOINT), and line-family (LINESTRING,MULTILINESTRING), including projected-anchorgeom_sf_text()andgeom_sf_label()annotations for those families. - known CRS inputs are normalized to WGS84 in R before serialization.
- Missing CRS emits
geom_sf layer has missing CRS; coordinates will be serialized as-is. - Rows that are unsupported, empty, invalid, or missing emit
geom_sf layer skipped %d unsupported, empty, invalid, or missing geometriesand are skipped while accepted rows remain renderable. - Optional browser validation is R/testthat/chromote based and may skip cleanly; when available, it covers sf family interactivity, stacked overlays, faceted and empty panels, projected anchor placement, sanitized interactivity payloads, and zoom suppression.
- gg2d3 does not provide tile basemaps, slippy map controls, JavaScript-side CRS reprojection, true geometry-overlap brushing, or large-map performance guarantees.
- sf annotations do not provide ggrepel collision avoidance, rich
text, rotation parity, or path-following placement. See
vignettes/d3-drawing-diagnostics.mdfor the detailed residual-risk list.
Scales
Continuous transforms
Log, sqrt, and reverse transforms work as expected:
(ggplot(diamonds, aes(carat, price)) +
geom_point(alpha = 0.1) +
scale_y_log10()) |>
gg2d3()Date and datetime scales
Date and POSIXct columns are automatically detected and rendered with
temporal D3 scales. Axis tick labels use the format from ggplot2’s
date_labels argument:
df <- data.frame(
date = seq(as.Date("2024-01-01"), as.Date("2024-12-31"), by = "month"),
value = cumsum(rnorm(12))
)
(ggplot(df, aes(date, value)) +
geom_line() +
geom_point() +
scale_x_date(date_labels = "%b %Y")) |>
gg2d3()POSIXct (datetime) works the same way:
df <- data.frame(
time = as.POSIXct("2024-01-01") + (0:23) * 3600,
temp = 15 + 5 * sin(seq(0, 2 * pi, length.out = 24)) + rnorm(24, sd = 0.5)
)
(ggplot(df, aes(time, temp)) +
geom_line() +
scale_x_datetime(date_labels = "%H:%M")) |>
gg2d3()Timezone information from
scale_x_datetime(timezone = ...) is preserved in
tooltips.
Secondary axes
Secondary axes are fully rendered — ticks, labels, and the axis title
from sec_axis() all appear on the opposite side of the
panel.
Color scales
Continuous color and fill aesthetics render as a true colorbar legend (a gradient with axis ticks), not a stack of discrete keys. Discrete palettes — viridis, brewer, manual — produce identical hex codes to ggplot2’s own output.
# Viridis continuous → colorbar legend
(ggplot(faithfuld, aes(waiting, eruptions, fill = density)) +
geom_tile() +
scale_fill_viridis_c()) |>
gg2d3()
# Brewer discrete
(ggplot(mpg, aes(displ, hwy, color = class)) +
geom_point() +
scale_color_brewer(palette = "Set2")) |>
gg2d3()
# Manual
(ggplot(mtcars, aes(wt, mpg, color = factor(cyl))) +
geom_point(size = 3) +
scale_color_manual(values = c("4" = "#1b9e77", "6" = "#d95f02", "8" = "#7570b3"))) |>
gg2d3()Coordinates
coord_flip
Swaps x and y axes. All geoms and scales adapt automatically:
(ggplot(mpg, aes(class, hwy)) +
geom_boxplot() +
coord_flip()) |>
gg2d3()coord_fixed
Enforces a fixed aspect ratio between x and y units:
(ggplot(mtcars, aes(wt, mpg)) +
geom_point() +
coord_fixed(ratio = 1)) |>
gg2d3()Faceting
facet_wrap
Wraps panels into rows by one or more variables:
(ggplot(mpg, aes(displ, hwy)) +
geom_point() +
facet_wrap(~class)) |>
gg2d3()With free scales:
(ggplot(mpg, aes(displ, hwy)) +
geom_point() +
facet_wrap(~class, scales = "free")) |>
gg2d3()facet_grid
Lays out panels in a grid defined by row and column variables:
(ggplot(mpg, aes(displ, hwy)) +
geom_point() +
facet_grid(drv ~ cyl)) |>
gg2d3()Free scales work per-row ("free_y") or per-column
("free_x"):
(ggplot(mpg, aes(displ, hwy)) +
geom_point() +
facet_grid(drv ~ cyl, scales = "free")) |>
gg2d3()Legends
Legends are generated automatically from mapped aesthetics. All standard legend types are supported:
- Discrete color/fill — color swatches with labels
- Continuous colorbar — gradient bar for continuous color/fill scales
- Size — graduated circles
- Shape — different point shapes
- Alpha — opacity levels
Legends can be positioned with
theme(legend.position = ...):
(ggplot(iris, aes(Sepal.Length, Sepal.Width, color = Species)) +
geom_point() +
theme(legend.position = "bottom")) |>
gg2d3()Use theme(legend.position = "none") to hide legends
entirely.
When multiple aesthetics share the same variable, guides are merged into a single legend automatically.
Theming
gg2d3 translates ggplot2 theme elements to SVG styling:
(ggplot(mtcars, aes(wt, mpg)) +
geom_point() +
theme_minimal() +
ggtitle("Minimal theme") +
labs(subtitle = "Rendered with D3", caption = "Source: mtcars")) |>
gg2d3()Theme elements that are translated include:
- Plot, panel, and legend backgrounds
- Major and minor grid lines
- Axis lines, ticks, and text
- Plot title, subtitle, and caption
- Legend title and text styling
Interactivity
gg2d3 provides a composable pipe-based API for adding interactivity. Each function takes a widget and returns a widget, so they chain naturally:
(ggplot(iris, aes(Sepal.Length, Sepal.Width, color = Species)) +
geom_point(size = 3)) |>
gg2d3() |>
d3_tooltip() |>
d3_hover() |>
d3_zoom() |>
d3_brush()You can use any combination — they are all optional and independent.
Tooltips
d3_tooltip() shows data values on hover. By default it
displays all mapped aesthetics.
# Default: show all aesthetics
gg2d3(p) |> d3_tooltip()
# Show specific fields only
gg2d3(p) |> d3_tooltip(fields = c("wt", "mpg"))
# Custom JavaScript formatter
gg2d3(p) |> d3_tooltip(formatter = "function(d) { return d.mpg + ' mpg'; }")Tooltips automatically format date/datetime values using the browser’s locale.
Hover highlighting
d3_hover() dims non-hovered elements so the hovered
group stands out.
# Softer dimming + highlight stroke
gg2d3(p) |> d3_hover(opacity = 0.5, stroke = "black", stroke_width = 2)When a brush selection is active, hover highlighting is automatically disabled to avoid visual conflicts.
Zoom and pan
d3_zoom() enables scroll-to-zoom and drag-to-pan.
Double-click resets to the original view.
Axes update dynamically during zoom. Temporal axes preserve their date formatting.
Brush selection
d3_brush() lets users drag to select a rectangular
region. Selected elements stay at full opacity while others dim.
# Custom callback receiving selected data
gg2d3(p) |> d3_brush(
on_brush = "function(data) { console.log(data.length + ' points selected'); }"
)Double-click clears the brush selection.
Linked views with Crosstalk
gg2d3 supports crosstalk for linking multiple widgets. Brushing in one widget highlights the same observations in all linked widgets.
has_crosstalk <- requireNamespace("crosstalk", quietly = TRUE)
has_htmltools <- requireNamespace("htmltools", quietly = TRUE)
if (!has_crosstalk || !has_htmltools) {
missing_crosstalk_packages <- c(
if (!has_crosstalk) "crosstalk",
if (!has_htmltools) "htmltools"
)
cat(
"PKGDOWN_CROSSTALK_OPTIONAL_SKIP: linked-view example not rendered; missing ",
paste(missing_crosstalk_packages, collapse = ", "),
".\n",
sep = ""
)
} else {
shared <- crosstalk::SharedData$new(
iris,
key = rownames(iris),
group = "pkgdown_crosstalk_iris"
)
p1 <- ggplot(iris, aes(Sepal.Length, Sepal.Width, color = Species)) +
geom_point(size = 2)
p1$data <- shared
p2 <- ggplot(iris, aes(Petal.Length, Petal.Width, color = Species)) +
geom_point(size = 2)
p2$data <- shared
w1 <- gg2d3(p1) |> d3_tooltip() |> d3_brush()
w2 <- gg2d3(p2) |> d3_tooltip() |> d3_brush()
htmltools::tagList(
htmltools::div(
style = paste(
"display: grid;",
"grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));",
"gap: 1rem;"
),
w1,
w2
)
)
}Crosstalk works in static HTML documents — no Shiny server required.
Combining features
A realistic example combining multiple features:
# warning = FALSE: loess emits "neighborhood too small" / "pseudoinverse"
# notes when fit per (class × year) — some classes have <4 points per
# facet. The fit still renders; the messages are expected for this demo.
(ggplot(mpg, aes(displ, hwy, color = class)) +
geom_point(size = 2) +
geom_smooth(method = "loess", se = TRUE) +
facet_wrap(~year) +
scale_color_brewer(palette = "Set2") +
labs(
title = "Engine displacement vs highway MPG",
subtitle = "By vehicle class and model year",
x = "Displacement (L)",
y = "Highway MPG"
) +
theme_minimal()) |>
gg2d3() |>
d3_tooltip() |>
d3_hover() |>
d3_zoom()Error handling and edge cases
gg2d3 provides three observable guarantees when data or geoms fall outside normal rendering scope:
-
Non-finite values —
NA,NaN, andInfare filtered from each layer with a single R warning per layer. Remaining finite points render normally; line/path geoms show a visible gap where the non-finite rows were removed. - Unsupported geoms — when a geom type has no D3 renderer, gg2d3 emits a browser-console warning instead of rendering marks for that layer.
-
R-level errors during build — if
ggplot_build()itself errors (e.g., incompatible stat/geom combinations), the error is surfaced as an R condition before any D3 rendering is attempted.
# Non-finite values: filtered with a single warning per layer;
# remaining points render with visible gaps
df <- data.frame(x = 1:10, y = c(1:4, NA, 6:9, NaN))
(ggplot(df, aes(x, y)) +
geom_point() +
geom_line()) |>
gg2d3()
# Warning: Removed 2 rows containing non-finite values (geom_point).
# The geom_line connects the finite segments and shows a visible gap.
# Ordinary polygons: grouped closed paths with row-order preservation
poly_edges <- data.frame(
group = rep(c("left", "right"), each = 4),
x = c(0, 1, 1, 0, 1.4, 2.4, 2.1, 1.2),
y = c(0, 0, 1, 0.8, 0.1, 0.2, 1, 0.9)
)
(ggplot(poly_edges, aes(x, y, group = group, fill = group)) +
geom_polygon(color = "grey35", linewidth = 0.4, alpha = 0.75) +
coord_fixed()) |>
gg2d3()
# topology/hole repair beyond grouped closed paths remains outside the shipped
# support contract.Tips
-
Pipe from ggplot directly: wrap the ggplot
expression in parentheses so
+resolves before|>:(ggplot(...) + geom_point()) |> gg2d3(). Without the parens,|>binds tighter than+andgg2d3()receives only the last geom — not the plot. The cleaner alternative is to assign first:p <- ggplot(...) + geom_point(); gg2d3(p). -
Inspect the IR: Use
gg2d3:::as_d3_ir(p)to see exactly what data is sent to D3. Useful for debugging unexpected rendering. -
Widget sizing: In R Markdown, set chunk options
fig.widthandfig.heightor passwidth/heighttogg2d3(). -
Performance: For large datasets (>10k points),
consider using
alphato reduce overdraw and limit interactivity features to what you need.