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/CHANGELOG.md +25 -1
- package/README.md +41 -86
- package/docs/developer-reference.md +22 -130
- package/openApi.json +12 -798
- package/package.json +13 -7
- package/plugin/index.js +178 -521
- package/public/app.js +53 -638
- package/public/index.html +0 -2
- package/public/plotter.html +20 -106
- package/test/lifecycle.test.js +6 -1
- package/docs/import.png +0 -0
- package/docs/polar.png +0 -0
- package/docs/polar_management.png +0 -0
- package/plugin/PolarFileStore.js +0 -133
- package/plugin/PolarTable.js +0 -897
- package/plugin/SI.js +0 -21
- package/plugin/import/ExpeditionImporter.js +0 -152
- package/plugin/import/FormatRegistry.js +0 -21
- package/plugin/import/ImportService.js +0 -199
- package/plugin/import/JieterImporter.js +0 -18
- package/plugin/import/OrcSource.js +0 -396
- package/plugin/import/SourceRegistry.js +0 -17
- package/plugin/import/canonical.js +0 -73
- package/plugin/import/matrixText.js +0 -133
- package/plugin/index.js.bak +0 -1003
- package/test/PolarFileStore.test.js +0 -216
- package/test/PolarTable.test.js +0 -349
- package/test/api.test.js +0 -502
- package/test/migration.test.js +0 -164
package/plugin/SI.js
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
class SI {
|
|
2
|
-
/**
|
|
3
|
-
* Utility class for unit conversions.
|
|
4
|
-
*/
|
|
5
|
-
static toKnots(metersPerSecond) {
|
|
6
|
-
return metersPerSecond * 1.94384;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
static fromKnots(knots) {
|
|
10
|
-
return knots / 1.94384;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
static toDegrees(radians) {
|
|
14
|
-
return radians * (180 / Math.PI);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
static fromDegrees(degrees) {
|
|
18
|
-
return degrees * (Math.PI / 180);
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
module.exports = SI;
|
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
const SI = require('../SI')
|
|
4
|
-
const { createCanonicalResource } = require('./canonical')
|
|
5
|
-
|
|
6
|
-
function parseNumber(value) {
|
|
7
|
-
if (typeof value !== 'string') return Number.NaN
|
|
8
|
-
const normalized = value.replace(/^"|"$/g, '').trim()
|
|
9
|
-
if (!normalized) return Number.NaN
|
|
10
|
-
return Number(normalized)
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
class ExpeditionImporter {
|
|
14
|
-
constructor() {
|
|
15
|
-
this.id = 'expedition'
|
|
16
|
-
this.name = 'Expedition'
|
|
17
|
-
this.description = 'Expedition polar text with one TWS row followed by repeated TWA/boat-speed pairs and optional inline beat/run target points.'
|
|
18
|
-
this.defaultSource = 'expedition'
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
parse(content) {
|
|
22
|
-
const lines = content
|
|
23
|
-
.replace(/^\uFEFF/, '')
|
|
24
|
-
.split(/\r?\n/)
|
|
25
|
-
.map(line => line.trim())
|
|
26
|
-
.filter(Boolean)
|
|
27
|
-
|
|
28
|
-
if (lines.length === 0) {
|
|
29
|
-
throw new Error('Import text is empty')
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
let sailnumber = null
|
|
33
|
-
let startIndex = 0
|
|
34
|
-
if (lines[0].startsWith('!')) {
|
|
35
|
-
sailnumber = lines[0].slice(1).trim() || null
|
|
36
|
-
startIndex = 1
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const rowData = []
|
|
40
|
-
const angleFrequency = new Map()
|
|
41
|
-
|
|
42
|
-
for (const rawLine of lines.slice(startIndex)) {
|
|
43
|
-
const cells = rawLine.split(/\t+/).map(cell => cell.trim()).filter(cell => cell.length > 0)
|
|
44
|
-
if (cells.length < 3) continue
|
|
45
|
-
|
|
46
|
-
const twsKnots = parseNumber(cells[0])
|
|
47
|
-
if (!Number.isFinite(twsKnots) || twsKnots <= 0) {
|
|
48
|
-
throw new Error(`Invalid Expedition TWS value '${cells[0]}'`)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
if ((cells.length - 1) % 2 !== 0) {
|
|
52
|
-
throw new Error(`Expedition row for TWS ${cells[0]} has an incomplete TWA/BSP pair`)
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const pairs = []
|
|
56
|
-
for (let index = 1; index < cells.length; index += 2) {
|
|
57
|
-
const angleDeg = parseNumber(cells[index])
|
|
58
|
-
const speedKnots = parseNumber(cells[index + 1])
|
|
59
|
-
if (!Number.isFinite(angleDeg) || angleDeg < 0 || angleDeg > 180) {
|
|
60
|
-
throw new Error(`Invalid Expedition TWA value '${cells[index]}'`)
|
|
61
|
-
}
|
|
62
|
-
if (!Number.isFinite(speedKnots) || speedKnots < 0) {
|
|
63
|
-
throw new Error(`Invalid Expedition boat speed value '${cells[index + 1]}'`)
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const angleKey = angleDeg.toFixed(6)
|
|
67
|
-
angleFrequency.set(angleKey, (angleFrequency.get(angleKey) || 0) + 1)
|
|
68
|
-
pairs.push({
|
|
69
|
-
angleDeg,
|
|
70
|
-
angleRad: SI.fromDegrees(angleDeg),
|
|
71
|
-
speed: SI.fromKnots(speedKnots)
|
|
72
|
-
})
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
rowData.push({
|
|
76
|
-
tws: SI.fromKnots(twsKnots),
|
|
77
|
-
pairs
|
|
78
|
-
})
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (rowData.length === 0) {
|
|
82
|
-
throw new Error('No Expedition polar rows were found')
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const commonAngleKeys = new Set(
|
|
86
|
-
Array.from(angleFrequency.entries())
|
|
87
|
-
.filter(([, count]) => count > 1)
|
|
88
|
-
.map(([key]) => key)
|
|
89
|
-
)
|
|
90
|
-
|
|
91
|
-
const twaAxis = Array.from(commonAngleKeys)
|
|
92
|
-
.map(key => SI.fromDegrees(Number(key)))
|
|
93
|
-
.sort((left, right) => left - right)
|
|
94
|
-
|
|
95
|
-
if (twaAxis.length === 0) {
|
|
96
|
-
throw new Error('No repeated Expedition TWA grid was detected')
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const boatSpeedMatrix = []
|
|
100
|
-
const derivedRows = []
|
|
101
|
-
|
|
102
|
-
rowData.forEach((row) => {
|
|
103
|
-
const pairByKey = new Map(row.pairs.map(pair => [pair.angleDeg.toFixed(6), pair]))
|
|
104
|
-
const uncommonPairs = row.pairs.filter(pair => !commonAngleKeys.has(pair.angleDeg.toFixed(6)) && pair.speed > 0)
|
|
105
|
-
const beatPair = uncommonPairs.length ? uncommonPairs[0] : null
|
|
106
|
-
const runPair = uncommonPairs.length > 1 ? uncommonPairs[uncommonPairs.length - 1] : null
|
|
107
|
-
|
|
108
|
-
boatSpeedMatrix.push(
|
|
109
|
-
twaAxis.map((angleRad) => {
|
|
110
|
-
const pair = pairByKey.get(SI.toDegrees(angleRad).toFixed(6))
|
|
111
|
-
return pair ? pair.speed : 0
|
|
112
|
-
})
|
|
113
|
-
)
|
|
114
|
-
|
|
115
|
-
if (beatPair || runPair) {
|
|
116
|
-
derivedRows.push({
|
|
117
|
-
tws: row.tws,
|
|
118
|
-
...(beatPair ? {
|
|
119
|
-
beat: {
|
|
120
|
-
twa: beatPair.angleRad,
|
|
121
|
-
tbs: beatPair.speed,
|
|
122
|
-
vmg: beatPair.speed * Math.abs(Math.cos(beatPair.angleRad))
|
|
123
|
-
}
|
|
124
|
-
} : {}),
|
|
125
|
-
...(runPair ? {
|
|
126
|
-
run: {
|
|
127
|
-
twa: runPair.angleRad,
|
|
128
|
-
tbs: runPair.speed,
|
|
129
|
-
vmg: runPair.speed * Math.abs(Math.cos(runPair.angleRad))
|
|
130
|
-
}
|
|
131
|
-
} : {})
|
|
132
|
-
})
|
|
133
|
-
}
|
|
134
|
-
})
|
|
135
|
-
|
|
136
|
-
return {
|
|
137
|
-
resource: {
|
|
138
|
-
...createCanonicalResource({
|
|
139
|
-
axes: {
|
|
140
|
-
tws: rowData.map(row => row.tws),
|
|
141
|
-
twa: twaAxis
|
|
142
|
-
},
|
|
143
|
-
values: { boatSpeedMatrix },
|
|
144
|
-
...(derivedRows.length ? { derived: { rows: derivedRows } } : {})
|
|
145
|
-
}),
|
|
146
|
-
...(sailnumber ? { sailnumber } : {})
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
module.exports = ExpeditionImporter
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
class FormatRegistry {
|
|
4
|
-
constructor(importers = []) {
|
|
5
|
-
this.importers = new Map(importers.map(importer => [importer.id, importer]))
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
get(id) {
|
|
9
|
-
return this.importers.get(id)
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
list() {
|
|
13
|
-
return Array.from(this.importers.values()).map(importer => ({
|
|
14
|
-
id: importer.id,
|
|
15
|
-
name: importer.name,
|
|
16
|
-
description: importer.description
|
|
17
|
-
}))
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
module.exports = FormatRegistry
|
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
const FormatRegistry = require('./FormatRegistry')
|
|
4
|
-
const SourceRegistry = require('./SourceRegistry')
|
|
5
|
-
const OrcSource = require('./OrcSource')
|
|
6
|
-
const JieterImporter = require('./JieterImporter')
|
|
7
|
-
const ExpeditionImporter = require('./ExpeditionImporter')
|
|
8
|
-
const { applyMetadata, validateCanonicalPolarResourceBody } = require('./canonical')
|
|
9
|
-
|
|
10
|
-
class ImportError extends Error {
|
|
11
|
-
constructor(status, message) {
|
|
12
|
-
super(message)
|
|
13
|
-
this.status = status
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function autoId(value, fallback = 'imported-polar') {
|
|
18
|
-
const slug = String(value || '')
|
|
19
|
-
.trim()
|
|
20
|
-
.toLowerCase()
|
|
21
|
-
.replace(/[^a-z0-9._-]+/g, '-')
|
|
22
|
-
.replace(/-+/g, '-')
|
|
23
|
-
.replace(/^-|-$/g, '')
|
|
24
|
-
return slug || fallback
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function timestampIdPart(now = Date.now()) {
|
|
28
|
-
const date = now instanceof Date ? now : new Date(now)
|
|
29
|
-
const pad = value => String(value).padStart(2, '0')
|
|
30
|
-
return [
|
|
31
|
-
date.getUTCFullYear(),
|
|
32
|
-
pad(date.getUTCMonth() + 1),
|
|
33
|
-
pad(date.getUTCDate()),
|
|
34
|
-
't',
|
|
35
|
-
pad(date.getUTCHours()),
|
|
36
|
-
pad(date.getUTCMinutes()),
|
|
37
|
-
pad(date.getUTCSeconds()),
|
|
38
|
-
'z'
|
|
39
|
-
].join('')
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function createTimestampedId(baseValue, fallback, exists, now) {
|
|
43
|
-
const baseId = autoId(baseValue, fallback)
|
|
44
|
-
const timestampedBaseId = `${baseId}-${timestampIdPart(now())}`
|
|
45
|
-
let id = timestampedBaseId
|
|
46
|
-
let suffix = 2
|
|
47
|
-
while (exists(id)) {
|
|
48
|
-
id = `${timestampedBaseId}-${suffix}`
|
|
49
|
-
suffix += 1
|
|
50
|
-
}
|
|
51
|
-
return id
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function validateRequestedId(id) {
|
|
55
|
-
if (typeof id !== 'string' || !id.trim()) {
|
|
56
|
-
throw new ImportError(400, "'id' must be a non-empty string when provided")
|
|
57
|
-
}
|
|
58
|
-
if (!/^[A-Za-z0-9._-]+$/.test(id.trim())) {
|
|
59
|
-
throw new ImportError(400, "'id' may contain only letters, numbers, '.', '_' and '-' characters")
|
|
60
|
-
}
|
|
61
|
-
return id.trim()
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
class ImportService {
|
|
65
|
-
constructor(store, options = {}) {
|
|
66
|
-
this.store = store
|
|
67
|
-
this.now = options.now || (() => Date.now())
|
|
68
|
-
this.registry = new FormatRegistry([
|
|
69
|
-
new JieterImporter(),
|
|
70
|
-
new ExpeditionImporter()
|
|
71
|
-
])
|
|
72
|
-
this.sourceRegistry = new SourceRegistry([
|
|
73
|
-
new OrcSource({
|
|
74
|
-
dataDir: store.dataDir,
|
|
75
|
-
fetcher: options.fetcher,
|
|
76
|
-
now: this.now
|
|
77
|
-
})
|
|
78
|
-
])
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
listFormats() {
|
|
82
|
-
return this.registry.list()
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
listSources() {
|
|
86
|
-
return this.sourceRegistry.list().map(descriptor => ({ ...descriptor }))
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
importText(formatId, body) {
|
|
90
|
-
if (!body || typeof body !== 'object') {
|
|
91
|
-
throw new ImportError(400, 'Expected a JSON object')
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
if (typeof body.content !== 'string' || !body.content.trim()) {
|
|
95
|
-
throw new ImportError(400, "'content' is required")
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const importer = this.registry.get(formatId)
|
|
99
|
-
if (!importer) {
|
|
100
|
-
throw new ImportError(400, `Unsupported import format: ${formatId}`)
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
let parsed
|
|
104
|
-
try {
|
|
105
|
-
parsed = importer.parse(body.content)
|
|
106
|
-
} catch (error) {
|
|
107
|
-
throw new ImportError(400, error.message)
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const resource = applyMetadata(
|
|
111
|
-
parsed.resource,
|
|
112
|
-
{ ...body, source: body.source || importer.defaultSource },
|
|
113
|
-
body.name || importer.name
|
|
114
|
-
)
|
|
115
|
-
|
|
116
|
-
const validationError = validateCanonicalPolarResourceBody(resource)
|
|
117
|
-
if (validationError) {
|
|
118
|
-
throw new ImportError(400, validationError)
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const id = createTimestampedId(
|
|
122
|
-
resource.sailnumber || resource.name || formatId,
|
|
123
|
-
`${formatId}-polar`,
|
|
124
|
-
(candidate) => this.store.exists(candidate),
|
|
125
|
-
this.now
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
this.store.saveCanonical(id, resource)
|
|
129
|
-
return { id, resource: { ...resource, id } }
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
async searchSource(sourceId, query) {
|
|
133
|
-
const source = this.sourceRegistry.get(sourceId)
|
|
134
|
-
if (!source) {
|
|
135
|
-
throw new ImportError(404, `Unsupported import source: ${sourceId}`)
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
try {
|
|
139
|
-
return await source.search(query)
|
|
140
|
-
} catch (error) {
|
|
141
|
-
if (Number.isInteger(error.status)) {
|
|
142
|
-
throw new ImportError(error.status, error.message)
|
|
143
|
-
}
|
|
144
|
-
throw error
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
async importSource(sourceId, externalId, body) {
|
|
149
|
-
const source = this.sourceRegistry.get(sourceId)
|
|
150
|
-
if (!source) {
|
|
151
|
-
throw new ImportError(404, `Unsupported import source: ${sourceId}`)
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
if (body != null && typeof body !== 'object') {
|
|
155
|
-
throw new ImportError(400, 'Expected a JSON object')
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
if (body && Object.keys(body).length > 0) {
|
|
159
|
-
throw new ImportError(400, 'External source imports do not accept metadata overrides')
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
let parsed
|
|
163
|
-
try {
|
|
164
|
-
parsed = await source.importByExternalId(externalId)
|
|
165
|
-
} catch (error) {
|
|
166
|
-
if (Number.isInteger(error.status)) {
|
|
167
|
-
throw new ImportError(error.status, error.message)
|
|
168
|
-
}
|
|
169
|
-
throw error
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const resource = applyMetadata(
|
|
173
|
-
parsed.resource,
|
|
174
|
-
{ source: parsed.resource.source || source.defaultSource },
|
|
175
|
-
parsed.fallbackName || source.name
|
|
176
|
-
)
|
|
177
|
-
|
|
178
|
-
const validationError = validateCanonicalPolarResourceBody(resource)
|
|
179
|
-
if (validationError) {
|
|
180
|
-
throw new ImportError(400, validationError)
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const id = (() => {
|
|
184
|
-
const baseId = autoId(externalId || resource.name || resource.sailnumber, `${sourceId}-polar`)
|
|
185
|
-
let candidate = baseId
|
|
186
|
-
let suffix = 2
|
|
187
|
-
while (this.store.exists(candidate)) {
|
|
188
|
-
candidate = `${baseId}-${suffix}`
|
|
189
|
-
suffix += 1
|
|
190
|
-
}
|
|
191
|
-
return candidate
|
|
192
|
-
})()
|
|
193
|
-
|
|
194
|
-
this.store.saveCanonical(id, resource)
|
|
195
|
-
return { id, resource: { ...resource, id } }
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
module.exports = { ImportService, ImportError, autoId, timestampIdPart, createTimestampedId }
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
const { parseMatrixPolarText } = require('./matrixText')
|
|
4
|
-
|
|
5
|
-
class JieterImporter {
|
|
6
|
-
constructor() {
|
|
7
|
-
this.id = 'jieter'
|
|
8
|
-
this.name = 'Jieter'
|
|
9
|
-
this.description = 'Semicolon-delimited Jieter/ORC table with TWS header, TWA rows, and optional beat/run target rows.'
|
|
10
|
-
this.defaultSource = 'jieter'
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
parse(content) {
|
|
14
|
-
return parseMatrixPolarText(content, { preferredDelimiters: [';'] })
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
module.exports = JieterImporter
|