0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

coesite4をPMTilesで使用できるようにする

0
Last updated at Posted at 2026-04-20

はじめに

coesite4を使用して、unvtのベクトルタイルのホスティングを行っています。現在はMBTilesを使用していますが、今後の展開を考えて、PMTilesに変更予定であり、そのため、coesite4もPMTilesでホスティング出来るようにします。

作業ディレクトリ(個人メモ)

レンタルサーバLinux9の
20260413coesite4PmtilesLocaltest

コード変更方針

Ccoestie4は認証機能などがあり、それらの機能があると分かりづらいので、まずはそれらの機能をコメントアウトします。その後、まずは、PMTilesでホスティング出来るように最小限の変更を行います。

PMTiles表示方法は方法は2つあります。
方法1:HTTP Range Requestsの方法。こちらが、PMTilesで使用されているメインの方法です。

方法2:
従来の普通のタイルURL(https://.../{z}/{x}/{y}.pbfなど)を指定する方法です。

coesite4は/routes/VT-r.jsなどで、ユーザによるタイル取得制限を行っているので、方法1ではその実装が難しいと思います。そのため、方法2で実装します。

認証なしで使用するためのコード変更

記事coesite4レポジトリを改良して認証なしサーバレポジトリを作成する(個人メモ)を参考にして、ローカルで認証なしで使えるようコードを編集します。

まずは、MBTilesがきちんと見られました。

PMTilesで使用するためのコード変更等

npm i pmtiles
でpmtilesモジュールをインストールします。

AI作成のコードを読み解く

AIにコードを編集してもらい、それを解説します。
今まではasync/awitと、Promiseの書き方が混在していたので、async/awaitの書き方に統一しました。

VT-open5.js
const express = require('express');
const router = express.Router();
const config = require('config');
const fs = require('fs');
const path = require('path');
const { PMTiles } = require('pmtiles');

// config constants
const defaultZ = config.get('defaultZ');
const pmtilesDir = config.get('pmtilesDir');

// global variables
const pmtilesPool = {};
const tz = config.get('tz');
const sTileName = config.get('sTileName');
const projectRoot = path.resolve(__dirname, '..');

class LocalPMTilesSource {
  constructor(archivePath) {
    this.archivePath = archivePath;
    this.handlePromise = fs.promises.open(archivePath, 'r');
  }

  getKey() {
    return this.archivePath;
  }

  async getBytes(offset, length, signal) {
    if (signal?.aborted) {
      throw new Error('Tile request aborted.');
    }

    const handle = await this.handlePromise;
    const buffer = Buffer.alloc(length);
    const { bytesRead } = await handle.read(buffer, 0, length, offset);

    return {
      data: buffer.buffer.slice(
        buffer.byteOffset,
        buffer.byteOffset + bytesRead,
      ),
    };
  }
}

//Get tile functions
const getPMTiles = async (t, z, x, y) => {
  // ex. t = UniteStreetMapVector
  let pmtilesPathSmall = '';
  let pmtilesPath1 = '';
  let pmtilesPath2 = '';
  let pmtilesPath3 = '';

  if (!tz[t]) tz[t] = defaultZ;

  const tz2 = tz[t] - 1;
  const tz3 = tz[t] - 2;
  const tilesetDir = path.resolve(projectRoot, pmtilesDir, t);

  // if contour, there is no sTileName[t]
  if (sTileName[t]) {
    pmtilesPathSmall = path.join(tilesetDir, `${sTileName[t]}.pmtiles`);
  }

  if (z < tz[t]) {
    pmtilesPath1 = pmtilesPathSmall;
    pmtilesPath2 = pmtilesPathSmall;
    pmtilesPath3 = pmtilesPathSmall;
  } else {
    pmtilesPath1 = path.join(
      tilesetDir,
      `${tz[t]}-${x >> (z - tz[t])}-${y >> (z - tz[t])}.pmtiles`,
    );
    pmtilesPath2 = path.join(
      tilesetDir,
      `${tz2}-${x >> (z - tz2)}-${y >> (z - tz2)}.pmtiles`,
    );
    pmtilesPath3 = path.join(
      tilesetDir,
      `${tz3}-${x >> (z - tz3)}-${y >> (z - tz3)}.pmtiles`,
    );
  }

  const candidates = [
    ...new Set([pmtilesPath1, pmtilesPath2, pmtilesPath3].filter(Boolean)),
  ];
  // ex, pmtilesPath1 = /home/20260413coesite4PmtilesLocaltest/pmtiles/UniteStreetMapVector/6-35-31.pmtiles
  // pmtilesPath2 = /home/20260413coesite4PmtilesLocaltest/pmtiles/UniteStreetMapVector/5-17-15.pmtiles
  // pmtilesPath3 = /home/20260413coesite4PmtilesLocaltest/pmtiles/UniteStreetMapVector/4-8-7.pmtiles

  const cachedPath = candidates.find(candidate => pmtilesPool[candidate]);

  if (cachedPath) {
    return pmtilesPool[cachedPath].pmtiles;
  }
  const existingPath = candidates.find(candidate => fs.existsSync(candidate));

  if (existingPath) {
    pmtilesPool[existingPath] = {
      pmtiles: new PMTiles(new LocalPMTilesSource(existingPath)),
      // openTime: new Date(),
    };
    return pmtilesPool[existingPath].pmtiles;
  }

  throw new Error(`${candidates[0] || tilesetDir} was not found.`);
};

const getTile = async (pmtiles, z, x, y) => {
  try {
    const r = await pmtiles.getZxyAttempt(z, x, y);
    if (!r) return null;
    return {
      tile: Buffer.from(r.data),
      headers: {
        'Cache-Control': r.cacheControl,
        Expires: r.expires,
      },
    };
  } catch (err) {
    throw err;
  }
};

/* GET Tile. */
router.get(`/zxy/:t/:z/:x/:y.pbf`, async function (req, res) {
  try {
    const t = req.params.t;
    const z = parseInt(req.params.z, 10);
    const x = parseInt(req.params.x, 10);
    const y = parseInt(req.params.y, 10);

    // Input validation - Security enhancement to prevent path traversal and invalid tile requests
    // Restrict tileset name to alphanumeric characters, hyphens, and underscores only
    if (!t || typeof t !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(t)) {
      res.status(400).send('Invalid tileset name');
      return;
    }
    // Validate zoom level within reasonable bounds (0-30) to prevent resource exhaustion
    if (isNaN(z) || z < 0 || z > 30) {
      res.status(400).send('Invalid zoom level');
      return;
    }
    // Validate X coordinate is within valid range for the zoom level
    if (isNaN(x) || x < 0 || x >= Math.pow(2, z)) {
      res.status(400).send('Invalid x coordinate');
      return;
    }
    // Validate Y coordinate is within valid range for the zoom level
    if (isNaN(y) || y < 0 || y >= Math.pow(2, z)) {
      res.status(400).send('Invalid y coordinate');
      return;
    }

    const pmtiles = await getPMTiles(t, z, x, y);
    const r = await getTile(pmtiles, z, x, y);

    if (r && r.tile) {
      res.set('content-type', 'application/vnd.mapbox-vector-tile');

      if (r.headers['Cache-Control']) {
        res.set('cache-control', r.headers['Cache-Control']);
      }

      if (r.headers.Expires) {
        res.set('expires', r.headers.Expires);
      }

      res.send(r.tile);
    } else {
      throw new Error(`tile not found: /zxy/${t}/${z}/${x}/${y}.pbf`);
    }
  } catch (err) {
    res.status(404).send(err.message);
  }
});

module.exports = router;

corsの記載は、app.jsで行っているので不要なようです。

constructor(archivePath) {
  this.archivePath = archivePath;
  this.handlePromise = fs.promises.open(archivePath, 'r');
}

引数 archivePathはPMTilesファイルの場所です。

fs.promises.open(...) は Node.js の非同期ファイルオープンです。
'r' は read-only、つまり「読み取り専用」で開くという意味です。
この時点では、this.handlePromiseにはPromiseが入ります。
https://nodejs.org/docs/latest-v22.x/api/fs.html#fspromisesopenpath-flags-mode

ドキュメントで fsPromises と書かれていますが、fs.promises.open(...)のことです。ドキュメントに記載の通り、 FileHandle objectのPromiseを返します。

constructorで一度だけ開いて Promise を持っておけば、同じ PMTiles ファイルに対する繰り返しアクセスで再利用しやすいため、ここで開いています。

getKey() {
  return this.archivePath;
}

getKey() は、該当JSファイル の中で明示的に getKey() と書いて呼ばれてはいません。pmtiles ライブラリ内部から source.getKey() として使われます。

  async getBytes(offset, length, signal) {
    if (signal?.aborted) {
      throw new Error('Tile request aborted.');
    }

    const handle = await this.handlePromise;
    const buffer = Buffer.alloc(length);
    const { bytesRead } = await handle.read(buffer, 0, length, offset);

    return {
      data: buffer.buffer.slice(
        buffer.byteOffset,
        buffer.byteOffset + bytesRead,
      ),
    };
  }

「PMTiles ファイルの中から、指定された位置の指定された長さのバイト列だけを読み出して返す」関数

引数の意味

  • offset
    読み始める位置、ファイルの先頭から何バイト目か
  • length
    読む長さ、何バイト分読みたいか
  • signal
    中断用の情報、リクエストがキャンセルされたか確認するために使う

const handle = await this.handlePromise;

constructor で保存しておいた this.handlePromise を待って、
実際にファイルを読むための handle を取り出します。
ここで得られる handle は、
「開かれた PMTiles ファイルそのものを操作するための取っ手」
のようなものです。

const buffer = Buffer.alloc(length);

Buffer は Node.js に最初から用意されている組み込みのグローバルオブジェクトです。
length バイト分の空き領域を確保します。
ここにファイルから読んだデータを入れます。

const { bytesRead } = await handle.read(buffer, 0, length, offset);

https://nodejs.org/docs/latest-v22.x/api/fs.html#filehandlereadbuffer-offset-length-position
filehandle.read(buffer, offset, length, position)の順番で入れていきます。
buffer: 読み込み先
0: buffer のどこから書くか
length: 何バイト読むか
offset: ファイルのどこから読むか

data: buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + bytesRead,),

buffer.buffer:Buffer の中にある元の ArrayBuffer を取り出しています。
slice(A, B)でAからBまでの値を切り取っています。
buffer.byteOffset:その中のどこから有効データが始まるか
buffer.byteOffset + bytesRead:実際に読めたデータの終わり


const tilesetDir = path.resolve(projectRoot, pmtilesDir, t);

「PMTiles のタイルセットが入っているディレクトリの絶対パスを作る」ためのコードです。

path.resolve() は、複数のパス要素をつなげて、最終的に絶対パスにする
ための Node.js の関数です。
path.join()もパスをつなぎますが、相対パスのままでもよいのが違いです。

projectRoot
例: /home/20260413coesite4PmtilesLocaltest

pmtilesDir
設定ファイルから読んだ PMTiles 用ディレクトリ名
例: pmtiles

t
URL パラメータで渡されたタイルセット名
例: UniteStreetMapVector

最終的には以下のようになります。
/home/20260413coesite4PmtilesLocaltest/pmtiles/UniteStreetMapVector

  const candidates = [
    ...new Set([pmtilesPath1, pmtilesPath2, pmtilesPath3].filter(Boolean)),
  ];

.filter(Boolean)はnull、undefined、falseのような「中身がない値」を除く書き方です。
new Set(...) で重複をなくします。
[...new Set(...)]で配列に戻しています。
ズームレベルが6未満の場合は、pmtilesPath1, 2, 3の3つのファイルは同じ名前のファイルとなっているので、1つになります。

const existingPath = candidates.find(candidate => fs.existsSync(candidate),

ここで、existingPathには、一つのパスのみ入ります。なぜなら、small-scale.pmtilesの場合は、ファイルが一つしか存在しないからです。また、ズームレベル6以上の場合も、対応するファイルはズームレベルが4,5,6のいずれかの一つしか存在しないからです。

const cachedPath = candidates.find(candidate => pmtilesPool[candidate]);

pmtilesPool[candidate]はオブジェクトですが、このオブジェクトが存在するcandidate、つまり「/home/20260413coesite4PmtilesLocaltest/pmtiles/UniteStreetMapVector/6-35-31.pmtiles」などのパスが、cachedPathに入ります。

pmtiles: new PMTiles(new LocalPMTilesSource(existingPath)),

new LocalPMTilesSource(existingPath)
existingPath にある .pmtiles ファイルを
ローカルファイルとして読める Source オブジェクトにしています。
つまり、
「このファイルから必要なバイトを読み出せるようにする準備」
です。

new PMTiles(...)
PMTiles ライブラリの本体オブジェクトを作っています。

const getTile = async (pmtiles, z, x, y) => {
  try {
    const r = await pmtiles.getZxyAttempt(z, x, y);
    if (!r) return null;
    return {
      tile: Buffer.from(r.data),
      headers: {
        'Cache-Control': r.cacheControl,
        Expires: r.expires,
      },
    };
  } catch (err) {
    throw err;
  }
};

pmtiles.getZxyAttempt(z, x, y)
https://github.com/protomaps/PMTiles/blob/main/js/src/index.ts
こちらの、getZxy関数が、MBTilesの「mbtiles.getTile(z, x, y, callback)」に対応していそうです。
PMTiles ファイルの中から指定したズーム・タイル座標に対応するデータを探して見つかれば返しています。
getZxyAttempt の戻り値 r には、主に以下の情報が入ります。

r.data
タイル本体のバイト列

r.cacheControl
キャッシュ制御ヘッダー

r.expires
有効期限ヘッダー

Buffer.from(r.data)

https://nodejs.org/docs/latest-v22.x/api/buffer.html#static-method-bufferfromarraybuffer-byteoffset-length
r.data は ArrayBuffer 系(バイナリデータを入れるための生のメモリ領域)のデータです。
Express の res.send(...) で扱いやすいように
Node.js の Buffer に変換しています。

ArrayBuffer は土台、
Buffer は Node.js で使いやすくしたもの、
というイメージです。

その他

getZxyAttempt() は PMTiles 内のタイルを取り出すときに、内部圧縮を解凍して返します。そのため、
res.set('content-encoding', 'gzip');
としてしまうと、「このHTTPレスポンス自体が gzip 圧縮されている」
とクライアントに伝えてしまうので不整合になる可能性があります。

default-exmple.hjson

以下の記載を追記しました。

pmtilesDir: pmtiles

対応フォルダの作成

20260413coesite4PmtilesLocaltest/mbtilesをコピペして、
20260413coesite4PmtilesLocaltest/pmtiles/UniteStreetMapVector
などのパスを作成しました。
さらに、地図表示テストのため、以下のファイルを上記パス下に保存しました。
small-scale.pmtiles
6-35-31.pmtiles

地図で確認する

上記の通り6-35-31(中央アフリカ共和国の首都バンギ)をコピーしました。
node app.jsとすると以下から地図が見られました。(サーバーをホストしているときのみです。)
https://k96mz.xyz/unvt/index.html

開発環境で確認する

coesite4を開発環境にクローンして、作成したVT-open.jsのコードに置き換えて試します。
無事に表示されました。

リファラー、及びArcGISPro用のコードにも実装する

VT-r2.js
const express = require('express');
const router = express.Router();
const axios = require('axios');
const NodeCache = require('node-cache');

const config = require('config');
const fs = require('fs');
const path = require('path');
const { PMTiles } = require('pmtiles');

// config constants
const defaultZ = config.get('defaultZ');
const pmtilesDir = config.get('pmtilesDir');

// global variables
const pmtilesPool = {};
const tz = config.get('tz');
const sTileName = config.get('sTileName');
const projectRoot = path.resolve(__dirname, '..');

class LocalPMTilesSource {
  constructor(archivePath) {
    this.archivePath = archivePath;
    this.handlePromise = fs.promises.open(archivePath, 'r');
  }

  getKey() {
    return this.archivePath;
  }

  async getBytes(offset, length, signal) {
    if (signal?.aborted) {
      throw new Error('Tile request aborted.');
    }

    const handle = await this.handlePromise;
    const buffer = Buffer.alloc(length);
    const { bytesRead } = await handle.read(buffer, 0, length, offset);

    return {
      data: buffer.buffer.slice(
        buffer.byteOffset,
        buffer.byteOffset + bytesRead,
      ),
    };
  }
}

// Get tile functions
const getPMTiles = async (t, z, x, y) => {
  // ex. t = UniteStreetMapVector
  let pmtilesPathSmall = '';
  let pmtilesPath1 = '';
  let pmtilesPath2 = '';
  let pmtilesPath3 = '';

  if (!tz[t]) tz[t] = defaultZ;

  const tz2 = tz[t] - 1;
  const tz3 = tz[t] - 2;
  const tilesetDir = path.resolve(projectRoot, pmtilesDir, t);

  // if contour, there is no sTileName[t]
  if (sTileName[t]) {
    pmtilesPathSmall = path.join(tilesetDir, `${sTileName[t]}.pmtiles`);
  }

  if (z < tz[t]) {
    pmtilesPath1 = pmtilesPathSmall;
    pmtilesPath2 = pmtilesPathSmall;
    pmtilesPath3 = pmtilesPathSmall;
  } else {
    pmtilesPath1 = path.join(
      tilesetDir,
      `${tz[t]}-${x >> (z - tz[t])}-${y >> (z - tz[t])}.pmtiles`,
    );
    pmtilesPath2 = path.join(
      tilesetDir,
      `${tz2}-${x >> (z - tz2)}-${y >> (z - tz2)}.pmtiles`,
    );
    pmtilesPath3 = path.join(
      tilesetDir,
      `${tz3}-${x >> (z - tz3)}-${y >> (z - tz3)}.pmtiles`,
    );
  }

  const candidates = [
    ...new Set([pmtilesPath1, pmtilesPath2, pmtilesPath3].filter(Boolean)),
  ];
  // ex, pmtilesPath1 = /home/20260413coesite4PmtilesLocaltest/pmtiles/UniteStreetMapVector/6-35-31.pmtiles
  // pmtilesPath2 = /home/20260413coesite4PmtilesLocaltest/pmtiles/UniteStreetMapVector/5-17-15.pmtiles
  // pmtilesPath3 = /home/20260413coesite4PmtilesLocaltest/pmtiles/UniteStreetMapVector/4-8-7.pmtiles

  const cachedPath = candidates.find(candidate => pmtilesPool[candidate]);

  if (cachedPath) {
    return pmtilesPool[cachedPath].pmtiles;
  }

  const existingPath = candidates.find(candidate => fs.existsSync(candidate));

  if (existingPath) {
    pmtilesPool[existingPath] = {
      pmtiles: new PMTiles(new LocalPMTilesSource(existingPath)),
      // openTime: new Date(),
    };
    return pmtilesPool[existingPath].pmtiles;
  }

  throw new Error(`${candidates[0] || tilesetDir} was not found.`);
};

const getTile = async (pmtiles, z, x, y) => {
  try {
    const r = await pmtiles.getZxyAttempt(z, x, y);
    if (!r) return null;
    return {
      tile: Buffer.from(r.data),
      headers: {
        'Cache-Control': r.cacheControl,
        Expires: r.expires,
      },
    };
  } catch (err) {
    throw err;
  }
};

// Creating a token cache.
// stdTTL is the cache expiration time (in seconds).
// Expired data is cleaned up every 120 seconds.
const tokenCache = new NodeCache({ stdTTL: 30, checkperiod: 120 });
const tokenIPSet = new Set(); // To record IP
const ipTimeoutMap = new Map(); // IP -> Timeout ID

function addOrRefreshIP(ip, ttlMs = 60 * 1000) {
  if (!ip) return;

  // If the IP is registered, remove it.
  if (ipTimeoutMap.has(ip)) {
    clearTimeout(ipTimeoutMap.get(ip));
  }

  // Adding IP to Set
  tokenIPSet.add(ip);

  const timeoutId = setTimeout(() => {
    tokenIPSet.delete(ip);
    ipTimeoutMap.delete(ip);
  }, ttlMs);

  // Save in Map for future updates.
  ipTimeoutMap.set(ip, timeoutId);
}

function getClientIP(req) {
  const rawIP = req.socket?.remoteAddress;

  if (typeof rawIP === 'string' && rawIP.startsWith('::ffff:')) {
    return rawIP.replace(/^::ffff:/, '');
  }

  return rawIP;
}

async function validateToken(token, clientIP) {
  let isTokenValid = false;

  // Only if a token exists, the following code checks its validity.
  if (!token) return isTokenValid;

  // 1. First, check if there is valid token information in the cache.
  if (tokenCache.has(token)) {
    isTokenValid = tokenCache.get(token);
    if (isTokenValid) addOrRefreshIP(clientIP);
    return isTokenValid;
  }

  // 2. If not found in cache, check via API as usual.
  let cleanedToken = token;
  const parts = token.split('?token=');
  if (parts.length > 1 && parts[0] === parts[1]) {
    cleanedToken = parts[0];
  }

  const generateTokenUrl =
    'https://dev-geoportal.dfs.un.org/arcgis/sharing/rest/generateToken';
  const params = new URLSearchParams({
    token: cleanedToken,
    serverUrl: 'https://dev-geoportal.dfs.un.org/unvt/rest/services',
    f: 'json',
  }).toString();
  const fullUrl = `${generateTokenUrl}?${params}`;

  try {
    const response = await axios.get(fullUrl);
    if (response.data && !response.data.error) {
      isTokenValid = true;
      addOrRefreshIP(clientIP);
    }
  } catch (error) {
    isTokenValid = false;
  }

  // 3. Save the validation result from the API to the cache.
  tokenCache.set(token, isTokenValid);
  return isTokenValid;
}

function validateTileRequest(t, z, x, y, res) {
  // Input validation - Security enhancement to prevent path traversal and invalid tile requests
  // Restrict tileset name to alphanumeric characters, hyphens, and underscores only
  if (!t || typeof t !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(t)) {
    res.status(400).send('Invalid tileset name');
    return false;
  }
  // Validate zoom level within reasonable bounds (0-30) to prevent resource exhaustion
  if (isNaN(z) || z < 0 || z > 30) {
    res.status(400).send('Invalid zoom level');
    return false;
  }
  // Validate X coordinate is within valid range for the zoom level
  if (isNaN(x) || x < 0 || x >= Math.pow(2, z)) {
    res.status(400).send('Invalid x coordinate');
    return false;
  }
  // Validate Y coordinate is within valid range for the zoom level
  if (isNaN(y) || y < 0 || y >= Math.pow(2, z)) {
    res.status(400).send('Invalid y coordinate');
    return false;
  }

  return true;
}

/* GET Tile. */
router.get(`/zxy/:t/:z/:x/:y.pbf`, async function (req, res) {
  try {
    const t = req.params.t;
    const z = parseInt(req.params.z, 10);
    const x = parseInt(req.params.x, 10);
    const y = parseInt(req.params.y, 10);

    if (!validateTileRequest(t, z, x, y, res)) return;

    const clientIP = getClientIP(req);
    const token = req.query?.token || null;
    const isTokenValid = await validateToken(token, clientIP);

    // if (!req.session.userId) {
    const whiteList = [
      // 'https://ubukawa.github.io/cors-cookie',
      'https://dev-geoportal.dfs.un.org/',
    ];
    const noSession = !req.session?.userId;
    const invalidReferer = !(
      req.headers.referer &&
      whiteList.some(value => req.headers.referer.includes(value))
    );
    const invalidToken = !(token && isTokenValid);
    const ipAllowed = !token && tokenIPSet.has(clientIP);

    if (noSession && invalidReferer && invalidToken && !ipAllowed) {
      // Redirect unauthenticated requests to home page
      // res.redirect('/')
      res
        .status(401)
        .send(`Please log in to get: /zxy/${t}/${z}/${x}/${y}.pbf`);
      return;
    }

    const pmtiles = await getPMTiles(t, z, x, y);
    const r = await getTile(pmtiles, z, x, y);

    if (r && r.tile) {
      res.set('content-type', 'application/vnd.mapbox-vector-tile');

      if (r.headers['Cache-Control']) {
        res.set('cache-control', r.headers['Cache-Control']);
      }

      if (r.headers.Expires) {
        res.set('expires', r.headers.Expires);
      }

      res.send(r.tile);
    } else {
      throw new Error(`tile not found: /zxy/${t}/${z}/${x}/${y}.pbf`);
    }
  } catch (err) {
    res.status(404).send(err.message);
  }
});

module.exports = router;

作成した、コードに置き換えて試したところ、無事に地図が表示されました。

まとめ

coesite4をPMTilesで使用できるようにしました。

Reference

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?