pmtiles-swarm 0.74.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 +66 -1
- package/docs/tile-stacks.md +69 -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/elevation.js +75 -0
- package/src/stack-tile.js +46 -5
- package/src/stacks.js +39 -3
- package/src/web/index.html +53 -0
- package/tools/terrain-probe.mjs +250 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,8 +7,73 @@
|
|
|
7
7
|
### 🐞 Bug fixes
|
|
8
8
|
- _...Add new stuff here..._
|
|
9
9
|
|
|
10
|
-
## 0.
|
|
10
|
+
## 0.76.0
|
|
11
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
|
+
|
|
48
|
+
## 0.75.0
|
|
49
|
+
### ✨ Features and improvements
|
|
50
|
+
- **`maskRange`, because nodata is a band and not a number.** `maskValues` and `maskColors` both
|
|
51
|
+
compare exactly, and cubic resampling overshoots at every edge it crosses - so a sea authored as
|
|
52
|
+
`0` arrives scattered across `-0.9` to `0`. The exact matches were masked and everything between
|
|
53
|
+
them was left standing.
|
|
54
|
+
|
|
55
|
+
Measured on a real merge, a terrain source over GEBCO at z13. The recipe masked `#018696` and
|
|
56
|
+
`#0186a0`, which decode to -1.0 m and 0.0 m; the nine colours between them, -0.9 m to -0.1 m,
|
|
57
|
+
were never masked. 1,227 pixels of one tile stood clear of all eight neighbours, the worst 25.1 m
|
|
58
|
+
above the bathymetry underneath - a scattering of 25 m pillars over open water, which is what
|
|
59
|
+
stipples a hillshade.
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{ "archive": "planet", "maskRange": [-1, 0] }
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A band rather than a width either side of a value, because nodata is rarely symmetric about
|
|
66
|
+
anything: sea is everything up to zero and nothing above it, and a width reaching a metre down
|
|
67
|
+
reaches a metre up as well, into ground that is really there. It is also what a recipe naming two
|
|
68
|
+
colours was already reaching for - they are the ends of one. A list of bands is accepted for a
|
|
69
|
+
source with a sentinel as well as a range, and the edges are inclusive, compared in thousandths
|
|
70
|
+
so `[-0.2, 0]` includes the -0.2 a Float32Array stores as -0.20000000298.
|
|
71
|
+
|
|
72
|
+
Worth recording that no average shows the problem this solves. That merged tile measures 0.2 m of
|
|
73
|
+
roughness at the median and is smooth by every summary statistic, because half a per cent of
|
|
74
|
+
pixels never move the middle of a distribution. `tools/terrain-probe.mjs` reports the tail and
|
|
75
|
+
counts pixels standing clear of their neighbours, which is the shape it makes.
|
|
76
|
+
|
|
12
77
|
- **The holes a mask leaves are feathered now, not just a cutline's edge.** `feather` faded a source
|
|
13
78
|
in at its `cutline` or `bounds` and did nothing about `maskValues` or `maskColors` - which is
|
|
14
79
|
where most of these recipes actually stop, and why the field looked like it did nothing.
|
package/docs/tile-stacks.md
CHANGED
|
@@ -241,6 +241,7 @@ it differs from the snake_case rio-rgbify-merge uses.
|
|
|
241
241
|
| `encoding` | `mapbox` or `terrarium`. Elevation space only. |
|
|
242
242
|
| `baseVal` / `interval` | Mapbox decode offset and step. Default `-10000` and `0.1`. |
|
|
243
243
|
| `maskValues` | Decoded heights meaning "no data here". Elevation space only. |
|
|
244
|
+
| `maskRange` | `[low, high]` in metres, or a list of them. Everything inside is nodata. Elevation space only. |
|
|
244
245
|
| `heightAdjustment` | Metres, added **after** masking. Elevation space only. |
|
|
245
246
|
| `feather` | Pixels to fade in over at the edge of the source's shape. Needs a `cutline` or `bounds`. Max 64. |
|
|
246
247
|
| `opacity` | `0`–`1`, scales the source alpha. RGBA space only. |
|
|
@@ -1014,6 +1015,74 @@ missing. Serving a source unclipped because its cutline could not be found would
|
|
|
1014
1015
|
put back exactly the data somebody asked to remove, which is the one failure a
|
|
1015
1016
|
clip must not have.
|
|
1016
1017
|
|
|
1018
|
+
## What a mask has to match
|
|
1019
|
+
|
|
1020
|
+
`maskValues` and `maskColors` both compare exactly, and an archive that was
|
|
1021
|
+
resampled on its way to being built does not hold the number it was authored
|
|
1022
|
+
with. Cubic overshoots at every edge it crosses, so a sea authored as exactly
|
|
1023
|
+
`0` arrives as a field of `0` with `-0.4` and `0.1` scattered through it near
|
|
1024
|
+
the coast.
|
|
1025
|
+
|
|
1026
|
+
Masking by colour makes this sharper rather than softer, because a colour is one
|
|
1027
|
+
exact number and the resampled ground either side of it is several others. A
|
|
1028
|
+
recipe masking `#018696` and `#0186a0` is masking **-1.0 m** and **0.0 m** —
|
|
1029
|
+
and leaving `#018697` through `#01869f`, which are -0.9 m through -0.1 m,
|
|
1030
|
+
entirely alone.
|
|
1031
|
+
|
|
1032
|
+
Measured on a real merge — a planet DEM over GEBCO bathymetry, one tile at z13:
|
|
1033
|
+
|
|
1034
|
+
| | |
|
|
1035
|
+
| --------------------------------------------- | ------------- |
|
|
1036
|
+
| planet source, pixels at exactly `0` | 109,441 |
|
|
1037
|
+
| its lowest value | -0.4 m |
|
|
1038
|
+
| merged tile, pixels at exactly `0` | 13 |
|
|
1039
|
+
| merged tile, pixels clear of all 8 neighbours | 1,227 (0.47%) |
|
|
1040
|
+
| the worst of them | 25.1 m |
|
|
1041
|
+
|
|
1042
|
+
`maskValues: [0]` took the 109,441 zeroes and left the rest standing. With
|
|
1043
|
+
bathymetry underneath, each survivor became a spike as tall as the difference
|
|
1044
|
+
between the two sources — a scattering of 25 m pillars over open water, which
|
|
1045
|
+
is what stipples a hillshade.
|
|
1046
|
+
|
|
1047
|
+
`maskRange` says the band outright:
|
|
1048
|
+
|
|
1049
|
+
```json
|
|
1050
|
+
{ "archive": "planet", "maskRange": [-1, 0] }
|
|
1051
|
+
```
|
|
1052
|
+
|
|
1053
|
+
A band rather than a width either side of a value, because nodata is rarely
|
|
1054
|
+
symmetric about anything. Sea is everything up to zero and nothing above it,
|
|
1055
|
+
and a width reaching a metre down reaches a metre up as well, into ground that
|
|
1056
|
+
is really there. It is also what the recipe above was already reaching for: its
|
|
1057
|
+
two colours are the ends of one.
|
|
1058
|
+
|
|
1059
|
+
Several, where a source has a sentinel as well as a band:
|
|
1060
|
+
|
|
1061
|
+
```json
|
|
1062
|
+
{
|
|
1063
|
+
"archive": "planet",
|
|
1064
|
+
"maskRange": [
|
|
1065
|
+
[-1, 0],
|
|
1066
|
+
[-10001, -9999]
|
|
1067
|
+
]
|
|
1068
|
+
}
|
|
1069
|
+
```
|
|
1070
|
+
|
|
1071
|
+
The edges are inclusive and compared in thousandths, because a `Float32Array`
|
|
1072
|
+
holds -0.2 as -0.20000000298 and an edge that does not include the number
|
|
1073
|
+
written on it leaves a row of pixels behind.
|
|
1074
|
+
|
|
1075
|
+
It is a trade rather than a free win: a band wide enough to catch the
|
|
1076
|
+
resampling's overshoot also eats genuine ground inside it. Reaching down to the
|
|
1077
|
+
lowest value the sea arrives at is usually the right price; reaching up above
|
|
1078
|
+
zero is not, which is the asymmetry a band can express and a width cannot.
|
|
1079
|
+
|
|
1080
|
+
Worth knowing that neither a median nor a colour ramp shows this. The merged
|
|
1081
|
+
tile above measures 0.2 m of roughness at the median and looks smooth by every
|
|
1082
|
+
summary statistic — half a per cent of pixels never move the middle of a
|
|
1083
|
+
distribution. `tools/terrain-probe.mjs` reports the tail and counts pixels
|
|
1084
|
+
standing clear of their neighbours, which is the shape this makes.
|
|
1085
|
+
|
|
1017
1086
|
## Feathering a seam
|
|
1018
1087
|
|
|
1019
1088
|
_Built for a cutline and for `bounds`. A mask edge is not feathered yet, and
|
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/elevation.js
CHANGED
|
@@ -162,6 +162,77 @@ export function maskHeights(heights, maskValues) {
|
|
|
162
162
|
return heights;
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Reads `maskRange` into a list of low-high pairs.
|
|
167
|
+
*
|
|
168
|
+
* One pair or several, because both read naturally: `[-1, 0]` is the common
|
|
169
|
+
* case and `[[-1, 0], [-10001, -9999]]` is a source with a sentinel as well as
|
|
170
|
+
* a band. A pair the wrong way round is taken as the same band rather than
|
|
171
|
+
* refused -- the recipe validation says so, and a merge that has got this far
|
|
172
|
+
* should not silently mask nothing.
|
|
173
|
+
* @param {Array<number[]>|number[]|undefined} maskRange - What the recipe said.
|
|
174
|
+
* @returns {Array<number[]>} - Pairs, low first.
|
|
175
|
+
*/
|
|
176
|
+
export function rangesOf(maskRange) {
|
|
177
|
+
if (!Array.isArray(maskRange) || maskRange.length === 0) return [];
|
|
178
|
+
const pairs = Array.isArray(maskRange[0]) ? maskRange : [maskRange];
|
|
179
|
+
const out = [];
|
|
180
|
+
for (const pair of pairs) {
|
|
181
|
+
if (!Array.isArray(pair) || pair.length < 2) continue;
|
|
182
|
+
const low = Number(pair[0]);
|
|
183
|
+
const high = Number(pair[1]);
|
|
184
|
+
if (!Number.isFinite(low) || !Number.isFinite(high)) continue;
|
|
185
|
+
out.push(low <= high ? [low, high] : [high, low]);
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Blanks every height inside a band.
|
|
192
|
+
*
|
|
193
|
+
* The shape nodata actually has. An archive resampled on its way to being
|
|
194
|
+
* built does not hold the number it was authored with -- cubic overshoots at
|
|
195
|
+
* every edge it crosses, so a sea authored as exactly 0 arrives as a field of
|
|
196
|
+
* 0 with -0.4 and 0.1 scattered through it near the coast. Masking the exact
|
|
197
|
+
* values takes some of them and leaves the rest standing, and where another
|
|
198
|
+
* source is underneath each survivor becomes a spike as tall as the difference
|
|
199
|
+
* between them: measured at 25 m on a real merge, on half a per cent of the
|
|
200
|
+
* tile, which is what stipples a hillshade.
|
|
201
|
+
*
|
|
202
|
+
* A band rather than a width either side of a value, because nodata is rarely
|
|
203
|
+
* symmetric about anything. Sea is everything from the deepest sentinel up to
|
|
204
|
+
* zero and nothing above it, and a width wide enough to reach the bottom of
|
|
205
|
+
* that reaches the same distance into real ground. See docs/tile-stacks.md --
|
|
206
|
+
* "What a mask has to match".
|
|
207
|
+
* @param {Float32Array} heights - Metres, modified in place.
|
|
208
|
+
* @param {Array<number[]>|number[]} [maskRange] - A `[low, high]` pair, or a list of them.
|
|
209
|
+
* @returns {Float32Array} - The same array.
|
|
210
|
+
*/
|
|
211
|
+
export function maskRanges(heights, maskRange) {
|
|
212
|
+
const bands = rangesOf(maskRange).map(([low, high]) => [
|
|
213
|
+
Math.round(low * 1000),
|
|
214
|
+
Math.round(high * 1000),
|
|
215
|
+
]);
|
|
216
|
+
if (bands.length === 0) return heights;
|
|
217
|
+
|
|
218
|
+
// Compared in thousandths, the same way maskHeights compares. A Float32Array
|
|
219
|
+
// holds -0.2 as -0.20000000298, which falls outside a band written as
|
|
220
|
+
// [-0.2, 0] -- and an edge that does not include the number written on it is
|
|
221
|
+
// a mask that quietly leaves a row of pixels behind.
|
|
222
|
+
for (let i = 0; i < heights.length; i += 1) {
|
|
223
|
+
const height = heights[i];
|
|
224
|
+
if (Number.isNaN(height)) continue;
|
|
225
|
+
const at = Math.round(height * 1000);
|
|
226
|
+
for (const [low, high] of bands) {
|
|
227
|
+
if (at >= low && at <= high) {
|
|
228
|
+
heights[i] = Number.NaN;
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return heights;
|
|
234
|
+
}
|
|
235
|
+
|
|
165
236
|
/**
|
|
166
237
|
* Reads one colour from a recipe into a packed 24-bit value.
|
|
167
238
|
*
|
|
@@ -638,8 +709,12 @@ export function mergeElevation(contributions, options) {
|
|
|
638
709
|
// Both masks say the same thing -- nothing here -- and both have to run
|
|
639
710
|
// before the height adjustment, which would otherwise shift the values
|
|
640
711
|
// out from under the comparison.
|
|
712
|
+
//
|
|
641
713
|
maskHeights(heights, source.maskValues);
|
|
642
714
|
maskColors(heights, contribution.raster, source.maskColors);
|
|
715
|
+
// A band as well as, not instead of: a source may have a sentinel it names
|
|
716
|
+
// exactly and a range of ground it does not want either.
|
|
717
|
+
maskRanges(heights, source.maskRange);
|
|
643
718
|
if (source.heightAdjustment) {
|
|
644
719
|
for (let i = 0; i < heights.length; i += 1) {
|
|
645
720
|
heights[i] += source.heightAdjustment;
|
package/src/stack-tile.js
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
fillNodata,
|
|
6
6
|
maskColors,
|
|
7
7
|
maskHeights,
|
|
8
|
+
maskRanges,
|
|
8
9
|
mergeElevation,
|
|
9
10
|
} from './elevation.js';
|
|
10
11
|
import { paddedKnown, parentsFor } from './mask-edge.js';
|
|
@@ -35,9 +36,42 @@ import { TileReadError } from './tiles.js';
|
|
|
35
36
|
* a tile in an archive or a hole where one is not needed.
|
|
36
37
|
*/
|
|
37
38
|
|
|
38
|
-
/**
|
|
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
|
+
*/
|
|
39
43
|
const PARENT_LIMIT = 6;
|
|
40
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
|
+
|
|
41
75
|
/** The deepest zoom a tile id is defined for. */
|
|
42
76
|
const MAX_ZOOM = 26;
|
|
43
77
|
|
|
@@ -140,8 +174,8 @@ export function clipsFor(resolved, cutlines, z, x, y, size = 256) {
|
|
|
140
174
|
* @param {object} options - Source, coordinates, the tile store and whether to climb.
|
|
141
175
|
* @returns {Promise<object|null>} - The tile and the zoom it came from.
|
|
142
176
|
*/
|
|
143
|
-
async function readFrom({ source, z, x, y, tiles, climb, signal }) {
|
|
144
|
-
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;
|
|
145
179
|
for (let at = z; at >= floor; at -= 1) {
|
|
146
180
|
const shift = z - at;
|
|
147
181
|
const tile = await tiles.getTile(
|
|
@@ -321,6 +355,7 @@ export function passThroughRead({
|
|
|
321
355
|
* @returns {Promise<object>} - `{contributors, contributions}` or `{error}`.
|
|
322
356
|
*/
|
|
323
357
|
async function readAll({ resolved, z, x, y, tiles, signal, clips }) {
|
|
358
|
+
const limit = parentLimitFor(resolved);
|
|
324
359
|
return Promise.all(
|
|
325
360
|
resolved.sources.map(async (source, index) => {
|
|
326
361
|
if (!source.entry) return { source, found: null };
|
|
@@ -339,6 +374,7 @@ async function readAll({ resolved, z, x, y, tiles, signal, clips }) {
|
|
|
339
374
|
tiles,
|
|
340
375
|
climb: true,
|
|
341
376
|
signal,
|
|
377
|
+
limit,
|
|
342
378
|
}),
|
|
343
379
|
};
|
|
344
380
|
} catch (error) {
|
|
@@ -450,7 +486,11 @@ async function gather({ reads, z, x, y, tiles, codec, signal, size, rgba }) {
|
|
|
450
486
|
* @returns {boolean} - True when it masks anything.
|
|
451
487
|
*/
|
|
452
488
|
function masksAnything(recipe) {
|
|
453
|
-
return Boolean(
|
|
489
|
+
return Boolean(
|
|
490
|
+
recipe?.maskValues?.length ||
|
|
491
|
+
recipe?.maskColors?.length ||
|
|
492
|
+
recipe?.maskRange?.length,
|
|
493
|
+
);
|
|
454
494
|
}
|
|
455
495
|
|
|
456
496
|
/**
|
|
@@ -506,12 +546,13 @@ async function readMaskEdges({
|
|
|
506
546
|
.catch(() => null);
|
|
507
547
|
if (!raster) return;
|
|
508
548
|
|
|
509
|
-
// The same
|
|
549
|
+
// The same masking the merge applies, asked of the parent. A ramp
|
|
510
550
|
// measured against a different idea of where the holes are would fade
|
|
511
551
|
// toward ground that is not a hole.
|
|
512
552
|
const heights = decodeHeights(raster, recipe);
|
|
513
553
|
maskHeights(heights, recipe.maskValues);
|
|
514
554
|
maskColors(heights, raster, recipe.maskColors);
|
|
555
|
+
maskRanges(heights, recipe.maskRange);
|
|
515
556
|
|
|
516
557
|
const flags = new Uint8Array(heights.length);
|
|
517
558
|
for (let i = 0; i < flags.length; i += 1) {
|
package/src/stacks.js
CHANGED
|
@@ -35,6 +35,8 @@ import { BLEND_MODES, isBlendMode } from './rgba.js';
|
|
|
35
35
|
* @property {number} [baseVal] - Mapbox decode offset.
|
|
36
36
|
* @property {number} [interval] - Mapbox decode step.
|
|
37
37
|
* @property {number[]} [maskValues] - Heights meaning "no data here".
|
|
38
|
+
* @property {Array<number[]>|number[]} [maskRange] - A `[low, high]` band of
|
|
39
|
+
* heights meaning "no data here", or a list of them.
|
|
38
40
|
* @property {Array<string|number[]>} [maskColors] - Pixel colours meaning the
|
|
39
41
|
* same, as "#rrggbb" or [r, g, b]. Exact, where a height mask has to round.
|
|
40
42
|
* @property {number} [heightAdjustment] - Metres, added after masking.
|
|
@@ -127,6 +129,28 @@ export function validateStack(stack) {
|
|
|
127
129
|
if (source?.maskValues !== undefined && !Array.isArray(source.maskValues)) {
|
|
128
130
|
problems.push(`sources[${index}].maskValues must be a list`);
|
|
129
131
|
}
|
|
132
|
+
if (source?.maskRange !== undefined) {
|
|
133
|
+
// One pair or a list of them. Anything else is a range nobody can read,
|
|
134
|
+
// and a mask that silently matches nothing is the failure this whole
|
|
135
|
+
// feature is most prone to.
|
|
136
|
+
const given = Array.isArray(source.maskRange) ? source.maskRange : null;
|
|
137
|
+
const pairs =
|
|
138
|
+
given && given.length && Array.isArray(given[0]) ? given : [given];
|
|
139
|
+
const usable =
|
|
140
|
+
given &&
|
|
141
|
+
given.length > 0 &&
|
|
142
|
+
pairs.every(
|
|
143
|
+
(pair) =>
|
|
144
|
+
Array.isArray(pair) &&
|
|
145
|
+
pair.length === 2 &&
|
|
146
|
+
pair.every((edge) => Number.isFinite(Number(edge))),
|
|
147
|
+
);
|
|
148
|
+
if (!usable) {
|
|
149
|
+
problems.push(
|
|
150
|
+
`sources[${index}].maskRange must be [low, high], or a list of them`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
130
154
|
if (source?.feather !== undefined) {
|
|
131
155
|
const feather = Number(source.feather);
|
|
132
156
|
if (!Number.isFinite(feather) || feather < 0 || feather > MAX_FEATHER) {
|
|
@@ -504,6 +528,7 @@ export class StackStore {
|
|
|
504
528
|
#stacks = new Map();
|
|
505
529
|
#problems = new Map();
|
|
506
530
|
#mtime = null;
|
|
531
|
+
#size = null;
|
|
507
532
|
#checkedAt = 0;
|
|
508
533
|
|
|
509
534
|
/**
|
|
@@ -528,10 +553,13 @@ export class StackStore {
|
|
|
528
553
|
let raw;
|
|
529
554
|
try {
|
|
530
555
|
raw = JSON.parse(await fs.readFile(this.#file, 'utf8'));
|
|
531
|
-
|
|
556
|
+
const stat = await fs.stat(this.#file);
|
|
557
|
+
this.#mtime = stat.mtimeMs;
|
|
558
|
+
this.#size = stat.size;
|
|
532
559
|
} catch (error) {
|
|
533
560
|
if (error.code === 'ENOENT') {
|
|
534
561
|
this.#mtime = null;
|
|
562
|
+
this.#size = null;
|
|
535
563
|
return;
|
|
536
564
|
}
|
|
537
565
|
throw error;
|
|
@@ -564,9 +592,15 @@ export class StackStore {
|
|
|
564
592
|
if (now - this.#checkedAt < 1000) return false;
|
|
565
593
|
this.#checkedAt = now;
|
|
566
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.
|
|
567
600
|
const stat = await fs.stat(this.#file).catch(() => null);
|
|
568
601
|
const mtime = stat?.mtimeMs ?? null;
|
|
569
|
-
|
|
602
|
+
const size = stat?.size ?? null;
|
|
603
|
+
if (mtime === this.#mtime && size === this.#size) return false;
|
|
570
604
|
await this.load();
|
|
571
605
|
return true;
|
|
572
606
|
}
|
|
@@ -652,6 +686,8 @@ export class StackStore {
|
|
|
652
686
|
`,
|
|
653
687
|
);
|
|
654
688
|
await fs.rename(temp, this.#file);
|
|
655
|
-
|
|
689
|
+
const written = await fs.stat(this.#file);
|
|
690
|
+
this.#mtime = written.mtimeMs;
|
|
691
|
+
this.#size = written.size;
|
|
656
692
|
}
|
|
657
693
|
}
|
package/src/web/index.html
CHANGED
|
@@ -1904,6 +1904,24 @@
|
|
|
1904
1904
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c],
|
|
1905
1905
|
);
|
|
1906
1906
|
|
|
1907
|
+
/**
|
|
1908
|
+
* One end of a mask range, for the box that shows it.
|
|
1909
|
+
*
|
|
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.
|
|
1914
|
+
* @param {*} range - What the recipe says.
|
|
1915
|
+
* @param {number} which - 0 for the low end, 1 for the high.
|
|
1916
|
+
* @returns {string} - What to put in the box.
|
|
1917
|
+
*/
|
|
1918
|
+
const rangeEdge = (range, which) => {
|
|
1919
|
+
if (!Array.isArray(range) || range.length === 0) return '';
|
|
1920
|
+
const first = Array.isArray(range[0]) ? range[0] : range;
|
|
1921
|
+
const edge = first[which];
|
|
1922
|
+
return Number.isFinite(Number(edge)) ? String(edge) : '';
|
|
1923
|
+
};
|
|
1924
|
+
|
|
1907
1925
|
/**
|
|
1908
1926
|
* Redraws the open detail pane, when it is one that changes by itself.
|
|
1909
1927
|
*
|
|
@@ -7618,6 +7636,25 @@ Every piece is hashed against the ` +
|
|
|
7618
7636
|
<input style="width:12rem" placeholder="-10000, 0, -1"
|
|
7619
7637
|
data-stack-field="maskValues" data-stack-index="${index}"
|
|
7620
7638
|
value="${escapeHtml((source.maskValues ?? []).join(', '))}" />
|
|
7639
|
+
</label>
|
|
7640
|
+
<label class="choice"
|
|
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
|
+
}
|
|
7621
7658
|
</label>`;
|
|
7622
7659
|
|
|
7623
7660
|
return `
|
|
@@ -7739,6 +7776,22 @@ Every piece is hashed against the ` +
|
|
|
7739
7776
|
.filter(Boolean);
|
|
7740
7777
|
} else if (field === 'required') {
|
|
7741
7778
|
source.required = event.target.checked;
|
|
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
|
+
}
|
|
7742
7795
|
} else if (field === 'feather') {
|
|
7743
7796
|
const pixels = Number(event.target.value);
|
|
7744
7797
|
if (event.target.value === '' || !(pixels > 0)) delete source.feather;
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* What is actually in a terrain tile, in metres.
|
|
4
|
+
*
|
|
5
|
+
* A colour ramp and a hillshade disagree about what looks wrong, and both of
|
|
6
|
+
* them lie about scale: quantisation of a tenth of a metre dithers a ramp's
|
|
7
|
+
* band edge into a checkerboard, and a hillshade turns the same tenth of a
|
|
8
|
+
* metre into a field of bumps if the ground is flat enough. Neither tells you
|
|
9
|
+
* which you are looking at. This prints the numbers.
|
|
10
|
+
*
|
|
11
|
+
* node tools/terrain-probe.mjs <tile-url> [--encoding mapbox] [--interval 0.1]
|
|
12
|
+
*
|
|
13
|
+
* where <tile-url> is one tile, for instance
|
|
14
|
+
*
|
|
15
|
+
* http://127.0.0.1:8090/archives/<infohash>/10/512/380.webp
|
|
16
|
+
* http://127.0.0.1:8090/stacks/<id>/10/512/380.webp
|
|
17
|
+
*
|
|
18
|
+
* The two lines worth reading are the step histogram and the flat-ground
|
|
19
|
+
* roughness. If the steps are all one quantum, what you are seeing is the
|
|
20
|
+
* encoding and the renderer, not the data.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { loadCodec } from '../src/codec.js';
|
|
24
|
+
import { decodeHeights } from '../src/elevation.js';
|
|
25
|
+
|
|
26
|
+
const args = process.argv.slice(2);
|
|
27
|
+
const url = args.find((a) => !a.startsWith('--'));
|
|
28
|
+
if (!url) {
|
|
29
|
+
console.error(
|
|
30
|
+
'usage: node tools/terrain-probe.mjs <tile-url> [--encoding mapbox] [--interval 0.1] [--baseVal -10000]',
|
|
31
|
+
);
|
|
32
|
+
process.exit(2);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One named option off the command line.
|
|
37
|
+
* @param {string} name - Its name, without the dashes.
|
|
38
|
+
* @param {string} fallback - What it is when absent.
|
|
39
|
+
* @returns {string} - The value.
|
|
40
|
+
*/
|
|
41
|
+
const option = (name, fallback) => {
|
|
42
|
+
const at = args.indexOf(`--${name}`);
|
|
43
|
+
return at >= 0 && args[at + 1] ? args[at + 1] : fallback;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const source = {
|
|
47
|
+
encoding: option('encoding', 'mapbox'),
|
|
48
|
+
interval: Number(option('interval', '0.1')),
|
|
49
|
+
baseVal: Number(option('baseVal', '-10000')),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const codec = await loadCodec();
|
|
53
|
+
if (!codec) {
|
|
54
|
+
console.error('this needs sharp: npm install sharp');
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const response = await fetch(url);
|
|
59
|
+
if (!response.ok) {
|
|
60
|
+
console.error(`${url} answered ${response.status}`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
const raster = await codec.decode(Buffer.from(await response.arrayBuffer()), {
|
|
64
|
+
channels: 3,
|
|
65
|
+
});
|
|
66
|
+
const heights = decodeHeights(raster, source);
|
|
67
|
+
const { width } = raster;
|
|
68
|
+
// Off the URL, so roughness can be reported against the ground a pixel covers
|
|
69
|
+
// rather than in the abstract. A metre between neighbours means one thing at
|
|
70
|
+
// z8 and another at z16.
|
|
71
|
+
const coordinates = url.match(/\/(\d+)\/(\d+)\/(\d+)\.[a-z]+$/);
|
|
72
|
+
const zoom = Number(coordinates?.[1] ?? 0);
|
|
73
|
+
const tileRow = Number(coordinates?.[3] ?? 0);
|
|
74
|
+
|
|
75
|
+
// Walked rather than spread into Math.min: a 512px tile is 262,144 numbers and
|
|
76
|
+
// that many arguments overflows the call stack.
|
|
77
|
+
let low = Infinity;
|
|
78
|
+
let high = -Infinity;
|
|
79
|
+
let known = 0;
|
|
80
|
+
let zeroes = 0;
|
|
81
|
+
for (const value of heights) {
|
|
82
|
+
if (!Number.isFinite(value)) continue;
|
|
83
|
+
known += 1;
|
|
84
|
+
if (value === 0) zeroes += 1;
|
|
85
|
+
if (value < low) low = value;
|
|
86
|
+
if (value > high) high = value;
|
|
87
|
+
}
|
|
88
|
+
if (known === 0) {
|
|
89
|
+
console.log(`${url}
|
|
90
|
+
every pixel is nodata`);
|
|
91
|
+
process.exit(0);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Every step between neighbouring pixels, left to right and top to bottom.
|
|
95
|
+
const steps = [];
|
|
96
|
+
for (let row = 0; row < width; row += 1) {
|
|
97
|
+
for (let column = 1; column < width; column += 1) {
|
|
98
|
+
steps.push(
|
|
99
|
+
Math.abs(
|
|
100
|
+
heights[row * width + column] - heights[row * width + column - 1],
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (let row = 1; row < width; row += 1) {
|
|
106
|
+
for (let column = 0; column < width; column += 1) {
|
|
107
|
+
steps.push(
|
|
108
|
+
Math.abs(
|
|
109
|
+
heights[row * width + column] - heights[(row - 1) * width + column],
|
|
110
|
+
),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const quantum = source.encoding === 'terrarium' ? 1 / 256 : source.interval;
|
|
116
|
+
const inQuanta = steps.map((s) => Math.round(s / quantum));
|
|
117
|
+
const counts = new Map();
|
|
118
|
+
for (const q of inQuanta) counts.set(q, (counts.get(q) ?? 0) + 1);
|
|
119
|
+
|
|
120
|
+
console.log(`${url}`);
|
|
121
|
+
console.log(
|
|
122
|
+
` ${width}x${width}, ${source.encoding}, one quantum = ${quantum} m`,
|
|
123
|
+
);
|
|
124
|
+
console.log(` heights ${low.toFixed(2)} m to ${high.toFixed(2)} m`);
|
|
125
|
+
console.log(` exactly zero: ${zeroes} of ${known} pixels`);
|
|
126
|
+
|
|
127
|
+
console.log('\n step between neighbouring pixels:');
|
|
128
|
+
const ordered = [...counts.entries()].sort((a, b) => a[0] - b[0]).slice(0, 8);
|
|
129
|
+
for (const [q, n] of ordered) {
|
|
130
|
+
const share = n / steps.length;
|
|
131
|
+
console.log(
|
|
132
|
+
` ${String(q).padStart(4)} quanta (${(q * quantum).toFixed(2)} m) ` +
|
|
133
|
+
`${'#'.repeat(Math.round(share * 40)).padEnd(40)} ${(share * 100).toFixed(1)}%`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// A median cannot see sparse spikes, and sparse spikes are what stipple a
|
|
138
|
+
// hillshade: a scattering of pixels standing well clear of their neighbours,
|
|
139
|
+
// too few to move the middle of any distribution. So the tail is what gets
|
|
140
|
+
// reported, and separately the count of pixels that disagree with everything
|
|
141
|
+
// around them.
|
|
142
|
+
const flat = [];
|
|
143
|
+
let spikes = 0;
|
|
144
|
+
let worstSpike = 0;
|
|
145
|
+
for (let row = 1; row < width - 1; row += 1) {
|
|
146
|
+
for (let column = 1; column < width - 1; column += 1) {
|
|
147
|
+
const at = row * width + column;
|
|
148
|
+
const here = heights[at];
|
|
149
|
+
if (!Number.isFinite(here)) continue;
|
|
150
|
+
|
|
151
|
+
const across = heights[at + 1] - 2 * here + heights[at - 1];
|
|
152
|
+
const down = heights[at + width] - 2 * here + heights[at - width];
|
|
153
|
+
if (Number.isFinite(across) && Number.isFinite(down)) {
|
|
154
|
+
flat.push(Math.abs(across) + Math.abs(down));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Standing clear of every neighbour, in the same direction. Terrain does
|
|
158
|
+
// not do this; a pixel that survived a mask its neighbours did not, or one
|
|
159
|
+
// the encoding placed a quantum out, does.
|
|
160
|
+
let above = 0;
|
|
161
|
+
let below = 0;
|
|
162
|
+
let seen = 0;
|
|
163
|
+
let nearest = Infinity;
|
|
164
|
+
for (const [dy, dx] of [
|
|
165
|
+
[-1, -1],
|
|
166
|
+
[-1, 0],
|
|
167
|
+
[-1, 1],
|
|
168
|
+
[0, -1],
|
|
169
|
+
[0, 1],
|
|
170
|
+
[1, -1],
|
|
171
|
+
[1, 0],
|
|
172
|
+
[1, 1],
|
|
173
|
+
]) {
|
|
174
|
+
const neighbour = heights[(row + dy) * width + column + dx];
|
|
175
|
+
if (!Number.isFinite(neighbour)) continue;
|
|
176
|
+
seen += 1;
|
|
177
|
+
if (here > neighbour) above += 1;
|
|
178
|
+
else if (here < neighbour) below += 1;
|
|
179
|
+
nearest = Math.min(nearest, Math.abs(here - neighbour));
|
|
180
|
+
}
|
|
181
|
+
if (seen === 8 && (above === 8 || below === 8) && nearest > quantum * 1.5) {
|
|
182
|
+
spikes += 1;
|
|
183
|
+
worstSpike = Math.max(worstSpike, nearest);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
flat.sort((one, two) => one - two);
|
|
189
|
+
/**
|
|
190
|
+
* A value at a percentile of the sorted roughness.
|
|
191
|
+
* @param {number} share - Where to look, 0 to 1.
|
|
192
|
+
* @returns {number} - Metres.
|
|
193
|
+
*/
|
|
194
|
+
const at = (share) =>
|
|
195
|
+
flat[Math.min(flat.length - 1, Math.floor(flat.length * share))] ?? 0;
|
|
196
|
+
|
|
197
|
+
// How much ground one pixel covers, so roughness can be read as a slope.
|
|
198
|
+
// Web mercator shrinks with latitude, and the tile's row is where that comes
|
|
199
|
+
// from -- a metre between neighbours means one thing at z8 and another at z16.
|
|
200
|
+
const rows = 2 ** zoom;
|
|
201
|
+
// Clamped: a row outside the pyramid would put the latitude past the pole and
|
|
202
|
+
// report no ground at all, which reads as a broken tile rather than a typo.
|
|
203
|
+
const row = Math.min(Math.max(tileRow, 0), rows - 1);
|
|
204
|
+
const latitude = Math.atan(Math.sinh(Math.PI * (1 - (2 * row) / rows)));
|
|
205
|
+
const groundMetres = (40075016.686 * Math.cos(latitude)) / rows / width;
|
|
206
|
+
|
|
207
|
+
console.log('\n roughness, as the second difference between neighbours:');
|
|
208
|
+
for (const [label, share] of [
|
|
209
|
+
['half of them are under', 0.5],
|
|
210
|
+
['nine in ten under', 0.9],
|
|
211
|
+
['ninety-nine in a hundred under', 0.99],
|
|
212
|
+
['the worst', 1],
|
|
213
|
+
]) {
|
|
214
|
+
console.log(
|
|
215
|
+
` ${label.padEnd(32)} ${at(share).toFixed(2).padStart(9)} m` +
|
|
216
|
+
` (${(at(share) / quantum).toFixed(0)} quanta)`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
console.log(
|
|
221
|
+
`\n relief across this tile: ${(high - low).toFixed(1)} m` +
|
|
222
|
+
`, about ${groundMetres.toFixed(0)} m of ground per pixel`,
|
|
223
|
+
);
|
|
224
|
+
console.log(
|
|
225
|
+
` pixels standing clear of all eight neighbours: ${spikes}` +
|
|
226
|
+
` (${((spikes / known) * 100).toFixed(2)}%)` +
|
|
227
|
+
(spikes ? `, worst ${worstSpike.toFixed(1)} m` : ''),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
console.log('');
|
|
231
|
+
if (spikes / known > 0.002 && worstSpike > quantum * 10) {
|
|
232
|
+
console.log(
|
|
233
|
+
' -> a scattering of pixels stands well clear of everything around it.\n' +
|
|
234
|
+
' That is what stipples a hillshade, and terrain does not do it. Look\n' +
|
|
235
|
+
' for something applied per pixel: a mask that matched some pixels and\n' +
|
|
236
|
+
' not their neighbours, or two sources meeting one pixel at a time.',
|
|
237
|
+
);
|
|
238
|
+
} else if (at(0.99) <= quantum * 2.5) {
|
|
239
|
+
console.log(
|
|
240
|
+
" -> smooth to the encoding's own resolution, in the tail as well as the\n" +
|
|
241
|
+
' middle. A checkerboard here is the colour ramp dithering across a\n' +
|
|
242
|
+
' band edge, and a hillshade exaggerating a tenth of a metre.',
|
|
243
|
+
);
|
|
244
|
+
} else {
|
|
245
|
+
console.log(
|
|
246
|
+
` -> rough, but evenly so, against ${(high - low).toFixed(0)} m of relief across\n` +
|
|
247
|
+
' the tile. That is what terrain looks like; compare a tile of flat\n' +
|
|
248
|
+
' ground before calling it noise.',
|
|
249
|
+
);
|
|
250
|
+
}
|