pmtiles-swarm 0.79.1 → 0.80.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,45 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.80.0
11
+ ### ✨ Features and improvements
12
+ - **A stack can be a source in another stack.** `{ "stack": "jaxa-with-gebco" }` beside `category`
13
+ and `archive`. A base worked out once — terrain over bathymetry, masked at the coast and faded
14
+ across it — is a thing to reuse rather than retype, and a recipe that names it follows every later
15
+ correction to it, exactly as a source over a category follows a rebuild.
16
+
17
+ It is merged as **heights**: the inner stack is evaluated for the tile and handed straight to the
18
+ merge above, with no encode and decode in between. That saves two conversions per tile and, more
19
+ to the point, does not round a value on its way from one merge into the next. So a nested source
20
+ may say anything that acts on heights — `maskValues`, `maskRange`, `heightAdjustment`, `cutline`,
21
+ `bounds`, the fade, `opacity`, `blend` — and nothing that describes stored bytes. `encoding` and
22
+ its parameters are refused, and so is `maskColors`, which compares channels as an archive stored
23
+ them and has none to compare here.
24
+
25
+ A loop is refused by name on the way down, so a stack naming itself and a ring of three are the
26
+ same case and neither needs a depth counter to stop. The depth limit is separate and is four:
27
+ every level is a full merge of everything under it, so a tile's cost multiplies rather than adds.
28
+
29
+ Coverage folds in one level down, and the ETag carries the inner stack's own ETag rather than its
30
+ id — without that, editing the inner recipe would leave the outer one serving from a cache that
31
+ still believed in it, and propagation is the whole point of naming a stack instead of copying it.
32
+ The console offers held stacks in the source picker, alongside categories and archives.
33
+
34
+ - **An export can set its attribution, and starts with the right one.** An archive travels without
35
+ the style that loaded it — seeded, mirrored, opened by people who never saw the stack it came from
36
+ — so its own metadata is the only place the credit survives. The export dialog has an
37
+ **Attribution** field, filled in from the stack: its own where the recipe states one, otherwise
38
+ every source's joined. Editable, because an export may be published under terms the recipe does
39
+ not know; filled in rather than blank, because unlike the description it is not something only the
40
+ person exporting knows.
41
+
42
+ Joined with ` | ` rather than `, `, in the TileJSON as well as in the archive. These strings are
43
+ almost always HTML links and a comma between two anchors renders as part of the last one's text,
44
+ which is why MapLibre, Mapbox and OpenLayers all separate them this way.
45
+
46
+ ### 🐞 Bug fixes
47
+ - _...Add new stuff here..._
48
+
10
49
  ## 0.79.1
11
50
  ### ✨ Features and improvements
12
51
  - _...Add new stuff here..._
@@ -52,6 +52,7 @@ of its parts.
52
52
  - [Clipping a source to a shape](#clipping-a-source-to-a-shape)
53
53
  - [What a mask has to match](#what-a-mask-has-to-match)
54
54
  - [Feathering a seam](#feathering-a-seam)
55
+ - [A stack as a source](#a-stack-as-a-source)
55
56
  - [Finding a stack](#finding-a-stack)
56
57
  - [Syncing a stack to another node](#syncing-a-stack-to-another-node)
57
58
  - [What the offline merge got wrong](#what-the-offline-merge-got-wrong)
@@ -231,7 +232,7 @@ it differs from the snake_case rio-rgbify-merge uses.
231
232
  | `boundsSource` | Index into `sources` whose bounds become the stack's. Omit for the union. |
232
233
  | `bounds` | Explicit `[w, s, e, n]`. Wins over `boundsSource`. |
233
234
  | `minzoom` / `maxzoom` | Clamps. Default to the min and **max** over the sources. |
234
- | `attribution` | Falls back to every source's, concatenated. |
235
+ | `attribution` | Falls back to every source's, joined with `\|`. |
235
236
 
236
237
  ### Source fields
237
238
 
@@ -252,8 +253,14 @@ it differs from the snake_case rio-rgbify-merge uses.
252
253
 
253
254
  `attribution` is not optional in practice. A stack is a derived work of every
254
255
  source in it, and the thing that reliably gets lost when tiles are combined is
255
- who the data belongs to. If it is omitted, the implementation should concatenate
256
- the sources' own TileJSON `attribution` strings rather than emit nothing.
256
+ who the data belongs to. If it is omitted, every source's own TileJSON
257
+ `attribution` is joined rather than nothing being emitted.
258
+
259
+ Joined with `|`, not with a comma. These strings are almost always HTML links,
260
+ and a comma between two anchors renders as part of the last one's text — which
261
+ is why MapLibre, Mapbox and OpenLayers all separate them this way. The same
262
+ string is what an export writes into the archive's metadata, so a file and the
263
+ endpoint it was baked from credit their sources identically.
257
264
 
258
265
  ## Translating a rio-rgbify-merge config
259
266
 
@@ -812,6 +819,16 @@ that render as sea.
812
819
  image of nothing in particular, and `custom` carries its four factors or is not
813
820
  worth writing at all.
814
821
 
822
+ `attribution` is the third, and the one an export cannot afford to leave out. An
823
+ archive travels without the style that loaded it — it is seeded, mirrored and
824
+ opened by people who never saw the stack it came from — so its own metadata is
825
+ the only place the credit survives. The dialog is filled in from the stack: its
826
+ own `attribution` where the recipe states one, and otherwise every source's
827
+ joined with `|`, since a stack is a derived work of all of them. It is
828
+ editable, because an export may be published under terms the recipe does not
829
+ know about; it is filled in rather than blank, because unlike the description it
830
+ is not something only the person exporting knows.
831
+
815
832
  ### Starting one, and watching it
816
833
 
817
834
  **Export to archive**, on the stack, beside Edit and Delete. `POST
@@ -1354,6 +1371,85 @@ visible of the three and the cheapest to detect, since whether a neighbour exist
1354
1371
  is a directory lookup rather than a decode. It ramps down toward an absent
1355
1372
  neighbour, and nothing else has to happen.
1356
1373
 
1374
+ ## A stack as a source
1375
+
1376
+ A source may name a stack instead of a category or an archive:
1377
+
1378
+ ```json
1379
+ {
1380
+ "id": "hillshade-ready",
1381
+ "sources": [
1382
+ { "stack": "jaxa-with-gebco" },
1383
+ { "category": "swissalti", "featherMetres": 50 }
1384
+ ]
1385
+ }
1386
+ ```
1387
+
1388
+ The reason to want it is that a base worked out once — terrain over bathymetry,
1389
+ masked at the coast and faded across it — is a thing to reuse rather than
1390
+ retype. A recipe that names it follows every later correction to it, exactly as
1391
+ a source over a category follows a rebuild.
1392
+
1393
+ ### It is merged as heights
1394
+
1395
+ The inner stack is evaluated for the tile being built and its heights are handed
1396
+ straight to the merge above, with no encode and decode in between. That is not
1397
+ only a saving of two conversions per tile: an encoding is lossy about what it
1398
+ cannot represent, and a value that survived the inner merge should not be
1399
+ rounded on its way into the outer one. The inner stack's `output` block still
1400
+ applies where it is served on its own URL — it just has no part in this.
1401
+
1402
+ So a nested source may say anything that acts on heights, and nothing that
1403
+ describes stored bytes:
1404
+
1405
+ | Field | On a nested stack |
1406
+ | --------------------------------------------------- | -------------------------------- |
1407
+ | `maskValues`, `maskRange`, `heightAdjustment` | Yes, on the heights it produced |
1408
+ | `cutline`, `bounds`, `feather`, `featherMetres` | Yes |
1409
+ | `opacity`, `blend` | Yes |
1410
+ | `encoding`, `baseVal`, `interval`, the four factors | Refused — nothing was stored |
1411
+ | `maskColors` | Refused — no channels to compare |
1412
+
1413
+ `maskColors` is the one that reads like an omission and is not. It compares the
1414
+ three channels as the archive stored them, which is what makes it exact where a
1415
+ height mask has to round; a stack that was never stored has no such channels to
1416
+ compare, and masking the heights it decoded to would be a different operation
1417
+ wearing the same name.
1418
+
1419
+ ### Nothing passes through
1420
+
1421
+ The short-circuit that hands back a source's own bytes cannot apply: there are
1422
+ no bytes. A stack with a nested source always decodes, merges and encodes, and
1423
+ always needs a codec — `needsCodec` says so from the recipe rather than at the
1424
+ first tile.
1425
+
1426
+ ### Loops, and depth
1427
+
1428
+ A loop is refused by name on the way down: the resolver carries the chain of ids
1429
+ it has walked, and a source naming one already in it resolves to nothing rather
1430
+ than being followed. That covers a stack naming itself and a ring of three
1431
+ equally, and it needs no depth counter to terminate.
1432
+
1433
+ The depth limit is a separate thing, for the chain that does not loop and is
1434
+ still nobody's intention. **Four**, because every level is a full merge of
1435
+ everything under it: the cost of one tile multiplies rather than adds, and a
1436
+ five-deep chain over three sources each is a request nobody meant to make.
1437
+
1438
+ ### What the outer stack inherits
1439
+
1440
+ Coverage folds in, one level down: a nested stack answers for the ground its own
1441
+ sources cover, with the same minzoom, maxzoom and bounds it would advertise on
1442
+ its own, and its attribution joins the outer stack's.
1443
+
1444
+ The ETag includes the inner stack's own ETag rather than only its id. Without
1445
+ that, editing the inner recipe would leave every outer tile being served from a
1446
+ cache that still believed in it — and the whole point of naming a stack rather
1447
+ than copying it is that a correction propagates.
1448
+
1449
+ `isPinned` asks the same question one level down. A nested stack is only as
1450
+ pinned as what is underneath it, which is what decides whether the outer stack's
1451
+ tiles are safe to cache hard.
1452
+
1357
1453
  ## Finding a stack
1358
1454
 
1359
1455
  A stack has no infohash and appears in no feed, so nothing about it is
@@ -1669,10 +1765,6 @@ handling whatsoever.
1669
1765
 
1670
1766
  ## Open questions
1671
1767
 
1672
- - **Should a stack be able to stack another stack?** Composable and obviously
1673
- tempting; also an unbounded fan-out of swarm reads behind one request. If
1674
- allowed, the depth needs a hard limit and the resolution hash needs to include
1675
- the whole tree.
1676
1768
  - **Vector tiles.** Merging MVT layers from two archives is a real want and a
1677
1769
  completely different operation — decode protobuf, merge layer by layer,
1678
1770
  re-encode, with feature ID collisions to settle. It should be its own feature
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.79.1",
3
+ "version": "0.80.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
@@ -3202,6 +3202,9 @@ export function createApp({
3202
3202
  resolveStack(stack, {
3203
3203
  archive: (hash) => catalog.get(hash) ?? null,
3204
3204
  category: (name) => newestIn(name, req),
3205
+ // The recipe, so the resolver can walk it. Loops and depth are its
3206
+ // business, not this one's.
3207
+ stack: (id) => stacks?.get(id) ?? null,
3205
3208
  });
3206
3209
 
3207
3210
  /**
@@ -3308,6 +3311,7 @@ export function createApp({
3308
3311
  savePath: req.body?.savePath,
3309
3312
  name: req.body?.name,
3310
3313
  description: req.body?.description,
3314
+ attribution: req.body?.attribution,
3311
3315
  });
3312
3316
  return res.status(202).json({ bake: job });
3313
3317
  } catch (error) {
package/src/bake-jobs.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  wasStopped,
15
15
  } from './bake.js';
16
16
  import { PixelWorker } from './pixels.js';
17
+ import { stackCoverage } from './stacks.js';
17
18
  import { outputFormat } from './stack-tile.js';
18
19
 
19
20
  /**
@@ -409,7 +410,9 @@ export class BakeManager {
409
410
  // presses the button rather than an hour in.
410
411
  assertBakeable(resolved, codec);
411
412
 
412
- const unresolved = resolved.sources.filter((source) => !source.entry);
413
+ const unresolved = resolved.sources.filter(
414
+ (source) => !source.entry && !source.nested,
415
+ );
413
416
  if (unresolved.length === resolved.sources.length) {
414
417
  const error = new Error(
415
418
  "none of this stack's sources resolved to an archive on this node",
@@ -601,6 +604,7 @@ export class BakeManager {
601
604
  filename: job.name,
602
605
  publishDir: job.publishDir ?? null,
603
606
  description: options.description ?? null,
607
+ attribution: options.attribution ?? null,
604
608
  categories: options.categories ?? null,
605
609
  },
606
610
  metadata: {
@@ -609,7 +613,11 @@ export class BakeManager {
609
613
  // description would fill in a field the dialog showed as empty, which
610
614
  // is a worse surprise than having no description at all.
611
615
  description: options.description,
612
- attribution: resolved.stack.attribution,
616
+ // What the dialog was shown, which is the stack's own where it has one
617
+ // and every source's joined where it has not. An archive travels
618
+ // without the style that loaded it, so this is the only place the
619
+ // credit survives -- and a stack is a derived work of all of them.
620
+ attribution: options.attribution ?? stackCoverage(resolved).attribution,
613
621
  encoding: resolved.stack.output?.encoding,
614
622
  encodingFactors: resolved.stack.output,
615
623
  sparse: resolved.stack.sparse,
package/src/elevation.js CHANGED
@@ -702,16 +702,24 @@ export function paintHeights(layers, weights = []) {
702
702
  export function mergeElevation(contributions, options) {
703
703
  const { size } = options;
704
704
  const layers = contributions.map((contribution) => {
705
- if (!contribution?.raster) return null;
705
+ if (!contribution?.raster && !contribution?.heights) return null;
706
706
  const source = contribution.source ?? {};
707
707
 
708
- let heights = decodeHeights(contribution.raster, source);
708
+ // A nested stack arrives as the numbers it produced rather than as pixels
709
+ // somebody has to decode: it was never stored, so there is nothing to
710
+ // decode and no channels to compare a colour against. Copied because the
711
+ // rest of this works in place and the caller's array is not ours.
712
+ let heights = contribution.heights
713
+ ? Float32Array.from(contribution.heights)
714
+ : decodeHeights(contribution.raster, source);
709
715
  // Both masks say the same thing -- nothing here -- and both have to run
710
716
  // before the height adjustment, which would otherwise shift the values
711
717
  // out from under the comparison.
712
718
  //
713
719
  maskHeights(heights, source.maskValues);
714
- maskColors(heights, contribution.raster, source.maskColors);
720
+ if (contribution.raster) {
721
+ maskColors(heights, contribution.raster, source.maskColors);
722
+ }
715
723
  // A band as well as, not instead of: a source may have a sentinel it names
716
724
  // exactly and a range of ground it does not want either.
717
725
  maskRanges(heights, source.maskRange);
@@ -724,13 +732,9 @@ export function mergeElevation(contributions, options) {
724
732
  // On the output's grid before anything else: a source whose tiles are a
725
733
  // different size covers the same extent, only sampled more or less
726
734
  // finely, so this is a plain scale rather than a realignment.
727
- if (contribution.raster.width !== size) {
728
- heights = resampleToSize(
729
- heights,
730
- contribution.raster.width,
731
- size,
732
- options.resampling,
733
- );
735
+ const width = contribution.width ?? contribution.raster.width;
736
+ if (width !== size) {
737
+ heights = resampleToSize(heights, width, size, options.resampling);
734
738
  }
735
739
 
736
740
  const parentZ = contribution.parentZ ?? options.z;
package/src/index.js CHANGED
@@ -494,6 +494,7 @@ PMTILES_SWARM_PUBLIC_URL
494
494
  return resolveStack(stack, {
495
495
  archive: (hash) => catalog.get(hash),
496
496
  category: (name) => catalog.byCategory(name)[0] ?? null,
497
+ stack: (id) => stacks?.get(id) ?? null,
497
498
  });
498
499
  })
499
500
  .catch((error) => console.warn(`[bake] resume failed: ${error.message}`));
package/src/stack-tile.js CHANGED
@@ -12,6 +12,7 @@ import { paddedKnown, parentsFor } from './mask-edge.js';
12
12
  import {
13
13
  INSIDE,
14
14
  OUTSIDE,
15
+ PARTIAL,
15
16
  classifyTile,
16
17
  cropMask,
17
18
  featherMask,
@@ -65,7 +66,11 @@ const PARENT_LIMIT = 6;
65
66
  export function parentLimitFor(resolved) {
66
67
  const { maxzoom } = stackCoverage(resolved);
67
68
  const shallowest = resolved.sources
68
- .map((source) => source.entry?.pmtiles?.maxZoom)
69
+ .map((source) =>
70
+ source.nested
71
+ ? stackCoverage(source.nested).maxzoom
72
+ : source.entry?.pmtiles?.maxZoom,
73
+ )
69
74
  .filter((zoom) => Number.isFinite(zoom));
70
75
  if (!shallowest.length || !Number.isFinite(maxzoom)) return PARENT_LIMIT;
71
76
 
@@ -304,6 +309,10 @@ export function passThroughRead({
304
309
  // decoding the tile to find out how wide it is.
305
310
  if (size) return null;
306
311
 
312
+ // A nested stack has no stored bytes at all, so there is nothing this could
313
+ // hand back even when it is the only thing answering.
314
+ if (reads.some((read) => read.nested)) return null;
315
+
307
316
  const answered = reads.filter((read) => read.found);
308
317
  // More than one and they have to be painted; none and there is nothing to
309
318
  // send. An error on any source is a decision this must not take, because the
@@ -362,6 +371,13 @@ async function readAll({ resolved, z, x, y, tiles, signal, clips }) {
362
371
  const limit = parentLimitFor(resolved);
363
372
  return Promise.all(
364
373
  resolved.sources.map(async (source, index) => {
374
+ // A stack in place of an archive. Nothing to fetch: it is evaluated
375
+ // rather than read, and `gather` is where that happens -- but the clip
376
+ // still decides whether it is worth evaluating at all.
377
+ if (source.nested) {
378
+ if (clips?.[index]?.where === OUTSIDE) return { source, found: null };
379
+ return { source, nested: true };
380
+ }
365
381
  if (!source.entry) return { source, found: null };
366
382
  // Nothing of this source is in the shape, so there is nothing to fetch.
367
383
  // Decided before the read, which is where the saving is: no swarm round
@@ -394,10 +410,63 @@ async function readAll({ resolved, z, x, y, tiles, signal, clips }) {
394
410
  * @param {object} options - The reads and what to decode them with.
395
411
  * @returns {Promise<object>} - `{contributors, contributions}` or `{error}`.
396
412
  */
397
- async function gather({ reads, z, x, y, tiles, codec, signal, size, rgba }) {
413
+ async function gather({
414
+ reads,
415
+ z,
416
+ x,
417
+ y,
418
+ tiles,
419
+ codec,
420
+ signal,
421
+ size,
422
+ rgba,
423
+ cutlines,
424
+ }) {
398
425
  const contributors = [];
399
426
  const contributions = [];
400
427
  for (const read of reads) {
428
+ if (read.nested) {
429
+ // Evaluated rather than read, and handed on as heights: encoding it here
430
+ // only for the merge above to decode it again would cost two conversions
431
+ // and lose whatever the inner encoding could not hold.
432
+ const inner = await stackHeights({
433
+ resolved: read.source.nested,
434
+ z,
435
+ x,
436
+ y,
437
+ tiles,
438
+ codec,
439
+ signal,
440
+ size,
441
+ cutlines,
442
+ });
443
+ if (inner?.error) {
444
+ if (read.source.required) {
445
+ return {
446
+ contributors,
447
+ error: {
448
+ status: inner.error.status,
449
+ message: `${read.source.name} is required: ${inner.error.message}`,
450
+ },
451
+ };
452
+ }
453
+ contributors.push(`${read.source.name}=error`);
454
+ contributions.push(null);
455
+ continue;
456
+ }
457
+ if (!inner) {
458
+ contributors.push(`${read.source.name}=absent`);
459
+ contributions.push(null);
460
+ continue;
461
+ }
462
+ contributors.push(`${read.source.name}=stack(${inner.contributors})`);
463
+ contributions.push({
464
+ source: read.source.source,
465
+ heights: inner.heights,
466
+ width: inner.width,
467
+ });
468
+ continue;
469
+ }
401
470
  if (read.error) {
402
471
  if (read.source.required) {
403
472
  return {
@@ -577,6 +646,98 @@ async function readMaskEdges({
577
646
  );
578
647
  }
579
648
 
649
+ /**
650
+ * Turns each clip into the weight its source is painted with.
651
+ *
652
+ * Rasterised at the grid the layers are painted on, and only for the tiles the
653
+ * edge actually crosses -- everything else was settled by `classifyTile`
654
+ * without touching a pixel.
655
+ * @param {object} options - The contributions, the clips and the grid.
656
+ * @returns {void}
657
+ */
658
+ function applyClips({ contributions, clips, z, x, y, grid }) {
659
+ for (const [index, contribution] of contributions.entries()) {
660
+ const clip = clips?.[index];
661
+ if (!contribution || !clip || clip.where !== PARTIAL) continue;
662
+ // Rasterised with a border where the edge is feathered, because the ramp
663
+ // is measured from the boundary and a boundary just outside the tile still
664
+ // decides what the pixels inside it weigh. The shape is known in full, so
665
+ // this costs the extra rows and nothing else -- unlike a mask read out of
666
+ // a source's own pixels, which would need its neighbours.
667
+ //
668
+ // Asked again rather than taken from the clip: `clipsFor` may have been
669
+ // given a grid the merge did not end up using, and a fade in metres is a
670
+ // different number of pixels on each of them.
671
+ const margin = featherFor(contribution.source, { z, y, size: grid });
672
+ const mask = rasterizeTile(clip.shape, z, x, y, grid, margin);
673
+ contribution.coverage = cropMask(
674
+ featherMask(mask, grid + margin * 2, margin),
675
+ grid,
676
+ margin,
677
+ );
678
+ }
679
+ }
680
+
681
+ /**
682
+ * One tile of a stack, as heights, for a stack that is using it as a source.
683
+ *
684
+ * The same reading and merging a served tile goes through, stopping before the
685
+ * encode. A nested stack is handed to the merge above it as the numbers it
686
+ * produced: encoding here only for that merge to decode it again would cost
687
+ * two conversions per tile and lose whatever the inner encoding could not
688
+ * hold. See docs/tile-stacks.md -- "A stack as a source".
689
+ * @param {object} options - The resolved inner stack and what to read with.
690
+ * @returns {Promise<object|null>} - `{heights, width, contributors}`, `{error}`,
691
+ * or null where nothing covered this tile.
692
+ */
693
+ export async function stackHeights({
694
+ resolved,
695
+ z,
696
+ x,
697
+ y,
698
+ tiles,
699
+ codec,
700
+ signal,
701
+ size,
702
+ cutlines,
703
+ }) {
704
+ const clips = clipsFor(resolved, cutlines, z, x, y, size);
705
+ const reads = await readAll({ resolved, z, x, y, tiles, signal, clips });
706
+ const gathered = await gather({
707
+ reads,
708
+ z,
709
+ x,
710
+ y,
711
+ tiles,
712
+ codec,
713
+ signal,
714
+ size,
715
+ rgba: false,
716
+ cutlines,
717
+ });
718
+ if (gathered.error) return { error: gathered.error };
719
+
720
+ const present = gathered.contributions.filter(Boolean);
721
+ if (present.length === 0) return null;
722
+
723
+ const grid =
724
+ size ?? Math.max(...present.map((c) => c.width ?? c.raster.width));
725
+ applyClips({ contributions: gathered.contributions, clips, z, x, y, grid });
726
+
727
+ const heights = mergeElevation(gathered.contributions, {
728
+ z,
729
+ x,
730
+ y,
731
+ size: grid,
732
+ gaussianBlurSigma: resolved.stack.gaussianBlurSigma,
733
+ resampling: resolved.stack.resampling,
734
+ });
735
+ // Nodata is deliberately not filled in. A hole is what lets the stack above
736
+ // show through, and a slab of -10000 is ground rather than a hole.
737
+ if (!heights) return null;
738
+ return { heights, width: grid, contributors: gathered.contributors.join() };
739
+ }
740
+
580
741
  /**
581
742
  * Merges what the sources gave into one encoded tile.
582
743
  * @param {object} options - Everything the merge needs.
@@ -603,32 +764,12 @@ async function merge({
603
764
  // source brought, so the finest one is not thrown away.
604
765
  const grid =
605
766
  size ??
606
- Math.max(...contributions.filter(Boolean).map((c) => c.raster.width));
767
+ Math.max(
768
+ ...contributions.filter(Boolean).map((c) => c.width ?? c.raster.width),
769
+ );
607
770
  const output = resolved.stack.output ?? {};
608
771
 
609
- // Rasterised at the grid the layers are painted on, and only for the tiles
610
- // the edge actually crosses. Everything else was settled by `classifyTile`
611
- // without touching a pixel.
612
- for (const [index, contribution] of contributions.entries()) {
613
- const clip = clips?.[index];
614
- if (!contribution || !clip || clip.where !== 'partial') continue;
615
- // Rasterised with a border where the edge is feathered, because the ramp
616
- // is measured from the boundary and a boundary just outside the tile still
617
- // decides what the pixels inside it weigh. The shape is known in full, so
618
- // this costs the extra rows and nothing else -- unlike a mask read out of
619
- // a source's own pixels, which would need its neighbours.
620
- //
621
- // Asked again rather than taken from the clip: `clipsFor` may have been
622
- // given a grid the merge did not end up using, and a fade in metres is a
623
- // different number of pixels on each of them.
624
- const margin = featherFor(contribution.source, { z, y, size: grid });
625
- const mask = rasterizeTile(clip.shape, z, x, y, grid, margin);
626
- contribution.coverage = cropMask(
627
- featherMask(mask, grid + margin * 2, margin),
628
- grid,
629
- margin,
630
- );
631
- }
772
+ applyClips({ contributions, clips, z, x, y, grid });
632
773
 
633
774
  const merging = {
634
775
  z,
@@ -816,6 +957,7 @@ export async function answerStackTile(options) {
816
957
  signal,
817
958
  size,
818
959
  rgba,
960
+ cutlines,
819
961
  });
820
962
  if (gathered.error) {
821
963
  return {
package/src/stacks.js CHANGED
@@ -74,6 +74,35 @@ const SPACES = new Set(['elevation', 'rgba']);
74
74
  /** An id has to survive being a path segment without being escaped. */
75
75
  const ID = /^[a-z0-9][a-z0-9._-]*$/i;
76
76
 
77
+ /**
78
+ * How many stacks deep a recipe may reach.
79
+ *
80
+ * A loop is caught by name and refused outright; this is for the chain that
81
+ * does not loop but is still nobody's intention -- every level is a full merge
82
+ * of everything under it, so the cost of a tile multiplies rather than adds.
83
+ */
84
+ const MAX_STACK_DEPTH = 4;
85
+
86
+ /**
87
+ * Fields that describe stored bytes, which a nested stack does not have.
88
+ *
89
+ * It is merged as heights -- evaluated and handed straight to the merge above
90
+ * it, with no encode and decode in between -- so an encoding is a question
91
+ * about nothing, and `maskColors` compares channels as they were stored where
92
+ * nothing was ever stored. Everything else a source may say still applies:
93
+ * masks by height, the fade, a cutline, an adjustment, opacity and blend.
94
+ */
95
+ const NOT_ON_A_NESTED_SOURCE = Object.freeze([
96
+ 'encoding',
97
+ 'baseVal',
98
+ 'interval',
99
+ 'redFactor',
100
+ 'greenFactor',
101
+ 'blueFactor',
102
+ 'baseShift',
103
+ 'maskColors',
104
+ ]);
105
+
77
106
  /**
78
107
  * Checks a stack definition, returning what is wrong with it.
79
108
  *
@@ -99,11 +128,37 @@ export function validateStack(stack) {
99
128
  }
100
129
 
101
130
  stack.sources.forEach((source, index) => {
102
- const named = [source?.category, source?.archive].filter(Boolean).length;
131
+ const named = [source?.category, source?.archive, source?.stack].filter(
132
+ Boolean,
133
+ ).length;
103
134
  // Both is worse than neither: it looks deliberate, and whichever one the
104
135
  // implementation happened to prefer would be a silent choice.
105
136
  if (named !== 1) {
106
- problems.push(`sources[${index}] needs exactly one of category, archive`);
137
+ problems.push(
138
+ `sources[${index}] needs exactly one of category, archive, stack`,
139
+ );
140
+ }
141
+ if (source?.stack) {
142
+ if (typeof source.stack !== 'string' || !ID.test(source.stack)) {
143
+ problems.push(`sources[${index}].stack must be the id of a stack`);
144
+ }
145
+ if (source.stack === stack.id) {
146
+ // Caught by the resolver as well, which has to handle a loop through
147
+ // three stacks anyway. Named here because a recipe naming itself is a
148
+ // mistake worth reading in the editor rather than in the tile.
149
+ problems.push(`sources[${index}].stack is this stack`);
150
+ }
151
+ // A nested stack is merged as heights: it never becomes bytes, so there
152
+ // is nothing for these to describe. Refused rather than ignored, since a
153
+ // field that quietly does nothing is the failure this is most prone to.
154
+ for (const key of NOT_ON_A_NESTED_SOURCE) {
155
+ if (source[key] !== undefined) {
156
+ problems.push(
157
+ `sources[${index}].${key} does not apply to a stack: it is ` +
158
+ 'merged as heights and never stored as pixels',
159
+ );
160
+ }
161
+ }
107
162
  }
108
163
  if (source?.opacity !== undefined) {
109
164
  const value = Number(source.opacity);
@@ -291,6 +346,9 @@ export function validateStack(stack) {
291
346
  */
292
347
  export function needsCodec(stack) {
293
348
  for (const [index, source] of (stack.sources ?? []).entries()) {
349
+ // Always: a nested stack is evaluated rather than read, and evaluating it
350
+ // means decoding everything underneath.
351
+ if (source.stack) return `sources[${index}].stack`;
294
352
  if (source.maskValues?.length) return `sources[${index}].maskValues`;
295
353
  if (source.maskColors?.length) return `sources[${index}].maskColors`;
296
354
  if (source.maskRange?.length) return `sources[${index}].maskRange`;
@@ -346,27 +404,59 @@ export function stackRevision(stack) {
346
404
  * resolves depends on who is asking — the same rule `/latest/<category>/`
347
405
  * applies, which takes the request's credential into account.
348
406
  * @param {Stack} stack - The definition.
349
- * @param {object} resolvers - `archive(hash)` and `category(name)` lookups.
407
+ * @param {object} resolvers - `archive(hash)` and `category(name)` lookups,
408
+ * and `stack(id)` returning a recipe for a source that names one.
409
+ * @param {string[]} [chain] - The stack ids already being resolved, so a
410
+ * recipe naming one of them stops rather than looping.
350
411
  * @returns {object} - The stack, its sources bottom-first, and any failures.
351
412
  */
352
- export function resolveStack(stack, resolvers) {
413
+ export function resolveStack(stack, resolvers, chain = []) {
414
+ const here = [...chain, stack.id];
353
415
  const sources = (stack.sources ?? []).map((source, index) => {
354
- const entry = source.category
355
- ? resolvers.category(source.category)
356
- : resolvers.archive(source.archive);
357
- return {
416
+ const common = {
358
417
  index,
359
418
  source,
360
- entry: entry ?? null,
361
419
  // The bottom-most source is the base, and a stack without its base is
362
420
  // holes rather than a map. Above it, absence is survivable.
363
421
  required: source.required ?? index === 0,
422
+ };
423
+
424
+ if (source.stack) {
425
+ // A stack in place of an archive. Resolved here rather than at the tile,
426
+ // so a recipe that cannot work says so once instead of at every request.
427
+ const inner = resolvers.stack?.(source.stack) ?? null;
428
+ const looping = Boolean(inner) && here.includes(inner.id);
429
+ const deep = here.length >= MAX_STACK_DEPTH;
430
+ return {
431
+ ...common,
432
+ entry: null,
433
+ name: source.stack,
434
+ // A stack of categories moves when they are rebuilt, exactly as one of
435
+ // them would on its own.
436
+ pinned: false,
437
+ nested:
438
+ inner && !looping && !deep
439
+ ? resolveStack(inner, resolvers, here)
440
+ : null,
441
+ // Told apart, because they call for different things: a loop is a
442
+ // recipe to fix, a name that resolves to nothing is a stack to add.
443
+ looping,
444
+ deep,
445
+ };
446
+ }
447
+
448
+ const entry = source.category
449
+ ? resolvers.category(source.category)
450
+ : resolvers.archive(source.archive);
451
+ return {
452
+ ...common,
453
+ entry: entry ?? null,
364
454
  name: source.category ?? source.archive,
365
455
  pinned: Boolean(source.archive),
366
456
  };
367
457
  });
368
458
 
369
- const missing = sources.filter((s) => !s.entry && s.required);
459
+ const missing = sources.filter((s) => s.required && !s.entry && !s.nested);
370
460
  return { stack, sources, missing };
371
461
  }
372
462
 
@@ -440,7 +530,21 @@ export function stacksUsing(stacks, entry, categoryInfo) {
440
530
  */
441
531
  export function stackCoverage(resolved) {
442
532
  const present = resolved.sources.filter((s) => s.entry?.pmtiles);
443
- const summaries = present.map((s) => s.entry.pmtiles);
533
+ // A nested stack answers for the ground its own sources cover, so it stands
534
+ // in for one here -- worked out the same way, one level down.
535
+ const nested = resolved.sources
536
+ .filter((s) => s.nested)
537
+ .map((s) => stackCoverage(s.nested));
538
+ const summaries = [
539
+ ...present.map((s) => s.entry.pmtiles),
540
+ ...nested.map((cover) => ({
541
+ minZoom: cover.minzoom,
542
+ maxZoom: cover.maxzoom,
543
+ bounds: cover.bounds,
544
+ format: cover.format,
545
+ attribution: cover.attribution,
546
+ })),
547
+ ];
444
548
 
445
549
  const minzoom =
446
550
  resolved.stack.minzoom ??
@@ -453,8 +557,12 @@ export function stackCoverage(resolved) {
453
557
 
454
558
  let bounds = resolved.stack.bounds;
455
559
  if (!bounds && resolved.stack.boundsSource !== undefined) {
456
- bounds = present.find((s) => s.index === resolved.stack.boundsSource)?.entry
457
- ?.pmtiles?.bounds;
560
+ const at = resolved.sources.find(
561
+ (s) => s.index === resolved.stack.boundsSource,
562
+ );
563
+ bounds =
564
+ at?.entry?.pmtiles?.bounds ??
565
+ (at?.nested ? stackCoverage(at.nested).bounds : undefined);
458
566
  }
459
567
  if (!bounds && summaries.length) {
460
568
  const boxes = summaries.map((s) => s.bounds).filter(Array.isArray);
@@ -478,10 +586,13 @@ export function stackCoverage(resolved) {
478
586
  // A stack is a derived work of every source in it, and attribution is the
479
587
  // thing that reliably goes missing when tiles are combined. Joined rather
480
588
  // than dropped when the recipe does not state one.
589
+ // Pipes, not commas. These are almost always HTML links, and a comma
590
+ // between two anchors reads as part of the last one's text -- which is how
591
+ // MapLibre, Mapbox and OpenLayers all write a multi-source attribution.
481
592
  const attribution =
482
593
  resolved.stack.attribution ??
483
594
  [...new Set(summaries.map((s) => s.attribution).filter(Boolean))].join(
484
- ', ',
595
+ ' | ',
485
596
  ) ??
486
597
  undefined;
487
598
 
@@ -512,7 +623,12 @@ export function stackEtag(resolved, z, x, y) {
512
623
  const parts = [
513
624
  resolved.stack.id,
514
625
  stackRevision(resolved.stack),
515
- ...resolved.sources.map((s) => s.entry?.infoHash ?? '-'),
626
+ // A nested stack contributes its own revision and its own sources, or an
627
+ // edit one level down would serve from a cache the outer stack thinks is
628
+ // still good.
629
+ ...resolved.sources.map((s) =>
630
+ s.nested ? stackEtag(s.nested, z, x, y) : (s.entry?.infoHash ?? '-'),
631
+ ),
516
632
  z,
517
633
  x,
518
634
  y,
@@ -531,7 +647,9 @@ export function stackEtag(resolved, z, x, y) {
531
647
  * @returns {boolean} - True when nothing here can move.
532
648
  */
533
649
  export function isPinned(resolved) {
534
- return resolved.sources.every((s) => s.pinned);
650
+ return resolved.sources.every((s) =>
651
+ s.nested ? isPinned(s.nested) : s.pinned,
652
+ );
535
653
  }
536
654
 
537
655
  /**
@@ -686,6 +686,20 @@
686
686
  <div class="sub" id="bake-filename"></div>
687
687
  </div>
688
688
 
689
+ <div class="field">
690
+ <label for="bake-attribution">Attribution</label>
691
+ <input id="bake-attribution" placeholder="" />
692
+ <div class="sub">
693
+ Written into the archive's own metadata, so the credit travels with
694
+ the file rather than living in whatever style happened to load it.
695
+ Filled in from every source's attribution joined with
696
+ <code>|</code>, since a stack is a derived work of all of them and
697
+ this is the thing that reliably goes missing when tiles are
698
+ combined. The stack's own <code>attribution</code> wins where it
699
+ has one.
700
+ </div>
701
+ </div>
702
+
689
703
  <div class="field">
690
704
  <label for="bake-description">Description</label>
691
705
  <input id="bake-description" placeholder="" />
@@ -7532,7 +7546,7 @@ Every piece is hashed against the ` +
7532
7546
  const select = $('stack-add-source');
7533
7547
  const taken = new Set(
7534
7548
  (stackDraft?.sources ?? []).flatMap((source) =>
7535
- [source.category, source.archive].filter(Boolean),
7549
+ [source.category, source.archive, source.stack].filter(Boolean),
7536
7550
  ),
7537
7551
  );
7538
7552
  const groups = [];
@@ -7617,6 +7631,38 @@ Every piece is hashed against the ` +
7617
7631
  );
7618
7632
  }
7619
7633
 
7634
+ try {
7635
+ // A stack in place of an archive. The reason to want it is that a
7636
+ // base worked out once -- terrain over bathymetry, masked and faded
7637
+ // -- is a thing to reuse rather than retype, and a recipe that names
7638
+ // it follows every later correction to it.
7639
+ const { stacks: held } = await api('/api/stacks');
7640
+ const free = (held ?? []).filter(
7641
+ (one) =>
7642
+ !taken.has(one.id) &&
7643
+ one.id !== $('stack-id').value.trim() &&
7644
+ one.space === (stackDraft?.space ?? 'elevation'),
7645
+ );
7646
+ if (free.length) {
7647
+ groups.push(
7648
+ `<optgroup label="Stacks — a recipe used as one layer">${free
7649
+ .map((one) => {
7650
+ const sources = one.sources?.length ?? 0;
7651
+ return `<option value="stk:${escapeHtml(one.id)}">${escapeHtml(
7652
+ one.title ?? one.id,
7653
+ )} — ${sources} source${sources === 1 ? '' : 's'}</option>`;
7654
+ })
7655
+ .join('')}</optgroup>`,
7656
+ );
7657
+ }
7658
+ } catch (error) {
7659
+ groups.push(
7660
+ `<optgroup label="Stacks — could not load: ${escapeHtml(
7661
+ error.message,
7662
+ )}"></optgroup>`,
7663
+ );
7664
+ }
7665
+
7620
7666
  select.innerHTML = groups.length
7621
7667
  ? groups.join('')
7622
7668
  : '<option value="">nothing left to add</option>';
@@ -7696,6 +7742,11 @@ Every piece is hashed against the ` +
7696
7742
  ? '<span class="pill">wins</span>'
7697
7743
  : '';
7698
7744
 
7745
+ // A nested stack is merged as heights: it was never stored, so there
7746
+ // is no encoding to read it with and no channels to compare a colour
7747
+ // against. Those fields are left out rather than shown and refused on
7748
+ // save; everything that acts on heights stays.
7749
+ const nested = Boolean(source.stack);
7699
7750
  const perSpace = rgba
7700
7751
  ? `<label class="choice">Opacity
7701
7752
  <input type="number" min="0" max="1" step="0.05" style="width:5rem"
@@ -7714,7 +7765,14 @@ Every piece is hashed against the ` +
7714
7765
  .join('')}
7715
7766
  </select>
7716
7767
  </label>`
7717
- : `<label class="choice">Encoding
7768
+ : `${
7769
+ nested
7770
+ ? `<div class="sub" style="max-width:34rem">Merged as heights:
7771
+ <code>${escapeHtml(source.stack)}</code> is evaluated for
7772
+ this tile and handed straight to this merge, so there is
7773
+ no encoding to read it with. Its own output settings
7774
+ apply where it is served on its own.</div>`
7775
+ : `<label class="choice">Encoding
7718
7776
  <select data-stack-field="encoding" data-stack-index="${index}">
7719
7777
  <option value="mapbox"${
7720
7778
  (source.encoding ?? 'mapbox') === 'mapbox' ? ' selected' : ''
@@ -7768,6 +7826,7 @@ Every piece is hashed against the ` +
7768
7826
  <code>6553.6, 25.6, 0.1, 10000</code> and terrarium is
7769
7827
  <code>256, 1, 0.00390625, 32768</code>.</div>`
7770
7828
  : ''
7829
+ }`
7771
7830
  }
7772
7831
  <label class="choice">Shift by
7773
7832
  <input type="number" step="any" style="width:6rem"
@@ -7804,13 +7863,17 @@ Every piece is hashed against the ` +
7804
7863
  <div class="row">
7805
7864
  <span class="muted">${index}</span>
7806
7865
  <code>${escapeHtml(
7807
- source.category ?? shortHash(source.archive) ?? '',
7866
+ source.stack ?? source.category ?? shortHash(source.archive) ?? '',
7808
7867
  )}</code>
7809
7868
  <span class="sub" title="${
7810
7869
  source.archive
7811
7870
  ? 'Pinned to this build. It will not follow a rebuild, and breaks if the archive is removed.'
7812
- : 'Follows whichever build in this category is newest.'
7813
- }">${source.archive ? 'pinned' : 'category'}</span>
7871
+ : source.stack
7872
+ ? 'Another stack, merged as one layer. It follows every later change to that recipe, and to whatever it resolves to.'
7873
+ : 'Follows whichever build in this category is newest.'
7874
+ }">${
7875
+ source.archive ? 'pinned' : source.stack ? 'stack' : 'category'
7876
+ }</span>
7814
7877
  ${role}
7815
7878
  <span class="bar-gap"></span>
7816
7879
  <button type="button" data-stack-move="${index}" data-stack-dir="-1"
@@ -7821,11 +7884,15 @@ Every piece is hashed against the ` +
7821
7884
  </div>
7822
7885
  <div class="bar">
7823
7886
  ${perSpace}
7824
- <label class="choice">Mask colours
7825
- <input style="width:10rem" placeholder="#000000"
7826
- data-stack-field="maskColors" data-stack-index="${index}"
7827
- value="${escapeHtml((source.maskColors ?? []).join(', '))}" />
7828
- </label>
7887
+ ${
7888
+ nested
7889
+ ? ''
7890
+ : `<label class="choice">Mask colours
7891
+ <input style="width:10rem" placeholder="#000000"
7892
+ data-stack-field="maskColors" data-stack-index="${index}"
7893
+ value="${escapeHtml((source.maskColors ?? []).join(', '))}" />
7894
+ </label>`
7895
+ }
7829
7896
  <label class="choice"
7830
7897
  title="Clip this source to a shape, so only what is inside it contributes. Most archives are already clipped when they are built — this is for one you did not build and cannot rebuild.">
7831
7898
  Clip to
@@ -8032,7 +8099,11 @@ Every piece is hashed against the ` +
8032
8099
  // Appended, so the newest source is the one that covers the rest --
8033
8100
  // which is what somebody adding a more detailed layer means.
8034
8101
  stackDraft.sources.push(
8035
- kind === 'arc' ? { archive: name } : { category: name },
8102
+ kind === 'arc'
8103
+ ? { archive: name }
8104
+ : kind === 'stk'
8105
+ ? { stack: name }
8106
+ : { category: name },
8036
8107
  );
8037
8108
  renderStackDraft();
8038
8109
  fillStackSourceChoices();
@@ -8177,6 +8248,10 @@ Every piece is hashed against the ` +
8177
8248
  // how tiles are combined; an archive describes what it is, and only
8178
8249
  // the person exporting it knows that.
8179
8250
  $('bake-description').value = '';
8251
+ // Unlike the description, this is filled in: it is not a thing only
8252
+ // the person exporting knows, it is a fact about the sources, and an
8253
+ // archive published without it is the failure worth preventing.
8254
+ $('bake-attribution').value = stack?.attribution ?? '';
8180
8255
  $('bake-categories').value = (stack?.categories ?? []).join(', ');
8181
8256
  $('bake-output').textContent = bakeOutputSummary(stack);
8182
8257
  showBakeFilename();
@@ -8268,6 +8343,7 @@ Every piece is hashed against the ` +
8268
8343
  const name = $('bake-name').value.trim();
8269
8344
  const filename = $('bake-file').value.trim();
8270
8345
  const description = $('bake-description').value.trim();
8346
+ const attribution = $('bake-attribution').value.trim();
8271
8347
 
8272
8348
  try {
8273
8349
  await api(`/api/stacks/${encodeURIComponent(id)}/bake`, {
@@ -8278,6 +8354,7 @@ Every piece is hashed against the ` +
8278
8354
  ...(name ? { name } : {}),
8279
8355
  ...(filename ? { filename } : {}),
8280
8356
  ...(description ? { description } : {}),
8357
+ ...(attribution ? { attribution } : {}),
8281
8358
  },
8282
8359
  });
8283
8360
  } catch (error) {