WebGLOverlayView
で遊んでみた
一体何をするのか
WebGLOverlayView
とは、先日のGoogle I/O 2021のウェブ プラットフォームの最新情報のセッション内で紹介された、Google MapをWebGLな3Dビューで操作できるようになった(※今のところbetaのみ)やつ。
まずはWebGLOverlayView
コンポーネントを作る
WebGLOverlayView
さんは、他のMaps JavaScript API
の例に漏れずclass継承型な取り回しで、これを生のまま使うのも今っぽくないので、コンポーネント志向なフレームワークを使ってコンポーネント化してみる。
つかうフレームワークはReact
にした。
選定理由は「なんとなく」。個人的にはReact
/Vue
どっちでも使えるし、自分しか使わんならぶっちゃけどっちでもいい1ので、直近で触ったのVue
だったし、React
にしてみた。
コンポーネントのインターフェースを決める
「このコンポーネントは何を入力として受け取り、何を出力するのか、そして何をしないのか」を決める。
入力(props
)
-
google.maps.Map
のmapOptions
→MAP ID、どの座標(経度、緯度)をどういうふうに(ズーム、視点の方位/仰角、etc.)表示するかを指定するもので、このオプションを固定にしてしまうのはコンポーネント設計上拡張性低すぎであまりにもイケてないというかダメなのでprops
で外から渡す(表示する側のコンポーネントに決めさせる)-
Maps JavaScript API
のMAP ID
→Googleから1マップごとに1意に発行される(べき)ID。mapOptions
に含まれる形で受け取る
-
- 検索文字列
→地図の表示座標・表示のさせ方は自由にできるのに検索文字列は固定とか、ちょっと意味わからないので、ふつうに外からprops
で受け取る。
出力(JSX)
- マップ
- if
Maps JavaScript API
のスクリプトのロードが完了していない- then 非表示
- else if MAP IDもしくはAPIキーがない
- then 非表示
- else (= スクリプトのロードが完了、MAP IDおよびAPIキーあり)
- then 表示
- if
このコンポーネントではしないこと
-
Maps JavaScript API
のスクリプトのロード
→この件の本質的性質として1コンポーネントが負うべき職責ではない(共通機能として実装すべき) - 描画済みのマップに対して、検索クエリを受け取って検索処理を発火、結果出力としてマップにピンを刺す
→これを共通機能化すべきかについては、事前の要件事情("現在"共通化が必要か。今はなくとも将来必要となりうる公算・期待値はどのくらいか)によるため一概には言えなく、そしてちなみにこの件では要件全てが私自身のさじ加減なので「別にしなくてもいい」という判断も可能。でも、分けといたほうがクールなので分けるよ!(ref: 関心の分離) -
Maps JavaScript API
のAPIキーの受け取り
→Googleからアカウントに対して認証キーとしてに発行されるクレデンシャル(認証情報)のため、当該コンポーネント内にハードコーディングするのが不適切なのはもちろん、この値が必要なのはMaps JavaScript API
のスクリプトのロード時なので、前述で共通機能化することとしたローダー側に任せればよく、そもそもこの値について関知する必要がないため受け取らない
つまり、このコンポーネントでやること
- 親コンポーネントから受け取った
mapOptions
をつかって、Maps JavaScript API
のスクリプトのローダーによって使用可能となっているはずであるMaps JavaScript API
からマップを表示する - 併せて、同じく親コンポーネントから受け取った検索文字列をつかって、マップ上の検索結果の座標にピンを刺す
- ついでに、表示されたマップでthree.jsを使って視点移動なアニメーションをさせてみる (きゅうに あたらしいようけんが あらわれた!!)
→まぁこれはゆーて、Google I/O 2021のデモでもやってたやつそのまんまなんですけどね。
実際に作ってみる
props
の型は、(<div>
を返すつもりなので)React.HTMLAttributes<HTMLDivElement>
を継承2しつつ、前述のmapOptions
やsearchQuery
を加えて、こんな感じ↓↓
/**
* props for WebglOverlayView
*/
interface Props extends React.HTMLAttributes<HTMLDivElement> {
mapOptions: google.maps.MapOptions
searchQuery?: string
}
で、本体はこのProps
をprops
とするFunctionComponent
として、こんな感じ↓↓
const Component: React.FunctionComponent<Props> = ({
mapOptions,
searchQuery = '',
...props
}) => {
...
return (
<MapWrapper
ref={mapDiv}
{...props}
/>
)
}
ちな、return
してる<MapWrapper>
くんは、<div>
なstyled component
で、こんな感じの定義↓↓
const MapWrapper = styled.div`
width: 800px;
height: 600px;
pointer-events: none;
`
大きさの定義と、ポインターイベント(マウスとかのやつ)を抑止してる(いらないから)ってだけのやつ。大意はない。
で、Map
クラス(google.maps.Map
)のコンストラクタには、マップ表示先の要素のDOMインスタンスを渡す必要があるのでRef
を使ってDOMの参照を引き出して渡す。
Ref
を取り出してるところ↓↓
const mapDiv = useRef<HTMLDivElement>(null)
...
return (
<MapWrapper
ref={mapDiv}
Ref
から取り出したDOMの参照を使ってgoogle.maps.Map
を new
しているところ↓↓
// Create a map instance.
const map = new google.maps.Map(mapDiv.current, mapOptions)
ちなみに、このgoogle.maps.Map
さんもタダでは使えない半分、文字通り意味でもというか、Maps JavaScript API
のスクリプトをロードしなければならないので、
const { loaderStatus: mapApiLoaderStatus } = useMapJsLoader()
という感じでカスタムフック化してそちらにおまかせをしている感じ。3
ロードが完了していると返り値であるloaderStatus
(ここではmapApiLoaderStatus
にリネームしている)がGoogleMapsApiLoaderStatus.LOADED
になるようにしている。(ちなこのGoogleMapsApiLoaderStatus
列挙子も自作したやつなのでググっても出てきません><)
そして、このmapApiLoaderStatus
がちゃんとLOADED
になってるか、とさっきのマップ挿入先のRef
がちゃんとDOMインスタンスを参照する準備ができてるか、を確認できてからMap
クラスをnew
してる。
if (mapApiLoaderStatus === GoogleMapsApiLoaderStatus.LOADED && mapDiv.current != null) {
// Create a map instance.
const map = new google.maps.Map(mapDiv.current, mapOptions)
そしてそれを使って(今回の本題である)google.maps.WebglOverlayView
インスタンスを生成、map
に適用する。
// Create a WebGL Overlay View instance.
const webglOverlay = new google.maps.WebglOverlayView()
// Add the overlay to the map.
webglOverlay.setMap(map)
そしてこれらは、スクリプトのロードやDOM生成待ちをする必要がある(またSSR的観点からは、クライアント描画案件だから)などの理由で、そのためのフック(副作用フック
)のhooks API
であるuseEffect()
経由で実行している。
以下、この辺周り全体のuseEffect()
処理↓↓
// prepare map & webglOverlayView
useEffect(() => {
if (mapApiLoaderStatus === GoogleMapsApiLoaderStatus.LOADED && mapDiv.current != null) {
// Create a map instance.
const map = new google.maps.Map(mapDiv.current, mapOptions)
// Create a WebGL Overlay View instance.
const webglOverlay = new google.maps.WebglOverlayView()
// Add the overlay to the map.
webglOverlay.setMap(map)
// set to component state
setMap(map)
setWebglOverlayView(webglOverlay)
}
}, [mapOptions, mapApiLoaderStatus, mapDiv, setMap, setWebglOverlayView])
上記の最後のsetXxx(aaa))
な処理は、ReactComponennt
の状態変数を宣言・定義するhooks API
であるsetState()
で作った状態変数のsetter
。
以下、宣言・定義箇所↓↓
const [map, setMap] = useState<google.maps.Map>()
const [webglOverlayView, setWebglOverlayView] = useState<google.maps.WebglOverlayView>()
あとは、別途に副作用系カスタムフックとして定義した「3D描画処理(via three.js)」と「検索結果の座標にピンを打つ処理」を呼び出して、おしまい。
// draw
useDrawer(map, webglOverlayView, mapOptions)
// use search feature
usePlaceService(searchQuery, map)
Storybookで確認してみる
表示するページをいちいち作らないとコンポーネントの表示・動作確認ができないのはダルいので、Storybookを使ってサクッと確認できる(なんなら確認しながら作れる)環境を作成しておいた。
早速使って確認してみる。
うん、おk。
WebGLOverlayView
を実際にページに埋め込んでみる(ページを作る)
コンポーネントのインターフェースを決める
入力(props
)
- 特になし。(いらねーもん、だって)
出力(JSX)
- タイトル: なんかもう適当に"WebglOverlayView"とかでいいや。
- 入力部
- MAP ID入力欄
- センシティブ情報なので
type="password"
。 - タブで次のコントロールへ移動、エンターキーで入力確定。
- 入力確定したら再入力不可(
disabled
をセットする; 理由後述)
- センシティブ情報なので
- APIキー入力欄
-センシティブ情報なのでtype="password"
。- タブで次のコントロールへ移動、エンターキーで入力確定。
- 入力確定したら再入力不可(
disabled
をセットする; 理由後述)
- 入力確定ボタン
- ボタンのラベル(表記)は「表示」とかでいいかな。
- MAP ID入力欄
- マップ表示部
- 先ほど作成した、
<WebglOverlayView>
コンポーネントを使う- MAP IDの入力が確定されるまでは、非表示でいいと思う
-
<WebglOverlayView>
でマップを表示するための、Maps JavaScript API
のスクリプトをロードしてくれるパーツを設置
→本来このようなパーツは、page
コンポーネント(i.e. 各ページ)に実装するのではなく、<App>
コンポーネント(page
の更に上位(最上位)にある「(ページ間遷移etc.も含んだ)Reactアプリケーション全体」を統括・定義するコンポーネント)とかに配置すべきもの。なぜならMaps JavaScript API
のスクリプトは1回だけロードすればよく(別APIキーによる別途ロードはエラーになるはず)、APIキーも通常はアプリケーション固有値として一意定義されてるはずだから。ただ今回はAPIキーをテキスト入力するようにしたかったので、そのテキスト入力の管理粒度であるこのコンポーネントでスクリーンショットも行うことにした、という経緯。なおこれに伴い再入力を許可できなくなったため、一度入力を確定したら再入力不可にしなければいけなくなりましたとさ。
- 先ほど作成した、
このコンポーネントではしないこと
-
<WebglOverlayView>
がやってくれること- 地図の表示
- 「
Maps JavaScript API
のスクリプトのロードしてくれる さん」のロード完了を検知して地図を表示する
- 「
- 地図の表示
-
Maps JavaScript API
のスクリプトのロード- このひとは、MAP IDの入力を受け付けて、ロードしてくれるひとに渡して「ロードよろ」するだけ。このひと自身がロードするわけではない。
- (マップにピンを刺すためのデータを取得するための)検索文字列の入力
- 今回はもう固定値でいいかなって。
→ちな、ラーメン
にしたよ!!
(Q: なぜ「ラーメン」なのですか? A: ラーメンおいしいよねー(※答えてない))
- 今回はもう固定値でいいかなって。
つまり、このコンポーネントでやること
- MAP IDとかAPIキーの入力を受け付ける。(一度入力確定したら再入力できないようにする)
- 入力確定云々とか入力値管理(状態変数としての保持etc.)も含む
-
Maps JavaScript API
のスクリプトのローダーに、入力されたAPIキー渡してロードを依頼する- 「ロードそのもの」はやらない
-
<WebglOverlayView>
を設置する- 入力されたMAP IDを渡してマップを表示してもらう
- MAP IDの入力が確定するまで非表示とする
実際に作ってみる
まずマップ表示部(およびスクリプト読み込み部)。
...
<MapBody>
{mapId.length > 0 && apiKey.length > 0 ? (
<MapJsLoaderProvider apiKey={apiKey}>
<WebglOverlayView
mapOptions={mapOptions}
searchQuery='ラーメン'
/>
</MapJsLoaderProvider>
) : undefined}
</MapBody>
...
構成しているのは、マップである<WebglOverlayView>
とMaps JavaScript API
のスクリプトをロードしてくれる<MapJsLoaderProvider>
(ContextProvider
にしてみました)で、
それぞれMAP ID、APIキーが入力されるまではまだいらない子なので、(三項演算子4で)mapId.length > 0 && apiKey.length > 0
がtrue
になるまではundefined
5で表示されないようにしてる。
ところで、おわかりいただけただろうか...
_人人人人人人人人人人人人人人人人人人人人_
> 'ラーメン'(※ハードコーディング) <
 ̄Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y ̄
バリカタである。(?)
次に入力部。
<MapHeader onSubmit={handleFormSubmit}>
mapId: <MapIdInput type='password' name='mapId' placeholder='input your Map ID here' value={mapIdInput} disabled={mapId.length > 0} onChange={handleMapIdInputChange} />
apiKey: <ApiKeyInput type='password' name='apiKey' placeholder='input your API_KEY here' value={apiKeyInput} disabled={apiKey.length > 0} onChange={handleApiKeyInputChange} />
<button disabled={mapIdInput.length === 0 || apiKeyInput.length === 0 || isFormSubmitted}>表示</button>
</MapHeader>
<MapHeader>
くんは<form>
なstyled component
、そして同じく<MapIdInput>
くんと<ApiKeyInput>
は<input>
。
タブ移動だのエンターで確定だのをサクッと実現するには<form>
のsubmit
イベントが便利なのでそうしました。
で、この辺の入力管理周りが以下↓↓
const [mapId, setMapId] = useState<string>('')
const [mapIdInput, setMapIdInput] = useState<string>(mapId)
const [apiKey, setApiKey] = useState<string>('')
const [apiKeyInput, setApiKeyInput] = useState<string>(apiKey)
const [isFormSubmitted, setIsFormSubmitted] = useState<boolean>(false)
...
const handleMapIdInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const { currentTarget: { value: mapIdInput } } = e
if (mapIdInput.length > 0) {
setMapIdInput(mapIdInput)
}
}, [setMapIdInput])
const handleApiKeyInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const { currentTarget: { value: apiKeyInput } } = e
if (apiKeyInput.length > 0) {
setApiKeyInput(apiKeyInput)
}
}, [setApiKeyInput])
const handleFormSubmit = useCallback((e: React.FormEvent) => {
e.preventDefault()
if (mapIdInput.length > 0 && apiKeyInput.length > 0) {
setMapId(mapIdInput)
setApiKey(apiKeyInput)
setIsFormSubmitted(true)
}
}, [mapIdInput, apiKeyInput, setMapId, setApiKey, setIsFormSubmitted])
まず状態変数を上から順に
-
mapId
(/setMapId
): MAP ID(確定値) -
mapIdInput
(/setMapIdInput
): MAP ID(入力値) -
apiKey
(/setApiKey
): APIキー(確定値) -
apiKeyInput
(/setApiKeyInput
): APIキー(入力値) -
isFormSubmitted
(/setIsFormSubmitted
):<form>
のsubmit
が発火したかどうかフラグ
で、「入力値」と「確定値」をぞれぞれわざわざ分けてるのは
-
入力値:
<input>
のchange
イベントで毎回変更。何か入力されるたび(≒1文字づつ)値が変わる。 -
確定値: 確定(
<form>
がsubmit
)されるまでは''
(空文字)、確定されたときに「入力値」が反映される。(マップたちの表示/非表示管理や、Maps JavaScript API
スクリプトのロード管理に便利!!)
という感じ。
最後は、MAP IDはMap
に渡すときにmapOption
のプロパティとして渡さないといけないので、
/**
* default options for `WebglOverlayView`
*/
const mapOptionsDefault: Omit<google.maps.MapOptions, 'mapId'> = {
tilt: 0,
heading: 0,
zoom: 18,
center: { lat: 33.590188, lng: 130.420685 },
// disable interactions due to animation loop and moveCamera
disableDefaultUI: true,
gestureHandling: 'none',
keyboardShortcuts: false
} as const
interface Props {}
const Component: React.FunctionComponent<Props> = (props) => {
...
const mapOptions = useMemo(() => ({
...mapOptionsDefault,
mapId
}), [mapId])
こんな感じでコンポーネントの外側に定数値としてgoogle.maps.MapOptions
のデフォルト値を定義し、コンポーネント内でスプレッド構文を使ってマージしている。
なお、mapOptionsDefault
のcenter
は地図の中心に持ってくる経緯度で、この座標は地元福岡の主要駅である「博多駅」を指している。(そして、検索キーワードは「ラーメン」、つまり...)
ちなここで使っているuseMemo()
は、いわゆるメモ化(memoization)を行うhooks API
で、メモ化要素を省けば
const mapOptions = {
...mapOptionsDefault,
mapId
}
と等価。
動作確認
うん、できた(๑•̀ㅂ•́)و✧
【飯テロ】博多駅周辺のラーメン屋さんの画像(※今回の実装とはあまり関係がありません←)
最後に、博多駅周辺のラーメン屋さんの画像といっしょにおわかれをしたいと思います。(テレビ番組EDあいさつ風)
この記事を読んでいただき、ありがとうございました。
【博多らーめんShin-Shin】博多Shin-Shinらーめん
ほんとの最後に
全ソースはこちら↓↓
toshi00ysm/play-with-webgloverlayview - GitHub
-
ちなみに、チーム開発における個人的な「React/Vueどっち使う?」問題での選定基準は、「チーム内エンジニアの実力による。」で、あまり難易度上げるとキツイかなって布陣の場合は、「一応使える」ようになるまでな初期の学習曲線が比較的緩やか(日本語doc/サンプルコードの充実度も含む)なVue、「必要なものがないなら、もうそれ自作すればええやん」ができるガチ勢猛者揃いなら、深みに潜っても(必要以上にゴチャらず)相応に実装/読解難易度上がるだけで済むReact、を勧めるようにしている。(「選ぶ/決める」ではなく「勧める」なのは、どっちでも使える自分が決めてそれを押し付けるのではなく、実際に困るであろう本人たちで選んだ上で納得して責任を持って使ってもらうため。) ↩
-
Props
型をReact.HTMLAttributes<HTMLDivElement>
から継承させてるのは、id
とかstyle
とかclassName
とかdata-*
みたいな属性プロパティを、コンポーネントの標準機能としてちゃんとこの子でも使えるようにしときたいけどいちいち全部書くのダルいし、てか継承すればよくね?って考えから。(多分使い方としてもあってる気がする) ↩ -
この
Maps JavaScript API
のスクリプトのロードをカスタムフック化した件は、これだけで1本記事が書けちゃう内容な気がするので割愛。もしかしたら後日ワンチャン別途記事書くかも。 ↩ -
三項演算子の使用は「(時として)コードの可読性を下げる」などの理由から一部コーディング規則で禁止されている場合があります。お近くのコーディング規約・規則をご確認の上、それに則った実装を行うようにしましょう。 ↩
-
JSX書くときの「こっからここまでは(これこれの条件のとき)表示しない」ってとき、ReactにTypeScriptない頃からの個人的慣習で
undefined
使ってんだけど、ReactComponentの描画ロジックの返り値の型がReactElement<any, any> | null
←こうなってる(つまり少なくとも「コンポーネントとして」何も表示しないよってときは「null
を返す」が正ってこと)ってことは、部分JSXとして「ここは何も表示させないだよ」するときもこれに合わせてnull
にしたほうがいいのだろうか?まぁ文法上(よほど几帳面に書かない限り)部分JSXとしてundefined
(を含むあらゆるfalsy値)が来ることはままあるし、それを絶対null
統一に仕様変更とかReactエコシステム爆破レベルの大リスクになる気がするから、「気にしなければ別に」な案件な気もするけれど。 ↩