grimoireplot 0.0.2__tar.gz → 0.0.3__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.
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: grimoireplot
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: GrimoirePlot is a live dashboard of plotly-compatible plots of remote data
5
5
  Author: William Droz
6
6
  Author-email: William Droz <william.droz@idiap.ch>
7
7
  Classifier: License :: OSI Approved :: MIT License
8
8
  Requires-Dist: aiohttp[speedups]>=3.13.3
9
9
  Requires-Dist: loguru>=0.7.3
10
- Requires-Dist: nicegui>=3.5.0,<4
10
+ Requires-Dist: nicegui>=3.14.0,<4
11
11
  Requires-Dist: plotly>=6.5.2
12
12
  Requires-Dist: python-dotenv>=1.2.1
13
13
  Requires-Dist: requests>=2.32.5
@@ -0,0 +1,212 @@
1
+ # SPDX-FileCopyrightText: Copyright © 2026 Idiap Research Institute <contact@idiap.ch>
2
+ # SPDX-FileContributor: William Droz <william.droz@idiap.ch>
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """Live Plotly updates using NiceGUI's built-in plotly APIs.
6
+
7
+ Follows the patterns from NiceGUI's plotly documentation:
8
+ https://nicegui.io/documentation/plotly#plot_updates
9
+
10
+ - Dict figures with mutable trace lists (not go.Figure) for extendTraces
11
+ - ``plot.run_plot_method('extendTraces', ...)`` for append-only live data
12
+ - ``plot.run_plot_method('relayout', ...)`` for layout-only changes
13
+ - Mutate ``plot.figure`` in place + ``plot.update()`` for structural changes
14
+ (NiceGUI calls Plotly.react on the client when config is unchanged)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any
20
+
21
+ from nicegui import ui
22
+
23
+ from grimoireplot.ui_elements import style_plotly_figure
24
+
25
+
26
+ def plot_uirevision(grimoire_name: str, chapter_name: str, plot_name: str) -> str:
27
+ """Stable uirevision key so Plotly preserves axis ranges across updates."""
28
+ return f"{grimoire_name}/{chapter_name}/{plot_name}"
29
+
30
+
31
+ def normalize_figure_dict(fig_data: dict) -> dict:
32
+ """Return a dict figure suitable for NiceGUI live updates.
33
+
34
+ NiceGUI recommends the declarative dict interface so trace ``x``/``y`` stay
35
+ mutable lists that can be appended and synced with extendTraces.
36
+ """
37
+ normalized: dict[str, Any] = {
38
+ "data": [],
39
+ "layout": dict(fig_data.get("layout", {})),
40
+ }
41
+ if "config" in fig_data:
42
+ normalized["config"] = dict(fig_data["config"])
43
+
44
+ for trace in fig_data.get("data", []):
45
+ trace_copy = dict(trace)
46
+ for axis in ("x", "y"):
47
+ values = trace.get(axis)
48
+ if isinstance(values, list):
49
+ trace_copy[axis] = list(values)
50
+ normalized["data"].append(trace_copy)
51
+
52
+ return normalized
53
+
54
+
55
+ def prepare_figure(fig_data: dict, uirevision: str) -> dict:
56
+ """Normalize, theme, and set uirevision on incoming figure data."""
57
+ styled = style_plotly_figure(normalize_figure_dict(fig_data))
58
+ styled["layout"]["uirevision"] = uirevision
59
+ return styled
60
+
61
+
62
+ def _title_text(layout: dict) -> str | None:
63
+ title = layout.get("title")
64
+ if isinstance(title, dict):
65
+ return title.get("text")
66
+ if isinstance(title, str):
67
+ return title
68
+ return None
69
+
70
+
71
+ def _axis_title_text(layout: dict, axis: str) -> str | None:
72
+ axis_layout = layout.get(axis, {})
73
+ if not isinstance(axis_layout, dict):
74
+ return None
75
+ title = axis_layout.get("title")
76
+ if isinstance(title, dict):
77
+ return title.get("text")
78
+ if isinstance(title, str):
79
+ return title
80
+ return None
81
+
82
+
83
+ def layout_relayout_diff(old_layout: dict, new_layout: dict) -> dict[str, Any]:
84
+ """Return plotly.js relayout keys for layout fields that changed."""
85
+ diff: dict[str, Any] = {}
86
+
87
+ new_title = _title_text(new_layout)
88
+ if _title_text(old_layout) != new_title:
89
+ diff["title.text"] = new_title
90
+
91
+ for axis in ("xaxis", "yaxis"):
92
+ new_axis_title = _axis_title_text(new_layout, axis)
93
+ if _axis_title_text(old_layout, axis) != new_axis_title:
94
+ diff[f"{axis}.title.text"] = new_axis_title
95
+
96
+ return diff
97
+
98
+
99
+ def _trace_type(trace: dict) -> str:
100
+ return trace.get("type", "scatter")
101
+
102
+
103
+ def trace_data_appended(
104
+ old_trace: dict, new_trace: dict
105
+ ) -> tuple[list[Any], list[Any]] | None:
106
+ """Return appended (x, y) if new_trace extends old_trace, else None."""
107
+ if _trace_type(old_trace) != _trace_type(new_trace):
108
+ return None
109
+
110
+ old_x, old_y = old_trace.get("x", []), old_trace.get("y", [])
111
+ new_x, new_y = new_trace.get("x", []), new_trace.get("y", [])
112
+
113
+ if not isinstance(old_x, list) or not isinstance(new_x, list):
114
+ return None
115
+ if not isinstance(old_y, list) or not isinstance(new_y, list):
116
+ return None
117
+ if len(new_x) < len(old_x) or len(new_y) < len(old_y):
118
+ return None
119
+ if new_x[: len(old_x)] != old_x or new_y[: len(old_y)] != old_y:
120
+ return None
121
+
122
+ return (new_x[len(old_x) :], new_y[len(old_y) :])
123
+
124
+
125
+ def _figure_dict(chart: ui.plotly) -> dict | None:
126
+ figure = chart.figure
127
+ return figure if isinstance(figure, dict) else None
128
+
129
+
130
+ def try_extend_traces_update(
131
+ chart: ui.plotly, new_fig_data: dict, uirevision: str
132
+ ) -> bool:
133
+ """Use NiceGUI's run_plot_method('extendTraces') when traces only grow."""
134
+ current = _figure_dict(chart)
135
+ if current is None:
136
+ return False
137
+
138
+ styled = prepare_figure(new_fig_data, uirevision)
139
+
140
+ old_data = current.get("data", [])
141
+ new_data = styled.get("data", [])
142
+ if len(old_data) != len(new_data) or not old_data:
143
+ return False
144
+
145
+ extend_x: list[list[Any]] = []
146
+ extend_y: list[list[Any]] = []
147
+ trace_indices: list[int] = []
148
+
149
+ for index, (old_trace, new_trace) in enumerate(zip(old_data, new_data)):
150
+ appended = trace_data_appended(old_trace, new_trace)
151
+ if appended is None:
152
+ return False
153
+ added_x, added_y = appended
154
+ if added_x:
155
+ extend_x.append(added_x)
156
+ extend_y.append(added_y)
157
+ trace_indices.append(index)
158
+
159
+ relayout_diff = layout_relayout_diff(
160
+ current.get("layout", {}), styled.get("layout", {})
161
+ )
162
+
163
+ if not trace_indices and not relayout_diff:
164
+ return True
165
+
166
+ if trace_indices:
167
+ chart.run_plot_method(
168
+ "extendTraces", {"x": extend_x, "y": extend_y}, trace_indices
169
+ )
170
+ for trace_index, added_x, added_y in zip(trace_indices, extend_x, extend_y):
171
+ current["data"][trace_index]["x"].extend(added_x)
172
+ current["data"][trace_index]["y"].extend(added_y)
173
+
174
+ if relayout_diff:
175
+ chart.run_plot_method("relayout", relayout_diff)
176
+
177
+ current.setdefault("layout", {}).update(styled["layout"])
178
+ current["layout"]["uirevision"] = uirevision
179
+ if "config" in styled:
180
+ current["config"] = styled["config"]
181
+
182
+ return True
183
+
184
+
185
+ def mutate_figure_and_update(
186
+ chart: ui.plotly, new_fig_data: dict, uirevision: str
187
+ ) -> None:
188
+ """Mutate plot.figure in place and call plot.update() (Plotly.react on client)."""
189
+ styled = prepare_figure(new_fig_data, uirevision)
190
+
191
+ figure = _figure_dict(chart)
192
+ if figure is None:
193
+ chart.figure = styled
194
+ chart.update()
195
+ return
196
+
197
+ figure["data"] = styled["data"]
198
+ figure["layout"] = styled["layout"]
199
+ if "config" in styled:
200
+ figure["config"] = styled["config"]
201
+ chart.update()
202
+
203
+
204
+ def update_plotly_chart(chart: ui.plotly, new_fig_data: dict, uirevision: str) -> None:
205
+ """Pick the lightest NiceGUI/Plotly update path for the incoming figure."""
206
+ if not try_extend_traces_update(chart, new_fig_data, uirevision):
207
+ mutate_figure_and_update(chart, new_fig_data, uirevision)
208
+
209
+
210
+ # Backwards-compatible aliases used by ui.py
211
+ try_incremental_update = try_extend_traces_update
212
+ apply_full_chart_update = mutate_figure_and_update
@@ -2,6 +2,7 @@
2
2
  # SPDX-FileContributor: William Droz <william.droz@idiap.ch>
3
3
  # SPDX-License-Identifier: MIT
4
4
 
5
+ import json
5
6
  import time
6
7
  import logging
7
8
  from functools import wraps
@@ -18,7 +19,7 @@ from grimoireplot.models import (
18
19
  delete_grimoire,
19
20
  )
20
21
 
21
- from grimoireplot.ui import dashboard_ui, refresh_chapter_plots
22
+ from grimoireplot.ui import dashboard_ui, refresh_chapter_plots, update_plot_chart
22
23
  from grimoireplot.ui_elements import setup_theme
23
24
 
24
25
  _GRIMOIRE_SECRET = get_grimoire_secret()
@@ -82,8 +83,17 @@ def my_app(host: str = "localhost", port: int = 8080):
82
83
  plot_name=add_plot_request.plot_name,
83
84
  json_data=add_plot_request.json_data,
84
85
  )
85
- # Try to refresh only the specific chapter's plots
86
- # If the chapter doesn't exist in UI yet (new grimoire/chapter), refresh the whole dashboard
86
+ # Update existing charts in place for live plots; fall back to UI refresh for new plots
87
+ fig_data = json.loads(add_plot_request.json_data)
88
+ if update_plot_chart(
89
+ add_plot_request.grimoire_name,
90
+ add_plot_request.chapter_name,
91
+ add_plot_request.plot_name,
92
+ fig_data,
93
+ ):
94
+ return {"status": "success", "plot_name": plot.name}
95
+
96
+ # New plot or chapter not yet rendered: refresh chapter or whole dashboard
87
97
  if not refresh_chapter_plots(
88
98
  add_plot_request.grimoire_name, add_plot_request.chapter_name
89
99
  ):
@@ -32,10 +32,55 @@ from grimoireplot.ui_elements import (
32
32
  create_btn_ghost,
33
33
  create_btn_danger,
34
34
  )
35
+ from grimoireplot.plot_live_update import plot_uirevision, update_plotly_chart
35
36
 
36
37
  # Store refreshable instances for each chapter's plots
37
38
  _chapter_plot_refreshables: dict[tuple[str, str], ui.refreshable] = {}
38
39
 
40
+ # Live plot charts keyed by (grimoire_name, chapter_name, plot_name)
41
+ _plot_charts: dict[tuple[str, str, str], ui.plotly] = {}
42
+
43
+
44
+ def _plot_key(
45
+ grimoire_name: str, chapter_name: str, plot_name: str
46
+ ) -> tuple[str, str, str]:
47
+ return (grimoire_name, chapter_name, plot_name)
48
+
49
+
50
+ def clear_plot_charts_for_chapter(grimoire_name: str, chapter_name: str) -> None:
51
+ """Remove chart references for a chapter before it is re-rendered."""
52
+ keys_to_remove = [
53
+ key
54
+ for key in _plot_charts
55
+ if key[0] == grimoire_name and key[1] == chapter_name
56
+ ]
57
+ for key in keys_to_remove:
58
+ _plot_charts.pop(key, None)
59
+
60
+
61
+ def clear_all_plot_charts() -> None:
62
+ """Remove all live plot chart references."""
63
+ _plot_charts.clear()
64
+
65
+
66
+ def update_plot_chart(
67
+ grimoire_name: str, chapter_name: str, plot_name: str, fig_data: dict
68
+ ) -> bool:
69
+ """Update an existing plot chart using NiceGUI's built-in plotly APIs.
70
+
71
+ Returns True if the chart was found and updated, False if a full refresh is needed.
72
+ """
73
+ chart = _plot_charts.get(_plot_key(grimoire_name, chapter_name, plot_name))
74
+ if chart is None:
75
+ return False
76
+
77
+ update_plotly_chart(
78
+ chart,
79
+ fig_data,
80
+ plot_uirevision(grimoire_name, chapter_name, plot_name),
81
+ )
82
+ return True
83
+
39
84
 
40
85
  def refresh_chapter_plots(grimoire_name: str, chapter_name: str) -> bool:
41
86
  """Refresh only the plots for a specific chapter.
@@ -44,6 +89,7 @@ def refresh_chapter_plots(grimoire_name: str, chapter_name: str) -> bool:
44
89
  """
45
90
  key = (grimoire_name, chapter_name)
46
91
  if key in _chapter_plot_refreshables:
92
+ clear_plot_charts_for_chapter(grimoire_name, chapter_name)
47
93
  _chapter_plot_refreshables[key].refresh()
48
94
  return True
49
95
  return False
@@ -63,6 +109,7 @@ def clear_chapter_refreshable(grimoire_name: str, chapter_name: str):
63
109
  def clear_all_chapter_refreshables():
64
110
  """Clear all chapter refreshables (called on full dashboard refresh)."""
65
111
  _chapter_plot_refreshables.clear()
112
+ clear_all_plot_charts()
66
113
 
67
114
 
68
115
  def _confirm_delete(name: str, delete_fn, on_deleted=None):
@@ -188,8 +235,12 @@ def render_plot(plot: Plot, grimoire_name: str, chapter_name: str):
188
235
 
189
236
  try:
190
237
  fig = json.loads(plot.json_data)
238
+ fig.setdefault("layout", {})["uirevision"] = plot_uirevision(
239
+ grimoire_name, chapter_name, plot.name
240
+ )
191
241
  with create_plot_container():
192
- create_plotly_chart(fig)
242
+ chart = create_plotly_chart(fig)
243
+ _plot_charts[_plot_key(grimoire_name, chapter_name, plot.name)] = chart
193
244
  create_delete_badge(
194
245
  lambda _, g=grimoire_name, c=chapter_name, p=plot: _confirm_delete(
195
246
  p.name, lambda name: delete_plot(g, c, name), on_deleted=on_deleted
@@ -649,18 +649,8 @@ def create_confirm_dialog(
649
649
  # ============================================================================
650
650
 
651
651
 
652
- def create_plotly_chart(fig_data: dict) -> ui.plotly:
653
- """Create a styled Plotly chart.
654
-
655
- Applies dark theme styling to the chart.
656
-
657
- Args:
658
- fig_data: Plotly figure data dict.
659
-
660
- Returns:
661
- Styled plotly component.
662
- """
663
- # Ensure dark theme layout
652
+ def style_plotly_figure(fig_data: dict) -> dict:
653
+ """Apply GrimoirePlot dark theme styling to a Plotly figure dict."""
664
654
  if "layout" not in fig_data:
665
655
  fig_data["layout"] = {}
666
656
 
@@ -671,10 +661,9 @@ def create_plotly_chart(fig_data: dict) -> ui.plotly:
671
661
  layout.setdefault("height", 450)
672
662
  layout.setdefault("autosize", True)
673
663
 
674
- # Disable Plotly animations
664
+ # Disable Plotly animations for smoother live updates
675
665
  layout.setdefault("transition", {"duration": 0})
676
666
 
677
- # Style axes
678
667
  for axis in ["xaxis", "yaxis"]:
679
668
  if axis not in layout:
680
669
  layout[axis] = {}
@@ -682,7 +671,6 @@ def create_plotly_chart(fig_data: dict) -> ui.plotly:
682
671
  layout[axis].setdefault("linecolor", "rgba(139, 92, 246, 0.3)")
683
672
  layout[axis].setdefault("tickcolor", "#64748B")
684
673
 
685
- # Style legend
686
674
  layout.setdefault(
687
675
  "legend",
688
676
  {
@@ -692,12 +680,31 @@ def create_plotly_chart(fig_data: dict) -> ui.plotly:
692
680
  },
693
681
  )
694
682
 
695
- # Enable responsive mode via Plotly config
696
683
  if "config" not in fig_data:
697
684
  fig_data["config"] = {}
698
685
  fig_data["config"].setdefault("responsive", True)
699
686
 
700
- chart = ui.plotly(fig_data).classes("w-full").style("height: 450px;")
687
+ return fig_data
688
+
689
+
690
+ def create_plotly_chart(fig_data: dict) -> ui.plotly:
691
+ """Create a styled Plotly chart.
692
+
693
+ Uses the dict figure interface recommended by NiceGUI for live updates.
694
+
695
+ Args:
696
+ fig_data: Plotly figure data dict.
697
+
698
+ Returns:
699
+ Styled plotly component.
700
+ """
701
+ from grimoireplot.plot_live_update import normalize_figure_dict
702
+
703
+ chart = (
704
+ ui.plotly(style_plotly_figure(normalize_figure_dict(fig_data)))
705
+ .classes("w-full")
706
+ .style("height: 450px;")
707
+ )
701
708
  return chart
702
709
 
703
710
 
@@ -8,7 +8,7 @@ build-backend = "uv_build"
8
8
 
9
9
  [project]
10
10
  name = "grimoireplot"
11
- version = "0.0.2"
11
+ version = "0.0.3"
12
12
  description = "GrimoirePlot is a live dashboard of plotly-compatible plots of remote data"
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.11"
@@ -20,7 +20,7 @@ classifiers = ["License :: OSI Approved :: MIT License"]
20
20
  dependencies = [
21
21
  "aiohttp[speedups]>=3.13.3",
22
22
  "loguru>=0.7.3",
23
- "nicegui>=3.5.0,<4",
23
+ "nicegui>=3.14.0,<4",
24
24
  "plotly>=6.5.2",
25
25
  "python-dotenv>=1.2.1",
26
26
  "requests>=2.32.5",
File without changes