Pictograms with Plotly and FontAwesome

Saturday, 12 October 2019

Pictograms with Plotly and FontAwesome

Plotly is one of the most powerful graphing packages for Python, JS and Julia. The cool feature is that the graphs are HTML bases with interactive graphs as opposed to a static jpg. There are several graphs that are missing, one of which is a pictogram. It's not a very silly graph, but  Luckily a pictogram is easy-ish to make.


Pictogram

A pictogram or pictograph is a graph where icons represent quantity. They are common form of infographics (=stylised images representing data).

They come in various forms, such as barchart-like and waffle chart-like.

Both requires the same trick, so I will do one of the latter (example pictured).

It technically is not a waffle chart as it is not formed by rectangles that fit together in a satisfying way, but distict icons.

Icons

FontAwesome is  a great CSS library full of really fun icons. It is loaded already in a Jupyter notebook for the menu buttons*.

* Except as version 4.7 as opposed to 5.11. If you add version 5.11 to a Jupyter notebook all your icons on the menu go weird. But nothing bad happens.

Python, Jupyter and JS

Jupyter notebooks are a great way to work on one-off Python projects. A cool feature is that the Python kernel and the JavaScript one can interact.
Plotly for Python is a wrapper to the JS version, so to do some alterations one has to inject JS code. Which means that describing how to make a graph in Python describes how to do one in JavaScript. So I'll talk Python.
In a Jupyter notebook there are two ways to add JS code. One is by using IPython.display.HTML:
from IPython.display import display,HTML

display(HTML("<script<alert('Hello world');<\script>

The other is by using the magic cell %%javascript, that is, you put at the top of a cell this line and it will run as JavaScript via your browser.
Also, you can make Jupyter use a NodeJS kernel. This is really handy especially in writing code for the diabolically great, but also diabolically fiddly, package Puppeeter, for example. But is not applicable here.
What happens when one shows a figure in a Jupyter cell is that with the cell (technically a div of class .output) a JS script is added, which creates the SVG graph. This rather cool as it means that the code can be copy-pasted anywhere.
The created SVG (vector image) has loads of mostly empty groups (polar or cartesian). There are a few layers above the one with the actual points, which has the class .plot. Each trace will have its own group and within them a group of class .points if you made a scatter or even bar plot and within these path elements of class .point that are translated in place.

A scatter plot no more

The first thing to do is to make a fake series of scatterplot traces, where each trace contains a a point corresponding to an item all in a nice tidy grid —a handy way of doing other plots too. With the axes hidden. The shape completely depends on your total count. But I simply did a prime factor deconvolution and found that the random typed numbers could make a 13 by 16 grid.
 

import plotly.graph_objs as go
from plotly.offline import iplot, init_notebook_mode
init_notebook_mode()
from IPython.display import display,HTML
import pandas as pd  ## panda is useless here, but all data is always a panda DataFrame...

title = 'Team scores'

ds = pd.Series({'Alpha' : 67, 'Bravo' : 30, 'Charlie' : 20, 'Delta': 12, 'Echo': 23, 'Foxtrot': 56})

print(sum(ds))

Xlim = 16
Ylim = 13
Xpos = 0
Ypos = 12 ##change to zero for upwards
series = []
for name, count in ds.iteritems():
    x = []
    y = []
    for j in range(0, count):
        if Xpos == Xlim:
            Xpos = 0
            Ypos -= 1 ##change to positive for upwards
        x.append(Xpos)
        y.append(Ypos)
        Xpos += 1
    series.append(go.Scatter(x=x, y=y, mode='markers', marker={'symbol': 'square', 'size': 15}, name=f'{name} ({count})'))


fig = go.Figure(dict(data=series, layout=go.Layout(
    title={'text': title, 'x': 0.5, 'xanchor': 'center'},
    paper_bgcolor='rgba(255,255,255,1)',
    plot_bgcolor='rgba(0,0,0,0)',
    xaxis=dict(showgrid=False,zeroline= False, showline=False, visible=False, showticklabels=False),
    yaxis=dict(showgrid=False,zeroline= False, showline=False, visible=False, showticklabels=False),
)))
fig.show()

The magic part

This is pretty standard and will produce a graph like this.
The next step is to change the squares to FontAwesome icons. Which is when the JS fun begins.
One issue is that often you'll have more than one plot and counting which plot in line this one is not fun.
Consequently we need a lazy way of grabbing this graph and only this one. The laziest way is adding display(HTML('<div id="this_element"></div>')) within the same cell and getting in JS its SVG quasi-sibling by getting the appropriate parent element and finding the svg canvas and giving it a unique ID too $('#this_element').parent().parent().parent().find('svg').attr('id','myGraph');.
Then we can select and change the elements. Although one thing to remember is that the graph will be loaded asynchronously, so we could either do it properly with promises and ready states or just wait half a second.
Image manipulation is easy with D3. Plotly has D3 as an attribute. In the main namespace Plotly is called _Plotly (Jupyter notebooks use require.js which will load it as Plotly, so we could use that route, but its overkill).
So we select the parent <code>_Plotly.d3.select($(point_element).parent()[0])</code> and append a text element (i elements don't work in SVG nor does the CSS for the FontAwesome classes) with the text corresponding to the unicode character listed next to the i element code <i class="fa fa-bug" aria-hidden="true"></i>. Add add a style("font-family","FontAwesome") element too. Do note that in this example I am using FA 4.7 as that is what Jupyter notebook uses. For 5.11 there are two options, this, but with the appropriate Font Family ('Font Awesome 5 Free', 'Font Awesome 5 Solid', 'Font Awesome 5 Regular', 'Font Awesome 5 Light', 'Font Awesome 5 Duotone', and 'Font Awesome 5 Brands') and two with the use element as described in their documentation here or here.

So combining these various lazy tricks we have this piece of python code that adds a piece of JS to change the graph into a pictograph!
display(HTML("""<div id="this_element"></div>
<script>
// wait 0.5 s before doing anything.
setTimeout(() => {
    //name the svg canvas
    $('#this_element').parent().parent().parent().find('svg').attr('id','myGraph');
    
    //this function changes the path-tag element el into the icon f11b added as text.
    function changer(idx, el) {
        const size = 15;
        let qjel = $(el);
        // the transform is middle-center'ed, but we want it to be top-left'ed
        let t = qjel.attr('transform');
        t = t.replace(/([\d\.]+),([\d\.]+)/,(m, x,y) => (parseFloat(x)-size/2)+','+(parseFloat(y)+size/2))
        _Plotly.d3.select(qjel.parent()[0])
                        .append("text")
                              .attr("transform", t)
                              .style("font-family","FontAwesome")
                              .style('font-size', size+"px")
                              .style('fill', qjel.css('fill'))
                              .text('\uf11b'); //https://fontawesome.com/v4.7.0/icon
        //goodbye marker
        qjel.detach();
    }
    // run the function for the traces and the legends.
    $('#myGraph .point').each(changer);
    $('#myGraph .legendpoints path').each(changer);
}, 500);
</script>
"""))

Result

Here is the final result (I added an div with a chosen id with width=100% and height=500px, the CDN for Plotly and FontAwesome and copy-pasted the script generated in the Jupyter Notebook and changed the id of the container to the one chosen and I copied pasted the code described above without the need for a timeout in the anonymous resolve function of the then function):

Footnote

This hack breaks if you hide a series (fixable by adding an event listener in JS) and does not show when you press save image. Also, it is possible to use custom "symbols" (markers) in D3.js, the underlying library for Plotly, however, this is far from simple. If there is an SVG that you would like to use, you can declare it as an SVG symbol and add it then like above, but with .append("use").attr("transform", t+' scale(0.01)').attr("xlink:href","#mySymbol") instead of text. Where #mySymbol or whatever is your symbol's id, like a normal SVG symbol. The addition of a space and scale to a given value is needed as most likely than not, you got your SVG from Flaticon and you need to shrink it a lot...

2 comments:

  1. Thanks for a great tip! I had been struggling with this for a while, and your solution helped med a lot.

    However, after implementing your solution, I realized we could perhaps do it even simpler. So I replaced the markers with pure text, and then I could use font awesome icons without hacking the figure. The only caveat is that the legend symbols aren't updated. See proposed solution below (sorry, couldn't fix the formatting).

    import pandas as pd
    import plotly
    import plotly.graph_objs as go
    plotly.offline.init_notebook_mode()

    title = 'Team scores'

    ds = pd.Series({'Alpha' : 67, 'Bravo' : 30, 'Charlie' : 20, 'Delta': 12, 'Echo': 23, 'Foxtrot': 56})


    Xlim = 16
    Ylim = 13
    Xpos = 0
    Ypos = 12 ##change to zero for upwards
    series = []
    for name, count in ds.iteritems():
    x = []
    y = []
    for j in range(0, count):
    if Xpos == Xlim:
    Xpos = 0
    Ypos -= 1
    x.append(Xpos)
    y.append(Ypos)
    Xpos += 1
    trace = go.Scatter(x=x,
    y=y,
    mode="text",
    text='\uf11b',
    textposition="middle center",
    textfont={"family": "FontAwesome",
    "size": 18,
    },
    name=f'{name} ({count})',
    hovertemplate=f'{name} ({count})',
    )
    series.append(trace)


    fig = go.Figure(dict(data=series, layout=go.Layout(
    title={'text': title, 'x': 0.5, 'xanchor': 'center'},
    paper_bgcolor='rgba(255,255,255,1)',
    plot_bgcolor='rgba(0,0,0,0)',
    xaxis=dict(showgrid=False,zeroline= False, showline=False, visible=False, showticklabels=False),
    yaxis=dict(showgrid=False,zeroline= False, showline=False, visible=False, showticklabels=False),
    )))
    fig

    ReplyDelete
    Replies
    1. Ooh. Clever solution!
      On my system the colour is black for all series with your solution, but that can be fixed with textfont/color, and I really like that it does not mess up when re-sized and majorly it is not a JS hack!

      Delete