grimoireplot 0.0.1__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.
- {grimoireplot-0.0.1 → grimoireplot-0.0.3}/PKG-INFO +4 -4
- {grimoireplot-0.0.1 → grimoireplot-0.0.3}/README.md +2 -2
- {grimoireplot-0.0.1 → grimoireplot-0.0.3}/grimoireplot/models.py +39 -18
- grimoireplot-0.0.3/grimoireplot/plot_live_update.py +212 -0
- {grimoireplot-0.0.1 → grimoireplot-0.0.3}/grimoireplot/server.py +56 -3
- {grimoireplot-0.0.1 → grimoireplot-0.0.3}/grimoireplot/ui.py +52 -1
- {grimoireplot-0.0.1 → grimoireplot-0.0.3}/grimoireplot/ui_elements.py +24 -17
- {grimoireplot-0.0.1 → grimoireplot-0.0.3}/pyproject.toml +2 -2
- {grimoireplot-0.0.1 → grimoireplot-0.0.3}/grimoireplot/__init__.py +0 -0
- {grimoireplot-0.0.1 → grimoireplot-0.0.3}/grimoireplot/client.py +0 -0
- {grimoireplot-0.0.1 → grimoireplot-0.0.3}/grimoireplot/common.py +0 -0
- {grimoireplot-0.0.1 → grimoireplot-0.0.3}/grimoireplot/create_some_plots.py +0 -0
- {grimoireplot-0.0.1 → grimoireplot-0.0.3}/grimoireplot/main.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: grimoireplot
|
|
3
|
-
Version: 0.0.
|
|
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.
|
|
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
|
|
@@ -24,10 +24,10 @@ Description-Content-Type: text/markdown
|
|
|
24
24
|
|
|
25
25
|

|
|
26
26
|
|
|
27
|
-
##
|
|
27
|
+
## Adding to your current project
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
|
-
uv
|
|
30
|
+
uv add grimoireplot
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
Or install from source:
|
|
@@ -7,6 +7,7 @@ from typing import Optional
|
|
|
7
7
|
from datetime import datetime
|
|
8
8
|
from sqlmodel import Field, Relationship, SQLModel, create_engine, Session
|
|
9
9
|
from sqlalchemy import ForeignKeyConstraint
|
|
10
|
+
from sqlalchemy.exc import IntegrityError
|
|
10
11
|
from dotenv import load_dotenv
|
|
11
12
|
|
|
12
13
|
load_dotenv()
|
|
@@ -118,36 +119,56 @@ def add_plot(
|
|
|
118
119
|
# Get or create Grimoire
|
|
119
120
|
grimoire = session.get(Grimoire, grimoire_name)
|
|
120
121
|
if grimoire is None:
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
122
|
+
try:
|
|
123
|
+
grimoire = Grimoire(name=grimoire_name)
|
|
124
|
+
session.add(grimoire)
|
|
125
|
+
session.commit()
|
|
126
|
+
session.refresh(grimoire)
|
|
127
|
+
except IntegrityError:
|
|
128
|
+
session.rollback()
|
|
129
|
+
grimoire = session.get(Grimoire, grimoire_name)
|
|
125
130
|
|
|
126
131
|
# Get or create Chapter
|
|
127
132
|
chapter = session.get(Chapter, (chapter_name, grimoire_name))
|
|
128
133
|
if chapter is None:
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
134
|
+
try:
|
|
135
|
+
chapter = Chapter(name=chapter_name, grimoire_name=grimoire_name)
|
|
136
|
+
session.add(chapter)
|
|
137
|
+
session.commit()
|
|
138
|
+
session.refresh(chapter)
|
|
139
|
+
except IntegrityError:
|
|
140
|
+
session.rollback()
|
|
141
|
+
chapter = session.get(Chapter, (chapter_name, grimoire_name))
|
|
133
142
|
|
|
134
143
|
# Get or create/replace Plot
|
|
135
144
|
plot = session.get(Plot, (plot_name, chapter_name, grimoire_name))
|
|
136
145
|
if plot is None:
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
146
|
+
try:
|
|
147
|
+
plot = Plot(
|
|
148
|
+
name=plot_name,
|
|
149
|
+
chapter_name=chapter_name,
|
|
150
|
+
grimoire_name=grimoire_name,
|
|
151
|
+
json_data=json_data,
|
|
152
|
+
)
|
|
153
|
+
session.add(plot)
|
|
154
|
+
session.commit()
|
|
155
|
+
session.refresh(plot)
|
|
156
|
+
except IntegrityError:
|
|
157
|
+
session.rollback()
|
|
158
|
+
plot = session.get(Plot, (plot_name, chapter_name, grimoire_name))
|
|
159
|
+
if plot is not None:
|
|
160
|
+
plot.json_data = json_data
|
|
161
|
+
session.add(plot)
|
|
162
|
+
session.commit()
|
|
163
|
+
session.refresh(plot)
|
|
144
164
|
else:
|
|
145
165
|
plot.json_data = json_data
|
|
146
166
|
session.add(plot)
|
|
167
|
+
session.commit()
|
|
168
|
+
session.refresh(plot)
|
|
147
169
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
170
|
+
if plot is None:
|
|
171
|
+
raise RuntimeError("Failed to create or retrieve plot")
|
|
151
172
|
return plot
|
|
152
173
|
|
|
153
174
|
|
|
@@ -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,11 @@
|
|
|
2
2
|
# SPDX-FileContributor: William Droz <william.droz@idiap.ch>
|
|
3
3
|
# SPDX-License-Identifier: MIT
|
|
4
4
|
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
import logging
|
|
8
|
+
from functools import wraps
|
|
9
|
+
|
|
5
10
|
from fastapi import HTTPException, Request
|
|
6
11
|
from nicegui import app, ui
|
|
7
12
|
from grimoireplot.common import get_grimoire_secret
|
|
@@ -14,11 +19,49 @@ from grimoireplot.models import (
|
|
|
14
19
|
delete_grimoire,
|
|
15
20
|
)
|
|
16
21
|
|
|
17
|
-
from grimoireplot.ui import dashboard_ui, refresh_chapter_plots
|
|
22
|
+
from grimoireplot.ui import dashboard_ui, refresh_chapter_plots, update_plot_chart
|
|
18
23
|
from grimoireplot.ui_elements import setup_theme
|
|
19
24
|
|
|
20
25
|
_GRIMOIRE_SECRET = get_grimoire_secret()
|
|
21
26
|
|
|
27
|
+
logger = logging.getLogger("grimoireplot")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def retry_on_db_error(max_retries: int = 5, base_delay: float = 0.1):
|
|
31
|
+
"""Decorator that retries a function on database errors up to max_retries times."""
|
|
32
|
+
|
|
33
|
+
def decorator(func):
|
|
34
|
+
@wraps(func)
|
|
35
|
+
def wrapper(*args, **kwargs):
|
|
36
|
+
for attempt in range(1, max_retries + 1):
|
|
37
|
+
try:
|
|
38
|
+
return func(*args, **kwargs)
|
|
39
|
+
except HTTPException:
|
|
40
|
+
raise
|
|
41
|
+
except Exception as e:
|
|
42
|
+
if attempt < max_retries:
|
|
43
|
+
delay = base_delay * (2 ** (attempt - 1))
|
|
44
|
+
logger.warning(
|
|
45
|
+
"Database error on attempt %d/%d: %s. Retrying in %.2fs...",
|
|
46
|
+
attempt,
|
|
47
|
+
max_retries,
|
|
48
|
+
str(e),
|
|
49
|
+
delay,
|
|
50
|
+
)
|
|
51
|
+
time.sleep(delay)
|
|
52
|
+
else:
|
|
53
|
+
logger.error(
|
|
54
|
+
"Database error on attempt %d/%d: %s. No more retries.",
|
|
55
|
+
attempt,
|
|
56
|
+
max_retries,
|
|
57
|
+
str(e),
|
|
58
|
+
)
|
|
59
|
+
raise
|
|
60
|
+
|
|
61
|
+
return wrapper
|
|
62
|
+
|
|
63
|
+
return decorator
|
|
64
|
+
|
|
22
65
|
|
|
23
66
|
def verify_secret(request: Request):
|
|
24
67
|
if (secret := request.headers.get("grimoire-secret")) is None:
|
|
@@ -31,6 +74,7 @@ def my_app(host: str = "localhost", port: int = 8080):
|
|
|
31
74
|
create_db_and_tables()
|
|
32
75
|
|
|
33
76
|
@app.post("/add_plot")
|
|
77
|
+
@retry_on_db_error(max_retries=5)
|
|
34
78
|
def add_plot_endpoint(add_plot_request: AddPlotRequest, request: Request):
|
|
35
79
|
verify_secret(request)
|
|
36
80
|
plot = add_plot(
|
|
@@ -39,8 +83,17 @@ def my_app(host: str = "localhost", port: int = 8080):
|
|
|
39
83
|
plot_name=add_plot_request.plot_name,
|
|
40
84
|
json_data=add_plot_request.json_data,
|
|
41
85
|
)
|
|
42
|
-
#
|
|
43
|
-
|
|
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
|
|
44
97
|
if not refresh_chapter_plots(
|
|
45
98
|
add_plot_request.grimoire_name, add_plot_request.chapter_name
|
|
46
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
|
|
653
|
-
"""
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|