diff --git a/bqplot/figure.py b/bqplot/figure.py index 3baee6bee..af143fea5 100644 --- a/bqplot/figure.py +++ b/bqplot/figure.py @@ -27,7 +27,7 @@ """ from traitlets import ( - Unicode, Instance, List, Dict, Enum, Float, Int, TraitError, default, + Bool, Unicode, Instance, List, Dict, Enum, Float, Int, TraitError, default, validate ) from ipywidgets import DOMWidget, register, widget_serialization @@ -87,6 +87,8 @@ class Figure(DOMWidget): pixel_ratio: Pixel ratio of the WebGL canvas (2 on retina screens). Set to 1 for better performance, but less crisp edges. If set to None it will use the browser's window.devicePixelRatio. + display_toolbar: boolean (default: True) + Show or hide the integrated toolbar. Layout Attributes @@ -152,6 +154,7 @@ class Figure(DOMWidget): .tag(sync=True, display_name='Legend position') animation_duration = Int().tag(sync=True, display_name='Animation duration') + display_toolbar = Bool(default_value=True).tag(sync=True) @default('scale_x') def _default_scale_x(self): diff --git a/bqplot/pyplot.py b/bqplot/pyplot.py index 48422857f..fcbd943ce 100644 --- a/bqplot/pyplot.py +++ b/bqplot/pyplot.py @@ -50,7 +50,6 @@ import sys from collections import OrderedDict from IPython.display import display -from ipywidgets import VBox from ipywidgets import Image as ipyImage from numpy import arange, issubdtype, array, column_stack, shape from .figure import Figure @@ -58,7 +57,6 @@ from .axes import Axis from .marks import (Lines, Scatter, ScatterGL, Hist, Bars, OHLC, Pie, Map, Image, Label, HeatMap, GridHeatMap, topo_load, Boxplot, Bins) -from .toolbar import Toolbar from .interacts import (BrushIntervalSelector, FastIntervalSelector, BrushSelector, IndexSelector, MultiSelector, LassoSelector) @@ -152,13 +150,8 @@ def show(key=None, display_toolbar=True): figure = current_figure() else: figure = _context['figure_registry'][key] - if display_toolbar: - if not hasattr(figure, 'pyplot'): - figure.pyplot = Toolbar(figure=figure) - figure.pyplot_vbox = VBox([figure, figure.pyplot]) - display(figure.pyplot_vbox) - else: - display(figure) + figure.display_toolbar = display_toolbar + display(figure) def figure(key=None, fig=None, **kwargs): diff --git a/js/less/bqplot.less b/js/less/bqplot.less index 9f4b4f7d7..ed1ae174e 100644 --- a/js/less/bqplot.less +++ b/js/less/bqplot.less @@ -325,6 +325,12 @@ image-rendering: -moz-crisp-edges; /* this is guaranteed to work for firefox */ } } + +.bqplot .toolbar_div { + position: absolute; + transition: visibility 0.5s linear, opacity 0.5s linear; +} + .tooltip_div { z-index: 1001; } diff --git a/js/src/Figure.ts b/js/src/Figure.ts index be0c10206..5bd55c1ec 100644 --- a/js/src/Figure.ts +++ b/js/src/Figure.ts @@ -28,6 +28,7 @@ import { AxisModel } from './AxisModel'; import { Mark } from './Mark'; import { MarkModel } from './MarkModel'; import { Interaction } from './Interaction'; +import { FigureModel } from './FigureModel'; THREE.ShaderChunk['scales'] = require('raw-loader!../shaders/scales.glsl').default; @@ -102,6 +103,7 @@ export class Figure extends widgets.DOMWidgetView { protected async renderImpl() { const figureSize = this.getFigureSize(); + this.width = figureSize.width; this.height = figureSize.height; @@ -327,6 +329,20 @@ export class Figure extends widgets.DOMWidgetView { window.removeEventListener('resize', this.debouncedRelayout); }); + this.toolbar_div = this.create_toolbar(); + if (this.model.get('display_toolbar')) { + this.toolbar_div.node().style.display = 'unset'; + } + + this.model.on('change:display_toolbar', (_, display_toolbar) => { + const toolbar = this.toolbar_div.node(); + if (display_toolbar) { + toolbar.style.display = 'unset'; + } else { + toolbar.style.display = 'none'; + } + }); + return Promise.all([mark_views_updated, axis_views_updated]); } @@ -1249,6 +1265,79 @@ export class Figure extends widgets.DOMWidgetView { this.el.classList.add(this.model.get('theme')); } + /** + * Generate an integrated toolbar which is shown on mouse over + * for this figure. + * + */ + create_toolbar(): d3.Selection { + const toolbar = d3 + .select(document.createElement('div')) + .attr('class', 'toolbar_div'); + + const panzoom = document.createElement('button'); + panzoom.classList.add('jupyter-widgets'); // @jupyter-widgets/controls css + panzoom.classList.add('jupyter-button'); // @jupyter-widgets/controls css + panzoom.setAttribute('data-toggle', 'tooltip'); + panzoom.setAttribute('title', 'PanZoom'); + const panzoomicon = document.createElement('i'); + panzoomicon.style.marginRight = '0px'; + panzoomicon.className = 'fa fa-arrows'; + panzoom.appendChild(panzoomicon); + panzoom.onclick = (e) => { + e.preventDefault(); + (this.model as FigureModel).panzoom(); + }; + + const reset = document.createElement('button'); + reset.classList.add('jupyter-widgets'); // @jupyter-widgets/controls css + reset.classList.add('jupyter-button'); // @jupyter-widgets/controls css + reset.setAttribute('data-toggle', 'tooltip'); + reset.setAttribute('title', 'Reset'); + const refreshicon = document.createElement('i'); + refreshicon.style.marginRight = '0px'; + refreshicon.className = 'fa fa-refresh'; + reset.appendChild(refreshicon); + reset.onclick = (e) => { + e.preventDefault(); + (this.model as FigureModel).reset(); + }; + + const save = document.createElement('button'); + save.classList.add('jupyter-widgets'); // @jupyter-widgets/controls css + save.classList.add('jupyter-button'); // @jupyter-widgets/controls css + save.setAttribute('data-toggle', 'tooltip'); + save.setAttribute('title', 'Save'); + const saveicon = document.createElement('i'); + saveicon.style.marginRight = '0px'; + saveicon.className = 'fa fa-save'; + save.appendChild(saveicon); + save.onclick = (e) => { + e.preventDefault(); + this.save_png(undefined, undefined); + }; + + toolbar.node().appendChild(panzoom); + toolbar.node().appendChild(reset); + toolbar.node().appendChild(save); + + this.el.appendChild(toolbar.node()); + toolbar.node().style.top = `${this.margin.top / 2.0}px`; + toolbar.node().style.right = `${this.margin.right}px`; + toolbar.node().style.visibility = 'hidden'; + toolbar.node().style.opacity = '0'; + this.el.addEventListener('mouseenter', () => { + toolbar.node().style.visibility = 'visible'; + toolbar.node().style.opacity = '1'; + }); + this.el.addEventListener('mouseleave', () => { + toolbar.node().style.visibility = 'hidden'; + toolbar.node().style.opacity = '0'; + }); + toolbar.node().style.display = 'none'; + return toolbar; + } + axis_views: widgets.ViewList; bg: d3.Selection; bg_events: d3.Selection; @@ -1278,6 +1367,7 @@ export class Figure extends widgets.DOMWidgetView { svg_background: d3.Selection; title: d3.Selection; tooltip_div: d3.Selection; + toolbar_div: d3.Selection; width: number; x_pad_dict: { [id: string]: number }; xPaddingArr: { [id: string]: number }; @@ -1287,7 +1377,7 @@ export class Figure extends widgets.DOMWidgetView { private dummyNodes: Dict = {}; private _update_requested: boolean; - private relayoutRequested: boolean = false; + private relayoutRequested = false; // this is public for the test framework, but considered a private API public _initial_marks_created: Promise; diff --git a/js/src/FigureModel.ts b/js/src/FigureModel.ts index a76e89f0d..4a6f4fc1a 100644 --- a/js/src/FigureModel.ts +++ b/js/src/FigureModel.ts @@ -15,7 +15,9 @@ import * as widgets from '@jupyter-widgets/base'; import { semver_range } from './version'; - +import { Interaction } from './Interaction'; +import { PanZoomModel } from './PanZoomModel'; +import * as _ from 'underscore'; export class FigureModel extends widgets.DOMWidgetModel { defaults() { return { @@ -54,6 +56,7 @@ export class FigureModel extends widgets.DOMWidgetModel { padding_y: 0.025, legend_location: 'top-right', animation_duration: 0, + display_toolbar: true, }; } @@ -76,6 +79,88 @@ export class FigureModel extends widgets.DOMWidgetModel { this.trigger('save_png'); } + /** + * Start or stop pan zoom mode + * + */ + panzoom(): void { + if (this.panzoomData.panning) { + this.set('interaction', this.panzoomData.cached_interaction); + this.panzoomData.panning = false; + this.save_changes(); + } else { + this.panzoomData.cached_interaction = this.get('interaction'); + const panzoom = this.panzoomData.panzoom; + if (panzoom) { + this.set('interaction', panzoom); + this.save_changes(); + } else { + this.create_panzoom_model().then((model) => { + this.set('interaction', model); + this.panzoomData.panzoom = model; + this.save_changes(); + }); + } + this.panzoomData.panning = true; + } + } + + /** + * Creates a panzoom interaction widget for the this model. + * + * It will discover the relevant scales of this model. + */ + private create_panzoom_model(): Promise { + return this.widget_manager + .new_widget({ + model_name: 'PanZoomModel', + model_module: 'bqplot', + model_module_version: this.get('_model_module_version'), + view_name: 'PanZoom', + view_module: 'bqplot', + view_module_version: this.get('_view_module_version'), + }) + .then((model: PanZoomModel) => { + return Promise.all(this.get('marks')).then((marks: any[]) => { + const x_scales = [], + y_scales = []; + for (let i = 0; i < marks.length; ++i) { + const preserve_domain = marks[i].get('preserve_domain'); + const scales = marks[i].get('scales'); + _.each(scales, (v, k) => { + const dimension = marks[i].get('scales_metadata')[k]['dimension']; + if (dimension === 'x' && !preserve_domain[k]) { + x_scales.push(scales[k]); + } + if (dimension === 'y' && !preserve_domain[k]) { + y_scales.push(scales[k]); + } + }); + } + model.set('scales', { + x: x_scales, + y: y_scales, + }); + model.save_changes(); + return model; + }); + }); + } + + /** + * Reset the scales, delete the PanZoom widget, set the figure + * interaction back to its previous value. + */ + reset(): void { + this.set('interaction', this.panzoomData.cached_interaction); + const panzoom = this.panzoomData.panzoom; + panzoom.reset_scales(); + panzoom.close(); + this.panzoomData.panzoom = null; + this.panzoomData.panning = false; + this.save_changes(); + } + static serializers = { ...widgets.DOMWidgetModel.serializers, marks: { deserialize: widgets.unpack_models }, @@ -85,4 +170,10 @@ export class FigureModel extends widgets.DOMWidgetModel { scale_y: { deserialize: widgets.unpack_models }, layout: { deserialize: widgets.unpack_models }, }; + + private panzoomData: { + panning: boolean; + cached_interaction: Interaction; + panzoom: PanZoomModel | undefined; + } = { panning: false, cached_interaction: null, panzoom: undefined }; } diff --git a/js/src/Graph.ts b/js/src/Graph.ts index 74f930fae..c69537e66 100644 --- a/js/src/Graph.ts +++ b/js/src/Graph.ts @@ -370,7 +370,9 @@ export class Graph extends Mark { } private dragstarted(d: NodeData) { - if (this.model.static) return; + if (this.model.static) { + return; + } if (!d3GetEvent().active) { this.force_layout.alphaTarget(0.4).restart(); } @@ -379,13 +381,17 @@ export class Graph extends Mark { } private dragged(d: NodeData) { - if (this.model.static) return; + if (this.model.static) { + return; + } d.fx = d3GetEvent().x; d.fy = d3GetEvent().y; } private dragended(d: NodeData) { - if (this.model.static) return; + if (this.model.static) { + return; + } if (!d3GetEvent().active) { this.force_layout.alphaTarget(0.4); } @@ -573,7 +579,7 @@ export class Graph extends Mark { compute_view_padding() { const xPadding = d3.max( - this.model.mark_data.map(function (d) { + this.model.mark_data.map((d) => { return ( (d.shape_attrs.r || d.shape_attrs.width / 2 || d.shape_attrs.rx) + 1.0 ); diff --git a/js/src/GridHeatMap.ts b/js/src/GridHeatMap.ts index 6e1ea0056..a5c06088b 100644 --- a/js/src/GridHeatMap.ts +++ b/js/src/GridHeatMap.ts @@ -317,7 +317,7 @@ export class GridHeatMap extends Mark { } const clearing_style = {}; - for (let key in style_dict) { + for (const key in style_dict) { clearing_style[key] = null; } applyStyles(elements, clearing_style); diff --git a/js/src/HeatMap.ts b/js/src/HeatMap.ts index e12c60261..d9c024655 100644 --- a/js/src/HeatMap.ts +++ b/js/src/HeatMap.ts @@ -60,10 +60,14 @@ export class HeatMap extends Mark { set_positional_scales() { this.listenTo(this.scales.x, 'domain_changed', () => { - if (!this.model.dirty) this.draw(); + if (!this.model.dirty) { + this.draw(); + } }); this.listenTo(this.scales.y, 'domain_changed', () => { - if (!this.model.dirty) this.draw(); + if (!this.model.dirty) { + this.draw(); + } }); } diff --git a/js/src/HeatMapModel.ts b/js/src/HeatMapModel.ts index dea10736f..7099fb72c 100644 --- a/js/src/HeatMapModel.ts +++ b/js/src/HeatMapModel.ts @@ -63,7 +63,9 @@ export class HeatMapModel extends MarkModel { } update_domains() { - if (!this.mark_data) return; + if (!this.mark_data) { + return; + } const scales = this.get('scales'); const flat_colors = [].concat.apply( diff --git a/js/src/HistModel.ts b/js/src/HistModel.ts index 92835080c..59fa7f744 100644 --- a/js/src/HistModel.ts +++ b/js/src/HistModel.ts @@ -162,7 +162,9 @@ export class HistModel extends MarkModel { } update_domains() { - if (!this.mark_data) return; + if (!this.mark_data) { + return; + } // For histogram, changing the x-scale domain changes a lot of // things including the data which is to be plotted. So the x-domain diff --git a/js/src/PanZoom.ts b/js/src/PanZoom.ts index 205ab5f6a..e9495954b 100644 --- a/js/src/PanZoom.ts +++ b/js/src/PanZoom.ts @@ -27,7 +27,7 @@ export class PanZoom extends interaction.Interaction { const that = this; // chrome bug that requires a listener on the parent svg node // https://github.com/d3/d3-zoom/issues/231#issuecomment-802713799 - this.parent.svg.node().addEventListener(`wheel`, nop, { passive: false }); + this.parent.svg.node().addEventListener('wheel', nop, { passive: false }); this.d3el .style('cursor', 'move') .call( @@ -56,7 +56,7 @@ export class PanZoom extends interaction.Interaction { } remove() { - this.parent.svg.node().removeEventListener(`wheel`, nop); + this.parent.svg.node().removeEventListener('wheel', nop); super.remove(); } diff --git a/js/src/ScatterGL.ts b/js/src/ScatterGL.ts index 44f56abba..35b783ccc 100644 --- a/js/src/ScatterGL.ts +++ b/js/src/ScatterGL.ts @@ -114,10 +114,10 @@ class AttributeParameters { class ColorAttributeParameters extends AttributeParameters { constructor( array: TypedArray, - item_size: number = 1, - mesh_per_attribute: number = 1, - normalized: boolean = false, - use_colormap: boolean = true + item_size = 1, + mesh_per_attribute = 1, + normalized = false, + use_colormap = true ) { super(array, item_size, mesh_per_attribute, normalized); this.use_colormap = use_colormap; @@ -129,10 +129,10 @@ class ColorAttributeParameters extends AttributeParameters { class SelectionAttributeParameters extends AttributeParameters { constructor( array: TypedArray, - item_size: number = 1, - mesh_per_attribute: number = 1, - normalized: boolean = false, - use_selection: boolean = true + item_size = 1, + mesh_per_attribute = 1, + normalized = false, + use_selection = true ) { super(array, item_size, mesh_per_attribute, normalized); this.use_selection = use_selection; @@ -677,7 +677,7 @@ export class ScatterGL extends Mark { value: THREE.InstancedBufferAttribute, value_previous: THREE.InstancedBufferAttribute, new_parameters: AttributeParameters, - animate: boolean = true, + animate = true, after_animation: Function = () => {} ) { if (animate) { @@ -752,7 +752,7 @@ export class ScatterGL extends Mark { this.transition(set, after_animation, this); } - update_x(rerender: boolean = true) { + update_x(rerender = true) { const x_array = to_float_array(this.model.get('x')); const new_markers_number = Math.min(x_array.length, this.y.array.length); @@ -774,7 +774,7 @@ export class ScatterGL extends Mark { } } - update_y(rerender: boolean = true) { + update_y(rerender = true) { const y_array = to_float_array(this.model.get('y')); const new_markers_number = Math.min(this.x.array.length, y_array.length); @@ -816,7 +816,7 @@ export class ScatterGL extends Mark { } } - update_color(rerender: boolean = true) { + update_color(rerender = true) { const color_parameters = this.get_color_attribute_parameters(); this.color = this.update_attribute('color', this.color, color_parameters); this.color.normalized = color_parameters.normalized; @@ -830,7 +830,7 @@ export class ScatterGL extends Mark { } } - update_opacity(rerender: boolean = true) { + update_opacity(rerender = true) { const opacity_parameters = this.get_opacity_attribute_parameters(); [this.opacity, this.opacity_previous] = this.update_attributes( 'opacity', @@ -844,7 +844,7 @@ export class ScatterGL extends Mark { } } - update_size(rerender: boolean = true) { + update_size(rerender = true) { const size_parameters = this.get_size_attribute_parameters(); [this.size, this.size_previous] = this.update_attributes( 'size', @@ -858,7 +858,7 @@ export class ScatterGL extends Mark { } } - update_rotation(rerender: boolean = true) { + update_rotation(rerender = true) { const rotation_parameters = this.get_rotation_attribute_parameters(); [this.rotation, this.rotation_previous] = this.update_attributes( 'rotation', @@ -872,7 +872,7 @@ export class ScatterGL extends Mark { } } - update_selected(rerender: boolean = true) { + update_selected(rerender = true) { const selected_parameters = this.get_selected_attribute_parameters(); this.selected = this.update_attribute( 'selected',