Google Maps には、ユーザが独自に地点とメモを登録して地図を作成する「マイマップ」という機能があります。 このマイマップに登録した地点情報を取得する API は 2016 年 9 月現在、公開されていません。 ただ、マイマップには KML フォーマットでエクスポートする機能が提供されており、これを利用すれば地点情報を取得することができます。

本記事では、この仕組みを利用した iOS アプリのサンプルを作成し、マイマップの地点情報を読み込み表示する方法を紹介します。

サンプルアプリのソースコードを GitHub で公開しています。 https://github.com/gaprot/OmosanMap-iOS

KML ファイルの URL 取得

マイマップに登録した地点情報が入った KML ファイルの URL を取得するには、以下のようにします。

  • Web ブラウザで取得したいマイマップを開く
  • マイマップ名の右にあるメニューから「 KML にエクスポート」を選択
  • 「地図全体」を選択、「.KMZ ファイルではなく〜」のチェックを外して「ダウンロード」

このままだと KMZ ファイルがダウンロードされるだけなので、ダウンロード先 URL を取得するために以下を行います。

  • Chrome の場合: ダウンロード履歴を開き、記述されている URL をコピー
  • Safari の場合: ダウンロード履歴を開き、項目を右クリックして「アドレスをコピー」

KML の構造

詳細な仕様はXML スキーマに譲りますが、KML は概ね下記のような構造になっています。

<?xml version='1.0' encoding='UTF-8'?>
<kml xmlns='http://www.opengis.net/kml/2.2'>
    <Document>
        <name>ドキュメント名</name>
        <description><![CDATA[ドキュメント詳細]]></description>
        <Folder>
            <name>グループ名</name>
            <Placemark>
                <name>地点名</name>
                <description><![CDATA[地点情報詳細]]></description>
                <styleUrl>適用するスタイルの URL (アンカー)</styleUrl>
                <Point>
                    <coordinates>緯度,経度,0.0</coordinates>
                </Point>
            </Placemark>
            ...以降、<Placemark>が続く...
        </Folder>
        ...以降、<Folder>が続く...
        <Style id='icon-ci-1-nodesc-normal'>
            <IconStyle>
                <Icon>
                    <href>地点マーカー画像へのパス</href>
                </Icon>
                <Color>マーカーの色(BBGGRRAA)</Color>
            </IconStyle>
            ...
        </Style>
        ...以降、<Style>が続く...
    </Document>
</kml>

ひとまずFolderノードごとに分類されたPlacemarkノードを読み取れば、マップビューに表示できそうです。

KML ファイルのダウンロードとパース

KML ファイルのダウンロードには Alamofire を使用しています。 カスタムアイコンも含めて取得したいので、zip 圧縮された KMZ ファイルをダウンロードした後、SSZipArchive で展開します。

// DocumentDataSource.swift

let directoryURL = NSFileManager.defaultManager().URLsForDirectory(.CachesDirectory, inDomains: .UserDomainMask)[0]
var localURL: NSURL?
Alamofire.download(.GET, URLString, destination: { (temporaryURL, response) in
    // ファイルのダウンロード先を指定
    guard let pathComponent = response.suggestedFilename else {
        fatalError()
    }
    let destinationURL = directoryURL.URLByAppendingPathComponent(pathComponent)

    // 同名のファイルが残っている場合は削除しておく
    if
        let destinationPath = destinationURL.path
    where
        NSFileManager.defaultManager().fileExistsAtPath(destinationPath)
    {
        try! NSFileManager.defaultManager().removeItemAtPath(destinationPath)
    }

    localURL = destinationURL
    return destinationURL
}).response { (request, response, data, error) in
    var result: ErrorType?
    defer {
        handler(error: result)
    }

    if let error = error {
        result = error
    } else {
        guard let localPath = localURL?.path else {
            fatalError()
        }

        let kmlDirPath = localPath.stringByReplacingOccurrencesOfString(".kmz", withString: "")
        self.basePath = kmlDirPath

        if NSFileManager.defaultManager().fileExistsAtPath(kmlDirPath) {
            try! NSFileManager.defaultManager().removeItemAtPath(kmlDirPath)
        }

        // KMZ ファイルを展開
        if SSZipArchive.unzipFileAtPath(localPath, toDestination: kmlDirPath) {
            let kmlPath = kmlDirPath.stringByAppendingString("/doc.kml")
            do {
                try self.parseKML(kmlPath)
            } catch let error {
                result = error
            }
        } else {
            // URL が間違っているとここに来ることがある
            result = Error.DownloadFailed
        }
    }
}

最後に Ji という XML パーサで KML ファイルをパースし、地点情報を取得します。以下はDocumentノードのパース例です。

// Document.swift

struct Document {
    let name: String
    let description: String
    let folders: [Folder]
    let styles: [String: Style]
}

extension Document {
    static func fromJiNode(node: JiNode) -> Document {
        var name = ""
        var description = ""
        var folders: [Folder] = []
        var styles: [String: Style] = [:]
        for childNode in node.children {
            guard let childNodeName = childNode.name?.lowercaseString else {
                continue
            }

            switch (childNodeName) {
            case "name":
                name = childNode.content ?? ""
            case "description":
                description = childNode.content ?? ""
            case "folder":
                folders.append(Folder.fromJiNode(childNode))
            case "style":
                let style = Style.fromJiNode(childNode)
                styles[style.id] = style
            default:
                break
            }
        }

        return Document(
            name: name,
            description: description,
            folders: folders,
            styles: styles
        )
    }
}

地点の表示とマーカー

Placemarkノードに地点名や緯度経度が入っています。 ここからMKPointAnnotation等を生成し、MKMapView#addAnnotationすれば、地図上に地点が表示されるはずです。

なおマイマップには各地点のマーカーのスタイルを編集することができ、その情報はStyleノードに収められています。各PlacemarkノードのstyleUrlにその参照名が入っているので、そのスタイルを適用します。

// MapViewController.swift

func mapView(mapView: MKMapView, viewForAnnotation annotation: MKAnnotation) -> MKAnnotationView? {
    // 自分の現在位置を表すアノテーションは除く
    if annotation === mapView.userLocation {
        return nil
    }
    guard let placemarkAnnotation = annotation as? PlacemarkAnnotation else {
        fatalError()
    }
    let styleID = placemarkAnnotation.styleID

    let identifier = "Pin"
    let annotationView =
        mapView.dequeueReusableAnnotationViewWithIdentifier(identifier)
            ?? MKAnnotationView(annotation: annotation, reuseIdentifier: identifier)
    annotationView.annotation = annotation

    let defaultIconImage = UIImage(named: "pin")
    if let iconURL = DocumentDataSource.shared.iconURL(for: styleID) {
        self.imageDownloader.downloadImage(URLRequest: NSURLRequest(URL: iconURL)) { (response) in
            switch response.result {
            case .Success(var image):
                if let color = DocumentDataSource.shared.iconColor(for: styleID) {
                    image = image.filteringColor(color)
                }
                annotationView.image = image
            case .Failure(_):
                annotationView.image = defaultIconImage
            }
        }
    } else {
        annotationView.image = defaultIconImage
    }

    // アノテーションをタップしたら「吹き出し」を表示
    // annotation の title と subtitle、rightCalloutAccessoryView が表示される 
    annotationView.canShowCallout = true
    annotationView.rightCalloutAccessoryView = UIButton(type: .DetailDisclosure)

    return annotationView
}

地点の詳細情報

Placemarkノードのdescriptionには HTML でフォーマットされた地点の詳細情報が入っています。以下のようにすれば、UITextViewに表示させることができます。

// PlaceDetailViewController.swift

if
    let descriptionTextData = placemark.descriptionText.dataUsingEncoding(NSUnicodeStringEncoding),
    let attributedDescriptionText = try? NSAttributedString(
        data: descriptionTextData,
        options: [NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType],
        documentAttributes: nil
    )
{
    self.descriptionTextView.attributedText = attributedDescriptionText
}

なお添付画像の URL はdescription内にimgタグとして含まれているので、これを抽出しサニタイズするとより良い見せ方ができると思います。

おわりに

このサンプルアプリは元々、弊社が表参道に移転した際に有志で作成していた「表参道ランチマップ」をアプリでも使えるようにしたいという要望から作られました。KML の URL を取得してしまえば好きなマイマップを表示できますので、色々な使い方ができるのではないかと思います。

Google マイマップの編集機能を CMS として考え、「サーバレス」でカスタムの地図アプリが実現できるところもユニークだと思いますので、様々なサービスに活用してもらえれば幸いです!