pmtiles-swarm 0.62.0 β†’ 0.63.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
@@ -4,6 +4,32 @@
4
4
  ### ✨ Features and improvements
5
5
  - _...Add new stuff here..._
6
6
 
7
+ ### 🐞 Bug fixes
8
+ - _...Add new stuff here..._
9
+
10
+ ## 0.63.0
11
+ ### ✨ Features and improvements
12
+ - **A terrain archive previews as terrain.** An archive whose `encoding` is `terrarium`,
13
+ `mapbox`, or `custom` with all four factors present now opens as hillshade with 3D relief
14
+ rather than as the raster it literally is β€” a terrain-RGB image drawn as colour says nothing
15
+ about the ground. The pitch ceiling goes to 85Β°, since MapLibre's default of 60 is not enough
16
+ to look across a landscape, and terrain itself is a control rather than a setting, because
17
+ flat hillshade is easier to compare against a map than a perspective is.
18
+
19
+ No server change was needed. The TileJSON has carried `encoding` and the four custom factors
20
+ for a while, and the preview already fetches it β€” so this is the page reading what was
21
+ already there. Stacks preview through the same file, which means a terrain stack gets the
22
+ view from its own declared output encoding.
23
+
24
+ `mlt` is not terrain. It travels in the same field and is a vector format, so the check names
25
+ the three encodings it can draw rather than testing that an encoding is set.
26
+
27
+ The raw tiles stay one click away and the map position survives the switch, because a missing
28
+ DEM tile hillshades as flat ground rather than as missing β€” the only way to see a hole is to
29
+ look at the pixels. A `custom` archive carrying none of its factors is drawn as an ordinary
30
+ raster with a note saying why.
31
+
32
+
7
33
  ### 🐞 Bug fixes
8
34
  - _...Add new stuff here..._
9
35
 
@@ -748,6 +748,35 @@ first tile can be seconds away.
748
748
  **Raster archives get the raster.** There is nothing to inspect in an image, so
749
749
  the panel says so and the map is for checking coverage.
750
750
 
751
+ **Terrain archives get hillshade and 3D relief**, and open in it. An archive
752
+ whose [`encoding`](tilejson.md#encoding) is `terrarium`, `mapbox`, or `custom`
753
+ with all four factors present is drawn as a `raster-dem` source: a hillshade
754
+ layer over sea-coloured background, terrain switchable from the control beside
755
+ the compass, and the pitch ceiling raised to 85Β° β€” MapLibre's default of 60 is
756
+ not enough to see relief, and looking across a landscape rather than down at it
757
+ is the point of the view.
758
+
759
+ `mlt` is not terrain. It travels in the same `encoding` field and is a vector
760
+ format, which is why the check names the three it can draw rather than testing
761
+ that an encoding is set at all.
762
+
763
+ Terrain and hillshade get a source each over the same URL, which is what
764
+ MapLibre's own terrain example does and what tileserver-gl ships. The two ask a
765
+ DEM source for different things β€” one is sampled for height across the whole
766
+ viewport, the other is shaded per tile β€” and sharing one between them has a
767
+ history of rendering artefacts. The tiles are requested once either way, since
768
+ the HTTP cache answers the second source.
769
+
770
+ **The raw tiles stay one click away**, from the link in the header, and the map
771
+ position survives the switch. That view is not decorative: a missing DEM tile
772
+ hillshades as flat ground rather than as missing, so the only way to see a hole
773
+ is to look at the pixels. A `custom` archive carrying none of its four factors
774
+ is drawn as an ordinary raster with a note saying why β€” pixels whose meaning is
775
+ not stated cannot honestly be rendered as heights.
776
+
777
+ Stacks preview through the same page, so a terrain stack gets the terrain view
778
+ from its own declared output encoding. See [tile stacks](tile-stacks.md).
779
+
751
780
  **What it has actually served** is on the archive's detail, as `served`, and
752
781
  across the node at `GET /api/stats` β€” requests, bytes, a breakdown by zoom and
753
782
  status, and which client addresses asked. Worth reading beside `reading`: an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.62.0",
3
+ "version": "0.63.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",
@@ -36,7 +36,8 @@
36
36
  header h1 { font-size: 1rem; margin: 0; }
37
37
  header .sub { color: var(--muted); font-size: 0.8rem; }
38
38
  header code { font-size: 0.75rem; color: var(--muted); }
39
- header a { color: var(--accent); font-size: 0.8rem; margin-left: auto; }
39
+ header .links { margin-left: auto; display: flex; gap: 0.9rem; }
40
+ header a { color: var(--accent); font-size: 0.8rem; }
40
41
  #map { flex: 1; min-height: 0; }
41
42
  .banner {
42
43
  margin: auto;
@@ -66,7 +67,10 @@
66
67
  <h1 id="name">Loading…</h1>
67
68
  <span class="sub" id="summary"></span>
68
69
  <code id="endpoint"></code>
69
- <a href="/">← console</a>
70
+ <span class="links">
71
+ <a id="mode" href="#" hidden></a>
72
+ <a href="/">← console</a>
73
+ </span>
70
74
  </header>
71
75
  <div id="note" hidden></div>
72
76
  <div id="map"></div>
@@ -113,6 +117,26 @@
113
117
  const vector =
114
118
  vectorLayers.length > 0 || /\.(pbf|mvt)(\?|$)/.test(tilejson.tiles?.[0] ?? '');
115
119
 
120
+ // The encodings MapLibre can read as a raster-dem source. Named rather
121
+ // than tested as "encoding is set", because `mlt` travels in the same
122
+ // field and is a vector format -- treating it as terrain would render a
123
+ // hillshade of nothing.
124
+ const TERRAIN = ['terrarium', 'mapbox', 'custom'];
125
+ const FACTORS = ['redFactor', 'greenFactor', 'blueFactor', 'baseShift'];
126
+ // `custom` is unreadable without all four, so an archive that declares it
127
+ // and carries none is not terrain this page can draw.
128
+ const terrainReady =
129
+ !vector &&
130
+ TERRAIN.includes(tilejson.encoding) &&
131
+ (tilejson.encoding !== 'custom' ||
132
+ FACTORS.every((name) => Number.isFinite(tilejson[name])));
133
+ // Terrain by default where there is terrain: a terrain-RGB archive drawn
134
+ // as an ordinary raster is a psychedelic mess that says nothing about the
135
+ // ground. The raw tiles stay one click away, because they are what shows
136
+ // a hole in the data -- a missing tile reads as flat, not as missing.
137
+ const raw = new URLSearchParams(location.search).has('raw');
138
+ const terrain = terrainReady && !raw;
139
+
116
140
  $('name').textContent = tilejson.name ?? tileJsonUrl;
117
141
  $('summary').textContent =
118
142
  `${vector ? 'vector' : 'raster'} Β· z${tilejson.minzoom ?? 0}–${tilejson.maxzoom ?? 14}` +
@@ -120,24 +144,104 @@
120
144
  ? vectorLayers.length > 0
121
145
  ? ` Β· ${vectorLayers.length} layers`
122
146
  : ' Β· no layer list yet'
123
- : '');
147
+ : terrainReady
148
+ ? ` Β· ${tilejson.encoding} terrain`
149
+ : '');
124
150
  $('endpoint').textContent = tilejson.tiles?.[0] ?? '';
125
151
 
126
152
  // One source, described entirely by the archive's own TileJSON. That
127
153
  // endpoint is already a complete, valid source description, so there is
128
154
  // nothing to reconstruct here β€” which is most of the reason it is worth
129
155
  // having.
130
- const style = {
131
- version: 8,
132
- sources: { archive: { type: vector ? 'vector' : 'raster', url: tileJsonUrl } },
133
- layers: [
134
- { id: 'bg', type: 'background', paint: { 'background-color': '#12141a' } },
135
- ],
136
- };
137
- if (!vector) {
156
+ /**
157
+ * The archive as a DEM source, described by its own TileJSON.
158
+ * @returns {object} - A raster-dem source.
159
+ */
160
+ const demSource = () => ({
161
+ type: 'raster-dem',
162
+ url: tileJsonUrl,
163
+ encoding: tilejson.encoding,
164
+ ...(tilejson.encoding === 'custom'
165
+ ? Object.fromEntries(FACTORS.map((name) => [name, tilejson[name]]))
166
+ : {}),
167
+ });
168
+
169
+ const style = terrain
170
+ ? {
171
+ version: 8,
172
+ // Two sources over one URL, which is what MapLibre's own terrain
173
+ // example does and what tileserver-gl ships. Terrain and hillshade
174
+ // ask a DEM source for different things -- one is sampled for
175
+ // height across the whole viewport, the other is shaded per tile --
176
+ // and sharing one source between them has a history of rendering
177
+ // artefacts. The tiles are requested once either way: the HTTP
178
+ // cache answers the second source.
179
+ sources: { terrain: demSource(), hillshade: demSource() },
180
+ terrain: { source: 'terrain' },
181
+ layers: [
182
+ {
183
+ id: 'bg',
184
+ type: 'background',
185
+ // Sea, not page furniture. Where a terrain archive has no data
186
+ // it is usually because there is water there.
187
+ paint: { 'background-color': '#16323f' },
188
+ },
189
+ {
190
+ id: 'hillshade',
191
+ type: 'hillshade',
192
+ source: 'hillshade',
193
+ paint: {
194
+ 'hillshade-shadow-color': 'hsl(39, 21%, 33%)',
195
+ 'hillshade-illumination-direction': 315,
196
+ 'hillshade-exaggeration': 0.8,
197
+ },
198
+ },
199
+ ],
200
+ }
201
+ : {
202
+ version: 8,
203
+ sources: {
204
+ archive: { type: vector ? 'vector' : 'raster', url: tileJsonUrl },
205
+ },
206
+ layers: [
207
+ {
208
+ id: 'bg',
209
+ type: 'background',
210
+ paint: { 'background-color': '#12141a' },
211
+ },
212
+ ],
213
+ };
214
+ if (!vector && !terrain) {
138
215
  style.layers.push({ id: 'archive', type: 'raster', source: 'archive' });
139
216
  }
140
217
 
218
+ // Offered rather than assumed, both ways round. Reading heights off the
219
+ // raw pixels is the only way to see that a tile is present but wrong.
220
+ if (terrainReady) {
221
+ const toggle = $('mode');
222
+ toggle.hidden = false;
223
+ toggle.textContent = terrain ? 'raw tiles β†’' : 'terrain β†’';
224
+ toggle.addEventListener('click', (event) => {
225
+ event.preventDefault();
226
+ // Assembled at click time so the hash, which is where the map keeps
227
+ // the position it is currently showing, survives the reload.
228
+ location.href =
229
+ location.pathname + (terrain ? '?raw=1' : '') + location.hash;
230
+ });
231
+ }
232
+
233
+ // An archive that says `custom` and carries none of the four factors
234
+ // cannot be read as heights by anything, this page included.
235
+ if (!vector && tilejson.encoding === 'custom' && !terrainReady) {
236
+ $('note').hidden = false;
237
+ $('note').textContent =
238
+ 'This archive declares the custom terrain encoding but not the four ' +
239
+ 'factors it is unreadable without β€” redFactor, greenFactor, ' +
240
+ 'blueFactor and baseShift. It is drawn as an ordinary raster, which ' +
241
+ 'is the only honest thing to do with pixels whose meaning is not ' +
242
+ 'stated.';
243
+ }
244
+
141
245
  // An archive whose metadata has not been read yet cannot be drawn: a
142
246
  // vector style needs `source-layer` names, and nothing in a tile request
143
247
  // supplies them. Say so, rather than presenting a black rectangle.
@@ -157,11 +261,28 @@
157
261
  center: tilejson.center?.slice(0, 2) ?? [0, 0],
158
262
  zoom: tilejson.center?.[2] ?? 2,
159
263
  maxZoom: (tilejson.maxzoom ?? 14) + 2,
264
+ // 60 is the default and it is not enough to see relief: the whole
265
+ // point of the terrain view is looking across a landscape rather than
266
+ // down at it.
267
+ ...(terrain ? { maxPitch: 85 } : {}),
160
268
  hash: true,
161
269
  });
162
- map.addControl(new maplibregl.NavigationControl(), 'top-left');
270
+ map.addControl(
271
+ new maplibregl.NavigationControl({ visualizePitch: terrain }),
272
+ 'top-left',
273
+ );
163
274
  map.addControl(new maplibregl.ScaleControl(), 'bottom-left');
164
275
 
276
+ if (terrain) {
277
+ // Terrain off is a useful view of a DEM in its own right -- hillshade
278
+ // flat on the screen is easier to compare against a map than a
279
+ // perspective is -- so this stays a switch rather than being set once.
280
+ map.addControl(
281
+ new maplibregl.TerrainControl({ source: 'terrain' }),
282
+ 'top-left',
283
+ );
284
+ }
285
+
165
286
  if (vector) {
166
287
  // MapLibre's own inspect control, rather than something equivalent
167
288
  // written here: it is maintained alongside the renderer, so it keeps