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/news/6428.feature.md b/packages/reflex-components-plotly/news/6428.feature.md new file mode 100644 index 00000000000..3c6b9f2ba09 --- /dev/null +++ b/packages/reflex-components-plotly/news/6428.feature.md @@ -0,0 +1 @@ +`rx.plotly` (and its dist variants like `rx.plotly.basic`) now accept a `locale` prop to localize Plotly's number/date formatting and modebar labels. The matching locale data from `plotly.js-locales` is resolved and merged into the chart config at render time, so per-chart locales work without any manual setup. 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 559621c715c..7649f24790e 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,16 +179,23 @@ 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: The imports for the plotly component. """ - return { + imports: ImportDict = { # For merging plotly data/layout/templates. - "mergician@v2.0.2": "mergician" + "mergician@v2.0.2": "mergician", } + if self.locale is not None: + # For locale dictionaries injected into plot config.locales. + imports["plotly.js-locales@3.5.0"] = ImportVar( + tag="plotlyLocales", + is_default=True, + ) + return imports def add_custom_code(self) -> list[str]: """Add custom codes for processing the plotly points data. @@ -192,7 +203,7 @@ def add_custom_code(self) -> list[str]: Returns: Custom code snippets for the module level. """ - return [ + codes = [ "const removeUndefined = (obj) => {Object.keys(obj).forEach(key => obj[key] === undefined && delete obj[key]); return obj}", """ const extractPoints = (points) => { @@ -224,6 +235,45 @@ def add_custom_code(self) -> list[str]: } """, ] + if self.locale is not None: + codes.append(""" +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, + }, + }; +} +""") + return codes @classmethod def create(cls, *children, **props) -> Component: @@ -251,7 +301,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 +335,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 @@ -327,7 +387,7 @@ class PlotlyBasic(Plotly): lib_dependencies: list[str] = ["plotly.js-basic-dist-min@3.5.1"] - def add_imports(self) -> ImportDict | list[ImportDict]: + def add_imports(self) -> ImportDict: """Add imports for the plotly basic component. Returns: @@ -353,7 +413,7 @@ class PlotlyCartesian(Plotly): lib_dependencies: list[str] = ["plotly.js-cartesian-dist-min@3.5.1"] - def add_imports(self) -> ImportDict | list[ImportDict]: + def add_imports(self) -> ImportDict: """Add imports for the plotly cartesian component. Returns: @@ -379,7 +439,7 @@ class PlotlyGeo(Plotly): lib_dependencies: list[str] = ["plotly.js-geo-dist-min@3.5.1"] - def add_imports(self) -> ImportDict | list[ImportDict]: + def add_imports(self) -> ImportDict: """Add imports for the plotly geo component. Returns: @@ -405,7 +465,7 @@ class PlotlyGl3d(Plotly): lib_dependencies: list[str] = ["plotly.js-gl3d-dist-min@3.5.1"] - def add_imports(self) -> ImportDict | list[ImportDict]: + def add_imports(self) -> ImportDict: """Add imports for the plotly 3d component. Returns: @@ -431,7 +491,7 @@ class PlotlyGl2d(Plotly): lib_dependencies: list[str] = ["plotly.js-gl2d-dist-min@3.5.1"] - def add_imports(self) -> ImportDict | list[ImportDict]: + def add_imports(self) -> ImportDict: """Add imports for the plotly 2d component. Returns: @@ -457,7 +517,7 @@ class PlotlyMapbox(Plotly): lib_dependencies: list[str] = ["plotly.js-mapbox-dist-min@3.5.1"] - def add_imports(self) -> ImportDict | list[ImportDict]: + def add_imports(self) -> ImportDict: """Add imports for the plotly mapbox component. Returns: @@ -483,7 +543,7 @@ class PlotlyFinance(Plotly): lib_dependencies: list[str] = ["plotly.js-finance-dist-min@3.5.1"] - def add_imports(self) -> ImportDict | list[ImportDict]: + def add_imports(self) -> ImportDict: """Add imports for the plotly finance component. Returns: @@ -509,7 +569,7 @@ class PlotlyStrict(Plotly): lib_dependencies: list[str] = ["plotly.js-strict-dist-min@3.5.1"] - def add_imports(self) -> ImportDict | list[ImportDict]: + def add_imports(self) -> ImportDict: """Add imports for the plotly strict component. Returns: diff --git a/pyi_hashes.json b/pyi_hashes.json index a8cb2ddc3bd..f5b97d72d2b 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -42,7 +42,7 @@ "packages/reflex-components-lucide/src/reflex_components_lucide/icon.pyi": "2e1da186a37e2bb8a1d90e16ee9a63b5", "packages/reflex-components-markdown/src/reflex_components_markdown/markdown.pyi": "79d0a59b1ba12a2f2c4a09fa6b5c776f", "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "eabf233471bc5b94084914f6f35ecd66", - "packages/reflex-components-plotly/src/reflex_components_plotly/plotly.pyi": "6b84ff7659294b20d21b0ef7781531fa", + "packages/reflex-components-plotly/src/reflex_components_plotly/plotly.pyi": "80b36863336e53c050cc61386f1c9271", "packages/reflex-components-radix/src/reflex_components_radix/__init__.pyi": "a77352f60fb6f4135b5d08a6e56efa6d", "packages/reflex-components-radix/src/reflex_components_radix/primitives/__init__.pyi": "bbd4d1a4fa73275a882c33ba485d0165", "packages/reflex-components-radix/src/reflex_components_radix/primitives/accordion.pyi": "2639b56ce9ccb8b404c6dc12eaace19d", diff --git a/tests/integration/tests_playwright/test_plotly.py b/tests/integration/tests_playwright/test_plotly.py new file mode 100644 index 00000000000..aba7eaa9bb2 --- /dev/null +++ b/tests/integration/tests_playwright/test_plotly.py @@ -0,0 +1,175 @@ +"""Integration tests for the plotly graphing component's locale support.""" + +from collections.abc import Generator + +import pytest +from playwright.sync_api import Page, expect + +from reflex.testing import AppHarness + + +def PlotlyLocaleApp(): + """App rendering a plotly figure with no locale, two locales, and a state config.""" + import plotly.graph_objects as go + + import reflex as rx + + figure = go.Figure( + data=[ + go.Scatter( + x=[1, 2, 3, 4], + y=[10, 15, 13, 17], + mode="lines+markers", + name="Trace 1", + ) + ] + ) + + class PlotlyConfigState(rx.State): + # A user-supplied plotly config delivered through state. The locale + # setting must be merged on top of this without discarding its options. + plotly_config: dict = {"modeBarButtonsToRemove": ["lasso2d"]} + + app = rx.App() + + def plot_box(plot_id: str, **plotly_props) -> "rx.Component": + return rx.box( + rx.plotly(data=figure, width="100%", height="100%", **plotly_props), + id=plot_id, + width="600px", + height="300px", + ) + + @app.add_page + def index(): + return rx.vstack( + plot_box("plot_default"), + plot_box("plot_de", locale="de"), + plot_box("plot_fr", locale="fr"), + plot_box("plot_config", config=PlotlyConfigState.plotly_config), + plot_box( + "plot_config_de", + config=PlotlyConfigState.plotly_config, + locale="de", + ), + plot_box( + "plot_config_fr", + config=PlotlyConfigState.plotly_config, + locale="fr", + ), + ) + + +@pytest.fixture(scope="module") +def plotly_locale_app( + app_harness_env: type[AppHarness], tmp_path_factory +) -> Generator[AppHarness, None, None]: + """Start PlotlyLocaleApp at tmp_path via AppHarness. + + Args: + app_harness_env: The AppHarness environment to use for the test. + tmp_path_factory: pytest tmp_path_factory fixture + + Yields: + running AppHarness instance + """ + with app_harness_env.create( + root=tmp_path_factory.mktemp("plotly_locale"), + app_name=f"plotlylocaleapp_{app_harness_env.__name__.lower()}", + app_source=PlotlyLocaleApp, + ) as harness: + assert harness.app_instance is not None, "app is not running" + yield harness + + +# (plot box id, expected "Autoscale" modebar title, expected "Pan" modebar title). +# The default plot has no locale (English); the others request a specific locale, +# and the expected titles come straight from the `plotly.js-locales` dictionaries. +EXPECTED_MODEBAR_TITLES = ( + ("plot_default", "Autoscale", "Pan"), + ("plot_de", "Automatische Skalierung", "Verschieben"), + ("plot_fr", "Échelle automatique", "Translation"), +) + + +def test_plotly_locale_modebar_titles(page: Page, plotly_locale_app: AppHarness): + """Each plot localizes its modebar tooltips according to its `locale` prop. + + Plotly translates modebar button tooltips via the chart config's locale data, + rendering the result into each button's ``data-title`` attribute. The buttons + are located by their stable ``data-attr``/``data-val`` (which do not change + with locale) so the asserted ``data-title`` reflects only the locale. + + Args: + page: Playwright page instance. + plotly_locale_app: Harness for PlotlyLocaleApp. + """ + assert plotly_locale_app.frontend_url is not None + page.goto(plotly_locale_app.frontend_url) + + autoscale_titles: list[str | None] = [] + for plot_id, expected_autoscale, expected_pan in EXPECTED_MODEBAR_TITLES: + box = page.locator(f"#{plot_id}") + # The figure is loaded via a dynamic import, so allow time for first render. + expect(box.locator(".js-plotly-plot")).to_be_visible(timeout=60_000) + + autoscale = box.locator('.modebar-btn[data-attr="zoom"][data-val="auto"]') + pan = box.locator('.modebar-btn[data-attr="dragmode"][data-val="pan"]') + + expect(autoscale).to_have_attribute("data-title", expected_autoscale) + expect(pan).to_have_attribute("data-title", expected_pan) + + autoscale_titles.append(autoscale.get_attribute("data-title")) + + # The default locale and the two requested locales each rendered distinctly. + assert len(set(autoscale_titles)) == 3, ( + f"locales did not produce distinct rendering: {autoscale_titles}" + ) + + +# (plot box id, expected "Autoscale" modebar title) for the plots whose `config` +# is supplied via a state var. The config removes the lasso button; where a locale +# is also set, the tooltips must still be localized, proving locale is merged with +# the given config rather than replacing it. +EXPECTED_CONFIG_MERGE = ( + ("plot_config", "Autoscale"), + ("plot_config_de", "Automatische Skalierung"), + ("plot_config_fr", "Échelle automatique"), +) + + +def test_plotly_locale_merges_with_state_config( + page: Page, plotly_locale_app: AppHarness +): + """A state-driven `config` is preserved when the `locale` setting is merged in. + + Each plot receives its plotly config from a state var that removes the lasso + modebar button. The locale is then merged on top of that config: the lasso + button stays removed (config honored) while the remaining tooltips are + localized (locale honored), confirming the two are merged rather than one + overwriting the other. + + Args: + page: Playwright page instance. + plotly_locale_app: Harness for PlotlyLocaleApp. + """ + assert plotly_locale_app.frontend_url is not None + page.goto(plotly_locale_app.frontend_url) + + for plot_id, expected_autoscale in EXPECTED_CONFIG_MERGE: + box = page.locator(f"#{plot_id}") + # The figure is loaded via a dynamic import, so allow time for first render. + expect(box.locator(".js-plotly-plot")).to_be_visible(timeout=60_000) + + # The state-supplied config removed only the lasso button... + expect( + box.locator('.modebar-btn[data-attr="dragmode"][data-val="lasso"]') + ).to_have_count(0) + # ...while leaving the other modebar buttons (e.g. box select) intact. + expect( + box.locator('.modebar-btn[data-attr="dragmode"][data-val="select"]') + ).to_have_count(1) + # The locale is merged on top of that config and still localizes tooltips. + expect( + box.locator('.modebar-btn[data-attr="zoom"][data-val="auto"]') + ).to_have_attribute("data-title", expected_autoscale) 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)