diff --git a/plugins/plotly-express/docs/indicator.md b/plugins/plotly-express/docs/indicator.md index c7e52f4d3..92d457e8d 100644 --- a/plugins/plotly-express/docs/indicator.md +++ b/plugins/plotly-express/docs/indicator.md @@ -83,6 +83,7 @@ Visualize a single numeric value with an angular gauge by passing `gauge="angula ```python order=indicator_plot,my_table import deephaven.plot.express as dx from deephaven import agg as agg + my_table = dx.data.stocks() # subset data and aggregate for DOG prices @@ -98,6 +99,7 @@ Visualize a single numeric value with a bullet gauge by passing `gauge="bullet"` ```python order=indicator_plot,my_table import deephaven.plot.express as dx from deephaven import agg as agg + my_table = dx.data.stocks() # subset data and aggregate for DOG prices @@ -113,12 +115,39 @@ Add a prefix and suffix to the numeric value by passing `prefix` and `suffix`. ```python order=indicator_plot,my_table import deephaven.plot.express as dx from deephaven import agg as agg + +my_table = dx.data.stocks() + +# subset data and aggregate for DOG prices +dog_avg = my_table.where("Sym = `DOG`").agg_by([agg.avg(cols="Price"), agg.first(cols="StartingPrice = Price")]) + +indicator_plot = dx.indicator(dog_avg, value="Price", reference="StartingPrice", prefix="$", suffix=" USD") +``` + +## Number Format + +Format the numbers by passing a format string to the `number_format` argument. +The format follows [the GWT Java NumberFormat syntax](https://www.gwtproject.org/javadoc/latest/com/google/gwt/i18n/client/NumberFormat.html). +The default format is set within the settings panel. If only `value` is specified, the default format matches the type of that column. +If `reference` is specified, the default format is the `Integer` format if they are both integers, otherwise the default format is the `Decimal` format. +If a prefix or suffix is passed within the format string, it will be overridden by the `prefix` and `suffix` arguments. + +```python +import deephaven.plot.express as dx +from deephaven import agg as agg + my_table = dx.data.stocks() # subset data and aggregate for DOG prices dog_avg = my_table.where("Sym = `DOG`").agg_by([agg.avg(cols="Price")]) -indicator_plot = dx.indicator(dog_avg, value="Price", prefix="$", suffix="USD") +# format the number with a dollar sign prefix, USD suffix, and three decimal places +indicator_plot = dx.indicator(dog_avg, value="Price", number_format="$#,##0.000USD") + +# prefix overrides the prefix from the number_format +indicator_plot_prefix = dx.indicator( + dog_avg, value="Price", number_format="$#,##0.000USD", prefix="Dollars: " +) ``` ### Delta Symbols @@ -128,12 +157,19 @@ Modify the symbol before the delta value by passing `increasing_text` and `decre ```python order=indicator_plot,my_table import deephaven.plot.express as dx from deephaven import agg as agg + my_table = dx.data.stocks() # subset data and aggregate for DOG prices -dog_avg = my_table.where("Sym = `DOG`").agg_by([agg.avg(cols="Price")]) +dog_agg = my_table.where("Sym = `DOG`").agg_by([agg.avg(cols="Price"), agg.first(cols="StartingPrice = Price")]) -indicator_plot = dx.indicator(dog_avg, value="Price", increasing_text="Up: ", decreasing_text="Down: ") +indicator_plot = dx.indicator( + dog_agg, + value="Price", + reference="StartingPrice", + increasing_text="Up: ", + decreasing_text="Down: " +) ``` ### Indicator with text @@ -143,26 +179,29 @@ Add text to the indicator by passing the text column name to the `text` argument ```python order=indicator_plot,my_table import deephaven.plot.express as dx from deephaven import agg as agg + my_table = dx.data.stocks() # subset data and aggregate prices, keeping the Sym -dog_avg = my_table.where("Sym = `DOG`").agg_by([agg.avg(cols="Price")]) +dog_avg = my_table.where("Sym = `DOG`").agg_by([agg.avg(cols="Price")], by="Sym") -indicator_plot = dx.indicator(dog_avg, value="Price", text="Sym") +indicator_plot = dx.indicator(dog_avg, value="Price", by="Sym", text="Sym") ``` ### Multiple indicators -Visualize multiple numeric values by passing in a table with multiple rows. By default, a square grid of indicators is created. +Visualize multiple numeric values by passing in a table with multiple rows and the `by` argument. By default, a square grid of indicators is created. ```python order=indicator_plot,my_table import deephaven.plot.express as dx +from deephaven import agg as agg + my_table = dx.data.stocks() # aggregate for average prices by Sym sym_avg = my_table.agg_by([agg.avg(cols="Price")], by="Sym") -indicator_plot = dx.indicator(sym_avg, value="Price") +indicator_plot = dx.indicator(sym_avg, value="Price", by="Sym") ``` ### Multiple rows @@ -171,12 +210,14 @@ By default, a grid of indicators is created. To create a specific amount of rows ```python order=indicator_plot,my_table import deephaven.plot.express as dx +from deephaven import agg as agg + my_table = dx.data.stocks() # aggregate for average prices by Sym sym_avg = my_table.agg_by([agg.avg(cols="Price")], by="Sym") -indicator_plot = dx.indicator(sym_avg, value="Price", rows=2) +indicator_plot = dx.indicator(sym_avg, value="Price", by="Sym", rows=2) ``` ### Multiple columns @@ -185,12 +226,14 @@ By default, a grid of indicators is created. To create a specific amount of colu ```python order=indicator_plot,my_table import deephaven.plot.express as dx +from deephaven import agg as agg + my_table = dx.data.stocks() # aggregate for average prices by Sym sym_avg = my_table.agg_by([agg.avg(cols="Price")], by="Sym") -indicator_plot = dx.indicator(sym_avg, value="Price", columns=2) +indicator_plot = dx.indicator(sym_avg, value="Price", by="Sym", cols=2) ``` ### Delta colors @@ -212,9 +255,9 @@ sym_agg = my_table.agg_by( indicator_plot = dx.indicator( sym_agg, value="Price", - reference="Starting Price", - increasing_color_sequence=["green", "darkgreen"], - decreasing_color_sequence=["red", "darkred"], + reference="StartingPrice", + increasing_color_sequence=["darkgreen", "green"], + decreasing_color_sequence=["darkred", "red"], ) ``` @@ -230,10 +273,10 @@ from deephaven import agg as agg my_table = dx.data.stocks() # subset data and aggregate for DOG prices -sym_agg = my_table.agg_by([agg.avg(cols="Price")]) +sym_agg = my_table.where("Sym = `DOG`").agg_by([agg.avg(cols="Price")]) indicator_plot = dx.indicator( - sym_agg, value="Price", gauge_color_sequence=["green", "darkgreen"] + sym_agg, value="Price", gauge="angular", gauge_color_sequence=["darkgreen", "green"] ) ``` @@ -262,6 +305,7 @@ indicator_plot = dx.indicator( value="Price", reference="StartingPrice", by="Sym", + by_vars=("increasing_color", "decreasing_color"), increasing_color_map={"DOG": "darkgreen"}, decreasing_color_map={"DOG": "darkred"}, ) diff --git a/plugins/plotly-express/docs/sidebar.json b/plugins/plotly-express/docs/sidebar.json index a19f52bf9..62ca30d8e 100644 --- a/plugins/plotly-express/docs/sidebar.json +++ b/plugins/plotly-express/docs/sidebar.json @@ -54,6 +54,10 @@ "label": "Icicle", "path": "icicle.md" }, + { + "label": "Indicator", + "path": "indicator.md" + }, { "label": "Line", "path": "line.md" diff --git a/plugins/plotly-express/src/deephaven/plot/express/__init__.py b/plugins/plotly-express/src/deephaven/plot/express/__init__.py index 6a4694442..3dd82122c 100644 --- a/plugins/plotly-express/src/deephaven/plot/express/__init__.py +++ b/plugins/plotly-express/src/deephaven/plot/express/__init__.py @@ -42,6 +42,7 @@ line_geo, line_mapbox, density_heatmap, + indicator, ) from .data import data_generators diff --git a/plugins/plotly-express/src/deephaven/plot/express/communication/DeephavenFigureListener.py b/plugins/plotly-express/src/deephaven/plot/express/communication/DeephavenFigureListener.py index 71388d7a3..103449b6c 100644 --- a/plugins/plotly-express/src/deephaven/plot/express/communication/DeephavenFigureListener.py +++ b/plugins/plotly-express/src/deephaven/plot/express/communication/DeephavenFigureListener.py @@ -69,7 +69,7 @@ def _setup_listeners(self) -> None: for table, node in self._partitioned_tables.values(): listen_func = partial(self._on_update, node) # if a table is not refreshing, it will never update, so no need to listen - if table.is_refreshing: + if table and table.is_refreshing: handle = listen(table, listen_func) self._handles.append(handle) self._liveness_scope.manage(handle) diff --git a/plugins/plotly-express/src/deephaven/plot/express/data_mapping/data_mapping_constants.py b/plugins/plotly-express/src/deephaven/plot/express/data_mapping/data_mapping_constants.py index 9b4c7c784..57bb69041 100644 --- a/plugins/plotly-express/src/deephaven/plot/express/data_mapping/data_mapping_constants.py +++ b/plugins/plotly-express/src/deephaven/plot/express/data_mapping/data_mapping_constants.py @@ -26,6 +26,8 @@ "x_start": "base", "color": "marker/color", "colors": "marker/colors", + "reference": "delta/reference", + "text_indicator": "title/text", } # x_end is not used, the calculations are made in preprocessing step and passed to x diff --git a/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/__init__.py b/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/__init__.py index 0e019f5e4..6f77dab05 100644 --- a/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/__init__.py +++ b/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/__init__.py @@ -3,5 +3,10 @@ DeephavenFigureNode, ) from .generate import generate_figure, update_traces -from .custom_draw import draw_ohlc, draw_candlestick, draw_density_heatmap +from .custom_draw import ( + draw_ohlc, + draw_candlestick, + draw_density_heatmap, + draw_indicator, +) from .RevisionManager import RevisionManager diff --git a/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/custom_draw.py b/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/custom_draw.py index 4d822dd55..5614da008 100644 --- a/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/custom_draw.py +++ b/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/custom_draw.py @@ -8,6 +8,14 @@ from plotly.graph_objects import Figure from plotly.validators.heatmap import ColorscaleValidator +# attach a prefix to the number format so that we can identify it as the GWT Java NumberFormat syntax +# https://www.gwtproject.org/javadoc/latest/com/google/gwt/i18n/client/NumberFormat.html +# this differentiates it from the d3 format syntax, which the user could provide through an unsafe update +# this should be safe as it shouldn't appear naturally in a d3 format string +# https://github.com/d3/d3-format/tree/v1.4.5#d3-format +# but isn't a perfect solution +FORMAT_PREFIX = "DEEPHAVEN_JAVA_FORMAT=" + def draw_finance( data_frame: DataFrame, @@ -186,3 +194,105 @@ def draw_density_heatmap( ) return heatmap + + +def draw_indicator( + data_frame: DataFrame, + value: str, + reference: str | None = None, + number: bool = True, + gauge: str | None = None, + axis: bool = False, + prefix: str | None = None, + suffix: str | None = None, + increasing_text: str | None = None, + decreasing_text: str | None = None, + number_format: str | None = None, + text_indicator: str | None = None, + layout_title: str | None = None, + title: str | None = None, +) -> Figure: + """Create an indicator chart. + + Args: + data_frame: The data frame to draw with + value: The column to use as the value. + reference: The column to use as the reference value. + number: True to show the number, False to hide it. + gauge: Specifies the type of gauge to use. + Set to "angular" for a half-circle gauge and "bullet" for a horizontal gauge. + axis: True to show the axis. Only valid if gauge is set. + prefix: A string to prepend to the number value. + suffix: A string to append to the number value. + increasing_text: The text to display before the delta if the number value + is greater than the reference value. + decreasing_text: The text to display before the delta if the number value + is less than the reference value. + number_format: The format to use for the number. + text_indicator: The column to use as text for the indicator. + layout_title: The title on the layout + title: The title on the indicator trace + + Returns: + The plotly indicator chart. + + """ + + modes = [] + if number: + modes.append("number") + if reference: + modes.append("delta") + if gauge: + modes.append("gauge") + mode = "+".join(modes) + + fig = go.Figure( + go.Indicator( + value=data_frame[value][0], + mode=mode, + domain={"x": [0, 1], "y": [0, 1]}, + ), + layout={ + "legend": {"tracegroupgap": 0}, + "margin": {"t": 60}, + }, + ) + + if reference: + fig.update_traces(delta_reference=data_frame[reference][0]) + + if layout_title: + fig.update_layout(title=layout_title) + + if title: + # This is the title on the indicator trace. This is where it should go by default. + # If if needs to go on the layout, it should be set in layout_title. + fig.update_traces(title_text=title) + + if text_indicator: + fig.update_traces(title_text=data_frame[text_indicator][0]) + + if gauge: + fig.update_traces(gauge={"shape": gauge, "axis": {"visible": axis}}) + + if prefix: + fig.update_traces(delta_prefix=prefix, number_prefix=prefix) + + if suffix: + fig.update_traces(delta_suffix=suffix, number_suffix=suffix) + + if increasing_text: + fig.update_traces(delta_increasing_symbol=increasing_text) + + if decreasing_text: + fig.update_traces(delta_decreasing_symbol=decreasing_text) + + if number_format: + # Plotly expects d3 format strings so these will be converted on the client. + fig.update_traces( + delta_valueformat=FORMAT_PREFIX + number_format, + number_valueformat=FORMAT_PREFIX + number_format, + ) + + return fig diff --git a/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/generate.py b/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/generate.py index 63d20ad55..ed3ac207b 100644 --- a/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/generate.py +++ b/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/generate.py @@ -59,6 +59,9 @@ "lat", "lon", "locations", + "value", + "reference", + "text_indicator", } DATA_ARGS.update(DATA_LIST_ARGS) @@ -82,6 +85,9 @@ "width_sequence": "line_width", "increasing_color_sequence": "increasing_line_color", "decreasing_color_sequence": "decreasing_line_color", + "gauge_color_sequence": "gauge_bar_color", + "increasing_color_sequence_indicator": "delta_increasing_color", + "decreasing_color_sequence_indicator": "delta_decreasing_color", "size_sequence": "marker_size", "mode": "mode", } @@ -704,6 +710,8 @@ def get_list_var_info(data_cols: Mapping[str, str | list[str]]) -> set[str]: # for them types.add("finance" if data_cols.get("x_finance", False) else None) + types.add("indicator" if data_cols.get("value", False) else None) + """for var, cols in data_cols.items(): # there should only be at most one data list (with the filtered # exception of finance charts) so the first one encountered is the var @@ -798,8 +806,8 @@ def hover_text_generator( Yields: A dictionary update """ - if isinstance(types, set) and "finance" in types: - # finance has no hover text currently (besides the default) + if isinstance(types, set) and ("finance" in types or "indicator" in types): + # finance and indicator have no custom hover text while True: yield {} @@ -1035,7 +1043,6 @@ def generate_figure( data_frame = construct_min_dataframe( table, data_cols=merge_cols(list(data_cols.values())) ) - px_fig = draw(data_frame=data_frame, **filtered_call_args) data_mapping, hover_mapping = create_data_mapping( diff --git a/plugins/plotly-express/src/deephaven/plot/express/plots/PartitionManager.py b/plugins/plotly-express/src/deephaven/plot/express/plots/PartitionManager.py index 84f94dce5..8b411f64d 100644 --- a/plugins/plotly-express/src/deephaven/plot/express/plots/PartitionManager.py +++ b/plugins/plotly-express/src/deephaven/plot/express/plots/PartitionManager.py @@ -15,6 +15,7 @@ from .. import DeephavenFigure from ..preprocess.Preprocessor import Preprocessor from ..shared import get_unique_names +from .subplots import atomic_make_grid PARTITION_ARGS = { "by": None, @@ -24,6 +25,9 @@ "size": ("size_sequence", "size_map"), "line_dash": ("line_dash_sequence", "line_dash_map"), "width": ("width_sequence", "width_map"), + "increasing_color": ("increasing_color_sequence", "increasing_color_map"), + "decreasing_color": ("decreasing_color_sequence", "decreasing_color_map"), + "gauge_color": ("gauge_color_sequence", "gauge_color_map"), } FACET_ARGS = {"facet_row", "facet_col"} @@ -44,6 +48,23 @@ "pattern_shape": ["", "/", "\\", "x", "+", "."], "size": [4, 5, 6, 7, 8, 9], "width": [4, 5, 6, 7, 8, 9], + # these are plot by but do not currently have a default sequence + # setting them to None ensures they don't have to be removed in the + # client for theming to work + "increasing_color": None, + "decreasing_color": None, + "gauge_color": None, +} + +# params that invoke plot bys and have no special cases like color and size +PLOT_BY_ONLY = { + "pattern_shape", + "symbol", + "line_dash", + "width", + "gauge_color", + "increasing_color", + "decreasing_color", } @@ -103,6 +124,59 @@ def is_single_numeric_col(val: str | list[str], numeric_cols: set[str]) -> bool: return (isinstance(val, str) or len(val) == 1) and val in numeric_cols +def update_title( + args: dict[str, Any], count: int, title: str | None, types: set[str] +) -> dict[str, Any]: + """ + Update the title + + Args: + args: Args used to determine the title + count: The number of partitions + title: The title to update + types: The types of the plot + + Returns: + dict[str, Any]: The updated title args + """ + title_args = {} + if "indicator" in types: + text_indicator = args.get("text_indicator") + + if "current_partition" in args: + partition_title = ", ".join(args["current_partition"].values()) + if count == 1: + # if there is only one partition, the title should still be on the indicator itself + # because of excessive padding when using the layout title + title_args["title"] = title + else: + title_args["layout_title"] = title + + if text_indicator is None: + # if there is no text column, the partition names should be used + # text can be False, which doesn't show text, so check for None specifically + if title: + # add the title to the layout as it could be possibly overwritten if count == 1 + title_args["layout_title"] = title + title_args["title"] = partition_title + elif title is not None: + # there must be only one trace, so put the title on the indicator itself + # this will be overwritten if there is a text column + title_args["title"] = title + + # regardless of if there is a partition or not, the title should be on the layout + # if there is a text column + if text_indicator and title: + # there is text, so add the title to the layout as it could be possibly overwritten if count == 1 + title_args["layout_title"] = title + + elif title is not None: + # currently, only indicators that are partitions have custom title behavior + # so this is the default behavior + title_args["title"] = title + return title_args + + class PartitionManager: """ Handles all partitions for the given args @@ -164,6 +238,9 @@ def __init__( # in some cases, such as violin plots, the default groups are a static object that is shared and should # be copied to not modify the original self.groups = copy(groups) if groups else set() + self.grid_rows = args.pop("rows", None) + self.grid_cols = args.pop("cols", None) + self.indicator = "indicator" in self.groups self.preprocessor = None self.set_long_mode_variables() self.convert_table_to_long_mode() @@ -172,6 +249,8 @@ def __init__( self.draw_figure = draw_figure self.constituents = [] + self.title = args.pop("title", None) + def set_long_mode_variables(self) -> None: """ If dealing with a "supports_lists" plot, set variables that will be @@ -257,12 +336,16 @@ def is_by(self, arg: str, map_val: str | list[str] | None = None) -> None: else: map_arg = PARTITION_ARGS[arg][1] map_val = self.args[map_arg] + if map_val == "by": self.args[map_arg] = None if isinstance(map_val, tuple): # the first element should be "by" and the map should be in the second, although a tuple with only "by" # in it should also work self.args[map_arg] = map_val[1] if len(map_val) == 2 else None + if self.args[arg] is None and self.by_vars and arg in self.by_vars: + # if there is no column specified for this specific arg, the by column is used + self.args[arg] = self.args["by"] self.args[f"{arg}_by"] = self.args.pop(arg) def handle_plot_by_arg( @@ -346,7 +429,7 @@ def handle_plot_by_arg( self.args["size_sequence"] = STYLE_DEFAULTS[arg] args["size_by"] = plot_by_cols - elif arg in {"pattern_shape", "symbol", "line_dash", "width"}: + elif arg in PLOT_BY_ONLY: seq_name, map_name = PARTITION_ARGS[arg][0], PARTITION_ARGS[arg][1] seq, map_ = args[seq_name], args[map_name] if map_ == "by" or isinstance(map_, dict): @@ -410,9 +493,17 @@ def process_partitions(self) -> Table | PartitionedTable: self.facet_row = val else: self.facet_col = val + """ + if arg == "text": + if self.by and val is None and self.indicator: + # if by is set, text should be set to "by" by default + # note that text can be False, which doesn't show text, + # so check for None specifically + args["text"] = self.by + """ # it's possible that by vars are set but by_vars is None, - # so partitioning is still needed but it won't affect styles + # so partitioning is still needed, but it won't affect styles if not self.by_vars and self.by: partition_cols.update(self.by if isinstance(self.by, list) else [self.by]) @@ -576,7 +667,6 @@ def partition_generator(self) -> Generator[dict[str, Any], None, None]: args[self.list_var] = self.pivot_vars["value"] args["current_partition"] = current_partition - args["table"] = table yield args elif ( @@ -607,7 +697,7 @@ def default_figure(self) -> DeephavenFigure: # there are no partitions until a better solution can be done # also need the px template to be set # the title can also be set here as it will never change - title = self.args.get("title") + title = self.title default_fig = px.scatter(x=[0], y=[0], title=title) default_fig.update_traces(x=[], y=[]) return DeephavenFigure(default_fig) @@ -630,6 +720,12 @@ def create_figure(self) -> DeephavenFigure: trace_generator = None figs = [] for i, args in enumerate(self.partition_generator()): + title_update = update_title( + args, len(self.constituents), self.title, self.groups + ) + + args = {**args, **title_update} + fig = self.draw_figure(call_args=args, trace_generator=trace_generator) if not trace_generator: trace_generator = fig.get_trace_generator() @@ -667,7 +763,12 @@ def create_figure(self) -> DeephavenFigure: figs.append(fig) try: - layered_fig = atomic_layer(*figs, which_layout=0) + if self.indicator: + layered_fig = atomic_make_grid( + *figs, rows=self.grid_rows, cols=self.grid_cols + ) + else: + layered_fig = atomic_layer(*figs, which_layout=0) except ValueError: return self.default_figure() diff --git a/plugins/plotly-express/src/deephaven/plot/express/plots/__init__.py b/plugins/plotly-express/src/deephaven/plot/express/plots/__init__.py index f87c84086..724812a98 100644 --- a/plugins/plotly-express/src/deephaven/plot/express/plots/__init__.py +++ b/plugins/plotly-express/src/deephaven/plot/express/plots/__init__.py @@ -10,3 +10,4 @@ from .subplots import make_subplots from .maps import scatter_geo, scatter_mapbox, density_mapbox, line_geo, line_mapbox from .heatmap import density_heatmap +from .indicator import indicator diff --git a/plugins/plotly-express/src/deephaven/plot/express/plots/_layer.py b/plugins/plotly-express/src/deephaven/plot/express/plots/_layer.py index db852a36d..90e30a53e 100644 --- a/plugins/plotly-express/src/deephaven/plot/express/plots/_layer.py +++ b/plugins/plotly-express/src/deephaven/plot/express/plots/_layer.py @@ -2,13 +2,12 @@ from functools import partial from typing import Any, Callable, cast, Tuple, TypedDict - +from deephaven.execution_context import make_user_exec_ctx from plotly.graph_objs import Figure from ..deephaven_figure import DeephavenFigure from ..shared import default_callback, unsafe_figure_update_wrapper -from deephaven.execution_context import make_user_exec_ctx class LayerSpecDict(TypedDict, total=False): diff --git a/plugins/plotly-express/src/deephaven/plot/express/plots/_private_utils.py b/plugins/plotly-express/src/deephaven/plot/express/plots/_private_utils.py index 33cd43513..ee658906f 100644 --- a/plugins/plotly-express/src/deephaven/plot/express/plots/_private_utils.py +++ b/plugins/plotly-express/src/deephaven/plot/express/plots/_private_utils.py @@ -101,7 +101,7 @@ def apply_args_groups(args: dict[str, Any], possible_groups: set[str] | None) -> Args: args: A dictionary of args to transform - groups: A set of groups used to transform the args + possible_groups: A set of groups used to transform the args """ groups: set = ( @@ -167,6 +167,19 @@ def apply_args_groups(args: dict[str, Any], possible_groups: set[str] | None) -> if "webgl" in groups: args["render_mode"] = "webgl" + if "indicator" in groups: + append_suffixes( + [ + "increasing_color_sequence", + "attached_increasing_color", + "decreasing_color_sequence", + "attached_decreasing_color", + "text", + ], + ["indicator"], + sync_dict, + ) + sync_dict.sync_pop() diff --git a/plugins/plotly-express/src/deephaven/plot/express/plots/indicator.py b/plugins/plotly-express/src/deephaven/plot/express/plots/indicator.py index 16a54117e..5a24203e6 100644 --- a/plugins/plotly-express/src/deephaven/plot/express/plots/indicator.py +++ b/plugins/plotly-express/src/deephaven/plot/express/plots/indicator.py @@ -1,17 +1,18 @@ from __future__ import annotations -from typing import Callable +from typing import Callable, Literal from ..shared import default_callback -from ..deephaven_figure import DeephavenFigure +from ..deephaven_figure import DeephavenFigure, draw_indicator from ..types import PartitionableTableLike, Gauge, StyleDict +from ._private_utils import process_args def indicator( table: PartitionableTableLike, value: str | None, reference: str | None = None, - text: str | None = None, + text: str | Literal[False] | None = None, by: str | list[str] | None = None, by_vars: str | tuple[str, ...] = "gauge_color", increasing_color: str | list[str] | None = None, @@ -30,8 +31,10 @@ def indicator( suffix: str | None = None, increasing_text: str | None = "▲", decreasing_text: str | None = "▼", + number_format: str | None = None, rows: int | None = None, - columns: int | None = None, + cols: int | None = None, + title: str | None = None, unsafe_update_figure: Callable = default_callback, ) -> DeephavenFigure: """ @@ -58,7 +61,8 @@ def indicator( gauge_color: A column or list of columns used for a plot by on color. Only valid if gauge is not None. See gauge_color_map for additional behaviors. - text: A column that contains text annotations. + text: A column that contains text annotations. Set to "by" if by is specified and is one column. + Set to False to hide text annotations. increasing_color_sequence: A list of colors to sequentially apply to the series. The colors loop, so if there are more series than colors, colors are reused. @@ -84,12 +88,16 @@ def indicator( is greater than the reference value. decreasing_text: The text to display before the delta if the number value is less than the reference value. + number_format: A string that specifies the number format for values and deltas. + Default is "#,##0.00" which formats numbers with commas every three digits + and two decimal places. rows: The number of rows of indicators to create. If None, the number of rows is determined by the number of columns. If both rows and columns are None, a square grid is created. - columns: The number of columns of indicators to create. + cols: The number of columns of indicators to create. If None, the number of columns is determined by the number of rows. If both rows and columns are None, a square grid is created. + title: The title of the chart unsafe_update_figure: An update function that takes a plotly figure as an argument and optionally returns a plotly figure. If a figure is not returned, the plotly figure passed will be assumed to be the return @@ -102,4 +110,6 @@ def indicator( A DeephavenFigure that contains the indicator chart """ - raise NotImplementedError + args = locals() + + return process_args(args, {"indicator"}, px_func=draw_indicator) diff --git a/plugins/plotly-express/src/deephaven/plot/express/plots/subplots.py b/plugins/plotly-express/src/deephaven/plot/express/plots/subplots.py index d08014951..9d10065c5 100644 --- a/plugins/plotly-express/src/deephaven/plot/express/plots/subplots.py +++ b/plugins/plotly-express/src/deephaven/plot/express/plots/subplots.py @@ -1,12 +1,13 @@ from __future__ import annotations import math -from typing import Any, TypeVar, List, cast, TypedDict - +from typing import Any, TypeVar, List, cast, TypedDict, Callable +from deephaven.execution_context import make_user_exec_ctx from plotly.graph_objs import Figure -from ._layer import layer, LayerSpecDict +from ._layer import layer, LayerSpecDict, atomic_layer from .. import DeephavenFigure +from ..shared import default_callback # generic grid that is a list of lists of anything T = TypeVar("T") @@ -207,7 +208,7 @@ def is_grid(specs: list[SubplotSpecDict] | Grid[SubplotSpecDict]) -> bool: return list_count == len(specs) and list_count > 0 -def make_subplots( +def atomic_make_subplots( *figs: Figure | DeephavenFigure, rows: int = 0, cols: int = 0, @@ -219,41 +220,23 @@ def make_subplots( column_widths: list[float] | None = None, row_heights: list[float] | None = None, specs: list[SubplotSpecDict] | Grid[SubplotSpecDict] | None = None, + unsafe_update_figure: Callable = default_callback, ) -> DeephavenFigure: """Create subplots. Either figs and at least one of rows and cols or grid should be passed. Args: - *figs: Figures to use. Should be used with rows and/or cols. - rows: A list of rows in the resulting subplot grid. This is - calculated from cols and number of figs provided if not passed - but cols is. - One of rows or cols should be provided if passing figs directly. - cols: A list of cols in the resulting subplot grid. This is - calculated from rows and number of figs provided if not passed - but rows is. - One of rows or cols should be provided if passing figs directly. - shared_xaxes: "rows", "cols"/True, "all" or None depending on what axes - should be shared - shared_yaxes: "rows"/True, "cols", "all" or None depending on what axes - should be shared - grid: A grid (list of lists) of figures to draw. None can be - provided in a grid entry - horizontal_spacing: Spacing between each column. Default 0.2 / cols - vertical_spacing: Spacing between each row. Default 0.3 / rows - column_widths: The widths of each column. Should sum to 1. - row_heights: The heights of each row. Should sum to 1. - specs: (Default value = None) - A list or grid of dicts that contain specs. An empty - dictionary represents no specs, and None represents no figure, either - to leave a gap on the subplots on provide room for a figure spanning - multiple columns. - 'l' is a float that adds left padding - 'r' is a float that adds right padding - 't' is a float that adds top padding - 'b' is a float that adds bottom padding - 'rowspan' is an int to make this figure span multiple rows - 'colspan' is an int to make this figure span multiple columns + *figs: See make_subplots + rows: See make_subplots + cols: See make_subplots + shared_xaxes: See make_subplots + shared_yaxes: See make_subplots + grid: See make_subplots + horizontal_spacing: See make_subplots + vertical_spacing: See make_subplots + column_widths: See make_subplots + row_heights: See make_subplots + specs: See make_subplots Returns: DeephavenFigure: The DeephavenFigure with subplots @@ -301,7 +284,7 @@ def make_subplots( col_starts, col_ends = get_domains(column_widths, horizontal_spacing) row_starts, row_ends = get_domains(row_heights, vertical_spacing) - return layer( + return atomic_layer( *[fig for fig_row in grid for fig in fig_row], specs=get_new_specs( spec_grid, @@ -312,4 +295,120 @@ def make_subplots( shared_xaxes, shared_yaxes, ), + unsafe_update_figure=unsafe_update_figure, + ) + + +def atomic_make_grid( + *figs: Figure | DeephavenFigure, + rows: int | None, + cols: int | None, +) -> DeephavenFigure: + """ + Create a grid of figures. + The number of rows and columns are calculated to be approximately square if both are None. + + Args: + *figs: Figures to use + rows: Rows in the grid. Can be None if cols is provided or if a square grid is desired. + cols: Columns in the grid. Can be None if rows is provided or if a square grid is desired. + + Returns: + DeephavenFigure: The DeephavenFigure with the grid of figures + """ + # grid size is approximately sqrt(len(figs)) + if rows is None and cols is None: + cols = math.ceil(math.sqrt(len(figs))) + rows = math.ceil(len(figs) / cols) + elif rows is None: + # if cols is not None, then rows is calculated from cols in atomic_make_subplots + rows = 0 + elif cols is None: + cols = 0 + if rows is None or cols is None: + raise ValueError("Invalid rows and cols") + return atomic_make_subplots(*figs, rows=rows, cols=cols) + + +def make_subplots( + *figs: Figure | DeephavenFigure, + rows: int = 0, + cols: int = 0, + shared_xaxes: str | bool | None = None, + shared_yaxes: str | bool | None = None, + grid: Grid[Figure | DeephavenFigure] | None = None, + horizontal_spacing: float | None = None, + vertical_spacing: float | None = None, + column_widths: list[float] | None = None, + row_heights: list[float] | None = None, + specs: list[SubplotSpecDict] | Grid[SubplotSpecDict] | None = None, + unsafe_update_figure: Callable = default_callback, +) -> DeephavenFigure: + """Create subplots. Either figs and at least one of rows and cols or grid + should be passed. + + Args: + *figs: Figures to use. Should be used with rows and/or cols. + rows: A list of rows in the resulting subplot grid. This is + calculated from cols and number of figs provided if not passed + but cols is. + One of rows or cols should be provided if passing figs directly. + cols: A list of cols in the resulting subplot grid. This is + calculated from rows and number of figs provided if not passed + but rows is. + One of rows or cols should be provided if passing figs directly. + shared_xaxes: "rows", "cols"/True, "all" or None depending on what axes + should be shared + shared_yaxes: "rows"/True, "cols", "all" or None depending on what axes + should be shared + grid: A grid (list of lists) of figures to draw. None can be + provided in a grid entry + horizontal_spacing: Spacing between each column. Default 0.2 / cols + vertical_spacing: Spacing between each row. Default 0.3 / rows + column_widths: The widths of each column. Should sum to 1. + row_heights: The heights of each row. Should sum to 1. + specs: (Default value = None) + A list or grid of dicts that contain specs. An empty + dictionary represents no specs, and None represents no figure, either + to leave a gap on the subplots on provide room for a figure spanning + multiple columns. + 'l' is a float that adds left padding + 'r' is a float that adds right padding + 't' is a float that adds top padding + 'b' is a float that adds bottom padding + 'rowspan' is an int to make this figure span multiple rows + 'colspan' is an int to make this figure span multiple columns + unsafe_update_figure: An update function that takes a plotly figure + as an argument and optionally returns a plotly figure. If a figure is not + returned, the plotly figure passed will be assumed to be the return value. + Used to add any custom changes to the underlying plotly figure. Note that + the existing data traces should not be removed. This may lead to unexpected + behavior if traces are modified in a way that break data mappings. + + Returns: + DeephavenFigure: The DeephavenFigure with subplots + + """ + args = locals() + + func = atomic_make_subplots + + new_fig = atomic_make_subplots( + *figs, + rows=rows, + cols=cols, + shared_xaxes=shared_xaxes, + shared_yaxes=shared_yaxes, + grid=grid, + horizontal_spacing=horizontal_spacing, + vertical_spacing=vertical_spacing, + column_widths=column_widths, + row_heights=row_heights, + specs=specs, ) + + exec_ctx = make_user_exec_ctx() + + new_fig.add_layer_to_graph(func, args, exec_ctx) + + return new_fig diff --git a/plugins/plotly-express/src/deephaven/plot/express/types/plots.py b/plugins/plotly-express/src/deephaven/plot/express/types/plots.py index 172d8d220..24d9a40b0 100644 --- a/plugins/plotly-express/src/deephaven/plot/express/types/plots.py +++ b/plugins/plotly-express/src/deephaven/plot/express/types/plots.py @@ -6,7 +6,7 @@ TableLike = Union[Table, DataFrame] PartitionableTableLike = Union[PartitionedTable, TableLike] -Gauge = Literal["shape", "bullet"] +Gauge = Literal["angular", "bullet"] # StyleDict is a dictionary that maps column values to style values. StyleDict = Dict[Union[str, Tuple[str]], str] diff --git a/plugins/plotly-express/src/js/src/PlotlyExpressChartModel.test.ts b/plugins/plotly-express/src/js/src/PlotlyExpressChartModel.test.ts index 54cfbf4fd..d8e1559bc 100644 --- a/plugins/plotly-express/src/js/src/PlotlyExpressChartModel.test.ts +++ b/plugins/plotly-express/src/js/src/PlotlyExpressChartModel.test.ts @@ -2,8 +2,13 @@ import type { Layout } from 'plotly.js'; import { dh as DhType } from '@deephaven/jsapi-types'; import { TestUtils } from '@deephaven/test-utils'; import { ChartModel } from '@deephaven/chart'; +import { type Formatter } from '@deephaven/jsapi-utils'; import { PlotlyExpressChartModel } from './PlotlyExpressChartModel'; -import { PlotlyChartWidgetData } from './PlotlyExpressChartUtils'; +import { + getWidgetData, + PlotlyChartWidgetData, + setDefaultValueFormat, +} from './PlotlyExpressChartUtils'; const SMALL_TABLE = TestUtils.createMockProxy({ columns: [{ name: 'x' }, { name: 'y' }] as DhType.Column[], @@ -23,6 +28,11 @@ const REALLY_LARGE_TABLE = TestUtils.createMockProxy({ subscribe: () => TestUtils.createMockProxy(), }); +jest.mock('./PlotlyExpressChartUtils', () => ({ + ...jest.requireActual('./PlotlyExpressChartUtils'), + setDefaultValueFormat: jest.fn(), +})); + function createMockWidget(tables: DhType.Table[], plotType = 'scatter') { const layoutAxes: Partial = {}; tables.forEach((_, i) => { @@ -57,7 +67,7 @@ function createMockWidget(tables: DhType.Table[], plotType = 'scatter') { yaxis: i === 0 ? 'y' : `y${i + 1}`, })), layout: { - title: 'layout', + title: { text: 'Test' }, ...layoutAxes, }, }, @@ -320,4 +330,66 @@ describe('PlotlyExpressChartModel', () => { new CustomEvent(ChartModel.EVENT_BLOCKER_CLEAR) ); }); + + it('should call setDefaultValueFormat when the formatter is updated', async () => { + const mockWidget = createMockWidget([SMALL_TABLE], 'scatter'); + const chartModel = new PlotlyExpressChartModel( + mockDh, + mockWidget, + jest.fn() + ); + + const mockSubscribe = jest.fn(); + await chartModel.subscribe(mockSubscribe); + await new Promise(process.nextTick); // Subscribe is async + const mockFormatter = TestUtils.createMockProxy(); + expect(setDefaultValueFormat).toHaveBeenCalledTimes(1); + chartModel.setFormatter(mockFormatter); + expect(setDefaultValueFormat).toHaveBeenCalledTimes(2); + }); + + it('should emit layout update events when the widget is updated and a title exists', async () => { + const mockWidget = createMockWidget([SMALL_TABLE], 'scatter'); + const chartModel = new PlotlyExpressChartModel( + mockDh, + mockWidget, + jest.fn() + ); + + const mockSubscribe = jest.fn(); + await chartModel.subscribe(mockSubscribe); + await new Promise(process.nextTick); // Subscribe is async + chartModel.setRenderOptions({ webgl: true }); + // no calls because the chart has webgl enabled + expect(mockSubscribe).toHaveBeenCalledTimes(0); + chartModel.setRenderOptions({ webgl: false }); + // blocking event should be emitted + expect(mockSubscribe).toHaveBeenCalledTimes(1); + expect(mockSubscribe).toHaveBeenLastCalledWith( + new CustomEvent(ChartModel.EVENT_BLOCKER) + ); + }); + + it('should emit layout update events if a widget is updated and has a title', async () => { + const mockWidget = createMockWidget([SMALL_TABLE]); + const chartModel = new PlotlyExpressChartModel( + mockDh, + mockWidget, + jest.fn() + ); + + const mockSubscribe = jest.fn(); + await chartModel.subscribe(mockSubscribe); + await new Promise(process.nextTick); + if (chartModel.widget instanceof Object) { + chartModel.handleWidgetUpdated( + getWidgetData(chartModel.widget), + chartModel.widget.exportedObjects + ); + } + expect(mockSubscribe).toHaveBeenCalledTimes(1); + expect(mockSubscribe).toHaveBeenLastCalledWith( + new CustomEvent(ChartModel.EVENT_LAYOUT_UPDATED) + ); + }); }); diff --git a/plugins/plotly-express/src/js/src/PlotlyExpressChartModel.ts b/plugins/plotly-express/src/js/src/PlotlyExpressChartModel.ts index f122daa1a..95a0f512e 100644 --- a/plugins/plotly-express/src/js/src/PlotlyExpressChartModel.ts +++ b/plugins/plotly-express/src/js/src/PlotlyExpressChartModel.ts @@ -1,5 +1,6 @@ import type { Layout, Data, PlotData, LayoutAxis } from 'plotly.js'; import type { dh as DhType } from '@deephaven/jsapi-types'; +import { type Formatter } from '@deephaven/jsapi-utils'; import { ChartModel, ChartUtils } from '@deephaven/chart'; import Log from '@deephaven/log'; import { RenderOptions } from '@deephaven/chart/dist/ChartModel'; @@ -18,6 +19,11 @@ import { removeColorsFromData, setWebGlTraceType, hasUnreplaceableWebGlTraces, + isSingleValue, + replaceValueFormat, + setDefaultValueFormat, + getDataTypeMap, + FormatUpdate, } from './PlotlyExpressChartUtils'; const log = Log.module('@deephaven/js-plugin-plotly-express.ChartModel'); @@ -131,6 +137,17 @@ export class PlotlyExpressChartModel extends ChartModel { */ hasAcknowledgedWebGlWarning = false; + /** + * The set of parameters that need to be replaced with the default value format. + */ + defaultValueFormatSet: Set = new Set(); + + /** + * Map of variable within the plotly data to type. + * For example, '0/value' -> 'int' + */ + dataTypeMap: Map = new Map(); + override getData(): Partial[] { const hydratedData = [...this.plotlyData]; @@ -145,11 +162,17 @@ export class PlotlyExpressChartModel extends ChartModel { paths.forEach(destination => { // The JSON pointer starts w/ /plotly/data and we don't need that part const parts = getPathParts(destination); + + const single = isSingleValue(hydratedData, parts); + // eslint-disable-next-line @typescript-eslint/no-explicit-any let selector: any = hydratedData; + for (let i = 0; i < parts.length; i += 1) { if (i !== parts.length - 1) { selector = selector[parts[i]]; + } else if (single) { + selector[parts[i]] = tableData[columnName]?.[0] ?? null; } else { selector[parts[i]] = tableData[columnName] ?? []; } @@ -227,6 +250,16 @@ export class PlotlyExpressChartModel extends ChartModel { super.setRenderOptions(renderOptions); } + override setFormatter(formatter: Formatter): void { + setDefaultValueFormat( + this.plotlyData, + this.defaultValueFormatSet, + this.dataTypeMap, + formatter + ); + super.setFormatter(formatter); + } + /** * Handle the WebGL option being set in the render options. * If WebGL is enabled, traces have their original types as given. @@ -264,6 +297,7 @@ export class PlotlyExpressChartModel extends ChartModel { // @deephaven/chart Chart component mutates the layout // If we want updates like the zoom range, we must only set the layout once on init + // The title is currently the only thing that can be updated after init if (Object.keys(this.layout).length > 0) { return; } @@ -296,6 +330,8 @@ export class PlotlyExpressChartModel extends ChartModel { ); } + this.defaultValueFormatSet = replaceValueFormat(this.plotlyData); + // Retrieve the indexes of traces that require WebGL so they can be replaced if WebGL is disabled this.webGlTraceIndices = getReplaceableWebGlTraceIndices(this.plotlyData); @@ -304,10 +340,28 @@ export class PlotlyExpressChartModel extends ChartModel { newReferences.forEach(async (id, i) => { this.tableDataMap.set(id, {}); // Plot may render while tables are being fetched. Set this to avoid a render error const table = (await references[i].fetch()) as DhType.Table; - this.addTable(id, table); + this.addTable(id, table).then(() => { + // The data type map requires the table to be added to the reference map + this.dataTypeMap = getDataTypeMap(deephaven, this.tableReferenceMap); + + setDefaultValueFormat( + this.plotlyData, + this.defaultValueFormatSet, + this.dataTypeMap, + this.formatter + ); + }); }); removedReferences.forEach(id => this.removeTable(id)); + + // title is the only thing expected to be updated after init from the layout + if ( + typeof plotlyLayout.title === 'object' && + plotlyLayout.title.text != null + ) { + this.fireLayoutUpdated({ title: plotlyLayout.title }); + } } handleFigureUpdated( @@ -316,7 +370,6 @@ export class PlotlyExpressChartModel extends ChartModel { ): void { const chartData = this.chartDataMap.get(tableId); const tableData = this.tableDataMap.get(tableId); - if (chartData == null) { log.warn('Unknown chartData for this event. Skipping update'); return; diff --git a/plugins/plotly-express/src/js/src/PlotlyExpressChartUtils.test.ts b/plugins/plotly-express/src/js/src/PlotlyExpressChartUtils.test.ts index 9ff30906d..d47d0a1ad 100644 --- a/plugins/plotly-express/src/js/src/PlotlyExpressChartUtils.test.ts +++ b/plugins/plotly-express/src/js/src/PlotlyExpressChartUtils.test.ts @@ -1,3 +1,7 @@ +import type { dh as DhType } from '@deephaven/jsapi-types'; +import { type Formatter } from '@deephaven/jsapi-utils'; +import { Delta } from 'plotly.js-dist-min'; +import { TestUtils } from '@deephaven/test-utils'; import { getPathParts, isLineSeries, @@ -10,8 +14,46 @@ import { getReplaceableWebGlTraceIndices, hasUnreplaceableWebGlTraces, setWebGlTraceType, + isSingleValue, + transformValueFormat, + replaceValueFormat, + FORMAT_PREFIX, + getDataTypeMap, + PlotlyChartDeephavenData, + setDefaultValueFormat, + convertToPlotlyNumberFormat, } from './PlotlyExpressChartUtils'; +const findColumn = jest.fn().mockImplementation(columnName => { + let type = 'int'; + if (columnName === 'y') { + type = 'double'; + } + return { type }; +}); + +const MOCK_TABLE = TestUtils.createMockProxy({ + columns: [{ name: 'x' }, { name: 'y' }] as DhType.Column[], + size: 500, + subscribe: () => TestUtils.createMockProxy(), + findColumn, +}); + +const getColumnTypeFormatter = jest.fn().mockImplementation(columnType => { + if (columnType === 'int') { + return { + defaultFormatString: '$#,##0USD', + }; + } + return { + defaultFormatString: '$#,##0.00USD', + }; +}); + +const FORMATTER = { + getColumnTypeFormatter, +} as unknown as Formatter; + describe('getDataMappings', () => { it('should return the data mappings from the widget data', () => { const widgetData = { @@ -318,3 +360,385 @@ describe('setWebGlTraceType', () => { expect(data[1].type).toBe('scatter'); }); }); + +describe('isSingleValue', () => { + it('should return true is the data contains an indicator trace', () => { + expect( + isSingleValue([{ type: 'indicator' }], ['0', 'delta', 'reference']) + ).toBe(true); + }); + + it('should return false if the data does not contain an indicator trace', () => { + expect(isSingleValue([{ type: 'scatter' }], ['0', 'x'])).toBe(false); + }); +}); + +describe('setDefaultValueFormat', () => { + it('should only set the valueformat to double if at least one type is double and there is no valueformat', () => { + const plotlyData = [ + { + type: 'indicator', + number: [], + value: {}, + delta: { + reference: {}, + prefix: 'prefix', + suffix: 'suffix', + }, + }, + { + type: 'indicator', + number: [], + value: { + valueformat: 'valueformat', + }, + delta: { + reference: {}, + valueformat: 'valueformat', + prefix: 'prefix', + suffix: 'suffix', + }, + }, + ] as unknown as Plotly.Data[]; + const defaultValueFormatSet = new Set([ + { + index: 0, + path: 'number', + typeFrom: ['value', 'delta/reference'], + options: { + format: true, + prefix: true, + suffix: true, + }, + }, + { + index: 0, + path: 'delta', + typeFrom: ['value', 'delta/reference'], + options: { + format: true, + prefix: false, + suffix: false, + }, + }, + ]); + const dataTypeMap = new Map([ + ['/plotly/data/0/value', 'int'], + ['/plotly/data/0/delta/reference', 'double'], + ]); + + setDefaultValueFormat( + plotlyData, + defaultValueFormatSet, + dataTypeMap, + FORMATTER + ); + + const expectedData = [ + { + type: 'indicator', + number: [], + value: {}, + delta: { reference: {}, prefix: 'prefix', suffix: 'suffix' }, + }, + { + type: 'indicator', + number: [], + value: { valueformat: 'valueformat' }, + delta: { + reference: {}, + valueformat: 'valueformat', + prefix: 'prefix', + suffix: 'suffix', + }, + }, + ]; + + expect(plotlyData).toEqual(expectedData); + }); + it('should set the value format to the int if all are int', () => { + const plotlyData = [ + { + type: 'indicator', + number: [], + value: {}, + delta: { + reference: {}, + prefix: 'prefix', + suffix: 'suffix', + }, + }, + ] as unknown as Plotly.Data[]; + const defaultValueFormatSet = new Set([ + { + index: 0, + path: 'number', + typeFrom: ['value', 'delta/reference'], + options: { + format: true, + prefix: false, + suffix: false, + }, + }, + { + index: 0, + path: 'delta', + typeFrom: ['value', 'delta/reference'], + options: { + format: true, + prefix: true, + suffix: true, + }, + }, + ]); + const dataTypeMap = new Map([ + ['/plotly/data/0/value', 'int'], + ['/plotly/data/0/delta/reference', 'int'], + ]); + + setDefaultValueFormat( + plotlyData, + defaultValueFormatSet, + dataTypeMap, + FORMATTER + ); + + const expectedData = [ + { + type: 'indicator', + number: [], + value: {}, + delta: { reference: {}, prefix: 'prefix', suffix: 'suffix' }, + }, + ]; + + expect(plotlyData).toEqual(expectedData); + }); +}); + +describe('convertToPlotlyNumberFormat', () => { + it('should convert the format to a Plotly number format', () => { + const data = {}; + const valueformat = '$##,##0.00USD'; + const options = { + format: true, + prefix: true, + suffix: true, + }; + + convertToPlotlyNumberFormat(data, valueformat, options); + + expect(data).toEqual({ + valueformat: '01,.2f', + prefix: '$', + suffix: 'USD', + }); + }); + + it('should not add the prefix and suffix if the are false', () => { + const data = {}; + const valueformat = '##,##0.00USD'; + const options = { + format: true, + prefix: false, + suffix: false, + }; + + convertToPlotlyNumberFormat(data, valueformat, options); + + expect(data).toEqual({ + valueformat: '01,.2f', + }); + }); + + it('should not add the format if it is false', () => { + const data = {}; + const valueformat = '##,##0.00USD'; + const options = { + format: false, + prefix: true, + suffix: true, + }; + + convertToPlotlyNumberFormat(data, valueformat, options); + + expect(data).toEqual({ + prefix: '', + suffix: 'USD', + }); + }); +}); + +describe('transformValueFormat', () => { + it('should not transform the value if it does not contain FORMAT_PREFIX', () => { + const data = { + valueformat: '.2f', + }; + + const numberFormatOptions = transformValueFormat(data); + expect(data.valueformat).toBe('.2f'); + expect(numberFormatOptions).toEqual({ + format: false, + }); + }); + + it('should transform the value if it contains FORMAT_PREFIX', () => { + const data = { + valueformat: `${FORMAT_PREFIX}#,##0.00`, + }; + + const numberFormatOptions = transformValueFormat(data); + + expect(data.valueformat).toBe('01,.2f'); + expect(numberFormatOptions).toEqual({ + format: false, + }); + }); + + it('should not replace the prefix and suffix if they are already there', () => { + const data = { + valueformat: `${FORMAT_PREFIX}$#,##0.00USD`, + prefix: 'prefix', + suffix: 'suffix', + }; + + const numberFormatOptions = transformValueFormat(data); + + expect(data.valueformat).toBe('01,.2f'); + expect(data.prefix).toBe('prefix'); + expect(data.suffix).toBe('suffix'); + expect(numberFormatOptions).toEqual({ + format: false, + }); + }); + + it('should replace the prefix and suffix if they are null', () => { + const data = { + valueformat: `${FORMAT_PREFIX}$#,##0.00USD`, + prefix: null, + suffix: null, + }; + + const numberFormatOptions = transformValueFormat(data); + + expect(data.valueformat).toBe('01,.2f'); + expect(data.prefix).toBe('$'); + expect(data.suffix).toBe('USD'); + expect(numberFormatOptions).toEqual({ + format: false, + }); + }); + + it('should return true for all format options if the format is not defined', () => { + const data = {} as Partial; + + const numberFormatOptions = transformValueFormat(data); + + expect(data.valueformat).toBe(undefined); + expect(numberFormatOptions).toEqual({ + format: true, + prefix: true, + suffix: true, + }); + }); + + it('should return true for the format but false for the prefix and suffix if they are defined', () => { + const data = { + prefix: 'prefix', + suffix: 'suffix', + } as Partial; + + const numberFormatOptions = transformValueFormat(data); + + expect(data.valueformat).toBe(undefined); + expect(numberFormatOptions).toEqual({ + format: true, + prefix: false, + suffix: false, + }); + }); +}); + +describe('replaceValueFormat', () => { + it('should replace formatting for indicator traces', () => { + const data = [ + { + type: 'indicator', + delta: { + valueformat: `${FORMAT_PREFIX}#,##0.00`, + }, + number: { + valueformat: `${FORMAT_PREFIX}#,##0.00`, + }, + }, + ] as Plotly.Data[]; + + const expectedData = [ + { + type: 'indicator', + delta: { + valueformat: '01,.2f', + prefix: '', + suffix: '', + }, + number: { + valueformat: '01,.2f', + prefix: '', + suffix: '', + }, + }, + ]; + + replaceValueFormat(data); + + expect(data).toEqual(expectedData); + }); + it('should add delta and number for traces that do not have valueformat set', () => { + const data = [ + { + type: 'indicator', + }, + ] as Plotly.Data[]; + + const expectedData = [ + { + type: 'indicator', + delta: {}, + number: {}, + }, + ]; + + replaceValueFormat(data); + + expect(data).toEqual(expectedData); + }); + + describe('getDataTypeMap', () => { + it('should return the data type map', () => { + const deephavenData = { + mappings: [ + { + table: 0, + data_columns: { + x: ['/plotly/data/0/x'], + y: ['/plotly/data/0/y'], + }, + }, + ], + is_user_set_color: false, + is_user_set_template: false, + } satisfies PlotlyChartDeephavenData; + + const mockTableData = new Map([[0, MOCK_TABLE]]); + + const dataTypeMap = getDataTypeMap(deephavenData, mockTableData); + + const expectedMap = new Map([ + ['0/x', 'int'], + ['0/y', 'double'], + ]); + + expect(dataTypeMap).toEqual(expectedMap); + }); + }); +}); diff --git a/plugins/plotly-express/src/js/src/PlotlyExpressChartUtils.ts b/plugins/plotly-express/src/js/src/PlotlyExpressChartUtils.ts index 009f46e8c..0fb23c10f 100644 --- a/plugins/plotly-express/src/js/src/PlotlyExpressChartUtils.ts +++ b/plugins/plotly-express/src/js/src/PlotlyExpressChartUtils.ts @@ -1,10 +1,14 @@ -import type { - Data, - LayoutAxis, - PlotlyDataLayoutConfig, - PlotType, +import { + type Data, + type Delta, + type LayoutAxis, + type PlotlyDataLayoutConfig, + type PlotNumber, + type PlotType, } from 'plotly.js'; import type { dh as DhType } from '@deephaven/jsapi-types'; +import { ChartUtils } from '@deephaven/chart'; +import { type Formatter } from '@deephaven/jsapi-utils'; /** * Traces that are at least partially powered by WebGL and have no SVG equivalent. @@ -23,6 +27,21 @@ const UNREPLACEABLE_WEBGL_TRACE_TYPES = new Set([ 'densitymapbox', ]); +/* + * A map of trace type to attributes that should be set to a single value instead + * of an array in the Figure object. The attributes should be relative to the trace + * within the plotly/data/ array. + */ +const SINGLE_VALUE_REPLACEMENTS = { + indicator: new Set(['value', 'delta/reference', 'title/text']), +} as Record>; + +/** + * A prefix for the number format to indicate it is in Java format and should be + * transformed to a d3 format + */ +export const FORMAT_PREFIX = 'DEEPHAVEN_JAVA_FORMAT='; + export interface PlotlyChartWidget { getDataAsBase64: () => string; exportedObjects: { fetch: () => Promise }[]; @@ -32,17 +51,19 @@ export interface PlotlyChartWidget { ) => void; } +export interface PlotlyChartDeephavenData { + mappings: Array<{ + table: number; + data_columns: Record; + }>; + is_user_set_template: boolean; + is_user_set_color: boolean; +} + export interface PlotlyChartWidgetData { type: string; figure: { - deephaven: { - mappings: Array<{ - table: number; - data_columns: Record; - }>; - is_user_set_template: boolean; - is_user_set_color: boolean; - }; + deephaven: PlotlyChartDeephavenData; plotly: PlotlyDataLayoutConfig; }; revision: number; @@ -50,6 +71,19 @@ export interface PlotlyChartWidgetData { removed_references: number[]; } +/** Information that is needed to update the default value format in the data + * The index is relative to the plotly/data/ array + * The path within the trace has the valueformat to update + * The typeFrom is a path to a variable that is mapped to a column type + * The options indicate if the prefix and suffix should be set + */ +export interface FormatUpdate { + index: number; + path: string; + typeFrom: string[]; + options: Record; +} + export function getWidgetData( widgetInfo: DhType.Widget ): PlotlyChartWidgetData { @@ -145,7 +179,7 @@ export function getPathParts(path: string): string[] { /** * Checks if a plotly series is a line series without markers * @param data The plotly data to check - * @returns True if the data is a line series without marakers + * @returns True if the data is a line series without markers */ export function isLineSeries(data: Data): boolean { return ( @@ -288,3 +322,234 @@ export function setWebGlTraceType( } }); } + +/** + * Check if the data at the selector should be replaced with a single value instead of an array + * @param data The data to check + * @param selector The selector to check + * @returns True if the data at the selector should be replaced with a single value + */ +export function isSingleValue(data: Data[], selector: string[]): boolean { + const index = parseInt(selector[0], 10); + const type = data[index].type as string; + const path = selector.slice(1).join('/'); + return SINGLE_VALUE_REPLACEMENTS[type]?.has(path) ?? false; +} + +/** + * Set the default value formats for all traces that require it + * @param plotlyData The plotly data to update + * @param defaultValueFormatSet The set of updates to make + * @param dataTypeMap The map of path to column type to pull the correct default format from + * @param formatter The formatter to use to get the default format + */ +export function setDefaultValueFormat( + plotlyData: Data[], + defaultValueFormatSet: Set, + dataTypeMap: Map, + formatter: Formatter | null = null +): void { + defaultValueFormatSet.forEach(({ index, path, typeFrom, options }) => { + const types = typeFrom.map(type => dataTypeMap.get(`${index}/${type}`)); + let columnType = null; + if (types.some(type => type === 'double')) { + // if any of the types are decimal, use decimal since it's the most specific + columnType = 'double'; + } else if (types.some(type => type === 'int')) { + columnType = 'int'; + } + if (columnType == null) { + return; + } + const typeFormatter = formatter?.getColumnTypeFormatter(columnType); + if (typeFormatter == null || !('defaultFormatString' in typeFormatter)) { + return; + } + + const valueFormat = typeFormatter.defaultFormatString as string; + + if (valueFormat == null) { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const trace = (plotlyData[index as number] as Record)[ + path as string + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any; + + convertToPlotlyNumberFormat(trace, valueFormat, options); + }); +} + +/** + * Convert the number format to a d3 number format + * @param data The data to update + * @param valueFormat The number format to convert to a d3 format + * @param options Options of what to update + */ + +export function convertToPlotlyNumberFormat( + data: Partial | Partial, + valueFormat: string, + options: Record = {} +): void { + // by default, everything should be updated dependent on updateFormat + const updateFormat = options?.format ?? true; + const updatePrefix = options?.prefix ?? updateFormat; + const updateSuffix = options?.suffix ?? updateFormat; + + const formatResults = ChartUtils.getPlotlyNumberFormat(null, '', valueFormat); + if ( + updateFormat && + formatResults?.tickformat != null && + formatResults?.tickformat !== '' + ) { + // eslint-disable-next-line no-param-reassign + data.valueformat = formatResults.tickformat; + } + if (updatePrefix) { + // there may be no prefix now, so remove the preexisting one + // eslint-disable-next-line @typescript-eslint/no-explicit-any, no-param-reassign + (data as any).prefix = ''; + + // prefix and suffix might already be set, which should take precedence + if (formatResults?.tickprefix != null && formatResults?.tickprefix !== '') { + // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any + (data as any).prefix = formatResults.tickprefix; + } + } + if (updateSuffix) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, no-param-reassign + (data as any).suffix = ''; + + // prefix and suffix might already be set, which should take precedence + if (formatResults?.ticksuffix != null && formatResults?.ticksuffix !== '') { + // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any + (data as any).suffix = formatResults.ticksuffix; + } + } +} + +/** + * Transform the number format to a d3 number format, which is used by Plotly + * @param numberFormat The number format to transform + * @returns The d3 number format + */ +export function transformValueFormat( + data: Partial | Partial +): Record { + let valueFormat = data?.valueformat; + if (valueFormat == null) { + // if there's no format, note this so that the default format can be used + // prefix and suffix should only be updated if the default format is used and they are not already set + return { + format: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prefix: (data as any)?.prefix == null, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + suffix: (data as any)?.suffix == null, + }; + } + + if (valueFormat.startsWith(FORMAT_PREFIX)) { + valueFormat = valueFormat.substring(FORMAT_PREFIX.length); + } else { + // don't transform if it's not a deephaven format + return { + format: false, + }; + } + + // transform once but don't transform again, so false is returned for format + const options = { + format: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prefix: (data as any)?.prefix == null, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + suffix: (data as any)?.suffix == null, + }; + convertToPlotlyNumberFormat(data, valueFormat, options); + return { + format: false, + }; +} + +/** + * Replace the number formats in the data with a d3 number format + * @param data The data to update + */ +export function replaceValueFormat(plotlyData: Data[]): Set { + const defaultValueFormatSet: Set = new Set(); + + plotlyData.forEach((trace, i) => { + if (trace.type === 'indicator') { + if (trace?.number == null) { + // eslint-disable-next-line no-param-reassign + trace.number = {}; + } + + const numberFormatOptions = transformValueFormat(trace.number); + + if (numberFormatOptions.format) { + defaultValueFormatSet.add({ + index: i, + path: 'number', + typeFrom: ['value', 'delta/reference'], + options: numberFormatOptions, + }); + } + + if (trace?.delta == null) { + // eslint-disable-next-line no-param-reassign + trace.delta = {}; + } + + const deltaFormatOptions = transformValueFormat(trace.delta); + + if (deltaFormatOptions.format) { + defaultValueFormatSet.add({ + index: i, + path: 'delta', + typeFrom: ['value', 'delta/reference'], + options: deltaFormatOptions, + }); + } + } + }); + return defaultValueFormatSet; +} + +/** + * Get the types of variables assocated with columns in the data + * For example, if the path /plotly/data/0/value is associated with a column of type int, + * the map will have the entry '0/value' -> 'int' + * @param deephavenData The deephaven data from the widget to get path and column name from + * @param tableReferenceMap The map of table index to table reference. + * Types are pulled from the table reference + * @returns A map of path to column type + */ +export function getDataTypeMap( + deephavenData: PlotlyChartDeephavenData, + tableReferenceMap: Map +): Map { + const dataTypeMap: Map = new Map(); + + const { mappings } = deephavenData; + + mappings.forEach(({ table: tableIndex, data_columns: dataColumns }) => { + const table = tableReferenceMap.get(tableIndex); + Object.entries(dataColumns).forEach(([columnName, paths]) => { + const column = table?.findColumn(columnName); + if (column == null) { + return; + } + const columnType = column.type; + paths.forEach(path => { + const cleanPath = getPathParts(path).join('/'); + dataTypeMap.set(cleanPath, columnType); + }); + }); + }); + return dataTypeMap; +} diff --git a/plugins/plotly-express/test/deephaven/plot/express/plots/test_indicator.py b/plugins/plotly-express/test/deephaven/plot/express/plots/test_indicator.py new file mode 100644 index 000000000..36a7c077a --- /dev/null +++ b/plugins/plotly-express/test/deephaven/plot/express/plots/test_indicator.py @@ -0,0 +1,587 @@ +import unittest + +from ..BaseTest import BaseTestCase + + +class IndicatorTestCase(BaseTestCase): + def setUp(self) -> None: + from deephaven import new_table + from deephaven.column import int_col, string_col + + self.source = new_table( + [ + int_col("value", [1, 2, 3]), + int_col("reference", [2, 2, 2]), + string_col("text", ["A", "B", "C"]), + string_col("single", ["A", "A", "A"]), + ] + ) + + def test_basic_indicator(self): + import src.deephaven.plot.express as dx + from deephaven.constants import NULL_INT + + chart = dx.indicator(self.source, value="value").to_dict(self.exporter) + + expected_data = [ + { + "delta": { + "decreasing": {"symbol": "▼"}, + "increasing": {"symbol": "▲"}, + }, + "domain": {"x": [0.0, 1.0], "y": [0.0, 1.0]}, + "mode": "number", + "type": "indicator", + "value": NULL_INT, + } + ] + + expected_layout = {"legend": {"tracegroupgap": 0}, "margin": {"t": 60}} + + expected_mappings = [ + {"data_columns": {"value": ["/plotly/data/0/value"]}, "table": 0} + ] + + self.assert_chart_equals( + chart, + expected_data=expected_data, + expected_layout=expected_layout, + expected_mappings=expected_mappings, + expected_is_user_set_template=False, + expected_is_user_set_color=False, + ) + + def test_complex_indicator(self): + import src.deephaven.plot.express as dx + from deephaven.constants import NULL_INT + + chart = dx.indicator( + self.source, + value="value", + reference="reference", + text="text", + number=False, + gauge="angular", + axis=False, + prefix="prefix", + suffix="suffix", + increasing_text="increasing", + decreasing_text="decreasing", + number_format="$#,##0.00", + title="title", + ).to_dict(self.exporter) + + expected_data = [ + { + "delta": { + "decreasing": {"symbol": "decreasing"}, + "increasing": {"symbol": "increasing"}, + "prefix": "prefix", + "reference": NULL_INT, + "suffix": "suffix", + "valueformat": "DEEPHAVEN_JAVA_FORMAT=$#,##0.00", + }, + "domain": {"x": [0.0, 1.0], "y": [0.0, 1.0]}, + "gauge": {"axis": {"visible": False}, "shape": "angular"}, + "mode": "delta+gauge", + "number": { + "prefix": "prefix", + "suffix": "suffix", + "valueformat": "DEEPHAVEN_JAVA_FORMAT=$#,##0.00", + }, + "title": {"text": "None"}, + "type": "indicator", + "value": NULL_INT, + } + ] + + expected_layout = { + "legend": {"tracegroupgap": 0}, + "margin": {"t": 60}, + "title": {"text": "title"}, + } + + expected_mappings = [ + { + "data_columns": { + "reference": ["/plotly/data/0/delta/reference"], + "text": ["/plotly/data/0/title/text"], + "value": ["/plotly/data/0/value"], + }, + "table": 0, + } + ] + + self.assert_chart_equals( + chart, + expected_data=expected_data, + expected_layout=expected_layout, + expected_mappings=expected_mappings, + expected_is_user_set_template=False, + expected_is_user_set_color=False, + ) + + def test_by_indicators(self): + import src.deephaven.plot.express as dx + from deephaven.constants import NULL_INT + + chart = dx.indicator( + self.source, + value="value", + reference="reference", + gauge="angular", + by="text", + by_vars=("increasing_color", "decreasing_color", "gauge_color"), + increasing_color_sequence=["salmon", "green", "chocolate"], + increasing_color_map={"B": "salmon"}, + decreasing_color_sequence=["blue", "purple", "red"], + decreasing_color_map={"B": "bisque"}, + gauge_color_sequence=["pink", "yellow", "orange"], + gauge_color_map={"B": "chartreuse"}, + ).to_dict(self.exporter) + + chart["plotly"]["layout"].pop("template") + + expected_data = [ + { + "delta": { + "decreasing": {"color": "red", "symbol": "▼"}, + "increasing": {"color": "chocolate", "symbol": "▲"}, + "reference": NULL_INT, + }, + "domain": {"x": [0.0, 0.45], "y": [0.0, 0.425]}, + "gauge": { + "axis": {"visible": True}, + "bar": {"color": "orange"}, + "shape": "angular", + }, + "mode": "number+delta+gauge", + "type": "indicator", + "title": {"text": "C"}, + "value": NULL_INT, + }, + { + "delta": { + "decreasing": {"color": "blue", "symbol": "▼"}, + "increasing": {"color": "salmon", "symbol": "▲"}, + "reference": NULL_INT, + }, + "domain": {"x": [0.0, 0.45], "y": [0.575, 1.0]}, + "gauge": { + "axis": {"visible": True}, + "bar": {"color": "pink"}, + "shape": "angular", + }, + "mode": "number+delta+gauge", + "type": "indicator", + "title": {"text": "A"}, + "value": NULL_INT, + }, + { + "delta": { + "decreasing": {"color": "bisque", "symbol": "▼"}, + "increasing": {"color": "salmon", "symbol": "▲"}, + "reference": NULL_INT, + }, + "domain": {"x": [0.55, 1.0], "y": [0.575, 1.0]}, + "gauge": { + "axis": {"visible": True}, + "bar": {"color": "chartreuse"}, + "shape": "angular", + }, + "mode": "number+delta+gauge", + "type": "indicator", + "title": {"text": "B"}, + "value": NULL_INT, + }, + ] + + expected_layout = {"legend": {"tracegroupgap": 0}, "margin": {"t": 60}} + + expected_mappings = [ + { + "data_columns": { + "reference": ["/plotly/data/0/delta/reference"], + "value": ["/plotly/data/0/value"], + }, + "table": 0, + }, + { + "data_columns": { + "reference": ["/plotly/data/1/delta/reference"], + "value": ["/plotly/data/1/value"], + }, + "table": 0, + }, + { + "data_columns": { + "reference": ["/plotly/data/2/delta/reference"], + "value": ["/plotly/data/2/value"], + }, + "table": 0, + }, + ] + + self.assert_chart_equals( + chart, + expected_data=expected_data, + expected_layout=expected_layout, + expected_mappings=expected_mappings, + expected_is_user_set_template=False, + expected_is_user_set_color=False, + ) + + def test_color_indicators(self): + import src.deephaven.plot.express as dx + + chart = dx.indicator( + self.source, + value="value", + increasing_color="text", + decreasing_color="text", + gauge_color="text", + increasing_color_sequence=["salmon", "green", "chocolate"], + increasing_color_map={"B": "salmon"}, + decreasing_color_sequence=["blue", "purple", "red"], + decreasing_color_map={"B": "bisque"}, + gauge_color_sequence=["pink", "yellow", "orange"], + gauge_color_map={"B": "chartreuse"}, + ).to_dict(self.exporter) + + chart_by = dx.indicator( + self.source, + value="value", + by="text", + by_vars=("increasing_color", "decreasing_color", "gauge_color"), + increasing_color_sequence=["salmon", "green", "chocolate"], + increasing_color_map={"B": "salmon"}, + decreasing_color_sequence=["blue", "purple", "red"], + decreasing_color_map={"B": "bisque"}, + gauge_color_sequence=["pink", "yellow", "orange"], + gauge_color_map={"B": "chartreuse"}, + ).to_dict(self.exporter) + + self.assert_chart_equals(chart, chart_by) + + def test_square_indicators(self): + import src.deephaven.plot.express as dx + from deephaven.constants import NULL_INT + + chart = dx.indicator(self.source, value="value", by="text").to_dict( + self.exporter + ) + + expected_data = [ + { + "delta": { + "decreasing": {"symbol": "▼"}, + "increasing": {"symbol": "▲"}, + }, + "domain": {"x": [0.0, 0.45], "y": [0.0, 0.425]}, + "mode": "number", + "type": "indicator", + "title": {"text": "C"}, + "value": NULL_INT, + }, + { + "delta": { + "decreasing": {"symbol": "▼"}, + "increasing": {"symbol": "▲"}, + }, + "domain": {"x": [0.0, 0.45], "y": [0.575, 1.0]}, + "mode": "number", + "type": "indicator", + "title": {"text": "A"}, + "value": NULL_INT, + }, + { + "delta": { + "decreasing": {"symbol": "▼"}, + "increasing": {"symbol": "▲"}, + }, + "domain": {"x": [0.55, 1.0], "y": [0.575, 1.0]}, + "mode": "number", + "type": "indicator", + "title": {"text": "B"}, + "value": NULL_INT, + }, + ] + + expected_layout = {"legend": {"tracegroupgap": 0}, "margin": {"t": 60}} + + expected_mappings = [ + {"data_columns": {"value": ["/plotly/data/0/value"]}, "table": 0}, + {"data_columns": {"value": ["/plotly/data/1/value"]}, "table": 0}, + {"data_columns": {"value": ["/plotly/data/2/value"]}, "table": 0}, + ] + + self.assert_chart_equals( + chart, + expected_data=expected_data, + expected_layout=expected_layout, + expected_mappings=expected_mappings, + expected_is_user_set_template=False, + expected_is_user_set_color=False, + ) + + def test_row_indicators(self): + import src.deephaven.plot.express as dx + from deephaven.constants import NULL_INT + + chart = dx.indicator(self.source, value="value", by="text", rows=1).to_dict( + self.exporter + ) + + expected_data = [ + { + "delta": { + "decreasing": {"symbol": "▼"}, + "increasing": {"symbol": "▲"}, + }, + "domain": {"x": [0.0, 0.28888888888888886], "y": [0.0, 1.0]}, + "mode": "number", + "type": "indicator", + "title": {"text": "A"}, + "value": NULL_INT, + }, + { + "delta": { + "decreasing": {"symbol": "▼"}, + "increasing": {"symbol": "▲"}, + }, + "domain": { + "x": [0.3555555555555555, 0.6444444444444444], + "y": [0.0, 1.0], + }, + "mode": "number", + "type": "indicator", + "title": {"text": "B"}, + "value": NULL_INT, + }, + { + "delta": { + "decreasing": {"symbol": "▼"}, + "increasing": {"symbol": "▲"}, + }, + "domain": { + "x": [0.711111111111111, 0.9999999999999999], + "y": [0.0, 1.0], + }, + "mode": "number", + "type": "indicator", + "title": {"text": "C"}, + "value": NULL_INT, + }, + ] + + expected_layout = {"legend": {"tracegroupgap": 0}, "margin": {"t": 60}} + + expected_mappings = [ + {"data_columns": {"value": ["/plotly/data/0/value"]}, "table": 0}, + {"data_columns": {"value": ["/plotly/data/1/value"]}, "table": 0}, + {"data_columns": {"value": ["/plotly/data/2/value"]}, "table": 0}, + ] + + self.assert_chart_equals( + chart, + expected_data=expected_data, + expected_layout=expected_layout, + expected_mappings=expected_mappings, + expected_is_user_set_template=False, + expected_is_user_set_color=False, + ) + + def test_column_indicators(self): + import src.deephaven.plot.express as dx + from deephaven.constants import NULL_INT + + chart = dx.indicator(self.source, value="value", by="text", cols=1).to_dict( + self.exporter + ) + + expected_data = [ + { + "delta": { + "decreasing": {"symbol": "▼"}, + "increasing": {"symbol": "▲"}, + }, + "domain": {"x": [0.0, 1.0], "y": [0.0, 0.26666666666666666]}, + "mode": "number", + "type": "indicator", + "title": {"text": "C"}, + "value": NULL_INT, + }, + { + "delta": { + "decreasing": {"symbol": "▼"}, + "increasing": {"symbol": "▲"}, + }, + "domain": { + "x": [0.0, 1.0], + "y": [0.36666666666666664, 0.6333333333333333], + }, + "mode": "number", + "type": "indicator", + "title": {"text": "B"}, + "value": NULL_INT, + }, + { + "delta": { + "decreasing": {"symbol": "▼"}, + "increasing": {"symbol": "▲"}, + }, + "domain": { + "x": [0.0, 1.0], + "y": [0.7333333333333333, 0.9999999999999999], + }, + "mode": "number", + "type": "indicator", + "title": {"text": "A"}, + "value": NULL_INT, + }, + ] + + expected_layout = {"legend": {"tracegroupgap": 0}, "margin": {"t": 60}} + + expected_mappings = [ + {"data_columns": {"value": ["/plotly/data/0/value"]}, "table": 0}, + {"data_columns": {"value": ["/plotly/data/1/value"]}, "table": 0}, + {"data_columns": {"value": ["/plotly/data/2/value"]}, "table": 0}, + ] + + self.assert_chart_equals( + chart, + expected_data=expected_data, + expected_layout=expected_layout, + expected_mappings=expected_mappings, + expected_is_user_set_template=False, + expected_is_user_set_color=False, + ) + + def test_title(self): + import src.deephaven.plot.express as dx + + chart = dx.indicator(self.source, value="value", title="title!").to_dict( + self.exporter + ) + + self.assertEqual(chart["plotly"]["data"][0]["title"]["text"], "title!") + self.assertEqual(chart["plotly"]["layout"].get("title"), None) + + chart = dx.indicator(self.source, value="value", by="single").to_dict( + self.exporter + ) + + self.assertEqual(chart["plotly"]["data"][0]["title"]["text"], "A") + self.assertEqual(chart["plotly"]["layout"].get("title"), None) + + chart = dx.indicator(self.source, value="value", by="text").to_dict( + self.exporter + ) + + self.assertEqual(chart["plotly"]["data"][0]["title"]["text"], "C") + self.assertEqual(chart["plotly"]["data"][1]["title"]["text"], "A") + self.assertEqual(chart["plotly"]["data"][2]["title"]["text"], "B") + self.assertEqual(chart["plotly"]["layout"].get("title"), None) + + chart = dx.indicator(self.source, value="value", by=["text", "single"]).to_dict( + self.exporter + ) + + self.assertEqual(chart["plotly"]["data"][0]["title"]["text"], "A, C") + self.assertEqual(chart["plotly"]["data"][1]["title"]["text"], "A, A") + self.assertEqual(chart["plotly"]["data"][2]["title"]["text"], "A, B") + self.assertEqual(chart["plotly"]["layout"].get("title"), None) + + chart = dx.indicator( + self.source, + value="value", + by="single", + title="title!", + ).to_dict(self.exporter) + + self.assertEqual(chart["plotly"]["data"][0]["title"]["text"], "A") + self.assertEqual(chart["plotly"]["layout"]["title"]["text"], "title!") + + chart = dx.indicator( + self.source, value="value", by="text", text="single", title="title!" + ).to_dict(self.exporter) + + # text is filled on the client side + self.assertEqual(chart["plotly"]["data"][0].get("text"), None) + self.assertEqual(chart["plotly"]["data"][1].get("text"), None) + self.assertEqual(chart["plotly"]["data"][2].get("text"), None) + self.assertEqual(chart["plotly"]["layout"]["title"]["text"], "title!") + + expected_mappings = [ + { + "data_columns": { + "single": ["/plotly/data/0/title/text"], + "value": ["/plotly/data/0/value"], + }, + "table": 0, + }, + { + "data_columns": { + "single": ["/plotly/data/1/title/text"], + "value": ["/plotly/data/1/value"], + }, + "table": 0, + }, + { + "data_columns": { + "single": ["/plotly/data/2/title/text"], + "value": ["/plotly/data/2/value"], + }, + "table": 0, + }, + ] + + self.assertEqual(chart["deephaven"]["mappings"], expected_mappings) + + chart = dx.indicator( + self.source, value="value", text="single", title="title!" + ).to_dict(self.exporter) + + # text is filled on the client side + self.assertEqual(chart["plotly"]["data"][0].get("text"), None) + self.assertEqual(chart["plotly"]["layout"]["title"]["text"], "title!") + + expected_mappings = [ + { + "data_columns": { + "single": ["/plotly/data/0/title/text"], + "value": ["/plotly/data/0/value"], + }, + "table": 0, + } + ] + + self.assertEqual(chart["deephaven"]["mappings"], expected_mappings) + + chart = dx.indicator( + self.source, value="value", by="single", text=False + ).to_dict(self.exporter) + + self.assertEqual(chart["plotly"]["data"][0].get("title"), None) + self.assertEqual(chart["plotly"]["layout"].get("title"), None) + + chart = dx.indicator( + self.source, value="value", by="single", text=False, title="title!" + ).to_dict(self.exporter) + + self.assertEqual(chart["plotly"]["data"][0]["title"]["text"], "title!") + self.assertEqual(chart["plotly"]["layout"].get("title"), None) + + chart = dx.indicator( + self.source, value="value", by="text", text=False, title="title!" + ).to_dict(self.exporter) + + self.assertEqual(chart["plotly"]["data"][0].get("title"), None) + self.assertEqual(chart["plotly"]["data"][1].get("title"), None) + self.assertEqual(chart["plotly"]["data"][2].get("title"), None) + self.assertEqual(chart["plotly"]["layout"]["title"]["text"], "title!") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/app.d/express.py b/tests/app.d/express.py index 6de047f07..dfe0de107 100644 --- a/tests/app.d/express.py +++ b/tests/app.d/express.py @@ -1,4 +1,4 @@ -from deephaven.column import int_col, string_col +from deephaven.column import int_col, string_col, double_col from deephaven import new_table import deephaven.plot.express as dx import plotly.express as px @@ -7,6 +7,8 @@ [ string_col("Categories", ["A", "B", "C"]), int_col("Values", [1, 3, 5]), + double_col("Price", [1.0, 3.0, 5.0]), + double_col("Reference", [3.0, 3.0, 3.0]), ] ) express_fig = dx.bar(table=express_source, x="Categories", y="Values") @@ -23,3 +25,13 @@ ] ) express_hist_by = dx.histogram(hist_source, x="Values", by="Categories", nbins=4) + +express_indicator = dx.indicator(express_source, value="Values", title="Indicator") + +express_by_indicator = dx.indicator( + express_source, + value="Price", + reference="Reference", + title="Indicator", + by="Categories", +) diff --git a/tests/express.spec.ts b/tests/express.spec.ts index a81447899..e51f1415a 100644 --- a/tests/express.spec.ts +++ b/tests/express.spec.ts @@ -17,4 +17,16 @@ test('Histogram loads', async ({ page }) => { await gotoPage(page, ''); await openPanel(page, 'express_hist_by', '.js-plotly-plot'); await expect(page.locator('.iris-chart-panel')).toHaveScreenshot(); -}); \ No newline at end of file +}); + +test('Indicator loads', async ({ page }) => { + await gotoPage(page, ''); + await openPanel(page, 'express_indicator', '.js-plotly-plot'); + await expect(page.locator('.iris-chart-panel')).toHaveScreenshot(); +}); + +test('Indicator grid loads', async ({ page }) => { + await gotoPage(page, ''); + await openPanel(page, 'express_indicator_by', '.js-plotly-plot'); + await expect(page.locator('.iris-chart-panel')).toHaveScreenshot(); +});