Django, Bokeh and HTMX - Data Driven Line Charts

In this video, we will create a dynamic, database-driven LINE chart for a country's historical GDP data. We will use Django for our backend functionality, will use Bokeh to create a line chart showing the trajectory of this GDP data, and will use HTMX to dynamically filter the country whose data is shown on the chart. We will also show how to create multi-line charts using Bokeh.

The associated video for this post is shown below:

Objectives

In this post we will:

  • Learn how to create a line-chart from database data, using Bokeh and Django
  • Learn how to update the country/data via HTMX requests
  • Learn how to create multi-line charts from database data, using Bokeh and Django
  • Learn how to add legends and line-colours to our chart

Project Setup

This post will build on the code written in the previous post. The final code for that post can be found on Github here, on the barchart branch. Clone this, and install the project requirements if necessary.

Next, create a new template: gdp/templates/line.html. This template will be responsible for displaying our line graph - add the following code to this template:

{% extends 'base.html' %} 

{% block content %}
    <p class="lead">GDP by country</p>

    <div class="row">
        <div id="linechart" class="col-10">
            {% include 'partials/gdp-bar.html' %}
        </div>
        <div class="col-2">
                <label>Year</label>
                <select id="select-year" 
                        class="custom-select" 
                        name="country"
                        autocomplete="off">

                    {% for c in countries %}
                        <option value="{{c}}" 
                        {% if country == c %} selected {% endif %}>{{c}}</option>
                    {% endfor %}
                </select>
        </div>
    </div>

{% endblock %}

This is similar to the index.html template from the last video - we have an included partial template, which is responsible for rendering the script and div we get by calling the Bokeh components(figure) function. This partial is unchanged from the last post.

We also have a <select> element with a name of country, which will be a dropdown of all the unique countries in our data. We will allow the user to select a country, and the line-chart will later show the data for that country.

The code on lines 17-21 relies on a few context variables - countries and country - which we'll add to the context in the view that renders this template. More on that soon!

Creating URL and View for Line Chart Page

We'll now create a URL and a view that will render the line.html template. Firstly, in the urls.py file, add the following code to urlpatterns:

path('line/', views.line, name='linechart')

Now, add the view for this URL.

def line(request):
    return render(request, 'line.html', {})

We will gradually fill this view out. The first thing we want to do is add the two context variables referenced in the template, countries and country. We want countries to have all the unique/distinct countries in the data, which we can get with the following code:

GDP.objects.values_list('country', flat=True).distinct()

This code gets all the values of the country field on our GDP model, flattens them into a list, and then calls the .distinct() method to get the unique values.

To get the country variable, we'll parse out the country from the GET request, providing a default of Germany if no GET parameter is present.

request.GET.get('country', 'Germany')

Let's add these to our view and pass a context to our template. We will also create an ORM query to filter the database data down to only that with the given country.

def line(request):
    # get all distinct/unique countries in the DB
    countries = GDP.objects.values_list('country', flat=True).distinct()

    # get selected country from the frontend via GET request (default: Germany)
    country = request.GET.get('country', 'Germany')
    
    # get the GDP objects from the DB
    gdps = GDP.objects.filter(country=country).order_by('year')

    # add variables to context, and pass to render()
    context = {
        'countries': countries,
        'country': country
    }
    return render(request, 'line.html', context)

This will provide the user with a dropdown on the line-chart page, allowing them to select a country from a list. Let's add the new URL to our gdp/templates/partials/navbar.html file.

<nav class="navbar navbar-expand-lg navbar-light bg-light">
    <a class="navbar-brand" href="{% url 'index' %}">GDP Analysis</a>

    <div id="navbarNav">
        <ul class="navbar-nav ml-auto">
            <li class="nav-item">
                <a class="nav-link" href="{% url 'index' %}">Home</a>
            </li>
            <li class="nav-item">
                <a class="nav-link" href="{% url 'linechart' %}">Line Chart</a>
            </li>
        </ul>
    </div>
</nav>

If you now navigate to the page, you should see the dropdown. Let's now focus on creating the line chart with Bokeh!

Creating Bokeh Line Chart

Within the views.py file, we're going to add a ColumnDataSource, a Bokeh figure, and then call the figure.line() method to create the line-chart. Finally, we'll call the components() function, passing our figure, to get the script and div that we will include in the context.

Add the following code to the view:

def line(request):
    # get all distinct/unique countries in the DB
    countries = GDP.objects.values_list('country', flat=True).distinct()

    # get selected country from the frontend via GET request (default: Germany)
    country = request.GET.get('country', 'Germany')
    
    # get the GDP objects from the DB
    gdps = GDP.objects.filter(country=country).order_by('year')

    # get relevant data from models and create ColumnDataSource
    country_years = [d.year for d in gdps]
    country_gdps = [d.gdp for d in gdps]
    cds = ColumnDataSource(data=dict(country_years=country_years, country_gdps=country_gdps))

    # create a figure, and add styles
    fig = figure(height=500, title=f"{country} GDP")
    fig.title.align = 'center'
    fig.title.text_font_size = '1.5em'
    fig.yaxis[0].formatter = NumeralTickFormatter(format="$0.0a")

    # call the figure's .line() function and pass CDS fields
    fig.line(x='country_years', y='country_gdps', source=cds, line_width=2)

    # call the components() function to get script and div
    script, div = components(fig)

    # add variables to context, and pass to render()
    context = {
        'countries': countries,
        'country': country,
        'script': script,
        'div': div,
    }

    # if HTMX request, render only the partial showing chart
    # otherwise, render line chart template
    if request.htmx:
        return render(request, 'partials/gdp-bar.html', context)
    return render(request, 'line.html', context)

If you now navigate to the line-chart page, you should see the chart for Germany, showing the country's GDP over time, as below:

We are now going to hook up HTMX to our dropdown on the right-hand-side of the above image, and allow the user to change the country selected!

Adding HTMX Attributes to Select Element

We are going to give users the ability to send a GET request to our line-chart view whenever a new country is selected. The view will extract the chosen country, will filter down the data to only rows with this country, and will use that data to populate the line-chart in Bokeh. Finally, as it's an HTMX request, the partial fragment will be returned with the new chart.

Add these HTMX attributes to the <select> element below:

<select id="select-year" 
        class="custom-select" 
        name="country"
        autocomplete="off"
        hx-get="{% url 'linechart' %}"
        hx-target="#linechart">

The target, specified with hx-target, is the div that surrounds the included partial containing the chart. This allows the new chart to be swapped seamlessly into the innerHTML of that target div, replacing the old chart with a new one.

This should now work and look similar to the below GIF.

And that's how to create a line-chart that can be dynamically updated via HTMX! We can now select any country from our data, and the line-chart will show its GDP from the first available year to the most recent year, creating a simple time-series graph.

We'll now show how to create multi-line charts using Bokeh, on the backend.

Creating Multi-Line Charts with Bokeh

The multi-line chart is a simple extension of the line chart, where multiple lines are shown on the same figure. Instead of passing lists to our ColumnDataSource fields, we will now pass nested lists (lists of lists), where each inner list contains the data for one specific country. We will then use the figure.multi_line() function to create the multi-line chart.

The code for the view is shown below - we choose 3 countries for our list (you can use whatever countries you wish, though!), and append country-specific lists to the parent list within a for-loop, to create the nested structure.

def line(request):
    # get all distinct/unique countries in the DB
    countries = GDP.objects.values_list('country', flat=True).distinct()

    # get selected country from the frontend via GET request (default: Germany)
    country = request.GET.get('country', 'Germany')
    
    # These will be our parent lists, containing country-specific lists as elements
    year_data = []
    gdp_data = []

    # we define 3 countries for the multi-line chart
    c = ['Germany', 'China', 'France']

    # for each country, we get the relevant data from DB, and add to parent lists
    for country in c:
        gdps = GDP.objects.filter(country=country).order_by('year')
        year_data.append([d.year for d in gdps])
        gdp_data.append([d.gdp for d in gdps])

    # we create CDS from nested lists, and provide country names and colours as additional fields
    cds = ColumnDataSource(data=dict(
        country_years=year_data, 
        country_gdps=gdp_data,
        names=c,
        colors=['red', 'blue', 'green']
    ))

    # create a figure, and add styles
    fig = figure(height=500, title=f"{country} GDP")
    fig.title.align = 'center'
    fig.title.text_font_size = '1.5em'
    fig.yaxis[0].formatter = NumeralTickFormatter(format="$0.0a")

    # call the figure's .multi_line() function and pass CDS fields
    # we add legend group and color data to differentiate the lines!
    fig.multi_line(
        source=cds, 
        xs='country_years', 
        ys='country_gdps', 
        line_width=2,
        legend_group='names',
        line_color='colors')

    # specify the location of the legend on the chart
    fig.legend.location = 'top_left'

    # call the components() function to get script and div
    script, div = components(fig)

    # add variables to context, and pass to render()
    context = {
        'countries': countries,
        'country': country,
        'script': script,
        'div': div,
    }

    # if HTMX request, render only the partial showing chart
    # otherwise, render line chart template
    if request.htmx:
        return render(request, 'partials/gdp-bar.html', context)
    return render(request, 'line.html', context)

With this change, we should now see a multi-line chart rendered in the template, consisting of lines for the 3 chosen countries. This should look similar to below:

We have multiple lines, with colours and a legend allowing us to discern which line belongs to which country. Simple as that!

As an optional extension for the reader, you could try creating a multiple-select element in the template, and hook up HTMX to pass data to the backend and allow users to change the country lines chosen. This is a bit more challenging, but can be done.

Summary

In this post, we've seen how to create a line-chart using Django and Bokeh, and have seen how to use HTMX to update the chart based on a user's selection, without needing to refresh the entire page. This provides a nice, interactive experience for showing underyling data as a more readable chart/visualization.

If you enjoyed this post, please subscribe to our YouTube channel and follow us on Twitter to keep up with our new content!

Please also consider buying us a coffee, to encourage us to create more posts and videos!

;