From 9b812f3ff3fa636cdf7553f5d8155318f959dbda Mon Sep 17 00:00:00 2001 From: Chris P Date: Wed, 31 Dec 2014 12:42:07 -0500 Subject: [PATCH 1/5] first pass at plotly-ipython graph widget --- plotly/widgets/__init__.py | 1 + plotly/widgets/graphWidget.js | 105 ++++++++++++++++++++++++++ plotly/widgets/graph_widget.py | 130 +++++++++++++++++++++++++++++++++ 3 files changed, 236 insertions(+) create mode 100644 plotly/widgets/__init__.py create mode 100644 plotly/widgets/graphWidget.js create mode 100644 plotly/widgets/graph_widget.py diff --git a/plotly/widgets/__init__.py b/plotly/widgets/__init__.py new file mode 100644 index 00000000000..49b087f17b7 --- /dev/null +++ b/plotly/widgets/__init__.py @@ -0,0 +1 @@ +from . graph_widget import Graph diff --git a/plotly/widgets/graphWidget.js b/plotly/widgets/graphWidget.js new file mode 100644 index 00000000000..4cdf717e686 --- /dev/null +++ b/plotly/widgets/graphWidget.js @@ -0,0 +1,105 @@ +window.genUID = function() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8); + return v.toString(16); + }); +}; + +require(["widgets/js/widget"], function(WidgetManager){ + + var GraphView = IPython.DOMWidgetView.extend({ + render: function(){ + var that = this; + + var frameId = window.genUID(); + var loadingId = 'loading-'+frameId; + + + var _graph_url = that.model.get('_graph_url'); + + // variable plotly_domain in the case of enterprise + var url_parts = _graph_url.split('/'); + var plotly_domain = url_parts[0] + '//' + url_parts[2]; + + // Place IFrame in output cell div `$el` + that.$el.css('width', '100%'); + that.$graph = $([''].join(' ')); + that.$graph.appendTo(that.$el); + + that.$loading = $('
Initializing...
') + .appendTo(that.$el); + + // initialize communication with the iframe + if(!('pingers' in window)){ + window.pingers = {}; + } + + window.pingers[frameId] = setInterval(function() { + that.graphContentWindow = $('#'+frameId)[0].contentWindow; + that.graphContentWindow.postMessage({ping: true}, plotly_domain); + }, 200); + + // Assign a message listener to the 'message' events + // from iframe's postMessage protocol. + // Filter the messages by iframe src so that the right message + // gets passed to the right widget + if(!('messageListeners' in window)){ + window.messageListeners = {}; + } + + window.messageListeners[frameId] = function(e) { + if(_graph_url.indexOf(e.origin)>-1) { + var frame = document.getElementById(frameId); + + if(frame === null){ + // frame doesn't exist in the dom anymore, clean up it's old event listener + window.removeEventListener('message', window.messageListeners[frameId]); + clearInterval(window.pingers[frameId]); + } else if(frame.contentWindow === e.source) { + // TODO: Stop event propagation, so each frame doesn't listen and filter + var frameContentWindow = $('#'+frameId)[0].contentWindow; + var message = e.data; + + if(message==='pong') { + $('#loading-'+frameId).hide(); + clearInterval(window.pingers[frameId]); + that.send({event: 'pong', graphId: frameId}); + } else if (message.type==='hover' || + message.type==='zoom' || + message.type==='click' || + message.type==='unhover') { + that.send({event: message.type, message: message, graphId: frameId}); + } + } + } + }; + + window.removeEventListener('message', window.messageListeners[frameId]); + window.addEventListener('message', window.messageListeners[frameId]); + + }, + + update: function() { + // Listen for messages from the graph widget in python + var message = this.model.get('_message'); + + message = JSON.parse(message); + + var plot = $('#'+message.graphId)[0].contentWindow; + plot.postMessage(message, 'https://plot.ly'); + + return GraphView.__super__.update.apply(this); + } + }); + + // Register the GraphView with the widget manager. + WidgetManager.register_widget_view('GraphView', GraphView); +}); + +//@ sourceURL=graphWidget.js diff --git a/plotly/widgets/graph_widget.py b/plotly/widgets/graph_widget.py new file mode 100644 index 00000000000..2ac5c504b40 --- /dev/null +++ b/plotly/widgets/graph_widget.py @@ -0,0 +1,130 @@ +from collections import deque +import json +import os + +# TODO: protected imports? +from IPython.html import widgets +from IPython.utils.traitlets import Unicode +from IPython.display import Javascript, display + +__all__ = None + +class Graph(widgets.DOMWidget): + """An interactive Plotly graph widget for use in IPython + Notebooks. + """ + _view_name = Unicode('GraphView', sync=True) + _message = Unicode(sync=True) + _graph_url = Unicode(sync=True) + + def __init__(self, graph_url, **kwargs): + """Initialize a plotly graph object. + Parameters + ---------- + graph_url: The url of a Plotly graph + + Examples + -------- + GraphWidget('https://plot.ly/~chris/3375') + """ + directory = os.path.dirname(os.path.realpath(__file__)) + js_widget_file = os.path.join(directory, 'graphWidget.js') + with open(js_widget_file) as f: + js_widget_code = f.read() + + display(Javascript(js_widget_code)) + + super(Graph, self).__init__(**kwargs) + + # TODO: Validate graph_url + self._graph_url = graph_url + self._listener_set = set() + self._event_handlers = { + 'click': widgets.CallbackDispatcher(), + 'hover': widgets.CallbackDispatcher(), + 'zoom': widgets.CallbackDispatcher() + } + + self._graphId = '' + self.on_msg(self._handle_msg) + + # messages to the iframe client need to wait for the + # iframe to communicate that it is ready + # unfortunately, this two-way blocking communication + # isn't possible (https://github.com/ipython/ipython/wiki/IPEP-21:-Widget-Messages#caveats) + # so we'll just cue up messages until they're ready to be sent + self._clientMessages = deque() + + def _handle_msg(self, message): + """Handle a msg from the front-end. + Parameters + ---------- + content: dict + Content of the msg.""" + content = message['content']['data']['content'] + if content.get('event', '') == 'pong': + self._graphId = content['graphId'] + + # ready to recieve - pop out all of the items in the deque + while self._clientMessages: + _message = self._clientMessages.popleft() + _message['graphId'] = self._graphId + _message = json.dumps(_message) + self._message = _message + + if content.get('event', '') in ['click', 'hover', 'zoom']: + self._event_handlers[content['event']](self, content) + + def _handle_registration(self, event_type, callback, remove): + self._event_handlers[event_type].register_callback(callback, + remove=remove) + event_callbacks = self._event_handlers[event_type].callbacks + if (len(event_callbacks) and event_type not in self._listener_set): + self._listener_set.add(event_type) + message = {'listen': list(self._listener_set)} + self._handle_outgoing_message(message) + + def _handle_outgoing_message(self, message): + if self._graphId == '': + self._clientMessages.append(message) + else: + message['graphId'] = self._graphId + self._message = json.dumps(message) + + def on_click(self, callback, remove=False): + """Register a callback to execute when the graph is clicked. + Parameters + ---------- + remove : bool (optional) + Set to true to remove the callback from the list of callbacks.""" + self._handle_registration('click', callback, remove) + + def on_hover(self, callback, remove=False): + """Register a callback to execute when you hover over points in the graph. + Parameters + ---------- + remove : bool (optional) + Set to true to remove the callback from the list of callbacks.""" + self._handle_registration('hover', callback, remove) + + def on_zoom(self, callback, remove=False): + """Register a callback to execute when you zoom in the graph. + Parameters + ---------- + remove : bool (optional) + Set to true to remove the callback from the list of callbacks.""" + self._handle_registration('zoom', callback, remove) + + def restyle(self, data, traces=None): + message = {'restyle': data, 'graphId': self._graphId} + if traces: + message['traces'] = traces + self._handle_outgoing_message(message) + + def relayout(self, layout): + message = {'relayout': layout, 'graphId': self._graphId} + self._handle_outgoing_message(message) + + def hover(self, hover_obj): + message = {'hover': hover_obj, 'graphId': self._graphId} + self._handle_outgoing_message(message) From 0f14ee183f6669faef4b0669fdd2022c38e3b572 Mon Sep 17 00:00:00 2001 From: Andrew Seier Date: Thu, 8 Jan 2015 22:16:28 -0800 Subject: [PATCH 2/5] Update widget methods with new functionality. --- plotly/widgets/graph_widget.py | 46 ++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/plotly/widgets/graph_widget.py b/plotly/widgets/graph_widget.py index 2ac5c504b40..de99b8257ad 100644 --- a/plotly/widgets/graph_widget.py +++ b/plotly/widgets/graph_widget.py @@ -128,3 +128,49 @@ def relayout(self, layout): def hover(self, hover_obj): message = {'hover': hover_obj, 'graphId': self._graphId} self._handle_outgoing_message(message) + + def add_traces(self, traces, new_indices=None): + """ + Add new data traces to a graph. + + If `new_indices` isn't specified, they are simply appended. + + :param (list[dict]) traces: The list of trace dicts + :param (list[int]|None|optional) new_indices: The final indices the + added traces should occupy. + + """ + body = {'traces': traces} + if new_indices is not None: + body['newIndices'] = new_indices + message = {'addTraces': body} + self._handle_outgoing_message(message) + + def delete_traces(self, indices): + """ + Delete data traces from a graph. + + :param (list[int]) indices: The indices of the traces to be removed + + """ + message = {'deleteTraces': {'indices': indices}} + self._handle_outgoing_message(message) + + def move_traces(self, current_indices, new_indices=None): + """ + Move data traces around in a graph. + + If new_indices isn't specified, the traces at the locations specified + in current_indices are moved to the end of the data array. + + :param (list[int]) current_indices: The initial indices the traces to + be moved occupy. + :param (list[int]|None|optional) new_indices: The final indices the + traces to be moved will occupy. + + """ + body = {'currentIndices': current_indices} + if new_indices is not None: + body['newIndices'] = new_indices + message = {'moveTraces': body} + self._handle_outgoing_message(message) From 695dd285286ca7e0fcf93b1892e444b38151a6b5 Mon Sep 17 00:00:00 2001 From: Andrew Seier Date: Sat, 10 Jan 2015 13:25:32 -0800 Subject: [PATCH 3/5] Handle communication with local plotly. --- plotly/widgets/graphWidget.js | 2 +- plotly/widgets/graph_widget.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/plotly/widgets/graphWidget.js b/plotly/widgets/graphWidget.js index 4cdf717e686..284182134bb 100644 --- a/plotly/widgets/graphWidget.js +++ b/plotly/widgets/graphWidget.js @@ -92,7 +92,7 @@ require(["widgets/js/widget"], function(WidgetManager){ message = JSON.parse(message); var plot = $('#'+message.graphId)[0].contentWindow; - plot.postMessage(message, 'https://plot.ly'); + plot.postMessage(message, message.plotlyDomain); return GraphView.__super__.update.apply(this); } diff --git a/plotly/widgets/graph_widget.py b/plotly/widgets/graph_widget.py index de99b8257ad..453b92884ae 100644 --- a/plotly/widgets/graph_widget.py +++ b/plotly/widgets/graph_widget.py @@ -7,6 +7,8 @@ from IPython.utils.traitlets import Unicode from IPython.display import Javascript, display +import plotly + __all__ = None class Graph(widgets.DOMWidget): @@ -85,6 +87,7 @@ def _handle_registration(self, event_type, callback, remove): self._handle_outgoing_message(message) def _handle_outgoing_message(self, message): + message['plotlyDomain'] = plotly.plotly.get_config()['plotly_domain'] if self._graphId == '': self._clientMessages.append(message) else: From b66b0175cce6cdd655efec4439ca398e6454d594 Mon Sep 17 00:00:00 2001 From: Andrew Seier Date: Sat, 10 Jan 2015 13:29:01 -0800 Subject: [PATCH 4/5] Update syntax for new plotly js code. --- plotly/widgets/graphWidget.js | 2 +- plotly/widgets/graph_widget.py | 32 ++++++++++++++++++++++---------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/plotly/widgets/graphWidget.js b/plotly/widgets/graphWidget.js index 284182134bb..1a0d969d521 100644 --- a/plotly/widgets/graphWidget.js +++ b/plotly/widgets/graphWidget.js @@ -42,7 +42,7 @@ require(["widgets/js/widget"], function(WidgetManager){ window.pingers[frameId] = setInterval(function() { that.graphContentWindow = $('#'+frameId)[0].contentWindow; - that.graphContentWindow.postMessage({ping: true}, plotly_domain); + that.graphContentWindow.postMessage({task: 'ping'}, plotly_domain); }, 200); // Assign a message listener to the 'message' events diff --git a/plotly/widgets/graph_widget.py b/plotly/widgets/graph_widget.py index 453b92884ae..a88953f5818 100644 --- a/plotly/widgets/graph_widget.py +++ b/plotly/widgets/graph_widget.py @@ -119,17 +119,21 @@ def on_zoom(self, callback, remove=False): self._handle_registration('zoom', callback, remove) def restyle(self, data, traces=None): - message = {'restyle': data, 'graphId': self._graphId} + message = {'task': 'restyle', 'update': data, 'graphId': self._graphId} if traces: message['traces'] = traces self._handle_outgoing_message(message) def relayout(self, layout): - message = {'relayout': layout, 'graphId': self._graphId} + message = { + 'task': 'relayout', 'update': layout, 'graphId': self._graphId + } self._handle_outgoing_message(message) def hover(self, hover_obj): - message = {'hover': hover_obj, 'graphId': self._graphId} + message = { + 'task': 'hover', 'event': hover_obj, 'graphId': self._graphId + } self._handle_outgoing_message(message) def add_traces(self, traces, new_indices=None): @@ -143,10 +147,11 @@ def add_traces(self, traces, new_indices=None): added traces should occupy. """ - body = {'traces': traces} + message = { + 'task': 'addTraces', 'traces': traces, 'graphId': self._graphId + } if new_indices is not None: - body['newIndices'] = new_indices - message = {'addTraces': body} + message['newIndices'] = new_indices self._handle_outgoing_message(message) def delete_traces(self, indices): @@ -156,7 +161,11 @@ def delete_traces(self, indices): :param (list[int]) indices: The indices of the traces to be removed """ - message = {'deleteTraces': {'indices': indices}} + message = { + 'task': 'deleteTraces', + 'indices': indices, + 'graphId': self._graphId + } self._handle_outgoing_message(message) def move_traces(self, current_indices, new_indices=None): @@ -172,8 +181,11 @@ def move_traces(self, current_indices, new_indices=None): traces to be moved will occupy. """ - body = {'currentIndices': current_indices} + message = { + 'task': 'moveTraces', + 'currentIndices': current_indices, + 'graphId': self._graphId + } if new_indices is not None: - body['newIndices'] = new_indices - message = {'moveTraces': body} + message['newIndices'] = new_indices self._handle_outgoing_message(message) From 964d1933554e71cd0b3b7305f27f10fa427e3546 Mon Sep 17 00:00:00 2001 From: Andrew Seier Date: Sat, 10 Jan 2015 13:30:40 -0800 Subject: [PATCH 5/5] =?UTF-8?q?Tricky=20double-sending=20fix=E2=80=A6=20(m?= =?UTF-8?q?ore)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All of our messages seem to be being sent twice! Not sure where that’s coming from. This is a temporary fix that synced with the new frontend postMessage hopefully we can revert this. --- plotly/widgets/graph_widget.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plotly/widgets/graph_widget.py b/plotly/widgets/graph_widget.py index a88953f5818..17e26817a53 100644 --- a/plotly/widgets/graph_widget.py +++ b/plotly/widgets/graph_widget.py @@ -1,6 +1,8 @@ from collections import deque import json import os +import random +import string # TODO: protected imports? from IPython.html import widgets @@ -88,6 +90,8 @@ def _handle_registration(self, event_type, callback, remove): def _handle_outgoing_message(self, message): message['plotlyDomain'] = plotly.plotly.get_config()['plotly_domain'] + message['taskID'] = ''.join([random.choice(string.ascii_letters) + for _ in range(20)]) if self._graphId == '': self._clientMessages.append(message) else: