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..1a0d969d521 --- /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({task: 'ping'}, 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, message.plotlyDomain); + + 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..17e26817a53 --- /dev/null +++ b/plotly/widgets/graph_widget.py @@ -0,0 +1,195 @@ +from collections import deque +import json +import os +import random +import string + +# TODO: protected imports? +from IPython.html import widgets +from IPython.utils.traitlets import Unicode +from IPython.display import Javascript, display + +import plotly + +__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): + 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: + 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 = {'task': 'restyle', 'update': data, 'graphId': self._graphId} + if traces: + message['traces'] = traces + self._handle_outgoing_message(message) + + def relayout(self, layout): + message = { + 'task': 'relayout', 'update': layout, 'graphId': self._graphId + } + self._handle_outgoing_message(message) + + def hover(self, hover_obj): + message = { + 'task': 'hover', 'event': 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. + + """ + message = { + 'task': 'addTraces', 'traces': traces, 'graphId': self._graphId + } + if new_indices is not None: + message['newIndices'] = new_indices + 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 = { + 'task': 'deleteTraces', + 'indices': indices, + 'graphId': self._graphId + } + 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. + + """ + message = { + 'task': 'moveTraces', + 'currentIndices': current_indices, + 'graphId': self._graphId + } + if new_indices is not None: + message['newIndices'] = new_indices + self._handle_outgoing_message(message)