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.
@@ -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(/&lt;/g, '<')
30
- .replace(/&gt;/g, '>')
31
- .replace(/&quot;/g, '"')
32
- .replace(/&#39;/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 }