Generalizing Interactivity

Mixing together key ingredients

In most of our demonstrations this semester, there has been some kind of interactivity, meaning we interact with something, and when that happens, some other thing is triggered. In the case of our random circle drawing demonstration, the thing we interacted with was a button (through a click), and when we did that, a new circle element was drawn somewhere on the screen:

In this demonstration, just like your mini-games for the lab challenges, interactivity is mediated through some sort of input element, like a button or a checkbox, which then triggers something to happen when those elements are clicked. In other cases, we’ve made our application interactive not with input elements but with the assistance of CSS. For example, in the random circle drawing with data joins example, we applied a CSS rule to our circles to trigger a CSS transition when the mouse hovers over them:

In either pattern — with input elements that are controlled with D3 and JavaScript, or with CSS rules that make something happen to certain shapes when we hover over them — we are still doing the same fundamental thing: we are making something in our application interactive. And regardless of what pattern we use, we can generalize that process of making something interactive as needing three key ingredients:

  1. One or more element(s) to interact with
  2. A means of interaction (Is it by clicking the mouse? Is it by pressing a key?)
  3. A thing to do when that element is interacted with through the stated means of interaction

In the world of programming for the web, we will always need to articulate what these three ingredients are through the code that we write. Once we master this pattern, we can create any kind of interactivity that we want.

So far, we’ve only cared about one kind of interactivity: we click something (e.g., a button), and then something happens. The thing that happens is triggered by a click on an element we select, and that thing gets triggered repeatedly every time the click occurs. But there are more ways to interact with elements, beyond just the click of the mouse! In fact, there is an entire list of different kinds of interactive events. These are called DOM events, and they are standardized names, like “click,” “mouseover”, or “mouseout”, that the browser recognizes and listens for inside the browser window. Each of these event types is triggered by something specific, and when we attach that event to specific elements, we can cause different kinds of things to happen inside the browser as a response.

In this discussion, we will explore three more kinds of interactive events beyond the click: mouseover, which occurs when the mouse hovers over a selected element; mouseout, which occurs when the mouse stops hovering over a selected element; and mousemove, which occurs whenever the mouse cursor moves within the bounds of a selected element.

Attaching events to elements

We will begin this exploration with our classic starting point: some random circles, randomly drawn across the SVG canvas. The code to generate this is derived from our original random circle drawing demonstration using data joins, as shown below.

const numCircles = 50;
const maxRadius = 30;
let data = [];
for(var i = 1; i <= numCircles; i++) {
    let x = Math.random()*width;
    let y = Math.random()*height;
    let r = Math.random()*maxRadius;
    let o = {xPosition: x, yPosition: y, radius: r};
    data.push(o);
}

const colorScale = d3.scaleSequential(d3.interpolatePlasma)
    .domain([0,1]);

let circle = svg.selectAll("circle")
    .data(data)
    .enter()
    .append("circle")
    .attr("cx", function(d) { return d.xPosition; })
    .attr("cy", function(d) { return d.yPosition; })
    .attr("r", function(d) { return d.radius; })
    .attr("fill", function() { return colorScale(Math.random()); })
    .attr("opacity", 0.8)

Using this as our starting point, our goal is to make these circles responsive to clicks and mouse hovering.

With D3, the general pattern for interactivity is the following:

SELECTION.on(EVENT, function() {
DO SOMETHING WHEN THE EVENT IS DETECTED
});

The SELECTION is, unsurprisingly, a selection of elements; they could be SVG elements, or HTML elements (like p or div). We get a selection using the d3.select() or d3.selectAll() pattern, and in some cases, the selections returned by these patterns are stored in other variables. (For example, in our data join pattern above, the variable named circle is actually a selection of <circle> elements, so we can refer to this selection by shorthand using the variable name alone.)

The EVENT is a name of an event, as a String value. The names of these events are standardized, and the browser recognizes these standardized names as corresponding to specific kinds of things that can happen to an element over the course of its lifespan. For example, the event name “click” is triggered whenever the mouse clicks on a selected element; the “mouseover” event is triggered when the mouse cursor hovers over a selected element, meaning it goes from being outside the bounding region of an element to being inside that region. The list of standardized and recognized event names is long, as shown here: HTML DOM Event Object.

The function expression function() { … } is called an event handler; it “handles” what happens every time the stated event is detected to be happening on the selected element(s). In other words, the event handler is a function that is triggered every time the event happens specifically on the element that has been selected.

Let’s attach an interactive event to our circles using an event name that we’re already quite familiar with: the “click” event. Specifically, we will use our pattern to make a circle disappear when we click on it. Combining the general D3 interaction pattern with the “click” event and the pattern we’ve seen before for removing elements (SELECTION.remove()), we get the following result (do you remember what the d3.select(this) pattern is doing?):

circle.on("click", function() {
    d3.select(this).remove();
});

With this added event, our demonstration now works like the following. What happens when you click a circle?

One of the conveniences of this pattern is that we can add any number of different interactive events to the same selection of elements, and each will be triggered independently of the others. For example, let’s consider another event named “mouseover”. This event is triggered any time the mouse cursor hovers over an element. If the mouse crosses any part of the bounding region of an element, moving from outside to inside (e.g., being within the colored fill region of a circle), then this event gets triggered. The effect of this is almost identical to what happens with the :hover CSS pseudo class selector, such as when we’ve modified SVG shapes’ attributes with the help of CSS on mouse hover.

We can add this event by using the exact same pattern above, but replacing “click” with “mouseover”. In this example, we’ll make it so that the outline stroke of the circle changes color when we hover over it, and have this event occur independently of our existing click event.

circle.on("click", function() {
    d3.select(this).remove();
});

circle.on("mouseover", function() {
    d3.select(this).attr("stroke", "#F6C900")
        .attr("stroke-width", 3);
});

The code above yields the following output — note that you can both click and hover over any circle to see the resulting effect; each triggers a different behavior.

You may have noticed a problem, however: when we are no longer hovering over a circle, the outline stroke doesn’t go away! Remember that when we used the CSS :hover selector (e.g., circle:hover { ... }), a given element’s appearance went back to its original state once we were no longer hovering over it. This happened automatically, without us needing to do anything else special. This is not the case when we try to do the same thing in D3 and JavaScript! In this example, if we change a circle’s stroke and stroke-width attributes, those attributes are there to stay, unless we specifically tell the browser to undo those changes when we are no longer hovering over the shape.

How do we undo these changes in attributes? The answer is in applying yet another new interactive event: "mouseout". This event gets triggered when the mouse is no longer hovering over an element, or more specifically when the cursor moves from being inside the bounding region of an element to being outside that bounding region. It’s extremely common (and useful) to pair the "mouseout" event with the “mouseover” event to switch back and forth between different visual states of an element. For example, here we can use the mouseout event to undo the changes in the stroke and stroke-width attributes we made with the mouseover event:

circle.on("click", function() {
    d3.select(this).remove();
});

circle.on("mouseover", function() {
    d3.select(this).attr("stroke", "#F6C900")
        .attr("stroke-width", 3);
});

circle.on("mouseout", function() {
    d3.select(this).attr("stroke", "none")
        .attr("stroke-width", 0);
});

This yields the following result — note that you can now click, hover over, and hover out of any circle, to different effect:

We can add any number of distinct events to any selection of elements using the above pattern. We can also chain these events together to work on a single selection, like this:

circle.on("click", function() {
    d3.select(this).remove();
}).on("mouseover", function() {
    d3.select(this).attr("stroke", "#F6C900")
        .attr("stroke-width", 3);
}).on("mouseout", function() {
    d3.select(this).attr("stroke", "none")
        .attr("stroke-width", 0);
});

Notice in the above that the .on() methods are being chained together, and they all bubble up to refer to the same selection of elements that is stored in the variable named circle.

Detecting mouse movement

In the click, mouseover, and mouseout events, a single action is being detected in each kind of interaction. As a result, the functions attached to these events get triggered one at a time, and if we want to repeatedly perform the action embedded within that function, we need to actively repeat the given event (e.g., click a button multiple times in succession). Other events occur on a more continuous time scale, rather than in discrete pockets of time. One such event is the “mousemove” event, which is triggered every single instant the mouse cursor’s position changes when the mouse is actively hovering over a selected element.

In the previous demonstration, we had an existing set of circle elements, to which we bound different interactive events. In this demonstration, we will do the inverse: we will start with a blank SVG canvas, and through listening for a specific interactive event, we will draw a new circle on that canvas wherever the mouse is currently located.

Just like our “click” events, we can listen for the “mousemove” event using the SELECTION.on() pattern. As in past demonstrations, let’s assume that we have stored a reference to an SVG canvas in another variable that we’ve conveniently named “svg”. If we wanted to listen for a mousemove event on this SVG canvas, we would write our code as follows:

svg.on("mousemove", function() {
    console.log("The mouse has moved!");
});

The following SVG canvas is empty (there are no shapes!), but this mousemove event has been attached to it. Open the console and move your mouse across the canvas; what do you see printed in the console?

You’ll notice that the message “The mouse has moved!” is being printed to the console over and over again — in fact, every single time the mouse position changes. This change in position could be very large, or extremely small; regardless of size of change, this event gets detected. Why would this be useful? A common use case for the mousemove event is to follow the movement of the mouse cursor across an element. For example, maybe we’ll want to constantly monitor the x- and y-coordinate position of the mouse cursor so that we can do something special depending on which region of the element the mouse is located. In our example, we’re going to do something different: we’re going to follow the mouse, and wherever it goes, we’re going to draw a new circle element, resulting in a trail of circles that follows the movement of the mouse.

Inside the mousemove event handler, we’re going to need to write the code we need to draw a new circle element. Every single time the mouse moves within the SVG canvas, a new circle element will be drawn:

svg.on("mousemove", function() {
    svg.append("circle");
});

But we won’t want to just draw any circle in any random location — we’re going to want to draw a circle at the mouse cursor’s current location, and then uses these coordinates as the cx and cy attributes of each circle we draw. How will we get the mouse cursor’s current location?

JavaScript has a means of getting this information, directly baked into the JavaScript language and the browser’s architecture. Inconveniently, working with this information is a bit messy and complicated. More conveniently, as of version 6, D3 has a built-in method called d3.pointer() that is able to retrieve the mouse’s current pixel coordinates on the screen in a more simplified structure, whenever the method is invoked. If we combine this pattern with the SELECTION.on() pattern, we can get the mouse’s pixel coordinate position every single time the mouse moves, so that we can do other things with those coordinates:

svg.on("mousemove", function(event) {
    let position = d3.pointer(event);
});

There are two very important things to note here. First, the d3.pointer() method accepts as an input argument a DOM event, which is a special object that stores lots of information about what’s going on inside the browser window. For our purposes, the most useful information stored in this event is the x- and y-pixel position of whatever just happened. If “the thing that just happened” is the mouse moved to a new position, then the x- and y-values returned by the event will be about the mouse’s position on the screen. How useful! The second thing to note is that when we use the SELECTION.on() pattern, the first parameter that gets passed into the event handler function (the function expression) is itself the event information for the named event in the first argument. When the named event is “mousemove”, then the event in function(event) { … } is specifically the mouse-moving event. (If the named event is “click” then event is specifically the mouse-clicking event, etc. Also note that the word event here is simply the name we are ascribing to this first parameter in the function expression; it doesn't have to be called event and can be named something different, if we want.). In order to use d3.pointer(), we must supply an event from which to extract the information we want; in order to access that event that we need to supply, we must create a connection to it through the first parameter of the function expression in the event handler.

When we pass this event into d3.pointer(), we get back an array of two values. The first value in this array is the x-coordinate of the event that just happened; the second value is the y-coordinate of the event that just happened. When the event we're passing into the method is the mousemove event, this means we can retrieve the x- and y-position of the mouse's current location, whenever it moves somewhere new, in separate variables using array indexing notation:

svg.on("mousemove", function(event) {
    let position = d3.pointer(event);
    console.log("Returned data:", position);
    let x = position[0];
    let y = position[1];
    console.log("X position is:", x, "Y position is:", y);
});

Now try moving your mouse across the following SVG canvas (as before, it’s empty). Open the console, and examine the numbers being printed. The first number in each line in the console is your mouse cursor’s current x position within the SVG canvas; the second number is your mouse cursor’s current y position within the canvas.

This information is accessed every single time the mousemove event gets triggered. We can leverage this information in drawing our circles, because we can pass these values of x- and y-coordinate position as the cx and cy attributes of new circles we draw. If we expand our code to the following:

svg.on("mousemove", function(event) {
    let position = d3.pointer(event);
    let x = position[0];
    let y = position[1];

    svg.append("circle")
        .attr("cx", x)
        .attr("cy", y)
        .attr("r", 10)
        .attr("fill", "steelblue")
        .attr("opacity", 0.8);
});

Then we get a trail of circles that follows the mouse everywhere we go. Try moving your mouse across the canvas below — what do you see?

This mousemove event can be used to do any sort of generative drawing, with any shapes. The sky's the limit! We can make the circles different sizes, color them along a gradient, and even make them disappear shortly after they are drawn, such as in this example (move your mouse across the canvas):

How is the above demonstration working?! We'll find out in the next unit, when we learn how to animate shapes with the help of D3.