3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

`WebGLOverlayView`で遊んでみた

Last updated at Posted at 2021-06-28

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.MapmapOptions
    →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 表示

このコンポーネントではしないこと

  • 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しつつ、前述のmapOptionssearchQueryを加えて、こんな感じ↓↓

/**
 * props for WebglOverlayView
 */
interface Props extends React.HTMLAttributes<HTMLDivElement> {
  mapOptions: google.maps.MapOptions
  searchQuery?: string
}

で、本体はこのPropspropsとする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.Mapnewしているところ↓↓

      // 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を使ってサクッと確認できる(なんなら確認しながら作れる)環境を作成しておいた。
早速使って確認してみる。

スクリーンショット - WebglOverlayView - storybook.png

うん、おk。

WebGLOverlayViewを実際にページに埋め込んでみる(ページを作る)

コンポーネントのインターフェースを決める

入力(props

  • 特になし。(いらねーもん、だって)

出力(JSX)

  • タイトル: なんかもう適当に"WebglOverlayView"とかでいいや。
  • 入力部
    • MAP ID入力欄
      • センシティブ情報なのでtype="password"
      • タブで次のコントロールへ移動、エンターキーで入力確定。
      • 入力確定したら再入力不可(disabledをセットする; 理由後述)
    • APIキー入力欄
      -センシティブ情報なのでtype="password"
      • タブで次のコントロールへ移動、エンターキーで入力確定。
      • 入力確定したら再入力不可(disabledをセットする; 理由後述)
    • 入力確定ボタン
    • ボタンのラベル(表記)は「表示」とかでいいかな。
  • マップ表示部
    • 先ほど作成した、<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 > 0trueになるまではundefined5で表示されないようにしてる。

ところで、おわかりいただけただろうか...

_人人人人人人人人人人人人人人人人人人人人_
> 'ラーメン'(※ハードコーディング) <
 ̄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のデフォルト値を定義し、コンポーネント内でスプレッド構文を使ってマージしている。

なお、mapOptionsDefaultcenterは地図の中心に持ってくる経緯度で、この座標は地元福岡の主要駅である「博多駅」を指している。(そして、検索キーワードは「ラーメン」、つまり...)

ちなここで使っているuseMemo()は、いわゆるメモ化(memoization)を行うhooks APIで、メモ化要素を省けば

  const mapOptions = {
    ...mapOptionsDefault,
    mapId
  }

と等価。

動作確認

スクリーンショット - WebglOverlayView.png

うん、できた(๑•̀ㅂ•́)و✧

【飯テロ】博多駅周辺のラーメン屋さんの画像(※今回の実装とはあまり関係がありません←)

最後に、博多駅周辺のラーメン屋さんの画像といっしょにおわかれをしたいと思います。(テレビ番組EDあいさつ風)
この記事を読んでいただき、ありがとうございました。

【一風堂】白丸元味
【一風堂】白丸元味.png

【一風堂】赤丸新味
【一風堂】赤丸新味.png

【一蘭】天然とんこつラーメン
【一蘭】天然とんこつラーメン.png

【一幸舎】ラーメン
【一幸舎】ラーメン.jpg

【博多らーめんShin-Shin】博多Shin-Shinらーめん
【博多らーめんShin-Shin】博多Shin-Shinらーめん.jpg

【博多担々麺とり田】担々麺
【博多担々麺とり田】担々麺.jpg

【元祖博多だるま】ラーメン
【元祖博多だるま】ラーメン.jpg

【長浜ナンバーワン】長浜ラーメン
【長浜ナンバーワン】長浜ラーメン.png

【元祖博多中洲屋台ラーメン一竜】とんこつラーメン
【元祖博多中洲屋台ラーメン一竜】とんこつラーメン.jpg

ほんとの最後に

全ソースはこちら↓↓
toshi00ysm/play-with-webgloverlayview - GitHub

  1. ちなみに、チーム開発における個人的な「React/Vueどっち使う?」問題での選定基準は、「チーム内エンジニアの実力による。」で、あまり難易度上げるとキツイかなって布陣の場合は、「一応使える」ようになるまでな初期の学習曲線が比較的緩やか(日本語doc/サンプルコードの充実度も含む)なVue、「必要なものがないなら、もうそれ自作すればええやん」ができるガチ勢猛者揃いなら、深みに潜っても(必要以上にゴチャらず)相応に実装/読解難易度上がるだけで済むReact、を勧めるようにしている。(「選ぶ/決める」ではなく「勧める」なのは、どっちでも使える自分が決めてそれを押し付けるのではなく、実際に困るであろう本人たちで選んだ上で納得して責任を持って使ってもらうため。)

  2. Props型をReact.HTMLAttributes<HTMLDivElement>から継承させてるのは、idとかstyleとかclassNameとかdata-*みたいな属性プロパティを、コンポーネントの標準機能としてちゃんとこの子でも使えるようにしときたいけどいちいち全部書くのダルいし、てか継承すればよくね?って考えから。(多分使い方としてもあってる気がする)

  3. このMaps JavaScript APIのスクリプトのロードをカスタムフック化した件は、これだけで1本記事が書けちゃう内容な気がするので割愛。もしかしたら後日ワンチャン別途記事書くかも。

  4. 三項演算子の使用は「(時として)コードの可読性を下げる」などの理由から一部コーディング規則で禁止されている場合があります。お近くのコーディング規約・規則をご確認の上、それに則った実装を行うようにしましょう。

  5. JSX書くときの「こっからここまでは(これこれの条件のとき)表示しない」ってとき、ReactにTypeScriptない頃からの個人的慣習でundefined使ってんだけど、ReactComponentの描画ロジックの返り値の型がReactElement<any, any> | null←こうなってる(つまり少なくとも「コンポーネントとして」何も表示しないよってときは「nullを返す」が正ってこと)ってことは、部分JSXとして「ここは何も表示させないだよ」するときもこれに合わせてnullにしたほうがいいのだろうか?まぁ文法上(よほど几帳面に書かない限り)部分JSXとしてundefined(を含むあらゆるfalsy値)が来ることはままあるし、それを絶対null統一に仕様変更とかReactエコシステム爆破レベルの大リスクになる気がするから、「気にしなければ別に」な案件な気もするけれど。

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?