diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..39052937fd --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/SESSION-brush-crossfacet-2026-02-20.md b/SESSION-brush-crossfacet-2026-02-20.md new file mode 100644 index 0000000000..9ff8b10b35 --- /dev/null +++ b/SESSION-brush-crossfacet-2026-02-20.md @@ -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. diff --git a/SESSION-brush-data-options-2026-02-14.md b/SESSION-brush-data-options-2026-02-14.md new file mode 100644 index 0000000000..04a32bf08c --- /dev/null +++ b/SESSION-brush-data-options-2026-02-14.md @@ -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 `` wrapper needs no fill/stroke +- Selection rect styling stored in `_selectionStyle` (raw user values) because Mark defaults are for the ``, 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) diff --git a/SESSION-brush-data-options-2026-02-15.md b/SESSION-brush-data-options-2026-02-15.md new file mode 100644 index 0000000000..f167f88063 --- /dev/null +++ b/SESSION-brush-data-options-2026-02-15.md @@ -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 ``. +- 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. diff --git a/SESSION-brush-dataless-2026-02-12.md b/SESSION-brush-dataless-2026-02-12.md new file mode 100644 index 0000000000..bcca929eb7 --- /dev/null +++ b/SESSION-brush-dataless-2026-02-12.md @@ -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 `` 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). diff --git a/SESSION-brush-merge-2026-02-16.md b/SESSION-brush-merge-2026-02-16.md new file mode 100644 index 0000000000..16afc8976b --- /dev/null +++ b/SESSION-brush-merge-2026-02-16.md @@ -0,0 +1,82 @@ +# Session: merge brush-data-options into brush-merge + +Date: 2026-02-16 +Branch: brush-merge (based on fil/brush-x) + +## Goal + +Merge `fil/brush-data-options` (brush with data, channel defaults, selection styling, filtered data) into `brush-merge` (which has brushX/brushY, interval snapping). The brushX/brushY feature is the priority; brush-data-options adds on top. + +## What brush-data-options adds (3 commits) + +- `brush(data, options)` accepts optional data and x/y/fx/fy channels +- Channel defaults propagate to reactive marks (inactive/context/focus) +- Selection styling (fill, fillOpacity, stroke, etc.) on the brush rectangle +- `BrushValue.data` — filtered subset of the brush's data +- `maybeTuple` for `[x, y]` pair inference +- `renderFilter` receives `channelDefaults` and spreads them into reactive mark options +- `filterData` helper computes filtered data subset when brush has data +- `filterIndex` adapts the filter to work on channel arrays by index +- Optimization: `filter === false` returns `[]` (not just `filter === true`) +- New tests: brushBrutalist (custom stroke styling), brushCoordinates (pair data) +- Documentation: "Data and options", "Selection styling", "Filtered data" sections + +## Conflicts + +Three files conflicted: +- `src/interactions/brush.js` +- `src/interactions/brush.d.ts` +- `test/plots/brush.ts` + +## Conflict resolution + +### `src/interactions/brush.js` + +- **Imports**: merged both — `keyword, maybeInterval, identity, isIterable` (brush-x + new) and `dataify, maybeTuple, take` (data-options) + `applyAttr` (data-options) +- **Constructor**: combined both — takes `(data, {dimension, interval, ...options})`. Uses `dataify(data)` and `super()` with channels/defaults from data-options, then sets `_dimension`, `_brush`, `_interval` from brush-x. Channel defaults and renderFilter setup from data-options. +- **render()**: both `snapping` (brush-x) and `filterIndex`/`filterData` (data-options) are needed. Extended `filterIndex` to handle 1D filters — for `dim !== "xy"`, indexes into `(dim === "x" ? X : Y)[i]` with 1-arg filter signatures. +- **Factory functions**: `brush(data, options)` from data-options stays as-is. `brushX(data, options)` and `brushY(data, options)` now accept data; if x (or y) is not specified and data is provided, defaults to `identity`. Uses `isIterable` to distinguish `brushX({interval})` (options only) from `brushX(data, options)`. +- **renderFilter**: takes `channelDefaults` from data-options; kept `filterSignature1D` and `intervalRound` helpers from brush-x. + +### `src/interactions/brush.d.ts` + +- **Imports**: merged — `ChannelValueSpec`, `Data`, `MarkOptions` from data-options; `Interval` from brush-x +- **BrushValue**: kept optional x/y fields from brush-x, `(...args: any[]) => boolean` filter signature from brush-x, `data?: any[]` from data-options +- **BrushOptions**: used data-options' version extending `MarkOptions` with x/y/fx/fy channels +- **Brush1DOptions**: extends `BrushOptions`, adds `interval` (for brushX/brushY) +- **brush()**: signature from data-options `(data?, options?)` +- **brushX/brushY**: overloaded — `(options?)` and `(data, options?)` signatures + +### `test/plots/brush.ts` + +- Kept both sets of tests — all brushX*/brushY* tests from brush-x, plus brushBrutalist and brushCoordinates from data-options +- Kept `formatValue` improvements from data-options (Array handling) + +## Post-merge changes + +### `brushX`/`brushY` accept data + +- `brushX(data)` defaults `x` to `identity` (each datum IS the x value) +- `brushY(data)` defaults `y` to `identity` (each datum IS the y value) +- `brushX({interval})` still works (options-only case detected via `isIterable`) +- `maybeTuple` pair inference only applies to the 2D `brush()` factory + +### Documentation + +- Added "2-D brushing" heading before the first example +- Changed "When the brush options specify" → "When the options specify" +- Updated `brushX` and `brushY` headings to `brushX(*data*, *options*)` / `brushY(*data*, *options*)` +- Added data description to both: "If *data* is specified without an **x**/**y** channel, each datum is used as the value directly" + +### Types + +- Renamed `BrushXYOptions` → `Brush1DOptions` +- Made `data` optional in `brushX`/`brushY` overloads (`data?: Data`) + +### New snapshot test + +- **`brushXData`** — `brushX` on an array of numbers (`Plot.valueof(penguins, "body_mass_g")`), with dodgeY dots and textarea showing the value + +## State + +All 1298 tests pass. TypeScript, lint, and prettier all clean. diff --git a/SESSION-brush-pending-2026-02-13.md b/SESSION-brush-pending-2026-02-13.md new file mode 100644 index 0000000000..4e0e732689 --- /dev/null +++ b/SESSION-brush-pending-2026-02-13.md @@ -0,0 +1,63 @@ +# Session: Brush improvements + +**Date:** 2026-02-13 +**Branch:** fil/brush-dataless + +## Summary + +Three improvements to the brush mark: + +1. **`pending` field** — intermediate brush values (start, brush events) include `pending: true`; committed values (end events) omit it, so listeners can skip in-progress updates. +2. **Default `pointerEvents: "none"`** — reactive marks (inactive, context, focus) now default to `pointerEvents: "none"`, so the brush can be placed below all three layers without users needing to set it explicitly. +3. **Compose user render transforms** — reactive marks now use `composeRender` to chain with any user-provided render transform, matching the pattern from `pointer.js`. + +## Changes + +### `src/interactions/brush.js` +- Import `pointer` and `composeRender` +- Null selection + non-end event: dispatch `{pending: true, x1, y1, …}` with pointer position (if `sourceEvent` exists), or `null` for programmatic clearing +- Non-null selection: include `pending: true` for start/brush events, omit for end +- `renderFilter`: default `pointerEvents: "none"`, destructure `render` from options and compose with `composeRender` + +### `src/interactions/brush.d.ts` +- Added `pending?: true` to `BrushValue` + +### `docs/interactions/brush.md` +- Documented `pending` property and the pattern for skipping intermediate values +- Added example showing `data.filter` with the brush filter +- Removed `pointerEvents: "none"` from all examples +- Moved brush below reactive marks in all examples +- Added tip about default `pointerEvents` + +### `test/brush-test.ts` +- Updated "brush dispatches value on programmatic brush move" to verify intermediate values have `pending` and committed values don't +- Added "brush reactive marks compose with user render transforms" test + +### `test/plots/brush.ts` +- Removed redundant `pointerEvents: "none"` from all snapshot tests + +## Decisions + +- During programmatic `brush.move(null)` (clearing other facets), `event.sourceEvent` is null, so those events still dispatch `null` rather than a pointer-based value +- Snapshot tests are unaffected because they only capture the final committed value (from "end" events), which has no `pending` + +## Future: move overlay to top of SVG + +Moving the brush `.overlay` rect to the top of the SVG would eliminate the need for `pointerEvents: "none"` entirely. + +**Why it's hard:** + +- d3-brush attaches event listeners directly on the `.overlay` rect +- Internally, d3-brush uses `this.parentNode` to navigate from the overlay to the selection rect and handles — moving the overlay out of its `` group breaks this +- With faceting, each brush group has a transform; a proxy rect at the SVG root would need to account for that transform to forward events correctly + +**Possible approach — proxy rect:** + +Create a transparent rect at the top of the SVG that forwards pointer events to the real overlay (which gets `pointer-events: none`). The proxy dispatches synthetic events on the real overlay, preserving `clientX`/`clientY` so d3-brush's coordinate math works. Non-faceted case is straightforward; faceted case needs transform handling. + +## Verification + +- `yarn test:mocha` — 1284 passing +- `yarn test:tsc` — clean +- `yarn test:prettier` — clean +- `yarn test:lint` — clean diff --git a/SESSION-brush-tip-suppression-2026-02-14.md b/SESSION-brush-tip-suppression-2026-02-14.md new file mode 100644 index 0000000000..cfa31b3334 --- /dev/null +++ b/SESSION-brush-tip-suppression-2026-02-14.md @@ -0,0 +1,35 @@ +# Session: Suppress tip during brush interaction + +Date: 2026-02-14 + +## Goal + +When a user starts a brush drag while a tip is visible, the tip should be immediately cancelled and stay hidden for the duration of the brush gesture. + +## Changes + +### `src/interactions/brush.js` + +- On `"start"` (with a real user event, i.e. `sourceEvent` exists): add CSS class `"plot-brushing"` to the SVG +- On `"end"` with null selection (brush cleared): remove class `"plot-brushing"` +- When the brush ends with an active selection, the class stays — this prevents sticky tips while a brush selection is visible + +### `src/interactions/pointer.js` + +1. **`pointerdown`**: Three-way branch after sticky handling: + - If `plot-brushing` class is present: immediately cancel the tip via `update(null)` and return (without `stopImmediatePropagation`, so the brush event continues) + - Otherwise: make the tip sticky as before + +2. **`pointermove`**: Split the early-return for drag into two cases: + - `state.sticky` → return (unchanged) + - Mouse drag (`buttons === 1`) → `update(null)` to hide any non-sticky tip during drag (catches edge cases and non-brush drags) + +### `test/plots/brush.ts` + +- Added `brushDotTip` test combining brush + `tip: true` on focus dots + +## Decisions + +- Only add `plot-brushing` on user-initiated brush start (`event.sourceEvent` check), not on programmatic `brush.move()`, to avoid baking the class into snapshot SVGs +- Only remove `plot-brushing` when the brush ends with a null selection (cleared), not when it ends with an active selection — this keeps tip suppression active while a brush is present +- Use `return void update(null)` (skip `stopImmediatePropagation`) when cancelling tip on brush start, so the brush event can propagate normally diff --git a/SESSION-brushXY-1D-2026-02-16.md b/SESSION-brushXY-1D-2026-02-16.md new file mode 100644 index 0000000000..6bc69c64d5 --- /dev/null +++ b/SESSION-brushXY-1D-2026-02-16.md @@ -0,0 +1,38 @@ +# Session: Implement brushX and brushY + +Date: 2026-02-16 + +## Summary + +Added `brushX()` and `brushY()` factory functions for single-axis brushing. + +## Changes + +### `src/interactions/brush.js` +- Constructor accepts `{dimension}` option (`"x"`, `"y"`, or `"xy"`), validated with `keyword()` +- Uses `d3.brushX()`, `d3.brushY()`, or `d3.brush()` accordingly +- Projection guard: throws if `dim !== "xy"` with a projection +- 1D selections normalized to 2D using NaN for the unconstrained axis; `inX`/`inY` helpers skip NaN dimensions +- Dispatched value conditionally includes x/y bounds (brushX omits y, brushY omits x) +- `filterFromBrush` branches on `dim` for 1D filters using `filterSignature1D` +- `move()` passes 1D array `[min, max]` to d3 brush for 1D, 2D array for xy +- Exported `brushX()` and `brushY()` factory functions + +### `src/interactions/brush.d.ts` +- `BrushValue` fields x1/x2/y1/y2 made optional (absent for the unconstrained dimension) +- `filter` signature relaxed to `(...args: any[]) => boolean` to cover 1D and 2D +- `move()` value fields made optional +- Added `brushX()` and `brushY()` declarations + +### `src/index.js` +- Added `brushX, brushY` to the brush export line + +### `test/plots/brush.ts` +- Added `brushXDot` and `brushYDot` snapshot tests + +### `test/brush-test.ts` +- Added two unit tests: brushX value has x1/x2 but no y1/y2; brushY has y1/y2 but no x1/x2; 1D filter works + +## Verification + +`yarn test` passes: 1288 tests, TypeScript, ESLint, Prettier all clean. diff --git a/SESSION-brushXY-2026-02-15.md b/SESSION-brushXY-2026-02-15.md new file mode 100644 index 0000000000..0d3560671f --- /dev/null +++ b/SESSION-brushXY-2026-02-15.md @@ -0,0 +1,99 @@ +# Session: brushX and brushY marks + +Date: 2026-02-15 / 2026-02-16 +Branch: fil/brush-x + +## Goal + +Implement `brushX` and `brushY` marks for single-axis brushing, with an optional `interval` for snapping. + +## Files changed (uncommitted, relative to last commit on branch) + +- `src/interactions/brush.js` — 1D brush logic, interval snapping, filter functions +- `src/interactions/brush.d.ts` — types for brushX/brushY, BrushOptions, updated BrushValue +- `src/index.js` — added brushX, brushY exports +- `docs/interactions/brush.md` — documentation for 1D brushing, brushX, brushY, interval snapping +- `docs/components/links.js` — fixed anchor regex to allow dots in `{#id}` patterns +- `test/plots/brush.ts` — 7 new snapshot tests (brushXDot, brushYDot, brushXHistogram, brushXHistogramFaceted, brushXTemporal, brushXTemporalReversed, brushYHistogram) +- `test/brush-test.ts` — unit tests for 1D value shape and filter signatures +- `test/output/brushX*.html`, `test/output/brushY*.html` — generated snapshots + +## Summary of changes + +### `src/interactions/brush.js` + +Extended the `Brush` class to support 1D brushing: + +- **Constructor** accepts `dimension` (`"x"`, `"y"`, or `"xy"`) and optional `interval`. Uses `d3.brushX()`, `d3.brushY()`, or `d3.brush()` accordingly. The interval is normalized via `maybeInterval`. +- **Selection normalization**: 1D d3 brush selections (a `[min, max]` array) are normalized to 2D with NaN for the unconstrained dimension. Helper functions `inX`/`inY` treat NaN as "always passes". +- **Dispatched value**: only includes `x1`/`x2` (for brushX) or `y1`/`y2` (for brushY), not both. +- **Interval snapping**: on brush `"end"`, the selection snaps to the nearest interval boundaries using `intervalRound` (picks the closest of `floor` and `offset`). A `snapping` flag prevents infinite re-dispatch. If both endpoints round to the same value, the selection spans one interval. Pixel values are sorted with `ascending` to handle inverted scales. +- **Filter function**: `filterFromBrush` branches on `dim`. For 1D brushes, uses `filterSignature1D` which wraps a 1-argument test (testing just the relevant channel value). When an interval is set, the filter floors the value before testing, for consistency with binned marks. +- **`move()`**: handles 1D format (`[min, max]`) vs 2D (`[[x1,y1],[x2,y2]]`) for `d3.brush.move`. +- **`renderFilter`**: computes midpoints from `x1`/`x2` and `y1`/`y2` channels as fallback when `x`/`y` channels are absent (as with rect marks from `binX`/`binY`). + +New helper functions: +- `intervalRound(interval, v)` — rounds to nearest interval boundary +- `filterSignature1D(test, currentFx, currentFy)` — 1-arg filter with facet support + +New factory functions: +- `brushX({interval})` — horizontal brush +- `brushY({interval})` — vertical brush + +### `src/interactions/brush.d.ts` + +- `BrushValue` fields `x1`/`x2`/`y1`/`y2` are now optional (absent for the unconstrained dimension) +- `filter` signature relaxed to `(...args: any[]) => boolean` (1D has different arity) +- New `BrushOptions` interface with documented `interval` option (typed as `Interval`) +- `brushX(options?)` and `brushY(options?)` declarations + +### `src/index.js` + +Added `brushX, brushY` to the exports from `./interactions/brush.js`. + +### `docs/interactions/brush.md` + +- Added "1-D brushing" section with dodgeY dot example for brushX +- Updated filter table to show both 1D and 2D filter signatures +- Updated BrushValue documentation: x/y fields are absent for the unconstrained dimension +- Updated brush.move documentation for 1D brushes +- Added `brushX(*options*)` API section with interval histogram example +- Added `brushY(*options*)` API section +- All hidden previews now have initial brush selections via `d3.timeout` + +### `docs/components/links.js` + +Fixed anchor regex from `[\w\d-]+` to `[\w\d.-]+` to support anchors like `#brush.focus`. + +### `test/plots/brush.ts` + +New snapshot tests: +- **`brushXDot`** — dodgeY dot plot with horizontal brush on `body_mass_g`, height 170, marginTop 10 +- **`brushYDot`** — dot plot with vertical brush, reactive marks on `culmen_depth_mm` +- **`brushXHistogram`** — histogram with three layers (inactive 0.8, context 0.3, focus 1), `thresholds: 40` +- **`brushXHistogramFaceted`** — histogram faceted by island, `fill: "species"` with color legend, three layers, `brushX({interval})` +- **`brushXTemporal`** — AAPL stock data, `brushX({interval: "month"})`, median Close reducer, three layers +- **`brushXTemporalReversed`** — same with `x: {reverse: true}`, tests snapping with inverted scales +- **`brushYHistogram`** — horizontal histogram with `brushY({interval: 0.5})` on `culmen_depth_mm`, three layers + +### `test/brush-test.ts` + +Unit tests verifying: +- brushX dispatches `x1`/`x2` without `y1`/`y2` +- brushY dispatches `y1`/`y2` without `x1`/`x2` +- 1D filter functions work correctly with facet arguments + +## Design decisions + +- **NaN normalization**: 1D selections are normalized to 2D with NaN for the unconstrained dimension. This lets the existing `ctx.update` and `focus.update` code stay unchanged — `inX`/`inY` helpers treat NaN as always passing. +- **Round, not floor**: brush endpoints snap to the nearest interval boundary rather than always flooring. This feels more natural — a brush endpoint near a boundary snaps to it regardless of direction. +- **Filter floors**: the filter function floors values via `interval.floor` before testing. This is correct for bin membership: a value belongs to the bin whose floor matches. +- **Projection guard**: `brushX`/`brushY` throw when used with projections (only `brush` supports projected coordinates). +- **Midpoint fallback in renderFilter**: rect marks (from `binX`/`binY`) have `x1`/`x2`/`y1`/`y2` channels but no `x`/`y`. The midpoint `(x1 + x2) / 2` is used for hit-testing against the brush bounds. +- **Three-layer opacity pattern**: histogram tests use inactive (0.8), context (0.3), focus (1) for clear contrast between states. +- **Snapping with inverted scales**: the snap code uses `.sort(ascending)` on pixel values to handle both normal and reversed scales correctly. +- **`Interval` type**: the `interval` option uses the existing `Interval` type from `src/interval.d.ts` which covers numbers, time interval names, and objects with `floor`/`offset` methods. + +## State + +All 1293 tests pass. TypeScript, lint, and prettier all pass. Uncommitted changes on top of the last commit on `fil/brush-x`. diff --git a/SESSION-hexbin-offset-2026-02-18.md b/SESSION-hexbin-offset-2026-02-18.md new file mode 100644 index 0000000000..fcd02be569 --- /dev/null +++ b/SESSION-hexbin-offset-2026-02-18.md @@ -0,0 +1,66 @@ +# Session: Hexbin offset fix + +## Context + +The hexbin grid origin (`ox`, `oy`) was hardcoded to `0.5, 0`. After commit aa880979 changed the pixel `offset` from 0.5 to 0 in the test environment, the hexbin output shifted because the grid origin didn't account for the coordinate shift caused by offset-dependent margins. + +## Decisions + +### ox = -offset, oy = -offset + +The margins shift all coordinates by `-offset`. The grid origin shifts by the same amount so binning is identical across offset values: `ox = -offset`, `oy = -offset`. + +The old `ox = 0.5` hack is no longer needed thanks to center-directed rounding (see below). + +### Center-directed rounding (fixes both edges) + +The core problem: `Math.round(n.5) = n+1` in JS, which creates floating bins outside the frame when data lands exactly on a grid boundary. The old fix was `ox = 0.5` (shifting the grid to avoid .5 boundaries on the right), but this caused floating bins on the left odd rows. + +The new approach: a continuous `round(x, center)` function that pulls x infinitesimally toward the center before rounding: +```js +function round(x, center) { + return Math.round(center + (x - center) * (1 - 1e-12)); +} +``` + +The center is computed from `dimensions` (the midpoint of the frame in bin-index space) and passed to `hbin`. + +### Hexgrid mark + +Uses `ox, oy` directly in `applyTransform` (no more `offset + ox, offset + oy` since ox already accounts for offset). + +Grid bounds fixed to account for odd-row hex offset: +```js +i0 = Math.floor((x0 - rx) / wx), +i1 = Math.ceil((x1 + rx) / wx), +``` +This ensures odd-row hexes at the frame edge are included (with ox=0, the old `floor(x0/wx)` missed them). + +## Verification + +### Tests +- All 1270 tests pass + +### Snapshot comparison (hexbin.svg) +- **Main vs Committed (both ox=0.5, different offset)**: Identical bin positions — confirms binning is offset-independent when ox compensates +- **Committed vs New (ox=0.5→0)**: 190→192 bins. ~10 boundary bins shifted to adjacent grid positions, net +2 bins. Expected consequence of removing the 0.5 hack +- **Hexgrid**: 660→682 cells (+22, one extra clipped column from the bounds fix) + +### hexbinEdge regression test +- 0 truly floating hexes (no hex whose visible area is entirely outside the frame) +- All x centers within frame (20–610) +- Worst-case y: 2.7px outside frame (well within hex radius of 11.5px), so large portion visible + +## Current state + +- `src/transforms/hexbin.js`: `ox = -offset, oy = -offset`, center-directed `round(x, center)` +- `src/marks/hexgrid.js`: uses `ox, oy` directly, expanded i bounds for odd-row edge hexes +- `test/plots/hexbin-edge.ts`: regression test with data on all 4 edges +- All 1270 tests pass + +## Notes + +- Test dimensions (width=630, margin=0, inset=20, binWidth=20) chosen so right edge (610) hits .5 boundary: 610/20 = 30.5 +- Left odd-row boundary also at .5: (20/20) - 0.5 = 0.5 +- Both resolved by center-directed rounding: right rounds down to 30, left rounds up to 1 +- The 1e-12 epsilon needs to be small enough not to affect non-.5 values but large enough to survive float64 precision diff --git a/docs/components/links.js b/docs/components/links.js index ed46b5fdfe..251a2f5e25 100644 --- a/docs/components/links.js +++ b/docs/components/links.js @@ -13,7 +13,7 @@ export function getAnchors(text) { .toLowerCase() ); } - for (const [, anchor] of text.matchAll(/ \{#([\w\d-]+)\}/g)) { + for (const [, anchor] of text.matchAll(/ \{#([\w\d.-]+)\}/g)) { anchors.push(anchor); } return anchors; diff --git a/docs/interactions/brush.md b/docs/interactions/brush.md index 71be62c846..0a2ab6b456 100644 --- a/docs/interactions/brush.md +++ b/docs/interactions/brush.md @@ -6,12 +6,24 @@ import * as topojson from "topojson-client"; import {shallowRef, computed, onMounted} from "vue"; import penguins from "../data/penguins.ts"; +const stocks = shallowRef([]); const world = shallowRef(null); const land = computed(() => world.value && topojson.feature(world.value, world.value.objects.land)); const allCities = shallowRef([]); const cities = computed(() => allCities.value.filter((d) => d.population > 500000)); onMounted(() => { + Promise.all([ + d3.csv("../data/aapl.csv", d3.autoType), + d3.csv("../data/amzn.csv", d3.autoType), + d3.csv("../data/goog.csv", d3.autoType) + ]).then(([aapl, amzn, goog]) => { + stocks.value = [ + ...aapl.map((d) => ({Symbol: "AAPL", ...d})), + ...amzn.map((d) => ({Symbol: "AMZN", ...d})), + ...goog.map((d) => ({Symbol: "GOOG", ...d})) + ]; + }); d3.json("../data/countries-110m.json").then((data) => (world.value = data)); d3.csv("../data/cities-10k.csv", d3.autoType).then((data) => (allCities.value = data)); }); @@ -20,7 +32,9 @@ onMounted(() => { # Brush mark -The **brush mark** renders a two-dimensional [brush](https://d3js.org/d3-brush) that allows the user to select a rectangular region by clicking and dragging. It is typically used to highlight a subset of data, or to filter data for display in a linked view. +The **brush mark** renders a [brush](https://d3js.org/d3-brush) that allows the user to select a region by clicking and dragging. It is typically used to highlight a subset of data, or to filter data for display in a linked view. + +## 2-D brushing :::plot hidden ```js @@ -42,7 +56,40 @@ Plot.plot({ }) ``` -The brush mark does not require data. When added to a plot, it renders a [brush](https://d3js.org/d3-brush) overlay covering the frame. The user can click and drag to create a rectangular selection, drag the selection to reposition it, or drag an edge or corner to resize it. Clicking outside the selection clears it. +The user can click and drag to create a rectangular selection, drag the selection to reposition it, or drag an edge or corner to resize it. Clicking outside the selection clears it. + +## 1-D brushing + +The **brushX** mark operates on the *x* axis. + +:::plot hidden +```js +Plot.plot({ + height: 200, + marks: ((brush) => (d3.timeout(() => brush.move({x1: 3200, x2: 4800})), [ + brush, + Plot.dot(penguins, Plot.dodgeY(brush.inactive({x: "body_mass_g", fill: "species"}))), + Plot.dot(penguins, Plot.dodgeY(brush.context({x: "body_mass_g", fill: "#ddd"}))), + Plot.dot(penguins, Plot.dodgeY(brush.focus({x: "body_mass_g", fill: "species"}))) + ]))(Plot.brushX()) +}) +``` +::: + +```js +const brush = Plot.brushX(); +Plot.plot({ + height: 200, + marks: [ + brush, + Plot.dot(penguins, Plot.dodgeY(brush.inactive({x: "body_mass_g", fill: "species"}))), + Plot.dot(penguins, Plot.dodgeY(brush.context({x: "body_mass_g", fill: "#ddd"}))), + Plot.dot(penguins, Plot.dodgeY(brush.focus({x: "body_mass_g", fill: "species"}))) + ] +}) +``` + +Similarly, the **brushY** mark operates on the *y* axis. ## Input events @@ -56,25 +103,34 @@ plot.addEventListener("input", (event) => { }); ``` -The **filter** function on the brush value tests whether a data point falls inside the selection. Its signature depends on whether the plot uses faceting: - -| Facets | Signature | -|-------------|--------------------------------| -| none | *filter*(*x*, *y*) | -| **fx** only | *filter*(*x*, *y*, *fx*) | -| **fy** only | *filter*(*x*, *y*, *fy*) | -| both | *filter*(*x*, *y*, *fx*, *fy*) | +The **filter** function on the brush value tests whether a data point falls inside the selection. Its signature depends on whether the plot uses faceting, and on the brush’s dimension: -When faceted, the filter returns true only for points in the brushed facet. For example: +| Facets | 1-D brush | 2-D brush | +|-------------------|-------------------------------|--------------------------------| +| *none* | *filter*(*value*) | *filter*(*x*, *y*) | +| **fx** only | *filter*(*value*, *fx*) | *filter*(*x*, *y*, *fx*) | +| **fy** only | *filter*(*value*, *fy*) | *filter*(*x*, *y*, *fy*) | +| **fx** and **fy** | *filter*(*value*, *fx*, *fy*) | *filter*(*x*, *y*, *fx*, *fy*) | ```js plot.addEventListener("input", () => { const filter = plot.value?.filter; - const selected = filter ? penguins.filter((d) => filter(d.culmen_length_mm, d.culmen_depth_mm)) : penguins; + const selected = filter + ? penguins.filter((d) => filter(d.culmen_length_mm, d.culmen_depth_mm)) + : penguins; console.log(selected); }); ``` +The facet arguments are optional: if *fx* or *fy* is undefined, the filter skips the facet check for that dimension. For example, if the selected region is [44, 46] × [17, 19] over the "Adelie" facet: + +```js +const filter = plot.value?.filter; // f(x, y, fx) +filter(45, 18) // true +filter(45, 18, "Adelie") // true +filter(45, 18, "Gentoo") // false +``` + ## Reactive marks The brush can be paired with reactive marks that respond to the brush state. Create a brush mark, then call its **inactive**, **context**, and **focus** methods to derive options that reflect the selection. @@ -88,7 +144,7 @@ A typical pattern is to layer three reactive marks: the inactive mark provides a :::plot hidden ```js Plot.plot({ - marks: ((brush) => (d3.timeout(() => brush.move({x1: 36, x2: 48, y1: 15, y2: 20})), [ + marks: ((brush) => (d3.timeout(() => brush.move({x1: 38, x2: 48, y1: 15, y2: 19})), [ brush, Plot.dot(penguins, brush.inactive({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species", r: 2})), Plot.dot(penguins, brush.context({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "#ccc", r: 2})), @@ -111,42 +167,146 @@ Plot.plot({ ``` :::tip -To achieve higher contrast, place the brush below the reactive marks; reactive marks default to using **pointerEvents** *none* to ensure they don't obstruct pointer events. +To achieve higher contrast, you can place the brush before the reactive marks; reactive marks default to using **pointerEvents** *none* to ensure they don't obstruct pointer events. ::: +## Data and options + +The brush accepts optional *data* and *options*. When the options specify **x**, **y**, **fx**, or **fy** channels, these become defaults for the associated reactive marks. + +:::plot defer hidden +```js +Plot.plot({ + marks: ((brush) => [ + brush, + Plot.dot(penguins, brush.inactive({fill: "species", r: 2})), + Plot.dot(penguins, brush.context({fill: "#ccc", r: 2})), + Plot.dot(penguins, brush.focus({fill: "species", r: 3})) + ])(Plot.brush(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm"})) +}) +``` +::: + +```js +const brush = Plot.brush(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm"}); +Plot.plot({ + marks: [ + brush, + Plot.dot(penguins, brush.inactive({fill: "species", r: 2})), + Plot.dot(penguins, brush.context({fill: "#ccc", r: 2})), + Plot.dot(penguins, brush.focus({fill: "species", r: 3})) + ] +}) +``` + +If neither **x** nor **y** is specified, *data* is assumed to be an array of values, such as [*x₀*, *x₁*, …] for 1-dimensional brushes, or an array of pairs [[*x₀*, *y₀*], [*x₁*, *y₁*], …] for 2-dimensional brushes. + +```js +const brush = Plot.brush(points); +``` + +### Selection styling + +The **fill**, **fillOpacity**, **stroke**, **strokeWidth**, and **strokeOpacity** options style the brush selection rectangle, overriding D3's defaults. + +```js +const brush = Plot.brush(penguins, { + x: "culmen_length_mm", + y: "culmen_depth_mm", + stroke: "currentColor", + strokeWidth: 1.5 +}); +``` + +### Filtered data + +When the brush has *data*, the [BrushValue](#brushvalue) includes a **data** property containing the subset filtered by the selection. + +```js +plot.addEventListener("input", () => { + console.log(plot.value?.data); // filtered subset of the brush's data + const selected = otherData.filter((d) => plot.value?.filter(d.x, d.y)); // filter a different dataset +}); +``` + ## Faceting -The brush mark supports [faceting](../features/facets.md). When the plot uses **fx** or **fy** facets, each facet gets its own brush. Starting a brush in one facet clears any selection in other facets. The dispatched value includes the **fx** and **fy** facet values of the brushed facet, and the **filter** function also filters on the relevant facet values. +The brush mark supports [faceting](../features/facets.md). When the plot uses **fx** or **fy** facets, each facet gets its own brush. The dispatched value includes the **fx** and **fy** facet values of the brushed facet, and the **filter** function also filters on the relevant facet values. + +
:::plot hidden ```js Plot.plot({ - height: 270, - grid: true, - marks: ((brush) => (d3.timeout(() => brush.move({x1: 43, x2: 50, y1: 17, y2: 19, fx: "Adelie"})), [ + height: 350, + y: {type: "log", grid: true}, + fy: {label: null}, + marks: ((brush) => (d3.timeout(() => brush.move({x1: new Date("2015-01-01"), x2: new Date("2016-06-01"), fy: "AAPL"})), [ Plot.frame(), brush, - Plot.dot(penguins, brush.inactive({x: "culmen_length_mm", y: "culmen_depth_mm", fx: "species", fill: "sex", r: 2})), - Plot.dot(penguins, brush.context({x: "culmen_length_mm", y: "culmen_depth_mm", fx: "species", fill: "#ccc", r: 2})), - Plot.dot(penguins, brush.focus({x: "culmen_length_mm", y: "culmen_depth_mm", fx: "species", fill: "sex", r: 3})) - ]))(Plot.brush()) + Plot.lineY(stocks, {x: "Date", y: "Close", fy: "Symbol", stroke: "#ccc", strokeWidth: 1}), + Plot.lineY(stocks, brush.inactive({x: "Date", y: "Close", fy: "Symbol", stroke: "Symbol"})), + Plot.lineY(stocks, brush.focus({x: "Date", y: "Close", fy: "Symbol", stroke: "Symbol", strokeWidth: 2})) + ]))(Plot.brushX()) }) ``` ::: ```js -const brush = Plot.brush(); +const brush = Plot.brushX(); +Plot.plot({ + height: 350, + y: {type: "log", grid: true}, + fy: {label: null}, + marks: [ + Plot.frame(), + brush, + Plot.lineY(stocks, {x: "Date", y: "Close", fy: "Symbol", stroke: "#ccc"}), + Plot.lineY(stocks, brush.inactive({x: "Date", y: "Close", fy: "Symbol", stroke: "Symbol"})), + Plot.lineY(stocks, brush.focus({x: "Date", y: "Close", fy: "Symbol", stroke: "Symbol", strokeWidth: 2})) + ] +}) +``` + +By default, starting a brush in one facet clears any selection in other facets. Set **sync** to true to brush across all facet panes simultaneously. When the user brushes in one facet, the same selection rectangle appears in all panes, and the reactive marks update across all facets. + +:::plot hidden +```js Plot.plot({ + height: 350, + y: {type: "log", grid: true}, + fy: {label: null}, + marks: ((brush) => (d3.timeout(() => brush.move({x1: new Date("2015-01-01"), x2: new Date("2016-06-01"), fy: "AAPL"})), [ + Plot.frame(), + brush, + Plot.lineY(stocks, {x: "Date", y: "Close", fy: "Symbol", stroke: "#ccc", strokeWidth: 1}), + Plot.lineY(stocks, brush.inactive({x: "Date", y: "Close", fy: "Symbol", stroke: "Symbol"})), + Plot.lineY(stocks, brush.focus({x: "Date", y: "Close", fy: "Symbol", stroke: "Symbol", strokeWidth: 2})) + ]))(Plot.brushX({sync: true})) +}) +``` +::: + +```js +const brush = Plot.brushX({sync: true}); +Plot.plot({ + height: 350, + y: {type: "log", grid: true}, + fy: {label: null}, marks: [ Plot.frame(), brush, - Plot.dot(penguins, brush.inactive({x: "culmen_length_mm", y: "culmen_depth_mm", fx: "species", fill: "sex", r: 2})), - Plot.dot(penguins, brush.context({x: "culmen_length_mm", y: "culmen_depth_mm", fx: "species", fill: "#ccc", r: 2})), - Plot.dot(penguins, brush.focus({x: "culmen_length_mm", y: "culmen_depth_mm", fx: "species", fill: "sex", r: 3})) + Plot.lineY(stocks, {x: "Date", y: "Close", fy: "Symbol", stroke: "#ccc"}), + Plot.lineY(stocks, brush.inactive({x: "Date", y: "Close", fy: "Symbol", stroke: "Symbol"})), + Plot.lineY(stocks, brush.focus({x: "Date", y: "Close", fy: "Symbol", stroke: "Symbol", strokeWidth: 2})) ] }) ``` +
+ +The dispatched value still includes **fx** (and **fy**), indicating the facet where the interaction originated. + ## Projections For plots with a [geographic projection](../features/projections.md), the brush operates in screen space. The brush value’s **x1**, **y1**, **x2**, **y2** bounds are expressed in pixels from the top-left corner of the frame, and the **filter** function takes the data's coordinates (typically longitude and latitude) and projects them to test against the brush extent. @@ -157,7 +317,7 @@ For plots with a [geographic projection](../features/projections.md), the brush ```js Plot.plot({ projection: "equal-earth", - marks: ((brush) => (d3.timeout(() => brush.move({x1: 80, x2: 300, y1: 60, y2: 200})), [ + marks: ((brush) => (d3.timeout(() => brush.move({x1: 300, x2: 500, y1: 50, y2: 200})), [ Plot.geo(land, {strokeWidth: 0.5}), Plot.sphere(), brush, @@ -197,9 +357,10 @@ The brush value dispatched on [_input_ events](#input-events). When the brush is - **fx** - the *fx* facet value, if applicable - **fy** - the *fy* facet value, if applicable - **filter** - a function to test whether a point is inside the selection +- **data** - when the brush has data, the filtered subset - **pending** - `true` during interaction; absent when committed -By convention, *x1* < *x2* and *y1* < *y2*. +By convention, *x1* < *x2* and *y1* < *y2*. The brushX value does not include *y1* and *y2*; similarly, the brushY value does not include *x1* and *x2*. The **pending** property indicates the user is still interacting with the brush. To skip intermediate values and react only to committed selections: @@ -210,13 +371,17 @@ plot.addEventListener("input", () => { }); ``` -## brush() {#brush} +## brush(*data*, *options*) {#brush} ```js const brush = Plot.brush() ``` -Returns a new brush. The mark exposes the **inactive**, **context**, and **focus** methods for creating reactive marks that respond to the brush state. +Returns a new brush with the given *data* and *options*. Both *data* and *options* are optional. If *data* is specified but the neither **x** nor **y** is specified in the *options*, *data* is assumed to be an array of pairs [[*x₀*, *y₀*], [*x₁*, *y₁*], …] such that **x** = [*x₀*, *x₁*, …] and **y** = [*y₀*, *y₁*, …]. + +The following *options* are supported: + +- **sync** - if true, the brush spans all facet panes simultaneously; defaults to false ## *brush*.inactive(*options*) {#brush.inactive} @@ -248,7 +413,11 @@ Returns mark options that hide the mark by default and, during brushing, show on brush.move({x1: 36, x2: 48, y1: 15, y2: 20}) ``` -Programmatically sets the brush selection in data space. The *value* must have **x1**, **x2**, **y1**, and **y2** properties. For faceted plots, include **fx** or **fy** to target a specific facet. Pass null to clear the selection. +Programmatically sets the brush selection in data space. For a 2D brush, the *value* must have **x1**, **x2**, **y1**, and **y2** properties; for brushX, **x1** and **x2**; for brushY, **y1** and **y2**. For faceted plots, include **fx** or **fy** to target a specific facet. Pass null to clear the selection. + +```js +brush.move({x1: 3500, x2: 5000}) // brushX +``` ```js brush.move({x1: 40, x2: 52, y1: 15, y2: 20, fx: "Chinstrap"}) @@ -259,3 +428,50 @@ brush.move(null) ``` For projected plots, the coordinates are in pixels (consistent with the [BrushValue](#brushvalue)), so you need to project the two corners of the brush beforehand. In the future Plot might expose its *projection* to facilitate this. Please upvote [this issue](https://github.com/observablehq/plot/issues/1191) to help prioritize this feature. + +## brushX(*data*, *options*) {#brushX} + +```js +const brush = Plot.brushX() +``` + +Returns a new horizontal brush mark that selects along the *x* axis. If *data* is specified without an **x** channel, each datum is used as the *x* value directly. In addition to the [brush options](#data-and-options), the *interval* option is supported: + +- **interval** - an interval to snap the brush to on release; a number for quantitative scales (_e.g._, `100`), a time interval name for temporal scales (_e.g._, `"month"`), or an object with *floor* and *offset* methods + +When an **interval** is set, the selection snaps to interval boundaries on release, and the filter rounds values before testing, for consistency with binned marks using the same interval. (Use the same interval in the bin transform so the brush aligns with bin edges.) + +:::plot defer hidden +```js +Plot.plot({ + marks: ((brush) => (d3.timeout(() => brush.move({x1: 3500, x2: 5000})), [ + Plot.rectY(penguins, Plot.binX({y: "count"}, {x: "body_mass_g", interval: 100, fill: "currentColor", fillOpacity: 0.3})), + brush, + Plot.rectY(penguins, Plot.binX({y: "count"}, brush.focus({x: "body_mass_g", interval: 100}))), + Plot.ruleY([0]) + ]))(Plot.brushX({interval: 100})) +}) +``` +::: + +```js +const brush = Plot.brushX({interval: 100}); +Plot.plot({ + marks: [ + Plot.rectY(penguins, Plot.binX({y: "count"}, {x: "body_mass_g", interval: 100, fill: "currentColor", fillOpacity: 0.3})), + brush, + Plot.rectY(penguins, Plot.binX({y: "count"}, brush.focus({x: "body_mass_g", interval: 100}))), + Plot.ruleY([0]) + ] +}) +``` + +The brushX mark does not support projections. + +## brushY(*data*, *options*) {#brushY} + +```js +const brush = Plot.brushY() +``` + +Returns a new vertical brush mark that selects along the *y* axis. If *data* is specified without a **y** channel, each datum is used as the *y* value directly. For the other options, see [brushX](#brushX). diff --git a/instructions.md b/instructions.md new file mode 100644 index 0000000000..d90da4d6c1 --- /dev/null +++ b/instructions.md @@ -0,0 +1,45 @@ +We are going to implement for real the prototype of Plot.brush that I developed in this notebook: +https://observablehq.com/@observablehq/plot-data-less-brush + +1. read the code, and understand how it works +2. port it to this repository +3. make sure to always simplify the code as much as possible, and follow code style from this repo (see interactions/ in particular) +4. add a simple unit test so I can play with it + +Once this is done, we will have to implement all the todos one by one. We need to prioritize so that we get to a complete support for information flow (e.g. the setter is more important than compatibility with tip). + +You can find todos at the top of the notebook but also inside the code. Make a prioritized list of all the todos and add it at the bottom of this file (instructions.md). + +Don't forget to write session notes. + +--- + +## PR description + +A data-less brush mark that renders a 2D rectangular brush, enabling selection and filtering. The brush mark has `aria-label="brush"`. + +The brush dispatches input events with the selection bounds (x1, x2, y1, y2) in data space and a filter function to test whether a point falls inside the selection. + +Brushes support faceted plots: each facet gets its own brush, starting one clears the others, and the value includes fx/fy as relevant (with support in the filter function). + +Three reactive mark options transforms — `brush.inactive`, `brush.context`, `brush.focus` — allow marks to respond to state by showing/hiding points inside or outside the selection. Reactive marks re-render as the brush moves, with `pointerEvents` set to "none" by default so they don't obstruct interaction if they are above the brush (which achieves better contrast in general). + +The brush supports geographic projections: in that casse, though, the {x1, x2, y1, y2} coordinates are returned in pixel space; but the filter projects lon/lat automatically for hit-testing. + +A `pending` flag distinguishes brushing events (user gesture) from committed selections when the brush gesture ends and the user releases the pointer. This is to allow workflows where we don't want to reredner continuously during brushing (e.g. to avoid costly calls to a database backend). + +The brush has a `move` property that allows the brush to be moved programmatically. + + +Ideas for future PRs: + +- **Support (options)** - control the brush's appearance; +- **Data-based brush** — support both `(options)` and `(data, options?)` signatures; when data is passed, return filtered data as `value.data`. +- **brushX, brushY** — directional brush variants, maybe with options (frameAnchor, snap, etc.) +- **neuter tip interaction** — find mechanism to suppress tips during brushing (possibly className flag) +- **Z awareness** — per Plot PR #1671; consider z option for reactive marks +- **Intersection algorithm** — support complex shapes (lines); currently only point-in-rect +- **pixel precision inverse** — round inverted values to the minimal precision that distinguishes half-pixels; for example: numbers as integers, dates as days. Needs a bit of research. Also we want uniform precision for linear/utc scales. + + +AI disclosure: I used @claude to help me generate the unit tests and maintain an action plan. diff --git a/src/index.js b/src/index.js index 4461bdfcb7..cd2a458f2b 100644 --- a/src/index.js +++ b/src/index.js @@ -53,7 +53,7 @@ export {window, windowX, windowY} from "./transforms/window.js"; export {select, selectFirst, selectLast, selectMaxX, selectMaxY, selectMinX, selectMinY} from "./transforms/select.js"; export {stackX, stackX1, stackX2, stackY, stackY1, stackY2} from "./transforms/stack.js"; export {treeNode, treeLink} from "./transforms/tree.js"; -export {Brush, brush} from "./interactions/brush.js"; +export {Brush, brush, brushX, brushY} from "./interactions/brush.js"; export {pointer, pointerX, pointerY} from "./interactions/pointer.js"; export {formatIsoDate, formatNumber, formatWeekday, formatMonth} from "./format.js"; export {scale} from "./scales.js"; diff --git a/src/interactions/brush.d.ts b/src/interactions/brush.d.ts index f63781e5aa..7dc8cdf5a7 100644 --- a/src/interactions/brush.d.ts +++ b/src/interactions/brush.d.ts @@ -1,4 +1,6 @@ -import type {RenderableMark} from "../mark.js"; +import type {ChannelValueSpec} from "../channel.js"; +import type {Interval} from "../interval.js"; +import type {Data, MarkOptions, RenderableMark} from "../mark.js"; import type {Rendered} from "../transforms/basic.js"; /** @@ -9,32 +11,78 @@ import type {Rendered} from "../transforms/basic.js"; */ export interface BrushValue { /** The lower *x* value of the brushed region. */ - x1: number | Date; + x1?: number | Date; /** The upper *x* value of the brushed region. */ - x2: number | Date; + x2?: number | Date; /** The lower *y* value of the brushed region. */ - y1: number | Date; + y1?: number | Date; /** The upper *y* value of the brushed region. */ - y2: number | Date; + y2?: number | Date; /** The *fx* facet value, if applicable. */ fx?: any; /** The *fy* facet value, if applicable. */ fy?: any; /** * A function to test whether a point falls inside the brush selection. - * The signature depends on active facets: *(x, y)*, *(x, y, fx)*, *(x, y, fy)*, - * or *(x, y, fx, fy)*. When faceted, returns true only for points in the brushed - * facet. For projected plots, *x* and *y* are typically longitude and latitude. + * The signature depends on the dimensions and active facets: for brushX + * and brushY, filter on the value *v* with *(v)*, *(v, fx)*, *(v, fy)*, + * or *(v, fx, fy)*; for a 2D brush, use *(x, y)*, *(x, y, fx)*, + * *(x, y, fy)*, or *(x, y, fx, fy)*. When faceted, returns true only for + * points in the brushed facet. For projected plots, *x* and *y* are + * typically longitude and latitude. */ - filter: (x: number | Date, y: number | Date, f1?: any, f2?: any) => boolean; + filter: (...args: any[]) => boolean; + /** When the brush has data, the subset of data matching the selection. */ + data?: any[]; /** True during interaction, absent when committed. */ pending?: true; } +/** Options for the brush mark. */ +export interface BrushOptions extends MarkOptions { + /** + * If true, the brush spans all facet panes simultaneously; defaults to false. + */ + sync?: boolean; + + /** + * An interval to snap the brush to, such as a number for quantitative scales + * or a time interval name like *month* for temporal scales. On brush end, the + * selection is rounded to the nearest interval boundaries; the dispatched + * filter function floors values before testing, for consistency with binned + * marks. Supported by brushX and brushY. + */ + interval?: Interval; + + /** + * The horizontal position channel, typically bound to the *x* scale. When + * specified, inherited by reactive marks as a default. + */ + x?: ChannelValueSpec; + + /** + * The vertical position channel, typically bound to the *y* scale. When + * specified, inherited by reactive marks as a default. + */ + y?: ChannelValueSpec; + + /** + * The horizontal facet channel, bound to the *fx* scale. When specified, + * inherited by reactive marks as a default. + */ + fx?: MarkOptions["fx"]; + + /** + * The vertical facet channel, bound to the *fy* scale. When specified, + * inherited by reactive marks as a default. + */ + fy?: MarkOptions["fy"]; +} + /** - * A brush mark that renders a two-dimensional [brush](https://d3js.org/d3-brush) - * allowing the user to select a rectangular region. The brush coordinates across - * facets, clearing previous selections when a new brush starts. + * A mark that renders a [brush](https://d3js.org/d3-brush) allowing the user to + * select a region. The brush coordinates across facets, clearing previous + * selections when a new brush starts. * * The brush dispatches an input event when the selection changes. The selection * is available as plot.value as a **BrushValue**, or null when the selection is @@ -42,6 +90,15 @@ export interface BrushValue { * reactive marks that respond to the brush state. */ export class Brush extends RenderableMark { + constructor(options?: BrushOptions); + /** + * Creates a new brush mark with the given *data* and *options*. If *data* and + * *options* specify **x** and **y** channels, these become defaults for + * reactive marks (**inactive**, **context**, **focus**). The **fill**, + * **fillOpacity**, **stroke**, **strokeWidth**, and **strokeOpacity** options + * style the brush selection rectangle. + */ + constructor(data?: Data, options?: BrushOptions); /** * Returns mark options that show the mark when no brush selection is active, * and hide it during brushing. Use this for the default appearance. @@ -64,13 +121,34 @@ export class Brush extends RenderableMark { /** * Programmatically sets the brush selection in data space. Pass an object - * with **x1**, **x2**, **y1**, **y2** (and optionally **fx**, **fy** for - * faceted plots) to set the selection, or null to clear it. + * with the relevant bounds (**x1** and **x2**, **y1** and **y2**, and + * **fx**, **fy** for faceted plots) to set the selection, or null to clear it. */ move( - value: {x1: number | Date; x2: number | Date; y1: number | Date; y2: number | Date; fx?: any; fy?: any} | null + value: {x1?: number | Date; x2?: number | Date; y1?: number | Date; y2?: number | Date; fx?: any; fy?: any} | null ): void; } -/** Creates a new brush mark. */ -export function brush(): Brush; +/** + * Creates a new brush mark with the given *data* and *options*. If neither + * **x** nor **y** is specified, they default to the first and second + * element of each datum, assuming [*x*, *y*] pairs. + */ +export function brush(options?: BrushOptions): Brush; +export function brush(data?: Data, options?: BrushOptions): Brush; + +/** + * Creates a one-dimensional brush mark along the *x* axis. If *data* is + * specified without an **x** channel, each datum is used as the *x* value + * directly. Not supported with projections. + */ +export function brushX(options?: BrushOptions): Brush; +export function brushX(data?: Data, options?: BrushOptions): Brush; + +/** + * Creates a one-dimensional brush mark along the *y* axis. If *data* is + * specified without a **y** channel, each datum is used as the *y* value + * directly. Not supported with projections. + */ +export function brushY(options?: BrushOptions): Brush; +export function brushY(data?: Data, options?: BrushOptions): Brush; diff --git a/src/interactions/brush.js b/src/interactions/brush.js index ba05d66dd5..52641c1f7c 100644 --- a/src/interactions/brush.js +++ b/src/interactions/brush.js @@ -1,51 +1,106 @@ -import {brush as d3Brush, create, pointer, select, selectAll} from "d3"; +import { + brush as d3Brush, + brushX as d3BrushX, + brushY as d3BrushY, + create, + pointer, + select, + selectAll, + ascending +} from "d3"; import {composeRender, Mark} from "../mark.js"; +import {constant, dataify, identity, isIterable, keyword, maybeInterval, maybeTuple, take} from "../options.js"; +import {applyAttr} from "../style.js"; + +const defaults = {ariaLabel: "brush", fill: "#777", fillOpacity: 0.3, stroke: "#fff"}; export class Brush extends Mark { - constructor() { - super(undefined, {}, {}, {}); - this._brush = d3Brush(); + constructor(data, {dimension = "xy", interval, sync = false, ...options} = {}) { + const {x, y, z} = options; + super( + dataify(data), + { + x: {value: x, scale: "x", optional: true}, + y: {value: y, scale: "y", optional: true} + }, + options, + defaults + ); + this._dimension = keyword(dimension, "dimension", ["x", "y", "xy"]); + this._brush = this._dimension === "x" ? d3BrushX() : this._dimension === "y" ? d3BrushY() : d3Brush(); + this._interval = interval == null ? null : maybeInterval(interval); + const channelDefaults = {x, y, z, fx: this.fx, fy: this.fy}; + this.inactive = renderFilter(true, channelDefaults); + this.context = renderFilter(false, channelDefaults); + this.focus = renderFilter(false, channelDefaults); this._brushNodes = []; - this.inactive = renderFilter(true); - this.context = renderFilter(false); - this.focus = renderFilter(false); + this._sync = sync; } render(index, scales, values, dimensions, context) { const {x, y, fx, fy} = scales; - const {inactive, context: ctx, focus} = this; - let target, currentNode, clearing; + const X = values.channels?.x?.value; + const Y = values.channels?.y?.value; + const FX = values.channels?.fx?.value; + const FY = values.channels?.fy?.value; + const {data, _brush, _brushNodes, inactive, context: ctx, focus} = this; + let target, currentNode, syncing; if (!index?.fi) { + const dim = this._dimension; + const interval = this._interval; + if (context.projection && dim !== "xy") throw new Error(`brush${dim.toUpperCase()} does not support projections`); const invertX = (!context.projection && x?.invert) || ((d) => d); const invertY = (!context.projection && y?.invert) || ((d) => d); - this._applyX = (!context.projection && x) || ((d) => d); - this._applyY = (!context.projection && y) || ((d) => d); + const applyX = (this._applyX = (!context.projection && x) || ((d) => d)); + const applyY = (this._applyY = (!context.projection && y) || ((d) => d)); context.dispatchValue(null); - const {_brush, _brushNodes} = this; + const sync = this._sync; + const filterIndex = + dim !== "xy" + ? fx && fy ? (f) => (_, i) => f((dim === "x" ? X : Y)[i], FX[i], FY[i]) + : fx ? (f) => (_, i) => f((dim === "x" ? X : Y)[i], FX[i]) + : fy ? (f) => (_, i) => f((dim === "x" ? X : Y)[i], FY[i]) + : (f) => (_, i) => f((dim === "x" ? X : Y)[i]) + : fx && fy ? (f) => (_, i) => f(X[i], Y[i], FX[i], FY[i]) + : fx ? (f) => (_, i) => f(X[i], Y[i], FX[i]) + : fy ? (f) => (_, i) => f(X[i], Y[i], FY[i]) + : (f) => (_, i) => f(X[i], Y[i]); // prettier-ignore + const filterData = + data != null && + ((filter) => (filter === true ? data : filter === false ? [] : take(data, index.filter(filterIndex(filter))))); + let snapping; _brush .extent([ - [dimensions.marginLeft - 1, dimensions.marginTop - 1], - [dimensions.width - dimensions.marginRight + 1, dimensions.height - dimensions.marginBottom + 1] + [dimensions.marginLeft - (dim !== "y"), dimensions.marginTop - (dim !== "x")], + [dimensions.width - dimensions.marginRight + (dim !== "y"), dimensions.height - dimensions.marginBottom + (dim !== "x")] ]) .on("start brush end", function (event) { + if (syncing) return; const {selection, type} = event; - if (type === "start" && !clearing) { + if (type === "start" && !snapping) { target = event.sourceEvent?.currentTarget ?? this; currentNode = _brushNodes.indexOf(target); - if (!clearing) { - clearing = true; + if (event.sourceEvent) context.ownerSVGElement.classList.add("no-tip"); + if (!sync) { + syncing = true; selectAll(_brushNodes.filter((_, i) => i !== currentNode)).call(_brush.move, null); - clearing = false; - for (let i = 0; i < _brushNodes.length; ++i) { - inactive.update(false, i); - ctx.update(true, i); - focus.update(false, i); - } + syncing = false; + } + for (let i = 0; i < _brushNodes.length; ++i) { + inactive.update(false, i); + ctx.update(true, i); + focus.update(false, i); } } if (selection === null) { if (type === "end") { + context.ownerSVGElement.classList.remove("no-tip"); + if (sync) { + syncing = true; + selectAll(_brushNodes.filter((_, i) => i !== currentNode)).call(_brush.move, null); + syncing = false; + } for (let i = 0; i < _brushNodes.length; ++i) { inactive.update(true, i); ctx.update(false, i); @@ -53,52 +108,81 @@ export class Brush extends Mark { } context.dispatchValue(null); } else { - inactive.update(false, currentNode); - ctx.update(true, currentNode); - focus.update(false, currentNode); + for (let i = sync ? 0 : currentNode, n = sync ? _brushNodes.length : currentNode + 1; i < n; ++i) { + inactive.update(false, i); + ctx.update(true, i); + focus.update(false, i); + } let value = null; if (event.sourceEvent) { const [px, py] = pointer(event, this); - const x1 = invertX(px); - const y1 = invertY(py); const facet = target?.__data__; - const filter = filterFromBrush(x, y, facet, context.projection, px, px, py, py); + const filter = filterFromBrush(dim, interval, x, y, facet, context.projection, px, px, py, py); value = { - x1, - x2: x1, - y1, - y2: y1, + ...(dim !== "y" && {x1: invertX(px), x2: invertX(px)}), + ...(dim !== "x" && {y1: invertY(py), y2: invertY(py)}), ...(fx && facet && {fx: facet.x}), ...(fy && facet && {fy: facet.y}), filter, + ...(filterData && {data: filterData(filter)}), pending: true }; } context.dispatchValue(value); } } else { - const [[px1, py1], [px2, py2]] = selection; - inactive.update(false, currentNode); - ctx.update((xi, yi) => !(px1 <= xi && xi < px2 && py1 <= yi && yi < py2), currentNode); - focus.update((xi, yi) => px1 <= xi && xi < px2 && py1 <= yi && yi < py2, currentNode); - - let x1 = invertX(px1), - x2 = invertX(px2); - let y1 = invertY(py1), - y2 = invertY(py2); - if (x1 > x2) [x2, x1] = [x1, x2]; - if (y1 > y2) [y2, y1] = [y1, y2]; + const [[px1, py1], [px2, py2]] = + dim === "xy" + ? selection + : dim === "x" + ? [ + [selection[0], NaN], + [selection[1], NaN] + ] + : [ + [NaN, selection[0]], + [NaN, selection[1]] + ]; + + const inX = isNaN(px1) ? () => true : (xi) => px1 <= xi && xi < px2; + const inY = isNaN(py1) ? () => true : (yi) => py1 <= yi && yi < py2; + + if (sync) { + syncing = true; + selectAll(_brushNodes.filter((_, i) => i !== currentNode)).call(_brush.move, selection); + syncing = false; + } + for (let i = sync ? 0 : currentNode, n = sync ? _brushNodes.length : currentNode + 1; i < n; ++i) { + inactive.update(false, i); + ctx.update((xi, yi) => !(inX(xi) && inY(yi)), i); + focus.update((xi, yi) => inX(xi) && inY(yi), i); + } + + const [x1, x2] = invertX && [invertX(px1), invertX(px2)].sort(ascending); + const [y1, y2] = invertY && [invertY(py1), invertY(py2)].sort(ascending); + + // Snap to interval on end + if (type === "end" && interval && !snapping) { + const s1 = dim === "x" ? x1 : y1; + const s2 = dim === "x" ? x2 : y2; + const r1 = intervalRound(interval, s1); + let r2 = intervalRound(interval, s2); + if (+r1 === +r2) r2 = interval.offset(r1); + snapping = true; + select(this).call(_brush.move, [r1, r2].map(dim === "x" ? applyX : applyY).sort(ascending)); + snapping = false; + return; + } const facet = target?.__data__; - const filter = filterFromBrush(x, y, facet, context.projection, px1, px2, py1, py2); + const filter = filterFromBrush(dim, interval, x, y, facet, context.projection, px1, px2, py1, py2); context.dispatchValue({ - x1, - x2, - y1, - y2, + ...(dim !== "y" && {x1, x2}), + ...(dim !== "x" && {y1, y2}), ...(fx && facet && {fx: facet.x}), ...(fy && facet && {fy: facet.y}), filter, + ...(filterData && {data: filterData(filter)}), ...(type !== "end" && {pending: true}) }); } @@ -107,6 +191,12 @@ export class Brush extends Mark { const g = create("svg:g").attr("aria-label", "brush"); g.call(this._brush); + const sel = g.select(".selection"); + applyAttr(sel, "fill", this.fill); + applyAttr(sel, "fill-opacity", this.fillOpacity); + applyAttr(sel, "stroke", this.stroke); + applyAttr(sel, "stroke-width", this.strokeWidth); + applyAttr(sel, "stroke-opacity", this.strokeOpacity); const node = g.node(); this._brushNodes.push(node); return node; @@ -122,69 +212,132 @@ export class Brush extends Mark { return (fx === undefined || d?.x === fx) && (fy === undefined || d?.y === fy); }); if (!node) return; - const px1 = this._applyX(x1); - const px2 = this._applyX(x2); - const py1 = this._applyY(y1); - const py2 = this._applyY(y2); - select(node).call(this._brush.move, [ - [Math.min(px1, px2), Math.min(py1, py2)], - [Math.max(px1, px2), Math.max(py1, py2)] - ]); + const [px1, px2] = [x1, x2].map(this._applyX).sort(ascending); + const [py1, py2] = [y1, y2].map(this._applyY).sort(ascending); + select(node).call( + this._brush.move, + this._dimension === "xy" + ? [ + [px1, py1], + [px2, py2] + ] + : this._dimension === "x" + ? [px1, px2] + : [py1, py2] + ); } } -export function brush() { - return new Brush(); +export function brush(data, options = {}) { + if (arguments.length === 1 && !isIterable(data)) (options = data), (data = undefined); + let {x, y, ...rest} = options; + [x, y] = maybeTuple(x, y); + return new Brush(data, {...rest, x, y}); +} + +export function brushX(data, options = {}) { + if (arguments.length === 1 && !isIterable(data)) (options = data), (data = undefined); + let {x, interval, ...rest} = options; + if (x === undefined && data != null) x = identity; + return new Brush(data, {...rest, dimension: "x", interval, x}); +} + +export function brushY(data, options = {}) { + if (arguments.length === 1 && !isIterable(data)) (options = data), (data = undefined); + let {y, interval, ...rest} = options; + if (y === undefined && data != null) y = identity; + return new Brush(data, {...rest, dimension: "y", interval, y}); } -function filterFromBrush(xScale, yScale, facet, projection, px1, px2, py1, py2) { - let px, py; - const stream = projection?.stream({ - point(x, y) { - px = x; - py = y; +function filterFromBrush(dim, interval, xScale, yScale, facet, projection, px1, px2, py1, py2) { + switch (dim) { + case "x": + case "y": { + const floor = interval ? (d) => interval.floor(d) : (d) => d; + const [scale, pv1, pv2] = dim === "x" ? [xScale, px1, px2] : [yScale, py1, py2]; + let p; + return filterSignature1D((d) => ((p = scale(floor(d))), pv1 <= p && p < pv2), facet?.x, facet?.y); } - }) ?? { - point: (x, y) => { - px = xScale(x); - py = yScale(y); + case "xy": { + let px, py; + const stream = projection?.stream({ + point(x, y) { + px = x; + py = y; + } + }) ?? { + point: (x, y) => { + px = xScale(x); + py = yScale(y); + } + }; + return filterSignature2D( + (dx, dy) => (stream.point(dx, dy), px1 <= px && px < px2 && py1 <= py && py < py2), + facet?.x, + facet?.y + ); } - }; - return filterSignature( - (dx, dy) => { - stream.point(dx, dy); - return px1 <= px && px < px2 && py1 <= py && py < py2; - }, - facet?.x, - facet?.y - ); + } } -function filterSignature(test, currentFx, currentFy) { +function filterSignature2D(test, currentFx, currentFy) { return currentFx === undefined ? currentFy === undefined ? (x, y) => test(x, y) - : (x, y, fy) => fy === currentFy && test(x, y) + : (x, y, fy) => (fy === undefined || fy === currentFy) && test(x, y) : currentFy === undefined - ? (x, y, fx) => fx === currentFx && test(x, y) - : (x, y, fx, fy) => fx === currentFx && fy === currentFy && test(x, y); + ? (x, y, fx) => (fx === undefined || fx === currentFx) && test(x, y) + : (x, y, fx, fy) => (fx === undefined || fx === currentFx) && (fy === undefined || fy === currentFy) && test(x, y); } -function renderFilter(initialTest) { +function filterSignature1D(test, currentFx, currentFy) { + return currentFx === undefined + ? currentFy === undefined + ? (v) => test(v) + : (v, fy) => (fy === undefined || fy === currentFy) && test(v) + : currentFy === undefined + ? (v, fx) => (fx === undefined || fx === currentFx) && test(v) + : (v, fx, fy) => (fx === undefined || fx === currentFx) && (fy === undefined || fy === currentFy) && test(v); +} + +function intervalRound(interval, v) { + const lo = interval.floor(v); + const hi = interval.offset(lo); + v = +v; + return v - +lo < +hi - v ? lo : hi; +} + +function renderFilter(initialTest, channelDefaults = {}) { const updatePerFacet = []; return Object.assign( function ({render, ...options} = {}) { return { pointerEvents: "none", + ...channelDefaults, ...options, render: composeRender(function (index, scales, values, dimensions, context, next) { - const {x: X, y: Y} = values; - const filter = (test) => - typeof test === "function" ? index.filter((i) => test(X[i], Y[i])) : test ? index : []; - let g = next(filter(initialTest), scales, values, dimensions, context); + const {x: X, y: Y, x1: X1, x2: X2, y1: Y1, y2: Y2} = values; + const MX = X ?? (X1 && X2 ? Float64Array.from(X1, (v, i) => (v + X2[i]) / 2) : undefined); + const MY = Y ?? (Y1 && Y2 ? Float64Array.from(Y1, (v, i) => (v + Y2[i]) / 2) : undefined); + const render = (test) => { + if (typeof test !== "function") test = constant(test); + let run = []; + const runs = [run]; + for (const i of index) { + if (test(MX?.[i], MY?.[i])) run.push(i); + else if (run.length) runs.push((run = [])); + } + const g = next(runs[0], scales, values, dimensions, context); + for (const run of runs.slice(1)) { + const h = next(run, scales, values, dimensions, context); + while (h.firstChild) g.appendChild(h.firstChild); + } + return g; + }; + let g = render(initialTest); updatePerFacet.push((test) => { const transform = g.getAttribute("transform"); - g.replaceWith((g = next(filter(test), scales, values, dimensions, context))); + g.replaceWith((g = render(test))); if (transform) g.setAttribute("transform", transform); }); return g; diff --git a/src/interactions/pointer.js b/src/interactions/pointer.js index f0c0f765b0..5d553fbb3e 100644 --- a/src/interactions/pointer.js +++ b/src/interactions/pointer.js @@ -140,7 +140,8 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op // squashed, selecting primarily on the dominant dimension. Across facets, // use unsquashed distance to determine the winner. function pointermove(event) { - if (state.sticky || (event.pointerType === "mouse" && event.buttons === 1)) return; // dragging + if (state.sticky) return; + if (event.pointerType === "mouse" && event.buttons === 1) return void update(null); // hide tip during drag let [xp, yp] = pointof(event); (xp -= tx), (yp -= ty); // correct for facets and band scales const kpx = xp < dimensions.marginLeft || xp > dimensions.width - dimensions.marginRight ? 1 : kx; @@ -166,6 +167,7 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op if (i == null) return; // not pointing if (state.sticky && state.roots.some((r) => r?.contains(event.target))) return; // stay sticky if (state.sticky) (state.sticky = false), state.renders.forEach((r) => r(null)); // clear all pointers + else if (svg.classList.contains("no-tip")) return void update(null); // cancel tip on brush start else (state.sticky = true), render(i); event.stopImmediatePropagation(); // suppress other pointers } diff --git a/test/brush-test.ts b/test/brush-test.ts index 136460c93e..aa481befd9 100644 --- a/test/brush-test.ts +++ b/test/brush-test.ts @@ -160,6 +160,183 @@ it("brush programmatic move on second facet selects the correct facet", async () assert.equal([...species][0], "Chinstrap", "filtered species should be Chinstrap"); }); +it("brush faceted filter without fx selects across all facets", async () => { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + const b = new Plot.Brush(); + const xy = {x: "culmen_length_mm", y: "culmen_depth_mm", fx: "species"}; + const plot = Plot.plot({ + marks: [Plot.dot(penguins, b.inactive({...xy, r: 2})), b] + }); + + let lastValue: any; + plot.addEventListener("input", () => (lastValue = plot.value)); + b.move({x1: 35, x2: 50, y1: 14, y2: 20, fx: "Adelie"}); + + // With fx: restricts to Adelie + const withFx = penguins.filter((d: any) => lastValue.filter(d.culmen_length_mm, d.culmen_depth_mm, d.species)); + assert.ok( + withFx.every((d: any) => d.species === "Adelie"), + "with fx should only select Adelie" + ); + + // Without fx: selects across all facets + const withoutFx = penguins.filter((d: any) => lastValue.filter(d.culmen_length_mm, d.culmen_depth_mm)); + const species = new Set(withoutFx.map((d: any) => d.species)); + assert.ok(species.size > 1, `without fx should select multiple species, got: ${[...species]}`); + assert.ok(withoutFx.length > withFx.length, "without fx should select more points"); +}); + +it("brush cross-facet filter selects across all facets", async () => { + const stocks = [ + ...(await d3.csv("data/aapl.csv", d3.autoType)).map((d: any) => ({...d, Symbol: "AAPL"})), + ...(await d3.csv("data/amzn.csv", d3.autoType)).map((d: any) => ({...d, Symbol: "AMZN"})), + ...(await d3.csv("data/goog.csv", d3.autoType)).map((d: any) => ({...d, Symbol: "GOOG"})) + ]; + const b = Plot.brushX({sync: true}); + const plot = Plot.plot({ + marks: [Plot.lineY(stocks, b.inactive({x: "Date", y: "Close", fy: "Symbol"})), b] + }); + + let lastValue: any; + plot.addEventListener("input", () => (lastValue = plot.value)); + b.move({x1: new Date("2015-01-01"), x2: new Date("2016-06-01"), fy: "AAPL"}); + + assert.ok(lastValue, "should have a value"); + assert.ok(lastValue.fy !== undefined, "value should include fy (origin facet)"); + + // Without fy: selects across all facets + const withoutFy = stocks.filter((d: any) => lastValue.filter(d.Date)); + const symbols = new Set(withoutFy.map((d: any) => d.Symbol)); + assert.ok(symbols.size > 1, `should select multiple symbols, got: ${[...symbols]}`); + + // With fy: restricts to the origin facet + const withFy = stocks.filter((d: any) => lastValue.filter(d.Date, d.Symbol)); + const fySymbols = new Set(withFy.map((d: any) => d.Symbol)); + assert.equal(fySymbols.size, 1, "with fy should restrict to origin facet"); + assert.ok(withFy.length < withoutFy.length, "with fy should select fewer points"); +}); + +it("brush faceted filter with fx and fy supports partial facet args", async () => { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + const b = new Plot.Brush(); + const xy = {x: "culmen_length_mm", y: "culmen_depth_mm", fx: "species", fy: "sex"}; + const plot = Plot.plot({ + marks: [Plot.dot(penguins, b.inactive({...xy, r: 2})), b] + }); + + let lastValue: any; + plot.addEventListener("input", () => (lastValue = plot.value)); + b.move({x1: 35, x2: 50, y1: 14, y2: 20, fx: "Adelie", fy: "MALE"}); + + // Both fx and fy: restricts to Adelie MALE + const withBoth = penguins.filter((d: any) => + lastValue.filter(d.culmen_length_mm, d.culmen_depth_mm, d.species, d.sex) + ); + assert.ok(withBoth.length > 0, "should select some points"); + assert.ok( + withBoth.every((d: any) => d.species === "Adelie" && d.sex === "MALE"), + "should only select Adelie MALE" + ); + + // Only fx (fy undefined): restricts to Adelie, any sex + const withFx = penguins.filter((d: any) => lastValue.filter(d.culmen_length_mm, d.culmen_depth_mm, d.species)); + assert.ok(withFx.length >= withBoth.length, "with fx only should select at least as many"); + assert.ok( + withFx.every((d: any) => d.species === "Adelie"), + "with fx only should restrict to Adelie" + ); + const sexes = new Set(withFx.map((d: any) => d.sex)); + assert.ok(sexes.size > 1, `with fx only should include multiple sexes, got: ${[...sexes]}`); + + // Only fy (fx undefined): restricts to MALE, any species + const withFy = penguins.filter((d: any) => lastValue.filter(d.culmen_length_mm, d.culmen_depth_mm, undefined, d.sex)); + assert.ok(withFy.length >= withBoth.length, "with fy only should select at least as many"); + assert.ok( + withFy.every((d: any) => d.sex === "MALE"), + "with fy only should restrict to MALE" + ); + const spp = new Set(withFy.map((d: any) => d.species)); + assert.ok(spp.size > 1, `with fy only should include multiple species, got: ${[...spp]}`); + + // Neither fx nor fy: selects across all facets + const withNeither = penguins.filter((d: any) => lastValue.filter(d.culmen_length_mm, d.culmen_depth_mm)); + assert.ok(withNeither.length >= withFx.length, "without facets should select at least as many as fx only"); + assert.ok(withNeither.length >= withFy.length, "without facets should select at least as many as fy only"); + const allSpecies = new Set(withNeither.map((d: any) => d.species)); + const allSexes = new Set(withNeither.map((d: any) => d.sex)); + assert.ok(allSpecies.size > 1, "without facets should include multiple species"); + assert.ok(allSexes.size > 1, "without facets should include multiple sexes"); +}); + +it("brush with data includes filtered data in value", () => { + const data = [ + {x: 10, y: 10}, + {x: 20, y: 20}, + {x: 30, y: 30}, + {x: 40, y: 40}, + {x: 50, y: 50} + ]; + const brush = Plot.brush(data, {x: "x", y: "y"}); + const plot = Plot.plot({ + x: {domain: [0, 60]}, + y: {domain: [0, 60]}, + marks: [ + brush, + Plot.dot(data, brush.inactive()), + Plot.dot(data, brush.context({fill: "#ccc"})), + Plot.dot(data, brush.focus({fill: "red"})) + ] + }); + + let lastValue: any; + plot.addEventListener("input", () => (lastValue = plot.value)); + brush.move({x1: 15, x2: 35, y1: 15, y2: 35}); + + assert.ok(lastValue, "should have a value"); + assert.ok(Array.isArray(lastValue.data), "value should have a data array"); + assert.equal(lastValue.data.length, 2, "filtered data should contain 2 points"); + assert.deepEqual(lastValue.data, [ + {x: 20, y: 20}, + {x: 30, y: 30} + ]); +}); + +it("brush with generator data includes filtered data in value", () => { + const data = [ + {x: 10, y: 10}, + {x: 20, y: 20}, + {x: 30, y: 30}, + {x: 40, y: 40}, + {x: 50, y: 50} + ]; + function* generate() { + yield* data; + } + const brush = Plot.brush(generate(), {x: "x", y: "y"}); + const plot = Plot.plot({ + x: {domain: [0, 60]}, + y: {domain: [0, 60]}, + marks: [ + brush, + Plot.dot(data, brush.inactive()), + Plot.dot(data, brush.context({fill: "#ccc"})), + Plot.dot(data, brush.focus({fill: "red"})) + ] + }); + + let lastValue: any; + plot.addEventListener("input", () => (lastValue = plot.value)); + brush.move({x1: 15, x2: 35, y1: 15, y2: 35}); + + assert.ok(lastValue, "should have a value"); + assert.ok(Array.isArray(lastValue.data), "value should have a data array"); + assert.equal(lastValue.data.length, 2, "filtered data should contain 2 points"); + assert.deepEqual(lastValue.data, [ + {x: 20, y: 20}, + {x: 30, y: 30} + ]); +}); + it("brush reactive marks compose with user render transforms", () => { const data = [ {x: 10, y: 10}, @@ -185,3 +362,79 @@ it("brush reactive marks compose with user render transforms", () => { }); assert.equal(rendered.length, 3, "user render should have been called for each reactive mark"); }); + +it("brushX value has x1/x2 but no y1/y2", async () => { + const data = [ + {x: 10, y: 10}, + {x: 20, y: 20}, + {x: 30, y: 30}, + {x: 40, y: 40}, + {x: 50, y: 50} + ]; + const brush = Plot.brushX(); + const plot = Plot.plot({ + x: {domain: [0, 60]}, + y: {domain: [0, 60]}, + marks: [ + Plot.dot(data, brush.inactive({x: "x", y: "y"})), + Plot.dot(data, brush.context({x: "x", y: "y", fill: "#ccc"})), + Plot.dot(data, brush.focus({x: "x", y: "y", fill: "red"})), + brush + ] + }); + + let lastValue: any; + plot.addEventListener("input", () => (lastValue = plot.value)); + + brush.move({x1: 15, x2: 45}); + + assert.ok(lastValue, "should have a value"); + assert.ok("x1" in lastValue, "value should have x1"); + assert.ok("x2" in lastValue, "value should have x2"); + assert.ok(!("y1" in lastValue), "value should not have y1"); + assert.ok(!("y2" in lastValue), "value should not have y2"); + assert.ok(typeof lastValue.filter === "function", "value should have a filter function"); + + // 1D filter takes a single argument + const filtered = data.filter((d) => lastValue.filter(d.x)); + assert.ok(filtered.length > 0, "should select some points"); + assert.ok(filtered.length < data.length, "should not include all points"); +}); + +it("brushY value has y1/y2 but no x1/x2", async () => { + const data = [ + {x: 10, y: 10}, + {x: 20, y: 20}, + {x: 30, y: 30}, + {x: 40, y: 40}, + {x: 50, y: 50} + ]; + const brush = Plot.brushY(); + const plot = Plot.plot({ + x: {domain: [0, 60]}, + y: {domain: [0, 60]}, + marks: [ + Plot.dot(data, brush.inactive({x: "x", y: "y"})), + Plot.dot(data, brush.context({x: "x", y: "y", fill: "#ccc"})), + Plot.dot(data, brush.focus({x: "x", y: "y", fill: "red"})), + brush + ] + }); + + let lastValue: any; + plot.addEventListener("input", () => (lastValue = plot.value)); + + brush.move({y1: 15, y2: 45}); + + assert.ok(lastValue, "should have a value"); + assert.ok("y1" in lastValue, "value should have y1"); + assert.ok("y2" in lastValue, "value should have y2"); + assert.ok(!("x1" in lastValue), "value should not have x1"); + assert.ok(!("x2" in lastValue), "value should not have x2"); + assert.ok(typeof lastValue.filter === "function", "value should have a filter function"); + + // 1D filter takes a single argument + const filtered = data.filter((d) => lastValue.filter(d.y)); + assert.ok(filtered.length > 0, "should select some points"); + assert.ok(filtered.length < data.length, "should not include all points"); +}); diff --git a/test/output/brushBrutalist.svg b/test/output/brushBrutalist.svg new file mode 100644 index 0000000000..0fa4df4291 --- /dev/null +++ b/test/output/brushBrutalist.svg @@ -0,0 +1,415 @@ + + + + + 14 + 15 + 16 + 17 + 18 + 19 + 20 + 21 + + + ↑ culmen_depth_mm + + + + 35 + 40 + 45 + 50 + 55 + + + culmen_length_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/brushCoordinates.html b/test/output/brushCoordinates.html new file mode 100644 index 0000000000..8da067dfed --- /dev/null +++ b/test/output/brushCoordinates.html @@ -0,0 +1,275 @@ +
+ + + + −2.5 + −2.0 + −1.5 + −1.0 + −0.5 + 0.0 + 0.5 + 1.0 + 1.5 + 2.0 + 2.5 + + + + −2 + −1 + 0 + 1 + 2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/brushCrossFacet.html b/test/output/brushCrossFacet.html new file mode 100644 index 0000000000..41719d7e2d --- /dev/null +++ b/test/output/brushCrossFacet.html @@ -0,0 +1,252 @@ +
+ + + + AAPL + + + AMZN + + + GOOG + + + + + + + + + + + 100 + + + + + + + + + 1k + + + + + + + 100 + + + + + + + + + 1k + + + + + + + 100 + + + + + + + + + 1k + + + + ↑ Close + + + + + 2014 + 2015 + 2016 + 2017 + 2018 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/brushDotTip.svg b/test/output/brushDotTip.svg new file mode 100644 index 0000000000..abfb11e928 --- /dev/null +++ b/test/output/brushDotTip.svg @@ -0,0 +1,416 @@ + + + + + 14 + 15 + 16 + 17 + 18 + 19 + 20 + 21 + + + ↑ culmen_depth_mm + + + + 35 + 40 + 45 + 50 + 55 + + + culmen_length_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/brushXData.html b/test/output/brushXData.html new file mode 100644 index 0000000000..6113631742 --- /dev/null +++ b/test/output/brushXData.html @@ -0,0 +1,389 @@ +
+ + + + 3,000 + 3,500 + 4,000 + 4,500 + 5,000 + 5,500 + 6,000 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/brushXDot.html b/test/output/brushXDot.html new file mode 100644 index 0000000000..fc1cbd3fe5 --- /dev/null +++ b/test/output/brushXDot.html @@ -0,0 +1,392 @@ +
+ + + + 3,000 + 3,500 + 4,000 + 4,500 + 5,000 + 5,500 + 6,000 + + + body_mass_g → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/brushXHistogram.html b/test/output/brushXHistogram.html new file mode 100644 index 0000000000..9ecc0d0692 --- /dev/null +++ b/test/output/brushXHistogram.html @@ -0,0 +1,119 @@ +
+ + + + 0 + 2 + 4 + 6 + 8 + 10 + 12 + 14 + 16 + 18 + 20 + 22 + + + ↑ Frequency + + + + 3,000 + 3,500 + 4,000 + 4,500 + 5,000 + 5,500 + 6,000 + + + body_mass_g → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/brushXHistogramFaceted.html b/test/output/brushXHistogramFaceted.html new file mode 100644 index 0000000000..c00d52ca42 --- /dev/null +++ b/test/output/brushXHistogramFaceted.html @@ -0,0 +1,295 @@ +
+
+
+ + + Adelie + + Chinstrap + + Gentoo +
+ + + + Biscoe + + + Dream + + + Torgersen + + + + + + + 0 + 5 + 10 + 15 + + + 0 + 5 + 10 + 15 + + + 0 + 5 + 10 + 15 + + + + ↑ Frequency + + + + + 3,000 + 3,500 + 4,000 + 4,500 + 5,000 + 5,500 + 6,000 + + + + body_mass_g → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
\ No newline at end of file diff --git a/test/output/brushXLine.html b/test/output/brushXLine.html new file mode 100644 index 0000000000..75ae43b29b --- /dev/null +++ b/test/output/brushXLine.html @@ -0,0 +1,81 @@ +
+ + + + 60 + 70 + 80 + 90 + 100 + 110 + 120 + 130 + 140 + 150 + 160 + 170 + 180 + 190 + + + ↑ Close + + + + 2014 + 2015 + 2016 + 2017 + 2018 + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/brushXTemporal.html b/test/output/brushXTemporal.html new file mode 100644 index 0000000000..4a296ce4f3 --- /dev/null +++ b/test/output/brushXTemporal.html @@ -0,0 +1,146 @@ +
+ + + + + 0 + 20 + 40 + 60 + 80 + 100 + 120 + 140 + 160 + 180 + + + ↑ Close + + + + 2014 + 2015 + 2016 + 2017 + 2018 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/brushXTemporalReversed.html b/test/output/brushXTemporalReversed.html new file mode 100644 index 0000000000..07068acf6e --- /dev/null +++ b/test/output/brushXTemporalReversed.html @@ -0,0 +1,146 @@ +
+ + + + + 0 + 20 + 40 + 60 + 80 + 100 + 120 + 140 + 160 + 180 + + + ↑ Close + + + + 2018 + 2017 + 2016 + 2015 + 2014 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/brushYDot.html b/test/output/brushYDot.html new file mode 100644 index 0000000000..d08a406801 --- /dev/null +++ b/test/output/brushYDot.html @@ -0,0 +1,411 @@ +
+ + + + 14 + 15 + 16 + 17 + 18 + 19 + 20 + 21 + + + ↑ culmen_depth_mm + + + + 35 + 40 + 45 + 50 + 55 + + + culmen_length_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/brushYHistogram.html b/test/output/brushYHistogram.html new file mode 100644 index 0000000000..7db3c552b6 --- /dev/null +++ b/test/output/brushYHistogram.html @@ -0,0 +1,113 @@ +
+ + + + 13 + 14 + 15 + 16 + 17 + 18 + 19 + 20 + 21 + 22 + + + ↑ culmen_depth_mm + + + + + 0 + 5 + 10 + 15 + 20 + 25 + 30 + 35 + 40 + + + Frequency → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/plots/brush.ts b/test/plots/brush.ts index 2419f4b2ed..33cab5160a 100644 --- a/test/plots/brush.ts +++ b/test/plots/brush.ts @@ -6,11 +6,17 @@ import {html} from "htl"; function formatValue(v: any) { if (v == null) return JSON.stringify(v); - const o: any = {}; + const lines: string[] = []; for (const [k, val] of Object.entries(v)) { - o[k] = typeof val === "function" ? `${k}(${paramNames(val as (...args: any[]) => any)})` : val; + const formatted = + typeof val === "function" + ? `${k}(${paramNames(val as (...args: any[]) => any)})` + : Array.isArray(val) + ? `Array(${val.length})` + : JSON.stringify(val); + lines.push(` ${k}: ${formatted}`); } - return JSON.stringify(o, null, 2); + return `{\n${lines.join(",\n")}\n}`; } function paramNames(fn: (...args: any[]) => any) { @@ -275,6 +281,351 @@ export async function brushRandomNormal() { return html`
${plot}${textarea}
`; } +export async function brushCrossFacet() { + const stocks = [ + ...(await d3.csv("data/aapl.csv", d3.autoType)).map((d: any) => ({...d, Symbol: "AAPL"})), + ...(await d3.csv("data/amzn.csv", d3.autoType)).map((d: any) => ({...d, Symbol: "AMZN"})), + ...(await d3.csv("data/goog.csv", d3.autoType)).map((d: any) => ({...d, Symbol: "GOOG"})) + ]; + const brush = Plot.brushX({sync: true}); + const plot = Plot.plot({ + height: 350, + y: {type: "log", grid: true}, + fy: {label: null}, + marks: [ + Plot.frame(), + brush, + Plot.lineY(stocks, {x: "Date", y: "Close", fy: "Symbol", stroke: "#ccc"}), + Plot.lineY(stocks, brush.inactive({x: "Date", y: "Close", fy: "Symbol", stroke: "Symbol"})), + Plot.lineY(stocks, brush.focus({x: "Date", y: "Close", fy: "Symbol", stroke: "Symbol", strokeWidth: 2})) + ] + }); + const textarea = html`