diff --git a/docs/src/index.md b/docs/src/index.md index 6e9ed50..d7d80a0 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -104,6 +104,25 @@ nothing #hide ``` ![spring animation](spring_animation.mp4) +## Aligning Layouts + +Any two-dimensional layout can have its principal axis aligned along a desired angle (default, zero angle), by nesting an "inner" layout into an [`Align`](@ref) layout. +For example, we may align the above `Spring` layout of the small cubical graph along the horizontal or vertical axes: + +```@docs +Align +``` +```@example layouts +g = smallgraph(:cubical) +f, ax, p = graphplot(g, layout=Align(Spring())) # horizontal alignment (zero angle by default) +hidedecorations!(ax); hidespines!(ax); ax.aspect = DataAspect(); f #hide +``` + +```@example layouts +f, ax, p = graphplot(g, layout=Align(Spring(), pi/2)) # vertical alignment +hidedecorations!(ax); hidespines!(ax); ax.aspect = DataAspect(); f #hide +``` + ## Stress Majorization ```@docs Stress diff --git a/src/NetworkLayout.jl b/src/NetworkLayout.jl index 55dec9e..257fb88 100644 --- a/src/NetworkLayout.jl +++ b/src/NetworkLayout.jl @@ -214,7 +214,7 @@ macro addcall(expr::Expr) @assert typedef isa Expr && typedef.head === :<: && typedef.args[2] isa Expr && # supertype - typedef.args[2].args[1] ∈ [:AbstractLayout, :IterativeLayout] "Macro musst be used on subtype of AbstractLayout" + typedef.args[2].args[1] ∈ [:AbstractLayout, :IterativeLayout] "Macro must be used on subtype of AbstractLayout" if typedef.args[1] isa Symbol # no type parameters name = typedef.args[1] @@ -238,5 +238,6 @@ include("stress.jl") include("spectral.jl") include("shell.jl") include("squaregrid.jl") +include("align.jl") end diff --git a/src/align.jl b/src/align.jl new file mode 100644 index 0000000..cf6415b --- /dev/null +++ b/src/align.jl @@ -0,0 +1,48 @@ +export Align + +""" + Align(inner_layout :: AbstractLayout{2, Ptype}, angle :: Ptype = zero(Ptype)) + +Align the vertex positions of `inner_layout` so that the principal axis of the resulting +layout makes an `angle` with the **x**-axis. +Also automatically centers the layout origin to its center of mass (average node position). + +Only supports two-dimensional inner layouts. +""" +@addcall struct Align{Ptype, L <: AbstractLayout{2, Ptype}} <: AbstractLayout{2, Ptype} + inner_layout :: L + angle :: Ptype + function Align(inner_layout::L, angle::Real) where {L <: AbstractLayout{2, Ptype}} where Ptype + new{Ptype, L}(inner_layout, convert(Ptype, angle)) + end +end +Align(inner_layout::AbstractLayout{2, Ptype}) where Ptype = Align(inner_layout, zero(Ptype)) + +function layout(algo::Align{Ptype, <:AbstractLayout{2, Ptype}}, adj_matrix::AbstractMatrix) where {Ptype} + # compute "inner" layout + rs = layout(algo.inner_layout, adj_matrix) + + # align the "inner" layout to have its principal axis make `algo.angle` with x-axis + # step 1: compute covariance matrix for PCA analysis: + # C = ∑ᵢ (rᵢ - ⟨r⟩) (rᵢ - ⟨r⟩)ᵀ + # for vertex positions rᵢ, i = 1, …, N, and center of mass ⟨r⟩ = N⁻¹ ∑ᵢ rᵢ. + centerofmass = sum(rs) / length(rs) + C = zeros(SMatrix{2, 2, Ptype}) + for r in rs + C += (r - centerofmass) * (r - centerofmass)' + end + vs = eigen(C).vectors + + # step 2: pick principal axis (largest eigenvalue → last eigenvalue/vector) + axis = vs[:, end] + axis_angle = atan(axis[2], axis[1]) + + # step 3: rotate positions `rs` so that new axis is aligned with `algo.angle` + s, c = sincos(-axis_angle + algo.angle) + R = @SMatrix [c -s; s c] # [cos(θ) -sin(θ); sin(θ) cos(θ)] + for (i, r) in enumerate(rs) + rs[i] = Point2{Ptype}(R * (r-centerofmass)) :: Point2{Ptype} + end + + return rs +end \ No newline at end of file diff --git a/test/runtests.jl b/test/runtests.jl index 42efe2f..bfe74c0 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,4 +1,5 @@ using NetworkLayout +using NetworkLayout: AbstractLayout, @addcall using Graphs using GeometryBasics using DelimitedFiles: readdlm @@ -465,4 +466,23 @@ jagmesh_adj = jagmesh() @test ep[5][2] != [2] end end + + @testset "Align" begin + @addcall struct Manual{Dim, Ptype} <: AbstractLayout{Dim, Ptype} + positions :: Vector{Point{Dim, Ptype}} + end + NetworkLayout.layout(algo::Manual, ::AbstractMatrix) = copy(algo.positions) + + g = Graph(2); add_edge!(g, 1, 2) + pos = Align(Manual([Point2f(1, 2), Point2f(2, 3)]), 0.0)(g) + @test all(r->abs(r[2])<1e-12, pos) + @test norm(pos[1]-pos[2]) == norm(Point2f(1, 2)-Point2f(2, 3)) + + g = Graph(3); add_edge!(g, 1, 2); add_edge!(g, 2, 3); add_edge!(g, 3, 1) + pos = Align(Manual([Point2f(0, 4), Point2f(-1, -2), Point2f(1, -2)]), 0.0)(g) + @test pos ≈ [Point2f(4, 0), Point2f(-2, 1), Point2f(-2, -1)] + + pos = Align(Manual([Point2f(0, 4), Point2f(-1, -2), Point2f(1, -2)]), π/2)(g) + @test pos ≈ [Point2f(0, 4), Point2f(-1, -2), Point2f(1, -2)] + end end