4 Commits

Author SHA1 Message Date
  mrkvon acdaa0cbc2 Add links to surrounding pages in --map option 2 years ago
  mrkvon fd03ac292d Enable --tmp option to specify temporary folder for downloaded tiles 2 years ago
  mrkvon 21ea64175d Continue refactor 2 years ago
  mrkvon 604ff031f7 big refactor, first working version 2 years ago
16 changed files with 1531 additions and 506 deletions
  1. 3
    8
      README.md
  2. 5
    1
      package.json
  3. 128
    0
      src/config.ts
  4. 72
    0
      src/download.ts
  5. 64
    25
      src/help.ts
  6. 5
    0
      src/http.ts
  7. 197
    22
      src/map.ts
  8. 35
    0
      src/parse-route.ts
  9. 0
    232
      src/path.ts
  10. 42
    81
      src/pdf.ts
  11. 222
    70
      src/route.ts
  12. 36
    51
      src/run.ts
  13. 96
    0
      src/tile-servers.ts
  14. 19
    0
      src/types.ts
  15. 21
    0
      src/utils.ts
  16. 586
    16
      yarn.lock

+ 3
- 8
README.md View File

@@ -2,7 +2,7 @@
2 2
 
3 3
 Generate pdf with [OpenStreetMap](https://openstreetmap.org) tiles.
4 4
 
5
-![example output of osm2pdf with --path parameter](https://git.mrkvon.org/mrkvon/osm2pdf/raw/branch/master/example.png)
5
+![example output of osm2pdf with --route parameter](https://git.mrkvon.org/mrkvon/osm2pdf/raw/branch/master/example.png)
6 6
 
7 7
 ## Video
8 8
 
@@ -24,20 +24,15 @@ sudo npm install -g osm2pdf
24 24
 You need to provide a route in gpx format. You can download it on [graphhopper website](https://graphhopper.com/maps/). Find your route and click _GPX export_ button.
25 25
 
26 26
 ```bash
27
-osm2pdf --route --zoom=10 --input=path/to/route.gpx --output=path/to/output
27
+osm2pdf --route --zoom=10 --input=path/to/route.gpx --output=path/to/output --tile-server=1
28 28
 ```
29 29
 
30
-or if you want to also draw the path on the map
31
-
32
-```bash
33
-osm2pdf --path --zoom=10 --input=path/to/route.gpx --output=path/to/output --distance --distance-step=20
34
-```
35 30
 ### Download a map
36 31
 
37 32
 You need to provide boundaries of the area you want to download.
38 33
 
39 34
 ```bash
40
-osm2pdf --north=70.923 --west=-4.373 --south=55.756 --east=27.872 --zoom=9 --output=path/to/output
35
+osm2pdf -n=70.923 -w=-4.373 -s=55.756 -e=27.872 --zoom=9 --output=path/to/output --tile-server=2
41 36
 ```
42 37
 
43 38
 ## Help

+ 5
- 1
package.json View File

@@ -56,11 +56,15 @@
56 56
     "typescript": "^3.7.5"
57 57
   },
58 58
   "dependencies": {
59
+    "@types/pdfkit": "^0.11.2",
59 60
     "axios": "^0.19.2",
61
+    "axios-rate-limit": "^1.3.0",
62
+    "chalk": "^4.1.2",
60 63
     "fs-extra": "^8.1.0",
61 64
     "gm": "^1.23.1",
62 65
     "haversine": "^1.1.1",
63 66
     "merge-img": "^2.1.3",
64
-    "minimist": "^1.2.0"
67
+    "minimist": "^1.2.0",
68
+    "pdfkit": "^0.12.3"
65 69
   }
66 70
 }

+ 128
- 0
src/config.ts View File

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

+ 72
- 0
src/download.ts View File

@@ -0,0 +1,72 @@
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, fileExists } 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 filename = getFilename(tmp, i);
14
+    if (!(await fileExists(filename))) {
15
+      const page = pages[i];
16
+      const info = `downloading page ${i + 1}/${pages.length}`;
17
+      const pageTiles = await getPage(page, { http, tileServer }, info);
18
+      await savePage(pageTiles, filename);
19
+    }
20
+  }
21
+}
22
+
23
+// download a single tile
24
+async function getTile(
25
+  { x, y, zoom: z }: Tile,
26
+  { http, tileServer }: { http: any; tileServer: TileServer },
27
+): Promise<Buffer> {
28
+  return Buffer.from(
29
+    (
30
+      await http.get(tileServer.url({ z, x, y }), {
31
+        responseType: 'arraybuffer',
32
+        headers: {
33
+          'User-Agent': 'osm2pdf',
34
+        },
35
+      })
36
+    ).data,
37
+    'binary',
38
+  );
39
+}
40
+
41
+async function getPage(
42
+  { x, y, sx, sy, zoom }: Page,
43
+  { http, tileServer }: { http: any; tileServer: TileServer },
44
+  info: string,
45
+): Promise<Buffer[][]> {
46
+  let progress = 0;
47
+  log(info, `[${'.'.repeat(progress)}${' '.repeat(sx * sy - progress)}]`);
48
+  const rows: Promise<Buffer[]>[] = [];
49
+  for (let i = 0; i < sx; i++) {
50
+    const row: Promise<Buffer>[] = [];
51
+    for (let j = 0; j < sy; j++) {
52
+      row.push(
53
+        getTile({ x: x + i, y: y + j, zoom }, { http, tileServer }).then(tile => {
54
+          log(info, `[${'.'.repeat(++progress)}${' '.repeat(sx * sy - progress)}]`);
55
+          return tile;
56
+        }),
57
+      );
58
+    }
59
+    rows.push(Promise.all(row));
60
+  }
61
+  return await Promise.all(rows);
62
+}
63
+
64
+async function mergeTiles(tiles: Buffer[][]): Promise<any> {
65
+  const row = await Promise.all(tiles.map(column => mergeImg(column, { direction: true })));
66
+  return await mergeImg(row);
67
+}
68
+
69
+async function savePage(pageTiles: Buffer[][], output: string): Promise<void> {
70
+  const image = await mergeTiles(pageTiles);
71
+  await new Promise(resolve => image.write(output, resolve));
72
+}

+ 64
- 25
src/help.ts View File

@@ -4,38 +4,77 @@ Export OpenStreetMap to pdf
4 4
 Usage:
5 5
 osm2pdf [options]
6 6
 
7
+=== Generic options (for Route and Area methods) ===
8
+
9
+--zoom          map zoom (default 12)
10
+--tile-server   url or number of the tile server (see also \`osm2pdf --list-tile-servers\`)
11
+                  find more tile servers at https://wiki.openstreetmap.org/wiki/Tile_servers
12
+                  please respect tile usage policies. download responsibly.
13
+--rate-limit    how many tiles per second can be downloaded (default 10)
14
+--output        name of the generated pdf file (".pdf" will be attached, existing files will be overwritten)
15
+-x              tiles per page horizontally (default 4)
16
+-y              tiles per page vertically (default 5)
17
+--tmp           temporary folder to save downloaded tiles to (default tmp[timestamp])
18
+                  if the folder doesn't exist, it will be created
19
+                  you can resume failed download with this option
20
+                  use carefully! folder will be deleted after successful download
21
+
22
+
23
+=== Methods ===
24
+
25
+=== Route ===
26
+
27
+Download route, given route coordinates in GPX format
28
+
7 29
 Options:
8
--h, --help                print this page
9
---route                   (optional) download the route
10
-                          you can download the GPX route file from https://graphhopper.com/maps/
11
-                          find the desired route and click "GPX export" (gpx button)
12
---path                    (optional) download the route and draw the path on it
13
-                          similar to --route option
14
---distance                (optional with --path option) write labels with distance in kilometres to path points
15
---distance-step           (optional with --distance option) distance between distance labels (kilometres); defaults to 10
16
---input <path/to/gpx>     (with --route or --path option) path to GPX route file
17
--n <latitude>   north
18
--w <longitude>  west
19
--s <latitude>   south
20
--e <longitude>  east      latitude or longitude of the desired map boundary (only when --route is not specified)
21
-                          downloads a map within a defined square
22
---zoom <level>            (optional) map zoom (number); defaults to 12; must be < 17
23
---sx <integer>            (optional) amount of tiles per page horizontally; defaults to 4
24
---sy <integer>            (optional) amount of tiles per page vertically; defaults to 5
25
---output <path/to/output> (optional) the desired name of the exported pdf file
30
+--route         execute Route method (required)
31
+--input         path to GPX route file (required)
32
+                  you can download the GPX route file e.g. from https://graphhopper.com/maps/
33
+                  find the desired route and click "GPX export" (gpx button)
34
+--no-path       don't draw path (optional)
35
+--no-distance   don't write labels with kilometers at the path (optional)
36
+--distance-step distance between distance labels (default 10 [kilometers])
26 37
 
27 38
 Examples:
28
-1. Provide map boundaries
39
+osm2pdf --route --input=path/to/some-route.gpx --output some-route-12 --zoom=12 --tile-server=2
40
+osm2pdf --route --input=path/to/other-route.gpx --output other-route-13 --zoom=13 --distance-step=5 --tile-server="http://{a|b}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png" --rate-limit=2
41
+
42
+
43
+=== Area ===
44
+
45
+Download rectangular area, given GPS boundaries
46
+
47
+Options:
48
+--map           execute Area method (required)
49
+-n              latitude of the north boundary (required)
50
+-s              latitude of the south boundary (required)
51
+-e              longitude of the east boundary (required)
52
+-w              longitude of the west boundary (required)
53
+
54
+Example:
55
+osm2pdf --map -n=50.1 -s=50 -w=14.9 -e=15 --output=some-name-15 --tile-server=3 --zoom=15
56
+
57
+
58
+=== List Tile Servers ===
59
+
60
+Print a list of some recommended tile servers to choose from
61
+
62
+Options:
63
+--list-tile-servers   (required)
64
+
65
+Example:
66
+osm2pdf --list-tile-servers
29 67
 
30
-  osm2pdf --zoom=10 -n=15.1 -s=14.9 -e=13.9 -w=13.7
31 68
 
32
-2. Provide a route in GPX format (can be exported at https://graphhopper.com/maps/)
33
-  
34
-  osm2pdf --path --zoom=15 --input=path/to/some_route.gpx --output=my-route --distance --distance-step=5
69
+=== Help ===
35 70
 
36
-  OR (route without highlighted path)
71
+Print help
72
+
73
+Options:
74
+-h, --help                print this page (required)
37 75
 
38
-  osm2pdf --route --zoom=15 --input=path/to/some_route.gpx --output=my-route
76
+Example:
77
+osm2pdf --help
39 78
 `;
40 79
 
41 80
 export default function help(): void {

+ 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 });

+ 197
- 22
src/map.ts View File

@@ -1,41 +1,216 @@
1
-import { lat2tile, lon2tile, getTileSize } from './osm';
2
-import { pages2pdf, Page, PageSize } from './pdf';
1
+import { lat2tile, lon2tile, getTileSize, lat2tileExact, lon2tileExact } from './osm';
2
+import { TileServer, Page } from './types';
3
+import { createPdf, PDFOptions } from './pdf';
4
+import { downloadPages } from './download';
5
+import { clearTmp, getFilename } from './utils';
6
+import { TILE_SIZE } from './route';
7
+import { MapConfig } from './config';
8
+import gm from 'gm';
9
+import log from './log';
3 10
 
4
-interface Boundaries {
11
+// main executable function
12
+export default async function map({
13
+  zoom,
14
+  output,
15
+  pageSizeX,
16
+  pageSizeY,
17
+  north,
18
+  west,
19
+  south,
20
+  east,
21
+  tileServer,
22
+  tmp,
23
+}: {
24
+  zoom: number;
25
+  output: string;
26
+  pageSizeX: number;
27
+  pageSizeY: number;
5 28
   north: number;
29
+  west: number;
6 30
   south: number;
7 31
   east: number;
8
-  west: number;
9
-  zoom: number;
32
+  tileServer: TileServer;
33
+  tmp: string;
34
+}) {
35
+  // collect pages
36
+  const pages: Page[] = boundaries2pages({ north, west, south, east, pageSizeX, pageSizeY, zoom });
37
+  // download pages
38
+  await downloadPages(pages, tmp, tileServer);
39
+  // draw boundary
40
+  await drawBoundary(pages, { north, west, south, east, zoom, tmp, pageSizeX, pageSizeY });
41
+  // create pdf
42
+  await createPdf(output, tmp, {
43
+    pageSizeX,
44
+    pageSizeY,
45
+    links: boundaries2links({ north, west, south, east, pageSizeX, pageSizeY, zoom }),
46
+  });
47
+  // clean up downloaded pages
48
+  await clearTmp(tmp);
10 49
 }
11 50
 
12
-// main executable function
13
-export default async function map(boundaries: Boundaries, pageSize: PageSize, output: string) {
14
-  const pages: Page[] = boundaries2pages(boundaries, pageSize);
51
+const drawBoundary = async (
52
+  pages: Page[],
53
+  {
54
+    north,
55
+    west,
56
+    south,
57
+    east,
58
+    zoom,
59
+    tmp,
60
+    pageSizeX,
61
+    pageSizeY,
62
+  }: Pick<MapConfig, 'north' | 'west' | 'south' | 'east' | 'zoom' | 'tmp' | 'pageSizeX' | 'pageSizeY'>,
63
+): Promise<void> => {
64
+  const tileBoundary = {
65
+    north: lat2tileExact(north, zoom),
66
+    south: lat2tileExact(south, zoom),
67
+    east: lon2tileExact(east, zoom),
68
+    west: lon2tileExact(west, zoom),
69
+  };
15 70
 
16
-  await pages2pdf(pages, output);
17
-}
71
+  for (let i = 0, len = pages.length; i < len; ++i) {
72
+    log(`drawing boundary ${i + 1}/${len}`);
73
+    const page = pages[i];
74
+    const filename = getFilename(tmp, i);
75
+    const control = gm(filename);
18 76
 
19
-function boundaries2pages({ north, west, south, east, zoom }: Boundaries, { sx, sy }: PageSize) {
20
-  const x = lon2tile(west, zoom);
21
-  const y = lat2tile(north, zoom);
77
+    const pixelBoundary = {
78
+      north: (tileBoundary.north - page.y) * TILE_SIZE,
79
+      south: (tileBoundary.south - page.y) * TILE_SIZE,
80
+      east: (tileBoundary.east - page.x) * TILE_SIZE,
81
+      west: (tileBoundary.west - page.x) * TILE_SIZE,
82
+    };
22 83
 
84
+    // draw boundary
85
+    const maxWidth = Math.max(pageSizeX, pageSizeY) * TILE_SIZE;
86
+    control.stroke('#000a', maxWidth).fill('#000f');
87
+    control.drawRectangle(
88
+      pixelBoundary.west - maxWidth / 2,
89
+      pixelBoundary.north - maxWidth / 2,
90
+      pixelBoundary.east + maxWidth / 2,
91
+      pixelBoundary.south + maxWidth / 2,
92
+    );
93
+
94
+    await new Promise<void>((resolve, reject) => {
95
+      // draw it
96
+      control.write(filename, err => {
97
+        if (err) return reject(err);
98
+        else return resolve();
99
+      });
100
+    });
101
+  }
102
+};
103
+
104
+const boundaries2links = ({
105
+  north,
106
+  west,
107
+  south,
108
+  east,
109
+  pageSizeX,
110
+  pageSizeY,
111
+  zoom,
112
+}: {
113
+  north: number;
114
+  west: number;
115
+  south: number;
116
+  east: number;
117
+  pageSizeX: number;
118
+  pageSizeY: number;
119
+  zoom: number;
120
+}): PDFOptions['links'] => {
121
+  const { width, height } = boundaries2size({ north, west, south, east, pageSizeX, pageSizeY, zoom });
122
+
123
+  const links: PDFOptions['links'] = [];
124
+  const pageSize = { width: pageSizeX * TILE_SIZE, height: pageSizeY * TILE_SIZE };
125
+  const breakPoints = {
126
+    x: [0, 0.25 * pageSize.width, 0.75 * pageSize.width, pageSize.width],
127
+    y: [0, 0.25 * pageSize.height, 0.75 * pageSize.height, pageSize.height],
128
+  };
129
+  for (let i = 0, len = width * height; i < len; ++i) {
130
+    const linksForPage: PDFOptions['links'][number] = [];
131
+    for (const yi of [-1, 0, 1]) {
132
+      for (const xi of [-1, 0, 1]) {
133
+        const url = i + yi * width + xi;
134
+        const x = breakPoints.x[xi + 1];
135
+        const y = breakPoints.y[yi + 1];
136
+        const w = breakPoints.x[xi + 2] - breakPoints.x[xi + 1];
137
+        const h = breakPoints.y[yi + 2] - breakPoints.y[yi + 1];
138
+        if (
139
+          url >= 0 &&
140
+          url < len &&
141
+          // we don't want to link to page itself
142
+          url !== i &&
143
+          // we don't want to go beyond left and right edges
144
+          url % width === (i % width) + xi &&
145
+          // we don't want to go beyond top and bottom edges
146
+          Math.floor(url / width) % height === (Math.floor(i / width) % height) + yi
147
+        ) {
148
+          linksForPage.push({ x, y, width: w, height: h, url });
149
+        }
150
+      }
151
+    }
152
+    links.push(linksForPage);
153
+  }
154
+  return links;
155
+};
156
+
157
+const boundaries2size = ({
158
+  north,
159
+  west,
160
+  south,
161
+  east,
162
+  pageSizeX,
163
+  pageSizeY,
164
+  zoom,
165
+}: {
166
+  north: number;
167
+  west: number;
168
+  south: number;
169
+  east: number;
170
+  pageSizeX: number;
171
+  pageSizeY: number;
172
+  zoom: number;
173
+}): { width: number; height: number } => {
23 174
   const { width, height } = getTileSize({ north, west, south, east, zoom });
175
+  return {
176
+    width: Math.ceil(width / pageSizeX),
177
+    height: Math.ceil(height / pageSizeY),
178
+  };
179
+};
24 180
 
25
-  const pagesX = Math.ceil(width / sx);
26
-  const pagesY = Math.ceil(height / sy);
181
+function boundaries2pages({
182
+  north,
183
+  west,
184
+  south,
185
+  east,
186
+  pageSizeX,
187
+  pageSizeY,
188
+  zoom,
189
+}: {
190
+  north: number;
191
+  west: number;
192
+  south: number;
193
+  east: number;
194
+  pageSizeX: number;
195
+  pageSizeY: number;
196
+  zoom: number;
197
+}) {
198
+  const { width, height } = boundaries2size({ north, west, south, east, zoom, pageSizeX, pageSizeY });
27 199
 
28
-  console.log('size', pagesX, 'x', pagesY, 'pages'); // tslint:disable-line:no-console
200
+  console.log('size', width, 'x', height, 'pages'); // tslint:disable-line:no-console
29 201
 
30 202
   const pages: Page[] = [];
31 203
 
32
-  for (let py = 0; py < pagesY; py++) {
33
-    for (let px = 0; px < pagesX; px++) {
204
+  const x = lon2tile(west, zoom);
205
+  const y = lat2tile(north, zoom);
206
+
207
+  for (let py = 0; py < height; py++) {
208
+    for (let px = 0; px < width; px++) {
34 209
       pages.push({
35
-        x: x + px * sx,
36
-        y: y + py * sy,
37
-        sx,
38
-        sy,
210
+        x: x + px * pageSizeX,
211
+        y: y + py * pageSizeY,
212
+        sx: pageSizeX,
213
+        sy: pageSizeY,
39 214
         zoom,
40 215
       });
41 216
     }

+ 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
-}

+ 42
- 81
src/pdf.ts View File

@@ -1,86 +1,47 @@
1
-import axios from 'axios';
2
-import mergeImg from 'merge-img';
3
-import gm from 'gm';
4
-import fs from 'fs-extra';
5
-import { pad } from './utils';
1
+import PDFDocument from 'pdfkit';
6 2
 import log from './log';
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 {}
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 }));
3
+import fs from 'fs-extra';
4
+import path from 'path';
5
+
6
+export interface PDFOptions {
7
+  pageSizeX: number;
8
+  pageSizeY: number;
9
+  links: {
10
+    x: number;
11
+    y: number;
12
+    width: number;
13
+    height: number;
14
+    url: string | number;
15
+  }[][];
16
+}
17
+
18
+export async function createPdf(output: string, tmp: string, { pageSizeX, pageSizeY, links }: PDFOptions) {
19
+  log('Creating pdf');
20
+  const files = await fs.readdir(tmp);
21
+  await new Promise<void>((resolve, reject) => {
22
+    const doc = new PDFDocument({ margin: 0, size: [256 * pageSizeX, 256 * pageSizeY], bufferPages: true });
23
+    const stream = doc.pipe(fs.createWriteStream(`${output}.pdf`));
24
+    let first = true;
25
+    for (const file of files) {
26
+      if (!first) {
27
+        doc.addPage();
28
+      } else {
29
+        first = !first;
30
+      }
31
+      doc.image(path.join(tmp, file));
38 32
     }
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
-}
33
+    for (let i = 0, len = links.length; i < len; ++i) {
34
+      doc.switchToPage(i);
35
+      for (const link of links[i]) {
36
+        const { x, y, width, height, url } = link;
37
+        doc.link(x, y, width, height, url as string);
38
+      }
39
+    }
40
+    doc.end();
54 41
 
55
-export async function createPdf(name: string, tmp: string) {
56
-  await new Promise((resolve, reject) => {
57
-    gm(`${tmp}/*.png`).write(`${name}.pdf`, err => {
58
-      if (!err) return resolve();
59
-      if (err) return reject(err);
60
-    });
42
+    stream.on('finish', resolve);
43
+    stream.on('error', reject);
61 44
   });
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
45
+  // eslint-disable-next-line: no-console
46
+  log(`Your map was saved to ${output}.pdf\n`);
86 47
 }

+ 222
- 70
src/route.ts View File

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

+ 36
- 51
src/run.ts View File

@@ -8,61 +8,46 @@
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';
16
+import chalk from 'chalk';
17 17
 
18 18
 (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
-    );
19
+  let config;
20
+  try {
21
+    config = getConfig();
22
+
23
+    switch (config.mode) {
24
+      case 'tiles': {
25
+        listTileServers();
26
+        return;
27
+      }
28
+      case 'map': {
29
+        await map(config);
30
+        return;
31
+      }
32
+      case 'route': {
33
+        await route(config);
34
+        return;
35
+      }
36
+      default:
37
+        help();
38
+        return;
39
+    }
40
+  } catch (e) {
41
+    // tslint:disable-next-line:no-console
42
+    console.log(JSON.stringify(e));
43
+    // tslint:disable-next-line:no-console
44
+    console.error(e);
45
+    if (config && 'tmp' in config) {
46
+      console.log(
47
+        chalk.red(
48
+          `\n\n************************\n\nError: ${e.message}\n\nYour download failed. You can resume it by adding "--tmp ${config.tmp}" to your command.\n\n************************\n`,
49
+        ),
50
+      );
51
+    }
67 52
   }
68 53
 })();

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

@@ -0,0 +1,96 @@
1
+import { TileServer } from './types';
2
+import { random } from './utils';
3
+import log from './log';
4
+
5
+export const parseUrl = (a: string) => {
6
+  const r = /^(?<base>.+?)\$?{(?<v1>[xyz]{1})}(?<s1>.+?)\$?{(?<v2>[xyz]{1})}(?<s2>.+?)\$?{(?<v3>[xyz]{1})}(?<end>.*)$/gm;
7
+  const {
8
+    groups: { base, v1, s1, v2, s2, v3, end },
9
+  } = r.exec(a) as { groups: { [name: string]: string } };
10
+  const r2 = /(?<base1>.*)({(?<servers>[a-z0-9|]+)})(?<base2>.*)/gm;
11
+  const {
12
+    groups: { base1, servers, base2 },
13
+  } = (r2.exec(base) ?? { groups: { base1: base, servers: '', base2: '' } }) as { groups: { [name: string]: string } };
14
+
15
+  return ({ x, y, z }: { x: number; y: number; z: number }) =>
16
+    `${base1}${random(servers.split('|'))}${base2}${v1 === 'x' ? x : v1 === 'y' ? y : z}${s1}${
17
+      v2 === 'x' ? x : v2 === 'y' ? y : z
18
+    }${s2}${v3 === 'x' ? x : v3 === 'y' ? y : z}${end}`;
19
+};
20
+
21
+export const rawServers = [
22
+  {
23
+    name: 'German fork of the Standard tile layer: openstreetmap.de',
24
+    url: 'https://{a|b|c}.tile.openstreetmap.de/{z}/{x}/{y}.png',
25
+    rateLimit: 5,
26
+  },
27
+  {
28
+    name: 'OSM France',
29
+    url: 'https://{a|b|c}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png',
30
+    rateLimit: 1,
31
+  },
32
+  {
33
+    name: 'Humanitarian map style',
34
+    url: 'http://{a|b}.tile.openstreetmap.fr/hot/${z}/${x}/${y}.png',
35
+    rateLimit: 1,
36
+  },
37
+  {
38
+    name: 'OpenTopoMap',
39
+    url: 'https://{a|b|c}.tile.opentopomap.org/{z}/{x}/{y}.png',
40
+    rateLimit: 2,
41
+  },
42
+  {
43
+    name: 'Stamen Toner Black & White map',
44
+    url: 'http://a.tile.stamen.com/toner/{z}/{x}/{y}.png',
45
+    rateLimit: 5,
46
+  },
47
+  {
48
+    name: 'mapnik map grayscale',
49
+    url: 'https://tiles.wmflabs.org/bw-mapnik/{z}/{x}/{y}.png',
50
+    rateLimit: 5,
51
+  },
52
+  {
53
+    name: 'mapnik map without labels',
54
+    url: 'https://tiles.wmflabs.org/osm-no-labels/${z}/${x}/${y}.png',
55
+    rateLimit: 5,
56
+  },
57
+  {
58
+    name: 'wmflabs Hike & Bike - Hiking map',
59
+    url: 'https://tiles.wmflabs.org/hikebike/${z}/${x}/${y}.png',
60
+    rateLimit: 1,
61
+  },
62
+  {
63
+    name: 'Mapy.cz Base',
64
+    url: 'https://m{1|2|3|4}.mapserver.mapy.cz/base-m/{z}-{x}-{y}',
65
+    rateLimit: 10,
66
+  },
67
+  {
68
+    name: 'Mapy.cz Turistic',
69
+    url: 'https://m{1|2|3|4}.mapserver.mapy.cz/turist-m/{z}-{x}-{y}',
70
+    rateLimit: 10,
71
+  },
72
+  {
73
+    name: 'CyclOSM: OpenStreetMap-based bicycle map',
74
+    url: 'https://{a|b|c}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png',
75
+    rateLimit: 1,
76
+  },
77
+];
78
+
79
+export const servers: TileServer[] = rawServers.map(({ url, rateLimit }) => ({
80
+  url: parseUrl(url),
81
+  rateLimit,
82
+}));
83
+
84
+export function listTileServers() {
85
+  const output: string[] = [];
86
+
87
+  output.push('');
88
+  rawServers.forEach(({ name, url }, index) => {
89
+    output.push(`${index + 1}. ${name}`);
90
+    output.push(url);
91
+    output.push('');
92
+  });
93
+  output.push('');
94
+
95
+  return log(output.join('\n'));
96
+}

+ 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 {}

+ 21
- 0
src/utils.ts View File

@@ -1,3 +1,24 @@
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`;
12
+
13
+export function random<T>(input: Array<T>): T {
14
+  return input[Math.floor(Math.random() * input.length)];
15
+}
16
+
17
+export const fileExists = async (file: string): Promise<boolean> => {
18
+  try {
19
+    await fs.access(file, fs.constants.F_OK);
20
+    return true;
21
+  } catch {
22
+    return false;
23
+  }
24
+};

+ 586
- 16
yarn.lock
File diff suppressed because it is too large
View File