Repository navigation
feat: add render_drawdata() for drawdata ScatterWidget/BarWidget - #361
Merged
Merged
Conversation
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.
- 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
marked this pull request as ready for review
October 28, 2026 21:52
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #278.
Summary
@render_drawdatabrings drawdata's drawing canvases into Shiny for Python: your users sketch a dataset directly in the app — brushing points of up to 4 classes withScatterWidget, or sculpting histogram bars by dragging withBarWidget— and Python reacts to every stroke viareactive_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.Install with
pip install 'shinywidgets[drawdata]', and see a fuller app inexamples/drawdata/. Both widgets already render with plain@render_widgettoday — 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/heighttraits —ScatterWidget's svg gets an inlinemax-width, andBarWidget's chart svg has fixedwidth/heightattributes with noviewBox(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 newdrawdatabehavior injs/src/fill.tslifts the caps without distortion: it removes the inline/CSS width caps and injects aviewBox+width:100%/aspect-ratiointoBarWidget's svg. Both widgets then fill their container while preserving aspect ratio (in a sized output — a fill layout or an explicitoutput_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'soverflow: 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'swidth/heightnow choose the coordinate space and aspect ratio, not the display size.Known limitations (upstream drawdata issues, not shinywidgets)
ScatterWidgetsyncs drawn data one batch behind: the first stroke never reaches Python until a second is drawn (the frontend mutates the model'sdataarray 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.BarWidgetignores Python-side updates and can't be seeded: it overwrites constructor-provideddataat first render (so seeding is impossible, even via a re-render) and registers nochange:handlers, so Python-side trait updates are dropped until the next re-render — re-render is the way to changen_bins,y_max, etc. This is drawdata#42; fix PR drawdata#44 is open upstream. Also avoid twoBarWidgets 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_drawdatais a thinrender_widget_basesubclass bound toUnion[ScatterWidget, BarWidget]; no coercion needed.data_as_pandas/data_as_X_yhelpers.tests/playwright/render_drawdata/asserts both widgets fit inside 1200px-wide explicit-height outputs and that aScatterWidgetfits a fill layout, aspect preserved.argpartitiontop-k, so peak memory stays bounded as the drawing grows (Copilot review feedback).drawdata>=0.5extra inpyproject.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.pyDraw 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 checkpasses locally: ruff clean, pyright 0 errors, 574 unit tests passing (1 new).The fill behavior was browser-verified in Chromium:
ScatterWidgetgrows past its 800px trait width in a fill card (851px, aspect preserved) and circles land under the cursor while scaled;BarWidgetshrinks into a narrow card with no overflow (injectedviewBox) and bar drags sync to Python while scaled; both re-apply correctly after an input-driven re-render; a plainpage_fluidrenders both full-width.