pmtiles-swarm 0.91.0 → 0.92.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,27 @@
7
7
  ### šŸž Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.92.0
11
+ ### šŸž Bug fixes
12
+ - **A stack's TileJSON published the addresses its sources are read from.** A URL source is named by
13
+ its URL and an S3 source by its bucket and key, and the document naming them is served to anybody
14
+ who can load the map — while the archives at the other end are read with credentials nobody else
15
+ has. The sources are gone from it entirely, which was the right answer anyway: they are the
16
+ ingredients of one endpoint, not a list for a client to join, and listing them invited fetching
17
+ those instead of the tiles. What is left is a count and a `revision` fingerprint covering the
18
+ recipe and what every source resolved to — the one thing the list was good for, in twenty bytes
19
+ rather than tens of kilobytes. The document for a 459-source stack is now 336 bytes.
20
+
21
+ - **Signing in threw away the view you asked for.** A link to `#stacks` on a guarded node asked for a
22
+ password and then showed the archives, with the address still reading `#stacks` — which is what
23
+ made it look broken rather than like a redirect. Both ways into the console now land where the
24
+ address says.
25
+
26
+ - **An archive the engine has no record of was drawn as 0%.** That is a different fact from "none of
27
+ it is here", and it is the more alarming one to get wrong: a library the engine failed to take back
28
+ after a restart read as a library that had lost its data. The row now says **not loaded**, and says
29
+ where the reason is logged and that nothing on disk has been touched.
30
+
10
31
  ## 0.91.0
11
32
  ### ✨ Features and improvements
12
33
  - **A source at a URL can be added by hand, not only imported.** **Add source → an address you
@@ -60,6 +60,7 @@ of its parts.
60
60
  - [Syncing a stack to another node](#syncing-a-stack-to-another-node)
61
61
  - [What the offline merge got wrong](#what-the-offline-merge-got-wrong)
62
62
  - [The stack editor](#the-stack-editor)
63
+ - [Merging vector sources](#merging-vector-sources)
63
64
  - [Staging](#staging)
64
65
  - [Open questions](#open-questions)
65
66
 
@@ -541,21 +542,42 @@ Derived from the resolved sources rather than from any one archive:
541
542
  - `attribution` — every source's, concatenated.
542
543
  - `tiles` — `/stacks/<id>/{z}/{x}/{y}.<ext>`.
543
544
 
544
- Plus a non-standard `stack` block naming what it resolved to, mirroring the
545
- `latest` block on `/latest/<category>/tiles.json`, so a consumer can tell one
546
- resolution from the next:
545
+ Plus a non-standard `stack` block, mirroring the `latest` block on
546
+ `/latest/<category>/tiles.json`, so a consumer can tell one resolution from the
547
+ next:
547
548
 
548
549
  ```jsonc
549
550
  "stack": {
550
551
  "id": "planet-terrain",
551
- "revision": 7,
552
- "sources": [
553
- { "category": "gebco", "infohash": "a074186d…", "name": "GEBCO_2026_…" },
554
- { "category": "planet-bathymetry", "infohash": "…", "name": "…" }
555
- ]
552
+ "space": "elevation",
553
+ "sources": 2,
554
+ "revision": "9f2c1a77b3e04d16"
556
555
  }
557
556
  ```
558
557
 
558
+ `revision` covers the recipe and what every source resolved to, so it moves
559
+ when a category resolves to a new build — which is the whole reason the block
560
+ exists.
561
+
562
+ ### The sources themselves are not in it
563
+
564
+ They were once, and it was wrong twice over.
565
+
566
+ A stack's sources are not a client's to join. They are the ingredients of one
567
+ endpoint, and this document exists to point at that endpoint: a map reads
568
+ `tiles`, not the archives behind it. Listing them invites somebody to fetch
569
+ those instead, which is the one thing a stack is there to stop them having to
570
+ do.
571
+
572
+ And a source may be an **address**. A URL, or a bucket and a key, published in
573
+ a document that is served to anybody who can load the map — while the archive
574
+ at the other end of it is read with credentials nobody else has. The tiles are
575
+ public on purpose; where they come from is not.
576
+
577
+ What is left says the same thing the list said, in twenty bytes rather than
578
+ tens of kilobytes: how many sources there are, and a fingerprint that moves
579
+ when any of them does.
580
+
559
581
  ## When a source will not answer
560
582
 
561
583
  A cache-mode source with no reachable peers, a category that resolves to an
@@ -2199,6 +2221,135 @@ Debounced, and it should not follow the map past the stack's `maxzoom`: above
2199
2221
  that the client overzooms and there is nothing new to see, but the tiles are
2200
2222
  still requested and still composited.
2201
2223
 
2224
+ ## Merging vector sources
2225
+
2226
+ **Not built.** This is the design, written down before anything is started so
2227
+ that the hard part is on record rather than discovered halfway through it.
2228
+
2229
+ Most of the machinery is indifferent to what a tile contains. Source
2230
+ resolution, per-source `minzoom`, `maxzoom` and `bounds`, painting order,
2231
+ nested stacks, the cache, the ETag, `unionOfTileIds` and the bake all treat a
2232
+ tile as an opaque thing, and the byte-for-byte passthrough already serves a
2233
+ vector archive today. What a vector space adds is a merge, and the merge is
2234
+ where the analogy with the two pixel spaces breaks.
2235
+
2236
+ ### What has no vector analogue
2237
+
2238
+ `opacity`, `blend`, `feather`, `featherMetres`, `gaussianBlurSigma`,
2239
+ `output.encoding`, `output.tileSize`, `maskValues`, `maskRange` and
2240
+ `maskColors`. Every one of them is an operation on a pixel, and there are no
2241
+ pixels. A recipe naming any of them under a vector space should be **refused by
2242
+ validation**, not accepted and ignored: a stack that says `feather: 50` and
2243
+ draws a hard edge is a worse answer than one that will not save.
2244
+
2245
+ The idea of a mask survives in three other forms — clip to a **shape**, keep or
2246
+ drop whole **layers** by name, and filter **features** by a property. Those
2247
+ three are the vector mask, and none of them is a mask in the sense the
2248
+ elevation space means.
2249
+
2250
+ ### Overlap is the whole problem
2251
+
2252
+ The pixel spaces work because the unit is a pixel, every source has one
2253
+ everywhere, and "the topmost covered source wins" is a complete rule. Vector's
2254
+ unit is a feature. Two sources that both carry a `water` layer over the same
2255
+ ground hold **two polygons of the same lake**, and emitting both overwrites
2256
+ neither. The tile carries both, the style draws both, and it shows as doubled
2257
+ outlines, darker semi-transparent fills, duplicated labels, and z-fighting
2258
+ between them.
2259
+
2260
+ So the first thing a vector space has to define is what winning means. Three
2261
+ answers, and the third is not a default:
2262
+
2263
+ **Per-layer replacement.** For each layer _name_, the topmost source carrying it
2264
+ supplies it whole, and the same-named layer of every source below is dropped for
2265
+ that tile. No geometry work at all: read the layer list, choose, re-emit. It is
2266
+ the right answer for a base plus an overlay of layers the base does not have —
2267
+ an OSM base under your own `buildings`, or under contours generated from the
2268
+ terrain these stacks already merge — and it is correct for a base plus a patch
2269
+ wherever the tile falls wholly inside the patch.
2270
+
2271
+ **Clip and union.** Each source is clipped to its own coverage, and the source
2272
+ below is additionally clipped _against_ the coverage of the one above it, so the
2273
+ two cannot both carry the same lake. This is the true counterpart of the pixel
2274
+ merge, and the only thing that works on a tile the boundary of a patch runs
2275
+ through. A **rectangle** clip in tile coordinates is cheap and exact —
2276
+ Sutherland-Hodgman for polygons, Liang-Barsky for lines — and a rectangle is
2277
+ what an imported file list states for every patch in it. An arbitrary
2278
+ **cutline** is a different matter and wants a real polygon-clipping library.
2279
+
2280
+ **Renaming.** The patch's `water` becomes `patch_water`, and the style decides
2281
+ what to do with it. No conflict and no geometry work, at the cost of a style
2282
+ that has to know. Worth having as a way out; not worth having as the rule.
2283
+
2284
+ The recommendation is per-layer replacement as the default, rectangle clipping
2285
+ for the base-and-patch case, and a **recipe warning** wherever two sources carry
2286
+ the same layer with no clip between them: _sources 0 and 1 both provide `water`,
2287
+ and tiles where they meet will carry both_. Silent doubling is the failure that
2288
+ gets found six months later by somebody squinting at a coastline.
2289
+
2290
+ ### A seam cannot be feathered
2291
+
2292
+ Where two sources disagree geometrically at a boundary, a road jogs and a
2293
+ coastline steps, and there is nothing to fade. [Feathering a
2294
+ seam](#feathering-a-seam) works because a pixel can be a mixture of two values.
2295
+ A feature cannot be a mixture of two features.
2296
+
2297
+ Vector merging is therefore clean only where the sources were cut to agree — the
2298
+ same condition that makes a provider's raster patches merge cleanly, minus the
2299
+ fade that hides the cases where it is not quite true. That belongs in the
2300
+ console rather than on the map.
2301
+
2302
+ ### Overzooming a parent
2303
+
2304
+ Possible, and needed: a base to z12 under a patch to z14 has to climb the base
2305
+ at z13, exactly as [resampling from a parent](#resampling-from-a-parent-tile)
2306
+ does for pixels. Scale the parent's coordinates by the zoom difference, subtract
2307
+ the quadrant's offset, clip to the extent plus the buffer. Exact for geometry,
2308
+ and it invents nothing: the generalisation and the label placement were chosen
2309
+ for the parent's zoom, so an overzoomed tile is thinner than a real one at that
2310
+ zoom and looks it.
2311
+
2312
+ ### Extent, buffer and schema
2313
+
2314
+ Sources at different **extents** — 4096 and 512 are both common — have to be
2315
+ normalised to one before anything is combined. A power-of-two ratio is exact;
2316
+ anything else quantises coordinates.
2317
+
2318
+ **Buffers** have to be re-clipped after any coordinate change, or features leak
2319
+ past the edge of the tile and draw twice at the join.
2320
+
2321
+ **Schemas** are the quiet one. Two sources whose `water` layers use `class` and
2322
+ `kind` merge into a layer the style half understands, and nothing in the merge
2323
+ can reconcile them. Worth comparing key sets on the first merged tile and
2324
+ reporting the disagreement as a problem on the stack, the way an unresolved
2325
+ source is reported.
2326
+
2327
+ **Feature ids** collide across sources. Renumbering settles the collision and
2328
+ breaks any client-side feature state keyed on them, so it is a choice to make
2329
+ out loud rather than a detail to settle inside the encoder.
2330
+
2331
+ ### The libraries
2332
+
2333
+ `@mapbox/vector-tile` and `pbf` to read, `vt-pbf` to write. Small, pure
2334
+ JavaScript, and ordinary dependencies rather than a probe: the optional
2335
+ treatment [the codec gets](#the-codec-problem) exists because a native build
2336
+ genuinely fails to install on some platforms, and none of that applies here.
2337
+
2338
+ Not written here either, unlike the PMTiles writer and the S3 signer. A protobuf
2339
+ round-trip has more edge cases than either — geometry command encoding, unknown
2340
+ value types, key and value pools shared across features — and getting one wrong
2341
+ produces a tile that parses and is subtly wrong.
2342
+
2343
+ ### Staging, when it is started
2344
+
2345
+ 1. **Layer operations only.** `space: "vector"`, with keep, drop and rename by
2346
+ layer name, over the per-source zoom and bounds skipping that already exists.
2347
+ Covers base-plus-overlay, and needs no geometry code whatsoever.
2348
+ 2. **Overzoom and rectangle clipping.** Covers base-plus-patch, which is the
2349
+ shape [importing a list of URLs](#importing-a-list-of-urls) makes easy to
2350
+ build.
2351
+ 3. **Cutline clipping and property filters.**
2352
+
2202
2353
  ## Staging
2203
2354
 
2204
2355
  1. ~~**Recipe and resolution.**~~ Done. `data/stacks.json`, load and validate,
@@ -2230,10 +2381,11 @@ handling whatsoever.
2230
2381
 
2231
2382
  ## Open questions
2232
2383
 
2233
- - **Vector tiles.** Merging MVT layers from two archives is a real want and a
2234
- completely different operation — decode protobuf, merge layer by layer,
2235
- re-encode, with feature ID collisions to settle. It should be its own feature
2236
- and should not be smuggled in under `space`.
2384
+ - **Vector tiles.** Designed rather than open now: see [merging vector
2385
+ sources](#merging-vector-sources). It is its own space and its own merge,
2386
+ and what stays open is whether the overlap rule proposed there — layer
2387
+ replacement by default, clipping for a patch — holds up against a real
2388
+ pair of archives.
2237
2389
  - **Does the recipe travel?** A stack is a small JSON document and the feed
2238
2390
  already distributes documents. Publishing recipes so a subscriber gets the
2239
2391
  stack along with its sources is attractive, and raises an obvious question
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.91.0",
3
+ "version": "0.92.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
@@ -50,15 +50,7 @@ import {
50
50
  stackCoverage,
51
51
  stackEtag,
52
52
  } from './stacks.js';
53
-
54
- /**
55
- * How many sources a stack's TileJSON names before it starts counting.
56
- *
57
- * Generous for a hand-written recipe -- those run to a handful -- and a hard
58
- * stop for one imported from a provider's index, where the list is hundreds
59
- * long and every entry is an address no client reads.
60
- */
61
- const TILEJSON_SOURCE_LIMIT = 25;
53
+ import { bakeRevision } from './bake.js';
62
54
 
63
55
  const here = path.dirname(fileURLToPath(import.meta.url));
64
56
 
@@ -3800,34 +3792,27 @@ export function createApp({
3800
3792
  minzoom: coverage.minzoom,
3801
3793
  maxzoom: coverage.maxzoom,
3802
3794
  bounds: coverage.bounds,
3803
- // Names what it resolved to, so a consumer can tell one resolution
3804
- // from the next without diffing tile URLs — the same reason the
3805
- // `/latest/` document carries a `latest` block.
3795
+ // Says that it resolved, and whether the resolution has moved, so a
3796
+ // consumer can tell one from the next without diffing tile URLs --
3797
+ // the same reason the `/latest/` document carries a `latest` block.
3798
+ //
3799
+ // A count and a fingerprint rather than the sources themselves. They
3800
+ // were listed here once, and it was wrong twice over. A stack's
3801
+ // sources are not a client's to join: they are the ingredients of one
3802
+ // endpoint, and a document listing them invites somebody to fetch
3803
+ // those instead of the tiles. And a source may be an address -- a URL,
3804
+ // or a bucket and a key -- which this document has no business
3805
+ // publishing: the tiles are public where the archives behind them are
3806
+ // read with credentials nobody else has.
3806
3807
  //
3807
- // Capped. A stack imported from a provider's file index has hundreds
3808
- // of sources — Mapterhorn's is 458 — and listing every one puts tens
3809
- // of kilobytes of addresses in a document every map load fetches, to
3810
- // say something no client does anything with. What the block is for is
3811
- // telling one resolution from the next, and what moves between
3812
- // resolutions is the infohashes, so those are what it keeps.
3808
+ // The fingerprint covers the recipe and what every source resolved
3809
+ // to, so it moves when a category resolves to a new build. That was
3810
+ // the only thing the list was good for.
3813
3811
  stack: {
3814
3812
  id: resolved.stack.id,
3815
3813
  space: resolved.stack.space ?? 'elevation',
3816
- sources: resolved.sources
3817
- .slice(0, TILEJSON_SOURCE_LIMIT)
3818
- .map((source) => ({
3819
- name: source.name,
3820
- infohash: source.entry?.infoHash ?? null,
3821
- archive: source.entry?.name ?? null,
3822
- })),
3823
- // Said rather than silently truncated: a consumer comparing two of
3824
- // these has to know it is looking at part of a list.
3825
- ...(resolved.sources.length > TILEJSON_SOURCE_LIMIT
3826
- ? {
3827
- more: resolved.sources.length - TILEJSON_SOURCE_LIMIT,
3828
- total: resolved.sources.length,
3829
- }
3830
- : {}),
3814
+ sources: resolved.sources.length,
3815
+ revision: bakeRevision(resolved),
3831
3816
  },
3832
3817
  };
3833
3818
  // A stack says how its tiles are encoded, so a style pointing at it does
@@ -1875,6 +1875,11 @@
1875
1875
  for (const entry of shown) {
1876
1876
  const s = entry.status ?? {};
1877
1877
  const progress = s.progress ?? 0;
1878
+ // The engine has never mentioned this archive. That is not the same
1879
+ // fact as "none of it is here", and drawing it as 0% said the second
1880
+ // -- so a library the engine failed to take back after a restart
1881
+ // read as a library that had lost its data.
1882
+ const known = Boolean(entry.status);
1878
1883
  const mode = entry.mode ?? 'mirror';
1879
1884
  const tr = document.createElement('tr');
1880
1885
  if (entry.infoHash === selected) tr.className = 'on';
@@ -1893,12 +1898,14 @@
1893
1898
  <td>${added(entry.createdAt)}</td>
1894
1899
  <td><span class="pill ${mode}">${mode}</span></td>
1895
1900
  <td>${originCell(entry)}</td>
1896
- <td>
1897
- <div class="track">
1898
- <i class="${progress >= 1 ? 'done' : ''}" style="width:${Math.min(100, progress * 100)}%"></i>
1899
- </div>
1900
- <div class="sub">${pct(progress)}</div>
1901
- </td>
1901
+ <td>${
1902
+ known
1903
+ ? `<div class="track">
1904
+ <i class="${progress >= 1 ? 'done' : ''}" style="width:${Math.min(100, progress * 100)}%"></i>
1905
+ </div>
1906
+ <div class="sub">${pct(progress)}</div>`
1907
+ : `<div class="sub bad" title="The engine has no record of this archive, so nothing here is being seeded or downloaded. It usually means the restore after a restart could not hand it back — the log says why, under [restore]. Nothing has been deleted; the data on disk is untouched.">not loaded</div>`
1908
+ }</td>
1902
1909
  <td title="${peersTitle(s)}">${s.peers ?? 0} / ${s.seeds ?? 0}${swarmSuffix(s)}</td>
1903
1910
  <td>${rate(s.downloadSpeed)}</td>
1904
1911
  <td>${rate(s.uploadSpeed)}</td>
@@ -7506,6 +7513,22 @@ Every piece is hashed against the ` +
7506
7513
  return TABS.includes(named) ? named : null;
7507
7514
  };
7508
7515
 
7516
+ /**
7517
+ * Lands on the view the address names, once there is a console to land
7518
+ * in.
7519
+ *
7520
+ * Two ways in, and both have to honour it: opening the page already
7521
+ * signed in, and signing in on arrival. Only the first did, so following
7522
+ * a link to `#stacks` on a guarded node asked for a password and then
7523
+ * showed the archives -- the address still saying `#stacks`, which is
7524
+ * the part that makes it read as broken rather than as a redirect.
7525
+ * @returns {void}
7526
+ */
7527
+ const landFromUrl = () => {
7528
+ const named = tabFromUrl();
7529
+ if (named && named !== 'archives') showTab(named);
7530
+ };
7531
+
7509
7532
  // The footer's year, set from the clock rather than typed into a file
7510
7533
  // nobody will remember to edit. The version beside it arrives with the
7511
7534
  // first status, and reads "…" until then.
@@ -9451,6 +9474,7 @@ Every piece is hashed against the ` +
9451
9474
  $('login').hidden = true;
9452
9475
  $('login-pass').value = '';
9453
9476
  refresh();
9477
+ landFromUrl();
9454
9478
  } catch (error) {
9455
9479
  $('login-error').textContent = error.message;
9456
9480
  }
@@ -9476,8 +9500,7 @@ Every piece is hashed against the ` +
9476
9500
  refresh();
9477
9501
  // After the archives have been asked for, so the tab that opens is the
9478
9502
  // one asked for and not the one this happens to start on.
9479
- const named = tabFromUrl();
9480
- if (named && named !== 'archives') showTab(named);
9503
+ landFromUrl();
9481
9504
  }
9482
9505
 
9483
9506
  start();