signalk-performance-processor 0.0.0-stage → 0.0.1
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 +12 -0
- package/LICENSE +21 -0
- package/README.md +114 -2
- package/index.js +368 -0
- package/lib/defaults.js +47 -0
- package/lib/processor.js +287 -0
- package/package.json +44 -4
- package/public/app.js +328 -0
- package/public/icon.svg +15 -0
- package/public/index.html +86 -0
- package/public/style.css +208 -0
package/lib/processor.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// The processing pipeline, free of Signal K so it can be tested and replayed against
|
|
4
|
+
// logged data. Units throughout: degrees and knots. Angles are signed, + = starboard;
|
|
5
|
+
// heel + = starboard side down; leeway + = sliding to starboard.
|
|
6
|
+
//
|
|
7
|
+
// Order of work:
|
|
8
|
+
// input corrections (CORRECTIONS with stage 'input')
|
|
9
|
+
// true wind apparent wind minus boat velocity through the water
|
|
10
|
+
// true wind corrections (stage 'trueWind')
|
|
11
|
+
// derived TWD and current
|
|
12
|
+
//
|
|
13
|
+
// A new correction is one more entry in CORRECTIONS plus its defaults in defaults.js;
|
|
14
|
+
// the pipeline, the Live view and the enable switch pick it up from there.
|
|
15
|
+
|
|
16
|
+
const { DEFAULTS } = require('./defaults')
|
|
17
|
+
|
|
18
|
+
const MAX_LEEWAY_DEG = 15
|
|
19
|
+
const MIN_LEEWAY_STW_KN = 1 // below this K x heel / STW^2 blows up; no leeway
|
|
20
|
+
|
|
21
|
+
const norm360 = d => ((d % 360) + 360) % 360
|
|
22
|
+
const norm180 = d => { const n = norm360(d); return n > 180 ? n - 360 : n }
|
|
23
|
+
const rad = d => d * Math.PI / 180
|
|
24
|
+
const deg = r => r * 180 / Math.PI
|
|
25
|
+
|
|
26
|
+
// Linear interpolation, holding the end values beyond either end.
|
|
27
|
+
function interp (xs, ys, x) {
|
|
28
|
+
if (x <= xs[0]) return ys[0]
|
|
29
|
+
const n = xs.length - 1
|
|
30
|
+
if (x >= xs[n]) return ys[n]
|
|
31
|
+
let i = 0
|
|
32
|
+
while (xs[i + 1] < x) i++
|
|
33
|
+
const t = (x - xs[i]) / (xs[i + 1] - xs[i])
|
|
34
|
+
return ys[i] + (ys[i + 1] - ys[i]) * t
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Upwind row in full up to angles.upwind, reach row at angles.reach, downwind row from
|
|
38
|
+
// angles.downwind, linear in |TWA| between.
|
|
39
|
+
function blendRows (absTwa, angles, up, reach, down) {
|
|
40
|
+
if (absTwa <= angles.upwind) return up
|
|
41
|
+
if (absTwa <= angles.reach) return up + (reach - up) * (absTwa - angles.upwind) / (angles.reach - angles.upwind)
|
|
42
|
+
if (absTwa <= angles.downwind) return reach + (down - reach) * (absTwa - angles.reach) / (angles.downwind - angles.reach)
|
|
43
|
+
return down
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function tableLookup (table, angles, absTwa, tws) {
|
|
47
|
+
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))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const fmt = (v, d = 1) => (v > 0 ? '+' : '') + v.toFixed(d)
|
|
54
|
+
|
|
55
|
+
const CORRECTIONS = [
|
|
56
|
+
{
|
|
57
|
+
id: 'stw',
|
|
58
|
+
stage: 'input',
|
|
59
|
+
name: 'Boat speed (STW)',
|
|
60
|
+
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))
|
|
68
|
+
const before = c.stw
|
|
69
|
+
c.stw = Math.max(0, c.stw + add1 + add2)
|
|
70
|
+
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)' : ''),
|
|
73
|
+
output: `${c.stw.toFixed(2)} kn`
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'leeway',
|
|
79
|
+
stage: 'input',
|
|
80
|
+
name: 'Leeway',
|
|
81
|
+
apply (c, cfg) {
|
|
82
|
+
if (c.heel == null) return { skipped: 'no heel data' }
|
|
83
|
+
const k = cfg.k
|
|
84
|
+
c.leeway = c.stw < MIN_LEEWAY_STW_KN
|
|
85
|
+
? 0
|
|
86
|
+
: Math.max(-MAX_LEEWAY_DEG, Math.min(MAX_LEEWAY_DEG, k * c.heel / (c.stw * c.stw)))
|
|
87
|
+
return {
|
|
88
|
+
input: `heel ${c.heel.toFixed(0)}°`,
|
|
89
|
+
detail: `K ${k} × heel ÷ STW²`,
|
|
90
|
+
output: `${Math.abs(c.leeway).toFixed(1)}° ${c.leeway < 0 ? 'port' : 'stbd'}`
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: 'twa',
|
|
96
|
+
stage: 'trueWind',
|
|
97
|
+
name: 'TWA table',
|
|
98
|
+
apply (c, cfg) {
|
|
99
|
+
const abs = Math.abs(c.twa)
|
|
100
|
+
const add = tableLookup(cfg, cfg.angles, abs, c.tws)
|
|
101
|
+
const before = c.twa
|
|
102
|
+
c.twa = Math.sign(c.twa || 1) * Math.max(0, Math.min(180, abs + add))
|
|
103
|
+
return {
|
|
104
|
+
input: `TWA ${Math.abs(before).toFixed(1)}° · TWS ${c.tws.toFixed(1)}`,
|
|
105
|
+
detail: `${fmt(add)}°`,
|
|
106
|
+
output: `TWA ${Math.abs(c.twa).toFixed(1)}°`
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: 'tws',
|
|
112
|
+
stage: 'trueWind',
|
|
113
|
+
name: 'TWS table',
|
|
114
|
+
apply (c, cfg, all) {
|
|
115
|
+
const pct = tableLookup(cfg, all.corrections.twa.angles, Math.abs(c.twa), c.tws)
|
|
116
|
+
const before = c.tws
|
|
117
|
+
c.tws = c.tws * (1 + pct / 100)
|
|
118
|
+
return {
|
|
119
|
+
input: `TWS ${before.toFixed(1)} kn`,
|
|
120
|
+
detail: `${fmt(pct)}%`,
|
|
121
|
+
output: `TWS ${c.tws.toFixed(1)} kn`
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
function runStage (stage, c, config, steps) {
|
|
128
|
+
for (const corr of CORRECTIONS.filter(x => x.stage === stage)) {
|
|
129
|
+
const cfg = config.corrections[corr.id]
|
|
130
|
+
if (!cfg || !cfg.enabled) {
|
|
131
|
+
steps.push({ id: corr.id, name: corr.name, enabled: false })
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
const r = corr.apply(c, cfg, config)
|
|
135
|
+
steps.push({ id: corr.id, name: corr.name, enabled: true, ...r })
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Input corrections only: STW and leeway from raw STW and heel. Used on its own to
|
|
140
|
+
// republish corrected STW as soon as the paddlewheel reports.
|
|
141
|
+
function correctInputs (input, config) {
|
|
142
|
+
const c = { stw: input.stw, heel: input.heel == null ? null : input.heel, leeway: 0 }
|
|
143
|
+
const steps = []
|
|
144
|
+
runStage('input', c, config, steps)
|
|
145
|
+
return { c, steps }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// input: { awa, aws, stw, heel?, headingMagnetic?, headingTrue?, sog?, cog? }
|
|
149
|
+
// returns { out: { stw, leeway, twa, tws, heading?, twd?, current? }, steps }
|
|
150
|
+
function process (input, config) {
|
|
151
|
+
const { c, steps } = correctInputs(input, config)
|
|
152
|
+
|
|
153
|
+
// True wind: apparent wind minus the boat's velocity through the water, which points
|
|
154
|
+
// along the heading turned by leeway.
|
|
155
|
+
const a = rad(input.awa)
|
|
156
|
+
const l = rad(c.leeway)
|
|
157
|
+
const x = input.aws * Math.cos(a) - c.stw * Math.cos(l)
|
|
158
|
+
const y = input.aws * Math.sin(a) - c.stw * Math.sin(l)
|
|
159
|
+
c.twa = deg(Math.atan2(y, x))
|
|
160
|
+
c.tws = Math.hypot(x, y)
|
|
161
|
+
steps.push({
|
|
162
|
+
id: 'truewind',
|
|
163
|
+
name: 'True wind',
|
|
164
|
+
enabled: true,
|
|
165
|
+
input: `AWA ${Math.abs(input.awa).toFixed(1)}° · ${input.aws.toFixed(1)} kn`,
|
|
166
|
+
detail: 'apparent wind − boat velocity',
|
|
167
|
+
output: `TWA ${Math.abs(c.twa).toFixed(1)}° · ${c.tws.toFixed(1)} kn`
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
runStage('trueWind', c, config, steps)
|
|
171
|
+
|
|
172
|
+
const out = { stw: c.stw, leeway: c.leeway, twa: c.twa, tws: c.tws }
|
|
173
|
+
|
|
174
|
+
const heading = input.headingTrue != null
|
|
175
|
+
? input.headingTrue
|
|
176
|
+
: input.headingMagnetic != null ? input.headingMagnetic + config.variation : null
|
|
177
|
+
if (heading != null) {
|
|
178
|
+
out.heading = norm360(heading)
|
|
179
|
+
out.twd = norm360(heading + c.twa)
|
|
180
|
+
if (input.sog != null && input.cog != null) {
|
|
181
|
+
// Current = ground velocity - water velocity; set is where it flows to.
|
|
182
|
+
const cr = rad(input.cog)
|
|
183
|
+
const wr = rad(heading + c.leeway)
|
|
184
|
+
const e = input.sog * Math.sin(cr) - c.stw * Math.sin(wr)
|
|
185
|
+
const n = input.sog * Math.cos(cr) - c.stw * Math.cos(wr)
|
|
186
|
+
out.current = { drift: Math.hypot(e, n), set: norm360(deg(Math.atan2(e, n))) }
|
|
187
|
+
steps.push({
|
|
188
|
+
id: 'current',
|
|
189
|
+
name: 'Current',
|
|
190
|
+
enabled: true,
|
|
191
|
+
input: `SOG ${input.sog.toFixed(1)} · COG ${input.cog.toFixed(0)}°`,
|
|
192
|
+
detail: 'ground − water velocity',
|
|
193
|
+
output: `${out.current.drift.toFixed(2)} kn → ${out.current.set.toFixed(0)}°`
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { out, steps }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---- configuration --------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
const clone = o => JSON.parse(JSON.stringify(o))
|
|
203
|
+
|
|
204
|
+
function isPlainObject (o) {
|
|
205
|
+
return o !== null && typeof o === 'object' && !Array.isArray(o)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Defaults first, then whatever the stored config sets. Arrays replace, objects merge.
|
|
209
|
+
function merge (base, over) {
|
|
210
|
+
if (!isPlainObject(over)) return base
|
|
211
|
+
const out = clone(base)
|
|
212
|
+
for (const [k, v] of Object.entries(over)) {
|
|
213
|
+
if (isPlainObject(v) && isPlainObject(out[k])) out[k] = merge(out[k], v)
|
|
214
|
+
else if (v !== undefined) out[k] = v
|
|
215
|
+
}
|
|
216
|
+
return out
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function checkAxis (errors, label, xs) {
|
|
220
|
+
if (!Array.isArray(xs) || xs.length < 1) { errors.push(`${label}: needs at least one value`); return }
|
|
221
|
+
if (xs.some(v => typeof v !== 'number' || !Number.isFinite(v))) { errors.push(`${label}: every value must be a number`); return }
|
|
222
|
+
for (let i = 1; i < xs.length; i++) {
|
|
223
|
+
if (!(xs[i] > xs[i - 1])) { errors.push(`${label}: must increase from left to right`); return }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function checkRow (errors, label, ys, n) {
|
|
228
|
+
if (!Array.isArray(ys) || ys.length !== n) { errors.push(`${label}: needs ${n} values, one per column`); return }
|
|
229
|
+
if (ys.some(v => typeof v !== 'number' || !Number.isFinite(v))) errors.push(`${label}: every value must be a number`)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function checkNumber (errors, label, v) {
|
|
233
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) errors.push(`${label}: must be a number`)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Merge a stored or submitted config over the defaults and check it. Numbers typed
|
|
237
|
+
// into a form arrive as strings, so coerce numeric-looking strings first.
|
|
238
|
+
function normalizeConfig (input) {
|
|
239
|
+
const coerce = v => {
|
|
240
|
+
if (Array.isArray(v)) return v.map(coerce)
|
|
241
|
+
if (isPlainObject(v)) return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, coerce(x)]))
|
|
242
|
+
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v)
|
|
243
|
+
return v
|
|
244
|
+
}
|
|
245
|
+
const raw = isPlainObject(input) ? clone(input) : {}
|
|
246
|
+
// Source names are text even when they look numeric.
|
|
247
|
+
const keepText = { sources: raw.sources, mode: raw.mode }
|
|
248
|
+
const config = merge(DEFAULTS, { ...coerce(raw), ...Object.fromEntries(Object.entries(keepText).filter(([, v]) => v !== undefined)) })
|
|
249
|
+
const errors = []
|
|
250
|
+
const k = config.corrections
|
|
251
|
+
|
|
252
|
+
if (!['shadow', 'replace'].includes(config.mode)) errors.push('mode: must be shadow or replace')
|
|
253
|
+
checkNumber(errors, 'variation', config.variation)
|
|
254
|
+
|
|
255
|
+
checkAxis(errors, 'STW linearity speeds', k.stw.linearity.stw)
|
|
256
|
+
checkRow(errors, 'STW linearity values', k.stw.linearity.add, (k.stw.linearity.stw || []).length)
|
|
257
|
+
checkAxis(errors, 'STW heel angles', k.stw.heel.heel)
|
|
258
|
+
checkRow(errors, 'STW heel values', k.stw.heel.add, (k.stw.heel.heel || []).length)
|
|
259
|
+
if (Array.isArray(k.stw.linearity.stw) && !(k.stw.linearity.stw[0] > 0)) errors.push('STW linearity speeds: must start above 0 kn')
|
|
260
|
+
|
|
261
|
+
checkNumber(errors, 'Leeway K', k.leeway.k)
|
|
262
|
+
|
|
263
|
+
for (const id of ['twa', 'tws']) {
|
|
264
|
+
const t = k[id]
|
|
265
|
+
const name = id.toUpperCase() + ' table'
|
|
266
|
+
checkAxis(errors, `${name} TWS columns`, t.tws)
|
|
267
|
+
for (const row of ['upwind', 'reach', 'downwind']) checkRow(errors, `${name} ${row} row`, t[row], (t.tws || []).length)
|
|
268
|
+
}
|
|
269
|
+
const ang = k.twa.angles
|
|
270
|
+
;['upwind', 'reach', 'downwind'].forEach(r => checkNumber(errors, `TWA table ${r} angle`, ang[r]))
|
|
271
|
+
if (!(ang.upwind < ang.reach && ang.reach < ang.downwind && ang.upwind > 0 && ang.downwind < 180)) {
|
|
272
|
+
errors.push('TWA table angles: need 0 < upwind < reach < downwind < 180')
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
for (const [id, c] of Object.entries(k)) {
|
|
276
|
+
if (typeof c.enabled !== 'boolean') errors.push(`${id}: enabled must be true or false`)
|
|
277
|
+
}
|
|
278
|
+
return { config, errors }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
module.exports = {
|
|
282
|
+
process,
|
|
283
|
+
correctInputs,
|
|
284
|
+
normalizeConfig,
|
|
285
|
+
CORRECTIONS,
|
|
286
|
+
_internals: { interp, blendRows, tableLookup, merge, norm180 }
|
|
287
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-performance-processor",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "H5000-style calibration for Signal K: corrects boat speed and leeway, applies TWA and TWS correction tables, and publishes true wind and current. Proof of concept.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node --test",
|
|
8
|
+
"prepublishOnly": "npm test"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"signalk-node-server-plugin",
|
|
12
|
+
"signalk-webapp",
|
|
13
|
+
"signalk-category-utility",
|
|
14
|
+
"signalk",
|
|
15
|
+
"sailing",
|
|
16
|
+
"racing",
|
|
17
|
+
"true-wind",
|
|
18
|
+
"calibration",
|
|
19
|
+
"leeway",
|
|
20
|
+
"speed-through-water"
|
|
21
|
+
],
|
|
22
|
+
"signalk-plugin-enabled-by-default": false,
|
|
23
|
+
"signalk": {
|
|
24
|
+
"displayName": "Performance Processor",
|
|
25
|
+
"appIcon": "./icon.svg"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"index.js",
|
|
29
|
+
"lib/",
|
|
30
|
+
"public/",
|
|
31
|
+
"CHANGELOG.md"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18"
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/ghotihook/signalk-performance-processor.git"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/ghotihook/signalk-performance-processor#readme",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/ghotihook/signalk-performance-processor/issues"
|
|
43
|
+
},
|
|
44
|
+
"author": "ghotihook",
|
|
45
|
+
"license": "MIT"
|
|
46
|
+
}
|
package/public/app.js
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
'use strict'
|
|
3
|
+
|
|
4
|
+
const API = '/plugins/signalk-performance-processor'
|
|
5
|
+
const LIVE_MS = 1000
|
|
6
|
+
const STALE_MS = 5000
|
|
7
|
+
|
|
8
|
+
// Display names and help for each correction, in pipeline order. A correction the
|
|
9
|
+
// plugin knows but this list does not still shows, under its id.
|
|
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.' },
|
|
12
|
+
leeway: { name: 'Leeway', what: 'Sideways slip: leeway = K × heel ÷ STW². Turns the boat’s velocity to leeward for true wind and current.' },
|
|
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.' }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const SOURCE_LABELS = {
|
|
18
|
+
apparentWind: 'Apparent wind',
|
|
19
|
+
stw: 'Speed through water',
|
|
20
|
+
heading: 'Heading',
|
|
21
|
+
attitude: 'Heel (attitude)',
|
|
22
|
+
gps: 'SOG and COG'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const state = {
|
|
26
|
+
view: 'live',
|
|
27
|
+
saved: null, // config as stored by the plugin
|
|
28
|
+
draft: null, // config being edited
|
|
29
|
+
defaults: null,
|
|
30
|
+
selected: 'twa',
|
|
31
|
+
live: null,
|
|
32
|
+
liveAt: 0,
|
|
33
|
+
saving: false
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const $ = id => document.getElementById(id)
|
|
37
|
+
const clone = o => JSON.parse(JSON.stringify(o))
|
|
38
|
+
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
|
39
|
+
const fmt = (v, d = 1) => v == null || !Number.isFinite(v) ? '--' : v.toFixed(d)
|
|
40
|
+
const dirty = () => JSON.stringify(state.saved) !== JSON.stringify(state.draft)
|
|
41
|
+
|
|
42
|
+
// ---- data ----------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
async function getJson (path) {
|
|
45
|
+
const res = await fetch(API + path, { credentials: 'include', cache: 'no-store' })
|
|
46
|
+
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
|
47
|
+
return res.json()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function loadConfig () {
|
|
51
|
+
try {
|
|
52
|
+
const body = await getJson('/config')
|
|
53
|
+
state.saved = body.config
|
|
54
|
+
state.draft = clone(body.config)
|
|
55
|
+
state.defaults = body.defaults
|
|
56
|
+
$('conn').hidden = true
|
|
57
|
+
} catch (e) {
|
|
58
|
+
$('conn').textContent = `Cannot reach the plugin (${e.message}). Is Performance Processor enabled?`
|
|
59
|
+
$('conn').hidden = false
|
|
60
|
+
}
|
|
61
|
+
renderAll()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function pollLive () {
|
|
65
|
+
try {
|
|
66
|
+
state.live = await getJson('/live')
|
|
67
|
+
state.liveAt = Date.now()
|
|
68
|
+
$('conn').hidden = true
|
|
69
|
+
} catch (e) {
|
|
70
|
+
$('conn').textContent = `Lost contact with the plugin (${e.message})`
|
|
71
|
+
$('conn').hidden = false
|
|
72
|
+
}
|
|
73
|
+
renderHeader()
|
|
74
|
+
if (state.view === 'live') renderLive()
|
|
75
|
+
if (state.view === 'settings') renderSourcesIfIdle()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function save () {
|
|
79
|
+
state.saving = true
|
|
80
|
+
renderSaveBar()
|
|
81
|
+
try {
|
|
82
|
+
const res = await fetch(API + '/config', {
|
|
83
|
+
method: 'PUT',
|
|
84
|
+
credentials: 'include',
|
|
85
|
+
headers: { 'Content-Type': 'application/json' },
|
|
86
|
+
body: JSON.stringify(state.draft)
|
|
87
|
+
})
|
|
88
|
+
const body = await res.json().catch(() => ({}))
|
|
89
|
+
if (!res.ok) {
|
|
90
|
+
const why = body.errors ? body.errors.join('; ') : `${res.status} ${res.statusText}`
|
|
91
|
+
throw new Error(res.status === 401 || res.status === 403 ? 'not authorised — log in to Signal K with write access' : why)
|
|
92
|
+
}
|
|
93
|
+
state.saved = body.config
|
|
94
|
+
state.draft = clone(body.config)
|
|
95
|
+
$('saveErrors').textContent = ''
|
|
96
|
+
} catch (e) {
|
|
97
|
+
$('saveErrors').textContent = e.message
|
|
98
|
+
}
|
|
99
|
+
state.saving = false
|
|
100
|
+
renderAll()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---- editing ---------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
// data-path="corrections.twa.upwind.2" → state.draft.corrections.twa.upwind[2]
|
|
106
|
+
function setPath (path, value) {
|
|
107
|
+
const keys = path.split('.')
|
|
108
|
+
let o = state.draft
|
|
109
|
+
for (let i = 0; i < keys.length - 1; i++) o = o[keys[i]]
|
|
110
|
+
o[keys[keys.length - 1]] = value
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function onEdit (e) {
|
|
114
|
+
const el = e.target
|
|
115
|
+
if (!el.dataset || !el.dataset.path || !state.draft) return
|
|
116
|
+
let value
|
|
117
|
+
if (el.type === 'checkbox') value = el.checked
|
|
118
|
+
else if (el.type === 'number') {
|
|
119
|
+
if (el.value === '') return // keep the old value until something is typed
|
|
120
|
+
value = Number(el.value)
|
|
121
|
+
} else value = el.value
|
|
122
|
+
setPath(el.dataset.path, value)
|
|
123
|
+
renderSaveBar()
|
|
124
|
+
if (el.type === 'checkbox' || el.type === 'radio' || el.tagName === 'SELECT') renderAll()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- rendering -----------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
function renderAll () {
|
|
130
|
+
renderHeader()
|
|
131
|
+
renderSaveBar()
|
|
132
|
+
for (const v of ['live', 'calibration', 'settings']) $('view-' + v).hidden = v !== state.view
|
|
133
|
+
document.querySelectorAll('nav a').forEach(a => a.classList.toggle('current', a.dataset.view === state.view))
|
|
134
|
+
if (state.view === 'live') renderLive()
|
|
135
|
+
if (state.view === 'calibration') renderCalibration()
|
|
136
|
+
if (state.view === 'settings') renderSettings()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function renderHeader () {
|
|
140
|
+
const mode = state.saved ? state.saved.mode : null
|
|
141
|
+
const badge = $('mode')
|
|
142
|
+
badge.textContent = mode === 'replace' ? 'Replace' : mode === 'shadow' ? 'Shadow' : '--'
|
|
143
|
+
badge.classList.toggle('replace', mode === 'replace')
|
|
144
|
+
$('status').textContent = state.live ? (state.live.running ? state.live.status : 'Plugin not running') : ''
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function renderSaveBar () {
|
|
148
|
+
$('saveBar').hidden = !(state.draft && (dirty() || $('saveErrors').textContent))
|
|
149
|
+
$('save').disabled = state.saving || !dirty()
|
|
150
|
+
$('revert').disabled = state.saving
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// -- Live
|
|
154
|
+
|
|
155
|
+
// True wind straight from the inputs, with no corrections, for comparison.
|
|
156
|
+
function uncorrected (input) {
|
|
157
|
+
const a = input.awa * Math.PI / 180
|
|
158
|
+
const x = input.aws * Math.cos(a) - input.stw
|
|
159
|
+
const y = input.aws * Math.sin(a)
|
|
160
|
+
return { twa: Math.atan2(y, x) * 180 / Math.PI, tws: Math.hypot(x, y) }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function tile (label, value, unit, raw) {
|
|
164
|
+
return `<div class="tile"><div class="label">${esc(label)}</div>` +
|
|
165
|
+
`<div class="value">${esc(value)}<span class="unit">${esc(unit)}</span></div>` +
|
|
166
|
+
`<div class="raw">${esc(raw || '')}</div></div>`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function renderLive () {
|
|
170
|
+
const live = state.live
|
|
171
|
+
const r = live && live.result
|
|
172
|
+
const old = !r || live.ageMs > STALE_MS
|
|
173
|
+
$('liveAge').textContent = r ? (old ? `last update ${Math.round(live.ageMs / 1000)} s ago` : '') : ''
|
|
174
|
+
|
|
175
|
+
if (!r) {
|
|
176
|
+
$('tiles').innerHTML = ''
|
|
177
|
+
$('steps').innerHTML = `<tr><td colspan="4" class="muted center">${esc(live ? live.status || 'Waiting for data…' : 'Waiting for data…')}</td></tr>`
|
|
178
|
+
} else {
|
|
179
|
+
const o = r.out
|
|
180
|
+
const u = uncorrected(r.input)
|
|
181
|
+
const side = v => v < 0 ? 'port' : 'stbd'
|
|
182
|
+
$('tiles').innerHTML = [
|
|
183
|
+
tile('TWA', fmt(Math.abs(o.twa)), `° ${side(o.twa)}`, `uncorrected ${fmt(Math.abs(u.twa))}°`),
|
|
184
|
+
tile('TWS', fmt(o.tws), 'kn', `uncorrected ${fmt(u.tws)} kn`),
|
|
185
|
+
tile('TWD', o.twd == null ? '--' : fmt(o.twd, 0), '° T', o.twd == null ? 'no heading' : ''),
|
|
186
|
+
tile('STW', fmt(o.stw, 2), 'kn', `paddlewheel ${fmt(r.input.stw, 2)} kn`),
|
|
187
|
+
tile('Leeway', fmt(Math.abs(o.leeway)), `° ${side(o.leeway)}`, r.input.heel == null ? 'no heel' : `heel ${fmt(r.input.heel, 0)}°`),
|
|
188
|
+
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')
|
|
189
|
+
].join('')
|
|
190
|
+
$('steps').innerHTML = r.steps.map(s => s.enabled
|
|
191
|
+
? `<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>`
|
|
192
|
+
: `<tr class="off"><th scope="row">${esc(s.name)}</th><td></td><td>off</td><td></td></tr>`
|
|
193
|
+
).join('')
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const inputs = live ? Object.entries(live.inputs) : []
|
|
197
|
+
$('inputs').innerHTML = inputs.length
|
|
198
|
+
? inputs.sort().map(([path, i]) => `<tr><td>${esc(path)}</td><td>${esc(i.source)}</td><td class="num ${i.ageMs > STALE_MS ? 'warn' : ''}">${fmt(i.ageMs / 1000)} s</td></tr>`).join('')
|
|
199
|
+
: '<tr><td colspan="3" class="muted center">Nothing received yet</td></tr>'
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// -- Calibration
|
|
203
|
+
|
|
204
|
+
function correctionIds () {
|
|
205
|
+
const known = Object.keys(CORRECTIONS)
|
|
206
|
+
const all = Object.keys(state.draft.corrections)
|
|
207
|
+
return known.filter(id => all.includes(id)).concat(all.filter(id => !known.includes(id)))
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function renderCalibration () {
|
|
211
|
+
if (!state.draft) return
|
|
212
|
+
const ids = correctionIds()
|
|
213
|
+
if (!ids.includes(state.selected)) state.selected = ids[0]
|
|
214
|
+
$('corrList').innerHTML = ids.map(id => {
|
|
215
|
+
const c = state.draft.corrections[id]
|
|
216
|
+
const name = (CORRECTIONS[id] || {}).name || id
|
|
217
|
+
return `<li class="${id === state.selected ? 'selected' : ''}">` +
|
|
218
|
+
`<button type="button" data-select="${esc(id)}" aria-pressed="${id === state.selected}">${esc(name)}</button>` +
|
|
219
|
+
`<label><input type="checkbox" data-path="corrections.${esc(id)}.enabled" ${c.enabled ? 'checked' : ''} aria-label="Enable ${esc(name)}"></label></li>`
|
|
220
|
+
}).join('')
|
|
221
|
+
|
|
222
|
+
const id = state.selected
|
|
223
|
+
const c = state.draft.corrections[id]
|
|
224
|
+
const meta = CORRECTIONS[id] || { name: id, what: '' }
|
|
225
|
+
let body = ''
|
|
226
|
+
if (id === 'stw') {
|
|
227
|
+
body = grid('Linearity (kn added)', 'STW kn', `corrections.stw.linearity`, 'stw', [['Add', 'add']], c.linearity) +
|
|
228
|
+
grid('Heel correction (kn added)', 'Heel °', `corrections.stw.heel`, 'heel', [['Add', 'add']], c.heel)
|
|
229
|
+
} else if (id === 'leeway') {
|
|
230
|
+
body = `<div class="fields">${field('K', 'corrections.leeway.k', c.k, 0.01)}</div>`
|
|
231
|
+
} else if (id === 'twa' || id === 'tws') {
|
|
232
|
+
const rows = [['Upwind', 'upwind'], ['Reach', 'reach'], ['Downwind', 'downwind']]
|
|
233
|
+
body = grid(id === 'twa' ? 'Degrees added to |TWA|' : 'Percent applied to TWS', 'TWS kn', `corrections.${id}`, 'tws', rows, c)
|
|
234
|
+
if (id === 'twa') {
|
|
235
|
+
const a = c.angles
|
|
236
|
+
body += `<div class="fields">${field('Upwind row to (° TWA)', 'corrections.twa.angles.upwind', a.upwind, 1)}` +
|
|
237
|
+
`${field('Reach row at (° TWA)', 'corrections.twa.angles.reach', a.reach, 1)}` +
|
|
238
|
+
`${field('Downwind row from (° TWA)', 'corrections.twa.angles.downwind', a.downwind, 1)}</div>`
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
$('corrEditor').innerHTML =
|
|
242
|
+
`<div class="panel-head"><h2>${esc(meta.name)}</h2><span class="state ${c.enabled ? 'on' : ''}">${c.enabled ? 'Enabled' : 'Disabled'}</span></div>` +
|
|
243
|
+
`<p class="what muted">${esc(meta.what)}</p>${body}` +
|
|
244
|
+
`<div><button type="button" class="ghost" data-defaults="${esc(id)}">Reset to defaults</button></div>`
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// A table whose first row is its axis (editable) and whose other rows are values.
|
|
248
|
+
function grid (title, axisLabel, base, axisKey, rows, obj) {
|
|
249
|
+
const axis = obj[axisKey]
|
|
250
|
+
const cell = (path, v, cls) => `<td><input type="number" step="any" class="${cls || ''}" data-path="${esc(path)}" value="${esc(v)}"></td>`
|
|
251
|
+
return `<div><h2>${esc(title)}</h2><div class="table-wrap"><table class="grid">` +
|
|
252
|
+
`<tr><th scope="row" class="muted">${esc(axisLabel)}</th>${axis.map((v, i) => cell(`${base}.${axisKey}.${i}`, v, 'axis')).join('')}</tr>` +
|
|
253
|
+
rows.map(([label, key]) => `<tr><th scope="row">${esc(label)}</th>${obj[key].map((v, i) => cell(`${base}.${key}.${i}`, v)).join('')}</tr>`).join('') +
|
|
254
|
+
'</table></div></div>'
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function field (label, path, value, step) {
|
|
258
|
+
return `<label class="field">${esc(label)}<input type="number" step="${step}" data-path="${esc(path)}" value="${esc(value)}"></label>`
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// -- Settings
|
|
262
|
+
|
|
263
|
+
const MODES = [
|
|
264
|
+
['shadow', 'Shadow', 'Publish to separate …Corrected paths. Your instruments and displays carry on as before.'],
|
|
265
|
+
['replace', 'Replace', 'Publish on the standard paths. Rank this plugin first in Server → Source Priorities, and zero the instrument’s own corrections so nothing is corrected twice.']
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
function renderSettings () {
|
|
269
|
+
if (!state.draft) return
|
|
270
|
+
$('modes').innerHTML = '<legend class="sr-only">Publish mode</legend>' + MODES.map(([id, name, desc]) =>
|
|
271
|
+
`<label class="mode ${state.draft.mode === id ? 'checked' : ''}"><input type="radio" name="mode" value="${id}" data-path="mode" ${state.draft.mode === id ? 'checked' : ''}>` +
|
|
272
|
+
`<div><b>${esc(name)}</b><span>${esc(desc)}</span></div></label>`).join('')
|
|
273
|
+
const v = $('variation')
|
|
274
|
+
if (document.activeElement !== v) v.value = state.draft.variation
|
|
275
|
+
renderSources()
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function renderSources () {
|
|
279
|
+
const seen = (state.live && state.live.sourcesSeen) || {}
|
|
280
|
+
$('sources').innerHTML = Object.entries(SOURCE_LABELS).map(([key, label]) => {
|
|
281
|
+
const chosen = state.draft.sources[key] || ''
|
|
282
|
+
const opts = Array.from(new Set([...(seen[key] || []), chosen].filter(Boolean)))
|
|
283
|
+
return `<label>${esc(label)}<select data-path="sources.${key}">` +
|
|
284
|
+
`<option value="">Any</option>` +
|
|
285
|
+
opts.map(s => `<option value="${esc(s)}" ${s === chosen ? 'selected' : ''}>${esc(s)}${(seen[key] || []).includes(s) ? '' : ' (not seen)'}</option>`).join('') +
|
|
286
|
+
'</select></label>'
|
|
287
|
+
}).join('')
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Sources arrive with live data; refresh the list unless a dropdown is in use.
|
|
291
|
+
function renderSourcesIfIdle () {
|
|
292
|
+
if (!state.draft || (document.activeElement && document.activeElement.tagName === 'SELECT')) return
|
|
293
|
+
renderSources()
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---- wiring ----------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
function route () {
|
|
299
|
+
const v = location.hash.replace('#', '')
|
|
300
|
+
state.view = ['live', 'calibration', 'settings'].includes(v) ? v : 'live'
|
|
301
|
+
renderAll()
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
document.addEventListener('change', onEdit)
|
|
305
|
+
document.addEventListener('click', e => {
|
|
306
|
+
const sel = e.target.closest('[data-select]')
|
|
307
|
+
if (sel) { state.selected = sel.dataset.select; renderCalibration() }
|
|
308
|
+
const def = e.target.closest('[data-defaults]')
|
|
309
|
+
if (def && state.defaults) {
|
|
310
|
+
const id = def.dataset.defaults
|
|
311
|
+
state.draft.corrections[id] = clone(state.defaults.corrections[id])
|
|
312
|
+
renderAll()
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
$('save').addEventListener('click', save)
|
|
316
|
+
$('revert').addEventListener('click', () => {
|
|
317
|
+
state.draft = clone(state.saved)
|
|
318
|
+
$('saveErrors').textContent = ''
|
|
319
|
+
renderAll()
|
|
320
|
+
})
|
|
321
|
+
window.addEventListener('hashchange', route)
|
|
322
|
+
window.addEventListener('beforeunload', e => { if (state.draft && dirty()) { e.preventDefault(); e.returnValue = '' } })
|
|
323
|
+
|
|
324
|
+
route()
|
|
325
|
+
loadConfig()
|
|
326
|
+
pollLive()
|
|
327
|
+
setInterval(pollLive, LIVE_MS)
|
|
328
|
+
})()
|
package/public/icon.svg
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 64 64" role="img" aria-label="Performance processor">
|
|
2
|
+
<rect width="64" height="64" rx="10" fill="#0d1117"/>
|
|
3
|
+
<!-- hull, seen from above, heading up -->
|
|
4
|
+
<path d="M32 9 C 41 19 41 39 37.5 54 H 26.5 C 23 39 23 19 32 9 Z" fill="none" stroke="#e6edf3" stroke-width="3" stroke-linejoin="round"/>
|
|
5
|
+
<!-- measured wind, from ahead to starboard -->
|
|
6
|
+
<line x1="56" y1="12" x2="36" y2="30" stroke="#8b949e" stroke-width="3" stroke-linecap="round" stroke-dasharray="4 4"/>
|
|
7
|
+
<!-- corrected true wind -->
|
|
8
|
+
<line x1="58" y1="24" x2="38" y2="34" stroke="#4cd97b" stroke-width="3.5" stroke-linecap="round"/>
|
|
9
|
+
<path d="M42.5 29.5 l-4.5 4.5 6 1.5" fill="none" stroke="#4cd97b" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
10
|
+
<!-- the correction between them -->
|
|
11
|
+
<path d="M51 16.5 A 12 12 0 0 1 54 26" fill="none" stroke="#f0b429" stroke-width="3" stroke-linecap="round"/>
|
|
12
|
+
<!-- leeway: slip to leeward -->
|
|
13
|
+
<path d="M14 46 H 22" stroke="#58a6ff" stroke-width="3" stroke-linecap="round"/>
|
|
14
|
+
<path d="M17.5 42.5 l-3.5 3.5 3.5 3.5" fill="none" stroke="#58a6ff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
|
15
|
+
</svg>
|