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 ADDED
@@ -0,0 +1,3 @@
1
+ node_modules/
2
+ .cache/
3
+ dist/
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 };
@@ -0,0 +1,10 @@
1
+ // TODO: implement
2
+ const s3Cache = function () {
3
+ return {
4
+ set: async function (key, value) { },
5
+ get: async function (key) {
6
+ return undefined;
7
+ },
8
+ };
9
+ };
10
+ export { s3Cache };
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 };
@@ -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
+ }