@signalk/charts-plugin 3.7.0 → 3.8.1
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/.prettierignore +1 -0
- package/CHANGELOG.md +109 -0
- package/README.md +72 -0
- package/docs/screenshots/1_overview.png +0 -0
- package/docs/screenshots/2_region.png +0 -0
- package/docs/screenshots/3_jobs.png +0 -0
- package/package.json +16 -11
- package/plugin/cacheLayout.d.ts +72 -0
- package/plugin/cacheLayout.d.ts.map +1 -0
- package/plugin/cacheLayout.js +243 -0
- package/plugin/cacheLayout.js.map +1 -0
- package/plugin/chartDownloader.d.ts +122 -29
- package/plugin/chartDownloader.d.ts.map +1 -1
- package/plugin/chartDownloader.js +491 -314
- package/plugin/chartDownloader.js.map +1 -1
- package/plugin/chartDownloaderMBTilesHelpers.d.ts +10 -0
- package/plugin/chartDownloaderMBTilesHelpers.d.ts.map +1 -0
- package/plugin/chartDownloaderMBTilesHelpers.js +202 -0
- package/plugin/chartDownloaderMBTilesHelpers.js.map +1 -0
- package/plugin/chartDownloaderTileHelpers.d.ts +30 -0
- package/plugin/chartDownloaderTileHelpers.d.ts.map +1 -0
- package/plugin/chartDownloaderTileHelpers.js +277 -0
- package/plugin/chartDownloaderTileHelpers.js.map +1 -0
- package/plugin/charts.d.ts.map +1 -1
- package/plugin/charts.js +24 -39
- package/plugin/charts.js.map +1 -1
- package/plugin/constants.d.ts.map +1 -1
- package/plugin/index.d.ts.map +1 -1
- package/plugin/index.js +564 -47
- package/plugin/index.js.map +1 -1
- package/plugin/pluginStatus.js.map +1 -1
- package/plugin/projection.js.map +1 -1
- package/plugin/tileServer.d.ts +4 -2
- package/plugin/tileServer.d.ts.map +1 -1
- package/plugin/tileServer.js +37 -8
- package/plugin/tileServer.js.map +1 -1
- package/plugin/tokenProvider.d.ts +63 -0
- package/plugin/tokenProvider.d.ts.map +1 -0
- package/plugin/tokenProvider.js +258 -0
- package/plugin/tokenProvider.js.map +1 -0
- package/plugin/types.d.ts +37 -2
- package/plugin/types.d.ts.map +1 -1
- package/public/images/jobs.png +0 -0
- package/public/images/overview.png +0 -0
- package/public/images/region.png +0 -0
- package/public/index.html +392 -113
- package/public/js/index.js +620 -0
- package/public/leaflet/images/downloads.png +0 -0
- package/public/leaflet/images/layers-2x.png +0 -0
- package/public/leaflet/images/layers.png +0 -0
- package/public/leaflet/images/lifebuoy.png +0 -0
- package/public/leaflet/images/spritesheet-2x.png +0 -0
- package/public/leaflet/images/spritesheet.svg +156 -0
- package/public/leaflet/leaflet.css +677 -0
- package/public/leaflet/leaflet.draw.css +253 -0
- package/public/leaflet/leaflet.draw.js +2957 -0
- package/public/leaflet/leaflet.js +7467 -0
- package/public/style.css +351 -37
- package/docs/screenshots/1_placeholder.png +0 -0
- package/public/index.js +0 -349
|
@@ -3,38 +3,97 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.ChartDownloader = exports.ChartSeedingManager = exports.
|
|
7
|
-
const fs_1 = __importDefault(require("fs"));
|
|
6
|
+
exports.ChartDownloader = exports.ChartSeedingManager = exports.FailureKind = exports.TileSource = exports.State = void 0;
|
|
8
7
|
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
8
|
const projection_1 = require("./projection");
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
9
|
+
const cacheLayout_1 = require("./cacheLayout");
|
|
10
|
+
// All DB-touching helpers (delete / purge / vacuum / region-tile listing) need
|
|
11
|
+
// the raw node:sqlite handle exposed by @signalk/mbtiles. Reaching through
|
|
12
|
+
// `provider._mbtilesHandle!._db!` worked but lied about the contract: a proxy
|
|
13
|
+
// provider whose mbtiles file failed to open at startup has neither, and the
|
|
14
|
+
// non-null assertions papered over the resulting TypeError. Centralising here
|
|
15
|
+
// gives a single descriptive failure surface and removes seven `!` operators
|
|
16
|
+
// from call sites.
|
|
17
|
+
function requireMbtilesDb(provider) {
|
|
18
|
+
const db = provider._mbtilesHandle?._db;
|
|
19
|
+
if (!db) {
|
|
20
|
+
throw new Error(`Provider "${provider.identifier}": mbtiles cache not open. ` +
|
|
21
|
+
`Configure the provider with proxy:true and ensure the cache path ` +
|
|
22
|
+
`is writable; this operation needs the raw SQLite handle.`);
|
|
23
|
+
}
|
|
24
|
+
return db;
|
|
25
|
+
}
|
|
26
|
+
const chartDownloaderTileHelpers_1 = require("./chartDownloaderTileHelpers");
|
|
27
|
+
const chartDownloaderMBTilesHelpers_1 = require("./chartDownloaderMBTilesHelpers");
|
|
28
|
+
var State;
|
|
29
|
+
(function (State) {
|
|
30
|
+
State[State["Stopped"] = 0] = "Stopped";
|
|
31
|
+
State[State["Running"] = 1] = "Running";
|
|
32
|
+
})(State || (exports.State = State = {}));
|
|
33
|
+
var JobType;
|
|
34
|
+
(function (JobType) {
|
|
35
|
+
JobType[JobType["None"] = 0] = "None";
|
|
36
|
+
JobType[JobType["Seed"] = 1] = "Seed";
|
|
37
|
+
JobType[JobType["Delete"] = 2] = "Delete";
|
|
38
|
+
})(JobType || (JobType = {}));
|
|
39
|
+
var TileSource;
|
|
40
|
+
(function (TileSource) {
|
|
41
|
+
TileSource[TileSource["None"] = 0] = "None";
|
|
42
|
+
TileSource[TileSource["FetchedFromCache"] = 1] = "FetchedFromCache";
|
|
43
|
+
TileSource[TileSource["FetchedFromRemote"] = 2] = "FetchedFromRemote";
|
|
44
|
+
})(TileSource || (exports.TileSource = TileSource = {}));
|
|
45
|
+
// Categorised failure kinds for tile fetches. Lets the worker keep
|
|
46
|
+
// per-job counts so an admin staring at "500000 failed" can tell whether
|
|
47
|
+
// the upstream is timing out, the auth is wrong, or the network is gone.
|
|
48
|
+
// Used in TileFetchResult.failure and surfaced via job.info().failures.
|
|
49
|
+
var FailureKind;
|
|
50
|
+
(function (FailureKind) {
|
|
51
|
+
FailureKind["None"] = "none";
|
|
52
|
+
FailureKind["Timeout"] = "timeout";
|
|
53
|
+
FailureKind["HttpClient"] = "http_4xx";
|
|
54
|
+
FailureKind["HttpServer"] = "http_5xx";
|
|
55
|
+
FailureKind["Network"] = "network";
|
|
56
|
+
FailureKind["NoRemoteUrl"] = "no_remote_url";
|
|
57
|
+
})(FailureKind || (exports.FailureKind = FailureKind = {}));
|
|
58
|
+
// Default tile-fetch timeout. Bumped from 5s to 10s — some legitimate
|
|
59
|
+
// tile providers (NOAA WMS, custom inland-water sources) take 5-15s on
|
|
60
|
+
// a cold-cache backend tile and a 5s default marked them as failures.
|
|
61
|
+
// Per-provider override available via OnlineChartProvider.timeoutMs and
|
|
62
|
+
// TokenProviderConfig.tile.timeoutMs (added on the same change).
|
|
63
|
+
const TILE_FETCH_TIMEOUT_MS = 10000;
|
|
64
|
+
// Default backoff before a single retry on a transient 5xx (502/503).
|
|
65
|
+
// Picked to be long enough that an upstream momentary blip clears, short
|
|
66
|
+
// enough that 32 workers don't all stall on a hard outage. Configurable
|
|
67
|
+
// at runtime via ChartDownloader.tileRetryBackoffMs (test harness sets
|
|
68
|
+
// it to 0 to keep mocked-503 tests fast).
|
|
69
|
+
const DEFAULT_TILE_RETRY_BACKOFF_MS = 1500;
|
|
22
70
|
class ChartSeedingManager {
|
|
23
71
|
static ActiveJobs = {};
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Reset all module-level static state owned by ChartSeedingManager and
|
|
74
|
+
* ChartDownloader so a test harness can run from a known baseline.
|
|
75
|
+
*
|
|
76
|
+
* The plugin's static fields outlive a `stop()` / `start()` cycle in
|
|
77
|
+
* production (which is intended — process-global gates and ID sequences),
|
|
78
|
+
* but in a test process running 200+ tests in sequence the residue
|
|
79
|
+
* shows up as cross-test interference: nextJobId drifts upward, an
|
|
80
|
+
* earlier low-disk-space probe leaves CachingEnabled=false for 2s,
|
|
81
|
+
* stale CacheStatistics from one provider get seen by an unrelated
|
|
82
|
+
* test asserting "stats are an object".
|
|
83
|
+
*
|
|
84
|
+
* Production code never calls this. The test app's beforeEach does.
|
|
85
|
+
*/
|
|
86
|
+
static resetForTests() {
|
|
87
|
+
this.ActiveJobs = {};
|
|
88
|
+
ChartDownloader.resetForTests();
|
|
89
|
+
}
|
|
90
|
+
static async createJob(cachePath, provider, options, minZoom, maxZoom, feature = undefined, bbox = undefined) {
|
|
91
|
+
const downloader = new ChartDownloader(cachePath, provider, options);
|
|
37
92
|
this.ActiveJobs[downloader.ID] = downloader;
|
|
93
|
+
if (feature)
|
|
94
|
+
downloader.initalizeJobFromFeature(feature, minZoom, maxZoom);
|
|
95
|
+
else if (bbox)
|
|
96
|
+
downloader.initializeJobFromBBox(bbox, minZoom, maxZoom);
|
|
38
97
|
return downloader;
|
|
39
98
|
}
|
|
40
99
|
// Cancel all running jobs and clear the registry. Used when the plugin stops
|
|
@@ -48,206 +107,389 @@ class ChartSeedingManager {
|
|
|
48
107
|
}
|
|
49
108
|
exports.ChartSeedingManager = ChartSeedingManager;
|
|
50
109
|
class ChartDownloader {
|
|
51
|
-
|
|
52
|
-
chartsPath;
|
|
110
|
+
cachePath;
|
|
53
111
|
provider;
|
|
112
|
+
options;
|
|
54
113
|
static MINIMUM_FREE_DISK_SPACE = 1024 * 1024 * 1024; // 1 GB
|
|
55
114
|
static nextJobId = 1;
|
|
115
|
+
static CachingEnabled = true;
|
|
116
|
+
static CacheStatistics = new Map();
|
|
117
|
+
static TilesCached = 0;
|
|
118
|
+
static lastDiskSpaceCheck = 0;
|
|
119
|
+
static lastDiskSpaceResult = true;
|
|
120
|
+
// See DEFAULT_TILE_RETRY_BACKOFF_MS. Public so the test harness can drop
|
|
121
|
+
// it to 0; production code should treat it as read-only.
|
|
122
|
+
static tileRetryBackoffMs = DEFAULT_TILE_RETRY_BACKOFF_MS;
|
|
56
123
|
id = ChartDownloader.nextJobId++;
|
|
57
|
-
|
|
124
|
+
type = JobType.None;
|
|
125
|
+
state = State.Stopped;
|
|
126
|
+
status = 'Idle';
|
|
58
127
|
totalTiles = 0;
|
|
59
128
|
downloadedTiles = 0;
|
|
60
129
|
failedTiles = 0;
|
|
61
130
|
cachedTiles = 0;
|
|
62
|
-
|
|
131
|
+
deletedTiles = 0;
|
|
132
|
+
// Per-job failure histogram, keyed by FailureKind. Surfaced via
|
|
133
|
+
// info().failures so the UI / admin can tell timeouts apart from
|
|
134
|
+
// 5xx apart from auth failures without grepping logs. Only the
|
|
135
|
+
// first failure of each kind is logged in detail (sampleLogged)
|
|
136
|
+
// so a job with 500k failed tiles doesn't drown the log buffer.
|
|
137
|
+
failureCounts = {};
|
|
138
|
+
sampleLogged = new Set();
|
|
63
139
|
areaDescription = '';
|
|
64
140
|
cancelRequested = false;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
this.
|
|
141
|
+
regionName = 'Unnamed';
|
|
142
|
+
tiles;
|
|
143
|
+
tilesInDB;
|
|
144
|
+
constructor(cachePath, provider, options) {
|
|
145
|
+
this.cachePath = cachePath;
|
|
70
146
|
this.provider = provider;
|
|
147
|
+
this.options = options;
|
|
71
148
|
}
|
|
72
149
|
get ID() {
|
|
73
150
|
return this.id;
|
|
74
151
|
}
|
|
75
|
-
async
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
this.
|
|
83
|
-
this.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
this.
|
|
87
|
-
this.tilesToDownload = await this.filterCachedTiles(this.tiles);
|
|
88
|
-
this.status = Status.Stopped;
|
|
89
|
-
this.totalTiles = this.tiles.length;
|
|
90
|
-
this.cachedTiles = this.totalTiles - this.tilesToDownload.length;
|
|
91
|
-
this.areaDescription = `BBox: [${bbox.join(', ')}]`;
|
|
152
|
+
async initalizeJobFromFeature(feature, minZoom, maxZoom) {
|
|
153
|
+
const geojson = (0, chartDownloaderTileHelpers_1.convertFeatureToGeoJSON)(feature);
|
|
154
|
+
this.tiles = () => (0, chartDownloaderTileHelpers_1.getTilesForGeoJSON)(geojson, minZoom, maxZoom);
|
|
155
|
+
// Defer the requireMbtilesDb call to factory invocation: the handle may
|
|
156
|
+
// not be open yet when initialize* runs (e.g. createJob before the
|
|
157
|
+
// proxy mbtiles is reconciled), but it must be open by the time the
|
|
158
|
+
// generator is iterated.
|
|
159
|
+
this.tilesInDB = () => (0, chartDownloaderMBTilesHelpers_1.getMBTilesForPolygon)(requireMbtilesDb(this.provider), geojson, minZoom, maxZoom);
|
|
160
|
+
this.totalTiles = (0, chartDownloaderTileHelpers_1.countTilesAdaptiveIterative)(geojson, minZoom, maxZoom);
|
|
161
|
+
this.state = State.Stopped;
|
|
162
|
+
this.regionName = feature?.properties?.name ?? 'Unnamed';
|
|
163
|
+
this.areaDescription = `Region: ${this.regionName}`;
|
|
92
164
|
}
|
|
93
|
-
async
|
|
94
|
-
|
|
95
|
-
this.
|
|
96
|
-
this.
|
|
97
|
-
this.totalTiles =
|
|
98
|
-
this.
|
|
99
|
-
this.areaDescription = `
|
|
165
|
+
async initializeJobFromBBox(bbox, minZoom, maxZoom) {
|
|
166
|
+
const geojson = (0, chartDownloaderTileHelpers_1.convertBboxToGeoJSON)(bbox);
|
|
167
|
+
this.tiles = () => (0, chartDownloaderTileHelpers_1.getTilesForGeoJSON)(geojson, minZoom, maxZoom);
|
|
168
|
+
this.tilesInDB = () => (0, chartDownloaderMBTilesHelpers_1.getMBTilesForPolygon)(requireMbtilesDb(this.provider), geojson, minZoom, maxZoom);
|
|
169
|
+
this.totalTiles = (0, chartDownloaderTileHelpers_1.countTilesAdaptiveIterative)(geojson, minZoom, maxZoom);
|
|
170
|
+
this.state = State.Stopped;
|
|
171
|
+
this.areaDescription = `BBox: [${bbox
|
|
172
|
+
.map((v) => Number(v).toFixed(3))
|
|
173
|
+
.join(', ')}]`;
|
|
100
174
|
}
|
|
101
|
-
static DISK_CHECK_INTERVAL_MS = 30_000;
|
|
102
175
|
/**
|
|
103
176
|
* Download map tiles for a specific area.
|
|
104
177
|
*/
|
|
105
178
|
async seedCache() {
|
|
106
179
|
// Guard against double-start: a second call while Running would share
|
|
107
180
|
// counters and concurrency slots with the first and corrupt progress.
|
|
108
|
-
if (this.
|
|
181
|
+
if (this.state === State.Running)
|
|
109
182
|
return;
|
|
183
|
+
const concurrency = 32;
|
|
110
184
|
this.cancelRequested = false;
|
|
111
|
-
this.
|
|
112
|
-
this.tilesToDownload = await this.filterCachedTiles(this.tiles);
|
|
185
|
+
this.state = State.Running;
|
|
113
186
|
this.downloadedTiles = 0;
|
|
187
|
+
this.cachedTiles = 0;
|
|
114
188
|
this.failedTiles = 0;
|
|
115
|
-
this.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
189
|
+
this.failureCounts = {};
|
|
190
|
+
this.sampleLogged = new Set();
|
|
191
|
+
this.type = JobType.Seed;
|
|
192
|
+
console.log(`[charts-plugin] job ${this.id} started seeding ` +
|
|
193
|
+
`provider=${this.provider.identifier} ` +
|
|
194
|
+
`area="${this.areaDescription}" totalTiles=${this.totalTiles}`);
|
|
195
|
+
const tileIterator = this.tiles();
|
|
196
|
+
let generatorDone = false;
|
|
197
|
+
const nextTile = () => {
|
|
198
|
+
if (generatorDone || this.cancelRequested)
|
|
199
|
+
return null;
|
|
200
|
+
const { value, done } = tileIterator.next();
|
|
201
|
+
if (done) {
|
|
202
|
+
generatorDone = true;
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
return value;
|
|
206
|
+
};
|
|
207
|
+
const worker = async () => {
|
|
208
|
+
while (!this.cancelRequested) {
|
|
209
|
+
const tile = nextTile();
|
|
210
|
+
if (!tile)
|
|
211
|
+
return;
|
|
212
|
+
if (!ChartDownloader.CachingEnabled) {
|
|
213
|
+
this.cancelRequested = true;
|
|
214
|
+
this.status = 'Insufficient disk space, canceling job';
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
127
217
|
try {
|
|
128
|
-
const
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
218
|
+
const result = await ChartDownloader.getTileFromCacheOrRemote(this.cachePath, this.provider, tile, this.options.refetch);
|
|
219
|
+
if (result.source === TileSource.None) {
|
|
220
|
+
this.failedTiles++;
|
|
221
|
+
const kind = result.failure?.kind ?? FailureKind.Network;
|
|
222
|
+
this.failureCounts[kind] = (this.failureCounts[kind] ?? 0) + 1;
|
|
223
|
+
// Log the first instance of each failure kind in this job —
|
|
224
|
+
// gives an operator something to grep for ("job 42 timeout
|
|
225
|
+
// first at z=12 x=2057 y=1364 status=undefined") without
|
|
226
|
+
// 500k log lines if every tile fails.
|
|
227
|
+
if (!this.sampleLogged.has(kind)) {
|
|
228
|
+
this.sampleLogged.add(kind);
|
|
229
|
+
console.warn(`[charts-plugin] job ${this.id} ` +
|
|
230
|
+
`provider=${this.provider.identifier} ` +
|
|
231
|
+
`first ${kind} failure at z=${tile.z} x=${tile.x} y=${tile.y}` +
|
|
232
|
+
(result.failure?.status
|
|
233
|
+
? ` status=${result.failure.status}`
|
|
234
|
+
: ''));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
else if (result.source === TileSource.FetchedFromCache) {
|
|
238
|
+
this.cachedTiles++;
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
this.downloadedTiles++;
|
|
133
242
|
}
|
|
243
|
+
this.totalTiles = Math.max(this.totalTiles, this.downloadedTiles + this.failedTiles + this.cachedTiles);
|
|
134
244
|
}
|
|
135
245
|
catch (err) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
246
|
+
this.failedTiles++;
|
|
247
|
+
const msg = err.message ?? String(err);
|
|
248
|
+
this.failureCounts[FailureKind.Network] =
|
|
249
|
+
(this.failureCounts[FailureKind.Network] ?? 0) + 1;
|
|
250
|
+
if (!this.sampleLogged.has(FailureKind.Network)) {
|
|
251
|
+
this.sampleLogged.add(FailureKind.Network);
|
|
252
|
+
console.warn(`[charts-plugin] job ${this.id} ` +
|
|
253
|
+
`provider=${this.provider.identifier} ` +
|
|
254
|
+
`first uncaught failure at z=${tile.z} x=${tile.x} y=${tile.y}: ${msg}`);
|
|
255
|
+
}
|
|
139
256
|
}
|
|
140
257
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
this.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
258
|
+
};
|
|
259
|
+
this.status = 'Seeding';
|
|
260
|
+
const workers = Array.from({ length: concurrency }, () => worker());
|
|
261
|
+
await Promise.all(workers);
|
|
262
|
+
this.state = State.Stopped;
|
|
263
|
+
if (this.options.mbtiles) {
|
|
264
|
+
try {
|
|
265
|
+
this.status = 'Creating MBTiles';
|
|
266
|
+
const safeRegionName = this.regionName
|
|
267
|
+
.normalize('NFKC') // normalize unicode
|
|
268
|
+
.replace(/[^a-zA-Z0-9_-]/g, '_') // allow only safe chars
|
|
269
|
+
.replace(/_+/g, '_') // collapse repeats
|
|
270
|
+
.replace(/^_+|_+$/g, '') // trim underscores
|
|
271
|
+
.slice(0, 100); // limit length
|
|
272
|
+
// Snapshot exports go to ${cachePath}/exports/, which is excluded from
|
|
273
|
+
// the chart scanner — the file is a build artifact for offline transfer
|
|
274
|
+
// (USB stick to another SK server), not a chart the running plugin
|
|
275
|
+
// serves. Re-running the job overwrites the file freely; no scanner
|
|
276
|
+
// handle holds it open.
|
|
277
|
+
const baseDir = path_1.default.resolve(this.cachePath, cacheLayout_1.EXPORTS_DIR);
|
|
278
|
+
const filePath = path_1.default.resolve((0, cacheLayout_1.exportMbtilesPath)(this.cachePath, safeRegionName, this.provider.identifier));
|
|
279
|
+
// Same separator-aware check as the migrate endpoint: a naive
|
|
280
|
+
// startsWith would miss sibling-directory prefix collisions.
|
|
281
|
+
if (filePath !== baseDir && !filePath.startsWith(baseDir + path_1.default.sep)) {
|
|
282
|
+
throw new Error('Invalid path detected');
|
|
283
|
+
}
|
|
284
|
+
// The export copies the whole seeded set into a second mbtiles, so it
|
|
285
|
+
// needs roughly as much free space again. Bail before opening the file
|
|
286
|
+
// rather than filling the disk and corrupting the working cache.
|
|
287
|
+
if (!(await ChartDownloader.hasDiskSpace(baseDir))) {
|
|
288
|
+
throw new Error('Insufficient disk space for mbtiles export');
|
|
289
|
+
}
|
|
290
|
+
const mbtiles = await (0, chartDownloaderMBTilesHelpers_1.openOrCreateMbtiles)(filePath, this.provider);
|
|
291
|
+
const iterator = this.tiles();
|
|
292
|
+
for (const tile of iterator) {
|
|
293
|
+
const buffer = await ChartDownloader.getTileFromMbTiles(this.provider._mbtilesHandle, tile);
|
|
294
|
+
if (buffer) {
|
|
295
|
+
await ChartDownloader.cacheTileToMbTiles(mbtiles, tile, buffer);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
mbtiles._db?.exec('PRAGMA wal_checkpoint(TRUNCATE);');
|
|
152
299
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
300
|
+
catch (err) {
|
|
301
|
+
// The tile seed itself already succeeded (state is Stopped); only the
|
|
302
|
+
// optional export failed. Record it and rethrow so the HTTP handler's
|
|
303
|
+
// .catch() can log it without an unhandled rejection crashing the
|
|
304
|
+
// server.
|
|
305
|
+
this.status = `Failed: ${err.message}`;
|
|
306
|
+
console.error(`[charts-plugin] job ${this.id} mbtiles export failed: ${err.message}`);
|
|
307
|
+
throw err;
|
|
161
308
|
}
|
|
162
309
|
}
|
|
163
|
-
this.status =
|
|
310
|
+
this.status = 'Completed';
|
|
311
|
+
console.log(`[charts-plugin] job ${this.id} completed ` +
|
|
312
|
+
`provider=${this.provider.identifier} ` +
|
|
313
|
+
`downloaded=${this.downloadedTiles} cached=${this.cachedTiles} ` +
|
|
314
|
+
`failed=${this.failedTiles} ` +
|
|
315
|
+
`failures=${JSON.stringify(this.failureCounts)}`);
|
|
164
316
|
}
|
|
165
317
|
async deleteCache() {
|
|
166
|
-
this.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
318
|
+
this.state = State.Running;
|
|
319
|
+
this.deletedTiles = 0;
|
|
320
|
+
this.type = JobType.Delete;
|
|
321
|
+
this.status = 'Deleting tiles';
|
|
322
|
+
console.log(`[charts-plugin] job ${this.id} delete started ` +
|
|
323
|
+
`provider=${this.provider.identifier} totalTiles=${this.totalTiles}`);
|
|
324
|
+
try {
|
|
325
|
+
const db = requireMbtilesDb(this.provider);
|
|
326
|
+
await (0, chartDownloaderMBTilesHelpers_1.deleteTilesInChunks)(db, this.tilesInDB(), 1000, (deleted) => {
|
|
327
|
+
console.log(`[charts-plugin] job ${this.id} deleted ${deleted} / ${this.totalTiles}`);
|
|
328
|
+
});
|
|
329
|
+
this.status = 'Purging orphaned images';
|
|
330
|
+
await (0, chartDownloaderMBTilesHelpers_1.purgeAllOrphanImages)(db, 1000, (deleted, totalDeleted) => {
|
|
331
|
+
this.deletedTiles += deleted;
|
|
332
|
+
console.log(`[charts-plugin] job ${this.id} purged ${totalDeleted} orphans ` +
|
|
333
|
+
`(last chunk ${deleted})`);
|
|
334
|
+
});
|
|
335
|
+
if (this.options.vacuum) {
|
|
336
|
+
this.status = 'Vacuuming MBTiles database';
|
|
337
|
+
(0, chartDownloaderMBTilesHelpers_1.vacuumMbtiles)(db);
|
|
179
338
|
}
|
|
339
|
+
this.status = 'Completed';
|
|
340
|
+
this.state = State.Stopped;
|
|
341
|
+
console.log(`[charts-plugin] job ${this.id} delete completed ` +
|
|
342
|
+
`provider=${this.provider.identifier} ` +
|
|
343
|
+
`deletedTiles=${this.deletedTiles}`);
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
// Record the failure on the job and reset state so the job doesn't sit
|
|
347
|
+
// wedged in 'Running' forever. Rethrow so callers / tests still observe
|
|
348
|
+
// it — the HTTP handler attaches a .catch() to keep the rejection from
|
|
349
|
+
// becoming an unhandled rejection that crashes the server.
|
|
350
|
+
this.state = State.Stopped;
|
|
351
|
+
this.status = `Failed: ${err.message}`;
|
|
352
|
+
console.error(`[charts-plugin] job ${this.id} delete failed: ${err.message}`);
|
|
353
|
+
throw err;
|
|
180
354
|
}
|
|
181
|
-
this.status = Status.Stopped;
|
|
182
355
|
}
|
|
183
356
|
cancelJob() {
|
|
184
357
|
this.cancelRequested = true;
|
|
185
358
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
return null; // filter out cached tile
|
|
196
|
-
}
|
|
197
|
-
catch (err) {
|
|
198
|
-
if (err.code === 'ENOENT') {
|
|
199
|
-
return tile; // file does not exist → uncached
|
|
359
|
+
info() {
|
|
360
|
+
const progress = () => {
|
|
361
|
+
if (this.totalTiles > 0) {
|
|
362
|
+
if (this.type === JobType.Seed) {
|
|
363
|
+
return ((this.downloadedTiles + this.cachedTiles + this.failedTiles) /
|
|
364
|
+
this.totalTiles);
|
|
365
|
+
}
|
|
366
|
+
else if (this.type === JobType.Delete) {
|
|
367
|
+
return this.deletedTiles / this.totalTiles;
|
|
200
368
|
}
|
|
201
|
-
console.error('Unexpected fs error:', err);
|
|
202
|
-
return tile; // treat unknown errors as uncached
|
|
203
369
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
return results.filter((t) => t !== null);
|
|
207
|
-
}
|
|
208
|
-
info() {
|
|
370
|
+
return 0;
|
|
371
|
+
};
|
|
209
372
|
return {
|
|
210
373
|
id: this.id,
|
|
374
|
+
type: this.type,
|
|
211
375
|
chartName: this.provider.name,
|
|
212
376
|
regionName: this.areaDescription,
|
|
213
377
|
totalTiles: this.totalTiles,
|
|
214
378
|
downloadedTiles: this.downloadedTiles,
|
|
215
379
|
cachedTiles: this.cachedTiles,
|
|
216
380
|
failedTiles: this.failedTiles,
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
381
|
+
// Per-failure-kind histogram. Empty {} for jobs with no failures.
|
|
382
|
+
// Surfaced so the UI / admin can see "1234 timeouts, 5 auth, 200 5xx"
|
|
383
|
+
// instead of an undifferentiated failedTiles count.
|
|
384
|
+
failures: { ...this.failureCounts },
|
|
385
|
+
deletedTiles: this.deletedTiles,
|
|
386
|
+
progress: progress(),
|
|
387
|
+
status: this.status,
|
|
388
|
+
state: this.state
|
|
222
389
|
};
|
|
223
390
|
}
|
|
224
|
-
static async getTileFromCacheOrRemote(chartsPath, provider, tile) {
|
|
225
|
-
|
|
391
|
+
static async getTileFromCacheOrRemote(chartsPath, provider, tile, overwrite = false) {
|
|
392
|
+
let stats = ChartDownloader.CacheStatistics.get(provider.identifier);
|
|
393
|
+
if (!stats) {
|
|
394
|
+
stats = { requests: 0, hits: 0, misses: 0, failures: 0 };
|
|
395
|
+
ChartDownloader.CacheStatistics.set(provider.identifier, stats);
|
|
396
|
+
}
|
|
397
|
+
stats.requests++;
|
|
398
|
+
// Try MBTiles cache first
|
|
399
|
+
if (!overwrite) {
|
|
400
|
+
const mbtilesBuffer = await ChartDownloader.getTileFromMbTiles(provider._mbtilesHandle, tile);
|
|
401
|
+
if (mbtilesBuffer) {
|
|
402
|
+
stats.hits++;
|
|
403
|
+
return { buffer: mbtilesBuffer, source: TileSource.FetchedFromCache };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
// Cache miss: fetch from remote
|
|
407
|
+
const { buffer, failure } = await ChartDownloader.fetchTileFromRemoteDetailed(provider, tile);
|
|
408
|
+
ChartDownloader.CachingEnabled =
|
|
409
|
+
await ChartDownloader.hasDiskSpace(chartsPath);
|
|
410
|
+
if (ChartDownloader.CachingEnabled && buffer) {
|
|
411
|
+
stats.misses++;
|
|
412
|
+
//Writing to mbtiles must be awaited
|
|
413
|
+
await ChartDownloader.cacheTileToMbTiles(provider._mbtilesHandle, tile, buffer);
|
|
414
|
+
return {
|
|
415
|
+
buffer,
|
|
416
|
+
source: TileSource.FetchedFromRemote,
|
|
417
|
+
failure: { kind: FailureKind.None }
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
stats.failures++;
|
|
421
|
+
return { buffer: null, source: TileSource.None, failure };
|
|
422
|
+
}
|
|
423
|
+
static async getTileFromMbTiles(mbtilesHandle, tile) {
|
|
424
|
+
if (!mbtilesHandle) {
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
226
427
|
try {
|
|
227
|
-
const
|
|
228
|
-
|
|
428
|
+
const cachedBuffer = await new Promise((resolve, reject) => {
|
|
429
|
+
mbtilesHandle.getTile(tile.z, tile.x, tile.y, (err, data) => {
|
|
430
|
+
if (err) {
|
|
431
|
+
// MBTiles returns an error when tile does not exist
|
|
432
|
+
if (err.message?.includes('Tile does not exist')) {
|
|
433
|
+
return resolve(null);
|
|
434
|
+
}
|
|
435
|
+
return reject(err);
|
|
436
|
+
}
|
|
437
|
+
resolve(data);
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
if (cachedBuffer) {
|
|
441
|
+
return cachedBuffer;
|
|
442
|
+
}
|
|
229
443
|
}
|
|
230
444
|
catch (err) {
|
|
231
|
-
|
|
445
|
+
console.error('Failed to read tile from MBTiles cache:', err);
|
|
232
446
|
}
|
|
233
|
-
|
|
234
|
-
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
static async cacheTileToMbTiles(mbtilesHandle, tile, buffer) {
|
|
450
|
+
if (buffer && ChartDownloader.CachingEnabled) {
|
|
235
451
|
try {
|
|
236
|
-
await
|
|
237
|
-
|
|
452
|
+
await new Promise((resolve, reject) => {
|
|
453
|
+
mbtilesHandle.putTile(tile.z, tile.x, tile.y, buffer, (err) => {
|
|
454
|
+
if (err)
|
|
455
|
+
reject(err);
|
|
456
|
+
else
|
|
457
|
+
resolve();
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
ChartDownloader.TilesCached++;
|
|
238
461
|
}
|
|
239
462
|
catch (err) {
|
|
240
|
-
console.error(
|
|
463
|
+
console.error('Failed to write tile to mbtiles cache:', err);
|
|
241
464
|
}
|
|
242
465
|
}
|
|
243
|
-
return buffer;
|
|
244
466
|
}
|
|
245
|
-
|
|
467
|
+
/**
|
|
468
|
+
* Detailed-result variant of fetchTileFromRemote. Returns the buffer on
|
|
469
|
+
* success and a `failure` discriminator on every other path so the
|
|
470
|
+
* caller can attribute "failed" tiles to a specific cause (timeout vs
|
|
471
|
+
* 5xx vs network) for diagnostic logs and per-job counters.
|
|
472
|
+
*
|
|
473
|
+
* Retries once on 502/503 with TILE_RETRY_BACKOFF_MS backoff: those are
|
|
474
|
+
* the response codes that genuinely benefit from one more try (LB hiccup,
|
|
475
|
+
* backend cold-start). 504 is excluded because it means the upstream
|
|
476
|
+
* already waited; another wait makes the per-tile latency intolerable.
|
|
477
|
+
* 4xx are not retried (auth or "tile genuinely doesn't exist" — neither
|
|
478
|
+
* gets better by trying again).
|
|
479
|
+
*/
|
|
480
|
+
static async fetchTileFromRemoteDetailed(provider, tile, timeoutMs = TILE_FETCH_TIMEOUT_MS) {
|
|
481
|
+
// Token providers fetch their token lazily; awaiting here ensures
|
|
482
|
+
// remoteUrl/headers below template against a non-stale token. Fast-path
|
|
483
|
+
// when the cached token is still inside its TTL: the call returns
|
|
484
|
+
// synchronously without I/O.
|
|
485
|
+
if (provider._tokenProvider) {
|
|
486
|
+
await provider._tokenProvider.ensureFreshToken();
|
|
487
|
+
}
|
|
246
488
|
// Local (non-proxy) providers have no remoteUrl; the POST /cache endpoint
|
|
247
489
|
// is open to any provider, so callers can still land here and should get
|
|
248
490
|
// a well-defined null rather than a crash.
|
|
249
491
|
if (!provider.remoteUrl) {
|
|
250
|
-
return null;
|
|
492
|
+
return { buffer: null, failure: { kind: FailureKind.NoRemoteUrl } };
|
|
251
493
|
}
|
|
252
494
|
let url = provider.remoteUrl
|
|
253
495
|
.replace('{z}', tile.z.toString())
|
|
@@ -272,175 +514,110 @@ class ChartDownloader {
|
|
|
272
514
|
url = url.replace('{bbox_3857}', `${mx1},${my1},${mx2},${my2}`);
|
|
273
515
|
}
|
|
274
516
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
headers: provider.headers,
|
|
280
|
-
signal: controller.signal
|
|
281
|
-
});
|
|
282
|
-
// Clear the abort timer as soon as the response head is in. A long body
|
|
283
|
-
// read was otherwise racing the timeout and could be aborted mid-stream
|
|
284
|
-
// while the caller waited on arrayBuffer().
|
|
285
|
-
clearTimeout(timeoutId);
|
|
286
|
-
if (!response.ok) {
|
|
287
|
-
return null;
|
|
517
|
+
let lastFailure = { kind: FailureKind.Network };
|
|
518
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
519
|
+
if (attempt > 0) {
|
|
520
|
+
await new Promise((r) => setTimeout(r, ChartDownloader.tileRetryBackoffMs));
|
|
288
521
|
}
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
522
|
+
const controller = new AbortController();
|
|
523
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
524
|
+
try {
|
|
525
|
+
const response = await fetch(url, {
|
|
526
|
+
headers: provider.headers,
|
|
527
|
+
signal: controller.signal
|
|
528
|
+
});
|
|
529
|
+
// Clear the abort timer as soon as the response head is in. A long
|
|
530
|
+
// body read was otherwise racing the timeout and could be aborted
|
|
531
|
+
// mid-stream while the caller waited on arrayBuffer().
|
|
532
|
+
clearTimeout(timeoutId);
|
|
533
|
+
if (response.ok) {
|
|
534
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
535
|
+
return {
|
|
536
|
+
buffer: Buffer.from(arrayBuffer),
|
|
537
|
+
failure: { kind: FailureKind.None }
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
// For token providers, an upstream 401/403 most likely means the
|
|
541
|
+
// cached token has expired or been revoked. Invalidate so the next
|
|
542
|
+
// fetch refreshes it. 5xx and other non-auth errors don't trigger
|
|
543
|
+
// this — they're not about credentials.
|
|
544
|
+
if ((response.status === 401 || response.status === 403) &&
|
|
545
|
+
provider._tokenProvider) {
|
|
546
|
+
provider._tokenProvider.invalidateToken();
|
|
307
547
|
}
|
|
548
|
+
const kind = response.status >= 500
|
|
549
|
+
? FailureKind.HttpServer
|
|
550
|
+
: FailureKind.HttpClient;
|
|
551
|
+
lastFailure = { kind, status: response.status };
|
|
552
|
+
// Retry only on 502/503 — those are the LB/backend-blip codes
|
|
553
|
+
// worth a second try. 504 already implies a long wait upstream;
|
|
554
|
+
// 4xx won't change on retry.
|
|
555
|
+
if (response.status === 502 || response.status === 503)
|
|
556
|
+
continue;
|
|
557
|
+
return { buffer: null, failure: lastFailure };
|
|
558
|
+
}
|
|
559
|
+
catch (err) {
|
|
560
|
+
clearTimeout(timeoutId);
|
|
561
|
+
// AbortError vs other network errors: the timeout's controller.abort
|
|
562
|
+
// throws an AbortError. Treat any other thrown error as Network.
|
|
563
|
+
const isAbort = err.name === 'AbortError' ||
|
|
564
|
+
controller.signal.aborted;
|
|
565
|
+
lastFailure = {
|
|
566
|
+
kind: isAbort ? FailureKind.Timeout : FailureKind.Network
|
|
567
|
+
};
|
|
568
|
+
// Don't retry on timeout (already slow) or generic network error
|
|
569
|
+
// (likely DNS/route failure — won't recover in 1.5s).
|
|
570
|
+
return { buffer: null, failure: lastFailure };
|
|
308
571
|
}
|
|
309
572
|
}
|
|
310
|
-
return
|
|
573
|
+
return { buffer: null, failure: lastFailure };
|
|
311
574
|
}
|
|
312
575
|
/**
|
|
313
|
-
*
|
|
314
|
-
*
|
|
576
|
+
* Backwards-compatible wrapper for callers that only need the buffer.
|
|
577
|
+
* Internal callers should prefer fetchTileFromRemoteDetailed for the
|
|
578
|
+
* categorised failure information.
|
|
315
579
|
*/
|
|
316
|
-
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
const crossesAntiMeridian = minLon > maxLon;
|
|
320
|
-
// Respect the provider's minzoom: low zooms outside the provider's range
|
|
321
|
-
// would 404 from the remote and just inflate totalTiles.
|
|
322
|
-
const minZoom = Math.max(tileServer_1.MIN_ZOOM, this.provider.minzoom ?? tileServer_1.MIN_ZOOM);
|
|
323
|
-
// Helper to process a lon/lat box normally. lonLatToTileXY returns
|
|
324
|
-
// tile-Y increasing southward, so for a box with minLat < maxLat the
|
|
325
|
-
// south edge yields the larger tile-Y.
|
|
326
|
-
const processBBox = (lo1, la1, lo2, la2) => {
|
|
327
|
-
for (let z = minZoom; z <= maxZoom; z++) {
|
|
328
|
-
const [minX, maxY] = (0, projection_1.lonLatToTile)(lo1, la1, z); // SW corner
|
|
329
|
-
const [maxX, minY] = (0, projection_1.lonLatToTile)(lo2, la2, z); // NE corner
|
|
330
|
-
for (let x = minX; x <= maxX; x++) {
|
|
331
|
-
for (let y = minY; y <= maxY; y++) {
|
|
332
|
-
tiles.push({ x, y, z });
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
};
|
|
337
|
-
if (!crossesAntiMeridian) {
|
|
338
|
-
// normal
|
|
339
|
-
processBBox(minLon, minLat, maxLon, maxLat);
|
|
340
|
-
}
|
|
341
|
-
else {
|
|
342
|
-
// crosses antimeridian — split into two boxes:
|
|
343
|
-
// [minLon -> 180] and [-180 -> maxLon]
|
|
344
|
-
processBBox(minLon, minLat, 180, maxLat);
|
|
345
|
-
processBBox(-180, minLat, maxLon, maxLat);
|
|
346
|
-
}
|
|
347
|
-
return tiles;
|
|
348
|
-
}
|
|
349
|
-
getTilesForGeoJSON(geojson, zoomMin = 1, zoomMax = 14) {
|
|
350
|
-
const tiles = [];
|
|
351
|
-
for (const feature of geojson.features) {
|
|
352
|
-
if (feature.geometry.type !== 'Polygon' &&
|
|
353
|
-
feature.geometry.type !== 'MultiPolygon') {
|
|
354
|
-
console.warn('Skipping non-polygon feature');
|
|
355
|
-
continue;
|
|
356
|
-
}
|
|
357
|
-
const boundingBox = (0, bbox_1.bbox)(feature.geometry); // [minX, minY, maxX, maxY]
|
|
358
|
-
for (let z = zoomMin; z <= zoomMax; z++) {
|
|
359
|
-
const [minX, minY] = (0, projection_1.lonLatToTile)(boundingBox[0], boundingBox[3], z); // top-left
|
|
360
|
-
const [maxX, maxY] = (0, projection_1.lonLatToTile)(boundingBox[2], boundingBox[1], z); // bottom-right
|
|
361
|
-
for (let x = minX; x <= maxX; x++) {
|
|
362
|
-
for (let y = minY; y <= maxY; y++) {
|
|
363
|
-
// Cheap AABB pre-filter avoids allocating a turf polygon and
|
|
364
|
-
// running booleanIntersects for tiles that can't possibly
|
|
365
|
-
// overlap the feature's bbox. Saves 90%+ of the turf work on
|
|
366
|
-
// concave regions.
|
|
367
|
-
const [tMinLon, tMinLat, tMaxLon, tMaxLat] = (0, projection_1.tileToBBox)(x, y, z);
|
|
368
|
-
if (tMaxLon < boundingBox[0] ||
|
|
369
|
-
tMinLon > boundingBox[2] ||
|
|
370
|
-
tMaxLat < boundingBox[1] ||
|
|
371
|
-
tMinLat > boundingBox[3]) {
|
|
372
|
-
continue;
|
|
373
|
-
}
|
|
374
|
-
const tilePoly = this.bboxPolygon([
|
|
375
|
-
tMinLon,
|
|
376
|
-
tMinLat,
|
|
377
|
-
tMaxLon,
|
|
378
|
-
tMaxLat
|
|
379
|
-
]);
|
|
380
|
-
if ((0, boolean_intersects_1.default)(feature, tilePoly)) {
|
|
381
|
-
tiles.push({ x, y, z });
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
return tiles;
|
|
580
|
+
static async fetchTileFromRemote(provider, tile, timeoutMs = TILE_FETCH_TIMEOUT_MS) {
|
|
581
|
+
const { buffer } = await ChartDownloader.fetchTileFromRemoteDetailed(provider, tile, timeoutMs);
|
|
582
|
+
return buffer;
|
|
388
583
|
}
|
|
389
|
-
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
584
|
+
static async hasDiskSpace(path) {
|
|
585
|
+
const now = Date.now();
|
|
586
|
+
// cache for 2 seconds
|
|
587
|
+
if (now - ChartDownloader.lastDiskSpaceCheck < 2000) {
|
|
588
|
+
return ChartDownloader.lastDiskSpaceResult;
|
|
393
589
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
timestamp: region.timestamp || '',
|
|
402
|
-
source: region.$source || '',
|
|
403
|
-
...(feature.properties || {})
|
|
404
|
-
}
|
|
405
|
-
};
|
|
406
|
-
const splitGeoFeature = (0, geojson_antimeridian_cut_1.default)(geoFeature);
|
|
407
|
-
const features = [];
|
|
408
|
-
const pushFeaturePolygon = (orig, coords, idx) => {
|
|
409
|
-
const poly = {
|
|
410
|
-
type: 'Feature',
|
|
411
|
-
id: idx != null && orig.id ? `${orig.id}-${idx}` : orig.id,
|
|
412
|
-
geometry: {
|
|
413
|
-
type: 'Polygon',
|
|
414
|
-
coordinates: coords
|
|
415
|
-
},
|
|
416
|
-
properties: orig.properties || {}
|
|
417
|
-
};
|
|
418
|
-
features.push(poly);
|
|
419
|
-
};
|
|
420
|
-
const f = splitGeoFeature;
|
|
421
|
-
if (f.geometry && f.geometry.type === 'MultiPolygon') {
|
|
422
|
-
const coords = f.geometry.coordinates;
|
|
423
|
-
coords.forEach((ring, i) => pushFeaturePolygon(f, ring, i));
|
|
590
|
+
try {
|
|
591
|
+
// check-disk-space is ESM-only and exports no require entry, so this
|
|
592
|
+
// CommonJS build can only reach it through a real dynamic import.
|
|
593
|
+
const { checkDiskSpace } = await import('check-disk-space');
|
|
594
|
+
const { free } = await checkDiskSpace(path);
|
|
595
|
+
ChartDownloader.lastDiskSpaceResult =
|
|
596
|
+
free >= ChartDownloader.MINIMUM_FREE_DISK_SPACE;
|
|
424
597
|
}
|
|
425
|
-
|
|
426
|
-
|
|
598
|
+
catch (err) {
|
|
599
|
+
ChartDownloader.lastDiskSpaceResult = false;
|
|
427
600
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
features
|
|
431
|
-
};
|
|
601
|
+
ChartDownloader.lastDiskSpaceCheck = now;
|
|
602
|
+
return ChartDownloader.lastDiskSpaceResult;
|
|
432
603
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
604
|
+
static getStatistics() {
|
|
605
|
+
return Object.fromEntries(ChartDownloader.CacheStatistics);
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Reset the static fields owned by ChartDownloader. See
|
|
609
|
+
* ChartSeedingManager.resetForTests for why this exists.
|
|
610
|
+
*/
|
|
611
|
+
static resetForTests() {
|
|
612
|
+
ChartDownloader.nextJobId = 1;
|
|
613
|
+
ChartDownloader.CachingEnabled = true;
|
|
614
|
+
ChartDownloader.CacheStatistics = new Map();
|
|
615
|
+
ChartDownloader.TilesCached = 0;
|
|
616
|
+
ChartDownloader.lastDiskSpaceCheck = 0;
|
|
617
|
+
ChartDownloader.lastDiskSpaceResult = true;
|
|
618
|
+
// Skip the production 1.5s backoff for mocked-503 tests; production
|
|
619
|
+
// value is restored per the constant on plugin reload.
|
|
620
|
+
ChartDownloader.tileRetryBackoffMs = 0;
|
|
444
621
|
}
|
|
445
622
|
}
|
|
446
623
|
exports.ChartDownloader = ChartDownloader;
|