diff --git a/vizro-core/changelog.d/20240607_125345_huong_li_nguyen_create_kpi_cards.md b/vizro-core/changelog.d/20240607_125345_huong_li_nguyen_create_kpi_cards.md new file mode 100644 index 000000000..aeeb34aef --- /dev/null +++ b/vizro-core/changelog.d/20240607_125345_huong_li_nguyen_create_kpi_cards.md @@ -0,0 +1,48 @@ + + +### Highlights ✨ + +- Introduce `Figure` as a new `Page` component, enabling all dash components to be reactive in + `Vizro`. See the [user guide on figure](XXXX) for more information. ([#493](https://github.com/mckinsey/vizro/pull/493)) +- Introduce styled KPI cards to be used inside `Figure`. See the [user guide on KPI cards](XXXX) for more information. ([#493](https://github.com/mckinsey/vizro/pull/493)) + + + + + + + diff --git a/vizro-core/docs/pages/API-reference/captured-callables.md b/vizro-core/docs/pages/API-reference/captured-callables.md index f3b054e25..a83e79850 100644 --- a/vizro-core/docs/pages/API-reference/captured-callables.md +++ b/vizro-core/docs/pages/API-reference/captured-callables.md @@ -1,6 +1,5 @@ -# Table functions - +# Table functions API reference for all pre-defined [`CapturedCallable`][vizro.models.types.CapturedCallable] table functions to be used in the [`AgGrid`][vizro.models.AgGrid] and [`Table`][vizro.models.Table] models. Visit the how-to guide on [tables](../user-guides/table.md) @@ -10,5 +9,13 @@ for more information. options: show_source: true +# Figure functions +API reference for all pre-defined [`CapturedCallable`][vizro.models.types.CapturedCallable] figure functions to be used in the +[`Figure`][vizro.models.Figure]. Visit the how-to guide on [figures](../user-guides/figure.md) +for more information. + +::: vizro.figures + options: + show_source: true diff --git a/vizro-core/docs/pages/user-guides/components.md b/vizro-core/docs/pages/user-guides/components.md index de1a5c933..222ffbff1 100755 --- a/vizro-core/docs/pages/user-guides/components.md +++ b/vizro-core/docs/pages/user-guides/components.md @@ -12,7 +12,7 @@ listed below to fill your dashboard with visuals. Use graphs to visualize your data with any Plotly chart. - [:octicons-arrow-right-24: View User Guide](graph.md) + [:octicons-arrow-right-24: View user guide](graph.md) - :material-table-large:{ .lg .middle } __Table__ @@ -20,7 +20,7 @@ listed below to fill your dashboard with visuals. Use tables to visualize your tabular data. - [:octicons-arrow-right-24: View User Guide](table.md) + [:octicons-arrow-right-24: View user guide](table.md) - :material-cards-outline:{ .lg .middle } __Card & button__ @@ -28,7 +28,15 @@ listed below to fill your dashboard with visuals. Use cards and buttons to visualize text, navigate to different URLs or attach any [action](actions.md). - [:octicons-arrow-right-24: View User Guide](card-button.md) + [:octicons-arrow-right-24: View user guide](card-button.md) + +- :material-graph:{ .lg .middle } __Figure__ + + --- + + Use figure to visualize your make any dash component reactive. + + [:octicons-arrow-right-24: View user guide](figure.md) - :octicons-table-16:{ .lg .middle } __Container__ @@ -36,7 +44,7 @@ listed below to fill your dashboard with visuals. Use containers to group your page components into sections and subsections. - [:octicons-arrow-right-24: View User Guide](container.md) + [:octicons-arrow-right-24: View user guide](container.md) - :material-tab-plus:{ .lg .middle } __Tabs__ @@ -44,6 +52,6 @@ listed below to fill your dashboard with visuals. Use tabs to group your containers and navigate between them. - [:octicons-arrow-right-24: View User Guide](tabs.md) + [:octicons-arrow-right-24: View user guide](tabs.md) diff --git a/vizro-core/docs/pages/user-guides/data.md b/vizro-core/docs/pages/user-guides/data.md index c670c7655..957ea4ffe 100644 --- a/vizro-core/docs/pages/user-guides/data.md +++ b/vizro-core/docs/pages/user-guides/data.md @@ -46,7 +46,7 @@ A static data source is the simplest way to send data to your dashboard and shou ### Supply directly -You can directly supply a pandas DataFrame into components such as [graphs](graph.md) and [tables](table.md). +You can directly supply a pandas DataFrame into components such as [graphs](graph.md), [tables](table.md) and [figures](figure.md). The below example uses the Iris data saved to a file `iris.csv` in the same directory as `app.py`. This data can be generated using `px.data.iris()` or [downloaded](../../assets/user_guides/data/iris.csv). diff --git a/vizro-core/docs/pages/user-guides/figure.md b/vizro-core/docs/pages/user-guides/figure.md new file mode 100644 index 000000000..1d8a9a8d2 --- /dev/null +++ b/vizro-core/docs/pages/user-guides/figure.md @@ -0,0 +1 @@ +# How to use figures diff --git a/vizro-core/examples/_dev/app.py b/vizro-core/examples/_dev/app.py index 82a08fa0b..223b14be7 100644 --- a/vizro-core/examples/_dev/app.py +++ b/vizro-core/examples/_dev/app.py @@ -1,65 +1,100 @@ -"""Example to show dashboard configuration.""" +"""Dev app to try things out.""" +import pandas as pd import vizro.models as vm -import vizro.plotly.express as px from vizro import Vizro -from vizro.actions import export_data -from vizro.models.types import capture -from vizro.tables import dash_ag_grid +from vizro.figures import kpi_card, kpi_card_reference -df = px.data.gapminder() +df = pd.DataFrame([[67434, 65553, "A"], [6434, 6553, "B"], [34, 53, "C"]], columns=["Actual", "Reference", "Category"]) -# Vizro filter exporting Page -------------------------------------------- -# Solution based on https://vizro.readthedocs.io/en/stable/pages/user-guides/actions/#export-data - -page_one = vm.Page( - title="Vizro filters exporting", - layout=vm.Layout(grid=[[0]] * 5 + [[1]]), +page = vm.Page( + title="KPI Indicators", + layout=vm.Layout(grid=[[0, 1, 2], [3, 4, 5], [6, 7, 8], [9, 10, -1]]), components=[ - vm.AgGrid(id="ag_grid_1", title="Equal Title One", figure=dash_ag_grid(data_frame=df)), - vm.Button(text="Export data", actions=[vm.Action(function=export_data())]), - ], - controls=[vm.Filter(column="continent"), vm.Filter(column="year")], -) - -# AgGrid filter exporting Page ------------------------------------------- -# Solution based on https://dash.plotly.com/dash-ag-grid/export-data-csv - - -# More about Vizro Custom Actions -> https://vizro.readthedocs.io/en/stable/pages/user-guides/custom-actions/ -@capture("action") -def ag_grid_data_exporting(): - """Custom Action.""" - return True - - -page_two = vm.Page( - title="AgGrid filters exporting", - layout=vm.Layout(grid=[[0]] * 5 + [[1]]), # grid = [[0], [0], [0], [0], [0], [1]] - components=[ - vm.AgGrid( - id="ag_grid_2", - title="Equal Title One", - figure=dash_ag_grid( - # underlying_ag_grid_2 is the id of the AgGrid component on the client-side. It is used to reference - # it's `exportDataAsCsv` property with the custom action below - id="underlying_ag_grid_2", + # Style 1: Value Only + vm.Figure(figure=kpi_card(data_frame=df, value_column="Actual", title="Value I", agg_func="sum")), + vm.Figure(figure=kpi_card(data_frame=df, value_column="Actual", title="Value II", agg_func="mean")), + vm.Figure(figure=kpi_card(data_frame=df, value_column="Actual", title="Value III", agg_func="median")), + # Style 2: Value and reference value + vm.Figure( + figure=kpi_card_reference( + data_frame=df, + value_column="Reference", + reference_column="Actual", + title="Ref. Value II", + agg_func="sum", + ) + ), + vm.Figure( + figure=kpi_card_reference( data_frame=df, - csvExportParams={ - "fileName": "ag_grid_2.csv", - }, + value_column="Actual", + reference_column="Reference", + title="Ref. Value I", + agg_func="sum", + ) + ), + vm.Figure( + id="kpi-card-reverse-coloring", + figure=kpi_card_reference( + data_frame=df, + value_column="Actual", + reference_column="Reference", + title="Ref. Value III", + agg_func="median", + icon="shopping_cart", ), ), - vm.Button( - id="button_2", - text="Export data", - actions=[vm.Action(function=ag_grid_data_exporting(), outputs=["underlying_ag_grid_2.exportDataAsCsv"])], + # Style 3: Value and icon + vm.Figure( + figure=kpi_card( + data_frame=df, + value_column="Actual", + icon="shopping_cart", + title="Icon I", + agg_func="sum", + value_format="${value:.2f}", + ) + ), + vm.Figure( + figure=kpi_card( + data_frame=df, + value_column="Actual", + icon="payment", + title="Icon II", + agg_func="mean", + value_format="{value:.0f}€", + ) + ), + vm.Figure( + figure=kpi_card( + data_frame=df, + value_column="Actual", + icon="monitoring", + title="Icon III", + agg_func="median", + ) + ), + # This should still work without a figure argument + vm.Card( + text=""" + # Text Card + Hello, this is a text card. + """ + ), + vm.Card( + text=""" + # Nav Card + Hello, this is a nav card. + """, + href="https://www.google.com", ), ], - controls=[vm.Filter(column="continent"), vm.Filter(column="year")], + controls=[vm.Filter(column="Category")], ) -dashboard = vm.Dashboard(pages=[page_one, page_two]) + +dashboard = vm.Dashboard(pages=[page]) if __name__ == "__main__": Vizro().build(dashboard).run() diff --git a/vizro-core/examples/_dev/assets/css/custom.css b/vizro-core/examples/_dev/assets/css/custom.css index 956c8f7b3..8a2651c0b 100644 --- a/vizro-core/examples/_dev/assets/css/custom.css +++ b/vizro-core/examples/_dev/assets/css/custom.css @@ -1,14 +1,4 @@ -#logo { - height: 40px; - margin-left: -8px; -} - -/* Apply styling to parent */ -.card:has(#my-card) { - background-color: white; -} - -/* Apply styling to children */ -#my-card p { - color: black; +/* Apply reverse color logic via CSS */ +#kpi-card-reverse-coloring .card-kpi:has(.color-neg) { + border-left: 4px solid #1a85ff; } diff --git a/vizro-core/mkdocs.yml b/vizro-core/mkdocs.yml index 8b442b9e4..591eac13e 100644 --- a/vizro-core/mkdocs.yml +++ b/vizro-core/mkdocs.yml @@ -15,6 +15,7 @@ nav: - Overview: pages/user-guides/components.md - Graphs: pages/user-guides/graph.md - Tables: pages/user-guides/table.md + - Figures: pages/user-guides/figure.md - Cards & buttons: pages/user-guides/card-button.md - Containers: pages/user-guides/container.md - Tabs: pages/user-guides/tabs.md diff --git a/vizro-core/pyproject.toml b/vizro-core/pyproject.toml index a8d5a8f91..8a5ae98af 100644 --- a/vizro-core/pyproject.toml +++ b/vizro-core/pyproject.toml @@ -78,7 +78,9 @@ filterwarnings = [ # Ignore until pandas 3 is released: "ignore:(?s).*Pyarrow will become a required dependency of pandas:DeprecationWarning", # Ignore until plotly fixes so the warning is no longer raised: - "ignore:When grouping with a length-1 list-like, you will need to pass a length-1 tuple to get_group:FutureWarning" + "ignore:When grouping with a length-1 list-like, you will need to pass a length-1 tuple to get_group:FutureWarning", + # Ignore warning when providing a custom format string to the KPI Cards: + "ignore:Custom format string detected." ] norecursedirs = ["tests/tests_utils", "tests/js"] pythonpath = ["tests/tests_utils", "examples/kpi"] diff --git a/vizro-core/schemas/0.1.18.dev0.json b/vizro-core/schemas/0.1.18.dev0.json index eb7c6878b..5c04f2438 100644 --- a/vizro-core/schemas/0.1.18.dev0.json +++ b/vizro-core/schemas/0.1.18.dev0.json @@ -172,6 +172,26 @@ "required": ["text"], "additionalProperties": false }, + "Figure": { + "title": "Figure", + "description": "Creates a figure-like object that can be displayed in the dashboard and is reactive to controls.\n\nArgs:\n type (Literal[\"figure\"]): Defaults to `\"figure\"`.\n figure (CapturedCallable): See [`vizro.figures`][vizro.figures].", + "type": "object", + "properties": { + "id": { + "title": "Id", + "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "default": "", + "type": "string" + }, + "type": { + "title": "Type", + "default": "figure", + "enum": ["figure"], + "type": "string" + } + }, + "additionalProperties": false + }, "Graph": { "title": "Graph", "description": "Wrapper for `dcc.Graph` to visualize charts in dashboard.\n\nArgs:\n type (Literal[\"graph\"]): Defaults to `\"graph\"`.\n figure (CapturedCallable): See [`CapturedCallable`][vizro.models.types.CapturedCallable].\n actions (List[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`.", @@ -344,6 +364,7 @@ "button": "#/definitions/Button", "card": "#/definitions/Card", "container": "#/definitions/Container", + "figure": "#/definitions/Figure", "graph": "#/definitions/Graph", "table": "#/definitions/Table", "tabs": "#/definitions/Tabs" @@ -362,6 +383,9 @@ { "$ref": "#/definitions/Container" }, + { + "$ref": "#/definitions/Figure" + }, { "$ref": "#/definitions/Graph" }, @@ -1135,6 +1159,7 @@ "button": "#/definitions/Button", "card": "#/definitions/Card", "container": "#/definitions/Container", + "figure": "#/definitions/Figure", "graph": "#/definitions/Graph", "table": "#/definitions/Table", "tabs": "#/definitions/Tabs" @@ -1153,6 +1178,9 @@ { "$ref": "#/definitions/Container" }, + { + "$ref": "#/definitions/Figure" + }, { "$ref": "#/definitions/Graph" }, diff --git a/vizro-core/schemas/generate.py b/vizro-core/schemas/generate.py index 9caf6bd15..21ac96c3d 100644 --- a/vizro-core/schemas/generate.py +++ b/vizro-core/schemas/generate.py @@ -12,7 +12,6 @@ parser.add_argument("--check", help="check schema is up to date", action="store_true") args = parser.parse_args() -# TODO: need to setup alias function to keep lowercase if wanted schema_json = Dashboard.schema_json(indent=4, by_alias=False) schema_path = Path(__file__).with_name(f"{__version__}.json") diff --git a/vizro-core/src/vizro/figures/__init__.py b/vizro-core/src/vizro/figures/__init__.py new file mode 100644 index 000000000..4a3c18286 --- /dev/null +++ b/vizro-core/src/vizro/figures/__init__.py @@ -0,0 +1,4 @@ +from vizro.figures.kpi_cards import kpi_card, kpi_card_reference + +# Please keep alphabetically ordered +__all__ = ["kpi_card", "kpi_card_reference"] diff --git a/vizro-core/src/vizro/figures/kpi_cards.py b/vizro-core/src/vizro/figures/kpi_cards.py new file mode 100644 index 000000000..35eef8d5c --- /dev/null +++ b/vizro-core/src/vizro/figures/kpi_cards.py @@ -0,0 +1,163 @@ +"""Contains default KPI card functions.""" + +from typing import Optional + +import dash_bootstrap_components as dbc +import numpy as np +import pandas as pd +from dash import html + +from vizro.models.types import capture + + +@capture("figure") +def kpi_card( # noqa: PLR0913 + data_frame: pd.DataFrame, + value_column: str, + *, + value_format: str = "{value}", + agg_func: str = "sum", + title: Optional[str] = None, + icon: Optional[str] = None, +) -> dbc.Card: + """Creates a styled KPI (Key Performance Indicator) card displaying a value. + + **Warning:** Note that the format string provided to `value_format` is being evaluated, so ensure that only trusted + user input is provided to prevent + [potential security risks](https://stackoverflow.com/questions/76783239/is-it-safe-to-use-python-str-format-method-with-user-submitted-templates-in-serv). + + Args: + data_frame: DataFrame containing the data. + value_column: Column name of the value to be shown. + value_format: Format string to be applied to the value. It must be a + [valid Python format](https://docs.python.org/3/library/string.html#format-specification-mini-language) + string where any of the below placeholders can be used. Defaults to "{value}". + + - value: `value_column` aggregated by `agg_func`. + + **Common examples include:** + + - "{value}": Displays the raw value. + - "${value:0.2f}": Formats the value as a currency with two decimal places. + - "{value:.0%}": Formats the value as a percentage without decimal places. + - "{value:,}": Formats the value with comma as a thousands separator. + + agg_func: String function name to be used for aggregating the data. Common options include + "sum", "mean" or "median". Default is "sum". For more information on possible functions, see + https://stackoverflow.com/questions/65877567/passing-function-names-as-strings-to-pandas-groupby-aggregrate. + title: KPI title displayed on top of the card. If not provided, it defaults to the capitalized `value_column`. + icon: Name of the icon from the [Google Material Icon Library](https://fonts.google.com/icons) to be displayed + on the left side of the KPI title. If not provided, no icon is displayed. + + Raises: + UserWarning: If `value_format` is provided, a warning is raised to make aware that only trusted user + input should be provided. + + Returns: + A Dash Bootstrap Components card (`dbc.Card`) containing the formatted KPI value. + + """ + title = title or f"{agg_func} {value_column}".title() + value = data_frame[value_column].agg(agg_func) + + return dbc.Card( + [ + dbc.CardHeader( + [ + html.P(icon, className="material-symbols-outlined") if icon else None, + html.H2(title), + ], + ), + dbc.CardBody(value_format.format(value=value)), + ], + className="card-kpi", + ) + + +@capture("figure") +def kpi_card_reference( # noqa: PLR0913 + data_frame: pd.DataFrame, + value_column: str, + reference_column: str, + *, + value_format: str = "{value}", + reference_format: str = "{delta_relative:.1%} vs. reference ({reference})", + agg_func: str = "sum", + title: Optional[str] = None, + icon: Optional[str] = None, +) -> dbc.Card: + """Creates a styled KPI (Key Performance Indicator) card displaying a value in comparison to a reference value. + + **Warning:** Note that the format string provided to `value_format` and `reference_format` is being evaluated, + so ensure that only trusted user input is provided to prevent + [potential security risks](https://stackoverflow.com/questions/76783239/is-it-safe-to-use-python-str-format-method-with-user-submitted-templates-in-serv). + + Args: + data_frame: DataFrame containing the data. + value_column: Column name of the value to be shown. + reference_column: Column name of the reference value for comparison. + value_format: Format string to be applied to the value. It must be a + [valid Python format](https://docs.python.org/3/library/string.html#format-specification-mini-language) + string where any of the below placeholders can be used. Defaults to "{value}". + + - value: `value_column` aggregated by `agg_func`. + - reference: `reference_column` aggregated by `agg_func`. + - delta: Difference between `value` and `reference`. + - delta_relative: Relative difference between `value` and `reference`. + + **Common examples include:** + + - "{value}": Displays the raw value. + - "${value:0.2f}": Formats the value as a currency with two decimal places. + - "{value:.0%}": Formats the value as a percentage without decimal places. + - "{value:,}": Formats the value with comma as a thousands separator. + + reference_format: Format string to be applied to the reference. For more details on possible placeholders, see + docstring on `value_format`. Defaults to "{delta_relative:.1%} vs. reference ({reference})". + agg_func: String function name to be used for aggregating the data. Common options include + "sum", "mean" or "median". Default is "sum". For more information on possible functions, see + https://stackoverflow.com/questions/65877567/passing-function-names-as-strings-to-pandas-groupby-aggregrate. + title: KPI title displayed on top of the card. If not provided, it defaults to the capitalized `value_column`. + icon: Name of the icon from the [Google Material Icon Library](https://fonts.google.com/icons) to be displayed + on the left side of the KPI title. If not provided, no icon is displayed. + + Raises: + UserWarning: If `value_format` or `reference_format` is provided, a warning is raised to make aware + that only trusted user input should be provided. + + Returns: + A Dash Bootstrap Components card (`dbc.Card`) containing the formatted KPI value and reference. + + """ + title = title or f"{agg_func} {value_column}".title() + value, reference = data_frame[[value_column, reference_column]].agg(agg_func) + delta = value - reference + delta_relative = delta / reference if reference else np.nan + + return dbc.Card( + [ + dbc.CardHeader( + [ + html.P(icon, className="material-symbols-outlined") if icon else None, + html.H2(title), + ], + ), + dbc.CardBody( + value_format.format(value=value, reference=reference, delta=delta, delta_relative=delta_relative) + ), + dbc.CardFooter( + [ + html.Span( + "arrow_circle_up" if delta > 0 else "arrow_circle_down", className="material-symbols-outlined" + ), + html.Span( + reference_format.format( + value=value, reference=reference, delta=delta, delta_relative=delta_relative + ) + ), + ], + className="color-pos" if delta > 0 else "color-neg", + ), + ], + className="card-kpi", + ) diff --git a/vizro-core/src/vizro/models/__init__.py b/vizro-core/src/vizro/models/__init__.py index ac73d54ee..d8216d482 100644 --- a/vizro-core/src/vizro/models/__init__.py +++ b/vizro-core/src/vizro/models/__init__.py @@ -1,7 +1,7 @@ # Keep this import at the top to avoid circular imports since it's used in every model. from ._base import VizroBaseModel # noqa: I001 from ._action import Action -from ._components import Card, Container, Graph, Table, Tabs +from ._components import Card, Container, Graph, Table, Tabs, Figure from ._components import AgGrid from ._components.form import Button, Checklist, DatePicker, Dropdown, RadioItems, RangeSlider, Slider from ._controls import Filter, Parameter @@ -15,7 +15,7 @@ Tabs.update_forward_refs(Container=Container) Container.update_forward_refs( - AgGrid=AgGrid, Button=Button, Card=Card, Graph=Graph, Layout=Layout, Table=Table, Tabs=Tabs + AgGrid=AgGrid, Button=Button, Card=Card, Figure=Figure, Graph=Graph, Layout=Layout, Table=Table, Tabs=Tabs ) Page.update_forward_refs( Accordion=Accordion, @@ -23,6 +23,7 @@ Button=Button, Card=Card, Container=Container, + Figure=Figure, Filter=Filter, Graph=Graph, Parameter=Parameter, @@ -45,6 +46,7 @@ "Dashboard", "DatePicker", "Dropdown", + "Figure", "Filter", "Graph", "Layout", diff --git a/vizro-core/src/vizro/models/_components/__init__.py b/vizro-core/src/vizro/models/_components/__init__.py index 07e95fabc..26435d27c 100644 --- a/vizro-core/src/vizro/models/_components/__init__.py +++ b/vizro-core/src/vizro/models/_components/__init__.py @@ -4,8 +4,9 @@ from vizro.models._components.button import Button from vizro.models._components.card import Card from vizro.models._components.container import Container +from vizro.models._components.figure import Figure from vizro.models._components.graph import Graph from vizro.models._components.table import Table from vizro.models._components.tabs import Tabs -__all__ = ["AgGrid", "Button", "Card", "Container", "Graph", "Table", "Tabs"] +__all__ = ["AgGrid", "Button", "Card", "Container", "Figure", "Graph", "Table", "Tabs"] diff --git a/vizro-core/src/vizro/models/_components/ag_grid.py b/vizro-core/src/vizro/models/_components/ag_grid.py index 70368ccb7..45e138820 100644 --- a/vizro-core/src/vizro/models/_components/ag_grid.py +++ b/vizro-core/src/vizro/models/_components/ag_grid.py @@ -58,7 +58,7 @@ def __call__(self, **kwargs): # Convenience wrapper/syntactic sugar. def __getitem__(self, arg_name: str): - # See table implementation for more details. + # See figure implementation for more details. if arg_name == "type": return self.type return self.figure[arg_name] diff --git a/vizro-core/src/vizro/models/_components/figure.py b/vizro-core/src/vizro/models/_components/figure.py new file mode 100644 index 000000000..01250261d --- /dev/null +++ b/vizro-core/src/vizro/models/_components/figure.py @@ -0,0 +1,63 @@ +from typing import Literal + +from dash import dcc, html + +try: + from pydantic.v1 import Field, PrivateAttr, validator +except ImportError: # pragma: no cov + from pydantic import Field, PrivateAttr, validator + +import vizro.figures as vf +from vizro.managers import data_manager +from vizro.models import VizroBaseModel +from vizro.models._components._components_utils import _callable_mode_validator_factory, _process_callable_data_frame +from vizro.models._models_utils import _log_call +from vizro.models.types import CapturedCallable + + +class Figure(VizroBaseModel): + """Creates a figure-like object that can be displayed in the dashboard and is reactive to controls. + + Args: + type (Literal["figure"]): Defaults to `"figure"`. + figure (CapturedCallable): See [`vizro.figures`][vizro.figures]. + + """ + + type: Literal["figure"] = "figure" + figure: CapturedCallable = Field( + import_path=vf, description="Function that returns a figure-like object to be visualized in the dashboard." + ) + + # Component properties for actions and interactions + _output_component_property: str = PrivateAttr("children") + + # Validators + _validate_callable_mode = _callable_mode_validator_factory("figure") + _validate_callable = validator("figure", allow_reuse=True, always=True)(_process_callable_data_frame) + + def __call__(self, **kwargs): + kwargs.setdefault("data_frame", data_manager[self["data_frame"]].load()) + figure = self.figure(**kwargs) + return figure + + def __getitem__(self, arg_name: str): + # pydantic discriminated union validation seems to try Figure["type"], which throws an error unless we + # explicitly redirect it to the correct attribute. + if self.figure: + if arg_name == "type": + return self.type + return self.figure[arg_name] + + @_log_call + def build(self): + return dcc.Loading( + # Optimally, we would like to provide id=self.id directly here such that we can target the CSS + # of the children via ID as well, but the `id` doesn't seem to be passed on to the loading component. + # I've raised an issue on dash here: https://github.com/plotly/dash/issues/2878 + # In the meantime, we are adding an extra html.div here. + html.Div(self.__call__(), id=self.id, className="figure-container"), + color="grey", + parent_className="loading-container", + overlay_style={"visibility": "visible", "opacity": 0.3}, + ) diff --git a/vizro-core/src/vizro/models/_components/graph.py b/vizro-core/src/vizro/models/_components/graph.py index 00218899d..af94ab119 100644 --- a/vizro-core/src/vizro/models/_components/graph.py +++ b/vizro-core/src/vizro/models/_components/graph.py @@ -73,8 +73,7 @@ def __call__(self, **kwargs): # Convenience wrapper/syntactic sugar. def __getitem__(self, arg_name: str): - # pydantic discriminated union validation seems to try Graph["type"], which throws an error unless we - # explicitly redirect it to the correct attribute. + # See figure implementation for more details. if arg_name == "type": return self.type return self.figure[arg_name] diff --git a/vizro-core/src/vizro/models/_components/table.py b/vizro-core/src/vizro/models/_components/table.py index 5ddda5637..cb6039c2c 100644 --- a/vizro-core/src/vizro/models/_components/table.py +++ b/vizro-core/src/vizro/models/_components/table.py @@ -57,8 +57,7 @@ def __call__(self, **kwargs): # Convenience wrapper/syntactic sugar. def __getitem__(self, arg_name: str): - # pydantic discriminated union validation seems to try Table["type"], which throws an error unless we - # explicitly redirect it to the correct attribute. + # See figure implementation for more details. if arg_name == "type": return self.type return self.figure[arg_name] diff --git a/vizro-core/src/vizro/models/types.py b/vizro-core/src/vizro/models/types.py index 41e9f0657..2794661d0 100644 --- a/vizro-core/src/vizro/models/types.py +++ b/vizro-core/src/vizro/models/types.py @@ -37,8 +37,8 @@ class CapturedCallable: `functools.partial`. Ready-to-use `CapturedCallable` instances are provided by Vizro. In this case refer to the [user guide on - Charts/Graph](../user-guides/graph.md), [Table](../user-guides/table.md) or [Actions](../user-guides/actions.md) - to see available choices. + Charts/Graph](../user-guides/graph.md), [Table](../user-guides/table.md), [Actions](../user-guides/actions.md) + or [Actions](../user-guides/figure.md) to see available choices. (Advanced) In case you would like to create your own `CapturedCallable`, please refer to the [user guide on custom charts](../user-guides/custom-charts.md), @@ -222,8 +222,8 @@ class capture: """Captures a function call to create a [`CapturedCallable`][vizro.models.types.CapturedCallable]. This is used to add the functionality required to make graphs and actions work in a dashboard. - Typically, it should be used as a function decorator. There are four possible modes: `"graph"`, `"table"`, - `"ag_grid"` and `"action"`. + Typically, it should be used as a function decorator. There are five possible modes: `"graph"`, `"table"`, + `"ag_grid"`, `"figure"` and `"action"`. Examples >>> @capture("graph") @@ -235,6 +235,9 @@ class capture: >>> @capture("ag_grid") >>> def ag_grid_function(): >>> ... + >>> @capture("figure") + >>> def figure_function(): + >>> ... >>> @capture("action") >>> def action_function(): >>> ... @@ -243,13 +246,15 @@ class capture: [custom graphs](../user-guides/custom-charts.md). For further help on the use of `@capture("table")` or `@capture("ag_grid")`, you can refer to the guide on [custom tables](../user-guides/custom-tables.md). + For further help on the use of `@capture("figure")`, you can refer to the guide on + [figures](../user-guides/figure.md). For further help on the use of `@capture("action")`, you can refer to the guide on [custom actions](../user-guides/custom-actions.md). """ - def __init__(self, mode: Literal["graph", "action", "table", "ag_grid"]): - """Decorator to capture a function call. Valid modes are "graph", "table", "action" and "ag_grid".""" + def __init__(self, mode: Literal["graph", "action", "table", "ag_grid", "figure"]): + """Decorator to capture a function call.""" self._mode = mode def __call__(self, func, /): @@ -304,7 +309,7 @@ def wrapped(*args, **kwargs): return captured_callable return wrapped - elif self._mode in ["table", "ag_grid"]: + elif self._mode in ["table", "ag_grid", "figure"]: @functools.wraps(func) def wrapped(*args, **kwargs): @@ -366,7 +371,7 @@ class OptionsDictType(TypedDict): [`Parameter`][vizro.models.Parameter].""" ComponentType = Annotated[ - Union["AgGrid", "Button", "Card", "Container", "Graph", "Table", "Tabs"], + Union["AgGrid", "Button", "Card", "Container", "Figure", "Graph", "Table", "Tabs"], Field( discriminator="type", description="Component that makes up part of the layout on the page.", diff --git a/vizro-core/src/vizro/static/css/bootstrap_overwrites.css b/vizro-core/src/vizro/static/css/bootstrap_overwrites.css index f69a5728b..483ef670e 100644 --- a/vizro-core/src/vizro/static/css/bootstrap_overwrites.css +++ b/vizro-core/src/vizro/static/css/bootstrap_overwrites.css @@ -21,3 +21,12 @@ but do not want to take over to `vizro-bootstrap` as these settings might not be .accordion-item .nav-link.active { border-left: 2px solid var(--border-enabled); } + +.card-header, +.card-footer, +.card-body { + background: inherit; + border: none; + margin: 0; + padding: 0; +} diff --git a/vizro-core/src/vizro/static/css/cards.css b/vizro-core/src/vizro/static/css/cards.css new file mode 100644 index 000000000..4db76c9af --- /dev/null +++ b/vizro-core/src/vizro/static/css/cards.css @@ -0,0 +1,49 @@ +.card-kpi { + border-left: 4px solid var(--text-secondary); +} + +.card-kpi .card-header { + display: flex; + flex-direction: row; + gap: 8px; +} + +.card-kpi .card-header .material-symbols-outlined { + line-height: 30px; +} + +.card-kpi .card-body { + align-items: center; + color: var(--text-secondary); + display: flex; + flex-grow: 1; + font-size: 3.6vh; + font-weight: 600; +} + +.card-kpi .card-footer { + display: flex; + font-weight: 600; + gap: 4px; +} + +.card-kpi .card-footer .material-symbols-outlined { + font-size: 20px; + line-height: 20px; +} + +.color-pos { + color: #1a85ff; +} + +.color-neg { + color: #d41159; +} + +.card-kpi:has(.color-pos) { + border-left: 4px solid #1a85ff; +} + +.card-kpi:has(.color-neg) { + border-left: 4px solid #d41159; +} diff --git a/vizro-core/src/vizro/static/css/layout.css b/vizro-core/src/vizro/static/css/layout.css index 3f2ef4a45..d505f7dc3 100644 --- a/vizro-core/src/vizro/static/css/layout.css +++ b/vizro-core/src/vizro/static/css/layout.css @@ -117,7 +117,8 @@ width: 100%; } -.loading-container { +.loading-container, +.figure-container { height: 100%; width: 100%; } diff --git a/vizro-core/tests/unit/vizro/models/_components/test_figure.py b/vizro-core/tests/unit/vizro/models/_components/test_figure.py new file mode 100644 index 000000000..d3518595e --- /dev/null +++ b/vizro-core/tests/unit/vizro/models/_components/test_figure.py @@ -0,0 +1,103 @@ +"""Unit tests for vizro.models.Figure.""" + +import pytest +from asserts import assert_component_equal +from dash import dcc, html + +try: + from pydantic.v1 import ValidationError +except ImportError: # pragma: no cov + from pydantic import ValidationError + +import vizro.models as vm +from vizro.figures import kpi_card +from vizro.managers import data_manager + + +@pytest.fixture +def kpi_card_with_dataframe(gapminder): + return kpi_card( + data_frame=gapminder, + value_column="lifeExp", + agg_func="mean", + value_format="{value:.3f}", + ) + + +@pytest.fixture +def kpi_card_with_str_dataframe(): + return kpi_card( + data_frame="gapminder", + value_column="lifeExp", + agg_func="mean", + value_format="{value:.3f}", + ) + + +class TestFigureInstantiation: + def test_create_figure_mandatory_only(self, kpi_card_with_dataframe): + figure = vm.Figure(figure=kpi_card_with_dataframe) + + assert hasattr(figure, "id") + assert figure.type == "figure" + assert figure.figure == kpi_card_with_dataframe + + def test_mandatory_figure_missing(self): + with pytest.raises(ValidationError, match="field required"): + vm.Figure() + + def test_wrong_captured_callable(self, standard_ag_grid): + with pytest.raises(ValidationError, match="CapturedCallable mode mismatch."): + vm.Figure(figure=standard_ag_grid) + + def test_failed_figure_with_no_captured_callable(self, standard_go_chart): + with pytest.raises(ValidationError, match="must provide a valid CapturedCallable object"): + vm.Figure(figure=standard_go_chart) + + +class TestDunderMethodsFigure: + def test_getitem_known_args(self, kpi_card_with_dataframe): + figure = vm.Figure(figure=kpi_card_with_dataframe) + assert figure["value_column"] == "lifeExp" + assert figure["agg_func"] == "mean" + assert figure["value_format"] == "{value:.3f}" + assert figure["type"] == "figure" + + def test_getitem_unknown_args(self, kpi_card_with_dataframe): + figure = vm.Figure(figure=kpi_card_with_dataframe) + with pytest.raises(KeyError): + figure["unknown_args"] + + +class TestProcessFigureDataFrame: + def test_process_figure_data_frame_str_df(self, kpi_card_with_str_dataframe, gapminder): + data_manager["gapminder"] = gapminder + figure = vm.Figure(figure=kpi_card_with_str_dataframe) + assert data_manager[figure["data_frame"]].load().equals(gapminder) + + def test_process_figure_data_frame_df(self, kpi_card_with_dataframe, gapminder): + figure = vm.Figure(figure=kpi_card_with_dataframe) + assert data_manager[figure["data_frame"]].load().equals(gapminder) + + +class TestFigureBuild: + def test_figure_build(self, kpi_card_with_dataframe, gapminder): + figure = vm.Figure(id="figure-id", figure=kpi_card_with_dataframe).build() + + expected_figure = dcc.Loading( + html.Div( + kpi_card( + data_frame=gapminder, + value_column="lifeExp", + agg_func="mean", + title="Mean Lifeexp", + value_format="{value:.3f}", + )(), + className="figure-container", + id="figure-id", + ), + color="grey", + parent_className="loading-container", + overlay_style={"visibility": "visible", "opacity": 0.3}, + ) + assert_component_equal(figure, expected_figure) diff --git a/vizro-core/tests/unit/vizro/models/test_models_utils.py b/vizro-core/tests/unit/vizro/models/test_models_utils.py index 395d99b9d..c3a1a312e 100644 --- a/vizro-core/tests/unit/vizro/models/test_models_utils.py +++ b/vizro-core/tests/unit/vizro/models/test_models_utils.py @@ -18,6 +18,8 @@ def test_set_components_validator(self, model_with_layout): def test_check_for_valid_component_types(self, model_with_layout): with pytest.raises( ValidationError, - match=re.escape("(allowed values: 'ag_grid', 'button', 'card', 'container', 'graph', 'table', 'tabs')"), + match=re.escape( + "(allowed values: 'ag_grid', 'button', 'card', 'container', 'figure', 'graph', 'table', 'tabs')" + ), ): model_with_layout(title="Page Title", components=[vm.Checklist()])