diff --git a/README.md b/README.md index 046e35a..597376d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,13 @@ Signal K Node server plugin to provide chart metadata, such as name, description and location of the actual chart tile data. -Supports both v1 and v2 Signal K resources api paths. +Chart metadata is derived from the following supported chart file types: +- MBTiles _(.mbtiles)_ +- TMS _(tilemapresource.xml and tiles)_ + +Additionally, chart metadata can be entered via the plugin configuration for other chart sources and types _(e.g. WMS, WMTS, S-57 tiles and tilejson)_. + +Chart metadata is made available to both v1 and v2 Signal K `resources` api paths. | Server Version | API | Path | |--- |--- |--- | @@ -10,44 +16,57 @@ Supports both v1 and v2 Signal K resources api paths. | 2.x.x | v2 | `/signalk/v2/api/resources/charts` | -_Note: v2 resource paths will only be made available on Signal K server >= v2._ +_Note: Version 2 resource paths will only be made available on Signal K server v2.0.0 and later_ -### Usage +## Usage -1. Install "Signal K Charts" plugin from Signal K Appstore +1. Install `@signalk/signalk-charts` from the Signal K Server Appstore -2. Configure plugin in **Plugin Config** +2. Configure the plugin in the Admin UI _(**Server -> Plugin Config -> Signal K Charts**)_ -- Add "Chart paths" which are the paths to the folders where chart files are stored. Defaults to `${signalk-configuration-path}/charts` +3. Activate the plugin +Chart metadata will then be available to client apps via the resources api `/resources/charts` for example: +- [Freeboard SK](https://www.npmjs.com/package/@signalk/freeboard-sk) +- [Tuktuk Chart Plotter](https://www.npmjs.com/package/tuktuk-chart-plotter) -3. Add "Chart paths" in plugin configuration. Defaults to `${signalk-configuration-path}/charts` -Chart paths configuration +## Configuration -4. Put charts into selected paths +### Local Chart Files -5. Add optional online chart providers +If you are using chart files stored on the Signal K Server you will need to add the locations where the chart files are stored so the plugin can generate the chart metadata. -Online chart providers configuration +Do this by adding "Chart paths" and providing the path to each folder on the Signal K Server where chart files are stored. _(Defaults to `${signalk-configuration-path}/charts`)_ +Chart paths configuration +When chart files are added to the folder(s) they will be processed by the plugin and the chart metadata will be available. -_WMS example:_ +### Online chart providers -server type configuration +If your chart source is not local to the Signal K Server you can add "Online Chart Providers" and enter the required charts metadata for the source. -6. Activate plugin +You will need to provide the following information: +1. A chart name for client applications to display +2. The URL to the chart source +3. Select the chart image format +4. The minimum and maximum zoom levels where chart data is available. + +You can also provide a description detailing the chart content. + +Online chart providers configuration + +For WMS & WMTS sources you can specify the layers you wish to display. + +Online chart provider layers -7. Use one of the client apps supporting Signal K charts, for example: -- [Freeboard SK](https://www.npmjs.com/package/@signalk/freeboard-sk) -- [Tuktuk Chart Plotter](https://www.npmjs.com/package/tuktuk-chart-plotter) ### Supported chart formats -- [MBTiles](https://github.com/mapbox/mbtiles-spec) file +- [MBTiles](https://github.com/mapbox/mbtiles-spec) files - Directory with cached [TMS](https://wiki.osgeo.org/wiki/Tile_Map_Service_Specification) tiles and `tilemapresource.xml` - Directory with XYZ tiles and `metadata.json` - Online [TMS](https://wiki.osgeo.org/wiki/Tile_Map_Service_Specification) @@ -57,13 +76,41 @@ Publicly available MBTiles charts can be found from: - [Finnish Transport Agency nautical charts](https://github.com/vokkim/rannikkokartat-mbtiles) - [Signal K World Coastline Map](https://github.com/netAction/signalk-world-coastline-map), download [MBTiles release](https://github.com/netAction/signalk-world-coastline-map/releases/download/v1.0/signalk-world-coastline-map-database.tgz) + +--- + ### API Plugin adds support for `/resources/charts` endpoints described in [Signal K specification](http://signalk.org/specification/1.0.0/doc/otherBranches.html#resourcescharts): -- `GET /signalk/v1/api/resources/charts/` returns metadata for all available charts -- `GET /signalk/v1/api/resources/charts/${identifier}/` returns metadata for selected chart -- `GET /signalk/v1/api/resources/charts/${identifier}/${z}/${x}/${y}` returns a single tile for selected offline chart. As charts-plugin isn't proxy, online charts is not available via this request. You should look the metadata to find proper request. +- List available charts + +```bash +# v1 API +GET /signalk/v1/api/resources/charts/` + +# v2 API +GET /signalk/v2/api/resources/charts/` +``` + +- Return metadata for selected chart + +```bash +# v1 API +GET /signalk/v1/api/resources/charts/${identifier}` + +# v2 API +GET /signalk/v2/api/resources/charts/${identifier}` +``` + +#### Chart Tiles +Chart tiles are retrieved using the url defined in the chart metadata. + +For local chart files located in the Chart Path(s) defined in the plugin configuration, the url will be: + +```bash +/signalk/chart-tiles/${identifier}/${z}/${x}/${y} +``` License ------- diff --git a/package.json b/package.json index 6c7e308..bf3e4aa 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,9 @@ "dependencies": { "@mapbox/mbtiles": "^0.12.1", "@signalk/server-api": "^2.0.0-beta.3", - "baconjs": "1.0.1", "bluebird": "3.5.1", "lodash": "^4.17.11", - "xml2js": "0.4.19" + "xml2js": "^0.6.2" }, "repository": { "type": "git", @@ -47,12 +46,12 @@ "@types/node": "^18.14.4", "@typescript-eslint/eslint-plugin": "^5.52.0", "@typescript-eslint/parser": "^5.52.0", - "body-parser": "1.18.2", + "body-parser": "^1.18.2", "chai": "4.1.2", "chai-http": "^4.2.1", "eslint": "^8.34.0", "eslint-config-prettier": "^8.6.0", - "express": "4.19.2", + "express": "^4.19.2", "mocha": "5.0.0", "prettier": "^2.8.4", "typescript": "^4.5.4" diff --git a/src/charts.ts b/src/charts.ts index 1ae32c9..0351311 100644 --- a/src/charts.ts +++ b/src/charts.ts @@ -80,13 +80,13 @@ function openMbtilesFile(file: string, filename: string) { type: 'tilelayer', scale: parseInt(res.metadata.scale) || 250000, v1: { - tilemapUrl: `~basePath~/charts/${identifier}/{z}/{x}/{y}`, + tilemapUrl: `~tilePath~/${identifier}/{z}/{x}/{y}`, chartLayers: res.metadata.vector_layers ? parseVectorLayers(res.metadata.vector_layers) : [] }, v2: { - url: `~basePath~/charts/${identifier}/{z}/{x}/{y}`, + url: `~tilePath~/${identifier}/{z}/{x}/{y}`, layers: res.metadata.vector_layers ? parseVectorLayers(res.metadata.vector_layers) : [] @@ -133,11 +133,11 @@ function directoryToMapInfo(file: string, identifier: string) { ;(info._fileFormat = 'directory'), (info._filePath = file), (info.v1 = { - tilemapUrl: `~basePath~/charts/${identifier}/{z}/{x}/{y}`, + tilemapUrl: `~tilePath~/${identifier}/{z}/{x}/{y}`, chartLayers: [] }) info.v2 = { - url: `~basePath~/charts/${identifier}/{z}/{x}/{y}`, + url: `~tilePath~/${identifier}/{z}/{x}/{y}`, layers: [] } diff --git a/src/constants.ts b/src/constants.ts deleted file mode 100644 index e3cd161..0000000 --- a/src/constants.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const apiRoutePrefix = { - 1: '/signalk/v1/api/resources', - 2: '/signalk/v2/api/resources' -} diff --git a/src/index.ts b/src/index.ts index e76d479..6c403e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,35 +1,27 @@ import * as bluebird from 'bluebird' import path from 'path' -import fs from 'fs' +import fs, { FSWatcher } from 'fs' import * as _ from 'lodash' import { findCharts } from './charts' -import { apiRoutePrefix } from './constants' import { ChartProvider, OnlineChartProvider } from './types' import { Request, Response, Application } from 'express' import { OutgoingHttpHeaders } from 'http' import { Plugin, - PluginServerApp, + ServerAPI, ResourceProviderRegistry } from '@signalk/server-api' -const MIN_ZOOM = 1 -const MAX_ZOOM = 24 - interface Config { chartPaths: string[] onlineChartProviders: OnlineChartProvider[] + accessToken: string } interface ChartProviderApp - extends PluginServerApp, + extends ServerAPI, ResourceProviderRegistry, Application { - statusMessage?: () => string - error: (msg: string) => void - debug: (...msg: unknown[]) => void - setPluginStatus: (pluginId: string, status?: string) => void - setPluginError: (pluginId: string, status?: string) => void config: { ssl: boolean configPath: string @@ -38,16 +30,21 @@ interface ChartProviderApp } } +const MIN_ZOOM = 1 +const MAX_ZOOM = 24 +const chartTilesPath = '/signalk/chart-tiles' +let chartPaths: Array +let onlineProviders = {} +let lastWatchEvent: number | undefined +const watchers: Array = [] + module.exports = (app: ChartProviderApp): Plugin => { - let chartProviders: { [key: string]: ChartProvider } = {} - let pluginStarted = false - let props: Config = { - chartPaths: [], - onlineChartProviders: [] - } + let _chartProviders: { [key: string]: ChartProvider } = {} const configBasePath = app.config.configPath const defaultChartsPath = path.join(configBasePath, '/charts') - const serverMajorVersion = app.config.version ? parseInt(app.config.version.split('.')[0]) : '1' + const serverMajorVersion = app.config.version + ? parseInt(app.config.version.split('.')[0]) + : '1' ensureDirectoryExists(defaultChartsPath) // ******** REQUIRED PLUGIN DEFINITION ******* @@ -99,7 +96,14 @@ module.exports = (app: ChartProviderApp): Plugin => { type: 'string', title: 'Map source / server type', default: 'tilelayer', - enum: ['tilelayer', 'S-57', 'WMS', 'WMTS', 'mapstyleJSON', 'tileJSON'], + enum: [ + 'tilelayer', + 'S-57', + 'WMS', + 'WMTS', + 'mapboxstyle', + 'tilejson' + ], description: 'Map data source type served by the supplied url. (Use tilelayer for xyz / tms tile sources.)' }, @@ -147,25 +151,27 @@ module.exports = (app: ChartProviderApp): Plugin => { name: 'Signal K Charts', schema: () => CONFIG_SCHEMA, uiSchema: () => CONFIG_UISCHEMA, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - start: (settings: any) => { - return doStartup(settings) // return required for tests + start: (config: object) => { + return doStartup(config as Config) // return required for tests }, stop: () => { + watchers.forEach((w) => w.close()) app.setPluginStatus('stopped') } } - const doStartup = (config: Config) => { - app.debug('** loaded config: ', config) - props = { ...config } + const doStartup = async (config: Config) => { + app.debug(`** loaded config: ${config}`) - const chartPaths = _.isEmpty(props.chartPaths) + registerRoutes() + app.setPluginStatus('Started') + + chartPaths = _.isEmpty(config.chartPaths) ? [defaultChartsPath] - : resolveUniqueChartPaths(props.chartPaths, configBasePath) + : resolveUniqueChartPaths(config.chartPaths, configBasePath) - const onlineProviders = _.reduce( - props.onlineChartProviders, + onlineProviders = _.reduce( + config.onlineChartProviders, (result: { [key: string]: object }, data) => { const provider = convertOnlineProviderConfig(data) result[provider.identifier] = provider @@ -173,59 +179,87 @@ module.exports = (app: ChartProviderApp): Plugin => { }, {} ) + + chartPaths.forEach((p) => { + app.debug(`watching folder.. ${p}`) + watchers.push(fs.watch(p, 'utf8', () => handleWatchEvent())) + }) + app.debug( `Start charts plugin. Chart paths: ${chartPaths.join( ', ' )}, online charts: ${Object.keys(onlineProviders).length}` ) - // Do not register routes if plugin has been started once already - pluginStarted === false && registerRoutes() - pluginStarted = true - const urlBase = `${app.config.ssl ? 'https' : 'http'}://localhost:${ - 'getExternalPort' in app.config ? app.config.getExternalPort() : 3000 - }` - app.debug('**urlBase**', urlBase) - app.setPluginStatus('Started') + return loadCharts() + } + + // Load chart files + const loadCharts = async () => { + app.debug(`Loading Charts....`) - const loadProviders = bluebird - .mapSeries(chartPaths, (chartPath: string) => findCharts(chartPath)) - .then((list: ChartProvider[]) => - _.reduce(list, (result, charts) => _.merge({}, result, charts), {}) + try { + const plist = await bluebird.mapSeries(chartPaths, (chartPath: string) => + findCharts(chartPath) + ) + const charts = _.reduce( + plist, + (result, charts) => _.merge({}, result, charts), + {} ) + app.debug( + `Chart plugin: Found ${ + _.keys(charts).length + } charts from ${chartPaths.join(', ')}.` + ) + _chartProviders = _.merge({}, charts, onlineProviders) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (e: any) { + console.error(`Error loading chart providers`, e.message) + _chartProviders = {} + app.setPluginError(`Error loading chart providers`) + } + } - return loadProviders - .then((charts: { [key: string]: ChartProvider }) => { - app.debug( - `Chart plugin: Found ${ - _.keys(charts).length - } charts from ${chartPaths.join(', ')}.` - ) - chartProviders = _.merge({}, charts, onlineProviders) - }) - .catch((e: Error) => { - console.error(`Error loading chart providers`, e.message) - chartProviders = {} - app.setPluginError(`Error loading chart providers`) - }) + const refreshProviders = async () => { + const td = Date.now() - (lastWatchEvent as number) + app.debug(`last watch event time elapsed = ${td}`) + if (lastWatchEvent && td > 5000) { + app.debug(`Reloading Charts...`) + lastWatchEvent = undefined + await loadCharts() + } + } + + const getChartProviders = async (): Promise<{ + [id: string]: ChartProvider + }> => { + await refreshProviders() + return _chartProviders + } + + const handleWatchEvent = () => { + lastWatchEvent = Date.now() } const registerRoutes = () => { app.debug('** Registering API paths **') + app.debug(`** Registering map tile path (${chartTilesPath} **`) app.get( - `/signalk/:version(v[1-2])/api/resources/charts/:identifier/:z([0-9]*)/:x([0-9]*)/:y([0-9]*)`, + `${chartTilesPath}/:identifier/:z([0-9]*)/:x([0-9]*)/:y([0-9]*)`, async (req: Request, res: Response) => { const { identifier, z, x, y } = req.params - const provider = chartProviders[identifier] - if (!provider) { + const providers = await getChartProviders() + if (!providers[identifier]) { return res.sendStatus(404) } - switch (provider._fileFormat) { + + switch (providers[identifier]._fileFormat) { case 'directory': return serveTileFromFilesystem( res, - provider, + providers[identifier], parseInt(z), parseInt(x), parseInt(y) @@ -233,14 +267,14 @@ module.exports = (app: ChartProviderApp): Plugin => { case 'mbtiles': return serveTileFromMbtiles( res, - provider, + providers[identifier], parseInt(z), parseInt(x), parseInt(y) ) default: console.log( - `Unknown chart provider fileformat ${provider._fileFormat}` + `Unknown chart provider fileformat ${providers[identifier]._fileFormat}` ) res.status(500).send() } @@ -250,24 +284,28 @@ module.exports = (app: ChartProviderApp): Plugin => { app.debug('** Registering v1 API paths **') app.get( - apiRoutePrefix[1] + '/charts/:identifier', - (req: Request, res: Response) => { + '/signalk/v1/api/resources/charts/:identifier', + async (req: Request, res: Response) => { const { identifier } = req.params - const provider = chartProviders[identifier] - if (provider) { - return res.json(sanitizeProvider(provider)) + const providers = await getChartProviders() + if (providers[identifier]) { + return res.json(sanitizeProvider(providers[identifier])) } else { return res.status(404).send('Not found') } } ) - app.get(apiRoutePrefix[1] + '/charts', (req: Request, res: Response) => { - const sanitized = _.mapValues(chartProviders, (provider) => - sanitizeProvider(provider) - ) - res.json(sanitized) - }) + app.get( + '/signalk/v1/api/resources/charts', + async (req: Request, res: Response) => { + const providers = await getChartProviders() + const sanitized = _.mapValues(providers, (provider) => + sanitizeProvider(provider) + ) + res.json(sanitized) + } + ) // v2 routes if (serverMajorVersion === 2) { @@ -283,21 +321,22 @@ module.exports = (app: ChartProviderApp): Plugin => { app.registerResourceProvider({ type: 'charts', methods: { - listResources: (params: { + listResources: async (params: { [key: string]: number | string | object | null }) => { - app.debug(`** listResources()`, params) + app.debug(`** listResources() ${params}`) + const providers = await getChartProviders() return Promise.resolve( - _.mapValues(chartProviders, (provider) => + _.mapValues(providers, (provider) => sanitizeProvider(provider, 2) ) ) }, - getResource: (id: string) => { - app.debug(`** getResource()`, id) - const provider = chartProviders[id] - if (provider) { - return Promise.resolve(sanitizeProvider(provider, 2)) + getResource: async (id: string) => { + app.debug(`** getResource() ${id}`) + const providers = await getChartProviders() + if (providers[id]) { + return Promise.resolve(sanitizeProvider(providers[id], 2)) } else { throw new Error('Chart not found!') } @@ -364,10 +403,12 @@ const sanitizeProvider = (provider: ChartProvider, version = 1) => { let v if (version === 1) { v = _.merge({}, provider.v1) - v.tilemapUrl = v.tilemapUrl.replace('~basePath~', apiRoutePrefix[1]) - } else if (version === 2) { + v.tilemapUrl = v.tilemapUrl + ? v.tilemapUrl.replace('~tilePath~', chartTilesPath) + : '' + } else { v = _.merge({}, provider.v2) - v.url = v.url ? v.url.replace('~basePath~', apiRoutePrefix[2]) : '' + v.url = v.url ? v.url.replace('~tilePath~', chartTilesPath) : '' } provider = _.omit(provider, [ '_filePath', diff --git a/src/types.ts b/src/types.ts index dd3e7da..f133bfb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,10 @@ -type MapSourceType = 'tilelayer' | 'S-57' | 'WMS' | 'WMTS' | 'mapstyleJSON' | 'tileJSON' +type MapSourceType = + | 'tilelayer' + | 'S-57' + | 'WMS' + | 'WMTS' + | 'mapboxstyle' + | 'tilejson' export interface ChartProvider { _fileFormat?: 'mbtiles' | 'directory' @@ -13,11 +19,11 @@ export interface ChartProvider { scale: number v1?: { tilemapUrl: string - chartLayers: string[] + chartLayers?: string[] } v2?: { url: string - layers: string[] + layers?: string[] } bounds?: number[] minzoom?: number diff --git a/test/expected-charts.json b/test/expected-charts.json index 5c8b180..7434814 100644 --- a/test/expected-charts.json +++ b/test/expected-charts.json @@ -14,7 +14,7 @@ "minzoom": 3, "name": "MBTILES_19", "scale": 250000, - "tilemapUrl": "/signalk/v1/api/resources/charts/test/{z}/{x}/{y}", + "tilemapUrl": "/signalk/chart-tiles/test/{z}/{x}/{y}", "type": "tilelayer" }, "tms-tiles": { @@ -32,7 +32,7 @@ "minzoom": 4, "name": "Översikt Svenska Sjökort", "scale": 4000000, - "tilemapUrl": "/signalk/v1/api/resources/charts/tms-tiles/{z}/{x}/{y}", + "tilemapUrl": "/signalk/chart-tiles/tms-tiles/{z}/{x}/{y}", "type": "tilelayer" }, "unpacked-tiles": { @@ -50,7 +50,7 @@ "minzoom": 3, "name": "NOAA MBTiles test file", "scale": 250000, - "tilemapUrl": "/signalk/v1/api/resources/charts/unpacked-tiles/{z}/{x}/{y}", + "tilemapUrl": "/signalk/chart-tiles/unpacked-tiles/{z}/{x}/{y}", "type": "tilelayer" } } \ No newline at end of file diff --git a/test/plugin-test.js b/test/plugin-test.js index f9bcfa5..00bc8b6 100644 --- a/test/plugin-test.js +++ b/test/plugin-test.js @@ -109,7 +109,8 @@ describe('GET /resources/charts', () => { }) -describe('GET /resources/charts/:identifier/:z/:x/:y', () => { + +describe('GET /signalk/chart-tiles/:identifier/:z/:x/:y', () => { let plugin let testServer beforeEach(() => @@ -119,11 +120,14 @@ describe('GET /resources/charts/:identifier/:z/:x/:y', () => { testServer = server }) ) - afterEach(done => testServer.close(() => done())) + afterEach(done => { + plugin.stop() + testServer.close(() => done()) + }) it('returns correct tile from MBTiles file', () => { return plugin.start({}) - .then(() => get(testServer, '/signalk/v1/api/resources/charts/test/4/5/6')) + .then(() => get(testServer, '/signalk/chart-tiles/test/4/5/6')) .then(response => { // unpacked-tiles contains same tiles as the test.mbtiles file expectTileResponse(response, 'charts/unpacked-tiles/4/5/6.png', 'image/png') @@ -133,7 +137,7 @@ describe('GET /resources/charts/:identifier/:z/:x/:y', () => { it('returns correct tile from directory', () => { const expectedTile = fs.readFileSync(path.resolve(__dirname, 'charts/unpacked-tiles/4/4/6.png')) return plugin.start({}) - .then(() => get(testServer, '/signalk/v1/api/resources/charts/unpacked-tiles/4/4/6')) + .then(() => get(testServer, '/signalk/chart-tiles/unpacked-tiles/4/4/6')) .then(response => { expectTileResponse(response, 'charts/unpacked-tiles/4/4/6.png', 'image/png') }) @@ -143,7 +147,7 @@ describe('GET /resources/charts/:identifier/:z/:x/:y', () => { const expectedTile = fs.readFileSync(path.resolve(__dirname, 'charts/tms-tiles/5/17/21.png')) // Y-coordinate flipped return plugin.start({}) - .then(() => get(testServer, '/signalk/v1/api/resources/charts/tms-tiles/5/17/10')) + .then(() => get(testServer, '/signalk/chart-tiles/tms-tiles/5/17/10')) .then(response => { expectTileResponse(response, 'charts/tms-tiles/5/17/21.png', 'image/png') }) @@ -151,7 +155,7 @@ describe('GET /resources/charts/:identifier/:z/:x/:y', () => { it('returns 404 for missing tile', () => { return plugin.start({}) - .then(() => get(testServer, '/signalk/v1/api/resources/charts/tms-tiles/5/55/10')) + .then(() => get(testServer, '/signalk/chart-tiles/tms-tiles/5/55/10')) .catch(e => e.response) .then(response => { expect(response.status).to.equal(404) @@ -160,7 +164,7 @@ describe('GET /resources/charts/:identifier/:z/:x/:y', () => { it('returns 404 for wrong chart identifier', () => { return plugin.start({}) - .then(() => get(testServer, '/signalk/v1/api/resources/charts/foo/4/4/6')) + .then(() => get(testServer, '/signalk/chart-tiles/foo/4/4/6')) .catch(e => e.response) .then(response => { expect(response.status).to.equal(404) diff --git a/tsconfig.json b/tsconfig.json index 8987f83..b16c5ef 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es6", + "target": "ES2022", "module": "commonjs", "outDir": "./plugin", "esModuleInterop": true, @@ -21,7 +21,7 @@ "ignoreCompilerErrors": true, "excludePrivate": true, "excludeNotExported": true, - "target": "ES5", + "target": "ES2022", "moduleResolution": "node", "preserveConstEnums": true, "stripInternal": true,