niloch / iplotter

JavaScript charting in ipython/jupyter notebooks -
http://niloch.github.io/iplotter/
MIT License
85 stars 10 forks source link

Python-JS binding (callback) #12

Closed josefkorbel closed 5 years ago

josefkorbel commented 5 years ago

Hello!

I am back again with another issue I would love to solve. After I solved Google Calendar Chart (#11) I was trying to make a callback function when user clicks on given day.

This is how I altered the template in order to achieve this.

<script type='text/javascript'>
    google.charts.load('current', {'packages':['{{ chart_package}}']});
    google.charts.setOnLoadCallback(drawChart);

    function drawChart() {            

        var data = new google.visualization.DataTable();

        data.addColumn({ type: 'date', id: 'Date' });
        data.addColumn({ type: 'number', id: 'Records' });

        {% for row in data %}
            data.addRow([new Date({{row[0]}}, {{row[1]}}, {{row[2]}}), {{row[3]}}]);
        {% endfor %}

        var chart = new google.visualization.{{chart_type}}(document.getElementById('{{div_id}}'));

        google.visualization.events.addListener(chart, 'select', selectHandler);

        // Click handler
        function selectHandler(e) {

            // Get item clicked
            var info = chart.getSelection();

            // Call python callback function with this date
            var unix_ts = info[0].date;              

            var kernel = IPython.notebook.kernel;                        
            var something = "print(1)";

            // Execute Python command
            kernel.execute(something);

            // Log to console
            console.log('Clicked timestamp: ' + new Date(info[0].date));
        }

        chart.draw(data, {{options}});
    }
</script>

This works well for logging to JS console. But when I want to execute the python command, it shows a red rectangle on top of the chart saying IPython is not defined. However when I open developer console in google chrome, the IPython objects exist and is accessible. Any ideas how this could be solved?

niloch commented 5 years ago

Interesting. I'm not a super expert in Javascript, but I've played with your code and noticed some things:

  1. You use the the {% for row in data %} Jinja templating syntax, which treats the data variable as python object to iterate over and converts each element to a string. However your data was already converted to a string by the json.dumps part in the render function here:

    return Template(head + self.template).render(
        div_id=div_id.replace(" ", "_"),
        data=json.dumps(data, indent=4).replace("'", "\\'").replace('"', "'"),
        chart_type=chart_type,
        chart_package=chart_package,
        options=json.dumps(options, indent=4).replace("'", "\\'").replace('"', "'"),
    )

    When I used the plotter.save and looked at the html file, I see many new Date objects with empty values and brackets, because Jinja was iterating over the jsonified string form of the data with bunch of newlines etc, not a python list of lists. It embeds the data into the javascript like this: data.addRow([new Date( , , ), ]);

        data.addRow([new Date( , , ), ]);
    
        data.addRow([new Date( , , ), ]);
    
        data.addRow([new Date(2, , ), ]);
    
        data.addRow([new Date(0, , ), ]);
    
        data.addRow([new Date(1, , ), ]);
    
        data.addRow([new Date(8, , ), ]);       

    So, I'm trying to figure out how you got it to work at all in #11 as I can't replicate your basic example.

  2. It's a bit confusing with a name collision on data being set as a javascript variable var data = new google.visualization.DataTable(); and iterating over the python string object named data

    {% for row in data %}
        data.addRow([new Date({{row[0]}}, {{row[1]}}, {{row[2]}}), {{row[3]}}]);
    {% endfor %}
  3. You reference your selectHandler listener here google.visualization.events.addListener(chart, 'select', selectHandler); before it is defined just below it. Not sure if this is a deal-breaker in javascript but it would be in python.

  4. The plots are being rendered in Iframes, which are isolated from the global notebook Javascript context so, it makes sense that the IPython kernel is not available. I don't think this libary lends itself to easily sending messages back to the Python runtime from the chart. Maybe writing some more custom javascript to work with them embedded data would meet your needs.

  5. I was able to get the even listener to work though for click events logged to the console with this complete example. Also note var tableRow = [new Date(row[0], row[1]-1, row[2]), row[3]] has a row[2]-1 because Javascript Date objects have month ranges from 0-11 instead of 1-12.


from iplotter import GCPlotter

plotter = GCPlotter()

data = [[2018, 8, 30, 0],
 [2018, 8, 29, 0],
 [2018, 8, 28, 0],
 [2018, 8, 27, 10],
 [2018, 8, 26, 23],
 [2018, 8, 25, 0],
 [2018, 8, 24, 0],
 [2018, 8, 23, 0],
 [2018, 8, 22, 0],
 [2018, 8, 21, 0],
 [2018, 8, 20, 1],
 [2018, 8, 19, 1],
 [2018, 8, 18, 1]]

template = """
<div id={{div_id}} style='width: 100%; height: 100%' ></div>
<script type='text/javascript'>
    google.charts.load('current', {'packages':['{{ chart_package}}']});
    google.charts.setOnLoadCallback(drawChart);

    function drawChart() {

        var json_data = {{data}}

var table = []

        var dataTable = new google.visualization.DataTable();
        dataTable.addColumn({ type: 'date', id: json_data[0][0] });
        dataTable.addColumn({ type: 'number', id: json_data[0][1]});

        for (i=1;i<json_data.length;i++) {
            var row = json_data[i];

            var tableRow = [new Date(row[0], row[1]-1, row[2]), row[3]]
            table.push(tableRow);
        }

        console.log(table);

        dataTable.addRows(table);

        var chart = new google.visualization.Calendar(document.getElementById('chart'));

        chart.draw(dataTable, {{options}});

        // Click handler
        function selectHandler(e) {

            // Get item clicked
            var info = chart.getSelection();

            // Log to console
            console.log('Clicked timestamp: ' + new Date(info[0].date));
        }

        google.visualization.events.addListener(chart, 'select', selectHandler);

    }
</script>

"""

options = {
    "width": 600,
    "height": 400
}

plotter.template = template

plotter.plot(data, chart_type="Calendar",chart_package='calendar', options=options)
josefkorbel commented 5 years ago

Hello @niloch thanks again for your reply and time.

I am aware of the fact that the charts are rendered to an IFrame, and I see that there could simply not be a way to achieve this, but I might need to switch back plotly or seaborn again because I need this callback feature, was just loving the fact that it is easily replicatable on frontend.

However your data was already converted to a string by the json.dumps part in the render function

I actually rewrote the return statement to this, so I am no longer dumping the data to json:

return Template(self.head + self.template).render(
    div_id=div_id.replace(" ", "_"),
    data=data,
    chart_type=chart_type,
    chart_package=chart_package,
    options=json.dumps(
        options, indent=4).replace("'", "\\'").replace('"', "'"))


It's a bit confusing with a name collision on data being set as a javascript variable var data = new google.visualization.DataTable(); and iterating over the python string object named data

That is true, however I guess the Jinja is smart enough to treat those values diferently as they are in double curly brackets, might try.


You reference your selectHandler listener here google.visualization.events.addListener(chart, 'select', selectHandler); before it is defined just below it.

The same is actually happening here, so I dont think this might be an issue.

google.charts.setOnLoadCallback(drawChart);

function drawChart() {}


The plots are being rendered in Iframes, which are isolated from the global notebook Javascript context

Yes that is what im scared of, maybe I could take a look at something like window.postMessage()


Javascript Date objects have month ranges from 0-11 instead of 1-12.

I am decreasing the month value by one already in my python code, but after second look I see that it makes no sense and should be placed inside the JS as this is JS-specific thing.

Will investigate further and will let you know, thanks for colaborating !

josefkorbel commented 5 years ago

Horaay, I managed to get data back to python using window.postMessage() API.

Edited event handler:

function selectHandler(e) {

    // Get item clicked
    var info = chart.getSelection();

    if (info[0] !== undefined) {
        // Call python callback function with this date
        var unix_ts = info[0].date;    

        // Sending Data to parent !
        window.top.postMessage(unix_ts, '*')

        // Log to console
        console.log('Clicked timestamp: ' + new Date(info[0].date));
    }
}

Edited part of iplotter:

def link_callback(self, callback):
    """ Function for linking IFrame and parent jupyter window using PostMessage API
    """
    js = \
    """
        var kernel = IPython.notebook.kernel;

        window.onmessage = function(e){{
            var command = "{callback}(" + e.data + ")";
            console.log(command);
            kernel.execute(command);
        }}
    """.format(callback=callback.__name__)
    display(Javascript(js))

So now, before plotting, I can pass any function to link_callback.

Example callback:

clicks = []

def callback_without_stdout(x):
    clicks.append(x)

Usage

plotter.link_callback(callback_with_stdout)

After clicking to few days in the Calendar char from GoogleCharts, this is the clicks list, which contains unix times of days that I've clicked on ! Miracle ! :dancer:

 In [6] : clicks
Out [6] : 
[1529020800000,
 1525996800000,
 1525132800000,
 1520467200000,
 1519776000000,
 1520640000000,
 1518825600000,
 1517616000000,
 1516406400000]

Now working on version of callback with stdout (printing to notebook) using ipywidgets.widgets.Output()

Cheers!

niloch commented 5 years ago

Wow Awesome! If you think there is an elegant and reuseble way to add this capability to other chart libraries, I'd be happy to accept a pull request! Thanks!