はじめに
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〜2 フレーム遅れて追従する
- ズームアウト時の位置ずれ: 沿岸都市のマーカーが海上に表示される
- パフォーマンス低下: マーカー数が増えると 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 transform | WebGL キャンバス |
| ズーム同期 | 遅延あり | 完全同期 |
| パフォーマンス | マーカー数に比例して低下 | 数千点でも軽快 |
| スタイリング | CSS で自由 | レイヤー paint/layout プロパティ |
| クリックイベント | DOM addEventListener | map.on(‘click’, layerId) |
| 選択ハイライト | style 直接変更 | フィルタ + 別レイヤー |
| 複雑な HTML | 可能 | 不可(circle/symbol のみ) |
DOM マーカーが適切なケース
GeoJSON レイヤーが万能というわけではない。以下のようなケースでは DOM マーカーの方が適切な場合もある。
- 複雑な HTML 構造(フォーム、画像、リッチなツールチップなど)をマーカー内に埋め込む必要がある場合
- マーカー数が少なく(10 個以下程度)、パフォーマンスが問題にならない場合
- CSS アニメーションを活用したい場合
まとめ
MapLibre GL JS で多数のマーカーを地図上に配置する場合、DOM ベースの maplibregl.Marker ではなく GeoJSON ソース + Circle/Symbol レイヤーを使うことで、ズーム・パン時の遅延を根本的に解消できる。特に、データの件数表示やクラスタリング的な表現は circle + symbol レイヤーの組み合わせで十分に実現可能である。