事前変換不要!DuckDBにGISデータを読み込んでベクタータイルを表示する

2025-11-14

事前変換不要!DuckDBにGISデータを読み込んでベクタータイルを表示する

CTOの井上です。弊社では現在、地理空間データを含む多様なデータを扱う新しいWeb上でのBIツールの開発に取り組んでおり、その一環として、GISデータの可視化機能に関するPoCを進めています。

WebGISにおける地図上でのデータ可視化においては、GeoJSON形式などでそのまま表示する方法もありますが、データ量が多くなると表示パフォーマンスが低下するという課題があります。そのため、より軽量で効率的な描画が可能なベクタータイル(Vector Tile)形式の利用が一般的です。中でもMapbox Vector Tile(MVT)は広く利用されています。

しかし、MVTを利用するためには、事前にGeoJSON等からの変換処理を行い、専用のタイルファイルを生成し、何らかのサーバーで配信する必要があります。特にBIツールでは多様なデータを読み込んで可視化できることが必要であり、事前変換をしなければいけないのでは大変です。

このPoCでは、そうした課題を解決するアプローチとして、DuckDBに格納されたGISデータからベクタータイルをオンデマンドで生成し、そのままMapLibre GL JSで表示するという構成を検証しました。事前変換を行わず、DuckDBのクエリ結果を直接タイルとして使用することで、以下のようにシンプルかつ柔軟な地図描画の仕組みを実現しました。

実装やデモページはこちらからお試しください。

タイルの表示はMVTへの事前変換が面倒

地理空間データをWeb上で可視化する際、最もシンプルな方法の一つは、GeoJSONなどの形式でデータを読み込み、地図ライブラリ上にそのまま描画するというものです。しかし、この手法には明確な課題があります。たとえば、全国規模の行政界ポリゴンや、数十万件以上のポイントデータなど、データ量が大きい場合は、クライアント側の描画処理が重くなり、地図の表示や操作が著しく遅くなることがあります。

この課題を解決するためによく使われるのが、「タイル」という概念です。タイルとは、地図全体をズームレベルと位置に応じて小さな正方形に分割し、必要な部分だけを逐次読み込む仕組みのことです。地図タイルには、ラスタ形式(画像)とベクター形式(ジオメトリ情報)の2種類がありますが、最近では描画の柔軟性やスタイルの動的変更が可能なベクタータイルの利用が増えています。

ベクタータイルのデータフォーマットとして特に広く採用されているのが、MVTです。これはベクタータイルを効率的に配信・描画するためのPBF(Protocol Buffers)バイナリ形式で、Mapboxによって定義され、MapboxやMapLibre GL JSを中心とした多くの地図ライブラリが対応しています。MVTはジオメトリを効率的に圧縮できるため、特に大きなデータでGeoJSONを直接表示するより高速です。

一方で、MVTを用いるには事前変換と配信環境の整備が必要で、実装・運用コストが高くなる傾向があります。また、描画パフォーマンスを重視してMVTを選択すると、データの可変性(更新の容易さ)や即時性が犠牲になるというトレードオフに直面します。

今回のPoCでは、このギャップを埋める手段として、DuckDBを用いたオンデマンドなベクタータイル生成に取り組みました。

DuckDBとは?

DuckDBは、「SQLite for Analytics」とも呼ばれる、軽量かつ高速な列指向型の分析向けデータベースエンジンです。C++で実装されており、シングルバイナリで動作する設計ながら、大規模なデータに対しても非常に高速なクエリ処理性能を発揮します。分析用途で主に利用されるPostgreSQLやSnowflake、BigQueryなどと比較しても、依存なくローカル環境で動作し、セットアップ不要かつ低リソースで扱える点が特徴です。

近年、DuckDBが注目を集める理由のひとつに、多様なファイルフォーマットや外部データソースを透過的に扱える点があります。たとえば、CSVやParquet、JSONといった形式に対して、SQLクエリで直接読み込み・集計・JOINが可能です。PostGIS形式やGeoJSON、SQLite、クラウドストレージ上のデータにも対応可能です。

こうした柔軟性は、「分析のためにETLパイプラインをわざわざ構築しなくてよい」という点で魅力的です。

さらに、DuckDB-WASM(WebAssembly版)の登場により、DuckDBをブラウザ上でそのまま実行できるようになりました。これにより、Webアプリケーションの中でDuckDBをバックエンド的に利用し、ブラウザで直接データを読み込み・変換・可視化するユースケースが急速に広がっています。

今回のPoCでも、このDuckDBの「どこでも動く軽量性」と「あらゆる形式を扱える柔軟性」を活かし、ブラウザ上でDuckDB-WASMを使用し、地理空間データをDuckDBに格納し、その後MVTへの変換を挟まずにベクタータイルとして配信するアプローチを検証しました。

DuckDBに地理空間データを格納する

ブラウザ上でDuckDB-WASMをインメモリで使用することを考えます。まずDuckDBを初期化し、地理空間データを格納します。以下のようなSQLで様々なファイルの読み込みが可能です。読み込み後に空間インデックス(R-Tree)を作成しておくと、後述のタイル取得処理が高速化されます。

CREATE TABLE example AS SELECT * FROM st_read("
https://example.com/hoge.geojson
");
CREATE INDEX example_idx ON example USING RTREE (geom);

DuckDBを初期化し、HTTPでGeoJSONを読み込むTypeScriptコード例です。

import * as duckdb from "@duckdb/duckdb-wasm";
import eh_worker from "@duckdb/duckdb-wasm/dist/duckdb-browser-eh.worker.js?url";
import mvp_worker from "@duckdb/duckdb-wasm/dist/duckdb-browser-mvp.worker.js?url";
import duckdb_wasm_eh from "@duckdb/duckdb-wasm/dist/duckdb-eh.wasm?url";
import duckdb_wasm from "@duckdb/duckdb-wasm/dist/duckdb-mvp.wasm?url";

const MANUAL_BUNDLES: duckdb.DuckDBBundles = {
  mvp: { mainModule: duckdb_wasm, mainWorker: mvp_worker },
  eh: { mainModule: duckdb_wasm_eh, mainWorker: eh_worker },
};

const bundle = await duckdb.selectBundle(MANUAL_BUNDLES);
const worker = new Worker(bundle.mainWorker!);
const logger = new duckdb.ConsoleLogger();
const db = new duckdb.AsyncDuckDB(logger, worker);
await db.instantiate(bundle.mainModule, bundle.pthreadWorker);

const conn = await db.connect();
await conn.execute("LOAD spatial;");

await conn.execute(`CREATE TABLE example AS SELECT * FROM st_read("
https://example.com/hoge.geojson);");`
);
await conn.execute(`CREATE INDEX example_idx ON example USING RTREE (geom);`);

読み込むファイルフォーマットについて補足します。DuckDBはGeoJSONをはじめ多くのフォーマットに対応しますが、フォーマットによってはデータサイズが大きいと読み込み時間が伸びます。たとえば10万件以上のFeatureを持つ約800MBのGeoJSONをDuckDB-WASMで読み込むと、環境によっては30秒以上かかることがあります。

初回読み込みを高速化するには、ジオメトリの最適化に加え、事前にGeoJSONをParquetに変換して使用するのが効果的です。DuckDBでも以下のように変換可能です。

CREATE TABLE example AS SELECT * FROM st_read("
https://example.com/hoge.geojson
);");
COPY example TO "hoge.parquet";

DuckDBからGeoJSONとしてタイルを取得する

次に、DuckDBのテーブルから任意のタイル(Z/X/Y)の範囲にあるレコードを取得します。Z、X、Yをパラメータに取り、タイル領域でクリップしたジオメトリをGeoJSONで返すSQLは以下のとおりです。

SELECT
  ST_AsGeoJSON(
    ST_Intersection(
      ST_Transform(geom, 'EPSG:4326', 'EPSG:3857', true),
      ST_TileEnvelope(${z}, ${x}, ${y})
    )
  ) AS geojson,
  name,       -- example property
  description -- example property
FROM example
WHERE ST_Intersects(
  ST_Transform(geom, 'EPSG:4326', 'EPSG:3857', true),
  ST_TileEnvelope(${z}, ${x}, ${y})
);

このクエリで使用している主な関数(spatial拡張)

  • ST_TileEnvelope:Z/X/Yから当該タイルの外接矩形(Web Mercator)を生成
  • ST_Transform:WGS84(EPSG:4326)からWeb Mercator(EPSG:3857)に変換(always_xy=true)
  • ST_Intersects:交差判定
  • ST_Intersection:交差部分のジオメトリを取得(タイル外のはみ出しを除去)
  • ST_AsGeoJSON:ジオメトリをGeoJSON文字列へ変換

TypeScriptでの取得例(FeatureCollectionで返却)

async function queryTile(
  conn: any,
  table: string,
  z: number,
  x: number,
  y: number,
  columns: string[] = []
) {
  const props = columns.length
    ? 
columns.map
((c) => `"${c.replace(/\"/g, '""')}"`).join(', ')
    : '*';

  const sql = `SELECT
    ST_AsGeoJSON(
      ST_Intersection(
        ST_Transform(geom, 'EPSG:4326', 'EPSG:3857', true),
        ST_TileEnvelope(${z}, ${x}, ${y})
      )
    ) AS geojson,
    ${props}
  FROM ${table}
  WHERE ST_Intersects(
    ST_Transform(geom, 'EPSG:4326', 'EPSG:3857', true),
    ST_TileEnvelope(${z}, ${x}, ${y})
  );`;

  const result = await conn.query(sql);
  const rows = result.toArray();

  const features = rows
    .map((row: any) => {
      if (!row.geojson) return null;
      let geometry;
      try {
        geometry = JSON.parse(row.geojson as string);
      } catch (e) {
        console.error('Error parsing GeoJSON:', e);
        return null;
      }
      const properties = Object.fromEntries(
        Object.entries(row).filter(([k]) => k !== 'geom' && k !== 'geojson')
      );
      return { type: 'Feature' as const, geometry, properties };
    })
    .filter(Boolean);

  return { type: 'FeatureCollection' as const, features };
}

この方法はシンプルですが、MapLibre GL JSのカスタムプロトコルではベクタータイルとしてMVTのみを扱うため、GeoJSONをそのまま表示できません。したがって、GeoJSON→MVTの変換工程が必要となり、パフォーマンス上のボトルネック増加につながり得ます。

DuckDBからMVTとしてタイルを取得する

実は、DuckDB-WASM v1.30.1以降、ST_AsMVTST_AsMVTGeom が利用可能になりました!

これらを使うことで、DuckDBから直接MVT(PBF)を生成できます。spatial拡張を有効化していれば、PostGIS互換の関数群(ST_AsMVTST_AsMVTGeomST_TileEnvelope など)により、タイル境界でのクリッピングからPBFへのエンコードまでをSQLで完結できます。

以下はSQL例です。

WITH tile_data AS (
  SELECT {
    'geometry': ST_AsMVTGeom(
      ST_Transform(
        ST_SimplifyPreserveTopology("geom", ${simplify}),
        'EPSG:4326', 'EPSG:3857', true
      ),
      ST_Extent(ST_TileEnvelope(${z}, ${x}, ${y})),
      4096,    -- extent(MVT標準)
      0,       -- buffer(必要に応じて 64 などを検討)
      false
    ),
    -- プロパティはMVTが受け付ける型へキャスト
    'name': TRY_CAST("name" AS VARCHAR),
    'value': TRY_CAST("value" AS INTEGER)
  } AS feature
  FROM ${table}
  WHERE "geom" IS NOT NULL
    AND ST_Intersects(
      ST_Transform("geom", 'EPSG:4326', 'EPSG:3857', true),
      ST_TileEnvelope(${z}, ${x}, ${y})
    )
  LIMIT 50000
)
SELECT ST_AsMVT(
  feature,
  'default', -- レイヤー名
  4096,
  'geometry'
) AS mvt
FROM tile_data
WHERE feature.geometry IS NOT NULL AND NOT ST_IsEmpty(feature.geometry);

実装上のポイントを解説します。

  • 座標順序の罠を避けるため、ST_Transform の第4引数 always_xy を true に設定し、常に lon,lat 順序とする
  • extent はタイル内の仮想座標解像度(整数格子)。MVTでは事実上 4096 が標準
  • buffer は隣接タイル跨ぎの見切れ防止の余白(extent と同じ座標系)。例: extent=4096buffer=64 など
  • MapLibre GL JSの tileSize(既定 512px)は画面描画の論理サイズであり、MVTの extentbuffer とは別概念
  • MVTプロパティ型は限定的(文字列、整数、浮動小数、真偽値のみ)。DuckDBの多様な型は TRY_CAST で安全にキャスト
  • フィーチャー数が多い場合は LIMIT とズーム別の簡略化でタイルサイズを抑制(例:ST_SimplifyPreserveTopology
  • ST_IsEmpty で空ジオメトリを除外

DuckDB-WASMのクエリ結果として Uint8Array(PBF)を得て、MapLibre GL JSのカスタムプロトコルハンドラからそのまま返却できます。

ただし、注意点があります。WASMメモリの再配置による ArrayBuffer detachment を避けるため、返却前に安全なコピーを取ることが必要です。

const result = await conn.query(/* 上記SQL */);
const raw = result.get(0)?.mvt as Uint8Array | undefined;
if (!raw || raw.length === 0) return new Uint8Array();
// detachment対策で安全なコピー
return new Uint8Array(raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength));

MapLibreでタイルを表示する

MapLibre GL JSには「プロトコル」機能があります。これを使用し、独自スキームのURLに対して独自のバイト列を返すハンドラを登録することができます。vector ソースの場合はMVTのPBF、raster ソースの場合は画像のバイト列を返せばOKです。

これにより、HTTP以外の経路やオンデマンド生成(今回のようなブラウザ内DuckDB-WASM)でも、通常の tiles 設定と同様に扱えます。

function parseDuckDBTileUrl(url: string): { tableSpec: string; zxy: { z: number; x: number; y: number } } | null {
  const m = url.match(/^duckdb:\/\/([^\/]+)\/(\d+)\/(\d+)\/(\d+)\.pbf$/);
  if (!m) return null;
  const [, tableSpec, z, x, y] = m;
  return { tableSpec, zxy: { z: +z, x: +x, y: +y } };
}

async function generateVectorTile(
  conn: AsyncDuckDBConnection,
  tableName: string,
  zxy: { z: number; x: number; y: number },
  geomCol = 'geom',
  selectedProps: string[] = [],
  columnTypes?: Record<string, string | null>
): Promise<Uint8Array> {
  const simplify = calculateSimplifyTolerance(zxy.z);

	const safeTableName = tableName.replace(/"/g, '""');
  const propStruct = selectedProps
    .map((col) => {
      const type = columnTypes?.[col]?.toUpperCase() ?? '';
      const key = col.replace(/'/g, "''");
      const isComplex = /(STRUCT|LIST|\[\]|MAP|JSON|UNION)/.test(type);
      const intTarget = /^(TINYINT|SMALLINT|UTINYINT|USMALLINT)$/.test(type)
        ? 'INTEGER'
        : /^(HUGEINT|UHUGEINT|UINTEGER|UBIGINT)$/.test(type)
        ? 'BIGINT'
        : null;
      const expr = isComplex
        ? `TRY_CAST("${col}" AS VARCHAR)`
        : intTarget
        ? `TRY_CAST("${col}" AS ${intTarget})`
        : `"${col}"`;
      return `'${key}': ${expr}`;
    })
    .join(', ');

  const sql = `WITH tile_data AS (
    SELECT {
      'geometry': ST_AsMVTGeom(
        ST_Transform(ST_SimplifyPreserveTopology("${geomCol}", ${simplify}), 'EPSG:4326', 'EPSG:3857', true),
        ST_Extent(ST_TileEnvelope(${zxy.z}, ${zxy.x}, ${zxy.y})),
        4096,
        0,
        false
      )${propStruct ? `, ${propStruct}` : ''}
    } AS feature
    FROM "${safeTableName}"
    WHERE "${geomCol}" IS NOT NULL
      AND ST_Intersects(
        ST_Transform("${geomCol}", 'EPSG:4326', 'EPSG:3857', true),
        ST_TileEnvelope(${zxy.z}, ${zxy.x}, ${zxy.y})
      )
    LIMIT 50000
  )
  SELECT ST_AsMVT(feature, 'default', 4096, 'geometry') AS mvt
  FROM tile_data
  WHERE feature.geometry IS NOT NULL AND NOT ST_IsEmpty(feature.geometry);`;

  const result = await conn.query(sql);
  const tile = result.get(0)?.mvt as Uint8Array | undefined;
  if (!tile || tile.length === 0) return new Uint8Array();
  return new Uint8Array(tile.buffer.slice(tile.byteOffset, tile.byteOffset + tile.byteLength));
}

maplibregl.addProtocol('duckdb', async (params) => {
  const parsed = parseDuckDBTileUrl(params.url);
  if (!parsed) return { data: new Uint8Array() };
  const { tableSpec, zxy } = parsed;
  const mvt = await generateVectorTile(conn, tableSpec, zxy, 'geom', ['name', 'value']);
  return { data: mvt };
});

function calculateSimplifyTolerance(zoom: number): number {
  if (zoom >= 15) return 0;
  const maxSimplify = 0.001;
  const t = Math.max(0, Math.min(1, zoom / 15));
  return +(maxSimplify * (1 - t)).toFixed(6);
}

検証結果

上記を実装し、国土数値情報の都道府県のポリゴンデータで検証したところ、以下のように表示されました。

実装やデモページはこちらからお試しください。

まとめ

本稿では、ブラウザ内でDuckDB-WASMを用い、SQLの結果をその場でMVTに変換し、MapLibre GL JSのプロトコル機能で描画する手法を示しました。事前変換や専用タイル配信インフラに依存せず、オンデマンドで高速かつ柔軟に可視化できます。

DuckDBのspatial拡張と ST_AsMVT / ST_AsMVTGeom を直接活用し、GeoJSON→MVT変換の中間工程を排除し、addProtocol によって duckdb:// スキーム経由でオンデマンドMVT供給を実現しました。これにより、

  • クエリ変更がそのままタイルに反映され、探索的分析の反復が速い
  • タイル生成・配信パイプラインやストレージ運用の負担を最小化
  • 任意スキーマ、派生列、空間結合、集計をSQLで直書きできる

といった柔軟性とパフォーマンスの両立を実現できます。

今後の課題:

  • ブラウザメモリに依存するため、超大規模データは前処理(Parquet化、列削減、事前集約)が推奨。複雑なジオメトリや極小ポリゴンが密集するデータは描画スピードが極端に低下するため最適化が必要
  • カスタムプロトコルはリクエストキャンセルや同時実行制御への配慮が必要
  • さらなるパフォーマンス向上にはプロトコル層でのMVTキャッシュ実装が有効

参考資料

参考:GeoJSONからベクタータイルへの変換

DuckDBから取得したGeoJSONデータを、MapLibre GL JSが理解できるベクタータイル(MVT/PBF)に変換する場合は、geojson-vtvt-pbf が便利です。

import geojsonvt from 'geojson-vt';
import vtpbf from 'vt-pbf';

export function geojsonToVectorTile(
  features: Feature[],
  z: number,
  x: number,
  y: number
): Uint8Array {
  const tileIndex = geojsonvt({ type: 'FeatureCollection', features }, {
    generateId: true,
    indexMaxZoom: z,
    maxZoom: z,
    buffer: 0,
    tolerance: 0,
    extent: 4096
  });

  const tile = tileIndex.getTile(z, x, y);
  if (!tile) return new Uint8Array();
  return vtpbf.fromGeojsonVt({ default: tile });
}
Japanese

Eukaryaでは様々な職種で採用を行っています!OSSにコントリビュートしていただける皆様からの応募をお待ちしております!

Eukarya 採用ページ

Eukarya is hiring for various positions! We are looking forward to your application from everyone who can contribute to OSS!

Eukarya Careers

Eukaryaは、Re:Earthと呼ばれるWebGISのSaaSの開発運営・研究開発を行っています。Web上で3Dを含むGIS(地図アプリの公開、データ管理、データ変換等)に関するあらゆる業務を完結できることを目指しています。ソースコードはほとんどOSSとしてGitHubで公開されています。

Re:Earth / ➔ Eukarya / ➔ note / ➔ GitHub

Eukarya is developing and operating a WebGIS SaaS called Re:Earth. We aim to complete all GIS-related tasks including 3D (such as publishing map applications, data management, and data conversion) on the web. Most of the source code is published on GitHub as OSS.

Re:Earth / ➔ Eukarya / ➔ Medium / ➔ GitHub