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

nordisk/facett

py/facett_demo/panes/gallery_pane.py

"""**facett-demo, in Streamlit.** Every wrapped component, rendered by the Rust wasm.

This is the showcase pane: it walks the manifest the INSTALLED wheel ships and mounts
each component on a WebGPU canvas with a real payload. It is the Streamlit twin of
the `facett-demo` wasm bundle, and it needs no korp-server, no database and no
network — which is the point. A demo that only works when a backend is up is a demo
nobody can show.

**There is no drawing code in this file, and there must never be.** The owner, 2026-09-05:
*"it must NOT use javascript to do graphics, it must REUSE the RUST components,
packaged in wasm. We had a real bad performance a few months ago when Claude
reimplemented components in JavaScript."* Every pixel below comes from
`facet_wrapped_bg.wasm`. This module chooses a name, hands over a payload, and writes
captions. No pandas, no plotly, no altair, no `st.line_chart` — those would each be a
facett component reimplemented by proxy.

The payloads come from :mod:`facett_demo.demo_payloads`, whose table is held
against the real registry by `xtask/tests/gallery_covers_the_manifest.rs`: every
shipped component is covered, and every payload is proven to BUILD through the same
adapter the wasm calls. So what this pane offers is exactly what will render.


**Size + generator (lane F, 2026-10-02).** A component whose manifest entry carries
`generate` (the graph family, `universe`, `decograph`) gets the demo's picker beside
it: data source, size, seed. The pick is sent as a GENERATOR REQUEST
(`{"generate": {"source", "size", "seed"}}`) and the graph is generated in the wasm by
the demo's own generators (`facett-graphgen`) — 100 000 nodes never cross as JSON from
here. The sources and sizes are read off the shipped manifest, so the picker offers
exactly what the component builds. `?gen=<source>:<size>:<seed>` preselects it.

**Groups like the demo.** The app's top tabs are facett-demo's tab groups (Grafer,
Kartor, …); :data:`GROUPS` maps each registry namespace to its demo group, and the app
puts each group's gallery components and its live korp pane under one tab.
"""

from __future__ import annotations

from typing import Any

import streamlit as st

from facett_demo.demo_payloads import is_fixture_only, payload_for
from facett_demo.facet_host import build_info, facett_component, manifest

#: Registry namespace -> facett-demo's tab group (`facett-demo/src/lib.rs` TAB_GROUPS),
#: in the demo's order. The registry's `data` namespace holds both the demo's `Diagram`
#: and `Tabeller` members (tables, charts, matrices), so it carries both names.
GROUPS: list[tuple[str, str]] = [
    ("graph", "🕸 Grafer"),
    ("map", "🗺 Kartor"),
    ("data", "📊 Tabeller & diagram"),
    ("ops", "⚙ Bygg & jobb"),
    ("ui", "🎛 App & UI"),
]

#: facett-demo's trailing group: anything the registry grows that GROUPS has not named.
REST = "Övrigt"

#: The picker's first choice for a component that also has a payload in the table.
GALLERY_DATA = "gallery payload"

#: How tall each surface gets. Maps and 3D want room; a stat grid does not.
_HEIGHTS: dict[str, int] = {
    "osm2d": 620,
    "osm3d": 620,
    "geomap": 560,
    "map": 520,
    "graph3d": 620,
    "graphnav": 640,
    "graphview": 560,
    "decograph": 600,
    "universe": 640,
    "graph2d": 540,
    "graphpan": 560,
    "lineage": 600,
    "pipeline": 480,
    "syschart": 520,
    "gantt": 460,
    "kanban": 520,
}
_DEFAULT_HEIGHT = 460


def _height(name: str) -> int:
    return _HEIGHTS.get(name, _DEFAULT_HEIGHT)


def _by_group(entries: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
    out: dict[str, list[dict[str, Any]]] = {}
    for c in entries:
        out.setdefault(c.get("group", "data"), []).append(c)
    return out


def top_groups(entries: list[dict[str, Any]] | None = None) -> list[tuple[str, str]]:
    """`(registry namespace, tab label)` for every namespace the shipped manifest uses,
    in the demo's order; namespaces GROUPS does not name share the trailing `Övrigt`
    tab (key `""`), shown rather than silently dropped."""
    grouped = _by_group(manifest() if entries is None else entries)
    out = [(key, label) for key, label in GROUPS if grouped.get(key)]
    if set(grouped) - {k for k, _ in GROUPS}:
        out.append(("", REST))
    return out


def _members(grouped: dict[str, list[dict[str, Any]]], key: str) -> list[dict[str, Any]]:
    if key:
        return grouped.get(key, [])
    known = {k for k, _ in GROUPS}
    return [c for g, cs in grouped.items() if g not in known for c in cs]


def parse_gen_link(spec: str) -> dict[str, Any] | None:
    """`?gen=<source>[:<size>[:<seed>]]` as `{"source", "size"?, "seed"?}` — the demo's
    deep-link shape (`facett_graphgen::gen_link_json` reads the same string in Rust;
    the wasm also accepts the string itself as `{"generate": "<link>"}`). ``None`` when
    malformed."""
    parts = [p.strip() for p in (spec or "").strip().split(":")]
    if not parts or not parts[0]:
        return None
    out: dict[str, Any] = {"source": parts[0]}
    try:
        if len(parts) > 1:
            out["size"] = int(parts[1])
        if len(parts) > 2:
            out["seed"] = int(parts[2])
    except ValueError:
        return None
    return out


def _size_label(n: int) -> str:
    if n >= 1_000_000 and n % 1_000_000 == 0:
        return f"{n // 1_000_000}M"
    if n >= 1_000 and n % 1_000 == 0:
        return f"{n // 1_000}k"
    return str(n)


def generator_request(component: dict[str, Any], *, key_prefix: str) -> dict[str, Any] | None:
    """Draw the size + generator picker for `component` (Streamlit widgets only — no
    drawing) and return the generator request it names, or ``None`` for the gallery's
    own payload. ``None`` too for a component the manifest gives no generator."""
    gen = component.get("generate")
    if not gen:
        return None
    name = component["name"]
    sources = [s["key"] for s in gen.get("sources", [])]
    labels = {s["key"]: s.get("label", s["key"]) for s in gen.get("sources", [])}
    sizes = [int(s) for s in gen.get("sizes", [])] or [int(gen.get("max", 1000))]
    table = payload_for(name)
    # A table payload that IS a generator request is that request's default, not a
    # second choice; a real data payload is offered first, as it was before.
    table_req = table.get("generate") if isinstance(table, dict) else None
    choices = ([GALLERY_DATA] if table is not None and table_req is None else []) + sources

    want = parse_gen_link(st.query_params.get("gen", "")) or {}
    if isinstance(table_req, dict):
        want = {**table_req, **want}
    src0 = want.get("source") if want.get("source") in sources else choices[0]
    # The nearest offered size to the asked one (the demo opens a tab at 10k).
    size0 = min(sizes, key=lambda s: abs(s - int(want.get("size", 10_000))))
    seed0 = int(want.get("seed", 1))

    source = st.selectbox(
        "data", choices, index=choices.index(src0), key=f"{key_prefix}-{name}-gen-source",
        format_func=lambda k: labels.get(k, k),
        help="the demo's generators (facett-graphgen), run in the wasm — nothing is sent but the request",
    )
    if source == GALLERY_DATA:
        return None
    size = st.select_slider(
        "size", options=sizes, value=size0, key=f"{key_prefix}-{name}-gen-size", format_func=_size_label,
        help=f"up to {_size_label(int(gen.get('max', sizes[-1])))} — this component's ceiling",
    )
    seed = st.number_input("seed", min_value=0, max_value=9_999, value=seed0, step=1, key=f"{key_prefix}-{name}-gen-seed")
    return {"generate": {"source": source, "size": int(size), "seed": int(seed)}}


def _mount(component: dict[str, Any], *, key_prefix: str) -> None:
    """Mount one component, and be honest about what it is showing."""
    name = component["name"]
    st.markdown(f"#### `{name}` — {component.get('title', name)}")
    st.caption(component.get("description", ""))

    if is_fixture_only(name):
        # NOT given a payload. Handing data to a component with no data seam would
        # make it render its own fixture while this page implied it was showing ours —
        # indistinguishable from success, which is why it is called out instead.
        st.info(
            f"`{name}` has no data seam in this build, so it is rendering **its own "
            f"fixture**. Nothing on this page was passed to it.",
            icon="ℹ️",
        )
        facett_component(name, height=_height(name), key=f"{key_prefix}-{name}")
        return

    if component.get("generate"):
        # The demo's size + generator, NEXT TO the component.
        side, main = st.columns([1, 4])
        with side:
            request = generator_request(component, key_prefix=key_prefix)
    else:
        main, request = st.container(), None
    payload = request if request is not None else payload_for(name)
    with main:
        if payload is None:
            # A data-driven component the payload table does not cover. Say so on the
            # page rather than raise: the rest of the gallery still mounts.
            st.error(f"`{name}` takes data and the gallery has no payload for it (demo_payloads.json)")
            return
        # A new request is a fresh frame (the key carries it), so the wasm generates the
        # new graph on a clean canvas instead of swapping 100k nodes under a live camera.
        key = f"{key_prefix}-{name}"
        if request is not None:
            g = request["generate"]
            key = f"{key}-{g['source']}-{g['size']}-{g['seed']}"
        facett_component(name, data=payload, height=_height(name), key=key)
        with st.expander(f"the payload `{name}` was mounted with", expanded=False):
            # `st.json` is a text viewer, not a chart — it draws no data graphics.
            st.json(payload)


def render_header() -> str:
    """The gallery's heading and its mode switch; returns the mode."""
    info = build_info()
    st.subheader("facett-demo — every wrapped component, drawn by the Rust wasm")
    st.caption(
        f"{info['components']} components from `facett {info['version']}` "
        f"· {info['wasm_bytes']:,} bytes of wasm · WebGPU, no WebGL, no JavaScript "
        f"renderer. Needs no server. Grouped like facett-demo's tabs."
    )
    return st.radio(
        "show",
        ["one at a time", "the whole group"],
        horizontal=True,
        key="gallery-mode",
        help=(
            "Each surface is a live WebGPU canvas. Mounting a whole group at once is "
            "several canvases on one page — fine on a desktop GPU, heavy on a laptop."
        ),
    )


def render_group(key: str, mode: str) -> None:
    """The gallery components of one group (`key` = registry namespace, `""` = Övrigt)."""
    grouped = _by_group(manifest())
    members = sorted(_members(grouped, key), key=lambda c: c["name"])
    if not members:
        st.info("this build ships no component in this group")
        return
    # `?pick=docview,model3d` preselects those components in their groups (one-at-a-time
    # mode): a link, or a headless browser with no way to click, can open the gallery on
    # a named component — e.g. one from each bundle group (core, docs, model3d).
    wanted = [n.strip() for v in st.query_params.get_all("pick") for n in v.split(",") if n.strip()]
    slot = key or "rest"
    if mode == "one at a time":
        names = [c["name"] for c in members]
        first = next((names.index(n) for n in wanted if n in names), 0)
        picked = st.selectbox(
            "component", names, index=first, key=f"gallery-pick-{slot}",
            format_func=lambda n: f"{n}{'  (fixture only)' if is_fixture_only(n) else ''}",
        )
        chosen = next(c for c in members if c["name"] == picked)
        _mount(chosen, key_prefix=f"one-{slot}")
    else:
        for c in members:
            _mount(c, key_prefix=f"all-{slot}")
            st.divider()


def render() -> None:
    """The whole gallery on its own (header + one tab per group) — for a host that
    mounts the gallery without the app's group tabs."""
    mode = render_header()
    groups = top_groups()
    for tab, (key, _) in zip(st.tabs([label for _, label in groups]), groups):
        with tab:
            render_group(key, mode)