Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
27319ef
sync option for cross-facet brushing; make fx, fy optional in the fil…
Fil Feb 20, 2026
7c710e9
`brushX` and `brushY`
Fil Feb 16, 2026
85228d4
words
Fil Feb 16, 2026
704a11e
tip+brush combination
Fil Feb 14, 2026
a49628f
no-tip
Fil Feb 14, 2026
a324c01
brush(data, options)
Fil Feb 16, 2026
84b0976
Merge branch 'fil/brush-dataless' into fil/brush-across-facets
Fil Feb 20, 2026
6495cab
Merge branch 'fil/brush-dataless' into fil/brush+tip
Fil Feb 20, 2026
84d9ece
Merge branch 'fil/brush-dataless' into fil/brush-x
Fil Feb 20, 2026
2591b79
Merge branch 'fil/brush-x' into fil/brush-data-options
Fil Feb 20, 2026
6701a3d
prettier
Fil Feb 20, 2026
620ead0
prettier
Fil Feb 20, 2026
d5af820
Merge branch 'fil/brush-x' into fil/brush-data-options
Fil Feb 20, 2026
8a0ceeb
default brush on 1-D example
Fil Feb 20, 2026
6ef6573
Merge branch 'fil/brush-x' into fil/brush-data-options
Fil Feb 20, 2026
4880701
Merge branch 'fil/brush+tip' into fil/brush-merge
Fil Feb 20, 2026
44a1e36
Merge branch 'fil/brush-x' into fil/brush-merge
Fil Feb 20, 2026
4f174c8
Merge branch 'fil/brush-data-options' into fil/brush-merge
Fil Feb 20, 2026
d4488ba
regenerate brushCrossFacet snapshot
Fil Feb 20, 2026
bd83a84
fix Brush1DOptions references
Fil Feb 20, 2026
e468497
cross-facet brushX with stocks
Fil Feb 20, 2026
9c81d85
brushX + line
Fil Feb 20, 2026
04746ca
Merge branch 'fil/brush-x' into fil/brush-data-options
Fil Feb 20, 2026
f25a940
Merge branch 'fil/brush-x' into fil/brush-merge
Fil Feb 20, 2026
b6eb54a
for 1-d brushes, removes the 1px inset on the non-data dimension
Fil Feb 21, 2026
3139166
Merge branch 'fil/brush-x' into fil/brush-data-options
Fil Feb 21, 2026
9b0b97e
Merge branch 'fil/brush-data-options' into fil/brush-merge
Fil Feb 21, 2026
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
143 changes: 143 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

Observable Plot is a JavaScript library for exploratory data visualization built on D3. It follows a "grammar of graphics" approach with scales and layered marks. The library is distributed as `@observablehq/plot` on npm.

## Development Commands

### Core Development

- `yarn test` - Run complete test suite (mocha, TypeScript, ESLint, Prettier)
- `yarn test:mocha` - Run only Mocha unit tests
- `yarn test:coverage` - Generate test coverage report with c8

### Build and Distribution

- `yarn prepublishOnly` - Build distribution files in `dist/` (UMD bundles)
- `rollup -c` - Manual build using Rollup configuration

### Code Quality

- `yarn test:lint` - Run ESLint
- `yarn test:prettier` - Check Prettier formatting
- `yarn prettier --write .` - Format code with Prettier
- `yarn test:tsc` - TypeScript type checking

### Testing Individual Files

```bash
yarn run mocha --conditions=mocha --parallel --watch test/marks/bar-test.js
```

## Architecture Overview

### Core Components

**Marks** (`src/marks/`): Visual primitives that render data

- Base class: `Mark` in `src/mark.js`
- 22+ mark types: area, bar, line, dot, text, geo, contour, etc.
- Lifecycle: `initialize()` → `filter()` → `scale()` → `render()`

**Scales** (`src/scales/`): Map data values to visual properties

- Registry in `src/scales/index.js` manages available scale types
- Auto-scaling with domain and range inference
- Scale functions created by `createScaleFunctions()`

**Transforms** (`src/transforms/`): Data transformations applied before rendering

- Basic: filter, sort, reverse, shuffle
- Statistical: bin, group, stack, window, normalize
- Spatial: dodge, hexbin, centroid
- Advanced: repel, tree operations

**Channels** (`src/channel.js`): Bind data fields to visual properties (x, y, color, size)

### Rendering Pipeline

1. Mark processing and flattening
2. Faceting (small multiples)
3. Channel collection from marks
4. Scale creation based on channels
5. Mark initialization with facet/channel info
6. Scale finalization and auto-scaling
7. Value computation with scales/projections
8. SVG rendering with faceting
9. Legend generation

## Testing Strategy

### Unit Tests

- Location: `test/*-test.js` files
- Framework: Mocha
- Focus: Specific API behavior and helper methods

### Snapshot Tests

- Location: `test/plots/*.ts` files
- Registry: `test/plots/index.ts`
- Expected SVG/HTML output in `test/output/`
- Deterministic: Use seeded random generators (e.g., `d3.randomLcg`)

### Adding New Snapshot Tests

1. Create new file in `test/plots/`
2. Export function from `test/plots/index.ts`
3. Run tests to generate snapshots
4. Use `git add` for new snapshots

### Regenerating Snapshots

```bash
rm -rf test/output
yarn test
```

## File Structure

```
src/
├── index.js # Main exports
├── plot.js # Core plot() function
├── mark.js # Base Mark class
├── channel.js # Channel system
├── scales.js & scales/ # Scale system
├── marks/ # Mark implementations
├── transforms/ # Data transformations
├── interactions/ # Interaction handlers
└── legends/ # Legend generation

test/
├── plots/ # Snapshot tests
├── data/ # Test datasets
├── output/ # Generated snapshots
└── *-test.js # Unit tests
```

## Key Dependencies

- **d3** (v7.9.0): Core dependency for visualization primitives
- **interval-tree-1d**: Spatial data structures
- **isoformat**: Date formatting

## Development Workflow

1. Use `yarn dev` for live development with visual feedback
2. Write unit tests for API changes
3. Add snapshot tests for visual features
4. Run `yarn test` before committing
5. Format code with Prettier
6. Update TypeScript definitions in `.d.ts` files
7. Consider updating documentation and CHANGELOG.md

## Architecture Patterns

- **Grammar of Graphics**: Layered marks with shared scales
- **Channel-Scale Architecture**: Automatic scale creation from data mappings
- **Transform Pipeline**: Composable data transformations
- **Extensible Design**: Clear base classes for new mark types
33 changes: 33 additions & 0 deletions SESSION-brush-crossfacet-2026-02-20.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Session: Cross-facet brushing

## Goal

Add `Plot.brush({sync: true})` to allow brushing across all facet panes simultaneously.

## Design decisions

- `sync: true` on the brush means the brush selection spans all facets
- When brushing in one facet, the same rectangle appears in all facets (programmatic sync via D3 brush.move)
- The dispatched value still includes `fx`/`fy` (the origin facet)
- The filter function's facet arguments are optional: omit them to select across all facets, pass them to restrict to the brushed facet
- Context/focus updates apply to ALL facets, not just the current one
- Closure `syncing` flag prevents recursive events from programmatic brush moves
- `brush.move()` with `sync: true` moves one node and lets the event handler sync the rest

## Changes

- `src/interactions/brush.js`: constructor accepts `{sync}` option, event handler branches for cross-facet behavior
- `src/interactions/brush.d.ts`: added `BrushOptions` interface with `sync` option, updated constructor and `brush()` signatures
- `test/plots/brush.ts`: added `brushCrossFacet` snapshot test (penguins, fx: species)
- `test/output/brushCrossFacet.html`: generated snapshot
- `test/brush-test.ts`: three new unit tests for optional facet args and cross-facet filter
- `docs/interactions/brush.md`: documented `sync: true` option and optional facet filter args

## Implementation notes

- Replaced local `clearing` with closure `syncing` + early return. Prevents all event types from recursive programmatic moves (the old `clearing` only guarded "start").
- For `sync: true` + `move()`: moves only the first brush node; the event handler syncs the rest.
- `filterSignature` keeps the original per-case signatures but facet args are optional: if `fx` or `fy` is undefined (not passed), the corresponding facet check is skipped. No facets → `(x, y)`.
- Option renamed from `facet: false` to `sync: true` (clearer intent).
- Hidden doc examples with `d3.timeout` must use `:::plot hidden` (not `defer`), otherwise the timeout doesn't fire.
- All 1288 tests pass, TypeScript compiles, ESLint + Prettier clean.
52 changes: 52 additions & 0 deletions SESSION-brush-data-options-2026-02-14.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Session: Brush data and options support

Date: 2026-02-14
Branch: fil/brush-data-options

## Goal

Make the Brush mark accept `(data, options)` like other marks (Dot, etc.), enabling:
- Styling the selection rectangle (fill, stroke, etc.)
- Declaring channel defaults (x, y, fx, fy) inherited by reactive filters
- Array-of-arrays data via `maybeTuple`
- Filtered data on `plot.value.data` when brush has data

## Changes

### `src/interactions/brush.js`
- Constructor accepts `(data, options)`, passes them to `super()`
- Declares x/y channels with `{scale, optional: true}`; fx/fy handled by Mark base class (not declared as channels, matching other marks)
- Stores `_selectionStyle` for fill/stroke overrides on `.selection` rect; applied via `applyAttr`
- `renderFilter` accepts `channelDefaults` spread before caller options
- `initialize()` captures arrayified data as `_data`, resolves FX/FY via `valueof`
- Render method includes `data` (filtered) in dispatched values
- Factory `brush(data, options)` applies `maybeTuple(x, y)` for `[[x,y]]` data

### `src/interactions/brush.d.ts`
- Added `BrushOptions` interface extending `MarkOptions` with x/y channels
- Updated `Brush` constructor and `brush()` factory signatures
- Added `data?: any[]` to `BrushValue`

### `docs/interactions/brush.md`
- Added sections for data and options, selection styling, filtered data
- Updated BrushValue documentation

### `test/plots/brush.ts`
- `brushBrutalist`: styled brush with `stroke: "currentColor"`, `strokeWidth: 3`, channel defaults inherited
- `brushCoordinates`: array-of-arrays data via `Plot.brush(data)` with `maybeTuple`

### `test/brush-test.ts`
- Unit tests for filtered data in value (array and generator data)
- Unit test for render transform composition

## Decisions

- Mark-level defaults use `fill: "none", stroke: "none"` — the `<g>` wrapper needs no fill/stroke
- Selection rect styling stored in `_selectionStyle` (raw user values) because Mark defaults are for the `<g>`, not `.selection`; applied via `applyAttr` for consistency
- fx/fy not declared as channels (matching other marks); resolved via `valueof` in `initialize()`
- Channel defaults spread before `...options` in `renderFilter` so caller overrides
- `data: any[]` on BrushValue since it's always the result of `Array.filter()`

## Verification

- `yarn test` passes (1288 tests, TypeScript, ESLint, Prettier all green)
28 changes: 28 additions & 0 deletions SESSION-brush-data-options-2026-02-15.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Session: Brush data and options support (continued)

Date: 2026-02-15
Branch: fil/brush-data-options

## Status

Continuing from the 2026-02-14 session. The previous session ran out of context; all changes were lost (not committed). The working tree is clean, brush.js is in its original (no data/options) state.

## Goal

Same as previous session — make the Brush mark accept `(data, options)`.

## Decisions from previous session (to re-apply)

- Defaults: `{ariaLabel: "brush", fill: "#777", fillOpacity: 0.3, stroke: "#fff"}` — match D3's `.selection` rect defaults exactly. Since Brush.render() doesn't call `applyIndirectStyles`, these don't leak to the outer `<g>`.
- No `_selectionStyle` — use `this.fill`, `this.stroke`, etc. directly via `applyAttr` on `.selection`.
- fx/fy not declared as channels (matching other marks); handled by Mark base class (`this.fx`/`this.fy`).
- Channel defaults (x, y, z, fx, fy) spread before `...options` in `renderFilter` so caller overrides.
- `dataify(data)` called in `super()` argument to arrayify generators once.
- `data` captured in closure variable at top of render to avoid `this` binding issue in d3 event handler.
- `filterData` is a higher-level function: `filter === true → data`, `filter === false → []`, otherwise `take(data, index.filter(filterIndex(filter)))`.
- FX/FY values accessed from `values.channels?.fx?.value` and `values.channels?.fy?.value`.
- filterSignature parameter name minification: not a concern since ES module isn't minified.

## Implementation needed

All changes from the plan file at `~/.claude/plans/rippling-soaring-fog.md`, incorporating the decisions above.
87 changes: 87 additions & 0 deletions SESSION-brush-dataless-2026-02-12.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Session: Data-less brush interaction

Date: 2026-02-12

## Goal

Port the Plot.brush prototype from the [data-less brush notebook](https://observablehq.com/@observablehq/plot-data-less-brush) into the Observable Plot repository.

## Decisions

- Brush is a `Mark` subclass (unlike pointer, which is a render transform), because it needs to render its own SVG element (d3.brush)
- File: `src/interactions/brush.js`, exported from `src/index.js`
- Constructor takes no arguments: `new Brush()` or `brush()`
- Three reactive render filters: `inactive` (visible by default), `context` (hidden by default, shows non-selected during brush), `focus` (hidden by default, shows selected during brush) — placed directly on the instance
- Private properties: `_brush` (d3 brush instance), `_brushNodes` (array of rendered SVG nodes)
- Dispatched value: `{x1, x2, y1, y2, filter, fx?, fy?}` where `filter(x, y, f1?, f2?)` tests point membership
- Filter signature varies by faceting: `(x, y)`, `(x, y, fx)`, `(x, y, fy)`, or `(x, y, fx, fy)`
- For geo projections, filter uses `projection.stream` to project lon/lat to pixels
- Facet data from `target.__data__` (set by Plot's `.datum(f)`)
- Event handler uses regular `function` (not arrow) so `this` is the DOM node for programmatic moves
- `event.sourceEvent.currentTarget` for user events; `this` for programmatic moves
- `clearing` flag prevents recursive event re-entry when clearing other facets
- Only clear other facets on trusted events (`event.sourceEvent`)
- **IIFE pattern for docs**: Hidden :::plot blocks use `((brush) => [...])(Plot.brush())` to create a fresh brush on each Vue re-render, avoiding stale `_brushNodes` accumulation
- **Preselection via d3.timeout**: Vue template expressions can't access window globals (`requestAnimationFrame`, `setTimeout`), so use `d3.timeout()` inside a comma operator expression
- **"Reactive marks"** is the term for brush.inactive/context/focus (not "companion marks")
- **Async geo data**: world and cities loaded via shallowRef + d3.json/csv in onMounted; geo section wrapped in `v-if` to avoid flash of empty chart
- **aria-label="brush"**: Added to the brush mark's `<g>` element

## Files created

- `src/interactions/brush.js` — main implementation
- `src/interactions/brush.d.ts` — TypeScript definitions with BrushValue interface
- `docs/interactions/brush.md` — documentation page
- `test/plots/brush.ts` — 9 snapshot tests with textarea logging
- `test/brush-test.ts` — 5 unit tests
- `test/output/brush*.html` — generated snapshots

## Files modified

- `src/index.js` — added `Brush, brush` export
- `src/index.d.ts` — added `export * from "./interactions/brush.js"`
- `test/plots/index.ts` — added brush.ts export
- `docs/features/interactions.md` — rewrote "Selecting" section with simple brush example
- `docs/.vitepress/config.ts` — added Brush to sidebar

## Key fixes during development

- Programmatic `d3.select(node).call(brush.move, …)` had no `sourceEvent`, so target was never assigned. Fixed: use `this` (the DOM node) as fallback.
- Recursive clear: `selectAll(nodes).call(brush.move, null)` triggers recursive start events that overwrite `target`/`currentNode`. Fixed with `clearing` flag.
- Clearing current node cancels programmatic move. Fixed: filter `_brushNodes` to exclude `currentNode` when clearing.
- Only clear on trusted events: programmatic moves don't need to clear others. Added `if (event.sourceEvent)` guard.
- `brushGeoUS` with `reflect-y` showed zero circles because y coordinates weren't negated. Fixed by negating `py` in pre-projection.
- Dots rendered above brush block pointer events. Fixed with `pointerEvents: "none"` on reactive marks.
- Vue parser can't handle block-body arrow functions `{ }` in :::plot blocks. Use concise arrow bodies or comma operator expressions instead.
- Vue template expressions don't have access to `requestAnimationFrame` or `setTimeout`. Use `d3.timeout()` instead.

## Snapshot tests

| Test | Description |
|------|-------------|
| brushSimple | Basic brush with dots, no reactive marks |
| brushDot | Penguins with inactive/context/focus, colored by species |
| brushFaceted | Penguins faceted by species (fx), with frame |
| brushFacetedFy | Penguins faceted by species (fy), with frame |
| brushFacetedFxFy | Penguins faceted by species (fx) and sex (fy), with frame |
| brushGeoUS | Pre-projected US state capitals with reflect-y |
| brushGeoWorld | World cities >500k with equal-earth projection |
| brushGeoWorldFaceted | World cities faceted by median population (fy), with frame |
| brushRandomNormal | 1000 seeded randomNormal [x, y] tuples |

## Known issues

- Re-render bug in brush.js: calling Plot.plot() multiple times with the same Brush instance accumulates stale `_brushNodes` and `updatePerFacet` entries. Workaround: use IIFE to create fresh brush each render. Not fixed in source.

## SSR fix (2026-02-20)

`:::plot hidden` examples with `brush.move()` crashed CI because:
1. During SSR, `d3.create("svg:g")` fails (no global `document`) — Vue catches this gracefully
2. `d3.timeout(() => brush.move({...}))` fires asynchronously via setTimeout after SSR
3. `brush.move()` was throwing `Error: No brush node found` — unhandled exception crashes Node

Fix: `brush.move()` now silently returns when no matching node is found, instead of throwing. Also simplified the hero example to use `brush.move()` instead of low-level `d3.select().call()`.

## Status

All 1284 tests pass (mocha, tsc, eslint, prettier).
Loading