It is amazing that several math papers have been written on non-overlapping label placement algorithms, but I neither have the math background nor the time to study these papers, let alone translate the algorithm into code. Python and R have codes for this kind of algorithm.
Anyway, the following code in Julia is sufficient for my case, not necessarily holistic enough for all situations and for everyone's case.
using DataFrames
using CairoMakie
using Random
function allpairs(n::Int)
arr = collect(Iterators.product(1:n, 1:n))
row, col = size(arr)
combo = []
for r ∈ 1:row-1, c ∈ (r + 1):col
push!(combo, arr[r, c])
end
combo
end
angle(x, y) = atan(y[2] - y[1], x[2] - x[1])
dist(x, y) = sqrt((x[2] - x[1])^2 + (y[2] - y[1])^2)
function label_dist(x, y, δw, δh)
x1, x2 = x[1], x[2]
y1, y2 = y[1], y[2]
x1b, x2b = x1 + δw, x2 + δw
y1b, y2b = y1 + δh, y2 + δh
left = x2b < x1
right = x1b < x2
bottom = y2b < y1
top = y1b < y2
d = top && left ? dist((x1, x2b), (y1b, y2)) :
left && bottom ? dist((x1, x2b), (y1, y2b)) :
bottom && right ? dist((x1b, x2), (y1, y2b)) :
right && top ? dist((x1b, x2), (y1b, y2)) :
left ? x1 - x2b :
right ? x2 - x1b :
bottom ? y1 - y2b :
top ? y2 - y1b :
0.0
d
end
function shift!(x, y, idx, r, angle)
x[idx[1]] -= r * cos(angle)
y[idx[1]] -= r * sin(angle)
x[idx[2]] += r * cos(angle)
y[idx[2]] += r * sin(angle)
end
function label_pos(x, y; # label positions (original/untransformed scale)
min_dist=0.002, # min. distance between labels (on a 0-1 scale)
shift_size=0.002, # distance to shift labels (on a 0-1 scale)
width=0.05, # width of label (on a 0-1 scale)
height=0.03, # height of label (on a 0-1 scale)
attempts::Int=100) # no. of tries to place labels
xlims = extrema(x)
Δx = xlims[2] - xlims[1]
ylims = extrema(y)
Δy = ylims[2] - ylims[1]
xt = (x .- xlims[1]) ./ Δx # map to a 0-1 scale
yt = (y .- ylims[1]) ./ Δy
combo = allpairs(length(xt))
iter = true
i = 1
while iter
pts = map(combo) do p
idx = [p[1], p[2]]
d = label_dist(xt[idx], yt[idx], width, height)
θ = angle(xt[idx], yt[idx])
(idx=idx, dist=d, angle=θ)
end
pos = findall(p -> p.dist < min_dist, pts)
foreach(p -> shift!(xt, yt, p.idx, shift_size, p.angle), pts[pos])
i += 1
iter = (i <= attempts) && !isempty(pos)
end
xt = xt .* Δx .+ xlims[1] # revert to original scale
yt = yt .* Δy .+ ylims[1]
(; x=xt, y=yt)
end
xlims = (0, 1)
ylims = (-10, 10)
Δxl = xlims[2] - xlims[1]
Δyl = ylims[2] - ylims[1]
n = 150 # no. of points to generate
seed = 392348
Random.seed!(seed)
xs = xlims[1] .+ rand(n) .* Δxl
ys = ylims[1] .+ rand(n) .* Δyl
labels = map(i -> "$i", eachindex(xs))
xt = xs[:]
yt = ys[:]
xt2 = xs[:]
yt2 = ys[:]
xt, yt = label_pos(xt, yt)
xlims2 = (xlims[1] - Δxl * 0.05, xlims[2] + Δxl * 0.05)
ylims2 = (ylims[1] - Δyl * 0.05, ylims[2] + Δyl * 0.05)
fig = Figure(resolution=(600,1200)) # must be square and preferably >500 px
axs = Axis(fig[1,1])
scatter!(axs, xs, ys, marker=:circle, markersize=5, color=:red)
axs.title = "non-overlap label placement"
foreach(eachindex(labels)) do i
text!(axs, position=(xt[i], yt[i]), labels[i], textsize=15, align=(:right, :bottom))
end
axs2 = Axis(fig[2,1])
scatter!(axs2, xs, ys, marker=:circle, markersize=5, color=:red)
axs2.title = "default label placement"
limits!(axs, xlims2, ylims2)
limits!(axs2, xlims2, ylims2)
foreach(eachindex(labels)) do i
text!(axs2, position=(xt2[i], yt2[i]), labels[i], textsize=15, align=(:right, :bottom))
end
display(fig)
The code produces the following 2 charts:

It is hardly near perfect or optimized, but it teases apart the labels far enough, so that I can save the figures as SVG, then use a vector drawing app to manually adjust the labels, where needed.
This is a start, as I am sure cleverer people can optimize this code. I hope this will be helpful to others.