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 +39 -0
- package/docs/tile-stacks.md +99 -7
- package/package.json +1 -1
- package/src/api.js +4 -0
- package/src/bake-jobs.js +10 -2
- package/src/elevation.js +14 -10
- package/src/index.js +1 -0
- package/src/stack-tile.js +168 -26
- package/src/stacks.js +134 -16
- package/src/web/index.html +88 -11
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..._
|
package/docs/tile-stacks.md
CHANGED
|
@@ -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,
|
|
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,
|
|
256
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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) =>
|
|
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({
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
457
|
-
|
|
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
|
-
|
|
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) =>
|
|
650
|
+
return resolved.sources.every((s) =>
|
|
651
|
+
s.nested ? isPinned(s.nested) : s.pinned,
|
|
652
|
+
);
|
|
535
653
|
}
|
|
536
654
|
|
|
537
655
|
/**
|
package/src/web/index.html
CHANGED
|
@@ -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
|
-
:
|
|
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
|
-
:
|
|
7813
|
-
|
|
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
|
-
|
|
7825
|
-
|
|
7826
|
-
|
|
7827
|
-
|
|
7828
|
-
|
|
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'
|
|
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) {
|