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 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
 
@@ -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.37.2",
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
- // Pieces in, bytes out. The console draws one progress bar for adds and
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
- onProgress: ({ received, total, done }) => {
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(
@@ -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 = async () => {
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