better-rtplot 0.4.11__tar.gz → 0.4.15__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.
Files changed (37) hide show
  1. better_rtplot-0.4.15/LICENSE +21 -0
  2. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/PKG-INFO +11 -4
  3. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/README.md +8 -1
  4. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/pyproject.toml +2 -2
  5. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/client.py +17 -1
  6. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/interactive_test.py +0 -0
  7. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/saved_plots/.gitignore +0 -0
  8. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/server_browser.py +118 -13
  9. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/server_browser_gui.py +0 -0
  10. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/static/index.html +40 -13
  11. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/static/katex/auto-render.min.js +0 -0
  12. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/static/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  13. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  14. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  15. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/static/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  16. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/static/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  17. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/static/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  18. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/static/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  19. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/static/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  20. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/static/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  21. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/static/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  22. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/static/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  23. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/static/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  24. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/static/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  25. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/static/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  26. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/static/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  27. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/static/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  28. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/static/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  29. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/static/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  30. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/static/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  31. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/static/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  32. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/static/katex/katex.min.css +0 -0
  33. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/static/katex/katex.min.js +0 -0
  34. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/static/uPlot.iife.min.js +0 -0
  35. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/static/uPlot.min.css +0 -0
  36. better_rtplot-0.4.11/LICENSE +0 -674
  37. {better_rtplot-0.4.11 → better_rtplot-0.4.15}/rtplot/server.py +0 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jmontp
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: better-rtplot
3
- Version: 0.4.11
3
+ Version: 0.4.15
4
4
  Summary:
5
- License: GPL V3.0
5
+ License: MIT
6
6
  Author: jmontp
7
7
  Author-email: jmontp@umich.edu
8
8
  Requires-Python: >=3.9,<3.13
9
- Classifier: License :: Other/Proprietary License
9
+ Classifier: License :: OSI Approved :: MIT License
10
10
  Classifier: Programming Language :: Python :: 3
11
11
  Classifier: Programming Language :: Python :: 3.9
12
12
  Classifier: Programming Language :: Python :: 3.10
@@ -67,6 +67,11 @@ Sender and server don't have to be on the same machine — see the
67
67
  No Python needed on the viewing machine. On Windows the binary opens
68
68
  a small Tk status window showing the listening URL.
69
69
 
70
+ To arrange several plots side by side, start the server with
71
+ `./rtplot-server --columns 2` (or `--columns 3`). The Python server accepts
72
+ the same option: `python -m rtplot.server_browser --columns 2`. See the
73
+ [multi-column example](examples/05_multi_column/).
74
+
70
75
  **Client** — pip install in the env that runs your script:
71
76
 
72
77
  ```bash
@@ -127,8 +132,10 @@ A rising line now draws itself in the browser tab.
127
132
  - **Config lives with the data.** The sender declares plot layout.
128
133
  - **Interactive controls.** Buttons, sliders, dials, displays — polled
129
134
  from your loop, no threads, no callbacks.
135
+ - **LaTeX labels.** Use `$...$` or `\(...\)` in plot and control text;
136
+ the server bundles KaTeX and works offline.
130
137
  - **Static HTML snapshots.** `save_snapshot("out.html")` writes a
131
- self-contained ~65 KB file with the current trace embedded.
138
+ self-contained file with the current trace and math rendering embedded.
132
139
 
133
140
  ---
134
141
 
@@ -43,6 +43,11 @@ Sender and server don't have to be on the same machine — see the
43
43
  No Python needed on the viewing machine. On Windows the binary opens
44
44
  a small Tk status window showing the listening URL.
45
45
 
46
+ To arrange several plots side by side, start the server with
47
+ `./rtplot-server --columns 2` (or `--columns 3`). The Python server accepts
48
+ the same option: `python -m rtplot.server_browser --columns 2`. See the
49
+ [multi-column example](examples/05_multi_column/).
50
+
46
51
  **Client** — pip install in the env that runs your script:
47
52
 
48
53
  ```bash
@@ -103,8 +108,10 @@ A rising line now draws itself in the browser tab.
103
108
  - **Config lives with the data.** The sender declares plot layout.
104
109
  - **Interactive controls.** Buttons, sliders, dials, displays — polled
105
110
  from your loop, no threads, no callbacks.
111
+ - **LaTeX labels.** Use `$...$` or `\(...\)` in plot and control text;
112
+ the server bundles KaTeX and works offline.
106
113
  - **Static HTML snapshots.** `save_snapshot("out.html")` writes a
107
- self-contained ~65 KB file with the current trace embedded.
114
+ self-contained file with the current trace and math rendering embedded.
108
115
 
109
116
  ---
110
117
 
@@ -1,9 +1,9 @@
1
1
  [tool.poetry]
2
2
  name = "better-rtplot"
3
- version = "0.4.11"
3
+ version = "0.4.15"
4
4
  description = ""
5
5
  authors = ["jmontp <jmontp@umich.edu>"]
6
- license = "GPL V3.0"
6
+ license = "MIT"
7
7
  readme = "README.md"
8
8
  packages = [{include = "rtplot"}]
9
9
  include = ["rtplot/static/*", "rtplot/static/katex/*", "rtplot/static/katex/fonts/*"]
@@ -90,6 +90,7 @@ else:
90
90
  SENDING_PLOT_UPDATE = "0"
91
91
  SENDING_DATA = "1"
92
92
  SENDING_DISPLAY = "4"
93
+ SENDING_TEXT_INPUT = "5"
93
94
 
94
95
  #Lightweight result type returned by poll_controls()
95
96
  ControlState = namedtuple("ControlState", ["values", "buttons"])
@@ -138,6 +139,7 @@ class Plot:
138
139
  names: List[str]
139
140
  colors: Optional[List[str]] = None
140
141
  line_style: Optional[List[str]] = None
142
+ show: Optional[List[bool]] = None
141
143
  line_width: Optional[float] = None
142
144
  title: Optional[str] = None
143
145
  xlabel: Optional[str] = None
@@ -151,6 +153,7 @@ class Plot:
151
153
  "names": list(self.names),
152
154
  "colors": list(self.colors) if self.colors is not None else None,
153
155
  "line_style": list(self.line_style) if self.line_style is not None else None,
156
+ "show": list(self.show) if self.show is not None else None,
154
157
  "line_width": self.line_width,
155
158
  "title": self.title,
156
159
  "xlabel": self.xlabel,
@@ -590,6 +593,18 @@ def set_display(display_id: str, value):
590
593
  socket.send_json({"id": str(display_id), "value": payload_value})
591
594
 
592
595
 
596
+ def set_text_input(input_id: str, value):
597
+ """Push a text_input control value to the browser and server state.
598
+
599
+ This is the editable-control companion to set_display(). It updates
600
+ declared 'text_input' controls without rebuilding the full plot config,
601
+ and the server keeps the value as the current control state returned by
602
+ poll_controls().
603
+ """
604
+ socket.send_string(SENDING_TEXT_INPUT, zmq.SNDMORE)
605
+ socket.send_json({"id": str(input_id), "value": str(value)})
606
+
607
+
593
608
  def poll_controls():
594
609
  """Drain the return channel non-blocking and return current control state.
595
610
 
@@ -644,7 +659,8 @@ def save_snapshot(path, server_url=None, animate=False, timeout=5.0):
644
659
 
645
660
  The server exposes a ``/snapshot.html`` endpoint that renders the
646
661
  current plot state as a self-contained HTML file with uPlot's JS +
647
- CSS inlined plus the most recent window of trace data. The result
662
+ CSS inlined plus the most recent window of trace data. Vendored KaTeX
663
+ assets are also inlined, so LaTeX plot labels render offline. The result
648
664
  opens in any browser offline and looks pixel-identical to what the
649
665
  live tab was showing. Ideal for committing reproducible example
650
666
  previews to a repo or emailing a static "here's what I was seeing"
@@ -20,7 +20,9 @@ subscribed tab's PUSH socket.
20
20
 
21
21
  import argparse
22
22
  import asyncio
23
+ import base64
23
24
  import dataclasses
25
+ import hashlib
24
26
  import json
25
27
  import os
26
28
  import socket
@@ -96,8 +98,17 @@ parser.add_argument(
96
98
  parser.add_argument(
97
99
  "-c",
98
100
  "--column",
99
- help="Create new plots in separate columns instead of rows",
100
- action="store_false",
101
+ dest="columns",
102
+ help="Shortcut for --columns 2",
103
+ action="store_const",
104
+ const=2,
105
+ )
106
+
107
+ parser.add_argument(
108
+ "--columns",
109
+ type=int,
110
+ metavar="N",
111
+ help="Arrange plots in N columns (default: 1)",
101
112
  )
102
113
 
103
114
  parser.add_argument(
@@ -178,7 +189,9 @@ parser.add_argument(
178
189
 
179
190
  args = parser.parse_args()
180
191
 
181
- NEW_SUBPLOT_IN_ROW = args.column
192
+ PLOT_COLUMNS = args.columns if args.columns is not None else 1
193
+ if PLOT_COLUMNS < 1:
194
+ parser.error("--columns must be at least 1")
182
195
  DEBUG_TEXT_ENABLED = args.debug
183
196
  SKIP_PLOT_DATAPOINTS = args.skip
184
197
  ADAPT_SKIP_PLOT_DATAPOINTS = args.adaptable
@@ -215,6 +228,7 @@ RECEIVED_PLOT_UPDATE = 0
215
228
  RECEIVED_DATA = 1
216
229
  SAVE_PLOT = 3
217
230
  RECEIVED_DISPLAY = 4
231
+ RECEIVED_TEXT_INPUT = 5
218
232
 
219
233
  BIND_ME_ID = "bind_me"
220
234
  BIND_ME_NAME = "Shared - Bind to me"
@@ -376,6 +390,7 @@ class Tab:
376
390
  control_rows: list = field(default_factory=list)
377
391
  slider_values: dict = field(default_factory=dict)
378
392
  text_values: dict = field(default_factory=dict)
393
+ text_dirty: set = field(default_factory=set)
379
394
  display_values: dict = field(default_factory=dict)
380
395
  display_dirty: set = field(default_factory=set)
381
396
  fps: float = 0.0
@@ -561,6 +576,7 @@ def parse_config(tab: Tab, json_config):
561
576
  for text_id, default_value in text_values.items()
562
577
  if text_id in active_text_ids
563
578
  }
579
+ tab.text_dirty = {text_id for text_id in tab.text_dirty if text_id in active_text_ids}
564
580
  tab.layout = layout
565
581
 
566
582
  num_traces = sum(traces_per_plot)
@@ -602,7 +618,8 @@ def build_config_message(tab: Tab, config_dict) -> dict:
602
618
  "slider_values": dict(tab.slider_values),
603
619
  "text_values": dict(tab.text_values),
604
620
  "display_values": dict(tab.display_values),
605
- "row_layout": bool(NEW_SUBPLOT_IN_ROW),
621
+ "columns": PLOT_COLUMNS,
622
+ "row_layout": PLOT_COLUMNS == 1,
606
623
  }
607
624
 
608
625
 
@@ -1056,6 +1073,20 @@ async def zmq_receiver(tab: Tab):
1056
1073
  tab.display_values[display_id] = value
1057
1074
  tab.display_dirty.add(display_id)
1058
1075
 
1076
+ elif category == RECEIVED_TEXT_INPUT:
1077
+ payload = await sock.recv_json()
1078
+ text_id = payload.get("id")
1079
+ if text_id is None:
1080
+ continue
1081
+ value = str(payload.get("value", ""))
1082
+ if tab.text_values.get(text_id) != value:
1083
+ tab.text_values[text_id] = value
1084
+ tab.text_dirty.add(text_id)
1085
+ refresh_config_message(tab)
1086
+ await send_control_event(
1087
+ tab, {"type": "text", "id": text_id, "value": value}
1088
+ )
1089
+
1059
1090
 
1060
1091
  ###############################
1061
1092
  # Pusher tasks #
@@ -1078,6 +1109,23 @@ async def display_pusher():
1078
1109
  )
1079
1110
 
1080
1111
 
1112
+ async def text_input_pusher():
1113
+ """Push dirty text_input values to viewers at ~30 Hz, per tab."""
1114
+ target_dt = 1.0 / 30.0
1115
+ while True:
1116
+ await asyncio.sleep(target_dt)
1117
+ if not ws_clients:
1118
+ continue
1119
+ for t in list(tabs.values()):
1120
+ if not t.text_dirty:
1121
+ continue
1122
+ values = {k: t.text_values[k] for k in t.text_dirty}
1123
+ t.text_dirty = set()
1124
+ await broadcast_text_tab(
1125
+ t.id, {"type": "text_update", "tab": t.id, "values": values}
1126
+ )
1127
+
1128
+
1081
1129
  async def ws_pusher():
1082
1130
  """Push new samples to viewers as binary delta frames, per tab."""
1083
1131
  target_dt = 1.0 / max(1, args.rate)
@@ -1324,8 +1372,21 @@ def _read_static_asset(name: str) -> str:
1324
1372
  return fh.read()
1325
1373
 
1326
1374
 
1327
- # Loaded once at import time so per-request cost is just the send.
1375
+ # Loaded once at import time so per-request cost is just the send. Version the
1376
+ # asset URLs by their contents: browsers can otherwise keep an old or damaged
1377
+ # KaTeX script after the executable is updated while fetching the new HTML.
1328
1378
  _INDEX_HTML = _read_static_asset("index.html")
1379
+ for _asset in (
1380
+ "uPlot.min.css",
1381
+ "uPlot.iife.min.js",
1382
+ "katex/katex.min.css",
1383
+ "katex/katex.min.js",
1384
+ "katex/auto-render.min.js",
1385
+ ):
1386
+ _digest = hashlib.sha256(_read_static_asset(_asset).encode("utf-8")).hexdigest()[:12]
1387
+ _INDEX_HTML = _INDEX_HTML.replace(
1388
+ f'"/static/{_asset}"', f'"/static/{_asset}?v={_digest}"'
1389
+ )
1329
1390
 
1330
1391
 
1331
1392
  async def handle_index(request):
@@ -1464,12 +1525,13 @@ _SNAPSHOT_TEMPLATE = """<!doctype html>
1464
1525
  <meta charset="utf-8" />
1465
1526
  <title>rtplot snapshot</title>
1466
1527
  <style>__UPLOT_CSS__</style>
1528
+ <style>__KATEX_CSS__</style>
1467
1529
  <style>
1468
1530
  html, body { margin: 0; padding: 0; background: #fafafa; color: #222;
1469
1531
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
1470
- #plots { display: flex; flex-direction: column; gap: 12px;
1471
- max-width: 900px; margin: 0 auto; padding: 20px 16px; }
1472
- .plot-wrap { background: #fff; border: 1px solid #ddd; border-radius: 4px; padding: 8px; }
1532
+ #plots { display: grid; grid-template-columns: repeat(var(--plot-columns, 1), minmax(320px, 1fr));
1533
+ gap: 12px; overflow-x: auto; max-width: 900px; margin: 0 auto; padding: 20px 16px; }
1534
+ .plot-wrap { background: #fff; border: 1px solid #ddd; border-radius: 4px; padding: 8px; min-width: 0; }
1473
1535
  .snap-footer { text-align: center; font-size: 12px; color: #999; padding: 8px 0 20px; }
1474
1536
  .snap-footer a { color: #2a5db0; text-decoration: none; }
1475
1537
  .snap-footer a:hover { text-decoration: underline; }
@@ -1483,10 +1545,14 @@ _SNAPSHOT_TEMPLATE = """<!doctype html>
1483
1545
  &middot; drag to zoom, double-click to reset
1484
1546
  </p>
1485
1547
  <script>__UPLOT_JS__</script>
1548
+ <script>__KATEX_JS__</script>
1549
+ <script>__KATEX_AUTO_RENDER_JS__</script>
1486
1550
  <script>
1487
1551
  (function () {
1488
1552
  const SNAP = __SNAPSHOT_JSON__;
1489
1553
  const plotsDiv = document.getElementById('plots');
1554
+ plotsDiv.style.setProperty('--plot-columns', SNAP.columns || 1);
1555
+ plotsDiv.style.maxWidth = `${900 * (SNAP.columns || 1)}px`;
1490
1556
  const COLOR_MAP = { r:'rgb(255,0,0)', g:'rgb(0,200,0)', b:'rgb(0,0,255)',
1491
1557
  c:'rgb(0,200,200)', m:'rgb(200,0,200)',
1492
1558
  y:'rgb(200,200,0)', k:'rgb(0,0,0)' };
@@ -1526,7 +1592,7 @@ _SNAPSHOT_TEMPLATE = """<!doctype html>
1526
1592
  data.push(Float64Array.from(src));
1527
1593
  }
1528
1594
  const opts = {
1529
- width: Math.max(640, plotsDiv.clientWidth - 40),
1595
+ width: Math.max(240, wrap.clientWidth - 16),
1530
1596
  height: 260,
1531
1597
  title: pcfg.title || '',
1532
1598
  scales: {
@@ -1539,10 +1605,19 @@ _SNAPSHOT_TEMPLATE = """<!doctype html>
1539
1605
  cursor: { drag: { x: true, y: false } },
1540
1606
  };
1541
1607
  const u = new uPlot(opts, data, wrap);
1542
- plots.push({ uplot: u, data: data, xs: xs, xrange: xrange,
1608
+ plots.push({ uplot: u, wrap: wrap, data: data, xs: xs, xrange: xrange,
1543
1609
  traceCount: traceCount, startIdx: traceOffset });
1544
1610
  traceOffset += traceCount;
1545
1611
  });
1612
+ renderMathInElement(plotsDiv, {
1613
+ delimiters: [
1614
+ { left: '$$', right: '$$', display: true },
1615
+ { left: '\\\\[', right: '\\\\]', display: true },
1616
+ { left: '\\\\(', right: '\\\\)', display: false },
1617
+ { left: '$', right: '$', display: false },
1618
+ ],
1619
+ throwOnError: false,
1620
+ });
1546
1621
  if (SNAP.animate) {
1547
1622
  let phase = 0;
1548
1623
  setInterval(function () {
@@ -1564,7 +1639,7 @@ _SNAPSHOT_TEMPLATE = """<!doctype html>
1564
1639
  window.addEventListener('resize', function () {
1565
1640
  plots.forEach(function (p) {
1566
1641
  p.uplot.setSize({
1567
- width: Math.max(640, plotsDiv.clientWidth - 40),
1642
+ width: Math.max(240, p.wrap.clientWidth - 16),
1568
1643
  height: 260,
1569
1644
  });
1570
1645
  });
@@ -1579,9 +1654,31 @@ _SNAPSHOT_TEMPLATE = """<!doctype html>
1579
1654
  try:
1580
1655
  _UPLOT_JS = _read_static_asset("uPlot.iife.min.js")
1581
1656
  _UPLOT_CSS = _read_static_asset("uPlot.min.css")
1657
+ _KATEX_JS = _read_static_asset(os.path.join("katex", "katex.min.js"))
1658
+ _KATEX_AUTO_RENDER_JS = _read_static_asset(
1659
+ os.path.join("katex", "auto-render.min.js")
1660
+ )
1661
+ _KATEX_CSS = _read_static_asset(os.path.join("katex", "katex.min.css"))
1662
+ # Snapshot files have no sibling font directory. Inline the vendored
1663
+ # WOFF2 faces so equations keep their layout when opened offline.
1664
+ _KATEX_FONT_DIR = os.path.join(
1665
+ os.path.dirname(os.path.abspath(__file__)), "static", "katex", "fonts"
1666
+ )
1667
+ for _font_name in os.listdir(_KATEX_FONT_DIR):
1668
+ if not _font_name.endswith(".woff2"):
1669
+ continue
1670
+ with open(os.path.join(_KATEX_FONT_DIR, _font_name), "rb") as _font_file:
1671
+ _font_data = base64.b64encode(_font_file.read()).decode("ascii")
1672
+ _KATEX_CSS = _KATEX_CSS.replace(
1673
+ f"url(fonts/{_font_name})",
1674
+ f"url(data:font/woff2;base64,{_font_data})",
1675
+ )
1582
1676
  except Exception as _exc: # noqa: BLE001
1583
1677
  _UPLOT_JS = f"/* uPlot load failed: {_exc} */"
1584
1678
  _UPLOT_CSS = ""
1679
+ _KATEX_JS = f"/* KaTeX load failed: {_exc} */"
1680
+ _KATEX_AUTO_RENDER_JS = ""
1681
+ _KATEX_CSS = ""
1585
1682
 
1586
1683
 
1587
1684
  def _build_snapshot_html(tab: Tab, animate: bool) -> str:
@@ -1624,6 +1721,7 @@ def _build_snapshot_html(tab: Tab, animate: bool) -> str:
1624
1721
 
1625
1722
  payload = {
1626
1723
  "plots": plots,
1724
+ "columns": PLOT_COLUMNS,
1627
1725
  "num_samples": int(hi - lo),
1628
1726
  "trace_data": trace_data,
1629
1727
  "animate": bool(animate),
@@ -1631,7 +1729,13 @@ def _build_snapshot_html(tab: Tab, animate: bool) -> str:
1631
1729
  html = _SNAPSHOT_TEMPLATE
1632
1730
  html = html.replace("__UPLOT_CSS__", _UPLOT_CSS)
1633
1731
  html = html.replace("__UPLOT_JS__", _UPLOT_JS)
1634
- html = html.replace("__SNAPSHOT_JSON__", json.dumps(payload))
1732
+ html = html.replace("__KATEX_CSS__", _KATEX_CSS)
1733
+ html = html.replace("__KATEX_JS__", _KATEX_JS)
1734
+ html = html.replace("__KATEX_AUTO_RENDER_JS__", _KATEX_AUTO_RENDER_JS)
1735
+ # Keep client-supplied labels from closing the script element while
1736
+ # preserving their exact value when JavaScript parses the payload.
1737
+ snapshot_json = json.dumps(payload).replace("<", "\\u003c")
1738
+ html = html.replace("__SNAPSHOT_JSON__", snapshot_json)
1635
1739
  return html
1636
1740
 
1637
1741
 
@@ -1682,11 +1786,12 @@ async def on_startup(app):
1682
1786
 
1683
1787
  app["ws_task"] = asyncio.create_task(ws_pusher())
1684
1788
  app["display_task"] = asyncio.create_task(display_pusher())
1789
+ app["text_input_task"] = asyncio.create_task(text_input_pusher())
1685
1790
  app["resources_task"] = asyncio.create_task(resources_pusher())
1686
1791
 
1687
1792
 
1688
1793
  async def on_cleanup(app):
1689
- for key in ("ws_task", "display_task", "resources_task"):
1794
+ for key in ("ws_task", "display_task", "text_input_task", "resources_task"):
1690
1795
  await _cancel_task(app.get(key))
1691
1796
  for t in list(tabs.values()):
1692
1797
  await _cancel_task(t.receiver_task)
@@ -62,10 +62,9 @@
62
62
  #ws-status { font-size: calc(12px * var(--ui-scale)); color: #666; margin-left: auto; }
63
63
 
64
64
  /* -------- Plots + controls (existing) -------- */
65
- #plots { display: flex; padding: 12px; gap: 12px; }
66
- #plots.row { flex-direction: column; }
67
- #plots.col { flex-direction: row; flex-wrap: wrap; }
68
- .plot-wrap { background: #fff; border: 1px solid #ddd; border-radius: 4px; padding: 8px; flex: 1 1 auto; min-width: 320px; }
65
+ #plots { display: grid; grid-template-columns: repeat(var(--plot-columns, 1), minmax(320px, 1fr)); padding: 12px; gap: 12px; overflow-x: auto; }
66
+ .plot-wrap { background: #fff; border: 1px solid #ddd; border-radius: 4px; padding: 8px; min-width: 0; }
67
+ #plots .ctrl-row, #plots #empty-state { grid-column: 1 / -1; }
69
68
  .plot-title { font-size: 13px; font-weight: 600; margin: 0 0 4px 4px; color: #333; }
70
69
  #empty-state { padding: 28px 32px; color: #888; font-size: calc(14px * var(--ui-scale)); font-style: italic; }
71
70
  /* Persistent banner the server raises when a client-supplied plot
@@ -265,6 +264,8 @@
265
264
  // a typo in someone's label can't blow up the whole page.
266
265
  const _latexDelims = [
267
266
  { left: '$$', right: '$$', display: true },
267
+ { left: '\\[', right: '\\]', display: true },
268
+ { left: '\\(', right: '\\)', display: false },
268
269
  { left: '$', right: '$', display: false },
269
270
  ];
270
271
  function renderLatexIn(el) {
@@ -301,6 +302,26 @@
301
302
  const resTabRates = document.getElementById('res-tab-rates');
302
303
  const resUnavail = document.getElementById('res-unavail');
303
304
 
305
+ // A failed KaTeX download can still leave auto-render defined, but its
306
+ // closure then holds an undefined KaTeX reference. Retry both scripts
307
+ // in order with fresh URLs and render labels already on the page.
308
+ if (typeof katex === 'undefined') {
309
+ const retry = Date.now();
310
+ const core = document.createElement('script');
311
+ core.src = `/static/katex/katex.min.js?retry=${retry}`;
312
+ core.onload = () => {
313
+ if (typeof katex === 'undefined') return;
314
+ const autoRender = document.createElement('script');
315
+ autoRender.src = `/static/katex/auto-render.min.js?retry=${retry}`;
316
+ autoRender.onload = () => {
317
+ renderLatexIn(plotsDiv);
318
+ tabbar.querySelectorAll('.tab-name').forEach(renderLatexIn);
319
+ };
320
+ document.head.appendChild(autoRender);
321
+ };
322
+ document.head.appendChild(core);
323
+ }
324
+
304
325
  // ---- Detect the monitor's refresh rate on page load ----
305
326
  let monitorHz = 0;
306
327
  (function measureMonitorHz() {
@@ -810,6 +831,7 @@
810
831
  const colors = pcfg.colors || DEFAULT_COLORS;
811
832
  const widths = pcfg.line_width || [];
812
833
  const styles = pcfg.line_style || [];
834
+ const shows = pcfg.show || [];
813
835
 
814
836
  const series = [{}];
815
837
  for (let t = 0; t < traceCount; t++) {
@@ -821,6 +843,7 @@
821
843
  dash: dash || undefined,
822
844
  points: { show: false },
823
845
  spanGaps: true,
846
+ show: shows[t] !== false,
824
847
  });
825
848
  }
826
849
 
@@ -829,8 +852,12 @@
829
852
  const plotHeight = (Number.isFinite(heightMul) && heightMul > 0)
830
853
  ? Math.round(baseHeight * heightMul)
831
854
  : baseHeight;
855
+ const wrap = document.createElement('div');
856
+ wrap.className = 'plot-wrap';
857
+ plotsDiv.appendChild(wrap);
858
+
832
859
  const opts = {
833
- width: Math.max(640, plotsDiv.clientWidth - 40),
860
+ width: Math.max(240, wrap.clientWidth - 16),
834
861
  height: plotHeight,
835
862
  title: pcfg.title || '',
836
863
  scales: {
@@ -846,10 +873,6 @@
846
873
  cursor: { drag: { x: false, y: false } },
847
874
  };
848
875
 
849
- const wrap = document.createElement('div');
850
- wrap.className = 'plot-wrap';
851
- plotsDiv.appendChild(wrap);
852
-
853
876
  const buffers = [];
854
877
  for (let t = 0; t < traceCount; t++) {
855
878
  const buf = new Float32Array(xrange);
@@ -863,6 +886,7 @@
863
886
  const u = new uPlot(opts, initialData, wrap);
864
887
  plots.push({
865
888
  uplot: u,
889
+ wrap: wrap,
866
890
  xs: xs,
867
891
  traceCount: traceCount,
868
892
  startIdx: traceOffset,
@@ -875,8 +899,8 @@
875
899
 
876
900
  function buildPlots(cfg) {
877
901
  destroyPlots();
878
- plotsDiv.classList.toggle('row', cfg.row_layout);
879
- plotsDiv.classList.toggle('col', !cfg.row_layout);
902
+ const columns = Number(cfg.columns) || (cfg.row_layout === false ? 2 : 1);
903
+ plotsDiv.style.setProperty('--plot-columns', Math.max(1, Math.floor(columns)));
880
904
 
881
905
  const controls = cfg.controls || [];
882
906
  const layout = (cfg.layout && cfg.layout.length)
@@ -889,7 +913,7 @@
889
913
  if (entry.kind === 'plot') {
890
914
  const pcfg = cfg.plots[entry.index];
891
915
  if (!pcfg) return;
892
- traceOffset += buildOnePlot(pcfg, cfg.row_layout, traceOffset);
916
+ traceOffset += buildOnePlot(pcfg, columns === 1, traceOffset);
893
917
  } else if (entry.kind === 'controls') {
894
918
  const row = controls[entry.index];
895
919
  if (!row) return;
@@ -1320,6 +1344,9 @@
1320
1344
  } else if (msg.type === 'display_update') {
1321
1345
  if (msg.tab && msg.tab !== activeTab) return;
1322
1346
  applyDisplayValues(msg.values);
1347
+ } else if (msg.type === 'text_update') {
1348
+ if (msg.tab && msg.tab !== activeTab) return;
1349
+ applyTextValues(msg.values);
1323
1350
  } else if (msg.type === 'peer_count') {
1324
1351
  const n = Number(msg.count) || 0;
1325
1352
  if (lastStatus.peers !== n) {
@@ -1339,7 +1366,7 @@
1339
1366
  window.addEventListener('resize', () => {
1340
1367
  plots.forEach(p => {
1341
1368
  p.uplot.setSize({
1342
- width: Math.max(640, plotsDiv.clientWidth - 40),
1369
+ width: Math.max(240, p.wrap.clientWidth - 16),
1343
1370
  height: p.height,
1344
1371
  });
1345
1372
  });