0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MapLibre GL JSと地理院タイルでハザードマップUIを作る(Astroアイランド・レイヤー切替・出典表記)

0
Posted at

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/dynamicssr: 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。いずれも setLayoutPropertyvisibility を出し入れする。

// 背景タイル切り替え(排他)
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 に宣言し、表示は setLayoutPropertyvisibility で切替。ハザードは raster-opacity で半透明重畳
  • center[経度, 緯度] 順。ラスタ(ハザード)とベクタ(GeoJSON活断層)はデータの性質で使い分け
  • 出典 attribution は利用規約上の必須要件

地図ライブラリと公的タイルの組み合わせさえ押さえれば、課金なしで日本の防災データを可視化するUIは個人開発でも十分作れる。背景切替・ハザードのON/OFF・マーカーと、UIを足すほど「地図で見られる」価値が出てくる。


この記事は Zenn にも同じ内容を投稿しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?