diff --git a/src/vuecore/engines/plotly/__init__.py b/src/vuecore/engines/plotly/__init__.py index eb8088e..e6ef41b 100644 --- a/src/vuecore/engines/plotly/__init__.py +++ b/src/vuecore/engines/plotly/__init__.py @@ -7,6 +7,9 @@ from .box import build as build_box from .saver import save +# Import build_utils to ensure it's available +from . import plot_builder # noqa: F401 + # Register the functions with the central dispatcher register_builder( plot_type=PlotType.SCATTER, engine=EngineType.PLOTLY, func=build_scatter diff --git a/src/vuecore/engines/plotly/bar.py b/src/vuecore/engines/plotly/bar.py index 5a7df31..e54b3fe 100644 --- a/src/vuecore/engines/plotly/bar.py +++ b/src/vuecore/engines/plotly/bar.py @@ -6,6 +6,24 @@ from vuecore.schemas.basic.bar import BarConfig from .theming import apply_bar_theme +from .plot_builder import build_plot + +# Define parameters handled by the theme script +THEMING_PARAMS = [ + "opacity", + "barmode", + "log_x", + "log_y", + "range_x", + "range_y", + "title", + "x_title", + "y_title", + "subtitle", + "template", + "width", + "height", +] def build(data: pd.DataFrame, config: BarConfig) -> go.Figure: @@ -31,37 +49,10 @@ def build(data: pd.DataFrame, config: BarConfig) -> go.Figure: go.Figure A `plotly.graph_objects.Figure` object representing the bar plot. """ - # Get all parameters from the config model, including extras - all_config_params = config.model_dump() - - # Define parameters handled by the theme script - theming_params = [ - "opacity", - "barmode", - "log_x", - "log_y", - "range_x", - "range_y", - "title", - "x_title", - "y_title", - "subtitle", - "template", - "width", - "height", - ] - - # Create the dictionary of arguments for px.bar - plot_args = { - k: v - for k, v in all_config_params.items() - if k not in theming_params and v is not None - } - - # Create the base figure using only the arguments relevant to px.bar - fig = px.bar(data, **plot_args) - - # Apply theme and additional styling to the generated figure. - fig = apply_bar_theme(fig, config) - - return fig + return build_plot( + data=data, + config=config, + px_function=px.bar, + theming_function=apply_bar_theme, + theming_params=THEMING_PARAMS, + ) diff --git a/src/vuecore/engines/plotly/box.py b/src/vuecore/engines/plotly/box.py index c7b2ffe..8390213 100644 --- a/src/vuecore/engines/plotly/box.py +++ b/src/vuecore/engines/plotly/box.py @@ -6,6 +6,25 @@ from vuecore.schemas.basic.box import BoxConfig from .theming import apply_box_theme +from .plot_builder import build_plot + +# Define parameters handled by the theme script +THEMING_PARAMS = [ + "boxmode", + "log_x", + "log_y", + "range_x", + "range_y", + "notched", + "points", + "title", + "x_title", + "y_title", + "subtitle", + "template", + "width", + "height", +] def build(data: pd.DataFrame, config: BoxConfig) -> go.Figure: @@ -31,38 +50,10 @@ def build(data: pd.DataFrame, config: BoxConfig) -> go.Figure: go.Figure A `plotly.graph_objects.Figure` object representing the box plot. """ - # Get all parameters from the config model, including extras - all_config_params = config.model_dump() - - # Define parameters handled by the theme script - theming_params = [ - "boxmode", - "log_x", - "log_y", - "range_x", - "range_y", - "notched", - "points", - "title", - "x_title", - "y_title", - "subtitle", - "template", - "width", - "height", - ] - - # Create the dictionary of arguments for px.box - plot_args = { - k: v - for k, v in all_config_params.items() - if k not in theming_params and v is not None - } - - # Create the base figure using only the arguments relevant to px.box - fig = px.box(data, **plot_args) - - # Apply theme and additional styling to the generated figure. - fig = apply_box_theme(fig, config) - - return fig + return build_plot( + data=data, + config=config, + px_function=px.box, + theming_function=apply_box_theme, + theming_params=THEMING_PARAMS, + ) diff --git a/src/vuecore/engines/plotly/line.py b/src/vuecore/engines/plotly/line.py index 029d263..71e1db7 100644 --- a/src/vuecore/engines/plotly/line.py +++ b/src/vuecore/engines/plotly/line.py @@ -6,6 +6,24 @@ from vuecore.schemas.basic.line import LineConfig from .theming import apply_line_theme +from .plot_builder import build_plot + +# Define parameters handled by the theme script +THEMING_PARAMS = [ + "markers", + "log_x", + "log_y", + "range_x", + "range_y", + "line_shape", + "title", + "x_title", + "y_title", + "subtitle", + "template", + "width", + "height", +] def build(data: pd.DataFrame, config: LineConfig) -> go.Figure: @@ -31,37 +49,10 @@ def build(data: pd.DataFrame, config: LineConfig) -> go.Figure: go.Figure A `plotly.graph_objects.Figure` object representing the line plot. """ - # Get all parameters from the config model, including extras - all_config_params = config.model_dump() - - # Define parameters handled by the theme script - theming_params = [ - "markers", - "log_x", - "log_y", - "range_x", - "range_y", - "line_shape", - "title", - "x_title", - "y_title", - "subtitle", - "template", - "width", - "height", - ] - - # Create the dictionary of arguments for px.line - plot_args = { - k: v - for k, v in all_config_params.items() - if k not in theming_params and v is not None - } - - # Create the base figure using only the arguments for px.line - fig = px.line(data, **plot_args) - - # Apply theme and additional styling - fig = apply_line_theme(fig, config) - - return fig + return build_plot( + data=data, + config=config, + px_function=px.line, + theming_function=apply_line_theme, + theming_params=THEMING_PARAMS, + ) diff --git a/src/vuecore/engines/plotly/plot_builder.py b/src/vuecore/engines/plotly/plot_builder.py new file mode 100644 index 0000000..8af074d --- /dev/null +++ b/src/vuecore/engines/plotly/plot_builder.py @@ -0,0 +1,65 @@ +# vuecore/engines/plotly/plot_builder.py +from typing import Any, Optional, List, Callable +import pandas as pd +import plotly.graph_objects as go + + +def build_plot( + data: pd.DataFrame, + config: Any, + px_function: Callable, + theming_function: Callable, + theming_params: List[str], + preprocess: Optional[Callable] = None, +) -> go.Figure: + """ + Base function to build Plotly figures with common patterns. + + The function follows these steps: + 1. Get all parameters from the config model + 2. Create the dictionary of arguments for the plot function + 3. Apply preprocessing + 4. Create the base figure + 5. Apply theme and additional styling + + Parameters + ---------- + data : pd.DataFrame + The DataFrame containing the plot data. + config : Any + The Pydantic model with all plot configurations. + px_function : Callable + The Plotly Express function to use (e.g., px.bar, px.scatter, etc). + theming_function : Callable + The theming function to apply to the figure. + theming_params : List[str] + List of parameter names handled by the theming function. + preprocess : Callable, Optional + Optional preprocessing function for special features. + + Returns + ------- + go.Figure + A styled Plotly figure object. + """ + # Get all parameters from the config model + all_config_params = config.model_dump() + + # Create the dictionary of arguments for the plot function + plot_args = { + k: v + for k, v in all_config_params.items() + if k not in theming_params and v is not None + } + + # Apply preprocessing if provided + if preprocess and callable(preprocess): + data, plot_args = preprocess(data, plot_args, config) + + # Create the base figure + fig = px_function(data, **plot_args) + + # Apply theme and additional styling + fig = theming_function(fig, config) + + return fig diff --git a/src/vuecore/engines/plotly/scatter.py b/src/vuecore/engines/plotly/scatter.py index 9af6bdb..ab94811 100644 --- a/src/vuecore/engines/plotly/scatter.py +++ b/src/vuecore/engines/plotly/scatter.py @@ -4,63 +4,62 @@ from vuecore.schemas.basic.scatter import ScatterConfig from vuecore.utils.statistics import get_density - from .theming import apply_scatter_theme +from .plot_builder import build_plot +# Define parameters handled by the theme script +THEMING_PARAMS = [ + "opacity", + "log_x", + "log_y", + "range_x", + "range_y", + "title", + "subtitle", + "x_title", + "y_title", + "template", + "width", + "height", + "marker_line_width", + "marker_line_color", + "color_by_density", +] -def build(data: pd.DataFrame, config: ScatterConfig) -> go.Figure: + +def scatter_preprocess(data, plot_args, config): """ - Creates a Plotly scatter plot from a DataFrame and a Pydantic configuration. + Preprocess data and arguments for scatter plots with density coloring. - This function acts as a bridge between the abstract plot definition and the - Plotly Express implementation. It translates the validated `ScattereConfig` - into the arguments for `plotly.express.scatter` and also forwards any - additional, unvalidated keyword arguments from plotly. The resulting figure - is then customized with layout and theme settings using `plotly.graph_objects`. - (https://plotly.com/python-api-reference/generated/plotly.express.scatter.html). + This function handles special preprocessing for scatter plots, particularly + for density-based coloring. Parameters ---------- data : pd.DataFrame The DataFrame containing the plot data. + plot_args : dict + Dictionary of arguments to be passed to the Plotly Express scatter function. config : ScatterConfig - The validated Pydantic model object with all plot configurations. + The validated Pydantic model with all scatter plot configurations. Returns ------- - go.Figure - A `plotly.graph_objects.Figure` object representing the scatter plot. - """ - # Get all parameters from the config model, including extras - all_config_params = config.model_dump() - - # Define parameters handled by the theme script - theming_params = [ - "opacity", - "log_x", - "log_y", - "range_x", - "range_y", - "title", - "subtitle", - "x_title", - "y_title", - "template", - "width", - "height", - "marker_line_width", - "marker_line_color", - "color_by_density", - ] + tuple + A tuple containing: + - data : pd.DataFrame + The original DataFrame (unchanged). + - plot_args : dict + The modified plot arguments dictionary with color settings adjusted + based on the configuration. - # Create the dictionary of arguments for px.scatter - plot_args = { - k: v - for k, v in all_config_params.items() - if k not in theming_params and v is not None - } - - # Handle density coloring separately + Notes + ----- + When density coloring is enabled, this function calculates density values + for the data points and uses them for color mapping, removing any discrete + color mapping that might conflict with continuous coloring. + """ + # Handle density coloring if config.color_by_density: # Calculate density and pass it to the 'color' argument density_values = get_density(data[config.x].values, data[config.y].values) @@ -73,10 +72,37 @@ def build(data: pd.DataFrame, config: ScatterConfig) -> go.Figure: # Use standard group-based coloring plot_args["color"] = config.color - # Create the base figure using only the arguments for px.scatter - fig = px.scatter(data, **plot_args) + return data, plot_args + + +def build(data: pd.DataFrame, config: ScatterConfig) -> go.Figure: + """ + Creates a Plotly scatter plot from a DataFrame and a Pydantic configuration. + + This function acts as a bridge between the abstract plot definition and the + Plotly Express implementation. It translates the validated `ScattereConfig` + into the arguments for `plotly.express.scatter` and also forwards any + additional, unvalidated keyword arguments from plotly. The resulting figure + is then customized with layout and theme settings using `plotly.graph_objects`. + (https://plotly.com/python-api-reference/generated/plotly.express.scatter.html). - # Apply theme and additional styling - fig = apply_scatter_theme(fig, config) + Parameters + ---------- + data : pd.DataFrame + The DataFrame containing the plot data. + config : ScatterConfig + The validated Pydantic model object with all plot configurations. - return fig + Returns + ------- + go.Figure + A `plotly.graph_objects.Figure` object representing the scatter plot. + """ + return build_plot( + data=data, + config=config, + px_function=px.scatter, + theming_function=apply_scatter_theme, + theming_params=THEMING_PARAMS, + preprocess=scatter_preprocess, + ) diff --git a/src/vuecore/engines/plotly/theming.py b/src/vuecore/engines/plotly/theming.py index 6243eb2..562f88c 100644 --- a/src/vuecore/engines/plotly/theming.py +++ b/src/vuecore/engines/plotly/theming.py @@ -6,6 +6,88 @@ from vuecore.schemas.basic.box import BoxConfig +def _get_axis_title(config, axis: str) -> str: + """ + Helper function to get axis title from configuration with appropriate fallbacks. + + This function attempts to retrieve an axis title using the following priority: + 1. Explicit axis title if provided in configuration + 2. Label mapping from configuration if available + 3. Title-cased column name as fallback + + Parameters + ---------- + config : Any + The configuration object containing styling and layout information. + axis : str + The axis identifier ('x' or 'y'). + + Returns + ------- + str + The appropriate title for the specified axis. + """ + axis_title_attr = f"{axis}_title" + axis_value_attr = axis + + # Use explicit title if provided + if getattr(config, axis_title_attr): + return getattr(config, axis_title_attr) + + # Use label mapping if available + if config.labels and getattr(config, axis_value_attr): + axis_value = getattr(config, axis_value_attr) + if axis_value in config.labels: + return config.labels[axis_value] + + # Fall back to title-cased column name + if getattr(config, axis_value_attr): + return getattr(config, axis_value_attr).title() + + return "" + + +def _apply_common_layout(fig: go.Figure, config) -> go.Figure: + """ + Applies common layout settings to a Plotly figure. + + This function handles the layout adjustments that are common across + different plot types, such as titles, dimensions, templates, and axis + properties. + + Parameters + ---------- + fig : go.Figure + The Plotly figure object to be styled. + config : Any + The configuration object containing all styling and layout information. + + Returns + ------- + go.Figure + The Plotly figure with common layout settings applied. + """ + x_title = _get_axis_title(config, "x") + y_title = _get_axis_title(config, "y") + + layout_updates = { + "title_text": config.title, + "title_subtitle_text": config.subtitle, + "xaxis_title": x_title, + "yaxis_title": y_title, + "height": config.height, + "width": config.width, + "template": config.template, + "xaxis_type": "log" if config.log_x else None, + "yaxis_type": "log" if config.log_y else None, + "xaxis_range": config.range_x, + "yaxis_range": config.range_y, + } + + fig.update_layout(**{k: v for k, v in layout_updates.items() if v is not None}) + return fig + + def apply_scatter_theme(fig: go.Figure, config: ScatterConfig) -> go.Figure: """ Applies a consistent layout and theme to a Plotly scatter plot. @@ -35,28 +117,9 @@ def apply_scatter_theme(fig: go.Figure, config: ScatterConfig) -> go.Figure: selector=dict(mode="markers"), ) - # Use the labels dictionary to set axis titles, falling back to defaults - x_title = config.x_title or ( - config.labels.get(config.x) if config.labels else None or config.x.title() - ) - y_title = config.y_title or ( - config.labels.get(config.y) if config.labels else None or config.y.title() - ) + # Apply common layout + fig = _apply_common_layout(fig, config) - # Apply layout updates for scatter plot - fig.update_layout( - title_text=config.title, - title_subtitle_text=config.subtitle, - xaxis_title=x_title, - yaxis_title=y_title, - height=config.height, - width=config.width, - template=config.template, - xaxis_type="log" if config.log_x else None, - yaxis_type="log" if config.log_y else None, - xaxis_range=config.range_x, - yaxis_range=config.range_y, - ) return fig @@ -86,28 +149,9 @@ def apply_line_theme(fig: go.Figure, config: LineConfig) -> go.Figure: line_shape=config.line_shape, ) - # Use the labels dictionary to set axis titles, falling back to defaults - x_title = config.x_title or ( - config.labels.get(config.x) if config.labels else None or config.x.title() - ) - y_title = config.y_title or ( - config.labels.get(config.y) if config.labels else None or config.y.title() - ) + # Apply common layout + fig = _apply_common_layout(fig, config) - # Apply layout updates for line plot - fig.update_layout( - title_text=config.title, - title_subtitle_text=config.subtitle, - xaxis_title=x_title, - yaxis_title=y_title, - height=config.height, - width=config.width, - template=config.template, - xaxis_type="log" if config.log_x else None, - yaxis_type="log" if config.log_y else None, - xaxis_range=config.range_x, - yaxis_range=config.range_y, - ) return fig @@ -134,29 +178,9 @@ def apply_bar_theme(fig: go.Figure, config: BarConfig) -> go.Figure: # Apply trace-specific updates for bar plots fig.update_traces(opacity=config.opacity, selector=dict(type="bar")) - # Use the labels dictionary to set axis titles, falling back to defaults - x_title = config.x_title or ( - config.labels.get(config.x) if config.labels else None or config.x.title() - ) - y_title = config.y_title or ( - config.labels.get(config.y) if config.labels else None or config.y.title() - ) + # Apply common layout + fig = _apply_common_layout(fig, config) - # Apply layout updates for bar plot - fig.update_layout( - title_text=config.title, - title_subtitle_text=config.subtitle, - xaxis_title=x_title, - yaxis_title=y_title, - height=config.height, - width=config.width, - template=config.template, - xaxis_type="log" if config.log_x else None, - yaxis_type="log" if config.log_y else None, - xaxis_range=config.range_x, - yaxis_range=config.range_y, - barmode=config.barmode, - ) return fig @@ -185,31 +209,7 @@ def apply_box_theme(fig: go.Figure, config: BoxConfig) -> go.Figure: boxpoints=config.points, notched=config.notched, selector=dict(type="box") ) - # Use the labels dictionary to set axis titles, falling back to defaults - x_title = config.x_title or ( - config.labels.get(config.x) - if config.x and config.labels - else None or (config.x.title() if config.x else None) - ) - y_title = config.y_title or ( - config.labels.get(config.y) - if config.y and config.labels - else None or (config.y.title() if config.y else None) - ) + # Apply common layout + fig = _apply_common_layout(fig, config) - # Apply layout updates for box plot - fig.update_layout( - title_text=config.title, - title_subtitle_text=config.subtitle, - xaxis_title=x_title, - yaxis_title=y_title, - height=config.height, - width=config.width, - template=config.template, - xaxis_type="log" if config.log_x else None, - yaxis_type="log" if config.log_y else None, - xaxis_range=config.range_x, - yaxis_range=config.range_y, - boxmode=config.boxmode, - ) return fig