Skip to content

Commit beebe66

Browse files
authored
Breaking potential loops, allowing widgets to be used to both get and set slicer position (#45)
* Refactor rate limiting logic into two callbacks, breaking potential loops * Add example demoing getting and setting position
1 parent d37d2de commit beebe66

File tree

2 files changed

+132
-52
lines changed

2 files changed

+132
-52
lines changed

dash_slicer/slicer.py

Lines changed: 76 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -580,26 +580,81 @@ def _create_client_callbacks(self):
580580
)
581581

582582
# ----------------------------------------------------------------------
583-
# Callback to rate-limit the index (using a timer/interval).
583+
# Callback to rate-limit the state (using a timer/interval).
584+
# This callback has as input anything that defines the state. The callback
585+
# checks what was changed, sets a timeout, and enables the timer.
584586

585587
app.clientside_callback(
586588
"""
587-
function update_index_rate_limiting(index, relayoutData, n_intervals, info, figure) {
589+
function update_rate_limiting_info(index, relayoutData, n_intervals) {
588590
589591
if (!window._slicer_{{ID}}) window._slicer_{{ID}} = {};
590592
let private_state = window._slicer_{{ID}};
591593
let now = window.performance.now();
592594
593-
// Get whether the slider was moved
595+
// Get whether the slider was moved, layout was changed, or timer ticked
594596
let slider_value_changed = false;
595597
let graph_layout_changed = false;
596598
let timer_ticked = false;
597599
for (let trigger of dash_clientside.callback_context.triggered) {
598600
if (trigger.prop_id.indexOf('slider') >= 0) slider_value_changed = true;
599-
if (trigger.prop_id.indexOf('graph') >= 0) graph_layout_changed = true;
600601
if (trigger.prop_id.indexOf('timer') >= 0) timer_ticked = true;
602+
if (trigger.prop_id.indexOf('graph') >= 0) {
603+
for (let key in relayoutData) {
604+
if (key.startsWith("xaxis.range") || key.startsWith("yaxis.range")) {
605+
graph_layout_changed = true;
606+
}
607+
}
608+
}
601609
}
602610
611+
// Set timeout and whether to disable the timer
612+
let disable_timer = false;
613+
if (slider_value_changed) {
614+
private_state.timeout = now + 200;
615+
} else if (graph_layout_changed) {
616+
private_state.timeout = now + 400; // need longer timeout for smooth scroll zoom
617+
} else if (!n_intervals) {
618+
private_state.timeout = now + 100; // initialize
619+
} else if (!private_state.timeout) {
620+
disable_timer = true;
621+
}
622+
623+
return disable_timer;
624+
}
625+
""".replace(
626+
"{{ID}}", self._context_id
627+
),
628+
Output(self._timer.id, "disabled"),
629+
[
630+
Input(self._slider.id, "value"),
631+
Input(self._graph.id, "relayoutData"),
632+
Input(self._timer.id, "n_intervals"),
633+
],
634+
)
635+
636+
# ----------------------------------------------------------------------
637+
# Callback to produce the (rate-limited) state.
638+
# Note how this callback only has the interval as input. This breaks
639+
# any loops in applications that want to both get and set the slicer
640+
# position.
641+
642+
app.clientside_callback(
643+
"""
644+
function update_state(n_intervals, index, info, figure) {
645+
646+
if (!window._slicer_{{ID}}) window._slicer_{{ID}} = {};
647+
let private_state = window._slicer_{{ID}};
648+
let now = window.performance.now();
649+
650+
// Ready to apply and stop the timer, or return early?
651+
if (!(private_state.timeout && now >= private_state.timeout)) {
652+
return dash_clientside.no_update;
653+
}
654+
655+
// Disable the timer
656+
private_state.timeout = 0;
657+
603658
// Calculate view range based on the volume
604659
let xrangeVol = [
605660
info.offset[0] - 0.5 * info.stepsize[0],
@@ -631,66 +686,35 @@ def _create_client_callbacks(self):
631686
let xrange = [Math.max(xrangeVol[0], xrangeFig[0]), Math.min(xrangeVol[1], xrangeFig[1])];
632687
let yrange = [Math.max(yrangeVol[0], yrangeFig[0]), Math.min(yrangeVol[1], yrangeFig[1])];
633688
634-
// Initialize return values
635-
let new_state = dash_clientside.no_update;
636-
let disable_timer = false;
637-
638-
// If the slider moved, remember the time when this happened
639-
private_state.new_time = private_state.new_time || 0;
640-
641-
642-
if (slider_value_changed) {
643-
private_state.new_time = now;
644-
private_state.timeout = 200;
645-
} else if (graph_layout_changed) {
646-
private_state.new_time = now;
647-
private_state.timeout = 400; // need longer timeout for smooth scroll zoom
648-
} else if (!n_intervals) {
649-
private_state.new_time = now;
650-
private_state.timeout = 100;
651-
}
652-
653-
// We can either update the rate-limited index timeout ms after
654-
// the real index changed, or timeout ms after it stopped
655-
// changing. The former makes the indicators come along while
656-
// dragging the slider, the latter is better for a smooth
657-
// experience, and the timeout can be set much lower.
658-
if (private_state.timeout && timer_ticked && now - private_state.new_time >= private_state.timeout) {
659-
private_state.timeout = 0;
660-
disable_timer = true;
661-
new_state = {
662-
index: index,
663-
index_changed: false,
664-
xrange: xrange,
665-
yrange: yrange,
666-
zpos: info.offset[2] + index * info.stepsize[2],
667-
axis: info.axis,
668-
color: info.color,
669-
};
670-
if (index != private_state.index) {
671-
private_state.index = index;
672-
new_state.index_changed = true;
673-
}
689+
// Create new state
690+
let new_state = {
691+
index: index,
692+
index_changed: false,
693+
xrange: xrange,
694+
yrange: yrange,
695+
zpos: info.offset[2] + index * info.stepsize[2],
696+
axis: info.axis,
697+
color: info.color,
698+
};
699+
if (index != private_state.last_index) {
700+
private_state.last_index = index;
701+
new_state.index_changed = true;
674702
}
675-
676-
return [new_state, disable_timer];
703+
return new_state;
677704
}
678705
""".replace(
679706
"{{ID}}", self._context_id
680707
),
708+
Output(self._state.id, "data"),
681709
[
682-
Output(self._state.id, "data"),
683-
Output(self._timer.id, "disabled"),
684-
],
685-
[
686-
Input(self._slider.id, "value"),
687-
Input(self._graph.id, "relayoutData"),
688710
Input(self._timer.id, "n_intervals"),
689711
],
690712
[
713+
State(self._slider.id, "value"),
691714
State(self._info.id, "data"),
692715
State(self._graph.id, "figure"),
693716
],
717+
# prevent_initial_call=True,
694718
)
695719

696720
# ----------------------------------------------------------------------

examples/get_and_set_position.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""
2+
An example that demonstrates how the slicer's index can be both read and written.
3+
"""
4+
5+
import dash
6+
import dash_html_components as html
7+
from dash_slicer import VolumeSlicer
8+
import dash_core_components as dcc
9+
from dash.dependencies import Input, Output, ALL
10+
import imageio
11+
12+
13+
app = dash.Dash(__name__) # , update_title=None)
14+
15+
vol = imageio.volread("imageio:stent.npz")
16+
slicer = VolumeSlicer(app, vol)
17+
18+
# We create a slider as an element that gets set from the slicer's state
19+
# and is also used to set the slicer's position. But this element can be anything,
20+
slider = dcc.Slider(id="slider", max=slicer.nslices)
21+
22+
# Create a store with a specific ID so we can set the slicer position.
23+
setpos_store = dcc.Store(
24+
id={"context": "app", "scene": slicer.scene_id, "name": "setpos"}
25+
)
26+
27+
app.layout = html.Div(
28+
[slicer.graph, slicer.slider, slider, setpos_store, *slicer.stores]
29+
)
30+
31+
32+
# Add callback to listen to changes in state of any slicers with a matching scene_id.
33+
# Note that we could also use a simpler callback using slicer.state as input.
34+
@app.callback(
35+
Output("slider", "value"),
36+
Input({"scene": slicer.scene_id, "context": ALL, "name": "state"}, "data"),
37+
)
38+
def respond_to_slicer_state(states):
39+
for state in states:
40+
if state and state["axis"] == 0:
41+
return state["index"]
42+
return dash.no_update
43+
44+
45+
# Add a callback to set the slicer position.
46+
@app.callback(
47+
Output(setpos_store.id, "data"),
48+
Input("slider", "value"),
49+
)
50+
def set_position_of_all_slicers_with_scene_id(value):
51+
return None, None, value # x, y, z (in scene coordinates)
52+
53+
54+
if __name__ == "__main__":
55+
# Note: dev_tools_props_check negatively affects the performance of VolumeSlicer
56+
app.run_server(debug=True, dev_tools_props_check=False)

0 commit comments

Comments
 (0)