signalk-polar-performance-plugin 1.3.3 → 2.0.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 CHANGED
@@ -1,5 +1,8 @@
1
1
  // app.js — Polar Performance Plugin webapp
2
- // Pages: Overview | Inputs | Settings | Outputs | Polars
2
+ // Pages: Overview | Inputs | Outputs
3
+ // The active polar and performance factor are selected in a separate polar
4
+ // management webapp (e.g. signalk-polar-management) and read here via SK paths
5
+ // — this webapp only displays them, it does not manage polar storage/selection.
3
6
  // No build step. Uses window.PolarCanvas from polar-canvas.js (loaded before this module).
4
7
  // Live data is updated in-place every second — DOM is only rebuilt on page switch.
5
8
 
@@ -47,7 +50,7 @@ async function apiGet(path, opts = {}) {
47
50
  try {
48
51
  const res = await fetch(API + path, { credentials: 'same-origin' })
49
52
  if (!res.ok) {
50
- if (!opts.silent503 || res.status !== 503) showMessage('API error ' + res.status + ': ' + path)
53
+ if (!(opts.silentStatuses || []).includes(res.status)) showMessage('API error ' + res.status + ': ' + path)
51
54
  return null
52
55
  }
53
56
  return res.json()
@@ -66,34 +69,6 @@ async function apiPut(path, body) {
66
69
  } catch (e) { showMessage('Save failed: ' + e.message); return null }
67
70
  }
68
71
 
69
- async function apiPost(path, body) {
70
- try {
71
- const res = await fetch(API + path, {
72
- method: 'POST', credentials: 'same-origin',
73
- headers: { 'Content-Type': 'application/json' },
74
- body: JSON.stringify(body)
75
- })
76
- if (!res.ok) {
77
- let detail = ''
78
- try {
79
- const payload = await res.json()
80
- if (payload?.error) detail = ': ' + payload.error
81
- } catch (_) {}
82
- showMessage('Import failed: ' + res.status + detail)
83
- return null
84
- }
85
- return res.json()
86
- } catch (e) { showMessage('Import failed: ' + e.message); return null }
87
- }
88
-
89
- async function apiDelete(path) {
90
- try {
91
- const res = await fetch(API + path, { method: 'DELETE', credentials: 'same-origin' })
92
- if (!res.ok) { showMessage('Delete failed: ' + res.status); return null }
93
- return res.json()
94
- } catch (e) { showMessage('Delete failed: ' + e.message); return null }
95
- }
96
-
97
72
  // Fetch a single scalar value from the SK REST API
98
73
  // ── Message bar ───────────────────────────────────────────────────────────────
99
74
  let _msgTimer = null
@@ -113,55 +88,6 @@ function sectionHeading(text) {
113
88
  return h
114
89
  }
115
90
 
116
- function createCollapsibleCard(title, startOpen = false) {
117
- const card = document.createElement('div')
118
- card.className = 'card mb-3'
119
-
120
- const header = document.createElement('div')
121
- header.className = 'card-header p-0'
122
-
123
- const button = document.createElement('button')
124
- button.type = 'button'
125
- button.className = 'btn btn-link btn-block text-start text-decoration-none px-3 py-2'
126
- button.style.textAlign = 'left'
127
-
128
- const body = document.createElement('div')
129
- body.className = 'card-body'
130
- body.style.display = startOpen ? '' : 'none'
131
-
132
- const syncLabel = () => {
133
- button.textContent = (body.style.display === 'none' ? '+ ' : '- ') + title
134
- button.setAttribute('aria-expanded', body.style.display === 'none' ? 'false' : 'true')
135
- }
136
-
137
- button.addEventListener('click', () => {
138
- body.style.display = body.style.display === 'none' ? '' : 'none'
139
- syncLabel()
140
- })
141
- syncLabel()
142
-
143
- header.appendChild(button)
144
- card.appendChild(header)
145
- card.appendChild(body)
146
- return { card, body }
147
- }
148
-
149
- function createPageCard(title) {
150
- const card = document.createElement('div')
151
- card.className = 'card mb-3'
152
-
153
- const header = document.createElement('div')
154
- header.className = 'card-header fw-bold text-uppercase'
155
- header.textContent = title
156
-
157
- const body = document.createElement('div')
158
- body.className = 'card-body'
159
-
160
- card.appendChild(header)
161
- card.appendChild(body)
162
- return { card, body }
163
- }
164
-
165
91
  // Set text of a span by id (fast in-place update, no DOM rebuild)
166
92
  function setVal(id, text) {
167
93
  const el = document.getElementById(id)
@@ -253,38 +179,6 @@ function createNumberInput(key, value, opts, showRevert, onSaved) {
253
179
  return wrap
254
180
  }
255
181
 
256
- function createPercentInput(key, value, opts, onSaved) {
257
- const wrap = document.createElement('span')
258
- const inp = document.createElement('input')
259
- inp.type = 'number'
260
- inp.className = 'form-control form-control-sm d-inline-block'
261
- inp.style.width = '90px'
262
- inp.value = Number.isFinite(value) ? (value * 100).toFixed(0) : (opts.defaultPercent ?? 100)
263
- if (opts.minPercent !== undefined) inp.min = opts.minPercent
264
- if (opts.maxPercent !== undefined) inp.max = opts.maxPercent
265
- if (opts.stepPercent !== undefined) inp.step = opts.stepPercent
266
-
267
- const suffix = document.createElement('span')
268
- suffix.className = 'ms-1 small text-muted'
269
- suffix.textContent = '%'
270
-
271
- inp.addEventListener('change', () => {
272
- const percent = Number(inp.value)
273
- if (!Number.isFinite(percent)) return
274
- apiPut('/settings', { [key]: percent / 100 }).then(s => {
275
- if (s) {
276
- settings = s
277
- inp.value = Number.isFinite(s[key]) ? (s[key] * 100).toFixed(0) : inp.value
278
- if (typeof onSaved === 'function') onSaved(s)
279
- }
280
- })
281
- })
282
-
283
- wrap.appendChild(inp)
284
- wrap.appendChild(suffix)
285
- return wrap
286
- }
287
-
288
182
  // Update warnings container in-place. Skips DOM write when content unchanged.
289
183
  function updateWarnings(el, items) {
290
184
  if (!el) return
@@ -306,9 +200,6 @@ let statusData = null // from /status — raw inputs + computed outputs
306
200
  let rawValues = {} // raw sensor values from /status, keyed by short name (tws/twa/bsp/hdg)
307
201
  let outputValues = {} // computed output values from /status, keyed by SK path string
308
202
  let settings = null
309
- let polarsList = []
310
- let importFormats = []
311
- let internetOnline = false
312
203
  let lifecycleWarnings = []
313
204
 
314
205
  // Canvas state
@@ -385,48 +276,24 @@ async function refreshSettings() {
385
276
  if (s) settings = s
386
277
  }
387
278
 
388
- async function refreshPolars() {
389
- const list = await apiGet('/polars')
390
- if (list) polarsList = list
391
- }
392
-
393
- async function refreshImportFormats() {
394
- const formats = await apiGet('/imports/formats')
395
- if (formats) importFormats = formats
396
- }
397
-
398
- async function refreshInternetStatus() {
399
- const result = await apiGet('/internet')
400
- internetOnline = !!(result && result.online)
401
- }
402
-
403
279
  async function refreshLibrary() {
404
- const activeId = settings?.activePolar
405
- if (!activeId) {
406
- if (libraryVersion !== '') {
407
- libraryVersion = ''; twsList = []; curves = {}
408
- if (polar) polar.setLibraryData([], {}, null)
409
- }
410
- return
411
- }
412
-
413
- const newList = await apiGet('/polars/' + encodeURIComponent(activeId) + '/axes/tws', { silent503: true })
280
+ const newList = await apiGet('/polar/axes/tws', { silentStatuses: [404] })
414
281
  if (!newList) {
415
- // No polar loaded — clear any previously displayed curves
282
+ // No active polar — clear any previously displayed curves
416
283
  if (libraryVersion !== '') {
417
284
  libraryVersion = ''; twsList = []; curves = {}
418
285
  if (polar) polar.setLibraryData([], {}, null)
419
286
  }
420
287
  return
421
288
  }
422
- const version = activeId + '|' + JSON.stringify(newList)
289
+ const version = JSON.stringify(newList)
423
290
  if (version === libraryVersion) return
424
291
  libraryVersion = version; twsList = newList; curves = {}
425
292
  await Promise.all(twsList.map(async tws => {
426
293
  try {
427
294
  const c = await apiGet(
428
- '/polars/' + encodeURIComponent(activeId) + '/queries/curve?tws=' + tws.toFixed(5) + '&step=' + STEP,
429
- { silent503: true }
295
+ '/polar/queries/curve?tws=' + tws.toFixed(5) + '&step=' + STEP,
296
+ { silentStatuses: [404] }
430
297
  )
431
298
  if (c) curves[tws] = c
432
299
  } catch (_) {}
@@ -462,13 +329,10 @@ async function refreshLive() {
462
329
  // 3. Update live TWS curve for canvas
463
330
  if (liveData && polar) {
464
331
  const tws = liveData.tws
465
- const activeId = settings?.activePolar
466
332
  if (tws !== null && (!liveTws || Math.abs(tws - liveTws) > 0.05)) {
467
333
  liveTws = tws
468
334
  try {
469
- liveCurve = activeId
470
- ? await apiGet('/polars/' + encodeURIComponent(activeId) + '/queries/curve?tws=' + tws.toFixed(5) + '&step=' + STEP)
471
- : null
335
+ liveCurve = await apiGet('/polar/queries/curve?tws=' + tws.toFixed(5) + '&step=' + STEP, { silentStatuses: [404] })
472
336
  }
473
337
  catch (_) {}
474
338
  }
@@ -517,6 +381,12 @@ function _buildOverviewPage() {
517
381
  // Live numbers (right) — skeleton built once; values updated in-place via setVal()
518
382
  const right = document.createElement('div')
519
383
  right.className = 'col-md-6'
384
+
385
+ // Active polar — read-only; selected in a separate polar management webapp
386
+ right.appendChild(sectionHeading('Active Polar'))
387
+ const polarInfoDiv = document.createElement('div'); polarInfoDiv.id = 'ov-polar-info'
388
+ right.appendChild(polarInfoDiv)
389
+
520
390
  right.appendChild(sectionHeading('Live Performance'))
521
391
  right.appendChild(buildTable([
522
392
  { label: 'True Wind Speed', id: 'ov-tws' },
@@ -584,6 +454,36 @@ function _buildOverviewPage() {
584
454
 
585
455
  function _tickOverview() {
586
456
  const d = liveData
457
+
458
+ const infoEl = document.getElementById('ov-polar-info')
459
+ if (infoEl) {
460
+ const p = meta?.activePolar
461
+ if (!p) {
462
+ infoEl.innerHTML = ''
463
+ const none = document.createElement('p'); none.className = 'text-muted small mb-2'
464
+ none.textContent = 'No active polar — select one in the polar management webapp.'
465
+ infoEl.appendChild(none)
466
+ } else if (!document.getElementById('ov-polar-name')) {
467
+ infoEl.innerHTML = ''
468
+ infoEl.appendChild(buildTable([
469
+ { label: 'Name', id: 'ov-polar-name' },
470
+ { label: 'Boat type', id: 'ov-polar-boatType' },
471
+ { label: 'Sail number', id: 'ov-polar-sailnumber' },
472
+ { label: 'Year', id: 'ov-polar-year' },
473
+ { label: 'Source', id: 'ov-polar-source' },
474
+ { label: 'Performance factor', id: 'ov-polar-perf' },
475
+ ]))
476
+ }
477
+ if (p) {
478
+ setVal('ov-polar-name', p.name || '—')
479
+ setVal('ov-polar-boatType', p.boatType || '—')
480
+ setVal('ov-polar-sailnumber', p.sailnumber || '—')
481
+ setVal('ov-polar-year', p.year ? String(p.year) : '—')
482
+ setVal('ov-polar-source', p.source || '—')
483
+ setVal('ov-polar-perf', fmtVal(meta?.performanceFactor, 'performance', RATIO_DEFAULT))
484
+ }
485
+ }
486
+
587
487
  setVal('ov-tws', fmtVal(d?.tws, 'tws', SPEED_DEFAULT))
588
488
  setVal('ov-twa', fmtVal(d?.twa != null ? Math.abs(d.twa) : null, 'twa', ANGLE_DEFAULT))
589
489
  setVal('ov-bsp', fmtVal(d?.bsp, 'bsp', SPEED_DEFAULT))
@@ -612,7 +512,7 @@ function _tickOverview() {
612
512
  if (d?.tws == null) warns.push('True wind speed — no data (environment.wind.speedTrue)')
613
513
  if (d?.twa == null) warns.push('True wind angle — no data (environment.wind.angleTrueWater)')
614
514
  if (d?.bsp == null) warns.push('Boat speed — no data')
615
- if (d?.tws != null && d?.polarState == null) warns.push('No polar loaded — configure in Polars')
515
+ if (d?.tws != null && d?.polarState == null) warns.push('No active polar — select one in the polar management webapp')
616
516
  warns.push(...polarStateWarnings(d))
617
517
  updateWarnings(document.getElementById('ov-warnings'), warns)
618
518
  }
@@ -705,110 +605,6 @@ const SMOOTHER_PARAMS = {
705
605
  Kalman: { key: 'smootherParamKalman', label: 'Steady-state gain (0–1)', min: 0.001, max: 1, step: 0.001, default: 0.1 },
706
606
  }
707
607
 
708
- function _buildSettingsPage() {
709
- if (!settings) {
710
- const p = document.createElement('p'); p.className = 'text-muted small mt-2'
711
- p.textContent = 'Loading settings…'; return p
712
- }
713
-
714
- const row = document.createElement('div')
715
- row.className = 'row g-3'
716
-
717
- // ── Left: polar canvas ────────────────────────────────────────────────────
718
- const left = document.createElement('div')
719
- left.className = 'col-md-6'
720
- const canvasEl = document.createElement('canvas')
721
- canvasEl.id = 'settings-polar-canvas'
722
- canvasEl.className = 'polar-canvas'
723
- left.appendChild(canvasEl)
724
- row.appendChild(left)
725
-
726
- // ── Right: settings controls ──────────────────────────────────────────────
727
- const right = document.createElement('div')
728
- right.className = 'col-md-6'
729
-
730
- // Polar
731
- right.appendChild(sectionHeading('Polar'))
732
-
733
- // Resolve metadata for the currently active polar
734
- const activeMeta = (() => {
735
- const a = settings?.activePolar
736
- if (!a) return {}
737
- const entry = polarsList.find(p => (typeof p === 'string' ? p : p.id) === a)
738
- return (entry && typeof entry === 'object') ? entry : {}
739
- })()
740
-
741
- right.appendChild(_settingsTable([
742
- { label: 'Active polar',
743
- control: _polarSelector() },
744
- { label: 'Performance adjust', desc: '100% = polar speed unchanged',
745
- control: createPercentInput('perfAdjust', settings.perfAdjust, { minPercent: 10, maxPercent: 200, stepPercent: 5, defaultPercent: 100 }, async () => {
746
- libraryVersion = '' // force full curve reload — perfAdjust changes speeds but not the TWS list
747
- await refreshLibrary()
748
- if (activePage === 'settings') switchPage('settings')
749
- }) },
750
- { label: 'Boat name', control: _metaDisplay(activeMeta.name) },
751
- { label: 'Boat type', control: _metaDisplay(activeMeta.boatType) },
752
- { label: 'Sail number', control: _metaDisplay(activeMeta.sailnumber) },
753
- { label: 'Year', control: _metaDisplay(activeMeta.year ? String(activeMeta.year) : '') },
754
- { label: 'Source', control: _metaDisplay(activeMeta.source) },
755
- ]))
756
-
757
- // Smoother
758
- // (moved to Inputs page)
759
-
760
- // Speed source
761
- // (moved to Inputs page)
762
-
763
- row.appendChild(right)
764
-
765
- // ── Canvas init + ResizeObserver ──────────────────────────────────────────
766
- let settingsPolar = null
767
-
768
- // Wrap canvas in a relative container so we can overlay a message
769
- const canvasWrap = document.createElement('div')
770
- canvasWrap.style.position = 'relative'
771
- canvasWrap.appendChild(canvasEl)
772
- left.innerHTML = ''
773
- left.appendChild(canvasWrap)
774
-
775
- const noPolarMsg = document.createElement('div')
776
- noPolarMsg.textContent = 'No polar loaded — select one above'
777
- noPolarMsg.style.cssText = 'position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:0.85rem;color:#888;pointer-events:none;'
778
- noPolarMsg.style.display = twsList.length ? 'none' : 'flex'
779
- canvasWrap.appendChild(noPolarMsg)
780
-
781
- if (window.PolarCanvas) {
782
- settingsPolar = new window.PolarCanvas(canvasEl, { showLibrary: true, showLiveCurve: false })
783
- if (Object.keys(meta).length) settingsPolar.setMeta(meta)
784
- if (twsList.length) settingsPolar.setLibraryData(twsList, curves, null)
785
- }
786
-
787
- if (window.ResizeObserver) {
788
- let _rafPending = false
789
- const obs = new ResizeObserver(() => {
790
- if (_rafPending) return
791
- _rafPending = true
792
- requestAnimationFrame(() => {
793
- _rafPending = false
794
- if (canvasEl.offsetWidth > 0 && settingsPolar) {
795
- settingsPolar.resize()
796
- settingsPolar.draw()
797
- }
798
- })
799
- })
800
- obs.observe(canvasEl)
801
- row._cleanup = () => { obs.disconnect(); settingsPolar = null }
802
- } else {
803
- requestAnimationFrame(() => {
804
- if (settingsPolar) { settingsPolar.resize(); settingsPolar.draw() }
805
- })
806
- row._cleanup = () => { settingsPolar = null }
807
- }
808
-
809
- return row
810
- }
811
-
812
608
  function _settingsTable(rows) {
813
609
  const tbl = document.createElement('table')
814
610
  tbl.className = 'table table-sm table-borderless mb-0'
@@ -828,50 +624,6 @@ function _settingsTable(rows) {
828
624
  tbl.appendChild(tbody); return tbl
829
625
  }
830
626
 
831
- /** Read-only display span for polar metadata fields. */
832
- function _metaDisplay(value) {
833
- const span = document.createElement('span')
834
- span.className = value ? 'small' : 'small text-muted'
835
- span.textContent = value || '—'
836
- return span
837
- }
838
-
839
- function readOptionalIntegerInput(input) {
840
- const text = String(input?.value || '').trim()
841
- if (!text) return undefined
842
- const value = Number(text)
843
- return Number.isInteger(value) ? value : undefined
844
- }
845
-
846
- function _polarSelector() {
847
- const sel = document.createElement('select')
848
- sel.className = 'form-select form-select-sm'; sel.style.width = '100%'
849
- const none = document.createElement('option'); none.value = ''; none.textContent = '— none —'
850
- sel.appendChild(none)
851
- polarsList.forEach(raw => {
852
- const p = typeof raw === 'string' ? { id: raw, name: raw } : raw
853
- const o = document.createElement('option'); o.value = p.id
854
- o.textContent = p.name && p.name !== p.id ? `${p.name} (${p.id})` : p.id
855
- sel.appendChild(o)
856
- })
857
- sel.value = settings?.activePolar || ''
858
- sel.addEventListener('change', () => {
859
- const request = sel.value
860
- ? apiPut('/polars/active', { id: sel.value })
861
- : apiDelete('/polars/active')
862
-
863
- request.then(async s => {
864
- if (!s) return
865
- settings = { ...(settings || {}), activePolar: s.id || '' }
866
- libraryVersion = '' // force full reload — new polar may share the same TWS list
867
- await refreshLibrary()
868
- await refreshPolars()
869
- if (activePage === 'settings') switchPage('settings')
870
- })
871
- })
872
- return sel
873
- }
874
-
875
627
  function _smootherSelector() {
876
628
  const sel = document.createElement('select')
877
629
  sel.className = 'form-select form-select-sm'; sel.style.width = '100%'
@@ -937,345 +689,11 @@ function _tickOutputs() {
937
689
  updateWarnings(document.getElementById('out-warnings'), polarStateWarnings(liveData))
938
690
  }
939
691
 
940
- // ── PAGE: Polars ───────────────────────────────────────────────────────────────
941
- function _buildPolarsPage() {
942
- const wrap = document.createDocumentFragment()
943
-
944
- const storedCard = createPageCard('Stored Polars')
945
- wrap.appendChild(storedCard.card)
946
- const listDiv = document.createElement('div'); listDiv.id = 'polars-list'
947
- _renderPolarsList(listDiv)
948
- storedCard.body.appendChild(listDiv)
949
-
950
- const textCard = createCollapsibleCard('Import Text Polar', false)
951
- wrap.appendChild(textCard.card)
952
- const textWrap = textCard.body
953
-
954
- const importMetaTable = document.createElement('table')
955
- importMetaTable.className = 'table table-sm table-borderless mb-2'
956
- const importMetaBody = document.createElement('tbody')
957
-
958
- const formatSel = document.createElement('select')
959
- formatSel.className = 'form-select form-select-sm'
960
- formatSel.style.width = '220px'
961
- if (!importFormats.length) {
962
- const opt = document.createElement('option')
963
- opt.value = ''
964
- opt.textContent = 'No formats available'
965
- formatSel.appendChild(opt)
966
- formatSel.disabled = true
967
- } else {
968
- importFormats.forEach(format => {
969
- const opt = document.createElement('option')
970
- opt.value = format.id
971
- opt.textContent = format.name
972
- formatSel.appendChild(opt)
973
- })
974
- }
975
-
976
- const makeTextInput = (placeholder, width) => {
977
- const input = document.createElement('input')
978
- input.type = 'text'
979
- input.className = 'form-control form-control-sm'
980
- if (width) input.style.width = width
981
- if (placeholder) input.placeholder = placeholder
982
- return input
983
- }
984
-
985
- const nameInp = makeTextInput('Optional display name')
986
- const sailInp = makeTextInput('Optional sail number')
987
- const typeInp = makeTextInput('Optional boat type')
988
- const yearInp = document.createElement('input')
989
- yearInp.type = 'number'
990
- yearInp.className = 'form-control form-control-sm'
991
- yearInp.style.width = '120px'
992
- yearInp.placeholder = 'Optional year'
993
- const sourceInp = makeTextInput('Defaults to format id')
994
-
995
- const addFormRow = (label, control, desc) => {
996
- const tr = document.createElement('tr')
997
- const tdL = document.createElement('td')
998
- tdL.textContent = label
999
- if (desc) {
1000
- const sm = document.createElement('small')
1001
- sm.className = 'text-muted d-block'
1002
- sm.textContent = desc
1003
- tdL.appendChild(sm)
1004
- }
1005
- const tdV = document.createElement('td')
1006
- tdV.appendChild(control)
1007
- tr.appendChild(tdL)
1008
- tr.appendChild(tdV)
1009
- importMetaBody.appendChild(tr)
1010
- }
1011
-
1012
- addFormRow('Format', formatSel, 'Text formats currently supported by the plugin.')
1013
- addFormRow('Name', nameInp)
1014
- addFormRow('Sail number', sailInp)
1015
- addFormRow('Boat type', typeInp)
1016
- addFormRow('Year', yearInp)
1017
- addFormRow('Source label', sourceInp, 'Optional metadata override stored with the canonical polar.')
1018
- importMetaTable.appendChild(importMetaBody)
1019
- textWrap.appendChild(importMetaTable)
1020
-
1021
- const importTextArea = document.createElement('textarea')
1022
- importTextArea.className = 'form-control form-control-sm mb-2'
1023
- importTextArea.rows = 12
1024
- importTextArea.placeholder = 'Paste Jieter or Expedition polar text here'
1025
- importTextArea.style.fontFamily = 'monospace'
1026
- importTextArea.style.fontSize = '0.8rem'
1027
- textWrap.appendChild(importTextArea)
1028
-
1029
- const notesArea = document.createElement('textarea')
1030
- notesArea.className = 'form-control form-control-sm mb-2'
1031
- notesArea.rows = 3
1032
- notesArea.placeholder = 'Optional notes stored with the imported polar'
1033
- textWrap.appendChild(notesArea)
1034
-
1035
- const importBtn = document.createElement('button')
1036
- importBtn.className = 'btn btn-sm btn-primary mb-3'
1037
- importBtn.textContent = 'Import'
1038
- importBtn.disabled = !importFormats.length
1039
- importBtn.addEventListener('click', async () => {
1040
- const format = formatSel.value
1041
- const content = importTextArea.value.trim()
1042
- if (!format) { showMessage('Select an import format'); return }
1043
- if (!content) { showMessage('Paste polar text first'); return }
1044
-
1045
- const body = {
1046
- content,
1047
- ...(nameInp.value.trim() ? { name: nameInp.value.trim() } : {}),
1048
- ...(sailInp.value.trim() ? { sailnumber: sailInp.value.trim() } : {}),
1049
- ...(typeInp.value.trim() ? { boatType: typeInp.value.trim() } : {}),
1050
- ...(readOptionalIntegerInput(yearInp) !== undefined ? { year: readOptionalIntegerInput(yearInp) } : {}),
1051
- ...(sourceInp.value.trim() ? { source: sourceInp.value.trim() } : {}),
1052
- ...(notesArea.value.trim() ? { notes: notesArea.value.trim() } : {})
1053
- }
1054
-
1055
- const result = await apiPost('/imports/text/' + encodeURIComponent(format), body)
1056
- if (result) {
1057
- showMessage('Imported "' + result.id + '"')
1058
- nameInp.value = ''
1059
- sailInp.value = ''
1060
- typeInp.value = ''
1061
- yearInp.value = ''
1062
- sourceInp.value = ''
1063
- importTextArea.value = ''
1064
- notesArea.value = ''
1065
- await refreshPolars()
1066
- _renderPolarsList(document.getElementById('polars-list'))
1067
- }
1068
- })
1069
- textWrap.appendChild(importBtn)
1070
-
1071
- const orcCard = createCollapsibleCard('Import ORC Certificate', false)
1072
- wrap.appendChild(orcCard.card)
1073
- const orcWrap = orcCard.body
1074
-
1075
- if (!internetOnline) {
1076
- const unavailable = document.createElement('div')
1077
- unavailable.className = 'text-muted small mb-3'
1078
- unavailable.textContent = 'No internet connection — ORC import is unavailable.'
1079
- orcWrap.appendChild(unavailable)
1080
- } else {
1081
- const sourceBlurb = document.createElement('p')
1082
- sourceBlurb.className = 'text-muted small mb-2'
1083
- sourceBlurb.textContent = 'Search the official ORC active certificate index by RefNo, boat name, sail number, or class.'
1084
- orcWrap.appendChild(sourceBlurb)
1085
-
1086
- const searchRow = document.createElement('div')
1087
- searchRow.className = 'd-flex flex-wrap gap-2 align-items-center mb-2'
1088
-
1089
- const orcSearchInp = makeTextInput('RefNo, boat name, sail number, or class', '320px')
1090
- const orcSearchBtn = document.createElement('button')
1091
- orcSearchBtn.className = 'btn btn-sm btn-secondary'
1092
- orcSearchBtn.textContent = 'Search'
1093
- searchRow.appendChild(orcSearchInp)
1094
- searchRow.appendChild(orcSearchBtn)
1095
- orcWrap.appendChild(searchRow)
1096
-
1097
- const orcResults = document.createElement('div')
1098
- orcResults.id = 'orc-results'
1099
- orcResults.className = 'mb-3'
1100
- orcWrap.appendChild(orcResults)
1101
-
1102
- const renderOrcResults = (items) => {
1103
- orcResults.innerHTML = ''
1104
-
1105
- if (!items.length) {
1106
- const empty = document.createElement('div')
1107
- empty.className = 'text-muted small'
1108
- empty.textContent = 'No ORC certificates matched the current search.'
1109
- orcResults.appendChild(empty)
1110
- return
1111
- }
1112
-
1113
- const table = document.createElement('table')
1114
- table.className = 'table table-sm table-hover mb-0'
1115
- const tbody = document.createElement('tbody')
1116
-
1117
- items.forEach(item => {
1118
- const tr = document.createElement('tr')
1119
-
1120
- const tdInfo = document.createElement('td')
1121
- const title = document.createElement('div')
1122
- title.className = 'fw-semibold'
1123
- title.textContent = item.name || item.externalId
1124
- tdInfo.appendChild(title)
1125
-
1126
- const meta = document.createElement('small')
1127
- meta.className = 'text-muted d-block'
1128
- const metaParts = [
1129
- item.externalId,
1130
- item.sailnumber,
1131
- item.boatType,
1132
- item.certificateName,
1133
- item.familyName,
1134
- item.countryId,
1135
- Number.isInteger(item.year) ? String(item.year) : ''
1136
- ].filter(Boolean)
1137
- meta.textContent = metaParts.join(' | ')
1138
- tdInfo.appendChild(meta)
1139
-
1140
- const tdAction = document.createElement('td')
1141
- tdAction.className = 'polar-actions'
1142
- const btn = document.createElement('button')
1143
- btn.className = 'btn btn-sm btn-primary'
1144
- btn.textContent = 'Import'
1145
- btn.addEventListener('click', async () => {
1146
- const result = await apiPost(
1147
- '/imports/sources/orc/items/' + encodeURIComponent(item.externalId)
1148
- )
1149
- if (result) {
1150
- showMessage('Imported "' + result.id + '" from ORC')
1151
- await refreshPolars()
1152
- _renderPolarsList(document.getElementById('polars-list'))
1153
- }
1154
- })
1155
- tdAction.appendChild(btn)
1156
-
1157
- tr.appendChild(tdInfo)
1158
- tr.appendChild(tdAction)
1159
- tbody.appendChild(tr)
1160
- })
1161
-
1162
- table.appendChild(tbody)
1163
- orcResults.appendChild(table)
1164
- }
1165
-
1166
- const runOrcSearch = async () => {
1167
- const q = orcSearchInp.value.trim()
1168
- if (!q) {
1169
- showMessage('Enter an ORC search term first')
1170
- return
1171
- }
1172
-
1173
- const query = new URLSearchParams({ q })
1174
- const results = await apiGet('/imports/sources/orc/search?' + query.toString())
1175
- if (results) renderOrcResults(results)
1176
- }
1177
-
1178
- orcSearchBtn.addEventListener('click', runOrcSearch)
1179
- orcSearchInp.addEventListener('keydown', (event) => {
1180
- if (event.key === 'Enter') {
1181
- event.preventDefault()
1182
- runOrcSearch()
1183
- }
1184
- })
1185
- }
1186
-
1187
- return wrap
1188
- }
1189
-
1190
- function _renderPolarsList(el) {
1191
- el.innerHTML = ''
1192
- if (!polarsList.length) {
1193
- const p = document.createElement('p'); p.className = 'text-muted small mb-3'
1194
- p.textContent = 'No canonical polars stored yet.'
1195
- el.appendChild(p)
1196
- return
1197
- }
1198
-
1199
- const tbl = document.createElement('table')
1200
- tbl.className = 'table table-sm table-borderless mb-2'
1201
- const tbody = document.createElement('tbody')
1202
-
1203
- polarsList.forEach(raw => {
1204
- const entry = typeof raw === 'string' ? { id: raw, name: raw } : raw
1205
- const id = entry.id
1206
- const isActive = id === settings?.activePolar
1207
- const tr = document.createElement('tr')
1208
- const tdN = document.createElement('td')
1209
- const labelSpan = document.createElement('span')
1210
- labelSpan.textContent = entry.name || id
1211
- tdN.appendChild(labelSpan)
1212
- if (isActive) {
1213
- const badge = document.createElement('span')
1214
- badge.className = 'badge bg-success ms-2'
1215
- badge.textContent = 'active'
1216
- tdN.appendChild(badge)
1217
- }
1218
-
1219
- const sub = []
1220
- if (entry.boatType) sub.push(entry.boatType)
1221
- if (entry.sailnumber) sub.push(entry.sailnumber)
1222
- if (sub.length) {
1223
- const sm = document.createElement('small')
1224
- sm.className = 'text-muted d-block'
1225
- sm.textContent = sub.join(' \u00b7 ')
1226
- tdN.appendChild(sm)
1227
- }
1228
-
1229
- const tdA = document.createElement('td')
1230
- tdA.className = 'polar-actions'
1231
- if (!isActive) {
1232
- const actBtn = document.createElement('button')
1233
- actBtn.className = 'btn btn-sm btn-outline-primary me-1'
1234
- actBtn.textContent = 'Activate'
1235
- actBtn.addEventListener('click', async () => {
1236
- const result = await apiPut('/polars/active', { id })
1237
- if (result) {
1238
- settings = { ...(settings || {}), activePolar: result.id }
1239
- libraryVersion = ''
1240
- await refreshLibrary()
1241
- await refreshPolars()
1242
- _renderPolarsList(el)
1243
- if (activePage === 'settings') switchPage('settings')
1244
- }
1245
- })
1246
- tdA.appendChild(actBtn)
1247
- }
1248
-
1249
- const delBtn = document.createElement('button')
1250
- delBtn.className = 'btn btn-sm btn-outline-danger'
1251
- delBtn.textContent = 'Delete'
1252
- delBtn.addEventListener('click', async () => {
1253
- if (!confirm('Delete polar "' + id + '"?')) return
1254
- await apiDelete('/polars/' + encodeURIComponent(id))
1255
- await refreshSettings()
1256
- await refreshPolars()
1257
- libraryVersion = ''
1258
- await refreshLibrary()
1259
- _renderPolarsList(el)
1260
- })
1261
- tdA.appendChild(delBtn)
1262
-
1263
- tr.appendChild(tdN)
1264
- tr.appendChild(tdA)
1265
- tbody.appendChild(tr)
1266
- })
1267
-
1268
- tbl.appendChild(tbody)
1269
- el.appendChild(tbl)
1270
- }
1271
-
1272
692
  // ── Navigation ────────────────────────────────────────────────────────────────
1273
693
  const PAGES = {
1274
- overview: { title: 'Overview', build: _buildOverviewPage },
1275
- inputs: { title: 'Inputs', build: _buildInputsPage },
1276
- settings: { title: 'Polar', build: _buildSettingsPage },
1277
- outputs: { title: 'Outputs', build: _buildOutputsPage },
1278
- polars: { title: 'Polar management', build: _buildPolarsPage },
694
+ overview: { title: 'Overview', build: _buildOverviewPage },
695
+ inputs: { title: 'Inputs', build: _buildInputsPage },
696
+ outputs: { title: 'Outputs', build: _buildOutputsPage },
1279
697
  }
1280
698
 
1281
699
  let _currentPageEl = null
@@ -1288,14 +706,11 @@ function switchPage(page) {
1288
706
  document.querySelectorAll('#main-nav .nav-link').forEach(l =>
1289
707
  l.classList.toggle('active', l.dataset.page === page)
1290
708
  )
1291
- const shell = document.getElementById('page-shell')
1292
709
  const title = document.getElementById('card-title')
1293
710
  const body = document.getElementById('card-body')
1294
711
  title.textContent = PAGES[page].title
1295
712
  body.innerHTML = ''
1296
- shell.classList.toggle('page-shellless', page === 'polars')
1297
- body.classList.toggle('page-shellless-body', page === 'polars')
1298
- body.classList.toggle('polar-layout', page === 'overview' || page === 'settings')
713
+ body.classList.toggle('polar-layout', page === 'overview')
1299
714
 
1300
715
  _currentPageEl = PAGES[page].build()
1301
716
  body.appendChild(_currentPageEl)
@@ -1332,7 +747,7 @@ async function init() {
1332
747
  }
1333
748
  })
1334
749
 
1335
- await Promise.all([refreshSettings(), refreshPolars(), refreshImportFormats(), refreshInternetStatus()])
750
+ await refreshSettings()
1336
751
  await loadMeta()
1337
752
  switchPage('overview')
1338
753
  await refreshLibrary()