Skip to content

Commit a70af94

Browse files
committed
Added the nbAgg backend to allow interactive figures in the IPython
notebook.
1 parent 5156c10 commit a70af94

File tree

8 files changed

+567
-166
lines changed

8 files changed

+567
-166
lines changed

lib/matplotlib/backend_bases.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2514,7 +2514,7 @@ class NonGuiException(Exception):
25142514
pass
25152515

25162516

2517-
class FigureManagerBase:
2517+
class FigureManagerBase(object):
25182518
"""
25192519
Helper class for pyplot mode, wraps everything up into a neat bundle
25202520
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
"""Interactive figures in the IPython notebook"""
2+
from base64 import b64encode
3+
import json
4+
import io
5+
import os
6+
from uuid import uuid4 as uuid
7+
8+
from IPython.display import display,Javascript,HTML
9+
from IPython.kernel.comm import Comm
10+
11+
from matplotlib.figure import Figure
12+
from matplotlib.backends.backend_webagg_core import (FigureManagerWebAgg,
13+
FigureCanvasWebAggCore,
14+
NavigationToolbar2WebAgg)
15+
from matplotlib.backend_bases import ShowBase, NavigationToolbar2
16+
17+
18+
class Show(ShowBase):
19+
def __call__(self, block=None):
20+
import matplotlib._pylab_helpers as pylab_helpers
21+
22+
# XXX How to do this just once? It has to deal with multiple
23+
# browser instances using the same kernel.
24+
display(Javascript(FigureManagerNbAgg.get_javascript()))
25+
26+
queue = pylab_helpers.Gcf._activeQue
27+
for manager in queue[:]:
28+
manager.show()
29+
# If we are not interactive, disable the figure from the active queue,
30+
# but don't destroy it.
31+
queue.remove(manager)
32+
33+
show = Show()
34+
35+
36+
def draw_if_interactive():
37+
# TODO: Sort out the expected interactive interface & make it easy for
38+
# somebody to "re-show" a specific figure.
39+
pass
40+
# from matplotlib import is_interactive
41+
# import matplotlib._pylab_helpers as pylab_helpers
42+
#
43+
# if is_interactive():
44+
# figManager = pylab_helpers.Gcf.get_active()
45+
# if figManager is not None:
46+
# figManager.show()
47+
48+
49+
def connection_info():
50+
"""
51+
Return a string showing the figure and connection status for
52+
the backend.
53+
54+
"""
55+
# TODO: Make this useful!
56+
import matplotlib._pylab_helpers as pylab_helpers
57+
for manager in pylab_helpers.Gcf.get_all_fig_managers():
58+
fig = manager.canvas.figure
59+
print fig.get_label() or "Figure {0}".format(manager.num),
60+
print [socket.supports_binary for socket in manager.web_sockets],
61+
print manager.web_sockets
62+
63+
print 'Figures pending show: ', len(pylab_helpers.Gcf._activeQue)
64+
65+
66+
class NavigationIPy(NavigationToolbar2WebAgg):
67+
# Note: Version 3.2 icons, not the later 4.0 ones.
68+
# http://fontawesome.io/3.2.1/icons/
69+
_font_awesome_classes = {
70+
'home': 'icon-home',
71+
'back': 'icon-arrow-left',
72+
'forward': 'icon-arrow-right',
73+
'zoom_to_rect': 'icon-check-empty',
74+
'move': 'icon-move',
75+
None: None
76+
}
77+
78+
# Use the standard toolbar items + download button
79+
toolitems = [(text, tooltip_text, _font_awesome_classes[image_file], name_of_method)
80+
for text, tooltip_text, image_file, name_of_method
81+
in NavigationToolbar2.toolitems
82+
if image_file in _font_awesome_classes]
83+
84+
85+
class FigureManagerNbAgg(FigureManagerWebAgg):
86+
ToolbarCls = NavigationIPy
87+
88+
def __init__(self, canvas, num):
89+
self._shown = False
90+
FigureManagerWebAgg.__init__(self, canvas, num)
91+
92+
def show(self):
93+
if not self._shown:
94+
self._create_comm()
95+
self._shown = True
96+
97+
@property
98+
def connected(self):
99+
return bool(self.web_sockets)
100+
101+
@classmethod
102+
def get_javascript(cls, stream=None):
103+
if stream is None:
104+
output = io.StringIO()
105+
else:
106+
output = stream
107+
super(FigureManagerNbAgg, cls).get_javascript(stream=output)
108+
with io.open(os.path.join(
109+
os.path.dirname(__file__),
110+
"web_backend",
111+
"nbagg_mpl.js"), encoding='utf8') as fd:
112+
output.write(fd.read())
113+
if stream is None:
114+
return output.getvalue()
115+
116+
def _create_comm(self):
117+
comm = CommSocket(self)
118+
self.add_web_socket(comm)
119+
return comm
120+
121+
def destroy(self):
122+
self._send_event('close')
123+
for comm in self.web_sockets.copy():
124+
comm.on_close()
125+
126+
127+
def new_figure_manager(num, *args, **kwargs):
128+
"""
129+
Create a new figure manager instance
130+
"""
131+
FigureClass = kwargs.pop('FigureClass', Figure)
132+
thisFig = FigureClass(*args, **kwargs)
133+
return new_figure_manager_given_figure(num, thisFig)
134+
135+
136+
def new_figure_manager_given_figure(num, figure):
137+
"""
138+
Create a new figure manager instance for the given figure.
139+
"""
140+
canvas = FigureCanvasWebAggCore(figure)
141+
manager = FigureManagerNbAgg(canvas, num)
142+
return manager
143+
144+
145+
class CommSocket(object):
146+
"""
147+
Manages the Comm connection between IPython and the browser (client).
148+
149+
Comms are 2 way, with the CommSocket being able to publish a message
150+
via the send_json method, and handle a message with on_message. On the
151+
JS side figure.send_message and figure.ws.onmessage do the sending and
152+
receiving respectively.
153+
154+
"""
155+
def __init__(self, manager):
156+
self.supports_binary = None
157+
self.manager = manager
158+
self.uuid = str(uuid())
159+
display(HTML("<div id=%r></div>" % self.uuid))
160+
try:
161+
self.comm = Comm('matplotlib', data={'id': self.uuid})
162+
except AttributeError:
163+
raise RuntimeError('Unable to create an IPython notebook Comm '
164+
'instance. Are you in the IPython notebook?')
165+
self.comm.on_msg(self.on_message)
166+
167+
def on_close(self):
168+
# When the socket is closed, deregister the websocket with
169+
# the FigureManager.
170+
if self.comm in self.manager.web_sockets:
171+
self.manager.remove_web_socket(self)
172+
self.comm.close()
173+
174+
def send_json(self, content):
175+
self.comm.send({'data': json.dumps(content)})
176+
177+
def send_binary(self, blob):
178+
# The comm is ascii, so we always send the image in base64
179+
# encoded data URL form.
180+
data_uri = "data:image/png;base64,{0}".format(b64encode(blob))
181+
self.comm.send({'data': data_uri})
182+
183+
def on_message(self, message):
184+
# The 'supports_binary' message is relevant to the
185+
# websocket itself. The other messages get passed along
186+
# to matplotlib as-is.
187+
188+
# Every message has a "type" and a "figure_id".
189+
message = json.loads(message['content']['data'])
190+
if message['type'] == 'closing':
191+
self.on_close()
192+
elif message['type'] == 'supports_binary':
193+
self.supports_binary = message['value']
194+
else:
195+
self.manager.handle_json(message)

lib/matplotlib/backends/backend_webagg_core.py

Lines changed: 71 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ def get_diff_image(self):
9292
# pixels can be compared in one numpy call, rather than
9393
# needing to compare each plane separately.
9494
buff = np.frombuffer(
95-
self._renderer.buffer_rgba(), dtype=np.uint32)
95+
self.get_renderer().buffer_rgba(), dtype=np.uint32)
9696
buff.shape = (
9797
self._renderer.height, self._renderer.width)
9898

@@ -129,7 +129,7 @@ def get_diff_image(self):
129129

130130
def get_renderer(self, cleared=None):
131131
# Mirrors super.get_renderer, but caches the old one
132-
# so that we can do things such as prodce a diff image
132+
# so that we can do things such as produce a diff image
133133
# in get_diff_image
134134
_, _, w, h = self.figure.bbox.bounds
135135
key = w, h, self.figure.dpi
@@ -200,6 +200,26 @@ def handle_event(self, event):
200200
self.send_event('figure_label', label=figure_label)
201201
self._force_full = True
202202
self.draw_idle()
203+
else:
204+
handler = getattr(self, 'handle_{}'.format(e_type), None)
205+
if handler is None:
206+
import warnings
207+
warnings.warn('Unhandled message type {}. {}'.format(e_type, event))
208+
else:
209+
return handler(event)
210+
211+
def handle_resize(self, event):
212+
x, y = event.get('width', 800), event.get('height', 800)
213+
x, y = int(x), int(y)
214+
fig = self.figure
215+
# An attempt at approximating the figure size in pixels.
216+
fig.set_size_inches(x / fig.dpi, y / fig.dpi)
217+
218+
_, _, w, h = self.figure.bbox.bounds
219+
# Acknowledge the resize, and force the viewer to update the canvas size to the
220+
# figure's new size (which is hopefully identical or within a pixel or so).
221+
self._png_is_old = True
222+
self.manager.resize(w, h)
203223

204224
def send_event(self, event_type, **kwargs):
205225
self.manager._send_event(event_type, **kwargs)
@@ -216,7 +236,54 @@ def stop_event_loop(self):
216236
backend_bases.FigureCanvasBase.stop_event_loop_default.__doc__
217237

218238

239+
class NavigationToolbar2WebAgg(backend_bases.NavigationToolbar2):
240+
_jquery_icon_classes = {
241+
'home': 'ui-icon ui-icon-home',
242+
'back': 'ui-icon ui-icon-circle-arrow-w',
243+
'forward': 'ui-icon ui-icon-circle-arrow-e',
244+
'zoom_to_rect': 'ui-icon ui-icon-search',
245+
'move': 'ui-icon ui-icon-arrow-4',
246+
'download': 'ui-icon ui-icon-disk',
247+
None: None,
248+
}
249+
250+
# Use the standard toolbar items + download button
251+
toolitems = [(text, tooltip_text, _jquery_icon_classes[image_file], name_of_method)
252+
for text, tooltip_text, image_file, name_of_method
253+
in (backend_bases.NavigationToolbar2.toolitems +
254+
(('Download', 'Download plot', 'download', 'download'),))
255+
if image_file in _jquery_icon_classes]
256+
257+
def _init_toolbar(self):
258+
self.message = ''
259+
self.cursor = 0
260+
261+
def set_message(self, message):
262+
if message != self.message:
263+
self.canvas.send_event("message", message=message)
264+
self.message = message
265+
266+
def set_cursor(self, cursor):
267+
if cursor != self.cursor:
268+
self.canvas.send_event("cursor", cursor=cursor)
269+
self.cursor = cursor
270+
271+
def dynamic_update(self):
272+
self.canvas.draw_idle()
273+
274+
def draw_rubberband(self, event, x0, y0, x1, y1):
275+
self.canvas.send_event(
276+
"rubberband", x0=x0, y0=y0, x1=x1, y1=y1)
277+
278+
def release_zoom(self, event):
279+
super(NavigationToolbar2WebAgg, self).release_zoom(event)
280+
self.canvas.send_event(
281+
"rubberband", x0=-1, y0=-1, x1=-1, y1=-1)
282+
283+
219284
class FigureManagerWebAgg(backend_bases.FigureManagerBase):
285+
ToolbarCls = NavigationToolbar2WebAgg
286+
220287
def __init__(self, canvas, num):
221288
backend_bases.FigureManagerBase.__init__(self, canvas, num)
222289

@@ -228,7 +295,7 @@ def show(self):
228295
pass
229296

230297
def _get_toolbar(self, canvas):
231-
toolbar = NavigationToolbar2WebAgg(canvas)
298+
toolbar = self.ToolbarCls(canvas)
232299
return toolbar
233300

234301
def resize(self, w, h):
@@ -275,7 +342,7 @@ def get_javascript(cls, stream=None):
275342
output.write(fd.read())
276343

277344
toolitems = []
278-
for name, tooltip, image, method in NavigationToolbar2WebAgg.toolitems:
345+
for name, tooltip, image, method in cls.ToolbarCls.toolitems:
279346
if name is None:
280347
toolitems.append(['', '', '', ''])
281348
else:
@@ -306,52 +373,3 @@ def _send_event(self, event_type, **kwargs):
306373
payload.update(kwargs)
307374
for s in self.web_sockets:
308375
s.send_json(payload)
309-
310-
311-
class NavigationToolbar2WebAgg(backend_bases.NavigationToolbar2):
312-
_jquery_icon_classes = {
313-
'home': 'ui-icon ui-icon-home',
314-
'back': 'ui-icon ui-icon-circle-arrow-w',
315-
'forward': 'ui-icon ui-icon-circle-arrow-e',
316-
'zoom_to_rect': 'ui-icon ui-icon-search',
317-
'move': 'ui-icon ui-icon-arrow-4',
318-
'download': 'ui-icon ui-icon-disk',
319-
None: None
320-
}
321-
322-
323-
# Use the standard toolbar items + download button
324-
toolitems = [(text, tooltip_text, _jquery_icon_classes[image_file], name_of_method)
325-
for text, tooltip_text, image_file, name_of_method
326-
in (backend_bases.NavigationToolbar2.toolitems +
327-
(('Download', 'Download plot', 'download', 'download'),))
328-
if image_file in _jquery_icon_classes]
329-
330-
def _init_toolbar(self):
331-
self.message = ''
332-
self.cursor = 0
333-
334-
def set_message(self, message):
335-
if message != self.message:
336-
self.canvas.send_event("message", message=message)
337-
self.message = message
338-
339-
def set_cursor(self, cursor):
340-
if cursor != self.cursor:
341-
self.canvas.send_event("cursor", cursor=cursor)
342-
self.cursor = cursor
343-
344-
def dynamic_update(self):
345-
self.canvas.draw_idle()
346-
347-
def draw_rubberband(self, event, x0, y0, x1, y1):
348-
self.canvas.send_event(
349-
"rubberband", x0=x0, y0=y0, x1=x1, y1=y1)
350-
351-
def release_zoom(self, event):
352-
super(NavigationToolbar2WebAgg, self).release_zoom(event)
353-
self.canvas.send_event(
354-
"rubberband", x0=-1, y0=-1, x1=-1, y1=-1)
355-
356-
FigureCanvas = FigureCanvasWebAggCore
357-
FigureManager = FigureManagerWebAgg

0 commit comments

Comments
 (0)