-
-
Notifications
You must be signed in to change notification settings - Fork 394
/
callbacks.py
1586 lines (1364 loc) · 52.8 KB
/
callbacks.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import asyncio
import base64
import time
from collections import defaultdict
from functools import partial
import numpy as np
from bokeh.models import (
BoxEditTool,
Button,
CustomJS,
DataRange1d,
DatetimeAxis,
FactorRange,
FreehandDrawTool,
PointDrawTool,
PolyDrawTool,
PolyEditTool,
Range1d,
)
from panel.io.notebook import push_on_root
from panel.io.state import set_curdoc, state
from panel.pane import panel
try:
from bokeh.models import XY, Panel
except Exception:
Panel = XY = None
from ...core.data import Dataset
from ...core.options import CallbackError
from ...core.util import (
VersionError,
datetime_types,
dimension_sanitizer,
dt64_to_dt,
isequal,
)
from ...element import Table
from ...streams import (
BoundsX,
BoundsXY,
BoundsY,
BoxEdit,
CDSStream,
CurveEdit,
DoubleTap,
Draw,
FreehandDraw,
Lasso,
MouseEnter,
MouseLeave,
PanEnd,
PlotReset,
PlotSize,
PointDraw,
PointerX,
PointerXY,
PointerY,
PolyDraw,
PolyEdit,
PressUp,
RangeX,
RangeXY,
RangeY,
Selection1D,
SelectionXY,
SelectMode,
SingleTap,
Stream,
Tap,
)
from ...util.warnings import warn
from .util import bokeh33, convert_timestamp
class Callback:
"""
Provides a baseclass to define callbacks, which return data from
bokeh model callbacks, events and attribute changes. The callback
then makes this data available to any streams attached to it.
The definition of a callback consists of a number of components:
* models : Defines which bokeh models the callback will be
attached on referencing the model by its key in
the plots handles, e.g. this could be the x_range,
y_range, plot, a plotting tool or any other
bokeh mode.
* attributes : The attributes define which attributes to send
back to Python. They are defined as a dictionary
mapping between the name under which the variable
is made available to Python and the specification
of the attribute. The specification should start
with the variable name that is to be accessed and
the location of the attribute separated by
periods. All models defined by the models and can
be addressed in this way, e.g. to get the start of
the x_range as 'x' you can supply {'x':
'x_range.attributes.start'}. Additionally certain
handles additionally make the cb_obj variables
available containing additional information about
the event.
* on_events : If the Callback should listen to bokeh events this
should declare the types of event as a list (optional)
* on_changes : If the Callback should listen to model attribute
changes on the defined ``models`` (optional)
If either on_events or on_changes are declared the Callback will
be registered using the on_event or on_change machinery, otherwise
it will be treated as a regular callback on the model. The
callback can also define a _process_msg method, which can modify
the data sent by the callback before it is passed to the streams.
A callback supports three different throttling modes:
- adaptive (default): The callback adapts the throttling timeout
depending on the rolling mean of the time taken to process each
message. The rolling window is controlled by the `adaptive_window`
value.
- throttle: Uses the fixed `throttle_timeout` as the minimum amount
of time between events.
- debounce: Processes the message only when no new event has been
received within the `throttle_timeout` duration.
"""
# Attributes to sync
attributes = {}
# The plotting handle(s) to attach the JS callback on
models = []
# Additional handles to hash on for uniqueness
extra_handles = []
# Conditions when callback should be skipped
skip_events = []
skip_changes = []
# Callback will listen to events of the supplied type on the models
on_events = []
# List of change events on the models to listen to
on_changes = []
# Internal state
_callbacks = {}
_transforms = []
# Asyncio background task
_background_task = set()
def __init__(self, plot, streams, source, **params):
self.plot = plot
self.streams = streams
self.source = source
self.handle_ids = defaultdict(dict)
self.reset()
self._active = False
self._prev_msg = None
def _transform(self, msg):
for transform in self._transforms:
msg = transform(msg, self)
return msg
def _process_msg(self, msg):
"""
Subclassable method to preprocess JSON message in callback
before passing to stream.
"""
return self._transform(msg)
def cleanup(self):
self.reset()
self.handle_ids = None
self.plot = None
self.source = None
self.streams = []
Callback._callbacks = {k: cb for k, cb in Callback._callbacks.items()
if cb is not self}
def reset(self):
if self.handle_ids:
handles = self._init_plot_handles()
for handle_name in self.models:
if handle_name not in handles:
continue
handle = handles[handle_name]
cb_hash = (id(handle), id(type(self)))
self._callbacks.pop(cb_hash, None)
self.plot_handles = {}
self._queue = []
def _filter_msg(self, msg, ids):
"""
Filter event values that do not originate from the plotting
handles associated with a particular stream using their
ids to match them.
"""
filtered_msg = {}
for k, v in msg.items():
if isinstance(v, dict) and 'id' in v:
if v['id'] in ids:
filtered_msg[k] = v['value']
else:
filtered_msg[k] = v
return filtered_msg
def on_msg(self, msg):
streams = []
for stream in self.streams:
handle_ids = self.handle_ids[stream]
ids = list(handle_ids.values())
filtered_msg = self._filter_msg(msg, ids)
processed_msg = self._process_msg(filtered_msg)
if not processed_msg:
continue
stream.update(**processed_msg)
stream._metadata = {h: {'id': hid, 'events': self.on_events}
for h, hid in handle_ids.items()}
streams.append(stream)
try:
with set_curdoc(self.plot.document):
Stream.trigger(streams)
except CallbackError as e:
if self.plot.root and self.plot.root.ref['id'] in state._handles:
handle, _ = state._handles[self.plot.root.ref['id']]
handle.update({'text/html': str(e)}, raw=True)
else:
raise e
except Exception as e:
raise e
finally:
for stream in streams:
stream._metadata = {}
def _init_plot_handles(self):
"""
Find all requested plotting handles and cache them along
with the IDs of the models the callbacks will be attached to.
"""
plots = [self.plot]
if self.plot.subplots:
plots += list(self.plot.subplots.values())
handles = {}
for plot in plots:
for k, v in plot.handles.items():
handles[k] = v
self.plot_handles = handles
requested = {}
for h in self.models+self.extra_handles:
if h in self.plot_handles:
requested[h] = handles[h]
self.handle_ids.update(self._get_stream_handle_ids(requested))
return requested
def _get_stream_handle_ids(self, handles):
"""
Gather the ids of the plotting handles attached to this callback
This allows checking that a stream is not given the state
of a plotting handle it wasn't attached to
"""
stream_handle_ids = defaultdict(dict)
for stream in self.streams:
for h in self.models+self.extra_handles:
if h in handles:
handle_id = handles[h].ref['id']
stream_handle_ids[stream][h] = handle_id
return stream_handle_ids
@classmethod
def resolve_attr_spec(cls, spec, cb_obj, model=None):
"""
Resolves a Callback attribute specification looking the
corresponding attribute up on the cb_obj, which should be a
bokeh model. If not model is supplied cb_obj is assumed to
be the same as the model.
"""
if not cb_obj:
raise AttributeError(f'Bokeh plot attribute {spec} could not be found')
if model is None:
model = cb_obj
spec = spec.split('.')
resolved = cb_obj
for p in spec[1:]:
if p == 'attributes':
continue
if isinstance(resolved, dict):
resolved = resolved.get(p)
else:
resolved = getattr(resolved, p, None)
return {'id': model.ref['id'], 'value': resolved}
def skip_event(self, event):
return any(skip(event) for skip in self.skip_events)
def skip_change(self, msg):
return any(skip(msg) for skip in self.skip_changes)
def _set_busy(self, busy):
"""
Sets panel.state to busy if available.
"""
if 'busy' not in state.param:
return # Check if busy state is supported
from panel.util import edit_readonly
with edit_readonly(state):
state.busy = busy
async def on_change(self, attr, old, new):
"""
Process change events adding timeout to process multiple concerted
value change at once rather than firing off multiple plot updates.
"""
self._queue.append((attr, old, new, time.time()))
if not self._active and self.plot.document:
self._active = True
self._set_busy(True)
await self.process_on_change()
async def on_event(self, event):
"""
Process bokeh UIEvents adding timeout to process multiple concerted
value change at once rather than firing off multiple plot updates.
"""
self._queue.append((event, time.time()))
if not self._active and self.plot.document:
self._active = True
self._set_busy(True)
await self.process_on_event()
async def process_on_event(self, timeout=None):
"""
Trigger callback change event and triggering corresponding streams.
"""
await asyncio.sleep(0.01)
if not self._queue:
self._active = False
self._set_busy(False)
return
# Get unique event types in the queue
events = list(dict([(event.event_name, event)
for event, dt in self._queue]).values())
self._queue = []
# Process event types
for event in events:
if self.skip_event(event):
continue
msg = {}
for attr, path in self.attributes.items():
model_obj = self.plot_handles.get(self.models[0])
msg[attr] = self.resolve_attr_spec(path, event, model_obj)
self.on_msg(msg)
await self.process_on_event()
async def process_on_change(self):
# Give on_change time to process new events
await asyncio.sleep(0.01)
if not self._queue:
self._active = False
self._set_busy(False)
return
self._queue = []
msg = {}
for attr, path in self.attributes.items():
attr_path = path.split('.')
if attr_path[0] == 'cb_obj':
obj_handle = self.models[0]
path = '.'.join(self.models[:1]+attr_path[1:])
else:
obj_handle = attr_path[0]
cb_obj = self.plot_handles.get(obj_handle)
try:
msg[attr] = self.resolve_attr_spec(path, cb_obj)
except Exception:
# To give BokehJS a chance to update the model
# https://github.com/holoviz/holoviews/issues/5746
await asyncio.sleep(0.05)
msg[attr] = self.resolve_attr_spec(path, cb_obj)
if self.skip_change(msg):
equal = True
else:
equal = isequal(msg, self._prev_msg)
if not equal or any(s.transient for s in self.streams):
self.on_msg(msg)
self._prev_msg = msg
await self.process_on_change()
def _schedule_event(self, event):
if self.plot.comm or not self.plot.document.session_context or state._is_pyodide:
task = asyncio.create_task(self.on_event(event))
self._background_task.add(task)
task.add_done_callback(self._background_task.discard)
else:
self.plot.document.add_next_tick_callback(partial(self.on_event, event))
def _schedule_change(self, attr, old, new):
if not self.plot.document:
return
if self.plot.comm or not self.plot.document.session_context or state._is_pyodide:
task = asyncio.create_task(self.on_change(attr, old, new))
self._background_task.add(task)
task.add_done_callback(self._background_task.discard)
else:
self.plot.document.add_next_tick_callback(partial(self.on_change, attr, old, new))
def set_callback(self, handle):
"""
Set up on_change events for bokeh server interactions.
"""
if self.on_events:
for event in self.on_events:
handle.on_event(event, self._schedule_event)
if self.on_changes:
for change in self.on_changes:
if change in ['patching', 'streaming']:
# Patch and stream events do not need handling on server
continue
handle.on_change(change, self._schedule_change)
def initialize(self, plot_id=None):
handles = self._init_plot_handles()
hash_handles, cb_handles = [], []
for handle_name in self.models+self.extra_handles:
if handle_name not in handles:
warn_args = (handle_name, type(self.plot).__name__,
type(self).__name__)
print('{} handle not found on {}, cannot '
'attach {} callback'.format(*warn_args))
continue
handle = handles[handle_name]
if handle_name not in self.extra_handles:
cb_handles.append(handle)
hash_handles.append(handle)
# Hash the plot handle with Callback type allowing multiple
# callbacks on one handle to be merged
hash_ids = [id(h) for h in hash_handles]
cb_hash = tuple(hash_ids)+(id(type(self)),)
if cb_hash in self._callbacks:
# Merge callbacks if another callback has already been attached
cb = self._callbacks[cb_hash]
cb.streams = list(set(cb.streams+self.streams))
for k, v in self.handle_ids.items():
cb.handle_ids[k].update(v)
self.cleanup()
return
for handle in cb_handles:
self.set_callback(handle)
self._callbacks[cb_hash] = self
class PointerXYCallback(Callback):
"""
Returns the mouse x/y-position on mousemove event.
"""
attributes = {'x': 'cb_obj.x', 'y': 'cb_obj.y'}
models = ['plot']
on_events = ['mousemove']
def _process_out_of_bounds(self, value, start, end):
"Clips out of bounds values"
if isinstance(value, np.datetime64):
v = dt64_to_dt(value)
if isinstance(start, (int, float)):
start = convert_timestamp(start)
if isinstance(end, (int, float)):
end = convert_timestamp(end)
s, e = start, end
if isinstance(s, np.datetime64):
s = dt64_to_dt(s)
if isinstance(e, np.datetime64):
e = dt64_to_dt(e)
else:
v, s, e = value, start, end
if v < s:
value = start
elif v > e:
value = end
return value
def _process_msg(self, msg):
x_range = self.plot.handles.get('x_range')
y_range = self.plot.handles.get('y_range')
xaxis = self.plot.handles.get('xaxis')
yaxis = self.plot.handles.get('yaxis')
if 'x' in msg and isinstance(xaxis, DatetimeAxis):
msg['x'] = convert_timestamp(msg['x'])
if 'y' in msg and isinstance(yaxis, DatetimeAxis):
msg['y'] = convert_timestamp(msg['y'])
if isinstance(x_range, FactorRange) and isinstance(msg.get('x'), (int, float)):
msg['x'] = x_range.factors[int(msg['x'])]
elif 'x' in msg and isinstance(x_range, (Range1d, DataRange1d)):
xstart, xend = x_range.start, x_range.end
if xstart > xend:
xstart, xend = xend, xstart
x = self._process_out_of_bounds(msg['x'], xstart, xend)
if x is None:
msg = {}
else:
msg['x'] = x
if isinstance(y_range, FactorRange) and isinstance(msg.get('y'), (int, float)):
msg['y'] = y_range.factors[int(msg['y'])]
elif 'y' in msg and isinstance(y_range, (Range1d, DataRange1d)):
ystart, yend = y_range.start, y_range.end
if ystart > yend:
ystart, yend = yend, ystart
y = self._process_out_of_bounds(msg['y'], ystart, yend)
if y is None:
msg = {}
else:
msg['y'] = y
return self._transform(msg)
class PointerXCallback(PointerXYCallback):
"""
Returns the mouse x-position on mousemove event.
"""
attributes = {'x': 'cb_obj.x'}
class PointerYCallback(PointerXYCallback):
"""
Returns the mouse x/y-position on mousemove event.
"""
attributes = {'y': 'cb_obj.y'}
class DrawCallback(PointerXYCallback):
on_events = ['pan', 'panstart', 'panend']
models = ['plot']
attributes = {'x': 'cb_obj.x', 'y': 'cb_obj.y', 'event': 'cb_obj.event_name'}
def __init__(self, *args, **kwargs):
self.stroke_count = 0
super().__init__(*args, **kwargs)
def _process_msg(self, msg):
event = msg.pop('event')
if event == 'panend':
self.stroke_count += 1
return self._transform(dict(msg, stroke_count=self.stroke_count))
class PopupMixin:
geom_type = 'any'
def initialize(self, plot_id=None):
super().initialize(plot_id=plot_id)
if not self.streams:
return
self._selection_event = None
self._processed_event = True
self._skipped_partial_event = False
self._existing_popup = None
stream = self.streams[0]
if not getattr(stream, 'popup', None):
return
elif Panel is None:
raise VersionError("Popup requires Bokeh >= 3.4")
close_button = Button(label="", stylesheets=[r"""
:host(.bk-Button) {
width: 100%;
height: 100%;
top: -1em;
}
.bk-btn, .bk-btn:hover, .bk-btn:active, .bk-btn:focus {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 0.5em;
margin: -0.5em;
outline: none;
box-shadow: none;
position: absolute;
top: 0;
right: 0;
}
.bk-btn::after {
content: '\2715';
}
"""],
css_classes=["popup-close-btn"])
self._panel = Panel(
position=XY(x=np.nan, y=np.nan),
anchor="top_left",
elements=[close_button],
visible=False
)
close_button.js_on_click(CustomJS(args=dict(panel=self._panel), code="panel.visible = false"))
self.plot.state.elements.append(self._panel)
self._watch_position()
def _watch_position(self):
geom_type = self.geom_type
self.plot.state.on_event('selectiongeometry', self._update_selection_event)
self.plot.state.js_on_event('selectiongeometry', CustomJS(
args=dict(panel=self._panel),
code=f"""
export default ({{panel}}, cb_obj, _) => {{
const el = panel.elements[1]
if ((el && !el.visible) || !cb_obj.final || ({geom_type!r} !== 'any' && cb_obj.geometry.type !== {geom_type!r})) {{
return
}}
let pos;
if (cb_obj.geometry.type === 'point') {{
pos = {{x: cb_obj.geometry.x, y: cb_obj.geometry.y}}
}} else if (cb_obj.geometry.type === 'rect') {{
pos = {{x: cb_obj.geometry.x1, y: cb_obj.geometry.y1}}
}} else if (cb_obj.geometry.type === 'poly') {{
pos = {{x: Math.max(...cb_obj.geometry.x), y: Math.max(...cb_obj.geometry.y)}}
}}
if (pos) {{
panel.position.setv(pos)
}}
}}""",
))
def _get_position(self, event):
if self.geom_type not in ('any', event.geometry['type']):
return
elif event.geometry['type'] == 'point':
return dict(x=event.geometry['x'], y=event.geometry['y'])
elif event.geometry['type'] == 'rect':
return dict(x=event.geometry['x1'], y=event.geometry['y1'])
elif event.geometry['type'] == 'poly':
return dict(x=np.max(event.geometry['x']), y=np.max(event.geometry['y']))
def _update_selection_event(self, event):
if (((prev:= self._selection_event) and prev.final and not self._processed_event) or
self.geom_type not in (event.geometry["type"], "any")):
return
self._selection_event = event
self._processed_event = not event.final
if event.final and self._skipped_partial_event:
self._process_selection_event()
self._skipped_partial_event = False
def on_msg(self, msg):
super().on_msg(msg)
if hasattr(self, '_panel'):
self._process_selection_event()
def _process_selection_event(self):
event = self._selection_event
if event is not None:
if self.geom_type not in (event.geometry["type"], "any"):
return
elif not event.final:
self._skipped_partial_event = True
return
if event:
self._processed_event = True
for stream in self.streams:
popup = stream.popup
if popup is not None:
break
if callable(popup):
popup = popup(**stream.contents)
# If no popup is defined, hide the panel
if popup is None:
if self._panel.visible:
self._panel.visible = False
return
if event is not None:
position = self._get_position(event)
else:
position = None
popup_pane = panel(popup)
if not popup_pane.visible:
return
if not popup_pane.stylesheets:
self._panel.stylesheets = [
"""
:host {
padding: 1em;
border-radius: 0.5em;
border: 1px solid lightgrey;
}
""",
]
else:
self._panel.stylesheets = []
self._panel.visible = True
# for existing popup, important to check if they're visible
# otherwise, UnknownReferenceError: can't resolve reference 'p...'
# meaning the popup has already been removed; we need to regenerate
if self._existing_popup and not self._existing_popup.visible:
if position:
self._panel.position = XY(**position)
if self.plot.comm: # update Jupyter Notebook
push_on_root(self.plot.root.ref['id'])
return
model = popup_pane.get_root(self.plot.document, self.plot.comm)
model.js_on_change('visible', CustomJS(
args=dict(panel=self._panel),
code="""
export default ({panel}, event, _) => {
if (!event.visible) {
panel.visible = false;
}
}""",
))
# the first element is the close button
self._panel.elements = [self._panel.elements[0], model]
if self.plot.comm: # update Jupyter Notebook
push_on_root(self.plot.root.ref['id'])
self._existing_popup = popup_pane
class TapCallback(PopupMixin, PointerXYCallback):
"""
Returns the mouse x/y-position on tap event.
Note: As of bokeh 0.12.5, there is no way to distinguish the
individual tap events within a doubletap event.
"""
geom_type = 'point'
on_events = ['tap', 'doubletap']
def _process_out_of_bounds(self, value, start, end):
"Sets out of bounds values to None"
if isinstance(value, np.datetime64):
v = dt64_to_dt(value)
if isinstance(start, (int, float)):
start = convert_timestamp(start)
if isinstance(end, (int, float)):
end = convert_timestamp(end)
s, e = start, end
if isinstance(s, np.datetime64):
s = dt64_to_dt(s)
if isinstance(e, np.datetime64):
e = dt64_to_dt(e)
else:
v, s, e = value, start, end
if v < s or v > e:
value = None
return value
class SingleTapCallback(TapCallback):
"""
Returns the mouse x/y-position on tap event.
"""
on_events = ['tap']
class PressUpCallback(TapCallback):
"""
Returns the mouse x/y-position of a pressup mouse event.
"""
on_events = ['pressup']
class PanEndCallback(TapCallback):
"""
Returns the mouse x/y-position of a pan end event.
"""
on_events = ['panend']
class DoubleTapCallback(TapCallback):
"""
Returns the mouse x/y-position on doubletap event.
"""
on_events = ['doubletap']
class MouseEnterCallback(PointerXYCallback):
"""
Returns the mouse x/y-position on mouseenter event, i.e. when
mouse enters the plot canvas.
"""
on_events = ['mouseenter']
class MouseLeaveCallback(PointerXYCallback):
"""
Returns the mouse x/y-position on mouseleave event, i.e. when
mouse leaves the plot canvas.
"""
on_events = ['mouseleave']
class RangeXYCallback(Callback):
"""
Returns the x/y-axis ranges of a plot.
"""
on_events = ['rangesupdate']
models = ['plot']
extra_handles = ['x_range', 'y_range']
attributes = {
'x0': 'cb_obj.x0',
'y0': 'cb_obj.y0',
'x1': 'cb_obj.x1',
'y1': 'cb_obj.y1',
}
def initialize(self, plot_id=None):
super().initialize(plot_id)
for stream in self.streams:
msg = self._process_msg({})
stream.update(**msg)
def _process_msg(self, msg):
if self.plot.state.x_range is not self.plot.handles['x_range']:
x_range = self.plot.handles['x_range']
msg['x0'], msg['x1'] = x_range.start, x_range.end
if self.plot.state.y_range is not self.plot.handles['y_range']:
y_range = self.plot.handles['y_range']
msg['y0'], msg['y1'] = y_range.start, y_range.end
data = {}
if 'x0' in msg and 'x1' in msg:
x0, x1 = msg['x0'], msg['x1']
if isinstance(self.plot.handles.get('xaxis'), DatetimeAxis):
if not isinstance(x0, datetime_types):
x0 = convert_timestamp(x0)
if not isinstance(x1, datetime_types):
x1 = convert_timestamp(x1)
if x0 > x1:
x0, x1 = x1, x0
data['x_range'] = (x0, x1)
if 'y0' in msg and 'y1' in msg:
y0, y1 = msg['y0'], msg['y1']
if isinstance(self.plot.handles.get('yaxis'), DatetimeAxis):
if not isinstance(y0, datetime_types):
y0 = convert_timestamp(y0)
if not isinstance(y1, datetime_types):
y1 = convert_timestamp(y1)
if y0 > y1:
y0, y1 = y1, y0
data['y_range'] = (y0, y1)
return self._transform(data)
class RangeXCallback(RangeXYCallback):
"""
Returns the x-axis range of a plot.
"""
on_events = ['rangesupdate']
models = ['plot']
attributes = {
'x0': 'cb_obj.x0',
'x1': 'cb_obj.x1',
}
class RangeYCallback(RangeXYCallback):
"""
Returns the y-axis range of a plot.
"""
on_events = ['rangesupdate']
models = ['plot']
attributes = {
'y0': 'cb_obj.y0',
'y1': 'cb_obj.y1'
}
class PlotSizeCallback(Callback):
"""
Returns the actual width and height of a plot once the layout
solver has executed.
"""
models = ['plot']
attributes = {'width': 'cb_obj.inner_width',
'height': 'cb_obj.inner_height'}
on_changes = ['inner_width', 'inner_height']
def _process_msg(self, msg):
if msg.get('width') and msg.get('height'):
return self._transform(msg)
else:
return {}
class SelectModeCallback(Callback):
attributes = {'box_mode': 'box_select.mode',
'lasso_mode': 'lasso_select.mode'}
models = ['box_select', 'lasso_select']
on_changes = ['mode']
def _process_msg(self, msg):
stream = self.streams[0]
if 'box_mode' in msg:
mode = msg.pop('box_mode')
if mode != stream.mode:
msg['mode'] = mode
if 'lasso_mode' in msg:
mode = msg.pop('lasso_mode')
if mode != stream.mode:
msg['mode'] = mode
return msg
class BoundsCallback(PopupMixin, Callback):
"""
Returns the bounds of a box_select tool.
"""
attributes = {'x0': 'cb_obj.geometry.x0',
'x1': 'cb_obj.geometry.x1',
'y0': 'cb_obj.geometry.y0',
'y1': 'cb_obj.geometry.y1'}
geom_type = 'rect'
models = ['plot']
on_events = ['selectiongeometry']
skip_events = [lambda event: event.geometry['type'] != 'rect',
lambda event: not event.final]
def _process_msg(self, msg):
if all(c in msg for c in ['x0', 'y0', 'x1', 'y1']):
if isinstance(self.plot.handles.get('xaxis'), DatetimeAxis):
msg['x0'] = convert_timestamp(msg['x0'])
msg['x1'] = convert_timestamp(msg['x1'])
if isinstance(self.plot.handles.get('yaxis'), DatetimeAxis):
msg['y0'] = convert_timestamp(msg['y0'])
msg['y1'] = convert_timestamp(msg['y1'])
msg = {'bounds': (msg['x0'], msg['y0'], msg['x1'], msg['y1'])}
return self._transform(msg)
else:
return {}
class SelectionXYCallback(BoundsCallback):
"""
Converts a bounds selection to numeric or categorical x-range
and y-range selections.
"""
def _process_msg(self, msg):
msg = super()._process_msg(msg)
if 'bounds' not in msg:
return msg
el = self.plot.current_frame
x0, y0, x1, y1 = msg['bounds']
x_range = self.plot.handles['x_range']
if isinstance(x_range, FactorRange):
x0, x1 = int(round(x0)), int(round(x1))
xfactors = x_range.factors[x0: x1]
if x_range.tags and x_range.tags[0]: