Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

.nornir/viewers-guide.md

viewers — the graph-navigator / 3D-tube / Streamlit / lineage subsystem — guide

Class: guide · audience: a techie driving facett-helix::TubeView, facett-graphnav::GraphNav, facett-lineage::LineageView, or embedding any of them into Streamlit. The what is in viewers-design.md; the zero-knowledge version in viewers-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:

  1. Build the Facet from data (or ::demo()).
  2. Drive it — either paint it (Facet::ui inside eframe) or drive it headless via update(Msg) and read state_json().
  3. Optionally embed it in Streamlit (register it, then cargo xtask release-streamlit, then facett_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

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 whole state_json, republished every frame) and window.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.