Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/js-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: JS Build

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
build:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'

- name: Install esbuild
run: curl -fsSL https://esbuild.github.io/dl/v0.19.11 | sh

- name: Install dependencies
run: npm install

- name: Build JavaScript
run: make js-all

- name: Check bundle sizes
run: ls -lh drawdata/static/*.js
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -158,4 +158,6 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
esbuild
.DS_Stores
.DS_Stores
node_modules/
package-lock.json
20 changes: 17 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
.PHONY: js docs
.PHONY: js js-scatter js-bar js-all css docs

install:
install:
# install the build tool for JS written in Golang
curl -fsSL https://esbuild.github.io/dl/v0.19.11 | sh
npm install
uv pip install -e .
uv pip install twine wheel jupyterlab marimo

Expand All @@ -14,8 +15,21 @@ pypi:
js:
./esbuild --watch=forever --bundle --format=esm --outfile=drawdata/static/scatter_widget.js js/scatter_widget.js

js-scatter:
./esbuild --watch=forever --bundle --format=esm --jsx=automatic --outfile=drawdata/static/scatter_widget.js js/scatter_widget.jsx

js-bar:
./esbuild --watch=forever --bundle --format=esm --jsx=automatic --outfile=drawdata/static/bar_widget.js js/bar_widget.jsx

css:
npx tailwindcss -i js/styles.css -o drawdata/static/widget.css --minify

js-all: css
./esbuild --bundle --format=esm --jsx=automatic --minify --outfile=drawdata/static/scatter_widget.js js/scatter_widget.jsx
./esbuild --bundle --format=esm --jsx=automatic --minify --outfile=drawdata/static/bar_widget.js js/bar_widget.jsx

clean:
rm -rf .ipynb_checkpoints build dist drawdata.egg-info
rm -rf .ipynb_checkpoints build dist drawdata.egg-info node_modules


docs:
Expand Down
264 changes: 264 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
# Plan: React + Radix UI Migration with esbuild

## Overview

Migrate the JavaScript widget codebase from vanilla D3.js to React + Radix UI while maintaining esbuild as the bundler. The goal is to have reusable React components that integrate with the existing anywidget Python bindings.

## Current State

- **Build tool**: esbuild v0.19.11 (already in place)
- **JS framework**: Vanilla JavaScript + D3.v7
- **Widgets**: ScatterWidget, BarWidget (D3-based)
- **Output**: ESM modules in `drawdata/static/`
- **Python bindings**: anywidget-based classes

## Target State

- **Build tool**: esbuild (same, with React/JSX support)
- **JS framework**: React 18 + Radix UI
- **Visualization**: D3.v7 (kept for data viz, integrated with React)
- **Output**: ESM modules in `drawdata/static/` (same location)
- **Python bindings**: anywidget (unchanged)

---

## Implementation Steps

### Step 1: Set up package.json for React dependencies

Create a `package.json` with:
- react, react-dom (v18)
- @radix-ui/react-* (components as needed: slider, button, dropdown, etc.)
- D3.v7 as a proper npm dependency

```bash
npm init -y
npm install react react-dom
npm install @radix-ui/react-slider @radix-ui/react-button @radix-ui/themes
npm install d3
npm install --save-dev esbuild
```

### Step 2: Update Makefile with React/JSX esbuild configuration

Modify esbuild commands to support JSX:

```makefile
js-scatter:
./esbuild --watch=forever --bundle --format=esm \
--jsx=automatic \
--loader:.js=jsx \
--outfile=drawdata/static/scatter_widget.js \
js/scatter_widget.jsx

js-bar:
./esbuild --watch=forever --bundle --format=esm \
--jsx=automatic \
--loader:.js=jsx \
--outfile=drawdata/static/bar_widget.js \
js/bar_widget.jsx

js-all:
./esbuild --bundle --format=esm \
--jsx=automatic \
--loader:.js=jsx \
--outfile=drawdata/static/scatter_widget.js \
js/scatter_widget.jsx
./esbuild --bundle --format=esm \
--jsx=automatic \
--loader:.js=jsx \
--outfile=drawdata/static/bar_widget.js \
js/bar_widget.jsx
```

Key flags:
- `--jsx=automatic`: Uses React 17+ JSX transform (no manual import needed)
- `--loader:.js=jsx`: Treat .js files as JSX (or use .jsx extension)
- `--format=esm`: ESM output for anywidget compatibility

### Step 3: Create React/anywidget integration wrapper

Create `js/lib/anywidget-react.js`:

```javascript
import React from 'react';
import { createRoot } from 'react-dom/client';

export function createWidget(Component) {
return {
render({ model, el }) {
const root = createRoot(el);

// Render React component with model prop
root.render(<Component model={model} />);

// Return cleanup function
return () => root.unmount();
}
};
}
```

### Step 4: Create base Radix UI theme/component structure

Create `js/lib/theme.js` for shared Radix theming:

```javascript
import { Theme } from '@radix-ui/themes';
import '@radix-ui/themes/styles.css';

export function WidgetTheme({ children }) {
return (
<Theme accentColor="blue" radius="medium">
{children}
</Theme>
);
}
```

### Step 5: Convert ScatterWidget to React

Create `js/scatter_widget.jsx`:

```jsx
import React, { useRef, useEffect, useState, useCallback } from 'react';
import * as d3 from 'd3';
import * as Slider from '@radix-ui/react-slider';
import { createWidget, useModelState } from './lib/anywidget-react';
import { WidgetTheme } from './lib/theme';

function ScatterCanvas({ data, brushSize, selectedClass, onDraw }) {
const svgRef = useRef(null);
// D3 rendering logic here, integrated with React refs
// ...
}

function ScatterWidget({ model }) {
const [data, setData] = useModelState(model, 'data');
const [brushSize, setBrushSize] = useModelState(model, 'brushsize');
const [selectedClass, setSelectedClass] = useState('a');

return (
<WidgetTheme>
<div className="scatter-widget">
<ScatterCanvas
data={data}
brushSize={brushSize}
selectedClass={selectedClass}
onDraw={handleDraw}
/>
<Controls
brushSize={brushSize}
onBrushSizeChange={setBrushSize}
selectedClass={selectedClass}
onClassChange={setSelectedClass}
/>
</div>
</WidgetTheme>
);
}

export default createWidget(ScatterWidget);
```

### Step 6: Create shared Radix UI control components

Create `js/components/Controls.jsx`:

```jsx
import * as Slider from '@radix-ui/react-slider';
import * as ToggleGroup from '@radix-ui/react-toggle-group';

export function BrushSizeSlider({ value, onChange }) {
return (
<Slider.Root value={[value]} onValueChange={([v]) => onChange(v)}>
<Slider.Track>
<Slider.Range />
</Slider.Track>
<Slider.Thumb />
</Slider.Root>
);
}

export function ClassSelector({ value, onChange, classes }) {
return (
<ToggleGroup.Root type="single" value={value} onValueChange={onChange}>
{classes.map(cls => (
<ToggleGroup.Item key={cls.id} value={cls.id}>
{cls.label}
</ToggleGroup.Item>
))}
</ToggleGroup.Root>
);
}
```

### Step 7: Convert BarWidget to React

Similar pattern to ScatterWidget, using shared components.

### Step 8: Update CSS for Radix UI

- Keep existing CSS variables for theming
- Add Radix UI overrides for consistent styling
- Maintain dark/light mode support

### Step 9: Test integration

- Verify anywidget model sync works with React state
- Test in Jupyter, marimo, and VSCode
- Verify bundle size is reasonable

---

## File Structure After Migration

```
js/
├── lib/
│ ├── anywidget-react.js # React/anywidget bridge
│ └── theme.js # Radix theme wrapper
├── components/
│ ├── Controls.jsx # Shared control components
│ ├── BrushSizeSlider.jsx
│ ├── ClassSelector.jsx
│ └── Canvas.jsx # D3 canvas component
├── scatter_widget.jsx # Main scatter widget
├── bar_widget.jsx # Main bar widget
└── d3.v7.js # (removed, use npm instead)

drawdata/static/
├── scatter_widget.js # Bundled output
├── scatter_widget.css
├── bar_widget.js # Bundled output
└── bar_widget.css
```

---

## Key Decisions to Confirm

1. **Radix UI Themes vs Primitives?**
- Themes: Pre-styled, faster to implement
- Primitives: Unstyled, more control over look
- Recommendation: Start with Primitives for better control over existing styles

2. **Keep D3 for visualization?**
- Yes - D3 is still the best for data visualization
- React handles UI components, D3 handles the canvas/SVG

3. **JSX file extension?**
- Use `.jsx` for clarity, or configure esbuild to handle `.js` as JSX
- Recommendation: Use `.jsx` for React components

4. **State management approach?**
- Custom hook `useModelState` that syncs React state with anywidget model
- Keeps bidirectional sync working

---

## Questions for User

1. Do you want to use Radix Themes (pre-styled) or Radix Primitives (unstyled)?
2. Should we keep the existing CSS styling or redesign with Radix?
3. Are there specific Radix components you want to prioritize (Slider, Button, Dialog, etc.)?
13 changes: 8 additions & 5 deletions demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import marimo

__generated_with = "0.13.6"
__generated_with = "0.18.0"
app = marimo.App(width="medium")


Expand All @@ -24,13 +24,11 @@ def _():

@app.cell(hide_code=True)
def _(mo):
mo.md(
r"""
mo.md(r"""
# Drawing a `ScatterChart`

This notebook contains a demo of the `ScatterWidget` inside of the [drawdata](https://github.com/koaning/drawdata) library. You should see that as you draw data, that the chart below updates.
"""
)
""")
return


Expand Down Expand Up @@ -95,5 +93,10 @@ def _(mo, widget):
return


@app.cell
def _():
return


if __name__ == "__main__":
app.run()
4 changes: 2 additions & 2 deletions drawdata/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class ScatterWidget(anywidget.AnyWidget):
as your draw data.
"""
_esm = Path(__file__).parent / 'static' / 'scatter_widget.js'
_css = Path(__file__).parent / 'static' / 'scatter_widget.css'
_css = Path(__file__).parent / 'static' / 'widget.css'
data = traitlets.List([]).tag(sync=True)
brushsize = traitlets.Int(40).tag(sync=True)
width = traitlets.Int(800).tag(sync=True)
Expand Down Expand Up @@ -67,7 +67,7 @@ class BarWidget(anywidget.AnyWidget):
as your draw data.
"""
_esm = Path(__file__).parent / 'static' / 'bar_widget.js'
_css = Path(__file__).parent / 'static' / 'bar_widget.css'
_css = Path(__file__).parent / 'static' / 'widget.css'
data = traitlets.List([]).tag(sync=True)
y_min = traitlets.Float(0.0).tag(sync=True)
y_max = traitlets.Float(1.0).tag(sync=True)
Expand Down
Loading
Loading