この記事は、防災アプリ開発 Advent Calendar 2024 22 日目の記事です。
はじめに
iOS ネイティブアプリで気象庁防災情報 XML をパースし、地図上に表示する方法を調査しました。今回は「気象警報・注意報」電文を MapKit という標準ライブラリを使って表示するシンプルなアプリを作成します。
実装内容は記載しますが、詳しい解説は省略しています。ご了承ください。
使用するデータ
データ種類 | リンク |
---|---|
地図データ | 気象庁 - 予報区等GISデータ 市町村等(気象警報等) |
気象庁 XML のサンプルデータ | 気象庁防災情報XMLフォーマット - 技術資料 |
データの下処理
地図データの準備
気象庁の「市町村等(気象警報等)」 GIS データを入手して、アプリで扱いやすいように変換します。シェープファイル形式で提供されているため、 GeoJSON に変換します。
入手した GIS データは詳細な地形情報が含まれておりファイルサイズが非常に大きいため、不要な情報を削除して軽量化します。
mapshaper を使いシェープファイル( *.shp
、 *.shx
、 *.dbf
)をインポートして1 Simplify
を適用し、「GeoJSON」でエクスポートします。
本記事ではファイル名は japan.json
として解説します。
気象庁 XML の準備
気象庁防災情報 XML のサンプルデータからファイル名が「VPWW54」で終わるファイルを使用します。
本記事では 15_14_01_170216_VPWW54.xml
(東京都を対象とした電文)を使用します。
「気象警報・注意報」電文は将来的な発表体系の変更が予告されています。
(気象庁防災情報XMLフォーマットに係る資料の一部更新について(PDF))
iOS アプリの実装
プロジェクト作成
Xcode2 で新規プロジェクトを作成します。
- Xcode を起動し、「Create New Project...」を選択
- iOS の
App
を選び、「Next」をクリック - プロジェクト名を入力し、「Interface」は
SwiftUI
を選択 - 保存先を指定して「Create」をクリック
データのインポート
- プロジェクトナビゲーター(画面左側にあるファイルツリー)で
Assets
やContentView
と同じ階層にResources
フォルダを作成 - Finder で地図データ(
japan.json
)と XML データ(15_14_01_170216_VPWW54.xml
)を選択して、Resources
フォルダにドラッグ&ドロップ
地図表示に使用するモデルの作成
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 というライブラリを使用させていただきます3。
Swift Package Manager を使ってライブラリの依存関係を追加します。
- 「File」 > 「Add Package Dependencies...」をクリック
- 「Search or Enter Package URL」と書かれている検索ボックスにリポジトリの URL を入力
- 「Add Package」をクリックしてパッケージを追加
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 以下の要素から取得しました。
head
要素では気象警報・注意報等が発表されている地域のみ要素が出現するため、都道府県に属する市区町村等すべての要素が欲しい場合は body
以下の要素から取得する必要があります。
ViewModel を実装
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
を次のように変更しました。
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
に以下の処理を追加しました。
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
を作成し、以下のように実装しました。
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 を削除します。)
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()
}
}
}
以上で完成です!
おわりに
気象庁 XML と GIS データを活用して、情報を地図表示するシンプルなアプリを作成しました。
私の本業はモバイルアプリエンジニアですが、MapKit の使用や XML のパースは今回が初めてでした。外部データのやり取りでは普段 JSON を使用することが多く、初めて XML のパース処理を実装してみましたが、JSON と比べて手間がかかると感じました。
地図の表示についてはカスタマイズに制限はありそうですが、手軽に表示することができました。インタラクティブな機能についてもっと深堀りしてみたいです。
次は Android アプリやサードパーティのライブラリを使った地図表示についても調査してみたいです。
おまけ
20 行程度の変更で、簡易的な発表状況の一覧表示を追加できます。
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()
}
}