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 +42 -1
- package/NOTICE.md +24 -0
- package/README.md +3 -0
- package/docs/terrain.md +239 -0
- package/docs/tile-stacks.md +0 -89
- package/package.json +1 -1
- package/src/api.js +263 -2
- package/src/contour-tile.js +1 -1
- package/src/elevation-lookup.js +159 -0
- package/src/web/index.html +4 -7
- package/src/web/preview.html +76 -17
- package/src/web/public.html +0 -1
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
|
-
[
|
|
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 |
|
package/docs/terrain.md
ADDED
|
@@ -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.
|
package/docs/tile-stacks.md
CHANGED
|
@@ -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.
|
|
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/
|
|
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/
|
|
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.
|
package/src/contour-tile.js
CHANGED
|
@@ -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/
|
|
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
|
+
}
|
package/src/web/index.html
CHANGED
|
@@ -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
|
-
//
|
|
5372
|
-
//
|
|
5373
|
-
|
|
5374
|
-
|
|
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 = [
|
package/src/web/preview.html
CHANGED
|
@@ -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
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
|
|
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
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
|
|
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 =
|
|
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)
|
|
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
|
package/src/web/public.html
CHANGED
|
@@ -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
|
}
|