@signalk/charts-plugin 3.1.1 → 3.2.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/README.md CHANGED
@@ -26,6 +26,9 @@ _Note: Version 2 resource paths will only be made available on Signal K server v
26
26
 
27
27
  3. Activate the plugin
28
28
 
29
+ > [!TIP]
30
+ > On Victron Venus devices you [need to install some dependencies manually](https://github.com/SignalK/charts-plugin/issues/40#issuecomment-3396744642) before installing the plugin.
31
+
29
32
  Chart metadata will then be available to client apps via the resources api `/resources/charts` for example:
30
33
  - [Freeboard SK](https://www.npmjs.com/package/@signalk/freeboard-sk)
31
34
  - [Tuktuk Chart Plotter](https://www.npmjs.com/package/tuktuk-chart-plotter)
@@ -66,6 +69,7 @@ For WMS & WMTS sources you can specify the layers you wish to display.
66
69
 
67
70
  <img src="https://github.com/user-attachments/assets/b9bfba38-8468-4eca-aeb3-96a80fcbc7a6" alt="Online chart provider layers" width="450"/>
68
71
 
72
+ A proxy for online charts can be created using the "Proxy through SignalK server" option. If enabled tiles will be fetched from the remote server and cached by the SignalK server making it possible to store the tiles for offline usage. Additional http headers can be passed to the remote server by adding colon separated headers, e.g. User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64). User-Agent is the header name and Mozilla... will be the value.
69
73
 
70
74
  ### Supported chart formats
71
75
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalk/charts-plugin",
3
- "version": "3.1.1",
3
+ "version": "3.2.0",
4
4
  "description": "Signal K plugin to provide chart support for Signal K server",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
@@ -13,13 +13,13 @@
13
13
  "lint": "eslint -c .eslintrc.js --ext .ts --ext .js --fix src/",
14
14
  "format": "npm run prettier && npm run lint",
15
15
  "ci-lint": "eslint -c .eslintrc.js --ext .ts --ext .js src/ && prettier --check src/",
16
- "test": "mocha",
17
- "deploy": "npm build && npm test && git tag -a v$npm_package_version -m 'Release v$npm_package_version'; git push --tags; npm publish --access public --scope @signalk ."
16
+ "test": "mocha"
18
17
  },
19
18
  "license": "Apache-2.0",
20
19
  "keywords": [
21
20
  "signalk",
22
21
  "signalk-node-server-plugin",
22
+ "signalk-webapp",
23
23
  "nautic",
24
24
  "chart",
25
25
  "mbtiles",
@@ -32,8 +32,14 @@
32
32
  "dependencies": {
33
33
  "@mapbox/mbtiles": "^0.12.1",
34
34
  "@signalk/server-api": "^2.0.0",
35
+ "@turf/bbox": "^7.2.0",
36
+ "@turf/boolean-intersects": "^7.2.0",
37
+ "@turf/helpers": "^7.2.0",
35
38
  "bluebird": "3.5.1",
39
+ "check-disk-space": "^3.4.0",
40
+ "geojson-antimeridian-cut": "^0.1.0",
36
41
  "lodash": "^4.17.11",
42
+ "p-limit": "^7.2.0",
37
43
  "xml2js": "0.6.2"
38
44
  },
39
45
  "repository": {
@@ -0,0 +1,76 @@
1
+ /// <reference types="node" />
2
+ /// <reference types="node" />
3
+ import type { BBox, FeatureCollection } from 'geojson';
4
+ import { ResourcesApi } from '@signalk/server-api';
5
+ import { ChartProvider } from './types';
6
+ export interface Tile {
7
+ x: number;
8
+ y: number;
9
+ z: number;
10
+ }
11
+ export declare enum Status {
12
+ Stopped = 0,
13
+ Running = 1
14
+ }
15
+ export declare class ChartSeedingManager {
16
+ static ActiveJobs: {
17
+ [key: number]: ChartDownloader;
18
+ };
19
+ static createJob(resourcesApi: ResourcesApi, chartsPath: string, provider: ChartProvider, maxZoom: number, regionGUI?: string | undefined, bbox?: BBox | undefined, tile?: Tile | undefined): Promise<ChartDownloader>;
20
+ }
21
+ export declare class ChartDownloader {
22
+ private resourcesApi;
23
+ private chartsPath;
24
+ private provider;
25
+ private static MINIMUM_FREE_DISK_SPACE;
26
+ private static nextJobId;
27
+ private id;
28
+ private maxZoom;
29
+ private status;
30
+ private totalTiles;
31
+ private downloadedTiles;
32
+ private failedTiles;
33
+ private cachedTiles;
34
+ private concurrentDownloadsLimit;
35
+ private areaDescription;
36
+ private cancelRequested;
37
+ private tiles;
38
+ private tilesToDownload;
39
+ constructor(resourcesApi: ResourcesApi, chartsPath: string, provider: ChartProvider);
40
+ get ID(): number;
41
+ initalizeJobFromRegion(regionGUID: string, maxZoom: number): Promise<void>;
42
+ initializeJobFromBBox(bbox: BBox, maxZoom: number): Promise<void>;
43
+ initializeJobFromTile(tile: Tile, maxZoom: number): Promise<void>;
44
+ /**
45
+ * Download map tiles for a specific area.
46
+ *
47
+ */
48
+ seedCache(): Promise<void>;
49
+ deleteCache(): Promise<void>;
50
+ cancelJob(): void;
51
+ private filterCachedTiles;
52
+ info(): {
53
+ id: number;
54
+ chartName: string;
55
+ regionName: string;
56
+ totalTiles: number;
57
+ downloadedTiles: number;
58
+ cachedTiles: number;
59
+ failedTiles: number;
60
+ progress: number;
61
+ status: Status;
62
+ };
63
+ static getTileFromCacheOrRemote(chartsPath: string, provider: ChartProvider, tile: Tile): Promise<Buffer | null>;
64
+ static fetchTileFromRemote(provider: ChartProvider, tile: Tile, timeoutMs?: number): Promise<Buffer | null>;
65
+ getSubTiles(tile: Tile, maxZoom: number): Tile[];
66
+ /**
67
+ * Get all tiles that intersect a bounding box up to a maximum zoom level.
68
+ * bbox = [minLon, minLat, maxLon, maxLat]
69
+ */
70
+ getTilesForBBox(bbox: BBox, maxZoom: number): Tile[];
71
+ getTilesForGeoJSON(geojson: FeatureCollection, zoomMin?: number, zoomMax?: number): Tile[];
72
+ private convertRegionToGeoJSON;
73
+ private lonLatToTileXY;
74
+ private tileToBBox;
75
+ private bboxPolygon;
76
+ }
@@ -0,0 +1,400 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ChartDownloader = exports.ChartSeedingManager = exports.Status = void 0;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const p_limit_1 = __importDefault(require("p-limit"));
10
+ const geojson_antimeridian_cut_1 = __importDefault(require("geojson-antimeridian-cut"));
11
+ const boolean_intersects_1 = __importDefault(require("@turf/boolean-intersects"));
12
+ const bbox_1 = require("@turf/bbox");
13
+ const helpers_1 = require("@turf/helpers");
14
+ const check_disk_space_1 = __importDefault(require("check-disk-space"));
15
+ var Status;
16
+ (function (Status) {
17
+ Status[Status["Stopped"] = 0] = "Stopped";
18
+ Status[Status["Running"] = 1] = "Running";
19
+ })(Status = exports.Status || (exports.Status = {}));
20
+ class ChartSeedingManager {
21
+ static ActiveJobs = {};
22
+ static async createJob(resourcesApi, chartsPath, provider, maxZoom, regionGUI = undefined, bbox = undefined, tile = undefined) {
23
+ const downloader = new ChartDownloader(resourcesApi, chartsPath, provider);
24
+ if (regionGUI)
25
+ downloader.initalizeJobFromRegion(regionGUI, maxZoom);
26
+ else if (bbox)
27
+ downloader.initializeJobFromBBox(bbox, maxZoom);
28
+ else if (tile) {
29
+ downloader.initializeJobFromTile(tile, maxZoom);
30
+ }
31
+ this.ActiveJobs[downloader.ID] = downloader;
32
+ return downloader;
33
+ }
34
+ }
35
+ exports.ChartSeedingManager = ChartSeedingManager;
36
+ class ChartDownloader {
37
+ resourcesApi;
38
+ chartsPath;
39
+ provider;
40
+ static MINIMUM_FREE_DISK_SPACE = 1024 * 1024 * 1024; // 1 GB
41
+ static nextJobId = 1;
42
+ id = ChartDownloader.nextJobId++;
43
+ maxZoom = 15;
44
+ status = Status.Stopped;
45
+ totalTiles = 0;
46
+ downloadedTiles = 0;
47
+ failedTiles = 0;
48
+ cachedTiles = 0;
49
+ concurrentDownloadsLimit = 20;
50
+ areaDescription = '';
51
+ cancelRequested = false;
52
+ tiles = [];
53
+ tilesToDownload = [];
54
+ constructor(resourcesApi, chartsPath, provider) {
55
+ this.resourcesApi = resourcesApi;
56
+ this.chartsPath = chartsPath;
57
+ this.provider = provider;
58
+ }
59
+ get ID() {
60
+ return this.id;
61
+ }
62
+ async initalizeJobFromRegion(regionGUID, maxZoom) {
63
+ const region = (await this.resourcesApi.getResource('regions', regionGUID));
64
+ const geojson = this.convertRegionToGeoJSON(region);
65
+ this.tiles = this.getTilesForGeoJSON(geojson, this.provider.minzoom, maxZoom);
66
+ this.tilesToDownload = await this.filterCachedTiles(this.tiles);
67
+ this.status = Status.Stopped;
68
+ this.totalTiles = this.tiles.length;
69
+ this.cachedTiles = this.totalTiles - this.tilesToDownload.length;
70
+ this.areaDescription = `Region: ${region?.name ?? ''}`;
71
+ this.maxZoom = maxZoom;
72
+ }
73
+ async initializeJobFromBBox(bbox, maxZoom) {
74
+ this.tiles = this.getTilesForBBox(bbox, maxZoom);
75
+ this.tilesToDownload = await this.filterCachedTiles(this.tiles);
76
+ this.status = Status.Stopped;
77
+ this.totalTiles = this.tiles.length;
78
+ this.cachedTiles = this.totalTiles - this.tilesToDownload.length;
79
+ this.areaDescription = `BBox: [${bbox.join(', ')}]`;
80
+ this.maxZoom = maxZoom;
81
+ }
82
+ async initializeJobFromTile(tile, maxZoom) {
83
+ this.tiles = this.getSubTiles(tile, maxZoom);
84
+ this.tilesToDownload = await this.filterCachedTiles(this.tiles);
85
+ this.status = Status.Stopped;
86
+ this.totalTiles = this.tiles.length;
87
+ this.cachedTiles = this.totalTiles - this.tilesToDownload.length;
88
+ this.areaDescription = `Tile: [${tile.x}, ${tile.y}, ${tile.z}]`;
89
+ this.maxZoom = maxZoom;
90
+ }
91
+ /**
92
+ * Download map tiles for a specific area.
93
+ *
94
+ */
95
+ async seedCache() {
96
+ this.cancelRequested = false;
97
+ this.status = Status.Running;
98
+ this.tilesToDownload = await this.filterCachedTiles(this.tiles);
99
+ this.downloadedTiles = 0;
100
+ this.failedTiles = 0;
101
+ this.cachedTiles = this.totalTiles - this.tilesToDownload.length;
102
+ const limit = (0, p_limit_1.default)(this.concurrentDownloadsLimit); // concurrent download limit
103
+ let tileCounter = 0;
104
+ this.tilesToDownload = await this.filterCachedTiles(this.tiles);
105
+ const tasks = this.tilesToDownload.map((tile) => limit(async () => {
106
+ if (this.cancelRequested) {
107
+ this.status = Status.Stopped;
108
+ return;
109
+ }
110
+ if (tileCounter % 1000 === 0) {
111
+ await new Promise((r) => setTimeout(r, 0));
112
+ try {
113
+ const { free } = await (0, check_disk_space_1.default)(this.chartsPath);
114
+ if (free < ChartDownloader.MINIMUM_FREE_DISK_SPACE) {
115
+ console.warn(`Low disk space. Stopping download.`);
116
+ this.status = Status.Stopped;
117
+ return;
118
+ }
119
+ }
120
+ catch (err) {
121
+ console.error(`Error checking disk space:`, err);
122
+ this.status = Status.Stopped;
123
+ return;
124
+ }
125
+ }
126
+ tileCounter++;
127
+ const buffer = await ChartDownloader.getTileFromCacheOrRemote(this.chartsPath, this.provider, tile);
128
+ if (buffer === null) {
129
+ this.failedTiles++;
130
+ }
131
+ else {
132
+ this.downloadedTiles++;
133
+ }
134
+ }));
135
+ try {
136
+ await Promise.all(tasks);
137
+ }
138
+ catch (err) {
139
+ console.error('Error downloading tiles:', err);
140
+ }
141
+ this.status = Status.Stopped;
142
+ }
143
+ async deleteCache() {
144
+ this.status = Status.Running;
145
+ for (const tile of this.tiles) {
146
+ if (this.cancelRequested)
147
+ break;
148
+ const tilePath = path_1.default.join(this.chartsPath, `${this.provider.name}`, `${tile.z}`, `${tile.x}`, `${tile.y}.${this.provider.format}`);
149
+ try {
150
+ await fs_1.default.promises.unlink(tilePath);
151
+ this.cachedTiles = Math.max(this.cachedTiles - 1, 0);
152
+ }
153
+ catch (err) {
154
+ if (err.code !== 'ENOENT') {
155
+ console.error(`Error deleting cached tile ${tilePath}:`, err);
156
+ }
157
+ }
158
+ }
159
+ this.status = Status.Stopped;
160
+ }
161
+ cancelJob() {
162
+ this.cancelRequested = true;
163
+ }
164
+ async filterCachedTiles(allTiles) {
165
+ const checks = allTiles.map(async (tile) => {
166
+ const tilePath = path_1.default.join(this.chartsPath, this.provider.name, `${tile.z}`, `${tile.x}`, `${tile.y}.${this.provider.format}`);
167
+ try {
168
+ await fs_1.default.promises.access(tilePath); // file exists
169
+ return null; // filter out cached tile
170
+ }
171
+ catch (err) {
172
+ if (err.code === 'ENOENT') {
173
+ return tile; // file does not exist → uncached
174
+ }
175
+ console.error('Unexpected fs error:', err);
176
+ return tile; // treat unknown errors as uncached
177
+ }
178
+ });
179
+ const results = await Promise.all(checks);
180
+ return results.filter((t) => t !== null);
181
+ }
182
+ info() {
183
+ return {
184
+ id: this.id,
185
+ chartName: this.provider.name,
186
+ regionName: this.areaDescription,
187
+ totalTiles: this.totalTiles,
188
+ downloadedTiles: this.downloadedTiles,
189
+ cachedTiles: this.cachedTiles,
190
+ failedTiles: this.failedTiles,
191
+ progress: this.totalTiles > 0
192
+ ? (this.downloadedTiles + this.cachedTiles + this.failedTiles) /
193
+ this.totalTiles
194
+ : 0,
195
+ status: this.status
196
+ };
197
+ }
198
+ static async getTileFromCacheOrRemote(chartsPath, provider, tile) {
199
+ const tilePath = path_1.default.join(chartsPath, `${provider.name}`, `${tile.z}`, `${tile.x}`, `${tile.y}.${provider.format}`);
200
+ try {
201
+ const data = await fs_1.default.promises.readFile(tilePath);
202
+ return data;
203
+ }
204
+ catch (err) {
205
+ //Cache miss, proceed to fetch from remote
206
+ }
207
+ const buffer = await this.fetchTileFromRemote(provider, tile);
208
+ if (buffer) {
209
+ try {
210
+ await fs_1.default.promises.mkdir(path_1.default.dirname(tilePath), { recursive: true });
211
+ await fs_1.default.promises.writeFile(tilePath, buffer);
212
+ }
213
+ catch (err) {
214
+ console.error(`Error writing tile ${tilePath}:`, err);
215
+ }
216
+ }
217
+ return buffer;
218
+ }
219
+ static async fetchTileFromRemote(provider, tile, timeoutMs = 5000) {
220
+ if (!provider.remoteUrl) {
221
+ console.error(`No remote URL defined for provider ${provider.name}`);
222
+ return null;
223
+ }
224
+ const url = provider.remoteUrl
225
+ .replace('{z}', tile.z.toString())
226
+ // To be able to handle NOAA WMTS caching as a tilemap source with -2 offset
227
+ .replace('{z-2}', (tile.z - 2).toString())
228
+ .replace('{x}', tile.x.toString())
229
+ .replace('{y}', tile.y.toString())
230
+ .replace('{-y}', (Math.pow(2, tile.z) - 1 - tile.y).toString());
231
+ const controller = new AbortController();
232
+ const id = setTimeout(() => controller.abort(), timeoutMs);
233
+ try {
234
+ const response = await fetch(url, {
235
+ headers: provider.headers,
236
+ signal: controller.signal
237
+ });
238
+ if (!response.ok) {
239
+ return null;
240
+ }
241
+ const arrayBuffer = await response.arrayBuffer();
242
+ const buffer = Buffer.from(arrayBuffer);
243
+ return buffer;
244
+ }
245
+ catch (err) {
246
+ return null;
247
+ }
248
+ finally {
249
+ clearTimeout(id);
250
+ }
251
+ }
252
+ getSubTiles(tile, maxZoom) {
253
+ const tiles = [tile];
254
+ for (let z = tile.z + 1; z <= maxZoom; z++) {
255
+ const zoomDiff = z - tile.z;
256
+ const factor = Math.pow(2, zoomDiff);
257
+ const startX = tile.x * factor;
258
+ const startY = tile.y * factor;
259
+ for (let x = startX; x < startX + factor; x++) {
260
+ for (let y = startY; y < startY + factor; y++) {
261
+ tiles.push({ x, y, z });
262
+ }
263
+ }
264
+ }
265
+ return tiles;
266
+ }
267
+ /**
268
+ * Get all tiles that intersect a bounding box up to a maximum zoom level.
269
+ * bbox = [minLon, minLat, maxLon, maxLat]
270
+ */
271
+ getTilesForBBox(bbox, maxZoom) {
272
+ const tiles = [];
273
+ const [minLon, minLat, maxLon, maxLat] = bbox;
274
+ const crossesAntiMeridian = minLon > maxLon;
275
+ // Helper to process a lon/lat box normally
276
+ const processBBox = (lo1, la1, lo2, la2) => {
277
+ for (let z = 0; z <= maxZoom; z++) {
278
+ const [minX, maxY] = this.lonLatToTileXY(lo1, la1, z);
279
+ const [maxX, minY] = this.lonLatToTileXY(lo2, la2, z);
280
+ for (let x = minX; x <= maxX; x++) {
281
+ for (let y = minY; y <= maxY; y++) {
282
+ tiles.push({ x, y, z });
283
+ }
284
+ }
285
+ }
286
+ };
287
+ if (!crossesAntiMeridian) {
288
+ // normal
289
+ processBBox(minLon, minLat, maxLon, maxLat);
290
+ }
291
+ else {
292
+ // crosses antimeridian — split into two boxes:
293
+ // [minLon -> 180] and [-180 -> maxLon]
294
+ processBBox(minLon, minLat, 180, maxLat);
295
+ processBBox(-180, minLat, maxLon, maxLat);
296
+ }
297
+ return tiles;
298
+ }
299
+ getTilesForGeoJSON(geojson, zoomMin = 1, zoomMax = 14) {
300
+ const tiles = [];
301
+ for (const feature of geojson.features) {
302
+ if (feature.geometry.type !== 'Polygon' &&
303
+ feature.geometry.type !== 'MultiPolygon') {
304
+ console.warn('Skipping non-polygon feature');
305
+ continue;
306
+ }
307
+ const boundingBox = (0, bbox_1.bbox)(feature.geometry); // [minX, minY, maxX, maxY]
308
+ for (let z = zoomMin; z <= zoomMax; z++) {
309
+ const [minX, minY] = this.lonLatToTileXY(boundingBox[0], boundingBox[3], z); // top-left
310
+ const [maxX, maxY] = this.lonLatToTileXY(boundingBox[2], boundingBox[1], z); // bottom-right
311
+ for (let x = minX; x <= maxX; x++) {
312
+ for (let y = minY; y <= maxY; y++) {
313
+ const tileBbox = this.tileToBBox(x, y, z);
314
+ const tilePoly = this.bboxPolygon(tileBbox);
315
+ if ((0, boolean_intersects_1.default)(feature, tilePoly)) {
316
+ tiles.push({ x, y, z });
317
+ }
318
+ }
319
+ }
320
+ }
321
+ }
322
+ return tiles;
323
+ }
324
+ convertRegionToGeoJSON(region) {
325
+ const feature = region.feature;
326
+ if (!feature || feature.type !== 'Feature' || !feature.geometry) {
327
+ throw new Error('Invalid region: missing feature or geometry');
328
+ }
329
+ const geoFeature = {
330
+ type: 'Feature',
331
+ id: feature.id || undefined,
332
+ geometry: feature.geometry,
333
+ properties: {
334
+ name: region.name || '',
335
+ description: region.description || '',
336
+ timestamp: region.timestamp || '',
337
+ source: region.$source || '',
338
+ ...(feature.properties || {})
339
+ }
340
+ };
341
+ const splitGeoFeature = (0, geojson_antimeridian_cut_1.default)(geoFeature);
342
+ const features = [];
343
+ const pushFeaturePolygon = (orig, coords, idx) => {
344
+ const poly = {
345
+ type: 'Feature',
346
+ id: idx != null && orig.id ? `${orig.id}-${idx}` : orig.id,
347
+ geometry: {
348
+ type: 'Polygon',
349
+ coordinates: coords
350
+ },
351
+ properties: orig.properties || {}
352
+ };
353
+ features.push(poly);
354
+ };
355
+ const f = splitGeoFeature;
356
+ if (f.geometry && f.geometry.type === 'MultiPolygon') {
357
+ for (let i = 0; i < f.geometry.coordinates.length; i++) {
358
+ pushFeaturePolygon(f, f.geometry.coordinates[i], i);
359
+ }
360
+ }
361
+ else if (f.geometry && f.geometry.type === 'Polygon') {
362
+ features.push(f);
363
+ }
364
+ return {
365
+ type: 'FeatureCollection',
366
+ features
367
+ };
368
+ }
369
+ lonLatToTileXY(lon, lat, zoom) {
370
+ const n = 2 ** zoom;
371
+ const x = Math.floor(((lon + 180) / 360) * n);
372
+ const y = Math.floor(((1 -
373
+ Math.log(Math.tan((lat * Math.PI) / 180) + 1 / Math.cos((lat * Math.PI) / 180)) /
374
+ Math.PI) /
375
+ 2) *
376
+ n);
377
+ return [x, y];
378
+ }
379
+ tileToBBox(x, y, z) {
380
+ const n = 2 ** z;
381
+ const lon1 = (x / n) * 360 - 180;
382
+ const lat1 = (Math.atan(Math.sinh(Math.PI * (1 - (2 * y) / n))) * 180) / Math.PI;
383
+ const lon2 = ((x + 1) / n) * 360 - 180;
384
+ const lat2 = (Math.atan(Math.sinh(Math.PI * (1 - (2 * (y + 1)) / n))) * 180) / Math.PI;
385
+ return [lon1, lat2, lon2, lat1];
386
+ }
387
+ bboxPolygon(boundingBox) {
388
+ const [minLon, minLat, maxLon, maxLat] = boundingBox;
389
+ return (0, helpers_1.polygon)([
390
+ [
391
+ [minLon, minLat],
392
+ [maxLon, minLat],
393
+ [maxLon, maxLat],
394
+ [minLon, maxLat],
395
+ [minLon, minLat]
396
+ ]
397
+ ]);
398
+ }
399
+ }
400
+ exports.ChartDownloader = ChartDownloader;
package/plugin/index.js CHANGED
@@ -32,6 +32,7 @@ const fs_1 = __importDefault(require("fs"));
32
32
  const _ = __importStar(require("lodash"));
33
33
  const charts_1 = require("./charts");
34
34
  const constants_1 = require("./constants");
35
+ const chartDownloader_1 = require("./chartDownloader");
35
36
  const MIN_ZOOM = 1;
36
37
  const MAX_ZOOM = 24;
37
38
  const chartTilesPath = '/signalk/chart-tiles';
@@ -42,6 +43,7 @@ module.exports = (app) => {
42
43
  chartPaths: [],
43
44
  onlineChartProviders: []
44
45
  };
46
+ let urlBase = '';
45
47
  const configBasePath = app.config.configPath;
46
48
  const defaultChartsPath = path_1.default.join(configBasePath, '/charts');
47
49
  const serverMajorVersion = app.config.version
@@ -119,6 +121,22 @@ module.exports = (app) => {
119
121
  title: 'URL',
120
122
  description: 'Map URL (for tilelayer include {z}, {x} and {y} parameters, e.g. "http://example.org/{z}/{x}/{y}.png")'
121
123
  },
124
+ proxy: {
125
+ type: 'boolean',
126
+ title: 'Proxy through signalk server',
127
+ description: 'Create a proxy to serve remote tiles and cache fetched tiles from the remote server, to serve them locally on subsequent requests. Use webapp to configure seeding jobs to prefetch tiles to local cache.',
128
+ default: false
129
+ },
130
+ headers: {
131
+ type: 'array',
132
+ title: 'Headers',
133
+ description: 'List of http headers to be sent to the remote server when requesting map tiles through proxy.',
134
+ items: {
135
+ title: 'Header Name: Value',
136
+ description: 'Name and Value of the HTTP header separated by colon',
137
+ type: 'string'
138
+ }
139
+ },
122
140
  style: {
123
141
  type: 'string',
124
142
  title: 'Vector Map Style',
@@ -156,6 +174,8 @@ module.exports = (app) => {
156
174
  const doStartup = (config) => {
157
175
  app.debug(`** loaded config: ${config}`);
158
176
  props = { ...config };
177
+ urlBase = `${app.config.ssl ? 'https' : 'http'}://localhost:${'getExternalPort' in app.config ? app.config.getExternalPort() : 3000}`;
178
+ app.debug(`**urlBase** ${urlBase}`);
159
179
  const chartPaths = _.isEmpty(props.chartPaths)
160
180
  ? [defaultChartsPath]
161
181
  : resolveUniqueChartPaths(props.chartPaths, configBasePath);
@@ -168,8 +188,11 @@ module.exports = (app) => {
168
188
  // Do not register routes if plugin has been started once already
169
189
  pluginStarted === false && registerRoutes();
170
190
  pluginStarted = true;
171
- const urlBase = `${app.config.ssl ? 'https' : 'http'}://localhost:${'getExternalPort' in app.config ? app.config.getExternalPort() : 3000}`;
172
- app.debug(`**urlBase** ${urlBase}`);
191
+ // v2 routes - register as Resource Provider, this needs to be always on startup
192
+ if (serverMajorVersion === 2) {
193
+ app.debug('** Registering v2 API paths **');
194
+ registerAsProvider();
195
+ }
173
196
  app.setPluginStatus('Started');
174
197
  const loadProviders = bluebird
175
198
  .mapSeries(chartPaths, (chartPath) => (0, charts_1.findCharts)(chartPath))
@@ -189,18 +212,72 @@ module.exports = (app) => {
189
212
  app.debug('** Registering API paths **');
190
213
  app.get(`${chartTilesPath}/:identifier/:z([0-9]*)/:x([0-9]*)/:y([0-9]*)`, async (req, res) => {
191
214
  const { identifier, z, x, y } = req.params;
215
+ const ix = parseInt(x);
216
+ const iy = parseInt(y);
217
+ const iz = parseInt(z);
192
218
  const provider = chartProviders[identifier];
193
219
  if (!provider) {
194
220
  return res.sendStatus(404);
195
221
  }
196
- switch (provider._fileFormat) {
197
- case 'directory':
198
- return serveTileFromFilesystem(res, provider, parseInt(z), parseInt(x), parseInt(y));
199
- case 'mbtiles':
200
- return serveTileFromMbtiles(res, provider, parseInt(z), parseInt(x), parseInt(y));
201
- default:
202
- console.log(`Unknown chart provider fileformat ${provider._fileFormat}`);
203
- res.status(500).send();
222
+ if (provider.proxy === true) {
223
+ return serveTileFromCacheOrRemote(res, provider, iz, ix, iy);
224
+ }
225
+ else {
226
+ switch (provider._fileFormat) {
227
+ case 'directory':
228
+ return serveTileFromFilesystem(res, provider, iz, ix, iy);
229
+ case 'mbtiles':
230
+ return serveTileFromMbtiles(res, provider, iz, ix, iy);
231
+ default:
232
+ console.log(`Unknown chart provider fileformat ${provider._fileFormat}`);
233
+ res.status(500).send();
234
+ }
235
+ }
236
+ });
237
+ app.post(`${chartTilesPath}/cache/:identifier`, async (req, res) => {
238
+ const { identifier } = req.params;
239
+ const { regionGUID, tile, bbox, maxZoom } = req.body;
240
+ const provider = chartProviders[identifier];
241
+ if (!provider) {
242
+ return res.sendStatus(500).send('Provider not found');
243
+ }
244
+ if (!maxZoom) {
245
+ return res.status(400).send('maxZoom parameter is required');
246
+ }
247
+ const maxZoomParsed = parseInt(maxZoom);
248
+ await chartDownloader_1.ChartSeedingManager.createJob(app.resourcesApi, defaultChartsPath, provider, maxZoomParsed, regionGUID, bbox
249
+ ? [bbox.minLon, bbox.minLat, bbox.maxLon, bbox.maxLat]
250
+ : undefined, tile);
251
+ return res.status(200);
252
+ });
253
+ app.get(`${chartTilesPath}/cache/jobs`, (req, res) => {
254
+ const jobs = Object.values(chartDownloader_1.ChartSeedingManager.ActiveJobs).map((job) => {
255
+ return job.info();
256
+ });
257
+ return res.status(200).json(jobs);
258
+ });
259
+ app.post(`${chartTilesPath}/cache/jobs/:id`, (req, res) => {
260
+ const { id } = req.params;
261
+ const { action } = req.body;
262
+ const parsedId = parseInt(id);
263
+ const job = chartDownloader_1.ChartSeedingManager.ActiveJobs[parsedId];
264
+ if (job && action) {
265
+ if (action === 'start') {
266
+ job.seedCache();
267
+ }
268
+ else if (action === 'stop') {
269
+ job.cancelJob();
270
+ }
271
+ else if (action === 'delete') {
272
+ job.deleteCache();
273
+ }
274
+ else if (action === 'remove') {
275
+ delete chartDownloader_1.ChartSeedingManager.ActiveJobs[parsedId];
276
+ }
277
+ else {
278
+ return res.status(404).send(`Job ${parsedId} not found`);
279
+ }
280
+ return res.status(200).send(`Job ${parsedId} ${action}ed`);
204
281
  }
205
282
  });
206
283
  app.debug('** Registering v1 API paths **');
@@ -218,11 +295,6 @@ module.exports = (app) => {
218
295
  const sanitized = _.mapValues(chartProviders, (provider) => sanitizeProvider(provider));
219
296
  res.json(sanitized);
220
297
  });
221
- // v2 routes
222
- if (serverMajorVersion === 2) {
223
- app.debug('** Registering v2 API paths **');
224
- registerAsProvider();
225
- }
226
298
  };
227
299
  // Resources API provider registration
228
300
  const registerAsProvider = () => {
@@ -259,6 +331,15 @@ module.exports = (app) => {
259
331
  app.debug('Failed Provider Registration!');
260
332
  }
261
333
  };
334
+ const serveTileFromCacheOrRemote = async (res, provider, z, x, y) => {
335
+ const buffer = await chartDownloader_1.ChartDownloader.getTileFromCacheOrRemote(defaultChartsPath, provider, { x, y, z });
336
+ if (!buffer) {
337
+ res.sendStatus(502);
338
+ return;
339
+ }
340
+ res.set('Content-Type', `image/${provider.format}`);
341
+ res.send(buffer);
342
+ };
262
343
  return plugin;
263
344
  };
264
345
  const responseHttpOptions = {
@@ -272,6 +353,22 @@ const resolveUniqueChartPaths = (chartPaths, configBasePath) => {
272
353
  };
273
354
  const convertOnlineProviderConfig = (provider) => {
274
355
  const id = _.kebabCase(_.deburr(provider.name));
356
+ const parseHeaders = (arr) => {
357
+ if (arr === undefined) {
358
+ return {};
359
+ }
360
+ return arr.reduce((acc, entry) => {
361
+ if (typeof entry == 'string') {
362
+ const idx = entry.indexOf(':');
363
+ const key = entry.slice(0, idx).trim();
364
+ const value = entry.slice(idx + 1).trim();
365
+ if (key && value) {
366
+ acc[key] = value;
367
+ }
368
+ }
369
+ return acc;
370
+ }, {});
371
+ };
275
372
  const data = {
276
373
  identifier: id,
277
374
  name: provider.name,
@@ -284,13 +381,18 @@ const convertOnlineProviderConfig = (provider) => {
284
381
  type: provider.serverType ? provider.serverType : 'tilelayer',
285
382
  style: provider.style ? provider.style : null,
286
383
  v1: {
287
- tilemapUrl: provider.url,
384
+ tilemapUrl: provider.proxy
385
+ ? `~tilePath~/${id}/{z}/{x}/{y}`
386
+ : provider.url,
288
387
  chartLayers: provider.layers ? provider.layers : null
289
388
  },
290
389
  v2: {
291
- url: provider.url,
390
+ url: provider.proxy ? `~tilePath~/${id}/{z}/{x}/{y}` : provider.url,
292
391
  layers: provider.layers ? provider.layers : null
293
- }
392
+ },
393
+ proxy: provider.proxy ? provider.proxy : false,
394
+ remoteUrl: provider.proxy ? provider.url : null,
395
+ headers: parseHeaders(provider.headers)
294
396
  };
295
397
  return data;
296
398
  };
package/plugin/types.d.ts CHANGED
@@ -23,6 +23,11 @@ export interface ChartProvider {
23
23
  format?: string;
24
24
  style?: string;
25
25
  layers?: string[];
26
+ proxy?: boolean;
27
+ remoteUrl?: string;
28
+ headers?: {
29
+ [key: string]: string;
30
+ };
26
31
  }
27
32
  export interface OnlineChartProvider {
28
33
  name: string;
@@ -32,6 +37,8 @@ export interface OnlineChartProvider {
32
37
  serverType: MapSourceType;
33
38
  format: 'png' | 'jpg';
34
39
  url: string;
40
+ proxy: boolean;
41
+ headers?: string[];
35
42
  style: string;
36
43
  layers: string[];
37
44
  }
@@ -0,0 +1,106 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+
4
+ <head>
5
+ <link rel="stylesheet" href="style.css" />
6
+ <title>Signalk charts-addin</title>
7
+ </head>
8
+
9
+ <body>
10
+
11
+ <fieldset>
12
+ <legend>Seed charts to prefetch tiles for offline usage.</legend>
13
+ <strong>Select chart provider:</strong><br>
14
+ <label for="chart">Chart:</label>
15
+ <select id="chart" name="chart" required>
16
+ </select>
17
+ <br>
18
+ <strong>Select region type:</strong>
19
+ <br>
20
+ <!-- Choice 1: Predefined Region -->
21
+ <label>
22
+ <input type="radio" name="inputType" value="region" checked>
23
+ Region
24
+ </label>
25
+ <select id="region" name="region">
26
+ </select>
27
+
28
+ <br>
29
+
30
+ <!-- Choice 2: Bounding Box -->
31
+ <label>
32
+ <input type="radio" name="inputType" value="bbox">
33
+ Bounding Box (minLon, minLat, maxLon, maxLat) West to East, South to North
34
+ </label>
35
+ <div>
36
+ <input type="number" id="bboxMinLon" placeholder="minLon" step="0.0001" disabled value="16.51">
37
+ <input type="number" id="bboxMinLat" placeholder="minLat" step="0.0001" disabled value="58.59">
38
+ <input type="number" id="bboxMaxLon" placeholder="maxLon" step="0.0001" disabled value="16.60">
39
+ <input type="number" id="bboxMaxLat" placeholder="maxLat" step="0.0001" disabled value="58.65">
40
+ </div>
41
+ <!-- Choice 3: Tile -->
42
+ <!-- <label>
43
+ <input type="radio" name="inputType" value="tile">
44
+ Tile (x, y, z)
45
+ </label>
46
+ <div>
47
+ <input type="number" id="tileX" placeholder="x" disabled>
48
+ <input type="number" id="tileY" placeholder="y" disabled>
49
+ <input type="number" id="tileZ" placeholder="z" disabled>
50
+ </div> -->
51
+
52
+ <strong>Select max zoom:</strong><br>
53
+ <label for="maxZoom">Max Zoom</label>
54
+ <select id="maxZoom" name="maxZoom" required>
55
+ <option value="4">4</option>
56
+ <option value="5">5</option>
57
+ <option value="6">6</option>
58
+ <option value="7">7</option>
59
+ <option value="8">8</option>
60
+ <option value="9">9</option>
61
+ <option value="10">10</option>
62
+ <option value="11">11</option>
63
+ <option value="12">12</option>
64
+ <option value="13">13</option>
65
+ <option value="14">14</option>
66
+ <option value="15" selected="selected">15</option>
67
+ <option value="16">16</option>
68
+ <option value="17">17</option>
69
+ <option value="18">18</option>
70
+ <option value="19">19</option>
71
+ <option value="20">20</option>
72
+ </select>
73
+
74
+ <br>
75
+ <button id="createJobButton">Create Seeding Job</button>
76
+ </fieldset>
77
+
78
+ <div id="output">
79
+ <table id="activeJobsTable" aria-live="polite">
80
+ <thead>
81
+ <tr>
82
+ <th>#</th>
83
+ <th>Chart</th>
84
+ <th>Region</th>
85
+ <th>Total tiles</th>
86
+ <th>Downloaded</th>
87
+ <th>Cached</th>
88
+ <th>Failed</th>
89
+ <th>Progress</th>
90
+ <th>Status</th>
91
+ <th>Actions</th>
92
+ </tr>
93
+ </thead>
94
+ <tbody>
95
+ <tr>
96
+ <td colspan="7" class="muted">Loading progress…</td>
97
+ </tr>
98
+ </tbody>
99
+ </table>
100
+ </div>
101
+
102
+ <script src="index.js"></script>
103
+
104
+ </body>
105
+
106
+ </html>
@@ -0,0 +1,303 @@
1
+
2
+
3
+
4
+ (function () {
5
+
6
+
7
+ })();
8
+
9
+ document.addEventListener('DOMContentLoaded', async () => {
10
+ const regionSelect = document.getElementById("region");
11
+ const bboxInputs = document.querySelectorAll("#bboxMinLon, #bboxMinLat, #bboxMaxLon, #bboxMaxLat");
12
+ const tileInputs = document.querySelectorAll("#tileX, #tileY, #tileZ");
13
+
14
+ function updateInputs() {
15
+ const type = document.querySelector('input[name="inputType"]:checked').value;
16
+
17
+ regionSelect.disabled = (type !== "region");
18
+ bboxInputs.forEach(i => i.disabled = (type !== "bbox"));
19
+ tileInputs.forEach(i => i.disabled = (type !== "tile"));
20
+ }
21
+
22
+ // Attach listeners
23
+ document.querySelectorAll('input[name="inputType"]').forEach(r => {
24
+ r.addEventListener("change", updateInputs);
25
+ });
26
+
27
+ updateInputs(); // initial load
28
+
29
+ if (!regionSelect) return;
30
+
31
+ try {
32
+ const resp = await fetch('/signalk/v2/api/resources/regions');
33
+ if (!resp.ok) throw new Error(`Failed to fetch regions: ${resp.status}`);
34
+ const data = await resp.json();
35
+ // clear existing options
36
+ regionSelect.innerHTML = '';
37
+
38
+ const entries = Object.entries(data || {});
39
+ entries
40
+ .sort(([, a], [, b]) => {
41
+ const na = (a && a.name) ? String(a.name) : '';
42
+ const nb = (b && b.name) ? String(b.name) : '';
43
+ return na.localeCompare(nb);
44
+ })
45
+ .forEach(([id, info]) => {
46
+ const opt = document.createElement('option');
47
+ opt.value = id;
48
+ opt.textContent = (info && info.name) ? info.name : id;
49
+ regionSelect.appendChild(opt);
50
+
51
+ });
52
+
53
+ // if no regions returned, add a fallback option
54
+ if (!regionSelect.options.length) {
55
+ const opt = document.createElement('option');
56
+ opt.value = '';
57
+ opt.textContent = '-- No regions available --';
58
+ regionSelect.appendChild(opt);
59
+ }
60
+ } catch (err) {
61
+ console.error('Error loading regions:', err);
62
+ }
63
+
64
+ //Fetch available maps and populate the chart select box
65
+ const chartSelect = document.getElementById('chart');
66
+ if (!chartSelect) return;
67
+
68
+ try {
69
+ const resp = await fetch('/signalk/v2/api/resources/charts');
70
+ if (!resp.ok) throw new Error(`Failed to fetch charts: ${resp.status}`);
71
+ const data = await resp.json();
72
+ // clear existing options
73
+ chartSelect.innerHTML = '';
74
+
75
+ const entries = Object.entries(data || {});
76
+ entries
77
+ .sort(([, a], [, b]) => {
78
+ const na = (a && a.name) ? String(a.name) : '';
79
+ const nb = (b && b.name) ? String(b.name) : '';
80
+ return na.localeCompare(nb);
81
+ })
82
+ .forEach(([id, info]) => {
83
+
84
+ if (info.proxy === true) {
85
+ const opt = document.createElement('option');
86
+ opt.value = id;
87
+ opt.textContent = (info && info.name) ? info.name : id;
88
+ chartSelect.appendChild(opt);
89
+ }
90
+ });
91
+
92
+ // if no maps returned, add a fallback option
93
+ if (!chartSelect.options.length) {
94
+ const opt = document.createElement('option');
95
+ opt.value = '';
96
+ opt.textContent = '-- No maps available --';
97
+ chartSelect.appendChild(opt);
98
+ }
99
+ } catch (err) {
100
+ console.error('Error loading maps:', err);
101
+ }
102
+
103
+ document.getElementById('createJobButton').addEventListener('click', async () => {
104
+ try {
105
+ const chart = document.getElementById('chart').value;
106
+ const regionGUID = document.getElementById('region').value;
107
+ const maxZoom = document.getElementById('maxZoom').value;
108
+ const bbox = {
109
+ minLon: parseFloat(document.getElementById('bboxMinLon').value),
110
+ minLat: parseFloat(document.getElementById('bboxMinLat').value),
111
+ maxLon: parseFloat(document.getElementById('bboxMaxLon').value),
112
+ maxLat: parseFloat(document.getElementById('bboxMaxLat').value)
113
+ };
114
+ // const tile = {
115
+ // z: parseInt(document.getElementById('tileZ').value),
116
+ // x: parseInt(document.getElementById('tileX').value),
117
+ // y: parseInt(document.getElementById('tileY').value),
118
+
119
+ // };
120
+ // console.log(tile);
121
+ const type = document.querySelector('input[name="inputType"]:checked').value;
122
+ const body = {};
123
+ if (type === 'region') {
124
+ body.regionGUID = regionGUID;
125
+ } else if (type === 'bbox') {
126
+ body.bbox = bbox;
127
+ }
128
+ // else if (type === 'tile') {
129
+ // body.tile = tile;
130
+ // }
131
+ body.maxZoom = maxZoom;
132
+ const resp = await fetch(`/signalk/chart-tiles/cache/${chart}`, {
133
+ method: 'POST',
134
+ headers: {
135
+ 'Content-Type': 'application/json'
136
+ },
137
+ body: JSON.stringify(body)
138
+ });
139
+ if (!resp.ok) throw new Error(`Failed to seed charts: ${resp.status}`);
140
+ const data = await resp.json();
141
+ } catch (err) {
142
+ console.error('Error seeding charts:', err);
143
+ }
144
+ });
145
+
146
+ const fmt = n => (typeof n === 'number' ? n.toLocaleString() : '-');
147
+ const statusText = s => {
148
+ switch (s) {
149
+ case 0: return 'Stopped';
150
+ case 1: return 'Running';
151
+ default: return String(s);
152
+ }
153
+ };
154
+
155
+ function renderJobs(items) {
156
+ const tbody = document.querySelector('#activeJobsTable tbody');
157
+ if (!tbody) return;
158
+ tbody.innerHTML = '';
159
+ if (!Array.isArray(items) || items.length === 0) {
160
+ const tr = document.createElement('tr');
161
+ tr.innerHTML = '<td colspan="10" class="muted">No active jobs</td>';
162
+ tbody.appendChild(tr);
163
+ return;
164
+ }
165
+
166
+ items.forEach((it, idx) => {
167
+ const tr = document.createElement('tr');
168
+
169
+ const pct = (typeof it.progress === 'number' && isFinite(it.progress)) ? Math.max(0, Math.min(100, it.progress * 100)) : 0;
170
+ const pctText = `${pct.toFixed(1)}%`;
171
+ tr.innerHTML = [
172
+ `<td>${idx + 1}</td>`,
173
+ `<td>${it.chartName || '-'}</td>`,
174
+ `<td>${it.regionName || '-'}</td>`,
175
+ `<td>${fmt(it.totalTiles)}</td>`,
176
+ `<td>${fmt(it.downloadedTiles)}</td>`,
177
+ `<td>${fmt(it.cachedTiles)}</td>`,
178
+ `<td>${fmt(it.failedTiles)}</td>`,
179
+ `<td>
180
+ <span class="progress-bar" aria-hidden="true">
181
+ <span class="progress-fill" style="width:${pct}%"></span>
182
+ </span>
183
+ <span class="percent">${pctText}</span>
184
+ </td>`,
185
+ `<td>${statusText(it.status)}</td>`,
186
+ `
187
+ <td class="action-buttons">
188
+ <button class="btn startstop-btn" data-id="${it.id}" data-totalTiles="${it.totalTiles}" title="Start">
189
+ <svg class="icon-play ${it.status === 1 ? "hidden" : ""}" viewBox="0 0 24 24" width="16" height="16" fill="white">
190
+ <path d="M8 5v14l11-7z"/>
191
+ </svg>
192
+ <svg class="icon-pause ${it.status === 1 ? "" : "hidden"}" viewBox="0 0 24 24" width="16" height="16" fill="white">
193
+ <path d="M6 19h4V5H6zm8-14v14h4V5z"/>
194
+ </svg>
195
+ </button>
196
+ <button class="btn delete-btn" data-id="${it.id}" title="Delete">
197
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="white">
198
+ <path d="M3 6h18v2H3zm3 3h12v12H6zM8 2h8v2H8z"/>
199
+ </svg>
200
+ </button>
201
+ <button class="btn remove-btn" data-id="${it.id}" title="Remove">
202
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="white">
203
+ <path d="M18.3 5.71L12 12l6.3 6.29-1.41 1.42L10.59 13.4 4.29 19.71 2.88 18.3 9.17 12 2.88 5.71 4.29 4.3 10.59 10.6l6.3-6.3z"/>
204
+ </svg>
205
+ </button>
206
+ </td>
207
+ `
208
+ ].join('');
209
+
210
+ tr.querySelectorAll('.startstop-btn').forEach(button => {
211
+ button.addEventListener('click', () => {
212
+ const playIcon = button.querySelector('.icon-play');
213
+ const pauseIcon = button.querySelector('.icon-pause');
214
+ const jobId = button.getAttribute('data-id');
215
+ const totalTiles = button.getAttribute('data-totalTiles');
216
+ const isRunning = pauseIcon.classList.contains('hidden');
217
+ console.log(isRunning)
218
+ if (isRunning) {
219
+ // Switch to stop
220
+ if (totalTiles > 100000 && !confirm(`This job has more than ${totalTiles} tiles to download. Starting it may take a long time and put high load on the server. Are you sure you want to start it?`)) {
221
+ return;
222
+ }
223
+ takeAction(jobId, 'start');
224
+ button.title = 'Stop';
225
+ } else {
226
+ // Switch to start
227
+ takeAction(jobId, 'stop');
228
+ button.title = 'Start';
229
+ }
230
+ });
231
+ });
232
+
233
+ tr.querySelectorAll('.delete-btn').forEach(button => {
234
+ button.addEventListener('click', () => {
235
+ if (confirm("This will delete all cached tiles for this job. Are you sure?")) {
236
+ const jobId = button.getAttribute('data-id');
237
+ if (jobId) {
238
+ takeAction(jobId, 'delete');
239
+ }
240
+ }
241
+ });
242
+ });
243
+
244
+ tr.querySelectorAll('.remove-btn').forEach(button => {
245
+ button.addEventListener('click', () => {
246
+ const jobId = button.getAttribute('data-id');
247
+ if (jobId) {
248
+ takeAction(jobId, 'remove');
249
+ }
250
+ });
251
+ });
252
+
253
+ tbody.appendChild(tr);
254
+ });
255
+
256
+
257
+
258
+ function takeAction(jobId, action) {
259
+ fetch(`/signalk/chart-tiles/cache/jobs/${jobId}`, {
260
+ method: 'POST',
261
+ headers: {
262
+ 'Content-Type': 'application/json'
263
+ },
264
+ body: JSON.stringify({
265
+ action: action,
266
+ })
267
+ })
268
+ .then(response => {
269
+ if (!response.ok) {
270
+ throw new Error(`Failed to ${action} job ${jobId}: ${response.status}`);
271
+ }
272
+ fetchActiveJobs();
273
+ })
274
+ .catch(error => {
275
+ console.error(`Error ${action} job:`, error);
276
+ });
277
+ }
278
+ }
279
+
280
+
281
+
282
+ async function fetchActiveJobs() {
283
+ const tbody = document.querySelector('#activeJobsTable tbody');
284
+ try {
285
+ const resp = await fetch('/signalk/chart-tiles/cache/jobs');
286
+ if (!resp.ok) throw new Error('HTTP ' + resp.status);
287
+ const data = await resp.json();
288
+ renderJobs(data);
289
+ } catch (err) {
290
+ console.error('Error fetching active jobs:', err);
291
+ tbody.innerHTML = '<tr><td colspan="10" class="muted">Error loading active jobs</td></tr>';
292
+ }
293
+ }
294
+
295
+
296
+
297
+ fetchActiveJobs();
298
+ // refresh every 2 seconds
299
+ setInterval(fetchActiveJobs, 2000);
300
+
301
+
302
+ });
303
+
@@ -0,0 +1,116 @@
1
+ #activeJobsTable {
2
+ width: 100%;
3
+ border-collapse: collapse;
4
+ font-family: Arial, sans-serif;
5
+ margin-top: 8px;
6
+ }
7
+
8
+ #activeJobsTable th,
9
+ #progressTable td {
10
+ border: 1px solid #ddd;
11
+ padding: 6px 8px;
12
+ text-align: left;
13
+ vertical-align: middle;
14
+ font-size: 13px;
15
+ }
16
+
17
+ #activeJobsTable th {
18
+ background: #f4f4f4;
19
+ font-weight: 600;
20
+ }
21
+
22
+ .progress-bar {
23
+ width: 160px;
24
+ height: 18px;
25
+ background: #eee;
26
+ border-radius: 6px;
27
+ overflow: hidden;
28
+ display: inline-block;
29
+ vertical-align: middle;
30
+ margin-right: 8px;
31
+ }
32
+
33
+ .progress-fill {
34
+ display: inline-block;
35
+ width: 0%;
36
+ height: 100%;
37
+ box-sizing: border-box;
38
+ background: linear-gradient(90deg, #4caf50, #2e7d32);
39
+ transition: width 300ms ease;
40
+ }
41
+
42
+ .percent {
43
+ font-size: 12px;
44
+ color: #333;
45
+ vertical-align: middle;
46
+ }
47
+
48
+ .muted {
49
+ color: #666;
50
+ font-size: 12px;
51
+ }
52
+
53
+ .action-buttons {
54
+ display: flex;
55
+ gap: 8px;
56
+ justify-content: center;
57
+ align-items: center;
58
+ }
59
+
60
+ .btn {
61
+ display: flex;
62
+ justify-content: center;
63
+ align-items: center;
64
+ width: 36px;
65
+ height: 36px;
66
+ border: none;
67
+ border-radius: 8px;
68
+ cursor: pointer;
69
+ background-color: #444;
70
+ transition: transform 0.15s ease, background-color 0.2s ease;
71
+ padding: 0;
72
+ line-height: 0;
73
+ }
74
+
75
+ .btn:hover {
76
+ transform: scale(1.1);
77
+ }
78
+
79
+ .icon {
80
+ width: 20px;
81
+ height: 20px;
82
+ fill: white;
83
+ display: block;
84
+ /* ensures true centering */
85
+ vertical-align: middle;
86
+ stroke-linecap: round;
87
+ stroke-linejoin: round;
88
+ }
89
+
90
+ .startstop-btn {
91
+ background-color: #27ae60;
92
+ }
93
+
94
+ .startstop-btn.running {
95
+ background-color: #c0392b;
96
+ }
97
+
98
+ .delete-btn {
99
+ background-color: #e74c3c;
100
+ }
101
+
102
+ .delete-btn:hover {
103
+ background-color: #c0392b;
104
+ }
105
+
106
+ .remove-btn {
107
+ background-color: #7f8c8d;
108
+ }
109
+
110
+ .remove-btn:hover {
111
+ background-color: #95a5a6;
112
+ }
113
+
114
+ .hidden {
115
+ display: none;
116
+ }