GuitarExtended
GuitarExtended

Reputation: 827

How to throttle function call on mouse event with D3.js

I'm calling a function on the "mousemove" event of a DOM element, using D3.js 's .on(), like so :

d3.select("#myelement").on("mousemove", myfunc);
function myfunc(){
    // Just to show that I need to get the mouse coordinates here
    console.log(d3.mouse(this));
}

I need the function I'm calling to be aware of the event, i.e. the mouse coordinates.

Since the rest of my code is quite computationally expensive, I'd like to throttle the calls to myfunc, say every 200 ms.

How can I do that while preserving the value of this in myfunc (so that d3.mouse(this) still works) ? I've tried this debounce function : https://davidwalsh.name/javascript-debounce-function And also this : https://remysharp.com/2010/07/21/throttling-function-calls But I'm unable to get those to work the way I want.

Upvotes: 5

Views: 4779

Answers (4)

Jack G
Jack G

Reputation: 5322

You do not need a big segment of code or an oversized library like D3js for a decent throttle function. The purpose of a throttle function is to reduce browser resources, not to apply so much overhead that you are using even more. Also, my different uses for throttle functions require many different circumstances for them. Here is my list of things that a 'good' throttle function needs that this one has.

  • Minimal overhead.
  • Immediate function call if it has been more than interval MS since the last call.
  • Avoiding executing function for another interval MS.
  • Delaying excessive event firing instead of dropping the event altogether.
  • Updates the delayed event when need be so that it doesn't become 'stale'.
  • Prevents the default action of the event when the throttled function is delayed.
  • Be able to remove the throttle event listener listener.

And, I believe that the following throttle function satisfies all of those.

var cachedThrottleFuncs = [],
    minimumInterval = 200; // minimum interval between throttled function calls
function throttle(func, obj, evt) {
    var timeouttype = 0,
        curFunc;
    function lowerTimeoutType(f){
        timeouttype=0;
        if (curFunc !== undefined){
            curFunc();
            curFunc = undefined;
        }
    };
    return cachedThrottleFuncs[ ~(
        ~cachedThrottleFuncs.indexOf(func) || 
        ~(
          cachedThrottleFuncs.push(function(Evt) {
            switch (timeouttype){
                case 0: // Execute immediatly
                    ++timeouttype;
                    func.call(Evt.target, Evt);
                    setTimeout(lowerTimeoutType, minimumInterval);
                    break;
                case 1: // Delayed execute
                    curFunc = func.bind(Evt.target, Evt);
                    Evt.preventDefault();
            }
          }) - 1
        )
    )];
};
function listen(obj, evt, func){
    obj.addEventListener(evt, throttle(func, obj, evt));
};
function mute(obj, evt, func){
    obj.removeEventListener(evt, throttle(func, obj, evt));
}

Example usage:

listen(document.body, 'scroll', function whenbodyscrolls(){
    if (document.body.scrollTop > 400)
        mute(document.body, 'scroll', whenbodyscrolls();
    else
        console.log('Body scrolled!')
});

Alternatively, if you only need to add event listeners, and you do not need to remove event listeners, then you can use the following even simpler version.

var minimumInterval = 200; // minimum interval between throttled function calls
function throttle(func, obj, evt) {
    var timeouttype = 0,
        curEvt = null;
    function lowerTimeoutType(f){
        timeouttype=0;
        if (curEvt !== null){
            func(curEvt);
            curEvt = null;
        }
    };
    return function(Evt) {
        switch (timeouttype){
            case 0: // Execute immediately
                ++timeouttype; // increase the timeouttype
                func(Evt);
                // Now, make it so that the timeouttype resets later
                setTimeout(lowerTimeoutType, minimumInterval);
                break;
            case 1: // Delayed execute
                // make it so that when timeouttype expires, your function
                // is called with the freshest event
                curEvt = Evt;
                Evt.preventDefault();
        }
    };
};

By default, this throttles the function to at most one call every 200ms. To change the interval to a different number of milliseconds, then simply change the value of minimumInterval.

Upvotes: 0

Gerardo Furtado
Gerardo Furtado

Reputation: 102174

The problem is not passing this to the debounce function, which is quite easy, as you can see in this JSFiddle (I'm linking a JSFiddle because the Stack snippet freezes when logging this or a D3 selection).

The real problem is passing the D3 event: since d3.event is null after the event has finished, you have to keep a reference to it. Otherwise, you'll have a Cannot read property 'sourceEvent' of null error when trying to use d3.mouse().

So, using the function of your second link, we can modify it to keep a reference to the D3 event:

function debounce(fn, delay) {
    var timer = null;
    return function() {
        var context = this,
            args = arguments,
            evt = d3.event;
            //we get the D3 event here
        clearTimeout(timer);
        timer = setTimeout(function() {
            d3.event = evt;
            //and use the reference here
            fn.apply(context, args);
        }, delay);
    };
}

Here is the demo, hover over the big circle, slowly moving your mouse:

var circle = d3.select("circle");

circle.on("mousemove", debounce(function() {
  console.log(d3.mouse(this));
}, 250));

function debounce(fn, delay) {
  var timer = null;
  return function() {
    var context = this,
      args = arguments,
      evt = d3.event;
    clearTimeout(timer);
    timer = setTimeout(function() {
    	d3.event = evt;
      fn.apply(context, args);
    }, delay);
  };
}
.as-console-wrapper { max-height: 30% !important;}
<script src="https://d3js.org/d3.v4.js"></script>
<svg>
  <circle cx="120" cy="80" r="50" fill="teal"></circle>
</svg>

PS: In both the JSFiddle and in the Stack snippet the function is called only when you stop moving the mouse, which is not the desired behaviour for a mousemove. I'll keep working on it.

Upvotes: 5

GuitarExtended
GuitarExtended

Reputation: 827

Thanks to Gerardo Furtado's answer, I managed to solve my problem by adapting the throttle function from this page like so :

function throttle(fn, threshhold, scope) {
  threshhold || (threshhold = 250);
  var last,
      deferTimer;
  return function () {
    var context = scope || this;

    var now = +new Date,
        args = arguments,
        event = d3.event;
    if (last && now < last + threshhold) {
      // hold on to it
      clearTimeout(deferTimer);
      deferTimer = setTimeout(function () {
        last = now;
          d3.event = event;
        fn.apply(context, args);
      }, threshhold);
    } else {
      last = now;
        d3.event = event;
      fn.apply(context, args);
    }
  };
}
});

Now the callback is aware of the d3.event and d3.mouse(this) can be used normally inside the function.

Upvotes: 2

paradite
paradite

Reputation: 6436

Edit: I am not sure if it would preserve the this keyword in your case though, you can give it try.

Instead of coding your own throttle or debounce, you could simply use a helper library like lodash and pass your function to the API to get a throttled version of your function:

Example with jQuery, but should also work with d3:

// Avoid excessively updating the position while scrolling.
jQuery(window).on('scroll', _.throttle(updatePosition, 100));

https://lodash.com/docs/#throttle

Upvotes: 0

Related Questions