Skip to content

Commit 5706f09

Browse files
authored
Merge pull request #1982 from GenericMappingTools/textrepel-improvs
Improvements to the textrepel function.
2 parents f6bbbb4 + 96a03d9 commit 5706f09

4 files changed

Lines changed: 131 additions & 44 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, trend1d, trend2d, triangulate, gmtsplit,
139+
sphtriangulate, surface, ternary, ternary!, text, text!, text_record, textrepel, 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/imgtiles.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,6 +1008,7 @@ function geocoder(address::String; options=String[])::GDtype
10081008
_ops = isempty(options) ? C_NULL : options # The default is ["SERVICE", "OSM_NOMINATIM"]
10091009
hSession = Gdal.OGRGeocodeCreateSession(_ops)
10101010
hLayer = Gdal.OGRGeocode(hSession, address, C_NULL, [""])
1011+
hLayer == C_NULL && (@warn("Geocoding failed for the address $address"); return GMTdataset())
10111012
hFDefn = Gdal.OGR_L_GetLayerDefn(hLayer)
10121013
hFeature = Gdal.OGR_L_GetNextFeature(hLayer)
10131014
count = Gdal.OGR_FD_GetFieldCount(hFDefn)

src/legend_funs.jl

Lines changed: 125 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -957,7 +957,7 @@ end
957957

958958
# --------------------------------------------------------------------------------------------------
959959
"""
960-
text_repel(points, labels; fontsize=10, force_push=1.0, force_pull=0.01,
960+
textrepel(points, labels; fontsize=10, force_push=1.0, force_pull=0.01,
961961
max_iter=500, padding=0.15) -> Matrix{Float64}
962962
963963
Compute adjusted text label positions so that they do not overlap each other or the data points,
@@ -976,10 +976,31 @@ similar to R's `ggrepel`. Uses a force-directed simulation: labels repel each ot
976976
### Returns
977977
A `Matrix{Float64}` of size `(N, 2)` with adjusted `(x, y)` positions in data coordinates.
978978
"""
979-
function text_repel(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)
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)
981+
ax, ay, pw, ph, sx, sy, xmin, ymin, n = _repel_setup(points)
982+
n != length(labels) && error("Number of points ($n) must match number of labels ($(length(labels)))")
983+
fs = fontsize * 2.54 / 72
984+
pad = Float64(padding)
985+
hws = [length(l) * 0.55 * fs / 2 + pad for l in labels]
986+
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)
989+
end
981990

982-
# Extract point coordinates
991+
# --------------------------------------------------------------------------------------------------
992+
function circlerepel(points; diameter::Real=10, force_push::Real=1.0, force_pull::Real=0.01,
993+
max_iter::Int=500, offset=10)
994+
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)
1000+
end
1001+
1002+
# --------------------------------------------------------------------------------------------------
1003+
function _repel_setup(points)
9831004
if isa(points, GMTdataset)
9841005
px = Float64.(points[:,1]); py = Float64.(points[:,2])
9851006
elseif isa(points, Vector{<:GMTdataset})
@@ -988,9 +1009,6 @@ function text_repel(points, labels::Vector{<:AbstractString}; fontsize::Int=10,
9881009
px = Float64.(points[:,1]); py = Float64.(points[:,2])
9891010
end
9901011
n = length(px)
991-
n != length(labels) && error("Number of points ($n) must match number of labels ($(length(labels)))")
992-
993-
# Plot region from CTRL.limits, fallback to data bbox
9941012
xmin, xmax, ymin, ymax = (CTRL.limits[7] != 0 || CTRL.limits[8] != 0) ?
9951013
(CTRL.limits[7], CTRL.limits[8], CTRL.limits[9], CTRL.limits[10]) :
9961014
(CTRL.limits[1] != CTRL.limits[2]) ?
@@ -1001,39 +1019,33 @@ function text_repel(points, labels::Vector{<:AbstractString}; fontsize::Int=10,
10011019
(dy == 0) && (ymin -= 0.5; ymax += 0.5; dy = 1.0)
10021020
pw, ph = _get_plotsize()
10031021
sx, sy = pw / dx, ph / dy
1004-
1005-
# Convert anchor points to cm space
10061022
ax = (px .- xmin) .* sx
10071023
ay = (py .- ymin) .* sy
1024+
return ax, ay, pw, ph, sx, sy, xmin, ymin, n
1025+
end
10081026

1009-
# Text box half-dimensions in cm (with padding)
1010-
fs = fontsize * 2.54 / 72
1011-
char_w = 0.55 * fs
1012-
char_h = fs
1013-
pad = Float64(padding)
1014-
hws = [length(l) * char_w / 2 + pad for l in labels]
1015-
hhs = fill(char_h / 2 + pad, n)
1016-
1017-
# Initialize label positions at anchor points
1027+
# --------------------------------------------------------------------------------------------------
1028+
function _repel_core(ax, ay, hws, hhs, pw, ph, sx, sy, xmin, ymin, fp, fa, min_off, max_iter)
1029+
n = length(ax)
10181030
lx = copy(ax)
10191031
ly = copy(ay)
1020-
vx = zeros(n) # velocities
1032+
vx = zeros(n)
10211033
vy = zeros(n)
1034+
fx = zeros(n)
1035+
fy = zeros(n)
1036+
decay = 0.7
10221037

1023-
fp = Float64(force_push)
1024-
fa = Float64(force_pull)
1025-
decay = 0.7 # velocity damping
1026-
1038+
global iters = 0
1039+
move_hist = fill(Inf, 10)
10271040
for _iter in 1:max_iter
1028-
fx = zeros(n)
1029-
fy = zeros(n)
1041+
fill!(fx, 0.0)
1042+
fill!(fy, 0.0)
10301043

10311044
# Repulsion between label pairs
10321045
for i in 1:n-1, j in i+1:n
1033-
ox = (hws[i] + hws[j]) - abs(lx[i] - lx[j]) # overlap in x
1034-
oy = (hhs[i] + hhs[j]) - abs(ly[i] - ly[j]) # overlap in y
1035-
(ox <= 0 || oy <= 0) && continue # no overlap
1036-
# Push proportional to overlap area
1046+
ox = (hws[i] + hws[j]) - abs(lx[i] - lx[j])
1047+
oy = (hhs[i] + hhs[j]) - abs(ly[i] - ly[j])
1048+
(ox <= 0 || oy <= 0) && continue
10371049
area = ox * oy
10381050
dx = lx[i] - lx[j]
10391051
dy = ly[i] - ly[j]
@@ -1045,40 +1057,114 @@ function text_repel(points, labels::Vector{<:AbstractString}; fontsize::Int=10,
10451057
fx[j] -= fdx; fy[j] -= fdy
10461058
end
10471059

1048-
# Repulsion from data points (push labels away from all anchor points)
1060+
# Repulsion from data points
10491061
for i in 1:n, j in 1:n
10501062
dx = lx[i] - ax[j]
10511063
dy = ly[i] - ay[j]
1052-
# Check if point j is inside label i's box
10531064
(abs(dx) > hws[i] || abs(dy) > hhs[i]) && continue
10541065
dist = max(hypot(dx, dy), 1e-6)
10551066
force = fp * 0.5 / dist
10561067
fx[i] += force * dx / dist
10571068
fy[i] += force * dy / dist
10581069
end
10591070

1060-
# Attraction back to own anchor (spring force)
1071+
# Attraction back to own anchor
10611072
for i in 1:n
10621073
fx[i] -= fa * (lx[i] - ax[i])
10631074
fy[i] -= fa * (ly[i] - ay[i])
10641075
end
10651076

10661077
# Update velocities and positions
1067-
any_movement = false
1078+
max_move = 0.0
10681079
for i in 1:n
10691080
vx[i] = (vx[i] + fx[i]) * decay
10701081
vy[i] = (vy[i] + fy[i]) * decay
1082+
spd = hypot(vx[i], vy[i])
1083+
if spd > 0.5
1084+
vx[i] *= 0.5 / spd
1085+
vy[i] *= 0.5 / spd
1086+
end
1087+
old_x, old_y = lx[i], ly[i]
10711088
lx[i] += vx[i]
10721089
ly[i] += vy[i]
1073-
# Clamp to plot region (cm space)
1074-
lx[i] = clamp(lx[i], hws[i], pw - hws[i])
1075-
ly[i] = clamp(ly[i], hhs[i], ph - hhs[i])
1076-
(abs(vx[i]) > 1e-4 || abs(vy[i]) > 1e-4) && (any_movement = true)
1090+
cx = clamp(lx[i], hws[i], pw - hws[i])
1091+
cy = clamp(ly[i], hhs[i], ph - hhs[i])
1092+
(cx != lx[i]) && (vx[i] = 0.0)
1093+
(cy != ly[i]) && (vy[i] = 0.0)
1094+
lx[i] = cx; ly[i] = cy
1095+
max_move = max(max_move, abs(lx[i] - old_x), abs(ly[i] - old_y))
1096+
end
1097+
iters += 1
1098+
#println(" iter $iters: max_move = $max_move")
1099+
move_hist[mod1(_iter, 10)] = max_move
1100+
(_iter >= 50 && minimum(move_hist) >= maximum(move_hist) * 0.9) && break
1101+
end
1102+
#println("textrepel: completed $iters iterations")
1103+
1104+
# Enforce minimum offset
1105+
if min_off > 0
1106+
for i in 1:n
1107+
dx = lx[i] - ax[i]
1108+
dy = ly[i] - ay[i]
1109+
dist = hypot(dx, dy)
1110+
if dist < min_off && dist > 1e-6
1111+
scale = min_off / dist
1112+
lx[i] = ax[i] + dx * scale
1113+
ly[i] = ay[i] + dy * scale
1114+
lx[i] = clamp(lx[i], hws[i], pw - hws[i])
1115+
ly[i] = clamp(ly[i], hhs[i], ph - hhs[i])
1116+
elseif dist < 1e-6
1117+
ly[i] = ay[i] + min_off
1118+
ly[i] = clamp(ly[i], hhs[i], ph - hhs[i])
1119+
end
1120+
end
1121+
end
1122+
1123+
# Pull-back: minimize offset without creating overlaps
1124+
for _pull in 1:50
1125+
moved = false
1126+
for i in 1:n
1127+
tox = ax[i] - lx[i]
1128+
toy = ay[i] - ly[i]
1129+
cur_dist = hypot(tox, toy)
1130+
(cur_dist < 1e-6) && continue
1131+
max_pull = min_off > 0 ? max(cur_dist - min_off, 0.0) / cur_dist : 1.0
1132+
(max_pull < 1e-6) && continue
1133+
1134+
lo, hi, best_t = 0.0, max_pull, 0.0
1135+
for _ in 1:20
1136+
t = (lo + hi) * 0.5
1137+
nx = lx[i] + t * tox
1138+
ny = ly[i] + t * toy
1139+
ok = true
1140+
for j in 1:n
1141+
(i == j) && continue
1142+
if (hws[i] + hws[j]) - abs(nx - lx[j]) > 0 && (hhs[i] + hhs[j]) - abs(ny - ly[j]) > 0
1143+
ok = false; break
1144+
end
1145+
end
1146+
if ok
1147+
for j in 1:n
1148+
if abs(nx - ax[j]) < hws[i] && abs(ny - ay[j]) < hhs[i]
1149+
ok = false; break
1150+
end
1151+
end
1152+
end
1153+
if ok
1154+
best_t = t; lo = t
1155+
else
1156+
hi = t
1157+
end
1158+
end
1159+
if best_t > 1e-6
1160+
lx[i] += best_t * tox
1161+
ly[i] += best_t * toy
1162+
moved = true
1163+
end
10771164
end
1078-
!any_movement && break
1165+
!moved && break
10791166
end
10801167

1081-
# Convert back to data coordinates
10821168
result = Matrix{Float64}(undef, n, 2)
10831169
for i in 1:n
10841170
result[i, 1] = lx[i] / sx + xmin

test/test_labellines.jl

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,12 +163,12 @@ GMT.add_labellines!(Dout2, d17, cmd17)
163163
@test !occursin("-Sq", cmd17[1]) # outside labels don't use -Sq
164164
#GMT.CTRL.pocket_R[1] = bak_R; GMT.CTRL.pocket_J[2] = bak_J # restore
165165

166-
# Test text_repel — force-directed label placement
166+
# Test textrepel — force-directed label placement
167167
println(" TEXT_REPEL")
168168
# 1) Clustered points: labels must spread out
169169
pts = [1.0 1.0; 1.05 1.05; 0.95 1.0; 1.0 0.95; 1.05 0.95]
170170
labs = ["Aa", "Bb", "Cc", "Dd", "Ee"]
171-
rp = GMT.text_repel(pts, labs)
171+
rp = GMT.textrepel(pts, labs)
172172
@test size(rp) == (5, 2)
173173
# All results must be finite
174174
@test all(isfinite.(rp))
@@ -177,15 +177,15 @@ rp = GMT.text_repel(pts, labs)
177177
resetGMT()
178178
pts2 = [0.0 0.0; 5.0 0.0; 0.0 5.0; 5.0 5.0]
179179
labs2 = ["A", "B", "C", "D"]
180-
rp2 = GMT.text_repel(pts2, labs2)
180+
rp2 = GMT.textrepel(pts2, labs2)
181181
for i in 1:4
182182
@test abs(rp2[i,1] - pts2[i,1]) < 1.5 # should stay close
183183
@test abs(rp2[i,2] - pts2[i,2]) < 1.5
184184
end
185185

186186
# 3) GMTdataset input
187187
D_repel = mat2ds(pts)
188-
rp3 = GMT.text_repel(D_repel, labs)
188+
rp3 = GMT.textrepel(D_repel, labs)
189189
@test size(rp3) == (5, 2)
190190

191191
# 5) No overlaps in result (axis-aligned box check in cm space)

0 commit comments

Comments
 (0)