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 d7d80a0..3175b43 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 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)))) 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..d9e9a98 --- /dev/null +++ b/src/egocentric.jl @@ -0,0 +1,425 @@ +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 +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)) + state.laststress = _mixedstress(state, first(algo.tseq)) + + return _egoemit(algo, state), state +end + +function Base.iterate(iter::LayoutIterator{<:Egocentric}, state) + algo = iter.algorithm + + state.stage > length(algo.tseq) && return nothing + + 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..d44fc0f 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -190,6 +190,181 @@ 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) + @test last(positions) == algo(adjacency_matrix(g)) + + # 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), + adj_matrix) + n += 1 + end + @test 2 <= n <= 1 + 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