Skip to content

feat: add render_drawdata() for drawdata ScatterWidget/BarWidget - #361

Merged
cpsievert merged 8 commits into
mainfrom
render-drawdata
Oct 28, 2026
Merged

cpsievert merged 8 commits into
mainfrom
render-drawdata

Conversation

@cpsievert

@cpsievert cpsievert commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Part of #278.

Summary

@render_drawdata brings drawdata's drawing canvases into Shiny for Python: your users sketch a dataset directly in the app — brushing points of up to 4 classes with ScatterWidget, or sculpting histogram bars by dragging with BarWidget — and Python reacts to every stroke via reactive_read(). It's a natural fit for interactive ML demos: the example app re-fits a k-NN classifier on every stroke and renders the decision boundary beside the canvas.

import pandas as pd
from drawdata import ScatterWidget
from shiny import App, ui
from shinywidgets import output_widget, reactive_read, render_drawdata

app_ui = ui.page_fillable(output_widget("canvas"))

def server(input):
    @render_drawdata
    def canvas():
        return ScatterWidget(width=800, height=400, n_classes=2)

    @render.text
    def n():
        df = pd.DataFrame(reactive_read(canvas.widget, "data"))
        return f"{len(df)} points drawn"

app = App(app_ui, server)

Install with pip install 'shinywidgets[drawdata]', and see a fuller app in examples/drawdata/. Both widgets already render with plain @render_widget today — this renderer adds correct fill behavior and documents the caveats below.

What's interesting

The widgets now fill their container, losslessly. Upstream caps the display at the widgets' width/height traits — ScatterWidget's svg gets an inline max-width, and BarWidget's chart svg has fixed width/height attributes with no viewBox (it doesn't even shrink, and its default 550px height was clipped by the generic 400px flex-basis). Those traits really define the data coordinate space (drawn points are stored in viewBox coordinates), so the new drawdata behavior in js/src/fill.ts lifts the caps without distortion: it removes the inline/CSS width caps and injects a viewBox + width:100%/aspect-ratio into BarWidget's svg. Both widgets then fill their container while preserving aspect ratio (in a sized output — a fill layout or an explicit output_widget(height=) — the fit also constrains the chart's width by the height left after its controls, so it can't be clipped by the output's overflow: hidden), and d3's CTM-based pointer math keeps brushing/dragging accurate while scaled — verified in Chromium with circles landing under the cursor at 1.06x and bar drags syncing to Python at 0.5x. The constructor's width/height now choose the coordinate space and aspect ratio, not the display size.

Known limitations (upstream drawdata issues, not shinywidgets)

  • ScatterWidget syncs drawn data one batch behind: the first stroke never reaches Python until a second is drawn (the frontend mutates the model's data array in place, so deep-equal change detection sees no diff). This is drawdata#40, which reproduces identically in Jupyter; fix PR drawdata#41 is open upstream. Undo/reset sync immediately, and the example app notes the boundary updates one stroke behind.
  • BarWidget ignores Python-side updates and can't be seeded: it overwrites constructor-provided data at first render (so seeding is impossible, even via a re-render) and registers no change: handlers, so Python-side trait updates are dropped until the next re-render — re-render is the way to change n_bins, y_max, etc. This is drawdata#42; fix PR drawdata#44 is open upstream. Also avoid two BarWidgets on one page: their collection buttons switch each other's active collection (drawdata#43, also fixed by drawdata#44). Browser-to-Python sync works fine.
Implementation notes (for reviewers)
  • render_drawdata is a thin render_widget_base subclass bound to Union[ScatterWidget, BarWidget]; no coercion needed.
  • Also verified during the spike (real Chromium via Playwright): re-render lifecycle (no DOM/model leaks), hidden tabs, two-session isolation, the sizing matrix (fill card, explicit height, full-screen), and the data_as_pandas / data_as_X_y helpers.
  • Browser regression coverage: tests/playwright/render_drawdata/ asserts both widgets fit inside 1200px-wide explicit-height outputs and that a ScatterWidget fits a fill layout, aspect preserved.
  • The example app's k-NN grid computation is chunked with an argpartition top-k, so peak memory stays bounded as the drawing grows (Copilot review feedback).
  • Housekeeping: drawdata>=0.5 extra in pyproject.toml (plus test/dev groups), README extras-table row, CHANGELOG entry, and a unit test that both widgets keep the generic fill decision.

Verification

pip install 'shinywidgets[drawdata]' matplotlib numpy pandas
shiny run examples/drawdata/app.py

Draw points of both classes on the canvas; a k-NN classifier re-fits on every stroke and the decision boundary re-renders beside the canvas (one stroke behind, per the upstream caveat above). make check passes locally: ruff clean, pyright 0 errors, 574 unit tests passing (1 new).

The fill behavior was browser-verified in Chromium: ScatterWidget grows past its 800px trait width in a fill card (851px, aspect preserved) and circles land under the cursor while scaled; BarWidget shrinks into a narrow card with no overflow (injected viewBox) and bar drags sync to Python while scaled; both re-apply correctly after an input-driven re-render; a plain page_fluid renders both full-width.

Thin renderer for drawdata's anywidget widgets (koaning/drawdata), which
already work with render_widget() unmodified. Adds:

- render_drawdata renderer with docstring documenting upstream caveats
  (ScatterWidget syncs one drawing batch behind, koaning/drawdata#40;
  BarWidget overwrites constructor data and ignores Python-side trait
  updates until re-render)
- drawdata layout defaults: opt out of filling layouts since both widgets
  have intrinsic pixel sizes (and BarWidget's default 550px height clips
  at the generic 400px flex-basis)

Verified in a real browser (Playwright): drawing, undo/reset, class
selection, brush size, Python<->browser trait sync, re-render lifecycle
(no leaks), hidden tabs, multi-session isolation. Spike report:
docs/widget-reports/drawdata/report.md
Add the drawdata extra (pyproject + test/dev groups), the README extras
table row, a CHANGELOG entry, and an examples/drawdata app (draw a
dataset, watch a k-NN re-fit on every stroke).
Upstream, drawdata caps each widget's display at its width/height traits:
ScatterWidget's svg gets an inline max-width (and .dd-scatter-container a
CSS max-width of 1200px), and BarWidget's chart svg has fixed width/height
attributes with no viewBox (and is clipped by the generic 400px flex-basis).

Since those traits really define the *data coordinate space* (drawn points
are stored in viewBox coordinates), scale the display losslessly around an
unchanged viewBox instead: a JS-side behavior (js/src/fill.ts) removes the
inline/CSS width caps and injects a viewBox + width:100%/aspect-ratio into
BarWidget's svg, so both widgets fill their container's width while
preserving aspect ratio. d3 resolves pointer coordinates through the svg's
CTM, so brushing/dragging still works while scaled (verified in Chromium:
circles land under the cursor at 1.06x and bar drags at 0.5x, both sync to
Python, and the behavior re-applies after a re-render).

The Python-side fill=False opt-out is removed: the widgets now keep the
generic fill decision.

This comment was marked as resolved.

- examples/drawdata: bound k-NN peak memory (chunk the grid, use the
  |a-b|^2 expansion so no Nx3 broadcast intermediate) and select the k
  nearest neighbors with argpartition instead of fully sorting each row
- js/fill.ts: when the output's height is definite (fill layout or an
  explicit output_widget(height=)), also constrain the drawdata chart's
  width by the height left after its controls, so lifting the width cap
  can't make the chart taller than the output (which overflow:hidden
  would clip). BarWidget's chart svg is measured directly since it sits
  next to its controls in a classless container (drawdata 0.5.x).
- Add a Playwright regression test covering explicit-height outputs for
  both widgets plus a fill-layout ScatterWidget
- Seeding BarWidget data is impossible even via re-render (the frontend
  always resets bins at render); re-render is the update path for n_bins
  etc.
- Warn against multiple BarWidgets on one page (cross-widget active
  collection contamination)
@cpsievert
cpsievert marked this pull request as ready for review October 28, 2026 21:52
@cpsievert
cpsievert merged commit 5c067c4 into main Oct 28, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants