PAIR-code / megaplot

Apache License 2.0
20 stars 5 forks source link

Add hitTest() wrappers for common DOM events #6

Open RyanMullins opened 2 years ago

RyanMullins commented 2 years ago

MegaPlot provides a basic Scene.hitTest(x, y) API that returns the full list of Sprites at that location. This requires API consumers to write essentially the same boilerplate code, shown below and also in the debugging demo:

let hitTestTask;
const {left, top} = container.getBoundingClientRect();

container.addEventListener('someMouseEvent', async (event: MouseEvent) => {
  if (hitTestTask) hitTestTask.cancel();

  const {x, y} = event
  hitTestTask = scene.hitTest((x - left), (y - top));
  const result: HitTestResult = await hitTestTask;

  // Do something with the results...
});

A common use case for interactive visualization is to get the top-most Sprite in response to a click or mousemove event and update that Sprite's state to indicate that it is selected of hovered. This could be done directly with Sprite.update() as in the debugging demo, or via a more circuitous path in a coordinated, linked visualization context, e.g., through LIT's centralized Selection and Focus services.

MegaPlot could add convenience APIs for .click(), .mousemove(), etc. that only focus on the single-sprite use case, attaching the event listener to the <canvas> element it injects into the DOM. This would allow consumers to define more complex behavior via the .hitTest() API is they care about something other than the top-most Sprite at the location.

jimbojw commented 2 years ago

Proposed API:

selection.onClick((e: MouseEvent, s: SpriteView, d: Datum, index: number, arry: Datum[]) => {
  /* ... */
});

selection.onMouseover((e: MouseEvent, s: SpriteView, d: Datum, index: number, arry: Datum[]) => {
  /* ... */
});

selection.onMouseout((e: MouseEvent, s: SpriteView, d: Datum, index: number, arry: Datum[]) => {
  /* ... */
});

Often, users won't need all the parameters. I suspect that usually it'll be just the first couple that get used (the Event, the SpriteView and/or the Datum).

API Rationale:

Concerns:

Open to discussion.

RyanMullins commented 2 years ago

Things I support...

Things I could use a bit more clarity on...

jimbojw commented 2 years ago

However, elements in a MegaPlot Selection all get mapped to the same canvas element, which doesn't inherently afford per-datum mouse event associations,

Yes, the underlying DOM events would be mousemove events on the canvas or its container element. The Megaplot mouseover/mouseout would be synthetic events.

Synthetic events are not uncommon even in the native browser implementation. For example, a doubleclick is a synthetic event which arises from two clicks. A click arises from mousedown followed by mouseup. On mobile, even mousedown/mouseup are synthetic, arising from touchstart/touchend respectively.

even if per-datum events were implemented, the elements in a Selection cannot independently update their state (nor can a SpriteView).

The current Selection implementation does not permit access to single data points. However, this is only a design choice. The underlying Sprite objects which are bound to data could be manipulated independently, and that's what I'm proposing with the .onMouseover() and .onMouseout() methods.

My concern is that having both of these events could potentially induce many (possibly expensive) redraws of the canvas. My intuition in the worst case scenario is that every .onMouseout(...) would be accompanied by an .onMouseover(...), each requiring a redraw, and those would be handled sequentially in client code, leading to 2x the redraws per mousemove (at whatever the debounced rate).

Draw calls are batched and run every animation frame while there's something to draw. Setting onMouseout()/onMouseover() callbacks would cause redraws to occur eventually, but only at most one per animation frame.

A slightly more expensive operation than drawing is the update, which flashes the new values into the data texture to be used during draw frames. This operation is also batched to reduce the frequency of CPU/GPU data shuttling.

The most expensive operation is the reading of the hit test results back from its output texture. Debouncing/throttling the native mousemove events would reduce hit testing and invocations of the onMouseover()/onMouseout() callbacks.

Could we combine these into a single method, to try to prevent excess redraws? I'm not sure that .onMousemove(...) is the right name, but maybe .onHover(...) better conveys the difference in intent between this behavior and .onClick(...)?

Yes, they could be combined into a single onHover() callback (but not to prevent redraws, as previously discussed).

In that case, the implication is that when the user stops hovering the sprite, it should revert to its pre-hover state. Or, more accurately, revert to animating toward the target state. For animation duration (TransitionTimeMs), the post-hover reversion should probably copy the duration of the onHover() to be symmetrical.

selection.onClick((e: MouseEvent, s: SpriteView, d: Datum, index: number, arry: Datum[]) => {
  /* ... */
});

selection.onHover((e: MouseEvent, s: SpriteView, d: Datum, index: number, arry: Datum[]) => {
  /* ... */
  s.TransitionTimeMs = 300;  // Used for both hover and reversion.
});

Note however that we're getting further from the DOM API by doing this. In CSS, there's a :hover pseudo-selector, but there is no hover event. To implement hover in native DOM/JavaScript, one must use a pair of mouseover/mouseout handlers and implement one's own reversion logic. I can only speculate why. My guess would be that in the DOM, keeping track of all the old states in order to revert would be too costly. This isn't a problem for CSS, because the cascading style can be recomputed. It's also not a big problem for us because we can make a copy of a relatively tight block of memory (a small range out of a big Float32Array) to capture all the properties of the sprite.

Addendum: looking at the DOM API some more, it seems like mouseenter/mouseleave may be slightly more appropriate than mouseover/mouseout semantically, since the enter/leave pair do not bubble, and neither would our synthetic events.