Skip to content

Add Egocentric layout - #104

Merged
hexaeder merged 5 commits into
JuliaGraphs:masterfrom
emfeltham:egocentric-layout
Sep 28, 2026
Merged

hexaeder merged 5 commits into
JuliaGraphs:masterfrom
emfeltham:egocentric-layout

Conversation

@emfeltham

@emfeltham emfeltham commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Adds Egocentric, a stress-majorization layout centred on a single focal vertex, following Brandes and Pich (2011).

Brandes, Ulrik, and Christian Pich. "More flexible radial layout." Journal of Graph Algorithms and Applications 15.1 (2011): 157-173.

pos = egocentric(g; focus=v)
pos = egocentric(g; focus=v, maxdist=4)             # bound the extent
pos = egocentric(g; focus=v, maxdist=4, compress=1) # one outer ring for "d > 4"

Layout

The layout interpolates between two objectives:

  • the plain stress objective, which matches all pairwise euclidean distances to graph distances (this is Stress), 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 focus. The result reads as concentric rings of constant geodesic distance, while the angles still come from the unconstrained stress solution, so vertices that are close in the network stay close on their ring. Optimization walks a schedule tseq of mixing parameters from 0 to 1, majorizing each stage to convergence: pure stress picks sensible angles first, then the radii are gradually enforced.

Notes

  • Implemented as an IterativeLayout, so LayoutIterator animates the schedule. The final layout is emitted twice, because layout returns the second to last item of the iterator.
  • The majorization 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. With the in-place sweep the radii land within ~1e-15 of the graph distances.
  • The focal vertex is never moved and stays at the origin. Stress is translation invariant, so holding one vertex fixed only fixes the gauge. It also means initialpos, pin and the returned positions all live in the same focus-centred frame, which is what makes pin usable here.
  • Initial positions come from classical (Torgerson) MDS of the graph distances plus a small jitter, computed inline from LinearAlgebra so there is no new dependency. This makes the result largely independent of seed.
  • Unconnected vertices reuse the existing uncon_dist treatment from Stress rather than being dropped, so the layout keeps the adjacency matrix -> one position per vertex contract.
  • maxdist optionally compresses vertices beyond a given ring onto a narrow outer band, keeping their angles. Without it a handful of remote vertices can dominate the frame, since radius equals graph distance. compress controls the falloff and accepts a function (default log1p), a number (collapse onto a single outer ring), or nothing (clamp onto the maxdist ring).

Supports dim/Ptype, initialpos, pin, seed/rng, weighted adjacency matrices, and composes with Align.

Specific changes

  • src/egocentric.jl (new), included from src/NetworkLayout.jl
  • test/runtests.jl: a Testing Egocentric Layout testset covering construction and argument validation, the focus sitting at the origin, radii matching graph distances (2d and 3d), weighted adjacency matrices, the maxdist/compress variants and that compression leaves angles untouched, unconnected vertices, determinism, initialpos/pin, the iterator, and monotone stress decrease along a stage
  • docs/src/index.md: an Egocentric Layout section with examples and an iterator animation, plus Egocentric added to the list of layouts that support pin

@codecov

codecov Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.50000% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.28%. Comparing base (6ad1e2f) to head (7264d87).
⚠️ Report is 4 commits behind head on master.

Files with missing lines Patch % Lines
src/egocentric.jl 97.50% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #104      +/-   ##
==========================================
- Coverage   97.41%   97.28%   -0.14%     
==========================================
  Files          10       11       +1     
  Lines         580      736     +156     
==========================================
+ Hits          565      716     +151     
- Misses         15       20       +5     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@hexaeder

Copy link
Copy Markdown
Collaborator

thanks for the contribution! Can you rebase on master and get rid of the double ejecting of final layout? That was an actual bug i fixed in #105

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 <noreply@anthropic.com>
`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. JuliaGraphs#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=<number>`, `compress=nothing`, 3d/Float32 and karate cases -- all
bit-identical.

Also adapts the iterator tests to the new `iterations` semantics from JuliaGraphs#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 <noreply@anthropic.com>
`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. JuliaGraphs#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=<number>`, `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)`.
@emfeltham

Copy link
Copy Markdown
Contributor Author

Okay, great. Take a look and let me know!

@hexaeder
hexaeder merged commit 8f9a327 into JuliaGraphs:master Sep 28, 2026
6 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants