diff --git a/README.md b/README.md
index 92cf917..b5a3bcf 100644
--- a/README.md
+++ b/README.md
@@ -69,6 +69,7 @@ For WMS & WMTS sources you can specify the layers you wish to display.
+A proxy for online charts can be created using the "Proxy through SignalK server" option. If enabled tiles will be fetched from the remote server and cached by the SignalK server making it possible to store the tiles for offline usage. Additional http headers can be passed to the remote server by adding colon separated headers, e.g. User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64). User-Agent is the header name and Mozilla... will be the value.
### Supported chart formats
diff --git a/package.json b/package.json
index 99325a7..290907a 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"keywords": [
"signalk",
"signalk-node-server-plugin",
+ "signalk-webapp",
"nautic",
"chart",
"mbtiles",
@@ -31,7 +32,12 @@
"dependencies": {
"@mapbox/mbtiles": "^0.12.1",
"@signalk/server-api": "^2.0.0",
+ "@turf/bbox": "^7.2.0",
+ "@turf/boolean-intersects": "^7.2.0",
+ "@turf/helpers": "^7.2.0",
"bluebird": "3.5.1",
+ "check-disk-space": "^3.4.0",
+ "geojson-antimeridian-cut": "^0.1.0",
"lodash": "^4.17.11",
"xml2js": "0.6.2"
},
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..afda67e
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,106 @@
+
+
+
+
+
+ Signalk charts-addin
+
+
+
+
+
+ Seed charts for offline usage
+ Select chart provider:
+ Chart:
+
+
+
+ Select region type:
+
+
+
+
+ Region
+
+
+
+
+
+
+
+
+
+ Bounding Box (minLon, minLat, maxLon, maxLat) West to East, South to North
+
+
+
+
+
+
+
+
+
+
+ Select max zoom:
+ Max Zoom
+
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+ 10
+ 11
+ 12
+ 13
+ 14
+ 15
+ 16
+ 17
+ 18
+ 19
+ 20
+
+
+
+ Create
+
+
+
+
+
+
+ #
+ Chart
+ Region
+ Total tiles
+ Downloaded
+ Cached
+ Failed
+ Progress
+ Status
+ Actions
+
+
+
+
+ Loading progress…
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/index.js b/public/index.js
new file mode 100644
index 0000000..e2ac110
--- /dev/null
+++ b/public/index.js
@@ -0,0 +1,303 @@
+
+
+
+(function () {
+
+
+})();
+
+document.addEventListener('DOMContentLoaded', async () => {
+ const regionSelect = document.getElementById("region");
+ const bboxInputs = document.querySelectorAll("#bboxMinLon, #bboxMinLat, #bboxMaxLon, #bboxMaxLat");
+ const tileInputs = document.querySelectorAll("#tileX, #tileY, #tileZ");
+
+ function updateInputs() {
+ const type = document.querySelector('input[name="inputType"]:checked').value;
+
+ regionSelect.disabled = (type !== "region");
+ bboxInputs.forEach(i => i.disabled = (type !== "bbox"));
+ tileInputs.forEach(i => i.disabled = (type !== "tile"));
+ }
+
+ // Attach listeners
+ document.querySelectorAll('input[name="inputType"]').forEach(r => {
+ r.addEventListener("change", updateInputs);
+ });
+
+ updateInputs(); // initial load
+
+ if (!regionSelect) return;
+
+ try {
+ const resp = await fetch('/signalk/v2/api/resources/regions');
+ if (!resp.ok) throw new Error(`Failed to fetch regions: ${resp.status}`);
+ const data = await resp.json();
+ // clear existing options
+ regionSelect.innerHTML = '';
+
+ const entries = Object.entries(data || {});
+ entries
+ .sort(([, a], [, b]) => {
+ const na = (a && a.name) ? String(a.name) : '';
+ const nb = (b && b.name) ? String(b.name) : '';
+ return na.localeCompare(nb);
+ })
+ .forEach(([id, info]) => {
+ const opt = document.createElement('option');
+ opt.value = id;
+ opt.textContent = (info && info.name) ? info.name : id;
+ regionSelect.appendChild(opt);
+
+ });
+
+ // if no regions returned, add a fallback option
+ if (!regionSelect.options.length) {
+ const opt = document.createElement('option');
+ opt.value = '';
+ opt.textContent = '-- No regions available --';
+ regionSelect.appendChild(opt);
+ }
+ } catch (err) {
+ console.error('Error loading regions:', err);
+ }
+
+ //Fetch available maps and populate the chart select box
+ const chartSelect = document.getElementById('chart');
+ if (!chartSelect) return;
+
+ try {
+ const resp = await fetch('/signalk/v2/api/resources/charts');
+ if (!resp.ok) throw new Error(`Failed to fetch charts: ${resp.status}`);
+ const data = await resp.json();
+ // clear existing options
+ chartSelect.innerHTML = '';
+
+ const entries = Object.entries(data || {});
+ entries
+ .sort(([, a], [, b]) => {
+ const na = (a && a.name) ? String(a.name) : '';
+ const nb = (b && b.name) ? String(b.name) : '';
+ return na.localeCompare(nb);
+ })
+ .forEach(([id, info]) => {
+
+ if (info.proxy === true) {
+ const opt = document.createElement('option');
+ opt.value = id;
+ opt.textContent = (info && info.name) ? info.name : id;
+ chartSelect.appendChild(opt);
+ }
+ });
+
+ // if no maps returned, add a fallback option
+ if (!chartSelect.options.length) {
+ const opt = document.createElement('option');
+ opt.value = '';
+ opt.textContent = '-- No maps available --';
+ chartSelect.appendChild(opt);
+ }
+ } catch (err) {
+ console.error('Error loading maps:', err);
+ }
+
+ document.getElementById('createJobButton').addEventListener('click', async () => {
+ try {
+ const chart = document.getElementById('chart').value;
+ const regionGUID = document.getElementById('region').value;
+ const maxZoom = document.getElementById('maxZoom').value;
+ const bbox = {
+ minLon: parseFloat(document.getElementById('bboxMinLon').value),
+ minLat: parseFloat(document.getElementById('bboxMinLat').value),
+ maxLon: parseFloat(document.getElementById('bboxMaxLon').value),
+ maxLat: parseFloat(document.getElementById('bboxMaxLat').value)
+ };
+ // const tile = {
+ // z: parseInt(document.getElementById('tileZ').value),
+ // x: parseInt(document.getElementById('tileX').value),
+ // y: parseInt(document.getElementById('tileY').value),
+
+ // };
+ // console.log(tile);
+ const type = document.querySelector('input[name="inputType"]:checked').value;
+ const body = {};
+ if (type === 'region') {
+ body.regionGUID = regionGUID;
+ } else if (type === 'bbox') {
+ body.bbox = bbox;
+ }
+ // else if (type === 'tile') {
+ // body.tile = tile;
+ // }
+ body.maxZoom = maxZoom;
+ const resp = await fetch(`/signalk/chart-tiles/cache/${chart}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(body)
+ });
+ if (!resp.ok) throw new Error(`Failed to seed charts: ${resp.status}`);
+ const data = await resp.json();
+ } catch (err) {
+ console.error('Error seeding charts:', err);
+ }
+ });
+
+ const fmt = n => (typeof n === 'number' ? n.toLocaleString() : '-');
+ const statusText = s => {
+ switch (s) {
+ case 0: return 'Stopped';
+ case 1: return 'Running';
+ default: return String(s);
+ }
+ };
+
+ function renderJobs(items) {
+ const tbody = document.querySelector('#activeJobsTable tbody');
+ if (!tbody) return;
+ tbody.innerHTML = '';
+ if (!Array.isArray(items) || items.length === 0) {
+ const tr = document.createElement('tr');
+ tr.innerHTML = 'No active jobs ';
+ tbody.appendChild(tr);
+ return;
+ }
+
+ items.forEach((it, idx) => {
+ const tr = document.createElement('tr');
+
+ const pct = (typeof it.progress === 'number' && isFinite(it.progress)) ? Math.max(0, Math.min(100, it.progress * 100)) : 0;
+ const pctText = `${pct.toFixed(1)}%`;
+ tr.innerHTML = [
+ `${idx + 1} `,
+ `${it.chartName || '-'} `,
+ `${it.regionName || '-'} `,
+ `${fmt(it.totalTiles)} `,
+ `${fmt(it.downloadedTiles)} `,
+ `${fmt(it.cachedTiles)} `,
+ `${fmt(it.failedTiles)} `,
+ `
+
+
+
+ ${pctText}
+ `,
+ `${statusText(it.status)} `,
+ `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+ ].join('');
+
+ tr.querySelectorAll('.startstop-btn').forEach(button => {
+ button.addEventListener('click', () => {
+ const playIcon = button.querySelector('.icon-play');
+ const pauseIcon = button.querySelector('.icon-pause');
+ const jobId = button.getAttribute('data-id');
+ const totalTiles = button.getAttribute('data-totalTiles');
+ const isRunning = pauseIcon.classList.contains('hidden');
+ console.log(isRunning)
+ if (isRunning) {
+ // Switch to stop
+ if (totalTiles > 100000 && !confirm(`This job has more than ${totalTiles} tiles to download. Starting it may take a long time and put high load on the server. Are you sure you want to start it?`)) {
+ return;
+ }
+ takeAction(jobId, 'start');
+ button.title = 'Stop';
+ } else {
+ // Switch to start
+ takeAction(jobId, 'stop');
+ button.title = 'Start';
+ }
+ });
+ });
+
+ tr.querySelectorAll('.delete-btn').forEach(button => {
+ button.addEventListener('click', () => {
+ if (confirm("This will delete all cached tiles for this job. Are you sure?")) {
+ const jobId = button.getAttribute('data-id');
+ if (jobId) {
+ takeAction(jobId, 'delete');
+ }
+ }
+ });
+ });
+
+ tr.querySelectorAll('.remove-btn').forEach(button => {
+ button.addEventListener('click', () => {
+ const jobId = button.getAttribute('data-id');
+ if (jobId) {
+ takeAction(jobId, 'remove');
+ }
+ });
+ });
+
+ tbody.appendChild(tr);
+ });
+
+
+
+ function takeAction(jobId, action) {
+ fetch(`/signalk/chart-tiles/cache/jobs/${jobId}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ action: action,
+ })
+ })
+ .then(response => {
+ if (!response.ok) {
+ throw new Error(`Failed to ${action} job ${jobId}: ${response.status}`);
+ }
+ fetchActiveJobs();
+ })
+ .catch(error => {
+ console.error(`Error ${action} job:`, error);
+ });
+ }
+ }
+
+
+
+ async function fetchActiveJobs() {
+ const tbody = document.querySelector('#activeJobsTable tbody');
+ try {
+ const resp = await fetch('/signalk/chart-tiles/cache/jobs');
+ if (!resp.ok) throw new Error('HTTP ' + resp.status);
+ const data = await resp.json();
+ renderJobs(data);
+ } catch (err) {
+ console.error('Error fetching active jobs:', err);
+ tbody.innerHTML = 'Error loading active jobs ';
+ }
+ }
+
+
+
+ fetchActiveJobs();
+ // refresh every 2 seconds
+ setInterval(fetchActiveJobs, 2000);
+
+
+});
+
diff --git a/public/style.css b/public/style.css
new file mode 100644
index 0000000..5bd8509
--- /dev/null
+++ b/public/style.css
@@ -0,0 +1,116 @@
+#activeJobsTable {
+ width: 100%;
+ border-collapse: collapse;
+ font-family: Arial, sans-serif;
+ margin-top: 8px;
+}
+
+#activeJobsTable th,
+#progressTable td {
+ border: 1px solid #ddd;
+ padding: 6px 8px;
+ text-align: left;
+ vertical-align: middle;
+ font-size: 13px;
+}
+
+#activeJobsTable th {
+ background: #f4f4f4;
+ font-weight: 600;
+}
+
+.progress-bar {
+ width: 160px;
+ height: 18px;
+ background: #eee;
+ border-radius: 6px;
+ overflow: hidden;
+ display: inline-block;
+ vertical-align: middle;
+ margin-right: 8px;
+}
+
+.progress-fill {
+ display: inline-block;
+ width: 0%;
+ height: 100%;
+ box-sizing: border-box;
+ background: linear-gradient(90deg, #4caf50, #2e7d32);
+ transition: width 300ms ease;
+}
+
+.percent {
+ font-size: 12px;
+ color: #333;
+ vertical-align: middle;
+}
+
+.muted {
+ color: #666;
+ font-size: 12px;
+}
+
+.action-buttons {
+ display: flex;
+ gap: 8px;
+ justify-content: center;
+ align-items: center;
+}
+
+.btn {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 36px;
+ height: 36px;
+ border: none;
+ border-radius: 8px;
+ cursor: pointer;
+ background-color: #444;
+ transition: transform 0.15s ease, background-color 0.2s ease;
+ padding: 0;
+ line-height: 0;
+}
+
+.btn:hover {
+ transform: scale(1.1);
+}
+
+.icon {
+ width: 20px;
+ height: 20px;
+ fill: white;
+ display: block;
+ /* ensures true centering */
+ vertical-align: middle;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.startstop-btn {
+ background-color: #27ae60;
+}
+
+.startstop-btn.running {
+ background-color: #c0392b;
+}
+
+.delete-btn {
+ background-color: #e74c3c;
+}
+
+.delete-btn:hover {
+ background-color: #c0392b;
+}
+
+.remove-btn {
+ background-color: #7f8c8d;
+}
+
+.remove-btn:hover {
+ background-color: #95a5a6;
+}
+
+.hidden {
+ display: none;
+}
\ No newline at end of file
diff --git a/src/@types/geojson-antimeridian-cut.d.ts b/src/@types/geojson-antimeridian-cut.d.ts
new file mode 100644
index 0000000..9c61ed5
--- /dev/null
+++ b/src/@types/geojson-antimeridian-cut.d.ts
@@ -0,0 +1,6 @@
+declare module 'geojson-antimeridian-cut' {
+ import { Feature, FeatureCollection, Geometry } from 'geojson';
+ export default function splitGeoJSON | FeatureCollection>(
+ geojson: T
+ ): T;
+}
\ No newline at end of file
diff --git a/src/chartDownloader.ts b/src/chartDownloader.ts
new file mode 100644
index 0000000..070ad71
--- /dev/null
+++ b/src/chartDownloader.ts
@@ -0,0 +1,431 @@
+import fs from "fs";
+import path from "path";
+import pLimit from "p-limit"
+import type { BBox, FeatureCollection, Polygon, MultiPolygon, Feature, Position } from 'geojson'
+import splitGeoJSON from 'geojson-antimeridian-cut';
+import booleanIntersects from '@turf/boolean-intersects'
+import { bbox } from '@turf/bbox'
+import { polygon } from '@turf/helpers'
+import checkDiskSpace from "check-disk-space";
+import { ChartProvider } from "./types";
+
+export interface Tile {
+ x: number
+ y: number
+ z: number
+}
+
+export enum Status {
+ Stopped,
+ Running,
+
+}
+
+export class ChartSeedingManager {
+ // Placeholder for future cache management methods
+ public static ActiveJobs: { [key: number]: ChartDownloader } = {};
+
+ public static async createJob(urlBase: string, chartsPath: string, provider: any, maxZoom: number, regionGUI: string | undefined = undefined, bbox: BBox | undefined = undefined, tile: Tile | undefined = undefined): Promise {
+ const downloader = new ChartDownloader(urlBase, chartsPath, provider);
+ if (regionGUI)
+ downloader.initalizeJobFromRegion(regionGUI, maxZoom);
+ else if (bbox)
+ downloader.initializeJobFromBBox(bbox, maxZoom);
+ else if (tile) {
+ downloader.initializeJobFromTile(tile, maxZoom);
+ }
+ this.ActiveJobs[downloader.ID] = downloader;
+ return downloader;
+ }
+
+ public static registerRoutes(app: any){
+
+ }
+}
+
+export class ChartDownloader {
+ private static DISK_USAGE_LIMIT = 1024 * 1024 * 1024; // 1 GB
+ private static nextJobId = 1;
+
+ private id : number = ChartDownloader.nextJobId++;
+ private maxZoom : number = 15;
+ private status: Status = Status.Stopped;
+ private totalTiles: number = 0;
+ private downloadedTiles: number = 0;
+ private failedTiles: number = 0;
+ private cachedTiles: number = 0;
+
+ private concurrentDownloadsLimit = 20;
+ private areaDescription: string = "";
+ private cancelRequested: boolean = false;
+
+ private tiles: Tile[] = [];
+ private tilesToDownload: Tile[] = [];
+
+
+ constructor(private urlBase: string, private chartsPath: string, private provider: any) {
+
+ }
+
+ get ID(): number {
+ return this.id;
+ }
+
+
+ public async initalizeJobFromRegion(regionGUID: string, maxZoom: number): Promise {
+ const region = await this.getRegion(regionGUID);
+ const geojson = this.convertRegionToGeoJSON(region);
+ this.tiles = this.getTilesForGeoJSON(geojson, this.provider.minzoom, maxZoom);
+ this.tilesToDownload = this.filterCachedTiles(this.tiles);
+
+ this.status = Status.Stopped;
+ this.totalTiles = this.tiles.length;
+ this.cachedTiles = this.totalTiles - this.tilesToDownload.length;
+ this.areaDescription = `Region: ${region.name || ""}`;
+ this.maxZoom = maxZoom;
+ }
+
+ public async initializeJobFromBBox(bbox: BBox, maxZoom: number): Promise {
+ this.tiles = this.getTilesForBBox(bbox, maxZoom);
+ this.tilesToDownload = this.filterCachedTiles(this.tiles);
+
+ this.status = Status.Stopped;
+ this.totalTiles = this.tiles.length;
+ this.cachedTiles = this.totalTiles - this.tilesToDownload.length;
+ this.areaDescription = `BBox: [${bbox.join(", ")}]`;
+ this.maxZoom = maxZoom;
+ }
+
+ public async initializeJobFromTile(tile: Tile, maxZoom: number): Promise {
+ this.tiles = this.getSubTiles(tile, maxZoom);
+ this.tilesToDownload = this.filterCachedTiles(this.tiles);
+
+ this.status = Status.Stopped;
+ this.totalTiles = this.tiles.length;
+ this.cachedTiles = this.totalTiles - this.tilesToDownload.length;
+ this.areaDescription = `Tile: [${tile.x}, ${tile.y}, ${tile.z}]`;
+ this.maxZoom = maxZoom;
+ }
+
+ /**
+ * Download map tiles for a specific area.
+ *
+ */
+ async seedCache(): Promise {
+ this.cancelRequested = false;
+ this.status = Status.Running;
+ this.tilesToDownload = await this.filterCachedTiles(this.tiles);
+ this.downloadedTiles = 0;
+ this.failedTiles = 0;
+ this.cachedTiles = this.totalTiles - this.tilesToDownload.length;
+ const limit = pLimit(this.concurrentDownloadsLimit); // concurrent download limit
+ const promises: Promise[] = [];
+ let tileCounter = 0
+ this.tilesToDownload = await this.filterCachedTiles(this.tiles);
+ for (const tile of this.tilesToDownload) {
+ if (this.cancelRequested) break;
+ if (tileCounter % 1000 === 0 && tileCounter > 0) {
+ try {
+ const { free } = await checkDiskSpace(this.chartsPath)
+ if (free < ChartDownloader.DISK_USAGE_LIMIT) {
+ console.warn(`Low disk space. Stopping download.`);
+ break;
+ }
+ } catch (err) {
+ console.error(`Error checking disk space:`, err);
+ break;
+ }
+ }
+ promises.push(limit(async () => {
+ if (this.cancelRequested)
+ return;
+ const buffer = await ChartDownloader.getTileFromCacheOrRemote(this.chartsPath, this.provider, tile);
+ if (buffer === null) {
+ this.failedTiles += 1;
+ } else {
+ this.downloadedTiles += 1;
+ }
+ }));
+ tileCounter++
+ }
+ try {
+ await Promise.all(promises);
+
+ } catch (err) {
+ // silent failure, caller can log if needed
+ console.error(`Error downloading tiles:`, err);
+ }
+ this.status = Status.Stopped;
+ }
+
+ async deleteCache(): Promise {
+ this.status = Status.Running;
+ for (const tile of this.tiles) {
+ if (this.cancelRequested) break;
+ const tilePath = path.join(this.chartsPath, `${this.provider.name}`, `${tile.z}`, `${tile.x}`, `${tile.y}.${this.provider.format}`);
+ if (fs.existsSync(tilePath)) {
+ try {
+ fs.promises.unlink(tilePath);
+ this.cachedTiles -= 1;
+ this.cachedTiles = Math.max(this.cachedTiles, 0);
+ } catch (err) {
+ console.error(`Error deleting cached tile ${tilePath}:`, err);
+ }
+ }
+ }
+ this.status = Status.Stopped;
+ }
+
+ public cancelJob() {
+ this.cancelRequested = true;
+ }
+
+ private filterCachedTiles(allTiles: Tile[]): Tile[] {
+ const uncachedTiles: Tile[] = [];
+ for (const tile of allTiles) {
+ const tilePath = path.join(this.chartsPath, `${this.provider.name}`, `${tile.z}`, `${tile.x}`, `${tile.y}.${this.provider.format}`);
+ if (!fs.existsSync(tilePath)) {
+ uncachedTiles.push(tile);
+ }
+ }
+ return uncachedTiles;
+ }
+
+ public info() {
+ return {
+ id: this.id,
+ chartName: this.provider.name,
+ regionName: this.areaDescription,
+ totalTiles: this.totalTiles,
+ downloadedTiles: this.downloadedTiles,
+ cachedTiles: this.cachedTiles,
+ failedTiles: this.failedTiles,
+ progress: this.totalTiles > 0 ? (this.downloadedTiles + this.cachedTiles + this.failedTiles) / this.totalTiles : 0,
+ status: this.status
+ };
+ }
+
+ static async getTileFromCacheOrRemote(chartsPath: string, provider: any, tile: Tile): Promise {
+ const tilePath = path.join(chartsPath, `${provider.name}`, `${tile.z}`, `${tile.x}`, `${tile.y}.${provider.format}`);
+ if (fs.existsSync(tilePath)) {
+ try {
+ const data = await fs.promises.readFile(tilePath);
+ return data;
+ } catch (err) {
+ console.error(`Error reading cached tile ${tilePath}:`, err);
+ }
+ }
+ const buffer = await this.fetchTileFromRemote(provider, tile);
+ if (buffer) {
+ if (!fs.existsSync(path.dirname(tilePath))) {
+ fs.mkdirSync(path.dirname(tilePath), { recursive: true });
+ }
+ await fs.promises.writeFile(tilePath, buffer);
+ }
+ return buffer;
+ }
+
+ static async fetchTileFromRemote(provider: any, tile: Tile): Promise {
+ const url = provider.remoteUrl
+ .replace("{z}", tile.z.toString())
+ .replace("{x}", tile.x.toString())
+ .replace("{y}", tile.y.toString())
+ .replace("{-y}", (Math.pow(2, tile.z) - 1 - tile.y).toString());
+ const response = await fetch(url, {
+ headers: provider.headers
+ });
+ if (!response.ok) {
+ return null;
+ }
+ const arrayBuffer = await response.arrayBuffer();
+ const buffer = Buffer.from(arrayBuffer);
+ return buffer
+ }
+
+ getSubTiles(tile: Tile, maxZoom: number): Tile[] {
+ const tiles: Tile[] = [tile];
+
+ for (let z = tile.z + 1; z <= maxZoom; z++) {
+ const zoomDiff = z - tile.z;
+ const factor = Math.pow(2, zoomDiff);
+
+ const startX = tile.x * factor;
+ const startY = tile.y * factor;
+
+ for (let x = startX; x < startX + factor; x++) {
+ for (let y = startY; y < startY + factor; y++) {
+ tiles.push({ x, y, z });
+ }
+ }
+ }
+
+ return tiles;
+ }
+
+ /**
+ * Get all tiles that intersect a bounding box up to a maximum zoom level.
+ * bbox = [minLon, minLat, maxLon, maxLat]
+ */
+ getTilesForBBox(bbox: BBox, maxZoom: number): Tile[] {
+ const tiles: Tile[] = [];
+ let [minLon, minLat, maxLon, maxLat] = bbox;
+
+ const crossesAntiMeridian = minLon > maxLon;
+
+ // Helper to process a lon/lat box normally
+ const processBBox = (lo1: number, la1: number, lo2: number, la2: number) => {
+ for (let z = 0; z <= maxZoom; z++) {
+ const [minX, maxY] = this.lonLatToTileXY(lo1, la1, z);
+ const [maxX, minY] = this.lonLatToTileXY(lo2, la2, z);
+
+ for (let x = minX; x <= maxX; x++) {
+ for (let y = minY; y <= maxY; y++) {
+ tiles.push({ x, y, z });
+ }
+ }
+ }
+ };
+
+ if (!crossesAntiMeridian) {
+ // normal
+ processBBox(minLon, minLat, maxLon, maxLat);
+ } else {
+ // crosses antimeridian — split into two boxes:
+ // [minLon -> 180] and [-180 -> maxLon]
+ processBBox(minLon, minLat, 180, maxLat);
+ processBBox(-180, minLat, maxLon, maxLat);
+ }
+
+ return tiles;
+ }
+
+ getTilesForGeoJSON(
+ geojson: FeatureCollection,
+ zoomMin = 1,
+ zoomMax = 14
+ ): Tile[] {
+ const tiles: Tile[] = [];
+
+ for (const feature of geojson.features) {
+ if (feature.geometry.type !== "Polygon" && feature.geometry.type !== "MultiPolygon") {
+ console.warn("Skipping non-polygon feature");
+ continue;
+ }
+
+ const boundingBox = bbox(feature.geometry as Polygon); // [minX, minY, maxX, maxY]
+ for (let z = zoomMin; z <= zoomMax; z++) {
+ const [minX, minY] = this.lonLatToTileXY(boundingBox[0], boundingBox[3], z); // top-left
+ const [maxX, maxY] = this.lonLatToTileXY(boundingBox[2], boundingBox[1], z); // bottom-right
+
+ for (let x = minX; x <= maxX; x++) {
+ for (let y = minY; y <= maxY; y++) {
+ const tileBbox = this.tileToBBox(x, y, z);
+ const tilePoly = this.bboxPolygon(tileBbox);
+
+ if (booleanIntersects(feature as any, tilePoly)) {
+ tiles.push({ x, y, z });
+ }
+ }
+ }
+ }
+ }
+
+ return tiles;
+ }
+
+
+ private async getRegion(regionGUID: string): Promise> {
+ let regionData: any
+ const resp = await fetch(`${this.urlBase}/signalk/v2/api/resources/regions/${regionGUID}`)
+ if (!resp.ok) {
+ const body = await resp.text().catch(() => '')
+ console.error(`Failed to fetch region ${regionGUID}: ${resp.status} ${resp.statusText} ${body}`)
+ return {};
+ }
+ regionData = await resp.json()
+ return regionData;
+ }
+
+
+ private convertRegionToGeoJSON(region: Record): FeatureCollection {
+ const feature = region.feature;
+ if (!feature || feature.type !== "Feature" || !feature.geometry) {
+ throw new Error("Invalid region: missing feature or geometry");
+ }
+
+ const geoFeature = {
+ type: "Feature" as const,
+ id: feature.id || undefined,
+ geometry: feature.geometry,
+ properties: {
+ name: region.name || "",
+ description: region.description || "",
+ timestamp: region.timestamp || "",
+ source: region.$source || "",
+ ...feature.properties,
+ },
+ };
+ const splitGeoFeature = splitGeoJSON(geoFeature);
+ const features: Feature[] = [];
+
+ const pushFeaturePolygon = (orig: Feature, coords: Position[][], idx?: number) => {
+ const poly: Feature = {
+ type: "Feature",
+ id: idx != null && orig.id ? `${orig.id}-${idx}` : orig.id,
+ geometry: {
+ type: "Polygon",
+ coordinates: coords
+ },
+ properties: orig.properties || {}
+ };
+ features.push(poly);
+ };
+
+ const f = splitGeoFeature as Feature;
+ if (f.geometry && f.geometry.type === "MultiPolygon") {
+ for (let i = 0; i < (f.geometry as MultiPolygon).coordinates.length; i++) {
+ pushFeaturePolygon(f, (f.geometry as MultiPolygon).coordinates[i], i);
+ }
+ } else if (f.geometry && f.geometry.type === "Polygon") {
+ features.push(f as Feature);
+ }
+
+ return {
+ type: "FeatureCollection" as const,
+ features
+ };
+ }
+
+ private lonLatToTileXY(lon: number, lat: number, zoom: number): [number, number] {
+ const n = 2 ** zoom;
+ const x = Math.floor(((lon + 180) / 360) * n);
+ const y = Math.floor(
+ ((1 -
+ Math.log(Math.tan((lat * Math.PI) / 180) + 1 / Math.cos((lat * Math.PI) / 180)) /
+ Math.PI) /
+ 2) *
+ n
+ );
+ return [x, y];
+ }
+
+ private tileToBBox(x: number, y: number, z: number): BBox {
+ const n = 2 ** z;
+ const lon1 = (x / n) * 360 - 180;
+ const lat1 = (Math.atan(Math.sinh(Math.PI * (1 - (2 * y) / n))) * 180) / Math.PI;
+ const lon2 = ((x + 1) / n) * 360 - 180;
+ const lat2 = (Math.atan(Math.sinh(Math.PI * (1 - (2 * (y + 1)) / n))) * 180) / Math.PI;
+ return [lon1, lat2, lon2, lat1];
+ }
+
+ private bboxPolygon(boundingBox: BBox) {
+ const [minLon, minLat, maxLon, maxLat] = boundingBox;
+ return polygon([[
+ [minLon, minLat],
+ [maxLon, minLat],
+ [maxLon, maxLat],
+ [minLon, maxLat],
+ [minLon, minLat]
+ ]]);
+ }
+}
\ No newline at end of file
diff --git a/src/index.ts b/src/index.ts
index 5dcc1ff..6b6bced 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,10 +1,12 @@
import * as bluebird from 'bluebird'
import path from 'path'
import fs from 'fs'
+import os from "os"
import * as _ from 'lodash'
import { findCharts } from './charts'
import { apiRoutePrefix } from './constants'
import { ChartProvider, OnlineChartProvider } from './types'
+import { ChartSeedingManager, ChartDownloader, Tile } from './chartDownloader'
import { Request, Response, Application } from 'express'
import { OutgoingHttpHeaders } from 'http'
import {
@@ -20,8 +22,8 @@ interface Config {
interface ChartProviderApp
extends ServerAPI,
- ResourceProviderRegistry,
- Application {
+ ResourceProviderRegistry,
+ Application {
config: {
ssl: boolean
configPath: string
@@ -41,6 +43,8 @@ module.exports = (app: ChartProviderApp): Plugin => {
chartPaths: [],
onlineChartProviders: []
}
+
+ let urlBase: string = ''
const configBasePath = app.config.configPath
const defaultChartsPath = path.join(configBasePath, '/charts')
const serverMajorVersion = app.config.version
@@ -48,6 +52,8 @@ module.exports = (app: ChartProviderApp): Plugin => {
: '1'
ensureDirectoryExists(defaultChartsPath)
+
+
// ******** REQUIRED PLUGIN DEFINITION *******
const CONFIG_SCHEMA = {
title: 'Signal K Charts',
@@ -122,6 +128,24 @@ module.exports = (app: ChartProviderApp): Plugin => {
description:
'Map URL (for tilelayer include {z}, {x} and {y} parameters, e.g. "http://example.org/{z}/{x}/{y}.png")'
},
+ proxy: {
+ type: 'boolean',
+ title: 'Proxy through signalk server',
+ description:
+ 'Create a proxy to serve remote tiles and cache fetched tiles from the remote server, to serve them locally on subsequent requests.',
+ default: false
+ },
+ headers: {
+ type: 'array',
+ title: 'Headers',
+ description:
+ 'List of http headers to be sent to the remote server when requesting map tiles through proxy.',
+ items: {
+ title: 'Header Name: Value',
+ description: 'Name and Value of the HTTP header separated by colon',
+ type: 'string'
+ }
+ },
style: {
type: 'string',
title: 'Vector Map Style',
@@ -165,6 +189,9 @@ module.exports = (app: ChartProviderApp): Plugin => {
app.debug(`** loaded config: ${config}`)
props = { ...config }
+ urlBase = `${app.config.ssl ? 'https' : 'http'}://localhost:${'getExternalPort' in app.config ? app.config.getExternalPort() : 3000}`
+ app.debug(`**urlBase** ${urlBase}`)
+
const chartPaths = _.isEmpty(props.chartPaths)
? [defaultChartsPath]
: resolveUniqueChartPaths(props.chartPaths, configBasePath)
@@ -172,7 +199,7 @@ module.exports = (app: ChartProviderApp): Plugin => {
const onlineProviders = _.reduce(
props.onlineChartProviders,
(result: { [key: string]: object }, data) => {
- const provider = convertOnlineProviderConfig(data)
+ const provider = convertOnlineProviderConfig(data, urlBase)
result[provider.identifier] = provider
return result
},
@@ -194,10 +221,6 @@ module.exports = (app: ChartProviderApp): Plugin => {
registerAsProvider()
}
- 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
@@ -229,36 +252,100 @@ module.exports = (app: ChartProviderApp): Plugin => {
`${chartTilesPath}/:identifier/:z([0-9]*)/:x([0-9]*)/:y([0-9]*)`,
async (req: Request, res: Response) => {
const { identifier, z, x, y } = req.params
+ const ix = parseInt(x)
+ const iy = parseInt(y)
+ const iz = parseInt(z)
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()
+ if (provider.proxy === true) {
+ return serveTileFromCacheOrRemote(res, provider, iz, ix, iy)
+ }
+ else
+ {
+ switch (provider._fileFormat) {
+ case 'directory':
+ return serveTileFromFilesystem(
+ res,
+ provider,
+ iz,
+ ix,
+ iy
+ )
+ case 'mbtiles':
+ return serveTileFromMbtiles(
+ res,
+ provider,
+ iz,
+ ix,
+ iy
+ )
+ default:
+ console.log(
+ `Unknown chart provider fileformat ${provider._fileFormat}`
+ )
+ res.status(500).send()
+ }
}
}
)
+ app.post(`${chartTilesPath}/cache/:identifier`, async (req: Request, res: Response) => {
+ const { identifier } = req.params
+ const { regionGUID, tile, bbox, maxZoom } = req.body as {
+ regionGUID?: string;
+ tile?: Tile; // query params come in as strings
+ bbox?: {
+ minLon: number;
+ minLat: number;
+ maxLon: number;
+ maxLat: number;
+ };
+ maxZoom?: string;
+ };
+ const provider = chartProviders[identifier]
+ if (!provider) {
+ return res.sendStatus(500).send("Provider not found")
+ }
+ if (!maxZoom) {
+ return res.status(400).send('maxZoom parameter is required')
+ }
+ const maxZoomParsed = parseInt(maxZoom)
+ const job = await ChartSeedingManager.createJob(urlBase, defaultChartsPath, provider, maxZoomParsed, regionGUID, bbox ? [bbox.minLon, bbox.minLat, bbox.maxLon, bbox.maxLat] : undefined, tile )
+ return res.status(200)
+ })
+
+ app.get(`${chartTilesPath}/cache/jobs`, (req: Request, res: Response) => {
+ const jobs = Object.entries(ChartSeedingManager.ActiveJobs).map(([id, job]) => {
+ return job.info()
+ })
+ return res.status(200).json(jobs)
+
+ })
+
+ app.post(`${chartTilesPath}/cache/jobs/:id`, (req: Request, res: Response) => {
+ const { id } = req.params
+ const { action } = req.body as { action: string }
+ const parsedId = parseInt(id)
+ const job = ChartSeedingManager.ActiveJobs[parsedId]
+ if (job && action) {
+ if (action === 'start') {
+ job.seedCache()
+ } else if (action === 'stop') {
+ job.cancelJob()
+ } else if (action === 'delete') {
+ job.deleteCache()
+ } else if (action === 'remove') {
+ delete ChartSeedingManager.ActiveJobs[parsedId]
+ }
+ else {
+ return res.status(404).send(`Job ${parsedId} not found`)
+ }
+ return res.status(200).send(`Job ${parsedId} ${action}ed`)
+ }
+ })
+
app.debug('** Registering v1 API paths **')
app.get(
@@ -322,6 +409,22 @@ module.exports = (app: ChartProviderApp): Plugin => {
}
}
+ const serveTileFromCacheOrRemote = async (
+ res: Response,
+ provider: ChartProvider,
+ z: number,
+ x: number,
+ y: number
+ ) => {
+ const buffer = await ChartDownloader.getTileFromCacheOrRemote(defaultChartsPath, provider, { x, y, z })
+ if (!buffer) {
+ res.sendStatus(500)
+ return
+ }
+ res.set('Content-Type', `image/${provider.format}`)
+ res.send(buffer)
+ }
+
return plugin
}
@@ -341,8 +444,26 @@ const resolveUniqueChartPaths = (
return _.uniq(paths)
}
-const convertOnlineProviderConfig = (provider: OnlineChartProvider) => {
+const convertOnlineProviderConfig = (provider: OnlineChartProvider, urlBase: string) => {
const id = _.kebabCase(_.deburr(provider.name))
+
+ const parseHeaders = (arr: string[] | undefined): { [key: string]: string } => {
+ if (arr === undefined) {
+ return {}
+ }
+ return arr.reduce<{ [key: string]: string }>((acc, entry) => {
+ if(typeof entry == 'string') {
+ const idx = entry.indexOf(':');
+ const key = entry.slice(0, idx).trim();
+ const value = entry.slice(idx + 1).trim();
+ if (key && value) {
+ acc[key] = value
+ }
+ }
+ return acc
+ }, {})
+ }
+
const data = {
identifier: id,
name: provider.name,
@@ -355,13 +476,16 @@ const convertOnlineProviderConfig = (provider: OnlineChartProvider) => {
type: provider.serverType ? provider.serverType : 'tilelayer',
style: provider.style ? provider.style : null,
v1: {
- tilemapUrl: provider.url,
+ tilemapUrl: provider.proxy ? `~tilePath~/${id}/{z}/{x}/{y}` : provider.url,
chartLayers: provider.layers ? provider.layers : null
},
v2: {
- url: provider.url,
+ url: provider.proxy ? `~tilePath~/${id}/{z}/{x}/{y}` : provider.url,
layers: provider.layers ? provider.layers : null
- }
+ },
+ proxy: provider.proxy ? provider.proxy : false,
+ remoteUrl: provider.proxy ? provider.url : null,
+ headers: parseHeaders(provider.headers),
}
return data
}
diff --git a/src/types.ts b/src/types.ts
index dad6bba..339e516 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -31,6 +31,9 @@ export interface ChartProvider {
format?: string
style?: string
layers?: string[]
+ proxy?: boolean
+ remoteUrl?: string
+ headers?: { [key: string]: string }
}
export interface OnlineChartProvider {
@@ -41,6 +44,8 @@ export interface OnlineChartProvider {
serverType: MapSourceType
format: 'png' | 'jpg'
url: string
+ proxy: boolean
+ headers?: string[]
style: string
layers: string[]
}
diff --git a/test/plugin-test.js b/test/plugin-test.js
index 2651889..34e3bef 100644
--- a/test/plugin-test.js
+++ b/test/plugin-test.js
@@ -75,10 +75,13 @@ describe('GET /resources/charts', () => {
expect(result.body['test-name']).to.deep.equal({
bounds: [-180, -90, 180, 90],
format: 'jpg',
+ headers: {},
identifier: 'test-name',
maxzoom: 15,
minzoom: 2,
name: 'Test Name',
+ proxy: false,
+ remoteUrl: null,
scale: 250000,
"style": null,
tilemapUrl: 'https://example.com',