Skip to content

Commit b06e9b2

Browse files
authored
Throttle mousemove events (#98)
1 parent 347a266 commit b06e9b2

File tree

3 files changed

+92
-6
lines changed

3 files changed

+92
-6
lines changed

js/src/comm.ts

+30-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { Throttler } from "./utils";
2+
13
// This class is a striped down version of Comm from @jupyter-widgets/base
24
// https://github.com/jupyter-widgets/ipywidgets/blob/88cec8/packages/base/src/services-shim.ts#L192-L335
35
// Note that the Kernel.IComm implementation is located here
@@ -6,8 +8,11 @@ export class ShinyComm {
68

79
// It seems like we'll want one comm per model
810
comm_id: string;
11+
throttler: Throttler;
912
constructor(model_id: string) {
1013
this.comm_id = model_id;
14+
// TODO: make this configurable (see comments in send() below)?
15+
this.throttler = new Throttler(100);
1116
}
1217

1318
// This might not be needed
@@ -31,8 +36,29 @@ export class ShinyComm {
3136
// this doesn't seem relevant to the widget?
3237
header: {}
3338
};
39+
3440
const msg_txt = JSON.stringify(msg);
35-
Shiny.setInputValue("shinywidgets_comm_send", msg_txt, {priority: "event"});
41+
42+
// Since ipyleaflet can send mousemove events very quickly when hovering over the map,
43+
// we throttle them to ensure that the server doesn't get overwhelmed. Said events
44+
// generate a payload that looks like this:
45+
// {"method": "custom", "content": {"event": "interaction", "type": "mousemove", "coordinates": [-17.76259815404015, 12.096729340756617]}}
46+
//
47+
// TODO: This is definitely not ideal. It would be better to have a way to specify/
48+
// customize throttle rates instead of having such a targetted fix for ipyleaflet.
49+
const is_mousemove =
50+
data.method === "custom" &&
51+
data.content.event === "interaction" &&
52+
data.content.type === "mousemove";
53+
54+
if (is_mousemove) {
55+
this.throttler.throttle(() => {
56+
Shiny.setInputValue("shinywidgets_comm_send", msg_txt, {priority: "event"});
57+
});
58+
} else {
59+
this.throttler.flush();
60+
Shiny.setInputValue("shinywidgets_comm_send", msg_txt, {priority: "event"});
61+
}
3662

3763
// When client-side changes happen to the WidgetModel, this send method
3864
// won't get called for _every_ change (just the first one). The
@@ -42,7 +68,9 @@ export class ShinyComm {
4268
// https://github.com/jupyter-widgets/ipywidgets/blob/88cec8b/packages/base/src/widget.ts#L550-L557
4369
if (callbacks && callbacks.iopub && callbacks.iopub.status) {
4470
setTimeout(() => {
45-
// TODO: Call this when Shiny reports that it is idle?
71+
// TODO-future: it doesn't seem quite right to report that shiny is always idle.
72+
// Maybe listen to the shiny-busy flag?
73+
// const state = document.querySelector("html").classList.contains("shiny-busy") ? "busy" : "idle";
4674
const msg = {content: {execution_state: "idle"}};
4775
callbacks.iopub.status(msg);
4876
}, 0);

js/src/utils.ts

+60-2
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,66 @@ import { decode } from 'base64-arraybuffer';
33
// On the server, we're using jupyter_client.session.json_packer to serialize messages,
44
// and it encodes binary data (i.e., buffers) as base64, so decode it before passing it
55
// along to the comm logic
6-
export function jsonParse(x: string) {
6+
function jsonParse(x: string) {
77
const msg = JSON.parse(x);
88
msg.buffers = msg.buffers.map((b: any) => decode(b));
99
return msg;
10-
}
10+
}
11+
12+
class Throttler {
13+
fnToCall: Function;
14+
wait: number;
15+
timeoutId: ReturnType<typeof setTimeout>;
16+
constructor(wait: number = 100) {
17+
if (wait < 0) throw new Error("wait must be a positive number");
18+
this.wait = wait;
19+
this._reset();
20+
}
21+
// Try to execute the function immediately, if it is not waiting
22+
// If it is waiting, update the function to be called
23+
throttle(fn: Function) {
24+
if (fn.length > 0) throw new Error("fn must not take any arguments");
25+
26+
if (this.isWaiting) {
27+
// If the timeout is currently waiting, update the func to be called
28+
this.fnToCall = fn;
29+
} else {
30+
// If there is nothing waiting, call it immediately
31+
// and start the throttling
32+
fn();
33+
this._setTimeout();
34+
}
35+
}
36+
// Execute the function immediately and reset the timeout
37+
// This is useful when the timeout is waiting and we want to
38+
// execute the function immediately to not have events be out
39+
// of order
40+
flush() {
41+
if (this.fnToCall) this.fnToCall();
42+
this._reset();
43+
}
44+
_setTimeout() {
45+
this.timeoutId = setTimeout(() => {
46+
if (this.fnToCall) {
47+
this.fnToCall();
48+
this.fnToCall = null;
49+
// Restart the timeout as we just called the function
50+
// This call is the key step of Throttler
51+
this._setTimeout();
52+
} else {
53+
this._reset();
54+
}
55+
}, this.wait);
56+
}
57+
_reset() {
58+
this.fnToCall = null;
59+
clearTimeout(this.timeoutId);
60+
this.timeoutId = null;
61+
}
62+
get isWaiting(): boolean {
63+
return this.timeoutId !== null;
64+
}
65+
}
66+
67+
68+
export { jsonParse, Throttler };

shinywidgets/static/output.js

+2-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)