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:
- How to create a customizable card layout
- How to substitute card content in a dynamic way
- 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 id
s 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 tomathjax=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 toparam
for parameters andparam_section
for sections. We will use them in callback. - We can safely modify
config
withcurrent_value
because we diddeepcopy
up in thefill_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 thecard_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:
- Use
dbc.Collapse
and CSS styles to customizedbc.Cards
- To make the dynamic layout, substitute the children of your anchor components in the callback
- Sticking to strict ID conventions with children will do the job for you
- 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.