Welcome to Dash Crush, your walk-through tutorial to advanced interactive Data Viz dashboards using Dash Plotly.
In the previous part, we learned how to add, remove, and reorder multiple dbc.Card
's inside a parent container.
In this chapter, we will continue our adventure with dynamic layouts. We will use the code from the two previous chapters and learn how to retrieve the state of dynamically created components.
TL;DR
In this chapter:
- How to retrieve the state of dynamically created components
- How to plot shapes in Graph
Introduction
The card represents a single layer of material and its properties. Firstly, we will use multiple cards to define the geometry of a multilayered structure. Next, we will retrieve the parameters of each layer to create a graph illustrating the structure's geometry.
The code
We will reuse the code from the previous parts of our Dash Plotly tutorial (card and deck components and callbacks). Thus, I will describe here the changes made on top of them. We will also use the visual layout described in part 2.
Install packages
Create and activate your virtual environment and install the following packages:
pip install dash pandas dash-bootstrap-components colorlover
We haven't used colorlover
yet. It's a package enabling adding color to scales manipulation. See https://github.com/plotly/colorlover for details.
The layout
The main layout file
Let's create the layout we are already familiar with from part 2: the sidebar, the work pane, and the main pane with a plot pane and a navbar. The code is located in src/layout/layout.py
:
# src/layout/layout.py
import dash_bootstrap_components as dbc
from src.layout.navbar import navbar
from src.layout.sidebar import sidebar_menu
from src.layout.plot_pane import plot_pane
from src.layout.style import COLORS
from src.layout.work_pane import work_pane
def create_layout():
"""Build layout"""
return dbc.Container(
[
dbc.Row(
[
dbc.Col(
[
sidebar_menu(),
],
width=1,
style={"backgroundColor": COLORS["white"]},
),
dbc.Col(
[work_pane()], width=2, style={"backgroundColor": COLORS["light-gray"]}
),
dbc.Col(
[navbar(), plot_pane()],
width=9,
),
]
),
],
fluid=True,
)
The work pane
The card deck, which was the main feature in the previous part, will now be placed in work_pane
(src/layout/work_pane.py)
:
#src/layout/work_pane.py
from dash import html, dcc
from src.layout.add_bar import add_bar
from src.layout.card import card_bulk
from src.layout.deck import deck
from src.layout.grid import spacer
from src.layout.style import COLORS
def work_pane():
"""Work pane"""
return html.Div(
[
spacer(10),
add_bar(),
spacer(10),
card_bulk(),
spacer(10),
deck(),
dcc.Store(id="config_store", data={}),
],
style={"backgroundColor": COLORS["light-gray"]},
className="scrollable-pane",
id="work_pane",
)
work_pane
also contains the dcc.Store
component config_store
. We will use it to preserve the configuration of geometry to plot.
We also added a new static card card_bulk
, that will represent the substrate of our structure.
The bulk card
card_bulk
is defined in src/layout/card.py
. Place the following code there:
# src/layout/card.py
from src.layout.dropdowns import BULK_OPTIONS
def card_bulk():
"""Bulk layer"""
card_header = dbc.CardHeader(
[
dbc.Row(
[
dbc.InputGroup(
[
dbc.Button(
html.I(className="fi-play"),
id="bulk_collapse_toggle",
className="btn-icon expand-marker selected",
size="sm",
disabled=False,
),
"Substrate",
],
size="sm",
)
]
)
]
)
card_body = dbc.Collapse(
dbc.CardBody(
children=[
html.Div(
children=[
html.Label("Material"),
dcc.Dropdown(id="bulk_material", options=BULK_OPTIONS, value="GaAs"),
],
hidden=False,
id="bulk_section",
),
],
id="bulk_options",
),
id="bulk_collapse",
is_open=True,
)
return html.Div([dbc.Card([card_header, card_body], className="dc-card")], id="card_bulk")
The bulk card consists of a header with a "Substrate" label, an "Expand" button, and a material selection Dropdown in the collapsible CardBody
. The bulk card is static, so there is no need for "Remove" or "Move Up/Down" buttons. The allowed bulk options are defined in src/layout/dropdowns.py
:
# src/layout/dropdowns.py
BULKS = ["GaAs", "InAs"]
BULK_OPTIONS = [{"label": name, "value": name} for name in BULKS]
The sidebar
The sidebar will contain only a single "Plot" button (src/layout/sidebar.py
).
# 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(
[
spacer(10),
_buttons(),
]
)
def _buttons():
return html.Div(
[
dbc.Button("Plot", id="plot_button", color="primary"),
],
className="d-grid gap-2",
)
The main pane
The main pane will consist of a standard navbar (src/layout/navbar.py
)
# src/layout/navbar.py
import dash_bootstrap_components as dbc
from dash import html
from src.layout.style import NAVBAR_DISPLAY_STYLE
def navbar():
"""Navbar"""
return html.Div(
html.Nav(
children=[
dbc.Row(
[
dbc.Col(
html.Label(
"Welcome to nanostructure vizualization dashboard",
style=NAVBAR_DISPLAY_STYLE,
className="text-primary",
),
width={"size": 6, "offset": 3},
),
],
),
]
)
)
and a plot pane, which we will utilize to plot the geometry (src/layout/plot_pane.py
)
"""Plot pane"""
from dash import dcc
from dash import html
import plotly.graph_objs as go
def create_empty_figure():
"""Style empty figure"""
layout = go.Layout(autosize=True)
return go.Figure(data=[], layout=layout)
def plot_pane():
"""Plot pane"""
return html.Div(
dcc.Graph(
id="geometry_graph",
figure=create_empty_figure(),
className="dc-graph",
),
)
CSS Styles
Let's style dcc.Graph
to use the whole available screen height (assets/dc-graph.css
):
/* assets/dc-graph.css */
.dc-graph {
height: calc(100vh - 50px); /* full-visible-height - navbar */
}
Now, we have our layout ready. Nevertheless, the actual work is done in callbacks.
Callbacks
We will use chained callbacks to plot the geometry. The first one will create a configuration structure, and the second will do the actual plotting. But first, let's add one more callback to handle the interactions with card_bulk
.
The bulk card callback
It will be placed in src/callbacks/card.py
and will be responsible for collapsing the card and styling the "Expand" button:
# src/callbacks/card.py
@app.callback(
Output("bulk_collapse", "is_open"),
Output("bulk_collapse_toggle", "className"),
Input("bulk_collapse_toggle", "n_clicks"),
State("bulk_collapse", "is_open"),
prevent_initial_call=True,
)
def _toggle_bulk(_, is_open):
collapse_is_open = not is_open
class_name = "btn-icon expand-marker"
if collapse_is_open:
class_name = "btn-icon expand-marker selected"
return collapse_is_open, class_name
The config callback
Let's create a callback that will pick all the dynamically created parameter components and turn them into a configuration structure for geometry. The configuration structure will be in the form of a list of dicts, where each dict corresponds to sa ingle layer.
The callback routine
We will implement that functionality in a few steps. All the code is placed in src/callbacks/config.py
. Let's start with the callback routine:
# src/callbacks/config.py
import logging
from dash import Input, Output, State, ALL
from src.utils.config import build_layer_config
def create_callbacks(app):
"""Create config callbacks"""
@app.callback(
Output("config_store", "data"),
Input("plot_button", "n_clicks"),
State("bulk_material", "value"),
State({"role": "material_dropdown", "index": ALL}, "value"),
State({"role": "material_dropdown", "index": ALL}, "id"),
State({"role": "param", "index": ALL, "name": ALL, "material": ALL}, "value"),
State({"role": "param", "index": ALL, "name": ALL, "material": ALL}, "id"),
prevent_initial_call=True,
)
def _create_config(
_, substrate, dropdowns, dropdown_ids, params, param_ids
): # pylint: disable=too-many-arguments
layers = build_layer_config(dropdowns, dropdown_ids, params, param_ids)
config = {"substrate": {"material": substrate}, "layers": layers}
logging.info("Structure config: %s", config)
return config
This callback is triggered by clicking the "Plot" button. It collects:
- material of bulk from
card_bulk
- IDs and values of all material selection dropdowns and all
param
components, bundles them into layer config, and saves the dictionary of all params toconfig_store
dcc.Store
.
The ruotinebuild_layer_config
is implemented in a separate file.
The layer config
Let's implement building for layer config. We start with mapping material value to the dropdown index. (src/utils/config.py
)
# src/utils/config.py
def _map_materials(materials, ids):
"""map material value to ID's index"""
result = {}
for step_id, value in zip(ids, materials):
step_idx = step_id["index"]
if step_idx in result:
raise ValueError(f"Duplicated index {step_idx}")
result[step_idx] = value
return result
We iterate over all the dropdowns and build the dictionary {index: material}
.
if step_idx in result:
raise ValueError(f"Duplicated index {step_idx}")
The code above is a paranoid check, as the index
field should be unique.
Parameter mapping
Now, let's implement similar bundling for parameter components. Additionally, we group all the parameters for the same index to get the dictionary {index: {field1: param1, field2: param2}}
.
def _map(params, ids):
"""group params sharing common ID's index"""
result = {}
for param_id, value in zip(ids, params):
index = param_id["index"]
param_name = param_id["name"]
if index not in result:
result[index] = {}
result[index][param_name] = value
return result
The layer config
Finally, let's merge the two mappings to get dthe esired structure [{material: value, params: {...}}]
def build_layer_config(dropdowns, dropdown_ids, params, param_ids):
"""build layer configs"""
dropdown_map = _map_dropdowns(dropdowns, dropdown_ids)
param_configs = _map(params, param_ids)
layer_config = [
{"material": material, "params": param_configs.get(idx, {})}
for idx, material in dropdown_map.items()
if material is not None
]
return layer_config
Here, we also filter out the dropdowns without a material selected.
The plotting callback
The config created with the _build_config
callback will be the plotting routine's trigger (src/callbacks/plot.py
).
If a callback did fire whenever any component changed, we would have needed lots of computing power. Instead, we do it as mentioned to limit the number of executed callbacks. This is important, especially when creating a graph requires heavy computations.
Let's import the needed packages:
# src/callbacks/plot.py
import colorlover
from dash import Input, Output
from dash.exceptions import PreventUpdate
import numpy as np
import plotly.graph_objs as go
from src.layout.dropdowns import MATERIALS
and define the color for plotting material layers. We will use the Blues
color scale:
COLORSCALE = colorlover.scales["3"]["seq"]["Blues"]
COLORS = {material: COLORSCALE[idx]for idx, material in enumerate(MATERIALS)}
The actual callback itself will take the layer config and create a plot Figure
:
def create_callbacks(app):
"""Create callbacks"""
@app.callback(
Output("geometry_graph", "figure"),
Input("config_store", "data"),
prevent_initial_call=True,
)
def _plot(config):
if not config:
raise PreventUpdate
areas, annotations = _plot_structure(config)
figure = create_figure(areas, annotations)
return figure
Now, let's implement _plot_structure
- a routine responsible for creating the graph.
Our graph will consist of two elements. This one will be rectangles that will represent the substrate and different layers.
def _plot_structure(config):
"""Plot structure geometry"""
layers = config["layers"]
thicknesses = [layer["params"]["thickness"] for layer in layers]
boarders = np.cumsum([0.0] + thicknesses)
materials = [layer["material"] for layer in layers]
substrate_area = [
{
"type": "rect",
"xref": "x",
"yref": "paper",
"x0": -2,
"y0": 0,
"x1": 0,
"y1": 1,
"fillcolor": COLORS[config["substrate"]["material"]],
"opacity": 0.2,
"line": {"width": 1, "color": "black", "dash": "longdash"},
}
]
layer_areas = [
{
"type": "rect",
"xref": "x",
"yref": "paper",
"x0": boarders[idx],
"y0": 0,
"x1": boarders[idx + 1],
"y1": 1,
"fillcolor": COLORS[material],
"opacity": 0.2,
"line": {"width": 1, "color": "black", "dash": "longdash"},
}
for idx, material in enumerate(materials)
]
areas = substrate_area + layer_areas
Layers are stacked along the x-axis. To calculate the border points of layers, we need to sum their thicknesses.
The other element is annotation texts placed over each rectangle, displaying the material's name:
positions = [boarders[idx] + 0.5 * thicknesses[idx] for idx in range(len(materials))]
annotations = go.Scatter(
x=positions,
y=[0.5 for _ in range(len(positions))],
mode="text",
text=materials,
textposition="top center",
textfont={"size": 16},
showlegend=False,
)
Annotations are both vertically and horizontally centered in each layer.
Finally, we return the needed elements:
return areas, annotations
The last piece of the puzzle is styling the figure. We set titles, turn off grids, set the y-axis range, and most importantly, pass areas
and annotations
to the Figure
object:
def create_figure(areas, annotations):
"""Style graph"""
layout = go.Layout(
autosize=True,
title="Nanostructure",
xaxis={"title": "dimension [nm]", "showgrid": False},
yaxis={"range":[0, 1], "showgrid": False},
shapes=areas,
modebar={"orientation": "v"},
)
return go.Figure(data=annotations, layout=layout)
Using the callback
Finally, we need to create all the callbacks. In dashboard.py
modify the callback creation with:
# dashboard.py
import src.callbacks.config
import src.callbacks.plot
and in create_callbacks(app)
:
# Append at the end of the function
src.callbacks.config.create_callbacks(app)
src.callbacks.plot.create_callbacks(app)
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
A quick recap:
- Sticking to strict IDs convention is key to grouping information from many components together
- Pass
ALL
relevant components' properties to a patching-matching callback - Evaluate
dash.context
to find out what action triggered the callback
The complete code from this chapter can be found on GitHub:
https://github.com/ptrhbt/dash-crush/tree/5-dynamic-layout-3-extraction
There is also the requirements.txt
file, which lists all the packages used. Just 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 part of Dash Crush! We will further explore the topic of configs and learn how to serialize the state of your app to a JSON file.