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
|
@@ -1,396 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
const fs = require('fs')
|
|
4
|
-
const path = require('path')
|
|
5
|
-
|
|
6
|
-
const ACTIVE_CERTIFICATES_URL = 'https://data.orc.org/public/WPub.dll?action=activecerts'
|
|
7
|
-
const CERTIFICATE_URL_PREFIX = 'https://data.orc.org/public/WPub.dll/CC/'
|
|
8
|
-
const DEFAULT_CACHE_TTL_MS = 12 * 60 * 60 * 1000
|
|
9
|
-
const MAX_SEARCH_RESULTS = 100
|
|
10
|
-
const KNOT_TO_MPS = 0.514444
|
|
11
|
-
const DEG_TO_RAD = Math.PI / 180
|
|
12
|
-
|
|
13
|
-
function sourceError(status, message) {
|
|
14
|
-
const error = new Error(message)
|
|
15
|
-
error.status = status
|
|
16
|
-
return error
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function ensureDir(dirPath) {
|
|
20
|
-
if (!fs.existsSync(dirPath)) {
|
|
21
|
-
fs.mkdirSync(dirPath, { recursive: true })
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function decodeEntities(value) {
|
|
26
|
-
return String(value || '')
|
|
27
|
-
.replace(/ /g, ' ')
|
|
28
|
-
.replace(/&/g, '&')
|
|
29
|
-
.replace(/</g, '<')
|
|
30
|
-
.replace(/>/g, '>')
|
|
31
|
-
.replace(/"/g, '"')
|
|
32
|
-
.replace(/'/g, "'")
|
|
33
|
-
.replace(/&#(\d+);/g, (_match, code) => String.fromCharCode(Number(code)))
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function stripTags(value) {
|
|
37
|
-
return decodeEntities(String(value || '').replace(/<[^>]+>/g, ''))
|
|
38
|
-
.replace(/\s+/g, ' ')
|
|
39
|
-
.trim()
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function normalize(value) {
|
|
43
|
-
return String(value || '').trim().toLowerCase()
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function parseNumber(value) {
|
|
47
|
-
const number = Number.parseFloat(String(value || '').replace(/[^0-9.+-]/g, ''))
|
|
48
|
-
return Number.isFinite(number) ? number : null
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function parseYearText(value) {
|
|
52
|
-
const match = String(value || '').match(/(19|20)\d{2}/)
|
|
53
|
-
if (!match) return undefined
|
|
54
|
-
const year = Number.parseInt(match[0], 10)
|
|
55
|
-
return Number.isInteger(year) ? year : undefined
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function parseXmlRows(xmlText) {
|
|
59
|
-
const rows = []
|
|
60
|
-
const rowMatches = xmlText.matchAll(/<ROW\b[^>]*>([\s\S]*?)<\/ROW>/g)
|
|
61
|
-
for (const rowMatch of rowMatches) {
|
|
62
|
-
const body = rowMatch[1]
|
|
63
|
-
const row = {}
|
|
64
|
-
for (const fieldMatch of body.matchAll(/<([A-Za-z0-9_:-]+)>([\s\S]*?)<\/\1>/g)) {
|
|
65
|
-
row[fieldMatch[1]] = decodeEntities(fieldMatch[2]).trim()
|
|
66
|
-
}
|
|
67
|
-
rows.push(row)
|
|
68
|
-
}
|
|
69
|
-
return rows
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function parseTableRows(tableHtml) {
|
|
73
|
-
return [...tableHtml.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/g)].map(match => {
|
|
74
|
-
const cells = [...match[1].matchAll(/<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/g)]
|
|
75
|
-
.map(cellMatch => stripTags(cellMatch[1]))
|
|
76
|
-
return cells.filter(cell => cell.length > 0)
|
|
77
|
-
}).filter(cells => cells.length > 0)
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function matrixFromAngleRows(angleRows, twsCount) {
|
|
81
|
-
return Array.from({ length: twsCount }, (_unused, twsIndex) => angleRows.map(row => row.values[twsIndex]))
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function createDerivedRows(twsValues, twaValues, speedMatrix, beatAngles, beatVmgs, runAngles, runVmgs) {
|
|
85
|
-
return {
|
|
86
|
-
rows: twsValues.map((tws, index) => {
|
|
87
|
-
const speedRow = speedMatrix[index]
|
|
88
|
-
const maxSpeed = Math.max(...speedRow)
|
|
89
|
-
const maxSpeedAngle = twaValues[speedRow.indexOf(maxSpeed)]
|
|
90
|
-
const beatAngle = beatAngles[index]
|
|
91
|
-
const beatVmg = beatVmgs[index]
|
|
92
|
-
const runAngle = runAngles[index]
|
|
93
|
-
const runVmg = runVmgs[index]
|
|
94
|
-
|
|
95
|
-
const beat = Number.isFinite(beatAngle) && Number.isFinite(beatVmg)
|
|
96
|
-
? {
|
|
97
|
-
twa: beatAngle,
|
|
98
|
-
tbs: beatVmg / Math.abs(Math.cos(beatAngle)),
|
|
99
|
-
vmg: beatVmg
|
|
100
|
-
}
|
|
101
|
-
: null
|
|
102
|
-
|
|
103
|
-
const run = Number.isFinite(runAngle) && Number.isFinite(runVmg)
|
|
104
|
-
? {
|
|
105
|
-
twa: runAngle,
|
|
106
|
-
tbs: runVmg / Math.abs(Math.cos(runAngle)),
|
|
107
|
-
vmg: runVmg
|
|
108
|
-
}
|
|
109
|
-
: null
|
|
110
|
-
|
|
111
|
-
return {
|
|
112
|
-
tws,
|
|
113
|
-
...(beat ? { beat } : {}),
|
|
114
|
-
...(run ? { run } : {}),
|
|
115
|
-
maxSpeed,
|
|
116
|
-
maxSpeedAngle
|
|
117
|
-
}
|
|
118
|
-
})
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function extractFieldText(htmlText, fieldId) {
|
|
123
|
-
const pattern = new RegExp(`<span class="data"[^>]*id="${fieldId}"[^>]*>([\\s\\S]*?)<\\/span>`, 'i')
|
|
124
|
-
const match = htmlText.match(pattern)
|
|
125
|
-
return match ? stripTags(match[1]) : ''
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function extractCertificateYear(htmlText, fallbackYear) {
|
|
129
|
-
const seriesYear = parseYearText(extractFieldText(htmlText, 'field_Series_VPP'))
|
|
130
|
-
if (Number.isInteger(seriesYear)) return seriesYear
|
|
131
|
-
const ageYear = parseYearText(extractFieldText(htmlText, 'field_AgeDt'))
|
|
132
|
-
if (Number.isInteger(ageYear)) return ageYear
|
|
133
|
-
return Number.isInteger(fallbackYear) ? fallbackYear : undefined
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function withTimeout(promise, timeoutMs, message) {
|
|
137
|
-
let timer = null
|
|
138
|
-
const timeout = new Promise((_, reject) => {
|
|
139
|
-
timer = setTimeout(() => reject(sourceError(503, message)), timeoutMs)
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
return Promise.race([promise, timeout]).finally(() => {
|
|
143
|
-
if (timer) clearTimeout(timer)
|
|
144
|
-
})
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
class OrcSource {
|
|
148
|
-
constructor({
|
|
149
|
-
dataDir,
|
|
150
|
-
fetcher,
|
|
151
|
-
now = () => Date.now(),
|
|
152
|
-
cacheTtlMs = DEFAULT_CACHE_TTL_MS
|
|
153
|
-
} = {}) {
|
|
154
|
-
this.fetcher = typeof fetcher === 'function'
|
|
155
|
-
? fetcher
|
|
156
|
-
: (...args) => {
|
|
157
|
-
if (typeof globalThis.fetch !== 'function') {
|
|
158
|
-
throw new Error('A fetch implementation is required for ORC source imports')
|
|
159
|
-
}
|
|
160
|
-
return globalThis.fetch(...args)
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (typeof this.fetcher !== 'function') {
|
|
164
|
-
throw new Error('A fetch implementation is required for ORC source imports')
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
this.id = 'orc'
|
|
168
|
-
this.name = 'ORC Active Certificates'
|
|
169
|
-
this.defaultSource = 'orc'
|
|
170
|
-
this.now = now
|
|
171
|
-
this.cacheTtlMs = cacheTtlMs
|
|
172
|
-
this.cacheDir = path.join(dataDir, 'import-cache')
|
|
173
|
-
this.activeCachePath = path.join(this.cacheDir, 'orc-activecerts.json')
|
|
174
|
-
ensureDir(this.cacheDir)
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
descriptor() {
|
|
178
|
-
return {
|
|
179
|
-
id: this.id,
|
|
180
|
-
name: this.name,
|
|
181
|
-
description: 'Official ORC active certificates and certificate pages',
|
|
182
|
-
url: ACTIVE_CERTIFICATES_URL
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
async search(query = '') {
|
|
187
|
-
const entries = await this.getActiveCertificates()
|
|
188
|
-
const needle = normalize(query)
|
|
189
|
-
const matches = needle
|
|
190
|
-
? entries.filter(entry => this.matchesQuery(entry, needle))
|
|
191
|
-
: entries
|
|
192
|
-
|
|
193
|
-
return matches.slice(0, MAX_SEARCH_RESULTS).map(entry => ({
|
|
194
|
-
externalId: entry.refNo,
|
|
195
|
-
name: entry.yachtName || entry.refNo,
|
|
196
|
-
sailnumber: entry.sailNo || undefined,
|
|
197
|
-
boatType: entry.boatClass || undefined,
|
|
198
|
-
year: entry.vppYear,
|
|
199
|
-
source: this.id,
|
|
200
|
-
dxtId: entry.dxtId,
|
|
201
|
-
countryId: entry.countryId,
|
|
202
|
-
certificateName: entry.certName || undefined,
|
|
203
|
-
familyName: entry.familyName || undefined
|
|
204
|
-
}))
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
async importByExternalId(externalId) {
|
|
208
|
-
const refNo = String(externalId || '').trim()
|
|
209
|
-
if (!refNo) {
|
|
210
|
-
throw sourceError(400, 'External ORC id is required')
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
const entries = await this.getActiveCertificates()
|
|
214
|
-
const entry = entries.find(candidate => candidate.refNo === refNo)
|
|
215
|
-
if (!entry) {
|
|
216
|
-
throw sourceError(404, `ORC certificate not found: ${refNo}`)
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const certificateHtml = await this.fetchText(`${CERTIFICATE_URL_PREFIX}${entry.dxtId}`, `ORC certificate ${entry.dxtId}`)
|
|
220
|
-
return {
|
|
221
|
-
resource: this.parseCertificateHtml(certificateHtml, entry),
|
|
222
|
-
fallbackName: entry.yachtName || refNo
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
matchesQuery(entry, needle) {
|
|
227
|
-
return [
|
|
228
|
-
entry.refNo,
|
|
229
|
-
entry.yachtName,
|
|
230
|
-
entry.sailNo,
|
|
231
|
-
entry.boatClass,
|
|
232
|
-
entry.countryId,
|
|
233
|
-
entry.certName,
|
|
234
|
-
entry.familyName,
|
|
235
|
-
entry.vppYear
|
|
236
|
-
].some(value => normalize(value).includes(needle))
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
async getActiveCertificates() {
|
|
240
|
-
const cached = this.readCache()
|
|
241
|
-
const isFresh = cached && (this.now() - cached.fetchedAtMs) < this.cacheTtlMs
|
|
242
|
-
if (isFresh) {
|
|
243
|
-
return cached.entries
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
try {
|
|
247
|
-
return await this.refreshActiveCertificates()
|
|
248
|
-
} catch (error) {
|
|
249
|
-
if (cached?.entries?.length) {
|
|
250
|
-
return cached.entries
|
|
251
|
-
}
|
|
252
|
-
throw error
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
readCache() {
|
|
257
|
-
try {
|
|
258
|
-
if (!fs.existsSync(this.activeCachePath)) return null
|
|
259
|
-
const payload = JSON.parse(fs.readFileSync(this.activeCachePath, 'utf8'))
|
|
260
|
-
if (!Array.isArray(payload.entries) || !Number.isFinite(payload.fetchedAtMs)) return null
|
|
261
|
-
return payload
|
|
262
|
-
} catch (_error) {
|
|
263
|
-
return null
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
writeCache(entries) {
|
|
268
|
-
fs.writeFileSync(this.activeCachePath, JSON.stringify({
|
|
269
|
-
fetchedAtMs: this.now(),
|
|
270
|
-
entries
|
|
271
|
-
}, null, 2), 'utf8')
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
async refreshActiveCertificates() {
|
|
275
|
-
const xmlText = await this.fetchText(ACTIVE_CERTIFICATES_URL, 'ORC active certificates index')
|
|
276
|
-
const entries = this.parseActiveCertificates(xmlText)
|
|
277
|
-
this.writeCache(entries)
|
|
278
|
-
return entries
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
async fetchText(url, label) {
|
|
282
|
-
let response
|
|
283
|
-
try {
|
|
284
|
-
response = await this.fetcher(url)
|
|
285
|
-
} catch (error) {
|
|
286
|
-
throw sourceError(503, `External source unavailable while fetching ${label}: ${error.message}`)
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
if (!response || typeof response.text !== 'function') {
|
|
290
|
-
throw sourceError(502, `Invalid response from ${label}`)
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
if (!response.ok) {
|
|
294
|
-
const status = response.status === 404 ? 404 : 502
|
|
295
|
-
throw sourceError(status, `Failed to fetch ${label}: HTTP ${response.status}`)
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
return response.text()
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
parseActiveCertificates(xmlText) {
|
|
302
|
-
const rows = parseXmlRows(xmlText)
|
|
303
|
-
if (!rows.length) {
|
|
304
|
-
throw sourceError(502, 'ORC active certificates response did not contain any rows')
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
return rows
|
|
308
|
-
.filter(row => row.RefNo && row.dxtID)
|
|
309
|
-
.map(row => ({
|
|
310
|
-
refNo: row.RefNo.trim(),
|
|
311
|
-
dxtId: row.dxtID.trim(),
|
|
312
|
-
countryId: row.CountryId?.trim() || '',
|
|
313
|
-
yachtName: row.YachtName?.trim() || '',
|
|
314
|
-
sailNo: row.SailNo?.trim() || '',
|
|
315
|
-
vppYear: Number.parseInt(row.VPPYear, 10) || undefined,
|
|
316
|
-
boatClass: row.Class?.trim() || '',
|
|
317
|
-
certName: row.CertName?.trim() || '',
|
|
318
|
-
familyName: row.FamilyName?.trim() || '',
|
|
319
|
-
issuedAt: row.dxtDate?.trim() || '',
|
|
320
|
-
expiry: row.Expiry?.trim() || ''
|
|
321
|
-
}))
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
parseCertificateHtml(htmlText, entry) {
|
|
325
|
-
const tableMatch = htmlText.match(/<table class="boatspeeds">([\s\S]*?)<\/table>/i)
|
|
326
|
-
if (!tableMatch) {
|
|
327
|
-
throw sourceError(502, `ORC certificate page did not contain a boatspeeds table for ${entry.refNo}`)
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
const rows = parseTableRows(tableMatch[0])
|
|
331
|
-
const windRow = rows.find(cells => normalize(cells[0]) === 'wind velocity')
|
|
332
|
-
const beatAnglesRow = rows.find(cells => normalize(cells[0]) === 'beat angles')
|
|
333
|
-
const beatVmgRow = rows.find(cells => normalize(cells[0]) === 'beat vmg')
|
|
334
|
-
const runVmgRow = rows.find(cells => normalize(cells[0]) === 'run vmg')
|
|
335
|
-
const gybeAnglesRow = rows.find(cells => normalize(cells[0]) === 'gybe angles')
|
|
336
|
-
const angleRows = rows.filter(cells => /^\d+(?:\.\d+)?°$/.test(cells[0]))
|
|
337
|
-
|
|
338
|
-
if (!windRow || !beatAnglesRow || !beatVmgRow || !runVmgRow || !gybeAnglesRow || angleRows.length === 0) {
|
|
339
|
-
throw sourceError(502, `ORC certificate page structure was incomplete for ${entry.refNo}`)
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
const twsValues = windRow.slice(1).map(parseNumber).map(value => value * KNOT_TO_MPS)
|
|
343
|
-
const twaValues = angleRows.map(row => parseNumber(row[0]) * DEG_TO_RAD)
|
|
344
|
-
const beatAngles = beatAnglesRow.slice(1).map(parseNumber).map(value => value * DEG_TO_RAD)
|
|
345
|
-
const beatVmgs = beatVmgRow.slice(1).map(parseNumber).map(value => value * KNOT_TO_MPS)
|
|
346
|
-
const runVmgs = runVmgRow.slice(1).map(parseNumber).map(value => value * KNOT_TO_MPS)
|
|
347
|
-
const runAngles = gybeAnglesRow.slice(1).map(parseNumber).map(value => value * DEG_TO_RAD)
|
|
348
|
-
const angleSpeedRows = angleRows.map(row => ({
|
|
349
|
-
twa: parseNumber(row[0]) * DEG_TO_RAD,
|
|
350
|
-
values: row.slice(1).map(parseNumber).map(value => value * KNOT_TO_MPS)
|
|
351
|
-
}))
|
|
352
|
-
|
|
353
|
-
if (
|
|
354
|
-
twsValues.some(value => !Number.isFinite(value)) ||
|
|
355
|
-
twaValues.some(value => !Number.isFinite(value)) ||
|
|
356
|
-
beatAngles.some(value => !Number.isFinite(value)) ||
|
|
357
|
-
beatVmgs.some(value => !Number.isFinite(value)) ||
|
|
358
|
-
runAngles.some(value => !Number.isFinite(value)) ||
|
|
359
|
-
runVmgs.some(value => !Number.isFinite(value)) ||
|
|
360
|
-
angleSpeedRows.some(row => row.values.length !== twsValues.length || row.values.some(value => !Number.isFinite(value)))
|
|
361
|
-
) {
|
|
362
|
-
throw sourceError(502, `ORC certificate page contained invalid numeric data for ${entry.refNo}`)
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
const boatSpeedMatrix = matrixFromAngleRows(angleSpeedRows, twsValues.length)
|
|
366
|
-
const certificateYear = extractCertificateYear(htmlText, entry.vppYear)
|
|
367
|
-
|
|
368
|
-
return {
|
|
369
|
-
kind: 'polarTable',
|
|
370
|
-
schemaVersion: '1.0.0',
|
|
371
|
-
name: entry.yachtName || entry.refNo,
|
|
372
|
-
...(entry.sailNo ? { sailnumber: entry.sailNo.replace(/\s+/g, '') } : {}),
|
|
373
|
-
...(entry.boatClass ? { boatType: entry.boatClass } : {}),
|
|
374
|
-
...(Number.isInteger(certificateYear) ? { year: certificateYear } : {}),
|
|
375
|
-
source: this.defaultSource,
|
|
376
|
-
units: {
|
|
377
|
-
tws: 'm/s',
|
|
378
|
-
twa: 'rad',
|
|
379
|
-
boatSpeed: 'm/s'
|
|
380
|
-
},
|
|
381
|
-
symmetry: {
|
|
382
|
-
portStarboardSymmetric: true
|
|
383
|
-
},
|
|
384
|
-
axes: {
|
|
385
|
-
tws: twsValues,
|
|
386
|
-
twa: twaValues
|
|
387
|
-
},
|
|
388
|
-
values: {
|
|
389
|
-
boatSpeedMatrix
|
|
390
|
-
},
|
|
391
|
-
derived: createDerivedRows(twsValues, twaValues, boatSpeedMatrix, beatAngles, beatVmgs, runAngles, runVmgs)
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
module.exports = OrcSource
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
class SourceRegistry {
|
|
4
|
-
constructor(sources = []) {
|
|
5
|
-
this.sources = new Map(sources.map(source => [source.id, source]))
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
get(id) {
|
|
9
|
-
return this.sources.get(id) || null
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
list() {
|
|
13
|
-
return [...this.sources.values()].map(source => source.descriptor())
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
module.exports = SourceRegistry
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
const DEFAULT_SCHEMA_VERSION = '1.0.0'
|
|
4
|
-
|
|
5
|
-
function createCanonicalResource({ axes, values, derived }) {
|
|
6
|
-
return {
|
|
7
|
-
kind: 'polarTable',
|
|
8
|
-
schemaVersion: DEFAULT_SCHEMA_VERSION,
|
|
9
|
-
units: {
|
|
10
|
-
tws: 'm/s',
|
|
11
|
-
twa: 'rad',
|
|
12
|
-
boatSpeed: 'm/s'
|
|
13
|
-
},
|
|
14
|
-
symmetry: {
|
|
15
|
-
portStarboardSymmetric: true
|
|
16
|
-
},
|
|
17
|
-
axes,
|
|
18
|
-
values,
|
|
19
|
-
...(derived ? { derived } : {})
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function applyMetadata(resource, metadata = {}, fallbackName = '') {
|
|
24
|
-
const merged = { ...resource }
|
|
25
|
-
const name = typeof metadata.name === 'string' && metadata.name.trim()
|
|
26
|
-
? metadata.name.trim()
|
|
27
|
-
: (resource.name || fallbackName)
|
|
28
|
-
|
|
29
|
-
if (name) merged.name = name
|
|
30
|
-
if (typeof metadata.sailnumber === 'string' && metadata.sailnumber.trim()) merged.sailnumber = metadata.sailnumber.trim()
|
|
31
|
-
if (typeof metadata.boatType === 'string' && metadata.boatType.trim()) merged.boatType = metadata.boatType.trim()
|
|
32
|
-
if (Number.isInteger(metadata.year)) merged.year = metadata.year
|
|
33
|
-
if (typeof metadata.source === 'string' && metadata.source.trim()) merged.source = metadata.source.trim()
|
|
34
|
-
if (typeof metadata.notes === 'string' && metadata.notes.trim()) merged.notes = metadata.notes.trim()
|
|
35
|
-
|
|
36
|
-
return merged
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function validateCanonicalPolarResourceBody(resource) {
|
|
40
|
-
if (!resource || typeof resource !== 'object') return 'Expected a JSON object'
|
|
41
|
-
if (resource.kind !== 'polarTable') return "'kind' must be 'polarTable'"
|
|
42
|
-
if (typeof resource.schemaVersion !== 'string' || !resource.schemaVersion.trim()) {
|
|
43
|
-
return "'schemaVersion' is required"
|
|
44
|
-
}
|
|
45
|
-
if (resource.units?.tws !== 'm/s' || resource.units?.twa !== 'rad' || resource.units?.boatSpeed !== 'm/s') {
|
|
46
|
-
return 'Only SI units are supported: tws=m/s, twa=rad, boatSpeed=m/s'
|
|
47
|
-
}
|
|
48
|
-
if (resource.symmetry?.portStarboardSymmetric !== true) {
|
|
49
|
-
return 'Only port/starboard symmetric polars are supported'
|
|
50
|
-
}
|
|
51
|
-
if (!Array.isArray(resource.axes?.tws) || resource.axes.tws.length === 0) {
|
|
52
|
-
return "'axes.tws' must be a non-empty array"
|
|
53
|
-
}
|
|
54
|
-
if (!Array.isArray(resource.axes?.twa) || resource.axes.twa.length === 0) {
|
|
55
|
-
return "'axes.twa' must be a non-empty array"
|
|
56
|
-
}
|
|
57
|
-
if (!Array.isArray(resource.values?.boatSpeedMatrix) || resource.values.boatSpeedMatrix.length !== resource.axes.tws.length) {
|
|
58
|
-
return "'values.boatSpeedMatrix' row count must match 'axes.tws'"
|
|
59
|
-
}
|
|
60
|
-
for (const row of resource.values.boatSpeedMatrix) {
|
|
61
|
-
if (!Array.isArray(row) || row.length !== resource.axes.twa.length) {
|
|
62
|
-
return "Each 'boatSpeedMatrix' row must match 'axes.twa' length"
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
return null
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
module.exports = {
|
|
69
|
-
DEFAULT_SCHEMA_VERSION,
|
|
70
|
-
createCanonicalResource,
|
|
71
|
-
applyMetadata,
|
|
72
|
-
validateCanonicalPolarResourceBody
|
|
73
|
-
}
|
|
@@ -1,133 +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
|
-
function detectDelimiter(line, preferredDelimiters) {
|
|
14
|
-
let bestDelimiter = null
|
|
15
|
-
let bestScore = -1
|
|
16
|
-
|
|
17
|
-
preferredDelimiters.forEach((delimiter) => {
|
|
18
|
-
const score = line.split(delimiter).length
|
|
19
|
-
if (score > bestScore) {
|
|
20
|
-
bestScore = score
|
|
21
|
-
bestDelimiter = delimiter
|
|
22
|
-
}
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
return bestScore > 1 ? bestDelimiter : null
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function toRows(content, preferredDelimiters) {
|
|
29
|
-
const lines = content
|
|
30
|
-
.replace(/^\uFEFF/, '')
|
|
31
|
-
.split(/\r?\n/)
|
|
32
|
-
.map(line => line.trim())
|
|
33
|
-
.filter(line => line && !line.startsWith('#') && !line.startsWith('//'))
|
|
34
|
-
|
|
35
|
-
if (lines.length === 0) {
|
|
36
|
-
throw new Error('Import text is empty')
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const delimiter = detectDelimiter(lines[0], preferredDelimiters)
|
|
40
|
-
if (!delimiter) {
|
|
41
|
-
throw new Error('Unable to detect a supported field delimiter')
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
return lines.map(line => line.split(delimiter).map(cell => cell.trim()))
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function headerLooksLikeTwsAxis(row) {
|
|
48
|
-
if (!Array.isArray(row) || row.length < 3) return false
|
|
49
|
-
return row.slice(1).every(cell => Number.isFinite(parseNumber(cell)))
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function parseMatrixPolarText(content, options = {}) {
|
|
53
|
-
const rows = toRows(content, options.preferredDelimiters || [';', ',', '\t'])
|
|
54
|
-
const headerIndex = rows.findIndex(headerLooksLikeTwsAxis)
|
|
55
|
-
|
|
56
|
-
if (headerIndex === -1) {
|
|
57
|
-
throw new Error('Expected a header row with TWS values')
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const headerRow = rows[headerIndex]
|
|
61
|
-
const twsAxis = headerRow.slice(1).map((cell) => {
|
|
62
|
-
const knots = parseNumber(cell)
|
|
63
|
-
if (!Number.isFinite(knots) || knots <= 0) {
|
|
64
|
-
throw new Error(`Invalid TWS value '${cell}' in header row`)
|
|
65
|
-
}
|
|
66
|
-
return SI.fromKnots(knots)
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
const angleMap = new Map()
|
|
70
|
-
const derivedMap = new Map(twsAxis.map((tws, index) => [index, { tws }]))
|
|
71
|
-
|
|
72
|
-
for (const row of rows.slice(headerIndex + 1)) {
|
|
73
|
-
if (!Array.isArray(row) || row.length < 2) continue
|
|
74
|
-
|
|
75
|
-
const angleDeg = parseNumber(row[0])
|
|
76
|
-
if (!Number.isFinite(angleDeg)) continue
|
|
77
|
-
|
|
78
|
-
const angleRad = SI.fromDegrees(angleDeg)
|
|
79
|
-
const values = twsAxis.map((_, index) => parseNumber(row[index + 1] || ''))
|
|
80
|
-
const nonZeroEntries = []
|
|
81
|
-
|
|
82
|
-
values.forEach((knots, index) => {
|
|
83
|
-
if (Number.isFinite(knots) && knots > 0) {
|
|
84
|
-
nonZeroEntries.push({ index, knots })
|
|
85
|
-
}
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
if (nonZeroEntries.length === 0) continue
|
|
89
|
-
|
|
90
|
-
const isTargetRow = options.allowTargetRows !== false && nonZeroEntries.length === 1
|
|
91
|
-
if (isTargetRow) {
|
|
92
|
-
const { index, knots } = nonZeroEntries[0]
|
|
93
|
-
const tbs = SI.fromKnots(knots)
|
|
94
|
-
const targetKey = angleRad < (Math.PI / 2) ? 'beat' : 'run'
|
|
95
|
-
derivedMap.set(index, {
|
|
96
|
-
...derivedMap.get(index),
|
|
97
|
-
[targetKey]: {
|
|
98
|
-
twa: angleRad,
|
|
99
|
-
tbs,
|
|
100
|
-
vmg: tbs * Math.abs(Math.cos(angleRad))
|
|
101
|
-
}
|
|
102
|
-
})
|
|
103
|
-
continue
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
angleMap.set(angleRad, values.map(knots => Number.isFinite(knots) && knots > 0 ? SI.fromKnots(knots) : 0))
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const twaAxis = Array.from(angleMap.keys()).sort((left, right) => left - right)
|
|
110
|
-
if (twaAxis.length === 0) {
|
|
111
|
-
throw new Error('No boat speed rows were found in the import text')
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const boatSpeedMatrix = twsAxis.map((_, twsIndex) => {
|
|
115
|
-
const row = twaAxis.map((twa) => angleMap.get(twa)[twsIndex] || 0)
|
|
116
|
-
if (!row.some(value => value > 0)) {
|
|
117
|
-
throw new Error('Each TWS column must contain at least one positive boat speed')
|
|
118
|
-
}
|
|
119
|
-
return row
|
|
120
|
-
})
|
|
121
|
-
|
|
122
|
-
const derivedRows = Array.from(derivedMap.values()).filter((row) => row.beat || row.run)
|
|
123
|
-
|
|
124
|
-
return {
|
|
125
|
-
resource: createCanonicalResource({
|
|
126
|
-
axes: { tws: twsAxis, twa: twaAxis },
|
|
127
|
-
values: { boatSpeedMatrix },
|
|
128
|
-
...(derivedRows.length ? { derived: { rows: derivedRows } } : {})
|
|
129
|
-
})
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
module.exports = { parseMatrixPolarText }
|