3行まとめ
- MapLibre GL JS(無料・OSS)+ 地理院タイル+ハザードマップポータルで、災害ハザードマップのUIを作った
- Astro の **アイランド(
<script>)**で組むと、地図ライブラリの SSR 問題に悩まされない。サーバーのデータはdata-*属性でクライアントに渡す - 地理院タイル3種を背景に、ハザード4種を
raster-opacityで重畳。表示切替はsetLayoutProperty、出典 attribution は必須
公的なハザードデータ(洪水・土砂・津波など)を地図上で見せたい、というのは土地・防災系のサービスでよくある要件。地図ライブラリと地図タイルの選び方でコストも実装も変わる。
選んだのは MapLibre GL JS(Mapbox GL JS の OSS フォーク、無料・トークン不要)+ 地理院タイル(国土地理院の地図タイル)+ ハザードマップポータルのラスタタイル。Astro の静的サイトに組み込んだときの実装をまとめる。
なぜ MapLibre GL JS + 地理院タイルなのか
地図表示の選択肢はいくつかあるが、
- Google Maps — 高機能だが API キー必須・従量課金
- Leaflet — 軽量だがベクタタイル・GPUレンダリングは弱い
- Mapbox GL JS — 高機能だがアクセストークン必須・無料枠超過で課金
- MapLibre GL JS — Mapbox GL JS v1 からフォークした OSS。トークン不要・完全無料
個人開発でコストを抑えつつ、ラスタ/ベクタを GPU で滑らかに描きたいなら MapLibre が手堅い。maplibre-gl を直接使い、地図タイルは地理院タイル(https://cyberjapandata.gsi.go.jp/xyz/{種別}/{z}/{x}/{y}.png)にした。
Astro のアイランドで地図を埋め込む
React や Next.js だと、MapLibre は window/WebGL に依存するため SSR で死ぬ。next/dynamic の ssr: false で逃がす…という定番のハマりがある。
Astro はこのへんが楽。.astro の <script> タグはビルド時にバンドルされ、クライアントでのみ実行される。サーバー側で評価されないので、ssr: false 相当の小細工なしにそのまま地図を初期化できる。
---
// フロントマター(サーバー側)。propsを受け取りHTMLを組み立てる
const { lat, lng, areaName, evacFacilities } = Astro.props
const facilitiesJson = JSON.stringify(evacFacilities.map(f => ({ name: f.name, lat: f.lat, lng: f.lng })))
---
<div
id="hazard-map"
data-lat={lat}
data-lng={lng}
data-area={areaName}
data-facilities={facilitiesJson}
class="w-full h-72 sm:h-96"
></div>
<style is:global>
@import 'maplibre-gl/dist/maplibre-gl.css';
</style>
<script>
import maplibregl from 'maplibre-gl'
// ここはクライアントでのみ実行される
</script>
CSS は <style is:global> 内で @import 'maplibre-gl/dist/maplibre-gl.css' する。これを忘れるとコントロールやポップアップの見た目が崩れる。
サーバーのデータは data-* 属性で渡す
Astro のアイランドでは、フロントマター(サーバー)の変数を <script>(クライアント)から直接は参照できない。橋渡しは data-* 属性でやる。座標や施設リストは JSON文字列にして属性に載せ、クライアント側で dataset から読んでパースする。
const container = document.getElementById('hazard-map') as HTMLElement | null
if (!container) throw new Error('map container not found')
const lat = parseFloat(container.dataset.lat!)
const lng = parseFloat(container.dataset.lng!)
const facilities: Array<{ name: string; lat: number; lng: number }> =
JSON.parse(container.dataset.facilities!)
サーバーで取得したリスクデータや避難施設リストを、この経路でクライアントの地図へ流し込む。
地理院タイル3種をベースにする
地図の style(MapLibre style spec v8)に、地理院タイルをラスタソースとして登録する。淡色・標準・標高段彩図の3種を入れて、淡色をデフォルト表示・残り2つは非表示にしておく。
const map = new maplibregl.Map({
container,
style: {
version: 8,
sources: {
'gsi-pale': { type: 'raster', tiles: ['https://cyberjapandata.gsi.go.jp/xyz/pale/{z}/{x}/{y}.png'], tileSize: 256, maxzoom: 18 },
'gsi-std': { type: 'raster', tiles: ['https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png'], tileSize: 256, maxzoom: 18 },
'gsi-relief': { type: 'raster', tiles: ['https://cyberjapandata.gsi.go.jp/xyz/relief/{z}/{x}/{y}.png'], tileSize: 256, maxzoom: 18 },
},
layers: [
{ id: 'gsi-pale', type: 'raster', source: 'gsi-pale' },
{ id: 'gsi-std', type: 'raster', source: 'gsi-std', layout: { visibility: 'none' } },
{ id: 'gsi-relief', type: 'raster', source: 'gsi-relief', layout: { visibility: 'none' } },
],
},
center: [lng, lat], // [経度, 緯度] の順なので注意
zoom: 14,
attributionControl: false,
})
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right')
map.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-right')
-
centerは[経度, 緯度]の順。GIS系は経度→緯度が多く、[緯度, 経度]で書くと地図があらぬ場所に飛ぶ定番ミス -
attributionControl: falseで自動の出典表示を切り、AttributionControl({ compact: true })を手動で右下に置いて制御している - ズームコントロールは
NavigationControl。方位リングは不要なのでshowCompass: false
ハザードを重ねる
ハザードマップポータル(https://disaportaldata.gsi.go.jp/raster/...)のラスタタイルを、洪水・高潮・津波・土砂の4種、同じ style.sources/layers に足す。初期は全部非表示、半透明にしておく。
const DISAPORTAL = 'https://disaportaldata.gsi.go.jp/raster'
// sources
'hazard-flood': { type: 'raster', tiles: [`${DISAPORTAL}/01_flood_l2_shinsuishin_data/{z}/{x}/{y}.png`], tileSize: 256, minzoom: 2, maxzoom: 17 },
'hazard-surge': { type: 'raster', tiles: [`${DISAPORTAL}/03_hightide_l2_shinsuishin_data/{z}/{x}/{y}.png`], tileSize: 256, minzoom: 2, maxzoom: 17 },
'hazard-tsunami': { type: 'raster', tiles: [`${DISAPORTAL}/04_tsunami_newlegend_data/{z}/{x}/{y}.png`], tileSize: 256, minzoom: 2, maxzoom: 17 },
'hazard-landslide': { type: 'raster', tiles: [`${DISAPORTAL}/05_dosekiryukeikaikuiki/{z}/{x}/{y}.png`], tileSize: 256, minzoom: 2, maxzoom: 17 },
// layers(背景の上・マーカーの下)
{ id: 'hazard-flood', type: 'raster', source: 'hazard-flood', layout: { visibility: 'none' }, paint: { 'raster-opacity': 0.75 } },
// surge / tsunami / landslide も同様
raster-opacity: 0.75 で下の地名・地形が透けるようにする。不透明のまま重ねると、浸水域は分かっても「それがどの町か」が読めなくなる。minzoom/maxzoom はタイルの配信範囲に合わせる(範囲外は空白になる)。色分け自体はタイル側で済んでいるラスタなので、クライアントは重ねるだけ。
レイヤーの表示切り替え
背景3種はラジオボタンで排他切替、ハザード4種はチェックボックスでON/OFF。いずれも setLayoutProperty で visibility を出し入れする。
// 背景タイル切り替え(排他)
const bgLayers = ['pale', 'std', 'relief'] as const
document.querySelectorAll<HTMLInputElement>('input[name="bg-layer"]').forEach(radio => {
radio.addEventListener('change', () => {
bgLayers.forEach(id => {
map.setLayoutProperty(`gsi-${id}`, 'visibility', id === radio.value ? 'visible' : 'none')
})
})
})
// ハザード切り替え(複数ON/OFF)
const hazardLayers = ['flood', 'surge', 'tsunami', 'landslide'] as const
hazardLayers.forEach(id => {
document.getElementById(`toggle-hazard-${id}`)?.addEventListener('change', (e) => {
const checked = (e.target as HTMLInputElement).checked
map.setLayoutProperty(`hazard-${id}`, 'visibility', checked ? 'visible' : 'none')
})
})
レイヤーを最初に全部 style に宣言しておいて、表示を visibility で切り替えるのがポイント。クリックのたびに addLayer/removeLayer するより速いし、ソースのタイルキャッシュも効く。
マーカー・ポップアップ・GeoJSON
地点系の情報は HTMLマーカーで載せる。map.on('load') の中で、避難施設・周辺POI(駅・病院など)・中心地点のマーカーを生成し、それぞれ Popup を紐づける。
map.on('load', () => {
for (const f of facilities) {
const el = document.createElement('div')
el.className = 'map-evac-marker'
const popup = new maplibregl.Popup({ offset: 15, closeButton: true })
.setHTML(`<div style="font-weight:600">${esc(f.name)}</div>`)
new maplibregl.Marker({ element: el }).setLngLat([f.lng, f.lat]).setPopup(popup).addTo(map)
}
// 中心地点をfitBoundsで画角に収める
})
DOM要素(div+SVG)を maplibregl.Marker({ element }) でラップする方式。CSSでマーカーの色・サイズ・ホバー拡大を付けられる。ポップアップのHTMLは施設名・住所など可変文字列を入れるので、esc() で簡易エスケープしてから差し込む。
活断層のように「面」で見せたいものは、ラスタではなく GeoJSON ベクタレイヤーで重ねる。fill(塗り)+line(輪郭)の2レイヤーにすると、半透明の面と境界線が両方出て見やすい。
map.addSource('fault', { type: 'geojson', data: { type: 'Feature', geometry: geom, properties: {} } })
map.addLayer({ id: 'fault-fill', type: 'fill', source: 'fault', layout: { visibility: 'none' }, paint: { 'fill-color': '#f97316', 'fill-opacity': 0.2 } })
map.addLayer({ id: 'fault-line', type: 'line', source: 'fault', layout: { visibility: 'none' }, paint: { 'line-color': '#f97316', 'line-width': 1.5 } })
ハザードは「色分け済みラスタ」、活断層は「GeoJSONベクタ」と、データの性質で使い分けている。
出典表記を忘れない
地理院タイルもハザードマップポータルも、利用にあたって出典表記が要る。凡例の片隅にリンクを置いている。
<a href="https://maps.gsi.go.jp/development/ichiran.html" target="_blank" rel="noopener noreferrer">地理院タイル</a>
<a href="https://disaportal.gsi.go.jp/" target="_blank" rel="noopener noreferrer">ハザードマップポータル</a>
公的データはタダで使えるが「出典を書く」のが利用条件。ここを外すと規約違反になるので、デザイン上目立たなくても必ず入れる。
まとめ
MapLibre GL JS + 地理院タイルで地図UIを組むときの要点は、
- 地図ライブラリは MapLibre GL JS(トークン不要・無料)、タイルは地理院+ハザードマップポータルで日本の公的データをそのまま重畳できる
-
Astro のアイランド(
<script>)はクライアント専用実行なので、React/Next のssr: false的な小細工が要らない。サーバーのデータはdata-*属性で渡す - レイヤーは最初に全部
styleに宣言し、表示はsetLayoutPropertyのvisibilityで切替。ハザードはraster-opacityで半透明重畳 -
centerは[経度, 緯度]順。ラスタ(ハザード)とベクタ(GeoJSON活断層)はデータの性質で使い分け - 出典 attribution は利用規約上の必須要件
地図ライブラリと公的タイルの組み合わせさえ押さえれば、課金なしで日本の防災データを可視化するUIは個人開発でも十分作れる。背景切替・ハザードのON/OFF・マーカーと、UIを足すほど「地図で見られる」価値が出てくる。
この記事は Zenn にも同じ内容を投稿しています。