facett-graphnav V.G — design
Class:
design· Function axis:f0the whole ·f1the pan/zoom camera ·f2Appearance (ranking + partition → visuals) ·f3the Arrow table view ·f4GEXF/GraphML import + export ·f5the stats panel ·f6the facett-demo tab ·f7chrome: canvas-first layout + the ⚙ settings fold. Strict: every claim points at a realfile:line. Workflow:graphnav-vg-guide.md; zero-knowledge walkthrough:graphnav-vg-for-idiots.md.
f0 — the whole
The GRAPH NAVIGATOR (V.G = G.V(), à la gdotv/Gephi) is facett_graphnav::GraphNav (facett-graphnav/src/lib.rs) — one facett_core::Facet that is its own Elm Model: every field round-trips serde, all mutation flows through GraphNav::update (a Msg matcher), and GraphNav::render is a pure &self → Vec<Msg> paint. The elevation from "a viewer" to "a graph-exploration tool that beats Gephi/gephi-lite/gdotv" adds five capabilities on top of the query/inspector/layout/search/path base, each observable in state_json so a headless robot drives it.
Everything reuses the sibling engines rather than re-implementing them: facett-graphpan (the camera), facett-graphview::analysis + ::community (the metrics), facett-grid (the table), and — in the demo — facett-demo::popgraph (the data).
f1 — the pan/zoom camera
The navigator lacked a camera; it now embeds one, reused from facett-graphpan: pub use facett_graphpan::GraphView as Camera (lib.rs), held on the Model as camera (#[serde(default)]). Convention (documented at the Camera re-export): graphnav interprets the camera's pan in normalised world units (the [-1,1]² layout space), not screen pixels, so the transform is rect-independent and headless- testable. Projection in render: screen = rect.center() + (world·zoom + pan)·scale.
Four Msgs drive it (Msg::CameraPan / CameraZoom / FitView / ZoomToSelection):
CameraPan([dx,dy])— add a world delta; the canvas divides the screen drag byscale·zoomso it feels 1:1.CameraZoom(f)— multiply zoom, clampedZOOM_MIN..ZOOM_MAX(0.25..4.0).FitView—Camera::fit()(pan→0, zoom→1).ZoomToSelection— centre the selected node:pan = -w·FOCUS_ZOOM,zoom = FOCUS_ZOOM(2.6). No-op with nothing selected.
The canvas senses click_and_drag; drag → CameraPan, hovered scroll → CameraZoom. Visual polish rides here too: node labels appear progressively (small graph, or zoom ≥ 1.6, or the hovered node + its neighbours + selection/path/match), and hovering a node dims the rest and rings its neighbours + brightens incident edges (gephi-lite-style focus). The camera prints under state_json.camera (pan, zoom).
f2 — Appearance: the analysis toolbox → visuals
facett-graphnav/src/appearance.rs is the wiring from facett_graphview::analysis to the canvas's visual encoding — the #1 gap versus Gephi's Appearance panel. Three independent channels, all pure + deterministic:
Metric(Degree / PageRank / Betweenness / Closeness / Eigenvector / KCore) —Metric::values(&graph)builds anAdjacencyfromgraph.edge_indices()and calls the matchinganalysis::centrality/analysis::kcorefunction.Partition(Louvain / Label / Component) —Partition::assign(&graph)returns a compact(of, count)fromcommunity::louvain/analysis::components::connected/ a first-seen label map.- ramp + radii —
normalize→[0,1];radii→[R_MIN,R_MAX](4..15);ramp(t)is a brand-neutral sequential indigo→teal→green→yellow ramp.
On the Model: size_metric, color_metric, partition (Option, serde-default), driven by Msg::SetSizeBy / SetColorMetric / SetPartition. The two colour channels are mutually exclusive (setting one clears the other, enforced in update). GraphNav::appearance() is the single source both the painter and state_json read: it returns AppearanceResult { radii, colors, color_mode, partition_count } with colour precedence partition > continuous metric > categorical StyleBy. Edge width scales with a normalised weight property (GraphNav::edge_weight). Surfaced under state_json.appearance + per-node radius/color in canvas.nodes.
Stable ontology palette. StyleBy::Label colours each node TYPE by its label STRING, not by first-seen order: style::label_color(label) = wheel(stable_hash(label) % 8) with a byte-stable FNV-1a. So Vessel is the SAME hue in a 3k graph and a 30k graph, whatever other labels are present — the fix for a type visibly "changing colour" as the query/scale changed. The legend (Styling.categories) stays first-seen order; only the colour rule is content-independent (Styling.stable). Property styling and index-based partition/community colouring still use positional wheel(index), where the number legitimately IS the meaning. korp's 3D cloud consumes the very same style::label_color, so 2D and 3D agree by construction and are both invariant to graph content.
Pinned ontology palette (the override knob). stable_hash fixes a label's hue against content/order, but it does not let a caller choose the hue (nor prevent two labels hashing to the same wheel slot). GraphNav carries a label_color_overrides: BTreeMap<String, [u8;3]> (serde-clean raw RGB; API in/out is Color32), set via with_label_colors([...]) / set_label_color(label, color) / clear_label_color(label). Under StyleBy::Label, compute_appearance consults the override FIRST (label_override_rgb) and only falls back to style::label_color for un-pinned labels — so a consumer can freeze a FIXED palette (Person/Vessel/Aircraft/Company/Address always the same colour). The override joins appearance_sig (cache invalidates when it changes) and, when the map is empty, colouring is byte-identical to the pure-wheel rule. korp consumes this (after a registry bump) to pin its ontology palette so graph colours never reshuffle between queries.
f3 — the Arrow data-laboratory table
facett-graphnav/src/table.rs exposes the graph as two Arrow RecordBatches:
node_batch—id, label, caption, degree, pagerank, betweenness, community(row order =graph.nodes).edge_batch—src, dst, type, weight(row order =graph.edges).
They are built with arrow-array / arrow-schema (v58, matching facett-grid) and fed straight to facett_grid::Grid::from_record_batch. Because row index = element index, two-way selection sync is a plain index: select_node_row / select_edge_row (grid → canvas) return the Msg; selected_node_row / selected_edge_row (canvas → grid) return the row. Schemas + counts + selection ride under state_json.table.
f4 — GEXF / GraphML import + export
facett-graphnav/src/io.rs. ExportFormat gains Gexf + GraphMl (kept JSON/DOT/CSV). Export is deterministic, dependency-free string XML: nodes carry label + properties + the computed metrics (GraphNav::metric_columns → degree/pagerank/betweenness/Louvain community) as GEXF <attvalue>s / GraphML <data>; edges carry type + weight. Import is a tolerant, dependency-free pull tokenizer (tokenize → Start/End/Text events, namespace-stripping, entity-decoding) feeding from_gexf / from_graphml, reachable via GraphNav::import(title, fmt, text) (GEXF/GraphML/JSON; DOT/CSV are write-only → Err). This is Gephi's front door — its round-trip is asserted in the robot test.
f5 — the stats panel
facett-graphview/src/analysis/stats.rs adds the two measures the toolbox lacked: average_clustering + transitivity, and diameter + average_path_length (undirected BFS, disconnected-safe — ∞ pairs ignored). GraphNav::stats() composes the whole-graph scalars — node/edge counts, density, average degree, connected-component count, Louvain modularity + community count, clustering, diameter, avg path length — into state_json.stats, shown in a collapsible 📊 stats UI panel (render).
f6 — the facett-demo tab
facett-demo/src/vg_lab_tab.rs mounts the navigator as the V.G tab, driven by the demo's own Swedish population graph (popgraph::population_scene → PropGraph via population_propgraph). A canvas ⇄ table toggle switches the lower panel between the navigator's full canvas (all toolbars) and the facett-grid node table (a grid row click syncs the canvas selection). It is a Facet; state_json merges the navigator's state with a vglab block (view mode + grid dims + selection). Registered in the deck + LEGEND (self-check: 52 facets).
f7 — chrome: canvas-first layout + the ⚙ fold
render (facett-graphnav/src/lib.rs) lays out canvas-first so the queried graph is the main attraction and can never be pushed off-screen by the controls. It uses egui's own panel primitives (L5 reuse — no hand-rolled layout):
egui::Panel::top(id)holds the chrome: the always-visible query + search bar (the primary actions) and one small ⚙ "modes & look"CollapsingHeader. The header is driven by the Model flagsettings_open(#[serde(default)], defaultfalse= FOLDED) via.open(Some(self.settings_open)); a header click emitsMsg::ToggleSettings. Folded, it is just the one ⚙ icon row, so the toolbar takes almost no vertical space on open. Its body (layout / colour / size / ramp / partition, the cycle browser, similar-to-selection, stats) wraps horizontally (horizontal_wrapped— proportional to the X width, never a square box) inside a height-cappedScrollArea(≤ 0.42·view), so even fully expanded the chrome cannot devour the canvas.egui::CentralPanel::showholds the canvas — THE MAIN ATTRACTION. It claims the whole remaining rect viaui.available_size()inside the central panel, so it is always fully visible + pannable/draggable no matter how much chrome sits above; it is never an appended row after unbounded content (the exact bug fixed: controls used to stack vertically and shove the canvas below the viewport).
Canvas-dominance proof. render records the claimed canvas rect + the frame viewport into the equality-neutral, #[serde(skip)] ChromeCache (same discipline as LayoutCache / AnalyticsCache), surfaced as state_json["chrome"] (settings_open, canvas{x,y,w,h}, view{w,h}, dominant_frac). Folded at 760×600 the canvas claims ≈ 0.90 of the view height sitting at the top; unfolded ≈ 0.78. The RED-when-broken test canvas_claims_the_dominant_area_when_folded asserts the folded canvas owns ≥ 0.70 of the view, sits high in Y, and dwarfs the chrome band — it goes red if the toolbar-stacked layout ever squeezes the canvas low again. chrome_settings_fold_defaults_folded_and_toggles + folded_chrome_omits_control_rows_unfold_reveals_them prove the fold defaults folded and that the control rows are genuinely absent from the AccessKit tree until unfolded.
Scale — freeze + guard
At 30 000 nodes the Barnes–Hut layout freeze (LayoutCache) made the layout cheap, and the analytics freeze (AnalyticsCache, mirroring it: #[serde(skip)], equality-neutral) stopped stats() / cycles() / appearance() re-running every frame. That left one wall: the analytics themselves are superlinear and single-threaded (CORE-SATURATION LAW), so even computing them once on load froze a single core for seconds. ANALYTICS_SCALE_GUARD (= 4_000, sized like facett-graph3d's MDS_MAX) draws the line — at/above it:
cycles()returns empty without enumerating (elementary-cycle enumerationdfs_cyclesrecurses to cycle length — a giant cycle would blow the stack, let alone the clock). The render toolbar moved the cycle browser into a collapsing panel socycles()runs only when opened; above the guard the panel shows n/a at this scale + an explicit compute anyway button (Msg::ForceCycles, a view-only override kept in the cache).stats()still computes the cheap scalars (counts, density, avg-degree, components, Louvain modularity, clustering) but drops the ALL-PAIRSdiameter+average_path_length(null→ shown n/a).appearance()skips a superlinear colour/size metric (Metric::heavy_at_scale— betweenness / closeness, O(V·E)) with ascale-limitednote; degree / pagerank / eigenvector / k-core (near-linear) stay on.
Below the guard every analytic runs exactly as before — state_json byte-identical, goldens unmoved (L2 additive); the n/a-at-scale states are new observable UI only above it. The canvas paint also viewport-culls: the in-lane egui equivalent of facett-graphview::clip's aabb_overlaps (that module is the offscreen-raster lane, so its Frame pipeline can't drop into the egui::Painter path) — off-screen node chips + edge segments are never tessellated, killing the single-core paint cost of pan/zoom at scale. analytics_scale_guard_skips_superlinear_above_threshold + analytics_below_guard_runs_full_suite are the RED-when-broken pair.
Robot surface
Per capability, a robot scenario drives the Msg/method surface and asserts state_json (facett-graphnav/tests/robot.rs: camera, appearance, table+grid, GEXF/ GraphML round-trip, stats), plus a demo-level scenario (facett-demo/tests/vg_lab.rs) that opens the tab → runs a layout → sizes+colours by a metric → opens the table → exports GEXF, with real egui_kittest paint frames.