pmtiles-swarm 0.102.0 โ†’ 0.103.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,6 +7,47 @@
7
7
  ### ๐Ÿž Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.103.0
11
+ ### โœจ Features and improvements
12
+ - **Elevation at a coordinate.** `GET /stacks/<id>/elevation?lon=&lat=&zoom=` answers the height
13
+ under a point, and `POST` of `{"points": [...]}` answers up to a thousand of them in the order
14
+ asked. The same two under `/archives/<infohash>/` and `/latest/<category>/`, since an archive of
15
+ terrain is already heights.
16
+
17
+ The shape follows tileserver-gl's endpoint deliberately, so a client written against that keeps
18
+ working โ€” see [NOTICE.md](NOTICE.md). What differs is that `"elevation": null` is an answer: the
19
+ height is read from the `Float32Array` a stack merges, where a hole is `NaN`, so "there is no data
20
+ here" and "this is at sea level" stay different answers. Anything reading encoded pixels has to
21
+ invent a height for ground nothing covers, because every triple of bytes in a terrain tile is one.
22
+
23
+ Points are grouped by the tile they fall in and each tile is read once, so a track down one valley
24
+ costs the few merges its tiles are worth rather than one per point.
25
+
26
+ - **Contour endpoints describe themselves.** `GET /stacks/<id>/contours/tiles.json`, and the same
27
+ under `/archives/` and `/latest/`. The zoom range is the thresholds' rather than the source's: a
28
+ stack with ground from z0 draws its first line at z9, and a client told otherwise spends eight
29
+ zooms of requests on tiles that can only answer 404.
30
+
31
+ - **Hillshade and contours can be switched off in the preview.** Two boxes in the header, for the
32
+ layers that page actually drew. Not only cosmetic for the contours: MapLibre stops fetching from a
33
+ source once no visible layer draws from it, so unticking the box really does stop the nine merges
34
+ each contour tile costs. The contour link is gone from the console and the catalogue with it โ€”
35
+ a hidden layer never asks its source for a tile, so there was nothing left for a separate page to
36
+ save. `?contours=1` still works, now as the box's starting state, and the box keeps it current so
37
+ the lines survive the raw/terrain switch.
38
+
39
+ - **Terrain has its own document.** [docs/terrain.md](docs/terrain.md) covers the encodings,
40
+ contours and elevation across all three ways terrain is served. They were under tile stacks, which
41
+ stopped being true once the same endpoints answered for an archive and a category.
42
+
43
+ ### ๐Ÿž Bug fixes
44
+ - **An archive or category elevation reading ignored the archive's zoom range.** The summary
45
+ spells it `minZoom`/`maxZoom` and everything downstream reads the TileJSON spelling, so passing
46
+ the summary straight through left both undefined and every reading clamped to a hardcoded
47
+ 0-14. An archive stopping at z8 was read at z14, where it has no tile, and answered `null` for
48
+ ground it covers. Caught before release; the stack path was never affected, since it takes its
49
+ range from the resolved recipe.
50
+
10
51
  ## 0.102.0
11
52
  ### โœจ Features and improvements
12
53
  - **Hillshade and contours can be switched off in the preview.** Two boxes in the header, shown only
@@ -43,7 +84,7 @@
43
84
  Worth knowing what it costs: a contour tile is traced from its own tile **plus its eight
44
85
  neighbours**, because a line crossing an edge has to be traced from the ground on both sides or it
45
86
  will not meet the line next door. Roughly nine merged terrain tiles each. See
46
- [tile-stacks.md](docs/tile-stacks.md) โ€” "Contours from a stack".
87
+ [terrain.md](docs/terrain.md) โ€” "Contours".
47
88
 
48
89
  A **Contours** button sits beside Preview and Terrain on any stack that draws as terrain, and the
49
90
  preview draws the lines over the hillshade rather than instead of it โ€” a contour on its own says
package/NOTICE.md CHANGED
@@ -80,6 +80,30 @@ Mapbox's.
80
80
  No code is copied either way. `src/elevation.js` and `src/rgba.js` are JavaScript written against
81
81
  the same rules, and `docs/tile-stacks.md` records where those rules differ from the fork's and why.
82
82
 
83
+ ## TileServer GL โ€” BSD-2-Clause
84
+
85
+ > Copyright (c) 2023, MapTiler.com
86
+ > Copyright (c) 2016, Klokan Technologies GmbH
87
+ > https://github.com/maptiler/tileserver-gl
88
+
89
+ The elevation endpoints follow tileserver-gl's. `src/elevation-lookup.js` implements the same
90
+ Web Mercator projection as its `lonLatToTilePixel` โ€” including the `ยฑ0.9999` clamp on `sin(lat)`,
91
+ which is where it cuts a projection that sends the poles to infinity โ€” and the endpoints keep its
92
+ request and response shapes: a `GET` for one reading, a `POST` of `{points: [...]}` answered by a
93
+ plain array in the order asked, and `long`/`lat`/`elevation`/`z`/`x`/`y`/`pixelX`/`pixelY` in the
94
+ body. That is deliberate, so a client written against tileserver-gl keeps working.
95
+
96
+ Two things are ours and differ. The height is read from the `Float32Array` a stack merges rather
97
+ than from one archive's pixels, so masks, clips and layer priority all apply โ€” and a hole is
98
+ `NaN`, which is reported as `"elevation": null`. Anything reading encoded pixels has to return a
99
+ height for every coordinate, because every triple of bytes in a terrain tile is one; this can say
100
+ there is no data. Following from that, a point with no data answers `200` with a null elevation
101
+ rather than tileserver-gl's `204`, since the tile and pixel it looked at are still worth reporting
102
+ and a `204` has no body to put them in.
103
+
104
+ No code is copied: `src/elevation-lookup.js` is written against the same rules. See
105
+ [docs/terrain.md](docs/terrain.md) โ€” "Elevation at a point".
106
+
83
107
  ## qBittorrent โ€” GPL-2.0-or-later
84
108
 
85
109
  > https://github.com/qbittorrent/qBittorrent
package/README.md CHANGED
@@ -810,6 +810,9 @@ which the endpoint answers 501.
810
810
  | `POST` | `/api/stacks/:id/import` | Import a provider's list of PMTiles URLs as sources on this stack. An index with an `items` list, or a plain list of addresses; `dryRun` says what it would write without writing it |
811
811
  | `POST`, `DELETE` | `/api/stacks/:id/bake` | Run the stack over its sources and write the result as an archive, or stop one that is running. Answers as soon as the job starts; watch it on `/api/stacks` |
812
812
  | `GET` | `/stacks/:id/preview` | A map of a stack, for looking at it โ€” **public** |
813
+ | `GET` | `/stacks/:id/contours/{z}/{x}/{y}.pbf`, `/archives/:infoHash/contours/โ€ฆ`, `/latest/:category/contours/โ€ฆ` | Contour lines traced from merged heights, as a vector tile โ€” **public**. `?interval=` or `?thresholds=` overrides what the recipe names |
814
+ | `GET` | `/stacks/:id/contours/tiles.json`, and the same under `/archives/:infoHash/` and `/latest/:category/` | What the contour endpoint covers โ€” **public**. The zoom range is the thresholds', not the source's: a stack with ground from z0 draws its first line at z9 |
815
+ | `GET`, `POST` | `/stacks/:id/elevation`, `/archives/:infoHash/elevation`, `/latest/:category/elevation` | The height under a coordinate โ€” **public**. `GET ?lon=&lat=&zoom=` for one reading; `POST {"points": [...]}` for up to 1000, answered in the order asked. `"elevation": null` means no data there, which an encoded tile cannot express. Follows tileserver-gl's shape โ€” see [docs/terrain.md](docs/terrain.md#elevation-at-a-point) |
813
816
  | `GET` | `/stacks/:id/tiles.json` | TileJSON for a stack โ€” **public**. `maxzoom` is the maximum over its sources, not the minimum |
814
817
  | `GET` | `/stacks/:id/:size/:z/:x/:y.:ext` | The same tile at 256 or 512 px. A tile's coordinates are an extent rather than a pixel count, so this is the same ground on a finer or coarser grid |
815
818
  | `GET` | `/stacks/:id/:z/:x/:y.:ext` | One tile of a stack โ€” **public**. Answered by the topmost source holding it; `X-Stack-Sources` says which were asked and what each said |
@@ -0,0 +1,239 @@
1
+ # Terrain
2
+
3
+ Terrain is a raster tile whose pixels are a packed elevation rather than a
4
+ colour. `pmtiles-swarm` serves it three ways -- one archive, the newest build in
5
+ a category, or a stack merged from several -- and everything in this document
6
+ works the same across all three, because all three end up handing over the same
7
+ thing: metres in a `Float32Array`, with `NaN` where nothing covered the ground.
8
+
9
+ That last part is why these endpoints live here rather than being left to a
10
+ client. An encoded terrain tile cannot say "no data": every triple of bytes is a
11
+ height, so a reader has to invent one for ground nothing covers, and the usual
12
+ invention is the encoding's base -- which reads as -10000 m or as sea level
13
+ depending on which convention wrote the file. Decoding to heights first means a
14
+ hole stays a hole all the way to the answer.
15
+
16
+ - [Encodings](#encodings)
17
+ - [Contours](#contours)
18
+ - [Elevation at a point](#elevation-at-a-point)
19
+
20
+ For how a stack merges several sources into one surface -- layer priority,
21
+ masking, feathering, nested stacks -- see [tile-stacks.md](tile-stacks.md).
22
+
23
+ ## Encodings
24
+
25
+ Three, and an archive states which in its own metadata:
26
+
27
+ | Encoding | Height from a pixel | Base |
28
+ | ----------- | -------------------------------------------------------------- | --------------------------------------------- |
29
+ | `mapbox` | `-10000 + (R * 256 * 256 + G * 256 + B) * 0.1` | `#000000` is -10000 m; sea level is `#0186a0` |
30
+ | `terrarium` | `(R * 256 + G + B / 256) - 32768` | `#800000` is 0 m |
31
+ | `custom` | `R * redFactor + G * greenFactor + B * blueFactor - baseShift` | whatever the four factors say |
32
+
33
+ All three are the same formula: `encodingFactors` in `src/elevation.js` derives
34
+ the four factors for the named ones rather than branching on the name, so
35
+ `custom` is not a separate code path. Note that `baseShift` is **subtracted**,
36
+ following MapLibre's style-spec โ€” a `mapbox` config writing `baseVal: -10000` is
37
+ the same quantity spelled with the opposite sign.
38
+
39
+ A `custom` archive that does not carry all four factors is unreadable as
40
+ terrain by anything, this node included, and is drawn as an ordinary raster
41
+ instead -- the preview says so rather than showing a plausible-looking wrong
42
+ map.
43
+
44
+ The `#0186a0` above is worth keeping in mind when reading a stack recipe (only
45
+ a stack masks; an archive is read as written): a
46
+ `mapbox` source whose nodata was written as its base is black, so it masks with
47
+ `maskColors: ["#000000"]` or with `maskValues: [-10000]`, while one whose nodata
48
+ was written as zero metres masks with `maskValues: [0]`. Those are different
49
+ files and the same-looking recipe does different things to each.
50
+
51
+ ## Contours
52
+
53
+ GET /stacks/<id>/contours/{z}/{x}/{y}.pbf
54
+ GET /archives/<infohash>/contours/{z}/{x}/{y}.pbf
55
+ GET /latest/<category>/contours/{z}/{x}/{y}.pbf
56
+
57
+ A contour tile is traced from heights rather than from an encoded terrain tile,
58
+ and where those heights come from is the interesting part.
59
+
60
+ All three read through the same tracer. What differs is what supplies the
61
+ heights: a stack merges its sources, applying everything its recipe says, while
62
+ an archive and a category decode one file's pixels and nothing else โ€” no recipe
63
+ to resolve, no masks, no merge. **That difference decides how good the holes
64
+ are**, which is the next section.
65
+
66
+ A tool that reads archives has to answer "what is the elevation here?" for
67
+ ground no archive covers, and an encoded terrain tile has no way to say
68
+ "nothing" โ€” every triple of bytes is some height. So it invents one. A constant
69
+ beside real terrain is a **cliff**, and a cliff under a contour tracer comes out
70
+ as lines packed arbitrarily tight along the seam. Filling with `-10000` rather
71
+ than `0` only moves the cliff and makes it taller. That is not a fault in the
72
+ tool; it is the shape of its input.
73
+
74
+ Nothing here fills anything in. A tile that is absent is passed as `undefined`
75
+ and reads as no-data, and `maplibre-contour` already understands that:
76
+ `HeightTile.fromRawDem` maps anything invalid to `NaN`, the tracer skips it, and
77
+ `combineNeighbors` answers `NaN` for a missing neighbour rather than throwing.
78
+ So a line stops at the edge of the data instead of diving off a cliff.
79
+
80
+ How much that buys you depends on the source, and the two cases are worth
81
+ keeping apart:
82
+
83
+ - **A stack** merges to `NaN` wherever nothing covered the ground โ€” the same
84
+ property that lets one stack show through another โ€” so a void _inside_ a
85
+ source's coverage is a hole too, provided the recipe masks it. That is what
86
+ `maskValues` and `maskColors` are for, and for contours they are load-bearing
87
+ rather than cosmetic.
88
+ - **An archive or a category** is decoded as written. Every pixel of a tile that
89
+ exists is a finite height, including whatever the file used for nodata, so the
90
+ only hole available is a missing tile. An archive that void-filled its oceans
91
+ with its encoding base will draw contours across them, and there is no recipe
92
+ in the request to say otherwise. Point a stack at it if that matters.
93
+
94
+ Where you want contours to run on past the coast, put a base under them in a
95
+ stack โ€” the merge is what makes them continuous, and `featherMetres` is what
96
+ stops the seam between two sources becoming its own little cliff.
97
+
98
+ ### Nine tiles per tile
99
+
100
+ A contour crossing a tile edge has to be traced from the ground on both sides,
101
+ or it will not meet the line in the next tile. So a contour tile is drawn from
102
+ its own tile plus its eight neighbours: **nine terrain tiles each**, asked for
103
+ together rather than in turn.
104
+
105
+ What those nine cost is the difference between the two paths. Over a stack each
106
+ one is a full merge of every source, which is why a run of contour tiles is
107
+ expensive; over an archive it is nine reads and nine decodes.
108
+
109
+ Either way every one of those tiles is also wanted by a neighbouring contour
110
+ tile, so a cache in front of the heights is what decides whether a run of these
111
+ is affordable โ€” not an optimisation to add later. Baking deliberately bypasses
112
+ the merged-tile cache, which is right for terrain, where each tile is written
113
+ once and never wanted again. It is not right here.
114
+
115
+ ### How far apart the lines go
116
+
117
+ Per zoom, because one interval is wrong at both ends of a map: at z8 a 20 m
118
+ contour is a band of ink, and at z15 a 500 m one is a blank tile through most of
119
+ the world. A recipe says either a number, meaning that interval wherever
120
+ contours are drawn at all, or a table of zoom to intervals. A request says the
121
+ same through `?interval=` or `?thresholds=`, which is the only way to set them
122
+ for an archive or a category, since neither has a recipe to write them in.
123
+ Saying nothing gets a built-in table.
124
+
125
+ Contours are a view of terrain rather than a property of it โ€” the same ground is
126
+ wanted at 10 m on a walking map and 100 m on an atlas โ€” which is why the request
127
+ can always override what the recipe says.
128
+
129
+ A level may name more than one interval. `[100, 500]` draws a line every hundred
130
+ metres and marks every fifth, and each feature carries `level` โ€” how many of the
131
+ intervals its height divides by, so 500 outranks 100. A style reads that to draw
132
+ the major lines thicker and label only those, from one layer rather than two
133
+ passes. The convention is `maplibre-contour`'s, so a style written against its
134
+ tiles works against these.
135
+
136
+ A zoom the table skips reads as the entry above it: a table naming 12 and 14
137
+ means 12 and 13 share a setting. Below the shallowest entry nothing is drawn at
138
+ all, and that is checked _before_ the nine merges โ€” at z5 a tile is most of a
139
+ continent, and nine merges is an expensive way to answer nothing.
140
+
141
+ ### What the endpoint claims
142
+
143
+ GET /stacks/<id>/contours/tiles.json
144
+ GET /archives/<infohash>/contours/tiles.json
145
+ GET /latest/<category>/contours/tiles.json
146
+
147
+ The zoom range is the thresholds', not the source's. Terrain serving z0โ€“z16
148
+ draws no contours at z2, and a client told otherwise fetches empty tiles all the
149
+ way down. It is never deeper than there is ground for either: a contour traced
150
+ from an upscaled parent is the parent's line drawn twice as thick, not new
151
+ detail.
152
+
153
+ Worth pointing a source at rather than declaring a range by hand โ€” that is what
154
+ the preview does, and it is why the preview stops asking for tiles below the
155
+ zoom the first line is drawn at.
156
+
157
+ ### Why the encoding is written here
158
+
159
+ `maplibre-contour` has a vector tile encoder and does not export it, and
160
+ `vt-pbf` โ€” the obvious dependency โ€” is built against `pbf` 3 while this tree
161
+ resolves `pbf` 5, whose `Pbf` default export no longer exists. So `contour-mvt.js`
162
+ writes the tile directly against `pbf` 5. It is narrow enough to be worth
163
+ owning: one layer of line strings with two numeric properties, where a general
164
+ encoder carries polygons, points, mixed property types and many layers.
165
+
166
+ Heights are written in ascending order, so two runs over the same contours
167
+ produce the same bytes whatever order the tracer closed its fragments in โ€” which
168
+ is what lets an export be resumed and a tile be keyed by content. A tile no
169
+ contour crossed is no tile at all rather than an empty layer, which a client
170
+ would pay to fetch and draw nothing from.
171
+
172
+ `maplibre-contour` itself is loaded through `createRequire`: the published 0.1.0
173
+ declares no `import` condition, so `import 'maplibre-contour'` fails outright.
174
+ The fix is in upstream main and unreleased. A git dependency would push that
175
+ requirement onto everyone installing this package, so the `require` path is
176
+ taken instead, and becomes an ordinary import when a release carries the fix.
177
+
178
+ ## Elevation at a point
179
+
180
+ Contours answer "where is 500 m?" across a tile. The elevation endpoints answer
181
+ the other question โ€” "how high is _here_?" โ€” from the same heights, so whatever
182
+ is true of one is true of the other.
183
+
184
+ GET /stacks/<id>/elevation?lon=-3.1883&lat=55.9533&zoom=12
185
+ POST /stacks/<id>/elevation {"points": [{"lon": โ€ฆ, "lat": โ€ฆ, "zoom": โ€ฆ}]}
186
+
187
+ GET /archives/<infohash>/elevation?lon=โ€ฆ&lat=โ€ฆ&zoom=โ€ฆ
188
+ GET /latest/<category>/elevation?lon=โ€ฆ&lat=โ€ฆ&zoom=โ€ฆ
189
+
190
+ with `POST` on all three. A stack applies its recipe; an archive and a category
191
+ decode one file, since terrain is already heights and needs no recipe to read
192
+ one.
193
+
194
+ The shape follows tileserver-gl's endpoint, deliberately, so a client written
195
+ against that keeps working โ€” see [NOTICE.md](../NOTICE.md). A single reading
196
+ answers an object; a batch answers a plain array in the order asked.
197
+
198
+ {"long": -3.1883, "lat": 55.9533, "elevation": 78.2,
199
+ "z": 12, "x": 2010, "y": 1283, "pixelX": 91, "pixelY": 204}
200
+
201
+ **`null` is an answer**, and how often you get one depends on the source. It
202
+ means "no height here", never "zero". A client that treats the two as the same
203
+ will put a track at sea level down a valley it has no data for.
204
+
205
+ Over a stack a hole is anywhere the merge came out `NaN` โ€” outside every
206
+ source's coverage, or masked by the recipe. Over an archive or a category it is
207
+ a missing tile and nothing else: every pixel of a tile that exists decodes to a
208
+ finite number, including whatever the file wrote for nodata.
209
+
210
+ That much cannot survive an encoded tile at all. Every triple of bytes in
211
+ terrain-RGB is a height, so anything reading pixels has to invent one for ground
212
+ nothing covers, and the usual invention is the encoding's base โ€” which reads as
213
+ โˆ’10000 m or as sea level depending on which convention wrote the file. Reading
214
+ heights first is what keeps "there is no data here" and "this is at sea level"
215
+ different answers.
216
+
217
+ Two things are clamped rather than refused. The zoom is clamped into the range
218
+ the source declares: asking for z18 of terrain that stops at z12 answers from
219
+ z12, because past that there is no more detail, only a parent upscaled โ€” so the
220
+ `z` in the reply is not always the `z` in the request. And the pixel is clamped
221
+ to the tile, because a coordinate exactly on the far edge rounds to one past the
222
+ last pixel.
223
+
224
+ That range comes from the recipe's coverage for a stack and from the archive's
225
+ own header for the other two. Getting it from the wrong place is not a small
226
+ bug: read at a zoom the source has no tile at, an archive answers `null` for
227
+ ground it actually covers.
228
+
229
+ Points are grouped by the tile they land in and each tile is read once, so a
230
+ track of a thousand coordinates down one valley costs the handful of tiles it
231
+ crosses rather than a thousand reads. That grouping is why the batch endpoint is
232
+ worth having at all, and why it is capped at 1000 points โ€” over a stack each
233
+ distinct tile is a full merge, and a request should not be able to ask for
234
+ hundreds of them by accident.
235
+
236
+ The zoom matters more than it looks. It is not a level of detail to be picked
237
+ generously: it decides which tile is read, and which sources a stack draws from
238
+ can differ between zooms. The deepest zoom available is the default because that
239
+ is where the best data is.
@@ -63,7 +63,6 @@ of its parts.
63
63
  - [Syncing a stack to another node](#syncing-a-stack-to-another-node)
64
64
  - [What the offline merge got wrong](#what-the-offline-merge-got-wrong)
65
65
  - [The stack editor](#the-stack-editor)
66
- - [Contours from a stack](#contours-from-a-stack)
67
66
  - [Merging vector sources](#merging-vector-sources)
68
67
  - [Staging](#staging)
69
68
  - [Open questions](#open-questions)
@@ -2321,94 +2320,6 @@ Debounced, and it should not follow the map past the stack's `maxzoom`: above
2321
2320
  that the client overzooms and there is nothing new to see, but the tiles are
2322
2321
  still requested and still composited.
2323
2322
 
2324
- ## Contours from a stack
2325
-
2326
- A contour tile is drawn from the heights a stack merges, not from an archive of
2327
- terrain. That distinction is the whole reason to do it here.
2328
-
2329
- A tool that reads archives has to answer "what is the elevation here?" for
2330
- ground no archive covers, and an encoded terrain tile has no way to say
2331
- "nothing" โ€” every triple of bytes is some height. So it invents one. A constant
2332
- beside real terrain is a **cliff**, and a cliff under a contour tracer comes out
2333
- as lines packed arbitrarily tight along the seam. Filling with `-10000` rather
2334
- than `0` only moves the cliff and makes it taller. That is not a fault in the
2335
- tool; it is the shape of its input.
2336
-
2337
- A stack has `NaN`, and `stackHeights` deliberately leaves its holes unfilled โ€”
2338
- the same property that lets one stack show through another. `maplibre-contour`
2339
- already understands it: `HeightTile.fromRawDem` maps anything invalid to `NaN`,
2340
- the tracer skips it, and `combineNeighbors` answers `NaN` for a neighbour that
2341
- is missing altogether rather than throwing. So a tile the stack covers none of
2342
- is passed as `undefined` and reads as no-data. Nothing is invented anywhere, and
2343
- a line stops at the edge of the data instead of diving off a cliff.
2344
-
2345
- Where you want contours to run on past the coast, put a base under them โ€” the
2346
- merge is what makes them continuous, and `featherMetres` is what stops the seam
2347
- between two sources becoming its own little cliff. For contours that feather is
2348
- load-bearing rather than cosmetic.
2349
-
2350
- ### Nine tiles per tile
2351
-
2352
- A contour crossing a tile edge has to be traced from the ground on both sides,
2353
- or it will not meet the line in the next tile. So a contour tile is drawn from
2354
- its own tile plus its eight neighbours: **roughly nine merged terrain tiles
2355
- each**, asked for together rather than in turn.
2356
-
2357
- Every one of those is a tile some neighbouring contour tile also wants, so a
2358
- cache in front of the merge is what decides whether a run of these is affordable
2359
- โ€” not an optimisation to add later. Baking deliberately bypasses the merged-tile
2360
- cache, which is right for terrain, where each tile is written once and never
2361
- wanted again. It is not right here.
2362
-
2363
- ### How far apart the lines go
2364
-
2365
- Per zoom, because one interval is wrong at both ends of a map: at z8 a 20 m
2366
- contour is a band of ink, and at z15 a 500 m one is a blank tile through most of
2367
- the world. A recipe says either a number, meaning that interval wherever
2368
- contours are drawn at all, or a table of zoom to intervals. Saying nothing gets
2369
- a built-in table.
2370
-
2371
- A level may name more than one interval. `[100, 500]` draws a line every hundred
2372
- metres and marks every fifth, and each feature carries `level` โ€” how many of the
2373
- intervals its height divides by, so 500 outranks 100. A style reads that to draw
2374
- the major lines thicker and label only those, from one layer rather than two
2375
- passes. The convention is `maplibre-contour`'s, so a style written against its
2376
- tiles works against these.
2377
-
2378
- A zoom the table skips reads as the entry above it: a table naming 12 and 14
2379
- means 12 and 13 share a setting. Below the shallowest entry nothing is drawn at
2380
- all, and that is checked _before_ the nine merges โ€” at z5 a tile is most of a
2381
- continent, and nine merges is an expensive way to answer nothing.
2382
-
2383
- ### What the endpoint claims
2384
-
2385
- The zoom range is the thresholds', not the stack's. A stack serving z0โ€“z16 draws
2386
- no contours at z2, and a client told otherwise fetches empty tiles all the way
2387
- down. It is never deeper than the stack has ground for either: a contour traced
2388
- from an upscaled parent is the parent's line drawn twice as thick, not new
2389
- detail.
2390
-
2391
- ### Why the encoding is written here
2392
-
2393
- `maplibre-contour` has a vector tile encoder and does not export it, and
2394
- `vt-pbf` โ€” the obvious dependency โ€” is built against `pbf` 3 while this tree
2395
- resolves `pbf` 5, whose `Pbf` default export no longer exists. So `contour-mvt.js`
2396
- writes the tile directly against `pbf` 5. It is narrow enough to be worth
2397
- owning: one layer of line strings with two numeric properties, where a general
2398
- encoder carries polygons, points, mixed property types and many layers.
2399
-
2400
- Heights are written in ascending order, so two runs over the same contours
2401
- produce the same bytes whatever order the tracer closed its fragments in โ€” which
2402
- is what lets an export be resumed and a tile be keyed by content. A tile no
2403
- contour crossed is no tile at all rather than an empty layer, which a client
2404
- would pay to fetch and draw nothing from.
2405
-
2406
- `maplibre-contour` itself is loaded through `createRequire`: the published 0.1.0
2407
- declares no `import` condition, so `import 'maplibre-contour'` fails outright.
2408
- The fix is in upstream main and unreleased. A git dependency would push that
2409
- requirement onto everyone installing this package, so the `require` path is
2410
- taken instead, and becomes an ordinary import when a release carries the fix.
2411
-
2412
2323
  ## Merging vector sources
2413
2324
 
2414
2325
  **Not built.** This is the design, written down before anything is started so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.102.0",
3
+ "version": "0.103.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
@@ -59,11 +59,13 @@ import {
59
59
  import { bakeRevision } from './bake.js';
60
60
  import zlib from 'node:zlib';
61
61
  import {
62
+ contourCoverage,
62
63
  contourTile,
63
64
  heightsFromArchive,
64
65
  heightsFromStack,
65
66
  } from './contour-tile.js';
66
67
  import { contourProblems } from './contour-options.js';
68
+ import { elevationsAt, pointProblems } from './elevation-lookup.js';
67
69
 
68
70
  const here = path.dirname(fileURLToPath(import.meta.url));
69
71
 
@@ -4175,7 +4177,7 @@ export function createApp({
4175
4177
  * nine merges a tile rather than one. Sharing a route would mean one set of
4176
4178
  * headers describing two things.
4177
4179
  *
4178
- * See docs/tile-stacks.md -- "Contours from a stack".
4180
+ * See docs/terrain.md -- "Contours".
4179
4181
  */
4180
4182
  /**
4181
4183
  * Answers one contour tile, for whatever produced the resolution.
@@ -4288,7 +4290,7 @@ export function createApp({
4288
4290
  * nine merges a tile rather than one. Sharing a route would mean one set of
4289
4291
  * headers describing two things.
4290
4292
  *
4291
- * See docs/tile-stacks.md -- "Contours from a stack".
4293
+ * See docs/terrain.md -- "Contours".
4292
4294
  */
4293
4295
  const serveStackContours = route(async (req, res) => {
4294
4296
  await stacks?.refresh();
@@ -4362,6 +4364,265 @@ export function createApp({
4362
4364
  });
4363
4365
  });
4364
4366
 
4367
+ /**
4368
+ * The height under a coordinate, from whatever provides the heights.
4369
+ *
4370
+ * The same `heightsAt` the contour routes are built on, for the same reason:
4371
+ * a stack's merged `Float32Array` carries `NaN` where nothing covered the
4372
+ * ground, so this can answer "no data" rather than inventing a height the
4373
+ * way anything reading encoded pixels has to.
4374
+ * @param {object} req - The request.
4375
+ * @param {object} res - The response.
4376
+ * @param {object} options - `points`, `coverage` and `heightsAt`.
4377
+ * @returns {Promise<void>}
4378
+ */
4379
+ const answerElevation = async (req, res, options) => {
4380
+ const codec = await loadCodec();
4381
+ if (!codec) {
4382
+ return res.status(501).json({
4383
+ error:
4384
+ 'reading a height means decoding pixels, and this node has no codec',
4385
+ hint: 'npm install sharp',
4386
+ });
4387
+ }
4388
+
4389
+ const wrong = pointProblems(options.points);
4390
+ if (wrong.length) return res.status(400).json({ error: wrong.join('; ') });
4391
+
4392
+ const controller = new AbortController();
4393
+ res.on('close', () => {
4394
+ if (!res.writableEnded) controller.abort();
4395
+ });
4396
+
4397
+ let found;
4398
+ try {
4399
+ found = await elevationsAt({
4400
+ heightsAt: options.heightsAt(codec, controller.signal),
4401
+ points: options.points,
4402
+ minzoom: options.coverage.minzoom ?? 0,
4403
+ maxzoom: options.coverage.maxzoom ?? 14,
4404
+ signal: controller.signal,
4405
+ });
4406
+ } catch (error) {
4407
+ if (error?.name === 'AbortError') return;
4408
+ throw error;
4409
+ }
4410
+
4411
+ res.setHeader('access-control-allow-origin', '*');
4412
+ // Short-lived rather than immutable even for pinned content: this is a
4413
+ // reading rather than a tile, and it is cheap to take again.
4414
+ res.setHeader('cache-control', 'public, max-age=300');
4415
+ return res.json(elevationBody(req, found));
4416
+ };
4417
+
4418
+ /**
4419
+ * The points one request is asking about.
4420
+ *
4421
+ * Two shapes, because there are two callers. A batch POSTs `{points: [...]}`
4422
+ * -- a track, a route, a set of markers. A single reading is a GET with the
4423
+ * coordinate in the query, which is what a browser's address bar and `curl`
4424
+ * can both produce.
4425
+ * @param {object} req - The request.
4426
+ * @returns {unknown} - Whatever was asked for, for `pointProblems` to judge.
4427
+ */
4428
+ const pointsOf = (req) => {
4429
+ if (req.method === 'POST') return req.body?.points;
4430
+ const { lon, long, lat, zoom, z } = req.query;
4431
+ return [{ lon: lon ?? long, lat, zoom: zoom ?? z }];
4432
+ };
4433
+
4434
+ /**
4435
+ * A single reading answers one object; a batch answers an array.
4436
+ *
4437
+ * The batch keeps tileserver-gl's shape, which is a plain array in the order
4438
+ * asked -- see NOTICE.md. A GET for one point answering a one-element array
4439
+ * would make every caller unwrap it.
4440
+ * @param {object} req - The request.
4441
+ * @param {object[]} found - What was read.
4442
+ * @returns {object|object[]} - The body to send.
4443
+ */
4444
+ const elevationBody = (req, found) =>
4445
+ req.method === 'POST' ? found : found[0];
4446
+
4447
+ /**
4448
+ * What a contour endpoint covers, as a TileJSON.
4449
+ *
4450
+ * Worth serving rather than leaving a client to guess, because the zoom
4451
+ * range is the thresholds' and not the source's: a stack with ground from z0
4452
+ * draws its first line at z9 under the default table. A client told the
4453
+ * source's range instead fetches nothing but 404s all the way down, and a
4454
+ * map showing only contours looks broken rather than empty.
4455
+ * @param {object} where - `root` for the URL, `name`, `coverage`, and the
4456
+ * `thresholds` that decide which zooms are drawn.
4457
+ * @returns {object} - A TileJSON document.
4458
+ */
4459
+ const contourTileJson = ({ root, name, coverage, thresholds }) => {
4460
+ const drawn = contourCoverage(coverage, thresholds);
4461
+ return {
4462
+ tilejson: '3.0.0',
4463
+ scheme: 'xyz',
4464
+ tiles: [`${root}/contours/{z}/{x}/{y}.pbf`],
4465
+ name,
4466
+ minzoom: drawn.minzoom,
4467
+ maxzoom: drawn.maxzoom,
4468
+ bounds: drawn.bounds,
4469
+ // Named here because a vector source is unusable without them: a style
4470
+ // needs the `source-layer`, and `level` is what draws the major lines
4471
+ // thicker without a second layer.
4472
+ vector_layers: [
4473
+ {
4474
+ id: 'contours',
4475
+ fields: { ele: 'Number', level: 'Number' },
4476
+ },
4477
+ ],
4478
+ };
4479
+ };
4480
+
4481
+ app.get(
4482
+ '/stacks/:id/contours/tiles.json',
4483
+ route(async (req, res) => {
4484
+ await stacks?.refresh();
4485
+ const resolved = stackOr404(req, res);
4486
+ if (!resolved) return;
4487
+ if (resolved.stack.space === 'rgba') {
4488
+ return res.status(400).json({
4489
+ error: 'this stack is imagery, and a colour has no contours to trace',
4490
+ });
4491
+ }
4492
+ res.setHeader('access-control-allow-origin', '*');
4493
+ return res.json(
4494
+ contourTileJson({
4495
+ root: `${baseUrl(req)}/stacks/${encodeURIComponent(req.params.id)}`,
4496
+ name: `${resolved.stack.title ?? resolved.stack.id} contours`,
4497
+ coverage: stackCoverage(resolved),
4498
+ thresholds: contourThresholdsFor(resolved.stack, req.query),
4499
+ }),
4500
+ );
4501
+ }),
4502
+ );
4503
+
4504
+ app.get(
4505
+ '/archives/:infoHash/contours/tiles.json',
4506
+ route(async (req, res) => {
4507
+ const entry = catalog.get(req.params.infoHash);
4508
+ if (!entry) return res.status(404).json({ error: 'unknown archive' });
4509
+ if (!tracesAsTerrain(entry)) {
4510
+ return res.status(400).json({
4511
+ error: 'this archive is not terrain, so it has no heights to trace',
4512
+ });
4513
+ }
4514
+ const doc = buildTileJson(entry, baseUrl(req));
4515
+ res.setHeader('access-control-allow-origin', '*');
4516
+ return res.json(
4517
+ contourTileJson({
4518
+ root: `${baseUrl(req)}/archives/${encodeURIComponent(entry.infoHash)}`,
4519
+ name: `${doc.name ?? entry.name} contours`,
4520
+ coverage: doc,
4521
+ thresholds: contourThresholdsFor({}, req.query),
4522
+ }),
4523
+ );
4524
+ }),
4525
+ );
4526
+
4527
+ app.get(
4528
+ '/latest/:category/contours/tiles.json',
4529
+ route(async (req, res) => {
4530
+ const entry = newestIn(req.params.category, req);
4531
+ if (!entry) return res.status(404).json({ error: 'no such category' });
4532
+ if (!tracesAsTerrain(entry)) {
4533
+ return res.status(400).json({
4534
+ error: 'this archive is not terrain, so it has no heights to trace',
4535
+ });
4536
+ }
4537
+ const doc = buildTileJson(entry, baseUrl(req));
4538
+ res.setHeader('access-control-allow-origin', '*');
4539
+ return res.json(
4540
+ contourTileJson({
4541
+ root: `${baseUrl(req)}/latest/${encodeURIComponent(req.params.category)}`,
4542
+ name: `${req.params.category} contours`,
4543
+ coverage: doc,
4544
+ thresholds: contourThresholdsFor({}, req.query),
4545
+ }),
4546
+ );
4547
+ }),
4548
+ );
4549
+
4550
+ const serveStackElevation = route(async (req, res) => {
4551
+ await stacks?.refresh();
4552
+ const resolved = stackOr404(req, res);
4553
+ if (!resolved) return;
4554
+ if (resolved.stack.space === 'rgba') {
4555
+ return res.status(400).json({
4556
+ error: 'this stack is imagery, and a colour has no height to read',
4557
+ });
4558
+ }
4559
+ const points = pointsOf(req);
4560
+ return answerElevation(req, res, {
4561
+ points,
4562
+ coverage: stackCoverage(resolved),
4563
+ heightsAt: (codec, signal) =>
4564
+ heightsFromStack({
4565
+ resolved,
4566
+ tiles,
4567
+ codec,
4568
+ cutlines,
4569
+ signal,
4570
+ heightsCache,
4571
+ }),
4572
+ });
4573
+ });
4574
+
4575
+ const serveArchiveElevation = route(async (req, res) => {
4576
+ const entry = catalog.get(req.params.infoHash);
4577
+ if (!entry) return res.status(404).json({ error: 'unknown archive' });
4578
+ if (!tracesAsTerrain(entry)) {
4579
+ return res.status(400).json({
4580
+ error: 'this archive is not terrain, so it has no heights to read',
4581
+ });
4582
+ }
4583
+ return answerElevation(req, res, {
4584
+ points: pointsOf(req),
4585
+ // The summary spells these `minZoom`/`maxZoom`; everything downstream
4586
+ // reads the TileJSON spelling. Passed straight through they were both
4587
+ // undefined, so every reading clamped to 0-14 whatever the archive held.
4588
+ coverage: {
4589
+ minzoom: entry.pmtiles?.minZoom,
4590
+ maxzoom: entry.pmtiles?.maxZoom,
4591
+ },
4592
+ heightsAt: (codec, signal) =>
4593
+ heightsFromArchive({ entry, tiles, codec, signal, heightsCache }),
4594
+ });
4595
+ });
4596
+
4597
+ const serveCategoryElevation = route(async (req, res) => {
4598
+ const entry = newestIn(req.params.category, req);
4599
+ if (!entry) return res.status(404).json({ error: 'no such category' });
4600
+ if (!tracesAsTerrain(entry)) {
4601
+ return res.status(400).json({
4602
+ error: 'this archive is not terrain, so it has no heights to read',
4603
+ });
4604
+ }
4605
+ return answerElevation(req, res, {
4606
+ points: pointsOf(req),
4607
+ // The summary spells these `minZoom`/`maxZoom`; everything downstream
4608
+ // reads the TileJSON spelling. Passed straight through they were both
4609
+ // undefined, so every reading clamped to 0-14 whatever the archive held.
4610
+ coverage: {
4611
+ minzoom: entry.pmtiles?.minZoom,
4612
+ maxzoom: entry.pmtiles?.maxZoom,
4613
+ },
4614
+ heightsAt: (codec, signal) =>
4615
+ heightsFromArchive({ entry, tiles, codec, signal, heightsCache }),
4616
+ });
4617
+ });
4618
+
4619
+ app.get('/stacks/:id/elevation', serveStackElevation);
4620
+ app.post('/stacks/:id/elevation', serveStackElevation);
4621
+ app.get('/archives/:infoHash/elevation', serveArchiveElevation);
4622
+ app.post('/archives/:infoHash/elevation', serveArchiveElevation);
4623
+ app.get('/latest/:category/elevation', serveCategoryElevation);
4624
+ app.post('/latest/:category/elevation', serveCategoryElevation);
4625
+
4365
4626
  /**
4366
4627
  * The three ways in. A contour tile is a vector tile whatever produced it,
4367
4628
  * so the extension is checked once and in one place.
@@ -26,7 +26,7 @@ import { stackHeights } from './stack-tile.js';
26
26
  * anywhere: where the stack has ground there are contours, and where it has a
27
27
  * hole the line simply stops.
28
28
  *
29
- * See docs/tile-stacks.md -- "Contours from a stack".
29
+ * See docs/terrain.md -- "Contours".
30
30
  */
31
31
 
32
32
  const require = createRequire(import.meta.url);
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Reading one height out of terrain, at a coordinate rather than a tile.
3
+ *
4
+ * The projection maths follows tileserver-gl's `lonLatToTilePixel` -- see
5
+ * NOTICE.md. What is different here is the answer for ground nothing covers.
6
+ * A server reading encoded tiles has to return something for every pixel,
7
+ * because every triple of bytes in a terrain tile is a height; this reads the
8
+ * merged `Float32Array` a stack produces, where a hole is `NaN`, so "there is
9
+ * no data here" is a thing it can actually say.
10
+ *
11
+ * See docs/terrain.md -- "Elevation at a point".
12
+ */
13
+
14
+ /** More than one request should turn into, since each unique tile is a merge. */
15
+ export const MAX_POINTS = 1000;
16
+
17
+ /**
18
+ * Where a coordinate falls on the tile grid at one zoom.
19
+ *
20
+ * Returned as a tile plus a fraction across it rather than as a pixel, because
21
+ * the pixel depends on the tile's width and that is not known until the tile
22
+ * has been read -- a stack serves whatever size its recipe says, and an
23
+ * archive whatever size it was written at.
24
+ * @param {number} lon - Longitude in degrees.
25
+ * @param {number} lat - Latitude in degrees.
26
+ * @param {number} zoom - The zoom to land on.
27
+ * @returns {object} - `{tileX, tileY, fracX, fracY}`.
28
+ */
29
+ export function tilePixelFor(lon, lat, zoom) {
30
+ // Limits latitude to 89.189, about a third of a tile past the edge of the
31
+ // world tile. Web Mercator sends the poles to infinity, so there has to be a
32
+ // cut somewhere and this is where tileserver-gl puts it.
33
+ const siny = Math.min(
34
+ Math.max(Math.sin((lat * Math.PI) / 180), -0.9999),
35
+ 0.9999,
36
+ );
37
+
38
+ const xWorld = 0.5 + lon / 360;
39
+ const yWorld = 0.5 - Math.log((1 + siny) / (1 - siny)) / (4 * Math.PI);
40
+
41
+ const scale = 2 ** zoom;
42
+ const fx = xWorld * scale;
43
+ const fy = yWorld * scale;
44
+ const tileX = Math.floor(fx);
45
+ const tileY = Math.floor(fy);
46
+
47
+ return { tileX, tileY, fracX: fx - tileX, fracY: fy - tileY };
48
+ }
49
+
50
+ /**
51
+ * The pixel a fraction across a tile lands on.
52
+ * @param {number} fraction - Where across the tile, 0 to 1.
53
+ * @param {number} size - The tile's width in pixels.
54
+ * @returns {number} - A pixel index inside the tile.
55
+ */
56
+ function pixelIn(fraction, size) {
57
+ // Clamped because a fraction of exactly 1 is reachable through rounding at
58
+ // the far edge, and `size` is one past the last pixel.
59
+ return Math.min(size - 1, Math.max(0, Math.floor(fraction * size)));
60
+ }
61
+
62
+ /**
63
+ * What is wrong with the points asked for, if anything.
64
+ * @param {unknown} points - What the request carried.
65
+ * @returns {string[]} - Problems, empty when usable.
66
+ */
67
+ export function pointProblems(points) {
68
+ if (!Array.isArray(points) || points.length === 0) {
69
+ return ['points must be a non-empty array of {lon, lat, zoom}'];
70
+ }
71
+ if (points.length > MAX_POINTS) {
72
+ return [`no more than ${MAX_POINTS} points in one request`];
73
+ }
74
+
75
+ const wrong = [];
76
+ points.forEach((point, at) => {
77
+ const lon = Number(point?.lon ?? point?.long);
78
+ const lat = Number(point?.lat);
79
+ if (!Number.isFinite(lon) || lon < -180 || lon > 180) {
80
+ wrong.push(`point ${at}: lon must be between -180 and 180`);
81
+ }
82
+ if (!Number.isFinite(lat) || lat < -90 || lat > 90) {
83
+ wrong.push(`point ${at}: lat must be between -90 and 90`);
84
+ }
85
+ const zoom = point?.zoom ?? point?.z;
86
+ if (zoom !== undefined && zoom !== null && zoom !== '') {
87
+ const level = Number(zoom);
88
+ if (!Number.isInteger(level) || level < 0 || level > 26) {
89
+ wrong.push(`point ${at}: zoom must be a whole number from 0 to 26`);
90
+ }
91
+ }
92
+ });
93
+ // Named all at once rather than one a request: fixing a batch of points one
94
+ // round trip at a time is the slowest way to find out about the second one.
95
+ return wrong;
96
+ }
97
+
98
+ /**
99
+ * The height under each point.
100
+ *
101
+ * Points are grouped by the tile they land in and each tile is read once, so a
102
+ * track of a thousand coordinates along a valley costs the handful of merges
103
+ * its tiles are worth rather than a thousand.
104
+ * @param {object} options - `heightsAt`, `points`, and the zoom range to clamp
105
+ * into. `signal` aborts the reads.
106
+ * @returns {Promise<object[]>} - One result per point, in the order asked.
107
+ */
108
+ export async function elevationsAt(options) {
109
+ const { heightsAt, points, minzoom = 0, maxzoom = 14, signal } = options;
110
+
111
+ const groups = new Map();
112
+ const asked = points.map((point, at) => {
113
+ const lon = Number(point.lon ?? point.long);
114
+ const lat = Number(point.lat);
115
+ // Clamped rather than refused: a client asking for z18 over a stack that
116
+ // stops at z12 wants the best height available there, not an error.
117
+ const wanted = Number(point.zoom ?? point.z ?? maxzoom);
118
+ const zoom = Math.min(Math.max(wanted, minzoom), maxzoom);
119
+
120
+ const { tileX, tileY, fracX, fracY } = tilePixelFor(lon, lat, zoom);
121
+ const key = `${zoom}/${tileX}/${tileY}`;
122
+ if (!groups.has(key)) groups.set(key, { zoom, tileX, tileY, wants: [] });
123
+ groups.get(key).wants.push({ at, fracX, fracY });
124
+ return { lon, lat, zoom, tileX, tileY };
125
+ });
126
+
127
+ const results = asked.map((one) => ({
128
+ long: one.lon,
129
+ lat: one.lat,
130
+ elevation: null,
131
+ z: one.zoom,
132
+ x: one.tileX,
133
+ y: one.tileY,
134
+ pixelX: null,
135
+ pixelY: null,
136
+ }));
137
+
138
+ for (const { zoom, tileX, tileY, wants } of groups.values()) {
139
+ signal?.throwIfAborted?.();
140
+ const tile = await heightsAt(zoom, tileX, tileY);
141
+ if (!tile?.data) continue;
142
+
143
+ const width = tile.width;
144
+ const height = tile.height ?? width;
145
+ for (const { at, fracX, fracY } of wants) {
146
+ const pixelX = pixelIn(fracX, width);
147
+ const pixelY = pixelIn(fracY, height);
148
+ const metres = tile.data[pixelY * width + pixelX];
149
+ results[at].pixelX = pixelX;
150
+ results[at].pixelY = pixelY;
151
+ // NaN is a hole the recipe left unfilled, which is not a height. Left as
152
+ // null so a caller can tell "no data here" from "sea level", which an
153
+ // encoded tile can never express.
154
+ results[at].elevation = Number.isFinite(metres) ? metres : null;
155
+ }
156
+ }
157
+
158
+ return results;
159
+ }
@@ -5368,13 +5368,10 @@ Every piece is hashed against the ` +
5368
5368
  const open = (href, text) =>
5369
5369
  `<a href="${href}" target="_blank" rel="noreferrer"><button type="button">${text}</button></a>`;
5370
5370
  if (!drawsAsTerrain(summary)) return open(base, label);
5371
- // Anything that draws as terrain can be traced, whether the heights
5372
- // are merged out of a recipe or read straight out of one archive.
5373
- return (
5374
- open(`${base}?raw=1`, label) +
5375
- open(base, 'Terrain') +
5376
- open(`${base}?contours=1`, 'Contours')
5377
- );
5371
+ // No contour button: the preview carries a checkbox for them, and a
5372
+ // hidden layer never asks its source for a tile, so there is nothing
5373
+ // to be saved by keeping them behind a page of their own.
5374
+ return open(`${base}?raw=1`, label) + open(base, 'Terrain');
5378
5375
  }
5379
5376
 
5380
5377
  const SETTINGS_GROUPS = [
@@ -160,13 +160,32 @@
160
160
  // a hole in the data -- a missing tile reads as flat, not as missing.
161
161
  const asked = new URLSearchParams(location.search);
162
162
  const raw = asked.has('raw');
163
- // Offered wherever there is terrain. The endpoint sits beside the
164
- // preview for all three -- a stack, one archive, and whichever build a
165
- // category points at -- so the source URL below is the same rewrite in
166
- // every case.
167
- const contours = asked.has('contours') && terrainReady;
163
+ // Built wherever there is terrain, and switched on by the checkbox in
164
+ // the header rather than by opening a different page. A hidden layer
165
+ // leaves its source unused -- `isHidden()` is true for any layer whose
166
+ // evaluated visibility is `none`, however it got that way -- so the
167
+ // layer costs nothing at all until it is ticked, which is what makes it
168
+ // a checkbox rather than a second link.
169
+ //
170
+ // The endpoint sits beside the preview for all three -- a stack, one
171
+ // archive, and whichever build a category points at -- so the rewrite
172
+ // below is the same in every case.
173
+ const contours = terrainReady;
174
+ const contoursOn = contours && asked.has('contours');
168
175
  const terrain = terrainReady && !raw;
169
176
 
177
+ // Fetched rather than assumed, because the zooms a stack draws lines at
178
+ // are its recipe's and not this page's to know. Failing is not fatal:
179
+ // the layer still works, it is only the range beside the box that goes.
180
+ let contourJson = null;
181
+ if (contours) {
182
+ contourJson = await fetch(
183
+ location.pathname.replace(/\/preview\/?$/, '/contours/tiles.json'),
184
+ )
185
+ .then((response) => (response.ok ? response.json() : null))
186
+ .catch(() => null);
187
+ }
188
+
170
189
  $('name').textContent = tilejson.name ?? tileJsonUrl;
171
190
  $('summary').textContent =
172
191
  `${vector ? 'vector' : 'raster'} ยท z${tilejson.minzoom ?? 0}โ€“${tilejson.maxzoom ?? 14}` +
@@ -255,22 +274,28 @@
255
274
  );
256
275
  style.sources.contours = {
257
276
  type: 'vector',
277
+ // The thresholds' range, not the stack's. A stack with ground from
278
+ // z0 draws its first line at z9 by default, and a source claiming z0
279
+ // asks for eight zooms of tiles that can only ever answer 404.
280
+ ...(contourJson
281
+ ? {
282
+ minzoom: contourJson.minzoom,
283
+ maxzoom: contourJson.maxzoom,
284
+ }
285
+ : {}),
258
286
  // Joined to the origin rather than run through `new URL()`, which
259
287
  // percent-encodes the braces: `{z}` becomes `%7Bz%7D`, MapLibre
260
288
  // never substitutes it, and every request asks the server for a tile
261
289
  // at the literal coordinates "{z}". `source` is already an absolute
262
290
  // path, so there is nothing to resolve.
263
291
  tiles: [location.origin + source],
264
- // The endpoint answers 404 above and below what its thresholds draw
265
- // at, which MapLibre reads as an empty tile rather than an error.
266
- minzoom: 0,
267
- maxzoom: 16,
268
292
  };
269
293
  style.layers.push({
270
294
  id: 'contours',
271
295
  type: 'line',
272
296
  source: 'contours',
273
297
  'source-layer': 'contours',
298
+ layout: { visibility: contoursOn ? 'visible' : 'none' },
274
299
  paint: {
275
300
  'line-color': 'rgba(255,255,255,0.45)',
276
301
  // `level` counts the intervals a height divides by, so the major
@@ -295,11 +320,17 @@
295
320
  // Assembled at click time so the hash, which is where the map keeps
296
321
  // the position it is currently showing, survives the reload.
297
322
  //
298
- // The contour flag rides along, because it is a thing drawn *over*
299
- // the terrain rather than a mode instead of it -- switching to raw
300
- // tiles and losing the lines would be answering a different question
301
- // than the one that was asked.
302
- const flags = [terrain ? 'raw=1' : '', contours ? 'contours=1' : '']
323
+ // Read off the address bar rather than off the value this page
324
+ // loaded with, because the contour box writes there as it is
325
+ // ticked. The lines are drawn *over* the terrain rather than being a
326
+ // mode instead of it, so switching to raw tiles and losing them
327
+ // would answer a different question than the one that was asked.
328
+ const flags = [
329
+ terrain ? 'raw=1' : '',
330
+ new URLSearchParams(location.search).has('contours')
331
+ ? 'contours=1'
332
+ : '',
333
+ ]
303
334
  .filter(Boolean)
304
335
  .join('&');
305
336
  location.href =
@@ -374,13 +405,15 @@
374
405
  * already on the map, one row up on the left.
375
406
  * @param {string} id - The layer to show and hide.
376
407
  * @param {string} label - What to call it.
408
+ * @param {object} [options] - `on` for the starting state, and `flag` to
409
+ * mirror that state into the query string.
377
410
  * @returns {void}
378
411
  */
379
- function layerToggle(id, label) {
412
+ function layerToggle(id, label, { on = true, flag = '' } = {}) {
380
413
  const box = document.createElement('label');
381
414
  const input = document.createElement('input');
382
415
  input.type = 'checkbox';
383
- input.checked = true;
416
+ input.checked = on;
384
417
  // `setLayoutProperty` calls `_checkLoaded()`, which throws before the
385
418
  // style has finished loading, so the box is not offered until it has.
386
419
  input.disabled = true;
@@ -393,6 +426,20 @@
393
426
  'visibility',
394
427
  input.checked ? 'visible' : 'none',
395
428
  );
429
+ if (!flag) return;
430
+ // Replaced rather than pushed: this is what the page is showing, not
431
+ // somewhere the back button should return to. It makes the state
432
+ // survive the raw/terrain switch, which reloads, and makes a preview
433
+ // with the lines on a link that can be sent to somebody.
434
+ const query = new URLSearchParams(location.search);
435
+ if (input.checked) query.set(flag, '1');
436
+ else query.delete(flag);
437
+ const search = query.toString();
438
+ history.replaceState(
439
+ null,
440
+ '',
441
+ location.pathname + (search ? `?${search}` : '') + location.hash,
442
+ );
396
443
  });
397
444
  box.append(input, document.createTextNode(label));
398
445
  $('layers').append(box);
@@ -401,7 +448,19 @@
401
448
  // Only for the layers this page actually put in the style. A checkbox
402
449
  // for a layer that is not there is a control that does nothing.
403
450
  if (terrain) layerToggle('hillshade', 'hillshade');
404
- if (contours) layerToggle('contours', 'contours');
451
+ if (contours) {
452
+ // The range is in the label because without it an empty map is
453
+ // indistinguishable from a broken one: contours start at z9 under the
454
+ // default thresholds, so a preview opened at z4 with the hillshade off
455
+ // draws nothing at all and says nothing about why.
456
+ const range = contourJson
457
+ ? ` z${contourJson.minzoom}โ€“${contourJson.maxzoom}`
458
+ : '';
459
+ layerToggle('contours', `contours${range}`, {
460
+ on: contoursOn,
461
+ flag: 'contours',
462
+ });
463
+ }
405
464
 
406
465
  if (vector) {
407
466
  // MapLibre's own inspect control, rather than something equivalent
@@ -464,7 +464,6 @@
464
464
  // hole, since a missing DEM tile hillshades as flat ground.
465
465
  add(`${root}/preview?raw=1`, 'preview');
466
466
  add(`${root}/preview`, 'terrain');
467
- add(`${root}/preview?contours=1`, 'contours');
468
467
  } else {
469
468
  add(`${root}/preview`, 'preview');
470
469
  }