chiitiler 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +3 -0
- package/Dockerfile +14 -0
- package/README.md +27 -0
- package/dist/cache/file.js +54 -0
- package/dist/cache/index.js +24 -0
- package/dist/cache/memory.js +12 -0
- package/dist/cache/s3.js +10 -0
- package/dist/cache.js +80 -0
- package/dist/server.js +139 -0
- package/dist/tiling.js +92 -0
- package/package.json +35 -0
- package/src/cache/file.ts +71 -0
- package/src/cache/index.ts +42 -0
- package/src/cache/memory.ts +15 -0
- package/src/cache/s3.ts +17 -0
- package/src/server.ts +156 -0
- package/src/tiling.ts +123 -0
- package/tsconfig.json +11 -0
package/.dockerignore
ADDED
package/Dockerfile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
FROM maptiler/tileserver-gl
|
|
2
|
+
|
|
3
|
+
USER root
|
|
4
|
+
WORKDIR /app/
|
|
5
|
+
COPY package*.json /app/
|
|
6
|
+
RUN npm install
|
|
7
|
+
COPY . .
|
|
8
|
+
|
|
9
|
+
# Lambda WebAdapter
|
|
10
|
+
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.7.1 /lambda-adapter /opt/extensions/lambda-adapter
|
|
11
|
+
ENV PORT=3000
|
|
12
|
+
ENV READINESS_CHECK_PATH=/health
|
|
13
|
+
|
|
14
|
+
CMD ["npm", "start"]
|
package/README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# chiitiler - Tiny VectorTile rendering server
|
|
2
|
+
|
|
3
|
+
chii-tiler
|
|
4
|
+
|
|
5
|
+
"tiny" in Japanese is "chiisai", shorten this into "chii"
|
|
6
|
+
|
|
7
|
+
## motivation
|
|
8
|
+
|
|
9
|
+
- In this type of server, there is a de-facto - [maptiler/tileserver-gl](https://github.com/maptiler/tileserver-gl), but this is too big for me.
|
|
10
|
+
- I want a server accept style.json-url and respond raster tile, inspired by [developmentseed/titiler](https://github.com/developmentseed/titiler)
|
|
11
|
+
|
|
12
|
+
## status
|
|
13
|
+
|
|
14
|
+
- this project is under development and experiment
|
|
15
|
+
|
|
16
|
+
## usage
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npm install
|
|
20
|
+
npm start
|
|
21
|
+
|
|
22
|
+
# then server will start
|
|
23
|
+
# http://localhost:3000/debug
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
- You can pass style.json url:
|
|
27
|
+
- http://localhost:3000/debug?url=https://tile.openstreetmap.jp/styles/osm-bright/style.json
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
function escapeFileName(url) {
|
|
4
|
+
return url
|
|
5
|
+
.replace(/https?:\/\//, '') // remove protocol
|
|
6
|
+
.replace(/\//g, '_') // replace slashes with underscores
|
|
7
|
+
.replace(/\?/g, '-') // replace question marks with dashes
|
|
8
|
+
.replace(/&/g, '-') // replace ampersands with dashes
|
|
9
|
+
.replace(/=/g, '-') // replace equals signs with dashes
|
|
10
|
+
.replace(/%/g, '-') // replace percent signs with dashes
|
|
11
|
+
.replace(/#/g, '-') // replace hash signs with dashes
|
|
12
|
+
.replace(/:/g, '-') // replace colons with dashes
|
|
13
|
+
.replace(/\+/g, '-') // replace plus signs with dashes
|
|
14
|
+
.replace(/ /g, '-') // replace spaces with dashes
|
|
15
|
+
.replace(/</g, '-') // replace less than signs with dashes
|
|
16
|
+
.replace(/>/g, '-') // replace greater than signs with dashes
|
|
17
|
+
.replace(/\*/g, '-') // replace asterisks with dashes
|
|
18
|
+
.replace(/\|/g, '-') // replace vertical bars with dashes
|
|
19
|
+
.replace(/"/g, '-') // replace double quotes with dashes
|
|
20
|
+
.replace(/'/g, '-') // replace single quotes with dashes
|
|
21
|
+
.replace(/\?/g, '-') // replace question marks with dashes
|
|
22
|
+
.replace(/\./g, '-') // replace dots with dashes
|
|
23
|
+
.replace(/,/g, '-') // replace commas with dashes
|
|
24
|
+
.replace(/;/g, '-') // replace semicolons with dashes
|
|
25
|
+
.replace(/\\/g, '-'); // replace backslashes with dashes
|
|
26
|
+
}
|
|
27
|
+
const fileCache = function (options) {
|
|
28
|
+
return {
|
|
29
|
+
set: async function (key, value) {
|
|
30
|
+
// write file
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
fs.writeFile(path.join(options.dir, escapeFileName(`${key}`)), value, (err) => {
|
|
33
|
+
if (err)
|
|
34
|
+
reject(err);
|
|
35
|
+
resolve();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
get: async function (key) {
|
|
40
|
+
// read file
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
// check exist
|
|
43
|
+
if (!fs.existsSync(path.join(options.dir, escapeFileName(`${key}`))))
|
|
44
|
+
return resolve(undefined);
|
|
45
|
+
fs.readFile(path.join(options.dir, escapeFileName(`${key}`)), (err, data) => {
|
|
46
|
+
if (err)
|
|
47
|
+
reject(err);
|
|
48
|
+
resolve(data);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
export { fileCache };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { memoryCache } from './memory.js';
|
|
2
|
+
import { s3Cache } from './s3.js';
|
|
3
|
+
import { fileCache } from './file.js';
|
|
4
|
+
const getCache = function (strategy, options = {}) {
|
|
5
|
+
if (strategy === 'none') {
|
|
6
|
+
return {
|
|
7
|
+
get: async () => undefined,
|
|
8
|
+
set: async () => undefined,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
else if (strategy === 'memory') {
|
|
12
|
+
return memoryCache();
|
|
13
|
+
}
|
|
14
|
+
else if (strategy === 'file') {
|
|
15
|
+
return fileCache({ dir: options['file:dir'] ?? './.cache' });
|
|
16
|
+
}
|
|
17
|
+
else if (strategy === 's3') {
|
|
18
|
+
return s3Cache({ bucket: options['s3:bucket'] ?? '' });
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
throw new Error(`Invalid cache strategy: ${strategy}`);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
export { getCache, };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const MEMORY_CACHE_KVS = {};
|
|
2
|
+
const memoryCache = function () {
|
|
3
|
+
return {
|
|
4
|
+
set: async function (key, value) {
|
|
5
|
+
MEMORY_CACHE_KVS[key] = value;
|
|
6
|
+
},
|
|
7
|
+
get: async function (key) {
|
|
8
|
+
return MEMORY_CACHE_KVS[key] ?? undefined;
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
export { memoryCache };
|
package/dist/cache/s3.js
ADDED
package/dist/cache.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// TODO: other cache implementation
|
|
2
|
+
const getCache = function (strategy) {
|
|
3
|
+
if (strategy === 'none') {
|
|
4
|
+
return {
|
|
5
|
+
get: async () => undefined,
|
|
6
|
+
set: async () => undefined,
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
else if (strategy === 'memory') {
|
|
10
|
+
return memoryCache;
|
|
11
|
+
}
|
|
12
|
+
else if (strategy === 'file') {
|
|
13
|
+
return fileCache;
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
throw new Error(`Invalid cache strategy: ${strategy}`);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
const MEMORY_CACHE_KVS = {};
|
|
20
|
+
const memoryCache = {
|
|
21
|
+
set: async function (key, value) {
|
|
22
|
+
MEMORY_CACHE_KVS[key] = value;
|
|
23
|
+
},
|
|
24
|
+
get: async function (key) {
|
|
25
|
+
return MEMORY_CACHE_KVS[key] ?? undefined;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
import * as fs from 'fs';
|
|
29
|
+
import * as path from 'path';
|
|
30
|
+
function escapeFileName(url) {
|
|
31
|
+
return url
|
|
32
|
+
.replace(/https?:\/\//, '') // remove protocol
|
|
33
|
+
.replace(/\//g, '_') // replace slashes with underscores
|
|
34
|
+
.replace(/\?/g, '-') // replace question marks with dashes
|
|
35
|
+
.replace(/&/g, '-') // replace ampersands with dashes
|
|
36
|
+
.replace(/=/g, '-') // replace equals signs with dashes
|
|
37
|
+
.replace(/%/g, '-') // replace percent signs with dashes
|
|
38
|
+
.replace(/#/g, '-') // replace hash signs with dashes
|
|
39
|
+
.replace(/:/g, '-') // replace colons with dashes
|
|
40
|
+
.replace(/\+/g, '-') // replace plus signs with dashes
|
|
41
|
+
.replace(/ /g, '-') // replace spaces with dashes
|
|
42
|
+
.replace(/</g, '-') // replace less than signs with dashes
|
|
43
|
+
.replace(/>/g, '-') // replace greater than signs with dashes
|
|
44
|
+
.replace(/\*/g, '-') // replace asterisks with dashes
|
|
45
|
+
.replace(/\|/g, '-') // replace vertical bars with dashes
|
|
46
|
+
.replace(/"/g, '-') // replace double quotes with dashes
|
|
47
|
+
.replace(/'/g, '-') // replace single quotes with dashes
|
|
48
|
+
.replace(/\?/g, '-') // replace question marks with dashes
|
|
49
|
+
.replace(/\./g, '-') // replace dots with dashes
|
|
50
|
+
.replace(/,/g, '-') // replace commas with dashes
|
|
51
|
+
.replace(/;/g, '-') // replace semicolons with dashes
|
|
52
|
+
.replace(/\\/g, '-'); // replace backslashes with dashes
|
|
53
|
+
}
|
|
54
|
+
const FILE_CACHE_DIR = './.cache';
|
|
55
|
+
const fileCache = {
|
|
56
|
+
set: async function (key, value) {
|
|
57
|
+
// write file
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
fs.writeFile(path.join(FILE_CACHE_DIR, escapeFileName(`${key}`)), value, (err) => {
|
|
60
|
+
if (err)
|
|
61
|
+
reject(err);
|
|
62
|
+
resolve();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
get: async function (key) {
|
|
67
|
+
// read file
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
// check exist
|
|
70
|
+
if (!fs.existsSync(path.join(FILE_CACHE_DIR, escapeFileName(`${key}`))))
|
|
71
|
+
return resolve(undefined);
|
|
72
|
+
fs.readFile(path.join(FILE_CACHE_DIR, escapeFileName(`${key}`)), (err, data) => {
|
|
73
|
+
if (err)
|
|
74
|
+
reject(err);
|
|
75
|
+
resolve(data);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
export { getCache, fileCache };
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
// for using native fetch in TypeScript
|
|
3
|
+
import { Hono } from 'hono';
|
|
4
|
+
import { serve } from '@hono/node-server';
|
|
5
|
+
import sharp from 'sharp';
|
|
6
|
+
import { getRenderer } from './tiling.js';
|
|
7
|
+
import { getCache } from './cache/index.js';
|
|
8
|
+
const cache = getCache('file');
|
|
9
|
+
const hono = new Hono();
|
|
10
|
+
hono.get('/health', (c) => {
|
|
11
|
+
return c.body('OK', 200);
|
|
12
|
+
});
|
|
13
|
+
hono.get('/tiles/:z/:x/:y_ext', async (c) => {
|
|
14
|
+
// path params
|
|
15
|
+
const z = Number(c.req.param('z'));
|
|
16
|
+
const x = Number(c.req.param('x'));
|
|
17
|
+
let [_y, ext] = c.req.param('y_ext').split('.');
|
|
18
|
+
const y = Number(_y);
|
|
19
|
+
if (['png', 'jpg', 'webp'].indexOf(ext) === -1) {
|
|
20
|
+
return c.body('Invalid extension', 400);
|
|
21
|
+
}
|
|
22
|
+
// query params
|
|
23
|
+
const tileSize = Number(c.req.query('tileSize') ?? 512);
|
|
24
|
+
const noSymbol = c.req.query('noSymbol') ?? false; // rendering symbol is very slow so provide option to disable it
|
|
25
|
+
const onlySymbol = c.req.query('onlySymbol') ?? false;
|
|
26
|
+
const url = c.req.query('url') ?? null;
|
|
27
|
+
if (url === null) {
|
|
28
|
+
return c.body('url is required', 400);
|
|
29
|
+
}
|
|
30
|
+
// load style.json
|
|
31
|
+
let style;
|
|
32
|
+
const cachedStyle = await cache.get(url);
|
|
33
|
+
if (cachedStyle === undefined) {
|
|
34
|
+
const res = await fetch(url);
|
|
35
|
+
style = await res.json();
|
|
36
|
+
cache.set(url, Buffer.from(JSON.stringify(style)));
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
style = (await JSON.parse(cachedStyle.toString()));
|
|
40
|
+
}
|
|
41
|
+
if (noSymbol) {
|
|
42
|
+
style = {
|
|
43
|
+
...style,
|
|
44
|
+
layers: style.layers.filter((layer) => layer.type !== 'symbol'),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
else if (onlySymbol) {
|
|
48
|
+
style = {
|
|
49
|
+
...style,
|
|
50
|
+
layers: style.layers.filter((layer) => layer.type === 'symbol'),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const { render } = getRenderer(style, { tileSize });
|
|
54
|
+
const pixels = await render(z, x, y);
|
|
55
|
+
const image = sharp(pixels, {
|
|
56
|
+
raw: {
|
|
57
|
+
width: tileSize,
|
|
58
|
+
height: tileSize,
|
|
59
|
+
channels: 4,
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
let imgBuf;
|
|
63
|
+
if (ext === 'jpg') {
|
|
64
|
+
imgBuf = await image.jpeg({ quality: 20 }).toBuffer();
|
|
65
|
+
}
|
|
66
|
+
else if (ext === 'webp') {
|
|
67
|
+
imgBuf = await image.webp({ quality: 100 }).toBuffer();
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
imgBuf = await image.png().toBuffer();
|
|
71
|
+
}
|
|
72
|
+
return c.body(imgBuf, 200, {
|
|
73
|
+
'Content-Type': `image/${ext}`,
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
hono.get('/debug', (c) => {
|
|
77
|
+
//demo tile
|
|
78
|
+
const url = c.req.query('url') ?? 'https://demotiles.maplibre.org/style.json';
|
|
79
|
+
return c.html(`<!-- show tile in MapLibre GL JS-->
|
|
80
|
+
|
|
81
|
+
<!DOCTYPE html>
|
|
82
|
+
<html>
|
|
83
|
+
<head>
|
|
84
|
+
<meta charset="utf-8" />
|
|
85
|
+
<title>MapLibre GL JS</title>
|
|
86
|
+
<!-- maplibre gl js-->
|
|
87
|
+
<script src="https://unpkg.com/maplibre-gl@3.3.1/dist/maplibre-gl.js"></script>
|
|
88
|
+
<link
|
|
89
|
+
rel="stylesheet"
|
|
90
|
+
href="https://unpkg.com/maplibre-gl@3.3.1/dist/maplibre-gl.css"
|
|
91
|
+
/>
|
|
92
|
+
<style>
|
|
93
|
+
body {
|
|
94
|
+
margin: 0;
|
|
95
|
+
padding: 0;
|
|
96
|
+
}
|
|
97
|
+
#map {
|
|
98
|
+
position: absolute;
|
|
99
|
+
top: 0;
|
|
100
|
+
bottom: 0;
|
|
101
|
+
width: 100%;
|
|
102
|
+
}
|
|
103
|
+
</style>
|
|
104
|
+
</head>
|
|
105
|
+
<body>
|
|
106
|
+
<div id="map" style="height: 100vh"></div>
|
|
107
|
+
<script>
|
|
108
|
+
const map = new maplibregl.Map({
|
|
109
|
+
hash: true,
|
|
110
|
+
container: 'map', // container id
|
|
111
|
+
style: {
|
|
112
|
+
version: 8,
|
|
113
|
+
sources: {
|
|
114
|
+
chiitiler: {
|
|
115
|
+
type: 'raster',
|
|
116
|
+
tiles: [
|
|
117
|
+
'http://localhost:3000/tiles/{z}/{x}/{y}.png?url=${url}',
|
|
118
|
+
],
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
layers: [
|
|
122
|
+
{
|
|
123
|
+
id: 'chiitiler',
|
|
124
|
+
type: 'raster',
|
|
125
|
+
source: 'chiitiler',
|
|
126
|
+
minzoom: 0,
|
|
127
|
+
maxzoom: 22,
|
|
128
|
+
}
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
center: [0, 0], // starting position [lng, lat]
|
|
132
|
+
zoom: 1, // starting zoom
|
|
133
|
+
});
|
|
134
|
+
</script>
|
|
135
|
+
</body>
|
|
136
|
+
</html>
|
|
137
|
+
`);
|
|
138
|
+
});
|
|
139
|
+
serve({ port: 3000, fetch: hono.fetch });
|
package/dist/tiling.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
// for using native fetch in TypeScript
|
|
3
|
+
import mbgl from '@maplibre/maplibre-gl-native';
|
|
4
|
+
// @ts-ignore
|
|
5
|
+
import SphericalMercator from '@mapbox/sphericalmercator';
|
|
6
|
+
import { getCache } from './cache/index.js';
|
|
7
|
+
const cache = getCache('file');
|
|
8
|
+
function getTileCenter(z, x, y, tileSize = 256) {
|
|
9
|
+
const mercator = new SphericalMercator({
|
|
10
|
+
size: tileSize,
|
|
11
|
+
});
|
|
12
|
+
const px = tileSize / 2 + x * tileSize;
|
|
13
|
+
const py = tileSize / 2 + y * tileSize;
|
|
14
|
+
const tileCenter = mercator.ll([px, py], z);
|
|
15
|
+
return tileCenter;
|
|
16
|
+
}
|
|
17
|
+
function getRenderer(style, options = { tileSize: 256 }) {
|
|
18
|
+
const render = function (z, x, y) {
|
|
19
|
+
/**
|
|
20
|
+
* zoom(renderingOptions): tileSize=256 -> z-1, 512 -> z, 1024 -> z+1...
|
|
21
|
+
* width, height(renderingOptions): equal to tileSize but:
|
|
22
|
+
* when zoom=0, entire globe is rendered in 512x512
|
|
23
|
+
* even when tilesize=256, entire globe is rendered in "512x512 at zoom=0"
|
|
24
|
+
* so we have to set 512 when tilesize=256 and zoom=0, and adjust ratio
|
|
25
|
+
*/
|
|
26
|
+
const renderingParams = options.tileSize === 256 && z === 0
|
|
27
|
+
? {
|
|
28
|
+
zoom: 0,
|
|
29
|
+
height: 512,
|
|
30
|
+
width: 512,
|
|
31
|
+
ratio: 0.5,
|
|
32
|
+
}
|
|
33
|
+
: {
|
|
34
|
+
zoom: z - 1 + Math.floor(options.tileSize / 512),
|
|
35
|
+
height: options.tileSize,
|
|
36
|
+
width: options.tileSize,
|
|
37
|
+
ratio: 1,
|
|
38
|
+
};
|
|
39
|
+
const map = new mbgl.Map({
|
|
40
|
+
request: function (req, callback) {
|
|
41
|
+
// TODO: better Caching
|
|
42
|
+
cache.get(req.url).then((val) => {
|
|
43
|
+
if (val !== undefined) {
|
|
44
|
+
// hit
|
|
45
|
+
callback(undefined, { data: val });
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
fetch(req.url)
|
|
49
|
+
.then((res) => {
|
|
50
|
+
if (res.status === 200) {
|
|
51
|
+
res.arrayBuffer().then((data) => {
|
|
52
|
+
cache.set(req.url, Buffer.from(data));
|
|
53
|
+
callback(undefined, {
|
|
54
|
+
data: Buffer.from(data),
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
// empty
|
|
60
|
+
callback();
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
.catch((err) => {
|
|
64
|
+
callback(err);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
ratio: renderingParams.ratio,
|
|
70
|
+
mode: "tile" /* MapMode.Tile */,
|
|
71
|
+
});
|
|
72
|
+
map.load(style);
|
|
73
|
+
const renderOptions = {
|
|
74
|
+
zoom: renderingParams.zoom,
|
|
75
|
+
width: renderingParams.width,
|
|
76
|
+
height: renderingParams.height,
|
|
77
|
+
center: getTileCenter(z, x, y, options.tileSize),
|
|
78
|
+
};
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
map.render(renderOptions, function (err, buffer) {
|
|
81
|
+
if (err)
|
|
82
|
+
reject(err);
|
|
83
|
+
resolve(buffer);
|
|
84
|
+
map.release();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
render,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export { getRenderer };
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "module",
|
|
3
|
+
"name": "chiitiler",
|
|
4
|
+
"version": "0.0.1",
|
|
5
|
+
"description": "",
|
|
6
|
+
"main": "dist/server.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"start": "tsc && node ./dist/server.js",
|
|
10
|
+
"prepare": "npm run build"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"chiitiler": "./dist/server.js"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [],
|
|
16
|
+
"author": "Kanahiro Iguchi",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "^20.6.0",
|
|
20
|
+
"maplibre-gl": "^3.3.1",
|
|
21
|
+
"ts-node": "^10.9.1",
|
|
22
|
+
"typescript": "^5.2.2"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@hono/node-server": "^1.1.1",
|
|
26
|
+
"@mapbox/sphericalmercator": "^1.2.0",
|
|
27
|
+
"@mapbox/tilebelt": "^1.0.2",
|
|
28
|
+
"@maplibre/maplibre-gl-native": "^5.2.0",
|
|
29
|
+
"@napi-rs/canvas": "^0.1.44",
|
|
30
|
+
"@tsconfig/node18": "^18.2.1",
|
|
31
|
+
"fast-png": "^6.2.0",
|
|
32
|
+
"hono": "^3.6.0",
|
|
33
|
+
"sharp": "^0.32.5"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { type Cache, type Value } from './index.js';
|
|
4
|
+
|
|
5
|
+
function escapeFileName(url: string) {
|
|
6
|
+
return url
|
|
7
|
+
.replace(/https?:\/\//, '') // remove protocol
|
|
8
|
+
.replace(/\//g, '_') // replace slashes with underscores
|
|
9
|
+
.replace(/\?/g, '-') // replace question marks with dashes
|
|
10
|
+
.replace(/&/g, '-') // replace ampersands with dashes
|
|
11
|
+
.replace(/=/g, '-') // replace equals signs with dashes
|
|
12
|
+
.replace(/%/g, '-') // replace percent signs with dashes
|
|
13
|
+
.replace(/#/g, '-') // replace hash signs with dashes
|
|
14
|
+
.replace(/:/g, '-') // replace colons with dashes
|
|
15
|
+
.replace(/\+/g, '-') // replace plus signs with dashes
|
|
16
|
+
.replace(/ /g, '-') // replace spaces with dashes
|
|
17
|
+
.replace(/</g, '-') // replace less than signs with dashes
|
|
18
|
+
.replace(/>/g, '-') // replace greater than signs with dashes
|
|
19
|
+
.replace(/\*/g, '-') // replace asterisks with dashes
|
|
20
|
+
.replace(/\|/g, '-') // replace vertical bars with dashes
|
|
21
|
+
.replace(/"/g, '-') // replace double quotes with dashes
|
|
22
|
+
.replace(/'/g, '-') // replace single quotes with dashes
|
|
23
|
+
.replace(/\?/g, '-') // replace question marks with dashes
|
|
24
|
+
.replace(/\./g, '-') // replace dots with dashes
|
|
25
|
+
.replace(/,/g, '-') // replace commas with dashes
|
|
26
|
+
.replace(/;/g, '-') // replace semicolons with dashes
|
|
27
|
+
.replace(/\\/g, '-'); // replace backslashes with dashes
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type fileCacheOptions = {
|
|
31
|
+
dir: string;
|
|
32
|
+
};
|
|
33
|
+
const fileCache: (options: fileCacheOptions) => Cache = function (options) {
|
|
34
|
+
return {
|
|
35
|
+
set: async function (key: string, value: Value) {
|
|
36
|
+
// write file
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
fs.writeFile(
|
|
39
|
+
path.join(options.dir, escapeFileName(`${key}`)),
|
|
40
|
+
value,
|
|
41
|
+
(err: any) => {
|
|
42
|
+
if (err) reject(err);
|
|
43
|
+
resolve();
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
get: async function (key: string): Promise<Value | undefined> {
|
|
49
|
+
// read file
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
// check exist
|
|
52
|
+
if (
|
|
53
|
+
!fs.existsSync(
|
|
54
|
+
path.join(options.dir, escapeFileName(`${key}`)),
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
return resolve(undefined);
|
|
58
|
+
|
|
59
|
+
fs.readFile(
|
|
60
|
+
path.join(options.dir, escapeFileName(`${key}`)),
|
|
61
|
+
(err: any, data: Buffer) => {
|
|
62
|
+
if (err) reject(err);
|
|
63
|
+
resolve(data);
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export { fileCache };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { memoryCache } from './memory.js';
|
|
2
|
+
import { s3Cache } from './s3.js';
|
|
3
|
+
import { fileCache } from './file.js';
|
|
4
|
+
|
|
5
|
+
type Value = Buffer;
|
|
6
|
+
type Strategy = 'none' | 'memory' | 'file' | 's3';
|
|
7
|
+
type GetCacheOptions = {
|
|
8
|
+
's3:bucket'?: string;
|
|
9
|
+
'file:dir'?: string;
|
|
10
|
+
};
|
|
11
|
+
type Cache = {
|
|
12
|
+
get: (key: string) => Promise<Value | undefined>;
|
|
13
|
+
set: (key: string, value: Value) => Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const getCache = function (
|
|
17
|
+
strategy: Strategy,
|
|
18
|
+
options: GetCacheOptions = {},
|
|
19
|
+
): Cache {
|
|
20
|
+
if (strategy === 'none') {
|
|
21
|
+
return {
|
|
22
|
+
get: async () => undefined,
|
|
23
|
+
set: async () => undefined,
|
|
24
|
+
};
|
|
25
|
+
} else if (strategy === 'memory') {
|
|
26
|
+
return memoryCache();
|
|
27
|
+
} else if (strategy === 'file') {
|
|
28
|
+
return fileCache({ dir: options['file:dir'] ?? './.cache' });
|
|
29
|
+
} else if (strategy === 's3') {
|
|
30
|
+
return s3Cache({ bucket: options['s3:bucket'] ?? '' });
|
|
31
|
+
} else {
|
|
32
|
+
throw new Error(`Invalid cache strategy: ${strategy}`);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export {
|
|
37
|
+
getCache,
|
|
38
|
+
type Value,
|
|
39
|
+
type Cache,
|
|
40
|
+
type Strategy,
|
|
41
|
+
type GetCacheOptions,
|
|
42
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Cache, type Value } from './index.js';
|
|
2
|
+
|
|
3
|
+
const MEMORY_CACHE_KVS: Record<string, Value> = {};
|
|
4
|
+
const memoryCache: () => Cache = function () {
|
|
5
|
+
return {
|
|
6
|
+
set: async function (key: string, value: Value) {
|
|
7
|
+
MEMORY_CACHE_KVS[key] = value;
|
|
8
|
+
},
|
|
9
|
+
get: async function (key: string): Promise<Value | undefined> {
|
|
10
|
+
return (MEMORY_CACHE_KVS[key] as Value) ?? undefined;
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export { memoryCache };
|
package/src/cache/s3.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// TODO: implement
|
|
2
|
+
|
|
3
|
+
import { type Cache, type Value } from './index.js';
|
|
4
|
+
|
|
5
|
+
type S3CacheOptions = {
|
|
6
|
+
bucket: string;
|
|
7
|
+
};
|
|
8
|
+
const s3Cache: (options: S3CacheOptions) => Cache = function () {
|
|
9
|
+
return {
|
|
10
|
+
set: async function (key: string, value: Value) {},
|
|
11
|
+
get: async function (key: string): Promise<Value | undefined> {
|
|
12
|
+
return undefined;
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export { s3Cache };
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
// for using native fetch in TypeScript
|
|
3
|
+
|
|
4
|
+
import { Hono } from 'hono';
|
|
5
|
+
import { serve } from '@hono/node-server';
|
|
6
|
+
import sharp from 'sharp';
|
|
7
|
+
import type { StyleSpecification } from 'maplibre-gl';
|
|
8
|
+
|
|
9
|
+
import { getRenderer } from './tiling.js';
|
|
10
|
+
import { getCache } from './cache/index.js';
|
|
11
|
+
const cache = getCache('file');
|
|
12
|
+
|
|
13
|
+
const hono = new Hono();
|
|
14
|
+
hono.get('/health', (c) => {
|
|
15
|
+
return c.body('OK', 200);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
hono.get('/tiles/:z/:x/:y_ext', async (c) => {
|
|
19
|
+
// path params
|
|
20
|
+
const z = Number(c.req.param('z'));
|
|
21
|
+
const x = Number(c.req.param('x'));
|
|
22
|
+
let [_y, ext] = c.req.param('y_ext').split('.');
|
|
23
|
+
const y = Number(_y);
|
|
24
|
+
|
|
25
|
+
if (['png', 'jpg', 'webp'].indexOf(ext) === -1) {
|
|
26
|
+
return c.body('Invalid extension', 400);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// query params
|
|
30
|
+
const tileSize = Number(c.req.query('tileSize') ?? 512);
|
|
31
|
+
const noSymbol = c.req.query('noSymbol') ?? false; // rendering symbol is very slow so provide option to disable it
|
|
32
|
+
const onlySymbol = c.req.query('onlySymbol') ?? false;
|
|
33
|
+
const url = c.req.query('url') ?? null;
|
|
34
|
+
|
|
35
|
+
if (url === null) {
|
|
36
|
+
return c.body('url is required', 400);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// load style.json
|
|
40
|
+
let style: StyleSpecification;
|
|
41
|
+
|
|
42
|
+
const cachedStyle = await cache.get(url);
|
|
43
|
+
if (cachedStyle === undefined) {
|
|
44
|
+
const res = await fetch(url);
|
|
45
|
+
style = await res.json();
|
|
46
|
+
cache.set(url, Buffer.from(JSON.stringify(style)));
|
|
47
|
+
} else {
|
|
48
|
+
style = (await JSON.parse(
|
|
49
|
+
cachedStyle.toString(),
|
|
50
|
+
)) as StyleSpecification;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (noSymbol) {
|
|
54
|
+
style = {
|
|
55
|
+
...style,
|
|
56
|
+
layers: style.layers.filter((layer) => layer.type !== 'symbol'),
|
|
57
|
+
};
|
|
58
|
+
} else if (onlySymbol) {
|
|
59
|
+
style = {
|
|
60
|
+
...style,
|
|
61
|
+
layers: style.layers.filter((layer) => layer.type === 'symbol'),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const { render } = getRenderer(style, { tileSize });
|
|
66
|
+
const pixels = await render(z, x, y);
|
|
67
|
+
|
|
68
|
+
const image = sharp(pixels, {
|
|
69
|
+
raw: {
|
|
70
|
+
width: tileSize,
|
|
71
|
+
height: tileSize,
|
|
72
|
+
channels: 4,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
let imgBuf: Buffer;
|
|
77
|
+
if (ext === 'jpg') {
|
|
78
|
+
imgBuf = await image.jpeg({ quality: 20 }).toBuffer();
|
|
79
|
+
} else if (ext === 'webp') {
|
|
80
|
+
imgBuf = await image.webp({ quality: 100 }).toBuffer();
|
|
81
|
+
} else {
|
|
82
|
+
imgBuf = await image.png().toBuffer();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return c.body(imgBuf, 200, {
|
|
86
|
+
'Content-Type': `image/${ext}`,
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
hono.get('/debug', (c) => {
|
|
91
|
+
//demo tile
|
|
92
|
+
const url =
|
|
93
|
+
c.req.query('url') ?? 'https://demotiles.maplibre.org/style.json';
|
|
94
|
+
|
|
95
|
+
return c.html(`<!-- show tile in MapLibre GL JS-->
|
|
96
|
+
|
|
97
|
+
<!DOCTYPE html>
|
|
98
|
+
<html>
|
|
99
|
+
<head>
|
|
100
|
+
<meta charset="utf-8" />
|
|
101
|
+
<title>MapLibre GL JS</title>
|
|
102
|
+
<!-- maplibre gl js-->
|
|
103
|
+
<script src="https://unpkg.com/maplibre-gl@3.3.1/dist/maplibre-gl.js"></script>
|
|
104
|
+
<link
|
|
105
|
+
rel="stylesheet"
|
|
106
|
+
href="https://unpkg.com/maplibre-gl@3.3.1/dist/maplibre-gl.css"
|
|
107
|
+
/>
|
|
108
|
+
<style>
|
|
109
|
+
body {
|
|
110
|
+
margin: 0;
|
|
111
|
+
padding: 0;
|
|
112
|
+
}
|
|
113
|
+
#map {
|
|
114
|
+
position: absolute;
|
|
115
|
+
top: 0;
|
|
116
|
+
bottom: 0;
|
|
117
|
+
width: 100%;
|
|
118
|
+
}
|
|
119
|
+
</style>
|
|
120
|
+
</head>
|
|
121
|
+
<body>
|
|
122
|
+
<div id="map" style="height: 100vh"></div>
|
|
123
|
+
<script>
|
|
124
|
+
const map = new maplibregl.Map({
|
|
125
|
+
hash: true,
|
|
126
|
+
container: 'map', // container id
|
|
127
|
+
style: {
|
|
128
|
+
version: 8,
|
|
129
|
+
sources: {
|
|
130
|
+
chiitiler: {
|
|
131
|
+
type: 'raster',
|
|
132
|
+
tiles: [
|
|
133
|
+
'http://localhost:3000/tiles/{z}/{x}/{y}.png?url=${url}',
|
|
134
|
+
],
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
layers: [
|
|
138
|
+
{
|
|
139
|
+
id: 'chiitiler',
|
|
140
|
+
type: 'raster',
|
|
141
|
+
source: 'chiitiler',
|
|
142
|
+
minzoom: 0,
|
|
143
|
+
maxzoom: 22,
|
|
144
|
+
}
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
center: [0, 0], // starting position [lng, lat]
|
|
148
|
+
zoom: 1, // starting zoom
|
|
149
|
+
});
|
|
150
|
+
</script>
|
|
151
|
+
</body>
|
|
152
|
+
</html>
|
|
153
|
+
`);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
serve({ port: 3000, fetch: hono.fetch });
|
package/src/tiling.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
// for using native fetch in TypeScript
|
|
3
|
+
|
|
4
|
+
import mbgl, { MapMode } from '@maplibre/maplibre-gl-native';
|
|
5
|
+
// @ts-ignore
|
|
6
|
+
import SphericalMercator from '@mapbox/sphericalmercator';
|
|
7
|
+
import type { StyleSpecification } from 'maplibre-gl';
|
|
8
|
+
|
|
9
|
+
import { getCache } from './cache/index.js';
|
|
10
|
+
const cache = getCache('file');
|
|
11
|
+
|
|
12
|
+
function getTileCenter(z: number, x: number, y: number, tileSize = 256) {
|
|
13
|
+
const mercator = new SphericalMercator({
|
|
14
|
+
size: tileSize,
|
|
15
|
+
});
|
|
16
|
+
const px = tileSize / 2 + x * tileSize;
|
|
17
|
+
const py = tileSize / 2 + y * tileSize;
|
|
18
|
+
const tileCenter = mercator.ll([px, py], z);
|
|
19
|
+
return tileCenter;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type GetRendererOptions = {
|
|
23
|
+
tileSize: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function getRenderer(
|
|
27
|
+
style: StyleSpecification,
|
|
28
|
+
options: GetRendererOptions = { tileSize: 256 },
|
|
29
|
+
): {
|
|
30
|
+
render: (
|
|
31
|
+
z: number,
|
|
32
|
+
x: number,
|
|
33
|
+
y: number,
|
|
34
|
+
) => Promise<Uint8Array | undefined>;
|
|
35
|
+
} {
|
|
36
|
+
const render = function (
|
|
37
|
+
z: number,
|
|
38
|
+
x: number,
|
|
39
|
+
y: number,
|
|
40
|
+
): Promise<Uint8Array | undefined> {
|
|
41
|
+
/**
|
|
42
|
+
* zoom(renderingOptions): tileSize=256 -> z-1, 512 -> z, 1024 -> z+1...
|
|
43
|
+
* width, height(renderingOptions): equal to tileSize but:
|
|
44
|
+
* when zoom=0, entire globe is rendered in 512x512
|
|
45
|
+
* even when tilesize=256, entire globe is rendered in "512x512 at zoom=0"
|
|
46
|
+
* so we have to set 512 when tilesize=256 and zoom=0, and adjust ratio
|
|
47
|
+
*/
|
|
48
|
+
const renderingParams =
|
|
49
|
+
options.tileSize === 256 && z === 0
|
|
50
|
+
? {
|
|
51
|
+
zoom: 0,
|
|
52
|
+
height: 512,
|
|
53
|
+
width: 512,
|
|
54
|
+
ratio: 0.5,
|
|
55
|
+
}
|
|
56
|
+
: {
|
|
57
|
+
zoom: z - 1 + Math.floor(options.tileSize / 512),
|
|
58
|
+
height: options.tileSize,
|
|
59
|
+
width: options.tileSize,
|
|
60
|
+
ratio: 1,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const map = new mbgl.Map({
|
|
64
|
+
request: function (req, callback) {
|
|
65
|
+
// TODO: better Caching
|
|
66
|
+
cache.get(req.url).then((val) => {
|
|
67
|
+
if (val !== undefined) {
|
|
68
|
+
// hit
|
|
69
|
+
callback(undefined, { data: val as Buffer });
|
|
70
|
+
} else {
|
|
71
|
+
fetch(req.url)
|
|
72
|
+
.then((res) => {
|
|
73
|
+
if (res.status === 200) {
|
|
74
|
+
res.arrayBuffer().then(
|
|
75
|
+
(data: ArrayBuffer) => {
|
|
76
|
+
cache.set(
|
|
77
|
+
req.url,
|
|
78
|
+
Buffer.from(data),
|
|
79
|
+
);
|
|
80
|
+
callback(undefined, {
|
|
81
|
+
data: Buffer.from(data),
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
} else {
|
|
86
|
+
// empty
|
|
87
|
+
callback();
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
.catch((err: any) => {
|
|
91
|
+
callback(err);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
ratio: renderingParams.ratio,
|
|
97
|
+
mode: MapMode.Tile,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
map.load(style);
|
|
101
|
+
|
|
102
|
+
const renderOptions = {
|
|
103
|
+
zoom: renderingParams.zoom,
|
|
104
|
+
width: renderingParams.width,
|
|
105
|
+
height: renderingParams.height,
|
|
106
|
+
center: getTileCenter(z, x, y, options.tileSize),
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
map.render(renderOptions, function (err, buffer) {
|
|
111
|
+
if (err) reject(err);
|
|
112
|
+
resolve(buffer);
|
|
113
|
+
map.release();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
render,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export { getRenderer };
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": ["@tsconfig/node18/tsconfig.json"],
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"moduleResolution": "NodeNext",
|
|
5
|
+
"outDir": "./dist",
|
|
6
|
+
"resolveJsonModule": true,
|
|
7
|
+
"esModuleInterop": true
|
|
8
|
+
},
|
|
9
|
+
"include": ["src/**/*"],
|
|
10
|
+
"exclude": ["node_modules", "build", "dist"]
|
|
11
|
+
}
|