Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

brush, pointer #721

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft

brush, pointer #721

wants to merge 1 commit into from

Conversation

mbostock
Copy link
Member

@mbostock mbostock commented Jan 29, 2022

Related #4. Like the new brush mark, except whatever is near the pointer is considered selected. It defaults to searching in a radius around the pointer in x and y, but you can configure it to search only along a single dimension, too.

pointer-demo.mov

TODO

  • Draw little whiskers connecting the pointer to the selected points Fun, but no.
  • An n option for selecting the n closest items to the pointer
  • Have the default r vary depending on whether n is finite
  • Should the pointer mark display anything when there’s a selection? Yes.
  • Should you be able to control what the pointer mark displays, or is it just 4px-radius circles? No.
  • Validate the mode option
  • Optimize the display so that only the visible circles are instantiated
  • A mode where things are only selected while the pointer is down?
  • … or maybe that just happens automatically (ephemeral hover; persistent drag)
  • … and where shift-dragging will add to the current selection?
  • … and maybe option-dragging removes from the current selection? Maybe in the future.
  • Do something nice for band scales?
  • Support data transforms (e.g., bin and group)
  • Support x1, x2, y1, and y2 (e.g., for selecting bins or rects)
  • Support mode = “rect” for rect selection, mode = “point” for point selection

@mbostock mbostock requested a review from Fil January 29, 2022 01:32
@Fil
Copy link
Contributor

Fil commented Jan 29, 2022

From the screen capture my feeling is that this is a different system than the one implemented for brush. With brush, the interaction mark shows the selected region, and is not responsible for highlighting the selected data points. Here the interaction mark shows the individual selected dots.

We should aim for a consistent approach (brush, lasso, pointer) and just show the selection region as an overlay of the corresponding shape.

The task of highlighting the selected marks is the same in all cases, and would be handled by the dot mark instead, maybe with the suggested selected: { stroke: "red" } option?

Rather than mode, could we use pointer, pointerX and pointerY? Conversely, would we prefer to remove brushX brushY and have only one brush with a mode option? (I think I like the mode approach better, if it works consistently.)

@mbostock
Copy link
Member Author

mbostock commented Jan 29, 2022

We should aim for a consistent approach (brush, lasso, pointer) and just show the selection region as an overlay of the corresponding shape.

Consistency is generally a good thing, but it’s worth thinking this through.

I find it helpful to think of two forms of selection: the geometric selection and the logical selection.

The geometric selection describes the area—some region of the plane—that is selected. For a brush that is an axis-aligned rectangular region defined by x1, x2, y1, and y2. For a lasso it is some complex polygon or set of polygons. For a pointer it might be a circle around the mouse, or an infinitely tall or wide region for one-dimensional pointing. Or, it might be a buffered region of a given radius around the path traveled by the pointer (in the case of the more persistent “spray paint” pointing that I’d like to implement). In some cases, say if we select the n points that are closest to the pointer, there might not be a simple definition of the geometric selection!

The logical selection describes which data are selected. In Plot this is represented exclusively by a subset of the mark’s index (node[Plot.selection]). This is the value that the plot exposes to the rest of the notebook, so you can e.g. show a table or a coordinated visualization. The logical selection, in a sense, is what matters to the user. It’s what the user seeks to control through interaction. The geometric selection is the means to that end: how to indicate that selection (conveniently). So, showing the logical selection, perhaps in addition to the geometric selection, is usually a good thing because it provides more direct feedback as to the effect of the user’s input.

In the case of a brush, the geometric selection is persistent: after brushing a rectangular region, you can drag that rectangular region around. We could, possibly, do this behavior with the lasso as well, where you draw an arbitrary polygon, and then you can pick up and move that polygon around to change your selection. But I don’t think we want or need that. Instead, my expectation with a lasso is that the geometric selection (the polygon) is ephemeral: it dissolves as soon as I lift the pointer, and I’m left only with a logical selection as a set of selected points. Similarly when I’m using the pointer, if we want to support a persistent selection where I can click and drag to select some points near the mouse, and then shift-click and drag to select some other points, I’d rather just see the logical selection. I don’t think I want a visual representation of a buffered region around the path of the pointer while it was down.

The challenge with showing the logical selection is that, unlike the geometric selection, we may not know an appropriate visual representation. If interaction marks only understand that you’re selecting infinitesimal points in x and y, then we could draw circles around those points, say, but that might not be a great representation say when you’re selecting circles of varying radii, or bars, images, rects, etc. But, maybe that’s fine? Maybe it just needs to convey a hint that you’ve selected something, even if it’s not perfect. The goal of Plot is just to get you something “good enough”, quickly.

As we’ve discussed, I like the idea of an internal selection channel maintained by Plot and some mechanism for marks to express style overrides when selected. Maybe it’s even possible for Plot to have defaults so that all marks have a default selected state. But there remain a variety of technical and design challenges to get this to work, and we’ve already merged the brush mark and I would like to add pointer, lasso, and perhaps others. Perhaps when we add selection styles in the future, we change the interaction marks so that they don’t have a visual representation of the logical selection? Or at least a way to turn it off.

Rather than mode, could we use pointer, pointerX and pointerY? Conversely, would we prefer to remove brushX brushY and have only one brush with a mode option? (I think I like the mode approach better, if it works consistently.)

It’s useful to control separately (1) whether the points to be selected are defined in x or y or both and (2) whether the selection region around the pointer is defined in x or y or both. As I showed in the video, the points are defined in x and y, but I only want to select in x. I think this will be a common pattern when there is an independent and dependent variable (e.g., time and temperature, respectively). Also, the separate constructors (pointer, pointerX, and pointerY) don’t just set the mode, they also provide convenient defaults for x and y: maybeTuple if both x and y are specified, and otherwise identity if only one of x and y is specified. I think we want to maintain this pattern for consistency with other marks in Plot. The defaults are setup so that you won’t have to think about it most of the time, hopefully.

If we want the brush to show the logical selection, we’ll need to take the mode option implemented here and implement that for brush, too.

@mbostock
Copy link
Member Author

mbostock commented Jan 29, 2022

Here’s my other idea for a visual representation of the logical selection. The first video is mode xy, the second mode x.

pointer-whiskers2.mov
pointer-whiskers.mov

It looks kinda neat, but I don’t think it will work as well when we make the pointer selection persistent using click-and-drag (and letting you point to multiple areas with shift-click-and-drag, or deselect with option-click-and-drag). And also this style wouldn’t work with the brush, where the position of the pointer isn’t relevant. And, perhaps most importantly, while looking cool it does put most of the emphasis on the pointer location which is already visible with the pointer anyway. But leaving it here for the record. 🙂

@mbostock
Copy link
Member Author

mbostock commented Jan 29, 2022

Rather than mode, could we use pointer, pointerX and pointerY?

Maybe a different interpretation of your comment is that pointer means mode = xy, pointerX means mode = x, and pointerY means mode = y. So, you’d never specify the mode option (or perhaps you could specify the mode option with pointer, but we’d expect you to use pointerX or pointerY instead). However, unlike Plot’s other similar constructors such as dotX and dotY, you’d be allowed to specify a y channel for pointerX, or an x channel for pointerY. Maybe that’s confusing? I think maybe that‘s confusing but it’s not a strong preference so let me know what you prefer.

@mbostock mbostock mentioned this pull request Jan 31, 2022
@Fil
Copy link
Contributor

Fil commented Feb 1, 2022

I'm confused by your remark since dotY does accept both x and y (it's just defaulting y to identity, and x to undefined) ;-)

Anyway, yes, I'd be inclined to use pointer as 2d (mode=xy), pointerY as mode=y and pointerX as mode=x, and in that case there is no need to expose "mode" as an option.

In terms of visual feedback, I don't like the circle much since it moves in sporadic jumps, whereas the link version is a bit less jarring as it's never static (it's still very jumpy though); but this can probably be smoothed out with a (quick) transition, a less aggressive shape and color, I don't know. link + circle would work too, the link easing the visual movement, but stopping before it actually occludes the target.

I think i'd use it most often to select the (n = 1) closest element to the pointer rather than a whole range, but it's more gut feeling than any experience with this type of selection mechanism. My hunch is that with a large n the flock of lines will tend to occlude a majority of the selected dots, and the ones that are not occluded will be those farthest from the pointer, which seems a bit opposite to the goal.

@mbostock
Copy link
Member Author

mbostock commented Feb 1, 2022

I'm confused by your remark since dotY does accept both x and y

Oh, whoops. I got confused because it wasn’t always that way; prior to 5b81573 dotX forced y to be null, and dotY forced x to be null.

So, does this mean brushX should allow y, and brushY should allow x? 🤔

plot/src/marks/brush.js

Lines 81 to 87 in 551f53a

export function brushX(data, {x = identity, ...options} = {}) {
return new Brush(data, {...options, x, y: null});
}
export function brushY(data, {y = identity, ...options} = {}) {
return new Brush(data, {...options, x: null, y});
}

@Fil
Copy link
Contributor

Fil commented Feb 1, 2022

I think the difference between brushX(… {y}) and brush(… {y}) would be if the brush highlights the logical selection; in the first case brushX(… {y}) the brush is horizontal, but the highlighted values are dots positioned in x and y. In the second case it is a 2-d brush.

@mbostock
Copy link
Member Author

mbostock commented Feb 1, 2022

Okay, I think I got it in the latest commit. Now brushX and brushY allow you to specify y and x, respectively. And pointerX and pointerY similarly only provide defaults for mode and x and y, respectively, while allowing you to specify y and x respectively.

@mbostock mbostock marked this pull request as ready for review February 2, 2022 00:40
@mbostock
Copy link
Member Author

mbostock commented Feb 2, 2022

I’ve implemented persistent selection (click and drag, and shift-click and drag). Let me know what you think, @Fil!

@mbostock
Copy link
Member Author

mbostock commented Feb 2, 2022

Some open tasks:

  • Allow fill on pointer. (It’s currently overridden by fill="none".)
  • For one-dimensional pointers, draw blue rules rather than dots?
  • Change the default n to 1 (instead of Infinity).
  • Allow the radius of pointer circles to be specified as r (a constant? a channel?).
  • Rename the current r option to distance, or something else, maybe maxR?
  • Allow the fill and stroke be specified as channels, not just constants?
  • Making clearing the persistent pointer selection less twitchy. (Any pointermove will interfere.)
  • Allow dx and dy on pointer.

The last task also applies to the brush—it’ll require some core changes, not just changes to pointer. The problem is that mark.data isn’t the same as the data returned by the transform. We have to decide whether we’re selecting from the original (untransformed) data or, perhaps more likely, selecting from the transformed data (e.g., selecting bins).

@Fil
Copy link
Contributor

Fil commented Feb 3, 2022

This branch currently breaks brushX and brushY on a 2-d scatterplot.

can be fixed by 020a1ea

@mbostock
Copy link
Member Author

mbostock commented Feb 3, 2022

This branch currently breaks brushX and brushY on a 2-d scatterplot.

Based on our earlier discussion, it is behaving as expected. (Or, it’s not clear what you mean by “breaks”—I see that brushX in your example is a functional 2D brush.) In your example you are specifying x and y, so brushX behaves the same as brush. The only difference between brush, brushX, and brushY is the defaults for x and y.

If you want one-dimensional brushing:

Plot.brushX(data, {x: "culmen_depth_mm"})

Or:

Plot.brush(data, {x: "culmen_depth_mm"})

If you want two-dimensional brushing:

Plot.brush(data, {x: "culmen_depth_mm", y: "culmen_length_mm"})

@Fil
Copy link
Contributor

Fil commented Feb 3, 2022

The case I found "broken" is when you want the data to be a 2-d scatterplot, and the brush to be on X only. This (in my mind) is what you want when you say brushX(data, {x, y}). It's also what's currently implemented in main.

Since we're not highlighting the logical selection, it's true that we can use brush(data, {x}) instead. Maybe I just need to adapt my mental model… but for now I don't see what is the purpose of brushX and brushY anymore, if the mode is driven by the x and y options. 🤔

@mbostock
Copy link
Member Author

mbostock commented Feb 3, 2022

but for now I don't see what is the purpose of brushX and brushY anymore, if the mode is driven by the x and y options

It’s the same as dotX and dotY. The only purpose is to specify different defaults.

plot/src/marks/dot.js

Lines 95 to 101 in 94d69a5

export function dotX(data, {x = identity, ...options} = {}) {
return new Dot(data, {...options, x});
}
export function dotY(data, {y = identity, ...options} = {}) {
return new Dot(data, {...options, y});
}

The brush doesn’t support a mode option currently. We could add one for symmetry with the other marks, but it’s a little redundant because brush doesn’t need the x and y channels in the other modes. Meaning if we did, you’d be passing in (and computing) the y channel with mode = x, but you wouldn’t be using it for anything. Though of course we could set the unused channels to null in the constructor I guess…

@Fil
Copy link
Contributor

Fil commented Feb 3, 2022

I've reverted my commit and updated my mental model!

@mbostock
Copy link
Member Author

mbostock commented Feb 3, 2022

I’ve made a few small improvements (to handle band scales, to improve the display of one-dimensional pointing using rules instead of circles, and to change the default n to 1).

I’m currently thinking that when you use brush or pointer with a transform, that you’re still selecting the source (input) data, i.e., the data prior to aggregation; you won’t be selecting e.g. bins.

@mbostock mbostock mentioned this pull request Feb 9, 2022
@mbostock mbostock changed the title pointer brush, pointer Feb 12, 2022
@mbostock
Copy link
Member Author

This branch now reverts #748, restoring the brush #71 and fixes #5.

}
if (!selectionEquals(this[selection], S)) {
this[selection] = S;
this.dispatchEvent(new Event("input", {bubbles: true}));
Copy link

@enjoylife enjoylife Jul 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure how much of a "public" api your wanting to expose in these events... but access to the selected data indexes or the range of the data is needed. e.g.

this.dispatchEvent(new CustomEvent("input", {bubbles: true, detail: {selected: S, range}}));

where range is something like if(X && x.invert) range = extent.map(x.invert);

Then in the listener, you can use the indexes for filtering right away or show the textual range to to the user.

Without access to the indexes used, callers only can view the plot.value. This only has what was selected. It does not provide what criteria was used for the selection, nor the data left out.

@kevinschaich
Copy link

kevinschaich commented Feb 1, 2023

Hey guys – any update on this? I saw this demo on observable (not sure if it ever got released) but would be keen to see this feature in a recent version on NPM 🚀

@mbostock mbostock marked this pull request as draft May 15, 2023 16:29
@Fil Fil mentioned this pull request May 29, 2023
24 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants