signalk-siparu 0.2.17 → 0.2.18

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/NOTICE CHANGED
@@ -40,3 +40,22 @@ Map data and assets served from the tile host (not bundled in this package):
40
40
  OpenStreetMap contributors - ODbL 1.0 - https://www.openstreetmap.org/copyright
41
41
  Protomaps basemaps - BSD-3-Clause - https://github.com/protomaps/basemaps
42
42
  Noto Sans glyphs - SIL OFL 1.1 - https://github.com/protomaps/basemaps-assets
43
+
44
+ -------------------------------------------------------------------------------
45
+ GeoNames gazetteer - https://www.geonames.org
46
+ Creative Commons Attribution 4.0 - https://creativecommons.org/licenses/by/4.0/
47
+
48
+ plugin/data/places.json.gz is an extract of coastal places (harbours, bays and the
49
+ towns behind them) from the GeoNames dump, built by tools/build-places.mjs. The
50
+ plugin uses it aboard to name where a voyage began and ended.
51
+ Current file: built 2026-10-06 from the GeoNames dump of 2026-10-06 and the World
52
+ Port Index as published on 2026-10-06;
53
+ sha256 7ed46496ab48dde6ed2d6b07878cb7404f659aeb16475ea4e06a96993dc4ac72.
54
+
55
+ -------------------------------------------------------------------------------
56
+ World Port Index (Publication 150) - https://msi.nga.mil
57
+ National Geospatial-Intelligence Agency, United States. A work of the US government,
58
+ in the public domain.
59
+
60
+ Port names and positions from the index are folded into plugin/data/places.json.gz
61
+ where GeoNames does not carry the port under the same name.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-siparu",
3
- "version": "0.2.17",
3
+ "version": "0.2.18",
4
4
  "description": "Kept aboard, proven ashore. Position, wind, depth and voyage history, recorded on the boat and readable from anywhere: sealed to the devices you name, signed on the way out. A read-only Signal K plugin with a built-in dashboard.",
5
5
  "keywords": [
6
6
  "signalk-node-server-plugin",
@@ -9,6 +9,7 @@
9
9
  "main": "plugin/dist/index.js",
10
10
  "files": [
11
11
  "plugin/dist",
12
+ "plugin/data",
12
13
  "public",
13
14
  "NOTICE"
14
15
  ],
Binary file
@@ -60,6 +60,7 @@ const approval_1 = require("./approval");
60
60
  const keystore_1 = require("./keystore");
61
61
  const keysync_1 = require("./keysync");
62
62
  const sealer_1 = require("./sealer");
63
+ const places_1 = require("./places");
63
64
  const voyagelog_1 = require("./voyagelog");
64
65
  const phaselog_1 = require("./phaselog");
65
66
  const PLUGIN_ID = 'siparu';
@@ -303,7 +304,7 @@ module.exports = (app) => {
303
304
  rollups = ru;
304
305
  const qs = new query_1.QueryService(st, ru);
305
306
  query = qs;
306
- const vl = new voyagelog_1.VoyageLog(st, opts, (msg) => app.debug(msg));
307
+ const vl = new voyagelog_1.VoyageLog(st, opts, (msg) => app.debug(msg), places_1.Gazetteer.lazy(places_1.PLACES_FILE, (msg) => app.debug(msg)));
307
308
  voyages = vl;
308
309
  const pl = new phaselog_1.PhaseLog(st, opts, (msg) => app.debug(msg));
309
310
  phases = pl;
@@ -0,0 +1,19 @@
1
+ export type PlaceResolver = (lat: number | null, lon: number | null) => string | null;
2
+ export declare const PLACES_FILE: string;
3
+ export declare const PLACE_RADIUS_NM = 5;
4
+ export declare const WATER_HANDICAP_NM = 1;
5
+ export declare const HAMLET_HANDICAP_NM = 0.75;
6
+ export declare class Gazetteer {
7
+ private readonly lat;
8
+ private readonly lon;
9
+ private readonly kind;
10
+ private readonly names;
11
+ private constructor();
12
+ static parse(text: string): Gazetteer;
13
+ static fromFile(file: string, log?: (msg: string) => void): Gazetteer | null;
14
+ static lazy(file: string, log?: (msg: string) => void, withinNm?: number): PlaceResolver;
15
+ get size(): number;
16
+ nearest(lat: number, lon: number, withinNm?: number): string | null;
17
+ private firstAtOrAbove;
18
+ resolver(withinNm?: number): PlaceResolver;
19
+ }
@@ -0,0 +1,161 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.Gazetteer = exports.HAMLET_HANDICAP_NM = exports.WATER_HANDICAP_NM = exports.PLACE_RADIUS_NM = exports.PLACES_FILE = void 0;
37
+ const fs = __importStar(require("node:fs"));
38
+ const path = __importStar(require("node:path"));
39
+ const node_zlib_1 = require("node:zlib");
40
+ const rollup_1 = require("./rollup");
41
+ const LAND = 0;
42
+ const WATER = 1;
43
+ const HAMLET = 2;
44
+ exports.PLACES_FILE = path.join(__dirname, '..', 'data', 'places.json.gz');
45
+ exports.PLACE_RADIUS_NM = 5;
46
+ exports.WATER_HANDICAP_NM = 1;
47
+ exports.HAMLET_HANDICAP_NM = 0.75;
48
+ function isRow(x) {
49
+ return (Array.isArray(x) &&
50
+ (x.length === 3 || (x.length === 4 && (x[3] === LAND || x[3] === WATER || x[3] === HAMLET))) &&
51
+ typeof x[0] === 'number' &&
52
+ typeof x[1] === 'number' &&
53
+ typeof x[2] === 'string' &&
54
+ x[0] >= -90 &&
55
+ x[0] <= 90 &&
56
+ x[1] >= -180 &&
57
+ x[1] <= 180 &&
58
+ x[2].length > 0);
59
+ }
60
+ class Gazetteer {
61
+ lat;
62
+ lon;
63
+ kind;
64
+ names;
65
+ constructor(lat, lon, kind, names) {
66
+ this.lat = lat;
67
+ this.lon = lon;
68
+ this.kind = kind;
69
+ this.names = names;
70
+ }
71
+ static parse(text) {
72
+ const parsed = JSON.parse(text);
73
+ const rows = Array.isArray(parsed.places) ? parsed.places.filter(isRow) : [];
74
+ rows.sort((a, b) => a[0] - b[0]);
75
+ const lat = new Float64Array(rows.length);
76
+ const lon = new Float64Array(rows.length);
77
+ const kind = new Uint8Array(rows.length);
78
+ const names = new Array(rows.length);
79
+ rows.forEach((r, i) => {
80
+ lat[i] = r[0];
81
+ lon[i] = r[1];
82
+ kind[i] = r[3] ?? 0;
83
+ names[i] = r[2];
84
+ });
85
+ return new Gazetteer(lat, lon, kind, names);
86
+ }
87
+ static fromFile(file, log) {
88
+ try {
89
+ const bytes = fs.readFileSync(file);
90
+ const text = bytes[0] === 0x1f && bytes[1] === 0x8b ? (0, node_zlib_1.gunzipSync)(bytes).toString('utf8') : bytes.toString('utf8');
91
+ const g = Gazetteer.parse(text);
92
+ if (g.size === 0) {
93
+ log?.(`places: ${file} holds no places; voyage ends stay positions`);
94
+ return null;
95
+ }
96
+ log?.(`places: ${g.size} places loaded from ${file}`);
97
+ return g;
98
+ }
99
+ catch (err) {
100
+ log?.(`places: gazetteer not loaded from ${file}: ${err}`);
101
+ return null;
102
+ }
103
+ }
104
+ static lazy(file, log, withinNm = exports.PLACE_RADIUS_NM) {
105
+ let loaded;
106
+ return (lat, lon) => {
107
+ if (lat === null || lon === null)
108
+ return null;
109
+ if (loaded === undefined)
110
+ loaded = Gazetteer.fromFile(file, log);
111
+ return loaded ? loaded.nearest(lat, lon, withinNm) : null;
112
+ };
113
+ }
114
+ get size() {
115
+ return this.names.length;
116
+ }
117
+ nearest(lat, lon, withinNm = exports.PLACE_RADIUS_NM) {
118
+ const band = withinNm / 60;
119
+ const cosLat = Math.cos((lat * Math.PI) / 180);
120
+ const lonBand = cosLat > 1e-6 ? band / cosLat : 360;
121
+ let best = null;
122
+ let bestScore = Infinity;
123
+ for (let i = this.firstAtOrAbove(lat - band); i < this.names.length; i++) {
124
+ const rowLat = this.lat[i];
125
+ if (rowLat > lat + band)
126
+ break;
127
+ const rowLon = this.lon[i];
128
+ let dLon = Math.abs(rowLon - lon);
129
+ if (dLon > 180)
130
+ dLon = 360 - dLon;
131
+ if (dLon > lonBand)
132
+ continue;
133
+ const d = (0, rollup_1.haversineNm)(lat, lon, rowLat, rowLon);
134
+ if (d >= withinNm)
135
+ continue;
136
+ const k = this.kind[i];
137
+ const score = k === WATER ? d + exports.WATER_HANDICAP_NM : k === HAMLET ? d + exports.HAMLET_HANDICAP_NM : d;
138
+ if (score < bestScore) {
139
+ bestScore = score;
140
+ best = this.names[i];
141
+ }
142
+ }
143
+ return best;
144
+ }
145
+ firstAtOrAbove(lat) {
146
+ let lo = 0;
147
+ let hi = this.lat.length;
148
+ while (lo < hi) {
149
+ const mid = (lo + hi) >>> 1;
150
+ if (this.lat[mid] < lat)
151
+ lo = mid + 1;
152
+ else
153
+ hi = mid;
154
+ }
155
+ return lo;
156
+ }
157
+ resolver(withinNm = exports.PLACE_RADIUS_NM) {
158
+ return (lat, lon) => (lat === null || lon === null ? null : this.nearest(lat, lon, withinNm));
159
+ }
160
+ }
161
+ exports.Gazetteer = Gazetteer;
@@ -1,6 +1,7 @@
1
1
  import { Snapshot, TrackPoint, Voyage, VoyageStatsResult } from './contract';
2
2
  import { Options } from './config';
3
3
  import { RollupEngine } from './rollup';
4
+ import { PlaceResolver } from './places';
4
5
  import { Logger, Store } from './store';
5
6
  export type EditResult = {
6
7
  ok: true;
@@ -13,13 +14,15 @@ export declare class VoyageLog {
13
14
  private store;
14
15
  private opts;
15
16
  private log;
17
+ private place;
16
18
  private state;
17
19
  private manual;
18
20
  private window;
19
21
  private chain;
20
22
  private persistedRevision;
21
23
  private filePath;
22
- constructor(store: Store, opts: Options, log: Logger);
24
+ constructor(store: Store, opts: Options, log: Logger, place?: PlaceResolver);
25
+ private named;
23
26
  private enqueue;
24
27
  init(now: number): Promise<void>;
25
28
  feed(snap: Snapshot): Promise<void>;
@@ -45,18 +45,29 @@ class VoyageLog {
45
45
  store;
46
46
  opts;
47
47
  log;
48
+ place;
48
49
  state = { voyages: [], nextId: 1 };
49
50
  manual = [];
50
51
  window = [];
51
52
  chain = Promise.resolve();
52
53
  persistedRevision = '';
53
54
  filePath;
54
- constructor(store, opts, log) {
55
+ constructor(store, opts, log, place = () => null) {
55
56
  this.store = store;
56
57
  this.opts = opts;
57
58
  this.log = log;
59
+ this.place = place;
58
60
  this.filePath = path.join(store.rollupDir, 'voyages.json');
59
61
  }
62
+ named(v) {
63
+ const start_port = v.start_port ?? (0, voyage_1.nearestPort)(v.start_lat, v.start_lon, this.opts.ports) ?? this.place(v.start_lat, v.start_lon);
64
+ const end_port = v.status === 'closed'
65
+ ? (v.end_port ?? (0, voyage_1.nearestPort)(v.end_lat, v.end_lon, this.opts.ports) ?? this.place(v.end_lat, v.end_lon))
66
+ : v.end_port;
67
+ if (start_port === v.start_port && end_port === v.end_port)
68
+ return v;
69
+ return { ...v, start_port, end_port };
70
+ }
60
71
  enqueue(job) {
61
72
  const run = this.chain.then(job);
62
73
  this.chain = run.then(() => undefined, (err) => this.log(`voyage error: ${err}`));
@@ -94,10 +105,14 @@ class VoyageLog {
94
105
  });
95
106
  }
96
107
  list(limit) {
97
- return [...this.state.voyages].sort((a, b) => b.start_ts - a.start_ts).slice(0, limit);
108
+ return [...this.state.voyages]
109
+ .sort((a, b) => b.start_ts - a.start_ts)
110
+ .slice(0, limit)
111
+ .map((v) => this.named(v));
98
112
  }
99
113
  current() {
100
- return this.state.voyages.find((v) => v.status === 'open') ?? null;
114
+ const open = this.state.voyages.find((v) => v.status === 'open');
115
+ return open ? this.named(open) : null;
101
116
  }
102
117
  async track(voyageId, now) {
103
118
  const v = this.state.voyages.find((x) => x.id === voyageId);