Lightning コンポーネント開発 TIPS: Google マップを表示する方法

今回は Lightning コンポーネントで Google Map を利用する方法です。
Google Map を利用する場合、Google Map　のライブラリをリモートで読み込む必要があります。
しかし、 Lightning コンポーネントでは静的リソース以外からの JavaScript の読み込みを禁止しています。
ではどのように実現できるか？というお話です。

## Visualforce ページ in Lightning コンポーネント
ご存知 Visualforce ページは、外部のライブラリの読み込みを許可しているので Google Map の読み込みを行えます。
つまり、Google Map を表示した Visualforce ページを iframe で Lightning コンポーネントに埋め込んでしまえば良いわけです。
そして、大抵の場合はこれだけで Google マップの表示に関する問題は解決します。

しかし、

##postMessage() による複雑なマップの描画
より複雑なマップの描画が必要になった場合、Lightning コンポーネントと Visualforce ページの間で情報を直接受け渡したい事があります。例えば、Lightning コンポーネントで受け取ったアプリケーションイベントの情報をマップを反映させたい場合などです。
JavaScript の window オブジェクトが持つ postMessage という関数を使って、 Lightning コンポーネントと Visualforce ページの間でメッセージの受け渡しが行なえます。
(window.postMessage を使ったメッセージの引き渡しには、送り先のドメインを指定しないといけないので予め取得しておく必要があります)

 
## サンプルコード
レコードの詳細ページにて、表示中の取引先レコード(コンテキストレコード)の地理情報を取得しマップ上に表示する Visualforce ページと Lighting バンドルのサンプルコードです。

![Screen_Shot_2018-08-10_at_17_46_59-2.png](https://qiita-image-store.s3.amazonaws.com/0/26378/581e376a-34f5-b5b2-f98f-890d7df09470.png)

このサンプルでは、Lightning コンポーネントと Visualforce ページのドメイン名を固定していますが、要件に合わせてカスタム設定から取得するなどの手法も検討してください。

```html:GoogleMap.vfp
<apex:page showHeader="false" sidebar="false" standardStylesheets="false">
    <meta name="viewport" content="initial-scale=1.0" />
    <meta charset="utf-8" />
    
    <div id="map"></div>   

    <script>
    const lexHost = "iamhrk623-dev-ed.lightning.force.com";
    let map;    
   
    (function() {
        
        // ページのロードが完了したら、LEX から送信される postMesssage に対するイベントリスナを登録する。
        // メッセージが LC からのものである事を確認した上で、type により処理を振り分ける。
        window.addEventListener("message", function(event) {
            if (event.origin === "https://" + lexHost) {
                if (event.data.type === "INIT") {
                    onInit(event.data.center, event.data.zoom);
                } 
            }
        }, false);
    })()
    function onScriptLoaded() {
        
        // スクリプトのロードが完了し、Google Map が利用可能になったことを LC に伝える
        postMessage({type: "READY"});
    } 
    function onInit(center, zoom) {
        
        // LC から引き渡された初期化用のパラメータを元に、Google Map とマーカーを初期化し表示する。
        map = new google.maps.Map(document.getElementById("map"), {
            center: center,
            zoom: zoom
        });
        const marker = new google.maps.Marker({
            position: center
        });
        marker.setMap(map);
    } 
    function postMessage(data) {
        parent.postMessage(data, "https://" + lexHost);
    }
    </script>
    <script src="https://maps.googleapis.com/maps/api/js?key=(YOUR API KEY)&callback=onScriptLoaded" />
    <style>
        #map {
        height: 100%;
        }
        html, body {
        height: 100%;
        margin: 0;
        padding: 0;
        }
    </style>
</apex:page>
```

```html:GoogleMap.cmp
<aura:component access="global" controller="GoogleMapController" implements="flexipage:availableForRecordHome,force:hasRecordId" >
    
    <!-- Public Attribute -->
    <aura:attribute access="public" type="String" name="recordId" />
    <aura:attribute access="public" type="String" name="vfHost" default="iamhrk623-dev-ed--c.visualforce.com" />

    <!-- Event Handler -->
    <aura:handler name="init"   value="{!this}"     action="{!c.onInit}"    />

    <!-- User Interface -->
    <div>
        <iframe aura:id="iframe" style="height: 500px;" frameborder="0" width="100%" src="/apex/GoogleMap" />
    </div>
</aura:component>
```

```js:GoogleMapController.js
({
    onInit: function(c, e, h) {

        // VF ページから送信される postMesssage に対するイベントリスナを登録する。
        // メッセージが VFページからのものである事を確認した上で、type により処理を振り分ける。
        window.addEventListener("message", $A.getCallback(function(event) {
            if (event.origin === "https://" + c.get("v.vfHost")) {
                if (event.data.type === "READY") {
                    h.onReady(c, h);
                }
            }
        }), false);
    },
})
```

```js:GoogleMapHelper.js
({
    onReady : function(c, h) {

        // 現在のコンテキストレコードを取得し、
        // そのレコードの緯度経度を初期化用のパラメータとして VF ページに引き渡す。
        h.getRecord(c, h, c.get("v.recordId"))
        .then($A.getCallback(function(record) {
            h.postMessage(c, h, {
                type: "INIT", 
                center: {lat: record.Geolocation__c.latitude, lng: record.Geolocation__c.longitude},
                zoom: 14
            });
        }))
        .catch($A.getCallback(function(reason) {
            console.error("GoogleMapHelper.onReady: " + reason);
        }));
    },
    postMessage : function(c, h, data) {
        const contentWindow = c.find("iframe").getElement().contentWindow;
        contentWindow.postMessage(data, "https://" + c.get("v.vfHost"));
    },
    getRecord : function(c, h, recordId) {
        var action = c.get('c.getRecord');
        action.setParams({
            recordId : recordId,
        });
        return new Promise(function (resolve, reject) {
            action.setCallback(this, function(response) {
                const ret = JSON.parse(response.getReturnValue());
                if (response.getState() === 'SUCCESS') ret.hasError ? reject(ret.message) : resolve(ret);
                else if (response.getState() === 'ERROR') reject(response.getError());
            });
            $A.enqueueAction(action);
        });
    },
})
```

```js:GoogleMapController.apxc
public class GoogleMapController {
    public class GoogleMapControllerException extends Exception {}
    public class Error {
        @AuraEnabled public String message;
        @AuraEnabled public Boolean hasError = true;
        public Error(String message){
            this.message = message;
        }
    } 
    
    @AuraEnabled 
    public static String getRecord(String recordId) {
        try {
            List<Account> records = [SELECT ID, Geolocation__c FROM Account WHERE Id = :recordId];
            if (records.isEmpty()) throw new GoogleMapControllerException('GoogleMapController.getRecord: No Record Found');
            return JSON.serialize(records[0]);
        } catch (Exception e) {
            return JSON.serialize(new Error(e.getMessage()));
        }
    }  
}
```