Repository: marimo-team/marimo
Pull request: improvement: support plotly config and renderers
Fixes #846 

Adds support for plotly config and using their `pio.renderers` feature.

```python
@app.cell
def __(fig, mo):
    fig  # Uses the default renderer
    return

@app.cell
def __(fig, mo):
    mo.ui.plotly(fig)  # Uses the default renderer
    return


@app.cell
def __(fig, mo):
    mo.ui.plotly(fig, config={})  # Uses the empty config
    return


@app.cell
def __(fig, mo):
    mo.ui.plotly(fig, config={"staticPlot": True})  # Uses the passed config
    return


@app.cell
def __(fig, mo):
    mo.ui.plotly(fig, renderer_name="browser")  # Uses a pre-defined rendererer
    return
```

Problem statement:
Plotly renderer config support
### Describe the bug

I want to set plotly defaults. In Jupyter I normally use scrollZoom and editable plots by default.

But in Marimo it seems that plotly plots neither have scrollZoom nor are they editable. Are either possible? Do I need to choose a different 'renderer'? Wrapping in a mo.ui.plotly doesn't change the behavior.

### Environment

{
  "marimo": "0.2.5",
  "OS": "Linux",
  "OS Version": "6.2.0-1019-gcp",
  "Processor": "x86_64",
  "Python Version": "3.10.12",
  "Binaries": {
    "Chrome": "--",
    "Node": "--"
  },
  "Requirements": {
    "black": "21.7b0",
    "click": "8.0.2",
    "jedi": "0.19.0",
    "pymdown-extensions": "9.11",
    "starlette": "0.37.1",
    "tomlkit": "0.12.1",
    "typing_extensions": "4.5.0",
    "uvicorn": "0.27.1"
  }
}

### Code to reproduce

```
import marimo as mo
import numpy as np
import plotly.express as px
import plotly.io as pio

pio.renderers.default = "notebook"
pio.renderers["notebook"].config["scrollZoom"] = True
pio.renderers["notebook"].config["editable"] = True
fig = px.scatter(np.random.rand(5))
# mo.ui.plotly(fig)  # this didn't help
fig
```

Hints from issue discussion:
@jamieray , thanks for opening this issue. We haven't really looked into default Plotly rendering options. How important is it to you that we support this? 
I'll still have some use for Marimo without `editable`, but I use `scrollZoom` on almost every plot because I don't know what axis limits will be useful a priori. So I would end up using Jupyter for much of my plotting if it's not available in marimo. I can look into other plotting options but thought I would check if I was doing something wrong here.
> So I would end up using Jupyter for much of my plotting if it's not available in marimo. I can look into other plotting options but thought I would check if I was doing something wrong here.

Thanks for letting us know. In that case, we can look into supporting this
Not sure if it's related but plotly usually default to the tool being _zoom_ (drag a rectangle to zoom) but in Marimo it is defaulting (at least for me) to _select_. (drag a rectangle and select some points, useful for a callback or getting the data, less helpful for data exploration.)
@jamieray , @astrowonk  This will be fixed in the next release (https://github.com/marimo-team/marimo/pull/1170)

You can pass config or a renderer name to the plugin. We still have the default tool being "selection", just we want people to use the marimo reactivity, but happy to change it if people feel strongly.

Commit message: fixes

Patch to review:
diff --git a/frontend/src/plugins/impl/plotly/PlotlyPlugin.tsx b/frontend/src/plugins/impl/plotly/PlotlyPlugin.tsx
index 9f6c1cbe262..592378ed0bf 100644
--- a/frontend/src/plugins/impl/plotly/PlotlyPlugin.tsx
+++ b/frontend/src/plugins/impl/plotly/PlotlyPlugin.tsx
@@ -13,9 +13,11 @@ import useEvent from "react-use-event-hook";
 import { PlotlyTemplateParser, createParser } from "./parse-from-template";
 import { Objects } from "@/utils/objects";
 import { set } from "lodash-es";
+import { useDeepCompareMemoize } from "@/hooks/useDeepCompareMemoize";
 
 interface Data {
   figure: Figure;
+  config: Partial<Plotly.Config>;
 }
 
 type AxisName = string;
@@ -46,6 +48,7 @@ export class PlotlyPlugin implements IPlugin<T, Data> {
       .object({})
       .passthrough()
       .transform((spec) => spec as unknown as Figure),
+    config: z.object({}).passthrough(),
   });
 
   render(props: IPluginProps<T, Data>): JSX.Element {
@@ -81,7 +84,7 @@ function initialLayout(figure: Figure): Partial<Plotly.Layout> {
 }
 
 export const PlotlyComponent = memo(
-  ({ figure, value, setValue }: PlotlyPluginProps) => {
+  ({ figure, value, setValue, config }: PlotlyPluginProps) => {
     const [layout, setLayout] = useState<Partial<Plotly.Layout>>(() => {
       return {
         ...initialLayout(figure),
@@ -98,7 +101,7 @@ export const PlotlyComponent = memo(
       setNonce((prev) => prev + 1);
     });
 
-    const config = useMemo((): Partial<Plotly.Config> => {
+    const plotlyConfig = useMemo((): Partial<Plotly.Config> => {
       return {
         displaylogo: false,
         modeBarButtonsToAdd: [
@@ -117,8 +120,11 @@ export const PlotlyComponent = memo(
             click: handleReset,
           },
         ],
+        // Prioritize user's config
+        ...config,
       };
-    }, [handleReset]);
+      // eslint-disable-next-line react-hooks/exhaustive-deps
+    }, [handleReset, useDeepCompareMemoize(config)]);
 
     useEffect(() => {
       // Update layout when figure.layout changes
@@ -158,7 +164,7 @@ export const PlotlyComponent = memo(
             setValue((prev) => ({ ...prev, ...obj }));
           }
         }}
-        config={config}
+        config={plotlyConfig}
         onSelected={useEvent((evt: Readonly<Plotly.PlotSelectionEvent>) => {
           if (!evt) {
             return;
diff --git a/marimo/_output/formatters/plotly_formatters.py b/marimo/_output/formatters/plotly_formatters.py
index fe012d58ccc..f346b097fbe 100644
--- a/marimo/_output/formatters/plotly_formatters.py
+++ b/marimo/_output/formatters/plotly_formatters.py
@@ -17,7 +17,7 @@ def package_name() -> str:
 
     def register(self) -> None:
         import plotly.graph_objects  # type: ignore[import-not-found,import-untyped,unused-ignore] # noqa: E501
-        import plotly.io  # type: ignore[import-not-found,import-untyped,unused-ignore] # noqa: E501
+        import plotly.io as pio  # type: ignore[import-not-found,import-untyped,unused-ignore] # noqa: E501
 
         from marimo._output import formatting
 
@@ -25,15 +25,24 @@ def register(self) -> None:
         def _show_plotly_figure(
             fig: plotly.graph_objects.Figure,
         ) -> tuple[KnownMimeType, str]:
-            json_str: str = plotly.io.to_json(fig)
+            json_str: str = pio.to_json(fig)
             plugin = PlotlyFormatter.render_plotly_dict(json.loads(json_str))
             return ("text/html", plugin.text)
 
     @staticmethod
     def render_plotly_dict(json: dict[Any, Any]) -> Html:
+        import plotly.io as pio  # type: ignore[import-not-found,import-untyped,unused-ignore] # noqa: E501
+
+        resolved_config: dict[str, Any] = {}
+        try:
+            default_renderer: Any = pio.renderers[pio.renderers.default]
+            resolved_config = default_renderer.config or {}
+        except AttributeError:
+            pass
+
         return Html(
             build_stateless_plugin(
                 component_name="marimo-plotly",
-                args={"figure": json},
+                args={"figure": json, "config": resolved_config},
             )
         )
diff --git a/marimo/_plugins/ui/_impl/plotly.py b/marimo/_plugins/ui/_impl/plotly.py
index e2c944437a2..0ea5eeffed2 100644
--- a/marimo/_plugins/ui/_impl/plotly.py
+++ b/marimo/_plugins/ui/_impl/plotly.py
@@ -10,6 +10,7 @@
     Final,
     List,
     Optional,
+    cast,
 )
 
 from marimo import _loggers
@@ -64,6 +65,15 @@ class plotly(UIElement[PlotlySelection, List[Dict[str, Any]]]):
     mo.hstack([plot, plot.value])
     ```
 
+    Or with custom configuration:
+
+    ```python
+    plot = mo.ui.plotly(
+        _plot,
+        config={"staticPlot": True},
+    )
+    ```
+
     **Attributes.**
 
     - `value`: a dict of the plot data
@@ -73,6 +83,14 @@ class plotly(UIElement[PlotlySelection, List[Dict[str, Any]]]):
     **Initialization Args.**
 
     - `figure`: A `plotly.graph_objects.Figure`
+    - `config`: optional configuration for the plot
+        This is a dictionary that is passed directly to the plotly.
+        See the plotly documentation for more information:
+        https://plotly.com/javascript/configuration-options/
+        This takes precedence over the default configuration of the renderer.
+    - `renderer_name`: optional renderer to use for the plot.
+        If this is not provided, the default renderer (pio.renderers.default)
+        is used.
     - `label`: optional text label for the element
     - `on_change`: optional callback to run when this element's value changes
     """
@@ -82,6 +100,8 @@ class plotly(UIElement[PlotlySelection, List[Dict[str, Any]]]):
     def __init__(
         self,
         figure: go.Figure,
+        config: Optional[Dict[str, Any]] = None,
+        renderer_name: Optional[str] = None,
         *,
         label: str = "",
         on_change: Optional[Callable[[JSONType], None]] = None,
@@ -92,12 +112,30 @@ def __init__(
 
         json_str = pio.to_json(figure)
 
+        resolved_config: Dict[str, Any] = {}
+        if config is not None:
+            resolved_config = config
+        else:
+            resolved_name: str = renderer_name or cast(
+                str, pio.renderers.default
+            )
+            default_renderer: Any = pio.renderers[resolved_name]
+            if default_renderer is not None:
+                try:
+                    resolved_config = default_renderer.config or {}
+                except AttributeError:
+                    LOGGER.warning(
+                        "Could not find default renderer configuration. "
+                        "Using an empty configuration."
+                    )
+
         super().__init__(
             component_name=plotly.name,
             initial_value={},
             label=label,
             args={
                 "figure": json.loads(json_str),
+                "config": resolved_config,
             },
             on_change=on_change,
         )
diff --git a/marimo/_smoke_tests/bugs/1161.py b/marimo/_smoke_tests/bugs/1161.py
index 7dfdf271de8..7ed96bad53c 100644
--- a/marimo/_smoke_tests/bugs/1161.py
+++ b/marimo/_smoke_tests/bugs/1161.py
@@ -1,3 +1,4 @@
+# Copyright 2024 Marimo. All rights reserved.
 import marimo
 
 __generated_with = "0.4.0"
diff --git a/marimo/_smoke_tests/bugs/1165.py b/marimo/_smoke_tests/bugs/1165.py
index 6e915ec1fd1..e4b859fd770 100644
--- a/marimo/_smoke_tests/bugs/1165.py
+++ b/marimo/_smoke_tests/bugs/1165.py
@@ -1,3 +1,4 @@
+# Copyright 2024 Marimo. All rights reserved.
 import marimo
 
 __generated_with = "0.4.0"
diff --git a/marimo/_smoke_tests/bugs/846.py b/marimo/_smoke_tests/bugs/846.py
new file mode 100644
index 00000000000..fbc7b27fbdb
--- /dev/null
+++ b/marimo/_smoke_tests/bugs/846.py
@@ -0,0 +1,106 @@
+# Copyright 2024 Marimo. All rights reserved.
+
+import marimo
+
+__generated_with = "0.4.0"
+app = marimo.App()
+
+
+@app.cell
+def __(mo):
+    mo.md(
+        "This notebook covers a couple different cases for rendering plotly under different configs and with diferrent renderers."
+    )
+    return
+
+
+@app.cell
+def __():
+    import marimo as mo
+    import plotly.io as pio
+
+    pio.renderers.default = "notebook"
+    pio.renderers["notebook"].config["scrollZoom"] = True
+    pio.renderers["notebook"].config["editable"] = True
+    return mo, pio
+
+
+@app.cell
+def __(pio):
+    pio.renderers.default
+    return
+
+
+@app.cell
+def __(pio):
+    pio.renderers[pio.renderers.default].config
+    return
+
+
+@app.cell
+def __(mo, pio):
+    keys = list(pio.renderers.keys())
+
+
+    def get_config(renderer):
+        try:
+            return str(renderer.config)
+        except:
+            return "none"
+
+
+    printed_configs = [
+        {"name": key, "config": get_config(pio.renderers[key])} for key in keys
+    ]
+    mo.ui.table(printed_configs)
+    return get_config, keys, printed_configs
+
+
+@app.cell
+def __(pio):
+    pio.renderers
+    return
+
+
+@app.cell
+def __():
+    import plotly.express as px
+
+    x_data = [1, 2, 3, 4, 5, 6]
+    y_data = [1, 2, 3, 2, 3, 4]
+    fig = px.scatter(x=x_data, y=y_data)
+    return fig, px, x_data, y_data
+
+
+@app.cell
+def __(fig):
+    fig  # Uses the default renderer "notebook"
+    return
+
+
+@app.cell
+def __(fig, mo):
+    mo.ui.plotly(fig)  # Uses the default renderer "notebook"
+    return
+
+
+@app.cell
+def __(fig, mo):
+    mo.ui.plotly(fig, config={})  # Uses the empty config
+    return
+
+
+@app.cell
+def __(fig, mo):
+    mo.ui.plotly(fig, config={"staticPlot": True})  # Uses the passed config
+    return
+
+
+@app.cell
+def __(fig, mo):
+    mo.ui.plotly(fig, renderer_name="browser")  # Uses a pre-defined rendererer
+    return
+
+
+if __name__ == "__main__":
+    app.run()


Code review comments:
diff_hunk: @@ -0,0 +1,106 @@
+# Copyright 2024 Marimo. All rights reserved.
+
+import marimo
+
+__generated_with = "0.4.0"
+app = marimo.App()
+
+
+@app.cell
+def __(mo):
+    mo.md(
+        "This notebook covers a couple different cases for rendering plotly under different configs and with diferrent renderers."
path: marimo/_smoke_tests/bugs/846.py
text: @user1:
```suggestion
        "This notebook covers a couple different cases for rendering plotly under different configs and with different renderers."
```