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)