Skip to content

Commit e6df8d7

Browse files
authored
Merge pull request #1976 from GenericMappingTools/improve-auto-inline-annots
Improvements to the automatic labellines algorithms.
2 parents 6b83e6c + 8606dc6 commit e6df8d7

2 files changed

Lines changed: 183 additions & 121 deletions

File tree

src/legend_funs.jl

Lines changed: 166 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -425,12 +425,23 @@ function best_label_pos(curves::GDtype, labels::Vector{<:AbstractString}; fontsi
425425
end
426426

427427
function _best_label_pos(D::Vector{<:GMTdataset}, labels::Vector{String}, nc::Int, fontsize::Int, prefer::Symbol)
428-
xmin, xmax, ymin, ymax = getregion(D)
428+
# Use the actual plot region (not data bbox) so cm↔data conversion matches the axes
429+
if CTRL.limits[7] != 0
430+
xmin, xmax, ymin, ymax = CTRL.limits[7:10]
431+
else
432+
xmin, xmax, ymin, ymax = getregion(D)
433+
end
429434
pw, ph = _get_plotsize()
430435
sx, sy = pw / (xmax - xmin), ph / (ymax - ymin)
431436

432-
# Convert to cm space for accurate visual distance/angle calculations
433-
crv = [hcat((D[k][:,1] .- xmin) .* sx, (D[k][:,2] .- ymin) .* sy) for k in 1:nc]
437+
# Convert to cm space — only the visible portion of each curve (inside the plot region)
438+
crv = Vector{Matrix{Float64}}(undef, nc)
439+
for k in 1:nc
440+
pts = D[k].data
441+
mask = vec((pts[:,1] .>= xmin) .& (pts[:,1] .<= xmax) .& (pts[:,2] .>= ymin) .& (pts[:,2] .<= ymax))
442+
vis = sum(mask) >= 2 ? pts[mask, :] : pts
443+
crv[k] = hcat((vis[:,1] .- xmin) .* sx, (vis[:,2] .- ymin) .* sy)
444+
end
434445

435446
# Text half-dimensions in cm
436447
pt2cm = 2.54 / 72
@@ -439,22 +450,31 @@ function _best_label_pos(D::Vector{<:GMTdataset}, labels::Vector{String}, nc::In
439450
hws = [length(l) * char_w / 2 for l in labels] # half-width per label
440451
hh = char_h * 0.9 # half-height (shared)
441452

442-
# Candidate range along arc length depends on preference
453+
# Preferred zone for targets (where labels WANT to be)
443454
prefer in (:begin, :middle, :end) || error("prefer must be :begin, :middle, or :end")
444-
frac_lo, frac_hi, frac_target = prefer == :begin ? (0.05, 0.55, 0.15) :
445-
prefer == :end ? (0.45, 0.95, 0.85) :
446-
(0.10, 0.90, 0.50)
455+
frac_lo, frac_hi = prefer == :begin ? (0.05, 0.30) :
456+
prefer == :end ? (0.60, 0.95) : (0.20, 0.80)
457+
center = prefer == :begin ? 0.17 : prefer == :end ? 0.83 : 0.50
458+
if nc == 1
459+
frac_targets = [center]
460+
else
461+
half_spread = min(0.20, 0.04 * nc)
462+
frac_targets = [center + half_spread * (2.0 * (i - 1) / (nc - 1) - 1.0) for i in 1:nc]
463+
end
447464

448-
# Generate candidate positions along each curve
449-
ncand = 19
450-
cands = [_bla_gen_candidates(crv[i], ncand, frac_lo, frac_hi) for i in 1:nc]
465+
# Candidate range is wider than target zone — labels prefer the target but CAN escape if all
466+
# candidates in the preferred zone have crossings (e.g. steep curves bunched at the center)
467+
cand_lo = max(0.05, frac_lo - 0.15)
468+
cand_hi = min(0.95, frac_hi + 0.15)
469+
ncand = 29
470+
cands = [_bla_gen_candidates(crv[i], ncand, cand_lo, cand_hi) for i in 1:nc]
451471

452472
# Greedy placement: maximize minimum clearance to other curves
453473
placed_cx = Float64[]; placed_cy = Float64[]; placed_a = Float64[]
454474
placed_hw = Float64[]; placed_hh = Float64[]
455475
result = Matrix{Float64}(undef, nc, 4) # each row: x1, y1, x2, y2
456476

457-
half_cross = 0.3 # half-length of crossing segment in cm (short to avoid multi-intersection)
477+
half_cross = 0.3 # half-length of crossing segment in cm
458478

459479
for i in 1:nc
460480
hw_i = hws[i]
@@ -465,13 +485,31 @@ function _best_label_pos(D::Vector{<:GMTdataset}, labels::Vector{String}, nc::In
465485
curvs = Vector{Float64}(undef, nj)
466486
fracs = Vector{Float64}(undef, nj)
467487

488+
# For each candidate: count crossings with other curves through the label bbox, measure clearance
489+
ncross_j = Vector{Int}(undef, nj)
490+
clears_j = Vector{Float64}(undef, nj)
491+
overlaps_j = falses(nj)
492+
468493
for j in 1:nj
469494
cx, cy, ang, curv = cands[i][j]
470-
fracs[j] = frac_lo + (frac_hi - frac_lo) * (j - 1) / max(nj - 1, 1)
471-
quality = 0.0
495+
fracs[j] = cand_lo + (cand_hi - cand_lo) * (j - 1) / max(nj - 1, 1)
472496
curvs[j] = curv
473497

474-
# Min distance from (cx,cy) to any segment of any other curve
498+
# Count how many OTHER curves cross through the label bounding box
499+
ncross = 0
500+
if nc > 1
501+
corners = _bla_corners(cx, cy, ang, hw_i, hh)
502+
for k in 1:nc
503+
k == i && continue
504+
for ei in 1:4
505+
eni = mod1(ei + 1, 4)
506+
ncross += _bla_crossing_count(corners[ei][1], corners[ei][2], corners[eni][1], corners[eni][2], crv[k])
507+
end
508+
end
509+
end
510+
ncross_j[j] = ncross
511+
512+
# Min distance to nearest other curve
475513
clearance = nc > 1 ? 1e10 : 0.0
476514
for k in 1:nc
477515
k == i && continue
@@ -481,70 +519,112 @@ function _best_label_pos(D::Vector{<:GMTdataset}, labels::Vector{String}, nc::In
481519
clearance = min(clearance, d)
482520
end
483521
end
522+
clears_j[j] = clearance
484523

485-
# Penalty when another curve is very close to the label center
486-
if clearance < hh * 2
487-
quality -= (hh * 2 - clearance) * 10.0
524+
# Reject candidates too close to plot edges (label or crossing segment would be clipped)
525+
margin = hh * 2.0
526+
if cx < margin || cy < margin || cx > pw - margin || cy > ph - margin
527+
overlaps_j[j] = true # reuse flag to mark as unusable
528+
continue
488529
end
489530

490-
# Penalty if perpendicular crosses own curve more than once
491-
nx, ny = -sin(ang), cos(ang)
492-
x1c, y1c = cx - nx * half_cross, cy - ny * half_cross
493-
x2c, y2c = cx + nx * half_cross, cy + ny * half_cross
494-
nself = _bla_crossing_count(x1c, y1c, x2c, y2c, crv[i])
495-
nself > 1 && (quality -= nself * 20.0)
496-
497-
# Penalty if any OTHER curve crosses through the label bounding box
498-
if nc > 1
499-
corners = _bla_corners(cx, cy, ang, hw_i * 1.3, hh * 1.3)
500-
for k in 1:nc
501-
k == i && continue
502-
for ei in 1:4
503-
eni = mod1(ei + 1, 4)
504-
ncx = _bla_crossing_count(corners[ei][1], corners[ei][2], corners[eni][1], corners[eni][2], crv[k])
505-
ncx > 0 && (quality -= ncx * 30.0)
506-
end
531+
# Check overlap with already-placed labels (with padding so labels don't get too close)
532+
pad = hh * 0.5 # extra margin around each label box
533+
for p in eachindex(placed_cx)
534+
if _bla_rboxes_overlap(cx, cy, ang, hw_i + pad, hh + pad,
535+
placed_cx[p], placed_cy[p], placed_a[p], placed_hw[p] + pad, placed_hh[p] + pad)
536+
overlaps_j[j] = true; break
507537
end
508538
end
539+
end
509540

510-
# Penalty for overlap with already-placed labels
511-
for p in eachindex(placed_cx)
512-
if _bla_rboxes_overlap(cx, cy, ang, hw_i, hh,
513-
placed_cx[p], placed_cy[p], placed_a[p], placed_hw[p], placed_hh[p])
514-
quality -= 100.0; break
541+
# Pick best: filter by crossings → label overlap → clearance → curvature; pick closest to frac_target
542+
min_ncross = minimum(ncross_j)
543+
clear_thresh = hh * 3.0 # min clearance from other curves (not squeezed)
544+
sorted_curvs = sort(curvs)
545+
curv_thresh = sorted_curvs[round(Int, nj * 0.75)] # 75th percentile — only reject extreme curvature
546+
547+
# Minimum clearance: curve must NOT touch label box — use diagonal of half-dimensions
548+
min_clear = hypot(hw_i, hh) * 1.2
549+
550+
# Try with progressively relaxed clearance, but NEVER below min_clear
551+
best_j = 1
552+
best_dist = Inf
553+
for ct in (clear_thresh, (clear_thresh + min_clear) / 2, min_clear)
554+
for j in 1:nj
555+
ncross_j[j] > min_ncross && continue
556+
overlaps_j[j] && continue
557+
clears_j[j] < ct && continue
558+
curvs[j] > curv_thresh && continue
559+
frac_dist = abs(fracs[j] - frac_targets[i])
560+
if frac_dist < best_dist
561+
best_dist = frac_dist; best_j = j
515562
end
516563
end
564+
best_dist < Inf && break
565+
end
517566

518-
qualities[j] = quality
567+
if best_dist == Inf # relax curvature filter, keep min_clear
568+
for ct in (clear_thresh, min_clear)
569+
for j in 1:nj
570+
ncross_j[j] > min_ncross && continue
571+
overlaps_j[j] && continue
572+
clears_j[j] < ct && continue
573+
frac_dist = abs(fracs[j] - frac_targets[i])
574+
if frac_dist < best_dist
575+
best_dist = frac_dist; best_j = j
576+
end
577+
end
578+
best_dist < Inf && break
579+
end
519580
end
520581

521-
# Phase 2: among candidates with acceptable quality, pick the best balance of
522-
# proximity to frac_target and low curvature (prefer straight sections).
523-
best_quality = maximum(qualities)
524-
threshold = best_quality - 5.0
582+
if best_dist == Inf # all labels overlap — farthest from placed labels, then closest to target
583+
max_sep = -Inf
584+
for j in 1:nj
585+
ncross_j[j] > min_ncross && continue
586+
cx_j, cy_j = cands[i][j][1], cands[i][j][2]
587+
min_d = isempty(placed_cx) ? Inf : minimum(hypot(cx_j - placed_cx[p], cy_j - placed_cy[p]) for p in eachindex(placed_cx))
588+
max_sep = max(max_sep, min_d)
589+
end
590+
thresh_sep = max_sep * 0.8
591+
best_dist = Inf
592+
for j in 1:nj
593+
ncross_j[j] > min_ncross && continue
594+
cx_j, cy_j = cands[i][j][1], cands[i][j][2]
595+
min_d = isempty(placed_cx) ? Inf : minimum(hypot(cx_j - placed_cx[p], cy_j - placed_cy[p]) for p in eachindex(placed_cx))
596+
min_d < thresh_sep && continue
597+
frac_dist = abs(fracs[j] - frac_targets[i])
598+
if frac_dist < best_dist
599+
best_dist = frac_dist; best_j = j
600+
end
601+
end
602+
end
525603

526-
best_j = 1
527-
best_score2 = -Inf
528-
for j in 1:nj
529-
qualities[j] < threshold && continue
530-
frac_dist = abs(fracs[j] - frac_target) # 0..0.4 typically
531-
score2 = -frac_dist * 10.0 - curvs[j] * 8.0 # balance preference vs curvature
532-
if score2 > best_score2
533-
best_score2 = score2
534-
best_j = j
604+
if best_dist == Inf # absolute last resort: closest to target, NO filters at all
605+
for j in 1:nj
606+
frac_dist = abs(fracs[j] - frac_targets[i])
607+
if frac_dist < best_dist
608+
best_dist = frac_dist; best_j = j
609+
end
535610
end
536611
end
537612

538613
cx, cy, ang = cands[i][best_j][1], cands[i][best_j][2], cands[i][best_j][3]
539614
push!(placed_cx, cx); push!(placed_cy, cy); push!(placed_a, ang)
540615
push!(placed_hw, hw_i); push!(placed_hh, hh)
541616

542-
# Short perpendicular crossing segment, convert to data coords
543-
nx, ny = -sin(ang), cos(ang)
544-
result[i,1] = (cx - nx * half_cross) / sx + xmin
545-
result[i,2] = (cy - ny * half_cross) / sy + ymin
546-
result[i,3] = (cx + nx * half_cross) / sx + xmin
547-
result[i,4] = (cy + ny * half_cross) / sy + ymin
617+
# Convert to data coords; perpendicular in data-space from the cm-space tangent angle
618+
cx_dat = cx / sx + xmin
619+
cy_dat = cy / sy + ymin
620+
nx_d, ny_d = -sin(ang) / sy, cos(ang) / sx # perpendicular to tangent in data-space
621+
nd = hypot(nx_d, ny_d)
622+
nx_d /= nd; ny_d /= nd
623+
half_d = min(xmax - xmin, ymax - ymin) * 0.015
624+
result[i,1] = clamp(cx_dat - nx_d * half_d, xmin, xmax)
625+
result[i,2] = clamp(cy_dat - ny_d * half_d, ymin, ymax)
626+
result[i,3] = clamp(cx_dat + nx_d * half_d, xmin, xmax)
627+
result[i,4] = clamp(cy_dat + ny_d * half_d, ymin, ymax)
548628
end
549629
return result
550630
end
@@ -601,6 +681,19 @@ function _label_pos_at_vals(D::Vector{<:GMTdataset}, nc::Int, xvals::Vector{Floa
601681
result[i,3] = best_px + nx * scale
602682
result[i,4] = best_py + ny * scale
603683
end
684+
685+
# Clamp all insertion points to the plot region so GMT doesn't skip labels outside it
686+
if CTRL.limits[7] != 0
687+
xmin, xmax, ymin, ymax = CTRL.limits[7:10]
688+
else
689+
xmin, xmax, ymin, ymax = getregion(D)
690+
end
691+
for i in 1:nc
692+
result[i,1] = clamp(result[i,1], xmin, xmax)
693+
result[i,2] = clamp(result[i,2], ymin, ymax)
694+
result[i,3] = clamp(result[i,3], xmin, xmax)
695+
result[i,4] = clamp(result[i,4], ymin, ymax)
696+
end
604697
return result
605698
end
606699

@@ -627,25 +720,19 @@ end
627720
# Returns Vector of (x, y, tangent_angle, curvature) where curvature = max angle change (rad) over a local window.
628721
function _bla_gen_candidates(c::Matrix{Float64}, ncand::Int, frac_lo::Float64=0.1, frac_hi::Float64=0.9)
629722
n = size(c, 1)
630-
arclen = Vector{Float64}(undef, n)
631-
arclen[1] = 0.0
632-
@inbounds for i in 2:n
633-
arclen[i] = arclen[i-1] + hypot(c[i,1] - c[i-1,1], c[i,2] - c[i-1,2])
634-
end
635723
# Per-segment angles
636724
seg_ang = Vector{Float64}(undef, max(n - 1, 1))
637725
@inbounds for i in 1:n-1
638726
seg_ang[i] = atan(c[i+1,2] - c[i,2], c[i+1,1] - c[i,1])
639727
end
640-
total = arclen[end]
728+
# Distribute candidates evenly by INDEX (not arc-length) so that steep curves
729+
# don't bunch all candidates in the same visual spot.
641730
result = Vector{NTuple{4,Float64}}(undef, ncand)
642731
@inbounds for j in 1:ncand
643732
frac = frac_lo + (frac_hi - frac_lo) * (j - 1) / max(ncand - 1, 1)
644-
target = frac * total
645-
idx = searchsortedlast(arclen, target)
646-
idx = clamp(idx, 1, n - 1)
647-
seg_len = arclen[idx+1] - arclen[idx]
648-
t = seg_len > 0 ? (target - arclen[idx]) / seg_len : 0.0
733+
fidx = frac * (n - 1) + 1 # 1-based float index
734+
idx = clamp(floor(Int, fidx), 1, n - 1)
735+
t = fidx - idx
649736
px = c[idx,1] + t * (c[idx+1,1] - c[idx,1])
650737
py = c[idx,2] + t * (c[idx+1,2] - c[idx,2])
651738
# Smoothed tangent over a few neighboring segments
@@ -764,59 +851,36 @@ function _add_labellines_apply(arg1::Vector{<:GMTdataset}, _cmd::Vector{String},
764851
color = _extract_W_color(arg1[k].header)
765852
font = color != "" ? "$(fontsize)p,,$color" : "$(fontsize)p"
766853
lbl = occursin(' ', labels[k]) ? "\"$(labels[k])\"" : labels[k]
767-
sq = @sprintf("-Sql%.10g/%.10g/%.10g/%.10g:+l%s+f%s+v", pos[k,1], pos[k,2], pos[k,3], pos[k,4], lbl, font)
768-
hdr = arg1[k].header
769-
hdr = replace(hdr, r" -Sq(?:[^\s\"]|\"[^\"]*\")*" => "") # Remove any previous -Sq option
854+
sq = @sprintf("-Sql%.7g/%.7g/%.7g/%.7g:+l%s+f%s+v", pos[k,1], pos[k,2], pos[k,3], pos[k,4], lbl, font)
855+
hdr = replace(arg1[k].header, r" -Sq(?:[^\s\"]|\"[^\"]*\")*" => "") # Remove any previous -Sq option
770856
arg1[k].header = string(hdr, " ", sq)
771857
end
772858
end
773859

774860
# Inject outside labels into d[:text] so they are processed by finish_PS_nested as a nested text call.
775861
function _inject_outside_labels!(d::Dict{Symbol,Any}, D::Vector{<:GMTdataset}, labels::Vector{String}, fontsize::Int, _cmd::Vector{String})
776-
info = _outside_label_data(D, labels, fontsize, _has_right_axis(_cmd[1]))
862+
info = _outside_label_data(D, labels, fontsize)
777863
Dt = mat2ds(hcat(info.x, info.y), info.labels)
778864
color = info.colors[1]
779865
fnt = color != "" ? "$(fontsize)p,,$color" : "$(fontsize)p"
780866
d[:text] = (data=Dt, font=fnt, justify="ML", offset=(0.15, 0.0), noclip=true)
781867
end
782868

783-
# Check if the right axis frame is drawn by looking for 'E', 'e' or 'r' in the -B axes spec.
784-
function _has_right_axis(cmd::String)::Bool
785-
# Find -B options that specify axes (contain frame letters, not just intervals like -Baf)
786-
for m in eachmatch(r"-B([A-Za-z]*)", cmd)
787-
s = m.captures[1]
788-
# Axes specs contain W/w/S/s/E/e/N/n/l/r/t/b/u/d — skip pure interval specs like "af", "xaf", "p"
789-
any(c -> c in "WSENwsen", s) && return any(c -> c in "Ee", s)
790-
end
791-
return false # No axes spec found, default frames don't draw right axis
792-
end
793-
794869
# Compute label positions outside the plot (at the right edge), with vertical repel to avoid overlaps.
795-
function _outside_label_data(D::Vector{<:GMTdataset}, labels::Vector{String}, fontsize::Int, right_axis::Bool)
870+
function _outside_label_data(D::Vector{<:GMTdataset}, labels::Vector{String}, fontsize::Int)
796871
nc = length(D)
797872

798873
# Get plot region limits from CTRL.pocket_R e.g. " -R0/10/-1.5/1.5"
799-
r = split(CTRL.pocket_R[1][4:end], '/') # skip " -R"
800-
ymin = parse(Float64, r[3])
801-
ymax = parse(Float64, r[4])
874+
ymin, ymax = CTRL.limits[9], CTRL.limits[10]
802875

803876
# x positions: at plot region edge if right axis is drawn, otherwise at each curve's last point
804877
xs = Vector{Float64}(undef, nc)
805878
ys = Vector{Float64}(undef, nc)
806879
colors = Vector{String}(undef, nc)
807-
if right_axis
808-
xmax = parse(Float64, r[2])
809-
for k in 1:nc
810-
xs[k] = xmax
811-
ys[k] = D[k].data[end, 2]
812-
colors[k] = _extract_W_color(D[k].header)
813-
end
814-
else
815-
for k in 1:nc
816-
xs[k] = D[k].data[end, 1]
817-
ys[k] = D[k].data[end, 2]
818-
colors[k] = _extract_W_color(D[k].header)
819-
end
880+
for k in 1:nc
881+
xs[k] = D[k].data[end, 1]
882+
ys[k] = D[k].data[end, 2]
883+
colors[k] = _extract_W_color(D[k].header)
820884
end
821885

822886
# Repel overlapping labels vertically

0 commit comments

Comments
 (0)