better-rtplot 0.5.0__tar.gz → 0.6.1__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.
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/PKG-INFO +4 -1
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/README.md +3 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/pyproject.toml +1 -1
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/client.py +53 -9
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/server_browser.py +23 -6
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/index.html +114 -33
- better_rtplot-0.6.1/rtplot/static/presentation.css +173 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/ui-view.js +51 -9
- better_rtplot-0.6.1/rtplot/ui_state.py +188 -0
- better_rtplot-0.5.0/rtplot/ui_state.py +0 -102
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/LICENSE +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/interactive_test.py +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/saved_plots/.gitignore +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/server.py +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/server_browser_gui.py +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/auto-render.min.js +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/katex.min.css +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/katex.min.js +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/uPlot.iife.min.js +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/uPlot.min.css +0 -0
- {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/ui-view.css +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: better-rtplot
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary:
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -132,6 +132,7 @@ A rising line now draws itself in the browser tab.
|
|
|
132
132
|
- **Remote-friendly.** Sender or plot host can bind. Live Bind /
|
|
133
133
|
Connect buttons retarget without restart.
|
|
134
134
|
- **Config lives with the data.** The sender declares plot layout.
|
|
135
|
+
- **Responsive presentation.** Native phone sizing, essential actions, compact navigation, and explicit semantic styles. See the [presentation guide](docs/presentation.md) and [before/after screenshots](docs/presentation-screenshots/README.md).
|
|
135
136
|
- **Sections and runtime state.** Optional collapsible sections, persistent status controls, and presentation updates that preserve streaming history. See the [section API](docs/sections.md) and [workflow example](examples/06_sections/README.md).
|
|
136
137
|
- **Interactive controls.** Buttons, sliders, dials, displays — polled
|
|
137
138
|
from your loop, no threads, no callbacks.
|
|
@@ -158,3 +159,5 @@ A rising line now draws itself in the browser tab.
|
|
|
158
159
|
Issues and feature requests:
|
|
159
160
|
[github.com/jmontp/rtplot/issues](https://github.com/jmontp/rtplot/issues).
|
|
160
161
|
|
|
162
|
+
Dropdown controls: [API and examples](docs/dropdowns.md).
|
|
163
|
+
|
|
@@ -107,6 +107,7 @@ A rising line now draws itself in the browser tab.
|
|
|
107
107
|
- **Remote-friendly.** Sender or plot host can bind. Live Bind /
|
|
108
108
|
Connect buttons retarget without restart.
|
|
109
109
|
- **Config lives with the data.** The sender declares plot layout.
|
|
110
|
+
- **Responsive presentation.** Native phone sizing, essential actions, compact navigation, and explicit semantic styles. See the [presentation guide](docs/presentation.md) and [before/after screenshots](docs/presentation-screenshots/README.md).
|
|
110
111
|
- **Sections and runtime state.** Optional collapsible sections, persistent status controls, and presentation updates that preserve streaming history. See the [section API](docs/sections.md) and [workflow example](examples/06_sections/README.md).
|
|
111
112
|
- **Interactive controls.** Buttons, sliders, dials, displays — polled
|
|
112
113
|
from your loop, no threads, no callbacks.
|
|
@@ -132,3 +133,5 @@ A rising line now draws itself in the browser tab.
|
|
|
132
133
|
|
|
133
134
|
Issues and feature requests:
|
|
134
135
|
[github.com/jmontp/rtplot/issues](https://github.com/jmontp/rtplot/issues).
|
|
136
|
+
|
|
137
|
+
Dropdown controls: [API and examples](docs/dropdowns.md).
|
|
@@ -3,7 +3,7 @@ import numpy as np
|
|
|
3
3
|
import time
|
|
4
4
|
import uuid
|
|
5
5
|
import warnings
|
|
6
|
-
from .ui_state import META_KEY, declarations, merge_state
|
|
6
|
+
from .ui_state import META_KEY, declarations, merge_state, presentation_requested
|
|
7
7
|
from collections import OrderedDict, namedtuple
|
|
8
8
|
from dataclasses import dataclass, field
|
|
9
9
|
from typing import List, Optional, Tuple, Union
|
|
@@ -162,6 +162,7 @@ class Plot:
|
|
|
162
162
|
xrange: Optional[int] = None
|
|
163
163
|
height: Optional[float] = None
|
|
164
164
|
section: Optional[str] = None
|
|
165
|
+
min_height: Optional[int] = None
|
|
165
166
|
|
|
166
167
|
def to_dict(self):
|
|
167
168
|
return _drop_none({
|
|
@@ -176,7 +177,7 @@ class Plot:
|
|
|
176
177
|
"yrange": _range_to_list(self.yrange),
|
|
177
178
|
"xrange": self.xrange,
|
|
178
179
|
"height": self.height,
|
|
179
|
-
"section": self.section,
|
|
180
|
+
"section": self.section, "min_height": self.min_height,
|
|
180
181
|
})
|
|
181
182
|
|
|
182
183
|
|
|
@@ -209,11 +210,12 @@ class Button:
|
|
|
209
210
|
label: str
|
|
210
211
|
color: Optional[str] = None
|
|
211
212
|
height: Optional[float] = None
|
|
213
|
+
appearance: Optional[str] = None
|
|
212
214
|
|
|
213
215
|
def to_dict(self):
|
|
214
216
|
return _drop_none({
|
|
215
217
|
"type": "button", "id": self.id, "label": self.label,
|
|
216
|
-
"color": self.color, "height": self.height,
|
|
218
|
+
"color": self.color, "height": self.height, "appearance": self.appearance,
|
|
217
219
|
})
|
|
218
220
|
|
|
219
221
|
|
|
@@ -267,11 +269,12 @@ class Display:
|
|
|
267
269
|
label: str
|
|
268
270
|
format: Optional[str] = None
|
|
269
271
|
height: Optional[float] = None
|
|
272
|
+
appearance: Optional[str] = None
|
|
270
273
|
|
|
271
274
|
def to_dict(self):
|
|
272
275
|
return _drop_none({
|
|
273
276
|
"type": "display", "id": self.id, "label": self.label,
|
|
274
|
-
"format": self.format, "height": self.height,
|
|
277
|
+
"format": self.format, "height": self.height, "appearance": self.appearance,
|
|
275
278
|
})
|
|
276
279
|
|
|
277
280
|
|
|
@@ -281,11 +284,12 @@ class Text:
|
|
|
281
284
|
label: str
|
|
282
285
|
value: str = ""
|
|
283
286
|
height: Optional[float] = None
|
|
287
|
+
appearance: Optional[str] = None
|
|
284
288
|
|
|
285
289
|
def to_dict(self):
|
|
286
290
|
return _drop_none({
|
|
287
291
|
"type": "text", "id": self.id, "label": self.label,
|
|
288
|
-
"value": self.value, "height": self.height,
|
|
292
|
+
"value": self.value, "height": self.height, "appearance": self.appearance,
|
|
289
293
|
})
|
|
290
294
|
|
|
291
295
|
|
|
@@ -306,18 +310,34 @@ class TextInput:
|
|
|
306
310
|
})
|
|
307
311
|
|
|
308
312
|
|
|
313
|
+
@dataclass
|
|
314
|
+
class Dropdown:
|
|
315
|
+
"""Single choice control. Omitted value selects the first option."""
|
|
316
|
+
id: str
|
|
317
|
+
label: str
|
|
318
|
+
options: List[Union[str, dict]]
|
|
319
|
+
value: Optional[str] = None
|
|
320
|
+
height: Optional[float] = None
|
|
321
|
+
|
|
322
|
+
def to_dict(self):
|
|
323
|
+
return _drop_none({"type": "dropdown", "id": self.id, "label": self.label,
|
|
324
|
+
"options": self.options, "value": self.value, "height": self.height})
|
|
325
|
+
|
|
326
|
+
|
|
309
327
|
@dataclass
|
|
310
328
|
class ControlsRow:
|
|
311
329
|
"""A row of control widgets, rendered in place of a plot."""
|
|
312
|
-
controls: List[Union[Button, Slider, Dial, Display, Text, TextInput, dict]] = field(default_factory=list)
|
|
330
|
+
controls: List[Union[Button, Slider, Dial, Display, Text, TextInput, Dropdown, dict]] = field(default_factory=list)
|
|
313
331
|
|
|
314
332
|
section: Optional[str] = None
|
|
333
|
+
title: Optional[str] = None
|
|
334
|
+
exclusive: Optional[bool] = None
|
|
315
335
|
|
|
316
336
|
def to_dict(self):
|
|
317
337
|
return _drop_none({"controls": [
|
|
318
338
|
c.to_dict() if hasattr(c, "to_dict") else c
|
|
319
339
|
for c in self.controls
|
|
320
|
-
], "section": self.section})
|
|
340
|
+
], "section": self.section, "title": self.title, "exclusive": self.exclusive})
|
|
321
341
|
|
|
322
342
|
|
|
323
343
|
@dataclass
|
|
@@ -328,6 +348,9 @@ class Section:
|
|
|
328
348
|
collapsible: bool = False
|
|
329
349
|
collapsed: bool = False
|
|
330
350
|
visible: bool = True
|
|
351
|
+
navigation: str = "primary"
|
|
352
|
+
density: str = "normal"
|
|
353
|
+
show_heading: bool = True
|
|
331
354
|
|
|
332
355
|
def to_dict(self):
|
|
333
356
|
return dict(vars(self))
|
|
@@ -337,11 +360,12 @@ class Section:
|
|
|
337
360
|
class View:
|
|
338
361
|
sections: List[Union[Section, dict]]
|
|
339
362
|
persistent_section: Optional[str] = None
|
|
363
|
+
essential_controls: Optional[List[str]] = None
|
|
340
364
|
|
|
341
365
|
def to_dict(self):
|
|
342
366
|
return _drop_none({"sections": [s.to_dict() if hasattr(s, "to_dict") else s
|
|
343
367
|
for s in self.sections],
|
|
344
|
-
"persistent_section": self.persistent_section})
|
|
368
|
+
"persistent_section": self.persistent_section, "essential_controls": self.essential_controls})
|
|
345
369
|
|
|
346
370
|
|
|
347
371
|
def local_plot():
|
|
@@ -600,6 +624,15 @@ def initialize_plots(plot_descriptions=1, handshake_timeout=2.0, *, view=None, u
|
|
|
600
624
|
"confirm support: layout may be flat and disabled states are NOT enforced.",
|
|
601
625
|
RuntimeWarning, stacklevel=2)
|
|
602
626
|
|
|
627
|
+
if any(c["type"] == "dropdown" for c in _control_registry.values()) and "dropdown_v1" not in _server_capabilities:
|
|
628
|
+
warnings.warn("Dropdowns require rtplot browser server >= 0.6.1. This server did not "
|
|
629
|
+
"confirm support; dropdown controls may be missing or unenforced.",
|
|
630
|
+
RuntimeWarning, stacklevel=2)
|
|
631
|
+
if presentation_requested(plot_desc_dict, _view) and "presentation_v1" not in _server_capabilities:
|
|
632
|
+
warnings.warn("Presentation options require rtplot browser server >= 0.6.0. This server did not "
|
|
633
|
+
"confirm support; essential placement, groups, and styling may be ignored.",
|
|
634
|
+
RuntimeWarning, stacklevel=2)
|
|
635
|
+
|
|
603
636
|
|
|
604
637
|
def _send_initialize_with_handshake(cfg, timeout):
|
|
605
638
|
"""Publish ``cfg`` and block until the server acks it.
|
|
@@ -752,12 +785,23 @@ def set_text_input(input_id: str, value):
|
|
|
752
785
|
socket.send_json({"id": str(input_id), "value": str(value)})
|
|
753
786
|
|
|
754
787
|
|
|
788
|
+
def set_dropdown(control_id: str, value: str):
|
|
789
|
+
"""Select a declared option without rebuilding the layout (server >= 0.6.1)."""
|
|
790
|
+
control = _control_registry.get(control_id, {})
|
|
791
|
+
if control.get("type") != "dropdown" or value not in control["options"]:
|
|
792
|
+
raise ValueError("set_dropdown requires a dropdown ID and a declared string value")
|
|
793
|
+
if "dropdown_v1" not in _server_capabilities:
|
|
794
|
+
raise RuntimeError("set_dropdown requires rtplot browser server >= 0.6.1")
|
|
795
|
+
socket.send_string(SENDING_TEXT_INPUT, zmq.SNDMORE)
|
|
796
|
+
socket.send_json({"id": control_id, "value": value, "session": _session, "generation": _generation})
|
|
797
|
+
|
|
798
|
+
|
|
755
799
|
def poll_controls():
|
|
756
800
|
"""Drain the return channel non-blocking and return current control state.
|
|
757
801
|
|
|
758
802
|
Returns a ControlState(values, buttons) where:
|
|
759
803
|
- values: dict of latest control values keyed by id. Sliders/dials
|
|
760
|
-
remain floats; text_input controls remain strings.
|
|
804
|
+
remain floats; text_input and dropdown controls remain strings.
|
|
761
805
|
- buttons: list of button ids that fired since the previous poll
|
|
762
806
|
(cleared after this call).
|
|
763
807
|
|
|
@@ -622,6 +622,8 @@ def control_allowed(tab, payload, kind):
|
|
|
622
622
|
state = {**CONTROL_DEFAULTS, **tab.ui_state.get("controls", {}).get(cid, {})}
|
|
623
623
|
if not state["enabled"] or not state["visible"]:
|
|
624
624
|
return False
|
|
625
|
+
if cid in tab.view.get("essential_controls", []):
|
|
626
|
+
return True
|
|
625
627
|
sid = info.get("section")
|
|
626
628
|
section = next((s for s in tab.view.get("sections", []) if s["id"] == sid), {})
|
|
627
629
|
return tab.ui_state.get("sections", {}).get(sid, {}).get("visible", section.get("visible", True))
|
|
@@ -649,7 +651,8 @@ def _expand_config(config):
|
|
|
649
651
|
plots.append((f"{key}/{i}", child))
|
|
650
652
|
layout.append({"kind": "row", "columns": columns, "plots": indices, "section": entry.get("section")})
|
|
651
653
|
elif "controls" in entry:
|
|
652
|
-
layout.append({"kind": "controls", "index": len(controls), "section": entry.get("section")
|
|
654
|
+
layout.append({"kind": "controls", "index": len(controls), "section": entry.get("section"),
|
|
655
|
+
"title": entry.get("title"), "exclusive": entry.get("exclusive", False)})
|
|
653
656
|
controls.append(entry["controls"])
|
|
654
657
|
else:
|
|
655
658
|
layout.append({"kind": "plot", "index": len(plots), "section": entry.get("section")})
|
|
@@ -673,8 +676,9 @@ def parse_config(tab: Tab, json_config):
|
|
|
673
676
|
for element in row:
|
|
674
677
|
if element.get("type") in ("slider", "dial") and "value" in element:
|
|
675
678
|
slider_values[element["id"]] = float(element["value"])
|
|
676
|
-
elif element.get("type")
|
|
677
|
-
|
|
679
|
+
elif element.get("type") in ("text_input", "dropdown"):
|
|
680
|
+
default = registry[element["id"]]["options"][0] if element["type"] == "dropdown" else ""
|
|
681
|
+
text_values[element["id"]] = str(element.get("value", default))
|
|
678
682
|
|
|
679
683
|
for plot_counter, (_, plot_description) in enumerate(plot_entries):
|
|
680
684
|
trace_names = plot_description["names"]
|
|
@@ -714,10 +718,11 @@ def parse_config(tab: Tab, json_config):
|
|
|
714
718
|
el["id"]
|
|
715
719
|
for row in control_rows
|
|
716
720
|
for el in row
|
|
717
|
-
if el.get("type")
|
|
721
|
+
if el.get("type") in ("text_input", "dropdown")
|
|
718
722
|
}
|
|
719
723
|
tab.text_values = {
|
|
720
|
-
text_id:
|
|
724
|
+
text_id: (default_value if registry[text_id]["type"] == "dropdown"
|
|
725
|
+
else tab.text_values.get(text_id, default_value))
|
|
721
726
|
for text_id, default_value in text_values.items()
|
|
722
727
|
if text_id in active_text_ids
|
|
723
728
|
}
|
|
@@ -1261,6 +1266,12 @@ async def zmq_receiver(tab: Tab):
|
|
|
1261
1266
|
if text_id is None:
|
|
1262
1267
|
continue
|
|
1263
1268
|
value = str(payload.get("value", ""))
|
|
1269
|
+
control = tab.control_registry.get(text_id, {})
|
|
1270
|
+
if control.get("type") == "dropdown":
|
|
1271
|
+
if (payload.get("session"), payload.get("generation")) != (tab.session, tab.generation):
|
|
1272
|
+
continue
|
|
1273
|
+
if payload.get("value") not in control["options"]:
|
|
1274
|
+
continue
|
|
1264
1275
|
if tab.text_values.get(text_id) != value:
|
|
1265
1276
|
tab.text_values[text_id] = value
|
|
1266
1277
|
tab.text_dirty.add(text_id)
|
|
@@ -1574,6 +1585,7 @@ _INDEX_HTML = _read_static_asset("index.html")
|
|
|
1574
1585
|
for _asset in (
|
|
1575
1586
|
"ui-view.js",
|
|
1576
1587
|
"ui-view.css",
|
|
1588
|
+
"presentation.css",
|
|
1577
1589
|
"uPlot.min.css",
|
|
1578
1590
|
"uPlot.iife.min.js",
|
|
1579
1591
|
"katex/katex.min.css",
|
|
@@ -1697,8 +1709,13 @@ async def handle_ws(request):
|
|
|
1697
1709
|
text_id = payload.get("id")
|
|
1698
1710
|
tid = ws_tab.get(ws, BIND_ME_ID)
|
|
1699
1711
|
t = tabs.get(tid)
|
|
1700
|
-
if text_id and t is not None and control_allowed(t, payload, {"text_input"}):
|
|
1712
|
+
if text_id and t is not None and control_allowed(t, payload, {"text_input", "dropdown"}):
|
|
1701
1713
|
value = str(payload.get("value", ""))
|
|
1714
|
+
control = t.control_registry.get(text_id, {})
|
|
1715
|
+
if control.get("type") == "dropdown":
|
|
1716
|
+
if payload.get("value") not in control["options"]:
|
|
1717
|
+
continue
|
|
1718
|
+
t.text_dirty.add(text_id)
|
|
1702
1719
|
t.text_values[text_id] = value
|
|
1703
1720
|
refresh_config_message(t)
|
|
1704
1721
|
await send_control_event(
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
5
6
|
<title>rtplot</title>
|
|
6
7
|
<link rel="stylesheet" href="/static/uPlot.min.css" />
|
|
7
8
|
<link rel="stylesheet" href="/static/ui-view.css" />
|
|
@@ -143,6 +144,7 @@
|
|
|
143
144
|
.res-stats .rs-tab .rs-hz { color: #2a5db0; font-weight: 500; }
|
|
144
145
|
.res-unavail { font-size: calc(11px * var(--ui-scale)); color: #999; font-style: italic; }
|
|
145
146
|
</style>
|
|
147
|
+
<link rel="stylesheet" href="/static/presentation.css" />
|
|
146
148
|
</head>
|
|
147
149
|
<body>
|
|
148
150
|
<div id="tabbar"></div>
|
|
@@ -154,12 +156,13 @@
|
|
|
154
156
|
</div>
|
|
155
157
|
<div id="header">
|
|
156
158
|
<h1>rtplot</h1>
|
|
157
|
-
<div id="status" class="green">Rate: -- Hz</div>
|
|
158
|
-
<div id="zmq-mode">ZMQ: --</div>
|
|
159
159
|
<div id="ws-status">connecting...</div>
|
|
160
160
|
<button id="menu-btn" class="btn" title="Settings" aria-label="Settings">☰</button>
|
|
161
161
|
</div>
|
|
162
162
|
<div id="menu-panel" aria-hidden="true">
|
|
163
|
+
<h2>Connection and performance</h2>
|
|
164
|
+
<div id="status" class="green">Rate: -- Hz</div>
|
|
165
|
+
<div id="zmq-mode">ZMQ: --</div>
|
|
163
166
|
<h2>Settings</h2>
|
|
164
167
|
<div class="menu-row">
|
|
165
168
|
<label for="menu-font">UI font scale</label>
|
|
@@ -391,12 +394,25 @@
|
|
|
391
394
|
}
|
|
392
395
|
menuBtn.addEventListener('click', (e) => {
|
|
393
396
|
e.stopPropagation();
|
|
394
|
-
menuPanel.classList.toggle('open');
|
|
397
|
+
const open = menuPanel.classList.toggle('open');
|
|
398
|
+
const top = Math.max(document.getElementById('header').getBoundingClientRect().bottom,
|
|
399
|
+
viewManager?.sticky.getBoundingClientRect().bottom || 0) + 8;
|
|
400
|
+
menuPanel.style.top = `${top}px`;
|
|
401
|
+
menuPanel.style.maxHeight = `calc(100dvh - ${top + 8}px)`;
|
|
402
|
+
menuPanel.setAttribute('aria-hidden', String(!open));
|
|
403
|
+
menuBtn.setAttribute('aria-expanded', String(open));
|
|
404
|
+
});
|
|
405
|
+
document.addEventListener('keydown', e => {
|
|
406
|
+
if (e.key === 'Escape' && menuPanel.classList.contains('open')) {
|
|
407
|
+
menuPanel.classList.remove('open'); menuPanel.setAttribute('aria-hidden', 'true');
|
|
408
|
+
menuBtn.setAttribute('aria-expanded', 'false'); menuBtn.focus();
|
|
409
|
+
}
|
|
395
410
|
});
|
|
396
411
|
document.addEventListener('click', (e) => {
|
|
397
412
|
if (!menuPanel.classList.contains('open')) return;
|
|
398
413
|
if (menuPanel.contains(e.target) || menuBtn.contains(e.target)) return;
|
|
399
414
|
menuPanel.classList.remove('open');
|
|
415
|
+
menuPanel.setAttribute('aria-hidden', 'true'); menuBtn.setAttribute('aria-expanded', 'false');
|
|
400
416
|
});
|
|
401
417
|
menuFontInput.addEventListener('input', () => {
|
|
402
418
|
settings.fontScale = Number(menuFontInput.value);
|
|
@@ -445,14 +461,22 @@
|
|
|
445
461
|
let activeLayout = null;
|
|
446
462
|
const controlElements = { displays: {}, displayFormats: {}, displayKinds: {}, sliders: {}, textInputs: {} };
|
|
447
463
|
|
|
464
|
+
const presentationTargets = '.section-nav,.section-header,#header,#menu-panel,#tabbar,.uplot';
|
|
465
|
+
let presentationPointer = false;
|
|
466
|
+
document.addEventListener('pointerdown', e => {
|
|
467
|
+
presentationPointer = Boolean(e.target.closest(presentationTargets));
|
|
468
|
+
setTimeout(() => { presentationPointer = false; }, 0);
|
|
469
|
+
}, true);
|
|
448
470
|
function sendCtrl(msg) {
|
|
449
471
|
if (msg.type.startsWith('control_')) {
|
|
450
|
-
if (!viewManager || !viewManager.canInteract(msg.id)) return;
|
|
472
|
+
if (!viewManager || !viewManager.canInteract(msg.id)) return false;
|
|
451
473
|
msg = {...msg, session: activeLayout?.session, generation: activeLayout?.generation};
|
|
452
474
|
}
|
|
453
475
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
454
476
|
socket.send(JSON.stringify(msg));
|
|
477
|
+
return true;
|
|
455
478
|
}
|
|
479
|
+
return false;
|
|
456
480
|
}
|
|
457
481
|
|
|
458
482
|
function parseDisplayFormat(fmt) {
|
|
@@ -678,7 +702,7 @@
|
|
|
678
702
|
const b = document.createElement('button');
|
|
679
703
|
b.className = 'ctrl-btn';
|
|
680
704
|
b.textContent = el.label || el.id;
|
|
681
|
-
if (el.color != null) {
|
|
705
|
+
if (el.color != null && (!el.appearance || el.appearance === 'default')) {
|
|
682
706
|
const c = resolveColor(el.color);
|
|
683
707
|
b.style.backgroundColor = c;
|
|
684
708
|
b.style.borderColor = c;
|
|
@@ -718,6 +742,28 @@
|
|
|
718
742
|
item.appendChild(val);
|
|
719
743
|
controlElements.displays[el.id] = val;
|
|
720
744
|
controlElements.displayKinds[el.id] = 'text';
|
|
745
|
+
} else if (el.type === 'dropdown') {
|
|
746
|
+
item.classList.add('ctrl-dropdown', 'flex');
|
|
747
|
+
const label = document.createElement('label');
|
|
748
|
+
label.textContent = el.label || el.id;
|
|
749
|
+
const select = document.createElement('select');
|
|
750
|
+
select.className = 'ctrl-textinput ctrl-select';
|
|
751
|
+
for (const choice of el.options) {
|
|
752
|
+
const option = document.createElement('option');
|
|
753
|
+
option.value = typeof choice === 'string' ? choice : choice.value;
|
|
754
|
+
option.textContent = typeof choice === 'string' ? choice : (choice.label ?? choice.value);
|
|
755
|
+
select.appendChild(option);
|
|
756
|
+
}
|
|
757
|
+
select.value = el.value ?? select.options[0].value;
|
|
758
|
+
let committed = select.value;
|
|
759
|
+
select.addEventListener('change', () => {
|
|
760
|
+
if (sendCtrl({type: 'control_text', id: el.id, value: select.value})) committed = select.value;
|
|
761
|
+
else select.value = committed;
|
|
762
|
+
});
|
|
763
|
+
select.setServerValue = value => { select.value = value; committed = value; };
|
|
764
|
+
label.appendChild(select);
|
|
765
|
+
item.appendChild(label);
|
|
766
|
+
controlElements.textInputs[el.id] = select;
|
|
721
767
|
} else if (el.type === 'text_input') {
|
|
722
768
|
item.classList.add('ctrl-text-input', 'flex');
|
|
723
769
|
if (el.label) {
|
|
@@ -735,9 +781,16 @@
|
|
|
735
781
|
if (el.max_length !== undefined && Number.isFinite(Number(el.max_length))) {
|
|
736
782
|
input.maxLength = Number(el.max_length);
|
|
737
783
|
}
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
784
|
+
let committed = input.value;
|
|
785
|
+
const commit = () => {
|
|
786
|
+
if (input.value === committed) return;
|
|
787
|
+
if (sendCtrl({ type: 'control_text', id: el.id, value: input.value })) committed = input.value;
|
|
788
|
+
};
|
|
789
|
+
input.addEventListener('blur', e => {
|
|
790
|
+
// Moving into presentation UI must preserve a draft without sending it.
|
|
791
|
+
if (presentationPointer || e.relatedTarget?.closest(presentationTargets)) return;
|
|
792
|
+
commit();
|
|
793
|
+
});
|
|
741
794
|
input.addEventListener('keydown', (e) => {
|
|
742
795
|
if (e.key === 'Enter') {
|
|
743
796
|
e.preventDefault();
|
|
@@ -765,12 +818,20 @@
|
|
|
765
818
|
}
|
|
766
819
|
}
|
|
767
820
|
|
|
768
|
-
function buildControlRow(row, section) {
|
|
821
|
+
function buildControlRow(row, section, options={}) {
|
|
769
822
|
const div = document.createElement('div');
|
|
770
823
|
div.className = 'ctrl-row';
|
|
824
|
+
if (options.title) {
|
|
825
|
+
const title = document.createElement('h3'); title.className = 'control-group-title'; title.textContent = options.title;
|
|
826
|
+
div.appendChild(title); div.setAttribute('role', 'group'); div.setAttribute('aria-label', options.title);
|
|
827
|
+
}
|
|
828
|
+
if (options.exclusive) div.classList.add('exclusive-group');
|
|
771
829
|
row.forEach(el => {
|
|
772
830
|
const item = buildControlElement(el);
|
|
773
831
|
applyElementSize(item, el);
|
|
832
|
+
item.dataset.appearance = el.appearance || 'default';
|
|
833
|
+
if (el.appearance === 'state') item.setAttribute('role', 'status');
|
|
834
|
+
item.querySelectorAll('input,select,svg.ctrl-dial').forEach(input => input.setAttribute('aria-label', el.label || el.id));
|
|
774
835
|
div.appendChild(item);
|
|
775
836
|
viewManager.register(el.id, item, section);
|
|
776
837
|
});
|
|
@@ -789,7 +850,8 @@
|
|
|
789
850
|
if (!values) return;
|
|
790
851
|
for (const [id, val] of Object.entries(values)) {
|
|
791
852
|
const input = controlElements.textInputs[id];
|
|
792
|
-
if (input) input.
|
|
853
|
+
if (input?.setServerValue) input.setServerValue(String(val));
|
|
854
|
+
else if (input) input.value = (val !== undefined && val !== null) ? String(val) : '';
|
|
793
855
|
}
|
|
794
856
|
}
|
|
795
857
|
|
|
@@ -850,11 +912,11 @@
|
|
|
850
912
|
|
|
851
913
|
const series = [{}];
|
|
852
914
|
for (let t = 0; t < traceCount; t++) {
|
|
853
|
-
const dash = (
|
|
915
|
+
const dash = ({'-': [10, 5], dashed: [10, 5], dotted: [2, 4], dashdot: [10, 4, 2, 4]})[styles[t]] || null;
|
|
854
916
|
series.push({
|
|
855
917
|
label: pcfg.names[t],
|
|
856
|
-
stroke: resolveColor(colors[t]),
|
|
857
|
-
width: widths[t] || 1,
|
|
918
|
+
stroke: resolveColor(colors[t] || DEFAULT_COLORS[t % DEFAULT_COLORS.length]),
|
|
919
|
+
width: (Array.isArray(widths) ? widths[t] : widths) || 1,
|
|
858
920
|
dash: dash || undefined,
|
|
859
921
|
points: { show: false },
|
|
860
922
|
spanGaps: true,
|
|
@@ -864,16 +926,15 @@
|
|
|
864
926
|
|
|
865
927
|
const baseHeight = rowLayout ? 260 : 320;
|
|
866
928
|
const heightMul = Number(pcfg.height);
|
|
867
|
-
const plotHeight = (Number.isFinite(heightMul) && heightMul > 0)
|
|
868
|
-
? Math.round(baseHeight * heightMul)
|
|
869
|
-
: baseHeight;
|
|
929
|
+
const plotHeight = Math.max(pcfg.min_height || 160, (Number.isFinite(heightMul) && heightMul > 0)
|
|
930
|
+
? Math.round(baseHeight * heightMul) : baseHeight);
|
|
870
931
|
const wrap = document.createElement('div');
|
|
871
932
|
wrap.className = 'plot-wrap';
|
|
872
933
|
parent.appendChild(wrap);
|
|
873
934
|
|
|
874
935
|
let plotState = null;
|
|
875
936
|
const opts = {
|
|
876
|
-
width: Math.max(
|
|
937
|
+
width: Math.max(120, wrap.clientWidth - 16),
|
|
877
938
|
height: plotHeight,
|
|
878
939
|
title: pcfg.title || '',
|
|
879
940
|
scales: {
|
|
@@ -886,6 +947,14 @@
|
|
|
886
947
|
],
|
|
887
948
|
series: series,
|
|
888
949
|
legend: { show: true },
|
|
950
|
+
hooks: { ready: [u => u.root.querySelectorAll('.u-legend .u-series').forEach((tr, i) => {
|
|
951
|
+
if (!i) return;
|
|
952
|
+
if (traceCount === 1 && pcfg.title === pcfg.names[0]) tr.querySelector('.u-label').classList.add('sr-only');
|
|
953
|
+
tr.tabIndex = 0; tr.setAttribute('role', 'button'); tr.setAttribute('aria-pressed', String(u.series[i].show)); tr.setAttribute('aria-label', `Toggle ${pcfg.names[i-1]}`);
|
|
954
|
+
tr.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); tr.querySelector('.u-label').click(); } });
|
|
955
|
+
})], setSeries: [u => u.root.querySelectorAll('.u-legend .u-series').forEach((tr, i) => {
|
|
956
|
+
if (i) tr.setAttribute('aria-pressed', String(u.series[i].show));
|
|
957
|
+
})] },
|
|
889
958
|
cursor: { drag: { x: false, y: false } },
|
|
890
959
|
};
|
|
891
960
|
|
|
@@ -929,7 +998,7 @@
|
|
|
929
998
|
plots.forEach(p => {
|
|
930
999
|
const visible = p.wrap.getClientRects().length > 0;
|
|
931
1000
|
if (visible) {
|
|
932
|
-
const width = Math.max(
|
|
1001
|
+
const width = Math.max(120, p.wrap.clientWidth - 16);
|
|
933
1002
|
if (!p.visible || Math.abs(p.uplot.width - width) > 1) {
|
|
934
1003
|
p.uplot.setSize({width, height: p.height});
|
|
935
1004
|
p.dataDirty = true; p.forceKeepScales = true;
|
|
@@ -956,6 +1025,7 @@
|
|
|
956
1025
|
function newRow(count, parent) {
|
|
957
1026
|
const row = document.createElement('div'); row.className = 'plot-row';
|
|
958
1027
|
row.style.setProperty('--plot-columns', Math.max(1, Math.floor(count)));
|
|
1028
|
+
row.style.setProperty('--compact-columns', Math.max(1, Math.min(2, Math.floor(count))));
|
|
959
1029
|
parent.appendChild(row); return row;
|
|
960
1030
|
}
|
|
961
1031
|
// Trace offsets come ONLY from wire declaration order, never section/DOM order.
|
|
@@ -976,9 +1046,10 @@
|
|
|
976
1046
|
entry.plots.forEach(index => buildOnePlot(cfg.plots[index], entry.columns === 1, offsets[index], row));
|
|
977
1047
|
} else if (entry.kind === 'controls') {
|
|
978
1048
|
implicitRows.delete(parent);
|
|
979
|
-
parent.appendChild(buildControlRow(controls[entry.index], entry.section));
|
|
1049
|
+
parent.appendChild(buildControlRow(controls[entry.index], entry.section, entry));
|
|
980
1050
|
}
|
|
981
1051
|
});
|
|
1052
|
+
viewManager.finish();
|
|
982
1053
|
applySliderValues(cfg.slider_values); applyTextValues(cfg.text_values); applyDisplayValues(cfg.display_values);
|
|
983
1054
|
viewManager.apply(cfg.ui_state || {}, cfg.ui_revision ?? 0);
|
|
984
1055
|
renderLatexIn(plotsDiv); applyVisibleSamples(); refreshPresentation(); scheduleRender();
|
|
@@ -1140,8 +1211,10 @@
|
|
|
1140
1211
|
|
|
1141
1212
|
// Stacked labels: name on top, endpoint on a muted subline so
|
|
1142
1213
|
// users can see at a glance which tab is wired to which device.
|
|
1143
|
-
const labels = document.createElement('
|
|
1144
|
-
labels.className = 'tab-labels';
|
|
1214
|
+
const labels = document.createElement('button');
|
|
1215
|
+
labels.className = 'tab-labels tab-select';
|
|
1216
|
+
labels.setAttribute('aria-label', `View ${t.name || id}`);
|
|
1217
|
+
labels.addEventListener('click', e => { e.stopPropagation(); if (id !== activeTab) switchTab(id); });
|
|
1145
1218
|
const name = document.createElement('span');
|
|
1146
1219
|
name.className = 'tab-name';
|
|
1147
1220
|
name.textContent = t.name || id;
|
|
@@ -1154,10 +1227,10 @@
|
|
|
1154
1227
|
labels.appendChild(ep);
|
|
1155
1228
|
el.appendChild(labels);
|
|
1156
1229
|
|
|
1157
|
-
const reconnect = document.createElement('
|
|
1230
|
+
const reconnect = document.createElement('button');
|
|
1158
1231
|
reconnect.className = 'tab-reconnect';
|
|
1159
1232
|
reconnect.textContent = '\u21BB';
|
|
1160
|
-
reconnect.title = 'Reconnect';
|
|
1233
|
+
reconnect.title = 'Reconnect'; reconnect.setAttribute('aria-label', `Reconnect ${t.name || id}`);
|
|
1161
1234
|
reconnect.addEventListener('click', (e) => {
|
|
1162
1235
|
e.stopPropagation();
|
|
1163
1236
|
sendCtrl({ type: 'tab_reconnect', id: id });
|
|
@@ -1171,17 +1244,17 @@
|
|
|
1171
1244
|
el.appendChild(reconnect);
|
|
1172
1245
|
|
|
1173
1246
|
if (id !== 'bind_me') {
|
|
1174
|
-
const ren = document.createElement('
|
|
1247
|
+
const ren = document.createElement('button');
|
|
1175
1248
|
ren.className = 'tab-rename';
|
|
1176
1249
|
ren.textContent = '\u270E';
|
|
1177
|
-
ren.title = 'Rename';
|
|
1250
|
+
ren.title = 'Rename'; ren.setAttribute('aria-label', `Rename ${t.name || id}`);
|
|
1178
1251
|
ren.addEventListener('click', (e) => { e.stopPropagation(); beginRename(el, id, t.name); });
|
|
1179
1252
|
el.appendChild(ren);
|
|
1180
1253
|
|
|
1181
|
-
const close = document.createElement('
|
|
1254
|
+
const close = document.createElement('button');
|
|
1182
1255
|
close.className = 'tab-close';
|
|
1183
1256
|
close.textContent = '\u00D7';
|
|
1184
|
-
close.title = 'Close tab';
|
|
1257
|
+
close.title = 'Close tab'; close.setAttribute('aria-label', `Close ${t.name || id}`);
|
|
1185
1258
|
close.addEventListener('click', (e) => {
|
|
1186
1259
|
e.stopPropagation();
|
|
1187
1260
|
if (confirm(`Close tab "${t.name || id}"?`)) {
|
|
@@ -1216,6 +1289,7 @@
|
|
|
1216
1289
|
add.className = 'tab-add';
|
|
1217
1290
|
add.textContent = '+ new device';
|
|
1218
1291
|
add.title = 'Add a tab that connects to a remote device';
|
|
1292
|
+
add.setAttribute('aria-label', 'Add data source');
|
|
1219
1293
|
add.addEventListener('click', openCreateForm);
|
|
1220
1294
|
tabbar.appendChild(add);
|
|
1221
1295
|
|
|
@@ -1231,10 +1305,15 @@
|
|
|
1231
1305
|
input.type = 'text';
|
|
1232
1306
|
input.className = 'tab-name-input';
|
|
1233
1307
|
input.value = currentName || '';
|
|
1234
|
-
nameSpan.
|
|
1308
|
+
const selector = nameSpan.closest('.tab-select');
|
|
1309
|
+
const original = selector || nameSpan;
|
|
1310
|
+
original.replaceWith(input);
|
|
1235
1311
|
input.focus();
|
|
1236
1312
|
input.select();
|
|
1313
|
+
let finished = false;
|
|
1237
1314
|
const commit = () => {
|
|
1315
|
+
if (finished) return;
|
|
1316
|
+
finished = true;
|
|
1238
1317
|
const newName = input.value.trim();
|
|
1239
1318
|
if (newName && newName !== currentName) {
|
|
1240
1319
|
sendCtrl({ type: 'tab_rename', id: id, name: newName });
|
|
@@ -1243,14 +1322,16 @@
|
|
|
1243
1322
|
const span = document.createElement('span');
|
|
1244
1323
|
span.className = 'tab-name';
|
|
1245
1324
|
span.textContent = currentName || '';
|
|
1246
|
-
input.replaceWith(span);
|
|
1325
|
+
if (selector) { nameSpan.textContent = currentName || ''; input.replaceWith(selector); } else input.replaceWith(span);
|
|
1247
1326
|
}
|
|
1248
1327
|
};
|
|
1249
1328
|
const cancel = () => {
|
|
1329
|
+
if (finished) return;
|
|
1330
|
+
finished = true;
|
|
1250
1331
|
const span = document.createElement('span');
|
|
1251
1332
|
span.className = 'tab-name';
|
|
1252
1333
|
span.textContent = currentName || '';
|
|
1253
|
-
input.replaceWith(span);
|
|
1334
|
+
if (selector) { nameSpan.textContent = currentName || ''; input.replaceWith(selector); } else input.replaceWith(span);
|
|
1254
1335
|
};
|
|
1255
1336
|
input.addEventListener('keydown', (e) => {
|
|
1256
1337
|
if (e.key === 'Enter') { e.preventDefault(); commit(); }
|
|
@@ -1339,13 +1420,13 @@
|
|
|
1339
1420
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
1340
1421
|
socket = new WebSocket(proto + '//' + location.host + '/ws');
|
|
1341
1422
|
socket.binaryType = 'arraybuffer';
|
|
1342
|
-
socket.onopen = () => { wsStatus.textContent = 'connected'; };
|
|
1423
|
+
socket.onopen = () => { wsStatus.textContent = 'Server connected'; wsStatus.dataset.health = 'connected'; };
|
|
1343
1424
|
socket.onclose = () => {
|
|
1344
|
-
wsStatus.textContent = '
|
|
1425
|
+
wsStatus.textContent = 'Reconnecting…'; wsStatus.dataset.health = 'disconnected';
|
|
1345
1426
|
if (viewManager) viewManager.connection(false);
|
|
1346
1427
|
setTimeout(connect, 1000);
|
|
1347
1428
|
};
|
|
1348
|
-
socket.onerror = () => { wsStatus.textContent = 'error'; };
|
|
1429
|
+
socket.onerror = () => { wsStatus.textContent = 'Connection error'; wsStatus.dataset.health = 'disconnected'; };
|
|
1349
1430
|
socket.onmessage = (ev) => {
|
|
1350
1431
|
if (typeof ev.data === 'string') {
|
|
1351
1432
|
let msg;
|