Plotly Timeline for non-date-type series

plotly
data visualization
Author

im@johnho.ca

Published

Monday, February 17, 2025

Abstract
highly customizable timeline/ gantt chart for small time-scale events

Intro

plotly express’s timeline is amazing for visualizing timeseries type data into beautiful Gantt Chart. Mermaid is another but it’s more suited for blogging or static data.

I occasionally found the need to visualize events on very small time scales (i.e. within seconds, or hours max). For example, the occurance of something within a video. Luckily with a bit of tinkering, we can accomplish this.

Customizing px.timeline

for our example, suppose we detected the following events in a video:

Code
# let's import what we need
import os, sys, datetime, random
import pandas as pd
import plotly.express as px
import warnings
warnings.filterwarnings('ignore')

# need this for plotly charts to render properly: https://stackoverflow.com/a/78749656
import plotly.io as pio
pio.renderers.default = "notebook"

events = [
    {'start': 0, 'end': 0.73, 'name': 'introduction'},
    {'start': 0.73, 'end': 1.03, 'name': 'action'},
    {'start': 1.03, 'end': 3.03, 'name': 'ads'},
    {'start': 3.03, 'end': 5.03, 'name': 'action'},
    {'start': 5.03, 'end': 12.57, 'name': 'ads'},
    {'start': 12.57, 'end': 13, 'name': 'conclusion'},
]

data = pd.DataFrame(events)
data.head()
start end name
0 0.00 0.73 introduction
1 0.73 1.03 action
2 1.03 3.03 ads
3 3.03 5.03 action
4 5.03 12.57 ads

based off of this stackoverflow answer, we built a custom timeline function called plotly_timeline and here’s what it outputs

Code
def plotly_timeline( df: pd.DataFrame, id_col:str,
    start_col_name: str="start", end_col_name: str="end",
    color_col_name: str = None,
    str_title: str ="Timeline Plot",
    total: float =None, min_x: float = None,
    hover_data=[], opacity: float = 1.0, bar_width : float = None,
    barmode: str = None, reverse_y : bool = False
                
):
    """plot the appearance of objects on a timeline
            ref: https://stackoverflow.com/a/66079696/14285096
    Args:
            total: if x values are not datetime then please provide a max x value
            min_x: if not provide, x-axis min value will adjust to what's provided in the df
    """
    req_col = [start_col_name, end_col_name, id_col]
    for c in req_col:
        assert c in df.columns, f"required column {c} not found"

    df = df.copy(deep=True)
    if total:
        df["delta"] = df[end_col_name] - df[start_col_name]
    df[id_col] = df[id_col].astype(str)
    color_col = color_col_name if color_col_name else id_col
    df[color_col] = df[color_col].astype(str)
    
    fig = px.timeline(
        df,
        x_start=start_col_name,
        x_end=end_col_name,
        y=id_col,
        color=color_col,
        title=str_title,
        hover_data=hover_data,
        opacity = opacity
    )
    if reverse_y:
        fig.update_yaxes(autorange="reversed")
    if barmode:
        fig.update_layout(barmode=barmode)

    if total:
        min_x = min_x if min_x else df[start_col_name].min()
        fig.layout.xaxis.type = "linear"
        for d in fig.data:
            filt = df[color_col] == d.name
            d.x = df[filt]["delta"].tolist()
            if bar_width:
                d.width = bar_width
        fig.update_xaxes(range=[min_x, total])
    return fig

f = plotly_timeline(data, 'name', total = 13, str_title = "Timeline Plot for non-date type timeseries")
f.show()

the key is providing the total variable which will cause the function to internally compute the delta values between the start and end time and use that to create the bars.

Date-type Time Series

The same function also supports date-type time series!

Suppose we want to create a typical Gantt with overlapping tasks (hat-tip to this other stackoverflow answer on the use of opacity), all we need is to set a different id_col and color_col_name while leaving total alone:

Code
df = pd.DataFrame([
    dict(Task="Job A", Start='2009-01-01', Finish='2009-02-28', Resource="Alex"),
    dict(Task="Job B", Start='2009-02-25', Finish='2009-04-15', Resource="Alex"),
    dict(Task="Job C", Start='2009-02-23', Finish='2009-05-23', Resource="Max"),
    dict(Task="Job D", Start='2009-02-20', Finish='2009-05-30', Resource="Max")
])
plotly_timeline(df, id_col = 'Resource', color_col_name='Task', str_title="Classic Gantt Chart",
                start_col_name= 'Start', end_col_name= 'Finish', opacity=.5)

Grouping all events into one timeline

all it takes is just an extra column in our input dataframe. Let’s say we have the following events detected for a swim-run video

Code
events = [
    {'start': 0, 'end': 0.73, 'name': 'transition'},
    {'start': 0.73, 'end': 1.03, 'name': 'swimming'},
    {'start': 1.03, 'end': 3.03, 'name': 'transition'},
    {'start': 3.03, 'end': 5.03, 'name': 'running'},
    {'start': 5.03, 'end': 12.57, 'name': 'transition'},
    {'start': 12.57, 'end': 13, 'name': 'finishline'},
]
data = pd.DataFrame(events)
data['event'] = 'swim-run'
data
start end name event
0 0.00 0.73 transition swim-run
1 0.73 1.03 swimming swim-run
2 1.03 3.03 transition swim-run
3 3.03 5.03 running swim-run
4 5.03 12.57 transition swim-run
5 12.57 13.00 finishline swim-run

our event will be homogeneous, leaving only the name column to contain the variable. On top of that, the ususal plotly formatting applies; for example, here we adjusted the look of the legend

Code
f = plotly_timeline(data, id_col = 'event', color_col_name='name', str_title = "All Events visualized in One Timeline",
                    total = 15, min_x = 0, bar_width = 0.2)
f.update_layout(
    autosize=True,
#     width=800,
    height=300,
    legend= {'orientation': 'h'},
    legend_title_text = "detected categories:"
)
f.show()

for debugging (if you ever want to do something bespoke and edit the function), I found that looking into the created figure’s data to be particularly useful…

Code
f.data[:]
(Bar({
     'alignmentgroup': 'True',
     'base': array([0.0, 1.03, 5.03], dtype=object),
     'hovertemplate': 'name=transition<br>start=%{base}<br>end=%{x}<br>event=%{y}<extra></extra>',
     'legendgroup': 'transition',
     'marker': {'color': '#636efa', 'opacity': 1.0, 'pattern': {'shape': ''}},
     'name': 'transition',
     'offsetgroup': 'transition',
     'orientation': 'h',
     'showlegend': True,
     'textposition': 'auto',
     'width': 0.2,
     'x': [0.73, 1.9999999999999998, 7.54],
     'xaxis': 'x',
     'y': array(['swim-run', 'swim-run', 'swim-run'], dtype=object),
     'yaxis': 'y'
 }),
 Bar({
     'alignmentgroup': 'True',
     'base': array([0.73], dtype=object),
     'hovertemplate': 'name=swimming<br>start=%{base}<br>end=%{x}<br>event=%{y}<extra></extra>',
     'legendgroup': 'swimming',
     'marker': {'color': '#EF553B', 'opacity': 1.0, 'pattern': {'shape': ''}},
     'name': 'swimming',
     'offsetgroup': 'swimming',
     'orientation': 'h',
     'showlegend': True,
     'textposition': 'auto',
     'width': 0.2,
     'x': [0.30000000000000004],
     'xaxis': 'x',
     'y': array(['swim-run'], dtype=object),
     'yaxis': 'y'
 }),
 Bar({
     'alignmentgroup': 'True',
     'base': array([3.03], dtype=object),
     'hovertemplate': 'name=running<br>start=%{base}<br>end=%{x}<br>event=%{y}<extra></extra>',
     'legendgroup': 'running',
     'marker': {'color': '#00cc96', 'opacity': 1.0, 'pattern': {'shape': ''}},
     'name': 'running',
     'offsetgroup': 'running',
     'orientation': 'h',
     'showlegend': True,
     'textposition': 'auto',
     'width': 0.2,
     'x': [2.0000000000000004],
     'xaxis': 'x',
     'y': array(['swim-run'], dtype=object),
     'yaxis': 'y'
 }),
 Bar({
     'alignmentgroup': 'True',
     'base': array([12.57], dtype=object),
     'hovertemplate': 'name=finishline<br>start=%{base}<br>end=%{x}<br>event=%{y}<extra></extra>',
     'legendgroup': 'finishline',
     'marker': {'color': '#ab63fa', 'opacity': 1.0, 'pattern': {'shape': ''}},
     'name': 'finishline',
     'offsetgroup': 'finishline',
     'orientation': 'h',
     'showlegend': True,
     'textposition': 'auto',
     'width': 0.2,
     'x': [0.4299999999999997],
     'xaxis': 'x',
     'y': array(['swim-run'], dtype=object),
     'yaxis': 'y'
 }))

Conclusion

being able to use plotly_timeline to visualize non-date-type time-series is possible with just a few extra lines of code. A key thing to note is that this works best on plotly==5.18.0 (see this issue), and I’ve personally found that the timeline might not render properly with plotly>=6.0.0.

the true power of this timeline vizualization comes when rendered within a streamlit app with a callback function, turning it into an interface for performing downstream task like trigger an action for a sub-section of an AV file as an example.