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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: better-rtplot
3
- Version: 0.2.2
3
+ Version: 0.2.5
4
4
  Summary:
5
5
  License: GPL V3.0
6
6
  Author: jmontp
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "better-rtplot"
3
- version = "0.2.2"
3
+ version = "0.2.5"
4
4
  description = ""
5
5
  authors = ["jmontp <jmontp@umich.edu>"]
6
6
  license = "GPL V3.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
- import pandas as pd # local import keeps Parquet deps optional
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
- save_current_plot(log_name)
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 = _open_zmq_socket(connect_ip=connect_ip)
780
+ zmq_socket = _try_open_zmq(connect_ip)
725
781
  except Exception as exc: # noqa: BLE001
726
- # Fall back to bind so the server keeps running and tell the browser.
727
- print(f"ZMQ reconfigure failed ({exc}); falling back to bind")
728
- zmq_socket = _open_zmq_socket(connect_ip=None)
729
- connect_ip = None
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 = _open_control_socket(connect_ip=connect_ip)
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(14px + 2px); }
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\">&#9776;</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
- pendingFrame = false;
1378
- plots.forEach(p => {
1379
- const data = [p.xs];
1380
- for (let t = 0; t < p.traceCount; t++) data.push(p.buffers[t]);
1381
- // Default resetScales=true so y-axis auto-fits when no yrange
1382
- // was supplied. With explicit yrange, uPlot pins the scale and
1383
- // this is essentially a no-op.
1384
- p.uplot.setData(data);
1385
- });
1386
- if (lastStatus.dirty) {
1387
- const txt = `Rate: ${lastStatus.fps.toFixed(0)} Hz` +
1388
- (lastStatus.nonPlot > 0 ? ` - Non Plot Trace: ${lastStatus.nonPlot}` : '');
1389
- statusDiv.textContent = txt;
1390
- statusDiv.className = lastStatus.statusByte === 1 ? 'red' : 'green';
1391
- lastStatus.dirty = false;
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
- save_current_plot(payload.get("name"))
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
- await reconfigure_zmq(request.app, connect_ip=ip)
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
- await reconfigure_zmq(request.app, connect_ip=None)
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: