From f21fe77c98dacbb916c232e710a243666c688217 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Sat, 12 Aug 2017 23:59:49 -0400 Subject: [PATCH] Revert "Merge pull request #5754 from blink1073/ipython-widget" This reverts commit 9c100c95c48e1c32ee07b83cd158b47ec4678a5d, reversing changes made to 6bed1be300ca9a9e7347657ac2f095cea6068897. The reason for doing this is that ipywidgets is still moving much too fast for Matplotlib to keep up with. The original work done in #5754 was done against ipywidgets v4, as of now the final touches are being put on v7. We are reverting back to our old inject-into-the-DOM method from `nbagg`, which is what is used by `%matplotlib notebook`. For embedding as a 'proper' widget use ipympl (github.com/matplotlib/jupyter-wigets) which will release on a schedule that matches the upstream jupyter ecosystem. closes #7695 --- lib/matplotlib/__init__.py | 10 - lib/matplotlib/backend_bases.py | 3 +- lib/matplotlib/backends/backend_nbagg.py | 289 +++++++++--------- .../backends/backend_webagg_core.py | 3 +- .../backends/web_backend/js/extension.js | 18 -- .../backends/web_backend/{js => }/mpl.js | 16 - .../web_backend/{js => }/mpl_tornado.js | 0 .../backends/web_backend/nbagg_mpl.js | 211 +++++++++++++ setup.py | 8 - setupext.py | 1 - 10 files changed, 362 insertions(+), 197 deletions(-) delete mode 100644 lib/matplotlib/backends/web_backend/js/extension.js rename lib/matplotlib/backends/web_backend/{js => }/mpl.js (98%) rename lib/matplotlib/backends/web_backend/{js => }/mpl_tornado.js (100%) create mode 100644 lib/matplotlib/backends/web_backend/nbagg_mpl.js diff --git a/lib/matplotlib/__init__.py b/lib/matplotlib/__init__.py index daf8e315f7cf..927e3582a082 100644 --- a/lib/matplotlib/__init__.py +++ b/lib/matplotlib/__init__.py @@ -1373,16 +1373,6 @@ def tk_window_focus(): return rcParams['tk.window_focus'] -# Jupyter extension paths -def _jupyter_nbextension_paths(): - return [{ - 'section': 'notebook', - 'src': 'backends/web_backend/js', - 'dest': 'matplotlib', - 'require': 'matplotlib/extension' - }] - - default_test_modules = [ 'matplotlib.tests', 'matplotlib.sphinxext.tests', diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 3d0e9f583319..f2044a50de14 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2188,8 +2188,7 @@ def print_figure(self, filename, dpi=None, facecolor=None, edgecolor=None, origfacecolor = self.figure.get_facecolor() origedgecolor = self.figure.get_edgecolor() - if dpi != 'figure': - self.figure.dpi = dpi + self.figure.dpi = dpi self.figure.set_facecolor(facecolor) self.figure.set_edgecolor(edgecolor) diff --git a/lib/matplotlib/backends/backend_nbagg.py b/lib/matplotlib/backends/backend_nbagg.py index 7274ad688879..cba19ea6d7cc 100644 --- a/lib/matplotlib/backends/backend_nbagg.py +++ b/lib/matplotlib/backends/backend_nbagg.py @@ -3,27 +3,23 @@ # lib/matplotlib/backends/web_backend/nbagg_uat.ipynb to help verify # that changes made maintain expected behaviour. +import datetime from base64 import b64encode import json import io -from tempfile import mkdtemp -import shutil import os import six from uuid import uuid4 as uuid -from IPython.display import display, HTML -from IPython import version_info +import tornado.ioloop + +from IPython.display import display, Javascript, HTML try: # Jupyter/IPython 4.x or later - from ipywidgets import DOMWidget - from traitlets import Unicode, Bool, Float, List, Any - from notebook.nbextensions import install_nbextension, check_nbextension + from ipykernel.comm import Comm except ImportError: # Jupyter/IPython 3.x or earlier - from IPython.html.widgets import DOMWidget - from IPython.utils.traitlets import Unicode, Bool, Float, List, Any - from IPython.html.nbextensions import install_nbextension + from IPython.kernel.comm import Comm from matplotlib import rcParams, is_interactive from matplotlib._pylab_helpers import Gcf @@ -33,6 +29,13 @@ from matplotlib.backend_bases import ( _Backend, FigureCanvasBase, NavigationToolbar2) from matplotlib.figure import Figure +from matplotlib import is_interactive +from matplotlib.backends.backend_webagg_core import (FigureManagerWebAgg, + FigureCanvasWebAggCore, + NavigationToolbar2WebAgg, + TimerTornado) +from matplotlib.backend_bases import (ShowBase, NavigationToolbar2, + FigureCanvasBase) def connection_info(): @@ -65,7 +68,6 @@ def connection_info(): 'zoom_to_rect': 'fa fa-square-o icon-check-empty', 'move': 'fa fa-arrows icon-move', 'download': 'fa fa-floppy-o icon-save', - 'export': 'fa fa-file-picture-o icon-picture', None: None } @@ -77,154 +79,161 @@ class NavigationIPy(NavigationToolbar2WebAgg): _FONT_AWESOME_CLASSES[image_file], name_of_method) for text, tooltip_text, image_file, name_of_method in (NavigationToolbar2.toolitems + - (('Download', 'Download plot', 'download', 'download'), - ('Export', 'Export plot', 'export', 'export'))) + (('Download', 'Download plot', 'download', 'download'),)) if image_file in _FONT_AWESOME_CLASSES] - def export(self): - buf = io.BytesIO() - self.canvas.figure.savefig(buf, format='png', dpi='figure') - # Figure width in pixels - pwidth = self.canvas.figure.get_figwidth()*self.canvas.figure.get_dpi() - # Scale size to match widget on HiPD monitors - width = pwidth/self.canvas._dpi_ratio - data = "" - data = data.format(b64encode(buf.getvalue()).decode('utf-8'), width) - display(HTML(data)) - - -class FigureCanvasNbAgg(DOMWidget, FigureCanvasWebAggCore): - _view_module = Unicode("matplotlib", sync=True) - _view_name = Unicode('MPLCanvasView', sync=True) - _toolbar_items = List(sync=True) - _closed = Bool(True) - _id = Unicode('', sync=True) - - # Must declare the superclass private members. - _png_is_old = Bool() - _force_full = Bool() - _current_image_mode = Unicode() - _dpi_ratio = Float(1.0) - _is_idle_drawing = Bool() - _is_saving = Bool() - _button = Any() - _key = Any() - _lastx = Any() - _lasty = Any() - _is_idle_drawing = Bool() - - def __init__(self, figure, *args, **kwargs): - super(FigureCanvasWebAggCore, self).__init__(figure, *args, **kwargs) - super(DOMWidget, self).__init__(*args, **kwargs) - self._uid = uuid().hex - self.on_msg(self._handle_message) - - def _handle_message(self, object, message, buffers): - # The 'supports_binary' message is relevant to the - # websocket itself. The other messages get passed along - # to matplotlib as-is. - - # Every message has a "type" and a "figure_id". - message = json.loads(message) - if message['type'] == 'closing': - self._closed = True - elif message['type'] == 'supports_binary': - self.supports_binary = message['value'] - elif message['type'] == 'initialized': - _, _, w, h = self.figure.bbox.bounds - self.manager.resize(w, h) - self.send_json('refresh') - else: - self.manager.handle_json(message) - - def send_json(self, content): - self.send({'data': json.dumps(content)}) - - def send_binary(self, blob): - # The comm is ascii, so we always send the image in base64 - # encoded data URL form. - data = b64encode(blob) - if six.PY3: - data = data.decode('ascii') - data_uri = "data:image/png;base64,{0}".format(data) - self.send({'data': data_uri}) - - def new_timer(self, *args, **kwargs): - return TimerTornado(*args, **kwargs) - - def start_event_loop(self, timeout): - FigureCanvasBase.start_event_loop_default(self, timeout) - - def stop_event_loop(self): - FigureCanvasBase.stop_event_loop_default(self) - class FigureManagerNbAgg(FigureManagerWebAgg): ToolbarCls = NavigationIPy def __init__(self, canvas, num): + self._shown = False FigureManagerWebAgg.__init__(self, canvas, num) - toolitems = [] - for name, tooltip, image, method in self.ToolbarCls.toolitems: - if name is None: - toolitems.append(['', '', '', '']) - else: - toolitems.append([name, tooltip, image, method]) - canvas._toolbar_items = toolitems - self.web_sockets = [self.canvas] + + def display_js(self): + # XXX How to do this just once? It has to deal with multiple + # browser instances using the same kernel (require.js - but the + # file isn't static?). + display(Javascript(FigureManagerNbAgg.get_javascript())) def show(self): - if self.canvas._closed: - self.canvas._closed = False - display(self.canvas) + if not self._shown: + self.display_js() + self._create_comm() else: self.canvas.draw_idle() + self._shown = True + + def reshow(self): + """ + A special method to re-show the figure in the notebook. + + """ + self._shown = False + self.show() + + @property + def connected(self): + return bool(self.web_sockets) + + @classmethod + def get_javascript(cls, stream=None): + if stream is None: + output = io.StringIO() + else: + output = stream + super(FigureManagerNbAgg, cls).get_javascript(stream=output) + with io.open(os.path.join( + os.path.dirname(__file__), + "web_backend", + "nbagg_mpl.js"), encoding='utf8') as fd: + output.write(fd.read()) + if stream is None: + return output.getvalue() + + def _create_comm(self): + comm = CommSocket(self) + self.add_web_socket(comm) + return comm def destroy(self): self._send_event('close') + # need to copy comms as callbacks will modify this list + for comm in list(self.web_sockets): + comm.on_close() + self.clearup_closed() + + def clearup_closed(self): + """Clear up any closed Comms.""" + self.web_sockets = set([socket for socket in self.web_sockets + if socket.is_open()]) + + if len(self.web_sockets) == 0: + self.canvas.close_event() + + def remove_comm(self, comm_id): + self.web_sockets = set([socket for socket in self.web_sockets + if not socket.comm.comm_id == comm_id]) + + +class FigureCanvasNbAgg(FigureCanvasWebAggCore): + def new_timer(self, *args, **kwargs): + return TimerTornado(*args, **kwargs) -def nbinstall(overwrite=False, user=True): +class CommSocket(object): """ - Copies javascript dependencies to the '/nbextensions' folder in - your IPython directory. - - Parameters - ---------- - - overwrite : bool - If True, always install the files, regardless of what may already be - installed. Defaults to False. - user : bool - Whether to install to the user's .ipython/nbextensions directory. - Otherwise do a system-wide install - (e.g. /usr/local/share/jupyter/nbextensions). Defaults to False. + Manages the Comm connection between IPython and the browser (client). + + Comms are 2 way, with the CommSocket being able to publish a message + via the send_json method, and handle a message with on_message. On the + JS side figure.send_message and figure.ws.onmessage do the sending and + receiving respectively. + """ - if (check_nbextension('matplotlib') or - check_nbextension('matplotlib', True)): - return - - # Make a temporary directory so we can wrap mpl.js in a requirejs define(). - tempdir = mkdtemp() - path = os.path.join(os.path.dirname(__file__), "web_backend") - shutil.copy2(os.path.join(path, "nbagg_mpl.js"), tempdir) - - with open(os.path.join(path, 'mpl.js')) as fid: - contents = fid.read() - - with open(os.path.join(tempdir, 'mpl.js'), 'w') as fid: - fid.write('define(["jquery"], function($) {\n') - fid.write(contents) - fid.write('\nreturn mpl;\n});') - - install_nbextension( - tempdir, - overwrite=overwrite, - symlink=False, - destination='matplotlib', - verbose=0, - **({'user': user} if version_info >= (3, 0, 0, '') else {}) - ) + def __init__(self, manager): + self.supports_binary = None + self.manager = manager + self.uuid = str(uuid()) + # Publish an output area with a unique ID. The javascript can then + # hook into this area. + display(HTML("
" % self.uuid)) + try: + self.comm = Comm('matplotlib', data={'id': self.uuid}) + except AttributeError: + raise RuntimeError('Unable to create an IPython notebook Comm ' + 'instance. Are you in the IPython notebook?') + self.comm.on_msg(self.on_message) + + manager = self.manager + self._ext_close = False + + def _on_close(close_message): + self._ext_close = True + manager.remove_comm(close_message['content']['comm_id']) + manager.clearup_closed() + + self.comm.on_close(_on_close) + + def is_open(self): + return not (self._ext_close or self.comm._closed) + + def on_close(self): + # When the socket is closed, deregister the websocket with + # the FigureManager. + if self.is_open(): + try: + self.comm.close() + except KeyError: + # apparently already cleaned it up? + pass + + def send_json(self, content): + self.comm.send({'data': json.dumps(content)}) + + def send_binary(self, blob): + # The comm is ascii, so we always send the image in base64 + # encoded data URL form. + data = b64encode(blob) + if six.PY3: + data = data.decode('ascii') + data_uri = "data:image/png;base64,{0}".format(data) + self.comm.send({'data': data_uri}) + + def on_message(self, message): + # The 'supports_binary' message is relevant to the + # websocket itself. The other messages get passed along + # to matplotlib as-is. + + # Every message has a "type" and a "figure_id". + message = json.loads(message['content']['data']) + if message['type'] == 'closing': + self.on_close() + self.manager.clearup_closed() + elif message['type'] == 'supports_binary': + self.supports_binary = message['value'] + else: + self.manager.handle_json(message) @_Backend.export diff --git a/lib/matplotlib/backends/backend_webagg_core.py b/lib/matplotlib/backends/backend_webagg_core.py index 917f4a437364..d22d00704a95 100644 --- a/lib/matplotlib/backends/backend_webagg_core.py +++ b/lib/matplotlib/backends/backend_webagg_core.py @@ -18,7 +18,7 @@ import io import json import os -import datetime +import time import warnings import numpy as np @@ -480,7 +480,6 @@ def get_javascript(cls, stream=None): with io.open(os.path.join( os.path.dirname(__file__), "web_backend", - "js", "mpl.js"), encoding='utf8') as fd: output.write(fd.read()) diff --git a/lib/matplotlib/backends/web_backend/js/extension.js b/lib/matplotlib/backends/web_backend/js/extension.js deleted file mode 100644 index be7ea701550c..000000000000 --- a/lib/matplotlib/backends/web_backend/js/extension.js +++ /dev/null @@ -1,18 +0,0 @@ - -define([], function() { - if (window.require) { - window.require.config({ - map: { - "*" : { - "matplotlib": "nbextensions/matplotlib/nbagg_mpl", - "jupyter-js-widgets": "nbextensions/jupyter-js-widgets/extension" - } - } - }); - } - - // Export the required load_ipython_extention - return { - load_ipython_extension: function() {} - }; -}); diff --git a/lib/matplotlib/backends/web_backend/js/mpl.js b/lib/matplotlib/backends/web_backend/mpl.js similarity index 98% rename from lib/matplotlib/backends/web_backend/js/mpl.js rename to lib/matplotlib/backends/web_backend/mpl.js index 6f49a96f7baa..cecebd8e0201 100644 --- a/lib/matplotlib/backends/web_backend/js/mpl.js +++ b/lib/matplotlib/backends/web_backend/mpl.js @@ -1,16 +1,4 @@ /* Put everything inside the global mpl namespace */ - -// Universal Module Definition -(function (root, factory) { - if (typeof define === 'function' && define.amd) { - // AMD - define(['jquery'], factory); - } else { - // Browser globals (root is window) - root.returnExports = factory(root.jQuery); - } -}(this, function ($) { - window.mpl = {}; @@ -564,7 +552,3 @@ mpl.figure.prototype.toolbar_button_onclick = function(name) { mpl.figure.prototype.toolbar_button_onmouseover = function(tooltip) { this.message.textContent = tooltip; }; - -return mpl; - -})); diff --git a/lib/matplotlib/backends/web_backend/js/mpl_tornado.js b/lib/matplotlib/backends/web_backend/mpl_tornado.js similarity index 100% rename from lib/matplotlib/backends/web_backend/js/mpl_tornado.js rename to lib/matplotlib/backends/web_backend/mpl_tornado.js diff --git a/lib/matplotlib/backends/web_backend/nbagg_mpl.js b/lib/matplotlib/backends/web_backend/nbagg_mpl.js new file mode 100644 index 000000000000..9471f5340d51 --- /dev/null +++ b/lib/matplotlib/backends/web_backend/nbagg_mpl.js @@ -0,0 +1,211 @@ +var comm_websocket_adapter = function(comm) { + // Create a "websocket"-like object which calls the given IPython comm + // object with the appropriate methods. Currently this is a non binary + // socket, so there is still some room for performance tuning. + var ws = {}; + + ws.close = function() { + comm.close() + }; + ws.send = function(m) { + //console.log('sending', m); + comm.send(m); + }; + // Register the callback with on_msg. + comm.on_msg(function(msg) { + //console.log('receiving', msg['content']['data'], msg); + // Pass the mpl event to the overriden (by mpl) onmessage function. + ws.onmessage(msg['content']['data']) + }); + return ws; +} + +mpl.mpl_figure_comm = function(comm, msg) { + // This is the function which gets called when the mpl process + // starts-up an IPython Comm through the "matplotlib" channel. + + var id = msg.content.data.id; + // Get hold of the div created by the display call when the Comm + // socket was opened in Python. + var element = $("#" + id); + var ws_proxy = comm_websocket_adapter(comm) + + function ondownload(figure, format) { + window.open(figure.imageObj.src); + } + + var fig = new mpl.figure(id, ws_proxy, + ondownload, + element.get(0)); + + // Call onopen now - mpl needs it, as it is assuming we've passed it a real + // web socket which is closed, not our websocket->open comm proxy. + ws_proxy.onopen(); + + fig.parent_element = element.get(0); + fig.cell_info = mpl.find_output_cell("
"); + if (!fig.cell_info) { + console.error("Failed to find cell for figure", id, fig); + return; + } + + var output_index = fig.cell_info[2] + var cell = fig.cell_info[0]; + +}; + +mpl.figure.prototype.handle_close = function(fig, msg) { + var width = fig.canvas.width/mpl.ratio + fig.root.unbind('remove') + + // Update the output cell to use the data from the current canvas. + fig.push_to_output(); + var dataURL = fig.canvas.toDataURL(); + // Re-enable the keyboard manager in IPython - without this line, in FF, + // the notebook keyboard shortcuts fail. + IPython.keyboard_manager.enable() + $(fig.parent_element).html(''); + fig.close_ws(fig, msg); +} + +mpl.figure.prototype.close_ws = function(fig, msg){ + fig.send_message('closing', msg); + // fig.ws.close() +} + +mpl.figure.prototype.push_to_output = function(remove_interactive) { + // Turn the data on the canvas into data in the output cell. + var width = this.canvas.width/mpl.ratio + var dataURL = this.canvas.toDataURL(); + this.cell_info[1]['text/html'] = ''; +} + +mpl.figure.prototype.updated_canvas_event = function() { + // Tell IPython that the notebook contents must change. + IPython.notebook.set_dirty(true); + this.send_message("ack", {}); + var fig = this; + // Wait a second, then push the new image to the DOM so + // that it is saved nicely (might be nice to debounce this). + setTimeout(function () { fig.push_to_output() }, 1000); +} + +mpl.figure.prototype._init_toolbar = function() { + var fig = this; + + var nav_element = $('
') + nav_element.attr('style', 'width: 100%'); + this.root.append(nav_element); + + // Define a callback function for later on. + function toolbar_event(event) { + return fig.toolbar_button_onclick(event['data']); + } + function toolbar_mouse_event(event) { + return fig.toolbar_button_onmouseover(event['data']); + } + + for(var toolbar_ind in mpl.toolbar_items){ + var name = mpl.toolbar_items[toolbar_ind][0]; + var tooltip = mpl.toolbar_items[toolbar_ind][1]; + var image = mpl.toolbar_items[toolbar_ind][2]; + var method_name = mpl.toolbar_items[toolbar_ind][3]; + + if (!name) { continue; }; + + var button = $(''); + button.click(method_name, toolbar_event); + button.mouseover(tooltip, toolbar_mouse_event); + nav_element.append(button); + } + + // Add the status bar. + var status_bar = $(''); + nav_element.append(status_bar); + this.message = status_bar[0]; + + // Add the close button to the window. + var buttongrp = $('
'); + var button = $(''); + button.click(function (evt) { fig.handle_close(fig, {}); } ); + button.mouseover('Stop Interaction', toolbar_mouse_event); + buttongrp.append(button); + var titlebar = this.root.find($('.ui-dialog-titlebar')); + titlebar.prepend(buttongrp); +} + +mpl.figure.prototype._root_extra_style = function(el){ + var fig = this + el.on("remove", function(){ + fig.close_ws(fig, {}); + }); +} + +mpl.figure.prototype._canvas_extra_style = function(el){ + // this is important to make the div 'focusable + el.attr('tabindex', 0) + // reach out to IPython and tell the keyboard manager to turn it's self + // off when our div gets focus + + // location in version 3 + if (IPython.notebook.keyboard_manager) { + IPython.notebook.keyboard_manager.register_events(el); + } + else { + // location in version 2 + IPython.keyboard_manager.register_events(el); + } + +} + +mpl.figure.prototype._key_event_extra = function(event, name) { + var manager = IPython.notebook.keyboard_manager; + if (!manager) + manager = IPython.keyboard_manager; + + // Check for shift+enter + if (event.shiftKey && event.which == 13) { + this.canvas_div.blur(); + event.shiftKey = false; + // Send a "J" for go to next cell + event.which = 74; + event.keyCode = 74; + manager.command_mode(); + manager.handle_keydown(event); + } +} + +mpl.figure.prototype.handle_save = function(fig, msg) { + fig.ondownload(fig, null); +} + + +mpl.find_output_cell = function(html_output) { + // Return the cell and output element which can be found *uniquely* in the notebook. + // Note - this is a bit hacky, but it is done because the "notebook_saving.Notebook" + // IPython event is triggered only after the cells have been serialised, which for + // our purposes (turning an active figure into a static one), is too late. + var cells = IPython.notebook.get_cells(); + var ncells = cells.length; + for (var i=0; i= 3 moved mimebundle to data attribute of output + data = data.data; + } + if (data['text/html'] == html_output) { + return [cell, data, j]; + } + } + } + } +} + +// Register the function which deals with the matplotlib target/channel. +// The kernel may be null if the page has been refreshed. +if (IPython.notebook.kernel != null) { + IPython.notebook.kernel.comm_manager.register_target('matplotlib', mpl.mpl_figure_comm); +} diff --git a/setup.py b/setup.py index 1bd79e687ee7..96cd6b33d17f 100644 --- a/setup.py +++ b/setup.py @@ -282,14 +282,6 @@ def run(self): ext_modules=ext_modules, package_dir=package_dir, package_data=package_data, - include_package_data=True, - data_files=[ - ('share/jupyter/nbextensions/matplotlib', [ - 'lib/matplotlib/backends/web_backend/js/extension.js', - 'lib/matplotlib/backends/web_backend/js/nbagg_mpl.js', - 'lib/matplotlib/backends/web_backend/js/mpl.js', - ]), - ], classifiers=classifiers, download_url="http://matplotlib.org/users/installing.html", diff --git a/setupext.py b/setupext.py index 03db2deaac61..9de8a6dac494 100644 --- a/setupext.py +++ b/setupext.py @@ -748,7 +748,6 @@ def get_package_data(self): 'backends/web_backend/jquery/css/themes/base/*.min.css', 'backends/web_backend/jquery/css/themes/base/images/*', 'backends/web_backend/css/*.*', - 'backends/web_backend/js/*.js', 'backends/Matplotlib.nib/*', 'mpl-data/stylelib/*.mplstyle', ]}