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', ]}