From c269a486f4c2aa3bf346e20ec0a77f213568ac3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 29 May 2023 19:03:05 +0200 Subject: [PATCH 01/39] brush interaction --- docs/.vitepress/config.ts | 1 + docs/features/interactions.md | 22 +- docs/interactions/brush.md | 14 + src/index.d.ts | 1 + src/index.js | 1 + src/interactions/brush.d.ts | 36 +++ src/interactions/brush.js | 93 +++++++ src/plot.js | 8 +- test/output/brushBand.svg | 52 ++++ test/output/brushFacets.svg | 443 +++++++++++++++++++++++++++++++ test/output/brushRectX.svg | 84 ++++++ test/output/brushRectY.svg | 84 ++++++ test/output/brushScatterplot.svg | 401 ++++++++++++++++++++++++++++ test/plots/brush.ts | 53 ++++ test/plots/index.ts | 1 + 15 files changed, 1292 insertions(+), 2 deletions(-) create mode 100644 docs/interactions/brush.md create mode 100644 src/interactions/brush.d.ts create mode 100644 src/interactions/brush.js create mode 100644 test/output/brushBand.svg create mode 100644 test/output/brushFacets.svg create mode 100644 test/output/brushRectX.svg create mode 100644 test/output/brushRectY.svg create mode 100644 test/output/brushScatterplot.svg create mode 100644 test/plots/brush.ts diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index fa9a56dfa6..b613dac095 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -131,6 +131,7 @@ export default defineConfig({ text: "Interactions", collapsed: true, items: [ + {text: "Brush", link: "/interactions/brush"}, {text: "Crosshair", link: "/interactions/crosshair"}, {text: "Pointer", link: "/interactions/pointer"} ] diff --git a/docs/features/interactions.md b/docs/features/interactions.md index bf1dae5741..9dba9576df 100644 --- a/docs/features/interactions.md +++ b/docs/features/interactions.md @@ -13,6 +13,15 @@ onMounted(() => { d3.csv("../data/athletes.csv", d3.autoType).then((data) => (olympians.value = data)); }); +const penguins = shallowRef([ + {culmen_length_mm: 32.1, culmen_depth_mm: 13.1}, + {culmen_length_mm: 59.6, culmen_depth_mm: 21.5} +]); + +onMounted(() => { + d3.csv("../data/penguins.csv", d3.autoType).then((data) => (penguins.value = data)); +}); + # Interactions @@ -54,7 +63,18 @@ These values are displayed atop the axes on the edge of the frame; unlike the ti ## Selecting -Support for selecting points within a plot through direct manipulation is under development. If you are interested in this feature, please upvote [#5](https://github.com/observablehq/plot/issues/5). See [#721](https://github.com/observablehq/plot/pull/721) for some early work on brushing. +The [brush transform](../interactions/brush.md) allows the interactive selection of discrete elements, such as dots in a scatterplot, by direct manipulation of the chart. A brush listens to mouse and touch events on the chart, allowing the user to define a rectangular region. All the data points that fall within the region are included in the selection. + +:::plot defer https://observablehq.com/@observablehq/plot-brush-interaction-dev +```js +Plot.plot({ + marks: [ + Plot.dot(penguins, { x: "culmen_length_mm", y: "culmen_depth_mm" }), + Plot.dot(penguins, Plot.brush({ x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species", stroke: "currentColor" })) + ] +}) +``` +::: ## Zooming diff --git a/docs/interactions/brush.md b/docs/interactions/brush.md new file mode 100644 index 0000000000..e974a656e0 --- /dev/null +++ b/docs/interactions/brush.md @@ -0,0 +1,14 @@ +# Brush transform + + +## Brush options + + +## brush(*options*) + + +## brushX(*options*) + + +## brushY(*options*) + diff --git a/src/index.d.ts b/src/index.d.ts index 24e2344eef..47e17628be 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -4,6 +4,7 @@ export * from "./curve.js"; export * from "./dimensions.js"; export * from "./format.js"; export * from "./inset.js"; +export * from "./interactions/brush.js"; export * from "./interactions/pointer.js"; export * from "./interval.js"; export * from "./legends.js"; diff --git a/src/index.js b/src/index.js index 98f5772b0d..9bf957f30b 100644 --- a/src/index.js +++ b/src/index.js @@ -42,6 +42,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, brushX, brushY} from "./interactions/brush.js"; export {pointer, pointerX, pointerY} from "./interactions/pointer.js"; export {formatIsoDate, formatWeekday, formatMonth} from "./format.js"; export {scale} from "./scales.js"; diff --git a/src/interactions/brush.d.ts b/src/interactions/brush.d.ts new file mode 100644 index 0000000000..8601a7aa3b --- /dev/null +++ b/src/interactions/brush.d.ts @@ -0,0 +1,36 @@ +import type {Rendered} from "../transforms/basic.js"; + +/** Options for the brush transform. */ +type BrushOptions = { + /** + * The brush’s intersection strategy; currently only “rect” is implemented, + * checking whether a rectangle defined by and intersects + * the brush area. + */ + intersection?: "rect"; +}; + +/** + * Applies a render transform to the specified *options* to filter the mark + * index such that only the point closest to the pointer is rendered; the mark + * will re-render interactively in response to pointer events. + */ +export function brush(options: T & BrushOptions): Rendered; + +/** + * Like the pointer transform, except the determination of the closest point + * considers mostly the *x* (horizontal↔︎) position; this should be used for + * plots where *x* is the dominant dimension, such as time in a time-series + * chart, the binned quantitative dimension in a histogram, or the categorical + * dimension of a bar chart. + */ +export function brushX(options: T & BrushOptions): Rendered; + +/** + * Like the pointer transform, except the determination of the closest point + * considers mostly the *y* (vertical↕︎) position; this should be used for plots + * where *y* is the dominant dimension, such as time in a time-series chart, the + * binned quantitative dimension in a histogram, or the categorical dimension of + * a bar chart. + */ +export function brushY(options: T & BrushOptions): Rendered; diff --git a/src/interactions/brush.js b/src/interactions/brush.js new file mode 100644 index 0000000000..e40e8db867 --- /dev/null +++ b/src/interactions/brush.js @@ -0,0 +1,93 @@ +import {create} from "../context.js"; +import {brush as brusher, brushX as brusherX, brushY as brusherY, union} from "d3"; +import {composeRender} from "../mark.js"; + +function brushTransform(mode, options) { + return { + ...options, + // Unlike other composed transforms, interactive transforms must be the + // outermost render function because they will re-render dynamically in + // response to pointer events. + render: composeRender(function (index, scales, values, dimensions, context, next) { + const {data} = context.getMarkState(this); + const {width, height, marginLeft, marginTop, marginRight, marginBottom} = dimensions; + context.dispatchValue(data); + let viz = next.call(this, [], scales, values, dimensions, context); + const {x, y} = scales; + const {x: X, x1: X1, x2: X2, y: Y, y1: Y1, y2: Y2} = values; + + const Xl = X1 && X2 ? X1.map((d, i) => Math.min(d, X2[i])) : X; + const Xm = X1 && X2 ? X1.map((d, i) => Math.max(d, X2[i])) : X; + const Yl = Y1 && Y2 ? Y1.map((d, i) => Math.min(d, Y2[i])) : Y; + const Ym = Y1 && Y2 ? Y1.map((d, i) => Math.max(d, Y2[i])) : Y; + + // Use a RAF so we have access to the (facet) transform + window.requestAnimationFrame?.(() => { + const transform = viz.getAttribute("transform"); // Facet transform + // Register all brushes on the mark so they can communicate + if (!this.brushes) this.brushes = []; + const {brushes} = this; + + const g = create("svg:g", context).attr("transform", transform).attr("class", "brusher"); + viz.replaceWith(g.node()); + viz.removeAttribute("transform"); + g.append(() => viz); + + const extent = [ + [marginLeft, marginTop], + [width - marginRight, height - marginBottom] + ]; + + const brush = (mode === "xy" ? brusher : mode === "x" ? brusherX : brusherY)().on( + "brush start end", + (event) => { + const {type, selection} = event; + let S; + if (selection) { + S = index; + if (mode.includes("x")) { + let [x0, x1] = mode === "xy" ? [selection[0][0], selection[1][0]] : selection; + if (x?.bandwidth) x0 -= x.bandwidth(); + S = S.filter((i) => x0 <= Xm[i] && Xl[i] <= x1); + } + if (mode.includes("y")) { + let [y0, y1] = mode === "xy" ? [selection[0][1], selection[1][1]] : selection; + if (y?.bandwidth) y0 -= y.bandwidth(); + S = S.filter((i) => y0 <= Ym[i] && Yl[i] <= y1); + } + } + viz.replaceWith((viz = next.call(this, S ?? [], scales, values, dimensions, context))); + + for (const b of brushes) { + if (b.brush === brush) { + b.index = S; + } else if (type === "start" && !event?.sourceEvent?.metaKey) { + b.selection.call(b.brush.move, null); + } + } + const filtered = brushes.map(({index}) => index).filter((d) => d); + context.dispatchValue( + filtered.length === 0 ? data : Array.from(union(...filtered), (i) => data[i]) // 🌶 todo typed array if data is numeric + ); + } + ); + + this.brushes.push({brush, selection: g.append("g").call(brush.extent(extent))}); + }); + + return viz; + }, options.render) + }; +} + +export function brush(options = {}) { + return brushTransform("xy", options); +} + +export function brushX(options = {}) { + return brushTransform("x", options); +} + +export function brushY(options = {}) { + return brushTransform("y", options); +} diff --git a/src/plot.js b/src/plot.js index 039a269ada..288599eab4 100644 --- a/src/plot.js +++ b/src/plot.js @@ -173,7 +173,7 @@ export function plot(options = {}) { // Allows e.g. the pointer transform to support viewof. context.dispatchValue = (value) => { - if (figure.value === value) return; + if (figure.value === value || selectionEquals(figure.value, value)) return; figure.value = value; figure.dispatchEvent(new Event("input", {bubbles: true})); }; @@ -742,3 +742,9 @@ function outerRange(scale) { if (x2 < x1) [x1, x2] = [x2, x1]; return [x1, x2 + scale.bandwidth()]; } + +function selectionEquals(A, B) { + if (!Array.isArray(A) || !Array.isArray(B) || A.length != B.length) return false; + for (let i = 0; i < A.length; ++i) if (A[i] !== B[i]) return false; + return true; +} diff --git a/test/output/brushBand.svg b/test/output/brushBand.svg new file mode 100644 index 0000000000..de5dbb9d2a --- /dev/null +++ b/test/output/brushBand.svg @@ -0,0 +1,52 @@ + + + + + + + + + FEMALE + MALE + + + sex + + + + + + + + Adelie + Chinstrap + Gentoo + + + species + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/brushFacets.svg b/test/output/brushFacets.svg new file mode 100644 index 0000000000..dac8092aa9 --- /dev/null +++ b/test/output/brushFacets.svg @@ -0,0 +1,443 @@ + + + + + Adelie + + + Chinstrap + + + Gentoo + + + + species + + + + + + + + + + + + + + + + 14 + 15 + 16 + 17 + 18 + 19 + 20 + 21 + + + + ↑ culmen_depth_mm + + + + + + + + + + + + + + + + + + 40 + 50 + + + 40 + 50 + + + 40 + 50 + + + + culmen_length_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/brushRectX.svg b/test/output/brushRectX.svg new file mode 100644 index 0000000000..b3c58cfacc --- /dev/null +++ b/test/output/brushRectX.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + + 3,000 + 3,500 + 4,000 + 4,500 + 5,000 + 5,500 + 6,000 + + + ↑ body_mass_g + + + + + + + + + + + + + + 0 + 5 + 10 + 15 + 20 + 25 + 30 + 35 + 40 + + + Frequency → + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/brushRectY.svg b/test/output/brushRectY.svg new file mode 100644 index 0000000000..2b5745d0f2 --- /dev/null +++ b/test/output/brushRectY.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + 0 + 5 + 10 + 15 + 20 + 25 + 30 + 35 + 40 + + + ↑ 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/brushScatterplot.svg b/test/output/brushScatterplot.svg new file mode 100644 index 0000000000..ca02f4f6c9 --- /dev/null +++ b/test/output/brushScatterplot.svg @@ -0,0 +1,401 @@ + + + + + + + + + + + + + + 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/plots/brush.ts b/test/plots/brush.ts new file mode 100644 index 0000000000..33635378fa --- /dev/null +++ b/test/plots/brush.ts @@ -0,0 +1,53 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export async function brushBand() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + marks: [ + Plot.cell(penguins, Plot.group({fill: "count"}, {x: "species", y: "sex", fillOpacity: 0.3})), + Plot.cell(penguins, Plot.brush(Plot.group({fill: "count"}, {x: "species", y: "sex"}))) + ] + }); +} + +export async function brushFacets() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + facet: {data: penguins, x: "species"}, + marks: [ + Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm"}), + Plot.dot(penguins, Plot.brush({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species", stroke: "black"})) + ] + }); +} + +export async function brushRectX() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + marks: [ + Plot.rectX(penguins, Plot.binY({x: "count"}, {y: "body_mass_g", fillOpacity: 0.5, thresholds: 20})), + Plot.rectX(penguins, Plot.brushY(Plot.binY({x: "count"}, {y: "body_mass_g", thresholds: 20}))) + ] + }); +} + +export async function brushRectY() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + marks: [ + Plot.rectY(penguins, Plot.binX({y: "count"}, {x: "body_mass_g", fillOpacity: 0.5, thresholds: 20})), + Plot.rectY(penguins, Plot.brushX(Plot.binX({y: "count"}, {x: "body_mass_g", thresholds: 20}))) + ] + }); +} + +export async function brushScatterplot() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + marks: [ + Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm"}), + Plot.dot(penguins, Plot.brush({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species", stroke: "black"})) + ] + }); +} diff --git a/test/plots/index.ts b/test/plots/index.ts index 9998e92373..a1eec099e2 100644 --- a/test/plots/index.ts +++ b/test/plots/index.ts @@ -42,6 +42,7 @@ export * from "./bin-strings.js"; export * from "./bin-timestamps.js"; export * from "./bounding-boxes.js"; export * from "./boxplot.js"; +export * from "./brush.js"; export * from "./caltrain-direction.js"; export * from "./caltrain.js"; export * from "./cars-dodge.js"; From addf3c5e1c206635bba69815f9fac88d45083b37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 29 May 2023 23:12:10 +0200 Subject: [PATCH 02/39] remove metaKey test --- src/interactions/brush.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interactions/brush.js b/src/interactions/brush.js index e40e8db867..a916f3c308 100644 --- a/src/interactions/brush.js +++ b/src/interactions/brush.js @@ -61,7 +61,7 @@ function brushTransform(mode, options) { for (const b of brushes) { if (b.brush === brush) { b.index = S; - } else if (type === "start" && !event?.sourceEvent?.metaKey) { + } else if (type === "start") { b.selection.call(b.brush.move, null); } } From 7824515d5022ea2015991b4cbf19ef5a2a9d8549 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 29 May 2023 23:30:53 +0200 Subject: [PATCH 03/39] apply suggestions from review --- src/interactions/brush.js | 112 ++++++++++++++++++++++---------------- src/plot.js | 8 +-- 2 files changed, 65 insertions(+), 55 deletions(-) diff --git a/src/interactions/brush.js b/src/interactions/brush.js index a916f3c308..185cb927e0 100644 --- a/src/interactions/brush.js +++ b/src/interactions/brush.js @@ -1,7 +1,9 @@ import {create} from "../context.js"; -import {brush as brusher, brushX as brusherX, brushY as brusherY, union} from "d3"; +import {brush as brusher, brushX as brusherX, brushY as brusherY} from "d3"; import {composeRender} from "../mark.js"; +const states = new WeakMap(); + function brushTransform(mode, options) { return { ...options, @@ -9,72 +11,79 @@ function brushTransform(mode, options) { // outermost render function because they will re-render dynamically in // response to pointer events. render: composeRender(function (index, scales, values, dimensions, context, next) { + const svg = context.ownerSVGElement; const {data} = context.getMarkState(this); - const {width, height, marginLeft, marginTop, marginRight, marginBottom} = dimensions; - context.dispatchValue(data); + + // Isolate state per-brush, per-plot; if the brush is reused by multiple + // marks, they will share the same state. + let state = states.get(svg); + if (!state) states.set(svg, (state = {brushes: [], selection: null})); + const {brushes} = state; let viz = next.call(this, [], scales, values, dimensions, context); const {x, y} = scales; const {x: X, x1: X1, x2: X2, y: Y, y1: Y1, y2: Y2} = values; - const Xl = X1 && X2 ? X1.map((d, i) => Math.min(d, X2[i])) : X; const Xm = X1 && X2 ? X1.map((d, i) => Math.max(d, X2[i])) : X; const Yl = Y1 && Y2 ? Y1.map((d, i) => Math.min(d, Y2[i])) : Y; const Ym = Y1 && Y2 ? Y1.map((d, i) => Math.max(d, Y2[i])) : Y; + const {width, height, marginLeft, marginTop, marginRight, marginBottom} = dimensions; - // Use a RAF so we have access to the (facet) transform - window.requestAnimationFrame?.(() => { - const transform = viz.getAttribute("transform"); // Facet transform - // Register all brushes on the mark so they can communicate - if (!this.brushes) this.brushes = []; - const {brushes} = this; - - const g = create("svg:g", context).attr("transform", transform).attr("class", "brusher"); - viz.replaceWith(g.node()); - viz.removeAttribute("transform"); - g.append(() => viz); + const g = create("svg:g", context).attr("class", "brushable"); - const extent = [ + const brush = (mode === "xy" ? brusher : mode === "x" ? brusherX : brusherY)() + .extent([ [marginLeft, marginTop], [width - marginRight, height - marginBottom] - ]; - - const brush = (mode === "xy" ? brusher : mode === "x" ? brusherX : brusherY)().on( - "brush start end", - (event) => { - const {type, selection} = event; - let S; - if (selection) { - S = index; - if (mode.includes("x")) { - let [x0, x1] = mode === "xy" ? [selection[0][0], selection[1][0]] : selection; - if (x?.bandwidth) x0 -= x.bandwidth(); - S = S.filter((i) => x0 <= Xm[i] && Xl[i] <= x1); - } - if (mode.includes("y")) { - let [y0, y1] = mode === "xy" ? [selection[0][1], selection[1][1]] : selection; - if (y?.bandwidth) y0 -= y.bandwidth(); - S = S.filter((i) => y0 <= Ym[i] && Yl[i] <= y1); - } + ]) + .on("brush start end", (event) => { + const {type, selection} = event; + let S = null; + if (selection) { + S = index; + if (mode.includes("x")) { + let [x0, x1] = mode === "xy" ? [selection[0][0], selection[1][0]] : selection; + if (x?.bandwidth) x0 -= x.bandwidth(); + S = S.filter((i) => x0 <= Xm[i] && Xl[i] <= x1); } - viz.replaceWith((viz = next.call(this, S ?? [], scales, values, dimensions, context))); + if (mode.includes("y")) { + let [y0, y1] = mode === "xy" ? [selection[0][1], selection[1][1]] : selection; + if (y?.bandwidth) y0 -= y.bandwidth(); + S = S.filter((i) => y0 <= Ym[i] && Yl[i] <= y1); + } + } + viz.replaceWith((viz = next.call(this, S ?? [], scales, values, dimensions, context))); - for (const b of brushes) { - if (b.brush === brush) { - b.index = S; - } else if (type === "start") { - b.selection.call(b.brush.move, null); - } + for (const b of brushes) { + if (b.brush === brush) { + b.index = S; + } else if (type === "start") { + b.selection.call(b.brush.move, null); } - const filtered = brushes.map(({index}) => index).filter((d) => d); - context.dispatchValue( - filtered.length === 0 ? data : Array.from(union(...filtered), (i) => data[i]) // 🌶 todo typed array if data is numeric - ); } - ); - this.brushes.push({brush, selection: g.append("g").call(brush.extent(extent))}); + if (!selectionEquals(S, state.selection)) { + state.selection = S; + // 🌶 todo typed array if data is numeric? + context.dispatchValue(S === null ? data : Array.from(S, (i) => data[i])); + } + }); + + brushes.push({ + brush, + selection: g.append("g").call(brush) }); + // Use a RAF so we have access to the (facet) transform of the original + // the element when we replace it with the brushable wrapper. + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(() => { + g.attr("transform", viz.getAttribute("transform")); + viz.replaceWith(g.node()); + viz.removeAttribute("transform"); + g.append(() => viz); + }); + } + return viz; }, options.render) }; @@ -91,3 +100,10 @@ export function brushX(options = {}) { export function brushY(options = {}) { return brushTransform("y", options); } + +function selectionEquals(A, B) { + if (A === B) return true; + if (!Array.isArray(A) || !Array.isArray(B) || A.length != B.length) return false; + for (let i = 0; i < A.length; ++i) if (A[i] !== B[i]) return false; + return true; +} diff --git a/src/plot.js b/src/plot.js index 288599eab4..039a269ada 100644 --- a/src/plot.js +++ b/src/plot.js @@ -173,7 +173,7 @@ export function plot(options = {}) { // Allows e.g. the pointer transform to support viewof. context.dispatchValue = (value) => { - if (figure.value === value || selectionEquals(figure.value, value)) return; + if (figure.value === value) return; figure.value = value; figure.dispatchEvent(new Event("input", {bubbles: true})); }; @@ -742,9 +742,3 @@ function outerRange(scale) { if (x2 < x1) [x1, x2] = [x2, x1]; return [x1, x2 + scale.bandwidth()]; } - -function selectionEquals(A, B) { - if (!Array.isArray(A) || !Array.isArray(B) || A.length != B.length) return false; - for (let i = 0; i < A.length; ++i) if (A[i] !== B[i]) return false; - return true; -} From a04ce1a6f5141a031b75123490184fd65f3f6490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 29 May 2023 23:41:16 +0200 Subject: [PATCH 04/39] simpler! and no RAF --- src/interactions/brush.js | 23 +++++------------------ 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/src/interactions/brush.js b/src/interactions/brush.js index 185cb927e0..abeac44c23 100644 --- a/src/interactions/brush.js +++ b/src/interactions/brush.js @@ -28,7 +28,8 @@ function brushTransform(mode, options) { const Ym = Y1 && Y2 ? Y1.map((d, i) => Math.max(d, Y2[i])) : Y; const {width, height, marginLeft, marginTop, marginRight, marginBottom} = dimensions; - const g = create("svg:g", context).attr("class", "brushable"); + const g = create("svg:g", context); + g.append(() => viz); const brush = (mode === "xy" ? brusher : mode === "x" ? brusherX : brusherY)() .extent([ @@ -57,7 +58,7 @@ function brushTransform(mode, options) { if (b.brush === brush) { b.index = S; } else if (type === "start") { - b.selection.call(b.brush.move, null); + b.target.call(b.brush.move, null); } } @@ -68,23 +69,9 @@ function brushTransform(mode, options) { } }); - brushes.push({ - brush, - selection: g.append("g").call(brush) - }); + brushes.push({brush, target: g.append("g").call(brush)}); - // Use a RAF so we have access to the (facet) transform of the original - // the element when we replace it with the brushable wrapper. - if (typeof requestAnimationFrame === "function") { - requestAnimationFrame(() => { - g.attr("transform", viz.getAttribute("transform")); - viz.replaceWith(g.node()); - viz.removeAttribute("transform"); - g.append(() => viz); - }); - } - - return viz; + return g.node(); }, options.render) }; } From ae143a9a745e03b17f15a4fdfcfffba3c5896918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 29 May 2023 23:46:25 +0200 Subject: [PATCH 05/39] fix tests --- test/output/brushBand.svg | 16 +++++++++- test/output/brushFacets.svg | 50 +++++++++++++++++++++++++++++--- test/output/brushRectX.svg | 10 ++++++- test/output/brushRectY.svg | 10 ++++++- test/output/brushScatterplot.svg | 16 +++++++++- 5 files changed, 94 insertions(+), 8 deletions(-) diff --git a/test/output/brushBand.svg b/test/output/brushBand.svg index de5dbb9d2a..32bf0cbd78 100644 --- a/test/output/brushBand.svg +++ b/test/output/brushBand.svg @@ -48,5 +48,19 @@ - + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/brushFacets.svg b/test/output/brushFacets.svg index dac8092aa9..8a82af2944 100644 --- a/test/output/brushFacets.svg +++ b/test/output/brushFacets.svg @@ -435,9 +435,51 @@ - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/brushRectX.svg b/test/output/brushRectX.svg index b3c58cfacc..4d904fa52a 100644 --- a/test/output/brushRectX.svg +++ b/test/output/brushRectX.svg @@ -80,5 +80,13 @@ - + + + + + + + + + \ No newline at end of file diff --git a/test/output/brushRectY.svg b/test/output/brushRectY.svg index 2b5745d0f2..04e721092e 100644 --- a/test/output/brushRectY.svg +++ b/test/output/brushRectY.svg @@ -80,5 +80,13 @@ - + + + + + + + + + \ No newline at end of file diff --git a/test/output/brushScatterplot.svg b/test/output/brushScatterplot.svg index ca02f4f6c9..821616ddf4 100644 --- a/test/output/brushScatterplot.svg +++ b/test/output/brushScatterplot.svg @@ -397,5 +397,19 @@ - + + + + + + + + + + + + + + + \ No newline at end of file From 548c25e7316f461c0f322bf3a7172ac599280545 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 30 May 2023 09:03:50 +0200 Subject: [PATCH 06/39] cleaner derived "channels" Xl and Xm test viewof --- src/interactions/brush.js | 44 ++-- test/output/brushViewof.html | 417 +++++++++++++++++++++++++++++++++++ test/plots/brush.ts | 16 ++ 3 files changed, 458 insertions(+), 19 deletions(-) create mode 100644 test/output/brushViewof.html diff --git a/src/interactions/brush.js b/src/interactions/brush.js index abeac44c23..2a632ac19b 100644 --- a/src/interactions/brush.js +++ b/src/interactions/brush.js @@ -1,15 +1,15 @@ import {create} from "../context.js"; import {brush as brusher, brushX as brusherX, brushY as brusherY} from "d3"; import {composeRender} from "../mark.js"; +import {take} from "../options.js"; const states = new WeakMap(); function brushTransform(mode, options) { return { ...options, - // Unlike other composed transforms, interactive transforms must be the - // outermost render function because they will re-render dynamically in - // response to pointer events. + // Interactive transforms must be the outermost render function because they + // will re-render dynamically in response to pointer events. render: composeRender(function (index, scales, values, dimensions, context, next) { const svg = context.ownerSVGElement; const {data} = context.getMarkState(this); @@ -17,15 +17,24 @@ function brushTransform(mode, options) { // Isolate state per-brush, per-plot; if the brush is reused by multiple // marks, they will share the same state. let state = states.get(svg); - if (!state) states.set(svg, (state = {brushes: [], selection: null})); - const {brushes} = state; + if (!state) states.set(svg, (state = {brushes: [], bounds: new WeakMap(), selection: null})); + const {brushes, bounds} = state; + + // Intersection bounds are computed once per mark (for all facets) + if (!bounds.has(this)) { + const {x, y} = scales; + const bx = x?.bandwidth?.() ?? 0; + const by = y?.bandwidth?.() ?? 0; + const {x: X, x1: X1, x2: X2, y: Y, y1: Y1, y2: Y2} = values; + const Xl = X1 && X2 ? X1.map((d, i) => Math.min(d, X2[i])) : X; + const Xm = X1 && X2 ? X1.map((d, i) => Math.max(d, X2[i]) + bx) : bx ? X.map((d) => d + bx) : X; + const Yl = Y1 && Y2 ? Y1.map((d, i) => Math.min(d, Y2[i])) : Y; + const Ym = Y1 && Y2 ? Y1.map((d, i) => Math.max(d, Y2[i]) + by) : by ? Y.map((d) => d + by) : Y; + bounds.set(this, {Xl, Xm, Yl, Ym}); + } + const {Xl, Xm, Yl, Ym} = bounds.get(this); + let viz = next.call(this, [], scales, values, dimensions, context); - const {x, y} = scales; - const {x: X, x1: X1, x2: X2, y: Y, y1: Y1, y2: Y2} = values; - const Xl = X1 && X2 ? X1.map((d, i) => Math.min(d, X2[i])) : X; - const Xm = X1 && X2 ? X1.map((d, i) => Math.max(d, X2[i])) : X; - const Yl = Y1 && Y2 ? Y1.map((d, i) => Math.min(d, Y2[i])) : Y; - const Ym = Y1 && Y2 ? Y1.map((d, i) => Math.max(d, Y2[i])) : Y; const {width, height, marginLeft, marginTop, marginRight, marginBottom} = dimensions; const g = create("svg:g", context); @@ -41,14 +50,12 @@ function brushTransform(mode, options) { let S = null; if (selection) { S = index; - if (mode.includes("x")) { - let [x0, x1] = mode === "xy" ? [selection[0][0], selection[1][0]] : selection; - if (x?.bandwidth) x0 -= x.bandwidth(); + if (mode === "x" || mode === "xy") { + const [x0, x1] = mode === "xy" ? [selection[0][0], selection[1][0]] : selection; S = S.filter((i) => x0 <= Xm[i] && Xl[i] <= x1); } - if (mode.includes("y")) { - let [y0, y1] = mode === "xy" ? [selection[0][1], selection[1][1]] : selection; - if (y?.bandwidth) y0 -= y.bandwidth(); + if (mode === "y" || mode === "xy") { + const [y0, y1] = mode === "xy" ? [selection[0][1], selection[1][1]] : selection; S = S.filter((i) => y0 <= Ym[i] && Yl[i] <= y1); } } @@ -64,8 +71,7 @@ function brushTransform(mode, options) { if (!selectionEquals(S, state.selection)) { state.selection = S; - // 🌶 todo typed array if data is numeric? - context.dispatchValue(S === null ? data : Array.from(S, (i) => data[i])); + context.dispatchValue(S === null ? data : take(data, S)); } }); diff --git a/test/output/brushViewof.html b/test/output/brushViewof.html new file mode 100644 index 0000000000..a366cb9556 --- /dev/null +++ b/test/output/brushViewof.html @@ -0,0 +1,417 @@ +
+ + + + + + + + + + + + + 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/plots/brush.ts b/test/plots/brush.ts index 33635378fa..dad94acdcb 100644 --- a/test/plots/brush.ts +++ b/test/plots/brush.ts @@ -1,5 +1,6 @@ import * as Plot from "@observablehq/plot"; import * as d3 from "d3"; +import {html} from "htl"; export async function brushBand() { const penguins = await d3.csv("data/penguins.csv", d3.autoType); @@ -51,3 +52,18 @@ export async function brushScatterplot() { ] }); } + +export async function brushViewof() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + const plot = Plot.plot({ + marks: [ + Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm"}), + Plot.dot(penguins, Plot.brush({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "sex", stroke: "black"})) + ] + }); + const textarea = html` + \ No newline at end of file diff --git a/test/output/brushRectX.svg b/test/output/brushRectX.svg index 4d904fa52a..d56d206cc9 100644 --- a/test/output/brushRectX.svg +++ b/test/output/brushRectX.svg @@ -80,13 +80,7 @@ - - - - - - - - + + \ No newline at end of file diff --git a/test/output/brushRectY.svg b/test/output/brushRectY.svg index 04e721092e..f3903b3bef 100644 --- a/test/output/brushRectY.svg +++ b/test/output/brushRectY.svg @@ -80,13 +80,7 @@ - - - - - - - - + + \ No newline at end of file diff --git a/test/output/brushScatterplot.svg b/test/output/brushScatterplot.svg index 821616ddf4..ebfcdbc051 100644 --- a/test/output/brushScatterplot.svg +++ b/test/output/brushScatterplot.svg @@ -397,19 +397,7 @@ - - - - - - - - - - - - - - + + \ No newline at end of file diff --git a/test/output/brushViewof.html b/test/output/brushViewof.html index a366cb9556..e79d3a7bcc 100644 --- a/test/output/brushViewof.html +++ b/test/output/brushViewof.html @@ -397,20 +397,8 @@ - - - - - - - - - - - - - - + + From 72a4bb481e77d2b214d4c8decabbd8090eaf1d52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 30 May 2023 11:45:20 +0200 Subject: [PATCH 10/39] extent (or logical) selectionMode --- src/interactions/brush.d.ts | 11 +++++--- src/interactions/brush.js | 53 ++++++++++++++++++++++++++----------- test/plots/brush.ts | 11 +++++++- 3 files changed, 54 insertions(+), 21 deletions(-) diff --git a/src/interactions/brush.d.ts b/src/interactions/brush.d.ts index 8601a7aa3b..cdbda0e9dc 100644 --- a/src/interactions/brush.d.ts +++ b/src/interactions/brush.d.ts @@ -3,11 +3,14 @@ import type {Rendered} from "../transforms/basic.js"; /** Options for the brush transform. */ type BrushOptions = { /** - * The brush’s intersection strategy; currently only “rect” is implemented, - * checking whether a rectangle defined by and intersects - * the brush area. + * The brush’s selection mode determines the contents of the plot’s value + * property when the user manipulates the brush: + * * **data** - default; the selected data + * * **extent** - the selection extent, in data space; [x1, x2] for brushX, + * [y1, y2] for brushY, [[x1, y1], [x2, y2]] for brush. When faceting, the + * *fx* and *fy* properties of the extent are set to the relevant values. */ - intersection?: "rect"; + selectionMode?: "data" | "extent"; }; /** diff --git a/src/interactions/brush.js b/src/interactions/brush.js index 3a00f9e8be..3838e6765c 100644 --- a/src/interactions/brush.js +++ b/src/interactions/brush.js @@ -1,12 +1,13 @@ import {create} from "../context.js"; -import {select} from "d3"; +import {select, transpose} from "d3"; import {brush as brusher, brushX as brusherX, brushY as brusherY} from "d3"; import {composeRender} from "../mark.js"; -import {take} from "../options.js"; +import {keyword, take} from "../options.js"; const states = new WeakMap(); -function brushTransform(mode, options) { +function brushTransform(mode, {selectionMode = "data", ...options}) { + selectionMode = keyword(selectionMode, "selectionMode", ["data", "extent"]); return { ...options, // Interactive transforms must be the outermost render function because they @@ -41,16 +42,21 @@ function brushTransform(mode, options) { const f = select(this).datum(); const {type, selection} = event; let S = null; - if (selection) { - S = f.index; - if (mode === "x" || mode === "xy") { - const [x0, x1] = mode === "xy" ? [selection[0][0], selection[1][0]] : selection; - S = S.filter((i) => x0 <= Xm[i] && Xl[i] <= x1); - } - if (mode === "y" || mode === "xy") { - const [y0, y1] = mode === "xy" ? [selection[0][1], selection[1][1]] : selection; - S = S.filter((i) => y0 <= Ym[i] && Yl[i] <= y1); - } + const [X, Y] = selection + ? mode === "xy" + ? transpose(selection) + : mode === "x" + ? [selection] + : [, selection] + : []; + if (X || Y) S = f.index; + if (X) { + const [x0, x1] = X; + S = S.filter((i) => x0 <= Xm[i] && Xl[i] <= x1); + } + if (Y) { + const [y0, y1] = Y; + S = S.filter((i) => y0 <= Ym[i] && Yl[i] <= y1); } // Only one facet can be active at a time; clear the others. if (type === "start") for (let i = 0; i < cancels.length; ++i) if (i !== (f.index.fi ?? 0)) cancels[i](); @@ -58,9 +64,24 @@ function brushTransform(mode, options) { f.display.replaceWith((f.display = next.call(this, S ?? [], scales, values, dimensions, context))); // Update the plot’s value if the selection has changed. - if (!selectionEquals(S, state.selection)) { - state.selection = S; - context.dispatchValue(S === null ? data : take(data, S)); + if (selectionMode === "data") { + if (!selectionEquals(S, state.selection)) { + state.selection = S; + context.dispatchValue(S === null ? data : take(data, S)); + } + } + // "extent" + else { + if (selection === null) { + context.dispatchValue(null); + } else { + if (X && x.invert) X.forEach((d, i) => (X[i] = x.invert(d))); + if (Y && y.invert) Y.forEach((d, i) => (Y[i] = y.invert(d))); + const value = X && Y ? transpose([X, Y]) : X ?? Y; + if ("fx" in scales) value.fx = index.fx; + if ("fy" in scales) value.fy = index.fy; + context.dispatchValue(value); + } } }); diff --git a/test/plots/brush.ts b/test/plots/brush.ts index 80c43d2e7e..7f3a28d43b 100644 --- a/test/plots/brush.ts +++ b/test/plots/brush.ts @@ -74,7 +74,16 @@ export async function brushFacetsViewof() { facet: {data: penguins, x: "species"}, marks: [ Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm"}), - Plot.dot(penguins, Plot.brush({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species", stroke: "black"})), + Plot.dot( + penguins, + Plot.brush({ + x: "culmen_length_mm", + y: "culmen_depth_mm", + fill: "species", + stroke: "black", + selectionMode: "extent" + }) + ), Plot.gridX({strokeOpacity: 1}), Plot.gridY({strokeOpacity: 1}) ] From 7e8d5ffd4f376ead60e20770084906dc3e512a3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 30 May 2023 11:57:33 +0200 Subject: [PATCH 11/39] better extent --- src/interactions/brush.d.ts | 8 +++++--- src/interactions/brush.js | 18 ++++++++++-------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/interactions/brush.d.ts b/src/interactions/brush.d.ts index cdbda0e9dc..234e47de72 100644 --- a/src/interactions/brush.d.ts +++ b/src/interactions/brush.d.ts @@ -6,9 +6,11 @@ type BrushOptions = { * The brush’s selection mode determines the contents of the plot’s value * property when the user manipulates the brush: * * **data** - default; the selected data - * * **extent** - the selection extent, in data space; [x1, x2] for brushX, - * [y1, y2] for brushY, [[x1, y1], [x2, y2]] for brush. When faceting, the - * *fx* and *fy* properties of the extent are set to the relevant values. + * * **extent** - the selection extent, in data space + * + * The extent is an object with properties *x*: [x1, x2], *y*: [y1, y2] for + * brushY, and both *x* and *y* for brush. Additionally, the *fx* and *fy* + * properties are also set when faceting. */ selectionMode?: "data" | "extent"; }; diff --git a/src/interactions/brush.js b/src/interactions/brush.js index 3838e6765c..a96cbb3736 100644 --- a/src/interactions/brush.js +++ b/src/interactions/brush.js @@ -1,5 +1,5 @@ import {create} from "../context.js"; -import {select, transpose} from "d3"; +import {ascending, select, transpose} from "d3"; import {brush as brusher, brushX as brusherX, brushY as brusherY} from "d3"; import {composeRender} from "../mark.js"; import {keyword, take} from "../options.js"; @@ -75,9 +75,9 @@ function brushTransform(mode, {selectionMode = "data", ...options}) { if (selection === null) { context.dispatchValue(null); } else { - if (X && x.invert) X.forEach((d, i) => (X[i] = x.invert(d))); - if (Y && y.invert) Y.forEach((d, i) => (Y[i] = y.invert(d))); - const value = X && Y ? transpose([X, Y]) : X ?? Y; + const value = {}; + if (X) value.x = x.invert ? X.map(x.invert).sort(ascending) : X; + if (Y) value.y = y.invert ? Y.map(y.invert).sort(ascending) : Y; if ("fx" in scales) value.fx = index.fx; if ("fy" in scales) value.fy = index.fy; context.dispatchValue(value); @@ -100,13 +100,15 @@ function brushTransform(mode, {selectionMode = "data", ...options}) { cancels[index.fi ?? 0] = () => target.call(brush.move, null); // When the plot is complete, append the target element to the top - // (z-index) and translate it to match the facet’s frame. + // (z-index), translate it to match the facet’s frame position, and + // initialize the plot’s value. if (typeof requestAnimationFrame === "function") { - requestAnimationFrame(() => + requestAnimationFrame(() => { select(svg) .append(() => target.node()) - .attr("transform", wrapper.attr("transform")) - ); + .attr("transform", wrapper.attr("transform")); + context.dispatchValue(null); + }); } return wrapper.node(); From eedbc23bfab82bb4f7368210927a0c78f4c0aaeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 30 May 2023 12:00:00 +0200 Subject: [PATCH 12/39] initialize with data --- src/interactions/brush.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interactions/brush.js b/src/interactions/brush.js index a96cbb3736..69c3c5ea0d 100644 --- a/src/interactions/brush.js +++ b/src/interactions/brush.js @@ -107,7 +107,7 @@ function brushTransform(mode, {selectionMode = "data", ...options}) { select(svg) .append(() => target.node()) .attr("transform", wrapper.attr("transform")); - context.dispatchValue(null); + context.dispatchValue(selectionMode === "data" ? data : null); }); } From 0f120d2189f102f14d534befab0ec23bcf12b293 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 30 May 2023 14:32:22 +0200 Subject: [PATCH 13/39] datum --- src/interactions/brush.js | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/interactions/brush.js b/src/interactions/brush.js index 69c3c5ea0d..155a163f2a 100644 --- a/src/interactions/brush.js +++ b/src/interactions/brush.js @@ -38,18 +38,17 @@ function brushTransform(mode, {selectionMode = "data", ...options}) { ]; const brush = (mode === "xy" ? brusher : mode === "x" ? brusherX : brusherY)() .extent(extent) - .on("brush start end", function (event) { - const f = select(this).datum(); + .on("brush start end", function (event, d) { const {type, selection} = event; let S = null; - const [X, Y] = selection - ? mode === "xy" - ? transpose(selection) - : mode === "x" - ? [selection] - : [, selection] - : []; - if (X || Y) S = f.index; + const [X, Y] = !selection + ? [] + : mode === "xy" + ? transpose(selection) + : mode === "x" + ? [selection] + : [, selection]; + if (X || Y) S = d.index; if (X) { const [x0, x1] = X; S = S.filter((i) => x0 <= Xm[i] && Xl[i] <= x1); @@ -59,9 +58,9 @@ function brushTransform(mode, {selectionMode = "data", ...options}) { S = S.filter((i) => y0 <= Ym[i] && Yl[i] <= y1); } // Only one facet can be active at a time; clear the others. - if (type === "start") for (let i = 0; i < cancels.length; ++i) if (i !== (f.index.fi ?? 0)) cancels[i](); + if (type === "start") for (let i = 0; i < cancels.length; ++i) if (i !== (d.index.fi ?? 0)) cancels[i](); - f.display.replaceWith((f.display = next.call(this, S ?? [], scales, values, dimensions, context))); + d.display.replaceWith((d.display = next.call(this, S ?? [], scales, values, dimensions, context))); // Update the plot’s value if the selection has changed. if (selectionMode === "data") { @@ -102,6 +101,7 @@ function brushTransform(mode, {selectionMode = "data", ...options}) { // When the plot is complete, append the target element to the top // (z-index), translate it to match the facet’s frame position, and // initialize the plot’s value. + // TODO: cleaner. if (typeof requestAnimationFrame === "function") { requestAnimationFrame(() => { select(svg) From d3a9c943780fa8c9a007c76581a11429c97cc383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 30 May 2023 14:44:57 +0200 Subject: [PATCH 14/39] cleaner, avoid a crash if the mark does not return a node --- src/interactions/brush.js | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/interactions/brush.js b/src/interactions/brush.js index 155a163f2a..f109c7dd5f 100644 --- a/src/interactions/brush.js +++ b/src/interactions/brush.js @@ -58,9 +58,9 @@ function brushTransform(mode, {selectionMode = "data", ...options}) { S = S.filter((i) => y0 <= Ym[i] && Yl[i] <= y1); } // Only one facet can be active at a time; clear the others. - if (type === "start") for (let i = 0; i < cancels.length; ++i) if (i !== (d.index.fi ?? 0)) cancels[i](); + if (type === "start") for (const target of targets) if (target !== this) target._cancelBrush(); - d.display.replaceWith((d.display = next.call(this, S ?? [], scales, values, dimensions, context))); + d.display?.replaceWith((d.display = next.call(this, S ?? [], scales, values, dimensions, context))); // Update the plot’s value if the selection has changed. if (selectionMode === "data") { @@ -84,19 +84,25 @@ function brushTransform(mode, {selectionMode = "data", ...options}) { } }); - states.set(svg, (state = {brush, cancels: [], selection: null})); + states.set(svg, (state = {brush, targets: [], selection: null})); } - const {brush, cancels} = state; + const {brush, targets} = state; const display = next.call(this, [], scales, values, dimensions, context); // Create a wrapper for the elements to display, and a target that will // carry the brush. Save references to the display and index of the current // facet. - const wrapper = create("svg:g", context).attr("aria-label", display.getAttribute("aria-label")); + const wrapper = create("svg:g", context); const target = create("svg:g", context).attr("aria-label", "brush").datum({display, index}).call(brush); - display.removeAttribute("aria-label"); - wrapper.append(() => display); - cancels[index.fi ?? 0] = () => target.call(brush.move, null); + const node = target.node(); + node._cancelBrush = () => target.call(brush.move, null); + targets.push(node); + + if (display) { + const al = display.getAttribute("aria-label"); + al && (wrapper.attr("aria-label", al), display.removeAttribute("aria-label")); + wrapper.append(() => display); + } // When the plot is complete, append the target element to the top // (z-index), translate it to match the facet’s frame position, and From e153848c116dffbadff306dde8114b06bf393e6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 30 May 2023 16:07:59 +0200 Subject: [PATCH 15/39] document --- docs/interactions/brush.md | 93 +++++++++++++++++++++++++++++++++++++ src/interactions/brush.d.ts | 6 +-- 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/docs/interactions/brush.md b/docs/interactions/brush.md index e974a656e0..b954b227ba 100644 --- a/docs/interactions/brush.md +++ b/docs/interactions/brush.md @@ -1,14 +1,107 @@ + + # Brush transform +The **brush transform** filters a mark interactively such that only the data that fall within the rectangular region defined by the user are rendered. It is typically used to select discrete elements, such as dots in a scatterplot: + +:::plot defer +```js +Plot.plot({ + marks: [ + Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", fill: "currentColor"}), + Plot.dot(penguins, Plot.brush({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species", stroke: "currentColor", r: 4})) + ] +}) +``` +::: + +When the chart has a dominant axis, an horizontal or vertical brush is recommended; for example, to select bars in a histogram: + +:::plot defer +```js +Plot.plot({ + marks: [ + Plot.rectY(penguins, Plot.binX({y: "count"}, {x: "body_mass_g", thresholds: 40, fillOpacity: 0.2})), + Plot.rectY(penguins, Plot.brushX(Plot.binX({y: "count"}, {fill:"currentColor", x: "body_mass_g", thresholds: 40}))), + ] +}) +``` +::: + +The brush transform is similar to the [pointer](../interaction/pointer.md) transform: it interactively filters the mark’s index to show a subset of the data, and re-renders the mark as the selection changes. Since the mark is lazily rendered during interaction, it is fast: only the visible elements are rendered as needed. And, like the filter and select transforms, unfiltered channel values are incorporated into default scale domains. + +The brush transform supports both one- and two-dimensional brushing modes. The two-dimensional mode, [brush](#brush-options-1), is used above and is suitable for scatterplots and the general case: it allows the user to define a rectangular region by clicking on a corner (_e.g._ the top-left corner) and dragging the pointer to the bottom-right corner. The one-dimensional modes, [brushX](#brushx-options) and [brushY](#brushy-options), in contrast only consider one dimension; this is desirable when a chart has a “dominant” dimension, such as time in a time-series chart, the binned quantitative dimension in a histogram, or the categorical dimension of a bar chart. + +The brush transform emits an [*input* event](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event) whenever the selection changes, and sets the value of the plot element to the selected data. This allows you to use a plot as an [Observable view](https://observablehq.com/@observablehq/views) (viewof), or to register an *input* event listener to react to brushing. ## Brush options +The following options control the brush transform: + +- **x1** - the starting horizontal↔︎ target position; bound to the *x* scale +- **y1** - the starting vertical↕︎ target position; bound to the *y* scale +- **x2** - the ending horizontal↔︎ target position; bound to the *x* scale +- **y2** - the ending vertical↕︎ target position; bound to the *y* scale +- **x** - the fallback horizontal↔︎ target position; bound to the *x* scale +- **y** - the fallback vertical↕︎ target position; bound to the *y* scale +- **selectionMode** - controls the value exposed to listeners of the *input* events. + +The positional options define a sensitive surface for each data point, defined on the horizontal axis as the extent between *x1* and *x2* if specified, between *x* and *x + bandwidth* if *x* is a band scale, or the value *x* otherwise. The sensitive surface’s vertical extent likewise spans from *y1* to *y2* if specified, from *y* to *y + bandwidth* if *y* is a band scale, or is equal to the *y* value otherwise. + +When the user interacts with the plot by clicking and dragging the brush to define a rectangular region, all the elements whose sensitive surface intersect with the brushed region are selected, and the mark is re-rendered. + +The brush’s selection mode determines the contents of the plot’s value property when the user manipulates the brush. It supports the following options: + +* **data** - default; the selected data +* **extent** - the selection extent, in data space + +The selected data is an array of the possibly transformed data rendered by the mark. For example, in the case of the histogram above, the selected data is an array of bins, each containing the penguins whose body mass is between the bin’s lower and upper bounds. + +The selection extent is an object with properties *x*: [x1, x2] for brushX, *y*: [y1, y2] for brushY, and both *x* and *y* for brush. Additionally, when faceting, it contains the facet’s *fx* and *fy* properties. + +For details on the user interface (including touch events, pointer events and modifier keys), see [d3-brush](https://github.com/d3/d3-brush). ## brush(*options*) +```js +Plot.dot(penguins, Plot.brush({x: "culmen_length_mm", y: "culmen_depth_mm"})) +``` + +Applies the brush render transform to the specified *options* to filter the mark index such that the points whose sensitive surface intersect with the brushed region the point closest to the pointer is rendered; the mark will re-render interactively in response to brush events. ## brushX(*options*) +```js +Plot.tip(aapl, Plot.pointerX({x: "Date", y: "Close"})) +``` + +Like [brush](#brush-options-1), except the determination of the intersection exclusively considers the *x* (horizontal↔︎) position; this should be used for plots where *x* is the dominant dimension, such as the binned quantitative dimension in a histogram, or the categorical dimension of a bar chart. ## brushY(*options*) +```js +Plot.tip(alphabet, Plot.pointerY({x: "frequency", y: "letter"})) +``` + +Like [brush](#brush-options-1), except the determination of the intersection exclusively considers the *y* (vertical↕) position; this should be used for plots where *y* is the dominant dimension. diff --git a/src/interactions/brush.d.ts b/src/interactions/brush.d.ts index 234e47de72..38a13b27e9 100644 --- a/src/interactions/brush.d.ts +++ b/src/interactions/brush.d.ts @@ -8,9 +8,9 @@ type BrushOptions = { * * **data** - default; the selected data * * **extent** - the selection extent, in data space * - * The extent is an object with properties *x*: [x1, x2], *y*: [y1, y2] for - * brushY, and both *x* and *y* for brush. Additionally, the *fx* and *fy* - * properties are also set when faceting. + * The extent is an object with properties *x*: [x1, x2] for brushX, *y*: [y1, + * y2] for brushY, and both *x* and *y* for brush. Additionally, when + * faceting, it contains the facet’s *fx* and *fy* properties. */ selectionMode?: "data" | "extent"; }; From cf362706f2dd4e907bc71230a4ae4cfbbafefb3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 30 May 2023 16:27:54 +0200 Subject: [PATCH 16/39] fix link --- docs/interactions/brush.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/interactions/brush.md b/docs/interactions/brush.md index b954b227ba..123b2393af 100644 --- a/docs/interactions/brush.md +++ b/docs/interactions/brush.md @@ -49,7 +49,7 @@ Plot.plot({ ``` ::: -The brush transform is similar to the [pointer](../interaction/pointer.md) transform: it interactively filters the mark’s index to show a subset of the data, and re-renders the mark as the selection changes. Since the mark is lazily rendered during interaction, it is fast: only the visible elements are rendered as needed. And, like the filter and select transforms, unfiltered channel values are incorporated into default scale domains. +The brush transform is similar to the [pointer](./pointer.md) transform: it interactively filters the mark’s index to show a subset of the data, and re-renders the mark as the selection changes. Since the mark is lazily rendered during interaction, it is fast: only the visible elements are rendered as needed. And, like the filter and select transforms, unfiltered channel values are incorporated into default scale domains. The brush transform supports both one- and two-dimensional brushing modes. The two-dimensional mode, [brush](#brush-options-1), is used above and is suitable for scatterplots and the general case: it allows the user to define a rectangular region by clicking on a corner (_e.g._ the top-left corner) and dragging the pointer to the bottom-right corner. The one-dimensional modes, [brushX](#brushx-options) and [brushY](#brushy-options), in contrast only consider one dimension; this is desirable when a chart has a “dominant” dimension, such as time in a time-series chart, the binned quantitative dimension in a histogram, or the categorical dimension of a bar chart. From 20a586cafdbeccb318647bf07e32de6b9be07faa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 21 Aug 2023 12:56:56 +0200 Subject: [PATCH 17/39] fix links --- docs/data/api.data.ts | 2 ++ docs/interactions/brush.md | 12 ++++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/data/api.data.ts b/docs/data/api.data.ts index f159806e72..224a0d8182 100644 --- a/docs/data/api.data.ts +++ b/docs/data/api.data.ts @@ -64,6 +64,8 @@ function getHref(name: string, path: string): string { } case "marks/crosshair": return "interactions/crosshair"; + case "marks/brush": + return "interactions/brush"; case "transforms/basic": { switch (name) { case "filter": diff --git a/docs/interactions/brush.md b/docs/interactions/brush.md index 123b2393af..c131e53195 100644 --- a/docs/interactions/brush.md +++ b/docs/interactions/brush.md @@ -51,7 +51,7 @@ Plot.plot({ The brush transform is similar to the [pointer](./pointer.md) transform: it interactively filters the mark’s index to show a subset of the data, and re-renders the mark as the selection changes. Since the mark is lazily rendered during interaction, it is fast: only the visible elements are rendered as needed. And, like the filter and select transforms, unfiltered channel values are incorporated into default scale domains. -The brush transform supports both one- and two-dimensional brushing modes. The two-dimensional mode, [brush](#brush-options-1), is used above and is suitable for scatterplots and the general case: it allows the user to define a rectangular region by clicking on a corner (_e.g._ the top-left corner) and dragging the pointer to the bottom-right corner. The one-dimensional modes, [brushX](#brushx-options) and [brushY](#brushy-options), in contrast only consider one dimension; this is desirable when a chart has a “dominant” dimension, such as time in a time-series chart, the binned quantitative dimension in a histogram, or the categorical dimension of a bar chart. +The brush transform supports both one- and two-dimensional brushing modes. The two-dimensional mode, [brush](#brush), is used above and is suitable for scatterplots and the general case: it allows the user to define a rectangular region by clicking on a corner (_e.g._ the top-left corner) and dragging the pointer to the bottom-right corner. The one-dimensional modes, [brushX](#brushX) and [brushY](#brushY), in contrast only consider one dimension; this is desirable when a chart has a “dominant” dimension, such as time in a time-series chart, the binned quantitative dimension in a histogram, or the categorical dimension of a bar chart. The brush transform emits an [*input* event](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event) whenever the selection changes, and sets the value of the plot element to the selected data. This allows you to use a plot as an [Observable view](https://observablehq.com/@observablehq/views) (viewof), or to register an *input* event listener to react to brushing. @@ -82,7 +82,7 @@ The selection extent is an object with properties *x*: [x1, x2] for brushX, *y*: For details on the user interface (including touch events, pointer events and modifier keys), see [d3-brush](https://github.com/d3/d3-brush). -## brush(*options*) +## brush(*options*) {#brush} ```js Plot.dot(penguins, Plot.brush({x: "culmen_length_mm", y: "culmen_depth_mm"})) @@ -90,18 +90,18 @@ Plot.dot(penguins, Plot.brush({x: "culmen_length_mm", y: "culmen_depth_mm"})) Applies the brush render transform to the specified *options* to filter the mark index such that the points whose sensitive surface intersect with the brushed region the point closest to the pointer is rendered; the mark will re-render interactively in response to brush events. -## brushX(*options*) +## brushX(*options*) {#brushX} ```js Plot.tip(aapl, Plot.pointerX({x: "Date", y: "Close"})) ``` -Like [brush](#brush-options-1), except the determination of the intersection exclusively considers the *x* (horizontal↔︎) position; this should be used for plots where *x* is the dominant dimension, such as the binned quantitative dimension in a histogram, or the categorical dimension of a bar chart. +Like [brush](#brush), except the determination of the intersection exclusively considers the *x* (horizontal↔︎) position; this should be used for plots where *x* is the dominant dimension, such as the binned quantitative dimension in a histogram, or the categorical dimension of a bar chart. -## brushY(*options*) +## brushY(*options*) {#brushY} ```js Plot.tip(alphabet, Plot.pointerY({x: "frequency", y: "letter"})) ``` -Like [brush](#brush-options-1), except the determination of the intersection exclusively considers the *y* (vertical↕) position; this should be used for plots where *y* is the dominant dimension. +Like [brush](#brush), except the determination of the intersection exclusively considers the *y* (vertical↕) position; this should be used for plots where *y* is the dominant dimension. From 33f0eac087db16647990be99d94592849e0ea499 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 22 Aug 2023 18:21:41 +0200 Subject: [PATCH 18/39] use Promise.resolve to get to the top z-index --- src/interactions/brush.js | 22 ++++++++---------- test/output/brushBand.svg | 12 ++++++++++ test/output/brushFacets.svg | 36 ++++++++++++++++++++++++++++++ test/output/brushFacetsViewof.html | 36 ++++++++++++++++++++++++++++++ test/output/brushRectX.svg | 6 +++++ test/output/brushRectY.svg | 6 +++++ test/output/brushScatterplot.svg | 12 ++++++++++ test/output/brushViewof.html | 12 ++++++++++ 8 files changed, 129 insertions(+), 13 deletions(-) diff --git a/src/interactions/brush.js b/src/interactions/brush.js index f109c7dd5f..a688bdb697 100644 --- a/src/interactions/brush.js +++ b/src/interactions/brush.js @@ -1,5 +1,5 @@ import {create} from "../context.js"; -import {ascending, select, transpose} from "d3"; +import {ascending, transpose} from "d3"; import {brush as brusher, brushX as brusherX, brushY as brusherY} from "d3"; import {composeRender} from "../mark.js"; import {keyword, take} from "../options.js"; @@ -104,18 +104,14 @@ function brushTransform(mode, {selectionMode = "data", ...options}) { wrapper.append(() => display); } - // When the plot is complete, append the target element to the top - // (z-index), translate it to match the facet’s frame position, and - // initialize the plot’s value. - // TODO: cleaner. - if (typeof requestAnimationFrame === "function") { - requestAnimationFrame(() => { - select(svg) - .append(() => target.node()) - .attr("transform", wrapper.attr("transform")); - context.dispatchValue(selectionMode === "data" ? data : null); - }); - } + // Translate the brush target to match the facet’s frame position, and + // initialize the plot’s value. We skip a beat so all the marks have been + // added before the brush (to top z-index). + Promise.resolve().then(() => { + node.setAttribute("transform", wrapper.attr("transform")); + svg.appendChild(node); + context.dispatchValue(selectionMode === "data" ? data : null); + }); return wrapper.node(); }, options.render) diff --git a/test/output/brushBand.svg b/test/output/brushBand.svg index 8dd95f1fc5..c96f2ada68 100644 --- a/test/output/brushBand.svg +++ b/test/output/brushBand.svg @@ -51,4 +51,16 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/brushFacets.svg b/test/output/brushFacets.svg index 598e9dc278..f937c87759 100644 --- a/test/output/brushFacets.svg +++ b/test/output/brushFacets.svg @@ -446,4 +446,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/brushFacetsViewof.html b/test/output/brushFacetsViewof.html index 731abc2e39..b34edaecc9 100644 --- a/test/output/brushFacetsViewof.html +++ b/test/output/brushFacetsViewof.html @@ -492,6 +492,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/brushRectX.svg b/test/output/brushRectX.svg index d56d206cc9..353acf5c61 100644 --- a/test/output/brushRectX.svg +++ b/test/output/brushRectX.svg @@ -83,4 +83,10 @@ + + + + + + \ No newline at end of file diff --git a/test/output/brushRectY.svg b/test/output/brushRectY.svg index f3903b3bef..ed7dc5026d 100644 --- a/test/output/brushRectY.svg +++ b/test/output/brushRectY.svg @@ -83,4 +83,10 @@ + + + + + + \ No newline at end of file diff --git a/test/output/brushScatterplot.svg b/test/output/brushScatterplot.svg index ebfcdbc051..a8a813f911 100644 --- a/test/output/brushScatterplot.svg +++ b/test/output/brushScatterplot.svg @@ -400,4 +400,16 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/brushViewof.html b/test/output/brushViewof.html index e79d3a7bcc..ecafab0857 100644 --- a/test/output/brushViewof.html +++ b/test/output/brushViewof.html @@ -400,6 +400,18 @@ + + + + + + + + + + + + \ No newline at end of file From e61d6a959387a112c6feb5d1fb2831dfdcf10e8f Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Mon, 28 Aug 2023 16:57:35 -0700 Subject: [PATCH 19/39] highlight experiment --- test/plots/highlight.ts | 61 +++++++++++++++++++++++++++++++++++++++++ test/plots/index.ts | 1 + 2 files changed, 62 insertions(+) create mode 100644 test/plots/highlight.ts diff --git a/test/plots/highlight.ts b/test/plots/highlight.ts new file mode 100644 index 0000000000..b3d6d52942 --- /dev/null +++ b/test/plots/highlight.ts @@ -0,0 +1,61 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export async function highlightDot() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + marks: [ + Plot.dot(penguins, { + x: "culmen_length_mm", + y: "culmen_depth_mm", + r: d3.randomLcg(42), + fill: "species", + fillOpacity: 0.1, + stroke: "species", + render(index, scales, values, dimensions, context, next) { + const svg = context.ownerSVGElement; + const ovalues = {...values, fill: null, stroke: null}; + const g = next(index, scales, values, dimensions, context); + const gg = svg.ownerDocument.createElementNS(svg.namespaceURI, "g"); + gg.appendChild(g); + let og: Element | Comment; // = + let ig: Element | Comment; // = gg.appendChild(document.createComment("selected")); + + function pointerenter(event) { + // g.replaceWith(gg); + g.remove(); + if (!ig) ig = gg.appendChild(document.createComment("unselected")); + if (!og) og = gg.appendChild(document.createComment("unselected")); + pointermove(event); + } + + function pointermove(event) { + const [px, py] = d3.pointer(event); + const {x, y} = values; + const I = []; + const O = []; + + for (const i of index) { + (Math.hypot(x[i] - px, y[i] - py) < 100 ? I : O).push(i); + } + + ig.replaceWith((ig = next(O, scales, ovalues, dimensions, context))); + og.replaceWith((og = next(I, scales, values, dimensions, context))); + } + + function pointerleave() { + // gg.replaceWith(g); + if (ig) ig.remove(), (ig = null); + if (og) og.remove(), (og = null); + gg.insertBefore(g, gg.firstChild); + } + + svg.addEventListener("pointerenter", pointerenter); + svg.addEventListener("pointermove", pointermove); + svg.addEventListener("pointerleave", pointerleave); + return gg; + } + }) + ] + }); +} diff --git a/test/plots/index.ts b/test/plots/index.ts index a1eec099e2..dc5482796a 100644 --- a/test/plots/index.ts +++ b/test/plots/index.ts @@ -119,6 +119,7 @@ export * from "./hexbin-z-null.js"; export * from "./hexbin-z.js"; export * from "./hexbin.js"; export * from "./high-cardinality-ordinal.js"; +export * from "./highlight.js"; export * from "./href-fill.js"; export * from "./ibm-trading.js"; export * from "./identity-scale.js"; From 854a5b67986c8ff2c62ed551eef433f1036f1408 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 30 Aug 2023 11:26:08 -0700 Subject: [PATCH 20/39] delegate render to derived mark --- src/channel.js | 2 + src/mark.js | 2 + test/output/highlightDot.html | 434 ++++++++++++++++++++++++++++++++++ test/plots/highlight.ts | 124 ++++++---- 4 files changed, 511 insertions(+), 51 deletions(-) create mode 100644 test/output/highlightDot.html diff --git a/src/channel.js b/src/channel.js index 4fb46c3448..ac699f1f6a 100644 --- a/src/channel.js +++ b/src/channel.js @@ -41,6 +41,8 @@ export function valueObject(channels, scales) { // promote symbol names (e.g., "plus") to symbol implementations (symbolPlus). // Note: mutates channel! export function inferChannelScale(name, channel) { + if (name === undefined) name = channel.scale; // TODO fixme + else name = name.replace(/^\w+:/, ""); // XXX const {scale, value} = channel; if (scale === true || scale === "auto") { switch (name) { diff --git a/src/mark.js b/src/mark.js index c815f76be0..14833f74f7 100644 --- a/src/mark.js +++ b/src/mark.js @@ -22,6 +22,7 @@ export class Mark { marginRight = margin, marginBottom = margin, marginLeft = margin, + creator, clip = defaults?.clip, channels: extraChannels, tip, @@ -85,6 +86,7 @@ export class Mark { if (render != null) { this.render = composeRender(render, this.render); } + creator?.call(this, options); // XXX } initialize(facets, facetChannels, plotOptions) { let data = arrayify(this.data); diff --git a/test/output/highlightDot.html b/test/output/highlightDot.html new file mode 100644 index 0000000000..6577344711 --- /dev/null +++ b/test/output/highlightDot.html @@ -0,0 +1,434 @@ +
+
+ + + Adelie + + Chinstrap + + Gentoo +
+ + + + + + + + + + + + + 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/plots/highlight.ts b/test/plots/highlight.ts index b3d6d52942..efff0c20f3 100644 --- a/test/plots/highlight.ts +++ b/test/plots/highlight.ts @@ -1,61 +1,83 @@ import * as Plot from "@observablehq/plot"; import * as d3 from "d3"; +function highlight({selected = {}, unselected = {}, ...options}: any = {}) { + return { + ...options, + creator() { + const s = (this.selected = new this.constructor(this.data, {...options, ...selected})); + const u = (this.unselected = new this.constructor(this.data, {...options, ...unselected})); + for (const c in selected) if (c in s.channels) this.channels[`selected:${c}`] = s.channels[c]; + for (const c in unselected) if (c in u.channels) this.channels[`unselected:${c}`] = u.channels[c]; + }, + render(index, scales, values, dimensions, context, next) { + const svg = context.ownerSVGElement; + const s = this.selected; + const u = this.unselected; + const svalues = {...values}; + const uvalues = {...values}; + for (const c in selected) svalues[c] = c in s.channels ? values[`selected:${c}`] : null; + for (const c in unselected) uvalues[c] = c in u.channels ? values[`unselected:${c}`] : null; + const g = next(index, scales, values, dimensions, context); + let ug: ChildNode; + let sg: ChildNode; + + function pointerenter(event) { + if (!ug) { + g.replaceWith((sg = document.createComment("selected"))); + sg.parentNode.insertBefore((ug = document.createComment("unselected")), sg); + } + pointermove(event); + } + + function pointermove(event) { + const [px, py] = d3.pointer(event); + const {x, y} = values; + const S = []; + const U = []; + + for (const i of index) { + (Math.hypot(x[i] - px, y[i] - py) < 100 ? S : U).push(i); + } + + ug.replaceWith((ug = u.render(U, scales, uvalues, dimensions, context))); + sg.replaceWith((sg = s.render(S, scales, svalues, dimensions, context))); + } + + function pointerleave() { + if (ug) { + ug.replaceWith(g); + sg.remove(); + sg = null; + ug = null; + } + } + + svg.addEventListener("pointerenter", pointerenter); + svg.addEventListener("pointermove", pointermove); + svg.addEventListener("pointerleave", pointerleave); + const f = svg.ownerDocument.createDocumentFragment(); + f.append(g, svg.ownerDocument.createComment("brush")); + return f as any; // TODO return a G element instead? + } + }; +} + export async function highlightDot() { const penguins = await d3.csv("data/penguins.csv", d3.autoType); return Plot.plot({ + color: {legend: true}, marks: [ - Plot.dot(penguins, { - x: "culmen_length_mm", - y: "culmen_depth_mm", - r: d3.randomLcg(42), - fill: "species", - fillOpacity: 0.1, - stroke: "species", - render(index, scales, values, dimensions, context, next) { - const svg = context.ownerSVGElement; - const ovalues = {...values, fill: null, stroke: null}; - const g = next(index, scales, values, dimensions, context); - const gg = svg.ownerDocument.createElementNS(svg.namespaceURI, "g"); - gg.appendChild(g); - let og: Element | Comment; // = - let ig: Element | Comment; // = gg.appendChild(document.createComment("selected")); - - function pointerenter(event) { - // g.replaceWith(gg); - g.remove(); - if (!ig) ig = gg.appendChild(document.createComment("unselected")); - if (!og) og = gg.appendChild(document.createComment("unselected")); - pointermove(event); - } - - function pointermove(event) { - const [px, py] = d3.pointer(event); - const {x, y} = values; - const I = []; - const O = []; - - for (const i of index) { - (Math.hypot(x[i] - px, y[i] - py) < 100 ? I : O).push(i); - } - - ig.replaceWith((ig = next(O, scales, ovalues, dimensions, context))); - og.replaceWith((og = next(I, scales, values, dimensions, context))); - } - - function pointerleave() { - // gg.replaceWith(g); - if (ig) ig.remove(), (ig = null); - if (og) og.remove(), (og = null); - gg.insertBefore(g, gg.firstChild); - } - - svg.addEventListener("pointerenter", pointerenter); - svg.addEventListener("pointermove", pointermove); - svg.addEventListener("pointerleave", pointerleave); - return gg; - } - }) + Plot.dot( + penguins, + highlight({ + x: "culmen_length_mm", + y: "culmen_depth_mm", + stroke: "species", + selected: {r: d3.randomLcg(), symbol: "asterisk"}, + unselected: {stroke: "#ccc"} + }) + ) ] }); } From 277709bdee1f895ddc2ead1dc7e54dffefff4dad Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 30 Aug 2023 11:35:10 -0700 Subject: [PATCH 21/39] long form sketch --- test/plots/highlight.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/plots/highlight.ts b/test/plots/highlight.ts index efff0c20f3..a5c9414752 100644 --- a/test/plots/highlight.ts +++ b/test/plots/highlight.ts @@ -65,9 +65,14 @@ function highlight({selected = {}, unselected = {}, ...options}: any = {}) { export async function highlightDot() { const penguins = await d3.csv("data/penguins.csv", d3.autoType); + // const highlighter = Plot.highlight(); return Plot.plot({ color: {legend: true}, marks: [ + // Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", stroke: "species", render: highlighter.inactive}), + // Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", stroke: "species", r: d3.randomLcg(), symbol: "asterisk", render: highlighter.selected, initializer: () => context.getMarkState(mark)}), + // Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", stroke: "#ccc", render: highlighter.unselected}), + // highlighter, Plot.dot( penguins, highlight({ From ec463e2944bf7f5231f37ef5384fa81ba638ddb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 5 Sep 2023 18:55:10 +0200 Subject: [PATCH 22/39] Adopt creator() --- src/interactions/brush.d.ts | 19 +- src/interactions/brush.js | 241 +++++----- test/output/brushBand.html | 53 +++ test/output/brushBand.svg | 66 --- test/output/brushFacets.svg | 737 ++++++++++++++--------------- test/output/brushFacetsViewof.html | 401 ++++++++++++++-- test/output/brushRectX.svg | 11 +- test/output/brushRectY.html | 85 ++++ test/output/brushRectY.svg | 92 ---- test/output/brushScatterplot.svg | 701 ++++++++++++++------------- test/output/brushViewof.html | 701 ++++++++++++++------------- test/plots/brush.ts | 147 ++++-- 12 files changed, 1764 insertions(+), 1490 deletions(-) create mode 100644 test/output/brushBand.html delete mode 100644 test/output/brushBand.svg create mode 100644 test/output/brushRectY.html delete mode 100644 test/output/brushRectY.svg diff --git a/src/interactions/brush.d.ts b/src/interactions/brush.d.ts index 38a13b27e9..40a6bef04b 100644 --- a/src/interactions/brush.d.ts +++ b/src/interactions/brush.d.ts @@ -3,16 +3,17 @@ import type {Rendered} from "../transforms/basic.js"; /** Options for the brush transform. */ type BrushOptions = { /** - * The brush’s selection mode determines the contents of the plot’s value - * property when the user manipulates the brush: - * * **data** - default; the selected data - * * **extent** - the selection extent, in data space - * - * The extent is an object with properties *x*: [x1, x2] for brushX, *y*: [y1, - * y2] for brushY, and both *x* and *y* for brush. Additionally, when - * faceting, it contains the facet’s *fx* and *fy* properties. + * How to display the selected mark when the user manipulates the brush. */ - selectionMode?: "data" | "extent"; + selected?: null; // TODO + /** + * How to display the unselected mark when the user manipulates the brush. + */ + unselected?: null; + /** + * The brush’s padding, defaults to 1. + */ + padding?: number; }; /** diff --git a/src/interactions/brush.js b/src/interactions/brush.js index a688bdb697..1579f2cfa4 100644 --- a/src/interactions/brush.js +++ b/src/interactions/brush.js @@ -1,120 +1,141 @@ -import {create} from "../context.js"; -import {ascending, transpose} from "d3"; +import {ascending, transpose, select} from "d3"; import {brush as brusher, brushX as brusherX, brushY as brusherY} from "d3"; -import {composeRender} from "../mark.js"; -import {keyword, take} from "../options.js"; +import {take} from "../options.js"; const states = new WeakMap(); -function brushTransform(mode, {selectionMode = "data", ...options}) { - selectionMode = keyword(selectionMode, "selectionMode", ["data", "extent"]); +function brushTransform(mode, {selected = {}, unselected = {}, padding = 1, ...options}) { + if (typeof padding !== "number") throw new Error(`invalid brush padding: ${padding}`); return { ...options, - // Interactive transforms must be the outermost render function because they - // will re-render dynamically in response to pointer events. - render: composeRender(function (index, scales, values, dimensions, context, next) { - const svg = context.ownerSVGElement; - const {data} = context.getMarkState(this); + creator() { + const s = (this.selected = new this.constructor(this.data, {...options, ...selected})); + const u = (this.unselected = new this.constructor(this.data, {...options, ...unselected})); + for (const c in selected) if (c in s.channels) this.channels[`selected:${c}`] = s.channels[c]; + for (const c in unselected) if (c in u.channels) this.channels[`unselected:${c}`] = u.channels[c]; + }, + render: function (index, scales, values, dimensions, context, next) { + const s = this.selected; + const u = this.unselected; + const svalues = {...values}; + const uvalues = {...values}; + for (const c in selected) svalues[c] = c in s.channels ? values[`selected:${c}`] : null; + for (const c in unselected) uvalues[c] = c in u.channels ? values[`unselected:${c}`] : null; + const g = next(index, scales, values, dimensions, context); + const {data} = context.getMarkState(this); // TODO: data might be attached to values in the future(?) - // Isolate state per-plot. - let state = states.get(svg); - if (state && !index.fi) throw new Error("The brush interaction currently supports only one brush per plot."); - if (!state) { - // Derive intersection bounds. - const {x, y} = scales; - const bx = x?.bandwidth?.() ?? 0; - const by = y?.bandwidth?.() ?? 0; - const {x: X, x1: X1, x2: X2, y: Y, y1: Y1, y2: Y2} = values; - const Xl = X1 && X2 ? X1.map((d, i) => Math.min(d, X2[i])) : X; - const Xm = X1 && X2 ? X1.map((d, i) => Math.max(d, X2[i]) + bx) : bx ? X.map((d) => d + bx) : X; - const Yl = Y1 && Y2 ? Y1.map((d, i) => Math.min(d, Y2[i])) : Y; - const Ym = Y1 && Y2 ? Y1.map((d, i) => Math.max(d, Y2[i]) + by) : by ? Y.map((d) => d + by) : Y; + const svg = context.ownerSVGElement; + function createBrush() { + // Isolate state per-plot. + let state = states.get(svg); + const transform = g.getAttribute("transform"); + if (state) { + if (!index.fi) throw new Error("The brush interaction currently supports only one brush per plot."); + } else { + // Derive intersection bounds. + const {x, y} = scales.scales; + const bx = x?.bandwidth ?? 0; + const by = y?.bandwidth ?? 0; + const {x: X, x1: X1, x2: X2, y: Y, y1: Y1, y2: Y2} = values; + const Xl = X1 && X2 ? X1.map((d, i) => Math.min(d, X2[i])) : X; + const Xm = X1 && X2 ? X1.map((d, i) => Math.max(d, X2[i]) + bx) : bx ? X.map((d) => d + bx) : X; + const Yl = Y1 && Y2 ? Y1.map((d, i) => Math.min(d, Y2[i])) : Y; + const Ym = Y1 && Y2 ? Y1.map((d, i) => Math.max(d, Y2[i]) + by) : by ? Y.map((d) => d + by) : Y; - // This brush is shared by all the facets. - const {width, height, marginLeft, marginTop, marginRight, marginBottom} = dimensions; - const extent = [ - [marginLeft, marginTop], - [width - marginRight, height - marginBottom] - ]; - const brush = (mode === "xy" ? brusher : mode === "x" ? brusherX : brusherY)() - .extent(extent) - .on("brush start end", function (event, d) { - const {type, selection} = event; - let S = null; - const [X, Y] = !selection - ? [] - : mode === "xy" - ? transpose(selection) - : mode === "x" - ? [selection] - : [, selection]; - if (X || Y) S = d.index; - if (X) { - const [x0, x1] = X; - S = S.filter((i) => x0 <= Xm[i] && Xl[i] <= x1); - } - if (Y) { - const [y0, y1] = Y; - S = S.filter((i) => y0 <= Ym[i] && Yl[i] <= y1); - } - // Only one facet can be active at a time; clear the others. - if (type === "start") for (const target of targets) if (target !== this) target._cancelBrush(); + // This brush is shared by all the facets. + const {width, height, marginLeft, marginTop, marginRight, marginBottom} = dimensions; + const extent = [ + [marginLeft - padding, marginTop - padding], + [width - marginRight + padding, height - marginBottom + padding] + ]; + const brush = (mode === "xy" ? brusher : mode === "x" ? brusherX : brusherY)() + .extent(extent) + .on("start brush end", function ({type, selection, sourceEvent}, fi) { + const b = state.brushState[fi]; + const {index, transform, g} = b; + const [X, Y] = !selection + ? [] + : mode === "xy" + ? transpose(selection) + : mode === "x" + ? [selection] + : [, selection]; - d.display?.replaceWith((d.display = next.call(this, S ?? [], scales, values, dimensions, context))); + const S = []; + const U = []; + for (const i of index) { + [U, S][+((!X || (X[0] < Xm[i] && Xl[i] < X[1])) && (!Y || (Y[0] < Ym[i] && Yl[i] < Y[1])))].push(i); + } + if (!b.ug) { + g.replaceWith((b.sg = document.createComment("selected"))); + b.sg.parentNode.insertBefore((b.ug = document.createComment("unselected")), b.sg); + } + b.ug.replaceWith((b.ug = u.render(U, scales, uvalues, dimensions, context))); + b.sg.replaceWith((b.sg = s.render(S, scales, svalues, dimensions, context))); + if (transform) { + b.ug.setAttribute("transform", transform); + b.sg.setAttribute("transform", transform); + } - // Update the plot’s value if the selection has changed. - if (selectionMode === "data") { - if (!selectionEquals(S, state.selection)) { - state.selection = S; - context.dispatchValue(S === null ? data : take(data, S)); + switch (type) { + // Only one facet can be active at a time; clear and unselect the others. + case "start": + if (sourceEvent && sourceEvent.type !== "empty") + for (const {i, empty} of state.brushState) if (i !== fi) empty(); + break; + case "end": + if (selection === null) { + b.sg.replaceWith(g); + b.ug.remove(); + b.sg = null; + b.ug = null; + if (sourceEvent) for (const {i, reset} of state.brushState) if (i !== fi) reset(); + } + break; } - } - // "extent" - else { - if (selection === null) { - context.dispatchValue(null); - } else { - const value = {}; - if (X) value.x = x.invert ? X.map(x.invert).sort(ascending) : X; - if (Y) value.y = y.invert ? Y.map(y.invert).sort(ascending) : Y; + + // Update the plot’s value. + state.selection = S; + const value = S === null ? data : take(data, S); + if (selection !== null) { + if (X) addBrushDomain(x, X, value); + if (Y) addBrushDomain(y, Y, value); if ("fx" in scales) value.fx = index.fx; if ("fy" in scales) value.fy = index.fy; - context.dispatchValue(value); } - } - }); - - states.set(svg, (state = {brush, targets: [], selection: null})); - } - const {brush, targets} = state; - const display = next.call(this, [], scales, values, dimensions, context); - - // Create a wrapper for the elements to display, and a target that will - // carry the brush. Save references to the display and index of the current - // facet. - const wrapper = create("svg:g", context); - const target = create("svg:g", context).attr("aria-label", "brush").datum({display, index}).call(brush); - const node = target.node(); - node._cancelBrush = () => target.call(brush.move, null); - targets.push(node); - - if (display) { - const al = display.getAttribute("aria-label"); - al && (wrapper.attr("aria-label", al), display.removeAttribute("aria-label")); - wrapper.append(() => display); + value.done = type === "end"; + context.dispatchValue(value); + }); + states.set(svg, (state = {brush, brushState: [], selection: null})); + } + const {brush, brushState} = state; + const fi = index.fi ?? 0; + const target = select(g.parentElement).append("g").attr("transform", transform).datum(fi).call(brush); + brushState[fi] = { + i: fi, + index, + transform, + g, + reset() { + target.call(brush.move, null); + }, + empty() { + target.call( + brush.move, + mode === "xy" + ? [ + [0, 0], + [0, 0] + ] + : [0, 0] + ); + } + }; + svg.removeEventListener("pointerenter", createBrush); } - - // Translate the brush target to match the facet’s frame position, and - // initialize the plot’s value. We skip a beat so all the marks have been - // added before the brush (to top z-index). - Promise.resolve().then(() => { - node.setAttribute("transform", wrapper.attr("transform")); - svg.appendChild(node); - context.dispatchValue(selectionMode === "data" ? data : null); - }); - - return wrapper.node(); - }, options.render) + svg.addEventListener("pointerenter", createBrush); + return g; + } }; } @@ -130,9 +151,15 @@ export function brushY(options = {}) { return brushTransform("y", options); } -function selectionEquals(A, B) { - if (A === B) return true; - if (!Array.isArray(A) || !Array.isArray(B) || A.length != B.length) return false; - for (let i = 0; i < A.length; ++i) if (A[i] !== B[i]) return false; - return true; +// Note: mutates value! +function addBrushDomain(x, X, value) { + if (x.type === "band" || x.type === "point") { + const b = x.bandwidth ?? 0; + value.x = x.domain.filter((d) => { + const v = x.apply(d); + return X[0] < v + b && v < X[1]; + }); + } else { + [value.x1, value.x2] = x.invert ? X.map(x.invert).sort(ascending) : X; + } } diff --git a/test/output/brushBand.html b/test/output/brushBand.html new file mode 100644 index 0000000000..4b4edd1ce1 --- /dev/null +++ b/test/output/brushBand.html @@ -0,0 +1,53 @@ +
+ + + + + + + + FEMALE + MALE + + + sex + + + + + + + + Adelie + Chinstrap + Gentoo + + + species + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/brushBand.svg b/test/output/brushBand.svg deleted file mode 100644 index c96f2ada68..0000000000 --- a/test/output/brushBand.svg +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - FEMALE - MALE - - - sex - - - - - - - - Adelie - Chinstrap - Gentoo - - - species - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/test/output/brushFacets.svg b/test/output/brushFacets.svg index f937c87759..d66fe83007 100644 --- a/test/output/brushFacets.svg +++ b/test/output/brushFacets.svg @@ -86,400 +86,353 @@ culmen_length_mm → - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/test/output/brushFacetsViewof.html b/test/output/brushFacetsViewof.html index b34edaecc9..c67c183221 100644 --- a/test/output/brushFacetsViewof.html +++ b/test/output/brushFacetsViewof.html @@ -435,33 +435,372 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - + - + @@ -471,7 +810,7 @@ - + @@ -481,7 +820,7 @@ - + @@ -492,42 +831,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/test/output/brushRectX.svg b/test/output/brushRectX.svg index 353acf5c61..c2d54ae39f 100644 --- a/test/output/brushRectX.svg +++ b/test/output/brushRectX.svg @@ -59,7 +59,7 @@ Frequency → - + @@ -80,13 +80,4 @@ - - - - - - - - - \ No newline at end of file diff --git a/test/output/brushRectY.html b/test/output/brushRectY.html new file mode 100644 index 0000000000..9febbf4f31 --- /dev/null +++ b/test/output/brushRectY.html @@ -0,0 +1,85 @@ +
+ + + + + + + + + + + + + + 0 + 5 + 10 + 15 + 20 + 25 + 30 + 35 + 40 + + + ↑ 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/brushRectY.svg b/test/output/brushRectY.svg deleted file mode 100644 index ed7dc5026d..0000000000 --- a/test/output/brushRectY.svg +++ /dev/null @@ -1,92 +0,0 @@ - - - - - - - - - - - - - - - 0 - 5 - 10 - 15 - 20 - 25 - 30 - 35 - 40 - - - ↑ 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/brushScatterplot.svg b/test/output/brushScatterplot.svg index a8a813f911..5be32596da 100644 --- a/test/output/brushScatterplot.svg +++ b/test/output/brushScatterplot.svg @@ -53,363 +53,348 @@ culmen_length_mm → - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/brushViewof.html b/test/output/brushViewof.html index ecafab0857..77abb351b4 100644 --- a/test/output/brushViewof.html +++ b/test/output/brushViewof.html @@ -53,364 +53,349 @@ culmen_length_mm → - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/plots/brush.ts b/test/plots/brush.ts index 7f3a28d43b..e7cdc4477c 100644 --- a/test/plots/brush.ts +++ b/test/plots/brush.ts @@ -4,21 +4,29 @@ import {html} from "htl"; export async function brushBand() { const penguins = await d3.csv("data/penguins.csv", d3.autoType); - return Plot.plot({ - marks: [ - Plot.cell(penguins, Plot.group({fill: "count"}, {x: "species", y: "sex", fillOpacity: 0.3})), - Plot.cell(penguins, Plot.brush(Plot.group({fill: "count"}, {x: "species", y: "sex"}))) - ] - }); + return showValue( + Plot.cell( + penguins, + Plot.brush(Plot.group({fill: "count"}, {x: "species", y: "sex", unselected: {fillOpacity: 0.3}})) + ).plot() + ); } export async function brushFacets() { const penguins = await d3.csv("data/penguins.csv", d3.autoType); return Plot.plot({ - facet: {data: penguins, x: "species"}, marks: [ - Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm"}), - Plot.dot(penguins, Plot.brush({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species", stroke: "black"})) + Plot.dot( + penguins, + Plot.brush({ + unselected: {fill: "white"}, + x: "culmen_length_mm", + y: "culmen_depth_mm", + fx: "species", + fill: "species", + stroke: "black" + }) + ) ] }); } @@ -27,69 +35,110 @@ export async function brushRectX() { const penguins = await d3.csv("data/penguins.csv", d3.autoType); return Plot.plot({ marks: [ - Plot.rectX(penguins, Plot.binY({x: "count"}, {y: "body_mass_g", fillOpacity: 0.5, thresholds: 20})), - Plot.rectX(penguins, Plot.brushY(Plot.binY({x: "count"}, {y: "body_mass_g", thresholds: 20}))) + Plot.rectX( + penguins, + Plot.brushY(Plot.binY({x: "count"}, {unselected: {fill: "gray"}, y: "body_mass_g", thresholds: 20})) + ) ] }); } export async function brushRectY() { const penguins = await d3.csv("data/penguins.csv", d3.autoType); - return Plot.plot({ - marks: [ - Plot.rectY(penguins, Plot.binX({y: "count"}, {x: "body_mass_g", fillOpacity: 0.5, thresholds: 20})), - Plot.rectY(penguins, Plot.brushX(Plot.binX({y: "count"}, {x: "body_mass_g", thresholds: 20}))) - ] - }); + return showValue( + Plot.plot({ + marks: [ + Plot.rectY( + penguins, + Plot.brushX(Plot.binX({y: "count"}, {unselected: {fillOpacity: 0.5}, x: "body_mass_g", thresholds: 20})) + ) + ] + }) + ); } export async function brushScatterplot() { const penguins = await d3.csv("data/penguins.csv", d3.autoType); return Plot.plot({ marks: [ - Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm"}), - Plot.dot(penguins, Plot.brush({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species", stroke: "black"})) - ] - }); -} - -export async function brushViewof() { - const penguins = await d3.csv("data/penguins.csv", d3.autoType); - const plot = Plot.plot({ - marks: [ - Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm"}), - Plot.dot(penguins, Plot.brush({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "sex", stroke: "black"})) - ] - }); - const textarea = html` + \ No newline at end of file diff --git a/test/output/brushFacets.svg b/test/output/brushFacets.svg deleted file mode 100644 index d66fe83007..0000000000 --- a/test/output/brushFacets.svg +++ /dev/null @@ -1,438 +0,0 @@ - - - - - Adelie - - - Chinstrap - - - Gentoo - - - - species - - - - - - - - - - - - - - - - 14 - 15 - 16 - 17 - 18 - 19 - 20 - 21 - - - - ↑ culmen_depth_mm - - - - - - - - - - - - - - - - - - 40 - 50 - - - 40 - 50 - - - 40 - 50 - - - - culmen_length_mm → - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/test/output/brushFacetsViewof.html b/test/output/brushFacetsViewof.html deleted file mode 100644 index c67c183221..0000000000 --- a/test/output/brushFacetsViewof.html +++ /dev/null @@ -1,836 +0,0 @@ -
- - - - Adelie - - - Chinstrap - - - Gentoo - - - - species - - - - - - - - - - - - - - - - 14 - 15 - 16 - 17 - 18 - 19 - 20 - 21 - - - - ↑ culmen_depth_mm - - - - - - - - - - - - - - - - - - 40 - 50 - - - 40 - 50 - - - 40 - 50 - - - - culmen_length_mm → - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
\ No newline at end of file diff --git a/test/output/brushMetroInequalityChange.html b/test/output/brushMetroInequalityChange.html new file mode 100644 index 0000000000..ba0b0d67a5 --- /dev/null +++ b/test/output/brushMetroInequalityChange.html @@ -0,0 +1,435 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + 3.5 + 4.0 + 4.5 + 5.0 + 5.5 + 6.0 + 6.5 + 7.0 + 7.5 + 8.0 + 8.5 + + + ↑ Inequality + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 200k + 300k + + + + + + + 1M + 2M + 3M + + + + + + + 10M + 20M + + + Population → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + New York + Chicago + Houston + Washington, D.C. + San Francisco + San Jose + Fairfield, Conn. + Binghamton, N.Y. + + + +
\ No newline at end of file diff --git a/test/output/brushRectX.html b/test/output/brushRectX.html new file mode 100644 index 0000000000..27b9257a20 --- /dev/null +++ b/test/output/brushRectX.html @@ -0,0 +1,85 @@ +
+ + + + + + + + + + + + 3,000 + 3,500 + 4,000 + 4,500 + 5,000 + 5,500 + 6,000 + + + ↑ body_mass_g + + + + + + + + + + + + + + 0 + 5 + 10 + 15 + 20 + 25 + 30 + 35 + 40 + + + Frequency → + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/brushRectX.svg b/test/output/brushRectX.svg deleted file mode 100644 index c2d54ae39f..0000000000 --- a/test/output/brushRectX.svg +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - - - - - - - - 3,000 - 3,500 - 4,000 - 4,500 - 5,000 - 5,500 - 6,000 - - - ↑ body_mass_g - - - - - - - - - - - - - - 0 - 5 - 10 - 15 - 20 - 25 - 30 - 35 - 40 - - - Frequency → - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/test/output/brushViewof.html b/test/output/brushScatterplot.html similarity index 82% rename from test/output/brushViewof.html rename to test/output/brushScatterplot.html index 77abb351b4..2638a6d3cb 100644 --- a/test/output/brushViewof.html +++ b/test/output/brushScatterplot.html @@ -53,349 +53,349 @@ culmen_length_mm → - - + + - + - - - - - + + + + + - - + + - + - + - + - - + + - + - + - + - + - - + + - + - + - + - - - + + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - + + - + - - + + - + - - + + - + - + - + - + - - + + - + - + - + - + - + - + - + - - + + - + - + - - + + - + - - + + - + - + - + - + - - + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + diff --git a/test/output/brushScatterplot.svg b/test/output/brushScatterplot.svg deleted file mode 100644 index 5be32596da..0000000000 --- a/test/output/brushScatterplot.svg +++ /dev/null @@ -1,400 +0,0 @@ - - - - - - - - - - - - - - 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/plots/brush.ts b/test/plots/brush.ts index e7cdc4477c..03a96096f6 100644 --- a/test/plots/brush.ts +++ b/test/plots/brush.ts @@ -14,108 +14,115 @@ export async function brushBand() { export async function brushFacets() { const penguins = await d3.csv("data/penguins.csv", d3.autoType); - return Plot.plot({ - marks: [ - Plot.dot( - penguins, - Plot.brush({ - unselected: {fill: "white"}, - x: "culmen_length_mm", - y: "culmen_depth_mm", - fx: "species", - fill: "species", - stroke: "black" - }) - ) - ] - }); + return showValue( + Plot.plot({ + marks: [ + Plot.dot( + penguins, + Plot.brush({ + unselected: {fill: "white"}, + x: "culmen_length_mm", + y: "culmen_depth_mm", + fx: "species", + fill: "species", + stroke: "black" + }) + ) + ] + }) + ); } -export async function brushRectX() { - const penguins = await d3.csv("data/penguins.csv", d3.autoType); - return Plot.plot({ - marks: [ - Plot.rectX( - penguins, - Plot.brushY(Plot.binY({x: "count"}, {unselected: {fill: "gray"}, y: "body_mass_g", thresholds: 20})) - ) - ] - }); +export async function brushMetroInequalityChange() { + const data = await d3.csv("data/metros.csv", d3.autoType); + return showValue( + Plot.plot({ + grid: true, + inset: 10, + x: { + type: "log", + label: "Population" + }, + y: { + label: "Inequality" + }, + color: { + scheme: "BuRd", + symmetric: false + }, + marks: [ + Plot.link( + data, + Plot.brush({ + x1: "POP_1980", + y1: "R90_10_1980", + x2: "POP_2015", + y2: "R90_10_2015", + unselected: {stroke: "#ccc"}, + markerEnd: "arrow", + stroke: (d) => d.R90_10_2015 - d.R90_10_1980 + }) + ), + Plot.text(data, { + x: "POP_2015", + y: "R90_10_2015", + filter: "highlight", + text: "nyt_display", + fill: "currentColor", + stroke: "white", + pointerEvents: "none", + dy: -8 + }) + ] + }) + ); } -export async function brushRectY() { +export async function brushRectX() { const penguins = await d3.csv("data/penguins.csv", d3.autoType); return showValue( Plot.plot({ marks: [ - Plot.rectY( + Plot.rectX( penguins, - Plot.brushX(Plot.binX({y: "count"}, {unselected: {fillOpacity: 0.5}, x: "body_mass_g", thresholds: 20})) + Plot.brushY(Plot.binY({x: "count"}, {unselected: {fill: "gray"}, y: "body_mass_g", thresholds: 20})) ) ] }) ); } -export async function brushScatterplot() { - const penguins = await d3.csv("data/penguins.csv", d3.autoType); - return Plot.plot({ - marks: [ - Plot.dot( - penguins, - Plot.brush({ - unselected: {fillOpacity: 0}, - selected: {fillOpacity: 1}, - x: "culmen_length_mm", - y: "culmen_depth_mm", - fill: "species", - stroke: "black", - fillOpacity: 0.5 - }) - ) - ] - }); -} - -export async function brushViewof() { +export async function brushRectY() { const penguins = await d3.csv("data/penguins.csv", d3.autoType); return showValue( Plot.plot({ marks: [ - Plot.dot( + Plot.rectY( penguins, - Plot.brush({ - unselected: {fill: "white"}, - x: "culmen_length_mm", - y: "culmen_depth_mm", - fill: "sex", - stroke: "black" - }) + Plot.brushX(Plot.binX({y: "count"}, {unselected: {fillOpacity: 0.5}, x: "body_mass_g", thresholds: 20})) ) ] }) ); } -export async function brushFacetsViewof() { +export async function brushScatterplot() { const penguins = await d3.csv("data/penguins.csv", d3.autoType); return showValue( Plot.plot({ - facet: {data: penguins, x: "species"}, marks: [ - Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm"}), Plot.dot( penguins, Plot.brush({ - selected: {fill: "species"}, + unselected: {fillOpacity: 0}, + selected: {fillOpacity: 1}, x: "culmen_length_mm", y: "culmen_depth_mm", + fill: "species", stroke: "black", - selectionMode: "extent" + fillOpacity: 0.5 }) - ), - Plot.gridX({strokeOpacity: 1, pointerEvents: "none"}), - Plot.gridY({strokeOpacity: 1, pointerEvents: "none"}) + ) ] }) ); From 7acd0ce207cb2edd97e731dd36e005ba870e7e94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Wed, 6 Sep 2023 11:22:16 +0200 Subject: [PATCH 29/39] show value.done --- test/plots/brush.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/plots/brush.ts b/test/plots/brush.ts index 03a96096f6..20b900ffaf 100644 --- a/test/plots/brush.ts +++ b/test/plots/brush.ts @@ -134,7 +134,7 @@ function showValue(plot) { textarea.value = !plot.value ? "—" : ( - ["x", "y", "x1", "x2", "y1", "y2"] + ["x", "y", "x1", "x2", "y1", "y2", "done"] .map((x) => x in plot.value ? `${x}: ${typeof plot.value[x] === "number" ? plot.value[x].toFixed(3) : plot.value[x]}` From 155b18455b9fc97b8899ad9eda9e81df4992129b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Wed, 6 Sep 2023 11:25:43 +0200 Subject: [PATCH 30/39] alphabetical order of case statements --- docs/data/api.data.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/data/api.data.ts b/docs/data/api.data.ts index 224a0d8182..fbefc5b217 100644 --- a/docs/data/api.data.ts +++ b/docs/data/api.data.ts @@ -62,10 +62,10 @@ function getHref(name: string, path: string): string { } break; } - case "marks/crosshair": - return "interactions/crosshair"; case "marks/brush": return "interactions/brush"; + case "marks/crosshair": + return "interactions/crosshair"; case "transforms/basic": { switch (name) { case "filter": @@ -141,7 +141,9 @@ export default { throw new Error(`anchor not found: ${href}#${name}`); } } - for (const {context: {href}} of allOptions) { + for (const { + context: {href} + } of allOptions) { if (!anchors.has(`/${href}.md`)) { throw new Error(`file not found: ${href}`); } From 347f085d49517d2b391a70a7f31b65bcbf1e9b6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Wed, 6 Sep 2023 11:26:29 +0200 Subject: [PATCH 31/39] combine onMounted --- docs/features/interactions.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/docs/features/interactions.md b/docs/features/interactions.md index 9dba9576df..24906940ea 100644 --- a/docs/features/interactions.md +++ b/docs/features/interactions.md @@ -9,16 +9,13 @@ const olympians = shallowRef([ {weight: 170, height: 2.21, sex: "male"} ]); -onMounted(() => { - d3.csv("../data/athletes.csv", d3.autoType).then((data) => (olympians.value = data)); -}); - const penguins = shallowRef([ {culmen_length_mm: 32.1, culmen_depth_mm: 13.1}, {culmen_length_mm: 59.6, culmen_depth_mm: 21.5} ]); onMounted(() => { + d3.csv("../data/athletes.csv", d3.autoType).then((data) => (olympians.value = data)); d3.csv("../data/penguins.csv", d3.autoType).then((data) => (penguins.value = data)); }); From 4da66623b722c8b2e7d9e41c5c331af3729aea2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Wed, 6 Sep 2023 12:08:35 +0200 Subject: [PATCH 32/39] more documentation --- docs/features/interactions.md | 21 ++++++----- docs/interactions/brush.md | 67 +++++++++++++++++------------------ 2 files changed, 46 insertions(+), 42 deletions(-) diff --git a/docs/features/interactions.md b/docs/features/interactions.md index 24906940ea..12225e5822 100644 --- a/docs/features/interactions.md +++ b/docs/features/interactions.md @@ -60,16 +60,21 @@ These values are displayed atop the axes on the edge of the frame; unlike the ti ## Selecting -The [brush transform](../interactions/brush.md) allows the interactive selection of discrete elements, such as dots in a scatterplot, by direct manipulation of the chart. A brush listens to mouse and touch events on the chart, allowing the user to define a rectangular region. All the data points that fall within the region are included in the selection. +The [brush transform](../interactions/brush.md) allows the interactive selection of discrete elements by direct manipulation of the chart. -:::plot defer https://observablehq.com/@observablehq/plot-brush-interaction-dev +:::plot defer https://observablehq.com/@observablehq/brushing-plot--1653 ```js -Plot.plot({ - marks: [ - Plot.dot(penguins, { x: "culmen_length_mm", y: "culmen_depth_mm" }), - Plot.dot(penguins, Plot.brush({ x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species", stroke: "currentColor" })) - ] -}) +Plot.dot( + penguins, + Plot.brush({ + x: "culmen_length_mm", + y: "culmen_depth_mm", + stroke: "currentColor", + fill: "#fff", + unselected: {strokeOpacity: 0.5}, + selected: {fill: "species"} + }) +).plot() ``` ::: diff --git a/docs/interactions/brush.md b/docs/interactions/brush.md index c131e53195..bc156ab972 100644 --- a/docs/interactions/brush.md +++ b/docs/interactions/brush.md @@ -4,18 +4,9 @@ import * as Plot from "@observablehq/plot"; import * as d3 from "d3"; import {ref, shallowRef, onMounted} from "vue"; -// const pointered = ref(true); -// const aapl = shallowRef([]); -// const industries = shallowRef([]); -// const olympians = shallowRef([]); const penguins = shallowRef([]); -// const linetip = ref("x"); -// const recttip = ref("x"); onMounted(() => { -// d3.csv("../data/aapl.csv", d3.autoType).then((data) => (aapl.value = data)); -// d3.csv("../data/athletes.csv", d3.autoType).then((data) => (olympians.value = data)); -// d3.csv("../data/bls-industry-unemployment.csv", d3.autoType).then((data) => (industries.value = data)); d3.csv("../data/penguins.csv", d3.autoType).then((data) => (penguins.value = data)); }); @@ -23,16 +14,21 @@ onMounted(() => { # Brush transform -The **brush transform** filters a mark interactively such that only the data that fall within the rectangular region defined by the user are rendered. It is typically used to select discrete elements, such as dots in a scatterplot: +The **brush transform** allows the interactive selection of discrete elements, such as dots in a scatterplot, by direct manipulation of the chart. A brush listens to mouse and touch events on the chart, allowing the user to define a rectangular region. All the data points that fall within the region are included in the selection. :::plot defer ```js -Plot.plot({ - marks: [ - Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", fill: "currentColor"}), - Plot.dot(penguins, Plot.brush({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species", stroke: "currentColor", r: 4})) - ] -}) +Plot.dot( + penguins, + Plot.brush({ + x: "culmen_length_mm", + y: "culmen_depth_mm", + stroke: "currentColor", + fill: "#fff", + unselected: {strokeOpacity: 0.5}, + selected: {fill: "species"} + }) +).plot() ``` ::: @@ -40,18 +36,25 @@ When the chart has a dominant axis, an horizontal or vertical brush is recommend :::plot defer ```js -Plot.plot({ - marks: [ - Plot.rectY(penguins, Plot.binX({y: "count"}, {x: "body_mass_g", thresholds: 40, fillOpacity: 0.2})), - Plot.rectY(penguins, Plot.brushX(Plot.binX({y: "count"}, {fill:"currentColor", x: "body_mass_g", thresholds: 40}))), - ] -}) +Plot.rectY( + penguins, + Plot.brushX( + Plot.binX( + {y: "count"}, + { + x: "body_mass_g", + thresholds: 40, + unselected: {opacity: 0.1}, + } + ) + ) +).plot() ``` ::: -The brush transform is similar to the [pointer](./pointer.md) transform: it interactively filters the mark’s index to show a subset of the data, and re-renders the mark as the selection changes. Since the mark is lazily rendered during interaction, it is fast: only the visible elements are rendered as needed. And, like the filter and select transforms, unfiltered channel values are incorporated into default scale domains. +The brush transform interactively partitions the mark’s index in two: the unselected subset — for points outside the region —, and the selected subset for points inside. As the selection changes, the mark is replaced by two derived marks: below, a mark for the unselected data, with the mark options combined with the **unselected** option; above, a mark for the selected data, with the mark options combined with the **selected** option. All the channel values are incorporated into default scale domains, allowing *e.g.* a color scale to include the fill channel of the selected mark. -The brush transform supports both one- and two-dimensional brushing modes. The two-dimensional mode, [brush](#brush), is used above and is suitable for scatterplots and the general case: it allows the user to define a rectangular region by clicking on a corner (_e.g._ the top-left corner) and dragging the pointer to the bottom-right corner. The one-dimensional modes, [brushX](#brushX) and [brushY](#brushY), in contrast only consider one dimension; this is desirable when a chart has a “dominant” dimension, such as time in a time-series chart, the binned quantitative dimension in a histogram, or the categorical dimension of a bar chart. +The brush transform supports both one- and two-dimensional brushing modes. The two-dimensional mode, [brush](#brush), is suitable for scatterplots and the general case: it allows the user to define a rectangular region by clicking on a corner (_e.g._ the top-left corner) and dragging the pointer to the bottom-right corner. The one-dimensional modes, [brushX](#brushX) and [brushY](#brushY), in contrast only consider one dimension; this is desirable when a chart has a “dominant” dimension, such as time in a time-series chart, the binned quantitative dimension in a histogram, or the categorical dimension of a bar chart. The brush transform emits an [*input* event](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event) whenever the selection changes, and sets the value of the plot element to the selected data. This allows you to use a plot as an [Observable view](https://observablehq.com/@observablehq/views) (viewof), or to register an *input* event listener to react to brushing. @@ -65,20 +68,16 @@ The following options control the brush transform: - **y2** - the ending vertical↕︎ target position; bound to the *y* scale - **x** - the fallback horizontal↔︎ target position; bound to the *x* scale - **y** - the fallback vertical↕︎ target position; bound to the *y* scale -- **selectionMode** - controls the value exposed to listeners of the *input* events. +- **selected** - additional options for the derived mark representing the selection +- **unselected** - additional options for the derived mark representing non-selected data The positional options define a sensitive surface for each data point, defined on the horizontal axis as the extent between *x1* and *x2* if specified, between *x* and *x + bandwidth* if *x* is a band scale, or the value *x* otherwise. The sensitive surface’s vertical extent likewise spans from *y1* to *y2* if specified, from *y* to *y + bandwidth* if *y* is a band scale, or is equal to the *y* value otherwise. -When the user interacts with the plot by clicking and dragging the brush to define a rectangular region, all the elements whose sensitive surface intersect with the brushed region are selected, and the mark is re-rendered. +When the user interacts with the plot by clicking and dragging the brush to define a rectangular region, all the elements whose sensitive surface intersect with the brushed region are selected, and the derived marks are re-rendered. -The brush’s selection mode determines the contents of the plot’s value property when the user manipulates the brush. It supports the following options: +The selected data exposed as the value of the plot is an array of the (possibly transformed) data rendered by the *selected* derived mark. For example, in the case of the histogram above, the selected data is an array of bins, each containing the penguins whose body mass is between the bin’s lower and upper bounds. -* **data** - default; the selected data -* **extent** - the selection extent, in data space - -The selected data is an array of the possibly transformed data rendered by the mark. For example, in the case of the histogram above, the selected data is an array of bins, each containing the penguins whose body mass is between the bin’s lower and upper bounds. - -The selection extent is an object with properties *x*: [x1, x2] for brushX, *y*: [y1, y2] for brushY, and both *x* and *y* for brush. Additionally, when faceting, it contains the facet’s *fx* and *fy* properties. +The value is decorated with the brush’s coordinates (in data space) as its **x1** and **x2** properties for a quantitative scale *x*, and its **x** property if *x* is ordinal — and likewise for *y*. The value is also decorated with a **done** property set to false while brushing, true when the user releases the pointer, and undefined when the brush is canceled. Additionally, when faceting, it exposes the brushed facet’s *fx* and *fy* properties. For details on the user interface (including touch events, pointer events and modifier keys), see [d3-brush](https://github.com/d3/d3-brush). @@ -88,7 +87,7 @@ For details on the user interface (including touch events, pointer events and mo Plot.dot(penguins, Plot.brush({x: "culmen_length_mm", y: "culmen_depth_mm"})) ``` -Applies the brush render transform to the specified *options* to filter the mark index such that the points whose sensitive surface intersect with the brushed region the point closest to the pointer is rendered; the mark will re-render interactively in response to brush events. +Applies the brush render transform to the specified *options* to filter the mark index such that the points whose sensitive surface intersect with the brushed region the point closest to the pointer is rendered. ## brushX(*options*) {#brushX} From 19a60edf74603b70814108149d5a01999a8ccff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Wed, 13 Sep 2023 17:11:01 +0200 Subject: [PATCH 33/39] add version badge --- docs/features/interactions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/interactions.md b/docs/features/interactions.md index 12225e5822..b36e4e4e83 100644 --- a/docs/features/interactions.md +++ b/docs/features/interactions.md @@ -60,7 +60,7 @@ These values are displayed atop the axes on the edge of the frame; unlike the ti ## Selecting -The [brush transform](../interactions/brush.md) allows the interactive selection of discrete elements by direct manipulation of the chart. +The [brush transform](../interactions/brush.md) allows the interactive selection of discrete elements by direct manipulation of the chart. :::plot defer https://observablehq.com/@observablehq/brushing-plot--1653 ```js From 4c29052d572c5d46e26151418374949e07e67fff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 18 Sep 2023 15:05:49 +0200 Subject: [PATCH 34/39] =?UTF-8?q?don't=20dispatch=20an=20event=20if=20not?= =?UTF-8?q?=20connected=20=E2=80=94=20it=20can=20only=20be=20an=20initiali?= =?UTF-8?q?zation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plot.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plot.js b/src/plot.js index 039a269ada..727a6c60b8 100644 --- a/src/plot.js +++ b/src/plot.js @@ -175,7 +175,7 @@ export function plot(options = {}) { context.dispatchValue = (value) => { if (figure.value === value) return; figure.value = value; - figure.dispatchEvent(new Event("input", {bubbles: true})); + if (figure.isConnected) figure.dispatchEvent(new Event("input", {bubbles: true})); }; // Reinitialize; for deriving channels dependent on other channels. From 820d4d950ccff6e6fca484f90558ac0d4d4d8aea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 18 Sep 2023 15:06:14 +0200 Subject: [PATCH 35/39] promote a value set up during initialization to the figure --- src/plot.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/plot.js b/src/plot.js index 727a6c60b8..2ec4cb4fc2 100644 --- a/src/plot.js +++ b/src/plot.js @@ -332,6 +332,10 @@ export function plot(options = {}) { if (subtitle != null) figure.append(createTitleElement(document, subtitle, "h3")); figure.append(...legends, svg); if (caption != null) figure.append(createFigcaption(document, caption)); + if ("value" in svg) { + figure.value = svg.value; + delete svg.value; + } } figure.scale = exposeScales(scales.scales); From 9dd5644771c703e506baabb33f83f1cd6f1ef379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 18 Sep 2023 15:06:25 +0200 Subject: [PATCH 36/39] test the initial value --- test/output/brushFacets.html | 891 ++++++++++++++++++----------------- test/plots/brush.ts | 1 + 2 files changed, 464 insertions(+), 428 deletions(-) diff --git a/test/output/brushFacets.html b/test/output/brushFacets.html index 5a507834ba..84c3c9e4db 100644 --- a/test/output/brushFacets.html +++ b/test/output/brushFacets.html @@ -1,440 +1,475 @@ -
-
+ - - - Adelie - - - Chinstrap - - - Gentoo - - - - species - - - - - - - - - - - - - - - - 14 - 15 - 16 - 17 - 18 - 19 - 20 - 21 - - - - ↑ culmen_depth_mm - - - - - + .plot-swatch>svg { + margin-right: 0.5em; + overflow: visible; + } + + .plot-swatches-wrap { + display: flex; + align-items: center; + min-height: 33px; + flex-wrap: wrap; + } + + .plot-swatches-wrap .plot-swatch { + display: inline-flex; + align-items: center; + margin-right: 1em; + } + + + Adelie + + Chinstrap + + Gentoo +
+ + + + Adelie + + + Chinstrap + + + Gentoo + - - - + + species - - - + + + + + + + + + + + - - - - 40 - 50 + + + 14 + 15 + 16 + 17 + 18 + 19 + 20 + 21 + - - 40 - 50 + + ↑ culmen_depth_mm - - 40 - 50 + + + + + + + + + + + + + - - - culmen_length_mm → - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + 40 + 50 + + + 40 + 50 + + + 40 + 50 + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + culmen_length_mm → - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + +
\ No newline at end of file diff --git a/test/plots/brush.ts b/test/plots/brush.ts index 20b900ffaf..031a99df78 100644 --- a/test/plots/brush.ts +++ b/test/plots/brush.ts @@ -16,6 +16,7 @@ export async function brushFacets() { const penguins = await d3.csv("data/penguins.csv", d3.autoType); return showValue( Plot.plot({ + color: {legend: true}, marks: [ Plot.dot( penguins, From 02710bbd2754f353a5be85603e6c89b75f27d58d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 18 Sep 2023 15:24:34 +0200 Subject: [PATCH 37/39] respect an existing render transform --- src/interactions/brush.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/interactions/brush.js b/src/interactions/brush.js index 54667f5df7..9dc1e6fdde 100644 --- a/src/interactions/brush.js +++ b/src/interactions/brush.js @@ -21,7 +21,9 @@ function brushTransform(mode, {selected = {}, unselected = {}, padding = 1, ...o const uvalues = {...values}; for (const c in selected) svalues[c] = c in s.channels ? values[`selected:${c}`] : undefined; for (const c in unselected) uvalues[c] = c in u.channels ? values[`unselected:${c}`] : undefined; - const g = next(index, scales, values, dimensions, context); + const g = options.render + ? options.render(index, scales, values, dimensions, context, next) + : next(index, scales, values, dimensions, context); const {data} = context.getMarkState(this); // TODO: data might be attached to values in the future(?) const svg = context.ownerSVGElement; From a41572d6572f5591dac9e23f0560243cded07713 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 21 Dec 2023 11:00:14 +0100 Subject: [PATCH 38/39] update tests --- test/output/brushBand.html | 8 +- test/output/brushFacets.html | 706 ++++++++++---------- test/output/brushMetroInequalityChange.html | 8 +- test/output/brushRectX.html | 8 +- test/output/brushRectY.html | 8 +- test/output/brushScatterplot.html | 692 +++++++++---------- test/output/highlightDot.html | 706 ++++++++++---------- 7 files changed, 1068 insertions(+), 1068 deletions(-) diff --git a/test/output/brushBand.html b/test/output/brushBand.html index 4b4edd1ce1..c1de285797 100644 --- a/test/output/brushBand.html +++ b/test/output/brushBand.html @@ -1,15 +1,15 @@
diff --git a/test/output/brushFacets.html b/test/output/brushFacets.html index 84c3c9e4db..249fe0b506 100644 --- a/test/output/brushFacets.html +++ b/test/output/brushFacets.html @@ -2,48 +2,48 @@
+ - Adelie + Adelie - Chinstrap + Chinstrap Gentoo
@@ -121,352 +121,352 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/output/brushMetroInequalityChange.html b/test/output/brushMetroInequalityChange.html index ba0b0d67a5..7bd777f46e 100644 --- a/test/output/brushMetroInequalityChange.html +++ b/test/output/brushMetroInequalityChange.html @@ -1,15 +1,15 @@
diff --git a/test/output/brushRectX.html b/test/output/brushRectX.html index 27b9257a20..ef26c06b3e 100644 --- a/test/output/brushRectX.html +++ b/test/output/brushRectX.html @@ -1,15 +1,15 @@
diff --git a/test/output/brushRectY.html b/test/output/brushRectY.html index 9febbf4f31..dc7ca2727f 100644 --- a/test/output/brushRectY.html +++ b/test/output/brushRectY.html @@ -1,15 +1,15 @@
diff --git a/test/output/brushScatterplot.html b/test/output/brushScatterplot.html index 2638a6d3cb..ba48740da7 100644 --- a/test/output/brushScatterplot.html +++ b/test/output/brushScatterplot.html @@ -1,15 +1,15 @@
@@ -54,348 +54,348 @@ culmen_length_mm → - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/output/highlightDot.html b/test/output/highlightDot.html index 6577344711..6e66c82e02 100644 --- a/test/output/highlightDot.html +++ b/test/output/highlightDot.html @@ -1,48 +1,48 @@
+ - Adelie + Adelie - Chinstrap + Chinstrap Gentoo
@@ -87,348 +87,348 @@ culmen_length_mm → - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file From db508cef1a70b270689fce210bab9a6152db39e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 27 May 2024 09:37:44 +0200 Subject: [PATCH 39/39] update tests --- test/output/brushBand.html | 10 +++++----- test/output/brushFacets.html | 4 ++-- test/output/brushMetroInequalityChange.html | 8 ++++---- test/output/brushRectX.html | 4 ++-- test/output/brushRectY.html | 4 ++-- test/output/brushScatterplot.html | 4 ++-- test/output/highlightDot.html | 4 ++-- 7 files changed, 19 insertions(+), 19 deletions(-) diff --git a/test/output/brushBand.html b/test/output/brushBand.html index c1de285797..59ce036d7f 100644 --- a/test/output/brushBand.html +++ b/test/output/brushBand.html @@ -13,7 +13,7 @@ white-space: pre; } - +