diff --git a/.eslintrc.js b/.eslintrc.js
new file mode 100644
index 0000000..e6c8977
--- /dev/null
+++ b/.eslintrc.js
@@ -0,0 +1,25 @@
+// eslint-disable-next-line no-undef
+module.exports = {
+ root: true,
+ extends: ['eslint:recommended', 'prettier'],
+ parserOptions: {
+ ecmaVersion: 2019,
+ sourceType: 'module'
+ },
+ env: {
+ node: true,
+ es2019: true
+ },
+ overrides: [
+ {
+ files: ['**/*.ts'],
+ extends: [
+ 'eslint:recommended',
+ 'plugin:@typescript-eslint/recommended',
+ 'prettier'
+ ],
+ parser: '@typescript-eslint/parser',
+ plugins: ['@typescript-eslint']
+ }
+ ]
+}
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index d860367..de315ee 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -11,7 +11,7 @@ jobs:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
- node-version: '16.x'
+ node-version: '18.x'
registry-url: 'https://registry.npmjs.org'
- run: npm publish --access public
env:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 8514289..e70d512 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -11,7 +11,7 @@ jobs:
strategy:
matrix:
- node-version: [10.x, 16.x]
+ node-version: [18.x]
steps:
- uses: actions/checkout@v2
diff --git a/.gitignore b/.gitignore
index 2752eb9..b21c47a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,5 @@
node_modules/
.DS_Store
+plugin/
+package-lock.json
+tsconfig.tsbuildinfo
diff --git a/.npmignore b/.npmignore
index fe9a459..87880ba 100644
--- a/.npmignore
+++ b/.npmignore
@@ -1,3 +1,11 @@
node_modules/
.DS_Store
test/
+package-lock.json
+src/
+tsconfig.json
+.eslintrc.js
+.prettierrc.json
+.github/
+tsconfig.tsbuildinfo
+*.tgz
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..8c158f2
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,5 @@
+{
+ "semi": false,
+ "singleQuote": true,
+ "trailingComma": "none"
+}
\ No newline at end of file
diff --git a/README.md b/README.md
index 8189978..046e35a 100644
--- a/README.md
+++ b/README.md
@@ -1,27 +1,47 @@
-# Signal K Node server Charts plugin
+# Signal K server Charts plugin
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.
+
+| Server Version | API | Path |
+|--- |--- |--- |
+| 1.x.x | v1 | `/signalk/v1/api/resources/charts` |
+| 2.x.x | v2 | `/signalk/v2/api/resources/charts` |
+
+
+_Note: v2 resource paths will only be made available on Signal K server >= v2._
+
### Usage
1. Install "Signal K Charts" plugin from Signal K Appstore
-2. Add "Chart paths" in plugin configuration. Defaults to `${signalk-configuration-path}/charts`
+2. Configure plugin in **Plugin Config**
+
+- Add "Chart paths" which are the paths to the folders where chart files are stored. Defaults to `${signalk-configuration-path}/charts`
+
+
+3. Add "Chart paths" in plugin configuration. Defaults to `${signalk-configuration-path}/charts`
-3. Put charts into selected paths
-4. Add optional online chart providers
+4. Put charts into selected paths
+
+5. Add optional online chart providers
+
+
+
_WMS example:_
-![image](https://user-images.githubusercontent.com/38519157/102832518-90077100-443e-11eb-9a1d-d0806bb2b10b.png)
-5. Activate plugin
+
+
+6. Activate plugin
-6. Use one of the client apps supporting Signal K charts, for example:
+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)
diff --git a/index.js b/index.js
deleted file mode 100644
index fff232c..0000000
--- a/index.js
+++ /dev/null
@@ -1 +0,0 @@
-module.exports = require('./src/plugin')
diff --git a/package.json b/package.json
index 1608c17..2460df3 100644
--- a/package.json
+++ b/package.json
@@ -1,11 +1,20 @@
{
"name": "@signalk/charts-plugin",
- "version": "2.3.1",
+ "version": "2.4.0",
"description": "Signal K plugin to provide chart support for Signal K server",
- "main": "index.js",
+ "main": "plugin/index.js",
"scripts": {
+ "build": "tsc",
+ "build-declaration": "tsc --declaration --allowJs false",
+ "watch": "npm run build -- -w",
+ "start": "npm run build -- -w",
+ "prepare": "tsc",
+ "prettier": "prettier -w src/",
+ "lint": "eslint -c .eslintrc.js --ext .ts --ext .js --fix src/",
+ "format": "npm run prettier && npm run lint",
+ "ci-lint": "eslint -c .eslintrc.js --ext .ts --ext .js src/ && prettier --check src/",
"test": "mocha",
- "deploy": "npm test && git tag -a v$npm_package_version -m 'Release v$npm_package_version'; git push --tags; npm publish --access public --scope @signalk ."
+ "deploy": "npm build && npm test && git tag -a v$npm_package_version -m 'Release v$npm_package_version'; git push --tags; npm publish --access public --scope @signalk ."
},
"license": "Apache-2.0",
"keywords": [
@@ -22,6 +31,7 @@
},
"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",
@@ -32,10 +42,19 @@
"url": "https://github.com/SignalK/charts-plugin"
},
"devDependencies": {
+ "@types/express": "^4.17.17",
+ "@types/lodash": "^4.14.191",
+ "@types/node": "^18.14.4",
+ "@typescript-eslint/eslint-plugin": "^5.52.0",
+ "@typescript-eslint/parser": "^5.52.0",
"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.16.2",
- "mocha": "5.0.0"
+ "mocha": "5.0.0",
+ "prettier": "^2.8.4",
+ "typescript": "^4.5.4"
}
}
diff --git a/src/@types/bluebird.d.ts b/src/@types/bluebird.d.ts
new file mode 100644
index 0000000..18ce023
--- /dev/null
+++ b/src/@types/bluebird.d.ts
@@ -0,0 +1 @@
+declare module 'bluebird'
diff --git a/src/@types/mapbox_mbtiles.d.ts b/src/@types/mapbox_mbtiles.d.ts
new file mode 100644
index 0000000..0b52a9c
--- /dev/null
+++ b/src/@types/mapbox_mbtiles.d.ts
@@ -0,0 +1 @@
+declare module '@mapbox/mbtiles'
diff --git a/src/@types/xml2js.d.ts b/src/@types/xml2js.d.ts
new file mode 100644
index 0000000..58c7edf
--- /dev/null
+++ b/src/@types/xml2js.d.ts
@@ -0,0 +1 @@
+declare module 'xml2js'
diff --git a/src/charts.js b/src/charts.js
deleted file mode 100644
index 5ce2c35..0000000
--- a/src/charts.js
+++ /dev/null
@@ -1,175 +0,0 @@
-const debug = require('debug')('signalk-charts-plugin')
-const Promise = require('bluebird')
-const path = require('path')
-const MBTiles = require('@mapbox/mbtiles')
-const xml2js = require('xml2js')
-const fs = Promise.promisifyAll(require('fs'))
-const _ = require('lodash')
-const {apiRoutePrefix} = require('./constants')
-
-function findCharts(chartBaseDir) {
- return fs
- .readdirAsync(chartBaseDir)
- .then(files => {
- return Promise.mapSeries(files, filename => {
- const isMbtilesFile = filename.match(/\.mbtiles$/i)
- const file = path.resolve(chartBaseDir, filename)
- const isDirectory = fs.statSync(file).isDirectory()
- if (isMbtilesFile) {
- return openMbtilesFile(file, filename)
- } else if (isDirectory) {
- return directoryToMapInfo(file, filename)
- } else {
- return Promise.resolve(null)
- }
- })
- })
- .then(result => _.filter(result, _.identity))
- .then(charts => _.reduce(charts, (result, chart) => {
- result[chart.identifier] = chart
- return result
- }, {}))
- .catch(err => {
- console.error(`Error reading charts directory ${chartBaseDir}:${err.message}`)
- })
-}
-
-function openMbtilesFile(file, filename) {
- return new Promise((resolve, reject) => {
- new MBTiles(file, (err, mbtiles) => {
- if (err) {
- return reject(err)
- }
- mbtiles.getInfo((err, metadata) => {
- if (err) {
- return reject(err)
- }
-
- return resolve({mbtiles, metadata})
- })
- })
- }).then(({mbtiles, metadata}) => {
- if (_.isEmpty(metadata) || metadata.bounds === undefined) {
- return null
- }
- const identifier = filename.replace(/\.mbtiles$/i, '')
- return {
- _fileFormat: 'mbtiles',
- _mbtilesHandle: mbtiles,
- _flipY: false,
- identifier,
- name: metadata.name || metadata.id,
- description: metadata.description,
- bounds: metadata.bounds,
- minzoom: metadata.minzoom,
- maxzoom: metadata.maxzoom,
- format: metadata.format,
- type: 'tilelayer',
- tilemapUrl: `${apiRoutePrefix}/charts/${identifier}/{z}/{x}/{y}`,
- scale: metadata.scale || '250000'
- }
- }).catch(e => {
- console.error(`Error loading chart ${file}`, e.message)
- return null
- })
-}
-
-function parseTilemapResource(tilemapResource) {
- return fs
- .readFileAsync(tilemapResource)
- .then(Promise.promisify(xml2js.parseString))
- .then(parsed => {
- const result = parsed.TileMap
- const name = _.get(result, 'Title.0')
- const format = _.get(result, 'TileFormat.0.$.extension')
- const scale = _.get(result, 'Metadata.0.$.scale')
- const bbox = _.get(result, 'BoundingBox.0.$')
- const zoomLevels = _.map(_.get(result, 'TileSets.0.TileSet')||[], set => parseInt(_.get(set, '$.href')))
- return {
- _flipY: true,
- name,
- description: name,
- bounds: bbox ? [parseFloat(bbox.minx), parseFloat(bbox.miny), parseFloat(bbox.maxx), parseFloat(bbox.maxy)] : undefined,
- minzoom: !_.isEmpty(zoomLevels) ? _.min(zoomLevels) : undefined,
- maxzoom: !_.isEmpty(zoomLevels) ? _.max(zoomLevels) : undefined,
- format,
- type: 'tilelayer',
- scale: scale || '250000'
- }
- })
-}
-
-function parseMetadataJson(metadataJson) {
- return fs
- .readFileAsync(metadataJson)
- .then(JSON.parse)
- .then(metadata => {
- function parseBounds(bounds) {
- if (_.isString(bounds)) {
- return _.map(bounds.split(','), bound => parseFloat(_.trim(bound)))
- } else if (_.isArray(bounds) && bounds.length === 4) {
- return bounds
- } else {
- return undefined
- }
- }
- return {
- _flipY: false,
- name: metadata.name || metadata.id,
- description: metadata.description,
- bounds: parseBounds(metadata.bounds),
- minzoom: parseIntIfNotUndefined(metadata.minzoom),
- maxzoom: parseIntIfNotUndefined(metadata.maxzoom),
- format: metadata.format,
- type: 'tilelayer',
- scale: metadata.scale || '250000'
- }
- })
-}
-
-function directoryToMapInfo(file, identifier) {
- function loadInfo() {
- const tilemapResource = path.join(file, 'tilemapresource.xml')
- const metadataJson = path.join(file, 'metadata.json')
-
- const hasTilemapResource = fs.existsSync(tilemapResource)
- const hasMetadataJson = fs.existsSync(metadataJson)
- if (hasTilemapResource) {
- return parseTilemapResource(tilemapResource)
- } else if (hasMetadataJson) {
- return parseMetadataJson(metadataJson)
- } else {
- return Promise.resolve(null)
- }
- }
-
- return loadInfo()
- .then(info => {
- if (info) {
- if (!info.format) {
- console.error(`Missing format metadata for chart ${identifier}`)
- return null
- }
- return _.merge(info, {
- identifier,
- _fileFormat: 'directory',
- _filePath: file,
- tilemapUrl: `${apiRoutePrefix}/charts/${identifier}/{z}/{x}/{y}`,
- })
- }
- return null
- })
- .catch(e => {
- console.error(`Error getting charts from ${file}`, e.message)
- return undefined
- })
-}
-
-function parseIntIfNotUndefined(val) {
- const parsed = parseInt(val)
- return _.isFinite(parsed) ? parsed : undefined
-}
-
-module.exports = {
- findCharts
-}
diff --git a/src/charts.ts b/src/charts.ts
new file mode 100644
index 0000000..1ae32c9
--- /dev/null
+++ b/src/charts.ts
@@ -0,0 +1,231 @@
+import * as bluebird from 'bluebird'
+import path from 'path'
+import MBTiles from '@mapbox/mbtiles'
+import * as xml2js from 'xml2js'
+import { Dirent, promises as fs } from 'fs'
+import * as _ from 'lodash'
+import { ChartProvider } from './types'
+
+export function findCharts(chartBaseDir: string) {
+ return fs
+ .readdir(chartBaseDir, { withFileTypes: true })
+ .then((files) => {
+ return bluebird.mapSeries(files, (file: Dirent) => {
+ const isMbtilesFile = file.name.match(/\.mbtiles$/i)
+ const filePath = path.resolve(chartBaseDir, file.name)
+ const isDirectory = file.isDirectory()
+ if (isMbtilesFile) {
+ return openMbtilesFile(filePath, file.name)
+ } else if (isDirectory) {
+ return directoryToMapInfo(filePath, file.name)
+ } else {
+ return Promise.resolve(null)
+ }
+ })
+ })
+ .then((result: ChartProvider) => _.filter(result, _.identity))
+ .then((charts: ChartProvider[]) =>
+ _.reduce(
+ charts,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (result: any, chart: ChartProvider) => {
+ result[chart.identifier] = chart
+ return result
+ },
+ {}
+ )
+ )
+ .catch((err: Error) => {
+ console.error(
+ `Error reading charts directory ${chartBaseDir}:${err.message}`
+ )
+ })
+}
+
+function openMbtilesFile(file: string, filename: string) {
+ return (
+ new Promise((resolve, reject) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ new MBTiles(file, (err: Error, mbtiles: any) => {
+ if (err) {
+ return reject(err)
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ mbtiles.getInfo((err: Error, metadata: any) => {
+ if (err) {
+ return reject(err)
+ }
+ return resolve({ mbtiles, metadata })
+ })
+ })
+ })
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ .then((res: any) => {
+ if (_.isEmpty(res.metadata) || res.metadata.bounds === undefined) {
+ return null
+ }
+ const identifier = filename.replace(/\.mbtiles$/i, '')
+ const data: ChartProvider = {
+ _fileFormat: 'mbtiles',
+ _filePath: file,
+ _mbtilesHandle: res.mbtiles,
+ _flipY: false,
+ identifier,
+ name: res.metadata.name || res.metadata.id,
+ description: res.metadata.description,
+ bounds: res.metadata.bounds,
+ minzoom: res.metadata.minzoom,
+ maxzoom: res.metadata.maxzoom,
+ format: res.metadata.format,
+ type: 'tilelayer',
+ scale: parseInt(res.metadata.scale) || 250000,
+ v1: {
+ tilemapUrl: `~basePath~/charts/${identifier}/{z}/{x}/{y}`,
+ chartLayers: res.metadata.vector_layers
+ ? parseVectorLayers(res.metadata.vector_layers)
+ : []
+ },
+ v2: {
+ url: `~basePath~/charts/${identifier}/{z}/{x}/{y}`,
+ layers: res.metadata.vector_layers
+ ? parseVectorLayers(res.metadata.vector_layers)
+ : []
+ }
+ }
+ return data
+ })
+ .catch((e: Error) => {
+ console.error(`Error loading chart ${file}`, e.message)
+ return null
+ })
+ )
+}
+
+function parseVectorLayers(layers: Array<{ id: string }>) {
+ return layers.map((l) => l.id)
+}
+
+function directoryToMapInfo(file: string, identifier: string) {
+ async function loadInfo() {
+ const tilemapResource = path.join(file, 'tilemapresource.xml')
+ const metadataJson = path.join(file, 'metadata.json')
+ try {
+ await fs.stat(tilemapResource)
+ return parseTilemapResource(tilemapResource)
+ } catch {
+ try {
+ await fs.stat(metadataJson)
+ return parseMetadataJson(metadataJson)
+ } catch {
+ return null
+ }
+ }
+ }
+
+ return loadInfo()
+ .then((info: ChartProvider | null) => {
+ if (info) {
+ if (!info.format) {
+ console.error(`Missing format metadata for chart ${identifier}`)
+ return null
+ }
+ info.identifier = identifier
+ ;(info._fileFormat = 'directory'),
+ (info._filePath = file),
+ (info.v1 = {
+ tilemapUrl: `~basePath~/charts/${identifier}/{z}/{x}/{y}`,
+ chartLayers: []
+ })
+ info.v2 = {
+ url: `~basePath~/charts/${identifier}/{z}/{x}/{y}`,
+ layers: []
+ }
+
+ return info
+ }
+ return null
+ })
+ .catch((e) => {
+ console.error(`Error getting charts from ${file}`, e.message)
+ return undefined
+ })
+}
+
+function parseTilemapResource(tilemapResource: string) {
+ return (
+ fs
+ .readFile(tilemapResource)
+ .then(bluebird.promisify(xml2js.parseString))
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ .then((parsed: any) => {
+ const result = parsed.TileMap
+ const name = _.get(result, 'Title.0')
+ const format = _.get(result, 'TileFormat.0.$.extension')
+ const scale = _.get(result, 'Metadata.0.$.scale')
+ const bbox = _.get(result, 'BoundingBox.0.$')
+ const zoomLevels = _.map(
+ _.get(result, 'TileSets.0.TileSet') || [],
+ (set) => parseInt(_.get(set, '$.href'))
+ )
+ const res: ChartProvider = {
+ _flipY: true,
+ name,
+ description: name,
+ bounds: bbox
+ ? [
+ parseFloat(bbox.minx),
+ parseFloat(bbox.miny),
+ parseFloat(bbox.maxx),
+ parseFloat(bbox.maxy)
+ ]
+ : undefined,
+ minzoom: !_.isEmpty(zoomLevels) ? _.min(zoomLevels) : undefined,
+ maxzoom: !_.isEmpty(zoomLevels) ? _.max(zoomLevels) : undefined,
+ format,
+ type: 'tilelayer',
+ scale: parseInt(scale) || 250000,
+ identifier: '',
+ _filePath: ''
+ }
+ return res
+ })
+ )
+}
+
+function parseMetadataJson(metadataJson: string) {
+ return fs
+ .readFile(metadataJson, { encoding: 'utf8' })
+ .then((txt) => {
+ return JSON.parse(txt)
+ })
+ .then((metadata) => {
+ function parseBounds(bounds: number[] | string) {
+ if (_.isString(bounds)) {
+ return _.map(bounds.split(','), (bound) => parseFloat(_.trim(bound)))
+ } else if (_.isArray(bounds) && bounds.length === 4) {
+ return bounds
+ } else {
+ return undefined
+ }
+ }
+ const res: ChartProvider = {
+ _flipY: false,
+ name: metadata.name || metadata.id,
+ description: metadata.description || '',
+ bounds: parseBounds(metadata.bounds),
+ minzoom: parseIntIfNotUndefined(metadata.minzoom),
+ maxzoom: parseIntIfNotUndefined(metadata.maxzoom),
+ format: metadata.format,
+ type: metadata.type,
+ scale: parseInt(metadata.scale) || 250000,
+ identifier: '',
+ _filePath: ''
+ }
+ return res
+ })
+}
+
+function parseIntIfNotUndefined(val: string) {
+ const parsed = parseInt(val)
+ return _.isFinite(parsed) ? parsed : undefined
+}
diff --git a/src/constants.js b/src/constants.js
deleted file mode 100644
index 5635613..0000000
--- a/src/constants.js
+++ /dev/null
@@ -1,3 +0,0 @@
-module.exports = {
- apiRoutePrefix: '/signalk/v1/api/resources'
-}
\ No newline at end of file
diff --git a/src/constants.ts b/src/constants.ts
new file mode 100644
index 0000000..e3cd161
--- /dev/null
+++ b/src/constants.ts
@@ -0,0 +1,4 @@
+export const apiRoutePrefix = {
+ 1: '/signalk/v1/api/resources',
+ 2: '/signalk/v2/api/resources'
+}
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..e76d479
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,438 @@
+import * as bluebird from 'bluebird'
+import path from 'path'
+import fs 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,
+ ResourceProviderRegistry
+} from '@signalk/server-api'
+
+const MIN_ZOOM = 1
+const MAX_ZOOM = 24
+
+interface Config {
+ chartPaths: string[]
+ onlineChartProviders: OnlineChartProvider[]
+}
+
+interface ChartProviderApp
+ extends PluginServerApp,
+ 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
+ version: string
+ getExternalPort: () => number
+ }
+}
+
+module.exports = (app: ChartProviderApp): Plugin => {
+ let chartProviders: { [key: string]: ChartProvider } = {}
+ let pluginStarted = false
+ let props: Config = {
+ chartPaths: [],
+ onlineChartProviders: []
+ }
+ const configBasePath = app.config.configPath
+ const defaultChartsPath = path.join(configBasePath, '/charts')
+ const serverMajorVersion = app.config.version ? parseInt(app.config.version.split('.')[0]) : '1'
+ ensureDirectoryExists(defaultChartsPath)
+
+ // ******** REQUIRED PLUGIN DEFINITION *******
+ const CONFIG_SCHEMA = {
+ title: 'Signal K Charts',
+ type: 'object',
+ properties: {
+ chartPaths: {
+ type: 'array',
+ title: 'Chart paths',
+ description: `Add one or more paths to find charts. Defaults to "${defaultChartsPath}"`,
+ items: {
+ type: 'string',
+ title: 'Path',
+ description: `Path for chart files, relative to "${configBasePath}"`
+ }
+ },
+ onlineChartProviders: {
+ type: 'array',
+ title: 'Online chart providers',
+ items: {
+ type: 'object',
+ title: 'Provider',
+ required: ['name', 'minzoom', 'maxzoom', 'format', 'url'],
+ properties: {
+ name: {
+ type: 'string',
+ title: 'Name'
+ },
+ description: {
+ type: 'string',
+ title: 'Description'
+ },
+ minzoom: {
+ type: 'number',
+ title: `Minimum zoom level, between [${MIN_ZOOM}, ${MAX_ZOOM}]`,
+ maximum: MAX_ZOOM,
+ minimum: MIN_ZOOM,
+ default: MIN_ZOOM
+ },
+ maxzoom: {
+ type: 'number',
+ title: `Maximum zoom level, between [${MIN_ZOOM}, ${MAX_ZOOM}]`,
+ maximum: MAX_ZOOM,
+ minimum: MIN_ZOOM,
+ default: 15
+ },
+ serverType: {
+ type: 'string',
+ title: 'Map source / server type',
+ default: 'tilelayer',
+ enum: ['tilelayer', 'S-57', 'WMS', 'WMTS', 'mapstyleJSON', 'tileJSON'],
+ description:
+ 'Map data source type served by the supplied url. (Use tilelayer for xyz / tms tile sources.)'
+ },
+ format: {
+ type: 'string',
+ title: 'Format',
+ default: 'png',
+ enum: ['png', 'jpg', 'pbf'],
+ description:
+ 'Format of map tiles: raster (png, jpg, etc.) / vector (pbf).'
+ },
+ url: {
+ type: 'string',
+ title: 'URL',
+ description:
+ 'Map URL (for tilelayer include {z}, {x} and {y} parameters, e.g. "http://example.org/{z}/{x}/{y}.png")'
+ },
+ style: {
+ type: 'string',
+ title: 'Vector Map Style',
+ description:
+ 'Path to file containing map style definitions for Vector maps (e.g. "http://example.org/styles/mymapstyle.json")'
+ },
+ layers: {
+ type: 'array',
+ title: 'Layers',
+ description:
+ 'List of map layer ids to display. (Use with WMS / WMTS types.)',
+ items: {
+ title: 'Layer Name',
+ description: 'Name of layer to display',
+ type: 'string'
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ const CONFIG_UISCHEMA = {}
+
+ const plugin: Plugin = {
+ id: 'charts',
+ 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
+ },
+ stop: () => {
+ app.setPluginStatus('stopped')
+ }
+ }
+
+ const doStartup = (config: Config) => {
+ app.debug('** loaded config: ', config)
+ props = { ...config }
+
+ const chartPaths = _.isEmpty(props.chartPaths)
+ ? [defaultChartsPath]
+ : resolveUniqueChartPaths(props.chartPaths, configBasePath)
+
+ const onlineProviders = _.reduce(
+ props.onlineChartProviders,
+ (result: { [key: string]: object }, data) => {
+ const provider = convertOnlineProviderConfig(data)
+ result[provider.identifier] = provider
+ return result
+ },
+ {}
+ )
+ 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')
+
+ const loadProviders = bluebird
+ .mapSeries(chartPaths, (chartPath: string) => findCharts(chartPath))
+ .then((list: ChartProvider[]) =>
+ _.reduce(list, (result, charts) => _.merge({}, result, charts), {})
+ )
+
+ 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 registerRoutes = () => {
+ app.debug('** Registering API paths **')
+
+ app.get(
+ `/signalk/:version(v[1-2])/api/resources/charts/: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) {
+ return res.sendStatus(404)
+ }
+ switch (provider._fileFormat) {
+ case 'directory':
+ return serveTileFromFilesystem(
+ res,
+ provider,
+ parseInt(z),
+ parseInt(x),
+ parseInt(y)
+ )
+ case 'mbtiles':
+ return serveTileFromMbtiles(
+ res,
+ provider,
+ parseInt(z),
+ parseInt(x),
+ parseInt(y)
+ )
+ default:
+ console.log(
+ `Unknown chart provider fileformat ${provider._fileFormat}`
+ )
+ res.status(500).send()
+ }
+ }
+ )
+
+ app.debug('** Registering v1 API paths **')
+
+ app.get(
+ apiRoutePrefix[1] + '/charts/:identifier',
+ (req: Request, res: Response) => {
+ const { identifier } = req.params
+ const provider = chartProviders[identifier]
+ if (provider) {
+ return res.json(sanitizeProvider(provider))
+ } 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)
+ })
+
+ // v2 routes
+ if (serverMajorVersion === 2) {
+ app.debug('** Registering v2 API paths **')
+ registerAsProvider()
+ }
+ }
+
+ // Resources API provider registration
+ const registerAsProvider = () => {
+ app.debug('** Registering as Resource Provider for `charts` **')
+ try {
+ app.registerResourceProvider({
+ type: 'charts',
+ methods: {
+ listResources: (params: {
+ [key: string]: number | string | object | null
+ }) => {
+ app.debug(`** listResources()`, params)
+ return Promise.resolve(
+ _.mapValues(chartProviders, (provider) =>
+ sanitizeProvider(provider, 2)
+ )
+ )
+ },
+ getResource: (id: string) => {
+ app.debug(`** getResource()`, id)
+ const provider = chartProviders[id]
+ if (provider) {
+ return Promise.resolve(sanitizeProvider(provider, 2))
+ } else {
+ throw new Error('Chart not found!')
+ }
+ },
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ setResource: (id: string, value: any) => {
+ throw new Error(`Not implemented!\n Cannot set ${id} to ${value}`)
+ },
+ deleteResource: (id: string) => {
+ throw new Error(`Not implemented!\n Cannot delete ${id}`)
+ }
+ }
+ })
+ } catch (error) {
+ app.debug('Failed Provider Registration!')
+ }
+ }
+
+ return plugin
+}
+
+const responseHttpOptions = {
+ headers: {
+ 'Cache-Control': 'public, max-age=7776000' // 90 days
+ }
+}
+
+const resolveUniqueChartPaths = (
+ chartPaths: string[],
+ configBasePath: string
+) => {
+ const paths = _.map(chartPaths, (chartPath) =>
+ path.resolve(configBasePath, chartPath)
+ )
+ return _.uniq(paths)
+}
+
+const convertOnlineProviderConfig = (provider: OnlineChartProvider) => {
+ const id = _.kebabCase(_.deburr(provider.name))
+ const data = {
+ identifier: id,
+ name: provider.name,
+ description: provider.description,
+ bounds: [-180, -90, 180, 90],
+ minzoom: Math.min(Math.max(1, provider.minzoom), 19),
+ maxzoom: Math.min(Math.max(1, provider.maxzoom), 19),
+ format: provider.format,
+ scale: 250000,
+ type: provider.serverType ? provider.serverType : 'tilelayer',
+ style: provider.style ? provider.style : null,
+ v1: {
+ tilemapUrl: provider.url,
+ chartLayers: provider.layers ? provider.layers : null
+ },
+ v2: {
+ url: provider.url,
+ layers: provider.layers ? provider.layers : null
+ }
+ }
+ return data
+}
+
+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 = _.merge({}, provider.v2)
+ v.url = v.url ? v.url.replace('~basePath~', apiRoutePrefix[2]) : ''
+ }
+ provider = _.omit(provider, [
+ '_filePath',
+ '_fileFormat',
+ '_mbtilesHandle',
+ '_flipY',
+ 'v1',
+ 'v2'
+ ]) as ChartProvider
+ return _.merge(provider, v)
+}
+
+const ensureDirectoryExists = (path: string) => {
+ if (!fs.existsSync(path)) {
+ fs.mkdirSync(path)
+ }
+}
+
+const serveTileFromFilesystem = (
+ res: Response,
+ provider: ChartProvider,
+ z: number,
+ x: number,
+ y: number
+) => {
+ const { format, _flipY, _filePath } = provider
+ const flippedY = Math.pow(2, z) - 1 - y
+ const file = _filePath
+ ? path.resolve(_filePath, `${z}/${x}/${_flipY ? flippedY : y}.${format}`)
+ : ''
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ res.sendFile(file, responseHttpOptions, (err: any) => {
+ if (err && err.code === 'ENOENT') {
+ res.sendStatus(404)
+ } else if (err) {
+ throw err
+ }
+ })
+}
+
+const serveTileFromMbtiles = (
+ res: Response,
+ provider: ChartProvider,
+ z: number,
+ x: number,
+ y: number
+) => {
+ provider._mbtilesHandle.getTile(
+ z,
+ x,
+ y,
+ (err: Error, tile: Buffer, headers: OutgoingHttpHeaders) => {
+ if (err && err.message && err.message === 'Tile does not exist') {
+ res.sendStatus(404)
+ } else if (err) {
+ console.error(
+ `Error fetching tile ${provider.identifier}/${z}/${x}/${y}:`,
+ err
+ )
+ res.sendStatus(500)
+ } else {
+ headers['Cache-Control'] = responseHttpOptions.headers['Cache-Control']
+ res.writeHead(200, headers)
+ res.end(tile)
+ }
+ }
+ )
+}
diff --git a/src/plugin.js b/src/plugin.js
deleted file mode 100644
index 7a9b3a4..0000000
--- a/src/plugin.js
+++ /dev/null
@@ -1,231 +0,0 @@
-const Promise = require('bluebird')
-const _ = require('lodash')
-const path = require('path')
-const fs = require('fs')
-const Charts = require('./charts')
-const {apiRoutePrefix} = require('./constants')
-
-const MIN_ZOOM = 1
-const MAX_ZOOM = 19
-
-module.exports = function(app) {
- let chartProviders = []
- let pluginStarted = false
- const configBasePath = app.config.configPath
- const defaultChartsPath = path.join(configBasePath, "/charts")
- ensureDirectoryExists(defaultChartsPath)
-
- function start(props) {
- const chartPaths = _.isEmpty(props.chartPaths)
- ? [defaultChartsPath]
- : resolveUniqueChartPaths(props.chartPaths, configBasePath)
- const onlineProviders = _.reduce(props.onlineChartProviders, (result, data) => {
- const provider = convertOnlineProviderConfig(data)
- result[provider.identifier] = provider
- return result
- }, {})
- app.debug(`Start charts plugin. Chart paths: ${chartPaths.join(', ')}, online charts: ${onlineProviders.length}`)
-
- const loadProviders = Promise.mapSeries(chartPaths, chartPath => Charts.findCharts(chartPath))
- .then(list => _.reduce(list, (result, charts) => _.merge({}, result, charts), {}))
- return loadProviders.then(charts => {
- app.debug(`Chart plugin: Found ${_.keys(charts).length} charts from ${chartPaths.join(', ')}`)
- chartProviders = _.merge({}, charts, onlineProviders)
- // Do not register routes if plugin has been started once already
- pluginStarted === false && registerRoutes()
- pluginStarted = true
- }).catch(e => {
- console.error(`Error loading chart providers`, e.message)
- chartProviders = {}
- })
- }
-
- function stop() {
- }
-
- function registerRoutes() {
- app.get(apiRoutePrefix + '/charts/:identifier/:z([0-9]*)/:x([0-9]*)/:y([0-9]*)', (req, res) => {
- const { identifier, z, x, y } = req.params
- const provider = chartProviders[identifier]
- if (!provider) {
- res.sendStatus(404)
- return
- }
- switch (provider._fileFormat) {
- case 'directory':
- return serveTileFromFilesystem(res, provider, z, x, y)
- case 'mbtiles':
- return serveTileFromMbtiles(res, provider, z, x, y)
- default:
- console.error(`Unknown chart provider fileformat ${provider._fileFormat}`)
- res.status(500).send()
- }
- })
-
- app.get(apiRoutePrefix + "/charts/:identifier", (req, res) => {
- const { identifier } = req.params
- const provider = chartProviders[identifier]
- if (provider) {
- return res.json(sanitizeProvider(provider))
- } else {
- return res.status(404).send('Not found')
- }
- })
-
- app.get(apiRoutePrefix + "/charts", (req, res) => {
- const sanitized = _.mapValues(chartProviders, sanitizeProvider)
- res.json(sanitized)
- })
- }
-
- return {
- id: 'charts',
- name: 'Signal K Charts',
- description: 'Singal K Charts resource',
- schema: {
- title: 'Signal K Charts',
- description: `Add one or more paths to find charts. Defaults to "${defaultChartsPath}"`,
- type: 'object',
- properties: {
- chartPaths: {
- type: 'array',
- title: 'Chart paths',
- items: {
- type: 'string',
- title: "Path",
- description: `Path for chart files, relative to "${configBasePath}"`
- }
- },
- onlineChartProviders: {
- type: 'array',
- title: 'Online chart providers',
- items: {
- type: 'object',
- title: 'Provider',
- required: ['name', 'minzoom', 'maxzoom', 'format', 'url'],
- properties: {
- name: {
- type: 'string',
- title: 'Name'
- },
- description: {
- type: 'string',
- title: 'Description'
- },
- minzoom: {
- type: 'number',
- title: `Minimum zoom level, between [${MIN_ZOOM}, ${MAX_ZOOM}]`,
- maximum: MAX_ZOOM,
- minimum: MIN_ZOOM,
- default: MIN_ZOOM,
- },
- maxzoom: {
- type: 'number',
- title: `Maximum zoom level, between [${MIN_ZOOM}, ${MAX_ZOOM}]`,
- maximum: MAX_ZOOM,
- minimum: MIN_ZOOM,
- default: 15,
- },
- serverType: {
- type: 'string',
- title: 'Server Type',
- default: 'tilelayer',
- enum: ['tilelayer', 'WMS']
- },
- format: {
- type: 'string',
- title: 'Format',
- default: 'png',
- enum: ['png', 'jpg']
- },
- url: {
- type: 'string',
- title: 'URL',
- description: 'Map URL (for tilelayer include {z}, {x} and {y} parameters, e.g. "http://example.org/{z}/{x}/{y}.png")'
- },
- layers: {
- type: 'array',
- title: 'Layers',
- description: '(WMS only) ',
- items: {
- title: 'Layer Name',
- description: 'Name of layer to display',
- type: 'string'
- }
- }
- }
- }
- }
- }
- },
- start,
- stop
- }
-}
-
-
-const responseHttpOptions = {
- headers: {
- 'Cache-Control': 'public, max-age=7776000' // 90 days
- }
-}
-
-function resolveUniqueChartPaths(chartPaths, configBasePath) {
- const paths = _.map(chartPaths, chartPath => path.resolve(configBasePath, chartPath))
- return _.uniq(paths)
-}
-
-function convertOnlineProviderConfig(provider) {
- const id = _.kebabCase(_.deburr(provider.name))
- return {
- name: provider.name,
- description: provider.description,
- bounds: [-180, -90, 180, 90],
- minzoom: Math.min(Math.max(1, provider.minzoom), 19),
- maxzoom: Math.min(Math.max(1, provider.maxzoom), 19),
- format: provider.format,
- scale: 'N/A',
- identifier: id,
- tilemapUrl: provider.url,
- type: (provider.serverType) ? provider.serverType : 'tilelayer',
- chartLayers: (provider.layers) ? provider.layers : null
- }
-}
-
-function sanitizeProvider(provider) {
- return _.omit(provider, ['_filePath', '_fileFormat', '_mbtilesHandle', '_flipY'])
-}
-
-function ensureDirectoryExists (path) {
- if (!fs.existsSync(path)) {
- fs.mkdirSync(path)
- }
-}
-
-function serveTileFromFilesystem(res, provider, z, x, y) {
- const {identifier, format, _flipY, _filePath} = provider
- const flippedY = Math.pow(2, z) - 1 - y
- const file = path.resolve(_filePath, `${z}/${x}/${_flipY ? flippedY : y}.${format}`)
- res.sendFile(file, responseHttpOptions, (err) => {
- if (err && err.code === 'ENOENT') {
- res.sendStatus(404)
- } else if (err) {
- throw err
- }
- })
-}
-
-function serveTileFromMbtiles(res, provider, z, x, y) {
- provider._mbtilesHandle.getTile(z, x, y, (err, tile, headers) => {
- if (err && err.message && err.message === 'Tile does not exist') {
- res.sendStatus(404)
- } else if (err) {
- console.error(`Error fetching tile ${provider.identifier}/${z}/${x}/${y}:`, err)
- res.sendStatus(500)
- } else {
- headers['Cache-Control'] = responseHttpOptions.headers['Cache-Control']
- res.writeHead(200, headers)
- res.end(tile)
- }
- })
-}
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..dd3e7da
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,40 @@
+type MapSourceType = 'tilelayer' | 'S-57' | 'WMS' | 'WMTS' | 'mapstyleJSON' | 'tileJSON'
+
+export interface ChartProvider {
+ _fileFormat?: 'mbtiles' | 'directory'
+ _filePath: string
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ _mbtilesHandle?: any
+ _flipY?: boolean
+ identifier: string
+ name: string
+ description: string
+ type: MapSourceType
+ scale: number
+ v1?: {
+ tilemapUrl: string
+ chartLayers: string[]
+ }
+ v2?: {
+ url: string
+ layers: string[]
+ }
+ bounds?: number[]
+ minzoom?: number
+ maxzoom?: number
+ format?: string
+ style?: string
+ layers?: string[]
+}
+
+export interface OnlineChartProvider {
+ name: string
+ description: string
+ minzoom: number
+ maxzoom: number
+ serverType: MapSourceType
+ format: 'png' | 'jpg'
+ url: string
+ style: string
+ layers: string[]
+}
diff --git a/test/charts/unpacked-tiles/metadata.json b/test/charts/unpacked-tiles/metadata.json
index f5fd362..a7208c3 100644
--- a/test/charts/unpacked-tiles/metadata.json
+++ b/test/charts/unpacked-tiles/metadata.json
@@ -10,6 +10,6 @@
"maxzoom": "4",
"tilejson": "2.0.0",
"scheme": "xyz",
- "type": "overlay",
+ "type": "tilelayer",
"description": "CAROLINAS"
}
diff --git a/test/expected-charts.json b/test/expected-charts.json
index 752683a..5c8b180 100644
--- a/test/expected-charts.json
+++ b/test/expected-charts.json
@@ -6,13 +6,14 @@
-61.875,
31.952162238
],
+ "chartLayers": [],
"description": "CAROLINAS",
"format": "png",
"identifier": "test",
"maxzoom": 5,
"minzoom": 3,
"name": "MBTILES_19",
- "scale": "250000",
+ "scale": 250000,
"tilemapUrl": "/signalk/v1/api/resources/charts/test/{z}/{x}/{y}",
"type": "tilelayer"
},
@@ -23,13 +24,14 @@
25.541777414896345,
66.0258911558707
],
+ "chartLayers": [],
"description": "Översikt Svenska Sjökort",
"format": "png",
"identifier": "tms-tiles",
"maxzoom": 5,
"minzoom": 4,
"name": "Översikt Svenska Sjökort",
- "scale": "4000000",
+ "scale": 4000000,
"tilemapUrl": "/signalk/v1/api/resources/charts/tms-tiles/{z}/{x}/{y}",
"type": "tilelayer"
},
@@ -40,13 +42,14 @@
-61.875,
31.952162238
],
+ "chartLayers": [],
"description": "CAROLINAS",
"format": "png",
"identifier": "unpacked-tiles",
"maxzoom": 4,
"minzoom": 3,
"name": "NOAA MBTiles test file",
- "scale": "250000",
+ "scale": 250000,
"tilemapUrl": "/signalk/v1/api/resources/charts/unpacked-tiles/{z}/{x}/{y}",
"type": "tilelayer"
}
diff --git a/test/plugin-test.js b/test/plugin-test.js
index f275407..f9bcfa5 100644
--- a/test/plugin-test.js
+++ b/test/plugin-test.js
@@ -8,7 +8,7 @@ const chaiHttp = require('chai-http')
const Promise = require('bluebird')
const express = require('express')
const expect = chai.expect
-const Plugin = require('../src/plugin')
+const Plugin = require('../plugin/index')
const expectedCharts = require('./expected-charts.json')
chai.use(chaiHttp)
@@ -79,7 +79,8 @@ describe('GET /resources/charts', () => {
maxzoom: 15,
minzoom: 2,
name: 'Test Name',
- scale: 'N/A',
+ scale: 250000,
+ "style": null,
tilemapUrl: 'https://example.com',
type: 'tilelayer',
chartLayers: null
@@ -105,6 +106,7 @@ describe('GET /resources/charts', () => {
expect(result.status).to.equal(404)
})
})
+
})
describe('GET /resources/charts/:identifier/:z/:x/:y', () => {
@@ -166,6 +168,7 @@ describe('GET /resources/charts/:identifier/:z/:x/:y', () => {
})
})
+
const expectTileResponse = (response, expectedTilePath, expectedFormat) => {
const expectedTile = fs.readFileSync(path.resolve(__dirname, expectedTilePath))
expect(response.status).to.equal(200)
@@ -179,6 +182,14 @@ const createDefaultApp = () => {
app.use(require('body-parser').json())
app.debug = (x) => console.log(x)
app.config = { configPath: path.resolve(__dirname) }
+
+ app.statusMessage = () => 'started'
+ app.error = (msg) => undefined
+ app.debug = (...msg) => undefined
+ app.setPluginStatus = (pluginId, status) => undefined
+ app.setPluginError = (pluginId, status) => undefined
+
+
return new Promise((resolve, reject) => {
const server = http.createServer(app)
server.listen(() => {
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..8987f83
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,32 @@
+{
+ "compilerOptions": {
+ "target": "es6",
+ "module": "commonjs",
+ "outDir": "./plugin",
+ "esModuleInterop": true,
+ "strict": true,
+ "allowJs": true,
+ "allowSyntheticDefaultImports": true,
+ "resolveJsonModule": true,
+ "composite": true,
+ "rootDir": "src"
+ },
+ "include": ["./src/**/*"],
+ "exclude": ["node_modules"],
+ "typedocOptions": {
+ "mode": "modules",
+ "out": "tsdocs",
+ "exclude": ["test", "node_modules"],
+ "theme": "default",
+ "ignoreCompilerErrors": true,
+ "excludePrivate": true,
+ "excludeNotExported": true,
+ "target": "ES5",
+ "moduleResolution": "node",
+ "preserveConstEnums": true,
+ "stripInternal": true,
+ "suppressExcessPropertyErrors": true,
+ "suppressImplicitAnyIndexErrors": true,
+ "module": "commonjs"
+ }
+}
\ No newline at end of file