@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 +4 -0
- package/package.json +9 -3
- package/plugin/chartDownloader.d.ts +76 -0
- package/plugin/chartDownloader.js +400 -0
- package/plugin/index.js +120 -18
- package/plugin/types.d.ts +7 -0
- package/public/index.html +106 -0
- package/public/index.js +303 -0
- package/public/style.css +116 -0
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.
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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.
|
|
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>
|
package/public/index.js
ADDED
|
@@ -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
|
+
|
package/public/style.css
ADDED
|
@@ -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
|
+
}
|