はじめに

MapLibre GL JS で地図上にカスタムマーカーを配置する際、maplibregl.Marker に独自の DOM 要素を渡す方法がよく使われる。件数バッジ付きの丸いマーカーなど、CSS で自由にスタイリングできる利点がある。

しかし、この方法にはズーム・パン操作時にマーカーが地図の動きに遅れて追従するという問題がある。特にマーカー数が多い場合やモバイルデバイスでは顕著になり、ズームアウトすると本来陸上にあるべきマーカーが海上に表示されるような視覚的な不整合も起きる。

本記事では、この問題の原因と、GeoJSON ソース + レイヤーを使った根本的な解決方法を解説する。

問題の再現

DOM マーカーによる実装(問題あり)

for (const point of dataPoints) {
  const el = document.createElement('div')
  Object.assign(el.style, {
    width: '32px',
    height: '32px',
    borderRadius: '50%',
    backgroundColor: point.color,
    border: '3px solid white',
    boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
    cursor: 'pointer',
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
  })

  // 件数バッジ
  if (point.count > 1) {
    const badge = document.createElement('span')
    badge.style.color = 'white'
    badge.style.fontSize = '11px'
    badge.style.fontWeight = 'bold'
    badge.textContent = String(point.count)
    el.appendChild(badge)
  }

  new maplibregl.Marker({ element: el })
    .setLngLat([point.lng, point.lat])
    .addTo(map)
}

この実装では以下の問題が発生する。

症状

  1. ズーム・パン時の遅延: マーカーが地図の動きに 1〜2 フレーム遅れて追従する
  2. ズームアウト時の位置ずれ: 沿岸都市のマーカーが海上に表示される
  3. パフォーマンス低下: マーカー数が増えると DOM の再配置コストが増大する

原因

maplibregl.Marker は DOM 要素を CSS transform で配置する仕組みになっている。地図がズーム・パンされるたびに、各マーカーの CSS transform を再計算して更新する必要があり、これが WebGL キャンバスの描画と非同期で行われるため、視覚的なずれが生じる。

地図描画 (WebGL)  ─── フレーム N ──→ フレーム N+1
マーカー更新 (DOM) ─── (遅延) ──→ フレーム N+1 or N+2

また、maplibregl.Marker のデフォルトアンカーは要素の下端中央になっている。ズームレベルが低い(広域表示の)場合、マーカー要素の高さ分のオフセットが地理的に大きな距離に相当し、マーカーが実際の座標より南にずれて見える。anchor: 'center' を指定すれば軽減できるが、遅延の問題は残る。

解決方法:GeoJSON ソース + レイヤー

MapLibre GL JS には、GeoJSON データをソースとして登録し、circle レイヤーや symbol レイヤーとして描画する仕組みがある。この方法ではマーカーが WebGL キャンバス上で直接描画されるため、地図の動きと完全に同期する。

GeoJSON データの構築

function buildGeoJSON(dataPoints) {
  return {
    type: 'FeatureCollection',
    features: dataPoints.map((point, i) => ({
      type: 'Feature',
      id: i,
      properties: {
        index: i,
        count: point.count,
        label: point.count > 1 ? String(point.count) : '',
        color: point.color,
      },
      geometry: {
        type: 'Point',
        coordinates: [point.lng, point.lat],
      },
    })),
  }
}

ソースとレイヤーの追加

map.on('load', () => {
  // GeoJSON ソースを追加
  map.addSource('points', {
    type: 'geojson',
    data: buildGeoJSON(dataPoints),
  })

  // Circle レイヤー(マーカー本体)
  map.addLayer({
    id: 'point-circles',
    type: 'circle',
    source: 'points',
    paint: {
      'circle-radius': [
        'case',
        ['>', ['get', 'count'], 1], 14,  // 複数件
        10,                                // 1件
      ],
      'circle-color': ['get', 'color'],
      'circle-stroke-width': 2,
      'circle-stroke-color': 'white',
    },
  })

  // Symbol レイヤー(件数ラベル)
  map.addLayer({
    id: 'point-labels',
    type: 'symbol',
    source: 'points',
    filter: ['>', ['get', 'count'], 1],
    layout: {
      'text-field': ['get', 'label'],
      'text-size': 11,
      'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
      'text-allow-overlap': true,
    },
    paint: {
      'text-color': 'white',
      'text-halo-color': 'rgba(0,0,0,0.3)',
      'text-halo-width': 1,
    },
  })
})

クリックイベントの処理

DOM マーカーでは要素に直接 addEventListener できたが、レイヤー方式では map.on('click', layerId, handler) を使う。

map.on('click', 'point-circles', (e) => {
  if (!e.features?.length) return
  const props = e.features[0].properties
  const coords = e.features[0].geometry.coordinates

  // ポップアップ表示
  new maplibregl.Popup({ offset: 15 })
    .setHTML(`<b>${props.region}</b><br/>${props.count}件`)
    .setLngLat(coords)
    .addTo(map)
})

// カーソルをポインターに変更
map.on('mouseenter', 'point-circles', () => {
  map.getCanvas().style.cursor = 'pointer'
})
map.on('mouseleave', 'point-circles', () => {
  map.getCanvas().style.cursor = ''
})

選択状態のハイライト

DOM マーカーでは el.style.border を直接変更できたが、レイヤー方式ではフィルタを使って選択マーカーのみにスタイルを適用する。

// ハイライト用の別レイヤーを追加
map.addLayer({
  id: 'point-selected',
  type: 'circle',
  source: 'points',
  filter: ['==', ['get', 'index'], -1], // 初期状態は非表示
  paint: {
    'circle-radius': 17,
    'circle-color': 'transparent',
    'circle-stroke-width': 3,
    'circle-stroke-color': '#FFD700',
  },
})

// 選択時にフィルタを更新
function selectPoint(index) {
  map.setFilter('point-selected', ['==', ['get', 'index'], index])
}

データの動的更新

データが変わった場合は setData でソースを更新する。

const source = map.getSource('points')
source.setData(buildGeoJSON(newDataPoints))

比較

DOM マーカーGeoJSON レイヤー
描画方式CSS transformWebGL キャンバス
ズーム同期遅延あり完全同期
パフォーマンスマーカー数に比例して低下数千点でも軽快
スタイリングCSS で自由レイヤー paint/layout プロパティ
クリックイベントDOM addEventListenermap.on(‘click’, layerId)
選択ハイライトstyle 直接変更フィルタ + 別レイヤー
複雑な HTML可能不可(circle/symbol のみ)

DOM マーカーが適切なケース

GeoJSON レイヤーが万能というわけではない。以下のようなケースでは DOM マーカーの方が適切な場合もある。

  • 複雑な HTML 構造(フォーム、画像、リッチなツールチップなど)をマーカー内に埋め込む必要がある場合
  • マーカー数が少なく(10 個以下程度)、パフォーマンスが問題にならない場合
  • CSS アニメーションを活用したい場合

まとめ

MapLibre GL JS で多数のマーカーを地図上に配置する場合、DOM ベースの maplibregl.Marker ではなく GeoJSON ソース + Circle/Symbol レイヤーを使うことで、ズーム・パン時の遅延を根本的に解消できる。特に、データの件数表示やクラスタリング的な表現は circle + symbol レイヤーの組み合わせで十分に実現可能である。