Making a callback in a mousemove handler asynchronous - javascript

I have the following event handler. Due to their frequency with which it occurs, it will be pretty "greedy". onMouseMove, it will draw a "lasso" around the selected area, then perform a throttled geospatial search around the area.
onMouseMove = ({ pageX, pageY }: MouseEvent) => {
const lasso = this.getLasso(pageX, pageY);
this.ctx.setLineDash([6]);
this.ctx.strokeStyle = '#ffffff';
this.ctx.strokeRect(lasso.left, lasso.top, lasso.width, lasso.height);
// this is the problem area.
this.props.onSelection(this.search(lasso));
}
However, I find the callstack is largely just about running this callback. The action to update the "lasso" is only 2ms, but the onSelection callback takes ~40ms. They're both grouped under Event (mousemove) in performance. I think this is making the animation of the lasso look very choppy.
Would there be a way to run this callback independently/asynchronously from the mouse event handler? It doesn't so much matter to the mousemove event when it completes.

Use setTimeout with a delay of 0. It will be added to the JavaScript scheduler queue, and run after the current callstack finishes.
In situ, this becomes:
onMouseMove = ({ pageX, pageY }: MouseEvent) => {
const lasso = this.getLasso(pageX, pageY);
this.ctx.setLineDash([6]);
this.ctx.strokeStyle = '#ffffff';
this.ctx.strokeRect(lasso.left, lasso.top, lasso.width, lasso.height);
// this is the problem area.
setTimeout(function () { this.props.onSelection(this.search(lasso));});
}
Since 0 is the default, you may omit it, as shown in the linked docs:
If this parameter is omitted, a value of 0 is used, meaning execute "immediately", or more accurately, as soon as possible. Note that in either case, the actual delay may be longer than intended; see Reasons for delays longer than specified below.

Related

Why does composedPath on event returns different value when delayed?

Because simply path doesn't exist on MouseEvent or WheelEvent type, I'm using an alternative: composedPath method. I couldn't experience a difference between them until now. I wrapped my wheel listener with requestAnimationFrame, and surprisingly with it composedPath returns an empty array. Just an example:
window.addEventListener('click', e => {
console.log('at event:', Array.from(e.composedPath()));
setTimeout(() => {
console.log('after event:', Array.from(e.composedPath()));
}, 0)
})
#test {
width: 120vw;
height: 120vh;
}
<div id='test'></div>
(it's advisable to check the browser's console, because window is a bit large to scroll through it)
If anyone has an idea how could I solve this problem, that would be great to know it, but the question is mainly to understand what makes the difference.
The specification defines this behavior. At the end of the steps for dispatching an event, we have:
(looping through ancestor elements adding them to the event's path)
...
Set event’s path to the empty list.
That is, in Step 5 the browser fills in the path information, and in Step 8 (after all event processing is done), the specification explicitly says the browser must clear the path information.
The specification characteristically doesn't say why, but Kaiido points out that the commit adding Step 8 is primarily concerned with the handling of event paths in relation to the Shadow DOM. In any case, the path you get back is determined in part by the currentTarget of the event you call composedPath on (in particular in relation to the shadow DOM), and the currentTarget only has meaning when an event is actively being dispatched (once the event has been fully dispatched, currentTarget is set to null). Even if the path weren't cleared by Step 8, if I'm reading the steps of composedPath correctly, it would just return the path [null] when called when currentTarget is null, which wouldn't be of any use. (It also makes sense for non-Shadow-DOM reasons: It's common for the event object to be closed over by functions that will never call composedPath, so it makes sense not to keep that information in memory unnecessarily. Any elements in the path that are later removed from the DOM would be cluttering up memory for no good reason. But the evidence suggests that's not the primary motivation for adding Step 8.)
If you need the path of the event, simply grab it during processing rather than after.
window.addEventListener("click", (e) => {
const path = Array.from(e.composedPath(), (e) => e === window ? "window" : e.nodeName);
setTimeout(() => {
console.log(path);
}, 0);
});
window.addEventListener("click", (e) => {
const path = Array.from(e.composedPath(), (e) => e === window ? "window" : e.nodeName);
setTimeout(() => {
console.log(path);
}, 0);
});
#test {
width: 120vw;
height: 120vh;
}
<div id='test'></div>
You can solve the problem by computing the composedPath once when the event fires, and store that in a variable for reuse inside the timeout.
window.addEventListener('click', e => {
const a = e.composedPath();
console.log('at event:', Array.from(a).length);
setTimeout(() => {
console.log('after event:', Array.from(a).length);
}, 0)
})
#test {
width: 120vw;
height: 120vh;
}
<div id='test'></div>
I'm a bit unsure as to why this is happening. It might be that they reuse the same Event over and over under the hood as an optimization in to save on memory usage. To reduce the number of times they need to allocate and deallocate memory. Which might end up being a big factor for performance on low end devices.

Order of timestamps in requestAnimationFrame and mouse events

I ran into a problem where timestamps received in requestAnimationFrame callbacks and mouse events do not seem to be in order, I mean I expect them to be increasing (as I hope that time goes only in one direction :)), but that doesn't seem to be the case. It can be illustrated by this example code:
<html><body>
<script type="text/javascript">
let lastTimesamp = -1;
function log(name, timestamp) {
console.log(name, timestamp);
console.assert(lastTimesamp < timestamp, "Invalid time", lastTimesamp, timestamp);
lastTimesamp = timestamp;
}
function update(timestamp) {
log("update", timestamp);
requestAnimationFrame(update);
}
requestAnimationFrame(update);
function mouseDown(event) {
log("mouseDown", event.timeStamp);
}
document.body.addEventListener("mousedown", mouseDown, false);
</script>
</body></html>
If you start clicking with your mouse you can see this sort of output eventually:
which implies that mouse-down event happened before the last update call.
I get the opposite situation on my production app: call to update is made with a timestamp which is before the last call to mouse-down callback.
Can someone explain it to me? From the documentation it looks like they are not necessary measured in the same way, but wouldn't it make sense to time them in the same time?
What happens here is that the AnimationFrameCallbacks queue has an higher priority than UI events.
So it may occur that your UI event fires in the same frame than the painting frame, it will thus get its timeStamp set at this moment, or even by the OS when it received it in the first place. But, the UA will chose to prioritize the AnimationFrameCallbacks instead of the UI event callbacks, so the UI event callback will get delayed until the next event-loop iteration.
Since the rAF callback gets its own timestamp from inside the event-loop iteration that will call it, this timestamp will be higher than the one of UI event, even though its callback fires before.
Also note that Chrome has it's requestAnimationFrame method completely broken, so it may not help for debugging.

wheel event PreventDefault does not cancel wheel event

I would like to get one event only per scroll event
I try this code but it produces "wheel" as many times the wheel event is triggered.
Any help? Thank you
window.addEventListener("wheel",
(e)=> {
console.log("wheel");
e.preventDefault();
},
{passive:false}
);
Use case (edit)
I want to allow scrolling from page to page only - with an animation while scrolling. As soon I detect the onwheel event, I would like to stop it before the animation finishes, otherwise the previous onwheel continues to fire and it is seen as new event, so going to the next of the targeted page
My conclusion :
It is not possible to cancel wheel events. In order to identify a new user wheel action while wheeling events (from a former user action) are on going, we need to calculate the speed/acceleration of such events
This is fairly simple problem, store anywhere the last direction and coditionally execute your code:
direction = '';
window.addEventListener('wheel', (e) => {
if (e.deltaY < 0) {
//scroll wheel up
if(direction !== 'up'){
console.log("up");
direction = 'up';
}
}
if (e.deltaY > 0) {
//scroll wheel down
if(direction !== 'down'){
console.log("down");
direction = 'down';
}
}
});
Anyway, the UX context should be defined.
May be that throttling or debouncing your function will give better results in some scenarios.
Throttling
Throttling enforces a maximum number of times a function can be called
over time. As in "execute this function at most once every 100
milliseconds."
Debouncing
Debouncing enforces that a function not be called again until a
certain amount of time has passed without it being called. As in
"execute this function only if 100 milliseconds have passed without it
being called.
In your case, maybe debouncing is the best option.
Temporary lock the browser scroll
$('#test').on('mousewheel DOMMouseScroll wheel', function(e) {
e.preventDefault();
e.stopPropagation();
return false;
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<div id="test">
<h1>1</h1>
<h1>2</h1>
<h1>3</h1>
<h1>4</h1>
<h1>5</h1>
<h1>6</h1>
<h1>7</h1>
<h1>8</h1>
<h1>9</h1>
<h1>10</h1>
</div>
Event.preventDefault() tells the browser not to do the default predefined action for that event, such as navigating to a page or submitting the enclosing form, etc. It does not necessarily prevent events from firing.
Also, there is a difference between the wheel event and the scroll event. The wheel event is fired when the user rotates a wheel button, and the scroll event is fired when the target's scrollTop or scrollLeft property is changed due to the scroll position being changed.
When the user rotates the wheel button, the wheel event is fired before any scroll events that could be fired. However, the wheel event might not result in any scroll event simply because the pointer is not hovering on any element or the element is not scrollable at the moment.
To aggregate quickly repeated function calls to the event handler, you can debounce the event handler function. The idea is to wait a certain amount before committing to the action. When a function is debounced, it becomes a new function, when called, sets off a timer that calls the wrapped function inside. The timer is reset and restarted when debounced function is called again. Look at the example diagram below.
© Ilya Kantor(https://github.com/javascript-tutorial/en.javascript.info, licensed under CC-BY-NC)
The function f is a debounced function with a 1000ms timeout duration and is called at time instants 0, 200ms, and 500ms with arguments a, b, and c, respectively. Because f is debounced, calls f(a) and f(b) were "not committed/ignored" because there was another call to f within a 1000ms duration. Still, call f(c) was "committed/accepted" at the time instant 1500ms because no further call followed within 1000ms.
To implement this, you can use the setTimeout and clearTimeout functions. The setTimeout function accepts an action(code or function to execute) and delay in milliseconds, then returns a timer ID in integer. The given action will be executed when the timer expires without being canceled.
const timerId = setTimeout(action, delay)
The clearTimeout function could then be used to destroy the timer with a given ID.
clearTimeout(timerId)
Following simple debounce implementation could be used:
// Default timeout is set to 1000ms
function debounce(func, timeout = 1000) {
// A slot to save timer id for current debounced function
let timer
// Return a function that conditionally calls the original function
return (...args) => {
// Immediately cancel the timer when called
clearTimeout(timer)
// Start another timer that will call the original function
// unless canceled by following call
timer = setTimeout(() => {
// Pass all arguments and `this` value
func.apply(this, args)
}, timeout)
}
}
Read more: Default parameters, Rest parameters, Function.apply(), this keyword
To use is quite simple:
eventTarget.addEventListener('wheel', debounce((e) => {
console.log('wheel', e)
}))
This will limit console.log calls to whenever a wheel event has not been fired in a second.
Live example:
function debounce(f, d = 99, t) {
return (...a) => {
clearTimeout(t)
t = setTimeout(() => {
f.apply(this, a)
}, d)
}
}
document.addEventListener('wheel', debounce((_) => {
console.log('wheel')
}))
A more modern approach uses Promise on top of this idea.
You almost had it But you need to wrap your code in a function.
I added some extra little bits so you can differentiate up and down :)
//scroll wheel manipulation
window.addEventListener('wheel', function (e) {
//TODO add delay
if (e.deltaY < 0) {
//scroll wheel up
console.log("up");
}
if (e.deltaY > 0) {
//scroll wheel down
console.log("down");
}
});
How it works?
(e) = This is just the event, the function is triggered when ever you scroll up and down, but without the function event it just doesn't know what to do! Normally people put "event" but im lazy.
deltaY = This is a function of the wheel scroll it just makes sure you scrolling along the Y axis. Its a standard inbuilt function there is no external variables you need to add.
Extras
setTimeout
You could add this. In the if statements as #Lonnie Best suggested
You could set a minimum amount of time that must pass before you consider an additional scroll event as actionable.
For example, below, 3 seconds must pass between scroll events before console.log("wheel") is fired again:
function createScrollEventHandler(milliseconds)
{
let allowed = true;
return (event)=>
{
event.preventDefault();
if (allowed)
{
console.log("wheel");
allowed = false;
setTimeout(()=>
{
allowed = true;
},milliseconds);
}
}
}
let scrollEventHandler = createScrollEventHandler(3000); // 3 seconds
window.addEventListener("wheel",scrollEventHandler);

How to interrupt previous event triggers in jQuery

So I've got a scroll event. It does a load of stuff to work out whether something should be moved on the page. When you scroll down, it fires off. If you wheel down, drag, it fires of bazillions and bazillions of times. As you'd expect, perhaps. Here's some simple dummy code to represent the sequence of events.
function scroller() {
// 1. A really expensive calculation that depends on the scroll position
// 2. Another expensive calculation to work out where should be now
// 3. Stop current animations
// 4. Animate an object to new position based on 1 and 2
}
$(window).on('resize' scroller);
Don't get me wrong, it's usually accurate so there isn't so much a concurrency issue. My animations inside the event call .stop() (as part #3) so the latest version is always* the right one but it's eating up a lot of CPU. I'd like to be a responsible developer here, not expecting every user to have a quad core i7.
So to my question... Can I kill off previous calls to my method from a particular event handler? Is there any way I can interfere with this stack of queued/parallel-running "processes" so that when a new one is added to the stack, the old ones are terminated instantly? I'm sure there's a concurrency-minded way of wording this but I can't think of it.
*At least I think that's the case - if the calculations took longer in an earlier run, their animation could be the last one to be called and could cock up the entire run! Hmm. I hadn't thought about that before thinking about it here. Another reason to stop the previous iterations immediately!
You can ensure the event is fired a maximum of once per x milliseconds. E.g.:
(function ($) {
$.fn.delayEvent = function (event, callback, ms) {
var whichjQuery = parseFloat($().jquery, 10)
, bindMethod = whichjQuery > 1.7 ? "on" : "bind"
, timer = 0;
$(this)[bindMethod](event, function (event) {
clearTimeout (timer);
timer = setTimeout($.proxy(callback, this, event), ms);
});
return $(this);
};
})(jQuery);
$(window).delayEvent("resize", scroller, 1000);
Minimalistic demo: http://jsfiddle.net/karim79/z2Qhz/6/

In Javascript, queuing the execution of function if the function is already executing, but cancel any previously queued

I've faced the following scenario quite often so I'm wondering if there is a built-in jQuery way of solving the issue.
Imagine the following code:
$(document).click(function() {
paintCanvas();
});
The problem with this code is that if the user clicks on the screen 50 times in rapid succession you are going to overload the browser with 50 calls to paintCanvas.
If paintCanvas is currently executing and a new request is created, we want to queue the new request so that it waits until paintCanvas is finished executing. However, at the same time, we can drop any previously queued calls to paintCanvas as we only care about the final state of the mouse, not all the intermediate states.
Here is some code that solves the problem:
var _isExecuting, _isQueued;
function paintCanvas() {
if (_isExecuting) {
if (!_isQueued) {
_isQueued = true;
setTimeout(function() {
_isQueued = false;
paintCanvas();
}, 150);
}
return;
}
_isExecuting = true;
// ... code goes here
_isExecuting = false;
};
This AJAX queue plugin essentially implements this functionality, but does so only in terms of AJAX. Surely this is a very common problem that can be solved in more generic way?
You shouldn't have to solve this problem with mousemove because the system already does that for you. While paintCanvas is executing, it is not generating hundreds of mousemove events even if the mouse is moving vigorously. Rather, the next event will be the current location of the mouse, not a queue of all the intervening mouse events.
Look at this jsFiddle: http://jsfiddle.net/jfriend00/4ZuMn/.
Wiggle your mouse around in the body (lower, right pane) as fast as you want. Then move the mouse out of the pane and notice that the count stops immediately - there are no more mouse events. It doesn't stack up mouse events ever. Whenever the system is ready for the next mouse event, it gets the latest position of the mouse. Individual mouse moves are NOT queued up - they do not accumulate. You can also see in the listing of mouse events that lots of intervening mouse events are not present (e.g. lots of coordinates are missing) even though the mouse went through more positions. This is because the system wasn't ready to make a mouse event when the mouse was in that position so that position was skipped.
Further, because javascript is single threaded, you will never get a new mouse event while you are currently processing one. The system won't generate a new one until you're done processing the one you're already one. So, you will never, ever see _isExecuting as true in javascript in your code. You simply don't need that check. And, since you don't need that check and it will never be true, none of your queuing code will ever execute. You can see here in this jsFiddle, that you can never catch a mousemove event that was re-entered: http://jsfiddle.net/jfriend00/ngnUT/. The inAction flag is never caught as true, no matter how fast or much you wiggle your mouse around.
Sounds like you want throttle/debounce features.
There are no built in methods that I know of from jQuery, you can use any of these though:
http://benalman.com/projects/jquery-throttle-debounce-plugin/
http://jsperf.com/jquery-throttle-methods
Though #rkw provided a link, I always prefer to show code here on SO. Here's some simple code that kind does what you want. A function that returns a buffered version of another function. This will keep delaying until it stops receiving the event for the given delay. You can tweak this if you don't want to to wait for the delay after the last event. All you'd need to do is keep track of when you first set the timeout and offset the subsequent calls to setTimeout.
Here's a working example http://jsfiddle.net/mendesjuan/qfFjZ/
function createBuffered(handler, delay) {
var timeoutId = null;
return function() {
var me = this;
if (timeoutId) {
window.clearTimeout(timeoutId);
}
timeoutId = setTimeout(function() {
handle.apply(me, arguments);
timeoutId = null;
}, delay);
}
}

Categories

Resources