気象庁 XML × GIS データで防災情報を地図表示する iOS アプリを作ってみた

:::note info
この記事は、[防災アプリ開発 Advent Calendar 2024](https://adventar.org/calendars/9939) 22 日目の記事です。
:::


# はじめに

iOS ネイティブアプリで気象庁防災情報 XML をパースし、地図上に表示する方法を調査しました。今回は「気象警報・注意報」電文を MapKit という標準ライブラリを使って表示するシンプルなアプリを作成します。
実装内容は記載しますが、詳しい解説は省略しています。ご了承ください。


# 使用するデータ

|データ種類|リンク|
|---|---|
|地図データ|[気象庁 - 予報区等GISデータ 市町村等（気象警報等）](https://www.data.jma.go.jp/developer/gis.html)|
|気象庁 XML のサンプルデータ|[気象庁防災情報XMLフォーマット - 技術資料](https://xml.kishou.go.jp/tec_material.html)|


### データの下処理


#### 地図データの準備

気象庁の「市町村等（気象警報等）」 GIS データを入手して、アプリで扱いやすいように変換します。シェープファイル形式で提供されているため、 GeoJSON に変換します。
入手した GIS データは詳細な地形情報が含まれておりファイルサイズが非常に大きいため、不要な情報を削除して軽量化します。

[mapshaper](https://mapshaper.org/) を使いシェープファイル（ `*.shp` 、 `*.shx` 、 `*.dbf` ）をインポートして[^1] `Simplify` を適用し、「GeoJSON」でエクスポートします。
本記事ではファイル名は `japan.json` として解説します。


#### 気象庁 XML の準備

気象庁防災情報 XML のサンプルデータからファイル名が「VPWW54」で終わるファイルを使用します。
本記事では `15_14_01_170216_VPWW54.xml` （東京都を対象とした電文）を使用します。

:::note info
「気象警報・注意報」電文は将来的な発表体系の変更が予告されています。
（[気象庁防災情報XMLフォーマットに係る資料の一部更新について（PDF）](https://xml.kishou.go.jp/20241031_jmaxml_info.pdf)）
:::


# iOS アプリの実装


### プロジェクト作成

Xcode[^2] で新規プロジェクトを作成します。
1. Xcode を起動し、「Create New Project...」を選択
2. iOS の `App` を選び、「Next」をクリック
3. プロジェクト名を入力し、「Interface」は `SwiftUI` を選択
4. 保存先を指定して「Create」をクリック


### データのインポート

1. プロジェクトナビゲーター（画面左側にあるファイルツリー）で `Assets` や `ContentView` と同じ階層に `Resources` フォルダを作成
2. Finder で地図データ（ `japan.json` ）と XML データ（ `15_14_01_170216_VPWW54.xml` ）を選択して、 `Resources` フォルダにドラッグ＆ドロップ


### 地図表示に使用するモデルの作成

Swift ファイル `WarningItem.swift` を作成し、次のようにモデルを定義しました。

```swift:WarningItem.swift
import Foundation

struct WarningItem: Identifiable {
    let id = UUID()
    let areaName: String
    let areaCode: String
    let kindNames: [String]
}
```

`id` は SwiftUI でループ処理をする際に役立つため実装しています。


### XML パース処理の実装

XML をパースする手段として標準ライブラリの「XMLParse」がありますが、使い勝手がよくなかったため、サードパーティーのライブラリを使用します。
今回は XPath での操作が可能な [Kanna](https://github.com/tid-kijyun/Kanna.git) というライブラリを使用させていただきます[^3]。

Swift Package Manager を使ってライブラリの依存関係を追加します。
1. 「File」 > 「Add Package Dependencies...」をクリック
2. 「Search or Enter Package URL」と書かれている検索ボックスにリポジトリの URL を入力
3. 「Add Package」をクリックしてパッケージを追加

`XMLParser.swift` ファイルを作成し、以下のように実装しました。

```swift:XMLParser.swift
import Kanna

struct XMLParser {
    private let namespaces = [
        "head": "http://xml.kishou.go.jp/jmaxml1/informationBasis1/"
    ]
    
    func parse(from xmlString: String) throws -> [WarningItem] {
        let xml = try Kanna.XML(xml: xmlString, encoding: .utf8)
        let xpath = "//head:Information[@type='気象警報・注意報（市町村等）']/head:Item"
        let items = xml.xpath(xpath, namespaces: namespaces).compactMap(parseWarningItem(from:))
        return items
    }
    
    private func parseWarningItem(from item: XMLElement) -> WarningItem? {
        guard let area = item.at_xpath("head:Areas/head:Area", namespaces: namespaces),
              let areaName = area.at_xpath("head:Name", namespaces: namespaces)?.text,
              let areaCode = area.at_xpath("head:Code", namespaces: namespaces)?.text else {
            return nil
        }
        let kindNames = item.xpath("head:Kind/head:Name", namespaces: namespaces).compactMap { $0.text }
        return WarningItem(areaName: areaName, areaCode: areaCode, kindNames: kindNames)
    }
}

```

body 要素でも同じような情報は取得可能ですが、構造がやや複雑であるため今回は head 以下の要素から取得しました。

:::note warn
`head` 要素では気象警報・注意報等が発表されている地域のみ要素が出現するため、都道府県に属する市区町村等すべての要素が欲しい場合は `body` 以下の要素から取得する必要があります。
:::


### ViewModel を実装

`ContentViewModel.swift` ファイルを作成し、次のように実装しました。

```swift:ContentViewModel.swift
import Foundation

final class ContentViewModel: ObservableObject {
    @Published var warningItems: [WarningItem] = []
    
    func loadAllData() {
        do {
            let warningItems = try loadXML(from: "15_14_01_170216_VPWW54")
            self.warningItems = warningItems
        } catch {
            assertionFailure("XMLのデコードに失敗しました: \(error)")
        }
    }
    
    private func loadXML(from fileName: String) throws -> [WarningItem] {
        guard let url = Bundle.main.url(forResource: fileName, withExtension: "xml"),
              let data = try? Data(contentsOf: url),
              let xmlString = String(data: data, encoding: .utf8) else {
            assertionFailure("XMLのパースに失敗しました")
            return []
        }
        
        let parser = XMLParser()
        let warningItems = try parser.parse(from: xmlString)
        return warningItems
    }
}
```

次に、最初の View 表示時に `loadAllData()` が自動的に実行されるよう設定します。
`ContentView.swift` を次のように変更しました。

```diff_swift:ContentView.swift
struct ContentView: View {
+   @StateObject var viewModel = ContentViewModel()
+   
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("Hello, world!")
        }
        .padding()
+       .onAppear {
+           viewModel.loadAllData()
+       }
    }
}
```

この時点でアプリ実行して、エラーが発生しなければ問題ないです。


### GeoJSON パース処理の作成

GeoJSON のパースには MapKit の `MKGeoJSONDecoder` クラスを使用します。
`ContentViewModel` に以下の処理を追加しました。

```diff_swift:ContentViewModel.swift
import Foundation
+ import MapKit

final class ContentViewModel: ObservableObject {
+   @Published var geoJSONFeatures: [MKGeoJSONFeature] = []
    @Published var warningItems: [WarningItem] = []

    func loadAllData() {
+       do {
+           let geoJSONFeatures = try loadGeoJSON(from: "japan")
+           self.geoJSONFeatures = geoJSONFeatures
+       } catch {
+           assertionFailure("GeoJSONのデコードに失敗しました: \(error)")
+       }
+       
        do {
            let warningItems = try loadXML(from: "15_14_01_170216_VPWW54")
            self.warningItems = warningItems
        } catch {
            assertionFailure("XMLのパースに失敗しました: \(error)")
        }
    }

+   private func loadGeoJSON(from fileName: String) throws -> [MKGeoJSONFeature] {
+       guard let url = Bundle.main.url(forResource: fileName, withExtension: "json"),
+             let data = try? Data(contentsOf: url) else {
+           assertionFailure("GeoJSONの読み込みに失敗しました")
+           return []
+       }
+       
+       let decoder = MKGeoJSONDecoder()
+       guard let features = try decoder.decode(data) as? [MKGeoJSONFeature] else {
+           assertionFailure("GeoJSONのデコードに失敗しました")
+           return []
+       }
+       return features
+   }
+   
    private func loadXML(from fileName: String) throws -> [WarningItem] {
    // 後略
}
```


### MapKit を使用した地図の View を実装

MapKit を使用した地図の View を作成します。
iOS アプリの UI フレームワークには主に SwiftUI と UIKit の 2 種類があります。本アプリは新しいフレームワークである SwiftUI で実装していますが、MapKit は SwiftUI 用の View が提供されていません。そこで、 UIKit の View を SwiftUI で利用できるようにします。

`MapView.swift` を作成し、以下のように実装しました。

```swift:MapView.swift
import SwiftUI
import MapKit

struct MapView: UIViewRepresentable {
    var geoJSONFeatures: [MKGeoJSONFeature]
    var warningItems: [WarningItem]
    
    func makeUIView(context: Context) -> MKMapView {
        let mapView = MKMapView()
        mapView.delegate = context.coordinator
        mapView.mapType = .mutedStandard
        return mapView
    }
    
    func updateUIView(_ uiView: MKMapView, context: Context) {
        context.coordinator.updateWarningItems(warningItems)
        
        uiView.removeOverlays(uiView.overlays)
        uiView.addOverlays(geoJSONFeatures.flatMap(createOverlays(from:)))
    }
    
    private func createOverlays(from feature: MKGeoJSONFeature) -> [MKOverlay] {
        guard let metadata = extractMetadata(from: feature) else {
            return []
        }
        return feature.geometry.flatMap { geometry in
            switch geometry {
            case let polygon as MKPolygon:
                polygon.title = metadata.regionName
                polygon.subtitle = metadata.regionCode
                return [polygon]
            case let multiPolygon as MKMultiPolygon:
                multiPolygon.title = metadata.regionName
                multiPolygon.subtitle = metadata.regionCode
                return multiPolygon.polygons
            default:
                return []
            }
        }
    }
    
    private func extractMetadata(from feature: MKGeoJSONFeature) -> Metadata? {
        guard let data = feature.properties else {
            return nil
        }
        let decoder = JSONDecoder()
        return try? decoder.decode(Metadata.self, from: data)
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator()
    }
    
    final class Coordinator: NSObject, MKMapViewDelegate {
        private var warningItems: [WarningItem] = []
        
        private let warningTypeColors: [(String, UIColor)] = [
            ("特別警報", .purple.withAlphaComponent(0.5)),
            ("警報", .red.withAlphaComponent(0.5)),
            ("注意報", .yellow.withAlphaComponent(0.5))
        ]
        
        func updateWarningItems(_ warningItems: [WarningItem]) {
            self.warningItems = warningItems
        }
        
        func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
            guard let polygon = overlay as? MKPolygon else {
                return .init()
            }
            
            let renderer = MKPolygonRenderer(polygon: polygon)
            renderer.fillColor = resolveFillColor(for: polygon)
            renderer.strokeColor = .gray
            renderer.lineWidth = 1
            return renderer
        }

        private func resolveFillColor(for polygon: MKPolygon) -> UIColor {
            guard let areaCode = polygon.subtitle,
                  let warningItem = warningItems.first(where: { $0.areaCode == areaCode }),
                  let (_, color) = warningTypeColors.first(where: { warningType, _ in
                      warningItem.kindNames.contains { $0.contains(warningType) }
                  }) else {
                return .clear
            }
            
            return color
        }
    }
    
    private struct Metadata: Codable {
        let regionName: String
        let regionCode: String

        enum CodingKeys: String, CodingKey {
            case regionName = "regionname"
            case regionCode = "regioncode"
        }
    }
}
```

作成した `MapView` を `ContentView` に配置します。
（ついでに不要な View を削除します。）

```diff_swift:ContentView.swift
struct ContentView: View {
    @StateObject var viewModel: ContentViewModel = ContentViewModel()
    
    var body: some View {
-       VStack {
+       MapView(
+           geoJSONFeatures: viewModel.geoJSONFeatures,
+           warningItems: viewModel.warningItems
+       )
-           Image(systemName: "globe")
-               .imageScale(.large)
-               .foregroundStyle(.tint)
-           Text("Hello, world!")
-       }
-       .padding()
        .onAppear {
            viewModel.loadAllData()
        }
    }
}
```

以上で完成です！

<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/195741/9ab14014-dbe6-62c7-79ce-a6ed0fee4fa2.png width=320>


# おわりに

気象庁 XML と GIS データを活用して、情報を地図表示するシンプルなアプリを作成しました。
私の本業はモバイルアプリエンジニアですが、MapKit の使用や XML のパースは今回が初めてでした。外部データのやり取りでは普段 JSON を使用することが多く、初めて XML のパース処理を実装してみましたが、JSON と比べて手間がかかると感じました。
地図の表示についてはカスタマイズに制限はありそうですが、手軽に表示することができました。インタラクティブな機能についてもっと深堀りしてみたいです。
次は Android アプリやサードパーティのライブラリを使った地図表示についても調査してみたいです。


# おまけ

20 行程度の変更で、簡易的な発表状況の一覧表示を追加できます。

<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/195741/4f3fdb4c-0102-6555-8442-e504bd11fc38.png width=320>

```diff_swift:ContentView.swift
    var body: some View {
-       MapView(
-           geoJSONFeatures: viewModel.geoJSONFeatures,
-           warningItems: viewModel.warningItems
-       )
+       VStack {
+           MapView(
+               geoJSONFeatures: viewModel.geoJSONFeatures,
+               warningItems: viewModel.warningItems
+           )
+           
+           Text("気象警報・注意報 発表状況")
+               .font(.headline)
+               .padding(8)
+           
+           List {
+               ForEach(viewModel.warningItems) { item in
+                   DisclosureGroup(item.areaName) {
+                       ForEach(item.kindNames.indices, id: \.self) { index in
+                           Text(item.kindNames[index])
+                       }
+                   }
+               }
+           }
+       }
        .onAppear {
            viewModel.loadAllData()
        }
    }
```


[^1]: *.dbf ファイルをインポートしないと属性情報が失われます。
[^2]: 使用した Xcode のバージョンは 16.1 です。
[^3]: 使用した Kanna のバージョンは 5.3.0 です。