signalk-charts-provider-simple 2.2.0 → 2.3.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.
Files changed (53) hide show
  1. package/README.md +11 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +481 -2
  4. package/dist/index.js.map +1 -1
  5. package/dist/types.d.ts +36 -0
  6. package/dist/types.d.ts.map +1 -1
  7. package/dist/utils/container-jobs.d.ts +15 -1
  8. package/dist/utils/container-jobs.d.ts.map +1 -1
  9. package/dist/utils/container-jobs.js +4 -1
  10. package/dist/utils/container-jobs.js.map +1 -1
  11. package/dist/utils/container-manager.d.ts +9 -1
  12. package/dist/utils/container-manager.d.ts.map +1 -1
  13. package/dist/utils/container-manager.js.map +1 -1
  14. package/dist/utils/custom-catalog-download.d.ts +33 -0
  15. package/dist/utils/custom-catalog-download.d.ts.map +1 -0
  16. package/dist/utils/custom-catalog-download.js +131 -0
  17. package/dist/utils/custom-catalog-download.js.map +1 -0
  18. package/dist/utils/custom-catalog-manager.d.ts +126 -0
  19. package/dist/utils/custom-catalog-manager.d.ts.map +1 -0
  20. package/dist/utils/custom-catalog-manager.js +414 -0
  21. package/dist/utils/custom-catalog-manager.js.map +1 -0
  22. package/dist/utils/job-failure.d.ts +22 -0
  23. package/dist/utils/job-failure.d.ts.map +1 -0
  24. package/dist/utils/job-failure.js +38 -0
  25. package/dist/utils/job-failure.js.map +1 -0
  26. package/dist/utils/noaa-enc-footprints.d.ts +132 -0
  27. package/dist/utils/noaa-enc-footprints.d.ts.map +1 -0
  28. package/dist/utils/noaa-enc-footprints.js +386 -0
  29. package/dist/utils/noaa-enc-footprints.js.map +1 -0
  30. package/dist/utils/rnc-converter.d.ts.map +1 -1
  31. package/dist/utils/rnc-converter.js +3 -2
  32. package/dist/utils/rnc-converter.js.map +1 -1
  33. package/dist/utils/s57-converter.d.ts +23 -0
  34. package/dist/utils/s57-converter.d.ts.map +1 -1
  35. package/dist/utils/s57-converter.js +258 -69
  36. package/dist/utils/s57-converter.js.map +1 -1
  37. package/package.json +3 -1
  38. package/public/css/custom-catalogs.css +407 -0
  39. package/public/index.html +19 -0
  40. package/public/js/download-simple.js +1 -1
  41. package/public/js/download-simple.ts +1 -1
  42. package/public/js/globals.d.ts +2 -0
  43. package/public/js/noaa-chart-selector.js +834 -0
  44. package/public/js/noaa-chart-selector.ts +960 -0
  45. package/public/js/tabs.js +3 -0
  46. package/public/js/tabs.ts +2 -0
  47. package/public/vendor/leaflet/images/layers-2x.png +0 -0
  48. package/public/vendor/leaflet/images/layers.png +0 -0
  49. package/public/vendor/leaflet/images/marker-icon-2x.png +0 -0
  50. package/public/vendor/leaflet/images/marker-icon.png +0 -0
  51. package/public/vendor/leaflet/images/marker-shadow.png +0 -0
  52. package/public/vendor/leaflet/leaflet.css +661 -0
  53. package/public/vendor/leaflet/leaflet.js +6 -0
package/README.md CHANGED
@@ -7,6 +7,7 @@ A lightweight Signal K server plugin for managing local nautical charts, written
7
7
  - **Local Chart Management**: MBTiles (raster and vector), with folder organization and enable/disable toggles
8
8
  - **Download Manager**: Built-in download queue with progress tracking and ZIP extraction
9
9
  - **Chart Catalog**: Browse and download charts from [chartcatalogs.github.io](https://chartcatalogs.github.io/) with automatic update notifications
10
+ - **NOAA Charts**: Draw a coverage region on a map from NOAA band-4 chart footprints; the plugin bundles the overlapping band-3/4/5 ENCs and converts them into a single vector MBTiles named after your chart set
10
11
  - **S-57 ENC Conversion**: Convert IENC/ENC charts to vector MBTiles with full S-52 symbology in Freeboard-SK
11
12
  - **BSB Raster Conversion**: Convert BSB/KAP raster charts and Pilot Charts to raster MBTiles
12
13
  - **World Basemaps**: GSHHG and OSM coastline basemaps for offline use
@@ -60,7 +61,7 @@ Access the plugin's web interface through your Signal K server:
60
61
  http://[your-server]:3000/plugins/signalk-charts-provider-simple/
61
62
  ```
62
63
 
63
- The interface provides four tabs:
64
+ The interface provides five tabs:
64
65
 
65
66
  1. **Manage Charts**:
66
67
  - View all charts with metadata (name, bounds, zoom levels, size)
@@ -86,6 +87,15 @@ The interface provides four tabs:
86
87
  - Automatic update notifications (Signal K delta + tab badge)
87
88
  - Category filtering (MBTiles / RNC / IENC / General)
88
89
 
90
+ 5. **NOAA Charts**:
91
+ - Build a named **chart set** of NOAA ENC coverage by clicking band-4 footprints on a map (Leaflet + OpenSeaMap)
92
+ - Each band-4 selection automatically pulls in the overlapping band-3 and band-5 ENCs
93
+ - Footprint data from NOAA's [official chart locator](https://www.charts.noaa.gov/InteractiveCatalog/data/enc.geojson)
94
+ - Download & convert produces one `.mbtiles` per chart set, with live progress and a log
95
+ - Out-of-date detection when NOAA publishes new editions or your selection changes
96
+ - Container runtime (`signalk-container`) required, same as other conversions
97
+ - US waters only — NOAA is the only source of freely-downloadable ENC charts
98
+
89
99
  ### Supported Formats
90
100
 
91
101
  | Format | Source | Conversion | Output |
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAQ,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAuExD,OAAO,KAAK,EAGV,iBAAiB,EAMlB,MAAM,YAAY,CAAC;AAepB,QAAA,MAAM,iBAAiB,GAAI,KAAK,iBAAiB,KAAG,MA+0FnD,CAAC;AAiHF,eAAe,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAQ,MAAM,EAAE,MAAM,qBAAqB,CAAC;AA0GxD,OAAO,KAAK,EAGV,iBAAiB,EAMlB,MAAM,YAAY,CAAC;AAepB,QAAA,MAAM,iBAAiB,GAAI,KAAK,iBAAiB,KAAG,MAi2GnD,CAAC;AAiHF,eAAe,iBAAiB,CAAC"}
package/dist/index.js CHANGED
@@ -22,7 +22,10 @@ import { parsePluginConfig } from './utils/plugin-config-schema.js';
22
22
  import { cleanupQuarantineDir, makeQuarantineDir, promoteQuarantine, sweepStaleQuarantineDirs } from './utils/quarantine.js';
23
23
  import { parseBody, parseShape } from './utils/rest-validation.js';
24
24
  import { getAllConversionProgress as getAllRncProgress, getConversionProgress as getRncProgress, initRncConverter, processPilotTar, processRncZip, setConversionFailed as setRncFailed } from './utils/rnc-converter.js';
25
- import { getAllConversionProgress as getAllS57Progress, getConversionProgress as getS57Progress, initS57Converter, processGshhg, processS57Zip, processShpBasemap, setConversionFailed as setS57Failed } from './utils/s57-converter.js';
25
+ import { getAllConversionProgress as getAllS57Progress, getConversionProgress as getS57Progress, initS57Converter, processGshhg, processS57Directory, processS57Zip, processShpBasemap, setConversionFailed as setS57Failed } from './utils/s57-converter.js';
26
+ import { band4MapEntries, computeInclusion, coverageByBox, getFootprintIndex, initNoaaEncFootprints, peekFootprintIndex } from './utils/noaa-enc-footprints.js';
27
+ import { appendCatalogLog, boxStatesForCatalog, clearCatalogCancel, clearCatalogProgress, createCustomCatalog, deleteCustomCatalog, evaluateFreshness, getCatalogDetail, getCatalogProgress, getCustomCatalog, initCustomCatalogManager, isCatalogBusy, isCatalogCancelRequested, isValidCatalogId, listCustomCatalogs, registerCatalogAbort, requestCatalogCancel, saveCustomCatalog, setCatalogBusy, setCatalogDetail, setCatalogFailed, setCatalogProgress } from './utils/custom-catalog-manager.js';
28
+ import { downloadAndExtractEncs } from './utils/custom-catalog-download.js';
26
29
  // JSON import with type attribute (NodeNext ESM). package.json's `version`
27
30
  // is what the marker file records so users can confirm the running build.
28
31
  import packageJson from '../package.json' with { type: 'json' };
@@ -197,7 +200,9 @@ const pluginConstructor = (app) => {
197
200
  initChartState(pluginDataDir);
198
201
  const dataDir = pluginDataDir;
199
202
  initCatalogManager(dataDir, app.debug.bind(app));
200
- const tempDirPattern = /^(s57-download-|rnc-download-|pilot-download-|shp-download-|gshhg-|convert-upload-)\d+$/;
203
+ initNoaaEncFootprints(dataDir, app.debug.bind(app));
204
+ initCustomCatalogManager(dataDir, app.debug.bind(app));
205
+ const tempDirPattern = /^(s57-download-|rnc-download-|pilot-download-|shp-download-|gshhg-|convert-upload-|custom-catalog-)\d+$/;
201
206
  try {
202
207
  const dataDirEntries = fs.readdirSync(dataDir, { withFileTypes: true });
203
208
  for (const entry of dataDirEntries) {
@@ -1905,6 +1910,205 @@ const pluginConstructor = (app) => {
1905
1910
  });
1906
1911
  }
1907
1912
  });
1913
+ // ---- Custom NOAA ENC catalogs ----
1914
+ // Build a named bundle of NOAA band-4 coverage areas selected on a map,
1915
+ // then download all selected band-4 ENCs plus overlapping band-3/5 ENCs
1916
+ // and convert them once into a single MBTiles named after the catalog.
1917
+ const CustomCatalogCreateBody = Type.Object({
1918
+ name: Type.String({ minLength: 1, maxLength: 120 })
1919
+ });
1920
+ const CustomCatalogUpdateBody = Type.Object({
1921
+ name: Type.Optional(Type.String({ minLength: 1, maxLength: 120 })),
1922
+ selectedBand4ChartIds: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 32 })))
1923
+ });
1924
+ // Map data: all NOAA band-4 footprints as selectable bbox areas. This is
1925
+ // the one endpoint that fetches the (large) NOAA enc.geojson; every other
1926
+ // endpoint reads the in-memory index via peekFootprintIndex(). Registered
1927
+ // BEFORE '/custom-catalogs/:id' so the literal path wins over the param.
1928
+ router.get('/custom-catalogs/noaa-enc-footprints', (req, res) => {
1929
+ const forceRefresh = req.query.refresh === '1' || req.query.refresh === 'true';
1930
+ void (async () => {
1931
+ try {
1932
+ const index = await getFootprintIndex({ forceRefresh });
1933
+ res.json({
1934
+ band4: band4MapEntries(index.all),
1935
+ fetchedAt: index.fetchedAt,
1936
+ stale: index.stale
1937
+ });
1938
+ }
1939
+ catch (error) {
1940
+ res.status(502).json({
1941
+ error: error instanceof Error ? error.message : 'Failed to load NOAA ENC footprints'
1942
+ });
1943
+ }
1944
+ })();
1945
+ });
1946
+ router.get('/custom-catalogs', (_req, res) => {
1947
+ res.json(listCustomCatalogs().map((c) => decorateCatalog(c)));
1948
+ });
1949
+ router.post('/custom-catalogs', (req, res) => {
1950
+ const body = parseBody(CustomCatalogCreateBody, req, res);
1951
+ if (!body) {
1952
+ return;
1953
+ }
1954
+ try {
1955
+ const catalog = createCustomCatalog(body.name);
1956
+ res.status(201).json(decorateCatalog(catalog));
1957
+ }
1958
+ catch (error) {
1959
+ res.status(400).json({
1960
+ success: false,
1961
+ error: error instanceof Error ? error.message : 'Failed to create catalog'
1962
+ });
1963
+ }
1964
+ });
1965
+ router.get('/custom-catalogs/:id', (req, res) => {
1966
+ const catalog = getCustomCatalog(req.params.id);
1967
+ if (!catalog) {
1968
+ res.status(404).json({ error: 'Custom catalog not found' });
1969
+ return;
1970
+ }
1971
+ res.json(decorateCatalog(catalog));
1972
+ });
1973
+ router.put('/custom-catalogs/:id', (req, res) => {
1974
+ const id = req.params.id;
1975
+ const catalog = getCustomCatalog(id);
1976
+ if (!catalog) {
1977
+ res.status(404).json({ error: 'Custom catalog not found' });
1978
+ return;
1979
+ }
1980
+ if (isCatalogBusy(id)) {
1981
+ res
1982
+ .status(409)
1983
+ .json({ error: 'Catalog is downloading/converting; try again when it finishes' });
1984
+ return;
1985
+ }
1986
+ const body = parseBody(CustomCatalogUpdateBody, req, res);
1987
+ if (!body) {
1988
+ return;
1989
+ }
1990
+ if (body.name !== undefined && body.name.trim() !== '') {
1991
+ catalog.name = body.name.trim();
1992
+ }
1993
+ if (body.selectedBand4ChartIds !== undefined) {
1994
+ catalog.selectedBand4ChartIds = Array.from(new Set(body.selectedBand4ChartIds));
1995
+ // Recompute coverage from the cached footprint index when available so
1996
+ // the UI shows an included-chart count immediately. The snapshot in
1997
+ // `chartVersions` is only refreshed at download time.
1998
+ const index = peekFootprintIndex();
1999
+ if (index) {
2000
+ catalog.includedChartIds = computeInclusion(index.all, catalog.selectedBand4ChartIds).includedChartIds;
2001
+ }
2002
+ // Any selection change requires a rebuild of the single combined chart.
2003
+ catalog.status = catalog.selectedBand4ChartIds.length === 0 ? 'empty' : 'out_of_date';
2004
+ }
2005
+ saveCustomCatalog(catalog);
2006
+ res.json(decorateCatalog(catalog));
2007
+ });
2008
+ router.delete('/custom-catalogs/:id', (req, res) => {
2009
+ const id = req.params.id;
2010
+ if (isCatalogBusy(id)) {
2011
+ res.status(409).json({ error: 'Catalog is downloading/converting; cannot delete now' });
2012
+ return;
2013
+ }
2014
+ if (!deleteCustomCatalog(id)) {
2015
+ res.status(404).json({ error: 'Custom catalog not found' });
2016
+ return;
2017
+ }
2018
+ // The produced .mbtiles (if any) is intentionally left in the chart
2019
+ // library — deleting the catalog definition is non-destructive. The user
2020
+ // can remove the chart itself from the Manage Charts tab.
2021
+ res.json({ success: true });
2022
+ });
2023
+ router.post('/custom-catalogs/:id/download-convert', (req, res) => {
2024
+ const id = req.params.id;
2025
+ const catalog = getCustomCatalog(id);
2026
+ if (!catalog) {
2027
+ res.status(404).json({ error: 'Custom catalog not found' });
2028
+ return;
2029
+ }
2030
+ if (catalog.selectedBand4ChartIds.length === 0) {
2031
+ res.status(400).json({ success: false, error: 'Select at least one coverage area first' });
2032
+ return;
2033
+ }
2034
+ if (isCatalogBusy(id)) {
2035
+ res.status(409).json({ success: false, error: 'Already downloading/converting' });
2036
+ return;
2037
+ }
2038
+ const cm = getContainerManager();
2039
+ if (!cm?.getRuntime()) {
2040
+ res.status(503).json({
2041
+ success: false,
2042
+ error: 'signalk-container plugin not available. Install it from the App Store and restart Signal K.'
2043
+ });
2044
+ return;
2045
+ }
2046
+ const budgetMax = getCpuBudget().maxConcurrentConversions;
2047
+ if (getConvertingCount() >= budgetMax) {
2048
+ res.status(429).json({
2049
+ success: false,
2050
+ error: `Too many conversions running (max ${budgetMax}). Please wait for one to finish.`
2051
+ });
2052
+ return;
2053
+ }
2054
+ handleCustomCatalogDownloadConvert(catalog);
2055
+ res.json({ success: true, message: 'Download and conversion started' });
2056
+ });
2057
+ router.get('/custom-catalogs/:id/status', (req, res) => {
2058
+ const id = req.params.id;
2059
+ const catalog = getCustomCatalog(id);
2060
+ if (!catalog) {
2061
+ res.status(404).json({ error: 'Custom catalog not found' });
2062
+ return;
2063
+ }
2064
+ res.json({
2065
+ ...decorateCatalog(catalog),
2066
+ busy: isCatalogBusy(id),
2067
+ progress: getCatalogProgress(id),
2068
+ detail: getCatalogDetail(id)
2069
+ });
2070
+ });
2071
+ router.post('/custom-catalogs/:id/cancel', (req, res) => {
2072
+ const id = req.params.id;
2073
+ if (!isValidCatalogId(id)) {
2074
+ res.status(400).json({ success: false, error: 'Invalid catalog id' });
2075
+ return;
2076
+ }
2077
+ if (!isCatalogBusy(id)) {
2078
+ res.json({ success: true, message: 'Nothing running' });
2079
+ return;
2080
+ }
2081
+ requestCatalogCancel(id);
2082
+ setCatalogProgress(id, 'cancelling', 'Cancelling…');
2083
+ // Reflect it in the structured detail immediately so the next /status
2084
+ // poll shows "Cancelling…" rather than the last conversion frame.
2085
+ setCatalogDetail(id, {
2086
+ phase: 'cancelling',
2087
+ sectionLabel: 'Cancelling…',
2088
+ sectionPercent: -1
2089
+ });
2090
+ // Aborts the in-flight container job on signalk-container >= 1.16.0;
2091
+ // downloads stop between charts; older containers stop at a band boundary.
2092
+ res.json({ success: true, message: 'Cancellation requested' });
2093
+ });
2094
+ router.get('/custom-catalogs/:id/log', (req, res) => {
2095
+ const id = req.params.id;
2096
+ if (!isValidCatalogId(id)) {
2097
+ res.status(400).json({ log: [], status: null });
2098
+ return;
2099
+ }
2100
+ const tail = parseInt(req.query.tail) || 200;
2101
+ const managerProgress = getCatalogProgress(id);
2102
+ const s57 = getS57Progress(id);
2103
+ // Manager progress carries the download phase; the S-57 converter carries
2104
+ // the detailed per-band conversion log. Concatenate for the full picture.
2105
+ const log = [...(managerProgress?.log ?? []), ...(s57?.log ?? [])];
2106
+ res.json({
2107
+ log: log.slice(-tail),
2108
+ status: managerProgress?.status ?? s57?.status ?? null,
2109
+ message: managerProgress?.message ?? s57?.message ?? ''
2110
+ });
2111
+ });
1908
2112
  };
1909
2113
  // Helper functions for catalog download routes (extracted to reduce nesting)
1910
2114
  function handleS57Download(res, app, url, chartNumber, catalogFile, zipfileDatetime, targetDir, chartPath, minzoom, maxzoom, chartTitle) {
@@ -2258,6 +2462,281 @@ const pluginConstructor = (app) => {
2258
2462
  message: 'GSHHG basemap conversion started'
2259
2463
  });
2260
2464
  }
2465
+ // Augment a persisted catalog with derived, non-persisted fields the UI
2466
+ // needs: the freshness-aware effective status, the live included-chart
2467
+ // count, and any reasons it's out of date. Freshness is only computed when
2468
+ // the NOAA footprint index is already in memory (the map endpoint loads it);
2469
+ // otherwise we fall back to the stored status so this never blocks on the
2470
+ // network.
2471
+ function decorateCatalog(catalog) {
2472
+ const chartPath = props.chartPath || defaultChartsPath;
2473
+ const index = peekFootprintIndex();
2474
+ if (!index) {
2475
+ return {
2476
+ ...catalog,
2477
+ effectiveStatus: catalog.selectedBand4ChartIds.length === 0 ? 'empty' : catalog.status,
2478
+ includedCount: catalog.includedChartIds.length,
2479
+ updateReasons: [],
2480
+ boxStates: {}
2481
+ };
2482
+ }
2483
+ const mbtilesPresent = catalog.convertedChartPath !== null &&
2484
+ fs.existsSync(path.join(chartPath, catalog.convertedChartPath));
2485
+ const freshness = evaluateFreshness(catalog, index, (rel) => fs.existsSync(path.join(chartPath, rel)));
2486
+ return {
2487
+ ...catalog,
2488
+ effectiveStatus: freshness.effectiveStatus,
2489
+ includedCount: freshness.recomputed.includedChartIds.length,
2490
+ updateReasons: freshness.reasons,
2491
+ // Per-box freshness by NOAA edition. A box is green only when the set is
2492
+ // cleanly converted and its MBTiles exists — a cancelled/failed build or
2493
+ // a deleted output shows red, matching the catalog-level status.
2494
+ boxStates: boxStatesForCatalog(catalog, index, mbtilesPresent)
2495
+ };
2496
+ }
2497
+ // Async download-and-convert flow for a custom catalog. Fetches the current
2498
+ // NOAA footprint index, resolves the band-3/4/5 ENC set, downloads every ZIP
2499
+ // into one combined tree, converts it once via processS57Directory, and
2500
+ // promotes the single MBTiles into the live chart library. The client polls
2501
+ // /custom-catalogs/:id/status and /log for progress.
2502
+ function handleCustomCatalogDownloadConvert(catalog) {
2503
+ const id = catalog.id;
2504
+ const chartPath = props.chartPath || defaultChartsPath;
2505
+ setCatalogBusy(id, true);
2506
+ clearCatalogProgress(id);
2507
+ setCatalogProgress(id, 'preparing', 'Resolving coverage…');
2508
+ setCatalogDetail(id, {
2509
+ phase: 'preparing',
2510
+ overallPercent: 0,
2511
+ sectionLabel: 'Resolving coverage…',
2512
+ sectionPercent: -1,
2513
+ downloadedChartIds: []
2514
+ });
2515
+ const stagedSoFar = [];
2516
+ // Abort controller for mid-job cancel: requestCatalogCancel(id) aborts it,
2517
+ // which kills the in-flight container job on signalk-container >= 1.16.0.
2518
+ const abortController = new AbortController();
2519
+ registerCatalogAbort(id, abortController);
2520
+ const stagingRoot = path.join(app.getDataDirPath(), `custom-catalog-${Date.now()}`);
2521
+ const encDir = path.join(stagingRoot, 'enc');
2522
+ const zipsDir = path.join(stagingRoot, 'zips');
2523
+ void (async () => {
2524
+ let quarantineDir = null;
2525
+ let convertingFlagged = false;
2526
+ try {
2527
+ // Refresh the footprint index so we download the current editions.
2528
+ const index = await getFootprintIndex();
2529
+ const inclusion = computeInclusion(index.all, catalog.selectedBand4ChartIds);
2530
+ if (inclusion.includedChartIds.length === 0) {
2531
+ throw new Error('No NOAA charts match the selected coverage areas');
2532
+ }
2533
+ appendCatalogLog(id, `Resolved ${inclusion.includedChartIds.length} ENC(s) from ` +
2534
+ `${catalog.selectedBand4ChartIds.length} selected area(s)`);
2535
+ if (inclusion.missingSelected.length > 0) {
2536
+ appendCatalogLog(id, `Note: no longer published: ${inclusion.missingSelected.join(', ')}`);
2537
+ }
2538
+ // Per-box coverage so the map can fill each band-4 box by its own
2539
+ // band-4 + nested-band-5 download fraction.
2540
+ setCatalogDetail(id, {
2541
+ coverageByBox: coverageByBox(index.all, catalog.selectedBand4ChartIds)
2542
+ });
2543
+ // 1. Download + extract every ENC ZIP into one combined input tree.
2544
+ const result = await downloadAndExtractEncs(inclusion.includedChartIds, encDir, zipsDir, {
2545
+ onProgress: (done, total, chartId) => {
2546
+ setCatalogProgress(id, 'downloading', `Downloading ${done}/${total}: ${chartId}`);
2547
+ setCatalogDetail(id, {
2548
+ phase: 'downloading',
2549
+ overallPercent: 0,
2550
+ sectionLabel: `Downloading ${done}/${total}`,
2551
+ sectionPercent: total > 0 ? Math.round((done / total) * 100) : -1
2552
+ });
2553
+ },
2554
+ // Flip the map area as each ENC lands, rather than all at once.
2555
+ onStaged: (chartId) => {
2556
+ stagedSoFar.push(chartId);
2557
+ setCatalogDetail(id, { downloadedChartIds: [...stagedSoFar] });
2558
+ },
2559
+ onLog: (line) => appendCatalogLog(id, line),
2560
+ isAborted: () => isCatalogCancelRequested(id)
2561
+ });
2562
+ // Cancel during download → stop before converting.
2563
+ if (isCatalogCancelRequested(id)) {
2564
+ throw new Error('Cancelled by user');
2565
+ }
2566
+ if (result.staged.length === 0) {
2567
+ throw new Error('All ENC downloads failed — check network connectivity');
2568
+ }
2569
+ appendCatalogLog(id, `Downloaded ${result.staged.length}/${inclusion.includedChartIds.length} ENC(s)` +
2570
+ (result.failed.length > 0 ? `, ${result.failed.length} failed` : ''));
2571
+ // A partial download yields an MBTiles that omits the failed cells, so
2572
+ // it must not be marked up-to-date (see the status decision after
2573
+ // conversion below).
2574
+ const partialDownload = result.failed.length > 0;
2575
+ if (partialDownload) {
2576
+ appendCatalogLog(id, `WARNING: ${result.failed.length} cell(s) failed to download: ` +
2577
+ `${result.failed.map((f) => f.chartId).join(', ')} — the built chart will be ` +
2578
+ `incomplete and stay flagged for rebuild.`);
2579
+ }
2580
+ // Persist the snapshot of what actually baked into the MBTiles — the
2581
+ // STAGED charts and their editions, not the full requested set. On a
2582
+ // partial download this records only the cells that made it in, so
2583
+ // freshness (recomputed full vs stored staged) flags the missing cells
2584
+ // and per-box edition state shows the affected boxes as needing a
2585
+ // refresh. On a complete download staged == the full set, so nothing
2586
+ // changes for the common case.
2587
+ const staged = new Set(result.staged);
2588
+ catalog.includedChartIds = inclusion.includedChartIds.filter((cid) => staged.has(cid));
2589
+ catalog.chartVersions = Object.fromEntries(Object.entries(inclusion.chartVersions).filter(([cid]) => staged.has(cid)));
2590
+ catalog.downloadedChartIds = result.staged;
2591
+ catalog.lastDownloadedAt = new Date().toISOString();
2592
+ catalog.status = 'downloaded';
2593
+ saveCustomCatalog(catalog);
2594
+ // 2. Convert the combined tree once → one MBTiles in the quarantine.
2595
+ // Flag converting so the global CPU-budget gate accounts for it.
2596
+ setCatalogProgress(id, 'converting', 'Converting combined ENC set…');
2597
+ setCatalogDetail(id, {
2598
+ phase: 'converting',
2599
+ overallPercent: 0,
2600
+ sectionLabel: 'Starting conversion…',
2601
+ sectionPercent: -1
2602
+ });
2603
+ // Re-check the conversion budget right before claiming a slot. The
2604
+ // admission check at request time ran before the (long) download, so
2605
+ // several catalogs could have passed it and now be ready to convert
2606
+ // together. This synchronous check + setConvertingState pair is atomic
2607
+ // in the event loop, so it can't over-subscribe maxConcurrentConversions.
2608
+ const convertBudgetMax = getCpuBudget().maxConcurrentConversions;
2609
+ if (getConvertingCount() >= convertBudgetMax) {
2610
+ throw new Error(`Too many conversions running (max ${convertBudgetMax}). Please retry when one finishes.`);
2611
+ }
2612
+ setConvertingState(id, true);
2613
+ convertingFlagged = true;
2614
+ quarantineDir = makeQuarantineDir(app.getDataDirPath(), id);
2615
+ const conv = await processS57Directory(encDir, quarantineDir, id, (status, message) => setCatalogProgress(id, status, message), {
2616
+ displayName: catalog.name,
2617
+ displayDescription: `Custom catalog: ${catalog.name}`,
2618
+ isAborted: () => isCatalogCancelRequested(id),
2619
+ // Overall (convert-only) bar treats the per-band tippecanoe passes
2620
+ // plus the final combine as equal slices, so it rises monotonically
2621
+ // through the whole conversion. The combine now reports its own %
2622
+ // (derived from tile-join's tile-coordinate output), so its section
2623
+ // bar is determinate too.
2624
+ onProgress: (p) => {
2625
+ // Once cancel is requested, the in-flight job is being killed but
2626
+ // may emit a few more progress lines before it dies. Don't let
2627
+ // those overwrite the "Cancelling…" state — otherwise the UI
2628
+ // flips back to "running" until the job actually unwinds.
2629
+ if (isCatalogCancelRequested(id)) {
2630
+ setCatalogDetail(id, {
2631
+ phase: 'cancelling',
2632
+ sectionLabel: 'Cancelling…',
2633
+ sectionPercent: -1
2634
+ });
2635
+ return;
2636
+ }
2637
+ const hasJoin = p.bucketCount >= 2;
2638
+ const denom = p.bucketCount + (hasJoin ? 1 : 0);
2639
+ const pct = p.bucketPercent >= 0 ? p.bucketPercent : 0;
2640
+ let overall;
2641
+ let sectionLabel;
2642
+ let sectionPercent;
2643
+ if (p.stage === 'join') {
2644
+ // Clamp: with a single bucket (no dedicated join slot) the
2645
+ // join collapses to a file move and emits 100%, which would
2646
+ // otherwise compute >100 here.
2647
+ overall =
2648
+ denom > 0
2649
+ ? Math.min(100, Math.round(((p.bucketCount + pct / 100) / denom) * 100))
2650
+ : 99;
2651
+ sectionLabel = `Combining ${p.bucketCount} tile sets`;
2652
+ sectionPercent = p.bucketPercent;
2653
+ }
2654
+ else if (p.stage === 'export') {
2655
+ overall = denom > 0 ? Math.round(((p.bucketIndex - 1) / denom) * 100) : 0;
2656
+ sectionLabel = `${p.bucketLabel} — reading charts (${p.bucketIndex} of ${p.bucketCount})`;
2657
+ sectionPercent = -1;
2658
+ }
2659
+ else {
2660
+ overall =
2661
+ denom > 0 ? Math.round(((p.bucketIndex - 1 + pct / 100) / denom) * 100) : 0;
2662
+ sectionLabel = `${p.bucketLabel} — generating tiles (${p.bucketIndex} of ${p.bucketCount})`;
2663
+ sectionPercent = Math.round(pct);
2664
+ }
2665
+ setCatalogDetail(id, {
2666
+ phase: p.stage === 'join' ? 'joining' : 'converting',
2667
+ overallPercent: overall,
2668
+ sectionLabel,
2669
+ sectionPercent
2670
+ });
2671
+ },
2672
+ signal: abortController.signal
2673
+ }, stagingRoot);
2674
+ // 3. Promote the single output into the live chart library (root).
2675
+ await promoteQuarantine(quarantineDir, [conv.mbtilesFile], chartPath);
2676
+ setChartEnabled(conv.mbtilesFile, true);
2677
+ catalog.convertedChartPath = conv.mbtilesFile;
2678
+ catalog.lastConvertedAt = new Date().toISOString();
2679
+ // Only a complete bundle counts as up-to-date. A partial download
2680
+ // produced a chart that omits some cells, so leave it out_of_date and
2681
+ // the UI flags it for a rebuild (which retries the failed cells).
2682
+ catalog.status = partialDownload ? 'out_of_date' : 'converted';
2683
+ saveCustomCatalog(catalog);
2684
+ await refreshChartProviders();
2685
+ const chartId = conv.mbtilesFile.replace(/\.mbtiles$/, '');
2686
+ if (chartProviders[chartId]) {
2687
+ emitChartDelta(chartId, sanitizeProvider(chartProviders[chartId], 2));
2688
+ }
2689
+ setCatalogProgress(id, 'completed', `Created ${conv.mbtilesFile}`);
2690
+ setCatalogDetail(id, {
2691
+ phase: 'completed',
2692
+ overallPercent: 100,
2693
+ sectionLabel: `Created ${conv.mbtilesFile}`,
2694
+ sectionPercent: 100
2695
+ });
2696
+ appendCatalogLog(id, `Done: ${conv.mbtilesFile}`);
2697
+ // Let the completed state linger briefly, then clear so the UI settles
2698
+ // onto the persisted 'converted' status.
2699
+ setTimeout(() => clearCatalogProgress(id), 8000);
2700
+ }
2701
+ catch (error) {
2702
+ const msg = error instanceof Error ? error.message : String(error);
2703
+ if (isCatalogCancelRequested(id)) {
2704
+ appendCatalogLog(id, 'Cancelled by user.');
2705
+ setCatalogProgress(id, 'cancelled', 'Cancelled');
2706
+ setCatalogDetail(id, {
2707
+ phase: 'cancelled',
2708
+ sectionLabel: 'Cancelled',
2709
+ sectionPercent: -1
2710
+ });
2711
+ // Drop the cancelled state after a short linger.
2712
+ setTimeout(() => clearCatalogProgress(id), 6000);
2713
+ }
2714
+ else {
2715
+ app.error(`Custom catalog ${id} download/convert failed: ${msg}`);
2716
+ setCatalogFailed(id, msg);
2717
+ setCatalogDetail(id, { phase: 'failed', sectionLabel: msg, sectionPercent: -1 });
2718
+ }
2719
+ // Mark out_of_date (unless it had a prior good conversion) so the UI
2720
+ // shows the coverage needs a (re)build.
2721
+ const latest = getCustomCatalog(id);
2722
+ if (latest && latest.selectedBand4ChartIds.length > 0 && latest.status !== 'converted') {
2723
+ latest.status = 'out_of_date';
2724
+ saveCustomCatalog(latest);
2725
+ }
2726
+ }
2727
+ finally {
2728
+ if (convertingFlagged) {
2729
+ setConvertingState(id, false);
2730
+ }
2731
+ if (quarantineDir !== null) {
2732
+ cleanupQuarantineDir(quarantineDir);
2733
+ }
2734
+ cleanupDir(stagingRoot);
2735
+ clearCatalogCancel(id);
2736
+ setCatalogBusy(id, false);
2737
+ }
2738
+ })();
2739
+ }
2261
2740
  const registerAsProvider = () => {
2262
2741
  app.debug('** Registering as Resource Provider for `charts` **');
2263
2742
  try {