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)