From cd39149abb978af41f53880d5152b2be9f3a87b6 Mon Sep 17 00:00:00 2001 From: Benjamin Barrera-Altuna Date: Thu, 30 Apr 2026 12:59:51 -0400 Subject: [PATCH] feat(plotly): add per-chart locale support --- docs/library/graphing/other-charts/plotly.md | 20 ++++++ .../src/reflex_components_plotly/plotly.py | 62 ++++++++++++++++++- pyi_hashes.json | 2 +- .../units/components/graphing/test_plotly.py | 32 ++++++++++ 4 files changed, 112 insertions(+), 4 deletions(-) diff --git a/docs/library/graphing/other-charts/plotly.md b/docs/library/graphing/other-charts/plotly.md index 4efe0e2f6c1..80143608571 100644 --- a/docs/library/graphing/other-charts/plotly.md +++ b/docs/library/graphing/other-charts/plotly.md @@ -35,6 +35,26 @@ def line_chart(): ) ``` +## Locale Configuration + +Use `locale` to localize Plotly number/date formatting and modebar labels: + +```python demo exec +df = px.data.gapminder().query("country=='Canada'") +fig = px.line(df, x="year", y="lifeExp", title="Life expectancy in Canada") + + +def localized_line_chart(): + return rx.center( + rx.plotly( + data=fig, + locale="de", + ), + ) +``` + +You can still pass `config`; when both are provided, `locale=` is applied as the final locale value. + ## 3D graphing example Let's create a 3D surface plot of Mount Bruno. This is a slightly more complicated example, but it wraps in Reflex using the same method. In fact, you can wrap any figure using the same approach. diff --git a/packages/reflex-components-plotly/src/reflex_components_plotly/plotly.py b/packages/reflex-components-plotly/src/reflex_components_plotly/plotly.py index 55740458db4..3c14ec32418 100644 --- a/packages/reflex-components-plotly/src/reflex_components_plotly/plotly.py +++ b/packages/reflex-components-plotly/src/reflex_components_plotly/plotly.py @@ -90,6 +90,10 @@ class Plotly(NoSSRComponent): config: Var[dict] = field(doc="The config of the graph.") + locale: Var[str] = field( + doc="The locale code used for Plotly formatting and modebar labels." + ) + use_resize_handler: Var[bool] = field( default=LiteralVar.create(True), doc="If true, the graph will resize when the window is resized.", @@ -175,7 +179,7 @@ class Plotly(NoSSRComponent): doc="Fired when a hovered element is no longer hovered." ) - def add_imports(self) -> dict[str, str]: + def add_imports(self) -> ImportDict: """Add imports for the plotly component. Returns: @@ -183,7 +187,12 @@ def add_imports(self) -> dict[str, str]: """ return { # For merging plotly data/layout/templates. - "mergician@v2.0.2": "mergician" + "mergician@v2.0.2": "mergician", + # For locale dictionaries injected into plot config.locales. + "plotly.js-locales@3.5.0": ImportVar( + tag="plotlyLocales", + is_default=True, + ), } def add_custom_code(self) -> list[str]: @@ -222,6 +231,43 @@ def add_custom_code(self) -> list[str]: }) }) } +""", + """ +const _rxResolvePlotlyLocaleData = (plotlyLocales, locale) => { + if (locale === undefined || locale === null) return null; + const localeString = String(locale).trim(); + if (localeString === "") return null; + + const normalizedLocale = localeString.toLowerCase().replace(/_/g, "-"); + const localesObject = plotlyLocales?.default ?? plotlyLocales; + if (!localesObject || typeof localesObject !== "object") return null; + + return ( + localesObject[normalizedLocale] ?? + localesObject[normalizedLocale.split("-")[0]] ?? + null + ); +} + +const _rxGetPlotlyLocaleConfig = (config, locale, plotlyLocales) => { + const localeData = _rxResolvePlotlyLocaleData(plotlyLocales, locale); + if (!localeData) { + if (locale === undefined || locale === null || String(locale).trim() === "") { + return config; + } + return { ...config, locale: String(locale) }; + } + + const localeName = localeData?.name ?? String(locale); + return { + ...config, + locale: localeName, + locales: { + ...(config?.locales ?? {}), + [localeName]: localeData, + }, + }; +} """, ] @@ -251,7 +297,7 @@ def create(cls, *children, **props) -> Component: def _exclude_props(self) -> set[str]: # These props are handled specially in the _render function - return {"data", "layout", "template"} + return {"data", "layout", "template", "locale"} def _render(self): tag = super()._render() @@ -285,6 +331,16 @@ def _render(self): Var(_js_expr=str(figure)), ] ) + if self.locale is not None: + config = self.config if self.config is not None else LiteralVar.create({}) + tag = tag.set( + props={ + **tag.props, + "config": Var( + _js_expr=f"_rxGetPlotlyLocaleConfig({config!s},{self.locale!s},plotlyLocales)" + ), + }, + ) return tag diff --git a/pyi_hashes.json b/pyi_hashes.json index b0bf954a0a2..91a03fabd18 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -42,7 +42,7 @@ "packages/reflex-components-lucide/src/reflex_components_lucide/icon.pyi": "e3ec310276f9d091fbb0261e523ca9ed", "packages/reflex-components-markdown/src/reflex_components_markdown/markdown.pyi": "da02f81678d920a68101c08fe64483a5", "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "d6a02e447dfd3c91bba84bcd02722aed", - "packages/reflex-components-plotly/src/reflex_components_plotly/plotly.pyi": "91e956633778c6992f04940c69ff7140", + "packages/reflex-components-plotly/src/reflex_components_plotly/plotly.pyi": "3db32f22ee4339e21796e206e1935de8", "packages/reflex-components-radix/src/reflex_components_radix/__init__.pyi": "19216eb3618f68c8a76e5e43801cf4af", "packages/reflex-components-radix/src/reflex_components_radix/primitives/__init__.pyi": "5404a8da97e8b5129133d7f300e3f642", "packages/reflex-components-radix/src/reflex_components_radix/primitives/accordion.pyi": "e8ef2b44f2afe3e9b8d678d523673882", diff --git a/tests/units/components/graphing/test_plotly.py b/tests/units/components/graphing/test_plotly.py index bef3a2c3218..85dcb2da646 100644 --- a/tests/units/components/graphing/test_plotly.py +++ b/tests/units/components/graphing/test_plotly.py @@ -43,3 +43,35 @@ def test_plotly_config_option(plotly_fig: go.Figure): """ # This tests just confirm that the component can be created with a config option. _ = rx.plotly(data=plotly_fig, config={"showLink": True}) + + +def test_plotly_locale_option_merges_into_config(plotly_fig: go.Figure): + """Test that locale is passed through plot config. + + Args: + plotly_fig: The figure to display. + """ + component = rx.plotly(data=plotly_fig, locale="de") + rendered = component._render() + + config_var = rendered.props.get("config") + assert config_var is not None + assert "locale" not in rendered.props + assert "_rxGetPlotlyLocaleConfig" in str(config_var) + assert "de" in str(config_var) + + +def test_plotly_basic_locale_option_merges_into_config(plotly_fig: go.Figure): + """Test that locale works for dynamic plotly dist variants too. + + Args: + plotly_fig: The figure to display. + """ + component = rx.plotly.basic(data=plotly_fig, locale="fr") + rendered = component._render() + + config_var = rendered.props.get("config") + assert config_var is not None + assert "locale" not in rendered.props + assert "_rxGetPlotlyLocaleConfig" in str(config_var) + assert "fr" in str(config_var)