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.
- {better_rtplot-0.2.1 → better_rtplot-0.2.2}/PKG-INFO +162 -70
- {better_rtplot-0.2.1 → better_rtplot-0.2.2}/README.md +161 -69
- {better_rtplot-0.2.1 → better_rtplot-0.2.2}/pyproject.toml +1 -1
- {better_rtplot-0.2.1 → better_rtplot-0.2.2}/rtplot/interactive_test.py +3 -2
- {better_rtplot-0.2.1 → better_rtplot-0.2.2}/rtplot/server_browser.py +150 -57
- {better_rtplot-0.2.1 → better_rtplot-0.2.2}/LICENSE +0 -0
- {better_rtplot-0.2.1 → better_rtplot-0.2.2}/rtplot/client.py +0 -0
- {better_rtplot-0.2.1 → better_rtplot-0.2.2}/rtplot/example_code.py +0 -0
- {better_rtplot-0.2.1 → better_rtplot-0.2.2}/rtplot/plot_log.py +0 -0
- {better_rtplot-0.2.1 → better_rtplot-0.2.2}/rtplot/saved_plots/.gitignore +0 -0
- {better_rtplot-0.2.1 → better_rtplot-0.2.2}/rtplot/server.py +0 -0
- {better_rtplot-0.2.1 → better_rtplot-0.2.2}/rtplot/static/uPlot.iife.min.js +0 -0
- {better_rtplot-0.2.1 → better_rtplot-0.2.2}/rtplot/static/uPlot.min.css +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: better-rtplot
|
|
3
|
-
Version: 0.2.
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
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.**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
- **
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
101
|
+
pip install better-rtplot
|
|
99
102
|
```
|
|
100
103
|
|
|
101
|
-
|
|
102
|
-
|
|
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:
|
|
107
|
-
prints in your Windows browser.
|
|
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
|
|
114
|
+
**Terminal 1 — start the plot server:**
|
|
115
115
|
|
|
116
116
|
```bash
|
|
117
|
-
python -m rtplot.server_browser
|
|
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.
|
|
138
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-

|
|
445
|
-

|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
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.**
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
- **
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
76
|
+
pip install better-rtplot
|
|
74
77
|
```
|
|
75
78
|
|
|
76
|
-
|
|
77
|
-
|
|
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:
|
|
82
|
-
prints in your Windows browser.
|
|
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
|
|
89
|
+
**Terminal 1 — start the plot server:**
|
|
90
90
|
|
|
91
91
|
```bash
|
|
92
|
-
python -m rtplot.server_browser
|
|
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.
|
|
113
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-

|
|
420
|
-

|
|
@@ -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.
|
|
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:
|
|
849
|
-
.ctrl-dial-dragging { cursor:
|
|
850
|
-
.ctrl-dial .dial-track { fill: #fafafa; stroke: #bcbcbc; stroke-width: 2; }
|
|
851
|
-
.ctrl-dial .dial-indicator { stroke: #2a5db0; stroke-width:
|
|
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
|
|
935
|
-
const
|
|
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
|
|
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
|
-
|
|
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({
|
|
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 =
|
|
1017
|
-
range.max =
|
|
1040
|
+
range.min = rangeMin;
|
|
1041
|
+
range.max = rangeMax;
|
|
1018
1042
|
range.step = step;
|
|
1019
1043
|
range.value = initial;
|
|
1020
|
-
|
|
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 =
|
|
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 -
|
|
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
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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
|
-
|
|
1119
|
+
drawIndicator();
|
|
1061
1120
|
|
|
1062
|
-
//
|
|
1063
|
-
//
|
|
1064
|
-
//
|
|
1065
|
-
//
|
|
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
|
|
1068
|
-
const
|
|
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
|
|
1078
|
-
|
|
1079
|
-
|
|
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(
|
|
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) => {
|
|
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 =>
|
|
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:
|
|
1302
|
+
height: plotHeight,
|
|
1210
1303
|
title: pcfg.title || '',
|
|
1211
1304
|
scales: {
|
|
1212
1305
|
x: { time: false, range: [0, xrange - 1] },
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|