Nuxt4とd3.jsでインタラクティブな日本地図を作る

掲載日

はじめに

Nuxt4とd3.jsでインタラクティブな日本地図を描画して、各都道府県のsvg画像を取得できるツールを作成したのでその技術ログです。

前提条件

  • Nuxt:4.1.1
  • d3 .js:7.9.0
    ※d3はバージョンによって結構書き方は気を付けないといけなさそう。

実装

d3.jsをinstall

まずd3ライブラリを追加します。

yarn add d3 @types/d3

地図情報を国土地理院データとmapshaperで取得

国土地理院のデータ

https://nlftp.mlit.go.jp/ksj/gml/datalist/KsjTmplt-N03-2024.html

下にスクロールするとZipファイルをDL出来るので、全国、各都道府県など必要なデータをダウンロードします。

ダウンロード出来たら解凍し、中にある「.geojson」拡張子のものを使用します。

利用規約のページがあり、利用の際は出典を明記する必要がありますので注意しましょう。

mapshaperで軽量化&シンプル化

国土地理院データは国の計量だけあって細かい離島の描写や海岸線等がとても高精度に描かれていて、その分データ容量も大きくなっています。

容量が大きいとWebページの表示にも影響が出るので、mapshaperというサイトか、コマンドを使って軽量化します。

どこまで小さくするかは地図の表示と用途によって要調整。

 

今回は軽量化だけすればいいやと思いサイトの方で軽量化だけ行っています。
コマンドを使えば(サイトの方でも?)天気予報などでおなじみの沖縄を右下に配置なども可能です。

d3.jsで地図を描画

データを取得してきたら、まずシンプルに地図を描画してみます。

<script setup lang="ts">
import * as d3 from 'd3'
import simpleMap from '~/assets/json/simplemap.json'

type GeoJSON = {
    type: string
    features: []
}

const json = simpleMap as GeoJSON

onMounted(() => {
    const width = 600
    const height = 600

    const svg = d3.select('#map').append('svg') // mapDiv配下に地図描画のためのsvg追加
        .attr('height', height) // 高さ指定
        .attr('width', width) // 横幅指定

    // 緯度経度を画面上の座標に変換する関数。参考にしたサイトではメルカトル図法を使っていたが、メルカトルだと塗りつぶしで枠外を塗りつぶしてしまって上手くいかなかった。
    const projection = d3.geoIdentity()
        .reflectY(true) // そのままだと日本地図がひっくり返ってしまうのでreflectYを指定する
        .fitSize([width, height], json as d3.GeoGeometryObjects) // 縦横指定してサイズを合わせる。

    const path = d3.geoPath(projection) // simpleMapのfeaturesの情報を、projection関数を通してsvgのpath要素のd属性の形式に変換する関数。

    svg.selectAll('path') // 指定の要素を選択する。さらに空の要素も選択できる?ということなので、dataでバインドしてenterで生成されたプレースホルダーをselectし、appendでそのプレースホルダーにpath要素を注入する…ということをやってると思われる?
        .data(json.features) // 選択した要素に配列をバインド。
        .enter() // 配列の要素数が既存の要素数より多い場合に、不足分のデータに対するプレースホルダーを生成する。(新しいDOM要素作成の準備をここで行う。)
        .append('path') // enterで生成されたプレースホルダーにDOM要素を追加する。
        .attr('d', path) // 上記で作成した関数を使ってd属性を設定
        .attr('stroke', 'black') // 都道府県の枠線の色設定
        .attr('stroke-width', 1) // 都道府県の枠線の太さ設定
        .attr('fill', 'none') // 塗りつぶし設定。
})
</script>

<template>
    <main class="max-w-5xl mx-auto p-4">
        <div
            id="map"
            class="flex items-center justify-center"
        />
    </main>
</template>

上記をコピペしてブラウザからアクセスすると以下のように表示されます。

シンプルに日本地図を表示した際のスクリーンショット。
出典:国土交通省国土数値情報ダウンロードサイト (データを加工して作成)

 

  • 参考にしたサイトでは、geoMercator(メルカトル図法)を使って描画していますが、何故かメルカトル図法を使うとパスが都道府県の形にならない?ようでfillで枠外を塗りつぶしたり意図した動作をしませんでした。
    代わりにgeoIdentity(恒等写像)を使っています。しかしこれがなぜうまくいくのか、メルカトル図法だとなぜ駄目かは分からず…。
    使用しているデータが参考サイトと違うので、それが原因かとも思ったのですが、参考サイトのデータを使ってもやはりメルカトル図法では上手くいかない。
    d3のバージョンが上がってるのでその辺が原因でしょうか。謎です。

インタラクティブ化

全国もしくは各都道府県を選択するとその都道府県だけをsvg画像を取得出来るようにしたいです。

選択した都道府県の位置が分かるように、クリックするとその都道府県を青色で塗りつぶすようにしてみます。

// --- (省略) ---
        .attr('fill', 'white') // 塗りつぶし設定。ここをnoneにすると各種イベントが効きにくくなる。noneだと枠線部分にしか判定が無い?
        .attr('data-pref-name', function (features) {
            return features['properties']['N03_001']
        }) // 離島などがありpathが分かれている都道府県をまとめて処理するため、N03_001に入っている都道府県データを取得する
        .on('click', function (_, features: Features) {
            const pref = features.properties.N03_001 // 付与しておいたdataを使って、クリックしたパスを取得
            const target = d3.selectAll('[data-pref-name=' + pref + ']')
            const indexOfPref = clickedPref.value.indexOf(pref)
            if (indexOfPref == -1) {
                clickedPref.value.push(pref)
                target.attr('fill', clickedColor)
            }
            else {
                clickedPref.value.splice(indexOfPref, 1)
                target.attr('fill', 'white')
            }
        })
</script>
// --- (省略) ---
  • attr('fill', '[カラーコード]')の部分をwhiteに変更します。ここをnoneにすると、クリックイベント等の効きが悪くなります。
    推測ですが、fill: noneにするとパスの範囲内であるという判定が無くなって枠線上にしかクリック判定が存在しないのかと思います。
    (ただ、枠線に合わせてクリックすれば効くかというとそんなこともなく。
    そんなにしっかり検証しておらず、目視チェックなので枠線に合わせてるつもりで合ってない可能性もありますが、本当の所は謎です。)
  • 離島が所属する都道府県はpathが離れているので、「クリックイベントが発生したpathを塗りつぶす」、という方法だと、例えば北海道をクリックしているのに択捉島辺りに色が付きません。
    なので、svg生成時にdata-pref-name属性に各都道府県名を付与しておき、click時に都道府県名を取得、pathをまとめて色を付けます。
    ついでにこの後のダウンロード操作に使うために、クリックした都道府県の情報を取得しておきます。

クリックした都道府県をsvg画像として保存

続いてクリックした都道府県部分をsvg画像として保存します。

描画用のsvg領域を作成しておき、そこに雑にクリックした箇所のpathを入れて保存してもそれっぽくはなるのですが、座標の位置が変わらないので上下左右に空白が出来てしまいます。

svgのパスを変更せずに保存すると、北海道が左に空白がある状態で表示されてしまう画像例。
出典:国土交通省国土数値情報ダウンロードサイト (データを加工して作成)


(添付のように、北海道なら左に余白が出来てしまいます。沖縄など南の方の都道府県は上に余白が出来ます。)

そこで、DLする部分の都道府県のパスを取得して分解し、最小x, y値を取得して各座標から引き算して左上詰めを行います。

path要素の分解に使う情報はMDNの「<path>」のページを参考にして、M(MoveTo)とL(LineTo)から引いています。

// --- (省略) ---
// 最大値最小値格納用インターフェイス

type MaxMin = {
    maxX: number
    maxY: number
    minX: number
    minY: number
}

// pathのMoveTo、LineTo要素を抽出するための正規表現
const TO = /[MmLl][0-9.]*,[0-9.]*/g
// 引き算する際に各接頭辞は邪魔になるので削除用正規表現
const REMOVE = /[MmLl]/

const onClickDownload = function () {
    const svg = document.createElement('svg') // DL用svg作成
    svg.setAttribute('id', 'downloadSvg')
    svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg')


    // 各県のパーツを左上に寄せるためと、画像サイズを決めるために最大・最小のx, y値を取得する
    let ds = ''
    for (const pref of clickedPref.value) {
        const target = d3.selectAll('[data-pref-name=' + pref + ']')
        if (target._groups) {
            for (const path of target._groups[0]) {
                ds += path.getAttribute('d').replace('Z', '')
            }
        }
    }
    // getMaxMinXY関数で最大値最小値を取得
    const maxMin = getMaxMinXY(ds)
    
    // 最大xy座標から最小xy座標を引いて画像サイズを決定
    svg.setAttribute('width', (maxMin.maxX - maxMin.minX).toString())
    svg.setAttribute('height', (maxMin.maxY - maxMin.minY).toString())
    
    for (const pref of clickedPref.value) {
        const target = d3.selectAll('[data-pref-name=' + pref + ']')
        for (const path of target._groups[0]) {
            const d = path.getAttribute('d')
            // 各座標を左上寄せして新しいpath nodeに設定しなおし
            const convertD = convertPathD(d, maxMin)
            const newPath = path.cloneNode()
            newPath.setAttribute('d', convertD)


            // svgにpathを追加
            svg.append(newPath)
        }
    }


    // svgを一旦隠し要素に描画
    const canvas = document.getElementById('canvas')
    if (canvas == null) {
        return
    }
    canvas.append(svg)
    
    // ダウンロードするsvg情報を取得
    const html = canvas.getHTML()


    // 隠しエリアその2にダウンロード用リンクを作成してクリックし、ダウンロード
    const downloadArea = document.getElementById('download')
    if (downloadArea == null) {
        return
    }
    const svgBlob = new Blob([html], { type: 'image/svg+xml' })
    const svgUrl = URL.createObjectURL(svgBlob)

    const a = document.createElement('a')
    a.href = svgUrl
    a.download = 'テスト'

    downloadArea.append(a)
    a.click()
    

    // 各種要素のリセット
    downloadArea.removeChild(a)
    URL.revokeObjectURL(svgUrl)
    canvas.innerHTML = ''
}

// パスの座標の最大値・最小値を求める
const getMaxMinXY = function (d: string) {
    const result: MaxMin = {
        maxX: 0,
        maxY: 0,
        minX: 0,
        minY: 0,
    }
    const toList = d.match(TO)
    if (toList == null) {
        return result
    }

    let maxX = 0
    let maxY = 0
    let minX = 9999
    let minY = 9999
    // x, yの最大値・最小値を取得
    for (let to of toList) {
        to = to.replace(REMOVE, '')
        const xyList = to.split(',')

        let x = minX
        let y = minY
        if (xyList[0] != null) {
            x = parseInt(xyList[0])
        }
        if (xyList[1] != null) {
            y = parseInt(xyList[1])
        }

        maxX = Math.max(maxX, x)
        maxY = Math.max(maxY, y)
        minX = Math.min(minX, x)
        minY = Math.min(minY, y)
    }

    result.maxX = maxX
    result.maxY = maxY
    result.minX = minX
    result.minY = minY

    return result
}


// pathの各座標から最小x, y値を引いて各要素を左上寄せする。
const convertPathD = function (d: string, maxMin: MaxMin) {
    const toList = d.match(TO)

    if (toList == null) {
        return d
    }

    let convertD = ''
    for (let to of toList) {
        let command = 'M'
        const _command = to.match(REMOVE)
        if (_command !== null) {
            command = _command[0]
        }

        to = to.replace(REMOVE, '')
        const xyList = to.split(',')

        let x = 0
        let y = 0
        if (xyList[0] != null) {
            x = parseFloat(xyList[0]) - maxMin.minX
        }
        if (xyList[1] != null) {
            y = parseFloat(xyList[1]) - maxMin.minY
        }

        convertD += command + x + ',' + y
    }

    return convertD + 'Z'
}
</script>

// --- (省略) ---

使用例

上記までで最低限動きが作れました。
その後色機能などを付けて作ったツールは「都道府県画像ジェネレータ」にアクセスでご利用いただけます。

これで必要な都道府県画像をダウンロードし、FigmaやCanva等で装飾すれば以下のようなそれっぽいバナーを簡単に作れます。(キャッチコピーと配色はGeminiに考えてもらいました。コード作成や画像作成それ自体はまだまだイマイチだと感じる生成AIですが、こうやって補助をしてもらうには優秀だなと感じます。)
 

都道府県地図画像ジェネレータを使用してバナーを作成した例。
出典:国土交通省国土数値情報ダウンロードサイト (データを加工して作成)

おわりに

国土地理院のデータを使ってd3で日本地図を描画したり、描画した日本地図をインタラクティブに扱えるようになりました。

今回は画像ツールとして落とし込んでますが、都道府県ごとの統計を表す図を作る場合(というか、d3としてはこの用途が一番正しいか。)や都道府県の選択UIにも使えます。

また今回は都道府県ごとにしていますが、更に細かい市区町村別データも国土地理院のデータには入っていて、そちらも描画可能です。

重ねてになりますが、国土地理院のデータは利用時に出典を明記するように書かれていますのでそこだけ注意です。

記事の作成者のA.W.のアイコン

この記事を書いた人

A.W.
茨城県在住Webエンジニアです。 PHPなどを業務で使用しています。 趣味ではGoやNuxt、Flutterをやってます。

Comment