pmtiles-swarm 0.99.1 → 0.100.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,42 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.100.0
11
+ ### ✨ Features and improvements
12
+ - **A nested stack takes `maskColors`, and fades into what is under it.** Two things a stack could
13
+ not do that every other kind of source could, both for the same reason: it is evaluated rather
14
+ than stored, so it has no bytes. That is a fact about storage, not about meaning, and it was
15
+ showing up in the recipe as a source with a different set of options.
16
+
17
+ A colour is now decoded into the height it names, under the encoding the inner stack packs its
18
+ own output in, and masked as a height — through `decodeHeights` rather than arithmetic written a
19
+ second time. `maskColors: ["#0186a0"]` and `maskValues: [0]` say the same thing to a
20
+ mapbox-encoded stack, so a source keeps its mask when it is swapped between an archive and a
21
+ stack. `encoding`, `baseVal`, `interval` and the custom factors stay refused: those describe how
22
+ to unpack channels into a number, and the number arrived already made.
23
+
24
+ Feathering a nested source now works rather than silently doing nothing. A ramp is measured in
25
+ pixels and the pixels that say how far a hole reaches are partly in the next tile — which for an
26
+ archive means reading its parent and for a stack means evaluating it again, which was never
27
+ implemented. Validation also stopped asking a nested source for a mask before it would accept a
28
+ feather: its holes are already an edge, and the mask being demanded would have made a second one.
29
+
30
+ The representation is unchanged. A nested stack is still merged as heights, because a hole is
31
+ `NaN` and no encoding has one — encoding it would turn every hole into a sentinel the recipe
32
+ above had to mask back out, which is the problem nesting avoids by construction.
33
+
34
+ ### 🐞 Bug fixes
35
+
36
+ ## 0.99.2
37
+ ### ✨ Features and improvements
38
+
39
+ ### 🐞 Bug fixes
40
+ - **The stack editor named a pinned archive by its infohash.** The list of saved stacks was fixed in
41
+ 0.98.3, but the editor has its own row and kept showing `33394361a935…` — where the menu that
42
+ offered the archive a moment earlier had shown its filename. The editor works on the recipe, and
43
+ a pinned source in a recipe is an infohash and nothing else, so the row had nothing else to
44
+ print. It remembers the names the source menu already fetched now, and the infohash is on hover.
45
+
10
46
  ## 0.99.1
11
47
  ### ✨ Features and improvements
12
48
 
@@ -55,6 +55,7 @@ of its parts.
55
55
  - [What a mask has to match](#what-a-mask-has-to-match)
56
56
  - [Feathering a seam](#feathering-a-seam)
57
57
  - [A stack as a source](#a-stack-as-a-source)
58
+ - [A colour is a height said another way](#a-colour-is-a-height-said-another-way)
58
59
  - [A source read straight from a URL](#a-source-read-straight-from-a-url)
59
60
  - [Importing a list of URLs](#importing-a-list-of-urls)
60
61
  - [Exporting on a schedule](#exporting-on-a-schedule)
@@ -1539,6 +1540,27 @@ height mask has to round; a stack that was never stored has no such channels to
1539
1540
  compare, and masking the heights it decoded to would be a different operation
1540
1541
  wearing the same name.
1541
1542
 
1543
+ ### A colour is a height said another way
1544
+
1545
+ `maskColors` compares the bytes a source was stored as, and a nested stack has
1546
+ none — it is evaluated, so what arrives is already metres. Refusing the field
1547
+ there would leave one kind of source unable to say a thing every other kind can,
1548
+ for a reason that is about storage rather than about meaning.
1549
+
1550
+ So the colour is decoded into the height it names, under the encoding the inner
1551
+ stack packs its own output in, and masked as a height. Through `decodeHeights`
1552
+ rather than arithmetic written a second time: it is the same question the merge
1553
+ asks of every pixel, and a second copy of it is a second thing to keep in step
1554
+ with `terrarium` and the custom factors.
1555
+
1556
+ `maskColors: ["#0186a0"]` and `maskValues: [0]` therefore say the same thing to
1557
+ a mapbox-encoded stack, which is what lets a source keep its mask when it is
1558
+ swapped between an archive and a stack.
1559
+
1560
+ What stays refused is the rest of that family — `encoding`, `baseVal`,
1561
+ `interval` and the custom factors. Those describe how to unpack channels into a
1562
+ number, and there are no channels: the number arrived already made.
1563
+
1542
1564
  ### Nothing passes through
1543
1565
 
1544
1566
  The short-circuit that hands back a source's own bytes cannot apply: there are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.99.1",
3
+ "version": "0.100.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/elevation.js CHANGED
@@ -187,6 +187,42 @@ export function rangesOf(maskRange) {
187
187
  return out;
188
188
  }
189
189
 
190
+ /**
191
+ * The heights a list of colours stands for, under one encoding.
192
+ *
193
+ * `maskColors` compares the bytes a source was stored as, which a nested stack
194
+ * has none of: it is evaluated, so what arrives is already metres. Rather than
195
+ * refuse the field there -- leaving one kind of source unable to say a thing
196
+ * every other kind can -- the colour is decoded into the height it names, and
197
+ * the mask is applied as a height like any other.
198
+ *
199
+ * Through `decodeHeights` rather than arithmetic written again here. It is the
200
+ * same question the merge asks of every pixel, and a second implementation of
201
+ * it is a second thing to keep in step with `terrarium` and the custom factors.
202
+ * @param {Array<string|number[]>} [colours] - What the recipe named.
203
+ * @param {object} [encoding] - `encoding`, and the factors a custom one needs.
204
+ * @returns {number[]|undefined} - Those heights, or undefined for no colours.
205
+ */
206
+ export function heightsForColors(colours, encoding = {}) {
207
+ const packed = (colours ?? [])
208
+ .map((colour) => parseColor(colour))
209
+ .filter((value) => value !== null);
210
+ if (!packed.length) return undefined;
211
+
212
+ const data = new Uint8Array(packed.length * 3);
213
+ packed.forEach((value, i) => {
214
+ data[i * 3] = (value >> 16) & 255;
215
+ data[i * 3 + 1] = (value >> 8) & 255;
216
+ data[i * 3 + 2] = value & 255;
217
+ });
218
+ return [
219
+ ...decodeHeights(
220
+ { data, channels: 3, width: packed.length, height: 1 },
221
+ encoding,
222
+ ),
223
+ ];
224
+ }
225
+
190
226
  /**
191
227
  * Blanks every height inside a band.
192
228
  *
@@ -719,6 +755,11 @@ export function mergeElevation(contributions, options) {
719
755
  maskHeights(heights, source.maskValues);
720
756
  if (contribution.raster) {
721
757
  maskColors(heights, contribution.raster, source.maskColors);
758
+ } else {
759
+ // A nested stack has no bytes to compare a colour against, so the
760
+ // colours it named were decoded into the heights they stand for before
761
+ // this. Same field, same meaning, asked of what there is.
762
+ maskHeights(heights, contribution.colorHeights);
722
763
  }
723
764
  // A band as well as, not instead of: a source may have a sentinel it names
724
765
  // exactly and a range of ground it does not want either.
package/src/stack-tile.js CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  decodeHeights,
4
4
  encodeHeights,
5
5
  fillNodata,
6
+ heightsForColors,
6
7
  maskColors,
7
8
  maskHeights,
8
9
  maskRanges,
@@ -504,6 +505,19 @@ async function gather({
504
505
  source: read.source.source,
505
506
  heights: inner.heights,
506
507
  width: inner.width,
508
+ // Kept so a feather can measure its ramp past this tile's border. The
509
+ // heights above are this tile only; where the hole continues is a
510
+ // question only the parent can answer, and for a stack that means
511
+ // evaluating it again rather than reading bytes.
512
+ nested: read.source.nested,
513
+ // A colour named against something that was never stored as pixels.
514
+ // Read as the height it stands for, under the encoding the inner
515
+ // stack packs its own output in -- which is the encoding those bytes
516
+ // would have been, had there been any.
517
+ colorHeights: heightsForColors(
518
+ read.source.source?.maskColors,
519
+ read.source.nested?.stack?.output ?? {},
520
+ ),
507
521
  });
508
522
  continue;
509
523
  }
@@ -598,7 +612,17 @@ async function gather({
598
612
  });
599
613
  }
600
614
 
601
- await readMaskEdges({ contributions, z, x, y, size, tiles, codec, signal });
615
+ await readMaskEdges({
616
+ contributions,
617
+ z,
618
+ x,
619
+ y,
620
+ size,
621
+ tiles,
622
+ codec,
623
+ signal,
624
+ cutlines,
625
+ });
602
626
  return { contributors, contributions };
603
627
  }
604
628
 
@@ -640,6 +664,7 @@ async function readMaskEdges({
640
664
  tiles,
641
665
  codec,
642
666
  signal,
667
+ cutlines,
643
668
  }) {
644
669
  if (!codec) return;
645
670
  await Promise.all(
@@ -648,9 +673,13 @@ async function readMaskEdges({
648
673
  const recipe = contribution.source ?? {};
649
674
  // On the source's own grid rather than the output's, because that is
650
675
  // what the ramp will be measured on.
651
- const grid = contribution.raster?.width ?? size;
676
+ const grid = contribution.raster?.width ?? contribution.width ?? size;
652
677
  const feather = featherFor(recipe, { z, y, size: grid });
653
- if (!feather || !masksAnything(recipe)) return;
678
+ if (!feather) return;
679
+ // A nested stack arrives with its holes already in it -- they are where
680
+ // its own sources stopped -- so it has an edge whether or not the recipe
681
+ // above it masks anything. Everything else needs a mask to have made one.
682
+ if (!contribution.nested && !masksAnything(recipe)) return;
654
683
  const layout = parentsFor({ z, x, y }, grid, feather);
655
684
  if (!layout) return;
656
685
 
@@ -658,6 +687,43 @@ async function readMaskEdges({
658
687
  let parentSize = grid;
659
688
  await Promise.all(
660
689
  layout.tiles.map(async (parent) => {
690
+ // A nested stack has no bytes to read: what its holes are is the
691
+ // answer to evaluating it, so it is evaluated. One level up and at
692
+ // most four tiles, and only for a source that actually fades --
693
+ // which is what keeps this from being the whole recipe again on
694
+ // every tile.
695
+ if (contribution.nested) {
696
+ const inner = await stackHeights({
697
+ resolved: contribution.nested,
698
+ z: parent.z,
699
+ x: parent.x,
700
+ y: parent.y,
701
+ tiles,
702
+ codec,
703
+ signal,
704
+ size: grid,
705
+ cutlines,
706
+ }).catch(() => null);
707
+ if (!inner?.heights) return;
708
+
709
+ // The masks the recipe above applies to it, on a copy: the ramp
710
+ // has to be measured against the same idea of a hole the merge
711
+ // will use, and stackHeights hands back an array we do not own.
712
+ // Colours included, as the heights they were read into.
713
+ const heights = Float32Array.from(inner.heights);
714
+ maskHeights(heights, recipe.maskValues);
715
+ maskHeights(heights, contribution.colorHeights);
716
+ maskRanges(heights, recipe.maskRange);
717
+
718
+ const flags = new Uint8Array(heights.length);
719
+ for (let i = 0; i < flags.length; i += 1) {
720
+ flags[i] = Number.isNaN(heights[i]) ? 0 : 1;
721
+ }
722
+ parentSize = inner.width;
723
+ known.set(`${parent.column},${parent.row}`, flags);
724
+ return;
725
+ }
726
+
661
727
  const tile = await (
662
728
  contribution.remote
663
729
  ? tiles.getRemoteTile(
package/src/stacks.js CHANGED
@@ -132,7 +132,6 @@ const NOT_ON_A_NESTED_SOURCE = Object.freeze([
132
132
  'greenFactor',
133
133
  'blueFactor',
134
134
  'baseShift',
135
- 'maskColors',
136
135
  ]);
137
136
 
138
137
  /**
@@ -292,7 +291,13 @@ export function validateStack(stack) {
292
291
  // a source that blends into what is under it, and it does nothing at all.
293
292
  // A mask is an edge as much as a cutline is -- the hole it leaves is where
294
293
  // most of these recipes actually stop.
294
+ //
295
+ // A nested stack always has one. Its holes are where its own sources
296
+ // stopped, and it hands them up unfilled precisely so the recipe above can
297
+ // show through -- so requiring a mask here asked for a second edge on top
298
+ // of the one it already brought.
295
299
  const fades =
300
+ source?.stack ||
296
301
  source?.cutline ||
297
302
  source?.bounds ||
298
303
  source?.maskValues?.length ||
@@ -8152,6 +8152,25 @@ Every piece is hashed against the ` +
8152
8152
  * one over an infohash pins to a build that will eventually be retired.
8153
8153
  * @returns {Promise<void>} - Resolves once filled.
8154
8154
  */
8155
+ /**
8156
+ * Archive names by infohash, learned while filling the source menu.
8157
+ *
8158
+ * The editor works on the recipe, and a pinned source in a recipe is an
8159
+ * infohash and nothing else -- so a row drawn from the draft alone can
8160
+ * only show forty characters of hex, which is what it used to do. The
8161
+ * menu that offered the archive had its name a moment earlier; this is
8162
+ * where that is kept so the row can use it.
8163
+ */
8164
+ const archiveNames = new Map();
8165
+
8166
+ /**
8167
+ * What to call a pinned archive in the editor.
8168
+ * @param {string} infoHash - The archive the recipe names.
8169
+ * @returns {string} - Its name, or the short hash where none is known.
8170
+ */
8171
+ const archiveLabel = (infoHash) =>
8172
+ archiveNames.get(infoHash) ?? shortHash(infoHash) ?? '';
8173
+
8155
8174
  const fillStackSourceChoices = async () => {
8156
8175
  const select = $('stack-add-source');
8157
8176
  const taken = new Set(
@@ -8212,6 +8231,14 @@ Every piece is hashed against the ` +
8212
8231
  // missing from a menu the operator is looking at. This is the same
8213
8232
  // endpoint the Archives view reads, and it answers a bare array.
8214
8233
  const held = await api('/api/torrents');
8234
+ // Every archive, before the menu is filtered down. The one the
8235
+ // editor most needs a name for is the one already in the draft,
8236
+ // which is exactly what `taken` removes below.
8237
+ for (const entry of held ?? []) {
8238
+ if (entry.infoHash && entry.name) {
8239
+ archiveNames.set(entry.infoHash, entry.name);
8240
+ }
8241
+ }
8215
8242
  // The same rule the tile route enforces, because a stack source is
8216
8243
  // read through exactly that path: a complete MBTiles belongs here,
8217
8244
  // and one still arriving does not -- a stack naming it would answer
@@ -8549,12 +8576,16 @@ Every piece is hashed against the ` +
8549
8576
  <div class="card tight">
8550
8577
  <div class="row">
8551
8578
  <span class="muted">${index}</span>
8552
- <code>${escapeHtml(
8579
+ <code${
8580
+ source.archive
8581
+ ? ` title="${escapeHtml(source.archive)}"`
8582
+ : ''
8583
+ }>${escapeHtml(
8553
8584
  remote
8554
8585
  ? (source.url || 'an address').replace(/^https?:\/\//, '')
8555
8586
  : (source.stack ??
8556
8587
  source.category ??
8557
- shortHash(source.archive) ??
8588
+ (source.archive ? archiveLabel(source.archive) : '') ??
8558
8589
  ''),
8559
8590
  )}</code>
8560
8591
  <span class="sub" title="${