Skip to content

Commit 1b702d3

Browse files
authored
Merge pull request #53 from JuliaGraphs/hw/fixed
add option to pin node positions
2 parents 8eca84d + 199dd56 commit 1b702d3

File tree

11 files changed

+333
-77
lines changed

11 files changed

+333
-77
lines changed

.github/workflows/CI.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
name: CI
22
on:
3-
- push
4-
- pull_request
3+
push:
4+
branches:
5+
- master
6+
tags: '*'
7+
pull_request:
58
jobs:
69
test:
710
name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ matrix.arch }} - ${{ github.event_name }}

Project.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
88
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
99
Requires = "ae029012-a4dd-5104-9daa-d747884805df"
1010
SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf"
11+
StaticArrays = "90137ffa-7385-5640-81b9-e52037218182"
1112

1213
[compat]
1314
GeometryBasics = "0.4"
1415
Requires = "1"
16+
StaticArrays = "1"
1517
julia = "1"
1618

1719
[extras]

docs/localmake.jl

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#! julia --startup-file=no
2+
3+
using Pkg
4+
Pkg.activate(@__DIR__)
5+
Pkg.develop(PackageSpec(path=dirname(@__DIR__))) # adds the package this script is called from
6+
Pkg.update()
7+
Pkg.instantiate()
8+
9+
using LiveServer
10+
@async serve(dir=joinpath(@__DIR__, "build"))
11+
12+
run = true
13+
while run
14+
try
15+
include("make.jl")
16+
catch e
17+
@info "make.jl error" e
18+
end
19+
20+
println("Run again? Enter! Exit with 'q'.")
21+
if readline() == "q"
22+
global run = false
23+
end
24+
end

docs/make.jl

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,19 @@ makedocs(; modules=[NetworkLayout],
1212
format=Documenter.HTML(; prettyurls=get(ENV, "CI", "false") == "true",
1313
canonical="https://juliagraphs.org/NetworkLayout.jl", assets=String[]),
1414
pages=["Home" => "index.md",
15-
"Interface" => "interface.md"])
15+
"Interface" => "interface.md"],
16+
strict=[:autodocs_block,
17+
:cross_references,
18+
:docs_block,
19+
:doctest,
20+
:eval_block,
21+
:example_block,
22+
:footnote,
23+
:linkcheck,
24+
:meta_block,
25+
#:missing_docs,
26+
:parse_error,
27+
:setup_block])
1628

1729
# if gh_pages branch gets to big, check out
1830
# https://juliadocs.github.io/Documenter.jl/stable/man/hosting/#gh-pages-Branch

docs/servedocs

Lines changed: 0 additions & 2 deletions
This file was deleted.

docs/src/index.md

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ All example images on this page are created using [`Makie.jl`](https://github.co
1111
using CairoMakie
1212
CairoMakie.activate!(type="png") # hide
1313
set_theme!(resolution=(800, 400)) #hide
14-
CairoMakie.inline!(true) # hide
1514
using NetworkLayout
1615
using GraphMakie, Graphs
1716
nothing #hide
@@ -174,3 +173,62 @@ layout = Spectral()
174173
f, ax, p = graphplot(g, layout=layout, node_size=0.0, edge_width=1.0)
175174
f #hide
176175
```
176+
177+
## `pin` Positions in Interative Layouts
178+
Sometimes it is desired to fix the positions of a few nodes while arranging the rest "naturally" around them.
179+
The iterative layouts [`Stress`](@ref), [`Spring`](@ref) and [`SFDP`](@ref) allow to pin
180+
nodes to certain positions, i.e. those node will stay fixed during the iteration.
181+
```@example layouts
182+
g = SimpleGraph(vcat(hcat(zeros(4,4), ones(4,4)), hcat(ones(4,4), zeros(4,4))))
183+
nothing #hide
184+
```
185+
186+
The keyword argument `pin` takes a Vector or a Dict of key - value pairs. The key has to be
187+
the index of the node. The value can take three forms:
188+
- `idx => Point2(x,y) ` or `idx => (x,y)` overwrites the initial position of that vertex and pins it there,
189+
- `idx => true/false` pins or unpins the vertex, position is taken from `initialpos`-keyword argument or random,
190+
- `idx => (false, true)` allows for fine control over which coordinate to pin.
191+
192+
```@example layouts
193+
initialpos = Dict(1=>Point2f(-1,0.5),
194+
3=>Point2f(1,0),
195+
4=>Point2f(1,0))
196+
pin = Dict(1=>true,
197+
2=>(-1,-0.5),
198+
3=>(true, false),
199+
4=>(true, false))
200+
nothing #hide
201+
```
202+
Example animation on how those keyword arguments effect different iterative layouts:
203+
```@example layouts
204+
springl = Spring(;initialpos, pin, seed=11) #2
205+
sfdpl = SFDP(;initialpos, pin, tol=0.0)
206+
stressl = Stress(;initialpos, pin, reltols=0.0, abstolx=0.0, iterations=100)
207+
208+
f = Figure(resolution=(1200,500))
209+
ax1 = f[1,1] = Axis(f; title="Spring")
210+
ax2 = f[1,2] = Axis(f; title="SFDP")
211+
ax3 = f[1,3] = Axis(f; title="Stress")
212+
213+
for ax in [ax1, ax2, ax3]
214+
xlims!(ax,-2,2); ylims!(ax,-1.4,1.4); vlines!(ax, 1; color=:red); hidespines!(ax); hidedecorations!(ax)
215+
end
216+
217+
node_color = vcat(:green, :green, :red, :red, [:black for _ in 1:4])
218+
node_size = vcat([40 for _ in 1:4], [20 for _ in 1:4])
219+
nlabels = vcat("1", "2", "3", "4", ["" for _ in 1:4])
220+
nlabels_align = (:center, :center)
221+
nlabels_color = :white
222+
p1 = graphplot!(ax1, g; layout=springl, node_color, node_size, nlabels, nlabels_align, nlabels_color)
223+
p2 = graphplot!(ax2, g; layout=sfdpl, node_color, node_size, nlabels, nlabels_align, nlabels_color)
224+
p3 = graphplot!(ax3, g; layout=stressl, node_color, node_size, nlabels, nlabels_align, nlabels_color)
225+
226+
iterators = [LayoutIterator(l, g) for l in (springl, sfdpl, stressl)]
227+
record(f, "pin_animation.mp4", zip(iterators...); framerate = 10) do (pos1, pos2, pos3)
228+
p1[:node_pos][] = pos1
229+
p2[:node_pos][] = pos2
230+
p3[:node_pos][] = pos3
231+
end
232+
nothing #hide
233+
```
234+
![pin animation](pin_animation.mp4)

src/NetworkLayout.jl

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ using GeometryBasics
44
using Requires
55
using LinearAlgebra: norm
66
using Random
7+
using StaticArrays
78

89
export LayoutIterator
910

@@ -137,6 +138,66 @@ function make_symmetric!(A::AbstractMatrix)
137138
return A
138139
end
139140

141+
"""
142+
Initialpos and pin can be given as diffent types (dicts, vectors, ...)
143+
Sanitize and transform them into
144+
145+
_initialpos :: Dict{Int,Point{dim,Ptype}}()
146+
_pin :: Dict{Int,SVector{dim,Bool}}()
147+
"""
148+
function _sanitize_initialpos_pin(dim, Ptype, initialpos, pin)
149+
if !isempty(initialpos)
150+
_initialpos = Dict{Int,Point{dim,Ptype}}(k => Point{dim,Ptype}(v) for (k, v) in pairs(initialpos))
151+
else
152+
_initialpos = Dict{Int,Point{dim,Ptype}}()
153+
end
154+
155+
_pin = Dict{Int,SVector{dim,Bool}}()
156+
for (k, v) in pairs(pin)
157+
if v == nothing
158+
continue
159+
elseif v isa Bool
160+
_pin[k] = SVector{dim,Bool}(v for i in 1:dim)
161+
else # some container
162+
if eltype(v) <: Bool
163+
_pin[k] = v
164+
else
165+
# seems to be an initial position
166+
_initialpos[k] = v
167+
_pin[k] = SVector{dim,Bool}([true for i in 1:dim])
168+
end
169+
end
170+
end
171+
return _initialpos, _pin
172+
end
173+
174+
"""
175+
From an point or a colletion of point like objects try to
176+
infer the PType and the dimension.
177+
178+
i.e.
179+
infer_pointtype([(1,2), (2.3, 4)]) == (2, Float64)
180+
"""
181+
infer_pointtype(::AbstractPoint{dim,t}) where {dim,t} = dim, t
182+
infer_pointtype(::NTuple{dim,t}) where {dim,t} = dim, t
183+
infer_pointtype(t::Tuple) = length(t), promote_type(typeof(t).parameters...)
184+
function infer_pointtype(v)
185+
v = values(v) # needed for broadcast ofer dict
186+
isempty(v) && throw(ArgumentError("Can not infer pointtype of empty container!"))
187+
elt = isconcretetype(eltype(v)) ? eltype(v) : promote_type(typeof.(v)...)
188+
189+
if elt <: Number
190+
return (length(v), elt)
191+
else
192+
ty = infer_pointtype.(v)
193+
dims = getindex.(ty, 1)
194+
if !all(isequal(first(dims)), dims)
195+
throw(ArgumentError("Got container with different point dimesions!"))
196+
end
197+
(dims[1], promote_type(getindex.(ty, 2)...))
198+
end
199+
end
200+
140201
"""
141202
@addcall
142203

src/sfdp.jl

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,15 @@ the nodes.
2020
- `iterations=100`: maximum number of iterations
2121
- `initialpos=Point{dim,Ptype}[]`
2222
23-
Provide list of initial positions. If length does not match Network size the initial
24-
positions will be truncated or filled up with random values between [-1,1] in every coordinate.
23+
Provide `Vector` or `Dict` of initial positions. All positions will be initialized
24+
using random coordinates between [-1,1]. Random positions will be overwritten using
25+
the key-val-pairs provided by this argument.
26+
27+
- `pin=[]`: Pin node positions (won't be updated). Can be given as `Vector` or `Dict`
28+
of node index -> value pairings. Values can be either
29+
- `(12, 4.0)` : overwrite initial position and pin
30+
- `true/false` : pin this position
31+
- `(true, false, false)` : only pin certain coordinates
2532
2633
- `seed=1`: Seed for random initial positions.
2734
"""
@@ -30,44 +37,45 @@ the nodes.
3037
C::T
3138
K::T
3239
iterations::Int
33-
initialpos::Vector{Point{Dim,Ptype}}
40+
initialpos::Dict{Int,Point{Dim,Ptype}}
41+
pin::Dict{Int,SVector{Dim,Bool}}
3442
seed::UInt
3543
end
3644

3745
# TODO: check SFDP default parameters
38-
function SFDP(; dim=2, Ptype=Float64, tol=1.0, C=0.2, K=1.0, iterations=100, initialpos=Point{dim,Ptype}[],
46+
function SFDP(; dim=2, Ptype=Float64,
47+
tol=1.0, C=0.2, K=1.0,
48+
iterations=100,
49+
initialpos=[], pin=[],
3950
seed=1)
4051
if !isempty(initialpos)
41-
initialpos = Point.(initialpos)
42-
Ptype = eltype(eltype(initialpos))
43-
# TODO fix initial pos if list has points of multiple types
44-
Ptype == Any && error("Please provide list of Point{N,T} with same T")
45-
dim = length(eltype(initialpos))
52+
dim, Ptype = infer_pointtype(initialpos)
53+
Ptype = promote_type(Float32, Ptype) # make sure to get at least f32 if given as int
4654
end
47-
return SFDP{dim,Ptype,typeof(tol)}(tol, C, K, iterations, initialpos, seed)
55+
_initialpos, _pin = _sanitize_initialpos_pin(dim, Ptype, initialpos, pin)
56+
57+
return SFDP{dim,Ptype,typeof(tol)}(tol, C, K, iterations, _initialpos, _pin, seed)
4858
end
4959

5060
function Base.iterate(iter::LayoutIterator{SFDP{Dim,Ptype,T}}) where {Dim,Ptype,T}
5161
algo, adj_matrix = iter.algorithm, iter.adj_matrix
5262
N = size(adj_matrix, 1)
53-
M = length(algo.initialpos)
5463
rng = MersenneTwister(algo.seed)
55-
startpos = Vector{Point{Dim,Ptype}}(undef, N)
56-
# take the first
57-
for i in 1:min(N, M)
58-
startpos[i] = algo.initialpos[i]
59-
end
60-
# fill the rest with random points
61-
for i in (M + 1):N
62-
startpos[i] = 2 .* rand(rng, Point{Dim,Ptype}) .- 1
64+
startpos = [2 .* rand(rng, Point{Dim,Ptype}) .- 1 for _ in 1:N]
65+
66+
for (k, v) in algo.initialpos
67+
startpos[k] = v
6368
end
64-
# iteratorstate: (#iter, energy, step, progress, old pos, stopflag)
65-
return startpos, (1, typemax(T), one(T), 0, startpos, false)
69+
70+
pin = [get(algo.pin, i, SVector{Dim,Bool}(false for _ in 1:Dim)) for i in 1:N]
71+
72+
# iteratorstate: (#iter, energy, step, progress, old pos, pin, stopflag)
73+
return startpos, (1, typemax(T), one(T), 0, startpos, pin, false)
6674
end
6775

6876
function Base.iterate(iter::LayoutIterator{<:SFDP}, state)
6977
algo, adj_matrix = iter.algorithm, iter.adj_matrix
70-
iter, energy0, step, progress, locs0, stopflag = state
78+
iter, energy0, step, progress, locs0, pin, stopflag = state
7179
K, C, tol = algo.K, algo.C, algo.tol
7280

7381
# stop if stopflag (tol reached) or nr of iterations reached
@@ -93,7 +101,14 @@ function Base.iterate(iter::LayoutIterator{<:SFDP}, state)
93101
((locs[j] .- locs[i]) / norm(locs[j] .- locs[i])))
94102
end
95103
end
96-
locs[i] = locs[i] .+ step .* (force ./ norm(force))
104+
if any(isnan, force)
105+
# if two points are at the exact same location
106+
# use random force in any direction
107+
rng = MersenneTwister(algo.seed + i)
108+
force = randn(rng, Ftype)
109+
end
110+
mask = (!).(pin[i]) # where pin=true mask will multiply with 0
111+
locs[i] = locs[i] .+ (step .* (force ./ norm(force))) .* mask
97112
energy = energy + norm(force)^2
98113
end
99114
step, progress = update_step(step, energy, energy0, progress)
@@ -103,7 +118,7 @@ function Base.iterate(iter::LayoutIterator{<:SFDP}, state)
103118
stopflag = true
104119
end
105120

106-
return locs, (iter + 1, energy, step, progress, locs, stopflag)
121+
return locs, (iter + 1, energy, step, progress, locs, pin, stopflag)
107122
end
108123

109124
# Calculate Attractive force

0 commit comments

Comments
 (0)