Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ visualizations.
lowercase letters (`"x"`, `"y"`, `"xy"`) draw a finer grid with additional
lines at the midpoints between ticks. Thanks to @zeileis for the suggestion.
(#578 @grantmcdermott)
- New `legend = "direct"` option (experimental) places text labels at the last
point of each group's data, coloured to match. Best suited to line-based plots
with x-sorted data. The right margin is automatically expanded to prevent
clipping. Pairs well with dynamic themes. For faceted plots, labels are drawn
in each panel for the groups present there. Supports `nudge_x`/`nudge_y` for
manual per-group offsets and `repel` for automatic vertical separation of
overlapping labels, e.g. `legend = legend("direct", repel = TRUE)`.
(#587 @grantmcdermott)
- `tinytheme()` now accepts additional `gap.axis` and `gap.lab` "primitives",
providing finer control for spacing between ticks-labels and labels-titles,
respectively, in dynamic themes. See the **Dynamic themes** entry above.
Expand Down
7 changes: 6 additions & 1 deletion R/facet.R
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ draw_facet_window = function(
ylab,
y, ymax, ymin,
tpars = NULL,
dynmar_computed = NULL
dynmar_computed = NULL,
dl_overshoot = 0
) {

if (is.null(tpars)) tpars = tpar()
Expand Down Expand Up @@ -195,6 +196,10 @@ draw_facet_window = function(
}
}

if (dl_overshoot > 0) {
fmar[4] = fmar[4] + dl_overshoot
}

# Now we set the margins. The trick here is that we simultaneously adjust
# inner (mar) and outer (oma) margins by the same amount, but in opposite
# directions, to preserve the overall facet and plot centroids.
Expand Down
6 changes: 6 additions & 0 deletions R/legend.R
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ sanitize_legend = function(legend, legend_args) {
if (length(new_legend) >= 1 && (is.null(names(new_legend)) || names(new_legend)[1] == "")) {
names(new_legend)[1] = "x"
}
# Evaluate language elements so e.g. c(0, 1, 2) becomes a real vector
for (nm in names(new_legend)) {
if (is.language(new_legend[[nm]])) {
new_legend[[nm]] = eval(new_legend[[nm]])
}
}
new_legend
} else {
list(x = "right!") # Fallback
Expand Down
106 changes: 106 additions & 0 deletions R/repel.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
#' Force-directed text repelling
#'
#' Resolves overlapping text labels using a two-phase algorithm: (1) push
#' overlapping labels apart symmetrically, (2) spring-pull labels back toward
#' their anchors without reintroducing overlaps.
#'
#' @param x,y Numeric vectors of current label positions.
#' @param widths,heights Numeric vectors of label bounding box dimensions (in
#' the same units as `x` and `y`).
#' @param anchor_x,anchor_y Numeric vectors of original target positions that
#' labels are pulled back toward during the spring pass. Defaults to `x`
#' and `y`.
#' @param min_gap Minimum padding between labels. Default `0`.
#' @param iterations Maximum number of repulsion iterations. Default `20`.
#' @param axis Movement constraint: `"both"` (default), `"x"`, or `"y"`.
#' @return A list with elements `x` and `y` giving adjusted positions.
#' @keywords internal
repel_text = function(x, y, widths, heights,
anchor_x = x, anchor_y = y,
min_gap = 0, iterations = 20,
axis = "both") {
n = length(x)
if (n <= 1) return(list(x = x, y = y))

valid = !is.na(x) & !is.na(y)
vx = x[valid]
vy = y[valid]
ax = anchor_x[valid]
ay = anchor_y[valid]
vw = widths[valid]
vh = heights[valid]
nv = sum(valid)

move_x = axis %in% c("both", "x")
move_y = axis %in% c("both", "y")

# Phase 1: resolve overlaps by pushing apart (full overlap each step)
for (iter in seq_len(iterations)) {
any_overlap = FALSE
for (i in seq_len(nv - 1)) {
for (j in (i + 1):nv) {
ox = (vw[i] + vw[j]) / 2 + min_gap - abs(vx[i] - vx[j])
oy = (vh[i] + vh[j]) / 2 + min_gap - abs(vy[i] - vy[j])

overlapping = if (axis == "y") oy > 0
else if (axis == "x") ox > 0
else ox > 0 && oy > 0
if (overlapping) {
any_overlap = TRUE
push_y = move_y && (oy <= ox || !move_x)
if (push_y) {
push = oy / 2
if (vy[i] <= vy[j]) {
vy[i] = vy[i] - push
vy[j] = vy[j] + push
} else {
vy[i] = vy[i] + push
vy[j] = vy[j] - push
}
} else {
push = ox / 2
if (vx[i] <= vx[j]) {
vx[i] = vx[i] - push
vx[j] = vx[j] + push
} else {
vx[i] = vx[i] + push
vx[j] = vx[j] - push
}
}
}
}
}
if (!any_overlap) break
}

# Phase 2: spring pass — pull toward anchors without reintroducing overlaps
displacements = (vx - ax)^2 + (vy - ay)^2
for (idx in order(displacements, decreasing = TRUE)) {
step_x = if (move_x) (ax[idx] - vx[idx]) * 0.5 else 0
step_y = if (move_y) (ay[idx] - vy[idx]) * 0.5 else 0
new_x = vx[idx] + step_x
new_y = vy[idx] + step_y

overlaps = FALSE
for (k in seq_len(nv)) {
if (k == idx) next
ox = (vw[idx] + vw[k]) / 2 + min_gap - abs(new_x - vx[k])
oy = (vh[idx] + vh[k]) / 2 + min_gap - abs(new_y - vy[k])
hit = if (axis == "y") oy > 0
else if (axis == "x") ox > 0
else ox > 0 && oy > 0
if (hit) {
overlaps = TRUE
break
}
}
if (!overlaps) {
vx[idx] = new_x
vy[idx] = new_y
}
}

x[valid] = vx
y[valid] = vy
list(x = x, y = y)
}
Loading
Loading