better-rtplot 0.2.2__tar.gz → 0.2.5__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.2.2 → better_rtplot-0.2.5}/PKG-INFO +1 -1
- {better_rtplot-0.2.2 → better_rtplot-0.2.5}/pyproject.toml +1 -1
- {better_rtplot-0.2.2 → better_rtplot-0.2.5}/rtplot/server_browser.py +334 -41
- better_rtplot-0.2.5/rtplot/server_browser_gui.py +1157 -0
- {better_rtplot-0.2.2 → better_rtplot-0.2.5}/LICENSE +0 -0
- {better_rtplot-0.2.2 → better_rtplot-0.2.5}/README.md +0 -0
- {better_rtplot-0.2.2 → better_rtplot-0.2.5}/rtplot/client.py +0 -0
- {better_rtplot-0.2.2 → better_rtplot-0.2.5}/rtplot/example_code.py +0 -0
- {better_rtplot-0.2.2 → better_rtplot-0.2.5}/rtplot/interactive_test.py +0 -0
- {better_rtplot-0.2.2 → better_rtplot-0.2.5}/rtplot/plot_log.py +0 -0
- {better_rtplot-0.2.2 → better_rtplot-0.2.5}/rtplot/saved_plots/.gitignore +0 -0
- {better_rtplot-0.2.2 → better_rtplot-0.2.5}/rtplot/server.py +0 -0
- {better_rtplot-0.2.2 → better_rtplot-0.2.5}/rtplot/static/uPlot.iife.min.js +0 -0
- {better_rtplot-0.2.2 → better_rtplot-0.2.5}/rtplot/static/uPlot.min.css +0 -0
|
@@ -12,6 +12,7 @@ import datetime
|
|
|
12
12
|
import json
|
|
13
13
|
import os
|
|
14
14
|
import struct
|
|
15
|
+
import sys
|
|
15
16
|
import time
|
|
16
17
|
import webbrowser
|
|
17
18
|
from collections import OrderedDict
|
|
@@ -21,6 +22,16 @@ import numpy as np
|
|
|
21
22
|
import zmq
|
|
22
23
|
import zmq.asyncio
|
|
23
24
|
|
|
25
|
+
# pyzmq's asyncio integration needs event_loop.add_reader(), which the
|
|
26
|
+
# Windows-default ProactorEventLoop (Python 3.8+) does not implement.
|
|
27
|
+
# Without this policy override, zmq_receiver() throws on its very first
|
|
28
|
+
# recv_string, the task dies silently, and any data the client pushes
|
|
29
|
+
# after that gets dropped — the browser plot stays blank with no visible
|
|
30
|
+
# error. Force SelectorEventLoop on Windows before any asyncio loop is
|
|
31
|
+
# created so pyzmq's reader-based integration works.
|
|
32
|
+
if sys.platform == "win32":
|
|
33
|
+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
34
|
+
|
|
24
35
|
try:
|
|
25
36
|
from aiohttp import WSMsgType, web
|
|
26
37
|
except ImportError as _exc:
|
|
@@ -306,9 +317,20 @@ def save_current_plot(log_name=None):
|
|
|
306
317
|
"""Persist the current buffer to a Parquet file.
|
|
307
318
|
|
|
308
319
|
Identical layout to ``server.save_current_plot`` so saved files are
|
|
309
|
-
interchangeable between the Qt and browser servers.
|
|
320
|
+
interchangeable between the Qt and browser servers. pandas + pyarrow
|
|
321
|
+
are optional and may be absent from slim builds (e.g. the Windows
|
|
322
|
+
exe). In that case we log a clear message and return without
|
|
323
|
+
raising — the receiver loop must not die because the user clicked
|
|
324
|
+
Save.
|
|
310
325
|
"""
|
|
311
|
-
|
|
326
|
+
try:
|
|
327
|
+
import pandas as pd # local import keeps Parquet deps optional
|
|
328
|
+
except ImportError as exc:
|
|
329
|
+
print(
|
|
330
|
+
"[rtplot] Save Plot unavailable: "
|
|
331
|
+
f"pandas/pyarrow not installed in this build ({exc})."
|
|
332
|
+
)
|
|
333
|
+
return
|
|
312
334
|
|
|
313
335
|
li = state["li"]
|
|
314
336
|
num_datapoints_in_plot = state["num_datapoints_in_plot"]
|
|
@@ -677,7 +699,10 @@ async def zmq_receiver():
|
|
|
677
699
|
|
|
678
700
|
elif category == SAVE_PLOT:
|
|
679
701
|
log_name = await zmq_socket.recv_string()
|
|
680
|
-
|
|
702
|
+
try:
|
|
703
|
+
save_current_plot(log_name)
|
|
704
|
+
except Exception as exc: # noqa: BLE001
|
|
705
|
+
print(f"[rtplot] save_current_plot failed: {exc}")
|
|
681
706
|
|
|
682
707
|
elif category == RECEIVED_DISPLAY:
|
|
683
708
|
payload = await zmq_socket.recv_json()
|
|
@@ -720,16 +745,49 @@ async def reconfigure_zmq(app, connect_ip=None):
|
|
|
720
745
|
except Exception: # noqa: BLE001
|
|
721
746
|
pass
|
|
722
747
|
|
|
748
|
+
# Give the OS a moment to release the freed TCP ports before we try
|
|
749
|
+
# to re-bind them. Linux usually releases immediately; Windows can
|
|
750
|
+
# hold on briefly even with linger=0, which caused "Bind doesn't work"
|
|
751
|
+
# reports in the exe. 150 ms is empirically enough in both cases.
|
|
752
|
+
await asyncio.sleep(0.15)
|
|
753
|
+
|
|
754
|
+
# Retry the open with a small backoff in case the OS is still
|
|
755
|
+
# releasing the port. Three quick attempts cover the typical Windows
|
|
756
|
+
# rebind race without making the UI feel sluggish.
|
|
757
|
+
def _try_open_zmq(ip):
|
|
758
|
+
last_exc = None
|
|
759
|
+
for attempt in range(3):
|
|
760
|
+
try:
|
|
761
|
+
return _open_zmq_socket(connect_ip=ip)
|
|
762
|
+
except Exception as exc: # noqa: BLE001
|
|
763
|
+
last_exc = exc
|
|
764
|
+
print(f"ZMQ open failed (attempt {attempt + 1}/3): {exc}")
|
|
765
|
+
time.sleep(0.1 * (attempt + 1))
|
|
766
|
+
raise last_exc if last_exc else RuntimeError("zmq open failed")
|
|
767
|
+
|
|
768
|
+
def _try_open_control(ip):
|
|
769
|
+
last_exc = None
|
|
770
|
+
for attempt in range(3):
|
|
771
|
+
try:
|
|
772
|
+
return _open_control_socket(connect_ip=ip)
|
|
773
|
+
except Exception as exc: # noqa: BLE001
|
|
774
|
+
last_exc = exc
|
|
775
|
+
print(f"ZMQ control open failed (attempt {attempt + 1}/3): {exc}")
|
|
776
|
+
time.sleep(0.1 * (attempt + 1))
|
|
777
|
+
raise last_exc if last_exc else RuntimeError("zmq control open failed")
|
|
778
|
+
|
|
723
779
|
try:
|
|
724
|
-
zmq_socket =
|
|
780
|
+
zmq_socket = _try_open_zmq(connect_ip)
|
|
725
781
|
except Exception as exc: # noqa: BLE001
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
zmq_socket
|
|
729
|
-
|
|
782
|
+
print(f"ZMQ reconfigure failed ({exc}); keeping previous state")
|
|
783
|
+
# Leave zmq_status pointing at whatever the caller asked for so
|
|
784
|
+
# the browser knows the new intent; the zmq_socket global may be
|
|
785
|
+
# in a partially-closed state but we'll retry on the next click.
|
|
786
|
+
await broadcast_zmq_status()
|
|
787
|
+
return
|
|
730
788
|
|
|
731
789
|
try:
|
|
732
|
-
control_push_socket =
|
|
790
|
+
control_push_socket = _try_open_control(connect_ip)
|
|
733
791
|
except Exception as exc: # noqa: BLE001
|
|
734
792
|
print(f"ZMQ control reconfigure failed ({exc}); control channel offline")
|
|
735
793
|
control_push_socket = None
|
|
@@ -818,27 +876,32 @@ INDEX_HTML = """<!doctype html>
|
|
|
818
876
|
<style>
|
|
819
877
|
html, body { margin: 0; padding: 0; height: 100%; font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif; background: #fafafa; color: #222; }
|
|
820
878
|
#header { display: flex; align-items: center; gap: 16px; padding: 8px 16px; background: #fff; border-bottom: 1px solid #ddd; position: sticky; top: 0; z-index: 10; }
|
|
821
|
-
#header h1 { margin: 0; font-size: 18px; font-weight: 600; }
|
|
822
|
-
#status { font-size: 14px; padding: 4px 10px; border-radius: 4px; background: #eee; }
|
|
879
|
+
#header h1 { margin: 0; font-size: calc(18px * var(--ui-scale)); font-weight: 600; }
|
|
880
|
+
#status { font-size: calc(14px * var(--ui-scale)); padding: 4px 10px; border-radius: 4px; background: #eee; }
|
|
823
881
|
#status.green { background: #d4f5d4; color: #186a18; }
|
|
824
882
|
#status.red { background: #f9d4d4; color: #8a1a1a; }
|
|
825
|
-
.btn { padding: 6px 12px; font-size: 14px; border: 1px solid #888; background: #fff; cursor: pointer; border-radius: 4px; }
|
|
883
|
+
.btn { padding: 6px 12px; font-size: calc(14px * var(--ui-scale)); border: 1px solid #888; background: #fff; cursor: pointer; border-radius: 4px; }
|
|
884
|
+
/* zmq mode buttons: the currently-active mode is green + not clickable, the other is white + clickable */
|
|
885
|
+
.btn.zmq-active { background: #d4f5d4; color: #186a18; border-color: #8dc88d; cursor: default; }
|
|
886
|
+
.btn.zmq-active:hover { background: #d4f5d4; }
|
|
887
|
+
.btn.zmq-disabled { background: #f0f0f0; color: #aaa; border-color: #ccc; cursor: not-allowed; }
|
|
888
|
+
.btn.zmq-disabled:hover { background: #f0f0f0; }
|
|
826
889
|
.btn:hover { background: #f0f0f0; }
|
|
827
|
-
#ip-input { padding: 6px 8px; font-size: 13px; border: 1px solid #888; border-radius: 4px; width: 170px; font-family: monospace; }
|
|
828
|
-
#zmq-mode { font-size: 12px; color: #555; padding: 2px 8px; background: #eef; border-radius: 4px; }
|
|
829
|
-
#ws-status { font-size: 12px; color: #666; margin-left: auto; }
|
|
890
|
+
#ip-input { padding: 6px 8px; font-size: calc(13px * var(--ui-scale)); border: 1px solid #888; border-radius: 4px; width: 170px; font-family: monospace; }
|
|
891
|
+
#zmq-mode { font-size: calc(12px * var(--ui-scale)); color: #555; padding: 2px 8px; background: #eef; border-radius: 4px; }
|
|
892
|
+
#ws-status { font-size: calc(12px * var(--ui-scale)); color: #666; margin-left: auto; }
|
|
830
893
|
#plots { display: flex; padding: 12px; gap: 12px; }
|
|
831
894
|
#plots.row { flex-direction: column; }
|
|
832
895
|
#plots.col { flex-direction: row; flex-wrap: wrap; }
|
|
833
896
|
.plot-wrap { background: #fff; border: 1px solid #ddd; border-radius: 4px; padding: 8px; flex: 1 1 auto; min-width: 320px; }
|
|
834
897
|
.plot-title { font-size: 13px; font-weight: 600; margin: 0 0 4px 4px; color: #333; }
|
|
835
|
-
:root { --ctrl-unit-h: 38px; }
|
|
898
|
+
:root { --ctrl-unit-h: 38px; --ui-scale: 1; }
|
|
836
899
|
.ctrl-row { display: flex; gap: 12px; align-items: center; padding: 10px 14px; background: #fff; border: 1px solid #ddd; border-radius: 4px; flex-wrap: wrap; }
|
|
837
900
|
.ctrl-item { display: flex; align-items: center; gap: 6px; }
|
|
838
901
|
.ctrl-item.flex { flex: 1 1 220px; min-width: 200px; }
|
|
839
|
-
.ctrl-item label { font-size: 13px; color: #444; }
|
|
840
|
-
.ctrl-btn { padding: 8px 16px; font-size: 14px; border: 1px solid #888; background: #fff; cursor: pointer; border-radius: 4px; font-weight: 500; display: flex; align-items: center; justify-content: center; }
|
|
841
|
-
.ctrl-item-tall > .ctrl-btn { align-self: stretch; padding-top: 0; padding-bottom: 0; font-size: calc(
|
|
902
|
+
.ctrl-item label { font-size: calc(13px * var(--ui-scale)); color: #444; }
|
|
903
|
+
.ctrl-btn { padding: 8px 16px; font-size: calc(14px * var(--ui-scale)); border: 1px solid #888; background: #fff; cursor: pointer; border-radius: 4px; font-weight: 500; display: flex; align-items: center; justify-content: center; }
|
|
904
|
+
.ctrl-item-tall > .ctrl-btn { align-self: stretch; padding-top: 0; padding-bottom: 0; font-size: calc(16px * var(--ui-scale)); }
|
|
842
905
|
.ctrl-item-tall > .ctrl-rangeinput,
|
|
843
906
|
.ctrl-item-tall > .ctrl-dial,
|
|
844
907
|
.ctrl-item-tall > .ctrl-numinput,
|
|
@@ -847,10 +910,10 @@ INDEX_HTML = """<!doctype html>
|
|
|
847
910
|
.ctrl-btn:hover { background: #f0f0f0; }
|
|
848
911
|
.ctrl-btn:active { background: #e2e2e2; }
|
|
849
912
|
.ctrl-slider .ctrl-rangeinput { flex: 1; min-width: 120px; }
|
|
850
|
-
.ctrl-numinput { width: 72px; font-family: monospace; font-size: 13px; padding: 4px 6px; border: 1px solid #b8b8b8; border-radius: 3px; background: #fff; color: #222; text-align: right; -moz-appearance: textfield; }
|
|
913
|
+
.ctrl-numinput { width: 72px; font-family: monospace; font-size: calc(13px * var(--ui-scale)); padding: 4px 6px; border: 1px solid #b8b8b8; border-radius: 3px; background: #fff; color: #222; text-align: right; -moz-appearance: textfield; }
|
|
851
914
|
.ctrl-numinput::-webkit-outer-spin-button,
|
|
852
915
|
.ctrl-numinput::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
|
853
|
-
.ctrl-nudgebtn { width: 26px; height: 26px; font-size: 15px; font-weight: 600; line-height: 1; padding: 0; border: 1px solid #b8b8b8; background: #f7f7f7; color: #333; cursor: pointer; border-radius: 3px; }
|
|
916
|
+
.ctrl-nudgebtn { width: 26px; height: 26px; font-size: calc(15px * var(--ui-scale)); font-weight: 600; line-height: 1; padding: 0; border: 1px solid #b8b8b8; background: #f7f7f7; color: #333; cursor: pointer; border-radius: 3px; }
|
|
854
917
|
.ctrl-nudgebtn:hover { background: #e9e9e9; }
|
|
855
918
|
.ctrl-nudgebtn:active { background: #dcdcdc; }
|
|
856
919
|
.ctrl-dial { cursor: ns-resize; flex: 0 0 auto; touch-action: none; user-select: none; }
|
|
@@ -860,9 +923,25 @@ INDEX_HTML = """<!doctype html>
|
|
|
860
923
|
.ctrl-dial .dial-arrow { fill: #c0c0c0; pointer-events: none; user-select: none; }
|
|
861
924
|
.ctrl-dial:hover .dial-track { stroke: #888; }
|
|
862
925
|
.ctrl-dial:hover .dial-arrow { fill: #888; }
|
|
863
|
-
.ctrl-val { font-family: monospace; font-size: 13px; min-width: 56px; text-align: right; color: #222; }
|
|
926
|
+
.ctrl-val { font-family: monospace; font-size: calc(13px * var(--ui-scale)); min-width: 56px; text-align: right; color: #222; }
|
|
864
927
|
.ctrl-display .ctrl-val { background: #f3f3f3; padding: 4px 10px; border-radius: 3px; min-width: 72px; border: 1px solid #e2e2e2; }
|
|
865
|
-
.ctrl-textval { background: #eef3ff; padding: 6px 12px; border-radius: 3px; border: 1px solid #c8d6ff; color: #1a3a7a; text-align: left; min-width: 160px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; }
|
|
928
|
+
.ctrl-textval { background: #eef3ff; padding: 6px 12px; border-radius: 3px; border: 1px solid #c8d6ff; color: #1a3a7a; text-align: left; min-width: 160px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: calc(14px * var(--ui-scale)); }
|
|
929
|
+
#menu-btn { margin-left: 8px; width: 34px; height: 34px; padding: 0; font-size: calc(18px * var(--ui-scale)); line-height: 1; display: flex; align-items: center; justify-content: center; }
|
|
930
|
+
#menu-panel { position: fixed; top: 56px; right: 16px; background: #fff; border: 1px solid #ccc; border-radius: 6px; box-shadow: 0 4px 14px rgba(0,0,0,0.12); padding: 16px 18px; min-width: 260px; z-index: 20; display: none; }
|
|
931
|
+
#menu-panel.open { display: block; }
|
|
932
|
+
#menu-panel h2 { margin: 0 0 10px 0; font-size: calc(14px * var(--ui-scale)); font-weight: 600; color: #333; text-transform: uppercase; letter-spacing: 0.03em; }
|
|
933
|
+
#menu-panel .menu-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
|
|
934
|
+
#menu-panel .menu-row:last-child { margin-bottom: 0; }
|
|
935
|
+
#menu-panel label { font-size: calc(12px * var(--ui-scale)); color: #555; font-weight: 500; }
|
|
936
|
+
#menu-panel .menu-ctrl { display: flex; align-items: center; gap: 8px; }
|
|
937
|
+
#menu-panel input[type=range] { flex: 1; }
|
|
938
|
+
#menu-panel input[type=number] { width: 88px; font-family: monospace; font-size: calc(13px * var(--ui-scale)); padding: 4px 6px; border: 1px solid #b8b8b8; border-radius: 3px; text-align: right; -moz-appearance: textfield; }
|
|
939
|
+
#menu-panel input[type=number]::-webkit-outer-spin-button,
|
|
940
|
+
#menu-panel input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
|
941
|
+
#menu-panel .menu-val { font-family: monospace; font-size: calc(12px * var(--ui-scale)); min-width: 44px; text-align: right; color: #333; }
|
|
942
|
+
#menu-panel .menu-hint { font-size: calc(11px * var(--ui-scale)); color: #888; margin-top: 2px; }
|
|
943
|
+
#menu-panel .menu-reset { margin-top: 6px; font-size: calc(11px * var(--ui-scale)); background: transparent; border: none; color: #2a5db0; cursor: pointer; padding: 2px 0; text-align: left; }
|
|
944
|
+
#menu-panel .menu-reset:hover { text-decoration: underline; }
|
|
866
945
|
</style>
|
|
867
946
|
</head>
|
|
868
947
|
<body>
|
|
@@ -875,6 +954,33 @@ INDEX_HTML = """<!doctype html>
|
|
|
875
954
|
<button id=\"connect-btn\" class=\"btn\">Connect</button>
|
|
876
955
|
<button id=\"bind-btn\" class=\"btn\">Bind</button>
|
|
877
956
|
<div id=\"ws-status\">connecting...</div>
|
|
957
|
+
<button id=\"menu-btn\" class=\"btn\" title=\"Settings\" aria-label=\"Settings\">☰</button>
|
|
958
|
+
</div>
|
|
959
|
+
<div id=\"menu-panel\" aria-hidden=\"true\">
|
|
960
|
+
<h2>Settings</h2>
|
|
961
|
+
<div class=\"menu-row\">
|
|
962
|
+
<label for=\"menu-font\">UI font scale</label>
|
|
963
|
+
<div class=\"menu-ctrl\">
|
|
964
|
+
<input id=\"menu-font\" type=\"range\" min=\"0.7\" max=\"2.0\" step=\"0.05\" value=\"1\" />
|
|
965
|
+
<span id=\"menu-font-val\" class=\"menu-val\">1.00x</span>
|
|
966
|
+
</div>
|
|
967
|
+
</div>
|
|
968
|
+
<div class=\"menu-row\">
|
|
969
|
+
<label for=\"menu-xrange\">Visible samples per plot</label>
|
|
970
|
+
<div class=\"menu-ctrl\">
|
|
971
|
+
<input id=\"menu-xrange\" type=\"number\" min=\"10\" step=\"10\" placeholder=\"auto\" />
|
|
972
|
+
<span class=\"menu-val\">samples</span>
|
|
973
|
+
</div>
|
|
974
|
+
</div>
|
|
975
|
+
<div class=\"menu-row\">
|
|
976
|
+
<label for=\"menu-maxfps\">Max plot refresh rate</label>
|
|
977
|
+
<div class=\"menu-ctrl\">
|
|
978
|
+
<input id=\"menu-maxfps\" type=\"number\" min=\"1\" step=\"1\" placeholder=\"auto\" />
|
|
979
|
+
<span class=\"menu-val\">Hz</span>
|
|
980
|
+
</div>
|
|
981
|
+
<div id=\"menu-monitor-hint\" class=\"menu-hint\">Monitor: measuring…</div>
|
|
982
|
+
</div>
|
|
983
|
+
<button id=\"menu-reset\" class=\"menu-reset\" type=\"button\">Reset to defaults</button>
|
|
878
984
|
</div>
|
|
879
985
|
<div id=\"plots\" class=\"row\"></div>
|
|
880
986
|
<script src=\"/static/uPlot.iife.min.js\"></script>
|
|
@@ -905,6 +1011,122 @@ INDEX_HTML = """<!doctype html>
|
|
|
905
1011
|
const connectBtn = document.getElementById('connect-btn');
|
|
906
1012
|
const bindBtn = document.getElementById('bind-btn');
|
|
907
1013
|
const zmqMode = document.getElementById('zmq-mode');
|
|
1014
|
+
const menuBtn = document.getElementById('menu-btn');
|
|
1015
|
+
const menuPanel = document.getElementById('menu-panel');
|
|
1016
|
+
const menuFontInput = document.getElementById('menu-font');
|
|
1017
|
+
const menuFontVal = document.getElementById('menu-font-val');
|
|
1018
|
+
const menuXrangeInput = document.getElementById('menu-xrange');
|
|
1019
|
+
const menuMaxfpsInput = document.getElementById('menu-maxfps');
|
|
1020
|
+
const menuMonitorHint = document.getElementById('menu-monitor-hint');
|
|
1021
|
+
const menuResetBtn = document.getElementById('menu-reset');
|
|
1022
|
+
|
|
1023
|
+
// ---- Detect the monitor's refresh rate on page load ----
|
|
1024
|
+
// Browsers deliberately don't expose the hardware refresh rate
|
|
1025
|
+
// (fingerprinting), so we calibrate it by running rAF callbacks
|
|
1026
|
+
// for ~500 ms and counting. requestAnimationFrame is locked to
|
|
1027
|
+
// the display refresh, so frames_per_sec == monitor Hz as long
|
|
1028
|
+
// as the tab is active during calibration.
|
|
1029
|
+
let monitorHz = 0;
|
|
1030
|
+
(function measureMonitorHz() {
|
|
1031
|
+
let count = 0;
|
|
1032
|
+
let start = 0;
|
|
1033
|
+
function tick(t) {
|
|
1034
|
+
if (start === 0) start = t;
|
|
1035
|
+
count += 1;
|
|
1036
|
+
const elapsed = t - start;
|
|
1037
|
+
if (elapsed < 500) {
|
|
1038
|
+
requestAnimationFrame(tick);
|
|
1039
|
+
} else {
|
|
1040
|
+
const measured = count * 1000 / elapsed;
|
|
1041
|
+
// Snap to common refresh rates so users see clean numbers
|
|
1042
|
+
// (60/75/90/120/144/165/240) instead of 59.8.
|
|
1043
|
+
const common = [30, 48, 50, 60, 72, 75, 90, 100, 120, 144, 165, 240];
|
|
1044
|
+
let best = measured;
|
|
1045
|
+
let bestDiff = Infinity;
|
|
1046
|
+
for (const c of common) {
|
|
1047
|
+
const d = Math.abs(measured - c);
|
|
1048
|
+
if (d < bestDiff && d / c < 0.05) { bestDiff = d; best = c; }
|
|
1049
|
+
}
|
|
1050
|
+
monitorHz = Math.round(best);
|
|
1051
|
+
if (menuMonitorHint) {
|
|
1052
|
+
menuMonitorHint.textContent = `Monitor: ${monitorHz} Hz (rAF cap)`;
|
|
1053
|
+
}
|
|
1054
|
+
menuMaxfpsInput.placeholder = `auto = ${monitorHz}`;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
requestAnimationFrame(tick);
|
|
1058
|
+
})();
|
|
1059
|
+
|
|
1060
|
+
// ---- Persistent client-side settings (hamburger menu) ----
|
|
1061
|
+
const SETTINGS_KEY = 'rtplotSettings.v1';
|
|
1062
|
+
const DEFAULT_SETTINGS = { fontScale: 1.0, visibleSamples: null, maxFps: null };
|
|
1063
|
+
let settings = Object.assign({}, DEFAULT_SETTINGS);
|
|
1064
|
+
try {
|
|
1065
|
+
const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}');
|
|
1066
|
+
settings = Object.assign(settings, saved);
|
|
1067
|
+
} catch (e) {}
|
|
1068
|
+
function saveSettings() {
|
|
1069
|
+
try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } catch (e) {}
|
|
1070
|
+
}
|
|
1071
|
+
function applyFontScale() {
|
|
1072
|
+
const s = Number(settings.fontScale) || 1;
|
|
1073
|
+
document.documentElement.style.setProperty('--ui-scale', s);
|
|
1074
|
+
menuFontInput.value = s;
|
|
1075
|
+
menuFontVal.textContent = s.toFixed(2) + 'x';
|
|
1076
|
+
}
|
|
1077
|
+
function applyVisibleSamples() {
|
|
1078
|
+
// Zoom uPlot's x scale to show only the newest N samples. If N is
|
|
1079
|
+
// null/unset, or >= the plot's full xrange, show everything. This
|
|
1080
|
+
// never adds data — it only hides older samples.
|
|
1081
|
+
const n = settings.visibleSamples;
|
|
1082
|
+
plots.forEach(p => {
|
|
1083
|
+
let lo = 0, hi = p.xrange - 1;
|
|
1084
|
+
if (n && Number.isFinite(n) && n > 0 && n < p.xrange) {
|
|
1085
|
+
lo = p.xrange - n;
|
|
1086
|
+
}
|
|
1087
|
+
try { p.uplot.setScale('x', { min: lo, max: hi }); } catch (e) {}
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
function syncMenuInputs() {
|
|
1091
|
+
menuFontInput.value = Number(settings.fontScale) || 1;
|
|
1092
|
+
menuFontVal.textContent = (Number(settings.fontScale) || 1).toFixed(2) + 'x';
|
|
1093
|
+
menuXrangeInput.value = settings.visibleSamples || '';
|
|
1094
|
+
menuMaxfpsInput.value = settings.maxFps || '';
|
|
1095
|
+
}
|
|
1096
|
+
menuBtn.addEventListener('click', (e) => {
|
|
1097
|
+
e.stopPropagation();
|
|
1098
|
+
menuPanel.classList.toggle('open');
|
|
1099
|
+
});
|
|
1100
|
+
document.addEventListener('click', (e) => {
|
|
1101
|
+
if (!menuPanel.classList.contains('open')) return;
|
|
1102
|
+
if (menuPanel.contains(e.target) || menuBtn.contains(e.target)) return;
|
|
1103
|
+
menuPanel.classList.remove('open');
|
|
1104
|
+
});
|
|
1105
|
+
menuFontInput.addEventListener('input', () => {
|
|
1106
|
+
settings.fontScale = Number(menuFontInput.value);
|
|
1107
|
+
applyFontScale();
|
|
1108
|
+
saveSettings();
|
|
1109
|
+
});
|
|
1110
|
+
menuXrangeInput.addEventListener('change', () => {
|
|
1111
|
+
const v = Number(menuXrangeInput.value);
|
|
1112
|
+
settings.visibleSamples = (Number.isFinite(v) && v > 0) ? v : null;
|
|
1113
|
+
applyVisibleSamples();
|
|
1114
|
+
saveSettings();
|
|
1115
|
+
});
|
|
1116
|
+
menuMaxfpsInput.addEventListener('change', () => {
|
|
1117
|
+
const v = Number(menuMaxfpsInput.value);
|
|
1118
|
+
settings.maxFps = (Number.isFinite(v) && v > 0) ? v : null;
|
|
1119
|
+
saveSettings();
|
|
1120
|
+
});
|
|
1121
|
+
menuResetBtn.addEventListener('click', () => {
|
|
1122
|
+
settings = Object.assign({}, DEFAULT_SETTINGS);
|
|
1123
|
+
saveSettings();
|
|
1124
|
+
syncMenuInputs();
|
|
1125
|
+
applyFontScale();
|
|
1126
|
+
applyVisibleSamples();
|
|
1127
|
+
});
|
|
1128
|
+
applyFontScale();
|
|
1129
|
+
syncMenuInputs();
|
|
908
1130
|
|
|
909
1131
|
const HEADER_SIZE = 16;
|
|
910
1132
|
const MSG_SNAPSHOT = 0;
|
|
@@ -1367,29 +1589,69 @@ INDEX_HTML = """<!doctype html>
|
|
|
1367
1589
|
totalTraces = traceOffset;
|
|
1368
1590
|
applySliderValues(cfg.slider_values);
|
|
1369
1591
|
applyDisplayValues(cfg.display_values);
|
|
1592
|
+
applyVisibleSamples();
|
|
1370
1593
|
scheduleRender();
|
|
1371
1594
|
}
|
|
1372
1595
|
|
|
1596
|
+
// Track render-FPS with a 1-second moving window so the status bar
|
|
1597
|
+
// can show both the data rate (from the server) and the actual
|
|
1598
|
+
// browser repaint rate.
|
|
1599
|
+
let renderFrameCount = 0;
|
|
1600
|
+
let renderFpsLastCheck = performance.now();
|
|
1601
|
+
let renderFps = 0;
|
|
1602
|
+
|
|
1603
|
+
let lastRenderTime = 0;
|
|
1604
|
+
function _doRender() {
|
|
1605
|
+
pendingFrame = false;
|
|
1606
|
+
// Update the render-FPS counter every repaint.
|
|
1607
|
+
renderFrameCount += 1;
|
|
1608
|
+
const now = performance.now();
|
|
1609
|
+
if (now - renderFpsLastCheck >= 1000) {
|
|
1610
|
+
renderFps = renderFrameCount * 1000 / (now - renderFpsLastCheck);
|
|
1611
|
+
renderFrameCount = 0;
|
|
1612
|
+
renderFpsLastCheck = now;
|
|
1613
|
+
lastStatus.dirty = true;
|
|
1614
|
+
}
|
|
1615
|
+
lastRenderTime = now;
|
|
1616
|
+
plots.forEach(p => {
|
|
1617
|
+
const data = [p.xs];
|
|
1618
|
+
for (let t = 0; t < p.traceCount; t++) data.push(p.buffers[t]);
|
|
1619
|
+
// Default resetScales=true so y-axis auto-fits when no yrange
|
|
1620
|
+
// was supplied. With explicit yrange, uPlot pins the scale and
|
|
1621
|
+
// this is essentially a no-op.
|
|
1622
|
+
p.uplot.setData(data);
|
|
1623
|
+
});
|
|
1624
|
+
if (lastStatus.dirty) {
|
|
1625
|
+
const txt =
|
|
1626
|
+
`Data ${lastStatus.fps.toFixed(0)} Hz · Render ${renderFps.toFixed(0)} Hz` +
|
|
1627
|
+
(lastStatus.nonPlot > 0 ? ` · non-plot ${lastStatus.nonPlot}` : '');
|
|
1628
|
+
statusDiv.textContent = txt;
|
|
1629
|
+
statusDiv.className = lastStatus.statusByte === 1 ? 'red' : 'green';
|
|
1630
|
+
lastStatus.dirty = false;
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1373
1634
|
function scheduleRender() {
|
|
1374
1635
|
if (pendingFrame) return;
|
|
1375
1636
|
pendingFrame = true;
|
|
1376
1637
|
requestAnimationFrame(() => {
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1638
|
+
// FPS cap: if the user set a maxFps in the hamburger menu and
|
|
1639
|
+
// we're within 1000/maxFps ms of the last actual repaint,
|
|
1640
|
+
// reschedule ourselves a bit later via setTimeout instead of
|
|
1641
|
+
// running the render now. This spaces repaints out without
|
|
1642
|
+
// losing any data (the ring buffers keep accumulating).
|
|
1643
|
+
const cap = Number(settings.maxFps) || 0;
|
|
1644
|
+
if (cap > 0) {
|
|
1645
|
+
const minInterval = 1000 / cap;
|
|
1646
|
+
const now = performance.now();
|
|
1647
|
+
const wait = minInterval - (now - lastRenderTime);
|
|
1648
|
+
if (wait > 1) {
|
|
1649
|
+
pendingFrame = false;
|
|
1650
|
+
setTimeout(scheduleRender, wait);
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1392
1653
|
}
|
|
1654
|
+
_doRender();
|
|
1393
1655
|
});
|
|
1394
1656
|
}
|
|
1395
1657
|
|
|
@@ -1458,6 +1720,20 @@ INDEX_HTML = """<!doctype html>
|
|
|
1458
1720
|
} else {
|
|
1459
1721
|
zmqMode.textContent = 'ZMQ: --';
|
|
1460
1722
|
}
|
|
1723
|
+
// Color-code the two mode buttons: the currently-active mode is
|
|
1724
|
+
// green and non-clickable (clicking it again would be a no-op);
|
|
1725
|
+
// the other is plain white and clickable so users can see at a
|
|
1726
|
+
// glance which action is available.
|
|
1727
|
+
bindBtn.classList.remove('zmq-active', 'zmq-disabled');
|
|
1728
|
+
connectBtn.classList.remove('zmq-active', 'zmq-disabled');
|
|
1729
|
+
if (mode === 'bind') {
|
|
1730
|
+
bindBtn.classList.add('zmq-active');
|
|
1731
|
+
// Clear the IP field so the user can type a fresh target
|
|
1732
|
+
// without having to manually erase the old value.
|
|
1733
|
+
ipInput.value = '';
|
|
1734
|
+
} else if (mode === 'connect') {
|
|
1735
|
+
connectBtn.classList.add('zmq-active');
|
|
1736
|
+
}
|
|
1461
1737
|
}
|
|
1462
1738
|
|
|
1463
1739
|
function connect() {
|
|
@@ -1497,8 +1773,14 @@ INDEX_HTML = """<!doctype html>
|
|
|
1497
1773
|
});
|
|
1498
1774
|
|
|
1499
1775
|
connectBtn.addEventListener('click', () => {
|
|
1776
|
+
// Already in connect mode — clicking again is a no-op unless
|
|
1777
|
+
// the user has typed a DIFFERENT IP into the input.
|
|
1500
1778
|
const ip = ipInput.value.trim();
|
|
1501
1779
|
if (!ip) { ipInput.focus(); return; }
|
|
1780
|
+
if (connectBtn.classList.contains('zmq-active')) {
|
|
1781
|
+
// Allow re-connecting to a different host while already in
|
|
1782
|
+
// connect mode (retarget). Fall through to send the message.
|
|
1783
|
+
}
|
|
1502
1784
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
1503
1785
|
socket.send(JSON.stringify({ type: 'configure_ip', ip: ip }));
|
|
1504
1786
|
}
|
|
@@ -1509,6 +1791,8 @@ INDEX_HTML = """<!doctype html>
|
|
|
1509
1791
|
});
|
|
1510
1792
|
|
|
1511
1793
|
bindBtn.addEventListener('click', () => {
|
|
1794
|
+
// Binding twice is a no-op; skip the WS round trip.
|
|
1795
|
+
if (bindBtn.classList.contains('zmq-active')) return;
|
|
1512
1796
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
1513
1797
|
socket.send(JSON.stringify({ type: 'bind' }));
|
|
1514
1798
|
}
|
|
@@ -1575,13 +1859,22 @@ async def handle_ws(request):
|
|
|
1575
1859
|
continue
|
|
1576
1860
|
ptype = payload.get("type")
|
|
1577
1861
|
if ptype == "save":
|
|
1578
|
-
|
|
1862
|
+
try:
|
|
1863
|
+
save_current_plot(payload.get("name"))
|
|
1864
|
+
except Exception as exc: # noqa: BLE001
|
|
1865
|
+
print(f"[rtplot] save_current_plot failed: {exc}")
|
|
1579
1866
|
elif ptype == "configure_ip":
|
|
1580
1867
|
ip = payload.get("ip")
|
|
1581
1868
|
if ip:
|
|
1582
|
-
|
|
1869
|
+
try:
|
|
1870
|
+
await reconfigure_zmq(request.app, connect_ip=ip)
|
|
1871
|
+
except Exception as exc: # noqa: BLE001
|
|
1872
|
+
print(f"[rtplot] configure_ip failed: {exc}")
|
|
1583
1873
|
elif ptype == "bind":
|
|
1584
|
-
|
|
1874
|
+
try:
|
|
1875
|
+
await reconfigure_zmq(request.app, connect_ip=None)
|
|
1876
|
+
except Exception as exc: # noqa: BLE001
|
|
1877
|
+
print(f"[rtplot] bind failed: {exc}")
|
|
1585
1878
|
elif ptype == "control_button":
|
|
1586
1879
|
btn_id = payload.get("id")
|
|
1587
1880
|
if btn_id:
|