Time series chart with Flask, Bootstrap and Chart.js
Create a time series chart test page with Flask, Bootstrap and Chart.js
We already have an application build with Flask and Bootstrap and want to add some charts. In this short post we create a single web page with a time series chart using Flask, Bootstrap and Chart.js. This page a test page that we can paste and customize for our application.
As always I am doing this on Ubuntu 22.04 Desktop.
Overview
The test page contains a chart showing time series demo data:
- The time series data is one sine per hour, one data point per minute, repeated 24 hours.
- The time series data may have some missing data points.
- It must be possible to zoom in and out.
- The chart should be responsive, x-axis only.
- Use milliseconds or ISO8601.
- It should be multilingual.
Almost everything is very standard. For the type of x-axis, we can choose between:
- 'time': the x-axis is consistent with time, data points that are missing are clearly visible.
- 'timeseries': the x-axis is compressed where data points are missing.
Here we use the 'time' type.
We generate sine data, one sine per hour, with values between -10 and +10, and intentionally add missing data points. Then we set 'spanGaps' to a value, 2 minutes, to ensure that the data points are not connected to each other.
spanGaps: 2 * 60 * 1000
The chart is responsive, but only in the x direction. The height of the graph is fixed and we create a linear y-axis with a minimum value (-10) and a maximum value (10).
To zoom in, we use the mouse wheel (scroll wheel) or by selecting an area with the mouse. We add a button to reset the zoom and return to the original chart.
To support different languages, we load the appropriate module of moment.js, for example, for German:
https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.30.1/locale/de.min.js
The code
Create a virtual environment and install Flask. Then create a project directory with the following structure:
├── app
│ ├── factory.py
│ └── templates
│ ├── base.html
│ └── home.html
└── run.py
As always we start with 'run.py', note that we are using port 5050 here:
# run.py
from app.factory import create_app
app = create_app()
if __name__ == '__main__':
app.run(
host='127.0.0.1',
port=5050,
use_debugger=True,
use_reloader=True,
)
In 'factory.py' we create the data points and specify the language:
# app/factory.py
import datetime
import logging
import math
from flask import current_app, Flask, render_template
def create_app():
app = Flask(__name__)
app.jinja_env.auto_reload = True
app.config['TEMPLATES_AUTO_RELOAD'] = True
app.logger.setLevel(logging.DEBUG)
@app.route('/', methods=['GET', 'POST'])
def chart():
current_app.logger.debug('()')
# select locale
locale = 'de-DE'
#locale = 'en-US'
#locale = 'nl-NL'
chart_locales = {
'de-DE': {'html': 'de', 'chartjs': 'de-DE', 'momentjs': 'de'},
'en-US': {'html': 'en', 'chartjs': 'en-US', 'momentjs': None},
'nl-NL': {'html': 'nl', 'chartjs': 'nl-NL', 'momentjs': 'nl'},
}
# 1 sine per hour, for 24 hours, starting now
dt = datetime.datetime.utcnow()
ts = int(dt.timestamp())
chart_data = []
dt_format_milliseconds = False
for h in range(24):
for minute in range(60):
# x: in milliseconds or iso8601
dt += datetime.timedelta(minutes=1)
if dt_format_milliseconds:
x = int(dt.timestamp() * 1000)
else:
x = dt.isoformat()
# y: -10 to 10
rad = 6 * minute
y = 10 * (math.sin(math.radians(rad)))
# insert missing data points
if rad > 120 and rad < 160:
continue
chart_data.append({'x': x, 'y': y})
return render_template(
'/chart.html',
page_title='Chart',
page_contains_charts=True,
chart_locale=chart_locales[locale],
chart_data=chart_data,
)
return app
We have two template files, the base page template and the chart page template. The base page template can be used by all pages, the chart page template contains the page with our chart.
{# app/templates/base.html #}
<!DOCTYPE html>
<html lang="{{ chart_locale['html'] }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>{{ page_title }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
</head>
<body>
<main id="main" class="container -fluid">
<div class="row">
<div class="col">
{% block main -%}{% endblock -%}
</div>
</div>
</main>
<!-- bootstrap + jquery -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
{%- if page_contains_charts -%}
<!-- chartjs -->
<script src=" https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js "></script>
<!-- moment -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.30.1/moment.min.js"></script>
<!-- moment with locale -->
{%- if chart_locale['momentjs'] is not none -%}
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.30.1/locale/{{ chart_locale['momentjs'] }}.min.js"></script>
{%- endif -%}
<!-- chartjs moment adapter -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/chartjs-adapter-moment/1.0.1/chartjs-adapter-moment.min.js" integrity="sha512-hVy4KxCKgnXi2ok7rlnlPma4JHXI1VPQeempoaclV1GwRHrDeaiuS1pI6DVldaj5oh6Opy2XJ2CTljQLPkaMrQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<!-- chartjs zoom -->
<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/chartjs-plugin-zoom/2.2.0/chartjs-plugin-zoom.min.js" integrity="sha512-FRGbE3pigbYamZnw4+uT4t63+QJOfg4MXSgzPn2t8AWg9ofmFvZ/0Z37ZpCawjfXLBSVX2p2CncsmUH2hzsgJg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script>
$(document).ready(function() {
moment.locale(chartLocale);
const ctx = document.getElementById('chart-canvas');
const chart = new Chart(ctx, {
type: chartType,
data: chartData,
options: chartOptions,
});
window.chart = chart;
function reset_zoom() {
window.chart.resetZoom();
}
$('#reset-zoom-button').click(function(){
reset_zoom();
});
});
</script>
{%- endif -%}
</body>
</html>
{# app/templates/chart.html #}
{% extends "base.html" %}
{% block main %}
<h2 class="mb-3">
{{ page_title }}
</h2>
<div class="row border-top border-bottom border-2 border-tertiary">
<div class="col my-4" style="height: 300px; ">
<canvas id="chart-canvas"></canvas>
</div>
</div>
<div class="row">
<div class="col">
<button id="reset-zoom-button" type="button" class="btn btn-outline-secondary btn-sm my-2 px-1 py-0">
Reset Zoom
</button>
</div>
</div>
<script>
const chartLocale = "{{ chart_locale['chartjs'] }}";
// colors from:
// https://github.com/chartjs/Chart.js/blob/master/docs/scripts/utils.js
const CHART_COLORS = {
red: 'rgb(255, 99, 132)',
orange: 'rgb(255, 159, 64)',
yellow: 'rgb(255, 205, 86)',
green: 'rgb(75, 192, 192)',
blue: 'rgb(54, 162, 235)',
purple: 'rgb(153, 102, 255)',
grey: 'rgb(201, 203, 207)'
};
const chartType = 'line';
const dataSet1 = {
label: 'dataSet1',
data: {{ chart_data|safe }},
spanGaps: 2 * 60 * 1000,
// line & point color & width
borderColor: CHART_COLORS.blue,
borderWidth: 1,
pointBackgroundColor: CHART_COLORS.red,
pointBorderColor: CHART_COLORS.red,
pointStyle: 'circle',
pointRadius: 1,
pointHoverRadius: 10
};
const chartData = {
datasets: [
dataSet1
]
};
const xScaleDisplayFormat = 'MMM DD HH:mm:ss';
const chartScales = {
x: {
type: 'time',
ticks: {
autoSkip: true,
autoSkipPadding: 10,
},
time: {
tooltipFormat: xScaleDisplayFormat,
displayFormats: {
millisecond: xScaleDisplayFormat,
second: xScaleDisplayFormat,
minute: xScaleDisplayFormat,
hour: xScaleDisplayFormat,
}
},
},
y: {
type: 'linear',
min: -10,
max: 10,
}
};
const chartZoom = {
pan: {
enabled: true,
mode: 'x',
modifierKey: 'ctrl',
},
zoom: {
wheel: {
enabled: true,
},
drag: {
enabled: true
},
mode: 'x',
}
};
const chartOptions = {
locale: chartLocale,
responsive: true,
maintainAspectRatio: false,
scales: chartScales,
plugins: {
zoom: chartZoom,
legend: {
display: false
}
}
};
</script>
{%- endblock -%}
To run, go to the project project directory and type:
python run.py
Then point your browser at:
http://127.0.0.1:5050
Summary
We created a single web page using Flask, Bootstrap and Chart.js. The chart shows a repeated sine wave. Although not very difficult, it takes time to go through all the configuration options available. We can now customize our chart test page and add it to our application.
Most viewed
- Using PyInstaller and Cython to create a Python executable
- Reducing page response times of a Flask SQLAlchemy website
- Using Python's pyOpenSSL to verify SSL certificates downloaded from a host
- Connect to a service on a Docker host from a Docker container
- Using UUIDs instead of Integer Autoincrement Primary Keys with SQLAlchemy and MariaDb
- SQLAlchemy: Using Cascade Deletes to delete related objects