diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 596c5a358c..b146ef23db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -619,8 +619,7 @@ jobs: patch_toml_versions "$PYPROJECT" "$PYPI_VER" rm -rf dist - uv build --package "$PACKAGE" --wheel - uv build --package "$PACKAGE" --sdist + uv build --package "$PACKAGE" - name: Upload artifacts uses: actions/upload-artifact@v4 diff --git a/sdk/python/Taskfile.yml b/sdk/python/Taskfile.yml index 97984620aa..5ea1bc715a 100644 --- a/sdk/python/Taskfile.yml +++ b/sdk/python/Taskfile.yml @@ -106,7 +106,7 @@ tasks: - kill -9 $(lsof -ti :8550) extensions-pre-commit-install: - desc: "Installs pre-commit hooks of all extensions, assuming they are present in the currently active uv-workspace." + desc: "Installs pre-commit hooks of all extensions." aliases: - pc-extensions - extensions-pre-commit @@ -125,8 +125,3 @@ tasks: - uv run --package flet-rive pre-commit install - uv run --package flet-video pre-commit install - uv run --package flet-webview pre-commit install - -# serve-extensions: -# desc: "Serves all extensions, assuming they are present in the currently active uv-workspace." -# cmds: -# - uv run --active --package flet-audio --directory path/to/flet-audio mkdocs serve diff --git a/sdk/python/examples/controls/charts/bar_chart/example_1.py b/sdk/python/examples/controls/charts/bar_chart/example_1.py index 7fa3eb5802..98029dc971 100644 --- a/sdk/python/examples/controls/charts/bar_chart/example_1.py +++ b/sdk/python/examples/controls/charts/bar_chart/example_1.py @@ -1,6 +1,5 @@ -import flet_charts as fch - import flet as ft +import flet_charts as fch def main(page: ft.Page): diff --git a/sdk/python/examples/controls/charts/candlestick_chart/example_1.py b/sdk/python/examples/controls/charts/candlestick_chart/example_1.py index f44d2e95a3..96ace3edad 100644 --- a/sdk/python/examples/controls/charts/candlestick_chart/example_1.py +++ b/sdk/python/examples/controls/charts/candlestick_chart/example_1.py @@ -1,6 +1,5 @@ -import flet_charts as ftc - import flet as ft +import flet_charts as ftc CANDLE_DATA = [ ("Mon", 24.8, 28.6, 23.9, 27.2), @@ -100,7 +99,6 @@ def handle_event(e: ftc.CandlestickChartEvent): horizontal_alignment=ftc.HorizontalAlignment.CENTER, fit_inside_horizontally=True, ), - handle_built_in_touches=True, on_event=handle_event, ) diff --git a/sdk/python/examples/controls/charts/matplotlib_chart/3d.py b/sdk/python/examples/controls/charts/matplotlib_chart/3d.py new file mode 100644 index 0000000000..638e9692e3 --- /dev/null +++ b/sdk/python/examples/controls/charts/matplotlib_chart/3d.py @@ -0,0 +1,36 @@ +import logging + +import flet_charts +import matplotlib.pyplot as plt +import numpy as np + +import flet as ft + +logging.basicConfig(level=logging.INFO) + + +def main(page: ft.Page): + plt.style.use("_mpl-gallery") + + # Make data for a double helix + n = 50 + theta = np.linspace(0, 2 * np.pi, n) + x1 = np.cos(theta) + y1 = np.sin(theta) + z1 = np.linspace(0, 1, n) + x2 = np.cos(theta + np.pi) + y2 = np.sin(theta + np.pi) + z2 = z1 + + # Plot with defined figure size + fig, ax = plt.subplots(subplot_kw={"projection": "3d"}, figsize=(8, 6)) + ax.fill_between(x1, y1, z1, x2, y2, z2, alpha=0.5) + ax.plot(x1, y1, z1, linewidth=2, color="C0") + ax.plot(x2, y2, z2, linewidth=2, color="C0") + + ax.set(xticklabels=[], yticklabels=[], zticklabels=[]) + + page.add(flet_charts.MatplotlibChartWithToolbar(figure=fig)) + + +ft.run(main) diff --git a/sdk/python/examples/controls/charts/matplotlib_chart/animate.py b/sdk/python/examples/controls/charts/matplotlib_chart/animate.py new file mode 100644 index 0000000000..5f7f7e6c3d --- /dev/null +++ b/sdk/python/examples/controls/charts/matplotlib_chart/animate.py @@ -0,0 +1,56 @@ +import logging + +import flet_charts +import matplotlib.pyplot as plt +import numpy as np + +import flet as ft + +logging.basicConfig(level=logging.INFO) + +state = {} + + +def main(page: ft.Page): + import matplotlib.animation as animation + + # Fixing random state for reproducibility + np.random.seed(19680801) + + def random_walk(num_steps, max_step=0.05): + """Return a 3D random walk as (num_steps, 3) array.""" + start_pos = np.random.random(3) + steps = np.random.uniform(-max_step, max_step, size=(num_steps, 3)) + walk = start_pos + np.cumsum(steps, axis=0) + return walk + + def update_lines(num, walks, lines): + for line, walk in zip(lines, walks): + line.set_data_3d(walk[:num, :].T) + return lines + + # Data: 40 random walks as (num_steps, 3) arrays + num_steps = 30 + walks = [random_walk(num_steps) for index in range(40)] + + # Attaching 3D axis to the figure + fig = plt.figure() + ax = fig.add_subplot(projection="3d") + + # Create lines initially without data + lines = [ax.plot([], [], [])[0] for _ in walks] + + # Setting the Axes properties + ax.set(xlim3d=(0, 1), xlabel="X") + ax.set(ylim3d=(0, 1), ylabel="Y") + ax.set(zlim3d=(0, 1), zlabel="Z") + + # Creating the Animation object + state["anim"] = animation.FuncAnimation( + fig, update_lines, num_steps, fargs=(walks, lines), interval=100 + ) + + page.add(flet_charts.MatplotlibChartWithToolbar(figure=fig, expand=True)) + + +ft.run(main) diff --git a/sdk/python/examples/controls/charts/matplotlib_chart/example_1.py b/sdk/python/examples/controls/charts/matplotlib_chart/bar_chart.py similarity index 96% rename from sdk/python/examples/controls/charts/matplotlib_chart/example_1.py rename to sdk/python/examples/controls/charts/matplotlib_chart/bar_chart.py index 578bca26af..e31b44daee 100644 --- a/sdk/python/examples/controls/charts/matplotlib_chart/example_1.py +++ b/sdk/python/examples/controls/charts/matplotlib_chart/bar_chart.py @@ -4,8 +4,6 @@ import flet as ft -matplotlib.use("svg") - def main(page: ft.Page): fig, ax = plt.subplots() diff --git a/sdk/python/examples/controls/charts/matplotlib_chart/handle_events.py b/sdk/python/examples/controls/charts/matplotlib_chart/handle_events.py new file mode 100644 index 0000000000..758f0b6894 --- /dev/null +++ b/sdk/python/examples/controls/charts/matplotlib_chart/handle_events.py @@ -0,0 +1,103 @@ +import flet_charts +import matplotlib.pyplot as plt +import numpy as np + +import flet as ft + +state = {} + + +def main(page: ft.Page): + # Fixing random state for reproducibility + np.random.seed(19680801) + + X = np.random.rand(100, 200) + xs = np.mean(X, axis=1) + ys = np.std(X, axis=1) + + fig, (ax, ax2) = plt.subplots(2, 1) + ax.set_title("click on point to plot time series") + (line,) = ax.plot(xs, ys, "o", picker=True, pickradius=5) + + class PointBrowser: + """ + Click on a point to select and highlight it -- the data that + generated the point will be shown in the lower Axes. Use the 'n' + and 'p' keys to browse through the next and previous points + """ + + def __init__(self): + self.lastind = 0 + + self.text = ax.text( + 0.05, 0.95, "selected: none", transform=ax.transAxes, va="top" + ) + (self.selected,) = ax.plot( + [xs[0]], [ys[0]], "o", ms=12, alpha=0.4, color="yellow", visible=False + ) + + def on_press(self, event): + if self.lastind is None: + return + if event.key not in ("n", "p"): + return + inc = 1 if event.key == "n" else -1 + + self.lastind += inc + self.lastind = np.clip(self.lastind, 0, len(xs) - 1) + self.update() + + def on_pick(self, event): + if event.artist != line: + return True + + N = len(event.ind) + if not N: + return True + + # the click locations + x = event.mouseevent.xdata + y = event.mouseevent.ydata + + distances = np.hypot(x - xs[event.ind], y - ys[event.ind]) + indmin = distances.argmin() + dataind = event.ind[indmin] + + self.lastind = dataind + self.update() + + def update(self): + if self.lastind is None: + return + + dataind = self.lastind + + ax2.clear() + ax2.plot(X[dataind]) + + ax2.text( + 0.05, + 0.9, + f"mu={xs[dataind]:1.3f}\nsigma={ys[dataind]:1.3f}", + transform=ax2.transAxes, + va="top", + ) + ax2.set_ylim(-0.5, 1.5) + self.selected.set_visible(True) + self.selected.set_data([xs[dataind]], [ys[dataind]]) + + self.text.set_text("selected: %d" % dataind) + fig.canvas.draw() + + browser = PointBrowser() + state["browser"] = browser + + fig.canvas.mpl_connect("pick_event", browser.on_pick) + fig.canvas.mpl_connect("key_press_event", browser.on_press) + + # plt.show() + + page.add(flet_charts.MatplotlibChartWithToolbar(figure=fig, expand=True)) + + +ft.run(main) diff --git a/sdk/python/examples/controls/charts/matplotlib_chart/media/3d.png b/sdk/python/examples/controls/charts/matplotlib_chart/media/3d.png new file mode 100644 index 0000000000..4b650fed36 Binary files /dev/null and b/sdk/python/examples/controls/charts/matplotlib_chart/media/3d.png differ diff --git a/sdk/python/examples/controls/charts/matplotlib_chart/media/animate.png b/sdk/python/examples/controls/charts/matplotlib_chart/media/animate.png new file mode 100644 index 0000000000..05e9c6bf21 Binary files /dev/null and b/sdk/python/examples/controls/charts/matplotlib_chart/media/animate.png differ diff --git a/sdk/python/examples/controls/charts/matplotlib_chart/media/example_1.png b/sdk/python/examples/controls/charts/matplotlib_chart/media/bar_chart.png similarity index 100% rename from sdk/python/examples/controls/charts/matplotlib_chart/media/example_1.png rename to sdk/python/examples/controls/charts/matplotlib_chart/media/bar_chart.png diff --git a/sdk/python/examples/controls/charts/matplotlib_chart/media/contour.png b/sdk/python/examples/controls/charts/matplotlib_chart/media/contour.png deleted file mode 100644 index ba8b25e02f..0000000000 Binary files a/sdk/python/examples/controls/charts/matplotlib_chart/media/contour.png and /dev/null differ diff --git a/sdk/python/examples/controls/charts/matplotlib_chart/media/example_2.png b/sdk/python/examples/controls/charts/matplotlib_chart/media/example_2.png deleted file mode 100644 index 1f0d05890d..0000000000 Binary files a/sdk/python/examples/controls/charts/matplotlib_chart/media/example_2.png and /dev/null differ diff --git a/sdk/python/examples/controls/charts/matplotlib_chart/media/handle_events.png b/sdk/python/examples/controls/charts/matplotlib_chart/media/handle_events.png new file mode 100644 index 0000000000..44c1e604ab Binary files /dev/null and b/sdk/python/examples/controls/charts/matplotlib_chart/media/handle_events.png differ diff --git a/sdk/python/examples/controls/charts/matplotlib_chart/media/scatter.png b/sdk/python/examples/controls/charts/matplotlib_chart/media/scatter.png deleted file mode 100644 index 97aa751a2a..0000000000 Binary files a/sdk/python/examples/controls/charts/matplotlib_chart/media/scatter.png and /dev/null differ diff --git a/sdk/python/examples/controls/charts/matplotlib_chart/media/toolbar.png b/sdk/python/examples/controls/charts/matplotlib_chart/media/toolbar.png new file mode 100644 index 0000000000..31315f8633 Binary files /dev/null and b/sdk/python/examples/controls/charts/matplotlib_chart/media/toolbar.png differ diff --git a/sdk/python/examples/controls/charts/matplotlib_chart/example_2.py b/sdk/python/examples/controls/charts/matplotlib_chart/toolbar.py similarity index 90% rename from sdk/python/examples/controls/charts/matplotlib_chart/example_2.py rename to sdk/python/examples/controls/charts/matplotlib_chart/toolbar.py index 6418bf402d..2c4a7c1cdf 100644 --- a/sdk/python/examples/controls/charts/matplotlib_chart/example_2.py +++ b/sdk/python/examples/controls/charts/matplotlib_chart/toolbar.py @@ -5,8 +5,6 @@ import flet as ft -matplotlib.use("svg") - def main(page: ft.Page): # Fixing random state for reproducibility @@ -33,7 +31,7 @@ def main(page: ft.Page): fig.tight_layout() - page.add(fch.MatplotlibChart(figure=fig, expand=True)) + page.add(fch.MatplotlibChartWithToolbar(figure=fig, expand=True)) ft.run(main) diff --git a/sdk/python/examples/controls/charts/radar_chart/example_1.py b/sdk/python/examples/controls/charts/radar_chart/example_1.py new file mode 100644 index 0000000000..bbd137dea4 --- /dev/null +++ b/sdk/python/examples/controls/charts/radar_chart/example_1.py @@ -0,0 +1,61 @@ +import flet as ft +import flet_charts as fch + + +def main(page: ft.Page): + page.title = "Radar chart" + page.padding = 20 + page.vertical_alignment = page.horizontal_alignment = "center" + page.theme_mode = ft.ThemeMode.LIGHT + + categories = ["macOS", "Linux", "Windows"] + + page.add( + fch.RadarChart( + expand=True, + titles=[fch.RadarChartTitle(text=label) for label in categories], + center_min_value=True, + tick_count=4, + ticks_text_style=ft.TextStyle(size=20, color=ft.Colors.ON_SURFACE), + title_text_style=ft.TextStyle( + size=24, weight=ft.FontWeight.BOLD, color=ft.Colors.ON_SURFACE + ), + on_event=lambda e: print(e.type), + data_sets=[ + fch.RadarDataSet( + fill_color=ft.Colors.with_opacity(0.2, ft.Colors.DEEP_PURPLE), + border_color=ft.Colors.DEEP_PURPLE, + entry_radius=4, + entries=[ + fch.RadarDataSetEntry(300), + fch.RadarDataSetEntry(50), + fch.RadarDataSetEntry(250), + ], + ), + fch.RadarDataSet( + fill_color=ft.Colors.with_opacity(0.15, ft.Colors.PINK), + border_color=ft.Colors.PINK, + entry_radius=4, + entries=[ + fch.RadarDataSetEntry(250), + fch.RadarDataSetEntry(100), + fch.RadarDataSetEntry(200), + ], + ), + fch.RadarDataSet( + fill_color=ft.Colors.with_opacity(0.12, ft.Colors.CYAN), + border_color=ft.Colors.CYAN, + entry_radius=4, + entries=[ + fch.RadarDataSetEntry(200), + fch.RadarDataSetEntry(150), + fch.RadarDataSetEntry(50), + ], + ), + ], + ) + ) + + +if __name__ == "__main__": + ft.run(main) diff --git a/sdk/python/examples/controls/charts/radar_chart/media/example_1.png b/sdk/python/examples/controls/charts/radar_chart/media/example_1.png new file mode 100644 index 0000000000..aaebaa547c Binary files /dev/null and b/sdk/python/examples/controls/charts/radar_chart/media/example_1.png differ diff --git a/sdk/python/examples/controls/charts/scatter_chart/example_1.py b/sdk/python/examples/controls/charts/scatter_chart/example_1.py index 91ac663159..2e66789b86 100644 --- a/sdk/python/examples/controls/charts/scatter_chart/example_1.py +++ b/sdk/python/examples/controls/charts/scatter_chart/example_1.py @@ -1,8 +1,7 @@ import random -import flet_charts as ftc - import flet as ft +import flet_charts as ftc class MySpot(ftc.ScatterChartSpot): @@ -20,6 +19,7 @@ def __init__( radius=radius, color=color, show_tooltip=show_tooltip, + selected=y == 43, ) diff --git a/sdk/python/packages/flet-charts/README.md b/sdk/python/packages/flet-charts/README.md index 96709b91eb..332cc1cd94 100644 --- a/sdk/python/packages/flet-charts/README.md +++ b/sdk/python/packages/flet-charts/README.md @@ -41,10 +41,11 @@ For examples, see [these](https://github.com/flet-dev/flet/tree/main/sdk/python/ ### Available charts -- `BarChart` -- `CandlestickChart` -- `LineChart` -- `MatplotlibChart` -- `PieChart` -- `PlotlyChart` -- `ScatterChart` +- [`BarChart`](https://docs.flet.dev/charts/bar_chart/) +- [`CandlestickChart`](https://docs.flet.dev/charts/candlestick_chart/) +- [`LineChart`](https://docs.flet.dev/charts/line_chart/) +- [`MatplotlibChart`](https://docs.flet.dev/charts/matplotlib_chart/) +- [`PieChart`](https://docs.flet.dev/charts/pie_chart/) +- [`PlotlyChart`](https://docs.flet.dev/charts/plotly_chart/) +- [`RadarChart`](https://docs.flet.dev/charts/radar_chart/) +- [`ScatterChart`](https://docs.flet.dev/charts/scatter_chart/) diff --git a/sdk/python/packages/flet-charts/src/flet_charts/__init__.py b/sdk/python/packages/flet-charts/src/flet_charts/__init__.py index 1dc2f4a2f9..b5200fd980 100644 --- a/sdk/python/packages/flet-charts/src/flet_charts/__init__.py +++ b/sdk/python/packages/flet-charts/src/flet_charts/__init__.py @@ -37,6 +37,13 @@ from flet_charts.pie_chart import PieChart, PieChartEvent from flet_charts.pie_chart_section import PieChartSection from flet_charts.plotly_chart import PlotlyChart +from flet_charts.radar_chart import ( + RadarChart, + RadarChartEvent, + RadarChartTitle, + RadarShape, +) +from flet_charts.radar_data_set import RadarDataSet, RadarDataSetEntry from flet_charts.scatter_chart import ( ScatterChart, ScatterChartEvent, @@ -95,6 +102,12 @@ "PieChartEvent", "PieChartSection", "PlotlyChart", + "RadarChart", + "RadarChartEvent", + "RadarChartTitle", + "RadarDataSet", + "RadarDataSetEntry", + "RadarShape", "ScatterChart", "ScatterChartEvent", "ScatterChartSpot", diff --git a/sdk/python/packages/flet-charts/src/flet_charts/bar_chart.py b/sdk/python/packages/flet-charts/src/flet_charts/bar_chart.py index bd7416f84e..3ea8ce14d0 100644 --- a/sdk/python/packages/flet-charts/src/flet_charts/bar_chart.py +++ b/sdk/python/packages/flet-charts/src/flet_charts/bar_chart.py @@ -180,7 +180,7 @@ class BarChart(ft.LayoutControl): group_alignment: ft.MainAxisAlignment = ft.MainAxisAlignment.SPACE_EVENLY """ - A alignment of the bar [`groups`][..] within this chart. + The alignment of the bar [`groups`][..] within this chart. If set to [`MainAxisAlignment.CENTER`][flet.MainAxisAlignment.CENTER], the space between the `groups` can be specified using [`group_spacing`][..]. diff --git a/sdk/python/packages/flet-charts/src/flet_charts/candlestick_chart.py b/sdk/python/packages/flet-charts/src/flet_charts/candlestick_chart.py index d9e2ab324a..7062d5e624 100644 --- a/sdk/python/packages/flet-charts/src/flet_charts/candlestick_chart.py +++ b/sdk/python/packages/flet-charts/src/flet_charts/candlestick_chart.py @@ -17,12 +17,14 @@ class CandlestickChartTooltip: """Configuration of the tooltip for [`CandlestickChart`][(p).]s.""" - bgcolor: ft.ColorValue = "#FF607D8B" + bgcolor: ft.ColorValue = "#FFFFECEF" """ Background color applied to the tooltip bubble. """ - border_radius: Optional[ft.BorderRadiusValue] = None + border_radius: ft.BorderRadiusValue = field( + default_factory=lambda: ft.BorderRadius.all(4) + ) """ Corner radius of the tooltip bubble. """ @@ -159,9 +161,10 @@ class CandlestickChart(ft.LayoutControl): Enables automatic tooltips and highlighting when hovering the chart. """ - handle_built_in_touches: bool = True + show_tooltips_for_selected_spots_only: bool = False """ - Allows the chart to manage tooltip visibility automatically. + Whether to permanently and only show the tooltips of spots with their + [`selected`][(p).CandlestickChartSpot.selected] property set to `True`. """ long_press_duration: Optional[ft.DurationValue] = None @@ -169,7 +172,7 @@ class CandlestickChart(ft.LayoutControl): The duration of a long press on the chart. """ - touch_spot_threshold: Optional[ft.Number] = None + touch_spot_threshold: ft.Number = 4 """ The distance threshold to consider a touch near a candlestick. """ diff --git a/sdk/python/packages/flet-charts/src/flet_charts/matplotlib_chart.py b/sdk/python/packages/flet-charts/src/flet_charts/matplotlib_chart.py index bf917c23a9..7160562e2d 100644 --- a/sdk/python/packages/flet-charts/src/flet_charts/matplotlib_chart.py +++ b/sdk/python/packages/flet-charts/src/flet_charts/matplotlib_chart.py @@ -2,18 +2,22 @@ import logging from dataclasses import dataclass, field from io import BytesIO -from typing import Optional +from typing import Any, Optional import flet as ft import flet.canvas as fc +_MATPLOTLIB_IMPORT_ERROR: Optional[ImportError] = None + try: - import matplotlib - from matplotlib.figure import Figure -except ImportError as e: - raise Exception( - 'Install "matplotlib" Python package to use MatplotlibChart control.' - ) from e + import matplotlib # type: ignore[import] + from matplotlib.figure import Figure # type: ignore[import] +except ImportError as e: # pragma: no cover - depends on optional dependency + matplotlib = None # type: ignore[assignment] + Figure = Any # type: ignore[assignment] + _MATPLOTLIB_IMPORT_ERROR = e +else: + matplotlib.use("module://flet_charts.matplotlib_backends.backend_flet_agg") __all__ = [ "MatplotlibChart", @@ -23,8 +27,6 @@ logger = logging.getLogger("flet-charts.matplotlib") -matplotlib.use("module://flet_charts.matplotlib_backends.backend_flet_agg") - figure_cursors = { "default": None, "pointer": ft.MouseCursor.CLICK, @@ -36,6 +38,13 @@ } +def _require_matplotlib() -> None: + if matplotlib is None: + raise ModuleNotFoundError( + 'Install "matplotlib" Python package to use MatplotlibChart control.' + ) from _MATPLOTLIB_IMPORT_ERROR + + @dataclass class MatplotlibChartMessageEvent(ft.Event["MatplotlibChart"]): message: str @@ -86,6 +95,10 @@ class MatplotlibChart(ft.GestureDetector): Triggers when toolbar buttons status is updated. """ + def init(self): + _require_matplotlib() + super().init() + def build(self): self.mouse_cursor = ft.MouseCursor.WAIT self.__started = False @@ -95,7 +108,7 @@ def build(self): self.canvas = fc.Canvas( # resize_interval=10, - on_resize=self.on_canvas_resize, + on_resize=self._on_canvas_resize, expand=True, ) self.keyboard_listener = ft.KeyboardListener( @@ -246,29 +259,55 @@ def _right_pan_end(self, e: ft.PointerEvent): ) def will_unmount(self): + """ + Called when the control is about to be removed from the page. + """ self.figure.canvas.manager.remove_web_socket(self) def home(self): + """ + Resets the view to the original state. + """ logger.debug("home)") self.send_message({"type": "toolbar_button", "name": "home"}) def back(self): + """ + Goes back to the previous view. + """ logger.debug("back()") self.send_message({"type": "toolbar_button", "name": "back"}) def forward(self): + """ + Goes forward to the next view. + """ logger.debug("forward)") self.send_message({"type": "toolbar_button", "name": "forward"}) def pan(self): + """ + Activates the pan tool. + """ logger.debug("pan()") self.send_message({"type": "toolbar_button", "name": "pan"}) def zoom(self): + """ + Activates the zoom tool. + """ logger.debug("zoom()") self.send_message({"type": "toolbar_button", "name": "zoom"}) - def download(self, format): + def download(self, format) -> bytes: + """ + Downloads the current figure in the specified format. + Args: + format (str): The format to download the figure in (e.g., 'png', + 'jpg', 'svg', etc.). + Returns: + bytes: The figure image in the specified format as a byte array. + """ logger.debug(f"Download in format: {format}") buff = BytesIO() self.figure.savefig(buff, format=format, dpi=self.figure.dpi * self.__dpr) @@ -347,23 +386,26 @@ async def _receive_loop(self): ) def send_message(self, message): + """Sends a message to the figure's canvas manager.""" logger.debug(f"send_message({message})") manager = self.figure.canvas.manager if manager is not None: manager.handle_json(message) def send_json(self, content): + """Sends a JSON message to the front end.""" logger.debug(f"send_json: {content}") self._main_loop.call_soon_threadsafe( lambda: self._receive_queue.put_nowait((False, content)) ) def send_binary(self, blob): + """Sends a binary message to the front end.""" self._main_loop.call_soon_threadsafe( lambda: self._receive_queue.put_nowait((True, blob)) ) - async def on_canvas_resize(self, e: fc.CanvasResizeEvent): + async def _on_canvas_resize(self, e: fc.CanvasResizeEvent): logger.debug(f"on_canvas_resize: {e.width}, {e.height}") if not self.__started: diff --git a/sdk/python/packages/flet-charts/src/flet_charts/matplotlib_chart_with_toolbar.py b/sdk/python/packages/flet-charts/src/flet_charts/matplotlib_chart_with_toolbar.py index 6f2f0fd657..9a7c28a2ff 100644 --- a/sdk/python/packages/flet-charts/src/flet_charts/matplotlib_chart_with_toolbar.py +++ b/sdk/python/packages/flet-charts/src/flet_charts/matplotlib_chart_with_toolbar.py @@ -1,10 +1,17 @@ from dataclasses import field - -from matplotlib.figure import Figure +from typing import Any, Optional import flet as ft import flet_charts +_MATPLOTLIB_IMPORT_ERROR: Optional[ImportError] = None + +try: + from matplotlib.figure import Figure # type: ignore +except ImportError as e: # pragma: no cover - depends on optional dependency + Figure = Any # type: ignore[assignment] + _MATPLOTLIB_IMPORT_ERROR = e + _download_formats = [ "eps", "jpeg", @@ -19,6 +26,13 @@ ] +def _require_matplotlib() -> None: + if _MATPLOTLIB_IMPORT_ERROR is not None: + raise ModuleNotFoundError( + 'Install "matplotlib" Python package to use MatplotlibChart control.' + ) from _MATPLOTLIB_IMPORT_ERROR + + @ft.control(kw_only=True, isolated=True) class MatplotlibChartWithToolbar(ft.Column): figure: Figure = field(metadata={"skip": True}) @@ -28,6 +42,7 @@ class MatplotlibChartWithToolbar(ft.Column): """ def build(self): + _require_matplotlib() self.mpl = flet_charts.MatplotlibChart( figure=self.figure, expand=True, @@ -63,7 +78,7 @@ def build(self): self.msg = ft.Text() self.controls = [ ft.Row( - [ + controls=[ self.home_btn, self.back_btn, self.fwd_btn, diff --git a/sdk/python/packages/flet-charts/src/flet_charts/plotly_chart.py b/sdk/python/packages/flet-charts/src/flet_charts/plotly_chart.py index c370c7b83b..5c739a9fd4 100644 --- a/sdk/python/packages/flet-charts/src/flet_charts/plotly_chart.py +++ b/sdk/python/packages/flet-charts/src/flet_charts/plotly_chart.py @@ -1,19 +1,28 @@ import re import xml.etree.ElementTree as ET from dataclasses import field +from typing import Any, Optional import flet as ft +_PLOTLY_IMPORT_ERROR: Optional[ImportError] = None + try: from plotly.graph_objects import Figure -except ImportError as e: - raise Exception( - 'Install "plotly" Python package to use PlotlyChart control.' - ) from e +except ImportError as e: # pragma: no cover - depends on optional dependency + Figure = Any # type: ignore[assignment] + _PLOTLY_IMPORT_ERROR = e __all__ = ["PlotlyChart"] +def _require_plotly() -> None: + if _PLOTLY_IMPORT_ERROR is not None: + raise ModuleNotFoundError( + 'Install "plotly" Python package to use PlotlyChart control.' + ) from _PLOTLY_IMPORT_ERROR + + @ft.control(kw_only=True) class PlotlyChart(ft.Container): """ @@ -41,6 +50,7 @@ class PlotlyChart(ft.Container): """ def init(self): + _require_plotly() self.alignment = ft.Alignment.CENTER self.__img = ft.Image(fit=ft.BoxFit.FILL) self.content = self.__img diff --git a/sdk/python/packages/flet-charts/src/flet_charts/radar_chart.py b/sdk/python/packages/flet-charts/src/flet_charts/radar_chart.py new file mode 100644 index 0000000000..423d2197d1 --- /dev/null +++ b/sdk/python/packages/flet-charts/src/flet_charts/radar_chart.py @@ -0,0 +1,214 @@ +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional + +import flet as ft +from flet_charts.radar_data_set import RadarDataSet +from flet_charts.types import ChartEventType + +__all__ = ["RadarChart", "RadarChartEvent", "RadarChartTitle", "RadarShape"] + + +class RadarShape(Enum): + """Shape of the radar grid and data polygons.""" + + CIRCLE = "circle" + """Draws radial circles for the grid and data outlines.""" + + POLYGON = "polygon" + """Draws straight-edged polygons for the grid and data outlines.""" + + +@ft.control("RadarChartTitle") +class RadarChartTitle(ft.BaseControl): + """ + Custom title configuration displayed around a [`RadarChart`][(p).]. + """ + + text: str = "" + """ + The text displayed for the title. + """ + + angle: ft.Number = 0 + """ + Rotation angle (in degrees) applied to the title. + """ + + position_percentage_offset: Optional[ft.Number] = None + """ + Defines the relative distance of this title from the chart center. + + - `0` draws this title near the inside edge of each section. + - `1` draws this title near the outside edge of each section. + + Must be between `0` and `1` (inclusive), if set. + + Note: + If set, it takes precedence over the parent + [`RadarChart.title_position_percentage_offset`][(p).] value. + """ + + text_spans: Optional[list[ft.TextSpan]] = None + """ + Inline spans appended to the title. + """ + + +@dataclass +class RadarChartEvent(ft.Event["RadarChart"]): + """ + Event raised for interactions with a [`RadarChart`][(p).]. + """ + + type: ChartEventType + """ + The touch or pointer event that occurred. + """ + + data_set_index: Optional[int] = None + """ + The index of the touched data set, if any. + """ + + entry_index: Optional[int] = None + """ + The index of the touched radar entry, if any. + """ + + entry_value: Optional[ft.Number] = None + """ + The value of the touched radar entry, if any. + """ + + +@ft.control("RadarChart") +class RadarChart(ft.LayoutControl): + """ + A radar chart made of multiple datasets. + """ + + data_sets: list[RadarDataSet] = field(default_factory=list) + """ + A list of [`RadarDataSet`][(p).] controls rendered on the chart. + """ + + titles: list[RadarChartTitle] = field(default_factory=list) + """ + The titles shown around this chart, matching the number of entries per set. + """ + + title_text_style: Optional[ft.TextStyle] = None + """ + The text style applied to titles around this chart. + """ + + title_position_percentage_offset: ft.Number = 0.2 + """ + Defines the relative distance of titles from the chart center. + + - `0` draws titles near the inside edge of each section. + - `1` draws titles near the outside edge of each section. + + Must be between `0` and `1` (inclusive). + + Raises: + ValueError: If set to a value less than `0` or greater than `1`. + """ + + radar_bgcolor: ft.ColorValue = ft.Colors.TRANSPARENT + """ + The background color of the radar area. + """ + + radar_border_side: ft.BorderSide = field( + default_factory=lambda: ft.BorderSide(width=2.0) + ) + """ + The outline drawn around the radar area. + """ + + radar_shape: RadarShape = RadarShape.POLYGON + """ + The shape of the radar area. + """ + + border: Optional[ft.Border] = None + """ + The border drawn around this chart. + """ + + center_min_value: bool = False + """ + Whether minimum entry values should be positioned at the center of this chart. + """ + + tick_count: ft.Number = 1 + """ + Number of tick rings drawn from the centre to the edge. + + Must be greater than or equal to `1`. + + Raises: + ValueError: If set to a value less than `1`. + """ + + ticks_text_style: Optional[ft.TextStyle] = None + """ + The text style used to draw tick labels. + """ + + tick_border_side: ft.BorderSide = field( + default_factory=lambda: ft.BorderSide(width=2.0) + ) + """ + The style of the tick rings. + """ + + grid_border_side: ft.BorderSide = field( + default_factory=lambda: ft.BorderSide(width=2.0) + ) + """ + The style of the radar grid lines. + """ + + animation: ft.AnimationValue = field( + default_factory=lambda: ft.Animation( + duration=ft.Duration(milliseconds=150), curve=ft.AnimationCurve.LINEAR + ) + ) + """ + Controls the implicit animation applied when updating this chart. + """ + + interactive: bool = True + """ + Enables touch interactions and event notifications. + """ + + long_press_duration: Optional[ft.DurationValue] = None + """ + The duration before a long-press event fires. + """ + + touch_spot_threshold: ft.Number = 10 + """ + The radius (in logical pixels) used to detect nearby entries for touches. + """ + + on_event: Optional[ft.EventHandler[RadarChartEvent]] = None + """ + Called when the chart is interacted with. + """ + + def init(self): + super().init() + entries_lengths = {len(ds.entries) for ds in self.data_sets} + if len(entries_lengths) > 1: + raise ValueError( + "All data sets in the data_sets list must have equal number of entries" + ) + if not (0 <= self.title_position_percentage_offset <= 1): + raise ValueError("title_position_percentage_offset must be between 0 and 1") + if self.tick_count is not None and self.tick_count < 1: + raise ValueError("tick_count must be greater than or equal to 1") diff --git a/sdk/python/packages/flet-charts/src/flet_charts/radar_data_set.py b/sdk/python/packages/flet-charts/src/flet_charts/radar_data_set.py new file mode 100644 index 0000000000..e462810843 --- /dev/null +++ b/sdk/python/packages/flet-charts/src/flet_charts/radar_data_set.py @@ -0,0 +1,66 @@ +from dataclasses import field +from typing import Optional + +import flet as ft + +__all__ = ["RadarDataSet", "RadarDataSetEntry"] + + +@ft.control("RadarDataSetEntry") +class RadarDataSetEntry(ft.BaseControl): + """ + A single data point rendered on a [`RadarChart`][(p).]. + """ + + value: ft.Number + """ + The numeric value drawn for this entry. + """ + + +@ft.control("RadarDataSet") +class RadarDataSet(ft.BaseControl): + """ + A collection of [`RadarDataSetEntry`][(p).] drawn as a filled radar shape. + """ + + entries: list[RadarDataSetEntry] = field(default_factory=list) + """ + The data points that compose this set. + """ + + fill_color: ft.ColorValue = ft.Colors.CYAN + """ + The color used to fill this dataset. + """ + + fill_gradient: Optional[ft.Gradient] = None + """ + The gradient used to fill this dataset. + + Takes precedence over [`fill_color`][..]. + """ + + border_color: ft.ColorValue = ft.Colors.CYAN + """ + The color of the dataset outline. + """ + + border_width: ft.Number = 2.0 + """ + The width of the dataset outline. + """ + + entry_radius: ft.Number = 5.0 + """ + The radius of each entry. + """ + + def init(self): + super().init() + entries_length = len(self.entries) + if entries_length != 0 and entries_length < 3: + raise ValueError( + f"entries can contain either 0 or at least 3 items, " + f"got {entries_length}" + ) diff --git a/sdk/python/packages/flet-charts/src/flet_charts/scatter_chart_spot.py b/sdk/python/packages/flet-charts/src/flet_charts/scatter_chart_spot.py index 940208af50..814001c4d4 100644 --- a/sdk/python/packages/flet-charts/src/flet_charts/scatter_chart_spot.py +++ b/sdk/python/packages/flet-charts/src/flet_charts/scatter_chart_spot.py @@ -102,7 +102,7 @@ class ScatterChartSpot(ft.BaseControl): selected: bool = False """ - TBD + Whether to treat this spot as selected. """ tooltip: Union[ScatterChartSpotTooltip, str] = field( diff --git a/sdk/python/packages/flet-charts/src/flutter/flet_charts/lib/src/candlestick_chart.dart b/sdk/python/packages/flet-charts/src/flutter/flet_charts/lib/src/candlestick_chart.dart index 9f44a517be..85ebd788d0 100644 --- a/sdk/python/packages/flet-charts/src/flutter/flet_charts/lib/src/candlestick_chart.dart +++ b/sdk/python/packages/flet-charts/src/flutter/flet_charts/lib/src/candlestick_chart.dart @@ -37,9 +37,6 @@ class _CandlestickChartControlState extends State { final bottomTitles = parseAxisTitles(widget.control.child("bottom_axis")); final interactive = widget.control.getBool("interactive", true)!; - final handleBuiltInTouches = - widget.control.getBool("handle_built_in_touches", true)!; - final touchSpotThreshold = widget.control.getDouble("touch_spot_threshold"); final spotControls = widget.control.children("spots"); final candlestickSpots = spotControls.map((spot) { @@ -54,21 +51,12 @@ class _CandlestickChartControlState extends State { ); }).toList(); - final selectedIndicators = spotControls - .asMap() - .entries - .where((entry) => entry.value.getBool("selected", false)!) - .map((entry) => entry.key) - .toList(); - - final showingIndicators = - (!interactive || !handleBuiltInTouches) ? selectedIndicators : []; - final candlestickTouchData = CandlestickTouchData( enabled: interactive && !widget.control.disabled, - handleBuiltInTouches: handleBuiltInTouches, + handleBuiltInTouches: !widget.control + .getBool("show_tooltips_for_selected_spots_only", false)!, longPressDuration: widget.control.getDuration("long_press_duration"), - touchSpotThreshold: touchSpotThreshold, + touchSpotThreshold: widget.control.getDouble("touch_spot_threshold", 4)!, touchTooltipData: parseCandlestickTouchTooltipData( context, widget.control, @@ -112,7 +100,12 @@ class _CandlestickChartControlState extends State { widget.control.get("vertical_grid_lines"), theme), candlestickTouchData: candlestickTouchData, - showingTooltipIndicators: showingIndicators, + showingTooltipIndicators: spotControls + .asMap() + .entries + .where((e) => e.value.getBool("selected", false)!) + .map((e) => e.key) + .toList(), rotationQuarterTurns: widget.control.getInt("rotation_quarter_turns", 0)!, ), diff --git a/sdk/python/packages/flet-charts/src/flutter/flet_charts/lib/src/extension.dart b/sdk/python/packages/flet-charts/src/flutter/flet_charts/lib/src/extension.dart index dc35644867..e0871b28ba 100644 --- a/sdk/python/packages/flet-charts/src/flutter/flet_charts/lib/src/extension.dart +++ b/sdk/python/packages/flet-charts/src/flutter/flet_charts/lib/src/extension.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'bar_chart.dart'; import 'candlestick_chart.dart'; import 'line_chart.dart'; +import 'radar_chart.dart'; import 'pie_chart.dart'; import 'scatter_chart.dart'; @@ -17,6 +18,8 @@ class Extension extends FletExtension { return CandlestickChartControl(key: key, control: control); case "LineChart": return LineChartControl(key: key, control: control); + case "RadarChart": + return RadarChartControl(key: key, control: control); case "PieChart": return PieChartControl(key: key, control: control); case "ScatterChart": diff --git a/sdk/python/packages/flet-charts/src/flutter/flet_charts/lib/src/radar_chart.dart b/sdk/python/packages/flet-charts/src/flutter/flet_charts/lib/src/radar_chart.dart new file mode 100644 index 0000000000..dfa5486fcc --- /dev/null +++ b/sdk/python/packages/flet-charts/src/flutter/flet_charts/lib/src/radar_chart.dart @@ -0,0 +1,104 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flet/flet.dart'; +import 'package:flutter/material.dart'; + +import 'utils/radar_chart.dart'; + +class RadarChartControl extends StatefulWidget { + final Control control; + + RadarChartControl({Key? key, required this.control}) + : super(key: ValueKey("control_${control.id}")); + + @override + State createState() => _RadarChartControlState(); +} + +class _RadarChartControlState extends State { + RadarChartEventData? _eventData; + + @override + Widget build(BuildContext context) { + debugPrint("RadarChart build: ${widget.control.id}‚"); + + final theme = Theme.of(context); + final animation = widget.control.getAnimation( + "animation", + ImplicitAnimationDetails( + duration: const Duration(milliseconds: 150), + curve: Curves.linear))!; + final interactive = widget.control.getBool("interactive", true)! && + !widget.control.disabled; + final border = widget.control.getBorder("border", theme); + final titleControls = widget.control.children("titles", visibleOnly: false); + + final chart = RadarChart( + RadarChartData( + dataSets: widget.control + .children("data_sets") + .map((ds) => parseRadarDataSet(ds, theme, context)) + .toList(), + + // Radar and borders + radarBackgroundColor: widget.control + .getColor("radar_bgcolor", context, Colors.transparent)!, + radarBorderData: widget.control.getBorderSide( + "radar_border_side", theme, + defaultValue: const BorderSide(width: 2))!, + radarShape: parseRadarShape( + widget.control.get("radar_shape"), RadarShape.polygon)!, + borderData: FlBorderData(show: border != null, border: border), + gridBorderData: widget.control.getBorderSide("grid_border_side", theme, + defaultValue: const BorderSide(width: 2))!, + + // Titles + titleTextStyle: widget.control.getTextStyle("title_text_style", theme), + titlePositionPercentageOffset: + widget.control.getDouble("title_position_percentage_offset", 0.2)!, + getTitle: titleControls.isNotEmpty + ? (int index, double angle) { + if (index >= titleControls.length) { + return RadarChartTitle(text: '', angle: angle); + } + final ctrl = titleControls[index]; + return parseRadarChartTitle(ctrl, theme, angle); + } + : null, + + // Ticks + tickCount: widget.control.getInt("tick_count", 1)!, + ticksTextStyle: widget.control.getTextStyle("ticks_text_style", theme), + tickBorderData: widget.control.getBorderSide("tick_border_side", theme, + defaultValue: const BorderSide(width: 2))!, + isMinValueAtCenter: widget.control.getBool("center_min_value", false)!, + + // Interaction + radarTouchData: RadarTouchData( + enabled: interactive, + longPressDuration: widget.control.getDuration("long_press_duration"), + touchSpotThreshold: widget.control.getDouble("touch_spot_threshold"), + touchCallback: (event, response) { + final eventData = RadarChartEventData.fromDetails(event, response); + if (eventData != _eventData) { + _eventData = eventData; + widget.control.triggerEvent("event", eventData.toMap()); + } + }, + ), + ), + duration: animation.duration, + curve: animation.curve, + ); + + return ConstrainedControl( + control: widget.control, + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return (constraints.maxHeight == double.infinity) + ? ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 300), + child: chart) + : chart; + })); + } +} diff --git a/sdk/python/packages/flet-charts/src/flutter/flet_charts/lib/src/utils/bar_chart.dart b/sdk/python/packages/flet-charts/src/flutter/flet_charts/lib/src/utils/bar_chart.dart index d13d1aa4b9..b13bb868fd 100644 --- a/sdk/python/packages/flet-charts/src/flutter/flet_charts/lib/src/utils/bar_chart.dart +++ b/sdk/python/packages/flet-charts/src/flutter/flet_charts/lib/src/utils/bar_chart.dart @@ -38,11 +38,11 @@ class BarChartEventData extends Equatable { } Map toMap() => { - 'type': eventType, - 'group_index': groupIndex, - 'rod_index': rodIndex, - 'stack_item_index': stackItemIndex, - }; + 'type': eventType, + 'group_index': groupIndex, + 'rod_index': rodIndex, + 'stack_item_index': stackItemIndex, + }; @override List get props => [eventType, groupIndex, rodIndex, stackItemIndex]; @@ -96,9 +96,8 @@ BarTouchTooltipData? parseBarTouchTooltipData( FLHorizontalAlignment.center, )!, getTooltipItem: (group, groupIndex, rod, rodIndex) { - var rod = control - .children("groups")[groupIndex] - .children("rods")[rodIndex]; + var rod = + control.children("groups")[groupIndex].children("rods")[rodIndex]; return parseBarTooltipItem(rod, context); }, ); @@ -118,8 +117,7 @@ BarTooltipItem? parseBarTooltipItem(Control rod, BuildContext context) { )!; if (tooltipTextStyle.color == null) { tooltipTextStyle = tooltipTextStyle.copyWith( - color: - rod.getGradient("gradient", theme)?.colors.first ?? + color: rod.getGradient("gradient", theme)?.colors.first ?? rod.getColor("color", context, Colors.blueGrey)!, ); } @@ -194,8 +192,7 @@ BarChartRodData parseBarChartRodData( defaultValue: BorderSide.none, ), backDrawRodData: BackgroundBarChartRodData( - show: - (bgFromY != null || + show: (bgFromY != null || bgToY != null || bgcolor != null || backgroundGradient != null), @@ -235,13 +232,10 @@ BarChartRodStackItem parseBarChartRodStackItem( ); } -BarChartAlignment? parseBarChartAlignment( - String? value, [ - BarChartAlignment? defaultValue, -]) { +BarChartAlignment? parseBarChartAlignment(String? value, + [BarChartAlignment? defaultValue]) { if (value == null) return defaultValue; return BarChartAlignment.values.firstWhereOrNull( - (e) => e.name.toLowerCase() == value.toLowerCase(), - ) ?? + (e) => e.name.toLowerCase() == value.toLowerCase()) ?? defaultValue; } diff --git a/sdk/python/packages/flet-charts/src/flutter/flet_charts/lib/src/utils/candlestick_chart.dart b/sdk/python/packages/flet-charts/src/flutter/flet_charts/lib/src/utils/candlestick_chart.dart index ce5ce38217..9272751e1c 100644 --- a/sdk/python/packages/flet-charts/src/flutter/flet_charts/lib/src/utils/candlestick_chart.dart +++ b/sdk/python/packages/flet-charts/src/flutter/flet_charts/lib/src/utils/candlestick_chart.dart @@ -39,24 +39,24 @@ CandlestickTouchTooltipData parseCandlestickTouchTooltipData( final theme = Theme.of(context); return CandlestickTouchTooltipData( - tooltipBorder: - parseBorderSide(tooltip["border_side"], theme, defaultValue: BorderSide.none)!, + tooltipBorder: parseBorderSide(tooltip["border_side"], theme, + defaultValue: BorderSide.none)!, rotateAngle: parseDouble(tooltip["rotation"], 0.0)!, - tooltipBorderRadius: parseBorderRadius(tooltip["border_radius"]), - tooltipPadding: parsePadding( - tooltip["padding"], const EdgeInsets.symmetric(horizontal: 16, vertical: 8))!, + tooltipBorderRadius: + parseBorderRadius(tooltip["border_radius"], BorderRadius.circular(4))!, + tooltipPadding: parsePadding(tooltip["padding"], + const EdgeInsets.symmetric(horizontal: 16, vertical: 8))!, tooltipHorizontalAlignment: parseFLHorizontalAlignment( tooltip["horizontal_alignment"], FLHorizontalAlignment.center)!, tooltipHorizontalOffset: parseDouble(tooltip["horizontal_offset"], 0)!, maxContentWidth: parseDouble(tooltip["max_width"], 120)!, fitInsideHorizontally: parseBool(tooltip["fit_inside_horizontally"], false)!, - fitInsideVertically: - parseBool(tooltip["fit_inside_vertically"], false)!, + fitInsideVertically: parseBool(tooltip["fit_inside_vertically"], false)!, showOnTopOfTheChartBoxArea: parseBool(tooltip["show_on_top_of_chart_box_area"], false)!, - getTooltipColor: (spot) => parseColor( - tooltip["bgcolor"], theme, const Color.fromRGBO(96, 125, 139, 1))!, + getTooltipColor: (spot) => + parseColor(tooltip["bgcolor"], theme, const Color(0xFFFFECEF))!, getTooltipItems: (painter, touchedSpot, spotIndex) { if (spotIndex < 0 || spotIndex >= spotControls.length) { return null; @@ -105,8 +105,9 @@ CandlestickTooltipItem? parseCandlestickTooltipItem( textStyle: textStyle, bottomMargin: parseDouble(tooltip["bottom_margin"], 8)!, textAlign: parseTextAlign(tooltip["text_align"], TextAlign.center)!, - textDirection: - parseBool(tooltip["rtl"], false)! ? TextDirection.rtl : TextDirection.ltr, + textDirection: parseBool(tooltip["rtl"], false)! + ? TextDirection.rtl + : TextDirection.ltr, children: tooltip["text_spans"] != null ? parseTextSpans(tooltip["text_spans"], theme, (s, eventName, [eventData]) { diff --git a/sdk/python/packages/flet-charts/src/flutter/flet_charts/lib/src/utils/radar_chart.dart b/sdk/python/packages/flet-charts/src/flutter/flet_charts/lib/src/utils/radar_chart.dart new file mode 100644 index 0000000000..fadd9f77e8 --- /dev/null +++ b/sdk/python/packages/flet-charts/src/flutter/flet_charts/lib/src/utils/radar_chart.dart @@ -0,0 +1,90 @@ +import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flet/flet.dart'; +import 'package:flutter/material.dart'; + +import 'charts.dart'; + +RadarShape? parseRadarShape(String? value, [RadarShape? defaultValue]) { + if (value == null) return defaultValue; + return RadarShape.values.firstWhereOrNull( + (e) => e.name.toLowerCase() == value.toLowerCase()) ?? + defaultValue; +} + +class RadarChartEventData extends Equatable { + final String eventType; + final int? dataSetIndex; + final int? entryIndex; + final double? entryValue; + + const RadarChartEventData({ + required this.eventType, + this.dataSetIndex, + this.entryIndex, + this.entryValue, + }); + + factory RadarChartEventData.fromDetails( + FlTouchEvent event, RadarTouchResponse? response) { + final touchedSpot = response?.touchedSpot; + + return RadarChartEventData( + eventType: eventMap[event.runtimeType.toString()] ?? "undefined", + dataSetIndex: touchedSpot?.touchedDataSetIndex, + entryIndex: touchedSpot?.touchedRadarEntryIndex, + entryValue: touchedSpot?.touchedRadarEntry.value, + ); + } + + Map toMap() => { + 'type': eventType, + 'data_set_index': dataSetIndex, + 'entry_index': entryIndex, + 'entry_value': entryValue, + }; + + @override + List get props => [eventType, dataSetIndex, entryIndex, entryValue]; +} + +RadarDataSet parseRadarDataSet( + Control dataSet, ThemeData theme, BuildContext context) { + final fillColor = dataSet.getColor("fill_color", context, Colors.cyan)!; + final fillGradient = dataSet.getGradient("fill_gradient", theme); + final borderColor = dataSet.getColor("border_color", context, Colors.cyan)!; + final borderWidth = dataSet.getDouble("border_width", 2.0)!; + final entryRadius = dataSet.getDouble("entry_radius", 5.0)!; + + final entries = dataSet + .children("entries") + .map((entry) => RadarEntry(value: entry.getDouble("value", 0)!)) + .toList(); + + return RadarDataSet( + dataEntries: entries, + fillColor: fillColor, + fillGradient: fillGradient, + borderColor: borderColor, + borderWidth: borderWidth, + entryRadius: entryRadius, + ); +} + +RadarChartTitle parseRadarChartTitle( + Control title, ThemeData theme, double defaultAngle) { + final spansValue = title.get("text_spans"); + final spans = spansValue != null + ? parseTextSpans(spansValue, theme, (control, eventName, [eventData]) { + control.triggerEvent(eventName, eventData); + }) + : null; + + return RadarChartTitle( + text: title.getString("text", "")!, + angle: title.getDouble("angle") ?? defaultAngle, + positionPercentageOffset: title.getDouble("position_percentage_offset"), + children: spans, + ); +} diff --git a/sdk/python/packages/flet/docs/charts/index.md b/sdk/python/packages/flet/docs/charts/index.md index 20c30f5227..9505582b34 100644 --- a/sdk/python/packages/flet/docs/charts/index.md +++ b/sdk/python/packages/flet/docs/charts/index.md @@ -40,6 +40,5 @@ pip install flet-charts # (1)! - [`MatplotlibChart`](matplotlib_chart.md) - [`PieChart`](pie_chart.md) - [`PlotlyChart`](plotly_chart.md) +- [`RadarChart`](radar_chart.md) - [`ScatterChart`](scatter_chart.md) - -Each chart page provides ready-to-run examples from `{{ examples }}`. diff --git a/sdk/python/packages/flet/docs/charts/matplotlib_chart.md b/sdk/python/packages/flet/docs/charts/matplotlib_chart.md index 9801caa7f5..f5e10f150c 100644 --- a/sdk/python/packages/flet/docs/charts/matplotlib_chart.md +++ b/sdk/python/packages/flet/docs/charts/matplotlib_chart.md @@ -4,28 +4,52 @@ examples: ../../examples/controls/charts/matplotlib_chart example_images: ../examples/controls/charts/matplotlib_chart/media --- -{{ class_summary(class_name) }} +{{ class_summary(class_name, image_url=example_images + "/toolbar.png", image_width="80%") }} ## Examples -### Example 1 +### Bar chart Based on an official [Matplotlib example](https://matplotlib.org/stable/gallery/lines_bars_and_markers/bar_colors.html#sphx-glr-gallery-lines-bars-and-markers-bar-colors-py). ```python ---8<-- "{{ examples }}/example_1.py" +--8<-- "{{ examples }}/bar_chart.py" ``` -{{ image(example_images + "/example_1.png", width="80%") }} +{{ image(example_images + "/bar_chart.png", width="80%") }} -### Example 2 +### Chart with Toolbar Based on an official [Matplotlib example](https://matplotlib.org/stable/gallery/lines_bars_and_markers/cohere.html#sphx-glr-gallery-lines-bars-and-markers-cohere-py). ```python ---8<-- "{{ examples }}/example_2.py" +--8<-- "{{ examples }}/toolbar.py" ``` -{{ image(example_images + "/example_2.png", width="80%") }} +{{ image(example_images + "/toolbar.png", width="80%") }} + +### 3D chart + +```python +--8<-- "{{ examples }}/3d.py" +``` + +{{ image(example_images + "/3d.png", width="80%") }} + +### Handle events + +```python +--8<-- "{{ examples }}/handle_events.py" +``` + +{{ image(example_images + "/handle_events.png", width="80%") }} + +### Animated chart + +```python +--8<-- "{{ examples }}/animate.py" +``` + +{{ image(example_images + "/animate.png", width="80%") }} {{ class_members(class_name) }} diff --git a/sdk/python/packages/flet/docs/charts/radar_chart.md b/sdk/python/packages/flet/docs/charts/radar_chart.md new file mode 100644 index 0000000000..359da53a87 --- /dev/null +++ b/sdk/python/packages/flet/docs/charts/radar_chart.md @@ -0,0 +1,19 @@ +--- +class_name: flet_charts.radar_chart.RadarChart +examples: ../../examples/controls/charts/radar_chart +example_images: ../examples/controls/charts/radar_chart/media +--- + +{{ class_summary(class_name) }} + +## Examples + +### Example 1 + +```python +--8<-- "{{ examples }}/example_1.py" +``` + +{{ image(example_images + "/example_1.png", width="80%") }} + +{{ class_members(class_name) }} diff --git a/sdk/python/packages/flet/docs/charts/types/radar_chart_event.md b/sdk/python/packages/flet/docs/charts/types/radar_chart_event.md new file mode 100644 index 0000000000..5d81e987e7 --- /dev/null +++ b/sdk/python/packages/flet/docs/charts/types/radar_chart_event.md @@ -0,0 +1 @@ +{{ class_all_options("flet_charts.radar_chart.RadarChartEvent") }} diff --git a/sdk/python/packages/flet/docs/charts/types/radar_chart_title.md b/sdk/python/packages/flet/docs/charts/types/radar_chart_title.md new file mode 100644 index 0000000000..341e1bffdb --- /dev/null +++ b/sdk/python/packages/flet/docs/charts/types/radar_chart_title.md @@ -0,0 +1 @@ +{{ class_all_options("flet_charts.radar_chart.RadarChartTitle") }} diff --git a/sdk/python/packages/flet/docs/charts/types/radar_data_set.md b/sdk/python/packages/flet/docs/charts/types/radar_data_set.md new file mode 100644 index 0000000000..87db683bce --- /dev/null +++ b/sdk/python/packages/flet/docs/charts/types/radar_data_set.md @@ -0,0 +1 @@ +{{ class_all_options("flet_charts.radar_data_set.RadarDataSet") }} diff --git a/sdk/python/packages/flet/docs/charts/types/radar_data_set_entry.md b/sdk/python/packages/flet/docs/charts/types/radar_data_set_entry.md new file mode 100644 index 0000000000..35a6c1e3f1 --- /dev/null +++ b/sdk/python/packages/flet/docs/charts/types/radar_data_set_entry.md @@ -0,0 +1 @@ +{{ class_all_options("flet_charts.radar_data_set.RadarDataSetEntry") }} diff --git a/sdk/python/packages/flet/docs/charts/types/radar_shape.md b/sdk/python/packages/flet/docs/charts/types/radar_shape.md new file mode 100644 index 0000000000..214275454d --- /dev/null +++ b/sdk/python/packages/flet/docs/charts/types/radar_shape.md @@ -0,0 +1 @@ +{{ class_all_options("flet_charts.radar_chart.RadarShape", separate_signature=False) }} diff --git a/sdk/python/packages/flet/mkdocs.yml b/sdk/python/packages/flet/mkdocs.yml index 2ecc83b086..28377481ae 100644 --- a/sdk/python/packages/flet/mkdocs.yml +++ b/sdk/python/packages/flet/mkdocs.yml @@ -142,7 +142,7 @@ plugins: preload_modules: [ flet, flet_ads ] filters: - "!^_" # Exclude private members starting with only one underscore - - "!(init|before_update)" + - "!(init|before_update|build|will_unmount|did_mount)" extensions: - griffe_modernized_annotations - griffe_warnings_deprecated @@ -264,15 +264,6 @@ nav: # - NativeAd: ads/nativead.md - Audio: audio/index.md - AudioRecorder: audio_recorder/index.md - - Charts: - - Overview: charts/index.md - - BarChart: charts/bar_chart.md - - CandlestickChart: charts/candlestick_chart.md - - LineChart: charts/line_chart.md - - MatplotlibChart: charts/matplotlib_chart.md - - PieChart: charts/pie_chart.md - - PlotlyChart: charts/plotly_chart.md - - ScatterChart: charts/scatter_chart.md - AlertDialog: controls/alertdialog.md - AnimatedSwitcher: controls/animatedswitcher.md - AppBar: controls/appbar.md @@ -299,6 +290,16 @@ nav: - Shape: controls/canvas/shape.md - Text: controls/canvas/text.md - Card: controls/card.md + - Charts: + - Overview: charts/index.md + - BarChart: charts/bar_chart.md + - CandlestickChart: charts/candlestick_chart.md + - LineChart: charts/line_chart.md + - MatplotlibChart: charts/matplotlib_chart.md + - PieChart: charts/pie_chart.md + - PlotlyChart: charts/plotly_chart.md + - RadarChart: charts/radar_chart.md + - ScatterChart: charts/scatter_chart.md - Checkbox: controls/checkbox.md - Chip: controls/chip.md - CircleAvatar: controls/circleavatar.md @@ -539,6 +540,11 @@ nav: - LineChartTooltip: charts/types/line_chart_tooltip.md - PieChartEvent: charts/types/pie_chart_event.md - PieChartSection: charts/types/pie_chart_section.md + - RadarChartEvent: charts/types/radar_chart_event.md + - RadarChartTitle: charts/types/radar_chart_title.md + - RadarDataSet: charts/types/radar_data_set.md + - RadarDataSetEntry: charts/types/radar_data_set_entry.md + - RadarShape: charts/types/radar_shape.md - ScatterChartEvent: charts/types/scatter_chart_event.md - ScatterChartSpot: charts/types/scatter_chart_spot.md - ScatterChartSpotTooltip: charts/types/scatter_chart_spot_tooltip.md diff --git a/sdk/python/packages/flet/src/flet/controls/border.py b/sdk/python/packages/flet/src/flet/controls/border.py index 378b3f8853..1854a3b201 100644 --- a/sdk/python/packages/flet/src/flet/controls/border.py +++ b/sdk/python/packages/flet/src/flet/controls/border.py @@ -86,34 +86,37 @@ class BorderSide: """ The style of this side of the border. - To omit a side, set `style` to `BorderStyle.NONE`. - This skips painting the border, but the border still has a `width`. + Tip: + To omit a side, set `style` to [`BorderStyle.NONE`][flet.]. This skips + painting the border, but the border still has a `width`. """ def __post_init__(self): - assert self.width >= 0.0, ( - f"width must be greater than or equal to 0.0, got {self.width}" - ) + if self.width < 0.0: + raise ValueError( + f"width must be greater than or equal to 0.0, got {self.width}" + ) # Properties @property def stroke_inset(self): """ - The amount of the stroke width that lies inside of the `BorderSide`. + The amount of the stroke width that lies inside this `BorderSide`. - For example, this will return the `width` for a `stroke_align` of -1, half - the `width` for a `stroke_align` of 0, and 0 for a `stroke_align` of 1. + For example, this will return the `width` for a `stroke_align` of `-`1, half + the `width` for a `stroke_align` of `0`, and `0` for a `stroke_align` of `1`. """ return self.width * (1 - (1 + self.stroke_align) / 2) @property def stroke_outset(self): """ - The amount of the stroke width that lies outside of the [BorderSide]. + The amount of the stroke width that lies outside this `BorderSide`. - For example, this will return 0 for a `stroke_align` of -1, half the - `width` for a `stroke_align` of 0, and the `width` for a `stroke_align` of 1. + For example, this will return `0` for a `stroke_align` of `-1`, half the + `width` for a `stroke_align` of `0`, and the `width` for a + `stroke_align` of `1`. """ return self.width * (1 + self.stroke_align) / 2 @@ -163,12 +166,6 @@ class Border: Each side of the border is an instance of [`BorderSide`][flet.]. - - Example: - ```python - container_1.border = ft.Border.all(10, ft.Colors.PINK_600) - container_1.border = ft.Border.only(bottom=ft.BorderSide(1, "black")) - ``` """ top: BorderSide = field(default_factory=lambda: BorderSide.none()) diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index e505726ada..84e2d5dd29 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -104,7 +104,23 @@ show-fixes = true [tool.ruff.lint] pydocstyle = { convention = 'google' } -isort = { known-first-party = ["flet", "flet_cli"] } +isort = { known-first-party = [ + "flet", + "flet_cli", + "flet_ads", + "flet_audio", + "flet_audio_recorder", + "flet_charts", + "flet_datatable2", + "flet_flashlight", + "flet_geolocator", + "flet_lottie", + "flet_map", + "flet_permission_handler", + "flet_rive", + "flet_video", + "flet_webview" +] } preview = true select = [ # pycodestyle