UNPKG

@signalk/charts-plugin

Version:

Signal K plugin to provide chart support for Signal K server

353 lines (352 loc) 15.1 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const bluebird = __importStar(require("bluebird")); const path_1 = __importDefault(require("path")); const fs_1 = __importDefault(require("fs")); const _ = __importStar(require("lodash")); const charts_1 = require("./charts"); const constants_1 = require("./constants"); const MIN_ZOOM = 1; const MAX_ZOOM = 24; module.exports = (app) => { let chartProviders = {}; let pluginStarted = false; let props = { chartPaths: [], onlineChartProviders: [] }; const configBasePath = app.config.configPath; const defaultChartsPath = path_1.default.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 = { id: 'charts', name: 'Signal K Charts', schema: () => CONFIG_SCHEMA, uiSchema: () => CONFIG_UISCHEMA, // eslint-disable-next-line @typescript-eslint/no-explicit-any start: (settings) => { return doStartup(settings); // return required for tests }, stop: () => { app.setPluginStatus('stopped'); } }; const doStartup = (config) => { app.debug('** loaded config: ', config); props = Object.assign({}, config); 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: ${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) => (0, charts_1.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); }) .catch((e) => { 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]*)`, (req, res) => __awaiter(void 0, void 0, void 0, function* () { 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(constants_1.apiRoutePrefix[1] + '/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(constants_1.apiRoutePrefix[1] + '/charts', (req, res) => { 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) => { app.debug(`** listResources()`, params); return Promise.resolve(_.mapValues(chartProviders, (provider) => sanitizeProvider(provider, 2))); }, getResource: (id) => { 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, value) => { throw new Error(`Not implemented!\n Cannot set ${id} to ${value}`); }, deleteResource: (id) => { 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, configBasePath) => { const paths = _.map(chartPaths, (chartPath) => path_1.default.resolve(configBasePath, chartPath)); return _.uniq(paths); }; const convertOnlineProviderConfig = (provider) => { 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, version = 1) => { let v; if (version === 1) { v = _.merge({}, provider.v1); v.tilemapUrl = v.tilemapUrl.replace('~basePath~', constants_1.apiRoutePrefix[1]); } else if (version === 2) { v = _.merge({}, provider.v2); v.url = v.url ? v.url.replace('~basePath~', constants_1.apiRoutePrefix[2]) : ''; } provider = _.omit(provider, [ '_filePath', '_fileFormat', '_mbtilesHandle', '_flipY', 'v1', 'v2' ]); return _.merge(provider, v); }; const ensureDirectoryExists = (path) => { if (!fs_1.default.existsSync(path)) { fs_1.default.mkdirSync(path); } }; const serveTileFromFilesystem = (res, provider, z, x, y) => { const { format, _flipY, _filePath } = provider; const flippedY = Math.pow(2, z) - 1 - y; const file = _filePath ? path_1.default.resolve(_filePath, `${z}/${x}/${_flipY ? flippedY : y}.${format}`) : ''; // eslint-disable-next-line @typescript-eslint/no-explicit-any res.sendFile(file, responseHttpOptions, (err) => { if (err && err.code === 'ENOENT') { res.sendStatus(404); } else if (err) { throw err; } }); }; const 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); } }); };