2026 02 18_layout_strategy_plan - mark-ik/graphshell GitHub Wiki
Date: 2026-02-18 Status: In Progress โ implementation started
GraphShell currently has one physics mode: Fruchterman-Reingold with fixed parameters. The goal is a layout system where the algorithm adapts to data topology and user context. Different preset configurations โ and for non-flat topologies, position injection โ drive layout without requiring AGPL dependencies (ForceAtlas2 is out).
Five presets are introduced: Peer (current defaults), Community (adaptive repulsion for clusters), Dense, Sparse, Timeline (y-axis temporal constraint). Three non-FR modes use position injection: Hierarchical (Sugiyama via rust-sugiyama), Radial (ego network circle), and Barnes-Hut (replaces FR for Community when N > 500).
egui_graphs owns positions internally between structural rebuilds. Positions can be overridden by
writing to egui_state.graph.node_mut(key).set_location(pos) after GraphView renders each frame.
This lets external algorithms drive positions while egui_graphs handles rendering and interaction.
Frame hooks in render_graph_in_ui_collect_actions() (render/mod.rs):
-
Hook A (within
egui_state_dirtyrebuild, beforeEguiGraphState::from_graph()): run Sugiyama, write positions toapp.graph.node.positionso they are seeded into egui_graphs on rebuild. -
Hook B (after
get_layout_state): Timeline y-injection, Radial ego injection, BH physics step.
sync_graph_positions_from_layout() in tile_behavior.rs:198 then reads egui_state positions back
to app.graph.node.position each frame (existing behavior; compatible with all presets).
Tasks
- New file
ports/graphshell/desktop/layout_preset.rs:pub enum LayoutPreset { Peer, Community, Dense, Sparse, Timeline, Hierarchical, Radial }impl LayoutPreset { fn label(), fn uses_fr(), fn is_position_injected() }pub fn params_for(preset, node_count) -> FruchtermanReingoldState- FR configs per preset (see Findings ยงFR Preset Parameters)
- Community preset: adaptive
c_repulse = (0.55 + 0.03 * N.sqrt()).min(2.5)
-
desktop/mod.rs: addpub(crate) mod layout_preset; -
app.rs:- Add
pub layout_preset: LayoutPresetandpub timeline_newer_at_top: boolfields - Add
GraphIntent::SetLayoutPreset(LayoutPreset)variant - Add
set_layout_preset(&mut self, preset)method (applies params, handles is_running, setsegui_state_dirtyfor Hierarchical) - Wire arm in
apply_intent() - Initialize in
new_from_dir()andnew_for_testing()
- Add
Validation Tests
-
test_set_layout_preset_updates_physics_configโ each preset produces distinct c_repulse -
test_community_preset_adaptive_repulsionโ c_repulse(N=100) > c_repulse(N=10) -
test_preset_preserves_fr_running_stateโis_runningpreserved across FR preset switch -
test_non_fr_preset_disables_frโ Hierarchical/Radial setis_running = false -
test_hierarchical_sets_egui_state_dirtyโ switching to Hierarchical triggers rebuild flag
Tasks
-
render/mod.rsโrender_physics_panel():- Add preset selector (horizontal wrapped selectable labels) above existing sliders
- Emit
GraphIntent::SetLayoutPreset(preset)on click - Add Timeline direction toggle (visible only when
layout_preset == Timeline):- "Newer at bottom" / "Newer at top" via
app.timeline_newer_at_top: bool
- "Newer at bottom" / "Newer at top" via
- Update Reset button to use
layout_preset::params_for(app.layout_preset, node_count)instead of hard-codeddefault_physics_state()
-
render/mod.rsโ Hook B: addapply_post_frame_layout_injection(app)call afterget_layout_state - New fn
apply_post_frame_layout_injection(app)dispatches on preset to injection fns - New fn
apply_timeline_y_positions(app):- Reads
node.last_visitedtimestamps across all nodes - Lerps each node's y 5% per frame toward
target_y = (t - t_min) / range * y_span - y_span/2 - Direction inverted if
app.timeline_newer_at_top
- Reads
Validation Tests
-
test_timeline_y_direction_flagโ verify newer-at-top produces lower y target for recent node
Tasks
-
Cargo.toml: addrust-sugiyama = "0.4"under non-Android deps section (petgraph 0.8.3 already present; compatible) -
render/mod.rsโ Hook A: inegui_state_dirtybranch, iflayout_preset == Hierarchical, callapply_hierarchical_sugiyama(app, graph_for_render)beforeEguiGraphState::from_graph() - New fn
apply_hierarchical_sugiyama(app, graph):- Call
rust_sugiyama::from_graph(&graph.inner).call()โHashMap<NodeIndex, (f32, f32)> - Scale coordinates by
SCALE = 80.0canvas units - Write to
app.graph.inner.node_weight_mut(idx).unwrap().position - On error (cycles, disconnected): fall back silently (keep existing positions)
- Call
Note: rust_sugiyama::from_graph exact API must be verified against docs.rs/0.4.0 at impl time.
Our graph.inner is StableGraph<Node, EdgeType, Directed> which IS StableDiGraph<Node, EdgeType>.
Validation Tests
-
test_hierarchical_does_not_panic_on_cyclic_graphโ fallback works for cyclic input -
test_hierarchical_positions_differ_from_defaultsโ positions change after applying preset
Tasks
-
render/mod.rsโ Hook B dispatcher: iflayout_preset == Radial, callapply_ego_radial_positions(app, ego)whereego = selected_nodes.primary().or(hovered_graph_node) - New fn
apply_ego_radial_positions(app, ego):- Read ego's current
egui_statelocation (don't move the ego itself) - Collect unique neighbors:
out_neighbors(ego) โช in_neighbors(ego)via HashSet dedup - Distribute on circle of radius 150.0 canvas units:
angle = TAU * i / n - Soft-spring each neighbor toward its target: lerp 12% per frame
-
egui_node.set_location(...)for each neighbor
- Read ego's current
Validation Tests
-
test_ego_radial_neighbors_at_correct_anglesโ mock egui_state, verify N neighbors at 360/Nยฐ intervals -
test_ego_radial_noop_when_no_selectionโ no-op whenprimary()andhovered_graph_nodeare None
Tasks
-
render/mod.rsโ Hook B dispatcher: iflayout_preset == Community && N > BH_NODE_THRESHOLD (500), callapply_barnes_hut_physics_step(app); this path also setsphysics.is_running = false - New struct
QuadTree(~100 lines):- Fields:
bounds: Rect,center_of_mass: Pos2,mass: f32,children: Option<Box<[QuadTree; 4]>> build(positions: &[(NodeKey, Pos2)]) -> Self-
approximate_repulsion(pos: Pos2, theta: f32, strength: f32) -> Vec2โ BH traversal, theta=0.9
- Fields:
- New fn
apply_barnes_hut_physics_step(app):- Read positions from egui_state (authoritative)
- Build quadtree from all node positions
- For each non-pinned node: compute BH repulsion + edge attraction force
- Update
app.graph.node.velocity(with damping) andapp.graph.node.position - Write new position to
egui_state.graph.node_mut(key).set_location() - Uses
app.physics.{c_repulse, c_attract, dt, damping}for force parameters
Validation Tests
-
test_bh_threshold_controls_activationโ BH not called for N=499, called for N=501 -
test_bh_repulsion_is_nonzeroโ two nearby nodes produce nonzero repulsion force -
test_bh_pinned_nodes_not_movedโ pinned nodes keep their positions after BH step
Tasks
- Write this file to:
implementation_strategy/2026-02-18_layout_strategy_plan.md - Update
INDEX.mdActive Implementation Plans table with this doc
| Crate | Algorithm | Status | License | Notes |
|---|---|---|---|---|
forceatlas2 0.8.0 |
ForceAtlas2 (Barnes-Hut) | Active Oct 2025 | AGPL v3 | Own graph type; AGPL incompatible with MPL |
fdg-sim 0.9.1 |
Custom spring + FR variants | Dormant Dec 2022 | Apache/MIT | petgraph-native; egui_graphs advanced demo uses it |
rust-sugiyama 0.4.0 |
Sugiyama layered DAG layout | Active Sep 2025 | MIT | petgraph StableDiGraph input; returns (x,y) coords |
dagre-rs 0.1.0 |
Dagre hierarchical | New Oct 2025 | ? | 437 LOC, single release; petgraph 0.8 |
| Graphviz bindings | dot, neato, sfdp, circo, twopi | Various | Various | C library required; batch layout only |
Missing in Rust: Kamada-Kawai/stress majorization, pure circular/radial layout, timeline layout, UMAP-style, mixed-subgraph layouts.
-
ForceAlgorithmtrait:from_state(S) -> Self,step(&mut self, graph, viewport),state(&self) -> S -
ExtraForcetrait: compose extra forces viaExtra<T, const ENABLED: bool>tuple โ changesGraphViewtype signature -
Layouttrait: fully custom positioning (not force-based);LayoutHierarchicalbuilt-in but minimal -
LayoutForceDirected<T: ForceAlgorithm>โ current usage withFruchtermanReingold -
Position injection (chosen approach): write
egui_node.set_location()post-frame; avoids generics cascade; compatible with existingGraphViewtype
Calibrated from research report ยง16 (D3-force constants) and ยง4 (Preset A):
| Preset | c_repulse | c_attract | k_scale | damping | FR running |
|---|---|---|---|---|---|
| Peer | 0.55 | 0.10 | 0.65 | 0.92 | preserved |
| Community | 0.55 + 0.03โN (โค2.5) | 0.06 | 0.80 | 0.88 | preserved |
| Dense | 0.25 | 0.15 | 0.45 | 0.95 | preserved |
| Sparse | 0.70 | 0.18 | 0.80 | 0.90 | preserved |
| Timeline | 0.40 | 0.12 | 0.55 | 0.93 | preserved |
| Hierarchical | 0.20 | 0.05 | 0.40 | 0.98 | false |
| Radial | 0.20 | 0.05 | 0.40 | 0.98 | false |
-
app.graph.node.positionโ seed for egui_graphs on structural rebuild; updated bysync_graph_positions_from_layout()each frame -
egui_state.graphโ authoritative live positions (FR updates these); read vianode(key).location() -
node.velocity: Vector2D<f32>โ available on Node struct; unused by FR (FR manages own velocities); used by BH physics step -
sync_graph_positions_from_layout()called fromtile_behavior.rs:198; reads egui_state โ app.graph; pinned nodes restored
// Expected API based on docs.rs/rust-sugiyama/0.4.0:
rust_sugiyama::from_graph(&stable_di_graph)
.call()
// -> Result<HashMap<NodeIndex, (f32, f32)>, _>Our graph.inner is StableGraph<Node, EdgeType, Directed> = StableDiGraph<Node, EdgeType>. Compatible.
Sugiyama produces integer-scale coordinates; multiply by SCALE=80.0 for canvas units.
- Surveyed Rust graph layout ecosystem
- Analyzed egui_graphs 0.29
ForceAlgorithm/Layouttrait extension points - Identified position injection as the right architecture (avoids generics cascade in
GraphBrowserApp) - Explored full codebase:
app.rs,render/mod.rs,graph/egui_adapter.rs,desktop/tile_behavior.rs - Located
sync_graph_positions_from_layout()call site (tile_behavior.rs:198) - Designed all 5 feature targets + Barnes-Hut threshold (N=500)
- Plan approved by user.
- Implementation started.
- Feature Targets 1โ6: all tasks completed.