viewers — the graph-navigator / 3D-tube / Streamlit / lineage subsystem — guide
Class:
guide· audience: a techie drivingfacett-helix::TubeView,facett-graphnav::GraphNav,facett-lineage::LineageView, or embedding any of them into Streamlit. The what is inviewers-design.md; the zero-knowledge version inviewers-for-idiots.md. This file is the workflow.
f0 — the shape of every task
All three viewers are facett_core::Facets. The workflow is always the same:
- Build the Facet from data (or
::demo()). - Drive it — either paint it (
Facet::uiinside eframe) or drive it headless viaupdate(Msg)and readstate_json(). - Optionally embed it in Streamlit (register it, then
cargo xtask release-streamlit, thenfacett_component("<name>")from Python).
f1 — render a TubeField (the 3D data-tube)
Build the field
use facett_helix::{TubeField, TubeView};
// Self-contained demo (a helical hot-band on the wall + a pulse down the core):
let view = TubeView::demo();
// Or sample your own two fields over the cylinder. `θ ∈ [0,1)` around the circle,
// `z ∈ [0,1]` along the axis. `wall(θ,z)` paints the outer surface; `core(z)` the
// centre axis.
let field = TubeField::from_fn(
64, // theta_bins — angular resolution
48, // z_slices — axial resolution
|theta, z| (theta * std::f32::consts::TAU).sin() * 0.5 + 0.5 + z * 0.0,
|z| z, // core ramps 0→1 down the axis
);
let mut view = TubeView::new("my tube", field);
Or set cells directly: field.set_wall(theta, z, v) (θ wraps, z clamps) and field.set_core(z, v). field.is_valid() is the invariant a deserialised field must pass; an invalid field meshes to nothing.
Drive it
- Paint it:
view.ui(ui)inside an eframe app (see the idiot guide for a 15-line runner). The toolbar offers spin + camera presets (Isometric / Front / Top / Oblique). - Headless: apply
TubeMsgand read back:
use facett_helix::TubeMsg;
use facett_map3d::ViewPreset;
view.update(TubeMsg::ToggleSpin(false)); // stop the auto-orbit
view.update(TubeMsg::Preset(ViewPreset::Front));
view.update(TubeMsg::SetAzimuth(1.2)); // radians; also stops the spin
let st = view.state_json();
// st["theta_bins"], st["z_slices"], st["vertex_count"], st["triangle_count"],
// st["wall_min"]/["wall_max"], st["core_min"]/["core_max"], st["azimuth"], ...
For a deterministic render (tests, snapshots) pin the clock: TubeView::demo().with_clock(2.0) — same clock ⇒ byte-identical state_json. The pure render core is view.faces(w, h) -> Vec<Face> (projected, Lambert-shaded, depth-sorted far→near) if you want the geometry without egui.
Reading: the wall is translucent (
WALL_ALPHA = 0.5) so the opaque core shows through the pipe. Both fields are colour-normalised against their own range, so each always spans the full blue→red ramp.
f2 — use the GRAPH NAVIGATOR
Build over a property graph
use facett_graphnav::{GraphNav, PropGraph, Node, Edge};
let mut g = PropGraph::new();
g.add_node(Node::new("p1", "Person").with("name", "Alice").with("age", "42"));
g.add_node(Node::new("c1", "Company").with("name", "Acme"));
g.add_edge(Edge::new("p1", "c1", "WORKS_AT"));
let mut nav = GraphNav::new("my graph", g);
// or: let mut nav = GraphNav::demo(); // a two-cluster social graph
Queries
Set the text, then run it (SetQuery then RunQuery). The engine is a tolerant subset of Cypher and Gremlin, with free-text fallback:
use facett_graphnav::Msg;
nav.update(Msg::SetQuery("MATCH (n:Person) RETURN n".into()));
nav.update(Msg::RunQuery);
// Cypher: MATCH (n) RETURN n · MATCH (n:Person {name:"Alice"}) RETURN n
// MATCH ()-[r:KNOWS]->() RETURN r
// Gremlin: g.V() · g.V().hasLabel('Person') · g.V().has('name','Alice')
// g.E() · g.E().hasLabel('KNOWS')
// anything else → free-text search over id / caption / label / property values
let st = nav.state_json();
// st["query"]["kind"] ∈ {nodes,edges,search,empty}, ["result_nodes"], ["error"]
Layouts, styling, paths, expand/collapse
use facett_graphnav::{LayoutKind, StyleBy};
nav.update(Msg::SetLayout(LayoutKind::Radial)); // Force/Hierarchical/Radial/Circular/Grid
nav.update(Msg::SetStyle(StyleBy::Label)); // or StyleBy::Property("age".into())
nav.update(Msg::SetPath(Some(("p1".into(), "p4".into())))); // highlights the shortest path
nav.update(Msg::SelectNode("p1".into()));
nav.update(Msg::ExpandNeighborhood("p1".into())); // reveal p1's neighbours
nav.update(Msg::SetSearch("ali".into())); // recompute the fuzzy match set
nav.update(Msg::SetFilterLabel(Some("Person".into())));
state_json reflects all of it: canvas.nodes carry x/y/color/on_path/ matched; path.nodes is the resolved sequence; layout.options lists the switcher; minimap is the bounds; schema.triples is the entity-relationship overview.
History, saved queries, subgraph extraction
nav.update(Msg::SaveQuery("people".into())); // pins the current query text by name
nav.update(Msg::RecallQuery("people".into())); // loads it back into the bar
nav.update(Msg::ClearHistory);
nav.update(Msg::SelectNode("p3".into()));
nav.update(Msg::ExtractSubgraph(2)); // ego-network within 2 hops of p3
// → state_json["subgraph"]; Msg::ClearSubgraph clears it
Export
use facett_graphnav::ExportFormat;
let json = nav.export(ExportFormat::Json); // pretty PropGraph
let dot = nav.export(ExportFormat::Dot); // digraph G { ... }
let csv = nav.export(ExportFormat::Csv); // # nodes / # edges blocks
The analysis toolbox directly
If you want the algorithms without the IDE, map your graph to indices and call facett_graphview::analysis:
use facett_graphview::analysis::{Adjacency, centrality, community, pathfinding, kcore};
let g = Adjacency::from_edges(n, &edges); // edges: &[(usize, usize)]
let pr = centrality::pagerank(&g, 0.85, 50);
let btw = centrality::betweenness(&g);
let comm = community::label_propagation(&g, 20); // .modularity is quality DATA
let path = pathfinding::shortest_path(&g, s, t);
let cores = kcore::core_numbers(&g);
All results are Vecs keyed by node index; everything is deterministic (no RNG).
f3 — embed a component in Streamlit
1. Make sure your component is registered
Every embeddable Facet is one Entry in Registry::with_builtins() (facet-wrapped/src/registry.rs:43). To add your own:
r.register(Entry {
name: "my-view", // the Streamlit key
description: "what it shows",
factory: || Box::new(my_crate::MyView::demo()), // fn() -> Box<dyn Facet>
});
The factory must build a fresh boxed Facet (its demo/discovery fixture). That is the only glue — no Streamlit-side code.
2. Build the wheel
cargo xtask wrap-wasm && cargo xtask publish-pip # build wasm + assemble the wheel (no upload)
cargo xtask wrap-wasm && cargo xtask publish-pip --publish # ...and twine upload to PyPI
This is pure Rust (std::process, no shell). It compiles the crate to wasm32-unknown-unknown, runs the matching wasm-bindgen into the Python package's frontend/, writes manifest.json + the loader index.html, then python -m build. Prerequisites: the wasm32-unknown-unknown rustup target (the xtask installs it if missing) and python with build (+ twine for --publish).
3. Use it from Python
import streamlit as st
from facett_streamlit import facett_component, components
st.sidebar.write("Components:", [c["name"] for c in components()])
facett_component("graphnav", height=560) # or "helix-tube", "lineage", ...
facett_component(name, height=…, key=…, **props) mounts the named component on a real eframe/egui canvas in the iframe — it renders interactively (pan/click/drive), not just a static image. The 13 built-in names: graphnav, helix-tube, stat-grid, console, timeline, table, log, matrix, tabbar, graphpan, plandag, security, lineage.
Headless verification of the shipped wasm. The frontend exports
window.facett_streamlit_state()(the live facet's wholestate_json, republished every frame) andwindow.facett_streamlit_mounted()(which component booted) — a browser drive reads exactly what the running canvas rendered.
f4 — view a lineage_events table
facett-lineage turns skade's reserved lineage_events table into the graph navigator: datasets are nodes, runs are edges.
use facett_lineage::{LineageView, read_rows};
use arrow_array::RecordBatch;
// From a skade lineage_events RecordBatch (no skade dependency needed):
let view = LineageView::from_record_batch("lineage", &batch)?;
// Or from already-decoded rows / the demo:
let rows = read_rows(&batch)?; // Vec<LineageRow>, null-tolerant
let view = facett_lineage::LineageView::from_rows("lineage", rows);
let view = LineageView::demo(); // a multi-system, multi-hop DAG
Drive it exactly like the navigator — facett_lineage re-exports Msg, LayoutKind, StyleBy, ExportFormat:
use facett_lineage::{Msg, StyleBy};
view.update(Msg::SetStyle(StyleBy::Label)); // colours datasets by their system
view.update(Msg::SetLayout(facett_lineage::LayoutKind::Radial));
let st = view.state_json();
// the whole GraphNav state PLUS st["lineage"]:
// ["events"], ["datasets"], ["runs"], ["operations"] (histogram),
// ["systems"], ["actors"], ["commit_seq_max"]
Nodes are labelled by system ("skade" for local, e.g. "spark" for a foreign source), ids are system-qualified ("spark:app-42/events"), and edge types are the uppercased operation (APPEND/OVERWRITE/RELEASE/…) carrying actor / commit cursor / timestamp. Feed it into Streamlit under the name lineage.