@signalk/charts-plugin
Version:
Signal K plugin to provide chart support for Signal K server
353 lines (352 loc) • 15.1 kB
JavaScript
;
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);
}
});
};