facett-graphgen/src/lib.rs
//! **Size + generator for the graph tabs** (owner, 2026-10-01: "in general have a size
//! selector and a selectable data generator").
//!
//! One reusable control ([`GenControl`]) and one wrapper facet ([`GenTab`]) every graph
//! tab whose component can take a generated model mounts:
//!
//! - **data** — `demo data` (the tab's own fixture, the default, so nothing changes until
//! asked), the **knut-popsim** Swedish population (`knut_popsim::simulate`, placed at its
//! kommun's real coordinates), or one of the seeded standard generators in
//! [`facett_graphview::generate`] (community rings, SBM, Erdős–Rényi, Barabási–Albert,
//! grid, tree, the repo → crate → function world);
//! - **size** — 100 · 1k · 10k · 100k, and 1M where the component's GPU path takes it
//! (each tab declares its own ceiling);
//! - **seed** — every generator is a pure function of `(data, size, seed)`.
//!
//! Generation runs ONCE per change — never per frame — on the frame after the change (the
//! pane says "generating…" for that frame), and the control shows what it cost:
//! generation ms, model build ms, nodes, edges. `update_json({"gen":{…}})` drives the
//! same path headlessly, synchronously.
//!
//! ## Why this is a library (lane F, 2026-10-02)
//!
//! It was `facett-demo/src/graphgen.rs` + `universe_tab.rs`. The owner asked for the same
//! graph tabs in the Streamlit facet demo, which mounts `facet-wrapped`'s registry in a
//! wasm — and `facet-wrapped` cannot depend on the demo binary. A second copy of the
//! lowerings there would be two writers for "what graph does `sbm:100000:1` draw" (LAW 5),
//! so the generator, the lowerings, [`GenTab`], the per-component [`build`] functions and
//! the [`universe`] tab moved here, and BOTH hosts call them: facett-demo's graph tabs and
//! facet-wrapped's `{"generate": {"source", "size", "seed"}}` data seam
//! ([`GenRequest`]). The demo re-exports this crate under its old module paths.
pub mod build;
pub mod universe;
use facett_core::Facet;
use facett_core::FacetCaps;
pub use facett_graphview::generate::{GenKind, Generated};
use facett_graphview::generate as ggen;
/// The size presets, ascending. A tab offers the ones `<=` its ceiling.
pub const SIZES: [usize; 5] = [100, 1_000, 10_000, 100_000, 1_000_000];
/// "100", "1k", "10k", "100k", "1M".
#[must_use]
pub fn size_label(n: usize) -> String {
if n >= 1_000_000 && n.is_multiple_of(1_000_000) {
format!("{}M", n / 1_000_000)
} else if n >= 1_000 && n.is_multiple_of(1_000) {
format!("{}k", n / 1_000)
} else {
n.to_string()
}
}
/// Where a tab's graph comes from.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Source {
/// The tab's own fixture — what it showed before this control existed.
Demo,
/// knut-popsim's Swedish population (Person + its ties), placed by kommun.
Population,
/// A seeded standard generator.
Gen(GenKind),
}
impl Source {
/// Every source, in picker order.
pub const ALL: [Source; 9] = [
Source::Demo,
Source::Population,
Source::Gen(GenKind::Community),
Source::Gen(GenKind::Sbm),
Source::Gen(GenKind::Random),
Source::Gen(GenKind::ScaleFree),
Source::Gen(GenKind::Grid),
Source::Gen(GenKind::Tree),
Source::Gen(GenKind::Crates),
];
#[must_use]
pub fn key(self) -> &'static str {
match self {
Source::Demo => "demo",
Source::Population => "population",
Source::Gen(k) => k.key(),
}
}
#[must_use]
pub fn label(self) -> &'static str {
match self {
Source::Demo => "demo data",
Source::Population => "population (knut-popsim)",
Source::Gen(k) => k.label(),
}
}
#[must_use]
pub fn from_key(k: &str) -> Option<Source> {
Source::ALL.into_iter().find(|s| s.key() == k)
}
}
/// A generated graph plus the per-node names a typed source carries (population labels
/// and relationship names); `None` for the untyped generators.
#[derive(Clone, Debug)]
pub struct GenGraph {
pub g: Generated,
pub source: Source,
/// Per-node label (`Person`, `Address`, …) — population only.
pub labels: Option<Vec<&'static str>>,
/// Per-edge relationship (`LIVES_AT`, …) — population only.
pub rels: Option<Vec<&'static str>>,
}
impl GenGraph {
/// The node label a typed model shows: the population label, else `g<group>`.
#[must_use]
pub fn label(&self, i: usize) -> String {
match &self.labels {
Some(l) => l[i].to_string(),
None => format!("g{}", self.g.group[i]),
}
}
/// The relationship name of edge `e`.
#[must_use]
pub fn rel(&self, e: usize) -> &'static str {
self.rels.as_ref().map_or("LINK", |r| r[e])
}
/// The node colour (by group).
#[must_use]
pub fn rgb(&self, i: usize) -> [u8; 3] {
ggen::group_rgb(self.g.group[i])
}
}
/// Generate `(source, n, seed)`. `None` for [`Source::Demo`] (the tab keeps its own).
#[must_use]
pub fn generate(source: Source, n: usize, seed: u64) -> Option<GenGraph> {
match source {
Source::Demo => None,
Source::Gen(k) => Some(GenGraph { g: ggen::generate(k, n, seed), source, labels: None, rels: None }),
Source::Population => Some(population(n, seed)),
}
}
/// knut-popsim's population cut to exactly `n` nodes (people are emitted with their ties,
/// so the cut drops only the last person's tail), placed at each node's kommun — the
/// real WGS-84 city, Web-Mercator projected — with a deterministic spread inside it.
fn population(n: usize, seed: u64) -> GenGraph {
use knut_popsim::{geo, simulate, SimParams};
let n = n.max(1);
// ~3.8 nodes per person at the default tie mix; ask for a little more and cut.
let persons = (n / 3 + 1).min(u32::MAX as usize) as u32;
let pg = simulate(SimParams { n_persons: persons, n_kommun: geo::CITIES.len() as u16, seed });
let n = n.min(pg.nodes.len());
let mut edges = Vec::with_capacity(pg.edges.len());
let mut rels = Vec::with_capacity(pg.edges.len());
for e in &pg.edges {
if (e.src as usize) < n && (e.dst as usize) < n {
edges.push((e.src, e.dst));
rels.push(e.rel);
}
}
let nodes = &pg.nodes[..n];
let group: Vec<u32> = nodes.iter().map(|p| u32::from(p.kommun)).collect();
let labels: Vec<&'static str> = nodes.iter().map(|p| p.label_name()).collect();
// Project every city once; normalise the country into the [-1, 1] box (north up).
let merc: Vec<(f64, f64)> = geo::CITIES.iter().map(|c| facett_map::mercator(c.lon, c.lat)).collect();
let (mut x0, mut x1, mut y0, mut y1) = (f64::MAX, f64::MIN, f64::MAX, f64::MIN);
for &(x, y) in &merc {
x0 = x0.min(x);
x1 = x1.max(x);
y0 = y0.min(y);
y1 = y1.max(y);
}
let span = (x1 - x0).max(y1 - y0).max(1e-9);
let mut rng = ggen::SplitMix::new(seed ^ 0x7069_6f70);
let pos = group
.iter()
.map(|&k| {
let (x, y) = merc[k as usize];
let (jx, jy) = (rng.unit() - 0.5, rng.unit() - 0.5);
[
(((x - x0) - (x1 - x0) * 0.5) / span * 1.8) as f32 + jx * 0.06,
(((y - y0) - (y1 - y0) * 0.5) / span * 1.8) as f32 + jy * 0.06,
]
})
.collect();
let g = Generated {
kind: GenKind::Community,
seed,
n,
edges,
groups: geo::CITIES.len() as u32,
group,
pos,
};
GenGraph { g, source: Source::Population, labels: Some(labels), rels: Some(rels) }
}
// ── lowerings: one generated graph → each component's model ─────────────────────────
/// World units per placement unit for the caller-laid-out 2-D canvases (chips are
/// 184 × 34). Capped so the whole graph frames at a zoom the canvases' interactive
/// clamp (0.25) can still reach: a bigger spread would frame at a zoom the first scroll
/// snaps away from.
fn spread(n: usize) -> f32 {
((n as f32).sqrt() * 160.0 + 400.0).min(1_500.0)
}
/// → a `facett-graphpan` model (the graphpan tab).
#[must_use]
pub fn to_pan_model(gg: &GenGraph) -> facett_graphpan::GraphModel {
use facett_graphpan::{GraphEdge, GraphModel, GraphNode};
let s = spread(gg.g.n);
let edge = egui::Color32::from_rgba_unmultiplied(170, 180, 205, 120);
let nodes = (0..gg.g.n)
.map(|i| {
let [r, g, b] = gg.rgb(i);
let p = gg.g.pos[i];
GraphNode {
id: format!("n{i}"),
label: gg.label(i),
fill: egui::Color32::from_rgb(r, g, b),
stroke: egui::Color32::WHITE,
pos: egui::pos2(p[0] * s, p[1] * s),
}
})
.collect();
let edges = gg
.g
.edges
.iter()
.map(|&(a, b)| GraphEdge { from: format!("n{a}"), to: format!("n{b}"), color: edge, dashed: false, label: None })
.collect();
GraphModel { nodes, edges }
}
/// → a `facett-graphview` model (decograph — the `DecoratedGraphView` falkor and the
/// World coderoom paint through).
#[must_use]
pub fn to_view_model(gg: &GenGraph) -> facett_graphview::GraphModel {
use facett_graphview::model::{Color, GraphEdge, GraphNode, Pos};
let s = spread(gg.g.n);
let nodes = (0..gg.g.n)
.map(|i| {
let [r, g, b] = gg.rgb(i);
let p = gg.g.pos[i];
GraphNode {
id: format!("n{i}"),
label: gg.label(i),
fill: Color::rgb(r, g, b),
stroke: Color::rgb(255, 255, 255),
pos: Pos::new(p[0] * s, p[1] * s),
}
})
.collect();
let edges = gg
.g
.edges
.iter()
.map(|&(a, b)| GraphEdge {
from: format!("n{a}"),
to: format!("n{b}"),
color: Color::rgba(170, 180, 205, 140),
dashed: false,
label: None,
})
.collect();
facett_graphview::GraphModel { nodes, edges }
}
/// → the navigator's typed property graph (Graphs · lineage's engine).
#[must_use]
pub fn to_propgraph(gg: &GenGraph) -> facett_graphnav::model::PropGraph {
use facett_graphnav::model::{Edge, Node, PropGraph};
let mut pg = PropGraph::new();
for i in 0..gg.g.n {
pg.add_node(
Node::new(format!("n{i}"), gg.label(i))
.with("name", format!("{} {i}", gg.label(i)))
.with("group", gg.g.group[i].to_string()),
);
}
for (e, &(a, b)) in gg.g.edges.iter().enumerate() {
pg.add_edge(Edge::new(format!("n{a}"), format!("n{b}"), gg.rel(e)));
}
pg
}
/// The placement as navigator places (the `geographic` chip draws it).
#[must_use]
pub fn to_places(gg: &GenGraph) -> Vec<facett_graphnav::layout::Place> {
gg.g.pos.iter().map(|p| Some([f64::from(p[0]), f64::from(-p[1])])).collect()
}
/// [`to_graph3d`]'s `(label, colour)` nodes, index edges, and per-node placement.
pub type Graph3dParts = (Vec<(String, egui::Color32)>, Vec<(usize, usize)>, Vec<[f32; 3]>);
/// → `Graph3D`'s `(label, colour)` nodes, index edges, and a host placement: the 2-D
/// placement on x/z with the group as a height terrace, so a 100 000-node cloud needs no
/// force pass.
#[must_use]
pub fn to_graph3d(gg: &GenGraph) -> Graph3dParts {
let groups = gg.g.groups.max(1) as f32;
let nodes = (0..gg.g.n)
.map(|i| {
let [r, g, b] = gg.rgb(i);
(gg.label(i), egui::Color32::from_rgb(r, g, b))
})
.collect();
let edges = gg.g.edges.iter().map(|&(a, b)| (a as usize, b as usize)).collect();
let pos = (0..gg.g.n)
.map(|i| {
let p = gg.g.pos[i];
let y = (gg.g.group[i] as f32 / groups - 0.5) * 0.6;
[p[0], y, p[1]]
})
.collect();
(nodes, edges, pos)
}
/// → a Cypher-shaped result (the `falkor` tab): every node a `(:<label> {caption})` row
/// with a FalkorDB-style integer id, every edge a `[:<REL>]` relation — what a
/// `MATCH (n)-[r]->(m) RETURN n, r, m` hands back, so the browser lays it out through
/// its own query path.
#[must_use]
pub fn to_cypher(gg: &GenGraph) -> facett_graphview::CypherResult {
use facett_graphview::{CypherEdge, CypherNode, CypherResult};
// Ids start at 1 000 (a live graph's ids are not 0-based dense indices either).
let id = |i: u32| 1_000 + i64::from(i);
CypherResult {
nodes: (0..gg.g.n).map(|i| CypherNode::new(id(i as u32), gg.label(i), format!("{} {i}", gg.label(i)))).collect(),
edges: gg.g.edges.iter().enumerate().map(|(e, &(a, b))| CypherEdge::new(id(a), id(b), gg.rel(e))).collect(),
}
}
/// → a `facett-graph` [`Scene`](facett_core::Scene) (the `graph` tab). A scene node is
/// addressed by its label (the a11y target, the selection key), so each label carries
/// its index: `"<label> <i>"`.
#[must_use]
pub fn to_scene(gg: &GenGraph) -> facett_core::Scene {
let mut s = facett_core::Scene::new();
for i in 0..gg.g.n {
let [r, g, b] = gg.rgb(i);
s.node(format!("{} {i}", gg.label(i)), egui::Color32::from_rgb(r, g, b));
}
for &(a, b) in &gg.g.edges {
s.edge(a as usize, b as usize);
}
s
}
/// → lineage rows: every edge a run that read dataset `a` and wrote dataset `b`, so the
/// lineage browser draws the generated graph as a dataset DAG.
#[must_use]
pub fn to_lineage_rows(gg: &GenGraph) -> Vec<facett_lineage::LineageRow> {
const OPS: [&str; 4] = ["append", "overwrite", "create", "delete"];
gg.g
.edges
.iter()
.enumerate()
.map(|(e, &(a, b))| facett_lineage::LineageRow {
event_id: format!("gen-{e}"),
actor: format!("job-{}", gg.g.group[a as usize]),
operation: OPS[e % OPS.len()].to_string(),
input_table: Some(format!("main.t{a}")),
input_snapshot: None,
output_table: format!("main.t{b}"),
output_snapshot: None,
source_system: None,
source_instance: None,
target_system: None,
target_instance: None,
ts_micros: 1_790_000_000_000_000 + e as i64 * 1_000,
commit_seq: Some(e as i64),
})
.collect()
}
// ── the control ─────────────────────────────────────────────────────────────────────
/// Wall clock in ms (wasm: `Date.now()`, which `std::time::Instant` cannot do there).
#[must_use]
pub fn now_ms() -> f64 {
#[cfg(target_arch = "wasm32")]
{
js_sys::Date::now()
}
#[cfg(not(target_arch = "wasm32"))]
{
use std::sync::OnceLock;
static T0: OnceLock<std::time::Instant> = OnceLock::new();
T0.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() * 1e3
}
}
/// Frame timing since the last build, measured where the pane draws (the app's own
/// frames, not the browser's): the first frame (build end → the start of the second
/// frame, i.e. after the GPU presented the first), the settle (the first frame, after 30,
/// within 25 % of the running mean) and the frame interval mean (an EMA).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct FrameTiming {
built_at_ms: f64,
last_ms: f64,
pub frames: u64,
pub first_frame_ms: Option<f64>,
pub settle_ms: Option<f64>,
pub ema_ms: f64,
}
impl FrameTiming {
/// Start counting at `now` (the build just finished).
#[must_use]
pub fn start(now: f64) -> Self {
Self { built_at_ms: now, last_ms: now, ..Self::default() }
}
/// One drawn frame at `now`.
pub fn frame(&mut self, now: f64) {
let dt = now - self.last_ms;
self.last_ms = now;
self.frames += 1;
if self.frames == 2 {
self.first_frame_ms = Some(now - self.built_at_ms);
}
if self.frames > 2 {
self.ema_ms = if self.ema_ms == 0.0 { dt } else { self.ema_ms + (dt - self.ema_ms) * 0.1 };
if self.settle_ms.is_none() && self.frames > 30 && (dt - self.ema_ms).abs() <= self.ema_ms * 0.25 {
self.settle_ms = Some(now - self.built_at_ms);
}
}
}
/// Frames per second from the interval EMA (0 before three frames).
#[must_use]
pub fn fps(&self) -> f64 {
if self.ema_ms > 0.0 { 1000.0 / self.ema_ms } else { 0.0 }
}
#[must_use]
pub fn json(&self) -> serde_json::Value {
serde_json::json!({
"frames": self.frames,
"first_frame_ms": self.first_frame_ms,
"settle_ms": self.settle_ms,
"frame_ms": self.ema_ms,
"fps": self.fps(),
})
}
}
/// What the last generation cost and produced.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct GenStats {
pub nodes: usize,
pub edges: usize,
pub gen_ms: f64,
pub build_ms: f64,
/// How many times the model was (re)built — one per change, never per frame.
pub builds: u64,
}
/// The size + data + seed selector. Pure state + one `ui` call; the owner of the model
/// ([`GenTab`]) does the work when [`take_request`](Self::take_request) says so.
#[derive(Clone, Debug)]
pub struct GenControl {
pub source: Source,
pub size: usize,
pub seed: u64,
/// The largest size this tab's component takes.
pub max: usize,
/// Sources this tab offers (a typed tab may refuse some).
sources: &'static [Source],
pending: bool,
pub stats: GenStats,
}
impl GenControl {
/// A control over every source up to `max` nodes, opening on `demo data`.
#[must_use]
pub fn new(max: usize) -> Self {
Self { source: Source::Demo, size: 10_000.min(max), seed: 1, max, sources: &Source::ALL, pending: false, stats: GenStats::default() }
}
/// Open on `source` at `size` (generated on the first frame the tab is drawn).
#[must_use]
pub fn starting_at(mut self, source: Source, size: usize) -> Self {
self.source = source;
self.size = size.min(self.max);
self.pending = source != Source::Demo;
self
}
/// Restrict the offered sources (`Demo` is always offered when the list holds it).
#[must_use]
pub fn with_sources(mut self, sources: &'static [Source]) -> Self {
self.sources = sources;
self
}
/// The sizes this tab offers.
pub fn sizes(&self) -> impl Iterator<Item = usize> + '_ {
SIZES.into_iter().filter(|&s| s <= self.max)
}
/// Ask for `(source, size, seed)`; returns whether anything changed. Out-of-range
/// sizes are clamped to the ceiling.
pub fn set(&mut self, source: Source, size: usize, seed: u64) -> bool {
let size = size.clamp(1, self.max);
let changed = (source, size, seed) != (self.source, self.size, self.seed);
if changed {
self.source = source;
self.size = size;
self.seed = seed;
self.pending = true;
}
changed
}
/// `true` once per change: the caller builds now and clears the flag.
pub fn take_request(&mut self) -> bool {
std::mem::take(&mut self.pending)
}
/// Is a rebuild waiting for the next frame?
#[must_use]
pub fn pending(&self) -> bool {
self.pending
}
/// Draw the row. Returns `true` when the user changed something (a rebuild is
/// pending; it runs on the next frame so the "generating…" line gets painted).
pub fn ui(&mut self, ui: &mut egui::Ui) -> bool {
let (mut source, mut size, mut seed) = (self.source, self.size, self.seed);
let th = facett_core::theme(ui);
ui.horizontal_wrapped(|ui| {
ui.label(egui::RichText::new("data").color(th.text_dim));
egui::ComboBox::from_id_salt(ui.id().with("gen-source"))
.selected_text(source.label())
.show_ui(ui, |ui| {
for s in self.sources {
ui.selectable_value(&mut source, *s, s.label());
}
});
ui.add_enabled_ui(source != Source::Demo, |ui| {
ui.label(egui::RichText::new("size").color(th.text_dim));
for s in self.sizes() {
ui.selectable_value(&mut size, s, size_label(s));
}
ui.label(egui::RichText::new("seed").color(th.text_dim));
ui.add(egui::DragValue::new(&mut seed).range(0..=9_999));
});
ui.separator();
let line = if self.pending {
format!("generating {} ...", size_label(size))
} else if self.stats.builds == 0 || self.source == Source::Demo {
"the tab's own data".to_string()
} else {
format!(
"{} nodes · {} edges · gen {:.0} ms · build {:.0} ms",
self.stats.nodes, self.stats.edges, self.stats.gen_ms, self.stats.build_ms
)
};
ui.label(egui::RichText::new(line).color(th.text_dim));
});
let changed = self.set(source, size, seed);
if changed {
ui.ctx().request_repaint();
}
changed
}
#[must_use]
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"source": self.source.key(),
"size": self.size,
"seed": self.seed,
"max": self.max,
"sizes": self.sizes().collect::<Vec<_>>(),
"sources": self.sources.iter().map(|s| s.key()).collect::<Vec<_>>(),
"pending": self.pending,
"nodes": self.stats.nodes,
"edges": self.stats.edges,
"gen_ms": self.stats.gen_ms,
"build_ms": self.stats.build_ms,
"builds": self.stats.builds,
})
}
/// Apply a `{"source":…, "size":…, "seed":…}` object (any subset). Returns whether
/// anything changed; an unknown source key is refused (`false`, nothing moves).
pub fn apply_json(&mut self, v: &serde_json::Value) -> bool {
let source = match v.get("source").and_then(|s| s.as_str()) {
Some(k) => match Source::from_key(k) {
Some(s) if self.sources.contains(&s) => s,
_ => return false,
},
None => self.source,
};
let size = v.get("size").and_then(serde_json::Value::as_u64).map_or(self.size, |s| s as usize);
let seed = v.get("seed").and_then(serde_json::Value::as_u64).unwrap_or(self.seed);
self.set(source, size, seed)
}
}
/// Every generated source (no `demo data`): what a tab built from a request offers.
pub const GENERATED_SOURCES: [Source; 8] = [
Source::Population,
Source::Gen(GenKind::Community),
Source::Gen(GenKind::Sbm),
Source::Gen(GenKind::Random),
Source::Gen(GenKind::ScaleFree),
Source::Gen(GenKind::Grid),
Source::Gen(GenKind::Tree),
Source::Gen(GenKind::Crates),
];
/// **A generator request** — `(source, size, seed)`, the one shape every host hands in:
/// facett-demo's deep link `?gen=sbm:100000:1` / `$FACETT_DEMO_GEN`, a tab's
/// `update_json({"gen": …})`, and facet-wrapped's data seam `{"generate": …}` (the
/// Streamlit gallery's picker). The graph is generated from it IN RUST, where the
/// component runs — a host never ships 100 000 nodes as JSON.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct GenRequest {
pub source: Source,
pub size: usize,
pub seed: u64,
}
impl GenRequest {
/// The defaults a request may omit: 10 000 nodes, seed 1.
pub const DEFAULT_SIZE: usize = 10_000;
pub const DEFAULT_SEED: u64 = 1;
/// Parse `{"source": "sbm", "size": 100000, "seed": 1}` (size and seed optional) or
/// the deep-link string `"sbm:100000:1"`. `Err` names what is wrong (an unknown
/// source lists the known ones), so a host can show it instead of a canvas.
pub fn parse(v: &serde_json::Value) -> Result<Self, String> {
if let Some(s) = v.as_str() {
return Self::from_link(s);
}
let Some(o) = v.as_object() else {
return Err(format!("a generator request is {{\"source\", \"size\", \"seed\"}} or \"<source>:<size>:<seed>\", not {v}"));
};
let key = o.get("source").and_then(serde_json::Value::as_str).unwrap_or("");
let source = Self::source(key)?;
let num = |k: &str| -> Result<Option<u64>, String> {
match o.get(k) {
None | Some(serde_json::Value::Null) => Ok(None),
Some(x) => x.as_u64().map(Some).ok_or_else(|| format!("generator `{k}` must be a whole number, not {x}")),
}
};
let size = num("size")?.map_or(Self::DEFAULT_SIZE, |n| n as usize);
let seed = num("seed")?.unwrap_or(Self::DEFAULT_SEED);
Ok(Self { source, size, seed })
}
/// Parse the deep-link form `<source>[:<size>[:<seed>]]`.
pub fn from_link(spec: &str) -> Result<Self, String> {
let mut it = spec.trim().split(':');
let source = Self::source(it.next().unwrap_or("").trim())?;
let mut num = |what: &str, d: u64| -> Result<u64, String> {
match it.next() {
None => Ok(d),
Some(x) => x.trim().parse::<u64>().map_err(|_| format!("generator {what} `{x}` in `{spec}` is not a whole number")),
}
};
let size = num("size", Self::DEFAULT_SIZE as u64)? as usize;
let seed = num("seed", Self::DEFAULT_SEED)?;
Ok(Self { source, size, seed })
}
fn source(key: &str) -> Result<Source, String> {
Source::from_key(key).ok_or_else(|| {
let known: Vec<&str> = Source::ALL.iter().map(|s| s.key()).collect();
format!("unknown generator source `{key}` (known: {})", known.join(", "))
})
}
/// The `{"source", "size", "seed"}` object (what `update_json({"gen": …})` takes).
#[must_use]
pub fn json(&self) -> serde_json::Value {
serde_json::json!({ "source": self.source.key(), "size": self.size, "seed": self.seed })
}
}
/// The deep link `<source>[:<size>[:<seed>]]` as the `{"source", …}` object a tab's
/// `update_json({"gen": …})` takes — only the parts the link names, so an omitted size
/// keeps the tab's own. `None` on a malformed value.
#[must_use]
pub fn gen_link_json(spec: &str) -> Option<serde_json::Value> {
let spec = spec.trim();
if spec.is_empty() {
return None;
}
let mut it = spec.split(':');
let mut g = serde_json::json!({ "source": it.next().unwrap_or("") });
if let Some(n) = it.next() {
g["size"] = n.trim().parse::<u64>().ok()?.into();
}
if let Some(s) = it.next() {
g["seed"] = s.trim().parse::<u64>().ok()?.into();
}
Some(g)
}
/// Builds a tab's component from a generated graph (the title is the deck key).
pub type BuildFn<C> = fn(&'static str, &GenGraph) -> C;
/// **The cheap state of a component at scale** — what a [`GenTab`] publishes instead of
/// the component's full `state_json` once the generated graph is bigger than
/// [`COMPACT_ABOVE`]. The wasm page publishes the whole demo's state EVERY frame
/// (`window.__facett_state()`), and a component's full state at 100 000 nodes is
/// 100 000 JSON objects plus whatever it derives from them — MEASURED natively
/// 2026-10-01 at 100k: decograph 1.0 s, graphpan 0.6 s, the navigator (no GPU) 11.8 s.
/// That would set the frame rate, not the GPU. The summary carries the counts and the
/// LANE readouts (which painter, which layout lane, still pending) — the numbers a
/// robot asserts at scale — and nothing per node.
pub trait GenSummary {
fn gen_summary(&self) -> serde_json::Value;
}
/// Above this many generated nodes a [`GenTab`] publishes its component's
/// [`GenSummary`], not its full state.
pub const COMPACT_ABOVE: usize = 5_000;
/// **A graph tab with the size + generator control** above its component.
///
/// It holds the tab's ORIGINAL component as the `demo data` source (so the default is
/// the tab exactly as it was) and a [`BuildFn`] that makes the same component type from
/// a generated graph. Everything else delegates to the component that is showing, and
/// its `state_json` gains a `gen` object (the control's state, what the build cost, the
/// app's frame timing).
pub struct GenTab<C> {
title: &'static str,
control: GenControl,
/// The component being shown.
inner: C,
/// The original (demo-data) component while a generated one is showing.
parked: Option<C>,
build: BuildFn<C>,
/// The app's frames since the last build (see [`FrameTiming`]).
timing: FrameTiming,
}
impl<C: Facet + GenSummary + 'static> GenTab<C> {
#[must_use]
pub fn new(title: &'static str, original: C, control: GenControl, build: BuildFn<C>) -> Self {
Self { title, control, inner: original, parked: None, build, timing: FrameTiming::default() }
}
/// **A tab built FROM a generator request** (facet-wrapped's `{"generate": …}` seam):
/// generates `req` now, builds the component from it, and offers only generated
/// sources (`sources` must not hold [`Source::Demo`] — there is no original to go
/// back to, and a fixture is never shown where generated data was asked for).
/// `None` for a [`Source::Demo`] request or a source `sources` does not offer.
#[must_use]
pub fn generated(
title: &'static str,
req: GenRequest,
max: usize,
sources: &'static [Source],
build: BuildFn<C>,
) -> Option<Self> {
if req.source == Source::Demo || !sources.contains(&req.source) {
return None;
}
let mut control = GenControl::new(max).with_sources(sources);
control.set(req.source, req.size, req.seed);
let _ = control.take_request();
let t0 = now_ms();
let gg = generate(control.source, control.size, control.seed)?;
let t1 = now_ms();
let inner = build(title, &gg);
let t2 = now_ms();
control.stats =
GenStats { nodes: gg.g.n, edges: gg.g.edges.len(), gen_ms: t1 - t0, build_ms: t2 - t1, builds: 1 };
Some(Self { title, control, inner, parked: None, build, timing: FrameTiming::start(t2) })
}
#[must_use]
pub fn control(&self) -> &GenControl {
&self.control
}
/// The component being shown.
pub fn inner_mut(&mut self) -> &mut C {
&mut self.inner
}
/// Set `(source, size, seed)` and rebuild NOW (the headless path). Returns whether
/// it changed anything.
pub fn select(&mut self, source: Source, size: usize, seed: u64) -> bool {
let changed = self.control.set(source, size, seed);
self.rebuild_if_requested();
changed
}
/// Does this tab publish the [`GenSummary`] (a generated graph above
/// [`COMPACT_ABOVE`])?
#[must_use]
pub fn compact(&self) -> bool {
self.control.source != Source::Demo && self.control.stats.nodes > COMPACT_ABOVE
}
/// Run the pending rebuild, if any: once per change.
fn rebuild_if_requested(&mut self) {
if !self.control.take_request() {
return;
}
match self.control.source {
Source::Demo => {
if let Some(orig) = self.parked.take() {
self.inner = orig;
}
self.control.stats = GenStats { builds: self.control.stats.builds, ..GenStats::default() };
}
source => {
let t0 = now_ms();
let Some(gg) = generate(source, self.control.size, self.control.seed) else { return };
let t1 = now_ms();
let built = (self.build)(self.title, &gg);
let t2 = now_ms();
let old = std::mem::replace(&mut self.inner, built);
if self.parked.is_none() {
self.parked = Some(old);
}
self.control.stats = GenStats {
nodes: gg.g.n,
edges: gg.g.edges.len(),
gen_ms: t1 - t0,
build_ms: t2 - t1,
builds: self.control.stats.builds + 1,
};
self.timing = FrameTiming::start(t2);
}
}
}
}
impl<C: Facet + GenSummary + 'static> Facet for GenTab<C> {
fn title(&self) -> &str {
self.title
}
fn ui(&mut self, ui: &mut egui::Ui) {
// A change made LAST frame is built now, before the control is drawn, so the
// control's line reports the finished build; a change made this frame is built
// on the next one (its "generating…" line paints first).
let was_pending = self.control.pending();
self.rebuild_if_requested();
if self.control.ui(ui) || was_pending {
ui.ctx().request_repaint();
}
ui.separator();
self.inner.ui(ui);
if self.control.source != Source::Demo {
self.timing.frame(now_ms());
}
}
fn state_json(&self) -> serde_json::Value {
let mut j = if self.compact() { self.inner.gen_summary() } else { self.inner.state_json() };
let mut g = self.control.state_json();
if let (Some(go), serde_json::Value::Object(t)) = (g.as_object_mut(), self.timing.json()) {
go.extend(t);
go.insert("compact".into(), self.compact().into());
}
match j.as_object_mut() {
Some(o) => {
o.insert("gen".into(), g);
}
None => j = serde_json::json!({ "inner": j, "gen": g }),
}
j
}
fn update_json(&mut self, msg_json: &str) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json)
&& let Some(g) = v.get("gen")
{
if self.control.apply_json(g) {
self.rebuild_if_requested();
}
return;
}
self.inner.update_json(msg_json);
}
fn component(&self) -> &'static str {
self.inner.component()
}
fn severity(&self) -> facett_core::Severity {
self.inner.severity()
}
fn caps(&self) -> FacetCaps {
self.inner.caps()
}
fn scale(&self) -> f32 {
self.inner.scale()
}
fn set_scale(&mut self, s: f32) {
self.inner.set_scale(s);
}
fn scale_range(&self) -> (f32, f32) {
self.inner.scale_range()
}
fn selection_json(&self) -> serde_json::Value {
self.inner.selection_json()
}
fn copy(&mut self) -> Option<String> {
self.inner.copy()
}
fn cut(&mut self) -> Option<String> {
self.inner.cut()
}
fn paste(&mut self, text: &str) -> bool {
self.inner.paste(text)
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
fn kind(&self) -> &'static str {
self.inner.kind()
}
fn portable_state(&self) -> Option<serde_json::Value> {
self.inner.portable_state()
}
fn load_state(&mut self, state: &serde_json::Value) -> bool {
self.inner.load_state(state)
}
}
// ── the summaries ───────────────────────────────────────────────────────────────────
/// The navigator's lane readouts, shared by `Graphs`, `lineage` and the bare navigator.
#[must_use]
pub fn nav_summary(nav: &facett_graphnav::GraphNav) -> serde_json::Value {
serde_json::json!({
"canvas": {
"layout_lane": nav.layout_lane(),
"layout_pending": nav.layout_pending(),
"layout_fallback": nav.layout_fallback(),
"layout_kernel": nav.layout_kernel(),
"visible_count": nav.visible_count(),
"total_nodes": nav.graph.nodes.len(),
"total_edges": nav.graph.edges.len(),
},
"legibility": { "opts": { "gpu_cloud": nav.legibility.gpu_cloud }, "frame": nav.legibility_frame_json() },
})
}
impl GenSummary for facett_lineage::LineageView {
fn gen_summary(&self) -> serde_json::Value {
let mut j = nav_summary(self.nav());
j["lineage"] = serde_json::json!({ "events": self.rows().len() });
j
}
}
impl GenSummary for facett_graphview::DecoratedGraphView {
fn gen_summary(&self) -> serde_json::Value {
let s = self.state();
serde_json::json!({
"node_count": s.model.nodes.len(),
"edge_count": s.model.edges.len(),
"zoom": s.zoom,
"selected": s.selected,
"renderer_installed": crate::universe::renderer_installed(),
"paint_rect": self.last_paint_rect().map(|r| [r.min.x, r.min.y, r.width(), r.height()]),
"legibility": self.legibility().state_json(),
})
}
}
impl GenSummary for facett_graphpan::GraphPanView {
fn gen_summary(&self) -> serde_json::Value {
let v = self.state();
serde_json::json!({
"zoom": v.zoom,
"selected": v.selected,
"gpu_opt_in": self.paint_opts().gpu_cloud,
"renderer_installed": crate::universe::renderer_installed(),
"legibility": self.legibility().state_json(),
})
}
}
impl GenSummary for facett_graphnav::GraphNav {
fn gen_summary(&self) -> serde_json::Value {
nav_summary(self)
}
}
// The coderoom: counts + the reused viz's lane readouts — not 100k crates and edges.
impl GenSummary for facett_coderoom_view::CoderoomPane {
fn gen_summary(&self) -> serde_json::Value {
let (n, m, k) = self.summary();
let g = self.graph();
serde_json::json!({
"kind": "coderoom",
"crate_count": n,
"edge_count": g.state().model.edges.len(),
"breaking_edges": m,
"impacted_count": k,
"target": self.target(),
"graph_nodes": g.state().model.nodes.len(),
"zoom": g.state().zoom,
"renderer_installed": crate::universe::renderer_installed(),
"paint_rect": g.last_paint_rect().map(|r| [r.min.x, r.min.y, r.width(), r.height()]),
"legibility": g.legibility().state_json(),
})
}
}
// The 2-D graph: lanes, camera and the stated a11y budget (`GraphView::scale_json`).
impl GenSummary for facett_graph::GraphView {
fn gen_summary(&self) -> serde_json::Value {
self.scale_json()
}
}
impl GenSummary for facett_graph3d::Graph3D {
fn gen_summary(&self) -> serde_json::Value {
// Graph3D's own state is already per-cloud, not per-node (measured 0 ms at 100k).
self.state_json()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn size_labels_read_right() {
assert_eq!(SIZES.map(size_label), ["100", "1k", "10k", "100k", "1M"]);
}
#[test]
fn population_is_cut_to_size_and_deterministic() {
let a = generate(Source::Population, 1_000, 5).unwrap();
let b = generate(Source::Population, 1_000, 5).unwrap();
assert_eq!(a.g.n, 1_000);
assert_eq!(a.g.fingerprint(), b.g.fingerprint());
assert_ne!(a.g.fingerprint(), generate(Source::Population, 1_000, 6).unwrap().g.fingerprint());
assert_eq!(a.label(0), "Person");
assert_eq!(a.rels.as_ref().unwrap().len(), a.g.edges.len());
assert!(a.g.pos.iter().all(|p| p[0].abs() <= 1.1 && p[1].abs() <= 1.1));
}
#[test]
fn every_lowering_keeps_the_counts() {
let gg = generate(Source::Gen(GenKind::Sbm), 500, 2).unwrap();
let (n, e) = (gg.g.n, gg.g.edges.len());
let m = to_pan_model(&gg);
assert_eq!((m.nodes.len(), m.edges.len()), (n, e));
let v = to_view_model(&gg);
assert_eq!((v.nodes.len(), v.edges.len()), (n, e));
let pg = to_propgraph(&gg);
assert_eq!(pg.nodes.len(), n);
let (nodes, edges, pos) = to_graph3d(&gg);
assert_eq!((nodes.len(), edges.len(), pos.len()), (n, e, n));
assert_eq!(to_lineage_rows(&gg).len(), e);
assert_eq!(to_places(&gg).len(), n);
}
#[test]
fn the_control_requests_one_build_per_change() {
let mut c = GenControl::new(100_000);
assert!(!c.take_request(), "demo data needs no build");
assert!(c.set(Source::Gen(GenKind::Grid), 1_000, 1));
assert!(c.take_request());
assert!(!c.take_request(), "once per change, not per frame");
assert!(!c.set(Source::Gen(GenKind::Grid), 1_000, 1), "same selection is no change");
c.set(Source::Gen(GenKind::Grid), 1_000_000, 1);
assert_eq!(c.size, 100_000, "clamped to the tab's ceiling");
assert_eq!(c.sizes().collect::<Vec<_>>(), [100, 1_000, 10_000, 100_000]);
assert!(!c.apply_json(&serde_json::json!({"source": "nope"})), "unknown source refused");
assert!(c.apply_json(&serde_json::json!({"source": "tree", "size": 100})));
assert_eq!((c.source, c.size), (Source::Gen(GenKind::Tree), 100));
}
fn decograph_tab() -> GenTab<facett_graphview::DecoratedGraphView> {
let orig = facett_graphview::DecoratedGraphView::local();
GenTab::new("decograph", orig, GenControl::new(100_000), crate::build::decograph)
}
/// A size/generator change REBUILDS the component (once), the same selection is
/// the same model (deterministic per seed), another seed is another model, and
/// `demo data` brings the original back.
#[test]
fn a_change_rebuilds_the_model_once_and_the_seed_decides_it() {
let mut t = decograph_tab();
let orig = t.state_json();
assert_eq!(orig["gen"]["source"], "demo");
assert!(t.select(Source::Gen(GenKind::ScaleFree), 1_000, 7));
let a = t.state_json();
assert_eq!(a["gen"]["nodes"], 1_000);
assert_eq!(a["gen"]["builds"], 1);
assert_ne!(a["nodes"], orig["nodes"], "the component holds the generated model");
assert!(!t.select(Source::Gen(GenKind::ScaleFree), 1_000, 7), "no change, no rebuild");
assert_eq!(t.state_json()["gen"]["builds"], 1);
let mut u = decograph_tab();
u.update_json(r#"{"gen":{"source":"scale-free","size":1000,"seed":7}}"#);
let b = u.state_json();
let strip = |mut v: serde_json::Value| {
v.as_object_mut().unwrap().remove("gen");
v
};
assert_eq!(strip(a.clone()), strip(b), "same (source, size, seed) → the same model, via the JSON path too");
u.update_json(r#"{"gen":{"seed":8}}"#);
assert_ne!(strip(a), strip(u.state_json()), "another seed → another model");
assert!(t.select(Source::Demo, 1_000, 7));
assert_eq!(strip(t.state_json()), strip(orig), "demo data restores the original component");
}
/// Above COMPACT_ABOVE a generated tab publishes the component's SUMMARY (counts +
/// lanes, nothing per node), and the summary is cheap enough to build every frame.
#[test]
fn a_big_generated_tab_publishes_its_summary() {
let mut t = decograph_tab();
t.select(Source::Gen(GenKind::Grid), 10_000, 1);
let s = t.state_json();
assert_eq!(s["gen"]["compact"], true);
assert_eq!(s["node_count"], 10_000);
assert!(s.get("nodes").is_none(), "no per-node array at scale");
assert!(s["legibility"].get("gpu_cloud").is_some(), "the lane readout survives");
let t0 = now_ms();
for _ in 0..100 {
let _ = t.state_json();
}
assert!(now_ms() - t0 < 2_000.0, "100 summaries at 10k nodes took {:.0} ms", now_ms() - t0);
}
/// The request shape every host hands in: the object and the deep-link string are
/// one request, defaults fill the gaps, and a bad source is refused BY NAME.
#[test]
fn a_generator_request_parses_both_shapes_and_refuses_by_name() {
let a = GenRequest::parse(&serde_json::json!({"source": "sbm", "size": 100_000, "seed": 1})).unwrap();
assert_eq!(a, GenRequest { source: Source::Gen(GenKind::Sbm), size: 100_000, seed: 1 });
assert_eq!(GenRequest::parse(&serde_json::json!("sbm:100000:1")).unwrap(), a);
assert_eq!(GenRequest::from_link("sbm:100000:1").unwrap(), a);
let d = GenRequest::parse(&serde_json::json!({"source": "crates"})).unwrap();
assert_eq!((d.size, d.seed), (GenRequest::DEFAULT_SIZE, GenRequest::DEFAULT_SEED));
let e = GenRequest::parse(&serde_json::json!({"source": "nope"})).unwrap_err();
assert!(e.contains("`nope`") && e.contains("sbm"), "{e}");
assert!(GenRequest::parse(&serde_json::json!({"source": "sbm", "size": "big"})).is_err());
assert!(GenRequest::from_link("sbm:lots").is_err());
assert_eq!(GenRequest::parse(&a.json()).unwrap(), a, "json() round-trips");
assert_eq!(gen_link_json("sbm:100000:1").unwrap(), serde_json::json!({"source": "sbm", "size": 100_000, "seed": 1}));
assert_eq!(gen_link_json("tree").unwrap(), serde_json::json!({"source": "tree"}));
assert!(gen_link_json("tree:x").is_none());
}
/// A tab built FROM a request holds the generated model at once (no fixture parked,
/// no `demo data` offered), is the same model the demo's control builds for the same
/// `(source, size, seed)`, and clamps to its ceiling.
#[test]
fn a_tab_built_from_a_request_is_the_demo_tabs_model() {
let req = GenRequest { source: Source::Gen(GenKind::ScaleFree), size: 1_000, seed: 7 };
let t = GenTab::generated("decograph", req, 100_000, &GENERATED_SOURCES, crate::build::decograph).unwrap();
let s = t.state_json();
assert_eq!(s["gen"]["nodes"], 1_000);
assert_eq!(s["gen"]["builds"], 1);
assert!(!s["gen"]["sources"].as_array().unwrap().iter().any(|k| k == "demo"), "no demo data on a generated tab");
let mut d = decograph_tab();
d.select(req.source, req.size, req.seed);
let strip = |mut v: serde_json::Value| {
v.as_object_mut().unwrap().remove("gen");
v
};
assert_eq!(strip(s), strip(d.state_json()), "the seam and the demo tab draw the same graph");
let big = GenRequest { size: 5_000_000, ..req };
let c = GenTab::generated("graphpan", big, FIX_CEILING, &GENERATED_SOURCES, crate::build::graphpan).unwrap();
assert_eq!(c.control().size, FIX_CEILING, "clamped to the component's ceiling");
assert!(
GenTab::generated("x", GenRequest { source: Source::Demo, ..req }, 10, &GENERATED_SOURCES, crate::build::graphpan).is_none(),
"demo data is not a generator"
);
}
const FIX_CEILING: usize = 1_000;
}