5
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?

TanStack Router × MapLibre GL JS:地図の状態をURLと同期させる

Posted at

これは MIERUNE Advent Calendar 2025 の17日目の記事です。
昨日は @natsukino8 さんによる QGISをお揃いのUIにする でした。

はじめに

地図の状態をURLと同期させる手法は様々ありますが、今回はTanStack Routerでの実装例になります。メインのフレームワークはReactを使います。
ルーティングの機能が欲しいだけのためにNext.jsを使うのではなく、その場合はルーティングのライブラリーだけ使えば良い、というのを実践してみました。

SvelteKitの例は過去に作りましたので、そちらを参照ください。

作ったもの

地図を動かすと、中心座標が表示されます。URLのクエリパラメーターも変化しますので、ブラウザの更新をしても最後の状態が保持されています。

スクリーンショット 2025-12-17 11.22.24.png

作り方

前提としてNode.jsはインストールされているものとします。

プロジェクト作成とライブラリのインストール

Vite で React + TypeScript のプロジェクトを作成し、必要なライブラリを入れます。

# プロジェクト作成
npm create vite@latest my-map-app -- --template react-ts
cd my-map-app

# 依存関係のインストール
# zod: URLパラメータのバリデーション用
# maplibre-gl: 地図表示用
npm install @tanstack/react-router maplibre-gl zod

# 開発用ライブラリ(Viteプラグイン、DevTools、CLI)
npm install -D @tanstack/router-plugin @tanstack/router-devtools @tanstack/router-cli

Viteの設定

プラグインを設定します。

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { tanstackRouter } from '@tanstack/router-plugin/vite'

export default defineConfig({
  plugins: [
    tanstackRouter(), // react() より前に記述するのがポイント
    react(),
  ],
})

初回のルート生成

ここがハマりポイントです。routeTree.gen.ts(ルートの構造定義ファイル)は自動生成されますが、初回はまだ存在しないため、手動で生成コマンドを叩く必要があります。

まず、今後のために package.json の scripts を修正しておきます。

package.json
{
  "scripts": {
    "dev": "vite",
    "build": "tsr generate && tsc -b && vite build", // ビルド前に生成
    "lint": "eslint .",
    "preview": "vite preview",
    "generate": "tsr generate" // 手動実行用のコマンドを追加
  }
}

修正したら、ターミナルで一度実行します。

npm run generate

これで src/routeTree.gen.ts が生成されます。まだルートファイルを作っていないので中身は空に近いですが、これでエラーが出なくなります。

アプリのエントリーポイント

生成された routeTree を使ってルーターを初期化します。

src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen' // さっき生成されたファイル

const router = createRouter({ routeTree })

// TypeScriptの型補完を効かせるための設定
declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>,
)

ルートレイアウトの作成

全ページ共通のレイアウト定義です。
src/routes フォルダを作成し、その中に __root.tsx を作成してください。

src/routes/__root.tsx
import { createRootRoute, Outlet } from '@tanstack/react-router'

export const Route = createRootRoute({
  component: () => (
    <>
      <Outlet />
    </>
  ),
})

地図ページの実装

ここがメインの実装です。URLパラメータと地図を同期させます。

src/routes/index.tsx を作成してください。

src/routes/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { useEffect, useRef } from 'react'
import { z } from 'zod'
import { CrosshairIcon } from '../components/CrosshairIcon'
import { CoordinateOverlay } from '../components/CoordinateOverlayProps'

// クエリパラメータの定義(バリデーションとデフォルト値)
const mapSearchSchema = z.object({
  lat: z.number().catch(35.6812), // 東京駅
  lng: z.number().catch(139.7671),
  zoom: z.number().catch(12),
  bearing: z.number().catch(0),
  pitch: z.number().catch(0), 
})

export const Route = createFileRoute('/')({
  validateSearch: (search: Record<string, unknown>) => mapSearchSchema.parse(search),
  component: MapComponent,
})

function MapComponent() {
  const mapContainer = useRef<HTMLDivElement>(null)
  const mapInstance = useRef<maplibregl.Map | null>(null)
  
  // URLから状態を取得 (型安全なところがポイント)
  const { lat, lng, zoom, bearing, pitch } = Route.useSearch()
  const navigate = Route.useNavigate()

  useEffect(() => {
    if (!mapContainer.current || mapInstance.current) return

    mapInstance.current = new maplibregl.Map({
      container: mapContainer.current,
      style: 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json',
      center: [lng, lat],
      zoom: zoom,
      bearing: bearing,
      pitch: pitch
    })

    const map = mapInstance.current

    // 地図が動いたらURLを更新
    map.on('moveend', () => {
      const center = map.getCenter()
      const newZoom = map.getZoom()
      const newBearing = map.getBearing()
      const newPitch = map.getPitch()

      navigate({
        search: (prev: { lat: number; lng: number; zoom: number }) => ({
          ...prev,
          lat: parseFloat(center.lat.toFixed(6)),
          lng: parseFloat(center.lng.toFixed(6)),
          zoom: parseFloat(newZoom.toFixed(2)),
          bearing: parseFloat(newBearing.toFixed(2)),
          pitch: parseFloat(newPitch.toFixed(2)),
        }),
        replace: true, // 戻るボタンの履歴を汚さないようにする
      })
    })

    return () => {
      map.remove()
      mapInstance.current = null
    }
  }, [])

  return (
    <div style={{ position: 'relative', width: '100vw', height: '100vh' }}>
      <div ref={mapContainer} style={{ width: '100%', height: '100%' }} />
      <CrosshairIcon />
      <CoordinateOverlay lat={lat} lng={lng} />
    </div>
  )
}

実行

サーバーを立ち上げます。

npm run dev

ポイント

URLを「信頼できる唯一の情報源」にする

今回の実装では、ステート(状態)の実体を URL そのものに委ねています。
これにより、「URLさえ見れば、アプリの状態が完全に再現できる」 状態になります。

validateSearchとZodによる堅牢性

URLパラメータはすべて文字列です。「?lat=35.6」も「?lat=あいうえお」も、受け取る側からすればただの文字です。
TanStack Routerの validateSearch を使うと、コンポーネントに届く前段階でデータをチェクできます。
これにより、コンポーネント側では lat が 「絶対に数値(number)である」 と保証されます。if (isNaN(lat)) などのチェックコードは不要になります。

URLの履歴を汚さない

Maps 関数には履歴を追加する push(デフォルト)と、現在の履歴を書き換える replace があります。

  • pushの場合:地図を動かすたびに「戻るボタン」の履歴が溜まります
  • replace: trueの場合:履歴は増えません。今回は履歴を溜めたくないのでこちらの設定にしています

コラム:今回の実装にファイルベースルーティングは必須?

「地図1枚出すだけなら、従来のコード定義(Code-Based)でも良いのでは?」と思った方もいるでしょう。その通りです。単機能アプリなら Code-Based の方がシンプルです。
しかし、今回はあえて File-Based Routing を採用しました。
将来的に「設定ページ」や「詳細ページ」を足したくなった時、ファイルを追加するだけで型安全に拡張できるなど、TanStack Routerの強力な機能が使えるということもあり、今回はこの構成にしています。

5
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
5
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?