flet-charts 0.85.0.dev2__tar.gz → 0.85.0.dev3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {flet_charts-0.85.0.dev2/src/flet_charts.egg-info → flet_charts-0.85.0.dev3}/PKG-INFO +2 -2
  2. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/pyproject.toml +2 -2
  3. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flet_charts/__init__.py +6 -0
  4. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flet_charts/line_chart.py +4 -0
  5. flet_charts-0.85.0.dev3/src/flet_charts/matplotlib_backends/backend_flet_agg.py +54 -0
  6. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flet_charts/matplotlib_chart.py +98 -36
  7. flet_charts-0.85.0.dev3/src/flet_charts/matplotlib_chart_canvas.py +72 -0
  8. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3/src/flet_charts.egg-info}/PKG-INFO +2 -2
  9. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flet_charts.egg-info/SOURCES.txt +2 -0
  10. flet_charts-0.85.0.dev3/src/flet_charts.egg-info/requires.txt +1 -0
  11. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flutter/flet_charts/lib/src/extension.dart +3 -0
  12. flet_charts-0.85.0.dev3/src/flutter/flet_charts/lib/src/matplotlib_chart_canvas.dart +276 -0
  13. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flutter/flet_charts/lib/src/utils/line_chart.dart +1 -1
  14. flet_charts-0.85.0.dev2/src/flet_charts/matplotlib_backends/backend_flet_agg.py +0 -20
  15. flet_charts-0.85.0.dev2/src/flet_charts.egg-info/requires.txt +0 -1
  16. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/LICENSE +0 -0
  17. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/README.md +0 -0
  18. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/setup.cfg +0 -0
  19. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flet_charts/bar_chart.py +0 -0
  20. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flet_charts/bar_chart_group.py +0 -0
  21. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flet_charts/bar_chart_rod.py +0 -0
  22. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flet_charts/bar_chart_rod_stack_item.py +0 -0
  23. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flet_charts/candlestick_chart.py +0 -0
  24. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flet_charts/candlestick_chart_spot.py +0 -0
  25. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flet_charts/chart_axis.py +0 -0
  26. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flet_charts/line_chart_data.py +0 -0
  27. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flet_charts/line_chart_data_point.py +0 -0
  28. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flet_charts/matplotlib_chart_with_toolbar.py +0 -0
  29. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flet_charts/pie_chart.py +0 -0
  30. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flet_charts/pie_chart_section.py +0 -0
  31. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flet_charts/plotly_chart.py +0 -0
  32. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flet_charts/radar_chart.py +0 -0
  33. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flet_charts/radar_data_set.py +0 -0
  34. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flet_charts/scatter_chart.py +0 -0
  35. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flet_charts/scatter_chart_spot.py +0 -0
  36. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flet_charts/types.py +0 -0
  37. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flet_charts.egg-info/dependency_links.txt +0 -0
  38. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flet_charts.egg-info/top_level.txt +0 -0
  39. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flutter/flet_charts/lib/flet_charts.dart +0 -0
  40. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flutter/flet_charts/lib/src/bar_chart.dart +0 -0
  41. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flutter/flet_charts/lib/src/candlestick_chart.dart +0 -0
  42. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flutter/flet_charts/lib/src/line_chart.dart +0 -0
  43. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flutter/flet_charts/lib/src/pie_chart.dart +0 -0
  44. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flutter/flet_charts/lib/src/radar_chart.dart +0 -0
  45. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flutter/flet_charts/lib/src/scatter_chart.dart +0 -0
  46. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flutter/flet_charts/lib/src/utils/bar_chart.dart +0 -0
  47. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flutter/flet_charts/lib/src/utils/candlestick_chart.dart +0 -0
  48. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flutter/flet_charts/lib/src/utils/charts.dart +0 -0
  49. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flutter/flet_charts/lib/src/utils/pie_chart.dart +0 -0
  50. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flutter/flet_charts/lib/src/utils/radar_chart.dart +0 -0
  51. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flutter/flet_charts/lib/src/utils/scatter_chart.dart +0 -0
  52. {flet_charts-0.85.0.dev2 → flet_charts-0.85.0.dev3}/src/flutter/flet_charts/pubspec.yaml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flet-charts
3
- Version: 0.85.0.dev2
3
+ Version: 0.85.0.dev3
4
4
  Summary: Interactive chart controls for Flet apps.
5
5
  Author-email: Flet contributors <hello@flet.dev>
6
6
  License-Expression: Apache-2.0
@@ -11,7 +11,7 @@ Project-URL: Issues, https://github.com/flet-dev/flet/issues
11
11
  Requires-Python: >=3.10
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
- Requires-Dist: flet==0.85.0.dev2
14
+ Requires-Dist: flet==0.85.0.dev3
15
15
  Dynamic: license-file
16
16
 
17
17
  # flet-charts
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "flet-charts"
3
- version = "0.85.0.dev2"
3
+ version = "0.85.0.dev3"
4
4
  description = "Interactive chart controls for Flet apps."
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Flet contributors", email = "hello@flet.dev" }]
7
7
  license = "Apache-2.0"
8
8
  requires-python = ">=3.10"
9
9
  dependencies = [
10
- "flet==0.85.0.dev2",
10
+ "flet==0.85.0.dev3",
11
11
  ]
12
12
 
13
13
  [project.urls]
@@ -33,6 +33,10 @@ from flet_charts.matplotlib_chart import (
33
33
  MatplotlibChartMessageEvent,
34
34
  MatplotlibChartToolbarButtonsUpdateEvent,
35
35
  )
36
+ from flet_charts.matplotlib_chart_canvas import (
37
+ MatplotlibChartCanvas,
38
+ MatplotlibChartCanvasResizeEvent,
39
+ )
36
40
  from flet_charts.matplotlib_chart_with_toolbar import MatplotlibChartWithToolbar
37
41
  from flet_charts.pie_chart import PieChart, PieChartEvent
38
42
  from flet_charts.pie_chart_section import PieChartSection
@@ -95,6 +99,8 @@ __all__ = [
95
99
  "LineChartEventSpot",
96
100
  "LineChartTooltip",
97
101
  "MatplotlibChart",
102
+ "MatplotlibChartCanvas",
103
+ "MatplotlibChartCanvasResizeEvent",
98
104
  "MatplotlibChartMessageEvent",
99
105
  "MatplotlibChartToolbarButtonsUpdateEvent",
100
106
  "MatplotlibChartWithToolbar",
@@ -59,6 +59,10 @@ class LineChartEvent(ft.Event["LineChart"]):
59
59
  spots: list[LineChartEventSpot]
60
60
  """
61
61
  Spots on which the event occurred.
62
+
63
+ Note:
64
+ This list is empty when the event does not target a concrete point, for
65
+ example when the pointer hovers over or taps empty chart space.
62
66
  """
63
67
 
64
68
 
@@ -0,0 +1,54 @@
1
+ import asyncio
2
+
3
+ from matplotlib import _api
4
+ from matplotlib.backends import backend_webagg_core
5
+
6
+
7
+ class TimerFletAsyncio(backend_webagg_core.TimerAsyncio):
8
+ """Asyncio timer that's safe to start from a worker thread.
9
+
10
+ Matplotlib's stock `TimerAsyncio._timer_start` calls
11
+ `asyncio.ensure_future`, which requires the calling thread to have a
12
+ current event loop. Flet's matplotlib chart runs `canvas.draw()` in a
13
+ worker thread to keep the asyncio loop free for input events; that
14
+ thread has no event loop. We capture the loop at construction time and
15
+ schedule via `run_coroutine_threadsafe` when invoked from off-loop.
16
+ """
17
+
18
+ def __init__(self, *args, **kwargs):
19
+ super().__init__(*args, **kwargs)
20
+ try:
21
+ self._loop = asyncio.get_running_loop()
22
+ except RuntimeError:
23
+ self._loop = asyncio.get_event_loop_policy().get_event_loop()
24
+
25
+ def _timer_start(self):
26
+ self._timer_stop()
27
+ coro = self._timer_task(max(self.interval / 1_000.0, 1e-6))
28
+ try:
29
+ current = asyncio.get_running_loop()
30
+ except RuntimeError:
31
+ current = None
32
+ if current is self._loop:
33
+ self._task = self._loop.create_task(coro)
34
+ else:
35
+ self._task = asyncio.run_coroutine_threadsafe(coro, self._loop)
36
+
37
+
38
+ class FigureCanvasFletAgg(backend_webagg_core.FigureCanvasWebAggCore):
39
+ """Canvas implementation used to render Matplotlib figures in Flet."""
40
+
41
+ manager_class = _api.classproperty(lambda cls: FigureManagerFletAgg)
42
+ supports_blit = False
43
+ _timer_cls = TimerFletAsyncio
44
+
45
+
46
+ class FigureManagerFletAgg(backend_webagg_core.FigureManagerWebAgg):
47
+ """Figure manager binding Matplotlib WebAgg tooling to Flet transport."""
48
+
49
+ _toolbar2_class = backend_webagg_core.NavigationToolbar2WebAgg
50
+
51
+
52
+ FigureCanvas = FigureCanvasFletAgg
53
+ FigureManager = FigureManagerFletAgg
54
+ interactive = True
@@ -1,11 +1,21 @@
1
1
  import asyncio
2
2
  import logging
3
+ import sys
4
+ import threading
3
5
  from dataclasses import dataclass, field
4
6
  from io import BytesIO
5
7
  from typing import Any, Optional
6
8
 
7
9
  import flet as ft
8
- import flet.canvas as fc
10
+ from flet_charts.matplotlib_chart_canvas import (
11
+ MatplotlibChartCanvas,
12
+ MatplotlibChartCanvasResizeEvent,
13
+ )
14
+
15
+ # Pyodide / WASM has no real threads — `asyncio.to_thread` runs synchronously
16
+ # on the same thread there, providing no benefit. Fall back to a same-loop
17
+ # render path on those platforms.
18
+ _HAS_THREADS = sys.platform != "emscripten"
9
19
 
10
20
  _MATPLOTLIB_IMPORT_ERROR: Optional[ImportError] = None
11
21
 
@@ -124,13 +134,21 @@ class MatplotlibChart(ft.GestureDetector):
124
134
  logger.debug(f"DPR: {self.__dpr}")
125
135
  self.__image_mode = "full"
126
136
 
127
- self.canvas = fc.Canvas(
128
- # resize_interval=10,
137
+ self.mpl_canvas = MatplotlibChartCanvas(
129
138
  on_resize=self._on_canvas_resize,
130
139
  expand=True,
131
140
  )
141
+ # Rubberband (zoom selection) overlay drawn on top of the chart image.
142
+ self._rubberband = ft.Container(
143
+ visible=False,
144
+ border=ft.Border.all(1, ft.Colors.with_opacity(0.6, ft.Colors.GREY)),
145
+ )
146
+ self._stack = ft.Stack(
147
+ controls=[self.mpl_canvas, self._rubberband],
148
+ expand=True,
149
+ )
132
150
  self.keyboard_listener = ft.KeyboardListener(
133
- self.canvas,
151
+ self._stack,
134
152
  autofocus=True,
135
153
  on_key_down=self._on_key_down,
136
154
  on_key_up=self._on_key_up,
@@ -151,6 +169,12 @@ class MatplotlibChart(ft.GestureDetector):
151
169
  self._width = 0
152
170
  self._height = 0
153
171
  self._waiting = False
172
+ # Serializes worker-thread renders against main-thread matplotlib
173
+ # operations like `figure.savefig()` (download). matplotlib's
174
+ # print_figure temporarily nulls `canvas.manager` while saving, which
175
+ # would crash an in-flight `canvas.draw()` running in our render
176
+ # thread.
177
+ self._mpl_lock = threading.Lock()
154
178
 
155
179
  def _on_key_down(self, e: ft.KeyboardEvent) -> None:
156
180
  """
@@ -405,7 +429,8 @@ class MatplotlibChart(ft.GestureDetector):
405
429
  """
406
430
  logger.debug(f"Download in format: {format}")
407
431
  buff = BytesIO()
408
- self.figure.savefig(buff, format=format, dpi=self.figure.dpi * self.__dpr)
432
+ with self._mpl_lock:
433
+ self.figure.savefig(buff, format=format, dpi=self.figure.dpi * self.__dpr)
409
434
  return buff.getvalue()
410
435
 
411
436
  async def _receive_loop(self):
@@ -419,23 +444,41 @@ class MatplotlibChart(ft.GestureDetector):
419
444
 
420
445
  while True:
421
446
  is_binary, content = await self._receive_queue.get()
447
+
448
+ # Coalesce stale items so interaction stays snappy:
449
+ # - Drop a binary frame if a newer one is queued behind it.
450
+ # - Drop a "draw" request if another is queued — the latest one
451
+ # will trigger the render with the most up-to-date state.
452
+ # Without this, every pointer event during pan/zoom triggers its
453
+ # own render and the chart visibly "plays back" buffered motion
454
+ # after the user releases the mouse.
422
455
  if is_binary:
456
+ if any(it[0] for it in self._receive_queue._queue):
457
+ continue
458
+ elif (
459
+ isinstance(content, dict)
460
+ and content.get("type") == "draw"
461
+ and any(
462
+ not it[0]
463
+ and isinstance(it[1], dict)
464
+ and it[1].get("type") == "draw"
465
+ for it in self._receive_queue._queue
466
+ )
467
+ ):
468
+ continue
469
+
470
+ if is_binary:
471
+ assert isinstance(content, (bytes, bytearray))
423
472
  logger.debug(f"receive_binary({len(content)})")
424
- if self.__image_mode == "full":
425
- await self.canvas.clear_capture()
426
-
427
- self.canvas.shapes = [
428
- fc.Image(
429
- src=content,
430
- x=0,
431
- y=0,
432
- width=self.figure.bbox.size[0] / self.__dpr,
433
- height=self.figure.bbox.size[1] / self.__dpr,
434
- )
435
- ]
436
- ft.context.disable_auto_update()
437
- self.canvas.update()
438
- await self.canvas.capture()
473
+ is_full = self.__image_mode == "full"
474
+ # Hand the frame to the client widget — full PNG replaces the
475
+ # backbuffer, diff PNG composites onto it. Awaiting naturally
476
+ # rate-limits this loop to the client's processing speed and
477
+ # yields the asyncio loop for incoming events.
478
+ if is_full:
479
+ await self.mpl_canvas.apply_full(bytes(content))
480
+ else:
481
+ await self.mpl_canvas.apply_diff(bytes(content))
439
482
  self.img_count += 1
440
483
  self._waiting = False
441
484
  else:
@@ -447,10 +490,23 @@ class MatplotlibChart(ft.GestureDetector):
447
490
  self.update()
448
491
  elif content["type"] == "draw" and not self._waiting:
449
492
  self._waiting = True
450
- self.send_message({"type": "draw"})
493
+ if _HAS_THREADS:
494
+ # Native runtime: render in a worker thread so the
495
+ # asyncio loop stays free for input events. handle_draw
496
+ # ends up in Agg/PIL C code that releases the GIL, so
497
+ # threading is effective. _waiting + the queue-dedupe
498
+ # above ensure only one render is ever in flight.
499
+ # The lock prevents overlap with main-thread savefig.
500
+ asyncio.create_task(asyncio.to_thread(self._draw_locked))
501
+ else:
502
+ # Pyodide / WASM: no real threads available. Render
503
+ # synchronously on the loop. Yield first so any
504
+ # backed-up pointer events can update matplotlib state
505
+ # before the (blocking) render runs.
506
+ for _ in range(10):
507
+ await asyncio.sleep(0)
508
+ self.send_message({"type": "draw"})
451
509
  elif content["type"] == "rubberband":
452
- if len(self.canvas.shapes) == 2:
453
- self.canvas.shapes.pop()
454
510
  if (
455
511
  content["x0"] != -1
456
512
  and content["y0"] != -1
@@ -461,18 +517,14 @@ class MatplotlibChart(ft.GestureDetector):
461
517
  y0 = self._height - content["y0"] / self.__dpr
462
518
  x1 = content["x1"] / self.__dpr
463
519
  y1 = self._height - content["y1"] / self.__dpr
464
- self.canvas.shapes.append(
465
- fc.Rect(
466
- x=x0,
467
- y=y0,
468
- width=x1 - x0,
469
- height=y1 - y0,
470
- paint=ft.Paint(
471
- stroke_width=1, style=ft.PaintingStyle.STROKE
472
- ),
473
- )
474
- )
475
- self.canvas.update()
520
+ self._rubberband.left = min(x0, x1)
521
+ self._rubberband.top = min(y0, y1)
522
+ self._rubberband.width = abs(x1 - x0)
523
+ self._rubberband.height = abs(y1 - y0)
524
+ self._rubberband.visible = True
525
+ else:
526
+ self._rubberband.visible = False
527
+ self._rubberband.update()
476
528
  elif content["type"] == "resize":
477
529
  self.send_message({"type": "refresh"})
478
530
  elif content["type"] == "message":
@@ -495,6 +547,16 @@ class MatplotlibChart(ft.GestureDetector):
495
547
  if manager is not None:
496
548
  manager.handle_json(message)
497
549
 
550
+ def _draw_locked(self):
551
+ """Worker-thread entry point for triggering a render.
552
+
553
+ Holds `_mpl_lock` for the duration of the synchronous draw so it
554
+ can't overlap with main-thread `figure.savefig()`, which temporarily
555
+ nulls `canvas.manager` and would crash an in-flight render.
556
+ """
557
+ with self._mpl_lock:
558
+ self.send_message({"type": "draw"})
559
+
498
560
  def send_json(self, content):
499
561
  """Sends a JSON message to the front end."""
500
562
  logger.debug(f"send_json: {content}")
@@ -508,7 +570,7 @@ class MatplotlibChart(ft.GestureDetector):
508
570
  lambda: self._receive_queue.put_nowait((True, blob))
509
571
  )
510
572
 
511
- async def _on_canvas_resize(self, e: fc.CanvasResizeEvent):
573
+ async def _on_canvas_resize(self, e: MatplotlibChartCanvasResizeEvent):
512
574
  """
513
575
  Handle canvas resize and initialize backend session on first resize.
514
576
 
@@ -0,0 +1,72 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Optional
3
+
4
+ import flet as ft
5
+
6
+ __all__ = ["MatplotlibChartCanvas", "MatplotlibChartCanvasResizeEvent"]
7
+
8
+
9
+ @dataclass
10
+ class MatplotlibChartCanvasResizeEvent(ft.Event["MatplotlibChartCanvas"]):
11
+ """
12
+ Event emitted when the canvas reports a new rendered size.
13
+ """
14
+
15
+ width: float = field(metadata={"data_field": "w"})
16
+ """New width of the canvas in logical pixels."""
17
+
18
+ height: float = field(metadata={"data_field": "h"})
19
+ """New height of the canvas in logical pixels."""
20
+
21
+
22
+ @ft.control("MatplotlibChartCanvas")
23
+ class MatplotlibChartCanvas(ft.LayoutControl):
24
+ """
25
+ Display widget for matplotlib WebAgg-style image streams.
26
+
27
+ Receives full and incremental "diff" PNG frames and composites them in
28
+ CPU memory, holding at most one decoded image for display at a time.
29
+ Avoids the per-frame `Picture.toImage` allocations that the generic
30
+ `flet.canvas.Canvas` capture path uses, which on Flutter web
31
+ (CanvasKit/WASM) accumulate and aren't promptly reclaimed by the JS GC
32
+ during animation, causing browser memory growth.
33
+ """
34
+
35
+ resize_interval: ft.Number = 10
36
+ """
37
+ Sampling interval in milliseconds for `on_resize` event.
38
+ """
39
+
40
+ on_resize: Optional[ft.EventHandler[MatplotlibChartCanvasResizeEvent]] = None
41
+ """
42
+ Called when the size of this canvas has changed.
43
+ """
44
+
45
+ async def apply_full(self, image_bytes: bytes) -> None:
46
+ """
47
+ Replace the current displayed image with a full PNG frame.
48
+
49
+ Args:
50
+ image_bytes: PNG bytes of the complete frame.
51
+ """
52
+ await self._invoke_method("apply_full", arguments={"bytes": image_bytes})
53
+
54
+ async def apply_diff(self, image_bytes: bytes) -> None:
55
+ """
56
+ Composite an incremental "diff" PNG frame onto the current image.
57
+
58
+ Pixels with non-zero alpha replace the corresponding pixels in the
59
+ existing backbuffer; transparent pixels leave the backbuffer
60
+ unchanged. If no backbuffer exists yet, the diff is treated as a
61
+ full frame.
62
+
63
+ Args:
64
+ image_bytes: PNG bytes of the diff frame.
65
+ """
66
+ await self._invoke_method("apply_diff", arguments={"bytes": image_bytes})
67
+
68
+ async def clear(self) -> None:
69
+ """
70
+ Clear the displayed image and discard the backbuffer.
71
+ """
72
+ await self._invoke_method("clear")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flet-charts
3
- Version: 0.85.0.dev2
3
+ Version: 0.85.0.dev3
4
4
  Summary: Interactive chart controls for Flet apps.
5
5
  Author-email: Flet contributors <hello@flet.dev>
6
6
  License-Expression: Apache-2.0
@@ -11,7 +11,7 @@ Project-URL: Issues, https://github.com/flet-dev/flet/issues
11
11
  Requires-Python: >=3.10
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
- Requires-Dist: flet==0.85.0.dev2
14
+ Requires-Dist: flet==0.85.0.dev3
15
15
  Dynamic: license-file
16
16
 
17
17
  # flet-charts
@@ -13,6 +13,7 @@ src/flet_charts/line_chart.py
13
13
  src/flet_charts/line_chart_data.py
14
14
  src/flet_charts/line_chart_data_point.py
15
15
  src/flet_charts/matplotlib_chart.py
16
+ src/flet_charts/matplotlib_chart_canvas.py
16
17
  src/flet_charts/matplotlib_chart_with_toolbar.py
17
18
  src/flet_charts/pie_chart.py
18
19
  src/flet_charts/pie_chart_section.py
@@ -34,6 +35,7 @@ src/flutter/flet_charts/lib/src/bar_chart.dart
34
35
  src/flutter/flet_charts/lib/src/candlestick_chart.dart
35
36
  src/flutter/flet_charts/lib/src/extension.dart
36
37
  src/flutter/flet_charts/lib/src/line_chart.dart
38
+ src/flutter/flet_charts/lib/src/matplotlib_chart_canvas.dart
37
39
  src/flutter/flet_charts/lib/src/pie_chart.dart
38
40
  src/flutter/flet_charts/lib/src/radar_chart.dart
39
41
  src/flutter/flet_charts/lib/src/scatter_chart.dart
@@ -0,0 +1 @@
1
+ flet==0.85.0.dev3
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
4
4
  import 'bar_chart.dart';
5
5
  import 'candlestick_chart.dart';
6
6
  import 'line_chart.dart';
7
+ import 'matplotlib_chart_canvas.dart';
7
8
  import 'radar_chart.dart';
8
9
  import 'pie_chart.dart';
9
10
  import 'scatter_chart.dart';
@@ -18,6 +19,8 @@ class Extension extends FletExtension {
18
19
  return CandlestickChartControl(key: key, control: control);
19
20
  case "LineChart":
20
21
  return LineChartControl(key: key, control: control);
22
+ case "MatplotlibChartCanvas":
23
+ return MatplotlibChartCanvasControl(key: key, control: control);
21
24
  case "RadarChart":
22
25
  return RadarChartControl(key: key, control: control);
23
26
  case "PieChart":
@@ -0,0 +1,276 @@
1
+ import 'dart:async';
2
+ import 'dart:typed_data';
3
+ import 'dart:ui' as ui;
4
+
5
+ import 'package:flet/flet.dart';
6
+ import 'package:flutter/material.dart';
7
+
8
+ /// Display widget for matplotlib WebAgg-style image streams.
9
+ ///
10
+ /// Receives full and incremental "diff" PNG frames via control method calls
11
+ /// and composites them in CPU memory. Holds at most one [ui.Image] for
12
+ /// display at a time, replacing it on each apply. This avoids the per-frame
13
+ /// `Picture.toImage` allocations that the generic Canvas+capture path uses,
14
+ /// which on Flutter web (CanvasKit/WASM) accumulate and are not promptly
15
+ /// reclaimed by the JS GC during animations.
16
+ class MatplotlibChartCanvasControl extends StatefulWidget {
17
+ final Control control;
18
+
19
+ MatplotlibChartCanvasControl({Key? key, required this.control})
20
+ : super(key: key ?? ValueKey("control_${control.id}"));
21
+
22
+ @override
23
+ State<MatplotlibChartCanvasControl> createState() =>
24
+ _MatplotlibChartCanvasState();
25
+ }
26
+
27
+ class _MatplotlibChartCanvasState extends State<MatplotlibChartCanvasControl> {
28
+ ui.Image? _displayImage;
29
+ Uint8List? _backbuffer;
30
+ int _bbWidth = 0;
31
+ int _bbHeight = 0;
32
+
33
+ // Serialize concurrent apply_full / apply_diff calls. Each invocation
34
+ // awaits the previous one so the backbuffer mutations happen in order.
35
+ Future<void>? _applyChain;
36
+
37
+ Size _lastSize = Size.zero;
38
+ int _lastResize = DateTime.now().millisecondsSinceEpoch;
39
+
40
+ @override
41
+ void initState() {
42
+ super.initState();
43
+ widget.control.addInvokeMethodListener(_invokeMethod);
44
+ }
45
+
46
+ @override
47
+ void dispose() {
48
+ widget.control.removeInvokeMethodListener(_invokeMethod);
49
+ _displayImage?.dispose();
50
+ _displayImage = null;
51
+ _backbuffer = null;
52
+ super.dispose();
53
+ }
54
+
55
+ Future<dynamic> _invokeMethod(String name, dynamic args) async {
56
+ switch (name) {
57
+ case "apply_full":
58
+ await _enqueue(() => _applyFull(_extractBytes(args)));
59
+ return;
60
+ case "apply_diff":
61
+ await _enqueue(() => _applyDiff(_extractBytes(args)));
62
+ return;
63
+ case "clear":
64
+ await _enqueue(() async {
65
+ _disposeDisplay();
66
+ _backbuffer = null;
67
+ _bbWidth = 0;
68
+ _bbHeight = 0;
69
+ if (mounted) setState(() {});
70
+ });
71
+ return;
72
+ default:
73
+ throw Exception("Unknown MatplotlibChartCanvas method: $name");
74
+ }
75
+ }
76
+
77
+ Uint8List _extractBytes(dynamic args) {
78
+ final v = args is Map ? args["bytes"] : args;
79
+ if (v is Uint8List) return v;
80
+ if (v is ByteData) {
81
+ return v.buffer.asUint8List(v.offsetInBytes, v.lengthInBytes);
82
+ }
83
+ if (v is List<int>) return Uint8List.fromList(v);
84
+ if (v is List && v.every((e) => e is int)) {
85
+ return Uint8List.fromList(v.cast<int>());
86
+ }
87
+ throw ArgumentError("Expected bytes for image data, got ${v.runtimeType}");
88
+ }
89
+
90
+ // Chains apply operations so they run sequentially. Without this,
91
+ // overlapping awaits could let a later diff be composited before an
92
+ // earlier full frame finished decoding, producing tearing.
93
+ Future<void> _enqueue(Future<void> Function() task) {
94
+ final prev = _applyChain ?? Future.value();
95
+ final next = prev.then((_) => task());
96
+ _applyChain = next.catchError((_) {});
97
+ return next;
98
+ }
99
+
100
+ Future<void> _applyFull(Uint8List bytes) async {
101
+ final decoded = await _decodeRgba(bytes);
102
+ if (decoded == null) return;
103
+
104
+ _backbuffer = decoded.bytes;
105
+ _bbWidth = decoded.width;
106
+ _bbHeight = decoded.height;
107
+
108
+ final image = await _makeImage(decoded.bytes, decoded.width, decoded.height);
109
+ _swapDisplay(image);
110
+ }
111
+
112
+ Future<void> _applyDiff(Uint8List bytes) async {
113
+ if (_backbuffer == null) {
114
+ // No baseline yet — treat as full so we don't render a transparent
115
+ // diff with no underlying frame.
116
+ await _applyFull(bytes);
117
+ return;
118
+ }
119
+
120
+ final decoded = await _decodeRgba(bytes);
121
+ if (decoded == null) return;
122
+
123
+ // Diffs from matplotlib are sized to the figure buffer. If the frame
124
+ // size has changed since the last full frame (e.g. resize race),
125
+ // promote to a full replace.
126
+ if (decoded.width != _bbWidth ||
127
+ decoded.height != _bbHeight ||
128
+ decoded.bytes.length != _backbuffer!.length) {
129
+ _backbuffer = decoded.bytes;
130
+ _bbWidth = decoded.width;
131
+ _bbHeight = decoded.height;
132
+ final image =
133
+ await _makeImage(decoded.bytes, decoded.width, decoded.height);
134
+ _swapDisplay(image);
135
+ return;
136
+ }
137
+
138
+ // Composite: matplotlib's diff PNG has alpha=0 for unchanged pixels.
139
+ // Where alpha != 0, copy the new pixel into the backbuffer.
140
+ final bb = _backbuffer!.buffer.asUint32List();
141
+ final df = decoded.bytes.buffer.asUint32List();
142
+ for (int i = 0; i < df.length; i++) {
143
+ // RGBA8888 on little-endian: alpha is the highest byte (0xFF000000).
144
+ if ((df[i] & 0xFF000000) != 0) {
145
+ bb[i] = df[i];
146
+ }
147
+ }
148
+
149
+ final image = await _makeImage(_backbuffer!, _bbWidth, _bbHeight);
150
+ _swapDisplay(image);
151
+ }
152
+
153
+ Future<_DecodedRgba?> _decodeRgba(Uint8List bytes) async {
154
+ if (bytes.isEmpty) {
155
+ debugPrint("MatplotlibChartCanvas: skipping empty image bytes");
156
+ return null;
157
+ }
158
+ // Take a defensive copy. msgpack_dart sometimes hands us a Uint8List
159
+ // backed by a buffer that's reused/freed by Safari's WASM runtime,
160
+ // causing CanvasKit's async decoder to throw "EncodingError: Loading
161
+ // error." after the original buffer is gone.
162
+ final owned = Uint8List.fromList(bytes);
163
+ ui.Codec? codec;
164
+ ui.Image? img;
165
+ try {
166
+ codec = await ui.instantiateImageCodec(owned, allowUpscaling: false);
167
+ final frame = await codec.getNextFrame();
168
+ img = frame.image;
169
+ final byteData =
170
+ await img.toByteData(format: ui.ImageByteFormat.rawRgba);
171
+ if (byteData == null) return null;
172
+ return _DecodedRgba(
173
+ bytes: byteData.buffer.asUint8List(),
174
+ width: img.width,
175
+ height: img.height,
176
+ );
177
+ } catch (e) {
178
+ debugPrint(
179
+ "MatplotlibChartCanvas: decode failed (${owned.length} bytes): $e");
180
+ rethrow;
181
+ } finally {
182
+ img?.dispose();
183
+ codec?.dispose();
184
+ }
185
+ }
186
+
187
+ Future<ui.Image> _makeImage(Uint8List rgba, int width, int height) {
188
+ final completer = Completer<ui.Image>();
189
+ ui.decodeImageFromPixels(
190
+ rgba,
191
+ width,
192
+ height,
193
+ ui.PixelFormat.rgba8888,
194
+ completer.complete,
195
+ );
196
+ return completer.future;
197
+ }
198
+
199
+ void _swapDisplay(ui.Image newImage) {
200
+ final old = _displayImage;
201
+ _displayImage = newImage;
202
+ if (mounted) setState(() {});
203
+ if (old != null) {
204
+ // Defer disposal to the next frame so any in-flight paint that still
205
+ // references the old image completes first.
206
+ WidgetsBinding.instance.addPostFrameCallback((_) {
207
+ old.dispose();
208
+ });
209
+ }
210
+ }
211
+
212
+ void _disposeDisplay() {
213
+ final old = _displayImage;
214
+ _displayImage = null;
215
+ if (old != null) {
216
+ WidgetsBinding.instance.addPostFrameCallback((_) {
217
+ old.dispose();
218
+ });
219
+ }
220
+ }
221
+
222
+ void _maybeReportResize(Size size) {
223
+ final resizeInterval = widget.control.getInt("resize_interval", 10)!;
224
+ final now = DateTime.now().millisecondsSinceEpoch;
225
+ if ((now - _lastResize > resizeInterval && _lastSize != size) ||
226
+ _lastSize.isEmpty) {
227
+ _lastSize = size;
228
+ _lastResize = now;
229
+ widget.control.triggerEvent("resize", {"w": size.width, "h": size.height});
230
+ }
231
+ }
232
+
233
+ @override
234
+ Widget build(BuildContext context) {
235
+ return LayoutBuilder(
236
+ builder: (context, constraints) {
237
+ // Fire on_resize on layout. matplotlib uses this to know the target
238
+ // figure size.
239
+ WidgetsBinding.instance.addPostFrameCallback((_) {
240
+ if (!mounted) return;
241
+ _maybeReportResize(constraints.biggest);
242
+ });
243
+ return CustomPaint(
244
+ size: constraints.biggest,
245
+ painter: _MatplotlibImagePainter(_displayImage),
246
+ );
247
+ },
248
+ );
249
+ }
250
+ }
251
+
252
+ class _DecodedRgba {
253
+ final Uint8List bytes;
254
+ final int width;
255
+ final int height;
256
+ _DecodedRgba({required this.bytes, required this.width, required this.height});
257
+ }
258
+
259
+ class _MatplotlibImagePainter extends CustomPainter {
260
+ final ui.Image? image;
261
+
262
+ _MatplotlibImagePainter(this.image);
263
+
264
+ @override
265
+ void paint(Canvas canvas, Size size) {
266
+ final img = image;
267
+ if (img == null) return;
268
+ final src =
269
+ Rect.fromLTWH(0, 0, img.width.toDouble(), img.height.toDouble());
270
+ final dst = Rect.fromLTWH(0, 0, size.width, size.height);
271
+ canvas.drawImageRect(img, src, dst, Paint());
272
+ }
273
+
274
+ @override
275
+ bool shouldRepaint(_MatplotlibImagePainter old) => old.image != image;
276
+ }
@@ -25,7 +25,7 @@ class LineChartEventData extends Equatable {
25
25
 
26
26
  Map<String, dynamic> toMap() => <String, dynamic>{
27
27
  'type': eventType,
28
- 'spots': barSpots,
28
+ 'spots': barSpots.map((spot) => spot.toMap()).toList(),
29
29
  };
30
30
 
31
31
  @override
@@ -1,20 +0,0 @@
1
- from matplotlib import _api
2
- from matplotlib.backends import backend_webagg_core
3
-
4
-
5
- class FigureCanvasFletAgg(backend_webagg_core.FigureCanvasWebAggCore):
6
- """Canvas implementation used to render Matplotlib figures in Flet."""
7
-
8
- manager_class = _api.classproperty(lambda cls: FigureManagerFletAgg)
9
- supports_blit = False
10
-
11
-
12
- class FigureManagerFletAgg(backend_webagg_core.FigureManagerWebAgg):
13
- """Figure manager binding Matplotlib WebAgg tooling to Flet transport."""
14
-
15
- _toolbar2_class = backend_webagg_core.NavigationToolbar2WebAgg
16
-
17
-
18
- FigureCanvas = FigureCanvasFletAgg
19
- FigureManager = FigureManagerFletAgg
20
- interactive = True
@@ -1 +0,0 @@
1
- flet==0.85.0.dev2