Gantt chart (px.timeline) with subplot with opacity and filtering implementation

Hello! I have a problem. I need to make a gantt chart as shown in this image:

Also, i need to implement a filtering system, like on this picture:

The filter selected in the dropdown menu is highlighted on the diagram, and the rest become transparent (keeping the color) . And all this should work through the callback.

Without thinking twice, I did the following:

@app.callback(
    Output('timeline-output', 'figure'),
    State('dropdown-input', 'value'),
    Input('dropdown-button', 'n_clicks')
)
def graph_filtering(reason, click):
    if reason:
        if len(reason)>0:
            filtered_df = df[df['reason'].isin(reason)]
          
            fx1 = px.timeline(df, 
                              x_start='state_begin', 
                              x_end='state_end',
                              y='endpoint_name'
                              ).update_layout(
                                  xaxis={
                                      'side':'top',
                                      'dtick':3.6e+6,
                                      'tickangle':0,
                                      'tickformat':'%H'},
                                  yaxis={
                                      'dtick':3.6e+6,
                                      'tickangle':0},
                                  showlegend=False
                                  ).update_traces(
                                       hovertemplate=None,
                                       hoverinfo='skip')

            fx2 = px.timeline(filtered_df, 
                              x_start='state_begin', 
                              x_end='state_end', 
                              y='endpoint_name',
                            ).update_layout(
                                xaxis={
                                    'side':'top',
                                    'dtick':3.6e+6,
                                    'tickangle':0},
                                yaxis={
                                    'dtick':3.6e+6,
                                    'tickangle':0},
                                showlegend=False).

            result_figure = make_subplots(specs=[[{'secondary_y':True}]])
                        result_figure.add_trace(
                            fx1.data[0],
                            secondary_y=False
                        )
                        result_figure.add_trace(
                            fx2.data[0],
                            secondary_y=True
                        )
                        result_figure.layout.xaxis = fx1.layout.xaxis
                        result_figure.data[0].opacity = 0.2
                        result_figure.update_layout(height=300, showlegend=False)
                        return result_figure


What i’am doing:

Having assigned the appropriate ids for callback (input, output) in app.layout, I created a function in which I created 2 figures and connected them using the make_subplots function, also set one of them to 0.2 opacity (to look like it should have been done initially)

At first glance, everything is fine and even filtering works, as described above (the selected filter is highlighted, and the rest become transparent). But as soon as I add to the parameters of px.timeline: color_discrete_map, color, something unimaginable begins to happen:

The diagram becomes like this:

As i delete or add filters, something starts to appear (with the right colors), but it does not work at all as it should.

I would like to attach two more screenshots that demonstrate what I say above and below, but I have a limit of 5 :frowning:

But if I return only 1 fig (fx1) in the function (and not make_subplots). In this case, the diagram correctly displays the colors.

Please help! How to solve this problem?

Hi @wankrey !
Welcome on the forum :confetti_ball:
Can you provide your whole code? With fake data if it’s confidential.
Help us to help you ! :smile:

Hi! Here is my app:

from dash import html, Output, Input, State, dcc, Dash
import dash_mantine_components as dmc
import plotly.express as px
from plotly.subplots import make_subplots
import pandas as pd
import sqlite3
from dash.exceptions import PreventUpdate

con = sqlite3.connect('factory_db.db')
df = pd.read_sql_query('SELECT * FROM sources', con=con)

CARD_STYLE = dict(withBorder=True,
                  shadow="sm",
                  radius="md",
                  style={'height': '450px'})
REASON_COLORS = dict(zip(df['reason'].unique(), df['color'].unique()))
STATE_COLORS = dict(zip(df['state'].unique(), df['color'].unique()))
COLORS = {x:x for x in df['color'].unique()}


app = Dash(name=__name__)


def get_layout():
    return html.Div([
        dmc.Paper([
            dmc.Grid([
                dmc.Col([
                    dmc.Card([
                        html.H2(children=[f'Client: {i}' for i in df['client_name'].unique()][0]),
                        html.H3(children=[f'Shift day: {i}' for i in df['shift_day'].unique()][0]),
                        html.H3(children=[f'Endpoint name: {i}' for i in df['endpoint_name'].unique()][0]),
                        html.H3(children=[f'State begin: {i}' for i in df['state_begin'].unique()][0]),
                        html.H3(children=[f'State end: {i}' for i in df['state_end'].unique()][-1]),
                        dcc.Dropdown(
                            id='dropdown-input',
                            value=df['reason'].unique(),
                            options=[{'label': x, 'value': x} for x in df['reason'].unique()],
                            multi=True,
                        ),
                        dmc.Button('Filter',
                                   id='dropdown-button'),
                        html.Div(id='indicators')],
                        **CARD_STYLE)
                ], span=6),
                dmc.Col([
                    dmc.Card([
                        html.Div(),
                        dcc.Graph(figure=px.pie(data_frame=df,
                                  values='duration_min', 
                                  names='reason',
                                  color='reason',
                                  color_discrete_map=REASON_COLORS
                                  ))],
                        **CARD_STYLE)], span=6),
                dmc.Col([
                    dmc.Card([
                        html.Div([
                            dcc.Graph(
                                id='timeline-output',
                            )])],
                        withBorder=True,
                        shadow='sm',
                        radius='md',
                        style={'height':'500px'})
                ], span=12),
            ], gutter="xl",)
        ])
    ])


app.layout = get_layout()


@app.callback(
    Output('timeline-output', 'figure'),
    State('dropdown-input', 'value'),
    Input('dropdown-button', 'n_clicks')
)
def graph_filtering(reason, click):
    if reason:
        if len(reason)>0:
            filtered_df = df[df['reason'].isin(reason)]
          
            fx1 = px.timeline(df, 
                              x_start='state_begin', 
                              x_end='state_end',
                              y='endpoint_name',
                              color='reason',
                              color_discrete_map=REASON_COLORS,
                            #   hover_data={
                            #     'state_begin':'|%H',
                            #     'state_end':'|%H',
                            #     'state': True,
                            #     'reason': True,
                            #     'duration_min': True,
                            #     'shift_day': True,
                            #     'period_name': True,
                            #     'operator': True
                            # },
                                    ).update_layout(
                                xaxis={
                                    'side':'top',
                                    'dtick':3.6e+6,
                                    'tickangle':0,
                                    'tickformat':'%H'},
                                yaxis={
                                    'dtick':3.6e+6,
                                    'tickangle':0},
                                showlegend=False).update_traces(hovertemplate=None,
                                              hoverinfo='skip')
            fx2 = px.timeline(filtered_df, 
                              x_start='state_begin', 
                              x_end='state_end', 
                              y='endpoint_name',
                              color='reason',
                              color_discrete_map=REASON_COLORS,
                              hover_data={
                                'state_begin':'|%H',
                                'state_end':'|%H',
                                'state': True,
                                'reason': True,
                                'duration_min': True,
                                'shift_day': True,
                                'period_name': True,
                                'operator': True
                            }
                            ).update_layout(
                                xaxis={
                                    'side':'top',
                                    'dtick':3.6e+6,
                                    'tickangle':0},
                                yaxis={
                                    'dtick':3.6e+6,
                                    'tickangle':0},
                                showlegend=False).update_traces(hovertemplate=
                                '<br>State: <b>%{customdata[0]}</b>'+
                                '<br>Reason: <b>%{customdata[1]}</b>'+
                                '<br>Beginning: <b>%{x|%H:%M:%S</b> (%d.%m)}'+
                                '<br>Duration: <b>%{customdata[2]:.2f}</b> min.'+
                                '<br>'+
                                '<br>Shift day: <b>%{customdata[3]|%d.%m.%Y}</b>'+
                                '<br>Shift: <b>%{customdata[4]}</b>'+
                                '<br>Operator: <b>%{customdata[5]}</b><extra></extra>')
            
            result_figure = make_subplots(specs=[[{'secondary_y':True}]])
            result_figure.add_trace(
                fx1.data[0],
                secondary_y=False
            )
            result_figure.add_trace(
                fx2.data[0],
                secondary_y=True
            )
            result_figure.layout.xaxis = fx1.layout.xaxis
            result_figure.data[0].opacity = 0.2
            result_figure.update_layout(height=300, showlegend=False)
            return result_figure
    else:
        raise PreventUpdate
        

if __name__ == '__main__':
    app.run_server(debug=True, host='0.0.0.0', port='8000')

How can i share sqlitedb for you?

Hi @wankrey !
I think few lines should be enough, I just need to have the structure of your data with few values to have the type, you can use something like

pd.set_option('max_columns', None)
print(df.head(20))

after df = pd.read_sql_query('SELECT * FROM sources', con=con)

There are 200+ values in total, I have selected several for you

Columns:

client_name
endpoint_id
endpoint_name
shift_day
calendar_day
state
status
reason
state_begin
state_end
duration_hour
duration_min
color
period_name
shift_name
operator
operator_auth_start
operator_auth_end
shift_begin
shift_end

Data:

('Brick Factory', 2007, 'Concrete Mixer', '2023-05-12', '2023-05-12', 'Downtime', 'Not working', 'The reason is not specified', '2023-05-12 08:00:00.000', '2023-05-12 08:02:15.000', 0.0375, 2.25, '#ed1c09', 'Daytime', 'Daytime', 'Martin N.L.', '2023-05-11 20:11:02.000', '2023-05-12 08:13:07.000', '08:00:00', '20:00:00')
('Brick Factory', 2007, 'Concrete Mixer', '2023-05-12', '2023-05-12', 'Calculation', 'Not working', 'Calculation', '2023-05-12 08:02:15.000', '2023-05-12 08:02:17.000', 0.0005555555555556, 0.03333333333333333, '#3d0a42', 'Daytime', 'Daytime', 'Martin N.L.', '2023-05-11 20:11:02.000', '2023-05-12 08:13:07.000', '08:00:00', '20:00:00')
('Brick Factory', 2007, 'Concrete Mixer', '2023-05-12', '2023-05-12', 'Downtime', 'Not working', 'Short stop', '2023-05-12 08:02:17.000', '2023-05-12 08:03:04.000', 0.013055555555555556, 0.7833333333333333, '#e0827b', 'Daytime', 'Daytime', 'Martin N.L.', '2023-05-11 20:11:02.000', '2023-05-12 08:13:07.000', '08:00:00', '20:00:00')
('Brick Factory', 2007, 'Concrete Mixer', '2023-05-12', '2023-05-12', 'Downtime', 'Not working', 'Short stop', '2023-05-12 08:03:04.000', '2023-05-12 08:13:07.000', 0.1675, 10.05, '#e0827b', 'Daytime', 'Daytime', 'Martin N.L.', '2023-05-11 20:11:02.000', '2023-05-12 08:13:07.000', '08:00:00', '20:00:00')
('Brick Factory', 2007, 'Concrete Mixer', '2023-05-12', '2023-05-12', 'Calculation', 'Not working', 'Calculation', '2023-05-12 08:13:07.000', '2023-05-12 08:13:08.000', 0.0002777777777778, 0.016666666666666666, '#3d0a42', 'Daytime', 'Daytime', 'Grant J.N.', '2023-05-12 08:13:07.000', '2023-05-12 19:05:30.000', '08:00:00', '20:00:00')
('Brick Factory', 2007, 'Concrete Mixer', '2023-05-12', '2023-05-13', 'Working', 'Working', 'Working', '2023-05-13 03:52:48.000', '2023-05-13 04:50:14.000', 0.9572222222222222, 57.43333333333333, '#2ecc40', 'Nighttime', 'Nighttime', 'Martin N.D.', '2023-05-12 20:16:37.000', None, '08:00:00', '20:00:00')
('Brick Factory', 2007, 'Concrete Mixer', '2023-05-12', '2023-05-13', 'Downtime', 'Not working', 'Short stop', '2023-05-13 04:50:14.000', '2023-05-13 04:52:13.000', 0.03305555555555555, 1.9833333333333334, '#e0827b', 'Nighttime', 'Nighttime', 'Martin N.D.', '2023-05-12 20:16:37.000', None, '08:00:00', '20:00:00')
('Brick Factory', 2007, 'Concrete Mixer', '2023-05-12', '2023-05-13', 'Working', 'Working', 'Working', '2023-05-13 04:52:13.000', '2023-05-13 05:00:00.000', 0.1297222222222222, 7.783333333333333, '#2ecc40', 'Nighttime', 'Nighttime', 'Martin N.D.', '2023-05-12 20:16:37.000', None, '08:00:00', '20:00:00')
('Brick Factory', 2007, 'Concrete Mixer', '2023-05-12', '2023-05-13', 'Working', 'Working', 'Working', '2023-05-13 05:00:00.000', '2023-05-13 05:05:00.000', 0.08333333333333333, 5.0, '#2ecc40', 'Break 5min', 'Nighttime', 'Martin N.D.', '2023-05-12 20:16:37.000', None, '08:00:00', '20:00:00')
('Brick Factory', 2007, 'Concrete Mixer', '2023-05-12', '2023-05-13', 'Working', 'Working', 'Working', '2023-05-13 05:05:00.000', '2023-05-13 05:49:41.000', 0.7447222222222222, 44.68333333333333, '#2ecc40', 'Nighttime', 'Nighttime', 'Martin N.D.', '2023-05-12 20:16:37.000', None, '08:00:00', '20:00:00')
('Brick Factory', 2007, 'Concrete Mixer', '2023-05-12', '2023-05-13', 'Downtime', 'Not working', 'Short stop', '2023-05-13 05:49:41.000', '2023-05-13 05:51:27.000', 0.029444444444444443, 1.7666666666666666, '#e0827b', 'Nighttime', 'Nighttime', 'Martin N.D.', '2023-05-12 20:16:37.000', None, '08:00:00', '20:00:00')
('Brick Factory', 2007, 'Concrete Mixer', '2023-05-12', '2023-05-13', 'Working', 'Working', 'Working', '2023-05-13 05:51:27.000', '2023-05-13 06:30:00.000', 0.6425, 38.55, '#2ecc40', 'Nighttime', 'Nighttime', 'Martin N.D.', '2023-05-12 20:16:37.000', None, '08:00:00', '20:00:00')

Thanks! Should be enough, I’m going to check what’s going on!

OK here we go, I had to do lot of cleaning :innocent:
How does it look? :

from dash import html, Output, Input, State, dcc, Dash, no_update, Patch
import dash_mantine_components as dmc
import plotly.express as px
import pandas as pd
import sqlite3

app = Dash(name=__name__)

con = sqlite3.connect('factory_db.db')
# extract only the columns we use
df = pd.read_sql(
    'sources', con=con,
    columns=["client_name", "shift_day", "endpoint_name", "state_begin", "state_end", 'reason',
             'duration_min', 'color', 'state', 'period_name', 'operator']
)

# modify reason 'The reason is not specified' to 'Not specified'
df['reason'].replace('The reason is not specified', 'Not specified', inplace=True)

CARD_STYLE = dict(withBorder=True, shadow="sm", radius="md", style={'height': '450px'})
REASON_COLORS = dict(zip(df['reason'].unique(), df['color'].unique()))
# create custom labels like 'client_name' to 'Client name'
LABELS = {label: label.replace('_', ' ').capitalize() for label in df.columns}

# Init figures
fig_pie = px.pie(df, values='duration_min', names='reason', color='reason',
                 color_discrete_map=REASON_COLORS, labels=LABELS)

fig_timeline = px.timeline(
    df,
    x_start='state_begin',
    x_end='state_end',
    y='endpoint_name',
    color='reason',
    color_discrete_map=REASON_COLORS,
    labels=LABELS,
    custom_data=['state', 'reason', 'duration_min', "shift_day", 'period_name', 'operator']
)

fig_timeline.update_traces(
    hovertemplate=(
        '<br>State: <b>%{customdata[0]}</b>'
        '<br>Reason: <b>%{customdata[1]}</b>'
        '<br>Beginning: <b>%{base|%H:%M:%S</b> (%d.%m)}'
        '<br>Duration: <b>%{customdata[2]:.2f}</b> min.'
        '<br>'
        '<br>Shift day: <b>%{customdata[3]|%d.%m.%Y}</b>'
        '<br>Shift: <b>%{customdata[4]}</b>'
        '<br>Operator: <b>%{customdata[5]}</b><extra></extra>'
    )
)

fig_timeline.update_xaxes(side='top', dtick=3.6e+6, tickangle=0, tickformat='%H\n%Y-%m-%d')
fig_timeline.update_yaxes(title_text=None)
fig_timeline.update_layout(showlegend=False)


app.layout = dmc.Paper([
    dmc.Grid([
        dmc.Col([
            dmc.Card([
                html.H2(f'Client: {df["client_name"][0]}'),
                html.H3(f'Shift day: {df["shift_day"][0]}'),
                html.H3(f'Endpoint name: {df["endpoint_name"][0]}'),
                html.H3(f'State begin: {df["state_begin"].min()[:-4]}'),
                html.H3(f'State end: {df["state_end"].max()[:-4]}'),
                html.P("Select reasons to highlight:"),
                dcc.Dropdown(
                    id='dropdown-input',
                    value=df['reason'].unique(), options=df['reason'].unique(),
                    multi=True, placeholder='Select reasons'
                ),
                dmc.Button('Filter', id='dropdown-button'),
            ], **CARD_STYLE)
        ], span=6),
        dmc.Col([
            dmc.Card(
                dcc.Graph(figure=fig_pie),
                **CARD_STYLE)
        ], span=6),
        dmc.Col([
            dmc.Card(
                dcc.Graph(id='timeline-output', figure=fig_timeline),
                **CARD_STYLE)
        ], span=12),
    ], gutter="xl")
])


@app.callback(
    Output('timeline-output', 'figure'),
    Input('dropdown-button', 'n_clicks'),
    State('dropdown-input', 'value'),
    State('timeline-output', 'figure'),
    prevent_initial_call=True,
)
def graph_filtering(_, reasons, fig):
    if not reasons:
        return no_update

    # get the index list of the traces (reason groups) to highlight
    filtered_traces = [i for i in range(len(fig['data'])) if fig['data'][i]['name'] in reasons]
    
    # set the opacity to 1 for selected reasons and 0.2 for the others
    # using the new feature Patch to update only the prop 'opacity' without updating the whole figure
    patched_fig = Patch()
    for i in range(len(fig['data'])):
        patched_fig['data'][i]['opacity'] = 1 if i in filtered_traces else 0.2

    return patched_fig


if __name__ == '__main__':
    app.run_server(debug=True)

2 Likes

Everything works as it should! Thank you so much! You saved my life! :slightly_smiling_face:

2 Likes

Some advice:

  • Generally it is better to initialize the figures outside any callback, and add prevent_initial_call=True, in the callback, because at init, the callback can be called several times depending on the number of input you use, hence the figure is re-rendered several times
  • Using plotly.express (px) figure is a good starting point for one figure, but it is not a good idea to create several px and mix them after
  • Px figure are not meant to be used in subplots, using make_subplots, you can create subplots with px using the facet_* parameters

Here I used only one figure with 4 traces (created by px) and modified the opacity of each traces depending of the selected 'reason', using the brand new feature Patch() to update only the parameter opacity instead of the whole figure :grin:

Yes, I’ve already noticed. Thank you for the advice and for this experience!

And I think with this dataset you can make some interesting dashboards :grin: