This vignette is intended as a reference for gggenes development. It explains the internal coordinate transformation pipeline and the rationale behind its design. If you are not planning to make changes to gggenes, you probably don’t need to read this.
gggenes geoms need to combine two types of measurements:
xmin and xmax) that scale with
the plot axesThis creates a challenge because ggplot2’s standard approach of
transforming data coordinates in draw_panel() doesn’t work
when absolute measurements are involved. At draw_panel()
time, the viewport doesn’t exist yet, so there’s no way to convert “4
mm” to native coordinate units. Even if we could, resizing the plot
would invalidate the calculation without re-running
draw_panel().
gggenes solves this by deferring grob construction to render time
using grid’s makeContent() mechanism:
draw_panel() packages the raw data, coord,
and panel_scales into a gTree with a custom
classmakeContent() is called by grid at render time, when
the viewport exists and unit conversions are validmakeContent()This ensures that absolute measurements are correctly converted regardless of plot size, and that resizing triggers a fresh conversion.
gggenes supports three coordinate systems: Cartesian, flipped
(coord_flip()), and polar (coord_polar()).
Rather than writing separate geometry logic for each, the package uses
an abstraction called “along/away”:
This maps to different axes depending on the coordinate system:
| Coordinate System | Along | Away |
|---|---|---|
| Cartesian | x (horizontal) | y (vertical) |
| Flipped | y (vertical) | x (horizontal) |
| Polar | theta (angle) | r (radius) |
With this abstraction, geometry can be defined once in along/away terms and then transformed appropriately for any coordinate system.
The following diagram illustrates the full transformation pipeline for each coordinate system:
TRANSFORMATION PIPELINE
┌─────────────────────────────────────────────────────────────────────────────┐
│ RAW DATA │
│ (xmin, xmax, y in data units) │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
coord$transform()
│
┌─────────────────────────┼─────────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
│ CARTESIAN │ │ FLIPPED │ │ POLAR │
├───────────────────┤ ├───────────────────┤ ├───────────────────┤
│ along = x (NPC) │ │ along = y (NPC) │ │ along = θ (rad) │
│ away = y (NPC) │ │ away = x (NPC) │ │ away = r (0–0.5) │
└───────────────────┘ └───────────────────┘ └───────────────────┘
│ │ │
▼ ▼ ▼
┌───────────────────────────────────────────────────────────────────┐
│ GEOMETRY FUNCTION │
│ (operates in along/away space, same for all coords) │
└───────────────────────────────────────────────────────────────────┘
│ │ │
│ │ ▼
│ │ ┌───────────────────┐
│ │ │ Polar segmentation│
│ │ │ (add vertices for │
│ │ │ smooth curves) │
│ │ └───────────────────┘
│ │ │
▼ ▼ ▼
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
│ FINAL CONVERSION │ │ FINAL CONVERSION │ │ FINAL CONVERSION │
├───────────────────┤ ├───────────────────┤ ├───────────────────┤
│ x = along │ │ x = away │ │ x = 0.5 + r×sin(θ)│
│ y = away │ │ y = along │ │ y = 0.5 + r×cos(θ)│
└───────────────────┘ └───────────────────┘ └───────────────────┘
│ │ │
└─────────────────────────┼─────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ GRID GROB │
│ (x, y in NPC units) │
└─────────────────────────────────────────────────────────────────────────────┘
A key subtlety is that along/away values represent different things depending on the coordinate system:
Cartesian/Flipped: Along and away are NPC
(normalised parent coordinates), scaled 0-1. The transformation from
data to NPC happens via coord$transform() and is complete
at this point.
Polar: Along is theta (radians, 0 to 2π) and away is r (radius, scaled 0–0.5). These are not NPC—they’re polar coordinates that still need to be converted to Cartesian NPC for grid to draw.
This means the data-to-NPC transformation happens at different stages:
Cartesian: data → coord$transform() → NPC (stored as along/away) → grid
Polar: data → coord$transform() → θ/r (stored as along/away) → trig conversion → NPC → grid
To encapsulate this complexity, gggenes uses a
compose_grob() function that handles the entire
transformation pipeline from raw data to final grob. This function:
Geometry is defined as a regular R function that receives a data row with transformed coordinates, and returns the polygon or polyline vertices:
gene_arrow_geometry <- function(data_row, gt, as_along, as_away) {
# Extract transformed coordinates from data_row
along_min <- data_row$along_min
along_max <- data_row$along_max
away <- data_row$away
# Convert units using the converter functions
arrowhead_along <- as_along(gt$arrowhead_width)
arrowhead_away <- as_away(gt$arrowhead_height)
body_away <- as_away(gt$arrow_body_height)
# Compute intermediate values
orientation <- ifelse(along_max > along_min, 1, -1)
arrowhead_along_clamped <- ifelse(
arrowhead_along > abs(along_max - along_min),
abs(along_max - along_min),
arrowhead_along
)
flange <- along_max - orientation * arrowhead_along_clamped
arrowhead_away_half <- arrowhead_away / 2
body_away_half <- body_away / 2
# Return vertex coordinates
list(
alongs = c(along_min, along_min, flange, flange, along_max, flange, flange),
aways = c(
away + body_away_half,
away - body_away_half,
away - body_away_half,
away - arrowhead_away_half,
away,
away + arrowhead_away_half,
away + body_away_half
)
)
}The function receives a standard interface:
data_row: A single-row data frame with transformed
coordinates. Contains along (for point geoms) or
along_min/along_max (for range geoms), plus
away. For subgene geoms, also contains
along_submin/along_submax.gt: The gTree object containing geom-specific
parameters as grid::unit() objects (e.g.,
gt$arrowhead_width).as_along: Function to convert a
grid::unit() to NPC along-units.as_away: Function to convert a
grid::unit() to NPC away-units.All coordinate values in data_row are already
transformed to along/away space. The geometry function converts unit
measurements as needed using the converter functions.
Converting absolute measurements to along/away values requires knowing:
compose_grob() creates the as_along and
as_away converter functions that close over these
values:
# Inside compose_grob():
as_along <- if (coord_system == "cartesian") {
function(unit) as.numeric(grid::convertWidth(unit, "npc"))
} else if (coord_system == "polar") {
function(unit) as.numeric(grid::convertWidth(unit, "npc")) / r
} else if (coord_system == "flip") {
function(unit) as.numeric(grid::convertHeight(unit, "npc"))
}
as_away <- if (coord_system == "cartesian") {
function(unit) as.numeric(grid::convertHeight(unit, "npc"))
} else if (coord_system == "polar") {
function(unit) as.numeric(grid::convertHeight(unit, "npc"))
} else if (coord_system == "flip") {
function(unit) as.numeric(grid::convertWidth(unit, "npc"))
}ggplot2 draws polar-coordinate plots by defining them in Cartesian
coordinates that are passed to grid. Grid doesn’t know it’s drawing a
polar-coordinate plot. Hence, a straight line in a polar-coordinate plot
cannot be defined as a line between two points, as this would be drawn
as a straight chord rather than an arc. To make lines follow the polar
geometry, they must be broken into many small segments before conversion
to Cartesian coordinates. In ggplot2, this is known as ‘munching’. This
segmentation is handled automatically by
compose_grob().
The segmentation algorithm works as follows:
sqrt((Δr)² + (Δθ)²)round(length * 100) segments between the
vertices—approximately 100 segments per unit of combined polar
distanceid groupings to
keep separate line segments separateNote that compose_grob() includes handling for the
special case where theta wraps around at 0/2π. When a range geom’s
endpoint transforms to exactly 0 radians but should logically be 2π
(based on the original data ordering), the value is corrected. However,
geometries that truly span across the 0/2π boundary (e.g., from 350° to
10°) are not currently supported and would need to be split into two
separate geometries.
After geometry is computed in along/away space, it’s converted to grid x/y:
if (coord_system == "cartesian") {
x <- alongs
y <- aways
} else if (coord_system == "polar") {
x <- 0.5 + aways * sin(alongs)
y <- 0.5 + aways * cos(alongs)
} else if (coord_system == "flip") {
x <- aways
y <- alongs
}For polar, this is the standard polar-to-Cartesian conversion, centered at (0.5, 0.5) in the viewport.
With this architecture:
draw_panel() is minimal; it just packages data, coord,
and panel_scales into a gTreemakeContent() defines units and the
geometry() function, then iterates over data rows calling
compose_grob() for eachcompose_grob() encapsulates the coordinate
transformation pipeline, which is independent of the geometry of any
individual geomThis makes it straightforward to add new geoms: define a geometry
function and unit specifications, then call compose_grob()
with the appropriate grob type. Currently, compose_grob()
supports three grob types:
"polygon": Creates a closed polygon using
grid::polygonGrob()"polyline": Creates open line(s) using
grid::polylineGrob(), with optional id vector
for multiple segments and arrow parameter for
arrowheads"text": Creates a text label using ggfittext. The
geometry function returns a bounding box (along_min,
along_max, away_min, away_max)
instead of vertices. Text styling (fontface, colour, etc.) comes from
data_row columns rather than the gp
parametermakeContent.genearrowtree <- function(x) {
data <- x$data
# Define geometry function with standard interface
geometry <- function(data_row, gt, as_along, as_away) {
# Extract transformed coordinates
along_min <- data_row$along_min
along_max <- data_row$along_max
away <- data_row$away
# Convert units
arrowhead_along <- as_along(gt$arrowhead_width)
arrowhead_away <- as_away(gt$arrowhead_height)
body_away <- as_away(gt$arrow_body_height)
# Compute geometry
orientation <- ifelse(along_max > along_min, 1, -1)
arrowhead_along_clamped <- ifelse(
arrowhead_along > abs(along_max - along_min),
abs(along_max - along_min),
arrowhead_along
)
flange <- along_max - orientation * arrowhead_along_clamped
arrowhead_away_half <- arrowhead_away / 2
body_away_half <- body_away / 2
list(
alongs = c(
along_min,
along_min,
flange,
flange,
along_max,
flange,
flange
),
aways = c(
away + body_away_half,
away - body_away_half,
away - body_away_half,
away - arrowhead_away_half,
away,
away + arrowhead_away_half,
away + body_away_half
)
)
}
# Prepare grob for each gene
grobs <- lapply(seq_len(nrow(data)), function(i) {
gene <- data[i, ]
# Reverse non-forward genes
if (!as.logical(gene$forward)) {
gene[, c("xmin", "xmax")] <- gene[, c("xmax", "xmin")]
}
# Set up graphical parameters
gp <- grid::gpar(
fill = ggplot2::alpha(gene$fill, gene$alpha),
col = ggplot2::alpha(gene$colour, gene$alpha),
lty = gene$linetype,
lwd = gene$linewidth * ggplot2::.pt
)
compose_grob(
geometry_fn = geometry,
gt = x,
data_row = gene,
grob_type = "polygon",
gp = gp
)
})
class(grobs) <- "gList"
grid::setChildren(x, grobs)
}