Browse Source

big refactor, first working version

merge --path and --route options (also in code)
implement server selecting
refactor config and parameter providing
implement rate limiting
mrkvon 11 months ago
parent
commit
604ff031f7
14 changed files with 567 additions and 449 deletions
  1. 1
    0
      package.json
  2. 110
    0
      src/config.ts
  3. 61
    0
      src/download.ts
  4. 5
    0
      src/http.ts
  5. 55
    18
      src/map.ts
  6. 35
    0
      src/parse-route.ts
  7. 0
    232
      src/path.ts
  8. 5
    77
      src/pdf.ts
  9. 217
    71
      src/route.ts
  10. 27
    51
      src/run.ts
  11. 19
    0
      src/tile-servers.ts
  12. 19
    0
      src/types.ts
  13. 8
    0
      src/utils.ts
  14. 5
    0
      yarn.lock

+ 1
- 0
package.json View File

@@ -57,6 +57,7 @@
57 57
   },
58 58
   "dependencies": {
59 59
     "axios": "^0.19.2",
60
+    "axios-rate-limit": "^1.3.0",
60 61
     "fs-extra": "^8.1.0",
61 62
     "gm": "^1.23.1",
62 63
     "haversine": "^1.1.1",

+ 110
- 0
src/config.ts View File

@@ -0,0 +1,110 @@
1
+import minimist from 'minimist';
2
+import { TileServer } from './types';
3
+// TODO json-schema validation of input
4
+//
5
+
6
+// Generic options
7
+type Mode = 'help' | 'map' | 'route' | 'tiles';
8
+
9
+interface ModeConfig {
10
+  mode: Mode;
11
+}
12
+
13
+interface BaseConfig extends ModeConfig {
14
+  zoom: number;
15
+  output: string;
16
+  tileServer: TileServer;
17
+  pageSizeX: number;
18
+  pageSizeY: number;
19
+}
20
+
21
+interface MapConfig extends BaseConfig {
22
+  mode: 'map';
23
+  north: number;
24
+  west: number;
25
+  south: number;
26
+  east: number;
27
+}
28
+
29
+interface RouteConfig extends BaseConfig {
30
+  mode: 'route';
31
+  input: string;
32
+  draw: boolean;
33
+  distance: boolean;
34
+  distanceStep: number;
35
+}
36
+
37
+interface HelpConfig extends ModeConfig {
38
+  mode: 'help';
39
+}
40
+
41
+interface TilesConfig extends ModeConfig {
42
+  mode: 'tiles';
43
+}
44
+
45
+export default function getConfig(): MapConfig | RouteConfig | HelpConfig | TilesConfig {
46
+  const helpConfig: HelpConfig = { mode: 'help' };
47
+  const tilesConfig: TilesConfig = { mode: 'tiles' };
48
+
49
+  const argv = minimist(process.argv.slice(2));
50
+
51
+  if (argv.h || argv.help) {
52
+    return helpConfig;
53
+  }
54
+
55
+  if (argv['list-tile-servers']) {
56
+    return tilesConfig;
57
+  }
58
+
59
+  const zoom = argv.zoom ?? 12;
60
+  const pageSizeX = argv.x ?? 4;
61
+  const pageSizeY = argv.y ?? 5;
62
+  const tileServer = getTileServer(argv['tile-server'], argv['rate-limit']);
63
+
64
+  const baseConfig = { zoom, pageSizeX, pageSizeY, tileServer };
65
+
66
+  if (argv.route) {
67
+    const input = argv.input;
68
+    if (!input) return helpConfig;
69
+
70
+    return {
71
+      ...baseConfig,
72
+      mode: 'route',
73
+      input,
74
+      draw: argv.path ?? true,
75
+      distance: !!argv.distance,
76
+      distanceStep: argv['distance-step'] ?? 10,
77
+      output: argv.output || 'route',
78
+    };
79
+  }
80
+
81
+  if (argv.map) {
82
+    const north: number = argv.n;
83
+    const west: number = argv.w;
84
+    const south: number = argv.s;
85
+    const east: number = argv.e;
86
+
87
+    if (typeof north !== 'number' || typeof south !== 'number' || typeof east !== 'number' || typeof west !== 'number')
88
+      return helpConfig;
89
+
90
+    const output = argv.output ?? `map_${north}_${west}_${south}_${east}_${zoom}`;
91
+    return {
92
+      ...baseConfig,
93
+      mode: 'map',
94
+      north,
95
+      south,
96
+      west,
97
+      east,
98
+      output,
99
+    };
100
+  }
101
+
102
+  return helpConfig;
103
+}
104
+
105
+function getTileServer(server: string | number, rateLimit: number = 10): TileServer {
106
+  return {
107
+    url: ({ x, y, z }) => `${server}/${z}/${x}/${y}.png`,
108
+    rateLimit: rateLimit ?? 5,
109
+  };
110
+}

+ 61
- 0
src/download.ts View File

@@ -0,0 +1,61 @@
1
+import { Page, TileServer, Tile } from './types';
2
+import { getHttp } from './http';
3
+import fs from 'fs-extra';
4
+import mergeImg from 'merge-img';
5
+import log from './log';
6
+import { getFilename } from './utils';
7
+
8
+// This is the main exported function. Provide pages, folder and server to dowload from and it fills the tmp folder with the pages
9
+export async function downloadPages(pages: Page[], tmp: string, tileServer: TileServer) {
10
+  await fs.ensureDir(tmp);
11
+  const http = getHttp(tileServer.rateLimit);
12
+  for (let i = 0, len = pages.length; i < len; i++) {
13
+    const page = pages[i];
14
+    log('page', `${i + 1}/${pages.length}`);
15
+    const pageTiles = await getPage(page, { http, tileServer });
16
+    await savePage(pageTiles, getFilename(tmp, i));
17
+  }
18
+}
19
+
20
+// download a single tile
21
+async function getTile(
22
+  { x, y, zoom: z }: Tile,
23
+  { http, tileServer }: { http: any; tileServer: TileServer },
24
+): Promise<Buffer> {
25
+  return Buffer.from(
26
+    (
27
+      await http.get(tileServer.url({ z, x, y }), {
28
+        responseType: 'arraybuffer',
29
+        headers: {
30
+          'User-Agent': 'osm2pdf',
31
+        },
32
+      })
33
+    ).data,
34
+    'binary',
35
+  );
36
+}
37
+
38
+async function getPage(
39
+  { x, y, sx, sy, zoom }: Page,
40
+  { http, tileServer }: { http: any; tileServer: TileServer },
41
+): Promise<Buffer[][]> {
42
+  const rows: Promise<Buffer[]>[] = [];
43
+  for (let i = 0; i < sx; i++) {
44
+    const row: Promise<Buffer>[] = [];
45
+    for (let j = 0; j < sy; j++) {
46
+      row.push(getTile({ x: x + i, y: y + j, zoom }, { http, tileServer }));
47
+    }
48
+    rows.push(Promise.all(row));
49
+  }
50
+  return await Promise.all(rows);
51
+}
52
+
53
+async function mergeTiles(tiles: Buffer[][]): Promise<any> {
54
+  const row = await Promise.all(tiles.map(column => mergeImg(column, { direction: true })));
55
+  return await mergeImg(row);
56
+}
57
+
58
+async function savePage(pageTiles: Buffer[][], output: string): Promise<void> {
59
+  const image = await mergeTiles(pageTiles);
60
+  await new Promise(resolve => image.write(output, resolve));
61
+}

+ 5
- 0
src/http.ts View File

@@ -0,0 +1,5 @@
1
+import rateLimit from 'axios-rate-limit';
2
+import axios from 'axios';
3
+
4
+export const getHttp = (rps: number) =>
5
+  rps === 0 ? axios : rateLimit(axios, { maxRequests: 1, perMilliseconds: 1000 / rps });

+ 55
- 18
src/map.ts View File

@@ -1,29 +1,66 @@
1 1
 import { lat2tile, lon2tile, getTileSize } from './osm';
2
-import { pages2pdf, Page, PageSize } from './pdf';
2
+import { TileServer, Page } from './types';
3
+import { createPdf } from './pdf';
4
+import { downloadPages } from './download';
5
+import { clearTmp } from './utils';
3 6
 
4
-interface Boundaries {
7
+// main executable function
8
+export default async function map({
9
+  zoom,
10
+  output,
11
+  pageSizeX,
12
+  pageSizeY,
13
+  north,
14
+  west,
15
+  south,
16
+  east,
17
+  tileServer,
18
+}: {
19
+  zoom: number;
20
+  output: string;
21
+  pageSizeX: number;
22
+  pageSizeY: number;
5 23
   north: number;
24
+  west: number;
6 25
   south: number;
7 26
   east: number;
8
-  west: number;
9
-  zoom: number;
10
-}
11
-
12
-// main executable function
13
-export default async function map(boundaries: Boundaries, pageSize: PageSize, output: string) {
14
-  const pages: Page[] = boundaries2pages(boundaries, pageSize);
15
-
16
-  await pages2pdf(pages, output);
27
+  tileServer: TileServer;
28
+}) {
29
+  // collect pages
30
+  const pages: Page[] = boundaries2pages({ north, west, south, east, pageSizeX, pageSizeY, zoom });
31
+  // download pages
32
+  const tmp = `tmp${Date.now()}`;
33
+  await downloadPages(pages, tmp, tileServer);
34
+  // create pdf
35
+  await createPdf(output, tmp);
36
+  // clean up downloaded pages
37
+  await clearTmp(tmp);
17 38
 }
18 39
 
19
-function boundaries2pages({ north, west, south, east, zoom }: Boundaries, { sx, sy }: PageSize) {
40
+function boundaries2pages({
41
+  north,
42
+  west,
43
+  south,
44
+  east,
45
+  pageSizeX,
46
+  pageSizeY,
47
+  zoom,
48
+}: {
49
+  north: number;
50
+  west: number;
51
+  south: number;
52
+  east: number;
53
+  pageSizeX: number;
54
+  pageSizeY: number;
55
+  zoom: number;
56
+}) {
20 57
   const x = lon2tile(west, zoom);
21 58
   const y = lat2tile(north, zoom);
22 59
 
23 60
   const { width, height } = getTileSize({ north, west, south, east, zoom });
24 61
 
25
-  const pagesX = Math.ceil(width / sx);
26
-  const pagesY = Math.ceil(height / sy);
62
+  const pagesX = Math.ceil(width / pageSizeX);
63
+  const pagesY = Math.ceil(height / pageSizeY);
27 64
 
28 65
   console.log('size', pagesX, 'x', pagesY, 'pages'); // tslint:disable-line:no-console
29 66
 
@@ -32,10 +69,10 @@ function boundaries2pages({ north, west, south, east, zoom }: Boundaries, { sx,
32 69
   for (let py = 0; py < pagesY; py++) {
33 70
     for (let px = 0; px < pagesX; px++) {
34 71
       pages.push({
35
-        x: x + px * sx,
36
-        y: y + py * sy,
37
-        sx,
38
-        sy,
72
+        x: x + px * pageSizeX,
73
+        y: y + py * pageSizeY,
74
+        sx: pageSizeX,
75
+        sy: pageSizeY,
39 76
         zoom,
40 77
       });
41 78
     }

+ 35
- 0
src/parse-route.ts View File

@@ -0,0 +1,35 @@
1
+import xml2js from 'xml2js';
2
+import fs from 'fs-extra';
3
+import { promisify } from 'util';
4
+
5
+const parseString = promisify(xml2js.parseString);
6
+
7
+export interface Coordinate {
8
+  lat: number;
9
+  lon: number;
10
+  ele: number;
11
+}
12
+
13
+interface StringCoordinate {
14
+  lat: string;
15
+  lon: string;
16
+}
17
+
18
+interface Route {
19
+  gpx: {
20
+    trk: {
21
+      trkseg: {
22
+        trkpt: {
23
+          $: StringCoordinate;
24
+          ele: string[];
25
+        }[];
26
+      }[];
27
+    }[];
28
+  };
29
+}
30
+
31
+export async function parseRoute(file: string): Promise<Coordinate[]> {
32
+  const xml = (await fs.readFile(file)).toString();
33
+  const raw = (await parseString(xml)) as Route;
34
+  return raw.gpx.trk[0].trkseg[0].trkpt.map(({ $: { lat, lon }, ele: [ele] }) => ({ lat: +lat, lon: +lon, ele: +ele }));
35
+}

+ 0
- 232
src/path.ts View File

@@ -1,232 +0,0 @@
1
-import fs from 'fs-extra';
2
-import { lat2tileExact, lon2tileExact } from './osm';
3
-import { Tile, Page, PageSize, savePage, createPdf, clearTemporary } from './pdf';
4
-import { pad } from './utils';
5
-import gm from 'gm';
6
-import * as vector from './vector';
7
-import { parseRoute, Coordinate } from './route';
8
-import log from './log';
9
-import * as nodePath from 'path';
10
-import haversine from 'haversine';
11
-
12
-const TILE_SIZE = 256;
13
-
14
-interface TileWithDistance extends Tile {
15
-  distance: number;
16
-}
17
-
18
-type Path = TileWithDistance[];
19
-
20
-interface DrawablePoint {
21
-  x: number;
22
-  y: number;
23
-  distance: number;
24
-}
25
-
26
-type DrawablePath = DrawablePoint[];
27
-
28
-export default async function path(
29
-  { zoom, input }: { zoom: number; input: string },
30
-  { sx, sy }: PageSize,
31
-  distanceStep: number,
32
-  output: string,
33
-) {
34
-  // get route
35
-  const route = await parseRoute(input);
36
-  // convert gps points to tile points
37
-  const tileRoute = getTileRouteWithDistance(route, zoom);
38
-  // convert tile points to pixel points of path
39
-  const drawablePath = getDrawablePath(tileRoute);
40
-  // collect the pages in abstract form
41
-  const pages = collectPages(tileRoute, sx, sy);
42
-
43
-  // download the tiles and connect them to pages
44
-  const tmp = `tmp${Date.now()}`;
45
-  await fs.ensureDir(tmp);
46
-  for (let i = 0, len = pages.length; i < len; ++i) {
47
-    const page = pages[i];
48
-    // tslint:disable-next-line:no-shadowed-variable
49
-    const { x, y, sx, sy, zoom } = page;
50
-    const filename = `${tmp}/${pad(i)}.png`;
51
-    // save page
52
-    log(`${i + 1}/${pages.length} downloading`);
53
-    await savePage({ x, y, sx, sy, zoom }, filename);
54
-    // draw paths
55
-    log(`${i + 1}/${pages.length} drawing`);
56
-    await drawPath(page, drawablePath, distanceStep, filename);
57
-  }
58
-  // make pdf from pages
59
-  log(`Creating pdf`);
60
-  await createPdf(output, tmp);
61
-  await clearTemporary(tmp);
62
-  log('');
63
-  // tslint:disable-next-line:no-console
64
-  console.log(`Finished! Your map was saved to ${output}.pdf`);
65
-}
66
-
67
-function getDrawablePath(tilePath: Path): DrawablePath {
68
-  const drawablePath: DrawablePath = [];
69
-  // tslint:disable-next-line:no-shadowed-variable
70
-  const path = tilePath.map(point => ({
71
-    x: point.x * TILE_SIZE,
72
-    y: point.y * TILE_SIZE,
73
-  }));
74
-
75
-  if (path.length <= 1) {
76
-    path.forEach(point => {
77
-      drawablePath.push({
78
-        x: point.x,
79
-        y: point.y,
80
-        distance: 0,
81
-      });
82
-    });
83
-  } else {
84
-    let lastPoint: vector.Vector | null;
85
-
86
-    // tslint:disable-next-line:no-shadowed-variable
87
-    path.forEach((point, i, path) => {
88
-      let direction;
89
-      if (i === 0) {
90
-        direction = vector.normal(vector.unit(vector.diff(path[i + 1], path[i])));
91
-      } else if (i === path.length - 1) {
92
-        direction = vector.normal(vector.unit(vector.diff(path[i], path[i - 1])));
93
-      } else {
94
-        direction = vector.normal(
95
-          vector.unit(
96
-            vector.sum(vector.unit(vector.diff(path[i], path[i - 1])), vector.unit(vector.diff(path[i + 1], path[i]))),
97
-          ),
98
-        );
99
-      }
100
-      const newPoint = vector.sum(point, vector.times(10, direction));
101
-      // don't draw a point if it's too close to the last drawn point
102
-      if (!lastPoint || vector.size(vector.diff(newPoint, lastPoint)) > 30) {
103
-        drawablePath.push({
104
-          x: newPoint.x,
105
-          y: newPoint.y,
106
-          distance: tilePath[i].distance,
107
-        });
108
-
109
-        lastPoint = newPoint;
110
-      }
111
-
112
-      // make sure the very last point is displayed
113
-      if (i === path.length - 1) {
114
-        // not too close!
115
-        drawablePath.pop();
116
-        drawablePath.push({
117
-          x: newPoint.x,
118
-          y: newPoint.y,
119
-          distance: tilePath[i].distance,
120
-        });
121
-      }
122
-    });
123
-  }
124
-
125
-  return drawablePath;
126
-}
127
-
128
-async function drawPath(page: Page, drawablePath: DrawablePath, distanceStep: number, filename: string) {
129
-  await new Promise((resolve, reject) => {
130
-    const control = gm(filename);
131
-
132
-    // tslint:disable-next-line:no-shadowed-variable
133
-    const path = drawablePath.map(({ x, y, distance }) => ({
134
-      x: x - page.x * TILE_SIZE,
135
-      y: y - page.y * TILE_SIZE,
136
-      distance,
137
-    }));
138
-
139
-    // draw points
140
-    control.stroke('red', 2).fill('#000f');
141
-    path.forEach(({ x, y }) => {
142
-      control.drawCircle(x, y, x + 3, y + 3);
143
-    });
144
-
145
-    // draw distance labels
146
-    if (distanceStep > -1) {
147
-      control
148
-        .stroke('none')
149
-        .fill('black')
150
-        .font(nodePath.resolve(__dirname, 'fonts/OpenSans/OpenSans-Bold.ttf'), 12);
151
-
152
-      let nextDistance = 0;
153
-      // tslint:disable-next-line:no-shadowed-variable
154
-      path.forEach(({ x, y, distance }, i, path) => {
155
-        if (distance >= nextDistance * distanceStep || i === path.length - 1) {
156
-          control.drawText(x + 7, y + 6, distance.toFixed(1));
157
-          ++nextDistance;
158
-        }
159
-      });
160
-    }
161
-
162
-    // draw it
163
-    control.write(filename, err => {
164
-      if (err) return reject(err);
165
-      else return resolve();
166
-    });
167
-  });
168
-}
169
-
170
-function collectPages(tileRoute: Path, sx: number, sy: number): Page[] {
171
-  const pages: Page[] = [];
172
-  const pageDict: { [id: string]: boolean } = {};
173
-
174
-  for (const tile of tileRoute) {
175
-    const pageX = Math.floor(tile.x / sx) * sx;
176
-    const pageY = Math.floor(tile.y / sy) * sy;
177
-
178
-    const id = `${pageX}-${pageY}`;
179
-    if (!pageDict[id]) {
180
-      pageDict[id] = true;
181
-      pages.push({
182
-        x: pageX,
183
-        y: pageY,
184
-        sx,
185
-        sy,
186
-        zoom: tile.zoom,
187
-      });
188
-    }
189
-  }
190
-
191
-  return pages;
192
-}
193
-/*
194
-function getTileRoute(route: Coordinate[], zoom: number): Tile[] {
195
-  return route.map(({ lat, lon }) => ({
196
-    x: lon2tileExact(lon, zoom),
197
-    y: lat2tileExact(lat, zoom),
198
-    zoom,
199
-  }));
200
-  }
201
-  */
202
-
203
-function getTileRouteWithDistance(route: Coordinate[], zoom: number): Path {
204
-  const output: Path = [];
205
-  let distance = 0;
206
-
207
-  for (let i = 0, len = route.length; i < len; ++i) {
208
-    if (i > 0) {
209
-      distance +=
210
-        (haversine(
211
-          {
212
-            latitude: route[i].lat,
213
-            longitude: route[i].lon,
214
-          },
215
-          {
216
-            latitude: route[i - 1].lat,
217
-            longitude: route[i - 1].lon,
218
-          },
219
-        ) **
220
-          2 +
221
-          (route[i].ele / 1000 - route[i - 1].ele / 1000) ** 2) **
222
-        0.5;
223
-    }
224
-    output.push({
225
-      x: lon2tileExact(route[i].lon, zoom),
226
-      y: lat2tileExact(route[i].lat, zoom),
227
-      zoom,
228
-      distance,
229
-    });
230
-  }
231
-  return output;
232
-}

+ 5
- 77
src/pdf.ts View File

@@ -1,86 +1,14 @@
1
-import axios from 'axios';
2
-import mergeImg from 'merge-img';
3 1
 import gm from 'gm';
4
-import fs from 'fs-extra';
5
-import { pad } from './utils';
6 2
 import log from './log';
7 3
 
8
-export interface Tile {
9
-  x: number;
10
-  y: number;
11
-  zoom: number;
12
-}
13
-
14
-export interface PageSize {
15
-  sx: number;
16
-  sy: number;
17
-}
18
-
19
-export interface Page extends Tile, PageSize {}
20
-
21
-async function getTile({ x, y, zoom }: Tile): Promise<Buffer> {
22
-  return Buffer.from(
23
-    (
24
-      await axios.get(`https://tile.openstreetmap.org/${zoom}/${x}/${y}.png`, {
25
-        responseType: 'arraybuffer',
26
-      })
27
-    ).data,
28
-    'binary',
29
-  );
30
-}
31
-
32
-export async function getPage({ x, y, sx, sy, zoom }: Page): Promise<Buffer[][]> {
33
-  const rows: Promise<Buffer[]>[] = [];
34
-  for (let i = 0; i < sx; i++) {
35
-    const row: Promise<Buffer>[] = [];
36
-    for (let j = 0; j < sy; j++) {
37
-      row.push(getTile({ x: x + i, y: y + j, zoom }));
38
-    }
39
-    rows.push(Promise.all(row));
40
-  }
41
-  return await Promise.all(rows);
42
-}
43
-
44
-async function mergeTiles(tiles: Buffer[][]): Promise<any> {
45
-  const row = await Promise.all(tiles.map(column => mergeImg(column, { direction: true })));
46
-  return await mergeImg(row);
47
-}
48
-
49
-export async function savePage(page: Page, output: string): Promise<void> {
50
-  const pageTiles = await getPage(page);
51
-  const image = await mergeTiles(pageTiles);
52
-  await new Promise(resolve => image.write(output, resolve));
53
-}
54
-
55
-export async function createPdf(name: string, tmp: string) {
4
+export async function createPdf(output: string, tmp: string) {
5
+  log('Creating pdf');
56 6
   await new Promise((resolve, reject) => {
57
-    gm(`${tmp}/*.png`).write(`${name}.pdf`, err => {
7
+    gm(`${tmp}/*.png`).write(`${output}.pdf`, err => {
58 8
       if (!err) return resolve();
59 9
       if (err) return reject(err);
60 10
     });
61 11
   });
62
-}
63
-
64
-export async function clearTemporary(tmp: string) {
65
-  await fs.remove(tmp);
66
-}
67
-
68
-export async function pages2pdf(pages: Page[], name: string): Promise<void> {
69
-  const tmp = `tmp${Date.now()}`;
70
-  await fs.ensureDir(tmp);
71
-
72
-  for (let i = 0, len = pages.length; i < len; i++) {
73
-    const page = pages[i];
74
-    log('page', `${i + 1}/${pages.length}`);
75
-    const pageTiles = await getPage(page);
76
-    const image = await mergeTiles(pageTiles);
77
-
78
-    await new Promise(resolve => image.write(`${tmp}/${pad(i)}.png`, resolve));
79
-  }
80
-
81
-  await createPdf(name, tmp);
82
-  await clearTemporary(tmp);
83
-
84
-  log();
85
-  console.log(`Finished! Your map was saved to ${name}.pdf`); // tslint:disable-line:no-console
12
+  // eslint-disable-next-line: no-console
13
+  log(`Your map was saved to ${output}.pdf\n`);
86 14
 }

+ 217
- 71
src/route.ts View File

@@ -1,90 +1,236 @@
1
-import xml2js from 'xml2js';
2
-import fs from 'fs-extra';
3
-import { promisify } from 'util';
4
-import { lat2tile, lon2tile } from './osm';
5
-import { pages2pdf, Tile, Page, PageSize } from './pdf';
6
-
7
-const parseString = promisify(xml2js.parseString);
8
-
9
-export default async function route(
10
-  { zoom, input }: { zoom: number; input: string },
11
-  pageSize: PageSize,
12
-  output: string,
13
-) {
14
-  // get route
15
-  // tslint:disable-next-line:no-shadowed-variable
16
-  const route = await parseRoute(input);
17
-  // get tile for each route point
18
-  const tiles = route2tiles(route, zoom);
19
-  const pages = tiles2pages(tiles, pageSize);
20
-  await pages2pdf(pages, output);
21
-}
1
+import { lat2tileExact, lon2tileExact } from './osm';
2
+import { createPdf } from './pdf';
3
+import { clearTmp, getFilename } from './utils';
4
+import gm from 'gm';
5
+import * as vector from './vector';
6
+import log from './log';
7
+import * as nodePath from 'path';
8
+import haversine from 'haversine';
9
+import { Tile, Page, TileServer } from './types';
10
+import { downloadPages } from './download';
11
+import { Coordinate, parseRoute } from './parse-route';
22 12
 
23
-export interface Coordinate {
24
-  lat: number;
25
-  lon: number;
26
-  ele: number;
27
-}
13
+const TILE_SIZE = 256;
28 14
 
29
-interface StringCoordinate {
30
-  lat: string;
31
-  lon: string;
15
+interface TileWithDistance extends Tile {
16
+  distance: number;
32 17
 }
33 18
 
34
-interface Route {
35
-  gpx: {
36
-    trk: {
37
-      trkseg: {
38
-        trkpt: {
39
-          $: StringCoordinate;
40
-          ele: string[];
41
-        }[];
42
-      }[];
43
-    }[];
44
-  };
19
+type Path = TileWithDistance[];
20
+
21
+interface DrawablePoint {
22
+  x: number;
23
+  y: number;
24
+  distance: number;
45 25
 }
46 26
 
47
-export async function parseRoute(file: string): Promise<Coordinate[]> {
48
-  const xml = (await fs.readFile(file)).toString();
49
-  const raw = (await parseString(xml)) as Route;
50
-  return raw.gpx.trk[0].trkseg[0].trkpt.map(({ $: { lat, lon }, ele: [ele] }) => ({ lat: +lat, lon: +lon, ele: +ele }));
27
+type DrawablePath = DrawablePoint[];
28
+
29
+export default async function path({
30
+  zoom,
31
+  input,
32
+  output,
33
+  pageSizeX,
34
+  pageSizeY,
35
+  draw,
36
+  distance,
37
+  distanceStep,
38
+  tileServer,
39
+}: {
40
+  zoom: number;
41
+  input: string;
42
+  output: string;
43
+  pageSizeX: number;
44
+  pageSizeY: number;
45
+  draw: boolean;
46
+  distance: boolean;
47
+  distanceStep: number;
48
+  tileServer: TileServer;
49
+}) {
50
+  // collect pages:
51
+  //
52
+  // get route
53
+  const route = await parseRoute(input);
54
+  // convert gps points to tile points
55
+  const tileRoute = getTileRouteWithDistance(route, zoom);
56
+  // convert tile points to pixel points of path
57
+  const drawablePath = getDrawablePath(tileRoute);
58
+  // collect the pages in abstract form
59
+  const pages = collectPages(tileRoute, pageSizeX, pageSizeY);
60
+
61
+  // download the tiles and connect them to pages
62
+  const tmp = `tmp${Date.now()}`;
63
+  await downloadPages(pages, tmp, tileServer);
64
+
65
+  // draw path on pages
66
+  if (draw) {
67
+    for (let i = 0, len = pages.length; i < len; ++i) {
68
+      // draw paths
69
+      log(`${i + 1}/${pages.length} drawing`);
70
+      const page = pages[i];
71
+      await drawPath(page, drawablePath, distance ? distanceStep : -1, getFilename(tmp, i));
72
+    }
73
+  }
74
+
75
+  // make pdf from pages
76
+  await createPdf(output, tmp);
77
+  await clearTmp(tmp);
51 78
 }
52 79
 
53
-// tslint:disable-next-line:no-shadowed-variable
54
-function route2tiles(route: Coordinate[], zoom: number): Tile[] {
55
-  const tiles: Tile[] = [];
56
-  // add tile for each route point
57
-  route.forEach(({ lat, lon }) => {
58
-    const tile: Tile = {
59
-      x: lon2tile(+lon, zoom),
60
-      y: lat2tile(+lat, zoom),
61
-      zoom,
62
-    };
80
+function getDrawablePath(tilePath: Path): DrawablePath {
81
+  const drawablePath: DrawablePath = [];
82
+  // tslint:disable-next-line:no-shadowed-variable
83
+  const path = tilePath.map(point => ({
84
+    x: point.x * TILE_SIZE,
85
+    y: point.y * TILE_SIZE,
86
+  }));
63 87
 
64
-    const found = tiles.findIndex(({ x, y }) => x === tile.x && y === tile.y);
65
-    if (found === -1) tiles.push(tile);
66
-  });
88
+  if (path.length <= 1) {
89
+    path.forEach(point => {
90
+      drawablePath.push({
91
+        x: point.x,
92
+        y: point.y,
93
+        distance: 0,
94
+      });
95
+    });
96
+  } else {
97
+    let lastPoint: vector.Vector | null;
98
+
99
+    // tslint:disable-next-line:no-shadowed-variable
100
+    path.forEach((point, i, path) => {
101
+      let direction;
102
+      if (i === 0) {
103
+        direction = vector.normal(vector.unit(vector.diff(path[i + 1], path[i])));
104
+      } else if (i === path.length - 1) {
105
+        direction = vector.normal(vector.unit(vector.diff(path[i], path[i - 1])));
106
+      } else {
107
+        direction = vector.normal(
108
+          vector.unit(
109
+            vector.sum(vector.unit(vector.diff(path[i], path[i - 1])), vector.unit(vector.diff(path[i + 1], path[i]))),
110
+          ),
111
+        );
112
+      }
113
+      const newPoint = vector.sum(point, vector.times(10, direction));
114
+      // don't draw a point if it's too close to the last drawn point
115
+      if (!lastPoint || vector.size(vector.diff(newPoint, lastPoint)) > 30) {
116
+        drawablePath.push({
117
+          x: newPoint.x,
118
+          y: newPoint.y,
119
+          distance: tilePath[i].distance,
120
+        });
121
+
122
+        lastPoint = newPoint;
123
+      }
67 124
 
68
-  return tiles;
125
+      // make sure the very last point is displayed
126
+      if (i === path.length - 1) {
127
+        // not too close!
128
+        drawablePath.pop();
129
+        drawablePath.push({
130
+          x: newPoint.x,
131
+          y: newPoint.y,
132
+          distance: tilePath[i].distance,
133
+        });
134
+      }
135
+    });
136
+  }
137
+
138
+  return drawablePath;
69 139
 }
70 140
 
71
-function tiles2pages(tiles: Tile[], { sx, sy }: PageSize): Page[] {
72
-  const pages: Page[] = [];
73
-  tiles.forEach(({ x, y, zoom }) => {
74
-    const page: Page = {
75
-      x: Math.floor(x / sx) * sx,
76
-      y: Math.floor(y / sy) * sy,
77
-      sx,
78
-      sy,
79
-      zoom,
80
-    };
141
+async function drawPath(page: Page, drawablePath: DrawablePath, distanceStep: number, filename: string) {
142
+  await new Promise((resolve, reject) => {
143
+    const control = gm(filename);
81 144
 
82 145
     // tslint:disable-next-line:no-shadowed-variable
83
-    const found = pages.findIndex(({ x, y }) => x === page.x && y === page.y);
84
-    if (found === -1) {
85
-      pages.push(page);
146
+    const path = drawablePath.map(({ x, y, distance }) => ({
147
+      x: x - page.x * TILE_SIZE,
148
+      y: y - page.y * TILE_SIZE,
149
+      distance,
150
+    }));
151
+
152
+    // draw points
153
+    control.stroke('red', 2).fill('#000f');
154
+    path.forEach(({ x, y }) => {
155
+      control.drawCircle(x, y, x + 3, y + 3);
156
+    });
157
+
158
+    // draw distance labels
159
+    if (distanceStep > -1) {
160
+      control
161
+        .stroke('none')
162
+        .fill('black')
163
+        .font(nodePath.resolve(__dirname, 'fonts/OpenSans/OpenSans-Bold.ttf'), 12);
164
+
165
+      let nextDistance = 0;
166
+      // tslint:disable-next-line:no-shadowed-variable
167
+      path.forEach(({ x, y, distance }, i, path) => {
168
+        if (distance >= nextDistance * distanceStep || i === path.length - 1) {
169
+          control.drawText(x + 7, y + 6, distance.toFixed(1));
170
+          ++nextDistance;
171
+        }
172
+      });
86 173
     }
174
+
175
+    // draw it
176
+    control.write(filename, err => {
177
+      if (err) return reject(err);
178
+      else return resolve();
179
+    });
87 180
   });
181
+}
182
+
183
+function collectPages(tileRoute: Path, sx: number, sy: number): Page[] {
184
+  const pages: Page[] = [];
185
+  const pageDict: { [id: string]: boolean } = {};
186
+
187
+  for (const tile of tileRoute) {
188
+    const pageX = Math.floor(tile.x / sx) * sx;
189
+    const pageY = Math.floor(tile.y / sy) * sy;
190
+
191
+    const id = `${pageX}-${pageY}`;
192
+    if (!pageDict[id]) {
193
+      pageDict[id] = true;
194
+      pages.push({
195
+        x: pageX,
196
+        y: pageY,
197
+        sx,
198
+        sy,
199
+        zoom: tile.zoom,
200
+      });
201
+    }
202
+  }
88 203
 
89 204
   return pages;
90 205
 }
206
+
207
+function getTileRouteWithDistance(route: Coordinate[], zoom: number): Path {
208
+  const output: Path = [];
209
+  let distance = 0;
210
+
211
+  for (let i = 0, len = route.length; i < len; ++i) {
212
+    if (i > 0) {
213
+      distance +=
214
+        (haversine(
215
+          {
216
+            latitude: route[i].lat,
217
+            longitude: route[i].lon,
218
+          },
219
+          {
220
+            latitude: route[i - 1].lat,
221
+            longitude: route[i - 1].lon,
222
+          },
223
+        ) **
224
+          2 +
225
+          (route[i].ele / 1000 - route[i - 1].ele / 1000) ** 2) **
226
+        0.5;
227
+    }
228
+    output.push({
229
+      x: lon2tileExact(route[i].lon, zoom),
230
+      y: lat2tileExact(route[i].lat, zoom),
231
+      zoom,
232
+      distance,
233
+    });
234
+  }
235
+  return output;
236
+}

+ 27
- 51
src/run.ts View File

@@ -8,61 +8,37 @@
8 8
 // otherwise download a map (default)
9 9
 // e.g. --zoom=15 --north= --south= --east= --west= --output=map --sx=4 --sy=5
10 10
 //
11
-
12
-import minimist from 'minimist';
11
+import getConfig from './config';
13 12
 import map from './map';
14 13
 import route from './route';
15
-import path from './path';
16 14
 import help from './help';
15
+import listTileServers from './tile-servers';
17 16
 
18 17
 (async () => {
19
-  const argv = minimist(process.argv.slice(2));
20
-
21
-  if (argv.h || argv.help) {
22
-    help();
23
-    return;
24
-  }
25
-
26
-  const zoom = argv.zoom ?? 12;
27
-
28
-  if (zoom >= 17) {
29
-    const SORRY =
30
-      'Sorry, OSM Tile Usage Policy (https://operations.osmfoundation.org/policies/tiles/) forbids downloading tiles with zoom 17 and higher.';
31
-    console.log(SORRY); // tslint:disable-line:no-console
32
-    return;
33
-  }
34
-
35
-  const sx = argv.sx ?? 4;
36
-  const sy = argv.sy ?? 5;
37
-  const output = argv.output;
38
-
39
-  if (argv.path) {
40
-    const input = argv.input;
41
-
42
-    const distanceStep = argv.distance ? argv['distance-step'] ?? 10 : -1;
43
-
44
-    if (!input) return help();
45
-
46
-    await path({ zoom, input }, { sx, sy }, distanceStep, output || 'path');
47
-  } else if (argv.route) {
48
-    const input = argv.input;
49
-
50
-    if (!input) return help();
51
-
52
-    await route({ zoom, input }, { sx, sy }, output || 'route');
53
-  } else {
54
-    const north = argv.n ?? argv.north;
55
-    const west = argv.w ?? argv.west;
56
-    const south = argv.s ?? argv.south;
57
-    const east = argv.e ?? argv.east;
58
-
59
-    if (typeof north !== 'number' || typeof south !== 'number' || typeof east !== 'number' || typeof west !== 'number')
60
-      return help();
61
-
62
-    await map(
63
-      { zoom, north, west, south, east },
64
-      { sx, sy },
65
-      output || `map_${north}_${west}_${south}_${east}_${zoom}`,
66
-    );
18
+  try {
19
+    const config = getConfig();
20
+
21
+    switch (config.mode) {
22
+      case 'tiles': {
23
+        return listTileServers();
24
+      }
25
+      case 'map': {
26
+        const { zoom, output, pageSizeX, pageSizeY, north, west, south, east, tileServer } = config;
27
+        return map({ zoom, output, pageSizeX, pageSizeY, north, west, south, east, tileServer });
28
+      }
29
+      case 'route': {
30
+        const { zoom, input, output, pageSizeX, pageSizeY, draw, distance, distanceStep, tileServer } = config;
31
+        return route({ zoom, input, output, pageSizeX, pageSizeY, draw, distance, distanceStep, tileServer });
32
+      }
33
+      default:
34
+        return help();
35
+    }
36
+  } catch (e) {
37
+    // tslint:disable-next-line:no-console
38
+    console.log(JSON.stringify(e));
39
+    // tslint:disable-next-line:no-console
40
+    console.log(JSON.stringify(e));
41
+    // tslint:disable-next-line:no-console
42
+    console.error(e);
67 43
   }
68 44
 })();

+ 19
- 0
src/tile-servers.ts View File

@@ -0,0 +1,19 @@
1
+import { TileServer } from './types';
2
+
3
+// const tileServer = 'http://a.tile.stamen.com/toner';
4
+// const tileServer = 'https://a.tile.openstreetmap.de'
5
+// const tileServer = 'https://tile.openstreetmap.org';
6
+// const tileServer = 'https://tiles.wmflabs.org/bw-mapnik/'
7
+// const tileServer = 'https://c.tile.openstreetmap.fr/osmfr'
8
+//
9
+
10
+export const servers: TileServer[] = [
11
+  {
12
+    url: ({ z, x, y }) => `https://a.tile.opentopomap.org/${z}/${x}/${y}.png`,
13
+    rateLimit: 1,
14
+  },
15
+];
16
+
17
+export default function listTileServers() {
18
+  return 'tile servers listing TODO';
19
+}

+ 19
- 0
src/types.ts View File

@@ -0,0 +1,19 @@
1
+type GetUrl = (coordinates: { z: number; x: number; y: number }) => string;
2
+
3
+export type TileServer = {
4
+  url: GetUrl;
5
+  rateLimit: number;
6
+};
7
+
8
+export interface Tile {
9
+  x: number;
10
+  y: number;
11
+  zoom: number;
12
+}
13
+
14
+export interface PageSize {
15
+  sx: number;
16
+  sy: number;
17
+}
18
+
19
+export interface Page extends Tile, PageSize {}

+ 8
- 0
src/utils.ts View File

@@ -1,3 +1,11 @@
1
+import fs from 'fs-extra';
2
+
1 3
 export function pad(num: number, decimals = 5): string {
2 4
   return ('0'.repeat(decimals) + num).slice(-decimals);
3 5
 }
6
+
7
+export async function clearTmp(tmp: string) {
8
+  await fs.remove(tmp);
9
+}
10
+
11
+export const getFilename = (tmp: string, i: number) => `${tmp}/${pad(i)}.png`;

+ 5
- 0
yarn.lock View File

@@ -643,6 +643,11 @@ aws4@^1.8.0:
643 643
   resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.9.1.tgz#7e33d8f7d449b3f673cd72deb9abdc552dbe528e"
644 644
   integrity sha512-wMHVg2EOHaMRxbzgFJ9gtjOOCrI80OHLG14rxi28XwOW8ux6IiEbRCGGGqCtdAIg4FQCbW20k9RsT4y3gJlFug==
645 645
 
646
+axios-rate-limit@^1.3.0:
647
+  version "1.3.0"
648
+  resolved "https://registry.yarnpkg.com/axios-rate-limit/-/axios-rate-limit-1.3.0.tgz#03241d24c231c47432dab6e8234cfde819253c2e"
649
+  integrity sha512-cKR5wTbU/CeeyF1xVl5hl6FlYsmzDVqxlN4rGtfO5x7J83UxKDckudsW0yW21/ZJRcO0Qrfm3fUFbhEbWTLayw==
650
+
646 651
 axios@*, axios@^0.19.2:
647 652
   version "0.19.2"
648 653
   resolved "https://registry.yarnpkg.com/axios/-/axios-0.19.2.tgz#3ea36c5d8818d0d5f8a8a97a6d36b86cdc00cb27"