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