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.
@@ -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.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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
+ })()
@@ -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>