LoginSignup
2
3

More than 5 years have passed since last update.

iOSでGoogleMapにヒートマップを表示する

Posted at

Google Mapにヒートマップを表示したかったのですが、Androidと違って良いライブラリが見つかりませんでした(CocoaPodsとかの諸々の関係とかで配布しづらいのも関係あると思いますが)

LFHeatmapLayer

ヒートマップの画像を作成するライブラリは
https://github.com/gpolak/LFHeatMap
と言うものが有ります。
これをつかってヒートマップ画像を作成して、GoogleMapのタイルレイヤーとして利用してしまう
というのが今回の流れです。

GMHeatmapTileLayer

WeightedLatLng

struct WeightedLatLng {
        let weight: Double
        let latlng: CLLocationCoordinate2D
}

ヒートマップ化するにあたって、重み付けされた緯度経度のデータが必要になるので、その構造体を定義します。

タイルレイヤーはfunc tileFor(x: UInt, y: UInt, zoom: UInt) -> UIImage?をオーバーライドする必要があります。
このメソッドは、地図をGoogleが各Zoomレベルに合わせてグリッド状に分割したときのタイルの番号を受け取って、オーバーレイの画像を返すメソッドです。

override func tileFor(x: UInt, y: UInt, zoom: UInt) -> UIImage? {

        let padding = 0.5

        //1. タイルの緯度経度の範囲を周辺のデータも含めて計算
        let tile_bounds = expand(bounds: convertToBounds(x: x, y: y, zoom: zoom), padding: padding)

        //2. 含まれているWeightedLatLngを抽出
        let containd_datas = self.data.filter { (datum) -> Bool in
            tile_bounds.contains(datum.latlng)
        }

        let pad = Double(self.tileSize) * padding
        let padded_size: Double = Double(self.tileSize) + pad * 2

        if containd_datas.count == 0 {
            return kGMSTileLayerNoTile
        }

        let tileRect = CGRect(x: 0, y: 0, width: padded_size, height: padded_size)
        //3. LFHeatMapを利用してオーバーレイ画像を作成
        let heatmap = LFHeatMap.heatMap(with: tileRect,
                                 boost: 1,
                                 points: containd_datas.map({ (datum) -> NSValue in
                                    var point: CGPoint! = convert(coordinate: datum.latlng, in: tile_bounds)
                                    point.x = point.x * CGFloat(padded_size)
                                    point.y = point.y * CGFloat(padded_size)
                                    return NSValue(cgPoint: point)
                                 }),
                                 weights: containd_datas.map({ (datum) -> NSNumber in
                                    return NSNumber(value: datum.weight)
                                 }))
        //4. 周辺データの範囲を削ってタイルの範囲の画像を返す
        let cropedCgImage: CGImage! = heatmap?.cgImage?.cropping(
            to: CGRect(x: pad, y: pad, width: Double(self.tileSize), height: Double(self.tileSize)))
        let resultImage = UIImage(cgImage: cropedCgImage)
        return resultImage
}

このメソッドのポイントは、単純にタイルの範囲のデータのみを抽出してしまうと、
タイルの境界線の外側に有るデータが外されてしまってタイル境界線付近のヒートマップが正しく計算されない事を回避していることです。
このためにタイルの緯度経度の各辺を1.5倍に拡大した範囲でヒートマップを作成して、画像を返す前に縁の部分を削っています。

残りの箇所は、先ほどのタイルの割当のロジックに基づいて
タイルの番号から緯度経度のGMSCoordinateBoundsを計算しているのみです。
また、LFHeatMapはCGRect上でのCGPointの位置に基いてヒートマップを計算してくれるライブラリなので
GMSCoordinateBounds上での緯度経度を単純な比率で計算しています。

import GoogleMaps
import LFHeatMap

struct WeightedLatLng {
        let weight: Double
        let latlng: CLLocationCoordinate2D
}

class GMHeatmapTileLayer: GMSSyncTileLayer {

    var data: [WeightedLatLng] = []

    init(data: [WeightedLatLng]) {
        self.data = data
    }

    func toLatitude(mercator: Double) -> Double {
        let radians = atan(exp(mercator * M_PI / 180))
        return 2 * (radians * 180 / M_PI) - 90
    }

    func convertToBounds(x: UInt, y: UInt, zoom: UInt) -> GMSCoordinateBounds {
        let number_of_tiles: UInt = 1 << zoom
        let longitudeSpan: Double = 360.0 / Double(number_of_tiles)
        let longitudeMin: Double = -180.0 + Double(x) * longitudeSpan

        let mercatorMax: Double = 180.0 - (Double(y) / Double(number_of_tiles)) * 360
        let mercatorMin = 180 - ((Double(y) + 1) / Double(number_of_tiles)) * 360
        let latitudeMax = toLatitude(mercator: mercatorMax)
        let latitudeMin = toLatitude(mercator: mercatorMin)

        return GMSCoordinateBounds(
            coordinate: CLLocationCoordinate2D(latitude: latitudeMin, longitude: longitudeMin + longitudeSpan),
            coordinate: CLLocationCoordinate2D(latitude: latitudeMax, longitude: longitudeMin))
    }

    func wrapping_lat(lat: Double) -> Double {
        return (lat + 180).truncatingRemainder(dividingBy: 180)
    }

    func wrapping_lng(lng: Double) -> Double {
        return (lng + 360).truncatingRemainder(dividingBy: 360)
    }

    func expand(bounds: GMSCoordinateBounds, padding: Double) -> GMSCoordinateBounds {

        var lat_min = min(bounds.southWest.latitude, bounds.northEast.latitude)
        var lat_max = max(bounds.southWest.latitude, bounds.northEast.latitude)
        var lng_min = min(bounds.southWest.longitude, bounds.northEast.longitude)
        var lng_max = max(bounds.southWest.longitude, bounds.northEast.longitude)

        let lat_dist = abs(lat_max - lat_min)
        let lng_dist = abs(lng_max - lng_min)

        let lat_pad = lat_dist * padding
        let lng_pad = lng_dist * padding

        lat_min = wrapping_lat(lat: lat_min - lat_pad)
        lat_max = wrapping_lat(lat: lat_max + lat_pad)
        lng_min = wrapping_lng(lng: lng_min - lng_pad)
        lng_max = wrapping_lng(lng: lng_max + lng_pad)

        let topLeft = CLLocationCoordinate2D(latitude: lat_min, longitude: lng_min)
        let bottomRight = CLLocationCoordinate2D(latitude: lat_max, longitude: lng_max)

        return GMSCoordinateBounds(
            coordinate: topLeft,
            coordinate: bottomRight)
    }

    func convert(coordinate: CLLocationCoordinate2D, in bounds: GMSCoordinateBounds) -> CGPoint? {
        if !bounds.contains(coordinate) {
            return nil
        }

        let x = abs(coordinate.longitude - bounds.southWest.longitude) / abs(bounds.northEast.longitude - bounds.southWest.longitude)
        let y = abs(coordinate.latitude - bounds.northEast.latitude) / abs(bounds.southWest.latitude - bounds.northEast.latitude)

        return CGPoint(x: x,
                       y: y)
    }

    override func tileFor(x: UInt, y: UInt, zoom: UInt) -> UIImage? {

        let padding = 0.5

        let tile_bounds = expand(bounds: convertToBounds(x: x, y: y, zoom: zoom), padding: padding)

        let containd_datas = self.data.filter { (datum) -> Bool in
            tile_bounds.contains(datum.latlng)
        }

        let pad = Double(self.tileSize) * padding
        let padded_size: Double = Double(self.tileSize) + pad * 2

        if containd_datas.count == 0 {
            return kGMSTileLayerNoTile
        }

        let tileRect = CGRect(x: 0, y: 0, width: padded_size, height: padded_size)
        let heatmap = LFHeatMap.heatMap(with: tileRect,
                                 boost: 1,
                                 points: containd_datas.map({ (datum) -> NSValue in
                                    var point: CGPoint! = convert(coordinate: datum.latlng, in: tile_bounds)
                                    point.x = point.x * CGFloat(padded_size)
                                    point.y = point.y * CGFloat(padded_size)
                                    return NSValue(cgPoint: point)
                                 }),
                                 weights: containd_datas.map({ (datum) -> NSNumber in
                                    return NSNumber(value: datum.weight)
                                 }))
        let cropedCgImage: CGImage! = heatmap?.cgImage?.cropping(
            to: CGRect(x: pad, y: pad, width: Double(self.tileSize), height: Double(self.tileSize)))
        let resultImage = UIImage(cgImage: cropedCgImage)
        return resultImage
    }
}
2
3
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
2
3