🧺

supercluster & Web Workerで地図クラスタリングしたら1000万件でも耐えられるようになった

に公開

こんにちは!CastingONEの大沼です。

始めに

弊社ではお仕事検索でマップ上に勤務先をマーカーで表示する機能があります。マーカーが大量にある場合はクラスタリングすることで表示する数を絞られてパフォーマンスが良くなりますが、それでも数には限度があります。StackBlitzで検証した時ではreact-leafletをそのまま使った場合は5万件程度、pureなleafletをそのまま使って出来るだけマーカーが作り直されないように実装して50万件程度なら耐えられそうということを確認しました。
しかし要件としてはmax 100万件にできると嬉しいとのことでもっとパフォーマンスを改善できないか相談されました。ここまでくるともはやバックエンドでやらないと無理なのでは?と思いつつ、もしフロントでやるならクラスタリングロジックをWeb Workerに切り出すことで更にパフォーマンスを上げられると思って試してみたところ、なんと1000万件でもクラッシュせず耐えることができました🎉 しかもsuperclusterというクラスタリングのライブラリがとても優秀でセットアップさえ終われば1000万件でも表示領域のクラスタリング結果を一瞬で出すという衝撃の結果を出しました。

ちなみにデバッグログ表示では4.5秒くらいでクラスタリングは終わったようです。

この記事ではsupercluster & Web Workerを使ったクラスタリングの実装内容について説明します。検証環境は以下にありますので、先に動作確認や詳細のコードを見たい方はこちらをご参照ください。

従来の実装比較

参考までに従来の検証環境も載せておきます。

react-leafletでクラスタリング

react-leafletのコンポーネントをオーバーヘッドがかかるのか最初の生成でもかなり時間がかかってしまいます。またアイコンやツールチップの表示の変更をするとマーカーが変わるため再度クラスタリング処理が走ってしまうのか移動にモタついてしまいます。
パフォーマンスが最も悪く、5万件くらいでかなり苦しくなります。

pure leafletで出来るだけ再生成されないように調整

上記の結果を踏まえ、pureのleafletを使ってマーカーを生成し、そのマーカーを可能な限り使い回すような書き方に調整しました。これでパフォーマンスはだいぶ改善されて50万件でもセットアップさえ終わればサクサク動くようになりました。

Web Workerとのやりとりをメソッド形式で行えるようにラップする

実際の実装に入る前に、Web Workerを使いやすくなるように設計します。Web Workerをそのまま使うとイベント形式で送信するためロジックの流れが非常に分かりづらくなり保守が難しくなります。

Web Workerをそのまま使う場合の呼び出し例
// Viteを使っている場合は`?worker`を足すことでWorkerとして起動させることができる
import MyWorker from './myWorker?worker';

const worker = new MyWorker();

// 全てmessageイベントから受け取る
worker.addEventListener('message', (event) => {
  // 受け取ったデータでどのリクエストに対するレスポンスか判断しないといけない
  const { data } = event;
});

// 全てpostMessageで送信し、その結果はmessageイベントでしか受け取れずロジックの流れを追いづらい
worker.postMessage({ numSpot: 1000 });

設計方針

WorkerをラップしてPromiseで結果が返ってくるような形にして、Web Workerとのやりとりをやりやすくします。

クラスでラップして実行しやすくするコードのイメージ
// Workerで処理するメソッド群(実際はWorkerファイルの方で定義し、型だけimportする)
const handlers = {
  setup: (numSpot: number) => {
    // セットアップ処理
  }
}

// Workerインスタンスを渡してクライアントでやり取りするためのクラスを生成する
// Workerで処理できるメソッド群の型を渡しておくことで型安全にリクエストを投げられる
const client = new ClientWorker<typeof handlers>(worker);

client
  // 以下の構成でWorkerで定義したハンドラーにリクエストする
  // 第一引数: メソッド名
  // 第二引数: メソッドパラメータ
  .call('setup', 1000)
  // 処理が終わったらthenで受け取れる
  .then(() => {
    console.log('ready')
  });

Workerとのやり取りは中継するだけで済むように、以下のようなインターフェースを定義します。

Workerとのやりとりの汎用インターフェース
export type AnyWorkerHandlers = Record<string, (params: any) => any>;

export type WorkerRequest<
  Handlers extends AnyWorkerHandlers,
  K extends keyof Handlers = keyof Handlers
> = {
  id: string;
  type: 'req';
  method: K;
  params: Parameters<Handlers[K]>[0];
};

export type WorkerResponse<
  Handlers extends AnyWorkerHandlers,
  K extends keyof Handlers = keyof Handlers
> =
  | {
      id: string;
      type: 'res';
      ok: true;
      result: Awaited<ReturnType<Handlers[K]>>;
    }
  | {
      id: string;
      type: 'res';
      ok: false;
      error: { message: string };
    };

handlers を定義して入れた場合以下のような型が生成されます。

handlersを定義した入れた例
const handlers = {
  setup: (numSpot: number) => {
    // セットアップ処理
  }
}

type SetupWorkerRequest = WorkerRequest<typeof handlers>
// {
//   id: string;
//   type: 'req';
//   method: 'setup';
//   params: number
// }

type SetupWorkerResponse = WorkerResponse<typeof handlers>
// | {
//     id: string;
//     type: 'res';
//     ok: true;
//     result: void;
//   }
// | {
//     id: string;
//     type: 'res';
//     ok: false;
//     error: { message: string };
//   };

クライアント側のラッパーを実装

設計方針のイメージに合うように実装すると以下のようなラッパーになりました。crypto.randomUUID()でユニークなIDを生成してリクエスト中のデータを保持しておき、返ってきたメッセージがIDと一致していたらresolveしてデータを返すようにします。

メソッド形式でやりとりするラッパークラス
export class WorkerClient<Handlers extends AnyWorkerHandlers> {
  private worker: Worker;
  /** workerの実行完了待ちのハンドラ群 */
  private pendingHandlerMap = new Map<
    string,
    (res: WorkerResponse<Handlers>) => void
  >();

  constructor(worker: Worker) {
    this.worker = worker;

    worker.addEventListener(
      'message',
      (event: MessageEvent<WorkerResponse<Handlers>>) => {
        const { data } = event;
        if (data.type !== 'res') {
          return;
        }
        const callback = this.pendingHandlerMap.get(data.id);
        if (callback) {
          this.pendingHandlerMap.delete(data.id);
          callback(data);
        }
      }
    );
  }

  call<K extends keyof Handlers>(
    method: K,
    params: Parameters<Handlers[K]>[0]
  ): Promise<ReturnType<Handlers[K]>> {
    const id = crypto.randomUUID();
    const req: WorkerRequest<Handlers, K> = {
      id,
      type: 'req',
      method,
      params,
    };
    this.worker.postMessage(req);

    return new Promise<ReturnType<Handlers[K]>>((resolve, reject) => {
      this.pendingHandlerMap.set(id, (res) => {
        if (!res.ok) {
          reject(new Error(res.error.message));
          return;
        }
        resolve(res.result);
      });
    });
  }

  terminate() {
    this.worker.terminate();
  }
}

Worker側のラッパーを実装

Worker側の実装は以下のようになります。

Worker側の処理をhandlersで行えるようにする
/**
 * handlersで定義したメソッド群をworker側で処理できるようにする
 */
export const createWorkerHandler = <Handlers extends AnyWorkerHandlers>(
  handlers: Handlers
) => {
  return async (event: MessageEvent<WorkerRequest<Handlers>>) => {
    const message = event.data;
    if (message.type !== 'req') return;

    try {
      const result = await handlers[message.method](message.params);
      const res: WorkerResponse<Handlers> = {
        id: message.id,
        type: 'res',
        ok: true,
        result,
      };
      self.postMessage(res);
    } catch (error) {
      const res: WorkerResponse<Handlers> = {
        id: message.id,
        type: 'res',
        ok: false,
        error: {
          message: error instanceof Error ? error.message : String(error),
        },
      };
      self.postMessage(res);
    }
  };
};

これを実際に使う場合は以下のようにhandlersを定義してmessageイベントのハンドラに渡す感じになります。

const handlers = {
  setup: (numSpot: number) => {
    // セットアップ処理
  }
} satisfies AnyWorkerHandlers;

self.addEventListener('message', createWorkerHandler(handlers))

Web Workerラッパーを通してsuperclusterを使った地図クラスタリングする

前セクションで実装したラッパーを使うことでWeb Workerとのやりとりが非常にやりやすくなったので、これベースで実装していきます。まずはWorker側の実装ですが、特殊なことをしないため基本的にはsuperclusterのメソッドを呼び出すだけで良いです。

superclusterを使ったクラスタリング処理をWorker上に定義
import Supercluster from 'supercluster';
import type { PointFeature } from 'supercluster';
import { range, random } from 'es-toolkit';
import type { AnyWorkerHandlers } from '../protocol';
import { createWorkerHandler } from '../createWorkerHandler';

const DEFAULT_POSITION = { lat: 35.6812362, lng: 139.7645499 };

let clusterIndex: Supercluster<{ id: number }> | null = null;

const handlers = {
  setup: (numSpot: number) => {
    // ダミーデータを生成
    const spots = range(0, numSpot).map((index) => ({
      id: index,
      lat: random(DEFAULT_POSITION.lat - 0.05, DEFAULT_POSITION.lat + 0.05),
      lng: random(DEFAULT_POSITION.lng - 0.05, DEFAULT_POSITION.lng + 0.05),
    }));

    clusterIndex = new Supercluster({
      log: true,
      radius: 100,
      maxZoom: 18,
    });
    const points = spots.map((spot, index): PointFeature<{ id: number }> => {
      return {
        type: 'Feature',
        geometry: {
          type: 'Point',
          coordinates: [spot.lng, spot.lat],
        },
        properties: {
          id: index + 1,
        },
      };
    });
    clusterIndex.load(points);
    return;
  },
  clustering: ({ bbox, zoom }: { bbox: GeoJSON.BBox; zoom: number }) => {
    if (clusterIndex == null) {
      throw new Error('クラスタの準備が完了していません。');
    }
    const clusters = clusterIndex.getClusters(bbox, zoom);
    return clusters;
  },
  getClusterExpansionZoom: (clusterId: number) => {
    if (clusterIndex == null) {
      throw new Error('クラスタの準備が完了していません。');
    }
    const zoom = clusterIndex.getClusterExpansionZoom(clusterId);
    return zoom;
  },
  getLeaves: (clusterId: number) => {
    if (clusterIndex == null) {
      throw new Error('クラスタの準備が完了していません。');
    }
    const points = clusterIndex.getLeaves(clusterId, Number.POSITIVE_INFINITY);
    return points;
  },
} satisfies AnyWorkerHandlers;

export type ClusterWorkerHandlers = typeof handlers;

self.addEventListener('message', createWorkerHandler(handlers));

handlersの定義を元にクライアント側の呼び出しを以下のように書くことで表示上のクラスタデータを取得できます。

表示中のマップに含まれるクラスタデータを取得するhooks
import ClusterWorker from './clusterWorker?worker';
import type { ClusterWorkerHandlers } from './clusterWorker';
import { WorkerClient } from '../WorkerClient';

const createClusterWorkerClient = () => {
  const worker = new ClusterWorker();
  const client = new WorkerClient<ClusterWorkerHandlers>(worker);
  return client;
};

type ClusterWorkerClient = ReturnType<typeof createClusterWorkerClient>;

const useClusterWorker = ({
  numSpot,
  map,
  bounds,
}: {
  numSpot: number;
  map: L.Map | null;
  bounds: L.LatLngBounds | null;
}) => {
  const [workerClient, setWorkerClient] = useState<ClusterWorkerClient | null>(
    null
  );
  const [clusters, setClusters] = useState<
    Array<ClusterFeature<{}> | PointFeature<{ id: number }>>
  >([]);

  useEffect(() => {
    const workerClient = createClusterWorkerClient();
    workerClient
      .call('setup', numSpot)
      .then(() => {
        setWorkerClient(workerClient);
      });

    return () => {
      workerClient.terminate();
      setWorkerClient(null);
    };
  }, [numSpot]);

  useEffect(() => {
    if (workerClient == null || map == null || bounds == null) {
      return;
    }

    const northEast = bounds.getNorthEast();
    const southWest = bounds.getSouthWest();
    // [westLng, southLat, eastLng, northLat]
    const bbox: GeoJSON.BBox = [
      southWest.lng,
      southWest.lat,
      northEast.lng,
      northEast.lat,
    ];

    workerClient
      .call('clustering', { bbox, zoom: map.getZoom() })
      .then((clusters) => {
        setClusters(clusters);
      });
  }, [workerClient, map, bounds]);

  return {
    /** 準備中か */
    isPreparing: workerClient == null,
    /** Workerとやりとりするクライアント */
    workerClient,
    /** クラスタリングデータ */
    clusters,
  };
};

このhooksを使ってマーカーを立てます。クラスタかポイントかはsuperclusterの型を参考に判断し、それぞれのコンポーネントをrenderすれば良いです。クラスタクリック時のズーム値の取得など、Workerとやりとりがスポットで必要な場合はラッパーを返しているのでラッパー経由でリクエストを投げることができ、当初の設計思想通りロジックの流れが読みやすくなっています。
本筋とは関係ないhooksやコンポーネントの実装コードは折りたたんで載せておきますので、詳細のコードを見たい方は開いてご確認ください。

useMap hooks
import { useState } from 'react';
import L from 'leaflet';

/**
 * マップがアクティブか調べる
 * @param map - Leafletのマップ
 */
export const checkIsActiveMap = (map: L.Map) => {
  // 既にマップが削除されている場合はpanesが空になっているので、それで判断する
  return Object.keys(map.getPanes()).length > 0;
};

export const useMap = () => {
  const [map, setMap] = useState<L.Map | null>(null);
  const [bounds, setBounds] = useState<L.LatLngBounds | null>(null);

  const ref = useMemo(() => {
    let map: L.Map | null = null;
    return (element: HTMLElement | null) => {
      if (element == null) {
        map?.remove();
        setMap(null);
        setBounds(null);
        return;
      }

      map = L.map(element, {
        doubleClickZoom: false,
      });
      map.setView(DEFAULT_POSITION, 15);

      const localMap = map;
      map.addEventListener('zoomend', () => {
        setBounds(localMap.getBounds());
      });
      map.addEventListener('moveend', () => {
        setBounds(localMap.getBounds());
      });

      setMap(map);
      setBounds(map.getBounds());
    };
  }, []);

  useEffect(() => {
    if (map == null || !checkIsActiveMap(map)) {
      return;
    }

    const layer = L.tileLayer(
      'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
      {
        // 著作権の表示
        attribution:
          '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
      }
    );
    map.addLayer(layer);

    return () => {
      map.removeLayer(layer);
    };
  }, [map]);

  return {
    ref,
    map,
    bounds,
  };
};
Clusterコンポーネント
import type { FC } from 'react';
import { useRef, useState, useCallback, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
import type { Position as GeoPosition } from 'geojson';
import L from 'leaflet';

import { checkIsActiveMap } from '../utils/checkIsActiveMap';

export type ClusterProps = {
  /** Leafletのマップ */
  map: L.Map;
  /** 座標 */
  position: GeoPosition;
  /** 個数 */
  count: number;
  /**
   * クリック時
   */
  onClick: (event: L.LeafletMouseEvent) => void;
};

export const Cluster: FC<ClusterProps> = ({
  map,
  position,
  count,
  onClick,
}) => {
  const [clusterMarker] = useState(() => {
    const size = count < 100 ? 'small' : count < 1000 ? 'medium' : 'large';
    const icon = L.divIcon({
      html: `<div><span>${count}</span></div>`,
      className: `marker-cluster marker-cluster-${size}`,
      iconSize: L.point(40, 40),
    });

    const [lng, lat] = position;
    const marker = L.marker(L.latLng(lat, lng), {
      icon,
    });
    return marker;
  });

  // 位置の更新
  const [lng, lat] = position;
  useEffect(() => {
    clusterMarker.setLatLng(L.latLng(lat, lng));
  }, [clusterMarker, lat, lng]);

  // 個数の更新
  useEffect(() => {
    const size = count < 100 ? 'small' : count < 1000 ? 'medium' : 'large';
    clusterMarker.setIcon(
      L.divIcon({
        html: `<div><span>${count}</span></div>`,
        className: `marker-cluster marker-cluster-${size}`,
        iconSize: L.point(40, 40),
      })
    );
  }, [clusterMarker, count]);

  const onClickRef = useRef(onClick);
  onClickRef.current = onClick;

  const handleClick = useCallback((event: L.LeafletMouseEvent) => {
    onClickRef.current(event);
  }, []);

  useEffect(() => {
    if (!checkIsActiveMap(map)) {
      return;
    }

    clusterMarker.addEventListener('click', handleClick);
    map.addLayer(clusterMarker);

    return () => {
      clusterMarker.removeEventListener('click', handleClick);
      if (checkIsActiveMap(map)) {
        map.removeLayer(clusterMarker);
      }
    };
  }, [map, clusterMarker, handleClick]);

  return null;
};
Markerコンポーネント
import { type FC, useEffect, useState, useRef } from 'react';
import L from 'leaflet';
import Supercluster from 'supercluster';
import type { Position as GeoPosition } from 'geojson';

import { checkIsActiveMap } from '../utils/checkIsActiveMap';

export type MarkerProps = {
  /** Leafletのマップ */
  map: L.Map;
  /** アイコン */
  icon: L.Icon;
  /** ポイントデータ */
  pointFeature: Supercluster.PointFeature<Supercluster.AnyProps>;
  /** 座標 */
  position: GeoPosition;
  /** ツールチップテキスト */
  tooltipText?: string;
  /**
   * クリック時
   * @param event - イベント
   */
  onClick?: (event: L.LeafletMouseEvent) => void;
};

export const Marker: FC<MarkerProps> = ({
  map,
  icon,
  position,
  tooltipText,
  onClick,
}) => {
  const [marker] = useState<L.Marker>(() => {
    const [lng, lat] = position;
    return L.marker(L.latLng(lat, lng), {
      icon,
    });
  });

  useEffect(() => {
    if (!checkIsActiveMap(map)) {
      return;
    }

    map.addLayer(marker);

    return () => {
      if (checkIsActiveMap(map)) {
        map.removeLayer(marker);
      }
    };
  }, [map, marker]);

  // 位置の更新
  const [lng, lat] = position;
  useEffect(() => {
    marker.setLatLng(L.latLng(lat, lng));
  }, [marker, lat, lng]);

  // アイコンの設定
  useEffect(() => {
    marker.setIcon(icon);
  }, [marker, icon]);

  // ツールチップの設定
  useEffect(() => {
    if (tooltipText == null) {
      return;
    }

    marker.bindTooltip(tooltipText, {
      permanent: true,
      direction: 'top',
    });

    return () => {
      marker.unbindTooltip();
    };
  }, [marker, tooltipText]);

  // deps回避のためにrefで保持
  const onClickRef = useRef(onClick);
  onClickRef.current = onClick;
  useEffect(() => {
    const clickListener = (event: L.LeafletMouseEvent) => {
      if (onClickRef.current) {
        onClickRef.current(event);
      }
    };
    marker.addEventListener('click', clickListener);

    return () => {
      marker.removeEventListener('click', clickListener);
    };
  }, [marker]);

  return null;
};
クラスタリング結果を見てマーカーを表示する
const checkIsClusterFeature = function <P extends Record<string, any>>(
  cluster: ClusterFeature<P> | PointFeature<P>
): cluster is ClusterFeature<P> {
  return cluster.properties.cluster;
};

export const MapContainer: FC<MapContainerProps> = ({ numSpot }) => {
  const { ref, map, bounds } = useMap();
  const { isPreparing, workerClient, clusters } = useClusterWorker({
    numSpot,
    map,
    bounds,
  });
  const [selectedSpotId, setSelectedSpotId] = useState<number | null>(null);

  return (
    <div>
      <div>{isPreparing ? '準備中' : '完了'}</div>
      <button
        onClick={() => {
          map?.panTo(DEFAULT_POSITION);
        }}
      >
        初期位置に戻る
      </button>
      <div ref={ref} style={{ height: '80vh', minHeight: 340 }}>
        {map != null &&
          clusters.map((cluster, index) => {
            if (checkIsClusterFeature(cluster)) {
              const clusterId = cluster.properties.cluster_id;
              return (
                <Cluster
                  key={cluster.id ?? index}
                  map={map}
                  position={cluster.geometry.coordinates}
                  count={cluster.properties.point_count}
                  onClick={async (event) => {
                    if (workerClient == null) {
                      return;
                    }
                    // クラスタークリック時に拡大するズーム値をWorker側で計算してその結果を取得
                    const zoom = await workerClient.call(
                      'getClusterExpansionZoom',
                      clusterId
                    );
                    map.setView(event.latlng, zoom);
                  }}
                />
              );
            }
 
            const spotId = cluster.properties.id;
            return (
              <Marker
                key={spotId ?? index}
                map={map}
                icon={spotId === selectedSpotId ? SELECTED_ICON : DEFAULT_ICON}
                pointFeature={cluster}
                position={cluster.geometry.coordinates}
                tooltipText={
                  spotId === selectedSpotId
                    ? `スポットID: ${spotId}`
                    : undefined
                }
                onClick={() => {
                  setSelectedSpotId(spotId);
                  const [lng, lat] = cluster.geometry.coordinates;
                  map.panTo(L.latLng(lat, lng));
                }}
              />
            );
          })}
      </div>
    </div>
  );
};

その他: クラスタリングの中身をページネーションで確認する

以上でクラスタリングと結果をマップに表示する実装が完了しましたが、最大までズームした後に中身を確認することができません。leaflet側で提供されているクラスタリングではgifアニメのように展開してマーカーが表示されるようになります。


leafletのクラスタリングライブラリのデフォルト挙動

マーカーが出ることでそれぞれクリックして選択させることができるのですが、数が増えると分かりづらくなるのと、そもそもマーカーとして出すメリットがそれほどないと思ったので、中身をページネーションで表示するような形で代用しました。なお、leafletのクラスタリングではhoverした時にエリアが表示していますが、こちらはなくても困らないと思うので実装はしませんでした。


ライブラリの代わりにクラスタ内のデータをページネーションで表示する自作実装

gifアニメのようなページネーションの表示は以下のようにleafletのツールチップにReact renderした結果を表示して実装しました。ツールチップの表示や更新をrenderが終わったタイミングで行うことでDOMのサイズが変わっても適切な位置に調整してくれます。

スポットIDリストをページネーション表示するコンポーネント
const PER_PAGE = 5;
const ContainSpotList: FC<{
  containSpotIds: number[];
  onRendered: () => void;
}> = ({ containSpotIds, onRendered }) => {
  const [page, setPage] = useState(0);

  useEffect(() => {
    onRendered();
  });

  return (
    <div style={{ minWidth: '140px' }}>
      <div style={{ marginBottom: '4px' }}>スポットIDリスト</div>
      <ul style={{ margin: 0, paddingLeft: '16px' }}>
        {containSpotIds
          .slice(page * PER_PAGE, (page + 1) * PER_PAGE)
          .map((spotId) => {
            return <li key={spotId}>{spotId}</li>;
          })}
      </ul>
      <div
        style={{
          marginTop: '4px',
          display: 'flex',
          gap: '4px',
          justifyContent: 'flex-end',
          alignItems: 'center',
        }}
      >
        <div style={{ paddingRight: '4px' }}>
          {1 + page * PER_PAGE} -{' '}
          {Math.min((page + 1) * PER_PAGE, containSpotIds.length)} /{' '}
          {containSpotIds.length}
        </div>
        <button
          disabled={page <= 0}
          onClick={() => {
            setPage(page - 1);
          }}
        >
          {'<'}
        </button>
        <button
          disabled={page + 1 >= containSpotIds.length / PER_PAGE}
          onClick={() => {
            setPage(page + 1);
          }}
        >
          {'>'}
        </button>
      </div>
    </div>
  );
};
ツールチップ上にスポットIDリストを描画して表示する
 import type { FC } from 'react';
 import { useRef, useState, useCallback, useEffect } from 'react';
+import { createRoot } from 'react-dom/client';
 import type { Position as GeoPosition } from 'geojson';
 import L from 'leaflet';

 export type ClusterProps = {
   /** Leafletのマップ */
   map: L.Map;
   /** 座標 */
   position: GeoPosition;
   /** 個数 */
   count: number;
+  /** クラスターが持っているスポットIDリスト */
+  containSpotIds?: number[];
   /**
    * クリック時
    */
   onClick: (event: L.LeafletMouseEvent) => void;
 };

 export const Cluster: FC<ClusterProps> = ({
   map,
   position,
   count,
+  containSpotIds,
   onClick,
 }) => {
   const [clusterMarker] = useState(() => {
     const size = count < 100 ? 'small' : count < 1000 ? 'medium' : 'large';
     const icon = L.divIcon({
       html: `<div><span>${count}</span></div>`,
       className: `marker-cluster marker-cluster-${size}`,
       iconSize: L.point(40, 40),
     });
 
     const [lng, lat] = position;
     const marker = L.marker(L.latLng(lat, lng), {
       icon,
     });
     return marker;
   });

   // 省略

+  useEffect(() => {
+    if (containSpotIds == null) {
+      return;
+    }
+
+    const element = document.createElement('div');
+    // バブリングによってクリックイベントを拾われないようにブロックしておく
+    element.addEventListener('click', (event) => {
+      event.preventDefault();
+      event.stopPropagation();
+    });
+    const root = createRoot(element);
+
+    let isFirstRendered = false;
+    root.render(
+      <ContainSpotList
+        containSpotIds={containSpotIds}
+        // Reactの描画が終わったら
+        onRendered={() => {
+          // 既に一度描画が終わっていたらツールチップの中身だけ更新して表示位置を調整する
+          if (isFirstRendered) {
+            clusterMarker.setTooltipContent(element);
+            return;
+          }
+
+          // 初めての描画の場合はoptionの設定をしてツールチップを表示する
+          isFirstRendered = true;
+          clusterMarker.bindTooltip(element, {
+            permanent: true,
+            interactive: true,
+            direction: 'top',
+            offset: [0, -10],
+          });
+        }}
+      />
+    );
+
+    return () => {
+      clusterMarker.unbindTooltip();
+      // 以下の警告を回避するためにワンクッション置いてからunmountする
+      // Attempted to synchronously unmount a root while React was already rendering.
+      setTimeout(() => {
+        root.unmount();
+      });
+    };
+  }, [clusterMarker, containSpotIds]);

   return null;
 };

クラスタリングの中身はgetLeavesメソッドで取得できるので、Worker経由で取得してセットします。

最大ズームの時、中身を取得してClusterコンポーネントに結果を渡す
 export const MapContainer: FC<MapContainerProps> = ({ numSpot }) => {
   const { ref, map, bounds } = useMap();
   const { isPreparing, workerClient, clusters } = useClusterWorker({
     numSpot,
     map,
     bounds,
   });
   const [selectedSpotId, setSelectedSpotId] = useState<number | null>(null);
+  const [selectedClusterSpotIdsMap, setSelectedClusterSpotIdsMap] = useState<
+    Record<number, number[]>
+  >({});

   return (
     <div>
       <div>{isPreparing ? '準備中' : '完了'}</div>
       <button
         onClick={() => {
           map?.panTo(DEFAULT_POSITION);
         }}
       >
         初期位置に戻る
       </button>
       <div ref={ref} style={{ height: '80vh', minHeight: 340 }}>
         {map != null &&
           clusters.map((cluster, index) => {
             if (checkIsClusterFeature(cluster)) {
               const clusterId = cluster.properties.cluster_id;
               return (
                 <Cluster
                   key={cluster.id ?? index}
                   map={map}
                   position={cluster.geometry.coordinates}
                   count={cluster.properties.point_count}
+                  containSpotIds={selectedClusterSpotIdsMap[clusterId]}
                   onClick={async (event) => {
                     if (
                       workerClient == null ||
+                      selectedClusterSpotIdsMap[clusterId] != null
                     ) {
                       return;
                     }
                     const zoom = await workerClient.call(
                       'getClusterExpansionZoom',
                       clusterId
                     );
                     map.setView(event.latlng, zoom);
+                    if (zoom <= map.getMaxZoom()) {
+                      return;
+                    }
+                    const points = await workerClient.call(
+                      'getLeaves',
+                      clusterId
+                    );
+                    setSelectedClusterSpotIdsMap({
+                      [clusterId]: points.map((point) => point.properties.id),
+                    });
+                    setSelectedSpotId(null);
                   }}
                 />
               );
             }

             // Markerのrender部分は省略
           })}
       </div>
     </div>
   );
 };

終わりに

以上がsupercluster & Web Workerで地図クラスタリングをする方法でした。superclusterのクラスタリング処理が圧倒的にパフォーマンスが良く、セットアップさえできれば1000万件であっても一瞬で結果を返してくれたのは驚きでした。セットアップもWeb Workerで切り出していることでアプリを止めずに裏でやってくれるのでもっと数が多くてもいけそうな気がしますが、これ以上増やすとメモリの上限に引っかかってしまいました。

メモリの上限はハイエンドのPCであればそこまで問題ならないと思いますが、低スペックのスマホだとかなり苦しいと思うので、弊社の場合だとスマホでアクセスすることを考えてバックエンド側でクラスタリングした方が良いだろうなと思いました。管理サイトのようにPCでしか使わない画面とかだと真価を発揮しそうです。
Web Workerはイベントでのやり取りなので保守性がかなり心配でしたが、いい感じにラップできて通常のメソッドとそこまで変わらないくらいの構成になったのが良かったです。汎用的な設計なので他でも使い回しができそうです。
自前でクラスタリングしたい場合やWeb Workerにロジックを切り出したい場合の参考になれば幸いです。

Discussion