forked from bokeh/bokeh
-
Notifications
You must be signed in to change notification settings - Fork 11
/
plotting_helpers.py
495 lines (434 loc) · 18.1 KB
/
plotting_helpers.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
from collections import Iterable, Sequence
import itertools
from numbers import Number
import numpy as np
import re
from six import string_types
from . import glyphs
from .objects import (
BoxSelectionOverlay, BoxSelectTool, BoxZoomTool, CategoricalAxis,
ColumnDataSource, ClickTool, CrosshairTool, DataRange1d, DatetimeAxis,
EmbedTool, FactorRange, Grid, HoverTool, Legend, LinearAxis, LogAxis,
ObjectExplorerTool, PanTool, Plot, PreviewSaveTool, Range, Range1d,
ResetTool, ResizeTool, Tool, WheelZoomTool,
)
from .properties import ColorSpec, Date, Datetime
import warnings
def get_default_color(plot=None):
colors = [
"#1f77b4",
"#ff7f0e", "#ffbb78",
"#2ca02c", "#98df8a",
"#d62728", "#ff9896",
"#9467bd", "#c5b0d5",
"#8c564b", "#c49c94",
"#e377c2", "#f7b6d2",
"#7f7f7f",
"#bcbd22", "#dbdb8d",
"#17becf", "#9edae5"
]
if plot:
renderers = plot.renderers
renderers = [x for x in renderers if x.__view_model__ == "Glyph"]
num_renderers = len(renderers)
return colors[num_renderers]
else:
return colors[0]
def get_default_alpha(plot=None):
return 1.0
def _glyph_doc(args, props, desc):
params_tuple =tuple(itertools.chain.from_iterable(sorted(list(args.items()))))
params = "\t%s : %s\n" * len(args) % params_tuple
return """%s
Parameters
----------
%s
Additionally, the following properties are accepted as keyword arguments: %s
Returns
-------
plot : :py:class:`Plot <bokeh.objects.Plot>`
""" % (desc, params, props)
def _match_data_params(argnames, glyphclass, datasource, serversource,
args, kwargs):
""" Processes the arguments and kwargs passed in to __call__ to line
them up with the argnames of the underlying Glyph
Returns
---------
glyph_params : dict of params that should be in the glyphspec
"""
# Go through the list of position and keyword arguments, matching up
# the full list of required glyph data attributes
attributes = dict(zip(argnames, args))
if len(args) < len(argnames):
for argname in argnames[len(args):]:
if argname in kwargs:
attributes[argname] = kwargs.pop(argname)
else:
raise RuntimeError("Missing required glyph parameter '%s'" % argname)
# Go through keys in alpha order, so that *_units are handled after
# the dataspec dict is already created
dataspecs = glyphclass.dataspecs_with_refs()
for kw in kwargs:
if (kw.endswith("_units") and kw[:-6] in dataspecs) or kw in dataspecs:
attributes[kw] = kwargs[kw]
glyph_params = {}
for var in sorted(attributes.keys()):
val = attributes[var]
if var.endswith("_units") and var[:-6] in dataspecs:
dspec = var[:-6]
if dspec not in glyph_params:
raise RuntimeError("Cannot set units on undefined field '%s'" % dspec)
curval = glyph_params[dspec]
if not isinstance(curval, dict):
# TODO: This assumes that string values are fields; this is invalid
# for ColorSpecs, but all this logic is to handle dataspec units, and
# ColorSpecs do not have units. However, if there are other kinds of
# DataSpecs that do have string constants, then we will need to fix
# this up to have smarter detection of field names.
if isinstance(curval, string_types):
glyph_params[dspec] = {"field": curval, "units": val}
else:
glyph_params[dspec] = {"value": curval, "units": val}
else:
glyph_params[dspec]["units"] = val
continue
if isinstance(val, dict) or isinstance(val, Number):
glyph_val = val
elif isinstance(dataspecs.get(var, None), ColorSpec) and (ColorSpec.isconst(val) or val is None):
# This check for color constants needs to happen relatively early on because
# both strings and certain iterables are valid colors.
glyph_val = val
elif isinstance(val, string_types):
if glyphclass == glyphs.Text:
# TODO (bev) this is hacky, now that text is a DataSpec, it has to be a sequence
glyph_val = [val]
elif serversource is None and val not in datasource.column_names:
raise RuntimeError("Column name '%s' does not appear in data source %r" % (val, datasource))
else:
if val not in datasource.column_names:
datasource.column_names.append(val)
datasource.data[val] = []
units = getattr(dataspecs[var], 'units', 'data')
glyph_val = {'field' : val, 'units' : units}
elif isinstance(val, np.ndarray):
if val.ndim != 1:
raise RuntimeError("Columns need to be 1D (%s is not)" % var)
datasource.add(val, name=var)
units = getattr(dataspecs[var], 'units', 'data')
glyph_val = {'field' : var, 'units' : units}
elif isinstance(val, Iterable):
datasource.add(val, name=var)
units = getattr(dataspecs[var], 'units', 'data')
glyph_val = {'field' : var, 'units' : units}
else:
raise RuntimeError("Unexpected column type: %s" % type(val))
glyph_params[var] = glyph_val
return glyph_params
def _update_plot_data_ranges(plot, datasource, xcols, ycols):
"""
Parameters
----------
plot : plot
datasource : datasource
xcols : names of columns that are in the X axis
ycols : names of columns that are in the Y axis
"""
if isinstance(plot.x_range, DataRange1d):
x_column_ref = [x for x in plot.x_range.sources if x.source == datasource]
if len(x_column_ref) > 0:
x_column_ref = x_column_ref[0]
for cname in xcols:
if cname not in x_column_ref.columns:
x_column_ref.columns.append(cname)
else:
plot.x_range.sources.append(datasource.columns(*xcols))
plot.x_range._dirty = True
if isinstance(plot.y_range, DataRange1d):
y_column_ref = [y for y in plot.y_range.sources if y.source == datasource]
if len(y_column_ref) > 0:
y_column_ref = y_column_ref[0]
for cname in ycols:
if cname not in y_column_ref.columns:
y_column_ref.columns.append(cname)
else:
plot.y_range.sources.append(datasource.columns(*ycols))
plot.y_range._dirty = True
def _materialize_colors_and_alpha(kwargs, prefix="", default_alpha=1.0):
"""
Given a kwargs dict, a prefix, and a default value, looks for different
color and alpha fields of the given prefix, and fills in the default value
if it doesn't exist.
"""
kwargs = kwargs.copy()
# TODO: The need to do this and the complexity of managing this kind of
# thing throughout the codebase really suggests that we need to have
# a real stylesheet class, where defaults and Types can declaratively
# substitute for this kind of imperative logic.
color = kwargs.pop(prefix+"color", get_default_color())
for argname in ("fill_color", "line_color"):
kwargs[argname] = kwargs.get(prefix + argname, color)
# NOTE: text fill color should really always default to black, hard coding
# this here now untils the stylesheet solution exists
kwargs["text_color"] = kwargs.get(prefix + "text_color", "black")
alpha = kwargs.pop(prefix+"alpha", default_alpha)
for argname in ("fill_alpha", "line_alpha", "text_alpha"):
kwargs[argname] = kwargs.get(prefix + argname, alpha)
return kwargs
def _get_legend(plot):
legend = [x for x in plot.renderers if x.__view_model__ == "Legend"]
if len(legend) > 0:
legend = legend[0]
else:
legend = None
return legend
def _make_legend(plot):
legend = Legend(plot=plot)
plot.renderers.append(legend)
plot._dirty = True
return legend
def _get_select_tool(plot):
"""returns select tool on a plot, if it's there
"""
select_tool = [x for x in plot.tools if x.__view_model__ == "BoxSelectTool"]
if len(select_tool) > 0:
select_tool = select_tool[0]
else:
select_tool = None
return select_tool
def _get_range(range_input):
if range_input is None:
return DataRange1d()
if isinstance(range_input, Range):
return range_input
if isinstance(range_input, Sequence):
if all(isinstance(x, string_types) for x in range_input):
return FactorRange(factors=range_input)
if len(range_input) == 2:
try:
return Range1d(start=range_input[0], end=range_input[1])
except ValueError: # @mattpap suggests ValidationError instead
pass
raise ValueError("Unrecognized range input: '%s'" % str(range_input))
def _get_axis_class(axis_type, range_input):
if axis_type is None:
return None
elif axis_type is "linear":
return LinearAxis
elif axis_type is "log":
return LogAxis
elif axis_type == "datetime":
return DatetimeAxis
elif axis_type == "auto":
if isinstance(range_input, FactorRange):
return CategoricalAxis
elif isinstance(range_input, Range1d):
try:
# Easier way to validate type of Range1d parameters
Datetime.validate(Datetime(), range_input.start)
return DatetimeAxis
except ValueError:
pass
return LinearAxis
else:
raise ValueError("Unrecognized axis_type: '%r'" % axis_type)
def _get_num_minor_ticks(axis_class, num_minor_ticks):
if isinstance(num_minor_ticks, int):
if num_minor_ticks <= 1:
raise ValueError("num_minor_ticks must be > 1")
return num_minor_ticks
if num_minor_ticks is None:
return 0
if num_minor_ticks == 'auto':
if axis_class is LogAxis:
return 10
return 5
def _new_xy_plot(x_range=None, y_range=None, plot_width=None, plot_height=None,
x_axis_type="auto", y_axis_type="auto",
x_axis_location="below", y_axis_location="left",
x_minor_ticks='auto', y_minor_ticks='auto',
tools="pan,wheel_zoom,box_zoom,save,resize,select,reset", **kw):
# Accept **kw to absorb other arguments which the actual factory functions
# might pass in, but that we don't care about
p = Plot()
p.title = kw.pop("title", "Plot")
p.x_range = _get_range(x_range)
p.y_range = _get_range(y_range)
if plot_width: p.plot_width = plot_width
if plot_height: p.plot_height = plot_height
x_axiscls = _get_axis_class(x_axis_type, p.x_range)
if x_axiscls:
if x_axiscls is LogAxis:
p.x_mapper_type = 'log'
xaxis = x_axiscls(plot=p)
xaxis.ticker.num_minor_ticks = _get_num_minor_ticks(x_axiscls, x_minor_ticks)
axis_label = kw.pop('x_axis_label', None)
if axis_label:
xaxis.axis_label = axis_label
xgrid = Grid(plot=p, dimension=0, ticker=xaxis.ticker)
if x_axis_location == "top":
warnings.warn("'top' is deprecated, use 'above'")
p.above.append(xaxis)
elif x_axis_location == "bottom":
warnings.warn("'bottom' is deprecated, use 'below'")
p.below.append(xaxis)
elif x_axis_location == "above":
p.above.append(xaxis)
elif x_axis_location == "below":
p.below.append(xaxis)
y_axiscls = _get_axis_class(y_axis_type, p.y_range)
if y_axiscls:
if y_axiscls is LogAxis:
p.y_mapper_type = 'log'
yaxis = y_axiscls(plot=p)
yaxis.ticker.num_minor_ticks = _get_num_minor_ticks(y_axiscls, y_minor_ticks)
axis_label = kw.pop('y_axis_label', None)
if axis_label:
yaxis.axis_label = axis_label
ygrid = Grid(plot=p, dimension=1, ticker=yaxis.ticker)
if y_axis_location == "left":
p.left.append(yaxis)
elif y_axis_location == "right":
p.right.append(yaxis)
border_args = ["min_border", "min_border_top", "min_border_bottom", "min_border_left", "min_border_right"]
for arg in border_args:
if arg in kw:
setattr(p, arg, kw.pop(arg))
fill_args = ["background_fill", "border_fill"]
for arg in fill_args:
if arg in kw:
setattr(p, arg, kw.pop(arg))
style_arg_prefix = ["title", "outline"]
for prefix in style_arg_prefix:
for k in list(kw):
if k.startswith(prefix):
setattr(p, k, kw.pop(k))
tool_objs = []
temp_tool_str = str()
if isinstance(tools, list):
for tool in tools:
if isinstance(tool, Tool):
tool_objs.append(tool)
elif isinstance(tool, string_types):
temp_tool_str+=tool + ','
else:
raise ValueError("tool should be a valid str or Tool Object")
tools = temp_tool_str
# Remove pan/zoom tools in case of categorical axes
remove_pan_zoom = (isinstance(p.x_range, FactorRange) or
isinstance(p.y_range, FactorRange))
removing = []
for tool in re.split(r"\s*,\s*", tools.strip()):
# re.split will return empty strings; ignore them.
if remove_pan_zoom and ("pan" in tool or "zoom" in tool):
removing.append(tool)
continue
if tool == "":
continue
if tool == "pan":
tool_obj = PanTool(plot=p, dimensions=["width", "height"])
elif tool == "xpan":
tool_obj = PanTool(plot=p, dimensions=["width"])
elif tool == "ypan":
tool_obj = PanTool(plot=p, dimensions=["height"])
elif tool == "wheel_zoom":
tool_obj = WheelZoomTool(plot=p, dimensions=["width", "height"])
elif tool == "xwheel_zoom":
tool_obj = WheelZoomTool(plot=p, dimensions=["width"])
elif tool == "ywheel_zoom":
tool_obj = WheelZoomTool(plot=p, dimensions=["height"])
elif tool == "save":
tool_obj = PreviewSaveTool(plot=p)
elif tool == "resize":
tool_obj = ResizeTool(plot=p)
elif tool == "click":
tool_obj = ClickTool(plot=p, always_active=True)
elif tool == "crosshair":
tool_obj = CrosshairTool(plot=p)
elif tool == "select":
tool_obj = BoxSelectTool()
overlay = BoxSelectionOverlay(tool=tool_obj)
p.renderers.append(overlay)
elif tool == "box_zoom":
tool_obj = BoxZoomTool(plot=p)
overlay = BoxSelectionOverlay(tool=tool_obj)
p.renderers.append(overlay)
elif tool == "hover":
tool_obj = HoverTool(plot=p, always_active=True, tooltips={
"index": "$index",
"data (x, y)": "($x, $y)",
"canvas (x, y)": "($sx, $sy)",
})
elif tool == "previewsave":
tool_obj = PreviewSaveTool(plot=p)
elif tool == "embed":
tool_obj = EmbedTool(plot=p)
elif tool == "reset":
tool_obj = ResetTool(plot=p)
elif tool == "object_explorer":
tool_obj = ObjectExplorerTool()
else:
known_tools = "pan, xpan, ypan, wheel_zoom, xwheel_zoom, ywheel_zoom, box_zoom, save, resize, crosshair, select, previewsave, reset, hover, or embed"
raise ValueError("invalid tool: %s (expected one of %s)" % (tool, known_tools))
tool_objs.append(tool_obj)
#Checking for repeated tools
repeated_tools = []
for typname, grp in itertools.groupby(sorted(str(type(i)) for i in tool_objs)):
if len(list(grp)) > 1: repeated_tools+=typname
if repeated_tools:
repeated = str()
for tools in repeated_tools:
repeated += tools
warnings.warn("tools:%s are being repeated!"%repeated)
p.tools.extend(tool_objs)
if removing:
warnings.warn("Categorical plots do not support pan and zoom operations.\n"
"Removing tool(s): %s" %', '.join(removing))
return p
def _handle_1d_data_args(args, datasource=None, create_autoindex=True,
suggested_names=[]):
""" Returns a datasource and a list of names corresponding (roughly)
to the input data. If only a single array was given, and an index
array was created, then the index's name is returned first.
"""
arrays = []
if datasource is None:
datasource = ColumnDataSource()
# First process all the arguments to homogenize shape. After this
# process, "arrays" should contain a uniform list of string/ndarray/iterable
# corresponding to the inputs.
for arg in args:
if isinstance(arg, string_types):
# This has to be handled before our check for Iterable
arrays.append(arg)
elif isinstance(arg, np.ndarray):
if arg.ndim == 1:
arrays.append(arg)
else:
arrays.extend(arg)
elif isinstance(arg, Iterable):
arrays.append(arg)
elif isinstance(arg, Number):
arrays.append([arg])
# Now handle the case when they've only provided a single array of
# inputs (i.e. just the values, and no x positions). Generate a new
# dummy array for this.
if create_autoindex and len(arrays) == 1:
arrays.insert(0, np.arange(len(arrays[0])))
# Now put all the data into a DataSource, or insert into existing one
names = []
for i, ary in enumerate(arrays):
if isinstance(ary, string_types):
name = ary
else:
if i < len(suggested_names):
name = suggested_names[i]
elif i == 0 and create_autoindex:
name = datasource.add(ary, name="_autoindex")
else:
name = datasource.add(ary)
names.append(name)
return names, datasource
class _list_attr_splat(list):
def __setattr__(self, attr, value):
for x in self:
setattr(x, attr, value)