はじめに

こんにちは、Swift Advent Calendar 2024の19日目を担当しますfummicc1です。 この記事では、ジオハッシュ(GeoHash)を可視化できるアプリについての話を書きました。 Swift Advent CalendarなのでSwiftに関連する話も書いたつもりですが、Swift以外の話も含まれているのであらかじめご了承いただけると嬉しいです。

この記事に書いてあること

  • ジオハッシュについて
  • ジオハッシュを可視化するアプリを作った話
  • ジオハッシュを確認できるアプリを作った際のSwiftに関連する話

ジオハッシュとは

ジオハッシュは地図上の特定の区域を表現した文字列のことやそのアルゴリズムのことを指します。世界地図に対して「経度を2分割→緯度を2分割」という処理を繰り返すことで生成されるビット列をハッシュ化することで文字列を生成します。

ジオハッシュの例: 区域ごとに一意の文字列が割り当てられる
Screenshot 2024-12-14 at 22 26 59

ジオハッシュの生成では、境界を区切って0,1を割り当てる作業を緯度・経度ごとに行います。経度だけに絞ってみると下記のように分割されていきます。

経度を1bitで分割 経度を2bitで分割 経度を3bitで分割

どこまで分割を行うかでビット列の長さが変わり、長ければ長いほど狭い区域を表現するジオハッシュ文字列となります。

ジオハッシュのビット列は、経度と緯度それぞれについてのビット列を交互に並べたものです。

例えば、経度のビット列が「11100」で,緯度のビット列が「10001」の場合、ジオハッシュは「1110100001」となります。

ビット列は5bitごとに32種類の英数字にマッピングされます。数字10個 + アルファベット22個からa,i,l,oを除いたものの合計32種類から構成されます。

ジオハッシュ生成の流れ
Screenshot 2024-12-14 at 23 31 42

このジオハッシュから、地点の緯度・経度の区域を特定することができます。

例えば、日本はおおよそ1桁のジオハッシュでは「w」もしくは「x」で表現されます。ジオハッシュの桁が長ければ長いほど、その文字列が示す区域が狭くなるので1桁のジオハッシュだと結構な領域を示しています。詳しい制度についてはこちらに記述がありました。

日本はおおよそwかxに属する 西日本付近でxからwに変わる
Screenshot 2024-12-14 at 23 31 42 Screenshot 2024-12-14 at 23 32 04

ジオハッシュの特性を応用することで、「ある地点の近くにある場所」の探索を文字列の比較で実現ができることができます。(ただし、多少の精度の誤差があることに注意が必要です。)

ジオハッシュをSwiftで実装したい

勉強も兼ねてGeoHashSwiftというリポジトリに実装しました。ですが、既にGeoHashというSwiftで実装されたライブラリがあったのでそちらも参考に実装をしました。

ジオハッシュを可視化するアプリが欲しかった

ジオハッシュのライブラリを作った後に実装が正しいことを確認する必要があったのですが、文字列を見ても正しいジオハッシュかどうかが直感的に分からなかったので入力した緯度経度から生成されるジオハッシュを簡単に確認できるアプリを作りました。

検索機能もつけてみました。(手元で動かすととても重いです)

ビット列の長さをスライダーで変えられることができて、マップの中心のジオハッシュと周囲の区域のジオハッシュが表示されるアプリとなっています。

周囲のジオハッシュは幅優先探索で5ステップ先の周囲までを取得しています。全てのジオハッシュを取得すると2 ** ビット列の長さのジオハッシュを取得することになるので、計算が重くなったので断念しました。

5ステップ先のジオハッシュを取得するだけでも、Map上で動作確認をすると処理が重いと感じたので改善に取り組んだことをメモします。

Viewに記述された処理をメインスレッド外で実行する

SwiftUIのViewはMainActorであるため、Viewに直接計算メソッドを書いている場合、処理がメインスレッド上で実行されてしまいます。今回はGlobalActorを定義してメソッドをactorで隔離させることでメインスレッドで処理が走らないようにしました。

@globalActor
struct ComputationActor {
    actor ActorType {}
    static let shared = ActorType()
}

struct ContentData: Identifiable {
    var bound: [CLLocationCoordinate2D] = []
    var geohash: GeoHash
    
    var id: GeoHash {
        geohash
    }
}

struct ContentView: View {
    
    // ...bodyに関するコード

    // 周囲のジオハッシュを取得するコード
    @ComputationActor
    private func updateBounds(coord: CLLocationCoordinate2D) async {
        var geoHashes = await Deque(
            [
                (
                    GeoHash(
                        latitude: coord.latitude,
                        longitude: coord.longitude,
                        precision: .exact(digits: bitsLength)
                    ),
                    0
                ),
            ]
        )
        var data: [ContentData] = []
        var seen: Set<GeoHash> = []
        while geoHashes.count > 0 {
            guard let (geoHash, depth) = geoHashes.popFirst() else {
                break
            }
            if depth > 4 {
                break
            }
            if seen.contains(geoHash) {
                continue
            }
            seen.insert(geoHash)
            let centerBound = geoHash.getBound().map({
                CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude)
            })
            data.append(
                .init(
                    bound: centerBound + [centerBound[0]],
                    geohash: geoHash
                )
            )
            for neighbor in geoHash.getNeighbors() {
                if seen.contains(neighbor) {
                    continue
                }
                let neighborBound = neighbor.getBound().map({
                    CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude)
                })
                data.append(
                    .init(
                        bound: neighborBound + [neighborBound[0]],
                        geohash: neighbor
                    )
                )
                geoHashes.append((neighbor, depth + 1))
            }
        }
        await MainActor.run { [data] in
            self.data = data
        }
    }
}

swift-collectionsのDequeを使う

上記のコードにもあるDequeはswift-collectionsにある実装を利用しています。Arrayだと先頭の要素を取得して削除するremoveFirstがO(n)ですが、DequeのpopFirstは平均的にはO(1)の定数時間で終わるのでこちらを使ってみました。

参考: https://www.swift.org/blog/swift-collections/

RuntimeWarningを出してみたい

こちらは処理を軽くするための取り組みではありませんが、開発者がRuntimeWarningを出すことができることを知ったので、今回作成したライブラリにRuntimeWarningを出すようにしてみました。詳しくはpointfreeのswift-issue-reportingというライブラリを参考にしたので、ぜひご確認してみてください。RuntimeWarningはアプリの不具合を実行時に伝えることができる手段の一つだと捉えています。

例えば、今回作成したジオハッシュのライブラリでは下記の点が事前条件として考えられますが、もし下記に該当しない不適切なデータが入力された場合にRuntimeWarningを出すというケースを想定しました。

  • 入力される緯度・経度は適切な範囲内にあること(-90 <= latitude <= 90, -180 <= longitude <= 180)
  • 入力されるジオハッシュは英数字から構成され、“a”, “i”, “l”, “o"を含んでいないこと

なぜこちらのコードで実現できるのかは自分もよく理解できていないので後日理解できたら追記したいと考えていますが、下記のコードを利用することでRuntimeWarningを出すことができました。

import Foundation
import OSLog

enum RuntimeWarning {
    static func log(message: StaticString, args: any CVarArg...) {
        #if DEBUG
        var dso: UnsafeRawPointer?
        // ref: https://github.com/pointfreeco/swift-issue-reporting/blob/a3f634d1a409c7979cabc0a71b3f26ffa9fc8af1/Sources/IssueReporting/IssueReporters/RuntimeWarningReporter.swift#L39-L55
        let count = _dyld_image_count()
        for i in 0..<count {
          if let name = _dyld_get_image_name(i) {
            let swiftString = String(cString: name)
            if swiftString.hasSuffix("/SwiftUI") {
              if let header = _dyld_get_image_header(i) {
                dso = UnsafeRawPointer(header)
              }
            }
          }
        }
        guard let dso = dso else {
            assertionFailure("Failed to get DSO")
            return
        }
        os_log(
            .fault,
            dso: dso,
            log: OSLog(
                subsystem: "com.apple.runtime-issues",
                category: "ReportRuntimeWarningSample"
            ),
            message,
            args
        )
        #endif
    }
}

// 利用例
extension GeoHash {
    /// Base32 characters used to hash
    ///
    // a, i, l and o are omitted
    package static var base32Chars: String {
        "0123456789bcdefghjkmnpqrstuvwxyz"
    }

    static func makeBinary(
        from geoHash: String,
        precision: GeoHashBitsPrecision
    ) -> String {
        var binary = ""

        for char in geoHash {
            guard let index = Self.base32Chars.firstIndex(of: char) else {
                // ジオハッシュの文字列に不適切な文字がある場合にRuntimeWarningを出す
                RuntimeWarning.log(
                    message: "Invalid geohash character %s in geoHash: %s",
                    args: String(char), geoHash
                )
                continue
            }
不適切な文字列が入力された場合にRuntimeWarningが表示される
Screenshot 2024-12-19 at 23 11 11

さいごに

この記事では、ジオハッシュを可視化するアプリを作った話を書きました。少しでも参考になれば幸いです。

余談ですが、ジオハッシュを可視化するアプリは実際に動かすと非常にViewがカクツクのでこれから改善が必要です。もしご興味がある方がいればコードを覗いてみてください。

今年もありがとうございました。来年もよろしくお願いいたします!