Skip to content

UI Styling

The haskoning_equation package provides a comprehensive styling system for building consistent and interactive user interfaces. This includes styling for charts, tables, and individual form fields.

Overview

The styling system provides three main capabilities:

  • CardStyle — Consistent card-like styling for components with titles, subtitles, and icons
  • FieldStyle — Control UI rendering of individual form fields
  • Component Styling — Material Design Icons (MDI) support and theming

CardStyle

CardStyle applies consistent card-like styling to chart or input components. It can be used at the field level or class level.

Split-layout: Field-Level Styling

This is the way to style a Split-layout application. An example is given here of a single panel in the UI. Field titles, descriptions and default values can be used to customize the way input fields are displayed. To style the panel itself, embed a CardStyle in a Pydantic field's json_schema_extra. Note the nesting of the basemodels in this example.

python
from enum import StrEnum
from pydantic import BaseModel, Field
from haskoning_equation.style import FloatFieldStyle
from haskoning_equation.units import units

class WlcDestination(StrEnum):
    INFLUENT = "Influent (buffer)"
    WLC_BUFFER = "WLC Buffer"
    EFFLUENT = "Effluent"
    OUT_OF_SCOPE = "Outside the Nereda-scope"

class NeredaReactorSizing(InputBaseModel):
    number_reactors: Annotated[int, Field(
        title="Number of Reactors",
        description="Number of reactors",
        ge=1,
        default=6,
        json_schema_extra={'unit': units.DIMENSIONLESS},
    )]
    reactor_volume: Annotated[float, Field(
        title="Volume per Reactor: Process",
        description="Process volume of a reactor. Possibly includes extra volume to "
        "accommodate the transition of DWF to RWF in case of CF without and IB.",
        ge=100,
        default=12000,
        json_schema_extra={'unit': units.VOLUME_STANDARD},
    )]
    water_depth_process: Annotated[float, Field(
        title="Water Depth: Process",
        description="Water depth when the reactor is aerating",
        ge=4.5,
        default=8.0,
        json_schema_extra={'unit': units.LENGTH_STANDARD},
    )]
    wlc_height: Annotated[float, Field(
        title="Water level correction height",
        description="Height for the water level correction (minimum value: 0.15 m)",
        default=0.15,
        ge=0.15,
        json_schema_extra={'unit': units.LENGTH_STANDARD},
    )]
    wlc_safety_margin: Annotated[float, Field(
        title="WLC Safety Margin",
        description="Safety margin for the water level correction",
        default=0.05,
        frozen=True,
        json_schema_extra={
            'unit': units.LENGTH_STANDARD,
            'style': FloatFieldStyle(disabled=True),
        },
    )]
    wlc_destination: Annotated[WlcDestination, Field(
        title="Water Level Correction Destination",
        description="Destination of WLC Discharge (if SAF it is backup WLC destination)",
        default=WlcDestination.INFLUENT,
    )]
    sludge_discharge_height_cache: Annotated[float, Field(
        default=0,
        json_schema_extra={
            'unit': units.LENGTH_STANDARD,
            'style': FloatFieldStyle(disabled=True, hidden=True)})]

class ProcessSetup(InputBaseModel):
    nereda_reactors_sizing: NeredaReactorSizing = Field(
        NeredaReactorSizing(),
        json_schema_extra={
            'style': CardStyle(
                title="Nereda Reactors (NR)",
                subtitle="Define the reactor sizing."
            )
        }
    )

This leads to the following UI panel:

Triangle example

Combined-layout: Class-Level Styling

Use the way below to style a combined-layout application. Use the @apply_style decorator to attach a style to an entire model:

python
from pydantic import BaseModel, Field
from haskoning_equation.style import apply_style, CardStyle

@apply_style(
    style=CardStyle(
        title="Triangle",
        subtitle="Calculate sides or angle of a right triangle",
        icon="mdi-angle-acute",
        min_inputs=2,
        image_url="/haskoning_basic_hydraulics/static/images/driehoek.jpg",
    )
)
class TriangleInput(BaseModel):
    angle: float | None = Field(
        default=None, title="α (°)", description="The angle of the triangle in degrees."
    )
    opposite: float | None = Field(
        default=None, title="A", description="The length of the side opposite to the angle."
    )
    adjacent: float | None = Field(
        default=None,
        title="B",
        description="The length of the side adjacent to the angle.",
    )
    hypotenuse: float | None = Field(
        default=None, title="C", description="The length of the hypotenuse."
    )

    @model_validator(mode="after")
    def check_at_least_two(self) -> Self:
        check_minimum_number_of_inputs(self, 2)
        return self

The result of this in the ui is:

Triangle example

FieldStyle

FieldStyle classes control UI rendering of individual fields. Use them in json_schema_extra to specify control type, disabled state, or visibility.

Available FieldStyle Classes

ClassField TypeUI ControlsDefaultExample Usage
BooleanFieldStyleboolswitch, selectswitchBooleanFieldStyle()
IntegerFieldStyleintslider, textfieldtextfieldIntegerFieldStyle(type="slider")
FloatFieldStylefloatslider, textfieldtextfieldFloatFieldStyle(disabled=True)
StringFieldStylestrtextfield, textareatextfieldStringFieldStyle()
EnumFieldStyleEnumselectselectEnumFieldStyle()

All styles support disabled, hidden, cols, offset, and css options for additional UI control.

Example Usage

python
from typing import Annotated

from pydantic import Field
from haskoning_equation.models import InputBaseModel
from haskoning_equation.style import IntegerFieldStyle, FloatFieldStyle
from haskoning_equation.units import units

class ScenarioSettings(InputBaseModel):
    scenario_duration: Annotated[int, Field(
        title="Scenario duration",
        description="Total scenario duration",
        default=24 * 60,
        ge=0,
        json_schema_extra={
            'unit': units.TIME_PHASE,
            'style': IntegerFieldStyle(type="slider"),
        },
    )]
    general_load_factor: Annotated[float, Field(
        title="General load factor",
        description="Load factor for the design",
        default=1,
        ge=0,
        json_schema_extra={
            'unit': units.DIMENSIONLESS,
            'style': FloatFieldStyle(disabled=True),
        },
    )]

Working with Charts

ChartSchema

ChartSchema provides type-safe ChartJS configurations. Below are examples for common chart types.

Line Chart Example

python
import math
import random

from haskoning_equation.style.chartjs_pydantic import (
    ChartSchema, ChartType, ChartData, Dataset, ChartOptions,
    PluginsOptions, LegendOptions, PositionType, ScalesOptions, 
    AxisOptions, InteractionOptions, InteractionMode
)

def create_default_line_chart_data(num_minutes: int = 1440) -> ChartSchema:
    oxygen_data = []
    redox_data = []

    for i in range(0, num_minutes, 10):
        phase = (i % num_minutes) / num_minutes * 2 * math.pi
        oxygen_y = 1 + math.sin(phase) + random.uniform(-0.2, 0.2)
        if oxygen_y < 0:
            oxygen_y = 0
        redox_y = 0 + 100 * math.cos(phase) + random.uniform(-10, 10)
        if redox_y < -100 or redox_y > 100:
            redox_y = max(min(redox_y, 100), -100)
        oxygen_data.append({'x': i / 60, 'y': oxygen_y})
        redox_data.append({'x': i / 60, 'y': redox_y})

    datasets = [
        Dataset(
            label="Oxygen",
            data=oxygen_data,
            backgroundColor='#89b0e9',
            borderWidth=2,
            fill=True,
            yAxisID='y',
            tension=0.4,
            pointRadius=0
        ),
        Dataset(
            label="Redox",
            data=redox_data,
            backgroundColor='#605d5b',
            borderWidth=2,
            fill=False,
            yAxisID='y1',
            tension=0.4,
            pointRadius=0
        )
    ]

    data = ChartData(datasets=datasets)
    options = ChartOptions(
        responsive=True,
        maintainAspectRatio=False,
        interaction=InteractionOptions(
            mode=InteractionMode.INDEX,
            intersect=False
        ),
        plugins=PluginsOptions(
            legend=LegendOptions(
                position=PositionType.TOP,
            )
        ),
        scales=ScalesOptions(
            x=AxisOptions(
                display=True,
                title={'display': True, 'text': 'Hours'},
                type="linear",
            ),
            y=AxisOptions(
                display=True,
                position=PositionType.LEFT,
                title={'display': True, 'text': 'mg/L'},
                type="linear",
            ),
            y1=AxisOptions(
                display=True,
                position=PositionType.RIGHT,
                title={'display': True, 'text': 'mV'},
                type="linear",
            ),
        )
    )

    return ChartSchema(
        type=ChartType.LINE,
        data=data,
        options=options
    )

Scatter Chart Example

python
import random

from haskoning_equation.style.chartjs_pydantic import (
    ChartSchema, ChartType, ChartData, Dataset, ChartOptions,
    PluginsOptions, LegendOptions, PositionType, ScalesOptions, AxisOptions
)

def create_default_scatter_chart_data(num_days: int = 30) -> ChartSchema:
    reactor_01_points = []
    reactor_02_points = []

    for i in range(num_days):
        reactor_01_points.append({'x': i, 'y': random.uniform(1000, 14000)})
        reactor_02_points.append({'x': i, 'y': random.uniform(1000, 14000)})

    datasets = [
        Dataset(
            label="Reactor 01",
            data=reactor_01_points,
            backgroundColor='#5ea054',
            pointRadius=6
        ),
        Dataset(
            label="Reactor 02",
            data=reactor_02_points,
            backgroundColor='#edcc21',
            pointRadius=6
        )
    ]

    data = ChartData(datasets=datasets)
    options = ChartOptions(
        responsive=True,
        maintainAspectRatio=False,
        plugins=PluginsOptions(
            legend=LegendOptions(
                position=PositionType.TOP,
            )
        ),
        scales=ScalesOptions(
            x=AxisOptions(
                display=True,
                title={'display': True, 'text': 'Dates'},
                type="linear",
            ),
            y=AxisOptions(
                display=True,
                position=PositionType.LEFT,
                title={'display': True, 'text': 'Feed flow per day'},
                type="linear",
            ),
        )
    )

    return ChartSchema(
        type=ChartType.SCATTER,
        data=data,
        options=options
    )

State Timeline Chart Example

python
from haskoning_equation.style.chartjs_pydantic import (
    ChartSchema, ChartType, ChartData, Dataset, ChartOptions,
    PluginsOptions, LegendOptions, PositionType, ScalesOptions, AxisOptions
)

def create_default_state_timeline_chart_data(
        reactor_names=None, num_cycles: int = 8) -> ChartSchema:
    if reactor_names is None:
        reactor_names = ["Reactor01", "Reactor02"]
    react_intervals = []
    settle_intervals = []
    feed_decant_intervals = []

    for reactor_name in reactor_names:
        current_time = 0
        for cycle in range(num_cycles):
            react_intervals.append(
                {'x': [current_time, current_time + 2], 'y': reactor_name})
            current_time += 2

            settle_intervals.append(
                {'x': [current_time, current_time + 0.5], 'y': reactor_name})
            current_time += 0.5

            feed_decant_intervals.append(
                {'x': [current_time, current_time + 0.5], 'y': reactor_name})
            current_time += 0.5

    datasets = [
        Dataset(label="React", data=react_intervals, backgroundColor='#adcdfe'),
        Dataset(label="Settle", data=settle_intervals, backgroundColor='#8ac082'),
        Dataset(label="Feed/Decant", data=feed_decant_intervals, backgroundColor='#5984c8')
    ]

    data = ChartData(datasets=datasets)
    options = ChartOptions(
        indexAxis='y',
        responsive=True,
        maintainAspectRatio=False,
        plugins=PluginsOptions(
            legend=LegendOptions(position=PositionType.TOP)
        ),
        scales=ScalesOptions(
            x=AxisOptions(
                display=True,
                title={'display': True, 'text': 'Hours', 'padding': {'top': 10}},
                type="linear",
            ),
            y=AxisOptions(
                display=True,
                title={'display': True, 'text': 'Reactors'},
                stacked=True,
            ),
        )
    )
    return ChartSchema(type=ChartType.BAR, data=data, options=options)

Working with Tables

TableSchema

TableSchema provides type-safe table configurations with support for nested headers, styling, and units.

Table with Nested Headers

python
from pydantic import BaseModel, ConfigDict
from haskoning_equation.style import CardStyle, FloatFieldStyle
from haskoning_equation.style.tables import TableHeader, TableSchema, TableSubHeader

class TableItemSchema(BaseModel):
    model_config = ConfigDict(validate_assignment=True, extra="allow")

typology_overview = TableSchema(
    headers=[
        TableHeader(title="Typology ID", key="typo_id", sortable=True),
        TableHeader(title="Typology spaces", key="typo_n_spaces", sortable=True),
        TableHeader(
            title="Typology area",
            key="typo_area",
            sortable=True,
            style=FloatFieldStyle(type="number", precision=1),
        ),
        TableHeader(
            title="Ventilation",
            children=[
                TableSubHeader(
                    title="Specified Supply Airflow",
                    key="typo_Specified Supply Airflow",
                    style=FloatFieldStyle(type="number", precision=1),
                ),
                TableSubHeader(
                    title="Specified Return Airflow",
                    key="typo_Specified Return Airflow",
                    style=FloatFieldStyle(type="number", precision=1),
                ),
            ],
        ),
    ],
    items=[
        TableItemSchema(**{
            "typo_id": "Office",
            "typo_n_spaces": 10,
            "typo_area": 250.5,
            "typo_Specified Supply Airflow": 300.0,
            "typo_Specified Return Airflow": 290.0,
        }),
        TableItemSchema(**{
            "typo_id": "Meeting room",
            "typo_n_spaces": 4,
            "typo_area": 120.0,
            "typo_Specified Supply Airflow": 150.0,
            "typo_Specified Return Airflow": 140.0,
        }),
    ],
    style=CardStyle(
        title="Typology overview",
        subtitle="Overview of all typologies in the project",
    ),
)

Editable Table with Hidden Columns

python
from pydantic import BaseModel, ConfigDict
from haskoning_equation.style import CardStyle, StringFieldStyle, FloatFieldStyle, BaseStyle
from haskoning_equation.style.tables import TableHeader, TableSchema, TableSubHeader

class TableItemSchema(BaseModel):
    model_config = ConfigDict(validate_assignment=True, extra="allow")

editable_space_overview = TableSchema(
    headers=[
        TableHeader(
            title="Building data",
            children=[
                TableSubHeader(
                    title="Number",
                    key="number",
                    style=StringFieldStyle(editable=True, type="textfield"),
                ),
                TableSubHeader(
                    title="Name",
                    key="name",
                    style=StringFieldStyle(editable=True, type="textfield"),
                ),
                TableSubHeader(
                    title="Level",
                    key="level",
                    style=StringFieldStyle(editable=True, type="textfield"),
                ),
                TableSubHeader(
                    title="UID", 
                    key="uid", 
                    style=BaseStyle(hidden=True)
                ),
                TableSubHeader(
                    title="Application ID",
                    key="application_id",
                    style=BaseStyle(hidden=True),
                ),
                TableSubHeader(
                    title="Area",
                    key="area",
                    style=FloatFieldStyle(editable=True, type="number", precision=2),
                ),
                TableSubHeader(
                    title="Height",
                    key="height",
                    style=FloatFieldStyle(editable=True, type="number", precision=1),
                ),
                TableSubHeader(
                    title="Volume",
                    key="volume",
                    style=FloatFieldStyle(editable=True, type="number", precision=2),
                ),
            ],
        ),
    ],
    items=[
        TableItemSchema(
            number="101", name="Office 1", level="Level 1",
            uid="abcde12345", application_id="appid12345",
            area=25.5, height=3500, volume=89.25,
        ),
        TableItemSchema(
            number="102", name="Office 2", level="Level 1",
            uid="vwxyz67890", application_id="appid67890",
            area=40.0, height=3500, volume=140.0,
        ),
    ],
    style=CardStyle(
        title="Space overview",
        subtitle="Overview of all spaces in the project",
    ),
)