MENU

問い合わせ


    【NuxtとGoogle Mapsで作る情報集約マップ 第9回】マーカーが数千件でも重くしない:クラスタリングと表示範囲読み込み

    Google Maps API とNuxtで社内向けの情報集約マップを作る連載の第9回です。前回(第8回)では、カテゴリで地点を絞り込み、左の一覧と地図のピンを連動させました。今回は、地点が数千件に増えても地図が重くならないように、近くのピンを「◯件」の丸にまとめるクラスタリングと、地図に見えている範囲の地点だけをサーバーから読み込む仕組みを入れます。

    「最初は数十件でも、何年か使えば地点は数千件になる。地図が固まったりしない?」

    結論から言うと、Google Maps の地図はピンの数が増えるほどブラウザの負担が大きくなるので、「見えている範囲だけ取る」「近くのピンはまとめて描く」の2つを組み合わせるのが定番の対策です。どちらも画面とサーバーの中の処理で、Google の追加のAPI費用は発生しません。大事なのは、今と数年後の地点数の見込みを最初に決めておくことです。件数の桁が変わると、選ぶべき作り方も見積もりも変わります。

    目次

    この回で作るもの:クラスタリングと表示範囲読み込み

    操作画面の動き
    地図を縮小する近くのピンが「128」のような件数入りの丸(クラスタ)にまとまる。件数が多いほど丸が大きく、色が変わる
    クラスタをクリックするその範囲が収まるまで地図が拡大され、個々のピンに分かれていく
    地図を動かす・拡大縮小する操作が止まった時点で、見えている範囲の地点だけをサーバーから取り直す
    ピンや一覧の行を選ぶ選んだピンはクラスタにまとめず、どのズームでも単独で表示される

    仕組み:取る量を減らし、描く量を減らす

    地点が多いときに重くなる原因は、大きく2つあります。

    原因対策今回の実装
    サーバーから全件を毎回受け取る(通信量・処理量が件数に比例)地図の表示範囲(bbox=表示範囲を囲む四角形の東西南北)の地点だけ取るGET /api/points?bbox=西,南,東,北 と、緯度・経度の索引
    ピン1本1本を画面の部品として描く(部品が数千個になる)近くのピンを1つの丸にまとめて描く(クラスタリング)@googlemaps/markerclusterer

    さらに、地図を少し動かしただけで全部のピンを作り直していた第8回までの描き方を、増えた分・消えた分だけ更新する差分更新に変えます。

    実装

    ファイル役割
    server/utils/bbox.tsbbox の文字列を検証して数値にする(新規)
    server/api/points/index.get.tsbbox で範囲内の地点だけ返す(更新)
    server/plugins/db-init.ts緯度・経度の索引を作る(更新)
    scripts/seed-points.ts動作確認用に5,000件のダミー地点を入れる(新規)
    app/composables/useClusterer.tsクラスタリングの準備と、件数入りの丸の描き方(新規)
    app/composables/useMarkers.ts差分更新と、選択中ピンのクラスタ除外(更新)
    app/components/MapView.vue / app/pages/index.vue表示範囲の変化を受けて取り直す(更新)

    ライブラリは Google が公開している @googlemaps/markerclusterer(2系)を追加します。

    npm install @googlemaps/markerclusterer

    📰 出典:Google Maps Platform「Marker clustering」

    手順1:表示範囲で絞り込むAPI

    server/utils/bbox.ts

    const lat = z.number().min(-90).max(90)
    const lng = z.number().min(-180).max(180)
    
    export const bboxSchema = z
      .string()
      .transform((s) => s.split(',').map((v) => Number(v)))
      .pipe(z.tuple([lng, lat, lng, lat]))
      .refine(([, south, , north]) => south <= north, '南端は北端以下にしてください')
      .transform(([west, south, east, north]) => ({ west, south, east, north }))

    bbox は「西,南,東,北」(最小経度,最小緯度,最大経度,最大緯度)の順にしました。GeoJSON など地理データの世界でよく使われる並びです。数値でない値や範囲外の値は zod の検証で 400 エラーになります。

    server/api/points/index.get.ts

    // 1回に返す最大件数。地図を大きく縮小しても、応答とブラウザの負荷がこれ以上増えないようにする
    const MAX_POINTS = 10000
    
    const querySchema = z.object({
      bbox: bboxSchema.optional(),
    })
    
    export default defineEventHandler(async (event) => {
      const { bbox } = await getValidatedQuery(event, (q) => querySchema.parse(q))
      const db = useDatabase()
    
      if (!bbox) {
        const { rows } = await db.sql`SELECT * FROM points ORDER BY id LIMIT ${MAX_POINTS}`
        return (rows as unknown as PointRow[]).map(toPoint)
      }
    
      const { west, south, east, north } = bbox
      // 通常は 西 <= 東。日付変更線(経度180度)をまたぐ範囲だけ 西 > 東 になるので条件を分ける
      const { rows } = west <= east
        ? await db.sql`SELECT * FROM points
            WHERE lat BETWEEN ${south} AND ${north} AND lng BETWEEN ${west} AND ${east}
            ORDER BY id LIMIT ${MAX_POINTS}`
        : await db.sql`SELECT * FROM points
            WHERE lat BETWEEN ${south} AND ${north} AND (lng >= ${west} OR lng <= ${east})
            ORDER BY id LIMIT ${MAX_POINTS}`
      return (rows as unknown as PointRow[]).map(toPoint)
    })

    server/plugins/db-init.ts(追加部分)

      // 第9回: 表示範囲(緯度・経度の範囲)での検索を速くする索引
      await db.sql`CREATE INDEX IF NOT EXISTS points_lat_lng ON points (lat, lng)`

    索引(=本の索引のように、目的の行を早く探すための仕組み)を作ると、SQLite は「緯度の範囲」で候補を絞ってから経度を確認します。実際に EXPLAIN QUERY PLAN で確認すると、SEARCH points USING INDEX points_lat_lng と表示され、索引が使われていました。

    手順2:5,000件のダミー地点を入れるスクリプト

    scripts/seed-points.ts(要点)

    import { DatabaseSync } from 'node:sqlite'
    
    const db = new DatabaseSync('.data/gmap.sqlite')
    // …(件数の検証、固定の種から作る乱数 random() は省略)
    
    // 東京23区がおおむね収まる範囲に散らす
    const AREA = { south: 35.55, north: 35.82, west: 139.56, east: 139.92 }
    const insert = db.prepare(
      'INSERT INTO points (name, category, lat, lng, memo, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
    )
    
    // まとめて1つのトランザクションにすると、1件ずつ確定するより桁違いに速い
    db.exec('BEGIN')
    for (let i = 1; i <= count; i++) {
      const lat = AREA.south + (AREA.north - AREA.south) * random()
      const lng = AREA.west + (AREA.east - AREA.west) * random()
      const category = CATEGORIES[Math.floor(random() * CATEGORIES.length)]
      insert.run(`シード地点 ${String(i).padStart(5, '0')}`, category, +lat.toFixed(6), +lng.toFixed(6), SEED_MEMO, now)
    }
    db.exec('COMMIT')

    npm run seed で5,000件、npm run seed -- 20000 で件数指定、node scripts/seed-points.ts --remove でこのスクリプトが入れた地点だけを消せます。Node.js 24 は TypeScript のファイルを型注釈を取り除いてそのまま実行できるので、ビルドの手間はありません。

    📰 出典:Node.js「SQLite」

    手順3:クラスタリングの準備

    app/composables/useClusterer.ts(要点)

    // 型だけを静的に読み込む。本体はブラウザで必要になったときに動的 import する(下の ensure 参照)
    import type { MarkerClusterer, Renderer } from '@googlemaps/markerclusterer'
    
    export function useClusterer() {
      const { load } = useGoogleMaps()
      let clusterer: MarkerClusterer | null = null
    
      async function ensure(map: google.maps.Map): Promise<MarkerClusterer> {
        if (clusterer) return clusterer
        // このパッケージは package.json の main が CommonJS 形式のため、サーバー描画(Node.js)で
        // 静的に import すると名前付き export が見つからずエラーになる。ブラウザでだけ読み込む
        const [{ AdvancedMarkerElement }, { MarkerClusterer, SuperClusterAlgorithm }] = await Promise.all([
          load('marker'),
          import('@googlemaps/markerclusterer'),
        ])
        if (clusterer) return clusterer // await の間に別の呼び出しが作っていた場合
    
        const renderer: Renderer = {
          render({ count, position }) {
            const badge = document.createElement('div')
            badge.className = 'cluster-badge'
            badge.dataset.size = count >= 1000 ? 'l' : count >= 100 ? 'm' : 's'
            badge.textContent = count.toLocaleString('ja-JP')
            const marker = new AdvancedMarkerElement({
              position,
              title: `${count}件の地点(クリックで拡大)`,
              zIndex: Math.min(count, 900), // 件数が多いほど手前。選択中のピン(999)よりは奥
              gmpClickable: true,
            })
            marker.append(badge)
            return marker
          },
        }
    
        clusterer = new MarkerClusterer({
          map,
          renderer,
          // ズーム16より拡大したらまとめない(既定値と同じ。個々のピンを必ず押せるようにする)
          algorithm: new SuperClusterAlgorithm({ maxZoom: 16, radius: 60 }),
        })
        return clusterer
      }
      // …(get() と、破棄時に clusterer.setMap(null) で片付ける処理は省略)
    }

    ポイントは3つです。

    • 件数入りの丸は自前で描く:ライブラリ既定の描き方は英語の説明文で、内部で非推奨のプロパティを使う箇所もあるため、Renderer を自作して AdvancedMarkerElement に append する第2回と同じ作法にそろえました。丸の見た目は app/assets/css/main.css の .cluster-badge で、件数に応じて大きさと色を変えています
    • ライブラリはブラウザでだけ読み込む:筆者の環境では、最初に通常の import で書いたところ、ビルド自体は成功したのに、ページを開くとサーバー側で「名前付き export が見つからない」エラーになりました。このパッケージは Node.js から見ると CommonJS 形式として読まれるためです。地図と同じくブラウザ専用の機能なので、必要になった時点で動的 import() しています
    • クラスタのクリックは、ライブラリ既定の動作(そのクラスタの範囲に地図を合わせて拡大)をそのまま使っています

    📰 出典:npm「@googlemaps/markerclusterer」

    手順4:マーカーを差分だけ更新する

    app/composables/useMarkers.ts(render の変更部分)

        // 1) 消えた地点・内容が変わった地点のマーカーを外す
        const next = new Map(points.map((p) => [p.id, p]))
        const removed: google.maps.marker.AdvancedMarkerElement[] = []
        for (const [id, marker] of markers) {
          const p = next.get(id)
          if (p && p.updatedAt === pointsById.get(id)?.updatedAt) continue
          marker.map = null // 選択中でクラスタの外にあるマーカーもあるので、直接外す
          removed.push(marker)
          markers.delete(id)
          pins.delete(id)
          pointsById.delete(id)
          if (highlightedId === id) highlightedId = null
        }
    
        // 2) 新しく範囲に入った地点(と内容が変わった地点)だけマーカーを作る
        const added: google.maps.marker.AdvancedMarkerElement[] = []
        for (const point of points) {
          if (markers.has(point.id)) continue
          // …(PinElement と AdvancedMarkerElement の作成は第8回と同じ。ただし map は指定しない)
          added.push(marker)
        }
    
        // 3) まとめて反映し、再計算は最後に1回だけ(noDraw = true)
        mc.removeMarkers(removed, true)
        mc.addMarkers(added, true)
        // 選択中の地点があれば強調と InfoWindow を出し直す(その中で mc.render() も呼ぶ)
        onSelectionChange(selectedId.value)

    マーカーを作るときに map を指定しなくなった点が大きな変更です。地図に載せるか、丸の中にまとめて隠すかは、クラスタリング側が決めます。addMarkers / removeMarkers の第2引数 true は「今は再計算しない」という指定で、最後に1回だけ再計算させて無駄を省いています。更新日時が変わった地点は「内容が変わった」とみなして作り直します。

    選択中のピンは、クラスタから外して地図に直接載せます。

      // 第9回: 選択中のピンはクラスタから外して、どのズームでも単独で表示する
      function highlight(id: number | null) {
        const mc = clusterer.get()
        // …(同じ地点なら再計算だけして終了。前に選んでいたピンは元の大きさに戻し、
        //     mc?.addMarker(prevMarker, true) でクラスタリングの対象に戻す)
        highlightedId = id
        const pin = id === null ? undefined : pins.get(id)
        const marker = id === null ? undefined : markers.get(id)
        if (pin) pin.scale = 1.4
        if (marker) {
          marker.zIndex = 999
          mc?.removeMarker(marker, true)
          marker.map = currentMap
        }
        mc?.render()
      }

    こうしないと、一覧から選んだ地点が丸の中に隠れてしまい、InfoWindow の吹き出しが出せなくなります。

    手順5:表示範囲が変わったら取り直す

    app/components/MapView.vue(追加部分)

        // idle は移動・ズームの操作が落ち着いたときに1回だけ発生する(ドラッグ中は発生しない)
        map.value.addListener('idle', () => {
          const bounds = map.value?.getBounds()
          if (bounds) emit('bounds-change', bounds.toJSON())
        })

    app/utils/bbox.ts

    export function toBboxParam(b: google.maps.LatLngBoundsLiteral): string {
      const floor = (v: number) => Math.floor(v * 1000) / 1000
      const ceil = (v: number) => Math.ceil(v * 1000) / 1000
      return [floor(b.west), floor(b.south), ceil(b.east), ceil(b.north)].join(',')
    }
    
    // 地図の準備ができる前(サーバー描画時を含む)に使う初期範囲。東京駅周辺(第1回の初期表示付近)
    export const INITIAL_BBOX = '139.750,35.670,139.785,35.695'

    app/pages/index.vue(変更部分)

    // 第9回: 表示範囲が変わると query が変わり、useFetch が自動で取り直す(古い要求は取り消される)
    const bbox = ref(INITIAL_BBOX)
    const { data: points, refresh } = await useFetch<Point[]>('/api/points', {
      query: { bbox },
      default: () => [],
    })
    
    function onBoundsChange(b: google.maps.LatLngBoundsLiteral) {
      bbox.value = toBboxParam(b)
    }

    useFetch の query に ref を渡すと、値が変わるたびに自動で取り直してくれます。既定の設定(dedupe: 'cancel')では、前の要求がまだ終わっていなければ取り消されるので、「古い範囲の結果が後から届いて上書きする」事故も起きにくくなっています。範囲は小数3桁(約100m)に外側へ丸め、ほんの少しの移動で毎回違う URL にならないようにしました。

    📰 出典:Nuxt「useFetch」

    地図の準備ができる前(サーバーで最初のHTMLを作るとき)は画面の大きさが分からないので、東京駅周辺の固定範囲を使います。地図が表示されて最初の idle が来た時点で、実際の表示範囲に切り替わります。

    一覧・件数・選択の扱いを見直す

    表示範囲だけを読み込むようにすると、「手元にあるデータ=地図に見えている範囲の地点」になります。第8回の画面の意味がいくつか変わるので、合わせて直しました。

    項目第8回まで第9回から
    件数表示表示 N / 全 M 件表示 N / 地図の範囲内 M 件
    一覧全件を表示範囲内の先頭200件まで。超えた分は「ほか N 件。地図を拡大すると絞り込めます」
    選択中の地点が範囲外に出たとき一覧から消えると選択解除詳細パネルには表示し続ける(解除するのはカテゴリで非表示にしたときだけ)

    一覧を200件で打ち切るのは、数千行を一度に画面へ描くと、地図より先に一覧が重くなるためです。全件を一覧で見たい要件があるなら、ページ送りや「仮想スクロール(見えている行だけ描く仕組み)」を別途入れることになります。

    動作確認の方法

    1. npm run dev を一度起動してテーブルを作ったら、npm run seed で5,000件を追加する
    2. http://localhost:3000 を開き、地図を縮小 → ピンが件数入りの丸にまとまることを確認
    3. 丸をクリック → 地図が拡大され、丸が分かれていくことを確認
    4. DevTools の Network タブで、地図を動かすたびに /api/points?bbox=… が呼ばれることを確認
    5. Performance パネルで地図をパンしたときの記録を取り、長い処理(Long Task)が大量に出ないことを確認
    6. 一覧から地点を選ぶ → 縮小してもその地点のピンだけは丸にまとまらずに残ることを確認
    7. 確認が終わったら node scripts/seed-points.ts --remove でダミー地点を消す

    筆者の環境では、npm run build と npm run typecheck の成功に加え、ビルド後のサーバーに5,000件のダミー地点を入れて curl で次を確認しました。

    確認内容結果
    シードスクリプトで5,000件追加0.1秒未満で完了
    bbox なし(全5,020件)約1.2MB の応答
    東京駅周辺の初期範囲69件・約15KB の応答
    南北が逆・数値でない・緯度95度の bboxいずれも 400
    日付変更線をまたぐ bbox200(0件)
    最初のHTML「表示 69 / 地図の範囲内 69 件」と一覧69行が入る

    bbox で絞ると、応答の大きさが全件の80分の1程度になりました(件数や範囲によって変わります)。一方、地図上でのクラスタ表示・クラスタのクリック・パン時の再読み込み・ブラウザでの描画性能は、執筆環境に本物のAPIキーとブラウザでの操作環境がないため確認できていません。 クラスタリングで「何件まで快適か」は端末の性能にも左右されるので、ご自身のキーと、実際に使う端末(特に社用のスマートフォンやノートPC)で計測してください。

    つまずきやすい点・セキュリティ上の注意

    • bbox の値をそのまま SQL に埋め込む:必ず検証してから、db0 の sql タグ(値を安全に渡す仕組み)で渡します。文字列連結で SQL を組むと、SQLインジェクション(=細工した入力でデータベースを操作される攻撃)の入口になります
    • 件数の上限を設けない:地図を最大まで縮小すると全件を要求されます。今回は1回10,000件で打ち切りました。上限に達したら「拡大してください」と表示する、縮小時は件数だけ返す、などの設計は要件次第です
    • 動かすたびに全ピンを作り直す:差分更新にしないと、少しのパンで数千個の部品を作り直すことになります
    • マーカーに map を指定したままクラスタに渡す:まとめるはずのピンが一瞬表示されるなど、表示が乱れる原因になります
    • 仮ピン(登録用)をクラスタに入れる:登録途中のピンが丸に隠れてしまいます。第5回のとおり別管理のままにしています
    • SQLite のまま大規模化する:緯度・経度の索引は簡易的なものです。数十万件以上や「近い順」の検索が必要なら、PostgreSQL の PostGIS(地理データ用の拡張機能)などを検討します

    発注者向けメモ

    • クラスタリングと表示範囲読み込みは、Google の追加API費用が発生しない処理です。費用は開発工数と、自社サーバー・データベースの負荷として現れます
    • 地点数の見込み(今と3年後)を最初に伝えてください。数百件なら全件読み込みで足り、数千〜数万件なら今回の作り、数十万件を超えるなら地図タイルを事前に作る方式など別の作りになり、見積もりも変わります
    • 「一覧で全件を見たい」「全件を地図に一度に出したい」という要望は重くなる代表例です。本当に必要か(検索や絞り込みで足りないか)を業務の流れで確認しておくと、工数を抑えられます
    • 性能の確認は実際に使う端末で行うのが確実です。開発者の高性能なPCでは快適でも、現場のスマートフォンでは遅い、ということがよく起こります。受け入れテストの条件(端末・件数・操作)を決めておきましょう

    発注者がやることのチェックリストです。

    • ☐ 地点数の見込み(今・1年後・3年後)を数字で伝えた
    • ☐ 一覧に全件が必要か、範囲や絞り込みで足りるかを決めた
    • ☐ 性能を確認する端末と、合格の目安(操作して何秒以内など)を決めた
    • ☐ 件数が上限を超えたときの画面の出し方を確認した

    開発会社への確認に使える質問例です。

    • 「地点が今の10倍になったとき、どこから遅くなりますか?その場合の対応策は?」
    • 「地図を動かしたときに、サーバーへの問い合わせは何回発生しますか?」
    • 「性能の確認は、どの端末・何件のデータで行いますか?」

    まとめと次回予告

    この回では、地図に見えている範囲の地点だけをサーバーから読み込み、近くのピンを件数入りの丸にまとめて表示するようにしました。サーバー側は bbox の検証と索引、画面側は差分更新とクラスタリング、そして「手元のデータ=範囲内の地点」になったことに合わせた一覧・件数・選択の見直し、の3点が押さえどころです。

    次回は「現在地から半径◯km/描いたエリア内の地点を探す」です。ブラウザの位置情報で現在地を取り、半径の円や、地図上に描いた多角形の中にある地点だけを一覧に残す機能を作ります。

    この連載の記事一覧

    この記事は連載「NuxtとGoogle Mapsで作る情報集約マップ」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (2件)

      目次