better-rtplot 0.2.1__tar.gz → 0.2.2__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.1
3
+ Version: 0.2.2
4
4
  Summary:
5
5
  License: GPL V3.0
6
6
  Author: jmontp
@@ -29,9 +29,9 @@ Description-Content-Type: text/markdown
29
29
 
30
30
  **rtplot** lets a Python script push live data to a plot window — locally, or
31
31
  across the network — with a few lines of code on the sender side. The plot
32
- window can be a traditional Qt application or a modern browser UI, and it
33
- also supports interactive controls (buttons, sliders, dials, text and
34
- numeric displays) that feed values back into the sending script in real time.
32
+ window runs in any modern browser and supports interactive controls
33
+ (buttons, sliders, dials, text and numeric displays) that feed values
34
+ back into the sending script in real time.
35
35
 
36
36
  Typical use: a robot or data-acquisition script runs on a Raspberry Pi or
37
37
  microcontroller host, and you watch live signals and tweak gains from a
@@ -44,7 +44,6 @@ laptop on the same network.
44
44
  - [Highlights](#highlights)
45
45
  - [Install](#install)
46
46
  - [60-second quickstart](#60-second-quickstart)
47
- - [Choosing a server: browser vs. Qt](#choosing-a-server-browser-vs-qt)
48
47
  - [Interactive controls](#interactive-controls)
49
48
  - [Reading controls from Python](#reading-controls-from-python)
50
49
  - [Pushing values into displays](#pushing-values-into-displays)
@@ -53,6 +52,7 @@ laptop on the same network.
53
52
  - [Sending data](#sending-data)
54
53
  - [Saving data](#saving-data)
55
54
  - [Networking modes](#networking-modes)
55
+ - [Viewing the plot from another device](#viewing-the-plot-from-another-device)
56
56
  - [Performance tuning](#performance-tuning)
57
57
  - [CLI reference](#cli-reference)
58
58
  - [Examples](#examples)
@@ -61,18 +61,23 @@ laptop on the same network.
61
61
 
62
62
  ## Highlights
63
63
 
64
- - **Fast.** 500+ fps on a single trace on a modern laptop. Binary WebSocket
65
- deltas on the browser server; raw Qt rendering on the desktop server.
66
- - **Two frontends.** A new browser-based server (aiohttp + uPlot) and the
67
- original pyqtgraph desktop server. Both speak the same ZMQ protocol, so
68
- client code is identical.
69
- - **Remote-friendly.** Either the sender or the plot host can bind — pick
70
- whichever fits your network. Works across LAN, WSL, and SSH tunnels.
71
- - **Plot config lives with the data.** The sender declares the plot layout,
72
- so a Pi running your experiment owns the look of its own dashboards.
73
- - **Interactive controls** *(browser server only)*. Declare buttons,
74
- sliders, dials, numeric/text displays in the same `initialize_plots`
75
- call. Poll from your tight loop; no threads, no callbacks.
64
+ - **Fast.** Binary WebSocket deltas push data at up to 1 kHz. The
65
+ browser coalesces incoming samples into a single repaint per
66
+ `requestAnimationFrame`, so rendering runs at your monitor's refresh
67
+ rate (typically 60 Hz, 120 Hz on higher-refresh displays) regardless
68
+ of how fast samples arrive.
69
+ - **Browser-based.** The plot window is served by aiohttp and rendered
70
+ by uPlot in any modern browser. No desktop GUI toolkit to install,
71
+ works over SSH port forwarding out of the box.
72
+ - **Remote-friendly.** Either the sender or the plot host can bind
73
+ pick whichever fits your network. Works across LAN, WSL, and SSH
74
+ tunnels.
75
+ - **Plot config lives with the data.** The sender declares the plot
76
+ layout, so a Pi running your experiment owns the look of its own
77
+ dashboards.
78
+ - **Interactive controls.** Declare buttons, sliders, dials,
79
+ numeric/text displays in the same `initialize_plots` call. Poll from
80
+ your tight loop; no threads, no callbacks.
76
81
  - **Save to Parquet** with a single button click or `client.save_plot()`
77
82
  call.
78
83
 
@@ -80,45 +85,41 @@ laptop on the same network.
80
85
 
81
86
  ## Install
82
87
 
83
- Minimum installjust the client (send data only):
84
-
85
- ```bash
86
- pip install better-rtplot
87
- ```
88
-
89
- Add the browser server (recommended):
88
+ Install rtplot with the server bundle this is the normal path and
89
+ gets you everything:
90
90
 
91
91
  ```bash
92
92
  pip install "better-rtplot[browser]"
93
93
  ```
94
94
 
95
- Add the Qt/pyqtgraph server instead:
95
+ This pulls `aiohttp` (for serving the plot UI) plus `pandas` + `pyarrow`
96
+ (for saving runs to Parquet). If you only need the sender side — your
97
+ script pushes data to someone else's plot host and you don't run a
98
+ server locally — you can install the client-only minimum instead:
96
99
 
97
100
  ```bash
98
- pip install "better-rtplot[server]"
101
+ pip install better-rtplot
99
102
  ```
100
103
 
101
- The `browser` extra pulls `aiohttp` + `pandas` + `pyarrow`; the `server`
102
- extra pulls `pyqtgraph` + `PySide6` + `pandas` + `pyarrow`. If you only
103
- `pip install better-rtplot` and try to launch a server, rtplot will print
104
- a friendly message telling you which extra to add.
104
+ In that case, if you later try to launch a server locally you'll get a
105
+ clear error telling you to add the `[browser]` extra.
105
106
 
106
- WSL users: the browser server works out of the box open the URL it
107
- prints in your Windows browser. The Qt server needs an X server such as
108
- [VcXsrv](https://sourceforge.net/projects/vcxsrv/).
107
+ WSL users: nothing extra needed. The plot window is served by HTTP, so
108
+ just open the URL rtplot prints in your Windows browser.
109
109
 
110
110
  ---
111
111
 
112
112
  ## 60-second quickstart
113
113
 
114
- **Terminal 1 — start a plot window:**
114
+ **Terminal 1 — start the plot server:**
115
115
 
116
116
  ```bash
117
- python -m rtplot.server_browser # browser UI at http://localhost:8050
118
- # or
119
- python -m rtplot.server # desktop Qt window
117
+ python -m rtplot.server_browser
120
118
  ```
121
119
 
120
+ It prints a URL like `http://localhost:8050` — open that in your
121
+ browser. The page stays blank until a client sends a plot config.
122
+
122
123
  **Terminal 2 — send data:**
123
124
 
124
125
  ```python
@@ -134,31 +135,14 @@ for i in range(10000):
134
135
  time.sleep(0.01)
135
136
  ```
136
137
 
137
- That's it. Open http://localhost:8050 if you used the browser server; the
138
- Qt server will pop up its own window.
139
-
140
- ---
141
-
142
- ## Choosing a server: browser vs. Qt
143
-
144
- | | **Browser server** (`rtplot.server_browser`) | **Qt server** (`rtplot.server`) |
145
- |---|---|---|
146
- | Frontend | aiohttp + uPlot in any modern browser | pyqtgraph + PySide6 desktop window |
147
- | Extra | `[browser]` | `[server]` |
148
- | Works over SSH | Yes (just forward the HTTP port) | No (needs X forwarding) |
149
- | Interactive controls | **Yes** — buttons, sliders, dials, displays | No |
150
- | Typical frame rate | 60 Hz render, 1000 Hz data push cap | 500+ fps |
151
- | Saves to Parquet | Yes | Yes |
152
-
153
- If you're on WSL, running remotely, or you want interactive controls,
154
- **use the browser server**. The Qt server is still available for local
155
- desktop use and for legacy setups.
138
+ That's it. The browser tab you opened will start drawing the two
139
+ traces in real time.
156
140
 
157
141
  ---
158
142
 
159
143
  ## Interactive controls
160
144
 
161
- *Browser server only.* Declare a control row inline in your plot layout:
145
+ Declare a control row inline in your plot layout:
162
146
 
163
147
  ```python
164
148
  from rtplot import client
@@ -283,7 +267,8 @@ A styled plot dict accepts any of:
283
267
 
284
268
  Special row entries (not plots themselves):
285
269
 
286
- - `{"controls": [...]}` — a row of interactive controls (browser server only)
270
+ - `{"controls": [...]}` — a row of interactive controls (see
271
+ [Interactive controls](#interactive-controls))
287
272
  - `{"non_plot_labels": ["name1", "name2"]}` — extra scalar names that ride
288
273
  along with `send_array` and get saved into the output Parquet file, but
289
274
  aren't rendered as traces
@@ -380,6 +365,124 @@ no extra config.
380
365
 
381
366
  ---
382
367
 
368
+ ## Viewing the plot from another device
369
+
370
+ The section above is about the link between your *sender script* and the
371
+ *plot host* (the machine running `rtplot.server_browser`). This section
372
+ is about the other relationship: the link between the plot host and a
373
+ separate *viewer device* — a phone, tablet, or another laptop that just
374
+ wants to open the browser UI.
375
+
376
+ **You don't need SSH for this.** The plot host already runs a plain HTTP
377
+ server on port `8050`, bound to every interface, and the viewer device
378
+ is only a web browser. All you need to do is get traffic from the
379
+ viewer to port `8050` on the plot host.
380
+
381
+ ### On the same LAN (phone, tablet, another laptop on the same Wi-Fi)
382
+
383
+ 1. Find the plot host's LAN IP:
384
+
385
+ ```powershell
386
+ ipconfig | findstr IPv4 # Windows
387
+ ```
388
+ ```bash
389
+ ip -4 addr | grep inet # Linux/WSL
390
+ ```
391
+
392
+ 2. Open `http://<lan_ip>:8050` in the browser on the viewer device.
393
+
394
+ 3. If Windows, allow inbound connections on port `8050` through Windows
395
+ Defender Firewall. The very first time you run
396
+ `python -m rtplot.server_browser`, Windows pops up an "Allow Python to
397
+ receive connections" dialog — tick **Private networks** and click
398
+ **Allow**. If you missed the dialog, add the rule manually from an
399
+ elevated PowerShell:
400
+
401
+ ```powershell
402
+ # PowerShell as Administrator
403
+ New-NetFirewallRule -DisplayName "rtplot" `
404
+ -Direction Inbound -LocalPort 8050 -Protocol TCP `
405
+ -Action Allow -Profile Private
406
+ ```
407
+
408
+ Only allow on **Private** (home / trusted Wi-Fi), not **Public**,
409
+ unless you know what you're doing. To remove the rule later:
410
+
411
+ ```powershell
412
+ Remove-NetFirewallRule -DisplayName "rtplot"
413
+ ```
414
+
415
+ No router configuration, no SSH tunneling, no external accounts. Just a
416
+ firewall exception.
417
+
418
+ ### WSL2 wrinkle
419
+
420
+ If you run the server inside WSL2 instead of native Windows, WSL2's
421
+ `localhost` auto-forward lets **you** reach it from your Windows browser,
422
+ but does **not** forward traffic from the LAN. To expose a WSL2-hosted
423
+ server to other devices you need one extra hop — a Windows-side port
424
+ proxy that forwards incoming LAN traffic into WSL2:
425
+
426
+ ```powershell
427
+ # PowerShell as Administrator
428
+ $wslIp = (wsl hostname -I).Trim().Split()[0]
429
+ netsh interface portproxy add v4tov4 `
430
+ listenport=8050 listenaddress=0.0.0.0 `
431
+ connectport=8050 connectaddress=$wslIp
432
+ New-NetFirewallRule -DisplayName "rtplot wsl" `
433
+ -Direction Inbound -LocalPort 8050 -Protocol TCP `
434
+ -Action Allow -Profile Private
435
+ ```
436
+
437
+ WSL2's IP changes on every reboot, so rerun the `netsh` line after a
438
+ restart (or just run `rtplot.server_browser` from native Windows and
439
+ skip this whole step).
440
+
441
+ To undo:
442
+ ```powershell
443
+ netsh interface portproxy delete v4tov4 listenport=8050 listenaddress=0.0.0.0
444
+ Remove-NetFirewallRule -DisplayName "rtplot wsl"
445
+ ```
446
+
447
+ ### Across the internet (viewer on cellular, another network, etc.)
448
+
449
+ Two easy options, neither of which requires touching your router:
450
+
451
+ **Cloudflare Tunnel** (free, one-shot URL):
452
+
453
+ ```powershell
454
+ winget install --id Cloudflare.cloudflared
455
+ cloudflared tunnel --url http://localhost:8050
456
+ ```
457
+
458
+ Prints an `https://<random>.trycloudflare.com` URL valid for the
459
+ lifetime of the command — paste it into the viewer's browser. Kill the
460
+ command when you're done.
461
+
462
+ **Tailscale** (private mesh VPN, best for recurring setups):
463
+
464
+ Install [Tailscale](https://tailscale.com) on both the plot host and
465
+ every viewer device. Each device gets a stable `100.x.y.z` IP that
466
+ works from any network. Open `http://100.x.y.z:8050` on the viewer.
467
+
468
+ Both tunnel paths forward the HTTP + WebSocket traffic that the browser
469
+ needs; neither involves ZMQ, since the viewer is browser-only. Your
470
+ sender script keeps talking to the plot host locally as usual.
471
+
472
+ ### Ports at a glance
473
+
474
+ | Port | What it's for | Who actually needs it open |
475
+ |---|---|---|
476
+ | `8050` (TCP) | HTTP + WebSocket to the browser UI | the plot host, inbound from viewers |
477
+ | `5555` (TCP) | ZMQ data (sender → server) | only the sender and the plot host |
478
+ | `5556` (TCP) | ZMQ control return channel (server → sender) | only the sender and the plot host |
479
+
480
+ For the "other device is a viewer" case, you only need to expose `8050`.
481
+ `5555` / `5556` are between the sender script and the plot host — they
482
+ do not need to be reachable from the viewer device at all.
483
+
484
+ ---
485
+
383
486
  ## Performance tuning
384
487
 
385
488
  If you start running out of frames, try these, in roughly this order:
@@ -401,7 +504,7 @@ If you start running out of frames, try these, in roughly this order:
401
504
 
402
505
  ## CLI reference
403
506
 
404
- **Browser server** (`python -m rtplot.server_browser`):
507
+ `python -m rtplot.server_browser` accepts:
405
508
 
406
509
  | Flag | Default | Meaning |
407
510
  |---|---|---|
@@ -417,14 +520,6 @@ If you start running out of frames, try these, in roughly this order:
417
520
  | `-sd DIR` / `--save-dir DIR` | cwd | Where to write `.parquet` saves |
418
521
  | `-sn NAME` / `--save-name NAME` | — | Prefix for saved filenames |
419
522
 
420
- **Qt server** (`python -m rtplot.server`): same `-p`, `-n`, `-a`, `-c`,
421
- `-d`, `-sd`, `-sn` flags as above, plus:
422
-
423
- | Flag | Meaning |
424
- |---|---|
425
- | `-b` / `--bigscreen` | Pre-configure for the neurobionics lab big-screen display |
426
- | `-t FILE` / `--plot_config FILE` | Load a plot configuration from a file on startup |
427
-
428
523
  ---
429
524
 
430
525
  ## Examples
@@ -441,6 +536,3 @@ If you start running out of frames, try these, in roughly this order:
441
536
  python -m rtplot.interactive_test
442
537
  ```
443
538
 
444
- ![Qt server example 1](https://github.com/jmontp/rtplot/blob/master/.images/rtplot_example1.png)
445
- ![Qt server example 2](https://github.com/jmontp/rtplot/blob/master/.images/rtplot_example2.png)
446
-
@@ -4,9 +4,9 @@
4
4
 
5
5
  **rtplot** lets a Python script push live data to a plot window — locally, or
6
6
  across the network — with a few lines of code on the sender side. The plot
7
- window can be a traditional Qt application or a modern browser UI, and it
8
- also supports interactive controls (buttons, sliders, dials, text and
9
- numeric displays) that feed values back into the sending script in real time.
7
+ window runs in any modern browser and supports interactive controls
8
+ (buttons, sliders, dials, text and numeric displays) that feed values
9
+ back into the sending script in real time.
10
10
 
11
11
  Typical use: a robot or data-acquisition script runs on a Raspberry Pi or
12
12
  microcontroller host, and you watch live signals and tweak gains from a
@@ -19,7 +19,6 @@ laptop on the same network.
19
19
  - [Highlights](#highlights)
20
20
  - [Install](#install)
21
21
  - [60-second quickstart](#60-second-quickstart)
22
- - [Choosing a server: browser vs. Qt](#choosing-a-server-browser-vs-qt)
23
22
  - [Interactive controls](#interactive-controls)
24
23
  - [Reading controls from Python](#reading-controls-from-python)
25
24
  - [Pushing values into displays](#pushing-values-into-displays)
@@ -28,6 +27,7 @@ laptop on the same network.
28
27
  - [Sending data](#sending-data)
29
28
  - [Saving data](#saving-data)
30
29
  - [Networking modes](#networking-modes)
30
+ - [Viewing the plot from another device](#viewing-the-plot-from-another-device)
31
31
  - [Performance tuning](#performance-tuning)
32
32
  - [CLI reference](#cli-reference)
33
33
  - [Examples](#examples)
@@ -36,18 +36,23 @@ laptop on the same network.
36
36
 
37
37
  ## Highlights
38
38
 
39
- - **Fast.** 500+ fps on a single trace on a modern laptop. Binary WebSocket
40
- deltas on the browser server; raw Qt rendering on the desktop server.
41
- - **Two frontends.** A new browser-based server (aiohttp + uPlot) and the
42
- original pyqtgraph desktop server. Both speak the same ZMQ protocol, so
43
- client code is identical.
44
- - **Remote-friendly.** Either the sender or the plot host can bind — pick
45
- whichever fits your network. Works across LAN, WSL, and SSH tunnels.
46
- - **Plot config lives with the data.** The sender declares the plot layout,
47
- so a Pi running your experiment owns the look of its own dashboards.
48
- - **Interactive controls** *(browser server only)*. Declare buttons,
49
- sliders, dials, numeric/text displays in the same `initialize_plots`
50
- call. Poll from your tight loop; no threads, no callbacks.
39
+ - **Fast.** Binary WebSocket deltas push data at up to 1 kHz. The
40
+ browser coalesces incoming samples into a single repaint per
41
+ `requestAnimationFrame`, so rendering runs at your monitor's refresh
42
+ rate (typically 60 Hz, 120 Hz on higher-refresh displays) regardless
43
+ of how fast samples arrive.
44
+ - **Browser-based.** The plot window is served by aiohttp and rendered
45
+ by uPlot in any modern browser. No desktop GUI toolkit to install,
46
+ works over SSH port forwarding out of the box.
47
+ - **Remote-friendly.** Either the sender or the plot host can bind
48
+ pick whichever fits your network. Works across LAN, WSL, and SSH
49
+ tunnels.
50
+ - **Plot config lives with the data.** The sender declares the plot
51
+ layout, so a Pi running your experiment owns the look of its own
52
+ dashboards.
53
+ - **Interactive controls.** Declare buttons, sliders, dials,
54
+ numeric/text displays in the same `initialize_plots` call. Poll from
55
+ your tight loop; no threads, no callbacks.
51
56
  - **Save to Parquet** with a single button click or `client.save_plot()`
52
57
  call.
53
58
 
@@ -55,45 +60,41 @@ laptop on the same network.
55
60
 
56
61
  ## Install
57
62
 
58
- Minimum installjust the client (send data only):
59
-
60
- ```bash
61
- pip install better-rtplot
62
- ```
63
-
64
- Add the browser server (recommended):
63
+ Install rtplot with the server bundle this is the normal path and
64
+ gets you everything:
65
65
 
66
66
  ```bash
67
67
  pip install "better-rtplot[browser]"
68
68
  ```
69
69
 
70
- Add the Qt/pyqtgraph server instead:
70
+ This pulls `aiohttp` (for serving the plot UI) plus `pandas` + `pyarrow`
71
+ (for saving runs to Parquet). If you only need the sender side — your
72
+ script pushes data to someone else's plot host and you don't run a
73
+ server locally — you can install the client-only minimum instead:
71
74
 
72
75
  ```bash
73
- pip install "better-rtplot[server]"
76
+ pip install better-rtplot
74
77
  ```
75
78
 
76
- The `browser` extra pulls `aiohttp` + `pandas` + `pyarrow`; the `server`
77
- extra pulls `pyqtgraph` + `PySide6` + `pandas` + `pyarrow`. If you only
78
- `pip install better-rtplot` and try to launch a server, rtplot will print
79
- a friendly message telling you which extra to add.
79
+ In that case, if you later try to launch a server locally you'll get a
80
+ clear error telling you to add the `[browser]` extra.
80
81
 
81
- WSL users: the browser server works out of the box open the URL it
82
- prints in your Windows browser. The Qt server needs an X server such as
83
- [VcXsrv](https://sourceforge.net/projects/vcxsrv/).
82
+ WSL users: nothing extra needed. The plot window is served by HTTP, so
83
+ just open the URL rtplot prints in your Windows browser.
84
84
 
85
85
  ---
86
86
 
87
87
  ## 60-second quickstart
88
88
 
89
- **Terminal 1 — start a plot window:**
89
+ **Terminal 1 — start the plot server:**
90
90
 
91
91
  ```bash
92
- python -m rtplot.server_browser # browser UI at http://localhost:8050
93
- # or
94
- python -m rtplot.server # desktop Qt window
92
+ python -m rtplot.server_browser
95
93
  ```
96
94
 
95
+ It prints a URL like `http://localhost:8050` — open that in your
96
+ browser. The page stays blank until a client sends a plot config.
97
+
97
98
  **Terminal 2 — send data:**
98
99
 
99
100
  ```python
@@ -109,31 +110,14 @@ for i in range(10000):
109
110
  time.sleep(0.01)
110
111
  ```
111
112
 
112
- That's it. Open http://localhost:8050 if you used the browser server; the
113
- Qt server will pop up its own window.
114
-
115
- ---
116
-
117
- ## Choosing a server: browser vs. Qt
118
-
119
- | | **Browser server** (`rtplot.server_browser`) | **Qt server** (`rtplot.server`) |
120
- |---|---|---|
121
- | Frontend | aiohttp + uPlot in any modern browser | pyqtgraph + PySide6 desktop window |
122
- | Extra | `[browser]` | `[server]` |
123
- | Works over SSH | Yes (just forward the HTTP port) | No (needs X forwarding) |
124
- | Interactive controls | **Yes** — buttons, sliders, dials, displays | No |
125
- | Typical frame rate | 60 Hz render, 1000 Hz data push cap | 500+ fps |
126
- | Saves to Parquet | Yes | Yes |
127
-
128
- If you're on WSL, running remotely, or you want interactive controls,
129
- **use the browser server**. The Qt server is still available for local
130
- desktop use and for legacy setups.
113
+ That's it. The browser tab you opened will start drawing the two
114
+ traces in real time.
131
115
 
132
116
  ---
133
117
 
134
118
  ## Interactive controls
135
119
 
136
- *Browser server only.* Declare a control row inline in your plot layout:
120
+ Declare a control row inline in your plot layout:
137
121
 
138
122
  ```python
139
123
  from rtplot import client
@@ -258,7 +242,8 @@ A styled plot dict accepts any of:
258
242
 
259
243
  Special row entries (not plots themselves):
260
244
 
261
- - `{"controls": [...]}` — a row of interactive controls (browser server only)
245
+ - `{"controls": [...]}` — a row of interactive controls (see
246
+ [Interactive controls](#interactive-controls))
262
247
  - `{"non_plot_labels": ["name1", "name2"]}` — extra scalar names that ride
263
248
  along with `send_array` and get saved into the output Parquet file, but
264
249
  aren't rendered as traces
@@ -355,6 +340,124 @@ no extra config.
355
340
 
356
341
  ---
357
342
 
343
+ ## Viewing the plot from another device
344
+
345
+ The section above is about the link between your *sender script* and the
346
+ *plot host* (the machine running `rtplot.server_browser`). This section
347
+ is about the other relationship: the link between the plot host and a
348
+ separate *viewer device* — a phone, tablet, or another laptop that just
349
+ wants to open the browser UI.
350
+
351
+ **You don't need SSH for this.** The plot host already runs a plain HTTP
352
+ server on port `8050`, bound to every interface, and the viewer device
353
+ is only a web browser. All you need to do is get traffic from the
354
+ viewer to port `8050` on the plot host.
355
+
356
+ ### On the same LAN (phone, tablet, another laptop on the same Wi-Fi)
357
+
358
+ 1. Find the plot host's LAN IP:
359
+
360
+ ```powershell
361
+ ipconfig | findstr IPv4 # Windows
362
+ ```
363
+ ```bash
364
+ ip -4 addr | grep inet # Linux/WSL
365
+ ```
366
+
367
+ 2. Open `http://<lan_ip>:8050` in the browser on the viewer device.
368
+
369
+ 3. If Windows, allow inbound connections on port `8050` through Windows
370
+ Defender Firewall. The very first time you run
371
+ `python -m rtplot.server_browser`, Windows pops up an "Allow Python to
372
+ receive connections" dialog — tick **Private networks** and click
373
+ **Allow**. If you missed the dialog, add the rule manually from an
374
+ elevated PowerShell:
375
+
376
+ ```powershell
377
+ # PowerShell as Administrator
378
+ New-NetFirewallRule -DisplayName "rtplot" `
379
+ -Direction Inbound -LocalPort 8050 -Protocol TCP `
380
+ -Action Allow -Profile Private
381
+ ```
382
+
383
+ Only allow on **Private** (home / trusted Wi-Fi), not **Public**,
384
+ unless you know what you're doing. To remove the rule later:
385
+
386
+ ```powershell
387
+ Remove-NetFirewallRule -DisplayName "rtplot"
388
+ ```
389
+
390
+ No router configuration, no SSH tunneling, no external accounts. Just a
391
+ firewall exception.
392
+
393
+ ### WSL2 wrinkle
394
+
395
+ If you run the server inside WSL2 instead of native Windows, WSL2's
396
+ `localhost` auto-forward lets **you** reach it from your Windows browser,
397
+ but does **not** forward traffic from the LAN. To expose a WSL2-hosted
398
+ server to other devices you need one extra hop — a Windows-side port
399
+ proxy that forwards incoming LAN traffic into WSL2:
400
+
401
+ ```powershell
402
+ # PowerShell as Administrator
403
+ $wslIp = (wsl hostname -I).Trim().Split()[0]
404
+ netsh interface portproxy add v4tov4 `
405
+ listenport=8050 listenaddress=0.0.0.0 `
406
+ connectport=8050 connectaddress=$wslIp
407
+ New-NetFirewallRule -DisplayName "rtplot wsl" `
408
+ -Direction Inbound -LocalPort 8050 -Protocol TCP `
409
+ -Action Allow -Profile Private
410
+ ```
411
+
412
+ WSL2's IP changes on every reboot, so rerun the `netsh` line after a
413
+ restart (or just run `rtplot.server_browser` from native Windows and
414
+ skip this whole step).
415
+
416
+ To undo:
417
+ ```powershell
418
+ netsh interface portproxy delete v4tov4 listenport=8050 listenaddress=0.0.0.0
419
+ Remove-NetFirewallRule -DisplayName "rtplot wsl"
420
+ ```
421
+
422
+ ### Across the internet (viewer on cellular, another network, etc.)
423
+
424
+ Two easy options, neither of which requires touching your router:
425
+
426
+ **Cloudflare Tunnel** (free, one-shot URL):
427
+
428
+ ```powershell
429
+ winget install --id Cloudflare.cloudflared
430
+ cloudflared tunnel --url http://localhost:8050
431
+ ```
432
+
433
+ Prints an `https://<random>.trycloudflare.com` URL valid for the
434
+ lifetime of the command — paste it into the viewer's browser. Kill the
435
+ command when you're done.
436
+
437
+ **Tailscale** (private mesh VPN, best for recurring setups):
438
+
439
+ Install [Tailscale](https://tailscale.com) on both the plot host and
440
+ every viewer device. Each device gets a stable `100.x.y.z` IP that
441
+ works from any network. Open `http://100.x.y.z:8050` on the viewer.
442
+
443
+ Both tunnel paths forward the HTTP + WebSocket traffic that the browser
444
+ needs; neither involves ZMQ, since the viewer is browser-only. Your
445
+ sender script keeps talking to the plot host locally as usual.
446
+
447
+ ### Ports at a glance
448
+
449
+ | Port | What it's for | Who actually needs it open |
450
+ |---|---|---|
451
+ | `8050` (TCP) | HTTP + WebSocket to the browser UI | the plot host, inbound from viewers |
452
+ | `5555` (TCP) | ZMQ data (sender → server) | only the sender and the plot host |
453
+ | `5556` (TCP) | ZMQ control return channel (server → sender) | only the sender and the plot host |
454
+
455
+ For the "other device is a viewer" case, you only need to expose `8050`.
456
+ `5555` / `5556` are between the sender script and the plot host — they
457
+ do not need to be reachable from the viewer device at all.
458
+
459
+ ---
460
+
358
461
  ## Performance tuning
359
462
 
360
463
  If you start running out of frames, try these, in roughly this order:
@@ -376,7 +479,7 @@ If you start running out of frames, try these, in roughly this order:
376
479
 
377
480
  ## CLI reference
378
481
 
379
- **Browser server** (`python -m rtplot.server_browser`):
482
+ `python -m rtplot.server_browser` accepts:
380
483
 
381
484
  | Flag | Default | Meaning |
382
485
  |---|---|---|
@@ -392,14 +495,6 @@ If you start running out of frames, try these, in roughly this order:
392
495
  | `-sd DIR` / `--save-dir DIR` | cwd | Where to write `.parquet` saves |
393
496
  | `-sn NAME` / `--save-name NAME` | — | Prefix for saved filenames |
394
497
 
395
- **Qt server** (`python -m rtplot.server`): same `-p`, `-n`, `-a`, `-c`,
396
- `-d`, `-sd`, `-sn` flags as above, plus:
397
-
398
- | Flag | Meaning |
399
- |---|---|
400
- | `-b` / `--bigscreen` | Pre-configure for the neurobionics lab big-screen display |
401
- | `-t FILE` / `--plot_config FILE` | Load a plot configuration from a file on startup |
402
-
403
498
  ---
404
499
 
405
500
  ## Examples
@@ -415,6 +510,3 @@ If you start running out of frames, try these, in roughly this order:
415
510
  python -m rtplot.server_browser &
416
511
  python -m rtplot.interactive_test
417
512
  ```
418
-
419
- ![Qt server example 1](https://github.com/jmontp/rtplot/blob/master/.images/rtplot_example1.png)
420
- ![Qt server example 2](https://github.com/jmontp/rtplot/blob/master/.images/rtplot_example2.png)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "better-rtplot"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = ""
5
5
  authors = ["jmontp <jmontp@umich.edu>"]
6
6
  license = "GPL V3.0"
@@ -108,13 +108,14 @@ def main():
108
108
  "colors": ["b"],
109
109
  "title": "Interactive Controls Test",
110
110
  "yrange": [-1.5, 1.5],
111
+ "height": 1.5,
111
112
  }
112
113
  controls_row_prompt = {"controls": [
113
114
  {"type": "text", "id": "prompt", "label": "Task",
114
115
  "value": "Starting..."},
115
116
  ]}
116
117
  controls_row_buttons = {"controls": [
117
- {"type": "button", "id": "start", "label": "Start"},
118
+ {"type": "button", "id": "start", "label": "Start", "height": 2},
118
119
  {"type": "button", "id": "stop", "label": "Stop"},
119
120
  {"type": "button", "id": "fail", "label": "Abort"},
120
121
  ]}
@@ -125,7 +126,7 @@ def main():
125
126
  controls_row_dial = {"controls": [
126
127
  {"type": "dial", "id": "freq", "label": "Freq (Hz)",
127
128
  "min": 0.1, "max": 5.0, "value": 1.0, "step": 0.05,
128
- "sensitivity": 0.5, "format": "{:.2f}"},
129
+ "sensitivity": 0.2, "format": "{:.2f}", "height": 2},
129
130
  ]}
130
131
  controls_row_status = {"controls": [
131
132
  {"type": "text", "id": "status", "label": "Status",
@@ -437,6 +437,7 @@ def build_config_message(config_dict):
437
437
  "ylabel": plot_description.get("ylabel"),
438
438
  "xrange": plot_description.get("xrange"),
439
439
  "yrange": plot_description.get("yrange"),
440
+ "height": plot_description.get("height"),
440
441
  }
441
442
  )
442
443
  return {
@@ -831,11 +832,18 @@ INDEX_HTML = """<!doctype html>
831
832
  #plots.col { flex-direction: row; flex-wrap: wrap; }
832
833
  .plot-wrap { background: #fff; border: 1px solid #ddd; border-radius: 4px; padding: 8px; flex: 1 1 auto; min-width: 320px; }
833
834
  .plot-title { font-size: 13px; font-weight: 600; margin: 0 0 4px 4px; color: #333; }
835
+ :root { --ctrl-unit-h: 38px; }
834
836
  .ctrl-row { display: flex; gap: 12px; align-items: center; padding: 10px 14px; background: #fff; border: 1px solid #ddd; border-radius: 4px; flex-wrap: wrap; }
835
837
  .ctrl-item { display: flex; align-items: center; gap: 6px; }
836
838
  .ctrl-item.flex { flex: 1 1 220px; min-width: 200px; }
837
839
  .ctrl-item label { font-size: 13px; color: #444; }
838
- .ctrl-btn { padding: 8px 16px; font-size: 14px; border: 1px solid #888; background: #fff; cursor: pointer; border-radius: 4px; font-weight: 500; }
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); }
842
+ .ctrl-item-tall > .ctrl-rangeinput,
843
+ .ctrl-item-tall > .ctrl-dial,
844
+ .ctrl-item-tall > .ctrl-numinput,
845
+ .ctrl-item-tall > .ctrl-nudgebtn,
846
+ .ctrl-item-tall > .ctrl-val { align-self: center; }
839
847
  .ctrl-btn:hover { background: #f0f0f0; }
840
848
  .ctrl-btn:active { background: #e2e2e2; }
841
849
  .ctrl-slider .ctrl-rangeinput { flex: 1; min-width: 120px; }
@@ -845,11 +853,13 @@ INDEX_HTML = """<!doctype html>
845
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; }
846
854
  .ctrl-nudgebtn:hover { background: #e9e9e9; }
847
855
  .ctrl-nudgebtn:active { background: #dcdcdc; }
848
- .ctrl-dial { cursor: grab; flex: 0 0 auto; touch-action: none; user-select: none; }
849
- .ctrl-dial-dragging { cursor: grabbing; }
850
- .ctrl-dial .dial-track { fill: #fafafa; stroke: #bcbcbc; stroke-width: 2; }
851
- .ctrl-dial .dial-indicator { stroke: #2a5db0; stroke-width: 3; stroke-linecap: round; }
856
+ .ctrl-dial { cursor: ns-resize; flex: 0 0 auto; touch-action: none; user-select: none; }
857
+ .ctrl-dial-dragging { cursor: ns-resize; }
858
+ .ctrl-dial .dial-track { fill: #fafafa; stroke: #bcbcbc; stroke-width: 2.5; }
859
+ .ctrl-dial .dial-indicator { stroke: #2a5db0; stroke-width: 4; stroke-linecap: round; }
860
+ .ctrl-dial .dial-arrow { fill: #c0c0c0; pointer-events: none; user-select: none; }
852
861
  .ctrl-dial:hover .dial-track { stroke: #888; }
862
+ .ctrl-dial:hover .dial-arrow { fill: #888; }
853
863
  .ctrl-val { font-family: monospace; font-size: 13px; min-width: 56px; text-align: right; color: #222; }
854
864
  .ctrl-display .ctrl-val { background: #f3f3f3; padding: 4px 10px; border-radius: 3px; min-width: 72px; border: 1px solid #e2e2e2; }
855
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; }
@@ -931,8 +941,10 @@ INDEX_HTML = """<!doctype html>
931
941
  }
932
942
 
933
943
  function makeScalarControl(el, renderWidget) {
934
- const min = (el.min !== undefined) ? Number(el.min) : 0;
935
- const max = (el.max !== undefined) ? Number(el.max) : 1;
944
+ const hasMin = el.min !== undefined && Number.isFinite(Number(el.min));
945
+ const hasMax = el.max !== undefined && Number.isFinite(Number(el.max));
946
+ const min = hasMin ? Number(el.min) : -Infinity;
947
+ const max = hasMax ? Number(el.max) : Infinity;
936
948
  const step = (el.step !== undefined && Number(el.step) > 0) ? Number(el.step) : 0.01;
937
949
  const fmt = parseDisplayFormat(el.format);
938
950
  const formatVal = (v) => (fmt && fmt.kind === 'fixed')
@@ -940,10 +952,11 @@ INDEX_HTML = """<!doctype html>
940
952
  : String(v);
941
953
  const clampRound = (v) => {
942
954
  v = Math.max(min, Math.min(max, Number(v)));
943
- const snapped = Math.round((v - min) / step) * step + min;
955
+ const base = hasMin ? min : 0;
956
+ const snapped = Math.round((v - base) / step) * step + base;
944
957
  return Number(snapped.toFixed(10));
945
958
  };
946
- let value = clampRound((el.value !== undefined) ? Number(el.value) : min);
959
+ let value = clampRound((el.value !== undefined) ? Number(el.value) : (hasMin ? min : 0));
947
960
 
948
961
  const item = document.createElement('div');
949
962
  item.className = 'ctrl-item ctrl-slider flex';
@@ -957,25 +970,32 @@ INDEX_HTML = """<!doctype html>
957
970
  const input = document.createElement('input');
958
971
  input.type = 'number';
959
972
  input.className = 'ctrl-numinput';
960
- input.min = min;
961
- input.max = max;
973
+ if (hasMin) input.min = min;
974
+ if (hasMax) input.max = max;
962
975
  input.step = step;
963
976
  input.value = formatVal(value);
964
977
 
965
- const applyLocal = (v) => {
978
+ // fromDrag is true when the scalar control is being updated by a
979
+ // widget's own drag handler — in that case the widget already owns
980
+ // its visual state and setValue should not reset it.
981
+ const applyLocal = (v, fromDrag) => {
966
982
  value = clampRound(v);
967
983
  input.value = formatVal(value);
968
- if (widget && widget.setValue) widget.setValue(value);
984
+ if (widget && widget.setValue) widget.setValue(value, !!fromDrag);
969
985
  };
970
- const commit = (v) => {
971
- applyLocal(v);
986
+ const commit = (v, fromDrag) => {
987
+ applyLocal(v, fromDrag);
972
988
  sendCtrl({ type: 'control_slider', id: el.id, value: Number(value) });
973
989
  };
974
990
 
975
991
  const sensitivity = (el.sensitivity !== undefined && Number.isFinite(Number(el.sensitivity)))
976
992
  ? Number(el.sensitivity)
977
993
  : 1.0;
978
- widget = renderWidget({ min, max, step, initial: value, commit, applyLocal, sensitivity });
994
+ widget = renderWidget({
995
+ min, max, step, initial: value,
996
+ commit, applyLocal,
997
+ sensitivity, hasMin, hasMax,
998
+ });
979
999
  if (widget && widget.node) item.appendChild(widget.node);
980
1000
 
981
1001
  const minusBtn = document.createElement('button');
@@ -1004,35 +1024,42 @@ INDEX_HTML = """<!doctype html>
1004
1024
  plusBtn.addEventListener('click', () => commit(value + step));
1005
1025
  item.appendChild(plusBtn);
1006
1026
 
1007
- controlElements.sliders[el.id] = { setValue: applyLocal };
1027
+ controlElements.sliders[el.id] = { setValue: (v) => applyLocal(v, false) };
1008
1028
  if (fmt) controlElements.displayFormats[el.id] = fmt;
1009
1029
  return item;
1010
1030
  }
1011
1031
 
1012
- function buildSliderWidget({ min, max, step, initial, commit }) {
1032
+ function buildSliderWidget({ min, max, step, initial, commit, applyLocal, hasMin, hasMax }) {
1033
+ // HTML range inputs can't represent unbounded values, so fall back
1034
+ // to sane defaults when the user omits min/max on a slider.
1035
+ const rangeMin = hasMin ? min : 0;
1036
+ const rangeMax = hasMax ? max : 1;
1013
1037
  const range = document.createElement('input');
1014
1038
  range.type = 'range';
1015
1039
  range.className = 'ctrl-rangeinput';
1016
- range.min = min;
1017
- range.max = max;
1040
+ range.min = rangeMin;
1041
+ range.max = rangeMax;
1018
1042
  range.step = step;
1019
1043
  range.value = initial;
1020
- range.addEventListener('change', () => commit(Number(range.value)));
1044
+ // Live preview: update the number box (and any other mirrors) on every
1045
+ // drag tick, but only actually send the value to Python on release.
1046
+ range.addEventListener('input', () => applyLocal(Number(range.value), true));
1047
+ range.addEventListener('change', () => commit(Number(range.value), true));
1021
1048
  return {
1022
1049
  node: range,
1023
- setValue: (v) => { range.value = v; },
1050
+ setValue: (v, fromDrag) => { range.value = v; },
1024
1051
  };
1025
1052
  }
1026
1053
 
1027
- function buildDialWidget({ min, max, initial, commit, sensitivity }) {
1054
+ function buildDialWidget({ min, max, initial, commit, applyLocal, sensitivity, hasMin, hasMax }) {
1028
1055
  const svgNS = 'http://www.w3.org/2000/svg';
1029
- const size = 72;
1056
+ const size = 100;
1030
1057
  const svg = document.createElementNS(svgNS, 'svg');
1031
1058
  svg.setAttribute('viewBox', `0 0 ${size} ${size}`);
1032
1059
  svg.setAttribute('width', size);
1033
1060
  svg.setAttribute('height', size);
1034
1061
  svg.classList.add('ctrl-dial');
1035
- const cx = size / 2, cy = size / 2, r = size / 2 - 7;
1062
+ const cx = size / 2, cy = size / 2, r = size / 2 - 8;
1036
1063
 
1037
1064
  const track = document.createElementNS(svgNS, 'circle');
1038
1065
  track.setAttribute('cx', cx);
@@ -1041,54 +1068,75 @@ INDEX_HTML = """<!doctype html>
1041
1068
  track.classList.add('dial-track');
1042
1069
  svg.appendChild(track);
1043
1070
 
1071
+ // Up/down arrows as drag-direction hints, inside the circle at the
1072
+ // 12 and 6 o'clock positions. Drawn as polygons (rather than text
1073
+ // glyphs) so they scale with the SVG viewBox when the dial is
1074
+ // resized via the `height` multiplier.
1075
+ const mkArrow = (pointsUp, y) => {
1076
+ const p = document.createElementNS(svgNS, 'polygon');
1077
+ const w = 7, h = 6;
1078
+ const pts = pointsUp
1079
+ ? `${cx},${y - h / 2} ${cx - w / 2},${y + h / 2} ${cx + w / 2},${y + h / 2}`
1080
+ : `${cx - w / 2},${y - h / 2} ${cx + w / 2},${y - h / 2} ${cx},${y + h / 2}`;
1081
+ p.setAttribute('points', pts);
1082
+ p.classList.add('dial-arrow');
1083
+ return p;
1084
+ };
1085
+ svg.appendChild(mkArrow(true, cy - r + 9));
1086
+ svg.appendChild(mkArrow(false, cy + r - 9));
1087
+
1044
1088
  const indicator = document.createElementNS(svgNS, 'line');
1045
1089
  indicator.classList.add('dial-indicator');
1046
1090
  svg.appendChild(indicator);
1047
1091
 
1048
- let current = initial;
1049
- function renderAt(v) {
1050
- current = v;
1051
- const frac = (max === min) ? 0 : (v - min) / (max - min);
1052
- const theta = ((-135 + 270 * frac) * Math.PI / 180);
1053
- const x2 = cx + (r - 4) * Math.sin(theta);
1054
- const y2 = cy - (r - 4) * Math.cos(theta);
1092
+ // Unified rotation math: the indicator advances by 2π radians for
1093
+ // every `valuePerRotation` units of value change, in either
1094
+ // direction. Hardstops fall out naturally — when value clamps,
1095
+ // the per-tick delta is 0 and the indicator stops. sensitivity
1096
+ // sets how much of the value range one rotation covers:
1097
+ // bounded: valuePerRotation = (max - min) * sensitivity
1098
+ // sensitivity=1 -> 1 rotation per range
1099
+ // sensitivity=0.2 -> 5 rotations per range
1100
+ // unbounded: valuePerRotation = sensitivity directly
1101
+ // (raw value units per rotation)
1102
+ const bothBounded = hasMin && hasMax;
1103
+ const refRange = bothBounded ? (max - min) : 1;
1104
+ const valuePerRotation = refRange * sensitivity;
1105
+ const PX_PER_ROTATION = 100;
1106
+ const BASE_ANGLE = (-135 * Math.PI) / 180;
1107
+
1108
+ let currentValue = initial;
1109
+ let currentAngle = BASE_ANGLE;
1110
+
1111
+ function drawIndicator() {
1112
+ const x2 = cx + (r - 4) * Math.sin(currentAngle);
1113
+ const y2 = cy - (r - 4) * Math.cos(currentAngle);
1055
1114
  indicator.setAttribute('x1', cx);
1056
1115
  indicator.setAttribute('y1', cy);
1057
1116
  indicator.setAttribute('x2', x2);
1058
1117
  indicator.setAttribute('y2', y2);
1059
1118
  }
1060
- renderAt(current);
1119
+ drawIndicator();
1061
1120
 
1062
- // Angular drag: track the pointer angle from the dial center and
1063
- // accumulate signed deltas so the user can spin the dial "round and
1064
- // round" each full turn maps to (max-min) * sensitivity of value.
1065
- // sensitivity > 1 => coarser, sensitivity < 1 => finer control.
1121
+ // Vertical drag: drag up = value increases. 100 px of drag =
1122
+ // one full indicator rotation = valuePerRotation units of value
1123
+ // change. When value is pinned at a hardstop, delta = 0 and the
1124
+ // indicator stops too.
1066
1125
  svg.addEventListener('pointerdown', (e) => {
1067
- const rect = svg.getBoundingClientRect();
1068
- const cxGlobal = rect.left + rect.width / 2;
1069
- const cyGlobal = rect.top + rect.height / 2;
1070
- let prevAngle = Math.atan2(e.clientY - cyGlobal, e.clientX - cxGlobal);
1071
- let accumulated = 0;
1072
- const startV = current;
1073
- const range = (max - min) || 1;
1126
+ const startY = e.clientY;
1127
+ const startV = currentValue;
1074
1128
  svg.classList.add('ctrl-dial-dragging');
1075
1129
 
1076
1130
  const onMove = (em) => {
1077
- const a = Math.atan2(em.clientY - cyGlobal, em.clientX - cxGlobal);
1078
- let delta = a - prevAngle;
1079
- if (delta > Math.PI) delta -= 2 * Math.PI;
1080
- else if (delta < -Math.PI) delta += 2 * Math.PI;
1081
- accumulated += delta;
1082
- prevAngle = a;
1083
- let v = startV + (accumulated / (2 * Math.PI)) * range * sensitivity;
1084
- v = Math.max(min, Math.min(max, v));
1085
- renderAt(v);
1131
+ const dy = startY - em.clientY; // positive when dragging up
1132
+ const target = startV + (dy / PX_PER_ROTATION) * valuePerRotation;
1133
+ applyLocal(target, true);
1086
1134
  };
1087
1135
  const onUp = () => {
1088
1136
  document.removeEventListener('pointermove', onMove);
1089
1137
  document.removeEventListener('pointerup', onUp);
1090
1138
  svg.classList.remove('ctrl-dial-dragging');
1091
- commit(current);
1139
+ commit(currentValue, true);
1092
1140
  };
1093
1141
  document.addEventListener('pointermove', onMove);
1094
1142
  document.addEventListener('pointerup', onUp, { once: true });
@@ -1098,7 +1146,22 @@ INDEX_HTML = """<!doctype html>
1098
1146
 
1099
1147
  return {
1100
1148
  node: svg,
1101
- setValue: (v) => { renderAt(v); },
1149
+ setValue: (v, fromDrag) => {
1150
+ if (fromDrag) {
1151
+ // Incremental update during a drag: rotate the indicator by
1152
+ // the delta since the last set, which implicitly pins it
1153
+ // when value clamps at a hardstop.
1154
+ const delta = v - currentValue;
1155
+ currentAngle += (delta / valuePerRotation) * 2 * Math.PI;
1156
+ } else {
1157
+ // External update (e.g. seeded server value on reconnect):
1158
+ // snap back to the base angle so we have a known starting
1159
+ // point for the next drag.
1160
+ currentAngle = BASE_ANGLE;
1161
+ }
1162
+ currentValue = v;
1163
+ drawIndicator();
1164
+ },
1102
1165
  };
1103
1166
  }
1104
1167
 
@@ -1147,10 +1210,35 @@ INDEX_HTML = """<!doctype html>
1147
1210
  return item;
1148
1211
  }
1149
1212
 
1213
+ function applyElementSize(item, el) {
1214
+ // height: per-element multiplier on the standard row height. Lets
1215
+ // users declare e.g. `{"type":"button","height":2}` to get a bigger
1216
+ // click target, or `{"type":"dial","height":2}` to get a bigger
1217
+ // knob. Values other than 1 set a min-height on the wrapper and,
1218
+ // for buttons, make the button stretch to fill it. For dials the
1219
+ // nested SVG itself is resized so the knob actually grows.
1220
+ const h = Number(el.height);
1221
+ if (Number.isFinite(h) && h > 0 && h !== 1) {
1222
+ item.style.minHeight = `calc(var(--ctrl-unit-h) * ${h})`;
1223
+ if (h > 1) item.classList.add('ctrl-item-tall');
1224
+ const dial = item.querySelector('.ctrl-dial');
1225
+ if (dial) {
1226
+ const base = Number(dial.getAttribute('width')) || 100;
1227
+ const newSize = Math.round(base * h);
1228
+ dial.setAttribute('width', newSize);
1229
+ dial.setAttribute('height', newSize);
1230
+ }
1231
+ }
1232
+ }
1233
+
1150
1234
  function buildControlRow(row) {
1151
1235
  const div = document.createElement('div');
1152
1236
  div.className = 'ctrl-row';
1153
- row.forEach(el => div.appendChild(buildControlElement(el)));
1237
+ row.forEach(el => {
1238
+ const item = buildControlElement(el);
1239
+ applyElementSize(item, el);
1240
+ div.appendChild(item);
1241
+ });
1154
1242
  return div;
1155
1243
  }
1156
1244
 
@@ -1204,9 +1292,14 @@ INDEX_HTML = """<!doctype html>
1204
1292
  });
1205
1293
  }
1206
1294
 
1295
+ const baseHeight = rowLayout ? 260 : 320;
1296
+ const heightMul = Number(pcfg.height);
1297
+ const plotHeight = (Number.isFinite(heightMul) && heightMul > 0)
1298
+ ? Math.round(baseHeight * heightMul)
1299
+ : baseHeight;
1207
1300
  const opts = {
1208
1301
  width: Math.max(640, plotsDiv.clientWidth - 40),
1209
- height: rowLayout ? 260 : 320,
1302
+ height: plotHeight,
1210
1303
  title: pcfg.title || '',
1211
1304
  scales: {
1212
1305
  x: { time: false, range: [0, xrange - 1] },
File without changes