Post

Embedding Plotly in a Jekyll Post

Yes, you can include Plotly’s interactive graphics inside your posts, and here is how!

In this post, we will see how you can:

  • generate Jekyll posts from Jupyter notebooks;
  • include Plotly figures inside the posts;
  • and render transparent images.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import pandas as pd
from plotting import express as px
from plotting import graph_objects as go

# Example from: https://plotly.com/python/sliders/

df = px.data.gapminder()
fig = px.scatter(
    df,
    x="gdpPercap",
    y="lifeExp",
    animation_frame="year",
    animation_group="country",
    size="pop",
    color="continent",
    hover_name="country",
    log_x=True,
    size_max=55,
    range_x=[100, 100000],
    range_y=[25, 90],
)

fig.show()

Notebook setup

When executing notebooks, Plotly figures are directly included the notebook. Because those graphics are HTML images with Javascript, including this HTML code in the markdown output can cause some issues with the Liquid templating system used by Jekyll. This is because the generated Javascript contains curly braces, which Liquid tries to interpret, but should not.

All the steps mentioned below, and more, are performed when importing _notebooks/plotting.py.

Changing the default renderer

Plotly offers a variety of renderers, one of which renders the Plotly figures in separated HTML files, that are included using iframe elements.

Among the IFrame renderers, we will use the iframe_connected renderer, that loads the necessary Plotly files from a CDN. This is useful to avoid including the whole Plotly library in your HTML files.

You can change the default plotly renderer with:

1
2
3
import plotly.io as pio

pio.renderers.default = "iframe_connected"

Patching the HTML directory

One issue with IFrame renderers is that they will generate all external files inside the same directory.

You can patch this by creating a custom renderer and make it the default:

1
2
3
4
5
6
7
8
9
10
11
12
import os
from uuid import uuid4  # To generated a new random directory for each notebook

import plotly.io as pio
from plotly.io._base_renderers import IFrameRenderer

pio.renderers["custom"] = IFrameRenderer(
    html_directory=os.path.join("../assets/notebooks", "html_" + str(uuid4())),
    include_plotlyjs="cdn",
)

pio.renderers.default = "custom"

Transparent background

If you support multiple themes on your website (e.g., light and dark), you might want your figures to render nicely with both themes.

Unfortunately, Plotly does not support dynamically changing the theme of a given figure, at least not easily.

A solution to this problem can be to specify a common Plotly theme that looks nice with all your website themes. In most cases, you’d like this theme to have a transparent background.

For this, you can also define a custom theme and make it the default:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from plotly.graph_objs import Figure

fig = Figure()
fig.update_layout(
    template="plotly_white",  # Change this to any Plotly built-in theme
    paper_bgcolor="rgba(0,0,0,0)",  # Transparent
    plot_bgcolor="rgba(0,0,0,0)",  # Transparent
    font_color="#d37e34",  # A nice orange font
    scene=dict(
        xaxis=dict(
            backgroundcolor="rgba(0,0,0,0)",  # Transparent
        ),
        yaxis=dict(
            backgroundcolor="rgba(0,0,0,0)",  # Transparent
        ),
        zaxis=dict(
            backgroundcolor="rgba(0,0,0,0)",  # Transparent
        ),
    ),
)

templated_fig = pio.to_templated(fig)
pio.templates["custom"] = templated_fig.layout.template

pio.templates.default = "custom"

Converting notebooks to posts

Jekyll generates posts from markdown files, and nbconvert can can convert notebooks into various formats, including markdown.

Therefore, it is just a matter of combining the two tools rightfully to generate Jekyll posts from Jupyter notebooks.

If not already, please install nbconvert:

1
pip install nbconvert

The following contents is inspired from notebooks_to_posts.sh.

Executing notebooks

When a notebook is run, it can generate some asset files, like plot images, that are included in the notebook using relative paths.

The important point is that, if you move a post to another directory, you have to move its assets accordingly. Additionally, Jekyll requires posts’ assets to be placed in the assets directory.

To satisfy both constraints, the best is to set the output directory of jupyter notebook to be inside the assets directory, e.g., with the following command:

1
2
3
4
5
6
7
8
9
echo "Executing notebooks..." && \
    [ -d _notebooks ] && \
    [ "$(find _notebooks -type f -iname '*.ipynb')" ] && \
    python3 -m jupyter nbconvert _notebooks/*.ipynb \
        --ExecutePreprocessor.kernel_name=python3 \
        --execute \
        --to markdown \
        --output-dir assets/notebooks || \
    echo "No notebook found"

The first three lines are here to check if: (1) the _notebooks directory exists and (2) if there is at least one notebook inside.

Let us examine the various arguments:

  • _notebooks/*.ipynb let us call the command on each notebook in he directory;
  • --ExecutePreprocessor.kernel_name=python3 makes sure we execute the notebooks with the given kernel. This is important if you plan to use this in GitHub workflows because the kernel installed inside a workflow might not have the same name as the one you used to create the notebook;
  • --execute actually executes the notebook (recommended);
  • --to markdown specifies the output file type;
  • and --output-dir assets/notebooks places the output files in the given directory, as mentioned above.

Fixing relative paths

Prior to moving the notebooks (converted to markdown) to the _posts directory, we must fix the path to the assets to match how Jekyll works.

For this, we will use sed, a command-line tool that can search and replace text in files, using regexes.

1
2
3
echo "Changing link to html image files" && \
    find assets/notebooks -type f -iname '*.md' | \
    xargs -n 1 sed -i -E 's/src="\.\.\//src="\//'

This command fixes paths starting with ../ to /, e.g., ../assets/some_path.html becomes /assets/some_path.html.

Keeping track of source path

Optionally, we may want to store the path to the original notebook file that generated each post. To do so, we add a source: <path> line to the YAML front matter of each post generated from a notebook.

This is especially useful if you want to provide some “suggest an edit” button on your posts, to link to the original file on GitHub.

This is what this very long Python line does:

1
2
3
echo "Changing relative path for posts generated from notebooks" && \
    find assets/notebooks -type f -iname '*.md' | \
    xargs -n 1 python3 -c "import sys,os;file=sys.argv[1];lines=open(file).readlines();index=lines.index('---\n');basename=os.path.basename(file);path=os.path.join('_notebooks', basename[:-2] + 'ipynb\n');lines.insert(index + 1, f'source: {path}');open(file, 'w').writelines(lines);"

Moving generated posts to correct directory

Finally, we can move the newly created posts inside the _posts directory:

1
2
echo "Moving new posts to _posts directory" && \
    mv assets/notebooks/*.md _posts/

More examples

Here are a few more examples from Plotly’s documentation.

Range slider

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Example from: https://plotly.com/python/time-series/

df = pd.read_csv(
    "https://raw.githubusercontent.com/plotly/"
    "datasets/master/finance-charts-apple.csv"
)

fig = go.Figure(go.Scatter(x=df["Date"], y=df["mavg"]))

fig.update_xaxes(
    rangeslider_visible=True,
    tickformatstops=[
        dict(dtickrange=[None, 1000], value="%H:%M:%S.%L ms"),
        dict(dtickrange=[1000, 60000], value="%H:%M:%S s"),
        dict(dtickrange=[60000, 3600000], value="%H:%M m"),
        dict(dtickrange=[3600000, 86400000], value="%H:%M h"),
        dict(dtickrange=[86400000, 604800000], value="%e. %b d"),
        dict(dtickrange=[604800000, "M1"], value="%e. %b w"),
        dict(dtickrange=["M1", "M12"], value="%b '%y M"),
        dict(dtickrange=["M12", None], value="%Y Y"),
    ],
)

fig.show()

3D Surface

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Example from: https://plotly.com/python/3d-surface-plots/

# Read data from a csv
z_data = pd.read_csv(
    "https://raw.githubusercontent.com/plotly/"
    "datasets/master/api_docs/mt_bruno_elevation.csv"
)

fig = go.Figure(data=[go.Surface(z=z_data.values)])
fig.update_traces(
    contours_z=dict(
        show=True, usecolormap=True, highlightcolor="limegreen", project_z=True
    )
)
fig.update_layout(
    title="Mt Bruno Elevation",
    autosize=False,
    scene_camera_eye=dict(x=1.87, y=0.88, z=-0.64),
    width=500,
    height=500,
    margin=dict(l=65, r=50, b=65, t=90),
)

fig.show()

Treemap

1
2
3
4
5
6
7
8
9
10
11
# Example from: https://plotly.com/python/plotly-express/

df = px.data.gapminder().query("year == 2007")
fig = px.treemap(
    df,
    path=[px.Constant("world"), "continent", "country"],
    values="pop",
    color="lifeExp",
    hover_data=["iso_alpha"],
)
fig.show()
This post is licensed under CC BY 4.0 by the author.