diff --git a/docs/api-reference/var_system.md b/docs/api-reference/var_system.md index 469ee8dca83..eb0348391b7 100644 --- a/docs/api-reference/var_system.md +++ b/docs/api-reference/var_system.md @@ -78,3 +78,39 @@ def factorial(value: NumberVar): Use `js_expression` to pass explicit JavaScript expressions; in the `multiply_array_values` example, we pass in a JavaScript expression that calculates the product of all elements in an array called `a` by using the reduce method to multiply each element with the accumulated result, starting from an initial value of 1. Later, we leverage `rx.cond` in the' factorial' function, we instantiate an array using the `range` function, and pass this array to `multiply_array_values`. + +## Hook Vars + +Some values only exist on the frontend and are exposed through React hooks. +`rx.vars.use_hook_var()` binds the return value of a no-argument hook to a unique variable name and returns it as a `Var`. +The hook call and the import of the hook are automatically included in any component that uses the var, so the value reflects the context of the component it is rendered in. + +```py +chart_width = rx.vars.use_hook_var( + library="recharts@3.8.1", + hook="useChartWidth", + _var_type=int | None, +) +``` + +A component using `chart_width` will import `useChartWidth` from `recharts` and render `const = useChartWidth();` in its body, so `chart_width` can be used like any other `Var[int | None]`. + +A hook var is evaluated once per compiled component, so every element that reads it must render inside the same one. An `rx.el.svg` root, an `@rx.memo` body, and a custom renderer body each compile into a single component and satisfy this. + +For the common case of React's built-in [`useId`](https://react.dev/reference/react/useId), `rx.vars.use_id()` returns a `Var[str]` containing a stable unique id for the rendered component. +This is useful for linking SVG elements to `defs` such as gradients or filters: + +```py +def gradient_rect() -> rx.Component: + gradient_id = rx.vars.use_id() + return rx.el.svg( + rx.el.svg.linear_gradient( + rx.el.svg.stop(offset="0%", stop_color="gold"), + rx.el.svg.stop(offset="100%", stop_color="tomato"), + id=gradient_id, + ), + rx.el.svg.rect(fill=f"url(#{gradient_id})", width=64, height=64), + width=64, + height=64, + ) +``` diff --git a/docs/library/graphing/charts/sankeychart.md b/docs/library/graphing/charts/sankeychart.md new file mode 100644 index 00000000000..0ff1ad9134d --- /dev/null +++ b/docs/library/graphing/charts/sankeychart.md @@ -0,0 +1,221 @@ +--- +components: + - rx.recharts.SankeyChart +title: Sankey Chart +meta_description: "Create Sankey charts in Python with Reflex. Build interactive Recharts Sankey diagrams to visualize weighted flows between stages, categories, or systems." +--- + +# Sankey Chart + +```python exec +import random +from typing import Any + +import reflex as rx +``` + +Sankey charts in Reflex are built on [Recharts](https://recharts.org/), a React charting library, and created in pure Python. A Sankey chart visualizes weighted flows between nodes, making it useful for showing movement through stages, resource allocation, user journeys, and other source-to-target relationships. + +## Simple Example + +An `rx.recharts.sankey_chart()` takes a `data` dictionary with `nodes` and `links`. Links refer to nodes by zero-based index. + +```python demo graphing +sankey_data = { + "nodes": [ + {"name": "Website"}, + {"name": "Landing Page"}, + {"name": "Product Page"}, + {"name": "Checkout"}, + {"name": "Purchase"}, + ], + "links": [ + {"source": 0, "target": 1, "value": 1200}, + {"source": 1, "target": 2, "value": 900}, + {"source": 2, "target": 3, "value": 420}, + {"source": 3, "target": 4, "value": 260}, + ], +} + + +def sankey_simple(): + return rx.recharts.sankey_chart( + rx.recharts.graphing_tooltip(), + data=sankey_data, + node_padding=24, + node_width=12, + link_curvature=0.55, + width="100%", + height=320, + ) +``` + +## Stateful Example + +Chart data can be tied to a State var. This example randomizes the flow values when the button is clicked. + +```python demo exec +class SankeyState(rx.State): + data: dict[str, Any] = { + "nodes": [ + {"name": "Marketing"}, + {"name": "Trial"}, + {"name": "Sales"}, + {"name": "Support"}, + {"name": "Retained"}, + ], + "links": [ + {"source": 0, "target": 1, "value": 600}, + {"source": 1, "target": 2, "value": 320}, + {"source": 2, "target": 4, "value": 210}, + {"source": 1, "target": 3, "value": 180}, + {"source": 3, "target": 4, "value": 130}, + ], + } + + @rx.event + def randomize_flows(self): + for link in self.data["links"]: + link["value"] = random.randint(80, 700) + + +def sankey_stateful(): + return rx.vstack( + rx.recharts.sankey_chart( + rx.recharts.graphing_tooltip(), + data=SankeyState.data, + node={ + "fill": rx.color("accent", 7), + "stroke": rx.color("accent", 10), + "strokeWidth": 2, + }, + link={ + "stroke": rx.color("gray", 7), + "strokeOpacity": 0.35, + }, + node_padding=18, + node_width=14, + width="100%", + height=320, + ), + rx.button("Randomize flows", on_click=SankeyState.randomize_flows), + width="100%", + ) +``` + + +## Full Node / Link Customization + +For complete control over node and link rendering, pass a +`@rx.recharts.sankey_chart.node` or `@rx.recharts.sankey_chart.link` decorated +function that returns an svg-based component. The function receives a +`Var[SankeyNodeProps]` or `Var[SankeyLinkProps]` object with the node or link +data `payload`, as well as the object's position and dimensions. You can use these +properties to construct a custom node or link. + +Because the component renders inside the SVG element of the chart, you can only +use `rx.el.svg` components to construct the custom node or link. + +The example below also uses `rx.recharts.use_chart_width()` to read the +rendered chart width and `rx.vars.use_id()` to generate a unique id that links +each link's gradient definition to the path that references it. + +```python demo graphing +styled_sankey_data = { + "nodes": [ + {"name": "Sources", "type": "source", "fill": rx.color("blue", 8)}, + {"name": "Direct", "type": "channel", "fill": rx.color("green", 8)}, + {"name": "Search", "type": "channel", "fill": rx.color("grass", 8)}, + {"name": "Paid", "type": "channel", "fill": rx.color("amber", 8)}, + {"name": "Revenue", "type": "outcome", "fill": rx.color("purple", 8)}, + ], + "links": [ + {"source": 0, "target": 1, "value": 350}, + {"source": 0, "target": 2, "value": 500}, + {"source": 0, "target": 3, "value": 220}, + {"source": 1, "target": 4, "value": 190}, + {"source": 2, "target": 4, "value": 260}, + {"source": 3, "target": 4, "value": 150}, + ], +} + + +def sankey_custom_render(): + @rx.recharts.sankey_chart.node + def custom_node( + node: rx.Var[rx.recharts.SankeyNodeProps], + ) -> rx.Component: + # Determine if the node is at the right edge of the chart to adjust the label position accordingly. + is_out = node.x + node.width + 6 > rx.recharts.use_chart_width() + return rx.fragment( + rx.el.svg.text( + node.payload.name, + x=rx.cond(is_out, node.x - 6, node.x + node.width + 6).to(int), + y=(node.y + node.height / 2).to(int), + text_anchor=rx.cond(is_out, "end", "start"), + fill=rx.color("gray", 12), + font_size=10, + ), + rx.el.svg.rect( + x=node.x.to(int), + y=node.y.to(int), + width=node.width.to(int), + height=node.height.to(int), + # Accessing custom keys in the payload needs a `dict` cast. + fill=node.payload.to(dict)["fill"], + stroke=rx.color("gray", 12), + stroke_width=1, + ), + ) + + @rx.recharts.sankey_chart.link + def custom_link( + link: rx.Var[rx.recharts.SankeyLinkProps], + ) -> rx.Component: + link_id = rx.vars.use_id() + source = link.payload.source.to(dict) + target = link.payload.target.to(dict) + return rx.fragment( + rx.el.svg.linear_gradient( + rx.el.svg.stop(offset="0%", stop_color=source["fill"]), + rx.el.svg.stop(offset="100%", stop_color=target["fill"]), + id=link_id, + ), + rx.el.svg.path( + d=( + f"M{link.sourceX},{link.sourceY} " + f"C{link.sourceControlX},{link.sourceY} " + f"{link.targetControlX},{link.targetY} " + f"{link.targetX},{link.targetY}" + ), + fill="none", + stroke=f"url(#{link_id})", + stroke_opacity=0.35, + stroke_width=link.linkWidth, + ), + rx.el.svg.text( + link.payload.value, + x=((link.sourceX + link.targetX) / 2).to(int), + y=((link.sourceY + link.targetY) / 2).to(int), + text_anchor="middle", + fill=rx.color("gray", 12), + font_size=10, + ), + ) + + return rx.recharts.sankey_chart( + data=styled_sankey_data, + node=custom_node, + link=custom_link, + width="100%", + height=340, + ) +``` + +## Related Charts + +Explore more chart types you can build with Reflex and Recharts in pure Python: + +- [Treemap](/docs/library/graphing/charts/treemap) +- [Funnel Chart](/docs/library/graphing/charts/funnelchart) +- [Pie Chart](/docs/library/graphing/charts/piechart) diff --git a/docs/wrapping-react/custom-code-and-hooks.md b/docs/wrapping-react/custom-code-and-hooks.md index c35f908d678..b75e000ea69 100644 --- a/docs/wrapping-react/custom-code-and-hooks.md +++ b/docs/wrapping-react/custom-code-and-hooks.md @@ -114,3 +114,22 @@ export function Div_7178f430b7b371af8a12d8265d65ab9b() { ```md alert info # You can mix custom code and hooks in the same component. Hooks can access a variable defined in the custom code, but custom code cannot access a variable defined in a hook. ``` + +## Using a Hook's Return Value + +`add_hooks` inserts hook statements into the component, but the values they define are not directly accessible from Python. When you need the return value of a no-argument hook, use `rx.vars.use_hook_var()`, which binds the hook call to a unique variable name and returns it as a `Var`. The hook statement and its import are automatically included in any component where the var is used, so it composes with regular props and var operations. + +```python +import reflex as rx + + +def use_chart_width() -> rx.Var[int | None]: + """Get the width of the enclosing recharts chart as a var.""" + return rx.vars.use_hook_var( + library="recharts@3.8.1", hook="useChartWidth", _var_type=int | None + ) +``` + +For React's built-in [`useId`](https://react.dev/reference/react/useId), `rx.vars.use_id()` returns a `Var[str]` with a stable unique id for the component being rendered, e.g. for linking SVG elements to gradient or filter definitions. + +A hook var is evaluated once per compiled component, so every element that reads it must render inside the same one. An `rx.el.svg` root, an `@rx.memo` body, and a custom renderer body each compile into a single component and satisfy this. diff --git a/packages/reflex-base/news/6708.feature.md b/packages/reflex-base/news/6708.feature.md new file mode 100644 index 00000000000..21e05d1f2f9 --- /dev/null +++ b/packages/reflex-base/news/6708.feature.md @@ -0,0 +1 @@ +Add `rx.vars.use_hook_var()` to create a `Var` bound to the value of a no-argument React hook imported from a given library, and `rx.vars.use_id()` to get React's stable `useId` value for the rendered component. diff --git a/packages/reflex-base/src/reflex_base/vars/__init__.py b/packages/reflex-base/src/reflex_base/vars/__init__.py index c986cf1fd36..2c78f8964df 100644 --- a/packages/reflex-base/src/reflex_base/vars/__init__.py +++ b/packages/reflex-base/src/reflex_base/vars/__init__.py @@ -28,6 +28,7 @@ LiteralStringVar, StringVar, ) +from .special import use_hook_var, use_id __all__ = [ "EMPTY_VAR_INT", @@ -66,6 +67,8 @@ "number", "object", "sequence", + "use_hook_var", + "use_id", "var_operation", "var_operation_return", ] diff --git a/packages/reflex-base/src/reflex_base/vars/special.py b/packages/reflex-base/src/reflex_base/vars/special.py new file mode 100644 index 00000000000..62caf5e389f --- /dev/null +++ b/packages/reflex-base/src/reflex_base/vars/special.py @@ -0,0 +1,64 @@ +"""Special Vars for rendering values from the environment.""" + +from types import UnionType +from typing import Any, TypeVar, cast, overload + +from typing_extensions import TypeForm + +from reflex_base.utils.imports import ImportVar +from reflex_base.utils.types import GenericType +from reflex_base.vars.base import Var, VarData, get_unique_variable_name + +HOOK_VAR_TYPE = TypeVar("HOOK_VAR_TYPE") +_REACT_LIBRARY = "react" +_USE_ID_HOOK = "useId" + + +@overload +def use_hook_var(library: str, hook: str) -> Var[Any]: ... + + +@overload +def use_hook_var( + library: str, hook: str, _var_type: TypeForm[HOOK_VAR_TYPE] +) -> Var[HOOK_VAR_TYPE]: ... + + +@overload +def use_hook_var(library: str, hook: str, _var_type: UnionType) -> Var[Any]: ... + + +def use_hook_var(library: str, hook: str, _var_type: Any = Any) -> Var: + """Get a Var representing a React hook's value. + + The hook is called once in each compiled component that reads the var, so + every element sharing one value must render inside the same component, such + as an ``rx.el.svg`` root, an ``@rx.memo`` body, or a custom renderer body. + + Args: + library: The library to import the hook from. + hook: The name of the hook. + _var_type: The type of the Var. + + Returns: + A Var representing the React hook. + """ + var_name = get_unique_variable_name() + hook_alias = f"{hook}_{var_name}" + return Var( + var_name, + _var_type=cast(GenericType, _var_type), + _var_data=VarData( + imports={library: ImportVar(tag=hook, alias=hook_alias)}, + hooks=(f"const {var_name} = {hook_alias}();",), + ), + ).guess_type() + + +def use_id() -> Var[str]: + """Get the stable React useId hook value for a component. + + Returns: + A Var representing the useId hook value. + """ + return use_hook_var(library=_REACT_LIBRARY, hook=_USE_ID_HOOK, _var_type=str) diff --git a/packages/reflex-components-core/news/6708.bugfix.md b/packages/reflex-components-core/news/6708.bugfix.md new file mode 100644 index 00000000000..5fe178bf4ad --- /dev/null +++ b/packages/reflex-components-core/news/6708.bugfix.md @@ -0,0 +1 @@ +`rx.el.svg` and its children now render as one memoized component, so `defs` such as gradients and the elements that reference them by id always share one render scope. diff --git a/packages/reflex-components-core/src/reflex_components_core/el/elements/media.py b/packages/reflex-components-core/src/reflex_components_core/el/elements/media.py index 66b7981ef92..e8e2f964718 100644 --- a/packages/reflex-components-core/src/reflex_components_core/el/elements/media.py +++ b/packages/reflex-components-core/src/reflex_components_core/el/elements/media.py @@ -4,6 +4,7 @@ from reflex_base.components.component import Component, ComponentNamespace, field from reflex_base.constants.colors import Color +from reflex_base.constants.compiler import MemoizationMode from reflex_base.vars.base import Var from reflex_components_core.el.elements.inline import ReferrerPolicy @@ -274,9 +275,15 @@ class Source(VoidBaseHTML): class Svg(BaseHTML): - """Display the svg element.""" + """Display the svg element. + + The svg root and its descendants render as a single memoized component, + so ``defs`` and the elements that reference them by id share one render + scope and hook vars such as ``rx.vars.use_id()`` resolve to one value. + """ tag = "svg" + _memoization_mode = MemoizationMode(recursive=False) width: Var[str | int] = field(doc="The width of the svg.") height: Var[str | int] = field(doc="The height of the svg.") xmlns: Var[str] = field(doc="The XML namespace declaration.") diff --git a/packages/reflex-components-recharts/news/6708.feature.md b/packages/reflex-components-recharts/news/6708.feature.md new file mode 100644 index 00000000000..9627636e7b7 --- /dev/null +++ b/packages/reflex-components-recharts/news/6708.feature.md @@ -0,0 +1 @@ +Added a Recharts Sankey chart wrapper (`rx.recharts.sankey_chart`) with support for custom node and link renderers, and `rx.recharts.use_chart_width()` for reading the rendered chart width as a `Var`. diff --git a/packages/reflex-components-recharts/pyproject.toml b/packages/reflex-components-recharts/pyproject.toml index a9b27fd3c80..f7392f8c312 100644 --- a/packages/reflex-components-recharts/pyproject.toml +++ b/packages/reflex-components-recharts/pyproject.toml @@ -7,7 +7,10 @@ readme = "README.md" authors = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] requires-python = ">=3.10" -dependencies = ["reflex-base >= 0.9.0"] +dependencies = [ + "reflex-base >= 0.9.7.post31.dev0", + "reflex-components-core >= 0.9.0", +] [tool.hatch.version] source = "uv-dynamic-versioning" @@ -21,7 +24,13 @@ fallback-version = "0.0.0dev0" artifacts = ["/src/**/*.pyi"] [tool.hatch.build.hooks.reflex-pyi] -dependencies = ["ruff", "reflex-base"] +dependencies = [ + "ruff", + "reflex-base", + "reflex-components-core", + "reflex-components-lucide", + "reflex-components-sonner", +] [build-system] requires = ["hatchling", "uv-dynamic-versioning", "hatch-reflex-pyi"] diff --git a/packages/reflex-components-recharts/src/reflex_components_recharts/__init__.py b/packages/reflex-components-recharts/src/reflex_components_recharts/__init__.py index 2149214affe..d0f5b8d6162 100644 --- a/packages/reflex-components-recharts/src/reflex_components_recharts/__init__.py +++ b/packages/reflex-components-recharts/src/reflex_components_recharts/__init__.py @@ -56,6 +56,15 @@ "ScatterChart", "funnel_chart", "FunnelChart", + "sankey_chart", + "SankeyChart", + "SankeyNode", + "SankeyLink", + "SankeyData", + "SankeyNodePayload", + "SankeyNodeProps", + "SankeyLinkPayload", + "SankeyLinkProps", "treemap", "Treemap", ], @@ -73,6 +82,7 @@ "LabelList", "cell", "Cell", + "use_chart_width", "layer", "Layer", "rectangle", diff --git a/packages/reflex-components-recharts/src/reflex_components_recharts/charts.py b/packages/reflex-components-recharts/src/reflex_components_recharts/charts.py index 6fa8989468e..2cdf4df9d6a 100644 --- a/packages/reflex-components-recharts/src/reflex_components_recharts/charts.py +++ b/packages/reflex-components-recharts/src/reflex_components_recharts/charts.py @@ -2,14 +2,18 @@ from __future__ import annotations -from collections.abc import Sequence -from typing import Any, ClassVar +import inspect +from collections.abc import Callable, Mapping, Sequence +from typing import Any, ClassVar, TypedDict, get_args, get_origin, get_type_hints -from reflex_base.components.component import Component, field +from reflex_base.components.component import Component, ComponentNamespace, field +from reflex_base.components.memo import memo from reflex_base.constants import EventTriggers from reflex_base.constants.colors import Color from reflex_base.event import EventHandler, no_args_event_spec from reflex_base.vars.base import Var +from reflex_base.vars.object import RestProp +from typing_extensions import NotRequired from reflex_components_recharts.general import ResponsiveContainer @@ -534,6 +538,229 @@ class FunnelChart(ChartBase): ] +class SankeyNode(TypedDict): + """A node in a Sankey chart.""" + + name: str + type: NotRequired[str] + fill: NotRequired[str | Color] + stroke: NotRequired[str | Color] + strokeWidth: NotRequired[int | float] + strokeOpacity: NotRequired[int | float] + + +class SankeyLink(TypedDict): + """A weighted link between two Sankey chart nodes.""" + + source: int + target: int + value: int | float + fill: NotRequired[str | Color] + fillOpacity: NotRequired[int | float] + stroke: NotRequired[str | Color] + strokeWidth: NotRequired[int | float] + strokeOpacity: NotRequired[int | float] + + +class SankeyData(TypedDict): + """The source data for a Sankey chart.""" + + nodes: Sequence[SankeyNode] + links: Sequence[SankeyLink] + + +class SankeyNodePayload(TypedDict): + """The payload for a Sankey chart node.""" + + name: str + sourceLinks: list[SankeyLinkPayload] + targetLinks: list[SankeyLinkPayload] + value: int | float + depth: int + x: int | float + dx: int | float + y: int | float + dy: int | float + + +class SankeyNodeProps(TypedDict): + """The props for a custom Sankey chart node.""" + + height: int | float + width: int | float + payload: SankeyNodePayload + index: int + x: int | float + y: int | float + + +class SankeyLinkPayload(TypedDict): + """The payload for a Sankey chart link.""" + + source: SankeyNodePayload + target: SankeyNodePayload + value: int | float + dy: int | float + sy: int | float + ty: int | float + + +class SankeyLinkProps(TypedDict): + """The props for a custom Sankey chart link.""" + + sourceX: int | float + targetX: int | float + sourceY: int | float + targetY: int | float + sourceControlX: int | float + targetControlX: int | float + sourceRelativeY: int | float + targetRelativeY: int | float + linkWidth: int | float + index: int + payload: SankeyLinkPayload + + +def _sankey_renderer( + fn: Callable, + props_type: type, + decorator_name: str, +) -> Callable[..., Component]: + """Create a memoized Sankey renderer with a typed rest-prop parameter. + + Args: + fn: The renderer function to wrap. + props_type: The TypedDict props type expected by the renderer. + decorator_name: The public decorator name used in error messages. + + Returns: + A memoized renderer wrapper. + """ + sig = inspect.signature(fn) + if len(sig.parameters) != 1: + msg = ( + f"@{decorator_name} decorated function must take a single argument " + f"of type {props_type.__name__}, got {sig.parameters}" + ) + raise TypeError(msg) + + first_param = next(iter(sig.parameters.values())) + if first_param.kind not in ( + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + ): + msg = ( + f"@{decorator_name} decorated function parameter must be callable " + f"by keyword, got {first_param.kind.description}" + ) + raise TypeError(msg) + + hints = get_type_hints(fn, include_extras=True) + param_annotation = hints.get(first_param.name, first_param.annotation) + if ( + get_origin(param_annotation) is not Var + or not (args := get_args(param_annotation)) + or args[0] is not props_type + ): + msg = ( + f"@{decorator_name} decorated function must take a single argument " + f"of type {props_type.__name__}, got {sig.parameters}" + ) + raise TypeError(msg) + + def _wrapper(rest: RestProp) -> Component: + return fn(**{first_param.name: rest.to(props_type)}) + + _wrapper.__name__ = fn.__name__ + _wrapper.__module__ = fn.__module__ + return memo(wrapper=None)(_wrapper) + + +def sankey_node( + fn: Callable, +) -> Callable[..., Component]: + """A decorator to create a custom Sankey chart node. + + Args: + fn: A function that takes a SankeyNodeProps and returns a Reflex component. + + Returns: + A function that takes a SankeyNodeProps and returns a Reflex component. + """ + return _sankey_renderer(fn, SankeyNodeProps, "sankey_node") + + +def sankey_link( + fn: Callable, +) -> Callable[..., Component]: + """A decorator to create a custom Sankey chart link. + + Args: + fn: A function that takes a SankeyLinkProps and returns a Reflex component. + + Returns: + A function that takes a SankeyLinkProps and returns a Reflex component. + """ + return _sankey_renderer(fn, SankeyLinkProps, "sankey_link") + + +class SankeyChart(ChartBase): + """A Sankey chart component in Recharts.""" + + tag = "Sankey" + + alias = "RechartsSankeyChart" + + name_key: Var[str] = field(doc='The key of each node name. Default: "name"') + + data_key: Var[str | int] = field(doc='The key of each link value. Default: "value"') + + data: Var[SankeyData | Mapping[str, Any]] = field( + doc="The source data, including nodes and the weighted links between them." + ) + + margin: Var[dict[str, Any]] = field( + doc='The sizes of whitespace around the chart. Default: {"top": 5, "right": 5, "bottom": 5, "left": 5}' + ) + + node: Var[Any] = field( + doc="The configuration object or custom renderer used to draw nodes." + ) + + link: Var[Any] = field( + doc="The configuration object or custom renderer used to draw links." + ) + + sort: Var[bool] = field( + doc="Whether to sort nodes on the y-axis or display them in data order." + ) + + node_padding: Var[int] = field(doc="The padding between nodes.") + + node_width: Var[int] = field(doc="The width of each node.") + + link_curvature: Var[float] = field(doc="The curvature of each link.") + + iterations: Var[int] = field( + doc="The number of layout iterations used to position nodes and links." + ) + + # Valid children components + _valid_children: ClassVar[list[str]] = [ + "Legend", + "GraphingTooltip", + "Defs", + ] + + +class SankeyNamespace(ComponentNamespace): + """A namespace for the Sankey chart components.""" + + node = staticmethod(sankey_node) + link = staticmethod(sankey_link) + __call__ = staticmethod(SankeyChart.create) + + class Treemap(RechartsCharts): """A Treemap chart component in Recharts.""" @@ -616,4 +843,5 @@ def create(cls, *children, **props) -> Component: radial_bar_chart = RadialBarChart.create scatter_chart = ScatterChart.create funnel_chart = FunnelChart.create +sankey_chart = SankeyNamespace() treemap = Treemap.create diff --git a/packages/reflex-components-recharts/src/reflex_components_recharts/general.py b/packages/reflex-components-recharts/src/reflex_components_recharts/general.py index 7fd0d5b30b9..0332c0f4ef5 100644 --- a/packages/reflex-components-recharts/src/reflex_components_recharts/general.py +++ b/packages/reflex-components-recharts/src/reflex_components_recharts/general.py @@ -9,8 +9,10 @@ from reflex_base.constants.colors import Color from reflex_base.event import EventHandler, no_args_event_spec from reflex_base.vars.base import LiteralVar, Var +from reflex_base.vars.special import use_hook_var from .recharts import ( + _RECHARTS_LIBRARY, LiteralAnimationEasing, LiteralIconType, LiteralLayout, @@ -20,6 +22,8 @@ Recharts, ) +_USE_CHART_WIDTH_HOOK = "useChartWidth" + class ResponsiveContainer(Recharts, MemoizationLeaf): """A base class for responsive containers in Recharts.""" @@ -66,6 +70,7 @@ class ResponsiveContainer(Recharts, MemoizationLeaf): "RadialBarChart", "ResponsiveContainer", "ScatterChart", + "SankeyChart", "Treemap", "ComposedChart", "FunnelChart", @@ -299,6 +304,21 @@ class Cell(Recharts): ) +def use_chart_width() -> Var[int | None]: + """Get the chart width as a var. + + Outside of a chart context, this will be None/undefined. + + Returns: + The chart width var. + """ + return use_hook_var( + library=_RECHARTS_LIBRARY, + hook=_USE_CHART_WIDTH_HOOK, + _var_type=int | None, + ) + + class SvgElement(Recharts): """A Recharts component that renders a plain SVG element, which accepts the style prop directly rather than through recharts' wrapperStyle. diff --git a/packages/reflex-components-recharts/src/reflex_components_recharts/recharts.py b/packages/reflex-components-recharts/src/reflex_components_recharts/recharts.py index bebf93a8a07..492800031a5 100644 --- a/packages/reflex-components-recharts/src/reflex_components_recharts/recharts.py +++ b/packages/reflex-components-recharts/src/reflex_components_recharts/recharts.py @@ -4,11 +4,13 @@ from reflex_base.components.component import Component, MemoizationLeaf, NoSSRComponent +_RECHARTS_LIBRARY = "recharts@3.10.1" + class Recharts(Component): """A component that wraps a recharts lib.""" - library = "recharts@3.10.1" + library = _RECHARTS_LIBRARY def _get_style(self) -> dict: return {"wrapperStyle": self.style} @@ -17,7 +19,7 @@ def _get_style(self) -> dict: class RechartsCharts(NoSSRComponent, MemoizationLeaf): """A component that wraps a recharts lib.""" - library = "recharts@3.10.1" + library = _RECHARTS_LIBRARY LiteralAnimationEasing = Literal["ease", "ease-in", "ease-out", "ease-in-out", "linear"] diff --git a/pyi_hashes.json b/pyi_hashes.json index 65f8c8ae669..d736a17b561 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -111,12 +111,12 @@ "packages/reflex-components-react-player/src/reflex_components_react_player/audio.pyi": "39e4144cef066bbff8c27b36a205a54b", "packages/reflex-components-react-player/src/reflex_components_react_player/react_player.pyi": "8aa4c31da45479cab21ed6d597013008", "packages/reflex-components-react-player/src/reflex_components_react_player/video.pyi": "2682dc6e825d25307d8390d01e2bd653", - "packages/reflex-components-recharts/src/reflex_components_recharts/__init__.pyi": "8a8c59c2751ba91455d4b75c00940ae1", + "packages/reflex-components-recharts/src/reflex_components_recharts/__init__.pyi": "3d12b679c0e1d709e7ca4f6937f4b015", "packages/reflex-components-recharts/src/reflex_components_recharts/cartesian.pyi": "3485aaabbfd3ca89908ae19425c7297e", - "packages/reflex-components-recharts/src/reflex_components_recharts/charts.pyi": "280a7cd51298ee676f3d076104133f44", - "packages/reflex-components-recharts/src/reflex_components_recharts/general.pyi": "45fa9c7a3da5614dfe5bd7e7c62aaa92", + "packages/reflex-components-recharts/src/reflex_components_recharts/charts.pyi": "23a5a3670cb1070f733a2d0c65623733", + "packages/reflex-components-recharts/src/reflex_components_recharts/general.pyi": "fe22b3cf69e9ae3d425cc88c3511525b", "packages/reflex-components-recharts/src/reflex_components_recharts/polar.pyi": "99ebcfc07868061bdc3c2010d85a153f", - "packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "4f6c26f8c76543cc41e2b9dc400ece8a", + "packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "f2a9cf6db58d169289fbc76de6813300", "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "f170ac685b6ba5892370166c80684db3", "reflex/__init__.pyi": "a3e1782fab4a9aed55f66cc98af8c217", "reflex/components/__init__.pyi": "9facd05a776d0641432696bbf8e34388", diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index c7b8615423d..48e2c35bde2 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -2620,3 +2620,28 @@ def test_each_memo_wrapper_emits_one_component_module_file() -> None: "for Plain, one for WithProp, and one snapshot wrapper for the " f"LeafComponent boundary. Got: {sorted(ctx.memoize_wrappers)}" ) + + +def test_svg_boundary_shares_hook_var_between_children() -> None: + """Elements under one ``rx.el.svg`` read a hook var from a single hook call.""" + from reflex_base.vars.special import use_id + from reflex_components_core.el.elements.media import LinearGradient, Rect, Svg + + from reflex.compiler.compiler import compile_memo_components + + def page() -> Component: + gradient_id = use_id() + return Svg.create( + LinearGradient.create(id=gradient_id), + Rect.create(fill=f"url(#{gradient_id})"), + ) + + ctx, page_ctx = _compile_single_page(page) + memo_files, _ = compile_memo_components( + memos=tuple(ctx.auto_memo_components.values()) + ) + memo_code = "\n".join(code for _, code in memo_files) + + assert len(ctx.memoize_wrappers) == 1 + assert len(re.findall(r"= useId_\w+\(\);", memo_code)) == 1 + assert not any("useId" in hook for hook in page_ctx.hooks) diff --git a/tests/units/components/graphing/test_recharts.py b/tests/units/components/graphing/test_recharts.py index 3e4268eb891..c6a9875c82f 100644 --- a/tests/units/components/graphing/test_recharts.py +++ b/tests/units/components/graphing/test_recharts.py @@ -1,3 +1,12 @@ +from typing import get_type_hints + +import pytest +import reflex_components_recharts as recharts +from reflex_base.components.component import ( + ComponentNamespace, + evaluate_style_namespaces, +) +from reflex_components_recharts import charts from reflex_components_recharts.charts import ( AreaChart, BarChart, @@ -5,9 +14,18 @@ PieChart, RadarChart, RadialBarChart, + SankeyChart, + SankeyLinkPayload, + SankeyLinkProps, + SankeyNodePayload, + SankeyNodeProps, ScatterChart, + sankey_chart, ) -from reflex_components_recharts.general import ResponsiveContainer +from reflex_components_recharts.general import ResponsiveContainer, use_chart_width +from reflex_components_recharts.recharts import Recharts + +import reflex as rx def test_area_chart(): @@ -50,3 +68,100 @@ def test_scatter_chart(): sc = ScatterChart.create() assert isinstance(sc, ResponsiveContainer) assert isinstance(sc.children[0], ScatterChart) + + +def test_sankey_chart(): + sc = SankeyChart.create() + assert isinstance(sc, ResponsiveContainer) + assert isinstance(sc.children[0], SankeyChart) + assert sc.children[0].render()["name"] == "RechartsSankeyChart" + assert "link_width" not in SankeyChart.get_props() + + +def test_sankey_chart_namespace_can_be_used_as_style_key(): + assert isinstance(sankey_chart, ComponentNamespace) + assert evaluate_style_namespaces({sankey_chart: {"height": "20rem"}}) == { + SankeyChart.create: {"height": "20rem"} + } + + +def test_sankey_chart_accepts_unannotated_state_data(): + class SankeyState(rx.State): + data = { + "nodes": [{"name": "A"}, {"name": "B"}], + "links": [{"source": 0, "target": 1, "value": 1}], + } + + sc = SankeyChart.create(data=SankeyState.data) + assert isinstance(sc, ResponsiveContainer) + + +def test_sankey_link_payload_matches_recharts_runtime_shape(): + link_payload_hints = get_type_hints(SankeyLinkPayload) + assert link_payload_hints["source"] is SankeyNodePayload + assert link_payload_hints["target"] is SankeyNodePayload + assert "dy" in link_payload_hints + assert "width" not in link_payload_hints + assert "index" not in link_payload_hints + + link_props_hints = get_type_hints(SankeyLinkProps) + assert link_props_hints["index"] is int + assert link_props_hints["linkWidth"] == (int | float) + + +def test_sankey_renderer_decorators_accept_deferred_annotations(): + namespace = { + "rx": rx, + "SankeyLinkProps": SankeyLinkProps, + "SankeyNodeProps": SankeyNodeProps, + } + exec( + """ +from __future__ import annotations + + +def custom_node(node: rx.Var[SankeyNodeProps]) -> rx.Component: + return rx.fragment() + + +def custom_link(link: rx.Var[SankeyLinkProps]) -> rx.Component: + return rx.fragment() +""", + namespace, + ) + + assert callable(sankey_chart.node(namespace["custom_node"])) + assert callable(sankey_chart.link(namespace["custom_link"])) + + +def test_sankey_renderer_decorator_rejects_positional_only_parameter(): + def custom_node(node: rx.Var[SankeyNodeProps], /) -> rx.Component: + return rx.fragment() + + with pytest.raises(TypeError, match="keyword"): + sankey_chart.node(custom_node) + + +def test_use_chart_width(): + width = use_chart_width() + assert width._var_type == (int | None) + var_data = width._get_all_var_data() + assert var_data is not None + hook_alias = f"useChartWidth_{width!s}" + assert var_data.hooks == (f"const {width!s} = {hook_alias}();",) + assert dict(var_data.imports)[Recharts.library or ""] == ( + rx.ImportVar(tag="useChartWidth", alias=hook_alias), + ) + + +def test_sankey_typed_dicts_exported_from_package(): + for name in ( + "SankeyNode", + "SankeyLink", + "SankeyData", + "SankeyNodePayload", + "SankeyNodeProps", + "SankeyLinkPayload", + "SankeyLinkProps", + ): + assert getattr(recharts, name) is getattr(charts, name) diff --git a/tests/units/reflex_base/vars/test_special.py b/tests/units/reflex_base/vars/test_special.py new file mode 100644 index 00000000000..11c49c1bd2a --- /dev/null +++ b/tests/units/reflex_base/vars/test_special.py @@ -0,0 +1,86 @@ +"""Tests for reflex_base.vars.special hook-backed vars.""" + +from typing import Any + +from reflex_base.components.component import Component +from reflex_base.utils.imports import ImportVar +from reflex_base.vars.number import NumberVar +from reflex_base.vars.sequence import StringVar +from reflex_base.vars.special import use_hook_var, use_id + + +class HookComponent(Component): + """A minimal component for asserting hook var hoisting.""" + + library = "test-lib" + + tag = "HookComponent" + + +def test_use_hook_var_var_data(): + """use_hook_var carries the hook statement and its import in VarData.""" + v = use_hook_var(library="some-lib", hook="useThing") + assert v._var_type is Any + vd = v._get_all_var_data() + assert vd is not None + hook_alias = f"useThing_{v!s}" + assert vd.hooks == (f"const {v!s} = {hook_alias}();",) + assert vd.imports == (("some-lib", (ImportVar(tag="useThing", alias=hook_alias),)),) + + +def test_use_hook_var_guesses_var_type(): + """The returned Var is downcast to the class matching _var_type.""" + count = use_hook_var(library="some-lib", hook="useCount", _var_type=int) + assert isinstance(count, NumberVar) + assert count._var_type is int + + maybe = use_hook_var(library="some-lib", hook="useMaybe", _var_type=int | None) + assert maybe._var_type == (int | None) + + +def test_use_hook_var_names_are_unique(): + """Each call binds the hook value to a fresh variable name.""" + names = {str(use_hook_var(library="some-lib", hook="useThing")) for _ in range(5)} + assert len(names) == 5 + + +def test_same_named_hook_imports_are_aliased(): + """Same-named hooks from different libraries do not collide in JS imports.""" + first = use_hook_var(library="first-lib", hook="useThing") + second = use_hook_var(library="second-lib", hook="useThing") + first_alias = f"useThing_{first!s}" + second_alias = f"useThing_{second!s}" + first_var_data = first._get_all_var_data() + second_var_data = second._get_all_var_data() + + assert first_alias != second_alias + assert first_var_data is not None + assert second_var_data is not None + assert first_var_data.imports == ( + ("first-lib", (ImportVar(tag="useThing", alias=first_alias),)), + ) + assert second_var_data.imports == ( + ("second-lib", (ImportVar(tag="useThing", alias=second_alias),)), + ) + + +def test_use_id(): + """use_id returns a str Var bound to React's useId hook.""" + v = use_id() + assert isinstance(v, StringVar) + assert v._var_type is str + vd = v._get_all_var_data() + assert vd is not None + hook_alias = f"useId_{v!s}" + assert vd.hooks == (f"const {v!s} = {hook_alias}();",) + assert vd.imports == (("react", (ImportVar(tag="useId", alias=hook_alias),)),) + + +def test_hook_var_hoisted_into_component(): + """A component using a hook var renders the hook and pulls its import.""" + v = use_id() + comp = HookComponent.create(id=v) + hook_alias = f"useId_{v!s}" + assert f"const {v!s} = {hook_alias}();" in comp._get_all_hooks() + assert ImportVar(tag="useId", alias=hook_alias) in comp._get_all_imports()["react"] + assert comp.render()["props"] == [f"id:{v!s}"] diff --git a/uv.lock b/uv.lock index a4ef8668660..441bea554df 100644 --- a/uv.lock +++ b/uv.lock @@ -4223,10 +4223,14 @@ name = "reflex-components-recharts" source = { editable = "packages/reflex-components-recharts" } dependencies = [ { name = "reflex-base" }, + { name = "reflex-components-core" }, ] [package.metadata] -requires-dist = [{ name = "reflex-base", editable = "packages/reflex-base" }] +requires-dist = [ + { name = "reflex-base", editable = "packages/reflex-base" }, + { name = "reflex-components-core", editable = "packages/reflex-components-core" }, +] [[package]] name = "reflex-components-sonner"