Skip to content

Commit 0afacd3

Browse files
authored
Merge pull request #1987 from GenericMappingTools/annotate
Add an matplotlib style annotate function.
2 parents 8d73209 + 1b95f0d commit 0afacd3

5 files changed

Lines changed: 351 additions & 60 deletions

File tree

src/GMT.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ export
136136
psternary, psternary!, pstext, pstext!, pswiggle, pswiggle!, psxy, psxy!, psxyz, psxyz!, regress, resetGMT, rose,
137137
rose!, sample1d, scatter, scatter!, scatter3, scatter3!, solar, solar!, analemma, enso, keeling,
138138
sunsetrise, spectrum1d, sphdistance, sphinterpolate,
139-
sphtriangulate, surface, ternary, ternary!, text, text!, text_record, textrepel, trend1d, trend2d, triangulate, gmtsplit,
139+
sphtriangulate, surface, ternary, ternary!, text, text!, text_record, textrepel, annotate, annotate!, trend1d, trend2d, triangulate, gmtsplit,
140140
decorated, vector_attrib, wiggle, wiggle!, xyz2grd, gmtbegin, gmtend, gmthelp, subplot, gmtfig, inset, showfig,
141141
earthtide, gmt2grd, gravfft, gmtgravmag3d, gravmag3d, grdgravmag3d, gravprisms, grdseamount, parkermag, parkergrav, kovesi,
142142
pscoupe, pscoupe!, coupe, coupe!, psmeca, psmeca!, meca, meca!, psvelo, psvelo!, sac, sac!, velo, velo!, gmtisf, getbyattrib,

src/legend_funs.jl

Lines changed: 99 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -958,7 +958,7 @@ end
958958
# --------------------------------------------------------------------------------------------------
959959
"""
960960
textrepel(points, labels; fontsize=10, force_push=1.0, force_pull=0.01,
961-
max_iter=500, padding=0.15) -> Matrix{Float64}
961+
max_iter=500, pad=0.15, offset=10, offsets=false) -> Matrix{Float64}
962962
963963
Compute adjusted text label positions so that they do not overlap each other or the data points,
964964
similar to R's `ggrepel`. Uses a force-directed simulation: labels repel each other and data points
@@ -971,61 +971,69 @@ similar to R's `ggrepel`. Uses a force-directed simulation: labels repel each ot
971971
- `force_push`: repulsion strength multiplier (default 1.0).
972972
- `force_pull`: attraction strength back to anchor (default 0.01).
973973
- `max_iter`: maximum number of simulation iterations (default 500).
974-
- `padding`: extra padding around text boxes in cm (default 0.15).
974+
- `pad`: extra padding around text boxes in cm (default 0.15).
975+
- `offset`: minimum distance between label and anchor point in points (default 10).
976+
- `offsets`: if `true`, return label displacements in cm from each anchor point instead of
977+
absolute data coordinates (default `false`).
975978
976979
### Returns
977-
A `Matrix{Float64}` of size `(N, 2)` with adjusted `(x, y)` positions in data coordinates.
980+
A `Matrix{Float64}` of size `(N, 2)`. When `offsets=false` (default), values are adjusted
981+
`(x, y)` positions in data coordinates. When `offsets=true`, values are `(dx, dy)` offsets
982+
in centimetres from each original anchor point.
978983
"""
979-
function textrepel(points, labels::Vector{<:AbstractString}; fontsize::Int=10,
980-
force_push::Real=1.0, force_pull::Real=0.01, max_iter::Int=500, padding::Real=0.15, offset=10)
984+
function textrepel(points::Union{Matrix{<:Real}, GMTdataset}, labels::Vector{<:AbstractString}; fontsize::Int=10, force_push::Real=1.0,
985+
force_pull::Real=0.01, max_iter::Int=500, pad::Real=0.15, offset::Int=10, offsets::Bool=false)
986+
isa(points, Matrix) && (points = mat2ds(Float64.(points)))
987+
_textrepel(points, labels; fontsize=fontsize, force_push=Float64(force_push), force_pull=Float64(force_pull),
988+
max_iter=max_iter, pad=Float64(pad), offset=offset, offsets=offsets)
989+
end
990+
function _textrepel(points::GMTdataset, labels::Vector{<:AbstractString}; fontsize::Int=10, force_push::Float64=1.0,
991+
force_pull::Float64=0.01, max_iter::Int=500, pad::Float64=0.15, offset::Int=10, offsets::Bool=false)
981992
ax, ay, pw, ph, sx, sy, xmin, ymin, n = _repel_setup(points)
982993
n != length(labels) && error("Number of points ($n) must match number of labels ($(length(labels)))")
983994
fs = fontsize * 2.54 / 72
984-
pad = Float64(padding)
985995
hws = [length(l) * 0.55 * fs / 2 + pad for l in labels]
986996
hhs = fill(fs / 2 + pad, n)
987-
return _repel_core(ax, ay, hws, hhs, pw, ph, sx, sy, xmin, ymin,
988-
Float64(force_push), Float64(force_pull), Float64(offset) * 2.54 / 72, max_iter)
997+
return _repel_core(ax, ay, hws, hhs, pw, ph, sx, sy, xmin, ymin, Float64(force_push),
998+
force_pull, offset * 2.54 / 72, max_iter, offsets)
989999
end
9901000

9911001
# --------------------------------------------------------------------------------------------------
9921002
function circlerepel(points; diameter::Real=10, force_push::Real=1.0, force_pull::Real=0.01,
993-
max_iter::Int=500, offset=10)
1003+
max_iter::Int=500, offset=10, offsets::Bool=false)
1004+
isa(points, Matrix) && (points = mat2ds(Float64.(points)))
1005+
_circlerepel(points; diameter=Float64(diameter), force_push=Float64(force_push), force_pull=Float64(force_pull),
1006+
max_iter=max_iter, offset=offset, offsets=offsets)
1007+
end
1008+
function _circlerepel(points::GMTdataset; diameter::Float64=10, force_push::Float64=1.0, force_pull::Float64=0.01,
1009+
max_iter::Int=500, offset::Int=10, offsets::Bool=false)
9941010
ax, ay, pw, ph, sx, sy, xmin, ymin, n = _repel_setup(points)
995-
rad = Float64(diameter) * 2.54 / 72 / 2
996-
hws = fill(rad, n)
997-
hhs = fill(rad, n)
998-
return _repel_core(ax, ay, hws, hhs, pw, ph, sx, sy, xmin, ymin,
999-
Float64(force_push), Float64(force_pull), Float64(offset) * 2.54 / 72, max_iter)
1011+
rad = diameter * 2.54 / 72 / 2
1012+
hws, hhs = fill(rad, n), fill(rad, n)
1013+
return _repel_core(ax, ay, hws, hhs, pw, ph, sx, sy, xmin, ymin, Float64(force_push),
1014+
force_pull, offset * 2.54 / 72, max_iter, offsets)
10001015
end
10011016

10021017
# --------------------------------------------------------------------------------------------------
1003-
function _repel_setup(points)
1004-
if isa(points, GMTdataset)
1005-
px = Float64.(points[:,1]); py = Float64.(points[:,2])
1006-
elseif isa(points, Vector{<:GMTdataset})
1007-
px = Float64.(points[1][:,1]); py = Float64.(points[1][:,2])
1008-
else
1009-
px = Float64.(points[:,1]); py = Float64.(points[:,2])
1010-
end
1011-
n = length(px)
1018+
function _repel_setup(points::GMTdataset)
1019+
n = size(points,1)
10121020
xmin, xmax, ymin, ymax = (CTRL.limits[7] != 0 || CTRL.limits[8] != 0) ?
10131021
(CTRL.limits[7], CTRL.limits[8], CTRL.limits[9], CTRL.limits[10]) :
10141022
(CTRL.limits[1] != CTRL.limits[2]) ?
10151023
(CTRL.limits[1], CTRL.limits[2], CTRL.limits[3], CTRL.limits[4]) :
1016-
(minimum(px), maximum(px), minimum(py), maximum(py))
1024+
(points.bbox[1], points.bbox[2], points.bbox[3], points.bbox[4])
10171025
dx = xmax - xmin; dy = ymax - ymin
10181026
(dx == 0) && (xmin -= 0.5; xmax += 0.5; dx = 1.0)
10191027
(dy == 0) && (ymin -= 0.5; ymax += 0.5; dy = 1.0)
10201028
pw, ph = _get_plotsize()
10211029
sx, sy = pw / dx, ph / dy
1022-
ax = (px .- xmin) .* sx
1023-
ay = (py .- ymin) .* sy
1030+
ax = (points[:,1] .- xmin) .* sx
1031+
ay = (points[:,2] .- ymin) .* sy
10241032
return ax, ay, pw, ph, sx, sy, xmin, ymin, n
10251033
end
10261034

10271035
# --------------------------------------------------------------------------------------------------
1028-
function _repel_core(ax, ay, hws, hhs, pw, ph, sx, sy, xmin, ymin, fp, fa, min_off, max_iter)
1036+
function _repel_core(ax, ay, hws, hhs, pw, ph, sx, sy, xmin, ymin, fp, fa, min_off, max_iter, offsets::Bool=false)
10291037
n = length(ax)
10301038
lx = copy(ax)
10311039
ly = copy(ay)
@@ -1057,13 +1065,18 @@ function _repel_core(ax, ay, hws, hhs, pw, ph, sx, sy, xmin, ymin, fp, fa, min_o
10571065
fx[j] -= fdx; fy[j] -= fdy
10581066
end
10591067

1060-
# Repulsion from data points
1068+
# Repulsion from data points — box-edge aware.
1069+
# Acts whenever the nearest box edge is within min_off of the anchor,
1070+
# so diagonal labels are pushed far enough even when anchor is outside the box.
10611071
for i in 1:n, j in 1:n
10621072
dx = lx[i] - ax[j]
10631073
dy = ly[i] - ay[j]
1064-
(abs(dx) > hws[i] || abs(dy) > hhs[i]) && continue
1074+
near_x = clamp(ax[j], lx[i]-hws[i], lx[i]+hws[i])
1075+
near_y = clamp(ay[j], ly[i]-hhs[i], ly[i]+hhs[i])
1076+
edge_dist = hypot(ax[j]-near_x, ay[j]-near_y)
1077+
(edge_dist >= min_off) && continue
10651078
dist = max(hypot(dx, dy), 1e-6)
1066-
force = fp * 0.5 / dist
1079+
force = fp * 0.5 * (1.0 - edge_dist / max(min_off, 1e-10)) / dist
10671080
fx[i] += force * dx / dist
10681081
fy[i] += force * dy / dist
10691082
end
@@ -1101,7 +1114,8 @@ function _repel_core(ax, ay, hws, hhs, pw, ph, sx, sy, xmin, ymin, fp, fa, min_o
11011114
end
11021115
#println("textrepel: completed $iters iterations")
11031116

1104-
# Enforce minimum offset
1117+
# Enforce minimum center-distance between anchor and label center (original behaviour).
1118+
# This respects min_off for all label directions, especially horizontal/vertical.
11051119
if min_off > 0
11061120
for i in 1:n
11071121
dx = lx[i] - ax[i]
@@ -1120,6 +1134,36 @@ function _repel_core(ax, ay, hws, hhs, pw, ph, sx, sy, xmin, ymin, fp, fa, min_o
11201134
end
11211135
end
11221136

1137+
# Diagonal correction: for labels at an angle (not near-horizontal/vertical),
1138+
# push the label further so the box does not visually overlap the anchor marker.
1139+
# The extra distance scales with the box height (∝ fontsize, independent of text length)
1140+
# and is applied ONLY when both x and y placement components are significant.
1141+
for i in 1:n
1142+
dx = lx[i] - ax[i]
1143+
dy = ly[i] - ay[i]
1144+
dist = max(hypot(dx, dy), 1e-6)
1145+
(abs(dx / dist) <= 0.25 || abs(dy / dist) <= 0.25) && continue # near-horiz/vert: skip
1146+
near_x = clamp(ax[i], lx[i]-hws[i], lx[i]+hws[i])
1147+
near_y = clamp(ay[i], ly[i]-hhs[i], ly[i]+hhs[i])
1148+
clr = hhs[i] / 2 # minimum edge-clearance for diagonal labels
1149+
(hypot(ax[i]-near_x, ay[i]-near_y) >= clr) && continue # already clear enough
1150+
ux_s = dx / dist; uy_s = dy / dist
1151+
lo, hi = 0.0, hypot(hws[i], hhs[i]) + clr + dist
1152+
for _ in 1:30
1153+
mid = (lo + hi) / 2
1154+
nlx = lx[i] + mid * ux_s; nly = ly[i] + mid * uy_s
1155+
nnx = clamp(ax[i], nlx-hws[i], nlx+hws[i])
1156+
nny = clamp(ay[i], nly-hhs[i], nly+hhs[i])
1157+
if hypot(ax[i]-nnx, ay[i]-nny) < clr
1158+
lo = mid
1159+
else
1160+
hi = mid
1161+
end
1162+
end
1163+
lx[i] = clamp(lx[i] + hi * ux_s, hws[i], pw - hws[i])
1164+
ly[i] = clamp(ly[i] + hi * uy_s, hhs[i], ph - hhs[i])
1165+
end
1166+
11231167
# Pull-back: minimize offset without creating overlaps
11241168
for _pull in 1:50
11251169
moved = false
@@ -1145,7 +1189,20 @@ function _repel_core(ax, ay, hws, hhs, pw, ph, sx, sy, xmin, ymin, fp, fa, min_o
11451189
end
11461190
if ok
11471191
for j in 1:n
1148-
if abs(nx - ax[j]) < hws[i] && abs(ny - ay[j]) < hhs[i]
1192+
if j == i
1193+
# Own anchor: diagonal labels need edge-clearance; others just can't have anchor inside box
1194+
ddx = nx - ax[i]; ddy = ny - ay[i]
1195+
ddist = max(hypot(ddx, ddy), 1e-6)
1196+
if abs(ddx/ddist) > 0.25 && abs(ddy/ddist) > 0.25
1197+
nnx = clamp(ax[i], nx-hws[i], nx+hws[i])
1198+
nny = clamp(ay[i], ny-hhs[i], ny+hhs[i])
1199+
if hypot(ax[i]-nnx, ay[i]-nny) < hhs[i]/2
1200+
ok = false; break
1201+
end
1202+
elseif abs(nx - ax[i]) < hws[i] && abs(ny - ay[i]) < hhs[i]
1203+
ok = false; break
1204+
end
1205+
elseif abs(nx - ax[j]) < hws[i] && abs(ny - ay[j]) < hhs[i]
11491206
ok = false; break
11501207
end
11511208
end
@@ -1166,9 +1223,16 @@ function _repel_core(ax, ay, hws, hhs, pw, ph, sx, sy, xmin, ymin, fp, fa, min_o
11661223
end
11671224

11681225
result = Matrix{Float64}(undef, n, 2)
1169-
for i in 1:n
1170-
result[i, 1] = lx[i] / sx + xmin
1171-
result[i, 2] = ly[i] / sy + ymin
1226+
if offsets
1227+
for i in 1:n
1228+
result[i, 1] = lx[i] - ax[i] # cm offset from anchor
1229+
result[i, 2] = ly[i] - ay[i]
1230+
end
1231+
else
1232+
for i in 1:n
1233+
result[i, 1] = lx[i] / sx + xmin
1234+
result[i, 2] = ly[i] / sy + ymin
1235+
end
11721236
end
11731237
return result
11741238
end

0 commit comments

Comments
 (0)