DuckDBにMVTが来た!フロントで完結するベクトルタイル配信

に公開

先日、DuckDBで ST_AsMVTST_AsMVTGeom関数が利用できるようになりました。
https://github.com/duckdb/duckdb-spatial/issues/241

ということは、DuckDB WASMを用いることで、ファイルをアップロード、格納、MVTの配信がフロントエンドで完結するということなのでは?と思ったのが始まりでした。
恥ずかしながらDuckDBというツール自体をあまり使ったことがなかったので、勉強がてら実装してみました。
(使用するツール・フレームワークはSveltekit、DuckDB WASMです。)

本編

DuckDB WASMの使い方はありふれていると思うので導入部分は割愛します。
今回やりたいことは「ブラウザで適当にGeoなファイルを投げてMVTを配信して表示する」です。

出来上がったものがこちらです。

https://groovyjovy.github.io/duckdb_mvt/

リポジトリはこちらです。

https://github.com/groovyjovy/duckdb_mvt_example/tree/main

この2つのファイルを読んでおけばひととおり網羅できます。
https://github.com/groovyjovy/duckdb_mvt_example/blob/main/my-app/src/routes/%2Bpage.svelte

https://github.com/groovyjovy/duckdb_mvt_example/blob/main/my-app/src/lib/duckdb-spatial.svelte.ts

さて、以下のようにコードを書いていきます。全てを書くことはせず、要所を解説します。

  • Fileを受け取ってDuckDBにテーブルをつくります。テーブル名はファイル名をそのまま入れます。
  • 投げ込まれるファイルの座標系はEPSG:4326前提で作っています。(DuckDBは内部的に座標系を保持していないので変換するとなると結構大変です...)
duckdb-spatial.svelte.ts

// GeoファイルをDuckDBテーブルとして読み込む
async function loadGeoFile(file: File, tableName?: string): Promise<TableInfo> {
    if (!db || !conn) {
        throw new Error('DuckDB is not initialized');
    }

    isLoading = true;
    error = null;

    try {
        // テーブル名の生成(指定されていない場合)
        // ダブルクォートを除去してからサニタイズ
        const sanitizedFileName = file.name.replace(/"/g, '').replace(/[^a-zA-Z0-9_]/g, '_').replace(/\.(geojson|json|gpkg|shp)$/i, '');
        const finalTableName = tableName || sanitizedFileName;

        // ファイルをDuckDBに登録
        await db.registerFileHandle(
            file.name, 
            file, 
            duckdb.DuckDBDataProtocol.BROWSER_FILEREADER, 
            true
        );

        // ファイル情報を保存
        registeredFiles.set(file.name, {
            name: file.name,
            file: file,
            registered: true
        });

        // ST_Readでテーブルを作成(EPSG:4326のまま保存)
        await conn.query(`
            CREATE OR REPLACE TABLE "${finalTableName}" AS
            SELECT * FROM ST_Read('${file.name}')
        `);

        // ジオメトリカラムを検出してR-treeインデックスを作成
        const schema = await conn.query(`DESCRIBE "${finalTableName}"`);
        const columns = schema.toArray().map(row => row.toJSON());
        let geomColumnName = 'geom';

        for (const col of columns) {
            const colType = col.column_type.toUpperCase();
            if (colType.includes('GEOMETRY') || colType.includes('POINT') ||
                colType.includes('LINESTRING') || colType.includes('POLYGON')) {
                geomColumnName = col.column_name;
                break;
            }
        }

        // R-treeインデックスを作成
        try {
            await conn.query(`
                CREATE INDEX "${finalTableName}_rtree_idx"
                ON "${finalTableName}" USING RTREE ("${geomColumnName}")
            `);
            console.log(`Created R-tree index on ${finalTableName}.${geomColumnName}`);
        } catch (e) {
            console.warn(`Could not create R-tree index: ${e}`);
        }

        // テーブル情報を取得
        const tableInfo = await analyzeTable(finalTableName, file.name);
        
        // テーブル情報を保存
        tables.set(finalTableName, tableInfo);

        return tableInfo;
    } catch (e) {
        error = `ファイル読み込みエラー: ${e}`;
        throw e;
    } finally {
        isLoading = false;
    }
}

本題のMVTを配信する関数を書きます。

  • zxy座標と、テーブル名を引数としてタイルリクエストを受け取ります。
    • 動的にテーブル名を生成しているためこうする必要があります。
  • 戻り値の方がanyなのでUint8Arrayと信じ込ませて返します。(Typed DuckDBが欲しい...)
duckdb-spatial.svelte.ts
// 指定したテーブルからMVTを生成
async function generateMVT(
    tableName: string,
    z: number, 
    x: number, 
    y: number,
): Promise<Uint8Array> {
    if (!conn) throw new Error('Connection not initialized');
    
    // テーブル情報を取得
    const tableInfo = tables.get(tableName);
    if (!tableInfo) {
        console.error(`Table ${tableName} not found`);
        return new Uint8Array();
    }

    try {
        // MVT生成クエリの実行
        const result = await conn.query(`
            WITH transformed_data AS (
                SELECT 
                    ${tableInfo.columns.length > 0 ? tableInfo.columns.map(col => `"${col.name}"`).join(', ') + ',' : ''}
                    ST_Transform("${tableInfo.geomColumn}", 'EPSG:4326', 'EPSG:3857', true) as geom_3857
                FROM "${tableName}"
                WHERE ST_Intersects(
                    "${tableInfo.geomColumn}", 
                    ST_Transform(ST_TileEnvelope(${z}, ${x}, ${y}), 'EPSG:3857', 'EPSG:4326', true)
                )
                AND ST_GeometryType("${tableInfo.geomColumn}") != 'GEOMETRYCOLLECTION'
            ),
            mvt_data AS (
                SELECT 
                    ST_AsMVTGeom(
                        geom_3857,
                        ST_Extent(ST_TileEnvelope(${z}, ${x}, ${y})),
                        4096,
                        256,
                        false
                    ) AS mvt_geom,
                    ${tableInfo.columns.length > 0 ? tableInfo.columns.map(col => `"${col.name}"`).join(', ') : '1 as dummy'}
                FROM transformed_data
            )
            SELECT ST_AsMVT(mvt_data, '${tableName}') AS mvt
            FROM mvt_data
        `);

        const mvtData = result.toArray()[0].toJSON()?.mvt;
        if (!mvtData) {
            return new Uint8Array();
        }

        return mvtData as Uint8Array;
    } catch (error) {
        console.error(`MVT generation error for ${tableName}:`, error);
        return new Uint8Array();
    }
}

次にMVTを受け取るためのMaplibreのコードを書きます。AddProtocol芸で無理やり繋ぎます。便利ですね。
https://qiita.com/mg_kudo/items/cc88be1ed46fa77871fd

  • リクエストを解体してDuckDBにクエリします。
  • MVTで帰ってきていると信じて値をそのまま返します。
  • 余談ですが、ブラウザからfetchしているわけではないので、開発者ツールを除いてもタイルリクエストが送信されていないように見えます。
+page.svelte
// DuckDBプロトコルハンドラー
const duckdbLoadFn: AddProtocolAction = async (params) => {
    try {
        // URLからタイル座標とレイヤー名を抽出
        // duckdb://layer_name/{z}/{x}/{y} の形式を想定
        const urlParts = params.url.replace('duckdb://', '').split('/');
        if (urlParts.length !== 4) {
            throw new Error('Invalid URL format');
        }
        const [layer, z, x, y] = urlParts;
        const pbf = await duckdb.generateMVT(
            layer,
            parseInt(z, 10),
            parseInt(x, 10),
            parseInt(y, 10)
        );

        return { data: pbf };
    } catch (error) {
        console.error('MVT generation error:', error);
        return { data: new Uint8Array() };
    }
};

地図のコンポーネントを書いていきます。動的にソースが足されていくので、それに合わせて書いていきます。Svelte MapLibre GLを利用して書いています。

https://qiita.com/ciscorn/items/6f97f681f31cafe513bd

+page.svelte
<!-- 地図本体 -->
<div class="flex-1">
    <!-- プロトコルハンドラーの登録 -->
    <Protocol scheme="duckdb" loadFn={duckdbLoadFn} />
    
    <MapLibre
        bind:map
        center={mapCenter}
        zoom={mapZoom}
        style="https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
        class="w-full h-full"
    >
        <NavigationControl position="top-right" />
        <ScaleControl />
        
        <!-- 動的レイヤーの生成 -->
        {#each [...duckdb.tableMap.entries()] as [tableName, tableInfo]}
            <VectorTileSource
                id={`source-${tableName}`}
                tiles={[`duckdb://${tableName}/{z}/{x}/{y}`]}
                maxzoom={18}
                minzoom={5}
            >
                <!-- ポイントレイヤー(Pointジオメトリが含まれる場合のみ) -->
                {#if tableInfo.geometryTypes.has('Point')}
                    <CircleLayer
                        id={`layer-point-${tableName}`}
                        sourceLayer={tableName}
                        filter={['==', '$type', 'Point']}
                        layout={{
                            'visibility': layerVisibility[tableName] ? 'visible' : 'none'
                        }}
                        paint={{
                            'circle-radius': 8,
                            'circle-color': tableInfo.color,
                            'circle-stroke-width': 2,
                            'circle-stroke-color': '#ffffff'
                        }}
                    />
                {/if}
                
                <!-- ラインレイヤー(LineStringジオメトリが含まれる場合のみ) -->
                {#if tableInfo.geometryTypes.has('LineString')}
                    <LineLayer
                        id={`layer-line-${tableName}`}
                        sourceLayer={tableName}
                        filter={['==', '$type', 'LineString']}
                        layout={{
                            'visibility': layerVisibility[tableName] ? 'visible' : 'none'
                        }}
                        paint={{
                            'line-color': tableInfo.color,
                            'line-width': 3
                        }}
                    />
                {/if}
                
                <!-- ポリゴンレイヤー(Polygonジオメトリが含まれる場合のみ) -->
                {#if tableInfo.geometryTypes.has('Polygon')}
                    <FillLayer
                        id={`layer-fill-${tableName}`}
                        sourceLayer={tableName}
                        filter={['==', '$type', 'Polygon']}
                        layout={{
                            'visibility': layerVisibility[tableName] ? 'visible' : 'none'
                        }}
                        paint={{
                            'fill-color': tableInfo.color,
                            'fill-opacity': 0.3,
                            'fill-outline-color': tableInfo.color
                        }}
                    />
                {/if}
            </VectorTileSource>
        {/each}
    </MapLibre>
</div>

UIなどを作って完成です。GeoJSON、GeoPackage、KML、FlatGeobufなどを放り込んでいい感じにタイリングしてくれるのはいいですね。

Discussion