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 CHANGED
@@ -7,8 +7,73 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
- ## 0.74.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.
@@ -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.74.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
- return job ? this.#describe(job) : null;
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 where the next run can pick it up.
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
- /** How far up the pyramid a merge will climb for a source with no tile here. */
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(recipe?.maskValues?.length || recipe?.maskColors?.length);
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 two masks the merge applies, asked of the parent. A ramp
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
- this.#mtime = (await fs.stat(this.#file)).mtimeMs;
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
- if (mtime === this.#mtime) return false;
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
- this.#mtime = (await fs.stat(this.#file)).mtimeMs;
689
+ const written = await fs.stat(this.#file);
690
+ this.#mtime = written.mtimeMs;
691
+ this.#size = written.size;
656
692
  }
657
693
  }
@@ -1904,6 +1904,24 @@
1904
1904
  ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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
+ }