Dash Crush, part 2: progressive disclosure

Post Contents

Welcome to Dash Crush, your walk-through tutorial to advanced interactive Data Viz dashboards using Dash Plotly.
In the previous chapter, we learned how to use dash_bootstrap_components' themes, styles, and custom CSS to make pretty layouts. In this part, we will grasp one basic but powerful concept of Data Visualization that will help you make your dashboards user-friendly.

TL;DR

In this chapter:

  1. Progressive disclosure
  2. Using icons
  3. Hiding content from users
  4. Introduction to pattern-matching callbacks

The Zen of Data Visualization

Dash is a fantastic tool for building dashboards. Nevertheless, even the best tool does not do everything for its users. Data Visualization theory provides good practices that, for no money, make your app more user-friendly. I use extensively, especially two, which I would like to share:

  1. Single-screen experience: fitting the content of an app on a single screen saves users from distracting scrolling.
  2. Progressive disclosure: less is best, as it does not overwhelm the user. If an option is not relevant in the current context - hide it.

We have already used single-screen experience in the previous parts by fitting our layout to the visible area, but no more. To illustrate progressive disclosure, we will modify the code from the last part (you can find it here: https://github.com/ptrhbt/dash-crush/tree/1-layout) to show and hide information depending on the user's action.

The code

We will use dash-bootstrap-components' Collapse to implement an app that modifies the visibility of elements according to the user's action. We will also familiarize ourselves with Foundation icons and make our first introduction to pattern-matching callbacks. Let's go!

Install packages

Create and activate your virtual environment, and install the following packages:

pip install dash pandas dash-bootstrap-components

Use Foundation icons

Our entry point is dashboard.py. We pass the DBC theme and link to Foundation Icons' CSS as external stylesheets to the Dash constructor:

# dashboard.py

import dash
import dash_bootstrap_components as dbc
from src.layout import layout

EXTERNAL_STYLESHEETS = [
    dbc.themes.MINTY,
    "https://cdnjs.cloudflare.com/ajax/libs/foundicons/3.0.0/foundation-icons.min.css"
]

APP = dash.Dash(__name__, external_stylesheets=EXTERNAL_STYLESHEETS)
APP.layout = layout.create_layout

# Run the Dash app
if __name__ == "__main__":
    APP.run_server(debug=True)

The layout

Our layout files will be located in the src/layout directory. The navbar, plot pane, grid, and style are the same as in the previous chapter. The changes will be done mostly in the sidebar and work_pane and one small change in the top-level layout.

The sidebar

Our sidebar will consist of an expandable menu, built using dbc.Collapse, and a "Plot" button.
The complete code looks like:

# src/layout/sidebar.py

from dash import html
import dash_bootstrap_components as dbc
from src.layout.grid import spacer

def sidebar_menu():
    """Sidebar menu"""
    return html.Div(
        [
            _menu_collapse(),
            spacer(10),
            _buttons(),
        ]
    )

def _menu_collapse():
    """Collapsible menu"""
    return html.Div(
        [
            html.Div(
                dbc.Button(
                    [html.I(className="fi-play"), "Menu"],
                    id={"role": "sidebar_collapse_toggle", "index": "menu"},
                    color="primary",
                    outline=True,
                    className="btn-sidebar-1st selected",
                    style={"width": "100%"},
                )
            ),
            dbc.Collapse(
                dbc.ButtonGroup(
                    [
                        dbc.Button(
                            "Material",
                            id={"role": "menu_button", "index": "material"},
                            color="primary",
                            outline=True,
                            className="btn-sidebar-2nd",
                        ),
                        dbc.Button(
                            "Color",
                            id={"role": "menu_button", "index": "color"},
                            color="primary",
                            outline=True,
                            className="btn-sidebar-2nd",
                        ),
                    ],
                    vertical=True,
                    style={"width": "100%"},
                ),
                id={"role": "sidebar_collapse", "index": "menu"},
                is_open=True,
            ),
        ]
    )

def _buttons():
    return html.Div(
        [
            dbc.Button("Plot", id="plot_button", color="primary"),
        ],
        className="d-grid gap-2",
    )

Let's go through the key elements of it:

  • the menu consists of the trigger button "Menu" and the collapse section that contains two more buttons, "Material" and "Color".
  • additionally, the "Menu" button contains an icon. We use Foundation icons. A complete list of available icons can be found here: https://zurb.com/playground/foundation-icon-fonts-3. We need the html.I component to use the foundation icon in our code. Just pass the icon name prefixed with fi- as className, e.g.: html.I(className="fi-play")
  • inner buttons are styled as a single continuous menu using dbc.ButtonGroup.
    Buttons have the compound id being dict like: id={"role": "menu_button", "index": "color"},. They are needed to use pattern-match callbacks. role defines what each component does. And in our case, this is common for all buttons. However, index differentiates elements within groups but is common for each component handling one functionality.
  • the visibility of collapse will be modified using a callback.
  • to style the appearance of the "Menu" button:
    • span them to the full width of the sidebar
    • differentiate the top-level button and nested buttons' looks
    • mark which button has been selected
      To do so, we will use custom CSS passed with className. We will add CSS code later.

The work pane

Our work pane will consist of two collapses to separate different groups of parameters. One will be used for material selection, the other for visual parameters (color palette selection for the plot).

Here is the complete code:

# src/layout/work_pane.py

from dash import html, dcc
import dash_bootstrap_components as dbc
from src.layout.grid import spacer
from src.layout.style import COLORS

MATERIALS = ["GaAs", "InAs"]
PALETTES = ["Plotly", "Bold", "Vivid"]

def work_pane():
    """Working area"""
    return html.Div(
        [
            # REMARK: collapse order should be the same as button order in sidebar
            _material_collapse(),
            _color_collapse(),
        ],
        style={"backgroundColor": COLORS["light-gray"]},
        className="scrollable-pane",
        id="work_pane",
    )

def _material_collapse():
    """Collapsible container for material"""
    return dbc.Collapse(
        html.Div(
            [
                spacer(10),
                html.Label("Material"),
                dcc.Dropdown(
                    id="material_dropdown",
                    options=[{"label": name, "value": name} for name in MATERIALS],
                    value="GaAs",
                ),
            ]
        ),
        style={"backgroundColor": COLORS["light-gray"]},
        id={"role": "workpane_collapse", "index": "material"},
        is_open=True,
    )

def _color_collapse():
    """Collapsible container for color"""
    return dbc.Collapse(
        html.Div(
            [
                spacer(10),
                html.Label("Color palette"),
                dcc.Dropdown(
                    id="color_dropdown",
                    options=[{"label": name, "value": name} for name in PALETTES],
                    value="Plotly",
                ),
            ]
        ),
        style={"backgroundColor": COLORS["light-gray"]},
        id={"role": "workpane_collapse", "index": "color"},
    )

The important thing to notice is that dbc.Collapses also have the compound id. Both collapses have the same role workpane_collapse but index should match the index field of a specific button in the sidebar, which will trigger the collapse.

The top-level layout

One small aesthetic change in the layout is to remove padding around the content of the sidebar column. Add the following class:

className="padding-less",

to dbc.Col containing the sidebar menu. We will define the padding-less class in CSS later.

Custom CSS

We will differentiate the look of the top and nested sidebar buttons using custom CSS located in assets/sidebar-menu.css

/* assets/sidebar-menu.css */

.sidebar-menu {
    min-height: 100vh; /* full-visible-height */
}

.btn-sidebar-1st {
    border-bottom: 1px solid #dfe8f3;
    border-top: 1px solid #dfe8f3;
    border-left: none;
    border-right: none;
}

.btn-sidebar-1st:focus {
    box-shadow: none;
}

.btn-sidebar-1st.selected i {
    transform: rotate(90deg);
}

.btn-sidebar-2nd {
    background-color: #fafafa;
    border-bottom: 1px solid #dfe8f3;
    border-top: 1px solid #dfe8f3;
    border-left: none;
    border-right: none;
}

.btn-sidebar-2nd:focus {
    box-shadow: none;
}

.btn-sidebar-2nd.selected {
    border-left: 4px solid;
}

Interesting points:

  • .btn-sidebar-1st and .btn-sidebar-2nd describe the appearance when the button is in the "idle" state.
  • the suffix :focus in the style describes what happens to the button when it is focused with the mouse (no shadow)
  • the suffix .selected describes what happens when a button is clicked:
    • .btn-sidebar-1st.selected i will have the icon rotated by 90 degrees (i means it is the style of an icon)
    • .btn-sidebar-2nd.selected will have a left border that is bolder
      We need to set the style of the selected button in callbacks to modify its style.

To style the layout of text and icons on the button, we use the following CSS (assets/button.css):

/* assets/button.css */

button i {
    margin-left: 0.5rem;
    float: left;
}

.btn i {
    margin-left: 0.5rem;
    float: left;
}

.btn-icon i {
    margin-left: 0rem;
    float: none;
}

Will we define one more style in assets/padding-less.css to remove the padding from the sidebar column:

/* assets/padding-less.css */

.padding-less {
    padding: 0 0 0 0;
}

Callbacks

The layout's definition is done. However, we still need to put the interactivity inside our app. Therefore, we will add callbacks to bring our app to life through engaging interaction. And since we need more than one callback, we will structure them similarly to the layout in the src/callbacks folder.

The progressive disclosure

Firstly, we will add a callback responsible for:

  • collapsing the menu in the sidebar and changing the style of selected buttons
  • collapsing work areas in the work pane
The sidebar

Firstly, we add a callback that handles clicking on the "Menu" button, toggles underlying options collapse, and changes the appearance of the "Menu" button. It will be located in src/callbacks/sidebar.py:

# src/callbacks/sidebar.py

from dash import dcc, Input, Output, State, MATCH

def create_callbacks(app):
    @app.callback(
        Output({"role": "sidebar_collapse", "index": MATCH}, "is_open"),
        Output({"role": "sidebar_collapse_toggle", "index": MATCH}, "className"),
        Input({"role": "sidebar_collapse_toggle", "index": MATCH}, "n_clicks"),
        State({"role": "sidebar_collapse", "index": MATCH}, "is_open"),
        prevent_initial_call=True,
    )
    def _toggle_menu(_, is_open):
        collapse_is_open = not is_open

        class_name = "btn-sidebar-1st"
        if collapse_is_open:
            class_name = "btn-sidebar-1st selected"
        return collapse_is_open, class_name

We use Plotly's mechanism called "pattern matching callbacks". In our case, it's handled by the keyword MATCH. By using it we order Plotly to execute a callback for the elements that have matching index fields.

Let's analyze the logic: the trigger is clicking on the top-level button. We evaluate the state of the corresponding collapse. If it was open - we close it, if it was closed - we open it. Finally, we modify the style of the top-level button. The visual effect will be a rotation of the icon by 90 degrees.

(Note: in our case, we have only one top-level button, so a pattern-matching callback is an over-engineering, as we could use a regular callback)

The work pane

Now, we add another callback to src/callbacks/work_pane.py that will:

  • trigger the work pane's collapse
  • modify the style of nested sidebar buttons.
# src/callbacks/work_pane.py

import json
import dash
from dash import Input, Output, State, ALL
from src.callbacks.utils import get_caller_id

def create_callbacks(app):
    """Create workpane callbacks"""

    @app.callback(
        Output({"role": "workpane_collapse", "index": ALL}, "is_open"),
        Output({"role": "processing_button", "index": ALL}, "className"),
        Input({"role": "menu_button", "index": ALL}, "n_clicks"),
        State({"role": "menu_button", "index": ALL}, "id"),
        prevent_initial_call=True,
    )
    def _toggle_work_pane(_, ids):
        caller_id = get_caller_id(dash.callback_context)
        caller = json.loads(caller_id)

        is_open = [entry["index"] == caller["index"] for entry in ids]
        class_names = [
            "btn-sidebar-2nd selected" if entry["index"] == caller["index"] else "btn-sidebar-2nd"
            for entry in ids
        ]

        return is_open, class_names

Again, we use pattern-matching callbacks, but this time differently. We use the keyword ALL, which means that when the Input property of one component changes, data from all components is passed to the callback function. We need to do it this way because we want the action of opening the work pane collapse to be exclusive, i.e., when one collapse opens, the others should be closed.

Furthermore, we achieve that purpose by using two mechanisms:

  • passing ids of all components to the callback function as a state.
  • determining what the actual trigger of the callback was.

The first part is straightforward. Second, we need to inspect the so-called callback_context of Dash. It preserves information about the source of the callback. As it is quite an often action, I have implemented the handy helper get_caller_id, stored in src/callbacks/utils.py:

# src/callbacks/utils.py

from dash.exceptions import PreventUpdate

def get_caller_id(context):
    """Get caller id from dash callback context"""
    if not context.triggered:
        raise PreventUpdate

    caller_id = context.triggered[0]["prop_id"].split(".")[0]

    return caller_id

As you can see, there is no magic there, but we need to traverse the context.

Finally, we iterate over all the ids twice to determine if each collapse is open and assign className to trigger buttons in the sidebar.

Plotting

The last callback to implement will be plotting. We will modify callbacks from the previous chapter to evaluate the color palette. The code will be located in src/callbacks/plot_pane.py.

# src/callbacks/plot_pane.py

from itertools import cycle
from dash import Input, Output, State
from dash.exceptions import PreventUpdate
import pandas as pd
import plotly.graph_objs as go
import plotly.express as px

MATERIAL_DATA = {"GaAs": pd.read_csv("GaAs.dat"), "InAs": pd.read_csv("InAs.dat")}
PALETTES = {
    "Plotly": px.colors.qualitative.Plotly,
    "Bold": px.colors.qualitative.Bold,
    "Vivid": px.colors.qualitative.Vivid,
}

TRACES = ["E_so", "E_lh", "E_hh", "E_c"]

def create_figure(to_draw):
    """Style graph"""
    layout = go.Layout(
        autosize=True,
        title="Band structure",
        xaxis={"title": "k (wave vector) [1/nm]"},
        yaxis={"title": "E (Energy) [eV]"},
    )
    return go.Figure(data=to_draw, layout=layout)

def create_callbacks(app):
    """Create callbacks"""

    @app.callback(
        Output("band_structure_graph", "figure"),
        Input("plot_button", "n_clicks"),
        State("material_dropdown", "value"),
        State("color_dropdown", "value"),
        prevent_initial_call=True,
    )
    def plot(n_clicks, material, color_palette):  # pylint: disable=unused-argument
        if not n_clicks or not material or not color_palette:
            raise PreventUpdate

        data = MATERIAL_DATA[material]
        palette = cycle(PALETTES[color_palette])

        to_draw = []
        for trace in TRACES:
            to_draw.append(
                go.Scatter(
                    x=data["k"], y=data[trace], mode="lines", name=trace, marker_color=next(palette)
                )
            )

        return create_figure(to_draw)

Let's go through the code. We pass color_palette and material as states to plotting callbacks and use them to select the data to be plotted and the palette to be used. We use three of the color palettes defined in Plotly Express. itertools' cycle is used to convert a list into a cyclical iterator (to prevent exceeding the range). Finally, we assign marker_color as the next color from the palette for each trace.

Using callbacks

The final step is creating callbacks in dashboard.py to be able to use them. To do so, we import all the callbacks:

import src.callbacks.sidebar
import src.callbacks.work_pane
import src.callbacks.plot_pane

Wrap them into function:

def create_callbacks(app):
    """Create all callabacks"""
    src.callbacks.sidebar.create_callbacks(app)
    src.callbacks.work_pane.create_callbacks(app)
    src.callbacks.plot_pane.create_callbacks(app)

And execute the function on the instance of APP:

create_callbacks(APP)

We are ready to go!

Starting the app:

Start the app with python dashboard.py. You should see an indication of a running app in your console. The dashboard is available under http://localhost:8050.

Summary:

Quick recap:

  1. Hide from the user all the controls that are not relevant.
  2. Add "https://cdnjs.cloudflare.com/ajax/libs/foundicons/3.0.0/foundation-icons.min.css" to EXTERNAL_STYLESHEETS to use Foundation icons and refer to them via html.I component with the fi- prefix.
  3. What you can't style with pure Dash, you can most probably style with CSS.
  4. Use compound IDs and access your components with pattern-matching special keywords MATCH and ALL.

The complete code from this chapter can be found on GitHub:
https://github.com/ptrhbt/dash-crush/tree/2-progressive-disclosure

There is also the file requirements.txt which lists all packages used. You may install it to your virtual env with pip install -r requirements.txt.

References:

For expanding your knowledge of topics from this chapter, you may want to visit:

We will explore more of Dash magic in the upcoming parts of Dash Crush.

Share this Post:

Related posts