Metadata-Version: 2.5
Name: marimo-chem-widgets
Version: 0.5.0
Summary: anywidget structure viewers for marimo and Jupyter: a paginated grid and a chemical-space scatter plot
Project-URL: Homepage, https://github.com/PatWalters/marimo-chem-widgets
Project-URL: Repository, https://github.com/PatWalters/marimo-chem-widgets
Project-URL: Issues, https://github.com/PatWalters/marimo-chem-widgets/issues
Author: Patrick Walters
License-Expression: MIT
License-File: LICENSE
Keywords: anywidget,cheminformatics,chemistry,jupyter,marimo,rdkit,widget
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: Jupyter
Classifier: Intended Audience :: Science/Research
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Chemistry
Requires-Python: >=3.10
Requires-Dist: anywidget>=0.9
Requires-Dist: pandas>=1.5
Requires-Dist: rdkit>=2023.3.1
Provides-Extra: dev
Requires-Dist: marimo; extra == 'dev'
Requires-Dist: pytest; extra == 'dev'
Description-Content-Type: text/markdown

# marimo-chem-widgets

Structure viewers for chemistry notebooks, built as [anywidget](https://anywidget.dev)
widgets — they run in marimo, Jupyter, VS Code, and anywhere else ipywidgets render.

| Widget | What it is |
| --- | --- |
| `MolGrid` | a [mols2grid](https://github.com/cbouy/mols2grid)-style paginated grid of depictions |
| `MolScatter` | a chemical-space scatter plot whose hover tooltip is the structure |
| `MolList` | a scrollable single-select column of structures |
| `MolMasterDetail` | cluster centers on the left, their members on the right |

They compose: a scatter selection can feed a grid, and a master column can drive
one. Depictions are drawn by RDKit **in the kernel, on demand** — one page, one
hovered point — so a large DataFrame costs no more to display than a small one.

## Install

```bash
uv pip install git+https://github.com/PatWalters/marimo-chem-widgets   # from GitHub
uv pip install -e .                                                    # from a checkout
```

Needs Python 3.10+, `rdkit`, `pandas`, and `anywidget`. polars frames work too
(anything with a `to_pandas()`).

## Demo notebooks

Three demos come with it. Each one runs in the cloud on
[molab](https://molab.marimo.io) with nothing to install — click a badge:

| Notebook | What it shows | Run it |
| --- | --- | --- |
| [`demo_molgrid.py`](https://github.com/PatWalters/marimo-chem-widgets/blob/main/demo_molgrid.py) | the grid, 46 drugs | [![Open in molab](https://molab.marimo.io/molab-shield.png)](https://molab.marimo.io/github/PatWalters/marimo-chem-widgets/blob/main/demo_molgrid.py) |
| [`demo_molscatter.py`](https://github.com/PatWalters/marimo-chem-widgets/blob/main/demo_molscatter.py) | a 256-compound library in t-SNE space | [![Open in molab](https://molab.marimo.io/molab-shield.png)](https://molab.marimo.io/github/PatWalters/marimo-chem-widgets/blob/main/demo_molscatter.py) |
| [`demo_master_detail.py`](https://github.com/PatWalters/marimo-chem-widgets/blob/main/demo_master_detail.py) | Butina clusters &rarr; members | [![Open in molab](https://molab.marimo.io/molab-shield.png)](https://molab.marimo.io/github/PatWalters/marimo-chem-widgets/blob/main/demo_master_detail.py) |

Each notebook's script header installs the widgets straight from this repo, so
molab needs no setup. To run them from a checkout instead:

```bash
uv run marimo edit demo_molgrid.py         # the grid, 46 drugs
uv run marimo edit demo_molscatter.py      # a 256-compound library in t-SNE space
uv run marimo edit demo_master_detail.py   # Butina clusters -> members
```

## MolGrid

![MolGrid](https://raw.githubusercontent.com/PatWalters/marimo-chem-widgets/main/docs/molgrid.png)

```python
import marimo as mo
import pandas as pd
from marimo_chem_widgets import MolGrid

df = pd.read_csv("compounds.csv")   # SMILES, Name, MW, cLogP, ...

grid = mo.ui.anywidget(
    MolGrid(
        df,
        smiles_col="SMILES",
        subset=["Name", "MW"],           # printed under each structure
        tooltip=["cLogP", "TPSA"],       # revealed on hover
        n_cols=4,
        n_rows=3,                        # 12 per page; the footer paginates
        sort_by="MW",
        format={"MW": ".1f"},
    )
)
grid
```

Then, in another cell:

```python
grid.widget.get_selection()   # the rows the user clicked
```

### What it does

* **Pagination** — `n_rows` × `n_cols` per page, a pager for the rest. Only the
  visible page is drawn.
* **Selection** — click a card to toggle it; the selection survives paging,
  sorting, and searching. `get_selection()` returns those DataFrame rows.
* **Search** — substring search across the fields, or switch the toolbar
  dropdown to SMARTS for a substructure query, with the match highlighted in
  every depiction. A malformed SMARTS reports itself instead of throwing.
* **Sort** — any column, either direction, from the toolbar or from Python.
* **Alignment** — `align_smarts=` orients every depiction on a shared core,
  which is what makes an R-group series readable.
* **Coloring** — `color_by=` paints each card from one of its columns, with a
  colorbar or legend in the toolbar (see below).
* **Copy** — each card has a button that puts its SMILES on the clipboard.

### Arguments

| Argument | Default | Meaning |
| --- | --- | --- |
| `df` | — | pandas or polars DataFrame |
| `smiles_col` | `"SMILES"` | column holding SMILES strings |
| `mol_col` | `None` | column of RDKit `Mol` objects; wins over `smiles_col` |
| `subset` | first data column | fields printed under each structure |
| `tooltip` | everything else | fields shown on hover |
| `n_cols`, `n_rows` | `4`, `3` | page shape |
| `image_size` | `(200, 150)` | depiction size in pixels |
| `sort_by`, `sort_ascending` | `""`, `True` | initial sort |
| `align_smarts` | `None` | common core to orient depictions on |
| `format` | `None` | per-column format spec (`".1f"`) or callable |
| `color_by` | `None` | column to color the cards by |
| `color_mode` | `"footer"` | `"footer"`, `"tint"`, or `"border"` |
| `colormap` | `"viridis"` | ramp name or list of colors, for a numeric `color_by` |
| `palette` | Okabe-Ito | colors for a categorical `color_by` |
| `color_range` | `None` | `(vmin, vmax)` to fix the numeric scale |
| `selectable`, `selection` | `True`, `[]` | click-to-select and its initial state |
| `substruct_highlight` | `True` | highlight SMARTS matches in the depiction |

Methods: `get_selection()`, `get_filtered()`, `select_all()`,
`clear_selection()`, `set_dataframe()`, `refresh()`, plus the `dataframe`
property.

Every piece of state is a traitlet, so the grid can be driven from Python too:

```python
grid.widget.page = 2
grid.widget.search_mode = "smarts"
grid.widget.search = "c1ccc2ncncc2c1"
grid.widget.n_cols = 6
```

`set_dataframe()` swaps in a new set of molecules while keeping the layout and
fields — that is how the other widgets feed a live grid.

### Coloring cards by value

```python
MolGrid(
    df,
    subset=["Name", "pIC50"],
    color_by="pIC50",           # numeric -> ramp + colorbar
    color_mode="footer",        # "footer" | "tint" | "border"
    colormap="rdylgn",          # low red, high green
    color_range=(4, 9),         # optional: fix the scale
)
```

![colored cards](https://raw.githubusercontent.com/PatWalters/marimo-chem-widgets/main/docs/color_footer.png)

A numeric `color_by` gets a ramp and a colorbar; anything else is treated as
categorical and gets a legend. Built-in ramps are `viridis` (default), `magma`,
`rdylgn`, `rdbu`, and `blues`, or pass your own list of colors. Categorical
colors come from `palette` (Okabe-Ito by default).

The three modes differ only in where the color lands:

| `color_mode` | Effect |
| --- | --- |
| `"footer"` | the field strip under the structure; the text flips to light or dark for contrast |
| `"tint"` | the whole card, softly, with a stronger field strip |
| `"border"` | the card border |

The depiction panel is never painted — RDKit draws dark-on-light, and tinting
behind a structure costs more legibility than it buys.

The scale is fixed when the grid is built, so `set_dataframe()` recolors the new
rows **on the original scale** instead of rescaling to whatever is on screen —
which is what makes colors comparable when a scatter selection or a cluster
feeds the grid. Pass `color_range=(vmin, vmax)` to fix it explicitly, e.g. to
share one scale across several grids. A frame that doesn't carry the color
column simply isn't colored.

## MolScatter

![MolScatter](https://raw.githubusercontent.com/PatWalters/marimo-chem-widgets/main/docs/molscatter.png)

```python
from marimo_chem_widgets import MolGrid, MolScatter

scatter = mo.ui.anywidget(
    MolScatter(
        df,
        x="tsne_x",
        y="tsne_y",
        color_by="Scaffold",             # numeric -> viridis ramp + colorbar
        tooltip=["Name", "MW", "pIC50"],
        x_label="t-SNE 1",
        y_label="t-SNE 2",
    )
)
scatter
```

Any two columns work as coordinates: a t-SNE or UMAP embedding, two computed
properties, predicted vs. measured.

| Gesture | Effect |
| --- | --- |
| hover a point | draws that structure in a tooltip (cached after the first visit) |
| drag | box or lasso select, depending on the toolbar mode |
| shift+drag | add to the selection |
| click a point / empty space | toggle that point / clear |
| alt+drag, or pan mode | pan |
| wheel | zoom at the cursor |
| double-click, or **reset view** | back to the full extent |
| click a legend chip | select that whole series (shift to add) |

### Selection into a grid

The reactive way, in a downstream cell:

```python
scatter.value["selection"]                     # makes this cell reactive
mo.ui.anywidget(MolGrid(scatter.widget.get_selection(), subset=["Name"]))
```

Or link the two directly, in which case the grid updates in place with no cell
re-running — which is what you want when they sit side by side:

```python
scatter = MolScatter(df, x="tsne_x", y="tsne_y", mode="lasso")
grid = MolGrid(df, subset=["Name", "pIC50"], n_cols=3, n_rows=2)
scatter.link_grid(grid)

mo.hstack([scatter, grid])
```

### Arguments

| Argument | Default | Meaning |
| --- | --- | --- |
| `df` | — | pandas or polars DataFrame |
| `x`, `y` | `"x"`, `"y"` | coordinate columns |
| `smiles_col` / `mol_col` | `"SMILES"` / `None` | where the structures come from |
| `color_by` | `None` | numeric → viridis ramp + colorbar; anything else → categorical legend |
| `tooltip` | first 4 other columns | fields listed under the structure on hover |
| `width`, `height` | `700`, `480` | plot size in pixels |
| `point_size`, `opacity` | `4.5`, `0.85` | point appearance |
| `image_size` | `(280, 200)` | size of the tooltip depiction |
| `palette` | Okabe-Ito | colors for a categorical `color_by` |
| `colormap` | `"viridis"` | ramp name or list of colors, for a numeric `color_by` |
| `color_range` | `None` | `(vmin, vmax)` to fix the numeric scale |
| `format` | `None` | per-column format spec or callable |
| `x_label`, `y_label` | column names | axis labels |
| `mode` | `"box"` | `"box"`, `"lasso"`, or `"pan"` |

Methods: `get_selection()`, `select()`, `clear_selection()`, `link_grid(grid)`,
plus the `dataframe` and `selected_smiles` properties.

## MolList

A vertical, scrolling column of structures where exactly one row is current —
the master half of a master/detail view, and useful on its own as a picker.
Every row is drawn up front, so it is meant for tens to a few hundred
representatives, not a whole library.

```python
from marimo_chem_widgets import MolList

picker = mo.ui.anywidget(
    MolList(
        centers_df,
        subset=["Cluster", "Amine"],   # fields beside each structure
        badge_col="N",                 # pill in the corner
        width=240,
        height=420,
        selected=0,
    )
)
picker
```

```python
picker.value["selected"]          # row position, -1 for none
picker.widget.selected_row        # that row as a Series
```

Click a row to make it current; once the column has focus the up/down arrow keys
walk it.

### Arguments

| Argument | Default | Meaning |
| --- | --- | --- |
| `df` | — | pandas or polars DataFrame |
| `smiles_col` / `mol_col` | `"SMILES"` / `None` | where the structures come from |
| `subset` | first data column | fields beside each structure |
| `badge_col` | `None` | column rendered as a pill |
| `image_size` | `(150, 110)` | depiction size in pixels |
| `width`, `height` | `260`, `520` | list width, and the height it scrolls past |
| `selected` | `-1` | row current on first render |
| `align_smarts`, `format` | `None` | as elsewhere |

Methods: `select()`, `clear_selection()`, plus the `dataframe`, `selected_row`,
and `selected_smiles` properties.

## MolMasterDetail

![MolMasterDetail](https://raw.githubusercontent.com/PatWalters/marimo-chem-widgets/main/docs/master_detail.png)

```python
from marimo_chem_widgets import MolMasterDetail

view = MolMasterDetail(
    df,                        # SMILES, Name, MW, Cluster, is_center, ...
    group_col="Cluster",
    center_col="is_center",    # Butina's centroid, say; omit to use the first member
    master_subset=["Cluster", "Scaffold"],
    subset=["Name", "MW"],
    n_cols=3,
    n_rows=2,
)
view
```

Each master row carries its group's member count as a badge and groups are
ordered largest first. The detail side is an ordinary `MolGrid`, so it searches,
sorts, paginates, and its ticked structures come back through
`view.get_selection()`.

Representatives come from `center_col` (a boolean column), an explicit `centers`
frame, or — with neither — the first member of each group.

This one is a display helper rather than an `AnyWidget`: it owns two real
widgets and keeps them in step.

```python
view.master            # the MolList
view.grid              # the MolGrid
view.selected_group    # "C03"
view.get_detail()      # that group's rows
view.select_group("C07")
mo.hstack([view.master, view.grid])   # or lay them out yourself
```

Because it is a helper, a *cell* that reads `view.selected_group` reports what
was current when that cell last ran. For a cell that reacts to every click,
drive the two halves yourself:

```python
picker = mo.ui.anywidget(MolList(centers_df, subset=["Cluster"], badge_col="N"))

# ...in another cell
cluster = centers_df.Cluster[picker.value["selected"]]
mo.ui.anywidget(MolGrid(df[df.Cluster == cluster], subset=["Name"]))
```

### Arguments

| Argument | Default | Meaning |
| --- | --- | --- |
| `df` | — | every molecule, with a group column |
| `group_col` | — | the grouping column |
| `smiles_col` / `mol_col` | `"SMILES"` / `None` | where the structures come from |
| `center_col` | `None` | boolean column marking each group's representative |
| `centers` | `None` | an explicit representatives frame instead of `center_col` |
| `master_subset` | `[group_col]` | fields beside each master structure |
| `badge_col` | `"count"` | the pill on each master row |
| `sort_groups` | `"size"` | `"size"`, `"name"`, or `None` for order of appearance |
| `subset`, `tooltip`, `n_cols`, `n_rows`, `image_size`, `sort_by`, `sort_ascending`, `format`, `selectable` | — | forwarded to the detail grid |
| `color_by`, `color_mode`, `colormap`, `palette`, `color_range` | — | forwarded to the detail grid; the scale spans every group, so colors mean the same thing in each |
| `master_image_size`, `master_width`, `height` | `(150, 110)`, `250`, `520` | the master column's size |
| `align_smarts` | `None` | common core to orient every depiction on |
| `selected` | `0` | group showing on first render (`-1` for none) |

Attributes and methods: `master`, `grid`, `centers`, `dataframe`,
`selected_group`, `get_detail()`, `get_selection()`, `select_group()`,
`layout()`.

## Notes

* **Drawing is lazy where it matters.** `MolGrid` draws the current page,
  `MolScatter` draws the hovered molecule; both cache what they have drawn.
  `MolList` draws every row up front, which is why it is scoped to
  representatives.
* **Invalid SMILES are never dropped.** They get a placeholder tile, so row
  positions still line up with the DataFrame you passed in.
* **Selections are positional.** Row positions into the frame as given (its
  index is reset), so `get_selection()` is always `df.iloc[...]`.
* **Light and dark.** The chrome follows the notebook's own theme rather than the
  OS preference — marimo marks its theme inside the widget's shadow root, and
  the stylesheets honour that. The depiction panel deliberately stays light in
  both, because RDKit draws dark-on-light and recoloring the SVG would fight the
  element colors.
* **Structures are rendered server-side** as SVG by RDKit, so there is no
  JavaScript chemistry toolkit to load and depictions match what RDKit would
  give you anywhere else.

## Development

```
marimo_chem_widgets/
  _color.py         shared value-to-color mapping (ramps, palettes, legends)
  _draw.py          shared RDKit depiction helpers (parse, align, highlight, draw)
  mol_grid.py       MolGrid
  mol_scatter.py    MolScatter
  mol_list.py       MolList
  master_detail.py  MolMasterDetail
  static/           one .js + .css per widget
```

```bash
uv pip install -e ".[dev]"
python -m pytest -q          # 73 tests
```

The tests cover the Python side — payloads, paging, search, selection, coloring,
linking, and the constructor's error messages. The interactive behaviour lives in the
demo notebooks.

## License

MIT — see [LICENSE](https://github.com/PatWalters/marimo-chem-widgets/blob/main/LICENSE).
