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.
- package/README.md +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +481 -2
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +36 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/container-jobs.d.ts +15 -1
- package/dist/utils/container-jobs.d.ts.map +1 -1
- package/dist/utils/container-jobs.js +4 -1
- package/dist/utils/container-jobs.js.map +1 -1
- package/dist/utils/container-manager.d.ts +9 -1
- package/dist/utils/container-manager.d.ts.map +1 -1
- package/dist/utils/container-manager.js.map +1 -1
- package/dist/utils/custom-catalog-download.d.ts +33 -0
- package/dist/utils/custom-catalog-download.d.ts.map +1 -0
- package/dist/utils/custom-catalog-download.js +131 -0
- package/dist/utils/custom-catalog-download.js.map +1 -0
- package/dist/utils/custom-catalog-manager.d.ts +126 -0
- package/dist/utils/custom-catalog-manager.d.ts.map +1 -0
- package/dist/utils/custom-catalog-manager.js +414 -0
- package/dist/utils/custom-catalog-manager.js.map +1 -0
- package/dist/utils/job-failure.d.ts +22 -0
- package/dist/utils/job-failure.d.ts.map +1 -0
- package/dist/utils/job-failure.js +38 -0
- package/dist/utils/job-failure.js.map +1 -0
- package/dist/utils/noaa-enc-footprints.d.ts +132 -0
- package/dist/utils/noaa-enc-footprints.d.ts.map +1 -0
- package/dist/utils/noaa-enc-footprints.js +386 -0
- package/dist/utils/noaa-enc-footprints.js.map +1 -0
- package/dist/utils/rnc-converter.d.ts.map +1 -1
- package/dist/utils/rnc-converter.js +3 -2
- package/dist/utils/rnc-converter.js.map +1 -1
- package/dist/utils/s57-converter.d.ts +23 -0
- package/dist/utils/s57-converter.d.ts.map +1 -1
- package/dist/utils/s57-converter.js +258 -69
- package/dist/utils/s57-converter.js.map +1 -1
- package/package.json +3 -1
- package/public/css/custom-catalogs.css +407 -0
- package/public/index.html +19 -0
- package/public/js/download-simple.js +1 -1
- package/public/js/download-simple.ts +1 -1
- package/public/js/globals.d.ts +2 -0
- package/public/js/noaa-chart-selector.js +834 -0
- package/public/js/noaa-chart-selector.ts +960 -0
- package/public/js/tabs.js +3 -0
- package/public/js/tabs.ts +2 -0
- package/public/vendor/leaflet/images/layers-2x.png +0 -0
- package/public/vendor/leaflet/images/layers.png +0 -0
- package/public/vendor/leaflet/images/marker-icon-2x.png +0 -0
- package/public/vendor/leaflet/images/marker-icon.png +0 -0
- package/public/vendor/leaflet/images/marker-shadow.png +0 -0
- package/public/vendor/leaflet/leaflet.css +661 -0
- 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
|
|
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 |
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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 {
|