Dash Crush, part 3: dynamic layout (I) – custom cards

Post Contents

Welcome to Dash Crush, your walk-through tutorial to advanced interactive Data Viz dashboards using Dash Plotly.
In the previous part, we have learned how to implement the progressive disclosure principle to hide and show components.

This chapter highlights how to create dynamic layouts that enable adding, removing, and modifying components.
Since the amount of code is significant, I will split the topic into a few chapters to make it more digestible.
The upcoming chapter will expand functionalities from the previous parts. Let us start with modifying card content.

TL;DR

In this chapter:

  1. How to create a customizable card layout
  2. How to substitute card content in a dynamic way
  3. How to implement expanding of card content

Introduction

We will implement a card that represents a single material layer. The card will define a layer in terms of material (InAs, GaAs, or InGaAs being an alloy of the former two), thickness, and, if the layer is alloy, fraction of constituents. As fraction is relevant only for alloys, the fraction input will not be present for pure materials.

The code

Install packages

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

pip install dash pandas dash-bootstrap-components

The entry point

Let's create the Dash entry point with the MINTY theme and Foundation icons:

# 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

if __name__ == "__main__":
    APP.run_server(debug=True)

The layout

Our layout will consist only of a single card in the middle of the screen.

# src/layout/layout.py

import dash_bootstrap_components as dbc
from src.layout.card import empty_layer
from src.layout.dropdowns import MATERIAL_OPTIONS

def create_layout():
    """Build layout"""
    return dbc.Container(
        [
            dbc.Row(
                [
                    dbc.Col(
                        [empty_layer(index=0, material_options=MATERIAL_OPTIONS)],
                        width={"size": 2, "offset": 5},
                        className="scrollable-pane",
                        style={"backgroundColor": COLORS["light-gray"]},
                    ),
                ]
            ),
        ],
        fluid=True,
    )

className scrollable-pane is defined in assets/scrollable-pane.css and is used to span the Column over the whole height of the screen.

MATERIAL_OPTIONS are passed to dbc.Select's option property, which accepts list of dictionaries with name and label keys:

# src/layout/dropdowns.py

MATERIALS = ["GaAs", "InAs", "InGaAs"]
MATERIAL_OPTIONS = [{"label": name, "value": name} for name in MATERIALS]

Implement the card

The card component will be based on dbc.Card but with a few extensions. The code will be located in src/layout/card.py.

Let's import the packages needed:

# src/layout/card.py

from copy import deepcopy
import dash_bootstrap_components as dbc
from dash import html
from dash import dcc

Usually dbc.Card consists of CardHeader and CardBody. We have also added dcc.Store component to preserve the last value of material dropdown for callback:

def card_layer(index, material_options, material):
    """Card for layer"""

    # card_header =
    # card_body =

    return html.Div(
        [
            dbc.Card([card_header, card_body], className="dc-card"),
            dcc.Store(id={"role": "last_material", "index": index}, data=material),
        ],
        id={"name": "card_layer", "index": index},
    )

Firstly, let's define card_header. We will set up the header to contain an "Expand" button and material selection dropdown. Secondly, to align the components nicely, we will use dbc.InputGroup:

    card_header = dbc.CardHeader(
        dbc.Row(
            [
                dbc.InputGroup(
                    [
                        dbc.Button(
                            html.I(className="fi-play"),
                            id={"role": "card_toggle", "index": index},
                            size="sm",
                        ),
                        dbc.Select(
                            id={"role": "material_dropdown", "index": index},
                            options=deepcopy(material_options),
                            value=material,
                        ),
                    ],
                    size="sm",
                )
            ]
        )
    )

We will pack dbc.CardBody inside dbc.Collapse to make it collapsible by clicking the card_toggle

    card_body = dbc.Collapse(
        dbc.CardBody(children=[], id={"role": "material_params", "index": index}),
        id={"role": "card_collapse", "index": index},
        is_open=True,
    )

The important thing to notice is that all the ids so far are compound. They share the same numeric index field, although the role field differs. This is a rule that we will utilize later to implement interactivity.

Let's add empty_layer that we called in create_layout. It's a convenience wrapper only:

def empty_layer(index, material_options):
    """return empty card"""
    return card_layer(index, material_options, material=None)

At this point, you should see an empty expanded card on your screen. In the next step, let's make things prettier by adding children to our card and styles.

The card's content

Let's add an option to fill the card with content at creation. Start with modifying the card_layer signature to contain the children argument:

def card_layer(index, material_options, children, material):

Now, let's check if there’s any content available, and the card should be open. This code should be located at the beginning of card_layer:

    option_list = [option["value"] for option in material_options]  # list of available options
    card_children = children if material in option_list else [] # use children only if material supported
    body_open = card_children != []  # Open card body if content present
    expand_disabled = card_children == []  # Disable expand button if body empty
    expand_class = "btn-icon expand-marker selected" if body_open else "btn-icon expand-marker"

The snippet above checks if:

  • the selected material is provided and supported
  • there is any valid content
    If so, the card is styled and open.
    expand_class is CSS defined in assets delineating the "Expand" button's appearance for selected and unselected states.

Now, we need to pass expand_disabled and expand_class to the card_toggle button:

                        dbc.Button(
                            html.I(className="fi-play"),
                            id={"role": "card_toggle", "index": index},
                            size="sm",
                            className=expand_class,
                            disabled=expand_disabled,
                        ),

and conditionally make card_body open:

card_body = dbc.Collapse(
        dbc.CardBody(children=card_children, id={"role": "material_params", "index": index}),
        id={"role": "card_collapse", "index": index},
        is_open=body_open,
    )

The last thing to do in this file is the adjustment of the empty_layer wrapper:

def empty_layer(index, material_options):
    """return empty card"""
    return card_layer(index, material_options, children=[], material=None)

At this point, we have an empty card. Let's add some components to the card's body.

The card body's content

Adding an abstract layer will enable us to define a component config dictionary and build layouts from it.
Let's start with the definitions.

The components definition

Let's add this mapping to src/layout/dropdowns.py:

# src/layout/dropdowns.py

MATERIAL_MAPPING = {
    "GaAs": {
        "thickness": {
            "label": "Thickness",
            "component": "input",
            "config": dict(min=5.0, step=1.0, value=10.0, type="number"),
        }
    },
    "InAs": {
        "thickness": {
            "label": "Thickness",
            "component": "input",
            "config": dict(min=5.0, step=1.0, value=10.0, type="number"),
        }
    },
    "InGaAs": {
        "thickness": {
            "label": "Thickness",
            "component": "input",
            "config": dict(min=5.0, step=1.0, value=10.0, type="number"),
        },
        "x": {
            "label": "x as in $$In_{(1-x)}Ga_{(x)}As$$",
            "component": "input",
            "config": dict(min=0.01, step=0.01, max=0.99, value=0.47, type="number"),
        },
    },
}

It describes relevant parameters that should be placed in the card's body for each possible layer material (GaAs, InAs, GaAsIn). label is a displayable text, component determines if we need input or dropdown and config is a configuration structure to be passed to dbc.Input / dbc.Dropdown respectively.

Rendering components

Now, let's fill the card's body with the components. In this example, we use only inputs, but I put the code for dropdown in case you need it for your use case. All the code will be located in src/layout/card_body.py

Let's start with importing the needed packages:

# src/layout/card_body.py

from copy import deepcopy
import dash_bootstrap_components as dbc
from dash import html
from dash import dcc
from src.layout import dropdowns

Our entry point will be the fill_params method that returns components needed for a given material:

def fill_params(index, material, curr_values=None):
    """Fill card body with param input"""
    params = deepcopy(dropdowns.MATERIAL_MAPPING[material])
    sections = []
    for idx, (name, param_dict) in enumerate(params.items()):
        description = {"index": index, "name": name, "material": material}
        curr_value = curr_values[idx] if curr_values else None
        section = _param_section(param_dict, curr_value, description)
        sections.append(section)

    return sections

Let's go step by step. We get a list of needed components from MATERIAL_MAPPING for a given material. Then, we build a section iteratively using the _params_section function. We will define it soon.

The fill_params function can accept curr_values being values of components we want to set. In that case, the length of curr_values should equal the number of components to fill.

One last thing that requires explanation is description. It will be used as a part of compound ID. It's quite verbose, but thanks to that, we will be able to identify each component and retrieve its value. We will use that feature in the future.

Now, let's define _params_section. It's a dispatcher determining, whether the input or dropdown component should be rendered. Supported components are dropdown and input:

def _param_section(param_dict, curr_value, description):
    """single param section with label"""
    if param_dict["component"] == "dropdown":
        section = _dropdown_section(param_dict, curr_value, description)
    elif param_dict["component"] == "input":
        section = _input_section(param_dict, curr_value, description)
    return section

Finally, we can define the actual layout. It will be a labeled component. Input:

def _input_section(param_dict, curr_value, description):
    """single input with label"""
    label = param_dict["label"]
    config = param_dict["config"]

    if curr_value is not None:
        config["value"] = curr_value

    input_id = {"role": "param"}
    input_id.update(description)
    section_id = {"role": "param_section"}
    section_id.update(description)
    return html.Div(
        [dcc.Markdown(label, mathjax=True), dbc.Input(id=input_id, **config)],
        hidden=False,
        id=section_id,
    )

or dropdown:

def _dropdown_section(param_dict, curr_value, description):
    """single dropdown with label"""
    label = param_dict["label"]
    config = param_dict["config"]

    if curr_value is not None:
        config["value"] = curr_value

    dropdown_id = {"role": "param"}
    dropdown_id.update(description)
    section_id = {"role": "param_section"}
    section_id.update(description)
    return html.Div(
        [dcc.Markdown(label, mathjax=True), dcc.Dropdown(id=dropdown_id, **config)], hidden=False, id=section_id
    )

The logic of both sections is similar. Let's explain the potentially unclear points:

  • We create a component label with dcc.Markdown to enable mathematical syntax rendering, the trick to mathjax=True. (You may have noticed before one of the labels being "x as in $$In_{(1-x)}Ga_{(x)}As$$". The double dollar sign is an indicator of math syntax).
  • We assign IDs for both section Div and Component.
  • IDs are based on the description, with role set to param for parameters and param_section for sections. We will use them in callback.
  • We can safely modify config with current_value because we did deepcopy up in the fill_params method.

The CSS Style

The card's style is defined in assets/card.css:

/* assets/card.css */

/* REVISIT: Color requires maintenance when changing theme in config.py */
:root {
    --main-color: #0a3a2a;
    --background-color: #78c2ad;
}

.dc-card .card-header {
    color: var(--main-color);
    background-color: var(--background-color);
    border: 1px solid var(--background-color);
    padding-top: 0.25rem;
    padding-bottom: 0.25rem;
}

.dc-card .btn-icon {
    color: var(--main-color);
    background-color: var(--background-color);
    border: none;
}

.btn-icon.expand-marker {
    margin-right: 0.75rem;
}

.btn-icon.expand-marker.selected i {
    transform: rotate(90deg);
    float: left;
}

.dc-card .card-body {
    border: 1px solid var(--background-color);
    padding: 0.5rem;
    padding-top: 0rem;
}

.dc-card .card-body label{
    margin-top: 0.5rem;
    margin-bottom: 0.5rem;
}

Remark: If we have named the style file card.css, it would have overwritten the built-in card style, so each dbc.Card would be affected implicitly. In our case, we want to modify only the chosen cards explicitly. To do so, we named our custom style dc-card. (dc comes from "Dash Crush`).

We have already used some elements of this CSS, like padding or the "Expand" marker's rotation. The important thing is the color definition. In CSS variables in :root, card colors cannot be set independently for the header and body using API. Therefore, we defined CSS variables --main-color and --background-color. In that way, we can customize the appearance of the card.

Remark: The drawback of this approach is that we need to maintain color definitions and change them anytime we change the theme. I haven't found a way to share variables between Python and CSS. If you know a better solution, please drop me a line.

Callbacks

To make our layout dynamic, we need to add some callbacks.

Expanding the card

The first of them, _toggle_card, is similar to what we did in the previous chapter and is responsible for expanding body collapse.

import json
import dash
from dash import no_update, Input, Output, State, MATCH, ALL
from src.callbacks.utils import get_caller_id
from src.layout.dropdowns import MATERIAL_MAPPING

def create_callbacks(app):
    @app.callback(
        Output({"role": "card_collapse", "index": MATCH}, "is_open"),
        Output({"role": "card_toggle", "index": MATCH}, "className"),
        Output({"role": "card_toggle", "index": MATCH}, "disabled"),
        Input({"role": "card_toggle", "index": MATCH}, "n_clicks"),
        Input({"role": "material_dropdown", "index": MATCH}, "value"),
        State({"role": "step_collapse", "index": MATCH}, "is_open"),
        prevent_initial_call=True,
    )
    def _toggle_card(_, step, is_open):
        caller_id = get_caller_id(dash.callback_context)

        caller = json.loads(caller_id)

        class_name = "btn-icon expand-marker"
        collapse_disabled = False
        collapse_is_open = not is_open

        if caller["role"] == "material_dropdown":
            params_present = any(MATERIAL_MAPPING[step]) if step is not None else False
            params_present = True
            if params_present:
                collapse_is_open = True
            else:
                collapse_is_open = False
                collapse_disabled = True
        else:
            collapse_is_open = not is_open

        if collapse_is_open:
            class_name = "btn-icon expand-marker selected"

        return collapse_is_open, class_name, collapse_disabled

There are two possible triggers for this callback:

  • the card_toggle button clicked - in this case, we only set the collapse to open/close and set the button's style
  • the material_dropdown value changed - in this case, we need to check if the card's body is not empty and enable/disable the card_toggle button accordingly.

Of course, at this stage, we've yet to implement adding content to the body. To do so, we need another callback.

Dynamical card's content

This is the key moment of this chapter. The idea behind the dynamic layout is quite simple: we need a component to remain present during the lifetime of an application and to substitute its children.

from src.layout.card_body import fill_params

    @app.callback(
        Output({"role": "material_params", "index": MATCH}, "children"),
        Output({"role": "last_material", "index": MATCH}, "data"),
        Input({"role": "material_dropdown", "index": MATCH}, "value"),
        State({"role": "param", "index": MATCH, "name": ALL, "step": ALL}, "value"),
        State({"role": "last_material", "index": MATCH}, "data"),
        prevent_initial_call=True,
    )
    def _setup_card(material, current_values, last_material):
        """set card content according to chosen processing step"""

        caller_id = get_caller_id(dash.callback_context)
        caller = json.loads(caller_id)

        out_params = []

        if material:
            values = current_values if material == last_material else None
            out_params = fill_params(caller["index"], material, values)

        return out_params, material

The important part is to identify dynamic components at any point in time. For that, I use a strict convention for building component IDs. If you have other ideas for building dynamic layouts, please share.

Creating callbacks

The last step is creating callbacks in dashboard.by as we always do.

# dashboard.py

from src.callbacks.card import create_callbacks

And finally

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. Use dbc.Collapse and CSS styles to customize dbc.Cards
  2. To make the dynamic layout, substitute the children of your anchor components in the callback
  3. Sticking to strict ID conventions with children will do the job for you
  4. Use dcc.Markdown(..., mathjax=True) for rendering math

It was not so difficult, was it?

The complete code from this chapter can be found on GitHub:
https://github.com/ptrhbt/dash-crush/tree/3-dynamic-layout-1-card

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

I would be happy to hear your opinions about Dash Crush.

See you in the next chapter of Dash Crush! We will continue perfecting the dynamic layout. This time, we will handle multiple cards.

Share this Post:

Related posts