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

nordisk/facett

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;
}