signalk-compass-calibrator 0.2.0
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 +14 -0
- package/README.md +78 -0
- package/index.js +1111 -0
- package/lib/angles.js +70 -0
- package/lib/calibration.js +360 -0
- package/lib/plugin-schema.js +14 -0
- package/lib/profile-store.js +150 -0
- package/lib/prometheus-history-provider.js +310 -0
- package/package.json +50 -0
- package/public/app.js +1062 -0
- package/public/icon.svg +12 -0
- package/public/index.html +179 -0
- package/public/remoteEntry.js +54 -0
- package/public/screenshot.png +0 -0
- package/public/styles.css +527 -0
package/public/app.js
ADDED
|
@@ -0,0 +1,1062 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
let candidateProfile = null
|
|
4
|
+
let restoredFields = new Set()
|
|
5
|
+
let savedProfiles = []
|
|
6
|
+
let selectedProfile = null
|
|
7
|
+
let lastDiscovery = null
|
|
8
|
+
let runtimePollTimer = null
|
|
9
|
+
|
|
10
|
+
const STORAGE_KEY = 'signalk-compass-calibrator.settings.v2'
|
|
11
|
+
const SECRET_STORAGE_KEY = 'signalk-compass-calibrator.sessionSecrets.v1'
|
|
12
|
+
const MPS_TO_KNOTS = 1.9438444924406
|
|
13
|
+
const RUNTIME_POLL_MS = 2000
|
|
14
|
+
|
|
15
|
+
const paths = {
|
|
16
|
+
heading: 'navigation.headingMagnetic',
|
|
17
|
+
cog: 'navigation.courseOverGroundTrue',
|
|
18
|
+
sog: 'navigation.speedOverGround',
|
|
19
|
+
variation: 'navigation.magneticVariation'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const sourceInputs = {
|
|
23
|
+
[paths.heading]: {
|
|
24
|
+
inputId: 'headingSource',
|
|
25
|
+
label: 'heading'
|
|
26
|
+
},
|
|
27
|
+
[paths.cog]: {
|
|
28
|
+
inputId: 'cogSource',
|
|
29
|
+
label: 'COG'
|
|
30
|
+
},
|
|
31
|
+
[paths.sog]: {
|
|
32
|
+
inputId: 'sogSource',
|
|
33
|
+
label: 'SOG'
|
|
34
|
+
},
|
|
35
|
+
[paths.variation]: {
|
|
36
|
+
inputId: 'variationSource',
|
|
37
|
+
label: 'variation'
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const metricInputs = {
|
|
42
|
+
headingMagnetic: 'metricHeadingMagnetic',
|
|
43
|
+
courseOverGroundTrue: 'metricCourseOverGroundTrue',
|
|
44
|
+
speedOverGround: 'metricSpeedOverGround',
|
|
45
|
+
magneticVariation: 'metricMagneticVariation'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
setDefaultDates()
|
|
49
|
+
restorePersistedFields()
|
|
50
|
+
bindEvents()
|
|
51
|
+
loadRuntime().catch(showError)
|
|
52
|
+
loadSources().catch(showError)
|
|
53
|
+
loadProfiles().catch(showError)
|
|
54
|
+
|
|
55
|
+
function bindEvents () {
|
|
56
|
+
bindPersistence()
|
|
57
|
+
bindTabs()
|
|
58
|
+
document.getElementById('discover').addEventListener('click', () => runBusyAction('discover', 'discoverStatus', 'Discovering...', 'Discover failed', discoverSources))
|
|
59
|
+
document.getElementById('context').addEventListener('change', () => {
|
|
60
|
+
populateSourcePickers(pathsForContext(lastDiscovery, value('context')))
|
|
61
|
+
persistFields()
|
|
62
|
+
})
|
|
63
|
+
document.getElementById('calibrate').addEventListener('click', () => runBusyAction('calibrate', 'calibrateStatus', 'Calibrating...', 'Calibration failed', runCalibration))
|
|
64
|
+
document.getElementById('saveCandidate').addEventListener('click', () => runAction('Save failed', saveCandidate))
|
|
65
|
+
document.getElementById('cancelCandidate').addEventListener('click', cancelCandidate)
|
|
66
|
+
document.getElementById('profilesList').addEventListener('click', event => {
|
|
67
|
+
const action = event.target && event.target.dataset && event.target.dataset.action
|
|
68
|
+
const id = event.target && event.target.dataset && event.target.dataset.id
|
|
69
|
+
if (action === 'view' && id) runAction('Load failed', () => loadProfileDetails(id))
|
|
70
|
+
})
|
|
71
|
+
document.getElementById('deleteProfile').addEventListener('click', () => runAction('Delete failed', deleteSelectedProfile))
|
|
72
|
+
document.getElementById('activateRuntime').addEventListener('click', () => runBusyAction('activateRuntime', 'runtimeActionStatus', 'Activating...', 'Runtime activation failed', activateRuntime))
|
|
73
|
+
document.getElementById('deactivateRuntime').addEventListener('click', () => runBusyAction('deactivateRuntime', 'runtimeActionStatus', 'Deactivating...', 'Runtime deactivation failed', deactivateRuntime))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function bindTabs () {
|
|
77
|
+
document.querySelectorAll('.tabButton').forEach(button => {
|
|
78
|
+
button.addEventListener('click', () => showTab(button.dataset.tab))
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function showTab (id) {
|
|
83
|
+
document.querySelectorAll('.tabButton').forEach(button => {
|
|
84
|
+
button.classList.toggle('active', button.dataset.tab === id)
|
|
85
|
+
})
|
|
86
|
+
document.querySelectorAll('.tabPanel').forEach(panel => {
|
|
87
|
+
panel.classList.toggle('active', panel.id === id)
|
|
88
|
+
})
|
|
89
|
+
if (id === 'calibrationsTab') runAction('Load calibrations failed', loadProfiles)
|
|
90
|
+
if (id === 'runtimeTab') {
|
|
91
|
+
runAction('Load runtime failed', async () => {
|
|
92
|
+
await loadProfiles()
|
|
93
|
+
await loadRuntime()
|
|
94
|
+
await loadSources()
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
updateRuntimePolling(id === 'runtimeTab')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function updateRuntimePolling (enabled) {
|
|
101
|
+
if (runtimePollTimer) {
|
|
102
|
+
clearInterval(runtimePollTimer)
|
|
103
|
+
runtimePollTimer = null
|
|
104
|
+
}
|
|
105
|
+
if (!enabled) return
|
|
106
|
+
runtimePollTimer = setInterval(() => {
|
|
107
|
+
loadRuntime({ updatePickers: false }).catch(error => {
|
|
108
|
+
console.warn('Runtime refresh failed', error)
|
|
109
|
+
})
|
|
110
|
+
}, RUNTIME_POLL_MS)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function setDefaultDates () {
|
|
114
|
+
const now = new Date()
|
|
115
|
+
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
|
116
|
+
for (const id of ['to', 'discoverTo']) setLocalDate(id, now)
|
|
117
|
+
for (const id of ['from', 'discoverFrom']) setLocalDate(id, yesterday)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function setLocalDate (id, date) {
|
|
121
|
+
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
|
|
122
|
+
document.getElementById(id).value = local.toISOString().slice(0, 16)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function dateValue (id) {
|
|
126
|
+
const value = document.getElementById(id).value
|
|
127
|
+
return value ? new Date(value).toISOString() : null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function loadSources () {
|
|
131
|
+
const data = await api('/api/sources')
|
|
132
|
+
ensureContextOption(data.context || '')
|
|
133
|
+
setIfNotRestored('context', data.context || '')
|
|
134
|
+
if (data.prometheus) {
|
|
135
|
+
setIfNotRestored('baseUrl', data.prometheus.baseUrl || '')
|
|
136
|
+
setIfNotRestored('historyUsername', data.prometheus.auth && data.prometheus.auth.username || '')
|
|
137
|
+
}
|
|
138
|
+
if (data.metrics) setMetricInputs(data.metrics, true)
|
|
139
|
+
if (data.selected) {
|
|
140
|
+
setIfNotRestored('headingSource', data.selected.heading || '')
|
|
141
|
+
setIfNotRestored('cogSource', data.selected.cog || '')
|
|
142
|
+
setIfNotRestored('sogSource', data.selected.sog || '')
|
|
143
|
+
setIfNotRestored('variationSource', data.selected.variation || '')
|
|
144
|
+
}
|
|
145
|
+
populateRuntimeSources(data.live || [])
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function loadProfiles () {
|
|
149
|
+
const data = await api('/api/profiles')
|
|
150
|
+
savedProfiles = data.profiles || []
|
|
151
|
+
renderProfilesList(savedProfiles, data.activeProfileId)
|
|
152
|
+
populateRuntimeProfiles(savedProfiles, data.activeProfileId)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function discoverSources () {
|
|
156
|
+
const payload = {
|
|
157
|
+
baseUrl: value('baseUrl'),
|
|
158
|
+
auth: historyAuth(),
|
|
159
|
+
context: '',
|
|
160
|
+
metrics: metricValues(),
|
|
161
|
+
range: {
|
|
162
|
+
from: dateValue('discoverFrom'),
|
|
163
|
+
to: dateValue('discoverTo')
|
|
164
|
+
},
|
|
165
|
+
resolutionSeconds: 30
|
|
166
|
+
}
|
|
167
|
+
const data = await api('/api/discover', payload)
|
|
168
|
+
lastDiscovery = data
|
|
169
|
+
mirrorDiscoveryRangeToCalibration()
|
|
170
|
+
populateContextPicker(data.contextSummary)
|
|
171
|
+
const count = renderSources(data)
|
|
172
|
+
applyRecommendations(data.recommendations)
|
|
173
|
+
const commonContexts = data.contextSummary && data.contextSummary.contexts || []
|
|
174
|
+
if (commonContexts.length === 0 && count > 0) {
|
|
175
|
+
showWarning('Discovery found historical data, but no context appears on every required path.')
|
|
176
|
+
} else if (count === 0) {
|
|
177
|
+
showWarning('Discovery completed, but no historical samples matched these metrics, inferred context, sources and time range.')
|
|
178
|
+
} else {
|
|
179
|
+
showOk(`Historical source discovery completed for ${value('context')}: ${count} source entries found.`)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function renderSources (data) {
|
|
184
|
+
const summary = data.contextSummary || null
|
|
185
|
+
const selectedPaths = pathsForContext(data, value('context')) || data.paths || {}
|
|
186
|
+
populateSourcePickers(selectedPaths)
|
|
187
|
+
document.getElementById('sources').innerHTML = renderContextSummary(summary)
|
|
188
|
+
return countSourceRows(selectedPaths)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function populateContextPicker (summary) {
|
|
192
|
+
const select = document.getElementById('context')
|
|
193
|
+
if (!select || !summary) return
|
|
194
|
+
const contexts = summary.contexts && summary.contexts.length
|
|
195
|
+
? summary.contexts
|
|
196
|
+
: summary.allContexts || []
|
|
197
|
+
const previous = select.value
|
|
198
|
+
select.innerHTML = [
|
|
199
|
+
'<option value="">Select context</option>',
|
|
200
|
+
...contexts.map(context => `<option value="${escapeHtml(context)}">${escapeHtml(context)}</option>`)
|
|
201
|
+
].join('')
|
|
202
|
+
const selected = summary.selectedContext || contexts[0] || ''
|
|
203
|
+
select.value = contexts.includes(previous) ? previous : selected
|
|
204
|
+
persistFields()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function renderContextSummary (summary) {
|
|
208
|
+
if (!summary) return '<p class="muted">No discovery data yet.</p>'
|
|
209
|
+
const contexts = summary.contexts || []
|
|
210
|
+
const allContexts = summary.allContexts || []
|
|
211
|
+
const icon = summary.status === 'ok' ? '✔' : summary.status === 'warning' ? '⚠️' : '❌'
|
|
212
|
+
const label = contexts.length === 1
|
|
213
|
+
? `Context : ${contexts[0]}`
|
|
214
|
+
: contexts.length > 1
|
|
215
|
+
? `Contexts : ${contexts.join(', ')}`
|
|
216
|
+
: 'Context : none found on every required path'
|
|
217
|
+
const detailRows = []
|
|
218
|
+
for (const context of summary.details || []) {
|
|
219
|
+
for (const pathEntry of context.paths || []) {
|
|
220
|
+
const sources = pathEntry.sources && pathEntry.sources.length ? pathEntry.sources : [{ source: '', sampleCount: 0, coveragePercent: 0, firstSample: '', lastSample: '' }]
|
|
221
|
+
for (const source of sources) {
|
|
222
|
+
detailRows.push([
|
|
223
|
+
escapeHtml(context.context),
|
|
224
|
+
escapeHtml(pathEntry.path),
|
|
225
|
+
escapeHtml(source.source || 'none'),
|
|
226
|
+
source.sampleCount || 0,
|
|
227
|
+
formatValue(source.coveragePercent || 0),
|
|
228
|
+
escapeHtml(source.firstSample || ''),
|
|
229
|
+
escapeHtml(source.lastSample || '')
|
|
230
|
+
])
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const errors = summary.errors && summary.errors.length
|
|
235
|
+
? `<p class="error">${summary.errors.map(error => `${escapeHtml(error.path)}: ${escapeHtml(error.error)}`).join('<br>')}</p>`
|
|
236
|
+
: ''
|
|
237
|
+
const details = allContexts.length
|
|
238
|
+
? `<details class="advanced compactDetails">
|
|
239
|
+
<summary>Context source details</summary>
|
|
240
|
+
${table(['Context', 'Path', 'Source', 'Samples', 'Coverage %', 'First', 'Last'], detailRows)}
|
|
241
|
+
</details>`
|
|
242
|
+
: ''
|
|
243
|
+
return `
|
|
244
|
+
<div class="contextSummary ${summary.status}">
|
|
245
|
+
<span class="contextIcon">${icon}</span>
|
|
246
|
+
<strong>${escapeHtml(label)}</strong>
|
|
247
|
+
</div>
|
|
248
|
+
${errors}
|
|
249
|
+
${details}
|
|
250
|
+
`
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function pathsForContext (discovery, context) {
|
|
254
|
+
if (!discovery || !discovery.contextSummary || !context) return discovery && discovery.paths || {}
|
|
255
|
+
const details = discovery.contextSummary.details || []
|
|
256
|
+
const match = details.find(item => item.context === context)
|
|
257
|
+
if (!match) return discovery.paths || {}
|
|
258
|
+
return Object.fromEntries((match.paths || []).map(pathEntry => [pathEntry.path, pathEntry.sources || []]))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function countSourceRows (data) {
|
|
262
|
+
return Object.values(data || {}).reduce((count, sources) => count + (Array.isArray(sources) ? sources.length : 0), 0)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function mirrorDiscoveryRangeToCalibration () {
|
|
266
|
+
document.getElementById('from').value = document.getElementById('discoverFrom').value
|
|
267
|
+
document.getElementById('to').value = document.getElementById('discoverTo').value
|
|
268
|
+
persistFields()
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function ensureContextOption (context) {
|
|
272
|
+
if (!context) return
|
|
273
|
+
const select = document.getElementById('context')
|
|
274
|
+
ensureSelectOption(select, context)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function ensureSelectOption (select, optionValue) {
|
|
278
|
+
if (!select || select.tagName !== 'SELECT' || !optionValue) return
|
|
279
|
+
if (Array.from(select.options).some(option => option.value === optionValue)) return
|
|
280
|
+
select.insertAdjacentHTML('beforeend', `<option value="${escapeHtml(optionValue)}">${escapeHtml(optionValue)}</option>`)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function populateSourcePickers (data) {
|
|
284
|
+
for (const [path, target] of Object.entries(sourceInputs)) {
|
|
285
|
+
const sources = Array.isArray(data[path]) ? data[path] : []
|
|
286
|
+
const unique = bestSources(sources)
|
|
287
|
+
const select = document.getElementById(target.inputId)
|
|
288
|
+
const previous = select.value
|
|
289
|
+
select.innerHTML = [
|
|
290
|
+
'<option value="">Select source</option>',
|
|
291
|
+
...unique.map(source => `<option value="${escapeHtml(source.source)}">${escapeHtml(source.source)} (${source.sampleCount} samples, ${source.coveragePercent}% coverage)</option>`)
|
|
292
|
+
].join('')
|
|
293
|
+
if (previous && unique.some(source => source.source === previous)) {
|
|
294
|
+
select.value = previous
|
|
295
|
+
} else if (unique.length > 0) {
|
|
296
|
+
select.value = unique[0].source
|
|
297
|
+
persistFields()
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function applyRecommendations (recommendations) {
|
|
303
|
+
if (!recommendations || recommendations.error) return
|
|
304
|
+
|
|
305
|
+
if (recommendations.sources) {
|
|
306
|
+
for (const [key, source] of Object.entries(recommendations.sources)) {
|
|
307
|
+
const path = {
|
|
308
|
+
heading: paths.heading,
|
|
309
|
+
cog: paths.cog,
|
|
310
|
+
sog: paths.sog,
|
|
311
|
+
variation: paths.variation
|
|
312
|
+
}[key]
|
|
313
|
+
const target = sourceInputs[path]
|
|
314
|
+
if (target && source) {
|
|
315
|
+
document.getElementById(target.inputId).value = source
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (recommendations.filters) {
|
|
321
|
+
setNumberIfFinite('minSog', mpsToKnots(recommendations.filters.minSog))
|
|
322
|
+
setNumberIfFinite('maxCogRate', recommendations.filters.maxCogRate)
|
|
323
|
+
setNumberIfFinite('minSamplesPerBin', recommendations.filters.minSamplesPerBin)
|
|
324
|
+
}
|
|
325
|
+
if (recommendations.calibration) {
|
|
326
|
+
setNumberIfFinite('binSize', recommendations.calibration.binSize)
|
|
327
|
+
}
|
|
328
|
+
persistFields()
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function setNumberIfFinite (id, nextValue) {
|
|
332
|
+
if (!Number.isFinite(Number(nextValue))) return
|
|
333
|
+
const element = document.getElementById(id)
|
|
334
|
+
if (element) element.value = String(nextValue)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function bestSources (sources) {
|
|
338
|
+
const bySource = new Map()
|
|
339
|
+
for (const source of sources) {
|
|
340
|
+
if (!source.source) continue
|
|
341
|
+
const previous = bySource.get(source.source)
|
|
342
|
+
if (!previous || sourceScore(source) > sourceScore(previous)) {
|
|
343
|
+
bySource.set(source.source, source)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return Array.from(bySource.values()).sort((a, b) => sourceScore(b) - sourceScore(a))
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function sourceScore (source) {
|
|
350
|
+
return Number(source.coveragePercent || 0) * 1000000 + Number(source.sampleCount || 0)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function runCalibration () {
|
|
354
|
+
const payload = {
|
|
355
|
+
baseUrl: value('baseUrl'),
|
|
356
|
+
auth: historyAuth(),
|
|
357
|
+
context: value('context'),
|
|
358
|
+
metrics: metricValues(),
|
|
359
|
+
range: {
|
|
360
|
+
from: dateValue('from'),
|
|
361
|
+
to: dateValue('to')
|
|
362
|
+
},
|
|
363
|
+
sources: {
|
|
364
|
+
heading: value('headingSource'),
|
|
365
|
+
cog: value('cogSource'),
|
|
366
|
+
sog: value('sogSource'),
|
|
367
|
+
variation: value('variationSource')
|
|
368
|
+
},
|
|
369
|
+
filters: {
|
|
370
|
+
minSog: knotsToMps(numberValue('minSog')),
|
|
371
|
+
maxCogRate: numberValue('maxCogRate'),
|
|
372
|
+
minSamplesPerBin: numberValue('minSamplesPerBin')
|
|
373
|
+
},
|
|
374
|
+
calibration: {
|
|
375
|
+
binSize: numberValue('binSize')
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
candidateProfile = await api('/api/calibrate', payload)
|
|
379
|
+
renderProfile(candidateProfile)
|
|
380
|
+
document.getElementById('saveCandidate').disabled = false
|
|
381
|
+
document.getElementById('cancelCandidate').disabled = false
|
|
382
|
+
showOk('Calibration completed. Save the table or cancel it.')
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function saveCandidate () {
|
|
386
|
+
if (!candidateProfile) return
|
|
387
|
+
const saved = await api('/api/profiles', { profile: candidateProfile })
|
|
388
|
+
candidateProfile = null
|
|
389
|
+
resetCandidateReview()
|
|
390
|
+
await loadProfiles()
|
|
391
|
+
showTab('calibrationsTab')
|
|
392
|
+
await loadProfileDetails(saved.id)
|
|
393
|
+
showOk('Calibration table saved.')
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function cancelCandidate () {
|
|
397
|
+
candidateProfile = null
|
|
398
|
+
resetCandidateReview()
|
|
399
|
+
showTab('calibrationTab')
|
|
400
|
+
showOk('Candidate calibration discarded.')
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function resetCandidateReview () {
|
|
404
|
+
document.getElementById('candidate').innerHTML = 'No candidate profile yet.'
|
|
405
|
+
document.getElementById('candidate').className = 'summary muted'
|
|
406
|
+
document.getElementById('table').innerHTML = ''
|
|
407
|
+
clearCanvas('plot')
|
|
408
|
+
document.getElementById('saveCandidate').disabled = true
|
|
409
|
+
document.getElementById('cancelCandidate').disabled = true
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function renderProfile (profile, target = 'candidate') {
|
|
413
|
+
const ids = profileTargetIds(target)
|
|
414
|
+
document.getElementById(ids.summary).innerHTML = `
|
|
415
|
+
<div class="summaryGrid">
|
|
416
|
+
${metric('State', profile.state)}
|
|
417
|
+
${metric('Samples', profile.quality.sampleCount)}
|
|
418
|
+
${metric('Coverage', `${formatValue(profile.quality.coverageDeg)} deg`)}
|
|
419
|
+
${metric('Usable bins', profile.quality.usableBinCount)}
|
|
420
|
+
${metric('Mean error', `${formatValue(profile.quality.meanErrorDeg)} deg`)}
|
|
421
|
+
${metric('Stddev', `${formatValue(profile.quality.stddevDeg)} deg`)}
|
|
422
|
+
</div>
|
|
423
|
+
${profile.warnings && profile.warnings.length ? `<p class="warning">${profile.warnings.map(escapeHtml).join('<br>')}</p>` : ''}
|
|
424
|
+
${renderCalibrationTimeline(profile, target)}
|
|
425
|
+
`
|
|
426
|
+
document.getElementById(ids.summary).className = 'summary'
|
|
427
|
+
drawCoverageRose(`${target}GlobalCoverage`, binsFromCorrectionTable(profile.correctionTable || []), numberValue('minSamplesPerBin'))
|
|
428
|
+
document.getElementById(ids.table).innerHTML = table(
|
|
429
|
+
['Heading', 'Correction', 'Samples', 'Mean error', 'Stddev', 'Quality', 'Interpolated'],
|
|
430
|
+
profile.correctionTable.map(bin => [
|
|
431
|
+
`${bin.headingDeg} deg`,
|
|
432
|
+
`${formatValue(bin.correctionDeg)} deg`,
|
|
433
|
+
bin.samples,
|
|
434
|
+
`${formatValue(bin.meanErrorDeg)} deg`,
|
|
435
|
+
`${formatValue(bin.stddevDeg)} deg`,
|
|
436
|
+
`<span class="quality-${bin.quality}">${bin.quality}</span>`,
|
|
437
|
+
bin.interpolated ? 'yes' : ''
|
|
438
|
+
])
|
|
439
|
+
)
|
|
440
|
+
drawPlot(profile, ids.plot)
|
|
441
|
+
drawNavigationCoverageRoses(profile.segments || [], target)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function profileTargetIds (target) {
|
|
445
|
+
if (target === 'saved') return { summary: 'savedProfile', plot: 'savedPlot', table: 'savedTable' }
|
|
446
|
+
return { summary: 'candidate', plot: 'plot', table: 'table' }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function renderCalibrationTimeline (profile, target = 'candidate') {
|
|
450
|
+
const segments = profile.segments || []
|
|
451
|
+
if (!segments.length || !profile.range) return ''
|
|
452
|
+
const range = normalizeRange(profile.range)
|
|
453
|
+
if (!range) return ''
|
|
454
|
+
return `
|
|
455
|
+
<h3>Calibration timeline</h3>
|
|
456
|
+
<div class="coveragePanel">
|
|
457
|
+
<div>
|
|
458
|
+
<h4>Heading coverage</h4>
|
|
459
|
+
<canvas id="${target}GlobalCoverage" class="coverageRose" width="260" height="260"></canvas>
|
|
460
|
+
</div>
|
|
461
|
+
<div class="coverageNotes">
|
|
462
|
+
<p>Radial fill shows samples per heading bin against the minimum samples per bin.</p>
|
|
463
|
+
<p>Rings mark 25%, 50%, 75%, and 100% of the target.</p>
|
|
464
|
+
</div>
|
|
465
|
+
</div>
|
|
466
|
+
<div class="timelineLegend">
|
|
467
|
+
<span><i class="legendNavigation"></i>navigation</span>
|
|
468
|
+
<span><i class="legendStable"></i>COG stable used</span>
|
|
469
|
+
<span><i class="legendRejected"></i>rejected</span>
|
|
470
|
+
</div>
|
|
471
|
+
<div class="timelineScale">
|
|
472
|
+
<span>${escapeHtml(formatDateTime(range.from))}</span>
|
|
473
|
+
<span>${escapeHtml(formatDateTime((range.from + range.to) / 2))}</span>
|
|
474
|
+
<span>${escapeHtml(formatDateTime(range.to))}</span>
|
|
475
|
+
</div>
|
|
476
|
+
<div class="timelineTrack globalTimeline">
|
|
477
|
+
${segments.map(segment => timelineBlock(segment, range, 'navigation')).join('')}
|
|
478
|
+
${segments.flatMap(segment => segment.stableSegments || []).map(segment => timelineBlock(segment, range, 'stable')).join('')}
|
|
479
|
+
</div>
|
|
480
|
+
<div class="periodZooms">
|
|
481
|
+
${segments.map((segment, index) => renderNavigationZoom(segment, index, target)).join('')}
|
|
482
|
+
</div>
|
|
483
|
+
`
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function renderNavigationZoom (segment, index, target = 'candidate') {
|
|
487
|
+
const range = normalizeRange(segment)
|
|
488
|
+
if (!range) return ''
|
|
489
|
+
const stableSegments = segment.stableSegments || []
|
|
490
|
+
const durationMinutes = Math.round((range.to - range.from) / 6000) / 10
|
|
491
|
+
const coverageDeg = headingCoverageDeg(segment.headingBins || [])
|
|
492
|
+
const acceptedSamples = segment.stats && segment.stats.acceptedSamples || 0
|
|
493
|
+
return `
|
|
494
|
+
<details class="periodZoom">
|
|
495
|
+
<summary>
|
|
496
|
+
<span>Navigation ${index + 1}</span>
|
|
497
|
+
<small>${escapeHtml(formatDateTime(range.from))} - ${escapeHtml(formatDateTime(range.to))} · ${durationMinutes} min · ${acceptedSamples} samples · ${coverageDeg} deg</small>
|
|
498
|
+
</summary>
|
|
499
|
+
<div class="timelineScale">
|
|
500
|
+
<span>${escapeHtml(formatDateTime(range.from))}</span>
|
|
501
|
+
<span>${escapeHtml(formatDateTime((range.from + range.to) / 2))}</span>
|
|
502
|
+
<span>${escapeHtml(formatDateTime(range.to))}</span>
|
|
503
|
+
</div>
|
|
504
|
+
<div class="timelineTrack zoomTimeline">
|
|
505
|
+
${timelineBlock(segment, range, segment.quality === 'rejected' ? 'rejected' : 'navigation')}
|
|
506
|
+
${stableSegments.map(stable => timelineBlock(stable, range, 'stable')).join('')}
|
|
507
|
+
</div>
|
|
508
|
+
<div class="zoomSummary">
|
|
509
|
+
${metric('Quality', segment.quality || '')}
|
|
510
|
+
${metric('Reason', segment.reason || 'none')}
|
|
511
|
+
${metric('Stable parts', stableSegments.length)}
|
|
512
|
+
${metric('Used samples', acceptedSamples)}
|
|
513
|
+
${metric('Median speed', `${formatValue(mpsToKnots(segment.stats && segment.stats.sogMedian))} kn`)}
|
|
514
|
+
${metric('COG p90', `${formatValue(segment.stats && segment.stats.cogRateP90)} deg/s`)}
|
|
515
|
+
${metric('Heading coverage', `${coverageDeg} deg`)}
|
|
516
|
+
</div>
|
|
517
|
+
<canvas id="${target}-coverage-${index}" class="coverageRose small" width="180" height="180"></canvas>
|
|
518
|
+
</details>
|
|
519
|
+
`
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function timelineBlock (segment, range, type) {
|
|
523
|
+
const segmentRange = normalizeRange(segment)
|
|
524
|
+
if (!segmentRange) return ''
|
|
525
|
+
const left = percent((segmentRange.from - range.from) / (range.to - range.from))
|
|
526
|
+
const width = Math.max(0.3, percent((segmentRange.to - segmentRange.from) / (range.to - range.from)))
|
|
527
|
+
return `<span class="timelineBlock ${type}" style="left:${left}%;width:${width}%" title="${escapeHtml(formatDateTime(segmentRange.from))} - ${escapeHtml(formatDateTime(segmentRange.to))}"></span>`
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function loadRuntime (options = {}) {
|
|
531
|
+
const updatePickers = options.updatePickers !== false
|
|
532
|
+
const data = await api('/api/runtime')
|
|
533
|
+
if (updatePickers) {
|
|
534
|
+
populateRuntimeProfiles(data.profiles || savedProfiles, data.activeProfileId)
|
|
535
|
+
populateRuntimeSources(data.liveSources || [], data.activeInputSource)
|
|
536
|
+
if (data.activeProfileId) document.getElementById('runtimeProfile').value = data.activeProfileId
|
|
537
|
+
if (data.activeInputSource) document.getElementById('runtimeSource').value = data.activeInputSource
|
|
538
|
+
}
|
|
539
|
+
const activeProfile = data.activeProfile && (data.activeProfile.displayName || data.activeProfile.savedAt || data.activeProfile.id) || data.activeProfileId || 'none'
|
|
540
|
+
document.getElementById('runtime').innerHTML = `
|
|
541
|
+
<div class="runtimeGrid">
|
|
542
|
+
${runtimeMetric('Status', data.status)}
|
|
543
|
+
${runtimeMetric('Active profile', activeProfile)}
|
|
544
|
+
${runtimeMetric('Input source', data.activeInputSource || data.inputSource || 'none')}
|
|
545
|
+
${runtimeMetric('Raw heading', `${formatValue(data.lastRawHeadingDeg)} deg`)}
|
|
546
|
+
${runtimeMetric('Correction', `${formatValue(data.lastCorrectionDeg)} deg`)}
|
|
547
|
+
${runtimeMetric('Calibrated', `${formatValue(data.lastCalibratedHeadingDeg)} deg`)}
|
|
548
|
+
${runtimeMetric('Last input', formatRuntimeDate(data.lastInputAt))}
|
|
549
|
+
${runtimeMetric('Last publish', formatRuntimeDate(data.lastPublishedAt))}
|
|
550
|
+
</div>
|
|
551
|
+
`
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async function activateRuntime () {
|
|
555
|
+
const profileId = value('runtimeProfile')
|
|
556
|
+
const inputSource = value('runtimeSource')
|
|
557
|
+
await api('/api/runtime/config', { profileId, inputSource })
|
|
558
|
+
await loadRuntime()
|
|
559
|
+
showOk('Runtime calibration activated.')
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function deactivateRuntime () {
|
|
563
|
+
await api('/api/runtime/disable', {})
|
|
564
|
+
document.getElementById('runtimeProfile').value = ''
|
|
565
|
+
document.getElementById('runtimeSource').value = ''
|
|
566
|
+
await loadRuntime()
|
|
567
|
+
showOk('Runtime calibration deactivated.')
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function renderProfilesList (profiles, activeProfileId) {
|
|
571
|
+
document.getElementById('profilesList').innerHTML = table(
|
|
572
|
+
['Saved at', 'State', 'Samples', 'Coverage', 'Stddev', 'Action'],
|
|
573
|
+
profiles.map(profile => [
|
|
574
|
+
escapeHtml(profile.displayName || profile.savedAt || profile.createdAt || profile.id),
|
|
575
|
+
profile.id === activeProfileId ? 'runtime active' : escapeHtml(profile.state || 'saved'),
|
|
576
|
+
profile.quality ? profile.quality.sampleCount : '',
|
|
577
|
+
profile.quality ? `${formatValue(profile.quality.coverageDeg)} deg` : '',
|
|
578
|
+
profile.quality ? `${formatValue(profile.quality.stddevDeg)} deg` : '',
|
|
579
|
+
`<button type="button" data-action="view" data-id="${escapeHtml(profile.id)}">View</button>`
|
|
580
|
+
])
|
|
581
|
+
)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function loadProfileDetails (id) {
|
|
585
|
+
selectedProfile = await api(`/api/profiles/${encodeURIComponent(id)}`)
|
|
586
|
+
renderProfile(selectedProfile, 'saved')
|
|
587
|
+
document.getElementById('deleteProfile').disabled = false
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function deleteSelectedProfile () {
|
|
591
|
+
if (!selectedProfile) return
|
|
592
|
+
await api(`/api/profiles/${encodeURIComponent(selectedProfile.id)}`, undefined, 'DELETE')
|
|
593
|
+
selectedProfile = null
|
|
594
|
+
document.getElementById('savedProfile').innerHTML = 'Select a calibration table.'
|
|
595
|
+
document.getElementById('savedProfile').className = 'summary muted'
|
|
596
|
+
document.getElementById('savedTable').innerHTML = ''
|
|
597
|
+
clearCanvas('savedPlot')
|
|
598
|
+
document.getElementById('deleteProfile').disabled = true
|
|
599
|
+
await loadProfiles()
|
|
600
|
+
await loadRuntime()
|
|
601
|
+
showOk('Calibration table deleted.')
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function populateRuntimeProfiles (profiles, activeProfileId) {
|
|
605
|
+
const select = document.getElementById('runtimeProfile')
|
|
606
|
+
if (!select) return
|
|
607
|
+
const previous = select.value || activeProfileId || ''
|
|
608
|
+
select.innerHTML = [
|
|
609
|
+
'<option value="">Select table</option>',
|
|
610
|
+
...profiles.map(profile => `<option value="${escapeHtml(profile.id)}">${escapeHtml(profile.displayName || profile.savedAt || profile.createdAt || profile.id)}</option>`)
|
|
611
|
+
].join('')
|
|
612
|
+
if (previous) select.value = previous
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function populateRuntimeSources (sources, selectedSource = '') {
|
|
616
|
+
const select = document.getElementById('runtimeSource')
|
|
617
|
+
if (!select) return
|
|
618
|
+
const headingSources = Array.from(new Set([
|
|
619
|
+
...(sources || [])
|
|
620
|
+
.filter(source => source.paths && source.paths[paths.heading])
|
|
621
|
+
.map(source => source.source)
|
|
622
|
+
.filter(Boolean),
|
|
623
|
+
selectedSource
|
|
624
|
+
].filter(Boolean))).sort()
|
|
625
|
+
const previous = select.value || selectedSource
|
|
626
|
+
select.innerHTML = [
|
|
627
|
+
'<option value="">Select Signal K source</option>',
|
|
628
|
+
...headingSources.map(source => `<option value="${escapeHtml(source)}">${escapeHtml(source)}</option>`)
|
|
629
|
+
].join('')
|
|
630
|
+
if (previous && headingSources.includes(previous)) select.value = previous
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async function api (url, body, method) {
|
|
634
|
+
const endpoint = url.replace(/^\/+/, '')
|
|
635
|
+
const requestUrl = new URL(endpoint, `${window.location.origin}/plugins/compass-calibrator/`)
|
|
636
|
+
const options = body === undefined
|
|
637
|
+
? (method ? { method } : {})
|
|
638
|
+
: {
|
|
639
|
+
method: method || 'POST',
|
|
640
|
+
headers: { 'content-type': 'application/json' },
|
|
641
|
+
body: JSON.stringify(body)
|
|
642
|
+
}
|
|
643
|
+
const response = await fetch(requestUrl, options)
|
|
644
|
+
const payload = await parseResponse(response)
|
|
645
|
+
if (!response.ok) throw new Error(payload.error || `${response.status} ${response.statusText}`)
|
|
646
|
+
return payload
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async function parseResponse (response) {
|
|
650
|
+
const text = await response.text()
|
|
651
|
+
if (!text) return {}
|
|
652
|
+
try {
|
|
653
|
+
return JSON.parse(text)
|
|
654
|
+
} catch (error) {
|
|
655
|
+
return {
|
|
656
|
+
error: `${response.status} ${response.statusText}: ${text.slice(0, 240)}`
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async function runAction (fallback, action) {
|
|
662
|
+
clearMessage()
|
|
663
|
+
try {
|
|
664
|
+
await action()
|
|
665
|
+
} catch (error) {
|
|
666
|
+
showError(error, fallback)
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function runBusyAction (buttonId, statusId, busyText, fallback, action) {
|
|
671
|
+
const button = document.getElementById(buttonId)
|
|
672
|
+
const status = document.getElementById(statusId)
|
|
673
|
+
const previousText = button ? button.textContent : ''
|
|
674
|
+
if (button) {
|
|
675
|
+
button.disabled = true
|
|
676
|
+
button.classList.add('busy')
|
|
677
|
+
button.textContent = busyText
|
|
678
|
+
}
|
|
679
|
+
if (status) status.hidden = false
|
|
680
|
+
try {
|
|
681
|
+
await runAction(fallback, action)
|
|
682
|
+
} finally {
|
|
683
|
+
if (button) {
|
|
684
|
+
button.disabled = false
|
|
685
|
+
button.classList.remove('busy')
|
|
686
|
+
button.textContent = previousText
|
|
687
|
+
}
|
|
688
|
+
if (status) status.hidden = true
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function historyAuth () {
|
|
693
|
+
const username = value('historyUsername')
|
|
694
|
+
const password = value('historyPassword')
|
|
695
|
+
if (!username && !password) return null
|
|
696
|
+
return {
|
|
697
|
+
type: 'basic',
|
|
698
|
+
username,
|
|
699
|
+
password
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function metricValues () {
|
|
704
|
+
return Object.fromEntries(
|
|
705
|
+
Object.entries(metricInputs).map(([key, id]) => [key, value(id)])
|
|
706
|
+
)
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function setMetricInputs (metrics, keepRestored = false) {
|
|
710
|
+
for (const [key, id] of Object.entries(metricInputs)) {
|
|
711
|
+
if (metrics[key]) {
|
|
712
|
+
if (keepRestored) setIfNotRestored(id, metrics[key])
|
|
713
|
+
else document.getElementById(id).value = metrics[key]
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function showOk (message) {
|
|
719
|
+
showMessage(message, 'ok')
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function showWarning (message) {
|
|
723
|
+
showMessage(message, 'warning')
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function showError (error, fallback = 'Request failed') {
|
|
727
|
+
showMessage(`${fallback}: ${error.message || error}`, 'error')
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function showMessage (message, type) {
|
|
731
|
+
const element = document.getElementById('message')
|
|
732
|
+
element.hidden = false
|
|
733
|
+
element.className = `message ${type}`
|
|
734
|
+
element.textContent = message
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function clearMessage () {
|
|
738
|
+
const element = document.getElementById('message')
|
|
739
|
+
element.hidden = true
|
|
740
|
+
element.className = 'message'
|
|
741
|
+
element.textContent = ''
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function clearCanvas (id) {
|
|
745
|
+
const canvas = document.getElementById(id)
|
|
746
|
+
if (!canvas) return
|
|
747
|
+
const ctx = canvas.getContext('2d')
|
|
748
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function drawPlot (profile, canvasId = 'plot') {
|
|
752
|
+
const canvas = document.getElementById(canvasId)
|
|
753
|
+
const ctx = canvas.getContext('2d')
|
|
754
|
+
const width = canvas.width
|
|
755
|
+
const height = canvas.height
|
|
756
|
+
const chart = {
|
|
757
|
+
left: 78,
|
|
758
|
+
right: width - 64,
|
|
759
|
+
top: 26,
|
|
760
|
+
bottom: height - 78
|
|
761
|
+
}
|
|
762
|
+
ctx.clearRect(0, 0, width, height)
|
|
763
|
+
ctx.font = '12px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
|
764
|
+
ctx.strokeStyle = '#d8ded8'
|
|
765
|
+
ctx.lineWidth = 1
|
|
766
|
+
const values = profile.correctionTable.filter(bin => Number.isFinite(bin.correctionDeg))
|
|
767
|
+
const maxAbs = Math.max(5, ...values.map(bin => Math.abs(bin.correctionDeg)))
|
|
768
|
+
const yTicks = [-maxAbs, -maxAbs / 2, 0, maxAbs / 2, maxAbs]
|
|
769
|
+
const xTicks = [0, 90, 180, 270, 360]
|
|
770
|
+
const xFor = heading => chart.left + heading / 360 * (chart.right - chart.left)
|
|
771
|
+
const yFor = correction => chart.bottom - (correction + maxAbs) / (maxAbs * 2) * (chart.bottom - chart.top)
|
|
772
|
+
|
|
773
|
+
for (const correction of yTicks) {
|
|
774
|
+
const y = yFor(correction)
|
|
775
|
+
ctx.beginPath()
|
|
776
|
+
ctx.moveTo(chart.left, y)
|
|
777
|
+
ctx.lineTo(chart.right, y)
|
|
778
|
+
ctx.stroke()
|
|
779
|
+
ctx.fillStyle = '#65716b'
|
|
780
|
+
ctx.textAlign = 'right'
|
|
781
|
+
ctx.textBaseline = 'middle'
|
|
782
|
+
ctx.fillText(`${formatValue(correction)} deg`, chart.left - 7, y)
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
for (const heading of xTicks) {
|
|
786
|
+
const x = xFor(heading)
|
|
787
|
+
ctx.beginPath()
|
|
788
|
+
ctx.moveTo(x, chart.top)
|
|
789
|
+
ctx.lineTo(x, chart.bottom)
|
|
790
|
+
ctx.stroke()
|
|
791
|
+
ctx.fillStyle = '#65716b'
|
|
792
|
+
ctx.textAlign = 'center'
|
|
793
|
+
ctx.textBaseline = 'top'
|
|
794
|
+
ctx.fillText(`${heading} deg`, x, chart.bottom + 10)
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
ctx.fillStyle = '#65716b'
|
|
798
|
+
ctx.textAlign = 'left'
|
|
799
|
+
ctx.textBaseline = 'alphabetic'
|
|
800
|
+
ctx.fillText('Correction', chart.left, 16)
|
|
801
|
+
ctx.textAlign = 'right'
|
|
802
|
+
ctx.fillText('Heading', chart.right, height - 20)
|
|
803
|
+
|
|
804
|
+
if (!values.length) return
|
|
805
|
+
|
|
806
|
+
ctx.strokeStyle = '#11685d'
|
|
807
|
+
ctx.lineWidth = 2
|
|
808
|
+
ctx.beginPath()
|
|
809
|
+
values.forEach((bin, index) => {
|
|
810
|
+
const x = xFor(bin.headingDeg)
|
|
811
|
+
const y = yFor(bin.correctionDeg)
|
|
812
|
+
if (index === 0) ctx.moveTo(x, y)
|
|
813
|
+
else ctx.lineTo(x, y)
|
|
814
|
+
})
|
|
815
|
+
ctx.stroke()
|
|
816
|
+
|
|
817
|
+
for (const bin of values) {
|
|
818
|
+
ctx.fillStyle = bin.quality === 'good' ? '#137547' : '#9b5c00'
|
|
819
|
+
ctx.beginPath()
|
|
820
|
+
ctx.arc(xFor(bin.headingDeg), yFor(bin.correctionDeg), bin.interpolated ? 3 : 4, 0, Math.PI * 2)
|
|
821
|
+
ctx.fill()
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function drawNavigationCoverageRoses (segments, target = 'candidate') {
|
|
826
|
+
segments.forEach((segment, index) => {
|
|
827
|
+
drawCoverageRose(`${target}-coverage-${index}`, segment.headingBins || [], numberValue('minSamplesPerBin'))
|
|
828
|
+
})
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function drawCoverageRose (canvasId, bins, targetSamples) {
|
|
832
|
+
const canvas = document.getElementById(canvasId)
|
|
833
|
+
if (!canvas || !bins.length) return
|
|
834
|
+
const ctx = canvas.getContext('2d')
|
|
835
|
+
const width = canvas.width
|
|
836
|
+
const height = canvas.height
|
|
837
|
+
const centerX = width / 2
|
|
838
|
+
const centerY = height / 2
|
|
839
|
+
const radius = Math.min(width, height) * 0.38
|
|
840
|
+
const target = Math.max(1, Number(targetSamples || 1))
|
|
841
|
+
ctx.clearRect(0, 0, width, height)
|
|
842
|
+
ctx.font = `${Math.max(10, Math.round(width / 22))}px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`
|
|
843
|
+
|
|
844
|
+
drawCoverageRings(ctx, centerX, centerY, radius)
|
|
845
|
+
const binCount = bins.length
|
|
846
|
+
for (let index = 0; index < binCount; index += 1) {
|
|
847
|
+
const bin = bins[index]
|
|
848
|
+
const samples = Number(bin.samples || 0)
|
|
849
|
+
const fillFraction = Math.max(0, Math.min(1, samples / target))
|
|
850
|
+
const inner = 0
|
|
851
|
+
const outer = radius * fillFraction
|
|
852
|
+
const start = -Math.PI / 2 + index / binCount * Math.PI * 2
|
|
853
|
+
const end = -Math.PI / 2 + (index + 1) / binCount * Math.PI * 2
|
|
854
|
+
ctx.beginPath()
|
|
855
|
+
ctx.moveTo(centerX, centerY)
|
|
856
|
+
ctx.arc(centerX, centerY, outer, start + 0.01, end - 0.01)
|
|
857
|
+
ctx.closePath()
|
|
858
|
+
ctx.fillStyle = samples >= target ? '#137547' : samples > 0 ? '#9b5c00' : '#d8ded8'
|
|
859
|
+
ctx.fill()
|
|
860
|
+
if (inner > 0) ctx.clearRect(centerX - inner, centerY - inner, inner * 2, inner * 2)
|
|
861
|
+
}
|
|
862
|
+
drawCoverageFrame(ctx, centerX, centerY, radius)
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function drawCoverageRings (ctx, centerX, centerY, radius) {
|
|
866
|
+
ctx.strokeStyle = '#d8ded8'
|
|
867
|
+
ctx.lineWidth = 1
|
|
868
|
+
ctx.fillStyle = '#65716b'
|
|
869
|
+
ctx.textAlign = 'left'
|
|
870
|
+
ctx.textBaseline = 'middle'
|
|
871
|
+
for (const fraction of [0.25, 0.5, 0.75, 1]) {
|
|
872
|
+
ctx.beginPath()
|
|
873
|
+
ctx.arc(centerX, centerY, radius * fraction, 0, Math.PI * 2)
|
|
874
|
+
ctx.stroke()
|
|
875
|
+
ctx.fillText(`${Math.round(fraction * 100)}%`, centerX + radius * fraction + 4, centerY)
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function drawCoverageFrame (ctx, centerX, centerY, radius) {
|
|
880
|
+
ctx.strokeStyle = '#65716b'
|
|
881
|
+
ctx.lineWidth = 1
|
|
882
|
+
for (const heading of [0, 90, 180, 270]) {
|
|
883
|
+
const angle = -Math.PI / 2 + heading / 180 * Math.PI
|
|
884
|
+
ctx.beginPath()
|
|
885
|
+
ctx.moveTo(centerX, centerY)
|
|
886
|
+
ctx.lineTo(centerX + Math.cos(angle) * radius, centerY + Math.sin(angle) * radius)
|
|
887
|
+
ctx.stroke()
|
|
888
|
+
}
|
|
889
|
+
ctx.fillStyle = '#19211d'
|
|
890
|
+
ctx.textAlign = 'center'
|
|
891
|
+
ctx.textBaseline = 'middle'
|
|
892
|
+
const labels = [
|
|
893
|
+
['N', centerX, centerY - radius - 12],
|
|
894
|
+
['E', centerX + radius + 12, centerY],
|
|
895
|
+
['S', centerX, centerY + radius + 12],
|
|
896
|
+
['W', centerX - radius - 12, centerY]
|
|
897
|
+
]
|
|
898
|
+
for (const [label, x, y] of labels) ctx.fillText(label, x, y)
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function table (headers, rows) {
|
|
902
|
+
if (!rows.length) return '<p class="muted">No data.</p>'
|
|
903
|
+
return `
|
|
904
|
+
<table>
|
|
905
|
+
<thead><tr>${headers.map(header => `<th>${escapeHtml(header)}</th>`).join('')}</tr></thead>
|
|
906
|
+
<tbody>${rows.map(row => `<tr>${row.map(cell => `<td>${cell}</td>`).join('')}</tr>`).join('')}</tbody>
|
|
907
|
+
</table>
|
|
908
|
+
`
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function metric (label, value) {
|
|
912
|
+
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(String(value))}</strong></div>`
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function runtimeMetric (label, value) {
|
|
916
|
+
return `<div class="runtimeMetric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(String(value || 'none'))}</strong></div>`
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function value (id) {
|
|
920
|
+
return document.getElementById(id).value.trim()
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function bindPersistence () {
|
|
924
|
+
for (const id of persistedFieldIds()) {
|
|
925
|
+
const element = document.getElementById(id)
|
|
926
|
+
if (!element) continue
|
|
927
|
+
element.addEventListener('input', persistFields)
|
|
928
|
+
element.addEventListener('change', persistFields)
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function persistedFieldIds () {
|
|
933
|
+
return [
|
|
934
|
+
'context',
|
|
935
|
+
'baseUrl',
|
|
936
|
+
'historyUsername',
|
|
937
|
+
'discoverFrom',
|
|
938
|
+
'discoverTo',
|
|
939
|
+
'headingSource',
|
|
940
|
+
'cogSource',
|
|
941
|
+
'sogSource',
|
|
942
|
+
'variationSource',
|
|
943
|
+
'from',
|
|
944
|
+
'to',
|
|
945
|
+
'minSog',
|
|
946
|
+
'maxCogRate',
|
|
947
|
+
'binSize',
|
|
948
|
+
'minSamplesPerBin',
|
|
949
|
+
...Object.values(metricInputs)
|
|
950
|
+
]
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function restorePersistedFields () {
|
|
954
|
+
const values = readStoredJson(localStorage, STORAGE_KEY)
|
|
955
|
+
restoredFields = new Set()
|
|
956
|
+
for (const [id, fieldValue] of Object.entries(values)) {
|
|
957
|
+
const element = document.getElementById(id)
|
|
958
|
+
if (!element || fieldValue === undefined || fieldValue === null) continue
|
|
959
|
+
ensureSelectOption(element, fieldValue)
|
|
960
|
+
element.value = fieldValue
|
|
961
|
+
restoredFields.add(id)
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const secrets = readStoredJson(sessionStorage, SECRET_STORAGE_KEY)
|
|
965
|
+
if (secrets.historyPassword) {
|
|
966
|
+
const password = document.getElementById('historyPassword')
|
|
967
|
+
password.value = secrets.historyPassword
|
|
968
|
+
restoredFields.add('historyPassword')
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function persistFields () {
|
|
973
|
+
const values = {}
|
|
974
|
+
for (const id of persistedFieldIds()) {
|
|
975
|
+
const element = document.getElementById(id)
|
|
976
|
+
if (element) values[id] = element.value
|
|
977
|
+
}
|
|
978
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(values))
|
|
979
|
+
sessionStorage.setItem(SECRET_STORAGE_KEY, JSON.stringify({
|
|
980
|
+
historyPassword: document.getElementById('historyPassword').value
|
|
981
|
+
}))
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function readStoredJson (storage, key) {
|
|
985
|
+
try {
|
|
986
|
+
return JSON.parse(storage.getItem(key) || '{}')
|
|
987
|
+
} catch (error) {
|
|
988
|
+
return {}
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function setIfNotRestored (id, nextValue) {
|
|
993
|
+
if (restoredFields.has(id)) return
|
|
994
|
+
const element = document.getElementById(id)
|
|
995
|
+
if (element) element.value = nextValue
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function numberValue (id) {
|
|
999
|
+
return Number(document.getElementById(id).value)
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function normalizeRange (range) {
|
|
1003
|
+
const from = new Date(range.from).getTime()
|
|
1004
|
+
const to = new Date(range.to).getTime()
|
|
1005
|
+
if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) return null
|
|
1006
|
+
return { from, to }
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function percent (fraction) {
|
|
1010
|
+
return Math.round(Math.max(0, Math.min(1, fraction)) * 1000) / 10
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function formatDateTime (input) {
|
|
1014
|
+
const date = new Date(input)
|
|
1015
|
+
if (Number.isNaN(date.getTime())) return ''
|
|
1016
|
+
return date.toISOString().slice(0, 16).replace('T', ' ')
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function formatRuntimeDate (input) {
|
|
1020
|
+
if (!input) return 'none'
|
|
1021
|
+
const date = new Date(input)
|
|
1022
|
+
if (Number.isNaN(date.getTime())) return String(input)
|
|
1023
|
+
return date.toISOString().slice(0, 19).replace('T', ' ')
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function headingCoverageDeg (bins) {
|
|
1027
|
+
return bins.filter(bin => Number(bin.samples || 0) > 0).length * 10
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function binsFromCorrectionTable (correctionTable) {
|
|
1031
|
+
return correctionTable.map(bin => ({
|
|
1032
|
+
fromDeg: bin.fromDeg,
|
|
1033
|
+
toDeg: bin.toDeg,
|
|
1034
|
+
samples: Number(bin.samples || 0)
|
|
1035
|
+
}))
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function mpsToKnots (value) {
|
|
1039
|
+
const number = Number(value)
|
|
1040
|
+
return Number.isFinite(number) ? number * MPS_TO_KNOTS : null
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function knotsToMps (value) {
|
|
1044
|
+
const number = Number(value)
|
|
1045
|
+
return Number.isFinite(number) ? number / MPS_TO_KNOTS : null
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function formatValue (input) {
|
|
1049
|
+
if (input === null || input === undefined || Number.isNaN(input)) return ''
|
|
1050
|
+
if (typeof input === 'number') return Math.round(input * 100) / 100
|
|
1051
|
+
return input
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function escapeHtml (value) {
|
|
1055
|
+
return String(value).replace(/[&<>"']/g, char => ({
|
|
1056
|
+
'&': '&',
|
|
1057
|
+
'<': '<',
|
|
1058
|
+
'>': '>',
|
|
1059
|
+
'"': '"',
|
|
1060
|
+
"'": '''
|
|
1061
|
+
}[char]))
|
|
1062
|
+
}
|