signalk-performance-processor 0.0.3 → 0.0.4

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.4 — 2026-10-29
4
+
5
+ Still a proof of concept: run it in Shadow mode alongside your instruments. Saved configurations are upgraded to schema 2 on first load.
6
+
7
+ - **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.
8
+ - **TWS table is in knots** (H5000 units). Default downwind −0.5 … −3 kn (−10% of each column); upwind and reach blank, meaning no correction.
9
+ - **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.
10
+ - Config file schema 2. Schema 1 files are upgraded on load: the two STW rows become their grid and TWS percentages become knots.
11
+ - 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.
12
+ - Calibration tables: the breakpoints (TWS columns, STW speeds, heel angles) are fixed labels, not editable fields; only the correction values are edited.
13
+ - 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.
14
+
3
15
  ## 0.0.3 — 2026-10-29
4
16
 
5
17
  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.3).** Compare it against your instruments in Shadow mode before relying on it. See the [changelog](CHANGELOG.md).
11
+ > **Proof of concept (0.0.4).** 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 linearity and heel correction, leeway, 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.
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 slightly (the owner's leeway K, and the TWA table resampled to H5000 breakpoints).
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: a **linearity** row by speed plus a **heel correction** row by heel, the same split as the H5000 | On |
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 | Percent applied to TWS by TWS, same rows and blending | Off |
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
@@ -99,28 +99,28 @@ Everything is published under this plugin's own `$source`. Corrected STW goes ou
99
99
 
100
100
  Open **Webapps → Performance Processor**.
101
101
 
102
- - **Live**: the outputs next to their uncorrected values, what each step is doing right now, and which source and how fresh each input is.
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 fitted values for that correction.
102
+ - **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.
103
+ - **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.
104
104
  - **Settings**: Shadow or Replace, the fallback magnetic variation, and which source to read for each input.
105
105
 
106
106
  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
107
 
108
108
  ## Defaults for this boat
109
109
 
110
- Fitted to the 2025 race logs and checked on Jan–Mar 2026 races not used in fitting, by how little true wind direction and current jump through tacks, gybes and roundings.
110
+ What a fresh install starts with. Every value can be changed in the webapp.
111
111
 
112
- - **STW:** linearity +0.58 kn at 0.5 kn falling to 0 at 8.5 kn (tapering to 0 below 0.5 kn); heel 0 to −0.29 kn at 30°. This is the owner's measured heel × STW table, reduced to two rows within 0.03 kn of every cell.
112
+ - **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
113
  - **Leeway:** K = 7.9446, one value for every sail.
114
- - **TWA table (°):**
114
+ - **TWA table (°)**, from the owner's calibration fit (blank = too little data):
115
115
 
116
116
  | TWS (kn) | 5 | 10 | 15 | 20 | 25 | 30 |
117
117
  |---|---|---|---|---|---|---|
118
- | Upwind | −12.0 | −13.4 | −5.7 | −4.3 | −4.3 | −4.3 |
119
- | Reach | −10.7 | −10.7 | −10.7 | −10.7 | −10.7 | −10.7 |
120
- | Downwind | −4.2 | −3.5 | −1.6 | −1.3 | −1.3 | −1.3 |
118
+ | Upwind | −7.6 | −12.6 | −5.8 | −5.7 | +0.2 | |
119
+ | Reach | −2.4 | −4.4 | −5.4 | +1.4 | +3.9 | +1.0 |
120
+ | Downwind | +1.2 | −3.5 | +1.0 | | | |
121
121
 
122
- The reach row is provisional: it rests on roundings only.
123
- - **TWS table (%):** off. Downwind −11.1% at 15 kn and −14.4% from 20 kn; light air and upwind left at 0.
122
+ 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.
123
+ - **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
124
  - **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
125
 
126
126
  ## Configuration file
@@ -129,15 +129,20 @@ Fitted to the 2025 race logs and checked on Jan–Mar 2026 races not used in fit
129
129
 
130
130
  ```json
131
131
  {
132
- "schema": 1,
132
+ "schema": 2,
133
133
  "mode": "shadow",
134
134
  "variation": 12.8,
135
135
  "sources": { "apparentWind": "", "stw": "", "heading": "", "attitude": "", "gps": "", "variation": "" },
136
- "corrections": { "stw": { … }, "leeway": { … }, "twa": { … }, "tws": { … } }
136
+ "corrections": {
137
+ "stw": { "enabled": true, "heel": [-35, …, 35], "stw": [0.5, …, 8.5], "add": [[…], …] },
138
+ "leeway": { "enabled": true, "k": 7.9446 },
139
+ "twa": { "enabled": true, "tws": [5, …, 30], "upwind": […], "reach": […], "downwind": […], "angles": { … } },
140
+ "tws": { "enabled": false, "tws": [5, …, 30], "upwind": […], "reach": […], "downwind": […] }
141
+ }
137
142
  }
138
143
  ```
139
144
 
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.
145
+ `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
146
 
142
147
  ## Adding a correction
143
148
 
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
- linearity: { stw: [0.5, 1, 2, 3, 4, 6, 8.5], add: [0.58, 0.61, 0.38, 0.19, 0.09, 0.02, 0.00] },
21
- heel: { heel: [0, 10, 20, 30, 35], add: [0.00, -0.03, -0.13, -0.29, -0.29] }
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
- // Rows are blended by TWA: upwind in full to angles.upwind, reach at angles.reach,
28
- // downwind from angles.downwind. The TWS table shares these angles.
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: [-12.0, -13.4, -5.7, -4.3, -4.3, -4.3],
33
- reach: [-10.7, -10.7, -10.7, -10.7, -10.7, -10.7],
34
- downwind: [-4.2, -3.5, -1.6, -1.3, -1.3, -1.3],
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
- // Percent applied to TWS by TWS, same rows and blending as the TWA table.
38
- // Off by default: light air fitted to implausible values, so it is zeroed here.
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: [0, 0, 0, 0, 0, 0],
43
- reach: [0, 0, 0, 0, 0, 0],
44
- downwind: [0, 0, -11.1, -14.4, -14.4, -14.4]
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
- interp(table.tws, table.upwind, tws),
49
- interp(table.tws, table.reach, tws),
50
- interp(table.tws, table.downwind, tws))
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
- // Linearity starts at its first bin; below that taper to zero at 0 kn, so a
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 + add1 + add2)
127
+ c.stw = Math.max(0, c.stw + add)
70
128
  return {
71
- input: `${before.toFixed(2)} kn` + (c.heel == null ? '' : ` · heel ${Math.abs(c.heel).toFixed(0)}°`),
72
- detail: `${fmt(add1, 2)} linearity, ${fmt(add2, 2)} heel` + (c.heel == null ? ' (no heel data)' : ''),
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
- const pct = tableLookup(cfg, all.corrections.twa.angles, Math.abs(c.twa), c.tws)
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 * (1 + pct / 100)
181
+ c.tws = Math.max(0, c.tws + kn)
118
182
  return {
119
183
  input: `TWS ${before.toFixed(1)} kn`,
120
- detail: `${fmt(pct)}%`,
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
- name: 'True wind',
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
- if (ys.some(v => typeof v !== 'number' || !Number.isFinite(v))) errors.push(`${label}: every value must be a number`)
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 = 1
325
+ const CONFIG_SCHEMA = 2
256
326
  const MIGRATIONS = {
257
- // 1: cfg => ({ ...cfg, schema: 2, ... }),
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 linearity speeds', k.stw.linearity.stw)
299
- checkRow(errors, 'STW linearity values', k.stw.linearity.add, (k.stw.linearity.stw || []).length)
300
- checkAxis(errors, 'STW heel angles', k.stw.heel.heel)
301
- checkRow(errors, 'STW heel values', k.stw.heel.add, (k.stw.heel.heel || []).length)
302
- if (Array.isArray(k.stw.linearity.stw) && !(k.stw.linearity.stw[0] > 0)) errors.push('STW linearity speeds: must start above 0 kn')
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",
3
+ "version": "0.0.4",
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: 'Paddlewheel linearity at low speed, plus the loss when heeled. Knots added to STW before anything else uses it.' },
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: 'Percent applied to TWS by TWS, upwind, reaching and downwind. Blended at the TWA table’s angles. H5000 default is −10% downwind.' }
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
- if (el.value === '') return // keep the old value until something is typed
123
- value = Number(el.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="4" class="muted center">${esc(live ? live.status || 'Waiting for data…' : 'Waiting for data…')}</td></tr>`
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
- $('steps').innerHTML = r.steps.map(s => s.enabled
203
- ? `<tr><th scope="row">${esc(s.name)}</th><td class="mono">${esc(s.input || '')}</td><td>${esc(s.skipped ? 'skipped — ' + s.skipped : s.detail || '')}</td><td class="mono">${esc(s.output || '')}</td></tr>`
204
- : `<tr class="off"><th scope="row">${esc(s.name)}</th><td></td><td>off</td><td></td></tr>`
205
- ).join('')
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 = grid('Linearity (kn added)', 'STW kn', `corrections.stw.linearity`, 'stw', [['Add', 'add']], c.linearity) +
240
- grid('Heel correction (kn added)', 'Heel °', `corrections.stw.heel`, 'heel', [['Add', 'add']], c.heel)
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|' : 'Percent applied to TWS', 'TWS kn', `corrections.${id}`, 'tws', rows, c)
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 first row is its axis (editable) and whose other rows are values.
260
- function grid (title, axisLabel, base, axisKey, rows, obj) {
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, cls) => `<td><input type="number" step="any" class="${cls || ''}" data-path="${esc(path)}" value="${esc(v)}"></td>`
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((v, i) => cell(`${base}.${axisKey}.${i}`, v, 'axis')).join('')}</tr>` +
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
  }
@@ -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>Out</th></tr></thead>
41
- <tbody id="steps"><tr><td colspan="4" class="muted center">Waiting for data…</td></tr></tbody>
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 input.axis { background: var(--bg); color: var(--muted); }
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; }