diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1c81f90..1f05188 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,3 +40,9 @@ jobs: - run: npm ci && npm run build - run: pip install -e ".[dev,lonboard]" - run: pytest -q + # The agent-skill API reference is generated from widget traits and + # committed; fail if it's stale (regenerate with `npm run skill:gen`). + - name: Skill reference is up to date + run: | + npm run skill:gen + git diff --exit-code src/manywidgets/skill/references/widgets-api.md diff --git a/.gitignore b/.gitignore index 9b62472..d135add 100644 --- a/.gitignore +++ b/.gitignore @@ -26,5 +26,9 @@ docs/plugin.mjs # (scripts/build_widget_docs.py) — build artifacts, not source. docs/widgets/*.ipynb +# Agent tooling: the skill ships in the package (src/manywidgets/skill/) and is +# installed locally with `manywidgets install-skill` — don't commit the copy. +.claude/ + # OS .DS_Store diff --git a/README.md b/README.md index c7f4c8a..50fcce8 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,22 @@ Binder(source=slider, source_field="value", multiplier=100, offset=200) ``` +## Agent skill + +manywidgets ships an [agent skill](src/manywidgets/skill/SKILL.md) so coding +agents can help you build widgets and dashboards. Install it into a location your +agent discovers: + +```bash +manywidgets install-skill # ./.claude/skills/manywidgets/ (this project) +manywidgets install-skill --user # ~/.claude/skills/manywidgets/ (all projects) +manywidgets install-skill --path DIR # anywhere else (other agents) +``` + +The skill (an entrypoint plus `references/` on widget API, usage, and authoring) +travels inside the wheel, so it always matches the installed version. The +per-widget API reference is generated from widget traits — never hand-edited. + ## How it works / design Every widget extends a thin `BaseWidget` (auto-assigns a stable `widget_id`) and @@ -94,7 +110,10 @@ Each widget owns its docs in `src/manywidgets//doc.md` (prose + a `{code-cell}` example + an `{api-table}` placeholder). `npm run docs:gen` builds `docs/widgets/.ipynb` from those — auto-generating the API table from trait introspection — so the per-widget pages are **generated build artifacts** -(gitignored), not hand-maintained. Build and view: +(gitignored), not hand-maintained. The agent skill's API reference is generated +the same way (`npm run skill:gen` → `src/manywidgets/skill/references/widgets-api.md`, +which **is** committed and CI-checked for drift) — regenerate it after changing +any widget's traits. Build and view: ```bash # lonboard + geopandas + pyarrow are only needed for the lonboard interop example diff --git a/package.json b/package.json index e4e3166..6914087 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "test": "vitest run", "test:watch": "vitest", "docs:gen": "python scripts/build_widget_docs.py", + "skill:gen": "python scripts/build_skill_reference.py", "serve": "python -m http.server -d docs/_build/html 3030", "clean": "rm -rf src/manywidgets/*/dist" }, diff --git a/pyproject.toml b/pyproject.toml index eae3598..4e8840e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,9 @@ dev = [ "ipykernel", ] +[project.scripts] +manywidgets = "manywidgets.__main__:main" + [project.urls] Homepage = "https://github.com/developmentseed/manywidgets" Repository = "https://github.com/developmentseed/manywidgets" diff --git a/scripts/build_skill_reference.py b/scripts/build_skill_reference.py new file mode 100644 index 0000000..1a244c0 --- /dev/null +++ b/scripts/build_skill_reference.py @@ -0,0 +1,151 @@ +"""Generate the agent-skill API reference from widget trait introspection. + +Emits ``src/manywidgets/skill/references/widgets-api.md`` — a single, grouped +catalog with a per-widget trait table and a derived constructor signature. This +reuses the same introspection the docs builder uses (``public_traits`` / +``api_table`` / ``class_for`` from ``build_widget_docs``), so the reference can +never drift from the code. + +Unlike the per-widget doc notebooks (gitignored build artifacts), this file is +**committed** — it's small static markdown, and committing guarantees it ships +inside the wheel (so ``manywidgets install-skill`` works offline and the +reference always matches the installed version's API). + +Run: python scripts/build_skill_reference.py (with manywidgets importable) +CI regenerates this and fails on a dirty diff, so keep it committed and current. +""" + +from __future__ import annotations + +import pathlib + +import traitlets + +# Sibling module in scripts/; sys.path[0] is this script's dir when run directly. +from build_widget_docs import api_table, class_for, public_traits + +ROOT = pathlib.Path(__file__).resolve().parent.parent +WIDGETS_SRC = ROOT / "src" / "manywidgets" +OUT = ROOT / "src" / "manywidgets" / "skill" / "references" / "widgets-api.md" + +# Display grouping for the catalog. Widget dir names -> section. Any widget dir +# discovered but not listed here lands in "Other" so new widgets never vanish. +GROUPS: list[tuple[str, list[str]]] = [ + ("Charts & displays", ["chart", "stat", "number_display", "text", "legend"]), + ("Input controls", ["slider", "range_slider", "dropdown", "toggle", "button", "number_input"]), + ("Layout containers", ["row", "column", "grid"]), + ("Linking", ["binder"]), + ("Lonboard interop", ["layer_toggle", "layer_filter", "filter_binder"]), +] + +# Containers take their children positionally as well as via children=[...]. +CONTAINERS = {"row", "column", "grid"} + + +def signature(name: str, cls) -> str: + """A keyword-arg constructor signature derived from the public traits. + + No-default traits come first so the rendered call is valid Python ordering + (required-looking args before defaulted kwargs). + """ + required, defaulted = [], [] + for tname, tr in public_traits(cls): + if tname == "widget_id": + continue + dv = tr.default_value + if dv is traitlets.Undefined: + required.append(tname) + else: + defaulted.append(f"{tname}={dv!r}") + cls_name = "".join(p.capitalize() for p in name.split("_")) + return f"{cls_name}({', '.join(required + defaulted)})" + + +def discover() -> dict[str, object]: + """dir name -> widget class, for every widget dir that imports.""" + found = {} + for doc_path in sorted(WIDGETS_SRC.rglob("doc.md")): + name = doc_path.parent.name + cls = class_for(name) + if cls is None: + print(f"skip {name} (class not importable — is the [lonboard] extra installed?)") + continue + found[name] = cls + return found + + +def main() -> int: + found = discover() + grouped = {g: [] for g, _ in GROUPS} + grouped["Other"] = [] + placed = set() + for group, names in GROUPS: + for name in names: + if name in found: + grouped[group].append(name) + placed.add(name) + for name in found: + if name not in placed: + grouped["Other"].append(name) + + lines = [ + "# manywidgets — widget API reference", + "", + "", + "", + "Every widget is constructed with its traits as keyword arguments " + "(e.g. `Slider(min=0, max=10, value=5)`). Layout containers (`Row`, " + "`Column`, `Grid`) also accept children positionally: `Row(a, b)`.", + "Display a widget by leaving it as the last expression in a notebook cell.", + "", + "## Catalog", + "", + ] + for group, _ in GROUPS: + if grouped[group]: + cls_names = ", ".join( + "`" + "".join(p.capitalize() for p in n.split("_")) + "`" + for n in grouped[group] + ) + lines.append(f"- **{group}:** {cls_names}") + if grouped["Other"]: + cls_names = ", ".join( + "`" + "".join(p.capitalize() for p in n.split("_")) + "`" + for n in grouped["Other"] + ) + lines.append(f"- **Other:** {cls_names}") + lines.append("") + + for group, _ in GROUPS: + names = grouped[group] + if not names: + continue + lines.append(f"## {group}") + lines.append("") + for name in names: + cls = found[name] + cls_name = "".join(p.capitalize() for p in name.split("_")) + lines.append(f"### `{cls_name}`") + lines.append("") + doc = (cls.__doc__ or "").strip().split("\n\n")[0].strip() + if doc: + lines.append(doc) + lines.append("") + lines.append("```python") + lines.append(signature(name, cls)) + lines.append("```") + lines.append("") + if name in CONTAINERS: + lines.append("Also: `%s(child1, child2, ...)` — children passed positionally." % cls_name) + lines.append("") + lines.append(api_table(cls)) + lines.append("") + + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text("\n".join(lines).rstrip() + "\n") + print(f"wrote {OUT.relative_to(ROOT)} ({len(found)} widgets)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/manywidgets/__main__.py b/src/manywidgets/__main__.py new file mode 100644 index 0000000..e4d2b44 --- /dev/null +++ b/src/manywidgets/__main__.py @@ -0,0 +1,107 @@ +"""``manywidgets`` command-line entry point. + +Currently exposes a single subcommand, ``install-skill``, which copies the +bundled agent skill (``manywidgets/skill/``) into a location your coding agent +discovers. Claude Code looks in ``.claude/skills/`` (project) and +``~/.claude/skills/`` (personal), but the skill is plain Markdown — use +``--path`` to drop it wherever another agent expects it. + + manywidgets install-skill # ./.claude/skills/manywidgets/ + manywidgets install-skill --user # ~/.claude/skills/manywidgets/ + manywidgets install-skill --path DIR # DIR/manywidgets/ + manywidgets install-skill --force # overwrite an existing copy +""" + +from __future__ import annotations + +import argparse +import pathlib +import shutil +import sys +from importlib import resources + + +def _bundled_skill_dir() -> pathlib.Path: + """Path to the skill files shipped inside the installed package.""" + return pathlib.Path(str(resources.files("manywidgets") / "skill")) + + +def _resolve_target(args: argparse.Namespace) -> pathlib.Path: + """Where the ``manywidgets/`` skill folder should be written.""" + if args.path: + base = pathlib.Path(args.path).expanduser() + elif args.user: + base = pathlib.Path.home() / ".claude" / "skills" + else: + base = pathlib.Path.cwd() / ".claude" / "skills" + return base / "manywidgets" + + +def install_skill(args: argparse.Namespace) -> int: + src = _bundled_skill_dir() + if not (src / "SKILL.md").is_file(): + print( + f"error: bundled skill not found at {src}. " + "Reinstall manywidgets, or run from a source checkout after " + "`npm run skill:gen`.", + file=sys.stderr, + ) + return 1 + + dest = _resolve_target(args) + if dest.exists(): + if not args.force: + print( + f"error: {dest} already exists. Re-run with --force to overwrite.", + file=sys.stderr, + ) + return 1 + shutil.rmtree(dest) + + dest.parent.mkdir(parents=True, exist_ok=True) + # Copy only the skill content (SKILL.md + references/*.md), not stray files. + shutil.copytree( + src, + dest, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), + ) + print(f"Installed manywidgets skill to {dest}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="manywidgets") + sub = parser.add_subparsers(dest="command", required=True) + + p = sub.add_parser( + "install-skill", + help="Copy the bundled agent skill into a discoverable location.", + description=install_skill.__doc__, + ) + where = p.add_mutually_exclusive_group() + where.add_argument( + "--user", + action="store_true", + help="Install to ~/.claude/skills/ instead of the current project.", + ) + where.add_argument( + "--path", + metavar="DIR", + help="Install under an arbitrary directory (for agents other than Claude).", + ) + p.add_argument( + "--force", + action="store_true", + help="Overwrite an existing installation.", + ) + p.set_defaults(func=install_skill) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/manywidgets/skill/SKILL.md b/src/manywidgets/skill/SKILL.md new file mode 100644 index 0000000..c7ae144 --- /dev/null +++ b/src/manywidgets/skill/SKILL.md @@ -0,0 +1,71 @@ +--- +name: manywidgets +description: >- + Build, lay out, and link manywidgets widgets in Jupyter notebooks — charts + (Chart), input controls (Slider, RangeSlider, Dropdown, Toggle, Button, + NumberInput), value displays (Stat, NumberDisplay, Text, Legend), layout + containers (Row, Column, Grid), the Binder linking primitive, and lonboard map + interop (LayerToggle, LayerFilter, FilterBinder). Use when constructing notebook + widgets or dashboards with manywidgets, composing layouts, linking widgets' + values, or authoring a new manywidgets widget. +--- + +# manywidgets + +`manywidgets` is a set of [anywidget](https://anywidget.dev)-based widgets for +data analysis and geospatial work in notebooks. Widgets are self-contained, +compose, link to each other, and render both in a live Jupyter kernel and +statically (no kernel) via the `myst-anywidget-static-export` MyST plugin. + +## Install + +```bash +pip install manywidgets +pip install "manywidgets[lonboard]" # optional lonboard map interop +``` + +## The import surface + +```python +from manywidgets import ( + Chart, # Chart.js charts + Slider, RangeSlider, Dropdown, Toggle, Button, NumberInput, # input controls + Stat, NumberDisplay, Text, Legend, # value displays + Row, Column, Grid, # layout containers + Binder, # linking w/ transforms +) +from manywidgets.lonboard import LayerToggle, LayerFilter, FilterBinder # optional +``` + +Construct a widget with its traits as keyword arguments and display it by leaving +it as the last expression in a cell: + +```python +Slider(label="Amplitude", min=0, max=5, value=1) +``` + +## Linking (two tools) + +- **`ipywidgets.jslink` / `jsdlink`** — pass-through link, "A's trait = B's trait". + The canonical, kernel-free choice; works live and statically. +- **`Binder`** — when you need a transform (`target = source*multiplier + offset`) + or a nested/dotted-path target (e.g. `view_state.zoom`) that jslink can't express. + +## Where to read more + +- **`references/widgets-api.md`** — every widget, its traits, defaults, and a + constructor signature. Auto-generated from the code, so it's authoritative. + Read this first when you need exact trait names or defaults. +- **`references/usage.md`** — how to display widgets, compose `Row`/`Column`/`Grid` + layouts, link widgets (`jslink`/`jsdlink`/`Binder`), drive charts, and handle + events (`Button.on_click`, `observe`). +- **`references/authoring.md`** — how to author a new manywidgets widget, including + the static-export safety rules you must follow. + +## Key facts + +- Every widget has an auto-assigned `widget_id`; `Binder` and cross-widget links + use it. You normally don't set it. +- Widgets are authored to survive **static export** (no kernel). Python-side + callbacks (`Button.on_click`, `observe`) only run with a live kernel; browser + links (`jslink`/`Binder`) keep working statically. diff --git a/src/manywidgets/skill/references/authoring.md b/src/manywidgets/skill/references/authoring.md new file mode 100644 index 0000000..a5cec06 --- /dev/null +++ b/src/manywidgets/skill/references/authoring.md @@ -0,0 +1,113 @@ +# Authoring a new manywidgets widget + +Every widget is a self-contained unit: a Python class, TypeScript source, a built +bundle, styles, and tests. The fastest way to make a new one is to **copy an +existing widget directory** and rename it. + +```bash +cp -r src/manywidgets/slider src/manywidgets/my_widget +``` + +``` +src/manywidgets/my_widget/ +├── __init__.py # re-export the class +├── widget.py # the BaseWidget subclass (traits + methods) +├── doc.md # the widget's docs (auto-assembled into the docs site) +├── src/index.ts # the render() function +├── style.css # styling via the _css trait +├── dist/widget.js # built by esbuild (gitignored) +└── tests/ + ├── test_my_widget.py # pytest + └── my_widget.test.ts # vitest + jsdom +``` + +Then: rename the class in `widget.py` / `__init__.py`, update `tests/` and +`doc.md`, add the class to `src/manywidgets/__init__.py`, and add the new +`dist/widget.js` path to the `ensured-targets` / `skip-if-exists` lists in +`pyproject.toml`. The build (`scripts/build.mjs`) auto-discovers any widget dir +with a `src/index.ts`. + +## The golden-example pattern + +`widget.py` subclasses `BaseWidget` (which auto-assigns `widget_id`) and points +`_esm` / `_css` at its own files via `asset(__file__, ...)`. Give every synced +trait a `help="…"` — the docs API table and the agent reference are generated +from it. + +```python +from __future__ import annotations +import traitlets +from .._base import BaseWidget, asset + +class MyWidget(BaseWidget): + """One-line summary (used in the generated reference).""" + _esm = asset(__file__, "dist", "widget.js") + _css = asset(__file__, "style.css") + value = traitlets.Float(0.0, help="Current value.").tag(sync=True) +``` + +`src/index.ts` exports `{ render }`, builds plain DOM (no frameworks), and imports +shared helpers from `@manywidgets/core`. + +## Static-export safety rules (must follow) + +manywidgets widgets render in a live kernel **and** statically (no kernel) via the +`myst-anywidget-static-export` plugin. To stay compatible: + +1. **Wrap every `model.save_changes()`** — use `safeSaveChanges(model)` from core + (there is no kernel statically). +2. **One listener per trait.** Use `onChanges(model, ["a", "b"], fn)` — never + `model.on("change:a change:b", fn)`. The static model emitter does not split + space-separated event names, so the combined form silently never fires. This is + the single most common static-export bug. +3. **Style via the `_css` trait** (or `ensureShadowCss` from core for libraries + that inject CSS at runtime) — never append a `` into the mount `el`. +4. **Vanilla DOM only** — no `createRoot(el)` that wipes shadow children. +5. **Stay buffer-free** for core widgets (JSON-serialisable traits). Binary traits + need an `nbclient` pre-execute step on export. +6. **Cross-widget access via `resolveModel`** from core — it resolves root widgets + by `widget_id` and fans writes out to sub-model proxies, handling both the + live-kernel and static-export cases. + +## Container widgets (rendering children) + +To render *other* widgets inside your DOM (how `Row`/`Column`/`Grid` work): + +```python +from ipywidgets import Widget, widget_serialization + +children = traitlets.List(trait=traitlets.Instance(Widget)).tag(sync=True, **widget_serialization) +_myst_child_traits = traitlets.List(["children"]).tag(sync=True) +``` + +```js +import { renderChild } from "@manywidgets/core"; +const cleanups = []; +for (const ref of model.get("children") || []) { + const cell = container.appendChild(document.createElement("div")); + cleanups.push(await renderChild(args, ref, cell)); // pass the whole render args +} +return () => cleanups.forEach((d) => d()); +``` + +`widget_serialization` serialises each child to an `IPY_MODEL_` ref; children +keep their own JS, CSS, and links. Requires plugin v0.2.0+ for static export. + +## Tests + +- `tests/test_.py` — traits, defaults, methods (pytest). +- `tests/.test.ts` — `render()` behaviour with the shared `fakeModel` from + `@manywidgets/test-utils`. The fake model mimics the **strict static emitter** + (exact event names only), so a regression to space-separated `on(...)` fails the + test automatically. + +Run them: `pytest` and `npm test`. After changing any trait, regenerate the agent +reference with `npm run skill:gen`. + +## Docs (`doc.md`) + +Each widget owns its docs. `scripts/build_widget_docs.py` turns `doc.md` into +`docs/widgets/.ipynb` (a gitignored build artifact). In `doc.md`: write +prose as Markdown, a runnable example as a ` ```{code-cell} python ` fence, and put +`{api-table}` where the trait table should go. Add the page to the `widgets:` toc +in `docs/myst.yml` and regenerate with `npm run docs:gen`. diff --git a/src/manywidgets/skill/references/usage.md b/src/manywidgets/skill/references/usage.md new file mode 100644 index 0000000..bd53794 --- /dev/null +++ b/src/manywidgets/skill/references/usage.md @@ -0,0 +1,143 @@ +# Using manywidgets + +Practical patterns for building notebook widgets and dashboards. For exact trait +names, defaults, and constructor signatures, see `widgets-api.md`. + +## Display + +A widget renders when it's the last expression in a cell, or via `display(w)`. +Mutating a synced trait after display updates the live widget: + +```python +from manywidgets import Slider +s = Slider(label="Amplitude", min=0, max=5, value=1) +s # renders here +# ... later cell: +s.value = 3 # updates the rendered widget +``` + +## Layout: Row, Column, Grid + +Containers take children **positionally** or via `children=[...]`, and keep them +fully interactive and linked (live and in static export). They nest freely. + +```python +from manywidgets import Row, Column, Grid, Stat, Chart, Slider + +Column( + Row(Stat(label="Revenue", value="$1.2M"), Stat(label="Users", value=8421), gap="16px"), + Chart(title="Trend", height=320), + gap="16px", +) +``` + +- `Row(*children, gap="8px", align="stretch")` — horizontal; `align` is CSS + `align-items`. +- `Column(*children, gap="8px", align="stretch")` — vertical. +- `Grid(*children, columns=2, gap="8px")` — N equal-width columns, row-major. + +A common dashboard shape is a `Column` of a `Row` of `Stat`s (a KPI strip), a +`Chart`, and a `Row`/`Column` of controls. Compose with nesting; tune spacing with +`gap`. + +## Linking widgets + +Two complementary tools — both work live and in static export. + +### jslink / jsdlink (same value) + +Use when two traits should hold the **same value**. + +```python +from ipywidgets import jslink, jsdlink +from manywidgets import Slider, Chart + +slider = Slider(label="Height", min=200, max=600, value=320) +chart = Chart(title="Linked", height=320) + +jsdlink((slider, "value"), (chart, "height")) # one-way: slider -> chart +# jslink((a, "value"), (b, "value")) # two-way +``` + +### Binder (transforms & nested paths) + +Use when jslink can't express the link: a linear transform, or writing a +dotted-path target. `Binder` computes `target = source*multiplier + offset`. + +```python +from manywidgets import Binder + +Binder(source=slider, source_field="value", + target=chart, target_field="height", + multiplier=100, offset=200) # height = value*100 + 200 + +# nested / dotted-path target (e.g. a lonboard map's view state): +Binder(source=zoom_slider, target=some_map, target_field="view_state.zoom") +``` + +`Binder` accepts widget instances (it reads their `widget_id`) or explicit id +strings. + +| Need | Use | +|---|---| +| Same value, A → B | `jsdlink` | +| Same value, A ↔ B | `jslink` | +| Scaled / offset value | `Binder` (`multiplier`/`offset`) | +| Write a nested dict key | `Binder` (dotted `target_field`) | + +## Charts + +`Chart` holds series; manage them with methods rather than setting `series_data` +directly: + +```python +import numpy as np +from manywidgets import Chart + +chart = Chart(title="Demo", x_label="x", y_label="y", height=320) +x = np.linspace(0, 10, 100) +chart.add_series(x=x, y=np.sin(x), name="sin") # line by default +chart.add_series(x=x, y=np.cos(x), name="cos", series_type="scatter") +# chart.update_series(0, x=x, y=np.sin(2*x)) +# chart.clear_series() +# chart.set_options(...) # extra Chart.js options +chart +``` + +`chart.clicked_point` / `chart.hover_point` are written from JS on interaction — +read them or `observe` them; don't pass them to the constructor. + +## Events (need a live kernel) + +Python-side callbacks only run with a live kernel (they're inert in static +export, though browser links still respond). + +```python +from manywidgets import Button, NumberDisplay + +btn = Button(label="+1") +count = NumberDisplay(value=0) + +def on_click(widget): + count.value = widget.clicks # widget.clicks auto-increments per click + +btn.on_click(on_click) +Row(btn, count) +``` + +Any synced trait can be watched with traitlets `observe`: + +```python +slider.observe(lambda change: print(change["new"]), names="value") +``` + +## Lonboard interop + +Optional (`pip install "manywidgets[lonboard]"`). Control a lonboard map's layers: + +- `LayerToggle(layer=..., label=...)` — show/hide a layer. +- `LayerFilter(layer=..., categories=[...])` — filter a layer by category. +- `FilterBinder(source=range_slider, layer=...)` — drive a layer's `filter_range` + from a `RangeSlider`'s low/high. + +See `widgets-api.md` for their traits. diff --git a/src/manywidgets/skill/references/widgets-api.md b/src/manywidgets/skill/references/widgets-api.md new file mode 100644 index 0000000..8e5497b --- /dev/null +++ b/src/manywidgets/skill/references/widgets-api.md @@ -0,0 +1,323 @@ +# manywidgets — widget API reference + + + +Every widget is constructed with its traits as keyword arguments (e.g. `Slider(min=0, max=10, value=5)`). Layout containers (`Row`, `Column`, `Grid`) also accept children positionally: `Row(a, b)`. +Display a widget by leaving it as the last expression in a notebook cell. + +## Catalog + +- **Charts & displays:** `Chart`, `Stat`, `NumberDisplay`, `Text`, `Legend` +- **Input controls:** `Slider`, `RangeSlider`, `Dropdown`, `Toggle`, `Button`, `NumberInput` +- **Layout containers:** `Row`, `Column`, `Grid` +- **Linking:** `Binder` +- **Lonboard interop:** `LayerToggle`, `LayerFilter`, `FilterBinder` + +## Charts & displays + +### `Chart` + +An interactive Chart.js chart. + +```python +Chart(series_data, chart_options, clicked_point, hover_point, chart_type='line', width=800, height=400, title='', x_label='', y_label='', animation_enabled=True, tooltips_enabled=True, legend_enabled=True) +``` + +| Trait | Type | Default | Description | +|---|---|---|---| +| `chart_type` | Unicode | `'line'` | Default series type (line, scatter, bar, …). | +| `series_data` | List | — | The chart series (use add_series/update_series/clear_series). | +| `chart_options` | Dict | — | Extra Chart.js options, deep-merged into the defaults. | +| `width` | Int | `800` | Width in pixels. | +| `height` | Int | `400` | Height in pixels. | +| `title` | Unicode | `''` | Chart title. | +| `x_label` | Unicode | `''` | X-axis title. | +| `y_label` | Unicode | `''` | Y-axis title. | +| `animation_enabled` | Bool | `True` | Animate chart updates. | +| `tooltips_enabled` | Bool | `True` | Show hover tooltips. | +| `legend_enabled` | Bool | `True` | Show the legend. | +| `clicked_point` | Dict | — | Written from JS on click: {series, index, x, y, label}. | +| `hover_point` | Dict | — | Written from JS on hover: {series, index, x, y, label}. | +| `widget_id` | Unicode | `''` | Stable unique id used for cross-widget linking (auto-assigned). | + +### `Stat` + +A metric card with an optional signed delta. + +```python +Stat(label='', value='', unit='', delta=None) +``` + +| Trait | Type | Default | Description | +|---|---|---|---| +| `label` | Unicode | `''` | Metric label. | +| `value` | Any | `''` | Displayed value. | +| `unit` | Unicode | `''` | Unit shown after the value. | +| `delta` | Any | `None` | Signed change; coloured ▲ green / ▼ red. | +| `widget_id` | Unicode | `''` | Stable unique id used for cross-widget linking (auto-assigned). | + +### `NumberDisplay` + +An animated numeric readout. + +```python +NumberDisplay(value=0.0, format='{}', duration=600, label='') +``` + +| Trait | Type | Default | Description | +|---|---|---|---| +| `value` | Float | `0.0` | Target value (animates to it). | +| `format` | Unicode | `'{}'` | Format spec: "{}", "{:.1f}", "{:,}", "{:,.0f}". | +| `duration` | Int | `600` | Animation duration in ms (0 = instant). | +| `label` | Unicode | `''` | Label shown above the number. | +| `widget_id` | Unicode | `''` | Stable unique id used for cross-widget linking (auto-assigned). | + +### `Text` + +A text readout, optionally Markdown. + +```python +Text(value='', markdown=False) +``` + +| Trait | Type | Default | Description | +|---|---|---|---| +| `value` | Unicode | `''` | | +| `markdown` | Bool | `False` | | +| `widget_id` | Unicode | `''` | Stable unique id used for cross-widget linking (auto-assigned). | + +### `Legend` + +A discrete colour legend (swatch + label per entry). + +```python +Legend(entries, title='') +``` + +| Trait | Type | Default | Description | +|---|---|---|---| +| `entries` | List | — | Rows as [color, label]; color is [r,g,b]/[r,g,b,a] (0–255) or a CSS string. | +| `title` | Unicode | `''` | Optional legend title. | +| `widget_id` | Unicode | `''` | Stable unique id used for cross-widget linking (auto-assigned). | + +## Input controls + +### `Slider` + +A numeric slider with a value readout. + +```python +Slider(value=0.0, min=0.0, max=100.0, step=1.0, label='') +``` + +| Trait | Type | Default | Description | +|---|---|---|---| +| `value` | Float | `0.0` | Current value. | +| `min` | Float | `0.0` | Minimum value. | +| `max` | Float | `100.0` | Maximum value. | +| `step` | Float | `1.0` | Step size. | +| `label` | Unicode | `''` | Label shown above the slider. | +| `widget_id` | Unicode | `''` | Stable unique id used for cross-widget linking (auto-assigned). | + +### `RangeSlider` + +A dual-handle numeric range slider. + +```python +RangeSlider(low=0.0, high=100.0, min=0.0, max=100.0, step=1.0, label='') +``` + +| Trait | Type | Default | Description | +|---|---|---|---| +| `low` | Float | `0.0` | Lower handle (kept <= high). | +| `high` | Float | `100.0` | Upper handle (kept >= low). | +| `min` | Float | `0.0` | Minimum value. | +| `max` | Float | `100.0` | Maximum value. | +| `step` | Float | `1.0` | Step size. | +| `label` | Unicode | `''` | Label shown above the slider. | +| `widget_id` | Unicode | `''` | Stable unique id used for cross-widget linking (auto-assigned). | + +### `Dropdown` + +A ```` control. + +```python +NumberInput(value=0.0, min=None, max=None, step=1.0, label='') +``` + +| Trait | Type | Default | Description | +|---|---|---|---| +| `value` | Float | `0.0` | Current value. | +| `min` | Float | `None` | Optional minimum. | +| `max` | Float | `None` | Optional maximum. | +| `step` | Float | `1.0` | Step size. | +| `label` | Unicode | `''` | Label shown above the input. | +| `widget_id` | Unicode | `''` | Stable unique id used for cross-widget linking (auto-assigned). | + +## Layout containers + +### `Row` + +Arrange child widgets in a horizontal row. + +```python +Row(children, gap='8px', align='stretch') +``` + +Also: `Row(child1, child2, ...)` — children passed positionally. + +| Trait | Type | Default | Description | +|---|---|---|---| +| `children` | List | — | Child widgets, left to right. | +| `gap` | Unicode | `'8px'` | CSS gap between children. | +| `align` | Unicode | `'stretch'` | CSS align-items (cross axis). | +| `widget_id` | Unicode | `''` | Stable unique id used for cross-widget linking (auto-assigned). | + +### `Column` + +Arrange child widgets in a vertical column. + +```python +Column(children, gap='8px', align='stretch') +``` + +Also: `Column(child1, child2, ...)` — children passed positionally. + +| Trait | Type | Default | Description | +|---|---|---|---| +| `children` | List | — | Child widgets, top to bottom. | +| `gap` | Unicode | `'8px'` | CSS gap between children. | +| `align` | Unicode | `'stretch'` | CSS align-items (cross axis). | +| `widget_id` | Unicode | `''` | Stable unique id used for cross-widget linking (auto-assigned). | + +### `Grid` + +Arrange child widgets in an N-column grid. + +```python +Grid(children, columns=2, gap='8px') +``` + +Also: `Grid(child1, child2, ...)` — children passed positionally. + +| Trait | Type | Default | Description | +|---|---|---|---| +| `children` | List | — | Child widgets, in row-major order. | +| `columns` | Int | `2` | Number of equal-width columns. | +| `gap` | Unicode | `'8px'` | CSS gap between cells. | +| `widget_id` | Unicode | `''` | Stable unique id used for cross-widget linking (auto-assigned). | + +## Linking + +### `Binder` + +Link a source widget's trait to a target widget's trait, in the browser. + +```python +Binder(source_widget_id='', source_field='value', target_widget_id='', target_field='', multiplier=1.0, offset=0.0, label='') +``` + +| Trait | Type | Default | Description | +|---|---|---|---| +| `source_widget_id` | Unicode | `''` | widget_id of the source widget. | +| `source_field` | Unicode | `'value'` | Source trait to read. | +| `target_widget_id` | Unicode | `''` | widget_id of the target widget. | +| `target_field` | Unicode | `''` | Target trait/dotted-path to write (e.g. 'view_state.zoom'). | +| `multiplier` | Float | `1.0` | Linear transform multiplier. | +| `offset` | Float | `0.0` | Linear transform offset. | +| `label` | Unicode | `''` | Optional status label. | +| `widget_id` | Unicode | `''` | Stable unique id used for cross-widget linking (auto-assigned). | + +## Lonboard interop + +### `LayerToggle` + +Toggle a lonboard layer's visibility. + +```python +LayerToggle(layer, value=True, label='Layer') +``` + +| Trait | Type | Default | Description | +|---|---|---|---| +| `layer` | Instance | — | The lonboard layer to show/hide. | +| `value` | Bool | `True` | Desired layer visibility. | +| `label` | Unicode | `'Layer'` | Label next to the switch. | +| `widget_id` | Unicode | `''` | Stable unique id used for cross-widget linking (auto-assigned). | + +### `LayerFilter` + +Filter a lonboard layer by categorical value via checkboxes. + +```python +LayerFilter(layer, categories, value, label='Filter') +``` + +| Trait | Type | Default | Description | +|---|---|---|---| +| `layer` | Instance | — | The lonboard layer to filter. | +| `categories` | List | — | Scalars or [value, label] pairs, one per category. | +| `value` | List | — | Currently-enabled category values. | +| `label` | Unicode | `'Filter'` | Legend heading. | +| `widget_id` | Unicode | `''` | Stable unique id used for cross-widget linking (auto-assigned). | + +### `FilterBinder` + +Bind a (Range)Slider to a lonboard layer's ``filter_range``. + +```python +FilterBinder(source, layer, low_field='low', high_field='high', filter_field='filter_range', label='') +``` + +| Trait | Type | Default | Description | +|---|---|---|---| +| `source` | Instance | — | The slider providing low/high values. | +| `layer` | Instance | — | The lonboard layer to filter. | +| `low_field` | Unicode | `'low'` | Source trait for the low bound. | +| `high_field` | Unicode | `'high'` | Source trait for the high bound. | +| `filter_field` | Unicode | `'filter_range'` | Layer trait to write [low, high] to. | +| `label` | Unicode | `''` | Optional status label. | +| `widget_id` | Unicode | `''` | Stable unique id used for cross-widget linking (auto-assigned). |