Dash Crush, part 5: dynamic layout (III) – retrieving state

Post Contents

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:

  1. How to retrieve the state of dynamically created components
  2. 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 to config_store dcc.Store.
    The ruotine build_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:

  1. Sticking to strict IDs convention is key to grouping information from many components together
  2. Pass ALL relevant components' properties to a patching-matching callback
  3. 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.

Share this Post:

Related posts