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/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
+ '&': '&amp;',
1057
+ '<': '&lt;',
1058
+ '>': '&gt;',
1059
+ '"': '&quot;',
1060
+ "'": '&#39;'
1061
+ }[char]))
1062
+ }