--- title: "Coordinate Transformations in gggenes" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Coordinate Transformations in gggenes} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>" ) ``` 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. ## The Problem gggenes geoms need to combine two types of measurements: 1. **Data-derived coordinates** (e.g. gene positions defined with `xmin` and `xmax`) that scale with the plot axes 2. **Absolute measurements** (e.g. arrowhead dimensions defined in millimetres) that remain constant regardless of plot size This 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()`. ## The Solution: Deferred Rendering gggenes solves this by deferring grob construction to render time using grid's `makeContent()` mechanism: 1. `draw_panel()` packages the raw data, `coord`, and `panel_scales` into a `gTree` with a custom class 2. `makeContent()` is called by grid at render time, when the viewport exists and unit conversions are valid 3. The actual geometry is constructed and converted to grid coordinates inside `makeContent()` This ensures that absolute measurements are correctly converted regardless of plot size, and that resizing triggers a fresh conversion. ## The Along/Away Abstraction 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": - **Along**: The direction along the molecule backbone - **Away**: The direction perpendicular to the backbone 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) │ └─────────────────────────────────────────────────────────────────────────────┘ ``` ### Important: Along/Away Are Not Always NPC 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 ``` ## The compose_grob() Pipeline To encapsulate this complexity, gggenes uses a `compose_grob()` function that handles the entire transformation pipeline from raw data to final grob. This function: 1. Detects the coordinate system 2. Transforms data to along/away coordinates 3. Converts unit measurements (mm, etc.) to along/away values 4. Calls a geometry function with the transformed values 5. Segments the geometry for polar coordinates (so curves appear smooth) 6. Converts along/away to grid NPC coordinates 7. Creates and returns the appropriate grob ### Geometry Functions Geometry is defined as a regular R function that receives a data row with transformed coordinates, and returns the polygon or polyline vertices: ```r 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. ### Unit Conversion Converting absolute measurements to along/away values requires knowing: 1. The coordinate system (determines which grid dimension to convert) 2. For polar "along", the current radius (angular size scales inversely with radius) `compose_grob()` creates the `as_along` and `as_away` converter functions that close over these values: ```r # 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")) } ``` ### Polar Segmentation 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: 1. For each pair of consecutive vertices (with wrap-around for polygons), calculate a "length" in polar space: `sqrt((Δr)² + (Δθ)²)` 2. Create `round(length * 100)` segments between the vertices—approximately 100 segments per unit of combined polar distance 3. Linearly interpolate both r and θ to create the intermediate points 4. For polylines, the algorithm respects `id` groupings to keep separate line segments separate Note 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. ### Final Conversion After geometry is computed in along/away space, it's converted to grid x/y: ```r 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. ## Putting It Together With this architecture: - `draw_panel()` is minimal; it just packages data, coord, and panel_scales into a gTree - `makeContent()` defines units and the `geometry()` function, then iterates over data rows calling `compose_grob()` for each - `compose_grob()` encapsulates the coordinate transformation pipeline, which is independent of the geometry of any individual geom This 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` parameter ### Example: Complete makeContent Implementation ```r makeContent.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) } ```