22from plotly .graph_objects import Figure
33from dash import Dash
44from dash .dependencies import Input , Output , State , ALL
5- from dash_core_components import Graph , Slider , Store
5+ from dash_core_components import Graph , Slider , Store , Interval
66
77from .utils import img_array_to_uri , get_thumbnail_size , shape3d_to_size2d
88
@@ -26,6 +26,7 @@ class VolumeSlicer:
2626 reverse_y (bool): Whether to reverse the y-axis, so that the origin of
2727 the slice is in the top-left, rather than bottom-left. Default True.
2828 (This sets the figure's yaxes ``autorange`` to "reversed" or True.)
29+ Note: setting this to False affects performance, see #12.
2930 scene_id (str): the scene that this slicer is part of. Slicers
3031 that have the same scene-id show each-other's positions with
3132 line indicators. By default this is derived from ``id(volume)``.
@@ -51,9 +52,13 @@ class VolumeSlicer:
5152 The value in the store must be an 3-element tuple (x, y, z) in scene coordinates.
5253 To apply the position for one position only, use e.g ``(None, None, x)``.
5354
54- Some notes on performance: for a smooth experience, create the `Dash`
55- application with `update_title=None`, and when running the server in debug
56- mode, consider setting `dev_tools_props_check=False`.
55+ Some notes on performance: for a smooth experience, avoid triggering
56+ unnecessary figure updates. When adding a callback that uses the
57+ slicer position, use the (rate limited) `index` and `pos` stores
58+ rather than the slider value. Further, create the `Dash` application
59+ with `update_title=None`, and when running the server in debug mode,
60+ consider setting `dev_tools_props_check=False`.
61+
5762 """
5863
5964 _global_slicer_counter = 0
@@ -154,6 +159,20 @@ def stores(self):
154159 """
155160 return self ._stores
156161
162+ @property
163+ def index (self ):
164+ """A dcc.Store containing the integer slice number. This value
165+ is a rate-limited version of the slider value.
166+ """
167+ return self ._index
168+
169+ @property
170+ def pos (self ):
171+ """A dcc.Store containing the float position in scene coordinates,
172+ along the slice-axis.
173+ """
174+ return self ._pos
175+
157176 @property
158177 def overlay_data (self ):
159178 """A dcc.Store containing the overlay data. The form of this
@@ -277,63 +296,107 @@ def _create_dash_components(self):
277296 config = {"scrollZoom" : True },
278297 )
279298
280- # Create a slider object that the user can put in the layout (or not)
299+ initial_index = info ["size" ][2 ] // 2
300+ initial_pos = info ["origin" ][2 ] + initial_index * info ["spacing" ][2 ]
301+
302+ # Create a slider object that the user can put in the layout (or not).
303+ # Note that the tooltip introduces a measurable performance penalty,
304+ # so maybe we can display it in a different way?
281305 self ._slider = Slider (
282306 id = self ._subid ("slider" ),
283307 min = 0 ,
284308 max = info ["size" ][2 ] - 1 ,
285309 step = 1 ,
286- value = info ["size" ][2 ] // 2 ,
287- tooltip = {"always_visible" : False , "placement" : "left" },
310+ value = initial_index ,
288311 updatemode = "drag" ,
312+ tooltip = {"always_visible" : False , "placement" : "left" },
289313 )
290314
291315 # Create the stores that we need (these must be present in the layout)
316+
317+ # A dict of static info for this slicer
292318 self ._info = Store (id = self ._subid ("info" ), data = info )
293- self ._position = Store (
294- id = self ._subid ("position" , True , axis = self ._axis ), data = 0
295- )
296- self ._setpos = Store (id = self ._subid ("setpos" , True ), data = None )
297- self ._requested_index = Store (id = self ._subid ("req-index" ), data = 0 )
298- self ._request_data = Store (id = self ._subid ("req-data" ), data = "" )
319+
320+ # A list of low-res slices (encoded as base64-png)
299321 self ._lowres_data = Store (id = self ._subid ("lowres" ), data = thumbnails )
322+
323+ # A list of mask slices (encoded as base64-png or null)
300324 self ._overlay_data = Store (id = self ._subid ("overlay" ), data = [])
325+
326+ # Slice data provided by the server
327+ self ._server_data = Store (id = self ._subid ("server-data" ), data = "" )
328+
329+ # Store image traces for the slicer.
301330 self ._img_traces = Store (id = self ._subid ("img-traces" ), data = [])
331+
332+ # Store indicator traces for the slicer.
302333 self ._indicator_traces = Store (id = self ._subid ("indicator-traces" ), data = [])
334+
335+ # A timer to apply a rate-limit between slider.value and index.data
336+ self ._timer = Interval (id = self ._subid ("timer" ), interval = 100 , disabled = True )
337+
338+ # The (integer) index of the slice to show. This value is rate-limited
339+ self ._index = Store (id = self ._subid ("index" ), data = initial_index )
340+
341+ # The (float) position (in scene coords) of the current slice,
342+ # used to publish our position to slicers with the same scene_id.
343+ self ._pos = Store (
344+ id = self ._subid ("pos" , True , axis = self ._axis ), data = initial_pos
345+ )
346+
347+ # Signal to set the position of other slicers with the same scene_id.
348+ self ._setpos = Store (id = self ._subid ("setpos" , True ), data = None )
349+
303350 self ._stores = [
304351 self ._info ,
305- self ._position ,
306- self ._setpos ,
307- self ._requested_index ,
308- self ._request_data ,
309352 self ._lowres_data ,
310353 self ._overlay_data ,
354+ self ._server_data ,
311355 self ._img_traces ,
312356 self ._indicator_traces ,
357+ self ._timer ,
358+ self ._index ,
359+ self ._pos ,
360+ self ._setpos ,
313361 ]
314362
315363 def _create_server_callbacks (self ):
316364 """Create the callbacks that run server-side."""
317365 app = self ._app
318366
319367 @app .callback (
320- Output (self ._request_data .id , "data" ),
321- [Input (self ._requested_index .id , "data" )],
368+ Output (self ._server_data .id , "data" ),
369+ [Input (self ._index .id , "data" )],
322370 )
323371 def upload_requested_slice (slice_index ):
324372 slice = img_array_to_uri (self ._slice (slice_index ))
325373 return {"index" : slice_index , "slice" : slice }
326374
327375 def _create_client_callbacks (self ):
328376 """Create the callbacks that run client-side."""
377+
378+ # setpos (external)
379+ # \
380+ # slider --[rate limit]--> index --> pos
381+ # \ \
382+ # \ server_data (a new slice)
383+ # \ \
384+ # \ --> image_traces
385+ # ----------------------- / \
386+ # -----> figure
387+ # /
388+ # indicator_traces
389+ # /
390+ # pos (external)
391+
329392 app = self ._app
330393
331394 # ----------------------------------------------------------------------
332- # Callback to trigger fellow slicers to go to a specific position.
395+ # Callback to trigger fellow slicers to go to a specific position on click .
333396
334397 app .clientside_callback (
335398 """
336- function trigger_setpos (data, index, info) {
399+ function update_setpos_from_click (data, index, info) {
337400 if (data && data.points && data.points.length) {
338401 let point = data["points"][0];
339402 let xyz = [point["x"], point["y"]];
@@ -350,11 +413,11 @@ def _create_client_callbacks(self):
350413 )
351414
352415 # ----------------------------------------------------------------------
353- # Callback to update index from external setpos signal .
416+ # Callback to update slider based on external setpos signals .
354417
355418 app .clientside_callback (
356419 """
357- function respond_to_setpos (positions, cur_index, info) {
420+ function update_slider_value (positions, cur_index, info) {
358421 for (let trigger of dash_clientside.callback_context.triggered) {
359422 if (!trigger.value) continue;
360423 let pos = trigger.value[2 - info.axis];
@@ -381,64 +444,81 @@ def _create_client_callbacks(self):
381444 )
382445
383446 # ----------------------------------------------------------------------
384- # Callback to update position (in scene coordinates) from the index .
447+ # Callback to rate-limit the index (using a timer/interval) .
385448
386449 app .clientside_callback (
387450 """
388- function update_position(index, info) {
389- return info.origin[2] + index * info.spacing[2];
390- }
391- """ ,
392- Output (self ._position .id , "data" ),
393- [Input (self ._slider .id , "value" )],
394- [State (self ._info .id , "data" )],
395- )
451+ function update_index_rate_limiting(index, n_intervals, interval) {
396452
397- # ----------------------------------------------------------------------
398- # Callback to request new slices.
399- # Note: this callback cannot be merged with the one below, because
400- # it would create a circular dependency.
453+ if (!window._slicer_{{ID}}) window._slicer_{{ID}} = {};
454+ let slicer_state = window._slicer_{{ID}};
455+ let now = window.performance.now();
401456
402- app .clientside_callback (
403- """
404- function update_request(index) {
457+ // Get whether the slider was moved
458+ let slider_was_moved = false;
459+ for (let trigger of dash_clientside.callback_context.triggered) {
460+ if (trigger.prop_id.indexOf('slider') >= 0) slider_was_moved = true;
461+ }
405462
406- // Clear the cache?
407- if (!window.slicecache_for_{{ID}}) { window.slicecache_for_{{ID}} = {}; }
408- let slice_cache = window.slicecache_for_{{ID}} ;
463+ // Initialize return values
464+ let req_index = dash_clientside.no_update;
465+ let disable_timer = false ;
409466
410- // Request a new slice (or not)
411- let request_index = index;
412- if (slice_cache[index]) {
413- return window.dash_clientside.no_update;
414- } else {
415- console.log('requesting slice ' + index);
416- return index;
467+ // If the slider moved, remember the time when this happened
468+ slicer_state.new_time = slicer_state.new_time || 0;
469+
470+ if (slider_was_moved) {
471+ slicer_state.new_time = now;
472+ } else if (!n_intervals) {
473+ disable_timer = true; // start disabled
474+ }
475+
476+ // We can either update the rate-limited index interval ms after
477+ // the real index changed, or interval ms after it stopped
478+ // changing. The former makes the indicators come along while
479+ // dragging the slider, the latter is better for a smooth
480+ // experience, and the interval can be set much lower.
481+ if (index != slicer_state.req_index) {
482+ if (now - slicer_state.new_time >= interval) {
483+ req_index = slicer_state.req_index = index;
484+ disable_timer = true;
485+ console.log('requesting slice ' + req_index);
486+ }
417487 }
488+
489+ return [req_index, disable_timer];
418490 }
419491 """ .replace (
420492 "{{ID}}" , self ._context_id
421493 ),
422- Output (self ._requested_index .id , "data" ),
423- [Input (self .slider .id , "value" )],
494+ [
495+ Output (self ._index .id , "data" ),
496+ Output (self ._timer .id , "disabled" ),
497+ ],
498+ [Input (self ._slider .id , "value" ), Input (self ._timer .id , "n_intervals" )],
499+ [State (self ._timer .id , "interval" )],
424500 )
425501
426502 # ----------------------------------------------------------------------
427- # Callback that creates a list of image traces (slice and overlay) .
503+ # Callback to update position (in scene coordinates) from the index .
428504
429505 app .clientside_callback (
430506 """
431- function update_image_traces(index, req_data, overlays, lowres, info, current_traces) {
507+ function update_pos(index, info) {
508+ return info.origin[2] + index * info.spacing[2];
509+ }
510+ """ ,
511+ Output (self ._pos .id , "data" ),
512+ [Input (self ._index .id , "data" )],
513+ [State (self ._info .id , "data" )],
514+ )
432515
433- // Add data to the cache if the data is indeed new
434- if (!window.slicecache_for_{{ID}}) { window.slicecache_for_{{ID}} = {}; }
435- let slice_cache = window.slicecache_for_{{ID}};
436- for (let trigger of dash_clientside.callback_context.triggered) {
437- if (trigger.prop_id.indexOf('req-data') >= 0) {
438- slice_cache[req_data.index] = req_data;
439- break;
440- }
441- }
516+ # ----------------------------------------------------------------------
517+ # Callback that creates a list of image traces (slice and overlay).
518+
519+ app .clientside_callback (
520+ """
521+ function update_image_traces(index, server_data, overlays, lowres, info, current_traces) {
442522
443523 // Prepare traces
444524 let slice_trace = {
@@ -455,14 +535,14 @@ def _create_client_callbacks(self):
455535 overlay_trace.hovertemplate = '';
456536 let new_traces = [slice_trace, overlay_trace];
457537
458- // Depending on the state of the cache, use full data, or use lowres and request slice
459- if (slice_cache[index]) {
460- let cached = slice_cache[index];
461- slice_trace.source = cached.slice;
538+ // Use full data, or use lowres
539+ if (index == server_data.index) {
540+ slice_trace.source = server_data.slice;
462541 } else {
463542 slice_trace.source = lowres[index];
464543 // Scale the image to take the exact same space as the full-res
465- // version. It's not correct, but it looks better ...
544+ // version. Note that depending on how the low-res data is
545+ // created, the pixel centers may not be correctly aligned.
466546 slice_trace.dx *= info.size[0] / info.lowres_size[0];
467547 slice_trace.dy *= info.size[1] / info.lowres_size[1];
468548 slice_trace.x0 += 0.5 * slice_trace.dx - 0.5 * info.spacing[0];
@@ -474,7 +554,7 @@ def _create_client_callbacks(self):
474554 if (new_traces[0].source == current_traces[0].source &&
475555 new_traces[1].source == current_traces[1].source)
476556 {
477- new_traces = window. dash_clientside.no_update;
557+ new_traces = dash_clientside.no_update;
478558 }
479559 return new_traces;
480560 }
@@ -483,8 +563,8 @@ def _create_client_callbacks(self):
483563 ),
484564 Output (self ._img_traces .id , "data" ),
485565 [
486- Input (self .slider .id , "value" ),
487- Input (self ._request_data .id , "data" ),
566+ Input (self ._slider .id , "value" ),
567+ Input (self ._server_data .id , "data" ),
488568 Input (self ._overlay_data .id , "data" ),
489569 ],
490570 [
@@ -497,12 +577,9 @@ def _create_client_callbacks(self):
497577 # ----------------------------------------------------------------------
498578 # Callback to create scatter traces from the positions of other slicers.
499579
500- # Create a callback to create a trace representing all slice-indices that:
501- # * corresponding to the same volume data
502- # * match any of the selected axii
503580 app .clientside_callback (
504581 """
505- function handle_indicator (positions1, positions2, info, current) {
582+ function update_indicator_traces (positions1, positions2, info, current) {
506583 let x0 = info.origin[0], y0 = info.origin[1];
507584 let x1 = x0 + info.size[0] * info.spacing[0], y1 = y0 + info.size[1] * info.spacing[1];
508585 x0 = x0 - info.spacing[0], y0 = y0 - info.spacing[1];
@@ -536,7 +613,7 @@ def _create_client_callbacks(self):
536613 {
537614 "scene" : self ._scene_id ,
538615 "context" : ALL ,
539- "name" : "position " ,
616+ "name" : "pos " ,
540617 "axis" : axis ,
541618 },
542619 "data" ,
@@ -562,7 +639,6 @@ def _create_client_callbacks(self):
562639 for (let trace of indicators) { traces.push(trace); }
563640
564641 // Update figure
565- console.log("updating figure");
566642 let figure = {...ori_figure};
567643 figure.data = traces;
568644
0 commit comments