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

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

# LFHeatmapLayer

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

# GMHeatmapTileLayer

## WeightedLatLng 

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

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

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

```swift
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`上での緯度経度を単純な比率で計算しています。


```swift
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
    }
}
```