pmtiles-swarm 0.75.0 → 0.76.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 +38 -0
- package/package.json +1 -1
- package/src/api.js +25 -0
- package/src/bake-jobs.js +92 -2
- package/src/bake.js +59 -0
- package/src/stack-tile.js +38 -3
- package/src/stacks.js +15 -3
- package/src/web/index.html +43 -22
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,44 @@
|
|
|
7
7
|
### 🐞 Bug fixes
|
|
8
8
|
- _...Add new stuff here..._
|
|
9
9
|
|
|
10
|
+
## 0.76.0
|
|
11
|
+
### ✨ Features and improvements
|
|
12
|
+
- **A stopped export stays stopped.** Stopping one left a checkpoint, and a checkpoint says what was
|
|
13
|
+
in progress but not why it stopped - so the next restart could not tell somebody pressing Stop
|
|
14
|
+
from a crash, and picked it up again. Stopping now writes a marker beside the checkpoint, and a
|
|
15
|
+
restart reports what it is holding rather than resuming it. Starting or resuming clears the marker.
|
|
16
|
+
|
|
17
|
+
And the work can be thrown away, which it could not be before: `DELETE /api/stacks/:id/bake/work`
|
|
18
|
+
removes the working directory, which for an abandoned export is hundreds of gigabytes of buffered
|
|
19
|
+
tiles that previously had to be found by hand. Refused while a merge is running rather than pulled
|
|
20
|
+
out from under it.
|
|
21
|
+
|
|
22
|
+
### 🐞 Bug fixes
|
|
23
|
+
- **An edit inside one clock tick went unnoticed.** `stacks.json` is re-read when its modification
|
|
24
|
+
time changes, and a filesystem's clock is coarser than an edit: on NTFS the tick is about 15 ms,
|
|
25
|
+
measured here at 36 rapid rewrites in 40 landing on the same timestamp. Two edits that close
|
|
26
|
+
together left the mtime alone and the second was never read.
|
|
27
|
+
|
|
28
|
+
The size is compared as well now, which catches the ones that changed the file's length - most of
|
|
29
|
+
them - and costs nothing, since the stat was already being made.
|
|
30
|
+
|
|
31
|
+
This is also what made the reload test flake, roughly one run in three: it wrote the file twice in
|
|
32
|
+
quick succession and then depended on how those two writes fell against the clock. It stamps both
|
|
33
|
+
writes to the same instant now and asserts they really are the same, so it tests whether a change
|
|
34
|
+
is noticed rather than whether the clock happened to tick.
|
|
35
|
+
- **A stack with a shallow global source served holes above z14.** How far the merge would climb
|
|
36
|
+
for a source with no tile at this zoom was a fixed six levels. GEBCO is z0-8 and the sea floor has
|
|
37
|
+
no more detail to give, so a stack serving z16 has to upscale that z8 tile eight levels - and at
|
|
38
|
+
z15 the climb stopped one short of the only tile that existed. Over open water, where the other
|
|
39
|
+
source was sparse and had nothing either, no source contributed at all and the stack correctly
|
|
40
|
+
answered no-tile. A rectangular hole, one tile wide, in the middle of the sea.
|
|
41
|
+
|
|
42
|
+
It is derived now rather than fixed: the deepest zoom the stack serves, less the shallowest source
|
|
43
|
+
under it. Nothing to set and nothing to get wrong - the right answer is computable, and a smaller
|
|
44
|
+
one would only punch holes. Somebody who wants the merge to stop climbing says so with `maxzoom`,
|
|
45
|
+
which stops the stack serving that deep at all: the same wish, said where it also stops the work.
|
|
46
|
+
Never below the old six, so no stack reaches less far than it did.
|
|
47
|
+
|
|
10
48
|
## 0.75.0
|
|
11
49
|
### ✨ Features and improvements
|
|
12
50
|
- **`maskRange`, because nodata is a band and not a number.** `maskValues` and `maskColors` both
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.76.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
|
@@ -3303,6 +3303,31 @@ export function createApp({
|
|
|
3303
3303
|
}),
|
|
3304
3304
|
);
|
|
3305
3305
|
|
|
3306
|
+
/**
|
|
3307
|
+
* Throws away what a stopped export had done.
|
|
3308
|
+
*
|
|
3309
|
+
* Separate from stopping, and deliberately a second decision. Stopping keeps
|
|
3310
|
+
* the work because an export may be hours in and somebody may want it back;
|
|
3311
|
+
* this is for when they do not, and until now the only way to be rid of
|
|
3312
|
+
* hundreds of gigabytes of buffered tiles was to find the directory by hand.
|
|
3313
|
+
*/
|
|
3314
|
+
app.delete(
|
|
3315
|
+
'/api/stacks/:id/bake/work',
|
|
3316
|
+
route(async (req, res) => {
|
|
3317
|
+
if (!bakes) {
|
|
3318
|
+
return res.status(501).json({ error: 'this node does not bake' });
|
|
3319
|
+
}
|
|
3320
|
+
const discarded = await bakes.discard(req.params.id);
|
|
3321
|
+
if (!discarded) {
|
|
3322
|
+
return res.status(409).json({
|
|
3323
|
+
error:
|
|
3324
|
+
'there is no stopped export for that stack, or one is still running',
|
|
3325
|
+
});
|
|
3326
|
+
}
|
|
3327
|
+
return res.json({ discarded: true });
|
|
3328
|
+
}),
|
|
3329
|
+
);
|
|
3330
|
+
|
|
3306
3331
|
/**
|
|
3307
3332
|
* Creates or replaces a stack.
|
|
3308
3333
|
*
|
package/src/bake-jobs.js
CHANGED
|
@@ -7,7 +7,11 @@ import {
|
|
|
7
7
|
bakeStack,
|
|
8
8
|
bakedArchiveName,
|
|
9
9
|
bakedName,
|
|
10
|
+
clearStopped,
|
|
11
|
+
discardCheckpoint,
|
|
12
|
+
markStopped,
|
|
10
13
|
mergeTileFor,
|
|
14
|
+
wasStopped,
|
|
11
15
|
} from './bake.js';
|
|
12
16
|
import { PixelWorker } from './pixels.js';
|
|
13
17
|
import { outputFormat } from './stack-tile.js';
|
|
@@ -124,6 +128,10 @@ export class BakeManager {
|
|
|
124
128
|
#loadCodec;
|
|
125
129
|
#cutlines;
|
|
126
130
|
#jobs = new Map();
|
|
131
|
+
// Exports somebody stopped, whose work is still on disk. Kept so the console
|
|
132
|
+
// can offer to resume or discard them: after a restart there is no job in
|
|
133
|
+
// memory, only a directory nobody would think to look for.
|
|
134
|
+
#held = new Map();
|
|
127
135
|
|
|
128
136
|
/**
|
|
129
137
|
* @param {object} deps - The library, the tile store, the config and the codec probe.
|
|
@@ -152,11 +160,36 @@ export class BakeManager {
|
|
|
152
160
|
*/
|
|
153
161
|
get(stackId) {
|
|
154
162
|
const job = this.#jobs.get(stackId);
|
|
155
|
-
|
|
163
|
+
if (job) return this.#describe(job);
|
|
164
|
+
|
|
165
|
+
// Nothing running, but work somebody stopped may still be there. Reported
|
|
166
|
+
// in the same shape so the console has one thing to read.
|
|
167
|
+
const stopped = this.#held.get(stackId);
|
|
168
|
+
if (!stopped) return null;
|
|
169
|
+
return {
|
|
170
|
+
stackId,
|
|
171
|
+
phase: 'stopped',
|
|
172
|
+
written: stopped.written,
|
|
173
|
+
resumable: true,
|
|
174
|
+
...stopped.describe,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Every stack with work waiting that nobody has decided about.
|
|
180
|
+
* @returns {string[]} - Their ids.
|
|
181
|
+
*/
|
|
182
|
+
heldStacks() {
|
|
183
|
+
return [...this.#held.keys()];
|
|
156
184
|
}
|
|
157
185
|
|
|
158
186
|
/**
|
|
159
|
-
* Stops a bake, leaving its work
|
|
187
|
+
* Stops a bake, leaving its work for somebody to pick up or throw away.
|
|
188
|
+
*
|
|
189
|
+
* Marked as stopped on purpose, so the next start leaves it alone. The work
|
|
190
|
+
* is still there and `resume` takes it up again -- what changes is who
|
|
191
|
+
* decides, which for a job that may be hours from finishing should be a
|
|
192
|
+
* person rather than a restart.
|
|
160
193
|
* @param {string} stackId - Which stack.
|
|
161
194
|
* @returns {boolean} - True if there was one to stop.
|
|
162
195
|
*/
|
|
@@ -164,10 +197,46 @@ export class BakeManager {
|
|
|
164
197
|
const job = this.#jobs.get(stackId);
|
|
165
198
|
if (!job || job.finishedAt) return false;
|
|
166
199
|
job.cancelling = true;
|
|
200
|
+
job.stoppedOnPurpose = true;
|
|
167
201
|
job.controller.abort();
|
|
202
|
+
// Not awaited: the abort has to reach the merge now, and a mark written a
|
|
203
|
+
// moment later is still written long before anything restarts.
|
|
204
|
+
markStopped(workDirFor(job, this.#config)).catch(() => {});
|
|
168
205
|
return true;
|
|
169
206
|
}
|
|
170
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Throws away what a stopped export had done.
|
|
210
|
+
*
|
|
211
|
+
* The counterpart to stopping. An export that will not be finished leaves
|
|
212
|
+
* hundreds of gigabytes of buffered tiles behind, and until now the only way
|
|
213
|
+
* to be rid of them was to find the directory by hand.
|
|
214
|
+
* @param {string} stackId - Which stack.
|
|
215
|
+
* @returns {Promise<boolean>} - True if there was work to discard.
|
|
216
|
+
*/
|
|
217
|
+
async discard(stackId) {
|
|
218
|
+
const running = this.#jobs.get(stackId);
|
|
219
|
+
// Refused rather than raced. Removing the directory under a running merge
|
|
220
|
+
// would have it fail on its next write, reporting a disk problem for
|
|
221
|
+
// something somebody chose.
|
|
222
|
+
if (running && !running.finishedAt) return false;
|
|
223
|
+
|
|
224
|
+
let found = false;
|
|
225
|
+
for (const root of this.#workRoots()) {
|
|
226
|
+
const directory = path.join(root, WORK_DIR, stackId);
|
|
227
|
+
const there = await fs
|
|
228
|
+
.access(directory)
|
|
229
|
+
.then(() => true)
|
|
230
|
+
.catch(() => false);
|
|
231
|
+
if (!there) continue;
|
|
232
|
+
await discardCheckpoint(directory);
|
|
233
|
+
found = true;
|
|
234
|
+
}
|
|
235
|
+
this.#jobs.delete(stackId);
|
|
236
|
+
this.#held.delete(stackId);
|
|
237
|
+
return found;
|
|
238
|
+
}
|
|
239
|
+
|
|
171
240
|
/**
|
|
172
241
|
* Picks up exports a previous run did not finish.
|
|
173
242
|
*
|
|
@@ -203,6 +272,21 @@ export class BakeManager {
|
|
|
203
272
|
const resolved = resolve(stackId);
|
|
204
273
|
if (!resolved || bakeRevision(resolved) !== state.revision) continue;
|
|
205
274
|
|
|
275
|
+
// Somebody stopped this one. It stays where it is until they say
|
|
276
|
+
// otherwise -- an export begun again by a restart is the opposite of
|
|
277
|
+
// what pressing Stop meant.
|
|
278
|
+
if (await wasStopped(path.join(directory, stackId))) {
|
|
279
|
+
this.#held.set(stackId, {
|
|
280
|
+
written: state.written ?? 0,
|
|
281
|
+
describe: state.describe,
|
|
282
|
+
});
|
|
283
|
+
console.log(
|
|
284
|
+
`[bake] ${stackId} was stopped on purpose; leaving its ` +
|
|
285
|
+
`${state.written ?? 0} tiles for you to resume or discard`,
|
|
286
|
+
);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
|
|
206
290
|
try {
|
|
207
291
|
const job = await this.start({ resolved, ...state.describe });
|
|
208
292
|
started.push(job);
|
|
@@ -367,6 +451,12 @@ export class BakeManager {
|
|
|
367
451
|
const destination = path.join(workDir, job.name);
|
|
368
452
|
const format = outputFormat(resolved);
|
|
369
453
|
|
|
454
|
+
// Running again is the answer to having been stopped, so the mark goes.
|
|
455
|
+
// Left behind, an export somebody restarted by hand would be passed over
|
|
456
|
+
// by the next restart, which is the same surprise the other way round.
|
|
457
|
+
await clearStopped(workDir);
|
|
458
|
+
this.#held.delete(job.stackId);
|
|
459
|
+
|
|
370
460
|
// Sized with the batch, so every merge in flight has a thread to do its
|
|
371
461
|
// arithmetic on rather than queueing behind one. Only where there is pixel
|
|
372
462
|
// work to move: a passthrough bake hands bytes straight through and would
|
package/src/bake.js
CHANGED
|
@@ -42,6 +42,7 @@ const DEFAULT_CONCURRENCY = 4;
|
|
|
42
42
|
/** What the working directory holds while a bake is in progress. */
|
|
43
43
|
const FILES = Object.freeze({
|
|
44
44
|
state: 'bake-state.json',
|
|
45
|
+
stopped: 'bake-stopped.json',
|
|
45
46
|
entries: 'bake-entries.bin',
|
|
46
47
|
tiles: 'bake-tiles.bin',
|
|
47
48
|
});
|
|
@@ -59,6 +60,64 @@ export function checkpointPaths(workDir) {
|
|
|
59
60
|
};
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Records that an export was stopped on purpose.
|
|
65
|
+
*
|
|
66
|
+
* A checkpoint says what was in progress, not why it stopped, so a crash and
|
|
67
|
+
* somebody pressing Stop look identical on disk -- and the node picks both up
|
|
68
|
+
* on the next start. That is right for the crash and wrong for the person, who
|
|
69
|
+
* stopped it and then watched it begin again.
|
|
70
|
+
*
|
|
71
|
+
* Written beside the checkpoint rather than into it, so it cannot be lost to a
|
|
72
|
+
* half-finished write of the state the export is still making.
|
|
73
|
+
* @param {string} workDir - The bake's working directory.
|
|
74
|
+
* @returns {Promise<void>} - Resolves once it is written.
|
|
75
|
+
*/
|
|
76
|
+
export async function markStopped(workDir) {
|
|
77
|
+
await fs
|
|
78
|
+
.writeFile(
|
|
79
|
+
path.join(workDir, FILES.stopped),
|
|
80
|
+
JSON.stringify({ at: new Date().toISOString() }),
|
|
81
|
+
)
|
|
82
|
+
.catch(() => {});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Forgets that an export was stopped, because it is running again.
|
|
87
|
+
* @param {string} workDir - The bake's working directory.
|
|
88
|
+
* @returns {Promise<void>} - Resolves once it is gone.
|
|
89
|
+
*/
|
|
90
|
+
export async function clearStopped(workDir) {
|
|
91
|
+
await fs
|
|
92
|
+
.rm(path.join(workDir, FILES.stopped), { force: true })
|
|
93
|
+
.catch(() => {});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether an export was stopped on purpose rather than interrupted.
|
|
98
|
+
* @param {string} workDir - The bake's working directory.
|
|
99
|
+
* @returns {Promise<boolean>} - True when somebody stopped it.
|
|
100
|
+
*/
|
|
101
|
+
export async function wasStopped(workDir) {
|
|
102
|
+
return fs
|
|
103
|
+
.access(path.join(workDir, FILES.stopped))
|
|
104
|
+
.then(() => true)
|
|
105
|
+
.catch(() => false);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Forgets an export's unfinished work.
|
|
110
|
+
*
|
|
111
|
+
* The whole directory, because a checkpoint is only meaningful with the tiles
|
|
112
|
+
* it names -- leaving either behind is leaving something that will be picked
|
|
113
|
+
* up and found wanting.
|
|
114
|
+
* @param {string} workDir - The bake's working directory.
|
|
115
|
+
* @returns {Promise<void>} - Resolves once it is gone.
|
|
116
|
+
*/
|
|
117
|
+
export async function discardCheckpoint(workDir) {
|
|
118
|
+
await fs.rm(workDir, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
|
|
62
121
|
/**
|
|
63
122
|
* One entry, as a checkpoint stores it.
|
|
64
123
|
*
|
package/src/stack-tile.js
CHANGED
|
@@ -36,9 +36,42 @@ import { TileReadError } from './tiles.js';
|
|
|
36
36
|
* a tile in an archive or a hole where one is not needed.
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* How far up the pyramid a merge will climb for a source with no tile here,
|
|
41
|
+
* where the stack does not say and nothing can be worked out.
|
|
42
|
+
*/
|
|
40
43
|
const PARENT_LIMIT = 6;
|
|
41
44
|
|
|
45
|
+
/**
|
|
46
|
+
* How far this stack has to climb for its shallowest source to keep working.
|
|
47
|
+
*
|
|
48
|
+
* A global source is shallow on purpose -- GEBCO is z0-8 and the sea floor has
|
|
49
|
+
* no more detail to give -- so serving a stack to z16 means upscaling that z8
|
|
50
|
+
* tile eight levels. A fixed limit truncates exactly the arrangement this
|
|
51
|
+
* feature is for: at z15 the climb stopped one level short of the only tile
|
|
52
|
+
* that exists, no source contributed, and the stack answered no-tile over open
|
|
53
|
+
* water.
|
|
54
|
+
*
|
|
55
|
+
* So it is derived from what the stack spans rather than assumed or set: the
|
|
56
|
+
* right answer is computable, and a recipe naming a smaller one would only
|
|
57
|
+
* punch holes in itself. Somebody who wants the merge to stop climbing says so
|
|
58
|
+
* with `maxzoom`, which stops the stack serving that deep at all -- the same
|
|
59
|
+
* wish, said where it also stops the work.
|
|
60
|
+
*
|
|
61
|
+
* Never below the old fixed limit, so no stack reaches less far than it did.
|
|
62
|
+
* @param {object} resolved - The resolved stack.
|
|
63
|
+
* @returns {number} - Levels a source may climb.
|
|
64
|
+
*/
|
|
65
|
+
export function parentLimitFor(resolved) {
|
|
66
|
+
const { maxzoom } = stackCoverage(resolved);
|
|
67
|
+
const shallowest = resolved.sources
|
|
68
|
+
.map((source) => source.entry?.pmtiles?.maxZoom)
|
|
69
|
+
.filter((zoom) => Number.isFinite(zoom));
|
|
70
|
+
if (!shallowest.length || !Number.isFinite(maxzoom)) return PARENT_LIMIT;
|
|
71
|
+
|
|
72
|
+
return Math.max(PARENT_LIMIT, maxzoom - Math.min(...shallowest));
|
|
73
|
+
}
|
|
74
|
+
|
|
42
75
|
/** The deepest zoom a tile id is defined for. */
|
|
43
76
|
const MAX_ZOOM = 26;
|
|
44
77
|
|
|
@@ -141,8 +174,8 @@ export function clipsFor(resolved, cutlines, z, x, y, size = 256) {
|
|
|
141
174
|
* @param {object} options - Source, coordinates, the tile store and whether to climb.
|
|
142
175
|
* @returns {Promise<object|null>} - The tile and the zoom it came from.
|
|
143
176
|
*/
|
|
144
|
-
async function readFrom({ source, z, x, y, tiles, climb, signal }) {
|
|
145
|
-
const floor = climb ? Math.max(0, z - PARENT_LIMIT) : z;
|
|
177
|
+
async function readFrom({ source, z, x, y, tiles, climb, signal, limit }) {
|
|
178
|
+
const floor = climb ? Math.max(0, z - (limit ?? PARENT_LIMIT)) : z;
|
|
146
179
|
for (let at = z; at >= floor; at -= 1) {
|
|
147
180
|
const shift = z - at;
|
|
148
181
|
const tile = await tiles.getTile(
|
|
@@ -322,6 +355,7 @@ export function passThroughRead({
|
|
|
322
355
|
* @returns {Promise<object>} - `{contributors, contributions}` or `{error}`.
|
|
323
356
|
*/
|
|
324
357
|
async function readAll({ resolved, z, x, y, tiles, signal, clips }) {
|
|
358
|
+
const limit = parentLimitFor(resolved);
|
|
325
359
|
return Promise.all(
|
|
326
360
|
resolved.sources.map(async (source, index) => {
|
|
327
361
|
if (!source.entry) return { source, found: null };
|
|
@@ -340,6 +374,7 @@ async function readAll({ resolved, z, x, y, tiles, signal, clips }) {
|
|
|
340
374
|
tiles,
|
|
341
375
|
climb: true,
|
|
342
376
|
signal,
|
|
377
|
+
limit,
|
|
343
378
|
}),
|
|
344
379
|
};
|
|
345
380
|
} catch (error) {
|
package/src/stacks.js
CHANGED
|
@@ -528,6 +528,7 @@ export class StackStore {
|
|
|
528
528
|
#stacks = new Map();
|
|
529
529
|
#problems = new Map();
|
|
530
530
|
#mtime = null;
|
|
531
|
+
#size = null;
|
|
531
532
|
#checkedAt = 0;
|
|
532
533
|
|
|
533
534
|
/**
|
|
@@ -552,10 +553,13 @@ export class StackStore {
|
|
|
552
553
|
let raw;
|
|
553
554
|
try {
|
|
554
555
|
raw = JSON.parse(await fs.readFile(this.#file, 'utf8'));
|
|
555
|
-
|
|
556
|
+
const stat = await fs.stat(this.#file);
|
|
557
|
+
this.#mtime = stat.mtimeMs;
|
|
558
|
+
this.#size = stat.size;
|
|
556
559
|
} catch (error) {
|
|
557
560
|
if (error.code === 'ENOENT') {
|
|
558
561
|
this.#mtime = null;
|
|
562
|
+
this.#size = null;
|
|
559
563
|
return;
|
|
560
564
|
}
|
|
561
565
|
throw error;
|
|
@@ -588,9 +592,15 @@ export class StackStore {
|
|
|
588
592
|
if (now - this.#checkedAt < 1000) return false;
|
|
589
593
|
this.#checkedAt = now;
|
|
590
594
|
|
|
595
|
+
// Size as well as the timestamp. A filesystem's clock is coarser than an
|
|
596
|
+
// edit: on NTFS the tick is about 15 ms, so two writes in quick succession
|
|
597
|
+
// land on the same mtime and the second would never be seen. Comparing the
|
|
598
|
+
// length as well catches the ones that changed it, which is most of them --
|
|
599
|
+
// and it costs nothing, since the stat was made anyway.
|
|
591
600
|
const stat = await fs.stat(this.#file).catch(() => null);
|
|
592
601
|
const mtime = stat?.mtimeMs ?? null;
|
|
593
|
-
|
|
602
|
+
const size = stat?.size ?? null;
|
|
603
|
+
if (mtime === this.#mtime && size === this.#size) return false;
|
|
594
604
|
await this.load();
|
|
595
605
|
return true;
|
|
596
606
|
}
|
|
@@ -676,6 +686,8 @@ export class StackStore {
|
|
|
676
686
|
`,
|
|
677
687
|
);
|
|
678
688
|
await fs.rename(temp, this.#file);
|
|
679
|
-
|
|
689
|
+
const written = await fs.stat(this.#file);
|
|
690
|
+
this.#mtime = written.mtimeMs;
|
|
691
|
+
this.#size = written.size;
|
|
680
692
|
}
|
|
681
693
|
}
|
package/src/web/index.html
CHANGED
|
@@ -1905,18 +1905,21 @@
|
|
|
1905
1905
|
);
|
|
1906
1906
|
|
|
1907
1907
|
/**
|
|
1908
|
-
*
|
|
1908
|
+
* One end of a mask range, for the box that shows it.
|
|
1909
1909
|
*
|
|
1910
|
-
*
|
|
1911
|
-
*
|
|
1912
|
-
* than the editor
|
|
1910
|
+
* A recipe may hold several bands where the editor offers one. The first
|
|
1911
|
+
* is what the boxes show, and the row says how many more there are --
|
|
1912
|
+
* silently dropping them would be worse than saying the editor cannot
|
|
1913
|
+
* draw them.
|
|
1913
1914
|
* @param {*} range - What the recipe says.
|
|
1915
|
+
* @param {number} which - 0 for the low end, 1 for the high.
|
|
1914
1916
|
* @returns {string} - What to put in the box.
|
|
1915
1917
|
*/
|
|
1916
|
-
const
|
|
1918
|
+
const rangeEdge = (range, which) => {
|
|
1917
1919
|
if (!Array.isArray(range) || range.length === 0) return '';
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
+
const first = Array.isArray(range[0]) ? range[0] : range;
|
|
1921
|
+
const edge = first[which];
|
|
1922
|
+
return Number.isFinite(Number(edge)) ? String(edge) : '';
|
|
1920
1923
|
};
|
|
1921
1924
|
|
|
1922
1925
|
/**
|
|
@@ -7635,11 +7638,23 @@ Every piece is hashed against the ` +
|
|
|
7635
7638
|
value="${escapeHtml((source.maskValues ?? []).join(', '))}" />
|
|
7636
7639
|
</label>
|
|
7637
7640
|
<label class="choice"
|
|
7638
|
-
title="Mask every height
|
|
7639
|
-
Mask
|
|
7640
|
-
<input style="width:
|
|
7641
|
-
data-stack-field="
|
|
7642
|
-
value="${
|
|
7641
|
+
title="Mask every height between these two, inclusive. Separate from Mask heights, which takes values one at a time — nodata is often a band rather than a number, because an archive resampled on its way to being built does not hold what it was authored with. A sea authored as 0 arrives scattered across -0.9 m to 0, and masking the two ends of that leaves everything between, standing proud of whatever is underneath. Leave both empty for no band.">
|
|
7642
|
+
Mask between
|
|
7643
|
+
<input type="number" step="any" style="width:5.5rem" placeholder="low"
|
|
7644
|
+
data-stack-field="maskRangeLow" data-stack-index="${index}"
|
|
7645
|
+
value="${rangeEdge(source.maskRange, 0)}" />
|
|
7646
|
+
and
|
|
7647
|
+
<input type="number" step="any" style="width:5.5rem" placeholder="high"
|
|
7648
|
+
data-stack-field="maskRangeHigh" data-stack-index="${index}"
|
|
7649
|
+
value="${rangeEdge(source.maskRange, 1)}" /> m
|
|
7650
|
+
${
|
|
7651
|
+
Array.isArray(source.maskRange) &&
|
|
7652
|
+
Array.isArray(source.maskRange[0]) &&
|
|
7653
|
+
source.maskRange.length > 1
|
|
7654
|
+
? `<span class="sub">and ${source.maskRange.length - 1} more,
|
|
7655
|
+
which editing here replaces</span>`
|
|
7656
|
+
: ''
|
|
7657
|
+
}
|
|
7643
7658
|
</label>`;
|
|
7644
7659
|
|
|
7645
7660
|
return `
|
|
@@ -7761,16 +7776,22 @@ Every piece is hashed against the ` +
|
|
|
7761
7776
|
.filter(Boolean);
|
|
7762
7777
|
} else if (field === 'required') {
|
|
7763
7778
|
source.required = event.target.checked;
|
|
7764
|
-
} else if (field === '
|
|
7765
|
-
//
|
|
7766
|
-
//
|
|
7767
|
-
//
|
|
7768
|
-
const
|
|
7769
|
-
|
|
7770
|
-
|
|
7771
|
-
.
|
|
7772
|
-
|
|
7773
|
-
|
|
7779
|
+
} else if (field === 'maskRangeLow' || field === 'maskRangeHigh') {
|
|
7780
|
+
// A band needs both ends, so one on its own is a half-typed thought
|
|
7781
|
+
// rather than a range -- read them together, and write nothing until
|
|
7782
|
+
// there are two numbers to write.
|
|
7783
|
+
const row = event.target.closest('.card');
|
|
7784
|
+
const edge = (name) => {
|
|
7785
|
+
const box = row?.querySelector(`[data-stack-field="${name}"]`);
|
|
7786
|
+
return box && box.value.trim() !== '' ? Number(box.value) : null;
|
|
7787
|
+
};
|
|
7788
|
+
const low = edge('maskRangeLow');
|
|
7789
|
+
const high = edge('maskRangeHigh');
|
|
7790
|
+
if (Number.isFinite(low) && Number.isFinite(high)) {
|
|
7791
|
+
source.maskRange = low <= high ? [low, high] : [high, low];
|
|
7792
|
+
} else if (low === null && high === null) {
|
|
7793
|
+
delete source.maskRange;
|
|
7794
|
+
}
|
|
7774
7795
|
} else if (field === 'feather') {
|
|
7775
7796
|
const pixels = Number(event.target.value);
|
|
7776
7797
|
if (event.target.value === '' || !(pixels > 0)) delete source.feather;
|