pmtiles-swarm 0.37.2 → 0.38.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 +45 -0
- package/docs/serving-tiles.md +33 -0
- package/package.json +1 -1
- package/src/library.js +49 -15
- package/src/web/index.html +499 -8
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,51 @@
|
|
|
7
7
|
### 🐞 Bug fixes
|
|
8
8
|
- _...Add new stuff here..._
|
|
9
9
|
|
|
10
|
+
## 0.38.0
|
|
11
|
+
### ✨ Features and improvements
|
|
12
|
+
- **Warm region now asks which region.** The API has taken `bounds`, a zoom range, `maxTiles` and
|
|
13
|
+
`concurrency` since it was written; the console posted `{}` and took every default, so the button
|
|
14
|
+
warmed the archive's whole extent to a zoom chosen for it and there was no way from the interface
|
|
15
|
+
to ask for a city.
|
|
16
|
+
|
|
17
|
+
It now opens a dialog. The four edges are number fields, so a bounding box from anywhere else
|
|
18
|
+
pastes straight in and edges given the wrong way round are read as a box rather than refused;
|
|
19
|
+
**Pick on a map** opens a map beside them for drawing the area instead — drag to draw, alt-drag to
|
|
20
|
+
move, or take the whole view. Whichever is used, the numbers are what gets sent.
|
|
21
|
+
|
|
22
|
+
It says how many tiles the choice comes to before anything starts, counted by the same arithmetic
|
|
23
|
+
the run uses — which is the figure worth having, since each zoom level is four times the one
|
|
24
|
+
below it — and warns when the area exceeds the ceiling instead of letting the job stop there
|
|
25
|
+
quietly.
|
|
26
|
+
|
|
27
|
+
The map draws the archive itself, served by this node, rather than a remote basemap — which a node
|
|
28
|
+
with no route to the internet cannot fetch, leaving a box floating over a tile-loading error.
|
|
29
|
+
Drawing the archive also shows where its data actually is, which a generic basemap cannot: an
|
|
30
|
+
archive with a hole over half a country looks like one. MapLibre is imported only when the map is
|
|
31
|
+
opened, so the console does not carry a mapping library for a button most sessions never press,
|
|
32
|
+
and a node without the vendor bundle keeps the number fields.
|
|
33
|
+
|
|
34
|
+
### 🐞 Bug fixes
|
|
35
|
+
|
|
36
|
+
## 0.37.3
|
|
37
|
+
### ✨ Features and improvements
|
|
38
|
+
|
|
39
|
+
### 🐞 Bug fixes
|
|
40
|
+
- **An archive fetched from a URL is now hashed the same way one already on disk is.** `creator` was
|
|
41
|
+
passed on the local path and nowhere else, so every archive a schedule ever built was hashed
|
|
42
|
+
inside this process — the one serving tiles and the console — rather than in the sidecar's
|
|
43
|
+
one-shot hasher. Four things followed from that one omission: the hash competed with serving,
|
|
44
|
+
it could not be cancelled (`create-torrent` takes no signal, so the add's AbortController reached
|
|
45
|
+
the download and stopped there), it reported nothing while it ran, and it produced a v1 torrent
|
|
46
|
+
rather than a hybrid. An archive arriving from a feed got a lesser torrent, built the slower way,
|
|
47
|
+
than the same file added by path.
|
|
48
|
+
- **A fetched archive now says when it has stopped downloading and started hashing.** The remote
|
|
49
|
+
add's progress callback dropped the `phase` the hasher reports, and its entry carried no phase at
|
|
50
|
+
all, so `runningAdds()` called it `fetching` from beginning to end. With the byte counts equal at
|
|
51
|
+
that point, the row sat at 100% "fetching" for the whole hash — which reads as a transfer that
|
|
52
|
+
completed and then hung, and was reported as exactly that. The bar now hands over to the hash and
|
|
53
|
+
fills again as pieces are read.
|
|
54
|
+
|
|
10
55
|
## 0.37.2
|
|
11
56
|
### ✨ Features and improvements
|
|
12
57
|
|
package/docs/serving-tiles.md
CHANGED
|
@@ -598,6 +598,39 @@ rather than grinding through the region to prove the archive is unreadable.
|
|
|
598
598
|
in flight (4 by default). Raising concurrency helps when the bottleneck is swarm
|
|
599
599
|
latency rather than bandwidth.
|
|
600
600
|
|
|
601
|
+
### From the console
|
|
602
|
+
|
|
603
|
+
**Warm region** on an archive opens the same request with the choices visible.
|
|
604
|
+
|
|
605
|
+
The four edges are number fields, so a bounding box from anywhere else pastes
|
|
606
|
+
straight in, and edges given the wrong way round are read as a box rather than
|
|
607
|
+
refused. **Pick on a map** opens a map beside them if you would rather draw the
|
|
608
|
+
area: drag to draw, alt-drag to move the map, **Use the whole view** to take
|
|
609
|
+
the current frame. Whichever you use, the numbers are what gets sent.
|
|
610
|
+
|
|
611
|
+
It tells you how many tiles the choice comes to before you start, counted the
|
|
612
|
+
same way the run counts them — which is the number worth seeing, because every
|
|
613
|
+
zoom level is four times the one below it. If the area exceeds `maxTiles`, it
|
|
614
|
+
says so rather than letting the job stop silently at the ceiling.
|
|
615
|
+
|
|
616
|
+
**The map draws the archive itself**, served by this node. Not a remote
|
|
617
|
+
basemap: a node may have no route to the internet, and a box floating over a
|
|
618
|
+
tile-loading error is worse than no map at all. Drawing the archive also shows
|
|
619
|
+
where its data actually is, which a generic basemap cannot — an archive with a
|
|
620
|
+
hole over half a country looks like one, and warming that area would be a
|
|
621
|
+
wasted job.
|
|
622
|
+
|
|
623
|
+
Two consequences worth knowing. Drawing reads tiles through the same path a
|
|
624
|
+
visitor would, so on a cache-mode archive it pulls a few pieces from the swarm
|
|
625
|
+
— at the zooms you pick an area at that is a handful, and they are tiles the
|
|
626
|
+
warm was about to read anyway. And a vector archive that has not yet reported
|
|
627
|
+
its layer list cannot be drawn, because a vector style needs `source-layer`
|
|
628
|
+
names; the dialog says so and leaves the number fields working.
|
|
629
|
+
|
|
630
|
+
MapLibre is loaded only when the map is opened, so the console does not carry a
|
|
631
|
+
mapping library for a button most sessions never press. If it is not installed
|
|
632
|
+
at all, the map is dropped and the number fields remain.
|
|
633
|
+
|
|
601
634
|
**Warming a mirror node does nothing useful** — it already holds everything and
|
|
602
635
|
reads its local file. The endpoint still works; it just finishes almost
|
|
603
636
|
immediately.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.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/library.js
CHANGED
|
@@ -311,20 +311,7 @@ export class Library {
|
|
|
311
311
|
comment: options.comment,
|
|
312
312
|
md5: options.md5 ?? this.#config.md5,
|
|
313
313
|
signal: controller.signal,
|
|
314
|
-
|
|
315
|
-
// labels it in bytes, and a piece count means nothing beside a download
|
|
316
|
-
// measured in gigabytes — so it is converted here, where the file size
|
|
317
|
-
// is known, rather than teaching the console a second unit. Accurate to
|
|
318
|
-
// within one piece, which at 4 MiB against a planet archive is four
|
|
319
|
-
// parts in a million.
|
|
320
|
-
onHashProgress: ({ piece, pieces }) => {
|
|
321
|
-
const state = this.#running.get(requested);
|
|
322
|
-
if (!state || !pieces || !size) return;
|
|
323
|
-
state.received = Math.min(
|
|
324
|
-
size,
|
|
325
|
-
Math.round(((piece + 1) / pieces) * size),
|
|
326
|
-
);
|
|
327
|
-
},
|
|
314
|
+
onHashProgress: this.#hashProgress(requested),
|
|
328
315
|
});
|
|
329
316
|
|
|
330
317
|
return await this.#register(created, {
|
|
@@ -352,6 +339,28 @@ export class Library {
|
|
|
352
339
|
}
|
|
353
340
|
}
|
|
354
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Reports a hash's progress as bytes, which is what the console draws.
|
|
344
|
+
*
|
|
345
|
+
* Pieces are what a hasher counts; the adds list has one bar and labels it in
|
|
346
|
+
* bytes, and a piece count means nothing beside a download measured in
|
|
347
|
+
* gigabytes. Converted here, where the size is known, rather than teaching
|
|
348
|
+
* the console a second unit. Accurate to within one piece — at 4 MiB against
|
|
349
|
+
* a planet archive, four parts in a million.
|
|
350
|
+
* @param {string} key - How the add is registered in #running.
|
|
351
|
+
* @returns {Function} - An onHashProgress callback.
|
|
352
|
+
*/
|
|
353
|
+
#hashProgress(key) {
|
|
354
|
+
return ({ piece, pieces }) => {
|
|
355
|
+
const state = this.#running.get(key);
|
|
356
|
+
if (!state || !pieces || !state.total) return;
|
|
357
|
+
state.received = Math.min(
|
|
358
|
+
state.total,
|
|
359
|
+
Math.round(((piece + 1) / pieces) * state.total),
|
|
360
|
+
);
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
355
364
|
/**
|
|
356
365
|
* Turns a caller's choice of location into a directory, and checks it.
|
|
357
366
|
*
|
|
@@ -884,8 +893,33 @@ export class Library {
|
|
|
884
893
|
fetchAttempts: this.#config.fetchAttempts,
|
|
885
894
|
fetchRetryDelayMs: (this.#config.fetchRetrySeconds ?? 5) * 1000,
|
|
886
895
|
signal: controller.signal,
|
|
887
|
-
|
|
896
|
+
// The same hasher a local add gets, which this route never asked for.
|
|
897
|
+
//
|
|
898
|
+
// Without it the hash ran in this process -- the one serving tiles and
|
|
899
|
+
// the console -- for every archive a schedule ever built, could not be
|
|
900
|
+
// cancelled because create-torrent takes no signal, reported nothing
|
|
901
|
+
// while it ran, and produced a v1 torrent rather than a hybrid. So an
|
|
902
|
+
// archive arriving from a feed got a lesser torrent, built the slower
|
|
903
|
+
// way, than the same file added by path.
|
|
904
|
+
creator: this.#creator(),
|
|
905
|
+
onHashProgress: this.#hashProgress(url),
|
|
906
|
+
onProgress: ({ phase, received, total, done }) => {
|
|
888
907
|
const state = this.#running.get(url);
|
|
908
|
+
if (phase === 'hashing') {
|
|
909
|
+
// The transfer is over and a different job has started, measured
|
|
910
|
+
// in the same units. Handing the bar over rather than leaving it
|
|
911
|
+
// at 100%: a finished download that sits at 100% for the length of
|
|
912
|
+
// a 128 GiB hash reads as one that completed and then hung, which
|
|
913
|
+
// is how it was reported.
|
|
914
|
+
if (state) {
|
|
915
|
+
Object.assign(state, {
|
|
916
|
+
phase: 'hashing',
|
|
917
|
+
received: undefined,
|
|
918
|
+
total,
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
889
923
|
if (state) Object.assign(state, { received, total });
|
|
890
924
|
const pct = total ? ((received / total) * 100).toFixed(1) : '?';
|
|
891
925
|
console.log(
|
package/src/web/index.html
CHANGED
|
@@ -306,6 +306,12 @@
|
|
|
306
306
|
background: var(--bg);
|
|
307
307
|
}
|
|
308
308
|
.piecebar.thin { height: 14px; }
|
|
309
|
+
#warm-map { height: 320px; border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
|
|
310
|
+
#warm-map .maplibregl-canvas { cursor: crosshair; }
|
|
311
|
+
.warmgrid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.6rem; margin: 0.6rem 0; }
|
|
312
|
+
.warmgrid > label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.8rem; color: var(--muted); }
|
|
313
|
+
.warmgrid > label > input { width: 100%; }
|
|
314
|
+
@media (max-width: 620px) { .warmgrid { grid-template-columns: repeat(2, 1fr); } }
|
|
309
315
|
.piecerow { display: grid; grid-template-columns: 7rem 1fr 4rem; gap: 0.6rem; align-items: center; margin-bottom: 0.5rem; }
|
|
310
316
|
.piecerow > .label { font-size: 0.8rem; color: var(--muted); }
|
|
311
317
|
.piecerow > .value { font-size: 0.8rem; color: var(--muted); text-align: right; }
|
|
@@ -456,6 +462,54 @@
|
|
|
456
462
|
<a href="https://github.com/TechIdiots-LLC/pmtiles-swarm" rel="noreferrer">source</a>
|
|
457
463
|
</footer>
|
|
458
464
|
|
|
465
|
+
<dialog id="warm-dialog">
|
|
466
|
+
<form method="dialog" id="warm-form">
|
|
467
|
+
<h2>Warm region</h2>
|
|
468
|
+
<div class="sub" style="margin-bottom:0.8rem">
|
|
469
|
+
Reads tiles ahead of anyone asking for them, so the first visitor to
|
|
470
|
+
an area does not wait on the swarm for it. Pick the area and the zoom
|
|
471
|
+
range: the tile count quadruples with every level, so the whole world
|
|
472
|
+
to deep zoom is millions of reads and almost never what is wanted.
|
|
473
|
+
</div>
|
|
474
|
+
|
|
475
|
+
<div class="warmgrid">
|
|
476
|
+
<label>West<input id="warm-west" type="number" step="any" min="-180" max="180" required /></label>
|
|
477
|
+
<label>South<input id="warm-south" type="number" step="any" min="-85.05" max="85.05" required /></label>
|
|
478
|
+
<label>East<input id="warm-east" type="number" step="any" min="-180" max="180" required /></label>
|
|
479
|
+
<label>North<input id="warm-north" type="number" step="any" min="-85.05" max="85.05" required /></label>
|
|
480
|
+
</div>
|
|
481
|
+
|
|
482
|
+
<div class="bar" style="margin:0.5rem 0">
|
|
483
|
+
<button type="button" id="warm-pick">Pick on a map</button>
|
|
484
|
+
<button type="button" id="warm-use-archive">The whole archive</button>
|
|
485
|
+
</div>
|
|
486
|
+
|
|
487
|
+
<div id="warm-map-wrap" hidden>
|
|
488
|
+
<div id="warm-map"></div>
|
|
489
|
+
<div class="sub" id="warm-map-note">
|
|
490
|
+
Drag to draw an area; alt-drag to move the map.
|
|
491
|
+
</div>
|
|
492
|
+
<div class="bar" style="margin:0.5rem 0">
|
|
493
|
+
<button type="button" id="warm-use-view">Use the whole view</button>
|
|
494
|
+
</div>
|
|
495
|
+
</div>
|
|
496
|
+
|
|
497
|
+
<div class="warmgrid">
|
|
498
|
+
<label>Zoom from<input id="warm-minz" type="number" step="1" min="0" max="22" required /></label>
|
|
499
|
+
<label>Zoom to<input id="warm-maxz" type="number" step="1" min="0" max="22" required /></label>
|
|
500
|
+
<label>At most<input id="warm-max" type="number" step="1" min="1" required /></label>
|
|
501
|
+
<label>At a time<input id="warm-conc" type="number" step="1" min="1" max="64" /></label>
|
|
502
|
+
</div>
|
|
503
|
+
|
|
504
|
+
<div class="sub" id="warm-estimate"></div>
|
|
505
|
+
<div class="sub" id="warm-error" style="color:var(--danger)"></div>
|
|
506
|
+
<menu>
|
|
507
|
+
<button value="cancel">Cancel</button>
|
|
508
|
+
<button id="warm-go" value="default">Start warming</button>
|
|
509
|
+
</menu>
|
|
510
|
+
</form>
|
|
511
|
+
</dialog>
|
|
512
|
+
|
|
459
513
|
<dialog id="move-dialog">
|
|
460
514
|
<form method="dialog" id="move-form">
|
|
461
515
|
<h2>Set location</h2>
|
|
@@ -1911,14 +1965,7 @@
|
|
|
1911
1965
|
$('copy-hash').onclick = () => copy(entry.infoHash, 'Infohash');
|
|
1912
1966
|
|
|
1913
1967
|
if (servable) {
|
|
1914
|
-
$('warm').onclick =
|
|
1915
|
-
try {
|
|
1916
|
-
await api(`/api/torrents/${infoHash}/warm`, { method: 'POST', body: {} });
|
|
1917
|
-
toast('warming started');
|
|
1918
|
-
} catch (error) {
|
|
1919
|
-
toast(error.message);
|
|
1920
|
-
}
|
|
1921
|
-
};
|
|
1968
|
+
$('warm').onclick = () => openWarmDialog(entry);
|
|
1922
1969
|
}
|
|
1923
1970
|
|
|
1924
1971
|
$('set-location').onclick = async () => {
|
|
@@ -2211,6 +2258,450 @@ Every piece is hashed against the ` +
|
|
|
2211
2258
|
`<div class="sub">zoom ${z} · served through this node</div>`;
|
|
2212
2259
|
}
|
|
2213
2260
|
|
|
2261
|
+
// ── Warming ───────────────────────────────────────────────────────────
|
|
2262
|
+
|
|
2263
|
+
// The same arithmetic warm.js walks, without walking it. A count is all
|
|
2264
|
+
// that is wanted here, and the enumeration is one tile at a time — for a
|
|
2265
|
+
// continent at zoom 12 that is millions of objects to arrive at a
|
|
2266
|
+
// number. Kept deliberately beside the server's version: if the two ever
|
|
2267
|
+
// disagree, the dialog is lying about what the button will do.
|
|
2268
|
+
const lonToTileX = (lon, z) => Math.floor(((lon + 180) / 360) * 2 ** z);
|
|
2269
|
+
const latToTileY = (lat, z) => {
|
|
2270
|
+
const clamped = Math.max(-85.05112878, Math.min(85.05112878, lat));
|
|
2271
|
+
const radians = (clamped * Math.PI) / 180;
|
|
2272
|
+
return Math.floor(
|
|
2273
|
+
((1 - Math.log(Math.tan(radians) + 1 / Math.cos(radians)) / Math.PI) /
|
|
2274
|
+
2) *
|
|
2275
|
+
2 ** z,
|
|
2276
|
+
);
|
|
2277
|
+
};
|
|
2278
|
+
|
|
2279
|
+
/**
|
|
2280
|
+
* How many tiles a warm would read, capped the way the server caps it.
|
|
2281
|
+
* @param {number[]} bounds - [west, south, east, north].
|
|
2282
|
+
* @param {number} minZoom - First zoom level.
|
|
2283
|
+
* @param {number} maxZoom - Last zoom level.
|
|
2284
|
+
* @param {number} limit - The ceiling; counting stops there.
|
|
2285
|
+
* @returns {{count: number, capped: boolean}} - The estimate.
|
|
2286
|
+
*/
|
|
2287
|
+
function countTiles(bounds, minZoom, maxZoom, limit) {
|
|
2288
|
+
const [west, south, east, north] = bounds;
|
|
2289
|
+
let total = 0;
|
|
2290
|
+
for (let z = minZoom; z <= maxZoom; z++) {
|
|
2291
|
+
const span = 2 ** z;
|
|
2292
|
+
const clamp = (v) => Math.max(0, Math.min(span - 1, v));
|
|
2293
|
+
const x0 = clamp(lonToTileX(west, z));
|
|
2294
|
+
const x1 = clamp(lonToTileX(east, z));
|
|
2295
|
+
const y0 = clamp(latToTileY(north, z));
|
|
2296
|
+
const y1 = clamp(latToTileY(south, z));
|
|
2297
|
+
total += (x1 - x0 + 1) * (y1 - y0 + 1);
|
|
2298
|
+
if (total >= limit) return { count: limit, capped: true };
|
|
2299
|
+
}
|
|
2300
|
+
return { count: total, capped: false };
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
/**
|
|
2304
|
+
* The box as a GeoJSON feature, for drawing it on the map.
|
|
2305
|
+
* @param {number[]} box - [west, south, east, north].
|
|
2306
|
+
* @returns {object} - A polygon feature.
|
|
2307
|
+
*/
|
|
2308
|
+
const boxFeature = ([w, s, e, n]) => ({
|
|
2309
|
+
type: 'Feature',
|
|
2310
|
+
properties: {},
|
|
2311
|
+
geometry: {
|
|
2312
|
+
type: 'Polygon',
|
|
2313
|
+
coordinates: [
|
|
2314
|
+
[
|
|
2315
|
+
[w, s],
|
|
2316
|
+
[e, s],
|
|
2317
|
+
[e, n],
|
|
2318
|
+
[w, n],
|
|
2319
|
+
[w, s],
|
|
2320
|
+
],
|
|
2321
|
+
],
|
|
2322
|
+
},
|
|
2323
|
+
});
|
|
2324
|
+
|
|
2325
|
+
let warmMap;
|
|
2326
|
+
|
|
2327
|
+
/**
|
|
2328
|
+
* Opens the warm dialog for one archive.
|
|
2329
|
+
*
|
|
2330
|
+
* The API has taken bounds, a zoom range and a ceiling since it was
|
|
2331
|
+
* written; the console sent `{}` and took every default, so the button
|
|
2332
|
+
* warmed the archive's whole extent to a zoom chosen for it and there
|
|
2333
|
+
* was no way to ask for anything else. This is that request, made
|
|
2334
|
+
* visible.
|
|
2335
|
+
* @param {object} entry - The catalog entry being warmed.
|
|
2336
|
+
* @returns {Promise<void>} - Resolves once the dialog is up.
|
|
2337
|
+
*/
|
|
2338
|
+
async function openWarmDialog(entry) {
|
|
2339
|
+
const summary = entry.pmtiles ?? {};
|
|
2340
|
+
const archive = summary.bounds ?? [-180, -85.051129, 180, 85.051129];
|
|
2341
|
+
const minZoom = summary.minZoom ?? 0;
|
|
2342
|
+
|
|
2343
|
+
const edges = {
|
|
2344
|
+
west: $('warm-west'),
|
|
2345
|
+
south: $('warm-south'),
|
|
2346
|
+
east: $('warm-east'),
|
|
2347
|
+
north: $('warm-north'),
|
|
2348
|
+
};
|
|
2349
|
+
|
|
2350
|
+
const readBounds = () => {
|
|
2351
|
+
const values = ['west', 'south', 'east', 'north'].map((k) =>
|
|
2352
|
+
Number(edges[k].value),
|
|
2353
|
+
);
|
|
2354
|
+
if (values.some((v) => !Number.isFinite(v))) return null;
|
|
2355
|
+
const [west, south, east, north] = values;
|
|
2356
|
+
// Read as a box however it was typed, so edges the wrong way round
|
|
2357
|
+
// are understood rather than refused.
|
|
2358
|
+
return [
|
|
2359
|
+
Math.min(west, east),
|
|
2360
|
+
Math.min(south, north),
|
|
2361
|
+
Math.max(west, east),
|
|
2362
|
+
Math.max(south, north),
|
|
2363
|
+
];
|
|
2364
|
+
};
|
|
2365
|
+
|
|
2366
|
+
/** Redraws the box and the estimate from whatever the fields say. */
|
|
2367
|
+
function draw() {
|
|
2368
|
+
const bounds = readBounds();
|
|
2369
|
+
const note = $('warm-estimate');
|
|
2370
|
+
if (!bounds) {
|
|
2371
|
+
note.textContent = 'Give all four edges to see how much this is.';
|
|
2372
|
+
return;
|
|
2373
|
+
}
|
|
2374
|
+
const from = Number($('warm-minz').value);
|
|
2375
|
+
const to = Number($('warm-maxz').value);
|
|
2376
|
+
const limit = Number($('warm-max').value) || 5000;
|
|
2377
|
+
if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) {
|
|
2378
|
+
note.textContent = 'The top zoom must not be below the first.';
|
|
2379
|
+
return;
|
|
2380
|
+
}
|
|
2381
|
+
const { count, capped } = countTiles(bounds, from, to, limit);
|
|
2382
|
+
note.textContent = capped
|
|
2383
|
+
? `More than ${limit.toLocaleString()} tiles in this — warming stops at that ceiling. Raise it, draw a smaller area, or lower the top zoom.`
|
|
2384
|
+
: `About ${count.toLocaleString()} tiles.`;
|
|
2385
|
+
warmMap?.getSource('warm-box')?.setData(boxFeature(bounds));
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
const setBounds = (box) => {
|
|
2389
|
+
const [w, s, e, n] = box;
|
|
2390
|
+
edges.west.value = w.toFixed(4);
|
|
2391
|
+
edges.south.value = s.toFixed(4);
|
|
2392
|
+
edges.east.value = e.toFixed(4);
|
|
2393
|
+
edges.north.value = n.toFixed(4);
|
|
2394
|
+
draw();
|
|
2395
|
+
};
|
|
2396
|
+
|
|
2397
|
+
setBounds(archive);
|
|
2398
|
+
$('warm-minz').value = minZoom;
|
|
2399
|
+
// The same default the server picks, for the same reason: each level
|
|
2400
|
+
// is four times the last, so stopping short of the archive's maximum
|
|
2401
|
+
// is nearly always what was meant.
|
|
2402
|
+
$('warm-maxz').value = Math.min(summary.maxZoom ?? 6, minZoom + 6);
|
|
2403
|
+
$('warm-max').value = 5000;
|
|
2404
|
+
$('warm-conc').value = '';
|
|
2405
|
+
$('warm-error').textContent = '';
|
|
2406
|
+
|
|
2407
|
+
for (const input of [
|
|
2408
|
+
...Object.values(edges),
|
|
2409
|
+
$('warm-minz'),
|
|
2410
|
+
$('warm-maxz'),
|
|
2411
|
+
$('warm-max'),
|
|
2412
|
+
]) {
|
|
2413
|
+
input.oninput = draw;
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
$('warm-use-archive').onclick = () => setBounds(archive);
|
|
2417
|
+
$('warm-use-view').onclick = () => {
|
|
2418
|
+
if (!warmMap) return;
|
|
2419
|
+
const b = warmMap.getBounds();
|
|
2420
|
+
setBounds([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]);
|
|
2421
|
+
};
|
|
2422
|
+
|
|
2423
|
+
$('warm-form').onsubmit = async (event) => {
|
|
2424
|
+
if (event.submitter?.value === 'cancel') return;
|
|
2425
|
+
event.preventDefault();
|
|
2426
|
+
const bounds = readBounds();
|
|
2427
|
+
if (!bounds) {
|
|
2428
|
+
$('warm-error').textContent = 'All four edges are needed.';
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
try {
|
|
2432
|
+
await api(`/api/torrents/${entry.infoHash}/warm`, {
|
|
2433
|
+
method: 'POST',
|
|
2434
|
+
body: {
|
|
2435
|
+
bounds,
|
|
2436
|
+
minZoom: Number($('warm-minz').value),
|
|
2437
|
+
maxZoom: Number($('warm-maxz').value),
|
|
2438
|
+
maxTiles: Number($('warm-max').value) || undefined,
|
|
2439
|
+
concurrency: Number($('warm-conc').value) || undefined,
|
|
2440
|
+
},
|
|
2441
|
+
});
|
|
2442
|
+
$('warm-dialog').close();
|
|
2443
|
+
toast('warming started');
|
|
2444
|
+
refresh();
|
|
2445
|
+
} catch (error) {
|
|
2446
|
+
$('warm-error').textContent = error.message;
|
|
2447
|
+
}
|
|
2448
|
+
};
|
|
2449
|
+
|
|
2450
|
+
// Shown closed. The numbers are the whole of what the request needs,
|
|
2451
|
+
// and somebody who already has a bounding box should not have to wait
|
|
2452
|
+
// on a renderer to paste it in.
|
|
2453
|
+
const wrap = $('warm-map-wrap');
|
|
2454
|
+
wrap.hidden = true;
|
|
2455
|
+
$('warm-pick').textContent = 'Pick on a map';
|
|
2456
|
+
$('warm-pick').onclick = async () => {
|
|
2457
|
+
wrap.hidden = !wrap.hidden;
|
|
2458
|
+
$('warm-pick').textContent = wrap.hidden
|
|
2459
|
+
? 'Pick on a map'
|
|
2460
|
+
: 'Hide the map';
|
|
2461
|
+
if (wrap.hidden) return;
|
|
2462
|
+
// Mounted only once it is visible: a map sized inside a hidden
|
|
2463
|
+
// element measures zero and stays that way.
|
|
2464
|
+
await mountWarmMap(entry, readBounds() ?? archive, draw, setBounds);
|
|
2465
|
+
draw();
|
|
2466
|
+
};
|
|
2467
|
+
|
|
2468
|
+
$('warm-dialog').showModal();
|
|
2469
|
+
draw();
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
/**
|
|
2473
|
+
* A style that draws the archive being warmed, from this node.
|
|
2474
|
+
*
|
|
2475
|
+
* Its TileJSON is already a complete source description, so there is
|
|
2476
|
+
* nothing to reconstruct. A raster archive draws directly. A vector one
|
|
2477
|
+
* needs `source-layer` names, which only the layer list supplies -- and
|
|
2478
|
+
* that lives in a metadata block some writers put after every tile, so
|
|
2479
|
+
* on a partial download it is among the last bytes to arrive. Without it
|
|
2480
|
+
* there is nothing to style, and saying so beats a black rectangle.
|
|
2481
|
+
* @param {object} entry - The archive.
|
|
2482
|
+
* @returns {Promise<{spec: object, note?: string}>} - Style and any caveat.
|
|
2483
|
+
*/
|
|
2484
|
+
async function archiveStyle(entry) {
|
|
2485
|
+
const backdrop = {
|
|
2486
|
+
id: 'bg',
|
|
2487
|
+
type: 'background',
|
|
2488
|
+
paint: { 'background-color': '#12141a' },
|
|
2489
|
+
};
|
|
2490
|
+
const url = `/archives/${entry.infoHash}/tiles.json`;
|
|
2491
|
+
let tilejson;
|
|
2492
|
+
try {
|
|
2493
|
+
const response = await fetch(url);
|
|
2494
|
+
if (!response.ok) throw new Error(String(response.status));
|
|
2495
|
+
tilejson = await response.json();
|
|
2496
|
+
} catch {
|
|
2497
|
+
return {
|
|
2498
|
+
spec: { version: 8, sources: {}, layers: [backdrop] },
|
|
2499
|
+
note:
|
|
2500
|
+
'This archive is not serving tiles yet, so the map has nothing ' +
|
|
2501
|
+
'to draw. The box and the numbers still work.',
|
|
2502
|
+
};
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
const layers = tilejson.vector_layers ?? [];
|
|
2506
|
+
const vector =
|
|
2507
|
+
layers.length > 0 ||
|
|
2508
|
+
/\.(pbf|mvt)(\?|$)/.test(tilejson.tiles?.[0] ?? '');
|
|
2509
|
+
const spec = {
|
|
2510
|
+
version: 8,
|
|
2511
|
+
sources: { archive: { type: vector ? 'vector' : 'raster', url } },
|
|
2512
|
+
layers: [backdrop],
|
|
2513
|
+
};
|
|
2514
|
+
|
|
2515
|
+
if (!vector) {
|
|
2516
|
+
spec.layers.push({ id: 'archive', type: 'raster', source: 'archive' });
|
|
2517
|
+
return { spec };
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
// One flat line-and-fill pass over every layer the archive declares.
|
|
2521
|
+
// Not a cartographic style and not trying to be: what is wanted here
|
|
2522
|
+
// is enough shape to recognise a coastline and find a city, and that
|
|
2523
|
+
// is exactly what drawing every geometry in one colour gives.
|
|
2524
|
+
for (const layer of layers) {
|
|
2525
|
+
spec.layers.push(
|
|
2526
|
+
{
|
|
2527
|
+
id: `archive-${layer.id}-fill`,
|
|
2528
|
+
type: 'fill',
|
|
2529
|
+
source: 'archive',
|
|
2530
|
+
'source-layer': layer.id,
|
|
2531
|
+
filter: ['==', ['geometry-type'], 'Polygon'],
|
|
2532
|
+
paint: { 'fill-color': '#2b3550', 'fill-outline-color': '#41527d' },
|
|
2533
|
+
},
|
|
2534
|
+
{
|
|
2535
|
+
id: `archive-${layer.id}-line`,
|
|
2536
|
+
type: 'line',
|
|
2537
|
+
source: 'archive',
|
|
2538
|
+
'source-layer': layer.id,
|
|
2539
|
+
filter: ['==', ['geometry-type'], 'LineString'],
|
|
2540
|
+
paint: { 'line-color': '#41527d', 'line-width': 0.7 },
|
|
2541
|
+
},
|
|
2542
|
+
);
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
return layers.length > 0
|
|
2546
|
+
? { spec }
|
|
2547
|
+
: {
|
|
2548
|
+
spec,
|
|
2549
|
+
note:
|
|
2550
|
+
'This archive has not reported its vector layers yet, so ' +
|
|
2551
|
+
'there is nothing to draw. Give the edges in degrees, or try ' +
|
|
2552
|
+
'again once the metadata has arrived from the swarm.',
|
|
2553
|
+
};
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
/**
|
|
2557
|
+
* Puts a map in the dialog, loading MapLibre only when one is wanted.
|
|
2558
|
+
*
|
|
2559
|
+
* Imported here rather than with the page: the console is mostly a
|
|
2560
|
+
* table, and a mapping library is a large thing to make every session
|
|
2561
|
+
* fetch for a button most of them never press.
|
|
2562
|
+
* @param {object} entry - The archive, which is also the basemap.
|
|
2563
|
+
* @param {number[]} bounds - Where to open.
|
|
2564
|
+
* @param {Function} draw - Redraws the box and the estimate.
|
|
2565
|
+
* @param {Function} setBounds - Writes a new box into the fields.
|
|
2566
|
+
* @returns {Promise<void>} - Resolves once there is a map, or there is not.
|
|
2567
|
+
*/
|
|
2568
|
+
async function mountWarmMap(entry, bounds, draw, setBounds) {
|
|
2569
|
+
const target = $('warm-map');
|
|
2570
|
+
if (!target) return;
|
|
2571
|
+
const corners = [
|
|
2572
|
+
[bounds[0], bounds[1]],
|
|
2573
|
+
[bounds[2], bounds[3]],
|
|
2574
|
+
];
|
|
2575
|
+
if (warmMap) {
|
|
2576
|
+
warmMap.resize();
|
|
2577
|
+
warmMap.fitBounds(corners, { padding: 20, animate: false });
|
|
2578
|
+
return;
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
let maplibregl;
|
|
2582
|
+
try {
|
|
2583
|
+
maplibregl = await import('/vendor/maplibre-gl/maplibre-gl.mjs');
|
|
2584
|
+
} catch {
|
|
2585
|
+
// No map, but the numbers still work. A node may be running without
|
|
2586
|
+
// the vendor bundle deployed, and a missing library is no reason to
|
|
2587
|
+
// refuse to warm anything.
|
|
2588
|
+
target.remove();
|
|
2589
|
+
$('warm-map-note').textContent =
|
|
2590
|
+
'No map here; give the edges in degrees.';
|
|
2591
|
+
return;
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
if (!document.getElementById('maplibre-css')) {
|
|
2595
|
+
const link = document.createElement('link');
|
|
2596
|
+
link.id = 'maplibre-css';
|
|
2597
|
+
link.rel = 'stylesheet';
|
|
2598
|
+
link.href = '/vendor/maplibre-gl/maplibre-gl.css';
|
|
2599
|
+
document.head.append(link);
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
// The archive is the basemap.
|
|
2603
|
+
//
|
|
2604
|
+
// A remote style is the obvious choice and the wrong one: this node
|
|
2605
|
+
// may have no route to the internet at all, and a box floating over a
|
|
2606
|
+
// tile-loading error is worse than no map, because it was supposed to
|
|
2607
|
+
// be the thing you could see. The archive is served by this node,
|
|
2608
|
+
// right here, and it is also the thing being warmed -- so it shows
|
|
2609
|
+
// where the data actually is, which a generic basemap cannot. An
|
|
2610
|
+
// archive with a hole over half a country looks like one.
|
|
2611
|
+
//
|
|
2612
|
+
// Drawing it does read tiles, through the same path a visitor would.
|
|
2613
|
+
// At the zooms you pick an area at that is a handful, and they are
|
|
2614
|
+
// tiles the warm was about to read anyway.
|
|
2615
|
+
const style = await archiveStyle(entry);
|
|
2616
|
+
warmMap = new maplibregl.Map({
|
|
2617
|
+
container: target,
|
|
2618
|
+
style: style.spec,
|
|
2619
|
+
bounds: corners,
|
|
2620
|
+
fitBoundsOptions: { padding: 20 },
|
|
2621
|
+
attributionControl: false,
|
|
2622
|
+
});
|
|
2623
|
+
if (style.note) $('warm-map-note').textContent = style.note;
|
|
2624
|
+
warmMap.addControl(new maplibregl.NavigationControl(), 'top-left');
|
|
2625
|
+
// Shift-drag is box-zoom by default, and dragging is wanted for
|
|
2626
|
+
// drawing instead.
|
|
2627
|
+
warmMap.boxZoom.disable();
|
|
2628
|
+
|
|
2629
|
+
await new Promise((resolve) => warmMap.on('load', resolve));
|
|
2630
|
+
warmMap.addSource('warm-box', {
|
|
2631
|
+
type: 'geojson',
|
|
2632
|
+
data: boxFeature(bounds),
|
|
2633
|
+
});
|
|
2634
|
+
warmMap.addLayer({
|
|
2635
|
+
id: 'warm-box-fill',
|
|
2636
|
+
type: 'fill',
|
|
2637
|
+
source: 'warm-box',
|
|
2638
|
+
paint: { 'fill-color': '#4f8cff', 'fill-opacity': 0.2 },
|
|
2639
|
+
});
|
|
2640
|
+
warmMap.addLayer({
|
|
2641
|
+
id: 'warm-box-line',
|
|
2642
|
+
type: 'line',
|
|
2643
|
+
source: 'warm-box',
|
|
2644
|
+
paint: { 'line-color': '#4f8cff', 'line-width': 2 },
|
|
2645
|
+
});
|
|
2646
|
+
|
|
2647
|
+
// Drag draws the area. Panning stays available on alt-drag and on the
|
|
2648
|
+
// navigation control, because a map that can only be drawn on cannot
|
|
2649
|
+
// be aimed -- and the archive may cover the world.
|
|
2650
|
+
let anchor = null;
|
|
2651
|
+
const canvas = warmMap.getCanvasContainer();
|
|
2652
|
+
const at = (event) => {
|
|
2653
|
+
const box = canvas.getBoundingClientRect();
|
|
2654
|
+
return warmMap.unproject([
|
|
2655
|
+
event.clientX - box.left,
|
|
2656
|
+
event.clientY - box.top,
|
|
2657
|
+
]);
|
|
2658
|
+
};
|
|
2659
|
+
|
|
2660
|
+
canvas.addEventListener('mousedown', (event) => {
|
|
2661
|
+
if (event.button !== 0 || event.altKey) return;
|
|
2662
|
+
event.preventDefault();
|
|
2663
|
+
warmMap.dragPan.disable();
|
|
2664
|
+
anchor = at(event);
|
|
2665
|
+
});
|
|
2666
|
+
canvas.addEventListener('mousemove', (event) => {
|
|
2667
|
+
if (!anchor) return;
|
|
2668
|
+
const now = at(event);
|
|
2669
|
+
warmMap
|
|
2670
|
+
.getSource('warm-box')
|
|
2671
|
+
.setData(
|
|
2672
|
+
boxFeature([
|
|
2673
|
+
Math.min(anchor.lng, now.lng),
|
|
2674
|
+
Math.min(anchor.lat, now.lat),
|
|
2675
|
+
Math.max(anchor.lng, now.lng),
|
|
2676
|
+
Math.max(anchor.lat, now.lat),
|
|
2677
|
+
]),
|
|
2678
|
+
);
|
|
2679
|
+
});
|
|
2680
|
+
|
|
2681
|
+
const finish = (event) => {
|
|
2682
|
+
if (!anchor) return;
|
|
2683
|
+
const now = at(event);
|
|
2684
|
+
const drawn = [
|
|
2685
|
+
Math.min(anchor.lng, now.lng),
|
|
2686
|
+
Math.min(anchor.lat, now.lat),
|
|
2687
|
+
Math.max(anchor.lng, now.lng),
|
|
2688
|
+
Math.max(anchor.lat, now.lat),
|
|
2689
|
+
];
|
|
2690
|
+
anchor = null;
|
|
2691
|
+
warmMap.dragPan.enable();
|
|
2692
|
+
// A click is not an area. Without this, tapping the map would set a
|
|
2693
|
+
// box of no width and warm nothing at all.
|
|
2694
|
+
if (drawn[2] - drawn[0] < 1e-6 || drawn[3] - drawn[1] < 1e-6) {
|
|
2695
|
+
draw();
|
|
2696
|
+
return;
|
|
2697
|
+
}
|
|
2698
|
+
setBounds(drawn);
|
|
2699
|
+
};
|
|
2700
|
+
canvas.addEventListener('mouseup', finish);
|
|
2701
|
+
canvas.addEventListener('mouseleave', finish);
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
|
|
2214
2705
|
// ── Adding ────────────────────────────────────────────────────────────
|
|
2215
2706
|
let chosenCategories = new Set();
|
|
2216
2707
|
|