From 39fd66ac5d3ef86f9bf9d4eda1a656bf092a152a Mon Sep 17 00:00:00 2001 From: Eric Martin Feltham Date: Mon, 21 Sep 2026 12:07:59 -0400 Subject: [PATCH 1/4] Add Egocentric layout Adds `Egocentric`, a stress-majorization layout centred on a single focal vertex, following Brandes and Pich, "More Flexible Radial Layout", Journal of Graph Algorithms and Applications 15(1):157-173 (2011, doi 10.7155/jgaa.00221). The layout interpolates between the plain stress objective (`Stress`) and a "focus" objective that only weights the pairs involving the focal vertex. Minimising the latter alone puts every vertex at a radius equal to its graph distance from the focus, so the layout reads as concentric rings of constant geodesic distance while the angles still come from the unconstrained stress solution. Optimisation walks a schedule `tseq` of mixing parameters from 0 to 1, majorising each stage to convergence. Details worth noting for review: - Implemented as an `IterativeLayout`, so `LayoutIterator` animates the schedule. The last item is emitted twice because `layout` returns the second to last item of the iterator. - The majorisation sweep updates positions in place (Gauss-Seidel). The simultaneous (Jacobi) variant of the same update oscillates rather than converging: on a two-vertex graph it alternates around the ideal distance indefinitely. - The focal vertex is never moved and stays at the origin. Stress is translation invariant, so this only fixes the gauge, and it means `initialpos`, `pin` and the returned positions all live in the same focus-centred frame. - Initial positions come from classical (Torgerson) MDS of the graph distances plus a small jitter, computed inline so no new dependency is needed. This makes the result largely independent of the seed. - Unconnected vertices reuse the existing `uncon_dist` treatment from `Stress` rather than being dropped, so the result always has one position per vertex. - `maxdist` optionally compresses vertices beyond a given ring onto a narrow outer band, keeping their angles. Without it a few remote vertices can dominate the frame, since radius equals graph distance. `compress` controls the falloff and accepts a function, a number, or `nothing`. Supports `dim`/`Ptype`, `initialpos`, `pin`, `seed`/`rng`, weighted adjacency matrices, and composes with `Align`. Co-Authored-By: Claude Opus 5 --- docs/src/index.md | 59 +++++- src/NetworkLayout.jl | 1 + src/egocentric.jl | 433 +++++++++++++++++++++++++++++++++++++++++++ test/runtests.jl | 163 ++++++++++++++++ 4 files changed, 654 insertions(+), 2 deletions(-) create mode 100644 src/egocentric.jl diff --git a/docs/src/index.md b/docs/src/index.md index d7d80a0..9be7c60 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -152,6 +152,60 @@ nothing #hide ``` ![stress animation](stress_animation.mp4) +## Egocentric Layout +```@docs +Egocentric +``` +### Example +An egocentric layout puts one vertex at the centre and arranges everyone else on +concentric rings, one per geodesic step away from it. The angles still come from +a regular stress layout, so vertices that are close in the network stay close on +their ring. +```@example layouts +set_theme!(size=(800, 400)) #hide +g = watts_strogatz(120, 4, 0.3; seed=2) +focus = 1 +layout = Egocentric(; focus) + +node_color = [i == focus ? :tomato : :black for i in 1:nv(g)] +node_size = [i == focus ? 20 : 6 for i in 1:nv(g)] +f, ax, p = graphplot(g; layout, node_color, node_size, edge_width=0.5) +for r in 1:maximum(gdistances(g, focus)) + arc!(ax, Point2f(0), r, -pi, pi; color=(:gray, 0.5), linewidth=0.5) +end +hidedecorations!(ax); hidespines!(ax); ax.aspect = DataAspect(); f +``` + +### Bounding the extent with `maxdist` +Since the radius of a vertex equals its graph distance, a few remote vertices can +dominate the frame. `maxdist` keeps the rings up to that distance intact and +compresses everything beyond it onto a narrow band, keeping the angles: +```@example layouts +layout = Egocentric(; focus, maxdist=4) +f, ax, p = graphplot(g; layout, node_color, node_size, edge_width=0.5) +for r in 1:4 + arc!(ax, Point2f(0), r, -pi, pi; color=(:gray, 0.5), linewidth=0.5) +end +hidedecorations!(ax); hidespines!(ax); ax.aspect = DataAspect(); f +``` +Passing a number instead of a function, e.g. `compress=1`, collapses everything +past `maxdist` onto a single outer ring, which is a compact way to show "further +away than `maxdist`" without saying how much further. + +### Iterator Example +```@example layouts +layout = Egocentric(; focus) +f, ax, p = graphplot(g; layout, node_color, node_size, edge_width=0.5) +hidedecorations!(ax); hidespines!(ax); ax.aspect = DataAspect() #hide +iterator = LayoutIterator(layout, g) +record(f, "egocentric_animation.mp4", iterator; framerate = 10) do pos + p[:node_pos][] = pos + autolimits!(ax) +end +nothing #hide +``` +![egocentric animation](egocentric_animation.mp4) + ## Shell/Circular Layout ```@docs Shell @@ -195,8 +249,9 @@ f #hide ## `pin` Positions in Interative Layouts Sometimes it is desired to fix the positions of a few nodes while arranging the rest "naturally" around them. -The iterative layouts [`Stress`](@ref), [`Spring`](@ref) and [`SFDP`](@ref) allow to pin -nodes to certain positions, i.e. those node will stay fixed during the iteration. +The iterative layouts [`Stress`](@ref), [`Spring`](@ref), [`SFDP`](@ref) and +[`Egocentric`](@ref) allow to pin nodes to certain positions, i.e. those node will +stay fixed during the iteration. ```@example layouts g = SimpleGraph(vcat(hcat(zeros(4,4), ones(4,4)), hcat(ones(4,4), zeros(4,4)))) nothing #hide diff --git a/src/NetworkLayout.jl b/src/NetworkLayout.jl index ba35227..c9b755a 100644 --- a/src/NetworkLayout.jl +++ b/src/NetworkLayout.jl @@ -232,6 +232,7 @@ include("sfdp.jl") include("buchheim.jl") include("spring.jl") include("stress.jl") +include("egocentric.jl") include("spectral.jl") include("shell.jl") include("squaregrid.jl") diff --git a/src/egocentric.jl b/src/egocentric.jl new file mode 100644 index 0000000..7c19a58 --- /dev/null +++ b/src/egocentric.jl @@ -0,0 +1,433 @@ +using LinearAlgebra: norm, eigen, Symmetric + +export Egocentric, egocentric + +""" + Egocentric(; kwargs...)(adj_matrix) + egocentric(adj_matrix; kwargs...) + +Compute an egocentric ("focus") graph layout using stress majorization, centered +on a single focal vertex. Takes an adjacency matrix representation of a network +and returns coordinates of the nodes, translated such that the focal vertex sits +at the origin. + +The layout interpolates between two objectives, following Brandes and Pich, +"More Flexible Radial Layout", Journal of Graph Algorithms and Applications +15(1):157-173 (2011, +[doi 10.7155/jgaa.00221](https://doi.org/10.7155/jgaa.00221)): + +- a plain stress objective, which tries to match *all* pairwise euclidean + distances to the corresponding graph distances (this is [`Stress`](@ref)), and +- a *focus* objective, which only weights the pairs involving the focal vertex. + +Minimizing the focus objective alone places every vertex at a radius equal to +its graph distance from the focal vertex, producing concentric rings of +constant geodesic distance. Optimization proceeds along a schedule `tseq` of +mixing parameters `t ∈ [0,1]`, where the weights used in iteration stage `t` are +`(1-t)*W + t*Z` with `W[i,j] = d[i,j]^-2` and `Z` equal to `W` on the row and +column of the focal vertex and zero elsewhere. Each stage is majorized to +convergence before moving on to the next. Starting at `t=0` and ending at `t=1` +therefore uses the unconstrained stress layout to pick sensible *angles*, then +gradually enforces the radii. + +## Inputs: +- `adj_matrix`: Matrix of pairwise distances. + +## Keyword Arguments +- `focus=1`: Index of the focal vertex. It is held at the origin, so all returned + positions are relative to it. Pinning it has no effect. +- `dim=2`, `Ptype=Float64`: Determines dimension and output type `Point{dim,Ptype}`. +- `tseq=0.0:0.1:1.0` + + Schedule of mixing parameters, from pure stress (`t=0`) to pure focus (`t=1`). + Must be non-empty with entries in `[0,1]`. + +- `iterations=100`: maximum number of majorization steps *per* entry of `tseq`. +- `abstols=0.0` + + Absolute tolerance for convergence of stress. A stage terminates if the + difference between two successive stresses is less than abstol. + +- `reltols=10e-5` + + Relative tolerance for convergence of stress. A stage terminates if the + improvement in stress relative to the current stress is less than reltol. + +- `abstolx=10e-6` + + Absolute tolerance for convergence of layout. A stage terminates if the + largest movement of any single node is less than abstolx. + +- `maxdist=nothing` + + If given, radially compress every vertex further than `maxdist` from the focal + vertex onto a narrow band outside `maxdist`, keeping its angle. This bounds the + extent of the layout so that the interesting, near part of the network fills + the frame. Vertices that are unconnected to the focal vertex are affected by + this too (see `uncon_dist`). Without `maxdist` no compression is applied. + +- `compress=log1p` + + How far past `maxdist` a vertex at excess radius `Δ = r - maxdist` is drawn: its + new radius is `maxdist + compress(Δ)`. May be a function, a real number (all + distant vertices land on a single ring at `maxdist + compress`), or `nothing` + (equivalent to `0`, i.e. clamp onto the `maxdist` ring). Ignored when `maxdist` + is `nothing`. + +- `uncon_dist=(maxdist, Ncomps)->maxdist*Ncomps^(1/3)` + + Per default, unconnected vertices in the graph get a pairwise "ideal" distance + which scales with the number of connected components and the maximum distance + within the components. + +- `initialpos=Point{dim,Ptype}[]` + + Provide `Vector` or `Dict` of initial positions. By default all positions are + initialized using classical multidimensional scaling of the graph distances + plus a small random jitter, which makes the result largely deterministic. + Those positions will be overwritten using the key-val-pairs provided by this + argument. + +- `pin=[]`: Pin node positions (won't be updated). Can be given as `Vector` or `Dict` + of node index -> value pairings. Values can be either + - `(12, 4.0)` : overwrite initial position and pin + - `true/false` : pin this position + - `(true, false, false)` : only pin certain coordinates + + Pinned positions are given relative to the focal vertex (which sits at the + origin), and are exempt from the `maxdist` compression. + +- `seed=1`: Seed for the random jitter on the initial positions. +- `rng=DEFAULT_RNG[](seed)` + + Create rng based on seed. Defaults to `MersenneTwister`, can be specified + by overwriting `DEFAULT_RNG[]` +""" +@addcall struct Egocentric{Dim,Ptype,FT<:AbstractFloat,TS,MD,CF,UF,RNG} <: + IterativeLayout{Dim,Ptype} + focus::Int + tseq::TS + iterations::Int + abstols::FT + reltols::FT + abstolx::FT + maxdist::MD + compress::CF + uncon_dist::UF + initialpos::Dict{Int,Point{Dim,Ptype}} + pin::Dict{Int,SVector{Dim,Bool}} + rng::RNG +end + +function Egocentric(; focus=1, + dim=2, + Ptype=Float64, + tseq=0.0:0.1:1.0, + iterations=100, + abstols=0.0, + reltols=10e-5, + abstolx=10e-6, + maxdist=nothing, + compress=log1p, + uncon_dist=(maxd, N) -> maxd * N^(1 / 3), + initialpos=[], pin=[], + seed=1, rng=DEFAULT_RNG[](seed)) + if !isempty(initialpos) + dim, Ptype = infer_pointtype(initialpos) + Ptype = promote_type(Float32, Ptype) # make sure to get at least f32 if given as int + end + + focus > 0 || throw(ArgumentError("focus needs to be a valid vertex index, got $focus")) + isempty(tseq) && throw(ArgumentError("tseq needs to be non-empty!")) + all(t -> 0 ≤ t ≤ 1, tseq) || throw(ArgumentError("All entries of tseq need to be in [0,1]!")) + iterations > 0 || throw(ArgumentError("Iterations need to be > 0")) + + _initialpos, _pin = _sanitize_initialpos_pin(dim, Ptype, initialpos, pin) + + _compress = _compressfun(compress) + _maxdist = maxdist === nothing ? nothing : float(maxdist) + + FT = promote_type(Float64, typeof(abstols), typeof(reltols), typeof(abstolx)) + TS, MD, CF, UF, RNG = typeof(tseq), typeof(_maxdist), typeof(_compress), typeof(uncon_dist), + typeof(rng) + return Egocentric{dim,Ptype,FT,TS,MD,CF,UF,RNG}(focus, tseq, iterations, FT(abstols), + FT(reltols), FT(abstolx), + _maxdist, _compress, uncon_dist, + _initialpos, _pin, rng) +end + +# `Returns` is only available from 1.7 on, but compat allows 1.6 +@static if !isdefined(Base, :Returns) + struct Returns{V} <: Function + value::V + end + (obj::Returns)(args...; kw...) = obj.value +end + +""" +Normalize the `compress` keyword into a function `Δ -> extra radius`. +""" +_compressfun(f) = f +_compressfun(::Nothing) = Returns(0.0) +_compressfun(b::Bool) = b ? Returns(1.0) : Returns(0.0) +_compressfun(c::Real) = Returns(float(c)) + +""" +Iteration state of the [`Egocentric`](@ref) layout. `positions` are the positions +of the majorization itself, already focus-centered but not yet radially +compressed; the compression is applied on the way out in `_egoemit`. `pintarget` +holds the fixed positions of the pinned vertices. +""" +mutable struct EgocentricState{PT,FT} + positions::Vector{PT} + D::Matrix{FT} + W::Matrix{FT} + Z::Matrix{FT} + wsum::Vector{FT} + zsum::Vector{FT} + pin::Union{Nothing,Vector{<:SVector}} + pintarget::Vector{PT} + stage::Int # index into tseq + iter::Int # majorization steps taken within the current stage + laststress::FT + finished::Bool +end + +function Base.iterate(iter::LayoutIterator{<:Egocentric{Dim,Ptype,FT}}) where {Dim,Ptype,FT} + algo, δ = iter.algorithm, iter.adj_matrix + N = assertsquare(δ) + algo.focus ≤ N || + throw(ArgumentError("focus=$(algo.focus) is out of bounds for a graph with $N vertices!")) + + make_symmetric!(δ) + D = pairwise_distance(δ, FT) + _replace_unconnected!(D, algo.uncon_dist) + + W = zeros(FT, N, N) + for j in 1:N, i in 1:N + i == j && continue + W[i, j] = D[i, j]^-2 + end + + # focus weights: W restricted to the row and column of the focal vertex + Z = zeros(FT, N, N) + Z[algo.focus, :] .= @view W[algo.focus, :] + Z[:, algo.focus] .= @view W[:, algo.focus] + + positions = _egoinitialpos(algo, D, N) + + if isempty(algo.pin) + pin = nothing + else + pin = [get(algo.pin, i, SVector{Dim,Bool}(false for _ in 1:Dim)) for i in 1:N] + end + + state = EgocentricState(positions, D, W, Z, vec(sum(W; dims=2)), vec(sum(Z; dims=2)), + pin, copy(positions), 1, 0, zero(FT), false) + state.laststress = _mixedstress(state, first(algo.tseq)) + + return _egoemit(algo, state), state +end + +function Base.iterate(iter::LayoutIterator{<:Egocentric}, state) + algo = iter.algorithm + + state.finished && return nothing + + if state.stage > length(algo.tseq) + # emit the final layout a second time: `layout` returns the second to + # last item of the iterator + state.finished = true + return _egoemit(algo, state), state + end + + t = algo.tseq[state.stage] + oldpos = copy(state.positions) + _majorize!(state.positions, algo.focus, state, t) + + moved = maximum(i -> norm(state.positions[i] - oldpos[i]), eachindex(oldpos)) + state.iter += 1 + + newstress = _mixedstress(state, t) + converged = (state.laststress - newstress) ≤ algo.reltols * state.laststress || + abs(state.laststress - newstress) ≤ algo.abstols || + moved ≤ algo.abstolx + state.laststress = newstress + + if converged || state.iter ≥ algo.iterations + state.stage += 1 + state.iter = 0 + if state.stage ≤ length(algo.tseq) + state.laststress = _mixedstress(state, algo.tseq[state.stage]) + end + end + + return _egoemit(algo, state), state +end + +""" +One SMACOF majorization sweep with the mixed weights `(1-t)*W + t*Z`. + +`pos` is updated in place, i.e. the update of node `i` already sees the new +positions of nodes `1:i-1`. The simultaneous (Jacobi) variant of this update can +oscillate instead of converging. + +The focal vertex is never moved. Stress is translation invariant, so holding one +vertex fixed only fixes the gauge; keeping it at the origin means that user +supplied positions (`initialpos`, `pin`) and the returned layout live in the same, +focus-centered frame. +""" +function _majorize!(pos::Vector{PT}, focus::Int, state::EgocentricState, t) where {PT} + D, W, Z = state.D, state.W, state.Z + pin, pintarget = state.pin, state.pintarget + for i in eachindex(pos) + i == focus && continue # holds the gauge, see above + pinned = pin === nothing ? nothing : pin[i] + pinned !== nothing && all(pinned) && continue + + acc = zero(PT) + for j in eachindex(pos) + i == j && continue + w = (1 - t) * W[i, j] + t * Z[i, j] + iszero(w) && continue + offset = pos[i] - pos[j] + nrm = norm(offset) + # coincident nodes carry no direction information, only pull towards `pos[j]` + inv_nrm = nrm > 1e-5 ? inv(nrm) : zero(nrm) + acc += w * (pos[j] + D[i, j] * offset * inv_nrm) + end + + denom = (1 - t) * state.wsum[i] + t * state.zsum[i] + iszero(denom) && continue + new = acc / denom + if pinned !== nothing && any(pinned) + new = PT(ntuple(k -> pinned[k] ? pintarget[i][k] : new[k], length(new))) + end + pos[i] = new + end + return pos +end + +""" +Stress of the current layout under the mixed weights of stage `t`. +""" +function _mixedstress(state::EgocentricState, t) + (1 - t) * stress(state.positions, state.D, state.W) + + t * stress(state.positions, state.D, state.Z) +end + +""" +Copy out the current layout, applying the radial compression beyond `maxdist`. +The focal vertex already sits at the origin, see [`_majorize!`](@ref). +""" +function _egoemit(algo::Egocentric{Dim,Ptype}, state::EgocentricState) where {Dim,Ptype} + pos = copy(state.positions) + + if algo.maxdist !== nothing + maxdist = algo.maxdist + for i in eachindex(pos) + state.pin !== nothing && any(state.pin[i]) && continue + r = norm(pos[i]) + r > maxdist || continue + pos[i] = pos[i] * Ptype((maxdist + algo.compress(r - maxdist)) / r) + end + end + return pos +end + +""" +Vertices which can't reach each other end up with a distance of `typemax` or +`Inf` (the latter if the Floyd-Warshall sum overflowed). Replace both with a +finite "ideal" distance based on the number of connected components. +""" +function _replace_unconnected!(D::Matrix{T}, uncon_dist) where {T} + unreachable(x) = !isfinite(x) || x ≥ typemax(T) + any(unreachable, D) || return D + + maxd = zero(T) + for d in D + unreachable(d) || (maxd = max(maxd, d)) + end + iszero(maxd) && (maxd = one(T)) + + dist = T(uncon_dist(maxd, _count_components(D, unreachable))) + for i in eachindex(D) + unreachable(D[i]) && (D[i] = dist) + end + return D +end + +function _count_components(D, unreachable) + N = size(D, 1) + seen = falses(N) + ncomps = 0 + for i in 1:N + seen[i] && continue + ncomps += 1 + for j in i:N + unreachable(D[i, j]) || (seen[j] = true) + end + end + return ncomps +end + +""" +Initial positions from classical multidimensional scaling of the graph distances +plus a small jitter, overwritten by the user provided `initialpos`. +""" +function _egoinitialpos(algo::Egocentric{Dim,Ptype}, D, N) where {Dim,Ptype} + coords = _classical_mds(D, Dim) + rng = copy(algo.rng) + startpos = Vector{Point{Dim,Ptype}}(undef, N) + for i in 1:N + startpos[i] = Point{Dim,Ptype}(ntuple(k -> coords[k, i] + Ptype(0.2 * (rand(rng) - 0.5)), + Dim)) + end + + # the MDS solution is centered on the centroid, move it onto the focal vertex + # so that `initialpos` and `pin` are interpreted in the focus-centered frame + origin = startpos[algo.focus] + startpos .= startpos .- Ref(origin) + + for (k, v) in algo.initialpos + startpos[k] = v + end + startpos[algo.focus] = zero(Point{Dim,Ptype}) + return startpos +end + +""" +Classical (Torgerson) multidimensional scaling of the distance matrix `D` into +`dim` dimensions. Returns a `dim × N` matrix of coordinates. Falls back to zeros +if the eigendecomposition fails, in which case the jitter alone seeds the +majorization. +""" +function _classical_mds(D::Matrix{T}, dim::Int) where {T} + N = size(D, 1) + coords = zeros(T, dim, N) + N < 2 && return coords + + # double centering of the squared distances + D2 = D .^ 2 + rowmean = vec(sum(D2; dims=2)) ./ N + grandmean = sum(rowmean) / N + B = Matrix{T}(undef, N, N) + for j in 1:N, i in 1:N + B[i, j] = -(D2[i, j] - rowmean[i] - rowmean[j] + grandmean) / 2 + end + + local F + try + F = eigen(Symmetric(B)) + catch + return coords + end + + # eigen returns ascending eigenvalues, the largest ones are at the end + for k in 1:min(dim, N) + λ = F.values[end - k + 1] + λ > 0 || continue + coords[k, :] .= @views F.vectors[:, end - k + 1] .* sqrt(λ) + end + return coords +end diff --git a/test/runtests.jl b/test/runtests.jl index 78ea45d..03f0631 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -190,6 +190,169 @@ jagmesh_adj = jagmesh() end end + @testset "Testing Egocentric Layout" begin + println("Egocentric") + @testset "Egocentric construction" begin + algo = Egocentric() + @test algo isa Egocentric{2,Float64} + @test algo.focus == 1 + algo = Egocentric(; dim=3, Ptype=Float32, focus=4) + @test algo isa Egocentric{3,Float32} + @test algo.focus == 4 + algo = Egocentric(; initialpos=[Point2f(1, 2)]) + @test algo isa Egocentric{2,Float32} + + @test_throws ArgumentError Egocentric(; focus=0) + @test_throws ArgumentError Egocentric(; tseq=Float64[]) + @test_throws ArgumentError Egocentric(; tseq=[0.0, 1.5]) + @test_throws ArgumentError Egocentric(; iterations=0) + @test_throws ArgumentError Egocentric(; focus=11)(wheel_graph(10)) + end + + @testset "focal vertex sits at the origin" begin + g = watts_strogatz(40, 4, 0.1; seed=3) + for v in (1, 7, 40) + pos = egocentric(g; focus=v) + @test pos[v] == zero(Point2f) + @test length(pos) == nv(g) + end + end + + @testset "radii match the graph distances" begin + for g in (wheel_graph(10), watts_strogatz(60, 4, 0.1; seed=3), + smallgraph(:petersen), path_graph(2), SimpleGraph(1)) + v = 1 + pos = egocentric(g; focus=v) + d = gdistances(g, v) + @test all(i -> isapprox(norm(pos[i]), d[i]; atol=1e-6), 1:nv(g)) + end + end + + @testset "distances are respected in 3d" begin + g = watts_strogatz(40, 4, 0.1; seed=3) + pos = egocentric(g; focus=6, dim=3, Ptype=Float32) + @test typeof(pos) == Vector{Point3f} + d = gdistances(g, 6) + @test all(i -> isapprox(norm(pos[i]), d[i]; atol=1e-4), 1:nv(g)) + end + + @testset "weighted adjacency matrix" begin + w = Float64.(adjacency_matrix(path_graph(4))) + w[1, 2] = w[2, 1] = 3.0 + pos = egocentric(w; focus=1) + @test norm.(pos) ≈ [0.0, 3.0, 4.0, 5.0] + end + + @testset "maxdist compression" begin + g = watts_strogatz(60, 4, 0.1; seed=3) + v = 7 + d = gdistances(g, v) + @test maximum(d) > 2 + + pos = egocentric(g; focus=v, maxdist=2) + @test all(i -> isapprox(norm(pos[i]), d[i]; atol=1e-6), findall(<=(2), d)) + # compressed, but still ordered by distance and never inside the ring + far = findall(>(2), d) + @test all(i -> norm(pos[i]) > 2, far) + @test all(((i, j),) -> d[i] >= d[j] || norm(pos[i]) < norm(pos[j]), + Iterators.product(far, far)) + + # a constant puts every distant vertex on a single outer ring + pos = egocentric(g; focus=v, maxdist=2, compress=1) + @test all(i -> isapprox(norm(pos[i]), 3; atol=1e-6), far) + + # `nothing` clamps onto the ring itself + pos = egocentric(g; focus=v, maxdist=2, compress=nothing) + @test all(i -> isapprox(norm(pos[i]), 2; atol=1e-6), far) + @test maximum(norm, pos) <= 2 + 1e-6 + + # angles are untouched by the compression + plain = egocentric(g; focus=v) + @test all(i -> isapprox(atan(pos[i][2], pos[i][1]), + atan(plain[i][2], plain[i][1]); atol=1e-6), + setdiff(1:nv(g), v)) + end + + @testset "unconnected vertices" begin + g = SimpleGraph(10) + add_edge!(g, 1, 2); add_edge!(g, 2, 3); add_edge!(g, 5, 6) + pos = egocentric(g; focus=1) + @test all(p -> all(isfinite, p), pos) + @test norm(pos[2]) ≈ 1 + @test norm(pos[3]) ≈ 2 + # everything unreachable from the focus ends up on the same outer ring + unreachable = 4:10 + @test length(unique(round.(norm.(pos[unreachable]); digits=6))) == 1 + + pos = egocentric(g; focus=1, uncon_dist=(maxd, N) -> 42.0) + @test all(i -> isapprox(norm(pos[i]), 42; atol=1e-6), unreachable) + end + + @testset "deterministic without a random seed effect" begin + g = smallgraph(:karate) + @test egocentric(g; focus=3) == egocentric(g; focus=3) + # the seed only jitters the initial positions, the radii are pinned down + # by the graph distances either way + @test norm.(egocentric(g; focus=3, seed=1)) ≈ norm.(egocentric(g; focus=3, seed=2)) + end + + @testset "initialpos and pin" begin + g = watts_strogatz(40, 4, 0.1; seed=3) + pos = egocentric(g; focus=5, pin=Dict(3 => (7.0, 7.0))) + @test pos[3] == Point2(7.0, 7.0) + + # pinned vertices are exempt from the compression + pos = egocentric(g; focus=5, maxdist=2, pin=Dict(3 => (7.0, 7.0))) + @test pos[3] == Point2(7.0, 7.0) + + # single coordinates can be pinned + pos = egocentric(g; focus=5, initialpos=Dict(9 => (2.0, 2.0)), pin=Dict(9 => (true, false))) + @test pos[9][1] == 2.0 + @test pos[9][2] != 2.0 + + # pinning the focus is a no-op, it stays at the origin + pos = egocentric(g; focus=5, pin=Dict(5 => (3.0, 3.0))) + @test pos[5] == zero(Point2) + end + + @testset "iterator" begin + g = wheel_graph(10) + adj_matrix = adjacency_matrix(g) + algo = Egocentric(; focus=2) + positions = Any[] + for p in LayoutIterator(algo, adj_matrix) + push!(positions, p) + end + @test !isempty(positions) + @test all(p -> length(p) == nv(g), positions) + @test all(p -> p[2] == zero(Point2), positions) + # `layout` returns the converged layout the iterator ends on + @test last(positions) == algo(adjacency_matrix(g)) + + # `iterations` caps each stage: at most initial + stages*iterations + final repeat + n = 0 + for _ in LayoutIterator(Egocentric(; focus=2, tseq=[0.0, 0.5, 1.0], iterations=2, + reltols=0.0, abstols=0.0, abstolx=0.0), + adjacency_matrix(g)) + n += 1 + end + @test 2 <= n <= 2 + 3 * 2 + end + + @testset "stress decreases along the schedule" begin + g = smallgraph(:karate) + adj_matrix = adjacency_matrix(g) + d = NetworkLayout.pairwise_distance(Matrix(adj_matrix), Float64) + w = [i == j ? 0.0 : d[i, j]^-2 for i in 1:nv(g), j in 1:nv(g)] + stresses = Float64[] + for p in LayoutIterator(Egocentric(; focus=1, tseq=[0.0]), adj_matrix) + push!(stresses, NetworkLayout.stress(p, d, w)) + end + @test all(<=(1e-9), diff(stresses)) + @test last(stresses) < first(stresses) + end + end + @testset "Testing Spring Algorithm" begin println("Spring wheel_graph") @testset "Spring construction" begin From 9dc6b565ea50bee9c753f7e7ffa898a951b24dfe Mon Sep 17 00:00:00 2001 From: Eric Martin Feltham Date: Mon, 28 Sep 2026 10:49:24 -0400 Subject: [PATCH 2/4] Drop the double-emit workaround, fixed upstream by #105 `layout` used to return the second to last item of the iterator, so the `Egocentric` iterator emitted the converged layout twice to make sure the final one was the one returned. #105 fixed that bug, so the workaround can go: `iterate` now simply terminates once the `tseq` schedule is exhausted. The workaround was the only writer of `EgocentricState.finished`, so that field is removed too. The returned layout is unchanged; the iterator just yields one item fewer. Verified by comparing positions before and after across the plain, `maxdist`, `compress=`, `compress=nothing`, 3d/Float32 and karate cases -- all bit-identical. Also adapts the iterator tests to the new `iterations` semantics from #105: a single-stage `tseq` with `iterations=l` and zero tolerances now yields exactly `l + 1` layouts, with `last(vec) == algo(adj_matrix)`. No `>=` to `>` change was needed in the layout itself, because the per-stage counter is incremented after each sweep and so already counted actual iterations. Co-Authored-By: Claude Opus 5 --- src/egocentric.jl | 12 ++---------- test/runtests.jl | 20 ++++++++++++++++---- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/egocentric.jl b/src/egocentric.jl index 7c19a58..d9e9a98 100644 --- a/src/egocentric.jl +++ b/src/egocentric.jl @@ -190,7 +190,6 @@ mutable struct EgocentricState{PT,FT} stage::Int # index into tseq iter::Int # majorization steps taken within the current stage laststress::FT - finished::Bool end function Base.iterate(iter::LayoutIterator{<:Egocentric{Dim,Ptype,FT}}) where {Dim,Ptype,FT} @@ -223,7 +222,7 @@ function Base.iterate(iter::LayoutIterator{<:Egocentric{Dim,Ptype,FT}}) where {D end state = EgocentricState(positions, D, W, Z, vec(sum(W; dims=2)), vec(sum(Z; dims=2)), - pin, copy(positions), 1, 0, zero(FT), false) + pin, copy(positions), 1, 0, zero(FT)) state.laststress = _mixedstress(state, first(algo.tseq)) return _egoemit(algo, state), state @@ -232,14 +231,7 @@ end function Base.iterate(iter::LayoutIterator{<:Egocentric}, state) algo = iter.algorithm - state.finished && return nothing - - if state.stage > length(algo.tseq) - # emit the final layout a second time: `layout` returns the second to - # last item of the iterator - state.finished = true - return _egoemit(algo, state), state - end + state.stage > length(algo.tseq) && return nothing t = algo.tseq[state.stage] oldpos = copy(state.positions) diff --git a/test/runtests.jl b/test/runtests.jl index 03f0631..d44fc0f 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -326,17 +326,29 @@ jagmesh_adj = jagmesh() @test !isempty(positions) @test all(p -> length(p) == nv(g), positions) @test all(p -> p[2] == zero(Point2), positions) - # `layout` returns the converged layout the iterator ends on @test last(positions) == algo(adjacency_matrix(g)) - # `iterations` caps each stage: at most initial + stages*iterations + final repeat + # a single stage iterates at most `iterations` times on the initial layout + for l in [1, 5, 10] + vec = Any[] + it = LayoutIterator(Egocentric(; focus=2, tseq=[0.0], iterations=l, + reltols=0.0, abstols=0.0, abstolx=0.0), + adj_matrix) + for p in it + push!(vec, p) + end + @test length(vec) == l + 1 + @test it.algorithm(adj_matrix) == last(vec) + end + + # `iterations` caps every stage of the schedule separately n = 0 for _ in LayoutIterator(Egocentric(; focus=2, tseq=[0.0, 0.5, 1.0], iterations=2, reltols=0.0, abstols=0.0, abstolx=0.0), - adjacency_matrix(g)) + adj_matrix) n += 1 end - @test 2 <= n <= 2 + 3 * 2 + @test 2 <= n <= 1 + 3 * 2 end @testset "stress decreases along the schedule" begin From a4350fc2fadd6a10cccd15e54c148b7583d36671 Mon Sep 17 00:00:00 2001 From: Eric Martin Feltham Date: Mon, 28 Sep 2026 10:52:55 -0400 Subject: [PATCH 3/4] Drop the double-emit workaround (per request), fixed in #105 `layout` used to return the second to last item of the iterator, so the `Egocentric` iterator emitted the converged layout twice to make sure the final one was the one returned. #105 fixed that bug, so the workaround can go: `iterate` now simply terminates once the `tseq` schedule is exhausted. The workaround was the only writer of `EgocentricState.finished`, so that field is removed too. The returned layout is unchanged; the iterator now yields one item fewer. Verified by comparing positions before and after across the plain, `maxdist`, `compress=`, `compress=nothing`, 3d/Float32 and karate cases -- all bit-identical. Also adapts the iterator tests to the new `iterations` semantics from single-stage `tseq` with `iterations=l` and zero tolerances now yields exactly `l + 1` layouts, with `last(vec) == algo(adj_matrix)`. --- src/egocentric.jl | 12 ++---------- test/runtests.jl | 20 ++++++++++++++++---- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/egocentric.jl b/src/egocentric.jl index 7c19a58..d9e9a98 100644 --- a/src/egocentric.jl +++ b/src/egocentric.jl @@ -190,7 +190,6 @@ mutable struct EgocentricState{PT,FT} stage::Int # index into tseq iter::Int # majorization steps taken within the current stage laststress::FT - finished::Bool end function Base.iterate(iter::LayoutIterator{<:Egocentric{Dim,Ptype,FT}}) where {Dim,Ptype,FT} @@ -223,7 +222,7 @@ function Base.iterate(iter::LayoutIterator{<:Egocentric{Dim,Ptype,FT}}) where {D end state = EgocentricState(positions, D, W, Z, vec(sum(W; dims=2)), vec(sum(Z; dims=2)), - pin, copy(positions), 1, 0, zero(FT), false) + pin, copy(positions), 1, 0, zero(FT)) state.laststress = _mixedstress(state, first(algo.tseq)) return _egoemit(algo, state), state @@ -232,14 +231,7 @@ end function Base.iterate(iter::LayoutIterator{<:Egocentric}, state) algo = iter.algorithm - state.finished && return nothing - - if state.stage > length(algo.tseq) - # emit the final layout a second time: `layout` returns the second to - # last item of the iterator - state.finished = true - return _egoemit(algo, state), state - end + state.stage > length(algo.tseq) && return nothing t = algo.tseq[state.stage] oldpos = copy(state.positions) diff --git a/test/runtests.jl b/test/runtests.jl index 03f0631..d44fc0f 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -326,17 +326,29 @@ jagmesh_adj = jagmesh() @test !isempty(positions) @test all(p -> length(p) == nv(g), positions) @test all(p -> p[2] == zero(Point2), positions) - # `layout` returns the converged layout the iterator ends on @test last(positions) == algo(adjacency_matrix(g)) - # `iterations` caps each stage: at most initial + stages*iterations + final repeat + # a single stage iterates at most `iterations` times on the initial layout + for l in [1, 5, 10] + vec = Any[] + it = LayoutIterator(Egocentric(; focus=2, tseq=[0.0], iterations=l, + reltols=0.0, abstols=0.0, abstolx=0.0), + adj_matrix) + for p in it + push!(vec, p) + end + @test length(vec) == l + 1 + @test it.algorithm(adj_matrix) == last(vec) + end + + # `iterations` caps every stage of the schedule separately n = 0 for _ in LayoutIterator(Egocentric(; focus=2, tseq=[0.0, 0.5, 1.0], iterations=2, reltols=0.0, abstols=0.0, abstolx=0.0), - adjacency_matrix(g)) + adj_matrix) n += 1 end - @test 2 <= n <= 2 + 3 * 2 + @test 2 <= n <= 1 + 3 * 2 end @testset "stress decreases along the schedule" begin From 7264d87acffd2575b10901a2219a090b11f23858 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hans=20W=C3=BCrfel?= Date: Mon, 28 Sep 2026 19:04:15 +0200 Subject: [PATCH 4/4] fix typo, add news --- NEWS.md | 1 + docs/src/index.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index 9dbd460..493e79c 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,6 +1,7 @@ # NetworkLayout Release Notes ## v0.4.11 Changelog +- New `Egocentric` layout: stress majorization centered on a focal vertex, placing every node on a ring at its graph distance from the focus (Brandes & Pich 2011). - Fixes two bugs in the iteration scheme: - The layout iterator returned the second-to-last layout; now it returns the final positions after full iteration. - Iterative layouts computed `iterations` layouts, including the initial guess. Now `iterations` describes the number of actual *iterations*, and the layout iterator returns at most `iterations + 1` layouts. diff --git a/docs/src/index.md b/docs/src/index.md index 9be7c60..3175b43 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -250,7 +250,7 @@ f #hide ## `pin` Positions in Interative Layouts Sometimes it is desired to fix the positions of a few nodes while arranging the rest "naturally" around them. The iterative layouts [`Stress`](@ref), [`Spring`](@ref), [`SFDP`](@ref) and -[`Egocentric`](@ref) allow to pin nodes to certain positions, i.e. those node will +[`Egocentric`](@ref) allow to pin nodes to certain positions, i.e. those nodes will stay fixed during the iteration. ```@example layouts g = SimpleGraph(vcat(hcat(zeros(4,4), ones(4,4)), hcat(ones(4,4), zeros(4,4))))