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 1 year 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"