pmtiles-swarm 0.64.0 → 0.66.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/CHANGELOG.md +56 -0
- package/docs/tile-stacks.md +68 -0
- package/package.json +1 -1
- package/src/api.js +2 -0
- package/src/bake-jobs.js +82 -23
- package/src/bake.js +110 -26
- package/src/config.js +11 -0
- package/src/pixel-worker.js +70 -0
- package/src/pixels.js +129 -0
- package/src/stack-tile.js +32 -1
- package/src/web/index.html +151 -21
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,62 @@
|
|
|
4
4
|
### ✨ Features and improvements
|
|
5
5
|
- _...Add new stuff here..._
|
|
6
6
|
|
|
7
|
+
### 🐞 Bug fixes
|
|
8
|
+
- _...Add new stuff here..._
|
|
9
|
+
|
|
10
|
+
## 0.66.0
|
|
11
|
+
### ✨ Features and improvements
|
|
12
|
+
- **An export asks where it should go, and what to call it.** **Export to archive** now opens a
|
|
13
|
+
dialog instead of a confirmation, because there is something to decide. The save location uses
|
|
14
|
+
the same picker every other destination in this console uses, so a location named once under
|
|
15
|
+
Settings is offered here too — a baked planet is hundreds of gigabytes and which disk it lands on
|
|
16
|
+
is not a detail. The name and description default to the stack's and can be changed.
|
|
17
|
+
|
|
18
|
+
The filename is shown as the name is typed. A name is not a filename — separators and the
|
|
19
|
+
characters Windows refuses become dashes, and the date is appended so successive builds do not
|
|
20
|
+
collide — and a preview promising one thing while the server writes another would be worse than
|
|
21
|
+
no preview at all. A test asserts the browser's rule and `safeSegment` agree.
|
|
22
|
+
|
|
23
|
+
### 🐞 Bug fixes
|
|
24
|
+
- **A save location the node did not know was silently ignored.** The export resolved it after the
|
|
25
|
+
merge and swallowed the error, so naming a location that did not exist, or one that could not be
|
|
26
|
+
written, meant an hour of merging answered with a shrug and the archive on the default disk. It
|
|
27
|
+
is resolved before anything starts and the error is allowed through, which is what lets the
|
|
28
|
+
dialog put it beside the field that caused it.
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### 🐞 Bug fixes
|
|
33
|
+
- _...Add new stuff here..._
|
|
34
|
+
|
|
35
|
+
## 0.65.0
|
|
36
|
+
### ✨ Features and improvements
|
|
37
|
+
- **A bake stays out of the way of the node it runs on.** It runs in the main process — unlike
|
|
38
|
+
hashing, there is no sidecar to send it to — and the merge's pixel maths is entirely
|
|
39
|
+
synchronous, so every millisecond of it is a millisecond the node is not answering requests.
|
|
40
|
+
Three changes, all of them from measuring rather than assuming.
|
|
41
|
+
|
|
42
|
+
**The pixel maths moved to a worker.** `src/pixels.js` runs `elevation.js` and `rgba.js`
|
|
43
|
+
unchanged on another thread. Against a request arriving every 5 ms while a bake runs, the delay
|
|
44
|
+
that request sees at the 99th percentile falls from 10.9 ms to 6.5 ms with two sources, and from
|
|
45
|
+
18.6 ms to 10.6 ms with four. The bake is 2–17% slower for it. Rasters are handed over rather
|
|
46
|
+
than copied — a decoded tile is most of a megabyte per source — so a merge takes ownership of
|
|
47
|
+
what it is given, which is safe because nothing reads a contribution afterwards and is asserted
|
|
48
|
+
rather than assumed. Serving does not use it: one tile is a few milliseconds nobody notices.
|
|
49
|
+
|
|
50
|
+
**The checkpoint appends instead of rewriting.** It went through `serializeDirectory`, which was
|
|
51
|
+
elegant reuse and the wrong tool — that is a distribution format, and producing it costs a varint
|
|
52
|
+
pass over every entry. Re-encoding all of them every time also made the total work quadratic in
|
|
53
|
+
the length of the job. Fixed 24-byte records can be appended, and only the last one can change
|
|
54
|
+
once written, so a checkpoint costs the work since the last one: **446 ms at four million entries
|
|
55
|
+
becomes 11 ms, and stays 11 ms**.
|
|
56
|
+
|
|
57
|
+
**And `stacks.bakePauseMs`**, how long a bake waits between tiles. Zero by default, which is
|
|
58
|
+
right for a node baking and doing nothing else. On a node that is also serving maps it is the
|
|
59
|
+
direct trade between how long the bake takes and how much of the machine it takes while running.
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
7
63
|
### 🐞 Bug fixes
|
|
8
64
|
- _...Add new stuff here..._
|
|
9
65
|
|
package/docs/tile-stacks.md
CHANGED
|
@@ -45,6 +45,7 @@ of its parts.
|
|
|
45
45
|
- [What to iterate](#what-to-iterate)
|
|
46
46
|
- [Running it](#running-it)
|
|
47
47
|
- [What a baked archive says about itself](#what-a-baked-archive-says-about-itself)
|
|
48
|
+
- [Staying out of the way of the node it runs on](#staying-out-of-the-way-of-the-node-it-runs-on)
|
|
48
49
|
- [What identifies a bake](#what-identifies-a-bake)
|
|
49
50
|
- [Starting one, and watching it](#starting-one-and-watching-it)
|
|
50
51
|
- [What exists now](#what-exists-now)
|
|
@@ -636,6 +637,59 @@ reason the live stack answers 404.
|
|
|
636
637
|
- **Cancellable**, and stopping keeps the work: realising it is the wrong recipe
|
|
637
638
|
should not mean waiting it out, and it should not mean starting over either.
|
|
638
639
|
|
|
640
|
+
### Staying out of the way of the node it runs on
|
|
641
|
+
|
|
642
|
+
A bake runs in the main process, and unlike hashing there is no sidecar to send
|
|
643
|
+
it to. That matters because `elevation.js` and `rgba.js` are entirely
|
|
644
|
+
synchronous — decoding heights, masking, resampling and painting are loops over
|
|
645
|
+
typed arrays — so every millisecond of it is a millisecond the node is not
|
|
646
|
+
answering requests. Three things follow from having measured that rather than
|
|
647
|
+
assumed it.
|
|
648
|
+
|
|
649
|
+
**The pixel maths goes to a worker.** `src/pixels.js` and `src/pixel-worker.js`
|
|
650
|
+
run the same functions, unchanged, on another thread. Measured against a request
|
|
651
|
+
arriving every 5 ms while a bake runs, the delay that request sees at the 99th
|
|
652
|
+
percentile:
|
|
653
|
+
|
|
654
|
+
| workload | on the main thread | in a worker |
|
|
655
|
+
| ------------------------- | ------------------ | ----------- |
|
|
656
|
+
| 2 sources, 512px | 10.9 ms | 6.5 ms |
|
|
657
|
+
| 4 sources, 512px | 18.6 ms | 10.6 ms |
|
|
658
|
+
| 8 sources, 512px, blurred | 22.3 ms | 17.1 ms |
|
|
659
|
+
|
|
660
|
+
The bake itself is 2–17% slower for it, which is the trade. Rasters are handed
|
|
661
|
+
to the worker rather than copied — a decoded tile is most of a megabyte per
|
|
662
|
+
source, and copying each one is work on the very thread this exists to keep
|
|
663
|
+
free. The cost of that is the caller gives them up, which is safe because
|
|
664
|
+
nothing reads a contribution after its merge, and is asserted rather than left
|
|
665
|
+
as a comment.
|
|
666
|
+
|
|
667
|
+
Serving does not use it. One tile's merge is a few milliseconds nobody notices,
|
|
668
|
+
and a thread per request would cost more than it saved.
|
|
669
|
+
|
|
670
|
+
**The checkpoint appends instead of rewriting.** The first version wrote entries
|
|
671
|
+
through `serializeDirectory`, which was elegant reuse and the wrong tool: that
|
|
672
|
+
is a _distribution_ format, and producing it costs a varint pass over every
|
|
673
|
+
entry — 264 ms at a million entries, of which only 12 ms is the compression.
|
|
674
|
+
Re-encoding all of them every checkpoint also made the total work quadratic in
|
|
675
|
+
the length of the job.
|
|
676
|
+
|
|
677
|
+
A checkpoint is read once, by this process, on the machine that wrote it. Fixed
|
|
678
|
+
24-byte records cost nothing to produce and can be appended, and only the last
|
|
679
|
+
one can change after it is written — a run of identical tiles extends it. So a
|
|
680
|
+
checkpoint now costs the work since the last one:
|
|
681
|
+
|
|
682
|
+
| entries | rewriting everything | appending |
|
|
683
|
+
| --------- | -------------------- | --------- |
|
|
684
|
+
| 100,000 | 36 ms | 11 ms |
|
|
685
|
+
| 1,000,000 | 191 ms | 11 ms |
|
|
686
|
+
| 4,000,000 | 446 ms | 11 ms |
|
|
687
|
+
|
|
688
|
+
**And there is a knob.** `stacks.bakePauseMs` is how long a bake waits between
|
|
689
|
+
tiles. Zero is as fast as it can go, which is right for a node baking and doing
|
|
690
|
+
nothing else. On a node that is also serving maps it is the direct trade: how
|
|
691
|
+
long the bake takes against how much of the machine it takes while it runs.
|
|
692
|
+
|
|
639
693
|
### What identifies a bake
|
|
640
694
|
|
|
641
695
|
`bakeRevision` is the recipe's revision and what each source resolved to,
|
|
@@ -676,6 +730,20 @@ worth writing at all.
|
|
|
676
730
|
planet bake is hours, and a request that waited for the archive is a request
|
|
677
731
|
nothing could hold open. `DELETE` on the same address stops it.
|
|
678
732
|
|
|
733
|
+
The button opens a dialog rather than a confirmation, because there is
|
|
734
|
+
something to decide. **Where it lands** uses the same picker every other
|
|
735
|
+
destination in this console uses, so a location named once under Settings is
|
|
736
|
+
offered here too — and a baked planet is hundreds of gigabytes, so which disk it
|
|
737
|
+
goes on is not a detail. The location is resolved _before_ anything is merged:
|
|
738
|
+
a name this node does not know, or a path it cannot write, is the caller's
|
|
739
|
+
mistake and they can fix it, but only if they are told now rather than an hour
|
|
740
|
+
later.
|
|
741
|
+
|
|
742
|
+
**What it is called** defaults to the stack's title and can be changed, as can
|
|
743
|
+
the description. The dialog shows the filename as the name is typed, because a
|
|
744
|
+
name is not a filename — the same sanitising the server does, and a preview that
|
|
745
|
+
promised something else would be worse than no preview.
|
|
746
|
+
|
|
679
747
|
A bake has two halves and they are watched in two places, deliberately. Merging
|
|
680
748
|
is about a stack, so it is reported on the stack — tiles written, tiles skipped,
|
|
681
749
|
the zoom it is working through. What happens afterwards is an archive being
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.66.0",
|
|
4
4
|
"description": "BitTorrent distribution for PMTiles map archives: create torrents, watch folders, publish and subscribe to RSS feeds, and seed through qBittorrent or an embedded client",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/api.js
CHANGED
|
@@ -3181,6 +3181,8 @@ export function createApp({
|
|
|
3181
3181
|
categories: req.body?.categories,
|
|
3182
3182
|
location: req.body?.location,
|
|
3183
3183
|
savePath: req.body?.savePath,
|
|
3184
|
+
name: req.body?.name,
|
|
3185
|
+
description: req.body?.description,
|
|
3184
3186
|
});
|
|
3185
3187
|
return res.status(202).json({ bake: job });
|
|
3186
3188
|
} catch (error) {
|
package/src/bake-jobs.js
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
bakedName,
|
|
7
7
|
mergeTileFor,
|
|
8
8
|
} from './bake.js';
|
|
9
|
+
import { PixelWorker } from './pixels.js';
|
|
9
10
|
import { outputFormat } from './stack-tile.js';
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -112,9 +113,21 @@ export class BakeManager {
|
|
|
112
113
|
throw error;
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
// Resolved before anything starts, and allowed to throw. A location that
|
|
117
|
+
// does not exist or cannot be written is the caller's mistake and they can
|
|
118
|
+
// fix it -- but only if they are told now. Checked at the end instead, it
|
|
119
|
+
// is an hour of merging answered with a shrug and the default disk.
|
|
120
|
+
const publishDir =
|
|
121
|
+
options.publishDir ?? (await this.#savePath(options)) ?? undefined;
|
|
122
|
+
|
|
123
|
+
// What the archive will be called, which the caller may have chosen. The
|
|
124
|
+
// file follows it; the date is what keeps successive builds apart.
|
|
125
|
+
const archiveName = options.name?.trim() || resolved.stack.title || stackId;
|
|
126
|
+
|
|
115
127
|
const job = {
|
|
116
128
|
stackId,
|
|
117
129
|
title: resolved.stack.title ?? stackId,
|
|
130
|
+
archiveName,
|
|
118
131
|
phase: 'merging',
|
|
119
132
|
startedAt: new Date().toISOString(),
|
|
120
133
|
written: 0,
|
|
@@ -125,7 +138,8 @@ export class BakeManager {
|
|
|
125
138
|
finishedAt: null,
|
|
126
139
|
error: null,
|
|
127
140
|
infoHash: null,
|
|
128
|
-
name: bakedName(resolved, { when: new Date() }),
|
|
141
|
+
name: bakedName(resolved, { when: new Date(), name: archiveName }),
|
|
142
|
+
publishDir,
|
|
129
143
|
};
|
|
130
144
|
this.#jobs.set(stackId, job);
|
|
131
145
|
|
|
@@ -158,6 +172,63 @@ export class BakeManager {
|
|
|
158
172
|
const destination = path.join(workDir, job.name);
|
|
159
173
|
const format = outputFormat(resolved);
|
|
160
174
|
|
|
175
|
+
// Only where there is pixel work to move. A passthrough bake hands bytes
|
|
176
|
+
// straight through and would pay for a thread it never uses.
|
|
177
|
+
const pixels = codec ? new PixelWorker() : null;
|
|
178
|
+
try {
|
|
179
|
+
await this.#merge(
|
|
180
|
+
job,
|
|
181
|
+
resolved,
|
|
182
|
+
codec,
|
|
183
|
+
pixels,
|
|
184
|
+
workDir,
|
|
185
|
+
destination,
|
|
186
|
+
format,
|
|
187
|
+
options,
|
|
188
|
+
);
|
|
189
|
+
} finally {
|
|
190
|
+
await pixels?.close().catch(() => {});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// The second half. `addLocalArchive` registers the add in the library's own
|
|
194
|
+
// in-progress list, which is what the archives view already draws -- so the
|
|
195
|
+
// hashing shows up there without this having to report it twice.
|
|
196
|
+
job.phase = 'importing';
|
|
197
|
+
const entry = await this.#library.addLocalArchive(destination, {
|
|
198
|
+
categories: options.categories ?? resolved.stack.categories,
|
|
199
|
+
// Moved out of the working directory as it is taken on, so a finished
|
|
200
|
+
// archive does not live among the checkpoint files of the job that made
|
|
201
|
+
// it.
|
|
202
|
+
publishDir: job.publishDir,
|
|
203
|
+
mode: 'mirror',
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
job.infoHash = entry.infoHash;
|
|
207
|
+
job.phase = 'done';
|
|
208
|
+
job.finishedAt = new Date().toISOString();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The merging half, which is where the time goes.
|
|
213
|
+
* @param {object} job - The job to update as it goes.
|
|
214
|
+
* @param {object} resolved - The resolved stack.
|
|
215
|
+
* @param {object|null} codec - The codec, where the recipe needs one.
|
|
216
|
+
* @param {object|null} pixels - Somewhere to do the pixel maths.
|
|
217
|
+
* @param {string} workDir - Where the unfinished work lives.
|
|
218
|
+
* @param {string} destination - Where the archive goes.
|
|
219
|
+
* @param {string} format - The output format.
|
|
220
|
+
* @returns {Promise<void>} - Resolves when the file is written.
|
|
221
|
+
*/
|
|
222
|
+
async #merge(
|
|
223
|
+
job,
|
|
224
|
+
resolved,
|
|
225
|
+
codec,
|
|
226
|
+
pixels,
|
|
227
|
+
workDir,
|
|
228
|
+
destination,
|
|
229
|
+
format,
|
|
230
|
+
options,
|
|
231
|
+
) {
|
|
161
232
|
// Read through the tile store rather than off the disk, so a cache-mode
|
|
162
233
|
// source is scanned the same way it is served: its directories come out of
|
|
163
234
|
// the swarm, and the store holds them to its own byte budget.
|
|
@@ -194,13 +265,15 @@ export class BakeManager {
|
|
|
194
265
|
resolved,
|
|
195
266
|
tiles: this.#tiles,
|
|
196
267
|
codec,
|
|
268
|
+
pixels,
|
|
197
269
|
signal: job.controller.signal,
|
|
198
270
|
format,
|
|
199
271
|
}),
|
|
200
272
|
header: { format },
|
|
273
|
+
pauseMs: this.#config.stacks?.bakePauseMs ?? 0,
|
|
201
274
|
metadata: {
|
|
202
|
-
name:
|
|
203
|
-
description: resolved.stack.description,
|
|
275
|
+
name: job.archiveName,
|
|
276
|
+
description: options.description ?? resolved.stack.description,
|
|
204
277
|
attribution: resolved.stack.attribution,
|
|
205
278
|
encoding: resolved.stack.output?.encoding,
|
|
206
279
|
encodingFactors: resolved.stack.output,
|
|
@@ -215,23 +288,6 @@ export class BakeManager {
|
|
|
215
288
|
});
|
|
216
289
|
|
|
217
290
|
job.tiles = result.written;
|
|
218
|
-
|
|
219
|
-
// The second half. `addLocalArchive` registers the add in the library's own
|
|
220
|
-
// in-progress list, which is what the archives view already draws -- so the
|
|
221
|
-
// hashing shows up there without this having to report it twice.
|
|
222
|
-
job.phase = 'importing';
|
|
223
|
-
const entry = await this.#library.addLocalArchive(destination, {
|
|
224
|
-
categories: options.categories ?? resolved.stack.categories,
|
|
225
|
-
// Moved out of the working directory as it is taken on, so a finished
|
|
226
|
-
// archive does not live among the checkpoint files of the job that made
|
|
227
|
-
// it.
|
|
228
|
-
publishDir: options.publishDir ?? (await this.#savePath(options)),
|
|
229
|
-
mode: 'mirror',
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
job.infoHash = entry.infoHash;
|
|
233
|
-
job.phase = 'done';
|
|
234
|
-
job.finishedAt = new Date().toISOString();
|
|
235
291
|
}
|
|
236
292
|
|
|
237
293
|
/**
|
|
@@ -240,9 +296,10 @@ export class BakeManager {
|
|
|
240
296
|
* @returns {Promise<string|undefined>} - A path, or undefined for the default.
|
|
241
297
|
*/
|
|
242
298
|
async #savePath(options) {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
299
|
+
// Not caught. `resolveSavePath` refuses a name this node does not know and
|
|
300
|
+
// a path it cannot write, and both are worth refusing rather than quietly
|
|
301
|
+
// putting hundreds of gigabytes somewhere else.
|
|
302
|
+
const explicit = await this.#library.resolveSavePath(options);
|
|
246
303
|
return explicit ?? this.#config.savePath ?? undefined;
|
|
247
304
|
}
|
|
248
305
|
|
|
@@ -262,6 +319,8 @@ export class BakeManager {
|
|
|
262
319
|
skipped: job.skipped,
|
|
263
320
|
zoom: job.zoom,
|
|
264
321
|
name: job.name,
|
|
322
|
+
archiveName: job.archiveName,
|
|
323
|
+
publishDir: job.publishDir ?? null,
|
|
265
324
|
infoHash: job.infoHash,
|
|
266
325
|
error: job.error,
|
|
267
326
|
};
|
package/src/bake.js
CHANGED
|
@@ -1,18 +1,12 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import zlib from 'node:zlib';
|
|
4
3
|
import { tileIdToZxy } from 'pmtiles';
|
|
5
|
-
import {
|
|
4
|
+
import { unionOfTileIds } from './pmtiles-scan.js';
|
|
6
5
|
import crypto from 'node:crypto';
|
|
7
6
|
import { safeSegment } from './savepath.js';
|
|
8
7
|
import { answerStackTile, outputFormat, outputSize } from './stack-tile.js';
|
|
9
8
|
import { needsCodec, stackRevision } from './stacks.js';
|
|
10
|
-
import {
|
|
11
|
-
Compression,
|
|
12
|
-
PMTilesWriter,
|
|
13
|
-
TileType,
|
|
14
|
-
serializeDirectory,
|
|
15
|
-
} from './pmtiles-write.js';
|
|
9
|
+
import { Compression, PMTilesWriter, TileType } from './pmtiles-write.js';
|
|
16
10
|
|
|
17
11
|
/**
|
|
18
12
|
* Running a stack over its sources' coverage and writing a real archive.
|
|
@@ -51,6 +45,62 @@ export function checkpointPaths(workDir) {
|
|
|
51
45
|
};
|
|
52
46
|
}
|
|
53
47
|
|
|
48
|
+
/**
|
|
49
|
+
* One entry, as a checkpoint stores it.
|
|
50
|
+
*
|
|
51
|
+
* A fixed record rather than `serializeDirectory`, which was the first thing
|
|
52
|
+
* tried here and is the wrong tool. That is a *distribution* format: compact on
|
|
53
|
+
* disk, and it costs a varint pass over every entry to produce -- 264 ms at a
|
|
54
|
+
* million entries, of which only 12 ms is the compression. Re-encoding all of
|
|
55
|
+
* them every checkpoint also makes the total work quadratic in the length of
|
|
56
|
+
* the job.
|
|
57
|
+
*
|
|
58
|
+
* A checkpoint wants none of that. It is read once, by this process, on a
|
|
59
|
+
* machine that just wrote it. Fixed-width records cost nothing to produce and
|
|
60
|
+
* can be appended, which is what turns the checkpoint from a stall that grows
|
|
61
|
+
* into one that does not.
|
|
62
|
+
*/
|
|
63
|
+
const RECORD_BYTES = 24;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Packs entries into fixed records.
|
|
67
|
+
* @param {object[]} entries - The entries to pack.
|
|
68
|
+
* @returns {Buffer} - `RECORD_BYTES` per entry.
|
|
69
|
+
*/
|
|
70
|
+
function packEntries(entries) {
|
|
71
|
+
const buffer = Buffer.alloc(entries.length * RECORD_BYTES);
|
|
72
|
+
for (const [index, entry] of entries.entries()) {
|
|
73
|
+
const at = index * RECORD_BYTES;
|
|
74
|
+
// Doubles for the two that can be large: a tile id past z26 and an offset
|
|
75
|
+
// past 4 GiB both exceed what 32 bits hold, and both are safe integers.
|
|
76
|
+
buffer.writeDoubleLE(entry.tileId, at);
|
|
77
|
+
buffer.writeDoubleLE(entry.offset, at + 8);
|
|
78
|
+
buffer.writeUInt32LE(entry.length, at + 16);
|
|
79
|
+
buffer.writeUInt32LE(entry.runLength, at + 20);
|
|
80
|
+
}
|
|
81
|
+
return buffer;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Reads packed records back into entries.
|
|
86
|
+
* @param {Buffer} buffer - What `packEntries` wrote.
|
|
87
|
+
* @returns {object[]} - The entries.
|
|
88
|
+
*/
|
|
89
|
+
function unpackEntries(buffer) {
|
|
90
|
+
const count = Math.floor(buffer.length / RECORD_BYTES);
|
|
91
|
+
const entries = new Array(count);
|
|
92
|
+
for (let index = 0; index < count; index += 1) {
|
|
93
|
+
const at = index * RECORD_BYTES;
|
|
94
|
+
entries[index] = {
|
|
95
|
+
tileId: buffer.readDoubleLE(at),
|
|
96
|
+
offset: buffer.readDoubleLE(at + 8),
|
|
97
|
+
length: buffer.readUInt32LE(at + 16),
|
|
98
|
+
runLength: buffer.readUInt32LE(at + 20),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return entries;
|
|
102
|
+
}
|
|
103
|
+
|
|
54
104
|
/**
|
|
55
105
|
* Reads a checkpoint, if there is one worth resuming from.
|
|
56
106
|
*
|
|
@@ -83,10 +133,10 @@ export async function readCheckpoint(workDir, revision) {
|
|
|
83
133
|
// shorter than the entries say, some of what they point at is not there.
|
|
84
134
|
if (buffered.size < state.dataBytes) return null;
|
|
85
135
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
136
|
+
const entries = unpackEntries(stored);
|
|
137
|
+
// A record file shorter than the state claims means the two disagree about
|
|
138
|
+
// what was written, and the entries are the half that can be believed.
|
|
139
|
+
if (state.entryCount !== undefined && entries.length !== state.entryCount) {
|
|
90
140
|
return null;
|
|
91
141
|
}
|
|
92
142
|
|
|
@@ -94,25 +144,41 @@ export async function readCheckpoint(workDir, revision) {
|
|
|
94
144
|
}
|
|
95
145
|
|
|
96
146
|
/**
|
|
97
|
-
* Writes a checkpoint,
|
|
147
|
+
* Writes a checkpoint, appending what is new rather than rewriting it all.
|
|
98
148
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* the
|
|
149
|
+
* Only the last entry can change once written -- a run of identical tiles
|
|
150
|
+
* extends it -- so everything before that is already on disk and correct. The
|
|
151
|
+
* write is therefore the size of the work since the last checkpoint, not the
|
|
152
|
+
* size of the job so far.
|
|
102
153
|
* @param {string} workDir - The working directory.
|
|
103
|
-
* @param {object} state - Scalars worth keeping
|
|
154
|
+
* @param {object} state - Scalars worth keeping, including `entryCount`.
|
|
104
155
|
* @param {object[]} entries - The entries as they stand.
|
|
105
|
-
* @
|
|
156
|
+
* @param {number} [persisted] - How many are already on disk.
|
|
157
|
+
* @returns {Promise<number>} - How many are on disk now.
|
|
106
158
|
*/
|
|
107
|
-
export async function writeCheckpoint(workDir, state, entries) {
|
|
159
|
+
export async function writeCheckpoint(workDir, state, entries, persisted = 0) {
|
|
108
160
|
const paths = checkpointPaths(workDir);
|
|
161
|
+
|
|
162
|
+
// Back up one, because the last record written may have grown a longer run
|
|
163
|
+
// since. Everything before it is settled.
|
|
164
|
+
const from = Math.max(0, Math.min(persisted, entries.length) - 1);
|
|
165
|
+
const handle = await fs.open(paths.entries, persisted > 0 ? 'r+' : 'w');
|
|
166
|
+
try {
|
|
167
|
+
const tail = packEntries(entries.slice(from));
|
|
168
|
+
if (tail.length > 0)
|
|
169
|
+
await handle.write(tail, 0, tail.length, from * RECORD_BYTES);
|
|
170
|
+
await handle.truncate(entries.length * RECORD_BYTES);
|
|
171
|
+
} finally {
|
|
172
|
+
await handle.close();
|
|
173
|
+
}
|
|
174
|
+
|
|
109
175
|
// Entries first. A state file naming more progress than the entries hold
|
|
110
176
|
// would resume into an archive missing tiles it believes it wrote.
|
|
111
177
|
await fs.writeFile(
|
|
112
|
-
paths.
|
|
113
|
-
|
|
178
|
+
paths.state,
|
|
179
|
+
JSON.stringify({ ...state, entryCount: entries.length }),
|
|
114
180
|
);
|
|
115
|
-
|
|
181
|
+
return entries.length;
|
|
116
182
|
}
|
|
117
183
|
|
|
118
184
|
/**
|
|
@@ -166,12 +232,16 @@ function stamp(when = new Date()) {
|
|
|
166
232
|
* map and two files cannot share a path. The archive's *name* is not dated --
|
|
167
233
|
* see `bakedMetadata` -- so a rebuild keeps its identity the way every other
|
|
168
234
|
* rebuild here does.
|
|
235
|
+
*
|
|
236
|
+
* Follows the name it is given, so renaming an export in the dialog renames the
|
|
237
|
+
* file too. A name and a filename that disagree is a small thing that costs an
|
|
238
|
+
* afternoon the first time somebody goes looking on disk.
|
|
169
239
|
* @param {object} resolved - The resolved stack.
|
|
170
|
-
* @param {object} [options] - `
|
|
240
|
+
* @param {object} [options] - `name` to override the stack's, `when`, `suffix`.
|
|
171
241
|
* @returns {string} - A filename.
|
|
172
242
|
*/
|
|
173
243
|
export function bakedName(resolved, options = {}) {
|
|
174
|
-
const title = resolved.stack.title ?? resolved.stack.id;
|
|
244
|
+
const title = options.name ?? resolved.stack.title ?? resolved.stack.id;
|
|
175
245
|
const slug = safeSegment(title) || 'stack';
|
|
176
246
|
const tail = options.suffix ? `-${options.suffix}` : '';
|
|
177
247
|
return `${slug}-${stamp(options.when)}${tail}.pmtiles`;
|
|
@@ -297,7 +367,7 @@ export function tileTypeFor(format) {
|
|
|
297
367
|
* @returns {Function} - `(z, x, y) => Promise<Buffer|null>`.
|
|
298
368
|
*/
|
|
299
369
|
export function mergeTileFor(options) {
|
|
300
|
-
const { resolved, tiles, codec, signal } = options;
|
|
370
|
+
const { resolved, tiles, codec, signal, pixels } = options;
|
|
301
371
|
const format = options.format ?? outputFormat(resolved);
|
|
302
372
|
const size = options.size ?? outputSize(resolved.stack);
|
|
303
373
|
|
|
@@ -313,6 +383,7 @@ export function mergeTileFor(options) {
|
|
|
313
383
|
signal,
|
|
314
384
|
size,
|
|
315
385
|
format,
|
|
386
|
+
pixels,
|
|
316
387
|
});
|
|
317
388
|
|
|
318
389
|
// A required source that cannot be read stops the job. Baking around it
|
|
@@ -357,6 +428,7 @@ export async function bakeStack(options) {
|
|
|
357
428
|
header = {},
|
|
358
429
|
deduplicate = true,
|
|
359
430
|
checkpointEvery = DEFAULT_CHECKPOINT_EVERY,
|
|
431
|
+
pauseMs = 0,
|
|
360
432
|
} = options;
|
|
361
433
|
|
|
362
434
|
if (!sources?.length) throw new Error('a bake needs at least one source');
|
|
@@ -386,13 +458,14 @@ export async function bakeStack(options) {
|
|
|
386
458
|
let skipped = found?.skipped ?? 0;
|
|
387
459
|
let lastTileId = found?.lastTileId ?? -1;
|
|
388
460
|
let sinceCheckpoint = 0;
|
|
461
|
+
let persisted = found?.entries.length ?? 0;
|
|
389
462
|
|
|
390
463
|
/**
|
|
391
464
|
* Writes down where the job has got to.
|
|
392
465
|
* @returns {Promise<void>} - Resolves once it is durable.
|
|
393
466
|
*/
|
|
394
467
|
const checkpoint = async () => {
|
|
395
|
-
await writeCheckpoint(
|
|
468
|
+
persisted = await writeCheckpoint(
|
|
396
469
|
workDir,
|
|
397
470
|
{
|
|
398
471
|
revision,
|
|
@@ -404,6 +477,7 @@ export async function bakeStack(options) {
|
|
|
404
477
|
clustered: writer.clustered,
|
|
405
478
|
},
|
|
406
479
|
writer.entries,
|
|
480
|
+
persisted,
|
|
407
481
|
);
|
|
408
482
|
sinceCheckpoint = 0;
|
|
409
483
|
};
|
|
@@ -429,6 +503,16 @@ export async function bakeStack(options) {
|
|
|
429
503
|
sinceCheckpoint += 1;
|
|
430
504
|
onProgress?.({ written, skipped, tileId, z, x, y });
|
|
431
505
|
if (sinceCheckpoint >= checkpointEvery) await checkpoint();
|
|
506
|
+
|
|
507
|
+
// Handing time back, where the operator asked for that. A bake on a node
|
|
508
|
+
// that is also serving tiles holds the main thread for a few milliseconds
|
|
509
|
+
// per tile -- the pixel maths is synchronous -- and this is the knob that
|
|
510
|
+
// trades how long the bake takes for how much of the machine it takes
|
|
511
|
+
// while it runs. Off by default, because a node baking nothing else pays
|
|
512
|
+
// for this and gets nothing.
|
|
513
|
+
if (pauseMs > 0) {
|
|
514
|
+
await new Promise((resolve) => setTimeout(resolve, pauseMs));
|
|
515
|
+
}
|
|
432
516
|
}
|
|
433
517
|
} catch (error) {
|
|
434
518
|
// A cancelled bake keeps its work. Deleting it would make stopping and
|
package/src/config.js
CHANGED
|
@@ -364,6 +364,17 @@ const DEFAULTS = {
|
|
|
364
364
|
* be thrown away, and a backup should be able to tell the difference.
|
|
365
365
|
*/
|
|
366
366
|
cacheDir: undefined,
|
|
367
|
+
/**
|
|
368
|
+
* Milliseconds a bake waits between tiles. Zero is as fast as it can go.
|
|
369
|
+
*
|
|
370
|
+
* The pixel maths a merge does is synchronous, so a bake holds the main
|
|
371
|
+
* thread for a few milliseconds per tile -- and on a node that is also
|
|
372
|
+
* serving tiles, that is every request waiting behind it. This is the knob
|
|
373
|
+
* that trades how long the bake takes for how much of the machine it takes
|
|
374
|
+
* while it runs. Off by default: a node baking nothing pays for this and
|
|
375
|
+
* gets nothing.
|
|
376
|
+
*/
|
|
377
|
+
bakePauseMs: 0,
|
|
367
378
|
},
|
|
368
379
|
/**
|
|
369
380
|
* Folders scanned for new archives. Each entry is `{ path, categories,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { parentPort } from 'node:worker_threads';
|
|
2
|
+
import { encodeHeights, fillNodata, mergeElevation } from './elevation.js';
|
|
3
|
+
import { compositeRgba, toRaster } from './rgba.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The pixel half of a merge, off the main thread.
|
|
7
|
+
*
|
|
8
|
+
* `elevation.js` and `rgba.js` are entirely synchronous — decoding heights,
|
|
9
|
+
* masking, resampling and painting are all loops over typed arrays — so on the
|
|
10
|
+
* main thread a merge blocks everything else for as long as it takes. Serving
|
|
11
|
+
* one tile that way is a few milliseconds nobody notices. A bake is that, over
|
|
12
|
+
* and over, for hours, on a node that is also answering requests.
|
|
13
|
+
*
|
|
14
|
+
* This runs the same functions, unchanged, in a worker. Nothing here decides
|
|
15
|
+
* anything; the caller has already worked out what to merge and how.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Merges one tile's contributions and encodes the result.
|
|
20
|
+
* @param {object} job - `{space, contributions, options, output}`.
|
|
21
|
+
* @returns {object|null} - A raster, or null where nothing covered the tile.
|
|
22
|
+
*/
|
|
23
|
+
function run(job) {
|
|
24
|
+
const { space, contributions, options, output = {} } = job;
|
|
25
|
+
|
|
26
|
+
if (space === 'rgba') {
|
|
27
|
+
const composited = compositeRgba(contributions, options);
|
|
28
|
+
if (!composited) return null;
|
|
29
|
+
// Alpha is kept unless the recipe asks for a flat tile.
|
|
30
|
+
return toRaster(composited, output.alpha !== false);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const merged = mergeElevation(contributions, options);
|
|
34
|
+
if (!merged) return null;
|
|
35
|
+
fillNodata(merged, output.nodata);
|
|
36
|
+
return encodeHeights(merged, {
|
|
37
|
+
...output,
|
|
38
|
+
width: options.size,
|
|
39
|
+
height: options.size,
|
|
40
|
+
encoding: job.encoding,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
parentPort?.on('message', (message) => {
|
|
45
|
+
try {
|
|
46
|
+
const raster = run(message.job);
|
|
47
|
+
if (!raster) {
|
|
48
|
+
parentPort.postMessage({ id: message.id, raster: null });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
// Copied into a buffer of its own and transferred, rather than handed over
|
|
52
|
+
// as it stands. A Buffer can be a view into a shared pool, and transferring
|
|
53
|
+
// that pool would take memory this worker is still using along with it.
|
|
54
|
+
const bytes = Uint8Array.prototype.slice.call(raster.data);
|
|
55
|
+
parentPort.postMessage(
|
|
56
|
+
{
|
|
57
|
+
id: message.id,
|
|
58
|
+
raster: {
|
|
59
|
+
data: bytes,
|
|
60
|
+
width: raster.width,
|
|
61
|
+
height: raster.height,
|
|
62
|
+
channels: raster.channels,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
[bytes.buffer],
|
|
66
|
+
);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
parentPort.postMessage({ id: message.id, error: error.message });
|
|
69
|
+
}
|
|
70
|
+
});
|
package/src/pixels.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { Worker } from 'node:worker_threads';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Somewhere other than the main thread to do a merge's pixel maths.
|
|
7
|
+
*
|
|
8
|
+
* Opt-in and handed to `answerStackTile`, rather than switched on for
|
|
9
|
+
* everything. Serving one tile does a few milliseconds of synchronous work and
|
|
10
|
+
* nobody notices; a bake does that for hours beside a node that is also
|
|
11
|
+
* answering requests, and there it is the difference between a slow bake and a
|
|
12
|
+
* slow node. See docs/tile-stacks.md — "Running it".
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
/** How long to wait for a worker to answer before giving up on it. */
|
|
18
|
+
const REPLY_TIMEOUT_MS = 120000;
|
|
19
|
+
|
|
20
|
+
export class PixelWorker {
|
|
21
|
+
#worker;
|
|
22
|
+
#pending = new Map();
|
|
23
|
+
#next = 1;
|
|
24
|
+
#closed = false;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Starts a worker.
|
|
28
|
+
* @param {object} [options] - `timeoutMs` for a reply.
|
|
29
|
+
*/
|
|
30
|
+
constructor(options = {}) {
|
|
31
|
+
this.#worker = new Worker(path.join(here, 'pixel-worker.js'));
|
|
32
|
+
this.timeoutMs = options.timeoutMs ?? REPLY_TIMEOUT_MS;
|
|
33
|
+
// The thread does not hold the process open. A bake keeps it busy; nothing
|
|
34
|
+
// else should keep it alive.
|
|
35
|
+
this.#worker.unref();
|
|
36
|
+
|
|
37
|
+
this.#worker.on('message', (message) => {
|
|
38
|
+
const waiting = this.#pending.get(message.id);
|
|
39
|
+
if (!waiting) return;
|
|
40
|
+
this.#pending.delete(message.id);
|
|
41
|
+
clearTimeout(waiting.timer);
|
|
42
|
+
if (message.error) waiting.reject(new Error(message.error));
|
|
43
|
+
else waiting.resolve(message.raster);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// A worker that dies takes every request in flight with it, and saying so
|
|
47
|
+
// is better than leaving them pending for ever.
|
|
48
|
+
const fail = (error) => {
|
|
49
|
+
this.#closed = true;
|
|
50
|
+
for (const waiting of this.#pending.values()) {
|
|
51
|
+
clearTimeout(waiting.timer);
|
|
52
|
+
waiting.reject(error);
|
|
53
|
+
}
|
|
54
|
+
this.#pending.clear();
|
|
55
|
+
};
|
|
56
|
+
this.#worker.on('error', fail);
|
|
57
|
+
this.#worker.on('exit', (code) => {
|
|
58
|
+
if (!this.#closed)
|
|
59
|
+
fail(new Error(`the pixel worker exited with ${code}`));
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Merges one tile's contributions, off this thread.
|
|
65
|
+
* @param {object} job - `{space, contributions, options, output, encoding}`.
|
|
66
|
+
* @returns {Promise<object|null>} - A raster, or null where nothing covered it.
|
|
67
|
+
*/
|
|
68
|
+
async merge(job) {
|
|
69
|
+
// Async so that everything below rejects rather than throws. A caller
|
|
70
|
+
// reaching for `.catch` on a job it handed over should not be handed an
|
|
71
|
+
// exception instead, and a malformed raster is otherwise thrown from the
|
|
72
|
+
// copy below before there is a promise to reject.
|
|
73
|
+
if (this.#closed) throw new Error('the pixel worker is closed');
|
|
74
|
+
|
|
75
|
+
const id = this.#next;
|
|
76
|
+
this.#next += 1;
|
|
77
|
+
|
|
78
|
+
// Rasters are handed over rather than cloned, and where possible without
|
|
79
|
+
// being copied first either. A decoded tile is most of a megabyte per
|
|
80
|
+
// source, and copying every one of them is work on the very thread this
|
|
81
|
+
// exists to keep free -- enough of it to cost more than it saves.
|
|
82
|
+
//
|
|
83
|
+
// Copied only when the buffer does not own its memory. Node pools
|
|
84
|
+
// allocations under 4 KiB, and transferring a pooled buffer would hand
|
|
85
|
+
// over the whole pool with everything else in it; a raster is far larger
|
|
86
|
+
// than that and so is always its own allocation, but the check is what
|
|
87
|
+
// makes that a fact rather than an assumption.
|
|
88
|
+
//
|
|
89
|
+
// Either way the caller gives up these rasters. Nothing reads them after a
|
|
90
|
+
// merge, which is what makes that safe.
|
|
91
|
+
const transfers = [];
|
|
92
|
+
const owned = (bytes) =>
|
|
93
|
+
bytes.byteOffset === 0 && bytes.buffer.byteLength === bytes.byteLength;
|
|
94
|
+
const contributions = job.contributions.map((contribution) => {
|
|
95
|
+
if (!contribution?.raster?.data) return contribution;
|
|
96
|
+
const source = contribution.raster.data;
|
|
97
|
+
const bytes = owned(source)
|
|
98
|
+
? source
|
|
99
|
+
: Uint8Array.prototype.slice.call(source);
|
|
100
|
+
transfers.push(bytes.buffer);
|
|
101
|
+
return {
|
|
102
|
+
...contribution,
|
|
103
|
+
raster: { ...contribution.raster, data: bytes },
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const timer = setTimeout(() => {
|
|
109
|
+
this.#pending.delete(id);
|
|
110
|
+
reject(new Error('the pixel worker did not answer'));
|
|
111
|
+
}, this.timeoutMs);
|
|
112
|
+
timer.unref?.();
|
|
113
|
+
this.#pending.set(id, { resolve, reject, timer });
|
|
114
|
+
this.#worker.postMessage(
|
|
115
|
+
{ id, job: { ...job, contributions } },
|
|
116
|
+
transfers,
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Stops the worker.
|
|
123
|
+
* @returns {Promise<void>} - Resolves once it is gone.
|
|
124
|
+
*/
|
|
125
|
+
async close() {
|
|
126
|
+
this.#closed = true;
|
|
127
|
+
await this.#worker.terminate();
|
|
128
|
+
}
|
|
129
|
+
}
|
package/src/stack-tile.js
CHANGED
|
@@ -294,6 +294,7 @@ async function merge({
|
|
|
294
294
|
rgba,
|
|
295
295
|
format,
|
|
296
296
|
gathered,
|
|
297
|
+
pixels,
|
|
297
298
|
}) {
|
|
298
299
|
const { contributors, contributions } = gathered;
|
|
299
300
|
const first = contributions.find(Boolean);
|
|
@@ -306,6 +307,34 @@ async function merge({
|
|
|
306
307
|
Math.max(...contributions.filter(Boolean).map((c) => c.raster.width));
|
|
307
308
|
const output = resolved.stack.output ?? {};
|
|
308
309
|
|
|
310
|
+
const merging = {
|
|
311
|
+
z,
|
|
312
|
+
x,
|
|
313
|
+
y,
|
|
314
|
+
size: grid,
|
|
315
|
+
resampling: resolved.stack.resampling,
|
|
316
|
+
gaussianBlurSigma: resolved.stack.gaussianBlurSigma,
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// Off this thread, where somebody has provided somewhere to put it. The same
|
|
320
|
+
// functions either way -- a worker runs `elevation.js` and `rgba.js`
|
|
321
|
+
// unchanged -- so this is about where the work happens and not what it is.
|
|
322
|
+
if (pixels) {
|
|
323
|
+
const off = await pixels.merge({
|
|
324
|
+
space: rgba ? 'rgba' : 'elevation',
|
|
325
|
+
contributions,
|
|
326
|
+
options: merging,
|
|
327
|
+
output,
|
|
328
|
+
encoding: output.encoding ?? first.source?.encoding,
|
|
329
|
+
});
|
|
330
|
+
if (!off) return { contributors, format, empty: true };
|
|
331
|
+
const body = await codec.encode(off, {
|
|
332
|
+
format,
|
|
333
|
+
lossless: rgba ? output.lossless === true : true,
|
|
334
|
+
});
|
|
335
|
+
return { contributors, format, body };
|
|
336
|
+
}
|
|
337
|
+
|
|
309
338
|
// The two spaces differ only here. Everything around this -- reading, the
|
|
310
339
|
// parent fallback, the cache, the headers -- is the same either way, which is
|
|
311
340
|
// why they are one path rather than two.
|
|
@@ -370,7 +399,8 @@ async function merge({
|
|
|
370
399
|
* @returns {Promise<StackAnswer>} - What to serve, or why there is nothing.
|
|
371
400
|
*/
|
|
372
401
|
export async function answerStackTile(options) {
|
|
373
|
-
const { resolved, z, x, y, tiles, codec, stackCache, signal, size } =
|
|
402
|
+
const { resolved, z, x, y, tiles, codec, stackCache, signal, size, pixels } =
|
|
403
|
+
options;
|
|
374
404
|
const format = options.format ?? outputFormat(resolved);
|
|
375
405
|
const rgba = resolved.stack.space === 'rgba';
|
|
376
406
|
|
|
@@ -426,6 +456,7 @@ export async function answerStackTile(options) {
|
|
|
426
456
|
rgba,
|
|
427
457
|
format,
|
|
428
458
|
gathered,
|
|
459
|
+
pixels,
|
|
429
460
|
});
|
|
430
461
|
|
|
431
462
|
// Awaited, though it is tempting not to be. The write is a local disk write
|
package/src/web/index.html
CHANGED
|
@@ -644,6 +644,59 @@
|
|
|
644
644
|
</form>
|
|
645
645
|
</dialog>
|
|
646
646
|
|
|
647
|
+
<dialog id="bake-dialog">
|
|
648
|
+
<form method="dialog" id="bake-form">
|
|
649
|
+
<h2>Export to archive</h2>
|
|
650
|
+
<div class="sub" style="margin-bottom:0.8rem">
|
|
651
|
+
Runs the recipe over its sources and writes the result as a real
|
|
652
|
+
archive, with its own infohash, seeded like any other. It reads every
|
|
653
|
+
tile the sources hold, so it can take hours — and it can be stopped,
|
|
654
|
+
which keeps the work for the next run to carry on from.
|
|
655
|
+
</div>
|
|
656
|
+
|
|
657
|
+
<div class="field">
|
|
658
|
+
<label>Stack</label>
|
|
659
|
+
<div class="sub"><code id="bake-stack"></code></div>
|
|
660
|
+
</div>
|
|
661
|
+
|
|
662
|
+
<div class="field">
|
|
663
|
+
<label for="bake-name">Name</label>
|
|
664
|
+
<input id="bake-name" placeholder="Terrain" />
|
|
665
|
+
<div class="sub">
|
|
666
|
+
What the archive is called, and what a rebuild of it keeps — so
|
|
667
|
+
leave the date out of it. The file gets one:
|
|
668
|
+
<code id="bake-filename"></code>
|
|
669
|
+
</div>
|
|
670
|
+
</div>
|
|
671
|
+
|
|
672
|
+
<div class="field">
|
|
673
|
+
<label for="bake-description">Description</label>
|
|
674
|
+
<input id="bake-description" placeholder="Bathymetry under terrain" />
|
|
675
|
+
<div class="sub">
|
|
676
|
+
Optional. The date this was baked is added to whatever is here.
|
|
677
|
+
</div>
|
|
678
|
+
</div>
|
|
679
|
+
|
|
680
|
+
<div id="bake-location"></div>
|
|
681
|
+
|
|
682
|
+
<div class="field">
|
|
683
|
+
<label for="bake-categories">Categories</label>
|
|
684
|
+
<input id="bake-categories" placeholder="terrain, weekly" />
|
|
685
|
+
<div class="sub">
|
|
686
|
+
Comma separated. What the archive is filed under once it is added,
|
|
687
|
+
which is what <code>/latest/<category>/</code> follows. Left
|
|
688
|
+
empty, it takes the stack's own.
|
|
689
|
+
</div>
|
|
690
|
+
</div>
|
|
691
|
+
|
|
692
|
+
<div class="sub" id="bake-error" style="color: var(--danger)"></div>
|
|
693
|
+
<menu>
|
|
694
|
+
<button value="cancel">Cancel</button>
|
|
695
|
+
<button id="bake-go" value="default">Export</button>
|
|
696
|
+
</menu>
|
|
697
|
+
</form>
|
|
698
|
+
</dialog>
|
|
699
|
+
|
|
647
700
|
<dialog id="adopt-dialog">
|
|
648
701
|
<form method="dialog" id="adopt-form">
|
|
649
702
|
<h2>Adopt existing torrents</h2>
|
|
@@ -5149,6 +5202,18 @@ Every piece is hashed against the ` +
|
|
|
5149
5202
|
'and caching them would put a second copy of the archive ' +
|
|
5150
5203
|
'beside the first. Evicted least-recently-used.',
|
|
5151
5204
|
},
|
|
5205
|
+
{
|
|
5206
|
+
key: 'stacks.bakePauseMs',
|
|
5207
|
+
label: 'Pause between tiles while exporting',
|
|
5208
|
+
type: 'number',
|
|
5209
|
+
placeholder: '0',
|
|
5210
|
+
unit: 'ms',
|
|
5211
|
+
help:
|
|
5212
|
+
'Zero is as fast as it can go. A merge does its pixel maths ' +
|
|
5213
|
+
'on the main thread, so an export holds it for a few ' +
|
|
5214
|
+
'milliseconds per tile and every request waits behind that. ' +
|
|
5215
|
+
'Set this on a node that is also serving maps.',
|
|
5216
|
+
},
|
|
5152
5217
|
{
|
|
5153
5218
|
key: 'stacks.cacheDir',
|
|
5154
5219
|
label: 'Where they go',
|
|
@@ -7666,6 +7731,87 @@ Every piece is hashed against the ` +
|
|
|
7666
7731
|
$('stacks-add').onclick = () =>
|
|
7667
7732
|
openStackDialog().catch((e) => toast(e.message));
|
|
7668
7733
|
|
|
7734
|
+
/**
|
|
7735
|
+
* Asks where an export should land before starting it.
|
|
7736
|
+
*
|
|
7737
|
+
* A dialog rather than a confirm, because there is something to decide:
|
|
7738
|
+
* a baked planet is hundreds of gigabytes and the disk it goes on is not
|
|
7739
|
+
* a detail. The same picker every other destination in this console
|
|
7740
|
+
* uses, so a location named once under Settings is offered here too.
|
|
7741
|
+
* @param {string} id - The stack to export.
|
|
7742
|
+
* @returns {Promise<void>} - Resolves once it is asked for or dismissed.
|
|
7743
|
+
*/
|
|
7744
|
+
async function openBakeDialog(id, stack) {
|
|
7745
|
+
$('bake-stack').textContent = id;
|
|
7746
|
+
$('bake-error').textContent = '';
|
|
7747
|
+
$('bake-name').value = stack?.title ?? id;
|
|
7748
|
+
$('bake-description').value = stack?.description ?? '';
|
|
7749
|
+
$('bake-categories').value = (stack?.categories ?? []).join(', ');
|
|
7750
|
+
showBakeFilename();
|
|
7751
|
+
$('bake-location').innerHTML = locationPicker('bake-loc');
|
|
7752
|
+
await fillLocations('bake-loc');
|
|
7753
|
+
$('bake-dialog').showModal();
|
|
7754
|
+
}
|
|
7755
|
+
|
|
7756
|
+
/**
|
|
7757
|
+
* Shows what the file will be called, as the name is typed.
|
|
7758
|
+
*
|
|
7759
|
+
* The same rules the server applies, because a name is not a filename:
|
|
7760
|
+
* separators and the characters Windows refuses become dashes, and the
|
|
7761
|
+
* date is appended so successive builds do not collide. Seeing that
|
|
7762
|
+
* before pressing the button beats finding it on disk afterwards.
|
|
7763
|
+
* @returns {void}
|
|
7764
|
+
*/
|
|
7765
|
+
function showBakeFilename() {
|
|
7766
|
+
const typed = $('bake-name').value.trim() || $('bake-stack').textContent;
|
|
7767
|
+
const slug =
|
|
7768
|
+
typed
|
|
7769
|
+
.replace(/[/\\<>:"|?*\u0000-\u001f\u007f -]/g, '-')
|
|
7770
|
+
.replace(/-{2,}/g, '-')
|
|
7771
|
+
.replace(/^[.\s-]+/, '')
|
|
7772
|
+
.replace(/[.\s-]+$/, '')
|
|
7773
|
+
.slice(0, 120) || 'stack';
|
|
7774
|
+
const day = new Date().toISOString().slice(0, 10).replaceAll('-', '');
|
|
7775
|
+
$('bake-filename').textContent = `${slug}-${day}.pmtiles`;
|
|
7776
|
+
}
|
|
7777
|
+
|
|
7778
|
+
$('bake-name').addEventListener('input', showBakeFilename);
|
|
7779
|
+
|
|
7780
|
+
$('bake-form').addEventListener('submit', async (event) => {
|
|
7781
|
+
if (event.submitter?.value === 'cancel') return;
|
|
7782
|
+
event.preventDefault();
|
|
7783
|
+
|
|
7784
|
+
const id = $('bake-stack').textContent;
|
|
7785
|
+
const categories = $('bake-categories')
|
|
7786
|
+
.value.split(',')
|
|
7787
|
+
.map((one) => one.trim())
|
|
7788
|
+
.filter(Boolean);
|
|
7789
|
+
const name = $('bake-name').value.trim();
|
|
7790
|
+
const description = $('bake-description').value.trim();
|
|
7791
|
+
|
|
7792
|
+
try {
|
|
7793
|
+
await api(`/api/stacks/${encodeURIComponent(id)}/bake`, {
|
|
7794
|
+
method: 'POST',
|
|
7795
|
+
body: {
|
|
7796
|
+
...chosenLocation('bake-loc'),
|
|
7797
|
+
...(categories.length > 0 ? { categories } : {}),
|
|
7798
|
+
...(name ? { name } : {}),
|
|
7799
|
+
...(description ? { description } : {}),
|
|
7800
|
+
},
|
|
7801
|
+
});
|
|
7802
|
+
} catch (error) {
|
|
7803
|
+
// Kept open, because the answer is usually something to change here
|
|
7804
|
+
// -- a location that does not exist, or a codec that is not
|
|
7805
|
+
// installed -- rather than something to go away and think about.
|
|
7806
|
+
$('bake-error').textContent = error.message;
|
|
7807
|
+
return;
|
|
7808
|
+
}
|
|
7809
|
+
|
|
7810
|
+
$('bake-dialog').close();
|
|
7811
|
+
toast(`Exporting ${id}`);
|
|
7812
|
+
loadStacks().catch((e) => toast(e.message));
|
|
7813
|
+
});
|
|
7814
|
+
|
|
7669
7815
|
// Editing and deleting hang off the list, which is redrawn on every
|
|
7670
7816
|
// change, so they are delegated the same way the source rows are.
|
|
7671
7817
|
$('stacks-list').addEventListener('click', async (event) => {
|
|
@@ -7674,27 +7820,11 @@ Every piece is hashed against the ` +
|
|
|
7674
7820
|
const bake = event.target.dataset.stackBake;
|
|
7675
7821
|
const stopBake = event.target.dataset.stackBakeStop;
|
|
7676
7822
|
if (bake !== undefined) {
|
|
7677
|
-
|
|
7678
|
-
|
|
7679
|
-
|
|
7680
|
-
|
|
7681
|
-
|
|
7682
|
-
'archive?\n\nThis reads every tile the sources hold and can ' +
|
|
7683
|
-
'take hours. It can be stopped, and stopping keeps the work.',
|
|
7684
|
-
)
|
|
7685
|
-
) {
|
|
7686
|
-
return;
|
|
7687
|
-
}
|
|
7688
|
-
try {
|
|
7689
|
-
await api(`/api/stacks/${encodeURIComponent(bake)}/bake`, {
|
|
7690
|
-
method: 'POST',
|
|
7691
|
-
body: {},
|
|
7692
|
-
});
|
|
7693
|
-
toast(`Exporting ${bake}`);
|
|
7694
|
-
} catch (error) {
|
|
7695
|
-
toast(error.message);
|
|
7696
|
-
}
|
|
7697
|
-
loadStacks().catch((e) => toast(e.message));
|
|
7823
|
+
const { stacks } = await api('/api/stacks');
|
|
7824
|
+
await openBakeDialog(
|
|
7825
|
+
bake,
|
|
7826
|
+
stacks.find((one) => one.id === bake),
|
|
7827
|
+
).catch((e) => toast(e.message));
|
|
7698
7828
|
} else if (stopBake !== undefined) {
|
|
7699
7829
|
try {
|
|
7700
7830
|
await api(`/api/stacks/${encodeURIComponent(stopBake)}/bake`, {
|