diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 434d3ce..2623511 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,10 @@ on: env: CARGO_TERM_COLOR: always RUSTFLAGS: -D warnings + RUSTDOCFLAGS: -D warnings + +permissions: + contents: read jobs: test: @@ -16,6 +20,10 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install system deps (Bevy / winit) run: | sudo apt-get update @@ -35,13 +43,27 @@ jobs: run: cargo fmt --all -- --check - name: test hyper-viz - run: cargo test -p hyper-viz + run: cargo test --locked -p hyper-viz - name: test hyper-viz-bevy (lib) - run: cargo test -p hyper-viz-bevy --lib + run: cargo test --locked -p hyper-viz-bevy --lib - name: clippy - run: cargo clippy -p hyper-viz -p hyper-viz-bevy --all-targets -- -D warnings + run: cargo clippy --locked --workspace --all-targets -- -D warnings + + - name: Public Rust documentation + run: cargo doc --locked --workspace --no-deps + + - name: Exporter and benchmark aggregation tests + run: | + PYTHONPATH=scripts:scripts/benchmarks python -m unittest \ + scripts/test_mathlib_graph.py scripts/benchmarks/test_datasets.py \ + scripts/benchmarks/test_summarize.py + + - name: CLI help and version + run: | + cargo run --locked -- --help + cargo run --locked -- --version - name: headless example - run: cargo run -p hyper-viz --example project_scene + run: cargo run --locked -p hyper-viz --example project_scene diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..be94c35 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,52 @@ +# Release notes + +## 0.2.0 — unreleased + +Rust workspace crates, the `hyper` CLI, `hypergraph-viz`, and the +`hypergraph-viz-viewer` companion share version `0.2.0`. The Python `[viewer]` +extra requires the matching companion. This minor version marks new layout and +exploration features and Rust API changes while the project remains pre-1.0. + +### Features + +- Connectivity-based spatial initialization, Normalized and LinLog force models, + separate pair/set weights, degree/arity normalization, derived-set influence, + movement limits, display fading, structural rebuild, and full-graph framing. +- An optional native dependency canvas (`--view dependencies`) with validated + import direction, independent import/dependent traversal, filters, shortest + paths, exact module search, expansion, and bounded navigation history. +- Rust dependency-query APIs and source attributes retained in projected scenes. +- Clean camera tours with structural layout and wall-clock navigation capture; + timestamped video export preserves navigation speed when captures are dropped, + with adjustable hull opacity and bounded group outlines for dense tours. +- Expanded API, architecture, viewer, migration, and contributor documentation. + +### Fixes + +- Scene entities refresh when input reloads in dependency mode, preventing stale + indices on return to Spatial. Newly created nodes stay hidden in Dependencies. +- Explicit HIF input formats continue through CLI startup and watched reloads + when a view mode is selected. + +### Compatibility and migration + +- `LayoutConfig` struct literals must provide `topology` or use + `..Default::default()`. Construct `ForceLayout3D` through its constructors; + its structural connectivity cache is private. +- Manual `SceneMeta`, `SceneNode`, and `SceneHyperedge` literals require `attrs`. + `SceneHyperedge::hub_index` is optional: only bipartite projections have hubs. + See the [Rust API migration guide](docs/API.md#scene-migration). +- Manual `ShowcaseConfig` literals require `clean`, `realtime`, `structural`, + `hull_opacity`, and `hull_outlines`. Use `false` for these booleans and `None` + for opacity to retain the original annotated offline tour. +- Headless layout defaults retain Legacy forces, and Auto view remains Spatial. + Native `hypergraph.v1` and session `hyperviz.session.v1` formats remain supported. +- Python imports, HIF interchange, viewer launching, and CPython 3.10+ support + retain their existing interfaces. These layout controls are available through + Rust and the native viewer; this release adds no Python layout API or notebook + embedding. + +The published `0.1.2` wheels predate these features. Build this checkout until +`0.2.0` is published. Package publication requires a separately created +`python-v0.2.0` tag on a validated commit; see the [release guide](docs/PYTHON_RELEASE.md). +Rust crates retain `publish = false` pending a separate crates.io release decision. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b1a0c2c..75a63f2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,9 +9,9 @@ This repository is **visualization only**: | In scope | Out of scope | |---|---| -| `hypergraph.v1` schema, scene IR, projections | Memory engines / claim pipelines | -| Headless 3D force layout | Proprietary host business logic | -| Optional Bevy + egui viewer | Publishing crates without a release decision | +| Native/HIF interchange, scene IR, projections | Memory engines / claim pipelines | +| Headless layout, dependency queries | Proprietary host business logic | +| Optional Bevy viewer, Python bindings and launcher | Publishing packages without a release decision | Do not add Atomic Memory Core types, private service URLs, secrets, or claim- pipeline code here. Host apps own those concerns and should feed this library @@ -21,12 +21,21 @@ plain vertices + hyperedges (or `hypergraph.v1` JSON). Requirements: Rust **1.89+** (see `rust-toolchain.toml`; Bevy 0.18 MSRV), a GPU/windowing stack for the native viewer. +Workspace-wide clippy and documentation also build the Python extension and +require **CPython 3.10+** on PATH. When your system Python is older, activate a +supported virtual environment first or set `PYO3_PYTHON=/path/to/python3.12`. ```bash -cargo test -p hyper-viz -cargo test -p hyper-viz-bevy --lib -cargo clippy -p hyper-viz -p hyper-viz-bevy --all-targets -- -D warnings +cargo test --locked -p hyper-viz +cargo test --locked -p hyper-viz-bevy --lib +cargo clippy --locked --workspace --all-targets -- -D warnings cargo fmt --all -- --check +RUSTDOCFLAGS="-D warnings" cargo doc --locked --workspace --no-deps + +# Standard-library exporter and benchmark aggregation checks +PYTHONPATH=scripts:scripts/benchmarks python3 -m unittest \ + scripts/test_mathlib_graph.py scripts/benchmarks/test_datasets.py \ + scripts/benchmarks/test_summarize.py # Headless example cargo run -p hyper-viz --example project_scene @@ -35,6 +44,28 @@ cargo run -p hyper-viz --example project_scene cargo run -- fixtures/sample.json ``` +For Python development, use CPython 3.10+ and a virtual environment: + +```bash +python -m venv .venv +. .venv/bin/activate # Windows PowerShell: .venv\Scripts\Activate.ps1 +python -m pip install maturin==1.9.6 -r crates/hyper-viz-python/requirements-test.txt +maturin develop --locked --manifest-path crates/hyper-viz-python/Cargo.toml +cargo run --locked -p hyper-viz-python --bin stub_gen +git diff --exit-code -- crates/hyper-viz-python/python/hyper_viz/_core.pyi +python -m pytest crates/hyper-viz-python/tests -q +python -m mypy --strict crates/hyper-viz-python/python/hyper_viz +python crates/hyper-viz-python/examples/xgi_interop.py +``` + +The launcher tests use a substitute executable and can run without a display. +Desktop wheel builds and installed-package integration checks run in +[the Python workflow](.github/workflows/python.yml) on all supported platforms. +See the [release guide](docs/PYTHON_RELEASE.md) for package contents and source +distribution checks. Compile documentation examples when changing public APIs, +and update [API](docs/API.md), [architecture](docs/ARCHITECTURE.md), and the +relevant user guide when behavior changes. + ## Pull requests - Prefer small, focused diffs with a clear Conventional Commit subject. diff --git a/Cargo.lock b/Cargo.lock index b75b36b..ede9aa0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3164,7 +3164,7 @@ checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" [[package]] name = "hyper" -version = "0.1.2" +version = "0.2.0" dependencies = [ "clap", "hyper-viz", @@ -3175,7 +3175,7 @@ dependencies = [ [[package]] name = "hyper-viz" -version = "0.1.2" +version = "0.2.0" dependencies = [ "jsonschema", "num-bigint", @@ -3189,7 +3189,7 @@ dependencies = [ [[package]] name = "hyper-viz-bevy" -version = "0.1.2" +version = "0.2.0" dependencies = [ "bevy", "bevy_egui", @@ -3205,7 +3205,7 @@ dependencies = [ [[package]] name = "hyper-viz-python" -version = "0.1.2" +version = "0.2.0" dependencies = [ "hyper-viz", "pyo3", diff --git a/Cargo.toml b/Cargo.toml index 89dd14a..f5015f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/hyper-viz", "crates/hyper-viz-bevy", "crates/hyper-viz-python"] [workspace.package] -version = "0.1.2" +version = "0.2.0" edition = "2024" license = "MIT OR Apache-2.0" authors = ["Bregy Malpartida "] diff --git a/README.md b/README.md index 143e1e4..653e11c 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ **A native 3D hypergraph viewer for Python workflows and Rust applications.** + + Realtime navigation through the Mathlib dependency case study + + A hypergraph represents a group relationship as one edge connecting any number of nodes: authors on a paper, participants in an interaction, or modules in a dependency group. Hyper lets you inspect those memberships, find overlapping @@ -10,12 +14,6 @@ groups, and explore neighborhoods with search, picking, and lasso selection. [Try the viewer](#try-the-viewer) · [Use Python](#use-from-python) · [Use Rust](#use-from-rust) · [Documentation](#documentation) -[![Mathlib module dependency groups in Hyper's native 3D viewer](docs/media/mathlib.gif)](docs/media/mathlib.mp4) - -[Full-resolution Mathlib demo](docs/media/mathlib.mp4) · -[Reproduce the demo](docs/mathlib-demo.md). -The recording uses a settled layout; it is not a live-layout performance test. - ## Try the viewer Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then open @@ -154,6 +152,21 @@ Notebook embedding, live Python updates, and direct scientific-library object adapters are not yet available; see the [notebook roadmap](docs/NOTEBOOK_ROADMAP.md) for browser plans. +## Layout and exploration + +Choose bipartite, clique, or star projection to inspect the same memberships +from different views. Search, picking, lasso selection, and neighborhood focus +help you explore individual nodes and overlapping groups; display settings and +saved sessions keep the view manageable. + +The upcoming `0.2.0` release adds connectivity-based initialization and tunable +Legacy, Normalized, and LinLog layouts. An optional dependency canvas explores +graphs with validated import-direction attributes through bounded traversal, +filters, and shortest paths. See the [viewer guide](docs/VIEWER.md), +[Rust layout and query APIs](docs/API.md), and [release notes](CHANGELOG.md). +Build this checkout to use these additions until `0.2.0` is published; +published `0.1.2` wheels predate them. + ## Use from Rust The `hyper-viz` core handles JSON I/O, projections, and 3D force layout without @@ -209,6 +222,25 @@ See the [viewer guide](docs/VIEWER.md) for more commands, and the +## Use cases and benchmarks + +Hyper works with group relationships such as coauthorship, biochemical +reactions, collaboration networks, and software dependencies. The Python +quick start above demonstrates overlapping coauthor groups. + +One larger case study explores **Mathlib module imports** and their dependency +groups. It provides a reproducible dataset and a workload for evaluating layout +and rendering behavior. + + +[Case study and reproduction](docs/mathlib-demo.md) · +[Full-resolution recording](docs/media/mathlib-realtime.mp4) · +[Layout comparisons and native measurements](docs/benchmarks/module-explorer.md). +The recording follows realtime camera motion through a settled layout, with +capture overhead included; it is not a live-layout performance test. +The [ecosystem benchmarks](docs/benchmarks/ecosystem-2026-10-03/README.md) +cover additional workloads, methods, and limitations. + ## Documentation | Guide | What you'll find | diff --git a/crates/hyper-viz-bevy/Cargo.toml b/crates/hyper-viz-bevy/Cargo.toml index c88cc41..b98e66b 100644 --- a/crates/hyper-viz-bevy/Cargo.toml +++ b/crates/hyper-viz-bevy/Cargo.toml @@ -28,7 +28,7 @@ hyper-viz = { workspace = true } bevy = { version = "0.18", default-features = false, features = [ "default_app", "2d_bevy_render", "3d_bevy_render", "ui_bevy_render", "scene", "picking", "std", "android-game-activity", "android_shared_stdcxx", "bevy_winit", - "default_font", "multi_threaded", "webgl2", "x11", "wayland", "sysinfo_plugin", + "default_font", "multi_threaded", "webgl2", "x11", "wayland", "sysinfo_plugin", "bmp", ] } bevy_egui = "0.39" bevy_panorbit_camera = { version = "0.34", features = ["bevy_egui"] } diff --git a/crates/hyper-viz-bevy/README.md b/crates/hyper-viz-bevy/README.md index eba1f7b..a44e1eb 100644 --- a/crates/hyper-viz-bevy/README.md +++ b/crates/hyper-viz-bevy/README.md @@ -76,3 +76,14 @@ On quit (and ~450ms after the last change) the viewer writes selection, focus neighborhood, and find. Desktop path is the OS config dir (`hyper-viz/session.json`); wasm uses `localStorage`. `HYPER_VIZ_SESSION` overrides the file or `off` disables it. + +Auto mode preserves the spatial viewer. The optional native egui dependency +canvas requires `.with_view_mode(ViewMode::Dependencies)` or `--view dependencies`. +It provides exact module selection, directional expansion, filters, shortest paths, +a clipped layered canvas, full neighbor lists, and history. Use `.with_view_mode(ViewMode::Spatial)` +on the plugin to keep a host in Spatial, or configure a standalone app through +`VisualizerConfig::with_module` and `visualizer_app_with_config`. The dependency +view pauses spatial work; returning restores the previous simulation state. + +See [Mathlib exploration](../../docs/mathlib-demo.md#explore-imports-and-dependents) +and `examples/module_benchmark.rs` for normal-canvas measurements. diff --git a/crates/hyper-viz-bevy/examples/module_benchmark.rs b/crates/hyper-viz-bevy/examples/module_benchmark.rs new file mode 100644 index 0000000..0693c93 --- /dev/null +++ b/crates/hyper-viz-bevy/examples/module_benchmark.rs @@ -0,0 +1,88 @@ +//! Finite measurement of the normal native dependency canvas (no tour systems). +//! HYPER_VIZ_SESSION=off cargo run --release -p hyper-viz-bevy --example module_benchmark -- GRAPH MODULE OUTPUT +use bevy::{ + prelude::*, + render::view::screenshot::{Screenshot, save_to_disk}, +}; +use hyper_viz::{Projection, load_json}; +use hyper_viz_bevy::{ViewMode, VisualizerConfig, visualizer_app_with_config}; +use std::{path::PathBuf, time::Instant}; +#[derive(Resource)] +struct Measurement { + frame: usize, + last: Instant, + samples: Vec, + output: PathBuf, +} +fn measure(mut commands: Commands, mut m: ResMut, mut exits: MessageWriter) { + let now = Instant::now(); + let ms = now.duration_since(m.last).as_secs_f64() * 1000.; + m.last = now; + m.frame += 1; + if m.frame > 60 { + m.samples.push(ms); + } + if m.frame == 90 { + commands + .spawn(Screenshot::primary_window()) + .observe(save_to_disk(m.output.join("canvas.png"))); + } + if m.frame == 240 { + let csv = format!( + "frame,milliseconds\n{}", + m.samples + .iter() + .enumerate() + .map(|(i, ms)| format!("{i},{ms:.3}\n")) + .collect::() + ); + std::fs::write(m.output.join("frames.csv"), csv).unwrap(); + m.samples.sort_by(f64::total_cmp); + let n = m.samples.len(); + println!( + "Native canvas: median {:.3} ms, p95 {:.3} ms, {} measured frames", + m.samples[n / 2], + m.samples[(n * 95 / 100).min(n - 1)], + n + ); + exits.write(AppExit::Success); + } +} +fn main() { + let args: Vec<_> = std::env::args().skip(1).collect(); + assert!( + (3..=4).contains(&args.len()), + "usage: module_benchmark GRAPH MODULE OUTPUT [auto|spatial|dependencies]" + ); + let output = PathBuf::from(&args[2]); + std::fs::create_dir_all(&output).unwrap(); + let scene = load_json(&args[0]) + .unwrap() + .project(Projection::StarCentroid); + let config = VisualizerConfig::new("Module explorer validation") + .with_view_mode(match args.get(3).map(String::as_str) { + Some("auto") => ViewMode::Auto, + Some("spatial") => ViewMode::Spatial, + _ => ViewMode::Dependencies, + }) + .with_module(&args[1]) + .with_size(1920, 1080); + let mut app = visualizer_app_with_config(scene, config); + for mut window in app + .world_mut() + .query::<&mut Window>() + .iter_mut(app.world_mut()) + { + window.resolution = + bevy::window::WindowResolution::new(1920, 1080).with_scale_factor_override(1.0); + } + app.insert_resource(bevy::winit::WinitSettings::continuous()) + .insert_resource(Measurement { + frame: 0, + last: Instant::now(), + samples: Vec::new(), + output, + }) + .add_systems(Last, measure) + .run(); +} diff --git a/crates/hyper-viz-bevy/src/animation.rs b/crates/hyper-viz-bevy/src/animation.rs index 44e370d..c079c6d 100644 --- a/crates/hyper-viz-bevy/src/animation.rs +++ b/crates/hyper-viz-bevy/src/animation.rs @@ -20,7 +20,9 @@ impl Plugin for StatusAnimationPlugin { app.init_resource::().add_systems( Update, ( - detect_status_changes.run_if(resource_exists::), + detect_status_changes + .run_if(crate::explorer_state::spatial_mode) + .run_if(resource_exists::), tick_status_bursts, ) .chain(), @@ -66,7 +68,7 @@ pub fn node_motion( let raw = scene .hyperedges .iter() - .find(|he| he.hub_index == index) + .find(|he| he.hub_index == Some(index)) .map(|he| he.status.as_str()) .unwrap_or(""); let key_id = node.hyperedge_id.as_deref().unwrap_or(&node.id); diff --git a/crates/hyper-viz-bevy/src/camera.rs b/crates/hyper-viz-bevy/src/camera.rs index 46eee28..de9e73f 100644 --- a/crates/hyper-viz-bevy/src/camera.rs +++ b/crates/hyper-viz-bevy/src/camera.rs @@ -25,9 +25,13 @@ impl Plugin for CameraPlugin { .add_systems( Update, ( - release_text_focus_for_orbit, - auto_fit_camera.run_if(resource_exists::), - apply_frame_request.run_if(resource_exists::), + release_text_focus_for_orbit.run_if(crate::explorer_state::spatial_mode), + auto_fit_camera + .run_if(resource_exists::) + .run_if(crate::explorer_state::spatial_mode), + apply_frame_request + .run_if(resource_exists::) + .run_if(crate::explorer_state::spatial_mode), ), ); } @@ -91,7 +95,7 @@ fn auto_fit_camera( *fitted_epoch = Some(epoch.0); return; } - if layout.iterations() < 20 { + if layout.running && layout.iterations() < 20 { return; } // Fit once. Live reloads used to re-frame every epoch and yank the camera. @@ -176,3 +180,34 @@ fn apply_camera_fit(cam_q: &mut Query<&mut PanOrbitCamera>, positions: &[Vec3]) cam.force_update = true; true } + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn paused_initial_layout_frames_without_waiting_for_simulation_steps() { + let scene = hyper_viz::Hypergraph::new() + .vertex("a", "a", "module") + .vertex("b", "b", "module") + .project(hyper_viz::Projection::StarCentroid); + let mut layout = GraphLayout::from_scene(scene, &crate::graph::LayoutSettings::default()); + layout.running = false; + layout.layout.positions = vec![ + hyper_viz::Vec3::new(-1000., 0., 0.), + hyper_viz::Vec3::new(1000., 0., 0.), + ]; + let mut app = App::new(); + app.insert_resource(layout) + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems(Update, auto_fit_camera); + let camera = app.world_mut().spawn(PanOrbitCamera::default()).id(); + app.update(); + let orbit = app.world().get::(camera).unwrap(); + let (_, expected_radius) = + camera_fit(&[Vec3::new(-1000., 0., 0.), Vec3::new(1000., 0., 0.)]).unwrap(); + assert_eq!(orbit.target_radius, expected_radius); + } +} diff --git a/crates/hyper-viz-bevy/src/explorer_canvas.rs b/crates/hyper-viz-bevy/src/explorer_canvas.rs new file mode 100644 index 0000000..08d8b27 --- /dev/null +++ b/crates/hyper-viz-bevy/src/explorer_canvas.rs @@ -0,0 +1,289 @@ +use crate::explorer_state::ExplorerState; +use bevy_egui::egui; +use hyper_viz::{DependencyIndex, DependencyScope, HypergraphScene}; +use std::collections::{BTreeMap, HashMap}; + +pub struct CanvasNode { + pub index: usize, + pub position: [f32; 2], +} +pub fn layered_positions( + index: &DependencyIndex, + scope: &DependencyScope, + canonical: &DependencyScope, +) -> Vec { + let Some(center) = scope.center else { + return Vec::new(); + }; + let mut columns: BTreeMap> = BTreeMap::new(); + for &node in canonical + .import_depths + .keys() + .chain(canonical.dependent_depths.keys()) + { + let column = if node == center { + 0 + } else if let Some(depth) = canonical.dependent_depths.get(&node) { + -(*depth as i32) + } else { + canonical.import_depths.get(&node).copied().unwrap_or(1) as i32 + }; + columns.entry(column).or_default().push(node); + } + let mut positions = HashMap::new(); + for (column, mut nodes) in columns { + nodes.sort_by(|a, b| index.id(*a).cmp(&index.id(*b))); + nodes.dedup(); + for (row, node) in nodes.iter().enumerate() { + positions.insert(*node, [column as f32 * 340., row as f32 * 44.]); + } + } + scope + .nodes + .iter() + .map(|i| CanvasNode { + index: *i, + position: positions.get(i).copied().unwrap_or([0., 0.]), + }) + .collect() +} + +pub fn canvas(ui: &mut egui::Ui, state: &mut ExplorerState, scene: &HypergraphScene) { + let nodes = layered_positions(&state.index, &state.scope, &state.canonical); + let (response, painter) = ui.allocate_painter(ui.available_size(), egui::Sense::drag()); + let rect = response.rect; + if state.fit_requested && !nodes.is_empty() { + let min = nodes + .iter() + .fold(egui::vec2(f32::INFINITY, f32::INFINITY), |v, n| { + egui::vec2(v.x.min(n.position[0]), v.y.min(n.position[1])) + }); + let max = nodes + .iter() + .fold(egui::vec2(f32::NEG_INFINITY, f32::NEG_INFINITY), |v, n| { + egui::vec2(v.x.max(n.position[0]), v.y.max(n.position[1])) + }); + let size = max - min; + state.current.zoom = ((rect.width() - 300.) / size.x.max(1.)) + .min((rect.height() - 120.) / size.y.max(1.)) + .clamp(0.1, 1.); + let midpoint = (min + max) * 0.5; + state.current.pan = [ + -midpoint.x * state.current.zoom, + -midpoint.y * state.current.zoom, + ]; + state.fit_requested = false; + } + + if response.dragged() { + let d = response.drag_delta(); + state.current.pan[0] += d.x; + state.current.pan[1] += d.y; + } + if response.hovered() { + let scroll = ui.input(|i| i.smooth_scroll_delta.y); + if scroll != 0. { + state.current.zoom = (state.current.zoom * (scroll * 0.002).exp()).clamp(0.1, 4.); + } + } + let zoom = state.current.zoom; + let origin = rect.center() + egui::vec2(state.current.pan[0], state.current.pan[1]); + let coords: HashMap<_, _> = nodes + .iter() + .map(|n| { + ( + n.index, + origin + egui::vec2(n.position[0], n.position[1]) * zoom, + ) + }) + .collect(); + for &(source, target) in &state.scope.edges { + let (Some(&a), Some(&b)) = (coords.get(&source), coords.get(&target)) else { + continue; + }; + if a == b { + continue; + } + let delta = b - a; + let unit = delta.normalized(); + let start = a + unit * 18. * zoom; + let end = b - unit * 18. * zoom; + let highlighted = state + .path + .as_ref() + .is_some_and(|p| p.windows(2).any(|pair| pair == [source, target])); + let color = if highlighted { + egui::Color32::YELLOW + } else { + egui::Color32::from_rgb(100, 135, 175) + }; + let stroke = egui::Stroke::new(if highlighted { 2.5 } else { 1. }, color); + painter.line_segment([start, end], stroke); + let head = 8. * zoom.clamp(0.6, 1.2); + let perpendicular = egui::vec2(-unit.y, unit.x); + painter.line_segment( + [end, end - unit * head + perpendicular * head * 0.45], + stroke, + ); + painter.line_segment( + [end, end - unit * head - perpendicular * head * 0.45], + stroke, + ); + } + let mut select = None; + for node in nodes { + let p = coords[&node.index]; + let center = Some(node.index) == state.scope.center; + let category = state.categories[node.index]; + let color = if center { + egui::Color32::from_rgb(255, 193, 80) + } else if category.external { + egui::Color32::from_rgb(175, 145, 225) + } else if category.test { + egui::Color32::from_rgb(240, 145, 155) + } else if category.tactic { + egui::Color32::from_rgb(100, 210, 150) + } else if category.umbrella { + egui::Color32::from_rgb(225, 160, 85) + } else { + egui::Color32::from_rgb(115, 175, 235) + }; + let hit = egui::Rect::from_center_size( + p, + if zoom >= 0.55 { + egui::vec2(240., 38.) * zoom + } else { + egui::vec2(8., 8.) + }, + ); + if !rect.intersects(hit) { + continue; + } + painter.circle_filled(p, if center { 10. } else { (6. * zoom).max(2.) }, color); + let id = &scene.nodes[node.index].id; + let short = id + .rsplit('.') + .take(2) + .collect::>() + .into_iter() + .rev() + .collect::>() + .join("."); + if zoom >= 0.55 || center { + let label_pos = p + egui::vec2(12., 0.); + let galley = painter.layout_no_wrap( + short, + egui::FontId::proportional(if center { + 13. + } else { + 13. * zoom.clamp(0.7, 1.5) + }), + color, + ); + let label_pos = label_pos - egui::vec2(0., galley.size().y / 2.); + painter.rect_filled( + egui::Rect::from_min_size(label_pos, galley.size()).expand(2.), + 2., + ui.visuals().panel_fill, + ); + painter.galley(label_pos, galley, color); + } + let both = node.index != state.scope.center.unwrap_or(usize::MAX) + && state.scope.import_depths.contains_key(&node.index) + && state.scope.dependent_depths.contains_key(&node.index); + let tooltip = format!( + "{}{}\n{}", + id, + if both { " (import and dependent)" } else { "" }, + scene.nodes[node.index] + .attrs + .get("path") + .and_then(|v| v.as_str()) + .unwrap_or("") + ); + if ui + .interact( + hit.intersect(rect), + ui.id().with(node.index), + egui::Sense::click(), + ) + .on_hover_text(tooltip) + .clicked() + { + select = Some(id.clone()); + } + } + if let Some(id) = select { + state.select_module(&id); + } + painter.text( + rect.left_top() + egui::vec2(16., 16.), + egui::Align2::LEFT_TOP, + "Dependents <- Center -> Imports\nArrows: importer -> imported · Drag to pan · Scroll to zoom · Zoom in for labels; hover for full names", + egui::FontId::proportional(13.), + egui::Color32::GRAY, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn dual_role_nodes_once_and_retained_rows_stable() { + let scope = DependencyScope { + center: Some(0), + nodes: vec![0, 1, 2], + import_depths: HashMap::from([(0, 0), (1, 1), (2, 1)]), + dependent_depths: HashMap::from([(0, 0), (1, 1)]), + ..Default::default() + }; + let index = DependencyIndex::default(); + let all = layered_positions(&index, &scope, &scope); + assert_eq!(all.len(), 3); + assert!(all[1].position[0] < 0.); + let mut small = scope.clone(); + small.nodes = vec![0, 2]; + let visible = layered_positions(&index, &small, &scope); + assert_eq!(visible[1].position, all[2].position); + assert_eq!(all[0].position, [0., 0.]); + } + #[test] + fn rows_survive_filtering_and_scene_reordering() { + use hyper_viz::{Hyperedge, Hypergraph, Projection, ScopeOptions}; + let mut graph = Hypergraph::new() + .vertex("a", "a", "module") + .vertex("b", "b", "module") + .vertex("c", "c", "module"); + for target in ["b", "c"] { + let mut edge = Hyperedge::new(target, ["a", target]).with_kind("import"); + edge.attrs.insert("source".into(), "a".into()); + edge.attrs.insert("target".into(), target.into()); + graph.add_hyperedge(edge); + } + let positions = |scene: &HypergraphScene, filter: bool| { + let index = DependencyIndex::new(scene); + let center = index.node_index("a").unwrap(); + let canonical = index.scope( + center, + &ScopeOptions::default(), + &vec![true; scene.node_count()], + ); + let mut allowed = vec![true; scene.node_count()]; + if filter { + allowed[index.node_index("b").unwrap()] = false; + } + let scope = index.scope(center, &ScopeOptions::default(), &allowed); + layered_positions(&index, &scope, &canonical) + .into_iter() + .map(|n| (scene.nodes[n.index].id.clone(), n.position)) + .collect::>() + }; + let scene = graph.project(Projection::StarCentroid); + let all = positions(&scene, false); + assert_eq!(all["c"], [340., 44.]); + assert_eq!(positions(&scene, true)["c"], all["c"]); + graph.vertices.reverse(); + let reversed = graph.project(Projection::StarCentroid); + assert_eq!(positions(&reversed, false), all); + } +} diff --git a/crates/hyper-viz-bevy/src/explorer_state.rs b/crates/hyper-viz-bevy/src/explorer_state.rs new file mode 100644 index 0000000..24e57a2 --- /dev/null +++ b/crates/hyper-viz-bevy/src/explorer_state.rs @@ -0,0 +1,415 @@ +use bevy::prelude::*; +use hyper_viz::session::{ExplorerSnapshot, ViewMode}; +use hyper_viz::{ + DependencyIndex, DependencyScope, HypergraphScene, ModuleCategory, module_categories, +}; + +#[derive(Resource, Default)] +pub struct ExplorerState { + pub mode: ViewMode, + pub requested: ViewMode, + pub user_mode: bool, + pub current: ExplorerSnapshot, + pub index: DependencyIndex, + pub categories: Vec, + pub scope: DependencyScope, + pub canonical: DependencyScope, + pub allowed: Vec, + pub path: Option>, + pub path_blocked: bool, + pub epoch: Option, + pub history: Vec, + pub cursor: usize, + corpus: Vec<(usize, String)>, + search_cache: std::collections::HashMap>, + pub group: Option, + pub fit_requested: bool, +} +impl ExplorerState { + pub fn choose_mode(&mut self, mode: ViewMode) { + self.mode = mode; + self.user_mode = true; + } + pub fn replace_scene(&mut self, scene: &HypergraphScene, epoch: u64) { + self.index = DependencyIndex::new(scene); + self.categories = module_categories(scene); + self.corpus = scene + .nodes + .iter() + .enumerate() + .filter(|(_, n)| n.role == hyper_viz::NodeRole::Vertex) + .map(|(i, n)| (i, n.id.to_lowercase())) + .collect(); + self.search_cache.clear(); + self.group = None; + self.epoch = Some(epoch); + if !self.user_mode { + if self.requested == ViewMode::Auto { + self.mode = ViewMode::Spatial; + } else { + self.mode = self.requested; + } + } + self.rebuild(); + } + pub fn select_module(&mut self, id: &str) { + if self.index.node_index(id).is_none() { + return; + } + if let Some(snapshot) = self.history.get_mut(self.cursor) { + *snapshot = self.current.clone(); + } + self.fit_requested = true; + self.current.center = Some(id.to_owned()); + self.current.expanded_ids.clear(); + self.current.pan = [0., 0.]; + self.current.zoom = 1.; + self.group = None; + self.rebuild(); + self.record(); + } + pub fn search(&mut self, query: &str) -> &[usize] { + let query = query.trim().to_lowercase(); + if query.is_empty() { + return &[]; + } + if self.search_cache.contains_key(&query) { + return &self.search_cache[&query]; + } + + let mut candidates: Vec<_> = self + .corpus + .iter() + .filter_map(|(i, id)| { + let rank = if id == &query { + 0 + } else if id.starts_with(&query) { + 1 + } else if id.contains(&query) { + 2 + } else { + let mut letters = query.chars(); + let mut next = letters.next(); + for c in id.chars() { + if next == Some(c) { + next = letters.next(); + } + } + if next.is_some() { + return None; + } + 3 + }; + Some((rank, id, *i)) + }) + .collect(); + candidates.sort(); + let results = candidates.into_iter().take(20).map(|(_, _, i)| i).collect(); + if self.search_cache.len() >= 4 { + self.search_cache.clear(); + } + self.search_cache.insert(query.clone(), results); + &self.search_cache[&query] + } + pub fn snapshot(&self) -> ExplorerSnapshot { + self.current.clone() + } + pub fn restore(&mut self, mut snapshot: ExplorerSnapshot) { + snapshot.normalize(); + self.fit_requested = false; + self.current = snapshot; + self.group = None; + self.rebuild(); + } + pub fn record(&mut self) { + if self.history.get(self.cursor) == Some(&self.current) { + return; + } + if !self.history.is_empty() { + self.history.truncate(self.cursor + 1); + } + self.history.push(self.current.clone()); + if self.history.len() > 32 { + self.history.remove(0); + } + self.cursor = self.history.len() - 1; + } + pub fn back(&mut self) { + if let Some(snapshot) = self.history.get_mut(self.cursor) { + *snapshot = self.current.clone(); + } + if self.cursor > 0 { + self.cursor -= 1; + self.restore(self.history[self.cursor].clone()); + } + } + pub fn forward(&mut self) { + if let Some(snapshot) = self.history.get_mut(self.cursor) { + *snapshot = self.current.clone(); + } + if self.cursor + 1 < self.history.len() { + self.cursor += 1; + self.restore(self.history[self.cursor].clone()); + } + } + pub fn rebuild(&mut self) { + self.current.normalize(); + self.allowed = self + .categories + .iter() + .map(|c| self.current.filters.allows(*c)) + .collect(); + self.scope = DependencyScope::default(); + self.canonical = DependencyScope::default(); + self.path = None; + self.path_blocked = false; + let Some(center) = self + .current + .center + .as_deref() + .and_then(|id| self.index.node_index(id)) + else { + return; + }; + self.allowed[center] = true; + self.scope = self + .index + .scope(center, &self.current.options, &self.allowed); + self.canonical = self.index.scope( + center, + &self.current.options, + &vec![true; self.allowed.len()], + ); + if let Some(target) = self + .current + .target + .as_deref() + .and_then(|id| self.index.node_index(id)) + { + self.path = self.index.shortest_path(center, target, &self.allowed); + self.path_blocked = self.path.is_none() + && self + .index + .shortest_path(center, target, &vec![true; self.allowed.len()]) + .is_some(); + } + if let Some(path) = &self.path { + self.scope = self + .index + .include_path(&self.scope, path, self.current.options.budget); + self.canonical = + self.index + .include_path(&self.canonical, path, self.current.options.budget); + } + for id in &self.current.expanded_ids { + if let Some(node) = self.index.node_index(id) { + self.scope = self.index.expand( + center, + &self.scope, + node, + &self.current.options, + &self.allowed, + ); + self.canonical = self.index.expand( + center, + &self.canonical, + node, + &self.current.options, + &vec![true; self.allowed.len()], + ); + } + } + if let Some(path) = &self.path { + self.scope = self + .index + .include_path(&self.scope, path, self.current.options.budget); + self.canonical = + self.index + .include_path(&self.canonical, path, self.current.options.budget); + } + } + pub fn expand(&mut self, id: String) { + if !self.current.expanded_ids.contains(&id) { + self.current.expanded_ids.push(id); + self.rebuild(); + self.record(); + } + } +} +pub fn spatial_mode(state: Option>) -> bool { + state.is_none_or(|s| s.mode == ViewMode::Spatial) +} + +pub fn sync_explorer( + layout: Option>, + epoch: Res, + mut state: ResMut, + settings: Res, + store: Option>, +) { + let Some(layout) = layout else { + return; + }; + if state.epoch == Some(epoch.0) { + return; + } + let first = state.epoch.is_none(); + state.replace_scene(&layout.scene, epoch.0); + if first + && let Some(store) = store + && store.enabled + && let Some(saved) = store + .session + .views + .get(&hyper_viz::view_key(&layout.scene.meta.id)) + .and_then(|v| v.explorer.as_ref()) + { + state.restore(saved.snapshot.clone()); + state.history = saved.history.iter().rev().take(32).cloned().collect(); + state.history.reverse(); + state.cursor = saved.cursor.min(state.history.len().saturating_sub(1)); + } + if first && let Some(id) = &settings.initial_module { + state.select_module(id); + } +} +#[cfg(test)] +mod tests { + use super::*; + use hyper_viz::{Hyperedge, Hypergraph, Projection}; + fn scene() -> HypergraphScene { + let mut g = Hypergraph::new() + .vertex("a", "a", "module") + .vertex("ab", "ab", "module") + .vertex("c", "c", "module"); + let mut e = Hyperedge::new("a:ab", ["a", "ab"]).with_kind("import"); + e.attrs.insert("source".into(), "a".into()); + e.attrs.insert("target".into(), "ab".into()); + g.add_hyperedge(e); + g.project(Projection::StarCentroid) + } + #[test] + fn imports_stay_spatial_unless_dependency_view_is_explicit() { + let mut state = ExplorerState::default(); + state.replace_scene(&scene(), 0); + assert_eq!(state.mode, ViewMode::Spatial); + state.requested = ViewMode::Dependencies; + state.replace_scene(&scene(), 1); + assert_eq!(state.mode, ViewMode::Dependencies); + } + #[test] + fn exact_selection_history_and_reload_use_stable_ids() { + let mut s = ExplorerState::default(); + s.replace_scene(&scene(), 0); + assert_eq!(s.search("a")[0], 0); + assert!(s.current.center.is_none()); + s.select_module("a"); + assert_eq!(s.scope.nodes, vec![0, 1]); + s.current.filters.tests = false; + s.expand("ab".into()); + s.select_module("c"); + s.back(); + assert_eq!(s.current.center.as_deref(), Some("a")); + assert!(!s.current.filters.tests); + assert_eq!(s.current.expanded_ids, ["ab"]); + s.forward(); + assert_eq!(s.current.center.as_deref(), Some("c")); + let removed = Hypergraph::new() + .vertex("a", "a", "module") + .project(Projection::StarCentroid); + s.replace_scene(&removed, 1); + assert_eq!(s.current.center.as_deref(), Some("c")); + assert!(s.scope.nodes.is_empty()); + } + #[test] + fn snapshot_sanitizes_nonfinite_values_and_bounds() { + let mut snapshot = ExplorerSnapshot { + pan: [f32::NAN, 2.], + zoom: f32::INFINITY, + ..Default::default() + }; + snapshot.options.budget = 5000; + let mut state = ExplorerState::default(); + state.restore(snapshot); + assert_eq!(state.current.pan, [0., 0.]); + assert_eq!(state.current.zoom, 1.); + assert_eq!(state.current.options.budget, 1000); + } + #[test] + fn explicit_mode_survives_a_scene_reload() { + let mut state = ExplorerState::default(); + state.replace_scene(&scene(), 0); + state.choose_mode(ViewMode::Spatial); + state.replace_scene(&scene(), 1); + assert_eq!(state.mode, ViewMode::Spatial); + } + #[test] + fn filtering_an_intermediate_stops_replayed_expansion() { + let mut graph = Hypergraph::new(); + for id in ["A", "MathlibTest.B", "C", "D"] { + graph = graph.vertex(id, id, "module"); + } + for (a, b) in [("A", "MathlibTest.B"), ("MathlibTest.B", "C"), ("C", "D")] { + let mut edge = Hyperedge::new(format!("{a}:{b}"), [a, b]).with_kind("import"); + edge.attrs.insert("source".into(), a.into()); + edge.attrs.insert("target".into(), b.into()); + graph.add_hyperedge(edge); + } + let mut state = ExplorerState::default(); + state.replace_scene(&graph.project(Projection::StarCentroid), 0); + state.select_module("A"); + state.current.options.imports = hyper_viz::TraversalDepth::Hops(2); + state.rebuild(); + state.expand("C".into()); + assert_eq!(state.scope.nodes.len(), 4); + state.current.filters.tests = false; + state.rebuild(); + assert_eq!(state.scope.nodes, [state.index.node_index("A").unwrap()]); + } + #[test] + fn tracing_a_path_adds_steps_beyond_depth_and_discloses_budget_omissions() { + let mut graph = Hypergraph::new(); + for id in ["A", "B", "C", "D"] { + graph = graph.vertex(id, id, "module"); + } + for (a, b) in [("A", "B"), ("B", "C"), ("C", "D")] { + let mut edge = Hyperedge::new(format!("{a}:{b}"), [a, b]).with_kind("import"); + edge.attrs.insert("source".into(), a.into()); + edge.attrs.insert("target".into(), b.into()); + graph.add_hyperedge(edge); + } + let mut state = ExplorerState::default(); + state.replace_scene(&graph.project(Projection::StarCentroid), 0); + state.select_module("A"); + state.current.target = Some("D".into()); + state.rebuild(); + assert_eq!(state.scope.nodes.len(), 4); + assert_eq!(state.path.as_ref().unwrap().len(), 4); + state.current.options.budget = 2; + state.rebuild(); + assert_eq!(state.scope.nodes.len(), 2); + assert_eq!(state.scope.omitted, 2); + assert_eq!(state.path.as_ref().unwrap().len(), 4); + } + #[test] + fn displayed_path_modules_can_be_expanded() { + let mut graph = Hypergraph::new(); + for id in ["A", "B", "C", "D"] { + graph = graph.vertex(id, id, "module"); + } + for (a, b) in [("A", "B"), ("B", "C"), ("C", "D")] { + let mut edge = Hyperedge::new(format!("{a}:{b}"), [a, b]).with_kind("import"); + edge.attrs.insert("source".into(), a.into()); + edge.attrs.insert("target".into(), b.into()); + graph.add_hyperedge(edge); + } + let mut state = ExplorerState::default(); + state.replace_scene(&graph.project(Projection::StarCentroid), 0); + state.select_module("A"); + state.current.target = Some("C".into()); + state.rebuild(); + assert_eq!(state.scope.nodes.len(), 3); + state.expand("C".into()); + assert_eq!(state.scope.nodes.len(), 4); + } +} diff --git a/crates/hyper-viz-bevy/src/explorer_ui.rs b/crates/hyper-viz-bevy/src/explorer_ui.rs new file mode 100644 index 0000000..1b8a969 --- /dev/null +++ b/crates/hyper-viz-bevy/src/explorer_ui.rs @@ -0,0 +1,530 @@ +use crate::{explorer_state::ExplorerState, focus::FocusScope, graph::GraphLayout}; +use bevy::prelude::*; +use bevy_egui::{EguiContexts, egui}; +use bevy_panorbit_camera::PanOrbitCamera; +use hyper_viz::{TraversalDepth, session::ViewMode}; +use std::collections::HashSet; + +fn depth(ui: &mut egui::Ui, label: &str, value: &mut TraversalDepth) { + egui::ComboBox::from_label(label) + .selected_text(match value { + TraversalDepth::Off => "Off".into(), + TraversalDepth::Hops(n) => format!("{n} hop(s)"), + TraversalDepth::Transitive => "Transitive".into(), + }) + .show_ui(ui, |ui| { + ui.selectable_value(value, TraversalDepth::Off, "Off"); + for n in 1..=6 { + ui.selectable_value(value, TraversalDepth::Hops(n), format!("{n} hop(s)")); + } + ui.selectable_value(value, TraversalDepth::Transitive, "Transitive"); + }); +} +pub fn scoped_edges(state: &ExplorerState) -> HashSet { + let nodes: HashSet<_> = state.scope.nodes.iter().copied().collect(); + let mut edges: HashSet<_> = state + .index + .relations() + .iter() + .filter(|r| nodes.contains(&r.source) && nodes.contains(&r.target)) + .flat_map(|r| r.hyperedge_indices.iter().copied()) + .collect(); + edges.extend(state.group); + edges +} +#[allow(clippy::too_many_arguments)] +pub fn explorer_panel( + mut contexts: EguiContexts, + mut state: ResMut, + layout: Res, + mut focus: ResMut, + mut search: Local, + mut target: Local, + mut list: Local, + mut hulls: ResMut, + mut frame: ResMut, +) { + if state.mode != ViewMode::Dependencies + && state.requested != ViewMode::Dependencies + && !state.user_mode + { + return; + } + let Ok(ctx) = contexts.ctx_mut() else { + return; + }; + let previous_mode = state.mode; + egui::TopBottomPanel::top("explorer_mode").show(ctx, |ui| { + ui.horizontal(|ui| { + ui.strong("Module explorer"); + ui.selectable_value(&mut state.mode, ViewMode::Dependencies, "Dependencies"); + ui.selectable_value(&mut state.mode, ViewMode::Spatial, "Spatial"); + }); + }); + if state.mode != previous_mode { + state.user_mode = true; + } + if state.mode != ViewMode::Dependencies { + return; + } + let before = state.snapshot(); + let mut selected = None; + let mut target_selected = None; + egui::SidePanel::left("module_controls") + .default_width(330.) + .min_width(280.) + .show(ctx, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + ui.horizontal(|ui| { + if ui.button("Back").clicked() { state.back(); } + if ui.button("Forward").clicked() { state.forward(); } + }); + selected = module_picker(ui, "Find a module", &mut search, &mut state, &layout.scene); + ui.separator(); + if let Some(center) = state.current.center.clone() { + ui.strong(¢er); + if let Some(i) = state.index.node_index(¢er) { + ui.label(format!("{} direct imports · {} direct dependents", state.index.imports(i).len(), state.index.dependents(i).len())); + if let Some(path) = layout.scene.nodes[i].attrs.get("path").and_then(|v| v.as_str()) { ui.label(path); } + } else { ui.label("This module is absent from the current graph. Choose another module."); } + } else { ui.label("Search and choose one module to begin."); } + depth(ui, "Imports", &mut state.current.options.imports); + depth(ui, "Dependents", &mut state.current.options.dependents); + ui.horizontal(|ui| { + ui.label("Node budget"); + ui.add(egui::DragValue::new(&mut state.current.options.budget).range(1..=1000)); + }); + ui.collapsing("Include categories", |ui| { + ui.checkbox(&mut state.current.filters.umbrella, "Umbrella modules"); + ui.checkbox(&mut state.current.filters.tests, "Tests"); + ui.checkbox(&mut state.current.filters.tactics, "Tactics"); + ui.checkbox(&mut state.current.filters.external, "External modules"); + }); + ui.label(format!("{} shown · {} budget omitted · {} excluded by filters", state.scope.nodes.len(), state.scope.omitted, state.canonical.total_reachable.saturating_sub(state.scope.total_reachable))); + ui.small("Filters stop traversal; the selected module is always retained."); + ui.horizontal(|ui| { + if ui.button("Reset expansion").clicked() { state.current.expanded_ids.clear(); } + if ui.button("Fit").clicked() { state.fit_requested = true; } + }); + ui.checkbox(&mut list, "List view"); + if ui.button("Inspect displayed scope in Spatial").clicked() { + focus.nodes = Some(state.scope.nodes.iter().copied().collect()); + focus.hyperedges = Some(scoped_edges(&state)); + if state.group.is_some() { hulls.enabled = true; } + frame.pending = true; + state.choose_mode(ViewMode::Spatial); + } + ui.separator(); + target_selected = module_picker(ui, "Shortest import path to", &mut target, &mut state, &layout.scene); + if let Some(target) = &state.current.target { + ui.label(format!("Target: {target}")); + if state.index.node_index(target).is_none() { ui.label("The target is absent from the current graph."); } + } + if let Some(path) = &state.path { + ui.label(format!("{} import hop(s)", path.len().saturating_sub(1))); + let omitted=path.iter().filter(|i|!state.scope.nodes.contains(i)).count(); + if omitted>0 {ui.label(format!("{omitted} path modules omitted from canvas by node budget; full path below."));} + for &i in path { ui.label(&layout.scene.nodes[i].id); } + } else if state.current.target.is_some() { + ui.label(if state.path_blocked { "A path exists, but current filters block it." } else { "No directed import path exists." }); + } + if let Some(center) = state.current.center.as_deref().and_then(|id| state.index.node_index(id)) { + for (label, neighbors) in [("Direct imports", state.index.imports(center).to_vec()), ("Direct dependents", state.index.dependents(center).to_vec())] { + ui.collapsing(format!("{label} ({})", neighbors.len()), |ui| { + for i in neighbors { + ui.horizontal(|ui| { + if ui.selectable_label(false, &layout.scene.nodes[i].id).clicked() { selected = Some(layout.scene.nodes[i].id.clone()); } + if ui.small_button("+1").on_hover_text("Expand this module by one hop").clicked() { state.expand(layout.scene.nodes[i].id.clone()); } + }); + } + }); + } + let center_id = &layout.scene.nodes[center].id; + let exported_id = format!("deps:{center_id}"); + let group = layout.scene.hyperedges.iter().enumerate().find(|(_, e)| e.kind == "dependency_group" && (e.attrs.get("source").and_then(|v| v.as_str()) == Some(center_id) || e.id == exported_id)); + if let Some((i, group)) = group { + ui.collapsing(format!("Exported dependency group ({})", group.member_indices.len()), |ui| { + let mut include = state.group == Some(i); + if ui.checkbox(&mut include, "Include this group in Spatial").changed() { state.group = if include { Some(i) } else { None }; } + if group.member_indices.iter().any(|i| !state.scope.nodes.contains(i)) { ui.small("Its hull appears when all members are in the displayed scope. The full member list follows."); } + for &member in &group.member_indices { ui.label(&layout.scene.nodes[member].id); } + }); + } + } + ui.separator(); + ui.small("Blue: module · Gold: umbrella / center\nGreen: tactic · Pink: test · Purple: external"); + if !state.index.warnings().is_empty() { ui.label(format!("{} malformed imports excluded from directed queries", state.index.warnings().len())); } + }); + }); + if let Some(id) = selected { + state.select_module(&id); + } + if let Some(id) = target_selected { + state.current.target = Some(id); + } + if before != state.current { + state.rebuild(); + state.record(); + } + egui::CentralPanel::default().show(ctx, |ui| { + if state.scope.nodes.is_empty() { + ui.centered_and_justified(|ui| { + ui.label("Choose a module to inspect its dependencies and dependents."); + }); + } else if *list { + egui::ScrollArea::vertical().show(ui, |ui| { + for i in state.scope.nodes.clone() { + ui.horizontal(|ui| { + if ui.button(&layout.scene.nodes[i].id).clicked() { + state.select_module(&layout.scene.nodes[i].id); + } + if ui.button("Expand +1").clicked() { + state.expand(layout.scene.nodes[i].id.clone()); + } + }); + } + }); + } else { + crate::explorer_canvas::canvas(ui, &mut state, &layout.scene); + } + }); +} +fn module_picker( + ui: &mut egui::Ui, + label: &str, + query: &mut String, + state: &mut ExplorerState, + scene: &hyper_viz::HypergraphScene, +) -> Option { + ui.label(label); + let response = ui.add(egui::TextEdit::singleline(query).id(egui::Id::new(label))); + let results = state.search(query).to_vec(); + let mut chosen = None; + if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + chosen = results.first().map(|i| scene.nodes[*i].id.clone()); + } + if !query.trim().is_empty() { + if results.is_empty() { + ui.label("No matching module."); + } + for i in results { + if ui.selectable_label(false, &scene.nodes[i].id).clicked() { + chosen = Some(scene.nodes[i].id.clone()); + } + } + } + chosen +} + +#[allow(clippy::type_complexity)] +pub fn spatial_visibility( + state: Res, + mut nodes: Query< + &mut Visibility, + Or<( + With, + With, + )>, + >, + mut cameras: Query<&mut PanOrbitCamera>, + lasso: Res, + mut previous: Local>, + mut running: Local, + mut layout: Option>, +) { + if *previous == Some(state.mode) { + return; + } + let spatial = state.mode == ViewMode::Spatial; + if let Some(ref mut layout) = layout { + if !spatial { + *running = layout.running; + layout.running = false; + } else if previous.is_some() { + layout.running = *running; + } + } + if !spatial { + for mut visibility in &mut nodes { + *visibility = Visibility::Hidden; + } + } else { + for mut visibility in &mut nodes { + if *visibility == Visibility::Hidden { + *visibility = Visibility::Inherited; + } + } + } + for mut camera in &mut cameras { + camera.enabled = spatial && !lasso.enabled; + } + *previous = Some(state.mode); +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy_egui::{EguiContext, EguiUserTextures, PrimaryEguiContext}; + use hyper_viz::{Hyperedge, Hypergraph, Projection}; + fn input_frame( + app: &mut App, + ctx: &egui::Context, + events: Vec, + ) -> egui::FullOutput { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1600., 900.), + )), + events, + ..Default::default() + }); + app.update(); + ctx.end_pass() + } + fn type_into(app: &mut App, ctx: &egui::Context, label: &str, text: &str) { + let rect = ctx.read_response(egui::Id::new(label)).unwrap().rect; + let pos = rect.center(); + input_frame( + app, + ctx, + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: Default::default(), + }, + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }, + egui::Event::Text(text.into()), + ], + ); + } + fn enter(app: &mut App, ctx: &egui::Context) { + input_frame( + app, + ctx, + vec![egui::Event::Key { + key: egui::Key::Enter, + physical_key: None, + pressed: true, + repeat: false, + modifiers: Default::default(), + }], + ); + } + #[test] + fn spatial_transfer_contains_only_displayed_imports_and_chosen_group() { + let mut graph = Hypergraph::new(); + for id in ["a", "b", "c", "d"] { + graph = graph.vertex(id, id, "module"); + } + for (a, b) in [("a", "b"), ("b", "c")] { + let mut edge = Hyperedge::new(format!("{a}:{b}"), [a, b]).with_kind("import"); + edge.attrs.insert("source".into(), a.into()); + edge.attrs.insert("target".into(), b.into()); + graph.add_hyperedge(edge); + } + graph = graph.hyperedge("deps:a", ["a", "b"], "deps").hyperedge( + "global", + ["a", "b", "c", "d"], + "global", + ); + let mut state = ExplorerState::default(); + state.replace_scene(&graph.project(Projection::StarCentroid), 0); + state.select_module("a"); + assert_eq!(state.scope.nodes, [0, 1]); + assert_eq!(scoped_edges(&state), HashSet::from([0])); + state.group = Some(2); + assert_eq!(scoped_edges(&state), HashSet::from([0, 2])); + } + #[test] + fn switching_modes_restores_running_state_and_hides_spatial_entities() { + for running in [true, false] { + let scene = Hypergraph::new() + .vertex("a", "a", "module") + .project(Projection::StarCentroid); + let mut layout = + GraphLayout::from_scene(scene, &crate::graph::LayoutSettings::default()); + layout.running = running; + let mut state = ExplorerState::default(); + state.choose_mode(ViewMode::Dependencies); + let mut app = App::new(); + app.insert_resource(layout) + .insert_resource(state) + .init_resource::() + .add_systems(Update, spatial_visibility); + let node = app + .world_mut() + .spawn(( + crate::render::SceneNodeEntity { index: 0 }, + Visibility::Inherited, + )) + .id(); + app.update(); + assert!(!app.world().resource::().running); + assert_eq!( + *app.world().get::(node).unwrap(), + Visibility::Hidden + ); + app.world_mut() + .resource_mut::() + .choose_mode(ViewMode::Spatial); + app.update(); + assert_eq!(app.world().resource::().running, running); + assert_eq!( + *app.world().get::(node).unwrap(), + Visibility::Inherited + ); + } + } + + #[test] + fn explicitly_opened_explorer_can_return_from_spatial() { + let scene = Hypergraph::new() + .vertex("a", "a", "module") + .project(Projection::StarCentroid); + let mut state = ExplorerState::default(); + state.replace_scene(&scene, 0); + let mut app = App::new(); + app.insert_resource(state) + .insert_resource(GraphLayout::from_scene( + scene, + &crate::graph::LayoutSettings::default(), + )) + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems(Update, explorer_panel); + let entity = app + .world_mut() + .spawn((EguiContext::default(), PrimaryEguiContext)) + .id(); + let ctx = app + .world_mut() + .get_mut::(entity) + .unwrap() + .get_mut() + .clone(); + let default_output = input_frame(&mut app, &ctx, Vec::new()); + assert!( + default_output.shapes.is_empty(), + "Default Spatial has no explorer toolbar" + ); + app.world_mut().resource_mut::().requested = ViewMode::Dependencies; + app.world_mut() + .resource_mut::() + .choose_mode(ViewMode::Spatial); + let output = input_frame(&mut app, &ctx, Vec::new()); + let pos = output + .shapes + .iter() + .find_map(|shape| { + if let egui::Shape::Text(text) = &shape.shape + && text.galley.text() == "Dependencies" + { + Some(text.pos + text.galley.rect.center().to_vec2()) + } else { + None + } + }) + .expect("Optional explorer retains the route back from Spatial"); + input_frame( + &mut app, + &ctx, + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: Default::default(), + }, + ], + ); + input_frame( + &mut app, + &ctx, + vec![egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }], + ); + assert_eq!( + app.world().resource::().mode, + ViewMode::Dependencies + ); + } + + #[test] + fn actual_panel_selects_one_exact_module_and_traces_beyond_depth() { + let mut graph = Hypergraph::new() + .vertex("a", "a", "module") + .vertex("ab", "ab", "module") + .vertex("c", "c", "module"); + for (a, b) in [("a", "ab"), ("ab", "c")] { + let mut e = Hyperedge::new(format!("{a}:{b}"), [a, b]).with_kind("import"); + e.attrs.insert("source".into(), a.into()); + e.attrs.insert("target".into(), b.into()); + graph.add_hyperedge(e); + } + let scene = graph.project(Projection::StarCentroid); + let mut state = ExplorerState::default(); + state.requested = ViewMode::Dependencies; + state.replace_scene(&scene, 0); + let mut app = App::new(); + app.insert_resource(state) + .insert_resource(GraphLayout::from_scene( + scene, + &crate::graph::LayoutSettings::default(), + )) + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems(Update, explorer_panel); + let entity = app + .world_mut() + .spawn((EguiContext::default(), PrimaryEguiContext)) + .id(); + let ctx = app + .world_mut() + .get_mut::(entity) + .unwrap() + .get_mut() + .clone(); + input_frame(&mut app, &ctx, Vec::new()); + type_into(&mut app, &ctx, "Find a module", "a"); + assert!( + app.world() + .resource::() + .current + .center + .is_none() + ); + enter(&mut app, &ctx); + assert_eq!( + app.world() + .resource::() + .current + .center + .as_deref(), + Some("a") + ); + assert_eq!(app.world().resource::().scope.nodes.len(), 2); + type_into(&mut app, &ctx, "Shortest import path to", "c"); + enter(&mut app, &ctx); + let state = app.world().resource::(); + assert_eq!(state.path, Some(vec![0, 1, 2])); + assert_eq!(state.scope.nodes.len(), 3); + } +} diff --git a/crates/hyper-viz-bevy/src/focus.rs b/crates/hyper-viz-bevy/src/focus.rs index 4bf2b7e..a9cb305 100644 --- a/crates/hyper-viz-bevy/src/focus.rs +++ b/crates/hyper-viz-bevy/src/focus.rs @@ -12,9 +12,10 @@ pub struct AttentionMode { pub on: bool, } -#[derive(Resource, Debug, Clone, Default)] +#[derive(Resource, Debug, Clone, Default, PartialEq, Eq)] pub struct FocusScope { pub nodes: Option>, + pub hyperedges: Option>, } impl FocusScope { @@ -28,6 +29,11 @@ impl FocusScope { pub fn clear(&mut self) { self.nodes = None; + self.hyperedges = None; + } + + pub fn contains_hyperedge(&self, scene: &HypergraphScene, index: usize) -> bool { + hyper_viz::hyperedge_in_scope(scene, index, self.nodes.as_ref(), self.hyperedges.as_ref()) } } @@ -86,7 +92,7 @@ pub fn live_work_indices(scene: &HypergraphScene) -> Vec { .collect() } -/// AABB center plus a perspective distance that keeps the box in view. +/// AABB center plus a perspective distance that keeps all supplied points in view. /// Uses Bevy's default 45° vertical FOV (`tan(fov/2) ≈ 0.414`). pub fn camera_fit(positions: &[Vec3]) -> Option<(Vec3, f32)> { if positions.is_empty() { @@ -99,8 +105,10 @@ pub fn camera_fit(positions: &[Vec3]) -> Option<(Vec3, f32)> { max = max.max(*p); } let center = (min + max) * 0.5; - let half_extents = (max - min) * 0.5; - let radius = half_extents.length().max(1.0); + let radius = positions + .iter() + .map(|p| (*p - center).length()) + .fold(1.0_f32, f32::max); let tan_half_fov = 0.414_213_56; // tan(22.5°) let distance = (radius / tan_half_fov * 1.2).max(8.0); Some((center, distance)) @@ -142,7 +150,7 @@ fn focus_seeds( let mut seeds = selection.to_vec(); for he_idx in selected_hyperedges { if let Some(he) = layout.scene.hyperedges.get(*he_idx) { - seeds.push(he.hub_index); + seeds.extend(he.hub_index); seeds.extend(he.member_indices.iter().copied()); } } @@ -161,6 +169,7 @@ pub fn isolate_selection( return; } scope.nodes = Some(neighborhood(&layout.scene, seeds)); + scope.hyperedges = None; } pub fn toggle_focus( @@ -181,6 +190,7 @@ pub fn toggle_focus( return; } scope.nodes = Some(neighborhood(&layout.scene, seeds)); + scope.hyperedges = None; } pub struct FocusPlugin; @@ -205,6 +215,23 @@ mod tests { assert!((radius - 8.0).abs() < 1e-5); } + #[test] + fn camera_fit_does_not_pad_empty_corners_of_a_sparse_box() { + let points = [ + Vec3::X * 100., + -Vec3::X * 100., + Vec3::Y * 100., + -Vec3::Y * 100., + Vec3::Z * 100., + -Vec3::Z * 100., + ]; + let (center, distance) = camera_fit(&points).unwrap(); + assert_eq!(center, Vec3::ZERO); + assert!( + (280.0..310.0).contains(&distance), + "Empty box corners inflated distance to {distance}" + ); + } #[test] fn camera_fit_empty_is_none() { assert!(camera_fit(&[]).is_none()); diff --git a/crates/hyper-viz-bevy/src/graph.rs b/crates/hyper-viz-bevy/src/graph.rs index f224e17..39be6fe 100644 --- a/crates/hyper-viz-bevy/src/graph.rs +++ b/crates/hyper-viz-bevy/src/graph.rs @@ -17,6 +17,10 @@ pub struct GraphLayout { pub scene: HypergraphScene, /// Hyperedge status keyed by hub scene index (avoids O(nodes×edges) scans). pub hub_status: Vec>, + pub last_steps: usize, + pub last_step_ms: f64, + pub positions_revision: u64, + pub initialization_ms: f64, } impl GraphLayout { @@ -49,6 +53,8 @@ pub struct LayoutSettings { pub watch: bool, pub projection: Projection, pub input_format: InputFormat, + pub view_mode: hyper_viz::session::ViewMode, + pub initial_module: Option, } /// Published with each replacement scene so renderers cannot consume a stale layout. @@ -75,6 +81,8 @@ impl Default for LayoutSettings { watch: false, projection: Projection::Bipartite, input_format: InputFormat::Auto, + view_mode: hyper_viz::session::ViewMode::Auto, + initial_module: None, } } } @@ -90,12 +98,19 @@ fn layout_from_path( impl GraphLayout { pub fn from_scene(scene: HypergraphScene, settings: &LayoutSettings) -> Self { - let layout = ForceLayout3D::from_scene(&scene, settings.config.clone()); + let large = + scene.vertices_count() >= 1000 && !hyper_viz::DependencyIndex::new(&scene).is_empty(); + let mut config = settings.config.clone(); + if large { + config.gravity = 0.001; + config.centroid_attraction = 0.0005; + } + let layout = ForceLayout3D::from_scene(&scene, config); let link_count = layout.edges.len(); let node_count = scene.node_count(); let hub_status = hub_status_from_scene(&scene); - Self { + let mut result = Self { layout, node_count, link_count, @@ -103,7 +118,54 @@ impl GraphLayout { iterations_per_frame: settings.iterations_per_frame, scene, hub_status, + last_steps: 0, + last_step_ms: 0., + positions_revision: 0, + initialization_ms: 0., + }; + if large { + seed_neutral(&mut result); + } + result + } + + /// Keep every vertex and hyperedge available in a stable spatial overview. + pub fn full_graph_overview( + &mut self, + settings: &mut LayoutSettings, + hulls: &mut crate::hyperedge_hull::HyperedgeHullSettings, + lines: &mut crate::render::LinkRenderSettings, + ) { + settings.config.gravity = 0.00001; + settings.config.topology = hyper_viz::TopologySettings { + model: hyper_viz::LayoutModel::Normalized, + ..Default::default() + }; + settings.node_size = 8.; + settings.config.centroid_attraction = 0.0005; + settings.config.repulsion = 500.; + settings.config.dt = 0.3; + settings.config.damping = 0.85; + self.layout.config = settings.config.clone(); + self.running = false; + hulls.enabled = true; + hulls.opacity = 0.001; + hulls.wireframe = false; + hulls.hide_hubs = true; + lines.max_lines = 0; + lines.opacity = 0.06; + } + + /// Connectivity initialization plus bounded refinement, independent of labels. + pub fn rebuild_structural(&mut self) { + let start = std::time::Instant::now(); + self.layout.seed_from_topology(&self.scene, 256); + for _ in 0..64 { + self.layout.step(); } + self.initialization_ms = start.elapsed().as_secs_f64() * 1000.; + self.positions_revision += 1; + self.running = false; } /// Hot-reload: keep force-layout positions for nodes that still exist (matched by stable id). @@ -129,6 +191,10 @@ impl GraphLayout { running: old.running, iterations_per_frame: old.iterations_per_frame, hub_status: hub_status_from_scene(&scene), + last_steps: 0, + last_step_ms: 0., + positions_revision: 0, + initialization_ms: old.initialization_ms, scene, } } @@ -137,20 +203,27 @@ impl GraphLayout { fn hub_status_from_scene(scene: &HypergraphScene) -> Vec> { let mut hub_status = vec![None; scene.node_count()]; for he in &scene.hyperedges { - if let Some(slot) = hub_status.get_mut(he.hub_index) { + if let Some(hub) = he.hub_index + && let Some(slot) = hub_status.get_mut(hub) + { *slot = Some(he.status.clone()); } } hub_status } +#[allow(clippy::too_many_arguments)] pub fn init_graph( mut commands: Commands, - settings: Res, + mut settings: ResMut, + mut hulls: Option>, + mut lines: Option>, + showcase: Option>, + session: Option>, initial: Option>, mut watch: Option>, ) { - let layout = if let Some(init) = initial { + let mut layout = if let Some(init) = initial { GraphLayout::from_scene(init.0.clone(), &settings) } else if let Some(path) = &settings.graph_path { match layout_from_path(path, &settings) { @@ -179,6 +252,27 @@ pub fn init_graph( watch = settings.watch, "Hypergraph scene initialized" ); + settings.config = layout.layout.config.clone(); + if showcase.is_none() + && layout.node_count >= 1000 + && layout.scene.links.is_empty() + && !hyper_viz::DependencyIndex::new(&layout.scene).is_empty() + && let (Some(hulls), Some(lines)) = (hulls.as_deref_mut(), lines.as_deref_mut()) + { + layout.full_graph_overview(&mut settings, hulls, lines); + let saved = session + .as_ref() + .filter(|store| store.enabled && store.has_saved_session); + if let Some(store) = saved { + crate::session::apply_layout_prefs(&store.session.prefs.layout, &mut settings); + layout.layout.config = settings.config.clone(); + layout.iterations_per_frame = settings.iterations_per_frame; + } + layout.rebuild_structural(); + if let Some(store) = saved { + layout.running = store.session.prefs.layout.running; + } + } commands.insert_resource(layout); } @@ -296,20 +390,39 @@ pub fn poll_live_scene( pub fn step_layout(mut layout: ResMut) { if !layout.running { + layout.last_steps = 0; + layout.last_step_ms = 0.; return; } - let iters = if layout.node_count >= 800 { - layout.iterations_per_frame.min(2) - } else if layout.node_count >= 400 { - layout.iterations_per_frame.min(3) - } else { - layout.iterations_per_frame - }; - - for _ in 0..iters { + let start = std::time::Instant::now(); + let mut steps = 0; + for _ in 0..layout.iterations_per_frame.max(1) { layout.layout.step(); + steps += 1; + if start.elapsed().as_secs_f64() >= 0.008 { + break; + } + } + layout.last_steps = steps; + layout.last_step_ms = start.elapsed().as_secs_f64() * 1000.; +} + +/// Neutral stable-ID positions for force-model comparisons. +pub fn seed_neutral(layout: &mut GraphLayout) { + let seed = |s: &str| { + let h = s.bytes().fold(0xcbf29ce484222325_u64, |h, b| { + (h ^ b as u64).wrapping_mul(0x100000001b3) + }); + let c = |shift| (((h >> shift) & 0xffff_u64) as f32 / 32767.5) - 1.; + hyper_viz::Vec3::new(c(0), c(16), c(32)) + }; + for (i, node) in layout.scene.nodes.iter().enumerate() { + layout.layout.positions[i] = seed(&node.id) * 500.; + layout.layout.velocities[i] = hyper_viz::Vec3::default(); } + // Mark a position revision for hulls even when the simulation is paused. + layout.positions_revision += 1; } #[cfg(test)] @@ -368,6 +481,161 @@ mod watch_tests { ); } + #[test] + fn large_import_graph_starts_with_all_geometry_in_a_paused_overview() { + let mut graph = Hypergraph::new(); + for n in 0..1000 { + let id = n.to_string(); + graph = graph.vertex(&id, &id, "module"); + } + for n in 1..1000 { + let id = n.to_string(); + let mut edge = + hyper_viz::Hyperedge::new(format!("import:{n}"), ["0", &id]).with_kind("import"); + edge.attrs.insert("source".into(), "0".into()); + edge.attrs.insert("target".into(), id.into()); + graph.add_hyperedge(edge); + } + graph = graph.hyperedge("group", ["0", "1", "2"], "Group"); + let mut app = App::new(); + app.init_resource::() + .init_resource::() + .init_resource::() + .insert_resource(crate::InitialScene(graph.project(Projection::StarCentroid))) + .add_systems(Startup, init_graph); + app.update(); + let layout = app.world().resource::(); + let lines = app.world().resource::(); + let hulls = app + .world() + .resource::(); + assert!( + !layout.running, + "Full geometry remains stable until Run is chosen" + ); + assert!(hulls.enabled, "No higher-arity hyperedges hidden"); + assert!(!hulls.wireframe); + assert!(hulls.opacity > 0. && hulls.opacity <= 0.01); + let (visible, omitted) = crate::spatial_visibility::visible_lines( + &layout.scene, + &crate::focus::FocusScope::default(), + hulls.hide_hubs, + lines.budget(), + ); + assert_eq!(visible.len(), 999); + assert_eq!(omitted, 0); + assert_eq!(layout.node_count, 1000); + assert_eq!( + layout.layout.config.topology.model, + hyper_viz::LayoutModel::Normalized + ); + assert_eq!( + layout.iterations(), + 64, + "Only actual refinement steps count" + ); + assert!(layout.initialization_ms > 0.); + assert!( + layout + .layout + .positions + .iter() + .all(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()) + ); + } + + #[test] + fn tour_initialization_keeps_explicit_line_opacity() { + let mut graph = Hypergraph::new(); + for n in 0..1000 { + let id = n.to_string(); + graph = graph.vertex(&id, &id, "module"); + } + let mut edge = hyper_viz::Hyperedge::new("import", ["0", "1"]).with_kind("import"); + edge.attrs.insert("source".into(), "0".into()); + edge.attrs.insert("target".into(), "1".into()); + graph.add_hyperedge(edge); + let mut app = App::new(); + app.init_resource::() + .insert_resource(crate::InitialScene(graph.project(Projection::StarCentroid))) + .insert_resource(crate::render::LinkRenderSettings { + max_lines: 0, + opacity: 0.10, + }) + .insert_resource(crate::showcase::ShowcaseConfig { + frames: 1, + fps: 30, + warmup: 0, + capture: None, + report: Default::default(), + live_layout: false, + clean: false, + realtime: false, + structural: false, + hull_opacity: None, + hull_outlines: false, + }) + .add_systems(Startup, init_graph); + app.update(); + assert_eq!( + app.world() + .resource::() + .opacity, + 0.10 + ); + } + + #[test] + fn structural_rebuild_and_reload_preserve_complete_connectivity() { + let graph = Hypergraph::new() + .vertex("a", "a", "subject-one") + .vertex("b", "b", "subject-two") + .vertex("c", "c", "subject-three") + .hyperedge("e", ["a", "b", "c"], "e"); + let mut settings = LayoutSettings::default(); + settings.config.topology.model = hyper_viz::LayoutModel::Normalized; + let scene = graph.project(Projection::StarCentroid); + let mut layout = GraphLayout::from_scene(scene.clone(), &settings); + layout.rebuild_structural(); + let positions = layout.layout.positions.clone(); + let mut renamed = scene.clone(); + for node in &mut renamed.nodes { + node.kind = "unrelated".into(); + node.label = "renamed".into(); + } + let mut other = GraphLayout::from_scene(renamed.clone(), &settings); + other.rebuild_structural(); + assert_eq!( + other.layout.positions, positions, + "Subject metadata does not create structure" + ); + let reloaded = GraphLayout::from_scene_preserve(&layout, renamed, &settings); + assert_eq!(reloaded.layout.positions, positions); + assert_eq!(reloaded.layout.config.topology, settings.config.topology); + assert_eq!(reloaded.scene.hyperedges.len(), 1); + assert_eq!(reloaded.node_count, 3); + assert!(!reloaded.running); + } + + #[test] + fn running_layout_steps_at_least_once_and_reports_actual_work() { + let scene = Hypergraph::new() + .vertex("a", "a", "vertex") + .project(Projection::StarCentroid); + let mut layout = GraphLayout::from_scene(scene, &LayoutSettings::default()); + layout.iterations_per_frame = 0; + let mut app = App::new(); + app.insert_resource(layout).add_systems(Update, step_layout); + app.update(); + let layout = app.world().resource::(); + assert_eq!(layout.iterations(), 1); + assert_eq!(layout.last_steps, 1); + assert!(layout.last_step_ms >= 0.); + app.world_mut().resource_mut::().running = false; + app.update(); + assert_eq!(app.world().resource::().last_steps, 0); + } + #[test] fn unchanged_file_stamp_does_not_replace_the_live_scene() { let (_tmp, mut app) = fixture(); diff --git a/crates/hyper-viz-bevy/src/hyperedge_hull.rs b/crates/hyper-viz-bevy/src/hyperedge_hull.rs index b52dded..a2696a3 100644 --- a/crates/hyper-viz-bevy/src/hyperedge_hull.rs +++ b/crates/hyper-viz-bevy/src/hyperedge_hull.rs @@ -18,7 +18,7 @@ use crate::render::SceneNodeEntity; const TOPOLOGY_INTERVAL: u32 = 8; const WIREFRAME_ALL_LIMIT: usize = 64; -#[derive(Resource, Debug, Clone)] +#[derive(Resource, Debug, Clone, PartialEq)] pub struct HyperedgeHullSettings { pub enabled: bool, pub opacity: f32, @@ -60,13 +60,19 @@ impl Plugin for HyperedgeHullPlugin { app.init_resource::().add_systems( Update, ( - sync_hyperedge_hulls.run_if(resource_exists::), + sync_hyperedge_hulls + .after(crate::graph::step_layout) + .run_if(resource_exists::) + .run_if(crate::explorer_state::spatial_mode), draw_hull_wireframes .run_if(resource_exists::) + .run_if(crate::explorer_state::spatial_mode) .run_if(|settings: Res| { settings.enabled && settings.wireframe }), - update_hub_visibility.run_if(resource_exists::), + update_hub_visibility + .run_if(resource_exists::) + .run_if(crate::explorer_state::spatial_mode), ), ); } @@ -94,8 +100,9 @@ fn sync_hyperedge_hulls( focus: Res, time: Res