Skip to content

Feature request: Support adjustable padding for panel clipping #6881

Description

@psoldath

coord_cartesian() currently provides two clear and useful clipping behaviours:

  • clip = "on" clips panel content exactly at the panel boundary.
  • clip = "off" allows panel content to extend beyond that boundary.

For some plots, however, ggplot2 users could benefit from an intermediate option: retaining panel clipping while allowing the clipping boundary to extend by a small, adjustable physical distance.

This would be particularly valuable when a geom is positioned exactly at, or very close to, a coordinate limit. In such cases, part of the rendered geom may extend beyond the panel and be removed when clip = "on". Setting clip = "off" preserves the complete geom, but also allows other panel content to extend freely into the surrounding plot area, which may be undesirable.

An adjustable clipping allowance—for example, half a line width—could preserve boundary geoms while retaining the protection provided by panel clipping.

Current behaviour with clip = "on"

The following example places a horizontal line at the upper y-limit and a vertical line at the upper x-limit. The line widths and tick lengths are exaggerated to make the clipping clearly visible.

library(ggplot2)

line_width <- 3

plot_clipped <- ggplot(
  data.frame(
    x = c(0, 1),
    y = c(0, 1)
  ),
  aes(x, y)
) +
  geom_blank() +
  geom_hline(yintercept = 1) +
  geom_vline(xintercept = 1) +
  coord_cartesian(
    expand = FALSE,
    clip = "on"
  ) +
  theme_classic(
    base_line_size = line_width
  ) +
  theme(
    axis.ticks.length = grid::unit(
      line_width * 3,
      "pt"
    )
  )

plot_clipped
Image

The horizontal and vertical lines are centred on the upper coordinate limits. Half of each stroke therefore lies inside the panel and half outside it.

Because clip = "on" clips exactly at the panel boundary, the outer half of each stroke is removed. The boundary geoms consequently appear to have only half their intended line width.

This is particularly visible beside the axis lines and terminal tick marks, which retain their complete strokes. A line positioned at the same coordinate as the final break would more naturally have half its stroke inside the panel and half immediately outside it, just as a tick mark is centred on its break.

Current behaviour with clip = "off"

Disabling clipping preserves the complete boundary geoms:

plot_unclipped <- plot_clipped +
  coord_cartesian(
    expand = FALSE,
    clip = "off"
  )

plot_unclipped
Image

The geoms now retain their intended line width and correspond visually with the axis lines and tick marks.

However, clip = "off" disables clipping for all panel content. Geoms can therefore extend into the axes, labels, legends, and plot margins.

This is a broader change than is needed when the goal is simply to preserve the outer half of a stroke at the panel boundary.

Proposed behaviour

Could coord_cartesian() support a small, adjustable amount of physical padding around the clipping boundary?

For example:

coord_cartesian(
  expand = FALSE,
  clip = "on",
  clip_padding = grid::unit(0.2, "mm")
)

The coordinate limits, panel dimensions, axis positions, and data mapping would remain unchanged. Only the physical boundary at which panel content is clipped would move slightly beyond the panel.

This would provide an intermediate option:

  • content within the small clipping allowance would remain visible;
  • content extending farther outside the panel would still be clipped.

A natural allowance for line-based geoms would often be half a line width. For visual consistency, a line positioned at the final break should be able to retain half its stroke inside the panel and half immediately outside it, just as the tick mark is centred on that break.

The exact interface is not central to this request. The main feature would be the ability to control the physical clipping boundary independently of the coordinate range and panel dimensions.

Proof of concept

The desired behaviour can be produced by preserving the original viewport for coordinate mapping and wrapping it in a slightly larger viewport that controls clipping.

The following helper converts a ggplot2 line width to half of its physical stroke width:

half_linewidth_unit <- function(linewidth) {
  lwd <- linewidth * ggplot2::.pt

  # Convert the full stroke width to inches, then take half.
  grid::unit(
    (lwd / 96) / 2,
    "inches"
  )
}

A custom Cartesian coordinate system can then override the panel drawing method:

CoordCartesianPadded <- ggplot2::ggproto(
  "CoordCartesianPadded",
  ggplot2::CoordCartesian,

  clip_padding = NULL,

  draw_panel = function(
    self,
    panel,
    params,
    theme
  ) {
    padding <- self$clip_padding

    if (is.null(padding)) {
      linewidth <- ggplot2::calc_element(
        "geom",
        theme
      )$linewidth

      padding <- half_linewidth_unit(linewidth)
    }

    if (
      !grid::is.unit(padding) ||
        length(padding) != 1L
    ) {
      stop(
        "`clip_padding` must be NULL or a single grid unit.",
        call. = FALSE
      )
    }

    parent <- ggplot2::ggproto_parent(
      ggplot2::CoordCartesian,
      self
    )

    inner_panel <- parent$draw_panel(
      panel = panel,
      params = params,
      theme = theme
    )

    # Preserve the original panel dimensions and coordinate mapping.
    inner_panel$vp <- grid::viewport(
      width = grid::unit(1, "npc") - 2 * padding,
      height = grid::unit(1, "npc") - 2 * padding,
      clip = "inherit"
    )

    # Apply clipping at the enlarged physical boundary.
    grid::gTree(
      children = grid::gList(inner_panel),
      vp = grid::viewport(
        width = grid::unit(1, "npc") + 2 * padding,
        height = grid::unit(1, "npc") + 2 * padding,
        clip = self$clip
      )
    )
  }
)

The corresponding constructor reuses the existing coord_cartesian() arguments and validation:

coord_cartesian_padded <- function(
  xlim = NULL,
  ylim = NULL,
  expand = TRUE,
  default = FALSE,
  clip = "on",
  reverse = "none",
  ratio = NULL,
  clip_padding = NULL
) {
  coord <- ggplot2::coord_cartesian(
    xlim = xlim,
    ylim = ylim,
    expand = expand,
    default = default,
    clip = clip,
    reverse = reverse,
    ratio = ratio
  )

  ggplot2::ggproto(
    NULL,
    CoordCartesianPadded,
    limits = coord$limits,
    reverse = coord$reverse,
    expand = coord$expand,
    default = coord$default,
    clip = coord$clip,
    ratio = coord$ratio,
    clip_padding = clip_padding
  )
}

The example can then be recreated with a clipping allowance equal to half the line width:

plot_padded <- ggplot(
  data.frame(
    x = c(0, 1),
    y = c(0, 1)
  ),
  aes(x, y)
) +
  geom_blank() +
  geom_hline(yintercept = 1) +
  geom_vline(xintercept = 1) +
  coord_cartesian_padded(
    expand = FALSE,
    clip = "on",
    clip_padding = half_linewidth_unit(line_width)
  ) +
  theme_classic(
    base_line_size = line_width
  ) +
  theme(
    axis.ticks.length = grid::unit(
      line_width * 3,
      "pt"
    )
  )

plot_padded
Image

The coordinate range and data mapping are unchanged. The boundary geoms retain their complete strokes, while content extending beyond the small allowance remains clipped.

This code is intended only as a proof of concept rather than a proposed implementation.

Why this would be valuable

Plots without axis expansion are common in publication figures. In these plots, geoms positioned at or near a panel boundary may be clipped and therefore rendered smaller than intended.

Current workarounds either change the coordinate mapping, modify the data, require low-level grob manipulation, or disable clipping entirely. A small clipping allowance would preserve the intended appearance of boundary geoms without changing the coordinate mapping or disabling panel clipping.

An explicit clip_padding argument would provide the essential functionality, potentially with half the resolved geom line width as a useful default.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions