signalk-performance-processor 0.0.3 → 0.0.5
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.
- package/CHANGELOG.md +21 -0
- package/README.md +36 -21
- package/index.js +67 -9
- package/lib/defaults.js +33 -15
- package/lib/processor.js +127 -31
- package/package.json +1 -1
- package/public/app.js +91 -18
- package/public/index.html +12 -2
- package/public/style.css +10 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.5 — 2026-10-29
|
|
4
|
+
|
|
5
|
+
Still a proof of concept: run it in Shadow mode alongside your instruments.
|
|
6
|
+
|
|
7
|
+
- **Fix: feedback loop with another STW plugin in Replace mode.** With `signalk-stw-heel-correction` (0.2 or earlier) on the same server, each plugin corrected the other's STW and handed it back, so STW and true wind ran away. Without a chosen STW source, STW is now read from sensors only (updates that carry a `source` object from a data connection), never from another plugin. Choosing a source in Settings still overrides this.
|
|
8
|
+
- **Rate limit as a circuit breaker:** no path is published more often than 20 Hz. A feedback loop is cut at its first repeat, and a backlog flushed after a dropout is thinned rather than stopped; nothing latches. While updates are being dropped, the status and the Live page say so, with a count and the source driving them, for 10 s after the last one; the first is logged.
|
|
9
|
+
- The status says when STW is arriving only from other plugins, and names them.
|
|
10
|
+
- Settings: the STW source's blank choice reads **Auto (sensors only)**.
|
|
11
|
+
|
|
12
|
+
## 0.0.4 — 2026-10-29
|
|
13
|
+
|
|
14
|
+
Still a proof of concept: run it in Shadow mode alongside your instruments. Saved configurations are upgraded to schema 2 on first load.
|
|
15
|
+
|
|
16
|
+
- **STW correction is a heel × speed table** (signed heel rows, STW columns), like the H5000's boat speed / heel table, with bilinear interpolation and blank cells filled from the nearest value. The default is the owner's full measured table. A table in the `signalk-stw-heel-correction` CSV format can be pasted into the webapp.
|
|
17
|
+
- **TWS table is in knots** (H5000 units). Default downwind −0.5 … −3 kn (−10% of each column); upwind and reach blank, meaning no correction.
|
|
18
|
+
- **New TWA table defaults** from the owner's calibration fit. Blank TWA cells mean too little data and use the nearest measured cells in the row.
|
|
19
|
+
- Config file schema 2. Schema 1 files are upgraded on load: the two STW rows become their grid and TWS percentages become knots.
|
|
20
|
+
- Live pipeline: each step's result is tagged **Published** or **Working**, worked out from what later enabled steps replace, so intermediate values are not mistaken for outputs.
|
|
21
|
+
- Calibration tables: the breakpoints (TWS columns, STW speeds, heel angles) are fixed labels, not editable fields; only the correction values are edited.
|
|
22
|
+
- Live page: a "Published to Signal K" table lists exactly what the plugin sends and on which path. The pipeline's true wind step is labelled "before tables", so its working TWA is not mistaken for a second output.
|
|
23
|
+
|
|
3
24
|
## 0.0.3 — 2026-10-29
|
|
4
25
|
|
|
5
26
|
Still a proof of concept: run it in Shadow mode alongside your instruments.
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ If your TWD swings 5–10° every time you tack or gybe, the wind hasn't shifted
|
|
|
8
8
|
|
|
9
9
|
This plugin does the calibration job of a B&G H5000-class instrument processor inside Signal K. It corrects boat speed and leeway, then applies H5000-style TWA and TWS correction tables, and publishes corrected true wind and current. You edit everything in a webapp and can watch each correction work live. Run it in **Shadow mode** first: it publishes alongside your existing instruments without changing anything they do.
|
|
10
10
|
|
|
11
|
-
> **Proof of concept (0.0.
|
|
11
|
+
> **Proof of concept (0.0.5).** Compare it against your instruments in Shadow mode before relying on it. See the [changelog](CHANGELOG.md).
|
|
12
12
|
|
|
13
13
|
## Why it exists
|
|
14
14
|
|
|
@@ -22,14 +22,14 @@ Instrument processors fix this with calibration tables, but:
|
|
|
22
22
|
|
|
23
23
|
This plugin moves the calibration into Signal K, where you can see it, change it, log it and fit it from your own race data:
|
|
24
24
|
|
|
25
|
-
- **The same shape as an H5000:** boat speed
|
|
25
|
+
- **The same shape as an H5000:** a boat speed table by heel and speed, leeway (K × heel ÷ boat speed²), and TWA/TWS tables by wind speed with upwind, reach and downwind rows. An existing calibration carries across, and the H5000 tuning rule ("TWD lifts on every tack: take half the error off TWA") works unchanged.
|
|
26
26
|
- **Every correction has a switch and a live readout,** so you can see what each one does to the numbers right now.
|
|
27
27
|
- **The calibration is a versioned JSON file,** easy to back up, compare and fit from logged races offline.
|
|
28
28
|
- **It is safe to try.** Shadow mode publishes `…Corrected` paths beside your instruments. Replace mode takes over the standard paths only when you choose it.
|
|
29
29
|
|
|
30
30
|
### On the boat it was built for
|
|
31
31
|
|
|
32
|
-
From the offline model the defaults were fitted with: trained on 2025 race logs, tested on Jan–Mar 2026 races it had not seen, and measured by how far true wind direction jumps through each manoeuvre (median). The shipped defaults differ
|
|
32
|
+
From the offline model the defaults were fitted with: trained on 2025 race logs, tested on Jan–Mar 2026 races it had not seen, and measured by how far true wind direction jumps through each manoeuvre (median). The shipped defaults differ (the owner's leeway K, full STW table and TWA table from a later calibration fit), so treat these as an indication, not a guarantee.
|
|
33
33
|
|
|
34
34
|
| | Uncorrected | Instrument (H2000) | This plugin |
|
|
35
35
|
|---|---|---|---|
|
|
@@ -54,11 +54,11 @@ The corrections run in this order, each on the output of the one before:
|
|
|
54
54
|
|
|
55
55
|
| Step | What | Default |
|
|
56
56
|
|---|---|---|
|
|
57
|
-
| Boat speed (STW) | Knots added to the paddlewheel
|
|
57
|
+
| Boat speed (STW) | Knots added to the paddlewheel from a **heel × speed table** (signed heel rows, STW columns), like the H5000's boat speed / heel table | On |
|
|
58
58
|
| Leeway | Leeway = K × heel ÷ STW², turning the boat's velocity to leeward | On, K = 7.9446 |
|
|
59
59
|
| True wind | Apparent wind minus the boat's velocity through the water | Always |
|
|
60
60
|
| TWA table | Degrees added to \|TWA\| by TWS, with upwind, reach and downwind rows blended by TWA (70° / 100° / 130°) | On |
|
|
61
|
-
| TWS table |
|
|
61
|
+
| TWS table | Knots added to TWS by TWS, same rows and blending (H5000 units) | Off |
|
|
62
62
|
| TWD, current | TWD from heading and corrected TWA; current from SOG/COG minus water velocity | Always, when heading and GPS are available |
|
|
63
63
|
|
|
64
64
|
### Inputs
|
|
@@ -73,7 +73,17 @@ The corrections run in this order, each on the output of the one before:
|
|
|
73
73
|
|
|
74
74
|
True wind is worked out on every apparent wind update. Inputs older than 3 seconds are not used. Without STW there is no true wind. Without heel, the heel correction and leeway are skipped. Without heading, there is no TWD or current.
|
|
75
75
|
|
|
76
|
-
Feed it **measured** apparent wind and **raw** paddlewheel speed.
|
|
76
|
+
Feed it **measured** apparent wind and **raw** paddlewheel speed. Unless you choose an STW source in Settings, STW is read **from sensors only**: updates that came in through a data connection (NMEA 0183, NMEA 2000), which carry a `source` object, and not STW published by another plugin, which does not. See [Running alongside other STW plugins](#running-alongside-other-stw-plugins).
|
|
77
|
+
|
|
78
|
+
### Running alongside other STW plugins
|
|
79
|
+
|
|
80
|
+
This plugin publishes STW too, so it must never correct another plugin's STW and hand the result back. Two correctors on `navigation.speedThroughWater` that each take any source but their own correct each other's output without end: STW, and true wind with it, run away. Signal K's own pattern for correction plugins (subscribe with `sourcePolicy: 'all'`, skip your own `$source`, publish on the same path) does not cover this case, because neither plugin ever sees its own source come back.
|
|
81
|
+
|
|
82
|
+
- **By default it reads STW from sensors only**, so another corrector's output is ignored and no loop can form.
|
|
83
|
+
- **Choosing a source in Settings overrides this**, including choosing another plugin, for a deliberate chain such as `signalk-stw-heel-correction` → this plugin with the STW correction here turned off. That plugin must then read only the paddlewheel (its own default from 0.3.0).
|
|
84
|
+
- **Circuit breaker:** whatever the sources, the plugin never publishes a path more often than 20 Hz: an update less than 50 ms after the last one on that path is not published. A loop's next hop arrives within the same millisecond, so the chain ends at the first repeat instead of running away, and the server stays responsive. A backlog flushed after a dropout is thinned, not stopped. Nothing latches. While updates are being dropped the status and the Live page end with `Rate limited: N updates over 20 Hz not published in the last 10 s, driven by "…"`, naming the source. If it stays up, look for a loop with another STW plugin; a loop that is cut still leaves STW corrected twice.
|
|
85
|
+
|
|
86
|
+
Only one plugin should correct STW. Otherwise the correction is applied twice even without a loop. In Shadow mode, note that `signalk-stw-heel-correction` also uses `navigation.speedThroughWaterCorrected` for its separate-path output, so the two would overwrite each other there.
|
|
77
87
|
|
|
78
88
|
### Outputs
|
|
79
89
|
|
|
@@ -86,7 +96,7 @@ Feed it **measured** apparent wind and **raw** paddlewheel speed. If another plu
|
|
|
86
96
|
| Leeway (when on) | `navigation.leewayAngle` | `navigation.leewayAngleCorrected` |
|
|
87
97
|
| Current | `environment.current` (`drift`, `setTrue`) | `environment.currentCorrected` |
|
|
88
98
|
|
|
89
|
-
Everything is published under this plugin's own `$source`. Corrected STW goes out each time the paddlewheel reports, while the STW correction is on; the rest goes out with each apparent wind update. Shadow paths carry `meta` with units, since they are not in the Signal K schema.
|
|
99
|
+
Everything is published under this plugin's own `$source`. Corrected STW goes out each time the paddlewheel reports, while the STW correction is on; the rest goes out with each apparent wind update. No path goes out more often than 20 Hz (see **Circuit breaker** under [Running alongside other STW plugins](#running-alongside-other-stw-plugins)). Shadow paths carry `meta` with units, since they are not in the Signal K schema.
|
|
90
100
|
|
|
91
101
|
## Modes
|
|
92
102
|
|
|
@@ -99,28 +109,28 @@ Everything is published under this plugin's own `$source`. Corrected STW goes ou
|
|
|
99
109
|
|
|
100
110
|
Open **Webapps → Performance Processor**.
|
|
101
111
|
|
|
102
|
-
- **Live**: the outputs next to their uncorrected values,
|
|
103
|
-
- **Calibration**: every correction in pipeline order with an on/off switch. Select one to edit its values, then **Save**. **Reset to defaults** puts back this boat's
|
|
104
|
-
- **Settings**: Shadow or Replace, the fallback magnetic variation, and which source to read for each input.
|
|
112
|
+
- **Live**: the outputs next to their uncorrected values; the pipeline, with each step's result tagged **Published** (sent to Signal K) or **Working** (only feeds the next step); a **Published to Signal K** table listing every value sent and its path; and which source and how fresh each input is.
|
|
113
|
+
- **Calibration**: every correction in pipeline order with an on/off switch. Select one to edit its values, then **Save**. Table headings (the breakpoints) are fixed; only the values are edited, and blank cells are allowed where a table says so. The boat speed table also has **Paste a table**, which reads the `signalk-stw-heel-correction` CSV format. **Reset to defaults** puts back this boat's values for that correction.
|
|
114
|
+
- **Settings**: Shadow or Replace, the fallback magnetic variation, and which source to read for each input. **Any** takes every source; for STW the blank choice is **Auto (sensors only)**.
|
|
105
115
|
|
|
106
116
|
All configuration lives in the webapp. It is saved to `config.json` in the plugin's data directory (under `~/.signalk/plugin-config-data/`), not in the admin **Plugin Config** form, which only points here; saving that form cannot overwrite the tables. Saving needs write access when Signal K security is on.
|
|
107
117
|
|
|
108
118
|
## Defaults for this boat
|
|
109
119
|
|
|
110
|
-
|
|
120
|
+
What a fresh install starts with. Every value can be changed in the webapp.
|
|
111
121
|
|
|
112
|
-
- **STW:**
|
|
122
|
+
- **STW:** the owner's measured heel × STW table, 15 heel rows (−35° to +35°) by 17 speed columns (0.5 to 8.5 kn), in knots added. Blank cells (corners the boat never sails in) are filled from the nearest value in their row; below 0.5 kn the correction tapers to zero. A table in the same CSV format as `signalk-stw-heel-correction` can be pasted straight into the webapp.
|
|
113
123
|
- **Leeway:** K = 7.9446, one value for every sail.
|
|
114
|
-
- **TWA table (°)
|
|
124
|
+
- **TWA table (°)**, from the owner's calibration fit (blank = too little data):
|
|
115
125
|
|
|
116
126
|
| TWS (kn) | 5 | 10 | 15 | 20 | 25 | 30 |
|
|
117
127
|
|---|---|---|---|---|---|---|
|
|
118
|
-
| Upwind | −
|
|
119
|
-
| Reach | −
|
|
120
|
-
| Downwind |
|
|
128
|
+
| Upwind | −7.6 | −12.6 | −5.8 | −5.7 | +0.2 | |
|
|
129
|
+
| Reach | −2.4 | −4.4 | −5.4 | +1.4 | +3.9 | +1.0 |
|
|
130
|
+
| Downwind | +1.2 | −3.5 | +1.0 | | | |
|
|
121
131
|
|
|
122
|
-
|
|
123
|
-
- **TWS table (
|
|
132
|
+
A blank TWA cell uses the measured cells either side, holding the nearest one at the ends: upwind at 30 kn uses +0.2, downwind from 20 kn uses +1.0.
|
|
133
|
+
- **TWS table (kn):** off. Downwind −0.5, −1, −1.5, −2, −2.5, −3 (−10% of each column); upwind and reach blank, which in this table means no correction.
|
|
124
134
|
- **Fallback variation:** 12.8° E, used only when nothing on the boat sends true heading or magnetic variation. The Live page shows which applies.
|
|
125
135
|
|
|
126
136
|
## Configuration file
|
|
@@ -129,15 +139,20 @@ Fitted to the 2025 race logs and checked on Jan–Mar 2026 races not used in fit
|
|
|
129
139
|
|
|
130
140
|
```json
|
|
131
141
|
{
|
|
132
|
-
"schema":
|
|
142
|
+
"schema": 2,
|
|
133
143
|
"mode": "shadow",
|
|
134
144
|
"variation": 12.8,
|
|
135
145
|
"sources": { "apparentWind": "", "stw": "", "heading": "", "attitude": "", "gps": "", "variation": "" },
|
|
136
|
-
"corrections": {
|
|
146
|
+
"corrections": {
|
|
147
|
+
"stw": { "enabled": true, "heel": [-35, …, 35], "stw": [0.5, …, 8.5], "add": [[…], …] },
|
|
148
|
+
"leeway": { "enabled": true, "k": 7.9446 },
|
|
149
|
+
"twa": { "enabled": true, "tws": [5, …, 30], "upwind": […], "reach": […], "downwind": […], "angles": { … } },
|
|
150
|
+
"tws": { "enabled": false, "tws": [5, …, 30], "upwind": […], "reach": […], "downwind": […] }
|
|
151
|
+
}
|
|
137
152
|
}
|
|
138
153
|
```
|
|
139
154
|
|
|
140
|
-
`schema` is the version of the file's shape. A release that changes the shape bumps `CONFIG_SCHEMA` in `lib/processor.js` and adds a migration from the previous version, so older files are upgraded as they load and nothing is reset. A file without `schema` (saved by 0.0.1) is read as schema 1. A file from a newer release is refused rather than guessed at, and is never overwritten by an older plugin.
|
|
155
|
+
`schema` is the version of the file's shape. A release that changes the shape bumps `CONFIG_SCHEMA` in `lib/processor.js` and adds a migration from the previous version, so older files are upgraded as they load and nothing is reset. A file without `schema` (saved by 0.0.1) is read as schema 1. Schema 1 files (0.0.1–0.0.3) are upgraded to 2: their two STW rows become the grid they describe, and their TWS percentages become knots. Blank table cells are stored as `null`. A file from a newer release is refused rather than guessed at, and is never overwritten by an older plugin.
|
|
141
156
|
|
|
142
157
|
## Adding a correction
|
|
143
158
|
|
package/index.js
CHANGED
|
@@ -20,6 +20,12 @@ const MAX_PLAUSIBLE_HEEL_DEG = 90
|
|
|
20
20
|
// See signalk-stw-heel-correction: below this a correction is float noise, and recording
|
|
21
21
|
// it as an output would make the next identical raw sample look like an echo.
|
|
22
22
|
const ECHO_MIN_DELTA_MS = 1e-6
|
|
23
|
+
// Circuit breaker: no path is published more often than this (20 Hz). A feedback loop
|
|
24
|
+
// through another plugin delivers its next update within the same millisecond, so the
|
|
25
|
+
// chain ends at the first repeat rather than running away, and the server stays
|
|
26
|
+
// responsive. Nothing latches; the status says while it is happening.
|
|
27
|
+
const MIN_PUBLISH_INTERVAL_MS = 50
|
|
28
|
+
const RATE_NOTE_MS = 10000 // how long a dropped update keeps the status flagged
|
|
23
29
|
|
|
24
30
|
// Inputs, grouped the way they are chosen in Settings.
|
|
25
31
|
const INPUTS = {
|
|
@@ -84,10 +90,38 @@ module.exports = function (app) {
|
|
|
84
90
|
return s === plugin.id || s.startsWith(plugin.id + '.')
|
|
85
91
|
}
|
|
86
92
|
|
|
93
|
+
// A plugin publishes with no source object, so the server gives its update only a
|
|
94
|
+
// $source. Data from a connection (NMEA 0183, NMEA 2000) always carries one.
|
|
95
|
+
const fromSensor = update => update.source != null && typeof update.source === 'object'
|
|
96
|
+
|
|
87
97
|
// Backstop echo detector for the one path that is both input and output (STW in
|
|
88
98
|
// replace mode): remember published values that differ from their input.
|
|
89
99
|
const recentStw = []
|
|
90
100
|
let loopWarned = false
|
|
101
|
+
|
|
102
|
+
// Rate limit (see MIN_PUBLISH_INTERVAL_MS): when each path was last published, and
|
|
103
|
+
// when updates were dropped recently, for the status.
|
|
104
|
+
let publishedAt = {}
|
|
105
|
+
let dropsAt = []
|
|
106
|
+
let dropFrom = ''
|
|
107
|
+
function recentDrops (now) {
|
|
108
|
+
while (dropsAt.length && now - dropsAt[0] > RATE_NOTE_MS) dropsAt.shift()
|
|
109
|
+
return dropsAt.length
|
|
110
|
+
}
|
|
111
|
+
function noteDrop (now, from) {
|
|
112
|
+
const first = !recentDrops(now)
|
|
113
|
+
dropsAt.push(now)
|
|
114
|
+
dropFrom = from
|
|
115
|
+
if (!first) return
|
|
116
|
+
app.error(`Rate limited: "${from}" is driving updates faster than ${1000 / MIN_PUBLISH_INTERVAL_MS} Hz. Is another plugin correcting this plugin's output?`)
|
|
117
|
+
lastStatus = statusText + rateNote(now) // flag it now, not at the next refresh
|
|
118
|
+
app.setPluginStatus(lastStatus)
|
|
119
|
+
}
|
|
120
|
+
function rateNote (now) {
|
|
121
|
+
const n = recentDrops(now)
|
|
122
|
+
return n ? ` · Rate limited: ${n} update${n === 1 ? '' : 's'} over ${1000 / MIN_PUBLISH_INTERVAL_MS} Hz not published in the last ${RATE_NOTE_MS / 1000} s, ` +
|
|
123
|
+
`driven by "${dropFrom}". Usually a feedback loop with another STW plugin: check the STW source in Settings` : ''
|
|
124
|
+
}
|
|
91
125
|
function noteStw (ms) {
|
|
92
126
|
recentStw.push(ms)
|
|
93
127
|
if (recentStw.length > 32) recentStw.shift()
|
|
@@ -103,10 +137,13 @@ module.exports = function (app) {
|
|
|
103
137
|
// live numbers in the status keep moving without flooding the admin UI.
|
|
104
138
|
let statusAt = 0
|
|
105
139
|
let statusKind = null
|
|
140
|
+
let statusText = ''
|
|
106
141
|
function status (kind, text) {
|
|
107
142
|
const now = Date.now()
|
|
108
143
|
if (kind === statusKind && now - statusAt < STATUS_INTERVAL_MS) return
|
|
109
144
|
statusKind = kind
|
|
145
|
+
statusText = text
|
|
146
|
+
text += rateNote(now)
|
|
110
147
|
lastStatus = text
|
|
111
148
|
statusAt = now
|
|
112
149
|
app.setPluginStatus(text)
|
|
@@ -120,7 +157,15 @@ module.exports = function (app) {
|
|
|
120
157
|
return Math.abs(h) > MAX_PLAUSIBLE_HEEL_DEG ? null : h
|
|
121
158
|
}
|
|
122
159
|
|
|
123
|
-
function publish (values, timestamp) {
|
|
160
|
+
function publish (values, timestamp, from) {
|
|
161
|
+
const now = Date.now()
|
|
162
|
+
const kept = values.filter(v => {
|
|
163
|
+
if (now - (publishedAt[v.path] ?? -Infinity) < MIN_PUBLISH_INTERVAL_MS) return false
|
|
164
|
+
publishedAt[v.path] = now
|
|
165
|
+
return true
|
|
166
|
+
})
|
|
167
|
+
if (kept.length < values.length) noteDrop(now, from)
|
|
168
|
+
values = kept
|
|
124
169
|
if (!values.length) return
|
|
125
170
|
// No source object: the server stamps $source with plugin.id, which is what the
|
|
126
171
|
// loop guard and the user's Source Priorities entry match on.
|
|
@@ -128,7 +173,7 @@ module.exports = function (app) {
|
|
|
128
173
|
}
|
|
129
174
|
|
|
130
175
|
// Corrected STW goes out as soon as the paddlewheel reports, at its own rate.
|
|
131
|
-
function onStw (rawMs, timestamp, now) {
|
|
176
|
+
function onStw (rawMs, timestamp, now, from) {
|
|
132
177
|
const stw = config.corrections.stw
|
|
133
178
|
if (!stw.enabled) return
|
|
134
179
|
const stwKn = rawMs * MS_TO_KN
|
|
@@ -138,16 +183,21 @@ module.exports = function (app) {
|
|
|
138
183
|
const { c } = correctInputs({ stw: stwKn, heel: heelDeg(now) }, config)
|
|
139
184
|
const ms = c.stw / MS_TO_KN
|
|
140
185
|
if (config.mode === 'replace' && Math.abs(ms - rawMs) > ECHO_MIN_DELTA_MS) noteStw(ms)
|
|
141
|
-
publish([{ path: outPath('stw'), value: ms }], timestamp)
|
|
186
|
+
publish([{ path: outPath('stw'), value: ms }], timestamp, from)
|
|
142
187
|
}
|
|
143
188
|
|
|
144
189
|
// True wind, current and leeway go out on every apparent wind update.
|
|
145
|
-
function onWind (timestamp, now) {
|
|
190
|
+
function onWind (timestamp, now, from) {
|
|
146
191
|
const awa = fresh('environment.wind.angleApparent', now)
|
|
147
192
|
const aws = fresh('environment.wind.speedApparent', now)
|
|
148
193
|
const stw = fresh('navigation.speedThroughWater', now)
|
|
149
194
|
if (awa == null || aws == null) return status('nowind', 'Waiting for apparent wind')
|
|
150
|
-
if (stw == null)
|
|
195
|
+
if (stw == null) {
|
|
196
|
+
const others = !config.sources.stw && sourcesSeen.stw ? Array.from(sourcesSeen.stw) : []
|
|
197
|
+
return status('nostw', others.length
|
|
198
|
+
? `No speed through water from a sensor, only from ${others.join(', ')}. Choose the STW source in Settings`
|
|
199
|
+
: 'Waiting for speed through water — no true wind without it')
|
|
200
|
+
}
|
|
151
201
|
|
|
152
202
|
const awsKn = aws * MS_TO_KN
|
|
153
203
|
const stwKn = stw * MS_TO_KN
|
|
@@ -178,7 +228,7 @@ module.exports = function (app) {
|
|
|
178
228
|
if (o.twd != null) values.push({ path: outPath('twd'), value: o.twd / RAD_TO_DEG })
|
|
179
229
|
if (config.corrections.leeway.enabled) values.push({ path: outPath('leeway'), value: o.leeway / RAD_TO_DEG })
|
|
180
230
|
if (o.current) values.push({ path: outPath('current'), value: { drift: o.current.drift / MS_TO_KN, setTrue: o.current.set / RAD_TO_DEG } })
|
|
181
|
-
publish(values, timestamp)
|
|
231
|
+
publish(values, timestamp, from)
|
|
182
232
|
|
|
183
233
|
const missing = []
|
|
184
234
|
if (input.heel == null) missing.push('heel')
|
|
@@ -191,6 +241,7 @@ module.exports = function (app) {
|
|
|
191
241
|
const now = Date.now()
|
|
192
242
|
let windChanged = false
|
|
193
243
|
let windTimestamp = null
|
|
244
|
+
let windFrom = ''
|
|
194
245
|
for (const update of (delta.updates || [])) {
|
|
195
246
|
if (isOwnSource(update.$source)) continue
|
|
196
247
|
for (const v of (update.values || [])) {
|
|
@@ -199,9 +250,14 @@ module.exports = function (app) {
|
|
|
199
250
|
if (v.path !== 'navigation.attitude' && !Number.isFinite(v.value)) continue
|
|
200
251
|
|
|
201
252
|
const src = String(update.$source || '')
|
|
253
|
+
const sensor = fromSensor(update)
|
|
202
254
|
;(sourcesSeen[group] = sourcesSeen[group] || new Set()).add(src)
|
|
203
255
|
const wanted = config.sources[group]
|
|
204
256
|
if (wanted && src !== wanted) continue
|
|
257
|
+
// Without a chosen source, STW is read from sensors only. Another plugin's STW
|
|
258
|
+
// is already corrected, and this plugin publishes STW too: taking each other's
|
|
259
|
+
// output, two correctors feed each other without end.
|
|
260
|
+
if (!wanted && group === 'stw' && !sensor) continue
|
|
205
261
|
|
|
206
262
|
if (v.path === 'navigation.speedThroughWater' && config.mode === 'replace' && isStwEcho(v.value)) {
|
|
207
263
|
if (!loopWarned) {
|
|
@@ -212,16 +268,18 @@ module.exports = function (app) {
|
|
|
212
268
|
}
|
|
213
269
|
|
|
214
270
|
latest[v.path] = { value: v.value, source: src, at: now }
|
|
215
|
-
if (group === 'apparentWind') { windChanged = true; windTimestamp = update.timestamp }
|
|
216
|
-
if (group === 'stw') onStw(v.value, update.timestamp, now)
|
|
271
|
+
if (group === 'apparentWind') { windChanged = true; windTimestamp = update.timestamp; windFrom = src }
|
|
272
|
+
if (group === 'stw') onStw(v.value, update.timestamp, now, src)
|
|
217
273
|
}
|
|
218
274
|
}
|
|
219
|
-
if (windChanged) onWind(windTimestamp, now)
|
|
275
|
+
if (windChanged) onWind(windTimestamp, now, windFrom)
|
|
220
276
|
}
|
|
221
277
|
|
|
222
278
|
function startWith (cfg) {
|
|
223
279
|
stopEverything()
|
|
224
280
|
config = cfg
|
|
281
|
+
publishedAt = {}
|
|
282
|
+
dropsAt = []
|
|
225
283
|
latest = {}
|
|
226
284
|
sourcesSeen = {}
|
|
227
285
|
lastResult = null
|
package/lib/defaults.js
CHANGED
|
@@ -13,35 +13,53 @@ const DEFAULTS = {
|
|
|
13
13
|
variation: 12.8,
|
|
14
14
|
sources: { apparentWind: '', stw: '', heading: '', attitude: '', gps: '', variation: '' }, // '' = any but this plugin
|
|
15
15
|
corrections: {
|
|
16
|
-
// Knots added to STW: linearity by speed, plus heel correction by |heel|.
|
|
17
|
-
// The 185-cell heel x STW table reduced to two rows (every cell within 0.03 kn).
|
|
18
16
|
stw: {
|
|
19
17
|
enabled: true,
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
// Knots added to STW, by signed heel (rows, + = starboard side down) and STW (columns).
|
|
19
|
+
// The owner's measured heel x STW table; blank cells are filled from the nearest value.
|
|
20
|
+
heel: [-35, -30, -25, -20, -15, -10, -5, 0, 5, 10, 15, 20, 25, 30, 35],
|
|
21
|
+
stw: [0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5],
|
|
22
|
+
add: [
|
|
23
|
+
[null, null, null, null, null, null, null, null, null, null, null, -0.276, -0.283, -0.287, -0.291, -0.293, -0.294],
|
|
24
|
+
[null, null, null, null, null, null, null, null, null, null, -0.266, -0.276, -0.283, -0.287, -0.291, -0.293, -0.294],
|
|
25
|
+
[null, null, null, null, null, null, null, null, -0.139, -0.16, -0.175, -0.185, -0.192, -0.197, -0.2, -0.202, -0.203],
|
|
26
|
+
[null, null, null, null, null, null, 0.009, -0.035, -0.065, -0.086, -0.101, -0.111, -0.118, -0.122, -0.126, -0.128, -0.129],
|
|
27
|
+
[0.536, 0.555, 0.442, 0.315, 0.208, 0.125, 0.065, 0.022, -0.008, -0.029, -0.043, -0.053, -0.06, -0.065, -0.068, -0.07, -0.071],
|
|
28
|
+
[0.552, 0.582, 0.474, 0.351, 0.246, 0.165, 0.105, 0.063, 0.033, 0.012, -0.002, -0.012, -0.019, -0.023, -0.026, -0.029, -0.03],
|
|
29
|
+
[0.562, 0.597, 0.493, 0.372, 0.268, 0.188, 0.129, 0.087, 0.057, 0.037, 0.022, 0.013, 0.006, 0.001, -0.002, -0.004, -0.005],
|
|
30
|
+
[0.565, 0.602, 0.5, 0.379, 0.276, 0.196, 0.137, 0.095, 0.065, 0.045, 0.031, 0.021, 0.014, 0.01, 0.007, 0.004, 0.003],
|
|
31
|
+
[0.562, 0.597, 0.494, 0.372, 0.269, 0.188, 0.129, 0.087, 0.057, 0.037, 0.023, 0.013, 0.006, 0.002, -0.001, -0.004, -0.005],
|
|
32
|
+
[0.552, 0.582, 0.475, 0.352, 0.246, 0.165, 0.106, 0.063, 0.034, 0.013, -0.001, -0.011, -0.018, -0.023, -0.026, -0.028, -0.029],
|
|
33
|
+
[0.536, 0.557, 0.444, 0.317, 0.21, 0.127, 0.067, 0.024, -0.006, -0.027, -0.042, -0.051, -0.058, -0.063, -0.066, -0.068, -0.069],
|
|
34
|
+
[null, null, null, null, null, null, 0.012, -0.031, -0.062, -0.083, -0.098, -0.108, -0.114, -0.119, -0.122, -0.124, -0.126],
|
|
35
|
+
[null, null, null, null, null, null, null, null, -0.133, -0.155, -0.17, -0.18, -0.187, -0.191, -0.194, -0.197, -0.198],
|
|
36
|
+
[null, null, null, null, null, null, null, null, null, null, -0.258, -0.268, -0.275, -0.28, -0.283, -0.285, -0.286],
|
|
37
|
+
[null, null, null, null, null, null, null, null, null, null, null, -0.268, -0.275, -0.28, -0.283, -0.285, -0.286]
|
|
38
|
+
]
|
|
22
39
|
},
|
|
23
40
|
// Leeway (degrees) = K x heel / STW^2, one K for every sail: downwind heel is
|
|
24
41
|
// small, so leeway there is near zero whatever K is.
|
|
25
42
|
leeway: { enabled: true, k: 7.9446 },
|
|
26
|
-
// Degrees added to |TWA| by TWS; negative = TWA was reading too wide.
|
|
27
|
-
//
|
|
28
|
-
//
|
|
43
|
+
// Degrees added to |TWA| by TWS; negative = TWA was reading too wide. From the
|
|
44
|
+
// owner's boatcal fit; blank = too little data, filled from the nearest measured
|
|
45
|
+
// cells in the row. Rows are blended by TWA: upwind in full to angles.upwind, reach
|
|
46
|
+
// at angles.reach, downwind from angles.downwind. The TWS table shares these angles.
|
|
29
47
|
twa: {
|
|
30
48
|
enabled: true,
|
|
31
49
|
tws: TWS_BINS.slice(),
|
|
32
|
-
upwind: [-
|
|
33
|
-
reach: [-
|
|
34
|
-
downwind: [
|
|
50
|
+
upwind: [-7.6, -12.6, -5.8, -5.7, 0.2, null],
|
|
51
|
+
reach: [-2.4, -4.4, -5.4, 1.4, 3.9, 1.0],
|
|
52
|
+
downwind: [1.2, -3.5, 1.0, null, null, null],
|
|
35
53
|
angles: { upwind: 70, reach: 100, downwind: 130 }
|
|
36
54
|
},
|
|
37
|
-
//
|
|
38
|
-
//
|
|
55
|
+
// Knots added to TWS by TWS, same rows and blending as the TWA table (H5000 units).
|
|
56
|
+
// Downwind: -10% of each column. Blank = no correction. Off until switched on.
|
|
39
57
|
tws: {
|
|
40
58
|
enabled: false,
|
|
41
59
|
tws: TWS_BINS.slice(),
|
|
42
|
-
upwind: [
|
|
43
|
-
reach: [
|
|
44
|
-
downwind: [0,
|
|
60
|
+
upwind: [null, null, null, null, null, null],
|
|
61
|
+
reach: [null, null, null, null, null, null],
|
|
62
|
+
downwind: [-0.5, -1, -1.5, -2, -2.5, -3]
|
|
45
63
|
}
|
|
46
64
|
}
|
|
47
65
|
}
|
package/lib/processor.js
CHANGED
|
@@ -43,11 +43,74 @@ function blendRows (absTwa, angles, up, reach, down) {
|
|
|
43
43
|
return down
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// Interpolate along a row whose blank cells (null) mean "not measured": use only the
|
|
47
|
+
// measured cells, holding the nearest one beyond them. An all-blank row is zero.
|
|
48
|
+
function interpRow (xs, ys, x) {
|
|
49
|
+
const kx = []; const ky = []
|
|
50
|
+
ys.forEach((y, i) => { if (y != null) { kx.push(xs[i]); ky.push(y) } })
|
|
51
|
+
return kx.length ? interp(kx, ky, x) : 0
|
|
52
|
+
}
|
|
53
|
+
|
|
46
54
|
function tableLookup (table, angles, absTwa, tws) {
|
|
47
55
|
return blendRows(absTwa, angles,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
56
|
+
interpRow(table.tws, table.upwind, tws),
|
|
57
|
+
interpRow(table.tws, table.reach, tws),
|
|
58
|
+
interpRow(table.tws, table.downwind, tws))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---- heel x STW table ----------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
// A blank cell means "not measured", typically a corner of the grid the boat never
|
|
64
|
+
// sails in. Extend the nearest value in its row into it rather than reading it as zero,
|
|
65
|
+
// then fill an all-blank row from the nearest row with data. As signalk-stw-heel-correction.
|
|
66
|
+
function fillGaps (grid) {
|
|
67
|
+
const table = grid.map(r => r.slice())
|
|
68
|
+
const cols = table[0].length
|
|
69
|
+
for (const row of table) {
|
|
70
|
+
let last = null
|
|
71
|
+
for (let j = 0; j < cols; j++) { if (row[j] != null) last = row[j]; else if (last != null) row[j] = last }
|
|
72
|
+
last = null
|
|
73
|
+
for (let j = cols - 1; j >= 0; j--) { if (row[j] != null) last = row[j]; else if (last != null) row[j] = last }
|
|
74
|
+
}
|
|
75
|
+
for (let i = 0; i < table.length; i++) {
|
|
76
|
+
if (table[i][0] != null) continue
|
|
77
|
+
for (let d = 1; d < table.length; d++) {
|
|
78
|
+
const src = (i - d >= 0 && table[i - d][0] != null) ? table[i - d] : (i + d < table.length && table[i + d][0] != null) ? table[i + d] : null
|
|
79
|
+
if (src) { table[i] = src.slice(); break }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return table
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function bracket (bins, v) {
|
|
86
|
+
if (v <= bins[0]) return [0, 0, 0]
|
|
87
|
+
const n = bins.length - 1
|
|
88
|
+
if (v >= bins[n]) return [n, n, 0]
|
|
89
|
+
let i = 0
|
|
90
|
+
while (bins[i + 1] < v) i++
|
|
91
|
+
return [i, i + 1, (v - bins[i]) / (bins[i + 1] - bins[i])]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The filled grid is worked out once per config object, not on every update.
|
|
95
|
+
const filledCache = new WeakMap()
|
|
96
|
+
function filledGrid (cfg) {
|
|
97
|
+
let f = filledCache.get(cfg)
|
|
98
|
+
if (!f) { f = fillGaps(cfg.add); filledCache.set(cfg, f) }
|
|
99
|
+
return f
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Bilinear in signed heel and STW, holding the edge values beyond the grid. Below the
|
|
103
|
+
// first STW column the correction tapers to zero at 0 kn, so a stopped boat is not
|
|
104
|
+
// given speed.
|
|
105
|
+
function stwGridLookup (cfg, heel, stw) {
|
|
106
|
+
const t = filledGrid(cfg)
|
|
107
|
+
const s0 = cfg.stw[0]
|
|
108
|
+
const at = s => {
|
|
109
|
+
const [h0, h1, ht] = bracket(cfg.heel, heel)
|
|
110
|
+
const [j0, j1, st] = bracket(cfg.stw, s)
|
|
111
|
+
return t[h0][j0] * (1 - ht) * (1 - st) + t[h0][j1] * (1 - ht) * st + t[h1][j0] * ht * (1 - st) + t[h1][j1] * ht * st
|
|
112
|
+
}
|
|
113
|
+
return stw < s0 ? at(s0) * Math.max(0, stw) / s0 : at(stw)
|
|
51
114
|
}
|
|
52
115
|
|
|
53
116
|
const fmt = (v, d = 1) => (v > 0 ? '+' : '') + v.toFixed(d)
|
|
@@ -55,27 +118,23 @@ const fmt = (v, d = 1) => (v > 0 ? '+' : '') + v.toFixed(d)
|
|
|
55
118
|
const CORRECTIONS = [
|
|
56
119
|
{
|
|
57
120
|
id: 'stw',
|
|
121
|
+
produces: ['stw'], // quantities this step's result replaces
|
|
58
122
|
stage: 'input',
|
|
59
123
|
name: 'Boat speed (STW)',
|
|
60
124
|
apply (c, cfg) {
|
|
61
|
-
|
|
62
|
-
// stopped boat is not given speed.
|
|
63
|
-
const lin = cfg.linearity
|
|
64
|
-
const add1 = c.stw < lin.stw[0]
|
|
65
|
-
? lin.add[0] * Math.max(0, c.stw) / lin.stw[0]
|
|
66
|
-
: interp(lin.stw, lin.add, c.stw)
|
|
67
|
-
const add2 = c.heel == null ? 0 : interp(cfg.heel.heel, cfg.heel.add, Math.abs(c.heel))
|
|
125
|
+
const add = stwGridLookup(cfg, c.heel == null ? 0 : c.heel, c.stw)
|
|
68
126
|
const before = c.stw
|
|
69
|
-
c.stw = Math.max(0, c.stw +
|
|
127
|
+
c.stw = Math.max(0, c.stw + add)
|
|
70
128
|
return {
|
|
71
|
-
input: `${before.toFixed(2)} kn` + (c.heel == null ? '' : ` · heel ${
|
|
72
|
-
detail: `${fmt(
|
|
129
|
+
input: `${before.toFixed(2)} kn` + (c.heel == null ? ' · no heel data, upright row used' : ` · heel ${c.heel.toFixed(0)}°`),
|
|
130
|
+
detail: `${fmt(add, 2)} kn from the heel × STW table`,
|
|
73
131
|
output: `${c.stw.toFixed(2)} kn`
|
|
74
132
|
}
|
|
75
133
|
}
|
|
76
134
|
},
|
|
77
135
|
{
|
|
78
136
|
id: 'leeway',
|
|
137
|
+
produces: ['leeway'], // quantities this step's result replaces
|
|
79
138
|
stage: 'input',
|
|
80
139
|
name: 'Leeway',
|
|
81
140
|
apply (c, cfg) {
|
|
@@ -93,6 +152,7 @@ const CORRECTIONS = [
|
|
|
93
152
|
},
|
|
94
153
|
{
|
|
95
154
|
id: 'twa',
|
|
155
|
+
produces: ['twa'], // quantities this step's result replaces
|
|
96
156
|
stage: 'trueWind',
|
|
97
157
|
name: 'TWA table',
|
|
98
158
|
apply (c, cfg) {
|
|
@@ -109,15 +169,19 @@ const CORRECTIONS = [
|
|
|
109
169
|
},
|
|
110
170
|
{
|
|
111
171
|
id: 'tws',
|
|
172
|
+
produces: ['tws'], // quantities this step's result replaces
|
|
112
173
|
stage: 'trueWind',
|
|
113
174
|
name: 'TWS table',
|
|
114
175
|
apply (c, cfg, all) {
|
|
115
|
-
|
|
176
|
+
// Blank cells mean no correction there.
|
|
177
|
+
const z = row => row.map(v => v == null ? 0 : v)
|
|
178
|
+
const kn = tableLookup({ tws: cfg.tws, upwind: z(cfg.upwind), reach: z(cfg.reach), downwind: z(cfg.downwind) },
|
|
179
|
+
all.corrections.twa.angles, Math.abs(c.twa), c.tws)
|
|
116
180
|
const before = c.tws
|
|
117
|
-
c.tws = c.tws
|
|
181
|
+
c.tws = Math.max(0, c.tws + kn)
|
|
118
182
|
return {
|
|
119
183
|
input: `TWS ${before.toFixed(1)} kn`,
|
|
120
|
-
detail: `${fmt(
|
|
184
|
+
detail: `${fmt(kn, 2)} kn`,
|
|
121
185
|
output: `TWS ${c.tws.toFixed(1)} kn`
|
|
122
186
|
}
|
|
123
187
|
}
|
|
@@ -128,11 +192,11 @@ function runStage (stage, c, config, steps) {
|
|
|
128
192
|
for (const corr of CORRECTIONS.filter(x => x.stage === stage)) {
|
|
129
193
|
const cfg = config.corrections[corr.id]
|
|
130
194
|
if (!cfg || !cfg.enabled) {
|
|
131
|
-
steps.push({ id: corr.id, name: corr.name, enabled: false })
|
|
195
|
+
steps.push({ id: corr.id, name: corr.name, produces: corr.produces, enabled: false })
|
|
132
196
|
continue
|
|
133
197
|
}
|
|
134
198
|
const r = corr.apply(c, cfg, config)
|
|
135
|
-
steps.push({ id: corr.id, name: corr.name, enabled: true, ...r })
|
|
199
|
+
steps.push({ id: corr.id, name: corr.name, produces: corr.produces, enabled: true, ...r })
|
|
136
200
|
}
|
|
137
201
|
}
|
|
138
202
|
|
|
@@ -160,7 +224,8 @@ function process (input, config) {
|
|
|
160
224
|
c.tws = Math.hypot(x, y)
|
|
161
225
|
steps.push({
|
|
162
226
|
id: 'truewind',
|
|
163
|
-
|
|
227
|
+
produces: ['twa', 'tws'],
|
|
228
|
+
name: 'True wind, before tables',
|
|
164
229
|
enabled: true,
|
|
165
230
|
input: `AWA ${Math.abs(input.awa).toFixed(1)}° · ${input.aws.toFixed(1)} kn`,
|
|
166
231
|
detail: 'apparent wind − boat velocity',
|
|
@@ -196,6 +261,7 @@ function process (input, config) {
|
|
|
196
261
|
out.current = { drift: Math.hypot(e, n), set: norm360(deg(Math.atan2(e, n))) }
|
|
197
262
|
steps.push({
|
|
198
263
|
id: 'current',
|
|
264
|
+
produces: ['current'],
|
|
199
265
|
name: 'Current',
|
|
200
266
|
enabled: true,
|
|
201
267
|
input: `SOG ${input.sog.toFixed(1)} · COG ${input.cog.toFixed(0)}°`,
|
|
@@ -234,9 +300,10 @@ function checkAxis (errors, label, xs) {
|
|
|
234
300
|
}
|
|
235
301
|
}
|
|
236
302
|
|
|
237
|
-
function checkRow (errors, label, ys, n) {
|
|
303
|
+
function checkRow (errors, label, ys, n, blanksAllowed) {
|
|
238
304
|
if (!Array.isArray(ys) || ys.length !== n) { errors.push(`${label}: needs ${n} values, one per column`); return }
|
|
239
|
-
|
|
305
|
+
const ok = v => (blanksAllowed && v === null) || (typeof v === 'number' && Number.isFinite(v))
|
|
306
|
+
if (!ys.every(ok)) errors.push(`${label}: every value must be a number${blanksAllowed ? ' or blank' : ''}`)
|
|
240
307
|
}
|
|
241
308
|
|
|
242
309
|
function checkNumber (errors, label, v) {
|
|
@@ -250,11 +317,32 @@ function checkNumber (errors, label, v) {
|
|
|
250
317
|
// config of schema `old` into schema `old + 1`. Older files are then upgraded step by
|
|
251
318
|
// step as they load and saved back in the current shape; nothing is lost or reset.
|
|
252
319
|
//
|
|
253
|
-
// Schema 1 (0.0.1): mode, variation, sources, corrections { stw, leeway, twa, tws }
|
|
320
|
+
// Schema 1 (0.0.1–0.0.3): mode, variation, sources, corrections { stw, leeway, twa, tws },
|
|
321
|
+
// with stw as two rows: linearity { stw, add } and heel { heel, add } by |heel|.
|
|
322
|
+
// Schema 2 (0.0.4): stw is one grid { heel (signed), stw, add[heel][stw] }, blanks allowed;
|
|
323
|
+
// the TWS table is in knots (was percent); both wind tables allow blanks.
|
|
254
324
|
|
|
255
|
-
const CONFIG_SCHEMA =
|
|
325
|
+
const CONFIG_SCHEMA = 2
|
|
256
326
|
const MIGRATIONS = {
|
|
257
|
-
//
|
|
327
|
+
// Two rows become the grid they describe: signed heel rows mirrored from the |heel|
|
|
328
|
+
// row, the linearity speeds as columns, each cell linearity + heel.
|
|
329
|
+
1: cfg => {
|
|
330
|
+
cfg = clone(cfg)
|
|
331
|
+
const t = cfg.corrections && cfg.corrections.tws
|
|
332
|
+
if (t && Array.isArray(t.tws)) {
|
|
333
|
+
// percent of TWS -> knots at each column
|
|
334
|
+
for (const r of ['upwind', 'reach', 'downwind']) {
|
|
335
|
+
if (Array.isArray(t[r])) t[r] = t[r].map((v, j) => typeof v === 'number' ? +(v / 100 * t.tws[j]).toFixed(3) : v)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const old = cfg.corrections && cfg.corrections.stw
|
|
339
|
+
if (!old || !old.linearity || !old.heel) return cfg
|
|
340
|
+
const ah = old.heel.heel
|
|
341
|
+
const heel = Array.from(new Set(ah.map(h => -h).concat(ah))).sort((x, y) => x - y)
|
|
342
|
+
const stwBins = old.linearity.stw
|
|
343
|
+
const add = heel.map(h => stwBins.map((_, j) => +(old.linearity.add[j] + interp(ah, old.heel.add, Math.abs(h))).toFixed(4)))
|
|
344
|
+
return { ...cfg, corrections: { ...cfg.corrections, stw: { enabled: old.enabled, heel, stw: stwBins, add } } }
|
|
345
|
+
}
|
|
258
346
|
}
|
|
259
347
|
|
|
260
348
|
// Bring a raw config up to the current schema. A config without `schema` predates the
|
|
@@ -295,11 +383,19 @@ function normalizeConfig (input) {
|
|
|
295
383
|
if (!['shadow', 'replace'].includes(config.mode)) errors.push('mode: must be shadow or replace')
|
|
296
384
|
checkNumber(errors, 'variation', config.variation)
|
|
297
385
|
|
|
298
|
-
checkAxis(errors, 'STW
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
if (Array.isArray(
|
|
386
|
+
checkAxis(errors, 'STW table heel angles', k.stw.heel)
|
|
387
|
+
checkAxis(errors, 'STW table speeds', k.stw.stw)
|
|
388
|
+
if (Array.isArray(k.stw.stw) && !(k.stw.stw[0] > 0)) errors.push('STW table speeds: must start above 0 kn')
|
|
389
|
+
const g = k.stw.add
|
|
390
|
+
if (!Array.isArray(g) || !Array.isArray(k.stw.heel) || g.length !== k.stw.heel.length) {
|
|
391
|
+
errors.push('STW table: needs one row per heel angle')
|
|
392
|
+
} else if (g.some(r => !Array.isArray(r) || r.length !== (k.stw.stw || []).length)) {
|
|
393
|
+
errors.push('STW table: every row needs one value per speed')
|
|
394
|
+
} else if (g.some(r => r.some(v => v !== null && (typeof v !== 'number' || !Number.isFinite(v))))) {
|
|
395
|
+
errors.push('STW table: every value must be a number or blank')
|
|
396
|
+
} else if (!g.some(r => r.some(v => v !== null))) {
|
|
397
|
+
errors.push('STW table: has no values at all')
|
|
398
|
+
}
|
|
303
399
|
|
|
304
400
|
checkNumber(errors, 'Leeway K', k.leeway.k)
|
|
305
401
|
|
|
@@ -307,7 +403,7 @@ function normalizeConfig (input) {
|
|
|
307
403
|
const t = k[id]
|
|
308
404
|
const name = id.toUpperCase() + ' table'
|
|
309
405
|
checkAxis(errors, `${name} TWS columns`, t.tws)
|
|
310
|
-
for (const row of ['upwind', 'reach', 'downwind']) checkRow(errors, `${name} ${row} row`, t[row], (t.tws || []).length)
|
|
406
|
+
for (const row of ['upwind', 'reach', 'downwind']) checkRow(errors, `${name} ${row} row`, t[row], (t.tws || []).length, true)
|
|
311
407
|
}
|
|
312
408
|
const ang = k.twa.angles
|
|
313
409
|
;['upwind', 'reach', 'downwind'].forEach(r => checkNumber(errors, `TWA table ${r} angle`, ang[r]))
|
|
@@ -327,5 +423,5 @@ module.exports = {
|
|
|
327
423
|
normalizeConfig,
|
|
328
424
|
CORRECTIONS,
|
|
329
425
|
CONFIG_SCHEMA,
|
|
330
|
-
_internals: { interp, blendRows, tableLookup, merge, norm180, migrate }
|
|
426
|
+
_internals: { interp, interpRow, blendRows, tableLookup, merge, norm180, migrate, fillGaps, stwGridLookup }
|
|
331
427
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-performance-processor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "True wind direction that holds steady through tacks and gybes. H5000-style calibration in Signal K: boat speed, leeway, and TWA/TWS tables by wind speed, edited and watched live in a webapp. Try it in shadow mode beside your existing instruments.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
package/public/app.js
CHANGED
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
// Display names and help for each correction, in pipeline order. A correction the
|
|
9
9
|
// plugin knows but this list does not still shows, under its id.
|
|
10
10
|
const CORRECTIONS = {
|
|
11
|
-
stw: { name: 'Boat speed (STW)', what: '
|
|
11
|
+
stw: { name: 'Boat speed (STW)', what: 'Knots added to the paddlewheel by heel (rows, + = starboard side down) and speed (columns), like the H5000 boat speed / heel table. Blank cells are filled from the nearest value in their row. Applied before anything else uses STW.' },
|
|
12
12
|
leeway: { name: 'Leeway', what: 'Sideways slip: leeway = K × heel ÷ STW². Turns the boat’s velocity to leeward for true wind and current.' },
|
|
13
13
|
twa: { name: 'TWA table', what: 'Degrees added to |TWA| by TWS, upwind, reaching and downwind, H5000 style. Negative = TWA was reading too wide. Rows blend by TWA at the angles below.' },
|
|
14
|
-
tws: { name: 'TWS table', what: '
|
|
14
|
+
tws: { name: 'TWS table', what: 'Knots added to TWS by TWS, upwind, reaching and downwind, H5000 style. Blended at the TWA table’s angles. Blank = no correction. The default downwind row is −10% of each column.' }
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
const SOURCE_LABELS = {
|
|
@@ -119,8 +119,9 @@
|
|
|
119
119
|
let value
|
|
120
120
|
if (el.type === 'checkbox') value = el.checked
|
|
121
121
|
else if (el.type === 'number') {
|
|
122
|
-
|
|
123
|
-
value
|
|
122
|
+
// A blank cell in a table that allows it means "not measured"; elsewhere keep the
|
|
123
|
+
// old value until something is typed.
|
|
124
|
+
if (el.value === '') { if (!el.dataset.nullable) return; value = null } else value = Number(el.value)
|
|
124
125
|
} else value = el.value
|
|
125
126
|
setPath(el.dataset.path, value)
|
|
126
127
|
renderSaveBar()
|
|
@@ -186,7 +187,7 @@
|
|
|
186
187
|
|
|
187
188
|
if (!r) {
|
|
188
189
|
$('tiles').innerHTML = ''
|
|
189
|
-
$('steps').innerHTML = `<tr><td colspan="
|
|
190
|
+
$('steps').innerHTML = `<tr><td colspan="5" class="muted center">${esc(live ? live.status || 'Waiting for data…' : 'Waiting for data…')}</td></tr>`
|
|
190
191
|
} else {
|
|
191
192
|
const o = r.out
|
|
192
193
|
const u = uncorrected(r.input)
|
|
@@ -199,10 +200,23 @@
|
|
|
199
200
|
tile('Leeway', fmt(Math.abs(o.leeway)), `° ${side(o.leeway)}`, r.input.heel == null ? 'no heel' : `heel ${fmt(r.input.heel, 0)}°`),
|
|
200
201
|
tile('Current', o.current ? fmt(o.current.drift, 2) : '--', o.current ? `kn · ${fmt(o.current.set, 0)}°` : 'kn', o.current ? '' : 'no SOG/COG or heading')
|
|
201
202
|
].join('')
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
).
|
|
203
|
+
// A step's result is published only if no later enabled step replaces it; otherwise
|
|
204
|
+
// it is a working value that only feeds the steps after it.
|
|
205
|
+
const last = {}
|
|
206
|
+
r.steps.forEach((s, i) => { if (s.enabled && !s.skipped) (s.produces || []).forEach(q => { last[q] = i }) })
|
|
207
|
+
const NAMES = { stw: 'STW', leeway: 'Leeway', twa: 'TWA', tws: 'TWS', current: 'Current' }
|
|
208
|
+
$('steps').innerHTML = r.steps.map((s, i) => {
|
|
209
|
+
if (!s.enabled) return `<tr class="off"><th scope="row">${esc(s.name)}</th><td></td><td>off</td><td></td><td></td></tr>`
|
|
210
|
+
const sent = (s.produces || []).filter(q => last[q] === i)
|
|
211
|
+
const working = (s.produces || []).filter(q => last[q] !== i)
|
|
212
|
+
const tag = s.skipped ? '' : [
|
|
213
|
+
sent.length ? `<span class="chip pub">Published: ${sent.map(q => NAMES[q]).join(', ')}</span>` : '',
|
|
214
|
+
working.length ? `<span class="chip work">Working: ${working.map(q => NAMES[q]).join(', ')}</span>` : ''
|
|
215
|
+
].join(' ')
|
|
216
|
+
return `<tr class="${sent.length ? '' : 'working'}"><th scope="row">${esc(s.name)}</th><td class="mono">${esc(s.input || '')}</td>` +
|
|
217
|
+
`<td>${esc(s.skipped ? 'skipped — ' + s.skipped : s.detail || '')}</td><td class="mono result">${esc(s.output || '')}</td><td>${tag}</td></tr>`
|
|
218
|
+
}).join('')
|
|
219
|
+
renderPublished(o, live.outputPaths || {})
|
|
206
220
|
}
|
|
207
221
|
|
|
208
222
|
const inputs = live ? Object.entries(live.inputs) : []
|
|
@@ -211,6 +225,22 @@
|
|
|
211
225
|
: '<tr><td colspan="3" class="muted center">Nothing received yet</td></tr>'
|
|
212
226
|
}
|
|
213
227
|
|
|
228
|
+
// The values the plugin actually sends, one per path, matching what index.js publishes.
|
|
229
|
+
function renderPublished (o, paths) {
|
|
230
|
+
const on = id => !!(state.saved && state.saved.corrections[id] && state.saved.corrections[id].enabled)
|
|
231
|
+
const side = v => v < 0 ? 'port' : 'stbd'
|
|
232
|
+
const rows = [
|
|
233
|
+
['TWA', `${fmt(Math.abs(o.twa))}° ${side(o.twa)}`, paths.twa],
|
|
234
|
+
['TWS', `${fmt(o.tws)} kn`, paths.tws]
|
|
235
|
+
]
|
|
236
|
+
if (o.twd != null) rows.push(['TWD', `${fmt(o.twd, 0)}° T`, paths.twd])
|
|
237
|
+
if (on('stw')) rows.push(['STW', `${fmt(o.stw, 2)} kn`, paths.stw])
|
|
238
|
+
if (on('leeway')) rows.push(['Leeway', `${fmt(Math.abs(o.leeway))}° ${side(o.leeway)}`, paths.leeway])
|
|
239
|
+
if (o.current) rows.push(['Current', `${fmt(o.current.drift, 2)} kn → ${fmt(o.current.set, 0)}°`, paths.current])
|
|
240
|
+
$('published').innerHTML = rows.map(([label, value, p]) =>
|
|
241
|
+
`<tr><th scope="row">${esc(label)}</th><td class="num">${esc(value)}</td><td class="mono">${esc(p || '')}</td></tr>`).join('')
|
|
242
|
+
}
|
|
243
|
+
|
|
214
244
|
// -- Calibration
|
|
215
245
|
|
|
216
246
|
function correctionIds () {
|
|
@@ -236,13 +266,16 @@
|
|
|
236
266
|
const meta = CORRECTIONS[id] || { name: id, what: '' }
|
|
237
267
|
let body = ''
|
|
238
268
|
if (id === 'stw') {
|
|
239
|
-
body =
|
|
240
|
-
|
|
269
|
+
body = grid2d('Knots added to STW', 'Heel ° ╲ STW kn', 'corrections.stw', c) +
|
|
270
|
+
`<details class="paste"><summary>Paste a table</summary>` +
|
|
271
|
+
`<p class="muted sub">Same format as signalk-stw-heel-correction: first row <code>heel\\stw</code> then the speeds; each further row a heel angle then its corrections. Commas, tabs or semicolons; blank cells allowed.</p>` +
|
|
272
|
+
`<textarea id="pasteStw" rows="8" spellcheck="false" aria-label="Heel by STW table to paste"></textarea>` +
|
|
273
|
+
`<div><button type="button" class="ghost" id="pasteStwGo">Replace the table</button> <span id="pasteStwMsg" class="warn"></span></div></details>`
|
|
241
274
|
} else if (id === 'leeway') {
|
|
242
275
|
body = `<div class="fields">${field('K', 'corrections.leeway.k', c.k, 0.01)}</div>`
|
|
243
276
|
} else if (id === 'twa' || id === 'tws') {
|
|
244
277
|
const rows = [['Upwind', 'upwind'], ['Reach', 'reach'], ['Downwind', 'downwind']]
|
|
245
|
-
body = grid(id === 'twa' ? 'Degrees added to |TWA|' : '
|
|
278
|
+
body = grid(id === 'twa' ? 'Degrees added to |TWA| (blank = not enough data: nearest measured value used)' : 'Knots added to TWS (blank = no correction)', 'TWS kn', `corrections.${id}`, 'tws', rows, c, true)
|
|
246
279
|
if (id === 'twa') {
|
|
247
280
|
const a = c.angles
|
|
248
281
|
body += `<div class="fields">${field('Upwind row to (° TWA)', 'corrections.twa.angles.upwind', a.upwind, 1)}` +
|
|
@@ -256,16 +289,47 @@
|
|
|
256
289
|
`<div><button type="button" class="ghost" data-defaults="${esc(id)}">Reset to defaults</button></div>`
|
|
257
290
|
}
|
|
258
291
|
|
|
259
|
-
// A table whose
|
|
260
|
-
|
|
292
|
+
// A table whose header row is its fixed axis (the breakpoints, shown as labels) and
|
|
293
|
+
// whose other rows are the editable correction values.
|
|
294
|
+
function grid (title, axisLabel, base, axisKey, rows, obj, nullable) {
|
|
261
295
|
const axis = obj[axisKey]
|
|
262
|
-
const cell = (path, v,
|
|
296
|
+
const cell = (path, v, name) => `<td><input type="number" step="any"${nullable ? ' data-nullable="1"' : ''} data-path="${esc(path)}" value="${v == null ? '' : esc(v)}" aria-label="${esc(name)}"></td>`
|
|
263
297
|
return `<div><h2>${esc(title)}</h2><div class="table-wrap"><table class="grid">` +
|
|
264
|
-
`<tr><th scope="row" class="muted">${esc(axisLabel)}</th>${axis.map(
|
|
265
|
-
rows.map(([label, key]) => `<tr><th scope="row">${esc(label)}</th>${obj[key].map((v, i) => cell(`${base}.${key}.${i}`, v)).join('')}</tr>`).join('') +
|
|
298
|
+
`<tr><th scope="row" class="muted">${esc(axisLabel)}</th>${axis.map(v => `<th scope="col" class="axis">${esc(v)}</th>`).join('')}</tr>` +
|
|
299
|
+
rows.map(([label, key]) => `<tr><th scope="row">${esc(label)}</th>${obj[key].map((v, i) => cell(`${base}.${key}.${i}`, v, `${label} at ${axis[i]}`)).join('')}</tr>`).join('') +
|
|
266
300
|
'</table></div></div>'
|
|
267
301
|
}
|
|
268
302
|
|
|
303
|
+
// A two-dimensional table: fixed row and column headings, editable cells, and blank
|
|
304
|
+
// cells allowed (null = not measured).
|
|
305
|
+
function grid2d (title, corner, base, t) {
|
|
306
|
+
const head = `<tr><th scope="col" class="muted">${esc(corner)}</th>${t.stw.map(v => `<th scope="col" class="axis">${esc(v)}</th>`).join('')}</tr>`
|
|
307
|
+
const rows = t.heel.map((h, i) => `<tr><th scope="row" class="axis">${esc(h)}</th>` +
|
|
308
|
+
t.add[i].map((v, j) => `<td><input type="number" step="any" data-nullable="1" data-path="${base}.add.${i}.${j}" value="${v == null ? '' : esc(v)}" aria-label="heel ${esc(h)} at ${esc(t.stw[j])} kn"></td>`).join('') + '</tr>')
|
|
309
|
+
return `<div><h2>${esc(title)}</h2><div class="table-wrap"><table class="grid">${head}${rows.join('')}</table></div></div>`
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Read a pasted heel x STW table: header row of speeds, then a heel angle and its
|
|
313
|
+
// corrections per row. Tolerates the usual spreadsheet damage.
|
|
314
|
+
function parseStwTable (text) {
|
|
315
|
+
const clean = c => c.trim().replace(/^"(.*)"$/, '$1').replace(/\u2212/g, '-').replace(/\u00b0/g, '').trim()
|
|
316
|
+
const lines = text.replace(/^\ufeff/, '').trim().split(/\r?\n/).filter(l => l.trim())
|
|
317
|
+
if (lines.length < 2) throw new Error('needs a header row of speeds and at least one heel row')
|
|
318
|
+
const sep = lines[0].includes(',') ? ',' : lines[0].includes('\t') ? '\t' : ';'
|
|
319
|
+
const rows = lines.map(l => l.split(sep).map(clean))
|
|
320
|
+
const num = (c, what) => { const n = Number(c); if (c === '' || !Number.isFinite(n)) throw new Error(`${what} is not a number: "${c}"`); return n }
|
|
321
|
+
let stw = rows[0].slice(1).map((c, j) => num(c, `speed ${j + 1} in the header`))
|
|
322
|
+
let heel = []; let add = []
|
|
323
|
+
rows.slice(1).forEach((r, i) => {
|
|
324
|
+
if (r.length - 1 !== stw.length) throw new Error(`row ${i + 2} has ${r.length - 1} values, the header has ${stw.length} speeds`)
|
|
325
|
+
heel.push(num(r[0], `heel in row ${i + 2}`))
|
|
326
|
+
add.push(r.slice(1).map((c, j) => c === '' ? null : num(c, `value at heel ${r[0]}, ${stw[j]} kn`)))
|
|
327
|
+
})
|
|
328
|
+
if (stw.length > 1 && stw[0] > stw[stw.length - 1]) { stw = stw.reverse(); add = add.map(r => r.reverse()) }
|
|
329
|
+
if (heel.length > 1 && heel[0] > heel[heel.length - 1]) { heel = heel.reverse(); add = add.reverse() }
|
|
330
|
+
return { heel, stw, add }
|
|
331
|
+
}
|
|
332
|
+
|
|
269
333
|
function field (label, path, value, step) {
|
|
270
334
|
return `<label class="field">${esc(label)}<input type="number" step="${step}" data-path="${esc(path)}" value="${esc(value)}"></label>`
|
|
271
335
|
}
|
|
@@ -293,7 +357,7 @@
|
|
|
293
357
|
const chosen = state.draft.sources[key] || ''
|
|
294
358
|
const opts = Array.from(new Set([...(seen[key] || []), chosen].filter(Boolean)))
|
|
295
359
|
return `<label>${esc(label)}<select data-path="sources.${key}">` +
|
|
296
|
-
`<option value=""
|
|
360
|
+
`<option value="">${key === 'stw' ? 'Auto (sensors only)' : 'Any'}</option>` +
|
|
297
361
|
opts.map(s => `<option value="${esc(s)}" ${s === chosen ? 'selected' : ''}>${esc(s)}${(seen[key] || []).includes(s) ? '' : ' (not seen)'}</option>`).join('') +
|
|
298
362
|
'</select></label>'
|
|
299
363
|
}).join('')
|
|
@@ -317,6 +381,15 @@
|
|
|
317
381
|
document.addEventListener('click', e => {
|
|
318
382
|
const sel = e.target.closest('[data-select]')
|
|
319
383
|
if (sel) { state.selected = sel.dataset.select; renderCalibration() }
|
|
384
|
+
if (e.target.id === 'pasteStwGo') {
|
|
385
|
+
try {
|
|
386
|
+
const t = parseStwTable($('pasteStw').value)
|
|
387
|
+
Object.assign(state.draft.corrections.stw, t)
|
|
388
|
+
renderAll()
|
|
389
|
+
} catch (err) {
|
|
390
|
+
$('pasteStwMsg').textContent = err.message
|
|
391
|
+
}
|
|
392
|
+
}
|
|
320
393
|
const def = e.target.closest('[data-defaults]')
|
|
321
394
|
if (def && state.defaults) {
|
|
322
395
|
const id = def.dataset.defaults
|
package/public/index.html
CHANGED
|
@@ -35,10 +35,20 @@
|
|
|
35
35
|
<div id="tiles" class="tiles"></div>
|
|
36
36
|
<div class="panel">
|
|
37
37
|
<div class="panel-head"><h2>Pipeline</h2><span id="liveAge" class="muted sub"></span></div>
|
|
38
|
+
<p class="muted sub intro">Each step works on the result of the one before. Only results marked <b>Published</b> are sent to Signal K; <b>Working</b> results feed the next step and go nowhere else.</p>
|
|
38
39
|
<div class="table-wrap">
|
|
39
40
|
<table>
|
|
40
|
-
<thead><tr><th>Step</th><th>In</th><th>Correction</th><th>
|
|
41
|
-
<tbody id="steps"><tr><td colspan="
|
|
41
|
+
<thead><tr><th>Step</th><th>In</th><th>Correction</th><th>Result</th><th>Sent to Signal K</th></tr></thead>
|
|
42
|
+
<tbody id="steps"><tr><td colspan="5" class="muted center">Waiting for data…</td></tr></tbody>
|
|
43
|
+
</table>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
<div class="panel">
|
|
47
|
+
<div class="panel-head"><h2>Published to Signal K</h2><span class="muted sub">only these values are sent; the pipeline above shows the working</span></div>
|
|
48
|
+
<div class="table-wrap">
|
|
49
|
+
<table>
|
|
50
|
+
<thead><tr><th>Value</th><th class="num">Now</th><th>Path</th></tr></thead>
|
|
51
|
+
<tbody id="published"><tr><td colspan="3" class="muted center">Nothing published yet</td></tr></tbody>
|
|
42
52
|
</table>
|
|
43
53
|
</div>
|
|
44
54
|
</div>
|
package/public/style.css
CHANGED
|
@@ -104,6 +104,11 @@ table { width: 100%; border-collapse: collapse; }
|
|
|
104
104
|
th, td { padding: 8px; border-bottom: 1px solid var(--line); white-space: nowrap; text-align: left; }
|
|
105
105
|
th { font-size: 0.75em; color: var(--muted); text-transform: uppercase; }
|
|
106
106
|
tr.off td { color: var(--muted); }
|
|
107
|
+
/* Pipeline: published results stand out, working values recede */
|
|
108
|
+
.chip { display: inline-block; font-size: 0.7em; font-weight: 700; padding: 2px 8px; border-radius: 999px; white-space: nowrap; }
|
|
109
|
+
.chip.pub { background: var(--active); color: var(--accent); border: 1px solid var(--accent); }
|
|
110
|
+
.chip.work { color: var(--muted); border: 1px dashed var(--line); }
|
|
111
|
+
tr.working .result { color: var(--muted); font-style: italic; }
|
|
107
112
|
td.mono { font-variant-numeric: tabular-nums; }
|
|
108
113
|
|
|
109
114
|
/* Calibration */
|
|
@@ -153,12 +158,16 @@ td.mono { font-variant-numeric: tabular-nums; }
|
|
|
153
158
|
.grid th, .grid td { border: 0; padding: 0; }
|
|
154
159
|
.grid th { padding: 0 6px; }
|
|
155
160
|
.grid input { width: 4.6em; text-align: right; font-variant-numeric: tabular-nums; background: var(--cell); }
|
|
156
|
-
.grid
|
|
161
|
+
.grid th.axis { text-align: right; padding: 0 10px 2px; font-size: 0.8em; font-weight: 600; color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
157
162
|
.grid input:focus, .field input:focus { outline: 2px solid var(--accent); outline-offset: 0; }
|
|
158
163
|
.fields { display: flex; flex-wrap: wrap; gap: 16px; }
|
|
159
164
|
.field { display: flex; flex-direction: column; gap: 4px; font-size: 0.8em; font-weight: 600; }
|
|
160
165
|
.field input { width: 7em; }
|
|
161
166
|
|
|
167
|
+
.paste summary { cursor: pointer; font-size: 0.85em; color: var(--accent); min-height: 32px; }
|
|
168
|
+
.paste textarea { width: 100%; font: 0.8em ui-monospace, monospace; background: var(--bg); color: var(--fg); border: 1px solid var(--line); border-radius: 6px; padding: 8px; }
|
|
169
|
+
.paste code { font-size: 0.95em; }
|
|
170
|
+
|
|
162
171
|
/* Settings */
|
|
163
172
|
.modes { border: 0; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
|
164
173
|
.mode { display: flex; gap: 12px; align-items: flex-start; padding: 10px; border: 1px solid var(--line); border-radius: 8px; cursor: pointer; }
|