mcpField
L
large-scale-map-visualization

large-scale-map-visualization

github.com/curiositech開発257229 回インストール2 日前に更新
リポジトリ

large-scale-map-visualization について

大規模な地理データを扱うWeb地図開発者向けのパフォーマンス最適化スキルです。Leaflet.js、空間クラスタリング、ビューポート読み込みに加え、ズームに応じた段階表示やCanvas描画、地図操作の手法を扱います。

主な機能

  1. 01Leaflet.jsの地図性能最適化
  2. 02Superclusterによる空間クラスタリング
  3. 03ビューポートに応じたデータ読み込み
  4. 04ズームレベルに応じた段階表示
  5. 05Canvas描画とイベントのデバウンス

活用シーン

  1. 01多数のマーカーがある地図の最適化
  2. 02現在の地図範囲内の地点読み込み
  3. 03リアルタイム地図の描画性能改善
  4. 04ズームレベルに応じたクラスタ表示

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/curiositech/some_claude_skills --skill large-scale-map-visualization

インストール後、Claude Code で/large-scale-map-visualizationを入力して読み込まれたか確認します。

同梱ファイル.claude-plugin/plugin.json

詳細

大規模マップ可視化のエキスパート

5,000〜100,000件以上の地理データポイントを扱う、高パフォーマンスなウェブマップ実装のエキスパートです。Leaflet.js の最適化、空間クラスタリングアルゴリズム、ビューポートベースの読み込み、マップアプリケーション向けの段階的な情報開示 UX パターンを専門としています。

起動トリガー

次の場合に起動: 「マップのパフォーマンス」、「マーカーが多すぎる」、「マップが遅い」、「クラスタリング」、「1万件のポイント」、「マーカーのクラスタリング」、「Leaflet のパフォーマンス」、「空間可視化」、「地理空間クラスタリング」、「ビューポート読み込み」、「マップデータの最適化」、「リアルタイムマップ」、「Supercluster」、「マーカークラスター」

次の場合は対象外: 静的マップ画像(Mapbox/Google Static を使用) | 3D 可視化(Maplibre GL を使用) | 地理データ以外の可視化(D3.js/Chart.js を使用) | 100個未満のマーカーを使うシンプルなマップ(標準の Leaflet で十分です)

主な専門分野

パフォーマンスアーキテクチャ
┌─────────────────────────────────────────────────────────────┐
│              MAP PERFORMANCE TIERS                          │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  0-100 markers    → Vanilla Leaflet (no optimization)      │
│  100-1,000        → Basic clustering (react-leaflet-cluster)│
│  1,000-10,000     → Supercluster + viewport loading        │
│  10,000-50,000    → Supercluster + canvas + sampling       │
│  50,000-500,000   → Web Workers + server-side clustering   │
│  500,000+         → MVT tiles + backend pre-aggregation    │
│                                                             │
└─────────────────────────────────────────────────────────────┘
技術スタックの選定
ユースケース最適なライブラリ理由
React + 5,000件未満のポイントreact-leaflet-cluster手軽に導入でき、Leaflet.markercluster をラップします
React + 5,000〜50,000件のポイントuse-supercluster フック3〜5倍高速で、ビューポートを考慮し、GeoJSON にネイティブ対応しています
React + 50,000件以上のポイントsupercluster + Web Workersクラスタリングをバックグラウンドスレッドにオフロードします
静的サイトサーバーサイドクラスタリングビルド時に事前計算します
リアルタイム更新Canvas レンダラー + サンプリングDOM 操作を最小限に抑えます

主な手法

1. Supercluster によるマーカークラスタリング

Supercluster が代替手段より優れている理由:

  • パフォーマンス: Leaflet.markercluster の8秒以上に対して、50万件のポイントを1〜2秒で処理します
  • アーキテクチャ: インデックスベースの k-d ツリークラスタリングで、サーバーサイドまたは Worker 内で実行できます
  • API: シンプルな GeoJSON の入出力
  • ビューポート対応: 表示範囲内のポイントのみをクラスタリングします

実装パターン:

import useSupercluster from "use-supercluster";

export function OptimizedMap({ locations }: { locations: Place[] }) {
  const mapRef = useRef<L.Map | null>(null);
  const [bounds, setBounds] = useState<BBox | null>(null);
  const [zoom, setZoom] = useState(10);

  // Convert to GeoJSON Feature collection
  const points = useMemo(() =>
    locations.map(place => ({
      type: "Feature" as const,
      properties: {
        cluster: false,
        placeId: place.id,
        place
      },
      geometry: {
        type: "Point" as const,
        coordinates: [place.longitude, place.latitude]
      }
    })),
    [locations]
  );

  // Cluster points based on viewport
  const { clusters, supercluster } = useSupercluster({
    points,
    bounds,
    zoom,
    options: {
      radius: 75,        // Cluster radius in pixels
      maxZoom: 16,       // Stop clustering at street level
      minPoints: 2       // Minimum points to form cluster
    }
  });

  // Update viewport on map move
  useEffect(() => {
    if (!mapRef.current) return;

    const handleMove = () => {
      const map = mapRef.current!;
      const b = map.getBounds();
      setBounds([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]);
      setZoom(map.getZoom());
    };

    mapRef.current.on("moveend", handleMove);
    handleMove(); // Initial load

    return () => mapRef.current?.off("moveend", handleMove);
  }, []);

  return (
    <MapContainer ref={mapRef} preferCanvas={true}>
      {clusters.map(cluster => {
        const [lng, lat] = cluster.geometry.coordinates;
        const { cluster: isCluster, point_count } = cluster.properties;

        if (isCluster) {
          return (
            <Marker
              key={`cluster-${cluster.id}`}
              position={[lat, lng]}
              icon={createClusterIcon(point_count, zoom)}
              eventHandlers={{
                click: () => {
                  const expansionZoom = Math.min(
                    supercluster!.getClusterExpansionZoom(cluster.id),
                    18
                  );
                  mapRef.current?.setView([lat, lng], expansionZoom, {
                    animate: true
                  });
                }
              }}
            />
          );
        }

        return (
          <PlaceMarker
            key={cluster.properties.placeId}
            place={cluster.properties.place}
          />
        );
      })}
    </MapContainer>
  );
}
2. ビューポートベースの読み込み(Supabase + PostGIS)

データベース関数:

CREATE OR REPLACE FUNCTION find_in_viewport(
  min_lng DOUBLE PRECISION,
  min_lat DOUBLE PRECISION,
  max_lng DOUBLE PRECISION,
  max_lat DOUBLE PRECISION,
  zoom_level INTEGER DEFAULT 11,
  max_results INTEGER DEFAULT 10000
)
RETURNS TABLE (
  id UUID,
  name TEXT,
  latitude DOUBLE PRECISION,
  longitude DOUBLE PRECISION
  /* other fields */
) AS $$
BEGIN
  -- At low zoom levels, sample to reduce density
  IF zoom_level < 9 THEN
    RETURN QUERY
    SELECT
      p.id, p.name,
      ST_Y(p.geog::geometry) as latitude,
      ST_X(p.geog::geometry) as longitude
    FROM places p
    WHERE p.geog && ST_MakeEnvelope(min_lng, min_lat, max_lng, max_lat, 4326)::geography
    AND random() < 0.2  -- Show 20% for performance
    LIMIT max_results / 2;
  ELSE
    -- Full data at higher zoom
    RETURN QUERY
    SELECT
      p.id, p.name,
      ST_Y(p.geog::geometry) as latitude,
      ST_X(p.geog::geometry) as longitude
    FROM places p
    WHERE p.geog && ST_MakeEnvelope(min_lng, min_lat, max_lng, max_lat, 4326)::geography
    LIMIT max_results;
  END IF;
END;
$$ LANGUAGE plpgsql STABLE;

-- Ensure spatial index exists
CREATE INDEX IF NOT EXISTS idx_places_geog ON places USING GIST (geog);

React Query フック:

import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/lib/supabase";

type BBox = [number, number, number, number]; // [west, south, east, north]

export function usePlacesInViewport(
  bounds: BBox | null,
  zoom: number,
  enabled = true
) {
  return useQuery({
    queryKey: ["places", "viewport", bounds?.join(","), zoom],
    queryFn: async () => {
      if (!bounds) return [];

      const [west, south, east, north] = bounds;

      const { data, error } = await supabase.rpc("find_in_viewport", {
        min_lng: west,
        min_lat: south,
        max_lng: east,
        max_lat: north,
        zoom_level: zoom
      });

      if (error) throw error;
      return data || [];
    },
    enabled: enabled && !!bounds,
    staleTime: 5 * 60 * 1000,    // 5 min (locations rarely change)
    gcTime: 30 * 60 * 1000,       // 30 min in cache
    refetchOnWindowFocus: false
  });
}
3. 段階的な情報開示戦略

ズームレベルに応じて適切な詳細レベルを表示します。

const getClusterOptions = (zoom: number) => ({
  radius: zoom < 10 ? 100 : zoom < 14 ? 75 : 50,
  maxZoom: 16,
  minPoints: zoom < 10 ? 5 : 2
});

const getMarkerSize = (zoom: number) =>
  zoom < 12 ? 24 : zoom < 15 ? 32 : 40;

const shouldShowLabel = (zoom: number) => zoom >= 14;
4. パフォーマンス向上のための Canvas レンダリング
import L from "leaflet";

// Enable canvas renderer globally
const canvasRenderer = L.canvas({
  tolerance: 10,      // Hit detection tolerance
  padding: 0.5        // Extra render area (0.5 = 50% of viewport)
});

const mapOptions = {
  preferCanvas: true,
  renderer: canvasRenderer,
  // Disable animations on mobile
  zoomAnimation: !isMobile(),
  fadeAnimation: !isMobile(),
  markerZoomAnimation: !isMobile()
};

パフォーマンス向上: 1,000個以上のマーカーでレンダリングが3〜5倍高速になります

5. 効率的なクラスターアイコン
import L from "leaflet";

// Use divIcon (faster than custom components)
function createClusterIcon(count: number, zoom: number) {
  const size = getMarkerSize(zoom);

  return L.divIcon({
    html: `
      <div style="
        width: ${size}px;
        height: ${size}px;
        background: linear-gradient(135deg, #d97706, #f59e0b);
        border-radius: 50%;
        border: 3px solid #1a1410;
        display: flex;
        align-items: center;
        justify-content: center;
        color: white;
        font-weight: bold;
        font-size: ${zoom < 12 ? '10px' : '14px'};
        box-shadow: 0 4px 12px rgba(0,0,0,0.4);
      ">
        ${count}
      </div>
    `,
    className: "cluster-icon",
    iconSize: [size, size],
    iconAnchor: [size / 2, size / 2]
  });
}
6. マップイベントのデバウンス
import { useDebouncedCallback } from "use-debounce";

const handleMapMove = useDebouncedCallback(() => {
  const bounds = mapRef.current?.getBounds();
  const zoom = mapRef.current?.getZoom();
  if (bounds && zoom) {
    setBounds([
      bounds.getWest(),
      bounds.getSouth(),
      bounds.getEast(),
      bounds.getNorth()
    ]);
    setZoom(zoom);
  }
}, 300); // 300ms debounce

useEffect(() => {
  mapRef.current?.on("moveend", handleMapMove);
  return () => mapRef.current?.off("moveend", handleMapMove);
}, []);

パフォーマンスベンチマーク

実環境でのテストと調査に基づいています(出典は参考資料を参照)。

戦略1,000件のポイント5,000件のポイント10,000件のポイントモバイル(4G)
クラスタリングなし800ms3.5s ❌8s ❌12s ❌
基本的なクラスタリング400ms1.8s ⚠️4s ⚠️6s ❌
Leaflet.markercluster200ms800ms ⚠️2s ⚠️3s ⚠️
Supercluster + ビューポート150ms ✅300ms ✅500ms ✅800ms ✅
Supercluster + Canvas100ms ✅200ms ✅350ms ✅500ms ✅

目標とするパフォーマンス:

  • 初期読み込み: 500ms 未満(体感速度)
  • パン/ズーム: 200ms 未満で応答
  • マーカーのクリック: 100ms 未満
  • モバイル: デスクトップの2倍の時間まで許容

UX パターン

クラスターの操作パターン
  1. クリックして展開(推奨)

    • クラスターをクリック → 展開ズームレベルまでズームします
    • 内包するポイントを「スパイダー」表示します
  2. クリックしてリスト表示

    • クラスターをクリック → すべてのアイテムをサイドバーに表示します
    • 密集したエリア(繁華街の中心部など)に適しています
  3. ホバーでプレビュー

    • クラスターにホバー → 件数と上位3件を表示します
    • 発見を促す UX に適しています
読み込み状態
{isLoading && (
  <div className="absolute inset-0 bg-leather-900/50 backdrop-blur-sm z-[1000] flex items-center justify-center">
    <div className="text-sand-100">
      Loading {loadedCount} of {totalCount} locations...
    </div>
  </div>
)}
空の状態
{!isLoading && clusters.length === 0 && (
  <div className="absolute inset-0 flex items-center justify-center z-[999]">
    <div className="text-center max-w-md p-6">
      <MapPin className="h-12 w-12 text-sand-400 mx-auto mb-4" />
      <h3 className="font-bitter text-xl text-sand-100 mb-2">
        No locations in this area
      </h3>
      <p className="text-sand-400 mb-4">
        Try zooming out or searching a different location.
      </p>
      <button onClick={resetView} className="btn-primary">
        Reset View
      </button>
    </div>
  </div>
)}

よくある落とし穴

❌ 避けるべきアンチパターン
  1. すべてのデータを最初に読み込む

    // BAD: Fetches 10k records on mount
    const { data } = useQuery(["all-places"], fetchAllPlaces);
    
  2. マップが移動するたびに再レンダリングする

    // BAD: Updates state on every pixel
    map.on("move", () => setBounds(map.getBounds()));
    
  3. 複雑なマーカーコンポーネント

    // BAD: React component per marker
    <Marker icon={<ComplexSVGComponent />} />
    
  4. ズームレベルに応じた調整をしない

    // BAD: Same clustering at all zoom levels
    const clusterOptions = { radius: 80, maxZoom: 20 };
    
✅ ベストプラクティス
  1. デバウンスを使ったビューポートベースの読み込み
  2. シンプルなマーカーアイコン(インラインスタイルを使った divIcon)
  3. 段階的な情報開示(ズームレベルに合わせて調整)
  4. 大規模なデータセットには Canvas レンダリングを使用
  5. React Query のキャッシュを適切に設定

実例

Zillow のパターン
  • 低ズーム:近隣エリアごとの価格クラスター
  • 中ズーム:価格付きの個別物件
  • 高ズーム:物件カード全体
  • クリック:クラスターを展開するか、詳細を開きます
Airbnb のパターン
  • サーバーサイド:10 段階のズームレベルで事前にクラスター化
  • クライアントサイド:300ms のデバウンスを適用したビューポート API
  • レンダリング:価格ラベルには Canvas を使用
  • インタラクション:ホバーでプレビュー、クリックで詳細を表示
OpenStreetMap のパターン
  • タイルベース:事前にレンダリングされたラスタータイル
  • ベクタータイル:10 万件以上の POI 向け
  • 簡略化:低ズーム時に詳細を減らします
  • キャッシュ:CDN とブラウザーキャッシュを積極的に活用

技術スタックとの互換性

フレームワーク
  • ✅ Next.js 13 以降(App Router + Server Components)
  • ✅ Next.js Pages Router
  • ✅ Vite + React
  • ✅ Remix
  • ✅ Astro(クライアントアイランドを使用)
データベース
  • ✅ Supabase(PostGIS) - 推奨。空間インデックスが組み込まれています
  • ✅ PostgreSQL + PostGIS
  • ⚠️ MongoDB(地理空間クエリは PostGIS より低速です)
  • ⚠️ Firebase(空間クエリのサポートが限定的です)
マップライブラリ
  • ✅ Leaflet.js - 静的タイル + マーカーに最適
  • ✅ Mapbox GL JS - ベクタータイルに適しています
  • ✅ Maplibre GL JS - オープンソースの Mapbox 代替
  • ❌ Google Maps API - 高価で、柔軟性に欠けます

移行チェックリスト

既存の低速なマップを最適化する場合:

  • 現在のパフォーマンスを測定します(Chrome DevTools の Performance タブ)
  • データセット内のマーカー/ポイントの総数を数えます
  • データベースに空間インデックスがあるか確認します(EXPLAIN ANALYZE)
  • クラスタリングライブラリをインストールします(npm install use-supercluster)
  • ビューポートベースの読み込みを実装します
  • Canvas レンダラーのオプションを追加します
  • モバイルデバイスでテストします(4G の速度制限を適用)
  • 読み込み状態を追加します
  • プログレッシブディスクロージャーを実装します
  • パフォーマンス監視を設定します
  • ズームレベルごとの動作を文書化します

依存関係

{
  "dependencies": {
    "leaflet": "^1.9.4",
    "react-leaflet": "^4.2.1",
    "supercluster": "^8.0.1",
    "use-supercluster": "^1.2.0",
    "@tanstack/react-query": "^5.0.0",
    "use-debounce": "^10.0.0"
  }
}

参考資料

研究論文
技術ガイド
UX に関する調査

バージョン履歴

  • 2026-01-09:sobriety.tools の場所マップ最適化に基づき、初版のスキルを作成
  • 8 件の信頼できる情報源から調査内容を統合
  • Next.js 15、Leaflet 1.9.4、Supabase PostGIS でテスト済み

スキルの作成者:Claude Code(Sonnet 4.5) 分野:地理空間データの可視化、Web パフォーマンス 難易度:上級(PostGIS、React、空間アルゴリズムの知識が必要)

コメント